"""通知渠道配置 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, # 口径字段(2026-09-22 DAMA 回查 §5 收口):上线后本页在无预警推送的账套会显示 0 条, # 属口径而非故障——现有 8 行历史记录全是 `alert_id IS NULL` 的 OKR 复盘提醒(无账套归属), # 按「宁少勿错」不返回。前端据此文案提示,避免被当成页面坏了。 "policy": "alert-linked-only", "note": ("仅显示与预警推送关联的记录(按父键 kpi_alerts 归属会话账套)。" "无父键的历史通知(如 OKR 复盘提醒,写入方 backend/scripts/okr_review_reminder.py)" "不属于任何账套,不在本列表返回。口径登记:docs/audit-and-notification-caliber-20260922.md"), "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] }