Files
group_fqcd_jr/app/service/customer_service_handover_admin_service.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

125 lines
5.8 KiB
Python

"""管理员查看客服转人工队列的只读服务。
本模块只暴露工单中已经二次脱敏的最小必要字段;它不读取原始会话、账户、画像或
联系方式,也不提供接单、分配、解决或关闭工单的能力 ——
**工单的处置(分配/接单/解决/关闭/取消)在
`app/service/customer_service_handover_action_service.py`**,那是另一条有写权限与
状态机校验的边界,两者刻意分开:读侧可以被更宽的人群使用,写侧必须 `handover:write`。
"""
from datetime import date, datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import select
from app.core.contracts import RequestContext
from app.core.conversation_privacy import sanitize_customer_service_message
from app.core.cursor import parse_cursor
from app.core.errors import GenericResourceNotFoundError
from app.infrastructure.db import SessionFactory
from app.model.platform import HandoverTicket
from app.service.authorization_service import AuthorizationService
class CustomerServiceHandoverAdminService:
"""面向管理员的客服转人工工单只读边界。"""
permission = "handover:read"
async def list_tickets(
self,
context: RequestContext,
*,
limit: int = 20,
cursor: str | None = None,
status: str | None = None,
) -> dict[str, Any]:
"""按工单 ID 倒序返回一页已脱敏的转人工队列;可按状态筛。"""
await AuthorizationService.require(context, self.permission, admin=True)
before = parse_cursor(cursor)
async with SessionFactory() as session:
statement = select(HandoverTicket).order_by(HandoverTicket.id.desc()).limit(limit)
if before is not None:
statement = statement.where(HandoverTicket.id < before)
if status:
# 状态取值由接口层用 `HandoverStatus` 收口(与数据库 CHECK 逐字对齐),
# 这里不再重复枚举,避免两处口径漂移。
statement = statement.where(HandoverTicket.status == status)
tickets = list(await session.scalars(statement))
return {
"data": [self._list_item(ticket) for ticket in tickets],
"meta": {"trace_id": context.trace_id, "status": status},
}
async def get_ticket(self, ticket_no: str, context: RequestContext) -> dict[str, Any]:
"""返回一个工单的脱敏摘要,不回读或拼接原始会话。"""
await AuthorizationService.require(context, self.permission, admin=True)
async with SessionFactory() as session:
ticket = await session.scalar(
select(HandoverTicket).where(HandoverTicket.ticket_no == ticket_no)
)
if ticket is None:
raise GenericResourceNotFoundError("转人工工单不存在")
return {"data": self._detail_item(ticket), "meta": {"trace_id": context.trace_id}}
@classmethod
def _list_item(cls, ticket: HandoverTicket) -> dict[str, Any]:
"""列表只提供队列识别、路由与状态字段,避免正文在列表页批量扩散。"""
return {
"ticket_id": str(ticket.id),
"ticket_no": ticket.ticket_no,
"session_id": ticket.session_id,
"source_agent": ticket.source_agent,
"priority": ticket.priority,
"reason_code": ticket.reason_code,
"status": ticket.status,
# 受理人(坐席)与流转时间是队列路由信息,不是客户数据,列表页需要它。
"assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None,
"accepted_at": cls._public_value(ticket.accepted_at),
"resolved_at": cls._public_value(ticket.resolved_at),
"closed_at": cls._public_value(ticket.closed_at),
"created_at": cls._public_value(ticket.created_at),
"updated_at": cls._public_value(ticket.updated_at),
}
@classmethod
def _detail_item(cls, ticket: HandoverTicket) -> dict[str, Any]:
"""详情只追加已脱敏摘要与受控来源,仍不返回客户标识或原始消息。"""
return {
**cls._list_item(ticket),
"intent": ticket.intent,
"confidence": cls._public_value(ticket.confidence),
"reason_detail": cls._safe_text(ticket.reason_detail),
"conversation_summary": cls._safe_text(ticket.conversation_summary),
"resolution": cls._safe_text(ticket.resolution),
"assigned_at": cls._public_value(ticket.assigned_at),
"source_references": cls._safe_source_references(ticket.source_references),
}
@staticmethod
def _safe_text(value: str | None) -> str | None:
"""兼容历史工单:读取时再次隐藏旧记录中可能存在的敏感凭据。"""
return sanitize_customer_service_message(value) if value is not None else None
@staticmethod
def _safe_source_references(value: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
"""来源只透出检索引用协议字段,拒绝未来扩展字段意外进入管理面。"""
allowed = {"source_type", "source_id", "title", "score"}
return [
{key: item[key] for key in allowed if key in item}
for item in (value or [])
if isinstance(item, dict)
]
@staticmethod
def _public_value(value: Any) -> Any:
"""统一序列化 ORM 的日期、数值和内部整数主键。"""
if isinstance(value, datetime):
return value.isoformat() + ("Z" if value.tzinfo is None else "")
if isinstance(value, (date, Decimal)):
return str(value)
if isinstance(value, int):
return str(value)
return value