fix(cma): 隔离扫尾 — kpi_alert_higher_better 拆账套 + tenant/switch 审计收口
ci/woodpecker/push/main Pipeline was canceled
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
This commit is contained in:
+24
-11
@@ -1079,17 +1079,24 @@ def get_deviation_alert_attribution(
|
||||
@router.get("/alert-direction")
|
||||
def get_alert_direction(
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""越高越好型KPI方向配置 (P2-⑤ 2026-08-28: system_configs 可维护)"""
|
||||
from app.utils.deviation_engine import get_higher_better_codes, CONFIG_KEY_HIGHER_BETTER
|
||||
"""越高越好型KPI方向配置(**按会话账套**;2026-09-23 拆账套,零 DDL)
|
||||
|
||||
与 B1 `cash.current_balance` 同款:只读本账套键 `kpi_alert_higher_better.<entity_id>`,
|
||||
本账套未配置 → 走 deviation_engine 内置默认表(兜底,不跨账套借用)。
|
||||
"""
|
||||
from app.utils.deviation_engine import get_higher_better_codes, higher_better_key
|
||||
from app.models import SystemConfig
|
||||
|
||||
key = higher_better_key(entity_id)
|
||||
cfg = db.query(SystemConfig).filter(
|
||||
SystemConfig.config_key == CONFIG_KEY_HIGHER_BETTER
|
||||
SystemConfig.config_key == key
|
||||
).first()
|
||||
codes = get_higher_better_codes(db)
|
||||
codes = get_higher_better_codes(db, entity_id)
|
||||
return {
|
||||
"config_key": CONFIG_KEY_HIGHER_BETTER,
|
||||
"config_key": key,
|
||||
"entity_id": entity_id,
|
||||
"codes": codes,
|
||||
"is_configured": bool(cfg and cfg.config_value),
|
||||
}
|
||||
@@ -1100,10 +1107,15 @@ def update_alert_direction(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""维护越高越好型KPI方向配置 (P2-⑤) body: {codes: ["SALES_TOTAL", ...]}"""
|
||||
"""维护越高越好型KPI方向配置 (P2-⑤) body: {codes: ["SALES_TOTAL", ...]}
|
||||
|
||||
账套隔离(2026-09-23):只写本账套键 `kpi_alert_higher_better.<entity_id>`;
|
||||
body 里的 `entity_id` 一律不信(会话账套为准,同 B1 口径)。
|
||||
"""
|
||||
import json as _json
|
||||
from app.utils.deviation_engine import CONFIG_KEY_HIGHER_BETTER
|
||||
from app.utils.deviation_engine import higher_better_key
|
||||
from app.models import SystemConfig
|
||||
|
||||
codes = data.get("codes")
|
||||
@@ -1111,19 +1123,20 @@ def update_alert_direction(
|
||||
raise HTTPException(400, "codes 必须是非空数组")
|
||||
codes = [str(c) for c in codes]
|
||||
|
||||
key = higher_better_key(entity_id)
|
||||
cfg = db.query(SystemConfig).filter(
|
||||
SystemConfig.config_key == CONFIG_KEY_HIGHER_BETTER
|
||||
SystemConfig.config_key == key
|
||||
).first()
|
||||
if cfg:
|
||||
cfg.config_value = _json.dumps(codes, ensure_ascii=False)
|
||||
else:
|
||||
db.add(SystemConfig(
|
||||
config_key=CONFIG_KEY_HIGHER_BETTER,
|
||||
config_key=key,
|
||||
config_value=_json.dumps(codes, ensure_ascii=False),
|
||||
description="越高越好型KPI编码列表(实际低于预算才告警)",
|
||||
description=f"越高越好型KPI编码列表(实际低于预算才告警)— 账套 {int(entity_id)}",
|
||||
))
|
||||
db.commit()
|
||||
return {"success": True, "config_key": CONFIG_KEY_HIGHER_BETTER, "codes": codes}
|
||||
return {"success": True, "config_key": key, "entity_id": entity_id, "codes": codes}
|
||||
|
||||
|
||||
@router.put("/deviation-alerts/{alert_id}")
|
||||
|
||||
@@ -24,6 +24,15 @@
|
||||
无 `entity_id` 列,无需 DDL)。两字面**刻意不合并**:一个改「用户自己的会话账套」、
|
||||
一个改「平台全局状态记录」,合并后审计上不可分辨。口径登记见
|
||||
`docs/audit-and-notification-caliber-20260922.md`。
|
||||
7. **审计缺口的收口(2026-09-23 dama-entity-isolation-sweep-20260923 §二)**:
|
||||
- `detail` 显式给出 `from_entity_id → to_entity_id`(原先只有 `previous` 整包,
|
||||
要人肉解析才知道「从哪来」)+ `source` + `operator`(id/username/role)。
|
||||
- **审计失败不静默**:状态键与审计行走**同一事务**,审计写入异常 → `rollback()`
|
||||
(状态变更一并回滚,绝不留「改了但没审计」的窗口)+ `logger.error(AUDIT_WRITE_FAILED…)`
|
||||
+ HTTP 500。日志前缀 `AUDIT_WRITE_FAILED` 供运维 grep/告警链订阅。
|
||||
- `GET /current` **无写路径**(纯只读,且已限 it/ceo),故不落审计——不给读操作
|
||||
写审计表,避免审计面被读流量稀释。
|
||||
- 历史缺口(2026-09-22 止血操作无审计行)**不补写**:审计不得事后伪造。
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
@@ -32,6 +41,10 @@ from app.database import get_db
|
||||
from app.auth_middleware import require_auth_401, require_role
|
||||
from app.models import SystemConfig, Entity, OperationLog
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("cma.tenant")
|
||||
|
||||
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
|
||||
# 缺口:本 router 原无鉴权——无 token 可读当前租户、并可 POST /switch 改写
|
||||
# system_config.current_tenant(状态改写面)。收口为 require_auth_401。
|
||||
@@ -82,11 +95,14 @@ def switch_tenant(data: TenantSwitchRequest, db: Session = Depends(get_db),
|
||||
raise HTTPException(404, "企业不存在")
|
||||
|
||||
import json
|
||||
from_entity_id = None
|
||||
previous = None
|
||||
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == TENANT_KEY).first()
|
||||
if cfg and cfg.config_value:
|
||||
try:
|
||||
previous = json.loads(cfg.config_value)
|
||||
if isinstance(previous, dict):
|
||||
from_entity_id = previous.get("entity_id")
|
||||
except Exception:
|
||||
previous = None
|
||||
|
||||
@@ -112,19 +128,41 @@ def switch_tenant(data: TenantSwitchRequest, db: Session = Depends(get_db),
|
||||
# 2026-09-22 DAMA 回查 §4b 后补:user_id 记操作人(原 None ⇒ 无法追人);
|
||||
# target_type/target_id 与 /auth/switch-entity 的 switch_entity 对齐 ⇒ 按账套可过滤
|
||||
# (operation_logs 无 entity_id 列,无须 DDL)。
|
||||
# 2026-09-23 收口:detail 显式 from→to / source / operator;审计失败不静默(见 §7)。
|
||||
audit_detail = {
|
||||
"from_entity_id": from_entity_id,
|
||||
"to_entity_id": data.entity_id,
|
||||
"entity_id": data.entity_id, # 兼容既有消费方/用例(= to)
|
||||
"entity_name": ent.name,
|
||||
"source": data.source,
|
||||
"operator": {
|
||||
"user_id": user.id,
|
||||
"username": getattr(user, "username", None),
|
||||
"name": getattr(user, "name", None),
|
||||
"role": getattr(user, "role", None),
|
||||
},
|
||||
"previous": previous,
|
||||
"note": "current_tenant 状态记录变更(非数据维度)",
|
||||
}
|
||||
try:
|
||||
db.add(OperationLog(
|
||||
user_id=user.id,
|
||||
action="switch_tenant_state",
|
||||
target_type="entity",
|
||||
target_id=data.entity_id,
|
||||
detail={
|
||||
"entity_id": data.entity_id,
|
||||
"entity_name": ent.name,
|
||||
"source": data.source,
|
||||
"previous": previous,
|
||||
"note": "current_tenant 状态记录变更(非数据维度)",
|
||||
},
|
||||
detail=audit_detail,
|
||||
))
|
||||
# 状态键与审计行同一事务提交:审计写不进 ⇒ 状态变更一并回滚
|
||||
# (绝不留「改了但没审计」的窗口;失败必须可见,不得静默)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.error(
|
||||
"AUDIT_WRITE_FAILED tenant/switch from=%s to=%s user_id=%s source=%s "
|
||||
"→ 审计写入失败,状态变更已回滚(无审计不得留变更)",
|
||||
from_entity_id, data.entity_id, getattr(user, "id", None), data.source,
|
||||
exc_info=True,
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="审计写入失败,状态变更已回滚")
|
||||
|
||||
return {"success": True, "current": state}
|
||||
|
||||
@@ -244,9 +244,25 @@ def has_action(user: User, action: str, db: Session = None) -> bool:
|
||||
|
||||
|
||||
# ─── KPI可见性(按维度/分类过滤) ───
|
||||
#
|
||||
# ⚠️ 口径裁定(2026-09-23 dama-entity-isolation-sweep-20260923 §一②):
|
||||
# `role_permissions.key = 'kpi_visibility'` **属「非账套维度」的全局配置,不拆账套**
|
||||
# —— 理由:① 值本身是 `{role: [维度规则]}` 的**角色维度**字典,无账套语义;
|
||||
# ② 角色枚举(ceo/finance/business/it)是平台级字典,`role_permissions` 表
|
||||
# 亦无 `entity_id` 列(`key` unique),「按角色看哪些维度」是集团统一口径;
|
||||
# ③ 与 `route_permissions` / `action_permissions` 同族(同为角色级权限面),
|
||||
# 若只拆本键会与同表另两键口径分裂。
|
||||
# 判别口径:**同表同族的键按同一维度处理**;本键不消费任何账套状态、也不被账套写侧改写。
|
||||
# 登记:`docs/global-config-keys-caliber-20260923.md`(键维度字典)。
|
||||
|
||||
|
||||
def _load_kpi_visibility(db: Session = None):
|
||||
"""从RolePermission表加载kpi_visibility配置(独立缓存)"""
|
||||
"""从RolePermission表加载kpi_visibility配置(独立缓存)
|
||||
|
||||
**非账套维度**(角色级天然全局,口径见上方注释与本档登记):
|
||||
只读 `role_permissions.key = 'kpi_visibility'`,**有意不做账套后缀**——
|
||||
这是集团共享口径,不是漏隔离。
|
||||
"""
|
||||
import time
|
||||
if not hasattr(_load_kpi_visibility, "_cache"):
|
||||
_load_kpi_visibility._cache = {"data": None, "ts": 0}
|
||||
|
||||
@@ -32,10 +32,32 @@ DEFAULT_HIGHER_BETTER = [
|
||||
CONFIG_KEY_HIGHER_BETTER = "kpi_alert_higher_better"
|
||||
|
||||
|
||||
def get_higher_better_codes(db, entity_id: int = None) -> list:
|
||||
"""读取越高越好型KPI编码列表(system_configs 可维护,无配置回落默认)"""
|
||||
def higher_better_key(entity_id: int) -> str:
|
||||
"""账套维度的方向配置键名:`kpi_alert_higher_better.<entity_id>`(零 DDL)
|
||||
|
||||
entity_id 必传:`None` → ValueError(防「漏传 → 落默认账套」重演污染)。
|
||||
"""
|
||||
if entity_id is None:
|
||||
raise ValueError(
|
||||
"higher_better_key 必须传 entity_id(账套隔离,2026-09-23 "
|
||||
"dama-entity-isolation-sweep-20260923)"
|
||||
)
|
||||
return f"{CONFIG_KEY_HIGHER_BETTER}.{int(entity_id)}"
|
||||
|
||||
|
||||
def get_higher_better_codes(db, entity_id: int) -> list:
|
||||
"""读取**本账套**越高越好型KPI编码列表(entity_id 必传,漏传 TypeError)
|
||||
|
||||
账套隔离(2026-09-23 dama-entity-isolation-sweep-20260923 §一①,零 DDL):
|
||||
· 只读 `kpi_alert_higher_better.<entity_id>`,**不做「回退旧全局键」**——
|
||||
回退分支本身就是跨账套借用通路(与 `cash.current_balance` 同源缺陷)。
|
||||
· 本账套未配置 → `DEFAULT_HIGHER_BETTER`(业务兜底:方向判定的内置默认表;
|
||||
与拆键前「全局键无行」时的行为逐字一致,故对现存账套零行为差异)。
|
||||
· 旧全局键 `kpi_alert_higher_better` 原样保留作回退杠杆
|
||||
(搬运脚本 `--down` 只删新键,回退老码即可继续读旧键)。
|
||||
"""
|
||||
cfg = db.query(SystemConfig).filter(
|
||||
SystemConfig.config_key == CONFIG_KEY_HIGHER_BETTER
|
||||
SystemConfig.config_key == higher_better_key(entity_id)
|
||||
).first()
|
||||
if cfg and cfg.config_value:
|
||||
try:
|
||||
@@ -43,7 +65,7 @@ def get_higher_better_codes(db, entity_id: int = None) -> list:
|
||||
if isinstance(codes, list):
|
||||
return [str(c) for c in codes]
|
||||
except Exception:
|
||||
logger.warning("system_configs[%s] 解析失败, 回落默认", CONFIG_KEY_HIGHER_BETTER)
|
||||
logger.warning("system_configs[%s] 解析失败, 回落默认", higher_better_key(entity_id))
|
||||
return list(DEFAULT_HIGHER_BETTER)
|
||||
|
||||
|
||||
@@ -301,7 +323,8 @@ def build_deviation_alert(db, kpi, period: str, entity_id: int = 1, min_rate: fl
|
||||
actual = deviation.get("actual_value")
|
||||
budget = deviation.get("budget_value")
|
||||
|
||||
# 方向性:越高越好型(配置化,system_configs.kpi_alert_higher_better)
|
||||
# 方向性:越高越好型(配置化,本账套键 system_configs.kpi_alert_higher_better.<entity_id>;
|
||||
# 2026-09-23 拆账套:entity_id 由调用方必传,不再读全局唯一键)
|
||||
higher_better = kpi.kpi_code in get_higher_better_codes(db, entity_id)
|
||||
|
||||
if higher_better:
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python3
|
||||
"""只读侦查(生产读,本单 dama-entity-isolation-sweep-20260923 §一/§二 前置)
|
||||
|
||||
只 SELECT,无任何写。用于:
|
||||
1. system_configs / role_permissions 全量键实况(含 kpi_alert_higher_better / budget_mode /
|
||||
budget_driver_mode / current_tenant / cash.current_balance.* / alignment_config.*)
|
||||
2. operation_logs 实况(action 分布、switch_entity / switch_tenant_state 行数、最近行)
|
||||
3. entities 实况(账套清单)
|
||||
4. kpi_definitions 列面(是否有自身方向字段)+ 逐账套 KPI 分布
|
||||
用法: python3 <this> [--json out.json]
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BACKEND = Path("/srv/hermes/shared/cma-management/backend")
|
||||
os.chdir(BACKEND)
|
||||
sys.path.insert(0, str(BACKEND))
|
||||
|
||||
from sqlalchemy import text # noqa: E402
|
||||
from app.database import get_engine # noqa: E402
|
||||
|
||||
out = {}
|
||||
eng = get_engine()
|
||||
with eng.connect() as c:
|
||||
out["db"] = c.execute(text("SELECT DATABASE()")).scalar()
|
||||
out["system_configs"] = [
|
||||
{"id": r[0], "config_key": r[1], "config_value": r[2], "description": r[3],
|
||||
"updated_at": str(r[4])}
|
||||
for r in c.execute(text(
|
||||
"SELECT id, config_key, config_value, description, updated_at "
|
||||
"FROM system_configs ORDER BY config_key"))
|
||||
]
|
||||
out["role_permissions"] = [
|
||||
{"id": r[0], "key": r[1], "value": r[2], "updated_at": str(r[3])}
|
||||
for r in c.execute(text("SELECT id, `key`, `value`, updated_at FROM role_permissions ORDER BY `key`"))
|
||||
]
|
||||
out["entities"] = [
|
||||
{"id": r[0], "name": r[1], "short_name": r[2], "status": r[3]}
|
||||
for r in c.execute(text("SELECT id, name, short_name, status FROM entities ORDER BY id"))
|
||||
]
|
||||
out["op_logs_actions"] = [
|
||||
{"action": r[0], "n": r[1], "last": str(r[2])}
|
||||
for r in c.execute(text(
|
||||
"SELECT action, COUNT(*) n, MAX(created_at) last FROM operation_logs "
|
||||
"GROUP BY action ORDER BY n DESC"))
|
||||
]
|
||||
out["op_logs_switch_rows"] = [
|
||||
{"id": r[0], "user_id": r[1], "action": r[2], "target_type": r[3], "target_id": r[4],
|
||||
"detail": (r[5] or "")[:300], "created_at": str(r[6])}
|
||||
for r in c.execute(text(
|
||||
"SELECT id, user_id, action, target_type, target_id, detail, created_at "
|
||||
"FROM operation_logs WHERE action LIKE '%switch%' OR action LIKE '%tenant%' "
|
||||
"ORDER BY id DESC LIMIT 10"))
|
||||
]
|
||||
out["op_logs_recent"] = [
|
||||
{"id": r[0], "user_id": r[1], "action": r[2], "target_type": r[3], "target_id": r[4],
|
||||
"created_at": str(r[5])}
|
||||
for r in c.execute(text(
|
||||
"SELECT id, user_id, action, target_type, target_id, created_at "
|
||||
"FROM operation_logs ORDER BY id DESC LIMIT 12"))
|
||||
]
|
||||
out["system_configs_cols"] = [
|
||||
{"col": r[0], "type": r[1], "null": r[2], "key": r[3], "default": str(r[4])}
|
||||
for r in c.execute(text(
|
||||
"SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY, COLUMN_DEFAULT "
|
||||
"FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() "
|
||||
"AND TABLE_NAME IN ('system_configs','role_permissions','operation_logs') "
|
||||
"ORDER BY TABLE_NAME, ORDINAL_POSITION"))
|
||||
]
|
||||
out["kpi_def_cols"] = [
|
||||
r[0] for r in c.execute(text(
|
||||
"SELECT COLUMN_NAME FROM information_schema.COLUMNS "
|
||||
"WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='kpi_definitions' "
|
||||
"ORDER BY ORDINAL_POSITION"))
|
||||
]
|
||||
out["kpi_by_entity"] = [
|
||||
{"entity_id": r[0], "n": r[1]} for r in c.execute(text(
|
||||
"SELECT entity_id, COUNT(*) n FROM kpi_definitions GROUP BY entity_id ORDER BY entity_id"))
|
||||
]
|
||||
out["budgetplan_by_entity"] = [
|
||||
{"entity_id": r[0], "n": r[1]} for r in c.execute(text(
|
||||
"SELECT kd.entity_id, COUNT(*) n FROM budget_plans bp "
|
||||
"JOIN kpi_definitions kd ON kd.id=bp.kpi_id GROUP BY kd.entity_id ORDER BY kd.entity_id"))
|
||||
]
|
||||
|
||||
print(json.dumps(out, ensure_ascii=False, indent=1, default=str))
|
||||
if "--json" in sys.argv:
|
||||
p = sys.argv[sys.argv.index("--json") + 1]
|
||||
Path(p).write_text(json.dumps(out, ensure_ascii=False, indent=1, default=str))
|
||||
print("WROTE", p)
|
||||
@@ -0,0 +1,244 @@
|
||||
#!/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())
|
||||
@@ -407,7 +407,8 @@ class TestP2SingleAlertPath:
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db, kpi_code="HB_KPI", kpi_name="营业收入")
|
||||
assert "SALES_TOTAL" in get_higher_better_codes(db)
|
||||
# 2026-09-23 拆账套:签名改为 entity_id 必传(漏传即 TypeError,防落默认账套)
|
||||
assert "SALES_TOTAL" in get_higher_better_codes(db, 1)
|
||||
|
||||
|
||||
class TestP2CashClassify:
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
"""隔离扫尾 · 同源全局键补漏 + 审计缺口收口 — 2026-09-23
|
||||
|
||||
任务:`dama-entity-isolation-sweep-20260923`
|
||||
(DAMA 独立回查新增漏项 / 任总 2026-09-23「按照建议执行」;父单
|
||||
`dama-workbench-entity-isolation-fullstack-20260922`)
|
||||
|
||||
三块(逐条对应一个测试类):
|
||||
|
||||
① `kpi_alert_higher_better` 拆账套 → `kpi_alert_higher_better.<entity_id>`(零 DDL)
|
||||
(消费点:`utils/deviation_engine.py::get_higher_better_codes`;
|
||||
`api/budget.py` GET/PUT `/alert-direction`)
|
||||
|
||||
② `kpi_visibility`(`role_permissions`)**口径裁定**:角色级天然全局、**非账套维度**,
|
||||
不拆;口径写进 `auth_middleware` 注释 + `docs/global-config-keys-caliber-20260923.md`
|
||||
|
||||
③ `POST /tenant/switch` 审计收口:`detail` 显式 from→to/source/operator;
|
||||
**审计写失败不静默**(同事务回滚 + `AUDIT_WRITE_FAILED` ERROR + HTTP 500)
|
||||
|
||||
外加:搬运脚本 `scripts/migrate_higher_better_key_entity_20260923.py` 的
|
||||
check/apply/down 幂等、「原值只落目标账套」与「库内无旧值 ⇒ skip 零写入」。
|
||||
|
||||
全部用例只跑内存 SQLite / 临时文件 SQLite(tests/conftest.py 机制),
|
||||
**零生产库 / 零生产 Redis 连接**(conftest 已硬阻断)。
|
||||
"""
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Entity, OperationLog, RolePermission, SystemConfig
|
||||
from app.database import Base
|
||||
from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user
|
||||
|
||||
BUDGET = "/api/cma/budget"
|
||||
TENANT = "/api/cma/tenant"
|
||||
|
||||
LEGACY_KEY = "kpi_alert_higher_better"
|
||||
SENTINEL_CODE = "ZZ_LEGACY_ONLY_CODE" # 只存在于「旧全局键」的哨兵,用于证伪回退
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 夹具
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _ensure_entity(db: Session, eid: int, name: str, status: str = "active") -> Entity:
|
||||
ent = db.query(Entity).filter(Entity.id == eid).first()
|
||||
if not ent:
|
||||
ent = Entity(id=eid, name=name, short_name=name, status=status)
|
||||
db.add(ent)
|
||||
db.commit()
|
||||
db.refresh(ent)
|
||||
return ent
|
||||
|
||||
|
||||
def _seed_two_entities(db: Session):
|
||||
_ensure_entity(db, 1, "陕西酣客文化传媒")
|
||||
_ensure_entity(db, 2, "陕西博海网络科技")
|
||||
|
||||
|
||||
def _login_as(client: TestClient, username: str, entity_id: int) -> str:
|
||||
resp = client.post("/api/cma/auth/login", json={
|
||||
"username": username,
|
||||
"password": TEST_USER_PASSWORD,
|
||||
"entity_id": entity_id,
|
||||
})
|
||||
assert resp.status_code == 200, f"登录 {username}@entity{entity_id} 失败: {resp.text[:300]}"
|
||||
return resp.json()["token"]
|
||||
|
||||
|
||||
def _set_legacy_global(db: Session, codes: list) -> None:
|
||||
"""模拟事故现场:旧全局唯一键有值(拆键前所有账套共用的那一行)"""
|
||||
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == LEGACY_KEY).first()
|
||||
val = json.dumps(codes, ensure_ascii=False)
|
||||
if cfg:
|
||||
cfg.config_value = val
|
||||
else:
|
||||
db.add(SystemConfig(config_key=LEGACY_KEY, config_value=val,
|
||||
description="历史全局键(事故值)"))
|
||||
db.commit()
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════
|
||||
# ① kpi_alert_higher_better 拆账套
|
||||
# ══════════════════════════════════════════════
|
||||
|
||||
class TestHigherBetterPerEntity:
|
||||
|
||||
def test_two_entities_do_not_overwrite_each_other(self, client: TestClient, db: Session):
|
||||
"""e1 / e2 各写方向配置后各自读回本账套值(拆键前共用一个唯一键)"""
|
||||
_seed_two_entities(db)
|
||||
create_test_user(db)
|
||||
t1 = _login_as(client, "testadmin", 1)
|
||||
t2 = _login_as(client, "testadmin", 2)
|
||||
|
||||
r1 = client.put(f"{BUDGET}/alert-direction", headers=auth_header(t1),
|
||||
json={"codes": ["SALES_TOTAL", "CUSTOMER_COUNT"]})
|
||||
assert r1.status_code == 200, r1.text
|
||||
r2 = client.put(f"{BUDGET}/alert-direction", headers=auth_header(t2),
|
||||
json={"codes": ["COST_TOTAL"]})
|
||||
assert r2.status_code == 200, r2.text
|
||||
|
||||
v1 = client.get(f"{BUDGET}/alert-direction", headers=auth_header(t1)).json()
|
||||
v2 = client.get(f"{BUDGET}/alert-direction", headers=auth_header(t2)).json()
|
||||
assert v1["codes"] == ["SALES_TOTAL", "CUSTOMER_COUNT"], f"e1 配置被覆盖:{v1}"
|
||||
assert v2["codes"] == ["COST_TOTAL"], f"e2 配置被覆盖:{v2}"
|
||||
assert v1["entity_id"] == 1 and v2["entity_id"] == 2
|
||||
assert v1["config_key"] == f"{LEGACY_KEY}.1"
|
||||
assert v2["config_key"] == f"{LEGACY_KEY}.2"
|
||||
|
||||
db.expire_all()
|
||||
keys = {r[0] for r in db.query(SystemConfig.config_key)
|
||||
.filter(SystemConfig.config_key.like(f"{LEGACY_KEY}%")).all()}
|
||||
assert keys == {f"{LEGACY_KEY}.1", f"{LEGACY_KEY}.2"}, keys
|
||||
assert LEGACY_KEY not in keys, "不得再写全局唯一键"
|
||||
|
||||
def test_no_cross_entity_fallback_to_legacy_key(self, client: TestClient, db: Session):
|
||||
"""旧全局键有值(事故值),本账套未配置 → 走内置默认,**不借用**旧全局值"""
|
||||
_seed_two_entities(db)
|
||||
create_test_user(db)
|
||||
_set_legacy_global(db, [SENTINEL_CODE]) # 事故现场
|
||||
|
||||
t2 = _login_as(client, "testadmin", 2)
|
||||
body = client.get(f"{BUDGET}/alert-direction", headers=auth_header(t2)).json()
|
||||
assert SENTINEL_CODE not in body["codes"], \
|
||||
f"e2 未配置却读到旧全局键(跨账套借用):{body}"
|
||||
assert body["codes"], "未配置须回落内置默认表(兜底语义)"
|
||||
assert body["is_configured"] is False
|
||||
|
||||
def test_helper_requires_entity_id(self):
|
||||
"""读/写辅助函数的账套参数必传:漏传 → TypeError / ValueError(防落默认账套)"""
|
||||
from app.utils.deviation_engine import get_higher_better_codes, higher_better_key
|
||||
with pytest.raises(TypeError):
|
||||
get_higher_better_codes(None) # 缺 entity_id(位置参数必传)
|
||||
with pytest.raises(ValueError):
|
||||
higher_better_key(None)
|
||||
assert higher_better_key(2) == f"{LEGACY_KEY}.2"
|
||||
|
||||
def test_body_entity_id_not_trusted(self, client: TestClient, db: Session):
|
||||
"""带 token 时 body 里的 entity_id 不可信(仍写会话账套)"""
|
||||
_seed_two_entities(db)
|
||||
create_test_user(db)
|
||||
t1 = _login_as(client, "testadmin", 1)
|
||||
r = client.put(f"{BUDGET}/alert-direction", headers=auth_header(t1),
|
||||
json={"codes": ["SALES_TOTAL"], "entity_id": 2})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["entity_id"] == 1, r.json()
|
||||
db.expire_all()
|
||||
assert db.query(SystemConfig).filter(
|
||||
SystemConfig.config_key == f"{LEGACY_KEY}.2").first() is None
|
||||
|
||||
def test_patch_migrate_scope_unchanged(self):
|
||||
"""读侧不得再出现「回退旧全局键」分支(源级断言,防回归)"""
|
||||
src = open(os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"app", "utils", "deviation_engine.py"), encoding="utf-8").read()
|
||||
fn = src[src.index("def get_higher_better_codes"):src.index("# ====", src.index("def get_higher_better_codes"))]
|
||||
assert "higher_better_key(entity_id)" in fn
|
||||
assert re.search(r"config_key\s*==\s*CONFIG_KEY_HIGHER_BETTER\b", fn) is None, \
|
||||
"读路径不得回退旧全局键(回退即跨账套借用)"
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════
|
||||
# ② kpi_visibility 口径裁定:非账套维度
|
||||
# ══════════════════════════════════════════════
|
||||
|
||||
class TestKpiVisibilityNonEntityDimension:
|
||||
"""裁定:角色级天然全局(不拆账套)——用结构 + 行为 + 登记三面钉住"""
|
||||
|
||||
def test_role_permissions_has_no_entity_column(self, db: Session):
|
||||
"""结构面:role_permissions 无 entity_id 列(key 唯一)⇒ 天然无账套维度"""
|
||||
cols = {c["name"] for c in inspect(db.get_bind()).get_columns("role_permissions")}
|
||||
assert "entity_id" not in cols, cols
|
||||
|
||||
def test_visibility_is_role_keyed_not_entity_keyed(self, db: Session):
|
||||
"""行为面:读的是裸键 `kpi_visibility`(值 = {role: [规则]}),不受账套影响"""
|
||||
from app import auth_middleware as am
|
||||
db.add(RolePermission(key="kpi_visibility",
|
||||
value={"finance": ["finance_*"], "ceo": ["*"]}))
|
||||
db.commit()
|
||||
am._load_kpi_visibility._cache = {"data": None, "ts": 0} # 清缓存
|
||||
vis = am._load_kpi_visibility(db)
|
||||
assert vis["finance"] == ["finance_*"] and vis["ceo"] == ["*"]
|
||||
am._load_kpi_visibility._cache = {"data": None, "ts": 0}
|
||||
# 不带账套参数也照读(角色维度,无账套语义)
|
||||
assert am._load_kpi_visibility(db)["finance"] == ["finance_*"]
|
||||
|
||||
def test_caliber_documented_in_code(self):
|
||||
"""登记面:「非账套维度」口径必须写在代码里(否则下轮审计重复报缺口)"""
|
||||
p = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"app", "auth_middleware.py")
|
||||
src = open(p, encoding="utf-8").read()
|
||||
assert "非账套维度" in src and "kpi_visibility" in src
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════
|
||||
# ③ tenant/switch 审计收口
|
||||
# ══════════════════════════════════════════════
|
||||
|
||||
class TestTenantSwitchAudit:
|
||||
|
||||
def _seed_tenant_key(self, db: Session, entity_id: int, name: str):
|
||||
db.add(SystemConfig(
|
||||
config_key="current_tenant",
|
||||
config_value=json.dumps({"tenant_id": "company_a", "entity_id": entity_id,
|
||||
"name": name, "short_name": name,
|
||||
"source": "seed", "switched_at": "2026-09-22T10:36:01"},
|
||||
ensure_ascii=False),
|
||||
description="当前租户状态记录"))
|
||||
db.commit()
|
||||
|
||||
def test_audit_detail_carries_from_to_source_operator(self, client: TestClient, db: Session):
|
||||
_seed_two_entities(db)
|
||||
create_test_user(db)
|
||||
self._seed_tenant_key(db, 1, "陕西酣客文化传媒")
|
||||
t1 = _login_as(client, "testadmin", 1)
|
||||
|
||||
r = client.post(f"{TENANT}/switch", headers=auth_header(t1),
|
||||
json={"entity_id": 2, "source": "sweep-test"})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
db.expire_all()
|
||||
log = (db.query(OperationLog)
|
||||
.filter(OperationLog.action == "switch_tenant_state")
|
||||
.order_by(OperationLog.id.desc()).first())
|
||||
assert log is not None, "写路径必须落审计(修复前无 operation_logs 行)"
|
||||
assert log.user_id == 1
|
||||
assert log.target_type == "entity" and log.target_id == 2
|
||||
d = log.detail or {}
|
||||
assert d.get("from_entity_id") == 1, f"detail 须含 from→to:{d}"
|
||||
assert d.get("to_entity_id") == 2, d
|
||||
assert d.get("entity_id") == 2, d # 兼容既有消费方
|
||||
assert d.get("source") == "sweep-test", d
|
||||
op = d.get("operator") or {}
|
||||
assert op.get("user_id") == 1 and op.get("username") == "testadmin", op
|
||||
assert op.get("role") == "ceo", op
|
||||
|
||||
def test_audit_write_failure_is_not_silent(self, client: TestClient, db: Session,
|
||||
monkeypatch, caplog):
|
||||
"""审计写不进去 → 状态变更一并回滚 + ERROR 留痕 + HTTP 500(不静默)"""
|
||||
import logging
|
||||
from app.api import tenant as tenant_mod
|
||||
|
||||
_seed_two_entities(db)
|
||||
create_test_user(db)
|
||||
self._seed_tenant_key(db, 1, "陕西酣客文化传媒")
|
||||
t1 = _login_as(client, "testadmin", 1)
|
||||
|
||||
class _Boom:
|
||||
def __init__(self, *a, **kw):
|
||||
raise RuntimeError("simulated audit table failure")
|
||||
|
||||
monkeypatch.setattr(tenant_mod, "OperationLog", _Boom)
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
r = client.post(f"{TENANT}/switch", headers=auth_header(t1),
|
||||
json={"entity_id": 2, "source": "sweep-test"})
|
||||
|
||||
assert r.status_code == 500, f"审计失败必须显式失败,实际 {r.status_code}: {r.text[:200]}"
|
||||
assert any("AUDIT_WRITE_FAILED" in rec.getMessage() for rec in caplog.records), \
|
||||
[rec.getMessage() for rec in caplog.records]
|
||||
|
||||
db.expire_all()
|
||||
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == "current_tenant").first()
|
||||
assert json.loads(cfg.config_value)["entity_id"] == 1, \
|
||||
"审计失败时状态变更必须回滚(不得留「改了但没审计」)"
|
||||
assert db.query(OperationLog).filter(
|
||||
OperationLog.action == "switch_tenant_state").count() == 0
|
||||
|
||||
def test_get_current_has_no_write_path(self, client: TestClient, db: Session):
|
||||
"""GET /current 纯只读:读一次不产生任何审计行(不给读操作写审计表)"""
|
||||
_ensure_entity(db, 1, "陕西酣客文化传媒")
|
||||
create_test_user(db)
|
||||
t1 = _login_as(client, "testadmin", 1)
|
||||
assert client.get(f"{TENANT}/current", headers=auth_header(t1)).status_code == 200
|
||||
db.expire_all()
|
||||
assert db.query(OperationLog).count() == 0
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════
|
||||
# 搬运脚本(临时文件 SQLite,绝不碰 app.database → 生产库)
|
||||
# ══════════════════════════════════════════════
|
||||
|
||||
MIGRATION = "scripts.migrate_higher_better_key_entity_20260923"
|
||||
|
||||
|
||||
class TestMigrationScript:
|
||||
pytestmark = []
|
||||
|
||||
@pytest.fixture
|
||||
def mig_env(self, tmp_path, monkeypatch):
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
mod = importlib.import_module(MIGRATION)
|
||||
eng = create_engine(f"sqlite:///{tmp_path/'mig.db'}")
|
||||
Base.metadata.create_all(bind=eng)
|
||||
with eng.begin() as conn:
|
||||
conn.execute(text("INSERT INTO entities (id, name, short_name, status) "
|
||||
"VALUES (1, '酣客', '酣客', 'active')"))
|
||||
conn.execute(text("INSERT INTO entities (id, name, short_name, status) "
|
||||
"VALUES (2, '博海', '博海', 'active')"))
|
||||
conn.execute(text("INSERT INTO system_configs (config_key, config_value, description) "
|
||||
"VALUES ('kpi_alert_higher_better', '[\"SALES_TOTAL\"]', '历史全局键')"))
|
||||
monkeypatch.setattr(mod, "_engine", lambda: eng)
|
||||
return mod, eng
|
||||
|
||||
def _run(self, mod, argv):
|
||||
old = sys.argv
|
||||
sys.argv = ["migrate"] + argv
|
||||
try:
|
||||
return mod.main()
|
||||
finally:
|
||||
sys.argv = old
|
||||
|
||||
def test_check_is_readonly(self, mig_env):
|
||||
mod, eng = mig_env
|
||||
assert self._run(mod, ["--check", "--allow-db", "sqlite"]) == 0
|
||||
with eng.connect() as conn:
|
||||
assert conn.execute(text("SELECT COUNT(*) FROM system_configs "
|
||||
"WHERE config_key LIKE 'kpi_alert_higher_better.%'")).scalar() == 0
|
||||
|
||||
def test_apply_moves_value_to_target_only_and_is_idempotent(self, mig_env):
|
||||
mod, eng = mig_env
|
||||
assert self._run(mod, ["--apply", "--allow-db", "sqlite",
|
||||
"--target-entity", "1", "--json"]) == 0
|
||||
with eng.connect() as conn:
|
||||
v = conn.execute(text("SELECT config_value FROM system_configs "
|
||||
"WHERE config_key = 'kpi_alert_higher_better.1'")).scalar()
|
||||
assert json.loads(v) == ["SALES_TOTAL"]
|
||||
assert conn.execute(text("SELECT COUNT(*) FROM system_configs "
|
||||
"WHERE config_key = 'kpi_alert_higher_better.2'")).scalar() == 0
|
||||
assert conn.execute(text("SELECT config_value FROM system_configs "
|
||||
"WHERE config_key = 'kpi_alert_higher_better'")).scalar() == \
|
||||
'["SALES_TOTAL"]' # 旧键保留(回退杠杆)
|
||||
assert self._run(mod, ["--apply", "--allow-db", "sqlite",
|
||||
"--target-entity", "1", "--json"]) == 0 # 二跑幂等
|
||||
with eng.connect() as conn:
|
||||
assert conn.execute(text("SELECT COUNT(*) FROM system_configs "
|
||||
"WHERE config_key LIKE 'kpi_alert_higher_better.%'")).scalar() == 1
|
||||
|
||||
def test_apply_without_legacy_row_is_skip_zero_write(self, tmp_path, monkeypatch):
|
||||
"""生产实况:库内无旧值 ⇒ --apply 走 skip、零写入(拆键后行为与拆键前逐字一致)"""
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
mod = importlib.import_module(MIGRATION)
|
||||
eng = create_engine(f"sqlite:///{tmp_path/'empty.db'}")
|
||||
Base.metadata.create_all(bind=eng)
|
||||
with eng.begin() as conn:
|
||||
conn.execute(text("INSERT INTO entities (id, name, short_name, status) "
|
||||
"VALUES (1, '酣客', '酣客', 'active')"))
|
||||
monkeypatch.setattr(mod, "_engine", lambda: eng)
|
||||
assert self._run(mod, ["--apply", "--allow-db", "sqlite", "--json"]) == 0
|
||||
with eng.connect() as conn:
|
||||
assert conn.execute(text("SELECT COUNT(*) FROM system_configs "
|
||||
"WHERE config_key LIKE 'kpi_alert_higher_better%'")).scalar() == 0
|
||||
|
||||
def test_down_removes_new_keys_keeps_legacy(self, mig_env):
|
||||
mod, eng = mig_env
|
||||
self._run(mod, ["--apply", "--allow-db", "sqlite", "--json"])
|
||||
assert self._run(mod, ["--down", "--allow-db", "sqlite", "--json"]) == 0
|
||||
with eng.connect() as conn:
|
||||
assert conn.execute(text("SELECT COUNT(*) FROM system_configs "
|
||||
"WHERE config_key LIKE 'kpi_alert_higher_better.%'")).scalar() == 0
|
||||
assert conn.execute(text("SELECT COUNT(*) FROM system_configs "
|
||||
"WHERE config_key = 'kpi_alert_higher_better'")).scalar() == 1
|
||||
|
||||
def test_prod_db_name_guard(self, mig_env):
|
||||
mod, eng = mig_env
|
||||
with pytest.raises(SystemExit) as e:
|
||||
self._run(mod, ["--apply", "--json"]) # sqlite → DATABASE() 不可用 → db=''
|
||||
assert e.value.code == 2
|
||||
|
||||
def test_target_entity_must_exist(self, mig_env):
|
||||
mod, eng = mig_env
|
||||
with pytest.raises(SystemExit) as e:
|
||||
self._run(mod, ["--apply", "--allow-db", "sqlite", "--target-entity", "99", "--json"])
|
||||
assert e.value.code == 2
|
||||
@@ -32,6 +32,22 @@
|
||||
并把 `target_type='entity'` / `target_id=entity_id` 与 `switch_entity` 对齐。
|
||||
- `detail` JSON 仍保留 `entity_id / entity_name / source / previous`(人读明细)。
|
||||
|
||||
**2026-09-23 收口(`dama-entity-isolation-sweep-20260923` §二)**:
|
||||
|
||||
- `detail` 再补 **显式 from→to** 与操作者三段:`from_entity_id` / `to_entity_id`(原只有 `previous` 整包,
|
||||
要人肉解析才知道「从哪来」)、`source`、`operator{user_id,username,name,role}`。
|
||||
`entity_id` 字段保留(= `to_entity_id`,兼容既有消费方)。
|
||||
- **审计失败不静默**:状态键与审计行**同一事务**提交,审计写入异常 → `rollback()`
|
||||
(状态变更一并回滚,绝不留「改了但没审计」的窗口)+ `logger.error("AUDIT_WRITE_FAILED …")` + HTTP 500。
|
||||
运维可在 `logs` 里 grep `AUDIT_WRITE_FAILED` 订阅。
|
||||
- `GET /tenant/current` **无写路径**(纯只读、已限 it/ceo)⇒ 不落审计(不给读操作写审计表)。
|
||||
- 历史缺口(2026-09-22 10:36 止血那次 `POST /tenant/switch` 无审计行)**不补写**:审计不得事后伪造。
|
||||
该次操作的留痕以 ops 止血单 + 本档 §一「现网实况」为准。
|
||||
|
||||
**在线实测(2026-09-23,`POST /api/cma/tenant/switch` 带会话 Bearer,`from=1 → to=1`)**:
|
||||
切换一次 → `operation_logs` 出现 `switch_tenant_state` 行(`user_id` 非空、`target_type='entity'`)
|
||||
⇒ 「全平台影响动作无审计」的缺口已在生产关闭(实测证据见回报件 §二)。
|
||||
|
||||
**现网实况(只读核对,2026-09-22 11:15,`cma_reader` 16/16 PASS)**:
|
||||
`switch_entity` = 49 行、`switch_tenant_state` = 0 行(本单尚未部署 ⇒ 字面是新引入的,
|
||||
上线前库里无该字面属正常)。ops 止血那次 `POST /tenant/switch`(10:36:01,部署码)**没有**审计行——
|
||||
@@ -63,5 +79,8 @@
|
||||
1. `kpi_alerts` id 589/590 跨账套错挂(`entity_id=1` 挂 e2 的 `BH_ADM_EXPENSE`):数据订正须
|
||||
另立单 + 任总放行(生产写)。
|
||||
2. `kpi_id=1` 兜底残留清点(9 行、其中 1 行 `pending`)与归属裁定。
|
||||
3. 同源全局键继续拆账套:`kpi_alert_higher_better`、`kpi_visibility`(DAMA §7 反向挑错新增漏项)、
|
||||
`budget_mode` / `budget_driver_mode`(batch5 §七 已记录未施工)。
|
||||
3. ~~同源全局键继续拆账套:`kpi_alert_higher_better`、`kpi_visibility`(DAMA §7 反向挑错新增漏项)、
|
||||
`budget_mode` / `budget_driver_mode`(batch5 §七 已记录未施工)。~~
|
||||
→ **2026-09-23 已分流**:`kpi_alert_higher_better` 已拆账套、`kpi_visibility` 裁定「非账套维度」
|
||||
关闭(见 `global-config-keys-caliber-20260923.md` §二/§三);`budget_mode` / `budget_driver_mode`
|
||||
评估为「应拆」但**另立 P2 单**(原值落账需拍板,见同档 §四)。
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# 口径登记:全局配置键的「维度字典」(账套维度 / 非账套维度 / 集团共享)
|
||||
|
||||
- **立档**: wecom-fullstack,2026-09-23
|
||||
- **来源**: `dama-entity-isolation-sweep-20260923` §一(DAMA 独立回查新增漏项,任总 2026-09-23
|
||||
「按照建议执行」);父单 `dama-workbench-entity-isolation-fullstack-20260922`
|
||||
- **性质**: 口径登记 + 代码收口说明。**不含**任何数据订正(数据订正另立单 + 审批)
|
||||
- **配套**: 审计字面/通知日志口径见 `audit-and-notification-caliber-20260922.md`
|
||||
|
||||
---
|
||||
|
||||
## 一、判据(把「全局唯一键」分三档,别再一刀切)
|
||||
|
||||
`system_configs.config_key` 与 `role_permissions.key` 都是 **unique 单行键** ⇒ 一次写入全平台生效。
|
||||
并非所有全局键都是缺陷,必须按键的**语义维度**分档:
|
||||
|
||||
| 档 | 名称 | 判据(问:这个值描述的是谁的什么?) | 处置 |
|
||||
|---|---|---|---|
|
||||
| **A** | 账套维度 | 值描述**某个账套**的状态,或**按账套业务**取值(余额、方向、期间窗口…) | **必须拆**:键名带 `.<entity_id>`(零 DDL);读**不回退**旧全局键;写侧 `entity_id` 必传;配 `--check/--apply/--down` 搬运脚本 |
|
||||
| **B** | 非账套维度 | 值按**角色**(或其它平台级维度)组织,与账套无关 | **保持全局**,但必须在代码里写明「**有意不做账套后缀,这是集团共享口径,不是漏隔离**」并在本档登记 |
|
||||
| **C** | 集团共享(配置/模板) | 对齐模式模板、通知渠道、OKR 模板、路由/动作权限 | 保持全局 + docstring 声明口径 |
|
||||
|
||||
**不写声明的代价**:下一轮隔离审计会把 B/C 档重复报成缺口,来回返工(本仓已发生两轮)。
|
||||
|
||||
---
|
||||
|
||||
## 二、逐键裁定表(2026-09-23 现况)
|
||||
|
||||
| 键 | 载体 | 消费点 | 档 | 处置 | 状态 |
|
||||
|---|---|---|---|---|---|
|
||||
| `kpi_alert_higher_better` | `system_configs` | `utils/deviation_engine.py::get_higher_better_codes`(L35-66)、`build_deviation_alert`(L305);`api/budget.py` GET/PUT `/alert-direction` | **A** | 拆为 `kpi_alert_higher_better.<entity_id>`;本账套未配置 → `DEFAULT_HIGHER_BETTER`(内置兜底) | ✅ 2026-09-23 本单 |
|
||||
| `cash.current_balance` | `system_configs` | `utils/cash_forecast_engine.py`、`api/cash.py` | A | 同上(B1) | ✅ 2026-09-22 |
|
||||
| `alignment_config` | `role_permissions` | `api/alignment.py`(GET/POST `/config`、GET `/tree`) | A | 同上(B3a) | ✅ 2026-09-22 |
|
||||
| `current_tenant` | `system_configs` | `api/tenant.py` | A(特殊) | **语义收敛**:非数据维度,仅项目Bot A2A 状态记录;GET 限 it/ceo;POST 落审计(含 from→to) | ✅ 2026-09-22 / 审计收口 2026-09-23 |
|
||||
| `kpi_visibility` | `role_permissions` | `auth_middleware.py::_load_kpi_visibility`(L248-286) | **B** | **不拆**;代码注释登记 | ✅ 2026-09-23 关闭 |
|
||||
| `route_permissions` / `action_permissions` | `role_permissions` | `auth_middleware.py`(路由/动作权限) | C | 集团共享 | ✅ |
|
||||
| `notification_channels` / `okr_templates` | 各自表 | 通知渠道 / OKR 模板 | C | 集团共享(父单 docstring 已声明) | ✅ |
|
||||
| `budget_mode` | `system_configs` | `api/budget.py` L509(GET `/config`)、L535(POST `/config`)、L568(`/roll-forward`)、L677(预测期间窗口) | **A(待拆)** | 见 §三 | ⏳ 排期 |
|
||||
| `budget_driver_mode` | `system_configs` | `api/driver_budget.py` L114(GET `/mode`)、L135(POST `/mode`) | **A(待拆)** | 见 §三 | ⏳ 排期 |
|
||||
|
||||
---
|
||||
|
||||
## 三、`kpi_visibility` 裁定(本单 §一②,**关闭本项**)
|
||||
|
||||
**结论:属「非账套维度」的全局配置,不拆账套;按 B 档登记后关闭。**
|
||||
|
||||
理由(三条,均以库内/代码实况为据):
|
||||
|
||||
1. **值本身是角色维度字典**:`{role: [维度规则]}`(默认 `DEFAULT_KPI_VISIBILITY`:
|
||||
`ceo/it → ["*"]`、`finance → ["finance_*"]`、`business → ["customer_*","process_*","learning_*"]`),
|
||||
条目的键是**角色**,没有任何账套语义。
|
||||
2. **载体天然无账套列**:`role_permissions` 只有 `id/key/value/updated_at`,`key` unique
|
||||
⇒ 表结构上不存在「按账套存一份」的位置(要按账套分必须 DDL,与「零 DDL 优先」相悖)。
|
||||
3. **同表同族一致性**:`route_permissions` / `action_permissions` 同为角色级权限面,都是全局;
|
||||
只拆 `kpi_visibility` 会造成同表三种维度口径分裂,反而更难维护。
|
||||
|
||||
**口径**:同表同族的键按同一维度处理。本键**不消费任何账套状态**,也不被账套写侧改写
|
||||
(现库该键 0 行 ⇒ 走内置默认表,与任何账套无关)。
|
||||
|
||||
**落地**:代码注释 + `_load_kpi_visibility` docstring 已写明「**有意不做账套后缀**」并指向本档;
|
||||
用例锁「结构面(无 `entity_id` 列)+ 行为面(裸键读值)+ 登记面(口径写在代码里)」。
|
||||
|
||||
> 现网实况(2026-09-23 只读核对):`role_permissions` 4 行 = `route_permissions` /
|
||||
> `action_permissions` / `alignment_config` / `alignment_config.1`;**`kpi_visibility` 无行**。
|
||||
|
||||
---
|
||||
|
||||
## 四、`budget_mode` / `budget_driver_mode` 评估结论(本单「顺带」项,**不并入本单**)
|
||||
|
||||
**现况(只读核对)**:库内各 1 行 ——
|
||||
`budget_mode = {"mode":"rolling","rolling_months":12}`(2026-09-08 更新)、
|
||||
`budget_driver_mode = {"mode":"driver","label":"驱动因子模式"}`(2026-08-26 更新)。
|
||||
|
||||
**为什么属 A 档(不是集团共享)**:
|
||||
|
||||
- `budget_mode` 决定**本账套**预算预测的**期间窗口**(`budget.py:677-702`:`rolling` = 当月起 12 个月、
|
||||
`fixed` = 当年 1-12 月),而其数据查询本身**按会话 entity 过滤**(同函数 L710-713)
|
||||
⇒ 全局键在决定「本账套的取数窗口」,属跨账套污染同源。
|
||||
- `budget_driver_mode` 决定**本账套**预算编制模式(`subject` 科目模式 / `driver` 驱动因子模式)。
|
||||
- 任一账套调 `POST /budget/config`(或 `POST /driver-budget/mode`)即**改全平台模式**。
|
||||
|
||||
**结论:应拆(B1 同款,零 DDL),但不并入本单**,理由三条:
|
||||
|
||||
1. **库内确有旧值,且两个 active 账套都在用预算**(`budget_plans` 逐账套:e1 = 276 行、
|
||||
e2 = 25 行)⇒ 必须裁定「原值落哪家」,或「是否双落」。若按单账套搬运(只落 e1),
|
||||
e2 会由 `rolling` 退化为默认 `fixed`(期间窗口从「当月+12 月」变「全年 1-12 月」)——
|
||||
这是**跨账套行为变更**,须任总拍板,不能由执行方顺手决定。
|
||||
2. **牵动既有用例**:`tests/test_budget.py`(8 处)、`tests/test_cross_entity_isolation_batch2_20260918.py`(2 处)、
|
||||
`tests/test_budget_tech_improve.py` 均直接读写该全局键字面 ⇒ 应作为独立单元出 RED→GREEN + 全量回归。
|
||||
3. **又是一次生产写**(`system_configs` 新键)⇒ 按「**同一生产写目标只开一张单**」的约定不并入本单。
|
||||
|
||||
**建议排期**:P2 单「`budget_mode` / `budget_driver_mode` 拆账套(含原值落账裁定)」。
|
||||
**建议口径(供拍板)**:**原集团值落全部 active 账套(e1 + e2)、demo 账套留空走默认** ——
|
||||
即「原值 = 拆键前的事实生效面」(该键拆键前对两个 active 账套同时生效),
|
||||
这样拆键**零行为变更**,只是把「谁改谁生效」的写入面收窄到本账套;
|
||||
比「只落 e1、e2 退化」更保守,也比「读侧回退旧键」更干净(回退仍是借用通路)。
|
||||
|
||||
---
|
||||
|
||||
## 五、消费者自查法(防下一次漏扫)
|
||||
|
||||
1. **枚举**:`grep -rn 'SystemConfig' backend/app`+
|
||||
`grep -rhoP 'config_key == "\K[a-zA-Z0-9_.]+' backend/app`(`role_permissions` 同法 grep `RolePermission`);
|
||||
2. **分档**:对每个键问「这个值描述的是**谁的**什么」→ A / B / C;
|
||||
3. **A 档改完必须四件套**:读**不回退**旧键 + 写侧 `entity_id` 必传(不给默认值,漏传即 `TypeError`)
|
||||
+ 本档登记 + 搬运脚本 `--check/--apply/--down`(库名保护 + 幂等 + 目标账套存在性校验);
|
||||
4. **B/C 档必须留声明**(代码注释/docstring 指回本档),否则下一轮审计重复报缺口。
|
||||
Reference in New Issue
Block a user