Files
cma-management/backend/scripts/migrate_higher_better_key_entity_20260923.py
T
wecom-fullstack 477515c453
ci/woodpecker/push/main Pipeline was canceled
fix(cma): 隔离扫尾 — kpi_alert_higher_better 拆账套 + tenant/switch 审计收口
任务 dama-entity-isolation-sweep-20260923(DAMA 独立回查漏项 / 任总 2026-09-23「按照建议执行」)

① 补漏同源全局键 kpi_alert_higher_better(system_configs,零 DDL)
   - deviation_engine: 新增 higher_better_key(entity_id);get_higher_better_codes(db, entity_id)
     改为只读本账套键、不回退旧全局键;未配置 → DEFAULT_HIGHER_BETTER(兜底,与原行为逐字一致)
   - budget.py: GET/PUT /alert-direction 挂 get_entity_id,只读写会话账套键;body entity_id 不信
   - scripts/migrate_higher_better_key_entity_20260923.py: --check/--apply/--down + 库名保护 + 幂等
   - 生产实况:库内该键 0 行 ⇒ 无原值可搬运(--apply 走 skip 零写入),e1 行为不变

② kpi_visibility 口径裁定:角色级天然全局 = 非账套维度,不拆(关闭本项)
   - auth_middleware 注释 + docstring 登记「有意不做账套后缀」;口径字典
     docs/global-config-keys-caliber-20260923.md(含 budget_mode/budget_driver_mode 评估结论)

③ tenant/switch 审计收口
   - detail 显式 from_entity_id → to_entity_id + source + operator{id,username,name,role}
   - 审计失败不静默:同事务回滚 + AUDIT_WRITE_FAILED ERROR + HTTP 500(不留「改了但没审计」)
   - GET /current 无写路径不落审计;历史缺口不补写

证据:新增/改写用例 17 条;全量 1397 passed(去新增文件 1380 + 17);RED 态 8 failed
2026-09-23 18:29:52 +08:00

