背景(项目Bot 补充复验 §四#2):以下 4 个只读端点未做会话账套鉴权,
用 entity=1(酣客) 会话 token + 显式 entity 参数可读到 entity=2(博海) 数据(HTTP 200):
- GET /data-quality/governance-check?entity_id=2 → 200 score=88(对照 entity1=72)
- GET /products/matrix?entity_id=2 → 200
- GET /alerts/risk-matrix?entity=bohai → 200 博海风险矩阵
- GET /action-plans/coso-checklist?entity=bohai → 200 博海 COSO total_score=55
另:risk-matrix / coso-checklist 在非法实体值下静默回落酣客数据,UI 标签与数据不一致。
改动:
1. deps.py 新增统一防护入口 assert_entity_scope / assert_entity_slug_scope /
get_session_entity / resolve_session_slug(文案对齐 get_entity_id:无权访问企业 entity_id=X(当前账套: Y))
2. governance-check / products/matrix:entity_id 须等于会话账套,否则 403;
缺省按会话账套(governance-check 原缺省 0=全部 → 登录态下按账套收敛)
3. risk-matrix / coso-checklist:entity 须等于会话账套,否则 403;
非法值 → 400 显式报错(不再静默回落酣客)
4. P3:POST /auth/switch-entity 加 DEPRECATED 注释(前端已无调用,保留不删)
5. 测试:tests/test_security_multitenant.py 新增 TestCrossEntityEndpointAuth 15 例
(跨实体 403 / 同实体 200 / 缺省按账套 / 非法显式报错 / 参照端点行为不变)
验证:pytest tests/ -q → 951 passed(10 项失败为改动前既有基线,已 stash 对照确认);
真实后端冒烟 19/19 PASS(entity=1 会话 4 端点跨实体全 403)。
348 lines
14 KiB
Python
348 lines
14 KiB
Python
"""预警 API"""
|
|
from fastapi import APIRouter, Depends, Query, HTTPException, Request
|
|
from typing import Optional
|
|
from sqlalchemy.orm import Session
|
|
from app.database import get_db
|
|
from app.deps import get_entity_id, assert_entity_slug_scope, resolve_session_slug
|
|
from app.auth_middleware import require_auth, require_role
|
|
from app.models import KPIAlert, OperationLog, ActionPlan, KPIDefinition
|
|
import logging
|
|
|
|
logger = logging.getLogger("cma.alerts")
|
|
|
|
router = APIRouter(prefix="/api/cma/alerts", tags=["预警"],
|
|
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
|
)
|
|
|
|
@router.get("")
|
|
def list_alerts(status: str = None, page: int = Query(1, ge=1), db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
|
# 账套隔离: join kpi_definitions 按企业过滤 (2026-08-23 P1b)
|
|
query = db.query(KPIAlert).join(KPIDefinition, KPIDefinition.id == KPIAlert.kpi_id).filter(KPIDefinition.entity_id == entity_id)
|
|
if status:
|
|
query = query.filter(KPIAlert.status == status)
|
|
total = query.count()
|
|
alerts = query.order_by(KPIAlert.created_at.desc()).offset((page-1)*20).limit(20).all()
|
|
return {"total": total, "data": [{c.name: getattr(a, c.name) for c in KPIAlert.__table__.columns} for a in alerts]}
|
|
|
|
@router.post("/{alert_id}/resolve")
|
|
def resolve_alert(alert_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
|
alert = db.query(KPIAlert).join(KPIDefinition, KPIDefinition.id == KPIAlert.kpi_id).filter(
|
|
KPIAlert.id == alert_id, KPIDefinition.entity_id == entity_id).first()
|
|
if not alert:
|
|
raise HTTPException(404, "预警不存在")
|
|
alert.status = "resolved"
|
|
alert.resolution = data.get("resolution", "")
|
|
alert.assignee = data.get("assignee", alert.assignee)
|
|
from datetime import datetime; alert.resolved_at = datetime.now()
|
|
db.commit()
|
|
db.refresh(alert)
|
|
return {
|
|
"message": "已处理",
|
|
"assignee": alert.assignee,
|
|
"alert": {c.name: getattr(alert, c.name) for c in KPIAlert.__table__.columns},
|
|
"suggest_create_action_plan": alert.alert_level == "red",
|
|
}
|
|
|
|
|
|
@router.post("/{alert_id}/process")
|
|
def process_alert(alert_id: int, data: dict, db: Session = Depends(get_db)):
|
|
"""标记预警为处理中"""
|
|
alert = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first()
|
|
if not alert:
|
|
raise HTTPException(404, "预警不存在")
|
|
if alert.status == "resolved":
|
|
raise HTTPException(400, "已处理的预警不能重复处理")
|
|
assignee = data.get("assignee")
|
|
if not assignee:
|
|
raise HTTPException(400, "缺少处理人")
|
|
alert.status = "processing"
|
|
alert.assignee = assignee
|
|
db.commit()
|
|
db.refresh(alert)
|
|
return {"message": "已标记为处理中", "alert": {c.name: getattr(alert, c.name) for c in KPIAlert.__table__.columns}}
|
|
|
|
|
|
@router.post("/{alert_id}/escalate")
|
|
def escalate_alert(alert_id: int, data: dict, db: Session = Depends(get_db)):
|
|
"""升级预警级别"""
|
|
alert = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first()
|
|
if not alert:
|
|
raise HTTPException(404, "预警不存在")
|
|
assignee = data.get("assignee")
|
|
if not assignee:
|
|
raise HTTPException(400, "缺少处理人")
|
|
if alert.status == "resolved":
|
|
raise HTTPException(400, "已处理的预警不能升级")
|
|
if alert.alert_level != "red":
|
|
alert.alert_level = "red"
|
|
alert.assignee = assignee
|
|
db.commit()
|
|
db.refresh(alert)
|
|
return {"message": "已升级", "alert": {c.name: getattr(alert, c.name) for c in KPIAlert.__table__.columns}}
|
|
|
|
|
|
@router.get("/check-timeout")
|
|
def check_alert_timeout(db: Session = Depends(get_db)):
|
|
"""超时预警检测 — 超过24小时未处理的pending预警自动升级为红色"""
|
|
from datetime import datetime, timedelta
|
|
cutoff = datetime.now() - timedelta(hours=24)
|
|
timeout_alerts = db.query(KPIAlert).filter(
|
|
KPIAlert.status == "pending",
|
|
KPIAlert.created_at < cutoff,
|
|
KPIAlert.alert_level != "red",
|
|
).all()
|
|
upgraded_count = 0
|
|
for alert in timeout_alerts:
|
|
alert.alert_level = "red"
|
|
alert.status = "processing"
|
|
upgraded_count += 1
|
|
if upgraded_count:
|
|
db.commit()
|
|
return {"total_timeout": len(timeout_alerts), "upgraded_count": upgraded_count}
|
|
|
|
|
|
@router.post("/{alert_id}/create-action-plan")
|
|
def create_action_plan_from_alert(alert_id: int, data: dict, db: Session = Depends(get_db)):
|
|
"""从预警创建改善行动计划"""
|
|
alert = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first()
|
|
if not alert:
|
|
raise HTTPException(404, "预警不存在")
|
|
if alert.action_plan_linked_id:
|
|
raise HTTPException(400, "已关联改善计划")
|
|
plan = ActionPlan(
|
|
alert_id=alert.id,
|
|
kpi_id=alert.kpi_id,
|
|
title=f"改善: {alert.alert_message}",
|
|
assignee=data.get("assignee", ""),
|
|
priority="high" if alert.alert_level == "red" else "medium",
|
|
created_by=data.get("created_by", ""),
|
|
)
|
|
db.add(plan)
|
|
db.commit()
|
|
db.refresh(plan)
|
|
alert.action_plan_linked_id = plan.id
|
|
db.commit()
|
|
return {"message": "改善行动计划已创建", "plan_id": plan.id, "priority": plan.priority}
|
|
|
|
|
|
# ──────────────────────────────────────────────
|
|
# 功能4: 风险矩阵热力图 (CMA P2 - ERM框架、风险识别四象限)
|
|
|
|
RISK_MATRIX_DATA = {
|
|
"hanke": {
|
|
"entity_name": "陕西酣客(白酒经销)",
|
|
"quadrants": [
|
|
{
|
|
"impact": "high",
|
|
"probability": "high",
|
|
"label": "高影响×高概率",
|
|
"risks": [
|
|
{
|
|
"id": "risk_001",
|
|
"name": "流动性风险",
|
|
"impact_label": "高",
|
|
"probability_label": "高",
|
|
"detail": "现金2.2万 vs 短债350万 → 断流风险",
|
|
"impact_value": 90,
|
|
"probability_value": 85,
|
|
"type": "red",
|
|
"measures": ["催收大额应收", "协商短期借款续贷"],
|
|
"responsible": "任富海",
|
|
"deadline": "7月底",
|
|
},
|
|
{
|
|
"id": "risk_002",
|
|
"name": "合规风险",
|
|
"impact_label": "高",
|
|
"probability_label": "高",
|
|
"detail": "欠税426万 · 折旧违规",
|
|
"impact_value": 95,
|
|
"probability_value": 80,
|
|
"type": "red",
|
|
"measures": ["补缴欠税计划", "重新梳理折旧政策"],
|
|
"responsible": "任富海",
|
|
"deadline": "8月底",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"impact": "high",
|
|
"probability": "medium",
|
|
"label": "高影响×中概率",
|
|
"risks": [
|
|
{
|
|
"id": "risk_003",
|
|
"name": "政策风险",
|
|
"impact_label": "高",
|
|
"probability_label": "中",
|
|
"detail": "白酒消费税调整可能导致成本上升15-20%",
|
|
"impact_value": 85,
|
|
"probability_value": 50,
|
|
"type": "orange",
|
|
"measures": ["关注政策动向", "预留税务缓冲资金"],
|
|
"responsible": "财务部",
|
|
"deadline": "持续关注",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"impact": "medium",
|
|
"probability": "high",
|
|
"label": "中影响×高概率",
|
|
"risks": [
|
|
{
|
|
"id": "risk_004",
|
|
"name": "运营风险",
|
|
"impact_label": "中",
|
|
"probability_label": "高",
|
|
"detail": "Model C 成本模型未落地,成本核算偏差",
|
|
"impact_value": 60,
|
|
"probability_value": 80,
|
|
"type": "yellow",
|
|
"measures": ["推动Model C落地", "建立成本标准化流程"],
|
|
"responsible": "财务部",
|
|
"deadline": "8月中",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"impact": "medium",
|
|
"probability": "medium",
|
|
"label": "中影响×中概率",
|
|
"risks": [
|
|
{
|
|
"id": "risk_005",
|
|
"name": "战略风险",
|
|
"impact_label": "中",
|
|
"probability_label": "中",
|
|
"detail": "酒类零交易,新业务方向不确定",
|
|
"impact_value": 55,
|
|
"probability_value": 55,
|
|
"type": "yellow",
|
|
"measures": ["制定新业务评估框架", "定期战略复盘"],
|
|
"responsible": "管理层",
|
|
"deadline": "9月底",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"impact": "low",
|
|
"probability": "low",
|
|
"label": "低影响×低概率",
|
|
"risks": [
|
|
{
|
|
"id": "risk_006",
|
|
"name": "市场风险",
|
|
"impact_label": "低",
|
|
"probability_label": "低",
|
|
"detail": "行业需求波动,但酣客已基本退出市场",
|
|
"impact_value": 25,
|
|
"probability_value": 20,
|
|
"type": "green",
|
|
"measures": ["定期监控行业数据"],
|
|
"responsible": "业务部",
|
|
"deadline": "每季度",
|
|
},
|
|
{
|
|
"id": "risk_007",
|
|
"name": "人员风险",
|
|
"impact_label": "低",
|
|
"probability_label": "低",
|
|
"detail": "核心团队稳定,短期内无流失风险",
|
|
"impact_value": 20,
|
|
"probability_value": 15,
|
|
"type": "green",
|
|
"measures": ["保持团队激励", "关键岗位备份"],
|
|
"responsible": "人事部",
|
|
"deadline": "持续",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
"bohai": {
|
|
"entity_name": "陕西博海科技(IT服务)",
|
|
"quadrants": [
|
|
{
|
|
"impact": "high",
|
|
"probability": "medium",
|
|
"label": "高影响×中概率",
|
|
"risks": [
|
|
{
|
|
"id": "risk_b_001",
|
|
"name": "现金流风险",
|
|
"impact_label": "高",
|
|
"probability_label": "中",
|
|
"detail": "应收账款账期延长,现金流紧张",
|
|
"impact_value": 85,
|
|
"probability_value": 55,
|
|
"type": "orange",
|
|
"measures": ["加快应收催收", "建立信用管理制度"],
|
|
"responsible": "任富海",
|
|
"deadline": "7月底",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"impact": "medium",
|
|
"probability": "high",
|
|
"label": "中影响×高概率",
|
|
"risks": [
|
|
{
|
|
"id": "risk_b_002",
|
|
"name": "项目交付风险",
|
|
"impact_label": "中",
|
|
"probability_label": "高",
|
|
"detail": "多个项目并行,交付压力大",
|
|
"impact_value": 65,
|
|
"probability_value": 75,
|
|
"type": "yellow",
|
|
"measures": ["优化项目排期", "增加外包资源"],
|
|
"responsible": "项目部",
|
|
"deadline": "持续",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"impact": "low",
|
|
"probability": "medium",
|
|
"label": "低影响×中概率",
|
|
"risks": [
|
|
{
|
|
"id": "risk_b_003",
|
|
"name": "技术迭代风险",
|
|
"impact_label": "低",
|
|
"probability_label": "中",
|
|
"detail": "新技术跟踪不及时,可能落后",
|
|
"impact_value": 30,
|
|
"probability_value": 45,
|
|
"type": "green",
|
|
"measures": ["定期技术培训", "技术栈评估"],
|
|
"responsible": "技术部",
|
|
"deadline": "每季度",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
}
|
|
|
|
|
|
@router.get("/risk-matrix")
|
|
def get_risk_matrix(
|
|
request: Request,
|
|
entity: Optional[str] = Query(None, description="hanke/bohai(须与会话账套一致);缺省按会话账套"),
|
|
):
|
|
"""风险矩阵热力图数据 - CMA P2 ERM四象限
|
|
|
|
多租户(cma-cross-entity-endpoints-auth-20260914):
|
|
非法实体值 → 400 显式报错(不再静默回落酣客数据);跨实体 → 403。
|
|
"""
|
|
slug = assert_entity_slug_scope(request, entity)
|
|
if slug is None:
|
|
slug = resolve_session_slug(request, "hanke")
|
|
data = RISK_MATRIX_DATA.get(slug)
|
|
if not data:
|
|
raise HTTPException(404, f"实体 entity={slug} 无风险矩阵数据")
|
|
return data
|