feat: AI 余额监控服务(平台/账号 CRUD、并发轮询、Telegram 阈值提醒)

This commit is contained in:
2026-08-04 23:51:06 +08:00
commit 17d2496a65
20 changed files with 2690 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.venv/
data/
__pycache__/
*.pyc
.pytest_cache/
*.log
+108
View File
@@ -0,0 +1,108 @@
# AI Balance Monitor
纯 Python 的多平台 AI 余额监控服务。自定义平台、自定义账号、Telegram 阈值提醒。
## 功能
- **自定义平台**HTTP 请求(GET/POST)、URL / Headers / Body 模板、`{{apiKey}}` 占位符嵌入、JSON 余额提取路径(支持 `data.balance``data[0].balance`)、货币单位、图标(@lobehub/icons 键)、监控间隔覆盖
- **账号管理**:每个平台下任意账号,API Key 以 Base64 存储
- **监控调度**:全局默认间隔 + 平台级覆盖,线程池并发轮询
- **失败处理**:网络错误/5xx 自动重试(默认 2 次),仍失败则跳过并记录;401/403 自动禁用该账号并通知
- **Telegram 提醒**:余额低于阈值提醒一次,恢复到阈值以上发恢复消息后重新武装;认证失败禁用也通知
- **余额历史**:每次拉取自动记录,界面可查看趋势
- **完整增删改**:平台 / 账号 / 设置均可通过界面或 REST API 管理
- **面板登录**:单密码保护,Token 有效期 7 天
## 快速开始
```bash
# 1. 安装依赖
python -m venv .venv
.venv\Scripts\pip install -r requirements.txt
# 2. 修改配置(可选)
# config.json:端口、密码、全局间隔、Telegram
# 3. 启动
.venv\Scripts\python main.py
# 浏览器打开 http://127.0.0.1:8000
```
## 配置(config.json
| 字段 | 说明 | 默认 |
| --- | --- | --- |
| `port` | 服务端口 | 8000 |
| `password` | 面板登录密码 | admin123(请修改) |
| `global_interval_seconds` | 全局监控间隔(秒) | 300 |
| `retry_count` | 网络错误默认重试次数 | 2 |
| `timeout_seconds` | 请求默认超时(秒) | 10 |
| `max_workers` | 并发轮询线程数 | 8 |
| `telegram_bot_token` | Telegram Bot Token | 空 |
| `telegram_chat_id` | 接收提醒的 Chat ID | 空 |
> 除 `port` 外,其余均可在界面「设置」页修改并即时生效。改端口需重启。
## 平台配置说明
- **URL / Headers / Body** 中统一用 `{{apiKey}}` 占位,添加账号后自动替换为该账号的 API Key
- **余额提取路径**:点路径或数组索引,如 `data.balance``data[0].balance``$.data.balance`
- **Headers / Body** 为 JSON 格式;Body 仅在 POST 时发送
- **间隔 / 重试 / 超时** 留空则使用全局默认值
- **图标**:填 @lobehub/icons 的键(如 `OpenAI``DeepSeek``Anthropic`),前端内置品牌色映射,未识别时显示平台首字母
示例(OpenAI 余额接口):
```
URL: https://api.openai.com/v1/dashboard/billing/credit_grants?api_key={{apiKey}}
Headers: {"Authorization": "Bearer {{apiKey}}"}
路径: data.total_granted
```
## 提醒机制
- 余额 **<** 阈值 → 发送「余额不足」提醒(一次),此后不再重复
- 余额 **≥** 阈值 → 发送「已恢复」提醒,并重新武装(下次再低于阈值会再次提醒)
- 提醒发送成功才更新状态;Telegram 未配置或发送失败时只记录日志,不会误发
- 401/403 → 自动禁用该账号并发送通知;修复后可在界面重新启用
## REST API 摘要
认证:`POST /api/login` 获取 token,之后请求带 `Authorization: Bearer <token>`
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| POST | `/api/login` / `/api/logout` | 登录 / 登出 |
| GET/POST | `/api/platforms` | 平台列表 / 创建 |
| PUT/DELETE | `/api/platforms/{id}` | 更新 / 删除(级联删账号) |
| GET/POST | `/api/accounts` | 账号列表 / 创建 |
| PUT/DELETE | `/api/accounts/{id}` | 更新 / 删除 |
| POST | `/api/accounts/{id}/check` | 立即检查 |
| GET | `/api/accounts/{id}/history` | 余额历史(`?limit=` |
| GET/PUT | `/api/settings` | 设置读取 / 更新 |
| POST | `/api/settings/password` | 修改密码 |
## 测试
```bash
.venv\Scripts\python -m pytest tests -q
```
## 目录结构
```
main.py 入口
config.json 配置
app/
api.py FastAPI 路由 + 认证
config.py 配置加载/保存
db.py SQLite 数据层
models.py Pydantic 模型
fetcher.py 请求执行 + 重试 + 路径提取
alert.py 阈值提醒状态机
monitor.py 并发调度器
telegram.py Telegram 发送
static/ 前端(HTML/CSS/JS
tests/ pytest 测试
data/ 运行时生成 SQLitegitignore
```
View File
+81
View File
@@ -0,0 +1,81 @@
"""阈值提醒状态机:低于阈值提醒一次,恢复后重新武装。
状态(accounts.alert_armed):
- 1 = 已武装,低于阈值时可提醒
- 0 = 已提醒,等待余额恢复到阈值以上
发送成功才切换状态;Telegram 失败则保持原状态,下次检查再试。
"""
from __future__ import annotations
import logging
from datetime import datetime
from app import db
from app.config import Config
from app.telegram import send_message
logger = logging.getLogger("monitor.alert")
def _fmt(v: float | None) -> str:
return "" if v is None else f"{v:g}"
def build_alert_message(kind: str, account: dict, platform: dict, balance: float | None) -> str:
pname = platform["name"]
aname = account["name"]
cur = platform["currency"]
th = account["threshold"]
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if kind == "below":
return (f"⚠️ 余额不足提醒\n"
f"平台:{pname}\n账号:{aname}\n"
f"当前余额:{_fmt(balance)} {cur}\n阈值:{_fmt(th)} {cur}\n"
f"时间:{now}")
if kind == "recovered":
return (f"✅ 余额已恢复\n"
f"平台:{pname}\n账号:{aname}\n"
f"当前余额:{_fmt(balance)} {cur}\n阈值:{_fmt(th)} {cur}\n"
f"时间:{now}")
if kind == "disabled":
return (f"🚫 账号已自动禁用\n"
f"平台:{pname}\n账号:{aname}\n"
f"原因:{account.get('last_error') or '认证失败'}")
return ""
def evaluate(account: dict, platform: dict, balance: float, cfg: Config) -> None:
"""拉取成功后调用:按状态机决定是否发送提醒,发送成功才更新武装状态。"""
threshold = account["threshold"]
armed = bool(account["alert_armed"])
below = balance < threshold
if below and armed:
kind = "below"
elif not below and not armed:
kind = "recovered"
else:
return
message = build_alert_message(kind, account, platform, balance)
if send_message(cfg.telegram_bot_token, cfg.telegram_chat_id, message):
new_armed = 0 if kind == "below" else 1
with db.get_conn() as conn:
conn.execute("UPDATE accounts SET alert_armed = ? WHERE id = ?", (new_armed, account["id"]))
conn.execute(
"INSERT INTO alert_log (account_id, type, message) VALUES (?, ?, ?)",
(account["id"], kind, message),
)
logger.info("已发送%s提醒: account=%s", kind, account["id"])
else:
logger.warning("提醒发送失败,保持原状态稍后重试: account=%s kind=%s", account["id"], kind)
def notify_disabled(account: dict, platform: dict, cfg: Config) -> None:
"""认证失败禁用账号后通知用户(不参与状态机)。"""
message = build_alert_message("disabled", account, platform, None)
if send_message(cfg.telegram_bot_token, cfg.telegram_chat_id, message):
with db.get_conn() as conn:
conn.execute(
"INSERT INTO alert_log (account_id, type, message) VALUES (?, ?, ?)",
(account["id"], "disabled", message),
)
+325
View File
@@ -0,0 +1,325 @@
"""FastAPI 应用:认证、平台/账号 CRUD、设置、静态前端。"""
from __future__ import annotations
import base64
import logging
import secrets
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import Depends, FastAPI, Header, HTTPException, Query
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import ValidationError
from app import db
from app.config import Config, load_config, save_config
from app.models import (
AccountCreate,
AccountUpdate,
LoginRequest,
PasswordChange,
PlatformCreate,
PlatformUpdate,
SettingsUpdate,
)
from app.monitor import Monitor
logger = logging.getLogger("monitor.api")
STATIC_DIR = Path(__file__).resolve().parent / "static"
# 内存 token 表:{token: 过期时间戳},重启即失效(单用户场景足够)
_tokens: dict[str, float] = {}
TOKEN_TTL = 7 * 24 * 3600
def _b64(v: str) -> str:
return base64.b64encode(v.encode("utf-8")).decode("ascii")
def _unb64(v: str) -> str:
try:
return base64.b64decode(v.encode("ascii")).decode("utf-8")
except Exception:
return v
def _account_out(row: dict) -> dict:
out = dict(row)
out["api_key"] = _unb64(out["api_key"]) # 前端展示/编辑需要明文(本地工具约定)
return out
def require_auth(authorization: str | None = Header(default=None)) -> None:
if authorization is None or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="未登录")
token = authorization.removeprefix("Bearer ").strip()
ts = _tokens.get(token)
if ts is None:
raise HTTPException(status_code=401, detail="登录已失效")
if ts < __import__("time").time():
_tokens.pop(token, None)
raise HTTPException(status_code=401, detail="登录已失效")
def create_app(cfg: Config | None = None) -> FastAPI:
cfg = cfg or load_config()
db.init_db()
monitor = Monitor(cfg)
@asynccontextmanager
async def lifespan(app: FastAPI):
monitor.start()
yield
monitor.stop()
app = FastAPI(title="AI Balance Monitor", lifespan=lifespan)
# ---------- 认证 ----------
@app.post("/api/login")
def login(body: LoginRequest):
import time
if body.password != cfg.password:
raise HTTPException(status_code=401, detail="密码错误")
token = secrets.token_urlsafe(32)
_tokens[token] = time.time() + TOKEN_TTL
return {"token": token}
@app.post("/api/logout")
def logout(authorization: str | None = Header(default=None)):
if authorization and authorization.startswith("Bearer "):
_tokens.pop(authorization.removeprefix("Bearer ").strip(), None)
return {"ok": True}
# ---------- 平台 ----------
@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
FROM platforms p ORDER BY p.id"""
).fetchall()
return [dict(r) for r in rows]
@app.post("/api/platforms", dependencies=[Depends(require_auth)])
def create_platform(body: PlatformCreate):
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
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,
),
)
pid = cur.lastrowid
except Exception as exc:
if "UNIQUE" in str(exc):
raise HTTPException(status_code=409, detail="平台名称已存在")
raise HTTPException(status_code=400, detail=str(exc))
return {"id": pid}
@app.put("/api/platforms/{pid}", dependencies=[Depends(require_auth)])
def update_platform(pid: int, body: PlatformUpdate):
fields = {}
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:
fields["retry_count"] = body.retry_count
if body.timeout_seconds is not None:
fields["timeout_seconds"] = body.timeout_seconds
if body.enabled is not None:
fields["enabled"] = int(body.enabled)
if body.note is not None:
fields["note"] = body.note
if not fields:
raise HTTPException(status_code=400, detail="没有可更新的字段")
try:
with db.get_conn() as conn:
cur = conn.execute(
"UPDATE platforms SET "
+ ", ".join(f"{k}=?" for k in fields)
+ ", updated_at=datetime('now','localtime') WHERE id=?",
(*fields.values(), pid),
)
except Exception as exc:
if "UNIQUE" in str(exc):
raise HTTPException(status_code=409, detail="平台名称已存在")
raise HTTPException(status_code=400, detail=str(exc))
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="平台不存在")
return {"id": pid}
@app.delete("/api/platforms/{pid}", dependencies=[Depends(require_auth)])
def delete_platform(pid: int):
with db.get_conn() as conn:
cur = conn.execute("DELETE FROM platforms WHERE id=?", (pid,))
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="平台不存在")
return {"ok": True}
# ---------- 账号 ----------
@app.get("/api/accounts", dependencies=[Depends(require_auth)])
def list_accounts(
platform_id: int | None = Query(default=None),
include_disabled: bool = Query(default=True),
):
sql = """SELECT a.*, p.name AS platform_name, p.currency, p.icon,
p.interval_seconds AS platform_interval
FROM accounts a JOIN platforms p ON p.id = a.platform_id"""
params: list = []
if platform_id is not None:
sql += " WHERE a.platform_id = ?"
params.append(platform_id)
if not include_disabled:
sql += " AND a.enabled = 1" if "WHERE" in sql else " WHERE a.enabled = 1"
sql += " ORDER BY a.id"
with db.get_conn() as conn:
rows = conn.execute(sql, params).fetchall()
return [_account_out(dict(r)) for r in rows]
@app.post("/api/accounts", dependencies=[Depends(require_auth)])
def create_account(body: AccountCreate):
with db.get_conn() as conn:
plat = conn.execute("SELECT id FROM platforms WHERE id=?", (body.platform_id,)).fetchone()
if plat is None:
raise HTTPException(status_code=404, detail="平台不存在")
try:
with db.get_conn() as conn:
cur = conn.execute(
"""INSERT INTO accounts (platform_id, name, api_key, threshold, enabled, note)
VALUES (?, ?, ?, ?, ?, ?)""",
(body.platform_id, body.name, _b64(body.api_key),
body.threshold, int(body.enabled), body.note),
)
aid = cur.lastrowid
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc))
return {"id": aid}
@app.put("/api/accounts/{aid}", dependencies=[Depends(require_auth)])
def update_account(aid: int, body: AccountUpdate):
fields = {}
if body.platform_id is not None:
fields["platform_id"] = body.platform_id
if body.name is not None:
fields["name"] = body.name
if body.api_key is not None:
fields["api_key"] = _b64(body.api_key)
if body.threshold is not None:
fields["threshold"] = body.threshold
if body.enabled is not None:
fields["enabled"] = int(body.enabled)
if body.note is not None:
fields["note"] = body.note
if not fields:
raise HTTPException(status_code=400, detail="没有可更新的字段")
if "platform_id" in fields:
with db.get_conn() as conn:
plat = conn.execute("SELECT id FROM platforms WHERE id=?", (fields["platform_id"],)).fetchone()
if plat is None:
raise HTTPException(status_code=404, detail="平台不存在")
with db.get_conn() as conn:
cur = conn.execute(
"UPDATE accounts SET "
+ ", ".join(f"{k}=?" for k in fields)
+ ", updated_at=datetime('now','localtime') WHERE id=?",
(*fields.values(), aid),
)
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="账号不存在")
return {"id": aid}
@app.delete("/api/accounts/{aid}", dependencies=[Depends(require_auth)])
def delete_account(aid: int):
with db.get_conn() as conn:
cur = conn.execute("DELETE FROM accounts WHERE id=?", (aid,))
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="账号不存在")
return {"ok": True}
@app.post("/api/accounts/{aid}/check", dependencies=[Depends(require_auth)])
def check_account_now(aid: int):
if not monitor.check_now(aid):
raise HTTPException(status_code=409, detail="账号未启用或正在检查中")
return {"ok": True}
@app.get("/api/accounts/{aid}/history", dependencies=[Depends(require_auth)])
def account_history(aid: int, limit: int = Query(default=30, ge=1, le=200)):
with db.get_conn() as conn:
rows = conn.execute(
"SELECT balance, checked_at FROM balance_history WHERE account_id=? "
"ORDER BY id DESC LIMIT ?",
(aid, limit),
).fetchall()
return [dict(r) for r in reversed(rows)]
# ---------- 设置 ----------
@app.get("/api/settings", dependencies=[Depends(require_auth)])
def get_settings():
return {
"port": cfg.port,
"global_interval_seconds": cfg.global_interval_seconds,
"retry_count": cfg.retry_count,
"timeout_seconds": cfg.timeout_seconds,
"max_workers": cfg.max_workers,
"telegram_bot_token": cfg.telegram_bot_token,
"telegram_chat_id": cfg.telegram_chat_id,
}
@app.put("/api/settings", dependencies=[Depends(require_auth)])
def update_settings(body: SettingsUpdate):
changed = False
for field in ("global_interval_seconds", "retry_count", "timeout_seconds",
"max_workers", "telegram_bot_token", "telegram_chat_id"):
value = getattr(body, field)
if value is not None and getattr(cfg, field) != value:
setattr(cfg, field, value)
changed = True
if changed:
save_config(cfg)
return {"ok": True}
@app.post("/api/settings/password", dependencies=[Depends(require_auth)])
def change_password(body: PasswordChange):
if body.old_password != cfg.password:
raise HTTPException(status_code=401, detail="旧密码错误")
cfg.password = body.new_password
save_config(cfg)
return {"ok": True}
# ---------- 静态前端 ----------
@app.get("/")
def index():
return FileResponse(STATIC_DIR / "index.html")
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
return app
+46
View File
@@ -0,0 +1,46 @@
"""配置加载与保存:config.json 是唯一配置来源。"""
from __future__ import annotations
import json
import threading
from dataclasses import dataclass, asdict
from pathlib import Path
CONFIG_PATH = Path(__file__).resolve().parent.parent / "config.json"
_lock = threading.Lock()
@dataclass
class Config:
"""全局配置(运行时可改的字段会在界面设置页暴露)。"""
port: int = 8000
password: str = "admin123"
global_interval_seconds: int = 300
retry_count: int = 2
timeout_seconds: int = 10
max_workers: int = 8
telegram_bot_token: str = ""
telegram_chat_id: str = ""
def load_config() -> Config:
with _lock:
if not CONFIG_PATH.exists():
cfg = Config()
save_config(cfg)
return cfg
raw = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
known = {f: getattr(Config, f) for f in Config.__dataclass_fields__}
data = {k: v for k, v in raw.items() if k in known}
return Config(**data)
def save_config(cfg: Config) -> None:
with _lock:
CONFIG_PATH.write_text(
json.dumps(asdict(cfg), ensure_ascii=False, indent=2),
encoding="utf-8",
)
+83
View File
@@ -0,0 +1,83 @@
"""SQLite 数据层:连接短生命周期(每次操作新建),线程安全。"""
from __future__ import annotations
import sqlite3
from pathlib import Path
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
DB_PATH = DATA_DIR / "monitor.db"
SCHEMA = """
CREATE TABLE IF NOT EXISTS platforms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
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,
enabled INTEGER NOT NULL DEFAULT 1,
note TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
);
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform_id INTEGER NOT NULL REFERENCES platforms(id) ON DELETE CASCADE,
name TEXT NOT NULL,
api_key TEXT NOT NULL,
threshold REAL NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
alert_armed INTEGER NOT NULL DEFAULT 1,
last_balance REAL,
last_status TEXT NOT NULL DEFAULT 'pending',
last_error TEXT NOT NULL DEFAULT '',
last_check_at TEXT,
note TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
);
CREATE TABLE IF NOT EXISTS balance_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
balance REAL NOT NULL,
checked_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
);
CREATE TABLE IF NOT EXISTS alert_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL,
type TEXT NOT NULL,
message TEXT NOT NULL,
sent_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
);
CREATE INDEX IF NOT EXISTS idx_accounts_platform ON accounts(platform_id);
CREATE INDEX IF NOT EXISTS idx_history_account ON balance_history(account_id, id);
CREATE INDEX IF NOT EXISTS idx_alert_account ON alert_log(account_id);
"""
def get_conn() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH, timeout=30)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
def init_db() -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
with get_conn() as conn:
conn.executescript(SCHEMA)
def rows_to_dicts(rows: list[sqlite3.Row]) -> list[dict]:
return [dict(r) for r in rows]
+118
View File
@@ -0,0 +1,118 @@
"""余额获取:模板渲染 → HTTP 请求(带重试)→ JSON 路径提取。"""
from __future__ import annotations
import json
import logging
import re
import time
from dataclasses import dataclass
import requests
logger = logging.getLogger("monitor.fetcher")
PLACEHOLDER = "{{apiKey}}"
_TOKEN_RE = re.compile(r"([^.\[]+)|\[(\d+)\]")
@dataclass
class FetchResult:
"""一次拉取的结果。"""
ok: bool
balance: float | None = None
auth_error: bool = False
status_code: int | None = None
error: str = ""
def render_template(text: str, api_key: str) -> str:
"""把模板中的 {{apiKey}} 替换为账号密钥。"""
return text.replace(PLACEHOLDER, api_key)
def extract_balance(data: object, path: str) -> float:
"""按点路径/数组索引提取余额,如 data.balance、data[0].balance、$.data[0].balance。
取到的值必须是数字或可转数字的字符串,否则抛 ValueError。
"""
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}")
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)
return url, headers, body
def fetch_balance(platform: dict, api_key: str, retry_count: int, timeout: int) -> FetchResult:
"""执行一次余额拉取。
策略:
- 401/403 → auth_error(不重试,由调用方禁用账号并通知)
- 其他 4xx → 直接失败(配置问题,不重试)
- 网络异常 / 5xx → 重试 retry_count 次,间隔 2s
- JSON 解析/路径提取失败 → 直接失败
"""
url, headers, body = _build_request(platform, api_key)
method = platform["method"]
attempts = retry_count + 1
for attempt in range(attempts):
try:
if method == "GET":
resp = requests.get(url, headers=headers, timeout=timeout)
else:
resp = requests.post(url, headers=headers, data=body, timeout=timeout)
except requests.RequestException as exc:
logger.warning("请求异常(%s/%s) %s: %s", attempt + 1, attempts, url, exc)
if attempt < attempts - 1:
time.sleep(2)
continue
if resp.status_code in (401, 403):
return FetchResult(ok=False, auth_error=True, status_code=resp.status_code,
error=f"HTTP {resp.status_code}(认证失败)")
if resp.status_code >= 500:
logger.warning("服务端错误(%s/%s) %s status=%s", attempt + 1, attempts, url, resp.status_code)
if attempt < attempts - 1:
time.sleep(2)
continue
if resp.status_code >= 400:
return FetchResult(ok=False, status_code=resp.status_code,
error=f"HTTP {resp.status_code}: {resp.text[:120]}")
try:
data = resp.json()
except ValueError:
return FetchResult(ok=False, status_code=resp.status_code,
error="响应不是合法 JSON")
try:
balance = extract_balance(data, platform["balance_path"])
except ValueError 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} 次后仍失败")
+90
View File
@@ -0,0 +1,90 @@
"""API 请求/响应模型(Pydantic)。"""
from __future__ import annotations
from pydantic import BaseModel, Field, model_validator
# ---------- 平台 ----------
class PlatformBase(BaseModel):
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)
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)
enabled: bool = True
note: str = Field(default="", max_length=200)
class PlatformCreate(PlatformBase):
pass
class PlatformUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=64)
currency: str | None = Field(default=None, min_length=1, 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)
enabled: bool | None = None
note: str | None = Field(default=None, max_length=200)
# ---------- 账号 ----------
class AccountCreate(BaseModel):
platform_id: int
name: str = Field(min_length=1, max_length=64)
api_key: str = Field(min_length=1)
threshold: float = Field(default=0, ge=0)
enabled: bool = True
note: str = Field(default="", max_length=200)
class AccountUpdate(BaseModel):
platform_id: int | None = None
name: str | None = Field(default=None, min_length=1, max_length=64)
api_key: str | None = Field(default=None, min_length=1)
threshold: float | None = Field(default=None, ge=0)
enabled: bool | None = None
note: str | None = Field(default=None, max_length=200)
@model_validator(mode="after")
def _at_least_one(self) -> "AccountUpdate":
fields = self.model_fields_set
if not fields:
raise ValueError("至少需要提供一个字段")
return self
# ---------- 系统 ----------
class LoginRequest(BaseModel):
password: str
class SettingsUpdate(BaseModel):
global_interval_seconds: int | None = Field(default=None, ge=10, le=86400)
retry_count: int | None = Field(default=None, ge=0, le=10)
timeout_seconds: int | None = Field(default=None, ge=1, le=120)
max_workers: int | None = Field(default=None, ge=1, le=64)
telegram_bot_token: str | None = None
telegram_chat_id: str | None = None
class PasswordChange(BaseModel):
old_password: str
new_password: str = Field(min_length=4)
+191
View File
@@ -0,0 +1,191 @@
"""监控调度器:线程池并发轮询所有启用账号。
调度逻辑:
- 每个账号记录 next_check_at(epoch 秒),间隔取平台覆盖值或全局默认
- 调度线程每秒扫描一次,到期的账号且无进行中任务则提交线程池
- 任务完成后把 next_check_at 更新为 now + interval,避免重复执行
"""
from __future__ import annotations
import logging
import threading
import time
from concurrent.futures import Future, ThreadPoolExecutor
from app import db
from app.alert import evaluate, notify_disabled
from app.config import Config
from app.fetcher import fetch_balance
logger = logging.getLogger("monitor.scheduler")
# JOIN 查询后拆分为账号/平台两个 dict 用到的字段
_ACCOUNT_FIELDS = [
"id", "platform_id", "name", "api_key", "threshold", "enabled",
"alert_armed", "last_balance", "last_status", "last_error",
"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",
]
_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
FROM accounts a JOIN platforms p ON p.id = a.platform_id
"""
def _split(row: dict) -> tuple[dict, dict]:
"""把 JOIN 行拆成 (account, platform) 两个 dict。"""
account = {k: row[k] for k in _ACCOUNT_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["id"] = platform.pop("platform_id")
platform["enabled"] = platform.pop("platform_enabled")
return account, platform
def _account_interval(platform: dict, cfg: Config) -> float:
return float(platform.get("interval_seconds") or cfg.global_interval_seconds)
class Monitor:
def __init__(self, cfg: Config) -> None:
self.cfg = cfg
self._stop = threading.Event()
self._pool: ThreadPoolExecutor | None = None
self._thread: threading.Thread | None = None
self._next_check: dict[int, float] = {}
self._inflight: set[int] = set()
self._lock = threading.Lock()
# ---------- 生命周期 ----------
def start(self) -> None:
if self._thread is not None:
return
self._pool = ThreadPoolExecutor(max_workers=self.cfg.max_workers, thread_name_prefix="check")
self._thread = threading.Thread(target=self._loop, name="monitor-loop", daemon=True)
self._thread.start()
logger.info(
"监控调度已启动,全局间隔 %ss,并发 %s",
self.cfg.global_interval_seconds,
self.cfg.max_workers,
)
def stop(self) -> None:
self._stop.set()
if self._thread is not None:
self._thread.join(timeout=5)
if self._pool is not None:
self._pool.shutdown(wait=False, cancel_futures=True)
logger.info("监控调度已停止")
# ---------- 调度循环 ----------
def _loop(self) -> None:
while not self._stop.is_set():
try:
self._dispatch()
except Exception:
logger.exception("调度循环异常")
self._stop.wait(1)
def _dispatch(self) -> None:
with db.get_conn() as conn:
rows = conn.execute(
_ACCOUNT_SQL + " WHERE a.enabled = 1 AND p.enabled = 1"
).fetchall()
now = time.time()
for row in rows:
data = dict(row)
aid = data["id"]
with self._lock:
if aid in self._inflight:
continue
if now < self._next_check.get(aid, 0):
continue
self._inflight.add(aid)
assert self._pool is not None
future = self._pool.submit(self._check_one, data)
future.add_done_callback(lambda f, a=aid: self._on_done(a, f))
def _on_done(self, account_id: int, future: Future) -> None:
with self._lock:
self._inflight.discard(account_id)
if future.exception():
logger.error("账号 %s 检查任务异常: %s", account_id, future.exception())
# ---------- 单次检查 ----------
def _check_one(self, row: dict) -> None:
cfg = self.cfg
account, platform = _split(row)
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))
with db.get_conn() as conn:
if result.ok:
conn.execute(
"UPDATE accounts SET last_balance=?, last_status='ok', last_error='', "
"last_check_at=datetime('now','localtime') WHERE id=?",
(result.balance, account["id"]),
)
conn.execute(
"INSERT INTO balance_history (account_id, balance) VALUES (?, ?)",
(account["id"], result.balance),
)
elif result.auth_error:
conn.execute(
"UPDATE accounts SET enabled=0, last_status='disabled', last_error=?, "
"last_check_at=datetime('now','localtime') WHERE id=?",
(result.error, account["id"]),
)
else:
conn.execute(
"UPDATE accounts SET last_status='error', last_error=?, "
"last_check_at=datetime('now','localtime') WHERE id=?",
(result.error, account["id"]),
)
if result.auth_error:
notify_disabled(account, platform, cfg)
elif result.ok:
evaluate(account, platform, result.balance, cfg)
# 调度:无论成功失败,按间隔排下一次
with self._lock:
self._next_check[account["id"]] = time.time() + _account_interval(platform, cfg)
# ---------- 手动触发 ----------
def check_now(self, account_id: int) -> bool:
"""API 手动立即检查;账号/平台未启用或已在检查中则返回 False。"""
with db.get_conn() as conn:
row = conn.execute(
_ACCOUNT_SQL + " WHERE a.id = ? AND a.enabled = 1 AND p.enabled = 1",
(account_id,),
).fetchone()
if row is None:
return False
data = dict(row)
with self._lock:
if account_id in self._inflight:
return False
self._inflight.add(account_id)
assert self._pool is not None
future = self._pool.submit(self._check_one, data)
future.add_done_callback(lambda f, a=account_id: self._on_done(a, f))
return True
+559
View File
@@ -0,0 +1,559 @@
/* AI Balance Monitor 前端逻辑 */
(() => {
"use strict";
const TOKEN_KEY = "abm_token";
let token = localStorage.getItem(TOKEN_KEY) || "";
let accounts = [];
let platforms = [];
let settings = null;
let refreshTimer = null;
/* ---------- 工具 ---------- */
async function api(path, opts = {}) {
const headers = Object.assign({ "Content-Type": "application/json" }, opts.headers || {});
if (token) headers["Authorization"] = "Bearer " + token;
const resp = await fetch("/api" + path, Object.assign({}, opts, { headers }));
if (resp.status === 401 && !path.startsWith("/login")) {
logout();
throw new Error("登录已失效");
}
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.detail || ("HTTP " + resp.status));
return data;
}
function toast(msg, kind = "ok") {
const el = document.createElement("div");
el.className = "toast " + kind;
el.innerHTML = `<span class="toast-dot"></span><span>${escapeHtml(msg)}</span>`;
document.getElementById("toast-root").appendChild(el);
requestAnimationFrame(() => el.classList.add("show"));
setTimeout(() => {
el.classList.remove("show");
setTimeout(() => el.remove(), 250);
}, 2600);
}
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]));
}
function fmtBalance(v) {
if (v === null || v === undefined) return null;
const n = Number(v);
if (!Number.isFinite(n)) return null;
return n >= 100 ? n.toFixed(2) : n >= 1 ? n.toFixed(3) : n.toFixed(4);
}
function fmtTime(s) {
if (!s) return "—";
return s;
}
/* ---------- 品牌图标映射(@lobehub/icons 键 → 品牌色) ---------- */
const BRAND_COLORS = {
OpenAI: "#10A37F", GPT: "#10A37F", AzureOpenAI: "#0078D4",
Anthropic: "#D97757", Claude: "#D97757",
Google: "#4285F4", Gemini: "#4285F4", GoogleAIStudio: "#4285F4",
DeepSeek: "#4D6BFE",
MoonshotAI: "#1E1E1E", Kimi: "#1E1E1E",
ZhipuAI: "#3859FF", ChatGLM: "#3859FF",
Qwen: "#615CED", Tongyi: "#615CED", Aliyun: "#FF6A00",
Groq: "#F55036",
MistralAI: "#FF7000", Mistral: "#FF7000",
Meta: "#0668E1", Llama: "#0668E1",
XAI: "#1B1B1B", XAIGrok: "#1B1B1B", Grok: "#1B1B1B",
OpenRouter: "#B35CFF",
Perplexity: "#1FB8CD",
Cohere: "#39594D",
HuggingFace: "#FFD21E",
TogetherAI: "#FFB1D9",
Replicate: "#F26E1E",
Baidu: "#2932E1", Qianfan: "#2932E1",
MiniMax: "#FFC80A",
SiliconFlow: "#3E9E6B",
FireworksAI: "#FF2D20",
Cerebras: "#00D1B2",
NVIDIA: "#76B900",
Volcengine: "#3370FF", Doubao: "#3370FF",
TencentCloud: "#006EFF", Hunyuan: "#006EFF",
};
function brandStyle(iconKey) {
const color = BRAND_COLORS[iconKey] || "#8A8F98";
const abbr = (iconKey || "").slice(0, 2).toUpperCase() || "AI";
return { color, abbr };
}
/* ---------- 视图/状态 ---------- */
function switchView(name) {
document.querySelectorAll(".view").forEach((v) => v.classList.add("hidden"));
document.getElementById("view-" + name).classList.remove("hidden");
document.querySelectorAll(".tab").forEach((t) => {
t.classList.toggle("active", t.dataset.view === name);
});
if (name === "platforms") renderPlatforms();
if (name === "settings") loadSettings();
}
async function refresh() {
try {
const [acc, plat] = await Promise.all([api("/accounts"), api("/platforms")]);
accounts = acc;
platforms = plat;
renderAccounts();
if (!document.getElementById("view-platforms").classList.contains("hidden")) renderPlatforms();
const d = new Date();
document.getElementById("refresh-time").textContent =
d.toLocaleTimeString("zh-CN", { hour12: false });
} catch (e) {
if (e.message !== "登录已失效") console.warn("刷新失败", e);
}
}
/* ---------- 渲染:账号 ---------- */
function statusInfo(a) {
if (!a.enabled) return { cls: "disabled", text: "已禁用" };
if (a.last_status === "disabled") return { cls: "disabled", text: "已禁用" };
if (a.last_status === "error") return { cls: "err", text: "异常" };
if (a.last_status === "ok") {
if (a.last_balance !== null && a.last_balance < a.threshold) {
return { cls: "warn", text: "低于阈值" };
}
return { cls: "ok", text: "正常" };
}
return { cls: "pending", text: "待检查" };
}
function accountCard(a, idx) {
const st = statusInfo(a);
const plat = platforms.find((p) => p.id === a.platform_id) || {};
const b = brandStyle(plat.icon || "");
const bal = fmtBalance(a.last_balance);
const th = fmtBalance(a.threshold);
const below = a.last_status === "ok" && bal !== null && Number(a.last_balance) < a.threshold;
const cardCls = ["account-card", st.cls === "warn" ? "below" : "", st.cls === "err" ? "error" : "",
st.cls === "disabled" ? "disabled" : ""].join(" ").trim();
const pct = th && bal !== null ? Math.min(100, (Number(a.last_balance) / Number(th)) * 100) : 100;
const fillCls = below ? "threshold-fill below" : "threshold-fill";
return `
<div class="${cardCls}" style="animation-delay:${Math.min(idx, 12) * 40}ms">
<div class="card-top">
<div class="badge" style="background:${b.color}">${escapeHtml(b.abbr)}</div>
<div class="card-title">
<div class="card-platform">${escapeHtml(plat.name || "未知平台")} · ${escapeHtml(plat.currency || "")}</div>
<div class="card-name" title="${escapeHtml(a.name)}">${escapeHtml(a.name)}</div>
</div>
</div>
<div class="balance-row">
${bal !== null
? `<span class="balance-value ${below ? "below" : ""}">${bal}</span><span class="balance-currency">${escapeHtml(plat.currency || "")}</span>`
: `<span class="balance-empty">—</span>`}
</div>
<div class="threshold-bar" title="阈值 ${th ?? "—"} ${escapeHtml(plat.currency || "")}">
<div class="${fillCls}" style="width:${bal === null ? 0 : pct}%"></div>
</div>
<div class="card-meta">
<span class="badge-pill ${st.cls}">${st.text}</span>
<span>上次检查:${fmtTime(a.last_check_at)}</span>
${a.last_error ? `<span style="color:var(--err)">${escapeHtml(a.last_error)}</span>` : ""}
</div>
<div class="card-actions">
<button class="btn sm" data-act="history" data-id="${a.id}">历史</button>
<button class="btn sm" data-act="check" data-id="${a.id}" ${!a.enabled ? "disabled" : ""}>立即检查</button>
<button class="btn sm" data-act="edit" data-id="${a.id}">编辑</button>
<button class="btn sm danger" data-act="del" data-id="${a.id}">删除</button>
</div>
</div>`;
}
function renderAccounts() {
const grid = document.getElementById("account-grid");
const stats = { total: accounts.length, ok: 0, below: 0, error: 0, disabled: 0, pending: 0 };
accounts.forEach((a) => {
const st = statusInfo(a);
if (st.cls === "ok") stats.ok++;
else if (st.cls === "warn") stats.below++;
else if (st.cls === "err") stats.error++;
else if (st.cls === "disabled") stats.disabled++;
else stats.pending++;
});
document.getElementById("stat-total").textContent = stats.total;
document.getElementById("stat-ok").textContent = stats.ok;
document.getElementById("stat-below").textContent = stats.below;
document.getElementById("stat-error").textContent = stats.error;
document.getElementById("stat-disabled").textContent = stats.disabled;
document.getElementById("stat-pending").textContent = stats.pending;
grid.innerHTML = accounts.map(accountCard).join("");
document.getElementById("empty-hint").classList.toggle("hidden", accounts.length > 0);
}
/* ---------- 渲染:平台 ---------- */
function renderPlatforms() {
const list = document.getElementById("platform-list");
if (platforms.length === 0) {
list.innerHTML = `<div class="empty-hint">还没有平台,点击右上角「+ 添加平台」创建。</div>`;
return;
}
list.innerHTML = platforms.map((p, i) => {
const b = brandStyle(p.icon || "");
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>
<div class="platform-info">
<div class="platform-name">${escapeHtml(p.name)}
${p.enabled ? "" : '<span class="badge-pill disabled">已停用</span>'}
</div>
<div class="platform-meta">
${escapeHtml(p.method)} ${escapeHtml(p.url)} ·
提取 ${escapeHtml(p.balance_path)} ·
${escapeHtml(p.currency)} ·
间隔 ${p.interval_seconds || "全局"}s ·
${p.account_count} 个账号
</div>
</div>
<div class="platform-actions">
<button class="btn sm" data-act="toggle" data-id="${p.id}">${p.enabled ? "停用" : "启用"}</button>
<button class="btn sm" data-act="edit" data-id="${p.id}">编辑</button>
<button class="btn sm danger" data-act="del" data-id="${p.id}">删除</button>
</div>
</div>`;
}).join("");
}
/* ---------- 模态框 ---------- */
function openModal(html, onMount) {
const root = document.getElementById("modal-root");
const mask = document.createElement("div");
mask.className = "modal-mask";
mask.innerHTML = html;
mask.addEventListener("click", (e) => { if (e.target === mask) closeModal(mask); });
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeModal(mask); }, { once: true });
root.appendChild(mask);
if (onMount) onMount(mask);
return mask;
}
function closeModal(mask) {
if (mask) mask.remove();
}
function confirmDialog(text, onOk) {
const mask = openModal(`
<div class="modal confirm-modal">
<h3>确认操作</h3>
<p class="confirm-text">${text}</p>
<div class="modal-actions">
<button class="btn" data-cancel>取消</button>
<button class="btn danger" data-ok>确认删除</button>
</div>
</div>`);
mask.querySelector("[data-cancel]").onclick = () => closeModal(mask);
mask.querySelector("[data-ok]").onclick = () => { closeModal(mask); onOk(); };
}
/* 平台表单 */
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}';
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>
<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-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}} 占位,添加账号时自动替换。</p>
<p class="modal-error" id="pf-error"></p>
<div class="modal-actions">
<button class="btn" data-cancel>取消</button>
<button class="btn primary" id="pf-save">保存</button>
</div>
</div>`, (mask) => {
mask.querySelector("[data-cancel]").onclick = () => closeModal(mask);
mask.querySelector("#pf-save").onclick = async () => {
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"),
note: val("#pf-note"),
interval_seconds: numOrNull("#pf-interval"), retry_count: numOrNull("#pf-retry"),
timeout_seconds: numOrNull("#pf-timeout"), enabled: p ? p.enabled : true,
};
let headers = {};
try { headers = JSON.parse(val("#pf-headers") || "{}"); }
catch (e) { err("#pf-error", "Headers 不是合法 JSON"); return; }
payload.headers = headers;
if (!payload.name || !payload.url || !payload.balance_path) { err("#pf-error", "名称 / URL / 提取路径必填"); return; }
if (payload.method === "POST") {
try { JSON.parse(val("#pf-body") || "{}"); } catch (e) { err("#pf-error", "Body 不是合法 JSON"); return; }
}
payload.body = val("#pf-body");
try {
if (isEdit) await api("/platforms/" + p.id, { method: "PUT", body: JSON.stringify(payload) });
else await api("/platforms", { method: "POST", body: JSON.stringify(payload) });
closeModal(mask);
toast(isEdit ? "平台已更新" : "平台已添加");
await refresh();
switchView("platforms");
} catch (e) { err("#pf-error", e.message); }
};
});
}
/* 账号表单 */
function accountForm(a) {
const isEdit = !!a;
const v = a || {};
const opts = platforms.map((p) =>
`<option value="${p.id}" ${v.platform_id === p.id ? "selected" : ""}>${escapeHtml(p.name)}</option>`).join("");
openModal(`
<div class="modal">
<h3>${isEdit ? "编辑账号" : "添加账号"}</h3>
<div class="form-grid">
<div class="form-row"><label>所属平台 *</label><select id="ac-platform">${opts}</select></div>
<div class="form-row"><label>账号名称 *</label><input id="ac-name" value="${escapeHtml(v.name || "")}" placeholder="如 主账号"></div>
<div class="form-row full"><label>API Key *</label><input id="ac-key" type="password" value="${escapeHtml(v.api_key || "")}" placeholder="sk-..."></div>
<div class="form-row"><label>提醒阈值(余额低于此值提醒)</label><input id="ac-threshold" type="number" step="any" min="0" value="${v.threshold ?? 0}"></div>
<div class="form-row">
<label>启用监控</label>
<select id="ac-enabled">
<option value="1" ${v.enabled !== false ? "selected" : ""}>启用</option>
<option value="0" ${v.enabled === false ? "selected" : ""}>停用</option>
</select>
</div>
<div class="form-row full"><label>备注</label><input id="ac-note" value="${escapeHtml(v.note || "")}"></div>
</div>
<p class="modal-error" id="ac-error"></p>
<div class="modal-actions">
<button class="btn" data-cancel>取消</button>
<button class="btn primary" id="ac-save">保存</button>
</div>
</div>`, (mask) => {
mask.querySelector("[data-cancel]").onclick = () => closeModal(mask);
mask.querySelector("#ac-save").onclick = async () => {
const payload = {
platform_id: Number(val("#ac-platform")), name: val("#ac-name"),
api_key: val("#ac-key"), threshold: Number(val("#ac-threshold") || 0),
enabled: val("#ac-enabled") === "1", note: val("#ac-note"),
};
if (!payload.platform_id || !payload.name || !payload.api_key) { err("#ac-error", "平台 / 名称 / API Key 必填"); return; }
try {
if (isEdit) await api("/accounts/" + a.id, { method: "PUT", body: JSON.stringify(payload) });
else await api("/accounts", { method: "POST", body: JSON.stringify(payload) });
closeModal(mask);
toast(isEdit ? "账号已更新" : "账号已添加");
await refresh();
switchView("monitor");
} catch (e) { err("#ac-error", e.message); }
};
});
}
/* 历史弹窗 */
async function showHistory(accountId) {
const acc = accounts.find((a) => a.id === accountId);
if (!acc) return;
const plat = platforms.find((p) => p.id === acc.platform_id) || {};
const mask = openModal(`
<div class="modal history-modal">
<h3>余额历史 · ${escapeHtml(acc.name)}</h3>
<div id="hist-body" style="color:var(--text-3);font-size:12px">加载中…</div>
</div>`);
try {
const hist = await api(`/accounts/${accountId}/history?limit=30`);
const body = mask.querySelector("#hist-body");
if (hist.length === 0) {
body.innerHTML = `<div style="padding:20px 0;text-align:center">暂无记录(检查成功后自动记录)</div>`;
return;
}
const max = Math.max(...hist.map((h) => h.balance), 1e-9);
const bars = hist.map((h) =>
`<i title="${fmtBalance(h.balance)} ${escapeHtml(plat.currency || "")} @ ${escapeHtml(h.checked_at)}" style="height:${Math.max(6, (h.balance / max) * 100)}%"></i>`).join("");
const items = [...hist].reverse().map((h) =>
`<div class="history-item"><span>${escapeHtml(h.checked_at)}</span><span class="h-bal">${fmtBalance(h.balance)} ${escapeHtml(plat.currency || "")}</span></div>`).join("");
body.innerHTML = `<div class="spark">${bars}</div>${items}`;
} catch (e) {
mask.querySelector("#hist-body").textContent = "加载失败:" + e.message;
}
}
/* ---------- 设置 ---------- */
async function loadSettings() {
try {
settings = await api("/settings");
document.getElementById("set-interval").value = settings.global_interval_seconds;
document.getElementById("set-retry").value = settings.retry_count;
document.getElementById("set-timeout").value = settings.timeout_seconds;
document.getElementById("set-workers").value = settings.max_workers;
document.getElementById("set-token").value = settings.telegram_bot_token;
document.getElementById("set-chatid").value = settings.telegram_chat_id;
} catch (e) { toast(e.message, "err"); }
}
/* ---------- 事件 ---------- */
function val(id) { return document.getElementById(id).value.trim(); }
function numOrNull(id) { const s = val(id); return s === "" ? null : Number(s); }
function err(id, msg) { document.getElementById(id).textContent = msg; }
function bindEvents() {
document.getElementById("login-btn").onclick = doLogin;
document.getElementById("login-password").addEventListener("keydown", (e) => { if (e.key === "Enter") doLogin(); });
document.getElementById("logout-btn").onclick = logout;
document.querySelectorAll(".tab").forEach((t) => t.addEventListener("click", () => switchView(t.dataset.view)));
document.getElementById("add-platform-btn").onclick = () => platformForm(null);
document.getElementById("save-settings-btn").onclick = saveSettings;
document.getElementById("change-pwd-btn").onclick = changePassword;
document.getElementById("account-grid").addEventListener("click", onAccountAction);
document.getElementById("platform-list").addEventListener("click", onPlatformAction);
}
async function doLogin() {
const pw = val("login-password");
if (!pw) return;
try {
const data = await api("/login", { method: "POST", body: JSON.stringify({ password: pw }) });
token = data.token;
localStorage.setItem(TOKEN_KEY, token);
document.getElementById("login-error").textContent = "";
document.getElementById("login-view").classList.add("hidden");
document.getElementById("app-view").classList.remove("hidden");
await refresh();
startRefreshTimer();
} catch (e) {
document.getElementById("login-error").textContent = e.message;
}
}
function logout() {
if (token) api("/logout", { method: "POST" }).catch(() => {});
token = "";
localStorage.removeItem(TOKEN_KEY);
stopRefreshTimer();
document.getElementById("app-view").classList.add("hidden");
document.getElementById("login-view").classList.remove("hidden");
document.getElementById("login-password").value = "";
}
function startRefreshTimer() {
stopRefreshTimer();
refreshTimer = setInterval(refresh, 10000);
}
function stopRefreshTimer() { if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; } }
async function onAccountAction(e) {
const btn = e.target.closest("[data-act]");
if (!btn) return;
const id = Number(btn.dataset.id);
const act = btn.dataset.act;
if (act === "edit") accountForm(accounts.find((a) => a.id === id));
else if (act === "del") {
const a = accounts.find((x) => x.id === id);
confirmDialog(`确定删除账号「${escapeHtml(a ? a.name : id)}」?其历史记录将一并删除,此操作不可恢复。`, async () => {
try { await api("/accounts/" + id, { method: "DELETE" }); toast("账号已删除"); await refresh(); }
catch (err2) { toast(err2.message, "err"); }
});
} else if (act === "check") {
btn.disabled = true;
try { await api(`/accounts/${id}/check`, { method: "POST" }); toast("已开始检查"); }
catch (err2) { toast(err2.message, "err"); btn.disabled = false; }
setTimeout(refresh, 1500);
} else if (act === "history") {
showHistory(id);
}
}
async function onPlatformAction(e) {
const btn = e.target.closest("[data-act]");
if (!btn) return;
const id = Number(btn.dataset.id);
const act = btn.dataset.act;
const p = platforms.find((x) => x.id === id);
if (act === "edit") platformForm(p);
else if (act === "toggle") {
try {
await api("/platforms/" + id, { method: "PUT", body: JSON.stringify({ enabled: !p.enabled }) });
toast(p.enabled ? "平台已停用" : "平台已启用");
await refresh();
renderPlatforms();
} catch (err2) { toast(err2.message, "err"); }
} else if (act === "del") {
confirmDialog(`确定删除平台「${escapeHtml(p ? p.name : id)}」?<br>其下 ${p ? p.account_count : 0} 个账号及历史记录将一并删除,此操作不可恢复。`, async () => {
try { await api("/platforms/" + id, { method: "DELETE" }); toast("平台已删除"); await refresh(); switchView("platforms"); }
catch (err2) { toast(err2.message, "err"); }
});
}
}
async function saveSettings() {
try {
await api("/settings", {
method: "PUT",
body: JSON.stringify({
global_interval_seconds: Number(val("set-interval")),
retry_count: Number(val("set-retry")),
timeout_seconds: Number(val("set-timeout")),
max_workers: Number(val("set-workers")),
telegram_bot_token: val("set-token"),
telegram_chat_id: val("set-chatid"),
}),
});
toast("设置已保存");
await loadSettings();
} catch (e) { toast(e.message, "err"); }
}
async function changePassword() {
try {
await api("/settings/password", {
method: "POST",
body: JSON.stringify({ old_password: val("set-oldpwd"), new_password: val("set-newpwd") }),
});
document.getElementById("set-oldpwd").value = "";
document.getElementById("set-newpwd").value = "";
toast("密码已修改");
} catch (e) { toast(e.message, "err"); }
}
/* ---------- 启动 ---------- */
function init() {
bindEvents();
if (token) {
document.getElementById("login-view").classList.add("hidden");
document.getElementById("app-view").classList.remove("hidden");
refresh().then(() => startRefreshTimer()).catch(() => logout());
}
}
init();
})();
+128
View File
@@ -0,0 +1,128 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Balance Monitor</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<!-- 登录视图 -->
<div id="login-view" class="login-view">
<div class="login-card">
<div class="login-logo"></div>
<h1>AI Balance Monitor</h1>
<p class="login-sub">多平台 AI 余额监控面板</p>
<input id="login-password" type="password" placeholder="管理密码" autocomplete="current-password">
<button id="login-btn" class="btn primary block">登录</button>
<p id="login-error" class="login-error"></p>
</div>
</div>
<!-- 主应用 -->
<div id="app-view" class="app-view hidden">
<header class="topbar">
<div class="brand">
<span class="brand-dot"></span>
<span class="brand-name">AI Balance Monitor</span>
</div>
<nav class="tabs">
<button class="tab active" data-view="monitor">监控</button>
<button class="tab" data-view="platforms">平台</button>
<button class="tab" data-view="settings">设置</button>
</nav>
<div class="topbar-right">
<span id="refresh-time" class="refresh-time"></span>
<button id="logout-btn" class="btn ghost sm">登出</button>
</div>
</header>
<main class="content">
<!-- 监控视图 -->
<section id="view-monitor" class="view">
<div class="stats-bar">
<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 warn"><span id="stat-below">0</span><label>低于阈值</label></div>
<div class="stat err"><span id="stat-error">0</span><label>异常</label></div>
<div class="stat disabled"><span id="stat-disabled">0</span><label>已禁用</label></div>
<div class="stat pending"><span id="stat-pending">0</span><label>待检查</label></div>
</div>
<div id="account-grid" class="account-grid"></div>
<div id="empty-hint" class="empty-hint hidden">
<p>还没有账号。先到「平台」页添加一个平台,再回来添加账号。</p>
</div>
</section>
<!-- 平台视图 -->
<section id="view-platforms" class="view hidden">
<div class="view-head">
<h2>平台配置</h2>
<button id="add-platform-btn" class="btn primary">+ 添加平台</button>
</div>
<div id="platform-list" class="platform-list"></div>
</section>
<!-- 设置视图 -->
<section id="view-settings" class="view hidden">
<div class="view-head"><h2>设置</h2></div>
<div class="settings-card">
<h3>监控</h3>
<div class="form-row">
<label>全局监控间隔(秒)</label>
<input id="set-interval" type="number" min="10">
</div>
<div class="form-row">
<label>默认重试次数</label>
<input id="set-retry" type="number" min="0" max="10">
</div>
<div class="form-row">
<label>默认超时(秒)</label>
<input id="set-timeout" type="number" min="1" max="120">
</div>
<div class="form-row">
<label>并发数</label>
<input id="set-workers" type="number" min="1" max="64">
</div>
</div>
<div class="settings-card">
<h3>Telegram 提醒</h3>
<div class="form-row">
<label>Bot Token</label>
<input id="set-token" type="password" placeholder="123456:ABC-DEF...">
</div>
<div class="form-row">
<label>Chat ID</label>
<input id="set-chatid" type="text" placeholder="如 123456789">
</div>
</div>
<div class="settings-card">
<h3>安全</h3>
<div class="form-row">
<label>旧密码</label>
<input id="set-oldpwd" type="password" autocomplete="current-password">
</div>
<div class="form-row">
<label>新密码(≥4 位)</label>
<input id="set-newpwd" type="password" autocomplete="new-password">
</div>
</div>
<div class="settings-actions">
<button id="save-settings-btn" class="btn primary">保存设置</button>
<button id="change-pwd-btn" class="btn">修改密码</button>
<span class="settings-hint">端口需修改 config.json 后重启生效。</span>
</div>
</section>
</main>
</div>
<!-- 模态框 -->
<div id="modal-root"></div>
<div id="toast-root"></div>
<script src="/static/app.js"></script>
</body>
</html>
+409
View File
@@ -0,0 +1,409 @@
/* ===== 设计基础 ===== */
:root {
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--radius: 12px;
--radius-sm: 8px;
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", system-ui, sans-serif;
--bg: #f5f6f8;
--bg-elev: #ffffff;
--bg-elev-2: #f0f1f4;
--border: rgba(15, 23, 42, 0.08);
--border-strong: rgba(15, 23, 42, 0.14);
--text: #1a1d24;
--text-2: #5b6270;
--text-3: #9aa1ad;
--accent: #3b6ef6;
--accent-strong: #2f5ae0;
--accent-soft: rgba(59, 110, 246, 0.1);
--ok: #16a34a;
--ok-soft: rgba(22, 163, 74, 0.12);
--warn: #d97706;
--warn-soft: rgba(217, 119, 6, 0.13);
--err: #dc2626;
--err-soft: rgba(220, 38, 38, 0.11);
--disabled-soft: rgba(100, 116, 139, 0.14);
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.05);
--shadow-md: 0 4px 16px rgba(15, 23, 42, 0.08);
--shadow-lg: 0 12px 40px rgba(15, 23, 42, 0.16);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0e1015;
--bg-elev: #171a21;
--bg-elev-2: #1e222b;
--border: rgba(255, 255, 255, 0.07);
--border-strong: rgba(255, 255, 255, 0.13);
--text: #e8eaef;
--text-2: #9aa1ad;
--text-3: #6b7280;
--accent: #5b8cff;
--accent-strong: #3b6ef6;
--accent-soft: rgba(91, 140, 255, 0.14);
--ok: #34d399;
--ok-soft: rgba(52, 211, 153, 0.13);
--warn: #fbbf24;
--warn-soft: rgba(251, 191, 36, 0.13);
--err: #f87171;
--err-soft: rgba(248, 113, 113, 0.13);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.35);
--shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.5);
}
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.hidden { display: none !important; }
/* ===== 通用组件 ===== */
.btn {
font-family: inherit;
font-size: 13px;
font-weight: 500;
padding: 8px 14px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-strong);
background: var(--bg-elev);
color: var(--text);
cursor: pointer;
transition: transform 120ms var(--ease-out), background 150ms ease, border-color 150ms ease, opacity 150ms ease;
}
.btn:active { transform: scale(0.97); }
.btn:hover { background: var(--bg-elev-2); }
.btn.primary {
background: var(--accent);
border-color: transparent;
color: #fff;
}
.btn.primary:hover { background: var(--accent-strong); }
.btn.danger { color: var(--err); }
.btn.danger:hover { background: var(--err-soft); }
.btn.ghost { background: transparent; border-color: transparent; color: var(--text-2); }
.btn.ghost:hover { background: var(--bg-elev-2); color: var(--text); }
.btn.sm { padding: 5px 10px; font-size: 12px; }
.btn.block { width: 100%; padding: 10px; font-size: 14px; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
input, textarea, select {
font-family: inherit;
font-size: 13px;
color: var(--text);
background: var(--bg-elev);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
padding: 8px 10px;
width: 100%;
transition: border-color 150ms ease, box-shadow 150ms ease;
}
input:focus, textarea:focus, select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
textarea { resize: vertical; min-height: 72px; font-family: "Cascadia Code", Consolas, monospace; }
label { font-size: 12px; font-weight: 500; color: var(--text-2); margin-bottom: 5px; display: block; }
/* ===== 登录 ===== */
.login-view {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.login-card {
width: 320px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 16px;
padding: 32px 28px;
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
gap: 14px;
animation: card-in 260ms var(--ease-out);
}
.login-logo { font-size: 28px; text-align: center; color: var(--accent); }
.login-card h1 { font-size: 19px; text-align: center; letter-spacing: -0.02em; }
.login-sub { text-align: center; color: var(--text-3); font-size: 12px; margin-top: -8px; }
.login-error { color: var(--err); font-size: 12px; text-align: center; min-height: 16px; }
/* ===== 顶栏 ===== */
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
gap: 24px;
padding: 0 24px;
height: 52px;
background: color-mix(in srgb, var(--bg-elev) 72%, transparent);
backdrop-filter: blur(16px) saturate(160%);
-webkit-backdrop-filter: blur(16px) saturate(160%);
border-bottom: 1px solid var(--border);
}
.brand { display: flex; align-items: center; gap: 8px; font-weight: 600; letter-spacing: -0.01em; }
.brand-dot {
width: 10px; height: 10px; border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 4px var(--accent-soft);
}
.tabs { display: flex; gap: 4px; }
.tab {
font-family: inherit; font-size: 13px; font-weight: 500;
padding: 6px 14px; border: none; border-radius: 999px;
background: transparent; color: var(--text-2); cursor: pointer;
transition: background 150ms ease, color 150ms ease;
}
.tab:hover { background: var(--bg-elev-2); color: var(--text); }
.tab.active { background: var(--accent-soft); color: var(--accent); }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 12px; }
.refresh-time { font-size: 12px; color: var(--text-3); }
/* ===== 内容 ===== */
.content { max-width: 1080px; margin: 0 auto; padding: 24px; }
.view-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.view-head h2 { font-size: 17px; letter-spacing: -0.02em; }
/* 统计条 */
.stats-bar {
display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 20px;
}
.stat {
flex: 1; min-width: 90px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 16px;
display: flex; flex-direction: column; gap: 2px;
box-shadow: var(--shadow-sm);
}
.stat span { font-size: 22px; font-weight: 650; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.stat label { margin: 0; font-size: 11px; color: var(--text-3); }
.stat.ok span { color: var(--ok); }
.stat.warn span { color: var(--warn); }
.stat.err span { color: var(--err); }
.stat.disabled span { color: var(--text-2); }
.stat.pending span { color: var(--text-3); }
/* ===== 账号卡片 ===== */
.account-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 14px;
}
.account-card {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
box-shadow: var(--shadow-sm);
display: flex;
flex-direction: column;
gap: 10px;
opacity: 0;
transform: translateY(8px);
animation: card-in 300ms var(--ease-out) forwards;
transition: transform 180ms var(--ease-out), box-shadow 180ms var(--ease-out), border-color 180ms ease;
}
@media (hover: hover) and (pointer: fine) {
.account-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); }
}
.account-card.below { border-color: color-mix(in srgb, var(--warn) 55%, transparent); }
.account-card.error { border-color: color-mix(in srgb, var(--err) 55%, transparent); }
.account-card.disabled { opacity: 0.65; }
.card-top { display: flex; align-items: center; gap: 10px; }
.badge {
width: 34px; height: 34px; border-radius: 9px;
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 13px; color: #fff;
flex-shrink: 0;
letter-spacing: 0.02em;
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.18);
}
.card-title { min-width: 0; }
.card-platform { font-size: 11px; color: var(--text-3); display: flex; align-items: center; gap: 5px; }
.card-name { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.balance-row { display: flex; align-items: baseline; gap: 6px; }
.balance-value { font-size: 26px; font-weight: 700; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.balance-value.below { color: var(--warn); }
.balance-value.error { color: var(--err); }
.balance-currency { font-size: 13px; color: var(--text-2); font-weight: 500; }
.balance-empty { color: var(--text-3); font-size: 22px; font-weight: 600; }
.card-meta { font-size: 11.5px; color: var(--text-3); display: flex; flex-direction: column; gap: 2px; }
.card-meta .badge-pill { align-self: flex-start; }
.badge-pill {
font-size: 11px; font-weight: 500;
padding: 2px 8px; border-radius: 999px;
}
.badge-pill.ok { background: var(--ok-soft); color: var(--ok); }
.badge-pill.warn { background: var(--warn-soft); color: var(--warn); }
.badge-pill.err { background: var(--err-soft); color: var(--err); }
.badge-pill.disabled { background: var(--disabled-soft); color: var(--text-2); }
.badge-pill.pending { background: var(--bg-elev-2); color: var(--text-3); }
.card-actions { display: flex; gap: 6px; margin-top: 2px; }
/* 阈值条 */
.threshold-bar {
height: 4px; border-radius: 2px;
background: var(--bg-elev-2);
overflow: hidden;
}
.threshold-fill {
height: 100%; border-radius: 2px;
background: var(--ok);
transition: width 300ms var(--ease-out), background 200ms ease;
}
.threshold-fill.below { background: var(--warn); }
/* ===== 平台列表 ===== */
.platform-list { display: flex; flex-direction: column; gap: 10px; }
.platform-row {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
display: flex; align-items: center; gap: 12px;
box-shadow: var(--shadow-sm);
opacity: 0;
animation: card-in 260ms var(--ease-out) forwards;
}
.platform-info { flex: 1; min-width: 0; }
.platform-name { font-weight: 600; display: flex; align-items: center; gap: 8px; }
.platform-meta { font-size: 11.5px; color: var(--text-3); margin-top: 2px; }
.platform-actions { display: flex; gap: 6px; }
.platform-off { opacity: 0.55; }
/* ===== 设置 ===== */
.settings-card {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px 20px;
margin-bottom: 14px;
box-shadow: var(--shadow-sm);
opacity: 0;
animation: card-in 260ms var(--ease-out) forwards;
}
.settings-card h3 { font-size: 13px; margin-bottom: 12px; color: var(--text-2); font-weight: 600; }
.form-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; max-width: 420px; }
.form-row:last-child { margin-bottom: 0; }
.settings-actions { display: flex; align-items: center; gap: 10px; margin-top: 4px; }
.settings-hint { font-size: 12px; color: var(--text-3); }
/* ===== 模态框 ===== */
.modal-mask {
position: fixed; inset: 0; z-index: 100;
background: rgba(10, 12, 18, 0.45);
display: flex; align-items: center; justify-content: center;
padding: 20px;
animation: fade-in 180ms ease;
backdrop-filter: blur(2px);
}
.modal {
width: 480px; max-width: 100%;
max-height: 86vh; overflow-y: auto;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: var(--shadow-lg);
padding: 22px 24px;
animation: modal-in 220ms var(--ease-out);
}
.modal h3 { font-size: 16px; letter-spacing: -0.02em; margin-bottom: 16px; }
.modal .form-row { max-width: none; }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
.modal-error { color: var(--err); font-size: 12px; min-height: 16px; margin-top: 8px; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.form-grid .full { grid-column: 1 / -1; }
.form-hint { font-size: 11px; color: var(--text-3); margin-top: 4px; }
/* 历史弹窗 */
.history-modal .modal { width: 420px; }
.history-list { display: flex; flex-direction: column; gap: 6px; max-height: 300px; overflow-y: auto; }
.history-item {
display: flex; justify-content: space-between; align-items: center;
padding: 7px 10px; border-radius: var(--radius-sm);
background: var(--bg-elev-2);
font-size: 12.5px; font-variant-numeric: tabular-nums;
}
.history-item .h-bal { font-weight: 600; }
.spark { display: flex; align-items: flex-end; gap: 2px; height: 34px; margin: 10px 0 4px; }
.spark i {
flex: 1; background: var(--accent);
border-radius: 2px 2px 0 0;
opacity: 0.85;
min-height: 2px;
transition: opacity 150ms ease;
}
.spark i:hover { opacity: 1; }
/* 确认弹窗 */
.confirm-modal .modal { width: 340px; }
.confirm-text { color: var(--text-2); font-size: 13px; margin-bottom: 18px; line-height: 1.6; }
/* ===== Toast ===== */
#toast-root {
position: fixed; bottom: 20px; right: 20px; z-index: 200;
display: flex; flex-direction: column; gap: 8px;
}
.toast {
background: var(--bg-elev);
border: 1px solid var(--border-strong);
color: var(--text);
font-size: 13px;
padding: 10px 16px;
border-radius: 10px;
box-shadow: var(--shadow-md);
display: flex; align-items: center; gap: 8px;
opacity: 0;
transform: translateY(10px);
transition: opacity 220ms var(--ease-out), transform 220ms var(--ease-out);
}
.toast.show { opacity: 1; transform: translateY(0); }
.toast.ok .toast-dot { background: var(--ok); }
.toast.err .toast-dot { background: var(--err); }
.toast-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
/* 空状态 */
.empty-hint {
text-align: center; color: var(--text-3);
padding: 60px 0; font-size: 13px;
}
/* ===== 动画 ===== */
@keyframes card-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes modal-in {
from { opacity: 0; transform: scale(0.96); }
to { opacity: 1; transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
* { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
.account-card { opacity: 1; transform: none; }
}
+30
View File
@@ -0,0 +1,30 @@
"""Telegram Bot 消息发送(同步、带超时;失败不抛异常,返回 False)。"""
from __future__ import annotations
import logging
import requests
logger = logging.getLogger("monitor.telegram")
API_BASE = "https://api.telegram.org/bot{token}/sendMessage"
def send_message(bot_token: str, chat_id: str, text: str) -> bool:
"""发送一条消息。配置缺失或失败时记日志并返回 False。"""
if not bot_token or not chat_id:
logger.warning("Telegram 未配置(bot_token/chat_id 为空),跳过发送: %s", text[:60])
return False
try:
resp = requests.post(
API_BASE.format(token=bot_token),
json={"chat_id": chat_id, "text": text},
timeout=10,
)
if resp.status_code == 200:
return True
logger.error("Telegram 发送失败 status=%s body=%s", resp.status_code, resp.text[:200])
except requests.RequestException as exc:
logger.error("Telegram 请求异常: %s", exc)
return False
+26
View File
@@ -0,0 +1,26 @@
"""AI Balance Monitor 入口:python main.py"""
from __future__ import annotations
import logging
import uvicorn
from app.api import create_app
from app.config import load_config
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
def main() -> None:
cfg = load_config()
app = create_app(cfg)
print(f"AI Balance Monitor 已启动: http://127.0.0.1:{cfg.port}")
uvicorn.run(app, host="0.0.0.0", port=cfg.port, log_level="warning")
if __name__ == "__main__":
main()
+5
View File
@@ -0,0 +1,5 @@
fastapi>=0.110
uvicorn>=0.29
requests>=2.31
pytest>=8.0
httpx>=0.27
+33
View File
@@ -0,0 +1,33 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import pytest
@pytest.fixture
def test_db(tmp_path, monkeypatch):
"""把数据库指向临时目录,避免污染真实 data/monitor.db。"""
from app import db
monkeypatch.setattr(db, "DB_PATH", tmp_path / "test.db")
monkeypatch.setattr(db, "DATA_DIR", tmp_path)
db.init_db()
return db
@pytest.fixture
def sample_platform(test_db):
"""插入一个示例平台,返回 dict。"""
from app import db
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 ('OpenAI', 'USD', 'OpenAI', 'GET', 'https://x.test/api?key={{apiKey}}',
'{"Authorization": "Bearer {{apiKey}}"}', '', 'data.balance', 60, 2, 10, 1, '')"""
)
pid = cur.lastrowid
return dict((test_db.get_conn().execute("SELECT * FROM platforms WHERE id=?", (pid,)).fetchone()))
+114
View File
@@ -0,0 +1,114 @@
"""提醒状态机测试:低于阈值提醒一次、恢复后重新武装、发送失败不切换状态。"""
import pytest
from app import db
from app.alert import build_alert_message, evaluate, notify_disabled
ACCOUNT = {
"id": 1, "name": "主账号", "threshold": 20.0, "alert_armed": 1,
"last_check_at": "2026-08-04 10:00:00",
}
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 accounts (id, platform_id, name, api_key, threshold, enabled, alert_armed)
VALUES (1, 1, '主账号', 'a2tva2V5', 20.0, ?, ?)""",
(enabled, armed),
)
def _armed(test_db):
with db.get_conn() as conn:
return conn.execute("SELECT alert_armed FROM accounts WHERE id=1").fetchone()[0]
def _log(test_db):
with db.get_conn() as conn:
return conn.execute("SELECT type FROM alert_log WHERE account_id=1 ORDER BY id").fetchall()
class TestEvaluate:
def test_below_threshold_armed_sends_and_disarms(self, test_db, monkeypatch):
_insert_account(test_db, armed=1)
sent = []
monkeypatch.setattr("app.alert.send_message", lambda *a: sent.append(a) or True)
evaluate(ACCOUNT, PLATFORM, 12.5, type("Cfg", (), {"telegram_bot_token": "t", "telegram_chat_id": "c"})())
assert len(sent) == 1
assert "12.5" in sent[0][2] and "OpenAI" in sent[0][2]
assert _armed(test_db) == 0
assert [r[0] for r in _log(test_db)] == ["below"]
def test_still_below_no_second_alert(self, test_db, monkeypatch):
_insert_account(test_db, armed=0)
sent = []
monkeypatch.setattr("app.alert.send_message", lambda *a: sent.append(1) or True)
evaluate(dict(ACCOUNT, alert_armed=0), PLATFORM, 5.0, None)
assert sent == []
def test_recover_after_disarmed(self, test_db, monkeypatch):
_insert_account(test_db, armed=0)
sent = []
monkeypatch.setattr("app.alert.send_message", lambda *a: sent.append(a[2]) or True)
evaluate(dict(ACCOUNT, alert_armed=0), PLATFORM, 30.0, type("Cfg", (), {"telegram_bot_token": "t", "telegram_chat_id": "c"})())
assert len(sent) == 1 and "恢复" in sent[0]
assert _armed(test_db) == 1
def test_above_threshold_armed_no_alert(self, test_db, monkeypatch):
_insert_account(test_db, armed=1)
sent = []
monkeypatch.setattr("app.alert.send_message", lambda *a: sent.append(1) or True)
evaluate(ACCOUNT, PLATFORM, 99.0, None)
assert sent == []
assert _armed(test_db) == 1
def test_exactly_threshold_is_not_below(self, test_db, monkeypatch):
_insert_account(test_db, armed=1)
sent = []
monkeypatch.setattr("app.alert.send_message", lambda *a: sent.append(1) or True)
evaluate(ACCOUNT, PLATFORM, 20.0, None)
assert sent == []
def test_send_failure_keeps_armed(self, test_db, monkeypatch):
_insert_account(test_db, armed=1)
monkeypatch.setattr("app.alert.send_message", lambda *a: False)
evaluate(ACCOUNT, PLATFORM, 5.0, type("Cfg", (), {"telegram_bot_token": "t", "telegram_chat_id": "c"})())
assert _armed(test_db) == 1 # 发送失败 → 保持武装,下次再试
assert _log(test_db) == []
def test_full_cycle(self, test_db, monkeypatch):
_insert_account(test_db, armed=1)
sent = []
monkeypatch.setattr("app.alert.send_message", lambda *a: sent.append(a[2]) or True)
cfg = type("Cfg", (), {"telegram_bot_token": "t", "telegram_chat_id": "c"})()
evaluate(ACCOUNT, PLATFORM, 8.0, cfg)
evaluate(dict(ACCOUNT, alert_armed=0), PLATFORM, 8.0, cfg) # 仍低于 → 不再发
evaluate(dict(ACCOUNT, alert_armed=0), PLATFORM, 50.0, cfg) # 恢复 → 发恢复
evaluate(dict(ACCOUNT, alert_armed=1), PLATFORM, 60.0, cfg) # 正常 → 不发
evaluate(dict(ACCOUNT, alert_armed=1), PLATFORM, 3.0, cfg) # 又低于 → 再提醒
assert len(sent) == 3
assert [r[0] for r in _log(test_db)] == ["below", "recovered", "below"]
class TestNotifyDisabled:
def test_disabled_notification(self, test_db, monkeypatch):
_insert_account(test_db)
sent = []
monkeypatch.setattr("app.alert.send_message", lambda *a: sent.append(a[2]) or True)
notify_disabled(dict(ACCOUNT, last_error="HTTP 401(认证失败)"), PLATFORM,
type("Cfg", (), {"telegram_bot_token": "t", "telegram_chat_id": "c"})())
assert len(sent) == 1 and "禁用" in sent[0] and "401" in sent[0]
assert [r[0] for r in _log(test_db)] == ["disabled"]
class TestBuildMessage:
def test_below_message_content(self):
msg = build_alert_message("below", ACCOUNT, PLATFORM, 12.5)
assert "12.5 USD" in msg and "20 USD" in msg and "主账号" in msg
def test_unknown_kind_empty(self):
assert build_alert_message("nope", ACCOUNT, PLATFORM, 1) == ""
+164
View File
@@ -0,0 +1,164 @@
"""API 全链路测试:认证、平台/账号 CRUD、设置。"""
import base64
import pytest
from fastapi.testclient import TestClient
from app.api import create_app
from app.config import Config
@pytest.fixture
def client(test_db, tmp_path, monkeypatch):
from app import config as cfgmod
monkeypatch.setattr(cfgmod, "CONFIG_PATH", tmp_path / "config.json")
cfg = Config(
port=8000, password="admin123", global_interval_seconds=60,
retry_count=2, timeout_seconds=10, max_workers=4,
)
app = create_app(cfg)
with TestClient(app) as c:
yield c
def _auth(client):
return {"Authorization": "Bearer " + client.post("/api/login", json={"password": "admin123"}).json()["token"]}
class TestAuth:
def test_unauthorized_blocked(self, client):
assert client.get("/api/platforms").status_code == 401
def test_wrong_password(self, client):
assert client.post("/api/login", json={"password": "nope"}).status_code == 401
def test_login_ok(self, client):
resp = client.post("/api/login", json={"password": "admin123"})
assert resp.status_code == 200 and resp.json()["token"]
def test_logout_invalidates(self, client):
token = _auth(client)["Authorization"].removeprefix("Bearer ")
assert client.post("/api/logout", headers={"Authorization": f"Bearer {token}"}).status_code == 200
assert client.get("/api/platforms", headers={"Authorization": f"Bearer {token}"}).status_code == 401
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",
"interval_seconds": 120, "retry_count": 1, "timeout_seconds": 15,
"enabled": True, "note": "",
}
class TestPlatforms:
def test_crud_flow(self, client):
h = _auth(client)
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"]
upd = client.put(f"/api/platforms/{pid}", json={"currency": "CNY", "interval_seconds": 300}, headers=h)
assert upd.status_code == 200
assert client.get("/api/platforms", headers=h).json()[0]["currency"] == "CNY"
assert client.delete(f"/api/platforms/{pid}", headers=h).status_code == 200
assert client.get("/api/platforms", headers=h).json() == []
def test_duplicate_name_409(self, client):
h = _auth(client)
client.post("/api/platforms", json=PLATFORM_PAYLOAD, headers=h)
assert client.post("/api/platforms", json=PLATFORM_PAYLOAD, headers=h).status_code == 409
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
def test_delete_cascades_accounts(self, client, test_db):
from app import db
h = _auth(client)
pid = client.post("/api/platforms", json=PLATFORM_PAYLOAD, headers=h).json()["id"]
client.post("/api/accounts", json={"platform_id": pid, "name": "a1", "api_key": "sk-1"}, headers=h)
client.delete(f"/api/platforms/{pid}", headers=h)
with db.get_conn() as conn:
assert conn.execute("SELECT COUNT(*) FROM accounts").fetchone()[0] == 0
class TestAccounts:
def _make_platform(self, client, h):
return client.post("/api/platforms", json=PLATFORM_PAYLOAD, headers=h).json()["id"]
def test_create_returns_decoded_key(self, client):
h = _auth(client)
pid = self._make_platform(client, h)
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"
def test_key_stored_base64(self, client, test_db):
from app import db
h = _auth(client)
pid = self._make_platform(client, h)
client.post("/api/accounts", json={"platform_id": pid, "name": "", "api_key": "sk-secret"}, headers=h)
with db.get_conn() as conn:
raw = conn.execute("SELECT api_key FROM accounts").fetchone()[0]
assert raw == base64.b64encode(b"sk-secret").decode()
def test_update_and_delete(self, client):
h = _auth(client)
pid = self._make_platform(client, h)
aid = client.post("/api/accounts", json={"platform_id": pid, "name": "", "api_key": "sk-1"}, headers=h).json()["id"]
assert client.put(f"/api/accounts/{aid}", json={"threshold": 5.5, "name": "新名"}, headers=h).status_code == 200
acc = client.get("/api/accounts", headers=h).json()[0]
assert acc["name"] == "新名" and acc["threshold"] == 5.5
assert client.delete(f"/api/accounts/{aid}", headers=h).status_code == 200
assert client.get("/api/accounts", headers=h).json() == []
def test_account_requires_platform(self, client):
h = _auth(client)
assert client.post("/api/accounts", json={"platform_id": 999, "name": "x", "api_key": "k"}, headers=h).status_code == 404
def test_check_now_disabled_account(self, client):
h = _auth(client)
pid = self._make_platform(client, h)
aid = client.post("/api/accounts", json={"platform_id": pid, "name": "x", "api_key": "k", "enabled": False}, headers=h).json()["id"]
assert client.post(f"/api/accounts/{aid}/check", headers=h).status_code == 409
def test_history_flow(self, client, test_db):
from app import db
h = _auth(client)
pid = self._make_platform(client, h)
aid = client.post("/api/accounts", json={"platform_id": pid, "name": "x", "api_key": "k"}, headers=h).json()["id"]
with db.get_conn() as conn:
conn.execute("INSERT INTO balance_history (account_id, balance) VALUES (?, ?)", (aid, 1.5))
conn.execute("INSERT INTO balance_history (account_id, balance) VALUES (?, ?)", (aid, 2.5))
hist = client.get(f"/api/accounts/{aid}/history", headers=h).json()
assert [x["balance"] for x in hist] == [1.5, 2.5]
class TestSettings:
def test_update_settings(self, client, tmp_path):
h = _auth(client)
resp = client.put("/api/settings", json={"global_interval_seconds": 120, "telegram_bot_token": "t:1"}, headers=h)
assert resp.status_code == 200
cfg = client.get("/api/settings", headers=h).json()
assert cfg["global_interval_seconds"] == 120 and cfg["telegram_bot_token"] == "t:1"
# 写回 config.json 且可重新加载
from app.config import load_config
assert load_config().global_interval_seconds == 120
def test_change_password(self, client):
h = _auth(client)
assert client.post("/api/settings/password", json={"old_password": "wrong", "new_password": "new123"}, headers=h).status_code == 401
assert client.post("/api/settings/password", json={"old_password": "admin123", "new_password": "new123"}, headers=h).status_code == 200
assert client.post("/api/login", json={"password": "admin123"}).status_code == 401
assert client.post("/api/login", json={"password": "new123"}).status_code == 200
+174
View File
@@ -0,0 +1,174 @@
"""fetcher 核心逻辑测试:路径提取、模板渲染、请求/重试策略。"""
import pytest
from app.fetcher import extract_balance, fetch_balance, render_template
# ---------- extract_balance ----------
class TestExtractBalance:
def test_dot_path(self):
assert extract_balance({"data": {"balance": 12.5}}, "data.balance") == 12.5
def test_array_index(self):
assert extract_balance({"data": [{"balance": 3.0}]}, "data[0].balance") == 3.0
def test_dollar_prefix(self):
assert extract_balance({"a": {"b": 7}}, "$.a.b") == 7
def test_root_key(self):
assert extract_balance({"balance": 1}, "balance") == 1
def test_nested_array(self):
data = {"list": [[1, 2], [3, 4]]}
assert extract_balance(data, "list[1][0]") == 3
def test_string_number(self):
assert extract_balance({"data": {"balance": "9.99"}}, "data.balance") == 9.99
def test_missing_key_raises(self):
with pytest.raises(ValueError):
extract_balance({"data": {}}, "data.balance")
def test_index_out_of_range_raises(self):
with pytest.raises(ValueError):
extract_balance({"data": []}, "data[0].balance")
def test_non_numeric_raises(self):
with pytest.raises(ValueError):
extract_balance({"data": {"balance": "abc"}}, "data.balance")
def test_bool_rejected(self):
with pytest.raises(ValueError):
extract_balance({"data": {"balance": True}}, "data.balance")
# ---------- render_template ----------
class TestRenderTemplate:
def test_replace(self):
assert render_template("https://x?key={{apiKey}}&a=1", "sk-123") == "https://x?key=sk-123&a=1"
def test_multiple(self):
assert render_template("{{apiKey}}/{{apiKey}}", "k") == "k/k"
# ---------- fetch_balancemock requests ----------
class FakeResponse:
def __init__(self, status_code=200, text="", json_data=None):
self.status_code = status_code
self.text = text
self._json = json_data
def json(self):
if self._json is None:
raise ValueError("No JSON object could be decoded")
return self._json
PLATFORM_GET = {
"method": "GET",
"url": "https://x.test/api?key={{apiKey}}",
"headers": {"Authorization": "Bearer {{apiKey}}"},
"body": "",
"balance_path": "data.balance",
}
class TestFetchBalance:
def test_get_success(self, monkeypatch):
calls = []
def fake_get(url, headers=None, timeout=10):
calls.append((url, headers))
assert url == "https://x.test/api?key=sk-1"
assert headers["Authorization"] == "Bearer sk-1"
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)
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):
captured["data"] = data
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)
assert result.ok
assert captured["data"] == '{"api_key": "sk-2"}'
def test_401_is_auth_error_no_retry(self, monkeypatch):
calls = []
def fake_get(url, headers=None, timeout=10):
calls.append(1)
return FakeResponse(401, text="unauthorized")
monkeypatch.setattr("requests.get", fake_get)
result = fetch_balance(PLATFORM_GET, "bad", retry_count=3, timeout=10)
assert not result.ok and result.auth_error
assert len(calls) == 1
def test_5xx_retries_then_success(self, monkeypatch):
calls = []
def fake_get(url, headers=None, timeout=10):
calls.append(1)
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)
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)
assert not result.ok and not result.auth_error
def test_network_error_retries(self, monkeypatch):
calls = []
def fake_get(url, headers=None, timeout=10):
calls.append(1)
import requests
if len(calls) < 3:
raise requests.ConnectionError("boom")
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)
assert result.ok and result.balance == 8
def test_other_4xx_no_retry(self, monkeypatch):
calls = []
def fake_get(url, headers=None, timeout=10):
calls.append(1)
return FakeResponse(404, text="not found")
monkeypatch.setattr("requests.get", fake_get)
result = fetch_balance(PLATFORM_GET, "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)
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)
assert not result.ok
assert "JSON" in result.error