R50 通知推送:dispatcher 投递服务、业务事件接入与员工推送开关

这个提交包含在:
2026-04-05 11:21:39 +08:00
父节点 ff0a677bf3
当前提交 47e10e6acc
共修改 24 个文件,包含 1701 行新增和 197 行删除
+7
查看文件
@@ -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),
});
}
+44
查看文件
@@ -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<NotificationChannelListResponse>("/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<NotificationDeliveryListResponse>(
`/api/notifications/deliveries${queryString ? `?${queryString}` : ""}`,
);
}
+1
查看文件
@@ -31,6 +31,7 @@ export type MeResponse = {
departmentId: string;
directManagerId: string | null;
status: string;
notificationPushEnabled: boolean;
roleCodes: string[];
wecomBinding?: {
externalUserId: string;
+24 -1
查看文件
@@ -32,6 +32,7 @@ type AuthContextValue = AuthState & {
changePassword: (oldPassword: string, newPassword: string) => Promise<void>;
logout: () => Promise<void>;
bootstrapByRefresh: () => Promise<void>;
refreshMe: () => Promise<void>;
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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+112 -1
查看文件
@@ -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<string, string>, 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<string | null>(null);
const [form, setForm] = useState<ChannelFormState>(buildInitialForm);
const [lastTestResult, setLastTestResult] = useState<TestResultState | null>(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() {
<div className="space-y-6">
<PageHeader
title="通知渠道"
subtitle="维护企微群机器人渠道、业务域路由与测试发送。IM 继续通过 Outbox 异步派发。"
subtitle="维护企微群机器人渠道、业务域路由、测试发送与最近投递记录。目标业务通知会在提交后同步尝试外推。"
actions={
<Button type="button" variant="secondary" onClick={startCreate} data-testid="notification-channels-create">
新建渠道
@@ -404,6 +424,97 @@ export function NotificationChannelsPage() {
) : null}
</div>
<div className="card-std space-y-4 p-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h3 className="text-base font-semibold text-zinc-900">最近投递记录</h3>
<p className="text-sm text-zinc-500">展示业务通知同步外推的最近状态,不包含页面里的“测试发送”结果。</p>
</div>
<div className="flex flex-wrap gap-3">
<select
className="select-std"
value={deliveryFilters.channelId}
onChange={(event) => setDeliveryFilters((prev) => ({ ...prev, channelId: event.target.value }))}
>
<option value="">全部渠道</option>
{channels.map((channel) => (
<option key={channel.id} value={channel.id}>
{channel.name}
</option>
))}
</select>
<select
className="select-std"
value={deliveryFilters.status}
onChange={(event) =>
setDeliveryFilters((prev) => ({
...prev,
status: event.target.value as NotificationDeliveryStatus | "",
}))
}
>
<option value="">全部状态</option>
<option value="success">成功</option>
<option value="failed">失败</option>
<option value="pending">处理中</option>
</select>
</div>
</div>
{deliveriesQuery.isLoading ? <TableSkeleton columns={7} rows={4} /> : null}
{deliveriesQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveError(deliveriesQuery.error)}
</p>
) : null}
{!deliveriesQuery.isLoading && !deliveriesQuery.isError ? (
<div className="rounded-lg border border-zinc-200">
<Table data-testid="notification-deliveries-table">
<TableHeader>
<tr>
<TableHead>通知</TableHead>
<TableHead>渠道</TableHead>
<TableHead>状态</TableHead>
<TableHead>尝试次数</TableHead>
<TableHead>结果码</TableHead>
<TableHead>最近尝试</TableHead>
<TableHead>错误摘要</TableHead>
</tr>
</TableHeader>
<TableBody>
{(deliveriesQuery.data?.items ?? []).length ? (
(deliveriesQuery.data?.items ?? []).map((item) => (
<TableRow key={`${item.notificationId}:${item.channelId}`}>
<TableCell className="max-w-sm">
<div className="font-medium text-zinc-900">{item.notificationTitle || item.notificationType}</div>
<div className="text-xs text-zinc-500">{item.notificationType}</div>
</TableCell>
<TableCell>
<div className="font-medium text-zinc-900">{item.channelName}</div>
<div className="text-xs text-zinc-500">{formatChannelType(item.channelType)}</div>
</TableCell>
<TableCell>{formatDeliveryStatus(item.status)}</TableCell>
<TableCell>{item.attemptCount}</TableCell>
<TableCell className="text-xs text-zinc-600">{item.resultCode ?? "-"}</TableCell>
<TableCell className="text-xs text-zinc-600">{formatDateTime(item.latestAttemptAt)}</TableCell>
<TableCell className="max-w-xs text-xs text-zinc-600">{item.errorMessage ?? "-"}</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={7} className="py-4 text-center text-sm text-zinc-500">
暂无投递记录。
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
) : null}
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]">
<div className="card-std space-y-4 p-6">
<div className="flex items-center justify-between gap-3">
@@ -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 通知推送主链路回归完成。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 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 校验,范围与依赖已冻结。
3 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 回归通过,迁移与个人推送偏好链路验收完成。
4 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 投递与重试链路验收完成。
5 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 回归通过,目标业务事件外推链路验收完成。
6 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 回归通过,投递记录查询与个人偏好接口验收完成。
7 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 回归通过,通知渠道页投递记录展示验收完成。
8 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 回归通过,个人设置页推送开关验收完成。
9 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 通知推送主链路回归完成。
@@ -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");
+35 -23
查看文件
@@ -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,
+13 -10
查看文件
@@ -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) });
},
+67 -1
查看文件
@@ -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;
+114
查看文件
@@ -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',
{
+19 -17
查看文件
@@ -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,
};
}
+30 -25
查看文件
@@ -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);
},
+13 -10
查看文件
@@ -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);
},
+47 -32
查看文件
@@ -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);
},
@@ -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,
},
};
@@ -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,
};
+1 -33
查看文件
@@ -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) {
+31 -1
查看文件
@@ -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,
};
+140
查看文件
@@ -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: '[email protected]',
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();
});
});
+133 -1
查看文件
@@ -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();
});
});
@@ -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(
@@ -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(
@@ -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,
}),
);
});
});