## 用户报的第二件事:格式改了,提示没跟着改
弹窗里的 URL 示例/说明、密钥标签、限流说明都写死成企业微信了——选「通用 JSON」时
占位还是 `qyapi.weixin.qq.com`,限流还写着「企微硬限 20」。现在这些文案挂在**适配器**上
(`url_hint/url_help/secret_label/secret_help/limit_help`),由接口随格式下发,切格式即时更新;
限流值在用户没手动改过时也跟着格式的推荐值走(企微 20 / 通用 JSON 60 / Bark 60)。
## 新格式:Bark(iOS 推送)
- `POST {url}`,body `{title, body, markdown, group, level[, device_key]}`
—— `markdown` 传富文本、`body` 传纯文本兜底(老版本 App 不认 markdown 字段时也能看清)
- URL 两种填法都支持:直接粘 Bark 复制的那串(`https://api.day.app/<key>`,key 在路径里),
或填 `https://api.day.app/push` + 把 key 填到「设备 Key」(作为 device_key 发送)
- **成功判定按格式**:Bark 是 `code==200`(企业微信是 `errcode==0`)→ 新增
`BaseAdapter.ok_codes`,`_post_once` 用它判定。少了这一步 Bark 的每次成功都会被误判成失败
- 正文按 2048 字节截断(走 APNs,体量有限)
## 顺带
- 通用 JSON 的 `secret` 现在会作为 `X-Webhook-Secret` 请求头发出(原先填了没用)
- `_formats()` 改为序列化适配器元信息,新增格式只改一处
## 验证
- 格式切换 14 项断言全绿:三种格式的 URL 示例/密钥标签/密钥说明/限流说明/模板区/限流默认值
全部跟着切换;下拉里 Bark 出现在已实现区
- Bark 端到端 7 项:`code=200` 判成功、`code=400` 判失败(含重试)、请求体带
device_key/title/body/markdown/group、URL 里的 key 在接口回显里被打码
- 文档:NOTIFY.md §5 的格式表补 Bark 列(URL 怎么填 / 成功码 / 约束),并说明
"提示文案挂在适配器上,别写死在页面里"
191 lines
7.7 KiB
Python
191 lines
7.7 KiB
Python
"""通知 / Webhook 管理接口(系统级设置,全部 admin_required)。
|
||
|
||
配置读写统一走 `core/notifier`(它落 `app_meta.notify_webhooks` 一个键)——本模块
|
||
只做"取配置 → 改一处 → 存回去"的编排,**校验与持久化都在 notifier 里**,不在 web 层
|
||
重复实现(避免两份规则漂移)。
|
||
|
||
安全口径:
|
||
- URL 里有凭据(企微 `?key=`)→ 回显一律 `notifier.mask_url()`;
|
||
- 前端"留空不修改":提交上来的 url 若是打码值或空,`save_config` 会保留原值;
|
||
- `secret` 永不回显,只回 `secret_set`。
|
||
"""
|
||
import uuid
|
||
|
||
from flask import Blueprint, jsonify, request
|
||
from flask_login import current_user
|
||
|
||
from core import notify_events, notifier
|
||
from core.logger import get_logger
|
||
from web.auth import admin_required
|
||
|
||
_log = get_logger("web.notify")
|
||
bp = Blueprint("notify", __name__)
|
||
|
||
|
||
def _operator():
|
||
try:
|
||
return current_user.username or "admin"
|
||
except Exception:
|
||
return "admin"
|
||
|
||
|
||
def _formats():
|
||
"""可用格式(含未实现的,前端据此置灰)。
|
||
|
||
每项带**该格式自己的提示文案**(URL 占位/说明、密钥标签、限流说明)——
|
||
这些必须跟着格式走,不能把企业微信的说明写死在页面上。
|
||
"""
|
||
out = [cls.meta() for _, cls in notifier.ADAPTERS.items()]
|
||
for name, label in notifier.PLANNED_FORMATS.items():
|
||
out.append({"name": name, "label": label + "(未实现)", "implemented": False,
|
||
"byte_limit": 0, "limit_default": 20, "needs_template": False,
|
||
"secret_label": "签名密钥(可选)", "secret_help": "",
|
||
"url_hint": "", "url_help": "该格式尚未实现,可先用「通用 JSON」手搓",
|
||
"limit_help": ""})
|
||
return out
|
||
|
||
|
||
@bp.route("/api/notify/webhooks")
|
||
@admin_required
|
||
def api_notify_webhooks():
|
||
"""全部 webhook 配置(url 打码、secret 只回是否配置过)+ 格式清单 + 事件目录。"""
|
||
cfg = notifier.get_public_config()
|
||
return jsonify({"ok": True, "webhooks": cfg["webhooks"], "settings": cfg["settings"],
|
||
"formats": _formats(), "meta_key": notifier.META_KEY,
|
||
"max_hooks": notifier.MAX_HOOKS,
|
||
"planned": list(notifier.PLANNED_FORMATS)})
|
||
|
||
|
||
@bp.route("/api/notify/webhooks", methods=["POST"])
|
||
@admin_required
|
||
def api_notify_webhook_create():
|
||
"""新建一条 webhook。body 即该条配置(见 doc/NOTIFY.md 的字段表)。"""
|
||
data = request.json or {}
|
||
cfg = notifier.get_config()
|
||
if len(cfg["webhooks"]) >= notifier.MAX_HOOKS:
|
||
return jsonify({"ok": False, "error": f"最多 {notifier.MAX_HOOKS} 条"}), 400
|
||
new_id = "wh_" + uuid.uuid4().hex[:8]
|
||
hook = dict(data)
|
||
hook["id"] = new_id
|
||
cfg["webhooks"].append(hook)
|
||
ok, msg = notifier.save_config(cfg)
|
||
if not ok:
|
||
return jsonify({"ok": False, "error": msg, "errors": msg
|
||
if isinstance(msg, list) else None}), 400
|
||
_log.info("新增通知 webhook: %s(%s)by %s", hook.get("name"), new_id, _operator())
|
||
return jsonify({"ok": True, "msg": "已创建", "id": new_id})
|
||
|
||
|
||
@bp.route("/api/notify/webhooks/<hook_id>", methods=["PUT"])
|
||
@admin_required
|
||
def api_notify_webhook_update(hook_id):
|
||
"""更新一条(部分字段;url/secret 省略或为打码值时保持原值)。"""
|
||
data = request.json or {}
|
||
cfg = notifier.get_config()
|
||
target = next((h for h in cfg["webhooks"] if h["id"] == hook_id), None)
|
||
if not target:
|
||
return jsonify({"ok": False, "error": "webhook 不存在"}), 404
|
||
for k, v in data.items():
|
||
if k in ("id", "created_at"):
|
||
continue
|
||
target[k] = v
|
||
ok, msg = notifier.save_config(cfg)
|
||
if not ok:
|
||
return jsonify({"ok": False, "error": msg, "errors": msg
|
||
if isinstance(msg, list) else None}), 400
|
||
_log.info("更新通知 webhook: %s by %s", hook_id, _operator())
|
||
return jsonify({"ok": True, "msg": "已保存"})
|
||
|
||
|
||
@bp.route("/api/notify/webhooks/<hook_id>", methods=["DELETE"])
|
||
@admin_required
|
||
def api_notify_webhook_delete(hook_id):
|
||
cfg = notifier.get_config()
|
||
before = len(cfg["webhooks"])
|
||
cfg["webhooks"] = [h for h in cfg["webhooks"] if h["id"] != hook_id]
|
||
if len(cfg["webhooks"]) == before:
|
||
return jsonify({"ok": False, "error": "webhook 不存在"}), 404
|
||
ok, msg = notifier.save_config(cfg)
|
||
if not ok:
|
||
return jsonify({"ok": False, "error": msg}), 400
|
||
_log.info("删除通知 webhook: %s by %s", hook_id, _operator())
|
||
return jsonify({"ok": True, "msg": "已删除"})
|
||
|
||
|
||
@bp.route("/api/notify/webhooks/<hook_id>/test", methods=["POST"])
|
||
@admin_required
|
||
def api_notify_webhook_test(hook_id):
|
||
"""同步发一条测试消息(用户等结果),返回真实 HTTP 状态与平台错误码。
|
||
|
||
⚠ 不占业务令牌桶:否则管理员点两下测试就把业务通知的配额吃掉了。
|
||
"""
|
||
cfg = notifier.get_config()
|
||
hook = next((h for h in cfg["webhooks"] if h["id"] == hook_id), None)
|
||
if not hook:
|
||
return jsonify({"ok": False, "error": "webhook 不存在"}), 404
|
||
event = ((request.json or {}).get("event") or "notify.test").strip()
|
||
rec = notifier.send_test(hook, event=event, operator=_operator())
|
||
return jsonify({"ok": bool(rec.get("ok")),
|
||
"msg": "测试消息已发出" if rec.get("ok") else "发送失败",
|
||
"http_status": rec.get("http_status"),
|
||
"errcode": rec.get("errcode"),
|
||
"elapsed_ms": rec.get("elapsed_ms"),
|
||
"attempts": rec.get("attempts"),
|
||
"error": rec.get("error") or "",
|
||
"url": notifier.mask_url(hook.get("url", ""))})
|
||
|
||
|
||
@bp.route("/api/notify/preview", methods=["POST"])
|
||
@admin_required
|
||
def api_notify_preview():
|
||
"""保存前预览:真实请求体 + UTF-8 字节数 + 是否会被截断。"""
|
||
data = request.json or {}
|
||
hook = data.get("hook") or {}
|
||
# 预览时 URL 可能还没填:给个占位,只关心渲染结果
|
||
hook = dict(hook)
|
||
hook.setdefault("url", "https://example.invalid/hook")
|
||
out = notifier.preview(hook, event=(data.get("event") or "notify.test"))
|
||
return jsonify({"ok": not out.get("error"), **out})
|
||
|
||
|
||
@bp.route("/api/notify/events")
|
||
@admin_required
|
||
def api_notify_events():
|
||
"""事件目录(前端画勾选树 / 模板占位符速查)。"""
|
||
rows = notify_events.list_events()
|
||
cats = []
|
||
for e in rows:
|
||
if e["category"] not in cats:
|
||
cats.append(e["category"])
|
||
return jsonify({"ok": True, "events": rows, "categories": cats})
|
||
|
||
|
||
@bp.route("/api/notify/logs")
|
||
@admin_required
|
||
def api_notify_logs():
|
||
"""最近发送记录(内存环形缓冲,重启清空;历史见 logs/notify.log)。"""
|
||
limit = request.args.get("limit", 50)
|
||
try:
|
||
limit = max(1, min(int(limit), 200))
|
||
except (TypeError, ValueError):
|
||
limit = 50
|
||
return jsonify({"ok": True, "logs": notifier.get_logs(limit),
|
||
"note": "仅显示本次运行期间记录;历史见 logs/notify.log"})
|
||
|
||
|
||
@bp.route("/api/notify/settings", methods=["POST"])
|
||
@admin_required
|
||
def api_notify_settings():
|
||
"""全局设置:global_enabled / default_agg_window / default_rate_limit / log_keep。"""
|
||
data = request.json or {}
|
||
cfg = notifier.get_config()
|
||
for k in ("global_enabled", "default_agg_window", "default_rate_limit",
|
||
"log_keep", "http_timeout"):
|
||
if k in data and data[k] is not None:
|
||
cfg["settings"][k] = data[k]
|
||
ok, msg = notifier.save_config(cfg)
|
||
if not ok:
|
||
return jsonify({"ok": False, "error": msg}), 400
|
||
_log.info("更新通知全局设置 by %s: %s", _operator(), data)
|
||
return jsonify({"ok": True, "msg": "已保存"})
|