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