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