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:
@@ -19,9 +19,11 @@ from services.assistant_materials import FIELDS
|
||||
|
||||
|
||||
def manifest_entry(name="a.csv", sha="a" * 64, size=100, mime="text/csv"):
|
||||
return {"id": store.uid("item"), "relative_path": name, "sha256": sha,
|
||||
"size_bytes": size, "mime_type": mime, "status": "queued",
|
||||
"received_at": store.now()}
|
||||
"""客户端清单条目:只有设计允许的 4 个字段(03 §9.4)。
|
||||
|
||||
id / status / received_at 由服务端生成——见 test_client_cannot_declare_server_fields。
|
||||
"""
|
||||
return {"relative_path": name, "sha256": sha, "size_bytes": size, "mime_type": mime}
|
||||
|
||||
|
||||
class StoreTestCase(unittest.TestCase):
|
||||
@@ -70,6 +72,30 @@ class ImportBatchTests(StoreTestCase):
|
||||
self.assertEqual(batch["revision"], 1)
|
||||
self.assertEqual(batch["parser_version"], material_import.PARSER_VERSION)
|
||||
|
||||
def test_client_cannot_declare_server_fields(self):
|
||||
"""状态机的写权不交给调用方:客户端不得自报 id / status / received_at。"""
|
||||
for field, value in [("status", "parsed"), ("id", "item-x"),
|
||||
("received_at", store.now()), ("document_id", "doc-1")]:
|
||||
with self.subTest(field=field):
|
||||
entry = manifest_entry()
|
||||
entry[field] = value
|
||||
with store.transaction(self.db_path) as db:
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
material_import.create(db, self.project_id, "demo", [entry], self.actor)
|
||||
self.assertIn("不允许由客户端声明", str(ctx.exception))
|
||||
|
||||
def test_server_generates_item_identity_and_queued_status(self):
|
||||
with store.transaction(self.db_path) as db:
|
||||
batch = material_import.create(db, self.project_id, "demo",
|
||||
[manifest_entry()], self.actor)
|
||||
item = batch["items"][0]
|
||||
self.assertTrue(item["id"].startswith("item-"))
|
||||
self.assertEqual(item["status"], "queued")
|
||||
self.assertIsNotNone(item["received_at"])
|
||||
# BR-02:发生时间未知就保持 null,不用导入时间冒充
|
||||
self.assertIsNone(item["observed_at"])
|
||||
self.assertIsNone(item["document_id"])
|
||||
|
||||
def test_empty_manifest_rejected(self):
|
||||
with store.transaction(self.db_path) as db:
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
|
||||
Reference in New Issue
Block a user