ci/woodpecker/push/main Pipeline was canceled
任务 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
245 lines
10 KiB
Python
245 lines
10 KiB
Python
#!/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())
|