commit 17d2496a65e11781242f9b28b1c58a5b2c92649c Author: Zichao Lin Date: Tue Aug 4 23:51:06 2026 +0800 feat: AI 余额监控服务(平台/账号 CRUD、并发轮询、Telegram 阈值提醒) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d048b70 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv/ +data/ +__pycache__/ +*.pyc +.pytest_cache/ +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..163f76b --- /dev/null +++ b/README.md @@ -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 `。 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| 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) +``` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/alert.py b/app/alert.py new file mode 100644 index 0000000..83dfa89 --- /dev/null +++ b/app/alert.py @@ -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), + ) diff --git a/app/api.py b/app/api.py new file mode 100644 index 0000000..d51c73e --- /dev/null +++ b/app/api.py @@ -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 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..f8ce220 --- /dev/null +++ b/app/config.py @@ -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", + ) diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..0ec02fe --- /dev/null +++ b/app/db.py @@ -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] diff --git a/app/fetcher.py b/app/fetcher.py new file mode 100644 index 0000000..fdfd8d9 --- /dev/null +++ b/app/fetcher.py @@ -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} 次后仍失败") diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..63c0f47 --- /dev/null +++ b/app/models.py @@ -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) diff --git a/app/monitor.py b/app/monitor.py new file mode 100644 index 0000000..5fefb3f --- /dev/null +++ b/app/monitor.py @@ -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 diff --git a/app/static/app.js b/app/static/app.js new file mode 100644 index 0000000..de5daf4 --- /dev/null +++ b/app/static/app.js @@ -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 = `${escapeHtml(msg)}`; + 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 ` +
+
+
${escapeHtml(b.abbr)}
+
+
${escapeHtml(plat.name || "未知平台")} · ${escapeHtml(plat.currency || "")}
+
${escapeHtml(a.name)}
+
+
+
+ ${bal !== null + ? `${bal}${escapeHtml(plat.currency || "")}` + : ``} +
+
+
+
+
+ ${st.text} + 上次检查:${fmtTime(a.last_check_at)} + ${a.last_error ? `${escapeHtml(a.last_error)}` : ""} +
+
+ + + + +
+
`; + } + + 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 = `
还没有平台,点击右上角「+ 添加平台」创建。
`; + return; + } + list.innerHTML = platforms.map((p, i) => { + const b = brandStyle(p.icon || ""); + return ` +
+
${escapeHtml(b.abbr)}
+
+
${escapeHtml(p.name)} + ${p.enabled ? "" : '已停用'} +
+
+ ${escapeHtml(p.method)} ${escapeHtml(p.url)} · + 提取 ${escapeHtml(p.balance_path)} · + ${escapeHtml(p.currency)} · + 间隔 ${p.interval_seconds || "全局"}s · + ${p.account_count} 个账号 +
+
+
+ + + +
+
`; + }).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(` + `); + 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(` + `, (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) => + ``).join(""); + openModal(` + `, (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(` + `); + try { + const hist = await api(`/accounts/${accountId}/history?limit=30`); + const body = mask.querySelector("#hist-body"); + if (hist.length === 0) { + body.innerHTML = `
暂无记录(检查成功后自动记录)
`; + return; + } + const max = Math.max(...hist.map((h) => h.balance), 1e-9); + const bars = hist.map((h) => + ``).join(""); + const items = [...hist].reverse().map((h) => + `
${escapeHtml(h.checked_at)}${fmtBalance(h.balance)} ${escapeHtml(plat.currency || "")}
`).join(""); + body.innerHTML = `
${bars}
${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)}」?
其下 ${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(); +})(); diff --git a/app/static/index.html b/app/static/index.html new file mode 100644 index 0000000..89ab455 --- /dev/null +++ b/app/static/index.html @@ -0,0 +1,128 @@ + + + + + +AI Balance Monitor + + + + + + + + + + + + +
+ + + + diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..8a3d820 --- /dev/null +++ b/app/static/style.css @@ -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; } +} diff --git a/app/telegram.py b/app/telegram.py new file mode 100644 index 0000000..b2fd65a --- /dev/null +++ b/app/telegram.py @@ -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 diff --git a/main.py b/main.py new file mode 100644 index 0000000..dc417b3 --- /dev/null +++ b/main.py @@ -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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..52138c0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.110 +uvicorn>=0.29 +requests>=2.31 +pytest>=8.0 +httpx>=0.27 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3d4f413 --- /dev/null +++ b/tests/conftest.py @@ -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())) diff --git a/tests/test_alert.py b/tests/test_alert.py new file mode 100644 index 0000000..bc2771d --- /dev/null +++ b/tests/test_alert.py @@ -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) == "" diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..811a1c2 --- /dev/null +++ b/tests/test_api.py @@ -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 diff --git a/tests/test_fetcher.py b/tests/test_fetcher.py new file mode 100644 index 0000000..3970771 --- /dev/null +++ b/tests/test_fetcher.py @@ -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="")) + result = fetch_balance(PLATFORM_GET, "k", retry_count=0, timeout=10) + assert not result.ok + assert "JSON" in result.error