补齐客服转人工工单流:从"只能看"到"能推进"(基线状态机,不自行发明)
## 问题 `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:
@@ -1,13 +1,21 @@
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, Path, Query, Response
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.dependencies.auth import build_request_context
|
||||
from app.api.dependencies.database import get_session
|
||||
from app.api.dependencies.rate_limit import enforce_rate_limit
|
||||
from app.api.schemas.admin import (
|
||||
EmptyPayload,
|
||||
EndpointPayload,
|
||||
HandoverAssignPayload,
|
||||
HandoverCancelPayload,
|
||||
HandoverClosePayload,
|
||||
HandoverResolvePayload,
|
||||
HandoverStatus,
|
||||
IntentPayload,
|
||||
ItemPayload,
|
||||
NegativePayload,
|
||||
@@ -17,12 +25,17 @@ from app.api.schemas.admin import (
|
||||
ReviewPayload,
|
||||
RoutingPayload,
|
||||
)
|
||||
from app.api.views.envelope import envelope as _envelope
|
||||
from app.core.advisor_backtest_contracts import AllocationBacktestQuery
|
||||
from app.core.contracts import RequestContext
|
||||
from app.core.profile_governance_contracts import ProfileDriftReviewRequest
|
||||
from app.service.admin_service import AdminService
|
||||
from app.service.allocation_backtest_service import AllocationBacktestService
|
||||
from app.service.api_transaction_service import ApiTransactionService
|
||||
from app.service.customer_profile_candidate_service import CustomerProfileCandidateService
|
||||
from app.service.customer_service_handover_action_service import (
|
||||
CustomerServiceHandoverActionService,
|
||||
)
|
||||
from app.service.customer_service_handover_admin_service import CustomerServiceHandoverAdminService
|
||||
from app.service.profile_governance_service import ProfileGovernanceService
|
||||
|
||||
@@ -218,11 +231,15 @@ async def audit_records(
|
||||
async def list_customer_service_handover_tickets(
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
cursor: str | None = Query(default=None),
|
||||
status: HandoverStatus | None = Query(default=None), # noqa: B008
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""只读查看客服待转人工队列;不暴露原始会话或处理动作。"""
|
||||
"""只读查看客服转人工队列;不暴露原始会话。可按状态筛。
|
||||
|
||||
处置动作在下面五个端点里,需要 `handover:write`(本端点只需 `handover:read`)。
|
||||
"""
|
||||
return await CustomerServiceHandoverAdminService().list_tickets(
|
||||
context, limit=limit, cursor=cursor
|
||||
context, limit=limit, cursor=cursor, status=status
|
||||
)
|
||||
|
||||
|
||||
@@ -231,10 +248,142 @@ async def get_customer_service_handover_ticket(
|
||||
ticket_no: str,
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""只读查看单个工单的脱敏转接摘要。"""
|
||||
"""只读查看单个工单的脱敏转接摘要与流转状态。"""
|
||||
return await CustomerServiceHandoverAdminService().get_ticket(ticket_no, context)
|
||||
|
||||
|
||||
# ---- 转人工工单处置(分配 / 接单 / 解决 / 关闭 / 取消)----
|
||||
#
|
||||
# 状态机与权限口径见 `docs/02` §7.2 与 `app/service/customer_service_handover_action_service.py`:
|
||||
# pending -> assigned -> processing -> resolved -> closed,未解决可 cancelled。
|
||||
# 五个端点都要求 `handover:write` + admin 角色,并且都要求 `Idempotency-Key`
|
||||
# (重复提交直接回放上次结果,不会二次推进状态机)。
|
||||
|
||||
|
||||
@router.post("/customer-service/handover-tickets/{ticket_no}/assignments")
|
||||
async def assign_customer_service_handover_ticket(
|
||||
payload: HandoverAssignPayload,
|
||||
ticket_no: str = Path(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"),
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
session: AsyncSession = Depends(get_session), # noqa: B008
|
||||
key: str | None = Header(default=None, alias="Idempotency-Key"),
|
||||
) -> dict[str, Any]:
|
||||
"""把待处理工单分配给一个坐席(`pending -> assigned`)。"""
|
||||
return await _handover_action(
|
||||
session,
|
||||
context,
|
||||
key,
|
||||
f"POST /api/v1/admin/customer-service/handover-tickets/{ticket_no}/assignments",
|
||||
payload.model_dump(),
|
||||
lambda inner: CustomerServiceHandoverActionService(inner).assign(
|
||||
ticket_no, payload.assignee_id, context
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/customer-service/handover-tickets/{ticket_no}/acceptances")
|
||||
async def accept_customer_service_handover_ticket(
|
||||
ticket_no: str = Path(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"),
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
session: AsyncSession = Depends(get_session), # noqa: B008
|
||||
key: str | None = Header(default=None, alias="Idempotency-Key"),
|
||||
) -> dict[str, Any]:
|
||||
"""坐席接单(`pending` 自助接管 / `assigned -> processing`)。"""
|
||||
return await _handover_action(
|
||||
session,
|
||||
context,
|
||||
key,
|
||||
f"POST /api/v1/admin/customer-service/handover-tickets/{ticket_no}/acceptances",
|
||||
{},
|
||||
lambda inner: CustomerServiceHandoverActionService(inner).accept(ticket_no, context),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/customer-service/handover-tickets/{ticket_no}/resolutions")
|
||||
async def resolve_customer_service_handover_ticket(
|
||||
payload: HandoverResolvePayload,
|
||||
ticket_no: str = Path(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"),
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
session: AsyncSession = Depends(get_session), # noqa: B008
|
||||
key: str | None = Header(default=None, alias="Idempotency-Key"),
|
||||
) -> dict[str, Any]:
|
||||
"""给出解决结论(`processing -> resolved`)。"""
|
||||
return await _handover_action(
|
||||
session,
|
||||
context,
|
||||
key,
|
||||
f"POST /api/v1/admin/customer-service/handover-tickets/{ticket_no}/resolutions",
|
||||
payload.model_dump(),
|
||||
lambda inner: CustomerServiceHandoverActionService(inner).resolve(
|
||||
ticket_no, payload.resolution, context
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/customer-service/handover-tickets/{ticket_no}/closures")
|
||||
async def close_customer_service_handover_ticket(
|
||||
ticket_no: str = Path(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"),
|
||||
payload: HandoverClosePayload | None = None,
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
session: AsyncSession = Depends(get_session), # noqa: B008
|
||||
key: str | None = Header(default=None, alias="Idempotency-Key"),
|
||||
) -> dict[str, Any]:
|
||||
"""归档关闭(`resolved -> closed`);可选补一条说明。"""
|
||||
note = payload.note if payload is not None else ""
|
||||
return await _handover_action(
|
||||
session,
|
||||
context,
|
||||
key,
|
||||
f"POST /api/v1/admin/customer-service/handover-tickets/{ticket_no}/closures",
|
||||
{"note": note},
|
||||
lambda inner: CustomerServiceHandoverActionService(inner).close(
|
||||
ticket_no, note, context
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/customer-service/handover-tickets/{ticket_no}/cancellations")
|
||||
async def cancel_customer_service_handover_ticket(
|
||||
payload: HandoverCancelPayload,
|
||||
ticket_no: str = Path(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"),
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
session: AsyncSession = Depends(get_session), # noqa: B008
|
||||
key: str | None = Header(default=None, alias="Idempotency-Key"),
|
||||
) -> dict[str, Any]:
|
||||
"""取消未解决的工单(`pending|assigned|processing -> cancelled`)。"""
|
||||
return await _handover_action(
|
||||
session,
|
||||
context,
|
||||
key,
|
||||
f"POST /api/v1/admin/customer-service/handover-tickets/{ticket_no}/cancellations",
|
||||
payload.model_dump(),
|
||||
lambda inner: CustomerServiceHandoverActionService(inner).cancel(
|
||||
ticket_no, payload.reason, context
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _handover_action(
|
||||
session: AsyncSession,
|
||||
context: RequestContext,
|
||||
key: str | None,
|
||||
scope: str,
|
||||
body: dict[str, Any],
|
||||
action: Callable[[AsyncSession], Awaitable[dict[str, Any]]],
|
||||
) -> dict[str, Any]:
|
||||
"""工单处置的统一外壳:幂等 + 统一信封。
|
||||
|
||||
幂等记录与业务写入**同事务**(`ApiTransactionService.execute_in`),
|
||||
重复请求直接回放 `response_json`,不会二次驱动状态机。
|
||||
幂等范围含路径参数 —— 同一个键用在两张不同工单上会被当成两次不同操作
|
||||
(与风控处置同一口径:把路径参数折成模板会让不同资源互相回放)。
|
||||
"""
|
||||
data = await ApiTransactionService().execute_in(
|
||||
session, context, scope, key, body, action
|
||||
)
|
||||
return _envelope(data, context)
|
||||
|
||||
|
||||
@router.get("/customer-profile-candidates")
|
||||
async def list_customer_profile_candidates(
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
|
||||
Reference in New Issue
Block a user