feat(v2): S1 接口层——材料导入与事实冲突的 8 个端点

T02 第三个子卡。命名空间按 T00/04 §1 定案 P1 挂 project-assistant 之下,
复用现有鉴权与同源代理,鉴权/代理代码零改动。

新增 backend/api/prescreen.py(7 路径 / 8 操作)+ test_prescreen_api.py(27 项)。
app.py 仅新增 1 个 include_router 与 1 个异常处理器注册。

落实:
- 404 统一口径(03 §2):不存在与无访问权一律 404,不泄露对象存在性。
  既有 check_project 抛 403 且被 5 个模块共用,故不改它,由本模块 scope() 包装。
  实测:无权项目返回 404 NOT_FOUND 而非 403。
- 错误信封(D2):只在新路由输出 {error:{code,message,retryable,request_id}}。
- E-09 与 E-05 精确区分:新增 store.IdempotencyConflict,不再靠字符串比对猜码。
- 客户端清单只接受 4 个字段;id/status/received_at 由服务端生成——
  状态机写权不下放。manifest 校验因此从"整对象"改为"4 字段 + 服务端补全"。

**实测发现的缺口并已修复**:Input(extra="forbid") 的校验错误由 FastAPI 校验层在
进入端点前抛出,不经过 run(),导致同一命名空间出现两种错误形状。现注册
RequestValidationError 处理器,但**只在 prescreen 路径生效**,其它路径交回
FastAPI 默认实现——既有端点形状经实测确认未变(含校验错误的形状)。

