feat(P19IM1-1,P19IM1-2): 新增通知渠道配置与Outbox派发
这个提交包含在:
@@ -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,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户