feat(cma): audit write-side entity closure + entity-dimension data dictionary

task dama-entity-isolation-sweep-20260923 (sections 2-2 4/5, merged by projectBot 2026-09-23 18:2x)

4) operation_logs write side: every audit write carries the account (no DDL, no column added)

   - new app/utils/oplog.py::log_op() single wrapper: sets detail.entity_id; scope=global for

     by-design non-entity writes (platform dict / role-level); scope=batch + OPLOG_BATCH_NO_ENTITY

     sentinel for cross-account batches; no context -> OPLOG_NO_ENTITY sentinel (never guess)

   - 16 remaining construction points converted (data/alert_rules/bi_reports/kpi_causality/

     bot_bridge x2/budget_generate/driver_budget/kpis _log x3/templates _log x2/erp_sync);

     pre-4 state had 20 sites without entity_id, now 0 outside the wrapper

   - read side untouched (knowledge_service still user_id fallback); history not backfilled

5) docs/entity-dimension-data-dictionary.md: 8-table three-state registry

   (kpi_templates/driver_factor_templates/scenario_suggestions/bot_bridge_config = non-entity;

    kpi_subject_map = inherited via kpi_id; analysis_results/subjects = account dimension,

    legacy rows still NULL -> other task; operation_logs = non-entity column + detail.entity_id)

Evidence: RED 2 failed/7 passed (reverted call sites) -> GREEN full 1428 passed; 9 new tests

including two read-back rows; app md5 restore verified identical; no cross-account writes

