Files
ai-balance-monitor/app/api.py
T

326 lines
13 KiB
Python

"""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