feat(P19IM1-1,P19IM1-2): 新增通知渠道配置与Outbox派发
这个提交包含在:
@@ -1,11 +1,12 @@
|
||||
const { pushPaidReimbursementToErp } = require("./erp");
|
||||
const { pushPaidReimbursementToErp } = require('./erp');
|
||||
const { sendInstantMessageByChannel } = require('../notifications/channels');
|
||||
|
||||
class IntegrationDispatchError extends Error {
|
||||
constructor({ code, message, retryable, statusCode, response }) {
|
||||
super(message || code || "integration_dispatch_failed");
|
||||
this.name = "IntegrationDispatchError";
|
||||
this.code = code || "integration_dispatch_failed";
|
||||
this.retryable = typeof retryable === "boolean" ? retryable : true;
|
||||
super(message || code || 'integration_dispatch_failed');
|
||||
this.name = 'IntegrationDispatchError';
|
||||
this.code = code || 'integration_dispatch_failed';
|
||||
this.retryable = typeof retryable === 'boolean' ? retryable : true;
|
||||
this.statusCode = Number.isFinite(statusCode) ? Number(statusCode) : null;
|
||||
this.response = response ?? null;
|
||||
}
|
||||
@@ -21,32 +22,57 @@ function toDispatchError(result) {
|
||||
});
|
||||
}
|
||||
|
||||
async function dispatchOutboxEvent({ config, eventType, payload }) {
|
||||
if (eventType === "erp.reimbursement.paid") {
|
||||
async function dispatchOutboxEvent({ prisma, config, eventType, payload }) {
|
||||
if (eventType === 'erp.reimbursement.paid') {
|
||||
const result = await pushPaidReimbursementToErp(config, payload);
|
||||
if (!result.ok) {
|
||||
throw toDispatchError(result);
|
||||
}
|
||||
|
||||
return {
|
||||
channel: "erp",
|
||||
channel: 'erp',
|
||||
code: result.code,
|
||||
response: result.response ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
if (eventType === "notification.im") {
|
||||
if (eventType === 'notification.im') {
|
||||
const channelId = String(payload?.channelId ?? '').trim();
|
||||
const channel = channelId
|
||||
? await prisma.notificationChannel.findUnique({
|
||||
where: { id: channelId },
|
||||
select: {
|
||||
id: true,
|
||||
channel_type: true,
|
||||
name: true,
|
||||
enabled: true,
|
||||
config_json: true,
|
||||
template_json: true,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
const result = await sendInstantMessageByChannel({
|
||||
config,
|
||||
channel,
|
||||
payload,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
throw toDispatchError(result);
|
||||
}
|
||||
|
||||
return {
|
||||
channel: "im",
|
||||
code: "mock_sent",
|
||||
response: {
|
||||
channelType: payload.channelType,
|
||||
},
|
||||
channel: 'im',
|
||||
code: result.code,
|
||||
response: result.response ?? null,
|
||||
skipped: result.skipped ?? false,
|
||||
previewMessage: result.previewMessage ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
throw new IntegrationDispatchError({
|
||||
code: "unsupported_outbox_event",
|
||||
code: 'unsupported_outbox_event',
|
||||
message: `unsupported_outbox_event:${eventType}`,
|
||||
retryable: false,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
function toSafeString(value, maxLength = 2000) {
|
||||
if (value == null) {
|
||||
return '';
|
||||
}
|
||||
return String(value).trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function buildWecomMarkdownMessage(message) {
|
||||
const title = toSafeString(message?.title, 200);
|
||||
const body = toSafeString(message?.body, 4000);
|
||||
const detailUrl = toSafeString(message?.detailUrl, 2000);
|
||||
const footer = toSafeString(message?.footer, 200);
|
||||
const deepLinkLabel = toSafeString(message?.deepLinkLabel || '查看详情', 50) || '查看详情';
|
||||
|
||||
const lines = [];
|
||||
if (title) {
|
||||
lines.push(`# ${title}`);
|
||||
}
|
||||
if (body) {
|
||||
lines.push(body);
|
||||
}
|
||||
if (detailUrl) {
|
||||
lines.push(`[${deepLinkLabel}](${detailUrl})`);
|
||||
}
|
||||
if (footer) {
|
||||
lines.push(footer);
|
||||
}
|
||||
|
||||
return lines.filter(Boolean).join('\n\n').trim();
|
||||
}
|
||||
|
||||
function buildSignedWebhookUrl(webhookUrl, secret) {
|
||||
const rawUrl = String(webhookUrl ?? '').trim();
|
||||
if (!rawUrl || !secret) {
|
||||
return rawUrl;
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
const signBase = `${timestamp}\n${secret}`;
|
||||
const sign = encodeURIComponent(
|
||||
crypto.createHmac('sha256', secret).update(signBase).digest('base64'),
|
||||
);
|
||||
|
||||
const separator = rawUrl.includes('?') ? '&' : '?';
|
||||
return `${rawUrl}${separator}timestamp=${timestamp}&sign=${sign}`;
|
||||
}
|
||||
|
||||
async function sendWecomMessage({ channel, message }) {
|
||||
const configJson = channel?.config_json && typeof channel.config_json === 'object' ? channel.config_json : {};
|
||||
const mode = String(configJson.mode ?? 'mock').trim() || 'mock';
|
||||
const previewMessage = buildWecomMarkdownMessage(message);
|
||||
|
||||
if (mode === 'mock') {
|
||||
return {
|
||||
ok: true,
|
||||
skipped: false,
|
||||
code: 'mock_sent',
|
||||
response: {
|
||||
channelType: 'wecom',
|
||||
mode,
|
||||
},
|
||||
previewMessage,
|
||||
};
|
||||
}
|
||||
|
||||
if (mode !== 'wecom_webhook') {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
retryable: false,
|
||||
code: 'invalid_channel_mode',
|
||||
message: `Unsupported wecom mode: ${mode}`,
|
||||
response: null,
|
||||
previewMessage,
|
||||
};
|
||||
}
|
||||
|
||||
const webhookUrl = String(configJson.webhookUrl ?? '').trim();
|
||||
if (!webhookUrl) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
retryable: false,
|
||||
code: 'wecom_webhook_missing',
|
||||
message: 'WeCom webhook url missing',
|
||||
response: null,
|
||||
previewMessage,
|
||||
};
|
||||
}
|
||||
|
||||
const timeoutMs = Math.max(100, Number(configJson.timeoutMs ?? 5000));
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(buildSignedWebhookUrl(webhookUrl, configJson.secret ?? null), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
msgtype: 'markdown',
|
||||
markdown: {
|
||||
content: previewMessage,
|
||||
},
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
let responseBody = null;
|
||||
try {
|
||||
responseBody = await response.json();
|
||||
} catch {
|
||||
responseBody = null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
retryable: response.status >= 500 || response.status === 429,
|
||||
code: response.status >= 500 ? 'wecom_http_5xx' : response.status === 429 ? 'wecom_http_429' : 'wecom_http_4xx',
|
||||
message: `WeCom webhook HTTP ${response.status}`,
|
||||
response: {
|
||||
status: response.status,
|
||||
body: responseBody,
|
||||
},
|
||||
previewMessage,
|
||||
};
|
||||
}
|
||||
|
||||
if (responseBody && Number(responseBody.errcode ?? 0) !== 0) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
retryable: false,
|
||||
code: 'wecom_api_error',
|
||||
message: `WeCom API errcode ${responseBody.errcode}`,
|
||||
response: responseBody,
|
||||
previewMessage,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
skipped: false,
|
||||
code: 'wecom_sent',
|
||||
response: responseBody,
|
||||
previewMessage,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
retryable: true,
|
||||
code: 'wecom_timeout',
|
||||
message: `WeCom timeout (${timeoutMs}ms)`,
|
||||
response: null,
|
||||
previewMessage,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
retryable: true,
|
||||
code: 'wecom_network_error',
|
||||
message: String(error?.message ?? error ?? 'WeCom network error'),
|
||||
response: null,
|
||||
previewMessage,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildWecomMarkdownMessage,
|
||||
sendWecomMessage,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户