664 行
20 KiB
JavaScript
664 行
20 KiB
JavaScript
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;
|