Files
group_fqcd_jr/app/api/schemas/admin.py
T
lzf_0626 076d786bc6 补齐客服转人工工单流:从"只能看"到"能推进"(基线状态机,不自行发明)
## 问题
`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 项)
2026-09-15 00:41:59 +08:00

163 lines
6.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, HttpUrl
class StrictPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
class ReleasePayload(StrictPayload):
release_no: str = Field(min_length=1, max_length=64)
title: str = Field(min_length=1, max_length=128)
change_summary: str = Field(min_length=1, max_length=1000)
class ItemPayload(StrictPayload):
# `fund_market` 必须在列内:`admin_service` 有专门的 `FUND_MARKET_FIELDS` 校验与
# `namespace=fund_market / config_key=default` 读取路径,缺了它会让行情配置
# 走 API 一律 422、只能靠工具内默认值运行(曾因此被回退过一次)。
namespace: Literal["agent_tools", "memory", "relationship", "runtime", "fund_market"]
item_key: str = Field(min_length=1, max_length=128)
value_json: dict[str, Any]
schema_version: Literal["1"] = "1"
class EndpointPayload(StrictPayload):
endpoint_code: str = Field(min_length=1, max_length=64)
provider: str = Field(min_length=1, max_length=32)
model_name: str = Field(min_length=1, max_length=128)
base_url: HttpUrl
secret_ref: str = Field(pattern=r"^env:[A-Z][A-Z0-9_]{0,100}$")
capabilities: list[str] = Field(min_length=1, max_length=20)
allowed_data_levels: list[str] = Field(min_length=1, max_length=20)
context_window: int = Field(gt=0)
timeout_ms: int = Field(default=15000, ge=100, le=120000)
class FallbackPayload(StrictPayload):
endpoint_id: int = Field(gt=0)
fallback_order: int = Field(ge=1, le=2)
retryable_error_codes: list[str] = Field(default_factory=list)
class RoutingPayload(StrictPayload):
rule_code: str = Field(min_length=1, max_length=64)
agent_type: str = Field(min_length=2, max_length=32)
task_type: str = Field(min_length=1, max_length=48)
model_policy: str = Field(min_length=1, max_length=32)
primary_endpoint_id: int = Field(gt=0)
fallbacks: list[FallbackPayload] = Field(default_factory=list, max_length=2)
max_attempts: int = Field(default=2, ge=1, le=3)
latency_budget_ms: int = Field(default=15000, ge=100, le=120000)
priority: int = Field(default=100, ge=0)
class PromptPayload(StrictPayload):
release_id: int = Field(gt=0)
prompt_code: str = Field(min_length=1, max_length=64)
task_type: str = Field(min_length=1, max_length=48)
agent_type: str | None = Field(default=None, max_length=32)
version: int = Field(ge=1)
system_prompt: str = Field(min_length=1, max_length=32000)
user_prompt_template: str = Field(min_length=1, max_length=32000)
input_schema: dict[str, Any] | None = None
output_schema: dict[str, Any] | None = None
class IntentPayload(StrictPayload):
agent_type: str = Field(min_length=2, max_length=32)
intent_code: str = Field(min_length=1, max_length=64)
intent_name: str = Field(min_length=1, max_length=128)
description: str | None = Field(default=None, max_length=500)
examples: list[str]
classifier_instruction: str | None = None
confidence_threshold: str = Field(default="0.6000", pattern=r"^(0(\.\d{1,4})?|1(\.0{1,4})?)$")
max_clarification_rounds: int = Field(default=2, ge=0, le=10)
transfer_on_failure: bool = True
allowed_tools: list[str] = Field(default_factory=list)
priority: int = Field(default=100, ge=0)
version: int = Field(default=1, ge=1)
#: `agent_reply_template.chk_template_scene` 允许的取值。**必须逐字对齐**:
#: 此前 `ReplyPayload.scene` 只校验长度(`min_length=1, max_length=32`),
#: 于是传一个不在列内的场景会**穿过接口校验、撞上数据库 CHECK 约束**,
#: 最终以 `500` 冒出(`(3819, "Check constraint 'chk_template_scene' is violated.")`)——
#: 那本该是一次 `422` 参数校验失败。2026-09-13 由接口逐条测试发现。
ReplyScene = Literal[
"disclaimer",
"low_confidence",
"compliance_block",
"transfer",
"model_failure",
"system_busy",
"clarification",
]
class ReplyPayload(StrictPayload):
template_code: str = Field(min_length=1, max_length=64)
scene: ReplyScene
title: str = Field(min_length=1, max_length=128)
content_text: str = Field(min_length=1, max_length=32000)
variables: list[str] = Field(default_factory=list)
locale: str = Field(default="zh-CN", max_length=16)
version: int = Field(default=1, ge=1)
class NegativePayload(StrictPayload):
rule_code: str = Field(min_length=1, max_length=64)
word_pattern: str = Field(min_length=1, max_length=256)
match_type: Literal["contains", "exact"] = "contains"
category: str = Field(min_length=1, max_length=32)
severity: Literal["block", "replace", "warn"] = "block"
applicable_agents: list[str] | None = None
safe_reply_template_code: str | None = Field(default=None, max_length=64)
class ReviewPayload(StrictPayload):
decision: Literal["approved", "rejected"]
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