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:
+32
-28
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
]
|
||||
@@ -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 中提取余额(数字),失败抛异常由上层记录。"""
|
||||
@@ -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/balance,Bearer 认证)"
|
||||
|
||||
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
@@ -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>Headers(JSON,值支持 {{apiKey}})</label><textarea id="pf-headers">${escapeHtml(headersStr)}</textarea></div>
|
||||
<div class="form-row full"><label>Body(POST 时使用,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) });
|
||||
|
||||
Reference in New Issue
Block a user