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>
171 lines
6.8 KiB
Python
171 lines
6.8 KiB
Python
"""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)区分:两者的用户文案与恢复动作不同,
|
||
接口层需要能分别映射为不同的错误码,故不用字符串比对来猜。
|
||
"""
|