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

## 问题
`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
+142
View File
@@ -0,0 +1,142 @@
"""幂等补齐 `handover:write`(客服转人工工单处置)权限并授权给 admin。
## 为什么需要它
`docs/02-数据库建表设计.md` §7.2 定义了工单状态机
(`pending -> assigned -> processing -> resolved -> closed`,未解决可 `cancelled`),
但平台此前**只有** `handover:read`(9046,只读队列)—— 于是工单只能看、不能推进,
库里 40 张单子全部停在 `pending`。处置端点与状态机见
`app/service/customer_service_handover_action_service.py` 与
`app/api/controllers/admin.py` 的五个 action 端点。
权限码 **9069 已并进种子** `tools/seed_test_rbac.py`(那是定义源),
本脚本只做"幂等补齐 + 授权",不重建任何东西;id 必须与种子逐条一致
(一致性由 `tools/check_rbac_seed_consistency.py` 守着)。
用法::
python tools/grant_handover_write_permission.py # 只打印要做什么
python tools/grant_handover_write_permission.py --apply # 真写
"""
from __future__ import annotations
import argparse
import asyncio
import sys
from datetime import UTC, datetime
from sqlalchemy import text
from app.infrastructure.db import SessionFactory
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr]
#: (id, permission_code, resource, action, data_scope) —— 与种子里那一行逐字一致。
PERMISSION: tuple[int, str, str, str, str] = (
9069, "handover:write", "handover", "write", "all",
)
#: 授权给哪些角色:工单队列是**管理面**(读侧也是 admin),所以只给 admin。
#: `ADMIN_PERMISSIONS` 在种子里是全量元组,重跑种子也会自动带上这一条。
GRANTED_ROLES: tuple[str, ...] = ("admin",)
async def apply(*, dry_run: bool) -> int:
now = datetime.now(UTC).replace(tzinfo=None)
perm_id, code, resource, action, scope = PERMISSION
async with SessionFactory() as session, session.begin():
found = await session.scalar(
text("SELECT id FROM sys_permission WHERE permission_code = :code"), {"code": code}
)
print(f"权限 {code}: {'已存在(跳过插入)' if found else '缺失,将新增 id=' + str(perm_id)}")
if dry_run:
role = await session.scalar(
text("SELECT id FROM sys_role WHERE role_code = 'admin'")
)
print(f"角色 admin: {'存在' if role else '缺失(请先跑 seed_test_rbac.py)'}")
print("\n[dry-run] 未写入任何数据。加 --apply 真写。")
return 0
if found is None:
await session.execute(
text(
"""
INSERT INTO sys_permission
(id, permission_code, resource, action, data_scope, created_at, updated_at)
VALUES (:id, :code, :resource, :action, :scope, :now, :now)
"""
),
{"id": perm_id, "code": code, "resource": resource, "action": action,
"scope": scope, "now": now},
)
resolved_id = perm_id
else:
resolved_id = int(found)
for role_code in GRANTED_ROLES:
role_id = await session.scalar(
text("SELECT id FROM sys_role WHERE role_code = :code"), {"code": role_code}
)
if role_id is None:
print(f"跳过角色 {role_code}:不存在")
continue
have = await session.scalar(
text(
"SELECT 1 FROM sys_role_permission WHERE role_id = :r AND permission_id = :p"
),
{"r": int(role_id), "p": resolved_id},
)
if have:
print(f"授权:{role_code} 已拥有 {code}(跳过)")
continue
await session.execute(
text(
"INSERT INTO sys_role_permission (role_id, permission_id, created_at)"
" VALUES (:r, :p, :now)"
),
{"r": int(role_id), "p": resolved_id, "now": now},
)
print(f"授权:{role_code} += {code}")
return await verify()
async def verify() -> int:
"""按权限码实测一遍,而不是只看插了几行。"""
async with SessionFactory() as session:
rows = (
await session.execute(
text(
"""
SELECT p.permission_code, p.data_scope, r.role_code
FROM sys_permission p
LEFT JOIN sys_role_permission rp ON rp.permission_id = p.id
LEFT JOIN sys_role r ON r.id = rp.role_id
WHERE p.permission_code = 'handover:write'
ORDER BY r.role_code
"""
)
)
).all()
if not rows:
print("校验失败:库里查不到 handover:write")
return 1
for code, scope, role_code in rows:
print(f"校验:{code} scope={scope} → 角色 {role_code or '(未授权任何角色)'}")
if not any(role_code for _code, _scope, role_code in rows):
print("校验失败:handover:write 存在但没有授权给任何角色(处置端点会一律 403)")
return 1
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="补齐并授权 handover:write")
parser.add_argument("--apply", action="store_true", help="真正写入(默认只打印)")
args = parser.parse_args()
return asyncio.run(apply(dry_run=not args.apply))
if __name__ == "__main__":
raise SystemExit(main())