补齐客服转人工工单流:从"只能看"到"能推进"(基线状态机,不自行发明)

## 问题
`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:
2026-09-15 00:41:59 +08:00
parent ed59e93b53
commit 076d786bc6
17 changed files with 1413 additions and 28 deletions
+152 -3
View File
@@ -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),
+37
View File
@@ -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
@@ -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)
@@ -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),
}
+8
View File
@@ -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 },
@@ -36,7 +36,7 @@
<section><div class="panel__header"><h2 class="panel__title">模型端点</h2><span class="section-heading__meta">密钥引用不会在前端暴露</span></div><div data-model-table></div></section>
</section>
<section class="operations-view admin-view" data-admin-view="audit" hidden><div class="panel__header"><h2 class="panel__title">最近审计记录</h2><button class="button table-action" type="button" data-reload-audit>刷新</button></div><div data-audit-table></div></section>
<section class="operations-view admin-view" data-admin-view="handover" hidden><div class="panel__header"><h2 class="panel__title">客服转人工工单</h2><span class="section-heading__meta">仅展示二次脱敏摘要</span></div><div data-handover-table></div></section>
<section class="operations-view admin-view" data-admin-view="handover" hidden><div class="panel__header"><h2 class="panel__title">客服转人工工单</h2><span class="section-heading__meta">仅展示二次脱敏摘要;处置动作按状态给出</span><div class="admin-release-actions"><label class="form-field"><span class="form-field__label">状态</span><select class="form-field__input" data-handover-status><option value="">全部</option><option value="pending">待处理</option><option value="assigned">已分配</option><option value="processing">处理中</option><option value="resolved">已解决</option><option value="closed">已关闭</option><option value="cancelled">已取消</option></select></label><button class="button table-action" type="button" data-reload-handover>刷新</button></div></div><div data-handover-table></div></section>
<section class="operations-view admin-view" data-admin-view="candidates" hidden><div class="panel__header"><h2 class="panel__title">客户画像候选</h2><span class="section-heading__meta">审核后方可进入正式记忆</span></div><div data-candidate-table></div></section>
<section class="operations-view admin-view" data-admin-view="advisor" hidden><div class="panel__header"><h2 class="panel__title">待审投顾内容</h2><span class="section-heading__meta">推荐方案与投资方案书;审核通过后再发布</span><button class="button table-action" type="button" data-reload-advisor>刷新</button></div><div data-advisor-table></div></section>
<section class="operations-view admin-view" data-admin-view="knowledge" hidden><div class="panel__header"><h2 class="panel__title">知识库文档</h2><span class="section-heading__meta">上传后自动切分入库并投向量同步;客服据此作答</span><button class="button table-action" type="button" data-reload-knowledge>刷新</button></div><form class="admin-inline-form" data-knowledge-form><label class="form-field"><span class="form-field__label">文档文件</span><input class="form-field__input" type="file" name="file" accept=".txt,.md,.docx" required></label><label class="form-field"><span class="form-field__label">知识类型</span><select class="form-field__input" name="knowledge_type"><option value="faq">faq(问答)</option><option value="product" selected>product(产品资料)</option><option value="policy">policy(制度规则)</option></select></label><button class="button button--primary" type="submit">上传并入库</button><span class="admin-inline-form__status" data-knowledge-status></span></form><div data-knowledge-table></div></section>
@@ -50,6 +50,6 @@
浏览器按**完整 URL** 去重,两条不同 query 会被当成两个模块、**各执行一次**,
于是入口里的 `mountShell()` 跑两遍,页面上出现**两份顶部导航与页脚**。
改版本号时是**替换**这一行,不是新增一行。 -->
<script type="module" src="/static/portal/employee-console/workspace/workspace.js?v=20260914-3"></script>
<script type="module" src="/static/portal/employee-console/workspace/workspace.js?v=20260914-4"></script>
</body>
</html>
@@ -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 `<div class="admin-release-actions"><button class="button table-action" type="button" data-ticket="${escapeHtml(item.ticket_no)}">摘要</button>${buttons
.map(([action, label, extra]) => `<button class="button table-action ${extra || ''}" type="button" data-handover-action="${action}" data-handover-ticket="${escapeHtml(item.ticket_no)}">${label}</button>`)
.join('')}</div>`;
}
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) => `<button class="button table-action" type="button" data-ticket="${escapeHtml(item.ticket_no)}">查看摘要</button>`);
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}`, `<dl class="detail-grid"><div><dt>状态</dt><dd>${escapeHtml(item.status)}</dd></div><div><dt>优先级</dt><dd>${escapeHtml(item.priority)}</dd></div><div><dt>识别意图</dt><dd>${escapeHtml(item.intent || '--')}</dd></div><div><dt>置信度</dt><dd>${escapeHtml(value(item.confidence))}</dd></div></dl><section><h3 class="section-heading__title">转接原因</h3><p class="admin-detail-copy">${escapeHtml(item.reason_detail || '--')}</p></section><section><h3 class="section-heading__title">脱敏会话摘要</h3><p class="admin-detail-copy">${escapeHtml(item.conversation_summary || '--')}</p></section>`);
const row = (label, value) => `<div><dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value ?? '--')}</dd></div>`;
showDetail(`工单 ${item.ticket_no}`, `<dl class="detail-grid">${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)}</dl><section><h3 class="section-heading__title">转接原因</h3><p class="admin-detail-copy">${escapeHtml(item.reason_detail || '--')}</p></section><section><h3 class="section-heading__title">脱敏会话摘要</h3><p class="admin-detail-copy">${escapeHtml(item.conversation_summary || '--')}</p></section><section><h3 class="section-heading__title">处置结论</h3><p class="admin-detail-copy">${escapeHtml(item.resolution || '(尚未填写)')}</p></section>`);
} catch (error) { showDetail('转人工工单', `<div class="form-alert form-alert--visible">${escapeHtml(error.message)}</div>`); }
}
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);