Files
group_fqcd_jr/tests/integration/test_handover_outbox_mysql.py
T
lzf_0626 6516ccb385 feat: 第二版——接口契约对齐 docs/05,修复静默故障与数据库基线
相对第一版 46fc976 的完整变更。组员迁移对照表见 docs/20。

一、对外契约对齐 docs/05(破坏性,共 4 处,组员需按 docs/20 调整)
1) 配置发布端点改为文档规定的复数资源名:submit→validations、
   approve→reviews(需 body decision)、activate→activations、
   rollback→rollbacks;第一版这 4 个动词式路径 docs/05 从未定义过。
2) 错误码由 8 个笼统码改为 15 个具体语义码(FORBIDDEN→AGENT_PERMISSION_DENIED、
   UNAUTHORIZED→AUTHENTICATION_REQUIRED、CONFLICT→RESOURCE_VERSION_CONFLICT、
   RESOURCE_NOT_FOUND→RUN_NOT_FOUND/SESSION_NOT_FOUND 等),
   输入类错误状态码 400→422。
3) POST /api/v1/agent-runs 与 GET /api/v1/agent-runs/{run_id} 统一为
   {data, meta} 信封(data 内字段名与语义未变)。
4) 错误响应体统一为 {error:{code,message,retryable,field_errors}, meta:{trace_id}},
   不再返回 FastAPI 默认的 {"detail": ...}。

二、数据库基线与约束
新增 39 张表的基线迁移(链根)与联合唯一键纠偏(4 张表、删 8 增 4,幂等收敛);
撤下 config_release 的双人复核 CHECK(应用层已允许自审,审核节点保留,
自审如实写入 reviewer_id);记忆 active key 生成列与唯一键;
activate 开始记录 supersedes_release_id 使版本链可追溯。
docs/00 基线未修改,未重命名或删除任何表与字段。

三、修复会静默出错或无报错的缺陷
- 跑完集成测试后平台会静默失去生效配置:清理只删自己创建的版本,却没有恢复被它
  顶成 superseded 的原生效版本,且审计一并删除因而完全无痕,表现为所有工具被拒
  但没有任何报错。已修清理逻辑并加恢复。
- Worker 单轮异常导致进程退出;记忆抽取调用方的“事务已开始”异常;
  召回缓存丢失 degraded 标记;连接时区未生效导致 created_at/updated_at 差 8 小时;
  .env 与 os.getenv 密钥来源分裂导致“没有可用的已批准模型端点”。
- 记忆信号识别漏判与跨键误命中;SSE 未带 Accept 的协商行为。

四、功能补齐
记忆链路 P1/P2/P3(抽取、受控词表、召回与缓存、生命周期级联及投影事件)、
fin_* 场内交易只读 ORM 层、agent_intent_config 状态流转并在运行期真正生效、
限流(Redis 固定窗口、故障一律放行)、游标校验、trace_id 中间件、
示例业务 Agent fund_query_demo 与一键端到端验证脚本,以及审计/指纹/迁移状态工具。

五、文档与验证
新增 docs/19(业务 Agent 接入实操)、docs/20(第一版迁移指南)与 docs/evidence 证据;
docs/01/02/06/08/09/17 同步实现现状。

验证结果:ruff 通过、mypy 103 文件无错、unit+contract 447 passed、
integration 29 passed、acceptance_check --production 7 PASS、
demo_agent_e2e 9/9 PASS(含失败关闭反证)。
2026-09-10 15:55:54 +08:00

187 lines
7.5 KiB
Python

