Files
cma-management/backend/scripts/seed_dq_rule_config.py
T
wecom-fullstack 36f4f54799
ci/woodpecker/push/main Pipeline failed
feat(data-quality): 财务七规则可配置化最小实现(ASK-20260928-05)
任总 2026-09-28 13:38 放行「4 和 5」→ 落笔最小实现:
- 新表 quality_rule_config(39 项:37 规则级 + 2 全局)= 唯一配置源
- 三层解析:账套行 → 全账套模板(entity_id=0) → 代码内置默认(=清单 §7 建议值,零行为差异)
- 端点 /api/cma/data-quality/rule-config(GET/PUT)+ /history + /meta:写入仅 ceo/it,
  platform 级仅 it,演示账套(functional)写禁,变更落 operation_logs 同事务
- 数据质量页新增「规则配置」只读 Tab(懒加载)
- 唯一行为变更:污染判定去掉 sync —— 酣客 zxt-sync 系统迁入数据不再被误判(口径可配置)
- 出厂默认落库脚本 scripts/seed_dq_rule_config.py(--check/--apply/--rollback,幂等 39/0)
- 回归:新套件 35 例 + 存量 1508 passed(唯一 FAILED 为他人未完成
  migrate_subjects_entity_id_20260923.py 改动所致,HEAD 干净树同例通过)

生产写(DDL/seed)与 push 均走审批门:DDL=54434656 push=3bd00774
2026-09-28 13:56:16 +08:00

