Files
group_fqcd_jr/tools/grant_handover_write_permission.py
T
lzf_0626 076d786bc6 补齐客服转人工工单流:从"只能看"到"能推进"(基线状态机,不自行发明)
## 问题
`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 项)
2026-09-15 00:41:59 +08:00

143 lines
5.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""幂等补齐 `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())