"""C1 回归:转人工申请只能有一条路由,且必须真正写入 Outbox 事件。
历史上 `conversations.py` 与 `public_platform.py` 各注册了一份
`POST /api/v1/conversations/{session_id}/handover-requests`,前者先注册生效且
只写审计,导致转人工的异步链路(Outbox → Worker)永不触发。本文件用真实 HTTP
请求 + 真实 MySQL 断言唯一路由与事件落库。
"""
import asyncio
from collections.abc import Iterator
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, text
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.conversation import ConversationMessage
from app.model.platform import DomainEventOutbox, HandoverTicket
from app.model.session import ConversationSession
HANDOVER_PATH = "/api/v1/conversations/{session_id}/handover-requests"
TEST_USER_ID = 9001 # 与 tools/seed_test_rbac.py 的测试客户号段一致
def _flatten(routes: Any) -> Iterator[Any]:
"""展开 include_router 的结果。
当前 FastAPI 版本把 include_router 的记录保留为 `_IncludedRouter` 而不摊平
到 `app.routes`,查重必须先递归展开到叶子路由。
"""
for route in routes:
inner = getattr(route, "original_router", None)
if inner is not None:
yield from _flatten(inner.routes)
else:
yield route
def test_handover_route_is_registered_exactly_once_by_writing_controller() -> None:
all_routes = list(_flatten(app.routes))
routes = [
route
for route in all_routes
if getattr(route, "path", None) == HANDOVER_PATH
and "POST" in (getattr(route, "methods", None) or set())
]
assert len(routes) == 1, [getattr(route, "path", None) for route in all_routes]
# 唯一入口必须落在写 Outbox 的那份实现上。
assert routes[0].endpoint.__module__ == "app.api.controllers.public_platform"
seen: list[tuple[str, str]] = [
(method, getattr(route, "path", ""))
for route in all_routes
for method in (getattr(route, "methods", None) or set())
]
duplicates = sorted({item for item in seen if seen.count(item) > 1})
assert duplicates == [], f"存在重复注册的路由: {duplicates}"
@pytest.mark.integration
def test_handover_request_writes_ticket_event_and_audit() -> None:
session_id = f"it-handover-{uuid4().hex}"
key = f"it-handover-key-{uuid4().hex}"
handover_id = ""
async def prepare() -> None:
async with SessionFactory() as db:
db.add(
ConversationSession(
session_id=session_id, user_id=TEST_USER_ID, portal="api",
agent_type="customer_service", status="active",
clarification_round=0, message_count=1,
)
)
await db.flush()
db.add(
ConversationMessage(
session_id=session_id, message_no=1, customer_id=TEST_USER_ID,
portal="api", role="user", content="我要转人工",
created_at=datetime.now(UTC).replace(tzinfo=None),
)
)
await db.commit()
async def context() -> RequestContext:
return RequestContext(
user_id=str(TEST_USER_ID), trace_id=str(uuid4()),
roles=("customer",), permissions=("handover:create",),
)
app.dependency_overrides[build_request_context] = context
asyncio.run(prepare())
try:
with TestClient(app) as client:
response = client.post(
HANDOVER_PATH.format(session_id=session_id),
json={"reason_code": "user_requested", "reason_detail": "集成测试转人工"},
headers={"Idempotency-Key": key},
)
assert response.status_code == 202, response.text
body = response.json()
handover_id = body["data"]["handover_id"]
assert handover_id.startswith("ticket-")
assert body["data"]["session_id"] == session_id
assert body["data"]["status"] == "pending"
async def verify() -> tuple[HandoverTicket | None, DomainEventOutbox | None,
list[InteractionAudit], list[InteractionAudit]]:
async with SessionFactory() as db:
ticket = await db.scalar(
select(HandoverTicket).where(HandoverTicket.ticket_no == handover_id)
)
event = await db.scalar(
select(DomainEventOutbox).where(
DomainEventOutbox.event_type == "conversation.transfer_requested",
DomainEventOutbox.aggregate_id == session_id,
)
)
audits = list(
await db.scalars(
select(InteractionAudit).where(
InteractionAudit.action_type == "platform.handover",
InteractionAudit.session_id == session_id,
)
)
)
legacy = list(
await db.scalars(
select(InteractionAudit).where(
InteractionAudit.action_type == "conversation.transfer_requested",
InteractionAudit.session_id == session_id,
)
)
)
return ticket, event, audits, legacy
ticket, event, audits, legacy = asyncio.run(verify())
assert ticket is not None and ticket.status == "pending"
assert ticket.source_message_id is not None # 取自会话首条消息
assert event is not None, "转人工必须写 Outbox 事件"
assert event.aggregate_type == "conversation"
assert event.status == "pending"
assert event.payload["ticket_no"] == handover_id
assert len(audits) == 1
assert legacy == [], "旧实现(只写审计、不发事件)不得再被调用"
finally:
async def cleanup() -> None:
async with SessionFactory() as db:
await db.execute(
delete(HandoverTicket).where(HandoverTicket.session_id == session_id)
)
await db.execute(
delete(DomainEventOutbox).where(
DomainEventOutbox.aggregate_id == session_id
)
)
await db.execute(
delete(InteractionAudit).where(
InteractionAudit.session_id == session_id
)
)
await db.execute(
delete(ConversationMessage).where(
ConversationMessage.session_id == session_id
)
)
await db.execute(
delete(ConversationSession).where(
ConversationSession.session_id == session_id
)
)
await db.execute(
text("DELETE FROM api_request_receipt WHERE idempotency_key = :key"),
{"key": key},
)
await db.commit()
asyncio.run(cleanup())
app.dependency_overrides.clear()