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}")
|
||||
|
||||
+51
-13
@@ -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}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user