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:
@@ -0,0 +1,307 @@
|
|||||||
|
"""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)
|
||||||
@@ -95,6 +95,15 @@ from api.project_assistant import router as project_assistant_router
|
|||||||
from api.operations_center import router as operations_router
|
from api.operations_center import router as operations_router
|
||||||
app.include_router(operations_router)
|
app.include_router(operations_router)
|
||||||
app.include_router(project_assistant_router)
|
app.include_router(project_assistant_router)
|
||||||
|
# S1 预审接口:必须注册在 project_assistant 之后(更具体的前缀先匹配;
|
||||||
|
# 其路径为 .../project-assistant/prescreen/...)——见 claude_use/T00/04 §1
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from api.prescreen import router as prescreen_router, validation_handler
|
||||||
|
|
||||||
|
# 只为 prescreen 路径输出统一错误信封;处理器内部对其它路径交回 FastAPI 默认实现,
|
||||||
|
# 因此既有端点的 {"detail": ...} 形状不变(有测试与冒烟锁定该隔离)。
|
||||||
|
app.add_exception_handler(RequestValidationError, validation_handler)
|
||||||
|
app.include_router(prescreen_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ def idempotent_lookup(db, actor, operation, key, request_hash):
|
|||||||
if not prior:
|
if not prior:
|
||||||
return None
|
return None
|
||||||
if prior[0] != request_hash:
|
if prior[0] != request_hash:
|
||||||
raise Conflict("幂等键已用于不同请求")
|
raise IdempotencyConflict("幂等键已用于不同请求")
|
||||||
return prior[1]
|
return prior[1]
|
||||||
|
|
||||||
|
|
||||||
@@ -160,3 +160,11 @@ def time_listing(db, project_ids=None):
|
|||||||
|
|
||||||
class Conflict(ValueError):
|
class Conflict(ValueError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class IdempotencyConflict(Conflict):
|
||||||
|
"""幂等键已用于不同载荷(E-09 IDEMPOTENCY_CONFLICT)。
|
||||||
|
|
||||||
|
与普通 Conflict(E-05 REVISION_CONFLICT)区分:两者的用户文案与恢复动作不同,
|
||||||
|
接口层需要能分别映射为不同的错误码,故不用字符串比对来猜。
|
||||||
|
"""
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ PARSER_VERSION = "assistant_materials.parse@1"
|
|||||||
|
|
||||||
_STATUS_ORDER = ("queued", "parsing", "parsed", "failed")
|
_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:
|
def _derive_status(items: list[dict]) -> str:
|
||||||
"""由逐项状态派生批次状态。
|
"""由逐项状态派生批次状态。
|
||||||
@@ -57,8 +62,19 @@ def _validate_manifest(manifest: list[dict]) -> list[dict]:
|
|||||||
items: list[dict] = []
|
items: list[dict] = []
|
||||||
seen_sha: set[str] = set()
|
seen_sha: set[str] = set()
|
||||||
for index, entry in enumerate(manifest):
|
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:
|
try:
|
||||||
parsed = ImportItem(**entry)
|
parsed = ImportItem(**draft)
|
||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
# 逐项错误沿用 E-01/E-16 的文案口径,指明是第几项
|
# 逐项错误沿用 E-01/E-16 的文案口径,指明是第几项
|
||||||
first = exc.errors()[0]
|
first = exc.errors()[0]
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -19,9 +19,11 @@ from services.assistant_materials import FIELDS
|
|||||||
|
|
||||||
|
|
||||||
def manifest_entry(name="a.csv", sha="a" * 64, size=100, mime="text/csv"):
|
def manifest_entry(name="a.csv", sha="a" * 64, size=100, mime="text/csv"):
|
||||||
return {"id": store.uid("item"), "relative_path": name, "sha256": sha,
|
"""客户端清单条目:只有设计允许的 4 个字段(03 §9.4)。
|
||||||
"size_bytes": size, "mime_type": mime, "status": "queued",
|
|
||||||
"received_at": store.now()}
|
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):
|
class StoreTestCase(unittest.TestCase):
|
||||||
@@ -70,6 +72,30 @@ class ImportBatchTests(StoreTestCase):
|
|||||||
self.assertEqual(batch["revision"], 1)
|
self.assertEqual(batch["revision"], 1)
|
||||||
self.assertEqual(batch["parser_version"], material_import.PARSER_VERSION)
|
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):
|
def test_empty_manifest_rejected(self):
|
||||||
with store.transaction(self.db_path) as db:
|
with store.transaction(self.db_path) as db:
|
||||||
with self.assertRaises(ValueError) as ctx:
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
|||||||
Reference in New Issue
Block a user