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_type="kpi",
target_id=kpi.id, target_id=kpi.id,
detail={ detail={
"entity_id": sug.entity_id, # 2026-09-23 ④:审计带账套(=建议账套,端点已校验=会话账套)
"suggestion_id": sug.id, "suggestion_id": sug.id,
"suggestion_title": sug.title, "suggestion_title": sug.title,
"apply_action": "kpi_target", "apply_action": "kpi_target",
@@ -218,6 +219,7 @@ def _apply_budget_adjust(db: Session, sug: AISuggestion, params: dict, current_u
target_type="budget", target_type="budget",
target_id=plan.id, target_id=plan.id,
detail={ detail={
"entity_id": sug.entity_id, # 2026-09-23 ④:审计带账套(=建议账套)
"suggestion_id": sug.id, "suggestion_id": sug.id,
"suggestion_title": sug.title, "suggestion_title": sug.title,
"apply_action": "budget_adjust", "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_type="action_plan",
target_id=plan.id, target_id=plan.id,
detail={ detail={
"entity_id": sug.entity_id, # 2026-09-23 ④:审计带账套(=建议账套)
"suggestion_id": sug.id, "suggestion_id": sug.id,
"suggestion_title": sug.title, "suggestion_title": sug.title,
"apply_action": "action_plan", "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.deps import get_entity_id
from app.auth_middleware import require_auth, require_role from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPIValue, KPIAlert, OperationLog from app.models import KPIDefinition, KPIValue, KPIAlert, OperationLog
from app.utils.oplog import log_op
# 裁定 R1(2026-09-19 财务口径):demo 账套不参与预警生成/遍历(单一事实源) # 裁定 R1(2026-09-19 财务口径):demo 账套不参与预警生成/遍历(单一事实源)
# 裁定 R5(2026-09-19 财务口径):期间非规整月 YYYY-MM → 该 kpi_alerts 行不入库(生成侧守卫) # 裁定 R5(2026-09-19 财务口径):期间非规整月 YYYY-MM → 该 kpi_alerts 行不入库(生成侧守卫)
from app.utils.alert_entity_scope import guard_alert_period, is_excluded_entity 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.refresh(rule)
# 日志 # 日志
db.add(OperationLog( log_op(db, "create_alert_rule", target_type="alert_rule", target_id=rule.id,
action="create_alert_rule", target_type="alert_rule", detail=f"KPI={kpi.kpi_code}({kpi.kpi_name}) type={rule_type}",
detail=f"KPI={kpi.kpi_code}({kpi.kpi_name}) type={rule_type}", entity_id=entity_id)
))
db.commit() db.commit()
return {"data": {c.name: getattr(rule, c.name) for c in AlertRule.__table__.columns}} 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.deps import get_entity_id
from app.auth_middleware import require_auth, require_role from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPIValue, BiReportTemplate, BiReport, OperationLog, KPICausality from app.models import KPIDefinition, KPIValue, BiReportTemplate, BiReport, OperationLog, KPICausality
from app.utils.oplog import log_op
logger = logging.getLogger("bi-reports") 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.add(r)
db.commit() db.commit()
db.refresh(r) 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() db.commit()
return {c.name: getattr(r, c.name) for c in BiReport.__table__.columns} 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 from app.risk_levels import risk_level
# 裁定 R1/R5(2026-09-19 财务口径):demo 账套任何写通道不产生业务预警;期间非规整月不入库(单一事实源) # 裁定 R1/R5(2026-09-19 财务口径):demo 账套任何写通道不产生业务预警;期间非规整月不入库(单一事实源)
from app.utils.alert_entity_scope import entity_alert_guard, guard_alert_period from app.utils.alert_entity_scope import entity_alert_guard, guard_alert_period
from app.utils.oplog import log_op, SCOPE_GLOBAL
import json import json
logger = logging.getLogger("cma.bot_bridge") 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 before = db.execute(text("SELECT COUNT(*) FROM zxt_account_dict")).scalar() or 0
db.execute(stmt, clean) db.execute(stmt, clean)
after = db.execute(text("SELECT COUNT(*) FROM zxt_account_dict")).scalar() or 0 after = db.execute(text("SELECT COUNT(*) FROM zxt_account_dict")).scalar() or 0
db.add(OperationLog( log_op(db, "import", target_type="zxt_account_dict", target_id=None,
action="import", target_type="zxt_account_dict", target_id=None, detail={"bot": bot.get("name"), "endpoint": "/api/cma/bot/dict/zxt-account",
detail={"bot": bot.get("name"), "endpoint": "/api/cma/bot/dict/zxt-account", "upserted": len(clean), "before": before, "after": after, "source": source},
"upserted": len(clean), "before": before, "after": after, "source": source}, # zxt_account_dict = 智享通平台级科目字典(无 entity 列)⇒ 显式声明非账套维度
)) entity_id=None, scope=SCOPE_GLOBAL)
db.commit() db.commit()
except HTTPException: except HTTPException:
raise raise
@@ -1263,13 +1264,12 @@ def bot_subjects_upsert(
}, synchronize_session=False) }, synchronize_session=False)
db.flush() db.flush()
after = db.query(func.count(Subject.id)).scalar() or 0 after = db.query(func.count(Subject.id)).scalar() or 0
db.add(OperationLog( log_op(db, "import", target_type="subjects", target_id=None,
action="import", target_type="subjects", target_id=None, detail={"bot": bot.get("name"), "endpoint": "/api/cma/bot/subjects",
detail={"bot": bot.get("name"), "endpoint": "/api/cma/bot/subjects", "risk_level": "L3", "inserted": len(to_insert), "updated": len(to_update),
"risk_level": "L3", "inserted": len(to_insert), "updated": len(to_update), "skipped": len(to_skip), "before": before, "after": after,
"skipped": len(to_skip), "before": before, "after": after, "update_existing": update_existing, "source": "json"},
"update_existing": update_existing, "source": "json"}, entity_id=entity_id)
))
db.commit() db.commit()
except HTTPException: except HTTPException:
raise raise
+2 -1
View File
@@ -137,7 +137,8 @@ def create_budget_plan(
action="create", action="create",
target_type="budget", target_type="budget",
target_id=plan.id, 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.add(log)
db.commit() 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.deps import get_entity_id
from app.auth_middleware import require_auth, require_role from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, BudgetPlan, KPIValue, OperationLog from app.models import KPIDefinition, BudgetPlan, KPIValue, OperationLog
from app.utils.oplog import log_op, SCOPE_BATCH
import json import json
import logging import logging
from datetime import datetime from datetime import datetime
@@ -225,6 +226,7 @@ def generate_budget_from_kpis(
period = f"{year}-{month:02d}" period = f"{year}-{month:02d}"
results = [] results = []
total_amount = 0 total_amount = 0
_kpi_entities = set() # 2026-09-23 ④:审计带账套(取被处理 KPI 自身的账套,不猜会话账套)
for item in items: for item in items:
kpi_id = item.get("kpi_id") 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() kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi: if not kpi:
continue continue
_kpi_entities.add(getattr(kpi, "entity_id", None))
# 检查是否已有记录 # 检查是否已有记录
existing = db.query(BudgetPlan).filter( existing = db.query(BudgetPlan).filter(
@@ -283,20 +286,20 @@ def generate_budget_from_kpis(
"plan_id": plan_id, "plan_id": plan_id,
}) })
# 操作日志 # 操作日志(2026-09-23 ④:detail 带账套;跨账套批量 ⇒ 留空 + 哨兵,不猜账套)
log = OperationLog( _entities = sorted(x for x in _kpi_entities if x is not None)
user_id=getattr(current_user, "id", None), log_op(db, "kpi_generate_budget", target_type="budget",
action="kpi_generate_budget", user_id=getattr(current_user, "id", None),
target_type="budget", detail={
detail=json.dumps({ "year": year,
"year": year, "month": month,
"month": month, "version": version,
"version": version, "item_count": len(results),
"item_count": len(results), "total_amount": total_amount,
"total_amount": total_amount, "kpi_entity_ids": _entities,
}, ensure_ascii=False), },
) entity_id=(_entities[0] if len(_entities) == 1 else None),
db.add(log) scope=(SCOPE_BATCH if len(_entities) > 1 else None))
db.commit() db.commit()
return { return {
+7 -3
View File
@@ -9,6 +9,7 @@ from typing import Optional
from app.database import get_db from app.database import get_db
from app.deps import get_entity_id from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role from app.auth_middleware import require_auth, require_role
from app.utils.oplog import log_op
from app.models import KPIValue, DataSourceConfig, OperationLog, KPIDefinition from app.models import KPIValue, DataSourceConfig, OperationLog, KPIDefinition
# ERP 直连同步通道停用标记(2026-08-31 OPS 安全加固:erp-gateway DB 凭据已删除) # 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.commit()
db.refresh(source) 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() db.commit()
return {"data": {c.name: getattr(source, c.name) for c in DataSourceConfig.__table__.columns}} 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]) setattr(source, key, data[key])
db.commit() db.commit()
db.refresh(source) 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() db.commit()
return {"data": {c.name: getattr(source, c.name) for c in DataSourceConfig.__table__.columns}} 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() ).first()
if not source: if not source:
raise HTTPException(404, "数据源不存在") 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.delete(source)
db.commit() db.commit()
return {"message": "删除成功"} return {"message": "删除成功"}
+1
View File
@@ -203,6 +203,7 @@ def push_deviation_to_map(
target_type="map", target_type="map",
target_id=map_id, target_id=map_id,
detail=json.dumps({ detail=json.dumps({
"entity_id": entity_id, # 2026-09-23 ④:审计带账套(=会话账套)
"node_id": node_id, "node_id": node_id,
"deviation_id": deviation_id, "deviation_id": deviation_id,
"kpi_code": kpi_code, "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.deps import get_entity_id
from app.auth_middleware import require_auth, require_role from app.auth_middleware import require_auth, require_role
from app.models import SystemConfig, OperationLog from app.models import SystemConfig, OperationLog
from app.utils.oplog import log_op
from app.models.driver_budget import DriverFactorTemplate, DriverFactorBudget from app.models.driver_budget import DriverFactorTemplate, DriverFactorBudget
router = APIRouter( router = APIRouter(
@@ -126,6 +127,7 @@ def set_driver_mode(
data: dict, data: dict,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user=Depends(require_auth), current_user=Depends(require_auth),
entity_id: int = Depends(get_entity_id),
): ):
"""切换预算编制模式: subject(科目模式) / driver(驱动因子模式)""" """切换预算编制模式: subject(科目模式) / driver(驱动因子模式)"""
mode = data.get("mode", "subject") mode = data.get("mode", "subject")
@@ -145,13 +147,11 @@ def set_driver_mode(
db.add(cfg) db.add(cfg)
db.commit() db.commit()
log = OperationLog( # 2026-09-23 ④:审计带账套(会话账套)+ 显式标注该键当前仍是**全局键**
action="update", #(budget_driver_mode 拆账套 = 另单排期项,见 dama-entity-isolation-sweep-20260923 §二之三)
target_type="budget", log_op(db, "update", target_type="budget", target_id=0,
target_id=0, detail={"mode": mode, "action": "切换编制模式", "entity_scope": "global_key"},
detail=json.dumps({"mode": mode, "action": "切换编制模式"}, ensure_ascii=False), entity_id=entity_id)
)
db.add(log)
db.commit() db.commit()
return {"message": f"已切换为{'驱动因子模式' if mode == 'driver' else '科目模式'}", "mode": mode} return {"message": f"已切换为{'驱动因子模式' if mode == 'driver' else '科目模式'}", "mode": mode}
@@ -335,6 +335,7 @@ def driver_calculate(
target_type="budget", target_type="budget",
target_id=budget_record.id, target_id=budget_record.id,
detail=json.dumps({ detail=json.dumps({
"entity_id": entity_id, # 2026-09-23 ④:审计带账套(=会话账套)
"name": name, "name": name,
"formula": formula_text, "formula": formula_text,
"factors": complete_factors, "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.deps import get_entity_id
from app.auth_middleware import require_auth, require_role from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPICausality, KPIValue, OperationLog from app.models import KPIDefinition, KPICausality, KPIValue, OperationLog
from app.utils.oplog import log_op
from app.services.causality_verification import ( from app.services.causality_verification import (
ALL_STATUSES, ALL_STATUSES,
ALL_SOURCE_TYPES, ALL_SOURCE_TYPES,
@@ -356,8 +357,9 @@ def create_causality(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES
db.add(c) db.add(c)
db.commit() db.commit()
db.refresh(c) db.refresh(c)
db.add(OperationLog(action="create", target_type="kpi_causality", log_op(db, "create", target_type="kpi_causality", target_id=c.id,
detail=f"创建因果链: {src.kpi_code}→{tgt.kpi_code} (source={source_type})")) detail=f"创建因果链: {src.kpi_code}→{tgt.kpi_code} (source={source_type})",
entity_id=c.entity_id)
db.commit() db.commit()
return _to_dict(c) 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.verify_status = target_status
c.verified_at = datetime.now() c.verified_at = datetime.now()
c.verified_by = str(verified_by)[:50] c.verified_by = str(verified_by)[:50]
db.add(OperationLog(action="verify", target_type="kpi_causality", log_op(db, "verify", target_type="kpi_causality", target_id=causality_id,
detail=f"因果链 #{causality_id} 人工确认: {target_status} (by {verified_by})")) detail=f"因果链 #{causality_id} 人工确认: {target_status} (by {verified_by})",
entity_id=c.entity_id)
db.commit() db.commit()
db.refresh(c) db.refresh(c)
d = _to_dict(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.deps import get_entity_id
from app.auth_middleware import require_auth, require_role, filter_kpis_by_role, kpi_visible_dims 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.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.api.kpi_governance import validate_kpi_payload, kpi_issues_message
from app.utils.threshold_parser import ( from app.utils.threshold_parser import (
calc_level as _tp_calc_level, 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.add(kpi)
db.commit() db.commit()
db.refresh(kpi) 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) 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: if hasattr(kpi, k) and v is not None:
setattr(kpi, k, v) setattr(kpi, k, v)
db.commit() 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) 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, "战略地图不存在") raise HTTPException(404, "战略地图不存在")
kpi.map_id = map_id kpi.map_id = map_id
db.commit() 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) return kpi_to_dict(kpi)
def _log(db, user_id, action, target_type, target_id, detail): def _log(db, user_id, action, target_type, target_id, detail, entity_id=None):
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) """统一走 log_op(2026-09-23 ④:审计写入统一带 `detail.entity_id`)"""
db.add(log) return log_op(db, action, target_type=target_type, target_id=target_id,
db.commit() detail=detail, user_id=user_id, entity_id=entity_id, commit=True)
@router.get("/{kpi_id}/objectives") @router.get("/{kpi_id}/objectives")
+1
View File
@@ -2971,6 +2971,7 @@ def generate_report(
target_type="report", target_type="report",
target_id=record.id, target_id=record.id,
detail=json.dumps({ detail=json.dumps({
"entity_id": entity_id, # 2026-09-23 ④:审计带账套(=会话账套)
"report_type": req.report_type, "report_type": req.report_type,
"period": period, "period": period,
"trigger_type": req.trigger_type, "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.auth_middleware import require_role
from app.deps import get_entity_id from app.deps import get_entity_id
from app.models import KPITemplate, KPIDefinition, OperationLog 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 from app.api.kpi_governance import validate_kpi_payload, kpi_issues_message
router = APIRouter(prefix="/api/cma/templates", tags=["KPI模板库"], 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.add(t)
db.commit() db.commit()
db.refresh(t) 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) 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 t.usage_count = (t.usage_count or 0) + 1
db.commit() 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} return {c.name: getattr(kpi, c.name) for c in kpi.__table__.columns}
def _log(db, user_id, action, target_type, target_id, detail): def _log(db, user_id, action, target_type, target_id, detail, entity_id=None, scope=None):
import json """统一走 log_op(2026-09-23 ④:审计写入统一带 `detail.entity_id`)"""
log = OperationLog(user_id=user_id, action=action, target_type=target_type, return log_op(db, action, target_type=target_type, target_id=target_id,
target_id=target_id, detail=json.dumps(detail, ensure_ascii=False) if detail else None) detail=detail, user_id=user_id, entity_id=entity_id, scope=scope, commit=True)
db.add(log)
db.commit()
+107
View File
@@ -0,0 +1,107 @@
# -*- coding: utf-8 -*-
"""操作审计写入统一入口(2026-09-23,`dama-entity-isolation-sweep-20260923` §二之二④)。
**约定(只动写侧;不加列、不改读侧口径、不回填历史)**:
1. 每条 `operation_logs` **尽量带 `detail.entity_id`** —— 语义是「**本动作所属账套**」:
会话账套(`Depends(get_entity_id)`)/ Bot 入参账套 / 被处理对象自身的账套。
2. 确实没有账套上下文的写入口 → `entity_id=None`,并打一条 `OPLOG_NO_ENTITY` 警告:
把覆盖缺口留成**可 grep 的哨兵**(下一轮审计收口有据可查),
**绝不猜账套** —— 猜错比留空更有害(错账套会让 `knowledge_service` 归集时又串一次)。
3. 新的审计写入口一律走 `log_op()`,不要新写 `OperationLog(...)` 构造(第 28 处必漏)。
**读侧(本轮不动)**:`app/services/knowledge_service.py::extract_events` 仍按
`user_id → user_entities` 兜底过滤 —— `user_id IS NULL` 的 Bot/脚本日志因此进不了记忆抽取
(**覆盖缺口,非泄漏**)。`detail.entity_id` 到位后下一轮才切读侧。
"""
from __future__ import annotations
import json
import logging
from app.models import OperationLog
logger = logging.getLogger("cma.oplog")
#: 哨兵前缀(供 grep / 告警链订阅):无账套上下文的写入口
NO_ENTITY_TAG = "OPLOG_NO_ENTITY"
#: 哨兵前缀:跨账套批量写(一次动作覆盖多账套,无法归单一账套)——覆盖缺口,待下一轮收口
BATCH_NO_ENTITY_TAG = "OPLOG_BATCH_NO_ENTITY"
#: `scope` 取值:显式声明「该写入口不是账套维度」(平台字典表 / 角色级配置),不再报缺口
SCOPE_GLOBAL = "global"
#: `scope` 取值:一次动作跨多账套批量(无法归单账套)——照实报警,不猜账套
SCOPE_BATCH = "batch"
def _as_detail_dict(detail) -> dict:
"""把三种历史 detail 写法归一到 dict(**不改变已有存储形态语义**)。
- ``dict`` → 直接用(复制一份,不就地改调用方的对象)
- JSON **对象**字符串(历史写法 ``json.dumps({...})``)→ 解析为 dict,与 entity_id 合并进同一对象
- 其他字符串 → ``{"note": <原文>}``(原先以标量字符串存的行,升级为对象并附账套)
"""
if detail is None:
return {}
if isinstance(detail, dict):
return dict(detail)
if isinstance(detail, str):
s = detail.strip()
if s.startswith("{"):
try:
v = json.loads(s)
except (ValueError, TypeError):
v = None
if isinstance(v, dict):
return v
return {"note": detail}
return {"note": str(detail)}
def log_op(db, action, *, target_type=None, target_id=None, detail=None,
user_id=None, entity_id=None, scope=None, commit=False):
"""写一条 `operation_logs`,统一带账套(`detail.entity_id`)。
:param db: SQLAlchemy Session
:param action: 动作名(DB 非空列)
:param target_type: 目标类型(DB 非空列)
:param target_id: 目标 id(可空)
:param detail: dict(推荐)/ JSON 对象字符串 / 普通 str(存为 ``{"note": <原文>}``)/ None
:param user_id: 操作人;Bot/脚本无用户时 None(读侧本轮仍按 user_id 兜底)
:param entity_id: **本动作所属账套**;无上下文时 None(触发 OPLOG_NO_ENTITY 哨兵)
:param scope: ``SCOPE_GLOBAL`` = 该写入口按设计非账套维度(写 ``detail.entity_scope``,不报警);
``SCOPE_BATCH`` = 跨账套批量(写 ``entity_scope=batch`` **并**报警,不猜账套);
None = 未知(报警,留可 grep 的覆盖缺口)
:param commit: 是否立即提交(默认 False,交由调用方事务边界)
"""
d = _as_detail_dict(detail)
# 调用方若已把账套写在 detail 里(历史写法),沿用之,避免同一个键两套来源
if entity_id is None and d.get("entity_id") is not None:
entity_id = d["entity_id"]
if entity_id is not None:
d["entity_id"] = int(entity_id)
elif scope == SCOPE_GLOBAL:
d["entity_scope"] = SCOPE_GLOBAL
elif scope == SCOPE_BATCH:
d["entity_scope"] = SCOPE_BATCH
logger.warning(
"%s action=%s target_type=%s target_id=%s — 跨账套批量写,detail.entity_id 留空"
"(禁止猜账套)", BATCH_NO_ENTITY_TAG, action, target_type, target_id)
else:
logger.warning(
"%s action=%s target_type=%s target_id=%s — 该写入口无账套上下文,"
"detail.entity_id 留空(禁止猜账套)", NO_ENTITY_TAG, action, target_type, target_id)
row = OperationLog(
user_id=user_id,
action=action,
target_type=target_type,
target_id=target_id,
detail=d or None,
)
db.add(row)
if commit:
db.commit()
return row
+1
View File
@@ -16,6 +16,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.database import get_session_local from app.database import get_session_local
from app.models import KPIDefinition, KPIValue, KPIAlert, ActionPlan, OperationLog from app.models import KPIDefinition, KPIValue, KPIAlert, ActionPlan, OperationLog
from app.utils.oplog import log_op
from app.utils.threshold_parser import eval_threshold, LEVEL_PRIORITY from app.utils.threshold_parser import eval_threshold, LEVEL_PRIORITY
# 裁定2a(2026-09-17 财务口径):比率类指标统一「百分比(%)」口径(取数×100), # 裁定2a(2026-09-17 财务口径):比率类指标统一「百分比(%)」口径(取数×100),
# 未核对/疑似单位错配的项降级待核(不落预警、不推送) # 未核对/疑似单位错配的项降级待核(不落预警、不推送)
+13 -10
View File
@@ -23,6 +23,7 @@ load_dotenv()
from app.database import get_engine, get_session_local from app.database import get_engine, get_session_local
from app.models import KPIDefinition, KPIValue, OperationLog, DataSourceConfig from app.models import KPIDefinition, KPIValue, OperationLog, DataSourceConfig
from app.utils.oplog import log_op, SCOPE_BATCH
logger = logging.getLogger("erp_sync") logger = logging.getLogger("erp_sync")
@@ -421,16 +422,18 @@ def run_sync(dry_run: bool = False, kpi_codes: list = None, use_api: bool = True
fail += 1 fail += 1
if not dry_run: if not dry_run:
log = OperationLog( # 2026-09-23 ④:审计带账套。本脚本一次遍历**全库 active ERP KPI(可能跨账套)**:
action="erp_sync", # 全部同账套 ⇒ 写该账套;跨账套 ⇒ 留空 + OPLOG_BATCH_NO_ENTITY 哨兵(不猜账套)
target_type="kpi", _entity_ids = sorted({getattr(k, "entity_id", None) for k in kpis} - {None})
detail=json.dumps({ log_op(db, "erp_sync", target_type="kpi",
"total": len(kpis), "success": success, detail={
"failed": fail, "api_mode": use_api, "total": len(kpis), "success": success,
"period": datetime.now().strftime("%Y-%m"), "failed": fail, "api_mode": use_api,
}, ensure_ascii=False), "period": datetime.now().strftime("%Y-%m"),
) "kpi_entity_ids": _entity_ids,
db.add(log) },
entity_id=(_entity_ids[0] if len(_entity_ids) == 1 else None),
scope=(SCOPE_BATCH if len(_entity_ids) != 1 else None))
db.commit() db.commit()
logger.info(f"同步完成: {success}成功 / {fail}失败 / {len(kpis)}总计") logger.info(f"同步完成: {success}成功 / {fail}失败 / {len(kpis)}总计")
@@ -28,6 +28,8 @@ import os
import re import re
import sys import sys
import ast
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import create_engine, inspect, text from sqlalchemy import create_engine, inspect, text
@@ -35,6 +37,8 @@ from sqlalchemy.orm import Session
from app.models import Entity, OperationLog, RolePermission, SystemConfig from app.models import Entity, OperationLog, RolePermission, SystemConfig
from app.database import Base from app.database import Base
from app.utils.oplog import (BATCH_NO_ENTITY_TAG, NO_ENTITY_TAG, SCOPE_BATCH,
SCOPE_GLOBAL, log_op)
from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user
BUDGET = "/api/cma/budget" BUDGET = "/api/cma/budget"
@@ -378,3 +382,157 @@ class TestMigrationScript:
with pytest.raises(SystemExit) as e: with pytest.raises(SystemExit) as e:
self._run(mod, ["--apply", "--allow-db", "sqlite", "--target-entity", "99", "--json"]) self._run(mod, ["--apply", "--allow-db", "sqlite", "--target-entity", "99", "--json"])
assert e.value.code == 2 assert e.value.code == 2
# ══════════════════════════════════════════════
# ④ operation_logs 写侧统一带账套(`detail.entity_id`,零 DDL)
# 任务单 §二之二④(项目Bot 2026-09-23 18:2x 并入)
# ══════════════════════════════════════════════
BACKEND_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OPLOG_REL = os.path.join("app", "utils", "oplog.py")
def _operation_log_construction_sites():
"""全仓 `OperationLog(` 构造点 → [(相对路径, 行号, 所在语句源码)]
语句源码取「包含该构造调用的完整 Assign/Expr 语句」(多行 dict 字面量整段在内),
用于断言「写入时带账套」这件事落在**语句级**,而不是碰巧命中了别处的 entity_id。
"""
out = []
for rel in ("app", "scripts"):
for dp, _dn, fns in os.walk(os.path.join(BACKEND_ROOT, rel)):
if "__pycache__" in dp:
continue
for fn in fns:
if not fn.endswith(".py"):
continue
path = os.path.join(dp, fn)
src = open(path, encoding="utf-8").read()
tree = ast.parse(src)
lines = src.splitlines()
stmts = [(n.lineno, n.end_lineno) for n in ast.walk(tree)
if isinstance(n, (ast.Assign, ast.Expr, ast.Return))
and getattr(n, "lineno", None) and getattr(n, "end_lineno", None)]
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
fn_node = node.func
name = getattr(fn_node, "id", None) or getattr(fn_node, "attr", None)
if name != "OperationLog":
continue
cand = [s for s in stmts if s[0] <= node.lineno and s[1] >= node.end_lineno]
s, e = min(cand, key=lambda x: x[1] - x[0]) if cand else (node.lineno, node.end_lineno)
out.append((os.path.relpath(path, BACKEND_ROOT).replace("\\", "/"),
node.lineno, "\n".join(lines[s - 1:e])))
return out
class TestAuditWriteSideEntityId:
"""④ 写侧收口:所有审计行带账套,且集中封装(不加列、不回填历史)"""
def test_no_new_raw_OperationLog_sites_lack_entity(self):
"""源级断言:除集中封装件外,每个构造点的语句里必须出现 entity_id"""
sites = _operation_log_construction_sites()
assert len(sites) >= 13, f"构造点数量异常(疑似收集失败): {len(sites)}"
missing = [(p, l) for p, l, seg in sites
if p != OPLOG_REL and "entity_id" not in seg]
assert missing == [], f"以下构造点未带账套(禁止猜账套,必须显式): {missing}"
def test_wrapper_is_single_place_constructing_rows(self):
"""集中封装:裸构造 OperationLog 只允许出现在 app/utils/oplog.py(防第 28 处再漏)"""
wrappers = [(p, l) for p, l, _seg in _operation_log_construction_sites() if p == OPLOG_REL]
assert len(wrappers) == 1, f"oplog.py 应恰好 1 处裸构造(log_op 本体): {wrappers}"
def test_log_op_callers_pass_entity_or_scope(self):
"""所有 `log_op(` 调用点必须传 entity_id= 或 scope=(否则等于留覆盖缺口)"""
bad = []
for rel in ("app", "scripts"):
for dp, _dn, fns in os.walk(os.path.join(BACKEND_ROOT, rel)):
if "__pycache__" in dp:
continue
for fn in fns:
if not fn.endswith(".py"):
continue
path = os.path.join(dp, fn)
tree = ast.parse(open(path, encoding="utf-8").read())
for node in ast.walk(tree):
if isinstance(node, ast.Call) and getattr(node.func, "id", None) == "log_op":
if not ({"entity_id", "scope"} & {k.arg for k in node.keywords}):
bad.append((os.path.relpath(path, BACKEND_ROOT), node.lineno))
assert bad == [], f"log_op 调用点缺账套参数: {bad}"
# ── 落库实证 ────────────────────────────────
def test_readback_entity_id_persisted(self, db: Session):
"""落库实证 ①:log_op 写入 → **raw SELECT 回查数据库**,detail.entity_id 在表里"""
log_op(db, "unit_probe_entity", target_type="kpi", target_id=123,
detail={"kpi_code": "PROBE_KPI"}, entity_id=2, commit=True)
row = db.execute(text("SELECT action, detail FROM operation_logs "
"WHERE action = 'unit_probe_entity'")).fetchone()
assert row is not None, "审计行未落库"
detail = json.loads(row[1]) if isinstance(row[1], str) else row[1]
assert detail.get("entity_id") == 2, f"落库 detail 未带账套: {detail}"
assert detail.get("kpi_code") == "PROBE_KPI", f"原 detail 内容被破坏: {detail}"
def test_readback_kpi_create_endpoint_audit_entity(self, client: TestClient, db: Session):
"""落库实证 ②:真实写端点(POST /kpis,会话账套 e2)→ 回查审计行 detail.entity_id == 2"""
_seed_two_entities(db)
create_test_user(db)
token = _login_as(client, "testadmin", 2) # 博海 e2
resp = client.post("/api/cma/kpis", headers=auth_header(token), json={
"kpi_code": "F_AUDIT_PROBE_E2", "kpi_name": "审计落库探针",
"dimension": "finance", "target_value": 100.0, "unit": "%",
"formula": "测试公式", "data_source": "测试系统", "data_owner": "测试管理员",
})
assert resp.status_code == 200, resp.text[:300]
row = db.execute(text("SELECT detail FROM operation_logs WHERE action = 'create' "
"AND target_type = 'kpi' ORDER BY id DESC LIMIT 1")).scalar()
assert row is not None, "端点未写审计行"
detail = json.loads(row) if isinstance(row, str) else row
assert detail.get("entity_id") == 2, f"审计 detail 未带会话账套: {detail}"
# ── 形态与哨兵 ──────────────────────────────
def test_legacy_json_string_detail_is_not_nested(self, db: Session):
"""历史写法(detail=json.dumps({...}))归一后不嵌套:entity_id 与原键同一层"""
log_op(db, "unit_probe_legacy", target_type="budget", target_id=1,
detail=json.dumps({"year": 2026, "mode": "rolling"}, ensure_ascii=False),
entity_id=1, commit=True)
detail = db.execute(text("SELECT detail FROM operation_logs "
"WHERE action = 'unit_probe_legacy'")).scalar()
detail = json.loads(detail) if isinstance(detail, str) else detail
assert detail == {"year": 2026, "mode": "rolling", "entity_id": 1}, detail
def test_global_scope_no_gap_warning(self, db: Session, caplog):
"""非账套维度(平台/角色级):写 entity_scope=global,且不产生缺口告警"""
with caplog.at_level("WARNING", logger="cma.oplog"):
log_op(db, "unit_probe_global", target_type="template", target_id=1,
detail={"kpi_code": "T1"}, scope=SCOPE_GLOBAL, commit=True)
detail = db.execute(text("SELECT detail FROM operation_logs "
"WHERE action = 'unit_probe_global'")).scalar()
detail = json.loads(detail) if isinstance(detail, str) else detail
assert detail.get("entity_scope") == "global" and "entity_id" not in detail, detail
assert NO_ENTITY_TAG not in caplog.text
def test_missing_context_warns_sentinel_never_guesses(self, db: Session, caplog):
"""无账套上下文:留空 + 可 grep 哨兵(绝不猜账套)"""
with caplog.at_level("WARNING", logger="cma.oplog"):
log_op(db, "unit_probe_unknown", target_type="kpi", target_id=1,
detail={"note": "no context"}, commit=True)
assert NO_ENTITY_TAG in caplog.text
detail = db.execute(text("SELECT detail FROM operation_logs "
"WHERE action = 'unit_probe_unknown'")).scalar()
detail = json.loads(detail) if isinstance(detail, str) else detail
assert "entity_id" not in detail, detail
def test_batch_scope_warns_own_sentinel(self, db: Session, caplog):
"""跨账套批量:entity_scope=batch + 独立哨兵(不猜单账套)"""
with caplog.at_level("WARNING", logger="cma.oplog"):
log_op(db, "unit_probe_batch", target_type="kpi", detail={"total": 3},
scope=SCOPE_BATCH, commit=True)
assert BATCH_NO_ENTITY_TAG in caplog.text
detail = db.execute(text("SELECT detail FROM operation_logs "
"WHERE action = 'unit_probe_batch'")).scalar()
detail = json.loads(detail) if isinstance(detail, str) else detail
assert detail.get("entity_scope") == "batch", detail
+162
View File
@@ -0,0 +1,162 @@
# CMA 账套维度数据字典(entity-dimension data dictionary)
- **版本**: v1.0(2026-09-23)
- **来源任务**: `dama-entity-isolation-sweep-20260923` §二之二⑤(项目Bot 2026-09-23 18:2x 并入)
- **裁定依据**: `dama-entity-dimension-ruling-20260923`(项目Bot)+
`0-P1-project-20260923-entity-dimension-convergence-fullstack`(施工面)+
本单 §一/§二(DAMA 独立回查漏项与口径裁定)
- **取证脚本**: `_verification/20260923-entity-isolation-sweep/round2/inspect_entity_dimension_dictionary_20260923.py`
(只读:`information_schema` + `COUNT(*)`;产物 `entity-dimension-schema-20260923.json`)
- **本档性质**: **只落文档、不落代码**。规则口径以本档为准;表结构/行数为 2026-09-23 生产实况快照。
> 一句话口径:**「表里有 `entity_id` 列」≠「这张表是账套维度」**;反之亦然
> ——三态登记的是**归属语义**,不是列面。查库前先查本档。
---
## §0 三态定义与判定法
| 态 | 含义 | 判定法 | 归属写法 |
|---|---|---|---|
| **A|账套维度** | 每一行都属于某一个账套,跨账套读=污染 | 表有 `entity_id`(或等价外键)**且**查询按会话账套过滤 | 必须显式写会话账套,禁 default |
| **B|非账套维度(天然全局)** | 行本身没有账套语义(平台字典 / 角色级配置 / 模板库) | 表无 `entity_id`,且**加了也不会有第二个值**(如角色可见性按角色而不按账套) | 不写账套;审计侧记 `entity_scope=global` |
| **C|经 X 继承** | 表无 `entity_id`,归属由其外键指向的对象决定 | 表无 `entity_id`,但有指向 A 态对象的 FK | **必须经该 FK 查(join)**;禁按非 FK 字段反查 |
**判定三问**(新表/新键入库前必答):
1. 同一业务对象在两个账套会不会**同时存在且值不同**?会 → A;不会 → 看第 2 问。
2. 它是否**天然由角色/平台/行业**决定(写一份即全平台通用)?是 → B。
3. 它是否总**依附于另一个 A 态对象**存在?是 → C(并写明经哪个键继承)。
---
## §1 八张表逐条登记(本轮裁定结论)
| # | 表 | 态 | 行数(09-23) | 关键依据 | 处置 / 施工状态 |
|---|---|---|---|---|---|
| 1 | `kpi_templates` | **B 非账套** | 59 | 无 `entity_id`;系统预置模板库(`is_system`),全体账套共用 | 不拆。写入审计记 `entity_scope=global`(`templates.py::_log(create_template)`) |
| 2 | `driver_factor_templates` | **B 非账套** | **0** | 无 `entity_id`;行业模板库(行业包定义) | 不拆。空表,将来若按客户定制再回本档加「例外行」 |
| 3 | `kpi_subject_map` | **C 经 `kpi_id` 继承** | 89 | 无 `entity_id`;列面仅 `kpi_id/subject_code/calc_type/weight/remark` | **禁按 `subject_code` 反查**(科目码在 `subjects` 里分层,反查会跨账套);一律 `join kpi_definitions ON kpi_id` 后按 `kpi_definitions.entity_id` 过滤 |
| 4 | `analysis_results` | **A 账套维度** | 2 | `entity_id` 列**已落地**(`NULL` 允许,`MUL`) | ⚠️**存量 2 行仍为 `NULL`** ⇒ 归属回填 + 读侧按账套收敛,属**另单施工**(本单不动数据) |
| 5 | `scenario_suggestions` | **B 非账套** | 4 | 无 `entity_id`;按 `alert_type` 的**情景建议模板库** | 不拆(模板与账套无关,实例化时才落账套) |
| 6 | `subjects` | **A + 平台层(分层)** | 207 | `entity_id` 列**已落地**(`NULL` 允许,`MUL`) | ⚠️**存量 207 行全为 `NULL`**。语义 = `NULL` 全局标准准则科目(共享)/非 `NULL` 账套私有科目。回填与「新写入必带账套」属**另单施工**(本单 §三-5 只记「列落地与部署须同窗」) |
| 7 | `operation_logs` | **B(列面非账套)+ 写 `detail.entity_id`** | 338 | 列面仅 `id/user_id/action/target_type/target_id/detail(json)/created_at`,**不加列** | 账套信息写进 `detail.entity_id`;按设计无法归属的写入口显式 `entity_scope`。**读侧本轮不动**(见 §3) |
| 8 | `bot_bridge_config` | **B 非账套** | 1 | 无 `entity_id`;Bot 鉴权注册表(`bot_name` 唯一 + `token`) | 不拆(Bot 身份与账套正交;账套由请求的会话/Bot 白名单决定) |
### 逐条补充
- **1/2/5(模板库三兄弟)**:都不是「某账套的模板」,而是「可被任一账套实例化的模板」。
实例化产物(`kpi_definitions` / `budget_plans` / `action_plans`)才是 A 态 ⇒
**污染点在实例化写入**(本单 §二之二④ 已覆盖这些写入点的审计侧;写入侧的账套强制在各自单内完成)。
- **3(`kpi_subject_map`)**:唯一允许的归属入口是 `kpi_id`。**反查禁令**:
`SELECT * FROM kpi_subject_map WHERE subject_code = ?` 拿到的可能是他账套 KPI 的映射
(科目码在 `subjects` 里是同码多行分层)。
- **4(`analysis_results`)**:列已落地、存量归属未回填 ⇒ 现网读侧**不能**假设「非空 = 已隔离」;
回填前一律按「`entity_id IS NULL` = 未知归属」处理。
- **6(`subjects` 分层)**:`entity_id IS NULL` 的行 = **平台标准准则科目**(只读共享);
账套私有行必须显式带 `entity_id`。**新写入不得依赖模型 `default`**(`api/bot_bridge.py::bot_subjects_upsert` 已显式落本会话账套)。
- **7(`operation_logs`)**:见 §3。
- **8(`bot_bridge_config`)**:1 行 = 当前在册 Bot。**注意**:这是一张**凭据表**,
任何导出/日志都不得落 `token` 明文。
---
## §2 键级登记(`system_configs` / `role_permissions`)
### 2.1 `system_configs`(当前键集合 = 5 个,09-23 实况)
| 键 | 态 | 归属写法 | 说明 |
|---|---|---|---|
| `cash.current_balance.1` | **A 已拆** | 账套后缀 | 治本单 `0c2e57a`(B1 同款)。读不回退旧键 |
| `cash.current_balance` | 旧全局键(保留) | — | **回退杠杆**,不再被读;不删 |
| `kpi_alert_higher_better.<eid>` | **A 已拆**(本单 §一) | 账套后缀 | ⚠️ 生产库该键**当前 0 行**(无值可搬)。未配置 ⇒ 走内置 `DEFAULT_HIGHER_BETTER`,**不回退旧全局键** |
| `current_tenant` | **非数据维度(状态记录)** | — | 全平台「当前租户状态」记录,**不是数据隔离维度**(语义已收敛,禁再当隔离用)。写路径审计见 §3 |
| `budget_mode` | **A 待拆(已排期)** | 账套后缀 | 决定**本账套**预算期间窗口(rolling=当月起 12 月 / fixed=全年)。任一账套 PUT 即改全平台 ⇒ 真缺口。**待拍板**:拆键时旧值落账口径(建议落全部 active 账套 e1+e2,demo 留空) |
| `budget_driver_mode` | **A 待拆(已排期)** | 账套后缀 | 决定**本账套**预算编制模式(subject/driver)。同上;当前审计写入口已带会话账套并标注 `entity_scope=global_key`(`driver_budget.py::set_driver_mode`) |
> 排期单不在本单范围内(本单 §二之三「不强行纳入」;理由与被拒样态见本单回报 §五)。
### 2.2 `role_permissions`(当前键集合 = 4 个)
| 键 | 态 | 裁定与理由 |
|---|---|---|
| `route_permissions` | **B 非账套(角色维度)** | 值 = `{role: [页面]}`,条目的键是**角色**,无账套语义 |
| `action_permissions` | **B 非账套(角色维度)** | 值 = `{role: [动作]}`,同上 |
| `kpi_visibility`(库内**当前无此键**,走代码 `DEFAULT_KPI_VISIBILITY`) | **B 非账套(角色维度)** | 本单 §二裁定**不拆**:①值本身按角色索引(`ceo/it→[*]`、`finance→[finance_*]`…);②载体只有 `key/value` 且 `key` 唯一,结构上无处放「按账套存一份」(要分必须 DDL,与零 DDL 优先相悖);③同表同族另两键同为角色级 —— 只拆本键会让同表三种维度口径分裂。**代码内已登记口径**(`auth_middleware.py` 注释 + docstring 明写「非账套维度…有意不做账套后缀」) |
| `alignment_config` / `alignment_config.1` | **A 已拆** | `alignment_config` = 旧全局键(回退杠杆);`.1` = 账套 1(治本单 `0c2e57a`) |
> **新增键纪律**:往 `system_configs` 加键前必须回答 §0 三问并**在本档补一行**。
> 若判定为 A 态而未带账套后缀 ⇒ 下轮审计会重复报缺口(DAMA 回查机制如此)。
---
## §3 `operation_logs` 写侧约定(零 DDL)
### 3.1 约定(本单 §二之二④ 落地)
1. **每条审计尽量带 `detail.entity_id`** —— 语义 = **本动作所属账套**:
会话账套(`Depends(get_entity_id)`)/ Bot 入参账套 / 被处理对象自身的账套(优先后者,最准)。
2. **确无账套上下文** ⇒ 留空 + 可 grep 哨兵(日志告警,**绝不猜账套**):
- `OPLOG_NO_ENTITY`:写入口无账套上下文(覆盖缺口,待收口);
- `OPLOG_BATCH_NO_ENTITY`:一次动作跨多账套批量(如 `scripts/erp_sync.py` 全库遍历)。
3. **显式非账套维度** ⇒ `detail.entity_scope = "global"`(**不**报警;如平台字典表 `zxt_account_dict`、模板库写入)。
跨账套批量 ⇒ 同时写 `entity_scope = "batch"` + 哨兵。
4. **集中封装**:新代码一律调 `app/utils/oplog.py::log_op()`,**不要**再裸写 `OperationLog(...)`
(裸构造点已收敛到封装件内 1 处;源级用例 `TestAuditWriteSideEntityId` 会拦住第 28 处)。
5. **不做**:不加列、不改读侧口径、不回填历史(历史行不猜账套)。
### 3.2 读侧现状(本轮**有意不动**)
`app/services/knowledge_service.py::extract_events` 仍按 `user_id → user_entities` 兜底过滤 ⇒
`user_id IS NULL` 的 Bot/脚本日志**进不了记忆抽取**(**覆盖缺口,非泄漏**)。
`detail.entity_id` 全量到位后,下一轮才切读侧(届时改按 `detail.entity_id` 归集,覆盖缺口随之闭合)。
### 3.3 生产实况(2026-09-23 快照,可复算)
| 指标 | 值 |
|---|---|
| 总行数 | 338 |
| `user_id IS NULL` | 231(Bot/脚本写入) |
| `detail.entity_id` 非空 | 50(e1 25 / e2 25 —— **先例写法**,本单只是把先例普及到全部写入口) |
| `detail.entity_scope` 非空 | 0(新码未部署,字段由本轮起才写入) |
---
## §4 待施工 / 排期项(本档只登记,不在本单施工)
| # | 项 | 态 | 归属单 | 前置 |
|---|---|---|---|---|
| D1 | `subjects` 存量 207 行 `entity_id` 回填 + 新写入必带账套 | A | 另单(施工面 = `0-P1-project-20260923-entity-dimension-convergence-fullstack`) | 列已落地;部署与新写入同窗 |
| D2 | `analysis_results` 存量 2 行 `entity_id` 回填 + 读侧收敛 | A | 同上 | 同上 |
| D3 | `budget_mode` / `budget_driver_mode` 拆账套 | A | **P2 排期单(待任总拍板旧值落账口径)** | 拍板 → 单开 |
| D4 | `operation_logs` 读侧改按 `detail.entity_id` 归集 | — | 下一轮收口单 | 写侧全量到位(本单)+ 部署 |
| D5 | `bi_reports` 创建端点未绑定会话账套(`entity_id` 走模型 default=1) | A | **非阻塞新发现**,另立单 | 本单只把审计改成「取被处理行自身账套」,未改写入行为 |
---
## §5 复算 / 取证方式
```bash
# 结构 + 行数 + 键集合 + 审计覆盖(只读,零写)
cd /srv/hermes/shared/cma-management/backend
./venv/bin/python3 \
/root/.hermes/profiles/wecom-fullstack/pending-tasks/_verification/20260923-entity-isolation-sweep/round2/inspect_entity_dimension_dictionary_20260923.py
# 单表确认某列是否存在(判定 A 态的前置)
mysql ... -e "SELECT COLUMN_NAME FROM information_schema.COLUMNS \
WHERE TABLE_SCHEMA='cma' AND TABLE_NAME='subjects' AND COLUMN_NAME='entity_id'"
```
**读库三禁忌**(本档口径):
1. 禁把 `entity_id IS NULL` 当「公共/默认账套」直接返回给某个账套的界面;
2. 禁按非 FK 字段(如 `subject_code`)反查 C 态表(`kpi_subject_map`);
3. 禁把 `current_tenant` 当数据隔离维度(它是状态记录,不是维度)。
---
## 变更记录
| 日期 | 版本 | 变更 |
|---|---|---|
| 2026-09-23 | v1.0 | 首版:8 表三态登记 + 键级登记(`system_configs` 5 键 / `role_permissions` 4 键)+ `operation_logs` 写侧约定 + 5 项待施工/排期 |