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>
212 lines
8.9 KiB
Python
212 lines
8.9 KiB
Python
"""S1 材料导入服务:文件集合清单的生命周期。
|
||
|
||
职责 owner(docs/v2/03-detailed-design.md §9.2「材料导入服务」):
|
||
集合清单、逐项状态与重试、批次状态派生。
|
||
|
||
边界(不得越界):
|
||
- **不建立平行上传 API**:文件字节仍走既有
|
||
`POST /projects/{project_id}/documents` → `ProjectAssistantService.upload()`
|
||
→ `assistant_materials.material()`。本模块只登记清单与回写解析结果。
|
||
- **不创建平行持久化入口**:复用 `assistant_objects`(kind=`import_batch`)。
|
||
items 是批次内的值对象(契约中 `ImportItem` 无 `revision`),故不另立
|
||
`import_item` kind;批次 revision 由 `put()` 维护。
|
||
- **不做真实性核验**:`SourceLocator` 能定位不等于材料真实可信(01 §1)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from services import assistant_store as store
|
||
from contracts.prescreen import ErrorCode, MAX_BATCH_BYTES, MAX_BATCH_FILES
|
||
|
||
KIND = "import_batch"
|
||
IDEMPOTENCY_OPERATION = "import_batch"
|
||
|
||
# 解析实现版本:冻结进批次,使"用哪版解析器得到这个结果"可追溯(BR-22)。
|
||
# 解析逻辑在 assistant_materials.parse(),其行为变更时必须同步提升此版本。
|
||
PARSER_VERSION = "assistant_materials.parse@1"
|
||
|
||
_STATUS_ORDER = ("queued", "parsing", "parsed", "failed")
|
||
|
||
# 客户端在清单里**只能**声明这 4 个字段(docs/v2/03-detailed-design.md §9.4)。
|
||
# id / status / received_at / document_id / error_code 一律由服务端决定——
|
||
# 让客户端自报 id 或 status 等于把状态机的写权交给调用方。
|
||
MANIFEST_FIELDS = frozenset({"relative_path", "mime_type", "size_bytes", "sha256"})
|
||
|
||
|
||
def _derive_status(items: list[dict]) -> str:
|
||
"""由逐项状态派生批次状态。
|
||
|
||
ready = 全部 parsed
|
||
failed = 全部 failed
|
||
partial = 既有 parsed 又有 failed(含 queued/parsing 混入的中间态)
|
||
"""
|
||
statuses = {item["status"] for item in items}
|
||
if statuses == {"parsed"}:
|
||
return "ready"
|
||
if statuses == {"failed"}:
|
||
return "failed"
|
||
if "failed" in statuses and "parsed" in statuses:
|
||
return "partial"
|
||
return "parsing"
|
||
|
||
|
||
def _validate_manifest(manifest: list[dict]) -> list[dict]:
|
||
from pydantic import ValidationError
|
||
from contracts.prescreen import ImportItem
|
||
|
||
if not manifest:
|
||
raise ValueError("材料集合为空;请选择至少一个文件")
|
||
if len(manifest) > MAX_BATCH_FILES:
|
||
raise ValueError(f"单批最多 {MAX_BATCH_FILES} 个文件;请拆分后重试")
|
||
|
||
items: list[dict] = []
|
||
seen_sha: set[str] = set()
|
||
for index, entry in enumerate(manifest):
|
||
if not isinstance(entry, dict):
|
||
raise ValueError(f"第 {index + 1} 个文件不是对象")
|
||
extra = sorted(set(entry) - MANIFEST_FIELDS)
|
||
if extra:
|
||
raise ValueError(f"第 {index + 1} 个文件包含不允许由客户端声明的字段:{extra}")
|
||
|
||
# id / status / received_at 由服务端决定;observed_at 留空——
|
||
# 导入时间不冒充材料发生时间(14 §3),未知保持 null(BR-02)
|
||
draft = dict(entry, id=store.uid("item"), status="queued",
|
||
received_at=store.now(), document_id=None,
|
||
error_code=None, observed_at=None)
|
||
try:
|
||
parsed = ImportItem(**draft)
|
||
except ValidationError as exc:
|
||
# 逐项错误沿用 E-01/E-16 的文案口径,指明是第几项
|
||
first = exc.errors()[0]
|
||
where = ".".join(str(p) for p in first["loc"]) or "输入"
|
||
raise ValueError(f"第 {index + 1} 个文件不合法({where}):{first['msg']}") from exc
|
||
if parsed.sha256 in seen_sha:
|
||
raise ValueError(f"第 {index + 1} 个文件与同批其他条目哈希重复:{parsed.relative_path}")
|
||
seen_sha.add(parsed.sha256)
|
||
items.append(parsed.model_dump(mode="json"))
|
||
|
||
total = sum(item["size_bytes"] for item in items)
|
||
if total > MAX_BATCH_BYTES:
|
||
raise ValueError(f"单批总量 {total} 超过上限 {MAX_BATCH_BYTES} 字节;请拆分后重试")
|
||
return items
|
||
|
||
|
||
def create(db, project_id, data_domain, manifest, actor, idempotency_key="", parser_version=PARSER_VERSION):
|
||
"""登记一次文件集合导入。返回 ImportBatch(status=queued)。
|
||
|
||
幂等:同一 actor+project+key 且载荷相同 → 返回原批次;载荷不同 → E-09。
|
||
"""
|
||
request_hash = store.digest({"project": project_id, "domain": data_domain, "manifest": manifest})
|
||
if idempotency_key:
|
||
prior = store.idempotent_lookup(db, actor, IDEMPOTENCY_OPERATION, idempotency_key, request_hash)
|
||
if prior:
|
||
return store.get(db, KIND, prior)
|
||
|
||
items = _validate_manifest(manifest)
|
||
batch = {
|
||
"id": store.uid("import"),
|
||
"project_id": project_id,
|
||
"data_domain": data_domain,
|
||
"status": "queued",
|
||
"created_at": store.now(),
|
||
"created_by": actor,
|
||
"parser_version": parser_version,
|
||
"items": items,
|
||
"total_bytes": sum(item["size_bytes"] for item in items),
|
||
"warnings": [],
|
||
}
|
||
batch = store.put(db, KIND, batch) # revision=1
|
||
if idempotency_key:
|
||
store.idempotent_remember(db, actor, IDEMPOTENCY_OPERATION, idempotency_key, request_hash, batch["id"])
|
||
store.event(db, project_id, "import_batch.created", actor,
|
||
{"batch_id": batch["id"], "files": len(items), "total_bytes": batch["total_bytes"]})
|
||
return batch
|
||
|
||
|
||
def load(db, batch_id, project_id=None):
|
||
batch = store.get(db, KIND, batch_id)
|
||
if project_id is not None and batch["project_id"] != project_id:
|
||
# 不存在与无访问权统一对外 404(03 §2),故这里也走 KeyError
|
||
raise KeyError("导入批次不存在")
|
||
return batch
|
||
|
||
|
||
def listing(db, project_id, limit=20):
|
||
rows = store.listing(db, KIND, project_id)
|
||
return {"items": rows[:limit], "next_cursor": None if len(rows) <= limit else str(limit)}
|
||
|
||
|
||
def mark_item(db, batch_id, item_id, status, document_id=None, error_code=None, warning=None):
|
||
"""由既有上传链回写单条解析结果。
|
||
|
||
`parsed` 必须带 document_id;`failed` 必须带 error_code —— 与契约同规则,
|
||
避免出现"状态说成功但无法定位原件"的条目(AC-23)。
|
||
"""
|
||
if status not in _STATUS_ORDER:
|
||
raise ValueError(f"未知条目状态:{status}")
|
||
if status == "failed" and not error_code:
|
||
raise ValueError("failed 的条目必须给出 error_code")
|
||
batch = store.get(db, KIND, batch_id)
|
||
item = next((i for i in batch["items"] if i["id"] == item_id), None)
|
||
if item is None:
|
||
raise KeyError("导入条目不存在")
|
||
if batch["status"] == "cancelled":
|
||
# 取消后忽略迟到结果(03 §9.5),不写回
|
||
return batch
|
||
item["status"] = status
|
||
if document_id is not None:
|
||
item["document_id"] = document_id
|
||
item["error_code"] = error_code if status == "failed" else None
|
||
if warning:
|
||
batch["warnings"].append(f"{item['relative_path']}: {warning}")
|
||
batch["status"] = _derive_status(batch["items"])
|
||
return store.put(db, KIND, batch)
|
||
|
||
|
||
def retry(db, batch_id, item_ids, actor, expected):
|
||
"""仅把失败项重置为 queued;已 parsed 的条目不重解析、不重写(AC-23 反例)。"""
|
||
batch = store.get(db, KIND, batch_id)
|
||
if batch["status"] == "cancelled":
|
||
raise store.Conflict("批次已取消,不能重试")
|
||
if batch["status"] not in ("partial", "failed"):
|
||
raise ValueError("只有部分失败或全部失败的批次可以重试")
|
||
|
||
targets = set(item_ids)
|
||
unknown = targets - {i["id"] for i in batch["items"]}
|
||
if unknown:
|
||
raise KeyError(f"导入条目不存在:{sorted(unknown)}")
|
||
|
||
reset = []
|
||
for item in batch["items"]:
|
||
if item["id"] not in targets:
|
||
continue
|
||
if item["status"] != "failed":
|
||
raise ValueError(f"条目 {item['relative_path']} 不是失败项,不能重试")
|
||
item["status"] = "queued"
|
||
item["error_code"] = None
|
||
reset.append(item["id"])
|
||
|
||
batch["status"] = _derive_status(batch["items"])
|
||
batch = store.put(db, KIND, batch, expected=expected)
|
||
store.event(db, batch["project_id"], "import_batch.retried", actor,
|
||
{"batch_id": batch_id, "items": reset})
|
||
return batch
|
||
|
||
|
||
def cancel(db, batch_id, actor, expected):
|
||
batch = store.get(db, KIND, batch_id)
|
||
if batch["status"] in ("ready", "partial", "failed", "cancelled"):
|
||
raise store.Conflict("批次已结束,不能取消")
|
||
batch["status"] = "cancelled"
|
||
batch = store.put(db, KIND, batch, expected=expected)
|
||
store.event(db, batch["project_id"], "import_batch.cancelled", actor, {"batch_id": batch_id})
|
||
return batch
|
||
|
||
|
||
# 供 S1c 接口层把服务端异常映射为错误码(03 §2 的统一信封)
|
||
ERROR_BY_EXCEPTION = {
|
||
"KeyError": ErrorCode.NOT_FOUND,
|
||
"Conflict": ErrorCode.REVISION_CONFLICT,
|
||
"ValueError": ErrorCode.IMPORT_BATCH_INVALID,
|
||
}
|