补齐客服转人工工单流:从"只能看"到"能推进"(基线状态机,不自行发明)

## 问题
`svc_handover_ticket` 的 DDL 与状态机在 `docs/02` §7.2 早就定好了
(pending → assigned → processing → resolved → closed,未解决可 cancelled),
但平台**只有 handover:read(只读队列)**:没有任何入口能改状态、assigned_to /
accepted_at / resolved_at / closed_at / resolution 五列**全库 0 非空**,
于是 40 张工单永远停在 pending —— 用户看到的就是"工单全都长一样"。

## 改了什么
后端:
- 新增 `app/service/customer_service_handover_action_service.py`:五个动作
  (分配/接单/解决/关闭/取消),`SELECT ... FOR UPDATE` 锁单后判状态;
  接单允许从 pending 自助接管(同时记受理人);取消不写 closed_at(该列属 closed 状态);
  每次流转写一条 interaction_audit(handover.assigned/accepted/resolved/closed/cancelled);
  非法流转 409、坐席不存在 422、工单不存在 404;回包不含 customer_id/session_id。
- 只读服务保持只读(读侧与写侧是两条边界,单测守着"读侧不许长出写方法"),
  但列表支持 `?status=` 六态筛选、详情补上受理人与流转时间(坐席侧路由信息,非客户数据)。
- `app/api/controllers/admin.py`:五个 action 端点 A049–A053
  (assignments / acceptances / resolutions / closures / cancellations),
  走 `ApiTransactionService.execute_in` —— 幂等记录与业务写入同事务、重复键回放。
- 权限:新增 `handover:write`(9069,只授 admin),已并进种子
  `tools/seed_test_rbac.py`;配套幂等脚本 `tools/grant_handover_write_permission.py`。

前端(管理员工作台 · 转人工工单页):
- 按状态给按钮(待处理→分配/直接接单、已分配→接单、处理中→解决、已解决→关闭、
  未解决都可取消),加了状态筛选与"刷新";摘要弹窗补上受理人与四个时间点、处置结论。
- api-client 注册五个端点;workspace.js 的 api-client 引用与页面自身的 ?v= 一并升版,
  避免浏览器拿旧缓存(旧缓存里没有这些端点)。

冒烟与测试:
- `tools/e2e_smoke_test.py`:B 段建的测试工单由 F 段走完 分配→接单→解决→关闭 收尾
  —— 既不再把测试件堆在 pending 队列里(此前每次冒烟攒一张),又让每次冒烟都覆盖一遍状态机。
  总数 40 → 44 项,实测 44/44 全绿。
- 新增单测 24 条(状态机合法/非法路径、越权、坐席不存在、审计、视图不泄漏客户标识)
  与一条真机集成用例(HTTP 十步 + 数据库侧审计证据 + 自动清理)。
- 读侧那条"详情不得返回 assigned_to"的旧断言按新口径更新,并写清为什么。

## 验证
- `pytest tests/unit tests/contract` → 1489 passed, 2 skipped, 0 failed
- 新增集成用例通过;`tests/integration` 全量跑时
  `test_memory_extraction` / `test_run_cancellation_mysql` 两条偶发红 —— 单独跑都通过,
  是 AGENTS.md 已登记的"常驻 Worker 抢队列"(跑验收前须先停 Worker)
- `tools/portal_api_check.py` → 41 项通过 39、失败 0
- `tools/e2e_smoke_test.py` → 44/44 全通过
- `python tools/check_rbac_seed_consistency.py` → 通过(种子 63 条权限)
- 真机 HTTP 实测:分配→接单→解决→关闭四步 200 且时间戳齐全;取消路径 200 且 closed_at 为空;
  同键重发回放不二次推进;对已关闭工单再分配 409;风控账号处置 403

