## 问题 `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 项)
290 lines
11 KiB
Python
290 lines
11 KiB
Python
"""客服转人工工单的**人工处置**服务:分配 / 接单 / 解决 / 关闭 / 取消。
|
||
|
||
状态机照基线实现,**不自行发明**(`docs/02-数据库建表设计.md` §7.2、`docs/05` §8.5):
|
||
|
||
```text
|
||
pending -> assigned -> processing -> resolved -> closed
|
||
| | |
|
||
`----------+-------------+-> cancelled
|
||
```
|
||
|
||
- `cancelled` 只允许在**未解决**状态进入;每次分配、接单、解决、关闭、取消都写
|
||
`interaction_audit`(谁、何时、把哪张单子从什么状态推到什么状态);
|
||
- 只读脱敏查看在 `customer_service_handover_admin_service`,本模块只管状态与字段流转,
|
||
**不读原始会话、不回吐客户标识**;
|
||
- 落点是「客服领域 Service」而不是 Controller —— `docs/05` 明确要求
|
||
"客服工单分配、接单、解决和关闭由客服领域 Service 处理,不允许 Controller 直接写
|
||
`svc_handover_ticket`"。
|
||
|
||
两个刻意的口径:
|
||
|
||
1. **`accept` 允许从 `pending` 或 `assigned` 进入 `processing`**:前者是"自助接管"
|
||
(同时把 `assigned_to` 记成接单人),后者是"由被分配人接单"。这对应
|
||
`docs/03-平台端到端流程文档.md` 的"只有 `pending/assigned` 状态可以被合法接管"。
|
||
2. **`cancel` 不写 `closed_at`**:该列语义是"关闭时间",属 `closed` 状态;
|
||
取消的原因写进 `resolution`("解决结论及理由"),状态置 `cancelled`。
|
||
这样"关闭"与"取消"在报表上不会混成一类。
|
||
|
||
并发口径:所有动作先 `SELECT ... FOR UPDATE` 锁住该工单再判状态,
|
||
同一张单子被两个人同时点也不会两次推进(后到的那个会看到已经变化的状态而报错)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from datetime import UTC, datetime
|
||
from typing import Any
|
||
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.core.contracts import RequestContext
|
||
from app.core.conversation_privacy import sanitize_customer_service_message
|
||
from app.core.errors import (
|
||
GenericResourceNotFoundError,
|
||
InvalidStateError,
|
||
ValidationAgentError,
|
||
)
|
||
from app.model.audit import InteractionAudit
|
||
from app.model.platform import HandoverTicket
|
||
from app.model.risk import RiskUser
|
||
from app.service.authorization_service import AuthorizationService
|
||
|
||
#: 处置动作所需的权限码(`tools/seed_test_rbac.py` 的 9069,只给 admin)。
|
||
WRITE_PERMISSION = "handover:write"
|
||
|
||
#: 允许"取消"的未解决状态(与 `docs/02` §7.2 的图一致)。
|
||
CANCELLABLE_STATUSES = ("pending", "assigned", "processing")
|
||
|
||
#: 允许"接管(接单)"的状态。
|
||
ACCEPTABLE_STATUSES = ("pending", "assigned")
|
||
|
||
|
||
class HandoverActionError(InvalidStateError):
|
||
"""工单当前状态不允许执行该动作(409)。
|
||
|
||
继承 `InvalidStateError`("非法状态转换")而不是直接用 `ConflictAgentError`:
|
||
后者的文档写明"只用于继承与 except,禁止直接抛出"。
|
||
错误码沿用平台唯一的 409 码 `RUN_NOT_CANCELLABLE`(`docs/05` §3.5 只登记了这一个),
|
||
真正的原因在 `message` 里。
|
||
"""
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _Action:
|
||
action_type: str
|
||
title: str
|
||
|
||
|
||
ACTIONS = {
|
||
"assign": _Action("handover.assigned", "分配"),
|
||
"accept": _Action("handover.accepted", "接单"),
|
||
"resolve": _Action("handover.resolved", "解决"),
|
||
"close": _Action("handover.closed", "关闭"),
|
||
"cancel": _Action("handover.cancelled", "取消"),
|
||
}
|
||
|
||
|
||
class CustomerServiceHandoverActionService:
|
||
"""面向管理员的转人工工单处置边界。"""
|
||
|
||
permission = WRITE_PERMISSION
|
||
|
||
def __init__(self, session: AsyncSession) -> None:
|
||
self.session = session
|
||
|
||
# ---------- 五个动作 ----------
|
||
|
||
async def assign(
|
||
self, ticket_no: str, assignee_id: int, context: RequestContext
|
||
) -> dict[str, Any]:
|
||
"""把待处理工单分配给一个坐席:`pending -> assigned`。"""
|
||
await AuthorizationService.require(context, self.permission, admin=True)
|
||
ticket = await self._load_for_update(ticket_no)
|
||
if ticket.status != "pending":
|
||
raise HandoverActionError(
|
||
f"当前状态为{ticket.status},只有 pending 的工单可以分配"
|
||
)
|
||
assignee = await self.session.scalar(
|
||
select(RiskUser.id).where(RiskUser.id == assignee_id)
|
||
)
|
||
if assignee is None:
|
||
raise ValidationAgentError(f"坐席 {assignee_id} 不存在")
|
||
now = _now()
|
||
ticket.assigned_to = assignee_id
|
||
ticket.assigned_at = now
|
||
ticket.status = "assigned"
|
||
ticket.updated_at = now
|
||
await self._finish(
|
||
ticket, context, "assign", {"assignee_id": assignee_id, "to_status": "assigned"}
|
||
)
|
||
return self._view(ticket)
|
||
|
||
async def accept(self, ticket_no: str, context: RequestContext) -> dict[str, Any]:
|
||
"""坐席接单:`pending -> processing`(自助接管)或 `assigned -> processing`。"""
|
||
await AuthorizationService.require(context, self.permission, admin=True)
|
||
ticket = await self._load_for_update(ticket_no)
|
||
if ticket.status not in ACCEPTABLE_STATUSES:
|
||
raise HandoverActionError(
|
||
f"当前状态为{ticket.status},只有 pending/assigned 的工单可以接单"
|
||
)
|
||
now = _now()
|
||
operator_id = int(context.user_id)
|
||
detail: dict[str, Any] = {"from_status": ticket.status, "to_status": "processing"}
|
||
if ticket.status == "pending":
|
||
# 自助接管:没有分配过就直接接单的人,就是实际受理人。
|
||
ticket.assigned_to = operator_id
|
||
ticket.assigned_at = now
|
||
detail["self_claimed"] = True
|
||
ticket.accepted_at = now
|
||
ticket.status = "processing"
|
||
ticket.updated_at = now
|
||
await self._finish(ticket, context, "accept", detail)
|
||
return self._view(ticket)
|
||
|
||
async def resolve(
|
||
self, ticket_no: str, resolution: str, context: RequestContext
|
||
) -> dict[str, Any]:
|
||
"""给出解决结论:`processing -> resolved`。"""
|
||
await AuthorizationService.require(context, self.permission, admin=True)
|
||
ticket = await self._load_for_update(ticket_no)
|
||
if ticket.status != "processing":
|
||
raise HandoverActionError(
|
||
f"当前状态为{ticket.status},只有 processing 的工单可以解决"
|
||
)
|
||
text = _require_text(resolution, "解决结论")
|
||
now = _now()
|
||
ticket.resolution = text
|
||
ticket.resolved_at = now
|
||
ticket.status = "resolved"
|
||
ticket.updated_at = now
|
||
await self._finish(
|
||
ticket, context, "resolve", {"to_status": "resolved", "resolution_length": len(text)}
|
||
)
|
||
return self._view(ticket)
|
||
|
||
async def close(
|
||
self, ticket_no: str, note: str, context: RequestContext
|
||
) -> dict[str, Any]:
|
||
"""归档关闭:`resolved -> closed`。可选补一条说明,追加在结论后面。"""
|
||
await AuthorizationService.require(context, self.permission, admin=True)
|
||
ticket = await self._load_for_update(ticket_no)
|
||
if ticket.status != "resolved":
|
||
raise HandoverActionError(
|
||
f"当前状态为{ticket.status},只有 resolved 的工单可以关闭"
|
||
)
|
||
now = _now()
|
||
extra = note.strip()
|
||
if extra:
|
||
ticket.resolution = (
|
||
f"{ticket.resolution}\n关闭补充:{extra}" if ticket.resolution else extra
|
||
)
|
||
ticket.closed_at = now
|
||
ticket.status = "closed"
|
||
ticket.updated_at = now
|
||
await self._finish(
|
||
ticket, context, "close", {"to_status": "closed", "has_note": bool(extra)}
|
||
)
|
||
return self._view(ticket)
|
||
|
||
async def cancel(
|
||
self, ticket_no: str, reason: str, context: RequestContext
|
||
) -> dict[str, Any]:
|
||
"""取消未解决的工单:`pending|assigned|processing -> cancelled`。"""
|
||
await AuthorizationService.require(context, self.permission, admin=True)
|
||
ticket = await self._load_for_update(ticket_no)
|
||
if ticket.status not in CANCELLABLE_STATUSES:
|
||
raise HandoverActionError(
|
||
f"当前状态为{ticket.status},只有未解决(pending/assigned/processing)"
|
||
"的工单可以取消"
|
||
)
|
||
text = _require_text(reason, "取消原因")
|
||
from_status = ticket.status
|
||
now = _now()
|
||
ticket.status = "cancelled"
|
||
ticket.resolution = f"已取消:{text}"
|
||
ticket.updated_at = now
|
||
# `closed_at` 不写:它属于 `closed` 状态(见模块 docstring 的口径 2)。
|
||
await self._finish(
|
||
ticket,
|
||
context,
|
||
"cancel",
|
||
{"from_status": from_status, "to_status": "cancelled"},
|
||
)
|
||
return self._view(ticket)
|
||
|
||
# ---------- 内部 ----------
|
||
|
||
async def _load_for_update(self, ticket_no: str) -> HandoverTicket:
|
||
ticket = await self.session.scalar(
|
||
select(HandoverTicket)
|
||
.where(HandoverTicket.ticket_no == ticket_no)
|
||
.with_for_update()
|
||
)
|
||
if ticket is None:
|
||
raise GenericResourceNotFoundError("转人工工单不存在")
|
||
return ticket
|
||
|
||
async def _finish(
|
||
self,
|
||
ticket: HandoverTicket,
|
||
context: RequestContext,
|
||
action: str,
|
||
detail: dict[str, Any],
|
||
) -> None:
|
||
"""写一条审计并提交;审计与业务写入同事务,失败即整体回滚。"""
|
||
meta = ACTIONS[action]
|
||
self.session.add(
|
||
InteractionAudit(
|
||
actor_type="user",
|
||
actor_id=int(context.user_id),
|
||
target_customer_id=ticket.customer_id,
|
||
session_id=ticket.session_id,
|
||
portal="api",
|
||
action_type=meta.action_type,
|
||
detail={
|
||
"ticket_no": ticket.ticket_no,
|
||
"action": action,
|
||
**detail,
|
||
},
|
||
created_at=_now(),
|
||
)
|
||
)
|
||
await self.session.commit()
|
||
await self.session.refresh(ticket)
|
||
|
||
@staticmethod
|
||
def _view(ticket: HandoverTicket) -> dict[str, Any]:
|
||
"""处置结果视图:只回工单自身的流转字段,不含客户标识。"""
|
||
return {
|
||
"ticket_no": ticket.ticket_no,
|
||
"status": ticket.status,
|
||
"priority": ticket.priority,
|
||
"assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None,
|
||
"assigned_at": _iso(ticket.assigned_at),
|
||
"accepted_at": _iso(ticket.accepted_at),
|
||
"resolved_at": _iso(ticket.resolved_at),
|
||
"closed_at": _iso(ticket.closed_at),
|
||
"resolution": sanitize_customer_service_message(ticket.resolution)
|
||
if ticket.resolution
|
||
else None,
|
||
"updated_at": _iso(ticket.updated_at),
|
||
}
|
||
|
||
|
||
def _require_text(value: str, label: str) -> str:
|
||
text = (value or "").strip()
|
||
if len(text) < 2:
|
||
raise ValidationAgentError(f"{label}至少 2 个字")
|
||
return text
|
||
|
||
|
||
def _iso(value: datetime | None) -> str | None:
|
||
return value.isoformat() + "Z" if value is not None and value.tzinfo is None else (
|
||
value.isoformat() if value is not None else None
|
||
)
|
||
|
||
|
||
def _now() -> datetime:
|
||
return datetime.now(UTC).replace(tzinfo=None)
|