feat(P19IM1-1,P19IM1-2): 新增通知渠道配置与Outbox派发

这个提交包含在:
2026-03-08 13:14:04 +08:00
父节点 43a0af0803
当前提交 f6d9f23553
共修改 13 个文件,包含 1582 行新增和 364 行删除
+57 -39
查看文件
@@ -1,75 +1,93 @@
const { dispatchOutboxEvent } = require("../integrations/dispatcher");
const { sendWecomMessage } = require('../integrations/im/wecom');
const { isImChannelTypeSupported, normalizeChannelType } = require('./imRouting');
function applyTemplate(template, context) {
if (typeof template !== "string") {
return "";
if (typeof template !== 'string') {
return '';
}
return template.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, key) => {
const value = context[key];
return value == null ? "" : String(value);
return value == null ? '' : String(value);
});
}
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 =
channel?.template_json && typeof channel.template_json === "object"
channel?.template_json && typeof channel.template_json === 'object'
? channel.template_json
: null;
const templatedTitle = template?.title ? applyTemplate(template.title, context) : "";
const templatedBody = template?.body ? applyTemplate(template.body, context) : "";
const 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 {
title: (templatedTitle || context.title).slice(0, 200),
body: (templatedBody || context.body).slice(0, 2000),
title: (template?.title ? applyTemplate(template.title, context) : context.title).slice(0, 200),
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,
targetId: context.targetId || null,
};
}
async function sendInstantMessageByChannel({ config, channel, payload }) {
if (!config.imNotifierEnabled) {
const normalizedChannelType = normalizeChannelType(channel?.channel_type ?? payload?.channelType ?? '');
if (!config?.imNotifierEnabled) {
return {
ok: false,
code: "im_notifier_disabled",
message: "IM notifier disabled",
ok: true,
skipped: true,
code: 'im_notifier_disabled',
response: { channelType: normalizedChannelType || null },
previewMessage: null,
};
}
if (!channel?.enabled) {
if (!channel) {
return {
ok: false,
code: "channel_disabled",
message: "notification channel disabled",
ok: true,
skipped: true,
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 result = await dispatchOutboxEvent({
config,
eventType: "notification.im",
payload: {
channelType: channel.channel_type,
channelName: channel.name,
message,
},
});
return {
ok: true,
code: result.code,
response: result.response,
};
return sendWecomMessage({ channel, message });
}
module.exports = {
applyTemplate,
buildNotificationMessage,
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,
};