feat(P23IM2-1,P23IM2-2,P23IM2-3): 密钥加密持久化/遮罩读模型/企微可重试语义/迁移脚本

这个提交包含在:
2026-03-13 21:50:04 +08:00
父节点 a4c7268b33
当前提交 a8ec86016c
共修改 9 个文件,包含 1063 行新增和 40 行删除
+95 -31
查看文件
@@ -4,6 +4,10 @@ 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,
@@ -36,7 +40,7 @@ const ROUTING_SCHEMA = z
})
.strict();
const CONFIG_SCHEMA = z
const CONFIG_CREATE_SCHEMA = z
.object({
mode: z.enum(CHANNEL_MODE_VALUES),
webhookUrl: z.string().trim().max(2000).nullish(),
@@ -55,6 +59,26 @@ const CONFIG_SCHEMA = z
}
});
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(),
@@ -69,7 +93,7 @@ const CHANNEL_CREATE_SCHEMA = z
channelType: z.enum(CHANNEL_TYPE_VALUES),
name: z.string().trim().min(1).max(100),
enabled: z.boolean().optional(),
configJson: CONFIG_SCHEMA.optional(),
configJson: CONFIG_CREATE_SCHEMA.optional(),
templateJson: TEMPLATE_SCHEMA.optional(),
})
.strict();
@@ -78,7 +102,7 @@ const CHANNEL_PATCH_SCHEMA = z
.object({
name: z.string().trim().min(1).max(100).optional(),
enabled: z.boolean().optional(),
configJson: CONFIG_SCHEMA.optional(),
configJson: CONFIG_PATCH_SCHEMA.optional(),
templateJson: TEMPLATE_SCHEMA.optional(),
})
.strict()
@@ -141,11 +165,16 @@ function normalizeRouting(routing) {
};
}
function normalizeConfigJson(input = {}) {
function normalizeConfigJson(input = {}, options = {}) {
return {
mode: input.mode ?? 'mock',
webhookUrl: String(input.webhookUrl ?? '').trim() || null,
secret: String(input.secret ?? '').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),
};
@@ -161,6 +190,36 @@ function normalizeTemplateJson(input = {}) {
};
}
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,
@@ -174,8 +233,8 @@ function mapNotificationItem(record) {
};
}
function mapNotificationChannel(record) {
const configJson = normalizeConfigJson(record.config_json);
function mapNotificationChannel(record, options = {}) {
const configJson = mapChannelConfigForRead(record.config_json, options);
const templateJson = normalizeTemplateJson(record.template_json ?? undefined);
return {
@@ -191,9 +250,19 @@ function mapNotificationChannel(record) {
}
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;
}
@@ -358,7 +427,7 @@ async function notificationsRoutes(fastify) {
});
return sendData(reply, request, {
items: items.map(mapNotificationChannel),
items: items.map((item) => mapNotificationChannel(item, { config: fastify.config, logger: fastify.log })),
});
},
);
@@ -381,7 +450,7 @@ async function notificationsRoutes(fastify) {
channel_type: input.channelType,
name: input.name,
enabled: input.enabled ?? true,
config_json: normalizeConfigJson(input.configJson),
config_json: normalizeConfigJson(input.configJson, { config: fastify.config }),
template_json: normalizeTemplateJson(input.templateJson),
created_by: request.employee.id,
updated_by: request.employee.id,
@@ -402,7 +471,7 @@ async function notificationsRoutes(fastify) {
});
return sendData(reply, request, {
channel: mapNotificationChannel(channel),
channel: mapNotificationChannel(channel, { config: fastify.config, logger: fastify.log }),
});
} catch (error) {
handleNotificationChannelWriteError(error);
@@ -422,7 +491,7 @@ async function notificationsRoutes(fastify) {
async (request, reply) => {
const channelId = parseNotificationId(request.params?.id);
const input = parseBody(CHANNEL_PATCH_SCHEMA, request.body);
await loadNotificationChannelOrThrow(fastify.prisma, channelId);
const existingChannel = await loadNotificationChannelOrThrow(fastify.prisma, channelId);
try {
const channel = await fastify.prisma.notificationChannel.update({
@@ -430,7 +499,7 @@ async function notificationsRoutes(fastify) {
data: {
...(input.name ? { name: input.name } : {}),
...(typeof input.enabled === 'boolean' ? { enabled: input.enabled } : {}),
...(input.configJson ? { config_json: normalizeConfigJson(input.configJson) } : {}),
...(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,
},
@@ -450,7 +519,7 @@ async function notificationsRoutes(fastify) {
});
return sendData(reply, request, {
channel: mapNotificationChannel(channel),
channel: mapNotificationChannel(channel, { config: fastify.config, logger: fastify.log }),
});
} catch (error) {
handleNotificationChannelWriteError(error);
@@ -497,14 +566,8 @@ async function notificationsRoutes(fastify) {
});
return sendData(reply, request, {
channel: mapNotificationChannel(channel),
testResult: {
ok: result.ok,
code: result.code,
previewMessage: result.previewMessage ?? null,
response: result.response ?? null,
skipped: result.skipped ?? false,
},
channel: mapNotificationChannel(channel, { config: fastify.config, logger: fastify.log }),
testResult: mapTestResult(result),
});
},
);
@@ -539,7 +602,7 @@ async function notificationsRoutes(fastify) {
},
update: {
enabled: true,
config_json: normalizeConfigJson({ mode: 'mock' }),
config_json: normalizeConfigJson({ mode: 'mock' }, { config: fastify.config }),
template_json: normalizeTemplateJson(),
updated_by: request.employee.id,
},
@@ -547,7 +610,7 @@ async function notificationsRoutes(fastify) {
channel_type: input.channelType,
name: input.name,
enabled: true,
config_json: normalizeConfigJson({ mode: 'mock' }),
config_json: normalizeConfigJson({ mode: 'mock' }, { config: fastify.config }),
template_json: normalizeTemplateJson(),
created_by: request.employee.id,
updated_by: request.employee.id,
@@ -583,17 +646,18 @@ async function notificationsRoutes(fastify) {
});
return sendData(reply, request, {
channel: mapNotificationChannel(channel),
testResult: {
ok: result.ok,
code: result.code,
previewMessage: result.previewMessage ?? null,
response: result.response ?? null,
skipped: result.skipped ?? false,
},
channel: mapNotificationChannel(channel, { config: fastify.config, logger: fastify.log }),
testResult: mapTestResult(result),
});
},
);
}
notificationsRoutes.__testables = {
mapChannelConfigForRead,
mapTestResult,
normalizeConfigJson,
normalizeRouting,
};
module.exports = notificationsRoutes;