## 问题 `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 项)
163 lines
6.0 KiB
Python
163 lines
6.0 KiB
Python
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
|