验证:
- scripts/verify.py 9/9 PASS(exit 0);全量单测 225 项 OK(196 → 225)
- claude_use/T02/smoke_s1.py 在真实 app.py 上 10/10 PASS:覆盖路由注册、
  真实前缀下的 manager 鉴权放行、404 统一、信封形状、以及既有端点未被污染

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-29 16:45:07 +08:00
co-authored by Claude Code
parent c59fff68d3
commit fcf4d8c948
6 changed files with 695 additions and 5 deletions
+324
View File
@@ -0,0 +1,324 @@
"""S1 接口层测试:材料集合导入与事实确认的 HTTP 契约。
只挂 prescreen 路由到独立 app,数据库指向临时文件——不触碰真实库。
"""
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
from fastapi.testclient import TestClient
from api import prescreen as api
from services import assistant_store as store
from services import fact_conflict
from services.project_assistant import ProjectAssistantService
HEADERS = {"X-Xingbang-Demo-Role": "manager"}
# 演示身份可访问的项目:DEMO-A/B/C、LAB-001、XB-2026-001
PROJECT = "DEMO-B"
FOREIGN_PROJECT = "OTHER-9" # 不在演示身份的项目列表里
def manifest(name="a.csv", sha="a" * 64, size=100):
return {"relative_path": name, "sha256": sha, "size_bytes": size, "mime_type": "text/csv"}
class PrescreenApiTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.path = Path(self.temp.name)
self.service = ProjectAssistantService(self.path / "assistant.sqlite3")
self.patches = [
patch.object(api, "_service", lambda: self.service),
patch.dict(os.environ, {"XINGBANG_AUTH_USERS": "", "XINGBANG_REQUIRE_AUTH": "0",
"XINGBANG_LOCAL_MODEL_URL": ""}),
]
for p in self.patches:
p.start()
app = FastAPI()
# 必须与 app.py 的注册保持一致:否则本测试的 app 不忠实于生产接线,
# 校验错误会走 FastAPI 默认形状而测不到信封。
app.add_exception_handler(RequestValidationError, api.validation_handler)
app.include_router(api.router)
self.client = TestClient(app)
self.base = "/api/xingbang/risk-agent/project-assistant/prescreen"
def tearDown(self):
self.client.close()
for p in reversed(self.patches):
p.stop()
self.temp.cleanup()
# ------------------------------------------------------------ 工具
def post(self, path, body=None, headers=None):
return self.client.post(self.base + path, json=body if body is not None else {},
headers={**HEADERS, **(headers or {})})
def get(self, path, headers=None):
return self.client.get(self.base + path, headers={**HEADERS, **(headers or {})})
def error(self, response):
body = response.json()
self.assertIn("error", body, body)
return body["error"]
def seed_conflict(self):
"""直接经服务层造一个未解决冲突(本文件测的是 HTTP 层,不重复测存储层)。
注意:演示种子项目 DEMO-B 可能**已有**已确认的 facility.amount,
所以不能假设"首次确认一定无冲突"。这里按现状自适应。
传 unit=None 使候选值不做万元→元换算,与已确认值同口径可比。
"""
def cand(cid, value, source, locator):
return {"id": cid, "field": "facility.amount", "value": value,
"unit": None, "source": source, "locator": locator}
with self.service.connect() as db:
db.execute("BEGIN IMMEDIATE")
project = store.get(db, "project", PROJECT)
existing = project.get("values", {}).get("facility.amount")
if existing is None:
payload = fact_conflict.detect(project, cand("c0", "1000", "a.csv", "第2行"),
"demo-manager", "初始确认")
store.put(db, "project", project)
self.assertIsNone(payload, "首次确认不应产生冲突")
project = store.get(db, "project", PROJECT)
existing = project["values"]["facility.amount"]
different = next(v for v in ("777", "888", "999") if str(v) != str(existing))
payload = fact_conflict.detect(project, cand("c1", different, "b.csv", "第9行"),
"demo-manager", "第二来源")
store.put(db, "project", project)
self.assertIsNotNone(payload, "与已确认值不同,应产生冲突")
return fact_conflict.create(db, PROJECT, payload, "demo-manager")
# ------------------------------------------------------------ 导入批次
def test_create_batch_returns_202_queued(self):
response = self.post(f"/projects/{PROJECT}/import-batches", {"manifest": [manifest()]})
self.assertEqual(response.status_code, 202, response.text)
batch = response.json()["batch"]
self.assertEqual(batch["status"], "queued")
self.assertEqual(batch["data_domain"], "demo")
self.assertEqual(batch["revision"], 1)
self.assertTrue(batch["items"][0]["id"].startswith("item-"))
self.assertEqual(batch["items"][0]["status"], "queued")
self.assertIsNone(batch["items"][0]["observed_at"])
def test_client_supplied_server_fields_rejected(self):
bad = dict(manifest(), status="parsed")
response = self.post(f"/projects/{PROJECT}/import-batches", {"manifest": [bad]})
self.assertEqual(response.status_code, 422, response.text)
self.assertEqual(self.error(response)["code"], "IMPORT_BATCH_INVALID")
def test_absolute_path_rejected_with_envelope(self):
response = self.post(f"/projects/{PROJECT}/import-batches",
{"manifest": [manifest(name="C:/x.csv")]})
self.assertEqual(response.status_code, 422)
error = self.error(response)
self.assertEqual(error["code"], "IMPORT_BATCH_INVALID")
self.assertFalse(error["retryable"])
self.assertTrue(error["request_id"])
def test_unknown_body_field_rejected_with_envelope(self):
"""校验层在进入端点前抛错,也必须走统一信封(否则同命名空间两种形状)。"""
response = self.post(f"/projects/{PROJECT}/import-batches",
{"manifest": [manifest()], "data_domain": "production"})
self.assertEqual(response.status_code, 422, response.text)
error = self.error(response)
self.assertEqual(error["code"], "INVALID_REQUEST")
self.assertTrue(error["request_id"])
def test_invalid_query_param_rejected_with_envelope(self):
response = self.get(f"/projects/{PROJECT}/import-batches?limit=notanumber")
self.assertEqual(response.status_code, 422, response.text)
self.assertEqual(self.error(response)["code"], "INVALID_REQUEST")
def test_empty_manifest_rejected(self):
response = self.post(f"/projects/{PROJECT}/import-batches", {"manifest": []})
self.assertEqual(response.status_code, 422, response.text)
def test_foreign_project_is_404_not_403(self):
# 03 §2:不存在与无访问权统一 404,不泄露对象是否存在
response = self.post(f"/projects/{FOREIGN_PROJECT}/import-batches",
{"manifest": [manifest()]})
self.assertEqual(response.status_code, 404, response.text)
self.assertEqual(self.error(response)["code"], "NOT_FOUND")
def test_get_batch_by_id(self):
created = self.post(f"/projects/{PROJECT}/import-batches", {"manifest": [manifest()]}).json()["batch"]
response = self.get(f"/import-batches/{created['id']}")
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(response.json()["id"], created["id"])
def test_get_unknown_batch_is_404_envelope(self):
response = self.get("/import-batches/import-does-not-exist")
self.assertEqual(response.status_code, 404)
error = self.error(response)
self.assertEqual(error["code"], "NOT_FOUND")
# 文案不暴露对象是否存在
self.assertEqual(error["message"], "对象不可访问或不存在")
def test_idempotent_replay_returns_same_batch(self):
body = {"manifest": [manifest()]}
first = self.post(f"/projects/{PROJECT}/import-batches", body,
headers={"Idempotency-Key": "k1"}).json()["batch"]
again = self.post(f"/projects/{PROJECT}/import-batches", body,
headers={"Idempotency-Key": "k1"}).json()["batch"]
self.assertEqual(first["id"], again["id"])
def test_idempotency_conflict_is_409(self):
self.post(f"/projects/{PROJECT}/import-batches", {"manifest": [manifest()]},
headers={"Idempotency-Key": "k2"})
response = self.post(f"/projects/{PROJECT}/import-batches",
{"manifest": [manifest(sha="c" * 64)]},
headers={"Idempotency-Key": "k2"})
self.assertEqual(response.status_code, 409, response.text)
self.assertEqual(self.error(response)["code"], "IDEMPOTENCY_CONFLICT")
def test_overlong_idempotency_key_rejected(self):
response = self.post(f"/projects/{PROJECT}/import-batches", {"manifest": [manifest()]},
headers={"Idempotency-Key": "x" * 200})
self.assertEqual(response.status_code, 422, response.text)
def test_list_batches_for_project(self):
self.post(f"/projects/{PROJECT}/import-batches", {"manifest": [manifest()]})
response = self.get(f"/projects/{PROJECT}/import-batches")
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(len(response.json()["items"]), 1)
def test_retry_only_failed_items(self):
batch = self.post(f"/projects/{PROJECT}/import-batches",
{"manifest": [manifest(name="ok.csv"),
manifest(name="bad.csv", sha="b" * 64)]}).json()["batch"]
ids = [item["id"] for item in batch["items"]]
with self.service.connect() as db:
from services import material_import
material_import.mark_item(db, batch["id"], ids[0], "parsed", document_id="doc-1")
batch = material_import.mark_item(db, batch["id"], ids[1], "failed",
error_code="MATERIAL_PARSE_FAILED")
response = self.post(f"/import-batches/{batch['id']}/retry",
{"item_ids": [ids[1]], "expected_revision": batch["revision"]})
# 202 表示已接受重试;但该请求是同步执行的,直接看返回体
self.assertIn(response.status_code, (200, 202), response.text)
items = {item["id"]: item["status"] for item in response.json()["items"]}
self.assertEqual(items[ids[1]], "queued")
def test_retry_with_stale_revision_is_409(self):
batch = self.post(f"/projects/{PROJECT}/import-batches", {"manifest": [manifest()]}).json()["batch"]
with self.service.connect() as db:
from services import material_import
batch = material_import.mark_item(db, batch["id"], batch["items"][0]["id"], "failed",
error_code="MATERIAL_PARSE_FAILED")
response = self.post(f"/import-batches/{batch['id']}/retry",
{"item_ids": [batch["items"][0]["id"]], "expected_revision": 1})
self.assertEqual(response.status_code, 409, response.text)
self.assertEqual(self.error(response)["code"], "REVISION_CONFLICT")
def test_cancel_then_retry_refused(self):
batch = self.post(f"/projects/{PROJECT}/import-batches", {"manifest": [manifest()]}).json()["batch"]
cancelled = self.post(f"/import-batches/{batch['id']}/cancel",
{"expected_revision": batch["revision"]})
self.assertEqual(cancelled.status_code, 200, cancelled.text)
self.assertEqual(cancelled.json()["status"], "cancelled")
again = self.post(f"/import-batches/{batch['id']}/cancel",
{"expected_revision": cancelled.json()["revision"]})
self.assertEqual(again.status_code, 409, again.text)
# ------------------------------------------------------------ 事实冲突
def test_list_conflicts_and_filter(self):
conflict = self.seed_conflict()
response = self.get(f"/projects/{PROJECT}/fact-conflicts?status=unresolved")
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual([row["id"] for row in response.json()["items"]], [conflict["id"]])
empty = self.get(f"/projects/{PROJECT}/fact-conflicts?status=resolved")
self.assertEqual(empty.json()["items"], [])
def test_invalid_status_filter_rejected(self):
response = self.get(f"/projects/{PROJECT}/fact-conflicts?status=whatever")
self.assertEqual(response.status_code, 422, response.text)
self.assertEqual(self.error(response)["code"], "INVALID_REQUEST")
def test_get_conflict_by_id(self):
conflict = self.seed_conflict()
response = self.get(f"/fact-conflicts/{conflict['id']}")
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(response.json()["status"], "unresolved")
def test_resolve_happy_path_adopts_value_and_persists(self):
conflict = self.seed_conflict()
chosen = conflict["candidates"][1]["evidence_id"]
response = self.post(f"/fact-conflicts/{conflict['id']}/resolve",
{"selected_evidence_ids": [chosen],
"resolution_note": "以合同金额为准",
"expected_revision": conflict["revision"]})
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(response.json()["status"], "resolved")
# 值必须真的落到项目事实里(resolve 就地改 project,接口层负责保存)
with self.service.connect() as db:
project = store.get(db, "project", PROJECT)
self.assertEqual(str(project["values"]["facility.amount"]),
str(conflict["candidates"][1]["value"]))
self.assertEqual(project["selected_sources"]["facility.amount"], chosen)
self.assertEqual(project["facts"]["facility_amount"],
project["values"]["facility.amount"])
def test_resolve_with_foreign_evidence_is_422(self):
conflict = self.seed_conflict()
response = self.post(f"/fact-conflicts/{conflict['id']}/resolve",
{"selected_evidence_ids": ["ev-nope"],
"resolution_note": "说明",
"expected_revision": conflict["revision"]})
self.assertEqual(response.status_code, 422, response.text)
self.assertEqual(self.error(response)["code"], "INVALID_REQUEST")
def test_resolve_twice_is_409(self):
conflict = self.seed_conflict()
chosen = conflict["candidates"][1]["evidence_id"]
self.post(f"/fact-conflicts/{conflict['id']}/resolve",
{"selected_evidence_ids": [chosen], "resolution_note": "说明",
"expected_revision": conflict["revision"]})
again = self.post(f"/fact-conflicts/{conflict['id']}/resolve",
{"selected_evidence_ids": [chosen], "resolution_note": "再来",
"expected_revision": conflict["revision"]})
self.assertEqual(again.status_code, 409, again.text)
def test_resolve_unknown_conflict_is_404(self):
response = self.post("/fact-conflicts/fc-nope/resolve",
{"selected_evidence_ids": ["ev-1"], "resolution_note": "说明",
"expected_revision": 1})
self.assertEqual(response.status_code, 404, response.text)
# ------------------------------------------------------------ 边界
def test_envelope_shape_is_stable(self):
response = self.get("/import-batches/nope")
error = self.error(response)
self.assertEqual(set(error), {"code", "message", "retryable", "request_id"})
def test_existing_endpoints_still_use_detail_shape(self):
"""D2 决策的证据:本模块以外不产出 error 信封。"""
import api.project_assistant as legacy
self.assertFalse(hasattr(legacy, "_envelope"))
self.assertFalse(hasattr(legacy, "S1_ERROR_HTTP"))
def test_router_is_mounted_under_project_assistant_prefix(self):
# 命名空间定案 P1:零鉴权改动的关键前提是前缀在 project-assistant 之下
self.assertEqual(api.router.prefix,
"/api/xingbang/risk-agent/project-assistant/prescreen")
def test_storage_layer_untouched_by_api_layer(self):
"""接口层不直接写 SQL,只经服务层——避免出现第二份业务规则。"""
import api.prescreen as module
with open(module.__file__, encoding="utf-8") as handle:
source = handle.read()
for banned in ("INSERT INTO", "UPDATE ", "DELETE FROM", "SELECT "):
self.assertNotIn(banned, source)
if __name__ == "__main__":
unittest.main()