R50 通知推送:dispatcher 投递服务、业务事件接入与员工推送开关

这个提交包含在:
2026-04-05 11:21:39 +08:00
父节点 ff0a677bf3
当前提交 47e10e6acc
共修改 24 个文件,包含 1701 行新增和 197 行删除
@@ -0,0 +1,349 @@
const { resolveNotificationDomainScope } = require('../constants/notificationTypes');
const { sendInstantMessageByChannel } = require('./notifications/channels');
const { buildDetailUrl } = require('./notifications/detailUrl');
const { matchesNotificationChannel } = require('./notifications/imRouting');
function uniq(values) {
return Array.from(new Set((values ?? []).filter(Boolean)));
}
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function normalizeJsonValue(value) {
if (value == null) {
return null;
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return value;
}
try {
return JSON.parse(JSON.stringify(value));
} catch {
return { value: String(value) };
}
}
function trimMessage(value, maxLength = 1000) {
return String(value ?? '').trim().slice(0, maxLength) || null;
}
function buildSkipResult(reason, extra = {}) {
return {
deliveredChannels: [],
skippedReason: reason,
notificationCount: 0,
eligibleRecipientCount: 0,
matchedChannelCount: 0,
...extra,
};
}
async function resolveEligibleRecipients(prisma, recipientEmployeeIds) {
const recipientIds = uniq(recipientEmployeeIds);
if (!recipientIds.length) {
return [];
}
return prisma.employee.findMany({
where: {
id: { in: recipientIds },
status: 'active',
notification_push_enabled: true,
},
select: {
id: true,
roles: {
select: {
role_code: true,
},
},
},
});
}
async function persistDeliveryAttempt({
prisma,
notificationId,
channelId,
attemptNo,
status,
resultCode = null,
errorMessage = null,
responseBody = null,
attemptedAt = new Date(),
}) {
const delivery = await prisma.notificationDelivery.create({
data: {
notification_id: notificationId,
channel_id: channelId,
status: 'pending',
attempt_no: attemptNo,
attempted_at: attemptedAt,
},
});
return prisma.notificationDelivery.update({
where: { id: delivery.id },
data: {
status,
result_code: trimMessage(resultCode, 64),
error_message: trimMessage(errorMessage, 2000),
response_body: normalizeJsonValue(responseBody),
},
});
}
async function dispatchNotificationBatch({
prisma,
config,
logger,
requestId,
notifications,
type,
title,
body,
targetType,
targetId,
targetPath = null,
actorId = null,
maxAttempts = 3,
retryDelayMs = 5000,
sendMessage = sendInstantMessageByChannel,
}) {
const notificationItems = Array.isArray(notifications) ? notifications.filter(Boolean) : [];
if (!notificationItems.length) {
return buildSkipResult('no_notifications');
}
if (!config?.imNotifierEnabled) {
logger?.info?.(
{
requestId,
actorId,
notificationType: type,
notificationCount: notificationItems.length,
skippedReason: 'im_notifier_disabled',
},
'Notification dispatch skipped',
);
return buildSkipResult('im_notifier_disabled', {
notificationCount: notificationItems.length,
});
}
const anchorNotification = notificationItems[0];
const recipientIds = uniq(
notificationItems.map((item) => item.recipient_employee_id ?? item.recipientEmployeeId ?? null),
);
const eligibleRecipients = await resolveEligibleRecipients(prisma, recipientIds);
if (!eligibleRecipients.length) {
logger?.info?.(
{
requestId,
actorId,
notificationType: type,
notificationCount: notificationItems.length,
skippedReason: 'no_eligible_recipients',
},
'Notification dispatch skipped',
);
return buildSkipResult('no_eligible_recipients', {
notificationCount: notificationItems.length,
});
}
const recipientRoleCodes = uniq(
eligibleRecipients.flatMap((recipient) => recipient.roles.map((role) => role.role_code)),
);
const domainScope = resolveNotificationDomainScope(type, targetType ?? anchorNotification.target_type ?? null);
if (!domainScope) {
logger?.warn?.(
{
requestId,
actorId,
notificationType: type,
notificationCount: notificationItems.length,
skippedReason: 'domain_scope_unresolved',
},
'Notification dispatch skipped',
);
return buildSkipResult('domain_scope_unresolved', {
notificationCount: notificationItems.length,
eligibleRecipientCount: eligibleRecipients.length,
});
}
const channels = await prisma.notificationChannel.findMany({
where: {
enabled: true,
channel_type: {
in: ['wecom', 'wechat_work'],
},
},
orderBy: [{ channel_type: 'asc' }, { name: 'asc' }],
});
const matchedChannels = channels.filter((channel) =>
matchesNotificationChannel(channel, {
notificationType: type,
domainScope,
recipientRoleCodes,
}),
);
if (!matchedChannels.length) {
logger?.info?.(
{
requestId,
actorId,
notificationType: type,
notificationCount: notificationItems.length,
eligibleRecipientCount: eligibleRecipients.length,
skippedReason: 'no_matching_channel',
},
'Notification dispatch skipped',
);
return buildSkipResult('no_matching_channel', {
notificationCount: notificationItems.length,
eligibleRecipientCount: eligibleRecipients.length,
});
}
const detailUrl = buildDetailUrl(config, targetPath);
const deliveredChannels = [];
for (const channel of matchedChannels) {
let channelResult = null;
for (let attemptNo = 1; attemptNo <= maxAttempts; attemptNo += 1) {
let result;
try {
result = await sendMessage({
config,
channel,
payload: {
title,
body,
targetType,
targetId,
detailUrl,
},
});
} catch (error) {
result = {
ok: false,
retryable: true,
code: 'dispatcher_unhandled_error',
message: String(error?.message ?? error ?? 'dispatcher_unhandled_error'),
response: null,
};
}
const deliveryStatus = result.ok ? 'success' : 'failed';
await persistDeliveryAttempt({
prisma,
notificationId: anchorNotification.id,
channelId: channel.id,
attemptNo,
status: deliveryStatus,
resultCode: result.code,
errorMessage: result.ok ? null : result.message,
responseBody: result.response ?? null,
});
logger?.info?.(
{
requestId,
actorId,
notificationType: type,
channelId: channel.id,
attemptNo,
notificationCount: notificationItems.length,
eligibleRecipientCount: eligibleRecipients.length,
resultCode: result.code,
ok: result.ok,
},
'Notification delivery attempt finished',
);
channelResult = {
channelId: channel.id,
channelName: channel.name,
status: deliveryStatus,
attemptNo,
resultCode: result.code,
errorMessage: result.ok ? null : trimMessage(result.message, 2000),
};
if (result.ok || !result.retryable || attemptNo >= maxAttempts) {
break;
}
await wait(retryDelayMs);
}
deliveredChannels.push(channelResult);
}
return {
deliveredChannels,
skippedReason: null,
notificationCount: notificationItems.length,
eligibleRecipientCount: eligibleRecipients.length,
matchedChannelCount: matchedChannels.length,
};
}
async function flushNotificationDispatchTasks({
prisma,
config,
logger,
requestId,
tasks,
}) {
const results = [];
for (const task of tasks ?? []) {
try {
results.push(
await dispatchNotificationBatch({
prisma,
config,
logger,
requestId,
...task,
}),
);
} catch (error) {
logger?.error?.(
{
err: error,
requestId,
notificationType: task?.type ?? null,
targetType: task?.targetType ?? null,
targetId: task?.targetId ?? null,
},
'Notification dispatch failed',
);
results.push(
buildSkipResult('dispatch_failed', {
notificationCount: Array.isArray(task?.notifications) ? task.notifications.length : 0,
}),
);
}
}
return results;
}
module.exports = {
dispatchNotificationBatch,
flushNotificationDispatchTasks,
__testables: {
buildSkipResult,
normalizeJsonValue,
resolveEligibleRecipients,
trimMessage,
wait,
},
};
@@ -0,0 +1,38 @@
const { signPayload } = require('../../utils/signedUrl');
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;
}
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
const token = signPayload(
{
targetPath: normalizedPath,
exp: Math.floor(Date.now() / 1000) + 15 * 60,
},
config.jwtSecret,
);
return `${baseUrl}/api/auth/deeplink?token=${encodeURIComponent(token)}`;
}
module.exports = {
buildDetailUrl,
resolveBaseUrl,
};
+1 -33
查看文件
@@ -7,44 +7,12 @@ const {
matchesNotificationChannel,
normalizeChannelType,
} = require('./imRouting');
const { signPayload } = require('../../utils/signedUrl');
const { buildDetailUrl } = require('./detailUrl');
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;
}
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
const token = signPayload(
{
targetPath: normalizedPath,
exp: Math.floor(Date.now() / 1000) + 15 * 60,
},
config.jwtSecret,
);
return `${baseUrl}/api/auth/deeplink?token=${encodeURIComponent(token)}`;
}
async function resolveRecipientRoleCodes(tx, recipientEmployeeIds) {
const recipientIds = uniq(recipientEmployeeIds);
if (!recipientIds.length) {
+31 -1
查看文件
@@ -49,8 +49,38 @@ async function createNotifications(tx, input) {
});
}
async function createNotificationsWithRecords(tx, input) {
const recipientIds = uniq(input.recipientEmployeeIds);
if (recipientIds.length === 0) {
return [];
}
const title = String(input.title ?? '').slice(0, 200);
const body = input.body != null ? String(input.body).slice(0, 5000) : null;
const type = String(input.type ?? '').slice(0, 64);
const targetType = input.targetType != null ? String(input.targetType).slice(0, 64) : null;
const created = [];
for (const recipientId of recipientIds) {
created.push(
await tx.notification.create({
data: {
recipient_employee_id: recipientId,
type,
title,
body,
target_type: targetType,
target_id: input.targetId ?? null,
},
}),
);
}
return created;
}
module.exports = {
createNotifications,
createNotificationsWithRecords,
resolveRecipientsForAssignee,
};