## 问题 `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 项)
125 lines
5.8 KiB
Python
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
|