From edf19e121b5d91b00e5ac8d1833640b2eb66e7c6 Mon Sep 17 00:00:00 2001 From: Zichao Lin Date: Wed, 5 Aug 2026 00:21:31 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=9B=91=E6=8E=A7?= =?UTF-8?q?=E9=93=BE=E8=B7=AF=20api=5Fkey=20=E6=9C=AA=E8=A7=A3=E7=A0=81?= =?UTF-8?q?=E8=87=B4=20401=E3=80=81=E7=9E=AC=E6=97=B6=20401=20=E8=AF=AF?= =?UTF-8?q?=E7=A6=81=E3=80=81=E5=89=8D=E7=AB=AF=E8=A1=A8=E5=8D=95=E5=A4=9A?= =?UTF-8?q?=E5=A4=84=E9=97=AE=E9=A2=98=20=20-=20monitor:=20=E8=B0=83?= =?UTF-8?q?=E5=BA=A6=E9=93=BE=E8=B7=AF=E5=AF=B9=20base64=20=E5=AD=98?= =?UTF-8?q?=E5=82=A8=E7=9A=84=20api=5Fkey=20=E8=A7=A3=E7=A0=81=EF=BC=88?= =?UTF-8?q?=E6=AD=A4=E5=89=8D=E7=9B=B4=E6=8E=A5=E5=8F=91=E9=80=81=E7=BC=96?= =?UTF-8?q?=E7=A0=81=E4=B8=B2=E5=AF=BC=E8=87=B4=E6=89=80=E6=9C=89=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=20401=EF=BC=89=20-=20fetcher:=20401/403=20=E5=85=88?= =?UTF-8?q?=E6=8C=89=E9=87=8D=E8=AF=95=E6=AC=A1=E6=95=B0=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E5=86=8D=E5=88=A4=E5=AE=9A=E7=A6=81=E7=94=A8=EF=BC=8C=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E7=9E=AC=E6=97=B6=20401=20=E8=AF=AF=E6=9D=80=EF=BC=9B?= =?UTF-8?q?POST=20=E8=87=AA=E5=8A=A8=E8=A1=A5=20Content-Type:=20applicatio?= =?UTF-8?q?n/json=20-=20ui:=20val/err=20=E5=85=BC=E5=AE=B9=20#=20=E5=89=8D?= =?UTF-8?q?=E7=BC=80=EF=BC=88=E4=BF=9D=E5=AD=98=E6=97=A0=E5=8F=8D=E5=BA=94?= =?UTF-8?q?=E7=9A=84=E6=A0=B9=E5=9B=A0=EF=BC=89=EF=BC=9B=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=9B=9E=E5=A1=AB=20headers=20=E5=8F=8D?= =?UTF-8?q?=E8=BD=AC=E4=B9=89=EF=BC=9B=E7=9B=91=E6=8E=A7=E9=A1=B5=E8=A1=A5?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E8=B4=A6=E5=8F=B7=E5=85=A5=E5=8F=A3=20-=20te?= =?UTF-8?q?st:=20=E6=96=B0=E5=A2=9E=20monitor=20=E8=A7=A3=E7=A0=81?= =?UTF-8?q?=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95=EF=BC=8C=E5=85=B1=2051=20?= =?UTF-8?q?=E4=B8=AA=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api.py | 11 ++++++++- app/fetcher.py | 7 ++++++ app/monitor.py | 10 ++++++++ app/static/app.js | 54 +++++++++++++++++++++++++++++++++++-------- app/static/index.html | 4 ++++ tests/test_fetcher.py | 18 ++++++++++++--- tests/test_monitor.py | 41 ++++++++++++++++++++++++++++++++ 7 files changed, 132 insertions(+), 13 deletions(-) create mode 100644 tests/test_monitor.py diff --git a/app/api.py b/app/api.py index d51c73e..b654f3e 100644 --- a/app/api.py +++ b/app/api.py @@ -3,6 +3,7 @@ from __future__ import annotations import base64 +import json import logging import secrets from contextlib import asynccontextmanager @@ -103,7 +104,15 @@ def create_app(cfg: Config | None = None) -> FastAPI: """SELECT p.*, (SELECT COUNT(*) FROM accounts a WHERE a.platform_id = p.id) AS account_count FROM platforms p ORDER BY p.id""" ).fetchall() - return [dict(r) for r in rows] + result = [] + for r in rows: + d = dict(r) + try: + d["headers"] = json.loads(d["headers"]) if d["headers"] else {} + except (ValueError, TypeError): + d["headers"] = {} + result.append(d) + return result @app.post("/api/platforms", dependencies=[Depends(require_auth)]) def create_platform(body: PlatformCreate): diff --git a/app/fetcher.py b/app/fetcher.py index fdfd8d9..6030640 100644 --- a/app/fetcher.py +++ b/app/fetcher.py @@ -68,6 +68,8 @@ def _build_request(platform: dict, api_key: str) -> tuple[str, dict, str | None] body = None if platform["method"] == "POST" and platform.get("body"): body = render_template(platform["body"], api_key) + if not any(k.lower() == "content-type" for k in headers): + headers["Content-Type"] = "application/json" return url, headers, body @@ -95,6 +97,11 @@ def fetch_balance(platform: dict, api_key: str, retry_count: int, timeout: int) time.sleep(2) continue if resp.status_code in (401, 403): + # 认证失败可能是瞬时的(key 刚生效/风控),先按重试次数再确认,仍失败才判定 + if attempt < attempts - 1: + logger.warning("认证失败(%s/%s) %s status=%s,重试确认", attempt + 1, attempts, url, resp.status_code) + time.sleep(1) + continue return FetchResult(ok=False, auth_error=True, status_code=resp.status_code, error=f"HTTP {resp.status_code}(认证失败)") if resp.status_code >= 500: diff --git a/app/monitor.py b/app/monitor.py index 5fefb3f..6e013d8 100644 --- a/app/monitor.py +++ b/app/monitor.py @@ -8,6 +8,7 @@ from __future__ import annotations +import base64 import logging import threading import time @@ -42,9 +43,18 @@ _ACCOUNT_SQL = """ """ +def _unb64(v: str) -> str: + """api_key 以 base64 存储,内部使用时必须解码回明文。""" + try: + return base64.b64decode(v.encode("ascii")).decode("utf-8") + except Exception: + return v + + def _split(row: dict) -> tuple[dict, dict]: """把 JOIN 行拆成 (account, platform) 两个 dict。""" account = {k: row[k] for k in _ACCOUNT_FIELDS if k in row} + account["api_key"] = _unb64(account["api_key"]) platform = {k: row[k] for k in _PLATFORM_FIELDS if k in row} platform["name"] = platform.pop("platform_name") platform["id"] = platform.pop("platform_id") diff --git a/app/static/app.js b/app/static/app.js index de5daf4..a372c3f 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -54,6 +54,22 @@ return s; } + /* 容错 JSON 解析:允许用户写 Python 风格的单引号;失败返回 null */ + function parseJsonInput(text, label, errorId) { + let s = (text || "").trim(); + if (!s) return {}; + try { + return JSON.parse(s); + } catch (_) { + try { + return JSON.parse(s.replace(/'/g, '"')); + } catch (_2) { + err(errorId, `${label} 不是合法 JSON:键和值必须用双引号,如 {"key": "value"}`); + return null; + } + } + } + /* ---------- 品牌图标映射(@lobehub/icons 键 → 品牌色) ---------- */ const BRAND_COLORS = { @@ -193,7 +209,15 @@ document.getElementById("stat-pending").textContent = stats.pending; grid.innerHTML = accounts.map(accountCard).join(""); - document.getElementById("empty-hint").classList.toggle("hidden", accounts.length > 0); + const hint = document.getElementById("empty-hint"); + if (accounts.length > 0) { + hint.classList.add("hidden"); + } else { + hint.classList.remove("hidden"); + hint.innerHTML = platforms.length === 0 + ? "

还没有平台。先到「平台」页添加一个平台,再回来添加账号。

" + : "

还没有账号。点击右上角「+ 添加账号」开始监控。

"; + } } /* ---------- 渲染:平台 ---------- */ @@ -266,7 +290,11 @@ function platformForm(p) { const isEdit = !!p; const v = p || {}; - const headersStr = Object.keys(v.headers || {}).length ? JSON.stringify(v.headers, null, 2) : '{\n "Authorization": "Bearer {{apiKey}}"\n}'; + let savedHeaders = v.headers || {}; + if (typeof savedHeaders === "string") { + try { savedHeaders = JSON.parse(savedHeaders); } catch (_) { savedHeaders = {}; } + } + const headersStr = Object.keys(savedHeaders).length ? JSON.stringify(savedHeaders, null, 2) : '{\n "Authorization": "Bearer {{apiKey}}"\n}'; openModal(` -

提示:apikey 在 URL / Header / Body 中统一用 {{apiKey}} 占位,添加账号时自动替换。

+

提示:apikey 在 URL / Header / Body 中统一用 {{apiKey}} 占位,添加账号时自动替换。Headers/Body 需为 JSON,键值用双引号(单引号会自动兼容)。