fix: 修复监控链路 api_key 未解码致 401、瞬时 401 误禁、前端表单多处问题 - monitor: 调度链路对 base64 存储的 api_key 解码(此前直接发送编码串导致所有账号 401) - fetcher: 401/403 先按重试次数确认再判定禁用,避免瞬时 401 误杀;POST 自动补 Content-Type: application/json - ui: val/err 兼容 # 前缀(保存无反应的根因);平台编辑回填 headers 反转义;监控页补添加账号入口 - test: 新增 monitor 解码回归测试,共 51 个用例
This commit is contained in:
+10
-1
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
from contextlib import asynccontextmanager
|
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
|
"""SELECT p.*, (SELECT COUNT(*) FROM accounts a WHERE a.platform_id = p.id) AS account_count
|
||||||
FROM platforms p ORDER BY p.id"""
|
FROM platforms p ORDER BY p.id"""
|
||||||
).fetchall()
|
).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)])
|
@app.post("/api/platforms", dependencies=[Depends(require_auth)])
|
||||||
def create_platform(body: PlatformCreate):
|
def create_platform(body: PlatformCreate):
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ def _build_request(platform: dict, api_key: str) -> tuple[str, dict, str | None]
|
|||||||
body = None
|
body = None
|
||||||
if platform["method"] == "POST" and platform.get("body"):
|
if platform["method"] == "POST" and platform.get("body"):
|
||||||
body = render_template(platform["body"], api_key)
|
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
|
return url, headers, body
|
||||||
|
|
||||||
|
|
||||||
@@ -95,6 +97,11 @@ def fetch_balance(platform: dict, api_key: str, retry_count: int, timeout: int)
|
|||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
continue
|
continue
|
||||||
if resp.status_code in (401, 403):
|
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,
|
return FetchResult(ok=False, auth_error=True, status_code=resp.status_code,
|
||||||
error=f"HTTP {resp.status_code}(认证失败)")
|
error=f"HTTP {resp.status_code}(认证失败)")
|
||||||
if resp.status_code >= 500:
|
if resp.status_code >= 500:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import time
|
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]:
|
def _split(row: dict) -> tuple[dict, dict]:
|
||||||
"""把 JOIN 行拆成 (account, platform) 两个 dict。"""
|
"""把 JOIN 行拆成 (account, platform) 两个 dict。"""
|
||||||
account = {k: row[k] for k in _ACCOUNT_FIELDS if k in row}
|
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 = {k: row[k] for k in _PLATFORM_FIELDS if k in row}
|
||||||
platform["name"] = platform.pop("platform_name")
|
platform["name"] = platform.pop("platform_name")
|
||||||
platform["id"] = platform.pop("platform_id")
|
platform["id"] = platform.pop("platform_id")
|
||||||
|
|||||||
+45
-9
@@ -54,6 +54,22 @@
|
|||||||
return s;
|
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 键 → 品牌色) ---------- */
|
/* ---------- 品牌图标映射(@lobehub/icons 键 → 品牌色) ---------- */
|
||||||
|
|
||||||
const BRAND_COLORS = {
|
const BRAND_COLORS = {
|
||||||
@@ -193,7 +209,15 @@
|
|||||||
document.getElementById("stat-pending").textContent = stats.pending;
|
document.getElementById("stat-pending").textContent = stats.pending;
|
||||||
|
|
||||||
grid.innerHTML = accounts.map(accountCard).join("");
|
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
|
||||||
|
? "<p>还没有平台。先到「平台」页添加一个平台,再回来添加账号。</p>"
|
||||||
|
: "<p>还没有账号。点击右上角「+ 添加账号」开始监控。</p>";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 渲染:平台 ---------- */
|
/* ---------- 渲染:平台 ---------- */
|
||||||
@@ -266,7 +290,11 @@
|
|||||||
function platformForm(p) {
|
function platformForm(p) {
|
||||||
const isEdit = !!p;
|
const isEdit = !!p;
|
||||||
const v = 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(`
|
openModal(`
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<h3>${isEdit ? "编辑平台" : "添加平台"}</h3>
|
<h3>${isEdit ? "编辑平台" : "添加平台"}</h3>
|
||||||
@@ -290,7 +318,7 @@
|
|||||||
<div class="form-row"><label>超时秒数(留空用全局 ${settings ? settings.timeout_seconds : 10})</label><input id="pf-timeout" type="number" min="1" max="120" value="${v.timeout_seconds ?? ""}"></div>
|
<div class="form-row"><label>超时秒数(留空用全局 ${settings ? settings.timeout_seconds : 10})</label><input id="pf-timeout" type="number" min="1" max="120" value="${v.timeout_seconds ?? ""}"></div>
|
||||||
<div class="form-row full"><label>备注</label><input id="pf-note" value="${escapeHtml(v.note || "")}"></div>
|
<div class="form-row full"><label>备注</label><input id="pf-note" value="${escapeHtml(v.note || "")}"></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="form-hint">提示:apikey 在 URL / Header / Body 中统一用 {{apiKey}} 占位,添加账号时自动替换。</p>
|
<p class="form-hint">提示:apikey 在 URL / Header / Body 中统一用 {{apiKey}} 占位,添加账号时自动替换。Headers/Body 需为 JSON,键值用双引号(单引号会自动兼容)。</p>
|
||||||
<p class="modal-error" id="pf-error"></p>
|
<p class="modal-error" id="pf-error"></p>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn" data-cancel>取消</button>
|
<button class="btn" data-cancel>取消</button>
|
||||||
@@ -306,13 +334,13 @@
|
|||||||
interval_seconds: numOrNull("#pf-interval"), retry_count: numOrNull("#pf-retry"),
|
interval_seconds: numOrNull("#pf-interval"), retry_count: numOrNull("#pf-retry"),
|
||||||
timeout_seconds: numOrNull("#pf-timeout"), enabled: p ? p.enabled : true,
|
timeout_seconds: numOrNull("#pf-timeout"), enabled: p ? p.enabled : true,
|
||||||
};
|
};
|
||||||
let headers = {};
|
let headers = parseJsonInput(val("#pf-headers"), "Headers", "#pf-error");
|
||||||
try { headers = JSON.parse(val("#pf-headers") || "{}"); }
|
if (headers === null) return;
|
||||||
catch (e) { err("#pf-error", "Headers 不是合法 JSON"); return; }
|
|
||||||
payload.headers = headers;
|
payload.headers = headers;
|
||||||
if (!payload.name || !payload.url || !payload.balance_path) { err("#pf-error", "名称 / URL / 提取路径必填"); return; }
|
if (!payload.name || !payload.url || !payload.balance_path) { err("#pf-error", "名称 / URL / 提取路径必填"); return; }
|
||||||
if (payload.method === "POST") {
|
if (payload.method === "POST") {
|
||||||
try { JSON.parse(val("#pf-body") || "{}"); } catch (e) { err("#pf-error", "Body 不是合法 JSON"); return; }
|
const bodyObj = parseJsonInput(val("#pf-body"), "Body", "#pf-error");
|
||||||
|
if (bodyObj === null) return;
|
||||||
}
|
}
|
||||||
payload.body = val("#pf-body");
|
payload.body = val("#pf-body");
|
||||||
try {
|
try {
|
||||||
@@ -420,9 +448,9 @@
|
|||||||
|
|
||||||
/* ---------- 事件 ---------- */
|
/* ---------- 事件 ---------- */
|
||||||
|
|
||||||
function val(id) { return document.getElementById(id).value.trim(); }
|
function val(id) { return document.getElementById(String(id).replace(/^#/, "")).value.trim(); }
|
||||||
function numOrNull(id) { const s = val(id); return s === "" ? null : Number(s); }
|
function numOrNull(id) { const s = val(id); return s === "" ? null : Number(s); }
|
||||||
function err(id, msg) { document.getElementById(id).textContent = msg; }
|
function err(id, msg) { document.getElementById(String(id).replace(/^#/, "")).textContent = msg; }
|
||||||
|
|
||||||
function bindEvents() {
|
function bindEvents() {
|
||||||
document.getElementById("login-btn").onclick = doLogin;
|
document.getElementById("login-btn").onclick = doLogin;
|
||||||
@@ -430,6 +458,14 @@
|
|||||||
document.getElementById("logout-btn").onclick = logout;
|
document.getElementById("logout-btn").onclick = logout;
|
||||||
document.querySelectorAll(".tab").forEach((t) => t.addEventListener("click", () => switchView(t.dataset.view)));
|
document.querySelectorAll(".tab").forEach((t) => t.addEventListener("click", () => switchView(t.dataset.view)));
|
||||||
document.getElementById("add-platform-btn").onclick = () => platformForm(null);
|
document.getElementById("add-platform-btn").onclick = () => platformForm(null);
|
||||||
|
document.getElementById("add-account-btn").onclick = () => {
|
||||||
|
if (platforms.length === 0) {
|
||||||
|
toast("请先在「平台」页添加平台", "err");
|
||||||
|
switchView("platforms");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
accountForm(null);
|
||||||
|
};
|
||||||
document.getElementById("save-settings-btn").onclick = saveSettings;
|
document.getElementById("save-settings-btn").onclick = saveSettings;
|
||||||
document.getElementById("change-pwd-btn").onclick = changePassword;
|
document.getElementById("change-pwd-btn").onclick = changePassword;
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,10 @@
|
|||||||
|
|
||||||
<!-- 监控视图 -->
|
<!-- 监控视图 -->
|
||||||
<section id="view-monitor" class="view">
|
<section id="view-monitor" class="view">
|
||||||
|
<div class="view-head">
|
||||||
|
<h2>账号监控</h2>
|
||||||
|
<button id="add-account-btn" class="btn primary">+ 添加账号</button>
|
||||||
|
</div>
|
||||||
<div class="stats-bar">
|
<div class="stats-bar">
|
||||||
<div class="stat"><span id="stat-total">0</span><label>账号</label></div>
|
<div class="stat"><span id="stat-total">0</span><label>账号</label></div>
|
||||||
<div class="stat ok"><span id="stat-ok">0</span><label>正常</label></div>
|
<div class="stat ok"><span id="stat-ok">0</span><label>正常</label></div>
|
||||||
|
|||||||
+15
-3
@@ -105,7 +105,7 @@ class TestFetchBalance:
|
|||||||
assert result.ok
|
assert result.ok
|
||||||
assert captured["data"] == '{"api_key": "sk-2"}'
|
assert captured["data"] == '{"api_key": "sk-2"}'
|
||||||
|
|
||||||
def test_401_is_auth_error_no_retry(self, monkeypatch):
|
def test_401_retries_then_auth_error(self, monkeypatch):
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
def fake_get(url, headers=None, timeout=10):
|
def fake_get(url, headers=None, timeout=10):
|
||||||
@@ -113,9 +113,21 @@ class TestFetchBalance:
|
|||||||
return FakeResponse(401, text="unauthorized")
|
return FakeResponse(401, text="unauthorized")
|
||||||
|
|
||||||
monkeypatch.setattr("requests.get", fake_get)
|
monkeypatch.setattr("requests.get", fake_get)
|
||||||
result = fetch_balance(PLATFORM_GET, "bad", retry_count=3, timeout=10)
|
result = fetch_balance(PLATFORM_GET, "bad", retry_count=2, timeout=10)
|
||||||
assert not result.ok and result.auth_error
|
assert not result.ok and result.auth_error
|
||||||
assert len(calls) == 1
|
assert len(calls) == 3 # 401 也按重试次数确认后再判定
|
||||||
|
|
||||||
|
def test_transient_401_then_success(self, monkeypatch):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_get(url, headers=None, timeout=10):
|
||||||
|
calls.append(1)
|
||||||
|
return FakeResponse(401) if len(calls) == 1 else FakeResponse(200, json_data={"data": {"balance": 6.6}})
|
||||||
|
|
||||||
|
monkeypatch.setattr("requests.get", fake_get)
|
||||||
|
result = fetch_balance(PLATFORM_GET, "k", retry_count=2, timeout=10)
|
||||||
|
assert result.ok and result.balance == 6.6
|
||||||
|
assert len(calls) == 2 # 瞬时 401 重试后成功,不误判禁用
|
||||||
|
|
||||||
def test_5xx_retries_then_success(self, monkeypatch):
|
def test_5xx_retries_then_success(self, monkeypatch):
|
||||||
calls = []
|
calls = []
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""monitor 调度链路测试:api_key base64 解码等关键行为。"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
|
||||||
|
from app.monitor import _split
|
||||||
|
|
||||||
|
|
||||||
|
def _row(**overrides):
|
||||||
|
row = {
|
||||||
|
"id": 1, "platform_id": 2, "name": "a",
|
||||||
|
"api_key": base64.b64encode(b"sk-123").decode(),
|
||||||
|
"threshold": 0, "enabled": 1, "alert_armed": 1,
|
||||||
|
"last_balance": None, "last_status": "pending", "last_error": "",
|
||||||
|
"last_check_at": None, "note": "",
|
||||||
|
"platform_name": "P", "currency": "USD", "icon": "", "method": "GET",
|
||||||
|
"url": "http://x", "headers": "{}", "body": "", "balance_path": "b",
|
||||||
|
"interval_seconds": None, "retry_count": None, "timeout_seconds": None,
|
||||||
|
"platform_enabled": 1, "platform_note": "",
|
||||||
|
}
|
||||||
|
row.update(overrides)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
class TestSplit:
|
||||||
|
def test_api_key_decoded(self):
|
||||||
|
"""监控链路必须使用解码后的明文 key,否则请求永远 401。"""
|
||||||
|
account, platform = _split(_row())
|
||||||
|
assert account["api_key"] == "sk-123"
|
||||||
|
|
||||||
|
def test_platform_fields_mapped(self):
|
||||||
|
account, platform = _split(_row())
|
||||||
|
assert platform["name"] == "P"
|
||||||
|
assert platform["id"] == 2
|
||||||
|
assert platform["enabled"] == 1
|
||||||
|
assert platform["url"] == "http://x"
|
||||||
|
assert platform["balance_path"] == "b"
|
||||||
|
|
||||||
|
def test_plain_key_passthrough(self):
|
||||||
|
"""未编码的 key(历史数据)原样使用,不抛错。"""
|
||||||
|
account, _ = _split(_row(api_key="sk-plain"))
|
||||||
|
assert account["api_key"] == "sk-plain"
|
||||||
Reference in New Issue
Block a user