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

290 lines
11 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.
"""客服转人工工单的**人工处置**服务:分配 / 接单 / 解决 / 关闭 / 取消。
状态机照基线实现,**不自行发明**(`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)