227 lines
11 KiB
Python
Raw 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.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""`quality_rule_config` 出厂默认值落库(全账套模板行 entity_id=0,is_default=1)
任务:ASK-20260928-05(CMA 财务七规则可配置化 · 最小实现),清单 §8「初始化」条目。
**本脚本是可选的**:读路径三层解析的最后一层就是**代码内置默认**
(`app/dq_rule_config.py` 的 SPEC/GLOBAL_SPEC,与清单 §7 建议默认值逐项一致),
因此**不落库也能上线且行为相同**(零行为差异)。落库的价值只有两个:
1. 让「全账套模板」显式可见(UI 的「来源」列由 `内置默认` 变成 `全账套模板`);
2. 给「恢复出厂默认 / 当前值 vs 默认值 diff」留一行可审计的锚(`is_default=1`)。
幂等契约
--------
首跑 --apply:created = 39(缺失项),skipped = 0
二跑 --apply:created = 0,skipped = 39,零副作用(**不覆盖**已存在的行)
已存在且值与默认**不同** → 只报告 diff(`differs`),**不覆盖**(`--force` 才覆盖)
边界
----
* 生产写:需要在**审批门**放行后执行(`approval-gate`);不改表结构(DDL 由
`backend/scripts/db_migrate.py --apply` 的部署链特权步负责)。
* 连接:与 db_migrate 同口径 —— `--db-url` > `CMA_MIGRATE_DB_USER/PASS`(TCP)> 本机 root socket;
显式拒绝应用运行账号(`cma_api`)。
* 回滚:`--rollback latest` 只删本次 --apply 记录在 journal 里的行 id(零 `DELETE` 全表)。
用法
----
python3 scripts/seed_dq_rule_config.py --check # 只读预检(默认动作)
python3 scripts/seed_dq_rule_config.py --apply # 落默认行 + 写 journal
python3 scripts/seed_dq_rule_config.py --apply --force # 已存在且不同的行也覆盖(慎用)
python3 scripts/seed_dq_rule_config.py --rollback latest
"""
import argparse
import json
import os
import socket
import sys
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import create_engine, text # noqa: E402
HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT_JOURNAL_DIR = os.path.join(HERE, "backups", "dq-rule-config-seed-journal")
DEFAULT_SOCKET = "/var/run/mysqld/mysqld.sock"
DEFAULT_DB = "cma"
FORBIDDEN_RUN_ACCOUNTS = {"cma_api"}
TABLE = "quality_rule_config"
def _db_name(args) -> str:
return (args.db_name or os.getenv("CMA_MIGRATE_DB_NAME")
or os.getenv("CMA_DB_NAME") or DEFAULT_DB)
def build_engine(args):
db_name = _db_name(args)
if args.db_url:
url = args.db_url if "/" in args.db_url.rsplit("@", 1)[-1] else args.db_url.rstrip("/") + "/" + db_name
return create_engine(url, pool_pre_ping=True), f"db-url({url})"
user = os.getenv("CMA_MIGRATE_DB_USER", "").strip()
if user:
if user in FORBIDDEN_RUN_ACCOUNTS:
raise SystemExit(f"[FAIL] {user} 是应用运行账号,不得用于本脚本;请用专用账号或 root socket。")
pwd = os.getenv("CMA_MIGRATE_DB_PASS", "")
host = os.getenv("CMA_MIGRATE_DB_HOST", "127.0.0.1")
port = os.getenv("CMA_MIGRATE_DB_PORT", "3306")
url = f"mysql+pymysql://{user}:{pwd}@{host}:{port}/{db_name}?charset=utf8mb4"
return create_engine(url, pool_pre_ping=True), f"tcp({user}@{host}:{port}/{db_name})"
sock = args.socket or os.getenv("CMA_MIGRATE_DB_SOCKET") or DEFAULT_SOCKET
if not os.path.exists(sock):
raise SystemExit(f"[FAIL] 未设 CMA_MIGRATE_DB_USER 且 socket 不存在:{sock}")
url = f"mysql+pymysql://root@localhost/{db_name}?unix_socket={sock}&charset=utf8mb4"
return create_engine(url, pool_pre_ping=True), f"socket(root@{sock}/{db_name})"
def _defaults():
"""(rule_key, param_key, value, value_type, open_level, desc) 全量默认 —— 单一来源 = app/dq_rule_config。"""
from app import dq_rule_config as dqcfg
out = []
for rule in dqcfg.RULE_KEYS:
for param, spec in dqcfg.SPEC[rule].items():
out.append((rule, param, spec.get("default"), spec.get("type"),
spec.get("open_level"), spec.get("desc")))
for param, spec in dqcfg.GLOBAL_SPEC.items():
out.append((dqcfg.GLOBAL_RULE_KEY, param, spec.get("default"), spec.get("type"),
spec.get("open_level"), spec.get("desc")))
# 规则级 detail_limit 的出厂值 = 全局 detail_limit(内建默认口径)
return out
def _journal_dir() -> str:
d = os.getenv("CMA_DQ_SEED_JOURNAL_DIR") or DEFAULT_JOURNAL_DIR
os.makedirs(d, exist_ok=True)
return d
def cmd_check(args, engine, conn_desc) -> int:
rows = _defaults()
missing, same, differs = [], [], []
with engine.connect() as conn:
if not conn.dialect.has_table(conn, TABLE):
print(f"[check] 连接 {conn_desc} → 表 {TABLE} **不存在**:"
f"请先跑 `backend/scripts/db_migrate.py --apply`(D 部署链特权迁移步)。")
return 0
for rule, param, value, vtype, level, desc in rows:
got = conn.execute(text(
f"SELECT param_value FROM {TABLE} WHERE entity_id=0 AND rule_key=:r AND param_key=:p"),
{"r": rule, "p": param}).scalar()
if got is None:
missing.append((rule, param, value))
elif json.loads(got) == value:
same.append((rule, param))
else:
differs.append((rule, param, json.loads(got), value))
print(f"[check] 连接 {conn_desc}")
print(f"[check] 待插入 {len(missing)} / 已一致 {len(same)} / 值不同 {len(differs)}(共 {len(rows)} 项)")
for rule, param, value in missing[:10]:
print(f" + {rule}.{param} = {value}")
for rule, param, have, want in differs:
print(f" ! {rule}.{param} 库内={have} 默认={want}(不加 --force 不覆盖)")
return 0
def cmd_apply(args, engine, conn_desc) -> int:
rows = _defaults()
created, skipped, differs, failed = [], [], [], []
with engine.begin() as conn:
if not conn.dialect.has_table(conn, TABLE):
print(f"[FAIL] 表 {TABLE} 不存在 —— 先跑 db_migrate.py --apply")
return 1
for rule, param, value, vtype, level, desc in rows:
payload = json.dumps(value, ensure_ascii=False)
cur = conn.execute(text(
f"SELECT id, param_value FROM {TABLE} WHERE entity_id=0 AND rule_key=:r AND param_key=:p"),
{"r": rule, "p": param}).fetchone()
if cur is None:
res = conn.execute(text(
f"INSERT INTO {TABLE} (entity_id, rule_key, param_key, param_value, value_type,"
f" open_level, is_default, description, updated_by)"
f" VALUES (0, :r, :p, :v, :t, :l, 1, :d, 'seed:ASK-20260928-05')"),
{"r": rule, "p": param, "v": payload, "t": vtype, "l": level, "d": (desc or "")[:500]})
created.append({"id": int(res.lastrowid), "rule_key": rule, "param_key": param,
"value": value})
elif json.loads(cur[1]) == value:
skipped.append((rule, param))
elif args.force:
conn.execute(text(f"UPDATE {TABLE} SET param_value=:v, is_default=1,"
f" updated_by='seed:ASK-20260928-05' WHERE id=:i"),
{"v": payload, "i": cur[0]})
failed.append({"id": int(cur[0]), "rule_key": rule, "param_key": param,
"before": json.loads(cur[1]), "after": value, "forced": True})
else:
differs.append((rule, param, json.loads(cur[1]), value))
payload = {
"action": "seed_dq_rule_config", "status": "applied",
"at": datetime.now().isoformat(timespec="seconds"),
"connection": conn_desc, "db_name": _db_name(args),
"created": created, "created_count": len(created),
"skipped_count": len(skipped), "differs": differs, "forced": failed,
"actor": f"{os.getenv('USER', 'unknown')}@{socket.gethostname()}",
}
# 文件名带微秒:同一秒内连跑两次不会互相覆盖(离线回归实证)
path = os.path.join(_journal_dir(), f"{datetime.now().strftime('%Y%m%dT%H%M%S-%f')}-applied.json")
with open(path, "w", encoding="utf-8") as fh:
json.dump(payload, fh, ensure_ascii=False, indent=2)
print(f"[apply] 连接 {conn_desc}")
print(f"[apply] 新增 {len(created)} / 跳过(已一致) {len(skipped)} / 值不同未覆盖 {len(differs)}"
f" / 强制覆盖 {len(failed)}")
for rule, param, have, want in differs:
print(f" ! {rule}.{param} 库内={have} ≠ 默认={want}(未覆盖)")
print(f"[apply] journal: {path}")
return 0
def cmd_rollback(args, engine, conn_desc) -> int:
d = _journal_dir()
ref = args.rollback
if ref in ("latest", ""):
cands = sorted(f for f in os.listdir(d) if f.endswith("-applied.json"))
if not cands:
raise SystemExit("[FAIL] 没有可回滚的 journal")
path = os.path.join(d, cands[-1])
else:
path = ref if os.path.exists(ref) else os.path.join(d, os.path.basename(ref))
payload = json.load(open(path, encoding="utf-8"))
ids = [c["id"] for c in payload.get("created", [])]
print(f"[rollback] journal={os.path.basename(path)} 将删除 {len(ids)} 行(仅本次新增的 id)")
if not ids:
print("[rollback] 无新增行,零动作")
return 0
with engine.begin() as conn:
res = conn.execute(text(f"DELETE FROM {TABLE} WHERE id IN ({','.join(str(int(i)) for i in ids)})"))
print(f"[rollback] deleted={res.rowcount}(连接 {conn_desc})")
return 0
def main() -> int:
ap = argparse.ArgumentParser(description="quality_rule_config 出厂默认值落库(ASK-20260928-05)")
g = ap.add_mutually_exclusive_group()
g.add_argument("--check", action="store_true", help="只读预检(默认动作)")
g.add_argument("--apply", action="store_true", help="落默认行 + journal")
g.add_argument("--rollback", metavar="REF", nargs="?", const="latest",
help="回滚最近一次 --apply 新增的行(REF=latest 或 journal 文件名)")
ap.add_argument("--force", action="store_true", help="已存在且值不同时也覆盖(慎用)")
ap.add_argument("--json", action="store_true")
ap.add_argument("--socket", default=None)
ap.add_argument("--db-url", default=None)
ap.add_argument("--db-name", default=None)
args = ap.parse_args()
engine, desc = build_engine(args)
if args.apply:
return cmd_apply(args, engine, desc)
if args.rollback is not None:
return cmd_rollback(args, engine, desc)
return cmd_check(args, engine, desc)
if __name__ == "__main__":
sys.exit(main())