# 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 项待施工/排期 |