Files
AgentLab/backend/app/api/notify.py
T
ThearchyHelios 479cec0af7 通知事件一次登记(总方案 X6、5.1):六个新事件进 notify/service.EVENTS,推给哪几条路也登记在那一处
复核指出 X6 漏做:第 2、3 波的数据源变化、口径召回、评测、触达四块都要往 EVENTS、EVENT_LABEL 和前端设置页的事件列表里
加东西,正是 X6 要避免的同一处多方冲突;消息里能放什么也没有一处先定好。

做了什么:
- EVENTS 改成登记表:每个事件一行,叫法 + 推给哪几条路(scopes:global 全局渠道自己订阅、user 相关的人自己的个人渠道、
  app 应用版本 spec.notify 的订阅)。本轮新登记 source.changed、ledger.recall、eval.regressed、eval.failed、task.digest、
  key.expiring,scopes 都是空的——登记了、还没有推送。实现推送的那一块只改自己那一行的 scopes(各行之间隔着说明行,几块并行
  改也不会挨着冲突),前端叫法表不用再动。EVENT_LABEL 从登记表派生,老的 import 不变。
- 模块说明写清消息结构:标题、状态、条数、链接,加编号和搭建时起的名字;不放报告和答案里的数、表名、列名、取值、SQL、
  按运行数据渲染出来的句子。
- _channels 只走事件登记了的路;没登记的事件直接报错(推送之前先登记)。现有五个事件登记的路照现在的做法,行为不变。
- 接口:订阅按登记核对(subscribe_problem):没推送的「还没有推送,暂时不能订阅」,范围不对的「不推给范围是「应用」的
  渠道」。只核对这次新勾的:以前存下的照旧,开关、改名不会因此 422。GET /api/notify/events 多给 scopes(空的照实给)。
- 前端:terms.ts 的 eventLabel 和 EVENTS 一一对应、逐字一致,六个新事件先有叫法;设置页的渠道编辑框、应用的通知改按
  GET /api/notify/events 列(这种范围收得到的才列,已经勾着的照样列出来能取消;取不到时照实说)。

顺带修掉的(就在要改的那张列表上):设置页以前按前端叫法表的键列事件,里面有五个服务端从来不认的(待办升级、待办超时、
待办处理完、动作失败、动作已生效),一勾保存就 422;改派、升级、催办只在投递记录里出现,另起 deliveryEventLabel(加上测试
消息),记录里照实写是哪一种。服务端 EVENT_LABEL 的四句老叫法(有待办派给我、实例完成、实例失败、写入结果未知)改成和界面
同一句,界面上看到的字不变。

验证:新增 test_notify_events(登记表、前后端逐字对照、接口拒收、只走登记了的路),去掉 _channels 的按路过滤或「只核对新勾
的」回跑各有一项失败;check-capabilities 加一段「可订阅的事件照服务端登记列」,把列表改回按叫法表的键列,四项失败。
2026-10-16 12:12:14 +08:00

