Files
2026-09-22 16:08:12 +08:00

280 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""对**真实 PostgreSQL 服务器**做台账契约一致性校验。
为什么需要它:本工具开发机上没有任何 PostgreSQL(无服务、无 Docker),因此
PG 后端的真实读写语义无法在开发环境里验证。这个脚本让你**在自己的库上**
一次性验完,而且:
* **全程在一个事务里,最后 ``ROLLBACK``** —— 连建表都在事务内,
对你的真实库**零残留**(表本来存在就什么都不变);
* 用**同一套契约**分别跑本地 SQLite 与 PostgreSQL,然后**逐项对比**,
这样"两个后端行为不一致"会直接暴露成 FAIL,而不是等同事用出问题才发现;
* 写入的记录 ``lic_id`` 一律带 ``PGCHECK-`` 前缀,且**所有观测值都只统计这些
记录** —— 库里已有的真实台账既不参与对比,也不会被打印出来;
* 不会改动你的任何真实数据(验证结束后事务回滚)。
用法::
# 1) 显式参数(密码走环境变量或交互输入,避免留在命令历史里)
uv run python tools/pg_check.py --host 127.0.0.1 --port 5432 \\
--dbname chongming_licenses --user license_ro
# 2) 或用 DSN
uv run python tools/pg_check.py --dsn "postgresql://license_ro@127.0.0.1:5432/chongming_licenses"
# 密码来源:环境变量 PGPASSWORD 或交互式提问(推荐);
# 也支持 --password(会留在命令历史里,仅用于自动化)
退出码 0 = 两个后端行为一致。
"""
from __future__ import annotations
import argparse
import getpass
import os
import sys
import tempfile
from datetime import timedelta
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "src"))
from chongming_license import ( # noqa: E402
Registry,
build_license,
build_payload,
generate_keypair,
machine_code,
utc_today,
write_license,
)
from chongming_license_gui import pg_store # noqa: E402
from chongming_license_gui.pg_store import ( # noqa: E402
DEFAULT_DBNAME,
DEFAULT_HOST,
DEFAULT_PORT,
DEFAULT_SCHEMA,
DEFAULT_SSLMODE,
SSL_MODES,
PgConfig,
PostgresRegistry,
)
PREFIX = "PGCHECK-"
STATUSES = ("expiring", "forever", "active", "expired", "not_yet_valid")
failures: list[str] = []
def check(name: str, ok: bool, detail: str = "") -> None:
print(f"[{'PASS' if ok else 'FAIL'}] {name}" + (f" — {detail}" if detail else ""))
if not ok:
failures.append(name)
def _mine(records) -> list[str]:
"""只保留本次校验写入的记录(真实台账不参与对比、也不被打印)。"""
return [r.lic_id for r in records if r.lic_id.startswith(PREFIX)]
# ---------------------------------------------------------------------------
# 契约:在任意后端上跑同一套操作,返回"可比较的观测值"
# ---------------------------------------------------------------------------
def _demo_docs(sandbox: Path) -> list[dict]:
"""两条用于校验的授权:一条即将到期、一条永久(覆盖排序哨兵与状态分桶)。
客户名刻意混入 ASCII(``PGCheck``),用来验证"大小写不敏感"这个方言差异点。
"""
private_key, _ = generate_keypair()
today = utc_today()
sources = {
"board_serial": "pgcheckboard01",
"product_uuid": "pgcheckproductuuid0123456789ab",
"mac": "001b44pgcheck",
"machine_id": "pgcheckmachineid0123456789abcdef",
}
specs = [
(f"{PREFIX}EXPIRING", "PGCheck 契约校验(示例)", ["aibox_sophon_adapter"],
(today + timedelta(days=20)).isoformat()),
(f"{PREFIX}FOREVER", "PGCheck 永久(示例)",
["aibox_sophon_adapter", "ip_sound_column"], None),
]
docs = []
for lic_id, customer, plugins, expires in specs:
payload = build_payload(
machine_code=machine_code(sources), plugins=plugins, sources=sources,
customer=customer, lic_id=lic_id, expires_at=expires, note="载荷备注",
)
doc = build_license(payload, private_key, kid="pg-check")
write_license(doc, sandbox / f"{lic_id}.lic")
docs.append(doc)
return docs
def contract(reg, docs: list[dict], sandbox: Path) -> dict:
"""同一套操作 → 观测值。两边结果必须逐项相同。"""
ids = [d["payload"]["lic_id"] for d in docs]
mine = lambda records: _mine(records) # noqa: E731 - 短小,读起来更直观
obs: dict[str, object] = {}
for doc in docs:
reg.save(doc, public_key_fp="PGCHECK-FP", source="pg-check")
# 排序:到期日升序,永久(NULL)用哨兵排最后
obs["顺序(到期升序/永久最后)"] = mine(reg.query())
obs["客户模糊检索"] = mine(reg.query(customer="永久"))
obs["客户检索大小写不敏感"] = mine(reg.query(customer="pgcheck"))
obs["插件精确检索"] = sorted(mine(reg.query(plugin="ip_sound_column")))
obs["关键字检索"] = sorted(mine(reg.query(keyword="契约校验")))
obs["状态筛选(永久)"] = mine(reg.query(status="forever"))
obs["状态分桶"] = {s: len(mine(reg.query(status=s))) for s in STATUSES}
rec = reg.get(ids[0])
obs["get 命中"] = rec.lic_id if rec else None
obs["机器码已大写"] = bool(rec) and rec.machine_code == rec.machine_code.upper()
obs["来源指纹回读一致"] = bool(rec) and rec.sources == docs[0]["payload"]["sources"]
# 人工批注:手工改过之后,重新入库不得被载荷备注冲掉
obs["update_note 返回值"] = reg.update_note(ids[0], "人工批注")
reg.save(docs[0], public_key_fp="PGCHECK-FP", source="renew")
obs["重新入库后仍是人工批注"] = reg.get(ids[0]).note
# 首次入库时间要保留(库的 SQLite 版把 created_at 排除在 DO UPDATE 之外)
created_before = reg.get(ids[0]).created_at
reg.save(docs[0], public_key_fp="PGCHECK-FP", source="renew")
obs["重新入库后 created_at 不变"] = reg.get(ids[0]).created_at == created_before
# 导出:文件名规则、CSV 表头与编码(这些方法是从库继承的,两边应完全一致)
my_records = [r for r in reg.query() if r.lic_id.startswith(PREFIX)]
out = sandbox / "export"
obs["导出 .lic 文件名"] = sorted(p.name for p in reg.export_licenses(out, my_records))
csv_path = reg.export_csv(sandbox / "台账.csv", my_records)
raw = csv_path.read_bytes()
obs["CSV 带 BOM"] = raw[:3] == b"\xef\xbb\xbf"
lines = csv_path.read_text(encoding="utf-8-sig").splitlines()
obs["CSV 行数(含表头)"] = len(lines)
obs["CSV 表头列数"] = len(lines[0].split(",")) if lines else 0
obs["delete 返回值"] = reg.delete(ids[1])
obs["删除后剩余"] = mine(reg.query())
return obs
# ---------------------------------------------------------------------------
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="在真实 PostgreSQL 上校验台账后端与本地 SQLite 行为一致",
)
parser.add_argument("--dsn",
help="连接串(postgresql://用户@主机:端口/库名);给了它就不用下面各项")
parser.add_argument("--host", default=DEFAULT_HOST)
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"默认 {DEFAULT_PORT}")
parser.add_argument("--dbname", default=DEFAULT_DBNAME)
parser.add_argument("--user", default="")
parser.add_argument("--password", default=None,
help="不建议:会留在命令历史里。优先用 PGPASSWORD 或交互输入")
parser.add_argument("--sslmode", default=DEFAULT_SSLMODE, choices=list(SSL_MODES))
parser.add_argument("--schema", default=DEFAULT_SCHEMA)
return parser.parse_args()
def resolve_config(args: argparse.Namespace) -> tuple[PgConfig, str]:
"""返回 (配置, 密码)。密码优先级:--password > DSN 内 > PGPASSWORD。"""
if args.dsn:
# 连接串解析交给 pg_store(进而交给 psycopg),避免自己写一套连接串文法
config = PgConfig.from_dsn(args.dsn, schema=args.schema)
password = config.password
else:
config = PgConfig(host=args.host, port=args.port, dbname=args.dbname,
user=args.user, sslmode=args.sslmode, schema=args.schema)
password = ""
if args.password:
password = args.password
return config, password or os.environ.get("PGPASSWORD", "")
def main() -> int:
args = parse_args()
try:
config, password = resolve_config(args)
except ImportError:
print("未安装 PostgreSQL 驱动:uv sync --extra postgres")
return 2
if not config.dbname:
print("请指定数据库名(--dbname 或 --dsn)")
return 2
if not password:
password = getpass.getpass(f"{config.user or 'postgres'}@{config.host} 的密码(不显示):")
print("=" * 70)
print("台账契约一致性校验:本地 SQLite vs PostgreSQL")
print(f"目标:{config.display()}") # 展示串不含密码
print("说明:所有写入都在一个事务里,结束时 ROLLBACK;")
print(" 观测值只统计本次的 PGCHECK-* 记录,你的真实台账不参与、也不会被打印。")
print("=" * 70)
sandbox = Path(tempfile.mkdtemp(prefix="clg-pgcheck-"))
docs = _demo_docs(sandbox)
# ---- 1) 本地 SQLite 基准 ----
print("\n--- 本地 SQLite(基准)---")
sqlite_sandbox = sandbox / "sqlite"
sqlite_sandbox.mkdir(parents=True, exist_ok=True)
with Registry(sqlite_sandbox / "ledger.db") as sqlite_reg:
sqlite_obs = contract(sqlite_reg, docs, sqlite_sandbox)
for key, value in sqlite_obs.items():
print(f" {key} = {value}")
# ---- 2) PostgreSQL(一个事务,最后回滚)----
print("\n--- PostgreSQL(事务内执行,结束回滚)---")
pg_sandbox = sandbox / "pg"
pg_sandbox.mkdir(parents=True, exist_ok=True)
try:
reg = PostgresRegistry(config, password=password, autocommit=False)
except Exception as exc: # noqa: BLE001
print(f"[FAIL] 连接失败:{exc}")
return 1
try:
pg_obs = contract(reg, docs, pg_sandbox)
except Exception as exc: # noqa: BLE001 - 把差异当成校验失败而不是崩溃
print(f"[FAIL] 在 PostgreSQL 上执行契约时出错:{type(exc).__name__}: {exc}")
reg._conn.rollback()
reg.close()
return 1
# ---- 3) 逐项对比 ----
print("\n--- 逐项对比(SQLite 基准 vs PostgreSQL)---")
for key, expected in sqlite_obs.items():
actual = pg_obs.get(key)
check(key, actual == expected, "" if actual == expected
else f"SQLite={expected!r} PG={actual!r}")
# ---- 4) 回滚并确认没有残留 ----
reg._conn.rollback()
table = f'"{config.schema}"."{pg_store.TABLE}"'
try:
row = reg._conn.execute(
f"SELECT count(*) AS n FROM {table} WHERE lic_id LIKE '{PREFIX}%'"
).fetchone()
residue = row["n"] if row else 0
print(f"\n回滚完成:库里残留的校验记录数 = {residue}")
if residue:
print("[FAIL] 残留了校验记录 —— 这不该发生")
failures.append("回滚后有残留")
except Exception as exc: # noqa: BLE001
# 表本来不存在 → 连建表一起被回滚了,这正是期望结果
print(f"\n回滚完成:表不存在(建表也随事务一起回滚)—— {type(exc).__name__}")
reg.close()
print("\n" + "=" * 70)
if failures:
print(f"❌ {len(failures)} 项在两个后端之间不一致:{failures}")
print(" 请把上面的差异贴给研发 —— 这正是本脚本存在的意义。")
return 1
print("✅ PostgreSQL 后端与本地 SQLite 行为完全一致(且已在事务中回滚,无残留)")
return 0
if __name__ == "__main__":
sys.exit(main())