-
feat(plan): 召回分配 P2 —— 批量写路径(唯一的写动作) · 300b3f50
POST/GET /pac/v1/plans/assignments,@RequirePermission(PLAN_DISPATCH)。 这是整条生产线上唯一改库的地方(T8),所以护栏比功能本身花的力气多。 ## AssignStrategy 定四值,不是三值 原设计 dedicated/spread/manual。但 spread 会同时装下两群**完全不同**的人: · 有专属客服、只是这次容量满了没轮到他 —— 医患关系还在 · 从头就没有可用专属(无专属 / 专属已离岗)—— 本来就是关系薄弱那批 实测本地 2,724 条候选:专属且在岗 85.1% / 专属但已离岗 7.5% / 无专属 7.3%, 后两类合计约 15%。混成一个值,T20 要算的「专属 vs 铺平完成率差」就废了: 铺平那组低,到底是策略不好还是那群人本来难打,永远分不出来。
⚠ ️ 主管界面**不多一种状态**:ASSIGN_STRATEGY_META.groupZh 把两种 spread 合并成 「铺平」一档,只有反推分析才拆开看 —— 内部口径的精细度不倒灌到主管的注意力上(T13)。 ## 三道闸 ① requirePermission —— 与 controller 上的装饰器**故意重复**:装饰器只在 guard 链生效, 而 MCP 端点是 @Public() 直接短路。判据同时放进 service,换个入口进来护栏还在。 ② rejectSyntheticIdentity —— 企微 mintToken 造的 `wx:` 合成身份直接拒。 教条原文是「本期不管(demo 用途)」,那句话的前提是当时还没有写路径; 有了写路径,"不管"必须落成"硬拒",否则就是把已知的身份伪造面留在最危险的位置。 ③ scope 绑定 + **数量对不上整体拒绝**。对不上只有两种可能:确认单过期(单子被引擎 supersede 了)或跨诊所越权,两种都不该落一半 —— 落一半的批次事后既解释不清也回不去, 而主管看到"成功 87/100"完全无从判断哪 13 条为什么没落。 两个 helper 单独成文件(dispatch-guard.ts),S4 挂 MCP 写工具时直接复用,不重写一遍。 ## 并发与幂等 按 (客服,策略,时效) 分桶,每桶一条**带条件** updateMany: `where: { id: {in}, status:'active', assigneeUserId: null, supersededAt: null }` —— where 带状态条件即并发安全,count 与 chunk 长度的差就是确认期间被抢走的。 同款技巧见 recycle-scheduler。⛔ 不循环调 PlanService.assign(500 条 ≈1500 次往返, 且它写不了归因列);⛔ 也不让单条 assign 委托批量(每次自助认领都会造一条垃圾批次)。 共用判据收口到 claim-guard.assertAssignable —— 教条七「已认领能否强制改派」 那条待确认决策正好落在这一个函数上,将来只改这一处。 requestId 幂等:**前置回查**(重放零副作用)+ P2002 兜底回查。⛔ 冲突不抛错 —— 抛了模型会以为失败、换个参数重试,那才是真正的重复分配。 applied=0 → 抛错回滚,库里不留空批次(空批次会让报表出现一堆 0/0 且查不到原因)。 ## 两个实测踩出来的坑 1.🔴 **路由被吃**:GET /plans/assignments 被 PlanController 的裸 `@Get(':id')` 匹配成 planId='assignments',报的是 Prisma uuid 解析错(90000),完全看不出是路由撞了。 → AssignmentController 必须在 module 里**排在 PlanController 之前**(Nest 按注册顺序匹配)。 同类先例:plan.controller:80 的 `doctors` 路由也踩过。 2. **agentStats 加起来比批次少**:退回后 assignee 被清空,那条从所有人名下消失, 500 条退 1 条 → 各人 planned 之和 499,主管一眼看出对不上,而"某人退了几条" 恰恰是他最想看的列。→ 从 plan_event_logs 的 assign 事件回捞原始承接人 (createdAt >= 批次创建时刻)。这正是账本存在的意义:主表存当前值,历史归属只有账本留得住。 ## 其他 · expiresInDays 收**相对天数**,服务端按 host 时区转**当地日末** —— 让模型算 ISO 时刻正是 commit 45498176 修过的坑(纯日期被当 UTC 零点偏 8 小时); 按小时算则上午分的和下午分的在同一天不同时刻过期,客服形不成预期。 · items 是 (planId, assigneeUserId) **对**不是分组:溢出转铺平后归属逐条算, 且 T13 的逐条微调时效在分组形状里无处安放。 · 500 硬护栏是**技术**上限(单事务 + bind 变量),不是批次规模的业务上限 —— 业务上限该由产品定并加在助手圈人那层,写这里会让两件事永远分不开。 · AssignmentDetail 的字段叫 agentStats 不叫 agents:Brief 里 `agents` 是人数(number), 同名不同型会让前端拿 brief 类型读 detail 时静默拿到 undefined。 ## 验证(本地真实数据 2,724 条活跃 plan) 857 单测通过 + 端到端 11 项: staff 调分配端点 → 403 Missing permissions: plan:dispatch 500 条一次调用 → **741ms**,assigned=500,4 个客服 × 125 expiresAt → 2026-08-05T15:59:59.999Z = 北京 08-05 当地日末✅ 同 requestId 再调 → duplicate:true,库里仍 1 个批次 混入他诊所 planId → 整批拒绝,不留空批次 确认期间被人抢先认领 → assigned=2,skipped=[claimed_by_other],其余照落 全部落不上 → 拒绝,批次数不变 归属账本 → 504 条 assign 事件,承接人 5 / 发起人 1 批次列表 / 单批详情 → planned=500 inHand=499 released=1 untouched=499 agentStats 之和 → 500 == 批次 planned✅ GET /plans/:id → 未被路由改动误伤 测试数据已清理(批次=0 归因单=0 事件=0 在手=0) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed
| Name |
Last commit
|
Last update |
|---|---|---|
| .. | ||
| cli | Loading commit data... | |
| common | Loading commit data... | |
| config | Loading commit data... | |
| modules | Loading commit data... | |
| openapi | Loading commit data... | |
| prisma | Loading commit data... | |
| queues | Loading commit data... | |
| redis | Loading commit data... | |
| types | Loading commit data... | |
| app.module.ts | Loading commit data... | |
| health.controller.ts | Loading commit data... | |
| instrument.ts | Loading commit data... | |
| main.ts | Loading commit data... |