Files
cma-management/backend/app/api/notifications.py
T
Hermes CI Fix 38787a006f fix(notifications): deterministic tiebreaker for paginated notification log list
Same-class gap as e369508 (alerts list): single-key ORDER BY created_at DESC plus offset/limit lets MySQL filesort return an unstable order inside same-second tie groups, so offset pagination duplicates and skips rows. Append secondary key id DESC, aligning with app/api/alerts.py, app/api/cash.py and app/api/data_quality.py.

Entity-scope caliber untouched: parent-key join attribution, alert_id IS NULL rows still excluded, policy/note wording unchanged. Local commit only - no push (release gated by ASK-20260925-04).
2026-09-25 16:34:52 +08:00

157 lines
7.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.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()
# 排序补**确定性次键** `id DESC`(任务 0-P3-project-20260925-cma-same-class-pagination-determinism-fullstack):
# 通知日志由预警推送批量写入,同一秒多行时 MySQL filesort 在并列组内顺序不受控 ⇒ 单键
# `created_at DESC` + offset/limit 会让偏移分页重复并漏行。两键后偏移分页成为全序分页。
# 同族先例:app/api/alerts.py(e369508)、app/api/cash.py:165、app/api/data_quality.py:230。
# 本行**不改**任何账套收口口径(join 归属 / `alert_id IS NULL` 不返回 / policy+note 文案)。
logs = scoped.order_by(
NotificationLog.created_at.desc(), NotificationLog.id.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]
}