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

618 lines
48 KiB
Markdown
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.
# OPTIMIZATION
已实现、但还需要你实测确认的事项。确认没问题后把对应条目删掉。
## 发版体检修复(`src/study_tool/app.py`、`routes/settings.py`、`notify.py`、`web/src/components/SettingsDialog.tsx`)
**状态**:已实现,已用独立端口 + 独立库实例复测(首页 / `/query` / 系统设置 / 插件管理均正常渲染,无 console 报错);「清空密钥」「推送失败重试」需你实测。
### 做了什么
- 启动日志不再输出 `AUTH_TOKEN` 明文(日志同时进控制台、内存和 SQLite,前端「系统日志」里能看到),改成「(需要访问令牌:见 .env 的 AUTH_TOKEN)」。
- 系统设置:温度留空 / 填 0 = 不改,不再把已保存的温度清成 0;API Key、app_secret 右侧加「清空」按钮(`clear_api_key`、`notify.clear_app_secret`),不用再拿一个假值覆盖。
- 故障推送:发送失败只冷却 1 分钟,原来一次网络抖动会把整段 `cooldown_min` 的冷却占掉。
- 文案与文档:`platforms/`、`pugins/` 统一成 `plugins/`;`docs/pugin.md` 更名为 `docs/plugin.md`。
- 工程:`pyproject.toml` 版本与 `metadata.yaml` 对齐,`bumpversion` 同时更新两处;`.gitignore` 补 `.idea/`、`.ruff_cache/`。
### 怎么测
1. 重启服务,看控制台和右上角「系统日志」:启动那行应是 `控制台启动 -> http://127.0.0.1:8848(需要访问令牌:见 .env 的 AUTH_TOKEN)`,不应出现令牌本身。
2. 已保存 API Key 时打开「系统设置 → 大模型」:API Key 右侧有「清空」,点了保存后 `has_key` 应变成 false;「故障推送」里的 app_secret 同理。
3. 把推送 `base_url` 改成打不通的地址,触发一次故障推送:日志应出现「故障推送失败」,约 1 分钟后再次触发会重新尝试,而不是等满 10 分钟。
## 卡片标题显示学员姓名 + 日志面板明亮模式(`plugins/gdgxk/main.py`、`utils/learn.py`、`web/src/components/ui/LogLines.tsx`、`AccountCard.tsx`、`SystemLogDialog.tsx`)
**状态**:已实现,已用隔离实例截图核对(标题显示平台姓名、日志面板明亮 + 分级配色);「登录后回填姓名」和「进度上报新文案」需你下次学习时确认。
### 做了什么
- 广东公需课 `profile()` 改成先解析个人中心头像区的 `<a class="name">`:学员账号平台显示的是打码姓名(如「*栋」),老师账号是完整姓名;解析不到才退回原来的「你好,X 老师」。卡片标题仍是 `user → username` 的优先级,所以登录 / 刷新课程后标题就变成姓名,第二行继续显示登录账号。
- 进度上报日志改成能对得上的说法:`进度上报:平台已记 1200/2580 秒(46%),本次上报到 1201 秒`;课件结束改 `课件完成:xxx(平台已记 2580/2580 秒)`,不再出现「还差一半却说 100%」。
- 新增 `ui/LogLines.tsx`:按 `时间 [级别] 内容` 拆行渲染,info 灰蓝、warn 琥珀、error / fail 红色、success 绿色,时间戳淡灰。
- 账号卡片的「课程日志」和右上角「系统日志」都从黑底改成浅色底(`bg-slate-50` + 细边框),长行自动换行不再横向滚动。
### 怎么测
1. 刷新页面后点一次「刷新课程」(或「重新登录」):卡片标题应从手机号变成平台姓名(广东公需课学员是「*栋」这种打码名),第二行仍显示登录账号。
2. 下次学习时看日志:应出现 `进度上报:平台已记 x/y 秒(z%),本次上报到 n 秒`,百分比与秒数对得上;课件跑完是 `平台已记 y/y 秒`。
3. 日志面板应是浅色底:info 灰蓝、warn 琥珀色、error 红色;点右上角「系统日志」也是同样的浅色配色,长行自动换行。
## 账号卡片:修改账号信息弹窗 + 自绘确认弹窗(`web/src/components/AccountEditDialog.tsx`、`ui/ConfirmDialog.tsx`、`ui/Modal.tsx`、`ui/Button.tsx`、`AccountCard.tsx`、`QuizDialog.tsx`、`App.tsx`)
**状态**:已实现,已用独立端口 + 复制库实例截图核对「修改账号信息」弹窗与「删除账号」确认框;「保存后自动重新登录」这一条还需你实测。
### 做了什么
- 账号卡片「更多操作」菜单最上面新增「修改账号信息」;账号带 `lastError` 时,红条下方也直接给一个「修改账号信息」按钮,出错账号不用再翻菜单。
- 新增 `AccountEditDialog`:可改学习平台 / 登录账号 / 密码 / 备注。密码留空表示不修改(后端 `account_upsert` 保留原密码),带显示/隐藏切换。保存后写库并立刻用新凭据重新登录一次:弹窗先关,登录在后台跑,失败走 toast + 卡片红条。
- 新增 `ui/ConfirmDialog.tsx` 的 `useConfirm()`:项目内统一风格的确认弹窗(图标 + 标题 + 说明 + 主次按钮,危险操作用红色实心按钮)。替换了三处 `window.confirm`:删除账号(`App.tsx`)、提交测验缺题(`QuizDialog.tsx`)、再考一次(`QuizDialog.tsx`)。
- `Modal` 增加 `sm` 尺寸;`Button` 增加 `danger-solid` 变体。
### 怎么测
1. 刷新页面,点账号卡片右下「⋯」→「修改账号信息」:弹窗应回填当前平台 / 账号 / 备注,密码框为空并提示「已保存密码,需要更换时再填」。
2. 只改备注后保存:应提示「账号信息已保存,正在用新凭据重新登录…」,随后登录成功并刷新课程;密码留空不应把库里密码清空。
3. 故意填错密码保存:卡片应变成「登录失败」+ 红条,红条下出现「修改账号信息」按钮,点它能直接再改。
4. 点「⋯」→「删除账号」:弹自绘确认框(红色图标 + 红色「删除账号」按钮),取消不删除、确定才删;测验缺题提交、再考一次也应弹同样的自绘弹窗,不再出现浏览器默认框。
## 故障推送:呆呆面板推送渠道(`study_tool/notify.py`、`plugins/daidai_push/`、`routes/settings.py`、`engine.py`、`web/src/components/SettingsDialog.tsx`)
**状态**:已实现并实测通过(本机已用给定 app_key / app_secret 换到 token 并成功发出测试推送;进度异常 / 多次重试的自动触发还需一次真实学习验证)。
### 做了什么
- 新增 `study_tool/notify.py`:渠道注册表 + `fault()` 故障上报 + `retry()` 重试计数 + 冷却(同账号同类型 `cooldown_min` 分钟内只推一次,重试只在刚达到阈值时推)。
- 新增插件 `plugins/daidai_push/`(`plugin.json` 里 `"kind": "notify"`):`POST {base_url}/api/open-api/token` 换 token,内存缓存并按 `expires_in` 过期,发送遇 401/403 自动换新 token 重发一次;`POST {base_url}/api/notifications/send` 发通知。
- 设置项 `notify`:`base_url`(默认 `https://corn.hanhan.ltd`)、`app_key`、`app_secret`、`channel_name`(留空走默认渠道)、`enabled`、四个分类开关、`retry_threshold`(默认 3)、`cooldown_min`(默认 10)。`GET/POST /api/settings` 读写,`app_secret` 只回显前后几位;新增 `POST /api/notify/test`。
- 触发点:学完仍未完成 / 进度没推进 / 没有可识别活动、课程处理异常、整体运行异常、登录失败、会话失效且无法续期;公需课掉线重登与「未完成任务点自动重学」、莞易学单课时重报都计入重试次数。
- 前端「系统设置」新增「故障推送」区块(含「测试推送」按钮),插件管理里该插件显示能力「推送」。
### 怎么测
1. 重启服务,打开「系统设置」:左侧是「大模型 / 学习模式 / 故障推送」三栏,右侧只显示当前分类;切到「故障推送」应能看到已填好的 app_key / app_secret(secret 显示为掩码),点面板右上「测试推送」应收到一条「【测试】学习控制台推送」。
2. 「插件管理」里应有「呆呆面板推送」卡片,能力显示「推送」;停用后点「测试推送」应提示没有可用渠道,重新启用后恢复。
3. 跑一门**学不完**的课(或故意让会话失效):课程结束应收到「学习进度异常」或「登录/会话异常」推送,且同一账号 10 分钟内不会重复推。
4. 公需课连续 3 轮进度不增加、或莞易学单课时连续 3 次上报未生效时,应收到「多次重试」推送(阈值可在设置里改)。
## 插件管理卡片:能力中文 + 适配网站(`web/src/components/PluginsDialog.tsx`、`plugins/__init__.py`、`platforms/contract.py`)
**状态**:已实现,待实测确认(前端已 `cd web && pnpm build`;`/api/plugins` 多了 `sites`,需重启服务才生效)。
### 做了什么
- 卡片上的能力名由 `login / session / …` 改成中文:登录 / 会话 / 续期 / 账号信息 / 课程列表 / 课程详情 / 学习 / 测验 / 抓包(映射在 `PluginsDialog.tsx`,接口仍返回能力键)。
- `/api/plugins` 每条插件多返回 `sites`:取平台 `entries` 并去掉被更具体域名覆盖的父域(莞易学的 `dgjy.net` 不会单独列)。
- 卡片内的信息改成白底分组面板:`适配网站` 一行用等宽字体标签列出域名,`能力` 一行用蓝色标签(chip)逐个列出,`平台 / id` 放在最下面一行弱化展示。
- 顺手把卡片里过期的 `plugins/`、`plugin.json` 文案改成 `plugins/`、`plugin.json`。
### 怎么测
1. 重启服务后打开右上角「插件管理」:两张卡片都显示中文能力,且各有一行「适配网站」(公需课 `jsglpt.gds.edu.cn · jsxx.gds.edu.cn`,莞易学 `jspx.dgjy.net · study.dgjy.net`)。
2. 点停用 / 启用 / 重载:清单刷新后网站与能力仍正确。
## 公需课交互式测验(`plugins/gdgxk/main.py` 的 `quiz` / `quiz_submit`)
**状态**:取题已验证(本机账号那门课解析出 30 道题、题型 01、选项 A–D);**交卷未实测**(该课考核早已提交,`done=true`,所以弹窗只会显示题目、按钮禁用)。
### 做了什么
- 公需课补上 `quiz` / `quiz_submit` 两个槽位,前端卡片的「打开测验」因此出现(能力由方法推导)。
- 取题:考核是**独立活动页** `jsxx.gds.edu.cn/<aid>/study/course/<plan_id>`(课程主页上没有 `#testForm`),从课程目录里找标题含「考核 / 考试」的活动,打开它再 `exam.parse`;题型映射成弹窗认的 `01/02/03`。已交卷的课也返回题目,方便回看。
- 交卷:`exam.submit_letters` 重取一次考核页拿隐藏字段,把用户选的字母按选项 `value`(`Choice0..4`)换成平台要的值,再走原来的 `finishTest2` 提交并回读得分。
- `/api/quiz/submit` 改为按平台分发;莞易学的交卷逻辑挪进 `quiz.submit_quiz`,行为不变。
- 顺带修好学习流程里的自动考核:`exam.run` 之前也读课程主页(拿不到表单),现在同样改为读考核活动页。
### 怎么测
1. 找一门**考核还没交**的公需课,点「打开测验」:应列出 30 道题的题干 / 题型 / 选项。
2. 点「AI 答题」:应分批把答案填进选项(走 `/api/quiz/answer`,纯大模型)。
3. 改几道答案后点交卷:应返回得分;再点一次「打开测验」应显示已交卷且题目可见。
4. 若提示「考核页里没有找到题目」,把该考核页 HTML 发我,`exam.parse` 的选择器要跟着改。
## 刷新课程反复重登 / 空平台报错(`web/src/App.tsx`、`routes/accounts.py`、`plugins/gdgxk/main.py`)
**状态**:已实现,待实测确认(前端需先 `cd web && pnpm build`)。
### 做了什么
- `refreshCourses` 在 `session_expired` 自动重登后的重试补上 `platform`,不再出现「平台「」还没有实现刷新课程」,登录成功的课程也会正常写回卡片。
- 前端只在运行中(`discovering` / `studying`)才把 `/api/runs` 的 `cookies` 回写账号;运行结束后不再用开跑时的旧会话覆盖刚登录的新会话。
- `_clear_done_run` 一并清掉运行状态里的 `cookies`,重新登录后旧会话不会继续挂在 `/api/runs` 里。
- 公需课学习中掉线重登(`_relogin`)成功后,用 `database.account_save_cookies` 把新会话写回账号,并 `set_state(cookies=…)` 同步运行状态。
### 怎么测
1. 让账号会话失效(隔一段时间或换设备登录一次),点「刷新课程」:应看到「会话已失效,正在自动重新登录…」后直接读到课程,不再弹「平台「」还没有实现刷新课程」。
2. 自动重登后等 10 秒以上,再点一次「刷新课程」:不应再次触发登录(说明旧 cookie 没有被写回去)。
3. 学习中故意让会话掉线,日志出现「重新登录成功,继续学习」后结束运行,再点「刷新课程」:应一次成功、不再重登。
## 公需课自动重学(`plugins/gdgxk/main.py` 的 `study`)
**状态**:已实现,待实测确认。
### 做了什么
- 每刷完一轮活动,都重新请求进度页 `/study/course/progess` 取任务点状态;全部已完成(或一开始就全部已完成)时也会重取一次,以平台状态为准。
- 进度页里还有未完成任务点,就等 60 秒(`ROUND_WAIT`)后自动重新开始学习,只重刷未完成的活动。
- 连续 3 轮(`MAX_STALL`)完成数没增加,或达到 5 轮(`MAX_ROUNDS`)就停止,日志列出剩余任务点,避免考核等非视频任务点死循环。
- 重试等待期间点「停止」会立即退出。
### 怎么测
1. 选一个有未完成任务点的账号点「开始学习」,观察日志有没有「第 N 轮结束……稍后自动重新学习」,并且自动开始下一轮。
2. 选一个全部任务点已完成的账号,日志应显示「平台进度:N/N 个活动已完成」后结束。
3. 若某个任务点平台迟迟不记完成,应在 3 轮后停下并列出该任务点;如果 60 秒不够平台刷新状态,调大 `ROUND_WAIT`。
## 公需课选课兜底(`plugins/gdgxk/utils/register.py`)
**状态**:已实现,**未实测**(写的时候没有账号,选课超市的页面结构和选课请求是按截图推断的)。
### 做了什么
- `list_courses` 发现课程列表里没有目标年度的课时,调用 `register.ensure`:读 `/uc/store/course/` 里所有课程链接,逐个打开详情页判断状态。
- 状态判断:任一页按钮是「进入学习」= 已经选中,直接重读课程列表;选中的课位置对应页写「已有选课」= 已选了别的课,不再选;都没选就选第 `pick` 门。
- 配置在 `plugins/gdgxk/config.json`:`year`(空 = 当前年份)、`pick`(第几门,从 1 开始)。暂未做弹窗让用户挑。
### 怎么测
1. 用一个当年还没选课的账号点「刷新课程」,看系统日志有没有「公需课选课:…」。
2. 若提示「没有解析到课程链接」或「没有找到选课按钮的请求地址」,把 `/uc/store/course/` 页面 HTML、未选状态的详情页 HTML、点「选课」时的请求(cURL)发我,据此改 `register.py` 里的正则和请求方式(现在假定选课是 GET)。
## 公开查询页 `/query`
**状态**:已实现,待实测确认(前端需先 `cd web && pnpm build`)。
### 做了什么
- 新增 `routes/query.py`:`GET /api/query?username=` 按用户名返回账号的课程列表(名称 / 章节 / 状态 / 进度)和运行进度,不含密码 / cookie / 账号 id。
- `app.py` 的 token 中间件放行 `/query`、`/api/query`、`/favicon.svg` 和 `/assets/*`;`/query` 与 `/` 共用同一个前端入口。
- 前端 `main.tsx` 按路径渲染:`/query` 显示新页面 `pages/QueryPage.tsx`(上方输入框 + 查询按钮,下方课程列表与进度),其余路径仍是控制台。
### 怎么测
1. 设置 `AUTH_TOKEN` 后启动,不带 token 打开 `/query`,应能正常显示;打开 `/` 仍要求 token。
2. 输入已添加账号的用户名点「查询」,应显示课程列表和进度;输入不存在的用户名显示「没有找到该账号」。
3. 注意:该接口任何人知道用户名就能查到课程与进度,公网部署请确认可接受。
## 题库匹配(核心 `bank.py` + 公需课题库数据)
**状态**:已实现,待实测确认。匹配部分已完成;公需课考核已接到它上面(见下一节)。
### 做了什么
- 新增 `src/study_tool/bank.py`(与平台无关):题干归一化后按「精确 → 互相包含(两边至少 8 字)→ 相似度 ≥ 0.9」查题库;没命中的题按 5 题一批问设置里的大模型;没配大模型就留空并打 warn 日志。入口 `bank.answer(key, questions, bank)`。
- 新增 `plugins/gdgxk/utils/bank.py` 与 `plugins/gdgxk/bank/2026.json`:题库数据由脚本的 121 条 `videoBank` 转成 `[{"q": 题干, "a": "B"}]`。明年换题库只需往 `bank/` 加 JSON,同目录所有 `*.json` 会合并。
- `answer.py`(考核按序答案)没动:考核改走题干匹配,按序答案留着备用。
### 怎么测
1. 在项目根目录用 Python 执行:`from study_tool.plugins.gdgxk.utils import bank; bank.answer("t", [{"no":1,"type":"判断","title":"生成式人工智能(GenAI)目前还没有在教育领域得到广泛应用。","options":[{"key":"A","text":"正确"},{"key":"B","text":"错误"}]}])`,应返回 `{"1": {"answer": ["B"], "source": "bank"}}`。
2. 换一道题库里没有的题:没配大模型时日志有「还没配置大模型」警告;配了大模型会返回 `source: "llm"`。
## 公需课课程考核(`plugins/gdgxk/utils/exam.py`)
**状态**:已实现,待实测确认(本轮只验证了解析与选项映射,交卷响应体还没在真实环境跑过)。
### 做了什么
- 考核不是 JSON 接口:题目内嵌在课程学习页 HTML(`#topicList .m-topic-item`),交卷是 `POST .../ncts/test/delivery/<id>/finishTest2`,body 为 `_method=put` + 表单序列化(Spring method override)。
- 题型与字段:单选 `radio`、多选 `checkbox`(最多 5 个选项)、是非 `radio`,字段名 `qti_response_Q{n}:{n+1}:1_RESPONSE`,值 `Choice0..4`;隐藏字段 `qti_item_*` 原样回传。
- `exam.parse` 解析题面 / 选项字母 / 提交值;`exam.answer` 把题库或 AI 给的字母换成 `ChoiceN`;`exam.submit` 按页面 `ajaxSubmit` 的做法交卷。
- 题库没覆盖全部题目时不交卷,只打 warn 并列出缺答案的题号(避免瞎蒙把分数拉低);交完卷解析「已得 N 分」,低于 60 分打 warn 提示人工重考。
- `main.py` 的 `study()` 在视频轮次跑完、进度仍有缺口时调用一次考核,交完再按进度页复核。`opts["exam_auto"]=False` 可关掉自动交卷。
- 页面上已显示「您已完成测验」时直接跳过,不会重复交卷。
### 怎么测
1. 找一门视频已刷完、只差考核的课跑一次,日志应出现「考核已交卷,得分 N」。
2. 故意让题库缺题(临时改 `bank/2026.json`):应出现「考核共 N 题,M 题没答案,本次不交卷」,且不提交。
3. 重跑已完成的课:日志应是「考核已完成」,不再请求 `finishTest2`。
## 目录整理:config / database / llm 包,平台代码收进插件
**状态**:已实现,待实测确认。
### 做了什么
- 二次清理:莞易学专属常量(站点地址、登录授权入口、课程板块、`NODE` / `DESENC`、`DEBUG_DIR` 等)迁到 `plugins/guanyixue/config.py`;测验请求模型 / 答题提示词 / 题型名迁到 `plugins/guanyixue/quiz.py`;`config.LOGDIR`、`SessionExpired` 再导出、`AccountsIn` 无人使用,已删除;核心模块的 star import 改为显式。
- `config.py` → `config/` 包:`config/__init__.py` 放常量 / 环境变量 / 根目录定位,`config/settings.py` 放系统设置默认值与读写。
- `settings_store.py` 拆开:`/api/settings` 移到 `routes/settings.py`;设置读写留在 `config/settings.py`(插件不再反向 import 路由)。
- `llm.py` → `llm/` 包:`llm_chat()` 在 `llm/__init__.py`,`/api/llm/test` 移到 `routes/llm.py`;后续大模型相关代码都放这个目录。
- `store.py` → `database/` 包,改用 SQLAlchemy 2.0 ORM:`database/models.py` 定义 settings / kv / accounts / login_log / run_log / run_state / snapshots 七张表,`database/__init__.py` 提供 engine、线程级 scoped Session 和原来的读写函数(函数名与返回结构不变)。
- `capture.py` → `plugins/guanyixue/capture.py`;抓包接口按账号所属平台走 `registry.get(...)` 的 `capture` / `captures` / `capture_reset`。
- `quiz.py` → `plugins/guanyixue/quiz.py`(莞易学专用);插件 `main.py` import 它来注册 `/api/quiz/*`。
- `app.py` 调 `routes.load_routes()` 自动扫描并导入 `routes/*`(新增路由文件不用改 `app.py`,之前漏写 import 会导致全部 `/api/*` 404);新增依赖 `sqlalchemy>=2.0`(`pyproject.toml`,需 `uv sync`)。
- `docs/architecture.md`、`README.md`、`docs/plugin.md` 同步更新目录与模块引用。
### 怎么测
1. `uv sync` 后启动,`/api/health` 正常,`data/study.db` 能正常建表 / 读写。
2. 账号列表 / 登录 / 登录记录、课程日志、运行状态(`/api/runs`)、快照、设置保存、LLM 测试接口,行为与改造前一致。
3. 抓包开始 / 停止 / 查看记录正常;插件停用 / 启用 / 重载正常。
4. 莞易学插件启用时,`/api/quiz`、`/api/quiz/submit`、`/api/quiz/answer` 仍可用。
5. 莞易学账号登录(旧版 jspx 与新版 study 两条链路都走一遍)、刷新课程、学习正常;新版课程学习开头仍有「新版课程 ID」日志。
6. 首次启动不再创建 `data/logs` 目录(只要 `data/debug` 与数据库)。
7. 启动后不会再出现 `src/data/`:`database` 的 `DATA_DIR` 复用 `config.DATA`(仓库根 `data/`)。之前 `database/` 比旧 `store.py` 深一层,路径多上了一级才落到 `src/data`;原 `src/data/study.db` 里的账号 / 登录记录 / 日志已合并进根目录 `data/study.db`,页面应能看到两个 gdgxk 账号。
### 已知取舍
- `/api/quiz/submit`、`/api/quiz/answer` 仍由莞易学插件自己注册;加载器负责在停用 / 重载时摘掉,所以不再重复注册。要做成通用路由需等第二个平台有测验。
## 插件可删除:路由随插件进出、前端按能力显示、残件归位
**状态**:已实现,待实测确认(我只做了静态检查、前端 typecheck / build,以及在临时副本里实际启动验证)。
### 做了什么
- `plugins/__init__.py`:加载器记录每个插件 import 期间注册到 `app` 的路由,停用 / 重载 / 加载失败时一并摘除;重载不再重复注册 `/api/quiz*`。
- `platforms/contract.py` / `base.py`:能力新增 `quiz`(`quiz(self, s, d, b)`)与 `capture`;`/api/platforms` 的 `capabilities` 会带上它们。莞易学 `quiz.py` 的取题拆成 `fetch_quiz`,`/api/quiz` 改走 `pf.quiz`。
- 去掉写死的 `guanyixue` 默认值:`accounts.platform` 默认空串、请求模型默认空串、`_capture_platform` 不再兜底;前端平台列表初值为空,「打开测验」「抓包」按 `capabilities` 显示,页脚和添加账号提示不再写「莞易学」。
- 残件归位:`script/des.js`、`desenc.js`、`study_video.py` → `plugins/guanyixue/script/`;`script/capture-jsglpt.py`、`test-gdgxk.py` → `plugins/gdgxk/script/`;`DESENC` 路径随插件目录走;`test-gdgxk.py` 改成走新的插件 API。
- 已验证(临时副本):正常启动;停用 → 启用 → 重载两次后 `/api/quiz*` 恰好各一条;删掉莞易学目录 / 删光所有插件目录后服务都能启动,`/api/platforms` 分别只剩 gdgxk / 为空。
### 怎么测
1. `cd web && pnpm build` 后启动,界面能打开;平台下拉里有「莞易学」「广东公需课」。
2. 莞易学账号卡片「更多」菜单有「打开测验」「抓包」;广东公需课账号的菜单里没有这两项。
3. 「插件」弹窗里停用莞易学:`/api/quiz` 返回 404;再启用恢复;反复点「重载」不报错。
4. 把 `plugins/guanyixue` 整个目录挪走再启动:服务正常,添加账号只能选公需课;之前的莞易学账号登录时提示平台未实现,不影响其它账号。
5. 在仓库根目录执行 `python src/study_tool/plugins/guanyixue/script/study_video.py verify <trainCourseId>` 能跑(脚本自己把 `src` 加进 `sys.path`)。
6. Docker 构建后 nodejs 仍能被登录调用(`DESENC` 现在指向插件目录)。
## 本机启动读取根目录 .env
**状态**:已实现,待实测确认。
### 做了什么
- 修问题:以前只有 docker compose 会读 `.env`,本机 `python app.py` / `uv run app.py` / `start.bat`
下 `AUTH_TOKEN` / `PORT` / `RUN_LOG_KEEP` / `SNAPSHOT_KEEP` 全部不生效。
- `study_tool/__init__.py` 用 `python-dotenv` 加载仓库根 `.env`;放在包初始化处,早于 store / config
读环境变量(`database/__init__.py` 是 import 时就读 `DB_PATH` / `RUN_LOG_KEEP`)。已存在的环境变量优先,Docker 由 compose 注入的不受影响。
- 新依赖 `python-dotenv>=1.0`(`pyproject.toml` / `requirements.txt`,需 `uv sync`)。
- `app.py` 监听地址改成 `HOST` → `BIND_ADDR` → `127.0.0.1`,`.env` 里的 `BIND_ADDR` 本机也生效。
- README 环境变量表补 `.env` 说明与 `BIND_ADDR` / `TZ` 两行;`.env.example` 说明改为「本机与 Docker 都读」。
### 怎么测
1. 根目录有 `.env`(`AUTH_TOKEN` 非空)时直接 `python app.py`,启动日志里的地址应带 `?token=`。
2. 不带 token 开 `http://127.0.0.1:8848/` 应显示「需要访问令牌」页;带 `?token=<值>` 正常进控制台。
3. `.env` 改 `PORT=8849` 重启 → 实际监听 8849;命令行 `set PORT=8850` 再启动应压过 `.env`。
4. `.env` 里 `BIND_ADDR=127.0.0.1` → 只绑本机;改 `0.0.0.0` → 同网段可访问。
## 莞易学工具按阶段拆到 utils
**状态**:已实现,待实测确认。
### 做了什么
- `plugins/guanyixue/` 下的 `jspx.py` / `study.py` 按阶段拆到 `utils/`:`http.py`(会话 / cookie /
token)、`login.py`(登录 / SSO / 续期)、`course.py`(旧版课程抓取与代际分发)、
`feedback.py`(反馈表 / 测验)、`learn.py`(旧版学习流程)、`spa.py`(新版 SPA)。
- `main.py` 只留 `Platform` 子类,按 `course["generation"]` 分发到 `learn` / `spa`。
- 新模块全部改成显式 import,不再用 `from study_tool.config import *` / `jspx import *`;
`quiz.py` 的引用同步改成 `utils.*`。
### 怎么测
1. `GET /api/plugins` 里 `guanyixue` 的 loaded 为 true。
2. 用莞易学账号走一遍登录、刷新课程、发现课程(旧版 / 新版各一门更好)。
3. 启动一次学习并停止,确认日志里仍有进度与完成状态输出。
## 莞易学迁到插件目录
**状态**:已实现,待实测确认。
### 做了什么
- `platforms/guanyixue.py` → `plugins/guanyixue/main.py`;`jspx.py` / `study.py` / `detect.py`
一并搬进 `plugins/guanyixue/`,插件自包含,热重载能覆盖全部实现模块。
- 导入路径改成 `study_tool.plugins.guanyixue.*`;`quiz.py` 里对 `jspx` / `study` 的引用同步更新。
- `platforms/__init__.py` 只剩运行时,`load_builtin()` 只调 `load_plugins()`。
- 新增 `plugins/guanyixue/plugin.json`(id / title / name)。
### 怎么测
1. `GET /api/plugins` 列出 `gdgxk` 与 `guanyixue`,两者 enabled / loaded 都是 true。
2. 莞易学:刷新课程 → 发现课程 → 开始学习(旧版 / 新版各一次);测验弹窗照常。
3. 插件弹窗里对 guanyixue 点「重载」后再跑一次学习,行为不变。
## 插件热加载 + 启用/停用清单
**状态**:已实现,待实测确认。
### 做了什么
- `plugins/__init__.py` 重写:`load_plugins()` / `discover()` / `set_enabled()` / `reload()`。
- 热加载:把 `study_tool.plugins.<名字>`(含子模块)从 `sys.modules` 摘掉后重新 import,
import 时 `registry.register()` 覆盖;重载前先 `registry.unregister()` 旧 id,改 id 不留残影。
- 启用清单存在设置项 `plugins`(`{"disabled": [...]}`):没列进去的默认启用,
新丢进目录的插件自动生效;停用的插件被摘出注册表,`/api/platforms` 与账号平台下拉同步消失。
- `platforms/registry.py` 新增 `unregister(pid)`。
- 新增接口:`GET /api/plugins`、`POST /api/plugins/{name}/enable|disable`、
`POST /api/plugins/reload`、`POST /api/plugins/{name}/reload`;失败返回 `{"ok": false, "error": ...}`。
- 前端顶栏新增「插件管理」弹窗(`web/src/components/PluginsDialog.tsx`):列出插件与状态,
支持启停 / 单个重载 / 全部重载;启停后会重新拉 `/api/platforms`,「添加账号」的平台下拉同步变化。
- 引擎 / 路由 / 平台实现均未改动。
### 怎么测
1. `GET /api/plugins` 列出 `gdgxk`(enabled / loaded 为 true,capabilities 七项)。
2. `POST /api/plugins/gdgxk/disable` → `GET /api/platforms` 里 gdgxk 消失;再 enable 回来。
3. 改 `plugins/gdgxk/main.py` 里一句日志文案 → `POST /api/plugins/gdgxk/reload` →
不重启服务再跑一次学习,日志应变成新文案。
4. 重启服务:停用状态应被记住(设置项 `plugins`)。
5. 前端顶栏「插件管理」:停用 gdgxk → 「添加账号」的平台下拉立刻少一项;重新启用后恢复。
## 平台契约改为子类实现(注册方式调整)
**状态**:已实现,待实测确认。
### 做了什么
- `Platform` 从 dataclass「注册时逐项传函数」改成基类:`id` / `label` / `entries` 是类属性,
能力是子类方法;没实现的阶段保持基类的 `None`,`capabilities()` 与 `/api/platforms` 不变。
- `plugins/gdgxk/main.py`(原 `platforms/gdgxk.py`):`session`(原 `new_session`)/ `login` /
`list_courses` / `discover` / `study` 等收进 `class Gdgxk(Platform)`;解析 / 上报类 helper
仍是模块级函数。
- `platforms/guanyixue.py`:改成 `class Guanyixue(Platform)`,方法转调 `jspx` / `study`。
- `plugins/` 改成插件目录:`plugins/<名字>/plugin.json`(只放 `id` / `title` / `name`)+ `main.py`
(`Platform` 子类);`plugins/__init__.py::load_plugins()` 扫目录 import,`load_builtin()` 调它。
插件里不再声明任何能力绑定。
- 引擎 / 路由 / `quiz.py` / 前端 / `platforms/contract.py` 均未改动。
- 新增 [docs/plugin.md](plugin.md):插件骨架、槽位契约、新增与迁移流程、常见坑。
### 怎么测
1. `GET /api/platforms` 仍返回 `gdgxk` 与 `guanyixue`,`capabilities` 与改动前一致
(广东公需课:`login` / `session` / `renew` / `profile` / `courses` / `discover` / `study`)。
2. 莞易学:刷新课程 → 发现课程 → 开始学习照常(旧版 / 新版都跑一次)。
3. 广东公需课:登录 → 刷新课程 → 开始学习照常。
## 平台流程下沉(契约第二步)
**状态**:已实现,待实测确认。
### 做了什么
- `SessionExpired` 移到 `platforms/errors.py`,`config.py` 继续导出;平台实现不再反向 import 引擎。
- 旧版学习流程(反馈表 / 测验 / 刷课件)从 `engine.py` 搬到 `platforms/jspx.py`;新版 SPA 流程(含真播视频)搬到 `platforms/study.py`。
- `engine.py` 只剩账号级编排(课程循环 / 会话续期 / 按平台分发,115 行),不再 import 任何具体平台模块。
- `routes/*`、`quiz.py` 去掉对 `platforms.jspx` / `platforms.study` 的 star import;平台没实现 `session` / `dump_cookies` 时明确报错,不再退回莞易学的默认实现(原来会静默按 dgjy 过滤 cookie)。
### 怎么测
1. 莞易学:刷新课程 → 发现课程 → 开始学习(快速、常规各跑一次),反馈表 / 测验照常提交。
2. 广东公需课:开始学习,确认进度照常上报。
3. 错误分支:平台缺少 `session` 时,`/api/courses`、`/api/discover` 应返回「还没有实现 session」。
## 添加账号选择平台
**状态**:已实现,待实测确认。
### 做了什么
- 「添加账号」弹窗最上面新增「学习平台」下拉,选项来自 `GET /api/platforms`(当前有「莞易学」「广东公需课」)。
- 账号记录新增 `platform` 字段(SQLite 老库启动时自动 `ALTER TABLE` 补列)。
- 登录(`/api/login`)、刷新课程(`/api/courses`)、发现课程(`/api/discover`、`/api/quiz`)、
开始学习(引擎 `run_one_course`)都按账号所选平台取 `Platform` 上的实现;选「莞易学」时
仍然自动识别旧版 / 新版课程页。
- 以后新增平台:在 `platforms/<名字>.py` 实现 `session` / `login` / `list_courses` / `discover` /
`study` 并注册,下拉里就会多一个选项。
### 怎么测
1. 打开「添加账号」,确认最上面能看到「学习平台 / 莞易学」。
2. 正常添加账号并登录,卡片右上角显示所选平台的 label(当前为「莞易学」)。
3. 用一个已有账号点「刷新课程」「开始学习」,行为应与改动前一致(旧版 / 新版都能跑)。
### 如果没生效
- 下拉是空的:`GET /api/platforms` 应返回 `[{id, label}]`;检查 `platforms/__init__.py` 的
`load_builtin()` 是否导入了平台模块。
- 登录报「平台 xxx 还没有实现登录」:该平台的 `Platform.login` 没注册或 id 对不上。
- 老库启动报 `no such column: platform`:删掉 `data/study.db` 重启(当前账号数据为空),
或确认 `store._ensure_columns()` 已执行。
## 新旧平台合并为「莞易学」
**状态**:已实现,待实测确认。
### 做了什么
- `jspx.dgjy.net`(登录入口)和 `study.dgjy.net`(学习入口)在注册表里合并成一个平台:
`id=guanyixue`、`label=莞易学`。卡片右上角只显示「莞易学」。
- 课程发现时按 URL 和页面标记判断代际:旧版 `legacy`(`tier-tree-one` / `tier3`)、
新版 `spa`(Vue SPA / `/training/*`),写进 `course["generation"]`。
- 学习实现按代际分发:`legacy` 走 `engine._study_builtin`,`spa` 走 `engine.run_study_course`。
- 课程日志里的「平台:」一行会显示 `莞易学 · 旧版页面` 或 `莞易学 · 新版页面`。
- `GET /api/platforms` 返回 `[{id:"guanyixue",label:"莞易学"}]`,前端用 label 展示。
### 怎么测
1. 分别用一个旧版课程页账号和一个新版课程页账号(或同一账号的两门课)各跑一次自动学习。
2. 看卡片「课程日志」:应出现 `平台:莞易学 · 旧版页面` / `平台:莞易学 · 新版页面`,不再出现 `jspx` / `study` 平台名。
3. 卡片头部徽标应为「莞易学」;打开测验,交卷后后台日志正常,说明 `generation` 分支没走错。
### 如果没生效
- 日志报「无法识别课程页结构」:把页面快照发出来,可能站点又改版了,
需要补 `platforms/detect.py` 的代际标记。
- 日志报 `平台 xxx 没有可用的学习实现`:`course["platform"]` 不是 `guanyixue`,
检查 `platforms/jspx.py` 的 `_discover_once()` 是否被其他分支覆盖了。
## 前端 React 重构
**状态**:已实现,待实测确认。
### 做了什么
- 删除单文件 `static/index.html`,前端源码迁到 `web/`:React 19 + TypeScript + Vite +
Tailwind CSS v4,构建产物输出到 `static/`(`index.html` + `assets/*` + `favicon.svg`)。
- 页面功能保持不变:账号轮询(5s)、卡片内日志(1.5s)、课程勾选、登录 / 刷新 / 开始 / 停止 /
抓包 / 删除、自动交测验 / 反馈表开关、测验弹窗(手动作答 / AI 流式答题 / 再考一次)、
系统设置、系统日志、离线提示、Toast。
- FastAPI 新增 `/assets` 静态挂载和 `/favicon.svg`,`/` 仍返回 `static/index.html` 且禁止缓存。
### 怎么测
1. 直接 `python app.py`,打开页面:账号卡片、状态、课程列表、进度条、日志折叠都正常。
2. 走一遍登录 → 刷新课程 → 勾选 → 开始学习 → 停止,观察按钮 loading 和日志面板。
3. 打开测验(含单选 / 多选 / 判断 / 填空),提交后看标准答案和「再考一次」。
4. 设置里改大模型 / 学习模式并保存,刷新后值仍在;测试连接正常。
5. 右上角系统日志可查看、清空;断网后出现「后端未连接」提示。
### 如果没生效
- 页面空白 / 404 `/assets/...`:确认 `static/` 里有构建产物,且 `python app.py` 是在仓库根启动。
- 改了 `web/` 但页面没变:执行 `cd web && pnpm build`,构建产物才会进 `static/`。
- 端口不是 8848 时 `pnpm dev` 的 `/api` 代理会打错:改 `web/vite.config.ts` 的 `server.proxy`。
## 首次进入课程自动加入(syncSelf)
**状态**:已实现,待实测确认。
### 做了什么
首次进入没加入过的课程时,平台课时树会回 `code=500`「你还没有加入学习…」,前端把这句话
渲染成挡路的弹层,不点就进不去。真正把账号同步进课程的是页面自己发的这一发:
```
GET /mod/school/wanpeng/syncSelf?courseCode=<courseCode>
```
(抓包实录:`isJoin=0 needJoin=1` → syncSelf 约 2s → 再拉 `getById` 就是 `isJoin=1`。)
`POST /course/front/student/join` 对 `isFreeJoin=0` 的慕课课会被拒「该课程无法直接加入,请联系管理员」,
所以现在 `ensure_study_join` 的顺序是:已加入 → `student/join` → `syncSelf` + 轮询 `getById`(约 11s)
→ 刷新 jspx 课程页复查。`course_code` 取自 `trainCourse/getById` 的 `courseCode`。
### 怎么测
1. 用一个没加入过该课程的账号,或把某课时的 `studyLog` 清掉(制造「首次进入」)。
2. 跑一次自动学习,看日志里加入那一段。
预期出现其中一条:
- `加入培训:同步课程页后已加入(student/join 被拒:…;syncSelf=请求成功)`
- `加入培训:刷新课程页后已加入(…)`
确认点是:之后的 `period/study/log` 真记账(课时 `completeTime` 有值),且全程不需要人工点弹层。
### 如果没生效
- 日志是 `加入培训被拒:…;课程没有 courseCode,无法走同步接口`
→ 该账号/该课程的 `getById` 没返回 `courseCode`,需要再抓一次包定位它的来源
(抓包时重点看 `syncSelf?courseCode=` 参数是从哪个响应/本地状态来的)。
- 日志显示 syncSelf 成功但 `getById` 一直 `isJoin=0`
→ 同步在服务端没落库,先看 `syncSelf` 响应的 `result.code`,再考虑补 `/mod/gjt/roleList` 之外的接口。
## AI 答题(流式 + 重试 + 日志)
**状态**:已实现,待实测确认。
### 做了什么
- `/api/quiz/answer` 从「等全部批次算完再返回」改成流式(`text/event-stream`):每批 5 题算完就推一行,连接一直有数据,反代不会空闲断连。
- 每批失败按错误类型决定是否重试一次:超时 / 5xx / 429 / 空内容 / 非 JSON 会重试(第二次关掉 `response_format`、口头要求 JSON),鉴权类错误直接放弃这一批。
- 一批里只答出部分题时,第二轮只补问没答出来的题。
- 解析加了兜底:标准 JSON、markdown 包裹、截断 JSON、`1.A / 第3题:B / 3. 错误` 这类自由文本都能识别;多选连写 `AC` 会拆成 `A#C`。
- 日志:开始(题数/批数/模型/超时)、每批耗时与题数、失败原因、模型原文摘要、结束统计;同时打到控制台、SQLite(`/api/log/history?key=<账号>`)和该账号卡片的「课程日志」。
### 怎么测
1. 打开一个测验,点「AI 答题」,看状态栏是否逐批更新「Ns · 已填 x/y 题」。
2. 结束后展开该账号卡片的「课程日志」,应能看到 `AI答题 开始/第 1/2 批…/结束` 若干行;控制台同样能看到。
3. 故意把 base_url 改错或断开网络再点一次,日志里应出现 `失败 x.xs:LLM 404/超时…`,界面提示「详见该账号课程日志」。
### 如果没生效
- 状态栏一直停在 0/0、日志里没有任何 `AI答题` 行 → 说明请求没到后端(看浏览器 Network 里 `/api/quiz/answer` 的状态码)。
- 日志里每批都 `失败 … LLM 401` → api_key 不对;`LLM 404` → base_url 少了 `/v1`。
- 每批都是 `本批只答出 0 题` 且模型原文很长 → 模型没按要求给题号,可把批次调小(`quiz.py` 里 `QUIZ_BATCH`)或换个指令遵循更好的模型。
## 模块拆分 + 统一日志源 + 平台插件
**状态**:已实现,待实测确认。
### 做了什么
- 代码改成 `src/` 包布局:`src/study_tool/` 下按层分 `config`(常量)、`runtime`(状态 + 日志)、`store`(SQLite)、`engine`(学习引擎)、`capture`、`models`、`settings_store`、`llm`、`quiz`、`platforms/`(平台插件)、`routes/`(HTTP 接口)。根目录 `app.py` 只做启动壳,`python app.py` / `start.bat` / Dockerfile 的启动方式不变;新增 `pyproject.toml`(`pip install -e .` 后可用 `study-tool` 命令)。
- 平台插件化:`platforms/base.py` 定义 `Platform(id, label, study)`,`platforms/registry.py` 提供 `register/get/all`,`platforms/jspx.py` 与 `platforms/study.py` 各自注册;`engine.run_one_course` 发现课程后改成 `registry.get(course["platform"]).study(...)`,不再写死 `if platform == "study"`。新增平台 = 加一个模块 + 一行 import,`docs/architecture.md` 有完整步骤。
- 日志统一走 `runtime.log(key, msg, level)`:控制台、内存(`/api/log`)、SQLite(`/api/log/history`)同一份;设置保存、服务启动都会写。
- 新增右上角「系统日志」弹窗,看 `key=system` 的全局日志;卡片「课程日志」看该账号的日志。
- GBK 控制台编不了的字符(⚠ 等)自动降级,不再让 worker 线程抛异常。
### 怎么测
1. 启动服务,控制台应出现 `[system] SQLite: …` 和 `[system] 控制台启动 -> …`。
2. 页面右上角「系统日志」→ 看到上面两行;保存一次设置 → 多一行「设置已更新:模型 …」。
3. 点账号卡片的「课程日志」→ 学习日志和以前一致;点「清空」后不再出现旧行。
4. 跑一门课(旧平台 / 新平台各一次),日志里应出现该课程的流程,说明插件分发走到了对应平台。
5. `python -c "import sys; sys.path.insert(0,'src'); from study_tool.platforms import load_builtin, registry; load_builtin(); print(registry.all())"` → 应看到 jspx / study 两个平台。
4. 跑一次 AI 答题 → 该账号「课程日志」里是 `AI答题 …`,控制台同步同一份内容。
### 如果没生效
- 控制台没有 `[system]` 行 → 跑的是旧进程(端口被占用时新代码会提示后直接退出),先结束旧 PID 再启动。
- 「系统日志」弹窗空白 → `GET /api/log?key=system` 有 lines 就是前端缓存问题,刷新即可;没有 lines 说明还没产生 system 日志,保存一次设置就能看到。
## 广东公需课平台接入(jsglpt → jsxx)
**状态**:登录(账号密码 + 图形验证码 OCR)、课程列表、课程发现、播放注册、进度心跳全部实测跑通;只剩「未学完课程」的进度增长待确认。
### 已确认的接口
- 登录:`POST https://jsglpt.gds.edu.cn/loginIn.do`,`loginUser.userName` / `loginUser.password` 都是 **base64**(账号是手机号),`loginUser.loginType=normal`;`captcha` 是**强制**的 4 位图形验证码,`GET /login/readCaptcha.action` 返回 `"data:image/png;base64,..."`,用 `ddddocr` 离线识别(识别错就换一张重试,最多 5 次)。登录失败时服务端把原因写回登录页 `#errorText`(如「请输入正确的验证码!」),据此区分是验证码还是密码问题。
- 换票:`POST /teacher/ssoItlmsGxkTicket.action?verificaCode=true&page=` → `ITLMS-<hex>||<ticketId>`;把票按 `||` 切成两段,`GET https://jsxx.gds.edu.cn/nts/loginToStoreByAccount?ticket=<前段>&ticketId=<后段>` 才拿到学习平台会话(入口来自 jsglpt 首页的隐藏表单 `SSO_ITLMS_PUBLIC_STUDY_FORM`;直接带 `?ticket=` 访问 `courseRegister` 是无效的)。两个域名的 cookie 要在同一个 session 里。
- 课程列表:`GET /uc/store/courseRegister`,页面里是 `showCourse('<课程id>','<课程名>')`;同一张卡片里还有 `课程学时:N`、`培训年度:YYYY`、`未完成/已完成` 徽标,已解析成 `section` / `info` / `status_label` 显示在卡片上。
- 学习页:`GET /study/course/<课程id>?v=1`(服务端渲染、UTF-8),含 `url1`(CDN 直链 MP4)、`t`(总秒数)、`md5Uid`、`/a_<aid>/...` 上报前缀。
- 播放注册:`POST /a_<aid>/study/video/user/updateVideoStatus1`(`_method=PUT&id=<课件id>&md5Uid=<uid>&playbackSessionId=<sid>&playbackTakeover=true`)→ `{time, md5}`。
- 进度心跳:`POST /a_<aid>/study/video/user/<课件id>/updateViewTime1`(`_method=PUT&aid=...&isLimit=false&md5&expires&md5Uid&lastViewTime=<秒>&playbackSessionId`)。
- 已看秒数:`GET /a_<aid>/study/video/user/getViewTime?id=&md5=&expires=&md5Uid=`,`responseData` 直接就是秒数。
### 踩到的坑
- 三个上报接口必须带 **Referer(指向课程页)**,否则 500 且响应体为空。
- `isLimit` 只能传 `false` / `true`,传 `"N"` 会 400。
- 课程列表页、学习页都是 UTF-8(别按 GBK 解,会花屏)。
### 关键结论
进度不靠播放器,`lastViewTime` 直接写服务端。实测把 2640 秒的课报到 2700 秒也被接受,说明可以跳着报、不必真播;默认 `speed=60`、`beat=2`,44 分钟的课约 45 秒报完。课程是否学完看 `getViewTime >= t`。
### 怎么测
1. 「添加账号」平台选「广东公需课」,填手机号 + 密码 → 能拉到课程列表就算登录成功。
2. 选一门**没学完**的课 → 开始学习 → 看「课程日志」里的进度上报行,再回平台页面核对已学时长。
3. 登录若报「平台要求图形验证码」,说明密码登录要过验证码,先改用浏览器登录后导入 cookie,再补 OCR。
### 如果没生效
- 400 → 看 Referer 有没有、`isLimit` 是不是 `false`。
- 500 且空响应 → 少了 Referer,或 `md5Uid` 取错。
- 课程列表为空 → jsxx 会话失效,重新登录(或等 `renew_session` 换票)。
- 登录报「请输入正确的验证码!」→ OCR 没认对,重试即可;连续失败说明 `ddddocr` 没装好。
## 运行状态与日志修复(重启标记 / 历史日志 / 删账号清日志)
**状态**:已实现,待实测确认。
### 做了什么
- **重启标记不再污染新一轮运行**:`app.py` 的 `hydrate_runs()` 把上次没跑完的状态标成「已中断」后立刻落库(库里不再一直挂着 `studying`);`runtime.set_state()` 在状态从非运行态切到 `discovering` / `studying` 时清掉上一轮的 `error` / `progress` / `finished_at`,所以点「开始学习」后不会还挂着「服务重启,上次运行已中断」。
- **重启后历史日志能读出来了**:前端拉日志的兜底条件原是「内存为空 **且** status 为空」,而重启后 status 恰好是 `interrupted`(非空),历史永远不读。现在只看内存是否为空(`web/src/App.tsx`)。
- **删账号连带清日志**:`store.account_delete()` 除 run_state / run_log(账号 id)外,还会按**用户名**清 run_log 与 login_log(登录日志的 key 和 `login_log.username` 记的是手机号,不是账号 id);`DELETE /api/accounts/{id}` 同时把内存里这两个 key 的日志一起丢掉。
### 怎么测
1. 重启服务后打开页面:卡片应是新状态;点「开始学习」,红条与上一轮的旧进度立即被覆盖。
2. 重启服务(内存日志清空)后展开某账号「课程日志」:应显示 SQLite 里的历史行,而不是「暂无日志」。
3. 删掉一个账号再刷新:该账号的课程日志、登录记录(`/api/loginlog`)都不应再有它的行;`system` 与其它账号的日志必须保留。
### 如果没生效
- 红条还在 → 看 `run_state` 里那条是否仍带旧 `error`,删掉该行即可复位。
- 历史日志还是空 → 确认 `static/` 已重新构建(`cd web && pnpm build`)并强刷页面。
- 删完还有日志 → 平台的登录用户名与账号 `username` 字段不一致时清不掉,需按账号实际写入的 key 处理。