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

这个提交包含在:
2026-03-08 13:14:04 +08:00
父节点 43a0af0803
当前提交 f6d9f23553
共修改 13 个文件,包含 1582 行新增和 364 行删除
+20 -132
查看文件
@@ -1,19 +1,17 @@
const nodemailer = require("nodemailer");
const { sendInstantMessageByChannel } = require("../notifications/channels");
const nodemailer = require('nodemailer');
function resolveEmailBaseUrl(config) {
const explicit = String(config.emailBaseUrl ?? "").trim();
const explicit = String(config.emailBaseUrl ?? '').trim();
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) {
return origin.replace(/\/+$/, "");
return origin.replace(/\/+$/, '');
}
return "";
return '';
}
let cachedTransportKey = null;
@@ -28,8 +26,8 @@ function getTransporter(config) {
return null;
}
const authUser = String(config.smtpUser ?? "").trim();
const authPass = String(config.smtpPass ?? "").trim();
const authUser = String(config.smtpUser ?? '').trim();
const authPass = String(config.smtpPass ?? '').trim();
const key = JSON.stringify({
host: config.smtpHost,
@@ -59,14 +57,9 @@ function uniq(values) {
async function sendNotificationEmailSafe(params) {
try {
const [emailResult, imResult] = await Promise.all([
sendNotificationEmail(params),
sendNotificationIm(params),
]);
const emailResult = await sendNotificationEmail(params);
return {
email: emailResult,
im: imResult,
};
} catch (error) {
params.logger?.error?.(
@@ -76,11 +69,10 @@ async function sendNotificationEmailSafe(params) {
notificationType: params.type,
recipients: params.recipientEmployeeIds?.length ?? 0,
},
"Failed to send notification",
'Failed to send notification email',
);
return {
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,
subject,
body,
type = "notification",
type = 'notification',
targetPath,
}) {
if (!config.emailEnabled) {
@@ -102,7 +94,7 @@ async function sendNotificationEmail({
const transporter = getTransporter(config);
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 };
}
@@ -114,7 +106,7 @@ async function sendNotificationEmail({
const employees = await prisma.employee.findMany({
where: {
id: { in: recipients },
status: "active",
status: 'active',
},
select: {
id: true,
@@ -123,16 +115,16 @@ async function sendNotificationEmail({
},
});
const emailFrom = String(config.emailFrom ?? "").trim();
const emailFrom = String(config.emailFrom ?? '').trim();
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 };
}
const baseUrl = resolveEmailBaseUrl(config);
const link = targetPath && baseUrl ? `${baseUrl}${targetPath}` : null;
const safeSubject = String(subject ?? "").trim().slice(0, 200) || "系统通知";
const safeBody = String(body ?? "").trim();
const safeSubject = String(subject ?? '').trim().slice(0, 200) || '系统通知';
const safeBody = String(body ?? '').trim();
let sent = 0;
let failed = 0;
@@ -152,8 +144,8 @@ async function sendNotificationEmail({
subject: safeSubject,
text,
headers: {
"x-hyb-notification-type": type,
"x-request-id": requestId ?? "",
'x-hyb-notification-type': type,
'x-request-id': requestId ?? '',
},
});
sent += 1;
@@ -167,111 +159,7 @@ async function sendNotificationEmail({
email: employee.email,
notificationType: type,
},
"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",
'Email send failed',
);
}
}