#!/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())