feat: 故障推送 / 公需课自动重学 / 插件解耦,发版 v0.0.8

体检修复:启动日志不再打印 AUTH_TOKEN、密钥可清空、推送失败不占整段冷却、目录名与文案统一成 plugins/。
This commit is contained in:
study-tool
2026-11-01 17:47:22 +08:00
parent 491fd08c1e
commit aa48145557
114 changed files with 7779 additions and 3302 deletions
+64 -48
View File
@@ -1,22 +1,19 @@
"""多账号学习控制台 —— 入口。
代码结构
config 常量 / 环境变量
config 常量 / 环境变量 / 系统设置(config.settings)
database SQLite 存储(SQLAlchemy ORM:模型 + 读写函数)
runtime 运行状态 + 统一日志(控制台 / 内存 / SQLite)
platform_jspx 旧版平台登录与课程抓取
platform_study 新版平台 API
platforms 平台运行时(registry / base / contract / errors)
plugins 平台插件(guanyixue / gdgxk …);平台实现、抓包、测验都在插件目录
engine 学习引擎(worker / 播放 / 提交)
capture 抓包
models HTTP 请求模型
settings_store 系统设置
llm 大模型调用
quiz 测验解析 / 交卷 / AI 答题
accounts 账号与登录接口
routes_misc 课程 / 学习 / 抓包 / 快照接口
log_routes 日志与运行状态接口
models HTTP 请求模型
routes 账号 / 设置 / 日志 / 课程等接口
日志只走 runtime.log(),不要自己 print。
"""
import json
import os
import threading
@@ -26,17 +23,16 @@ from fastapi import Request
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from study_tool import store
from study_tool.config import *
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
# 导入即把各路由模块的接口注册到 app 上(必须在 main() 之前)
from study_tool import llm, quiz, settings_store # noqa: E402,F401
from study_tool.routes import accounts, logs, misc # noqa: E402,F401
load_builtin() # 注册内置平台插件(jspx / study)
load_routes() # 扫描 routes/ 并导入全部路由模块,接口注册到 app 上(必须在 main() 之前)
load_builtin() # 扫 plugins/ 目录加载全部插件(目录为空也能正常启动)
APP_ID = "dgjy-study-console"
@@ -49,6 +45,7 @@ if os.path.isdir(_ASSETS_DIR):
# 不设置 = 不校验(本机使用,或前面已经有反代做鉴权)。/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;
@@ -65,30 +62,34 @@ background:#2b6cf6;color:#fff;font-size:14px;cursor:pointer}code{background:#f1f
@app.middleware("http")
async def auth_gate(request: Request, call_next):
if not AUTH_TOKEN:
# 公开查询页(/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 "")
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")
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 JSONResponse(
{"ok": False, "code": "unauthorized", "error": "缺少访问令牌(AUTH_TOKEN)"}, status_code=401
)
return HTMLResponse(_AUTH_PAGE, status_code=401)
def hydrate_runs():
"""进程重启后把上次的运行状态读回来;上次没跑完的标记成「已中断」。"""
try:
saved = store.run_state_all()
saved = database.run_state_all()
except Exception:
return
with RUNS_LOCK:
@@ -101,22 +102,26 @@ def hydrate_runs():
st["status"] = "interrupted"
st["error"] = "服务重启,上次运行已中断"
try:
store.run_state_set(k, st) # 落库,别让库里一直挂着 studying
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",
})
return HTMLResponse(
html,
headers={
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
"Pragma": "no-cache",
"Expires": "0",
},
)
@app.get("/favicon.svg")
@@ -129,13 +134,20 @@ def favicon():
@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": store.db_info(), "auth": bool(AUTH_TOKEN)}
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
@@ -146,6 +158,7 @@ def _lan_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))
@@ -157,6 +170,7 @@ def _lan_ip():
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"))
@@ -169,38 +183,40 @@ def main():
# 默认只监听本机;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
# 设了访问令牌时,打印出来的地址一律带上 ?token=:本机访问同样要校验令牌
suffix = ("/?token=%s" % AUTH_TOKEN) if AUTH_TOKEN else ""
# 设了访问令牌时,日志里只提示「需要令牌」,绝不打印令牌本身:日志同时进控制台、
# 内存和 SQLite,前端「系统日志」弹窗里能直接看到,明文等于把钥匙贴在墙上。
suffix = "(需要访问令牌:见 .env 的 AUTH_TOKEN)" if AUTH_TOKEN else ""
store.init() # 建库建表 + 导入旧的 settings.json / logs/*.log
hydrate_runs() # 上次的运行状态读回来(没跑完的标成已中断)
log("system", "SQLite: %s" % store.DB_PATH)
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")
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))
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))
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()
uvicorn.Server(uvicorn.Config(app, host="::1", port=port, log_level="warning", access_log=False)).run()
except Exception:
pass