文件
hyb-oa/server/src/routes/notifications.js
T

664 行
20 KiB
JavaScript
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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;