## 文档
`docs/44-演示流程.md`(场景 4/8 + 命令 + 44 项)、`docs/演示用/后端接口文档`(新增 §11.4b 与
A049–A053)、`docs/演示用/全功能流程-大白话版.md`(工单页签改"读写"+ 已知偏差)、
`AGENTS.md`(9066-9069 号段演进 + 冒烟 44 项)
This commit is contained in:
2026-09-15 00:41:59 +08:00
parent ed59e93b53
commit 076d786bc6
17 changed files with 1413 additions and 28 deletions
@@ -467,7 +467,7 @@
| **角色与权限** | 看角色清单(角色名/代码/权限数/用户数/状态);输入一个用户 ID,看**服务端实际解析出来的**角色、权限、数据范围 | 只读 |
| **配置与模型** | 平台里**唯一能改配置**的地方:新建发布版本 → 校验 → 审核 → 激活;管平台配置项(命名空间:工具白名单 `agent_tools`、记忆 `memory`、关系 `relationship`、运行时 `runtime`、行情 `fund_market`)与模型路由规则 | 读写 |
| **审计记录** | 最近 20 条审计(动作、主体、入口、时间);没有"看敏感详情"的权限时,详情由服务端**脱敏** | 只读 |
| **转人工工单** | 看客服转人工的工单与**二次脱敏**的会话摘要 | 只读 |
| **转人工工单** | 看客服转人工的工单与**二次脱敏**的会话摘要;**并且能处置**:分配 → 接单 → 解决 → 关闭(未解决可取消),可按状态筛,每一步都写审计 | 读写 |
| **画像候选** | 客户对话里提炼出的画像候选,批准后才进正式记忆(批准/驳回都要填意见) | 读写 |
| **投顾复核** | 待审的投顾交付物(推荐方案 + 投资方案书)→ 审核通过 / 驳回 / 发布给客户 | 读写 |
| **知识库** | 上传文档(faq / product / policy)→ 自动切分入库并投向量同步;**客服就是靠它作答**;也能让某份知识失效 | 读写 |
@@ -707,3 +707,4 @@ python tools/portal_api_check.py # 接口契约体检(按
| 投顾"已发布交付物"空态写"需管理员审核" | 文案没跟进"投顾可自助审核发布"这次改动 |
| 管理员配置发布没有"驳回/回滚"按钮、模型端点页是只读的、审计页没有筛选框 | 后端接口都有,前端未接 |
| 客户「资金流水」页顶部导航不高亮 | 导航配置里该页的 `active` 是空串 |
| 转人工工单队列里一堆一模一样的"端到端冒烟"单子 | 冒烟脚本每跑一次建一张。**2026-09-14 起它自己会走完处置闭环收尾**(分配→接单→解决→关闭),不再堆积;历史遗留的那些可以手动处置掉 |
@@ -1890,8 +1890,13 @@ model_failure | system_busy | clarification
| 编号 | 端点 | 用途 | 权限 |
|---|---|---|---|
| **A033** | `GET /api/v1/admin/audit-records` | 审计查询(`limit` 1–100 默认 20 + `cursor`) | `audit:read`(无 `audit:read-sensitive` 则 `detail` 被涂成 `{"redacted": true}`) |
| — | `GET /api/v1/admin/customer-service/handover-tickets` | 客服待转人工队列(只读) | 管理员 |
| — | `GET .../handover-tickets/{ticket_no}` | 单工单脱敏摘要 | 管理员 |
| — | `GET /api/v1/admin/customer-service/handover-tickets` | 客服转人工队列(只读);**支持 `?status=` 六态筛选** | `handover:read` + 管理员 |
| — | `GET .../handover-tickets/{ticket_no}` | 单工单脱敏摘要 + 流转进度 | 同上 |
| **A049** | `POST .../handover-tickets/{ticket_no}/assignments` | 分配工单(`pending → assigned`),体 `{assignee_id}` | `handover:write` + 管理员;幂等 |
| **A050** | `POST .../handover-tickets/{ticket_no}/acceptances` | 接单(`pending` 自助接管 / `assigned → processing`),无体 | 同上 |
| **A051** | `POST .../handover-tickets/{ticket_no}/resolutions` | 解决(`processing → resolved`),体 `{resolution}` ≥2 字 | 同上 |
| **A052** | `POST .../handover-tickets/{ticket_no}/closures` | 关闭(`resolved → closed`),体 `{note}` 可空 | 同上 |
| **A053** | `POST .../handover-tickets/{ticket_no}/cancellations` | 取消(`pending\|assigned\|processing → cancelled`),体 `{reason}` ≥2 字 | 同上 |
| **A039** | `GET /api/v1/admin/customer-profile-candidates` | 待处理画像候选(`limit` 默认 20) | `memory:candidate:review`(`admin=True`) |
| **A040** | `POST /api/v1/admin/customer-profile-candidates/{candidate_id}/reviews` | 批准/驳回候选 | 同上;**成功 200** |
| **A041** | `POST /api/v1/admin/advisor/asset-allocation-backtests` | 资产配置回测 | `asset-allocation:backtest`;**201**;幂等 |
@@ -1908,6 +1913,32 @@ model_failure | system_busy | clarification
**A040 批准时会处理同键旧正式记忆**(`conflict_type="candidate_promoted"`)。
### 11.4b 转人工工单处置(A049–A053)—— 2026-09-14 补齐
状态机照基线 `docs/02-数据库建表设计.md` §7.2 与 `docs/03` §客服转人工(**没有自行发明**):
```text
pending -> assigned -> processing -> resolved -> closed
| | |
`----------+-------------+-> cancelled
```
| 项 | 口径 |
|---|---|
| **权限** | 读 `handover:read`(9046,原本就有);**处置要 `handover:write`(9069,本次新增)**,且两者都要求 `admin`/`super_admin` 角色 |
| **幂等** | 五个端点**都要求 `Idempotency-Key`**,且幂等范围包含被折叠的路径参数(同一把键用在两张不同工单上算两次操作) |
| **审计** | 每次流转写一条 `interaction_audit`:`handover.assigned` / `handover.accepted` / `handover.resolved` / `handover.closed` / `handover.cancelled`,`detail` 里带 `ticket_no`、`from_status`、`to_status` |
| **并发** | 动作先 `SELECT ... FOR UPDATE` 锁工单再判状态:两个人同时点,后到的会看到已变化的状态并拿到 409 |
| **非法流转** | **409**(平台唯一的 409 码是 `RUN_NOT_CANCELLABLE`,真正原因在 `message` 里,例如"当前状态为closed,只有 pending 的工单可以分配") |
| **接单人不存在** | **422** `AGENT_INPUT_INVALID`(先查 `sys_user`,不让数据库外键以 500 冒出) |
| **响应字段** | `ticket_no`、`status`、`priority`、`assigned_to`、`assigned_at`、`accepted_at`、`resolved_at`、`closed_at`、`resolution`、`updated_at`;**不含 `customer_id`/`session_id`** |
| **两个刻意口径** | ① `accept` 允许从 `pending` 直接接管(同时把 `assigned_to` 记成接单人);② `cancel` **不写 `closed_at`**(该列属 `closed` 状态),取消原因写进 `resolution`("已取消:…") |
列表端点新增 `?status=`(六态之一,**与数据库 CHECK 逐字对齐**;传别的值直接 **422**,
不会漏到数据库变成 500)。
---
### 11.5 RBAC 只读查询(A035–A038)
`app/api/controllers/rbac.py`,前缀 `/api/v1/admin`。**四个端点全部只读,且不写审计。**