Files
cma-management/docs/entity-dimension-data-dictionary.md
wecom-fullstack 467472311d 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)
2026-09-23 23:08:26 +08:00

12 KiB
Raw Permalink Blame History

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 复算 / 取证方式

# 结构 + 行数 + 键集合 + 审计覆盖(只读,零写)
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 项待施工/排期