fix(cma): 隔离扫尾 — kpi_alert_higher_better 拆账套 + tenant/switch 审计收口
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:
wecom-fullstack
2026-09-23 18:29:52 +08:00
parent d13bf1b6e4
commit 477515c453
10 changed files with 965 additions and 33 deletions
+24 -11
View File
@@ -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}")
+51 -13
View File
@@ -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)。
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 状态记录变更(非数据维度)",
# 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),
},
))
db.commit()
"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=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}