Files
cma-management/backend/app/api/notifications.py
T
wecom-fullstack 0c2e57a4cf 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)
2026-09-22 11:06:02 +08:00

145 lines
5.7 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.deps import get_entity_id
from app.models import NotificationChannel, NotificationLog
from app.auth_middleware import require_role, require_auth_401
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)
# 缺口:本 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,
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db)):
"""通知历史(**按会话账套收口**,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()
).offset((page - 1) * 20).limit(20).all()
return {
"total": total,
"entity_id": entity_id,
"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]
}