R50 通知推送:dispatcher 投递服务、业务事件接入与员工推送开关

这个提交包含在:
2026-04-05 11:21:39 +08:00
父节点 ff0a677bf3
当前提交 47e10e6acc
共修改 24 个文件,包含 1701 行新增和 197 行删除
+114
查看文件
@@ -19,6 +19,13 @@ const LIST_QUERY_SCHEMA = z.object({
pageSize: z.coerce.number().int().min(1).max(100).optional(),
unreadOnly: z.enum(['0', '1', 'true', 'false']).optional(),
});
const DELIVERY_STATUS_VALUES = Object.freeze(['pending', 'success', 'failed']);
const DELIVERY_QUERY_SCHEMA = z.object({
notificationId: z.string().uuid().optional(),
channelId: z.string().uuid().optional(),
status: z.enum(DELIVERY_STATUS_VALUES).optional(),
limit: z.coerce.number().int().min(1).max(200).optional(),
});
const CHANNEL_ID_SCHEMA = z.string().uuid();
const ROLE_CODE_VALUES = Object.freeze(Object.keys(ROLE_PERMISSIONS));
@@ -141,6 +148,20 @@ function parseListQuery(query) {
};
}
function parseDeliveryQuery(query) {
const parsed = DELIVERY_QUERY_SCHEMA.safeParse(query ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
return {
notificationId: parsed.data.notificationId ?? null,
channelId: parsed.data.channelId ?? null,
status: parsed.data.status ?? null,
limit: parsed.data.limit ?? 200,
};
}
function parseNotificationId(value) {
const parsed = CHANNEL_ID_SCHEMA.safeParse(value);
if (!parsed.success) {
@@ -249,6 +270,54 @@ function mapNotificationChannel(record, options = {}) {
};
}
function mapNotificationDeliverySummary(record) {
return {
notificationId: record.notification_id,
channelId: record.channel_id,
channelName: record.channel?.name ?? '-',
channelType: record.channel?.channel_type ?? 'wecom',
notificationType: record.notification?.type ?? '',
notificationTitle: record.notification?.title ?? '',
status: record.status,
attemptCount: 1,
latestAttemptAt: record.attempted_at?.toISOString?.() ?? null,
resultCode: record.result_code ?? null,
errorMessage: record.error_message ?? null,
responseBody: record.response_body ?? null,
notificationCreatedAt: record.notification?.created_at?.toISOString?.() ?? null,
};
}
function summarizeDeliveryAttempts(items, query) {
const summaries = [];
const summaryMap = new Map();
const skippedKeys = new Set();
for (const item of items) {
const key = `${item.notification_id}:${item.channel_id}`;
if (skippedKeys.has(key)) {
continue;
}
const current = summaryMap.get(key);
if (!current) {
if (query.status && item.status !== query.status) {
skippedKeys.add(key);
continue;
}
const summary = mapNotificationDeliverySummary(item);
summaryMap.set(key, summary);
summaries.push(summary);
continue;
}
current.attemptCount += 1;
}
return summaries;
}
function handleNotificationChannelWriteError(error) {
if (error instanceof AppError) {
throw error;
@@ -412,6 +481,51 @@ async function notificationsRoutes(fastify) {
},
);
fastify.get(
'/notifications/deliveries',
{
preHandler: fastify.requirePermission('notification.channel.manage'),
schema: {
tags: ['notifications'],
summary: 'List Notification Deliveries',
},
},
async (request, reply) => {
const query = parseDeliveryQuery(request.query);
const where = {
...(query.notificationId ? { notification_id: query.notificationId } : {}),
...(query.channelId ? { channel_id: query.channelId } : {}),
};
const items = await fastify.prisma.notificationDelivery.findMany({
where,
orderBy: [{ attempted_at: 'desc' }, { attempt_no: 'desc' }],
take: query.limit,
include: {
channel: {
select: {
id: true,
channel_type: true,
name: true,
},
},
notification: {
select: {
id: true,
type: true,
title: true,
created_at: true,
},
},
},
});
return sendData(reply, request, {
items: summarizeDeliveryAttempts(items, query),
});
},
);
fastify.get(
'/notifications/channels',
{