"""多账号学习控制台 —— 入口。 代码结构 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 = """需要访问令牌

需要访问令牌

用 /?token=<AUTH_TOKEN> 打开本页,或把令牌填到下面。

""" @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()