Files
cma-management/backend/app/api/notifications.py
T
Hermes CI Fix a0762b367f fix(cma): entity isolation batch4 (cash plan writes, budget ownership, read leaks, okr template)
P0-A cash.py: scope api_update_plan / api_delete_plan / api_complete_plan /
api_register_payment to session entity (cross-entity id -> 404, aligning with
the file's existing get_entity_id endpoints).
P0-B budget.py: write entity_id on BudgetPlan creation + require the KPI to
belong to the session entity; scope /comparison, /comparison/kpi/{id} and
/deviation-report reads to the session entity.
P1 notifications.py: explicit field whitelist (name/config/enabled) on
update_channel to close mass-assignment.
P1 read leaks scoped to session entity: bot_kpis list, data_quality check/stats
(KPI calibre only; quality-log table has no entity_id column), kpis causality
chain + associate-map (KPI and map ownership), dashboard my-kpis,
customer_dashboard list, kpi_governance audit, budget_generate candidates,
driver_budget history (+ entity_id on its write path), expenses stats
(+ entity_id on its write path).
P2 okr_templates.py: write entity_id on the StrategicMap created by apply.

tests: tests/test_cross_entity_isolation_batch4_20260921.py (30 cases;
RED before fix = 22 failed, GREEN after = 30 passed; full suite 1302 passed /
1 xfailed / 0 failed).
2026-09-21 09:37:38 +08:00

120 lines
4.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""通知渠道配置 API"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import NotificationChannel, NotificationLog
from app.auth_middleware import require_role, require_auth_401
from datetime import datetime
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
# 缺口:本 router 6 个端点全部无鉴权——无 token 可读写通知渠道(config 内含 webhook
# 地址/令牌等凭据)、可触发渠道测试推送、可读推送日志。收口为 require_auth_401。
router = APIRouter(prefix="/api/cma/notifications", tags=["通知配置"],
dependencies=[Depends(require_auth_401)])
def ch_to_dict(c):
return {
"id": c.id,
"name": c.name,
"channel_type": c.channel_type,
"config": c.config,
"enabled": c.enabled,
"created_at": c.created_at.isoformat() if c.created_at else None,
}
@router.get("/channels")
def list_channels(db: Session = Depends(get_db)):
"""获取通知渠道列表"""
channels = db.query(NotificationChannel).order_by(NotificationChannel.id).all()
return {"data": [ch_to_dict(c) for c in channels]}
@router.post("/channels")
def create_channel(data: dict, db: Session = Depends(get_db)):
"""创建通知渠道"""
ch = NotificationChannel(
name=data["name"],
channel_type=data["channel_type"],
config=data.get("config", {}),
enabled=data.get("enabled", True),
)
db.add(ch)
db.commit()
db.refresh(ch)
return ch_to_dict(ch)
@router.put("/channels/{ch_id}")
def update_channel(ch_id: int, data: dict, db: Session = Depends(get_db)):
"""更新通知渠道"""
ch = db.query(NotificationChannel).filter(NotificationChannel.id == ch_id).first()
if not ch:
raise HTTPException(404, "渠道不存在")
# 字段白名单(2026-09-21 mass-assignment 收口):仅业务字段,其余键忽略
_updatable = ("name", "config", "enabled")
for k, v in data.items():
if k in _updatable:
setattr(ch, k, v)
db.commit()
db.refresh(ch)
return ch_to_dict(ch)
@router.delete("/channels/{ch_id}")
def delete_channel(ch_id: int, db: Session = Depends(get_db)):
"""删除通知渠道"""
ch = db.query(NotificationChannel).filter(NotificationChannel.id == ch_id).first()
if not ch:
raise HTTPException(404, "渠道不存在")
db.delete(ch)
db.commit()
return {"message": "已删除"}
@router.post("/channels/{ch_id}/test")
def test_channel(ch_id: int, db: Session = Depends(get_db)):
"""测试推送"""
from app.utils.notifier import push_alert
ch = db.query(NotificationChannel).filter(NotificationChannel.id == ch_id).first()
if not ch:
raise HTTPException(404, "渠道不存在")
config = ch.config or {}
test_alert = {
"alert_level": "yellow",
"alert_message": "【测试通知】这是一条管理会计OS的测试预警",
"kpi_name": "销售总额",
"period": datetime.now().strftime("%Y-%m"),
"actual_value": "800,000",
"target_value": "1,000,000",
}
results = push_alert(test_alert, [{
"name": ch.name, "channel_type": ch.channel_type,
"config": config, "enabled": True
}])
return {"results": results}
@router.get("/logs")
def list_logs(page: int = 1, db: Session = Depends(get_db)):
"""通知历史"""
total = db.query(NotificationLog).count()
logs = db.query(NotificationLog).order_by(
NotificationLog.created_at.desc()
).offset((page - 1) * 20).limit(20).all()
return {
"total": total,
"data": [{
"id": l.id,
"alert_id": l.alert_id,
"channel": l.channel,
"recipient": l.recipient,
"title": l.title,
"status": l.status,
"error_msg": l.error_msg,
"sent_at": l.sent_at.isoformat() if l.sent_at else None,
"created_at": l.created_at.isoformat() if l.created_at else None,
} for l in logs]
}