diff --git a/backend/app/api/ai_suggestions.py b/backend/app/api/ai_suggestions.py index 4a25d502..cea13260 100644 --- a/backend/app/api/ai_suggestions.py +++ b/backend/app/api/ai_suggestions.py @@ -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", diff --git a/backend/app/api/alert_rules.py b/backend/app/api/alert_rules.py index 5ee89053..c80233b4 100644 --- a/backend/app/api/alert_rules.py +++ b/backend/app/api/alert_rules.py @@ -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}} diff --git a/backend/app/api/bi_reports.py b/backend/app/api/bi_reports.py index b9bbb1b7..050b1625 100644 --- a/backend/app/api/bi_reports.py +++ b/backend/app/api/bi_reports.py @@ -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} diff --git a/backend/app/api/bot_bridge.py b/backend/app/api/bot_bridge.py index 1d2ae970..3bb1e705 100644 --- a/backend/app/api/bot_bridge.py +++ b/backend/app/api/bot_bridge.py @@ -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 diff --git a/backend/app/api/budget.py b/backend/app/api/budget.py index d25019d6..97bfe2c7 100644 --- a/backend/app/api/budget.py +++ b/backend/app/api/budget.py @@ -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() diff --git a/backend/app/api/budget_generate.py b/backend/app/api/budget_generate.py index 1452f300..5882dfb1 100644 --- a/backend/app/api/budget_generate.py +++ b/backend/app/api/budget_generate.py @@ -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 { diff --git a/backend/app/api/data.py b/backend/app/api/data.py index abe2dbb2..067802ae 100644 --- a/backend/app/api/data.py +++ b/backend/app/api/data.py @@ -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": "删除成功"} diff --git a/backend/app/api/deviation_push.py b/backend/app/api/deviation_push.py index d437f1b2..20660114 100644 --- a/backend/app/api/deviation_push.py +++ b/backend/app/api/deviation_push.py @@ -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, diff --git a/backend/app/api/driver_budget.py b/backend/app/api/driver_budget.py index d82d24e7..04a474c3 100644 --- a/backend/app/api/driver_budget.py +++ b/backend/app/api/driver_budget.py @@ -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, diff --git a/backend/app/api/kpi_causality.py b/backend/app/api/kpi_causality.py index c110f854..5f0ac85a 100644 --- a/backend/app/api/kpi_causality.py +++ b/backend/app/api/kpi_causality.py @@ -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) diff --git a/backend/app/api/kpis.py b/backend/app/api/kpis.py index be5fdc11..6db833f4 100644 --- a/backend/app/api/kpis.py +++ b/backend/app/api/kpis.py @@ -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") diff --git a/backend/app/api/reports.py b/backend/app/api/reports.py index e9f94a51..22c84040 100644 --- a/backend/app/api/reports.py +++ b/backend/app/api/reports.py @@ -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, diff --git a/backend/app/api/templates.py b/backend/app/api/templates.py index 9079cc81..ac4f1e52 100644 --- a/backend/app/api/templates.py +++ b/backend/app/api/templates.py @@ -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) diff --git a/backend/app/utils/oplog.py b/backend/app/utils/oplog.py new file mode 100644 index 00000000..739db012 --- /dev/null +++ b/backend/app/utils/oplog.py @@ -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 diff --git a/backend/scripts/alert_generator.py b/backend/scripts/alert_generator.py index 507951dd..d134a2ff 100644 --- a/backend/scripts/alert_generator.py +++ b/backend/scripts/alert_generator.py @@ -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.models import KPIDefinition, KPIValue, KPIAlert, ActionPlan, OperationLog +from app.utils.oplog import log_op from app.utils.threshold_parser import eval_threshold, LEVEL_PRIORITY # 裁定2a(2026-09-17 财务口径):比率类指标统一「百分比(%)」口径(取数×100), # 未核对/疑似单位错配的项降级待核(不落预警、不推送) diff --git a/backend/scripts/erp_sync.py b/backend/scripts/erp_sync.py index 91f939e0..e999cc95 100644 --- a/backend/scripts/erp_sync.py +++ b/backend/scripts/erp_sync.py @@ -23,6 +23,7 @@ load_dotenv() from app.database import get_engine, get_session_local from app.models import KPIDefinition, KPIValue, OperationLog, DataSourceConfig +from app.utils.oplog import log_op, SCOPE_BATCH 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 if not dry_run: - log = OperationLog( - action="erp_sync", - target_type="kpi", - detail=json.dumps({ - "total": len(kpis), "success": success, - "failed": fail, "api_mode": use_api, - "period": datetime.now().strftime("%Y-%m"), - }, ensure_ascii=False), - ) - db.add(log) + # 2026-09-23 ④:审计带账套。本脚本一次遍历**全库 active ERP KPI(可能跨账套)**: + # 全部同账套 ⇒ 写该账套;跨账套 ⇒ 留空 + OPLOG_BATCH_NO_ENTITY 哨兵(不猜账套) + _entity_ids = sorted({getattr(k, "entity_id", None) for k in kpis} - {None}) + log_op(db, "erp_sync", target_type="kpi", + detail={ + "total": len(kpis), "success": success, + "failed": fail, "api_mode": use_api, + "period": datetime.now().strftime("%Y-%m"), + "kpi_entity_ids": _entity_ids, + }, + entity_id=(_entity_ids[0] if len(_entity_ids) == 1 else None), + scope=(SCOPE_BATCH if len(_entity_ids) != 1 else None)) db.commit() logger.info(f"同步完成: {success}成功 / {fail}失败 / {len(kpis)}总计") diff --git a/backend/tests/test_entity_isolation_sweep_20260923.py b/backend/tests/test_entity_isolation_sweep_20260923.py index 7601f5b7..c1abf790 100644 --- a/backend/tests/test_entity_isolation_sweep_20260923.py +++ b/backend/tests/test_entity_isolation_sweep_20260923.py @@ -28,6 +28,8 @@ import os import re import sys +import ast + import pytest from fastapi.testclient import TestClient 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.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 BUDGET = "/api/cma/budget" @@ -378,3 +382,157 @@ class TestMigrationScript: with pytest.raises(SystemExit) as e: self._run(mod, ["--apply", "--allow-db", "sqlite", "--target-entity", "99", "--json"]) 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 diff --git a/docs/entity-dimension-data-dictionary.md b/docs/entity-dimension-data-dictionary.md new file mode 100644 index 00000000..2c0da42d --- /dev/null +++ b/docs/entity-dimension-data-dictionary.md @@ -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.` | **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 项待施工/排期 |