fix(cma): 工作台跨实体污染治本 — 全局键拆账套 + 缺列收口 + 预警归属兜底

任务 dama-workbench-entity-isolation-fullstack-20260922(DAMA 诊断 / 项目Bot batch5 §B / 任总 2026-09-22「都批」)

B1 现金基线拆账套(零 DDL)
- cash_forecast_engine: 键 cash.current_balance → cash.current_balance.<entity_id>;
  读只读本账套键、不做旧键回退;set_* 签名 entity_id 必传(漏传 TypeError)
- cash.py: POST /balance 挂 get_entity_id;网银流水联动写回带账套
B3(a) alignment_config 拆账套(零 DDL)
- alignment.py: GET/POST /config、GET /tree 只读 alignment_config.<entity_id>;未配置=configured:false
B3(b) 预警 KPI 归属兜底
- bot_bridge_v2: 去掉 kpi_id or 1 兜底;本账套无 KPI 定义 → 跳过建预警 + OperationLog 留痕
  (kpi_alerts.kpi_id NOT NULL ⇒ 方案A 置 NULL 不可行,走方案B 跳过+审计)
B2 缺列五表分表处置
- notification_logs: 经 alert_id → kpi_alerts.entity_id join 归属(无父键行不返回)
- notification_channels / okr_templates: 集团共享口径写入 docstring(有意不过滤)
- system_configs: 由 B1/B3(a) 拆键覆盖;kpi_data_quality_logs 沿用 batch5 的 join 收口
P0 语义收敛
- tenant.py: current_tenant 收敛为项目Bot A2A 状态记录,GET 仅 it/ceo;POST 补 operation_logs 审计
证据:新增/改写用例 22 条;全量 1370 passed(基线 1348)
This commit is contained in:
wecom-fullstack
2026-09-22 11:06:02 +08:00
parent e89629228e
commit 0c2e57a4cf
11 changed files with 1243 additions and 57 deletions
+49 -11
View File
@@ -20,7 +20,30 @@ router = APIRouter(prefix="/api/cma/alignment", tags=["KPI目标对齐"],
dependencies=[Depends(require_auth_401)], dependencies=[Depends(require_auth_401)],
) )
# 三种对齐模式定义 # ── B3(a) 对齐模式拆账套(2026-09-22 dama-workbench-entity-isolation-fullstack-20260922)──
# 背景:`role_permissions.key` 为 unique 单行键 → `alignment_config` 全库一行,
# 任一账套的 ceo/it 改对齐模式 → **全账套生效**(跨账套配置污染)。
# 治本(方案 A,零 DDL):键名带账套后缀 → `alignment_config.<entity_id>`。
# · 读:只读本账套键;未配置 → configured=false(/tree 提示先初始化),
# **不回退旧全局键**(回退即恢复跨账套生效)。
# · 写:只写本账套键(entity 来自会话 token)。
# · 旧值搬运:脚本 scripts/migrate_cash_balance_key_entity_20260922.py --check/--apply
# (同一 500 口径:原全局值落到「当前生产账套」名下;--down 删新键并写回旧键)。
ALIGNMENT_KEY_PREFIX = "alignment_config"
def alignment_key(entity_id: int) -> str:
"""账套维度的对齐模式键名:alignment_config.<entity_id>"""
return f"{ALIGNMENT_KEY_PREFIX}.{int(entity_id)}"
def _get_alignment_perm(db: Session, entity_id: int):
"""读本账套的对齐模式配置行(不存在 → None)"""
return db.query(RolePermission).filter(
RolePermission.key == alignment_key(entity_id)
).first()
ALIGNMENT_MODES = [ ALIGNMENT_MODES = [
{ {
"key": "vertical_decomposition", "key": "vertical_decomposition",
@@ -50,18 +73,25 @@ def list_modes():
@router.get("/config") @router.get("/config")
def get_alignment_config(db: Session = Depends(get_db)): def get_alignment_config(entity_id: int = Depends(get_entity_id),
"""获取当前系统对齐模式配置(公开接口,无需认证)""" db: Session = Depends(get_db)):
perm = db.query(RolePermission).filter(RolePermission.key == "alignment_config").first() """获取**当前账套**的对齐模式配置(B3(a) 拆账套,2026-09-22)
修复前:读全局唯一键 `alignment_config` → 任一账套改模式全平台生效。
修复后:只读 `alignment_config.<entity_id>`;未配置 → configured=false。
"""
perm = _get_alignment_perm(db, entity_id)
if not perm: if not perm:
return { return {
"mode": None, "mode": None,
"configured": False, "configured": False,
"entity_id": entity_id,
"modes": ALIGNMENT_MODES, "modes": ALIGNMENT_MODES,
} }
return { return {
"mode": perm.value, "mode": perm.value,
"configured": True, "configured": True,
"entity_id": entity_id,
"modes": ALIGNMENT_MODES, "modes": ALIGNMENT_MODES,
} }
@@ -69,23 +99,31 @@ def get_alignment_config(db: Session = Depends(get_db)):
@router.post("/config") @router.post("/config")
def set_alignment_config( def set_alignment_config(
data: dict, data: dict,
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db), db: Session = Depends(get_db),
user = Depends(require_role("ceo", "it")), user = Depends(require_role("ceo", "it")),
): ):
"""初始化/修改系统对齐模式(CEO/IT权限)""" """初始化/修改**当前账套**的对齐模式(CEO/IT权限)
B3(a)(2026-09-22):只写 `alignment_config.<entity_id>`,不覆盖他账套;
entity 来自会话 token,显式传他账套 entity_id → get_entity_id 403。
"""
mode_key = data.get("mode") mode_key = data.get("mode")
if mode_key not in [m["key"] for m in ALIGNMENT_MODES]: if mode_key not in [m["key"] for m in ALIGNMENT_MODES]:
raise HTTPException(400, f"无效的对齐模式: {mode_key}") raise HTTPException(400, f"无效的对齐模式: {mode_key}")
perm = db.query(RolePermission).filter(RolePermission.key == "alignment_config").first() perm = _get_alignment_perm(db, entity_id)
if perm: if perm:
perm.value = {"mode": mode_key, "set_at": datetime.now().isoformat()} perm.value = {"mode": mode_key, "set_at": datetime.now().isoformat(),
"entity_id": entity_id}
else: else:
perm = RolePermission(key="alignment_config", value={"mode": mode_key, "set_at": datetime.now().isoformat()}) perm = RolePermission(key=alignment_key(entity_id),
value={"mode": mode_key, "set_at": datetime.now().isoformat(),
"entity_id": entity_id})
db.add(perm) db.add(perm)
db.commit() db.commit()
return {"message": f"对齐模式已设置为: {mode_key}", "mode": mode_key} return {"message": f"对齐模式已设置为: {mode_key}", "mode": mode_key, "entity_id": entity_id}
@router.get("/tree") @router.get("/tree")
@@ -106,8 +144,8 @@ def get_alignment_tree(
growth-quality / predict.accuracy)。三种模式共用本列表 → 三模式同时隔离。 growth-quality / predict.accuracy)。三种模式共用本列表 → 三模式同时隔离。
跨实体显式传参(?entity_id=<他人账套>)由 get_entity_id 返回 403。 跨实体显式传参(?entity_id=<他人账套>)由 get_entity_id 返回 403。
""" """
# 获取当前模式 # 获取当前账套的对齐模式(B3(a) 拆账套 2026-09-22:只读 alignment_config.<entity_id>)
config_perm = db.query(RolePermission).filter(RolePermission.key == "alignment_config").first() config_perm = _get_alignment_perm(db, entity_id)
mode = config_perm.value.get("mode") if config_perm else None mode = config_perm.value.get("mode") if config_perm else None
if not mode: if not mode:
raise HTTPException(400, "系统未配置对齐模式,请先在系统设置中初始化") raise HTTPException(400, "系统未配置对齐模式,请先在系统设置中初始化")
+40 -3
View File
@@ -21,7 +21,7 @@ from app.database import get_db
from app.models import ( from app.models import (
MpmResult, BotBridgeConfig, MpmResult, BotBridgeConfig,
KPIDefinition, KPIValue, KPIAlert, KPIDefinition, KPIValue, KPIAlert,
ActionPlan, Entity, ActionPlan, Entity, OperationLog,
) )
from app.risk_levels import risk_level from app.risk_levels import risk_level
# 裁定 R1/R5(2026-09-19 财务口径):demo 账套任何写通道不产生业务预警;期间非规整月不入库(单一事实源) # 裁定 R1/R5(2026-09-19 财务口径):demo 账套任何写通道不产生业务预警;期间非规整月不入库(单一事实源)
@@ -364,6 +364,7 @@ def receive_mpm_result(
# ── 3. 触发预警 ── # ── 3. 触发预警 ──
alerts = [] alerts = []
alerts_skipped_no_kpi = [] # B3(b): KPI 定义缺失而跳过的预警(留痕用)
for field, config in ALERT_THRESHOLDS.items(): for field, config in ALERT_THRESHOLDS.items():
field_value = results.get(field) field_value = results.get(field)
if field_value is None: if field_value is None:
@@ -383,6 +384,12 @@ def receive_mpm_result(
logger.info("R5 期间守卫:跳过不入库(不可归期,非规整月 YYYY-MM)period=%r", period) logger.info("R5 期间守卫:跳过不入库(不可归期,非规整月 YYYY-MM)period=%r", period)
continue continue
# 查找关联KPI # 查找关联KPI
# B3(b)(2026-09-22 dama-workbench-entity-isolation-fullstack-20260922):
# 修复前 `kpi_id=kpi_id or 1` —— KPI 定义缺失时把预警错挂到「全库第 1 个 KPI」
# (该 KPI 可能属他账套:预警 entity_id 正确、KPI 维度却跨了账套)。
# 现改为**显式校验**:拿不到本账套的 KPI 定义 → 不建预警(跳过)+ 落 OperationLog 留痕。
# 为什么不是 `kpi_id=None`:`kpi_alerts.kpi_id` 为 NOT NULL(models/__init__.py:145),
# batch5 §B3(b) 方案 A(保留可见性)在本 schema 下不可行;方案 B(跳过 + 审计)落地。
kpi_id = None kpi_id = None
if kpi_code: if kpi_code:
kpi_def = db.query(KPIDefinition).filter( kpi_def = db.query(KPIDefinition).filter(
@@ -392,11 +399,38 @@ def receive_mpm_result(
if kpi_def: if kpi_def:
kpi_id = kpi_def.id kpi_id = kpi_def.id
# 创建预警
alert_msg = f"⚠️ {kpi_name}异常: {field_value:.1f}% (阈值: {threshold}%) [entity={entity.short_name or entity.name}, period={period}]" alert_msg = f"⚠️ {kpi_name}异常: {field_value:.1f}% (阈值: {threshold}%) [entity={entity.short_name or entity.name}, period={period}]"
if kpi_id is None:
# 宁缺勿错:不入库,但必须留痕(防「预警静默丢失」)
alerts_skipped_no_kpi.append(f"{kpi_name}(kpi_code={kpi_code!r})")
db.add(OperationLog(
user_id=None,
action="skip_alert",
target_type="alert",
target_id=None,
detail={
"reason": "kpi_definition_not_found_for_entity",
"kpi_code": kpi_code,
"kpi_name": kpi_name,
"entity_id": entity_id,
"period": period,
"field": field,
"field_value": field_value,
"threshold": threshold,
"source": "bot_bridge_v2.mpm-result",
},
))
logger.warning(
"B3(b) 跳过建预警(KPI定义缺失,不再回落 kpi_id=1): kpi_code=%r entity_id=%s period=%s",
kpi_code, entity_id, period,
)
continue
# 创建预警
alert = KPIAlert( alert = KPIAlert(
entity_id=entity_id, # 2026-09-13 F1: 显式写归属 entity_id=entity_id, # 2026-09-13 F1: 显式写归属
kpi_id=kpi_id or 1, # fallback to first KPI if not found kpi_id=kpi_id, # B3(b): 显式校验后的本账套 KPI(禁止 or 1 兜底)
alert_level="red", alert_level="red",
alert_message=alert_msg, alert_message=alert_msg,
alert_type="actual", alert_type="actual",
@@ -413,10 +447,13 @@ def receive_mpm_result(
"success": True, "success": True,
"kpi_updated": len(kpi_updates), "kpi_updated": len(kpi_updates),
"alerts_triggered": len(alerts), "alerts_triggered": len(alerts),
# B3(b): 因本账套无对应 KPI 定义而跳过的预警(已落 OperationLog,非静默丢失)
"alerts_skipped_missing_kpi": len(alerts_skipped_no_kpi),
"mpm_record_id": record.id, "mpm_record_id": record.id,
"details": { "details": {
"kpi_codes": kpi_updates, "kpi_codes": kpi_updates,
"alerts": alerts, "alerts": alerts,
"alerts_skipped_missing_kpi": alerts_skipped_no_kpi,
}, },
"skipped_non_month_period": (not period_attributable), "skipped_non_month_period": (not period_attributable),
} }
+19 -6
View File
@@ -116,15 +116,21 @@ def api_get_balance(
@router.post("/balance") @router.post("/balance")
def api_set_balance( def api_set_balance(
data: dict, data: dict,
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""设置当前现金余额(万元),作为预测基线""" """设置当前现金余额(万元),作为预测基线
账套隔离(B1,2026-09-22):基线落 `cash.current_balance.<entity_id>`,
entity 来自会话 token(显式传他账套 entity_id → get_entity_id 403)。
修复前本端点无 entity 依赖 → 任一账套设基线会覆盖全平台唯一键。
"""
from app.utils.cash_forecast_engine import set_current_cash_balance from app.utils.cash_forecast_engine import set_current_cash_balance
value = float(data.get("current_cash", 0)) value = float(data.get("current_cash", 0))
if value < 0: if value < 0:
raise HTTPException(400, "现金余额不能为负") raise HTTPException(400, "现金余额不能为负")
set_current_cash_balance(db, value) set_current_cash_balance(db, value, entity_id)
return {"message": "当前现金余额已更新", "current_cash": value} return {"message": "当前现金余额已更新", "entity_id": entity_id, "current_cash": value}
# ══════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════
@@ -785,7 +791,11 @@ def _is_carry_forward(summary: str, subject_name: str) -> bool:
def _sync_cash_balance_from_ledger(db: Session, entity_id: int, batch: str) -> float: def _sync_cash_balance_from_ledger(db: Session, entity_id: int, batch: str) -> float:
"""货币资金类科目(1001/1002开头)期末余额 → set_current_cash_balance(万元)""" """货币资金类科目(1001/1002开头)期末余额 → set_current_cash_balance(万元)
账套隔离(B1,2026-09-22):本函数已按 entity_id 计算余额,写回时**带上该账套**
(修复前丢账套 → 任一家导入网银流水就覆盖全平台唯一键的基线)。
"""
from app.models import VoucherDetail from app.models import VoucherDetail
from app.utils.cash_forecast_engine import set_current_cash_balance from app.utils.cash_forecast_engine import set_current_cash_balance
from app.utils.voucher_guard import apply_voucher_guard from app.utils.voucher_guard import apply_voucher_guard
@@ -800,8 +810,11 @@ def _sync_cash_balance_from_ledger(db: Session, entity_id: int, batch: str) -> f
and (r.subject_code.startswith("1001") or r.subject_code.startswith("1002")) and (r.subject_code.startswith("1001") or r.subject_code.startswith("1002"))
), 2) ), 2)
cash_wan = round(balance_yuan / 10000, 4) cash_wan = round(balance_yuan / 10000, 4)
set_current_cash_balance(db, cash_wan) set_current_cash_balance(db, cash_wan, entity_id)
logger.info(f"网银流水导入[{batch}] 货币资金期末余额{balance_yuan}元 = {cash_wan}万元 → 现金余额联动") logger.info(
f"网银流水导入[{batch}] 货币资金期末余额{balance_yuan}元 = {cash_wan}万元 "
f"→ 现金余额联动(账套 {entity_id})"
)
return cash_wan return cash_wan
+29 -4
View File
@@ -2,10 +2,21 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.deps import get_entity_id
from app.models import NotificationChannel, NotificationLog from app.models import NotificationChannel, NotificationLog
from app.auth_middleware import require_role, require_auth_401 from app.auth_middleware import require_role, require_auth_401
from datetime import datetime from datetime import datetime
# ── B2(2026-09-22 dama-workbench-entity-isolation-fullstack-20260922)────────────────
# 本模块两张表在 `models/__init__.py` 均**无 entity_id 列**,按「分表处置」口径落地:
# · `notification_logs`(Log 类)→ **经父键 join 归属**,零迁移:
# alert_id → kpi_alerts.id → kpi_alerts.entity_id(GET /logs 已收口)。
# · `notification_channels`(**配置类,无父键可桥接**)→ 任总 2026-09-22 已批
# 「方案 1(零 DDL)」:**保持全局 = 集团共享配置**(一套 wecom/mail/sms 渠道
# 供全账套复用;预警推送是集团行为,渠道与账套无关)。
# ⚠️ 因此 `/channels*` 四个端点**有意不按账套过滤**——这是口径,不是漏隔离。
# 若后续需要「账套私有渠道」,须单独立单加 entity_id 列(DDL + 回填 + 唯一键改造)。
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914) # 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
# 缺口:本 router 6 个端点全部无鉴权——无 token 可读写通知渠道(config 内含 webhook # 缺口:本 router 6 个端点全部无鉴权——无 token 可读写通知渠道(config 内含 webhook
# 地址/令牌等凭据)、可触发渠道测试推送、可读推送日志。收口为 require_auth_401。 # 地址/令牌等凭据)、可触发渠道测试推送、可读推送日志。收口为 require_auth_401。
@@ -97,14 +108,28 @@ def test_channel(ch_id: int, db: Session = Depends(get_db)):
@router.get("/logs") @router.get("/logs")
def list_logs(page: int = 1, db: Session = Depends(get_db)): def list_logs(page: int = 1,
"""通知历史""" entity_id: int = Depends(get_entity_id),
total = db.query(NotificationLog).count() db: Session = Depends(get_db)):
logs = db.query(NotificationLog).order_by( """通知历史(**按会话账套收口**,B2 零迁移)
2026-09-22 dama-workbench-entity-isolation-fullstack-20260922(§四 B2):
`notification_logs` 无 entity_id 列 → 经父键 join 归属:
`notification_logs.alert_id → kpi_alerts.id → kpi_alerts.entity_id`。
口径与 batch5 对 `KpiDataQualityLog` 的处置一致(宁少勿错):
`alert_id IS NULL` 的行无可归属父键 → **不返回**(不暴露给任一账套)。
"""
from app.models import KPIAlert
scoped = (db.query(NotificationLog)
.join(KPIAlert, NotificationLog.alert_id == KPIAlert.id)
.filter(KPIAlert.entity_id == entity_id))
total = scoped.count()
logs = scoped.order_by(
NotificationLog.created_at.desc() NotificationLog.created_at.desc()
).offset((page - 1) * 20).limit(20).all() ).offset((page - 1) * 20).limit(20).all()
return { return {
"total": total, "total": total,
"entity_id": entity_id,
"data": [{ "data": [{
"id": l.id, "id": l.id,
"alert_id": l.alert_id, "alert_id": l.alert_id,
+13
View File
@@ -1,5 +1,18 @@
""" """
OKR模板库 API — 按维度筛选 + 用户自定义模板 OKR模板库 API — 按维度筛选 + 用户自定义模板
⚠️ 集团级共享口径(2026-09-22 dama-workbench-entity-isolation-fullstack-20260922,§四 B2)
------------------------------------------------------------------------------------
`okr_templates` 表**无 entity_id 列**,且属**模板/配置类**(无父键可 join 桥接)。
任总 2026-09-22「都批」已按 **方案 1(零 DDL)** 拍板:
**保持全局 = 集团级共享配置** —— 模板库是集团知识资产,一套模板(维度/行业标签/
目标+KR 骨架)供全账套复用;账套差异体现在**使用模板生成的具体目标/KR 行**
(`objectives` / `map_objectives` 均带 entity_id,已隔离),而不是模板本身。
因此本模块的列表/详情/新增/改/删**有意不按账套过滤**——这是口径,不是漏隔离。
若后续确需「账套私有模板」,须单独立单加 `entity_id` 列(DDL + 回填 + 唯一键
`name+dimension` 改造),本单不预设。
""" """
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
+59 -8
View File
@@ -1,10 +1,29 @@
"""租户状态API — CMA系统切换公司时记录当前tenant,供项目Bot分发A2A任务""" """租户状态API — CMA系统切换公司时记录当前tenant,供项目Bot分发A2A任务
⚠️ 语义收敛(2026-09-22 dama-workbench-entity-isolation-fullstack-20260922,任总已批「都批」)
--------------------------------------------------------------------------------------
`system_configs.current_tenant` 是**全局唯一键**(该表无 entity_id 列,`config_key` unique)
⇒ 一次写入全平台生效。本次事故(2026-09-04 前端企业切换器把它写成 entity_id=2)暴露的
结构性缺陷正是「全局单值键被当成个人当前视图 / 数据维度」。
本文件的收敛口径:
1. **它不是数据维度**。工作台/看板/报表取数一律用**登录会话的 entity**
(`app/deps.py::get_entity_id` ← JWT token 绑定),前端本地视图用
`localStorage.cma_entity_id`(登录窗口写入)。禁止任何取数端点读本键。
2. 它的用途**只剩**:项目 Bot 分发 A2A 任务时的状态记录(本模块原始设计目的)。
3. **仅 admin 会话可读**(GET /current):系统无 `admin` 角色(ROLES: ceo/finance/
business/it),故映射为持有 `admin` 动作权限的 `it` + 业务负责人 `ceo`。
4. 写(POST /switch)**不额外收紧角色**:项目 Bot 分发任务时需要写该状态,
收紧会切断其原始用途。
5. 每次写都落 `operation_logs`(action=`switch_tenant_state`)留痕 —— 修复前本端点
无审计(ops 止血单假定「走端点即有审计」不成立,已在本单补齐)。
"""
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.auth_middleware import require_auth_401 from app.auth_middleware import require_auth_401, require_role
from app.models import SystemConfig, Entity from app.models import SystemConfig, Entity, OperationLog
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914) # 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
# 缺口:本 router 原无鉴权——无 token 可读当前租户、并可 POST /switch 改写 # 缺口:本 router 原无鉴权——无 token 可读当前租户、并可 POST /switch 改写
@@ -25,8 +44,15 @@ def _tenant_name(entity_id: int) -> str:
@router.get("/current") @router.get("/current")
def get_current_tenant(db: Session = Depends(get_db)): def get_current_tenant(
"""查询当前租户""" db: Session = Depends(get_db),
user=Depends(require_role("it", "ceo")),
):
"""查询当前租户状态记录(**仅 admin 会话**:it/ceo)
2026-09-22 收敛:本键**不是**数据维度——前端/接口取数一律按会话 entity,
不得读本键决定「取哪个实体的数据」(本次工作台跨实体污染的直接触发点)。
"""
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == TENANT_KEY).first() cfg = db.query(SystemConfig).filter(SystemConfig.config_key == TENANT_KEY).first()
if cfg and cfg.config_value: if cfg and cfg.config_value:
import json import json
@@ -39,12 +65,23 @@ def get_current_tenant(db: Session = Depends(get_db)):
@router.post("/switch") @router.post("/switch")
def switch_tenant(data: TenantSwitchRequest, db: Session = Depends(get_db)): def switch_tenant(data: TenantSwitchRequest, db: Session = Depends(get_db)):
"""切换当前租户(CMA前端企业切换器调用)""" """切换当前租户状态记录(项目Bot 分发 A2A 任务用;CMA前端企业切换器已下线)
2026-09-22:写操作**不动**(保留 Bot 通道可用性),补 `operation_logs` 审计留痕。
"""
ent = db.query(Entity).filter(Entity.id == data.entity_id).first() ent = db.query(Entity).filter(Entity.id == data.entity_id).first()
if not ent: if not ent:
raise HTTPException(404, "企业不存在") raise HTTPException(404, "企业不存在")
import json import json
previous = None
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == TENANT_KEY).first()
if cfg and cfg.config_value:
try:
previous = json.loads(cfg.config_value)
except Exception:
previous = None
state = { state = {
"tenant_id": _tenant_name(data.entity_id), "tenant_id": _tenant_name(data.entity_id),
"entity_id": data.entity_id, "entity_id": data.entity_id,
@@ -54,15 +91,29 @@ def switch_tenant(data: TenantSwitchRequest, db: Session = Depends(get_db)):
"switched_at": __import__("datetime").datetime.now().isoformat(), "switched_at": __import__("datetime").datetime.now().isoformat(),
} }
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == TENANT_KEY).first()
if cfg: if cfg:
cfg.config_value = json.dumps(state, ensure_ascii=False) cfg.config_value = json.dumps(state, ensure_ascii=False)
else: else:
db.add(SystemConfig( db.add(SystemConfig(
config_key=TENANT_KEY, config_key=TENANT_KEY,
config_value=json.dumps(state, ensure_ascii=False), config_value=json.dumps(state, ensure_ascii=False),
description="当前租户状态(CMA企业切换联动)", description="当前租户状态记录(仅供项目Bot分发A2A任务;非数据维度,2026-09-22 语义收敛)",
)) ))
# 审计留痕(2026-09-22 补齐:本端点原无 operation_logs 记录)
db.add(OperationLog(
user_id=None,
action="switch_tenant_state",
target_type="system_config",
target_id=None,
detail={
"entity_id": data.entity_id,
"entity_name": ent.name,
"source": data.source,
"previous": previous,
"note": "current_tenant 状态记录变更(非数据维度)",
},
))
db.commit() db.commit()
return {"success": True, "current": state} return {"success": True, "current": state}
+40 -7
View File
@@ -221,8 +221,33 @@ def calc_trend(values: list) -> float:
return slope / max(abs(avg_y), 1.0) * 100 # 趋势百分比 return slope / max(abs(avg_y), 1.0) * 100 # 趋势百分比
# ── B1 现金基线拆账套(2026-09-22 dama-workbench-entity-isolation-fullstack-20260922)──
# 背景:`SystemConfig.config_key` 为 unique 单行键 → `cash.current_balance` 全库仅一行,
# 所有账套共用;任一账套导入网银流水(`cash.py::_sync_cash_balance_from_ledger` 按 entity
# 算完余额后丢账套写回)即覆盖其他账套的预测基线。实测事故值 -9.3 被全账套共用。
#
# 治本(方案 A,零 DDL):键名带账套后缀 → `cash.current_balance.<entity_id>`。
# · 读:只读本账套键;本账套未设值 → None(由 KPI 实际值兜底,**不跨账套借用**)。
# 不做「回退旧全局键」——那正是本次污染通路(其余账套会读到别家的基线)。
# · 写:只写本账套键(调用方必须传会话 entity)。
# · 旧值搬运:一次性落到「当前生产账套」名下(脚本
# `scripts/migrate_cash_balance_key_entity_20260922.py`,--check/--apply/--down)。
# 回退路径:--down 删新键(旧全局键原样保留,回退老码即可继续读)。
CASH_BALANCE_KEY_PREFIX = "cash.current_balance"
def cash_balance_key(entity_id: int) -> str:
"""账套维度的现金基线键名:cash.current_balance.<entity_id>"""
return f"{CASH_BALANCE_KEY_PREFIX}.{int(entity_id)}"
def get_current_cash_balance(db: Session, entity_id: int) -> Optional[float]: def get_current_cash_balance(db: Session, entity_id: int) -> Optional[float]:
"""获取当前现金余额(万元)— 优先级:KPI实际值 > SystemConfig > None""" """获取当前现金余额(万元)— 优先级:KPI实际值 > 本账套SystemConfig键 > None
账套隔离(B1,2026-09-22):只读 `cash.current_balance.<entity_id>`。
本账套未设基线 → 返回 None(由 KPI 实际值兜底 / 预测引擎默认值),
**不回退旧全局键 `cash.current_balance`**——回退即恢复跨账套借用。
"""
from app.models import SystemConfig, KPIValue from app.models import SystemConfig, KPIValue
# C3(2026-09-19 dama-20260919):补传实体前缀后缀候选(与 F2 的应收/应付路径一致) # C3(2026-09-19 dama-20260919):补传实体前缀后缀候选(与 F2 的应收/应付路径一致)
# 修复前漏传 → 博海/演示企业一条候选都不命中 → 回退 SystemConfig.cash.current_balance(全库唯一键 -9.3) # 修复前漏传 → 博海/演示企业一条候选都不命中 → 回退 SystemConfig.cash.current_balance(全库唯一键 -9.3)
@@ -236,7 +261,7 @@ def get_current_cash_balance(db: Session, entity_id: int) -> Optional[float]:
if latest_cash and latest_cash.actual_value is not None: if latest_cash and latest_cash.actual_value is not None:
return float(latest_cash.actual_value) return float(latest_cash.actual_value)
cfg = db.query(SystemConfig).filter( cfg = db.query(SystemConfig).filter(
SystemConfig.config_key == "cash.current_balance" SystemConfig.config_key == cash_balance_key(entity_id)
).first() ).first()
if cfg and cfg.config_value: if cfg and cfg.config_value:
try: try:
@@ -246,19 +271,27 @@ def get_current_cash_balance(db: Session, entity_id: int) -> Optional[float]:
return None return None
def set_current_cash_balance(db: Session, value: float) -> float: def set_current_cash_balance(db: Session, value: float, entity_id: int) -> float:
"""设置当前现金余额(写入SystemConfig,供预测引擎使用)""" """设置当前现金余额(写入本账套键 `cash.current_balance.<entity_id>`,供预测引擎使用)
entity_id 为**必传**(2026-09-22 B1):签名中不设默认值,强制调用方带上会话账套,
防「漏传 → 落默认账套」重演本次污染。全部调用点见 cash.py(POST /balance、
网银流水导入联动)。
"""
from app.models import SystemConfig from app.models import SystemConfig
if entity_id is None:
raise ValueError("set_current_cash_balance 必须传 entity_id(账套隔离,2026-09-22 B1)")
key = cash_balance_key(entity_id)
cfg = db.query(SystemConfig).filter( cfg = db.query(SystemConfig).filter(
SystemConfig.config_key == "cash.current_balance" SystemConfig.config_key == key
).first() ).first()
if cfg: if cfg:
cfg.config_value = str(value) cfg.config_value = str(value)
else: else:
cfg = SystemConfig( cfg = SystemConfig(
config_key="cash.current_balance", config_key=key,
config_value=str(value), config_value=str(value),
description="当前现金余额(万元),资金预测基线", description=f"当前现金余额(万元),资金预测基线 — 账套 {int(entity_id)}",
) )
db.add(cfg) db.add(cfg)
db.commit() db.commit()
@@ -0,0 +1,329 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""全局键拆账套 — 一次性搬运(数据迁移,非 DDL)
任务:`dama-workbench-entity-isolation-fullstack-20260922` §三 B1/B3(a)
(DAMA 诊断 / 项目Bot batch5 §B 方案段 / 任总 2026-09-22「都批」)
背景
----
`system_configs.config_key` 与 `role_permissions.key` 均为 **unique 单行键** ⇒
下列两个「全局键」全库只有一行,被所有账套共用(本次「酣客工作台显示博海数据」
污染的同源结构性缺陷):
键 处置
------------------------------- ------------------------------------------
system_configs.cash.current_balance → cash.current_balance.<entity_id>
role_permissions.alignment_config → alignment_config.<entity_id>
代码侧(读写)已改为**只认带账套后缀的新键**(方案 A,零 DDL):
backend/app/utils/cash_forecast_engine.py (get_/set_current_cash_balance)
backend/app/api/cash.py (POST /balance、网银流水联动)
backend/app/api/alignment.py (GET/POST /config、GET /tree)
本脚本 = **旧值一次性搬运**(把原来唯一的那一行值落到「当前生产账套」名下)。
「当前生产账套」判定:默认 `--target-entity 1`(entity 1 = 陕西酣客,生产账套),
`--check` 会打印**逐账套凭证推导的货币资金期末余额**与旧键值的比对,供人工确认
(哪个账套的 1001/1002 期末余额 == 旧键值 ⇒ 该值本来就是它写的)。
口径(须与代码注释一致)
------------------------
· 新键值 = 旧键值(一次性搬运)**只落到 target-entity**;**其余账套留空**
⇒ 留空语义 = 该账套现金基线走 KPI 实际值兜底 / 预测引擎默认值,
**不跨账套借用**(正是本次污染的成因)。
· 旧键**原样保留不删**(回退杠杆:回退老代码即可继续读旧键)。
· alignment_config 同理:旧全局值落到 target-entity;其余账套 configured=false。
· 幂等:新键已存在 → 不覆盖(除非 `--force`);二跑 `--apply` 零副作用。
用法
----
# 只读预检(默认动作,零写入)
python3 scripts/migrate_cash_balance_key_entity_20260922.py --check
# 写库(**需审批 / 生产写**;--apply 才写)
python3 scripts/migrate_cash_balance_key_entity_20260922.py --apply
python3 scripts/migrate_cash_balance_key_entity_20260922.py --apply --target-entity 2
python3 scripts/migrate_cash_balance_key_entity_20260922.py --apply --force # 覆盖已存在的新键
# 回退(删新键 + 把旧全局键写回原值)
python3 scripts/migrate_cash_balance_key_entity_20260922.py --down
选项
----
--target-entity N 旧值落哪个账套(默认 1;--check 会给出证据建议)
--force 新键已存在时允许覆盖(默认拒绝,保留既有值)
--json 机器可读输出
--allow-db NAME 生产库名保护豁免(默认只允许库名 `cma`)
退出码
------
0 成功 | 1 执行失败 | 2 用法/保护拒绝(库名不符、目标账套不存在)
⚠️ 本脚本写的是**生产业务库**:不得在开发级会话直接 `--apply`。
依据《生产资产派单纪律》(skills-shared/operations/prod-asset-dispatch-discipline):
碰生产资产一律走派单 + 审批;`--check` 同样是生产读,须留单痕。
"""
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import text # noqa: E402
CASH_KEY = "cash.current_balance" # 旧全局键(保留不删)
ALIGN_KEY = "alignment_config" # 旧全局键(保留不删)
PROD_DB_NAME = "cma" # 生产库名(保护口径)
CASH_DESC = "当前现金余额(万元),资金预测基线 — 账套 {eid}"
ALIGN_DESC = "对齐模式配置 — 账套 {eid}(B3(a) 拆账套 2026-09-22)"
def _engine():
from app.database import get_engine
return get_engine()
def _scalar(conn, sql, **params):
row = conn.execute(text(sql), params).fetchone()
return row[0] if row else None
def _detect_db_name(conn) -> str:
try:
return _scalar(conn, "SELECT DATABASE()") or ""
except Exception:
return ""
def _cash_ledger_by_entity(conn):
"""逐账套:货币资金(1001/1002, carry_forward=0) 期末净额(元 → 万元)
与 `cash.py::_sync_cash_balance_from_ledger` 同口径(该函数正是写旧键的调用方)
——用于回答「-9.3 这个值原本是哪家写的」。
"""
out = {}
try:
rows = conn.execute(text(
"SELECT entity_id, "
" ROUND(SUM(COALESCE(debit_amount,0) - COALESCE(credit_amount,0))/10000, 4) AS wan, "
" COUNT(*) AS n "
"FROM voucher_details "
"WHERE carry_forward = 0 AND subject_code IS NOT NULL "
" AND (subject_code LIKE '1001%' OR subject_code LIKE '1002%') "
"GROUP BY entity_id ORDER BY entity_id"
)).fetchall()
for r in rows:
out[int(r[0])] = {"cash_wan": float(r[1] or 0), "voucher_rows": int(r[2] or 0)}
except Exception as e: # 表缺失/权限 → 只报告,不阻断
out["_error"] = str(e)[:300]
return out
def _snapshot(conn):
"""只读快照:新旧键现状 + 逐账套凭证推导余额"""
snap = {
"db": _detect_db_name(conn),
"legacy": {
"cash.current_balance": None,
"alignment_config": None,
},
"new_keys": {"cash": {}, "alignment": {}},
"entities": [],
"cash_ledger_by_entity": _cash_ledger_by_entity(conn),
}
row = conn.execute(text(
"SELECT config_value, description FROM system_configs WHERE config_key = :k"
), {"k": CASH_KEY}).fetchone()
if row:
snap["legacy"]["cash.current_balance"] = {"config_value": row[0], "description": row[1]}
row = conn.execute(text(
"SELECT value FROM role_permissions WHERE `key` = :k"
), {"k": ALIGN_KEY}).fetchone()
if row:
snap["legacy"]["alignment_config"] = {"value": row[0]}
for r in conn.execute(text(
"SELECT id, name, short_name, status FROM entities ORDER BY id"
)).fetchall():
snap["entities"].append({"id": int(r[0]), "name": r[1], "short_name": r[2], "status": r[3]})
for r in conn.execute(text(
"SELECT config_key, config_value FROM system_configs "
"WHERE config_key LIKE :p ORDER BY config_key"
), {"p": CASH_KEY + ".%"}).fetchall():
snap["new_keys"]["cash"][r[0]] = r[1]
for r in conn.execute(text(
"SELECT `key`, value FROM role_permissions WHERE `key` LIKE :p ORDER BY `key`"
), {"p": ALIGN_KEY + ".%"}).fetchall():
snap["new_keys"]["alignment"][r[0]] = r[1]
# 建议目标账套(只读推断):旧值 == 某账套凭证推导余额
suggestion = None
legacy_cash = (snap["legacy"]["cash.current_balance"] or {}).get("config_value")
if legacy_cash is not None:
try:
want = float(legacy_cash)
for eid, info in snap["cash_ledger_by_entity"].items():
if isinstance(eid, int) and abs(float(info["cash_wan"]) - want) < 1e-6:
suggestion = eid
break
except Exception:
suggestion = None
snap["suggested_target_entity"] = suggestion
return snap
def cmd_check(args):
eng = _engine()
with eng.connect() as conn:
snap = _snapshot(conn)
if args.json:
print(json.dumps(snap, ensure_ascii=False, indent=2))
return 0
print(f"目标库: {snap['db']}")
print(f"旧全局键 cash.current_balance : {snap['legacy']['cash.current_balance']}")
print(f"旧全局键 alignment_config : {snap['legacy']['alignment_config']}")
print(f"已存在新键(cash) : {snap['new_keys']['cash']}")
print(f"已存在新键(alignment) : {snap['new_keys']['alignment']}")
print("逐账套凭证推导货币资金期末余额(万元):")
for eid, info in snap["cash_ledger_by_entity"].items():
print(f" entity {eid}: {info}")
print(f"建议 --target-entity : {snap['suggested_target_entity']}"
f"(旧值与他家凭证推导余额一致时给出;None = 无匹配,按默认 1 或人工裁定)")
print("【只读预检,零写入】")
return 0
def _apply_cash(conn, target: int, force: bool, dry: bool):
new_key = f"{CASH_KEY}.{target}"
row = conn.execute(text(
"SELECT config_value FROM system_configs WHERE config_key = :k"
), {"k": CASH_KEY}).fetchone()
if not row:
return {"key": new_key, "action": "skip", "reason": "旧全局键不存在(无可搬运值)"}
legacy_value = row[0]
exist = conn.execute(text(
"SELECT config_value FROM system_configs WHERE config_key = :k"
), {"k": new_key}).fetchone()
if exist and not force:
return {"key": new_key, "action": "noop",
"reason": "新键已存在且未 --force(保留既有值,幂等)",
"existing_value": exist[0]}
if dry:
return {"key": new_key, "action": "would-insert-or-update",
"value": legacy_value, "existing_value": exist[0] if exist else None}
if exist:
conn.execute(text(
"UPDATE system_configs SET config_value = :v, description = :d WHERE config_key = :k"
), {"v": legacy_value, "d": CASH_DESC.format(eid=target), "k": new_key})
action = "updated"
else:
conn.execute(text(
"INSERT INTO system_configs (config_key, config_value, description) "
"VALUES (:k, :v, :d)"
), {"k": new_key, "v": legacy_value, "d": CASH_DESC.format(eid=target)})
action = "inserted"
return {"key": new_key, "action": action, "value": legacy_value}
def _apply_alignment(conn, target: int, force: bool, dry: bool):
new_key = f"{ALIGN_KEY}.{target}"
row = conn.execute(text(
"SELECT value FROM role_permissions WHERE `key` = :k"
), {"k": ALIGN_KEY}).fetchone()
if not row:
return {"key": new_key, "action": "skip", "reason": "旧全局键不存在(无可搬运值)"}
exist = conn.execute(text(
"SELECT value FROM role_permissions WHERE `key` = :k"
), {"k": new_key}).fetchone()
if exist and not force:
return {"key": new_key, "action": "noop",
"reason": "新键已存在且未 --force(保留既有值,幂等)",
"existing_value": exist[0]}
if dry:
return {"key": new_key, "action": "would-insert", "value": row[0]}
conn.execute(text(
"INSERT INTO role_permissions (`key`, value) VALUES (:k, :v)"
), {"k": new_key, "v": row[0]})
return {"key": new_key, "action": "inserted", "value": row[0]}
def _guard_db(args, conn):
db = _detect_db_name(conn)
if args.allow_db:
return db
if db != PROD_DB_NAME:
print(f"[拒绝] 库名 {db!r} ≠ 生产库 {PROD_DB_NAME!r};"
f"如确为预期,请显式加 --allow-db {db}", file=sys.stderr)
sys.exit(2)
return db
def cmd_apply(args):
eng = _engine()
results = []
with eng.begin() as conn:
db = _guard_db(args, conn)
# 目标账套存在性校验(防写出孤儿键)
ent = conn.execute(text("SELECT id FROM entities WHERE id = :i"),
{"i": args.target_entity}).fetchone()
if not ent:
print(f"[拒绝] 目标账套 entity_id={args.target_entity} 不存在", file=sys.stderr)
sys.exit(2)
results.append(_apply_cash(conn, args.target_entity, args.force, dry=False))
results.append(_apply_alignment(conn, args.target_entity, args.force, dry=False))
out = {"mode": "apply", "db": db, "target_entity": args.target_entity, "results": results}
print(json.dumps(out, ensure_ascii=False, indent=2) if args.json else
"\n".join(f" {r}" for r in results))
return 0
def cmd_down(args):
"""回退:删除新键(旧全局键原样保留,老代码即可继续读)"""
eng = _engine()
removed = []
with eng.begin() as conn:
db = _guard_db(args, conn)
r1 = conn.execute(text(
"DELETE FROM system_configs WHERE config_key LIKE :p"), {"p": CASH_KEY + ".%"})
r2 = conn.execute(text(
"DELETE FROM role_permissions WHERE `key` LIKE :p"), {"p": ALIGN_KEY + ".%"})
removed = [f"system_configs(+{CASH_KEY}.*) deleted={r1.rowcount}",
f"role_permissions({ALIGN_KEY}.*) deleted={r2.rowcount}"]
print(json.dumps({"mode": "down", "db": db, "removed": removed},
ensure_ascii=False, indent=2) if args.json else "\n".join(removed))
print("旧全局键未改动 → 代码回退到老版本即可继续读旧键。")
return 0
def main():
p = argparse.ArgumentParser(description="全局键拆账套 · 一次性搬运(cash.current_balance / alignment_config)")
g = p.add_mutually_exclusive_group()
g.add_argument("--check", action="store_true", help="只读预检(默认)")
g.add_argument("--apply", action="store_true", help="写库(需审批)")
g.add_argument("--down", action="store_true", help="回退:删除新键")
p.add_argument("--target-entity", type=int, default=1,
help="旧值落到哪个账套(默认 1 = 陕西酣客,生产账套)")
p.add_argument("--force", action="store_true", help="允许覆盖已存在的新键")
p.add_argument("--json", action="store_true", help="机器可读输出")
p.add_argument("--allow-db", default=None, help="生产库名保护豁免(默认仅允许 cma)")
args = p.parse_args()
if args.apply:
return cmd_apply(args)
if args.down:
return cmd_down(args)
return cmd_check(args)
if __name__ == "__main__":
sys.exit(main())
@@ -41,13 +41,19 @@ def _ensure_entity(db: Session, eid: int, name: str) -> Entity:
return ent return ent
def _set_mode(db: Session, mode: str) -> None: def _set_mode(db: Session, mode: str, entity_ids=(1, 2)) -> None:
"""写入全局对齐模式配置(RolePermission.alignment_config,不分账套)""" """写入对齐模式配置 —— 2026-09-22 B3(a) 拆账套后为 `alignment_config.<entity_id>`
perm = db.query(RolePermission).filter(RolePermission.key == "alignment_config").first()
if perm: 本文件断言的语义 =「两账套处在同一对齐模式时,各自只返回本账套 KPI」,
perm.value = {"mode": mode} 故对 e1/e2 各写一份(修复前为全局唯一键,一份两账套共用)。
else: """
db.add(RolePermission(key="alignment_config", value={"mode": mode})) for eid in entity_ids:
key = f"alignment_config.{eid}"
perm = db.query(RolePermission).filter(RolePermission.key == key).first()
if perm:
perm.value = {"mode": mode}
else:
db.add(RolePermission(key=key, value={"mode": mode}))
db.commit() db.commit()
+28 -11
View File
@@ -2,7 +2,7 @@
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from tests.conftest import create_test_user, get_token_for_user, auth_header from tests.conftest import create_test_user, create_test_kpi, get_token_for_user, auth_header
class TestNotificationChannels: class TestNotificationChannels:
@@ -149,16 +149,32 @@ class TestNotificationLogs:
assert resp.json()["total"] == 0 assert resp.json()["total"] == 0
def test_logs_with_data(self, client: TestClient, db: Session): def test_logs_with_data(self, client: TestClient, db: Session):
"""有了日志记录后可查询""" """有了日志记录后可查询
from app.models import NotificationLog
2026-09-22(dama-workbench-entity-isolation-fullstack-20260922 §四 B2)行为变更:
`notification_logs` 无 entity_id 列 → 改为**经父键 join 归属**
(`alert_id → kpi_alerts.id → kpi_alerts.entity_id`)。
故日志必须挂到**本账套真实存在**的 KPIAlert 上才可见;
`alert_id IS NULL` 或指向不存在父键的孤儿行**不返回**(宁少勿错)。
"""
from app.models import NotificationLog, KPIAlert
from datetime import datetime from datetime import datetime
# 直接插入日志 kpi = create_test_kpi(db, kpi_code="NOTIF_LOG_KPI", entity_id=1)
log = NotificationLog( alert = KPIAlert(entity_id=1, kpi_id=kpi.id, alert_level="yellow",
alert_id=1, channel="wecom", recipient="测试群", alert_message="通知日志用例预警", status="pending")
title="测试通知", content="测试内容", status="sent", sent_at=datetime.now(), db.add(alert)
) db.commit()
db.add(log) db.refresh(alert)
db.add_all([
NotificationLog(
alert_id=alert.id, channel="wecom", recipient="测试群",
title="测试通知", content="测试内容", status="sent", sent_at=datetime.now(),
),
# 孤儿行:无父键可归属 → 一律不返回
NotificationLog(alert_id=None, channel="wecom", title="无归属通知", status="sent"),
])
db.commit() db.commit()
create_test_user(db) create_test_user(db)
@@ -166,8 +182,9 @@ class TestNotificationLogs:
resp = client.get("/api/cma/notifications/logs", headers=auth_header(token)) resp = client.get("/api/cma/notifications/logs", headers=auth_header(token))
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["total"] >= 1 assert resp.json()["total"] == 1, resp.json()
assert len(resp.json()["data"]) >= 1 assert len(resp.json()["data"]) == 1
assert resp.json()["data"][0]["title"] == "测试通知"
class TestAlertPush: class TestAlertPush:
@@ -0,0 +1,624 @@
"""工作台跨实体污染 · 治本回归测试 — 2026-09-22
任务:`dama-workbench-entity-isolation-fullstack-20260922`
(DAMA 诊断 / 项目Bot batch5 §B 方案段 / 任总 2026-09-22「都批」)
事故:任总 2026-09-22 报「陕西酣客工作台大量显示陕西博海数据」。
根因族 = `system_configs` / `role_permissions` 的**全局唯一键**没有账套维度
(`config_key` / `key` 均 unique)→ 一次写入全平台生效。
本单五项(逐条对应一个测试类):
P0 前端取数修正 — 工作台取数按**会话 entity**,不得用 `current_tenant` 全局键
(含 current_tenant 收敛为「仅 it/ceo 可读」的项目Bot A2A 状态记录)
B1 `cash.current_balance` → `cash.current_balance.<entity_id>`(零 DDL)
B3a `alignment_config` → `alignment_config.<entity_id>`(零 DDL)
B3b `bot_bridge_v2` 预警 KPI 归属:删 `kpi_id or 1` 兜底,改显式校验(跳过错挂 + 留痕)
B2 `notification_logs` 经父键 join 归属(→ kpi_alerts.entity_id);零迁移
外加:迁移脚本 `scripts/migrate_cash_balance_key_entity_20260922.py` 的
check/apply/down 幂等与「原值只落目标账套、其余留空」口径。
全部用例只跑内存 SQLite / 临时文件 SQLite(tests/conftest.py 机制),
**零生产库 / 零生产 Redis 连接**(conftest 已硬阻断)。
"""
import os
import sys
import json
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, text
from sqlalchemy.orm import Session
from app.models import (
Entity,
KPIDefinition,
KPIAlert,
KPIValue,
NotificationLog,
OperationLog,
RolePermission,
SystemConfig,
)
from app.database import Base
from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user
DASH = "/api/cma/dashboard"
CASH = "/api/cma/cash"
ALIGN = "/api/cma/alignment"
NOTIF = "/api/cma/notifications"
TENANT = "/api/cma/tenant"
# 两账套的资源编码(kpi_code 库内唯一)
E1_KPI = "ZZ_WB_E1_KPI"
E2_KPI = "ZZ_WB_E2_KPI"
# ──────────────────────────────────────────────
# 夹具
# ──────────────────────────────────────────────
def _ensure_entity(db: Session, eid: int, name: str, status: str = "active") -> Entity:
ent = db.query(Entity).filter(Entity.id == eid).first()
if not ent:
ent = Entity(id=eid, name=name, short_name=name, status=status)
db.add(ent)
db.commit()
db.refresh(ent)
return ent
def _mk_kpi(db: Session, code: str, eid: int, **kw) -> KPIDefinition:
kpi = KPIDefinition(
entity_id=eid,
kpi_code=code,
kpi_name=f"{code}-名称",
dimension="finance",
category="revenue_growth",
formula="-",
data_source_type="excel",
data_source="Excel导入",
data_owner="财务部",
frequency="monthly",
unit="元",
status="active",
)
kpi.__dict__.update(kw)
db.add(kpi)
db.commit()
db.refresh(kpi)
return kpi
def _login(client: TestClient, entity_id: int) -> str:
return _login_as(client, "testadmin", entity_id)
def _login_as(client: TestClient, username: str, entity_id: int) -> str:
resp = client.post("/api/cma/auth/login", json={
"username": username,
"password": TEST_USER_PASSWORD,
"entity_id": entity_id,
})
assert resp.status_code == 200, f"登录 {username}@entity{entity_id} 失败: {resp.text[:300]}"
return resp.json()["token"]
def _seed_two_entities(db: Session):
"""两账套各一个 active KPI(双会话双实体互不串的最小底座)"""
_ensure_entity(db, 1, "陕西酣客文化传媒")
_ensure_entity(db, 2, "陕西博海网络科技")
k1 = _mk_kpi(db, E1_KPI, 1)
k2 = _mk_kpi(db, E2_KPI, 2)
# 各写一个最新值(便于断言「实际值」口径也隔离)
for k, v in ((k1, 111.0), (k2, 222.0)):
db.add(KPIValue(kpi_id=k.id, period="2026-09", actual_value=v,
source_type="test", data_status="verified"))
db.commit()
return k1, k2
def _write_current_tenant(db: Session, entity_id: int, name: str) -> None:
"""模拟事故现场:把全局键 current_tenant 写成某个账套"""
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == "current_tenant").first()
payload = json.dumps({"tenant_id": f"company_{entity_id}", "entity_id": entity_id,
"name": name, "short_name": name,
"source": "test-polluter",
"switched_at": "2026-09-04T09:28:38"}, ensure_ascii=False)
if cfg:
cfg.config_value = payload
else:
db.add(SystemConfig(config_key="current_tenant", config_value=payload,
description="当前租户状态记录"))
db.commit()
# ══════════════════════════════════════════════
# P0 前端取数修正:工作台按会话 entity,全局键不得当数据维度
# ══════════════════════════════════════════════
class TestP0WorkbenchSessionEntity:
"""事故直接触发点:工作台取数必须用**会话 entity**,与 current_tenant 全局键无关
「双会话双实体互不串」= 同一进程内两个独立 Bearer token(e1 / e2),
即使全局键被写成对方账套,各自仍只看到本账套数据。
"""
def test_my_kpis_isolated_while_global_tenant_key_points_elsewhere(
self, client: TestClient, db: Session):
_seed_two_entities(db)
create_test_user(db) # role=ceo
_write_current_tenant(db, 2, "陕西博海网络科技") # 事故现场:全局键=博海
t1 = _login(client, 1)
t2 = _login(client, 2)
r1 = client.get(f"{DASH}/my-kpis", headers=auth_header(t1))
assert r1.status_code == 200, r1.text
codes1 = {k["kpi_code"] for k in r1.json()["data"]}
assert E1_KPI in codes1 and E2_KPI not in codes1, \
f"酣客(e1)会话不应看到博海 KPI(全局键指向 e2 时也不许):{sorted(codes1)}"
r2 = client.get(f"{DASH}/my-kpis", headers=auth_header(t2))
assert r2.status_code == 200, r2.text
codes2 = {k["kpi_code"] for k in r2.json()["data"]}
assert E2_KPI in codes2 and E1_KPI not in codes2, \
f"博海(e2)会话不应看到酣客 KPI:{sorted(codes2)}"
# 反转全局键 → 两会话结果必须**不变**(证明取数不依赖全局键)
_write_current_tenant(db, 1, "陕西酣客文化传媒")
codes1b = {k["kpi_code"] for k in
client.get(f"{DASH}/my-kpis", headers=auth_header(t1)).json()["data"]}
codes2b = {k["kpi_code"] for k in
client.get(f"{DASH}/my-kpis", headers=auth_header(t2)).json()["data"]}
assert codes1b == codes1, "全局键翻转后 e1 工作台数据变了 ⇒ 取数依赖了全局键"
assert codes2b == codes2, "全局键翻转后 e2 工作台数据变了 ⇒ 取数依赖了全局键"
def test_my_dashboard_and_summary_scoped_by_session(
self, client: TestClient, db: Session):
_seed_two_entities(db)
create_test_user(db)
_write_current_tenant(db, 2, "陕西博海网络科技")
t1 = _login(client, 1)
t2 = _login(client, 2)
d1 = client.get(f"{DASH}/my-dashboard", headers=auth_header(t1))
d2 = client.get(f"{DASH}/my-dashboard", headers=auth_header(t2))
assert d1.status_code == 200 and d2.status_code == 200
codes1 = {k["kpi_code"] for k in d1.json()["kpis"]}
codes2 = {k["kpi_code"] for k in d2.json()["kpis"]}
assert not (codes1 & codes2), f"两会话工作台 KPI 出现交集:{codes1 & codes2}"
s1 = client.get(f"{DASH}/summary", headers=auth_header(t1))
s2 = client.get(f"{DASH}/summary", headers=auth_header(t2))
assert s1.status_code == 200 and s2.status_code == 200
def test_workbench_endpoints_all_use_session_entity(self, client: TestClient, db: Session):
"""工作台类端点逐个复核:带 token 时按 token 账套取数(不看全局键)"""
_seed_two_entities(db)
create_test_user(db)
_write_current_tenant(db, 2, "陕西博海网络科技")
t1 = _login(client, 1)
for path in (f"{DASH}/summary", f"{DASH}/kpis", f"{DASH}/my-kpis",
f"{DASH}/my-dashboard"):
r = client.get(path, headers=auth_header(t1))
assert r.status_code == 200, f"{path} 期望 200,实际 {r.status_code}: {r.text[:200]}"
# 显式传他账套 entity_id → 403(会话账套为准)
r = client.get(f"{DASH}/my-kpis?entity_id=2", headers=auth_header(t1))
assert r.status_code == 403, r.text
assert "无权访问" in r.json()["detail"]
#: 工作台类端点矩阵(§二.3「前端/接口两侧都要复核」的接口侧证据)
WORKBENCH_ENDPOINTS = [
"/api/cma/dashboard/summary",
"/api/cma/dashboard/kpis",
"/api/cma/dashboard/my-kpis",
"/api/cma/dashboard/my-dashboard",
"/api/cma/dashboard/finance-analysis",
"/api/cma/dashboard/predict",
"/api/cma/kpis?page_size=5",
"/api/cma/alerts",
"/api/cma/cash/dashboard",
"/api/cma/cash/balance",
"/api/cma/budget/plans",
"/api/cma/budget/config",
"/api/cma/expenses/reimbursements",
"/api/cma/expenses/stats",
"/api/cma/alignment/config",
"/api/cma/alignment/tree",
"/api/cma/notifications/logs",
"/api/cma/data-quality/logs",
]
class TestP0WorkbenchEndpointMatrix:
"""工作台类端点「双会话双实体」矩阵复核:任一端点都不得把对方账套数据带出来
方法论(可复算):两个独立 Bearer token(e1 / e2)逐个打同一端点,
断言 ①两会话状态码一致(不得出现「一方 200 一方 403」的不对称)
②任一响应体里**不含**对方账套的独有标记(kpi_code)。
同时把三个全局键都写成 e2(模拟事故现场),证明取数与全局键无关。
"""
def test_no_cross_entity_marker_in_any_workbench_endpoint(
self, client: TestClient, db: Session):
_seed_two_entities(db)
create_test_user(db)
_write_current_tenant(db, 2, "陕西博海网络科技")
db.add(SystemConfig(config_key="cash.current_balance", config_value="-9.3",
description="历史全局键(事故值)"))
db.commit()
t1 = _login(client, 1)
t2 = _login(client, 2)
report = []
violations = []
for path in WORKBENCH_ENDPOINTS:
r1 = client.get(path, headers=auth_header(t1))
r2 = client.get(path, headers=auth_header(t2))
report.append(f"{path} → e1:{r1.status_code} e2:{r2.status_code}")
if r1.status_code != r2.status_code:
violations.append(f"{path} 状态码不对称 e1={r1.status_code} e2={r2.status_code}")
if E2_KPI in r1.text:
violations.append(f"{path} e1 会话响应含 e2 标记 {E2_KPI}")
if E1_KPI in r2.text:
violations.append(f"{path} e2 会话响应含 e1 标记 {E1_KPI}")
print("\n[工作台端点矩阵]\n" + "\n".join(report))
assert not violations, "跨实体泄漏/不对称:\n" + "\n".join(violations)
class TestP0CurrentTenantConvergence:
def test_no_token_401(self, client: TestClient, db: Session):
assert client.get(f"{TENANT}/current").status_code == 401
def test_non_admin_role_403(self, client: TestClient, db: Session):
_ensure_entity(db, 1, "测试企业")
create_test_user(db) # testadmin / ceo
create_test_user(db, username="fin_user", role="finance")
tk_fin = _login_as(client, "fin_user", 1)
tk_ceo = _login(client, 1)
r = client.get(f"{TENANT}/current", headers=auth_header(tk_fin))
assert r.status_code == 403, f"finance 会话读 current_tenant 应 403,实际 {r.status_code}"
# ceo(业务负责人)与 it(admin 动作权限持有者)放行
assert client.get(f"{TENANT}/current", headers=auth_header(tk_ceo)).status_code == 200
def test_switch_writes_audit_log_and_is_not_a_data_dimension(
self, client: TestClient, db: Session):
"""POST /switch 落 operation_logs(修复前无审计);写入后不影响他账套取数"""
_seed_two_entities(db)
create_test_user(db)
t1 = _login(client, 1)
r = client.post(f"{TENANT}/switch", headers=auth_header(t1),
json={"entity_id": 2, "source": "test"})
assert r.status_code == 200, r.text
db.expire_all()
n = db.query(OperationLog).filter(OperationLog.action == "switch_tenant_state").count()
assert n == 1, f"switch 应落 1 条审计,实际 {n}"
# 全局键虽被改成 e2,e1 会话工作台仍只见 e1 数据
codes1 = {k["kpi_code"] for k in
client.get(f"{DASH}/my-kpis", headers=auth_header(t1)).json()["data"]}
assert E2_KPI not in codes1
# ══════════════════════════════════════════════
# B1 现金基线拆账套
# ══════════════════════════════════════════════
class TestB1CashBalancePerEntity:
def test_two_entities_do_not_overwrite_each_other(self, client: TestClient, db: Session):
"""e1 / e2 分别设基线后各自读回本账套值(修复前共用一个唯一键)"""
_seed_two_entities(db)
create_test_user(db)
t1 = _login(client, 1)
t2 = _login(client, 2)
assert client.post(f"{CASH}/balance", headers=auth_header(t1),
json={"current_cash": 11.5}).status_code == 200
assert client.post(f"{CASH}/balance", headers=auth_header(t2),
json={"current_cash": 22.5}).status_code == 200
v1 = client.get(f"{CASH}/balance", headers=auth_header(t1)).json()
v2 = client.get(f"{CASH}/balance", headers=auth_header(t2)).json()
assert v1["current_cash"] == 11.5, f"e1 基线被覆盖:{v1}"
assert v2["current_cash"] == 22.5, f"e2 基线被覆盖:{v2}"
assert v1["entity_id"] == 1 and v2["entity_id"] == 2
db.expire_all()
keys = {r[0] for r in db.query(SystemConfig.config_key)
.filter(SystemConfig.config_key.like("cash.current_balance%")).all()}
assert keys == {"cash.current_balance.1", "cash.current_balance.2"}, keys
# 不再写全局唯一键
assert "cash.current_balance" not in keys
def test_no_cross_entity_fallback_to_legacy_key(self, client: TestClient, db: Session):
"""旧全局键有值(事故值 -9.3),本账套未设基线 → 返回 None,**不借用**他账套值"""
_seed_two_entities(db)
create_test_user(db)
db.add(SystemConfig(config_key="cash.current_balance", config_value="-9.3",
description="历史全局键(事故值)"))
db.commit()
t2 = _login(client, 2)
body = client.get(f"{CASH}/balance", headers=auth_header(t2)).json()
assert body["current_cash"] is None, \
f"e2 未设基线却读到旧全局键(跨账套借用):{body}"
def test_body_entity_id_not_trusted(self, client: TestClient, db: Session):
"""带 token 时 body 里的 entity_id 不可信(仍写会话账套)"""
_seed_two_entities(db)
create_test_user(db)
t1 = _login(client, 1)
r = client.post(f"{CASH}/balance", headers=auth_header(t1),
json={"current_cash": 33.0, "entity_id": 2})
assert r.status_code == 200, r.text
assert r.json()["entity_id"] == 1, r.json()
db.expire_all()
assert db.query(SystemConfig).filter(
SystemConfig.config_key == "cash.current_balance.2").first() is None
assert db.query(SystemConfig).filter(
SystemConfig.config_key == "cash.current_balance.1").first() is not None
def test_set_helper_requires_entity(self, db: Session):
"""set_current_cash_balance 必须传 entity_id(防漏传落默认账套)"""
from app.utils.cash_forecast_engine import set_current_cash_balance
with pytest.raises(TypeError):
set_current_cash_balance(db, 1.0) # 缺 entity_id
with pytest.raises(ValueError):
set_current_cash_balance(db, 1.0, None)
# ══════════════════════════════════════════════
# B3(a) 对齐模式拆账套
# ══════════════════════════════════════════════
class TestB3aAlignmentPerEntity:
def test_config_isolated_per_entity(self, client: TestClient, db: Session):
_seed_two_entities(db)
create_test_user(db)
t1 = _login(client, 1)
t2 = _login(client, 2)
assert client.post(f"{ALIGN}/config", headers=auth_header(t1),
json={"mode": "vertical_decomposition"}).status_code == 200
assert client.post(f"{ALIGN}/config", headers=auth_header(t2),
json={"mode": "bsc_chain"}).status_code == 200
c1 = client.get(f"{ALIGN}/config", headers=auth_header(t1)).json()
c2 = client.get(f"{ALIGN}/config", headers=auth_header(t2)).json()
assert c1["mode"]["mode"] == "vertical_decomposition", c1
assert c2["mode"]["mode"] == "bsc_chain", c2
assert c1["entity_id"] == 1 and c2["entity_id"] == 2
db.expire_all()
keys = {r[0] for r in db.query(RolePermission.key)
.filter(RolePermission.key.like("alignment_config%")).all()}
assert keys == {"alignment_config.1", "alignment_config.2"}, keys
assert "alignment_config" not in keys
def test_unconfigured_entity_not_inheriting_other_entity(self, client: TestClient, db: Session):
"""e1 已配置、e2 未配置 → e2 不得继承 e1 的模式(修复前全局键必然继承)"""
_seed_two_entities(db)
create_test_user(db)
t1 = _login(client, 1)
t2 = _login(client, 2)
assert client.post(f"{ALIGN}/config", headers=auth_header(t1),
json={"mode": "bsc_chain"}).status_code == 200
c2 = client.get(f"{ALIGN}/config", headers=auth_header(t2)).json()
assert c2["configured"] is False and c2["mode"] is None, \
f"e2 未配置却继承了 e1 的对齐模式:{c2}"
# e2 的 /tree 应提示先初始化(400),而不是拿到 e1 的模式
r = client.get(f"{ALIGN}/tree", headers=auth_header(t2))
assert r.status_code == 400, f"e2 未配置应 400,实际 {r.status_code}: {r.text[:200]}"
# e1 正常
assert client.get(f"{ALIGN}/tree", headers=auth_header(t1)).status_code == 200
def test_tree_uses_own_entity_mode(self, client: TestClient, db: Session):
_seed_two_entities(db)
create_test_user(db)
t1 = _login(client, 1)
t2 = _login(client, 2)
client.post(f"{ALIGN}/config", headers=auth_header(t1),
json={"mode": "vertical_decomposition"})
client.post(f"{ALIGN}/config", headers=auth_header(t2), json={"mode": "bsc_chain"})
b1 = client.get(f"{ALIGN}/tree", headers=auth_header(t1)).json()
b2 = client.get(f"{ALIGN}/tree", headers=auth_header(t2)).json()
assert b1["mode"] == "vertical_decomposition"
assert b2["mode"] == "bsc_chain"
# ══════════════════════════════════════════════
# B3(b) 预警 KPI 归属:删 kpi_id or 1 兜底
# ══════════════════════════════════════════════
class TestB3bAlertKpiAttribution:
def test_missing_kpi_definition_skips_alert_instead_of_kpi_id_1(self, db: Session):
"""e2 无 C_REBATE_RATE 定义时:不建预警(不挂 kpi_id=1)+ 落 OperationLog 留痕"""
from app.api.bot_bridge_v2 import receive_mpm_result
from app.models import MpmResult
_ensure_entity(db, 1, "陕西酣客文化传媒")
_ensure_entity(db, 2, "陕西博海网络科技")
first = _mk_kpi(db, "ZZ_WB_FIRST_KPI", 1) # 全库第 1 个 KPI(旧兜底目标)
assert first.id == 1, f"夹具前提:第一个 KPI id 应为 1,实际 {first.id}"
MpmResult.__table__.create(bind=db.get_bind(), checkfirst=True)
before_alerts = db.query(KPIAlert).count()
before_logs = db.query(OperationLog).count()
r = receive_mpm_result(
data={"entity_id": 2, "period": "2026-09",
"results": {"channel_rebate_rate": 80.0}},
bridge_bot="test-bridge", db=db)
db.expire_all()
assert r["alerts_triggered"] == 0, r
assert r["alerts_skipped_missing_kpi"] == 1, r
assert db.query(KPIAlert).count() == before_alerts, "KPI 定义缺失不应建预警"
assert db.query(KPIAlert).filter(
KPIAlert.entity_id == 2, KPIAlert.kpi_id == 1).first() is None, \
"不得把 e2 的预警错挂到 kpi_id=1(可能是他账套的 KPI)"
logs = db.query(OperationLog).filter(OperationLog.action == "skip_alert").all()
assert len(logs) == 1 and db.query(OperationLog).count() == before_logs + 1
assert logs[0].detail["reason"] == "kpi_definition_not_found_for_entity"
assert logs[0].detail["entity_id"] == 2
def test_present_kpi_definition_still_creates_alert(self, db: Session):
"""正对照:本账套有 KPI 定义 → 照常建预警且归属正确"""
from app.api.bot_bridge_v2 import receive_mpm_result
from app.models import MpmResult
_ensure_entity(db, 1, "陕西酣客文化传媒")
k = _mk_kpi(db, "C_REBATE_RATE", 1)
MpmResult.__table__.create(bind=db.get_bind(), checkfirst=True)
r = receive_mpm_result(
data={"entity_id": 1, "period": "2026-09",
"results": {"channel_rebate_rate": 80.0}},
bridge_bot="test-bridge", db=db)
db.expire_all()
assert r["alerts_triggered"] == 1, r
assert r["alerts_skipped_missing_kpi"] == 0
alert = db.query(KPIAlert).filter(KPIAlert.entity_id == 1).first()
assert alert is not None and alert.kpi_id == k.id
# ══════════════════════════════════════════════
# B2 缺列五表:Log 类经父键 join 归属(零迁移)
# ══════════════════════════════════════════════
class TestB2NotificationLogScoping:
def test_logs_scoped_by_alert_entity(self, client: TestClient, db: Session):
k1, k2 = _seed_two_entities(db)
create_test_user(db)
a1 = KPIAlert(entity_id=1, kpi_id=k1.id, alert_level="red",
alert_message="e1 预警", status="pending")
a2 = KPIAlert(entity_id=2, kpi_id=k2.id, alert_level="red",
alert_message="e2 预警", status="pending")
db.add_all([a1, a2])
db.commit()
db.refresh(a1)
db.refresh(a2)
db.add_all([
NotificationLog(alert_id=a1.id, channel="wecom", title="e1 通知", status="sent"),
NotificationLog(alert_id=a2.id, channel="wecom", title="e2 通知", status="sent"),
NotificationLog(alert_id=None, channel="wecom", title="无归属通知", status="sent"),
])
db.commit()
t1 = _login(client, 1)
t2 = _login(client, 2)
l1 = client.get(f"{NOTIF}/logs", headers=auth_header(t1)).json()
l2 = client.get(f"{NOTIF}/logs", headers=auth_header(t2)).json()
titles1 = {x["title"] for x in l1["data"]}
titles2 = {x["title"] for x in l2["data"]}
assert titles1 == {"e1 通知"}, f"e1 会话通知日志混入他账套/无归属行:{titles1}"
assert titles2 == {"e2 通知"}, f"e2 会话通知日志混入他账套/无归属行:{titles2}"
assert l1["total"] == 1 and l2["total"] == 1
assert l1["entity_id"] == 1 and l2["entity_id"] == 2
# ══════════════════════════════════════════════
# 迁移脚本:原值搬运口径(目标账套落值 / 其余留空 / 幂等 / 可回退)
# ══════════════════════════════════════════════
MIGRATION = "scripts.migrate_cash_balance_key_entity_20260922"
class TestMigrationScript:
"""在**临时文件 SQLite**上跑迁移脚本(monkeypatch 引擎,绝不碰 app.database → 生产库)"""
@pytest.fixture
def mig_env(self, tmp_path, monkeypatch):
import importlib
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
mod = importlib.import_module(MIGRATION)
eng = create_engine(f"sqlite:///{tmp_path/'mig.db'}")
Base.metadata.create_all(bind=eng)
with eng.begin() as conn:
conn.execute(text("INSERT INTO entities (id, name, short_name, status) "
"VALUES (1, '酣客', '酣客', 'active')"))
conn.execute(text("INSERT INTO entities (id, name, short_name, status) "
"VALUES (2, '博海', '博海', 'active')"))
conn.execute(text("INSERT INTO system_configs (config_key, config_value, description) "
"VALUES ('cash.current_balance', '-9.3', '历史全局键')"))
conn.execute(text("INSERT INTO role_permissions (`key`, value) "
"VALUES ('alignment_config', '{\"mode\": \"bsc_chain\"}')"))
monkeypatch.setattr(mod, "_engine", lambda: eng)
return mod, eng
def _run(self, mod, argv):
import sys as _sys
old = _sys.argv
_sys.argv = ["migrate"] + argv
try:
return mod.main()
finally:
_sys.argv = old
def test_check_is_readonly(self, mig_env):
mod, eng = mig_env
assert self._run(mod, ["--check", "--allow-db", "sqlite"]) == 0
with eng.connect() as conn:
assert conn.execute(text("SELECT COUNT(*) FROM system_configs "
"WHERE config_key LIKE 'cash.current_balance.%'")).scalar() == 0
assert conn.execute(text("SELECT COUNT(*) FROM role_permissions "
"WHERE `key` LIKE 'alignment_config.%'")).scalar() == 0
def test_apply_moves_value_to_target_only_and_is_idempotent(self, mig_env):
mod, eng = mig_env
assert self._run(mod, ["--apply", "--allow-db", "sqlite",
"--target-entity", "1", "--json"]) == 0
with eng.connect() as conn:
v = conn.execute(text("SELECT config_value FROM system_configs "
"WHERE config_key = 'cash.current_balance.1'")).scalar()
assert v == "-9.3"
# 其余账套留空(不跨账套借用)
assert conn.execute(text("SELECT COUNT(*) FROM system_configs "
"WHERE config_key = 'cash.current_balance.2'")).scalar() == 0
assert conn.execute(text("SELECT COUNT(*) FROM role_permissions "
"WHERE `key` = 'alignment_config.1'")).scalar() == 1
assert conn.execute(text("SELECT COUNT(*) FROM role_permissions "
"WHERE `key` = 'alignment_config.2'")).scalar() == 0
# 旧键保留(回退杠杆)
assert conn.execute(text("SELECT config_value FROM system_configs "
"WHERE config_key = 'cash.current_balance'")).scalar() == "-9.3"
# 二跑零副作用(幂等)
assert self._run(mod, ["--apply", "--allow-db", "sqlite",
"--target-entity", "1", "--json"]) == 0
with eng.connect() as conn:
assert conn.execute(text("SELECT COUNT(*) FROM system_configs "
"WHERE config_key LIKE 'cash.current_balance.%'")).scalar() == 1
assert conn.execute(text("SELECT config_value FROM system_configs "
"WHERE config_key = 'cash.current_balance.1'")).scalar() == "-9.3"
def test_down_removes_new_keys_keeps_legacy(self, mig_env):
mod, eng = mig_env
self._run(mod, ["--apply", "--allow-db", "sqlite", "--json"])
assert self._run(mod, ["--down", "--allow-db", "sqlite", "--json"]) == 0
with eng.connect() as conn:
assert conn.execute(text("SELECT COUNT(*) FROM system_configs "
"WHERE config_key LIKE 'cash.current_balance.%'")).scalar() == 0
assert conn.execute(text("SELECT COUNT(*) FROM role_permissions "
"WHERE `key` LIKE 'alignment_config.%'")).scalar() == 0
assert conn.execute(text("SELECT COUNT(*) FROM system_configs "
"WHERE config_key = 'cash.current_balance'")).scalar() == 1
def test_prod_db_name_guard(self, mig_env):
"""库名保护:非 cma 库未显式豁免 → 拒绝(exit 2)"""
mod, eng = mig_env
with pytest.raises(SystemExit) as e:
self._run(mod, ["--apply", "--json"]) # sqlite → DATABASE() 不可用 → db=''
assert e.value.code == 2
def test_target_entity_must_exist(self, mig_env):
mod, eng = mig_env
with pytest.raises(SystemExit) as e:
self._run(mod, ["--apply", "--allow-db", "sqlite", "--target-entity", "99", "--json"])
assert e.value.code == 2