feat: AI 余额监控服务(平台/账号 CRUD、并发轮询、Telegram 阈值提醒)
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
.venv/
|
||||
data/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
*.log
|
||||
@@ -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/ 运行时生成 SQLite(gitignore)
|
||||
```
|
||||
@@ -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
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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
@@ -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} 次后仍失败")
|
||||
@@ -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
@@ -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
|
||||
@@ -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) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
}[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>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-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();
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1,5 @@
|
||||
fastapi>=0.110
|
||||
uvicorn>=0.29
|
||||
requests>=2.31
|
||||
pytest>=8.0
|
||||
httpx>=0.27
|
||||
@@ -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()))
|
||||
@@ -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) == ""
|
||||
@@ -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
|
||||
@@ -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_balance(mock 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
|
||||
Reference in New Issue
Block a user