Files
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

171 lines
6.8 KiB
Python
Raw Permalink 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.
"""Transactional JSON storage. Connections are closed, including error paths."""
import json
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timezone
from hashlib import sha256
from pathlib import Path
from uuid import uuid4
def now():
return datetime.now(timezone.utc).isoformat()
def uid(prefix):
return prefix + "-" + uuid4().hex
def digest(value):
return sha256(json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()).hexdigest()
@contextmanager
def transaction(path):
path.parent.mkdir(parents=True, exist_ok=True)
db = sqlite3.connect(path, timeout=15)
db.row_factory = sqlite3.Row
db.execute("PRAGMA busy_timeout=15000")
try:
with db:
yield db
finally:
db.close()
def initialize(path):
with transaction(path) as db:
db.executescript("""
CREATE TABLE IF NOT EXISTS assistant_objects (
kind TEXT NOT NULL, id TEXT NOT NULL, project_id TEXT NOT NULL,
revision INTEGER NOT NULL, body TEXT NOT NULL, PRIMARY KEY(kind,id));
CREATE INDEX IF NOT EXISTS assistant_scope ON assistant_objects(kind,project_id);
CREATE TABLE IF NOT EXISTS assistant_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT NOT NULL,
run_id TEXT, body TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS assistant_idempotency (
actor TEXT, operation TEXT, key TEXT, request_hash TEXT, object_id TEXT,
PRIMARY KEY(actor,operation,key));
CREATE TABLE IF NOT EXISTS assistant_jobs (
id TEXT PRIMARY KEY, project_id TEXT, state TEXT, owner TEXT,
lease_until REAL NOT NULL DEFAULT 0, attempts INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS assistant_meta (key TEXT PRIMARY KEY, value TEXT);
""")
schema = Path(__file__).resolve().parents[1] / "fixtures" / "xingbang_demo" / "business_flow_schema.sql"
if schema.exists():
db.executescript(schema.read_text(encoding="utf-8"))
def get(db, kind, object_id):
row = db.execute("SELECT body FROM assistant_objects WHERE kind=? AND id=?", (kind, object_id)).fetchone()
if row is None:
raise KeyError(f"{kind} 不存在")
return json.loads(row[0])
def listing(db, kind, project_id=None):
if project_id is None:
rows = db.execute("SELECT body FROM assistant_objects WHERE kind=? ORDER BY rowid DESC", (kind,))
else:
rows = db.execute("SELECT body FROM assistant_objects WHERE kind=? AND project_id=? ORDER BY rowid DESC", (kind, project_id))
return [json.loads(row[0]) for row in rows]
def put(db, kind, body, expected=None, immutable=False):
old = db.execute("SELECT revision FROM assistant_objects WHERE kind=? AND id=?", (kind, body["id"])).fetchone()
if immutable and old:
raise ValueError("发布版本和历史快照不可覆盖")
if expected is not None and (not old or old[0] != expected):
raise Conflict("内容已更新,请刷新后重试")
body = dict(body, revision=(old[0] + 1 if old else 1))
db.execute("INSERT INTO assistant_objects VALUES (?,?,?,?,?) ON CONFLICT(kind,id) DO UPDATE SET revision=excluded.revision,body=excluded.body",
(kind, body["id"], body.get("project_id", ""), body["revision"], json.dumps(body, ensure_ascii=False, allow_nan=False)))
return body
def event(db, project_id, action, actor, detail=None, run_id=None):
body = {"time": now(), "action": action, "actor": actor, "detail": detail or {}}
db.execute("INSERT INTO assistant_events(project_id,run_id,body) VALUES (?,?,?)", (project_id, run_id, json.dumps(body, ensure_ascii=False)))
def idempotent_lookup(db, actor, operation, key, request_hash):
"""幂等键查询(新增的共享 helper)。
既有 enqueue / create_report 各自内联同一段逻辑;本函数是新增实现,供新路径
使用。**未改写那两处**,避免影响既有运行与报告流程。
返回既有 object_id;同键异载荷抛 Conflict(对应 E-09 IDEMPOTENCY_CONFLICT)。
"""
prior = db.execute(
"SELECT request_hash,object_id FROM assistant_idempotency WHERE actor=? AND operation=? AND key=?",
(actor, operation, key),
).fetchone()
if not prior:
return None
if prior[0] != request_hash:
raise IdempotencyConflict("幂等键已用于不同请求")
return prior[1]
def idempotent_remember(db, actor, operation, key, request_hash, object_id):
"""登记幂等键。
用 INSERT OR REPLACE 而非既有内联代码的裸 INSERT:重试路径上同一个键可能再次
到达,裸 INSERT 会因主键冲突抛 sqlite3.IntegrityError,那不是 Conflict,无法被
translate() 映射为 409。同键同载荷在 lookup 阶段已提前返回,故此处的覆盖分支
不可达,语义不变。
"""
db.execute(
"INSERT OR REPLACE INTO assistant_idempotency VALUES (?,?,?,?,?)",
(actor, operation, key, request_hash, object_id),
)
def time_event(db, project_id, phase_code, phase_name, role, category, actor, source_type, source_id,
status="running", started_at=None, ended_at=None, detail=None, event_id=None):
"""Create an immutable phase interval; callers close it with finish_time_event."""
event_id = event_id or uid("time")
db.execute(
"INSERT INTO business_time_events VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
(event_id, project_id, phase_code, phase_name, role, category, status,
started_at or now(), ended_at, actor, source_type, source_id,
json.dumps(detail or {}, ensure_ascii=False)),
)
return event_id
def finish_time_event(db, source_type, source_id, status="completed", ended_at=None):
db.execute(
"UPDATE business_time_events SET status=?,ended_at=? WHERE source_type=? AND source_id=? AND status='running'",
(status, ended_at or now(), source_type, source_id),
)
def time_listing(db, project_ids=None):
query = "SELECT * FROM business_time_events"
params = []
if project_ids is not None:
if not project_ids:
return []
query += " WHERE project_id IN (" + ",".join("?" for _ in project_ids) + ")"
params.extend(project_ids)
query += " ORDER BY started_at"
result = []
for row in db.execute(query, params):
item = dict(row)
item["detail"] = json.loads(item["detail"] or "{}")
result.append(item)
return result
class Conflict(ValueError):
pass
class IdempotencyConflict(Conflict):
"""幂等键已用于不同载荷(E-09 IDEMPOTENCY_CONFLICT)。
与普通 Conflict(E-05 REVISION_CONFLICT)区分:两者的用户文案与恢复动作不同,
接口层需要能分别映射为不同的错误码,故不用字符串比对来猜。
"""