234 lines
12 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.
"""通知渠道与投递记录(BE-2)。设计文档 §2.4、§3.10。
渠道的地址和签名密钥加密落库,接口只回主机名、配没配密钥;改渠道时不传地址(或传空)表示保持原值。格式和地址的
校验同定时任务的 Webhook(app/notify/core.py)。个人渠道(scope=user)只有开了登录才有:它挂在用户 id 上,只有本人
能看能改;全局、应用的渠道关登录时谁都能改,开了登录要 builder 以上。定时任务的通知仍在定时任务上设,不迁移(Q7)。
个人渠道谁都能建(权限最低的使用者也能),地址另过一遍 SSRF 检查(tools/net.py,不开内网),保存和发送时各一次:
不然服务端替它往内网地址发 POST,测试接口交回的状态码和报错就成了探测内网的信号(A6.2:只有登记的端点能访问内网)。
全局、应用的渠道只有搭建者以上能建,和定时任务的 Webhook 同一个信任级别,地址照旧只查格式(内网的接收端很常见)。
"""
from __future__ import annotations
from typing import Any, Literal
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import or_, select
from app.api.deps import current_principal
from app.core.crypto import encrypt
from app.db.base import SessionLocal
from app.db.models import NotifyChannel, NotifyDelivery
from app.notify import core
from app.notify.service import EVENTS, channel_out, subscribe_problem
router = APIRouter(prefix="/api/notify", tags=["notify"])
class ChannelIn(BaseModel):
name: str = Field(min_length=1, max_length=100)
scope: Literal["global", "app", "user"] = "global"
owner_id: str | None = None
format: str = "generic"
#: 新建必填;修改时不传或传空表示保持原值
url: str | None = None
#: 签名密钥:不传保持原值,传空字符串表示去掉
secret: str | None = None
events: list[str] = Field(default_factory=list)
enabled: bool = True
def _can_edit(row_scope: str, owner_id: str | None) -> None:
principal = current_principal()
if principal.mode == "off":
if row_scope == "user":
raise HTTPException(422, "个人通知渠道需要开启登录后才能使用:关闭登录时没有用户,无法认出是谁的渠道")
return
if row_scope == "user":
if owner_id != principal.user_id:
raise HTTPException(403, "只能修改自己的通知渠道")
return
if not principal.at_least("builder"):
raise HTTPException(403, "只有搭建者和管理员可以修改全局或应用的通知渠道")
async def _safe_personal(scope: str, url: str) -> None:
"""个人渠道的地址过 SSRF 检查(见模块说明)。"""
from app.notify.service import personal_url_problem
if scope == "user" and (problem := await personal_url_problem(url)):
raise HTTPException(422, problem)
def _validated(payload: ChannelIn, *, url: str | None, saved: list[str] | None = None) -> None:
"""saved 是渠道已经订阅着的事件:只核对这次新勾的(notify/service.subscribe_problem)。以前存下的照旧——当时界面对
哪种渠道都列全部事件,现在才按事件推给哪几条路核对,不能让这些渠道连开关、改名都 422。"""
if payload.format not in core.FORMATS:
raise HTTPException(422, f"消息格式只能是{'、'.join(core.FORMAT_LABEL.values())}之一")
for event in dict.fromkeys(payload.events):
if event not in (saved or ()) and (problem := subscribe_problem(event, payload.scope)):
raise HTTPException(422, problem)
if url is not None and (problem := core.format_problem(payload.format, url)):
raise HTTPException(422, problem)
@router.get("/channels")
async def list_channels() -> dict[str, Any]:
"""{items}:形状同前端的接口约定(frontend/src/api/capabilities.ts)。"""
principal = current_principal()
stmt = select(NotifyChannel).order_by(NotifyChannel.created_at)
if principal.mode != "off":
# 个人渠道只给本人看
stmt = stmt.where(or_(NotifyChannel.scope != "user", NotifyChannel.owner_id == principal.user_id))
async with SessionLocal() as session:
return {"items": [channel_out(r) for r in (await session.execute(stmt)).scalars()]}
@router.get("/events")
async def list_events() -> list[dict[str, Any]]:
"""登记的通知事件和推给哪几条路(typesApp.ts 的 NotifyEventRow):设置页、应用的通知按 scopes 列可订阅的事件。
scopes 是空的,是登记了、还没有推送的,照实给出来,由界面不列。"""
return [{"event": name, "label": event.label, "scopes": list(event.scopes)} for name, event in EVENTS.items()]
@router.post("/channels", status_code=201)
async def create_channel(payload: ChannelIn) -> dict[str, Any]:
owner = current_principal().user_id if payload.scope == "user" else payload.owner_id
_can_edit(payload.scope, owner)
try:
url = core.check_url(payload.url)
except ValueError as e:
raise HTTPException(422, str(e)) from e
_validated(payload, url=url)
await _safe_personal(payload.scope, url)
row = NotifyChannel(name=payload.name.strip(), scope=payload.scope, owner_id=owner, format=payload.format,
url=encrypt(url), secret=encrypt(payload.secret) if payload.secret else None,
events=list(dict.fromkeys(payload.events)), enabled=payload.enabled)
async with SessionLocal() as session:
session.add(row)
await session.commit()
return channel_out(row)
class ChannelPatch(BaseModel):
"""改一部分:没给的项保持原值(界面上的启用开关只传 enabled)。"""
name: str | None = Field(default=None, min_length=1, max_length=100)
scope: Literal["global", "app", "user"] | None = None
owner_id: str | None = None
format: str | None = None
url: str | None = None
secret: str | None = None
events: list[str] | None = None
enabled: bool | None = None
@router.patch("/channels/{channel_id}")
async def patch_channel(channel_id: str, payload: ChannelPatch) -> dict[str, Any]:
async with SessionLocal() as session:
row = await session.get(NotifyChannel, channel_id)
if row is None:
raise HTTPException(404, "通知渠道不存在,可能已被删除")
current = {"name": row.name, "scope": row.scope, "owner_id": row.owner_id, "format": row.format,
"events": list(row.events or []), "enabled": row.enabled}
given = {k: v for k, v in payload.model_dump(exclude_unset=True).items() if v is not None}
return await update_channel(channel_id, ChannelIn(**{**current, **given}))
@router.put("/channels/{channel_id}")
async def update_channel(channel_id: str, payload: ChannelIn) -> dict[str, Any]:
async with SessionLocal() as session:
row = await session.get(NotifyChannel, channel_id)
if row is None:
raise HTTPException(404, "通知渠道不存在,可能已被删除")
_can_edit(row.scope, row.owner_id)
if payload.scope != row.scope:
raise HTTPException(422, "通知渠道的范围建好之后不能修改,请新建一个")
url = None
if payload.url:
try:
url = core.check_url(payload.url)
except ValueError as e:
raise HTTPException(422, str(e)) from e
if url is None:
from app.core.crypto import decrypt
current = decrypt(row.url) or ""
_validated(payload, url=current or None, saved=list(row.events or []))
else:
_validated(payload, url=url, saved=list(row.events or []))
await _safe_personal(row.scope, url)
row.url = encrypt(url)
row.name, row.format = payload.name.strip(), payload.format
row.events, row.enabled = list(dict.fromkeys(payload.events)), payload.enabled
if payload.secret is not None:
row.secret = encrypt(payload.secret) if payload.secret else None
if row.scope == "app":
row.owner_id = payload.owner_id
await session.commit()
return channel_out(row)
@router.delete("/channels/{channel_id}", status_code=204)
async def delete_channel(channel_id: str) -> None:
async with SessionLocal() as session:
row = await session.get(NotifyChannel, channel_id)
if row is None:
raise HTTPException(404, "通知渠道不存在,可能已被删除")
_can_edit(row.scope, row.owner_id)
await session.delete(row)
await session.commit()
@router.post("/channels/{channel_id}/test")
async def test_channel(channel_id: str) -> dict[str, Any]:
"""发一条测试消息(不重试),记一条 event=test 的投递记录。返回 {ok, http_status, message},同定时任务的测试。"""
from app.notify import send
async with SessionLocal() as session:
row = await session.get(NotifyChannel, channel_id)
if row is None:
raise HTTPException(404, "通知渠道不存在,可能已被删除")
_can_edit(row.scope, row.owner_id)
message = {"event": "test", "title": f"通知渠道「{row.name}」测试", "ok": True, "retry": False,
"lines": [("说明", "这是一条测试消息。收到即表示渠道地址配置正确"
+ (";请求头带有签名,接收端可据此核对签名密钥" if row.secret and row.format == "generic"
else ""))]}
outcome = await send(row, message)
async with SessionLocal() as session:
session.add(NotifyDelivery(channel_id=row.id, subject_kind="channel", subject_id=row.id, event="test",
status="ok" if outcome.ok else "failed", http_status=outcome.http_status,
attempts=outcome.attempts, error=outcome.error))
await session.commit()
if outcome.ok:
return {"ok": True, "http_status": outcome.http_status, "message": "请到接收端确认已收到"}
return {"ok": False, "http_status": outcome.http_status, "message": outcome.error or "发送失败"}
@router.get("/deliveries")
async def list_deliveries(
channel_id: str | None = None,
run_id: str | None = None,
task_id: str | None = None,
limit: int = Query(default=100, ge=1, le=500),
) -> dict[str, Any]:
"""投递记录。开登录后 user 角色只看发往自己的个人渠道的:别的渠道的投递记录里有所有应用、所有运行的待办和动作。"""
stmt = select(NotifyDelivery).order_by(NotifyDelivery.created_at.desc()).limit(limit)
principal = current_principal()
if principal.mode != "off" and not principal.at_least("builder"):
stmt = stmt.join(NotifyChannel, NotifyChannel.id == NotifyDelivery.channel_id).where(
NotifyChannel.scope == "user", NotifyChannel.owner_id == principal.user_id)
if channel_id:
stmt = stmt.where(NotifyDelivery.channel_id == channel_id)
if run_id:
stmt = stmt.where(NotifyDelivery.run_id == run_id)
if task_id:
stmt = stmt.where(NotifyDelivery.task_id == task_id)
async with SessionLocal() as session:
rows = (await session.execute(stmt)).scalars()
return {"items": [{
"id": r.id, "channel_id": r.channel_id, "subject_kind": r.subject_kind, "subject_id": r.subject_id,
"event": r.event, "run_id": r.run_id, "task_id": r.task_id, "status": r.status,
"http_status": r.http_status, "attempts": r.attempts, "error": r.error,
"created_at": r.created_at.isoformat() if r.created_at else None} for r in rows]}