176 lines
7.5 KiB
Python
176 lines
7.5 KiB
Python
"""评分卡冻结版的服务端存储(v7 机制第 5 项:基线漂移门禁)。
|
||
|
||
定位:只存“冻结的那一版”,不算分、不判 stale、不改旧分。
|
||
算分在 cloud/audit_scorecard.py 的 build_scorecard,
|
||
漂移判定在 check_baseline_stale,本模块只做冻结行的写入与读取。
|
||
|
||
键思路沿用仓库 DB 模式:ledger.py / audit_loop_driver.py 按
|
||
(module_id, contract_version) 计 slot,本表按
|
||
(rubric_version, baseline_head) 计冻结版——同一 rubric 版本在
|
||
同一 main 头上冻结的评分卡快照,是一版;main 头推进后冻结的,
|
||
是另一版,旧版仍在表里供追溯,不被覆盖。
|
||
|
||
行字段:
|
||
- rubric_version:评分口径版本(调用方传入的字符串,如 "v1";
|
||
本模块不规定口径内容、不替用户拍板阈值)。
|
||
- baseline_head:冻结时调用方传入的 main 头(字符串,非空)。
|
||
- frozen_at:冻结时间戳(秒,调用方不传时取当前时间)。
|
||
- scorecard_snapshot:build_scorecard 输出的完整 dict,以 JSON
|
||
文本存入,读回时还原为 dict。
|
||
|
||
只追加,不改删:
|
||
- 本模块只提供 ensure_schema(建表,幂等)、freeze_scorecard
|
||
(INSERT 新行)、get_frozen_scorecard / get_latest_frozen_scorecard /
|
||
list_frozen_scorecards(只读)。刻意不提供任何 UPDATE / DELETE
|
||
方法;SQLite 下另有触发器在表层挡住 UPDATE / DELETE
|
||
(写法沿用 ledger.py 的只追加触发器)。
|
||
- 不 commit:调用方在自己的事务边界提交(与 ledger.ensure_schema
|
||
同款约定);独立调用方须自己 commit,否则同连接外看不到新行。
|
||
- 真实 PG 未接通:PG 适配连接跳过 SQLite 触发器建(方言不同,
|
||
与 ledger.py 同款处理),PG 下的只追加护栏与真实 PG 验证均未做,
|
||
本项只用 SQLite / 内存库测试并明说。
|
||
- 本模块绝不在生产代码里探测版本控制状态、绝不读版本控制目录:
|
||
rubric_version / baseline_head / frozen_at 全部由调用方传入。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import time
|
||
|
||
_FROZEN_TABLE_SQL = (
|
||
"CREATE TABLE IF NOT EXISTS scorecard_baseline_freeze("
|
||
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||
"rubric_version TEXT NOT NULL, "
|
||
"baseline_head TEXT NOT NULL, "
|
||
"frozen_at DOUBLE PRECISION NOT NULL, "
|
||
"scorecard_snapshot TEXT NOT NULL)"
|
||
)
|
||
|
||
_FROZEN_INDEX_SQL = (
|
||
"CREATE INDEX IF NOT EXISTS scorecard_baseline_freeze_key "
|
||
"ON scorecard_baseline_freeze(rubric_version, baseline_head)"
|
||
)
|
||
|
||
# SQLite 只追加触发器(PG 不走这段,见模块 docstring)。
|
||
_SQLITE_GUARD_SQL = (
|
||
"CREATE TRIGGER IF NOT EXISTS scorecard_baseline_freeze_no_update "
|
||
"BEFORE UPDATE ON scorecard_baseline_freeze BEGIN "
|
||
"SELECT RAISE(ABORT, 'scorecard_baseline_freeze is append-only'); END",
|
||
"CREATE TRIGGER IF NOT EXISTS scorecard_baseline_freeze_no_delete "
|
||
"BEFORE DELETE ON scorecard_baseline_freeze BEGIN "
|
||
"SELECT RAISE(ABORT, 'scorecard_baseline_freeze is append-only'); END",
|
||
)
|
||
|
||
|
||
def _is_pg_conn(conn):
|
||
"""PgConnection 适配连接(触发器方言不同,须跳过 SQLite 护栏)。"""
|
||
return type(conn).__name__ == "PgConnection"
|
||
|
||
|
||
def ensure_schema(conn):
|
||
"""建冻结表 + 索引 + SQLite 只追加触发器(幂等,不 commit)。"""
|
||
conn.execute(_FROZEN_TABLE_SQL)
|
||
conn.execute(_FROZEN_INDEX_SQL)
|
||
if not _is_pg_conn(conn):
|
||
for stmt in _SQLITE_GUARD_SQL:
|
||
conn.execute(stmt)
|
||
|
||
|
||
def _require_non_empty_str(value, name):
|
||
if not isinstance(value, str) or value == "":
|
||
raise ValueError("%s must be a non-empty string" % name)
|
||
return value
|
||
|
||
|
||
def freeze_scorecard(conn, rubric_version, baseline_head,
|
||
scorecard_snapshot, frozen_at=None):
|
||
"""写入一个新冻结行(INSERT only),返回读回形态的行 dict。
|
||
|
||
scorecard_snapshot 必须是 build_scorecard 输出的 dict;不是 dict
|
||
时拒绝写入,不做任何格式猜测。调用方负责 commit。
|
||
"""
|
||
_require_non_empty_str(rubric_version, "rubric_version")
|
||
_require_non_empty_str(baseline_head, "baseline_head")
|
||
if not isinstance(scorecard_snapshot, dict):
|
||
raise ValueError("scorecard_snapshot must be a dict")
|
||
stamp = float(frozen_at) if frozen_at is not None else time.time()
|
||
payload = json.dumps(scorecard_snapshot, sort_keys=True,
|
||
ensure_ascii=False)
|
||
cur = conn.execute(
|
||
"INSERT INTO scorecard_baseline_freeze"
|
||
"(rubric_version, baseline_head, frozen_at, scorecard_snapshot) "
|
||
"VALUES (?, ?, ?, ?)",
|
||
(rubric_version, baseline_head, stamp, payload))
|
||
return {
|
||
"id": cur.lastrowid,
|
||
"rubric_version": rubric_version,
|
||
"baseline_head": baseline_head,
|
||
"frozen_at": stamp,
|
||
"scorecard_snapshot": scorecard_snapshot,
|
||
}
|
||
|
||
|
||
def _row_to_dict(row):
|
||
row_id, rubric_version, baseline_head, frozen_at, snapshot_text = row
|
||
return {
|
||
"id": row_id,
|
||
"rubric_version": rubric_version,
|
||
"baseline_head": baseline_head,
|
||
"frozen_at": frozen_at,
|
||
"scorecard_snapshot": json.loads(snapshot_text),
|
||
}
|
||
|
||
|
||
def get_frozen_scorecard(conn, rubric_version, baseline_head):
|
||
"""读某 (rubric_version, baseline_head) 的最新冻结行,无则 None。
|
||
|
||
同一键有多行时(不同 frozen_at 多次冻结)返回 id 最大的最新行,
|
||
旧行仍在表里,不被覆盖、不被删除。
|
||
"""
|
||
_require_non_empty_str(rubric_version, "rubric_version")
|
||
_require_non_empty_str(baseline_head, "baseline_head")
|
||
row = conn.execute(
|
||
"SELECT id, rubric_version, baseline_head, frozen_at, "
|
||
"scorecard_snapshot FROM scorecard_baseline_freeze "
|
||
"WHERE rubric_version = ? AND baseline_head = ? "
|
||
"ORDER BY id DESC LIMIT 1",
|
||
(rubric_version, baseline_head)).fetchone()
|
||
return _row_to_dict(row) if row is not None else None
|
||
|
||
|
||
def get_latest_frozen_scorecard(conn, rubric_version=None):
|
||
"""读最新冻结行;传 rubric_version 时只在该口径内取最新,无则 None。"""
|
||
if rubric_version is None:
|
||
row = conn.execute(
|
||
"SELECT id, rubric_version, baseline_head, frozen_at, "
|
||
"scorecard_snapshot FROM scorecard_baseline_freeze "
|
||
"ORDER BY id DESC LIMIT 1").fetchone()
|
||
else:
|
||
_require_non_empty_str(rubric_version, "rubric_version")
|
||
row = conn.execute(
|
||
"SELECT id, rubric_version, baseline_head, frozen_at, "
|
||
"scorecard_snapshot FROM scorecard_baseline_freeze "
|
||
"WHERE rubric_version = ? ORDER BY id DESC LIMIT 1",
|
||
(rubric_version,)).fetchone()
|
||
return _row_to_dict(row) if row is not None else None
|
||
|
||
|
||
def list_frozen_scorecards(conn, rubric_version=None, baseline_head=None):
|
||
"""按 id 升序列出冻结行;两参数均可选作过滤条件,不过滤时全列。"""
|
||
sql = ("SELECT id, rubric_version, baseline_head, frozen_at, "
|
||
"scorecard_snapshot FROM scorecard_baseline_freeze")
|
||
clauses, params = [], []
|
||
if rubric_version is not None:
|
||
_require_non_empty_str(rubric_version, "rubric_version")
|
||
clauses.append("rubric_version = ?")
|
||
params.append(rubric_version)
|
||
if baseline_head is not None:
|
||
_require_non_empty_str(baseline_head, "baseline_head")
|
||
clauses.append("baseline_head = ?")
|
||
params.append(baseline_head)
|
||
if clauses:
|
||
sql += " WHERE " + " AND ".join(clauses)
|
||
sql += " ORDER BY id ASC"
|
||
return [_row_to_dict(row) for row in conn.execute(sql, params).fetchall()]
|