diff --git a/client/src/api/auth.ts b/client/src/api/auth.ts index 9e20b644..0901c4fd 100644 --- a/client/src/api/auth.ts +++ b/client/src/api/auth.ts @@ -53,3 +53,10 @@ export async function unbindWecom() { body: JSON.stringify({}), }); } + +export async function updateNotificationPreferences(payload: { notificationPushEnabled: boolean }) { + return apiRequestData<{ notificationPushEnabled: boolean }>("/api/me/notification-preferences", { + method: "PATCH", + body: JSON.stringify(payload), + }); +} diff --git a/client/src/api/notificationChannels.ts b/client/src/api/notificationChannels.ts index 5f2bb511..1184deca 100644 --- a/client/src/api/notificationChannels.ts +++ b/client/src/api/notificationChannels.ts @@ -85,6 +85,28 @@ export type NotificationChannelTestResponse = { }; }; +export type NotificationDeliveryStatus = "pending" | "success" | "failed"; + +export type NotificationDeliveryRecord = { + notificationId: string; + channelId: string; + channelName: string; + channelType: NotificationChannelType; + notificationType: string; + notificationTitle: string; + status: NotificationDeliveryStatus; + attemptCount: number; + latestAttemptAt: string | null; + resultCode: string | null; + errorMessage: string | null; + responseBody: unknown; + notificationCreatedAt: string | null; +}; + +export type NotificationDeliveryListResponse = { + items: NotificationDeliveryRecord[]; +}; + export async function fetchNotificationChannels() { return apiRequestData("/api/notifications/channels"); } @@ -109,3 +131,25 @@ export async function testNotificationChannel(id: string) { body: JSON.stringify({}), }); } + +export async function fetchNotificationDeliveries(filters: { + notificationId?: string | null; + channelId?: string | null; + status?: NotificationDeliveryStatus | "" | null; +}) { + const params = new URLSearchParams(); + if (filters.notificationId) { + params.set("notificationId", filters.notificationId); + } + if (filters.channelId) { + params.set("channelId", filters.channelId); + } + if (filters.status) { + params.set("status", filters.status); + } + + const queryString = params.toString(); + return apiRequestData( + `/api/notifications/deliveries${queryString ? `?${queryString}` : ""}`, + ); +} diff --git a/client/src/api/types.ts b/client/src/api/types.ts index 0f0305c0..83575cae 100644 --- a/client/src/api/types.ts +++ b/client/src/api/types.ts @@ -31,6 +31,7 @@ export type MeResponse = { departmentId: string; directManagerId: string | null; status: string; + notificationPushEnabled: boolean; roleCodes: string[]; wecomBinding?: { externalUserId: string; diff --git a/client/src/auth/AuthContext.tsx b/client/src/auth/AuthContext.tsx index 313af91f..69d3a757 100644 --- a/client/src/auth/AuthContext.tsx +++ b/client/src/auth/AuthContext.tsx @@ -32,6 +32,7 @@ type AuthContextValue = AuthState & { changePassword: (oldPassword: string, newPassword: string) => Promise; logout: () => Promise; bootstrapByRefresh: () => Promise; + refreshMe: () => Promise; clearSession: () => void; offlineAccessMode: boolean; }; @@ -248,6 +249,27 @@ export function AuthProvider({ children }: PropsWithChildren) { } }, [clearSession]); + const refreshMe = useCallback(async () => { + let accessToken = tokenRef.current; + if (!accessToken) { + const refreshed = await refreshSession(); + accessToken = refreshed.accessToken; + tokenRef.current = accessToken; + } + + const me = await fetchCurrentEmployee(accessToken); + const permissions = resolvePermissionsForRoles(me.roleCodes ?? []); + setOfflineAccessMode(false); + writeOfflineAuthSnapshot(me, permissions); + setState((previous) => ({ + ...previous, + accessToken, + me, + permissions, + ready: true, + })); + }, []); + useEffect(() => { configureHttpAuth({ getAccessToken: () => tokenRef.current, @@ -311,10 +333,11 @@ export function AuthProvider({ children }: PropsWithChildren) { changePassword, logout, bootstrapByRefresh, + refreshMe, clearSession, offlineAccessMode, }), - [bootstrapByRefresh, changePassword, clearSession, login, logout, offlineAccessMode, state], + [bootstrapByRefresh, changePassword, clearSession, login, logout, offlineAccessMode, refreshMe, state], ); return {children}; diff --git a/client/src/pages/NotificationChannelsPage.tsx b/client/src/pages/NotificationChannelsPage.tsx index 562ac730..4cc0a044 100644 --- a/client/src/pages/NotificationChannelsPage.tsx +++ b/client/src/pages/NotificationChannelsPage.tsx @@ -4,11 +4,13 @@ import { toast } from "sonner"; import { mapApiErrorMessage } from "../api/errorMessages"; import { createNotificationChannel, + fetchNotificationDeliveries, fetchNotificationChannels, testNotificationChannel, updateNotificationChannel, type NotificationChannel, type NotificationChannelConfig, + type NotificationDeliveryStatus, type NotificationChannelPayload, type NotificationChannelTemplate, type NotificationChannelWriteConfig, @@ -171,6 +173,12 @@ function formatMode(mode: string) { return mode === "wecom_webhook" ? "Webhook" : "Mock"; } +function formatDeliveryStatus(status: NotificationDeliveryStatus) { + if (status === "success") return "成功"; + if (status === "failed") return "失败"; + return "处理中"; +} + function formatSummary(values: string[], labelMap: Record, emptyLabel: string) { if (!values.length) return emptyLabel; return values.map((value) => labelMap[value] ?? value).join("、"); @@ -219,11 +227,23 @@ export function NotificationChannelsPage() { const [editingId, setEditingId] = useState(null); const [form, setForm] = useState(buildInitialForm); const [lastTestResult, setLastTestResult] = useState(null); + const [deliveryFilters, setDeliveryFilters] = useState<{ channelId: string; status: NotificationDeliveryStatus | "" }>({ + channelId: "", + status: "", + }); const channelsQuery = useQuery({ queryKey: ["notification-channels"], queryFn: () => fetchNotificationChannels(), }); + const deliveriesQuery = useQuery({ + queryKey: ["notification-deliveries", deliveryFilters], + queryFn: () => + fetchNotificationDeliveries({ + channelId: deliveryFilters.channelId || null, + status: deliveryFilters.status || null, + }), + }); const saveMutation = useMutation({ mutationFn: async () => { @@ -308,7 +328,7 @@ export function NotificationChannelsPage() {
新建渠道 @@ -404,6 +424,97 @@ export function NotificationChannelsPage() { ) : null}
+
+
+
+

最近投递记录

+

展示业务通知同步外推的最近状态,不包含页面里的“测试发送”结果。

+
+
+ + +
+
+ + {deliveriesQuery.isLoading ? : null} + + {deliveriesQuery.isError ? ( +

+ {resolveError(deliveriesQuery.error)} +

+ ) : null} + + {!deliveriesQuery.isLoading && !deliveriesQuery.isError ? ( +
+ + + + 通知 + 渠道 + 状态 + 尝试次数 + 结果码 + 最近尝试 + 错误摘要 + + + + {(deliveriesQuery.data?.items ?? []).length ? ( + (deliveriesQuery.data?.items ?? []).map((item) => ( + + +
{item.notificationTitle || item.notificationType}
+
{item.notificationType}
+
+ +
{item.channelName}
+
{formatChannelType(item.channelType)}
+
+ {formatDeliveryStatus(item.status)} + {item.attemptCount} + {item.resultCode ?? "-"} + {formatDateTime(item.latestAttemptAt)} + {item.errorMessage ?? "-"} +
+ )) + ) : ( + + + 暂无投递记录。 + + + )} +
+
+
+ ) : null} +
+
diff --git a/issues/2026-04-04_r50-notification-push.csv b/issues/2026-04-04_r50-notification-push.csv new file mode 100644 index 00000000..023cdb34 --- /dev/null +++ b/issues/2026-04-04_r50-notification-push.csv @@ -0,0 +1,9 @@ +ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes +"A1","R50 执行合同建档与范围冻结","创建并校验 R50 通知渠道实际推送执行合同 CSV,冻结范围仅包含 NotificationDelivery 模型、同步 notificationDispatcher、目标 15 类业务通知接入、投递记录查询与个人外部推送开关;明确本轮不改邮件逻辑、不改通知模板结构、不改 outbox worker。","issues/2026-04-04_r50-notification-push.csv 列完整、Dependencies 为 JSON 数组字符串、Test_Method 可复现;范围仅覆盖 R50,不混入 R49 合同台账增强或新的权限码设计。","命令:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-04-04_r50-notification-push.csv。检查:1) 对照 AGENTS.md 与 issues/README.md 确认 12 列完整;2) A2-A8 依赖链符合 backend→frontend→reviewer→qa 收口;3) 方案明确目标事件改为同步 dispatcher,不再继续调用现有 enqueueImNotifications 以避免重复投递。","manual","DONE","DONE","DONE","issues/2026-04-04_r50-notification-push.csv","[]","回滚路径:删除本次 CSV 不影响业务。2026-04-04:Issue CSV 已创建并通过 validate_issues_csv.py 校验,范围与依赖已冻结。" +"A2","通知投递模型与个人偏好迁移","在 Prisma schema 中新增 NotificationDelivery 模型与 Employee.notification_push_enabled 字段,补齐关联、索引与迁移脚本,保证默认值兼容存量员工。","Prisma schema 与迁移可通过校验;Employee.notification_push_enabled 默认 true;NotificationDelivery 可记录 notification_id、channel_id、status、attempt_no、result_code、error_message、response_body、attempted_at;索引满足通知和渠道维度查询。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate。检查:1) schema.prisma 可通过 validate;2) migration.sql 能创建新表和字段;3) Notes 写明回滚时先下线代码再回滚迁移。","manual","DONE","DONE","DONE","server/prisma/schema.prisma|server/prisma/migrations","[""A1""]","回滚路径:先回退应用代码,再执行反向迁移移除 notification_deliveries 表与 employees.notification_push_enabled 字段。2026-04-04:R50 迁移与 schema 已落地,npx prisma validate 通过。2026-04-05:公网 smoke 回归通过,迁移与个人推送偏好链路验收完成。" +"A3","同步 dispatcher 与投递持久化","新增 server/src/services/notificationDispatcher.js,复用现有渠道路由、模板和企微发送逻辑,实现提交事务后的同步外部推送、3 次内重试、delivery 落库与脱敏日志。","dispatcher 能按 roleCodes/domainScopes/allowedTypes 聚合匹配渠道;过滤 notification_push_enabled=false 的员工;mock、wecom_webhook、429/5xx/timeout、非 retryable 失败、无匹配渠道、IM 总开关关闭等路径均有明确结果;delivery 每次尝试先写 pending 后更新 success/failed。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && node --check src/services/notificationDispatcher.js && npm test -- tests/unit/services/notificationDispatcher.test.js。业务:1) mock 渠道成功落一条 success 记录;2) retryable 错误最多尝试 3 次;3) opt-out 员工不会触发外推;4) 日志不输出标题、正文、金额等敏感内容。","manual","DONE","DONE","DONE","server/src/services/notificationDispatcher.js|server/src/services/notifications/index.js|server/src/services/notifications/channels.js|server/src/services/notifications/imRouting.js|server/tests/unit/services/notificationDispatcher.test.js","[""A2""]","回滚路径:恢复到仅站内通知 + 旧 outbox IM 路由,删除 dispatcher 调用。response_body 入库但前端不原样透出。2026-04-04:dispatcher、detailUrl 复用与 delivery 落库已实现;node --check 与 notificationDispatcher.test.js 通过。2026-04-05:公网 smoke 回归通过,dispatcher 投递与重试链路验收完成。" +"A4","目标业务事件切换到同步外推","将 workflow、reimbursements、purchaseRequests、travelRequests、assetMovements、inventoryChecks 六组目标流程从“createNotifications + enqueueImNotifications”切到“createNotifications + notificationDispatcher”,并保持邮件逻辑与业务事务边界不变。","15 类目标通知类型全部通过同步 dispatcher 发送;外部推送失败不会回滚业务单据;现有 sendNotificationEmailSafe 行为不回归;auth.js 与 todos.js 保持原行为。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && node --check src/routes/workflow.js && node --check src/routes/reimbursements.js && node --check src/routes/purchaseRequests.js && node --check src/routes/travelRequests.js && node --check src/routes/assetMovements.js && node --check src/routes/inventoryChecks.js && npm test -- tests/unit/routes/reimbursement.submit-side-effects.test.js tests/unit/routes/purchase.submit-side-effects.test.js。业务:1) 审批待办分配仍创建站内通知并同步尝试外推;2) 报销付款、资产分配、盘点完成主流程成功时,即使 webhook 失败也不回滚接口成功结果。","manual","DONE","DONE","DONE","server/src/routes/workflow.js|server/src/routes/reimbursements.js|server/src/routes/purchaseRequests.js|server/src/routes/travelRequests.js|server/src/routes/assetMovements.js|server/src/routes/inventoryChecks.js|server/tests/unit/routes/reimbursement.submit-side-effects.test.js|server/tests/unit/routes/purchase.submit-side-effects.test.js","[""A3""]","回滚路径:恢复目标流程中的 enqueueImNotifications 调用并移除 dispatcher 接入。2026-04-04:六组目标路由已切换到事务后同步 dispatcher;相关 node --check 与两组 side-effects 单测通过。2026-04-05:公网 smoke 回归通过,目标业务事件外推链路验收完成。" +"A5","投递记录查询 API 与个人偏好 API","扩展 /api/me 响应,新增 PATCH /api/me/notification-preferences 与 GET /api/notifications/deliveries,保证本人只能修改自己的外部推送开关,通知渠道管理员可查询最近投递记录。","/api/me 返回 notificationPushEnabled;PATCH /api/me/notification-preferences 仅接受 { notificationPushEnabled: boolean };GET /api/notifications/deliveries 支持 notificationId/channelId/status 过滤且权限固定为 notification.channel.manage;返回最近记录含状态、尝试次数、结果码与错误摘要。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && node --check src/routes/me.js && node --check src/routes/notifications.js && npm test -- tests/unit/routes/me.test.js tests/unit/routes/notifications.test.js。业务:1) 登录员工可开关自己的 notificationPushEnabled;2) 无 notification.channel.manage 权限访问 deliveries 返回 403;3) channelId/status 过滤返回正确记录。","manual","DONE","DONE","DONE","server/src/routes/me.js|server/src/routes/notifications.js|server/tests/unit/routes/me.test.js|server/tests/unit/routes/notifications.test.js","[""A2"",""A3""]","回滚路径:恢复 /api/me 原响应并删除新偏好接口与 deliveries 查询接口。2026-04-04:/api/me 与 deliveries 查询已实现;node --check、me.test.js 与 notifications.test.js 通过。2026-04-05:公网 smoke 回归通过,投递记录查询与个人偏好接口验收完成。" +"A6","通知渠道页投递记录 UI","扩展 client/src/api/notificationChannels.ts 与 NotificationChannelsPage,新增最近投递记录区域和按渠道/状态筛选,不影响现有渠道 CRUD 与测试发送。","通知渠道页能展示最近 delivery 汇总记录,支持 channelId/status 筛选;列表展示渠道名、锚定通知标题摘要、状态、尝试次数、结果码、最近尝试时间、错误摘要;测试发送结果仍只在现有即时结果卡片展示。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 打开通知渠道页可见 delivery 区域;2) 选择状态或渠道筛选后列表刷新;3) 现有新建/编辑/测试渠道流程不回归。","manual","DONE","DONE","DONE","client/src/api/notificationChannels.ts|client/src/pages/NotificationChannelsPage.tsx","[""A5""]","回滚路径:移除 delivery UI 与前端 API 封装,不影响既有渠道配置功能。2026-04-04:NotificationChannelsPage 已新增 recent deliveries 区域和筛选;client typecheck/build 通过。2026-04-05:公网 smoke 回归通过,通知渠道页投递记录展示验收完成。" +"A7","个人设置页外部推送开关与 auth 状态同步","扩展 MeResponse/AuthContext/ProfilePage,新增外部通知推送总开关,并在修改后同步刷新当前会话里的 me 状态。","个人设置页可看到外部通知推送说明与 checkbox;保存后当前页面和 AuthContext 中的 notificationPushEnabled 同步更新;关闭后仅停止外部渠道推送,站内通知与企业微信绑定入口不受影响。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 个人设置页勾选开关后提示成功并刷新当前状态;2) 重新进入页面仍保持后端返回值;3) 个人设置页其余完整档案入口、企业微信绑定卡片不回归。","manual","DONE","DONE","DONE","client/src/api/auth.ts|client/src/api/types.ts|client/src/auth/AuthContext.tsx|client/src/pages/ProfilePage.tsx","[""A5""]","回滚路径:删除 notificationPushEnabled 前端字段与开关区块,恢复 ProfilePage 原状。2026-04-04:ProfilePage 已新增外部推送总开关,AuthContext 增加 refreshMe;client typecheck/build 通过。2026-04-05:公网 smoke 回归通过,个人设置页推送开关验收完成。" +"A8","R50 reviewer QA 与收口","完成 reviewer 风格复核、后端/前端 post-check、关键业务回归和 CSV 状态回填,确认不存在重复投递、事务回滚误用、权限泄露与隐私日志问题。","A2-A7 的实现、review 与 regression 证据完整;后端语法/单测、前端 typecheck/build 全部通过;关键业务事件在 mock 或异常 webhook 下均能看到正确的 delivery 结果与个人开关隔离效果。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/notifications.js && node --check src/routes/me.js && node --check src/services/notificationDispatcher.js && npm test -- tests/unit/services/notificationDispatcher.test.js tests/unit/routes/notifications.test.js tests/unit/routes/me.test.js tests/unit/routes/reimbursement.submit-side-effects.test.js tests/unit/routes/purchase.submit-side-effects.test.js && cd ../client && npm run typecheck && npm run build。业务:1) 启用 mock 渠道后审批/付款/资产/盘点事件产生 delivery 记录;2) webhook 异常时看到最多 3 次尝试并最终 failed;3) 员工关闭外推后相关业务仍保留站内通知。","manual","DONE","DONE","DONE","issues/2026-04-04_r50-notification-push.csv|server/src/services/notificationDispatcher.js|server/src/routes/notifications.js|server/src/routes/me.js|client/src/pages/NotificationChannelsPage.tsx|client/src/pages/ProfilePage.tsx","[""A4"",""A6"",""A7""]","回滚路径:若发现重复投递或权限问题,先关闭 IM_NOTIFIER_ENABLED/相关渠道并回退本轮代码,再按 A2 Notes 处理迁移回滚。2026-04-04:已完成本地 reviewer 风格复核、git diff --check、Prisma validate、5 组后端单测、client typecheck/build。2026-04-05:公网 smoke 测试通过(OpenClaw 验收 2026-04-05),R50 通知推送主链路回归完成。" diff --git a/server/prisma/migrations/20260404123000_r50_notification_push/migration.sql b/server/prisma/migrations/20260404123000_r50_notification_push/migration.sql new file mode 100644 index 00000000..22413123 --- /dev/null +++ b/server/prisma/migrations/20260404123000_r50_notification_push/migration.sql @@ -0,0 +1,43 @@ +-- CreateEnum +CREATE TYPE "NotificationDeliveryStatus" AS ENUM ('pending', 'success', 'failed'); + +-- AlterTable +ALTER TABLE "employees" + ADD COLUMN "notification_push_enabled" BOOLEAN NOT NULL DEFAULT true; + +-- CreateTable +CREATE TABLE "notification_deliveries" ( + "id" UUID NOT NULL, + "notification_id" UUID NOT NULL, + "channel_id" UUID NOT NULL, + "status" "NotificationDeliveryStatus" NOT NULL, + "attempt_no" INTEGER NOT NULL, + "result_code" VARCHAR(64), + "error_message" TEXT, + "response_body" JSONB, + "attempted_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "notification_deliveries_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "notification_deliveries" + ADD CONSTRAINT "notification_deliveries_notification_id_fkey" + FOREIGN KEY ("notification_id") REFERENCES "notifications"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notification_deliveries" + ADD CONSTRAINT "notification_deliveries_channel_id_fkey" + FOREIGN KEY ("channel_id") REFERENCES "notification_channels"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- CreateIndex +CREATE INDEX "idx_notification_deliveries_notification_attempted" + ON "notification_deliveries"("notification_id", "attempted_at"); + +-- CreateIndex +CREATE INDEX "idx_notification_deliveries_channel_attempted" + ON "notification_deliveries"("channel_id", "attempted_at"); + +-- CreateIndex +CREATE INDEX "idx_notification_deliveries_status_attempted" + ON "notification_deliveries"("status", "attempted_at"); diff --git a/server/src/routes/assetMovements.js b/server/src/routes/assetMovements.js index 1a85967e..dfa8d499 100644 --- a/server/src/routes/assetMovements.js +++ b/server/src/routes/assetMovements.js @@ -3,10 +3,10 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { buildAuditLogData } = require("../utils/audit"); const { sendData } = require("../utils/reply"); -const { createNotifications } = require("../services/notifications"); -const { enqueueImNotifications } = require("../services/notifications/imEnqueue"); +const { createNotificationsWithRecords } = require("../services/notifications"); const { sendNotificationEmailSafe } = require("../services/email"); const { NOTIFICATION_TYPES } = require("../constants/notificationTypes"); +const { flushNotificationDispatchTasks } = require("../services/notificationDispatcher"); const UUID_SCHEMA = z.string().uuid(); const NOTE_SCHEMA = z.string().trim().max(500).nullable().optional(); @@ -204,6 +204,7 @@ async function assetMovementsRoutes(fastify) { const body = parseBody(ASSIGN_BODY_SCHEMA, request.body); const note = normalizeNote(body.note); const emailTasks = []; + const dispatchTasks = []; const result = await fastify.prisma.$transaction(async (tx) => { const asset = await ensureAssetExists(tx, assetId); @@ -261,7 +262,7 @@ async function assetMovementsRoutes(fastify) { const recipients = uniq([employee.id]); const title = `【资产领用】资产卡片 ${asset.asset_no}`; const bodyText = `资产卡片 ${asset.asset_no} 已分配给你,请在资产台账中查看。`; - await createNotifications(tx, { + const notifications = await createNotificationsWithRecords(tx, { recipientEmployeeIds: recipients, type: NOTIFICATION_TYPES.assetAssigned, title, @@ -269,18 +270,14 @@ async function assetMovementsRoutes(fastify) { targetType: "asset_card", targetId: asset.id, }); - await enqueueImNotifications(tx, { - config: fastify.config, - logger: fastify.log, - recipientEmployeeIds: recipients, + dispatchTasks.push({ + notifications, type: NOTIFICATION_TYPES.assetAssigned, title, body: bodyText, targetType: "asset_card", targetId: asset.id, targetPath: "/assets", - entityType: "asset_card", - entityId: asset.id, actorId: request.employee.id, }); emailTasks.push({ @@ -303,6 +300,13 @@ async function assetMovementsRoutes(fastify) { ...task, }); } + await flushNotificationDispatchTasks({ + prisma: fastify.prisma, + config: fastify.config, + logger: fastify.log, + requestId: request.id, + tasks: dispatchTasks, + }); return sendData(reply, request, { assetId: result.updatedAsset.id, @@ -326,6 +330,7 @@ async function assetMovementsRoutes(fastify) { const body = parseBody(RETURN_BODY_SCHEMA, request.body); const note = normalizeNote(body.note); const emailTasks = []; + const dispatchTasks = []; const result = await fastify.prisma.$transaction(async (tx) => { const asset = await ensureAssetExists(tx, assetId); @@ -368,7 +373,7 @@ async function assetMovementsRoutes(fastify) { const recipients = uniq([asset.assignee_employee_id]); const title = `【资产归还】资产卡片 ${asset.asset_no}`; const bodyText = `资产卡片 ${asset.asset_no} 已完成归还。`; - await createNotifications(tx, { + const notifications = await createNotificationsWithRecords(tx, { recipientEmployeeIds: recipients, type: NOTIFICATION_TYPES.assetReturned, title, @@ -376,18 +381,14 @@ async function assetMovementsRoutes(fastify) { targetType: "asset_card", targetId: asset.id, }); - await enqueueImNotifications(tx, { - config: fastify.config, - logger: fastify.log, - recipientEmployeeIds: recipients, + dispatchTasks.push({ + notifications, type: NOTIFICATION_TYPES.assetReturned, title, body: bodyText, targetType: "asset_card", targetId: asset.id, targetPath: "/assets", - entityType: "asset_card", - entityId: asset.id, actorId: request.employee.id, }); emailTasks.push({ @@ -410,6 +411,13 @@ async function assetMovementsRoutes(fastify) { ...task, }); } + await flushNotificationDispatchTasks({ + prisma: fastify.prisma, + config: fastify.config, + logger: fastify.log, + requestId: request.id, + tasks: dispatchTasks, + }); return sendData(reply, request, { assetId: result.updatedAsset.id, @@ -436,6 +444,7 @@ async function assetMovementsRoutes(fastify) { const hasLocationField = Object.prototype.hasOwnProperty.call(body, "toLocation"); const nextLocation = hasLocationField ? normalizeLocation(body.toLocation) : undefined; const emailTasks = []; + const dispatchTasks = []; const result = await fastify.prisma.$transaction(async (tx) => { const asset = await ensureAssetExists(tx, assetId); @@ -531,7 +540,7 @@ async function assetMovementsRoutes(fastify) { ]); const title = `【资产调拨】资产卡片 ${asset.asset_no}`; const bodyText = `资产卡片 ${asset.asset_no} 已完成调拨,请在资产台账中查看最新归属与位置。`; - await createNotifications(tx, { + const notifications = await createNotificationsWithRecords(tx, { recipientEmployeeIds: recipients, type: NOTIFICATION_TYPES.assetTransferred, title, @@ -539,18 +548,14 @@ async function assetMovementsRoutes(fastify) { targetType: "asset_card", targetId: asset.id, }); - await enqueueImNotifications(tx, { - config: fastify.config, - logger: fastify.log, - recipientEmployeeIds: recipients, + dispatchTasks.push({ + notifications, type: NOTIFICATION_TYPES.assetTransferred, title, body: bodyText, targetType: "asset_card", targetId: asset.id, targetPath: "/assets", - entityType: "asset_card", - entityId: asset.id, actorId: request.employee.id, }); emailTasks.push({ @@ -573,6 +578,13 @@ async function assetMovementsRoutes(fastify) { ...task, }); } + await flushNotificationDispatchTasks({ + prisma: fastify.prisma, + config: fastify.config, + logger: fastify.log, + requestId: request.id, + tasks: dispatchTasks, + }); return sendData(reply, request, { assetId: result.updatedAsset.id, diff --git a/server/src/routes/inventoryChecks.js b/server/src/routes/inventoryChecks.js index e9eb63e7..1e60cd27 100644 --- a/server/src/routes/inventoryChecks.js +++ b/server/src/routes/inventoryChecks.js @@ -3,11 +3,11 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { buildAuditLogData } = require("../utils/audit"); const { issueDocumentNumber } = require("../services/documentSequence"); -const { createNotifications, resolveRecipientsForAssignee } = require("../services/notifications"); -const { enqueueImNotifications } = require("../services/notifications/imEnqueue"); +const { createNotificationsWithRecords, resolveRecipientsForAssignee } = require("../services/notifications"); const { sendNotificationEmailSafe } = require("../services/email"); const { NOTIFICATION_TYPES } = require("../constants/notificationTypes"); const { sendData } = require("../utils/reply"); +const { flushNotificationDispatchTasks } = require("../services/notificationDispatcher"); const UUID_SCHEMA = z.string().uuid(); const INVENTORY_SCOPE_TYPES = ["all", "legal_entity", "cost_center", "location"]; @@ -517,6 +517,7 @@ async function inventoryChecksRoutes(fastify) { async (request, reply) => { const inventoryCheckId = parseUuid("id", request.params?.id); const emailTasks = []; + const dispatchTasks = []; const inventoryCheck = await fastify.prisma.$transaction(async (tx) => { const current = await ensureInventoryCheck(tx, inventoryCheckId); ensureCheckStatus(current, "counting"); @@ -581,7 +582,7 @@ async function inventoryChecksRoutes(fastify) { const title = `【盘点完成】盘点单 ${updated.check_no}`; const bodyText = `盘点单 ${updated.check_no} 已完成,请在资产盘点中查看结果。`; - await createNotifications(tx, { + const notifications = await createNotificationsWithRecords(tx, { recipientEmployeeIds: recipients, type: NOTIFICATION_TYPES.inventoryCheckCompleted, title, @@ -589,19 +590,14 @@ async function inventoryChecksRoutes(fastify) { targetType: "inventory_check", targetId: updated.id, }); - - await enqueueImNotifications(tx, { - config: fastify.config, - logger: fastify.log, - recipientEmployeeIds: recipients, + dispatchTasks.push({ + notifications, type: NOTIFICATION_TYPES.inventoryCheckCompleted, title, body: bodyText, targetType: "inventory_check", targetId: updated.id, targetPath: "/inventory-checks", - entityType: "inventory_check", - entityId: updated.id, actorId: request.employee.id, }); @@ -625,6 +621,13 @@ async function inventoryChecksRoutes(fastify) { ...task, }); } + await flushNotificationDispatchTasks({ + prisma: fastify.prisma, + config: fastify.config, + logger: fastify.log, + requestId: request.id, + tasks: dispatchTasks, + }); return sendData(reply, request, { inventoryCheck: mapInventoryCheck(inventoryCheck) }); }, diff --git a/server/src/routes/me.js b/server/src/routes/me.js index 2d3f790a..078d6571 100644 --- a/server/src/routes/me.js +++ b/server/src/routes/me.js @@ -1,5 +1,33 @@ +const { z } = require("zod"); + +const { AppError } = require("../utils/AppError"); +const { buildAuditLogData } = require("../utils/audit"); const { sendData } = require("../utils/reply"); +const NOTIFICATION_PREFERENCES_SCHEMA = z + .object({ + notificationPushEnabled: z.boolean(), + }) + .strict(); + +function buildValidationError(details) { + return new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details, + }); +} + +function parseNotificationPreferences(body) { + const parsed = NOTIFICATION_PREFERENCES_SCHEMA.safeParse(body ?? {}); + if (!parsed.success) { + throw buildValidationError(parsed.error.flatten()); + } + + return parsed.data; +} + async function meRoutes(fastify) { fastify.get( "/me", @@ -18,6 +46,7 @@ async function meRoutes(fastify) { department_id: true, direct_manager_id: true, status: true, + notification_push_enabled: true, roles: { select: { role_code: true } }, wecom_identity_binding: { select: { external_user_id: true, created_at: true } }, }, @@ -33,12 +62,49 @@ async function meRoutes(fastify) { departmentId: employee?.department_id ?? "", directManagerId: employee?.direct_manager_id ?? null, status: employee?.status ?? "active", + notificationPushEnabled: employee?.notification_push_enabled ?? true, roleCodes, wecomBinding: employee?.wecom_identity_binding ? { externalUserId: employee.wecom_identity_binding.external_user_id, createdAt: employee.wecom_identity_binding.created_at?.toISOString?.() ?? null } : null, }); }, ); + + fastify.patch( + "/me/notification-preferences", + { + preHandler: fastify.authenticate, + }, + async (request, reply) => { + const body = parseNotificationPreferences(request.body); + const employee = await fastify.prisma.employee.update({ + where: { id: request.employee.id }, + data: { + notification_push_enabled: body.notificationPushEnabled, + updated_by: request.employee.id, + }, + select: { + id: true, + notification_push_enabled: true, + }, + }); + + await fastify.prisma.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "EMPLOYEE_NOTIFICATION_PREFERENCE_UPDATED", + targetType: "employee", + targetId: employee.id, + extra: { + notificationPushEnabled: employee.notification_push_enabled, + }, + }), + }); + + return sendData(reply, request, { + notificationPushEnabled: employee.notification_push_enabled, + }); + }, + ); } module.exports = meRoutes; - diff --git a/server/src/routes/notifications.js b/server/src/routes/notifications.js index c0c25895..b47cdbd3 100644 --- a/server/src/routes/notifications.js +++ b/server/src/routes/notifications.js @@ -19,6 +19,13 @@ const LIST_QUERY_SCHEMA = z.object({ pageSize: z.coerce.number().int().min(1).max(100).optional(), unreadOnly: z.enum(['0', '1', 'true', 'false']).optional(), }); +const DELIVERY_STATUS_VALUES = Object.freeze(['pending', 'success', 'failed']); +const DELIVERY_QUERY_SCHEMA = z.object({ + notificationId: z.string().uuid().optional(), + channelId: z.string().uuid().optional(), + status: z.enum(DELIVERY_STATUS_VALUES).optional(), + limit: z.coerce.number().int().min(1).max(200).optional(), +}); const CHANNEL_ID_SCHEMA = z.string().uuid(); const ROLE_CODE_VALUES = Object.freeze(Object.keys(ROLE_PERMISSIONS)); @@ -141,6 +148,20 @@ function parseListQuery(query) { }; } +function parseDeliveryQuery(query) { + const parsed = DELIVERY_QUERY_SCHEMA.safeParse(query ?? {}); + if (!parsed.success) { + throw buildValidationError(parsed.error.flatten()); + } + + return { + notificationId: parsed.data.notificationId ?? null, + channelId: parsed.data.channelId ?? null, + status: parsed.data.status ?? null, + limit: parsed.data.limit ?? 200, + }; +} + function parseNotificationId(value) { const parsed = CHANNEL_ID_SCHEMA.safeParse(value); if (!parsed.success) { @@ -249,6 +270,54 @@ function mapNotificationChannel(record, options = {}) { }; } +function mapNotificationDeliverySummary(record) { + return { + notificationId: record.notification_id, + channelId: record.channel_id, + channelName: record.channel?.name ?? '-', + channelType: record.channel?.channel_type ?? 'wecom', + notificationType: record.notification?.type ?? '', + notificationTitle: record.notification?.title ?? '', + status: record.status, + attemptCount: 1, + latestAttemptAt: record.attempted_at?.toISOString?.() ?? null, + resultCode: record.result_code ?? null, + errorMessage: record.error_message ?? null, + responseBody: record.response_body ?? null, + notificationCreatedAt: record.notification?.created_at?.toISOString?.() ?? null, + }; +} + +function summarizeDeliveryAttempts(items, query) { + const summaries = []; + const summaryMap = new Map(); + const skippedKeys = new Set(); + + for (const item of items) { + const key = `${item.notification_id}:${item.channel_id}`; + if (skippedKeys.has(key)) { + continue; + } + + const current = summaryMap.get(key); + if (!current) { + if (query.status && item.status !== query.status) { + skippedKeys.add(key); + continue; + } + + const summary = mapNotificationDeliverySummary(item); + summaryMap.set(key, summary); + summaries.push(summary); + continue; + } + + current.attemptCount += 1; + } + + return summaries; +} + function handleNotificationChannelWriteError(error) { if (error instanceof AppError) { throw error; @@ -412,6 +481,51 @@ async function notificationsRoutes(fastify) { }, ); + fastify.get( + '/notifications/deliveries', + { + preHandler: fastify.requirePermission('notification.channel.manage'), + schema: { + tags: ['notifications'], + summary: 'List Notification Deliveries', + }, + }, + async (request, reply) => { + const query = parseDeliveryQuery(request.query); + const where = { + ...(query.notificationId ? { notification_id: query.notificationId } : {}), + ...(query.channelId ? { channel_id: query.channelId } : {}), + }; + + const items = await fastify.prisma.notificationDelivery.findMany({ + where, + orderBy: [{ attempted_at: 'desc' }, { attempt_no: 'desc' }], + take: query.limit, + include: { + channel: { + select: { + id: true, + channel_type: true, + name: true, + }, + }, + notification: { + select: { + id: true, + type: true, + title: true, + created_at: true, + }, + }, + }, + }); + + return sendData(reply, request, { + items: summarizeDeliveryAttempts(items, query), + }); + }, + ); + fastify.get( '/notifications/channels', { diff --git a/server/src/routes/purchaseRequests.js b/server/src/routes/purchaseRequests.js index e52f9851..cbfae0b8 100644 --- a/server/src/routes/purchaseRequests.js +++ b/server/src/routes/purchaseRequests.js @@ -26,14 +26,14 @@ const { } = require("../services/integrations/erpProcurement"); const { reserveBudgetForEntity, releaseReservedBudgetIfAny } = require("../services/purchaseBudget"); const { sendNotificationEmailSafe } = require("../services/email"); -const { createNotifications, resolveRecipientsForAssignee } = require("../services/notifications"); +const { createNotificationsWithRecords, resolveRecipientsForAssignee } = require("../services/notifications"); const { buildPendingTaskCopy, buildWorkflowEntityMeta, } = require("../services/notifications/workflowCopy"); -const { enqueueImNotifications } = require("../services/notifications/imEnqueue"); const { NOTIFICATION_TYPES } = require("../constants/notificationTypes"); const { openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage"); +const { dispatchNotificationBatch } = require("../services/notificationDispatcher"); const UUID_SCHEMA = z.string().uuid(); const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); @@ -896,8 +896,8 @@ function buildSkippedNotificationResult(reason) { return { skipped: true, reason }; } -function buildSkippedImResult(reason) { - return { queued: 0, duplicated: 0, matchedChannels: [], skippedReason: reason }; +function buildSkippedDispatchResult(reason) { + return { deliveredChannels: [], skippedReason: reason, notificationCount: 0, eligibleRecipientCount: 0, matchedChannelCount: 0 }; } function buildSkippedEmailResult(reason, failed = 0) { @@ -934,8 +934,8 @@ async function finalizePurchaseSubmitSideEffects({ actorId, loadSummary = loadPurchaseSubmitSummary, resolveRecipients = resolveRecipientsForAssignee, - createAppNotifications = createNotifications, - enqueueIm = enqueueImNotifications, + createAppNotifications = createNotificationsWithRecords, + dispatchNotifications = dispatchNotificationBatch, sendEmailSafe = sendNotificationEmailSafe, }) { let copy = buildPendingTaskCopy( @@ -986,7 +986,7 @@ async function finalizePurchaseSubmitSideEffects({ return { recipientEmployeeIds: [], notification: buildSkippedNotificationResult("recipient_resolution_failed"), - im: buildSkippedImResult("recipient_resolution_failed"), + dispatch: buildSkippedDispatchResult("recipient_resolution_failed"), email: buildSkippedEmailResult("recipient_resolution_failed"), }; } @@ -995,14 +995,15 @@ async function finalizePurchaseSubmitSideEffects({ return { recipientEmployeeIds: [], notification: buildSkippedNotificationResult("no_recipients"), - im: buildSkippedImResult("no_recipients"), + dispatch: buildSkippedDispatchResult("no_recipients"), email: buildSkippedEmailResult("no_recipients"), }; } let notification = buildSkippedNotificationResult("not_attempted"); + let notificationRecords = []; try { - notification = await createAppNotifications(prisma, { + notificationRecords = await createAppNotifications(prisma, { recipientEmployeeIds, type: NOTIFICATION_TYPES.workflowTaskAssigned, title, @@ -1010,6 +1011,7 @@ async function finalizePurchaseSubmitSideEffects({ targetType: "workflow_task", targetId: workflowTaskId, }); + notification = { count: notificationRecords.length }; } catch (error) { logger?.error?.( { @@ -1024,20 +1026,20 @@ async function finalizePurchaseSubmitSideEffects({ notification = buildSkippedNotificationResult("notification_failed"); } - let im = buildSkippedImResult("not_attempted"); + let dispatch = buildSkippedDispatchResult("not_attempted"); try { - im = await enqueueIm(prisma, { + dispatch = await dispatchNotifications({ + prisma, config, logger, - recipientEmployeeIds, + requestId, + notifications: notificationRecords, type: NOTIFICATION_TYPES.workflowTaskAssigned, title, body, targetType: "workflow_task", targetId: workflowTaskId, targetPath, - entityType: "purchase_request", - entityId: purchaseRequestId, actorId, }); } catch (error) { @@ -1049,9 +1051,9 @@ async function finalizePurchaseSubmitSideEffects({ workflowTaskId, recipients: recipientEmployeeIds.length, }, - "Failed to enqueue purchase submit IM notifications", + "Failed to dispatch purchase submit notifications", ); - im = buildSkippedImResult("enqueue_im_failed"); + dispatch = buildSkippedDispatchResult("dispatch_failed"); } let email = buildSkippedEmailResult("not_attempted"); @@ -1085,7 +1087,7 @@ async function finalizePurchaseSubmitSideEffects({ return { recipientEmployeeIds, notification, - im, + dispatch, email, }; } diff --git a/server/src/routes/reimbursements.js b/server/src/routes/reimbursements.js index 34560aa4..6db84b0b 100644 --- a/server/src/routes/reimbursements.js +++ b/server/src/routes/reimbursements.js @@ -5,17 +5,17 @@ const { buildAuditLogData } = require("../utils/audit"); const { currentDateString, parseDateOnlyString } = require("../utils/date"); const { findActiveDelegationForDelegate } = require("../utils/delegation"); const { - createNotifications, + createNotificationsWithRecords, resolveRecipientsForAssignee, } = require("../services/notifications"); const { buildPendingTaskCopy, buildWorkflowEntityMeta, } = require("../services/notifications/workflowCopy"); -const { enqueueImNotifications } = require("../services/notifications/imEnqueue"); const { NOTIFICATION_TYPES } = require("../constants/notificationTypes"); const { sendNotificationEmailSafe } = require("../services/email"); const { enqueueReimbursementJournalExport } = require("../services/integrations/erpJournal"); +const { dispatchNotificationBatch, flushNotificationDispatchTasks } = require("../services/notificationDispatcher"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); const { ensureCutoverAllowed } = require("../services/cutover"); const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool"); @@ -450,8 +450,8 @@ function buildSkippedNotificationResult(reason) { return { skipped: true, reason }; } -function buildSkippedImResult(reason) { - return { queued: 0, duplicated: 0, matchedChannels: [], skippedReason: reason }; +function buildSkippedDispatchResult(reason) { + return { deliveredChannels: [], skippedReason: reason, notificationCount: 0, eligibleRecipientCount: 0, matchedChannelCount: 0 }; } function buildSkippedEmailResult(reason, failed = 0) { @@ -488,8 +488,8 @@ async function finalizeReimbursementSubmitSideEffects({ actorId, loadSummary = loadReimbursementSubmitSummary, resolveRecipients = resolveRecipientsForAssignee, - createAppNotifications = createNotifications, - enqueueIm = enqueueImNotifications, + createAppNotifications = createNotificationsWithRecords, + dispatchNotifications = dispatchNotificationBatch, sendEmailSafe = sendNotificationEmailSafe, }) { let copy = buildPendingTaskCopy( @@ -540,7 +540,7 @@ async function finalizeReimbursementSubmitSideEffects({ return { recipientEmployeeIds: [], notification: buildSkippedNotificationResult("recipient_resolution_failed"), - im: buildSkippedImResult("recipient_resolution_failed"), + dispatch: buildSkippedDispatchResult("recipient_resolution_failed"), email: buildSkippedEmailResult("recipient_resolution_failed"), }; } @@ -549,14 +549,15 @@ async function finalizeReimbursementSubmitSideEffects({ return { recipientEmployeeIds: [], notification: buildSkippedNotificationResult("no_recipients"), - im: buildSkippedImResult("no_recipients"), + dispatch: buildSkippedDispatchResult("no_recipients"), email: buildSkippedEmailResult("no_recipients"), }; } let notification = buildSkippedNotificationResult("not_attempted"); + let notificationRecords = []; try { - notification = await createAppNotifications(prisma, { + notificationRecords = await createAppNotifications(prisma, { recipientEmployeeIds, type: NOTIFICATION_TYPES.workflowTaskAssigned, title, @@ -564,6 +565,7 @@ async function finalizeReimbursementSubmitSideEffects({ targetType: "workflow_task", targetId: workflowTaskId, }); + notification = { count: notificationRecords.length }; } catch (error) { logger?.error?.( { @@ -578,20 +580,20 @@ async function finalizeReimbursementSubmitSideEffects({ notification = buildSkippedNotificationResult("notification_failed"); } - let im = buildSkippedImResult("not_attempted"); + let dispatch = buildSkippedDispatchResult("not_attempted"); try { - im = await enqueueIm(prisma, { + dispatch = await dispatchNotifications({ + prisma, config, logger, - recipientEmployeeIds, + requestId, + notifications: notificationRecords, type: NOTIFICATION_TYPES.workflowTaskAssigned, title, body, targetType: "workflow_task", targetId: workflowTaskId, targetPath, - entityType: "reimbursement", - entityId: reimbursementId, actorId, }); } catch (error) { @@ -603,9 +605,9 @@ async function finalizeReimbursementSubmitSideEffects({ workflowTaskId, recipients: recipientEmployeeIds.length, }, - "Failed to enqueue reimbursement submit IM notifications", + "Failed to dispatch reimbursement submit notifications", ); - im = buildSkippedImResult("enqueue_im_failed"); + dispatch = buildSkippedDispatchResult("dispatch_failed"); } let email = buildSkippedEmailResult("not_attempted"); @@ -639,7 +641,7 @@ async function finalizeReimbursementSubmitSideEffects({ return { recipientEmployeeIds, notification, - im, + dispatch, email, }; } @@ -2042,6 +2044,7 @@ async function reimbursementRoutes(fastify) { const payInput = parsePayBody(request.body); let response = null; const emailTasks = []; + const dispatchTasks = []; await fastify.prisma.$transaction(async (tx) => { const reimbursementRows = await tx.$queryRaw` @@ -2196,7 +2199,7 @@ async function reimbursementRoutes(fastify) { const recipients = Array.from( new Set([reimbursement.owner_id, reimbursement.submitter_id].filter(Boolean)), ); - await createNotifications(tx, { + const notifications = await createNotificationsWithRecords(tx, { recipientEmployeeIds: recipients, type: NOTIFICATION_TYPES.reimbursementPaid, title: `【已付款】报销单 ${reimbursement.form_number ?? reimbursementId}`, @@ -2204,19 +2207,14 @@ async function reimbursementRoutes(fastify) { targetType: "reimbursement_form", targetId: reimbursementId, }); - - await enqueueImNotifications(tx, { - config: fastify.config, - logger: fastify.log, - recipientEmployeeIds: recipients, + dispatchTasks.push({ + notifications, type: NOTIFICATION_TYPES.reimbursementPaid, title: `【已付款】报销单 ${reimbursement.form_number ?? reimbursementId}`, body: `报销单 ${reimbursement.form_number ?? reimbursementId} 已完成付款。`, targetType: "reimbursement_form", targetId: reimbursementId, targetPath: `/reimbursements/${reimbursementId}`, - entityType: "reimbursement", - entityId: reimbursementId, actorId: request.employee.id, }); @@ -2276,6 +2274,13 @@ async function reimbursementRoutes(fastify) { ...task, }); } + await flushNotificationDispatchTasks({ + prisma: fastify.prisma, + config: fastify.config, + logger: fastify.log, + requestId: request.id, + tasks: dispatchTasks, + }); return sendData(reply, request, response); }, diff --git a/server/src/routes/travelRequests.js b/server/src/routes/travelRequests.js index af651a0b..a6426159 100644 --- a/server/src/routes/travelRequests.js +++ b/server/src/routes/travelRequests.js @@ -9,12 +9,11 @@ const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool const { reserveBudgetForEntity, releaseReservedBudgetIfAny } = require("../services/purchaseBudget"); const { parseWorkflowSteps, resolveActionableStep } = require("../utils/workflow"); const { sendNotificationEmailSafe } = require("../services/email"); -const { createNotifications, resolveRecipientsForAssignee } = require("../services/notifications"); +const { createNotificationsWithRecords, resolveRecipientsForAssignee } = require("../services/notifications"); const { buildPendingTaskCopy, buildWorkflowEntityMeta, } = require("../services/notifications/workflowCopy"); -const { enqueueImNotifications } = require("../services/notifications/imEnqueue"); const { buildTravelPaymentTrack } = require("../services/travelPayments"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); const { ensureCutoverAllowed } = require("../services/cutover"); @@ -24,6 +23,7 @@ const { resolveEntityPrecheck, } = require("../services/feeControl"); const { NOTIFICATION_TYPES } = require("../constants/notificationTypes"); +const { flushNotificationDispatchTasks } = require("../services/notificationDispatcher"); const UUID_SCHEMA = z.string().uuid(); const BOOLEAN_QUERY_SCHEMA = z.enum(["0", "1", "true", "false"]).optional(); @@ -929,6 +929,7 @@ async function createTravelRequestsRoutes(fastify) { const travelRequestId = parseTravelRequestId(request.params?.id); let response = null; const emailTasks = []; + const dispatchTasks = []; await fastify.prisma.$transaction(async (tx) => { const travelRequest = await loadTravelRequestForUpdate(tx, travelRequestId); @@ -1113,7 +1114,7 @@ async function createTravelRequestsRoutes(fastify) { ); const { title, body } = pendingCopy; - await createNotifications(tx, { + const notifications = await createNotificationsWithRecords(tx, { recipientEmployeeIds: recipients, type: NOTIFICATION_TYPES.workflowTaskAssigned, title, @@ -1121,19 +1122,14 @@ async function createTravelRequestsRoutes(fastify) { targetType: "workflow_task", targetId: workflowTask.id, }); - - await enqueueImNotifications(tx, { - config: fastify.config, - logger: fastify.log, - recipientEmployeeIds: recipients, + dispatchTasks.push({ + notifications, type: NOTIFICATION_TYPES.workflowTaskAssigned, title, body, targetType: "workflow_task", targetId: workflowTask.id, targetPath: `/workflow/tasks/${workflowTask.id}`, - entityType: "travel_request", - entityId: travelRequestId, actorId: request.employee.id, }); @@ -1184,6 +1180,13 @@ async function createTravelRequestsRoutes(fastify) { ...emailTask, }); } + await flushNotificationDispatchTasks({ + prisma: fastify.prisma, + config: fastify.config, + logger: fastify.log, + requestId: request.id, + tasks: dispatchTasks, + }); return sendData(reply, request, response); }, diff --git a/server/src/routes/workflow.js b/server/src/routes/workflow.js index c51d15be..3ff3aa0a 100644 --- a/server/src/routes/workflow.js +++ b/server/src/routes/workflow.js @@ -13,7 +13,7 @@ const { loadPurchaseRequestErpContext, } = require("../services/integrations/erpProcurement"); const { - createNotifications, + createNotificationsWithRecords, resolveRecipientsForAssignee, } = require("../services/notifications"); const { @@ -24,9 +24,9 @@ const { buildWorkflowEntityMeta, normalizeWorkflowEntityType, } = require("../services/notifications/workflowCopy"); -const { enqueueImNotifications } = require("../services/notifications/imEnqueue"); const { sendNotificationEmailSafe } = require("../services/email"); const { NOTIFICATION_TYPES } = require("../constants/notificationTypes"); +const { flushNotificationDispatchTasks } = require("../services/notificationDispatcher"); const { findActiveDelegationForDelegate, listActiveDelegationPrincipals, @@ -577,10 +577,9 @@ function resolveWorkflowOutcomeNotificationType(entityType, outcome) { async function queueTaskAssignedNotifications({ tx, - config, - logger, actorId, emailTasks, + dispatchTasks, recipientEmployeeIds, title, body, @@ -593,7 +592,7 @@ async function queueTaskAssignedNotifications({ return; } - await createNotifications(tx, { + const notifications = await createNotificationsWithRecords(tx, { recipientEmployeeIds: recipients, type: NOTIFICATION_TYPES.workflowTaskAssigned, title, @@ -601,19 +600,14 @@ async function queueTaskAssignedNotifications({ targetType: 'workflow_task', targetId: taskId, }); - - await enqueueImNotifications(tx, { - config, - logger, - recipientEmployeeIds: recipients, + dispatchTasks.push({ + notifications, type: NOTIFICATION_TYPES.workflowTaskAssigned, title, body, targetType: 'workflow_task', targetId: taskId, targetPath: `/workflow/tasks/${taskId}`, - entityType, - entityId, actorId, }); @@ -628,10 +622,9 @@ async function queueTaskAssignedNotifications({ async function queueWorkflowOutcomeNotifications({ tx, - config, - logger, actorId, emailTasks, + dispatchTasks, workflowInstance, outcome, comment = null, @@ -656,7 +649,7 @@ async function queueWorkflowOutcomeNotifications({ ? buildRejectedCopy(entityMeta, comment) : buildReturnedCopy(entityMeta, comment); - await createNotifications(tx, { + const notifications = await createNotificationsWithRecords(tx, { recipientEmployeeIds: recipients, type: notificationType, title: copy.title, @@ -664,19 +657,14 @@ async function queueWorkflowOutcomeNotifications({ targetType: entityMeta.targetType, targetId: workflowInstance.entity_id, }); - - await enqueueImNotifications(tx, { - config, - logger, - recipientEmployeeIds: recipients, + dispatchTasks.push({ + notifications, type: notificationType, title: copy.title, body: copy.body, targetType: entityMeta.targetType, targetId: workflowInstance.entity_id, targetPath: entityMeta.detailPath, - entityType: normalizedEntityType, - entityId: workflowInstance.entity_id, actorId, }); @@ -1355,6 +1343,7 @@ async function workflowRoutes(fastify) { const taskId = parseTaskId(request.params?.taskId); let result = null; const emailTasks = []; + const dispatchTasks = []; await fastify.prisma.$transaction(async (tx) => { const task = await lockWorkflowTask(tx, taskId); @@ -1458,10 +1447,9 @@ async function workflowRoutes(fastify) { await queueTaskAssignedNotifications({ tx, - config: fastify.config, - logger: fastify.log, actorId: request.employee.id, emailTasks, + dispatchTasks, recipientEmployeeIds: recipients, title: pendingCopy.title, body: pendingCopy.body, @@ -1613,10 +1601,9 @@ async function workflowRoutes(fastify) { try { await queueWorkflowOutcomeNotifications({ tx, - config: fastify.config, - logger: fastify.log, actorId: request.employee.id, emailTasks, + dispatchTasks, workflowInstance, outcome: 'approved', }); @@ -1647,6 +1634,13 @@ async function workflowRoutes(fastify) { ...task, }); } + await flushNotificationDispatchTasks({ + prisma: fastify.prisma, + config: fastify.config, + logger: fastify.log, + requestId: request.id, + tasks: dispatchTasks, + }); return sendData(reply, request, result); }, @@ -1662,6 +1656,7 @@ async function workflowRoutes(fastify) { const comment = parseRequiredComment(request.body); let result = null; const emailTasks = []; + const dispatchTasks = []; await fastify.prisma.$transaction(async (tx) => { const task = await lockWorkflowTask(tx, taskId); @@ -1797,10 +1792,9 @@ async function workflowRoutes(fastify) { try { await queueWorkflowOutcomeNotifications({ tx, - config: fastify.config, - logger: fastify.log, actorId: request.employee.id, emailTasks, + dispatchTasks, workflowInstance, outcome: 'rejected', comment, @@ -1831,6 +1825,13 @@ async function workflowRoutes(fastify) { ...task, }); } + await flushNotificationDispatchTasks({ + prisma: fastify.prisma, + config: fastify.config, + logger: fastify.log, + requestId: request.id, + tasks: dispatchTasks, + }); return sendData(reply, request, result); }, @@ -1846,6 +1847,7 @@ async function workflowRoutes(fastify) { const comment = parseRequiredComment(request.body); let result = null; const emailTasks = []; + const dispatchTasks = []; await fastify.prisma.$transaction(async (tx) => { const task = await lockWorkflowTask(tx, taskId); @@ -1981,10 +1983,9 @@ async function workflowRoutes(fastify) { try { await queueWorkflowOutcomeNotifications({ tx, - config: fastify.config, - logger: fastify.log, actorId: request.employee.id, emailTasks, + dispatchTasks, workflowInstance, outcome: 'returned', comment, @@ -2015,6 +2016,13 @@ async function workflowRoutes(fastify) { ...task, }); } + await flushNotificationDispatchTasks({ + prisma: fastify.prisma, + config: fastify.config, + logger: fastify.log, + requestId: request.id, + tasks: dispatchTasks, + }); return sendData(reply, request, result); }, @@ -2030,6 +2038,7 @@ async function workflowRoutes(fastify) { const { toApproverId, reason } = parseTransferBody(request.body); let result = null; const emailTasks = []; + const dispatchTasks = []; await fastify.prisma.$transaction(async (tx) => { const task = await lockWorkflowTask(tx, taskId); @@ -2121,10 +2130,9 @@ async function workflowRoutes(fastify) { const pendingCopy = buildPendingTaskCopy(entityMeta, { transfer: true }); await queueTaskAssignedNotifications({ tx, - config: fastify.config, - logger: fastify.log, actorId: request.employee.id, emailTasks, + dispatchTasks, recipientEmployeeIds: [toApproverId], title: pendingCopy.title, body: pendingCopy.body, @@ -2160,6 +2168,13 @@ async function workflowRoutes(fastify) { ...task, }); } + await flushNotificationDispatchTasks({ + prisma: fastify.prisma, + config: fastify.config, + logger: fastify.log, + requestId: request.id, + tasks: dispatchTasks, + }); return sendData(reply, request, result); }, diff --git a/server/src/services/notificationDispatcher.js b/server/src/services/notificationDispatcher.js new file mode 100644 index 00000000..37281890 --- /dev/null +++ b/server/src/services/notificationDispatcher.js @@ -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, + }, +}; diff --git a/server/src/services/notifications/detailUrl.js b/server/src/services/notifications/detailUrl.js new file mode 100644 index 00000000..9ab14d42 --- /dev/null +++ b/server/src/services/notifications/detailUrl.js @@ -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, +}; diff --git a/server/src/services/notifications/imEnqueue.js b/server/src/services/notifications/imEnqueue.js index 3e033660..4bf8a295 100644 --- a/server/src/services/notifications/imEnqueue.js +++ b/server/src/services/notifications/imEnqueue.js @@ -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) { diff --git a/server/src/services/notifications/index.js b/server/src/services/notifications/index.js index 23beedfe..9b6549c1 100644 --- a/server/src/services/notifications/index.js +++ b/server/src/services/notifications/index.js @@ -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, }; - diff --git a/server/tests/unit/routes/me.test.js b/server/tests/unit/routes/me.test.js new file mode 100644 index 00000000..53d7de34 --- /dev/null +++ b/server/tests/unit/routes/me.test.js @@ -0,0 +1,140 @@ +const Fastify = require('fastify'); + +const meRoutes = require('../../../src/routes/me'); +const { AppError } = require('../../../src/utils/AppError'); + +function createPrismaState(overrides = {}) { + const auditEntries = []; + const employeeRecord = { + id: 'employee-1', + employee_no: 'E001', + name: '测试员工', + phone: '13800000000', + email: 'employee@test.com', + department_id: 'department-1', + direct_manager_id: 'manager-1', + status: 'active', + notification_push_enabled: true, + roles: [{ role_code: 'employee' }], + wecom_identity_binding: null, + ...overrides, + }; + + return { + prisma: { + employee: { + findUnique: vi.fn().mockResolvedValue(employeeRecord), + update: vi.fn().mockImplementation(async ({ data }) => { + employeeRecord.notification_push_enabled = data.notification_push_enabled; + return { + id: employeeRecord.id, + notification_push_enabled: employeeRecord.notification_push_enabled, + }; + }), + }, + auditLog: { + create: vi.fn().mockImplementation(async ({ data }) => { + auditEntries.push(data); + return { id: `audit-${auditEntries.length}` }; + }), + }, + }, + employeeRecord, + auditEntries, + }; +} + +async function buildApp(prisma) { + const app = Fastify(); + app.decorate('prisma', prisma); + app.decorate('authenticate', async (request) => { + if (!request.headers.authorization) { + throw new AppError({ + code: 'AUTH_001', + message: '未登录', + statusCode: 401, + }); + } + + request.employee = { + id: 'employee-1', + permissionCodes: [], + }; + }); + app.setErrorHandler((error, request, reply) => { + if (error instanceof AppError) { + return reply.status(error.statusCode).send({ + requestId: request.id, + error: { + code: error.code, + message: error.message, + details: error.details ?? {}, + }, + }); + } + + return reply.status(500).send({ + requestId: request.id, + error: { + code: 'SYS_001', + message: '服务器内部错误', + details: {}, + }, + }); + }); + + await app.register(meRoutes, { prefix: '/api' }); + return app; +} + +describe('me routes', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('returns notificationPushEnabled in /me response', async () => { + const { prisma } = createPrismaState({ notification_push_enabled: false }); + const app = await buildApp(prisma); + + const response = await app.inject({ + method: 'GET', + url: '/api/me', + headers: { authorization: 'Bearer test-token' }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data).toEqual( + expect.objectContaining({ + id: 'employee-1', + notificationPushEnabled: false, + }), + ); + + await app.close(); + }); + + it('updates notification preferences for current employee', async () => { + const { prisma, employeeRecord, auditEntries } = createPrismaState(); + const app = await buildApp(prisma); + + const response = await app.inject({ + method: 'PATCH', + url: '/api/me/notification-preferences', + headers: { authorization: 'Bearer test-token' }, + payload: { + notificationPushEnabled: false, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data).toEqual({ notificationPushEnabled: false }); + expect(employeeRecord.notification_push_enabled).toBe(false); + expect(auditEntries[0]).toEqual( + expect.objectContaining({ + action: 'EMPLOYEE_NOTIFICATION_PREFERENCE_UPDATED', + }), + ); + + await app.close(); + }); +}); diff --git a/server/tests/unit/routes/notifications.test.js b/server/tests/unit/routes/notifications.test.js index 34da4233..d9bd5821 100644 --- a/server/tests/unit/routes/notifications.test.js +++ b/server/tests/unit/routes/notifications.test.js @@ -32,8 +32,45 @@ function buildChannelRecord(overrides = {}) { }; } -function createPrismaState({ channels = [] } = {}) { +function buildNotificationRecord(overrides = {}) { + const now = new Date('2026-03-13T09:00:00.000Z'); + return { + id: '22222222-2222-4222-8222-222222222222', + recipient_employee_id: 'employee-2', + type: 'workflow.task_assigned', + title: '【待办】采购申请待审批', + body: '请及时处理审批任务。', + target_type: 'workflow_task', + target_id: '33333333-3333-4333-8333-333333333333', + read_at: null, + created_at: now, + ...overrides, + }; +} + +function buildDeliveryRecord(overrides = {}) { + const notification = overrides.notification ?? buildNotificationRecord(); + const channel = overrides.channel ?? buildChannelRecord(); + const now = new Date('2026-03-13T10:00:00.000Z'); + return { + id: '44444444-4444-4444-8444-444444444444', + notification_id: notification.id, + channel_id: channel.id, + status: 'success', + attempt_no: 1, + result_code: 'mock_sent', + error_message: null, + response_body: { mode: 'mock' }, + attempted_at: now, + notification, + channel, + ...overrides, + }; +} + +function createPrismaState({ channels = [], deliveries = [] } = {}) { const stateChannels = channels.map((channel) => ({ ...channel })); + const stateDeliveries = deliveries.map((delivery) => ({ ...delivery })); const auditEntries = []; const notificationChannel = { @@ -117,6 +154,26 @@ function createPrismaState({ channels = [] } = {}) { return { prisma: { notificationChannel, + notificationDelivery: { + findMany: vi.fn().mockImplementation(async ({ where, take }) => { + let items = [...stateDeliveries]; + if (where?.notification_id) { + items = items.filter((item) => item.notification_id === where.notification_id); + } + if (where?.channel_id) { + items = items.filter((item) => item.channel_id === where.channel_id); + } + + items.sort((left, right) => { + const timeDiff = right.attempted_at.getTime() - left.attempted_at.getTime(); + if (timeDiff !== 0) { + return timeDiff; + } + return Number(right.attempt_no ?? 0) - Number(left.attempt_no ?? 0); + }); + return items.slice(0, take ?? items.length); + }), + }, auditLog: { create: vi.fn().mockImplementation(async ({ data }) => { auditEntries.push(data); @@ -443,4 +500,79 @@ describe('notifications channel routes', () => { await app.close(); }); + + it('lists grouped delivery summaries with latest status and attempt count', async () => { + const notification = buildNotificationRecord({ + id: '55555555-5555-4555-8555-555555555555', + title: '【待办】采购申请 CG-001 待审批', + }); + const channel = buildChannelRecord(); + const { prisma } = createPrismaState({ + channels: [channel], + deliveries: [ + buildDeliveryRecord({ + id: 'delivery-2', + notification, + channel, + notification_id: notification.id, + channel_id: channel.id, + status: 'failed', + attempt_no: 2, + result_code: 'wecom_timeout', + error_message: 'timeout', + attempted_at: new Date('2026-03-13T10:02:00.000Z'), + }), + buildDeliveryRecord({ + id: 'delivery-1', + notification, + channel, + notification_id: notification.id, + channel_id: channel.id, + status: 'failed', + attempt_no: 1, + result_code: 'wecom_http_429', + error_message: 'rate limit', + attempted_at: new Date('2026-03-13T10:01:00.000Z'), + }), + ], + }); + const app = await buildApp({ prisma }); + + const response = await app.inject({ + method: 'GET', + url: '/api/notifications/deliveries?status=failed', + headers: { authorization: 'Bearer test-token' }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.items).toEqual([ + expect.objectContaining({ + notificationId: notification.id, + channelId: channel.id, + channelName: channel.name, + notificationTitle: notification.title, + status: 'failed', + attemptCount: 2, + resultCode: 'wecom_timeout', + }), + ]); + + await app.close(); + }); + + it('rejects delivery list without notification.channel.manage permission', async () => { + const { prisma } = createPrismaState(); + const app = await buildApp({ prisma, permissionCodes: [] }); + + const response = await app.inject({ + method: 'GET', + url: '/api/notifications/deliveries', + headers: { authorization: 'Bearer test-token' }, + }); + + expect(response.statusCode).toBe(403); + expect(response.json().error.code).toBe('PERM_001'); + + await app.close(); + }); }); diff --git a/server/tests/unit/routes/purchase.submit-side-effects.test.js b/server/tests/unit/routes/purchase.submit-side-effects.test.js index d9a2bf45..57824879 100644 --- a/server/tests/unit/routes/purchase.submit-side-effects.test.js +++ b/server/tests/unit/routes/purchase.submit-side-effects.test.js @@ -31,11 +31,15 @@ describe('purchase submit side effects helper', () => { }, }), resolveRecipients: vi.fn().mockResolvedValue(['manager-1']), - createAppNotifications: vi.fn().mockResolvedValue({ count: 1 }), - enqueueIm: vi.fn().mockResolvedValue({ - queued: 1, - duplicated: 0, - matchedChannels: [{ id: 'channel-1' }], + createAppNotifications: vi.fn().mockResolvedValue([ + { + id: 'notification-1', + recipient_employee_id: 'manager-1', + }, + ]), + dispatchNotifications: vi.fn().mockResolvedValue({ + deliveredChannels: [{ channelId: 'channel-1', status: 'success' }], + skippedReason: null, }), sendEmailSafe: vi.fn().mockResolvedValue({ email: { skipped: true, sent: 0, failed: 0 }, @@ -50,7 +54,7 @@ describe('purchase submit side effects helper', () => { warn: vi.fn(), }; const createAppNotifications = vi.fn(); - const enqueueIm = vi.fn(); + const dispatchNotifications = vi.fn(); const sendEmailSafe = vi.fn(); const result = await finalizePurchaseSubmitSideEffects( @@ -58,20 +62,20 @@ describe('purchase submit side effects helper', () => { logger, resolveRecipients: vi.fn().mockRejectedValue(new Error('resolve failed')), createAppNotifications, - enqueueIm, + dispatchNotifications, sendEmailSafe, }), ); expect(logger.error).toHaveBeenCalledTimes(1); expect(createAppNotifications).not.toHaveBeenCalled(); - expect(enqueueIm).not.toHaveBeenCalled(); + expect(dispatchNotifications).not.toHaveBeenCalled(); expect(sendEmailSafe).not.toHaveBeenCalled(); expect(result).toEqual( expect.objectContaining({ recipientEmployeeIds: [], notification: expect.objectContaining({ skipped: true, reason: 'recipient_resolution_failed' }), - im: expect.objectContaining({ queued: 0, skippedReason: 'recipient_resolution_failed' }), + dispatch: expect.objectContaining({ skippedReason: 'recipient_resolution_failed' }), email: expect.objectContaining({ skipped: true, reason: 'recipient_resolution_failed' }), }), ); @@ -82,7 +86,7 @@ describe('purchase submit side effects helper', () => { error: vi.fn(), warn: vi.fn(), }; - const enqueueIm = vi.fn().mockRejectedValue(new Error('im failed')); + const dispatchNotifications = vi.fn().mockRejectedValue(new Error('dispatch failed')); const sendEmailSafe = vi.fn().mockResolvedValue({ email: { skipped: true, sent: 0, failed: 0 }, }); @@ -91,30 +95,34 @@ describe('purchase submit side effects helper', () => { createParams({ logger, createAppNotifications: vi.fn().mockRejectedValue(new Error('notifications failed')), - enqueueIm, + dispatchNotifications, sendEmailSafe, }), ); expect(logger.error).toHaveBeenCalledTimes(2); - expect(enqueueIm).toHaveBeenCalledTimes(1); + expect(dispatchNotifications).toHaveBeenCalledTimes(1); expect(sendEmailSafe).toHaveBeenCalledTimes(1); expect(result).toEqual( expect.objectContaining({ recipientEmployeeIds: ['manager-1'], notification: expect.objectContaining({ skipped: true, reason: 'notification_failed' }), - im: expect.objectContaining({ queued: 0, skippedReason: 'enqueue_im_failed' }), + dispatch: expect.objectContaining({ skippedReason: 'dispatch_failed' }), email: expect.objectContaining({ skipped: true, sent: 0, failed: 0 }), }), ); }); it('builds structured purchase copy for app notification, IM and email', async () => { - const createAppNotifications = vi.fn().mockResolvedValue({ count: 1 }); - const enqueueIm = vi.fn().mockResolvedValue({ - queued: 1, - duplicated: 0, - matchedChannels: [{ id: 'channel-1' }], + const createAppNotifications = vi.fn().mockResolvedValue([ + { + id: 'notification-1', + recipient_employee_id: 'manager-1', + }, + ]); + const dispatchNotifications = vi.fn().mockResolvedValue({ + deliveredChannels: [{ channelId: 'channel-1', status: 'success' }], + skippedReason: null, }); const sendEmailSafe = vi.fn().mockResolvedValue({ email: { skipped: true, sent: 0, failed: 0 }, @@ -123,7 +131,7 @@ describe('purchase submit side effects helper', () => { await finalizePurchaseSubmitSideEffects( createParams({ createAppNotifications, - enqueueIm, + dispatchNotifications, sendEmailSafe, }), ); @@ -135,11 +143,16 @@ describe('purchase submit side effects helper', () => { body: '申请人:李经理\n金额:5600.00\n单号:CG-20260324-001\n动作:请及时处理审批任务。', }), ); - expect(enqueueIm).toHaveBeenCalledWith( - {}, + expect(dispatchNotifications).toHaveBeenCalledWith( expect.objectContaining({ + prisma: {}, title: '【待办】采购申请 CG-20260324-001 待审批', body: '申请人:李经理\n金额:5600.00\n单号:CG-20260324-001\n动作:请及时处理审批任务。', + notifications: [ + expect.objectContaining({ + id: 'notification-1', + }), + ], }), ); expect(sendEmailSafe).toHaveBeenCalledWith( diff --git a/server/tests/unit/routes/reimbursement.submit-side-effects.test.js b/server/tests/unit/routes/reimbursement.submit-side-effects.test.js index de67e6e7..8bc30c85 100644 --- a/server/tests/unit/routes/reimbursement.submit-side-effects.test.js +++ b/server/tests/unit/routes/reimbursement.submit-side-effects.test.js @@ -31,11 +31,15 @@ describe('reimbursement submit side effects helper', () => { }, }), resolveRecipients: vi.fn().mockResolvedValue(['manager-1']), - createAppNotifications: vi.fn().mockResolvedValue({ count: 1 }), - enqueueIm: vi.fn().mockResolvedValue({ - queued: 1, - duplicated: 0, - matchedChannels: [{ id: 'channel-1' }], + createAppNotifications: vi.fn().mockResolvedValue([ + { + id: 'notification-1', + recipient_employee_id: 'manager-1', + }, + ]), + dispatchNotifications: vi.fn().mockResolvedValue({ + deliveredChannels: [{ channelId: 'channel-1', status: 'success' }], + skippedReason: null, }), sendEmailSafe: vi.fn().mockResolvedValue({ email: { skipped: true, sent: 0, failed: 0 }, @@ -50,7 +54,7 @@ describe('reimbursement submit side effects helper', () => { warn: vi.fn(), }; const createAppNotifications = vi.fn(); - const enqueueIm = vi.fn(); + const dispatchNotifications = vi.fn(); const sendEmailSafe = vi.fn(); const result = await finalizeReimbursementSubmitSideEffects( @@ -58,20 +62,20 @@ describe('reimbursement submit side effects helper', () => { logger, resolveRecipients: vi.fn().mockRejectedValue(new Error('resolve failed')), createAppNotifications, - enqueueIm, + dispatchNotifications, sendEmailSafe, }), ); expect(logger.error).toHaveBeenCalledTimes(1); expect(createAppNotifications).not.toHaveBeenCalled(); - expect(enqueueIm).not.toHaveBeenCalled(); + expect(dispatchNotifications).not.toHaveBeenCalled(); expect(sendEmailSafe).not.toHaveBeenCalled(); expect(result).toEqual( expect.objectContaining({ recipientEmployeeIds: [], notification: expect.objectContaining({ skipped: true, reason: 'recipient_resolution_failed' }), - im: expect.objectContaining({ queued: 0, skippedReason: 'recipient_resolution_failed' }), + dispatch: expect.objectContaining({ skippedReason: 'recipient_resolution_failed' }), email: expect.objectContaining({ skipped: true, reason: 'recipient_resolution_failed' }), }), ); @@ -82,7 +86,7 @@ describe('reimbursement submit side effects helper', () => { error: vi.fn(), warn: vi.fn(), }; - const enqueueIm = vi.fn().mockRejectedValue(new Error('im failed')); + const dispatchNotifications = vi.fn().mockRejectedValue(new Error('dispatch failed')); const sendEmailSafe = vi.fn().mockResolvedValue({ email: { skipped: true, sent: 0, failed: 0 }, }); @@ -91,30 +95,34 @@ describe('reimbursement submit side effects helper', () => { createParams({ logger, createAppNotifications: vi.fn().mockRejectedValue(new Error('notifications failed')), - enqueueIm, + dispatchNotifications, sendEmailSafe, }), ); expect(logger.error).toHaveBeenCalledTimes(2); - expect(enqueueIm).toHaveBeenCalledTimes(1); + expect(dispatchNotifications).toHaveBeenCalledTimes(1); expect(sendEmailSafe).toHaveBeenCalledTimes(1); expect(result).toEqual( expect.objectContaining({ recipientEmployeeIds: ['manager-1'], notification: expect.objectContaining({ skipped: true, reason: 'notification_failed' }), - im: expect.objectContaining({ queued: 0, skippedReason: 'enqueue_im_failed' }), + dispatch: expect.objectContaining({ skippedReason: 'dispatch_failed' }), email: expect.objectContaining({ skipped: true, sent: 0, failed: 0 }), }), ); }); it('builds structured reimbursement copy for app notification, IM and email', async () => { - const createAppNotifications = vi.fn().mockResolvedValue({ count: 1 }); - const enqueueIm = vi.fn().mockResolvedValue({ - queued: 1, - duplicated: 0, - matchedChannels: [{ id: 'channel-1' }], + const createAppNotifications = vi.fn().mockResolvedValue([ + { + id: 'notification-1', + recipient_employee_id: 'manager-1', + }, + ]); + const dispatchNotifications = vi.fn().mockResolvedValue({ + deliveredChannels: [{ channelId: 'channel-1', status: 'success' }], + skippedReason: null, }); const sendEmailSafe = vi.fn().mockResolvedValue({ email: { skipped: true, sent: 0, failed: 0 }, @@ -123,7 +131,7 @@ describe('reimbursement submit side effects helper', () => { await finalizeReimbursementSubmitSideEffects( createParams({ createAppNotifications, - enqueueIm, + dispatchNotifications, sendEmailSafe, }), ); @@ -135,11 +143,16 @@ describe('reimbursement submit side effects helper', () => { body: '申请人:王小明\n金额:1288.00\n单号:BX-20260324-001\n动作:请及时处理审批任务。', }), ); - expect(enqueueIm).toHaveBeenCalledWith( - {}, + expect(dispatchNotifications).toHaveBeenCalledWith( expect.objectContaining({ + prisma: {}, title: '【待办】报销单 BX-20260324-001 待审批', body: '申请人:王小明\n金额:1288.00\n单号:BX-20260324-001\n动作:请及时处理审批任务。', + notifications: [ + expect.objectContaining({ + id: 'notification-1', + }), + ], }), ); expect(sendEmailSafe).toHaveBeenCalledWith( diff --git a/server/tests/unit/services/notificationDispatcher.test.js b/server/tests/unit/services/notificationDispatcher.test.js new file mode 100644 index 00000000..acee41b5 --- /dev/null +++ b/server/tests/unit/services/notificationDispatcher.test.js @@ -0,0 +1,363 @@ +const { dispatchNotificationBatch } = require('../../../src/services/notificationDispatcher'); + +function buildChannel(overrides = {}) { + return { + id: 'channel-1', + channel_type: 'wecom', + name: '审批通知群', + enabled: true, + config_json: { + mode: 'mock', + webhookUrl: null, + timeoutMs: 5000, + routing: { + roleCodes: [], + domainScopes: [], + allowedTypes: [], + }, + }, + template_json: { + title: '{{title}}', + body: '{{body}}', + footer: '来自海音达工作台', + deepLinkLabel: '查看详情', + }, + ...overrides, + }; +} + +function createPrismaState({ employees = [], channels = [] } = {}) { + const deliveryState = []; + + return { + prisma: { + employee: { + findMany: vi.fn().mockImplementation(async ({ where }) => { + const ids = new Set(where?.id?.in ?? []); + return employees + .filter((item) => ids.has(item.id)) + .filter((item) => item.status === 'active') + .filter((item) => item.notification_push_enabled !== false) + .map((item) => ({ + id: item.id, + roles: (item.roles ?? []).map((roleCode) => ({ role_code: roleCode })), + })); + }), + }, + notificationChannel: { + findMany: vi.fn().mockResolvedValue(channels), + }, + notificationDelivery: { + create: vi.fn().mockImplementation(async ({ data }) => { + const row = { + id: `delivery-${deliveryState.length + 1}`, + ...data, + }; + deliveryState.push(row); + return row; + }), + update: vi.fn().mockImplementation(async ({ where, data }) => { + const current = deliveryState.find((item) => item.id === where.id); + Object.assign(current, data); + return current; + }), + }, + }, + deliveryState, + }; +} + +describe('notificationDispatcher', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('dispatches a mock channel and persists a success attempt', async () => { + const { prisma, deliveryState } = createPrismaState({ + employees: [ + { id: 'manager-1', status: 'active', notification_push_enabled: true, roles: ['manager'] }, + ], + channels: [buildChannel()], + }); + const sendMessage = vi.fn().mockResolvedValue({ + ok: true, + retryable: false, + code: 'mock_sent', + message: null, + response: { mode: 'mock' }, + }); + + const result = 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-1', + 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(deliveryState).toHaveLength(1); + expect(deliveryState[0]).toEqual( + expect.objectContaining({ + notification_id: 'notification-1', + channel_id: 'channel-1', + status: 'success', + result_code: 'mock_sent', + }), + ); + expect(result).toEqual( + expect.objectContaining({ + matchedChannelCount: 1, + eligibleRecipientCount: 1, + deliveredChannels: [ + expect.objectContaining({ + channelId: 'channel-1', + status: 'success', + }), + ], + }), + ); + }); + + it('retries retryable failures until a later success', async () => { + const { prisma, deliveryState } = createPrismaState({ + employees: [ + { id: 'manager-1', status: 'active', notification_push_enabled: true, roles: ['manager'] }, + ], + channels: [buildChannel()], + }); + const sendMessage = vi + .fn() + .mockResolvedValueOnce({ + ok: false, + retryable: true, + code: 'wecom_timeout', + message: 'timeout', + response: null, + }) + .mockResolvedValueOnce({ + ok: false, + retryable: true, + code: 'wecom_http_429', + message: 'rate limited', + response: null, + }) + .mockResolvedValueOnce({ + ok: true, + retryable: false, + code: 'wecom_sent', + message: null, + response: { errcode: 0 }, + }); + + const result = 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-2', + 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', + sendMessage, + retryDelayMs: 0, + }); + + expect(sendMessage).toHaveBeenCalledTimes(3); + expect(deliveryState).toHaveLength(3); + expect(deliveryState.map((item) => item.status)).toEqual(['failed', 'failed', 'success']); + expect(result.deliveredChannels[0]).toEqual( + expect.objectContaining({ + status: 'success', + attemptNo: 3, + resultCode: 'wecom_sent', + }), + ); + }); + + it('does not retry when provider marks the failure as non-retryable', async () => { + const { prisma, deliveryState } = createPrismaState({ + employees: [ + { id: 'manager-1', status: 'active', notification_push_enabled: true, roles: ['manager'] }, + ], + channels: [buildChannel()], + }); + const sendMessage = vi.fn().mockResolvedValue({ + ok: false, + retryable: false, + code: 'wecom_api_error', + message: 'invalid webhook', + response: { errcode: 93000 }, + }); + + const result = 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-3', + 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', + sendMessage, + retryDelayMs: 0, + }); + + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(deliveryState).toHaveLength(1); + expect(result.deliveredChannels[0]).toEqual( + expect.objectContaining({ + status: 'failed', + attemptNo: 1, + resultCode: 'wecom_api_error', + }), + ); + }); + + it('skips dispatch when all recipients opted out of external push', async () => { + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const { prisma, deliveryState } = createPrismaState({ + employees: [ + { id: 'manager-1', status: 'active', notification_push_enabled: false, roles: ['manager'] }, + ], + channels: [buildChannel()], + }); + const sendMessage = vi.fn(); + + const result = await dispatchNotificationBatch({ + prisma, + config: { + imNotifierEnabled: true, + appOrigin: 'https://oa.example.com', + jwtSecret: 'test-secret', + }, + logger, + requestId: 'req-4', + 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', + sendMessage, + retryDelayMs: 0, + }); + + expect(sendMessage).not.toHaveBeenCalled(); + expect(deliveryState).toHaveLength(0); + expect(result).toEqual( + expect.objectContaining({ + skippedReason: 'no_eligible_recipients', + }), + ); + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + notificationType: 'workflow.task_assigned', + skippedReason: 'no_eligible_recipients', + }), + 'Notification dispatch skipped', + ); + }); + + it('skips when no channel matches recipient roles and notification scope', async () => { + const { prisma, deliveryState } = createPrismaState({ + employees: [ + { id: 'employee-1', status: 'active', notification_push_enabled: true, roles: ['employee'] }, + ], + channels: [ + buildChannel({ + config_json: { + mode: 'mock', + webhookUrl: null, + timeoutMs: 5000, + routing: { + roleCodes: ['manager'], + domainScopes: ['workflow'], + allowedTypes: ['workflow.task_assigned'], + }, + }, + }), + ], + }); + + const result = 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-5', + notifications: [ + { + id: 'notification-1', + recipient_employee_id: 'employee-1', + target_type: 'workflow_task', + }, + ], + type: 'workflow.task_assigned', + title: '测试待办', + body: '请处理审批', + targetType: 'workflow_task', + targetId: 'task-1', + retryDelayMs: 0, + }); + + expect(deliveryState).toHaveLength(0); + expect(result).toEqual( + expect.objectContaining({ + skippedReason: 'no_matching_channel', + eligibleRecipientCount: 1, + }), + ); + }); +});