"""FastAPI 应用:认证、平台/账号 CRUD、设置、静态前端。""" from __future__ import annotations import base64 import json 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() result = [] for r in rows: d = dict(r) try: d["headers"] = json.loads(d["headers"]) if d["headers"] else {} except (ValueError, TypeError): d["headers"] = {} result.append(d) return result @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