diff --git a/AGENTS.md b/AGENTS.md index 6c1cab7..f0feff5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,7 @@ > 演示数据一键准备:`python tools/seed_demo_data.py`(10 步,顺序有依赖,见脚本内表格); > **演示流程(8 个场景照读版 + 排障表 + 账号速查)见 `docs/44-演示流程.md`**; > 交付自检(**两条线互补,都跑一遍**): -> `python tools/e2e_smoke_test.py`(**业务链路**冒烟:登录→下单→成交、风控扫描→处置闭环、客服问答,6 条线 40 项,`--read-only` 不动数据); +> `python tools/e2e_smoke_test.py`(**业务链路**冒烟:登录→下单→成交、风控扫描→处置闭环、客服问答与**转人工工单处置闭环**,6 条线 44 项,`--read-only` 不动数据); > `python tools/portal_api_check.py`(**接口契约**体检:按前端的方式调每个端点,核对状态码、信封形状与字段是否与前端期望一致,41 项;`--write` 加测写操作、`--dangerous` 再加测会改生效配置的操作)。 > ⚠️ **`start.ps1` 必须保存为 UTF-8 with BOM**:Windows PowerShell 5.1 在缺 BOM 时按系统 > ANSI(简中为 GBK)解析,中文注释直接抛 `Unexpected token '[璀﹀憡]'` 这类语法错误。 @@ -148,7 +148,12 @@ `9001-9017` 一期公共;`9018-9034` 客服二期/投顾;`9041-9046` 产品治理与候选审核 (**`9035-9040` 与 `4041-4046` 从未建过**,`9041` 是把冲突的 `9020-9035` 挪走的修正结果,见 `docs/36`); `9047-9050` 风控告警四个;`9051-9056` 推广/NL2SQL/探针; - `9057-9059` 投顾客户范围三项;**`9060-9065` 账户与交易看板六项**(2026-09-12 后追加,**`AGENTS.md` 旧写的「9046」已过期**)。 + `9057-9059` 投顾客户范围三项;**`9060-9065` 账户与交易看板六项**(2026-09-12 后追加,**`AGENTS.md` 旧写的「9046」已过期**); + **`9066-9068` 投顾代客三项**(`*:customer` 变体,服务层按 `customer_id == context.user_id` **动态拼**出来, + 对账工具抓不到字面量,曾是"投顾一操作客户就整片 403"的根因); + **`9069` 客服转人工工单处置 `handover:write`**(2026-09-14 补:此前只有 `handover:read`, + 状态机 `pending→assigned→processing→resolved→closed` 一个动作都没有入口,40 张单子全停在 `pending`; + 用 `tools/grant_handover_write_permission.py` 幂等补齐,只授给 `admin`)。 另注:`sys_user` 已改为「存在则更新、不存在才插入」,故重跑种子**不会**再弄丢演示密码。 - ⚠️ **`config_release` 是环境数据,不随代码合并**:本机 active 版本 id 与架构师环境**不同** (本机是我方发布的客服白名单;他那边还有风控的白名单)。**"白名单已发布"必须带环境限定**,换环境要重发。 diff --git a/app/api/controllers/admin.py b/app/api/controllers/admin.py index d67c062..5a90ae2 100644 --- a/app/api/controllers/admin.py +++ b/app/api/controllers/admin.py @@ -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), diff --git a/app/api/schemas/admin.py b/app/api/schemas/admin.py index f1f602b..25a58b7 100644 --- a/app/api/schemas/admin.py +++ b/app/api/schemas/admin.py @@ -121,5 +121,42 @@ class ReviewPayload(StrictPayload): comment: str = Field(default="", max_length=1000) +#: `svc_handover_ticket.status` 的取值,**必须逐字对齐数据库 CHECK 约束** +#: (`chk_handoff_status`,见 `docs/02-数据库建表设计.md` §7.2)。 +#: 与 `ReplyScene` 同一个教训:不在列内的值若穿到数据库,会以 500 冒出而不是 422。 +HandoverStatus = Literal[ + "pending", + "assigned", + "processing", + "resolved", + "closed", + "cancelled", +] + + +class HandoverAssignPayload(StrictPayload): + """分配工单给一个坐席(`sys_user.id`)。""" + + assignee_id: int = Field(gt=0) + + +class HandoverResolvePayload(StrictPayload): + """解决工单必须给结论(`resolution` 落库)。""" + + resolution: str = Field(min_length=2, max_length=2000) + + +class HandoverClosePayload(StrictPayload): + """关闭工单可选补一条说明,追加在结论之后。""" + + note: str = Field(default="", max_length=2000) + + +class HandoverCancelPayload(StrictPayload): + """取消工单必须给原因(写进 `resolution`)。""" + + reason: str = Field(min_length=2, max_length=500) + + class EmptyPayload(StrictPayload): pass diff --git a/app/service/customer_service_handover_action_service.py b/app/service/customer_service_handover_action_service.py new file mode 100644 index 0000000..d1ff014 --- /dev/null +++ b/app/service/customer_service_handover_action_service.py @@ -0,0 +1,289 @@ +"""客服转人工工单的**人工处置**服务:分配 / 接单 / 解决 / 关闭 / 取消。 + +状态机照基线实现,**不自行发明**(`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) diff --git a/app/service/customer_service_handover_admin_service.py b/app/service/customer_service_handover_admin_service.py index d7b8200..e0c36a9 100644 --- a/app/service/customer_service_handover_admin_service.py +++ b/app/service/customer_service_handover_admin_service.py @@ -1,7 +1,10 @@ """管理员查看客服转人工队列的只读服务。 本模块只暴露工单中已经二次脱敏的最小必要字段;它不读取原始会话、账户、画像或 -联系方式,也不提供接单、分配、解决或关闭工单的能力。 +联系方式,也不提供接单、分配、解决或关闭工单的能力 —— +**工单的处置(分配/接单/解决/关闭/取消)在 +`app/service/customer_service_handover_action_service.py`**,那是另一条有写权限与 +状态机校验的边界,两者刻意分开:读侧可以被更宽的人群使用,写侧必须 `handover:write`。 """ from datetime import date, datetime @@ -20,24 +23,33 @@ 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 + self, + context: RequestContext, + *, + limit: int = 20, + cursor: str | None = None, + status: str | None = None, ) -> dict[str, Any]: - """按工单 ID 倒序返回一页已脱敏的转人工队列。""" + """按工单 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}, + "meta": {"trace_id": context.trace_id, "status": status}, } async def get_ticket(self, ticket_no: str, context: RequestContext) -> dict[str, Any]: @@ -62,6 +74,11 @@ class CustomerServiceHandoverAdminService: "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), } @@ -75,6 +92,8 @@ class CustomerServiceHandoverAdminService: "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), } diff --git a/app/static/portal/common/api-client.js b/app/static/portal/common/api-client.js index e73e4c7..60e6bac 100644 --- a/app/static/portal/common/api-client.js +++ b/app/static/portal/common/api-client.js @@ -49,6 +49,14 @@ const ENDPOINTS = Object.freeze({ A033: { method: 'GET', path: '/api/v1/admin/audit-records' }, ADMIN_HANDOVERS: { method: 'GET', path: '/api/v1/admin/customer-service/handover-tickets' }, ADMIN_HANDOVER_DETAIL: { method: 'GET', path: '/api/v1/admin/customer-service/handover-tickets/{ticketNo}' }, + // 转人工工单处置(状态机见 docs/02 §7.2): + // pending -> assigned -> processing -> resolved -> closed,未解决可 cancelled。 + // 五个都要 `handover:write` + admin,且都带 Idempotency-Key(同键重发只回放结果)。 + ADMIN_HANDOVER_ASSIGN: { method: 'POST', path: '/api/v1/admin/customer-service/handover-tickets/{ticketNo}/assignments', idempotent: true }, + ADMIN_HANDOVER_ACCEPT: { method: 'POST', path: '/api/v1/admin/customer-service/handover-tickets/{ticketNo}/acceptances', idempotent: true }, + ADMIN_HANDOVER_RESOLVE: { method: 'POST', path: '/api/v1/admin/customer-service/handover-tickets/{ticketNo}/resolutions', idempotent: true }, + ADMIN_HANDOVER_CLOSE: { method: 'POST', path: '/api/v1/admin/customer-service/handover-tickets/{ticketNo}/closures', idempotent: true }, + ADMIN_HANDOVER_CANCEL: { method: 'POST', path: '/api/v1/admin/customer-service/handover-tickets/{ticketNo}/cancellations', idempotent: true }, ADMIN_ADVISOR_PENDING: { method: 'GET', path: '/api/v1/admin/advisor/pending-contents' }, ADMIN_ADVISOR_REVIEW: { method: 'POST', path: '/api/v1/admin/advisor/recommendations/{contentId}/reviews', idempotent: true }, ADMIN_ADVISOR_PUBLISH: { method: 'POST', path: '/api/v1/admin/advisor/recommendations/{contentId}/publications', idempotent: true }, diff --git a/app/static/portal/employee-console/workspace/index.html b/app/static/portal/employee-console/workspace/index.html index cc504ee..210c02e 100644 --- a/app/static/portal/employee-console/workspace/index.html +++ b/app/static/portal/employee-console/workspace/index.html @@ -36,7 +36,7 @@

