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