const { z } = require('zod'); const { AppError } = require('../utils/AppError'); const { sendData } = require('../utils/reply'); const { buildAuditLogData } = require('../utils/audit'); const { sendInstantMessageByChannel } = require('../services/notifications/channels'); const { buildStoredSecretFields, getChannelSecretReadModel, } = require('../services/notifications/channelConfig'); const { ROLE_PERMISSIONS } = require('../constants/permissions'); const { IM_SUPPORTED_NOTIFICATION_TYPES, NOTIFICATION_DOMAIN_SCOPES, } = require('../constants/notificationTypes'); const LIST_QUERY_SCHEMA = z.object({ page: z.coerce.number().int().min(1).optional(), pageSize: z.coerce.number().int().min(1).max(100).optional(), unreadOnly: z.enum(['0', '1', 'true', 'false']).optional(), }); const CHANNEL_ID_SCHEMA = z.string().uuid(); const ROLE_CODE_VALUES = Object.freeze(Object.keys(ROLE_PERMISSIONS)); const CHANNEL_TYPE_VALUES = Object.freeze(['wecom', 'wechat_work']); const CHANNEL_MODE_VALUES = Object.freeze(['mock', 'wecom_webhook']); const DOMAIN_SCOPE_VALUES = Object.freeze(Object.values(NOTIFICATION_DOMAIN_SCOPES)); const DEFAULT_TEMPLATE_JSON = Object.freeze({ title: '{{title}}', body: '{{body}}\n\n查看详情:{{detailUrl}}', footer: '来自海音达工作台', deepLinkLabel: '查看详情', }); const ROUTING_SCHEMA = z .object({ roleCodes: z.array(z.enum(ROLE_CODE_VALUES)).optional(), domainScopes: z.array(z.enum(DOMAIN_SCOPE_VALUES)).optional(), allowedTypes: z.array(z.enum(IM_SUPPORTED_NOTIFICATION_TYPES)).optional(), }) .strict(); const CONFIG_CREATE_SCHEMA = z .object({ mode: z.enum(CHANNEL_MODE_VALUES), webhookUrl: z.string().trim().max(2000).nullish(), secret: z.string().trim().max(500).nullish(), timeoutMs: z.coerce.number().int().min(100).max(60000).optional(), routing: ROUTING_SCHEMA.optional(), }) .strict() .superRefine((value, ctx) => { if (value.mode === 'wecom_webhook' && !String(value.webhookUrl ?? '').trim()) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['webhookUrl'], message: 'wecom_webhook_requires_webhook_url', }); } }); const CONFIG_PATCH_SCHEMA = z .object({ mode: z.enum(CHANNEL_MODE_VALUES), webhookUrl: z.string().trim().max(2000).nullish(), secret: z.string().trim().max(500).nullish(), clearSecret: z.boolean().optional(), timeoutMs: z.coerce.number().int().min(100).max(60000).optional(), routing: ROUTING_SCHEMA.optional(), }) .strict() .superRefine((value, ctx) => { if (value.mode === 'wecom_webhook' && !String(value.webhookUrl ?? '').trim()) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['webhookUrl'], message: 'wecom_webhook_requires_webhook_url', }); } }); const TEMPLATE_SCHEMA = z .object({ title: z.string().trim().min(1).max(200).optional(), body: z.string().trim().min(1).max(4000).optional(), footer: z.string().trim().min(1).max(200).optional(), deepLinkLabel: z.string().trim().min(1).max(50).optional(), }) .strict(); const CHANNEL_CREATE_SCHEMA = z .object({ channelType: z.enum(CHANNEL_TYPE_VALUES), name: z.string().trim().min(1).max(100), enabled: z.boolean().optional(), configJson: CONFIG_CREATE_SCHEMA.optional(), templateJson: TEMPLATE_SCHEMA.optional(), }) .strict(); const CHANNEL_PATCH_SCHEMA = z .object({ name: z.string().trim().min(1).max(100).optional(), enabled: z.boolean().optional(), configJson: CONFIG_PATCH_SCHEMA.optional(), templateJson: TEMPLATE_SCHEMA.optional(), }) .strict() .refine((value) => Object.keys(value).length > 0, { message: 'empty_patch', }); function buildValidationError(details, statusCode = 400) { return new AppError({ code: 'VALID_001', message: '参数校验失败', statusCode, details, }); } function uniqStrings(values) { return Array.from(new Set((values ?? []).filter(Boolean))); } function parseListQuery(query) { const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } const page = parsed.data.page ?? 1; const pageSize = parsed.data.pageSize ?? 20; const unreadOnlyRaw = parsed.data.unreadOnly ?? '0'; const unreadOnly = unreadOnlyRaw === '1' || unreadOnlyRaw === 'true'; return { page, pageSize, unreadOnly, }; } function parseNotificationId(value) { const parsed = CHANNEL_ID_SCHEMA.safeParse(value); if (!parsed.success) { throw buildValidationError({ id: 'invalid_uuid' }); } return parsed.data; } function parseBody(schema, body) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } return parsed.data; } function normalizeRouting(routing) { return { roleCodes: uniqStrings((routing?.roleCodes ?? []).map((item) => String(item).trim())), domainScopes: uniqStrings((routing?.domainScopes ?? []).map((item) => String(item).trim())), allowedTypes: uniqStrings((routing?.allowedTypes ?? []).map((item) => String(item).trim())), }; } function normalizeConfigJson(input = {}, options = {}) { return { mode: input.mode ?? 'mock', webhookUrl: String(input.webhookUrl ?? '').trim() || null, ...buildStoredSecretFields({ config: options.config, existingConfig: options.existingConfig, inputSecret: input.secret, clearSecret: Boolean(input.clearSecret), }), timeoutMs: Math.max(100, Number(input.timeoutMs ?? 5000)), routing: normalizeRouting(input.routing), }; } function normalizeTemplateJson(input = {}) { return { title: String(input.title ?? DEFAULT_TEMPLATE_JSON.title).trim() || DEFAULT_TEMPLATE_JSON.title, body: String(input.body ?? DEFAULT_TEMPLATE_JSON.body).trim() || DEFAULT_TEMPLATE_JSON.body, footer: String(input.footer ?? DEFAULT_TEMPLATE_JSON.footer).trim() || DEFAULT_TEMPLATE_JSON.footer, deepLinkLabel: String(input.deepLinkLabel ?? DEFAULT_TEMPLATE_JSON.deepLinkLabel).trim() || DEFAULT_TEMPLATE_JSON.deepLinkLabel, }; } function mapChannelConfigForRead(configJson, options = {}) { const rawConfig = configJson && typeof configJson === 'object' ? configJson : {}; const secretModel = getChannelSecretReadModel({ config: options.config, configJson: rawConfig, logger: options.logger, }); return { mode: rawConfig.mode ?? 'mock', webhookUrl: String(rawConfig.webhookUrl ?? '').trim() || null, timeoutMs: Math.max(100, Number(rawConfig.timeoutMs ?? 5000)), routing: normalizeRouting(rawConfig.routing), secretConfigured: secretModel.secretConfigured, secretMasked: secretModel.secretMasked, }; } function mapTestResult(result) { return { ok: result.ok, code: result.code, message: result.message ?? null, retryable: typeof result.retryable === 'boolean' ? result.retryable : null, previewMessage: result.previewMessage ?? null, response: result.response ?? null, skipped: result.skipped ?? false, }; } function mapNotificationItem(record) { return { id: record.id, type: record.type, title: record.title, body: record.body ?? null, targetType: record.target_type ?? null, targetId: record.target_id ?? null, readAt: record.read_at ? record.read_at.toISOString() : null, createdAt: record.created_at.toISOString(), }; } function mapNotificationChannel(record, options = {}) { const configJson = mapChannelConfigForRead(record.config_json, options); const templateJson = normalizeTemplateJson(record.template_json ?? undefined); return { id: record.id, channelType: record.channel_type, name: record.name, enabled: record.enabled, configJson, templateJson, createdAt: record.created_at?.toISOString?.() ?? null, updatedAt: record.updated_at?.toISOString?.() ?? null, }; } function handleNotificationChannelWriteError(error) { if (error instanceof AppError) { throw error; } if (error?.code === 'P2002') { throw buildValidationError({ name: 'duplicate_channel_name' }, 409); } const message = String(error?.message ?? error ?? ''); if (message.includes('FIELD_ENCRYPTION_KEY') || message.includes('Invalid encryption key')) { throw buildValidationError({ secret: 'field_encryption_key_missing' }); } throw error; } async function loadNotificationChannelOrThrow(prisma, channelId) { const channel = await prisma.notificationChannel.findUnique({ where: { id: channelId }, }); if (!channel) { throw new AppError({ code: 'SYS_404', message: 'Not Found', statusCode: 404, }); } return channel; } async function notificationsRoutes(fastify) { fastify.get( '/notifications', { preHandler: fastify.authenticate, }, async (request, reply) => { const query = parseListQuery(request.query); const skip = (query.page - 1) * query.pageSize; const where = { recipient_employee_id: request.employee.id, ...(query.unreadOnly ? { read_at: null } : {}), }; const [items, total, unreadCount] = await Promise.all([ fastify.prisma.notification.findMany({ where, orderBy: { created_at: 'desc' }, skip, take: query.pageSize, select: { id: true, type: true, title: true, body: true, target_type: true, target_id: true, read_at: true, created_at: true, }, }), fastify.prisma.notification.count({ where }), fastify.prisma.notification.count({ where: { recipient_employee_id: request.employee.id, read_at: null, }, }), ]); return sendData(reply, request, { page: query.page, pageSize: query.pageSize, total, unreadOnly: query.unreadOnly, unreadCount, items: items.map(mapNotificationItem), }); }, ); fastify.post( '/notifications/:id/mark-read', { preHandler: fastify.authenticate, }, async (request, reply) => { const notificationId = parseNotificationId(request.params?.id); const notification = await fastify.prisma.$transaction(async (tx) => { const locked = await tx.$queryRaw` SELECT id FROM "notifications" WHERE id = ${notificationId}::uuid FOR UPDATE `; if (!Array.isArray(locked) || locked.length === 0) { throw new AppError({ code: 'SYS_404', message: 'Not Found', statusCode: 404, }); } const current = await tx.notification.findUnique({ where: { id: notificationId }, select: { id: true, recipient_employee_id: true, read_at: true, type: true, title: true, body: true, target_type: true, target_id: true, created_at: true, }, }); if (!current) { throw new AppError({ code: 'SYS_404', message: 'Not Found', statusCode: 404, }); } if (current.recipient_employee_id !== request.employee.id) { throw new AppError({ code: 'PERM_001', message: '无权限', statusCode: 403, }); } if (current.read_at) { return current; } return tx.notification.update({ where: { id: notificationId }, data: { read_at: new Date(), }, select: { id: true, type: true, title: true, body: true, target_type: true, target_id: true, read_at: true, created_at: true, }, }); }); return sendData(reply, request, mapNotificationItem(notification)); }, ); fastify.get( '/notifications/channels', { preHandler: fastify.requirePermission('notification.channel.manage'), schema: { tags: ['notifications'], summary: 'List Notification Channels', }, }, async (request, reply) => { const items = await fastify.prisma.notificationChannel.findMany({ orderBy: [{ channel_type: 'asc' }, { name: 'asc' }], }); return sendData(reply, request, { items: items.map((item) => mapNotificationChannel(item, { config: fastify.config, logger: fastify.log })), }); }, ); fastify.post( '/notifications/channels', { preHandler: fastify.requirePermission('notification.channel.manage'), schema: { tags: ['notifications'], summary: 'Create Notification Channel', }, }, async (request, reply) => { const input = parseBody(CHANNEL_CREATE_SCHEMA, request.body); try { const channel = await fastify.prisma.notificationChannel.create({ data: { channel_type: input.channelType, name: input.name, enabled: input.enabled ?? true, config_json: normalizeConfigJson(input.configJson, { config: fastify.config }), template_json: normalizeTemplateJson(input.templateJson), created_by: request.employee.id, updated_by: request.employee.id, }, }); await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: 'NOTIFICATION_CHANNEL_CREATED', targetType: 'notification_channel', targetId: channel.id, extra: { channelType: channel.channel_type, name: channel.name, }, }), }); return sendData(reply, request, { channel: mapNotificationChannel(channel, { config: fastify.config, logger: fastify.log }), }); } catch (error) { handleNotificationChannelWriteError(error); } }, ); fastify.patch( '/notifications/channels/:id', { preHandler: fastify.requirePermission('notification.channel.manage'), schema: { tags: ['notifications'], summary: 'Update Notification Channel', }, }, async (request, reply) => { const channelId = parseNotificationId(request.params?.id); const input = parseBody(CHANNEL_PATCH_SCHEMA, request.body); const existingChannel = await loadNotificationChannelOrThrow(fastify.prisma, channelId); try { const channel = await fastify.prisma.notificationChannel.update({ where: { id: channelId }, data: { ...(input.name ? { name: input.name } : {}), ...(typeof input.enabled === 'boolean' ? { enabled: input.enabled } : {}), ...(input.configJson ? { config_json: normalizeConfigJson(input.configJson, { config: fastify.config, existingConfig: existingChannel.config_json }) } : {}), ...(input.templateJson ? { template_json: normalizeTemplateJson(input.templateJson) } : {}), updated_by: request.employee.id, }, }); await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: 'NOTIFICATION_CHANNEL_UPDATED', targetType: 'notification_channel', targetId: channel.id, extra: { channelType: channel.channel_type, name: channel.name, }, }), }); return sendData(reply, request, { channel: mapNotificationChannel(channel, { config: fastify.config, logger: fastify.log }), }); } catch (error) { handleNotificationChannelWriteError(error); } }, ); fastify.post( '/notifications/channels/:id/test', { preHandler: fastify.requirePermission('notification.channel.manage'), schema: { tags: ['notifications'], summary: 'Test Notification Channel By Id', }, }, async (request, reply) => { const channelId = parseNotificationId(request.params?.id); const channel = await loadNotificationChannelOrThrow(fastify.prisma, channelId); const result = await sendInstantMessageByChannel({ config: fastify.config, channel, payload: { title: '通知渠道测试', body: `测试人:${request.employee.name}(${request.employee.id})`, targetType: 'notification_channel', targetId: channel.id, }, }); await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: 'NOTIFICATION_CHANNEL_TESTED', targetType: 'notification_channel', targetId: channel.id, extra: { channelType: channel.channel_type, name: channel.name, resultCode: result.code, }, }), }); return sendData(reply, request, { channel: mapNotificationChannel(channel, { config: fastify.config, logger: fastify.log }), testResult: mapTestResult(result), }); }, ); fastify.post( '/notifications/channels/test', { preHandler: fastify.requirePermission('notification.channel.manage'), schema: { tags: ['notifications'], summary: 'Legacy Test Notification Channel', }, }, async (request, reply) => { const schema = z .object({ channelType: z.enum(CHANNEL_TYPE_VALUES), name: z.string().trim().min(1).max(100), }) .strict(); const input = parseBody(schema, request.body); let channel; try { channel = await fastify.prisma.notificationChannel.upsert({ where: { channel_type_name: { channel_type: input.channelType, name: input.name, }, }, update: { enabled: true, config_json: normalizeConfigJson({ mode: 'mock' }, { config: fastify.config }), template_json: normalizeTemplateJson(), updated_by: request.employee.id, }, create: { channel_type: input.channelType, name: input.name, enabled: true, config_json: normalizeConfigJson({ mode: 'mock' }, { config: fastify.config }), template_json: normalizeTemplateJson(), created_by: request.employee.id, updated_by: request.employee.id, }, }); } catch (error) { handleNotificationChannelWriteError(error); } const result = await sendInstantMessageByChannel({ config: fastify.config, channel, payload: { title: '通知渠道测试', body: `测试人:${request.employee.id}`, targetType: 'notification_channel', targetId: channel.id, }, }); await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: 'NOTIFICATION_CHANNEL_TESTED', targetType: 'notification_channel', targetId: channel.id, extra: { channelType: channel.channel_type, name: channel.name, resultCode: result.code, }, }), }); return sendData(reply, request, { channel: mapNotificationChannel(channel, { config: fastify.config, logger: fastify.log }), testResult: mapTestResult(result), }); }, ); } notificationsRoutes.__testables = { mapChannelConfigForRead, mapTestResult, normalizeConfigJson, normalizeRouting, }; module.exports = notificationsRoutes;