826 行
24 KiB
JavaScript
826 行
24 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(),
|
||
type: z.string().optional(),
|
||
});
|
||
const DELIVERY_STATUS_VALUES = Object.freeze(['pending', 'success', 'failed']);
|
||
const DELIVERY_QUERY_SCHEMA = z.object({
|
||
notificationId: z.string().uuid().optional(),
|
||
channelId: z.string().uuid().optional(),
|
||
status: z.enum(DELIVERY_STATUS_VALUES).optional(),
|
||
limit: z.coerce.number().int().min(1).max(200).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', 'wecom_app_message']);
|
||
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';
|
||
const types = uniqStrings(
|
||
String(parsed.data.type ?? '')
|
||
.split(',')
|
||
.map((item) => item.trim())
|
||
.filter(Boolean),
|
||
);
|
||
|
||
return {
|
||
page,
|
||
pageSize,
|
||
unreadOnly,
|
||
types,
|
||
};
|
||
}
|
||
|
||
function parseDeliveryQuery(query) {
|
||
const parsed = DELIVERY_QUERY_SCHEMA.safeParse(query ?? {});
|
||
if (!parsed.success) {
|
||
throw buildValidationError(parsed.error.flatten());
|
||
}
|
||
|
||
return {
|
||
notificationId: parsed.data.notificationId ?? null,
|
||
channelId: parsed.data.channelId ?? null,
|
||
status: parsed.data.status ?? null,
|
||
limit: parsed.data.limit ?? 200,
|
||
};
|
||
}
|
||
|
||
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 = {}) {
|
||
const mode = input.mode ?? 'mock';
|
||
const webhookUrl = mode === 'wecom_webhook' ? String(input.webhookUrl ?? '').trim() || null : null;
|
||
const secretFields =
|
||
mode === 'wecom_webhook'
|
||
? buildStoredSecretFields({
|
||
config: options.config,
|
||
existingConfig: options.existingConfig,
|
||
inputSecret: input.secret,
|
||
clearSecret: Boolean(input.clearSecret),
|
||
})
|
||
: {};
|
||
|
||
return {
|
||
mode,
|
||
webhookUrl,
|
||
...secretFields,
|
||
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 =
|
||
rawConfig.mode === 'wecom_webhook'
|
||
? getChannelSecretReadModel({
|
||
config: options.config,
|
||
configJson: rawConfig,
|
||
logger: options.logger,
|
||
})
|
||
: {
|
||
secretConfigured: false,
|
||
secretMasked: null,
|
||
};
|
||
|
||
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 mapNotificationDeliverySummary(record) {
|
||
return {
|
||
notificationId: record.notification_id,
|
||
channelId: record.channel_id,
|
||
channelName: record.channel?.name ?? '-',
|
||
channelType: record.channel?.channel_type ?? 'wecom',
|
||
notificationType: record.notification?.type ?? '',
|
||
notificationTitle: record.notification?.title ?? '',
|
||
status: record.status,
|
||
attemptCount: 1,
|
||
latestAttemptAt: record.attempted_at?.toISOString?.() ?? null,
|
||
resultCode: record.result_code ?? null,
|
||
errorMessage: record.error_message ?? null,
|
||
responseBody: record.response_body ?? null,
|
||
notificationCreatedAt: record.notification?.created_at?.toISOString?.() ?? null,
|
||
};
|
||
}
|
||
|
||
function summarizeDeliveryAttempts(items, query) {
|
||
const summaries = [];
|
||
const summaryMap = new Map();
|
||
const skippedKeys = new Set();
|
||
|
||
for (const item of items) {
|
||
const key = `${item.notification_id}:${item.channel_id}`;
|
||
if (skippedKeys.has(key)) {
|
||
continue;
|
||
}
|
||
|
||
const current = summaryMap.get(key);
|
||
if (!current) {
|
||
if (query.status && item.status !== query.status) {
|
||
skippedKeys.add(key);
|
||
continue;
|
||
}
|
||
|
||
const summary = mapNotificationDeliverySummary(item);
|
||
summaryMap.set(key, summary);
|
||
summaries.push(summary);
|
||
continue;
|
||
}
|
||
|
||
current.attemptCount += 1;
|
||
}
|
||
|
||
return summaries;
|
||
}
|
||
|
||
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 } : {}),
|
||
...(query.types.length ? { type: { in: query.types } } : {}),
|
||
};
|
||
|
||
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/mark-all-read',
|
||
{
|
||
preHandler: fastify.authenticate,
|
||
},
|
||
async (request, reply) => {
|
||
const result = await fastify.prisma.notification.updateMany({
|
||
where: {
|
||
recipient_employee_id: request.employee.id,
|
||
read_at: null,
|
||
},
|
||
data: {
|
||
read_at: new Date(),
|
||
},
|
||
});
|
||
|
||
return sendData(reply, request, {
|
||
updatedCount: Number(result?.count ?? 0),
|
||
});
|
||
},
|
||
);
|
||
|
||
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/deliveries',
|
||
{
|
||
preHandler: fastify.requirePermission('notification.channel.manage'),
|
||
schema: {
|
||
tags: ['notifications'],
|
||
summary: 'List Notification Deliveries',
|
||
},
|
||
},
|
||
async (request, reply) => {
|
||
const query = parseDeliveryQuery(request.query);
|
||
const where = {
|
||
...(query.notificationId ? { notification_id: query.notificationId } : {}),
|
||
...(query.channelId ? { channel_id: query.channelId } : {}),
|
||
};
|
||
|
||
const items = await fastify.prisma.notificationDelivery.findMany({
|
||
where,
|
||
orderBy: [{ attempted_at: 'desc' }, { attempt_no: 'desc' }],
|
||
take: query.limit,
|
||
include: {
|
||
channel: {
|
||
select: {
|
||
id: true,
|
||
channel_type: true,
|
||
name: true,
|
||
},
|
||
},
|
||
notification: {
|
||
select: {
|
||
id: true,
|
||
type: true,
|
||
title: true,
|
||
created_at: true,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
return sendData(reply, request, {
|
||
items: summarizeDeliveryAttempts(items, query),
|
||
});
|
||
},
|
||
);
|
||
|
||
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,
|
||
recipientEmployeeIds: [request.employee.id],
|
||
},
|
||
prisma: fastify.prisma,
|
||
});
|
||
|
||
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,
|
||
recipientEmployeeIds: [request.employee.id],
|
||
},
|
||
prisma: fastify.prisma,
|
||
});
|
||
|
||
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;
|