refactor: React 前端重构,新旧平台合并为莞易学
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
"""多账号学习控制台 —— 入口。
|
||||
|
||||
代码结构
|
||||
config 常量 / 环境变量
|
||||
runtime 运行状态 + 统一日志(控制台 / 内存 / SQLite)
|
||||
platform_jspx 旧版平台登录与课程抓取
|
||||
platform_study 新版平台 API
|
||||
engine 学习引擎(worker / 播放 / 提交)
|
||||
capture 抓包
|
||||
models HTTP 请求模型
|
||||
settings_store 系统设置
|
||||
llm 大模型调用
|
||||
quiz 测验解析 / 交卷 / AI 答题
|
||||
accounts 账号与登录接口
|
||||
routes_misc 课程 / 学习 / 抓包 / 快照接口
|
||||
log_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 store
|
||||
from study_tool.config import *
|
||||
from study_tool.platforms import load_builtin
|
||||
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)
|
||||
|
||||
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"
|
||||
|
||||
_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=<AUTH_TOKEN></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):
|
||||
if not AUTH_TOKEN:
|
||||
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 = store.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"] = "服务重启,上次运行已中断"
|
||||
RUNS[k] = st
|
||||
|
||||
|
||||
@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": store.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
|
||||
host = os.environ.get("HOST", "127.0.0.1")
|
||||
url = "http://127.0.0.1:%d" % port
|
||||
# 设了访问令牌时,打印出来的地址一律带上 ?token=:本机访问同样要校验令牌
|
||||
suffix = ("/?token=%s" % AUTH_TOKEN) if AUTH_TOKEN else ""
|
||||
|
||||
store.init() # 建库建表 + 导入旧的 settings.json / logs/*.log
|
||||
hydrate_runs() # 上次的运行状态读回来(没跑完的标成已中断)
|
||||
log("system", "SQLite: %s" % store.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()
|
||||
Reference in New Issue
Block a user