模型端点

密钥引用不会在前端暴露
- + @@ -50,6 +50,6 @@ 浏览器按**完整 URL** 去重,两条不同 query 会被当成两个模块、**各执行一次**, 于是入口里的 `mountShell()` 跑两遍,页面上出现**两份顶部导航与页脚**。 改版本号时是**替换**这一行,不是新增一行。 --> - + diff --git a/app/static/portal/employee-console/workspace/workspace.js b/app/static/portal/employee-console/workspace/workspace.js index f9e3cf7..68463ca 100644 --- a/app/static/portal/employee-console/workspace/workspace.js +++ b/app/static/portal/employee-console/workspace/workspace.js @@ -1,4 +1,4 @@ -import { apiClient } from '/static/portal/common/api-client.js?v=20260914'; +import { apiClient } from '/static/portal/common/api-client.js?v=20260914-handover'; import { getAuthContext, requireAdmin, updateAuthPermissions } from '/static/portal/common/auth.js?v=20260913'; import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js'; import { mountShell } from '/static/portal/common/layout/app-shell.js'; @@ -151,15 +151,59 @@ if (requireAdmin()) { } catch (error) { apiClient.reportError(error); renderError(targets.audits, error, loadAudits); } } + // ---- 转人工工单:只读队列 + 处置(分配/接单/解决/关闭/取消)---- + // + // 状态机照基线 `docs/02` §7.2:pending -> assigned -> processing -> resolved -> closed, + // 未解决可 cancelled。按钮按**当前状态**给(后端还会再判一次,前端只是不给非法入口)。 + const HANDOVER_STATUS_LABELS = { + pending: '待处理', + assigned: '已分配', + processing: '处理中', + resolved: '已解决', + closed: '已关闭', + cancelled: '已取消', + }; + + function handoverActions(item) { + const buttons = []; + if (item.status === 'pending') { + buttons.push(['assign', '分配']); + buttons.push(['accept', '直接接单']); + } else if (item.status === 'assigned') { + buttons.push(['accept', '接单']); + } else if (item.status === 'processing') { + buttons.push(['resolve', '解决']); + } else if (item.status === 'resolved') { + buttons.push(['close', '关闭']); + } + // 取消只在"未解决"的三个状态里给(与后端 CANCELLABLE_STATUSES 一致)。 + if (['pending', 'assigned', 'processing'].includes(item.status)) { + buttons.push(['cancel', '取消', 'button--danger']); + } + return `
${buttons + .map(([action, label, extra]) => ``) + .join('')}
`; + } + async function loadHandovers() { renderLoading(targets.handovers, 3); try { - const response = await apiClient.get('ADMIN_HANDOVERS', { query: { limit: 20 } }); + const status = document.querySelector('[data-handover-status]')?.value || ''; + const response = await apiClient.get('ADMIN_HANDOVERS', { + query: { limit: 20, ...(status ? { status } : {}) }, + }); state.handovers = Array.isArray(response.data) ? response.data : []; - if (!state.handovers.length) renderEmpty(targets.handovers, '暂无转人工工单', '当前没有待处理的客服转人工事项。'); - else { - targets.handovers.innerHTML = table(state.handovers, [['ticket_no', '工单编号'], ['source_agent', '来源 Agent'], ['priority', '优先级'], ['reason_code', '原因'], ['status', '状态'], ['created_at', '创建时间']], (item) => ``); + const scope = status ? `(筛:${HANDOVER_STATUS_LABELS[status] || status})` : ''; + if (!state.handovers.length) { + renderEmpty(targets.handovers, '暂无转人工工单', `当前没有符合条件的客服转人工事项${scope}。`); + } else { + targets.handovers.innerHTML = table( + state.handovers, + [['ticket_no', '工单编号'], ['source_agent', '来源 Agent'], ['priority', '优先级'], ['reason_code', '原因'], ['status', '状态'], ['assigned_to', '受理人'], ['created_at', '创建时间']], + handoverActions, + ); targets.handovers.querySelectorAll('[data-ticket]').forEach((button) => button.addEventListener('click', () => openHandover(button.dataset.ticket))); + targets.handovers.querySelectorAll('[data-handover-action]').forEach((button) => button.addEventListener('click', () => openHandoverAction(button.dataset.handoverAction, button.dataset.handoverTicket))); } } catch (error) { apiClient.reportError(error); renderError(targets.handovers, error, loadHandovers); } } @@ -169,10 +213,30 @@ if (requireAdmin()) { try { const response = await apiClient.get('ADMIN_HANDOVER_DETAIL', { pathParams: { ticketNo } }); const item = response.data; - showDetail(`工单 ${item.ticket_no}`, `
状态
${escapeHtml(item.status)}
优先级
${escapeHtml(item.priority)}
识别意图
${escapeHtml(item.intent || '--')}
置信度
${escapeHtml(value(item.confidence))}

