188 lines
7.9 KiB
Python
188 lines
7.9 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""M1b 注册同步执行器(QC 退回意见 #8:需真实执行记录 + 库内注册数据证据)。
|
||
|
||
做三件事并**落盘可复核证据**:
|
||
1. 建表(幂等 DDL,sqlite 方言;mariadb DDL 见 sql/m1b_ddl.sql)
|
||
2. 注册 7 类子对象 + 模板/蓝图的扩展字段定义(pbl_ext_field_def,幂等 upsert)
|
||
3. 注册平台公共模板(pbl_blueprint_template,tenant_id NULL,幂等 upsert)
|
||
4. 回读库内注册数据 -> 写 projects/pbls/deliverables/m1b/register_sync_report.json
|
||
+ projects/pbls/deliverables/audit/pbl_blueprint_audit.jsonl(审计轨迹)
|
||
|
||
用法:
|
||
python3 tools/m1b_register_sync.py # 执行并落盘证据
|
||
python3 tools/m1b_register_sync.py --db path.db # 指定库文件
|
||
python3 tools/m1b_register_sync.py --twice # 连跑两次验证幂等(行数不增)
|
||
"""
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import sys
|
||
|
||
# --- M1b sys.path bootstrap: modules/ 下各包互为兄弟仓库,需逐个入 path ---
|
||
_M1B_MOD_ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
|
||
_M1B_MODULES_DIR = os.path.abspath(os.path.join(_M1B_MOD_ROOT, ".."))
|
||
_M1B_CANDIDATES = [_M1B_MOD_ROOT, _M1B_MODULES_DIR]
|
||
try:
|
||
for _d in sorted(os.listdir(_M1B_MODULES_DIR)):
|
||
_sub = os.path.join(_M1B_MODULES_DIR, _d)
|
||
if os.path.isdir(_sub) and not _d.startswith("."):
|
||
_M1B_CANDIDATES.append(_sub)
|
||
except OSError:
|
||
pass
|
||
for _p in _M1B_CANDIDATES:
|
||
if _p not in sys.path:
|
||
sys.path.insert(0, _p)
|
||
# --- end bootstrap ---
|
||
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
MOD_ROOT = os.path.dirname(HERE)
|
||
REPO_ROOT = os.path.abspath(os.path.join(MOD_ROOT, "..", ".."))
|
||
for p in (REPO_ROOT, MOD_ROOT):
|
||
if p not in sys.path:
|
||
sys.path.insert(0, p)
|
||
|
||
from pbl_blueprint.m1b import init as m1b_init # noqa: E402
|
||
from pbl_blueprint.m1b.audit import flush_memory_audit # noqa: E402
|
||
from pbl_blueprint.m1b.dbutil import ( # noqa: E402
|
||
get_conn, reset_conn, sql_rows, table_exists,
|
||
)
|
||
from pbl_blueprint.m1b.tables import SUBOBJECT_TYPES, TABLES # noqa: E402
|
||
from pbl_blueprint.m1b.util import now_str # noqa: E402
|
||
|
||
DEFAULT_DB = os.path.join(REPO_ROOT, "projects", "pbls", "deliverables",
|
||
"db", "pbl_m1b.sqlite3")
|
||
REPORT_DIR = os.path.join(REPO_ROOT, "projects", "pbls", "deliverables", "m1b")
|
||
|
||
|
||
def snapshot(conn):
|
||
"""回读库内注册数据(表存在性 + 行数 + 关键明细)。"""
|
||
out = {"tables": {}, "counts": {}, "details": {}}
|
||
for t in TABLES:
|
||
n = t["name"]
|
||
ready = table_exists(n, conn=conn)
|
||
out["tables"][n] = ready
|
||
if not ready:
|
||
out["counts"][n] = None
|
||
continue
|
||
rows = sql_rows("SELECT COUNT(*) AS c FROM %s" % n, conn=conn)
|
||
out["counts"][n] = int(rows[0]["c"] or 0) if rows else 0
|
||
if out["tables"].get("pbl_ext_field_def"):
|
||
out["details"]["ext_field_defs_by_owner"] = sql_rows(
|
||
"SELECT owner_type, COUNT(*) AS n FROM pbl_ext_field_def "
|
||
"GROUP BY owner_type ORDER BY owner_type", conn=conn)
|
||
out["details"]["ext_field_defs_platform_null_tenant"] = sql_rows(
|
||
"SELECT COUNT(*) AS n FROM pbl_ext_field_def WHERE tenant_id IS NULL",
|
||
conn=conn)
|
||
out["details"]["ext_field_def_sample"] = sql_rows(
|
||
"SELECT owner_type, owner_code, field_key, field_type, required "
|
||
"FROM pbl_ext_field_def ORDER BY owner_type, field_key LIMIT 25",
|
||
conn=conn)
|
||
if out["tables"].get("pbl_blueprint_template"):
|
||
out["details"]["platform_templates"] = sql_rows(
|
||
"SELECT id, code, name, version, status, scope, tenant_id "
|
||
"FROM pbl_blueprint_template WHERE tenant_id IS NULL "
|
||
"ORDER BY code, version", conn=conn)
|
||
out["details"]["tenant_templates_count"] = sql_rows(
|
||
"SELECT COUNT(*) AS n FROM pbl_blueprint_template "
|
||
"WHERE tenant_id IS NOT NULL", conn=conn)
|
||
return out
|
||
|
||
|
||
def run(db_path=None, twice=False, actor_id="m1b_register_sync"):
|
||
"""执行注册同步,返回完整报告 dict。"""
|
||
db_path = db_path or os.environ.get("PBL_M1B_DB") or DEFAULT_DB
|
||
os.environ["PBL_M1B_DB"] = db_path
|
||
os.environ.setdefault("PBL_AUDIT_DIR",
|
||
os.path.join(REPO_ROOT, "projects", "pbls",
|
||
"deliverables", "audit"))
|
||
reset_conn()
|
||
conn = get_conn(db_path)
|
||
log = []
|
||
|
||
def step(name, payload):
|
||
log.append({"step": name, "at": now_str(), "result": payload})
|
||
|
||
r1 = m1b_init.load_m1b(conn=conn, actor_id=actor_id)
|
||
step("load_m1b#1", {"ddl": r1["ddl"], "ext_field_defs": r1["ext_field_defs"],
|
||
"platform_templates": r1["platform_templates"]})
|
||
snap1 = snapshot(conn)
|
||
step("snapshot#1", {"counts": snap1["counts"]})
|
||
|
||
snap2 = None
|
||
if twice:
|
||
r2 = m1b_init.load_m1b(conn=conn, actor_id=actor_id)
|
||
step("load_m1b#2(idempotency)", {
|
||
"ddl": r2["ddl"], "ext_field_defs": r2["ext_field_defs"],
|
||
"platform_templates": r2["platform_templates"]})
|
||
snap2 = snapshot(conn)
|
||
step("snapshot#2", {"counts": snap2["counts"]})
|
||
|
||
flushed, audit_path = flush_memory_audit()
|
||
step("audit_flush", {"records": flushed, "path": audit_path})
|
||
|
||
idempotent = None
|
||
if snap2 is not None:
|
||
idempotent = snap1["counts"] == snap2["counts"]
|
||
step("idempotency_check", {
|
||
"ok": idempotent, "counts_run1": snap1["counts"],
|
||
"counts_run2": snap2["counts"],
|
||
"diff": {k: [snap1["counts"].get(k), snap2["counts"].get(k)]
|
||
for k in snap1["counts"]
|
||
if snap1["counts"].get(k) != snap2["counts"].get(k)} or None})
|
||
|
||
report = {
|
||
"tool": "tools/m1b_register_sync.py",
|
||
"milestone": "M1b",
|
||
"executed_at": now_str(),
|
||
"actor_id": actor_id,
|
||
"db": {"path": db_path,
|
||
"exists": os.path.exists(db_path) if db_path != ":memory:" else False,
|
||
"size_bytes": (os.path.getsize(db_path)
|
||
if db_path != ":memory:" and os.path.exists(db_path)
|
||
else None),
|
||
"dialect": "sqlite",
|
||
"mariadb_ddl": "modules/pbl_blueprint/pbl_blueprint/sql/m1b_ddl.sql"},
|
||
"tables_declared": [t["name"] for t in TABLES],
|
||
"tables_ready": snap1["tables"],
|
||
"row_counts": snap1["counts"],
|
||
"subobject_types_registered": list(SUBOBJECT_TYPES),
|
||
"ext_field_def_seeds": len(m1b_init.EXT_FIELD_SEED),
|
||
"details": snap1["details"],
|
||
"idempotent": idempotent,
|
||
"audit": {"flushed_records": flushed, "path": audit_path},
|
||
"log": log,
|
||
}
|
||
return report
|
||
|
||
|
||
def main(argv=None):
|
||
ap = argparse.ArgumentParser(description="M1b register sync executor")
|
||
ap.add_argument("--db", help="sqlite 库文件路径(默认 deliverables/db/pbl_m1b.sqlite3)")
|
||
ap.add_argument("--twice", action="store_true", help="连跑两次验证幂等")
|
||
ap.add_argument("--report", help="报告输出路径")
|
||
args = ap.parse_args(argv)
|
||
|
||
report = run(db_path=args.db, twice=args.twice)
|
||
out = args.report or os.path.join(REPORT_DIR, "register_sync_report.json")
|
||
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||
with open(out, "w", encoding="utf-8") as fh:
|
||
fh.write(json.dumps(report, ensure_ascii=False, indent=2, default=str) + "\n")
|
||
brief = {
|
||
"ok": True,
|
||
"report": os.path.relpath(out, REPO_ROOT),
|
||
"db": report["db"]["path"],
|
||
"tables_ready": report["tables_ready"],
|
||
"row_counts": report["row_counts"],
|
||
"idempotent": report["idempotent"],
|
||
"audit_records": report["audit"]["flushed_records"],
|
||
}
|
||
print(json.dumps(brief, ensure_ascii=False, indent=2, default=str))
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|