245 lines
10 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 -*-
"""全局键拆账套 — `kpi_alert_higher_better` 一次性搬运(数据迁移,非 DDL)
任务:`dama-entity-isolation-sweep-20260923` §一①(DAMA 独立回查新增漏项,任总 2026-09-23 批)
背景
----
`system_configs.config_key` 为 **unique 单行键** ⇒ `kpi_alert_higher_better` 全库只有一行,
被所有账套共用。消费点(拆键前,读的都是那唯一一行):
backend/app/utils/deviation_engine.py get_higher_better_codes() L32/L37-38
build_deviation_alert() L305
backend/app/api/budget.py GET /alert-direction L1087
PUT /alert-direction L1114-1125
任一套账写一次,**方向判定(实际低于预算才告警 vs 实际超预算才告警)全平台翻转**。
治本(方案 A,零 DDL):键名带账套后缀 → `kpi_alert_higher_better.<entity_id>`
· 读:只读本账套键;本账套未配置 → `DEFAULT_HIGHER_BETTER`(内置默认表,兜底),
**不回退旧全局键**(回退即跨账套借用)。
· 写:`PUT /alert-direction` 只写会话账套键,body 里的 entity_id 不信。
本脚本 = 旧值一次性搬运。
⚠️ 生产实况(2026-09-23 只读核对):**库内无 `kpi_alert_higher_better` 行**
⇒ `--check` 报 `legacy=None`,`--apply` 走 `skip`(无可搬运值),零写入。
即:拆键后各账套行为与拆键前**逐字一致**(都走内置默认表),不存在「其余账套留空
导致行为退化」的问题。若将来出现旧全局行(例如先于本单部署的 PUT 写入过),
本脚本可直接把它落到 `--target-entity`。
用法
----
# 只读预检(默认动作,零写入)
python3 scripts/migrate_higher_better_key_entity_20260923.py --check
# 写库(**需审批 / 生产写**;--apply 才写)
python3 scripts/migrate_higher_better_key_entity_20260923.py --apply --target-entity 1
# 回退(删新键;旧全局键原样保留)
python3 scripts/migrate_higher_better_key_entity_20260923.py --down
退出码
------
0 成功 | 1 执行失败 | 2 用法/保护拒绝(库名不符、目标账套不存在)
⚠️ 本脚本写的是**生产业务库**:不得在开发级会话直接 `--apply`。
依据《生产资产派单纪律》:碰生产资产一律走派单 + 审批;`--check` 同样是生产读,须留单痕。
"""
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import text # noqa: E402
KEY = "kpi_alert_higher_better" # 旧全局键(保留不删)
PROD_DB_NAME = "cma" # 生产库名(保护口径)
DESC = "越高越好型KPI编码列表(实际低于预算才告警)— 账套 {eid}"
def _engine():
from app.database import get_engine
return get_engine()
def _scalar(conn, sql, **params):
row = conn.execute(text(sql), params).fetchone()
return row[0] if row else None
def _detect_db_name(conn) -> str:
try:
return _scalar(conn, "SELECT DATABASE()") or ""
except Exception:
return ""
def _entity_evidence(conn):
"""逐账套只读证据:KPI 定义数 / 预算行数(供「原值该落哪家」人工确认)"""
out = {}
try:
kpi_rows = conn.execute(text(
"SELECT entity_id, COUNT(*) FROM kpi_definitions GROUP BY entity_id ORDER BY entity_id"
)).fetchall()
bud_rows = conn.execute(text(
"SELECT kd.entity_id, COUNT(*) FROM budget_plans bp "
"JOIN kpi_definitions kd ON kd.id = bp.kpi_id GROUP BY kd.entity_id ORDER BY kd.entity_id"
)).fetchall()
kpi = {int(r[0]) for r in kpi_rows if r[0] is not None}
bud = {int(r[0]): int(r[1]) for r in bud_rows if r[0] is not None}
for eid in sorted(kpi | set(bud)):
out[eid] = {"budget_plans": bud.get(eid, 0)}
except Exception as e: # 表缺失/权限 → 只报告,不阻断
out["_error"] = str(e)[:300]
return out
def _snapshot(conn):
snap = {
"db": _detect_db_name(conn),
"legacy_key": KEY,
"legacy": None,
"new_keys": {},
"entities": [],
"entity_evidence": _entity_evidence(conn),
"suggested_target_entity": None,
}
row = conn.execute(text(
"SELECT config_value, description, updated_at FROM system_configs WHERE config_key = :k"
), {"k": KEY}).fetchone()
if row:
snap["legacy"] = {"config_value": row[0], "description": row[1], "updated_at": str(row[2])}
for r in conn.execute(text(
"SELECT id, name, short_name, status FROM entities ORDER BY id"
)).fetchall():
snap["entities"].append({"id": int(r[0]), "name": r[1], "short_name": r[2], "status": r[3]})
for r in conn.execute(text(
"SELECT config_key, config_value FROM system_configs WHERE config_key LIKE :p ORDER BY config_key"
), {"p": KEY + ".%"}).fetchall():
snap["new_keys"][r[0]] = r[1]
# 「原值落哪家」:旧全局值唯一时,若只有一家账套是 active 生产账套 → 建议它;
# 多 active 账套(e1 酣客 + e2 博海)⇒ 不给自动建议,需人工裁定(写进报告)。
active = [e["id"] for e in snap["entities"] if e["status"] == "active"]
if snap["legacy"] and len(active) == 1:
snap["suggested_target_entity"] = active[0]
return snap
def cmd_check(args):
eng = _engine()
with eng.connect() as conn:
snap = _snapshot(conn)
if args.json:
print(json.dumps(snap, ensure_ascii=False, indent=2))
return 0
print(f"目标库: {snap['db']}")
print(f"旧全局键 {KEY} : {snap['legacy']}")
print(f"已存在新键: {snap['new_keys']}")
print("逐账套只读证据(预算行数):")
for eid, info in snap["entity_evidence"].items():
print(f" entity {eid}: {info}")
print(f"建议 --target-entity : {snap['suggested_target_entity']}"
f"(None = 无旧值 或 多 active 账套需人工裁定)")
print("【只读预检,零写入】")
return 0
def _apply(conn, target: int, force: bool):
new_key = f"{KEY}.{target}"
row = conn.execute(text(
"SELECT config_value FROM system_configs WHERE config_key = :k"
), {"k": KEY}).fetchone()
if not row:
return {"key": new_key, "action": "skip", "reason": "旧全局键不存在(无可搬运值)"}
exist = conn.execute(text(
"SELECT config_value FROM system_configs WHERE config_key = :k"
), {"k": new_key}).fetchone()
if exist and not force:
return {"key": new_key, "action": "noop",
"reason": "新键已存在且未 --force(保留既有值,幂等)",
"existing_value": exist[0]}
if exist:
conn.execute(text(
"UPDATE system_configs SET config_value = :v, description = :d WHERE config_key = :k"
), {"v": row[0], "d": DESC.format(eid=target), "k": new_key})
return {"key": new_key, "action": "updated", "value": row[0]}
conn.execute(text(
"INSERT INTO system_configs (config_key, config_value, description) VALUES (:k, :v, :d)"
), {"k": new_key, "v": row[0], "d": DESC.format(eid=target)})
return {"key": new_key, "action": "inserted", "value": row[0]}
def _guard_db(args, conn):
db = _detect_db_name(conn)
if args.allow_db:
return db
if db != PROD_DB_NAME:
print(f"[拒绝] 库名 {db!r} ≠ 生产库 {PROD_DB_NAME!r};"
f"如确为预期,请显式加 --allow-db {db}", file=sys.stderr)
sys.exit(2)
return db
def cmd_apply(args):
eng = _engine()
with eng.begin() as conn:
db = _guard_db(args, conn)
ent = conn.execute(text("SELECT id FROM entities WHERE id = :i"),
{"i": args.target_entity}).fetchone()
if not ent:
print(f"[拒绝] 目标账套 entity_id={args.target_entity} 不存在", file=sys.stderr)
sys.exit(2)
results = [_apply(conn, args.target_entity, args.force)]
out = {"mode": "apply", "db": db, "target_entity": args.target_entity, "results": results}
print(json.dumps(out, ensure_ascii=False, indent=2) if args.json else
"\n".join(f" {r}" for r in results))
return 0
def cmd_down(args):
"""回退:删除新键(旧全局键原样保留,老代码即可继续读)"""
eng = _engine()
with eng.begin() as conn:
db = _guard_db(args, conn)
r = conn.execute(text("DELETE FROM system_configs WHERE config_key LIKE :p"),
{"p": KEY + ".%"})
removed = [f"system_configs({KEY}.*) deleted={r.rowcount}"]
print(json.dumps({"mode": "down", "db": db, "removed": removed},
ensure_ascii=False, indent=2) if args.json else "\n".join(removed))
print("旧全局键未改动 → 代码回退到老版本即可继续读旧键。")
return 0
def main():
p = argparse.ArgumentParser(
description="全局键拆账套 · 一次性搬运(kpi_alert_higher_better)")
g = p.add_mutually_exclusive_group()
g.add_argument("--check", action="store_true", help="只读预检(默认)")
g.add_argument("--apply", action="store_true", help="写库(需审批)")
g.add_argument("--down", action="store_true", help="回退:删除新键")
p.add_argument("--target-entity", type=int, default=1,
help="旧值落到哪个账套(默认 1 = 陕西酣客,当前生产账套)")
p.add_argument("--force", action="store_true", help="允许覆盖已存在的新键")
p.add_argument("--json", action="store_true", help="机器可读输出")
p.add_argument("--allow-db", default=None, help="生产库名保护豁免(默认仅允许 cma)")
args = p.parse_args()
if args.apply:
return cmd_apply(args)
if args.down:
return cmd_down(args)
return cmd_check(args)
if __name__ == "__main__":
sys.exit(main())