转接原因

${escapeHtml(item.reason_detail || '--')}

脱敏会话摘要

${escapeHtml(item.conversation_summary || '--')}

`); + const row = (label, value) => `
${escapeHtml(label)}
${escapeHtml(value ?? '--')}
`; + showDetail(`工单 ${item.ticket_no}`, `
${row('状态', HANDOVER_STATUS_LABELS[item.status] || item.status)}${row('优先级', item.priority)}${row('识别意图', item.intent)}${row('置信度', value(item.confidence))}${row('受理人', item.assigned_to)}${row('分配时间', item.assigned_at)}${row('接单时间', item.accepted_at)}${row('解决时间', item.resolved_at)}${row('关闭时间', item.closed_at)}

转接原因

${escapeHtml(item.reason_detail || '--')}

脱敏会话摘要

${escapeHtml(item.conversation_summary || '--')}

处置结论

${escapeHtml(item.resolution || '(尚未填写)')}

`); } catch (error) { showDetail('转人工工单', `
${escapeHtml(error.message)}
`); } } + function openHandoverAction(action, ticketNo) { + const item = state.handovers.find((rowdata) => String(rowdata.ticket_no) === String(ticketNo)); + const label = HANDOVER_STATUS_LABELS[item?.status] || item?.status || ''; + const copy = { + assign: `把工单 ${ticketNo} 分配给坐席 9003(本演示只提供"分配给我自己"),状态将变为「已分配」。`, + accept: `接管工单 ${ticketNo}(当前「${label}」),状态将变为「处理中」,受理人记为你。`, + resolve: `填写解决结论后工单 ${ticketNo} 变为「已解决」(结论会落库,可在摘要里回看)。`, + close: `把工单 ${ticketNo} 归档为「已关闭」;可留一条关闭补充说明。`, + cancel: `取消工单 ${ticketNo}(当前「${label}」)。取消只允许未解决的工单,原因会写进处置结论。`, + }[action]; + state.action = { type: 'handover', action, ticketNo }; + openActionDialog( + { assign: '分配工单', accept: '接单', resolve: '解决工单', close: '关闭工单', cancel: '取消工单' }[action], + copy, + true, + { assign: '处置说明', accept: '', resolve: '解决结论', close: '关闭补充(可空)', cancel: '取消原因' }[action], + ); + } + async function loadCandidates() { renderLoading(targets.candidates, 3); try { @@ -593,10 +657,12 @@ if (requireAdmin()) { ); } - function openActionDialog(title, copy, showComment) { + function openActionDialog(title, copy, showComment, commentLabel = '审核意见') { document.querySelector('[data-admin-action-title]').textContent = title; document.querySelector('[data-admin-action-copy]').textContent = copy; document.querySelector('[data-admin-comment-field]').hidden = !showComment; + const label = document.querySelector('[data-admin-comment-field] .form-field__label'); + if (label) label.textContent = commentLabel; document.querySelector('[data-admin-action-form]').elements.comment.value = ''; document.querySelector('[data-admin-action-alert]').classList.remove('form-alert--visible'); actionDialog.showModal(); @@ -610,6 +676,31 @@ if (requireAdmin()) { const comment = form.elements.comment.value.trim(); submit.disabled = true; try { + if (state.action.type === 'handover') { + const { action, ticketNo } = state.action; + // 五个动作各自的端点与载荷;`assign` 只提供"分配给我自己"(演示口径, + // 需要选具体坐席时把 assignee_id 换成下拉里的人)。 + const spec = { + // 「分配」在本页只提供"分配给我自己":管理员就是当前登录人(`context.userId`)。 + // 需要把工单派给别的坐席时,把这里换成一个人选下拉即可(后端收的是 + // `sys_user.id`,并且会校验该用户存在)。 + assign: ['ADMIN_HANDOVER_ASSIGN', { assignee_id: Number(context.userId) }], + accept: ['ADMIN_HANDOVER_ACCEPT', {}], + resolve: ['ADMIN_HANDOVER_RESOLVE', { resolution: comment }], + close: ['ADMIN_HANDOVER_CLOSE', { note: comment }], + cancel: ['ADMIN_HANDOVER_CANCEL', { reason: comment }], + }[action]; + if (!spec) throw new Error(`未知的工单动作:${action}`); + if (action !== 'accept' && action !== 'assign' && comment.length < 2) { + throw new Error('请填写至少 2 个字的说明'); + } + await apiClient.post(spec[0], spec[1], { pathParams: { ticketNo } }); + showToast('工单状态已更新'); + await Promise.all([loadHandovers(), loadAudits()]); + actionDialog.close(); + renderMetrics(); + return; + } if (state.action.type === 'advisor') { const { action, item } = state.action; const isBook = item.content_type === 'investment_goal_book'; @@ -664,6 +755,8 @@ if (requireAdmin()) { })); document.querySelector('[data-identity-form]').addEventListener('submit', queryIdentity); document.querySelector('[data-reload-audit]').addEventListener('click', loadAudits); + document.querySelector('[data-reload-handover]').addEventListener('click', loadHandovers); + document.querySelector('[data-handover-status]').addEventListener('change', loadHandovers); document.querySelector('[data-reload-advisor]').addEventListener('click', loadAdvisorReviews); document.querySelector('[data-reload-knowledge]').addEventListener('click', loadKnowledge); document.querySelector('[data-knowledge-form]').addEventListener('submit', submitKnowledge); diff --git a/docs/44-演示流程.md b/docs/44-演示流程.md index a77c546..8704644 100644 --- a/docs/44-演示流程.md +++ b/docs/44-演示流程.md @@ -99,7 +99,7 @@ python tools/e2e_smoke_test.py --read-only # 业务链路:登录→下单 python tools/portal_api_check.py # 接口契约:按前端的方式核对每个端点的状态码与字段 ``` -看最后一行:**40/40** 与 **41 项全通过**才开始演示(用例数会随开发增减, +看最后一行:**44/44** 与 **41 项全通过**才开始演示(用例数会随开发增减, 判断健康的准则是 **0 FAIL**,不是绝对条数)。有 FAIL 就按 §4 排查。 > 两者的分工:`e2e_smoke_test.py` 回答"**这条业务走得通吗**", @@ -206,11 +206,16 @@ Invoke-RestMethod -Uri http://127.0.0.1:8000/api/v1/users/me/orders -Method Post | | | |---|---| | **操作** | 客户页右下角浮窗 → 问「基金定投是什么」→ 点「转人工客服」 | -| **预期** | 先得到知识库答案;转人工返回 `202`,工单进入管理员队列 | +| **预期** | 先得到知识库答案;转人工返回 `202`,工单进入管理员队列(**新建的工单状态是 `pending`**) | **这体现什么**:客户**主动**请求人工与"答不上来"是两条不同路径 —— 前者建工单, 后者按既定口径引导拨打客服热线(**不建工单**,符合我们"答不了就转人工、不硬答"的原则)。 +> ✅ **这张工单现在可以在管理员工作台里被真正处置掉**(2026-09-14 补齐): +> 分配 → 接单 → 解决 → 关闭(未解决可取消),每一步都写审计。 +> 演完场景 8 时顺手走一遍,就能把"客户请求人工 → 坐席接管 → 结案归档"讲完整。 +> 状态机与权限见 §场景 8 的表格。 + ### 场景 5 · 风控预警处置(2 min)⭐ 重点 | | | @@ -299,7 +304,7 @@ Invoke-RestMethod -Uri http://127.0.0.1:8000/api/v1/users/me/orders -Method Post | 角色与权限 | 五个角色的权限数(**2026-09-14 实测**):admin **62** / advisor **31** / customer **26** / risk_operator **10** / operator **7**;还能按用户 ID 查"服务端实际解析出来的角色与数据范围" | | 配置与模型 | 工具白名单、提示词**走发布状态机**(草稿 → 待审核 → 已审核 → 生效,可回滚;被取代的版本落 superseded),不是改配置文件;模型路由规则也在这里 | | 审计记录 | 每一次权限判定、工具调用都有记录;没有"看敏感详情"的权限时,详情由服务端**脱敏** | -| 转人工工单 | 场景 4 建的工单在这里(**当前库里 40 条**);**不返回客户标识与原始对话**(二次脱敏) | +| 转人工工单 | 场景 4 建的工单在这里(**当前库里 40+ 张,多数是冒烟测试件**);只回**二次脱敏**摘要,不返回客户标识与原始对话。**可以处置**:分配 → 接单 → 解决 → 关闭(未解决可取消),按状态给按钮、可按状态筛 | | 画像候选 | 客服从对话里提炼的画像候选,批准后才进正式记忆 | | 投顾复核 | 待审的投顾交付物(推荐方案 + 投资方案书)→ 审核通过 / 驳回 / 发布给客户 | | 知识库 | 上传文档(faq / product / policy)→ 自动切分入库并投向量同步;**客服就是靠它作答**(当前 active 知识 24 份、知识元数据 199 行) | @@ -421,7 +426,7 @@ python tools/e2e_smoke_test.py python tools/seed_demo_data.py # 准备演示数据(10 步,首次/换机器) python tools/sync_market_prices.py # 刷行情(15 分钟有效期,下单 503 时补跑) powershell -ExecutionPolicy Bypass -File start.ps1 # 起 API + Worker(自动刷行情) -python tools/e2e_smoke_test.py # 全量体检(6 条线 40 项) +python tools/e2e_smoke_test.py # 全量体检(6 条线 44 项,含工单处置闭环) python tools/e2e_smoke_test.py --read-only # 只读体检,不动数据 python tools/check_today_profit_loss.py # 「今日盈亏」纯 SQL 复算并与接口逐只比对 python tools/portal_api_check.py # 接口契约体检(按前端的方式调,41 项) diff --git a/docs/演示用/全功能流程-大白话版.md b/docs/演示用/全功能流程-大白话版.md index 80cb9bf..feed34b 100644 --- a/docs/演示用/全功能流程-大白话版.md +++ b/docs/演示用/全功能流程-大白话版.md @@ -467,7 +467,7 @@ | **角色与权限** | 看角色清单(角色名/代码/权限数/用户数/状态);输入一个用户 ID,看**服务端实际解析出来的**角色、权限、数据范围 | 只读 | | **配置与模型** | 平台里**唯一能改配置**的地方:新建发布版本 → 校验 → 审核 → 激活;管平台配置项(命名空间:工具白名单 `agent_tools`、记忆 `memory`、关系 `relationship`、运行时 `runtime`、行情 `fund_market`)与模型路由规则 | 读写 | | **审计记录** | 最近 20 条审计(动作、主体、入口、时间);没有"看敏感详情"的权限时,详情由服务端**脱敏** | 只读 | -| **转人工工单** | 看客服转人工的工单与**二次脱敏**的会话摘要 | 只读 | +| **转人工工单** | 看客服转人工的工单与**二次脱敏**的会话摘要;**并且能处置**:分配 → 接单 → 解决 → 关闭(未解决可取消),可按状态筛,每一步都写审计 | 读写 | | **画像候选** | 客户对话里提炼出的画像候选,批准后才进正式记忆(批准/驳回都要填意见) | 读写 | | **投顾复核** | 待审的投顾交付物(推荐方案 + 投资方案书)→ 审核通过 / 驳回 / 发布给客户 | 读写 | | **知识库** | 上传文档(faq / product / policy)→ 自动切分入库并投向量同步;**客服就是靠它作答**;也能让某份知识失效 | 读写 | @@ -707,3 +707,4 @@ python tools/portal_api_check.py # 接口契约体检(按 | 投顾"已发布交付物"空态写"需管理员审核" | 文案没跟进"投顾可自助审核发布"这次改动 | | 管理员配置发布没有"驳回/回滚"按钮、模型端点页是只读的、审计页没有筛选框 | 后端接口都有,前端未接 | | 客户「资金流水」页顶部导航不高亮 | 导航配置里该页的 `active` 是空串 | +| 转人工工单队列里一堆一模一样的"端到端冒烟"单子 | 冒烟脚本每跑一次建一张。**2026-09-14 起它自己会走完处置闭环收尾**(分配→接单→解决→关闭),不再堆积;历史遗留的那些可以手动处置掉 | diff --git a/docs/演示用/后端接口文档-2026-09-14.md b/docs/演示用/后端接口文档-2026-09-14.md index fc73e8f..7901ce5 100644 --- a/docs/演示用/后端接口文档-2026-09-14.md +++ b/docs/演示用/后端接口文档-2026-09-14.md @@ -1890,8 +1890,13 @@ model_failure | system_busy | clarification | 编号 | 端点 | 用途 | 权限 | |---|---|---|---| | **A033** | `GET /api/v1/admin/audit-records` | 审计查询(`limit` 1–100 默认 20 + `cursor`) | `audit:read`(无 `audit:read-sensitive` 则 `detail` 被涂成 `{"redacted": true}`) | -| — | `GET /api/v1/admin/customer-service/handover-tickets` | 客服待转人工队列(只读) | 管理员 | -| — | `GET .../handover-tickets/{ticket_no}` | 单工单脱敏摘要 | 管理员 | +| — | `GET /api/v1/admin/customer-service/handover-tickets` | 客服转人工队列(只读);**支持 `?status=` 六态筛选** | `handover:read` + 管理员 | +| — | `GET .../handover-tickets/{ticket_no}` | 单工单脱敏摘要 + 流转进度 | 同上 | +| **A049** | `POST .../handover-tickets/{ticket_no}/assignments` | 分配工单(`pending → assigned`),体 `{assignee_id}` | `handover:write` + 管理员;幂等 | +| **A050** | `POST .../handover-tickets/{ticket_no}/acceptances` | 接单(`pending` 自助接管 / `assigned → processing`),无体 | 同上 | +| **A051** | `POST .../handover-tickets/{ticket_no}/resolutions` | 解决(`processing → resolved`),体 `{resolution}` ≥2 字 | 同上 | +| **A052** | `POST .../handover-tickets/{ticket_no}/closures` | 关闭(`resolved → closed`),体 `{note}` 可空 | 同上 | +| **A053** | `POST .../handover-tickets/{ticket_no}/cancellations` | 取消(`pending\|assigned\|processing → cancelled`),体 `{reason}` ≥2 字 | 同上 | | **A039** | `GET /api/v1/admin/customer-profile-candidates` | 待处理画像候选(`limit` 默认 20) | `memory:candidate:review`(`admin=True`) | | **A040** | `POST /api/v1/admin/customer-profile-candidates/{candidate_id}/reviews` | 批准/驳回候选 | 同上;**成功 200** | | **A041** | `POST /api/v1/admin/advisor/asset-allocation-backtests` | 资产配置回测 | `asset-allocation:backtest`;**201**;幂等 | @@ -1908,6 +1913,32 @@ model_failure | system_busy | clarification **A040 批准时会处理同键旧正式记忆**(`conflict_type="candidate_promoted"`)。 +### 11.4b 转人工工单处置(A049–A053)—— 2026-09-14 补齐 + +状态机照基线 `docs/02-数据库建表设计.md` §7.2 与 `docs/03` §客服转人工(**没有自行发明**): + +```text +pending -> assigned -> processing -> resolved -> closed + | | | + `----------+-------------+-> cancelled +``` + +| 项 | 口径 | +|---|---| +| **权限** | 读 `handover:read`(9046,原本就有);**处置要 `handover:write`(9069,本次新增)**,且两者都要求 `admin`/`super_admin` 角色 | +| **幂等** | 五个端点**都要求 `Idempotency-Key`**,且幂等范围包含被折叠的路径参数(同一把键用在两张不同工单上算两次操作) | +| **审计** | 每次流转写一条 `interaction_audit`:`handover.assigned` / `handover.accepted` / `handover.resolved` / `handover.closed` / `handover.cancelled`,`detail` 里带 `ticket_no`、`from_status`、`to_status` | +| **并发** | 动作先 `SELECT ... FOR UPDATE` 锁工单再判状态:两个人同时点,后到的会看到已变化的状态并拿到 409 | +| **非法流转** | **409**(平台唯一的 409 码是 `RUN_NOT_CANCELLABLE`,真正原因在 `message` 里,例如"当前状态为closed,只有 pending 的工单可以分配") | +| **接单人不存在** | **422** `AGENT_INPUT_INVALID`(先查 `sys_user`,不让数据库外键以 500 冒出) | +| **响应字段** | `ticket_no`、`status`、`priority`、`assigned_to`、`assigned_at`、`accepted_at`、`resolved_at`、`closed_at`、`resolution`、`updated_at`;**不含 `customer_id`/`session_id`** | +| **两个刻意口径** | ① `accept` 允许从 `pending` 直接接管(同时把 `assigned_to` 记成接单人);② `cancel` **不写 `closed_at`**(该列属 `closed` 状态),取消原因写进 `resolution`("已取消:…") | + +列表端点新增 `?status=`(六态之一,**与数据库 CHECK 逐字对齐**;传别的值直接 **422**, +不会漏到数据库变成 500)。 + +--- + ### 11.5 RBAC 只读查询(A035–A038) `app/api/controllers/rbac.py`,前缀 `/api/v1/admin`。**四个端点全部只读,且不写审计。** diff --git a/tests/integration/test_customer_service_handover_actions_mysql.py b/tests/integration/test_customer_service_handover_actions_mysql.py new file mode 100644 index 0000000..00b0a27 --- /dev/null +++ b/tests/integration/test_customer_service_handover_actions_mysql.py @@ -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))) diff --git a/tests/unit/service/test_customer_service_handover_actions.py b/tests/unit/service/test_customer_service_handover_actions.py new file mode 100644 index 0000000..8c840c9 --- /dev/null +++ b/tests/unit/service/test_customer_service_handover_actions.py @@ -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" diff --git a/tests/unit/service/test_customer_service_handover_admin_service.py b/tests/unit/service/test_customer_service_handover_admin_service.py index 5222ff6..24a71cb 100644 --- a/tests/unit/service/test_customer_service_handover_admin_service.py +++ b/tests/unit/service/test_customer_service_handover_admin_service.py @@ -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 diff --git a/tools/e2e_smoke_test.py b/tools/e2e_smoke_test.py index 475ddf8..06a009d 100644 --- a/tools/e2e_smoke_test.py +++ b/tools/e2e_smoke_test.py @@ -66,6 +66,8 @@ DEMO_OPERATOR = ("offsite_t", "offsite123") DEMO_ADMIN = ("admin_t", "88888888") RESULTS: list[tuple[str, bool, str]] = [] +#: 跨段传递的小状态(目前只有 B 段建的工单号,交给 F 段处置收尾)。 +CONTEXT: dict[str, str] = {} READ_ONLY = False @@ -303,6 +305,14 @@ def section_customer() -> None: idem=True) check("B12 转人工 C005", status in (200, 201, 202) and ok_code(payload), f"HTTP={status}") + # 记下这次建的工单,交给 F 段(那里已经拿着管理员令牌)走完整处置闭环。 + # + # 为什么必须收尾:本脚本每跑一次就建一张 `pending` 工单,此前平台**只有只读队列、 + # 没有任何入口能推进它**,于是演示库里攒了 30+ 张一模一样的"端到端冒烟"测试件, + # 管理员打开转人工工单页看到全是它。现在有了处置端点(A049-A053),F11 会把这张 + # 单子走完 分配→接单→解决→关闭:既清掉测试件,也让每次冒烟都覆盖一遍状态机。 + CONTEXT["handover_id"] = str(body_of(payload).get("handover_id") or "") + info("B12b 待 F11 处置", CONTEXT["handover_id"] or "(未拿到 handover_id)") # ---------------------------------------------------------------- C 风控 @@ -410,6 +420,30 @@ def section_advisor_operator_admin() -> None: else f"dict[{len(data)}]" if isinstance(data, dict) else "?") check(label, status == 200 and ok_code(payload), f"HTTP={status} {shape}") + # F11 用 B 段那张测试工单走完整处置闭环:分配 → 接单 → 解决 → 关闭。 + # + # 这一步有两个作用:① 收尾,不把冒烟测试件留在 pending 队列里; + # ② 每次冒烟都真机覆盖一遍工单状态机(`docs/02` §7.2)与它的权限、审计。 + ticket_no = CONTEXT.get("handover_id", "") + if READ_ONLY: + info("F11 工单处置闭环", "跳过(--read-only)") + return + if not ticket_no: + info("F11 工单处置闭环", "跳过(B12 没拿到 handover_id)") + return + steps = ( + ("A049 分配", "assignments", {"assignee_id": 9003}, "assigned"), + ("A050 接单", "acceptances", {}, "processing"), + ("A051 解决", "resolutions", {"resolution": "端到端冒烟:已回访并给出结论"}, "resolved"), + ("A052 关闭", "closures", {"note": "冒烟收尾"}, "closed"), + ) + base = f"/api/v1/admin/customer-service/handover-tickets/{ticket_no}" + for label, action, body, expected in steps: + status, payload = call("POST", f"{base}/{action}", admin, body, idem=True) + actual = body_of(payload).get("status") + check(f"F11 {label}", status == 200 and actual == expected, + f"HTTP={status} status={actual}(期望 {expected})") + def main() -> int: global READ_ONLY # noqa: PLW0603 - 单进程脚本,命令行开关就设一次 diff --git a/tools/grant_handover_write_permission.py b/tools/grant_handover_write_permission.py new file mode 100644 index 0000000..bba92ae --- /dev/null +++ b/tools/grant_handover_write_permission.py @@ -0,0 +1,142 @@ +"""幂等补齐 `handover:write`(客服转人工工单处置)权限并授权给 admin。 + +## 为什么需要它 + +`docs/02-数据库建表设计.md` §7.2 定义了工单状态机 +(`pending -> assigned -> processing -> resolved -> closed`,未解决可 `cancelled`), +但平台此前**只有** `handover:read`(9046,只读队列)—— 于是工单只能看、不能推进, +库里 40 张单子全部停在 `pending`。处置端点与状态机见 +`app/service/customer_service_handover_action_service.py` 与 +`app/api/controllers/admin.py` 的五个 action 端点。 + +权限码 **9069 已并进种子** `tools/seed_test_rbac.py`(那是定义源), +本脚本只做"幂等补齐 + 授权",不重建任何东西;id 必须与种子逐条一致 +(一致性由 `tools/check_rbac_seed_consistency.py` 守着)。 + +用法:: + + python tools/grant_handover_write_permission.py # 只打印要做什么 + python tools/grant_handover_write_permission.py --apply # 真写 +""" + +from __future__ import annotations + +import argparse +import asyncio +import sys +from datetime import UTC, datetime + +from sqlalchemy import text + +from app.infrastructure.db import SessionFactory + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr] + +#: (id, permission_code, resource, action, data_scope) —— 与种子里那一行逐字一致。 +PERMISSION: tuple[int, str, str, str, str] = ( + 9069, "handover:write", "handover", "write", "all", +) + +#: 授权给哪些角色:工单队列是**管理面**(读侧也是 admin),所以只给 admin。 +#: `ADMIN_PERMISSIONS` 在种子里是全量元组,重跑种子也会自动带上这一条。 +GRANTED_ROLES: tuple[str, ...] = ("admin",) + + +async def apply(*, dry_run: bool) -> int: + now = datetime.now(UTC).replace(tzinfo=None) + perm_id, code, resource, action, scope = PERMISSION + async with SessionFactory() as session, session.begin(): + found = await session.scalar( + text("SELECT id FROM sys_permission WHERE permission_code = :code"), {"code": code} + ) + print(f"权限 {code}: {'已存在(跳过插入)' if found else '缺失,将新增 id=' + str(perm_id)}") + if dry_run: + role = await session.scalar( + text("SELECT id FROM sys_role WHERE role_code = 'admin'") + ) + print(f"角色 admin: {'存在' if role else '缺失(请先跑 seed_test_rbac.py)'}") + print("\n[dry-run] 未写入任何数据。加 --apply 真写。") + return 0 + + if found is None: + await session.execute( + text( + """ + INSERT INTO sys_permission + (id, permission_code, resource, action, data_scope, created_at, updated_at) + VALUES (:id, :code, :resource, :action, :scope, :now, :now) + """ + ), + {"id": perm_id, "code": code, "resource": resource, "action": action, + "scope": scope, "now": now}, + ) + resolved_id = perm_id + else: + resolved_id = int(found) + + for role_code in GRANTED_ROLES: + role_id = await session.scalar( + text("SELECT id FROM sys_role WHERE role_code = :code"), {"code": role_code} + ) + if role_id is None: + print(f"跳过角色 {role_code}:不存在") + continue + have = await session.scalar( + text( + "SELECT 1 FROM sys_role_permission WHERE role_id = :r AND permission_id = :p" + ), + {"r": int(role_id), "p": resolved_id}, + ) + if have: + print(f"授权:{role_code} 已拥有 {code}(跳过)") + continue + await session.execute( + text( + "INSERT INTO sys_role_permission (role_id, permission_id, created_at)" + " VALUES (:r, :p, :now)" + ), + {"r": int(role_id), "p": resolved_id, "now": now}, + ) + print(f"授权:{role_code} += {code}") + + return await verify() + + +async def verify() -> int: + """按权限码实测一遍,而不是只看插了几行。""" + async with SessionFactory() as session: + rows = ( + await session.execute( + text( + """ + SELECT p.permission_code, p.data_scope, r.role_code + FROM sys_permission p + LEFT JOIN sys_role_permission rp ON rp.permission_id = p.id + LEFT JOIN sys_role r ON r.id = rp.role_id + WHERE p.permission_code = 'handover:write' + ORDER BY r.role_code + """ + ) + ) + ).all() + if not rows: + print("校验失败:库里查不到 handover:write") + return 1 + for code, scope, role_code in rows: + print(f"校验:{code} scope={scope} → 角色 {role_code or '(未授权任何角色)'}") + if not any(role_code for _code, _scope, role_code in rows): + print("校验失败:handover:write 存在但没有授权给任何角色(处置端点会一律 403)") + return 1 + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="补齐并授权 handover:write") + parser.add_argument("--apply", action="store_true", help="真正写入(默认只打印)") + args = parser.parse_args() + return asyncio.run(apply(dry_run=not args.apply)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/seed_test_rbac.py b/tools/seed_test_rbac.py index e3070a9..53f17a9 100644 --- a/tools/seed_test_rbac.py +++ b/tools/seed_test_rbac.py @@ -174,6 +174,12 @@ PERMISSIONS: tuple[tuple[int, str, str, str, str], ...] = ( "own_customers"), (9068, "portfolio-analysis:read:customer", "portfolio-analysis", "read", "own_customers"), + # ---- 9069:客服转人工工单**处置**(分配/接单/解决/关闭/取消) ---- + # 原先只有 9046 `handover:read`(只读队列),于是"工单只能看、不能推进": + # 库里 40 张单子全部停在 pending(`docs/02` §7.2 的状态机一个动作也没有入口)。 + # 状态机与审计见 `app/service/customer_service_handover_action_service.py`。 + # data_scope 取 `all`:工单队列本身就是全平台视图,不按归属客户切。 + (9069, "handover:write", "handover", "write", "all"), ) # 客户:业务侧自助能力(自己的会话、反馈、转人工、自己的记忆画像)。