Files
mini-openclaw-v2/backend/api/prescreen.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

308 lines
12 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 预审接口层:材料集合导入与事实确认。
命名空间(claude_use/T00/04-first-slice-contracts.md §1 定案 P1):
/api/xingbang/risk-agent/project-assistant/prescreen/...
挂在 project-assistant 前缀之下,因此**复用现有鉴权与代理,零改动**:
- `authorize()` 的角色→路径表已有 `path.startswith("project-assistant/")` → 客户经理放行;
reviewer 的写豁免同样覆盖该前缀(backend/api/risk_agent_access.py:49,53)。
- 同源代理 `/api/risk-agent/[...path]` 已转发 Authorization / X-Xingbang-Demo-Role /
Idempotency-Key(src/app/api/risk-agent/[...path]/route.ts:7)。
**不**使用设计草案原提的 `/api/prescreen` + `/api/core/prescreen`:那条路要求 admin
角色,且需改三处鉴权/代理代码。
错误信封(T00/04 §2 决策 D2):**只在本模块内**输出
`{error:{code,message,retryable,request_id}}`。不改 `api/project_assistant.translate()`,
也不注册 app 级处理器——既有端点的 `{"detail": ...}` 形状完全不变。
404 统一口径(03 §2):不存在与无访问权一律对外 404,不泄露对象是否存在。
既有 `check_project` 对无权项目抛 403 且被 5 个模块共用,故**不改它**,由本模块的
`scope()` 把访问失败包装成 404;演示域策略仍按 403(E-03 语义)。
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.exception_handlers import request_validation_exception_handler
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, Field
from api.project_assistant import enabled
from api.risk_agent_access import authorize, check_project
from contracts.prescreen import (
EVIDENCE_MAX,
MAX_BATCH_FILES,
NOTE_MAX,
ErrorCode,
S1_ERROR_HTTP,
S1_ERROR_RETRYABLE,
)
from services import assistant_store as store
from services import fact_conflict, material_import
from services.assistant_settings import flags
from services.fact_conflict import FactConflictUnresolved
from services.project_assistant import ProjectAssistantService
router = APIRouter(
prefix="/api/xingbang/risk-agent/project-assistant/prescreen",
dependencies=[Depends(authorize), Depends(enabled)],
tags=["兴邦预审 S1"],
)
DEMO_PROJECTS = ("DEMO-A", "DEMO-B", "DEMO-C")
NOT_FOUND_MESSAGE = "对象不可访问或不存在"
# ---------------------------------------------------------------- 请求模型
class Input(BaseModel):
"""与既有 api/project_assistant.Input 同口径:禁止未知字段(E-07)。"""
model_config = ConfigDict(extra="forbid")
class ImportBatchInput(Input):
# 清单条目只有 4 个字段;id/status/received_at 由服务端生成(03 §9.4)。
# data_domain 不在请求里:从项目读取,避免客户端与项目数据域不一致。
manifest: list[dict] = Field(min_length=1, max_length=MAX_BATCH_FILES)
class RetryInput(Input):
item_ids: list[str] = Field(min_length=1, max_length=MAX_BATCH_FILES)
expected_revision: int = Field(ge=1)
class CancelInput(Input):
expected_revision: int = Field(ge=1)
class ResolveInput(Input):
selected_evidence_ids: list[str] = Field(min_length=1, max_length=EVIDENCE_MAX)
resolution_note: str = Field(min_length=1, max_length=NOTE_MAX)
expected_revision: int = Field(ge=1)
# ---------------------------------------------------------------- 错误信封
def _envelope(request: Request, code: ErrorCode, message: str) -> JSONResponse:
request_id = request.headers.get("x-request-id") or store.uid("req")
return JSONResponse(
status_code=S1_ERROR_HTTP[code],
content={"error": {"code": code.value, "message": message,
"retryable": S1_ERROR_RETRYABLE[code],
"request_id": request_id}},
)
def scope(request: Request, project_id: str):
"""项目范围检查。通过返回 None;不通过返回 404/403 信封。
无权访问的项目按 404 处理(03 §2 不泄露对象存在性);演示域被禁用是策略性
拒绝,按 403(E-03)。
"""
try:
check_project(request, project_id)
except HTTPException:
return _envelope(request, ErrorCode.NOT_FOUND, NOT_FOUND_MESSAGE)
if project_id in DEMO_PROJECTS and not flags()["demo"]:
return _envelope(request, ErrorCode.ACCESS_DENIED, "演示数据域已禁用")
return None
def run(request: Request, call, invalid_code: ErrorCode = ErrorCode.INVALID_REQUEST):
"""执行并把异常映射为统一信封。
顺序重要:HTTPException 必须原样抛出,否则依赖注入阶段的 401/403 会被吞掉。
"""
try:
return call()
except HTTPException:
raise
except FactConflictUnresolved as exc:
# E-17 的「确认动作被拒」面;运行级结果面是 HTTP 200 且不走此信封
return _envelope(request, ErrorCode.FACT_CONFLICT_UNRESOLVED, str(exc))
except store.IdempotencyConflict as exc:
return _envelope(request, ErrorCode.IDEMPOTENCY_CONFLICT, str(exc))
except store.Conflict as exc:
return _envelope(request, ErrorCode.REVISION_CONFLICT, str(exc))
except PermissionError as exc:
return _envelope(request, ErrorCode.ACCESS_DENIED, str(exc))
except KeyError:
return _envelope(request, ErrorCode.NOT_FOUND, NOT_FOUND_MESSAGE)
except ValueError as exc:
return _envelope(request, invalid_code, str(exc))
# ---------------------------------------------------------------- 内部工具
def _service() -> ProjectAssistantService:
return ProjectAssistantService()
def _db():
return _service().connect()
def _project(db, project_id: str) -> dict:
project = store.get(db, "project", project_id) # 不存在 → KeyError → 404
if project.get("data_domain") not in ("demo", "production"):
raise ValueError("项目数据域异常,无法导入材料")
return project
def _actor(request: Request) -> str:
return request.state.identity["id"]
def _key(request: Request) -> str:
key = request.headers.get("idempotency-key", "")
if len(key) > 160:
raise ValueError("幂等键过长")
return key
def _limit(value: int) -> int:
return max(1, min(value, 100))
async def validation_handler(request: Request, exc: RequestValidationError):
"""请求体/查询参数校验失败也走统一信封。
为什么需要它:`Input(extra="forbid")` 之类由 FastAPI 的校验层在**进入端点之前**
抛错,不经过本模块的 `run()`,若不处理就会在同一命名空间里出现两种错误形状
(信封 + FastAPI 默认的 `{"detail": ...}`)。这是 smoke_s1.py 实测发现的。
**非 prescreen 路径一律交回 FastAPI 默认处理器**,因此既有端点的错误形状
完全不变(有测试锁定这一隔离)。
"""
if not request.url.path.startswith(router.prefix):
return await request_validation_exception_handler(request, exc)
errors = exc.errors() if callable(exc.errors) else exc.errors
first = errors[0] if errors else {}
where = ".".join(str(part) for part in first.get("loc", ()) if part != "body") or "请求"
return _envelope(request, ErrorCode.INVALID_REQUEST,
f"{where}:{first.get('msg', '校验失败')}")
# ---------------------------------------------------------------- 导入批次
@router.post("/projects/{project_id}/import-batches", status_code=202)
def create_import_batch(project_id: str, payload: ImportBatchInput, request: Request):
blocked = scope(request, project_id)
if blocked is not None:
return blocked
def action():
with _db() as db:
db.execute("BEGIN IMMEDIATE")
project = _project(db, project_id)
batch = material_import.create(db, project_id, project["data_domain"],
payload.manifest, _actor(request), _key(request))
return {"batch": batch}
return run(request, action, ErrorCode.IMPORT_BATCH_INVALID)
@router.get("/projects/{project_id}/import-batches")
def list_import_batches(project_id: str, request: Request, limit: int = 20):
blocked = scope(request, project_id)
if blocked is not None:
return blocked
def action():
with _db() as db:
return material_import.listing(db, project_id, _limit(limit))
return run(request, action)
@router.get("/import-batches/{batch_id}")
def get_import_batch(batch_id: str, request: Request):
def action():
with _db() as db:
batch = material_import.load(db, batch_id) # KeyError → 404
blocked = scope(request, batch["project_id"])
return blocked if blocked is not None else batch
return run(request, action)
@router.post("/import-batches/{batch_id}/retry", status_code=202)
def retry_import_batch(batch_id: str, payload: RetryInput, request: Request):
def action():
with _db() as db:
probe = material_import.load(db, batch_id)
blocked = scope(request, probe["project_id"])
if blocked is not None:
return blocked
with _db() as db:
db.execute("BEGIN IMMEDIATE")
return material_import.retry(db, batch_id, payload.item_ids,
_actor(request), payload.expected_revision)
return run(request, action, ErrorCode.IMPORT_BATCH_INVALID)
@router.post("/import-batches/{batch_id}/cancel")
def cancel_import_batch(batch_id: str, payload: CancelInput, request: Request):
def action():
with _db() as db:
probe = material_import.load(db, batch_id)
blocked = scope(request, probe["project_id"])
if blocked is not None:
return blocked
with _db() as db:
db.execute("BEGIN IMMEDIATE")
return material_import.cancel(db, batch_id, _actor(request), payload.expected_revision)
return run(request, action, ErrorCode.IMPORT_BATCH_INVALID)
# ---------------------------------------------------------------- 事实冲突
@router.get("/projects/{project_id}/fact-conflicts")
def list_fact_conflicts(project_id: str, request: Request, status: str | None = None,
limit: int = 20):
blocked = scope(request, project_id)
if blocked is not None:
return blocked
if status not in (None, "unresolved", "resolved"):
return _envelope(request, ErrorCode.INVALID_REQUEST,
"状态取值须为 unresolved 或 resolved")
def action():
with _db() as db:
return fact_conflict.listing(db, project_id, status, _limit(limit))
return run(request, action)
@router.get("/fact-conflicts/{conflict_id}")
def get_fact_conflict(conflict_id: str, request: Request):
def action():
with _db() as db:
row = fact_conflict.load(db, conflict_id)
blocked = scope(request, row["project_id"])
return blocked if blocked is not None else row
return run(request, action)
@router.post("/fact-conflicts/{conflict_id}/resolve")
def resolve_fact_conflict(conflict_id: str, payload: ResolveInput, request: Request):
def action():
with _db() as db:
probe = fact_conflict.load(db, conflict_id)
blocked = scope(request, probe["project_id"])
if blocked is not None:
return blocked
with _db() as db:
db.execute("BEGIN IMMEDIATE")
project = _project(db, probe["project_id"])
# resolve 就地写 project 的 values/selected_sources/facts,因此必须保存它;
# 不要在此重复计算 facts 的列名——resolve 已按 FIELDS 反查过。
resolved = fact_conflict.resolve(db, conflict_id, project,
payload.selected_evidence_ids,
payload.resolution_note,
_actor(request), payload.expected_revision)
store.put(db, "project", project)
return resolved
return run(request, action)