Files
study-tool/src/study_tool/app.py
T
study-tool aa48145557 feat: 故障推送 / 公需课自动重学 / 插件解耦,发版 v0.0.8
体检修复:启动日志不再打印 AUTH_TOKEN、密钥可清空、推送失败不占整段冷却、目录名与文案统一成 plugins/。
2026-11-01 17:47:22 +08:00

229 lines
9.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""多账号学习控制台 —— 入口。
代码结构
config 常量 / 环境变量 / 系统设置(config.settings)
database SQLite 存储(SQLAlchemy ORM:模型 + 读写函数)
runtime 运行状态 + 统一日志(控制台 / 内存 / SQLite)
platforms 平台运行时(registry / base / contract / errors)
plugins 平台插件(guanyixue / gdgxk …);平台实现、抓包、测验都在插件目录
engine 学习引擎(worker / 播放 / 提交)
llm 大模型调用
models HTTP 请求模型
routes 账号 / 设置 / 日志 / 课程等接口
日志只走 runtime.log(),不要自己 print。
"""
import json
import os
import threading
import uvicorn
from fastapi import Request
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from study_tool import database
from study_tool.config import STATIC_DIR
from study_tool.platforms import load_builtin
from study_tool.routes import load_routes
from study_tool.runtime import RUNS, RUNS_LOCK, log
from study_tool.webapp import app
load_routes() # 扫描 routes/ 并导入全部路由模块,接口注册到 app 上(必须在 main() 之前)
load_builtin() # 扫 plugins/ 目录加载全部插件(目录为空也能正常启动)
APP_ID = "dgjy-study-console"
# 前端构建产物(web/ 由 Vite 输出到 static/):静态资源挂在 /assets
_ASSETS_DIR = os.path.join(STATIC_DIR, "assets")
if os.path.isdir(_ASSETS_DIR):
app.mount("/assets", StaticFiles(directory=_ASSETS_DIR), name="assets")
# 远程访问令牌:设置了 AUTH_TOKEN 后,页面/接口都要带 ?token=<值>(浏览器会记住 cookie)。
# 不设置 = 不校验(本机使用,或前面已经有反代做鉴权)。/api/health 永远放行,供健康检查用。
AUTH_TOKEN = (os.environ.get("AUTH_TOKEN") or "").strip()
AUTH_COOKIE = "dgjy_auth"
_PUBLIC_PATHS = {"/query", "/api/query", "/favicon.svg"}
_AUTH_PAGE = """<!doctype html><meta charset="utf-8"><title>需要访问令牌</title>
<style>body{font:14px/1.7 system-ui,"Microsoft YaHei",sans-serif;background:#f5f6f8;color:#1f2430;
display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}
div{background:#fff;border:1px solid #e3e6ec;border-radius:12px;padding:26px 30px;max-width:520px}
h3{margin:0 0 10px}input{width:100%;padding:8px 10px;border:1px solid #d6dae2;border-radius:8px;
font-size:14px;margin-top:8px}button{margin-top:14px;padding:8px 18px;border:0;border-radius:8px;
background:#2b6cf6;color:#fff;font-size:14px;cursor:pointer}code{background:#f1f3f7;padding:1px 5px;border-radius:4px}</style>
<div><h3>需要访问令牌</h3>
<p>用 <code>/?token=&lt;AUTH_TOKEN&gt;</code> 打开本页,或把令牌填到下面。</p>
<form method="get" action="/"><input name="token" placeholder="访问令牌" autofocus><button>进入</button></form></div>
"""
@app.middleware("http")
async def auth_gate(request: Request, call_next):
# 公开查询页(/query + /api/query)及其静态资源不需要 token
if not AUTH_TOKEN or request.url.path in _PUBLIC_PATHS or request.url.path.startswith("/assets/"):
return await call_next(request)
tok = (
request.query_params.get("token")
or request.cookies.get(AUTH_COOKIE)
or request.headers.get("x-auth-token")
or ""
)
if tok == AUTH_TOKEN:
resp = await call_next(request)
if request.query_params.get("token"):
# 带上 ?token= 访问后下发 cookie,页面里所有同源 fetch 会自动带上
resp.set_cookie(AUTH_COOKIE, AUTH_TOKEN, max_age=30 * 24 * 3600, httponly=True, samesite="lax")
return resp
if request.url.path == "/api/health":
return await call_next(request)
if request.url.path.startswith("/api/"):
return JSONResponse(
{"ok": False, "code": "unauthorized", "error": "缺少访问令牌(AUTH_TOKEN)"}, status_code=401
)
return HTMLResponse(_AUTH_PAGE, status_code=401)
def hydrate_runs():
"""进程重启后把上次的运行状态读回来;上次没跑完的标记成「已中断」。"""
try:
saved = database.run_state_all()
except Exception:
return
with RUNS_LOCK:
for k, st in saved.items():
if k in RUNS:
continue
st = dict(st)
st.pop("log", None)
if st.get("status") in ("discovering", "studying"):
st["status"] = "interrupted"
st["error"] = "服务重启,上次运行已中断"
try:
database.run_state_set(k, st) # 落库,别让库里一直挂着 studying
except Exception:
pass
RUNS[k] = st
@app.get("/query", response_class=HTMLResponse) # 公开查询页:同一个前端入口,由前端按路径渲染
@app.get("/", response_class=HTMLResponse)
def index():
# 页面必须禁止缓存:后端重启或换端口后,旧标签页会拿着缓存里的 HTML 继续跑,
# 页面照常显示、所有接口全 ERR_CONNECTION_REFUSED,看起来就像"登录失败"。
html = open(os.path.join(STATIC_DIR, "index.html"), encoding="utf-8").read()
return HTMLResponse(
html,
headers={
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
"Pragma": "no-cache",
"Expires": "0",
},
)
@app.get("/favicon.svg")
def favicon():
path = os.path.join(STATIC_DIR, "favicon.svg")
if not os.path.exists(path):
return JSONResponse({}, status_code=404)
return FileResponse(path, headers={"Cache-Control": "public, max-age=86400"})
@app.get("/api/health")
def api_health():
return {
"ok": True,
"app": APP_ID,
"pid": os.getpid(),
"port": int(os.environ.get("PORT", "8848")),
"runs": len(RUNS),
"db": database.db_info(),
"auth": bool(AUTH_TOKEN),
}
def _port_open(port, host="127.0.0.1", timeout=0.6):
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(timeout)
return s.connect_ex((host, port)) == 0
def _lan_ip():
"""本机对外的网卡地址,用于启动提示;容器里拿到的是容器 IP,不能当访问地址,返回空。"""
if os.path.exists("/.dockerenv"):
return ""
import socket
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.connect(("223.5.5.5", 80))
return s.getsockname()[0]
except Exception:
return ""
def _probe_self(port):
"""端口已被占用时判断是不是本程序自己在跑,避免重复启动直接报错。"""
import urllib.request
try:
with urllib.request.urlopen("http://127.0.0.1:%d/api/health" % port, timeout=1.5) as r:
return json.loads(r.read().decode("utf-8"))
except Exception:
return None
def main():
port = int(os.environ.get("PORT", "8848"))
# 默认只监听本机;Docker / 远程管理用 HOST=0.0.0.0(.env 里写 BIND_ADDR 同样生效)
host = os.environ.get("HOST") or os.environ.get("BIND_ADDR") or "127.0.0.1"
url = "http://127.0.0.1:%d" % port
# 设了访问令牌时,日志里只提示「需要令牌」,绝不打印令牌本身:日志同时进控制台、
# 内存和 SQLite,前端「系统日志」弹窗里能直接看到,明文等于把钥匙贴在墙上。
suffix = "(需要访问令牌:见 .env 的 AUTH_TOKEN)" if AUTH_TOKEN else ""
database.init() # 建库建表 + 导入旧的 settings.json / logs/*.log
hydrate_runs() # 上次的运行状态读回来(没跑完的标成已中断)
log("system", "SQLite: %s" % database.DB_PATH)
if _port_open(port):
info = _probe_self(port)
if info and info.get("app") == APP_ID:
log("system", "服务已在运行 -> %s%s(PID %s)" % (url, suffix, info.get("pid")))
return
log(
"system",
"端口 %d 被其它程序占用(不是本程序):netstat -ano | findstr :%d,"
"换端口用 PORT=8849 python app.py" % (port, port),
level="error",
)
return
log("system", "控制台启动 -> %s%s" % (url, suffix))
if AUTH_TOKEN:
log("system", "IPv6 同步监听 http://[::1]:%d;远程访问 http://%s:%d" % (port, _lan_ip() or "<服务器IP>", port))
elif host == "0.0.0.0":
log("system", "已监听 0.0.0.0 但没设 AUTH_TOKEN:同网段谁都能打开,远程部署请设置", level="warn")
srv4 = uvicorn.Server(uvicorn.Config(app, host=host, port=port, log_level="warning", access_log=False))
def _serve_v6():
# Windows 上 localhost 优先解析到 ::1。只绑 127.0.0.1 时,
# 浏览器打开 http://localhost:8848 就是 ERR_CONNECTION_REFUSED。
try:
uvicorn.Server(uvicorn.Config(app, host="::1", port=port, log_level="warning", access_log=False)).run()
except Exception:
pass
threading.Thread(target=_serve_v6, daemon=True, name="uvicorn-ipv6").start()
srv4.run()
if __name__ == "__main__":
main()