W1-W3 实现企业微信个人应用消息通知
这个提交包含在:
@@ -1,7 +1,7 @@
|
|||||||
import { apiRequestData } from "./http";
|
import { apiRequestData } from "./http";
|
||||||
|
|
||||||
export type NotificationChannelType = "wecom" | "wechat_work";
|
export type NotificationChannelType = "wecom" | "wechat_work";
|
||||||
export type NotificationChannelMode = "mock" | "wecom_webhook";
|
export type NotificationChannelMode = "mock" | "wecom_webhook" | "wecom_app_message";
|
||||||
export type NotificationDomainScope =
|
export type NotificationDomainScope =
|
||||||
| "workflow"
|
| "workflow"
|
||||||
| "reimbursement"
|
| "reimbursement"
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ function formatChannelType(channelType: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatMode(mode: string) {
|
function formatMode(mode: string) {
|
||||||
|
if (mode === "wecom_app_message") return "企微应用消息";
|
||||||
return mode === "wecom_webhook" ? "Webhook" : "Mock";
|
return mode === "wecom_webhook" ? "Webhook" : "Mock";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +186,7 @@ function formatSummary(values: string[], labelMap: Record<string, string>, empty
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildPayload(form: ChannelFormState): NotificationChannelPayload {
|
function buildPayload(form: ChannelFormState): NotificationChannelPayload {
|
||||||
|
const isWebhookMode = form.mode === "wecom_webhook";
|
||||||
const templateJson: NotificationChannelTemplate = {
|
const templateJson: NotificationChannelTemplate = {
|
||||||
title: form.title.trim() || "{{title}}",
|
title: form.title.trim() || "{{title}}",
|
||||||
body: form.body.trim() || "{{body}}\n\n查看详情:{{detailUrl}}",
|
body: form.body.trim() || "{{body}}\n\n查看详情:{{detailUrl}}",
|
||||||
@@ -194,7 +196,7 @@ function buildPayload(form: ChannelFormState): NotificationChannelPayload {
|
|||||||
|
|
||||||
const configJson: NotificationChannelWriteConfig = {
|
const configJson: NotificationChannelWriteConfig = {
|
||||||
mode: form.mode,
|
mode: form.mode,
|
||||||
webhookUrl: form.webhookUrl.trim() || null,
|
webhookUrl: isWebhookMode ? form.webhookUrl.trim() || null : null,
|
||||||
timeoutMs: Math.max(100, Number(form.timeoutMs || 5000)),
|
timeoutMs: Math.max(100, Number(form.timeoutMs || 5000)),
|
||||||
routing: {
|
routing: {
|
||||||
roleCodes: form.roleCodes,
|
roleCodes: form.roleCodes,
|
||||||
@@ -203,9 +205,9 @@ function buildPayload(form: ChannelFormState): NotificationChannelPayload {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (form.secretAction === "replace") {
|
if (isWebhookMode && form.secretAction === "replace") {
|
||||||
configJson.secret = form.secret.trim() || null;
|
configJson.secret = form.secret.trim() || null;
|
||||||
} else if (form.secretAction === "clear") {
|
} else if (isWebhookMode && form.secretAction === "clear") {
|
||||||
configJson.clearSecret = true;
|
configJson.clearSecret = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,7 +249,7 @@ export function NotificationChannelsPage() {
|
|||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (editingId && form.secretAction === "replace" && !form.secret.trim()) {
|
if (editingId && form.mode === "wecom_webhook" && form.secretAction === "replace" && !form.secret.trim()) {
|
||||||
throw new Error("请输入新的 Webhook Secret,或切换为保留现有密钥。");
|
throw new Error("请输入新的 Webhook Secret,或切换为保留现有密钥。");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,12 +325,14 @@ export function NotificationChannelsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const channels = channelsQuery.data?.items ?? [];
|
const channels = channelsQuery.data?.items ?? [];
|
||||||
|
const isWebhookMode = form.mode === "wecom_webhook";
|
||||||
|
const isAppMessageMode = form.mode === "wecom_app_message";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="通知渠道"
|
title="通知渠道"
|
||||||
subtitle="维护企微群机器人渠道、业务域路由、测试发送与最近投递记录。目标业务通知会在提交后同步尝试外推。"
|
subtitle="维护企微通知渠道、业务域路由、测试发送与最近投递记录。目标业务通知会在提交后同步尝试外推。"
|
||||||
actions={
|
actions={
|
||||||
<Button type="button" variant="secondary" onClick={startCreate} data-testid="notification-channels-create">
|
<Button type="button" variant="secondary" onClick={startCreate} data-testid="notification-channels-create">
|
||||||
新建渠道
|
新建渠道
|
||||||
@@ -523,7 +527,7 @@ export function NotificationChannelsPage() {
|
|||||||
{editingId ? "编辑通知渠道" : "新建通知渠道"}
|
{editingId ? "编辑通知渠道" : "新建通知渠道"}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-zinc-500">
|
<p className="text-sm text-zinc-500">
|
||||||
通过角色 / 业务域 / 通知类型三维控制 IM 群路由,避免默认全量广播。
|
通过角色 / 业务域 / 通知类型三维控制 IM 路由,避免默认全量广播。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{editingId ? (
|
{editingId ? (
|
||||||
@@ -543,7 +547,7 @@ export function NotificationChannelsPage() {
|
|||||||
disabled={Boolean(editingId)}
|
disabled={Boolean(editingId)}
|
||||||
onChange={(event) => setForm((prev) => ({ ...prev, channelType: event.target.value as "wecom" }))}
|
onChange={(event) => setForm((prev) => ({ ...prev, channelType: event.target.value as "wecom" }))}
|
||||||
>
|
>
|
||||||
<option value="wecom">企微群机器人</option>
|
<option value="wecom">企微</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -572,7 +576,11 @@ export function NotificationChannelsPage() {
|
|||||||
>
|
>
|
||||||
<option value="mock">Mock</option>
|
<option value="mock">Mock</option>
|
||||||
<option value="wecom_webhook">企微 Webhook</option>
|
<option value="wecom_webhook">企微 Webhook</option>
|
||||||
|
<option value="wecom_app_message">企微应用消息(个人)</option>
|
||||||
</select>
|
</select>
|
||||||
|
{isAppMessageMode ? (
|
||||||
|
<p className="text-xs text-zinc-500">使用企业微信自建应用给已绑定成员发送个人消息。</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -586,90 +594,100 @@ export function NotificationChannelsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
{isWebhookMode ? (
|
||||||
<Label htmlFor="webhookUrl">Webhook URL</Label>
|
<>
|
||||||
<Input
|
<div className="space-y-2 md:col-span-2">
|
||||||
id="webhookUrl"
|
<Label htmlFor="webhookUrl">Webhook URL</Label>
|
||||||
value={form.webhookUrl}
|
<Input
|
||||||
placeholder={form.mode === "mock" ? "Mock 模式可留空" : "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?..."}
|
id="webhookUrl"
|
||||||
onChange={(event) => setForm((prev) => ({ ...prev, webhookUrl: event.target.value }))}
|
value={form.webhookUrl}
|
||||||
/>
|
placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?..."
|
||||||
</div>
|
onChange={(event) => setForm((prev) => ({ ...prev, webhookUrl: event.target.value }))}
|
||||||
|
/>
|
||||||
<div className="space-y-2 md:col-span-2">
|
|
||||||
<Label htmlFor="webhookSecret">Webhook Secret</Label>
|
|
||||||
{editingId && form.secretConfigured ? (
|
|
||||||
<div className="space-y-3 rounded-lg border border-zinc-200 bg-zinc-50 p-4">
|
|
||||||
<p className="text-xs text-zinc-600">
|
|
||||||
当前已配置密钥:{form.secretMasked ?? "******"}。默认保留现有密钥,不会回显明文。
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-4 text-sm text-zinc-700">
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="secretAction"
|
|
||||||
checked={form.secretAction === "keep"}
|
|
||||||
onChange={() =>
|
|
||||||
setForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
secretAction: "keep",
|
|
||||||
secret: "",
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
保留现有密钥
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="secretAction"
|
|
||||||
checked={form.secretAction === "replace"}
|
|
||||||
onChange={() =>
|
|
||||||
setForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
secretAction: "replace",
|
|
||||||
secret: "",
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
替换密钥
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="secretAction"
|
|
||||||
checked={form.secretAction === "clear"}
|
|
||||||
onChange={() =>
|
|
||||||
setForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
secretAction: "clear",
|
|
||||||
secret: "",
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
清空密钥
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<p className="text-xs text-zinc-500">
|
|
||||||
{editingId ? "当前未配置密钥,保存时可新增。" : "可选,配置后将参与签名。"}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{form.secretAction === "replace" ? (
|
<div className="space-y-2 md:col-span-2">
|
||||||
<Input
|
<Label htmlFor="webhookSecret">Webhook Secret</Label>
|
||||||
id="webhookSecret"
|
{editingId && form.secretConfigured ? (
|
||||||
value={form.secret}
|
<div className="space-y-3 rounded-lg border border-zinc-200 bg-zinc-50 p-4">
|
||||||
placeholder={editingId ? "输入新的 Webhook Secret" : "可选,配置后将参与签名"}
|
<p className="text-xs text-zinc-600">
|
||||||
onChange={(event) => setForm((prev) => ({ ...prev, secret: event.target.value }))}
|
当前已配置密钥:{form.secretMasked ?? "******"}。默认保留现有密钥,不会回显明文。
|
||||||
/>
|
</p>
|
||||||
) : null}
|
<div className="flex flex-wrap gap-4 text-sm text-zinc-700">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="secretAction"
|
||||||
|
checked={form.secretAction === "keep"}
|
||||||
|
onChange={() =>
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
secretAction: "keep",
|
||||||
|
secret: "",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
保留现有密钥
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="secretAction"
|
||||||
|
checked={form.secretAction === "replace"}
|
||||||
|
onChange={() =>
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
secretAction: "replace",
|
||||||
|
secret: "",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
替换密钥
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="secretAction"
|
||||||
|
checked={form.secretAction === "clear"}
|
||||||
|
onChange={() =>
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
secretAction: "clear",
|
||||||
|
secret: "",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
清空密钥
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
{editingId ? "当前未配置密钥,保存时可新增。" : "可选,配置后将参与签名。"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{form.secretAction === "clear" ? (
|
{form.secretAction === "replace" ? (
|
||||||
<p className="text-xs text-amber-600">保存后将清空当前 Webhook Secret。</p>
|
<Input
|
||||||
) : null}
|
id="webhookSecret"
|
||||||
</div>
|
value={form.secret}
|
||||||
|
placeholder={editingId ? "输入新的 Webhook Secret" : "可选,配置后将参与签名"}
|
||||||
|
onChange={(event) => setForm((prev) => ({ ...prev, secret: event.target.value }))}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{form.secretAction === "clear" ? (
|
||||||
|
<p className="text-xs text-amber-600">保存后将清空当前 Webhook Secret。</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs text-zinc-600 md:col-span-2">
|
||||||
|
{isAppMessageMode
|
||||||
|
? "个人应用消息使用现有企业微信应用配置和成员绑定,不需要填写 Webhook URL 或 Secret。"
|
||||||
|
: "Mock 模式只记录本地投递结果,不需要填写 Webhook URL 或 Secret。"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="flex items-center gap-2 text-sm text-zinc-700">
|
<label className="flex items-center gap-2 text-sm text-zinc-700">
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||||
|
W1,合同建档与现状确认,固定本轮目标为企业微信个人应用消息推送并确认当前公网仍只有 mock 通知渠道;本 Issue 不改业务数据。,1) 新合同 CSV 校验通过;2) 当前 mock 渠道与 R5 阻断原因记录清晰;3) 明确不使用群机器人作为正式审批通知口径。,流程级验证:git status --short;只读查询公网通知渠道摘要;python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-11_08-56-44-wecom-personal-app-message-r5-closeout.csv。,git|python3|node|manual,DONE,DONE,DONE,issues/2026-05-11_08-56-44-wecom-personal-app-message-r5-closeout.csv,[],回滚路径:删除本 CSV;不涉及代码或远端数据。已确认 W1 合同存在,公网当前仍以 mock 渠道阻断真实企微个人消息验收。
|
||||||
|
W2,后端企业微信个人应用消息适配器,新增 wecom_app_message 通知渠道模式,复用现有 WECOM_* 应用配置向绑定成员发送企业微信应用消息。,1) 渠道模式支持 wecom_app_message;2) textcard 带 deeplink,缺 deeplink 时降级 text;3) 仅向已绑定且启用通知的收件人发送;4) 无绑定不阻断业务并记录 wecom_app_no_bound_recipients;5) delivery 不记录 secret/token。,流程级验证:cd server && npm run prisma:validate;cd server && node --check src/services/integrations/im/wecom.js;cd server && node --check src/services/notificationDispatcher.js;cd server && npm test -- tests/unit/routes/notifications.test.js tests/unit/services/notificationDispatcher.test.js tests/unit/services/integrations/dispatcher.test.js tests/unit/routes/wecomAuth.test.js。,node|npm,DONE,DONE,DONE,server/src/services/integrations/im/wecom.js|server/src/services/notificationDispatcher.js|server/src/routes/notifications.js,"[""W1""]",不新增 Prisma migration;如企业微信 API 权限错误,记录错误码并停止 R5 完成。已通过 Prisma validate、node --check 和 4 组通知/企微单测。
|
||||||
|
W3,管理端通知渠道支持个人应用消息,通知渠道管理页新增企微应用消息个人模式,隐藏 webhook 必填和 secret 要求,保留路由与模板配置。,1) 管理页模式可选 wecom_app_message;2) 该模式不要求 Webhook URL/Secret;3) Mock 和 webhook 旧配置不回归;4) typecheck/build 通过。,流程级验证:cd client && npm run typecheck;cd client && npm run build;人工或 Playwright 打开 /system/notification-channels 确认模式选项和字段可理解。,npm|playwright,DONE,DONE,DONE,client/src/pages/NotificationChannelsPage.tsx|client/src/api/notificationChannels.ts,"[""W2""]",仅管理端配置 UI;不改变普通用户页面。已通过前端 typecheck 与生产 build。
|
||||||
|
W4,公网部署与真实个人消息渠道配置,部署当前 RC,完成 R4 复检,并将公网通知渠道从 mock 切到 wecom_app_message 个人应用消息模式。,1) deploy/trialrun/deploy.sh 成功;2) R4 只读复检通过;3) R5-WECOM-APP-PERSONAL enabled=true mode=wecom_app_message;4) R50-MOCK-007334 不再参与投递;5) 不修改远端 .env。,流程级验证:bash deploy/trialrun/deploy.sh;curl https://oa.hyinda.com/ready;Prisma migrate status;公网 bundle 扫描;管理员 API 或远端受控脚本 upsert 通知渠道后只读复核。,bash|ssh|node|manual,TODO,TODO,TODO,deploy/trialrun/deploy.sh|server/src/*|client/src/*,"[""W3""]",远端写操作仅限 notification_channels 配置;回滚:禁用 R5-WECOM-APP-PERSONAL 并恢复/启用 R50-MOCK-007334。
|
||||||
|
W5,R5 真实企微消息与 deeplink 收口,触发 SMOKE002->SMOKE101 新待办,自动验证企业微信应用消息 delivery、站内通知、移动 deeplink 和视觉;等待用户真机确认消息到达。,1) 最新待办 delivery resultCode=wecom_app_sent 且 errcode=0;2) 无 invaliduser;3) 移动通知和 deeplink 自动视觉通过;4) run-live-smoke-readiness.sh 通过;5) 用户确认 oa_smoke_02 手机企业微信收到并可点击回流后,S5/R5 才可 DONE。,流程级验证:source ~/.hyb-oa/live-smoke.env;触发测试单据;查询 /api/notifications/deliveries;Playwright 企业微信 UA 覆盖 iPhone 11 和 Redmi K60;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh;校验主 R5/S5/Q 合同并 git diff --check。,node|bash|playwright|manual,TODO,TODO,TODO,docs/trialrun/real-device-public-checklist.md|issues/2026-05-10_12-39-37-release-readiness-live-smoke.csv|issues/2026-05-10_20-33-04-formal-smoke-test-accounts.csv,"[""W4""]",若 delivery 成功但手机无提醒,先记录为企业微信客户端通知权限/应用可见范围待确认,不伪造 R5 DONE。
|
||||||
|
@@ -31,7 +31,7 @@ const DELIVERY_QUERY_SCHEMA = z.object({
|
|||||||
const CHANNEL_ID_SCHEMA = z.string().uuid();
|
const CHANNEL_ID_SCHEMA = z.string().uuid();
|
||||||
const ROLE_CODE_VALUES = Object.freeze(Object.keys(ROLE_PERMISSIONS));
|
const ROLE_CODE_VALUES = Object.freeze(Object.keys(ROLE_PERMISSIONS));
|
||||||
const CHANNEL_TYPE_VALUES = Object.freeze(['wecom', 'wechat_work']);
|
const CHANNEL_TYPE_VALUES = Object.freeze(['wecom', 'wechat_work']);
|
||||||
const CHANNEL_MODE_VALUES = Object.freeze(['mock', 'wecom_webhook']);
|
const CHANNEL_MODE_VALUES = Object.freeze(['mock', 'wecom_webhook', 'wecom_app_message']);
|
||||||
const DOMAIN_SCOPE_VALUES = Object.freeze(Object.values(NOTIFICATION_DOMAIN_SCOPES));
|
const DOMAIN_SCOPE_VALUES = Object.freeze(Object.values(NOTIFICATION_DOMAIN_SCOPES));
|
||||||
const DEFAULT_TEMPLATE_JSON = Object.freeze({
|
const DEFAULT_TEMPLATE_JSON = Object.freeze({
|
||||||
title: '{{title}}',
|
title: '{{title}}',
|
||||||
@@ -195,15 +195,22 @@ function normalizeRouting(routing) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeConfigJson(input = {}, options = {}) {
|
function normalizeConfigJson(input = {}, options = {}) {
|
||||||
|
const mode = input.mode ?? 'mock';
|
||||||
|
const webhookUrl = mode === 'wecom_webhook' ? String(input.webhookUrl ?? '').trim() || null : null;
|
||||||
|
const secretFields =
|
||||||
|
mode === 'wecom_webhook'
|
||||||
|
? buildStoredSecretFields({
|
||||||
|
config: options.config,
|
||||||
|
existingConfig: options.existingConfig,
|
||||||
|
inputSecret: input.secret,
|
||||||
|
clearSecret: Boolean(input.clearSecret),
|
||||||
|
})
|
||||||
|
: {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mode: input.mode ?? 'mock',
|
mode,
|
||||||
webhookUrl: String(input.webhookUrl ?? '').trim() || null,
|
webhookUrl,
|
||||||
...buildStoredSecretFields({
|
...secretFields,
|
||||||
config: options.config,
|
|
||||||
existingConfig: options.existingConfig,
|
|
||||||
inputSecret: input.secret,
|
|
||||||
clearSecret: Boolean(input.clearSecret),
|
|
||||||
}),
|
|
||||||
timeoutMs: Math.max(100, Number(input.timeoutMs ?? 5000)),
|
timeoutMs: Math.max(100, Number(input.timeoutMs ?? 5000)),
|
||||||
routing: normalizeRouting(input.routing),
|
routing: normalizeRouting(input.routing),
|
||||||
};
|
};
|
||||||
@@ -221,11 +228,17 @@ function normalizeTemplateJson(input = {}) {
|
|||||||
|
|
||||||
function mapChannelConfigForRead(configJson, options = {}) {
|
function mapChannelConfigForRead(configJson, options = {}) {
|
||||||
const rawConfig = configJson && typeof configJson === 'object' ? configJson : {};
|
const rawConfig = configJson && typeof configJson === 'object' ? configJson : {};
|
||||||
const secretModel = getChannelSecretReadModel({
|
const secretModel =
|
||||||
config: options.config,
|
rawConfig.mode === 'wecom_webhook'
|
||||||
configJson: rawConfig,
|
? getChannelSecretReadModel({
|
||||||
logger: options.logger,
|
config: options.config,
|
||||||
});
|
configJson: rawConfig,
|
||||||
|
logger: options.logger,
|
||||||
|
})
|
||||||
|
: {
|
||||||
|
secretConfigured: false,
|
||||||
|
secretMasked: null,
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mode: rawConfig.mode ?? 'mock',
|
mode: rawConfig.mode ?? 'mock',
|
||||||
@@ -693,7 +706,9 @@ async function notificationsRoutes(fastify) {
|
|||||||
body: `测试人:${request.employee.name}(${request.employee.id})`,
|
body: `测试人:${request.employee.name}(${request.employee.id})`,
|
||||||
targetType: 'notification_channel',
|
targetType: 'notification_channel',
|
||||||
targetId: channel.id,
|
targetId: channel.id,
|
||||||
|
recipientEmployeeIds: [request.employee.id],
|
||||||
},
|
},
|
||||||
|
prisma: fastify.prisma,
|
||||||
});
|
});
|
||||||
|
|
||||||
await fastify.prisma.auditLog.create({
|
await fastify.prisma.auditLog.create({
|
||||||
@@ -773,7 +788,9 @@ async function notificationsRoutes(fastify) {
|
|||||||
body: `测试人:${request.employee.id}`,
|
body: `测试人:${request.employee.id}`,
|
||||||
targetType: 'notification_channel',
|
targetType: 'notification_channel',
|
||||||
targetId: channel.id,
|
targetId: channel.id,
|
||||||
|
recipientEmployeeIds: [request.employee.id],
|
||||||
},
|
},
|
||||||
|
prisma: fastify.prisma,
|
||||||
});
|
});
|
||||||
|
|
||||||
await fastify.prisma.auditLog.create({
|
await fastify.prisma.auditLog.create({
|
||||||
|
|||||||
@@ -272,6 +272,7 @@ async function dispatchOutboxEvent({ prisma, config, eventType, payload }) {
|
|||||||
config,
|
config,
|
||||||
channel,
|
channel,
|
||||||
payload,
|
payload,
|
||||||
|
prisma,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
const crypto = require('node:crypto');
|
const crypto = require('node:crypto');
|
||||||
|
|
||||||
const { resolveChannelSecret } = require('../../notifications/channelConfig');
|
const { resolveChannelSecret } = require('../../notifications/channelConfig');
|
||||||
|
const {
|
||||||
|
getWecomAppAccessToken,
|
||||||
|
sanitizeWecomResponse,
|
||||||
|
} = require('../wecomAccessToken');
|
||||||
|
|
||||||
function toSafeString(value, maxLength = 2000) {
|
function toSafeString(value, maxLength = 2000) {
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
@@ -33,6 +37,30 @@ function buildWecomMarkdownMessage(message) {
|
|||||||
return lines.filter(Boolean).join('\n\n').trim();
|
return lines.filter(Boolean).join('\n\n').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildWecomTextPreview(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) {
|
function buildSignedWebhookUrl(webhookUrl, secret) {
|
||||||
const rawUrl = String(webhookUrl ?? '').trim();
|
const rawUrl = String(webhookUrl ?? '').trim();
|
||||||
if (!rawUrl || !secret) {
|
if (!rawUrl || !secret) {
|
||||||
@@ -73,7 +101,205 @@ function mapSecretError(result, previewMessage) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendWecomMessage({ config, channel, message }) {
|
function normalizeExternalUserIds(values) {
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
(values ?? [])
|
||||||
|
.map((value) => String(value ?? '').trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAgentId(value) {
|
||||||
|
const raw = String(value ?? '').trim();
|
||||||
|
const numeric = Number(raw);
|
||||||
|
return raw && Number.isFinite(numeric) ? numeric : raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasInvalidRecipients(responseBody) {
|
||||||
|
return Boolean(String(responseBody?.invaliduser ?? '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWecomAppPayload({ config, message, touser }) {
|
||||||
|
const agentid = normalizeAgentId(config?.wecomAgentId);
|
||||||
|
const title = toSafeString(message?.title || '系统通知', 200) || '系统通知';
|
||||||
|
const body = toSafeString(message?.body, 4000);
|
||||||
|
const footer = toSafeString(message?.footer, 200);
|
||||||
|
const detailUrl = toSafeString(message?.detailUrl, 2000);
|
||||||
|
const deepLinkLabel = toSafeString(message?.deepLinkLabel || '查看详情', 10) || '查看详情';
|
||||||
|
|
||||||
|
if (detailUrl) {
|
||||||
|
return {
|
||||||
|
touser,
|
||||||
|
msgtype: 'textcard',
|
||||||
|
agentid,
|
||||||
|
textcard: {
|
||||||
|
title,
|
||||||
|
description: [body, footer].filter(Boolean).join('\n\n') || title,
|
||||||
|
url: detailUrl,
|
||||||
|
btntxt: deepLinkLabel,
|
||||||
|
},
|
||||||
|
enable_id_trans: 0,
|
||||||
|
enable_duplicate_check: 1,
|
||||||
|
duplicate_check_interval: 1800,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
touser,
|
||||||
|
msgtype: 'text',
|
||||||
|
agentid,
|
||||||
|
text: {
|
||||||
|
content: buildWecomTextPreview(message) || title,
|
||||||
|
},
|
||||||
|
enable_id_trans: 0,
|
||||||
|
enable_duplicate_check: 1,
|
||||||
|
duplicate_check_interval: 1800,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendWecomAppMessage({
|
||||||
|
config,
|
||||||
|
configJson,
|
||||||
|
message,
|
||||||
|
recipientExternalUserIds,
|
||||||
|
}) {
|
||||||
|
const previewMessage = buildWecomTextPreview(message);
|
||||||
|
const externalUserIds = normalizeExternalUserIds(recipientExternalUserIds);
|
||||||
|
if (!externalUserIds.length) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
skipped: false,
|
||||||
|
retryable: false,
|
||||||
|
code: 'wecom_app_no_bound_recipients',
|
||||||
|
message: 'No bound WeCom recipients',
|
||||||
|
response: {
|
||||||
|
channelType: 'wecom',
|
||||||
|
mode: 'wecom_app_message',
|
||||||
|
recipientCount: 0,
|
||||||
|
},
|
||||||
|
previewMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutMs = Math.max(100, Number(configJson.timeoutMs ?? 5000));
|
||||||
|
const tokenResult = await getWecomAppAccessToken({ config, timeoutMs });
|
||||||
|
if (!tokenResult.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
skipped: false,
|
||||||
|
retryable: tokenResult.retryable,
|
||||||
|
code: tokenResult.code,
|
||||||
|
message: tokenResult.message,
|
||||||
|
response: tokenResult.response,
|
||||||
|
previewMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
const url = new URL('https://qyapi.weixin.qq.com/cgi-bin/message/send');
|
||||||
|
url.searchParams.set('access_token', tokenResult.accessToken);
|
||||||
|
const touser = externalUserIds.join('|');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(buildWecomAppPayload({ config, message, touser })),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseBody = await response.json().catch(() => null);
|
||||||
|
const sanitized = sanitizeWecomResponse(responseBody);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
skipped: false,
|
||||||
|
retryable: response.status >= 500 || response.status === 429,
|
||||||
|
code:
|
||||||
|
response.status >= 500
|
||||||
|
? 'wecom_app_http_5xx'
|
||||||
|
: response.status === 429
|
||||||
|
? 'wecom_app_http_429'
|
||||||
|
: 'wecom_app_http_4xx',
|
||||||
|
message: `WeCom app message HTTP ${response.status}`,
|
||||||
|
response: {
|
||||||
|
status: response.status,
|
||||||
|
body: sanitized,
|
||||||
|
},
|
||||||
|
previewMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseBody && Number(responseBody.errcode ?? 0) !== 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
skipped: false,
|
||||||
|
retryable: false,
|
||||||
|
code: 'wecom_app_api_error',
|
||||||
|
message: `WeCom app message errcode ${responseBody.errcode}`,
|
||||||
|
response: sanitized,
|
||||||
|
previewMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasInvalidRecipients(responseBody)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
skipped: false,
|
||||||
|
retryable: false,
|
||||||
|
code: 'wecom_app_invaliduser',
|
||||||
|
message: 'WeCom app message invaliduser',
|
||||||
|
response: sanitized,
|
||||||
|
previewMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
skipped: false,
|
||||||
|
retryable: false,
|
||||||
|
code: 'wecom_app_sent',
|
||||||
|
message: null,
|
||||||
|
response: {
|
||||||
|
...(sanitized ?? { errcode: 0 }),
|
||||||
|
recipientCount: externalUserIds.length,
|
||||||
|
},
|
||||||
|
previewMessage,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name === 'AbortError') {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
skipped: false,
|
||||||
|
retryable: true,
|
||||||
|
code: 'wecom_app_timeout',
|
||||||
|
message: `WeCom app message timeout (${timeoutMs}ms)`,
|
||||||
|
response: null,
|
||||||
|
previewMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
skipped: false,
|
||||||
|
retryable: true,
|
||||||
|
code: 'wecom_app_network_error',
|
||||||
|
message: String(error?.message ?? error ?? 'WeCom app message network error'),
|
||||||
|
response: null,
|
||||||
|
previewMessage,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendWecomMessage({ config, channel, message, recipientExternalUserIds }) {
|
||||||
const configJson = channel?.config_json && typeof channel.config_json === 'object' ? channel.config_json : {};
|
const configJson = channel?.config_json && typeof channel.config_json === 'object' ? channel.config_json : {};
|
||||||
const mode = String(configJson.mode ?? 'mock').trim() || 'mock';
|
const mode = String(configJson.mode ?? 'mock').trim() || 'mock';
|
||||||
const previewMessage = buildWecomMarkdownMessage(message);
|
const previewMessage = buildWecomMarkdownMessage(message);
|
||||||
@@ -93,6 +319,15 @@ async function sendWecomMessage({ config, channel, message }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mode === 'wecom_app_message') {
|
||||||
|
return sendWecomAppMessage({
|
||||||
|
config,
|
||||||
|
configJson,
|
||||||
|
message,
|
||||||
|
recipientExternalUserIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (mode !== 'wecom_webhook') {
|
if (mode !== 'wecom_webhook') {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -218,5 +453,6 @@ async function sendWecomMessage({ config, channel, message }) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
buildSignedWebhookUrl,
|
buildSignedWebhookUrl,
|
||||||
buildWecomMarkdownMessage,
|
buildWecomMarkdownMessage,
|
||||||
|
buildWecomTextPreview,
|
||||||
sendWecomMessage,
|
sendWecomMessage,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
const tokenCache = new Map();
|
||||||
|
|
||||||
|
function toSafeString(value, maxLength = 2000) {
|
||||||
|
if (value == null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return String(value).trim().slice(0, maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeWecomResponse(payload) {
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {};
|
||||||
|
for (const key of ['errcode', 'errmsg', 'invaliduser', 'invalidparty', 'invalidtag']) {
|
||||||
|
if (payload[key] != null) {
|
||||||
|
result[key] = payload[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.keys(result).length ? result : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCredentialKey(config) {
|
||||||
|
return [
|
||||||
|
toSafeString(config?.wecomCorpId, 128),
|
||||||
|
toSafeString(config?.wecomAgentId, 64),
|
||||||
|
toSafeString(config?.wecomAgentSecret, 16),
|
||||||
|
].join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTimeoutMs(timeoutMs) {
|
||||||
|
return Math.max(100, Number(timeoutMs ?? 5000));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMissingConfigResult(missing) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
retryable: false,
|
||||||
|
code: 'wecom_app_missing_config',
|
||||||
|
message: `Missing WeCom app config: ${missing.join(',')}`,
|
||||||
|
response: {
|
||||||
|
missing,
|
||||||
|
},
|
||||||
|
accessToken: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getWecomAppAccessToken({ config, timeoutMs = 5000, fetchImpl = fetch }) {
|
||||||
|
const corpId = toSafeString(config?.wecomCorpId, 128);
|
||||||
|
const agentId = toSafeString(config?.wecomAgentId, 64);
|
||||||
|
const agentSecret = toSafeString(config?.wecomAgentSecret, 500);
|
||||||
|
const missing = [];
|
||||||
|
if (!corpId) missing.push('WECOM_CORP_ID');
|
||||||
|
if (!agentId) missing.push('WECOM_AGENT_ID');
|
||||||
|
if (!agentSecret) missing.push('WECOM_AGENT_SECRET');
|
||||||
|
if (missing.length) {
|
||||||
|
return buildMissingConfigResult(missing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = getCredentialKey(config);
|
||||||
|
const cached = tokenCache.get(cacheKey);
|
||||||
|
if (cached?.accessToken && cached.expiresAt > Date.now() + 60 * 1000) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
retryable: false,
|
||||||
|
code: 'wecom_app_token_cached',
|
||||||
|
message: null,
|
||||||
|
response: { cached: true },
|
||||||
|
accessToken: cached.accessToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = resolveTimeoutMs(timeoutMs);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeout);
|
||||||
|
const url = new URL('https://qyapi.weixin.qq.com/cgi-bin/gettoken');
|
||||||
|
url.searchParams.set('corpid', corpId);
|
||||||
|
url.searchParams.set('corpsecret', agentSecret);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchImpl(url, {
|
||||||
|
method: 'GET',
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
const sanitized = sanitizeWecomResponse(payload);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
retryable: response.status >= 500 || response.status === 429,
|
||||||
|
code:
|
||||||
|
response.status >= 500
|
||||||
|
? 'wecom_app_token_http_5xx'
|
||||||
|
: response.status === 429
|
||||||
|
? 'wecom_app_token_http_429'
|
||||||
|
: 'wecom_app_token_http_4xx',
|
||||||
|
message: `WeCom app token HTTP ${response.status}`,
|
||||||
|
response: {
|
||||||
|
status: response.status,
|
||||||
|
body: sanitized,
|
||||||
|
},
|
||||||
|
accessToken: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload || Number(payload.errcode ?? 0) !== 0 || !payload.access_token) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
retryable: false,
|
||||||
|
code: 'wecom_app_token_api_error',
|
||||||
|
message: `WeCom app token errcode ${payload?.errcode ?? 'missing_payload'}`,
|
||||||
|
response: sanitized,
|
||||||
|
accessToken: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresInSeconds = Math.max(300, Number(payload.expires_in ?? 7200));
|
||||||
|
tokenCache.set(cacheKey, {
|
||||||
|
accessToken: String(payload.access_token),
|
||||||
|
expiresAt: Date.now() + Math.max(60, expiresInSeconds - 120) * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
retryable: false,
|
||||||
|
code: 'wecom_app_token_fetched',
|
||||||
|
message: null,
|
||||||
|
response: { cached: false, expiresIn: expiresInSeconds },
|
||||||
|
accessToken: String(payload.access_token),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name === 'AbortError') {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
retryable: true,
|
||||||
|
code: 'wecom_app_token_timeout',
|
||||||
|
message: `WeCom app token timeout (${timeout}ms)`,
|
||||||
|
response: null,
|
||||||
|
accessToken: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
retryable: true,
|
||||||
|
code: 'wecom_app_token_network_error',
|
||||||
|
message: String(error?.message ?? error ?? 'WeCom app token network error'),
|
||||||
|
response: null,
|
||||||
|
accessToken: null,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearWecomAppAccessTokenCache() {
|
||||||
|
tokenCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
clearWecomAppAccessTokenCache,
|
||||||
|
getWecomAppAccessToken,
|
||||||
|
sanitizeWecomResponse,
|
||||||
|
};
|
||||||
@@ -61,6 +61,11 @@ async function resolveEligibleRecipients(prisma, recipientEmployeeIds) {
|
|||||||
role_code: true,
|
role_code: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
wecom_identity_binding: {
|
||||||
|
select: {
|
||||||
|
external_user_id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -159,6 +164,9 @@ async function dispatchNotificationBatch({
|
|||||||
const recipientRoleCodes = uniq(
|
const recipientRoleCodes = uniq(
|
||||||
eligibleRecipients.flatMap((recipient) => recipient.roles.map((role) => role.role_code)),
|
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);
|
const domainScope = resolveNotificationDomainScope(type, targetType ?? anchorNotification.target_type ?? null);
|
||||||
if (!domainScope) {
|
if (!domainScope) {
|
||||||
logger?.warn?.(
|
logger?.warn?.(
|
||||||
@@ -225,11 +233,14 @@ async function dispatchNotificationBatch({
|
|||||||
payload: {
|
payload: {
|
||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
targetType,
|
targetType,
|
||||||
targetId,
|
targetId,
|
||||||
detailUrl,
|
detailUrl,
|
||||||
},
|
recipientEmployeeIds: recipientIds,
|
||||||
});
|
recipientExternalUserIds,
|
||||||
|
},
|
||||||
|
prisma,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
result = {
|
result = {
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|||||||
@@ -39,7 +39,40 @@ function buildNotificationMessage(channel, payload) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendInstantMessageByChannel({ config, channel, payload }) {
|
function uniqStrings(values) {
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
(values ?? [])
|
||||||
|
.map((value) => String(value ?? '').trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveRecipientExternalUserIds({ prisma, payload }) {
|
||||||
|
const explicitExternalUserIds = uniqStrings(payload?.recipientExternalUserIds);
|
||||||
|
if (explicitExternalUserIds.length) {
|
||||||
|
return explicitExternalUserIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
const employeeIds = uniqStrings(payload?.recipientEmployeeIds);
|
||||||
|
if (!prisma?.wecomIdentityBinding?.findMany || !employeeIds.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindings = await prisma.wecomIdentityBinding.findMany({
|
||||||
|
where: {
|
||||||
|
employee_id: { in: employeeIds },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
external_user_id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return uniqStrings(bindings.map((binding) => binding.external_user_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendInstantMessageByChannel({ config, channel, payload, prisma }) {
|
||||||
const normalizedChannelType = normalizeChannelType(channel?.channel_type ?? payload?.channelType ?? '');
|
const normalizedChannelType = normalizeChannelType(channel?.channel_type ?? payload?.channelType ?? '');
|
||||||
|
|
||||||
if (!config?.imNotifierEnabled) {
|
if (!config?.imNotifierEnabled) {
|
||||||
@@ -91,11 +124,24 @@ async function sendInstantMessageByChannel({ config, channel, payload }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const message = buildNotificationMessage(channel, payload);
|
const message = buildNotificationMessage(channel, payload);
|
||||||
return sendWecomMessage({ config, channel, message });
|
const configJson = channel?.config_json && typeof channel.config_json === 'object' ? channel.config_json : {};
|
||||||
|
const mode = String(configJson.mode ?? 'mock').trim() || 'mock';
|
||||||
|
const recipientExternalUserIds =
|
||||||
|
mode === 'wecom_app_message'
|
||||||
|
? await resolveRecipientExternalUserIds({ prisma, payload })
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return sendWecomMessage({
|
||||||
|
config,
|
||||||
|
channel,
|
||||||
|
message,
|
||||||
|
recipientExternalUserIds,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
applyTemplate,
|
applyTemplate,
|
||||||
buildNotificationMessage,
|
buildNotificationMessage,
|
||||||
|
resolveRecipientExternalUserIds,
|
||||||
sendInstantMessageByChannel,
|
sendInstantMessageByChannel,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -262,6 +262,9 @@ function createPrismaState({ channels = [], deliveries = [], notifications = []
|
|||||||
return { id: `audit-${auditEntries.length}` };
|
return { id: `audit-${auditEntries.length}` };
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
wecomIdentityBinding: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
$transaction: vi.fn().mockImplementation(async (callback) => callback(transactionClient)),
|
$transaction: vi.fn().mockImplementation(async (callback) => callback(transactionClient)),
|
||||||
},
|
},
|
||||||
stateChannels,
|
stateChannels,
|
||||||
@@ -423,6 +426,48 @@ describe('notifications channel routes', () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('creates wecom app message channel without webhook or secret', async () => {
|
||||||
|
const { prisma, stateChannels } = createPrismaState();
|
||||||
|
const app = await buildApp({
|
||||||
|
prisma,
|
||||||
|
config: { fieldEncryptionKey: validFieldEncryptionKey },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/notifications/channels',
|
||||||
|
headers: { authorization: 'Bearer test-token' },
|
||||||
|
payload: {
|
||||||
|
channelType: 'wecom',
|
||||||
|
name: '企微个人应用消息',
|
||||||
|
enabled: true,
|
||||||
|
configJson: {
|
||||||
|
mode: 'wecom_app_message',
|
||||||
|
timeoutMs: 800,
|
||||||
|
routing: {
|
||||||
|
roleCodes: ['manager'],
|
||||||
|
domainScopes: ['workflow'],
|
||||||
|
allowedTypes: ['workflow.task_assigned'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().data.channel.configJson).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
mode: 'wecom_app_message',
|
||||||
|
webhookUrl: null,
|
||||||
|
secretConfigured: false,
|
||||||
|
secretMasked: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(stateChannels[0].config_json.secretEncrypted).toBeUndefined();
|
||||||
|
expect(stateChannels[0].config_json.webhookUrl).toBeNull();
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects secret write when FIELD_ENCRYPTION_KEY is missing', async () => {
|
it('rejects secret write when FIELD_ENCRYPTION_KEY is missing', async () => {
|
||||||
const { prisma } = createPrismaState();
|
const { prisma } = createPrismaState();
|
||||||
const app = await buildApp({ prisma, config: { fieldEncryptionKey: '' } });
|
const app = await buildApp({ prisma, config: { fieldEncryptionKey: '' } });
|
||||||
@@ -585,6 +630,44 @@ describe('notifications channel routes', () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('tests wecom app message channel against current bound admin when available', async () => {
|
||||||
|
const { prisma } = createPrismaState({
|
||||||
|
channels: [
|
||||||
|
buildChannelRecord({
|
||||||
|
config_json: {
|
||||||
|
mode: 'wecom_app_message',
|
||||||
|
webhookUrl: null,
|
||||||
|
timeoutMs: 800,
|
||||||
|
routing: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const app = await buildApp({ prisma });
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/notifications/channels/11111111-1111-4111-8111-111111111111/test',
|
||||||
|
headers: { authorization: 'Bearer test-token' },
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().data.testResult).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
ok: true,
|
||||||
|
code: 'wecom_app_no_bound_recipients',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(prisma.wecomIdentityBinding.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { employee_id: { in: ['employee-1'] } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
it('lists grouped delivery summaries with latest status and attempt count', async () => {
|
it('lists grouped delivery summaries with latest status and attempt count', async () => {
|
||||||
const notification = buildNotificationRecord({
|
const notification = buildNotificationRecord({
|
||||||
id: '55555555-5555-4555-8555-555555555555',
|
id: '55555555-5555-4555-8555-555555555555',
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
const dispatcher = require("../../../../src/services/integrations/dispatcher");
|
const dispatcher = require("../../../../src/services/integrations/dispatcher");
|
||||||
|
const { clearWecomAppAccessTokenCache } = require("../../../../src/services/integrations/wecomAccessToken");
|
||||||
|
|
||||||
describe("Integration Dispatcher", () => {
|
describe("Integration Dispatcher", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
clearWecomAppAccessTokenCache();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it("dispatches erp.reimbursement.paid", async () => {
|
it("dispatches erp.reimbursement.paid", async () => {
|
||||||
const result = await dispatcher.dispatchOutboxEvent({
|
const result = await dispatcher.dispatchOutboxEvent({
|
||||||
config: { erpSyncEnabled: true, erpSyncProvider: "mock" },
|
config: { erpSyncEnabled: true, erpSyncProvider: "mock" },
|
||||||
@@ -72,6 +78,112 @@ describe("Integration Dispatcher", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("dispatches notification.im app message as no-bound when recipient has no wecom binding", async () => {
|
||||||
|
const prisma = {
|
||||||
|
notificationChannel: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
id: "channel-app",
|
||||||
|
channel_type: "wecom",
|
||||||
|
name: "app-message",
|
||||||
|
enabled: true,
|
||||||
|
config_json: { mode: "wecom_app_message", routing: {} },
|
||||||
|
template_json: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
wecomIdentityBinding: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await dispatcher.dispatchOutboxEvent({
|
||||||
|
prisma,
|
||||||
|
config: { imNotifierEnabled: true },
|
||||||
|
eventType: "notification.im",
|
||||||
|
payload: {
|
||||||
|
channelId: "channel-app",
|
||||||
|
channelType: "wecom",
|
||||||
|
title: "title",
|
||||||
|
body: "body",
|
||||||
|
recipientEmployeeIds: ["employee-1"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.channel).toBe("im");
|
||||||
|
expect(result.code).toBe("wecom_app_no_bound_recipients");
|
||||||
|
expect(prisma.wecomIdentityBinding.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { employee_id: { in: ["employee-1"] } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches notification.im app message to bound wecom member", async () => {
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: vi.fn().mockResolvedValue({ errcode: 0, access_token: "token-1", expires_in: 7200 }),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: vi.fn().mockResolvedValue({ errcode: 0, errmsg: "ok" }),
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const result = await dispatcher.dispatchOutboxEvent({
|
||||||
|
prisma: {
|
||||||
|
notificationChannel: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
id: "channel-app",
|
||||||
|
channel_type: "wecom",
|
||||||
|
name: "app-message",
|
||||||
|
enabled: true,
|
||||||
|
config_json: { mode: "wecom_app_message", timeoutMs: 500, routing: {} },
|
||||||
|
template_json: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
wecomIdentityBinding: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([{ external_user_id: "oa_smoke_02" }]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
imNotifierEnabled: true,
|
||||||
|
wecomCorpId: "corp-1",
|
||||||
|
wecomAgentId: "1000002",
|
||||||
|
wecomAgentSecret: "secret-1",
|
||||||
|
},
|
||||||
|
eventType: "notification.im",
|
||||||
|
payload: {
|
||||||
|
channelId: "channel-app",
|
||||||
|
channelType: "wecom",
|
||||||
|
title: "待办通知",
|
||||||
|
body: "请处理审批",
|
||||||
|
detailUrl: "https://oa.example.com/api/auth/deeplink?token=abc",
|
||||||
|
recipientEmployeeIds: ["employee-1"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.channel).toBe("im");
|
||||||
|
expect(result.code).toBe("wecom_app_sent");
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
const sendBody = JSON.parse(fetchMock.mock.calls[1][1].body);
|
||||||
|
expect(sendBody).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
touser: "oa_smoke_02",
|
||||||
|
msgtype: "textcard",
|
||||||
|
agentid: 1000002,
|
||||||
|
textcard: expect.objectContaining({
|
||||||
|
title: "待办通知",
|
||||||
|
url: "https://oa.example.com/api/auth/deeplink?token=abc",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(String(fetchMock.mock.calls[1][0])).toContain("/cgi-bin/message/send");
|
||||||
|
expect(JSON.stringify(result.response)).not.toContain("token-1");
|
||||||
|
});
|
||||||
|
|
||||||
it("throws error for unsupported event", async () => {
|
it("throws error for unsupported event", async () => {
|
||||||
await expect(
|
await expect(
|
||||||
dispatcher.dispatchOutboxEvent({
|
dispatcher.dispatchOutboxEvent({
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ function createPrismaState({ employees = [], channels = [] } = {}) {
|
|||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
roles: (item.roles ?? []).map((roleCode) => ({ role_code: roleCode })),
|
roles: (item.roles ?? []).map((roleCode) => ({ role_code: roleCode })),
|
||||||
|
wecom_identity_binding: item.externalUserId
|
||||||
|
? { external_user_id: item.externalUserId }
|
||||||
|
: null,
|
||||||
}));
|
}));
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -186,6 +189,65 @@ describe('notificationDispatcher', () => {
|
|||||||
expect(deliveryState[0]).toEqual(expect.objectContaining({ status: 'success' }));
|
expect(deliveryState[0]).toEqual(expect.objectContaining({ status: 'success' }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('passes bound wecom user ids to channel sender', async () => {
|
||||||
|
const { prisma } = createPrismaState({
|
||||||
|
employees: [
|
||||||
|
{
|
||||||
|
id: 'manager-1',
|
||||||
|
status: 'active',
|
||||||
|
notification_push_enabled: true,
|
||||||
|
roles: ['manager'],
|
||||||
|
externalUserId: 'oa_smoke_02',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
channels: [buildChannel({ config_json: { mode: 'wecom_app_message', routing: {} } })],
|
||||||
|
});
|
||||||
|
const sendMessage = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
retryable: false,
|
||||||
|
code: 'wecom_app_sent',
|
||||||
|
message: null,
|
||||||
|
response: { errcode: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await dispatchNotificationBatch({
|
||||||
|
prisma,
|
||||||
|
config: {
|
||||||
|
imNotifierEnabled: true,
|
||||||
|
appOrigin: 'https://oa.example.com',
|
||||||
|
jwtSecret: 'test-secret',
|
||||||
|
},
|
||||||
|
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||||
|
requestId: 'req-wecom-bound',
|
||||||
|
notifications: [
|
||||||
|
{
|
||||||
|
id: 'notification-1',
|
||||||
|
recipient_employee_id: 'manager-1',
|
||||||
|
target_type: 'workflow_task',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
type: 'workflow.task_assigned',
|
||||||
|
title: '测试待办',
|
||||||
|
body: '请处理审批',
|
||||||
|
targetType: 'workflow_task',
|
||||||
|
targetId: 'task-1',
|
||||||
|
targetPath: '/workflow/tasks/task-1',
|
||||||
|
sendMessage,
|
||||||
|
retryDelayMs: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendMessage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(sendMessage.mock.calls[0][0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
prisma,
|
||||||
|
payload: expect.objectContaining({
|
||||||
|
recipientEmployeeIds: ['manager-1'],
|
||||||
|
recipientExternalUserIds: ['oa_smoke_02'],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('retries retryable failures until a later success', async () => {
|
it('retries retryable failures until a later success', async () => {
|
||||||
const { prisma, deliveryState } = createPrismaState({
|
const { prisma, deliveryState } = createPrismaState({
|
||||||
employees: [
|
employees: [
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户