Files
mini-openclaw-v2/backend/services/material_import.py
T
qiaoqichaoandClaude Code fcf4d8c948 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>
2026-09-29 16:45:07 +08:00

212 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
}