diff --git a/backend/app/api/notifications.py b/backend/app/api/notifications.py index 82145a71..6b553821 100644 --- a/backend/app/api/notifications.py +++ b/backend/app/api/notifications.py @@ -130,6 +130,13 @@ def list_logs(page: int = 1, return { "total": total, "entity_id": entity_id, + # 口径字段(2026-09-22 DAMA 回查 §5 收口):上线后本页在无预警推送的账套会显示 0 条, + # 属口径而非故障——现有 8 行历史记录全是 `alert_id IS NULL` 的 OKR 复盘提醒(无账套归属), + # 按「宁少勿错」不返回。前端据此文案提示,避免被当成页面坏了。 + "policy": "alert-linked-only", + "note": ("仅显示与预警推送关联的记录(按父键 kpi_alerts 归属会话账套)。" + "无父键的历史通知(如 OKR 复盘提醒,写入方 backend/scripts/okr_review_reminder.py)" + "不属于任何账套,不在本列表返回。口径登记:docs/audit-and-notification-caliber-20260922.md"), "data": [{ "id": l.id, "alert_id": l.alert_id, diff --git a/backend/app/api/tenant.py b/backend/app/api/tenant.py index 39b56770..727e4c04 100644 --- a/backend/app/api/tenant.py +++ b/backend/app/api/tenant.py @@ -17,6 +17,13 @@ 收紧会切断其原始用途。 5. 每次写都落 `operation_logs`(action=`switch_tenant_state`)留痕 —— 修复前本端点 无审计(ops 止血单假定「走端点即有审计」不成立,已在本单补齐)。 + 6. **审计行可归属(2026-09-22 DAMA 独立回查 §4b 后补)**:`user_id` = 当前会话用户 + (原写 None ⇒ 事后无法追操作人);`target_type="entity"` + `target_id=` + 与 `POST /auth/switch-entity`(action=`switch_entity`,`auth.py`)**共用同一目标维度** + ⇒「按账套查换账套记录」= `WHERE target_type='entity' AND target_id=?`(`operation_logs` + 无 `entity_id` 列,无需 DDL)。两字面**刻意不合并**:一个改「用户自己的会话账套」、 + 一个改「平台全局状态记录」,合并后审计上不可分辨。口径登记见 + `docs/audit-and-notification-caliber-20260922.md`。 """ from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel @@ -64,7 +71,8 @@ def get_current_tenant( @router.post("/switch") -def switch_tenant(data: TenantSwitchRequest, db: Session = Depends(get_db)): +def switch_tenant(data: TenantSwitchRequest, db: Session = Depends(get_db), + user=Depends(require_auth_401)): """切换当前租户状态记录(项目Bot 分发 A2A 任务用;CMA前端企业切换器已下线) 2026-09-22:写操作**不动**(保留 Bot 通道可用性),补 `operation_logs` 审计留痕。 @@ -101,11 +109,14 @@ def switch_tenant(data: TenantSwitchRequest, db: Session = Depends(get_db)): )) # 审计留痕(2026-09-22 补齐:本端点原无 operation_logs 记录) + # 2026-09-22 DAMA 回查 §4b 后补:user_id 记操作人(原 None ⇒ 无法追人); + # target_type/target_id 与 /auth/switch-entity 的 switch_entity 对齐 ⇒ 按账套可过滤 + # (operation_logs 无 entity_id 列,无须 DDL)。 db.add(OperationLog( - user_id=None, + user_id=user.id, action="switch_tenant_state", - target_type="system_config", - target_id=None, + target_type="entity", + target_id=data.entity_id, detail={ "entity_id": data.entity_id, "entity_name": ent.name, diff --git a/backend/tests/test_workbench_entity_isolation_20260922.py b/backend/tests/test_workbench_entity_isolation_20260922.py index 6f0bceeb..7b1f06a8 100644 --- a/backend/tests/test_workbench_entity_isolation_20260922.py +++ b/backend/tests/test_workbench_entity_isolation_20260922.py @@ -298,6 +298,14 @@ class TestP0CurrentTenantConvergence: db.expire_all() n = db.query(OperationLog).filter(OperationLog.action == "switch_tenant_state").count() assert n == 1, f"switch 应落 1 条审计,实际 {n}" + # 审计行必须可归属(2026-09-22 DAMA 独立回查 §4b):操作人 + 账套目标维度 + log = (db.query(OperationLog) + .filter(OperationLog.action == "switch_tenant_state") + .order_by(OperationLog.id.desc()).first()) + assert log.user_id is not None, "审计行 user_id 不得为 None(事后须能追操作人)" + assert log.target_type == "entity" and log.target_id == 2, ( + f"审计须按账套可过滤(target_type/target_id),实际 {log.target_type}/{log.target_id}") + assert (log.detail or {}).get("entity_id") == 2, f"detail 须含 entity_id:{log.detail}" # 全局键虽被改成 e2,e1 会话工作台仍只见 e1 数据 codes1 = {k["kpi_code"] for k in client.get(f"{DASH}/my-kpis", headers=auth_header(t1)).json()["data"]} @@ -522,6 +530,10 @@ class TestB2NotificationLogScoping: assert titles2 == {"e2 通知"}, f"e2 会话通知日志混入他账套/无归属行:{titles2}" assert l1["total"] == 1 and l2["total"] == 1 assert l1["entity_id"] == 1 and l2["entity_id"] == 2 + # 口径字段(2026-09-22 DAMA 回查 §5):空页要能解释成口径而非故障 + for l in (l1, l2): + assert l["policy"] == "alert-linked-only", l + assert "无父键" in l["note"] and "okr_review_reminder" in l["note"], l # ══════════════════════════════════════════════ diff --git a/docs/audit-and-notification-caliber-20260922.md b/docs/audit-and-notification-caliber-20260922.md new file mode 100644 index 00000000..1c2fdf03 --- /dev/null +++ b/docs/audit-and-notification-caliber-20260922.md @@ -0,0 +1,67 @@ +# 口径登记:审计留痕(`operation_logs`)与通知发送记录(`notification_logs`) + +- **立档**: wecom-fullstack,2026-09-22 +- **来源**: DAMA 独立回查 `dama-recheck-workbench-entity-isolation-20260922`(2026-09-22 11:07–11:20) + §4b(审计字面与库内既有口径不一致 / 无 `entity_id` 列 / `user_id=None`)与 + §5(B2 上线后 `/notifications/logs` 在 e1/e2 均为 0 条 = 口径副作用)两处待办 +- **性质**: 口径登记 + 代码收口说明。**不含**任何数据订正(数据订正另立单 + 审批) + +--- + +## 一、`operation_logs` 审计字面字典(换账套类) + +| action 字面 | 写入端点 | 语义 | target_type | target_id | +|---|---|---|---|---| +| `switch_entity` | `post /api/cma/auth/switch-entity`(`app/api/auth.py`) | 改**用户自己的会话账套**(重签 token、旧 token 下线) | `entity` | 目标 `entity_id` | +| `switch_tenant_state` | `post /api/cma/tenant/switch`(`app/api/tenant.py`) | 改**平台全局状态记录** `system_configs.current_tenant`(项目Bot 分发 A2A 任务用;**非数据维度**) | `entity` | 目标 `entity_id` | + +**为什么两个字面不合并**:两者作用域不同(个人会话 vs 全局单值键),合并后审计上不可分辨 +「谁把自己切走了」与「谁改了全平台状态」。DAMA 复核提出「统一为 `switch_entity` 或登记字典」—— +本档取**后者(登记不合并)**,原因是前者会丢语义可分辨性;若后续裁定要求合并,改动面为 +`app/api/tenant.py` 一行字面 + 本表一行(可逆)。 + +**可归属与可过滤(2026-09-22 收口,随本档同一提交落地)**: + +- `operation_logs` **无** `entity_id` 列(仅 `user_id / action / target_type / target_id / detail / created_at`)。 + 故「按账套查换账套记录」不靠新列,而靠**目标维度**: + ```sql + SELECT * FROM operation_logs WHERE target_type='entity' AND target_id=? ORDER BY id DESC; + ``` +- `/tenant/switch` 原写 `user_id=None`(端点无当前用户注入)⇒ 事后无法追操作人。 + 已改为注入会话用户(`user=Depends(require_auth_401)` → `user_id=user.id`), + 并把 `target_type='entity'` / `target_id=entity_id` 与 `switch_entity` 对齐。 +- `detail` JSON 仍保留 `entity_id / entity_name / source / previous`(人读明细)。 + +**现网实况(只读核对,2026-09-22 11:15,`cma_reader` 16/16 PASS)**: +`switch_entity` = 49 行、`switch_tenant_state` = 0 行(本单尚未部署 ⇒ 字面是新引入的, +上线前库里无该字面属正常)。ops 止血那次 `POST /tenant/switch`(10:36:01,部署码)**没有**审计行—— +部署码该端点无审计,正是本单补齐项。 + +--- + +## 二、`/api/cma/notifications/logs` 口径(发送记录页) + +- **归属口径**:`notification_logs` 无 `entity_id` 列 ⇒ 经**父键** join 归属: + `notification_logs.alert_id → kpi_alerts.id → kpi_alerts.entity_id`;按会话账套过滤, + 与 batch5 对 `kpi_data_quality_log` 的处置同口径(**宁少勿错**)。 +- **`alert_id IS NULL` 的行**:无可归属父键 ⇒ **不返回给任何账套**(不跨账套暴露)。 + 现库 8 行历史记录(2026-09-11/12「OKR 季度复盘提醒」)**100% 属此类**, + 写入方 = `backend/scripts/okr_review_reminder.py`(复用本表留存审计,`alert_id` 留空)。 +- **上线后现象(须预期,不是故障)**:`/notifications/logs` 在 e1/e2 均返回 `total=0`, + 前端发送记录页为空。现役预警推送写入器 `app/utils/notifier.py` **必带** `alert_id`, + 故此后由预警产生的新记录会正常显示;只有「无父键历史行」永不出现在该页。 +- **页面与接口的提示(随本档同一提交落地)**: + - 接口返回 `policy="alert-linked-only"` + `note`(说明「仅显示与预警关联的推送」); + - 前端 `NotificationManage.vue` 发送记录表 `empty-text` 写明同一口径。 +- **未做(留待 P2 裁定)**:为无父键通知单列只读视图(需先给 `notification_logs` 补账套维度, + 属 DDL/迁移,超出「零迁移 B2」范围)。 + +--- + +## 三、本档未覆盖(另立单,见对应任务文件) + +1. `kpi_alerts` id 589/590 跨账套错挂(`entity_id=1` 挂 e2 的 `BH_ADM_EXPENSE`):数据订正须 + 另立单 + 任总放行(生产写)。 +2. `kpi_id=1` 兜底残留清点(9 行、其中 1 行 `pending`)与归属裁定。 +3. 同源全局键继续拆账套:`kpi_alert_higher_better`、`kpi_visibility`(DAMA §7 反向挑错新增漏项)、 + `budget_mode` / `budget_driver_mode`(batch5 §七 已记录未施工)。 diff --git a/frontend/src/views/NotificationManage.vue b/frontend/src/views/NotificationManage.vue index cd89c987..e6fb2a6a 100644 --- a/frontend/src/views/NotificationManage.vue +++ b/frontend/src/views/NotificationManage.vue @@ -42,7 +42,8 @@

发送记录

- +