LOCAL commit only - push is gated (ahead contains the P1 entity-dim batch commit fb4609b)
This commit is contained in:
wecom-fullstack
2026-09-23 23:08:26 +08:00
parent fb4609be14
commit 467472311d
18 changed files with 525 additions and 71 deletions
+3
View File
@@ -141,6 +141,7 @@ def _apply_kpi_target(db: Session, sug: AISuggestion, params: dict, current_user
target_type="kpi",
target_id=kpi.id,
detail={
"entity_id": sug.entity_id, # 2026-09-23 ④:审计带账套(=建议账套,端点已校验=会话账套)
"suggestion_id": sug.id,
"suggestion_title": sug.title,
"apply_action": "kpi_target",
@@ -218,6 +219,7 @@ def _apply_budget_adjust(db: Session, sug: AISuggestion, params: dict, current_u
target_type="budget",
target_id=plan.id,
detail={
"entity_id": sug.entity_id, # 2026-09-23 ④:审计带账套(=建议账套)
"suggestion_id": sug.id,
"suggestion_title": sug.title,
"apply_action": "budget_adjust",
@@ -278,6 +280,7 @@ def _apply_action_plan(db: Session, sug: AISuggestion, params: dict, current_use
target_type="action_plan",
target_id=plan.id,
detail={
"entity_id": sug.entity_id, # 2026-09-23 ④:审计带账套(=建议账套)
"suggestion_id": sug.id,
"suggestion_title": sug.title,
"apply_action": "action_plan",
+4 -4
View File
@@ -14,6 +14,7 @@ from app.database import get_db, Base
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPIValue, KPIAlert, OperationLog
from app.utils.oplog import log_op
# 裁定 R1(2026-09-19 财务口径):demo 账套不参与预警生成/遍历(单一事实源)
# 裁定 R5(2026-09-19 财务口径):期间非规整月 YYYY-MM → 该 kpi_alerts 行不入库(生成侧守卫)
from app.utils.alert_entity_scope import guard_alert_period, is_excluded_entity
@@ -140,10 +141,9 @@ def create_alert_rule(data: dict, db: Session = Depends(get_db), user=Depends(re
db.refresh(rule)
# 日志
db.add(OperationLog(
action="create_alert_rule", target_type="alert_rule",
detail=f"KPI={kpi.kpi_code}({kpi.kpi_name}) type={rule_type}",
))
log_op(db, "create_alert_rule", target_type="alert_rule", target_id=rule.id,
detail=f"KPI={kpi.kpi_code}({kpi.kpi_name}) type={rule_type}",
entity_id=entity_id)
db.commit()
return {"data": {c.name: getattr(rule, c.name) for c in AlertRule.__table__.columns}}
+3 -1
View File
@@ -13,6 +13,7 @@ from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPIValue, BiReportTemplate, BiReport, OperationLog, KPICausality
from app.utils.oplog import log_op
logger = logging.getLogger("bi-reports")
@@ -160,7 +161,8 @@ def create_report(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
db.add(r)
db.commit()
db.refresh(r)
db.add(OperationLog(action="create", target_type="bi_report", detail=f"创建报表: {r.name}"))
log_op(db, "create", target_type="bi_report", target_id=r.id,
detail=f"创建报表: {r.name}", entity_id=getattr(r, "entity_id", None))
db.commit()
return {c.name: getattr(r, c.name) for c in BiReport.__table__.columns}
+12 -12
View File
@@ -22,6 +22,7 @@ from app.models import KPICausality
from app.risk_levels import risk_level
# 裁定 R1/R5(2026-09-19 财务口径):demo 账套任何写通道不产生业务预警;期间非规整月不入库(单一事实源)
from app.utils.alert_entity_scope import entity_alert_guard, guard_alert_period
from app.utils.oplog import log_op, SCOPE_GLOBAL
import json
logger = logging.getLogger("cma.bot_bridge")
@@ -971,11 +972,11 @@ def bot_dict_zxt_account(
before = db.execute(text("SELECT COUNT(*) FROM zxt_account_dict")).scalar() or 0
db.execute(stmt, clean)
after = db.execute(text("SELECT COUNT(*) FROM zxt_account_dict")).scalar() or 0
db.add(OperationLog(
action="import", target_type="zxt_account_dict", target_id=None,
detail={"bot": bot.get("name"), "endpoint": "/api/cma/bot/dict/zxt-account",
"upserted": len(clean), "before": before, "after": after, "source": source},
))
log_op(db, "import", target_type="zxt_account_dict", target_id=None,
detail={"bot": bot.get("name"), "endpoint": "/api/cma/bot/dict/zxt-account",
"upserted": len(clean), "before": before, "after": after, "source": source},
# zxt_account_dict = 智享通平台级科目字典(无 entity 列)⇒ 显式声明非账套维度
entity_id=None, scope=SCOPE_GLOBAL)
db.commit()
except HTTPException:
raise
@@ -1263,13 +1264,12 @@ def bot_subjects_upsert(
}, synchronize_session=False)
db.flush()
after = db.query(func.count(Subject.id)).scalar() or 0
db.add(OperationLog(
action="import", target_type="subjects", target_id=None,
detail={"bot": bot.get("name"), "endpoint": "/api/cma/bot/subjects",
"risk_level": "L3", "inserted": len(to_insert), "updated": len(to_update),
"skipped": len(to_skip), "before": before, "after": after,
"update_existing": update_existing, "source": "json"},
))
log_op(db, "import", target_type="subjects", target_id=None,
detail={"bot": bot.get("name"), "endpoint": "/api/cma/bot/subjects",
"risk_level": "L3", "inserted": len(to_insert), "updated": len(to_update),
"skipped": len(to_skip), "before": before, "after": after,
"update_existing": update_existing, "source": "json"},
entity_id=entity_id)
db.commit()
except HTTPException:
raise
+2 -1
View File
@@ -137,7 +137,8 @@ def create_budget_plan(
action="create",
target_type="budget",
target_id=plan.id,
detail=__import__("json").dumps({"kpi_id": kpi_id, "period": period, "value": budget_value}, ensure_ascii=False),
detail=__import__("json").dumps({"kpi_id": kpi_id, "period": period, "value": budget_value,
"entity_id": entity_id}, ensure_ascii=False), # ④ 审计带账套
)
db.add(log)
db.commit()
+17 -14
View File
@@ -8,6 +8,7 @@ from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, BudgetPlan, KPIValue, OperationLog
from app.utils.oplog import log_op, SCOPE_BATCH
import json
import logging
from datetime import datetime
@@ -225,6 +226,7 @@ def generate_budget_from_kpis(
period = f"{year}-{month:02d}"
results = []
total_amount = 0
_kpi_entities = set() # 2026-09-23 ④:审计带账套(取被处理 KPI 自身的账套,不猜会话账套)
for item in items:
kpi_id = item.get("kpi_id")
@@ -238,6 +240,7 @@ def generate_budget_from_kpis(
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
continue
_kpi_entities.add(getattr(kpi, "entity_id", None))
# 检查是否已有记录
existing = db.query(BudgetPlan).filter(
@@ -283,20 +286,20 @@ def generate_budget_from_kpis(
"plan_id": plan_id,
})
# 操作日志
log = OperationLog(
user_id=getattr(current_user, "id", None),
action="kpi_generate_budget",
target_type="budget",
detail=json.dumps({
"year": year,
"month": month,
"version": version,
"item_count": len(results),
"total_amount": total_amount,
}, ensure_ascii=False),
)
db.add(log)
# 操作日志(2026-09-23 ④:detail 带账套;跨账套批量 ⇒ 留空 + 哨兵,不猜账套)
_entities = sorted(x for x in _kpi_entities if x is not None)
log_op(db, "kpi_generate_budget", target_type="budget",
user_id=getattr(current_user, "id", None),
detail={
"year": year,
"month": month,
"version": version,
"item_count": len(results),
"total_amount": total_amount,
"kpi_entity_ids": _entities,
},
entity_id=(_entities[0] if len(_entities) == 1 else None),
scope=(SCOPE_BATCH if len(_entities) > 1 else None))
db.commit()
return {
+7 -3
View File
@@ -9,6 +9,7 @@ from typing import Optional
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.utils.oplog import log_op
from app.models import KPIValue, DataSourceConfig, OperationLog, KPIDefinition
# ERP 直连同步通道停用标记(2026-08-31 OPS 安全加固:erp-gateway DB 凭据已删除)
@@ -365,7 +366,8 @@ def create_source(data: dict, db: Session = Depends(get_db), entity_id: int = De
db.commit()
db.refresh(source)
# 操作日志
db.add(OperationLog(action="create_source", target_type="source", detail=source.name))
log_op(db, "create_source", target_type="source", target_id=source.id,
detail=source.name, entity_id=entity_id)
db.commit()
return {"data": {c.name: getattr(source, c.name) for c in DataSourceConfig.__table__.columns}}
@@ -382,7 +384,8 @@ def update_source(source_id: int, data: dict, db: Session = Depends(get_db), ent
setattr(source, key, data[key])
db.commit()
db.refresh(source)
db.add(OperationLog(action="update_source", target_type="source", detail=source.name))
log_op(db, "update_source", target_type="source", target_id=source.id,
detail=source.name, entity_id=entity_id)
db.commit()
return {"data": {c.name: getattr(source, c.name) for c in DataSourceConfig.__table__.columns}}
@@ -394,7 +397,8 @@ def delete_source(source_id: int, db: Session = Depends(get_db), entity_id: int
).first()
if not source:
raise HTTPException(404, "数据源不存在")
db.add(OperationLog(action="delete_source", target_type="source", detail=source.name))
log_op(db, "delete_source", target_type="source", target_id=source.id,
detail=source.name, entity_id=entity_id)
db.delete(source)
db.commit()
return {"message": "删除成功"}
+1
View File
@@ -203,6 +203,7 @@ def push_deviation_to_map(
target_type="map",
target_id=map_id,
detail=json.dumps({
"entity_id": entity_id, # 2026-09-23 ④:审计带账套(=会话账套)
"node_id": node_id,
"deviation_id": deviation_id,
"kpi_code": kpi_code,
+8 -7
View File
@@ -10,6 +10,7 @@ from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import SystemConfig, OperationLog
from app.utils.oplog import log_op
from app.models.driver_budget import DriverFactorTemplate, DriverFactorBudget
router = APIRouter(
@@ -126,6 +127,7 @@ def set_driver_mode(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""切换预算编制模式: subject(科目模式) / driver(驱动因子模式)"""
mode = data.get("mode", "subject")
@@ -145,13 +147,11 @@ def set_driver_mode(
db.add(cfg)
db.commit()
log = OperationLog(
action="update",
target_type="budget",
target_id=0,
detail=json.dumps({"mode": mode, "action": "切换编制模式"}, ensure_ascii=False),
)
db.add(log)
# 2026-09-23 ④:审计带账套(会话账套)+ 显式标注该键当前仍是**全局键**
#(budget_driver_mode 拆账套 = 另单排期项,见 dama-entity-isolation-sweep-20260923 §二之三)
log_op(db, "update", target_type="budget", target_id=0,
detail={"mode": mode, "action": "切换编制模式", "entity_scope": "global_key"},
entity_id=entity_id)
db.commit()
return {"message": f"已切换为{'驱动因子模式' if mode == 'driver' else '科目模式'}", "mode": mode}
@@ -335,6 +335,7 @@ def driver_calculate(
target_type="budget",
target_id=budget_record.id,
detail=json.dumps({
"entity_id": entity_id, # 2026-09-23 ④:审计带账套(=会话账套)
"name": name,
"formula": formula_text,
"factors": complete_factors,
+7 -4
View File
@@ -12,6 +12,7 @@ from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPICausality, KPIValue, OperationLog
from app.utils.oplog import log_op
from app.services.causality_verification import (
ALL_STATUSES,
ALL_SOURCE_TYPES,
@@ -356,8 +357,9 @@ def create_causality(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES
db.add(c)
db.commit()
db.refresh(c)
db.add(OperationLog(action="create", target_type="kpi_causality",
detail=f"创建因果链: {src.kpi_code}→{tgt.kpi_code} (source={source_type})"))
log_op(db, "create", target_type="kpi_causality", target_id=c.id,
detail=f"创建因果链: {src.kpi_code}→{tgt.kpi_code} (source={source_type})",
entity_id=c.entity_id)
db.commit()
return _to_dict(c)
@@ -407,8 +409,9 @@ def verify_causality(causality_id: int, data: dict, db: Session = Depends(get_db
c.verify_status = target_status
c.verified_at = datetime.now()
c.verified_by = str(verified_by)[:50]
db.add(OperationLog(action="verify", target_type="kpi_causality",
detail=f"因果链 #{causality_id} 人工确认: {target_status} (by {verified_by})"))
log_op(db, "verify", target_type="kpi_causality", target_id=causality_id,
detail=f"因果链 #{causality_id} 人工确认: {target_status} (by {verified_by})",
entity_id=c.entity_id)
db.commit()
db.refresh(c)
d = _to_dict(c)
+9 -7
View File
@@ -10,6 +10,7 @@ from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role, filter_kpis_by_role, kpi_visible_dims
from app.models import StrategicMap, MapObjective, KPIDefinition, KPIValue, KPIAlert, OperationLog, Entity, KPICausality, KPIHierarchy, User
from app.utils.oplog import log_op
from app.api.kpi_governance import validate_kpi_payload, kpi_issues_message
from app.utils.threshold_parser import (
calc_level as _tp_calc_level,
@@ -794,7 +795,7 @@ def create_kpi(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES, enti
db.add(kpi)
db.commit()
db.refresh(kpi)
_log(db, 1, "create", "kpi", kpi.id, data)
_log(db, 1, "create", "kpi", kpi.id, data, entity_id=entity_id)
return kpi_to_dict(kpi)
@@ -834,7 +835,7 @@ def update_kpi(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRIT
if hasattr(kpi, k) and v is not None:
setattr(kpi, k, v)
db.commit()
_log(db, 1, "update", "kpi", kpi_id, data)
_log(db, 1, "update", "kpi", kpi_id, data, entity_id=entity_id)
return kpi_to_dict(kpi)
@@ -907,14 +908,15 @@ def associate_kpi_map(kpi_id: int, data: dict, db: Session = Depends(get_db), us
raise HTTPException(404, "战略地图不存在")
kpi.map_id = map_id
db.commit()
_log(db, 1, "update", "kpi", kpi_id, {"action": "associate-map", "map_id": map_id})
_log(db, 1, "update", "kpi", kpi_id, {"action": "associate-map", "map_id": map_id},
entity_id=entity_id)
return kpi_to_dict(kpi)
def _log(db, user_id, action, target_type, target_id, detail):
log = OperationLog(user_id=user_id, action=action, target_type=target_type, target_id=target_id, detail=json.dumps(detail, ensure_ascii=False) if detail else None)
db.add(log)
db.commit()
def _log(db, user_id, action, target_type, target_id, detail, entity_id=None):
"""统一走 log_op(2026-09-23 ④:审计写入统一带 `detail.entity_id`)"""
return log_op(db, action, target_type=target_type, target_id=target_id,
detail=detail, user_id=user_id, entity_id=entity_id, commit=True)
@router.get("/{kpi_id}/objectives")
+1
View File
@@ -2971,6 +2971,7 @@ def generate_report(
target_type="report",
target_id=record.id,
detail=json.dumps({
"entity_id": entity_id, # 2026-09-23 ④:审计带账套(=会话账套)
"report_type": req.report_type,
"period": period,
"trigger_type": req.trigger_type,
+10 -8
View File
@@ -10,6 +10,7 @@ from app.database import get_db
from app.auth_middleware import require_role
from app.deps import get_entity_id
from app.models import KPITemplate, KPIDefinition, OperationLog
from app.utils.oplog import log_op, SCOPE_GLOBAL
from app.api.kpi_governance import validate_kpi_payload, kpi_issues_message
router = APIRouter(prefix="/api/cma/templates", tags=["KPI模板库"],
@@ -75,7 +76,9 @@ def create_template(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES)
db.add(t)
db.commit()
db.refresh(t)
_log(db, 1, "create", "template", t.id, {"kpi_code": t.kpi_code, "kpi_name": t.kpi_name})
# kpi_templates = 系统预置/用户自定义**模板库**(非账套维度,裁定见 docs/entity-dimension-data-dictionary.md)
_log(db, 1, "create", "template", t.id, {"kpi_code": t.kpi_code, "kpi_name": t.kpi_name},
scope=SCOPE_GLOBAL)
return template_to_dict(t)
@@ -174,13 +177,12 @@ def instantiate_template(template_id: int, data: dict, db: Session = Depends(get
t.usage_count = (t.usage_count or 0) + 1
db.commit()
_log(db, 1, "create", "kpi", kpi.id, {"from_template": template_id, "kpi_code": kpi.kpi_code})
_log(db, 1, "create", "kpi", kpi.id, {"from_template": template_id, "kpi_code": kpi.kpi_code},
entity_id=entity_id)
return {c.name: getattr(kpi, c.name) for c in kpi.__table__.columns}
def _log(db, user_id, action, target_type, target_id, detail):
import json
log = OperationLog(user_id=user_id, action=action, target_type=target_type,
target_id=target_id, detail=json.dumps(detail, ensure_ascii=False) if detail else None)
db.add(log)
db.commit()
def _log(db, user_id, action, target_type, target_id, detail, entity_id=None, scope=None):
"""统一走 log_op(2026-09-23 ④:审计写入统一带 `detail.entity_id`)"""
return log_op(db, action, target_type=target_type, target_id=target_id,
detail=detail, user_id=user_id, entity_id=entity_id, scope=scope, commit=True)