"""通知 / 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(): """可用格式(含未实现的,前端据此置灰)。""" out = [] for name, cls in notifier.ADAPTERS.items(): out.append({"name": name, "label": cls.label, "implemented": True, "byte_limit": cls.byte_limit, "limit_default": cls.limit_default}) for name, label in notifier.PLANNED_FORMATS.items(): out.append({"name": name, "label": label + "(未实现)", "implemented": False, "byte_limit": 0, "limit_default": 20}) 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/", 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/", 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//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": "已保存"})