Compare commits

..
Author SHA1 Message Date
ila dde2ac127a docs: 同步 #15 交付页与本地开发页镜像 (#15)
docs/04 与线上 Wiki 的纯二进制安装与交叉验证记录保持一致,revision fa4c663fabf5db418104fc53b7ce00f41f5ba910。
2026-09-15 17:12:02 +08:00
ila 420365fc6b feat: 备份、恢复与校验提供 Go 子命令,交付支持纯二进制安装 (#15)
- server/cmd/lexgo 增加 backup / restore / verify 子命令,与既有 migrate/bootstrap/serve
  同一入口;备份仍调用 mysqldump,dump 与 manifest 格式和工具通道完全一致、可互相读取
- server/app/lexgo/ops.go:manifest、库名校验、版本解析、dump 库名切换检测、表行数与
  内容校验和、目标库准备、恢复前后源库校验、完整性校验(客户不选 schema,SQL 显式限定库名)
- ops_test.go:7 项无库规则测试与 1 项 MySQL 备份→恢复→校验集成用例
- 部署页补充纯二进制路径(lexgo migrate/bootstrap/backup/restore/verify)、两条通道的关系
  与「接口级两账号闭环仍在工具通道」的边界;Architecture/LocalDev/Home/README/AGENTS 同步
- gofmt 修正 #13 遗留的 library.go 字段对齐
2026-09-15 17:11:26 +08:00
ila 38d8975325 feat: 自托管交付的安装、备份与恢复工具及部署文档 (#15)
- scripts/ops.py:install-check(依赖与资源版本、客户端不得低于服务端)、init-database(建空库
  并提示最小权限)、backup(全库 dump + manifest,不含凭据)、restore(默认只写空库、
  --confirm 必需、覆盖需 --force、拒绝系统库与带 CREATE DATABASE/USE 的 dump、比对源库校验和)、
  verify(完整性 + 可选两账号接口闭环)、smoke(干净实例上建两个演练账号走通学习闭环)
- scripts/bench.py:写明规模的人造数据集性能测量,记录数据量、机器与 p50/p95
- tests/test_lexgo_ops.py:版本解析、库名白名单、dump 安全性、manifest 字段白名单等无库测试
- Wiki 新增 Deployment-and-Operations 页面与 wiki-docs.json 映射(含升级回滚与已知限制)
- 更新 Architecture、Business-Rules、Local-Development、Product-Requirements、Home 与
  README/AGENTS;本单无 schema 与接口变化
2026-09-15 16:30:23 +08:00
ila bdb38970e0 docs: 新增部署与运维页面映射 (#15)
从 docs/templates/deployment.md 建立 Deployment-and-Operations 页面映射,由 harness.py sync 导出核心镜像。
2026-09-15 16:27:36 +08:00
ila 20e13e909b docs: record issue 14 acceptance and merge (#14)
- Wiki:Home、Architecture、Product-Requirements 与 Project-Profile 标记 #14 已验收并合入 main
- README 同步当前进度,剩余 #15、#21、#24 与缺陷 #32
- docs/ 由 harness.py sync 导出,revision:Architecture b61d9e1b、Home 9f648520、
  Product-Requirements 358d7720、Project-Profile 5cf8e02e
2026-09-15 15:45:09 +08:00
ila e069894c4e feat: 补齐桌面与手机体验、显示偏好与键盘操作 (#14)
- 主题(浅色/深色/跟随系统)与正文字号(标准/大/特大)按账号存在本机,
  账号切换不串、退出回默认;深色走 html[data-theme] 与 Element Plus 的 html.dark
- style.css 收敛为语义调色板::root 的 53 个变量是文件内仅有的颜色字面量,
  其余规则全部走 var(),暗色只覆盖变量
- 字号经 --reader-font-scale 只作用于阅读面,不做全局缩放
- 阅读位置按账号+章节保存滚动比例与该章 content_sha256,正文换版本后不恢复
- 复习页键盘:空格/Enter 显示答案、1/2/3 评分;输入控件与聚焦按钮的按键不被劫持
- 站点头部新增「显示」控件,七个学习页面共用
- Playwright 新增 390×844 hasTouch 的 mobile 项目与移动用例(无溢出、可返回、
  触摸滑动不误开面板、深色与字号持久化、账号隔离);新增主题变量回归用例
- Wiki 记录 Architecture、Business-Rules、Local-Development 与需求更新
2026-09-15 15:11:30 +08:00
ila 5f86f17ac4 docs: record issue 13 acceptance and merge (#13)
- Wiki:Home、Architecture、Product-Requirements 与 Project-Profile 标记 #13 已验收并合入 main
- README 同步当前进度,剩余工作为 #14、#15、#21、#24 与缺陷 #32
- docs/ 由 harness.py sync 导出,revision:Architecture 2cfb6d79、Home d40a8f10、
  Product-Requirements f1f5f972、Project-Profile 0c035449
2026-09-15 14:31:18 +08:00
39 changed files with 4298 additions and 110 deletions
+7
View File
@@ -27,6 +27,11 @@
| 创建可选任务快照 | `python dev_scripts/harness.py archive 123 "修复登录超时"` |
| 增量导出已有快照 | `python dev_scripts/harness.py export` |
| 全量导出已有快照 | `python dev_scripts/harness.py export --all` |
| 后端二进制运维 | `./server/lexgo migrate\|bootstrap\|serve\|backup\|restore\|verify`(部署机无需 Python) |
| 检查部署依赖 | `python scripts/ops.py install-check` |
| 备份实例 | `python scripts/ops.py backup --out <目录>` |
| 恢复到空库 | `python scripts/ops.py restore --dump <备份.sql.gz> --database <库名> --confirm` |
| 校验实例 | `python scripts/ops.py verify --database <库名>` |
## 1. 永久规则
@@ -282,6 +287,8 @@ MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才
- #4 独立阅读选择小样位于 `spikes/selection/`,`python spikes/selection/serve.py` 默认仅本机 5184。桌面鼠标/键盘与 11 项测试已验证,#4 已获用户验收并关闭;真实手机长按/手柄/滚动详细证据仍未提供;禁止把窄屏桌面当作真机验收。Intl.Segmenter 只用于 UI 范围验证,不替代 #3 NLP;释义保存只在内存。固定 LinguaCafe 源码对照和与 v1 的差异记录见架构 Wiki。
- 2026-09-11 用户确认正式 NLP/词典采用全 Go。#5 已验收并合入 main;#6 使用 Go WordNet 解析和词形候选、Go Unicode 原文分片、schema v4 共享词典资源表,不调用 Python NLP。WordNet 3.0 ZIP 来源与摘要见 `server/wordnet-resource.json`,许可保留在 `server/WORDNET-LICENSE.txt`。词形候选不等于上下文消歧,不自动合并个人学习状态;#3 Python 小样只保留历史验证。当前词典仅英语释义,个人释义输入为临时草稿,持久化归 #7。
- 2026-09-11 用户确认 #7 个人词条口径(三项由 Agent 定案):身份为「学习者+语言+规范化词形」,大小写合并但**不按 lemma/候选合并**(`dog` 与 `dogs` 是两条记录);首次保存默认「新词」;状态为 新词/学习中/已知/忽略,只有「学习中」带 1~7 级,对应原版 stage 2/1/0/-1~-7;例句只保存手输内容,不自动关联原文句子。schema v5 新增 `lexgo_terms`(唯一键加状态/等级检查约束),个人释义与共享词典分离且不进入审计日志;等级编辑 UI 归 #8/#12。
- 2026-09-15 用户确认 #15 交付口径:只交付本机可复现的安装/备份/恢复材料并在本机演练,**不对外部署、不创建 release/tag、不邀请用户**;生产入口与 HTTPS 只写入文档;前端由反向代理托管 dist(不改后端代码);试用实例从**空库**开始、管理员由显式 bootstrap 建立、不带默认密码;备份=MySQL 全库 dump + 环境配置(凭据只存运维密码库,不进仓库/日志),不新增定时备份;性能用人造数据集实测并写明环境,只作观察不给承诺。备份/恢复规则:`restore` 必须 `--confirm`、默认只写空库、覆盖需 `--force`、库名必须含 lexgo 且不能是系统库、拒绝带 CREATE DATABASE/USE 的 dump,恢复前后比对源库逐表内容校验和。**附件(#21)尚未实施,恢复契约目前只覆盖数据库**;真实回滚、HTTPS、多机与定时备份仍未验证。部署与运维规则见 Wiki 页 `Deployment-and-Operations`(镜像 `docs/11-deployment-and-operations.md`)。
- 2026-09-15 用户确认 #14 显示与键盘口径:`theme ∈ {浅色,深色,跟随系统}`(默认跟随系统)与正文字号 `{标准,大,特大}`(1.0/1.15/1.3)**按账号保存在本机** `lexgo-learner-display:<账号 id>`,切换账号即换成该账号偏好或默认,退出回到默认,**不跨设备同步**;字号经 `--reader-font-scale` 只作用于阅读面(正文、释义内容、复习卡),不做全局缩放;深色用 `html[data-theme]` + Element Plus 的 `html.dark`,`style.css` 的 `:root` 是文件内仅有的颜色字面量;阅读位置按账号+章节保存滚动比例与该章 `content_sha256`,**正文换新版本后不恢复**;复习页 `空格`/`Enter` 显示答案、`1/2/3` 评分,输入类控件与聚焦按钮的按键不被劫持,带修饰键不拦截;移动验证用 390×844+`hasTouch` 的 Playwright `mobile` 项目(桌面项目 `testIgnore: mobile-*`),**真机长按选择与手感仍需人工确认**,不得用模拟设备结果冒充真机。本单无 schema 与接口变化。
- 2026-09-15 用户确认 #13 完成阅读与进度口径:`POST /api/v1/chapters/:id/complete` 只记已读、**不批量改变词语状态或等级**,只有 `ready` 章节可标记(其他 409),重复调用返回同一行且带 `duplicate`(不移动时间、不重复计数);完成记录保存标记时的 `content_sha256`,**正文新版本后该章回到未读**(记录保留,重读后更新同一行),只改标题不影响,章节删除随外键级联;`GET /api/v1/progress` 统计只含本人与当前语言,已读与分母都只算可阅读(`ready`)章节,已知/学习中/新词/忽略分开计数,`dueNow` 与到期复习队列共用 `dueTermsQuery` 与同一服务端时钟;schema 升到 v7(新表 `lexgo_chapter_progress`,不用 ALTER TABLE),需显式 migrate。不做每日目标、日历、难度评分、统计导出与取消已读,也不做 X10 批量标已知。
- 2026-09-15 既有缺陷记录:#32 编辑章节正文回到曾经用过的版本返回 500(`lexgo_ingest_jobs` 请求键与 `edit:<章节>:<内容摘要>` 冲突),已定位未修复,方案待用户确认;#13 的真实验证脚本因此自建 fixture 而不改写既有正文。
- 2026-09-11 用户确认 #12 词汇库口径:`GET /api/v1/terms` 列表同时匹配规范化身份键、显示原文与个人释义,大小写不敏感(`term` 为二进制排序规则,查询先转小写),`%`/`_`/`\` 按字面值转义,`status` 与 `kind` 可与搜索组合;分页 `page ≥ 1`、`limit ≤ 100`(默认 20)、按最近更新倒序;`PATCH /api/v1/terms/:id` 复用同一套释义/例句与状态/等级校验,**身份不可编辑**,只有状态或等级变化才重排复习时间,历史作答记录与计数保留;筛选与页码写入 `/vocab` URL。原型编辑页的「来自某章节」**不实现**(词条按身份存储、不引用章节);CSV(X04)、复习范围筛选(X11)、删除与批量清理(X14)不在范围内。
+4 -3
View File
@@ -2,16 +2,17 @@
面向自托管场景的阅读式语言学习项目,规划提供内容导入、阅读查词、词汇与短语、复习和实例管理。
已确认:**DevHarness 轻量模式、MySQL 8、go-admin 管理端**。工程基础 #2 已通过验收:两端用户名登录、学习账号管理、可撤销会话和本人英语空空间。管理端基于指定 go-admin/go-admin-ui 选用模块,学习端为独立 Vue 3 + TypeScript + Vite 工程,共用 Go 后端和 MySQL 8.4.3。#18 登录日志与操作审计已通过用户验收,支持管理员查询和 90 天保留清理。#5 粘贴导入与章节原文阅读已验收并合入 main;#6 全 Go 英语词典与点词查义已通过用户验收,PR #25 已合入 main;#7 个人词条与学习状态已通过用户验收,PR #26 已合入 main;#8 到期单词复习已通过用户验收,PR #27 已合入 main;#9 上传 TXT、校验编码后导入本人书库已通过用户验收,PR #28 已合入 main;#10 编辑与删除本人书籍章节已通过用户验收,PR #29 已合入 main;#11 连续短语的选择、保存与复习已通过用户验收,PR #30 已合入 main;#12 词汇库的搜索、筛选与编辑已通过用户验收,PR #31 已合入 main。#13 完成章节与个人基础进度已实现,待用户验收(新增 schema v7 与显式迁移)。试用交付仍未实现。MVP 定位为“支持多账号、数据独立的自托管学习工具”,先邀请少量用户使用;F01~F12 已确认,X 系列后置。
已确认:**DevHarness 轻量模式、MySQL 8、go-admin 管理端**。工程基础 #2 已通过验收:两端用户名登录、学习账号管理、可撤销会话和本人英语空空间。管理端基于指定 go-admin/go-admin-ui 选用模块,学习端为独立 Vue 3 + TypeScript + Vite 工程,共用 Go 后端和 MySQL 8.4.3。#18 登录日志与操作审计已通过用户验收,支持管理员查询和 90 天保留清理。#5 粘贴导入与章节原文阅读已验收并合入 main;#6 全 Go 英语词典与点词查义已通过用户验收,PR #25 已合入 main;#7 个人词条与学习状态已通过用户验收,PR #26 已合入 main;#8 到期单词复习已通过用户验收,PR #27 已合入 main;#9 上传 TXT、校验编码后导入本人书库已通过用户验收,PR #28 已合入 main;#10 编辑与删除本人书籍章节已通过用户验收,PR #29 已合入 main;#11 连续短语的选择、保存与复习已通过用户验收,PR #30 已合入 main;#12 词汇库的搜索、筛选与编辑已通过用户验收,PR #31 已合入 main。#13 完成章节与个人基础进度已通过用户验收,PR #33 已合入 main(schema v7 需显式迁移)。#14 桌面与手机体验、主题与键盘操作已通过用户验收,PR #34 已合入 main。试用交付仍未实现。MVP 定位为“支持多账号、数据独立的自托管学习工具”,先邀请少量用户使用;F01~F12 已确认,X 系列后置。
- [文档入口](docs/README.md) · [线上 Wiki](https://git.ilapage.cn/OPC/lexgo/wiki/Home)
- [英语分词与离线词典验证小样](spikes/english/README.md)(#3 已验收,独立本机入口)
- [阅读选择验证小样](spikes/selection/README.md)(#4 已验收,真机详细测试证据缺口保留)
- [项目档案](docs/00-project-profile.md) · [需求总览](docs/09-product-requirements-overview.md)
- [工作量估算](docs/10-workload-estimate.md):#2、#3、#4、#5、#6、#7、#8、#9、#10、#11、#12、#18 已验收,#13 已实现待验收,原规划中的 #5 已完成;剩余 #14、#15 与新增 #21、#24 按工单复核;后续结合集成结果重估,旧全量研究仅供参考。
- [工作量估算](docs/10-workload-estimate.md):#2、#3、#4、#5、#6、#7、#8、#9、#10、#11、#12、#13、#14、#18 已验收,原规划中的 #5 已完成;#15 已实现待验收;新增 #21、#24 待排期;缺陷 #32 待确认修复方案;后续结合集成结果重估,旧全量研究仅供参考。
- [四阶段实施总览 #16](https://git.ilapage.cn/OPC/lexgo/issues/16):14 张单元工单,工程基础 → 技术验证 → 首条学习闭环 → 补齐 MVP;原型 v1 已获用户验收。两端使用账号(用户名)+密码登录,不要求邮箱。
- [原型工单 #1](https://git.ilapage.cn/OPC/lexgo/issues/1):Quant-UX 桌面/手机原型 v1,预览入口与审核记录见工单及需求总览。
- [工作流](docs/01-workflow.md) · [开发与验证](docs/04-local-development-and-verification.md)
- 运维命令:`./lexgo migrate|bootstrap|serve|backup|restore|verify`(纯二进制),开发便利与交叉验证走 `python scripts/server.py …` / `python scripts/ops.py …`
- [工作流](docs/01-workflow.md) · [开发与验证](docs/04-local-development-and-verification.md) · [部署与运维](docs/11-deployment-and-operations.md)
## 本地工程基础
+10 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Project-Profile
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Project-Profile.-
wiki_revision: 78760fdce51217ebd047a05207d327b4874691d2
synchronized_at: 2026-09-15T03:50:38Z
wiki_revision: 5cf8e02e2248e93b1255ad85fb41153984c84cd1
synchronized_at: 2026-09-15T07:44:49Z
<!-- gitea-wiki-mirror:end -->
# LexGo 项目档案
@@ -170,3 +170,11 @@ server 是 go-admin 的选用模块接入:原样保留 SysUser、SysDept、必
## #12 当前工程状态(2026-09-15)
#11已通过用户验收并合入main。#12词汇库的搜索、筛选与编辑已按用户确认的契约实现,并于2026-09-15通过用户验收,PR #31已fast-forward-only合入main。本单没有数据库结构变化,schema仍为v6。`GET /api/v1/terms`提供本人当前语言的分页列表,搜索同时匹配规范化身份键、显示原文与个人释义且大小写不敏感(term列为二进制排序规则,查询先转小写),通配符按字面值转义,状态与类型可与搜索组合,分页默认20、上限100并按最近更新倒序;`PATCH /api/v1/terms/:id`复用同一套释义/例句与状态/等级校验,身份不可编辑,只有状态或等级变化才重排复习时间,历史作答记录与计数保留。学习端新增生词本页面,含搜索、筛选、分页、空态、无结果清除与编辑对话框(学习中显示1~7等级选择器),筛选与页码写入URL;表单字段抽成共享组件与阅读器面板复用。原型编辑页的「来自某章节」不实现,因为词条按身份存储、不引用章节。第4阶段已完成#9、#10、#11与#12;剩余#13~#15与#21、#24尚未实施。
## #13 当前工程状态(2026-09-15)
#12已通过用户验收并合入main。#13章节完成与个人基础进度已按用户确认的契约实现,并于2026-09-15通过用户验收,PR #33已fast-forward-only合入main。schema升到v7,新增lexgo_chapter_progress(一章一行、按content_sha256做内容版本门控、级联到章节与账号),仍然只使用可重放的CREATE TABLE IF NOT EXISTS,迁移需显式执行。POST /api/v1/chapters/:id/complete只记已读、不改变任何词语状态或等级,只有处理完成的章节可标记,重复调用返回同一条记录且不重复计数;GET /api/v1/progress按本人与当前语言统计已读章节(分子与分母都只含可阅读章节)、已知、学习中、新词、忽略、已保存词条与待复习,并给出每本书的已读进度,其中待复习与到期复习队列共用同一查询与同一服务端时钟。学习端新增进度页面与阅读页的显式「标记本章已读」,书籍页显示已读标记。正文改成新版本后该章自动回到未读并可在重读后更新同一条记录,删除章节时记录随之消失。第4阶段已完成#9、#10、#11、#12与#13;剩余#14、#15与#21、#24尚未实施。此外发现并单独记录了既有缺陷#32(编辑章节正文回到曾用版本返回500,已定位未修复,修复方案待用户确认)。
## #14 当前工程状态(2026-09-15)
#13已通过用户验收并合入main。#14桌面与手机体验、显示偏好与键盘操作已按用户确认的契约实现,并于2026-09-15通过用户验收,PR #34已fast-forward-only合入main。本单没有数据库与接口变化。学习端站点头部新增「显示」控件:主题可选浅色、深色或跟随系统,正文字号可选标准、大或特大(1.0/1.15/1.3),两者按账号保存在本机(键名含账号id),切换账号立即换成该账号自己的偏好或默认、退出登录回到默认、非法存储值回退默认,且不跨设备同步;字号只作用于阅读面(章节正文、释义面板内容、复习卡的词与释义),不做全局缩放。样式表收敛为一套语义调色板,:root的53个变量是文件内仅有的颜色字面量,深色通过html[data-theme]与Element Plus的html.dark只覆盖变量。阅读位置按账号与章节保存滚动比例和该章content_sha256,再次打开时恢复,正文换成新版本后不再套用旧位置。复习页支持空格或Enter显示答案、1/2/3分别答对/答错/再学一次,输入框与文本域内不触发快捷键,聚焦按钮只保留它自身的空格与Enter,所有可交互元素都有可见焦点环。移动端用390×844触摸视口的Playwright项目验证:八个界面无横向溢出且都能返回,触摸滑动可滚动且不误开释义面板,深色与特大字号刷新后保持且账号间不串。第4阶段已完成#9~#14;剩余#15与#21、#24以及已定位未修复的缺陷#32。真实手机的长按选择、手柄拖动、软键盘与安全区表现仍需人工确认,本单的触摸证据来自模拟设备。
+57 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Architecture-and-Code-Map.-
wiki_revision: 9bc2de753de2249c586306cdc27b566052941da2
synchronized_at: 2026-09-15T04:31:24Z
wiki_revision: fabdaf13861b4cae9ad6cd66a5bd904c08361f73
synchronized_at: 2026-09-15T09:08:48Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -344,7 +344,7 @@ schema v6 新增 `lexgo_term_reviews`(每个个人词条一行排期:`due_at
学习端 `stores/vocabulary.ts` 保存列表状态与筛选(打开/关闭编辑对话框、从阅读器返回都不会丢),`views/VocabView.vue` 提供搜索框与显式搜索按钮、状态与类型筛选、分页、空态与「没有匹配的词条 + 清除搜索与筛选」,并把筛选与页码同步到 `/vocab?...`(`router.replace`)。释义/例句/状态/等级四个字段抽成 `components/TermFormFields.vue`,与阅读器面板共用;等级选择器只在词汇库编辑对话框中出现(阅读器面板仍只有四个状态)。
## #13 章节完成与个人基础进度(2026-09-15)
## #13 章节完成与个人基础进度(2026-09-15,已验收并合入 main)
schema v7 新增 `lexgo_chapter_progress`:一章一行(`chapter_id` 主键),带 `owner_id`、`book_id`、`language`、`read_sha256`(标记时该章 `content_sha256` 的快照)、`read_at` 与时间戳,外键级联到章节、书籍与账号。仍然只加新表,不用 `ALTER TABLE ADD COLUMN`,保持每个版本 DDL 可重放。
@@ -356,3 +356,57 @@ schema v7 新增 `lexgo_chapter_progress`:一章一行(`chapter_id` 主键
`server/app/lexgo/progress.go` 集中实现:`CompleteChapter`(事务内锁定章节,缺行则插入,内容相同则原样返回,内容不同则把同一行推进到新版本)、`readAtByChapter`(批量取章节的已读时间,只认可「标记快照=当前内容」且章节 `ready`)、`LearnerProgressFor`(阅读分母与分子都只算 `ready` 章节,词条按四种状态分组,`dueNow` 复用 `dueTermsQuery`)。`review.go` 抽出 `dueTermsQuery`,到期复习队列和进度页共用同一谓词与同一服务端时钟,避免两处漂移。`ChapterSummary` 增加 `readAt` 字段,`BookDetail`、`ChapterDetail` 与章节编辑响应都会解析它,所以列表、阅读器和编辑后的状态一致。
学习端新增 `/progress` 页面与导航「进度」(`stores/progress.ts`、`views/ProgressView.vue`):已读章节、待复习、已知词、学习中、新词、忽略六张卡片,加每本书的已读进度与进入书籍的链接、已保存词条总数、刷新与失败重试、无书时的引导。阅读器正文下方新增显式的「标记本章已读」区块(`ReaderView.vue`,`data-testid="mark-read"`),显示已读时间与重复标记提示;书籍页章节列表对已读章节显示「已读」标记(`BookView.vue`)。`stores/library.ts` 增加 `markChapterRead`,只把 `readAt` 写回章节列表与打开中的阅读器,不动其他字段。
## #14 桌面与手机体验、主题与键盘(2026-09-15,已验收并合入 main)
**没有 schema 变化、没有新接口**:本单只改学习端与样式。事实先行:用带触摸的 390×844 移动视口逐个打开登录/书库/导入/书籍/阅读/查词面板/生词本/复习/进度,`scrollWidth` 全部等于 `innerWidth`,没有溢出元素——前几单的响应式基础成立,本单把它固化成自动化移动测试,并补上缺失的偏好与键盘操作。
| 新增文件 | 职责 |
|---|---|
| `learner/src/stores/preferences.ts` | 主题(浅色/深色/跟随系统)与正文字号(标准/大/特大);按账号存在 `lexgo-learner-display:<账号 id>` |
| `learner/src/components/DisplaySettings.vue` | 站点头部统一的「显示」下拉,展示当前值并可键盘操作 |
| `learner/src/composables/readingPosition.ts` | 按账号+章节保存滚动比例与该章 `content_sha256` |
| `learner/src/composables/reviewShortcuts.ts` | 复习页键盘映射与「不抢输入框」的判断 |
`style.css` 收敛为一套语义调色板:`:root` 里 53 个变量是**文件内仅有的颜色字面量**,其余规则全部走 `var(...)`;`html[data-theme='dark']` 覆盖同一批变量,`html.dark` 同时映射 Element Plus 的暗色变量(`main.ts` 引入 `element-plus/theme-chalk/dark/css-vars.css`),所以页面与组件跟随同一个选择。字号经 `--reader-font-scale` 只作用于阅读面(章节正文、释义面板内容、复习卡的词与释义),不做全局缩放。`ReaderView.vue` 在章节就绪后把正文下方的位置恢复到上次比例(正文版本变化则不恢复,等待可滚动后再应用,最多约 1 秒后放弃),并在滚动时按 400ms 防抖保存。`ReviewView.vue` 加了 `useReviewShortcuts` 与一行快捷键提示;`DisplaySettings` 出现在七个学习页面的头部。
移动测试项目:`playwright.config.ts` 增加 `mobile`(390×844,`isMobile` + `hasTouch`,Chromium),桌面项目用 `testIgnore` 排除 `mobile-*.spec.ts`,移动项目用 `testMatch` 只跑它们。`e2e/mobile-fixtures.ts` 提供移动端共用的 mock、无溢出检查与经 CDP `Input.dispatchTouchEvent` 的真实触摸滑动。
## #15 自托管交付:部署拓扑与运维工具(2026-09-15)
**没有 schema 变化、没有接口变化**:本单新增运维工具、部署文档与完整演练证据。
| 新增文件 | 职责 |
|---|---|
| `scripts/ops.py` | `install-check`(依赖与资源版本)、`init-database`(建空库并提示最小权限)、`backup`(全库 dump + `manifest.json`)、`restore`(默认只写空库,写后自动校验)、`verify`(数据库完整性 + 可选两账号接口闭环)、`smoke`(在干净实例上建两个演练账号走通学习闭环) |
| `scripts/bench.py` | 在写明规模的人造数据集上测量接口耗时,输出数据量、机器信息与 p50/p95,不做容量承诺 |
| `tests/test_lexgo_ops.py` | 不需要数据库的规则测试:版本号解析、库名白名单、dump 是否带库名切换、manifest 字段白名单、审计敏感列清单 |
**一次备份包含什么**:LexGo 把所有持久数据都放在 MySQL 里——账号与会话、书籍与章节原文、导入任务、词典归档(LONGBLOB)、个人词条、复习排期与作答、阅读进度、审计日志。因此备份 = 全库 dump + `.env.local`(凭据单独从运维密码库取)。词典资源在显式导入后进入数据库,恢复即带走,运行时不下载。
**恢复的安全边界**:`restore` 必须带 `--confirm`;库名必须含 `lexgo` 且不能是系统库;目标库已有数据时默认拒绝,覆盖需要 `--force`;拒绝加载带 `CREATE DATABASE`/`USE` 的旧式 dump(那会把数据写进文件里指定的库);恢复前后比对**源库**逐表内容校验和,源库被改动就中止。
**部署文档**:新建 Wiki 页 `Deployment-and-Operations`(`docs/11-deployment-and-operations.md`,按 `docs/templates/deployment.md` 结构),并加入 `wiki-docs.json` 映射。内容含服务概览、环境要求(含「MySQL 客户端版本不得低于服务端」这条实测规则)、首次部署、配置与凭据来源、日常运维、健康检查、升级与回滚、备份与恢复、已知限制。
**视图与进程**:后端单二进制监听 `127.0.0.1:8000`;两份 SPA 由反向代理托管 `dist`,反代把 `/api/` 转发到后端并把未知路径回落到 `index.html`;本机开发用 supervisor 托管 `lexgo-api`/`lexgo-learner`/`lexgo-admin` 三个 program。
## #15 自托管交付:部署拓扑与运维工具(2026-09-15)
**没有 schema 变化、没有接口变化**:本单新增运维工具、部署文档与完整演练证据。
| 新增文件 | 职责 |
|---|---|
| `server/cmd/lexgo`(追加子命令) | `backup`、`restore`、`verify`:与既有 `migrate`/`bootstrap`/`serve`/`audit-cleanup` 同一入口,部署机只需要二进制与 MySQL 客户端;备份仍调用 `mysqldump`,dump 与 manifest 格式与工具通道完全一致,可互相读取 |
| `scripts/ops.py` | `install-check`(依赖与资源版本)、`init-database`(建空库并提示最小权限)、`backup`(全库 dump + `manifest.json`)、`restore`(默认只写空库,写后自动校验)、`verify`(数据库完整性 + 可选两账号接口闭环)、`smoke`(在干净实例上建两个演练账号走通学习闭环) |
| `scripts/bench.py` | 在写明规模的人造数据集上测量接口耗时,输出数据量、机器信息与 p50/p95,不做容量承诺 |
| `tests/test_lexgo_ops.py` | 不需要数据库的规则测试:版本号解析、库名白名单、dump 是否带库名切换、manifest 字段白名单、审计敏感列清单 |
**一次备份包含什么**:LexGo 把所有持久数据都放在 MySQL 里——账号与会话、书籍与章节原文、导入任务、词典归档(LONGBLOB)、个人词条、复习排期与作答、阅读进度、审计日志。因此备份 = 全库 dump + `.env.local`(凭据单独从运维密码库取)。词典资源在显式导入后进入数据库,恢复即带走,运行时不下载。
**恢复的安全边界**:`restore` 必须带 `--confirm`;库名必须含 `lexgo` 且不能是系统库;目标库已有数据时默认拒绝,覆盖需要 `--force`;拒绝加载带 `CREATE DATABASE`/`USE` 的旧式 dump(那会把数据写进文件里指定的库);恢复前后比对**源库**逐表内容校验和,源库被改动就中止。
**部署文档**:新建 Wiki 页 `Deployment-and-Operations`(`docs/11-deployment-and-operations.md`,按 `docs/templates/deployment.md` 结构),并加入 `wiki-docs.json` 映射。内容含服务概览、环境要求(含「MySQL 客户端版本不得低于服务端」这条实测规则)、首次部署、配置与凭据来源、日常运维、健康检查、升级与回滚、备份与恢复、已知限制。
**两条通道**:Go 二进制提供数据库层的备份、恢复与校验(`lexgo backup|restore|verify`),Python 工具提供依赖检查、建库与 HTTP 接口级的两账号闭环(`ops.py smoke` / `verify --api`)。两者共用同一份 `LEXGO_*` 配置、同一套 manifest 与安全规则;2026-09-15 交叉验证两条通道可互相恢复对方的备份。
**视图与进程**:后端单二进制监听 `127.0.0.1:8000`;两份 SPA 由反向代理托管 `dist`,反代把 `/api/` 转发到后端并把未知路径回落到 `index.html`;本机开发用 supervisor 托管 `lexgo-api`/`lexgo-learner`/`lexgo-admin` 三个 program。
+30 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Business-Rules-and-Glossary.-
wiki_revision: 67b43213bdbf87c5af03c324d40ccc1b37a11ea6
synchronized_at: 2026-09-15T04:31:24Z
wiki_revision: 5f2912edd09256cf5fc52059145d65918e99f9cb
synchronized_at: 2026-09-15T08:27:38Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -276,3 +276,31 @@ exact优先;未命中再按WordNet异常表/词尾规则查候选,词性顺
- **待复习**使用与到期复习队列完全相同的谓词(状态为新词或学习中、且 `due_at` 不晚于当前服务端时钟)与同一时钟,页面数字与复习队列 `total` 一致。
**范围边界**:不含每日目标、日历、连续天数、难度评分;不含统计导出;不做「取消已读」(正文版本变化会自然回到未读);不包含复习范围筛选(X11)与练习模式(X08)。
## #14 显示偏好、阅读位置与键盘操作规则(2026-09-15)
**主题**:可选浅色、深色、跟随系统,默认跟随系统。显式选择不受系统变化影响;选择「跟随系统」时监听系统配色变化并实时切换。深色只改变颜色变量,不改变布局与功能。
**正文字号**:三档「标准 / 大 / 特大」(1.0 / 1.15 / 1.3 倍),作用于阅读面(章节正文、释义面板正文与例句、复习卡的词与释义)。这是有意的边界:不做全局 UI 缩放,避免窄屏下导航与按钮被挤坏。
**偏好的保存与账号隔离**:主题与字号保存在本机,键包含账号 id,因此**切换账号会立刻换成该账号自己的偏好或默认值,A 的选择不会带给 B**;未登录的登录页使用系统默认;退出登录后回到默认。**不跨设备同步**(范围外)。存储值读不出、不是合法取值或来自旧格式时回退到默认,而不是部分套用或抛错。
**保留阅读位置**:按「账号+章节」保存滚动比例,并记录当时的 `content_sha256`。再次打开同一章时恢复到该比例;**正文被改成新版本后不恢复**(旧偏移指向别的文字),同账号的其他章节互不影响。位置为 0(还没滚动)不保存;比例按窗口宽度换算,所以在桌面与手机上都能落在相近位置。本机行为,不跨设备。
**键盘操作**:复习页 `空格`/`Enter` 显示答案,`1` 认识/答对、`2` 不认识/答错、`3` 再学一次;界面给出快捷键提示,并保持「显示答案」与「答对」按钮自动获得焦点,键盘用户可以全程不碰鼠标。按键规则:**输入框、文本域、下拉与可编辑区域内的任何按键都不触发快捷键**;按下的键若属于当前聚焦控件(按钮与链接的 `空格`/`Enter`)则交给浏览器处理,避免重复触发;带修饰键的组合键一律不拦截。阅读器 `Esc` 关闭释义面板,面板内的 `Esc` 只关面板。
**触摸与滚动**:没有任何阻止默认行为的触摸处理;释义面板在窄屏是底部固定面板,页面为其预留底部空间,关闭后阅读位置不跳动。**长按原生选择与手柄拖动的手感仍需真机确认**,本单的触摸证据来自模拟设备。
## #15 交付与运维规则(2026-09-15)
**数据边界**:一个 LexGo 实例的全部持久数据都在 MySQL 中(账号、空间、会话、审计日志、书籍与章节原文、导入任务、词典归档、词条、复习排期、复习作答、阅读进度)。备份的完整对象因此是「全库 dump + 环境配置文件」;环境配置含凭据,单独保存在运维密码库,不放进备份目录。
**备份规则**:`backup` 使用 `mysqldump --single-transaction --routines --triggers --hex-blob --no-tablespaces`,输出 `.sql.gz` 与 `manifest.json`。manifest 记录创建时间、库名、产品标识、schema 版本、Git 提交、dump 的 sha256 与体积、逐表行数、客户端与服务端版本、包含与排除的内容说明;**不记录任何凭据值**。备份文件含个人数据,按个人数据对待:受控存放、不进 Git、不进工单。
**恢复规则**:恢复是写入操作,必须显式 `--confirm`;默认只写**空库**,目标库已有数据时拒绝,覆盖需要 `--force` 并在覆盖前另行备份;库名必须含 `lexgo` 且不能是 MySQL 系统库;带 `CREATE DATABASE`/`USE` 的 dump 一律拒绝(会写错库);恢复前后比对源库逐表内容校验和,源库变化即中止。
**升级与回退规则**:schema 迁移只新增对象(`CREATE TABLE IF NOT EXISTS`),旧二进制按版本号校验;回退到旧二进制前把 `lexgo_schema.version` 写回旧版本号。若迁移删改数据或旧二进制无法读写新结构,则按恢复流程处理。**真实回滚演练尚未执行**,属已知限制。
**安装规则**:空库不含默认密码与任何演示数据;管理员只能由显式 `bootstrap` 建立,库中已有账号时拒绝再次执行、不覆盖既有管理员;应用账号只对该库拥有最小权限,不使用管理员账号运行应用;服务默认只监听 `127.0.0.1`,对外由反向代理转发。
**验收账号规则**:试用验证使用虚构的演练账号,密码由演练进程生成、只经环境变量传入,不写入磁盘、日志、工单或截图。
+274 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Local-Development-and-Verification.-
wiki_revision: 8222610b19a406a74771ded2f3edfa94a69f6295
synchronized_at: 2026-09-15T04:31:24Z
wiki_revision: fa4c663fabf5db418104fc53b7ce00f41f5ba910
synchronized_at: 2026-09-15T09:11:27Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -474,3 +474,275 @@ node --test spikes/english/view.test.mjs
**本单顺带记录一个既有缺陷**:编辑章节正文回到曾经用过的版本会返回 500(#32,`lexgo_ingest_jobs` 唯一请求键与 `edit:<章节>:<内容摘要>` 键冲突),不属于本单范围,未修改代码;真实验证脚本因此改为自建 fixture 而不改写既有正文。
未验证:真实手机触屏与滚动证据仍属既有缺口(#14 承接);阅读进度没有按书排序或搜索(进度页按书籍编号列出);没有大数据量下的统计性能压测(统计为带前缀索引的计数查询)。
## #14 移动与主题验证(2026-09-15)
本单**无 schema 变化、无迁移**,纯学习端与文档改动;回退只需回到上一提交。
| 命令 | 结果 |
|---|---|
| `cd learner`:`npx vitest --run` / `npx vue-tsc --build` / `npx pnpm run build` / `npx playwright test` | 147 项单测、类型检查、构建、**23 项 E2E**(桌面 19 + 移动 4)全部通过;本单新增 20 单测与 8 项 E2E |
| `cd admin`:`npx pnpm test` / `npx pnpm lint` | 31 项与 lint 通过;管理端无改动 |
| `python -m unittest discover -s tests` / `python dev_scripts/harness.py check --strict` | 56 项与严格检查通过 |
| `LEXGO_TEST_DB_NAME=lexgo_test_issue13 python scripts/server.py test-integration` | 70 项通过(后端未改动,仍复跑一遍确认) |
**移动项目**:`npx playwright test --project=mobile` 使用 390×844、`isMobile` 与 `hasTouch` 的 Chromium。覆盖内容:八个界面(书库/导入/书籍/阅读/查词面板/生词本/复习/进度)在 390px 下 `scrollWidth ≤ innerWidth` 且没有越界元素、每页都能返回书库、显示控件可用手指点开且高度不小于 36px、经 CDP 触摸事件的上滑真的滚动了章节且没有误开释义面板、面板不越过视口且页面为它预留底部空间、关闭面板后阅读位置不跳、深色与特大字号在刷新后保持、退出登录回到默认且同一账号回来仍是自己的选择。
**主题回归测试**:`e2e/theme.spec.ts` 逐个读回 16 个调色板变量,断言浅色与深色下都解析成非空值、页面背景不是透明、正文与背景不同色、深浅两套取值不同、Element Plus 的 `--el-bg-color` 也跟随。这条用例来自本单的一个真实教训:批量把颜色字面量替换为变量时,调色板自身的定义也被替换成了自引用变量,所有颜色静默失效——几何断言全过,只有焦点环探针把问题暴露出来,所以补了这条变量级回归。
**真实链路**:真实学习端+真实 Go API+真实 MySQL 走了一遍——粘贴 40 行长章节并处理完成,滚到 45% 后离开再返回,位置被恢复(偏差 < 150px,页面提示「已回到上次阅读位置」);切到深色+特大后刷新仍保持,正文实际字号变大(> 18px);在真实到期卡上按空格显示了答案(**故意不评分**,避免改动 fixture 账号的真实复习计划);另用 390×844 触摸上下文在真实数据上检查了书库/生词本/进度无横向溢出。fixture 书籍在结束时删除;截图见 `.local/evidence/issue14-real-*.png`、`.local/evidence/issue14-mobile-*.png`、`issue14-theme-*.png`、`issue14-keyboard-review.png`。临时用例运行后删除。
**未验证**:真实手机上的长按选择、手柄拖动、滚动惯性、软键盘遮挡与安全区表现无法由本机自动化产生——本单的触摸证据来自模拟设备;**真机验证仍需人工完成**,这一点在 #4、#11 就已记录,本单未能消除。也未做 Safari/Firefox 等其他引擎的验证(项目既有测试只在 Chromium 上跑)。
## #15 交付、备份与恢复验证(2026-09-15)
本单**无 schema 变化、无迁移**:新增运维脚本、部署文档与演练证据。所有命令在仓库根执行;MySQL 客户端用 8.4(`LEXGO_MYSQL_BIN=D:/mysql843/bin`),因为本机 PATH 上是 5.7 客户端,`install-check` 会把它判为不达标。
### 运维命令
| 用途 | 命令 |
|---|---|
| 依赖与资源检查 | `python scripts/ops.py install-check` |
| 建空库并提示权限 | `python scripts/ops.py init-database --database <库名>` |
| 备份 | `python scripts/ops.py backup --out <目录>` |
| 恢复到空库 | `python scripts/ops.py restore --dump <备份.sql.gz> --database <库名> --confirm` |
| 校验(数据完整性,可选接口闭环) | `python scripts/ops.py verify --database <库名> [--manifest <manifest.json>] [--api http://127.0.0.1:<端口> --user <前缀> --password-env <变量名>]` |
| 干净实例两账号闭环演练 | `python scripts/ops.py smoke --api <url> --admin-user <账号> --user <前缀>` |
| 性能测量 | `python scripts/bench.py --database <库名> --api <url> --user <账号>` |
### 完整演练(2026-09-15,本机一次性通过,27 项顶层检查全部 `[ok]`)
演练脚本(`.local/tmp/issue15-drill.py`,一次性工具)依次执行:
1. **干净安装**:`init-database` 建空库 → `migrate`(schema v7,14 张表)→ 确认「新库没有账号、没有业务数据」→ `bootstrap` 建管理员 → **再次 `bootstrap` 被拒绝**(退出码 1,不覆盖管理员)→ 起独立 API 实例(:8010)。
2. **两个演练账号走通闭环**(`ops.py smoke`,19 项全过):管理员建 `trial_a`/`trial_b` → 新账号书库与进度为空 → 粘贴章节 → 章节处理完成 → 分词返回可点选词 → 词典查询返回明确状态 → 保存词义 → 另一账号查不到该词条 → 新词立刻到期 → 作答成功 → 重复作答不重复记账 → 完成章节只记已读 → 进度反映活动;`B` 读 `A` 的书 404、书库仍为空。
3. **备份 lexgo_dev**(约 12 MB gz):manifest 记录 schema 7、提交哈希、逐表行数、sha256;确认 manifest 无凭据字段;确认 dump 不含 `CREATE DATABASE`/`USE`(不会误写回源库)。
4. **恢复到空库** `lexgo_restore_drill` 并自动校验(33 项全过):逐表行数与备份一致(`sys_user` 6、`books` 27、`chapters` 33、`terms` 13、`term_reviews` 13、`review_answers` 55、`login_logs` 192 等)→ **逐表内容校验和与源库一致**(13 张表)→ **源库 lexgo_dev 未被改动**。
5. **安全边界**:恢复默认拒绝覆盖已有库;拒绝写入系统库 `mysql`;不带 `--confirm` 直接拒绝。
6. **恢复实例的两账号闭环与越权校验**(`:8011`,41 项全过)——恢复后的账号仍可登录、词条与到期队列仍在、答题与完成章节保持幂等、`B` 无法读取或修改 `A` 的数据。
7. **收尾**:删除两个演练库,确认 `lexgo_dev` 与测试库保留,`lexgo_dev` 仍是 schema 7。
### 性能观察(`scripts/bench.py`,人造数据集,单机无并发)
数据集:1 个账号、20 本书 20 章(每章约 500 个词)、2000 词条、8000 条复习作答。环境:Windows 10(10.0.19044)、8 核、31.7 GB 内存、MySQL 8.4.3、Go 工具链由 `scripts/server.py` 固定为 1.26.5。
| 接口 | 请求数 | p50 | p95 | 最大 | 错误 |
|---|---|---|---|---|---|
| `POST /login` | 10 | 82.2 ms | 93.3 ms | 93.3 ms | 0 |
| `GET /books` | 30 | 5.9 ms | 18.1 ms | 18.2 ms | 0 |
| `GET /chapters/1` | 30 | 8.3 ms | 20.1 ms | 22.0 ms | 0 |
| `GET /chapters/1/tokens` | 30 | 10.0 ms | 23.6 ms | 30.3 ms | 0 |
| `GET /terms`(第 1 页) | 30 | 10.2 ms | 20.4 ms | 21.5 ms | 0 |
| `GET /terms?query`(搜索) | 30 | 12.3 ms | 24.9 ms | 27.0 ms | 0 |
| `GET /progress` | 30 | 15.7 ms | 26.1 ms | 29.1 ms | 0 |
| `GET /reviews/queue` | 30 | 22.7 ms | 32.3 ms | 34.2 ms | 0 |
**这些是观察值,不是容量承诺**:单机、单进程、无并发压力;登录接口每地址每分钟限 30 次,因此登录只测 10 次,该数字不代表登录吞吐。原始报告:`.local/issue15-bench.json`。
### 集成验证矩阵(复用既有 Go 测试,`LEXGO_TEST_DB_NAME=lexgo_test_issue13` 70 项全过)
| 验收要求 | 覆盖用例(全部通过) |
|---|---|
| 越权与隔离 | `TestMySQLLibraryIsolationAndOwnership`、`TestMySQLTermIsolationAndInputRules`、`TestMySQLPhraseRulesAndIsolation`、`TestMySQLReviewAnswerErrorsAndOwnership`、`TestMySQLAccountIsolationAndRevocation` |
| 任务崩溃重试 | `TestMySQLIngestRecoveryWithoutRestart`、`TestMySQLIngestRecoveryAfterRestart`、`TestMySQLIngestAttemptsAreBoundedAndManualRetryRestarts`、`TestMySQLRetryAfterContentRestoredPublishesSameChapter`、`TestMySQLRecoverySkipsSupersededJobs` |
| 并发答题与并发删除 | `TestMySQLReviewConcurrentReplayOfOneAnswer`、`TestConcurrentDuplicateAccountHasOneWinner`、`TestMySQLConcurrentChapterDelete`、`TestMySQLDeleteDuringProcessing` |
| 长文与输入上限 | `TestMySQLPasteRejectsInvalidInputAndLimits`、`TestMySQLTextUploadKeepsChapterLimit`、`TestMySQLTextUploadRejectsInvalidSubmissions`、`TestMySQLTextUploadRejectsInvalidSubmissions` |
| 迁移与回退标记 | `TestMigrationRefusesUnownedOrUnsupportedSchema`、`TestMigrationCanResumeOwnedPartialSchema`、全部 `TestMigrationFrom*` |
### 治理与其它测试
`python -m unittest discover -s tests` 65 项通过(新增 9 项 `tests/test_lexgo_ops.py`,不需要数据库);`harness.py check --strict` 与 `sync --check` 通过。本单未改前端与后端业务代码,仍复跑 `LEXGO_TEST_DB_NAME=lexgo_test_issue13 python scripts/server.py test-integration` 70 项通过。
### 未验证
- **真实回滚(升级后切回旧二进制)未演练**:文档给出规则(迁移只新增对象,回退前写回版本号),但没有在真实实例上做过升级/回滚全过程。
- 未验证 HTTPS、域名、反代配置、多机与灰度;本机演练只在 `127.0.0.1` 上起两个临时实例。
- 未做定时备份、监控与告警;备份与恢复均为人工触发。
- 大库恢复耗时与磁盘空间上限未测;演练库与生产库规模不同。
- 附件(封面/音频,#21)尚未实现,恢复范围目前只覆盖数据库。
## #15 交付、备份与恢复验证(2026-09-15)
本单**无 schema 变化、无迁移**:新增运维脚本、部署文档与演练证据。所有命令在仓库根执行;MySQL 客户端用 8.4(`LEXGO_MYSQL_BIN=D:/mysql843/bin`),因为本机 PATH 上是 5.7 客户端,`install-check` 会把它判为不达标。
### 运维命令
纯二进制路径(部署机不需要 Python):
| 用途 | 命令 |
|---|---|
| 迁移 / 初始化管理员 | `./lexgo migrate` / `./lexgo bootstrap` |
| 备份 | `./lexgo backup --out <目录>` |
| 恢复到空库 | `./lexgo restore --dump <备份.sql.gz> --database <库名> --confirm` |
| 校验 | `./lexgo verify --database <库名> [--manifest <manifest.json>]` |
工具通道(开发便利与交叉验证):
| 用途 | 命令 |
|---|---|
| 依赖与资源检查 | `python scripts/ops.py install-check` |
| 建空库并提示权限 | `python scripts/ops.py init-database --database <库名>` |
| 备份 | `python scripts/ops.py backup --out <目录>` |
| 恢复到空库 | `python scripts/ops.py restore --dump <备份.sql.gz> --database <库名> --confirm` |
| 校验(数据完整性,可选接口闭环) | `python scripts/ops.py verify --database <库名> [--manifest <manifest.json>] [--api http://127.0.0.1:<端口> --user <前缀> --password-env <变量名>]` |
| 干净实例两账号闭环演练 | `python scripts/ops.py smoke --api <url> --admin-user <账号> --user <前缀>` |
| 性能测量 | `python scripts/bench.py --database <库名> --api <url> --user <账号>` |
### 两条通道的交叉验证(2026-09-15,19 项检查全部通过)
Go 二进制与 Python 工具各做一次备份,并**互相恢复对方的备份**:
1. 两条通道的 `manifest.json` 字段齐备、逐表行数一致、`schema_version` 一致、都不含凭据字段;
2. Go 产出的 dump 由 Python 恢复成功,Python 产出的 dump 由 Go 恢复成功(`lexgo restore` 输出 `verified`);
3. Go 校验 Go 恢复出来的库、Python 校验 Python 恢复出来的库,逐表行数与 manifest 一致;
4. 两个恢复实例的**逐表内容校验和都与源库一致**(13 张表),源库在过程中未被改动;
5. `lexgo verify` 可以直接校验源库;
6. 安全边界两条通道各自确认:Go 拒绝覆盖已有库、要求 `--confirm`,Python 拒绝系统库。
证据:`.local/issue15-crosscheck-evidence.json`。Go 侧另有 `TestMySQLOpsBackupRestoreRoundTrip` 集成用例(备份 → 恢复到空库 → 校验 → 拒绝二次覆盖 → 源库校验和不变)。
### 完整演练(2026-09-15,本机一次性通过,27 项顶层检查全部 `[ok]`)
演练脚本(`.local/tmp/issue15-drill.py`,一次性工具)依次执行:
1. **干净安装**:`init-database` 建空库 → `migrate`(schema v7,14 张表)→ 确认「新库没有账号、没有业务数据」→ `bootstrap` 建管理员 → **再次 `bootstrap` 被拒绝**(退出码 1,不覆盖管理员)→ 起独立 API 实例(:8010)。
2. **两个演练账号走通闭环**(`ops.py smoke`,19 项全过):管理员建 `trial_a`/`trial_b` → 新账号书库与进度为空 → 粘贴章节 → 章节处理完成 → 分词返回可点选词 → 词典查询返回明确状态 → 保存词义 → 另一账号查不到该词条 → 新词立刻到期 → 作答成功 → 重复作答不重复记账 → 完成章节只记已读 → 进度反映活动;`B` 读 `A` 的书 404、书库仍为空。
3. **备份 lexgo_dev**(约 12 MB gz):manifest 记录 schema 7、提交哈希、逐表行数、sha256;确认 manifest 无凭据字段;确认 dump 不含 `CREATE DATABASE`/`USE`(不会误写回源库)。
4. **恢复到空库** `lexgo_restore_drill` 并自动校验(33 项全过):逐表行数与备份一致(`sys_user` 6、`books` 27、`chapters` 33、`terms` 13、`term_reviews` 13、`review_answers` 55、`login_logs` 192 等)→ **逐表内容校验和与源库一致**(13 张表)→ **源库 lexgo_dev 未被改动**。
5. **安全边界**:恢复默认拒绝覆盖已有库;拒绝写入系统库 `mysql`;不带 `--confirm` 直接拒绝。
6. **恢复实例的两账号闭环与越权校验**(`:8011`,41 项全过)——恢复后的账号仍可登录、词条与到期队列仍在、答题与完成章节保持幂等、`B` 无法读取或修改 `A` 的数据。
7. **收尾**:删除两个演练库,确认 `lexgo_dev` 与测试库保留,`lexgo_dev` 仍是 schema 7。
### 性能观察(`scripts/bench.py`,人造数据集,单机无并发)
数据集:1 个账号、20 本书 20 章(每章约 500 个词)、2000 词条、8000 条复习作答。环境:Windows 10(10.0.19044)、8 核、31.7 GB 内存、MySQL 8.4.3、Go 工具链由 `scripts/server.py` 固定为 1.26.5。
| 接口 | 请求数 | p50 | p95 | 最大 | 错误 |
|---|---|---|---|---|---|
| `POST /login` | 10 | 82.2 ms | 93.3 ms | 93.3 ms | 0 |
| `GET /books` | 30 | 5.9 ms | 18.1 ms | 18.2 ms | 0 |
| `GET /chapters/1` | 30 | 8.3 ms | 20.1 ms | 22.0 ms | 0 |
| `GET /chapters/1/tokens` | 30 | 10.0 ms | 23.6 ms | 30.3 ms | 0 |
| `GET /terms`(第 1 页) | 30 | 10.2 ms | 20.4 ms | 21.5 ms | 0 |
| `GET /terms?query`(搜索) | 30 | 12.3 ms | 24.9 ms | 27.0 ms | 0 |
| `GET /progress` | 30 | 15.7 ms | 26.1 ms | 29.1 ms | 0 |
| `GET /reviews/queue` | 30 | 22.7 ms | 32.3 ms | 34.2 ms | 0 |
**这些是观察值,不是容量承诺**:单机、单进程、无并发压力;登录接口每地址每分钟限 30 次,因此登录只测 10 次,该数字不代表登录吞吐。原始报告:`.local/issue15-bench.json`。
### 集成验证矩阵(复用既有 Go 测试,`LEXGO_TEST_DB_NAME=lexgo_test_issue13` 70 项全过)
| 验收要求 | 覆盖用例(全部通过) |
|---|---|
| 越权与隔离 | `TestMySQLLibraryIsolationAndOwnership`、`TestMySQLTermIsolationAndInputRules`、`TestMySQLPhraseRulesAndIsolation`、`TestMySQLReviewAnswerErrorsAndOwnership`、`TestMySQLAccountIsolationAndRevocation` |
| 任务崩溃重试 | `TestMySQLIngestRecoveryWithoutRestart`、`TestMySQLIngestRecoveryAfterRestart`、`TestMySQLIngestAttemptsAreBoundedAndManualRetryRestarts`、`TestMySQLRetryAfterContentRestoredPublishesSameChapter`、`TestMySQLRecoverySkipsSupersededJobs` |
| 并发答题与并发删除 | `TestMySQLReviewConcurrentReplayOfOneAnswer`、`TestConcurrentDuplicateAccountHasOneWinner`、`TestMySQLConcurrentChapterDelete`、`TestMySQLDeleteDuringProcessing` |
| 长文与输入上限 | `TestMySQLPasteRejectsInvalidInputAndLimits`、`TestMySQLTextUploadKeepsChapterLimit`、`TestMySQLTextUploadRejectsInvalidSubmissions`、`TestMySQLTextUploadRejectsInvalidSubmissions` |
| 迁移与回退标记 | `TestMigrationRefusesUnownedOrUnsupportedSchema`、`TestMigrationCanResumeOwnedPartialSchema`、全部 `TestMigrationFrom*` |
### 治理与其它测试
`python -m unittest discover -s tests` 65 项通过(新增 9 项 `tests/test_lexgo_ops.py`,不需要数据库);`harness.py check --strict` 与 `sync --check` 通过。本单未改前端与后端业务代码,仍复跑 `LEXGO_TEST_DB_NAME=lexgo_test_issue13 python scripts/server.py test-integration` 70 项通过。
### 未验证
- **真实回滚(升级后切回旧二进制)未演练**:文档给出规则(迁移只新增对象,回退前写回版本号),但没有在真实实例上做过升级/回滚全过程。
- 未验证 HTTPS、域名、反代配置、多机与灰度;本机演练只在 `127.0.0.1` 上起两个临时实例。
- 未做定时备份、监控与告警;备份与恢复均为人工触发。
- 大库恢复耗时与磁盘空间上限未测;演练库与生产库规模不同。
- 附件(封面/音频,#21)尚未实现,恢复范围目前只覆盖数据库。
## #15 交付、备份与恢复验证(2026-09-15)
本单**无 schema 变化、无迁移**:新增运维脚本、部署文档与演练证据。所有命令在仓库根执行;MySQL 客户端用 8.4(`LEXGO_MYSQL_BIN=D:/mysql843/bin`),因为本机 PATH 上是 5.7 客户端,`install-check` 会把它判为不达标。
### 运维命令
纯二进制路径(部署机不需要 Python):
| 用途 | 命令 |
|---|---|
| 迁移 / 初始化管理员 | `./lexgo migrate` / `./lexgo bootstrap` |
| 备份 | `./lexgo backup --out <目录>` |
| 恢复到空库 | `./lexgo restore --dump <备份.sql.gz> --database <库名> --confirm` |
| 校验 | `./lexgo verify --database <库名> [--manifest <manifest.json>]` |
工具通道(开发便利与交叉验证):
| 用途 | 命令 |
|---|---|
| 依赖与资源检查 | `python scripts/ops.py install-check` |
| 建空库并提示权限 | `python scripts/ops.py init-database --database <库名>` |
| 备份 | `python scripts/ops.py backup --out <目录>` |
| 恢复到空库 | `python scripts/ops.py restore --dump <备份.sql.gz> --database <库名> --confirm` |
| 校验(数据完整性,可选接口闭环) | `python scripts/ops.py verify --database <库名> [--manifest <manifest.json>] [--api http://127.0.0.1:<端口> --user <前缀> --password-env <变量名>]` |
| 干净实例两账号闭环演练 | `python scripts/ops.py smoke --api <url> --admin-user <账号> --user <前缀>` |
| 性能测量 | `python scripts/bench.py --database <库名> --api <url> --user <账号>` |
### 纯二进制安装演练(2026-09-15,9 项检查全部通过)
不使用 Python 执行安装步骤:建空库 → `lexgo migrate`(v7,14 张表)→ 确认空库无账号 → `lexgo bootstrap` 建管理员 → **再次 bootstrap 被拒绝** → `lexgo verify` 通过 → `lexgo serve` 自身健康检查通过 → 两个演练账号走通学习闭环(19 项)→ 删除演练库。接口层的两账号闭环由工具通道驱动(它需要发 HTTP 请求),这一点在部署页已写明。证据:`.local/issue15-binary-drill-evidence.json`。
### 两条通道的交叉验证(2026-09-15,19 项检查全部通过)
Go 二进制与 Python 工具各做一次备份,并**互相恢复对方的备份**:
1. 两条通道的 `manifest.json` 字段齐备、逐表行数一致、`schema_version` 一致、都不含凭据字段;
2. Go 产出的 dump 由 Python 恢复成功,Python 产出的 dump 由 Go 恢复成功(`lexgo restore` 输出 `verified`);
3. Go 校验 Go 恢复出来的库、Python 校验 Python 恢复出来的库,逐表行数与 manifest 一致;
4. 两个恢复实例的**逐表内容校验和都与源库一致**(13 张表),源库在过程中未被改动;
5. `lexgo verify` 可以直接校验源库;
6. 安全边界两条通道各自确认:Go 拒绝覆盖已有库、要求 `--confirm`,Python 拒绝系统库。
证据:`.local/issue15-crosscheck-evidence.json`。Go 侧另有 `TestMySQLOpsBackupRestoreRoundTrip` 集成用例(备份 → 恢复到空库 → 校验 → 拒绝二次覆盖 → 源库校验和不变)。
### 完整演练(2026-09-15,本机一次性通过,27 项顶层检查全部 `[ok]`)
演练脚本(`.local/tmp/issue15-drill.py`,一次性工具)依次执行:
1. **干净安装**:`init-database` 建空库 → `migrate`(schema v7,14 张表)→ 确认「新库没有账号、没有业务数据」→ `bootstrap` 建管理员 → **再次 `bootstrap` 被拒绝**(退出码 1,不覆盖管理员)→ 起独立 API 实例(:8010)。
2. **两个演练账号走通闭环**(`ops.py smoke`,19 项全过):管理员建 `trial_a`/`trial_b` → 新账号书库与进度为空 → 粘贴章节 → 章节处理完成 → 分词返回可点选词 → 词典查询返回明确状态 → 保存词义 → 另一账号查不到该词条 → 新词立刻到期 → 作答成功 → 重复作答不重复记账 → 完成章节只记已读 → 进度反映活动;`B` 读 `A` 的书 404、书库仍为空。
3. **备份 lexgo_dev**(约 12 MB gz):manifest 记录 schema 7、提交哈希、逐表行数、sha256;确认 manifest 无凭据字段;确认 dump 不含 `CREATE DATABASE`/`USE`(不会误写回源库)。
4. **恢复到空库** `lexgo_restore_drill` 并自动校验(33 项全过):逐表行数与备份一致(`sys_user` 6、`books` 27、`chapters` 33、`terms` 13、`term_reviews` 13、`review_answers` 55、`login_logs` 192 等)→ **逐表内容校验和与源库一致**(13 张表)→ **源库 lexgo_dev 未被改动**。
5. **安全边界**:恢复默认拒绝覆盖已有库;拒绝写入系统库 `mysql`;不带 `--confirm` 直接拒绝。
6. **恢复实例的两账号闭环与越权校验**(`:8011`,41 项全过)——恢复后的账号仍可登录、词条与到期队列仍在、答题与完成章节保持幂等、`B` 无法读取或修改 `A` 的数据。
7. **收尾**:删除两个演练库,确认 `lexgo_dev` 与测试库保留,`lexgo_dev` 仍是 schema 7。
### 性能观察(`scripts/bench.py`,人造数据集,单机无并发)
数据集:1 个账号、20 本书 20 章(每章约 500 个词)、2000 词条、8000 条复习作答。环境:Windows 10(10.0.19044)、8 核、31.7 GB 内存、MySQL 8.4.3、Go 工具链由 `scripts/server.py` 固定为 1.26.5。
| 接口 | 请求数 | p50 | p95 | 最大 | 错误 |
|---|---|---|---|---|---|
| `POST /login` | 10 | 82.2 ms | 93.3 ms | 93.3 ms | 0 |
| `GET /books` | 30 | 5.9 ms | 18.1 ms | 18.2 ms | 0 |
| `GET /chapters/1` | 30 | 8.3 ms | 20.1 ms | 22.0 ms | 0 |
| `GET /chapters/1/tokens` | 30 | 10.0 ms | 23.6 ms | 30.3 ms | 0 |
| `GET /terms`(第 1 页) | 30 | 10.2 ms | 20.4 ms | 21.5 ms | 0 |
| `GET /terms?query`(搜索) | 30 | 12.3 ms | 24.9 ms | 27.0 ms | 0 |
| `GET /progress` | 30 | 15.7 ms | 26.1 ms | 29.1 ms | 0 |
| `GET /reviews/queue` | 30 | 22.7 ms | 32.3 ms | 34.2 ms | 0 |
**这些是观察值,不是容量承诺**:单机、单进程、无并发压力;登录接口每地址每分钟限 30 次,因此登录只测 10 次,该数字不代表登录吞吐。原始报告:`.local/issue15-bench.json`。
### 集成验证矩阵(复用既有 Go 测试,`LEXGO_TEST_DB_NAME=lexgo_test_issue13` 70 项全过)
| 验收要求 | 覆盖用例(全部通过) |
|---|---|
| 越权与隔离 | `TestMySQLLibraryIsolationAndOwnership`、`TestMySQLTermIsolationAndInputRules`、`TestMySQLPhraseRulesAndIsolation`、`TestMySQLReviewAnswerErrorsAndOwnership`、`TestMySQLAccountIsolationAndRevocation` |
| 任务崩溃重试 | `TestMySQLIngestRecoveryWithoutRestart`、`TestMySQLIngestRecoveryAfterRestart`、`TestMySQLIngestAttemptsAreBoundedAndManualRetryRestarts`、`TestMySQLRetryAfterContentRestoredPublishesSameChapter`、`TestMySQLRecoverySkipsSupersededJobs` |
| 并发答题与并发删除 | `TestMySQLReviewConcurrentReplayOfOneAnswer`、`TestConcurrentDuplicateAccountHasOneWinner`、`TestMySQLConcurrentChapterDelete`、`TestMySQLDeleteDuringProcessing` |
| 长文与输入上限 | `TestMySQLPasteRejectsInvalidInputAndLimits`、`TestMySQLTextUploadKeepsChapterLimit`、`TestMySQLTextUploadRejectsInvalidSubmissions`、`TestMySQLTextUploadRejectsInvalidSubmissions` |
| 迁移与回退标记 | `TestMigrationRefusesUnownedOrUnsupportedSchema`、`TestMigrationCanResumeOwnedPartialSchema`、全部 `TestMigrationFrom*` |
### 治理与其它测试
`python -m unittest discover -s tests` 65 项通过(新增 9 项 `tests/test_lexgo_ops.py`,不需要数据库);`harness.py check --strict` 与 `sync --check` 通过。本单未改前端与后端业务代码,仍复跑 `LEXGO_TEST_DB_NAME=lexgo_test_issue13 python scripts/server.py test-integration` 70 项通过。
### 未验证
- **真实回滚(升级后切回旧二进制)未演练**:文档给出规则(迁移只新增对象,回退前写回版本号),但没有在真实实例上做过升级/回滚全过程。
- 未验证 HTTPS、域名、反代配置、多机与灰度;本机演练只在 `127.0.0.1` 上起两个临时实例。
- 未做定时备份、监控与告警;备份与恢复均为人工触发。
- 大库恢复耗时与磁盘空间上限未测;演练库与生产库规模不同。
- 附件(封面/音频,#21)尚未实现,恢复范围目前只覆盖数据库。
+15 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Product-Requirements-Overview
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Product-Requirements-Overview.-
wiki_revision: abf6629dfe44d838dd235b11543d9fdf94429899
synchronized_at: 2026-09-15T04:31:25Z
wiki_revision: a1847957e287c7eb1a54905e13632aeca4cd75f7
synchronized_at: 2026-09-15T08:27:38Z
<!-- gitea-wiki-mirror:end -->
# 产品需求总览
@@ -278,6 +278,18 @@ F09 的词汇库已于 2026-09-15 通过用户验收:生词本分页列出本
## #13 交付范围更新(2026-09-15)
F11 的完成阅读与基础进度已实现,待用户验收:阅读页提供显式「标记本章已读」(只记已读、不批量改变词语状态),书籍页章节列表显示已读,新的进度页面显示已读章节(分母只含可阅读章节)、待复习、已知词、学习中、新词、忽略与已保存词条数,以及每本书的已读进度;重复标记不重复计数,待复习与到期复习队列共用同一规则与时钟。正文改成新版本后该章回到未读并可在重读后更新同一记录;删除章节时记录随之消失。本次新增 schema v7(一张新表),需要显式迁移。
F11 的完成阅读与基础进度已于 2026-09-15 通过用户验收:阅读页提供显式「标记本章已读」(只记已读、不批量改变词语状态),书籍页章节列表显示已读,新的进度页面显示已读章节(分母只含可阅读章节)、待复习、已知词、学习中、新词、忽略与已保存词条数,以及每本书的已读进度;重复标记不重复计数,待复习与到期复习队列共用同一规则与时钟。正文改成新版本后该章回到未读并可在重读后更新同一记录;删除章节时记录随之消失。本次新增 schema v7(一张新表),需要显式迁移。
仍未实现并留给后续工单:桌面与手机体验补齐(#14)、自托管试用交付与完整恢复(#15)、书籍音频与封面附件(#21)、书库列表优化(#24)。每日目标、日历、难度评分、统计导出明确不在范围内。另记录既有缺陷 #32(编辑章节正文回到曾用版本返回 500,已定位未修复,方案待用户确认)。
## #14 交付范围更新(2026-09-15)
F12 的电脑与手机基础体验已于 2026-09-15 通过用户验收:新增「显示」设置(浅色/深色/跟随系统;正文标准/大/特大),偏好按账号保存在本机、账号切换不串、不跨设备同步;阅读位置按账号与章节保留,正文换新版本后不套用旧位置;复习页支持空格显示答案与 1/2/3 评分,输入框内不误触全局快捷键,焦点环可见;手机端以 390×844 触摸视口验证八个界面无横向溢出、均可返回、触摸滑动可滚动且不误开释义面板。本次无数据库与接口变化。
仍未实现并留给后续工单:自托管试用交付与完整恢复(#15)、书籍音频与封面附件(#21)、书库列表优化(#24),以及已定位未修复的缺陷 #32。PWA、离线能力、自定义配色、全局 UI 缩放与偏好跨设备同步明确不在范围内。**真实手机的长按选择与操作手感仍需人工确认**,本单的触摸证据来自模拟设备。
## #15 交付状态更新(2026-09-15)
B04 与 F01–F12 的集成交付已实现,待用户验收:新部署与运维 Wiki 页(首次部署、配置与凭据来源、日常运维、健康检查、升级回滚、备份恢复、已知限制),运维工具 `scripts/ops.py`(`install-check` / `init-database` / `backup` / `restore` / `verify` / `smoke`)与 `scripts/bench.py`。本机完成完整演练:空库安装并由管理员建立两个虚构演练账号走通学习闭环;备份开发库后恢复到空实例,逐表行数与内容校验和一致、源库未被改动,两账号闭环与越权校验全通过。性能在 20 章/2000 词条/8000 条作答的人造数据集上实测(p50 6–23 ms,登录 82 ms,均无错误),只作观察不给承诺。
用户已确认的范围边界:本次只交付本机可复现的安装/备份/恢复材料并在本机演练,**不对外部署、不创建发布标签、不邀请用户**;生产入口与 HTTPS 只写入文档。**真实回滚、定时备份、监控告警、多机部署与 HTTPS 仍未验证**;附件(音频/封面,#21)尚未实现,因此恢复契约目前只覆盖数据库,待 #21 落地后由本单的恢复验收纳入附件。遗留缺陷 #32(编辑正文回到曾用版本返回 500)仍待确认修复方案。
+218
View File
@@ -0,0 +1,218 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Deployment-and-Operations.-
wiki_revision: 3791ef4adc5c5cc8d6e5006c719cd0ddccb15fc0
synchronized_at: 2026-09-15T09:08:49Z
<!-- gitea-wiki-mirror:end -->
# 部署与运维
本页面向 LexGo 的内部维护者:在一台干净的机器上首次部署、日常运维、健康检查、升级回滚与备份恢复。所有命令以仓库根为工作目录。**本页只写配置项名称与来源,不写任何真实密码、令牌或生产数据库地址。**
## 本页用途
让维护者在一台新机器上完成首次部署,并在需要时把实例从备份恢复回来,同时知道哪些步骤不可逆、哪些证据必须留下。
## 安全边界
- 凭据只从运维密码库或 `.env.local`(已被 `.gitignore` 忽略)读取;`.env.local` 权限应为仅本人可读写。
- 备份文件含全部用户数据(账号、书籍原文、词条、复习记录、审计日志、词典归档),必须按个人数据对待:存放位置受控、不进入 Git、不进入工单附件。
- `restore` 会写入数据库:默认只写空库,覆盖已有库必须显式 `--force`,任何情况下都必须 `--confirm`。
- 服务默认只监听 `127.0.0.1`;对外提供访问时经反向代理,不要直接暴露应用端口。
## 服务概览
| 项目 | 内容 |
|---|---|
| 组件 | Go 后端(`server/`)、学习端 SPA(`learner/`)、管理端 SPA(`admin/`)、MySQL 8 |
| 后端监听 | `LEXGO_LISTEN`,默认 `127.0.0.1:8000` |
| 前端托管 | 反向代理(nginx)托管已构建的 `dist`,同一域名下把 `/api/` 转发到后端 |
| 数据库 | MySQL 8(本机验证版本 8.4.3);库名经 `LEXGO_DB_NAME` 指定 |
| 运行时 | 后端为单个静态二进制,无需运行时依赖;构建需要 Go 1.26.5、Node 22、pnpm 9 |
| 进程托管 | supervisor(本项目开发机即为 `lexgo-api` / `lexgo-learner` / `lexgo-admin` 三个 program) |
| 日志 | supervisor 的 `stdout`/`stderr` 日志文件;应用自身不写文件日志 |
| 备份对象 | MySQL 全库(用户数据、原文、词典归档、审计)+ `.env.local`(单独从密码库取) |
## 环境要求
| 组件 | 版本要求 | 检查命令 | 预期结果 |
|---|---|---|---|
| 操作系统 | Windows 或 Linux | — | 本项目在 Windows 开发机验证;Linux 步骤为等价命令 |
| LexGo 二进制 | 与数据库 schema 版本匹配 | `./lexgo verify --database <库名>` | 校验通过;它自身会拒绍非 MySQL 8 |
| Python | 3.8+,**仅工具通道需要** | `python scripts/ops.py install-check` | 纯二进制路径(`lexgo ...`)不需要 Python;Python 通道用于开发便利与交叉验证 |
| MySQL 服务端 | 8.x | `mysql --version`(服务端 `SELECT VERSION()`) | 8.4.3 已验证 |
| MySQL 客户端 | **不低于服务端** | `python scripts/ops.py install-check` | 「客户端版本不低于服务端」为 ok;5.7 客户端连 8.4 服务端会被判 fail |
| Go | 1.26.5 | `go version` | 供 `scripts/server.py` 固定工具链构建 |
| Node / pnpm | Node 22、pnpm 9 | `node --version`、`pnpm --version` | 仅在需要构建前端时要求 |
| WordNet 资源 | `server/wordnet-resource.json` 固定的 ZIP | `python scripts/ops.py install-check` | 「WordNet 资源 pin」为 ok;资源只在显式导入时使用,不在运行时下载 |
## 首次部署
### 1. 取得代码并检查依赖
```bash
git clone <仓库地址> <部署目录>
cd <部署目录>
git rev-parse HEAD # 记录本次部署的提交哈希
python scripts/ops.py install-check
```
预期结果:提交哈希被记录到部署记录;`install-check` 全部 `[ok]`,否则按其提示补齐后再继续。
### 2. 创建数据库与配置
```bash
python scripts/ops.py init-database --database lexgo_prod
```
预期结果:输出「已创建库」并打印需要授予的最小权限(`SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP, REFERENCES`,仅限该库)。不要用管理员账号运行应用。
把 `.env.local`(从 `.env.example` 复制)填好:`LEXGO_DB_HOST/PORT/NAME/USER/PASSWORD`、`LEXGO_BOOTSTRAP_USERNAME/PASSWORD`、`LEXGO_LISTEN`。凭据值取自运维密码库,不进 Git、不进日志。
### 3. 迁移与初始化管理员
纯二进制路径(推荐,部署机只需要二进制与 MySQL 客户端):
```bash
./lexgo migrate # 只有这一步会改表结构
./lexgo bootstrap # 首次建立唯一管理员
```
开发便利路径(等价,额外做两件事:加载 `.env.local`、固定 Go 工具链):
```bash
python scripts/server.py migrate
python scripts/server.py bootstrap
```
预期结果:`migrate` 输出 schema 版本;`bootstrap` 输出创建成功。**库中已有账号时 `bootstrap` 会拒绝执行**,不会覆盖既有管理员。空库不含默认密码与任何演示数据。两种路径共用同一份 `LEXGO_*` 配置。
### 4. 构建产物
```bash
python scripts/server.py build # 产出 server/lexgo(.exe)
cd learner && pnpm install && pnpm run build
cd ../admin && pnpm install && pnpm run build
```
预期结果:后端二进制与两份 `dist` 生成。前端由反向代理托管;反向代理需把未知路径回落到 `index.html`(SPA 路由),并把 `/api/` 转发到后端。
### 5. 词典资源
词典归档在显式导入后存于 MySQL,备份与恢复会一并带走,运行时不下载。
### 6. 启动与健康检查
按 supervisor 配置启动三个 program(后端 + 两个静态站点或由反向代理托管)。然后执行下方「健康检查」全部命令。
## 配置与凭据来源
| 配置项 | 用途 | 来源 | 是否敏感 |
|---|---|---|---|
| `LEXGO_DB_HOST/PORT/NAME/USER/PASSWORD` | 数据库连接 | `.env.local`,值取自运维密码库 | 是(密码) |
| `LEXGO_BOOTSTRAP_USERNAME/PASSWORD` | 首次建立管理员 | 同上 | 是(密码) |
| `LEXGO_LISTEN` | 后端监听地址 | `.env.local` | 否 |
| `LEXGO_MYSQL_BIN` | 指定 MySQL 客户端目录 | 运维环境变量 | 否 |
| `LEXGO_TEST_DB_NAME` | 集成测试库(仅开发) | 环境变量 | 否 |
`scripts/ops.py` 的 manifest 与日志只记录配置项**名称**、库名、schema 版本与提交哈希,不记录任何凭据值。
## 日常运维
| 操作 | 命令 | 预期结果 |
|---|---|---|
| 查看状态 | `supervisorctl status lexgo-api` | `RUNNING`,uptime 持续增长 |
| 重启后端 | `supervisorctl restart lexgo-api` | `stopped` 后 `started` |
| 查看日志 | `supervisorctl tail -f lexgo-api stderr` | 持续输出应用日志 |
| 备份 | `./lexgo backup --out <目录>` | 生成 `lexgo-<时间戳>.sql.gz` 与 `manifest.json` |
| 校验实例 | `./lexgo verify --database <库名>` | 打印每项检查结果,最后「verification passed」 |
| 清理过期审计 | `python scripts/server.py audit-cleanup` | 只清理两张审计表中超过 90 天的记录 |
| 关闭确认 | `supervisorctl stop lexgo-api` | 服务停止;学习端与反代仍在,接口不可用 |
## 健康检查
```bash
supervisorctl status lexgo-api
curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8000/healthz
python scripts/ops.py verify --database <库名>
```
预期结果:状态 `RUNNING`;`curl` 返回 `200`;`verify` 全部 `[ok]`。任何一项不符合时不视为部署成功,按「升级与回滚」处理。
## 升级与回滚
### 升级
```bash
git rev-parse HEAD # 记录升级前提交,回滚需要
python scripts/ops.py backup --out <目录> # 有迁移时必须先备份
git fetch --all && git checkout <目标提交或标签>
python scripts/server.py build
cd learner && pnpm install && pnpm run build && cd ../admin && pnpm install && pnpm run build
python scripts/server.py migrate # 只有迁移单需要
supervisorctl restart lexgo-api
```
预期结果:重启后状态 `RUNNING`,健康检查全部通过。前端 `dist` 更新后由反代直接生效,无需重启后端。
### 回滚
```bash
git checkout <升级前记录的提交>
python scripts/server.py build
supervisorctl restart lexgo-api
```
预期结果:健康检查全部通过。
> **注意**:已执行的迁移通常不能用切回代码撤销。LexGo 的用法是:每个版本的迁移只新增对象(`CREATE TABLE IF NOT EXISTS`),旧二进制认版本号,因此回退到旧二进制前要把 `lexgo_schema.version` 写回旧版本号;若迁移删改了数据或旧二进制无法读写新结构,则按「备份与恢复」处理。**本节只描述规则,真实回滚尚未演练过,属于已知限制。**
## 备份与恢复
### 备份对象与频率
- **数据库**:一个实例的全部持久数据(账号与会话、书籍与章节原文、导入任务、词典归档、词条、复习排期与作答、阅读进度、审计日志)。
- **环境配置**:`.env.local`(含凭据)单独从运维密码库保存,**不放进备份目录**。
- **代码与二进制**:由 Git 提交哈希重建,manifest 中记录了该哈希。
- 建议频率:每次升级或有迁移前必须备份;日常按使用强度自行决定(本项目未启用定时任务)。保留份数与存放位置由运维决定。
```bash
./lexgo backup --out <备份目录>
```
预期结果:目录中出现 `lexgo-<时间戳>.sql.gz` 与 `manifest.json`(schema 版本、提交哈希、逐表行数、dump 的 sha256、客户端与服务端版本)。manifest 不含凭据。
备份与校验也可以走 Python 工具通道(`python scripts/ops.py backup|restore|verify`)。**两条通道的 dump 与 manifest 格式完全相同,可以互相读取**:2026-09-15 已交叉验证—— Go 产出的备份用 Python 恢复、Python 产出的备份用 Go 恢复,两个恢复实例的逐表行数与内容校验和都与源库一致。
### 恢复步骤
```bash
./lexgo restore --dump <备份目录>/lexgo-<时间戳>.sql.gz --database <新库名> --confirm
./lexgo verify --database <新库名> --manifest <备份目录>/manifest.json
```
预期结果:恢复写入空库并自动校验通过;`verify` 打印完整性与逐表行数比对,最后「verification passed」。恢复**不会**写入备份里的源库,工具会在恢复前后比对源库的内容校验和(本机演练已验证)。
**接口级的两账号闭环验证仍在 Python 工具通道**(它需要发 HTTP 请求):
```bash
python scripts/ops.py verify --database <库名> --api http://127.0.0.1:<端口> --user <账号前缀> --password-env <变量名>
python scripts/ops.py smoke --api http://127.0.0.1:<端口> --admin-user <管理员> --user <前缀>
```
> **注意**:`restore` 默认拒绝写入已有数据的库;覆盖必须 `--force`,执行前先备份当前库。库名必须包含 `lexgo` 且不能是 MySQL 系统库。
### 恢复演练记录
2026-09-15 在本机完成完整演练:空库安装(init-database → migrate → bootstrap → 管理员建两个演练账号 → 走通粘贴/阅读/查词/保存/复习/完成章节/进度)与「备份 lexgo_dev → 恢复到空库 → 起第二个 API 实例 → 两账号闭环与越权校验」,恢复后逐表行数与内容校验和都和源库一致,演练库用后删除。详细命令与结果见工单 #15 与本地开发页。
## 已知限制
- **真实回滚演练未做**:本文档给出规则,但没有在真实实例上执行过「升级 → 回滚」全过程。
- 未验证 HTTPS、域名、多机与灰度部署;本机演练只用 `127.0.0.1` 与模拟触摸视口。
- 未启用定时备份、监控与告警;备份由人工触发。
- **附件(封面/音频)尚未实现(工单 #21)**:本页的备份恢复范围目前只覆盖数据库;#21 落地后必须把附件存储纳入备份与恢复验收。
- 恢复演练是单机顺序执行,未验证大库恢复耗时与磁盘空间上限。
- 接口级的两账号闭环与越权验证需要 HTTP 客户端,目前只在 Python 工具通道提供;Go 二进制提供数据库层的备份、恢复与校验。
- 备份仍调用 `mysqldump`:自己实现一致性导出风险更高,因此部署机需要 MySQL 客户端而不只是服务端。
+9 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Home
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Home
wiki_revision: 9ee88d82494e22e689086b053770c4f4620d3657
synchronized_at: 2026-09-15T04:31:22Z
wiki_revision: 8c563c979bdfcacf1dda8743621b0ea00b3805f8
synchronized_at: 2026-09-15T09:08:48Z
<!-- gitea-wiki-mirror:end -->
# LexGo 文档入口
@@ -83,4 +83,10 @@ Quant-UX 原型 v1 已通过用户验收。[桌面预览](https://qux.ilapage.cn
#12 词汇库的搜索、筛选与编辑已于 2026-09-15 通过用户验收:生词本分页列出本人保存的词语与短语,可按关键词(匹配词形、原文或个人释义,大小写不敏感)与状态/类型筛选,并可编辑释义、例句、状态与 1~7 学习等级。编辑沿用与阅读器相同的领域规则:身份不可改、只有状态或等级变化才重排复习时间、历史作答记录保留;筛选与页码写入 URL,刷新或返回列表都保留。原型的「来自某章节」不实现,因为词条按身份存储、不引用章节。本次没有数据库结构变化;PR #31 已 fast-forward-only 合入 main。
#13 完成章节与个人基础进度已实现,待用户验收:阅读页正文下方提供显式的「标记本章已读」,只记录已读、不批量改变词语状态;重复标记返回同一条记录、不重复计数。书籍页章节列表显示「已读」,新的「进度」页面显示已读章节(只统计可阅读章节,形如「3 / 8 章(可阅读)」)、待复习、已知词、学习中、新词、忽略与已保存词条数,并按书显示已读进度;待复习与到期复习队列使用同一规则与同一时钟。正文被改成新版本后该章会回到未读(记录保留),重新标记即更新同一条记录;删除章节时记录随之消失。本次新增 schema v7(一张新表),需要显式迁移;开发库迁移前后各表计数一致。真实 API+MySQL 37 项检查与浏览器页面数字对照数据库直查全部一致。
#13 完成章节与个人基础进度已于 2026-09-15 通过用户验收:阅读页正文下方提供显式的「标记本章已读」,只记录已读、不批量改变词语状态;重复标记返回同一条记录、不重复计数。书籍页章节列表显示「已读」,新的「进度」页面显示已读章节(只统计可阅读章节,形如「3 / 8 章(可阅读)」)、待复习、已知词、学习中、新词、忽略与已保存词条数,并按书显示已读进度;待复习与到期复习队列使用同一规则与同一时钟。正文被改成新版本后该章会回到未读(记录保留),重新标记即更新同一条记录;删除章节时记录随之消失。本次新增 schema v7(一张新表),需要显式迁移;开发库迁移前后各表计数一致。真实 API+MySQL 37 项检查与浏览器页面数字对照数据库直查全部一致;PR #33 已 fast-forward-only 合入 main。
#14 桌面与手机体验、主题与键盘操作已于 2026-09-15 通过用户验收:新增「显示」设置(浅色/深色/跟随系统,正文标准/大/特大),偏好按账号保存在本机,切换账号不会串;阅读位置按账号与章节保留,正文换成新版本后不再套用旧位置;复习页支持空格显示答案、1/2/3 评分,输入框内不会误触快捷键,所有可交互元素有可见焦点环。手机端用 390×844 触摸视口验证八个界面无横向溢出、都能返回、触摸滑动滚动而不误开释义面板,深色与特大字号刷新后保持。本次没有数据库与接口变化;PR #34 已 fast-forward-only 合入 main。真实手机的长按选择与手感仍需人工确认。
#15 自托管试用交付已实现,待用户验收:新增运维工具(`scripts/ops.py`:依赖检查、建空库、备份、恢复、校验、两账号闭环演练;`scripts/bench.py`:写明规模的人造数据集性能测量)、新的 Wiki 部署与运维页,以及一次完整的本机演练——空库安装(建库 → 迁移 → 首次 bootstrap 建管理员 → 管理员建两个虚构演练账号 → 走通粘贴/阅读/查词/保存/复习/完成章节/进度)、备份 lexgo_dev、恢复到空实例并用第二个 API 实例完成两账号闭环与越权校验,恢复后逐表行数与内容校验和都和源库一致、源库未被改动。备份含全部用户数据(数据库)与环境配置(单独保管凭据),恢复默认只写空库、覆盖需要显式确认与 `--force`、拒绝写入系统库。性能在 20 章/2000 词条/8000 条作答的人造数据集上实测并记录环境,只作观察不给承诺。真实回滚、HTTPS、多机与定时备份仍未验证;附件(#21)尚未实施,恢复范围目前只覆盖数据库。
#15 自托管试用交付已实现,待用户验收:备份、恢复与校验同时提供**纯二进制子命令**(`lexgo backup|restore|verify`,部署机只需要二进制与 MySQL 客户端)与 Python 工具通道(交叉验证与开发便利),两条通道的 dump 与 manifest 格式一致、可互相恢复;新增运维工具(`scripts/ops.py`:依赖检查、建空库、备份、恢复、校验、两账号闭环演练;`scripts/bench.py`:写明规模的人造数据集性能测量)、新的 Wiki 部署与运维页,以及一次完整的本机演练——空库安装(建库 → 迁移 → 首次 bootstrap 建管理员 → 管理员建两个虚构演练账号 → 走通粘贴/阅读/查词/保存/复习/完成章节/进度)、备份 lexgo_dev、恢复到空实例并用第二个 API 实例完成两账号闭环与越权校验,恢复后逐表行数与内容校验和都和源库一致、源库未被改动。备份含全部用户数据(数据库)与环境配置(单独保管凭据),恢复默认只写空库、覆盖需要显式确认与 `--force`、拒绝写入系统库。性能在 20 章/2000 词条/8000 条作答的人造数据集上实测并记录环境,只作观察不给承诺。两条通道已在本机交叉验证(19 项)。真实回滚、HTTPS、多机与定时备份仍未验证;附件(#21)尚未实施,恢复范围目前只覆盖数据库。
+94
View File
@@ -0,0 +1,94 @@
import { expect, test, type Page } from '@playwright/test'
const user = { id: 42, username: 'fictional-keyboard', role: 'learner' }
const item = { id: 11, term: 'curiosity', originalForm: 'curiosity', definition: '好奇心;求知欲', examples: ['Curious minds ask.'], status: 'new', level: 0, kind: 'word', wordCount: 1, dueAt: '2026-01-02T00:00:00Z', reviewCount: 0 }
async function mockApi(page: Page): Promise<void> {
await page.route('**/api/v1/**', async route => {
const url = new URL(route.request().url())
const path = url.pathname
const method = route.request().method()
let data: unknown = null
if (path === '/api/v1/login') data = { token: 'fictional-session', user }
else if (path === '/api/v1/me') data = user
else if (path === '/api/v1/space') data = { ownerId: user.id, language: 'en' }
else if (path === '/api/v1/books') data = { items: [] }
else if (path === '/api/v1/reviews/queue') data = { items: [item], total: 1 }
else if (path.endsWith('/answers') && method === 'POST') {
data = { result: 'applied', duplicate: false, grade: 'correct', requeued: false, statusBefore: 'new', statusAfter: 'learning', levelBefore: 0, levelAfter: 1, dueAtBefore: item.dueAt, dueAtAfter: '2026-01-09T00:00:00Z', item }
} else if (path === '/api/v1/terms' && method === 'GET') {
data = { items: [{ ...item, updatedAt: '2026-01-02T00:00:00Z' }], total: 1, page: 1, limit: 20 }
}
await route.fulfill({ json: { code: 200, data } })
})
}
/** Presses Tab until the target holds the focus, so reachability is what is being tested. */
async function tabUntilFocused(page: Page, target: ReturnType<Page['getByTestId']>, limit = 15): Promise<boolean> {
for (let step = 0; step < limit; step += 1) {
if (await target.evaluate(element => element === document.activeElement).catch(() => false)) return true
await page.keyboard.press('Tab')
}
return target.evaluate(element => element === document.activeElement).catch(() => false)
}
async function login(page: Page): Promise<void> {
await page.goto('/')
await page.getByLabel('账号').fill(user.username)
await page.getByLabel('密码', { exact: true }).fill('fictional-password')
await page.getByRole('button', { name: '登录', exact: true }).click()
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
}
test('the due review can be driven from the keyboard alone', async ({ page }) => {
await mockApi(page)
await login(page)
await page.locator('.site-header nav').getByRole('link', { name: '到期复习' }).click()
await expect(page.getByTestId('review-card')).toBeVisible()
await expect(page.getByTestId('review-shortcuts')).toContainText('空格')
// The first action already holds the focus, so a keyboard user can keep going.
await expect(page.getByTestId('review-reveal')).toBeFocused()
// Space shows the answer, and the answer panel is announced as text.
await page.keyboard.press('Space')
await expect(page.getByTestId('review-definition')).toContainText('好奇心')
await expect(page.getByTestId('review-correct')).toBeFocused()
// A digit grades the card without touching the mouse.
await page.keyboard.press('1')
await expect(page.getByTestId('review-summary')).toBeVisible()
await page.screenshot({ path: '../.local/evidence/issue14-keyboard-review.png' })
// Tab reaches the closing action within a few presses, not after walking the whole page.
expect(await tabUntilFocused(page, page.getByTestId('review-finish'), 6)).toBe(true)
await page.keyboard.press('Enter')
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
})
test('typing in a form never triggers a global shortcut', async ({ page }) => {
await mockApi(page)
await login(page)
await page.locator('.site-header nav').getByRole('link', { name: '生词本' }).click()
await expect(page.locator('.vocab-row')).toHaveCount(1)
const search = page.getByLabel('搜索词 / 释义')
await search.click()
await search.type('curiosity 1 2 quit')
await expect(search).toHaveValue('curiosity 1 2 quit')
// The list only changes when the search is submitted, not while typing.
await expect(page.locator('.vocab-row')).toHaveCount(1)
})
test('the display control shows a visible focus ring', async ({ page }) => {
await mockApi(page)
await login(page)
const trigger = page.getByTestId('display-trigger')
// Reached by keyboard, which is what makes the focus ring appear in the first place.
expect(await tabUntilFocused(page, trigger)).toBe(true)
const outline = await trigger.evaluate(element => {
const style = getComputedStyle(element)
return { width: style.outlineWidth, style: style.outlineStyle }
})
expect(parseFloat(outline.width)).toBeGreaterThanOrEqual(2)
expect(outline.style).not.toBe('none')
})
+75
View File
@@ -0,0 +1,75 @@
import { expect, test } from '@playwright/test'
import { login, mockLearnerApi, touchSwipe } from './mobile-fixtures'
// Touch must scroll the chapter instead of being swallowed by the selection handling, and a tap
// must still open the word panel. This is emulated touch input through Chromium, not a real phone.
test('a touch swipe scrolls the chapter and does not open the word panel', async ({ page }) => {
await mockLearnerApi(page)
await login(page)
await page.getByRole('link', { name: 'Fictional mobile book' }).click()
await page.locator('.chapter-list a.chapter-name').first().click()
await expect(page.locator('.reader-text')).toBeVisible()
// The words arrive with the token response, so wait for them instead of sampling once.
await expect.poll(async () => page.locator('.reader-word').count()).toBeGreaterThan(3)
// A swipe upwards scrolls the page down and leaves the panel closed.
await touchSwipe(page, 640, 240)
await expect.poll(async () => page.evaluate(() => window.scrollY)).toBeGreaterThan(0)
expect(await page.locator('.lookup-panel').count()).toBe(0)
await page.screenshot({ path: '../.local/evidence/issue14-mobile-scrolled.png' })
// A tap on a word still opens the panel, which sits above the text without covering it.
await page.locator('.reader-word').nth(1).tap()
const panel = page.locator('.lookup-panel')
await expect(panel).toBeVisible()
const panelBox = (await panel.boundingBox())!
expect(panelBox.x).toBeGreaterThanOrEqual(0)
expect(panelBox.x + panelBox.width).toBeLessThanOrEqual(391)
// The page reserves room for the fixed sheet, so the last paragraph can still be read.
const padding = await page.evaluate(() => getComputedStyle(document.querySelector('.reader-page') as Element).paddingBottom)
expect(parseFloat(padding)).toBeGreaterThan(100)
// Closing the panel keeps the reading position instead of jumping away.
const before = await page.evaluate(() => window.scrollY)
await page.getByRole('button', { name: '关闭释义' }).tap()
await expect(panel).toHaveCount(0)
expect(Math.abs((await page.evaluate(() => window.scrollY)) - before)).toBeLessThan(40)
})
test('the display choice survives a reload and belongs to the account', async ({ page }) => {
await mockLearnerApi(page)
await login(page)
await page.getByTestId('display-trigger').tap()
await page.getByRole('menu').getByText('深色', { exact: true }).tap()
await page.getByTestId('display-trigger').tap()
await page.getByRole('menu').getByText('特大', { exact: true }).tap()
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
const readerScale = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--reader-font-scale').trim())
expect(readerScale).toBe('1.3')
// Reloading keeps both choices: they are stored, not just applied.
await page.reload()
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
expect(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--reader-font-scale').trim())).toBe('1.3')
// The reading text really is larger, which is what the choice promises.
await page.getByRole('link', { name: 'Fictional mobile book' }).click()
await page.locator('.chapter-list a.chapter-name').first().click()
await expect(page.locator('.reader-text')).toBeVisible()
const size = await page.locator('.reader-text').evaluate(element => parseFloat(getComputedStyle(element).fontSize))
expect(size).toBeGreaterThan(16)
await page.screenshot({ path: '../.local/evidence/issue14-mobile-dark-large.png' })
// Signing out and back in as another account must not inherit the first account's choice.
await page.getByRole('button', { name: '退出登录' }).tap()
await expect(page.getByRole('heading', { name: '欢迎回来' })).toBeVisible()
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
await page.getByLabel('账号').fill('fictional-mobile')
await page.getByLabel('密码', { exact: true }).fill('fictional-password')
await page.getByRole('button', { name: '登录', exact: true }).click()
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
// The same account keeps its own choice.
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
})
+101
View File
@@ -0,0 +1,101 @@
// Shared fixtures for the mobile (touch, 390x844) specs.
import { expect, type Page } from '@playwright/test'
export const mobileUser = { id: 42, username: 'fictional-mobile', role: 'learner' }
export const chapterText = [
'Curiosity opens the first door.',
'The second door stays closed until the reader asks.',
'A third line gives the page something to scroll.',
'Every paragraph is fictional and only used by the mobile checks.',
'Reading on a phone needs real scrolling, not a fixed frame.',
'The lookup panel must never swallow a swipe.',
'A tap still opens one word at a time.',
'Long enough text keeps the page taller than a phone screen.',
'The last line is here so the page can be scrolled to its end.',
].join('\n') + '\n'
/** A small mocked API with one book, one ready chapter and one due word. */
export async function mockLearnerApi(page: Page): Promise<void> {
const book = { id: 7, title: 'Fictional mobile book', language: 'en', chapterCount: 1, pendingCount: 0, processingCount: 0, readyCount: 1, failedCount: 0, createdAt: '', updatedAt: '' }
const chapter = {
id: 3, bookId: 7, ordinal: 1, title: 'Fictional mobile chapter', status: 'ready', charCount: chapterText.length,
errorReason: '', errorMessage: '', jobId: 9, readAt: null, createdAt: '', updatedAt: '',
}
const term = { id: 11, term: 'curiosity', originalForm: 'curiosity', definition: '好奇心;求知欲', examples: ['Curious minds ask.'], status: 'new', level: 0, kind: 'word', wordCount: 1, updatedAt: '2026-01-02T00:00:00Z' }
await page.route('**/api/v1/**', async route => {
const url = new URL(route.request().url())
const path = url.pathname
let data: unknown = null
if (path === '/api/v1/login') data = { token: 'fictional-session', user: mobileUser }
else if (path === '/api/v1/me') data = mobileUser
else if (path === '/api/v1/space') data = { ownerId: mobileUser.id, language: 'en' }
else if (path === '/api/v1/books') data = { items: [book] }
else if (path === '/api/v1/books/7') data = { book, chapters: [chapter] }
else if (path === '/api/v1/chapters/3/tokens') {
const tokens: unknown[] = []
let offset = 0
for (const piece of chapterText.match(/[A-Za-z]+|\s+|[^A-Za-z\s]+/g) ?? []) {
const start = offset
offset += piece.length
tokens.push({
text: piece, start, end: offset, startUtf16: start, endUtf16: offset,
kind: /^[A-Za-z]+$/.test(piece) ? 'word' : /^\s+$/.test(piece) ? 'space' : 'punctuation',
})
}
// The reader follows the tokens only while they describe the chapter's content version.
data = { textSha256: 'e'.repeat(64), tokens }
} else if (path === '/api/v1/chapters/3') {
data = { book, chapter: { ...chapter, contentSha256: 'e'.repeat(64), originalText: chapterText }, navigation: { previousChapterId: null, nextChapterId: null } }
} else if (path === '/api/v1/terms/lookup') {
data = { query: 'curiosity', terms: [{ term: 'curiosity', entries: [{ partOfSpeech: 'noun', definition: '好奇心' }] }], candidates: [] }
} else if (path === '/api/v1/terms' && route.request().method() === 'GET') {
data = { items: [term], total: 1, page: 1, limit: 20 }
} else if (path === '/api/v1/reviews/queue') {
data = { items: [{ ...term, dueAt: '2026-01-02T00:00:00Z', reviewCount: 0 }], total: 1 }
} else if (path === '/api/v1/progress') {
data = { readChapters: 0, totalChapters: 1, knownTerms: 0, learningTerms: 0, newTerms: 1, ignoredTerms: 0, savedTerms: 1, dueNow: 1, books: [{ id: 7, title: book.title, readChapters: 0, totalChapters: 1 }] }
}
await route.fulfill({ json: { code: 200, data } })
})
}
export async function login(page: Page): Promise<void> {
await page.goto('/')
await page.getByLabel('账号').fill(mobileUser.username)
await page.getByLabel('密码', { exact: true }).fill('fictional-password')
await page.getByRole('button', { name: '登录', exact: true }).click()
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
}
/** True when nothing sticks out sideways; the phone must never scroll left or right. */
export async function overflowOffenders(page: Page): Promise<string[]> {
return page.evaluate(() => {
const width = window.innerWidth
const offenders: string[] = []
for (const element of Array.from(document.querySelectorAll<HTMLElement>('body *'))) {
const box = element.getBoundingClientRect()
if (box.width === 0 && box.height === 0) continue
if (box.right > width + 1 || box.left < -1) {
offenders.push(`${element.tagName.toLowerCase()}.${(element.className || '').toString().split(' ').slice(0, 2).join('.')}`)
}
}
return offenders
})
}
/** A real touch swipe through CDP, which Chromium turns into scrolling. */
export async function touchSwipe(page: Page, fromY: number, toY: number): Promise<void> {
const client = await page.context().newCDPSession(page)
const send = (type: string, y: number) => client.send('Input.dispatchTouchEvent', {
type,
touchPoints: type === 'touchEnd' ? [] : [{ x: 195, y }],
})
await send('touchStart', fromY)
const steps = 8
for (let step = 1; step <= steps; step += 1) {
await send('touchMove', fromY + ((toY - fromY) * step) / steps)
}
await send('touchEnd', toY)
await client.detach()
}
+87
View File
@@ -0,0 +1,87 @@
import { expect, test } from '@playwright/test'
import { login, mockLearnerApi, overflowOffenders } from './mobile-fixtures'
// Every learner surface on a phone viewport: nothing sticks out sideways and every page can be
// left again. The measured truth in #14 was that the layouts already fit; this locks that in.
test('every page fits the phone width and can be left again', async ({ page }) => {
await mockLearnerApi(page)
await login(page)
const surfaces: { name: string; open: () => Promise<void>; back: () => Promise<void> }[] = [
{
name: '书库',
open: async () => { await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible() },
back: async () => { await page.locator('.site-header nav').getByRole('link', { name: '我的书库' }).click() },
},
{
name: '导入',
open: async () => { await page.getByRole('button', { name: '导入内容' }).click(); await expect(page.getByRole('heading', { name: '导入英文内容' })).toBeVisible() },
back: async () => { await page.locator('.site-header nav').getByRole('link', { name: '我的书库' }).click() },
},
{
name: '书籍',
open: async () => { await page.getByRole('link', { name: 'Fictional mobile book' }).click(); await expect(page.locator('.chapter-list')).toBeVisible() },
back: async () => { await page.getByRole('link', { name: '我的书库' }).first().click() },
},
{
name: '阅读',
open: async () => {
await page.getByRole('link', { name: 'Fictional mobile book' }).click()
await page.locator('.chapter-list a.chapter-name').first().click()
await expect(page.locator('.reader-text')).toBeVisible()
},
back: async () => { await page.locator('.breadcrumb').getByRole('link', { name: '我的书库' }).click() },
},
{
name: '查词面板',
open: async () => {
await page.getByRole('link', { name: 'Fictional mobile book' }).click()
await page.locator('.chapter-list a.chapter-name').first().click()
await expect(page.locator('.reader-text')).toBeVisible()
await page.locator('.reader-word').first().click()
await expect(page.locator('.lookup-panel')).toBeVisible()
},
back: async () => { await page.locator('.site-header nav').getByRole('link', { name: '我的书库' }).click() },
},
{
name: '生词本',
open: async () => { await page.locator('.site-header nav').getByRole('link', { name: '生词本' }).click(); await expect(page.getByRole('heading', { name: '生词本' })).toBeVisible() },
back: async () => { await page.locator('.site-header nav').getByRole('link', { name: '我的书库' }).click() },
},
{
name: '复习',
open: async () => { await page.locator('.site-header nav').getByRole('link', { name: '到期复习' }).click(); await expect(page.getByTestId('review-card')).toBeVisible() },
back: async () => { await page.locator('.site-header nav').getByRole('link', { name: '我的书库' }).click() },
},
{
name: '进度',
open: async () => { await page.locator('.site-header nav').getByRole('link', { name: '进度' }).click(); await expect(page.getByRole('heading', { name: '我的进度' })).toBeVisible() },
back: async () => { await page.locator('.site-header nav').getByRole('link', { name: '我的书库' }).click() },
},
]
for (const surface of surfaces) {
await surface.open()
expect(await overflowOffenders(page), `${surface.name} must not overflow sideways`).toEqual([])
const widths = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, inner: window.innerWidth }))
expect(widths.scroll, `${surface.name} must not scroll sideways`).toBeLessThanOrEqual(widths.inner)
await page.screenshot({ path: `../.local/evidence/issue14-mobile-${surface.name}.png`, fullPage: true })
await surface.back()
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
}
})
test('the display control is reachable on a phone', async ({ page }) => {
await mockLearnerApi(page)
await login(page)
const trigger = page.getByTestId('display-trigger')
await expect(trigger).toBeVisible()
const box = (await trigger.boundingBox())!
// A finger needs a target of at least 38 pixels; the control is in the header on every page.
expect(box.height).toBeGreaterThanOrEqual(36)
await trigger.tap()
await expect(page.getByRole('menu').getByText('主题')).toBeVisible()
await page.getByRole('menu').getByText('深色', { exact: true }).tap()
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
await page.screenshot({ path: '../.local/evidence/issue14-mobile-dark.png' })
})
+79
View File
@@ -0,0 +1,79 @@
import { expect, test, type Page } from '@playwright/test'
const user = { id: 42, username: 'fictional-theme', role: 'learner' }
// The palette is one indirection deep: a CSS variable that references itself silently resolves to
// nothing and every surface loses its colour. These checks read the values back from the browser.
const palette = ['--bg', '--surface', '--surface-input', '--text', '--text-muted', '--border', '--accent',
'--accent-marker', '--notice-bg', '--status-ready-bg', '--status-read-bg', '--status-failed-bg', '--word-learning-bg',
'--saved', '--sense-bg', '--el-color-primary']
async function mockApi(page: Page): Promise<void> {
await page.route('**/api/v1/**', async route => {
const path = new URL(route.request().url()).pathname
let data: unknown = null
if (path === '/api/v1/login') data = { token: 'fictional-session', user }
else if (path === '/api/v1/me') data = user
else if (path === '/api/v1/space') data = { ownerId: user.id, language: 'en' }
else if (path === '/api/v1/books') data = { items: [] }
await route.fulfill({ json: { code: 200, data } })
})
}
async function login(page: Page): Promise<void> {
await page.goto('/')
await page.getByLabel('账号').fill(user.username)
await page.getByLabel('密码', { exact: true }).fill('fictional-password')
await page.getByRole('button', { name: '登录', exact: true }).click()
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
}
test('every palette colour resolves, in both themes', async ({ page }) => {
await mockApi(page)
await login(page)
const read = () => page.evaluate(names => {
const root = getComputedStyle(document.documentElement)
const values: Record<string, string> = {}
for (const name of names) values[name] = root.getPropertyValue(name).trim()
// The palette is declared on :root, so that is where the painted surface is read.
const html = getComputedStyle(document.documentElement)
return {
values,
body: html.backgroundColor,
text: getComputedStyle(document.body).color,
}
}, palette)
const light = await read()
for (const name of palette) {
expect(light.values[name], `${name} must resolve in the light theme`).not.toBe('')
}
// The page really paints with them: an unresolved background would be transparent.
expect(light.body).not.toBe('rgba(0, 0, 0, 0)')
expect(light.body).not.toBe('transparent')
expect(light.text).not.toBe(light.body)
await page.screenshot({ path: '../.local/evidence/issue14-theme-light.png' })
await page.getByTestId('display-trigger').click()
await page.getByRole('menu').getByText('深色', { exact: true }).click()
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
const dark = await read()
for (const name of palette) {
expect(dark.values[name], `${name} must resolve in the dark theme`).not.toBe('')
}
// The dark theme is a different surface, not the same one relabelled.
expect(dark.values['--bg']).not.toBe(light.values['--bg'])
expect(dark.body).not.toBe(light.body)
expect(dark.text).not.toBe(dark.body)
// Element Plus follows the same choice, so its components do not stay on a white card.
const epBackground = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--el-bg-color').trim())
expect(epBackground).not.toBe('')
await page.screenshot({ path: '../.local/evidence/issue14-theme-dark.png' })
// Back to light by hand, and the surface returns to the original value.
await page.getByTestId('display-trigger').click()
await page.getByRole('menu').getByText('浅色', { exact: true }).click()
expect((await read()).body).toBe(light.body)
})
+11 -1
View File
@@ -12,6 +12,16 @@ export default defineConfig({
// under parallel workers must not fail an assertion that the app itself would pass.
expect: { timeout: 15000 },
use: { baseURL: 'http://127.0.0.1:5173', headless: true, trace: 'off' },
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'], channel: 'chrome' } }],
projects: [
// Desktop behaviour, including the keyboard paths.
{ name: 'chromium', use: { ...devices['Desktop Chrome'], channel: 'chrome' }, testIgnore: /mobile-.*\.spec\.ts$/ },
// A touch phone viewport: the same pages at 390x844 with touch enabled. This is an emulated
// device, not a real phone; the hand-feel of a long-press selection still needs a person.
{
name: 'mobile',
testMatch: /mobile-.*\.spec\.ts$/,
use: { ...devices['Pixel 7'], channel: 'chrome', viewport: { width: 390, height: 844 } },
},
],
webServer: { command: 'npm run dev -- --host 127.0.0.1', url: 'http://127.0.0.1:5173', reuseExistingServer: !process.env.CI },
})
+171
View File
@@ -0,0 +1,171 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import DisplaySettings from '../components/DisplaySettings.vue'
import { useSessionStore } from '../stores/session'
import {
applyPreference, defaultPreference, DISPLAY_KEY_PREFIX, FONT_SCALES, readPreference, resolveTheme,
useDisplayStore, writePreference, type DisplayPreference,
} from '../stores/preferences'
const user = { id: 42, username: 'fictional-display', role: 'learner' as const }
/** A controllable stand-in for the operating system colour scheme. */
function fakeSystem(initialDark: boolean) {
const listeners = new Set<() => void>()
const query = {
matches: initialDark,
media: '(prefers-color-scheme: dark)',
addEventListener: (_: string, handler: () => void) => { listeners.add(handler) },
removeEventListener: (_: string, handler: () => void) => { listeners.delete(handler) },
}
vi.stubGlobal('matchMedia', vi.fn(() => query))
return {
set(dark: boolean) {
query.matches = dark
for (const handler of listeners) handler()
},
listenerCount: () => listeners.size,
}
}
let wrapper: VueWrapper | undefined
describe('display preference rules', () => {
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); wrapper?.unmount() })
it('keeps each account apart and never shares a choice', () => {
const storage = localStorage
writePreference(storage, 42, { theme: 'dark', font: 'xlarge' })
expect(readPreference(storage, 42)).toEqual({ theme: 'dark', font: 'xlarge' })
// Another account has never chosen anything, so it gets the defaults.
expect(readPreference(storage, 43)).toEqual(defaultPreference())
expect(Object.keys(storage)).toContain(`${DISPLAY_KEY_PREFIX}42`)
})
it('falls back to the default for unreadable or unknown stored values', () => {
const storage = localStorage
storage.setItem(`${DISPLAY_KEY_PREFIX}42`, '{not json')
expect(readPreference(storage, 42)).toEqual(defaultPreference())
storage.setItem(`${DISPLAY_KEY_PREFIX}42`, JSON.stringify({ theme: 'neon', font: 'huge' }))
expect(readPreference(storage, 42)).toEqual(defaultPreference())
storage.setItem(`${DISPLAY_KEY_PREFIX}42`, JSON.stringify({ theme: 'dark' }))
expect(readPreference(storage, 42)).toEqual({ theme: 'dark', font: 'standard' })
// Without an account there is nothing to read or write.
expect(readPreference(storage, null)).toEqual(defaultPreference())
writePreference(storage, null, { theme: 'dark', font: 'large' })
expect(Object.keys(storage).some(key => key.endsWith(':null'))).toBe(false)
})
it('resolves the system choice and applies it to the document', () => {
expect(resolveTheme('system', true)).toBe('dark')
expect(resolveTheme('system', false)).toBe('light')
expect(resolveTheme('light', true)).toBe('light')
expect(resolveTheme('dark', false)).toBe('dark')
const root = document.createElement('div')
const preference: DisplayPreference = { theme: 'dark', font: 'large' }
expect(applyPreference(root, preference, false)).toBe('dark')
expect(root.dataset.theme).toBe('dark')
expect(root.dataset.themeChoice).toBe('dark')
expect(root.classList.contains('dark')).toBe(true)
expect(root.style.getPropertyValue('--reader-font-scale')).toBe(FONT_SCALES.large)
applyPreference(root, { theme: 'system', font: 'standard' }, true)
expect(root.dataset.theme).toBe('dark')
expect(root.dataset.themeChoice).toBe('system')
expect(root.style.getPropertyValue('--reader-font-scale')).toBe('1')
applyPreference(root, { theme: 'light', font: 'xlarge' }, true)
expect(root.dataset.theme).toBe('light')
// Light removes the Element Plus dark class, so its components follow too.
expect(root.classList.contains('dark')).toBe(false)
expect(root.style.getPropertyValue('--reader-font-scale')).toBe(FONT_SCALES.xlarge)
})
})
describe('display store', () => {
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); wrapper?.unmount() })
it('applies the signed-in account preference and saves later changes for it', () => {
const system = fakeSystem(false)
useSessionStore().user = { ...user }
localStorage.setItem(`${DISPLAY_KEY_PREFIX}42`, JSON.stringify({ theme: 'dark', font: 'large' }))
const display = useDisplayStore()
display.load(42)
expect(display.theme).toBe('dark')
expect(display.font).toBe('large')
expect(display.isDark).toBe(true)
expect(document.documentElement.dataset.theme).toBe('dark')
display.setFont('xlarge')
expect(JSON.parse(localStorage.getItem(`${DISPLAY_KEY_PREFIX}42`)!)).toEqual({ theme: 'dark', font: 'xlarge' })
system.set(true)
// An explicit choice ignores the system change.
expect(document.documentElement.dataset.theme).toBe('dark')
})
it('follows the system while it is chosen, and stops when it is not', () => {
const system = fakeSystem(false)
useSessionStore().user = { ...user }
const display = useDisplayStore()
display.load(42)
expect(document.documentElement.dataset.theme).toBe('light')
system.set(true)
expect(document.documentElement.dataset.theme).toBe('dark')
display.setTheme('light')
system.set(false)
system.set(true)
expect(document.documentElement.dataset.theme).toBe('light')
expect(system.listenerCount()).toBe(0)
})
it('switches preferences with the account instead of carrying them over', async () => {
fakeSystem(false)
localStorage.setItem(`${DISPLAY_KEY_PREFIX}42`, JSON.stringify({ theme: 'dark', font: 'xlarge' }))
const session = useSessionStore()
session.user = { ...user }
const display = useDisplayStore()
await flushPromises()
expect(display.isDark).toBe(true)
// The second account never chose anything: it must not inherit the first one's dark theme.
session.user = { id: 43, username: 'other', role: 'learner' as const }
await flushPromises()
expect(display.theme).toBe('system')
expect(display.font).toBe('standard')
expect(document.documentElement.dataset.theme).toBe('light')
// Signing out returns to the default as well.
session.user = null
await flushPromises()
expect(display.theme).toBe('system')
expect(document.documentElement.classList.contains('dark')).toBe(false)
})
})
describe('display settings control', () => {
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); wrapper?.unmount() })
it('shows the current choice and stores a new one', async () => {
fakeSystem(false)
useSessionStore().user = { ...user }
const display = useDisplayStore()
wrapper = mount(DisplaySettings)
await flushPromises()
expect(wrapper.get('[data-testid="display-trigger"]').text()).toContain('跟随系统')
expect(wrapper.get('[data-testid="display-trigger"]').text()).toContain('标准')
display.setTheme('dark')
display.setFont('large')
await flushPromises()
expect(wrapper.get('[data-testid="display-trigger"]').text()).toContain('深色')
expect(wrapper.get('[data-testid="display-trigger"]').text()).toContain('大')
// The control is reachable by keyboard and announces itself.
const trigger = wrapper.get('[data-testid="display-trigger"]')
expect(trigger.attributes('aria-label')).toBe('显示设置')
expect(trigger.attributes('role')).toBe('button')
expect(trigger.attributes('tabindex')).toBe('0')
})
})
+158
View File
@@ -0,0 +1,158 @@
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router'
import ReaderView from '../views/ReaderView.vue'
import ReviewView from '../views/ReviewView.vue'
import { POSITION_KEY_PREFIX } from '../composables/readingPosition'
import { useSessionStore } from '../stores/session'
const user = { id: 42, username: 'fictional-experience', role: 'learner' as const }
const sha = 'c'.repeat(64)
const text = 'Curiosity opens the first door.\nThe second door stays closed.\n'
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
let wrapper: VueWrapper | undefined
const reviewItem = {
id: 5, term: 'curiosity', originalForm: 'Curiosity', definition: '好奇心', examples: ['Curious minds ask.'],
status: 'new' as const, level: 0, kind: 'word' as const, wordCount: 1, dueAt: '2026-09-15T00:00:00Z', reviewCount: 0,
}
function mockApi(handlers: Record<string, (init?: RequestInit) => unknown>): MockInstance {
return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const url = String(input)
for (const [fragment, handler] of Object.entries(handlers)) {
if (url.includes(fragment)) return ok(handler(init))
}
return ok({})
})
}
async function mountWith(component: unknown, path: string) {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div>LibraryStub</div>' } },
{ path: '/chapters/:id', component: component as never },
{ path: '/review', component: component as never },
],
})
await router.push(path)
await router.isReady()
wrapper = mount(component as never, { attachTo: document.body, global: { plugins: [router] } })
await flushPromises()
await flushPromises()
return wrapper
}
describe('review shortcuts in the page', () => {
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
afterEach(() => { vi.restoreAllMocks(); wrapper?.unmount() })
it('reveals and grades from the keyboard', async () => {
const answers: string[] = []
useSessionStore().user = { ...user }
mockApi({
'/reviews/queue': () => ({ items: [reviewItem], total: 1 }),
'/answers': (init) => {
answers.push(JSON.parse(String(init?.body)).grade)
return {
result: 'applied', duplicate: false, grade: 'correct', requeued: false, statusBefore: 'new', statusAfter: 'learning',
levelBefore: 0, levelAfter: 1, dueAtBefore: reviewItem.dueAt, dueAtAfter: '2026-09-20T00:00:00Z', item: reviewItem,
}
},
})
const view = await mountWith(ReviewView, '/review')
expect(view.find('[data-testid="review-card"]').exists()).toBe(true)
expect(view.get('[data-testid="review-shortcuts"]').text()).toContain('空格')
// Space shows the answer instead of grading anything.
document.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }))
await flushPromises()
expect(view.get('[data-testid="review-definition"]').text()).toContain('好奇心')
expect(answers).toEqual([])
// A digit grades it; the queue then has nothing left.
document.dispatchEvent(new KeyboardEvent('keydown', { key: '1', bubbles: true }))
await flushPromises()
expect(answers).toEqual(['correct'])
expect(view.find('[data-testid="review-summary"]').exists()).toBe(true)
})
it('ignores shortcuts typed into a field and with a modifier held', async () => {
const answers: string[] = []
useSessionStore().user = { ...user }
mockApi({
'/reviews/queue': () => ({ items: [reviewItem], total: 1 }),
'/answers': (init) => {
answers.push(JSON.parse(String(init?.body)).grade)
return { result: 'applied', duplicate: false, grade: 'wrong', requeued: true, statusBefore: 'new', statusAfter: 'new', levelBefore: 0, levelAfter: 0, dueAtBefore: reviewItem.dueAt, dueAtAfter: reviewItem.dueAt, item: reviewItem }
},
})
const view = await mountWith(ReviewView, '/review')
view.get('[data-testid="review-reveal"]').trigger('click')
await flushPromises()
// A key press inside an input belongs to the input, wherever the learner is typing.
const field = document.createElement('input')
document.body.append(field)
field.dispatchEvent(new KeyboardEvent('keydown', { key: '2', bubbles: true }))
document.dispatchEvent(new KeyboardEvent('keydown', { key: '2', ctrlKey: true, bubbles: true }))
await flushPromises()
expect(answers).toEqual([])
field.remove()
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: '2', bubbles: true }))
await flushPromises()
expect(answers).toEqual(['wrong'])
})
})
describe('reading position in the reader', () => {
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
afterEach(() => { vi.restoreAllMocks(); wrapper?.unmount() })
function readerApi() {
return mockApi({
'/tokens': () => ({ tokens: [{ start: 0, end: 9, text: 'Curiosity', kind: 'word' }] }),
'/chapters/7': () => ({
book: { id: 3, title: 'Fictional reader', language: 'en' },
chapter: {
id: 7, bookId: 3, ordinal: 1, title: 'Fictional chapter', status: 'ready', charCount: 31, errorReason: '',
errorMessage: '', jobId: 9, readAt: null, createdAt: '', updatedAt: '', contentSha256: sha, originalText: text,
},
navigation: { previousChapterId: null, nextChapterId: null },
}),
})
}
it('restores the saved position for the same account and version', async () => {
useSessionStore().user = { ...user }
localStorage.setItem(`${POSITION_KEY_PREFIX}42:7`, JSON.stringify({ ratio: 0.5, sha, at: '2026-09-15T03:00:00Z' }))
const scrollTo = vi.fn()
vi.stubGlobal('scrollTo', scrollTo)
// A chapter long enough to have a scrolled position.
Object.defineProperty(document.documentElement, 'scrollHeight', { value: 4800, configurable: true })
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true })
readerApi()
const view = await mountWith(ReaderView, '/chapters/7')
expect(view.find('.reader-text').exists()).toBe(true)
await new Promise(resolve => setTimeout(resolve, 90))
expect(scrollTo).toHaveBeenCalledWith({ top: 2000 })
await flushPromises()
expect(view.get('[data-testid="position-notice"]').text()).toContain('上次阅读位置')
})
it('does not restore a position taken from another content version', async () => {
useSessionStore().user = { ...user }
localStorage.setItem(`${POSITION_KEY_PREFIX}42:7`, JSON.stringify({ ratio: 0.5, sha: 'd'.repeat(64) }))
const scrollTo = vi.fn()
vi.stubGlobal('scrollTo', scrollTo)
Object.defineProperty(document.documentElement, 'scrollHeight', { value: 4800, configurable: true })
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true })
readerApi()
const view = await mountWith(ReaderView, '/chapters/7')
await new Promise(resolve => setTimeout(resolve, 90))
expect(scrollTo).not.toHaveBeenCalled()
expect(view.find('[data-testid="position-notice"]').exists()).toBe(false)
})
})
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest'
import {
clearPosition, currentRatio, normalizeRatio, POSITION_KEY_PREFIX, positionKey, readPosition, writePosition,
} from '../composables/readingPosition'
import { ignoresShortcut, shortcutFor } from '../composables/reviewShortcuts'
const sha = 'a'.repeat(64)
const otherSha = 'b'.repeat(64)
describe('reading position storage', () => {
it('stores one position per account and chapter', () => {
const storage = localStorage
storage.clear()
writePosition(storage, 42, 7, sha, 0.5, new Date('2026-09-15T03:00:00Z'))
expect(readPosition(storage, 42, 7, sha)).toEqual({ ratio: 0.5, sha, at: '2026-09-15T03:00:00.000Z' })
// Another chapter, another account and the signed-out state know nothing about it.
expect(readPosition(storage, 42, 8, sha)).toBeNull()
expect(readPosition(storage, 43, 7, sha)).toBeNull()
expect(readPosition(storage, null, 7, sha)).toBeNull()
expect(positionKey(null, 7)).toBeNull()
expect(positionKey(42, 0)).toBeNull()
expect(Object.keys(storage)).toEqual([`${POSITION_KEY_PREFIX}42:7`])
})
it('drops a position taken from another content version', () => {
const storage = localStorage
storage.clear()
writePosition(storage, 42, 7, sha, 0.4)
expect(readPosition(storage, 42, 7, otherSha)).toBeNull()
// A position without a version is never restored either.
expect(readPosition(storage, 42, 7, '')).toBeNull()
})
it('ignores unreadable values and never restores the very top', () => {
const storage = localStorage
storage.clear()
storage.setItem(`${POSITION_KEY_PREFIX}42:7`, '{broken')
expect(readPosition(storage, 42, 7, sha)).toBeNull()
storage.setItem(`${POSITION_KEY_PREFIX}42:7`, JSON.stringify({ ratio: 'half', sha }))
expect(readPosition(storage, 42, 7, sha)).toBeNull()
expect(normalizeRatio(0)).toBeNull()
expect(normalizeRatio(-1)).toBeNull()
expect(normalizeRatio(Number.NaN)).toBeNull()
expect(normalizeRatio(3)).toBe(1)
expect(normalizeRatio(0.25)).toBe(0.25)
// A position of zero is not worth storing, so the top of a chapter needs no key at all.
storage.clear()
writePosition(storage, 42, 7, sha, 0)
expect(storage.getItem(`${POSITION_KEY_PREFIX}42:7`)).toBeNull()
writePosition(storage, 42, 7, sha, 0.5)
clearPosition(storage, 42, 7)
expect(storage.getItem(`${POSITION_KEY_PREFIX}42:7`)).toBeNull()
})
it('turns a scroll offset into a ratio and clamps it', () => {
expect(currentRatio({ scrollY: 0, innerHeight: 800 }, 4800)).toBe(0)
expect(currentRatio({ scrollY: 1000, innerHeight: 800 }, 4800)).toBeCloseTo(0.25, 5)
expect(currentRatio({ scrollY: 4000, innerHeight: 800 }, 4800)).toBe(1)
// A chapter shorter than the window cannot be scrolled.
expect(currentRatio({ scrollY: 0, innerHeight: 900 }, 900)).toBe(0)
})
})
describe('review keyboard shortcuts', () => {
it('maps space and enter to revealing and digits to grading', () => {
expect(shortcutFor(' ', false)).toBe('reveal')
expect(shortcutFor('Enter', false)).toBe('reveal')
// Before the answer is on screen a grade key means nothing.
expect(shortcutFor('1', false)).toBeNull()
expect(shortcutFor('1', true)).toBe('correct')
expect(shortcutFor('2', true)).toBe('wrong')
expect(shortcutFor('3', true)).toBe('again')
expect(shortcutFor('4', true)).toBeNull()
expect(shortcutFor('q', true)).toBeNull()
// Once revealed, space no longer reveals anything.
expect(shortcutFor(' ', true)).toBeNull()
})
it('never fires while the learner is typing or using a control', () => {
const form = document.createElement('form')
const input = document.createElement('input')
const textarea = document.createElement('textarea')
const button = document.createElement('button')
const contenteditable = document.createElement('div')
contenteditable.setAttribute('contenteditable', 'true')
const link = document.createElement('a')
link.setAttribute('href', '/vocab')
const plain = document.createElement('p')
form.append(input, textarea, button, contenteditable, link, plain)
for (const element of [input, textarea, contenteditable]) {
expect(ignoresShortcut(element, '1')).toBe(true)
expect(ignoresShortcut(element, ' ')).toBe(true)
}
// A focused button or link owns Space and Enter itself, but not the digit keys.
expect(ignoresShortcut(button, ' ')).toBe(true)
expect(ignoresShortcut(button, 'Enter')).toBe(true)
expect(ignoresShortcut(button, '1')).toBe(false)
expect(ignoresShortcut(link, ' ')).toBe(true)
expect(ignoresShortcut(link, '2')).toBe(false)
expect(ignoresShortcut(plain, '1')).toBe(false)
expect(ignoresShortcut(null, '1')).toBe(false)
})
})
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { ElDropdown, ElDropdownItem, ElDropdownMenu } from 'element-plus'
import { FONT_CHOICES, FONT_LABELS, THEME_CHOICES, THEME_LABELS, useDisplayStore } from '../stores/preferences'
// One header control for both display choices. The menu shows the current values, so the
// learner can tell what is active without opening anything else.
const display = useDisplayStore()
</script>
<template>
<ElDropdown trigger="click" data-testid="display-settings" @command="(value: string) => {
const [group, choice] = value.split(':')
if (group === 'theme') display.setTheme(choice as never)
if (group === 'font') display.setFont(choice as never)
}">
<button type="button" class="display-trigger" aria-label="显示设置" data-testid="display-trigger">
显示
<span class="display-current">{{ THEME_LABELS[display.theme] }} · {{ FONT_LABELS[display.font] }}</span>
</button>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem disabled>主题</ElDropdownItem>
<ElDropdownItem
v-for="choice in THEME_CHOICES"
:key="choice"
:command="`theme:${choice}`"
:class="{ 'is-current': display.theme === choice }"
>{{ THEME_LABELS[choice] }}{{ display.theme === choice ? ' ✓' : '' }}</ElDropdownItem>
<ElDropdownItem divided disabled>正文字号</ElDropdownItem>
<ElDropdownItem
v-for="choice in FONT_CHOICES"
:key="choice"
:command="`font:${choice}`"
:class="{ 'is-current': display.font === choice }"
>{{ FONT_LABELS[choice] }}{{ display.font === choice ? ' ✓' : '' }}</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</template>
@@ -0,0 +1,79 @@
/**
* Remembers where the reader stopped in a chapter. The position belongs to one account and one
* chapter, and it is tied to the chapter's content version: after the text is replaced, the old
* offset would point at different words, so it is dropped instead of restored.
*
* The value is a scroll ratio rather than a pixel offset, because the same chapter is read at
* different window widths (desktop and phone) where a pixel offset would land in the wrong place.
* This is local to the browser: it is not synced across devices.
*/
export const POSITION_KEY_PREFIX = 'lexgo-learner-position:'
export interface ReadingPosition {
ratio: number
sha: string
at: string
}
export function positionKey(userId: number | null, chapterId: number): string | null {
if (userId === null || !Number.isInteger(chapterId) || chapterId <= 0) return null
return `${POSITION_KEY_PREFIX}${userId}:${chapterId}`
}
/** Clamps a stored ratio into [0,1]; anything else is treated as "no usable position". */
export function normalizeRatio(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value)) return null
if (value <= 0) return null
return Math.min(value, 1)
}
export function readPosition(storage: Storage, userId: number | null, chapterId: number, sha: string): ReadingPosition | null {
const key = positionKey(userId, chapterId)
if (key === null) return null
try {
const raw = storage.getItem(key)
if (raw === null) return null
const parsed = JSON.parse(raw) as Partial<ReadingPosition> | null
const ratio = normalizeRatio(parsed?.ratio)
// A different content version means the offset describes words that are no longer there.
if (ratio === null || !parsed || parsed.sha !== sha) return null
return { ratio, sha, at: typeof parsed.at === 'string' ? parsed.at : '' }
} catch {
return null
}
}
export function writePosition(storage: Storage, userId: number | null, chapterId: number, sha: string, ratio: number, now = new Date()): void {
const key = positionKey(userId, chapterId)
const usable = normalizeRatio(ratio)
if (key === null || usable === null || !sha) return
try {
storage.setItem(key, JSON.stringify({ ratio: usable, sha, at: now.toISOString() } satisfies ReadingPosition))
} catch {
// A full or disabled store must not break reading.
}
}
export function clearPosition(storage: Storage, userId: number | null, chapterId: number): void {
const key = positionKey(userId, chapterId)
if (key === null) return
try {
storage.removeItem(key)
} catch {
// Ignored for the same reason as above.
}
}
export interface ReadingPositionOptions {
/** The scrollable document the reader lives in. */
view: () => Window
/** The current scrollable height, used to turn a pixel offset into a ratio. */
scrollHeight: () => number
}
/** Turns the current window scroll offset into the ratio that is stored. */
export function currentRatio(view: Pick<Window, 'scrollY' | 'innerHeight'>, scrollHeight: number): number {
const scrollable = scrollHeight - view.innerHeight
if (scrollable <= 0) return 0
return Math.min(Math.max(view.scrollY / scrollable, 0), 1)
}
@@ -0,0 +1,58 @@
import { onScopeDispose } from 'vue'
export type ReviewShortcut = 'reveal' | 'correct' | 'wrong' | 'again'
export interface ReviewShortcutOptions {
/** Called for a shortcut that applies in the current card state. */
onShortcut: (action: ReviewShortcut) => void
/** False while the card is busy or an answer failed; then no shortcut is accepted. */
enabled: () => boolean
/** Whether the answer is already on screen. */
revealed: () => boolean
}
const GRADE_KEYS: Record<string, ReviewShortcut> = { '1': 'correct', '2': 'wrong', '3': 'again' }
/**
* True when the key belongs to whatever currently has the focus instead of the review.
*
* Typing fields and dropdowns own every key. A focused button or link owns Space and Enter,
* because the browser already activates it there and a second handler would answer twice — but a
* digit is not a button key, so grading from the keyboard keeps working while a button is focused.
*/
export function ignoresShortcut(target: EventTarget | null, key: string): boolean {
const element = target as HTMLElement | null
if (!element || typeof element.closest !== 'function') return false
if (element.isContentEditable) return true
if (element.closest('input, textarea, select, [contenteditable="true"], .el-select, .el-dropdown') !== null) return true
if (key === ' ' || key === 'Enter') {
return element.closest('button, a[href], [role="button"]') !== null
}
return false
}
/** Maps one key press to a review action, or null when the key is not ours. */
export function shortcutFor(key: string, revealed: boolean): ReviewShortcut | null {
if (key === ' ' || key === 'Enter') return revealed ? null : 'reveal'
if (!revealed) return null
return GRADE_KEYS[key] ?? null
}
/**
* Keyboard operations for the due review: Space or Enter shows the answer, then 1, 2 and 3 grade
* it. A modifier key means the learner is using a browser or system shortcut, so it is left alone.
*/
export function useReviewShortcuts(options: ReviewShortcutOptions) {
function onKeydown(event: KeyboardEvent): void {
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return
if (!options.enabled()) return
if (ignoresShortcut(event.target, event.key)) return
const action = shortcutFor(event.key, options.revealed())
if (action === null) return
event.preventDefault()
options.onShortcut(action)
}
document.addEventListener('keydown', onKeydown)
onScopeDispose(() => document.removeEventListener('keydown', onKeydown))
}
+4
View File
@@ -8,6 +8,10 @@ import 'element-plus/es/components/radio/style/css'
import 'element-plus/es/components/radio-group/style/css'
import 'element-plus/es/components/select/style/css'
import 'element-plus/es/components/option/style/css'
import 'element-plus/es/components/dropdown/style/css'
import 'element-plus/es/components/dropdown-menu/style/css'
import 'element-plus/es/components/dropdown-item/style/css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import './style.css'
createApp(App).use(createPinia()).use(router).mount('#app')
+155
View File
@@ -0,0 +1,155 @@
import { computed, ref, watch } from 'vue'
import { defineStore } from 'pinia'
import { useSessionStore } from './session'
/** The three display choices the learner can make. */
export type ThemeChoice = 'light' | 'dark' | 'system'
export type FontChoice = 'standard' | 'large' | 'xlarge'
export const THEME_CHOICES: ThemeChoice[] = ['light', 'dark', 'system']
export const FONT_CHOICES: FontChoice[] = ['standard', 'large', 'xlarge']
export const THEME_LABELS: Record<ThemeChoice, string> = { light: '浅色', dark: '深色', system: '跟随系统' }
export const FONT_LABELS: Record<FontChoice, string> = { standard: '标准', large: '大', xlarge: '特大' }
/**
* The reader text scale. It multiplies the reading surfaces only (chapter text, lookup content,
* review card), so a larger step never squeezes the navigation or the buttons in a narrow window.
*/
export const FONT_SCALES: Record<FontChoice, string> = { standard: '1', large: '1.15', xlarge: '1.3' }
export interface DisplayPreference {
theme: ThemeChoice
font: FontChoice
}
/** Preferences are stored per account, so switching accounts cannot carry one across. */
export const DISPLAY_KEY_PREFIX = 'lexgo-learner-display:'
export function displayKey(userId: number | null): string | null {
return userId === null ? null : `${DISPLAY_KEY_PREFIX}${userId}`
}
export function defaultPreference(): DisplayPreference {
return { theme: 'system', font: 'standard' }
}
function isTheme(value: unknown): value is ThemeChoice {
return typeof value === 'string' && (THEME_CHOICES as string[]).includes(value)
}
function isFont(value: unknown): value is FontChoice {
return typeof value === 'string' && (FONT_CHOICES as string[]).includes(value)
}
/**
* Reads one account's stored display preference. Anything unreadable, unknown or from an older
* shape falls back to the default instead of throwing or half-applying a stored value.
*/
export function readPreference(storage: Storage, userId: number | null): DisplayPreference {
const key = displayKey(userId)
if (key === null) return defaultPreference()
try {
const raw = storage.getItem(key)
if (raw === null) return defaultPreference()
const parsed = JSON.parse(raw) as Partial<DisplayPreference> | null
const preference = defaultPreference()
if (parsed && isTheme(parsed.theme)) preference.theme = parsed.theme
if (parsed && isFont(parsed.font)) preference.font = parsed.font
return preference
} catch {
return defaultPreference()
}
}
export function writePreference(storage: Storage, userId: number | null, preference: DisplayPreference): void {
const key = displayKey(userId)
if (key === null) return
try {
storage.setItem(key, JSON.stringify({ theme: preference.theme, font: preference.font }))
} catch {
// A full or disabled store must not break the page: the choice still applies for this visit.
}
}
/** "system" resolves through the operating system preference; the other two are explicit. */
export function resolveTheme(theme: ThemeChoice, prefersDark: boolean): 'light' | 'dark' {
if (theme === 'system') return prefersDark ? 'dark' : 'light'
return theme
}
/**
* Applies a preference to the document: the palette attribute, the Element Plus dark class and
* the reading font scale. The dark class is what Element Plus components read.
*/
export function applyPreference(root: HTMLElement, preference: DisplayPreference, prefersDark: boolean): 'light' | 'dark' {
const resolved = resolveTheme(preference.theme, prefersDark)
root.dataset.theme = resolved
root.dataset.themeChoice = preference.theme
root.classList.toggle('dark', resolved === 'dark')
root.style.setProperty('--reader-font-scale', FONT_SCALES[preference.font])
return resolved
}
export function systemPrefersDark(): boolean {
return typeof window !== 'undefined' && typeof window.matchMedia === 'function'
? window.matchMedia('(prefers-color-scheme: dark)').matches
: false
}
export const useDisplayStore = defineStore('display', () => {
const session = useSessionStore()
const preference = ref<DisplayPreference>(defaultPreference())
const resolved = ref<'light' | 'dark'>('light')
let listening: MediaQueryList | null = null
const theme = computed(() => preference.value.theme)
const font = computed(() => preference.value.font)
const isDark = computed(() => resolved.value === 'dark')
function storage(): Storage | null {
try {
return window.localStorage
} catch {
return null
}
}
function paint(): void {
if (typeof document === 'undefined') return
resolved.value = applyPreference(document.documentElement, preference.value, systemPrefersDark())
}
/** Watches the operating system only while the choice actually follows it. */
function followSystem(): void {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return
listening?.removeEventListener('change', paint)
listening = null
if (preference.value.theme !== 'system') return
listening = window.matchMedia('(prefers-color-scheme: dark)')
listening.addEventListener('change', paint)
}
/** Loads the account's own preference (or the default) and applies it at once. */
function load(userId: number | null): void {
const store = storage()
preference.value = store ? readPreference(store, userId) : defaultPreference()
paint()
followSystem()
}
function save(next: Partial<DisplayPreference>): void {
if (next.theme !== undefined && isTheme(next.theme)) preference.value.theme = next.theme
if (next.font !== undefined && isFont(next.font)) preference.value.font = next.font
const store = storage()
if (store) writePreference(store, session.user?.id ?? null, preference.value)
paint()
followSystem()
}
watch(() => session.user?.id ?? null, (next, previous) => {
if (next !== previous) load(next)
}, { immediate: true })
return { preference, theme, font, resolved, isDark, load, save, setTheme: (value: ThemeChoice) => save({ theme: value }), setFont: (value: FontChoice) => save({ font: value }) }
})
+221 -82
View File
@@ -1,152 +1,273 @@
:root {
/* One palette for both themes. Every surface, text and status colour in the rules below
resolves through these variables, so the dark theme only overrides values, never layout.
These are the only literal colours in the file. */
--bg: #f7f5ee;
--surface: #fffdf8;
--surface-raised: #fffdf7;
--surface-input: #fffefa;
--text: #233d31;
--text-strong: #264a35;
--text-muted: #748073;
--text-faint: #687568;
--border: #e0e3d8;
--border-strong: #d9decf;
--border-input: #d6dccf;
--accent: #315c43;
--accent-soft: #eef2eb;
--accent-marker: #bc803d;
--accent-gold: #ba8345;
--brand-panel: #284c38;
--brand-panel-text: #f7f5ee;
--brand-panel-muted: #d4dfd2;
--brand-panel-faint: #c4d1c0;
--brand-panel-line: #d6b980;
--status-pending-bg: #f5f2e4;
--status-pending-border: #e2dcc2;
--status-pending-text: #7a6a35;
--status-processing-bg: #eaf1f7;
--status-processing-border: #c9dcea;
--status-processing-text: #35566e;
--status-ready-bg: #eef2eb;
--status-ready-border: #cbd9c9;
--status-ready-text: #315c43;
--status-failed-bg: #fff0e7;
--status-failed-border: #ebc3a8;
--status-failed-text: #8b4324;
--status-read-bg: #e7f0e9;
--status-read-border: #b9d3bf;
--status-read-text: #2c5b3d;
--notice-bg: #fff0e7;
--notice-border: #ebc3a8;
--notice-text: #8b4324;
--highlight: #eef2eb;
--sense-bg: #fbf7ee;
--sense-border: #d6b980;
--sense-text: #6b5b3e;
--sense-muted: #8b794e;
--saved: #2f6b45;
--word-learning-bg: #f6e7c9;
--word-known-bg: #dee9d6;
--word-ignored-text: #949c94;
--word-selected-bg: #e3e9d9;
--reader-font-scale: 1;
font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif;
color: #233d31;
background: #f7f5ee;
color: var(--text);
background: var(--bg);
font-synthesis: none;
--el-color-primary: #315c43;
--el-color-primary: var(--accent);
--el-color-primary-light-3: #597f66;
--el-color-primary-light-5: #8da493;
--el-color-primary-light-7: #bdccc0;
--el-color-primary-light-9: #eef2eb;
--el-color-primary-dark-2: #264a35;
--el-color-primary-light-9: var(--accent-soft);
--el-color-primary-dark-2: var(--text-strong);
--el-border-radius-base: 8px;
--el-font-size-base: 15px;
}
/* Dark palette. The Element Plus dark class is toggled by the display store, so components
follow the same choice as the page palette. */
html[data-theme='dark'] {
--bg: #171a17;
--surface: #1f231f;
--surface-raised: #1c201c;
--surface-input: #242824;
--text: #e7ece4;
--text-strong: #cfe0d3;
--text-muted: #9fae9c;
--text-faint: #8e9c8c;
--border: #333a33;
--border-strong: #3b433b;
--border-input: #3f483f;
--accent: #7db28f;
--accent-soft: #24312a;
--accent-marker: #d7a45f;
--accent-gold: #d7a45f;
--brand-panel: #10140f;
--brand-panel-text: #e7ece4;
--brand-panel-muted: #b9c7b6;
--brand-panel-faint: #97a695;
--brand-panel-line: #d7a45f;
--status-pending-bg: #33301f;
--status-pending-border: #4a442b;
--status-pending-text: #e0cf94;
--status-processing-bg: #1d2a33;
--status-processing-border: #2f4756;
--status-processing-text: #a8cbe0;
--status-ready-bg: #22301f;
--status-ready-border: #35492f;
--status-ready-text: #a9d0ac;
--status-failed-bg: #33231b;
--status-failed-border: #4d3527;
--status-failed-text: #e9ad86;
--status-read-bg: #1f3024;
--status-read-border: #33493a;
--status-read-text: #a5d3b1;
--notice-bg: #33231b;
--notice-border: #4d3527;
--notice-text: #e9ad86;
--highlight: #26332a;
--sense-bg: #23261d;
--sense-border: #4a4227;
--sense-text: #d9cba6;
--sense-muted: #c0ad7f;
--saved: #8ec69f;
--word-learning-bg: #3b3520;
--word-known-bg: #253120;
--word-ignored-text: #7d867d;
--word-selected-bg: #2b382d;
}
/* Element Plus reads its own dark variables; the shared palette keeps both in step. */
html.dark {
--el-bg-color: var(--surface);
--el-bg-color-overlay: var(--surface-raised);
--el-text-color-primary: var(--text);
--el-text-color-regular: var(--text);
--el-text-color-secondary: var(--text-muted);
--el-border-color: var(--border);
--el-border-color-light: var(--border);
--el-fill-color-blank: var(--surface-input);
--el-fill-color-light: var(--accent-soft);
color-scheme: dark;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; }
button, input { font: inherit; }
button:focus-visible, a:focus-visible { outline: 3px solid #bc803d; outline-offset: 4px; }
a { color: inherit; }
.brand { font-family: Georgia, serif; font-size: 30px; font-weight: 700; letter-spacing: -1px; text-decoration: none; }
.brand-dot { color: #ba8345; }
.brand-dot { color: var(--accent-gold); }
.login-page { min-height: 100dvh; display: grid; grid-template-columns: minmax(340px, 1fr) minmax(400px, 1fr); }
.welcome { background: #284c38; color: #f7f5ee; padding: 46px 10%; display: flex; flex-direction: column; justify-content: space-between; }
.welcome .brand { color: #f7f5ee; }
.welcome { background: var(--brand-panel); color: var(--brand-panel-text); padding: 46px 10%; display: flex; flex-direction: column; justify-content: space-between; }
.welcome .brand { color: var(--brand-panel-text); }
.welcome-copy { padding: 80px 0; max-width: 450px; }
.welcome-copy h2 { font-family: Georgia, 'Microsoft YaHei', serif; font-weight: 400; font-size: clamp(32px, 3.3vw, 52px); line-height: 1.55; letter-spacing: 2px; margin: 26px 0; }
.welcome-copy p { font-size: 16px; color: #d4dfd2; line-height: 1.9; }
.book-mark { width: 60px; height: 60px; color: #d6b980; }
.welcome-foot { font-size: 13px; letter-spacing: 2px; color: #c4d1c0; }
.welcome-copy p { font-size: 16px; color: var(--brand-panel-muted); line-height: 1.9; }
.book-mark { width: 60px; height: 60px; color: var(--brand-panel-line); }
.welcome-foot { font-size: 13px; letter-spacing: 2px; color: var(--brand-panel-faint); }
.login-main { display: grid; place-items: center; padding: 48px 28px; }
.login-form { width: min(100%, 360px); }
.eyebrow { color: #687568; font-size: 12px; letter-spacing: 3px; }
.eyebrow { color: var(--text-faint); font-size: 12px; letter-spacing: 3px; }
h1 { font-size: 30px; font-weight: 600; margin: 14px 0; letter-spacing: 1px; }
.subtle { color: #748073; font-size: 14px; line-height: 1.8; }
.subtle { color: var(--text-muted); font-size: 14px; line-height: 1.8; }
.login-form form { margin-top: 36px; }
.field { display: block; margin-bottom: 22px; }
.field label { display: block; font-size: 14px; margin-bottom: 10px; }
.el-input { --el-input-height: 46px; --el-input-bg-color: #fffefa; --el-input-border-color: #d6dccf; }
.el-input { --el-input-height: 46px; --el-input-bg-color: var(--surface-input); --el-input-border-color: var(--border-input); }
.el-button { min-height: 42px; }
.login-submit { width: 100%; margin-top: 8px; height: 48px; letter-spacing: 4px; }
.notice { padding: 12px 15px; background: #fff0e7; border: 1px solid #ebc3a8; color: #8b4324; border-radius: 6px; font-size: 14px; line-height: 1.6; }
.site-header { padding: 24px max(24px, calc((100vw - 1200px) / 2)); border-bottom: 1px solid #e0e3d8; display: flex; align-items: center; gap: 32px; background: #fffdf7; }
.notice { padding: 12px 15px; background: var(--notice-bg); border: 1px solid var(--notice-border); color: var(--notice-text); border-radius: 6px; font-size: 14px; line-height: 1.6; }
.site-header { padding: 24px max(24px, calc((100vw - 1200px) / 2)); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 32px; background: var(--surface-raised); }
.site-header nav { flex: 1; }
.active-nav { font-size: 15px; text-decoration: none; padding: 13px 0; border-bottom: 2px solid #315c43; }
.active-nav { font-size: 15px; text-decoration: none; padding: 13px 0; border-bottom: 2px solid var(--accent); }
.account { display: flex; align-items: center; gap: 18px; font-size: 14px; }
.account-name { max-width: 200px; overflow: hidden; text-overflow: ellipsis; }
.library { max-width: 1120px; margin: 60px auto; padding: 0 28px; }
.library-title { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 38px; }
.language { border: 1px solid #d9decf; padding: 9px 17px; border-radius: 20px; font-size: 13px; background: #fffdf7; }
.empty-library { background: #fffdf8; border: 1px solid #e0e3d8; border-radius: 12px; min-height: 360px; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 44px 20px; text-align: center; }
.empty-library .book-mark { color: #6e8967; width: 56px; height: 56px; }
.language { border: 1px solid var(--border-strong); padding: 9px 17px; border-radius: 20px; font-size: 13px; background: var(--surface-raised); }
.empty-library { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; min-height: 360px; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 44px 20px; text-align: center; }
.empty-library .book-mark { color: var(--accent); width: 56px; height: 56px; }
.empty-library h2 { font-weight: 500; font-size: 21px; margin: 26px 0 4px; }
.loading { padding: 80px 24px; text-align: center; color: #748073; }
.loading { padding: 80px 24px; text-align: center; color: var(--text-muted); }
/* Import / book / reader pages share one shell. */
.page { max-width: 1120px; margin: 60px auto; padding: 0 28px; }
.page-title { display: flex; align-items: center; justify-content: space-between; gap: 18px; flex-wrap: wrap; margin-bottom: 28px; }
.page-title h1 { margin: 14px 0 6px; overflow-wrap: anywhere; }
.page-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.library-actions { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.link-button { display: inline-flex; align-items: center; min-height: 42px; padding: 0 8px; color: #315c43; }
.breadcrumb { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 0 0 4px; font-size: 14px; color: #748073; }
.breadcrumb a { color: #315c43; }
.link-button { display: inline-flex; align-items: center; min-height: 42px; padding: 0 8px; color: var(--accent); }
.breadcrumb { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 0 0 4px; font-size: 14px; color: var(--text-muted); }
.breadcrumb a { color: var(--accent); }
/* Library book cards. */
.book-grid { list-style: none; margin: 0; padding: 0; display: grid; gap: 18px; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); }
.book-card { display: flex; flex-direction: column; gap: 10px; background: #fffdf8; border: 1px solid #e0e3d8; border-radius: 12px; padding: 22px; }
.book-card { display: flex; flex-direction: column; gap: 10px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 22px; }
.book-card .subtle { margin: 0; }
.book-title { font-family: Georgia, 'Microsoft YaHei', serif; font-size: 21px; font-weight: 600; color: #233d31; text-decoration: none; overflow-wrap: anywhere; }
.book-title:hover { color: #315c43; text-decoration: underline; }
.status-summary { align-self: flex-start; margin: 0; padding: 6px 13px; border-radius: 20px; background: #eef2eb; color: #3d5b48; font-size: 13px; }
.book-title { font-family: Georgia, 'Microsoft YaHei', serif; font-size: 21px; font-weight: 600; color: var(--text); text-decoration: none; overflow-wrap: anywhere; }
.book-title:hover { color: var(--accent); text-decoration: underline; }
.status-summary { align-self: flex-start; margin: 0; padding: 6px 13px; border-radius: 20px; background: var(--accent-soft); color: var(--accent); font-size: 13px; }
/* Chapter and job status, identical vocabulary for both. */
.status-chip { display: inline-flex; align-items: center; white-space: nowrap; padding: 5px 13px; border: 1px solid transparent; border-radius: 20px; font-size: 13px; }
.status-pending { background: #f5f2e4; border-color: #e2dcc2; color: #7a6a35; }
.status-processing { background: #eaf1f7; border-color: #c9dcea; color: #35566e; }
.status-ready { background: #eef2eb; border-color: #cbd9c9; color: #315c43; }
.status-failed { background: #fff0e7; border-color: #ebc3a8; color: #8b4324; }
.status-read { background: #e7f0e9; border-color: #b9d3bf; color: #2c5b3d; }
.status-pending { background: var(--status-pending-bg); border-color: var(--status-pending-border); color: var(--status-pending-text); }
.status-processing { background: var(--status-processing-bg); border-color: var(--status-processing-border); color: var(--status-processing-text); }
.status-ready { background: var(--accent-soft); border-color: var(--status-ready-border); color: var(--accent); }
.status-failed { background: var(--notice-bg); border-color: var(--notice-border); color: var(--notice-text); }
.status-read { background: var(--status-read-bg); border-color: var(--status-read-border); color: var(--status-read-text); }
/* Completing a chapter: one explicit action, with the reading rule spelled out. */
.chapter-complete { display: flex; align-items: center; justify-content: space-between; gap: 18px; flex-wrap: wrap; margin-top: 22px; padding: 18px 22px; background: #fffdf8; border: 1px solid #e0e3d8; border-radius: 12px; }
.chapter-complete { display: flex; align-items: center; justify-content: space-between; gap: 18px; flex-wrap: wrap; margin-top: 22px; padding: 18px 22px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; }
.chapter-complete .subtle { margin: 6px 0 0; }
.read-state { margin: 0; font-weight: 600; color: #2c5b3d; }
.read-state { margin: 0; font-weight: 600; color: var(--status-read-text); }
/* Progress page. */
.progress-stats { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); margin-top: 6px; }
.stat-card { background: #fffdf8; border: 1px solid #e0e3d8; border-radius: 12px; padding: 20px 22px; }
.stat-card h2 { margin: 0 0 10px; font-size: 15px; font-weight: 600; color: #3d5b48; letter-spacing: 0; }
.stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 20px 22px; }
.stat-card h2 { margin: 0 0 10px; font-size: 15px; font-weight: 600; color: var(--accent); letter-spacing: 0; }
.stat-card .subtle { margin: 6px 0 0; }
.stat-value { margin: 0; font-family: Georgia, 'Microsoft YaHei', serif; font-size: 30px; line-height: 1.2; color: #233d31; }
.stat-value { margin: 0; font-family: Georgia, 'Microsoft YaHei', serif; font-size: 30px; line-height: 1.2; color: var(--text); }
.progress-books { margin-top: 34px; }
.progress-books h2 { margin: 0 0 14px; font-size: 19px; }
.progress-book-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.progress-book-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; background: #fffdf8; border: 1px solid #e0e3d8; border-radius: 12px; padding: 16px 18px; }
.progress-book-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 16px 18px; }
/* Import form. */
.import-form { max-width: 720px; background: #fffdf8; border: 1px solid #e0e3d8; border-radius: 12px; padding: 28px; }
.import-form { max-width: 720px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 28px; }
.field-label { display: block; font-size: 14px; margin-bottom: 10px; }
.fixed-value { margin: 0; padding: 12px 15px; border: 1px solid #d6dccf; border-radius: 8px; background: #fffefa; font-size: 15px; }
.field-error { margin: 8px 0 0; color: #8b4324; font-size: 13px; line-height: 1.6; }
.counter { margin: 8px 0 0; color: #748073; font-size: 13px; }
.fixed-value { margin: 0; padding: 12px 15px; border: 1px solid var(--border-input); border-radius: 8px; background: var(--surface-input); font-size: 15px; }
.field-error { margin: 8px 0 0; color: var(--notice-text); font-size: 13px; line-height: 1.6; }
.counter { margin: 8px 0 0; color: var(--text-muted); font-size: 13px; }
.form-actions { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-top: 14px; }
.import-form .el-textarea__inner { min-height: 220px; line-height: 1.9; }
.book-select { width: 100%; }
/* Book chapters. */
.chapter-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
.chapter-row { display: grid; grid-template-columns: 40px minmax(0, 1fr) auto auto; align-items: center; gap: 14px; background: #fffdf8; border: 1px solid #e0e3d8; border-radius: 12px; padding: 16px 18px; }
.chapter-ordinal { font-family: Georgia, serif; font-size: 17px; color: #8b9a8b; text-align: center; }
.chapter-row { display: grid; grid-template-columns: 40px minmax(0, 1fr) auto auto; align-items: center; gap: 14px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 16px 18px; }
.chapter-ordinal { font-family: Georgia, serif; font-size: 17px; color: var(--text-faint); text-align: center; }
.chapter-info { min-width: 0; }
.chapter-name { display: inline-block; font-size: 16px; font-weight: 500; color: #233d31; text-decoration: none; overflow-wrap: anywhere; }
a.chapter-name:hover { color: #315c43; text-decoration: underline; }
.chapter-meta { margin: 4px 0 0; color: #748073; font-size: 13px; overflow-wrap: anywhere; }
.chapter-name { display: inline-block; font-size: 16px; font-weight: 500; color: var(--text); text-decoration: none; overflow-wrap: anywhere; }
a.chapter-name:hover { color: var(--accent); text-decoration: underline; }
.chapter-meta { margin: 4px 0 0; color: var(--text-muted); font-size: 13px; overflow-wrap: anywhere; }
/* Reader keeps the pasted text exactly as it was, including line breaks and tabs. */
.reader-page { max-width: 820px; }
.reader-text { white-space: pre-wrap; overflow-wrap: break-word; margin: 26px 0 0; font-family: Georgia, 'Times New Roman', 'Microsoft YaHei', serif; font-size: 17px; line-height: 2; }
.processing-hint { padding: 36px 0; color: #748073; }
.reader-nav { display: flex; align-items: center; justify-content: space-between; gap: 14px; flex-wrap: wrap; margin-top: 36px; padding-top: 22px; border-top: 1px solid #e0e3d8; }
.reader-text { white-space: pre-wrap; overflow-wrap: break-word; margin: 26px 0 0; font-family: Georgia, 'Times New Roman', 'Microsoft YaHei', serif; font-size: calc(17px * var(--reader-font-scale)); line-height: 2; }
.processing-hint { padding: 36px 0; color: var(--text-muted); }
.reader-nav { display: flex; align-items: center; justify-content: space-between; gap: 14px; flex-wrap: wrap; margin-top: 36px; padding-top: 22px; border-top: 1px solid var(--border); }
.reader-page.has-lookup { max-width: 1120px; }
.reader-workspace { display: grid; grid-template-columns: minmax(0, 1fr); gap: 32px; align-items: start; }
.has-lookup .reader-workspace { grid-template-columns: minmax(0, 1fr) 320px; }
.reader-body { min-width: 0; }
.reader-word { cursor: pointer; border-radius: 3px; }
/* Saved words keep one style per status everywhere they appear. */
.reader-word.is-new { border-bottom: 2px dotted #bc803d; }
.reader-word.is-learning { background: #f6e7c9; }
.reader-word.is-known { background: #dee9d6; }
.reader-word.is-ignored { color: #949c94; }
.reader-word:hover, .reader-word.is-selected { background: #e3e9d9; color: #264a35; }
.reader-word:focus-visible { outline: 2px solid #bc803d; outline-offset: 2px; background: #eef2eb; }
.tokens-notice { color: #748073; font-size: 14px; margin-top: 24px; }
.lookup-panel { position: sticky; top: 24px; margin-top: 26px; padding: 22px; border: 1px solid #d9decf; border-radius: 12px; background: #fffdf8; max-height: calc(100dvh - 48px); overflow-y: auto; overflow-wrap: anywhere; }
.lookup-heading { display: flex; align-items: start; justify-content: space-between; gap: 10px; border-bottom: 1px solid #e0e3d8; padding-bottom: 12px; }
.reader-word.is-new { border-bottom: 2px dotted var(--accent-marker); }
.reader-word.is-learning { background: var(--word-learning-bg); }
.reader-word.is-known { background: var(--word-known-bg); }
.reader-word.is-ignored { color: var(--word-ignored-text); }
.reader-word:hover, .reader-word.is-selected { background: var(--word-selected-bg); color: var(--text-strong); }
.reader-word:focus-visible { outline: 2px solid var(--accent-marker); outline-offset: 2px; background: var(--accent-soft); }
.tokens-notice { color: var(--text-muted); font-size: 14px; margin-top: 24px; }
.lookup-panel { position: sticky; top: 24px; margin-top: 26px; padding: 22px; border: 1px solid var(--border-strong); border-radius: 12px; background: var(--surface); max-height: calc(100dvh - 48px); overflow-y: auto; overflow-wrap: anywhere; }
.lookup-heading { display: flex; align-items: start; justify-content: space-between; gap: 10px; border-bottom: 1px solid var(--border); padding-bottom: 12px; }
.lookup-heading h2 { margin: 6px 0; font-family: Georgia, serif; font-size: 25px; }
.lookup-content { font-size: 15px; line-height: 1.7; }
.lookup-message { color: #8b4324; }
.lookup-content { font-size: calc(15px * var(--reader-font-scale)); line-height: 1.7; }
.lookup-message { color: var(--notice-text); }
.lookup-senses { padding-left: 22px; }
.lookup-senses li { padding-left: 3px; margin-bottom: 20px; }
.lookup-senses p { margin: 8px 0; }
.sense-heading span { color: #748073; font-size: 13px; }
.lookup-senses blockquote { border-left: 2px solid #cbd9c9; margin: 10px 0; padding-left: 12px; color: #687568; font-style: italic; }
.lookup-range { border-bottom: 1px solid #e0e3d8; padding-bottom: 14px; }
.sense-heading span { color: var(--text-muted); font-size: 13px; }
.lookup-senses blockquote { border-left: 2px solid var(--status-ready-border); margin: 10px 0; padding-left: 12px; color: var(--text-faint); font-style: italic; }
.lookup-range { border-bottom: 1px solid var(--border); padding-bottom: 14px; }
.lookup-range p { margin: 0 0 10px; }
.lookup-range-actions { display: flex; gap: 6px; flex-wrap: wrap; }
/* A saved phrase is one unit: the run is underlined and keeps its status colour. */
.reader-word.is-phrase { text-decoration: underline; text-decoration-style: double; text-underline-offset: 2px; border-radius: 3px; }
.reader-word.is-phrase-selected { outline: 2px solid #bc803d; outline-offset: 1px; }
.lookup-term { border-top: 1px solid #e0e3d8; padding-top: 18px; margin-top: 20px; }
.reader-word.is-phrase-selected { outline: 2px solid var(--accent-marker); outline-offset: 1px; }
.lookup-term { border-top: 1px solid var(--border); padding-top: 18px; margin-top: 20px; }
.lookup-term label { display: flex; justify-content: space-between; gap: 10px; font-size: 14px; margin-top: 14px; }
.lookup-term-title { display: flex; font-size: 14px; margin: 0; }
.lookup-term label span { color: #8b794e; font-size: 12px; }
.lookup-term label span { color: var(--sense-muted); font-size: 12px; }
.lookup-term .el-radio-group { margin-top: 8px; flex-wrap: wrap; gap: 4px 12px; }
.lookup-term .el-textarea { margin-top: 8px; }
.lookup-term .el-textarea textarea { font: inherit; line-height: 1.6; }
.lookup-saved { color: #2f6b45; font-size: 14px; margin: 12px 0 0; }
.lookup-saved { color: var(--saved); font-size: 14px; margin: 12px 0 0; }
.lookup-actions { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
.review-page { max-width: 680px; }
.vocab-page { max-width: 760px; }
@@ -155,26 +276,26 @@ a.chapter-name:hover { color: #315c43; text-decoration: underline; }
.vocab-search .el-input { max-width: 260px; }
.vocab-filter { width: 140px; }
.vocab-list { list-style: none; padding: 0; margin: 18px 0 0; }
.vocab-row { display: flex; align-items: center; gap: 14px; padding: 14px 16px; margin-bottom: 10px; border: 1px solid #dfe3d6; border-radius: 10px; background: #fffdf8; }
.vocab-row { display: flex; align-items: center; gap: 14px; padding: 14px 16px; margin-bottom: 10px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); }
.vocab-info { min-width: 0; flex: 1; }
.vocab-term { margin: 0; font-family: Georgia, serif; font-size: 18px; overflow-wrap: anywhere; }
.vocab-paging { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 18px; }
.term-fields label { display: flex; justify-content: space-between; gap: 10px; font-size: 14px; margin-top: 14px; }
.term-fields label span { color: #8b794e; font-size: 12px; }
.term-fields label span { color: var(--sense-muted); font-size: 12px; }
.term-fields .el-textarea { margin-top: 8px; }
.term-fields .el-radio-group { margin-top: 8px; flex-wrap: wrap; gap: 4px 12px; }
.term-level { display: flex; align-items: center; gap: 10px; margin: 12px 0 0; font-size: 14px; }
.term-level-select { width: 120px; }
.chapter-notice { margin: 12px 0 0; padding: 10px 14px; border: 1px solid #d9decf; border-radius: 8px; background: #fbf7ee; color: #6b5b3e; }
.chapter-notice { margin: 12px 0 0; padding: 10px 14px; border: 1px solid var(--border-strong); border-radius: 8px; background: var(--sense-bg); color: var(--sense-text); }
.chapter-list .chapter-row { flex-wrap: wrap; }
.review-notice { margin: 10px 0 0; padding: 10px 14px; border: 1px solid #d9decf; border-radius: 8px; background: #fbf7ee; color: #6b5b3e; }
.review-card, .review-summary { margin-top: 26px; padding: 28px; border: 1px solid #d9decf; border-radius: 14px; background: #fffdf8; }
.review-notice { margin: 10px 0 0; padding: 10px 14px; border: 1px solid var(--border-strong); border-radius: 8px; background: var(--sense-bg); color: var(--sense-text); }
.review-card, .review-summary { margin-top: 26px; padding: 28px; border: 1px solid var(--border-strong); border-radius: 14px; background: var(--surface); }
.review-summary h2 { margin-top: 0; font-family: Georgia, serif; font-size: 24px; }
.review-word { margin: 12px 0; font-family: Georgia, serif; font-size: 34px; }
.review-word { margin: 12px 0; font-family: Georgia, serif; font-size: calc(34px * var(--reader-font-scale)); }
.review-example { margin: 12px 0; font-family: Georgia, serif; font-size: 19px; line-height: 1.8; }
.review-answer { margin: 18px 0; padding-top: 18px; border-top: 1px solid #e0e3d8; }
.review-definition { margin: 0 0 12px; font-size: 18px; }
.review-answer blockquote { border-left: 2px solid #cbd9c9; margin: 10px 0; padding-left: 12px; color: #687568; font-style: italic; }
.review-answer { margin: 18px 0; padding-top: 18px; border-top: 1px solid var(--border); }
.review-definition { margin: 0 0 12px; font-size: calc(18px * var(--reader-font-scale)); }
.review-answer blockquote { border-left: 2px solid var(--status-ready-border); margin: 10px 0; padding-left: 12px; color: var(--text-faint); font-style: italic; }
.review-grades { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 8px; }
.review-actions { margin: 16px 0 0; }
@media (max-width: 760px) {
@@ -199,10 +320,28 @@ a.chapter-name:hover { color: #315c43; text-decoration: underline; }
.chapter-row .status-chip, .chapter-row .el-button { grid-column: 2; justify-self: start; }
.chapter-complete { flex-direction: column; align-items: stretch; }
.chapter-complete .el-button { width: 100%; }
.reader-text { font-size: 16px; line-height: 1.95; }
.reader-text { font-size: calc(16px * var(--reader-font-scale)); line-height: 1.95; }
.has-lookup .reader-workspace { grid-template-columns: minmax(0, 1fr); gap: 20px; }
.reader-page.has-lookup { padding-bottom: calc(45dvh + 28px); }
.lookup-panel { position: fixed; inset: auto 0 0; z-index: 20; max-height: 45dvh; margin-top: 0; padding: 16px 20px max(20px, env(safe-area-inset-bottom)); border-radius: 16px 16px 0 0; box-shadow: 0 -5px 24px #233d3114; }
.lookup-heading { position: sticky; top: -16px; z-index: 1; background: #fffdf8; }
.lookup-panel { position: fixed; inset: auto 0 0; z-index: 20; max-height: 45dvh; margin-top: 0; padding: 16px 20px max(20px, env(safe-area-inset-bottom)); border-radius: 16px 16px 0 0; box-shadow: 0 -5px 24px var(--text)14; }
.lookup-heading { position: sticky; top: -16px; z-index: 1; background: var(--surface); }
.reader-nav .el-button { flex: 1; }
}
/* One visible focus ring for every interactive element, including Element Plus controls. */
button:focus-visible, a:focus-visible, [role='button']:focus-visible,
.el-button:focus-visible, .el-input__inner:focus-visible, .el-select__wrapper:focus-visible {
outline: 3px solid var(--accent-marker);
outline-offset: 3px;
}
/* Header display control: theme and reader font size. */
.display-trigger { display: inline-flex; align-items: center; gap: 8px; min-height: 38px; padding: 0 12px; border: 1px solid var(--border-strong); border-radius: 20px; background: var(--surface-raised); color: var(--text); font-size: 13px; cursor: pointer; }
.display-trigger:hover { border-color: var(--accent); }
/* Element Plus styles arrive after this file for lazy-loaded views, so the ring is stated
with enough weight to win the tie while a control from its own library is focused. */
.display-trigger:focus-visible, .display-trigger.el-tooltip__trigger:focus-visible { outline: 3px solid var(--accent-marker); outline-offset: 3px; }
.display-current { color: var(--text-muted); }
.el-dropdown-menu__item.is-current { color: var(--accent); font-weight: 600; }
/* Discoverable keyboard operations on the review page. */
.shortcut-hint { margin: 22px 0 0; color: var(--text-muted); font-size: 13px; }
+2
View File
@@ -4,6 +4,7 @@ import { RouterLink, useRoute, useRouter } from 'vue-router'
import { ElButton, ElDialog, ElInput, ElMessageBox } from 'element-plus'
import { canRetry, statusLabel, TEXT_MAX_CODE_POINTS, textProblem, titleProblem, useLibraryStore, type ChapterSource } from '../stores/library'
import { useSessionStore } from '../stores/session'
import DisplaySettings from '../components/DisplaySettings.vue'
const session = useSessionStore()
const library = useLibraryStore()
@@ -146,6 +147,7 @@ onUnmounted(() => library.closeBook())
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/vocab">生词本</RouterLink> · <RouterLink to="/review">到期复习</RouterLink> · <RouterLink to="/progress">进度</RouterLink></nav>
<div class="account">
<DisplaySettings />
<span class="account-name">{{ session.user.username }}</span>
<ElButton text @click="logout">退出登录</ElButton>
</div>
+2
View File
@@ -4,6 +4,7 @@ import { RouterLink, useRoute, useRouter } from 'vue-router'
import { ElButton, ElInput, ElOption, ElRadio, ElRadioGroup, ElSelect } from 'element-plus'
import { LANGUAGE_LABEL, TEXT_MAX_CODE_POINTS, fileProblem, fileSizeLabel, textProblem, titleProblem, useLibraryStore, type SubmitTarget } from '../stores/library'
import { useSessionStore } from '../stores/session'
import DisplaySettings from '../components/DisplaySettings.vue'
const session = useSessionStore()
const library = useLibraryStore()
@@ -129,6 +130,7 @@ onUnmounted(() => {
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/vocab">生词本</RouterLink> · <RouterLink to="/review">到期复习</RouterLink> · <RouterLink to="/progress">进度</RouterLink></nav>
<div class="account">
<DisplaySettings />
<span class="account-name">{{ session.user.username }}</span>
</div>
</header>
+2
View File
@@ -2,6 +2,7 @@
import { computed, onMounted, ref } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { ElButton } from 'element-plus'
import DisplaySettings from '../components/DisplaySettings.vue'
import BookMark from '../components/BookMark.vue'
import { statusSummary, useLibraryStore } from '../stores/library'
import { useSessionStore } from '../stores/session'
@@ -40,6 +41,7 @@ onMounted(load)
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
<nav aria-label="学习导航"><RouterLink to="/" class="active-nav">我的书库</RouterLink> · <RouterLink to="/vocab">生词本</RouterLink> · <RouterLink to="/review">到期复习</RouterLink> · <RouterLink to="/progress">进度</RouterLink></nav>
<div class="account">
<DisplaySettings />
<span class="account-name">{{ session.user.username }}</span>
<ElButton text @click="logout">退出登录</ElButton>
</div>
+2
View File
@@ -4,6 +4,7 @@ import { RouterLink, useRouter } from 'vue-router'
import { ElButton } from 'element-plus'
import { useProgressStore } from '../stores/progress'
import { useSessionStore } from '../stores/session'
import DisplaySettings from '../components/DisplaySettings.vue'
const session = useSessionStore()
const progress = useProgressStore()
@@ -24,6 +25,7 @@ async function logout() {
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/vocab">生词本</RouterLink> · <RouterLink to="/review">到期复习</RouterLink> · <RouterLink to="/progress">进度</RouterLink></nav>
<div class="account">
<DisplaySettings />
<span class="account-name">{{ session.user.username }}</span>
<ElButton text @click="logout">退出登录</ElButton>
</div>
+66 -3
View File
@@ -6,8 +6,10 @@ import { canRetry, statusLabel, useLibraryStore } from '../stores/library'
import { useSessionStore } from '../stores/session'
import { useReaderLookup, type PhraseSpan, type ReaderToken } from '../composables/useReaderLookup'
import { adjustTokenRange, MAX_PHRASE_WORDS, normalizeTokenRange, phrasesAt, rangeOfSpan, wordIndices } from '../composables/readerRange'
import { currentRatio, readPosition, writePosition } from '../composables/readingPosition'
import { useTextSelection } from '../composables/useTextSelection'
import ReaderTokens from '../components/ReaderTokens.vue'
import DisplaySettings from '../components/DisplaySettings.vue'
import LookupPanel from '../components/LookupPanel.vue'
const session = useSessionStore()
@@ -88,6 +90,56 @@ async function load() {
retryError.value = ''
rangeNotice.value = ''
await library.loadChapter(chapterId.value)
restorePosition()
}
// Reading position: remembered per account and chapter, and only while the text is the version
// the position was taken from. The restore waits for the chapter to be on screen and for the
// browser to lay the paragraph out, otherwise the saved ratio would land in the wrong place.
const positionNotice = ref('')
let saveTimer: number | undefined
function storage(): Storage | null {
try { return window.localStorage } catch { return null }
}
function restorePosition(): void {
positionNotice.value = ''
const store = storage()
const sha = chapter.value?.contentSha256 ?? ''
const current = chapter.value
if (!store || !current || current.status !== 'ready' || !sha) return
const saved = readPosition(store, session.user?.id ?? null, current.id, sha)
if (!saved) return
// The chapter may still be laying out when the response arrives, so the restore waits for a
// scrollable page instead of trusting one timer; it gives up quietly after a short while.
let attempts = 0
const apply = () => {
const scrollable = document.documentElement.scrollHeight - window.innerHeight
if (scrollable <= 0) {
attempts += 1
if (attempts <= 20) window.setTimeout(apply, 50)
return
}
window.scrollTo({ top: Math.round(saved.ratio * scrollable) })
positionNotice.value = '已回到上次阅读位置。'
}
window.setTimeout(apply, 40)
}
function savePosition(): void {
const store = storage()
const current = chapter.value
if (!store || !current || current.status !== 'ready') return
const ratio = currentRatio(window, document.documentElement.scrollHeight)
if (ratio <= 0) return
writePosition(store, session.user?.id ?? null, current.id, current.contentSha256, ratio)
}
function onScroll(): void {
if (saveTimer !== undefined) window.clearTimeout(saveTimer)
// Debounced: scrolling must stay smooth and only the resting position is worth storing.
saveTimer = window.setTimeout(() => { saveTimer = undefined; savePosition() }, 400)
}
async function retry() {
@@ -132,10 +184,19 @@ async function logout() {
finally { await router.replace('/login') }
}
onMounted(load)
onMounted(() => {
window.addEventListener('scroll', onScroll, { passive: true })
void load()
})
watch(chapterId, () => { void load() })
// Leaving the page releases the chapter so polling stops.
onUnmounted(() => library.closeChapter())
// Leaving the page releases the chapter so polling stops and stores the last position.
// There is no separate unmount hook: this one already handles the page going away.
onUnmounted(() => {
if (saveTimer !== undefined) window.clearTimeout(saveTimer)
savePosition()
window.removeEventListener('scroll', onScroll)
library.closeChapter()
})
</script>
<template>
@@ -144,6 +205,7 @@ onUnmounted(() => library.closeChapter())
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/vocab">生词本</RouterLink> · <RouterLink to="/review">到期复习</RouterLink> · <RouterLink to="/progress">进度</RouterLink></nav>
<div class="account">
<DisplaySettings />
<span class="account-name">{{ session.user.username }}</span>
<ElButton text @click="logout">退出登录</ElButton>
</div>
@@ -222,6 +284,7 @@ onUnmounted(() => library.closeChapter())
<div>
<p v-if="readAt" class="read-state" data-testid="chapter-read-state">{{ readLabel }}</p>
<p v-else class="subtle">读完后标记已读;只记录已读,不改变词语状态。</p>
<p v-if="positionNotice" role="status" class="subtle" data-testid="position-notice">{{ positionNotice }}</p>
<p v-if="completeNotice" role="status" class="subtle" data-testid="complete-notice">{{ completeNotice }}</p>
</div>
<ElButton
+16
View File
@@ -3,6 +3,8 @@ import { onMounted } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { ElButton } from 'element-plus'
import ReviewCard from '../components/ReviewCard.vue'
import DisplaySettings from '../components/DisplaySettings.vue'
import { useReviewShortcuts, type ReviewShortcut } from '../composables/reviewShortcuts'
import { useReviewStore, type ReviewItem } from '../stores/review'
import { useSessionStore } from '../stores/session'
@@ -16,6 +18,18 @@ let lastGrade: 'correct' | 'wrong' | 'again' = 'correct'
function grade(value: 'correct' | 'wrong' | 'again') { lastGrade = value; void review.answer(value) }
function retry() { void review.answer(lastGrade) }
// Space or Enter shows the answer, then 1, 2 and 3 grade it. The shortcut never fires while the
// learner is typing, and a focused button keeps its own native key handling.
function onShortcut(action: ReviewShortcut): void {
if (action === 'reveal') { review.reveal(); return }
grade(action)
}
useReviewShortcuts({
onShortcut,
enabled: () => review.current !== null && !review.busy && review.error === '',
revealed: () => review.revealed,
})
onMounted(() => { void review.load() })
async function logout() {
try { await session.logout() }
@@ -32,6 +46,7 @@ const requireItem = (value: ReviewItem | null): ReviewItem => value as ReviewIte
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/vocab">生词本</RouterLink> · <RouterLink to="/review" class="active-nav">到期复习</RouterLink> · <RouterLink to="/progress">进度</RouterLink></nav>
<div class="account">
<DisplaySettings />
<span class="account-name">{{ session.user.username }}</span>
<ElButton text @click="logout">退出登录</ElButton>
</div>
@@ -73,6 +88,7 @@ const requireItem = (value: ReviewItem | null): ReviewItem => value as ReviewIte
@end="endReview"
@retry="retry"
/>
<p class="shortcut-hint" data-testid="review-shortcuts">键盘:空格 显示答案 · 1 答对 · 2 答错 · 3 再学一次</p>
</main>
</div>
</template>
+2
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { ElButton, ElDialog, ElInput, ElOption, ElSelect } from 'element-plus'
import TermFormFields from '../components/TermFormFields.vue'
import DisplaySettings from '../components/DisplaySettings.vue'
import { TERM_STATUSES, type TermStatus } from '../composables/useReaderLookup'
import { textProblem, titleProblem } from '../stores/library'
import { VOCAB_PAGE_SIZE, useVocabularyStore, type VocabularyTerm } from '../stores/vocabulary'
@@ -115,6 +116,7 @@ watch(() => [search.value, vocabulary.status, vocabulary.kind, vocabulary.page],
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/vocab" class="active-nav">生词本</RouterLink> · <RouterLink to="/review">到期复习</RouterLink> · <RouterLink to="/progress">进度</RouterLink></nav>
<div class="account">
<DisplaySettings />
<span class="account-name">{{ session.user.username }}</span>
<ElButton text @click="logout">退出登录</ElButton>
</div>
+207
View File
@@ -0,0 +1,207 @@
"""Measures the LexGo API on a stated synthetic dataset.
The numbers this prints describe one machine and one dataset; they are an observation, not a
capacity promise. Everything runs against a database whose name contains ``lexgo`` and that the
operator creates for the run, so no real instance is touched.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import platform
import statistics
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
import ops # noqa: E402
WORDS = ("curiosity", "door", "step", "reading", "language", "garden", "window", "music", "river",
"stone", "letter", "morning", "shadow", "bridge", "silence", "travel", "kitchen", "paper")
def machine_facts() -> dict:
facts = {
"os": platform.platform(),
"cpu_count": os.cpu_count(),
"python": platform.python_version(),
}
try:
import shutil
if os.name == "nt":
output = subprocess.run(["wmic", "computersystem", "get", "TotalPhysicalMemory"],
capture_output=True, text=True, encoding="utf-8", errors="replace").stdout
numbers = [line.strip() for line in output.splitlines() if line.strip().isdigit()]
if numbers:
facts["memory_gb"] = round(int(numbers[0]) / (1024 ** 3), 1)
else:
with open("/proc/meminfo") as handle:
for line in handle:
if line.startswith("MemTotal"):
facts["memory_gb"] = round(int(line.split()[1]) / (1024 ** 2), 1)
break
facts["go"] = subprocess.run(["go", "version"], capture_output=True, text=True,
encoding="utf-8", errors="replace").stdout.strip()
del shutil
except Exception:
pass
return facts
def seed(database: str, chapters: int, chapter_words: int, terms: int, reviews: int) -> dict:
"""Fills a fresh benchmark database with a synthetic learner, book, terms and schedules.
The account itself is left alone: it was created through the API beforehand, so its password
hash is a real one and the benchmark measures the same login path a user takes.
"""
for table in ("lexgo_review_answers", "lexgo_term_reviews", "lexgo_terms", "lexgo_chapters",
"lexgo_books", "lexgo_spaces", "lexgo_sessions"):
ops.query(database, "DELETE FROM " + table)
owner = int(ops.scalar(database, "SELECT user_id FROM sys_user ORDER BY user_id LIMIT 1", "0") or 0)
if not owner:
raise SystemExit("先创建一个账号再压测(例如 smoke 或 bootstrap)。")
ops.query(database, "INSERT INTO lexgo_spaces (owner_id, language) VALUES (%d, 'en')" % owner)
stamp = "2026-01-01 00:00:00.000"
body = " ".join(WORDS[index % len(WORDS)] for index in range(chapter_words))
for chapter in range(1, chapters + 1):
text = "%s chapter %d. " % (body, chapter)
sha = hashlib.sha256(text.encode("utf-8")).hexdigest()
ops.query(database, "INSERT INTO lexgo_books (id, owner_id, title, language, created_at, updated_at) "
"VALUES (%d, %d, 'Bench book %d', 'en', '%s', '%s')" % (chapter, owner, chapter, stamp, stamp))
ops.query(database, "INSERT INTO lexgo_chapters (id, book_id, owner_id, ordinal, title, original_text, char_count, "
"content_sha256, status, created_at, updated_at) VALUES (%d, %d, %d, 1, 'Bench %d', '%s', %d, "
"'%s', 'ready', '%s', '%s')" % (chapter, chapter, owner, chapter, text.replace("'", "''"), len(text), sha, stamp, stamp))
rows = []
for term_id in range(1, terms + 1):
word = "%s%d" % (WORDS[term_id % len(WORDS)], term_id)
rows.append("(%d, %d, 'en', '%s', '%s', '虚构释义', '', 'new', 0, '%s', '%s')" % (term_id, owner, word, word, stamp, stamp))
if len(rows) == 500:
ops.query(database, "INSERT INTO lexgo_terms (id, owner_id, language, term, original_form, definition, examples, "
"status, level, created_at, updated_at) VALUES " + ",".join(rows))
rows = []
if rows:
ops.query(database, "INSERT INTO lexgo_terms (id, owner_id, language, term, original_form, definition, examples, "
"status, level, created_at, updated_at) VALUES " + ",".join(rows))
rows = []
for term_id in range(1, terms + 1):
rows.append("(%d, %d, 'en', DATE_ADD('%s', INTERVAL %d SECOND), 0, 0, 0)" % (term_id, owner, stamp, term_id % 900000))
if len(rows) == 500:
ops.query(database, "INSERT INTO lexgo_term_reviews (term_id, owner_id, language, due_at, review_count, "
"correct_count, wrong_count) VALUES " + ",".join(rows))
rows = []
if rows:
ops.query(database, "INSERT INTO lexgo_term_reviews (term_id, owner_id, language, due_at, review_count, "
"correct_count, wrong_count) VALUES " + ",".join(rows))
if reviews:
rows = []
for index in range(1, reviews + 1):
rows.append("(%d, %d, 'b%063d', %d, 'correct', 'applied', 'new', 'learning', 0, 1, '%s', '%s', 0, '%s')"
% (index, owner, index, (index % terms) + 1, stamp, stamp, stamp))
if len(rows) == 500:
ops.query(database, "INSERT INTO lexgo_review_answers (id, owner_id, answer_key, term_id, grade, result, "
"status_before, status_after, level_before, level_after, due_at_before, due_at_after, "
"requeued, created_at) VALUES " + ",".join(rows))
rows = []
if rows:
ops.query(database, "INSERT INTO lexgo_review_answers (id, owner_id, answer_key, term_id, grade, result, "
"status_before, status_after, level_before, level_after, due_at_before, due_at_after, "
"requeued, created_at) VALUES " + ",".join(rows))
return {
"owner_id": owner,
"chapters": chapters,
"words_per_chapter": chapter_words,
"terms": terms,
"review_answers": reviews,
"row_counts": ops.row_counts(database),
}
def measure(label: str, call, repeats: int) -> dict:
timings = []
errors = 0
for index in range(repeats):
started = time.perf_counter()
status, _ = call(index)
elapsed = (time.perf_counter() - started) * 1000
if status >= 400:
errors += 1
timings.append(elapsed)
timings.sort()
return {
"endpoint": label,
"requests": repeats,
"errors": errors,
"p50_ms": round(statistics.median(timings), 1),
"p95_ms": round(timings[min(len(timings) - 1, int(len(timings) * 0.95))], 1),
"max_ms": round(timings[-1], 1),
}
def main() -> int:
parser = argparse.ArgumentParser(description="在写明规模的人造数据集上测量接口耗时")
parser.add_argument("--database", default="lexgo_bench")
parser.add_argument("--api", default="http://127.0.0.1:8012")
parser.add_argument("--user", default="bench_admin", help="压测账号")
parser.add_argument("--chapters", type=int, default=20)
parser.add_argument("--chapter-words", type=int, default=500)
parser.add_argument("--terms", type=int, default=2000)
parser.add_argument("--reviews", type=int, default=8000)
parser.add_argument("--repeats", type=int, default=30)
parser.add_argument("--out", default=str(ROOT / ".local" / "issue15-bench.json"))
args = parser.parse_args()
ops.load_env()
database = ops.validate_database_name(args.database, "压测")
dataset = seed(database, args.chapters, args.chapter_words, args.terms, args.reviews)
print("数据集:" + json.dumps(dataset["row_counts"], ensure_ascii=False))
client = ops.Api(args.api)
password = os.environ.get("LEXGO_BENCH_PASSWORD", os.environ.get("LEXGO_TRIAL_PASSWORD", ""))
status, token = client.login(args.user, password)
if status != 200 or not token:
raise SystemExit(args.user + " 无法登录:请先在演练库上建号并设置 LEXGO_BENCH_PASSWORD。")
# The login endpoint allows 30 attempts per minute per address, so a sustained loop would
# measure the limiter instead of the login path. A short loop keeps the number meaningful.
login_repeats = max(1, min(args.repeats, 10))
results = [measure("POST /login", lambda _: client.login(args.user, password), login_repeats)]
results.append(measure("GET /books", lambda _: client.call("GET", "books", token), args.repeats))
results.append(measure("GET /chapters/1", lambda _: client.call("GET", "chapters/1", token), args.repeats))
results.append(measure("GET /chapters/1/tokens", lambda _: client.call("GET", "chapters/1/tokens", token), args.repeats))
results.append(measure("GET /terms (page 1)", lambda _: client.call("GET", "terms?page=1&limit=20", token), args.repeats))
results.append(measure("GET /terms?query (search)", lambda _: client.call("GET", "terms?query=curiosity&limit=20", token), args.repeats))
results.append(measure("GET /progress", lambda _: client.call("GET", "progress", token), args.repeats))
results.append(measure("GET /reviews/queue", lambda _: client.call("GET", "reviews/queue", token), args.repeats))
report = {
"measured_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"api": args.api,
"database": database,
"dataset": dataset,
"machine": machine_facts(),
"mysql_server": ops.scalar("", "SELECT VERSION()", ""),
"results": results,
"note": "单机、单进程、无并发压力;数字是观察值,不是容量承诺。登录接口每分钟每地址限 30 次,"
"因此登录只测 10 次,且该数字不代表登录吞吐。",
}
Path(args.out).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
for row in results:
print(" %-28s p50 %7.1f ms p95 %7.1f ms max %7.1f ms 错误 %d" %
(row["endpoint"], row["p50_ms"], row["p95_ms"], row["max_ms"], row["errors"]))
print("报告:" + args.out)
return 0
if __name__ == "__main__":
sys.exit(main())
+783
View File
@@ -0,0 +1,783 @@
"""LexGo operations: dependency check, backup, restore and post-restore verification.
Everything a self-hosted instance needs to be installed, backed up and restored is in MySQL:
accounts, books and chapter text, personal terms, review schedules and answers, the imported
WordNet archive and the audit logs. This script therefore never invents a second storage
location: a dump plus the environment file is a complete backup.
Safety rules, because a restore can overwrite a working instance:
* a restore always needs ``--confirm``;
* the target database name must contain ``lexgo`` and must not be a MySQL system schema;
* a database that already holds LexGo data is refused unless ``--force`` is given;
* credentials are read from the environment or ``.env.local`` and are never written into a
manifest, a log line or a dump name.
Only the standard library is used, so the script runs on the deployment host without installing
anything beyond the MySQL client.
"""
from __future__ import annotations
import argparse
import gzip
import hashlib
import json
import os
import re
import shutil
import subprocess
import tempfile
import time
import sys
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
ENV_FILE = ROOT / ".env.local"
SYSTEM_SCHEMAS = {"mysql", "information_schema", "performance_schema", "sys"}
# Columns that must never appear in the audit tables: they exist to prove that no credential,
# request body or private learning content is stored there.
AUDIT_BANNED_COLUMNS = {"password", "token", "body", "content", "definition", "text"}
TABLES = [
"sys_user", "lexgo_spaces", "lexgo_sessions", "lexgo_login_logs", "lexgo_operation_logs",
"lexgo_books", "lexgo_chapters", "lexgo_ingest_jobs", "lexgo_dictionaries",
"lexgo_terms", "lexgo_term_reviews", "lexgo_review_answers", "lexgo_chapter_progress",
]
SCHEMA_VERSION = 7
class OpsError(SystemExit):
"""A failure the operator has to read and act on."""
def load_env(path=ENV_FILE):
"""Reads .env.local. Values are never printed; only key names are."""
values = {}
if path.exists():
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
values[key.strip()] = value.strip()
for key, value in values.items():
os.environ.setdefault(key, value)
return values
def mysql_binary(name):
"""Finds mysqldump/mysql via LEXGO_MYSQL_BIN, then PATH."""
configured = os.environ.get("LEXGO_MYSQL_BIN")
if configured:
candidate = Path(configured) / (name + (".exe" if os.name == "nt" else ""))
if candidate.exists():
return str(candidate)
found = shutil.which(name)
if not found:
raise OpsError(
"找不到 " + name + ":请安装 MySQL 客户端,或用 LEXGO_MYSQL_BIN 指向客户端目录。"
)
return found
def connection_args(database=None):
"""Builds client arguments. The password travels through the environment, not argv."""
args = [
"--host=" + os.environ.get("LEXGO_DB_HOST", "127.0.0.1"),
"--port=" + os.environ.get("LEXGO_DB_PORT", "3306"),
"--user=" + os.environ.get("LEXGO_DB_USER", ""),
]
if database:
args.append(database)
return args
def client_env():
env = dict(os.environ)
password = os.environ.get("LEXGO_DB_PASSWORD")
if password:
env["MYSQL_PWD"] = password
return env
def run(command, env=None, capture=True):
result = subprocess.run(command, env=env, capture_output=capture, text=True, encoding="utf-8", errors="replace")
if result.returncode != 0:
message = (result.stderr or "").strip().splitlines()
# The last line of a client error is the one an operator needs; never echo arguments,
# which is why the command line itself is not part of the message.
raise OpsError("命令失败:" + (message[-1] if message else "未知错误"))
return result.stdout or ""
def query(database, sql):
# SQL travels through stdin: a long INSERT would exceed the Windows command line limit.
result = subprocess.run([mysql_binary("mysql"), *connection_args(database), "--batch", "--skip-column-names"],
input=sql.encode("utf-8"), env=client_env(), capture_output=True)
if result.returncode != 0:
lines = result.stderr.decode("utf-8", "replace").strip().splitlines()
raise OpsError("查询失败:" + (lines[-1] if lines else "未知错误"))
output = result.stdout.decode("utf-8", "replace")
return [line.split("\t") for line in output.strip().splitlines() if line]
def scalar(database, sql, default=None):
rows = query(database, sql)
if not rows or not rows[0] or rows[0][0] == "NULL":
return default
return rows[0][0]
def database_exists(database):
rows = query("", "SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME='%s'" % database)
return bool(rows)
def sha256_of(path, chunk=1024 * 1024):
digest = hashlib.sha256()
with open(path, "rb") as handle:
while True:
block = handle.read(chunk)
if not block:
break
digest.update(block)
return digest.hexdigest()
def git_commit():
try:
return subprocess.run(["git", "rev-parse", "HEAD"], cwd=str(ROOT), capture_output=True, text=True,
encoding="utf-8", errors="replace").stdout.strip()
except OSError:
return ""
def version_of(text):
"""Pulls the version out of a client or server banner.
A client banner reads "Ver 14.14 Distrib 5.7.38", where the first number is the protocol
version, so the version after "Distrib" is the one to compare.
"""
banner = text or ""
match = re.search(r"Distrib\s+(\d+)\.(\d+)(?:\.(\d+))?", banner)
if not match:
match = re.search(r"(\d+)\.(\d+)(?:\.(\d+))?", banner)
if not match:
return None
return tuple(int(part) for part in match.groups(default="0"))
def validate_database_name(name, action):
if not name or name in SYSTEM_SCHEMAS or "lexgo" not in name:
raise OpsError("拒绝在 %s 上执行:库名必须包含 lexgo 且不能是系统库。" % action)
return name
def row_counts(database):
counts = {}
present = {row[0] for row in query("information_schema", "SELECT TABLE_NAME FROM TABLES WHERE TABLE_SCHEMA='%s'" % database)}
for table in TABLES:
if table in present:
counts[table] = int(scalar(database, "SELECT COUNT(*) FROM `%s`" % table, "0"))
return counts
# ---------------------------------------------------------------- install-check
def command_install_check(_args):
load_env()
checks = []
problems = []
def add(name, ok, detail):
checks.append((name, ok, detail))
if not ok:
problems.append(name)
# The client has to be at least as new as the server: an older mysqldump produces a dump the
# server cannot be restored from, which is worth catching before an incident rather than after.
try:
client = run([mysql_binary("mysql"), "--version"]).strip()
except OpsError as error:
client = str(error)
client_version = version_of(client)
add("MySQL 客户端存在", bool(client_version), client)
server = ""
database = os.environ.get("LEXGO_DB_NAME", "")
if database:
try:
server = scalar("", "SELECT VERSION()", "") or ""
except OpsError as error:
problems.append("数据库连接")
checks.append(("数据库连接", False, str(error)))
server_version = version_of(server)
if server_version:
add("MySQL 服务端为 8.x", server_version[0] == 8, server)
if client_version and server_version:
add("客户端版本不低于服务端", client_version >= server_version,
"客户端 " + ".".join(str(part) for part in client_version) + " / 服务端 " + ".".join(str(part) for part in server_version))
add("环境配置已加载", bool(database), "LEXGO_DB_NAME=" + (database or "(未设置)"))
for key in ("LEXGO_DB_USER", "LEXGO_DB_PASSWORD", "LEXGO_BOOTSTRAP_USERNAME"):
add("配置项 " + key + " 存在", bool(os.environ.get(key)), "已设置" if os.environ.get(key) else "未设置")
if not os.environ.get("LEXGO_LISTEN"):
print(" [info] LEXGO_LISTEN 未设置,服务将使用内置默认监听地址。")
# The dictionary resource is pinned by digest and imported explicitly, never downloaded at
# runtime; the pin file has to be present for an install to be reproducible.
resource = ROOT / "server" / "wordnet-resource.json"
if resource.exists():
pin = json.loads(resource.read_text(encoding="utf-8"))
add("WordNet 资源 pin", len(pin.get("sha256", "")) == 64 and bool(pin.get("source")), "sha256=" + pin.get("sha256", "")[:12] + "…")
else:
add("WordNet 资源 pin", False, "缺少 server/wordnet-resource.json")
for binary, label in (("go", "Go 工具链"), ("node", "Node"), ("pnpm", "pnpm")):
found = shutil.which(binary)
add(label + "(仅构建需要)", bool(found), found or "未安装:使用预构建产物时可不安装")
if os.environ.get("LEXGO_LISTEN", "").startswith("0.0.0.0"):
add("服务监听地址", False, "LEXGO_LISTEN 对外监听:" + os.environ["LEXGO_LISTEN"] + ",应由反向代理转发而不是直接暴露")
for name, ok, detail in checks:
print((" [ok] " if ok else " [fail] ") + name + " — " + detail)
if problems:
print("\n检查未通过:" + "、".join(problems))
print("按 wiki 的 Deployment-and-Operations 页面补齐后再安装。")
raise SystemExit(1)
print("\n依赖检查通过。")
if database:
try:
version = scalar(database, "SELECT version FROM lexgo_schema WHERE id=1", None)
print("当前 schema 版本:" + (version or "未知(尚未迁移)"))
except OpsError:
print("当前 schema 版本:无法读取(数据库可能尚未初始化)")
return 0
# ---------------------------------------------------------------- init-database
def command_init_database(args):
"""Creates the empty schema an installation starts from.
The server cannot connect to a database that does not exist yet, so this is the first step of
a fresh install. Grants are printed instead of applied: which account may reach the schema is
a decision for the operator's database policy, not for this tool.
"""
load_env()
database = validate_database_name(args.database or os.environ.get("LEXGO_DB_NAME", ""), "建库")
if database_exists(database):
tables = int(scalar(database, "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA='%s'" % database, "0") or 0)
print("库已存在:" + database + "(" + str(tables) + " 张表)")
if tables:
print("这是已有实例;需要升级时下一步是 python scripts/server.py migrate。")
return 0
else:
run([mysql_binary("mysql"), *connection_args(), "-e", "CREATE DATABASE `%s` CHARACTER SET utf8mb4" % database], env=client_env())
print("已创建库:" + database)
print(
"\n请为应用账号授予该库的最小权限(只限此库,不要使用管理员账号):\n"
" GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP, REFERENCES\n"
" ON `" + database + "`.* TO '<应用账号>'@'<来源主机>';\n"
" FLUSH PRIVILEGES;\n"
"\n下一步:写入 .env.local(凭据取自运维密码库)后执行\n"
" python scripts/server.py migrate\n"
" python scripts/server.py bootstrap"
)
return 0
# ---------------------------------------------------------------- backup
def command_backup(args):
load_env()
database = validate_database_name(args.database or os.environ.get("LEXGO_DB_NAME", ""), "备份")
out_dir = Path(args.out) if args.out else ROOT / ".local" / "backups" / datetime.now().strftime("%Y%m%d-%H%M%S")
if out_dir.exists() and any(out_dir.iterdir()) and not args.force:
raise OpsError("输出目录非空:" + str(out_dir) + "(加 --force 覆盖)")
out_dir.mkdir(parents=True, exist_ok=True)
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
sql_path = out_dir / ("lexgo-" + stamp + ".sql")
gz_path = Path(str(sql_path) + ".gz")
command = [
mysql_binary("mysqldump"), *connection_args(),
"--single-transaction", "--routines", "--triggers", "--hex-blob", "--no-tablespaces",
"--default-character-set=utf8mb4",
# Deliberately without --databases: that option writes CREATE DATABASE and USE into the
# dump, which would send a restore straight back into the source schema instead of the
# target one. The client is given the target database instead.
database,
]
print("正在备份 " + database + " …")
# The dump is captured and written as bytes: nothing about it is decoded or re-encoded on the
# way to disk, which keeps a binary-safe dump byte-exact.
dump = subprocess.run(command, env=client_env(), capture_output=True)
if dump.returncode != 0:
message = dump.stderr.decode("utf-8", "replace").strip().splitlines()
raise OpsError("mysqldump 失败:" + (message[-1] if message else "未知错误"))
with open(sql_path, "wb") as handle:
handle.write(dump.stdout)
with open(sql_path, "rb") as source, gzip.open(gz_path, "wb", compresslevel=6) as target:
shutil.copyfileobj(source, target)
sql_path.unlink()
manifest = {
"created_at": stamp,
"database": database,
"product": "lexgo",
"schema_version": int(scalar(database, "SELECT version FROM lexgo_schema WHERE id=1", "0") or 0),
"git_commit": git_commit(),
"dump_file": gz_path.name,
"dump_sha256": sha256_of(gz_path),
"dump_bytes": gz_path.stat().st_size,
"row_counts": row_counts(database),
"mysql_client": run([mysql_binary("mysqldump"), "--version"]).strip(),
"mysql_server": scalar("", "SELECT VERSION()", ""),
"contents": "accounts, spaces, sessions, audit logs, books, chapters, ingest jobs, "
"dictionary archive, terms, review schedules, review answers, reading progress",
"excludes": "credentials and the environment file; back those up separately from the operations vault",
}
(out_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
print("备份完成:" + str(gz_path))
print(" schema 版本 " + str(manifest["schema_version"]) + ",提交 " + (manifest["git_commit"][:8] or "未知"))
print(" 表行数 " + json.dumps(manifest["row_counts"], ensure_ascii=False))
print(" sha256 " + manifest["dump_sha256"][:16] + "…(完整值见 manifest.json)")
print(" 凭据与 .env.local 不在其中,请用运维密码库单独保存。")
return 0
# ---------------------------------------------------------------- restore
def ensure_empty_target(database, force):
if not database_exists(database):
run([mysql_binary("mysql"), *connection_args(), "-e", "CREATE DATABASE `%s` CHARACTER SET utf8mb4" % database], env=client_env())
print("已创建空库 " + database)
return
existing = scalar(database, "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA='%s'" % database, "0")
if int(existing or 0) == 0:
return
if not force:
raise OpsError(
"目标库 " + database + " 已有 " + str(existing) + " 张表。恢复默认只写空库;"
"确认要覆盖时再加 --force,并先对现有库做一次备份。"
)
print("警告:目标库已有数据,按 --force 覆盖。")
run([mysql_binary("mysql"), *connection_args(), "-e",
"DROP DATABASE `%s`; CREATE DATABASE `%s` CHARACTER SET utf8mb4" % (database, database)], env=client_env())
def dump_targets_instead_of_source(dump):
"""Reads the head of a dump and refuses one that would switch databases.
A dump taken with ``mysqldump --databases`` carries CREATE DATABASE and USE statements, so a
restore would write into the schema named inside the file rather than the requested target.
An older backup is rejected instead of being trusted.
"""
with gzip.open(dump, "rb") as handle:
head = handle.read(256 * 1024).decode("utf-8", "replace")
return "CREATE DATABASE" in head.upper() or bool(re.search(r"^USE `", head, re.M))
def dump_checksums(database):
"""Per-table content checksums, used to prove a restore equals its source."""
checksums = {}
for table in TABLES:
rows = query(database, "CHECKSUM TABLE `%s`" % table)
if rows and len(rows[0]) > 1:
checksums[table] = rows[0][1]
return checksums
def command_restore(args):
load_env()
if not args.confirm:
raise OpsError(
"恢复会写入数据库,需要显式确认:\n"
" python scripts/ops.py restore --dump <文件.sql.gz> --database <库名> --confirm\n"
"默认只恢复到空库;覆盖已有库还要加 --force。"
)
dump = Path(args.dump)
if not dump.exists():
raise OpsError("找不到备份文件:" + str(dump))
database = validate_database_name(args.database, "恢复")
manifest = {}
manifest_path = dump.parent / "manifest.json"
if manifest_path.exists():
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
expected = manifest.get("dump_sha256")
if expected and expected != sha256_of(dump):
raise OpsError("备份文件与 manifest 的 sha256 不一致,拒绝恢复。")
print("校验通过:备份文件与 manifest 的 sha256 一致。")
elif not args.skip_manifest_check:
raise OpsError("找不到 manifest.json,无法校验备份完整性(确认要继续时加 --skip-manifest-check)。")
if dump_targets_instead_of_source(dump):
raise OpsError(
"备份文件里带有 CREATE DATABASE / USE,恢复会写进文件里指定的库而不是目标库,拒绝执行。\n"
"请用本工具重新生成备份(它导出的是只含表数据的 dump)。"
)
# The source database must not change: its checksums are compared again afterwards.
source_database = manifest.get("database", "")
source_before = dump_checksums(source_database) if source_database and source_database != database and database_exists(source_database) else {}
ensure_empty_target(database, args.force)
print("正在恢复 " + dump.name + " 到 " + database + " …")
# The dump is decompressed to a temporary file and that file is handed to the client. Feeding
# a pipe from Python is unreliable on Windows for a stream this size, and a real file
# descriptor also means the client sees the plain SQL it expects.
work = Path(tempfile.mkdtemp(prefix="lexgo-restore-"))
plain = work / "dump.sql"
try:
with gzip.open(dump, "rb") as compressed, open(plain, "wb") as target:
shutil.copyfileobj(compressed, target)
with open(plain, "rb") as handle:
load = subprocess.run([mysql_binary("mysql"), *connection_args(database), "--default-character-set=utf8mb4"],
stdin=handle, env=client_env(), capture_output=True)
if load.returncode != 0:
message = load.stderr.decode("utf-8", "replace").strip().splitlines()
raise OpsError("恢复失败:" + (message[-1] if message else "未知错误"))
finally:
shutil.rmtree(work, ignore_errors=True)
print("恢复完成,开始校验。")
if source_before:
source_after = dump_checksums(source_database)
if source_after != source_before:
raise OpsError("源库 " + source_database + " 在校验过程中发生变化,已停止;请人工比对后再继续。")
print("源库 " + source_database + " 的内容校验和未变化。")
verify_args = argparse.Namespace(database=database, manifest=str(manifest_path) if manifest_path.exists() else "",
expect_schema=manifest.get("schema_version"), api="", user="", password_env="",
password_env_b="", skip_api=True)
return command_verify(verify_args)
# ---------------------------------------------------------------- verify
def integrity_checks(database):
checks = []
def add(name, ok, detail=""):
checks.append((name, ok, detail))
add("schema 表存在", int(scalar(database, "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA='%s' AND TABLE_NAME='lexgo_schema'" % database, "0") or 0) == 1)
add("产品标识为 lexgo", scalar(database, "SELECT product FROM lexgo_schema WHERE id=1", "") == "lexgo")
add("没有孤立章节(章节属于其书)", int(scalar(database, "SELECT COUNT(*) FROM lexgo_chapters c LEFT JOIN lexgo_books b ON b.id=c.book_id WHERE b.id IS NULL", "0") or 0) == 0)
add("章节归属与书归属一致", int(scalar(database, "SELECT COUNT(*) FROM lexgo_chapters c JOIN lexgo_books b ON b.id=c.book_id WHERE c.owner_id<>b.owner_id", "0") or 0) == 0)
add("每个词条都有排期行", int(scalar(database, "SELECT COUNT(*) FROM lexgo_terms t LEFT JOIN lexgo_term_reviews r ON r.term_id=t.id WHERE r.term_id IS NULL", "0") or 0) == 0)
add("排期行都指向存在的词条", int(scalar(database, "SELECT COUNT(*) FROM lexgo_term_reviews r LEFT JOIN lexgo_terms t ON t.id=r.term_id WHERE t.id IS NULL", "0") or 0) == 0)
add("复习记录引用有效词条", int(scalar(database, "SELECT COUNT(*) FROM lexgo_review_answers a LEFT JOIN lexgo_terms t ON t.id=a.term_id WHERE t.id IS NULL", "0") or 0) == 0)
add("等级只出现在学习中词条", int(scalar(database, "SELECT COUNT(*) FROM lexgo_terms WHERE (status='learning' AND (level<1 OR level>7)) OR (status<>'learning' AND level<>0)", "0") or 0) == 0)
add("完成记录都指向存在的章节", int(scalar(database, "SELECT COUNT(*) FROM lexgo_chapter_progress p LEFT JOIN lexgo_chapters c ON c.id=p.chapter_id WHERE c.id IS NULL", "0") or 0) == 0)
add("词条语言与所属空间一致", int(scalar(database, "SELECT COUNT(*) FROM lexgo_terms t JOIN lexgo_spaces s ON s.owner_id=t.owner_id WHERE t.language<>s.language", "0") or 0) == 0)
# The audit tables must not gain a column that could hold a credential or private content.
for table in ("lexgo_login_logs", "lexgo_operation_logs"):
columns = {row[0].lower() for row in query("information_schema", "SELECT COLUMN_NAME FROM COLUMNS WHERE TABLE_SCHEMA='%s' AND TABLE_NAME='%s'" % (database, table))}
banned = sorted(columns & AUDIT_BANNED_COLUMNS)
add("审计表 " + table + " 不含敏感列", not banned, "命中:" + ",".join(banned) if banned else "列名白名单通过")
return checks
class Api:
"""A very small API client: the operations tool must not depend on the test harness."""
def __init__(self, base):
self.base = base.rstrip("/")
def call(self, method, path, token=None, body=None):
import urllib.error
import urllib.request
request = urllib.request.Request(self.base + "/api/v1/" + path, method=method)
if token:
request.add_header("Authorization", "Bearer " + token)
payload = None
if body is not None:
payload = json.dumps(body).encode("utf-8")
request.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(request, payload, timeout=60) as response:
return response.status, json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
try:
return error.code, json.loads(error.read().decode("utf-8"))
except Exception:
return error.code, {}
def login(self, account, password):
status, payload = self.call("POST", "login", body={"username": account, "password": password})
return status, payload.get("data", {}).get("token", "")
def report(checks):
"""Prints one line per check and fails the command when any of them failed."""
failures = [name for name, ok, _ in checks if not ok]
for name, ok, detail in checks:
print((" [ok] " if ok else " [fail] ") + name + ("— " + detail if detail else ""))
if failures:
print("\n检查未通过 " + str(len(failures)) + " 项:" + "、".join(failures))
raise SystemExit(1)
print("\n检查通过:" + str(len(checks)) + " 项全部成功。")
return 0
def api_walk(api, user, password_a, password_b=None):
"""Two accounts walk the learning loop on the restored instance and one must not see the other."""
checks = []
client = Api(api)
def add(name, ok, detail=""):
checks.append((name, ok, detail))
status, token_a = client.login(user + "_a", password_a)
add("账号 " + user + "_a 可以登录恢复实例", status == 200 and bool(token_a))
status, token_b = client.login(user + "_b", password_b or password_a)
add("账号 " + user + "_b 可以登录恢复实例", status == 200 and bool(token_b))
if not token_a or not token_b:
return checks
status, books_a = client.call("GET", "books", token_a)
items_a = books_a.get("data", {}).get("items", [])
add("账号 A 能看到自己的书库", status == 200 and len(items_a) > 0, str(len(items_a)) + " 本书")
status, progress_a = client.call("GET", "progress", token_a)
add("账号 A 能读到自己的进度", status == 200, json.dumps(progress_a.get("data", {}), ensure_ascii=False)[:120])
# Isolation: ids that belong to A must not resolve for B.
if items_a:
book_id = items_a[0]["id"]
status, _ = client.call("GET", "books/%d" % book_id, token_b)
add("B 读取 A 的书籍被拒绝", status == 404, "HTTP " + str(status))
status, detail = client.call("GET", "books/%d" % book_id, token_a)
chapters = [chapter for chapter in detail.get("data", {}).get("chapters", []) if chapter["status"] == "ready"]
if chapters:
chapter_id = chapters[0]["id"]
status, _ = client.call("GET", "chapters/%d" % chapter_id, token_b)
add("B 读取 A 的章节被拒绝", status == 404, "HTTP " + str(status))
# A completes the chapter twice: the mark must stay idempotent after a restore.
status, _ = client.call("POST", "chapters/%d/complete" % chapter_id, token_a)
status2, second = client.call("POST", "chapters/%d/complete" % chapter_id, token_a)
duplicate = second.get("data", {}).get("progress", {}).get("duplicate")
add("恢复后完成章节仍然幂等", status == 200 and status2 == 200 and bool(duplicate), "duplicate=" + str(duplicate))
status, _ = client.call("POST", "chapters/%d/complete" % chapter_id, token_b)
add("B 不能完成 A 的章节", status == 404, "HTTP " + str(status))
# The review loop works on restored schedules.
status, queue = client.call("GET", "reviews/queue", token_a)
items = queue.get("data", {}).get("items", [])
add("账号 A 的到期队列可读", status == 200, "到期 " + str(queue.get("data", {}).get("total")) + " 条")
if items:
item = items[0]
answer = {"answerId": "ops-verify-restore-0001", "grade": "correct", "expectedDueAt": item["dueAt"]}
status, result = client.call("POST", "reviews/%d/answers" % item["id"], token_a, answer)
add("恢复后答题成功并更新排期", status in (200, 201) and result.get("data", {}).get("result") == "applied",
"level " + str(result.get("data", {}).get("levelAfter")))
status, replay = client.call("POST", "reviews/%d/answers" % item["id"], token_a, answer)
add("重复提交同一答案不重复记账", status in (200, 201) and bool(replay.get("data", {}).get("duplicate")),
"duplicate=" + str(replay.get("data", {}).get("duplicate")))
status, _ = client.call("POST", "reviews/%d/answers" % item["id"], token_b,
{"answerId": "ops-verify-restore-0002", "grade": "correct", "expectedDueAt": item["dueAt"]})
add("B 不能给 A 的词条答题", status == 404, "HTTP " + str(status))
return checks
def command_smoke(args):
"""Walks the whole learning loop on a freshly installed instance with two trial accounts."""
load_env()
client = Api(args.api)
password = os.environ.get(args.password_env, "")
if not password:
raise OpsError("环境变量 " + args.password_env + " 为空:请先为演练账号设置密码。")
checks = []
def add(name, ok, detail=""):
checks.append((name, ok, detail))
# 1. The first administrator exists and can log in: the installation is usable.
status, admin_token = client.login(args.admin_user, os.environ.get(args.admin_password_env, ""))
add("初始管理员可以登录", status == 200 and bool(admin_token))
if not admin_token:
return report(checks)
# 2. Two trial learners, created by the administrator. There is no self-registration.
for suffix in ("a", "b"):
account = args.user + "_" + suffix
status, payload = client.call("POST", "accounts", admin_token, {"username": account, "password": password})
if status == 201:
add("管理员创建演练账号 " + account, True, "HTTP 201")
else:
# An existing account is fine on a rerun; the password was set the first time.
existing = client.login(account, password)[0] == 200
add("演练账号 " + account + " 已存在且可登录", existing, payload.get("msg", "HTTP " + str(status)))
status, token_a = client.login(args.user + "_a", password)
status2, token_b = client.login(args.user + "_b", password)
add("两个演练账号都能登录", status == 200 and status2 == 200 and bool(token_a) and bool(token_b))
if not token_a or not token_b:
return report(checks)
add("新账号的学习空间是空的", client.call("GET", "books", token_a)[1].get("data", {}).get("items", []) == [])
add("新账号的进度从零开始", client.call("GET", "progress", token_a)[1].get("data", {}).get("totalChapters") == 0)
# 3. The learning loop on the new instance: paste, read, complete, save a word, review it.
text = "Curiosity opens the first door.\nThe second door stays closed.\n" * 6
status, pasted = client.call("POST", "books", token_a, {
"requestId": args.request_id, "title": "Fictional trial chapter", "text": text, "language": "en"})
add("演练账号可以粘贴章节", status == 201, "HTTP " + str(status))
if status != 201:
return report(checks)
book_id = pasted["data"]["book"]["id"]
chapter_id = pasted["data"]["chapter"]["id"]
ready = {}
for _ in range(40):
status, detail = client.call("GET", "chapters/%d" % chapter_id, token_a)
ready = detail.get("data", {}).get("chapter", {})
if ready.get("status") == "ready":
break
time.sleep(0.5)
add("章节在干净实例上处理完成", ready.get("status") == "ready", str(ready.get("status")))
if ready.get("status") != "ready":
return report(checks)
status, tokens = client.call("GET", "chapters/%d/tokens" % chapter_id, token_a)
words = [token for token in tokens.get("data", {}).get("tokens", []) if token.get("kind") == "word"]
add("章节可以分词并返回可点选的词", status == 200 and len(words) > 3, str(len(words)) + " 个词")
status, _ = client.call("GET", "terms/lookup?query=curiosity", token_a)
add("词典查询返回明确状态(含资源缺失时的降级)", status in (200, 404, 503), "lookup HTTP " + str(status))
if words:
word = words[0]
status, saved = client.call("POST", "terms", token_a, {
"chapterId": chapter_id, "start": word["start"], "end": word["end"],
"definition": "虚构释义", "status": "new"})
add("演练账号可以保存自己的词义", status in (200, 201), "HTTP " + str(status))
term_id = saved.get("data", {}).get("term", {}).get("id")
if term_id:
other_terms = client.call("GET", "terms?query=curiosity", token_b)[1].get("data", {}).get("total")
add("另一个账号看不到该词条", other_terms == 0, "B 查到 " + str(other_terms) + " 条")
queue = client.call("GET", "reviews/queue", token_a)[1]
items = queue.get("data", {}).get("items", [])
due = [entry for entry in items if entry["id"] == term_id]
add("新保存的词条立刻到期", bool(due), "到期 " + str(queue.get("data", {}).get("total")))
if due:
answer = {"answerId": args.answer_id, "grade": "correct", "expectedDueAt": due[0]["dueAt"]}
status, result = client.call("POST", "reviews/%d/answers" % term_id, token_a, answer)
add("到期复习可以作答", status in (200, 201) and result.get("data", {}).get("result") == "applied",
"level " + str(result.get("data", {}).get("levelAfter")))
replay = client.call("POST", "reviews/%d/answers" % term_id, token_a, answer)[1]
add("重复作答不重复记账", replay.get("data", {}).get("duplicate") is True)
status, completed = client.call("POST", "chapters/%d/complete" % chapter_id, token_a)
add("完成章节只记已读", status == 200 and completed.get("data", {}).get("progress", {}).get("read") is True)
data = client.call("GET", "progress", token_a)[1].get("data", {})
add("进度反映新实例上的活动", data.get("readChapters", 0) >= 1 and data.get("savedTerms", 0) >= 1,
json.dumps({key: data.get(key) for key in ("readChapters", "totalChapters", "knownTerms", "learningTerms", "newTerms", "dueNow")}, ensure_ascii=False))
add("B 无法读取 A 的书籍", client.call("GET", "books/%d" % book_id, token_b)[0] == 404)
add("B 的书库仍然为空", client.call("GET", "books", token_b)[1].get("data", {}).get("items", []) == [])
return report(checks)
def command_verify(args):
load_env()
database = validate_database_name(args.database or os.environ.get("LEXGO_DB_NAME", ""), "校验")
failures = []
print("校验数据库 " + database)
if not database_exists(database):
raise OpsError("数据库不存在:" + database)
version = int(scalar(database, "SELECT version FROM lexgo_schema WHERE id=1", "0") or 0)
if version != SCHEMA_VERSION:
print(" [warn] schema 版本 " + str(version) + ",本工具期望 " + str(SCHEMA_VERSION) + "(升级或回退阶段属正常)")
checks = integrity_checks(database)
if args.manifest:
manifest = json.loads(Path(args.manifest).read_text(encoding="utf-8"))
actual = row_counts(database)
for table, expected in manifest.get("row_counts", {}).items():
got = actual.get(table, 0)
checks.append(("表 " + table + " 行数与备份一致", got == expected, "备份 " + str(expected) + " / 现在 " + str(got)))
for name, ok, detail in checks:
print((" [ok] " if ok else " [fail] ") + name + ("— " + detail if detail else ""))
if not ok:
failures.append(name)
if args.api:
print("校验接口 " + args.api + "(账号前缀 " + args.user + ")")
password = os.environ.get(args.password_env, "")
if not password:
raise OpsError("环境变量 " + args.password_env + " 为空,无法登录演练账号。")
password_b = os.environ.get(args.password_env_b, "") or password
for name, ok, detail in api_walk(args.api, args.user, password, password_b):
print((" [ok] " if ok else " [fail] ") + name + ("— " + detail if detail else ""))
if not ok:
failures.append(name)
if failures:
print("\n校验未通过 " + str(len(failures)) + " 项:" + "、".join(failures))
raise SystemExit(1)
print("\n校验通过。")
return 0
def build_parser():
parser = argparse.ArgumentParser(description="LexGo 运维工具:依赖检查、备份、恢复与校验")
sub = parser.add_subparsers(dest="command", required=True)
install = sub.add_parser("install-check", help="只读检查依赖与资源版本")
install.set_defaults(func=command_install_check)
init = sub.add_parser("init-database", help="为全新安装创建空库并提示所需权限")
init.add_argument("--database", default="", help="要创建的库名,默认取 LEXGO_DB_NAME")
init.set_defaults(func=command_init_database)
backup = sub.add_parser("backup", help="导出全库并写出 manifest")
backup.add_argument("--out", default="", help="输出目录,默认 .local/backups/<时间戳>")
backup.add_argument("--database", default="", help="要备份的库,默认取 LEXGO_DB_NAME")
backup.add_argument("--force", action="store_true", help="输出目录非空时覆盖")
backup.set_defaults(func=command_backup)
restore = sub.add_parser("restore", help="把备份恢复到空库(覆盖已有库要 --force)")
restore.add_argument("--dump", required=True, help="backup 生成的 .sql.gz")
restore.add_argument("--database", required=True, help="目标库名,必须包含 lexgo")
restore.add_argument("--confirm", action="store_true", help="确认执行写入")
restore.add_argument("--force", action="store_true", help="目标库已有数据时覆盖")
restore.add_argument("--skip-manifest-check", action="store_true", help="没有 manifest 时跳过完整性校验")
restore.set_defaults(func=command_restore)
verify = sub.add_parser("verify", help="校验恢复结果的完整性与两账号隔离")
verify.add_argument("--database", default="", help="要校验的库,默认取 LEXGO_DB_NAME")
verify.add_argument("--manifest", default="", help="与备份的行数逐表比对")
verify.add_argument("--expect-schema", type=int, default=None)
verify.add_argument("--api", default="", help="例如 http://127.0.0.1:8010 ,启用两账号闭环校验")
verify.add_argument("--user", default="", help="演练账号前缀,实际账号为 <前缀>_a 与 <前缀>_b")
verify.add_argument("--password-env", default="LEXGO_TRIAL_PASSWORD", help="存放演练账号 _a 密码的环境变量名")
verify.add_argument("--password-env-b", default="", help="存放演练账号 _b 密码的环境变量名,默认与 _a 相同")
verify.add_argument("--skip-api", action="store_true")
verify.set_defaults(func=command_verify)
smoke = sub.add_parser("smoke", help="在干净实例上创建两个演练账号并走通学习闭环")
smoke.add_argument("--api", required=True, help="例如 http://127.0.0.1:8010")
smoke.add_argument("--admin-user", required=True, help="初始管理员账号")
smoke.add_argument("--admin-password-env", default="LEXGO_BOOTSTRAP_PASSWORD", help="初始管理员密码所在的环境变量名")
smoke.add_argument("--user", required=True, help="演练账号前缀,实际账号为 <前缀>_a 与 <前缀>_b")
smoke.add_argument("--password-env", default="LEXGO_TRIAL_PASSWORD", help="演练账号密码所在的环境变量名")
smoke.add_argument("--request-id", default="ops-smoke-0001", help="粘贴章节的幂等请求号")
smoke.add_argument("--answer-id", default="ops-smoke-answer-0001", help="复习作答的幂等编号")
smoke.set_defaults(func=command_smoke)
return parser
def main(argv=None):
args = build_parser().parse_args(argv)
try:
return args.func(args)
except OpsError as error:
print("错误:" + str(error), file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(main())
+3 -3
View File
@@ -140,9 +140,9 @@ type ChapterSummary struct {
Status string `json:"status"`
CharCount int `json:"charCount"`
// JobID lets a client retry a failed chapter without keeping the submit response.
JobID *int64 `json:"jobId"`
ErrorReason string `json:"errorReason"`
ErrorMessage string `json:"errorMessage"`
JobID *int64 `json:"jobId"`
ErrorReason string `json:"errorReason"`
ErrorMessage string `json:"errorMessage"`
// ReadAt is set only where the completion was resolved and still matches the chapter's
// current content; a chapter edited into a newer version reads as unread again.
ReadAt *time.Time `json:"readAt"`
+600
View File
@@ -0,0 +1,600 @@
package lexgo
import (
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
"gorm.io/gorm"
)
// Operations for a self-hosted instance: back up the whole database, restore a backup into an
// empty one, and verify the result. Everything a LexGo instance persists lives in MySQL (accounts,
// book and chapter text, the dictionary archive, personal terms, review schedules and answers,
// reading progress and the audit logs), so a dump plus the environment file is a complete backup.
//
// The same rules as the operations script apply, because a restore can overwrite a working
// instance: an explicit confirmation, a target name that contains "lexgo", a refusal to touch a
// database that already holds data, and a refusal to load a dump that would switch databases.
// OpsTables lists the tables a backup covers, in a stable order for reports.
var OpsTables = []string{
"sys_user", "lexgo_spaces", "lexgo_sessions", "lexgo_login_logs", "lexgo_operation_logs",
"lexgo_books", "lexgo_chapters", "lexgo_ingest_jobs", "lexgo_dictionaries",
"lexgo_terms", "lexgo_term_reviews", "lexgo_review_answers", "lexgo_chapter_progress",
}
var opsSystemSchemas = map[string]bool{
"mysql": true, "information_schema": true, "performance_schema": true, "sys": true,
}
// OpsManifest is written next to every dump. The field names match the operations script so either
// tool can read the other's backup, and it deliberately carries no credential.
type OpsManifest struct {
CreatedAt string `json:"created_at"`
Database string `json:"database"`
Product string `json:"product"`
Schema int `json:"schema_version"`
GitCommit string `json:"git_commit"`
DumpFile string `json:"dump_file"`
DumpSHA256 string `json:"dump_sha256"`
DumpBytes int64 `json:"dump_bytes"`
RowCounts map[string]int64 `json:"row_counts"`
MySQLClient string `json:"mysql_client"`
MySQLServer string `json:"mysql_server"`
Contents string `json:"contents"`
Excludes string `json:"excludes"`
}
// OpsCheck is one verification result.
type OpsCheck struct {
Name string
OK bool
Detail string
}
// ValidateOpsDatabaseName rejects anything that must never be written by a restore.
func ValidateOpsDatabaseName(name string) error {
if name == "" || opsSystemSchemas[strings.ToLower(name)] || !strings.Contains(strings.ToLower(name), "lexgo") {
return errors.New("refusing to act on " + name + ": the database name must contain lexgo and must not be a MySQL system schema")
}
return nil
}
// VersionTriple reads a dotted version out of a client or server banner. A client banner reads
// "Ver 14.14 Distrib 5.7.38", where the first number is the protocol version, so the number after
// "Distrib" is the one to compare.
func VersionTriple(text string) (int, int, int, bool) {
pattern := regexp.MustCompile(`Distrib\s+(\d+)\.(\d+)(?:\.(\d+))?`)
match := pattern.FindStringSubmatch(text)
if match == nil {
match = regexp.MustCompile(`(\d+)\.(\d+)(?:\.(\d+))?`).FindStringSubmatch(text)
}
if match == nil {
return 0, 0, 0, false
}
major, _ := strconv.Atoi(match[1])
minor, _ := strconv.Atoi(match[2])
patch := 0
if match[3] != "" {
patch, _ = strconv.Atoi(match[3])
}
return major, minor, patch, true
}
// DumpSwitchesDatabase reports whether a dump carries CREATE DATABASE or USE statements. Loading
// such a dump would write into the schema named inside the file instead of the requested target.
func DumpSwitchesDatabase(head string) bool {
if strings.Contains(strings.ToUpper(head), "CREATE DATABASE") {
return true
}
return regexp.MustCompile(`(?m)^USE `).MatchString(head)
}
func opsMySQLBinary(name string) (string, error) {
configured := os.Getenv("LEXGO_MYSQL_BIN")
if configured != "" {
candidate := filepath.Join(configured, name)
if _, err := os.Stat(candidate); err == nil {
return candidate, nil
}
if _, err := os.Stat(candidate + ".exe"); err == nil {
return candidate + ".exe", nil
}
}
found, err := exec.LookPath(name)
if err != nil {
return "", errors.New("cannot find " + name + ": install the MySQL client or point LEXGO_MYSQL_BIN at it")
}
return found, nil
}
func opsClientEnv() []string {
env := os.Environ()
if password := os.Getenv("LEXGO_DB_PASSWORD"); password != "" {
// The client reads MYSQL_PWD, so the password never appears in an argument list.
env = append(env, "MYSQL_PWD="+password)
}
return env
}
func opsConnectionArgs(database string) []string {
host := os.Getenv("LEXGO_DB_HOST")
if host == "" {
host = "127.0.0.1"
}
port := os.Getenv("LEXGO_DB_PORT")
if port == "" {
port = "3308"
}
args := []string{"--host=" + host, "--port=" + port, "--user=" + os.Getenv("LEXGO_DB_USER")}
if database != "" {
args = append(args, database)
}
return args
}
// OpsTableName qualifies a table with its database. The operations client deliberately does not
// select a schema (a restore may have to create one), so every introspection query is qualified.
func OpsTableName(database, table string) string {
return "`" + database + "`.`" + table + "`"
}
// OpsTableCounts reads the row count of every known table.
func OpsTableCounts(db *gorm.DB, database string) (map[string]int64, error) {
counts := map[string]int64{}
for _, table := range OpsTables {
var exists int64
if err := db.Raw("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?", database, table).Scan(&exists).Error; err != nil {
return counts, err
}
if exists == 0 {
continue
}
var total int64
if err := db.Raw("SELECT COUNT(*) FROM " + OpsTableName(database, table)).Scan(&total).Error; err != nil {
return counts, err
}
counts[table] = total
}
return counts, nil
}
// OpsTableChecksums returns a content checksum per table, which is what proves a restore equals
// its source: row counts alone could match after a bad write.
func OpsTableChecksums(db *gorm.DB, database string) (map[string]string, error) {
checksums := map[string]string{}
for _, table := range OpsTables {
rows, err := db.Raw("CHECKSUM TABLE " + OpsTableName(database, table)).Rows()
if err != nil {
// A missing table is reported as absent rather than failing the comparison.
continue
}
for rows.Next() {
var name, sum string
if err = rows.Scan(&name, &sum); err != nil {
rows.Close()
return checksums, err
}
checksums[table] = sum
}
rows.Close()
}
return checksums, nil
}
func opsDigest(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
digest := sha256.New()
if _, err = io.Copy(digest, file); err != nil {
return "", err
}
return hex.EncodeToString(digest.Sum(nil)), nil
}
func opsGzipFile(source, target string) error {
input, err := os.Open(source)
if err != nil {
return err
}
defer input.Close()
output, err := os.Create(target)
if err != nil {
return err
}
defer output.Close()
writer, err := gzip.NewWriterLevel(output, gzip.BestSpeed)
if err != nil {
return err
}
if _, err = io.Copy(writer, input); err != nil {
return err
}
return writer.Close()
}
// OpsBackupOptions describes one backup request.
type OpsBackupOptions struct {
Database string
OutDir string
Force bool
Commit string
Now time.Time
}
// OpsBackup runs mysqldump for the whole schema, compresses it and writes a manifest.
func OpsBackup(db *gorm.DB, options OpsBackupOptions) (OpsManifest, error) {
manifest := OpsManifest{}
database := options.Database
if err := ValidateOpsDatabaseName(database); err != nil {
return manifest, err
}
outDir := options.OutDir
if outDir == "" {
outDir = filepath.Join(".local", "backups", options.Now.UTC().Format("20060102-150405"))
}
if entries, err := os.ReadDir(outDir); err == nil && len(entries) > 0 && !options.Force {
return manifest, errors.New("output directory is not empty: " + outDir + " (use --force to overwrite)")
}
if err := os.MkdirAll(outDir, 0o755); err != nil {
return manifest, err
}
dumpTool, err := opsMySQLBinary("mysqldump")
if err != nil {
return manifest, err
}
stamp := options.Now.UTC().Format("20060102T150405Z")
plain := filepath.Join(outDir, "lexgo-"+stamp+".sql")
compressed := plain + ".gz"
defer os.Remove(plain)
// Deliberately without --databases: that option writes CREATE DATABASE and USE into the dump,
// which would send a restore into the source schema instead of the requested target.
//nolint:gosec // the arguments are configuration plus a validated database name, and the
// password travels through MYSQL_PWD rather than the command line.
command := exec.Command(dumpTool, append(opsConnectionArgs(""),
"--single-transaction", "--routines", "--triggers", "--hex-blob", "--no-tablespaces",
"--default-character-set=utf8mb4", database)...)
command.Env = opsClientEnv()
output, err := os.Create(plain)
if err != nil {
return manifest, err
}
command.Stdout = output
var stderr strings.Builder
command.Stderr = &stderr
runErr := command.Run()
closeErr := output.Close()
if runErr != nil {
return manifest, fmt.Errorf("mysqldump failed: %s", lastLine(stderr.String()))
}
if closeErr != nil {
return manifest, closeErr
}
if err = opsGzipFile(plain, compressed); err != nil {
return manifest, err
}
digest, err := opsDigest(compressed)
if err != nil {
return manifest, err
}
info, err := os.Stat(compressed)
if err != nil {
return manifest, err
}
counts, err := OpsTableCounts(db, database)
if err != nil {
return manifest, err
}
var schema int
if err = db.Raw("SELECT version FROM " + OpsTableName(database, "lexgo_schema") + " WHERE id = 1").Scan(&schema).Error; err != nil {
return manifest, err
}
var server string
_ = db.Raw("SELECT VERSION()").Scan(&server).Error
client := ""
if out, err := exec.Command(dumpTool, "--version").Output(); err == nil {
client = strings.TrimSpace(string(out))
}
manifest = OpsManifest{
CreatedAt: stamp, Database: database, Product: "lexgo", Schema: schema,
GitCommit: options.Commit, DumpFile: filepath.Base(compressed), DumpSHA256: digest,
DumpBytes: info.Size(), RowCounts: counts, MySQLClient: client, MySQLServer: server,
Contents: "accounts, spaces, sessions, audit logs, books, chapters, ingest jobs, dictionary archive, terms, review schedules, review answers, reading progress",
Excludes: "credentials and the environment file; back those up separately from the operations vault",
}
encoded, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return manifest, err
}
if err = os.WriteFile(filepath.Join(outDir, "manifest.json"), append(encoded, '\n'), 0o644); err != nil {
return manifest, err
}
return manifest, nil
}
func lastLine(text string) string {
lines := strings.Split(strings.TrimSpace(text), "\n")
if len(lines) == 0 {
return ""
}
return strings.TrimSpace(lines[len(lines)-1])
}
// OpsRestoreOptions describes one restore request.
type OpsRestoreOptions struct {
Dump string
Database string
Confirm bool
Force bool
SkipManifest bool
Now time.Time
}
// OpsRestore loads a backup into an empty database and verifies the result. The dump is refused
// when it would switch databases, the target must be empty unless --force is given, and the source
// database named in the manifest is compared before and after so the restore can never write there.
func OpsRestore(db *gorm.DB, client *gorm.DB, options OpsRestoreOptions) (OpsManifest, []OpsCheck, error) {
manifest := OpsManifest{}
if !options.Confirm {
return manifest, nil, errors.New("a restore writes to the database and needs confirmation: add --confirm")
}
if err := ValidateOpsDatabaseName(options.Database); err != nil {
return manifest, nil, err
}
if _, err := os.Stat(options.Dump); err != nil {
return manifest, nil, errors.New("backup file not found: " + options.Dump)
}
manifestPath := filepath.Join(filepath.Dir(options.Dump), "manifest.json")
if data, err := os.ReadFile(manifestPath); err == nil {
if err = json.Unmarshal(data, &manifest); err != nil {
return manifest, nil, errors.New("manifest.json is not readable")
}
digest, err := opsDigest(options.Dump)
if err != nil {
return manifest, nil, err
}
if manifest.DumpSHA256 != "" && manifest.DumpSHA256 != digest {
return manifest, nil, errors.New("the backup file does not match the sha256 in its manifest; refusing to restore")
}
} else if !options.SkipManifest {
return manifest, nil, errors.New("manifest.json not found next to the dump; add --skip-manifest-check to continue")
}
head, err := opsDumpHead(options.Dump)
if err != nil {
return manifest, nil, err
}
if DumpSwitchesDatabase(head) {
return manifest, nil, errors.New("this dump contains CREATE DATABASE/USE, so a restore would write into the schema named inside it; take a new backup with lexgo backup")
}
source := manifest.Database
var sourceBefore map[string]string
if source != "" && source != options.Database {
if sourceBefore, err = OpsTableChecksums(client, source); err != nil {
return manifest, nil, err
}
}
if err = opsPrepareTarget(client, options.Database, options.Force); err != nil {
return manifest, nil, err
}
if err = opsLoadDump(options.Dump, options.Database); err != nil {
return manifest, nil, err
}
if sourceBefore != nil {
sourceAfter, err := OpsTableChecksums(client, source)
if err != nil {
return manifest, nil, err
}
if !sameChecksums(sourceBefore, sourceAfter) {
return manifest, nil, errors.New("the source database " + source + " changed during the restore; stop and compare by hand")
}
}
checks, err := OpsVerify(client, options.Database, manifestPath)
return manifest, checks, err
}
func opsDumpHead(dump string) (string, error) {
file, err := os.Open(dump)
if err != nil {
return "", err
}
defer file.Close()
reader, err := gzip.NewReader(file)
if err != nil {
return "", errors.New("the backup file is not a gzip archive: " + err.Error())
}
defer reader.Close()
buffer := make([]byte, 256*1024)
total := 0
for total < len(buffer) {
read, err := reader.Read(buffer[total:])
total += read
if err != nil {
break
}
}
return string(buffer[:total]), nil
}
func opsPrepareTarget(client *gorm.DB, database string, force bool) error {
var exists int
if err := client.Raw("SELECT COUNT(*) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = ?", database).Scan(&exists).Error; err != nil {
return err
}
if exists == 0 {
return client.Exec("CREATE DATABASE `" + database + "` CHARACTER SET utf8mb4").Error
}
var tables int
if err := client.Raw("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = ?", database).Scan(&tables).Error; err != nil {
return err
}
if tables == 0 {
return nil
}
if !force {
return errors.New("target database " + database + " already holds " + strconv.Itoa(tables) + " tables; a restore only writes an empty database (use --force after taking a backup)")
}
if err := client.Exec("DROP DATABASE `" + database + "`").Error; err != nil {
return err
}
return client.Exec("CREATE DATABASE `" + database + "` CHARACTER SET utf8mb4").Error
}
func opsLoadDump(dump, database string) error {
client, err := opsMySQLBinary("mysql")
if err != nil {
return err
}
file, err := os.Open(dump)
if err != nil {
return err
}
defer file.Close()
reader, err := gzip.NewReader(file)
if err != nil {
return err
}
defer reader.Close()
//nolint:gosec // configuration plus a validated database name; the password is in MYSQL_PWD.
command := exec.Command(client, append(opsConnectionArgs(database), "--default-character-set=utf8mb4")...)
command.Env = opsClientEnv()
command.Stdin = reader
var stderr strings.Builder
command.Stderr = &stderr
if err = command.Run(); err != nil {
return fmt.Errorf("restore failed: %s", lastLine(stderr.String()))
}
return nil
}
func sameChecksums(before, after map[string]string) bool {
if len(before) != len(after) {
return false
}
for table, sum := range before {
if after[table] != sum {
return false
}
}
return true
}
// OpsVerify checks that a database holds consistent LexGo data and, when a manifest is given, that
// every table still has the number of rows the backup recorded.
func OpsVerify(client *gorm.DB, database string, manifestPath string) ([]OpsCheck, error) {
checks := []OpsCheck{}
add := func(name string, ok bool, detail string) { checks = append(checks, OpsCheck{name, ok, detail}) }
table := func(name string) string { return OpsTableName(database, name) }
linked := func(sql, name string) error {
var count int64
if err := client.Raw(sql).Scan(&count).Error; err != nil {
return err
}
add(name, count == 0, strconv.FormatInt(count, 10)+" 行")
return nil
}
var product string
_ = client.Raw("SELECT product FROM " + table("lexgo_schema") + " WHERE id = 1").Scan(&product).Error
add("产品标识为 lexgo", product == "lexgo", product)
chapter, book, term, review, answer, progress, space :=
table("lexgo_chapters"), table("lexgo_books"), table("lexgo_terms"), table("lexgo_term_reviews"),
table("lexgo_review_answers"), table("lexgo_chapter_progress"), table("lexgo_spaces")
if err := linked("SELECT COUNT(*) FROM "+chapter+" c LEFT JOIN "+book+" b ON b.id = c.book_id WHERE b.id IS NULL", "没有孤立章节(章节属于其书)"); err != nil {
return checks, err
}
if err := linked("SELECT COUNT(*) FROM "+chapter+" c JOIN "+book+" b ON b.id = c.book_id WHERE c.owner_id <> b.owner_id", "章节归属与书归属一致"); err != nil {
return checks, err
}
if err := linked("SELECT COUNT(*) FROM "+term+" t LEFT JOIN "+review+" r ON r.term_id = t.id WHERE r.term_id IS NULL", "每个词条都有排期行"); err != nil {
return checks, err
}
if err := linked("SELECT COUNT(*) FROM "+review+" r LEFT JOIN "+term+" t ON t.id = r.term_id WHERE t.id IS NULL", "排期行都指向存在的词条"); err != nil {
return checks, err
}
if err := linked("SELECT COUNT(*) FROM "+answer+" a LEFT JOIN "+term+" t ON t.id = a.term_id WHERE t.id IS NULL", "复习记录引用有效词条"); err != nil {
return checks, err
}
if err := linked("SELECT COUNT(*) FROM "+term+" WHERE (status = 'learning' AND (level < 1 OR level > 7)) OR (status <> 'learning' AND level <> 0)", "等级只出现在学习中词条"); err != nil {
return checks, err
}
if err := linked("SELECT COUNT(*) FROM "+progress+" p LEFT JOIN "+chapter+" c ON c.id = p.chapter_id WHERE c.id IS NULL", "完成记录都指向存在的章节"); err != nil {
return checks, err
}
if err := linked("SELECT COUNT(*) FROM "+term+" t JOIN "+space+" s ON s.owner_id = t.owner_id WHERE t.language <> s.language", "词条语言与所属空间一致"); err != nil {
return checks, err
}
// The audit tables must not gain a column that could hold a credential or private content.
for _, name := range []string{"lexgo_login_logs", "lexgo_operation_logs"} {
var columns []string
if err := client.Raw("SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?", database, name).Scan(&columns).Error; err != nil {
return checks, err
}
banned := []string{}
for _, column := range columns {
lower := strings.ToLower(column)
for _, word := range []string{"password", "token", "body", "content", "definition", "text"} {
if strings.Contains(lower, word) {
banned = append(banned, column)
break
}
}
}
sort.Strings(banned)
add("审计表 "+name+" 不含敏感列", len(banned) == 0, strings.Join(banned, ","))
}
if manifestPath != "" {
if data, err := os.ReadFile(manifestPath); err == nil {
var manifest OpsManifest
if err = json.Unmarshal(data, &manifest); err == nil {
counts, err := OpsTableCounts(client, database)
if err != nil {
return checks, err
}
tables := make([]string, 0, len(manifest.RowCounts))
for name := range manifest.RowCounts {
tables = append(tables, name)
}
sort.Strings(tables)
for _, name := range tables {
expected := manifest.RowCounts[name]
add("表 "+name+" 行数与备份一致", counts[name] == expected,
"备份 "+strconv.FormatInt(expected, 10)+" / 现在 "+strconv.FormatInt(counts[name], 10))
}
}
}
}
return checks, nil
}
// OpsAllOK reports whether every verification check passed.
func OpsAllOK(checks []OpsCheck) bool {
for _, check := range checks {
if !check.OK {
return false
}
}
return true
}
+287
View File
@@ -0,0 +1,287 @@
package lexgo
import (
"compress/gzip"
"encoding/json"
admin "go-admin/app/admin/models"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// The operations rules are checked here without a database; the round trip itself is covered by
// the MySQL integration test below and by the delivery drill.
func TestOpsDatabaseNameRules(t *testing.T) {
for _, name := range []string{"lexgo_dev", "lexgo_prod", "lexgo_restore_drill"} {
if err := ValidateOpsDatabaseName(name); err != nil {
t.Fatalf("%s must be accepted: %v", name, err)
}
}
for _, name := range []string{"", "mysql", "information_schema", "performance_schema", "sys", "app_prod", "LEXGO", "lexgo"} {
err := ValidateOpsDatabaseName(name)
if name == "LEXGO" || name == "lexgo" {
if err != nil {
t.Fatalf("%s contains lexgo and must be accepted", name)
}
continue
}
if err == nil {
t.Fatalf("%s must be refused", name)
}
}
}
func TestOpsVersionTriple(t *testing.T) {
// The first number in a client banner is the protocol version, so "Distrib" wins.
major, minor, patch, ok := VersionTriple("mysql Ver 14.14 Distrib 5.7.38, for Win64 (x86_64)")
if !ok || major != 5 || minor != 7 || patch != 38 {
t.Fatalf("client banner parsed as %d.%d.%d (%v)", major, minor, patch, ok)
}
if major, minor, patch, ok = VersionTriple("mysql Ver 8.4.3 for Win64 on x86_64"); !ok || major != 8 || minor != 4 || patch != 3 {
t.Fatalf("server banner parsed as %d.%d.%d (%v)", major, minor, patch, ok)
}
if _, _, _, ok = VersionTriple("no version"); ok {
t.Fatal("a banner without a version must not parse")
}
// An older client must sort below the server it would dump.
old := [3]int{}
newer := [3]int{}
old[0], old[1], old[2], _ = VersionTriple("Ver 14.14 Distrib 5.7.38")
newer[0], newer[1], newer[2], _ = VersionTriple("8.4.3")
if !(old[0] < newer[0]) {
t.Fatalf("5.7.38 must be older than 8.4.3: %v %v", old, newer)
}
}
func writeDumpFile(t *testing.T, body string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "dump.sql.gz")
handle, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
writer := gzip.NewWriter(handle)
if _, err = writer.Write([]byte(body)); err != nil {
t.Fatal(err)
}
if err = writer.Close(); err != nil {
t.Fatal(err)
}
if err = handle.Close(); err != nil {
t.Fatal(err)
}
return path
}
func TestOpsDumpHeadAndDatabaseSwitchDetection(t *testing.T) {
switching := writeDumpFile(t, "-- MySQL dump\nCREATE DATABASE /*!32312 IF NOT EXISTS*/ `lexgo_dev`;\nUSE `lexgo_dev`;\nCREATE TABLE t (id INT);\n")
head, err := opsDumpHead(switching)
if err != nil {
t.Fatal(err)
}
if !DumpSwitchesDatabase(head) {
t.Fatal("a dump with CREATE DATABASE must be detected")
}
useOnly := writeDumpFile(t, "-- MySQL dump\nUSE `lexgo_dev`;\nCREATE TABLE t (id INT);\n")
head, err = opsDumpHead(useOnly)
if err != nil {
t.Fatal(err)
}
if !DumpSwitchesDatabase(head) {
t.Fatal("a dump with USE must be detected")
}
plain := writeDumpFile(t, "-- MySQL dump\nDROP TABLE IF EXISTS `lexgo_terms`;\nCREATE TABLE `lexgo_terms` (id INT);\nINSERT INTO `lexgo_terms` VALUES (1);\n")
head, err = opsDumpHead(plain)
if err != nil {
t.Fatal(err)
}
if DumpSwitchesDatabase(head) {
t.Fatal("a table-only dump must be accepted")
}
}
func TestOpsRefusesRestoreWithoutConfirmation(t *testing.T) {
dump := writeDumpFile(t, "-- MySQL dump\nCREATE TABLE `lexgo_terms` (id INT);\n")
_, _, err := OpsRestore(nil, nil, OpsRestoreOptions{Dump: dump, Database: "lexgo_restore_drill"})
if err == nil {
t.Fatal("a restore without --confirm must be refused")
}
}
func TestOpsRefusesRestoreIntoSystemSchema(t *testing.T) {
dump := writeDumpFile(t, "-- MySQL dump\nCREATE TABLE `lexgo_terms` (id INT);\n")
_, _, err := OpsRestore(nil, nil, OpsRestoreOptions{Dump: dump, Database: "mysql", Confirm: true})
if err == nil {
t.Fatal("a restore into a system schema must be refused")
}
}
func TestOpsRefusesMissingBackupAndManifest(t *testing.T) {
_, _, err := OpsRestore(nil, nil, OpsRestoreOptions{Dump: filepath.Join(t.TempDir(), "absent.sql.gz"), Database: "lexgo_x", Confirm: true})
if err == nil {
t.Fatal("a missing backup file must be reported")
}
directory := t.TempDir()
dump := filepath.Join(directory, "lexgo-20260101T000000Z.sql.gz")
handle, err := os.Create(dump)
if err != nil {
t.Fatal(err)
}
writer := gzip.NewWriter(handle)
if _, err = writer.Write([]byte("-- MySQL dump\nCREATE TABLE `lexgo_terms` (id INT);\n")); err != nil {
t.Fatal(err)
}
writer.Close()
handle.Close()
_, _, err = OpsRestore(nil, nil, OpsRestoreOptions{Dump: dump, Database: "lexgo_x", Confirm: true})
if err == nil {
t.Fatal("a dump without a manifest must be refused unless the check is skipped")
}
// With --skip-manifest-check and --force the guards accept the request; the database step is
// where it goes next, which the integration test covers with a real connection.
head, err := opsDumpHead(dump)
if err != nil || DumpSwitchesDatabase(head) {
t.Fatalf("the dump itself is acceptable: %v", err)
}
}
func TestOpsChecksumComparison(t *testing.T) {
before := map[string]string{"lexgo_terms": "1", "lexgo_books": "2"}
if !sameChecksums(before, map[string]string{"lexgo_terms": "1", "lexgo_books": "2"}) {
t.Fatal("equal checksums must compare equal")
}
if sameChecksums(before, map[string]string{"lexgo_terms": "1", "lexgo_books": "3"}) {
t.Fatal("a changed table must be detected")
}
if sameChecksums(before, map[string]string{"lexgo_terms": "1"}) {
t.Fatal("a missing table must be detected")
}
}
func TestOpsManifestCarriesNoCredential(t *testing.T) {
// The manifest is written to disk: its shape is asserted so a later edit cannot add a secret.
manifest := OpsManifest{
CreatedAt: time.Now().UTC().Format(time.RFC3339), Database: "lexgo_dev", Product: "lexgo",
Schema: SchemaVersion, DumpFile: "lexgo-x.sql.gz", DumpSHA256: "abc",
}
encoded, err := jsonMarshal(manifest)
if err != nil {
t.Fatal(err)
}
for _, banned := range []string{"password", "secret", "token", "credential"} {
if containsFold(encoded, banned) {
t.Fatalf("manifest must not carry %q: %s", banned, encoded)
}
}
for _, expected := range []string{"schema_version", "row_counts", "dump_sha256", "git_commit"} {
if !containsFold(encoded, expected) {
t.Fatalf("manifest must carry %q: %s", expected, encoded)
}
}
}
// jsonMarshal and containsFold keep this test independent of the assertions helper used elsewhere.
func jsonMarshal(value any) (string, error) {
encoded, err := json.Marshal(value)
return string(encoded), err
}
func containsFold(haystack, needle string) bool {
return strings.Contains(strings.ToLower(haystack), strings.ToLower(needle))
}
// TestMySQLOpsBackupRestoreRoundTrip covers the operations path against real MySQL: back up a
// database, restore the dump into an empty one, and compare both the row counts and the table
// contents. It skips when the MySQL client tools are not installed, because the dump is taken by
// mysqldump.
func TestMySQLOpsBackupRestoreRoundTrip(t *testing.T) {
db := testDB(t)
source := os.Getenv("LEXGO_TEST_DB_NAME")
if source == "" {
t.Skip("needs LEXGO_TEST_DB_NAME")
}
if _, err := opsMySQLBinary("mysqldump"); err != nil {
t.Skip("mysqldump is not installed")
}
// A fixture row so the restore has something to bring back that this test created.
owner := admin.SysUser{Username: randomName("ops"), Password: fixturePassword, RoleId: 2, Status: "2"}
if err := db.Create(&owner).Error; err != nil {
t.Fatal(err)
}
before, err := OpsTableCounts(db, source)
if err != nil {
t.Fatal(err)
}
sourceChecksums, err := OpsTableChecksums(db, source)
if err != nil {
t.Fatal(err)
}
outDir := t.TempDir()
manifest, err := OpsBackup(db, OpsBackupOptions{
Database: source, OutDir: outDir, Commit: "test-commit", Now: time.Now()})
if err != nil {
t.Fatal(err)
}
if manifest.Schema != SchemaVersion || manifest.Product != "lexgo" || len(manifest.DumpSHA256) != 64 {
t.Fatalf("manifest looks wrong: %+v", manifest)
}
if len(manifest.RowCounts) == 0 {
t.Fatal("the manifest must record row counts")
}
encoded, err := os.ReadFile(filepath.Join(outDir, "manifest.json"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(strings.ToLower(string(encoded)), "password") {
t.Fatal("the manifest must not mention credentials")
}
target := "lexgo_test_ops_restore"
if err := db.Exec("DROP DATABASE IF EXISTS `" + target + "`").Error; err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Exec("DROP DATABASE IF EXISTS `" + target + "`") })
dumpPath := filepath.Join(outDir, manifest.DumpFile)
restored, checks, err := OpsRestore(db, db, OpsRestoreOptions{
Dump: dumpPath, Database: target, Confirm: true, Now: time.Now()})
if err != nil {
t.Fatal(err)
}
if restored.Schema != SchemaVersion {
t.Fatalf("restored schema %d", restored.Schema)
}
if !OpsAllOK(checks) {
t.Fatalf("verification failed: %+v", checks)
}
after, err := OpsTableCounts(db, target)
if err != nil {
t.Fatal(err)
}
if len(after) != len(before) {
t.Fatalf("restored %d tables, source has %d", len(after), len(before))
}
for table, expected := range before {
if after[table] != expected {
t.Fatalf("table %s restored %d rows, source has %d", table, after[table], expected)
}
}
// A second restore into the same database must be refused: only an empty target is written.
if _, _, err = OpsRestore(db, db, OpsRestoreOptions{Dump: dumpPath, Database: target, Confirm: true, Now: time.Now()}); err == nil {
t.Fatal("a restore into a database that holds data must be refused without --force")
}
// The source database is untouched by the restore.
nowChecksums, err := OpsTableChecksums(db, source)
if err != nil {
t.Fatal(err)
}
if !sameChecksums(sourceChecksums, nowChecksums) {
t.Fatal("the source database changed during the restore")
}
}
+173 -3
View File
@@ -3,12 +3,15 @@ package main
import (
"context"
"errors"
"flag"
"fmt"
"log"
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"sort"
"strings"
"syscall"
"time"
@@ -65,13 +68,20 @@ func openDB() (*gorm.DB, error) {
}
func run() error {
if len(os.Args) != 2 {
return errors.New("usage: lexgo migrate|bootstrap|serve|audit-cleanup")
if len(os.Args) < 2 {
return errors.New("usage: lexgo migrate|bootstrap|serve|audit-cleanup|backup|restore|verify")
}
command := os.Args[1]
if command != "migrate" && command != "bootstrap" && command != "serve" && command != "audit-cleanup" {
known := map[string]bool{"migrate": true, "bootstrap": true, "serve": true, "audit-cleanup": true,
"backup": true, "restore": true, "verify": true}
if !known[command] {
return errors.New("unknown command")
}
// backup, restore and verify need a database that may not exist yet, so they open the client
// without selecting a schema and are handled before the application database is required.
if command == "backup" || command == "restore" || command == "verify" {
return runOps(command, os.Args[2:])
}
db, err := openDB()
if err != nil {
return err
@@ -173,6 +183,166 @@ func run() error {
return srv.Shutdown(shutdown)
}
}
// runOps handles the operations subcommands: they talk to MySQL without selecting a schema, so
// they work on a database that a restore is about to create.
func runOps(command string, args []string) error {
flags := flag.NewFlagSet(command, flag.ContinueOnError)
// Only the flags that belong to this command are registered, so -h shows a usable list.
database := flags.String("database", "", "database to act on; defaults to LEXGO_DB_NAME")
var out, dump, manifest *string
var force, confirm, skipManifest *bool
switch command {
case "backup":
out = flags.String("out", "", "directory for the dump and its manifest")
force = flags.Bool("force", false, "overwrite a non-empty output directory")
case "restore":
dump = flags.String("dump", "", "backup file to restore")
confirm = flags.Bool("confirm", false, "confirm that the restore may write to the database")
force = flags.Bool("force", false, "overwrite a target database that already holds data")
skipManifest = flags.Bool("skip-manifest-check", false, "continue without the manifest that proves the backup is intact")
case "verify":
manifest = flags.String("manifest", "", "manifest.json to compare row counts with")
}
if err := flags.Parse(args); err != nil {
return err
}
client, err := openClient()
if err != nil {
return err
}
target := *database
if target == "" {
target = os.Getenv("LEXGO_DB_NAME")
}
switch command {
case "backup":
result, err := lexgo.OpsBackup(client, lexgo.OpsBackupOptions{
Database: target, OutDir: *out, Force: *force, Commit: gitCommit(), Now: time.Now()})
if err != nil {
return err
}
fmt.Printf("backup written: %s (%d bytes)\n", result.DumpFile, result.DumpBytes)
fmt.Printf(" schema %d, commit %s\n", result.Schema, shortCommit(result.GitCommit))
fmt.Printf(" row counts %s\n", rowCountsText(result.RowCounts))
fmt.Printf(" sha256 %s\n", result.DumpSHA256)
fmt.Println(" credentials and the environment file are not included; store those in the operations vault")
return nil
case "restore":
result, checks, err := lexgo.OpsRestore(client, client, lexgo.OpsRestoreOptions{
Dump: *dump, Database: target, Confirm: *confirm, Force: *force,
SkipManifest: *skipManifest, Now: time.Now()})
if err != nil {
return err
}
for _, check := range checks {
fmt.Printf(" %s%s\n", checkPrefix(check.OK), check.Name)
}
if !lexgo.OpsAllOK(checks) {
return errors.New("the restored database did not pass verification")
}
fmt.Printf("restore of %s into %s verified\n", result.DumpFile, target)
return nil
case "verify":
checks, err := lexgo.OpsVerify(client, target, *manifest)
if err != nil {
return err
}
for _, check := range checks {
detail := ""
if check.Detail != "" {
detail = " - " + check.Detail
}
fmt.Printf(" %s%s%s\n", checkPrefix(check.OK), check.Name, detail)
}
if !lexgo.OpsAllOK(checks) {
return errors.New("verification failed")
}
fmt.Println("verification passed")
return nil
}
return errors.New("unknown command")
}
func checkPrefix(ok bool) string {
if ok {
return "[ok] "
}
return "[fail] "
}
func shortCommit(commit string) string {
if len(commit) > 8 {
return commit[:8]
}
if commit == "" {
return "unknown"
}
return commit
}
func rowCountsText(counts map[string]int64) string {
tables := make([]string, 0, len(counts))
for table := range counts {
tables = append(tables, table)
}
sort.Strings(tables)
parts := make([]string, 0, len(tables))
for _, table := range tables {
parts = append(parts, fmt.Sprintf("%s=%d", table, counts[table]))
}
return strings.Join(parts, " ")
}
func gitCommit() string {
output, err := exec.Command("git", "rev-parse", "HEAD").Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(output))
}
// openClient connects without selecting a schema, which a restore needs because the target
// database may not exist yet.
func openClient() (*gorm.DB, error) {
c := driver.NewConfig()
c.Net = "tcp"
host := os.Getenv("LEXGO_DB_HOST")
if host == "" {
host = "127.0.0.1"
}
port := os.Getenv("LEXGO_DB_PORT")
if port == "" {
port = "3308"
}
c.Addr = net.JoinHostPort(host, port)
c.User = os.Getenv("LEXGO_DB_USER")
c.Passwd = os.Getenv("LEXGO_DB_PASSWORD")
if c.User == "" || c.Passwd == "" {
return nil, errors.New("set LEXGO_DB_USER and LEXGO_DB_PASSWORD")
}
c.ParseTime = true
c.Loc = time.UTC
c.Timeout = 5 * time.Second
c.Params = map[string]string{"charset": "utf8mb4"}
db, err := gorm.Open(mysql.Open(c.FormatDSN()), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
return nil, errors.New("database connection failed; check local configuration")
}
pool, err := db.DB()
if err != nil {
return nil, err
}
pool.SetMaxOpenConns(5)
pool.SetMaxIdleConns(2)
var version string
if err = db.Raw("SELECT VERSION()").Scan(&version).Error; err != nil || !strings.HasPrefix(version, "8.") {
pool.Close()
return nil, errors.New("MySQL 8 is required")
}
return db, nil
}
func main() {
if err := run(); err != nil {
log.Fatal(err)
+90
View File
@@ -0,0 +1,90 @@
"""Unit tests for the operations helper rules that must hold without a database.
The database paths (backup, restore, verify) are exercised by the #15 drill against real MySQL;
what is checked here is the logic that decides whether an operation is allowed at all.
"""
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
import ops # noqa: E402
class VersionParsingTests(unittest.TestCase):
def test_reads_the_version_after_distrib(self):
# A client banner's first number is the protocol version, not the server version.
self.assertEqual(ops.version_of("mysql Ver 14.14 Distrib 5.7.38, for Win64 (x86_64)"), (5, 7, 38))
self.assertEqual(ops.version_of("mysql Ver 8.4.3 for Win64 on x86_64"), (8, 4, 3))
self.assertIsNone(ops.version_of(""))
self.assertIsNone(ops.version_of("no version here"))
def test_compares_client_and_server_versions(self):
self.assertLess(ops.version_of("Ver 14.14 Distrib 5.7.38"), ops.version_of("8.4.3"))
self.assertGreaterEqual(ops.version_of("Ver 8.0.36"), ops.version_of("8.4.3".replace("8.4.3", "8.0.36")))
self.assertGreaterEqual(ops.version_of("8.4.3"), ops.version_of("8.4.3"))
class DatabaseNameTests(unittest.TestCase):
def test_accepts_lexgo_names(self):
for name in ("lexgo_dev", "lexgo_prod", "lexgo_restore_drill"):
self.assertEqual(ops.validate_database_name(name, "测试"), name)
def test_refuses_system_and_unrelated_names(self):
for name in ("mysql", "information_schema", "performance_schema", "sys", "app_prod", ""):
with self.assertRaises(SystemExit):
ops.validate_database_name(name, "测试")
class DumpSafetyTests(unittest.TestCase):
"""A dump that carries CREATE DATABASE/USE would restore into the wrong schema."""
def _write_dump(self, body):
import gzip
import tempfile
handle = tempfile.NamedTemporaryFile(delete=False, suffix=".sql.gz")
handle.close()
with gzip.open(handle.name, "wb") as target:
target.write(body.encode("utf-8"))
self.addCleanup(Path(handle.name).unlink)
return Path(handle.name)
def test_flags_a_dump_that_switches_database(self):
dump = self._write_dump("-- MySQL dump\nCREATE DATABASE /*!32312 IF NOT EXISTS*/ `lexgo_dev`;\nUSE `lexgo_dev`;\nCREATE TABLE t (id INT);\n")
self.assertTrue(ops.dump_targets_instead_of_source(dump))
def test_flags_a_dump_with_use_only(self):
dump = self._write_dump("-- MySQL dump\nUSE `lexgo_dev`;\nCREATE TABLE t (id INT);\n")
self.assertTrue(ops.dump_targets_instead_of_source(dump))
def test_accepts_a_table_only_dump(self):
dump = self._write_dump("-- MySQL dump\nDROP TABLE IF EXISTS `lexgo_terms`;\nCREATE TABLE `lexgo_terms` (id INT);\nINSERT INTO `lexgo_terms` VALUES (1);\n")
self.assertFalse(ops.dump_targets_instead_of_source(dump))
class ManifestTests(unittest.TestCase):
def test_manifest_keys_never_name_a_credential(self):
"""The manifest is written to disk, so its field names are checked, not its prose."""
import re
source = (ROOT / "scripts" / "ops.py").read_text(encoding="utf-8")
block = source[source.index(" manifest = {"):source.index('(out_dir / "manifest.json")')]
keys = re.findall(r'^\s+"([a-z_]+)":', block, re.M)
self.assertIn("schema_version", keys)
self.assertIn("row_counts", keys)
for key in keys:
for banned in ("password", "secret", "token", "credential"):
self.assertNotIn(banned, key.lower(), key)
def test_audit_tables_declare_no_sensitive_column(self):
"""The integrity check must keep naming the columns that may not appear in the audit logs."""
self.assertTrue({"password", "token", "body", "content"} <= ops.AUDIT_BANNED_COLUMNS)
self.assertIn("lexgo_login_logs", ops.TABLES)
self.assertIn("lexgo_operation_logs", ops.TABLES)
if __name__ == "__main__":
unittest.main()
+4
View File
@@ -83,6 +83,10 @@
{
"page": "Go-Analysis-Alternative",
"path": "docs/linguacafe-go-analysis.md"
},
{
"page": "Deployment-and-Operations",
"path": "docs/11-deployment-and-operations.md"
}
]
}