DAMA 独立回查 dama-recheck-workbench-entity-isolation-20260922 的 §4b/§5 两处待办:
1. POST /api/cma/tenant/switch 审计行原来不可归属:
- user_id=None → 改为注入会话用户(user=Depends(require_auth_401))
- target_type/target_id 从 system_config/None 对齐为 entity/<entity_id>,
与 /auth/switch-entity 的 switch_entity 共用目标维度 ⇒ 按账套可过滤,零 DDL
- action 字面 switch_tenant_state 保留不合并(与会话级 switch_entity 语义不同),
口径登记 docs/audit-and-notification-caliber-20260922.md
2. /api/cma/notifications/logs 增加 policy/note 口径字段,前端发送记录表
empty-text 提示同一口径 —— 避免「B2 上线后 e1/e2 均 0 条」被当成页面故障
(现库 8 行历史记录全为 alert_id IS NULL 的 OKR 复盘提醒,无账套归属)
回归:tests/test_workbench_entity_isolation_20260922.py 22/22;全量 1370 passed;前端 vite build 通过
152 lines
6.5 KiB
Python
152 lines
6.5 KiB
Python
"""通知渠道配置 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]
|
||
}
|