361 行
8.9 KiB
JavaScript
361 行
8.9 KiB
JavaScript
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,
|
|
},
|
|
},
|
|
wecom_identity_binding: {
|
|
select: {
|
|
external_user_id: 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 recipientExternalUserIds = uniq(
|
|
eligibleRecipients.map((recipient) => recipient.wecom_identity_binding?.external_user_id ?? null),
|
|
);
|
|
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,
|
|
recipientEmployeeIds: recipientIds,
|
|
recipientExternalUserIds,
|
|
},
|
|
prisma,
|
|
});
|
|
} 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,
|
|
},
|
|
};
|