补齐客服转人工工单流:从"只能看"到"能推进"(基线状态机,不自行发明)
## 问题 `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 项)
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
"""转人工工单**处置**的真实 MySQL + HTTP 闭环回归。
|
||||
|
||||
覆盖 `docs/02-数据库建表设计.md` §7.2 的状态机:
|
||||
|
||||
```text
|
||||
pending -> assigned -> processing -> resolved -> closed
|
||||
| | |
|
||||
`----------+-------------+-> cancelled
|
||||
```
|
||||
|
||||
一次跑完六件事:① 分配;② 接单;③ 解决;④ 关闭;⑤ 另造一张走取消;
|
||||
⑥ 非法流转被 409 拦住、越权被 403 拦住 —— 并用数据库里的 `interaction_audit`
|
||||
证明"每一步都留了痕"。
|
||||
|
||||
清理放在 `finally`:工单、审计、幂等回执全部按本次的 ticket_no / 幂等键删除,
|
||||
不给演示库留垃圾单。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from app.api.dependencies.auth import build_request_context
|
||||
from app.core.contracts import RequestContext
|
||||
from app.infrastructure.db import SessionFactory
|
||||
from app.main import app
|
||||
from app.model.audit import InteractionAudit
|
||||
from app.model.platform import HandoverTicket, RequestIdempotency
|
||||
|
||||
ADMIN_PATH = "/api/v1/admin/customer-service/handover-tickets"
|
||||
ASSIGNEE_ID = 9003
|
||||
ACTION_AUDITS = (
|
||||
"handover.assigned",
|
||||
"handover.accepted",
|
||||
"handover.resolved",
|
||||
"handover.closed",
|
||||
"handover.cancelled",
|
||||
)
|
||||
|
||||
|
||||
def _ticket_no() -> str:
|
||||
return f"ticket-{uuid4().hex[:24]}"
|
||||
|
||||
|
||||
async def _prepare(*ticket_numbers: str) -> None:
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
async with SessionFactory() as db:
|
||||
for ticket_no in ticket_numbers:
|
||||
db.add(
|
||||
HandoverTicket(
|
||||
ticket_no=ticket_no,
|
||||
session_id=f"it-handover-action-{uuid4().hex}",
|
||||
customer_id=None,
|
||||
source_agent="customer_service",
|
||||
intent="human_handover",
|
||||
confidence=0.5,
|
||||
priority="P1",
|
||||
reason_code="user_requested",
|
||||
reason_detail="集成测试:工单处置闭环",
|
||||
status="pending",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _status(ticket_no: str) -> HandoverTicket | None:
|
||||
async with SessionFactory() as db:
|
||||
return await db.scalar(
|
||||
select(HandoverTicket).where(HandoverTicket.ticket_no == ticket_no)
|
||||
)
|
||||
|
||||
|
||||
async def _audits(ticket_no: str) -> list[InteractionAudit]:
|
||||
async with SessionFactory() as db:
|
||||
return list(
|
||||
await db.scalars(
|
||||
select(InteractionAudit)
|
||||
.where(InteractionAudit.action_type.in_(ACTION_AUDITS))
|
||||
.order_by(InteractionAudit.id)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _cleanup(ticket_numbers: tuple[str, ...], keys: tuple[str, ...]) -> None:
|
||||
async with SessionFactory() as db:
|
||||
await db.execute(
|
||||
delete(InteractionAudit).where(
|
||||
InteractionAudit.action_type.in_(ACTION_AUDITS),
|
||||
InteractionAudit.detail["ticket_no"].as_string().in_(tuple(ticket_numbers)),
|
||||
)
|
||||
)
|
||||
await db.execute(
|
||||
delete(HandoverTicket).where(HandoverTicket.ticket_no.in_(tuple(ticket_numbers)))
|
||||
)
|
||||
if keys:
|
||||
await db.execute(
|
||||
delete(RequestIdempotency).where(RequestIdempotency.idempotency_key.in_(keys))
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_handover_ticket_state_machine_end_to_end() -> None:
|
||||
"""五步流转 + 取消 + 越权/非法流转拦截,全部走真实 HTTP 与 MySQL。"""
|
||||
full = _ticket_no()
|
||||
to_cancel = _ticket_no()
|
||||
keys: list[str] = []
|
||||
|
||||
def call(client: TestClient, action: str, ticket_no: str, payload: dict[str, Any]) -> Any:
|
||||
key = uuid4().hex
|
||||
keys.append(key)
|
||||
return client.post(
|
||||
f"{ADMIN_PATH}/{ticket_no}/{action}",
|
||||
json=payload,
|
||||
headers={"Idempotency-Key": key},
|
||||
)
|
||||
|
||||
def admin_context() -> RequestContext:
|
||||
return RequestContext(
|
||||
user_id="9003",
|
||||
trace_id="handover-action-integration",
|
||||
roles=("admin",),
|
||||
permissions=("handover:read", "handover:write"),
|
||||
)
|
||||
|
||||
def read_only_context() -> RequestContext:
|
||||
"""有 admin 角色、但只有只读权限码(客服只读队列的形态)。"""
|
||||
return RequestContext(
|
||||
user_id="9003",
|
||||
trace_id="handover-action-integration-readonly",
|
||||
roles=("admin",),
|
||||
permissions=("handover:read",),
|
||||
)
|
||||
|
||||
asyncio.run(_prepare(full, to_cancel))
|
||||
app.dependency_overrides[build_request_context] = admin_context
|
||||
try:
|
||||
with TestClient(app) as client:
|
||||
# ① 分配(自动分配给我自己)
|
||||
response = call(client, "assignments", full, {"assignee_id": ASSIGNEE_ID})
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["data"]["status"] == "assigned"
|
||||
|
||||
# ② 接单
|
||||
response = call(client, "acceptances", full, {})
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["data"]["status"] == "processing"
|
||||
|
||||
# ③ 解决
|
||||
response = call(
|
||||
client, "resolutions", full, {"resolution": "集成测试:已电话回访并给出结论"}
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["data"]["status"] == "resolved"
|
||||
|
||||
# ④ 关闭
|
||||
response = call(client, "closures", full, {"note": "归档"})
|
||||
assert response.status_code == 200, response.text
|
||||
closed = response.json()["data"]
|
||||
assert closed["status"] == "closed"
|
||||
assert closed["closed_at"] and closed["resolved_at"] and closed["accepted_at"]
|
||||
|
||||
# ⑤ 另一张走取消(未解决状态可取消)
|
||||
response = call(
|
||||
client, "cancellations", to_cancel, {"reason": "集成测试:重复工单合并"}
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
cancelled = response.json()["data"]
|
||||
assert cancelled["status"] == "cancelled"
|
||||
assert cancelled["closed_at"] is None, "取消不写关闭时间"
|
||||
assert str(cancelled["resolution"]).startswith("已取消:")
|
||||
|
||||
# ⑥ 非法流转:已关闭的工单不能再分配(409,且状态不变)
|
||||
response = call(client, "assignments", full, {"assignee_id": ASSIGNEE_ID})
|
||||
assert response.status_code == 409, response.text
|
||||
assert response.json()["error"]["retryable"] is False
|
||||
|
||||
# ⑦ 列表按状态筛;非法状态值 422(与数据库 CHECK 逐字对齐)
|
||||
listed = client.get(f"{ADMIN_PATH}?limit=100&status=closed").json()["data"]
|
||||
assert any(item["ticket_no"] == full for item in listed)
|
||||
assert all(item["status"] == "closed" for item in listed)
|
||||
assert client.get(f"{ADMIN_PATH}?status=已处理").status_code == 422
|
||||
|
||||
# ⑧ 详情回读:结论与时间戳都在,且不泄漏客户标识
|
||||
detail = client.get(f"{ADMIN_PATH}/{full}").json()["data"]
|
||||
assert detail["resolution"].startswith("集成测试:已电话回访")
|
||||
assert "关闭补充:归档" in detail["resolution"]
|
||||
assert detail["assigned_to"] == str(ASSIGNEE_ID)
|
||||
assert "customer_id" not in detail
|
||||
|
||||
# ⑨ 越权:只有 handover:read 时处置端点必须 403
|
||||
app.dependency_overrides[build_request_context] = read_only_context
|
||||
with TestClient(app) as client:
|
||||
response = call(client, "cancellations", to_cancel, {"reason": "越权尝试"})
|
||||
assert response.status_code == 403, response.text
|
||||
|
||||
# ⑩ 数据库侧证据:状态与审计都落库了
|
||||
ticket = asyncio.run(_status(full))
|
||||
assert ticket is not None and ticket.status == "closed"
|
||||
assert ticket.assigned_to == ASSIGNEE_ID
|
||||
assert ticket.resolved_at is not None and ticket.closed_at is not None
|
||||
cancelled_ticket = asyncio.run(_status(to_cancel))
|
||||
assert cancelled_ticket is not None and cancelled_ticket.status == "cancelled"
|
||||
|
||||
audits = [
|
||||
row for row in asyncio.run(_audits(full)) if row.detail["ticket_no"] == full
|
||||
]
|
||||
assert [row.action_type for row in audits] == [
|
||||
"handover.assigned",
|
||||
"handover.accepted",
|
||||
"handover.resolved",
|
||||
"handover.closed",
|
||||
], "每一步流转都必须留一条审计"
|
||||
assert audits[0].actor_id == ASSIGNEE_ID
|
||||
assert audits[0].detail["to_status"] == "assigned"
|
||||
assert audits[-1].detail["to_status"] == "closed"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
asyncio.run(_cleanup((full, to_cancel), tuple(keys)))
|
||||
@@ -0,0 +1,329 @@
|
||||
"""转人工工单处置状态机的单元测试(不连库)。
|
||||
|
||||
守着三件事:
|
||||
|
||||
1. **状态机口径**:`pending -> assigned -> processing -> resolved -> closed`,
|
||||
未解决可 `cancelled`(`docs/02-数据库建表设计.md` §7.2);
|
||||
2. **每次流转写审计**,且审计里带着"从什么状态到什么状态";
|
||||
3. **越权与非法流转失败关闭**:没有 `handover:write` 一律拒绝,状态不对一律 409。
|
||||
|
||||
用替身 Session 固定 ORM 交互(`scalar` 依次返回工单 / 坐席),真机回归在
|
||||
`tests/integration/test_customer_service_handover_actions_mysql.py`。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.contracts import RequestContext
|
||||
from app.core.errors import (
|
||||
ForbiddenAgentError,
|
||||
GenericResourceNotFoundError,
|
||||
InvalidStateError,
|
||||
ValidationAgentError,
|
||||
)
|
||||
from app.service.customer_service_handover_action_service import (
|
||||
CustomerServiceHandoverActionService,
|
||||
)
|
||||
|
||||
|
||||
class _FakeTicket:
|
||||
"""与 `HandoverTicket` 读取/写入字段对齐的轻量替身。"""
|
||||
|
||||
def __init__(self, status: str = "pending", **overrides: Any) -> None:
|
||||
self.id = 1
|
||||
self.ticket_no = "ticket-test-0001"
|
||||
self.session_id = "session-test"
|
||||
self.customer_id = 9001
|
||||
self.priority = "P1"
|
||||
self.status = status
|
||||
self.assigned_to: int | None = overrides.get("assigned_to")
|
||||
self.assigned_at: datetime | None = None
|
||||
self.accepted_at: datetime | None = None
|
||||
self.resolved_at: datetime | None = None
|
||||
self.closed_at: datetime | None = None
|
||||
self.resolution: str | None = None
|
||||
self.updated_at = datetime(2026, 9, 14, 12, 0, 0)
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self, ticket: _FakeTicket | None, *, assignee_exists: bool = True) -> None:
|
||||
self.ticket = ticket
|
||||
self.assignee_exists = assignee_exists
|
||||
self.added: list[Any] = []
|
||||
self.committed = 0
|
||||
|
||||
async def scalar(self, statement: Any) -> Any:
|
||||
"""按语句认目标表:查工单返回工单,查坐席返回存在性。
|
||||
|
||||
用语句文本判断而不是"第几次调用",否则连续调两个动作(先 assign 后 accept)
|
||||
时计数会串味,测试会假失败。
|
||||
"""
|
||||
if "svc_handover_ticket" in str(statement):
|
||||
return self.ticket
|
||||
return 1 if self.assignee_exists else None
|
||||
|
||||
def add(self, row: Any) -> None:
|
||||
self.added.append(row)
|
||||
|
||||
async def commit(self) -> None:
|
||||
self.committed += 1
|
||||
|
||||
async def refresh(self, _row: Any) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _context(*, write: bool = True, roles: tuple[str, ...] = ("admin",)) -> RequestContext:
|
||||
permissions = ("handover:read", "handover:write") if write else ("handover:read",)
|
||||
return RequestContext(
|
||||
user_id="9003",
|
||||
trace_id="handover-action-test",
|
||||
roles=roles,
|
||||
permissions=permissions,
|
||||
)
|
||||
|
||||
|
||||
def _service(
|
||||
ticket: _FakeTicket | None, *, assignee_exists: bool = True
|
||||
) -> tuple[CustomerServiceHandoverActionService, _FakeSession]:
|
||||
session = _FakeSession(ticket, assignee_exists=assignee_exists)
|
||||
service = CustomerServiceHandoverActionService(session) # type: ignore[arg-type]
|
||||
return service, session
|
||||
|
||||
|
||||
async def _run_denied(call: Any) -> None:
|
||||
with pytest.raises(ForbiddenAgentError):
|
||||
await call
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 状态机:合法路径
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_lifecycle_writes_one_audit_per_transition() -> None:
|
||||
"""待处理 → 已分配 → 处理中 → 已解决 → 已关闭,四步四审计。"""
|
||||
ticket = _FakeTicket("pending")
|
||||
service, session = _service(ticket)
|
||||
context = _context()
|
||||
|
||||
assigned = await service.assign("ticket-test-0001", 9003, context)
|
||||
assert ticket.status == "assigned"
|
||||
assert ticket.assigned_to == 9003
|
||||
assert assigned["status"] == "assigned"
|
||||
|
||||
processing = await service.accept("ticket-test-0001", context)
|
||||
assert ticket.status == "processing"
|
||||
assert ticket.accepted_at is not None
|
||||
assert processing["status"] == "processing"
|
||||
|
||||
resolved = await service.resolve("ticket-test-0001", "已电话回访并解释费率口径", context)
|
||||
assert ticket.status == "resolved"
|
||||
assert ticket.resolution == "已电话回访并解释费率口径"
|
||||
assert resolved["status"] == "resolved"
|
||||
|
||||
closed = await service.close("ticket-test-0001", "归档", context)
|
||||
assert ticket.status == "closed"
|
||||
assert ticket.closed_at is not None
|
||||
assert "关闭补充:归档" in str(ticket.resolution)
|
||||
assert closed["status"] == "closed"
|
||||
|
||||
assert [row.action_type for row in session.added] == [
|
||||
"handover.assigned",
|
||||
"handover.accepted",
|
||||
"handover.resolved",
|
||||
"handover.closed",
|
||||
]
|
||||
assert session.committed == 4
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accept_from_pending_self_claims_the_ticket() -> None:
|
||||
"""`pending` 也能被合法接管:接单人直接成为受理人(docs/03 的口径)。"""
|
||||
ticket = _FakeTicket("pending")
|
||||
service, session = _service(ticket)
|
||||
|
||||
await service.accept("ticket-test-0001", _context())
|
||||
|
||||
assert ticket.status == "processing"
|
||||
assert ticket.assigned_to == 9003
|
||||
assert ticket.assigned_at is not None
|
||||
assert session.added[0].detail["self_claimed"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_allowed_from_every_unresolved_status() -> None:
|
||||
"""`pending` / `assigned` / `processing` 都可取消,且不写 `closed_at`。"""
|
||||
for status in ("pending", "assigned", "processing"):
|
||||
ticket = _FakeTicket(status)
|
||||
service, session = _service(ticket)
|
||||
|
||||
view = await service.cancel("ticket-test-0001", "重复工单,合并处理", _context())
|
||||
|
||||
assert view["status"] == "cancelled"
|
||||
assert ticket.resolution == "已取消:重复工单,合并处理"
|
||||
assert ticket.closed_at is None, "取消不写关闭时间(它属于 closed 状态)"
|
||||
assert session.added[0].action_type == "handover.cancelled"
|
||||
assert session.added[0].detail["from_status"] == status
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 状态机:非法流转一律 409
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"status, action",
|
||||
[
|
||||
("assigned", "assign"),
|
||||
("processing", "assign"),
|
||||
("pending", "resolve"), # 还没接单就想解决
|
||||
("assigned", "resolve"),
|
||||
("resolved", "resolve"),
|
||||
("pending", "close"), # 还没解决就想关闭
|
||||
("processing", "close"),
|
||||
("closed", "cancel"), # 已解决/已关闭不能取消
|
||||
("resolved", "cancel"),
|
||||
("cancelled", "accept"),
|
||||
("closed", "accept"),
|
||||
],
|
||||
)
|
||||
async def test_illegal_transitions_are_rejected(status: str, action: str) -> None:
|
||||
ticket = _FakeTicket(status)
|
||||
service, session = _service(ticket)
|
||||
context = _context()
|
||||
calls = {
|
||||
"assign": lambda: service.assign("ticket-test-0001", 9003, context),
|
||||
"accept": lambda: service.accept("ticket-test-0001", context),
|
||||
"resolve": lambda: service.resolve("ticket-test-0001", "结论结论", context),
|
||||
"close": lambda: service.close("ticket-test-0001", "", context),
|
||||
"cancel": lambda: service.cancel("ticket-test-0001", "理由理由", context),
|
||||
}
|
||||
|
||||
with pytest.raises(InvalidStateError):
|
||||
await calls[action]()
|
||||
|
||||
assert ticket.status == status, "被拒绝的动作不得改动状态"
|
||||
assert session.added == []
|
||||
assert session.committed == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 输入与权限
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_write_permission_is_denied() -> None:
|
||||
"""只有 `handover:read` 的账号(客服只读队列)不能处置工单。"""
|
||||
service, session = _service(_FakeTicket("pending"))
|
||||
read_only = _context(write=False)
|
||||
|
||||
await _run_denied(service.accept("ticket-test-0001", read_only))
|
||||
await _run_denied(service.cancel("ticket-test-0001", "理由理由", read_only))
|
||||
|
||||
assert session.added == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_admin_role_with_permission_is_denied() -> None:
|
||||
"""权限码之外还要求 admin 角色:风控专员即使被误授权也进不来。"""
|
||||
service, _session = _service(_FakeTicket("pending"))
|
||||
|
||||
await _run_denied(
|
||||
service.accept("ticket-test-0001", _context(roles=("risk_operator",)))
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assign_to_unknown_assignee_is_422() -> None:
|
||||
"""坐席不存在 → 422(不是 500,也不是数据库外键报错)。"""
|
||||
service, session = _service(_FakeTicket("pending"), assignee_exists=False)
|
||||
|
||||
with pytest.raises(ValidationAgentError):
|
||||
await service.assign("ticket-test-0001", 999999, _context())
|
||||
|
||||
assert session.added == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("action", ["resolve", "cancel"])
|
||||
async def test_blank_reason_is_rejected(action: str) -> None:
|
||||
"""解决结论与取消原因都必须有实质内容(至少 2 个字)。"""
|
||||
status = "processing" if action == "resolve" else "pending"
|
||||
service, _session = _service(_FakeTicket(status))
|
||||
context = _context()
|
||||
|
||||
with pytest.raises(ValidationAgentError):
|
||||
if action == "resolve":
|
||||
await service.resolve("ticket-test-0001", " ", context)
|
||||
else:
|
||||
await service.cancel("ticket-test-0001", "x", context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_ticket_is_404() -> None:
|
||||
service, _session = _service(None)
|
||||
|
||||
with pytest.raises(GenericResourceNotFoundError):
|
||||
await service.accept("ticket-absent", _context())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_view_does_not_leak_customer_identity() -> None:
|
||||
"""处置结果不回吐客户标识(与只读侧同口径)。"""
|
||||
ticket = _FakeTicket("pending")
|
||||
service, _session = _service(ticket)
|
||||
|
||||
view = await service.assign("ticket-test-0001", 9003, _context())
|
||||
|
||||
assert "customer_id" not in view
|
||||
assert "session_id" not in view
|
||||
assert set(view) == {
|
||||
"ticket_no", "status", "priority", "assigned_to", "assigned_at",
|
||||
"accepted_at", "resolved_at", "closed_at", "resolution", "updated_at",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audit_carries_actor_and_session() -> None:
|
||||
"""审计必须能回答"谁、在哪张单子上、做了什么"。"""
|
||||
ticket = _FakeTicket("pending")
|
||||
service, session = _service(ticket)
|
||||
|
||||
await service.assign("ticket-test-0001", 9003, _context())
|
||||
|
||||
audit = session.added[0]
|
||||
assert audit.actor_type == "user"
|
||||
assert audit.actor_id == 9003
|
||||
assert audit.session_id == "session-test"
|
||||
assert audit.target_customer_id == 9001
|
||||
assert audit.detail["ticket_no"] == "ticket-test-0001"
|
||||
assert audit.detail["assignee_id"] == 9003
|
||||
|
||||
|
||||
def test_now_is_naive_utc() -> None:
|
||||
"""时间口径与全平台一致:库里存 naive UTC。"""
|
||||
from app.service.customer_service_handover_action_service import _now
|
||||
|
||||
value = _now()
|
||||
assert value.tzinfo is None
|
||||
assert abs((value - datetime.now(UTC).replace(tzinfo=None)).total_seconds()) < 5
|
||||
|
||||
|
||||
def test_read_service_has_no_write_methods() -> None:
|
||||
"""只读服务与处置服务是两条边界:读侧不许长出写方法。"""
|
||||
from app.service.customer_service_handover_admin_service import (
|
||||
CustomerServiceHandoverAdminService,
|
||||
)
|
||||
|
||||
for name in ("assign", "accept", "resolve", "close", "cancel"):
|
||||
assert not hasattr(CustomerServiceHandoverAdminService, name), (
|
||||
f"只读服务不应提供 {name}"
|
||||
)
|
||||
assert CustomerServiceHandoverAdminService.permission == "handover:read"
|
||||
@@ -71,7 +71,14 @@ async def test_handover_admin_gate_requires_dedicated_admin_permission(
|
||||
|
||||
|
||||
def test_handover_admin_detail_never_returns_raw_sensitive_or_unknown_fields() -> None:
|
||||
"""读取旧工单时仍二次脱敏,且来源字段采用显式白名单。"""
|
||||
"""读取旧工单时仍二次脱敏,且来源字段采用显式白名单。
|
||||
|
||||
⚠️ 2026-09-14 起**允许**返回受理人与流转时间(`assigned_to` / `assigned_at` /
|
||||
`accepted_at` / `resolved_at` / `closed_at` / `resolution`):工单补齐处置流之后,
|
||||
队列要能显示"派给谁了、走到哪一步了",这些是**坐席侧的路由信息,不是客户数据**。
|
||||
真正不能破的两条底线仍是:**不回吐客户标识**(`customer_id`)、
|
||||
**不回吐原始会话**(只给 `_safe_text` 脱敏后的摘要),下面的断言继续守着它们。
|
||||
"""
|
||||
data = CustomerServiceHandoverAdminService._detail_item(ticket())
|
||||
|
||||
assert data["reason_detail"] == "验证码[已隐藏],请回电"
|
||||
@@ -83,7 +90,11 @@ def test_handover_admin_detail_never_returns_raw_sensitive_or_unknown_fields() -
|
||||
"score": 0.9,
|
||||
}]
|
||||
assert "customer_id" not in data
|
||||
assert "assigned_to" not in data
|
||||
assert "internal_payload" not in str(data)
|
||||
# 处置流的字段在,但值是空的(这张单子还没被处置)。
|
||||
assert data["assigned_to"] is None
|
||||
assert data["accepted_at"] is None
|
||||
assert data["resolution"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user