mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 22:31:59 +00:00
479cec0af7
复核指出 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 加一段「可订阅的事件照服务端登记列」,把列表改回按叫法表的键列,四项失败。
234 lines
12 KiB
Python
234 lines
12 KiB
Python
"""通知渠道与投递记录(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]}
|