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,键值用双引号(单引号会自动兼容)。