feat(P19IM1-1,P19IM1-2): 新增通知渠道配置与Outbox派发
这个提交包含在:
+373
-82
@@ -1,31 +1,114 @@
|
||||
const { z } = require("zod");
|
||||
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 { AppError } = require('../utils/AppError');
|
||||
const { sendData } = require('../utils/reply');
|
||||
const { buildAuditLogData } = require('../utils/audit');
|
||||
const { sendInstantMessageByChannel } = require('../services/notifications/channels');
|
||||
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(),
|
||||
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_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 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_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_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 new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
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";
|
||||
const unreadOnlyRaw = parsed.data.unreadOnly ?? '0';
|
||||
const unreadOnly = unreadOnlyRaw === '1' || unreadOnlyRaw === 'true';
|
||||
|
||||
return {
|
||||
page,
|
||||
@@ -35,18 +118,49 @@ function parseListQuery(query) {
|
||||
}
|
||||
|
||||
function parseNotificationId(value) {
|
||||
const parsed = z.string().uuid().safeParse(value);
|
||||
const parsed = CHANNEL_ID_SCHEMA.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { id: "invalid_uuid" },
|
||||
});
|
||||
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 = {}) {
|
||||
return {
|
||||
mode: input.mode ?? 'mock',
|
||||
webhookUrl: String(input.webhookUrl ?? '').trim() || null,
|
||||
secret: String(input.secret ?? '').trim() || null,
|
||||
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 mapNotificationItem(record) {
|
||||
return {
|
||||
id: record.id,
|
||||
@@ -61,21 +175,45 @@ function mapNotificationItem(record) {
|
||||
}
|
||||
|
||||
function mapNotificationChannel(record) {
|
||||
const configJson = normalizeConfigJson(record.config_json);
|
||||
const templateJson = normalizeTemplateJson(record.template_json ?? undefined);
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
channelType: record.channel_type,
|
||||
name: record.name,
|
||||
enabled: record.enabled,
|
||||
configJson: record.config_json,
|
||||
templateJson: record.template_json,
|
||||
configJson,
|
||||
templateJson,
|
||||
createdAt: record.created_at?.toISOString?.() ?? null,
|
||||
updatedAt: record.updated_at?.toISOString?.() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function handleNotificationChannelWriteError(error) {
|
||||
if (error?.code === 'P2002') {
|
||||
throw buildValidationError({ name: 'duplicate_channel_name' }, 409);
|
||||
}
|
||||
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",
|
||||
'/notifications',
|
||||
{
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
@@ -91,7 +229,7 @@ async function notificationsRoutes(fastify) {
|
||||
const [items, total, unreadCount] = await Promise.all([
|
||||
fastify.prisma.notification.findMany({
|
||||
where,
|
||||
orderBy: { created_at: "desc" },
|
||||
orderBy: { created_at: 'desc' },
|
||||
skip,
|
||||
take: query.pageSize,
|
||||
select: {
|
||||
@@ -126,7 +264,7 @@ async function notificationsRoutes(fastify) {
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/notifications/:id/mark-read",
|
||||
'/notifications/:id/mark-read',
|
||||
{
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
@@ -142,8 +280,8 @@ async function notificationsRoutes(fastify) {
|
||||
`;
|
||||
if (!Array.isArray(locked) || locked.length === 0) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
code: 'SYS_404',
|
||||
message: 'Not Found',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
@@ -165,16 +303,16 @@ async function notificationsRoutes(fastify) {
|
||||
|
||||
if (!current) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
code: 'SYS_404',
|
||||
message: 'Not Found',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
if (current.recipient_employee_id !== request.employee.id) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
code: 'PERM_001',
|
||||
message: '无权限',
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
@@ -206,13 +344,17 @@ async function notificationsRoutes(fastify) {
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
"/notifications/channels",
|
||||
'/notifications/channels',
|
||||
{
|
||||
preHandler: fastify.requirePermission("notification.channel.manage"),
|
||||
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" }],
|
||||
orderBy: [{ channel_type: 'asc' }, { name: 'asc' }],
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
@@ -222,63 +364,120 @@ async function notificationsRoutes(fastify) {
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/notifications/channels/test",
|
||||
'/notifications/channels',
|
||||
{
|
||||
preHandler: fastify.requirePermission("notification.channel.manage"),
|
||||
preHandler: fastify.requirePermission('notification.channel.manage'),
|
||||
schema: {
|
||||
tags: ['notifications'],
|
||||
summary: 'Create Notification Channel',
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const schema = z
|
||||
.object({
|
||||
channelType: z.string().trim().min(1).max(32),
|
||||
name: z.string().trim().min(1).max(100),
|
||||
})
|
||||
.strict();
|
||||
const input = parseBody(CHANNEL_CREATE_SCHEMA, request.body);
|
||||
|
||||
const parsed = schema.safeParse(request.body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
|
||||
const channel = await fastify.prisma.notificationChannel.upsert({
|
||||
where: {
|
||||
channel_type_name: {
|
||||
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),
|
||||
template_json: normalizeTemplateJson(input.templateJson),
|
||||
created_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
enabled: true,
|
||||
config_json: {
|
||||
mode: "mock",
|
||||
});
|
||||
|
||||
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),
|
||||
});
|
||||
} 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);
|
||||
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) } : {}),
|
||||
...(input.templateJson ? { template_json: normalizeTemplateJson(input.templateJson) } : {}),
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
create: {
|
||||
channel_type: input.channelType,
|
||||
name: input.name,
|
||||
enabled: true,
|
||||
config_json: {
|
||||
mode: "mock",
|
||||
},
|
||||
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_UPDATED',
|
||||
targetType: 'notification_channel',
|
||||
targetId: channel.id,
|
||||
extra: {
|
||||
channelType: channel.channel_type,
|
||||
name: channel.name,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
channel: mapNotificationChannel(channel),
|
||||
});
|
||||
} 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.id}`,
|
||||
targetType: "notification_channel",
|
||||
title: '通知渠道测试',
|
||||
body: `测试人:${request.employee.name}(${request.employee.id})`,
|
||||
targetType: 'notification_channel',
|
||||
targetId: channel.id,
|
||||
},
|
||||
});
|
||||
@@ -286,8 +485,8 @@ async function notificationsRoutes(fastify) {
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "NOTIFICATION_CHANNEL_TESTED",
|
||||
targetType: "notification_channel",
|
||||
action: 'NOTIFICATION_CHANNEL_TESTED',
|
||||
targetType: 'notification_channel',
|
||||
targetId: channel.id,
|
||||
extra: {
|
||||
channelType: channel.channel_type,
|
||||
@@ -299,7 +498,99 @@ async function notificationsRoutes(fastify) {
|
||||
|
||||
return sendData(reply, request, {
|
||||
channel: mapNotificationChannel(channel),
|
||||
testResult: result,
|
||||
testResult: {
|
||||
ok: result.ok,
|
||||
code: result.code,
|
||||
previewMessage: result.previewMessage ?? null,
|
||||
response: result.response ?? null,
|
||||
skipped: result.skipped ?? false,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
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' }),
|
||||
template_json: normalizeTemplateJson(),
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
create: {
|
||||
channel_type: input.channelType,
|
||||
name: input.name,
|
||||
enabled: true,
|
||||
config_json: normalizeConfigJson({ mode: 'mock' }),
|
||||
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),
|
||||
testResult: {
|
||||
ok: result.ok,
|
||||
code: result.code,
|
||||
previewMessage: result.previewMessage ?? null,
|
||||
response: result.response ?? null,
|
||||
skipped: result.skipped ?? false,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
在新工单中引用
屏蔽一个用户