refactor: 平台改为代码内置 provider 架构,一次性迁移现有数据 - 新增 app/providers:BalanceProvider 基类 + DeepSeek 适配器 + 注册表 - platforms 表去除 method/url/headers/body/balance_path,新增 provider_id - 现有数据库已一次性迁移(备份 data/monitor.db.bak-v1),不保留迁移工具 - 平台 UI 改为选择内置提供方;fetcher/monitor 走 provider 构建请求与解析 - 表达式引擎(运算符/函数)随架构保留,供 provider 内部使用 - 测试更新至 provider 模式,共 92 个

This commit is contained in:
2026-08-05 00:56:22 +08:00
parent 1475821519
commit bdeb353bc2
16 changed files with 704 additions and 169 deletions
+32 -28
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import base64
import json
import logging
import secrets
import sqlite3
@@ -27,6 +26,7 @@ from app.models import (
SettingsUpdate,
)
from app.monitor import Monitor
from app.providers import get_provider, list_providers as list_providers_svc
logger = logging.getLogger("monitor.api")
@@ -130,36 +130,40 @@ def create_app(cfg: Config | None = None) -> FastAPI:
# ---------- 平台 ----------
@app.get("/api/providers", dependencies=[Depends(require_auth)])
def list_providers():
"""代码内置的平台适配器列表(新增平台 = 代码扩展)。"""
return list_providers_svc()
@app.get("/api/platforms", dependencies=[Depends(require_auth)])
def list_platforms():
with db.get_conn() as conn:
rows = conn.execute(
"""SELECT p.*, (SELECT COUNT(*) FROM accounts a WHERE a.platform_id = p.id) AS account_count
"""SELECT p.id, p.name, p.currency, p.icon, p.provider_id,
p.interval_seconds, p.retry_count, p.timeout_seconds,
p.enabled, p.note, p.created_at, p.updated_at,
(SELECT COUNT(*) FROM accounts a WHERE a.platform_id = p.id) AS account_count
FROM platforms p ORDER BY p.id"""
).fetchall()
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
return [dict(r) for r in rows]
@app.post("/api/platforms", dependencies=[Depends(require_auth)])
def create_platform(body: PlatformCreate):
provider = get_provider(body.provider_id)
if provider is None:
raise HTTPException(status_code=400, detail=f"不支持的平台提供方: {body.provider_id}")
currency = body.currency or provider.currency
icon = body.icon or provider.icon
try:
with db.get_conn() as conn:
cur = conn.execute(
"""INSERT INTO platforms (name, currency, icon, method, url, headers, body,
balance_path, interval_seconds, retry_count, timeout_seconds, enabled, note)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
"""INSERT INTO platforms (provider_id, name, currency, icon,
interval_seconds, retry_count, timeout_seconds, enabled, note)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
body.name, body.currency, body.icon, body.method, body.url,
__import__("json").dumps(body.headers, ensure_ascii=False),
body.body, body.balance_path, body.interval_seconds,
body.retry_count, body.timeout_seconds, int(body.enabled), body.note,
body.provider_id, body.name, currency, icon,
body.interval_seconds, body.retry_count, body.timeout_seconds,
int(body.enabled), body.note,
),
)
pid = cur.lastrowid
@@ -172,22 +176,22 @@ def create_app(cfg: Config | None = None) -> FastAPI:
@app.put("/api/platforms/{pid}", dependencies=[Depends(require_auth)])
def update_platform(pid: int, body: PlatformUpdate):
fields = {}
if body.provider_id is not None:
provider = get_provider(body.provider_id)
if provider is None:
raise HTTPException(status_code=400, detail=f"不支持的平台提供方: {body.provider_id}")
fields["provider_id"] = body.provider_id
# 换 provider 时若未显式给货币/图标,则重置为 provider 默认
if "currency" not in body.model_fields_set:
fields["currency"] = provider.currency
if "icon" not in body.model_fields_set:
fields["icon"] = provider.icon
if body.name is not None:
fields["name"] = body.name
if body.currency is not None:
fields["currency"] = body.currency
if body.icon is not None:
fields["icon"] = body.icon
if body.method is not None:
fields["method"] = body.method
if body.url is not None:
fields["url"] = body.url
if body.headers is not None:
fields["headers"] = __import__("json").dumps(body.headers, ensure_ascii=False)
if body.body is not None:
fields["body"] = body.body
if body.balance_path is not None:
fields["balance_path"] = body.balance_path
if body.interval_seconds is not None:
fields["interval_seconds"] = body.interval_seconds
if body.retry_count is not None:
+1 -5
View File
@@ -11,14 +11,10 @@ DB_PATH = DATA_DIR / "monitor.db"
SCHEMA = """
CREATE TABLE IF NOT EXISTS platforms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider_id TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL UNIQUE,
currency TEXT NOT NULL DEFAULT 'USD',
icon TEXT NOT NULL DEFAULT '',
method TEXT NOT NULL DEFAULT 'GET',
url TEXT NOT NULL,
headers TEXT NOT NULL DEFAULT '{}',
body TEXT NOT NULL DEFAULT '',
balance_path TEXT NOT NULL,
interval_seconds INTEGER,
retry_count INTEGER,
timeout_seconds INTEGER,
+236
View File
@@ -0,0 +1,236 @@
"""受限的余额表达式引擎。
在 JSON 提取路径基础上支持:
- 运算符:+ - * / // % ** 一元负号、括号
- 函数(白名单):float / int / abs / round / min / max / sum / len
- 路径写法与原来完全兼容:data.balance、data[0].x、$.data[0].x
示例:
data.balance / 100 # 分转元
float(data.balance) # 字符串转数字
data.granted + data.topped_up # 多字段求和
sum(data[0].balances) # 数组求和
round(data.balance, 2) * 0.9
安全性:无 eval、无任意变量访问,函数名严格白名单,路径只支持
dict 键 / list 索引访问。
"""
from __future__ import annotations
import re
_PATH_SEG_RE = re.compile(r"([^.\[]+)|\[(-?\d+)\]")
class ExprError(ValueError):
"""表达式语法错误 / 求值错误。"""
# ---------- 路径提取(返回原始值,不强制数字) ----------
def extract_path(data: object, path: str) -> object:
p = path.strip()
if p.startswith("$"):
p = p[1:]
cur: object = data
for key, idx in _PATH_SEG_RE.findall(p):
if key:
if not isinstance(cur, dict) or key not in cur:
raise ExprError(f"路径不存在: {path}(在 {key!r} 处)")
cur = cur[key]
if idx != "":
n = int(idx)
if not isinstance(cur, list) or n >= len(cur):
raise ExprError(f"数组索引越界: {path}(索引 {n}")
cur = cur[n]
return cur
def to_number(value: object, src: str) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
raise ExprError(f"余额不是数字: {src} -> {value!r}")
try:
return float(value)
except (TypeError, ValueError):
raise ExprError(f"余额不是数字: {src} -> {value!r}")
# ---------- 词法 ----------
_TOKEN_RE = re.compile(r"""
(?P<num>\d+(?:\.\d+)?)
| (?P<path>\$?\.?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*|\[-?\d+\])*)
| (?P<op>//|\*\*|[+\-*/%(),])
| (?P<ws>\s+)
""", re.VERBOSE)
def _tokenize(text: str) -> list[tuple[str, str]]:
tokens: list[tuple[str, str]] = []
pos = 0
for m in _TOKEN_RE.finditer(text):
if m.start() != pos:
raise ExprError(f"表达式语法错误(第 {pos + 1} 字符附近): {text!r}")
pos = m.end()
kind = m.lastgroup
if kind == "ws":
continue
val = m.group()
if kind == "path" and text[m.end():m.end() + 1] == "(" and "." not in val and "[" not in val:
tokens.append(("FUNC", val))
else:
tokens.append((kind.upper(), val))
if pos != len(text):
raise ExprError(f"表达式语法错误(第 {pos + 1} 字符附近): {text!r}")
tokens.append(("EOF", ""))
return tokens
# ---------- 函数白名单 ----------
def _call(name: str, args: list) -> object:
if name == "float":
_need_arity(name, args, 1)
return to_number(args[0], name)
if name == "int":
_need_arity(name, args, 1)
return int(to_number(args[0], name))
if name == "abs":
_need_arity(name, args, 1)
return abs(to_number(args[0], name))
if name == "round":
if len(args) == 1:
return round(to_number(args[0], name))
if len(args) == 2:
return round(to_number(args[0], name), int(args[1]))
raise ExprError("round() 需要 1 或 2 个参数")
if name == "len":
_need_arity(name, args, 1)
if not isinstance(args[0], (list, dict, str)):
raise ExprError("len() 参数必须是数组/对象/字符串")
return len(args[0])
if name == "sum":
if len(args) == 1 and isinstance(args[0], list):
return sum(to_number(v, name) for v in args[0])
if not args:
raise ExprError("sum() 至少需要 1 个参数")
return sum(to_number(v, name) for v in args)
if name == "min":
values = args[0] if len(args) == 1 and isinstance(args[0], list) else args
if not values:
raise ExprError("min() 参数不能为空")
return min(to_number(v, name) for v in values)
if name == "max":
values = args[0] if len(args) == 1 and isinstance(args[0], list) else args
if not values:
raise ExprError("max() 参数不能为空")
return max(to_number(v, name) for v in values)
raise ExprError(f"不支持的函数: {name}()(可用: float/int/abs/round/min/max/sum/len")
def _need_arity(name: str, args: list, n: int) -> None:
if len(args) != n:
raise ExprError(f"{name}() 需要 {n} 个参数,实际 {len(args)}")
# ---------- 语法分析 + 求值(递归下降,直接求值) ----------
class _Evaluator:
def __init__(self, data: object, text: str) -> None:
self.data = data
self.tokens = _tokenize(text)
self.pos = 0
def _peek(self) -> tuple[str, str]:
return self.tokens[self.pos]
def _next(self) -> tuple[str, str]:
tok = self.tokens[self.pos]
self.pos += 1
return tok
def _accept_op(self, *ops: str) -> tuple[str, str] | None:
kind, val = self._peek()
if kind == "OP" and val in ops:
return self._next()
return None
def _expect_op(self, op: str) -> None:
kind, val = self._next()
if kind != "OP" or val != op:
raise ExprError(f"期望 {op!r},实际 {val!r}")
def evaluate(self) -> float:
value = self._expr()
if self._peek() != ("EOF", ""):
raise ExprError(f"表达式多余内容: {self._peek()!r}")
return to_number(value, "表达式")
def _expr(self) -> object:
v = self._term()
while (op := self._accept_op("+", "-")) is not None:
rhs = self._term()
v = v + rhs if op[1] == "+" else v - rhs
return v
def _term(self) -> object:
v = self._factor()
while (op := self._accept_op("*", "/", "//", "%")) is not None:
rhs = self._factor()
if op[1] == "*":
v = v * rhs
elif op[1] == "/":
v = v / rhs
elif op[1] == "//":
v = v // rhs
else:
v = v % rhs
return v
def _factor(self) -> object:
v = self._unary()
if self._accept_op("**") is not None:
rhs = self._factor() # 右结合
v = v ** rhs
return v
def _unary(self) -> object:
if (op := self._accept_op("-", "+")) is not None:
v = self._unary()
return -v if op[1] == "-" else v
return self._primary()
def _primary(self) -> object:
kind, val = self._peek()
if kind == "NUM":
self._next()
return float(val) if "." in val else int(val)
if kind == "PATH":
self._next()
return extract_path(self.data, val)
if kind == "FUNC":
return self._call()
if kind == "OP" and val == "(":
self._next()
v = self._expr()
self._expect_op(")")
return v
raise ExprError(f"表达式语法错误: 意外的 {kind} {val!r}")
def _call(self) -> object:
name = self._next()[1]
self._expect_op("(")
args: list = []
if not (self._peek()[0] == "OP" and self._peek()[1] == ")"):
while True:
args.append(self._expr())
if self._accept_op(",") is None:
break
self._expect_op(")")
return _call(name, args)
def evaluate_balance(data: object, expression: str) -> float:
"""对响应 JSON 求值余额表达式,返回 float。失败抛 ExprError。"""
return _Evaluator(data, expression).evaluate()
+21 -47
View File
@@ -2,18 +2,18 @@
from __future__ import annotations
import json
import logging
import re
import time
from dataclasses import dataclass
import requests
from app.expr import extract_path, to_number
from app.providers.base import BalanceProvider
logger = logging.getLogger("monitor.fetcher")
PLACEHOLDER = "{{apiKey}}"
_TOKEN_RE = re.compile(r"([^.\[]+)|\[(\d+)\]")
@dataclass
@@ -33,57 +33,31 @@ def render_template(text: str, api_key: str) -> str:
def extract_balance(data: object, path: str) -> float:
"""按点路径/数组索引提取余额,如 data.balance、data[0].balance、$.data[0].balance
"""按点路径/数组索引提取余额(兼容旧写法,返回 float
取到的值必须是数字或可转数字的字符串,否则抛 ValueError
新写法请用 evaluate_balance(支持运算符与函数)
"""
p = path.strip()
if p.startswith("$"):
p = p[1:]
cur: object = data
for key, idx in _TOKEN_RE.findall(p):
if key:
if not isinstance(cur, dict) or key not in cur:
raise ValueError(f"路径不存在: {path}(在 {key!r} 处)")
cur = cur[key]
if idx != "":
n = int(idx)
if not isinstance(cur, list) or n >= len(cur):
raise ValueError(f"数组索引越界: {path}(索引 {n}")
cur = cur[n]
if isinstance(cur, bool) or not isinstance(cur, (int, float, str)):
raise ValueError(f"余额不是数字: {path} -> {cur!r}")
try:
return float(cur)
except (TypeError, ValueError):
raise ValueError(f"余额不是数字: {path} -> {cur!r}")
return to_number(extract_path(data, path), path)
def _build_request(platform: dict, api_key: str) -> tuple[str, dict, str | None]:
url = render_template(platform["url"], api_key)
headers_raw = platform.get("headers") or {}
if isinstance(headers_raw, str):
headers_raw = json.loads(headers_raw) if headers_raw.strip() else {}
headers = json.loads(render_template(json.dumps(headers_raw), api_key))
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
def fetch_balance(platform: dict, api_key: str, retry_count: int, timeout: int) -> FetchResult:
def fetch_balance(
provider: BalanceProvider,
api_key: str,
retry_count: int,
timeout: int,
) -> FetchResult:
"""执行一次余额拉取。
策略:
- 401/403 → auth_error不重试,由调用方禁用账号并通知)
- 其他 4xx → 直接失败(配置问题,不重试)
- 401/403 → 先按重试次数确认(可能瞬时),仍失败 → auth_error(调用方禁用账号并通知)
- 其他 4xx → 直接失败(不重试)
- 网络异常 / 5xx → 重试 retry_count 次,间隔 2s
- JSON 解析/路径提取失败 → 直接失败
- JSON 解析/余额提取失败 → 直接失败
"""
url, headers, body = _build_request(platform, api_key)
method = platform["method"]
url, headers, body = provider.build_request(api_key)
method = provider.method
if method == "POST" and body and not any(k.lower() == "content-type" for k in headers):
headers["Content-Type"] = "application/json"
attempts = retry_count + 1
for attempt in range(attempts):
try:
@@ -118,8 +92,8 @@ def fetch_balance(platform: dict, api_key: str, retry_count: int, timeout: int)
return FetchResult(ok=False, status_code=resp.status_code,
error="响应不是合法 JSON")
try:
balance = extract_balance(data, platform["balance_path"])
except ValueError as exc:
balance = provider.extract_balance(data)
except Exception as exc:
return FetchResult(ok=False, status_code=resp.status_code, error=str(exc))
return FetchResult(ok=True, balance=balance, status_code=resp.status_code)
return FetchResult(ok=False, error=f"网络错误,重试 {retry_count} 次后仍失败")
+5 -13
View File
@@ -8,14 +8,10 @@ from pydantic import BaseModel, Field, model_validator
# ---------- 平台 ----------
class PlatformBase(BaseModel):
provider_id: str = Field(min_length=1, max_length=64)
name: str = Field(min_length=1, max_length=64)
currency: str = Field(default="USD", min_length=1, max_length=16)
icon: str = Field(default="", max_length=64)
method: str = Field(default="GET", pattern="^(GET|POST)$")
url: str = Field(min_length=1)
headers: dict = Field(default_factory=dict)
body: str = Field(default="")
balance_path: str = Field(min_length=1)
currency: str | None = Field(default=None, min_length=1, max_length=16)
icon: str | None = Field(default=None, max_length=64)
interval_seconds: int | None = Field(default=None, ge=10)
retry_count: int | None = Field(default=None, ge=0, le=10)
timeout_seconds: int | None = Field(default=None, ge=1, le=120)
@@ -28,14 +24,10 @@ class PlatformCreate(PlatformBase):
class PlatformUpdate(BaseModel):
provider_id: str | None = Field(default=None, min_length=1, max_length=64)
name: str | None = Field(default=None, min_length=1, max_length=64)
currency: str | None = Field(default=None, min_length=1, max_length=16)
currency: str | None = Field(default=None, max_length=16)
icon: str | None = Field(default=None, max_length=64)
method: str | None = Field(default=None, pattern="^(GET|POST)$")
url: str | None = Field(default=None, min_length=1)
headers: dict | None = None
body: str | None = None
balance_path: str | None = Field(default=None, min_length=1)
interval_seconds: int | None = Field(default=None, ge=10)
retry_count: int | None = Field(default=None, ge=0, le=10)
timeout_seconds: int | None = Field(default=None, ge=1, le=120)
+19 -8
View File
@@ -18,6 +18,7 @@ from app import db
from app.alert import evaluate, notify_disabled
from app.config import Config
from app.fetcher import fetch_balance
from app.providers import get_provider
logger = logging.getLogger("monitor.scheduler")
@@ -28,17 +29,15 @@ _ACCOUNT_FIELDS = [
"last_check_at", "note",
]
_PLATFORM_FIELDS = [
"platform_id", "platform_name", "currency", "icon", "method", "url",
"headers", "body", "balance_path", "interval_seconds",
"retry_count", "timeout_seconds", "platform_enabled", "note",
"platform_id", "provider_id", "platform_name", "currency", "icon",
"interval_seconds", "retry_count", "timeout_seconds", "platform_enabled", "note",
]
_ACCOUNT_SQL = """
SELECT a.*,
p.name AS platform_name, p.currency, p.icon, p.method, p.url,
p.headers, p.body, p.balance_path, p.interval_seconds,
p.retry_count, p.timeout_seconds, p.enabled AS platform_enabled,
p.note AS platform_note
p.provider_id, p.name AS platform_name, p.currency, p.icon,
p.interval_seconds, p.retry_count, p.timeout_seconds,
p.enabled AS platform_enabled, p.note AS platform_note
FROM accounts a JOIN platforms p ON p.id = a.platform_id
"""
@@ -138,13 +137,25 @@ class Monitor:
def _check_one(self, row: dict) -> None:
cfg = self.cfg
account, platform = _split(row)
provider = get_provider(platform["provider_id"])
if provider is None:
logger.warning("平台 %s 的 provider 不存在: %r", platform["name"], platform["provider_id"])
with db.get_conn() as conn:
conn.execute(
"UPDATE accounts SET last_status='error', last_error=?, "
"last_check_at=datetime('now','localtime') WHERE id=?",
(f"provider 不存在: {platform['provider_id']}", account["id"]),
)
with self._lock:
self._next_check[account["id"]] = time.time() + _account_interval(platform, cfg)
return
retry = platform.get("retry_count")
if retry is None:
retry = cfg.retry_count
timeout = platform.get("timeout_seconds")
if timeout is None:
timeout = cfg.timeout_seconds
result = fetch_balance(platform, account["api_key"], int(retry), int(timeout))
result = fetch_balance(provider, account["api_key"], int(retry), int(timeout))
with db.get_conn() as conn:
if result.ok:
+28
View File
@@ -0,0 +1,28 @@
"""平台适配器注册表:新增平台 = 加一个文件 + 在这里登记。"""
from __future__ import annotations
from app.providers.base import BalanceProvider
from app.providers.deepseek import DeepSeekProvider
PROVIDERS: dict[str, type[BalanceProvider]] = {
"deepseek": DeepSeekProvider,
}
def get_provider(provider_id: str) -> BalanceProvider | None:
cls = PROVIDERS.get(provider_id)
return cls() if cls else None
def list_providers() -> list[dict]:
return [
{
"id": p.id,
"name": p.name,
"currency": p.currency,
"icon": p.icon,
"description": p.description,
}
for p in (cls() for cls in PROVIDERS.values())
]
+39
View File
@@ -0,0 +1,39 @@
"""平台适配器基类。
多平台支持通过继承 BalanceProvider 实现:
1. 新建 app/providers/<name>.py
2. 继承 BalanceProvider,实现 build_request / extract_balance
3. 在 __init__.py 的 PROVIDERS 注册表中登记
"""
from __future__ import annotations
from abc import ABC, abstractmethod
class BalanceProvider(ABC):
"""一个平台的余额获取实现。"""
#: 唯一标识(存入 platforms.provider_id
id: str = ""
#: 平台显示名
name: str = ""
#: 默认货币单位
currency: str = "USD"
#: @lobehub/icons 键(前端品牌色映射)
icon: str = ""
#: 简要说明(前端展示)
description: str = ""
#: 请求方法
method: str = "GET"
@abstractmethod
def build_request(self, api_key: str) -> tuple[str, dict, str | None]:
"""根据 api_key 构建请求,返回 (url, headers, body)。
body 为 None 表示无请求体;POST 平台返回 JSON 字符串。
"""
@abstractmethod
def extract_balance(self, data: object) -> float:
"""从响应 JSON 中提取余额(数字),失败抛异常由上层记录。"""
+28
View File
@@ -0,0 +1,28 @@
"""DeepSeek 开放平台余额。
接口:GET https://api.deepseek.com/user/balance
响应:{"is_available": true, "balance_infos": [{"currency": "CNY", "total_balance": "1.34", ...}]}
"""
from __future__ import annotations
from app.expr import evaluate_balance
from app.providers.base import BalanceProvider
class DeepSeekProvider(BalanceProvider):
id = "deepseek"
name = "DeepSeek"
currency = "CNY"
icon = "DeepSeek"
description = "DeepSeek 开放平台余额(GET /user/balanceBearer 认证)"
def build_request(self, api_key: str) -> tuple[str, dict, str | None]:
return (
"https://api.deepseek.com/user/balance",
{"Accept": "application/json", "Authorization": f"Bearer {api_key}"},
None,
)
def extract_balance(self, data: object) -> float:
return evaluate_balance(data, "balance_infos[0].total_balance")
+40 -35
View File
@@ -6,6 +6,7 @@
let token = localStorage.getItem(TOKEN_KEY) || "";
let accounts = [];
let platforms = [];
let providersList = [];
let settings = null;
let refreshTimer = null;
let filterStatus = "all";
@@ -141,10 +142,13 @@
async function refresh() {
try {
const [acc, plat, hist] = await Promise.all([api("/accounts"), api("/platforms"), api("/history")]);
const [acc, plat, hist, provs] = await Promise.all([
api("/accounts"), api("/platforms"), api("/history"), api("/providers"),
]);
accounts = acc;
platforms = plat;
historyCache = hist || {};
providersList = provs || [];
renderAccounts();
if (!document.getElementById("view-platforms").classList.contains("hidden")) renderPlatforms();
const d = new Date();
@@ -347,6 +351,7 @@
}
list.innerHTML = platforms.map((p, i) => {
const b = brandStyle(p.icon || "");
const prov = providersList.find((x) => x.id === p.provider_id);
return `
<div class="platform-row ${p.enabled ? "" : "platform-off"}" style="animation-delay:${Math.min(i, 10) * 35}ms">
<div class="badge" style="background:${b.color}">${escapeHtml(b.abbr)}</div>
@@ -355,11 +360,11 @@
${p.enabled ? "" : '<span class="badge-pill disabled">已停用</span>'}
</div>
<div class="platform-meta">
${escapeHtml(p.method)} ${escapeHtml(p.url)} ·
提取 ${escapeHtml(p.balance_path)} ·
${prov ? escapeHtml(prov.name) : "未知提供方"} ·
${escapeHtml(p.currency)} ·
间隔 ${p.interval_seconds || "全局"}s ·
${p.account_count} 个账号
${prov && prov.description ? `<div style="color:var(--text-3)">${escapeHtml(prov.description)}</div>` : ""}
</div>
</div>
<div class="platform-actions">
@@ -407,35 +412,28 @@
function platformForm(p) {
const isEdit = !!p;
const v = p || {};
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}';
const provOpts = providersList.length
? providersList.map((x) => `<option value="${x.id}" ${v.provider_id === x.id ? "selected" : ""}>${escapeHtml(x.name)}</option>`).join("")
: '<option value="">加载中…</option>';
const prov = providersList.find((x) => x.id === v.provider_id);
openModal(`
<div class="modal">
<h3>${isEdit ? "编辑平台" : "添加平台"}</h3>
<div class="form-grid">
<div class="form-row"><label>名称 *</label><input id="pf-name" value="${escapeHtml(v.name || "")}" placeholder="如 OpenAI"></div>
<div class="form-row"><label>货币单位 *</label><input id="pf-currency" value="${escapeHtml(v.currency || "USD")}"></div>
<div class="form-row"><label>图标键(@lobehub/icons</label><input id="pf-icon" value="${escapeHtml(v.icon || "")}" placeholder="如 OpenAI / DeepSeek"></div>
<div class="form-row">
<label>请求方法</label>
<select id="pf-method">
<option value="GET" ${(v.method || "GET") === "GET" ? "selected" : ""}>GET</option>
<option value="POST" ${v.method === "POST" ? "selected" : ""}>POST</option>
</select>
<div class="form-row full">
<label>平台提供方 *(代码内置)</label>
<select id="pf-provider">${provOpts}</select>
<div class="form-hint" id="pf-prov-desc">${prov ? escapeHtml(prov.description) : ""}</div>
</div>
<div class="form-row full"><label>URL *(支持 {{apiKey}}</label><input id="pf-url" value="${escapeHtml(v.url || "")}" placeholder="https://api.openai.com/v1/dashboard/billing/credit_grants?api_key={{apiKey}}"></div>
<div class="form-row full"><label>HeadersJSON,值支持 {{apiKey}}</label><textarea id="pf-headers">${escapeHtml(headersStr)}</textarea></div>
<div class="form-row full"><label>BodyPOST 时使用,JSON 模板,支持 {{apiKey}}</label><textarea id="pf-body" placeholder='{"api_key": "{{apiKey}}"}'>${escapeHtml(v.body || "")}</textarea></div>
<div class="form-row"><label>余额提取路径 *</label><input id="pf-path" value="${escapeHtml(v.balance_path || "")}" placeholder="data.balance 或 data[0].balance"></div>
<div class="form-row"><label>名称 *</label><input id="pf-name" value="${escapeHtml(v.name || "")}"></div>
<div class="form-row"><label>货币单位</label><input id="pf-currency" value="${escapeHtml(v.currency || "")}" placeholder="默认来自提供方"></div>
<div class="form-row"><label>图标键(@lobehub/icons</label><input id="pf-icon" value="${escapeHtml(v.icon || "")}" placeholder="默认来自提供方"></div>
<div class="form-row"><label>监控间隔(秒,留空用全局)</label><input id="pf-interval" type="number" min="10" value="${v.interval_seconds ?? ""}" placeholder="全局 ${settings ? settings.global_interval_seconds : 300}s"></div>
<div class="form-row"><label>重试次数(留空用全局 ${settings ? settings.retry_count : 2}</label><input id="pf-retry" type="number" min="0" max="10" value="${v.retry_count ?? ""}"></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>
<p class="form-hint">提示:apikey 在 URL / Header / Body 中统一用 {{apiKey}} 占位,添加账号时自动替换。Headers/Body 需为 JSON,键值用双引号(单引号会自动兼容)。</p>
<p class="form-hint">余额获取方式由代码内置的平台适配器决定,无需配置请求地址与提取路径。</p>
<p class="modal-error" id="pf-error"></p>
<div class="modal-actions">
<button class="btn" data-cancel>取消</button>
@@ -443,23 +441,30 @@
</div>
</div>`, (mask) => {
mask.querySelector("[data-cancel]").onclick = () => closeModal(mask);
const provSel = mask.querySelector("#pf-provider");
provSel.onchange = () => {
const x = providersList.find((q) => q.id === provSel.value);
if (!x) return;
mask.querySelector("#pf-prov-desc").textContent = x.description || "";
if (!mask.querySelector("#pf-name").value || !isEdit) mask.querySelector("#pf-name").value = x.name;
if (!mask.querySelector("#pf-currency").value) mask.querySelector("#pf-currency").value = x.currency;
if (!mask.querySelector("#pf-icon").value) mask.querySelector("#pf-icon").value = x.icon;
};
mask.querySelector("#pf-save").onclick = async () => {
const providerId = val("#pf-provider");
const payload = {
name: val("#pf-name"), currency: val("#pf-currency"), icon: val("#pf-icon"),
method: val("#pf-method"), url: val("#pf-url"), balance_path: val("#pf-path"),
provider_id: providerId,
name: val("#pf-name"),
currency: val("#pf-currency") || null,
icon: val("#pf-icon") || null,
note: val("#pf-note"),
interval_seconds: numOrNull("#pf-interval"), retry_count: numOrNull("#pf-retry"),
timeout_seconds: numOrNull("#pf-timeout"), enabled: p ? p.enabled : true,
interval_seconds: numOrNull("#pf-interval"),
retry_count: numOrNull("#pf-retry"),
timeout_seconds: numOrNull("#pf-timeout"),
enabled: p ? p.enabled : true,
};
let headers = parseJsonInput(val("#pf-headers"), "Headers", "#pf-error");
if (headers === null) return;
payload.headers = headers;
if (!payload.name || !payload.url || !payload.balance_path) { err("#pf-error", "名称 / URL / 提取路径必填"); return; }
if (payload.method === "POST") {
const bodyObj = parseJsonInput(val("#pf-body"), "Body", "#pf-error");
if (bodyObj === null) return;
}
payload.body = val("#pf-body");
if (!providerId) { err("#pf-error", "请选择平台提供方"); return; }
if (!payload.name) { err("#pf-error", "名称必填"); return; }
try {
if (isEdit) await api("/platforms/" + p.id, { method: "PUT", body: JSON.stringify(payload) });
else await api("/platforms", { method: "POST", body: JSON.stringify(payload) });
+1 -1
View File
@@ -14,7 +14,7 @@ PLATFORM = {"name": "OpenAI", "currency": "USD"}
def _insert_account(test_db, armed=1, enabled=1):
with db.get_conn() as conn:
conn.execute("INSERT INTO platforms (id, name, url, balance_path) VALUES (1, 'OpenAI', 'http://x', 'b')")
conn.execute("INSERT INTO platforms (id, provider_id, name) VALUES (1, 'deepseek', 'OpenAI')")
conn.execute(
"""INSERT INTO accounts (id, platform_id, name, api_key, threshold, enabled, alert_armed)
VALUES (1, 1, '主账号', 'a2tva2V5', 20.0, ?, ?)""",
+18 -10
View File
@@ -45,10 +45,7 @@ class TestAuth:
PLATFORM_PAYLOAD = {
"name": "OpenAI", "currency": "USD", "icon": "OpenAI", "method": "GET",
"url": "https://x.test?key={{apiKey}}",
"headers": {"Authorization": "Bearer {{apiKey}}"},
"body": "", "balance_path": "data.balance",
"provider_id": "deepseek", "name": "DeepSeek-Test",
"interval_seconds": 120, "retry_count": 1, "timeout_seconds": 15,
"enabled": True, "note": "",
}
@@ -60,11 +57,13 @@ class TestPlatforms:
pid = client.post("/api/platforms", json=PLATFORM_PAYLOAD, headers=h).json()["id"]
lst = client.get("/api/platforms", headers=h).json()
assert len(lst) == 1 and lst[0]["account_count"] == 0 and lst[0]["url"] == PLATFORM_PAYLOAD["url"]
assert len(lst) == 1 and lst[0]["account_count"] == 0
assert lst[0]["provider_id"] == "deepseek"
assert lst[0]["currency"] == "CNY" # 默认取 provider 的货币
upd = client.put(f"/api/platforms/{pid}", json={"currency": "CNY", "interval_seconds": 300}, headers=h)
upd = client.put(f"/api/platforms/{pid}", json={"currency": "USD", "interval_seconds": 300}, headers=h)
assert upd.status_code == 200
assert client.get("/api/platforms", headers=h).json()[0]["currency"] == "CNY"
assert client.get("/api/platforms", headers=h).json()[0]["currency"] == "USD"
assert client.delete(f"/api/platforms/{pid}", headers=h).status_code == 200
assert client.get("/api/platforms", headers=h).json() == []
@@ -74,10 +73,19 @@ class TestPlatforms:
client.post("/api/platforms", json=PLATFORM_PAYLOAD, headers=h)
assert client.post("/api/platforms", json=PLATFORM_PAYLOAD, headers=h).status_code == 409
def test_unknown_provider_400(self, client):
h = _auth(client)
assert client.post("/api/platforms", json=dict(PLATFORM_PAYLOAD, provider_id="nope"), headers=h).status_code == 400
def test_validation_errors(self, client):
h = _auth(client)
assert client.post("/api/platforms", json=dict(PLATFORM_PAYLOAD, method="DELETE"), headers=h).status_code == 422
assert client.post("/api/platforms", json=dict(PLATFORM_PAYLOAD, url=""), headers=h).status_code == 422
assert client.post("/api/platforms", json=dict(PLATFORM_PAYLOAD, name=""), headers=h).status_code == 422
assert client.post("/api/platforms", json=dict(PLATFORM_PAYLOAD, interval_seconds=5), headers=h).status_code == 422
def test_providers_list(self, client):
h = _auth(client)
provs = client.get("/api/providers", headers=h).json()
assert any(p["id"] == "deepseek" and p["currency"] == "CNY" for p in provs)
def test_delete_cascades_accounts(self, client, test_db):
from app import db
@@ -100,7 +108,7 @@ class TestAccounts:
aid = client.post("/api/accounts", json={"platform_id": pid, "name": "", "api_key": "sk-secret", "threshold": 10}, headers=h).json()["id"]
acc = client.get("/api/accounts", headers=h).json()[0]
assert acc["id"] == aid and acc["api_key"] == "sk-secret"
assert acc["platform_name"] == "OpenAI" and acc["currency"] == "USD"
assert acc["platform_name"] == "DeepSeek-Test" and acc["currency"] == "CNY"
def test_key_stored_base64(self, client, test_db):
from app import db
+134
View File
@@ -0,0 +1,134 @@
"""余额表达式引擎测试:运算符、函数、兼容性与安全。"""
import pytest
from app.expr import ExprError, evaluate_balance
DATA = {
"data": {
"balance": "1.50",
"total": 100,
"used": 40,
"fee": 2.5,
},
"list": [
{"x": 1, "y": 10},
{"x": 2, "y": 20},
],
"balances": [1, 2, 3],
"nested": {"a": {"b": 6}},
}
class TestPlainPath:
def test_dot_path(self):
assert evaluate_balance({"data": {"balance": 12.5}}, "data.balance") == 12.5
def test_array_index(self):
assert evaluate_balance(DATA, "list[0].x") == 1
def test_dollar_prefix(self):
assert evaluate_balance(DATA, "$.data.total") == 100
def test_string_number(self):
assert evaluate_balance(DATA, "data.balance") == 1.5
def test_missing_path(self):
with pytest.raises(ExprError, match="路径不存在"):
evaluate_balance(DATA, "data.nope")
def test_index_out_of_range(self):
with pytest.raises(ExprError, match="数组索引越界"):
evaluate_balance(DATA, "list[5].x")
class TestOperators:
def test_division(self):
assert evaluate_balance(DATA, "data.total / 100") == 1.0
def test_addition(self):
assert evaluate_balance(DATA, "data.total + data.used") == 140
def test_priority(self):
assert evaluate_balance(DATA, "data.total + data.used * 2") == 180
assert evaluate_balance(DATA, "(data.total + data.used) * 2") == 280
def test_floor_div_and_mod(self):
assert evaluate_balance(DATA, "data.total // 30") == 3
assert evaluate_balance(DATA, "data.total % 30") == 10
def test_power(self):
assert evaluate_balance(DATA, "2 ** 3 * 5") == 40
def test_unary_minus(self):
assert evaluate_balance(DATA, "-data.total") == -100
assert evaluate_balance(DATA, "data.total - -data.used") == 140
def test_float_result(self):
assert evaluate_balance(DATA, "data.total / 8") == 12.5
class TestFunctions:
def test_float(self):
assert evaluate_balance(DATA, "float(data.balance)") == 1.5
def test_int(self):
assert evaluate_balance(DATA, "int(data.total / 3)") == 33
def test_abs(self):
assert evaluate_balance(DATA, "abs(data.used - data.total)") == 60
def test_round_one_arg(self):
assert evaluate_balance(DATA, "round(data.fee * 3)") == 8
def test_round_two_args(self):
assert evaluate_balance(DATA, "round(data.fee, 1)") == 2.5
assert evaluate_balance(DATA, "round(3.14159, 2)") == 3.14
def test_sum_multi_args(self):
assert evaluate_balance(DATA, "sum(data.total, data.used, data.fee)") == 142.5
def test_sum_array(self):
assert evaluate_balance(DATA, "sum(balances)") == 6
def test_min_max(self):
assert evaluate_balance(DATA, "min(data.total, data.used)") == 40
assert evaluate_balance(DATA, "max(data.total, data.used)") == 100
assert evaluate_balance(DATA, "min(balances)") == 1
assert evaluate_balance(DATA, "max(balances)") == 3
def test_len(self):
assert evaluate_balance(DATA, "len(balances)") == 3
def test_nested_call(self):
assert evaluate_balance(DATA, "round(abs(data.used - data.total) / 3, 1)") == 20.0
class TestErrors:
def test_unknown_function(self):
with pytest.raises(ExprError, match="不支持的函数"):
evaluate_balance(DATA, "eval(data.balance)")
def test_syntax_error(self):
with pytest.raises(ExprError):
evaluate_balance(DATA, "data.total +")
with pytest.raises(ExprError):
evaluate_balance(DATA, "(data.total")
def test_bad_arity(self):
with pytest.raises(ExprError, match="float"):
evaluate_balance(DATA, "float()")
with pytest.raises(ExprError, match="round"):
evaluate_balance(DATA, "round(data.total, 2, 3)")
def test_division_by_zero(self):
with pytest.raises((ZeroDivisionError, ExprError)):
evaluate_balance(DATA, "data.total / 0")
def test_non_numeric_result(self):
with pytest.raises(ExprError, match="不是数字"):
evaluate_balance(DATA, "sum(list)") # 数组元素是对象,无法转数字
def test_string_literal_rejected(self):
with pytest.raises(ExprError):
evaluate_balance(DATA, "data.total + 'abc'")
+47 -18
View File
@@ -68,13 +68,32 @@ class FakeResponse:
return self._json
PLATFORM_GET = {
"method": "GET",
"url": "https://x.test/api?key={{apiKey}}",
"headers": {"Authorization": "Bearer {{apiKey}}"},
"body": "",
"balance_path": "data.balance",
}
class FakeProvider:
"""测试用 providerURL 带 key、余额在 data.balance。"""
method = "GET"
def __init__(self, path="data.balance"):
self.path = path
def build_request(self, api_key):
return (
"https://x.test/api?key=" + api_key,
{"Authorization": "Bearer " + api_key},
None,
)
def extract_balance(self, data):
from app.expr import evaluate_balance
return evaluate_balance(data, self.path)
class FakePostProvider(FakeProvider):
method = "POST"
def build_request(self, api_key):
return ("https://x.test/api", {}, '{"api_key": "' + api_key + '"}')
class TestFetchBalance:
@@ -88,12 +107,11 @@ class TestFetchBalance:
return FakeResponse(200, json_data={"data": {"balance": 42.5}})
monkeypatch.setattr("requests.get", fake_get)
result = fetch_balance(PLATFORM_GET, "sk-1", retry_count=2, timeout=10)
result = fetch_balance(FakeProvider(), "sk-1", retry_count=2, timeout=10)
assert result.ok and result.balance == 42.5
assert len(calls) == 1
def test_post_with_body(self, monkeypatch):
platform = dict(PLATFORM_GET, method="POST", body='{"api_key": "{{apiKey}}"}')
captured = {}
def fake_post(url, headers=None, data=None, timeout=10):
@@ -101,10 +119,21 @@ class TestFetchBalance:
return FakeResponse(200, json_data={"data": {"balance": 1}})
monkeypatch.setattr("requests.post", fake_post)
result = fetch_balance(platform, "sk-2", retry_count=2, timeout=10)
result = fetch_balance(FakePostProvider(), "sk-2", retry_count=2, timeout=10)
assert result.ok
assert captured["data"] == '{"api_key": "sk-2"}'
def test_post_content_type_auto(self, monkeypatch):
captured = {}
def fake_post(url, headers=None, data=None, timeout=10):
captured["headers"] = headers
return FakeResponse(200, json_data={"data": {"balance": 1}})
monkeypatch.setattr("requests.post", fake_post)
fetch_balance(FakePostProvider(), "sk-2", retry_count=0, timeout=10)
assert captured["headers"].get("Content-Type") == "application/json"
def test_401_retries_then_auth_error(self, monkeypatch):
calls = []
@@ -113,7 +142,7 @@ class TestFetchBalance:
return FakeResponse(401, text="unauthorized")
monkeypatch.setattr("requests.get", fake_get)
result = fetch_balance(PLATFORM_GET, "bad", retry_count=2, timeout=10)
result = fetch_balance(FakeProvider(), "bad", retry_count=2, timeout=10)
assert not result.ok and result.auth_error
assert len(calls) == 3 # 401 也按重试次数确认后再判定
@@ -125,7 +154,7 @@ class TestFetchBalance:
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)
result = fetch_balance(FakeProvider(), "k", retry_count=2, timeout=10)
assert result.ok and result.balance == 6.6
assert len(calls) == 2 # 瞬时 401 重试后成功,不误判禁用
@@ -137,13 +166,13 @@ class TestFetchBalance:
return FakeResponse(500) if len(calls) < 3 else FakeResponse(200, json_data={"data": {"balance": 5}})
monkeypatch.setattr("requests.get", fake_get)
result = fetch_balance(PLATFORM_GET, "k", retry_count=2, timeout=10)
result = fetch_balance(FakeProvider(), "k", retry_count=2, timeout=10)
assert result.ok and result.balance == 5
assert len(calls) == 3
def test_5xx_all_fail(self, monkeypatch):
monkeypatch.setattr("requests.get", lambda *a, **k: FakeResponse(503))
result = fetch_balance(PLATFORM_GET, "k", retry_count=2, timeout=10)
result = fetch_balance(FakeProvider(), "k", retry_count=2, timeout=10)
assert not result.ok and not result.auth_error
def test_network_error_retries(self, monkeypatch):
@@ -157,7 +186,7 @@ class TestFetchBalance:
return FakeResponse(200, json_data={"data": {"balance": 8}})
monkeypatch.setattr("requests.get", fake_get)
result = fetch_balance(PLATFORM_GET, "k", retry_count=2, timeout=10)
result = fetch_balance(FakeProvider(), "k", retry_count=2, timeout=10)
assert result.ok and result.balance == 8
def test_other_4xx_no_retry(self, monkeypatch):
@@ -168,19 +197,19 @@ class TestFetchBalance:
return FakeResponse(404, text="not found")
monkeypatch.setattr("requests.get", fake_get)
result = fetch_balance(PLATFORM_GET, "k", retry_count=2, timeout=10)
result = fetch_balance(FakeProvider(), "k", retry_count=2, timeout=10)
assert not result.ok and not result.auth_error
assert "404" in result.error
assert len(calls) == 1
def test_bad_json_path(self, monkeypatch):
monkeypatch.setattr("requests.get", lambda *a, **k: FakeResponse(200, json_data={"x": 1}))
result = fetch_balance(PLATFORM_GET, "k", retry_count=0, timeout=10)
result = fetch_balance(FakeProvider(), "k", retry_count=0, timeout=10)
assert not result.ok
assert "路径不存在" in result.error
def test_invalid_json_body(self, monkeypatch):
monkeypatch.setattr("requests.get", lambda *a, **k: FakeResponse(200, text="<html>"))
result = fetch_balance(PLATFORM_GET, "k", retry_count=0, timeout=10)
result = fetch_balance(FakeProvider(), "k", retry_count=0, timeout=10)
assert not result.ok
assert "JSON" in result.error
+3 -4
View File
@@ -12,8 +12,7 @@ def _row(**overrides):
"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",
"provider_id": "deepseek", "platform_name": "P", "currency": "USD", "icon": "",
"interval_seconds": None, "retry_count": None, "timeout_seconds": None,
"platform_enabled": 1, "platform_note": "",
}
@@ -32,8 +31,8 @@ class TestSplit:
assert platform["name"] == "P"
assert platform["id"] == 2
assert platform["enabled"] == 1
assert platform["url"] == "http://x"
assert platform["balance_path"] == "b"
assert platform["provider_id"] == "deepseek"
assert platform["currency"] == "USD"
def test_plain_key_passthrough(self):
"""未编码的 key(历史数据)原样使用,不抛错。"""
+52
View File
@@ -0,0 +1,52 @@
"""内置平台适配器测试。"""
import pytest
from app.expr import ExprError
from app.providers import get_provider, list_providers
from app.providers.deepseek import DeepSeekProvider
DEEPSEEK_RESPONSE = {
"is_available": True,
"balance_infos": [
{
"currency": "CNY",
"total_balance": "1.34",
"granted_balance": "0.00",
"topped_up_balance": "1.34",
}
],
}
class TestDeepSeek:
def test_build_request(self):
prov = DeepSeekProvider()
url, headers, body = prov.build_request("sk-abc")
assert url == "https://api.deepseek.com/user/balance"
assert headers["Authorization"] == "Bearer sk-abc"
assert body is None
assert prov.method == "GET"
def test_extract_balance(self):
assert DeepSeekProvider().extract_balance(DEEPSEEK_RESPONSE) == 1.34
def test_extract_missing(self):
with pytest.raises(ExprError):
DeepSeekProvider().extract_balance({"is_available": False})
class TestRegistry:
def test_get_provider(self):
prov = get_provider("deepseek")
assert isinstance(prov, DeepSeekProvider)
def test_unknown_provider_none(self):
assert get_provider("nope") is None
def test_list_providers(self):
provs = list_providers()
ids = [p["id"] for p in provs]
assert "deepseek" in ids
dp = next(p for p in provs if p["id"] == "deepseek")
assert dp["name"] == "DeepSeek" and dp["currency"] == "CNY" and dp["icon"] == "DeepSeek"