feat(P19IM1-1,P19IM1-2): 新增通知渠道配置与Outbox派发
这个提交包含在:
@@ -0,0 +1,86 @@
|
|||||||
|
const NOTIFICATION_DOMAIN_SCOPES = Object.freeze({
|
||||||
|
workflow: 'workflow',
|
||||||
|
reimbursement: 'reimbursement',
|
||||||
|
leave: 'leave',
|
||||||
|
procurement: 'procurement',
|
||||||
|
asset: 'asset',
|
||||||
|
inventory: 'inventory',
|
||||||
|
});
|
||||||
|
|
||||||
|
const NOTIFICATION_TYPES = Object.freeze({
|
||||||
|
workflowTaskAssigned: 'workflow.task_assigned',
|
||||||
|
reimbursementApproved: 'reimbursement.approved',
|
||||||
|
reimbursementRejected: 'reimbursement.rejected',
|
||||||
|
reimbursementReturned: 'reimbursement.returned',
|
||||||
|
reimbursementPaid: 'reimbursement.paid',
|
||||||
|
leaveApproved: 'leave.approved',
|
||||||
|
leaveRejected: 'leave.rejected',
|
||||||
|
purchaseRequestApproved: 'purchase_request.approved',
|
||||||
|
purchaseRequestRejected: 'purchase_request.rejected',
|
||||||
|
assetAssigned: 'asset.assigned',
|
||||||
|
assetReturned: 'asset.returned',
|
||||||
|
assetTransferred: 'asset.transferred',
|
||||||
|
inventoryCheckCompleted: 'inventory_check.completed',
|
||||||
|
});
|
||||||
|
|
||||||
|
const IM_SUPPORTED_NOTIFICATION_TYPES = Object.freeze([
|
||||||
|
NOTIFICATION_TYPES.workflowTaskAssigned,
|
||||||
|
NOTIFICATION_TYPES.reimbursementApproved,
|
||||||
|
NOTIFICATION_TYPES.reimbursementRejected,
|
||||||
|
NOTIFICATION_TYPES.reimbursementReturned,
|
||||||
|
NOTIFICATION_TYPES.reimbursementPaid,
|
||||||
|
NOTIFICATION_TYPES.leaveApproved,
|
||||||
|
NOTIFICATION_TYPES.leaveRejected,
|
||||||
|
NOTIFICATION_TYPES.purchaseRequestApproved,
|
||||||
|
NOTIFICATION_TYPES.purchaseRequestRejected,
|
||||||
|
NOTIFICATION_TYPES.assetAssigned,
|
||||||
|
NOTIFICATION_TYPES.assetReturned,
|
||||||
|
NOTIFICATION_TYPES.assetTransferred,
|
||||||
|
NOTIFICATION_TYPES.inventoryCheckCompleted,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const NOTIFICATION_TYPE_TO_DOMAIN_SCOPE = Object.freeze({
|
||||||
|
[NOTIFICATION_TYPES.workflowTaskAssigned]: NOTIFICATION_DOMAIN_SCOPES.workflow,
|
||||||
|
[NOTIFICATION_TYPES.reimbursementApproved]: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
|
||||||
|
[NOTIFICATION_TYPES.reimbursementRejected]: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
|
||||||
|
[NOTIFICATION_TYPES.reimbursementReturned]: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
|
||||||
|
[NOTIFICATION_TYPES.reimbursementPaid]: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
|
||||||
|
[NOTIFICATION_TYPES.leaveApproved]: NOTIFICATION_DOMAIN_SCOPES.leave,
|
||||||
|
[NOTIFICATION_TYPES.leaveRejected]: NOTIFICATION_DOMAIN_SCOPES.leave,
|
||||||
|
[NOTIFICATION_TYPES.purchaseRequestApproved]: NOTIFICATION_DOMAIN_SCOPES.procurement,
|
||||||
|
[NOTIFICATION_TYPES.purchaseRequestRejected]: NOTIFICATION_DOMAIN_SCOPES.procurement,
|
||||||
|
[NOTIFICATION_TYPES.assetAssigned]: NOTIFICATION_DOMAIN_SCOPES.asset,
|
||||||
|
[NOTIFICATION_TYPES.assetReturned]: NOTIFICATION_DOMAIN_SCOPES.asset,
|
||||||
|
[NOTIFICATION_TYPES.assetTransferred]: NOTIFICATION_DOMAIN_SCOPES.asset,
|
||||||
|
[NOTIFICATION_TYPES.inventoryCheckCompleted]: NOTIFICATION_DOMAIN_SCOPES.inventory,
|
||||||
|
});
|
||||||
|
|
||||||
|
const TARGET_TYPE_TO_DOMAIN_SCOPE = Object.freeze({
|
||||||
|
workflow_task: NOTIFICATION_DOMAIN_SCOPES.workflow,
|
||||||
|
reimbursement_form: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
|
||||||
|
leave_request: NOTIFICATION_DOMAIN_SCOPES.leave,
|
||||||
|
purchase_request: NOTIFICATION_DOMAIN_SCOPES.procurement,
|
||||||
|
asset_card: NOTIFICATION_DOMAIN_SCOPES.asset,
|
||||||
|
inventory_check: NOTIFICATION_DOMAIN_SCOPES.inventory,
|
||||||
|
});
|
||||||
|
|
||||||
|
function resolveNotificationDomainScope(type, targetType = null) {
|
||||||
|
const normalizedType = type == null ? '' : String(type).trim();
|
||||||
|
if (normalizedType && NOTIFICATION_TYPE_TO_DOMAIN_SCOPE[normalizedType]) {
|
||||||
|
return NOTIFICATION_TYPE_TO_DOMAIN_SCOPE[normalizedType];
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedTargetType = targetType == null ? '' : String(targetType).trim();
|
||||||
|
if (normalizedTargetType && TARGET_TYPE_TO_DOMAIN_SCOPE[normalizedTargetType]) {
|
||||||
|
return TARGET_TYPE_TO_DOMAIN_SCOPE[normalizedTargetType];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
NOTIFICATION_TYPES,
|
||||||
|
IM_SUPPORTED_NOTIFICATION_TYPES,
|
||||||
|
NOTIFICATION_DOMAIN_SCOPES,
|
||||||
|
resolveNotificationDomainScope,
|
||||||
|
};
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
const cron = require("node-cron");
|
const cron = require('node-cron');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
fetchPendingOutboxEvents,
|
fetchPendingOutboxEvents,
|
||||||
markOutboxFailed,
|
markOutboxFailed,
|
||||||
markOutboxProcessing,
|
markOutboxProcessing,
|
||||||
markOutboxSucceeded,
|
markOutboxSucceeded,
|
||||||
} = require("../services/integrations/outbox");
|
} = require('../services/integrations/outbox');
|
||||||
const { dispatchOutboxEvent } = require("../services/integrations/dispatcher");
|
const { dispatchOutboxEvent } = require('../services/integrations/dispatcher');
|
||||||
|
|
||||||
async function runIntegrationOutboxOnce(prisma, config, logger) {
|
async function runIntegrationOutboxOnce(prisma, config, logger) {
|
||||||
const items = await fetchPendingOutboxEvents(prisma, {
|
const items = await fetchPendingOutboxEvents(prisma, {
|
||||||
@@ -21,6 +21,7 @@ async function runIntegrationOutboxOnce(prisma, config, logger) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await dispatchOutboxEvent({
|
await dispatchOutboxEvent({
|
||||||
|
prisma,
|
||||||
config,
|
config,
|
||||||
eventType: item.event_type,
|
eventType: item.event_type,
|
||||||
payload: item.payload,
|
payload: item.payload,
|
||||||
@@ -28,7 +29,7 @@ async function runIntegrationOutboxOnce(prisma, config, logger) {
|
|||||||
await markOutboxSucceeded(prisma, item.id, null);
|
await markOutboxSucceeded(prisma, item.id, null);
|
||||||
succeeded += 1;
|
succeeded += 1;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const retryable = typeof error?.retryable === "boolean" ? error.retryable : true;
|
const retryable = typeof error?.retryable === 'boolean' ? error.retryable : true;
|
||||||
await markOutboxFailed(prisma, config, item, error, null, { retryable });
|
await markOutboxFailed(prisma, config, item, error, null, { retryable });
|
||||||
failed += 1;
|
failed += 1;
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -37,8 +38,11 @@ async function runIntegrationOutboxOnce(prisma, config, logger) {
|
|||||||
retryable,
|
retryable,
|
||||||
outboxId: item.id,
|
outboxId: item.id,
|
||||||
eventType: item.event_type,
|
eventType: item.event_type,
|
||||||
|
channelId: item.payload?.channelId ?? null,
|
||||||
|
channelType: item.payload?.channelType ?? null,
|
||||||
|
notificationType: item.payload?.notificationType ?? null,
|
||||||
},
|
},
|
||||||
"Integration outbox dispatch failed",
|
'Integration outbox dispatch failed',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,16 +70,16 @@ function startIntegrationOutboxJob(fastify) {
|
|||||||
fastify.log,
|
fastify.log,
|
||||||
);
|
);
|
||||||
fastify.metrics?.jobRunsTotal?.inc?.({
|
fastify.metrics?.jobRunsTotal?.inc?.({
|
||||||
job: "integration.outbox.dispatch",
|
job: 'integration.outbox.dispatch',
|
||||||
driver: "cron",
|
driver: 'cron',
|
||||||
outcome: "success",
|
outcome: 'success',
|
||||||
});
|
});
|
||||||
if (result.succeeded > 0) {
|
if (result.succeeded > 0) {
|
||||||
fastify.metrics?.jobProcessedTotal?.inc?.(
|
fastify.metrics?.jobProcessedTotal?.inc?.(
|
||||||
{
|
{
|
||||||
job: "integration.outbox.dispatch",
|
job: 'integration.outbox.dispatch',
|
||||||
driver: "cron",
|
driver: 'cron',
|
||||||
kind: "succeeded",
|
kind: 'succeeded',
|
||||||
},
|
},
|
||||||
result.succeeded,
|
result.succeeded,
|
||||||
);
|
);
|
||||||
@@ -83,26 +87,26 @@ function startIntegrationOutboxJob(fastify) {
|
|||||||
if (result.failed > 0) {
|
if (result.failed > 0) {
|
||||||
fastify.metrics?.jobProcessedTotal?.inc?.(
|
fastify.metrics?.jobProcessedTotal?.inc?.(
|
||||||
{
|
{
|
||||||
job: "integration.outbox.dispatch",
|
job: 'integration.outbox.dispatch',
|
||||||
driver: "cron",
|
driver: 'cron',
|
||||||
kind: "failed",
|
kind: 'failed',
|
||||||
},
|
},
|
||||||
result.failed,
|
result.failed,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fastify.log.error({ err: error }, "Integration outbox job failed");
|
fastify.log.error({ err: error }, 'Integration outbox job failed');
|
||||||
fastify.metrics?.jobRunsTotal?.inc?.({
|
fastify.metrics?.jobRunsTotal?.inc?.({
|
||||||
job: "integration.outbox.dispatch",
|
job: 'integration.outbox.dispatch',
|
||||||
driver: "cron",
|
driver: 'cron',
|
||||||
outcome: "failure",
|
outcome: 'failure',
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
running = false;
|
running = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.addHook("onClose", async () => {
|
fastify.addHook('onClose', async () => {
|
||||||
task.stop();
|
task.stop();
|
||||||
task.destroy();
|
task.destroy();
|
||||||
});
|
});
|
||||||
@@ -112,7 +116,7 @@ function startIntegrationOutboxJob(fastify) {
|
|||||||
cron: fastify.config.integrationOutboxCron,
|
cron: fastify.config.integrationOutboxCron,
|
||||||
batchSize: fastify.config.integrationOutboxBatchSize,
|
batchSize: fastify.config.integrationOutboxBatchSize,
|
||||||
},
|
},
|
||||||
"Integration outbox job scheduled",
|
'Integration outbox job scheduled',
|
||||||
);
|
);
|
||||||
|
|
||||||
return task;
|
return task;
|
||||||
|
|||||||
+373
-82
@@ -1,31 +1,114 @@
|
|||||||
const { z } = require("zod");
|
const { z } = require('zod');
|
||||||
|
|
||||||
const { AppError } = require("../utils/AppError");
|
const { AppError } = require('../utils/AppError');
|
||||||
const { sendData } = require("../utils/reply");
|
const { sendData } = require('../utils/reply');
|
||||||
const { buildAuditLogData } = require("../utils/audit");
|
const { buildAuditLogData } = require('../utils/audit');
|
||||||
const { sendInstantMessageByChannel } = require("../services/notifications/channels");
|
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({
|
const LIST_QUERY_SCHEMA = z.object({
|
||||||
page: z.coerce.number().int().min(1).optional(),
|
page: z.coerce.number().int().min(1).optional(),
|
||||||
pageSize: z.coerce.number().int().min(1).max(100).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) {
|
function parseListQuery(query) {
|
||||||
const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {});
|
const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {});
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
throw new AppError({
|
throw buildValidationError(parsed.error.flatten());
|
||||||
code: "VALID_001",
|
|
||||||
message: "参数校验失败",
|
|
||||||
statusCode: 400,
|
|
||||||
details: parsed.error.flatten(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = parsed.data.page ?? 1;
|
const page = parsed.data.page ?? 1;
|
||||||
const pageSize = parsed.data.pageSize ?? 20;
|
const pageSize = parsed.data.pageSize ?? 20;
|
||||||
const unreadOnlyRaw = parsed.data.unreadOnly ?? "0";
|
const unreadOnlyRaw = parsed.data.unreadOnly ?? '0';
|
||||||
const unreadOnly = unreadOnlyRaw === "1" || unreadOnlyRaw === "true";
|
const unreadOnly = unreadOnlyRaw === '1' || unreadOnlyRaw === 'true';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
page,
|
page,
|
||||||
@@ -35,18 +118,49 @@ function parseListQuery(query) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseNotificationId(value) {
|
function parseNotificationId(value) {
|
||||||
const parsed = z.string().uuid().safeParse(value);
|
const parsed = CHANNEL_ID_SCHEMA.safeParse(value);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
throw new AppError({
|
throw buildValidationError({ id: 'invalid_uuid' });
|
||||||
code: "VALID_001",
|
|
||||||
message: "参数校验失败",
|
|
||||||
statusCode: 400,
|
|
||||||
details: { id: "invalid_uuid" },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return parsed.data;
|
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) {
|
function mapNotificationItem(record) {
|
||||||
return {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
@@ -61,21 +175,45 @@ function mapNotificationItem(record) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mapNotificationChannel(record) {
|
function mapNotificationChannel(record) {
|
||||||
|
const configJson = normalizeConfigJson(record.config_json);
|
||||||
|
const templateJson = normalizeTemplateJson(record.template_json ?? undefined);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
channelType: record.channel_type,
|
channelType: record.channel_type,
|
||||||
name: record.name,
|
name: record.name,
|
||||||
enabled: record.enabled,
|
enabled: record.enabled,
|
||||||
configJson: record.config_json,
|
configJson,
|
||||||
templateJson: record.template_json,
|
templateJson,
|
||||||
createdAt: record.created_at?.toISOString?.() ?? null,
|
createdAt: record.created_at?.toISOString?.() ?? null,
|
||||||
updatedAt: record.updated_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) {
|
async function notificationsRoutes(fastify) {
|
||||||
fastify.get(
|
fastify.get(
|
||||||
"/notifications",
|
'/notifications',
|
||||||
{
|
{
|
||||||
preHandler: fastify.authenticate,
|
preHandler: fastify.authenticate,
|
||||||
},
|
},
|
||||||
@@ -91,7 +229,7 @@ async function notificationsRoutes(fastify) {
|
|||||||
const [items, total, unreadCount] = await Promise.all([
|
const [items, total, unreadCount] = await Promise.all([
|
||||||
fastify.prisma.notification.findMany({
|
fastify.prisma.notification.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: { created_at: "desc" },
|
orderBy: { created_at: 'desc' },
|
||||||
skip,
|
skip,
|
||||||
take: query.pageSize,
|
take: query.pageSize,
|
||||||
select: {
|
select: {
|
||||||
@@ -126,7 +264,7 @@ async function notificationsRoutes(fastify) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
fastify.post(
|
fastify.post(
|
||||||
"/notifications/:id/mark-read",
|
'/notifications/:id/mark-read',
|
||||||
{
|
{
|
||||||
preHandler: fastify.authenticate,
|
preHandler: fastify.authenticate,
|
||||||
},
|
},
|
||||||
@@ -142,8 +280,8 @@ async function notificationsRoutes(fastify) {
|
|||||||
`;
|
`;
|
||||||
if (!Array.isArray(locked) || locked.length === 0) {
|
if (!Array.isArray(locked) || locked.length === 0) {
|
||||||
throw new AppError({
|
throw new AppError({
|
||||||
code: "SYS_404",
|
code: 'SYS_404',
|
||||||
message: "Not Found",
|
message: 'Not Found',
|
||||||
statusCode: 404,
|
statusCode: 404,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -165,16 +303,16 @@ async function notificationsRoutes(fastify) {
|
|||||||
|
|
||||||
if (!current) {
|
if (!current) {
|
||||||
throw new AppError({
|
throw new AppError({
|
||||||
code: "SYS_404",
|
code: 'SYS_404',
|
||||||
message: "Not Found",
|
message: 'Not Found',
|
||||||
statusCode: 404,
|
statusCode: 404,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (current.recipient_employee_id !== request.employee.id) {
|
if (current.recipient_employee_id !== request.employee.id) {
|
||||||
throw new AppError({
|
throw new AppError({
|
||||||
code: "PERM_001",
|
code: 'PERM_001',
|
||||||
message: "无权限",
|
message: '无权限',
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -206,13 +344,17 @@ async function notificationsRoutes(fastify) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
fastify.get(
|
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) => {
|
async (request, reply) => {
|
||||||
const items = await fastify.prisma.notificationChannel.findMany({
|
const items = await fastify.prisma.notificationChannel.findMany({
|
||||||
orderBy: [{ channel_type: "asc" }, { name: "asc" }],
|
orderBy: [{ channel_type: 'asc' }, { name: 'asc' }],
|
||||||
});
|
});
|
||||||
|
|
||||||
return sendData(reply, request, {
|
return sendData(reply, request, {
|
||||||
@@ -222,63 +364,120 @@ async function notificationsRoutes(fastify) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
fastify.post(
|
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) => {
|
async (request, reply) => {
|
||||||
const schema = z
|
const input = parseBody(CHANNEL_CREATE_SCHEMA, request.body);
|
||||||
.object({
|
|
||||||
channelType: z.string().trim().min(1).max(32),
|
|
||||||
name: z.string().trim().min(1).max(100),
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
const parsed = schema.safeParse(request.body ?? {});
|
try {
|
||||||
if (!parsed.success) {
|
const channel = await fastify.prisma.notificationChannel.create({
|
||||||
throw new AppError({
|
data: {
|
||||||
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: {
|
|
||||||
channel_type: input.channelType,
|
channel_type: input.channelType,
|
||||||
name: input.name,
|
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,
|
await fastify.prisma.auditLog.create({
|
||||||
config_json: {
|
data: buildAuditLogData(request, {
|
||||||
mode: "mock",
|
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: {
|
await fastify.prisma.auditLog.create({
|
||||||
channel_type: input.channelType,
|
data: buildAuditLogData(request, {
|
||||||
name: input.name,
|
actorId: request.employee.id,
|
||||||
enabled: true,
|
action: 'NOTIFICATION_CHANNEL_UPDATED',
|
||||||
config_json: {
|
targetType: 'notification_channel',
|
||||||
mode: "mock",
|
targetId: channel.id,
|
||||||
},
|
extra: {
|
||||||
created_by: request.employee.id,
|
channelType: channel.channel_type,
|
||||||
updated_by: request.employee.id,
|
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({
|
const result = await sendInstantMessageByChannel({
|
||||||
config: fastify.config,
|
config: fastify.config,
|
||||||
channel,
|
channel,
|
||||||
payload: {
|
payload: {
|
||||||
title: "通知渠道测试",
|
title: '通知渠道测试',
|
||||||
body: `测试人:${request.employee.id}`,
|
body: `测试人:${request.employee.name}(${request.employee.id})`,
|
||||||
targetType: "notification_channel",
|
targetType: 'notification_channel',
|
||||||
targetId: channel.id,
|
targetId: channel.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -286,8 +485,8 @@ async function notificationsRoutes(fastify) {
|
|||||||
await fastify.prisma.auditLog.create({
|
await fastify.prisma.auditLog.create({
|
||||||
data: buildAuditLogData(request, {
|
data: buildAuditLogData(request, {
|
||||||
actorId: request.employee.id,
|
actorId: request.employee.id,
|
||||||
action: "NOTIFICATION_CHANNEL_TESTED",
|
action: 'NOTIFICATION_CHANNEL_TESTED',
|
||||||
targetType: "notification_channel",
|
targetType: 'notification_channel',
|
||||||
targetId: channel.id,
|
targetId: channel.id,
|
||||||
extra: {
|
extra: {
|
||||||
channelType: channel.channel_type,
|
channelType: channel.channel_type,
|
||||||
@@ -299,7 +498,99 @@ async function notificationsRoutes(fastify) {
|
|||||||
|
|
||||||
return sendData(reply, request, {
|
return sendData(reply, request, {
|
||||||
channel: mapNotificationChannel(channel),
|
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,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
+20
-132
@@ -1,19 +1,17 @@
|
|||||||
const nodemailer = require("nodemailer");
|
const nodemailer = require('nodemailer');
|
||||||
|
|
||||||
const { sendInstantMessageByChannel } = require("../notifications/channels");
|
|
||||||
|
|
||||||
function resolveEmailBaseUrl(config) {
|
function resolveEmailBaseUrl(config) {
|
||||||
const explicit = String(config.emailBaseUrl ?? "").trim();
|
const explicit = String(config.emailBaseUrl ?? '').trim();
|
||||||
if (explicit) {
|
if (explicit) {
|
||||||
return explicit.replace(/\/+$/, "");
|
return explicit.replace(/\/+$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
const origin = String(config.appOrigin ?? "").split(",")[0]?.trim?.() ?? "";
|
const origin = String(config.appOrigin ?? '').split(',')[0]?.trim?.() ?? '';
|
||||||
if (origin) {
|
if (origin) {
|
||||||
return origin.replace(/\/+$/, "");
|
return origin.replace(/\/+$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
return "";
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
let cachedTransportKey = null;
|
let cachedTransportKey = null;
|
||||||
@@ -28,8 +26,8 @@ function getTransporter(config) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const authUser = String(config.smtpUser ?? "").trim();
|
const authUser = String(config.smtpUser ?? '').trim();
|
||||||
const authPass = String(config.smtpPass ?? "").trim();
|
const authPass = String(config.smtpPass ?? '').trim();
|
||||||
|
|
||||||
const key = JSON.stringify({
|
const key = JSON.stringify({
|
||||||
host: config.smtpHost,
|
host: config.smtpHost,
|
||||||
@@ -59,14 +57,9 @@ function uniq(values) {
|
|||||||
|
|
||||||
async function sendNotificationEmailSafe(params) {
|
async function sendNotificationEmailSafe(params) {
|
||||||
try {
|
try {
|
||||||
const [emailResult, imResult] = await Promise.all([
|
const emailResult = await sendNotificationEmail(params);
|
||||||
sendNotificationEmail(params),
|
|
||||||
sendNotificationIm(params),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
email: emailResult,
|
email: emailResult,
|
||||||
im: imResult,
|
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
params.logger?.error?.(
|
params.logger?.error?.(
|
||||||
@@ -76,11 +69,10 @@ async function sendNotificationEmailSafe(params) {
|
|||||||
notificationType: params.type,
|
notificationType: params.type,
|
||||||
recipients: params.recipientEmployeeIds?.length ?? 0,
|
recipients: params.recipientEmployeeIds?.length ?? 0,
|
||||||
},
|
},
|
||||||
"Failed to send notification",
|
'Failed to send notification email',
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
email: { skipped: false, sent: 0, failed: params.recipientEmployeeIds?.length ?? 0 },
|
email: { skipped: false, sent: 0, failed: params.recipientEmployeeIds?.length ?? 0 },
|
||||||
im: { skipped: false, sent: 0, failed: params.recipientEmployeeIds?.length ?? 0 },
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,7 +85,7 @@ async function sendNotificationEmail({
|
|||||||
recipientEmployeeIds,
|
recipientEmployeeIds,
|
||||||
subject,
|
subject,
|
||||||
body,
|
body,
|
||||||
type = "notification",
|
type = 'notification',
|
||||||
targetPath,
|
targetPath,
|
||||||
}) {
|
}) {
|
||||||
if (!config.emailEnabled) {
|
if (!config.emailEnabled) {
|
||||||
@@ -102,7 +94,7 @@ async function sendNotificationEmail({
|
|||||||
|
|
||||||
const transporter = getTransporter(config);
|
const transporter = getTransporter(config);
|
||||||
if (!transporter) {
|
if (!transporter) {
|
||||||
logger?.warn?.({ requestId }, "Email enabled but transporter not configured");
|
logger?.warn?.({ requestId }, 'Email enabled but transporter not configured');
|
||||||
return { skipped: true, sent: 0, failed: 0 };
|
return { skipped: true, sent: 0, failed: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +106,7 @@ async function sendNotificationEmail({
|
|||||||
const employees = await prisma.employee.findMany({
|
const employees = await prisma.employee.findMany({
|
||||||
where: {
|
where: {
|
||||||
id: { in: recipients },
|
id: { in: recipients },
|
||||||
status: "active",
|
status: 'active',
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -123,16 +115,16 @@ async function sendNotificationEmail({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emailFrom = String(config.emailFrom ?? "").trim();
|
const emailFrom = String(config.emailFrom ?? '').trim();
|
||||||
if (!emailFrom) {
|
if (!emailFrom) {
|
||||||
logger?.warn?.({ requestId }, "Email enabled but EMAIL_FROM is empty");
|
logger?.warn?.({ requestId }, 'Email enabled but EMAIL_FROM is empty');
|
||||||
return { skipped: true, sent: 0, failed: 0 };
|
return { skipped: true, sent: 0, failed: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = resolveEmailBaseUrl(config);
|
const baseUrl = resolveEmailBaseUrl(config);
|
||||||
const link = targetPath && baseUrl ? `${baseUrl}${targetPath}` : null;
|
const link = targetPath && baseUrl ? `${baseUrl}${targetPath}` : null;
|
||||||
const safeSubject = String(subject ?? "").trim().slice(0, 200) || "系统通知";
|
const safeSubject = String(subject ?? '').trim().slice(0, 200) || '系统通知';
|
||||||
const safeBody = String(body ?? "").trim();
|
const safeBody = String(body ?? '').trim();
|
||||||
|
|
||||||
let sent = 0;
|
let sent = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
@@ -152,8 +144,8 @@ async function sendNotificationEmail({
|
|||||||
subject: safeSubject,
|
subject: safeSubject,
|
||||||
text,
|
text,
|
||||||
headers: {
|
headers: {
|
||||||
"x-hyb-notification-type": type,
|
'x-hyb-notification-type': type,
|
||||||
"x-request-id": requestId ?? "",
|
'x-request-id': requestId ?? '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
sent += 1;
|
sent += 1;
|
||||||
@@ -167,111 +159,7 @@ async function sendNotificationEmail({
|
|||||||
email: employee.email,
|
email: employee.email,
|
||||||
notificationType: type,
|
notificationType: type,
|
||||||
},
|
},
|
||||||
"Email send failed",
|
'Email send failed',
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { skipped: false, sent, failed };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendNotificationIm({
|
|
||||||
prisma,
|
|
||||||
config,
|
|
||||||
logger,
|
|
||||||
requestId,
|
|
||||||
recipientEmployeeIds,
|
|
||||||
subject,
|
|
||||||
body,
|
|
||||||
type = "notification",
|
|
||||||
targetPath,
|
|
||||||
}) {
|
|
||||||
if (!config.imNotifierEnabled) {
|
|
||||||
return { skipped: true, sent: 0, failed: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const notificationType = String(type ?? "notification");
|
|
||||||
const allowedTypePrefixes = ["reimbursement."];
|
|
||||||
const allowedTypes = new Set(["workflow.task_assigned"]);
|
|
||||||
|
|
||||||
const allowed =
|
|
||||||
allowedTypes.has(notificationType) ||
|
|
||||||
allowedTypePrefixes.some((prefix) => notificationType.startsWith(prefix));
|
|
||||||
|
|
||||||
if (!allowed) {
|
|
||||||
return { skipped: true, sent: 0, failed: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const recipients = uniq(recipientEmployeeIds);
|
|
||||||
if (!recipients.length) {
|
|
||||||
return { skipped: true, sent: 0, failed: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const channels = await prisma.notificationChannel.findMany({
|
|
||||||
where: {
|
|
||||||
enabled: true,
|
|
||||||
channel_type: {
|
|
||||||
in: ["dingtalk", "wechat", "wechat_work", "wecom"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
channel_type: true,
|
|
||||||
name: true,
|
|
||||||
enabled: true,
|
|
||||||
config_json: true,
|
|
||||||
template_json: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!channels.length) {
|
|
||||||
return { skipped: true, sent: 0, failed: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const safeSubject = String(subject ?? "").trim().slice(0, 200) || "系统通知";
|
|
||||||
const safeBody = String(body ?? "").trim();
|
|
||||||
|
|
||||||
let sent = 0;
|
|
||||||
let failed = 0;
|
|
||||||
|
|
||||||
for (const channel of channels) {
|
|
||||||
try {
|
|
||||||
const result = await sendInstantMessageByChannel({
|
|
||||||
config,
|
|
||||||
channel,
|
|
||||||
payload: {
|
|
||||||
title: safeSubject,
|
|
||||||
body: safeBody,
|
|
||||||
targetType: type,
|
|
||||||
targetId: targetPath ?? null,
|
|
||||||
recipientCount: recipients.length,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.ok) {
|
|
||||||
sent += 1;
|
|
||||||
} else {
|
|
||||||
failed += 1;
|
|
||||||
logger?.warn?.(
|
|
||||||
{
|
|
||||||
requestId,
|
|
||||||
channelId: channel.id,
|
|
||||||
channelType: channel.channel_type,
|
|
||||||
resultCode: result.code,
|
|
||||||
},
|
|
||||||
"IM notification skipped",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
failed += 1;
|
|
||||||
logger?.error?.(
|
|
||||||
{
|
|
||||||
err: error,
|
|
||||||
requestId,
|
|
||||||
channelId: channel.id,
|
|
||||||
channelType: channel.channel_type,
|
|
||||||
},
|
|
||||||
"IM notification send failed",
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
const { pushPaidReimbursementToErp } = require("./erp");
|
const { pushPaidReimbursementToErp } = require('./erp');
|
||||||
|
const { sendInstantMessageByChannel } = require('../notifications/channels');
|
||||||
|
|
||||||
class IntegrationDispatchError extends Error {
|
class IntegrationDispatchError extends Error {
|
||||||
constructor({ code, message, retryable, statusCode, response }) {
|
constructor({ code, message, retryable, statusCode, response }) {
|
||||||
super(message || code || "integration_dispatch_failed");
|
super(message || code || 'integration_dispatch_failed');
|
||||||
this.name = "IntegrationDispatchError";
|
this.name = 'IntegrationDispatchError';
|
||||||
this.code = code || "integration_dispatch_failed";
|
this.code = code || 'integration_dispatch_failed';
|
||||||
this.retryable = typeof retryable === "boolean" ? retryable : true;
|
this.retryable = typeof retryable === 'boolean' ? retryable : true;
|
||||||
this.statusCode = Number.isFinite(statusCode) ? Number(statusCode) : null;
|
this.statusCode = Number.isFinite(statusCode) ? Number(statusCode) : null;
|
||||||
this.response = response ?? null;
|
this.response = response ?? null;
|
||||||
}
|
}
|
||||||
@@ -21,32 +22,57 @@ function toDispatchError(result) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function dispatchOutboxEvent({ config, eventType, payload }) {
|
async function dispatchOutboxEvent({ prisma, config, eventType, payload }) {
|
||||||
if (eventType === "erp.reimbursement.paid") {
|
if (eventType === 'erp.reimbursement.paid') {
|
||||||
const result = await pushPaidReimbursementToErp(config, payload);
|
const result = await pushPaidReimbursementToErp(config, payload);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
throw toDispatchError(result);
|
throw toDispatchError(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
channel: "erp",
|
channel: 'erp',
|
||||||
code: result.code,
|
code: result.code,
|
||||||
response: result.response ?? null,
|
response: result.response ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eventType === "notification.im") {
|
if (eventType === 'notification.im') {
|
||||||
|
const channelId = String(payload?.channelId ?? '').trim();
|
||||||
|
const channel = channelId
|
||||||
|
? await prisma.notificationChannel.findUnique({
|
||||||
|
where: { id: channelId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
channel_type: true,
|
||||||
|
name: true,
|
||||||
|
enabled: true,
|
||||||
|
config_json: true,
|
||||||
|
template_json: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const result = await sendInstantMessageByChannel({
|
||||||
|
config,
|
||||||
|
channel,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
throw toDispatchError(result);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
channel: "im",
|
channel: 'im',
|
||||||
code: "mock_sent",
|
code: result.code,
|
||||||
response: {
|
response: result.response ?? null,
|
||||||
channelType: payload.channelType,
|
skipped: result.skipped ?? false,
|
||||||
},
|
previewMessage: result.previewMessage ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new IntegrationDispatchError({
|
throw new IntegrationDispatchError({
|
||||||
code: "unsupported_outbox_event",
|
code: 'unsupported_outbox_event',
|
||||||
message: `unsupported_outbox_event:${eventType}`,
|
message: `unsupported_outbox_event:${eventType}`,
|
||||||
retryable: false,
|
retryable: false,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
const crypto = require('node:crypto');
|
||||||
|
|
||||||
|
function toSafeString(value, maxLength = 2000) {
|
||||||
|
if (value == null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return String(value).trim().slice(0, maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWecomMarkdownMessage(message) {
|
||||||
|
const title = toSafeString(message?.title, 200);
|
||||||
|
const body = toSafeString(message?.body, 4000);
|
||||||
|
const detailUrl = toSafeString(message?.detailUrl, 2000);
|
||||||
|
const footer = toSafeString(message?.footer, 200);
|
||||||
|
const deepLinkLabel = toSafeString(message?.deepLinkLabel || '查看详情', 50) || '查看详情';
|
||||||
|
|
||||||
|
const lines = [];
|
||||||
|
if (title) {
|
||||||
|
lines.push(`# ${title}`);
|
||||||
|
}
|
||||||
|
if (body) {
|
||||||
|
lines.push(body);
|
||||||
|
}
|
||||||
|
if (detailUrl) {
|
||||||
|
lines.push(`[${deepLinkLabel}](${detailUrl})`);
|
||||||
|
}
|
||||||
|
if (footer) {
|
||||||
|
lines.push(footer);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.filter(Boolean).join('\n\n').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSignedWebhookUrl(webhookUrl, secret) {
|
||||||
|
const rawUrl = String(webhookUrl ?? '').trim();
|
||||||
|
if (!rawUrl || !secret) {
|
||||||
|
return rawUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const signBase = `${timestamp}\n${secret}`;
|
||||||
|
const sign = encodeURIComponent(
|
||||||
|
crypto.createHmac('sha256', secret).update(signBase).digest('base64'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const separator = rawUrl.includes('?') ? '&' : '?';
|
||||||
|
return `${rawUrl}${separator}timestamp=${timestamp}&sign=${sign}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendWecomMessage({ channel, message }) {
|
||||||
|
const configJson = channel?.config_json && typeof channel.config_json === 'object' ? channel.config_json : {};
|
||||||
|
const mode = String(configJson.mode ?? 'mock').trim() || 'mock';
|
||||||
|
const previewMessage = buildWecomMarkdownMessage(message);
|
||||||
|
|
||||||
|
if (mode === 'mock') {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
skipped: false,
|
||||||
|
code: 'mock_sent',
|
||||||
|
response: {
|
||||||
|
channelType: 'wecom',
|
||||||
|
mode,
|
||||||
|
},
|
||||||
|
previewMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode !== 'wecom_webhook') {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
skipped: false,
|
||||||
|
retryable: false,
|
||||||
|
code: 'invalid_channel_mode',
|
||||||
|
message: `Unsupported wecom mode: ${mode}`,
|
||||||
|
response: null,
|
||||||
|
previewMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const webhookUrl = String(configJson.webhookUrl ?? '').trim();
|
||||||
|
if (!webhookUrl) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
skipped: false,
|
||||||
|
retryable: false,
|
||||||
|
code: 'wecom_webhook_missing',
|
||||||
|
message: 'WeCom webhook url missing',
|
||||||
|
response: null,
|
||||||
|
previewMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutMs = Math.max(100, Number(configJson.timeoutMs ?? 5000));
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(buildSignedWebhookUrl(webhookUrl, configJson.secret ?? null), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
msgtype: 'markdown',
|
||||||
|
markdown: {
|
||||||
|
content: previewMessage,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
let responseBody = null;
|
||||||
|
try {
|
||||||
|
responseBody = await response.json();
|
||||||
|
} catch {
|
||||||
|
responseBody = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
skipped: false,
|
||||||
|
retryable: response.status >= 500 || response.status === 429,
|
||||||
|
code: response.status >= 500 ? 'wecom_http_5xx' : response.status === 429 ? 'wecom_http_429' : 'wecom_http_4xx',
|
||||||
|
message: `WeCom webhook HTTP ${response.status}`,
|
||||||
|
response: {
|
||||||
|
status: response.status,
|
||||||
|
body: responseBody,
|
||||||
|
},
|
||||||
|
previewMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseBody && Number(responseBody.errcode ?? 0) !== 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
skipped: false,
|
||||||
|
retryable: false,
|
||||||
|
code: 'wecom_api_error',
|
||||||
|
message: `WeCom API errcode ${responseBody.errcode}`,
|
||||||
|
response: responseBody,
|
||||||
|
previewMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
skipped: false,
|
||||||
|
code: 'wecom_sent',
|
||||||
|
response: responseBody,
|
||||||
|
previewMessage,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name === 'AbortError') {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
skipped: false,
|
||||||
|
retryable: true,
|
||||||
|
code: 'wecom_timeout',
|
||||||
|
message: `WeCom timeout (${timeoutMs}ms)`,
|
||||||
|
response: null,
|
||||||
|
previewMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
skipped: false,
|
||||||
|
retryable: true,
|
||||||
|
code: 'wecom_network_error',
|
||||||
|
message: String(error?.message ?? error ?? 'WeCom network error'),
|
||||||
|
response: null,
|
||||||
|
previewMessage,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildWecomMarkdownMessage,
|
||||||
|
sendWecomMessage,
|
||||||
|
};
|
||||||
@@ -1,75 +1,93 @@
|
|||||||
const { dispatchOutboxEvent } = require("../integrations/dispatcher");
|
const { sendWecomMessage } = require('../integrations/im/wecom');
|
||||||
|
const { isImChannelTypeSupported, normalizeChannelType } = require('./imRouting');
|
||||||
|
|
||||||
function applyTemplate(template, context) {
|
function applyTemplate(template, context) {
|
||||||
if (typeof template !== "string") {
|
if (typeof template !== 'string') {
|
||||||
return "";
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return template.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, key) => {
|
return template.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, key) => {
|
||||||
const value = context[key];
|
const value = context[key];
|
||||||
return value == null ? "" : String(value);
|
return value == null ? '' : String(value);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildNotificationMessage(channel, payload) {
|
function buildNotificationMessage(channel, payload) {
|
||||||
const context = {
|
|
||||||
title: String(payload?.title ?? "系统通知").slice(0, 200),
|
|
||||||
body: String(payload?.body ?? "").slice(0, 2000),
|
|
||||||
targetType: payload?.targetType ?? "",
|
|
||||||
targetId: payload?.targetId ?? "",
|
|
||||||
};
|
|
||||||
|
|
||||||
const template =
|
const template =
|
||||||
channel?.template_json && typeof channel.template_json === "object"
|
channel?.template_json && typeof channel.template_json === 'object'
|
||||||
? channel.template_json
|
? channel.template_json
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const templatedTitle = template?.title ? applyTemplate(template.title, context) : "";
|
const context = {
|
||||||
const templatedBody = template?.body ? applyTemplate(template.body, context) : "";
|
title: String(payload?.title ?? '系统通知').slice(0, 200),
|
||||||
|
body: String(payload?.body ?? '').slice(0, 4000),
|
||||||
|
targetType: payload?.targetType ?? '',
|
||||||
|
targetId: payload?.targetId ?? '',
|
||||||
|
detailUrl: payload?.detailUrl ?? '',
|
||||||
|
footer: payload?.footer ?? '来自海音达工作台',
|
||||||
|
deepLinkLabel: payload?.deepLinkLabel ?? '查看详情',
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: (templatedTitle || context.title).slice(0, 200),
|
title: (template?.title ? applyTemplate(template.title, context) : context.title).slice(0, 200),
|
||||||
body: (templatedBody || context.body).slice(0, 2000),
|
body: (template?.body ? applyTemplate(template.body, context) : context.body).slice(0, 4000),
|
||||||
|
detailUrl: context.detailUrl || null,
|
||||||
|
footer: (template?.footer ? applyTemplate(template.footer, context) : context.footer).slice(0, 200),
|
||||||
|
deepLinkLabel: String(template?.deepLinkLabel ?? context.deepLinkLabel).slice(0, 50) || '查看详情',
|
||||||
targetType: context.targetType || null,
|
targetType: context.targetType || null,
|
||||||
targetId: context.targetId || null,
|
targetId: context.targetId || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendInstantMessageByChannel({ config, channel, payload }) {
|
async function sendInstantMessageByChannel({ config, channel, payload }) {
|
||||||
if (!config.imNotifierEnabled) {
|
const normalizedChannelType = normalizeChannelType(channel?.channel_type ?? payload?.channelType ?? '');
|
||||||
|
|
||||||
|
if (!config?.imNotifierEnabled) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: true,
|
||||||
code: "im_notifier_disabled",
|
skipped: true,
|
||||||
message: "IM notifier disabled",
|
code: 'im_notifier_disabled',
|
||||||
|
response: { channelType: normalizedChannelType || null },
|
||||||
|
previewMessage: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!channel?.enabled) {
|
if (!channel) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: true,
|
||||||
code: "channel_disabled",
|
skipped: true,
|
||||||
message: "notification channel disabled",
|
code: 'channel_not_found',
|
||||||
|
response: { channelType: normalizedChannelType || null },
|
||||||
|
previewMessage: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!channel.enabled) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
skipped: true,
|
||||||
|
code: 'channel_disabled',
|
||||||
|
response: { channelType: normalizedChannelType || null },
|
||||||
|
previewMessage: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isImChannelTypeSupported(channel.channel_type)) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
skipped: true,
|
||||||
|
code: 'unsupported_channel_type',
|
||||||
|
response: { channelType: normalizedChannelType || null },
|
||||||
|
previewMessage: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = buildNotificationMessage(channel, payload);
|
const message = buildNotificationMessage(channel, payload);
|
||||||
const result = await dispatchOutboxEvent({
|
return sendWecomMessage({ channel, message });
|
||||||
config,
|
|
||||||
eventType: "notification.im",
|
|
||||||
payload: {
|
|
||||||
channelType: channel.channel_type,
|
|
||||||
channelName: channel.name,
|
|
||||||
message,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
code: result.code,
|
|
||||||
response: result.response,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
applyTemplate,
|
||||||
|
buildNotificationMessage,
|
||||||
sendInstantMessageByChannel,
|
sendInstantMessageByChannel,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
const { enqueueIntegrationEvent } = require('../integrations/outbox');
|
||||||
|
const {
|
||||||
|
IM_SUPPORTED_NOTIFICATION_TYPES,
|
||||||
|
resolveNotificationDomainScope,
|
||||||
|
} = require('../../constants/notificationTypes');
|
||||||
|
const {
|
||||||
|
matchesNotificationChannel,
|
||||||
|
normalizeChannelType,
|
||||||
|
} = require('./imRouting');
|
||||||
|
|
||||||
|
function uniq(values) {
|
||||||
|
return Array.from(new Set((values ?? []).filter(Boolean)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBaseUrl(config) {
|
||||||
|
const origin = String(config?.appOrigin ?? '').split(',')[0]?.trim?.() ?? '';
|
||||||
|
return origin ? origin.replace(/\/+$/, '') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDetailUrl(config, targetPath) {
|
||||||
|
const path = String(targetPath ?? '').trim();
|
||||||
|
if (!path) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = resolveBaseUrl(config);
|
||||||
|
if (!baseUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveRecipientRoleCodes(tx, recipientEmployeeIds) {
|
||||||
|
const recipientIds = uniq(recipientEmployeeIds);
|
||||||
|
if (!recipientIds.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await tx.employeeRole.findMany({
|
||||||
|
where: {
|
||||||
|
employee_id: { in: recipientIds },
|
||||||
|
employee: {
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
role_code: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return uniq(rows.map((row) => row.role_code));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildImDedupeKey({ notificationType, targetType, targetId, targetPath, channelId }) {
|
||||||
|
return [
|
||||||
|
notificationType,
|
||||||
|
targetType,
|
||||||
|
targetId || targetPath || 'no-target',
|
||||||
|
channelId,
|
||||||
|
].join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enqueueImNotifications(tx, params) {
|
||||||
|
const {
|
||||||
|
config,
|
||||||
|
logger,
|
||||||
|
recipientEmployeeIds,
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
targetType,
|
||||||
|
targetId,
|
||||||
|
targetPath,
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
actorId,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
if (!config?.imNotifierEnabled) {
|
||||||
|
return { queued: 0, duplicated: 0, matchedChannels: [], skippedReason: 'im_notifier_disabled' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const notificationType = String(type ?? '').trim();
|
||||||
|
if (!IM_SUPPORTED_NOTIFICATION_TYPES.includes(notificationType)) {
|
||||||
|
return { queued: 0, duplicated: 0, matchedChannels: [], skippedReason: 'notification_type_not_supported' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipientIds = uniq(recipientEmployeeIds);
|
||||||
|
if (!recipientIds.length) {
|
||||||
|
return { queued: 0, duplicated: 0, matchedChannels: [], skippedReason: 'no_recipients' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const domainScope = resolveNotificationDomainScope(notificationType, targetType ?? null);
|
||||||
|
if (!domainScope) {
|
||||||
|
return { queued: 0, duplicated: 0, matchedChannels: [], skippedReason: 'domain_scope_unresolved' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailUrl = buildDetailUrl(config, targetPath ?? null);
|
||||||
|
if (!detailUrl && targetPath) {
|
||||||
|
logger?.warn?.(
|
||||||
|
{
|
||||||
|
notificationType,
|
||||||
|
targetPath,
|
||||||
|
},
|
||||||
|
'APP_ORIGIN missing, IM detailUrl omitted',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipientRoleCodes = await resolveRecipientRoleCodes(tx, recipientIds);
|
||||||
|
const channels = await tx.notificationChannel.findMany({
|
||||||
|
where: {
|
||||||
|
enabled: true,
|
||||||
|
channel_type: {
|
||||||
|
in: ['wecom', 'wechat_work'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ channel_type: 'asc' }, { name: 'asc' }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
channel_type: true,
|
||||||
|
name: true,
|
||||||
|
enabled: true,
|
||||||
|
config_json: true,
|
||||||
|
template_json: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const matchedChannels = channels.filter((channel) =>
|
||||||
|
matchesNotificationChannel(channel, {
|
||||||
|
notificationType,
|
||||||
|
domainScope,
|
||||||
|
recipientRoleCodes,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!matchedChannels.length) {
|
||||||
|
return { queued: 0, duplicated: 0, matchedChannels: [], skippedReason: 'no_matching_channel' };
|
||||||
|
}
|
||||||
|
|
||||||
|
let queued = 0;
|
||||||
|
let duplicated = 0;
|
||||||
|
for (const channel of matchedChannels) {
|
||||||
|
const result = await enqueueIntegrationEvent(tx, {
|
||||||
|
eventType: 'notification.im',
|
||||||
|
dedupeKey: buildImDedupeKey({
|
||||||
|
notificationType,
|
||||||
|
targetType,
|
||||||
|
targetId,
|
||||||
|
targetPath,
|
||||||
|
channelId: channel.id,
|
||||||
|
}),
|
||||||
|
maxAttempts: config.integrationOutboxMaxAttempts,
|
||||||
|
actorId: actorId ?? null,
|
||||||
|
payload: {
|
||||||
|
channelId: channel.id,
|
||||||
|
channelType: normalizeChannelType(channel.channel_type),
|
||||||
|
notificationType,
|
||||||
|
domainScope,
|
||||||
|
title: String(title ?? '').slice(0, 200),
|
||||||
|
body: body == null ? '' : String(body).slice(0, 4000),
|
||||||
|
targetType: targetType ?? null,
|
||||||
|
targetId: targetId ?? null,
|
||||||
|
targetPath: targetPath ?? null,
|
||||||
|
detailUrl,
|
||||||
|
recipientEmployeeIds: recipientIds,
|
||||||
|
recipientRoleCodes,
|
||||||
|
entityType: entityType ?? targetType ?? null,
|
||||||
|
entityId: entityId ?? targetId ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.duplicated) {
|
||||||
|
duplicated += 1;
|
||||||
|
} else {
|
||||||
|
queued += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
queued,
|
||||||
|
duplicated,
|
||||||
|
matchedChannels: matchedChannels.map((channel) => channel.id),
|
||||||
|
skippedReason: null,
|
||||||
|
recipientRoleCodes,
|
||||||
|
domainScope,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildDetailUrl,
|
||||||
|
buildImDedupeKey,
|
||||||
|
resolveRecipientRoleCodes,
|
||||||
|
enqueueImNotifications,
|
||||||
|
};
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
const { IM_SUPPORTED_NOTIFICATION_TYPES } = require('../../constants/notificationTypes');
|
||||||
|
|
||||||
|
const CANONICAL_CHANNEL_TYPE_MAP = Object.freeze({
|
||||||
|
wechat_work: 'wecom',
|
||||||
|
wecom: 'wecom',
|
||||||
|
});
|
||||||
|
|
||||||
|
function uniq(values) {
|
||||||
|
return Array.from(new Set((values ?? []).filter(Boolean)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeChannelType(channelType) {
|
||||||
|
const normalized = String(channelType ?? '').trim().toLowerCase();
|
||||||
|
return CANONICAL_CHANNEL_TYPE_MAP[normalized] ?? normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImChannelTypeSupported(channelType) {
|
||||||
|
return normalizeChannelType(channelType) === 'wecom';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRouting(configJson) {
|
||||||
|
const config = configJson && typeof configJson === 'object' ? configJson : {};
|
||||||
|
const routing = config.routing && typeof config.routing === 'object' ? config.routing : {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
roleCodes: uniq((Array.isArray(routing.roleCodes) ? routing.roleCodes : []).map((item) => String(item).trim()).filter(Boolean)),
|
||||||
|
domainScopes: uniq((Array.isArray(routing.domainScopes) ? routing.domainScopes : []).map((item) => String(item).trim()).filter(Boolean)),
|
||||||
|
allowedTypes: uniq((Array.isArray(routing.allowedTypes) ? routing.allowedTypes : []).map((item) => String(item).trim()).filter(Boolean)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasIntersection(left, right) {
|
||||||
|
const rightSet = new Set(right ?? []);
|
||||||
|
return (left ?? []).some((item) => rightSet.has(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesNotificationChannel(channel, context) {
|
||||||
|
if (!channel?.enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isImChannelTypeSupported(channel.channel_type)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const notificationType = String(context?.notificationType ?? '').trim();
|
||||||
|
if (!IM_SUPPORTED_NOTIFICATION_TYPES.includes(notificationType)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const routing = normalizeRouting(channel.config_json);
|
||||||
|
if (routing.allowedTypes.length > 0 && !routing.allowedTypes.includes(notificationType)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const domainScope = String(context?.domainScope ?? '').trim();
|
||||||
|
if (routing.domainScopes.length > 0 && !routing.domainScopes.includes(domainScope)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipientRoleCodes = uniq((context?.recipientRoleCodes ?? []).map((item) => String(item).trim()).filter(Boolean));
|
||||||
|
if (routing.roleCodes.length > 0 && !hasIntersection(routing.roleCodes, recipientRoleCodes)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
normalizeChannelType,
|
||||||
|
isImChannelTypeSupported,
|
||||||
|
normalizeRouting,
|
||||||
|
matchesNotificationChannel,
|
||||||
|
};
|
||||||
@@ -4,6 +4,13 @@ const {
|
|||||||
} = require("../../src/services/integrations/dispatcher");
|
} = require("../../src/services/integrations/dispatcher");
|
||||||
|
|
||||||
describe("dispatchOutboxEvent", () => {
|
describe("dispatchOutboxEvent", () => {
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it("handles mock ERP success", async () => {
|
it("handles mock ERP success", async () => {
|
||||||
const result = await dispatchOutboxEvent({
|
const result = await dispatchOutboxEvent({
|
||||||
config: {
|
config: {
|
||||||
@@ -22,58 +29,235 @@ describe("dispatchOutboxEvent", () => {
|
|||||||
expect(result.response.externalId).toContain("BX-001");
|
expect(result.response.externalId).toContain("BX-001");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps ERP disabled as non-retryable dispatch error", async () => {
|
it("loads IM channel and dispatches mock wecom message", async () => {
|
||||||
await expect(
|
const prisma = {
|
||||||
dispatchOutboxEvent({
|
notificationChannel: {
|
||||||
config: {
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
erpSyncEnabled: false,
|
id: "channel-1",
|
||||||
erpSyncProvider: "mock",
|
channel_type: "wecom",
|
||||||
},
|
name: "采购经理群",
|
||||||
eventType: "erp.reimbursement.paid",
|
enabled: true,
|
||||||
payload: {
|
config_json: {
|
||||||
reimbursementId: "rb-2",
|
mode: "mock",
|
||||||
formNumber: "BX-002",
|
routing: {},
|
||||||
},
|
},
|
||||||
}),
|
template_json: {
|
||||||
).rejects.toMatchObject({
|
title: "{{title}}",
|
||||||
name: "IntegrationDispatchError",
|
body: "{{body}}",
|
||||||
code: "erp_sync_disabled",
|
footer: "来自海音达工作台",
|
||||||
retryable: false,
|
deepLinkLabel: "查看详情",
|
||||||
});
|
},
|
||||||
});
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
it("maps invalid ERP provider as non-retryable dispatch error", async () => {
|
|
||||||
await expect(
|
|
||||||
dispatchOutboxEvent({
|
|
||||||
config: {
|
|
||||||
erpSyncEnabled: true,
|
|
||||||
erpSyncProvider: "invalid-provider",
|
|
||||||
},
|
|
||||||
eventType: "erp.reimbursement.paid",
|
|
||||||
payload: {
|
|
||||||
reimbursementId: "rb-3",
|
|
||||||
formNumber: "BX-003",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).rejects.toMatchObject({
|
|
||||||
name: "IntegrationDispatchError",
|
|
||||||
code: "erp_sync_provider_invalid",
|
|
||||||
retryable: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles IM notification outbox", async () => {
|
|
||||||
const result = await dispatchOutboxEvent({
|
const result = await dispatchOutboxEvent({
|
||||||
config: {},
|
prisma,
|
||||||
|
config: {
|
||||||
|
imNotifierEnabled: true,
|
||||||
|
},
|
||||||
eventType: "notification.im",
|
eventType: "notification.im",
|
||||||
payload: {
|
payload: {
|
||||||
|
channelId: "channel-1",
|
||||||
|
channelType: "wecom",
|
||||||
|
notificationType: "workflow.task_assigned",
|
||||||
|
title: "【待办】采购申请待审批",
|
||||||
|
body: "采购申请已流转到你,请及时处理。",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prisma.notificationChannel.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { id: "channel-1" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
channel_type: true,
|
||||||
|
name: true,
|
||||||
|
enabled: true,
|
||||||
|
config_json: true,
|
||||||
|
template_json: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.channel).toBe("im");
|
||||||
|
expect(result.code).toBe("mock_sent");
|
||||||
|
expect(result.previewMessage).toContain("采购申请待审批");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("safely skips missing IM channel", async () => {
|
||||||
|
const result = await dispatchOutboxEvent({
|
||||||
|
prisma: {
|
||||||
|
notificationChannel: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
imNotifierEnabled: true,
|
||||||
|
},
|
||||||
|
eventType: "notification.im",
|
||||||
|
payload: {
|
||||||
|
channelId: "missing-channel",
|
||||||
channelType: "wecom",
|
channelType: "wecom",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.channel).toBe("im");
|
expect(result.channel).toBe("im");
|
||||||
expect(result.code).toBe("mock_sent");
|
expect(result.code).toBe("channel_not_found");
|
||||||
expect(result.response.channelType).toBe("wecom");
|
expect(result.skipped).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("safely skips disabled IM channel", async () => {
|
||||||
|
const result = await dispatchOutboxEvent({
|
||||||
|
prisma: {
|
||||||
|
notificationChannel: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
id: "channel-2",
|
||||||
|
channel_type: "wecom",
|
||||||
|
name: "disabled",
|
||||||
|
enabled: false,
|
||||||
|
config_json: { mode: "mock", routing: {} },
|
||||||
|
template_json: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
imNotifierEnabled: true,
|
||||||
|
},
|
||||||
|
eventType: "notification.im",
|
||||||
|
payload: {
|
||||||
|
channelId: "channel-2",
|
||||||
|
channelType: "wecom",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.code).toBe("channel_disabled");
|
||||||
|
expect(result.skipped).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps wecom 4xx as non-retryable IM dispatch error", async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
json: vi.fn().mockResolvedValue({ errcode: 40013, errmsg: "bad request" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
dispatchOutboxEvent({
|
||||||
|
prisma: {
|
||||||
|
notificationChannel: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
id: "channel-3",
|
||||||
|
channel_type: "wecom",
|
||||||
|
name: "wecom-4xx",
|
||||||
|
enabled: true,
|
||||||
|
config_json: {
|
||||||
|
mode: "wecom_webhook",
|
||||||
|
webhookUrl: "https://example.com/wecom",
|
||||||
|
timeoutMs: 500,
|
||||||
|
routing: {},
|
||||||
|
},
|
||||||
|
template_json: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
imNotifierEnabled: true,
|
||||||
|
},
|
||||||
|
eventType: "notification.im",
|
||||||
|
payload: {
|
||||||
|
channelId: "channel-3",
|
||||||
|
channelType: "wecom",
|
||||||
|
title: "test",
|
||||||
|
body: "body",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
name: "IntegrationDispatchError",
|
||||||
|
code: "wecom_http_4xx",
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps wecom 5xx as retryable IM dispatch error", async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 503,
|
||||||
|
json: vi.fn().mockResolvedValue({ errcode: 500, errmsg: "server error" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
dispatchOutboxEvent({
|
||||||
|
prisma: {
|
||||||
|
notificationChannel: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
id: "channel-4",
|
||||||
|
channel_type: "wecom",
|
||||||
|
name: "wecom-5xx",
|
||||||
|
enabled: true,
|
||||||
|
config_json: {
|
||||||
|
mode: "wecom_webhook",
|
||||||
|
webhookUrl: "https://example.com/wecom",
|
||||||
|
timeoutMs: 500,
|
||||||
|
routing: {},
|
||||||
|
},
|
||||||
|
template_json: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
imNotifierEnabled: true,
|
||||||
|
},
|
||||||
|
eventType: "notification.im",
|
||||||
|
payload: {
|
||||||
|
channelId: "channel-4",
|
||||||
|
channelType: "wecom",
|
||||||
|
title: "test",
|
||||||
|
body: "body",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
name: "IntegrationDispatchError",
|
||||||
|
code: "wecom_http_5xx",
|
||||||
|
retryable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps wecom timeout as retryable IM dispatch error", async () => {
|
||||||
|
global.fetch = vi.fn().mockRejectedValue({ name: "AbortError" });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
dispatchOutboxEvent({
|
||||||
|
prisma: {
|
||||||
|
notificationChannel: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
id: "channel-5",
|
||||||
|
channel_type: "wecom",
|
||||||
|
name: "wecom-timeout",
|
||||||
|
enabled: true,
|
||||||
|
config_json: {
|
||||||
|
mode: "wecom_webhook",
|
||||||
|
webhookUrl: "https://example.com/wecom",
|
||||||
|
timeoutMs: 500,
|
||||||
|
routing: {},
|
||||||
|
},
|
||||||
|
template_json: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
imNotifierEnabled: true,
|
||||||
|
},
|
||||||
|
eventType: "notification.im",
|
||||||
|
payload: {
|
||||||
|
channelId: "channel-5",
|
||||||
|
channelType: "wecom",
|
||||||
|
title: "test",
|
||||||
|
body: "body",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
name: "IntegrationDispatchError",
|
||||||
|
code: "wecom_timeout",
|
||||||
|
retryable: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws non-retryable error for unsupported event", async () => {
|
it("throws non-retryable error for unsupported event", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
const {
|
||||||
|
buildDetailUrl,
|
||||||
|
buildImDedupeKey,
|
||||||
|
enqueueImNotifications,
|
||||||
|
resolveRecipientRoleCodes,
|
||||||
|
} = require("../../../src/services/notifications/imEnqueue");
|
||||||
|
|
||||||
|
describe("IM enqueue", () => {
|
||||||
|
it("builds detail url from APP_ORIGIN", () => {
|
||||||
|
expect(buildDetailUrl({ appOrigin: "https://app.example.com" }, "/workflow/tasks/1")).toBe(
|
||||||
|
"https://app.example.com/workflow/tasks/1",
|
||||||
|
);
|
||||||
|
expect(buildDetailUrl({ appOrigin: "" }, "/workflow/tasks/1")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds dedupe key with target id first", () => {
|
||||||
|
expect(
|
||||||
|
buildImDedupeKey({
|
||||||
|
notificationType: "workflow.task_assigned",
|
||||||
|
targetType: "workflow_task",
|
||||||
|
targetId: "task-1",
|
||||||
|
targetPath: "/workflow/tasks/task-1",
|
||||||
|
channelId: "channel-1",
|
||||||
|
}),
|
||||||
|
).toBe("workflow.task_assigned:workflow_task:task-1:channel-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves unique recipient role codes", async () => {
|
||||||
|
const roles = await resolveRecipientRoleCodes(
|
||||||
|
{
|
||||||
|
employeeRole: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{ role_code: "manager" },
|
||||||
|
{ role_code: "manager" },
|
||||||
|
{ role_code: "finance_mgr" },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
["employee-1", "employee-2"],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(roles).toEqual(["manager", "finance_mgr"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enqueues one outbox event per matched channel", async () => {
|
||||||
|
const tx = {
|
||||||
|
employeeRole: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([{ role_code: "manager" }]),
|
||||||
|
},
|
||||||
|
notificationChannel: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "channel-1",
|
||||||
|
channel_type: "wecom",
|
||||||
|
name: "manager-workflow",
|
||||||
|
enabled: true,
|
||||||
|
config_json: {
|
||||||
|
routing: {
|
||||||
|
roleCodes: ["manager"],
|
||||||
|
domainScopes: ["workflow"],
|
||||||
|
allowedTypes: ["workflow.task_assigned"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template_json: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "channel-2",
|
||||||
|
channel_type: "wechat_work",
|
||||||
|
name: "all-workflow",
|
||||||
|
enabled: true,
|
||||||
|
config_json: {
|
||||||
|
routing: {
|
||||||
|
roleCodes: [],
|
||||||
|
domainScopes: ["workflow"],
|
||||||
|
allowedTypes: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template_json: null,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
integrationOutbox: {
|
||||||
|
findUnique: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(null)
|
||||||
|
.mockResolvedValueOnce({ id: "outbox-2", status: "pending" }),
|
||||||
|
create: vi.fn().mockImplementation(async ({ data }) => ({ id: `${data.dedupe_key}-id`, status: "pending" })),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await enqueueImNotifications(tx, {
|
||||||
|
config: {
|
||||||
|
imNotifierEnabled: true,
|
||||||
|
integrationOutboxMaxAttempts: 5,
|
||||||
|
appOrigin: "https://app.example.com",
|
||||||
|
},
|
||||||
|
logger: { warn: vi.fn() },
|
||||||
|
recipientEmployeeIds: ["employee-1"],
|
||||||
|
type: "workflow.task_assigned",
|
||||||
|
title: "【待办】采购申请待审批",
|
||||||
|
body: "采购申请已流转到你,请及时处理。",
|
||||||
|
targetType: "workflow_task",
|
||||||
|
targetId: "task-1",
|
||||||
|
targetPath: "/workflow/tasks/task-1",
|
||||||
|
entityType: "purchase_request",
|
||||||
|
entityId: "pr-1",
|
||||||
|
actorId: "actor-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.queued).toBe(1);
|
||||||
|
expect(result.duplicated).toBe(1);
|
||||||
|
expect(result.matchedChannels).toEqual(["channel-1", "channel-2"]);
|
||||||
|
expect(tx.integrationOutbox.create).toHaveBeenCalledTimes(1);
|
||||||
|
expect(tx.integrationOutbox.create.mock.calls[0][0].data.payload.detailUrl).toBe(
|
||||||
|
"https://app.example.com/workflow/tasks/task-1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("safely skips when no channel matches", async () => {
|
||||||
|
const tx = {
|
||||||
|
employeeRole: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([{ role_code: "manager" }]),
|
||||||
|
},
|
||||||
|
notificationChannel: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "channel-3",
|
||||||
|
channel_type: "wecom",
|
||||||
|
name: "finance-only",
|
||||||
|
enabled: true,
|
||||||
|
config_json: {
|
||||||
|
routing: {
|
||||||
|
roleCodes: ["finance_mgr"],
|
||||||
|
domainScopes: ["procurement"],
|
||||||
|
allowedTypes: ["purchase_request.approved"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template_json: null,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
integrationOutbox: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await enqueueImNotifications(tx, {
|
||||||
|
config: {
|
||||||
|
imNotifierEnabled: true,
|
||||||
|
integrationOutboxMaxAttempts: 5,
|
||||||
|
appOrigin: "https://app.example.com",
|
||||||
|
},
|
||||||
|
logger: { warn: vi.fn() },
|
||||||
|
recipientEmployeeIds: ["employee-1"],
|
||||||
|
type: "workflow.task_assigned",
|
||||||
|
title: "title",
|
||||||
|
body: "body",
|
||||||
|
targetType: "workflow_task",
|
||||||
|
targetId: "task-1",
|
||||||
|
targetPath: "/workflow/tasks/task-1",
|
||||||
|
entityType: "purchase_request",
|
||||||
|
entityId: "pr-1",
|
||||||
|
actorId: "actor-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.skippedReason).toBe("no_matching_channel");
|
||||||
|
expect(tx.integrationOutbox.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
const {
|
||||||
|
isImChannelTypeSupported,
|
||||||
|
matchesNotificationChannel,
|
||||||
|
normalizeChannelType,
|
||||||
|
normalizeRouting,
|
||||||
|
} = require("../../../src/services/notifications/imRouting");
|
||||||
|
|
||||||
|
describe("IM routing", () => {
|
||||||
|
it("normalizes wechat_work alias to wecom", () => {
|
||||||
|
expect(normalizeChannelType("wechat_work")).toBe("wecom");
|
||||||
|
expect(isImChannelTypeSupported("wecom")).toBe(true);
|
||||||
|
expect(isImChannelTypeSupported("dingtalk")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes routing arrays and drops empty values", () => {
|
||||||
|
expect(
|
||||||
|
normalizeRouting({
|
||||||
|
routing: {
|
||||||
|
roleCodes: ["manager", "manager", ""],
|
||||||
|
domainScopes: ["workflow", "workflow"],
|
||||||
|
allowedTypes: ["workflow.task_assigned", ""],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
roleCodes: ["manager"],
|
||||||
|
domainScopes: ["workflow"],
|
||||||
|
allowedTypes: ["workflow.task_assigned"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches channel when role scope type all hit", () => {
|
||||||
|
const matched = matchesNotificationChannel(
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
channel_type: "wecom",
|
||||||
|
config_json: {
|
||||||
|
routing: {
|
||||||
|
roleCodes: ["manager"],
|
||||||
|
domainScopes: ["workflow"],
|
||||||
|
allowedTypes: ["workflow.task_assigned"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
notificationType: "workflow.task_assigned",
|
||||||
|
domainScope: "workflow",
|
||||||
|
recipientRoleCodes: ["manager", "finance_mgr"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(matched).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects channel when any routing dimension does not match", () => {
|
||||||
|
const baseChannel = {
|
||||||
|
enabled: true,
|
||||||
|
channel_type: "wecom",
|
||||||
|
config_json: {
|
||||||
|
routing: {
|
||||||
|
roleCodes: ["manager"],
|
||||||
|
domainScopes: ["workflow"],
|
||||||
|
allowedTypes: ["workflow.task_assigned"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
matchesNotificationChannel(baseChannel, {
|
||||||
|
notificationType: "workflow.task_assigned",
|
||||||
|
domainScope: "procurement",
|
||||||
|
recipientRoleCodes: ["manager"],
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
matchesNotificationChannel(baseChannel, {
|
||||||
|
notificationType: "workflow.task_assigned",
|
||||||
|
domainScope: "workflow",
|
||||||
|
recipientRoleCodes: ["finance_mgr"],
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
matchesNotificationChannel(baseChannel, {
|
||||||
|
notificationType: "leave.approved",
|
||||||
|
domainScope: "workflow",
|
||||||
|
recipientRoleCodes: ["manager"],
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,42 +1,46 @@
|
|||||||
const dispatcher = require("../../../../src/services/integrations/dispatcher");
|
const dispatcher = require("../../../../src/services/integrations/dispatcher");
|
||||||
|
|
||||||
describe("Integration Dispatcher", () => {
|
describe("Integration Dispatcher", () => {
|
||||||
it("dispatches erp.reimbursement.paid", async () => {
|
it("dispatches erp.reimbursement.paid", async () => {
|
||||||
const result = await dispatcher.dispatchOutboxEvent({
|
const result = await dispatcher.dispatchOutboxEvent({
|
||||||
config: { erpSyncEnabled: true, erpSyncProvider: "mock" },
|
config: { erpSyncEnabled: true, erpSyncProvider: "mock" },
|
||||||
eventType: "erp.reimbursement.paid",
|
eventType: "erp.reimbursement.paid",
|
||||||
payload: { reimbursementId: 1 },
|
payload: { reimbursementId: "rb-1", formNumber: "BX-001" },
|
||||||
});
|
|
||||||
expect(result.channel).toBe("erp");
|
|
||||||
expect(result.code).toBe("mock_synced");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws IntegrationDispatchError if erp fails", async () => {
|
expect(result.channel).toBe("erp");
|
||||||
await expect(
|
expect(result.code).toBe("mock_synced");
|
||||||
dispatcher.dispatchOutboxEvent({
|
});
|
||||||
config: { erpSyncEnabled: false },
|
|
||||||
eventType: "erp.reimbursement.paid",
|
it("dispatches notification.im with mock channel", async () => {
|
||||||
payload: { reimbursementId: 1 },
|
const result = await dispatcher.dispatchOutboxEvent({
|
||||||
})
|
prisma: {
|
||||||
).rejects.toThrow(dispatcher.IntegrationDispatchError);
|
notificationChannel: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
id: "channel-1",
|
||||||
|
channel_type: "wecom",
|
||||||
|
name: "test",
|
||||||
|
enabled: true,
|
||||||
|
config_json: { mode: "mock", routing: {} },
|
||||||
|
template_json: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
config: { imNotifierEnabled: true },
|
||||||
|
eventType: "notification.im",
|
||||||
|
payload: { channelId: "channel-1", channelType: "wecom", title: "title", body: "body" },
|
||||||
});
|
});
|
||||||
|
|
||||||
it("dispatches notification.im", async () => {
|
expect(result.channel).toBe("im");
|
||||||
const result = await dispatcher.dispatchOutboxEvent({
|
expect(result.code).toBe("mock_sent");
|
||||||
config: {},
|
});
|
||||||
eventType: "notification.im",
|
|
||||||
payload: { channelType: "slack" },
|
|
||||||
});
|
|
||||||
expect(result.channel).toBe("im");
|
|
||||||
expect(result.response.channelType).toBe("slack");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws error for unsupported event", async () => {
|
it("throws error for unsupported event", async () => {
|
||||||
await expect(
|
await expect(
|
||||||
dispatcher.dispatchOutboxEvent({
|
dispatcher.dispatchOutboxEvent({
|
||||||
config: {},
|
config: {},
|
||||||
eventType: "unknown.event",
|
eventType: "unknown.event",
|
||||||
})
|
}),
|
||||||
).rejects.toThrow("unsupported_outbox_event:unknown.event");
|
).rejects.toThrow("unsupported_outbox_event:unknown.event");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户