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:
@@ -141,6 +141,7 @@ def _apply_kpi_target(db: Session, sug: AISuggestion, params: dict, current_user
|
||||
target_type="kpi",
|
||||
target_id=kpi.id,
|
||||
detail={
|
||||
"entity_id": sug.entity_id, # 2026-09-23 ④:审计带账套(=建议账套,端点已校验=会话账套)
|
||||
"suggestion_id": sug.id,
|
||||
"suggestion_title": sug.title,
|
||||
"apply_action": "kpi_target",
|
||||
@@ -218,6 +219,7 @@ def _apply_budget_adjust(db: Session, sug: AISuggestion, params: dict, current_u
|
||||
target_type="budget",
|
||||
target_id=plan.id,
|
||||
detail={
|
||||
"entity_id": sug.entity_id, # 2026-09-23 ④:审计带账套(=建议账套)
|
||||
"suggestion_id": sug.id,
|
||||
"suggestion_title": sug.title,
|
||||
"apply_action": "budget_adjust",
|
||||
@@ -278,6 +280,7 @@ def _apply_action_plan(db: Session, sug: AISuggestion, params: dict, current_use
|
||||
target_type="action_plan",
|
||||
target_id=plan.id,
|
||||
detail={
|
||||
"entity_id": sug.entity_id, # 2026-09-23 ④:审计带账套(=建议账套)
|
||||
"suggestion_id": sug.id,
|
||||
"suggestion_title": sug.title,
|
||||
"apply_action": "action_plan",
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.database import get_db, Base
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition, KPIValue, KPIAlert, OperationLog
|
||||
from app.utils.oplog import log_op
|
||||
# 裁定 R1(2026-09-19 财务口径):demo 账套不参与预警生成/遍历(单一事实源)
|
||||
# 裁定 R5(2026-09-19 财务口径):期间非规整月 YYYY-MM → 该 kpi_alerts 行不入库(生成侧守卫)
|
||||
from app.utils.alert_entity_scope import guard_alert_period, is_excluded_entity
|
||||
@@ -140,10 +141,9 @@ def create_alert_rule(data: dict, db: Session = Depends(get_db), user=Depends(re
|
||||
db.refresh(rule)
|
||||
|
||||
# 日志
|
||||
db.add(OperationLog(
|
||||
action="create_alert_rule", target_type="alert_rule",
|
||||
detail=f"KPI={kpi.kpi_code}({kpi.kpi_name}) type={rule_type}",
|
||||
))
|
||||
log_op(db, "create_alert_rule", target_type="alert_rule", target_id=rule.id,
|
||||
detail=f"KPI={kpi.kpi_code}({kpi.kpi_name}) type={rule_type}",
|
||||
entity_id=entity_id)
|
||||
db.commit()
|
||||
|
||||
return {"data": {c.name: getattr(rule, c.name) for c in AlertRule.__table__.columns}}
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition, KPIValue, BiReportTemplate, BiReport, OperationLog, KPICausality
|
||||
from app.utils.oplog import log_op
|
||||
|
||||
logger = logging.getLogger("bi-reports")
|
||||
|
||||
@@ -160,7 +161,8 @@ def create_report(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
db.add(r)
|
||||
db.commit()
|
||||
db.refresh(r)
|
||||
db.add(OperationLog(action="create", target_type="bi_report", detail=f"创建报表: {r.name}"))
|
||||
log_op(db, "create", target_type="bi_report", target_id=r.id,
|
||||
detail=f"创建报表: {r.name}", entity_id=getattr(r, "entity_id", None))
|
||||
db.commit()
|
||||
return {c.name: getattr(r, c.name) for c in BiReport.__table__.columns}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from app.models import KPICausality
|
||||
from app.risk_levels import risk_level
|
||||
# 裁定 R1/R5(2026-09-19 财务口径):demo 账套任何写通道不产生业务预警;期间非规整月不入库(单一事实源)
|
||||
from app.utils.alert_entity_scope import entity_alert_guard, guard_alert_period
|
||||
from app.utils.oplog import log_op, SCOPE_GLOBAL
|
||||
import json
|
||||
|
||||
logger = logging.getLogger("cma.bot_bridge")
|
||||
@@ -971,11 +972,11 @@ def bot_dict_zxt_account(
|
||||
before = db.execute(text("SELECT COUNT(*) FROM zxt_account_dict")).scalar() or 0
|
||||
db.execute(stmt, clean)
|
||||
after = db.execute(text("SELECT COUNT(*) FROM zxt_account_dict")).scalar() or 0
|
||||
db.add(OperationLog(
|
||||
action="import", target_type="zxt_account_dict", target_id=None,
|
||||
detail={"bot": bot.get("name"), "endpoint": "/api/cma/bot/dict/zxt-account",
|
||||
"upserted": len(clean), "before": before, "after": after, "source": source},
|
||||
))
|
||||
log_op(db, "import", target_type="zxt_account_dict", target_id=None,
|
||||
detail={"bot": bot.get("name"), "endpoint": "/api/cma/bot/dict/zxt-account",
|
||||
"upserted": len(clean), "before": before, "after": after, "source": source},
|
||||
# zxt_account_dict = 智享通平台级科目字典(无 entity 列)⇒ 显式声明非账套维度
|
||||
entity_id=None, scope=SCOPE_GLOBAL)
|
||||
db.commit()
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -1263,13 +1264,12 @@ def bot_subjects_upsert(
|
||||
}, synchronize_session=False)
|
||||
db.flush()
|
||||
after = db.query(func.count(Subject.id)).scalar() or 0
|
||||
db.add(OperationLog(
|
||||
action="import", target_type="subjects", target_id=None,
|
||||
detail={"bot": bot.get("name"), "endpoint": "/api/cma/bot/subjects",
|
||||
"risk_level": "L3", "inserted": len(to_insert), "updated": len(to_update),
|
||||
"skipped": len(to_skip), "before": before, "after": after,
|
||||
"update_existing": update_existing, "source": "json"},
|
||||
))
|
||||
log_op(db, "import", target_type="subjects", target_id=None,
|
||||
detail={"bot": bot.get("name"), "endpoint": "/api/cma/bot/subjects",
|
||||
"risk_level": "L3", "inserted": len(to_insert), "updated": len(to_update),
|
||||
"skipped": len(to_skip), "before": before, "after": after,
|
||||
"update_existing": update_existing, "source": "json"},
|
||||
entity_id=entity_id)
|
||||
db.commit()
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
@@ -137,7 +137,8 @@ def create_budget_plan(
|
||||
action="create",
|
||||
target_type="budget",
|
||||
target_id=plan.id,
|
||||
detail=__import__("json").dumps({"kpi_id": kpi_id, "period": period, "value": budget_value}, ensure_ascii=False),
|
||||
detail=__import__("json").dumps({"kpi_id": kpi_id, "period": period, "value": budget_value,
|
||||
"entity_id": entity_id}, ensure_ascii=False), # ④ 审计带账套
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition, BudgetPlan, KPIValue, OperationLog
|
||||
from app.utils.oplog import log_op, SCOPE_BATCH
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
@@ -225,6 +226,7 @@ def generate_budget_from_kpis(
|
||||
period = f"{year}-{month:02d}"
|
||||
results = []
|
||||
total_amount = 0
|
||||
_kpi_entities = set() # 2026-09-23 ④:审计带账套(取被处理 KPI 自身的账套,不猜会话账套)
|
||||
|
||||
for item in items:
|
||||
kpi_id = item.get("kpi_id")
|
||||
@@ -238,6 +240,7 @@ def generate_budget_from_kpis(
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi:
|
||||
continue
|
||||
_kpi_entities.add(getattr(kpi, "entity_id", None))
|
||||
|
||||
# 检查是否已有记录
|
||||
existing = db.query(BudgetPlan).filter(
|
||||
@@ -283,20 +286,20 @@ def generate_budget_from_kpis(
|
||||
"plan_id": plan_id,
|
||||
})
|
||||
|
||||
# 操作日志
|
||||
log = OperationLog(
|
||||
user_id=getattr(current_user, "id", None),
|
||||
action="kpi_generate_budget",
|
||||
target_type="budget",
|
||||
detail=json.dumps({
|
||||
"year": year,
|
||||
"month": month,
|
||||
"version": version,
|
||||
"item_count": len(results),
|
||||
"total_amount": total_amount,
|
||||
}, ensure_ascii=False),
|
||||
)
|
||||
db.add(log)
|
||||
# 操作日志(2026-09-23 ④:detail 带账套;跨账套批量 ⇒ 留空 + 哨兵,不猜账套)
|
||||
_entities = sorted(x for x in _kpi_entities if x is not None)
|
||||
log_op(db, "kpi_generate_budget", target_type="budget",
|
||||
user_id=getattr(current_user, "id", None),
|
||||
detail={
|
||||
"year": year,
|
||||
"month": month,
|
||||
"version": version,
|
||||
"item_count": len(results),
|
||||
"total_amount": total_amount,
|
||||
"kpi_entity_ids": _entities,
|
||||
},
|
||||
entity_id=(_entities[0] if len(_entities) == 1 else None),
|
||||
scope=(SCOPE_BATCH if len(_entities) > 1 else None))
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import Optional
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.utils.oplog import log_op
|
||||
from app.models import KPIValue, DataSourceConfig, OperationLog, KPIDefinition
|
||||
|
||||
# ERP 直连同步通道停用标记(2026-08-31 OPS 安全加固:erp-gateway DB 凭据已删除)
|
||||
@@ -365,7 +366,8 @@ def create_source(data: dict, db: Session = Depends(get_db), entity_id: int = De
|
||||
db.commit()
|
||||
db.refresh(source)
|
||||
# 操作日志
|
||||
db.add(OperationLog(action="create_source", target_type="source", detail=source.name))
|
||||
log_op(db, "create_source", target_type="source", target_id=source.id,
|
||||
detail=source.name, entity_id=entity_id)
|
||||
db.commit()
|
||||
return {"data": {c.name: getattr(source, c.name) for c in DataSourceConfig.__table__.columns}}
|
||||
|
||||
@@ -382,7 +384,8 @@ def update_source(source_id: int, data: dict, db: Session = Depends(get_db), ent
|
||||
setattr(source, key, data[key])
|
||||
db.commit()
|
||||
db.refresh(source)
|
||||
db.add(OperationLog(action="update_source", target_type="source", detail=source.name))
|
||||
log_op(db, "update_source", target_type="source", target_id=source.id,
|
||||
detail=source.name, entity_id=entity_id)
|
||||
db.commit()
|
||||
return {"data": {c.name: getattr(source, c.name) for c in DataSourceConfig.__table__.columns}}
|
||||
|
||||
@@ -394,7 +397,8 @@ def delete_source(source_id: int, db: Session = Depends(get_db), entity_id: int
|
||||
).first()
|
||||
if not source:
|
||||
raise HTTPException(404, "数据源不存在")
|
||||
db.add(OperationLog(action="delete_source", target_type="source", detail=source.name))
|
||||
log_op(db, "delete_source", target_type="source", target_id=source.id,
|
||||
detail=source.name, entity_id=entity_id)
|
||||
db.delete(source)
|
||||
db.commit()
|
||||
return {"message": "删除成功"}
|
||||
|
||||
@@ -203,6 +203,7 @@ def push_deviation_to_map(
|
||||
target_type="map",
|
||||
target_id=map_id,
|
||||
detail=json.dumps({
|
||||
"entity_id": entity_id, # 2026-09-23 ④:审计带账套(=会话账套)
|
||||
"node_id": node_id,
|
||||
"deviation_id": deviation_id,
|
||||
"kpi_code": kpi_code,
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import SystemConfig, OperationLog
|
||||
from app.utils.oplog import log_op
|
||||
from app.models.driver_budget import DriverFactorTemplate, DriverFactorBudget
|
||||
|
||||
router = APIRouter(
|
||||
@@ -126,6 +127,7 @@ def set_driver_mode(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""切换预算编制模式: subject(科目模式) / driver(驱动因子模式)"""
|
||||
mode = data.get("mode", "subject")
|
||||
@@ -145,13 +147,11 @@ def set_driver_mode(
|
||||
db.add(cfg)
|
||||
db.commit()
|
||||
|
||||
log = OperationLog(
|
||||
action="update",
|
||||
target_type="budget",
|
||||
target_id=0,
|
||||
detail=json.dumps({"mode": mode, "action": "切换编制模式"}, ensure_ascii=False),
|
||||
)
|
||||
db.add(log)
|
||||
# 2026-09-23 ④:审计带账套(会话账套)+ 显式标注该键当前仍是**全局键**
|
||||
#(budget_driver_mode 拆账套 = 另单排期项,见 dama-entity-isolation-sweep-20260923 §二之三)
|
||||
log_op(db, "update", target_type="budget", target_id=0,
|
||||
detail={"mode": mode, "action": "切换编制模式", "entity_scope": "global_key"},
|
||||
entity_id=entity_id)
|
||||
db.commit()
|
||||
|
||||
return {"message": f"已切换为{'驱动因子模式' if mode == 'driver' else '科目模式'}", "mode": mode}
|
||||
@@ -335,6 +335,7 @@ def driver_calculate(
|
||||
target_type="budget",
|
||||
target_id=budget_record.id,
|
||||
detail=json.dumps({
|
||||
"entity_id": entity_id, # 2026-09-23 ④:审计带账套(=会话账套)
|
||||
"name": name,
|
||||
"formula": formula_text,
|
||||
"factors": complete_factors,
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition, KPICausality, KPIValue, OperationLog
|
||||
from app.utils.oplog import log_op
|
||||
from app.services.causality_verification import (
|
||||
ALL_STATUSES,
|
||||
ALL_SOURCE_TYPES,
|
||||
@@ -356,8 +357,9 @@ def create_causality(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES
|
||||
db.add(c)
|
||||
db.commit()
|
||||
db.refresh(c)
|
||||
db.add(OperationLog(action="create", target_type="kpi_causality",
|
||||
detail=f"创建因果链: {src.kpi_code}→{tgt.kpi_code} (source={source_type})"))
|
||||
log_op(db, "create", target_type="kpi_causality", target_id=c.id,
|
||||
detail=f"创建因果链: {src.kpi_code}→{tgt.kpi_code} (source={source_type})",
|
||||
entity_id=c.entity_id)
|
||||
db.commit()
|
||||
return _to_dict(c)
|
||||
|
||||
@@ -407,8 +409,9 @@ def verify_causality(causality_id: int, data: dict, db: Session = Depends(get_db
|
||||
c.verify_status = target_status
|
||||
c.verified_at = datetime.now()
|
||||
c.verified_by = str(verified_by)[:50]
|
||||
db.add(OperationLog(action="verify", target_type="kpi_causality",
|
||||
detail=f"因果链 #{causality_id} 人工确认: {target_status} (by {verified_by})"))
|
||||
log_op(db, "verify", target_type="kpi_causality", target_id=causality_id,
|
||||
detail=f"因果链 #{causality_id} 人工确认: {target_status} (by {verified_by})",
|
||||
entity_id=c.entity_id)
|
||||
db.commit()
|
||||
db.refresh(c)
|
||||
d = _to_dict(c)
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role, filter_kpis_by_role, kpi_visible_dims
|
||||
from app.models import StrategicMap, MapObjective, KPIDefinition, KPIValue, KPIAlert, OperationLog, Entity, KPICausality, KPIHierarchy, User
|
||||
from app.utils.oplog import log_op
|
||||
from app.api.kpi_governance import validate_kpi_payload, kpi_issues_message
|
||||
from app.utils.threshold_parser import (
|
||||
calc_level as _tp_calc_level,
|
||||
@@ -794,7 +795,7 @@ def create_kpi(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES, enti
|
||||
db.add(kpi)
|
||||
db.commit()
|
||||
db.refresh(kpi)
|
||||
_log(db, 1, "create", "kpi", kpi.id, data)
|
||||
_log(db, 1, "create", "kpi", kpi.id, data, entity_id=entity_id)
|
||||
return kpi_to_dict(kpi)
|
||||
|
||||
|
||||
@@ -834,7 +835,7 @@ def update_kpi(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRIT
|
||||
if hasattr(kpi, k) and v is not None:
|
||||
setattr(kpi, k, v)
|
||||
db.commit()
|
||||
_log(db, 1, "update", "kpi", kpi_id, data)
|
||||
_log(db, 1, "update", "kpi", kpi_id, data, entity_id=entity_id)
|
||||
return kpi_to_dict(kpi)
|
||||
|
||||
|
||||
@@ -907,14 +908,15 @@ def associate_kpi_map(kpi_id: int, data: dict, db: Session = Depends(get_db), us
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
kpi.map_id = map_id
|
||||
db.commit()
|
||||
_log(db, 1, "update", "kpi", kpi_id, {"action": "associate-map", "map_id": map_id})
|
||||
_log(db, 1, "update", "kpi", kpi_id, {"action": "associate-map", "map_id": map_id},
|
||||
entity_id=entity_id)
|
||||
return kpi_to_dict(kpi)
|
||||
|
||||
|
||||
def _log(db, user_id, action, target_type, target_id, detail):
|
||||
log = OperationLog(user_id=user_id, action=action, target_type=target_type, target_id=target_id, detail=json.dumps(detail, ensure_ascii=False) if detail else None)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
def _log(db, user_id, action, target_type, target_id, detail, entity_id=None):
|
||||
"""统一走 log_op(2026-09-23 ④:审计写入统一带 `detail.entity_id`)"""
|
||||
return log_op(db, action, target_type=target_type, target_id=target_id,
|
||||
detail=detail, user_id=user_id, entity_id=entity_id, commit=True)
|
||||
|
||||
|
||||
@router.get("/{kpi_id}/objectives")
|
||||
|
||||
@@ -2971,6 +2971,7 @@ def generate_report(
|
||||
target_type="report",
|
||||
target_id=record.id,
|
||||
detail=json.dumps({
|
||||
"entity_id": entity_id, # 2026-09-23 ④:审计带账套(=会话账套)
|
||||
"report_type": req.report_type,
|
||||
"period": period,
|
||||
"trigger_type": req.trigger_type,
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.database import get_db
|
||||
from app.auth_middleware import require_role
|
||||
from app.deps import get_entity_id
|
||||
from app.models import KPITemplate, KPIDefinition, OperationLog
|
||||
from app.utils.oplog import log_op, SCOPE_GLOBAL
|
||||
from app.api.kpi_governance import validate_kpi_payload, kpi_issues_message
|
||||
|
||||
router = APIRouter(prefix="/api/cma/templates", tags=["KPI模板库"],
|
||||
@@ -75,7 +76,9 @@ def create_template(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES)
|
||||
db.add(t)
|
||||
db.commit()
|
||||
db.refresh(t)
|
||||
_log(db, 1, "create", "template", t.id, {"kpi_code": t.kpi_code, "kpi_name": t.kpi_name})
|
||||
# kpi_templates = 系统预置/用户自定义**模板库**(非账套维度,裁定见 docs/entity-dimension-data-dictionary.md)
|
||||
_log(db, 1, "create", "template", t.id, {"kpi_code": t.kpi_code, "kpi_name": t.kpi_name},
|
||||
scope=SCOPE_GLOBAL)
|
||||
return template_to_dict(t)
|
||||
|
||||
|
||||
@@ -174,13 +177,12 @@ def instantiate_template(template_id: int, data: dict, db: Session = Depends(get
|
||||
t.usage_count = (t.usage_count or 0) + 1
|
||||
db.commit()
|
||||
|
||||
_log(db, 1, "create", "kpi", kpi.id, {"from_template": template_id, "kpi_code": kpi.kpi_code})
|
||||
_log(db, 1, "create", "kpi", kpi.id, {"from_template": template_id, "kpi_code": kpi.kpi_code},
|
||||
entity_id=entity_id)
|
||||
return {c.name: getattr(kpi, c.name) for c in kpi.__table__.columns}
|
||||
|
||||
|
||||
def _log(db, user_id, action, target_type, target_id, detail):
|
||||
import json
|
||||
log = OperationLog(user_id=user_id, action=action, target_type=target_type,
|
||||
target_id=target_id, detail=json.dumps(detail, ensure_ascii=False) if detail else None)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
def _log(db, user_id, action, target_type, target_id, detail, entity_id=None, scope=None):
|
||||
"""统一走 log_op(2026-09-23 ④:审计写入统一带 `detail.entity_id`)"""
|
||||
return log_op(db, action, target_type=target_type, target_id=target_id,
|
||||
detail=detail, user_id=user_id, entity_id=entity_id, scope=scope, commit=True)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""操作审计写入统一入口(2026-09-23,`dama-entity-isolation-sweep-20260923` §二之二④)。
|
||||
|
||||
**约定(只动写侧;不加列、不改读侧口径、不回填历史)**:
|
||||
|
||||
1. 每条 `operation_logs` **尽量带 `detail.entity_id`** —— 语义是「**本动作所属账套**」:
|
||||
会话账套(`Depends(get_entity_id)`)/ Bot 入参账套 / 被处理对象自身的账套。
|
||||
2. 确实没有账套上下文的写入口 → `entity_id=None`,并打一条 `OPLOG_NO_ENTITY` 警告:
|
||||
把覆盖缺口留成**可 grep 的哨兵**(下一轮审计收口有据可查),
|
||||
**绝不猜账套** —— 猜错比留空更有害(错账套会让 `knowledge_service` 归集时又串一次)。
|
||||
3. 新的审计写入口一律走 `log_op()`,不要新写 `OperationLog(...)` 构造(第 28 处必漏)。
|
||||
|
||||
**读侧(本轮不动)**:`app/services/knowledge_service.py::extract_events` 仍按
|
||||
`user_id → user_entities` 兜底过滤 —— `user_id IS NULL` 的 Bot/脚本日志因此进不了记忆抽取
|
||||
(**覆盖缺口,非泄漏**)。`detail.entity_id` 到位后下一轮才切读侧。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.models import OperationLog
|
||||
|
||||
logger = logging.getLogger("cma.oplog")
|
||||
|
||||
#: 哨兵前缀(供 grep / 告警链订阅):无账套上下文的写入口
|
||||
NO_ENTITY_TAG = "OPLOG_NO_ENTITY"
|
||||
#: 哨兵前缀:跨账套批量写(一次动作覆盖多账套,无法归单一账套)——覆盖缺口,待下一轮收口
|
||||
BATCH_NO_ENTITY_TAG = "OPLOG_BATCH_NO_ENTITY"
|
||||
|
||||
#: `scope` 取值:显式声明「该写入口不是账套维度」(平台字典表 / 角色级配置),不再报缺口
|
||||
SCOPE_GLOBAL = "global"
|
||||
#: `scope` 取值:一次动作跨多账套批量(无法归单账套)——照实报警,不猜账套
|
||||
SCOPE_BATCH = "batch"
|
||||
|
||||
|
||||
def _as_detail_dict(detail) -> dict:
|
||||
"""把三种历史 detail 写法归一到 dict(**不改变已有存储形态语义**)。
|
||||
|
||||
- ``dict`` → 直接用(复制一份,不就地改调用方的对象)
|
||||
- JSON **对象**字符串(历史写法 ``json.dumps({...})``)→ 解析为 dict,与 entity_id 合并进同一对象
|
||||
- 其他字符串 → ``{"note": <原文>}``(原先以标量字符串存的行,升级为对象并附账套)
|
||||
"""
|
||||
if detail is None:
|
||||
return {}
|
||||
if isinstance(detail, dict):
|
||||
return dict(detail)
|
||||
if isinstance(detail, str):
|
||||
s = detail.strip()
|
||||
if s.startswith("{"):
|
||||
try:
|
||||
v = json.loads(s)
|
||||
except (ValueError, TypeError):
|
||||
v = None
|
||||
if isinstance(v, dict):
|
||||
return v
|
||||
return {"note": detail}
|
||||
return {"note": str(detail)}
|
||||
|
||||
|
||||
def log_op(db, action, *, target_type=None, target_id=None, detail=None,
|
||||
user_id=None, entity_id=None, scope=None, commit=False):
|
||||
"""写一条 `operation_logs`,统一带账套(`detail.entity_id`)。
|
||||
|
||||
:param db: SQLAlchemy Session
|
||||
:param action: 动作名(DB 非空列)
|
||||
:param target_type: 目标类型(DB 非空列)
|
||||
:param target_id: 目标 id(可空)
|
||||
:param detail: dict(推荐)/ JSON 对象字符串 / 普通 str(存为 ``{"note": <原文>}``)/ None
|
||||
:param user_id: 操作人;Bot/脚本无用户时 None(读侧本轮仍按 user_id 兜底)
|
||||
:param entity_id: **本动作所属账套**;无上下文时 None(触发 OPLOG_NO_ENTITY 哨兵)
|
||||
:param scope: ``SCOPE_GLOBAL`` = 该写入口按设计非账套维度(写 ``detail.entity_scope``,不报警);
|
||||
``SCOPE_BATCH`` = 跨账套批量(写 ``entity_scope=batch`` **并**报警,不猜账套);
|
||||
None = 未知(报警,留可 grep 的覆盖缺口)
|
||||
:param commit: 是否立即提交(默认 False,交由调用方事务边界)
|
||||
"""
|
||||
d = _as_detail_dict(detail)
|
||||
|
||||
# 调用方若已把账套写在 detail 里(历史写法),沿用之,避免同一个键两套来源
|
||||
if entity_id is None and d.get("entity_id") is not None:
|
||||
entity_id = d["entity_id"]
|
||||
|
||||
if entity_id is not None:
|
||||
d["entity_id"] = int(entity_id)
|
||||
elif scope == SCOPE_GLOBAL:
|
||||
d["entity_scope"] = SCOPE_GLOBAL
|
||||
elif scope == SCOPE_BATCH:
|
||||
d["entity_scope"] = SCOPE_BATCH
|
||||
logger.warning(
|
||||
"%s action=%s target_type=%s target_id=%s — 跨账套批量写,detail.entity_id 留空"
|
||||
"(禁止猜账套)", BATCH_NO_ENTITY_TAG, action, target_type, target_id)
|
||||
else:
|
||||
logger.warning(
|
||||
"%s action=%s target_type=%s target_id=%s — 该写入口无账套上下文,"
|
||||
"detail.entity_id 留空(禁止猜账套)", NO_ENTITY_TAG, action, target_type, target_id)
|
||||
|
||||
row = OperationLog(
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
detail=d or None,
|
||||
)
|
||||
db.add(row)
|
||||
if commit:
|
||||
db.commit()
|
||||
return row
|
||||
@@ -16,6 +16,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app.database import get_session_local
|
||||
from app.models import KPIDefinition, KPIValue, KPIAlert, ActionPlan, OperationLog
|
||||
from app.utils.oplog import log_op
|
||||
from app.utils.threshold_parser import eval_threshold, LEVEL_PRIORITY
|
||||
# 裁定2a(2026-09-17 财务口径):比率类指标统一「百分比(%)」口径(取数×100),
|
||||
# 未核对/疑似单位错配的项降级待核(不落预警、不推送)
|
||||
|
||||
+13
-10
@@ -23,6 +23,7 @@ load_dotenv()
|
||||
|
||||
from app.database import get_engine, get_session_local
|
||||
from app.models import KPIDefinition, KPIValue, OperationLog, DataSourceConfig
|
||||
from app.utils.oplog import log_op, SCOPE_BATCH
|
||||
|
||||
logger = logging.getLogger("erp_sync")
|
||||
|
||||
@@ -421,16 +422,18 @@ def run_sync(dry_run: bool = False, kpi_codes: list = None, use_api: bool = True
|
||||
fail += 1
|
||||
|
||||
if not dry_run:
|
||||
log = OperationLog(
|
||||
action="erp_sync",
|
||||
target_type="kpi",
|
||||
detail=json.dumps({
|
||||
"total": len(kpis), "success": success,
|
||||
"failed": fail, "api_mode": use_api,
|
||||
"period": datetime.now().strftime("%Y-%m"),
|
||||
}, ensure_ascii=False),
|
||||
)
|
||||
db.add(log)
|
||||
# 2026-09-23 ④:审计带账套。本脚本一次遍历**全库 active ERP KPI(可能跨账套)**:
|
||||
# 全部同账套 ⇒ 写该账套;跨账套 ⇒ 留空 + OPLOG_BATCH_NO_ENTITY 哨兵(不猜账套)
|
||||
_entity_ids = sorted({getattr(k, "entity_id", None) for k in kpis} - {None})
|
||||
log_op(db, "erp_sync", target_type="kpi",
|
||||
detail={
|
||||
"total": len(kpis), "success": success,
|
||||
"failed": fail, "api_mode": use_api,
|
||||
"period": datetime.now().strftime("%Y-%m"),
|
||||
"kpi_entity_ids": _entity_ids,
|
||||
},
|
||||
entity_id=(_entity_ids[0] if len(_entity_ids) == 1 else None),
|
||||
scope=(SCOPE_BATCH if len(_entity_ids) != 1 else None))
|
||||
db.commit()
|
||||
|
||||
logger.info(f"同步完成: {success}成功 / {fail}失败 / {len(kpis)}总计")
|
||||
|
||||
@@ -28,6 +28,8 @@ import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
import ast
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
@@ -35,6 +37,8 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Entity, OperationLog, RolePermission, SystemConfig
|
||||
from app.database import Base
|
||||
from app.utils.oplog import (BATCH_NO_ENTITY_TAG, NO_ENTITY_TAG, SCOPE_BATCH,
|
||||
SCOPE_GLOBAL, log_op)
|
||||
from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user
|
||||
|
||||
BUDGET = "/api/cma/budget"
|
||||
@@ -378,3 +382,157 @@ class TestMigrationScript:
|
||||
with pytest.raises(SystemExit) as e:
|
||||
self._run(mod, ["--apply", "--allow-db", "sqlite", "--target-entity", "99", "--json"])
|
||||
assert e.value.code == 2
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════
|
||||
# ④ operation_logs 写侧统一带账套(`detail.entity_id`,零 DDL)
|
||||
# 任务单 §二之二④(项目Bot 2026-09-23 18:2x 并入)
|
||||
# ══════════════════════════════════════════════
|
||||
|
||||
BACKEND_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
OPLOG_REL = os.path.join("app", "utils", "oplog.py")
|
||||
|
||||
|
||||
def _operation_log_construction_sites():
|
||||
"""全仓 `OperationLog(` 构造点 → [(相对路径, 行号, 所在语句源码)]
|
||||
|
||||
语句源码取「包含该构造调用的完整 Assign/Expr 语句」(多行 dict 字面量整段在内),
|
||||
用于断言「写入时带账套」这件事落在**语句级**,而不是碰巧命中了别处的 entity_id。
|
||||
"""
|
||||
out = []
|
||||
for rel in ("app", "scripts"):
|
||||
for dp, _dn, fns in os.walk(os.path.join(BACKEND_ROOT, rel)):
|
||||
if "__pycache__" in dp:
|
||||
continue
|
||||
for fn in fns:
|
||||
if not fn.endswith(".py"):
|
||||
continue
|
||||
path = os.path.join(dp, fn)
|
||||
src = open(path, encoding="utf-8").read()
|
||||
tree = ast.parse(src)
|
||||
lines = src.splitlines()
|
||||
stmts = [(n.lineno, n.end_lineno) for n in ast.walk(tree)
|
||||
if isinstance(n, (ast.Assign, ast.Expr, ast.Return))
|
||||
and getattr(n, "lineno", None) and getattr(n, "end_lineno", None)]
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
fn_node = node.func
|
||||
name = getattr(fn_node, "id", None) or getattr(fn_node, "attr", None)
|
||||
if name != "OperationLog":
|
||||
continue
|
||||
cand = [s for s in stmts if s[0] <= node.lineno and s[1] >= node.end_lineno]
|
||||
s, e = min(cand, key=lambda x: x[1] - x[0]) if cand else (node.lineno, node.end_lineno)
|
||||
out.append((os.path.relpath(path, BACKEND_ROOT).replace("\\", "/"),
|
||||
node.lineno, "\n".join(lines[s - 1:e])))
|
||||
return out
|
||||
|
||||
|
||||
class TestAuditWriteSideEntityId:
|
||||
"""④ 写侧收口:所有审计行带账套,且集中封装(不加列、不回填历史)"""
|
||||
|
||||
def test_no_new_raw_OperationLog_sites_lack_entity(self):
|
||||
"""源级断言:除集中封装件外,每个构造点的语句里必须出现 entity_id"""
|
||||
sites = _operation_log_construction_sites()
|
||||
assert len(sites) >= 13, f"构造点数量异常(疑似收集失败): {len(sites)}"
|
||||
missing = [(p, l) for p, l, seg in sites
|
||||
if p != OPLOG_REL and "entity_id" not in seg]
|
||||
assert missing == [], f"以下构造点未带账套(禁止猜账套,必须显式): {missing}"
|
||||
|
||||
def test_wrapper_is_single_place_constructing_rows(self):
|
||||
"""集中封装:裸构造 OperationLog 只允许出现在 app/utils/oplog.py(防第 28 处再漏)"""
|
||||
wrappers = [(p, l) for p, l, _seg in _operation_log_construction_sites() if p == OPLOG_REL]
|
||||
assert len(wrappers) == 1, f"oplog.py 应恰好 1 处裸构造(log_op 本体): {wrappers}"
|
||||
|
||||
def test_log_op_callers_pass_entity_or_scope(self):
|
||||
"""所有 `log_op(` 调用点必须传 entity_id= 或 scope=(否则等于留覆盖缺口)"""
|
||||
bad = []
|
||||
for rel in ("app", "scripts"):
|
||||
for dp, _dn, fns in os.walk(os.path.join(BACKEND_ROOT, rel)):
|
||||
if "__pycache__" in dp:
|
||||
continue
|
||||
for fn in fns:
|
||||
if not fn.endswith(".py"):
|
||||
continue
|
||||
path = os.path.join(dp, fn)
|
||||
tree = ast.parse(open(path, encoding="utf-8").read())
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Call) and getattr(node.func, "id", None) == "log_op":
|
||||
if not ({"entity_id", "scope"} & {k.arg for k in node.keywords}):
|
||||
bad.append((os.path.relpath(path, BACKEND_ROOT), node.lineno))
|
||||
assert bad == [], f"log_op 调用点缺账套参数: {bad}"
|
||||
|
||||
# ── 落库实证 ────────────────────────────────
|
||||
|
||||
def test_readback_entity_id_persisted(self, db: Session):
|
||||
"""落库实证 ①:log_op 写入 → **raw SELECT 回查数据库**,detail.entity_id 在表里"""
|
||||
log_op(db, "unit_probe_entity", target_type="kpi", target_id=123,
|
||||
detail={"kpi_code": "PROBE_KPI"}, entity_id=2, commit=True)
|
||||
row = db.execute(text("SELECT action, detail FROM operation_logs "
|
||||
"WHERE action = 'unit_probe_entity'")).fetchone()
|
||||
assert row is not None, "审计行未落库"
|
||||
detail = json.loads(row[1]) if isinstance(row[1], str) else row[1]
|
||||
assert detail.get("entity_id") == 2, f"落库 detail 未带账套: {detail}"
|
||||
assert detail.get("kpi_code") == "PROBE_KPI", f"原 detail 内容被破坏: {detail}"
|
||||
|
||||
def test_readback_kpi_create_endpoint_audit_entity(self, client: TestClient, db: Session):
|
||||
"""落库实证 ②:真实写端点(POST /kpis,会话账套 e2)→ 回查审计行 detail.entity_id == 2"""
|
||||
_seed_two_entities(db)
|
||||
create_test_user(db)
|
||||
token = _login_as(client, "testadmin", 2) # 博海 e2
|
||||
resp = client.post("/api/cma/kpis", headers=auth_header(token), json={
|
||||
"kpi_code": "F_AUDIT_PROBE_E2", "kpi_name": "审计落库探针",
|
||||
"dimension": "finance", "target_value": 100.0, "unit": "%",
|
||||
"formula": "测试公式", "data_source": "测试系统", "data_owner": "测试管理员",
|
||||
})
|
||||
assert resp.status_code == 200, resp.text[:300]
|
||||
row = db.execute(text("SELECT detail FROM operation_logs WHERE action = 'create' "
|
||||
"AND target_type = 'kpi' ORDER BY id DESC LIMIT 1")).scalar()
|
||||
assert row is not None, "端点未写审计行"
|
||||
detail = json.loads(row) if isinstance(row, str) else row
|
||||
assert detail.get("entity_id") == 2, f"审计 detail 未带会话账套: {detail}"
|
||||
|
||||
# ── 形态与哨兵 ──────────────────────────────
|
||||
|
||||
def test_legacy_json_string_detail_is_not_nested(self, db: Session):
|
||||
"""历史写法(detail=json.dumps({...}))归一后不嵌套:entity_id 与原键同一层"""
|
||||
log_op(db, "unit_probe_legacy", target_type="budget", target_id=1,
|
||||
detail=json.dumps({"year": 2026, "mode": "rolling"}, ensure_ascii=False),
|
||||
entity_id=1, commit=True)
|
||||
detail = db.execute(text("SELECT detail FROM operation_logs "
|
||||
"WHERE action = 'unit_probe_legacy'")).scalar()
|
||||
detail = json.loads(detail) if isinstance(detail, str) else detail
|
||||
assert detail == {"year": 2026, "mode": "rolling", "entity_id": 1}, detail
|
||||
|
||||
def test_global_scope_no_gap_warning(self, db: Session, caplog):
|
||||
"""非账套维度(平台/角色级):写 entity_scope=global,且不产生缺口告警"""
|
||||
with caplog.at_level("WARNING", logger="cma.oplog"):
|
||||
log_op(db, "unit_probe_global", target_type="template", target_id=1,
|
||||
detail={"kpi_code": "T1"}, scope=SCOPE_GLOBAL, commit=True)
|
||||
detail = db.execute(text("SELECT detail FROM operation_logs "
|
||||
"WHERE action = 'unit_probe_global'")).scalar()
|
||||
detail = json.loads(detail) if isinstance(detail, str) else detail
|
||||
assert detail.get("entity_scope") == "global" and "entity_id" not in detail, detail
|
||||
assert NO_ENTITY_TAG not in caplog.text
|
||||
|
||||
def test_missing_context_warns_sentinel_never_guesses(self, db: Session, caplog):
|
||||
"""无账套上下文:留空 + 可 grep 哨兵(绝不猜账套)"""
|
||||
with caplog.at_level("WARNING", logger="cma.oplog"):
|
||||
log_op(db, "unit_probe_unknown", target_type="kpi", target_id=1,
|
||||
detail={"note": "no context"}, commit=True)
|
||||
assert NO_ENTITY_TAG in caplog.text
|
||||
detail = db.execute(text("SELECT detail FROM operation_logs "
|
||||
"WHERE action = 'unit_probe_unknown'")).scalar()
|
||||
detail = json.loads(detail) if isinstance(detail, str) else detail
|
||||
assert "entity_id" not in detail, detail
|
||||
|
||||
def test_batch_scope_warns_own_sentinel(self, db: Session, caplog):
|
||||
"""跨账套批量:entity_scope=batch + 独立哨兵(不猜单账套)"""
|
||||
with caplog.at_level("WARNING", logger="cma.oplog"):
|
||||
log_op(db, "unit_probe_batch", target_type="kpi", detail={"total": 3},
|
||||
scope=SCOPE_BATCH, commit=True)
|
||||
assert BATCH_NO_ENTITY_TAG in caplog.text
|
||||
detail = db.execute(text("SELECT detail FROM operation_logs "
|
||||
"WHERE action = 'unit_probe_batch'")).scalar()
|
||||
detail = json.loads(detail) if isinstance(detail, str) else detail
|
||||
assert detail.get("entity_scope") == "batch", detail
|
||||
|
||||
Reference in New Issue
Block a user