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()` 跑两遍,页面上出现**两份顶部导航与页脚**。
改版本号时是**替换**这一行,不是新增一行。 -->
-
+