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

这个提交包含在:
2026-03-08 13:14:04 +08:00
父节点 43a0af0803
当前提交 f6d9f23553
共修改 13 个文件,包含 1582 行新增和 364 行删除
@@ -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,
};