Compare commits

..
Author SHA1 Message Date
ila f2fdf405a9 style: 放大章节阅读区与编辑区,使其随窗口宽度自适应
阅读区:正文列上限由固定 820px 改为 min(1040px, 92vw);打开查词面板时的容器由 1120px 改为
min(1240px, 96vw),大屏上正文列不再比不开面板时更窄。编辑区:章节编辑对话框由固定 620px 改为
min(1180px, 94vw),正文输入框最小高度改为 62vh(行数仅作初始值),书籍对话框 460px 改为 560px。

新增验证:reading 用例检查 768/1024/1280 三档宽度不横向溢出,并在 1280 下断言正文列宽 > 820px;
edit 用例断言章节对话框宽 > 900px、正文输入框高 > 400px 且不溢出。手机(≤760px)的底部抽屉样式未改动。

测试:学习端 159 单测、26 项 e2e、vue-tsc、build 全部通过。无长期文档影响(仅样式数值)。
2026-09-16 23:33:50 +08:00
ila 42945dec0a docs: 记录 #32、#40、#42 的验收结果与镜像 (#32 #40 #42)
- #40 与 #42 已由用户验收;PR #41 与 #43 以 rebase-ff 合入 main,#32 因分支携带仅改镜像的
  过程提交改以 squash 合入(f565c1c),三者均未改写分支历史。
- Wiki:Home 的两处"尚未验收"改为已验收文案,Project-Profile 追加三条已确认决策;
  会话规则数字、英汉词典与音标规则此前已发布。
- README 与 AGENTS 记录最终 schema v12、音标来源分层、30 天会话代价与 #32 的合并方式。
- docs/ 由 harness.py sync 导出,已通过 sync --check 与 harness check --strict。
2026-09-16 21:51:53 +08:00
ilaandila f565c1ca26 fix: 编辑回到曾经用过的正文版本复用该版本的任务 (#32)
编辑正文回到曾经用过的版本时,复用该内容版本已有的处理任务行(按 owner_id + chapter_id + content_sha256 匹配),避免重复插入触发唯一键错误。测试:Go 集成测试 A→B→A→B 全链路通过。

Co-authored-by: QiuSW <ila2002@qq.com>
2026-09-16 21:50:25 +08:00
ilaandila 4e4fb85d2f fix: 登录有效期由 8 小时改为 30 天 (#42)
- 有效期提取为具名常量 SessionLifetime(绝对有效期,不滑动续期),学习端与管理端共用
  同一登录接口,一处生效;数据库结构与接口字段不变,旧会话按各自 expires_at 自然过渡。
- 过期断言改为按常量计算(临期仍可用、超过后被拒),新增测试锁定 30 天常量、
  登录响应与库内 expires_at 一致、库内只存 SHA-256 摘要。
- 撤销规则不变:退出撤销当前会话,改密码/停用/重置撤销该账号全部会话。
- Wiki 已在线上更新(会话规则数字、验证说明、Home/Project-Profile 记录);镜像导出留到
  验收时执行,避免把尚未合入的 #40 段落带进本分支。
2026-09-16 21:42:38 +08:00
ila a412e000c0 docs: 记录英汉词典与规范音标的架构、规则与验证 (#40) 2026-09-16 13:27:17 +08:00
ila b1b2e6c6fe feat: 英汉词典与规范音标、多词典并存 (#40)
- ECDICT 常用子集(82,721 条,含中文释义)与 CMUdict ARPAbet→IPA 转写音标,
  由 scripts/dict_prepare.py 从 sha256 pin 的源显式准备,运行时不联网。
- schema v12:放开 lexgo_dictionaries 单行约束并增加 provider 列,英英与英汉词典
  可分别启用;查词合并时中文释义在前、英英释义随后。
- 音标只取可验证来源:优先 CMUdict 转写,否则保留 ECDICT 记法并标注来源,
  两者都不可靠时不显示;含 ^ 等丢失首音的记法整条丢弃。
- 屈折形经 WordNet lemma 解析后回查中文词典,但不把词目音标复制到屈折形上。
- 管理端可区分两种词典并分别启停;学习端查词面板显示音标与中文释义。
2026-09-16 13:17:26 +08:00
ila c293a41714 docs: record issue 37 acceptance and merge (#37)
- #37 已由用户验收;PR #38 已 fast-forward-only 合入 main,merge commit 728f7d6
- Wiki:Architecture 与 Product-Requirements 标记 #37 已验收,Local-Development 追加验收后
  最终状态(schema v11、未实现的两项作者显示),Home 替换为已验收文案,Project-Profile
  追加当前工程状态
- README 与项目档案记录最终 schema 版本、作者语义、未实现项与对话框样式教训
- docs/ 由 harness.py sync 导出,revision:Architecture 3cf18f98、Product-Requirements daf31e0d、
  Local-Development 7c41d62a、Home 3af7d779、Project-Profile 39adde48
2026-09-16 10:26:38 +08:00
ila 728f7d6c1e feat: 书籍页精简封面、可编辑书名与作者、标题旁显示作者 (#37)
- schema v11:lexgo_books 增加 author VARCHAR(120) NOT NULL DEFAULT '',复用 v10 的条件加列
  助手(改名 addAuthorColumn,表名取自本文件常量),保持迁移可重放;新建库 v3 语句也带该列
- PATCH /api/v1/books/:id 接受可选 author(省略保留、空串清空、trim、≤120),BookSummary 与
  BookRef 返回它;「编辑书名」改为「编辑书籍」,对话框同行编辑书名与作者
- 书籍页封面压成一行紧凑控件(预览+上传/替换/移除),去掉「封面」「书籍封面」规格与
  「音频与插图按章节设置」等文字,把高度让给章节列表;书名右侧显示书级作者(未设置不显示)
- 测试:Go 91 项(新增书级作者往返与 v10→v11 迁移用例)、学习端 157 单测与 26 项 E2E
- 真实链路:封面行高 68px、删掉的四段文字不再出现、作者显示在书名同一行右侧且刷新后保留
- 文档:Architecture / Business-Rules / Local-Development / Requirements / Home 同步
2026-09-15 23:10:36 +08:00
ila 3fe9bcb058 feat: 章节增加可选作者字段,编辑对话框更紧凑 (#37)
- schema v10:lexgo_chapters 增加 author VARCHAR(120) NOT NULL DEFAULT '';由 Go 条件步骤
  addChapterAuthorColumn(先查 information_schema 再 ALTER)在语句列表之后执行,保持
  「部分迁移可重试 / 回退标记后可重新升级」,新建库的 v3 语句也带该列
- 章节编辑接受 author(可选、trim、≤120 字符、空串清空),仅改作者不重新处理章节;
  ChapterSummary 与 ChapterSource 都返回它,阅读页在标题下显示非空作者
- 对话框:标题与作者改成「标签在左、输入框在右」的同行排版,正文编辑框加高到 18 行,
  插图与音频压缩为各一两行(规格写在下方),时机说明合并为一行
- 测试:Go 89 项(新增作者往返与 v9→v10 迁移用例)、学习端 157 单测与 26 项 E2E
- 真实链路:标签同行偏差 <8px、正文高度 415px、保存作者后阅读页显示、清空后消失
- 文档:Architecture / Business-Rules / Local-Development / Requirements / Home 同步
2026-09-15 22:49:56 +08:00
ila c22f9c3f06 fix: 导入 Element Plus 对话框与确认框样式,弹窗不再是文档流块 (#37)
- main.ts 补上 dialog 与 message-box 的样式导入:自 #10 起 ElDialog/ElMessageBox 一直
  缺少样式,退化成列表后面的普通块(用户报告「编辑区显示在章节列表下面」)
- 新增 e2e/overlay.ts:expectModalOverlay / expectMessageBoxOverlay 断言弹窗位于
  .el-overlay(position: fixed)、在视口内且横向不溢出
- attachments.spec.ts(章节编辑对话框、插图弹窗)与 edit.spec.ts(书名对话框、删除确认)
  都改用该断言,堵住「只看可见性、不看是否浮层」的验证盲区
- 真实链路确认:对话框遮罩为 rgba(0,0,0,0.5)、居中于视口;确认框同样为固定浮层
- 文档:Local-Development 记录缺陷、根因、修复与新增回归断言
2026-09-15 22:22:50 +08:00
ila 771c3df976 feat: 章节附件并入章节编辑对话框 (#37)
- 章节列表每行只保留「编辑」;移除「附件」按钮与独立的附件对话框
- 章节编辑对话框同时编辑标题、正文、本章插图与本章音频,并明确写出生效时机:
  标题与正文点「保存章节」后生效,插图与音频在选中文件后立即上传、取消不撤销
- 无插图/音频的章节仍显示「未设置」,后端口与 schema 无变化(仍 v9)
- 测试:157 单测与 26 项 E2E 通过(新断言:章节行只有一个按钮、对话框含四类控件与时机说明、
  对话框内上传后仍可用保存按钮提交文本);真实链路确认按钮数为 1、上传即时生效、取消后阅读页正常
- 文档:Architecture / Business-Rules / Local-Development / Requirements / Home 同步
2026-09-15 22:14:21 +08:00
ila 3ab26afe4a feat: 章节插图改为阅读页缩略图与弹窗看原图 (#37)
- 书籍页章节列表去掉插图列,并不再为列表预取图片(省掉每章一次请求)
- 阅读页把插图换成高 120px 的缩略图按钮(可 Tab、回车/Space 打开),点击后在对话框里
  按原图显示(最大 min(88vw,1200px) × 78vh,保持比例),Esc 关闭后正文位置不变
- 无插图的章节仍不显示占位;后端与 schema 无变化(仍 v9)
- 测试:157 单测与 26 项 E2E 通过(新断言:列表无 <img> 且不请求 /illustration、缩略图是按钮、
  弹窗内为对象 URL 图片);真实链路验证列表无图、缩略图 ≤130px、弹窗原图 naturalWidth>0
- 文档:Architecture / Business-Rules / Local-Development / Requirements / Home 同步修订
2026-09-15 21:36:00 +08:00
ila 0fd661e061 feat: 音频与插图改为章节级,封面维持书级 (#37)
- schema v9:lexgo_chapter_attachments(chapter_id+kind 唯一,MEDIUMBLOB)与
  lexgo_chapter_playback_positions(owner_id+chapter_id),并用幂等语句清空书级音频历史行
- 书级音频与播放位置接口下线(路由不再注册);封面仍是书级且行为不变
- 章节级接口:POST/DELETE/GET /chapters/:id/{audio,illustration}、PUT /chapters/:id/playback;
  章节列表与阅读器响应带 illustrationVersion/audioVersion/playbackSeconds(始终存在)
- 复用 #21 的类型嗅探、尺寸校验、ServeContent Range/ETag 包装与“先校验后写入”流程
- 学习端:封面区块收窄、章节行新增缩略图与「附件」入口、独立的章节附件对话框、
  阅读页正文上方插图与章级播放器(audioChapterId 防止复用上一章音频)、切换章节上报位置
- 测试:Go 87 项(含 v8→v9 迁移与回退、书级接口已下线、章级附件集成)、学习端 157 单测与
  26 项 E2E、真实 API 52 项、#15 恢复演练第三次 22 项
- 文档:Architecture / Business-Rules / Local-Development / Requirements / Home /
  Deployment 改为章级口径
2026-09-15 21:08:43 +08:00
ila 8a25b7be8b docs: record issue 15 and 21 acceptance and merge (#15, #21)
- 两个 PR 已按顺序 fast-forward-only 合入 main:#35(#15 自托管交付)、#36(#21 附件)
- Wiki:Architecture 合并重复的 #15 段落并标记 #15/#21 已验收,Requirements 与 Home 同步,
  Project-Profile 追加当前工程状态
- README 反映验收结果,并记录章级音频与插图的新工单
- docs/ 由 harness.py sync 导出,revision:Architecture 08472d2c、Product-Requirements 2d656357、
  Home eef77f39、Project-Profile 2599afbb
2026-09-15 20:25:44 +08:00
ila ab23ff60e5 feat: 书籍封面与音频附件、播放器与播放位置 (#21)
- schema v8:lexgo_book_attachments(按 book_id+kind 唯一,bytes MEDIUMBLOB)与
  lexgo_playback_positions(owner_id+book_id 主键),只用可重放 DDL,外键级联删书
- 后端:按文件头 magic bytes 判定类型(MP3 / JPG / PNG / WebP,含 WebP 维度解析),
  音频 ≤20 MiB、封面 ≤2 MiB 且 ≤4096²;上传/替换/移除;binaryResponse 交给
  http.ServeContent 提供 Range 206、416、ETag 与 304;播放位置 upsert,替换音频时重置
- 学习端:带凭据的 fetch + 对象 URL(令牌不进 URL),书库封面、书籍页「音频与封面」
  区块、阅读页常驻播放器(播放/暂停、进度、0.75–1.5 倍速、错误重试、位置上报)
- 测试:Go 86 项(新增附件单测/集成与 v7→v8 迁移)、学习端 157 单测与 26 项 E2E
- 顺带修掉 e2e/phrase.spec.ts 的偶发失败:重试条件改为断言期望词数、重读坐标、关面板后重试
- 文档:Architecture / Business-Rules / Local-Development / Requirements / Home /
  Deployment-and-Operations(备份范围含附件与体积提示)
2026-09-15 18:39:56 +08:00
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
ila a9952b3516 feat: 完成章节并查看个人基础进度 (#13)
- schema v7 新增 lexgo_chapter_progress(chapter_id 主键、read_sha256 内容版本门控、
  外键级联到章节/书籍/账号),仍然只用可重放的 CREATE TABLE IF NOT EXISTS
- POST /api/v1/chapters/:id/complete:只记已读、不改词语状态;只有 ready 章节可标记;
  重复调用返回同一行并带 duplicate,不移动已读时间也不重复计数;正文新版本后该章
  回到未读但保留记录,重新标记更新同一行
- GET /api/v1/progress:已读/可阅读章节、已知/学习中/新词/忽略、已保存词条、待复习
  与每本书的已读进度;到期数量与复习队列共用 dueTermsQuery 与同一时钟
- ChapterSummary 增加 readAt,书籍列表、阅读器与编辑响应都会解析
- 学习端新增 /progress 页面与导航、stores/progress.ts,阅读器新增显式「标记本章已读」
  区块,书籍页显示已读标记
- Wiki 记录 Architecture、Business-Rules、Local-Development 与需求更新
2026-09-15 12:31:53 +08:00
ila f2de395cd9 docs: record issue 12 acceptance and merge (#12)
- Wiki:Home、Architecture、Product-Requirements 与 Project-Profile 标记 #12 已验收
- README 与项目档案反映 #12 已通过验收,
2026-09-15 11:52:15 +08:00
ila 000d7e0dbf docs: 记录 #12 已确认的词汇库口径 (#12) 2026-09-14 23:48:25 +08:00
ila 3ebec5b11b feat: 词汇库搜索、筛选与编辑 (#12)
- GET /api/v1/terms 列表:查询键白名单、page/limit 边界(默认 20、上限 100)、
  排序 updated_at DESC;搜索同时匹配身份键、显示原文与个人释义,转小写后比较(term 为
  二进制排序规则),%/_/\ 按字面值转义;status 与 kind 可组合
- PATCH /api/v1/terms/:id 编辑:复用释义/例句与状态/等级校验,身份不可编辑;
  只有状态或等级变化才重排复习时间,历史作答记录与计数保留
- 学习端新增 /vocab 路由与导航、stores/vocabulary.ts、VocabView.vue(搜索、筛选、
  分页、空态与无结果清除、编辑对话框含 1~7 等级选择器、筛选与页码写入 URL)
- 释义/例句/状态/等级抽成 TermFormFields.vue,与阅读器面板共用,避免规则漂移
- Wiki 记录 Architecture、Business-Rules、Local-Development 与需求更新
2026-09-14 23:46:42 +08:00
ila 329ea7336a docs: record issue 11 acceptance and merge (#11)
- Home、Project-Profile、Architecture-and-Code-Map、Product-Requirements-Overview
  记录 #11 于 2026-09-14 通过用户验收,PR #30 已 fast-forward-only 合入 main
- 用户确认的 D3 存储方式(身份键派生 kind/词数、不新增列)随之记入长期文档;
  功能提交 bd77aa0 与审核整改提交 a658fa6 均与用户验收版本一致
2026-09-14 23:00:02 +08:00
ila a658fa6fd1 fix: 按审核结论补强短语实现与测试 (#11)
- adjustRange 改变范围时同时清空短语链接与已加载条目:调整端点后不再声称「已保存」,
  已输入文本保留,保存按当前范围确定的身份写入(可能更新另一条,但绝不重复)
- 新增 TestWordKeysNeverContainSpaces:显式锁定「词片段不含空白」这一不变量,
  termKind/termWordCount 完全依赖它,此前只有间接覆盖
- e2e 断言超时提高到 15s:11 个用例共用开发服务器并行运行时,首屏模块加载可能超过
  默认 5s 造成偶发失败(产品行为未变,连续两次并行全量运行均通过)
2026-09-14 22:47:14 +08:00
ila b1b76cfb7c docs: 记录 #11 已确认的短语口径 (#11) 2026-09-14 22:23:13 +08:00
ila bd77aa0a46 feat: 选择连续短语,保存并加入到期复习 (#11)
- 短语与单词共用 lexgo_terms:身份键为按序规范化词形以空格连接,单词键不含空格,
  因此 kind 与词数由身份键派生,不需要新列或第二套复习逻辑
- POST /api/v1/phrases 由服务端从本人 ready 章节推导词序列与身份,切进单词的范围 400;
  章节 tokens 增加 phrases 区间,队列项增加 kind/wordCount
- 跨章节匹配按连续词形比对,重叠取最左最长;短语高亮覆盖内部单词但不修改单词数据
- 学习端新增 readerRange 纯函数层与 useTextSelection(原生拖选 + 手机手柄,不拦截
  touchmove),面板提供短语标题与按词调整端点的按钮,复习卡把整段短语挖成一个空
- Wiki 记录 Architecture、Business-Rules、Local-Development 与需求更新
2026-09-14 22:21:51 +08:00
ila 1319c56cf8 docs: record issue 10 acceptance and merge (#10)
- Home、Project-Profile、Architecture-and-Code-Map、Product-Requirements-Overview
  记录 #10 于 2026-09-14 通过用户验收,PR #29 已 fast-forward-only 合入 main
- 功能提交 35ed692 与用户验收版本一致;本单没有数据库结构变化,schema 仍为 v6
2026-09-14 21:38:22 +08:00
ila 1d19b2b92f docs: 记录 #10 已确认的编辑与删除口径 (#10) 2026-09-13 23:21:55 +08:00
ila 35ed692c4b feat: 编辑和删除本人书籍与章节 (#10)
- PATCH /books/:id 改名、GET /chapters/:id/source 读取编辑用原文(任意状态)、
  PATCH /chapters/:id 改标题与正文、DELETE /books/:id 与 DELETE /chapters/:id
- 版本门控:任务只在 job.content_sha256 与章节版本一致时才能影响章节;过期版本任务
  被标为 superseded 且完全不触碰章节,认领与恢复扫描跳过并作废它们,重试旧版本任务 409
- 只有正文变化才重新处理:重复保存或改回原内容不新建任务;只改标题不改状态
- 删除在事务内硬删除并沿用外键级联,章节删除后重排序号;个人词条、复习排期与作答记录保留
- 处理中删除章节后,在途任务不再发布也不报错;并发删除同一章由书籍行锁序列化
- 学习端:书名与章节编辑对话框、删除确认弹窗、章节行编辑入口、书库删除提示
- Wiki 记录 Architecture、Business-Rules、Local-Development 与需求更新
2026-09-13 23:20:52 +08:00
ila 65b50d5038 docs: record issue 9 acceptance and merge (#9)
- Home、Project-Profile、Architecture-and-Code-Map、Product-Requirements-Overview
  记录 #9 于 2026-09-13 通过用户验收,PR #28 已 fast-forward-only 合入 main
- 功能提交 61d3f63 与用户验收版本一致;本单没有数据库结构变化,schema 仍为 v6
2026-09-13 22:36:46 +08:00
ila fb256d4916 docs: 记录 #9 已确认的 TXT 导入口径 (#9) 2026-09-11 23:38:38 +08:00
ila 61d3f63c69 feat: 上传 TXT 文件,校验编码后导入本人书库 (#9)
- POST /api/v1/books/upload 与 /api/v1/books/:id/chapters/upload:multipart 上传,
  字段白名单、未知或重复字段拒绝、非 multipart 拒绝、单槽并发门忙时 429
- 只接受 UTF-8(可选 BOM 剥离且不进入原文),UTF-16 按 BOM 识别并给出针对性提示,
  非法字节整体拒绝、不使用替换字符;2 MiB 字节上限之后仍套用单章 100000 码点上限
- 文件只在内存中解码,不创建临时文件;客户端文件名不参与任何路径也不入库
- 解码后交给现有 PasteBook/PasteChapter,分章、任务幂等与崩溃恢复与粘贴完全一致
- 学习端导入页新增「粘贴文本 / TXT 文件」来源切换与客户端预检,session.request 支持 FormData
- gofmt 整理 #8 引入的 import 顺序与空行
- 同步 Architecture-and-Code-Map、Business-Rules-and-Glossary、
  Local-Development-and-Verification、Product-Requirements-Overview 与 Home
2026-09-11 23:37:13 +08:00
ila cd2b893bba docs: record issue 8 acceptance and merge (#8)
- Home、Project-Profile、Architecture-and-Code-Map、Product-Requirements-Overview
  记录 #8 于 2026-09-11 通过用户验收,PR #27 已 fast-forward-only 合入 main
- 功能提交 0328505 与审核整改提交 3258498 均与用户验收版本一致;本次只更新
  验收状态文档与镜像
2026-09-11 22:55:57 +08:00
ila 85b8e24429 docs: 记录 #8 审核整改后的复习口径 (#8) 2026-09-11 22:48:14 +08:00
ila 325849816e fix: 整改 #8 审核问题 R1~R3 (#8)
- R2 并发同键作答:取得词条行锁后加锁复查答案键,stale 插入遇到唯一键冲突转为返回
  已记录结果,不再返回 500;新增两个 goroutine 同键提交的集成用例
- R3 排期:只有新建或状态/等级实际变化才移动 due_at,编辑释义与例句保留原排期,
  逾期词条不会被挤出当天队列
- R3 附带发现:保存未提及等级时保留已获得的等级,阅读器面板不再把 4 级词重置为 1 级
- R1 契约:作答响应 result 只取 applied/stale,另加 duplicate 标记,重放返回首次结果;
  客户端按首次结果计数,本轮只解决卡片而没有新计分时显示完成页而不是空队列
- R4/R5:stale 与重放分别给出角色为 status 的提示,answerId 作用域注释与实现一致
- Wiki 更新 Business-Rules-and-Glossary、Architecture-and-Code-Map、
  Local-Development-and-Verification 并同步镜像
2026-09-11 22:46:23 +08:00
ila ec5ec2db35 docs: 记录 #8 已确认的复习决策口径 (#8) 2026-09-11 20:54:55 +08:00
ila 0328505b77 feat: 完成到期单词复习与幂等答题 (#8)
- schema v6 新增 lexgo_term_reviews 与 lexgo_review_answers:语句都是可重试的加法迁移,
  既有已保存词汇按 created_at 立即进入队列;旧二进制回到 v5 仍可写入个人词条
- 固定间隔表 1/2/4/7/15/30/60 天:答对升级封顶 7、答错降级最低 1、再学一次不改等级,
  答错与再学立即回到本轮;已知与忽略不入队
- GET /api/v1/reviews/queue 与 POST /api/v1/reviews/:termId/answers:按 answerId 去重、
  按 expectedDueAt 判定过期标签页,重复提交与并发都不重复更新次数和间隔
- 学习端新增 /review 路由与到期复习入口,正面挖空例句、答案面评分、完成页与空队列页
- 同步 Architecture-and-Code-Map、Business-Rules-and-Glossary、
  Local-Development-and-Verification、Product-Requirements-Overview 与 Home
2026-09-11 20:54:17 +08:00
ila 527d8af0c4 docs: record issue 7 acceptance and merge (#7)
- Home、Project-Profile、Architecture-and-Code-Map、Product-Requirements-Overview
  记录 #7 于 2026-09-11 通过用户验收,PR #26 已 fast-forward-only 合入 main
- 功能提交 8c0946a 与用户验收版本一致;本次只更新验收状态文档与镜像
2026-09-11 20:29:33 +08:00
ila a5a8c3c72d docs: 记录 #7 已确认的个人词条决策 (#7) 2026-09-11 16:34:54 +08:00
ila 8c0946af9f feat: 保存个人词义与状态,并在其他章节同步显示 (#7)
- schema v5 新增 lexgo_terms:身份为学习者+语言+规范化词形,唯一键保证
  重复保存只更新同一条记录,不产生冲突副本
- POST /api/v1/terms 幂等保存并返回 created,GET /api/v1/terms/:id 仅本人可读,
  章节 tokens 为 word 片段附带 term:{id,status,level}
- 状态与等级边界:新词/学习中/已知/忽略,只有学习中带 1~7 级,其余必须为 0,
  并由数据库检查约束守住
- 学习端面板可编辑释义、例句与学习状态,正文按状态高亮;打开已保存词先读取原内容,
  读取失败时禁用保存,切换账号或退出后清理表单、状态与高亮
- 同步 Architecture-and-Code-Map、Business-Rules-and-Glossary、
  Local-Development-and-Verification、Product-Requirements-Overview 与 Home
2026-09-11 16:32:04 +08:00
ila 33182e584b docs: record issue 6 acceptance and merge (#6) 2026-09-11 14:47:22 +08:00
104 changed files with 20540 additions and 308 deletions
+22
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. 永久规则
@@ -281,3 +286,20 @@ MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才
- #3 独立小样位于 `spikes/english/`,使用 `.local/nlp-venv/Scripts/python.exe`(3.12.12)运行;固定 spaCy 3.8.7、英语模型 3.8.0、NLTK 3.9.2、WordNet 3.0。资源仅显式准备时下载,摘要见 resources.json。不得把本机无账号的实验接口用于正式学习端;后续集成仍需 Go 授权、数据归属和任务设计。原文不归一化,位置区分 cp/UTF-8/UTF-16,lemma 不自动合并学习状态。
- #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-16 用户验收 #37(六轮迭代后):章节级音频与插图、阅读页插图缩略图+弹窗看原图、书籍页章节行只有一个「编辑」并在同一对话框内编辑标题/作者/正文/插图/音频(文本点保存后生效、文件选中后立即上传)、书籍页封面压成一行紧凑控件、书名右侧显示书级作者、「编辑书籍」可改书名与作者。**schema 最终为 v11**:v9 章级附件(书级音频接口下线)、v10 章节作者、v11 书籍作者;v10/v11 用带守卫的条件加列(先查 information_schema 再 ALTER,位于语句列表之后),保持「部分迁移可重试、回退标记后可重新升级」。书籍作者与章节作者互相独立(都不继承)、均为可选自由文本(≤120 字符、留空即清空)。**未实现**:书库卡片显示书级作者、章节作者回落显示书级作者;真机听感与浏览器真实解码播放仍未验证。
- 2026-09-16 教训(#37):`main.ts` 漏引入 Element Plus 的 dialog/message-box 样式导致自 #10 起全站对话框退化为文档流块;「只看可见性、不看是否浮层」是验证盲区,现以 `learner/e2e/overlay.ts` 的 expectModalOverlay/expectMessageBoxOverlay 断言浮层定位。
- 2026-09-15 用户确认 #37 章级附件口径:封面维持**书级**;音频与插图改为**章节级**(一章各一张,插图固定显示在该章正文开头、可复用为章节列表缩略图);播放位置粒度改为**账号+章节**(播放中每 5 秒、暂停、离开或切换章节时上报,替换/移除音频时重置);类型与上限沿用 #21(MP3 ≤20 MiB;图片 JPG/PNG/WebP ≤2 MiB 且 ≤4096×4096,按内容判定);读取需会话并支持 Range/206、416、ETag/304;**书级音频接口与书级播放位置接口下线**,schema v9 新增 `lexgo_chapter_attachments` 与 `lexgo_chapter_playback_positions` 并用幂等语句清空书级音频历史行(测试期数据,不迁移、回退找不回);删除章节级联清空章级附件与位置。范围外:多图与图文混排、缩略图墙、多音轨、字幕同步、转写、TTS、转码、自动搜图、公开分享。
- 2026-09-15 用户确认 #21 附件口径:每本书可选**一张封面**(JPG/PNG/WebP,≤2 MiB,≤4096×4096)与**一段 MP3**(≤20 MiB),**按文件内容 magic bytes 判定类型、不看扩展名**;附件与播放位置**存 MySQL**(dump 即完整备份、属主校验一致、删书无孤儿文件);读取需会话并支持 **HTTP Range(206)**与 ETag/304,界面用带凭据的 fetch + 对象 URL(**令牌不进 URL**,代价是无渐进式流式播放);不自动播放、**不与文本同步**、不解析时长;播放位置按账号+书籍保存(播放中每 5 秒与暂停/离开上报),**替换或移除音频时重置**;跨账号一律 404;schema v8 新增两张表(可重放 DDL,回退写回版本号 7)。范围外:多音轨/分章音轨、字幕同步、逐句跟读、转写、TTS、转码、自动搜图、在线音频地址、公开分享。**浏览器是否真实解码播放尚未验证**(测试中媒体元素被桩替换),真机听感待人工确认。
- 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)不在范围内。
- 2026-09-11 用户确认 #11 短语口径:短语与单词**共用 `lexgo_terms`**,身份键为按阅读顺序的规范化词形以单个空格连接(单词键不含空格),因此 `kind` 与词数由身份键派生,**不新增列、无迁移**;范围两端对齐整词、内部标点与换行保留但不参与身份比较,2~12 词、键 ≤128 字符、片段 ≤191 字符,切进单词的范围 400。跨章节匹配按连续词形比对,重叠取**最左最长**;短语高亮覆盖内部单词但**不修改单词数据**,点击已保存短语优先打开短语面板;失效引用回退=高亮消失但词条与复习排期保留。短语进同一到期队列与同一套幂等作答,复习卡把整段短语挖成一个空;选择用原生拖选与手机系统手柄,不拦截 touchmove。同义形式合并、词性消歧、跨书移动、批量编辑(#12)与真机手感不在范围内。
- 2026-09-11 用户确认 #10 编辑与删除口径:可改名、改章节标题、编辑章节正文;**只有正文变化才重新处理**,重复保存或改回原内容不新建任务,只改标题不改状态。版本键是 `content_sha256`:任务只在与章节版本一致时才能影响章节,过期版本任务标为 `error_reason=superseded` 且**完全不触碰章节**(认领、恢复扫描、重试都按版本裁决);存储文本重算 SHA 与存储 SHA 不一致时按 `content_changed` 失败。删除为事务内硬删除 + 外键级联,删章后重排序号;个人词条、复习排期与作答记录**不随删除清理**。回收站/撤销、批量操作、章节跨书移动与语言变更不在范围内。编辑器行尾归一为 LF 是已知边界。
- 2026-09-11 用户确认 #9 TXT 导入口径:只接受 UTF-8(允许可选 BOM,解码时剥离且不进入原文),非法字节整体拒绝、不使用替换字符;UTF-16 按 BOM 识别后明确拒绝,GB18030 等按非法 UTF-8 拒绝。文件字节上限 2 MiB,之后仍套用单章 100000 码点上限;换行与空白不归一化。文件只在内存中解码、不创建临时文件,客户端文件名不参与任何路径也不入库。解码后交给既有 `PasteBook`/`PasteChapter`,分章(一次提交一章)、`requestId` 幂等与任务恢复与粘贴一致;不改 schema。EPUB/PDF/字幕、UTF-16 转码、按空行自动分章与断点续传不在范围内。
- 2026-09-11 用户确认 #8 到期单词复习决策表:固定间隔表 1/2/4/7/15/30/60 天,答对升级封顶 7、答错降级最低 1、再学一次不改等级,答错与再学立即回队;已知/忽略不入队,新保存的词立即到期,显式「学习中 level N」排 now+间隔[N];只有新建或状态/等级实际变化才移动复习时间,编辑释义或例句保留原排期,保存未提及等级时保留已获得等级。到期判定用 UTC 绝对时刻(`due_at ≤ now`),不引入本地日边界。作答按 `answerId` 去重并以 `expectedDueAt` 判定过期标签页,重复提交、网络重发与双标签页都不得重复更新次数与间隔(作答响应 `result` 只取 applied/stale,重放另用 `duplicate` 标记并返回首次结果);`correct_count` 只计答对,`wrong_count` 计答错与再学。短语复习归 #11,进度统计归 #13,不做策略配置 UI(X11)、练习模式(X08)与 FSRS。
- 2026-09-16 用户验收 #40 英汉词典与规范音标:ECDICT 常用子集(约 8.2 万条,MIT)提供中文释义、CMUdict(BSD-2)音标按固定 ARPAbet→IPA 映射转写,WordNet 词表用于裁剪子集;三者 sha256 pin 在 `server/zh-dictionary-resource.json`,`scripts/dict_prepare.py` 显式准备(产物不入库、运行时不联网,整包存 MySQL 因而包含在 #15 备份内)。**schema 最终为 v12**(放开 `lexgo_dictionaries` 单行 CHECK、增加 `provider` 列,两步带条件守卫、可重放;旧二进制见 v12 拒绝启动)。音标来源必须可追溯(`cmudict` 为 IPA 转写、`ecdict` 仅字符级规范化),不可靠就不显示(`grok` 属此类,`^` 丢失首音整条丢弃)。查词中文释义在前、英英释义随后,屈折形经 WordNet lemma 回查中文但不复制词目音标;两本词典可分别启停。许可文件随仓库保存,词典归档不提交。
- 2026-09-16 用户验收 #42 登录有效期:学习端与管理端共用同一登录接口,有效期由 8 小时改为 **30 天**(具名常量 `SessionLifetime`,绝对有效期、不滑动续期)。已知代价:令牌泄露窗口放大约 90 倍;设备/会话列表与「退出其他设备」未包含,如需另开工单。撤销规则不变(退出撤销当前会话,改密码/停用/重置撤销该账号全部会话,服务端只存 SHA-256 摘要)。
- 2026-09-16 用户验收 #32 缺陷修复:编辑正文回到曾用版本时复用该内容版本已有的处理任务行(按 `owner_id + chapter_id + content_sha256` 匹配),避免唯一键冲突;因分支带有仅改镜像的过程提交,PR #39 以 squash 方式合入 main(`f565c1c`),未改写分支历史。
+5 -4
View File
@@ -2,20 +2,21 @@
面向自托管场景的阅读式语言学习项目,规划提供内容导入、阅读查词、词汇与短语、复习和实例管理。
已确认:**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 英语词典与点词查义已实现,待用户验收。词语状态与复习仍未实现。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、#18 已验收,原规划中的 #5 已完成;剩余 #6~#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 与 #37(章级音频、章节插图、章节/书籍作者)已验收;#32(编辑复用版本缺陷)、#40(英汉词典与规范音标)与 #42(登录有效期 30 天)已验收;#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)
## 本地工程基础
运行前在 MySQL 8 中准备项目专用空库,复制 `.env.example` 为忽略的 `.env.local` 并填写本机连接及初始管理员密码。首次执行 `python scripts/server.py migrate`、`python scripts/server.py bootstrap`;随后执行 `python scripts/server.py serve`。两端分别通过 `npx --yes pnpm@9.15.1 --dir admin dev`、`npx --yes pnpm@9.15.1 --dir learner dev` 启动,首次需安装锁定依赖。
运行前在 MySQL 8 中准备项目专用空库,复制 `.env.example` 为忽略的 `.env.local` 并填写本机连接及初始管理员密码。首次执行 `python scripts/server.py migrate`、`python scripts/server.py bootstrap`(等价纯二进制路径:`./server/lexgo migrate` / `bootstrap`);随后执行 `python scripts/server.py serve`。两端分别通过 `npx --yes pnpm@9.15.1 --dir admin dev`、`npx --yes pnpm@9.15.1 --dir learner dev` 启动,首次需安装锁定依赖。书籍封面与书籍作者、章节音频/插图与章节作者、以及播放位置都存于数据库,包含在 `scripts/ops.py backup` 与 `./server/lexgo backup` 的备份范围内;schema 为 v11,需要显式迁移。
本地入口:学习端 http://127.0.0.1:5173,管理端 http://127.0.0.1:5174。完整安装与测试命令见[开发与验证](docs/04-local-development-and-verification.md)。账号使用用户名,无需邮箱;没有随代码交付的默认密码。
+25 -6
View File
@@ -6,6 +6,7 @@
<el-alert v-if="resources.notice" :title="resources.notice" type="success" :closable="false" show-icon />
<el-table v-loading="resources.loading" :data="resources.items" border :empty-text="resources.error ? '加载失败,请重试' : '尚未导入词典'">
<el-table-column label="名称" prop="name" min-width="175" />
<el-table-column label="提供者" width="100"><template #default="scope">{{ providerLabel(scope.row.provider) }}</template></el-table-column>
<el-table-column label="语言" width="80"><template #default>英语</template></el-table-column>
<el-table-column label="版本" prop="version" width="80" />
<el-table-column label="来源" prop="source" min-width="160" show-overflow-tooltip />
@@ -18,18 +19,23 @@
<el-card class="import-card">
<h2>导入词典</h2>
<el-form class="dictionary-form" label-position="top" @submit.prevent="upload">
<el-form-item label="词典类型">
<el-select v-model="form.format" :disabled="busy" @change="syncForm">
<el-option v-for="option in supportedOptions" :key="option.format" :label="option.name + ' · ' + option.description" :value="option.format" />
</el-select>
</el-form-item>
<el-form-item label="名称"><el-input v-model="form.name" maxlength="120" :disabled="busy" /></el-form-item>
<div class="form-row">
<el-form-item label="语言"><el-input model-value="英语" disabled /></el-form-item>
<el-form-item label="版本"><el-input v-model="form.version" readonly /></el-form-item>
</div>
<el-form-item label="来源"><el-input v-model="form.source" readonly /></el-form-item>
<el-form-item label="格式"><el-input v-model="form.format" readonly /></el-form-item>
<el-form-item label="词典文件">
<input id="dictionary-file" ref="fileInput" type="file" accept=".zip,application/zip" aria-label="词典文件" :disabled="busy" @change="selectFile">
</el-form-item>
<p class="subtle">WordNet 3.0 ZIP · 上限 32 MiB · 英语释义</p>
<p v-if="resources.supported?.source"><a :href="downloadURL" target="_blank" rel="noopener noreferrer">下载支持的词典文件</a></p>
<p class="subtle">{{ selectedOption?.downloadable ? '官方 ZIP · 上限 32 MiB · 英语释义' : '由 scripts/dict_prepare.py 在本机生成 · 上限 32 MiB · 中文释义与 IPA 音标' }}</p>
<p v-if="selectedOption?.downloadable && form.source"><a :href="form.source" target="_blank" rel="noopener noreferrer">下载支持的词典文件</a></p>
<p v-else class="subtle">先在服务器运行 python scripts/dict_prepare.py,再导入生成的 .local/dictionaries/zh-dict-v1.zip。</p>
<p class="subtle">导入失败时保留当前词典。</p>
<p v-if="fileError" role="alert" class="file-error">{{ fileError }}</p>
<el-button type="primary" native-type="submit" :loading="resources.saving" :disabled="resources.loading || !file">导入并启用</el-button>
@@ -46,17 +52,30 @@ export default {
name: 'DictionariesView',
components: { BasicLayout },
data: () => ({
resources: { items: [], supported: null, loading: false, saving: false, error: '', notice: '' },
resources: { items: [], supported: [], loading: false, saving: false, error: '', notice: '' },
form: { name: 'Princeton WordNet', language: 'en', version: '3.0', source: downloadURL, format: 'wordnet-3.0-zip' },
file: null, fileError: '', downloadURL
}),
computed: { busy() { return this.resources.loading || this.resources.saving } },
computed: {
busy() { return this.resources.loading || this.resources.saving },
supportedOptions() { return Array.isArray(this.resources.supported) ? this.resources.supported : [] },
selectedOption() { return this.supportedOptions.find(option => option.format === this.form.format) || null }
},
watch: { '$store.state.generation': { flush: 'sync', handler() { this.loader.invalidate(); this.clearFile() } } },
created() { this.loader = createDictionaryLoader(session, this.resources) },
mounted() { this.reload() },
beforeUnmount() { this.loader.invalidate(); this.file = null },
methods: {
reload() { return this.loader.load() },
reload() { return this.loader.load().then(() => this.syncForm()) },
providerLabel(provider) { return { wordnet: '英英释义', ecdict: '英汉释义' }[provider] || provider || '英英释义' },
// The server owns which formats are reviewable, so the form mirrors the selected spec instead
// of letting an operator type a version or source that the import would reject.
syncForm() {
const option = this.selectedOption || this.supportedOptions[0]
if (!option) return
this.form = { name: option.name, language: 'en', version: option.version, source: option.source, format: option.format }
this.clearFile()
},
statusLabel(status) { return { ready: '可用', disabled: '已停用', unavailable: '不可用' }[status] || '不可用' },
clearFile() { this.file = null; this.fileError = ''; if (this.$refs.fileInput) this.$refs.fileInput.value = '' },
selectFile(event) {
+27
View File
@@ -55,3 +55,30 @@ test('page exit or session change discards late responses', async () => {
await read
assert.deepEqual(state.items, [])
})
test('both dictionaries stay independently switchable and keep their provider', async () => {
const { createDictionaryLoader } = await import('../src/dictionaries.mjs')
const state = { items: [], supported: null, loading: false, saving: false, error: '', notice: '' }
const supported = [
{ provider: 'wordnet', name: 'Princeton WordNet', version: '3.0', format: 'wordnet-3.0-zip', source: 'https://example.invalid/wordnet.zip', downloadable: true },
{ provider: 'ecdict', name: '简明英汉字典增强版', version: 'ecdict-1.0.28+cmudict-0.7b', format: 'lexgo-zh-dict-v1', source: 'https://example.invalid/ecdict-stardict-28.zip', downloadable: false }
]
const rows = [
{ id: 1, provider: 'wordnet', name: 'Princeton WordNet', enabled: true, status: 'ready' },
{ id: 2, provider: 'ecdict', name: '简明英汉字典增强版', enabled: true, status: 'ready' }
]
const session = {
state: { generation: 1 },
listDictionaries: async () => ({ items: rows, supported }),
importDictionary: async () => ({ resource: rows[1], duplicate: false }),
setDictionaryEnabled: async (id, enabled) => ({ resource: { ...rows.find(row => row.id === id), enabled } })
}
const loader = createDictionaryLoader(session, state)
assert.equal(await loader.load(), true)
assert.deepEqual(state.supported, supported)
assert.equal(state.items.length, 2)
assert.equal(await loader.toggle(2, false), true)
assert.equal(state.items[1].enabled, false)
assert.equal(state.items[0].enabled, true)
assert.match(state.notice, /停用/)
})
+58 -3
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: 445504a7f95f9c018216f74e27d433edde521f73
synchronized_at: 2026-09-11T03:58:56Z
wiki_revision: a482d5ba1fa50d2b570042376134cc0d7a67ef36
synchronized_at: 2026-09-16T13:51:35Z
<!-- gitea-wiki-mirror:end -->
# LexGo 项目档案
@@ -145,4 +145,59 @@ server 是 go-admin 的选用模块接入:原样保留 SysUser、SysDept、必
## #6 当前工程状态(2026-09-11)
#5已验收且相关前置PR均已合入main。#6正式词典/点词查义已按全Go实现,位于feat/6-go-dictionary-lookup,等待用户验收;server schema v4,管理端新增英语词典页,学习端加入Go分片及查词面板。正式Go进程不依赖Python NLP,WordNet包随数据库持久化。#3小样仍是历史验证;保存个人释义和状态归#7,音频封面#21、列表优化#24尚未实施。
#5已验收且相关前置PR均已合入main。#6正式词典/点词查义已按全Go实现,已于2026-09-11通过用户验收,PR #25已合入main;server schema v4,管理端新增英语词典页,学习端加入Go分片及查词面板。正式Go进程不依赖Python NLP,WordNet包随数据库持久化。#3小样仍是历史验证;保存个人释义和状态归#7,音频封面#21、列表优化#24尚未实施。
## #7 当前工程状态(2026-09-11)
#6已通过用户验收并合入main。#7个人词条、学习状态与阅读器高亮已按用户确认口径实现,并于2026-09-11通过用户验收,PR #26已fast-forward-only合入main;server schema v5新增lexgo_terms,学习端面板可保存释义、例句与状态并按状态高亮,同一词形在本人其他章节显示一致。个人释义与共享词典分离,且不进入审计日志。词汇库#12、短语#11、到期复习#8、进度#13、音频封面#21与列表优化#24尚未实施。
## #8 当前工程状态(2026-09-11)
#7已通过用户验收并合入main。#8到期单词复习已按用户确认的决策表实现,并于2026-09-11通过用户验收,PR #27已fast-forward-only合入main;server schema v6新增lexgo_term_reviews(排期与计数)与lexgo_review_answers(作答记录),固定间隔1/2/4/7/15/30/60天,答对升级封顶7、答错降级最低1、再学一次不改等级,答错与再学立即回队,已知与忽略不入队。作答按answerId去重并以expectedDueAt判定过期标签页,重复提交、网络重发与双标签页都只记一次;到期判定用UTC绝对时刻,不引入本地日边界。独立审核(Claude Code)指出的并发同键500、编辑文本重排复习、面板保存重置等级三项已整改并复测。第3阶段「首条学习闭环」(#5~#8)至此全部验收;剩余#9~#15与#21、#24尚未实施。
## #9 当前工程状态(2026-09-13)
#8已通过用户验收并合入main。#9 TXT文件导入已按用户确认的契约实现,并于2026-09-13通过用户验收,PR #28已fast-forward-only合入main;本单没有数据库结构变化,schema仍为v6。解码只接受UTF-8(可选BOM剥离且不进原文),非法字节整体拒绝、不使用替换字符,UTF-16按BOM识别后明确拒绝;文件字节上限2MiB并仍受单章100000码点约束;换行与空白不归一化。文件只在内存中解码,不创建临时文件,客户端文件名不参与任何路径也不入库。解码后交给既有PasteBook/PasteChapter,分章、任务幂等与恢复与粘贴一致。第4阶段已完成#9;剩余#10~#15与#21、#24尚未实施。
## #10 当前工程状态(2026-09-14)
#9已通过用户验收并合入main。#10编辑与删除书籍章节已按用户确认的契约实现,并于2026-09-14通过用户验收,PR #29已fast-forward-only合入main;本单没有数据库结构变化,schema仍为v6。可改书名、章节标题与章节正文;只有正文变化才重新处理,重复保存或改回原内容不新建任务,只改标题不改变处理状态。版本门控按content_sha256执行:过期版本任务被标为superseded且完全不触碰章节,认领、恢复扫描与重试都按版本裁决,存储文本重算SHA与存储SHA不一致时按content_changed失败——这修掉了「处理中编辑导致新版本被标失败」的既有缺陷。删除为事务内硬删除加外键级联,删章后重排序号;个人词条、复习排期与作答记录不随删除清理。第4阶段已完成#9与#10;剩余#11~#15与#21、#24尚未实施。
## #11 当前工程状态(2026-09-14)
#10已通过用户验收并合入main。#11连续短语的选择、保存与复习已按用户确认的契约实现,并于2026-09-14通过用户验收,PR #30已fast-forward-only合入main。用户在验收前明确确认了D3的存储方式:短语与单词共用lexgo_terms,单词键不含空格、短语键以空格分隔,因此kind与词数由身份键在服务端派生,**不新增列、无迁移**,schema仍为v6;唯一键、排期表、到期队列与作答幂等路径全部复用。范围规则为两端对齐整词、内部标点与换行保留、2~12词、键≤128字符、片段≤191字符;跨章节按连续词形匹配,重叠取最左最长,短语高亮覆盖内部单词但不修改单词数据;失效引用回退为高亮消失而条目与排期保留;选择使用原生拖选与手机系统手柄,不拦截touchmove。独立审核(Claude Code)判定代码达标并指出一处流程问题(先实现后确认数据结构变更),已在评论7912记录确认与整改。第4阶段已完成#9、#10与#11;剩余#12~#15与#21、#24尚未实施。
## #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。真实手机的长按选择、手柄拖动、软键盘与安全区表现仍需人工确认,本单的触摸证据来自模拟设备。
## 当前工程状态(2026-09-15)
**#15 自托管试用交付**已按用户确认的契约实现,并于2026-09-15通过用户验收,PR #35已fast-forward-only合入main。本单没有数据库与接口变化,交付三部分:运维工具(`scripts/ops.py`的依赖检查、建空库、备份、恢复、校验与两账号闭环演练,`scripts/bench.py`的写明规模性能测量)、编译进后端的纯二进制运维子命令(`lexgo backup|restore|verify`,部署机只需二进制与MySQL客户端,与工具通道共用同一份manifest格式且可互相恢复),以及新建的Wiki部署与运维页。备份的完整对象是「MySQL全库dump+环境配置」:账号、原文、词典归档、词条、复习记录、阅读进度、审计日志以及附件全部在数据库里;恢复默认只写空库、覆盖需要显式确认与`--force`、拒绝系统库与带库名切换的dump,并在恢复前后比对源库逐表内容校验和。本机演练:空库安装并建两个虚构账号走通学习闭环(19项)、纯二进制安装(9项)、完整恢复(27项)、两条通道交叉验证(19项)与附件版恢复(22项),逐表行数与内容校验和都与源库一致、源库未被改动。性能在20章/2000词条/8000条作答的人造数据集上实测并写明环境,只作观察不给承诺;**真实回滚(升级后切回旧二进制)、HTTPS、多机部署、定时备份与监控告警仍未验证**。
**#21 书籍音频与封面附件**已按用户确认的设计证据(评论8116的线框S1–S4)实现,并于2026-09-15通过用户验收,PR #36已fast-forward-only合入main。schema升到v8,新增`lexgo_book_attachments`(按`book_id+kind`唯一,音频与封面字节存`MEDIUMBLOB`)与`lexgo_playback_positions`(按`owner_id+book_id`);类型按文件头magic bytes判定(MP3/JPG/PNG/WebP,含WebP维度解析),音频≤20MiB、封面≤2MiB且≤4096×4096;上传先校验后写入,被拒绝的上传保留旧附件;读取需会话,经`http.ServeContent`提供Range/206、416、ETag/304;播放位置按账号与书籍保存,替换或移除音频时重置,跨账号一律404。学习端新增书库封面、书籍页「音频与封面」区块与阅读页常驻播放器(播放/暂停、拖动、0.75–1.5倍速、位置上报),字节经带凭据的fetch取回并转为对象URL,**令牌不进URL**。验证:Go 86项顶层用例、学习端157单测与26项E2E(连续3次全量跑通过)、真实API+MySQL 41项、附件版恢复演练22项。**浏览器是否真实解码播放未验证**(测试中媒体元素被桩替换),真机听感与兼容性待人工确认;同时修掉了#11遗留的`e2e/phrase.spec.ts`偶发失败(重试条件改为断言期望词数、重读坐标、松开前留出扩展时间)。
原MVP范围#2~#15至此全部实施并验收。此后用户提出新的使用场景(多篇网络文章合并成一本书的多章,每章各有音频并新增章节插图),已确认在#21已交付代码基础上继续改、不推倒重做;该变更的完整契约草案见#21评论8129,已另建工单#37承接。仍待处理:#24书库列表优化与缺陷#32(编辑正文回到曾用版本返回500,修复方案待确认)。
## 当前工程状态(2026-09-16)
**#37 章节级音频与章节插图**已按用户确认的线框与增量契约实现,并于 2026-09-16 通过用户验收,PR #38 已 fast-forward-only 合入 main。本单经历六轮用户驱动的迭代,最终形态是:音频与插图挂在**章节**上(一章各一张),封面留在**书**上;阅读页在正文上方显示插图**缩略图**、点击在弹窗看原图,播放器只在本章有音频时出现;书籍页章节列表每行只有一个「编辑」,编辑对话框同时管理标题、作者、正文、插图与音频(标题/作者/正文点保存后生效,文件选中后立即上传);书籍页封面压成一行紧凑控件,书名右侧显示书级作者,「编辑书籍」可改书名与作者。**schema 经历三次追加**:v9 新增 `lexgo_chapter_attachments` 与 `lexgo_chapter_playback_positions` 并幂等清空书级音频历史行(书级音频接口与播放接口下线),v10 给 `lexgo_chapters` 加可选作者,v11 给 `lexgo_books` 加可选作者;v10/v11 用带守卫的条件加列(先查 `information_schema` 再 `ALTER TABLE`,在语句列表之后执行),保持「部分迁移可重试、回退标记后可重新升级」这一项目一贯保证。书籍作者与章节作者是两份互相独立、都可选(≤120 字符、留空即清空)的自由文本,章节不继承书级作者。验证:Go 91 项顶层用例、学习端 157 单测与 26 项 E2E、治理 65 项、真实 API+MySQL 52 项、#15 恢复演练第三次 22 项(附件按章节归属、按字节恢复、Range 可用)。
本单期间还发现并修复了一个从 #10 起就存在的真实缺陷:`main.ts` 漏引入 Element Plus 的 `dialog` 与 `message-box` 样式,导致全站所有对话框与确认框退化成文档流块;修复后补充了断言浮层定位属性的 `expectModalOverlay`/`expectMessageBoxOverlay` 用例,并把「只看可见性、不看是否浮层」这一验证盲区写回文档。未实现的确认项:书库卡片显示书级作者、章节作者继承书级作者;真机听感与浏览器真实解码播放仍需人工确认。
至此 MVP 原范围 #2~#15 与增补 #21、#37 全部实施并验收;仍待处理 #24 书库列表优化与缺陷 #32。#16 的 #37 复选框已勾选。
- 2026-09-16 新增第二本英语词典(#40):ECDICT 常用子集提供中文释义、CMUdict 提供 IPA 转写音标,二者由 sha256 pin 的源显式准备;schema v12 放开 `lexgo_dictionaries` 单行约束并增加 `provider` 列,英英与英汉词典可分别启停。音标来源必须可追溯(IPA 转写 / ECDICT 记法),不确定时不显示。许可:ECDICT 仓库 MIT(数据由公开资料整理,再分发前需自行评估)、CMUdict BSD-2,许可证随仓库保存,词典归档不提交进 Git。
- 2026-09-16 登录有效期由 8 小时改为 **30 天**(#42):两端共用同一登录接口,有效期是具名常量 `SessionLifetime`,绝对有效期、不滑动续期;服务端只存令牌 SHA-256 摘要,改密码/停用/重置仍撤销该账号全部会话。设备与会话管理界面未包含,如需另建工单。
- 2026-09-16 用户验收 #40(英汉词典与规范音标):ECDICT 常用子集提供中文释义、CMUdict 提供 IPA 转写音标,两本英语词典可分别启停。**schema 最终为 v12**(放开 `lexgo_dictionaries` 单行 CHECK、增加 `provider` 列,两步带条件守卫、可重放)。音标来源必须可追溯(`cmudict` IPA / `ecdict` 记法),不可靠就不显示。资源由 `scripts/dict_prepare.py` 从 sha256 pin 的源显式准备,产物不入库;许可为 ECDICT MIT 与 CMUdict BSD-2。
- 2026-09-16 用户验收 #42(登录有效期):学习端与管理端共用同一登录接口,有效期由 8 小时改为 **30 天**(具名常量 `SessionLifetime`,绝对有效期、不滑动续期)。已知代价:令牌泄露窗口放大约 90 倍;设备/会话列表与"退出其他设备"未包含。
- 2026-09-16 用户验收 #32(缺陷修复):编辑正文回到曾经用过的版本时复用该内容版本已有的处理任务行;因分支携带仅改镜像的过程提交,PR #39 以 squash 方式合入 main(`f565c1c`),未改写分支历史。
+342 -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: f317e21d651f2b5cb66810e9a4abf02831908559
synchronized_at: 2026-09-11T03:58:59Z
wiki_revision: 64a83458a283469672e111f423d37d9478a3d0e3
synchronized_at: 2026-09-16T13:51:36Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -228,7 +228,7 @@ lexgo_books(owner_id, title, language)、lexgo_chapters(book_id, owner_id, ordin
用户已明确选择全 Go:正式英语分词、原文位置映射、本地词典解析和词形候选查询由 Go 后端完成,不运行 Python NLP 服务。此前“Python 建议/全 Go 未决”仅为历史决策记录,由本决定覆盖;spikes/english 保留历史验证,不接入产品。#6 按该方向实施,当前方案见工单最新启动评论;WordNet 3.0 仍为首个资源(英语释义),词形规则候选不等同于 spaCy 上下文消歧,原文及个人学习状态不按候选合并。
## #6 全 Go 词典与阅读器(2026-09-11,已实现待验收)
## #6 全 Go 词典与阅读器(2026-09-11,已验收并合入 main)
`server/app/lexgo/wordnet.go` 负责固定 WordNet ZIP 校验/内存解析、Unicode 分词及词形候选;`dictionary.go` 负责资源与章节查词 API;`database.go` schema v4 新增单槽共享资源表 lexgo_dictionaries(元数据、enabled、SHA、ZIP LONGBLOB),已有学习数据不改写。每个 Router 按 SHA 缓存一个不可变词典,查询先读资源元数据,缓存未命中才读取 ZIP;进程重启从数据库恢复,不需要 Python NLP 或额外资源目录。
@@ -243,3 +243,342 @@ lexgo_books(owner_id, title, language)、lexgo_chapters(book_id, owner_id, ordin
管理端 `Dictionaries.vue` + `dictionaries.mjs` + session multipart 方法,复用 go-admin 导航/表单与身份失效保护。学习端 `useReaderLookup.ts` 校验原文片段/SHA/所有位置,`ReaderTokens.vue` 渲染可聚焦单词,`LookupPanel.vue` 展示释义及临时个人草稿。桌面侧栏,手机固定底部45dvh面板;关闭恢复焦点,仅无后续手动滚动时恢复自动调整前位置。旧响应在换词/换章/退出/离页后失效。
参考:[WordNet 数据格式](https://wordnet.princeton.edu/documentation/wndb5wn)、[词形规则](https://wordnet.princeton.edu/documentation/morphy7wn)。#3 仅历史实验,#6 不调用其实验服务。
## #7 个人词条与阅读器状态(2026-09-11,已验收并合入 main)
schema v5 新增 lexgo_terms:一个学习者对一个词形一条记录。身份键为 `(owner_id, language, term)`,`term` 是 Go 侧 `normalizeWord` 的结果(NFC、小写、弯撇号转直撇号),列使用 `utf8mb4_bin`,避免折叠 `resume`/`résumé`;`original_form` 保存最近一次保存的原词形供显示。`definition`/`examples` 是学习者自己的文本,例句按行存储;共享词典仍只在 `lexgo_dictionaries`,两者不混存。`status` 与 `level` 由数据库检查约束守住:只有 `learning` 允许 1~7,其他状态必须为 0。
| 接口 | 权限与输入/输出 |
|---|---|
| POST /api/v1/terms | 本人;`{chapterId,start,end,definition,examples[],status,level?}`;服务端按 #6 同一套 token 范围反推词形,`owner`/`language`/`term` 一律不接受客户端输入;唯一键 upsert,重复保存更新同一行;首次 201、更新 200,返回 `{term,created}` |
| GET /api/v1/terms/:id | 本人;他人编号与不存在编号统一 404,不泄露存在性 |
| GET /api/v1/chapters/:id/tokens | 在原响应上为 word 片段增加可选 `term:{id,status,level}`;服务端按本章词形分批(每批 500)查本人词条,其他章节保存的同形词同样命中 |
`server/app/lexgo/terms.go` 负责身份、状态/等级边界、文本上限、upsert 与章节点词状态;`database.go` 提供 v5;`dictionary.go` 的 tokens 与 lookup 共用 `wordAtRange`,保存与查询必须落在同一个完整单词范围上,所以客户端无法命名自己没有读到的词。个人词条不写审计日志。
学习端 `useReaderLookup.ts` 在原有查询状态上增加个人释义、例句、状态、已保存编号、预填与保存;打开已保存词先读 `GET /terms/:id`,读取失败时禁用保存,避免用空表单覆盖原内容。`LookupPanel.vue` 提供状态单选、释义与例句输入、保存与简短反馈;`ReaderTokens.vue` 按状态高亮 `is-new`/`is-learning`/`is-known`/`is-ignored`。换词、换章、离页、退出或切换账号都会清空表单、状态与高亮。管理端无改动。
## #8 复习调度与答题(2026-09-11,已验收并合入 main)
schema v6 新增 `lexgo_term_reviews`(每个个人词条一行排期:`due_at`、`review_count`、`correct_count`、`wrong_count`、`last_reviewed_at`)与 `lexgo_review_answers`(每次作答一条:`answer_key`、评分、状态/等级/间隔前后值、`result`、`requeued`、时间)。两条语句都是 `CREATE TABLE IF NOT EXISTS` 加 `INSERT IGNORE ... SELECT`,所以迁移是可重试的加法迁移;旧二进制回到 v5 仍可继续写 `lexgo_terms`,不需要改动个人词条表本身。既有已保存词汇在迁移中按 `due_at = created_at` 进入队列。
`server/app/lexgo/review.go` 负责间隔表、队列、评分转换、幂等与并发;`terms.go` 的保存路径通过 `syncTermReview` 维护排期行(`新词` 立即到期,显式 `学习中 level N` 排 `now + 间隔[N]`),计数在状态或等级变化时保留。
| 接口 | 权限与输入/输出 |
|---|---|
| GET /api/v1/reviews/queue | 本人+当前语言;仅 `新词`/`学习中` 且 `due_at ≤ now`,按 `due_at, id` 排序、最多 50 条;返回 `{items, total}`,`total` 是全部到期数;不接受查询参数 |
| POST /api/v1/reviews/:termId/answers | `{answerId, grade: correct\|wrong\|again, expectedDueAt}`;应用成功 201,重放或过期 200;返回首次结果 `result`(applied/stale) 与 `duplicate` 标记、前后状态/等级/到期时间、`requeued` 与词条新状态;加锁后再次读取答案键,所以并发的同键提交也返回记录而不是报错 |
学习端新增 `/review` 路由与书库、阅读器顶栏的「到期复习」入口;`stores/review.ts` 维护队列、本轮计数、评分与重学,`ReviewCard.vue`/`ReviewView.vue` 呈现正面(词+挖空例句)、答案面(个人释义+例句+三个评分按钮)、完成页与空队列页。一次评分对应一个 `answerId`,失败重试复用同一个;换词后重新生成。切换账号或退出登录会清空队列、计数与当前卡片。
参考:[LinguaCafe Review.vue](https://github.com/simjanos-dev/LinguaCafe/blob/c1ea298ce40c65b9dd33e9b26fd2e52fae66f2c8/resources/js/components/Review/Review.vue)。上游的随机抽卡、阶段降级与快捷键不属于本单;#8 只实现本项目的固定间隔、固定顺序与幂等作答。
## #9 TXT 上传导入(2026-09-11,已验收并合入 main)
`server/app/lexgo/upload.go` 负责把上传的 TXT 解码后交给与粘贴相同的核心:解码、multipart 解析与两条路由,schema 无变化(沿用 #5 的 `lexgo_books`/`lexgo_chapters`/`lexgo_ingest_jobs`)。文件只在内存中存在,不写临时文件,客户端文件名不参与任何路径也不入库。
| 接口 | 权限与输入/输出 |
|---|---|
| POST /api/v1/books/upload | 本人;multipart:`requestId`、`title`、`language`(可省略,省略即英语)、`file`;新建书籍与首章 |
| POST /api/v1/books/:id/chapters/upload | 本人且本人书籍;multipart:`requestId`、`title`、`file`;追加一章;不接受 `language` |
字段白名单之外的字段、重复字段、缺失 `file`、非 multipart 请求都返回 400;201 新建、200 重复、409 同编号换内容、404 他人书籍、401 未登录、429 已有文件正在上传(单槽并发门)。响应体与粘贴路径同为 `PasteResult`,所以学习端复用同一套跳转与轮询逻辑。
解码规则见业务规则页;实现上 `decodeTextUpload` 先按 UTF-16 BOM 识别并给出针对性提示,再剥离可选 UTF-8 BOM,然后用 `utf8.Valid` 整体校验,最后交给 `validatePaste`(非空、≤100000 码点)。因此上传与粘贴共享同一分章与任务规则:一次提交一章,`requestId` + 内容 SHA 幂等,worker 只发布已落库的原文。
学习端 `ImportView.vue` 增加「粘贴文本 / TXT 文件」来源切换(沿用已验收 v1 的切换与状态行),`stores/library.ts` 增加 `upload()` 与 `fileProblem`/`fileSizeLabel`,`session.request` 支持 `FormData`(multipart 请求不再被 JSON 化,边界由浏览器提供)。客户端预检只提前反馈,服务端结论为最终结论。
## #10 编辑与删除书籍章节(2026-09-11,已验收并合入 main)
`server/app/lexgo/edit.go` 提供改名、编辑与删除;`ingest.go` 增加版本门控。**schema 无变化**:`chapters.content_sha256` 与 `jobs.content_sha256` 就是版本键,新增的 `superseded` 复用现有 `error_reason` 列。
| 接口 | 权限与输入/输出 |
|---|---|
| PATCH /api/v1/books/:id | 本人;`{title}`;返回 `{book}` |
| GET /api/v1/chapters/:id/source | 本人任意状态;返回 `{source:{id,bookId,ordinal,title,text,status,contentSha256,charCount}}`,供编辑使用;不接受查询参数 |
| PATCH /api/v1/chapters/:id | 本人;`{title?,text?}`;返回 `{chapter,job,versionChanged}`;正文变化才新建任务 |
| DELETE /api/v1/books/:id | 本人;返回 `{deleted:{bookId,chapters}}`;FC 级联删除章节与任务 |
| DELETE /api/v1/chapters/:id | 本人;返回 `{deleted:{chapterId,bookId,remaining}}`;删除后重排序号 |
**版本门控**(本单修掉的缺陷):任务只在 `job.content_sha256 == chapter.content_sha256` 时才能影响章节。认领任务时用 JOIN 只取版本匹配的行,并先把过期版本任务一次性标为 `failed/superseded`;发布前再比对一次,不匹配就只把任务标为 `superseded` 并**完全不触碰章节**;恢复扫描同样先作废过期版本任务、只重排版本匹配的中断任务;重试接口拒绝版本不匹配的任务(409)。删除期间在途任务找不到章节时视为无事可做(级联已删除其任务行)。
`unprocessableReason` 保留一条内容一致性检查:存储的正文重新计算出的 SHA 必须等于该章节存储的 SHA,用于兜住绕过 API 的直接写入(`content_changed`),与版本门控互不重复。
学习端 `BookView.vue` 增加书名编辑对话框、章节编辑对话框(标题 + 正文,正文来自 source 接口)与两处确认弹窗(`ElMessageBox`),章节行增加「编辑」入口;`LibraryView.vue` 在书库被删后显示「书籍已删除 · 已保存的生词和短语仍保留在生词本。」;`stores/library.ts` 增加 `renameBook`、`updateChapter`、`loadChapterSource`、`deleteBook`、`deleteChapter`。正文编辑通过浏览器 textarea 输入,因此该章的行尾统一为 LF(粘贴与 TXT 导入仍保留原始 CRLF)。
## #11 短语选择、保存与复习(2026-09-11,已验收并合入 main)
短语与单词共用一张表和一套复习机制:`lexgo_terms` 的 `term` 列存身份键,**单词键不含空格、短语键以空格分隔**,所以「词或短语」不需要额外列,也不需要第二套排期/队列/作答逻辑。`kind` 与词数由身份键在服务端派生(`termKind`/`termWordCount`),视图与队列项随响应返回。
`server/app/lexgo/phrase.go`:
| 部分 | 职责 |
|---|---|
| `phraseWords` | 从本人 ready 章节的 token 里取完全落在选区内的词;**切进单词的范围直接 400**,不静默丢弃;2~12 个词 |
| `phraseKey` / `phraseSource` | 身份键=按顺序的规范化词形以单个空格连接;显示片段=选区原文(内部标点与换行保留) |
| `phraseMatches` | 跨章节匹配:按首词分组后顺序比对词形,候选按 (起点, 长度降序, id) 排序并取**最左最长**的互不重叠集合 |
| `phrasesForChapter` | 按 `term LIKE '% %'` 取本人短语并匹配,供 tokens 响应使用 |
| `SavePhrase` | 由服务端推导身份后走与单词相同的 `saveTerm` upsert;`kind` 冲突返回 409 |
| 接口 | 说明 |
|---|---|
| POST /api/v1/phrases | `{chapterId,start,end,definition,examples[],status,level?}`;本人 ready 章节;服务端推导词序列与身份,不接受客户端身份;首次 201、重复 200 |
| GET /api/v1/terms/:id | 复用;响应增加 `kind` 与 `wordCount` |
| GET /api/v1/chapters/:id/tokens | 响应增加 `phrases:[{id,status,wordCount,startToken,endToken}]` |
| GET /api/v1/reviews/queue | 队列项增加 `kind` 与 `wordCount`;短语与单词同一队列、同一作答接口 |
学习端:`composables/readerRange.ts` 是纯函数层(整词对齐、内部保留、端点按词调整、命中优先级、区间换算),`composables/useTextSelection.ts` 监听 `selectionchange`(100ms 去抖)与 document 的 `pointerup` 读取浏览器原生选区并映射为 token 索引,**不拦截 touchmove、不 preventDefault**;`ReaderTokens.vue` 为每个 token 输出 `data-token-index` 与短语区间样式;`ReaderView.vue` 负责把选区变成短语、shift 点击扩展、以及面板端点调整;`LookupPanel.vue` 增加短语标题与四个端点按钮;复习卡用 `maskedPrompt` 把整段短语挖成一个空。已保存短语点击优先打开短语面板,单词数据不受影响。
## #12 词汇库:搜索、筛选与编辑(2026-09-11,已验收并合入 main)
`server/app/lexgo/vocabulary.go` 提供只读列表与按 id 编辑,**不改 schema**(沿用 #11 的「身份键派生 kind/词数」)。
| 接口 | 权限与输入/输出 |
|---|---|
| GET /api/v1/terms | 本人+当前语言;`query`、`status`、`kind`、`page`、`limit`;返回 `{items,total,page,limit}`;默认 `page=1`、`limit=20`,上限 100;排序 `updated_at DESC, id DESC` |
| PATCH /api/v1/terms/:id | 本人;`{definition?,examples?,status?,level?}`;返回 `{term}`;身份字段不接受输入 |
参数校验沿用审计列表的既有约定:查询键白名单、重复键与未知键 400、`page ≥ 1`、`limit ≤ 100`、枚举值非法 400。搜索同时匹配规范化身份键、显示原文与个人释义;因为 `term` 使用 `utf8mb4_bin`,查询会先转小写再比较;`%`、`_`、`\` 经 `escapeLike` 转义后作为字面值,避免 `%` 命中全部。`kind` 通过键是否含空格判断(`term NOT LIKE '% %'` / `term LIKE '% %'`)。
编辑复用同一套领域规则:`termContent` 校验释义与例句上限、`termLevel` 校验状态与等级边界(只有 `learning` 带 1~7 级),只有状态或等级变化才调用 `syncTermReview` 重排(`reschedule=false` 时只补建缺失的排期行),因此**只改文本不动排期**、历史作答记录与计数保留。
学习端 `stores/vocabulary.ts` 保存列表状态与筛选(打开/关闭编辑对话框、从阅读器返回都不会丢),`views/VocabView.vue` 提供搜索框与显式搜索按钮、状态与类型筛选、分页、空态与「没有匹配的词条 + 清除搜索与筛选」,并把筛选与页码同步到 `/vocab?...`(`router.replace`)。释义/例句/状态/等级四个字段抽成 `components/TermFormFields.vue`,与阅读器面板共用;等级选择器只在词汇库编辑对话框中出现(阅读器面板仍只有四个状态)。
## #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 可重放。
| 接口 | 权限与输入/输出 |
|---|---|
| POST /api/v1/chapters/:id/complete | 本人;只有 `ready` 章节可标记,否则 409;重复调用返回同一行并带 `duplicate`;返回 `{progress:{chapterId,bookId,read,readAt,duplicate}}` |
| GET /api/v1/progress | 本人当前语言;返回 `{readChapters,totalChapters,knownTerms,learningTerms,newTerms,ignoredTerms,savedTerms,dueNow,books[]}` |
`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,已验收并合入 main)
**没有 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。
## #21 书籍音频与封面附件(2026-09-15,已验收并合入 main)
schema v8 新增两张表,都只用可重放的 `CREATE TABLE IF NOT EXISTS`:
| 表 | 结构 |
|---|---|
| `lexgo_book_attachments` | 主键 `(book_id, kind)`,`kind ∈ {audio, cover}`;`owner_id`、`mime`、`byte_size`、`sha256`、`bytes MEDIUMBLOB`、时间戳;外键级联到书籍与账号。一本书最多一段音频、一张封面,替换即覆盖同一行 |
| `lexgo_playback_positions` | 主键 `(owner_id, book_id)`,`position_seconds`、`updated_at`;外键级联到账号与书籍 |
**文件为什么存进 MySQL**:这样一份 dump 仍然是完整备份、附件与其它私有行走同一套属主校验、删除书籍不可能留下孤儿文件;代价是音频会增大数据库体积(单文件上限 20 MiB 已在文档写明)。
`server/app/lexgo/attachment.go` 集中实现:`sniffAttachment`(按文件头 magic bytes 判定类型,MP3 接受 ID3 或帧同步,图片接受 JPG/PNG/WebP 签名)、`coverDimensions`(JPEG/PNG 用标准库解码,WebP 读 VP8X/VP8/VP8L 头)、`validateAttachment`(音频 ≤ 20 MiB、封面 ≤ 2 MiB 且 ≤ 4096×4096)、`SaveAttachment`(先校验后 upsert,替换音频同时清空进度)、`DeleteAttachment`、`BookAttachmentFile`、`BookAttachmentsFor`、`SavePlaybackPosition`。
| 接口 | 行为 |
|---|---|
| `POST /api/v1/books/:id/audio`、`.../cover` | multipart 单文件;成功返回附件元数据;替换即覆盖;`413` 超限、`400` 类型或内容非法、他人 `404` |
| `DELETE /api/v1/books/:id/audio`、`.../cover` | 移除附件;移除音频同时删除该账号的进度行 |
| `GET /api/v1/books/:id/audio`、`.../cover` | 二进制响应,需会话;`respond` 支持 `binaryResponse`,交给 `http.ServeContent` 处理 **Range(206)**、`416`、`If-Modified-Since`,并按内容摘要给出 `ETag` 与 `304` |
| `PUT /api/v1/books/:id/playback` | `{positionSeconds}`,upsert,只写本人;音频不存在时 `404` |
| `GET /api/v1/books` | 增加 `coverVersion`(封面内容摘要,用于缓存与刷新判定)与 `hasAudio` |
| `GET /api/v1/books/:id`、`GET /api/v1/chapters/:id` | 书籍对象带 `attachments`:封面/音频元数据与该账号的 `playbackSeconds` |
学习端:`stores/library.ts` 用带鉴权的 `session.requestBlob` 取回字节并转成对象 URL(**不把令牌放进 URL**),库列表批量预取封面,阅读器按需取音频;`components/AudioPlayer.vue` 是播放器(播放/暂停、进度、0.75–1.5 倍速、错误重试,播放中每 5 秒与暂停/离开时上报位置);`views/LibraryView.vue` 显示封面(`aria-hidden` 的重复链接,标题链接仍是唯一可访问入口)、`views/BookView.vue` 新增「音频与封面」区块(上传/替换/移除、像素与体积提示、失败保留旧附件)、`views/ReaderView.vue` 在正文上方放常驻播放器条。
## #37 章节级音频与章节插图(2026-09-15)
**schema v9**:新增 `lexgo_chapter_attachments`(主键 `(chapter_id, kind)`,`kind ∈ {audio, illustration}`,`bytes MEDIUMBLOB`,外键级联到章节与账号)与 `lexgo_chapter_playback_positions`(主键 `(owner_id, chapter_id)`),并执行一条幂等语句 `DELETE FROM lexgo_book_attachments WHERE kind='audio'`。三张表都只用可重放的 DDL;`lexgo_book_attachments` 与 `lexgo_playback_positions` 保留结构(不删表、不改列),书级音频停止写入。
| 层 | 变化 |
|---|---|
| 书级 | 只剩**封面**:`kind='cover'`、`GET/POST/DELETE /api/v1/books/:id/cover`、`coverVersion` 缓存失效机制全部保持原样 |
| 章节级 | `POST/DELETE/GET /api/v1/chapters/:id/audio` 与 `.../illustration`、`PUT /api/v1/chapters/:id/playback` |
| 退役 | `POST/DELETE/GET /api/v1/books/:id/audio`、`PUT /api/v1/books/:id/playback`(路由不再注册,返回 404) |
| 响应 | `ChapterSummary` 增加 `illustrationVersion`、`audioVersion`、`playbackSeconds`,字段始终存在(无文件时为空串),书籍详情的章节列表与阅读器响应都带上它们;书的 `attachments` 只剩 `cover` |
复用不变:`sniffAttachment`(magic bytes 判定)、`validateAttachment`(音频 ≤20 MiB;图片 ≤2 MiB 且 ≤4096×4096)、`coverDimensions`(JPEG/PNG 用标准库、WebP 读容器头)、`binaryResponse` + `http.ServeContent`(Range/206、416、ETag/304)、上传「先校验后写入、失败保留旧文件」的流程,以及 `chapterAttachmentViews`/`chapterPlaybackSeconds` 的批量读取(一次查询喂整个章节列表)。
学习端:`stores/library.ts` 用 `uploadChapterFile`/`deleteChapterFile`/`saveChapterPlayback`/`reportChapterPlayback` 替换了书级音频动作,并新增 `illustrationUrls`(按章节)与 `audioChapterId`(记录当前加载的音频属于哪一章,切章不会复用上一章的文件);`views/BookView.vue` 的封面区块收窄为封面并新增「章节附件」对话框(`data-testid="attachment-dialog"`:插图与音频各自的预览、状态、上传/替换/移除与规格提示),章节列表行新增缩略图列与「附件」按钮;`views/ReaderView.vue` 在正文上方渲染本章插图,播放器只在**本章**有音频时出现,离开或切换章节时上报一次位置。
## #37 章节级音频与章节插图(2026-09-15)
**schema v9**:新增 `lexgo_chapter_attachments`(主键 `(chapter_id, kind)`,`kind ∈ {audio, illustration}`,`bytes MEDIUMBLOB`,外键级联到章节与账号)与 `lexgo_chapter_playback_positions`(主键 `(owner_id, chapter_id)`),并执行一条幂等语句 `DELETE FROM lexgo_book_attachments WHERE kind='audio'`。三张表都只用可重放的 DDL;`lexgo_book_attachments` 与 `lexgo_playback_positions` 保留结构(不删表、不改列),书级音频停止写入。
| 层 | 变化 |
|---|---|
| 书级 | 只剩**封面**:`kind='cover'`、`GET/POST/DELETE /api/v1/books/:id/cover`、`coverVersion` 缓存失效机制全部保持原样 |
| 章节级 | `POST/DELETE/GET /api/v1/chapters/:id/audio` 与 `.../illustration`、`PUT /api/v1/chapters/:id/playback` |
| 退役 | `POST/DELETE/GET /api/v1/books/:id/audio`、`PUT /api/v1/books/:id/playback`(路由不再注册,返回 404) |
| 响应 | `ChapterSummary` 增加 `illustrationVersion`、`audioVersion`、`playbackSeconds`,字段始终存在(无文件时为空串),书籍详情的章节列表与阅读器响应都带上它们;书的 `attachments` 只剩 `cover` |
复用不变:`sniffAttachment`(magic bytes 判定)、`validateAttachment`(音频 ≤20 MiB;图片 ≤2 MiB 且 ≤4096×4096)、`coverDimensions`(JPEG/PNG 用标准库、WebP 读容器头)、`binaryResponse` + `http.ServeContent`(Range/206、416、ETag/304)、上传「先校验后写入、失败保留旧文件」的流程,以及 `chapterAttachmentViews`/`chapterPlaybackSeconds` 的批量读取(一次查询喂整个章节列表)。
学习端:`stores/library.ts` 用 `uploadChapterFile`/`deleteChapterFile`/`saveChapterPlayback`/`reportChapterPlayback` 替换了书级音频动作,并新增 `illustrationUrls`(按章节)与 `audioChapterId`(记录当前加载的音频属于哪一章,切章不会复用上一章的文件);`views/BookView.vue` 的封面区块收窄为封面并新增「章节附件」对话框(`data-testid="attachment-dialog"`:插图与音频各自的预览、状态、上传/替换/移除与规格提示),章节列表行新增缩略图列与「附件」按钮;`views/ReaderView.vue` 在正文上方渲染本章插图的**缩略图**(高 120px 的按钮,`data-testid="chapter-illustration"`),点击后在对话框(`data-testid="illustration-dialog"`)里按原图显示(最大 `min(88vw,1200px)` × `78vh`,保持比例不裁切),播放器只在**本章**有音频时出现,离开或切换章节时上报一次位置;**书籍页的章节列表不显示也不预取插图**(2026-09-15 按用户要求修订)。
## #37 章节级音频与章节插图(2026-09-15)
**schema v9**:新增 `lexgo_chapter_attachments`(主键 `(chapter_id, kind)`,`kind ∈ {audio, illustration}`,`bytes MEDIUMBLOB`,外键级联到章节与账号)与 `lexgo_chapter_playback_positions`(主键 `(owner_id, chapter_id)`),并执行一条幂等语句 `DELETE FROM lexgo_book_attachments WHERE kind='audio'`。三张表都只用可重放的 DDL;`lexgo_book_attachments` 与 `lexgo_playback_positions` 保留结构(不删表、不改列),书级音频停止写入。
| 层 | 变化 |
|---|---|
| 书级 | 只剩**封面**:`kind='cover'`、`GET/POST/DELETE /api/v1/books/:id/cover`、`coverVersion` 缓存失效机制全部保持原样 |
| 章节级 | `POST/DELETE/GET /api/v1/chapters/:id/audio` 与 `.../illustration`、`PUT /api/v1/chapters/:id/playback` |
| 退役 | `POST/DELETE/GET /api/v1/books/:id/audio`、`PUT /api/v1/books/:id/playback`(路由不再注册,返回 404) |
| 响应 | `ChapterSummary` 增加 `illustrationVersion`、`audioVersion`、`playbackSeconds`,字段始终存在(无文件时为空串),书籍详情的章节列表与阅读器响应都带上它们;书的 `attachments` 只剩 `cover` |
复用不变:`sniffAttachment`(magic bytes 判定)、`validateAttachment`(音频 ≤20 MiB;图片 ≤2 MiB 且 ≤4096×4096)、`coverDimensions`(JPEG/PNG 用标准库、WebP 读容器头)、`binaryResponse` + `http.ServeContent`(Range/206、416、ETag/304)、上传「先校验后写入、失败保留旧文件」的流程,以及 `chapterAttachmentViews`/`chapterPlaybackSeconds` 的批量读取(一次查询喂整个章节列表)。
学习端:`stores/library.ts` 用 `uploadChapterFile`/`deleteChapterFile`/`saveChapterPlayback`/`reportChapterPlayback` 替换了书级音频动作,并新增 `illustrationUrls`(按章节)与 `audioChapterId`(记录当前加载的音频属于哪一章,切章不会复用上一章的文件);`views/BookView.vue` 的封面区块收窄为封面,**章节列表每行只有一个「编辑」按钮**,章节编辑对话框(`data-testid="chapter-dialog"`)同时编辑标题、正文、插图与音频(插图/音频区块带预览、状态、上传/替换/移除与规格提示,并明确写出「标题与正文点保存后生效,文件选中后立即上传」),`views/ReaderView.vue` 在正文上方渲染本章插图的**缩略图**(高 120px 的按钮,`data-testid="chapter-illustration"`),点击后在对话框(`data-testid="illustration-dialog"`)里按原图显示(最大 `min(88vw,1200px)` × `78vh`,保持比例不裁切),播放器只在**本章**有音频时出现,离开或切换章节时上报一次位置;**书籍页的章节列表不显示也不预取插图**(2026-09-15 按用户要求修订两次:先改为缩略图+弹窗,再把附件并入章节编辑对话框)。
## #37 章节级音频与章节插图(2026-09-15)
**schema v10**:`lexgo_chapters` 增加可选的 `author VARCHAR(120) NOT NULL DEFAULT ''`。MySQL 没有 `ADD COLUMN IF NOT EXISTS`,因此这一列由 Go 侧的条件步骤 `addChapterAuthorColumn` 添加(先查 `information_schema`,缺列才执行 `ALTER TABLE`),并在语句列表执行**之后**运行,保证「部分迁移可重试」「回退标记后可重新升级」这两条既有性质仍然成立;新建库的 v3 语句里也直接带上该列。章节编辑接口接受 `author`(可选、去首尾空白、≤120 字符、空串即清空),`ChapterSummary` 与 `ChapterSource` 都返回它,阅读页在标题下显示非空的作者。
**schema v9**:新增 `lexgo_chapter_attachments`(主键 `(chapter_id, kind)`,`kind ∈ {audio, illustration}`,`bytes MEDIUMBLOB`,外键级联到章节与账号)与 `lexgo_chapter_playback_positions`(主键 `(owner_id, chapter_id)`),并执行一条幂等语句 `DELETE FROM lexgo_book_attachments WHERE kind='audio'`。三张表都只用可重放的 DDL;`lexgo_book_attachments` 与 `lexgo_playback_positions` 保留结构(不删表、不改列),书级音频停止写入。
| 层 | 变化 |
|---|---|
| 书级 | 只剩**封面**:`kind='cover'`、`GET/POST/DELETE /api/v1/books/:id/cover`、`coverVersion` 缓存失效机制全部保持原样 |
| 章节级 | `POST/DELETE/GET /api/v1/chapters/:id/audio` 与 `.../illustration`、`PUT /api/v1/chapters/:id/playback` |
| 退役 | `POST/DELETE/GET /api/v1/books/:id/audio`、`PUT /api/v1/books/:id/playback`(路由不再注册,返回 404) |
| 响应 | `ChapterSummary` 增加 `illustrationVersion`、`audioVersion`、`playbackSeconds`,字段始终存在(无文件时为空串),书籍详情的章节列表与阅读器响应都带上它们;书的 `attachments` 只剩 `cover` |
复用不变:`sniffAttachment`(magic bytes 判定)、`validateAttachment`(音频 ≤20 MiB;图片 ≤2 MiB 且 ≤4096×4096)、`coverDimensions`(JPEG/PNG 用标准库、WebP 读容器头)、`binaryResponse` + `http.ServeContent`(Range/206、416、ETag/304)、上传「先校验后写入、失败保留旧文件」的流程,以及 `chapterAttachmentViews`/`chapterPlaybackSeconds` 的批量读取(一次查询喂整个章节列表)。
学习端:`stores/library.ts` 用 `uploadChapterFile`/`deleteChapterFile`/`saveChapterPlayback`/`reportChapterPlayback` 替换了书级音频动作,并新增 `illustrationUrls`(按章节)与 `audioChapterId`(记录当前加载的音频属于哪一章,切章不会复用上一章的文件);`views/BookView.vue` 的封面区块收窄为封面,**章节列表每行只有一个「编辑」按钮**,章节编辑对话框(`data-testid="chapter-dialog"`)同时编辑**章节标题、作者、正文、插图与音频**:标题与作者用「标签在左、输入框在右」的同一行排版(`.field-row`),正文编辑框加高(18 行),插图与音频压缩成各一两行(标签+状态+上传/替换/移除+「JPG/PNG/WebP · ≤2 MiB · ≤4096×4096」「MP3 · ≤20 MiB · 替换或移除会重置位置」),底部一行写明时机(`data-testid="attachment-timing"`),`views/ReaderView.vue` 在正文上方渲染本章插图的**缩略图**(高 120px 的按钮,`data-testid="chapter-illustration"`),点击后在对话框(`data-testid="illustration-dialog"`)里按原图显示(最大 `min(88vw,1200px)` × `78vh`,保持比例不裁切),播放器只在**本章**有音频时出现,离开或切换章节时上报一次位置;**书籍页的章节列表不显示也不预取插图**(2026-09-15 按用户要求修订两次:先改为缩略图+弹窗,再把附件并入章节编辑对话框)。
## #37 章节级音频与章节插图(2026-09-15)
**schema v11**:`lexgo_books` 增加可选的 `author VARCHAR(120) NOT NULL DEFAULT ''`,与 v10 的章节作者共用同一个条件加列助手 `addAuthorColumn`(先查 `information_schema`,缺列才 `ALTER TABLE`,在语句列表之后执行),新建库的 v3 建表语句也带该列。`BookUpdateInput` 增加可选的 `author`(省略则保留原值、空串即清空、≤120 字符、去首尾空白),`BookSummary`/`BookRef` 都返回它;书籍编辑接口因此从「重命名」变成「编辑书籍」(书名+作者)。
**schema v10**:`lexgo_chapters` 增加可选的 `author VARCHAR(120) NOT NULL DEFAULT ''`。MySQL 没有 `ADD COLUMN IF NOT EXISTS`,因此这一列由 Go 侧的条件步骤 `addChapterAuthorColumn` 添加(先查 `information_schema`,缺列才执行 `ALTER TABLE`),并在语句列表执行**之后**运行,保证「部分迁移可重试」「回退标记后可重新升级」这两条既有性质仍然成立;新建库的 v3 语句里也直接带上该列。章节编辑接口接受 `author`(可选、去首尾空白、≤120 字符、空串即清空),`ChapterSummary` 与 `ChapterSource` 都返回它,阅读页在标题下显示非空的作者。
**schema v9**:新增 `lexgo_chapter_attachments`(主键 `(chapter_id, kind)`,`kind ∈ {audio, illustration}`,`bytes MEDIUMBLOB`,外键级联到章节与账号)与 `lexgo_chapter_playback_positions`(主键 `(owner_id, chapter_id)`),并执行一条幂等语句 `DELETE FROM lexgo_book_attachments WHERE kind='audio'`。三张表都只用可重放的 DDL;`lexgo_book_attachments` 与 `lexgo_playback_positions` 保留结构(不删表、不改列),书级音频停止写入。
| 层 | 变化 |
|---|---|
| 书级 | 只剩**封面**:`kind='cover'`、`GET/POST/DELETE /api/v1/books/:id/cover`、`coverVersion` 缓存失效机制全部保持原样 |
| 章节级 | `POST/DELETE/GET /api/v1/chapters/:id/audio` 与 `.../illustration`、`PUT /api/v1/chapters/:id/playback` |
| 退役 | `POST/DELETE/GET /api/v1/books/:id/audio`、`PUT /api/v1/books/:id/playback`(路由不再注册,返回 404) |
| 响应 | `ChapterSummary` 增加 `illustrationVersion`、`audioVersion`、`playbackSeconds`,字段始终存在(无文件时为空串),书籍详情的章节列表与阅读器响应都带上它们;书的 `attachments` 只剩 `cover` |
复用不变:`sniffAttachment`(magic bytes 判定)、`validateAttachment`(音频 ≤20 MiB;图片 ≤2 MiB 且 ≤4096×4096)、`coverDimensions`(JPEG/PNG 用标准库、WebP 读容器头)、`binaryResponse` + `http.ServeContent`(Range/206、416、ETag/304)、上传「先校验后写入、失败保留旧文件」的流程,以及 `chapterAttachmentViews`/`chapterPlaybackSeconds` 的批量读取(一次查询喂整个章节列表)。
学习端:`stores/library.ts` 用 `uploadChapterFile`/`deleteChapterFile`/`saveChapterPlayback`/`reportChapterPlayback` 替换了书级音频动作,并新增 `illustrationUrls`(按章节)与 `audioChapterId`(记录当前加载的音频属于哪一章,切章不会复用上一章的文件);`views/BookView.vue` 的书籍页把封面压成**一行紧凑控件**(`.cover-row`:预览 120px +「上传/替换封面」「移除」,不含标题、字段名与规格提示文字),把高度让给章节列表;标题行用 `.title-line` 把**书级作者**显示在书名右侧(`data-testid="book-author"`,未设置则不显示);「编辑书名」改为**「编辑书籍」**,对话框内用同一套 `.field-row` 同行排版编辑**书名与作者**。**章节列表每行只有一个「编辑」按钮**,章节编辑对话框(`data-testid="chapter-dialog"`)同时编辑**章节标题、作者、正文、插图与音频**:标题与作者用「标签在左、输入框在右」的同一行排版(`.field-row`),正文编辑框加高(18 行),插图与音频压缩成各一两行(标签+状态+上传/替换/移除+「JPG/PNG/WebP · ≤2 MiB · ≤4096×4096」「MP3 · ≤20 MiB · 替换或移除会重置位置」),底部一行写明时机(`data-testid="attachment-timing"`),`views/ReaderView.vue` 在正文上方渲染本章插图的**缩略图**(高 120px 的按钮,`data-testid="chapter-illustration"`),点击后在对话框(`data-testid="illustration-dialog"`)里按原图显示(最大 `min(88vw,1200px)` × `78vh`,保持比例不裁切),播放器只在**本章**有音频时出现,离开或切换章节时上报一次位置;**书籍页的章节列表不显示也不预取插图**(2026-09-15 按用户要求修订两次:先改为缩略图+弹窗,再把附件并入章节编辑对话框)。
## #37 章节级音频与章节插图(2026-09-15,已验收并合入 main)
**schema v11**:`lexgo_books` 增加可选的 `author VARCHAR(120) NOT NULL DEFAULT ''`,与 v10 的章节作者共用同一个条件加列助手 `addAuthorColumn`(先查 `information_schema`,缺列才 `ALTER TABLE`,在语句列表之后执行),新建库的 v3 建表语句也带该列。`BookUpdateInput` 增加可选的 `author`(省略则保留原值、空串即清空、≤120 字符、去首尾空白),`BookSummary`/`BookRef` 都返回它;书籍编辑接口因此从「重命名」变成「编辑书籍」(书名+作者)。
**schema v10**:`lexgo_chapters` 增加可选的 `author VARCHAR(120) NOT NULL DEFAULT ''`。MySQL 没有 `ADD COLUMN IF NOT EXISTS`,因此这一列由 Go 侧的条件步骤 `addChapterAuthorColumn` 添加(先查 `information_schema`,缺列才执行 `ALTER TABLE`),并在语句列表执行**之后**运行,保证「部分迁移可重试」「回退标记后可重新升级」这两条既有性质仍然成立;新建库的 v3 语句里也直接带上该列。章节编辑接口接受 `author`(可选、去首尾空白、≤120 字符、空串即清空),`ChapterSummary` 与 `ChapterSource` 都返回它,阅读页在标题下显示非空的作者。
**schema v9**:新增 `lexgo_chapter_attachments`(主键 `(chapter_id, kind)`,`kind ∈ {audio, illustration}`,`bytes MEDIUMBLOB`,外键级联到章节与账号)与 `lexgo_chapter_playback_positions`(主键 `(owner_id, chapter_id)`),并执行一条幂等语句 `DELETE FROM lexgo_book_attachments WHERE kind='audio'`。三张表都只用可重放的 DDL;`lexgo_book_attachments` 与 `lexgo_playback_positions` 保留结构(不删表、不改列),书级音频停止写入。
| 层 | 变化 |
|---|---|
| 书级 | 只剩**封面**:`kind='cover'`、`GET/POST/DELETE /api/v1/books/:id/cover`、`coverVersion` 缓存失效机制全部保持原样 |
| 章节级 | `POST/DELETE/GET /api/v1/chapters/:id/audio` 与 `.../illustration`、`PUT /api/v1/chapters/:id/playback` |
| 退役 | `POST/DELETE/GET /api/v1/books/:id/audio`、`PUT /api/v1/books/:id/playback`(路由不再注册,返回 404) |
| 响应 | `ChapterSummary` 增加 `illustrationVersion`、`audioVersion`、`playbackSeconds`,字段始终存在(无文件时为空串),书籍详情的章节列表与阅读器响应都带上它们;书的 `attachments` 只剩 `cover` |
复用不变:`sniffAttachment`(magic bytes 判定)、`validateAttachment`(音频 ≤20 MiB;图片 ≤2 MiB 且 ≤4096×4096)、`coverDimensions`(JPEG/PNG 用标准库、WebP 读容器头)、`binaryResponse` + `http.ServeContent`(Range/206、416、ETag/304)、上传「先校验后写入、失败保留旧文件」的流程,以及 `chapterAttachmentViews`/`chapterPlaybackSeconds` 的批量读取(一次查询喂整个章节列表)。
学习端:`stores/library.ts` 用 `uploadChapterFile`/`deleteChapterFile`/`saveChapterPlayback`/`reportChapterPlayback` 替换了书级音频动作,并新增 `illustrationUrls`(按章节)与 `audioChapterId`(记录当前加载的音频属于哪一章,切章不会复用上一章的文件);`views/BookView.vue` 的书籍页把封面压成**一行紧凑控件**(`.cover-row`:预览 120px +「上传/替换封面」「移除」,不含标题、字段名与规格提示文字),把高度让给章节列表;标题行用 `.title-line` 把**书级作者**显示在书名右侧(`data-testid="book-author"`,未设置则不显示);「编辑书名」改为**「编辑书籍」**,对话框内用同一套 `.field-row` 同行排版编辑**书名与作者**。**章节列表每行只有一个「编辑」按钮**,章节编辑对话框(`data-testid="chapter-dialog"`)同时编辑**章节标题、作者、正文、插图与音频**:标题与作者用「标签在左、输入框在右」的同一行排版(`.field-row`),正文编辑框加高(18 行),插图与音频压缩成各一两行(标签+状态+上传/替换/移除+「JPG/PNG/WebP · ≤2 MiB · ≤4096×4096」「MP3 · ≤20 MiB · 替换或移除会重置位置」),底部一行写明时机(`data-testid="attachment-timing"`),`views/ReaderView.vue` 在正文上方渲染本章插图的**缩略图**(高 120px 的按钮,`data-testid="chapter-illustration"`),点击后在对话框(`data-testid="illustration-dialog"`)里按原图显示(最大 `min(88vw,1200px)` × `78vh`,保持比例不裁切),播放器只在**本章**有音频时出现,离开或切换章节时上报一次位置;**书籍页的章节列表不显示也不预取插图**(2026-09-15 按用户要求修订两次:先改为缩略图+弹窗,再把附件并入章节编辑对话框)。
## #32 编辑任务按内容版本复用(2026-09-16,已修复并合入 main)
**没有 schema 变化**:修复的是编辑路径的任务编排,不是数据模型。
`server/app/lexgo/edit.go` 的 `stageEditJob` 取代了原先「直接创建编辑任务」的写法。一章的一个内容摘要就是一个版本,而 `lexgo_ingest_jobs.request_key` 是唯一的,编辑请求派生的键又是「章节+内容摘要」,因此同一版本第二次成为当前版本时,原写法会撞唯一键、整笔事务回滚并冒泡为通用 500。
| 情况 | 处理 |
|---|---|
| 该章还没有描述这个内容版本的任务行 | 用派生键创建新任务(`edit:<章节>:<内容摘要>`,pending) |
| 已经有描述这个版本的行(改回曾经用过的正文、或该版本上次处理失败) | **复用该行的身份**,重置为 `pending`、`attempts = 0`、清空 `error_reason` 与 `finished_at`,刷新 `updated_at`;不新增行 |
| 复用到的行可能是粘贴版本的任务(键是粘贴请求号) | 正确:一行描述一个内容版本,键只是历史标识;worker 只按 `status = pending` 且「任务内容摘要 = 章节当前内容」认领 |
不变的门控:`RetryIngestJob` 仍拒绝「任务内容摘要 ≠ 章节当前内容」的旧任务(409),所以旧版本任务不会把新版本章节拉回处理;同一版本重复处理是幂等的;未改动的正文仍然不产生新版本、不新增任务。
## 英汉词典与规范音标(#40,schema v12)
一本自托管英语学习工具需要回答"这个词是什么意思",英语学习者往往先要中文。WordNet 只给英英释义,
因此 #40 增加第二本词典,并让两本可以分别启停。
### 资源与准备
- 数据源:ECDICT(`skywind3000/ECDICT`,Release 1.0.28 的 `ecdict-stardict-28.zip`,仓库 MIT)提供中文释义;
CMUdict(`cmusphinx/cmudict`,BSD-2)提供 ARPAbet 音素;WordNet 词表(与 `server/wordnet-resource.json` 同一 pin)
用于裁剪子集。
- `server/zh-dictionary-resource.json` 记录三者的 URL、sha256、字节数与许可证文件名;**镜像只用于传输,
身份是 sha256**,准备脚本逐个校验,不匹配即失败。
- `scripts/dict_prepare.py`(Python,仅工具链,不是运行时代码)执行:下载并校验三个源 → 解析 StarDict `.ifo/.idx/.dict`
与 CMUdict → 裁剪子集(小写单词,且存在于 WordNet 词表或带 ECDICT 考试标签)→ 生成音标 → 写 ZIP
(`manifest.json` + `entries.jsonl.gz`)。产物默认落在 `.local/dictionaries/`,**不入库**。
- 产出前强制校验:条目数与 pin 容差(±2000)、音标字符集必须全部落在 IPA 字母表内,否则拒绝产出;
`manifest.json` 记录三类音标计数与丢弃计数。
### 音标来源分层(不做语义猜测)
1. 词在 CMUdict 中 → 按固定 ARPAbet→IPA 映射转写(`1`→`ˈ`、`2`→`ˌ`、`0` 不标;`AH0`→`ə`、`IY0`→`i`、
`ER0`→`ɚ`;单音节不标重音,`cat` 是 `/kæt/` 而不是 `/kˈæt/`)。
2. 否则用 ECDICT 记法做**字符级**规范化:`'`→`ˈ`、`,`/`.`→`ˌ`、`:`→`ː`、`ә`(U+04D9)→`ə`(U+0259)、
`ε`→`e`、空格/`-`/`=`/`;`/`^` 删除;来源标记为 `ecdict`。
3. 音标以 `^` 开头说明原记法已丢失首音(`grok` 存成 `^rɔk`),整条丢弃——宁可没有,也不给错的转写;
出现其它非 IPA 字符同样丢弃。两个来源都没有音标就不显示。
导入时 `provider` 与 `phoneticSource` 一起进入条目,前端据此区分"IPA 转写"与"ECDICT 记法"。
### 存储与查询
- schema v12:`addDictionaryProvider` 增加 `provider VARCHAR(32) NOT NULL DEFAULT 'wordnet'`,
`dropDictionarySingleSlotCheck` 从 `information_schema` 查到 `CHECK (id = 1)` 后删除。
两步都是条件执行、可重放;版本行只在全部成功后推进,旧二进制看到 v12 会拒绝启动并要求显式迁移。
- 槽位固定:1 = WordNet(英英),2 = ECDICT(英汉 + 音标)。归档整包存进 `lexgo_dictionaries.archive`,
所以普通 dump 就是完整备份;`scripts/dict_prepare.py` 的产物约 3.0 MB。
- `parseResource` 按 `format` 分派解析器;`dictionaryCache` 按资源 id 缓存已解析词典(键含 sha256)。
- 查词合并(`mergeLookups`):中文释义条目在前、英英释义随后,`Resources` 列出全部启用词典,
`Resource` 保持为 WordNet 以兼容既有界面;任一本缺失/损坏/停用时只用剩下那本,都不可用时
仍是 `resource_missing`。
- 屈折形:WordNet 的规则解析给出词目后,再用该词目回查中文词典,因此 `dogs` 也能看到"狗";
但音标属于词目,**不复制**到屈折形上(`dogs` 不显示 `/dɔɡ/`)。
## 登录会话有效期(#42)
- 会话有效期是单一具名常量 `SessionLifetime`(`server/app/lexgo/service.go`),当前为 **30 天**;
登录时写入 `lexgo_sessions.expires_at`,每次请求按 `expires_at > now` 校验。学习端与管理端共用
同一登录接口,因此这一个值同时决定两端的有效期。
- 有效期是**绝对**的,不随请求顺延;没有刷新令牌或轮换机制。撤销路径与有效期无关,保持独立:
退出删除当前会话行,改密码、停用、重置删除该账号全部会话行,登录时顺手清理该账号已过期的行。
- 服务端只保存令牌的 SHA-256 摘要(`token_hash` 主键),原始令牌只在客户端 sessionStorage。
+295 -3
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: 935630005c9b1d7103550cf09eb10edfdbf8f3af
synchronized_at: 2026-09-11T03:59:01Z
wiki_revision: 1f89692c0e24c3d2235a4cd7ad78b90512852f65
synchronized_at: 2026-09-16T13:51:36Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -54,7 +54,7 @@ M0 固定首发语言语料、词条身份规则、短语选择与重叠规则
- 账号去首尾空白并转小写,3~32 位、ASCII 字母开头,可含数字、点、下划线和连字符;唯一约束阻止大小写和并发重复。密码 10~72 字节,使用 bcrypt,不填写必需邮箱。
- 初始管理员只通过显式 bootstrap 建立;库中已有账号时拒绝再次 bootstrap,不覆盖密码。管理页面只创建学习者,不能将输入角色升级为管理员;本阶段不提供账号删除。
- 同一账号可以登录多个会话。随机令牌仅在客户端保存,服务端存 SHA-256 摘要,8 小时过期。退出仅撤销当前会话,启停/重置会撤销目标账号全部会话;重新启用不能恢复旧会话。
- 同一账号可以登录多个会话。随机令牌仅在客户端保存,服务端存 SHA-256 摘要,**30 天**过期(2026-09-16 由 8 小时改为 30 天,见 #42;单一常量 `SessionLifetime`,绝对有效期、不滑动续期)。退出仅撤销当前会话,启停/重置会撤销目标账号全部会话;重新启用不能恢复旧会话。
- 每次请求读取账号当前状态与会话有效期;学习数据由认证身份确定,管理员权限不能解除学习接口的本人过滤。
- 两端分别使用 lexgo-admin-token、lexgo-learner-token 的 sessionStorage;401、退出和账号切换清除私人状态,以代次检查阻止迟到响应恢复旧数据。管理端拒绝学习者并撤销其新会话;撤销服务异常会提示未确认,不冒充服务端退出成功。
- 新建账号拥有英语空空间。此处没有书籍、阅读、词典或复习功能,不把空页面视为学习业务已实现。
@@ -153,3 +153,295 @@ POST /lookup 接收 {surface,lemma?}。查词键单独 casefold/NFC/弯撇号转
exact优先;未命中再按WordNet异常表/词尾规则查候选,词性顺序n/v/a/r、最多12条释义,保留原数据s词性。返回lemma表示规则候选,不承诺上下文消歧;歧义不会合并个人学习状态。not_found与resource_missing区分,网络错误可重试,不阻断阅读。#3使用spaCy上下文lemma的实验路径由全Go规则候选替代。
个人释义目前仅当前选择的临时草稿,界面标记未保存;换词、关闭、换章、离页和身份变化清理。持久化与个人词汇状态由#7实现,不把临时输入宣传为保存成功。
## #7 个人词条规则(2026-09-11)
个人词条身份是「学习者+语言+规范化词形」:`normalizeWord` 做 NFC、小写与弯撇号转换,而原文、章节文本与显示用的原词形都不归一化。同一词形不同大小写是同一条记录;不同词形(`dog` 与 `dogs`)是不同记录,不按 WordNet 候选或 lemma 合并,与 #6「不按候选合并个人学习状态」一致。语言取自已登录学习者的英语空间,不从请求读取。
| 业务状态 | level | 含义 | 进入到期复习 | 原版 stage |
|---|---|---|---|---|
| new 新词 | 0 | 已保存、尚未开始学习;首次保存默认 | 由 #8 决定 | 2 |
| learning 学习中 | 1~7 | 正在复习,越接近 7 越熟 | 是 | -1~-7 |
| known 已知 | 0 | 已掌握,不再进入到期复习 | 否 | 0 |
| ignored 忽略 | 0 | 明确忽略,不计入已知 | 否 | 1 |
上表是原版合并编码(状态与等级在同一字段)的显式替代,供 CSV 导出与旧数据迁移映射;#7 只保存与返回等级,#8 负责复习推进与到期时间。非 `learning` 状态携带非 0 等级、`learning` 等级超出 1~7、以及未知状态一律拒绝,`learning` 缺省等级为 1。
个人释义可为空(允许只记录状态),最长 2000 字符,可含换行与制表符;例句最多 5 条、每条最长 500 字符,不能为空行或含换行。保存失败保留学习者已输入的内容,成功后显示「已保存 · 状态」并立即更新正文高亮。
保存幂等由唯一键承担:重复提交同一词形只更新同一行,不产生第二条冲突记录;同一账号多端同时编辑为最后写入生效,本版不引入版本冲突拒绝。所有读写都属于会话本人:章节必须本人且已就绪,篡改 id、owner、language、term 或携带未知字段返回 400,他人编号与本人不可见编号统一 404。跨账号不共享任何数据与前端缓存,退出或切换账号后不保留前一账号的词条与高亮。
例句只保存学习者手输内容,不自动关联原文句子:当前分词只有词/空白/标点边界,没有句子切分规则。原型 v1 中「原文例句已关联到词条」是演示文案,不作为契约。等级选择器属于 #8/#12 的编辑界面,阅读器面板只提供四个状态。
## #8 复习规则(2026-09-11)
**间隔表**:答对后按新等级排期,等级上限 7。这是固定表,不是 FSRS,也不是上游「按等级选复习量少的日期」的算法。
| 等级 | 1 | 2 | 3 | 4 | 5 | 6 | 7(上限) |
|---|---|---|---|---|---|---|---|
| 下次复习 | 1 天 | 2 天 | 4 天 | 7 天 | 15 天 | 30 天 | 60 天 |
**入队范围**:只有状态 `新词` 或 `学习中` 且 `due_at ≤ now` 的词条进入队列;`已知` 与 `忽略` 不入队。新保存的词立即到期(`due_at` = 保存时刻),保存后就能复习;手动把词条改为「学习中 level N」时下次复习为 `now + 间隔[N]`,避免刚标等级就被当成到期。只有新建词条或状态/等级实际变化才移动复习时间:只修改释义、例句或原词形时保留原有排期,否则编辑文本会把逾期词条挤出当天队列。保存未提及等级时保留已获得的等级,只有进入 `学习中` 才从 1 开始,因此阅读器面板的保存不会把等级重置为 1。已在队列中的词条被改成 `已知`/`忽略` 后再次作答会被拒绝(409),排队信息由服务端裁决而不是前端过滤。
**三个评分动作**:`correct` 认识/答对 → 等级 +1(封顶 7)、按新等级排期、离开本轮;`wrong` 不认识/答错 → `学习中` 降一级(最低 1)、`新词` 保持 `新词`、`due_at = now` 立即回到本轮;`again` 再学一次 → 等级与状态不变、`due_at = now` 立即回到本轮。计数上 `correct_count` 只统计 `correct`,`wrong_count` 统计 `wrong` 与 `again`,`review_count` 统计全部已应用作答。词条的原文、个人释义与例句不因复习改变。
**时区与到期边界**:`due_at` 以 UTC 绝对时刻存储,到期判定是 `due_at ≤ now`,不引入本地日边界。理由:MVP 没有用户时区设置(属 X 系列边界),绝对时刻在多账号自托管下语义一致、没有夏令时陷阱;代价是复习时刻会随首次作答时间漂移,例如 22:00 答对的 1 天间隔词条要到次日 22:00 才到期。原型界面的「到期复习 N / M」指当前到期队列的位置与本轮卡片数,不是自然日统计。
**幂等与并发**:客户端每次作答生成一个 `answerId`,服务端以 `UNIQUE(owner_id, answer_id)` 去重。同一个 `answerId` 再次提交返回首次结果并把 `duplicate` 标记为 true(`result` 仍是首次的 `applied` 或 `stale`),不改变等级、间隔和次数,因此客户端重试可以按首次结果计数;同一词条在别处已经被推进(提交回传的 `expectedDueAt` 与服务端当前 `due_at` 不一致)时记为 `result=stale`,同样不改变任何状态。因此网络重发、双击、双标签页作答都只记账一次。每次尝试都会落一条 `lexgo_review_answers`(含 `result`),这是「只记账一次」的证据,也供 #13 统计使用。
**归属与错误**:词条归属由服务端按会话裁决,`owner` 不接受客户端输入;他人词条与不存在的词条统一 404,未登录 401,未知评分/缺少 `expectedDueAt`/未知字段 400,词条已变成 `已知`/`忽略` 409。复习接口不写审计日志:答题属于私人学习内容。
**范围边界**:本单只做单词复习。短语复习归 #11,到期范围筛选与策略配置界面(X11)不做,练习模式(X08)不做,进度与「已知词/待复习」计数归 #13。#8 不改变 #7 定下的身份口径:复习状态按规范化词形归属,仍不按 WordNet lemma 候选合并。
## #9 TXT 上传规则(2026-09-11)
**支持的编码**:只接受 UTF-8,允许带可选 UTF-8 BOM。BOM 在解码时剥离,不进入原文;其余字节必须整体合法,任何非法序列**直接拒绝**,绝不使用替换字符,因此章节里不会出现学习者没有写过的乱码。UTF-16(含记事本「另存为 Unicode」产生的大小端 BOM)单独识别并提示「请另存为 UTF-8 后重试」;GB18030、Latin-1 等其他编码按非法 UTF-8 拒绝。UTF-16 支持不在本单范围。
**换行与空白**:不做任何归一化,CRLF、LF、制表符、行尾空格与空行按原字节保存,阅读器以 `pre-wrap` 原样呈现——与粘贴路径一致。
**大小上限**:文件字节上限 2 MiB;解码后再套用单章上限(非空、≤100000 码点)。超限返回 400 并明确提示,不截断、不部分导入。2 MiB 对 100000 码点的 UTF-8 文本有足够余量。
**文件生命周期与路径**:上传内容只存在于内存中,解码后直接进入导入事务;服务端**不创建临时文件**,所以没有需要清理或可能泄漏的文件;**客户端文件名不参与任何文件系统路径、也不写入数据库**,它只在选择文件时用于显示(并可预填标题)。因此文件名即使写成 `..\..\windows\system32\evil.txt` 也不会影响任何存储位置。上传并发按单槽限制,忙时返回 429。
**导入与幂等**:上传与粘贴共用同一套规则——一次提交一章,`requestId` + 内容 SHA 保证重复上传同一文件只产生一章(返回第一次的章节并标记 `duplicate`),同一 `requestId` 换成其他内容返回 409。任务状态、失败重试与崩溃恢复沿用 #5 的任务机制,不新增状态。标题规则与粘贴完全相同(去空白后非空、≤120 字符);省略 `language` 时默认英语,与粘贴一致;追加章节不接受 `language`。
**范围边界**:不包含 EPUB、PDF、字幕与其他文件格式;不做按空行自动分章;不做 UTF-16/GB18030 转码;不做断点续传;不把来源文件名持久化(若将来需要「导入来源」溯源,另立范围)。
## #10 编辑、版本与删除规则(2026-09-11)
**可编辑内容**:书名、章节标题、章节正文。只有正文变化才重新处理;只改标题不改变处理状态,也不新建任务。
**版本规则**:`chapters.content_sha256` 是章节的版本键,`jobs.content_sha256` 是任务被创建时对应的版本。任务只能在版本匹配时影响章节:
- 过期版本的任务被标为 `error_reason=superseded`(「章节内容已更新为新版本,本次处理已作废」),**不会**把章节标成失败、也不会发布旧文本;界面上失败章节的重试按钮只出现于当前版本的任务。
- 任务重试要求版本匹配,否则 409,避免把当前章节拉回旧版本再失败一次。
- 重复保存同一正文不是新版本:不新建任务、不改变状态。改回原内容(内容相同)同样不触发处理。
- 编辑章节正文会保留章节编号与阅读入口(URL 不变),章节状态回到待处理,处理完成后原文即新版本。
**存储文本与版本一致性**:章节存储的正文重新计算出的 SHA 必须等于存储的 SHA;出现不一致(绕过 API 的直接写入)时按 `content_changed` 失败,不发布不确定内容。
**删除规则**:删除在事务内**硬删除**,并沿用现有外键级联清理:书籍 → 章节 → 任务。删除章节后剩余章节序号重排为连续(原型显示「剩余 N 章」,导航按序号取值);并发删除同一章由书籍行锁序列化,结果是一个成功、一个 404。重复删除返回 404,不把「已经不存在」当成成功。恢复路径是数据库备份与完整恢复(#15 演练范围),产品不提供回收站或撤销。
**个人学习记录保留**:删除书籍或章节**不删除**个人词条、复习排期与作答记录,因为它们按学习者归属、不引用章节;界面也没有「来自某章节」的引用(例句是学习者输入的副本)。所以删除只影响书籍、章节与处理任务,这一点在原型确认文案中就写明:「本章正文将被删除,已保存词条保留」「删除这本书及其章节?已保存的生词和短语将保留」。
**归属与边界**:改名、编辑、删除、读取编辑用原文都严格按会话归属;他人资源与不存在资源统一 404,未登录 401,空标题/空正文/超长文本/未知字段 400。学习端正文编辑框的行尾会统一为 LF(浏览器 textarea 行为),粘贴与 TXT 导入路径仍然保留原始 CRLF 与空白。
**范围边界**:不做封面与音频附件(#21)、不做回收站/撤销、不做批量操作、不做章节跨书移动、不做语言变更。
## #11 短语规则(2026-09-11)
**身份**:短语身份=按阅读顺序的规范化词形以单个空格连接(`a small, step` 与 `a small step` 都是 `a small step`),归属 `(owner, language)`;显示片段保存最近一次保存时的原文(内部标点、换行、多余空格原样保留,仅用于显示)。与单词共用同一张表,因此同一短语在不同章节保存只会得到一条记录,也共用同一幂等键与同一复习排期。
**范围**:两端对齐整词;首尾若落在空白或标点则跳过;**内部**标点与换行保留但不参与身份比较;不切开代理对、ZWJ 与组合字符。选区内少于 2 个词不是短语(单个词走单词面板),超过 12 个词、身份键超过 128 字符或原文片段超过 191 字符都返回 400 并给出可读提示。切进单词中间的范围被拒绝,而不是静默丢弃那个词。
**跨章节匹配**:在章节的词片段序列中找**连续词**,其规范化词形逐个相等(中间允许任意标点与空白)。因此编辑正文后:短语仍出现则继续高亮;不再出现则该章不高亮,但**词条与复习排期保留**;章节被删除同样保留。短语不存章节锚点,所以不存在悬空引用。
**重叠与点击**:同一位置多个候选按 (起点升序, 长度降序) 取互不重叠者,即「最左最长」,结果与输入顺序无关。短语高亮覆盖其内部的单词高亮,但**不修改单词数据**(状态、排期、计数都保留);点击命中规则是「在已保存短语范围内 → 打开短语面板,否则打开单词面板」。保存后的高亮立即出现,不需要重新加载分词。
**选择交互**:桌面用浏览器原生拖选(`selectionchange` 去抖 + `pointerup`),手机依赖系统选择手柄且**不拦截 touchmove**,面板提供起点/终点四个按钮按词调整,Shift 点击可把范围从一个词扩展到一个词,Escape 取消并保留阅读位置。单击(折叠选区)仍然是单词查询。真实手感属于运行验证范围,真机证据缺口保留。
**复习**:短语进入同一个到期队列与同一套间隔表;卡片正面显示短语并把**整段短语挖成一个空**(例句里没有该短语时只显示短语本身),答案面显示个人释义;答对/答错/再学与幂等、stale 规则与单词完全一致;计数归属也一致(`correct_count` 只计答对,`wrong_count` 计答错与再学)。
**范围边界**:不做短语自动合并同义形式、上下文词性消歧、短语跨书移动、批量编辑(#12)、真机手柄精细手感。短语的例句同样是手输内容,不自动关联原文句子。
## #12 词汇库规则(2026-09-11)
**列表范围**:只返回当前会话本人、当前语言(英语空间)的词条,单词与短语都在同一列表里;任何查询都不接受用户或语言参数,越权编号与不存在编号同样得到 404。
**搜索规则**:搜索词去首尾空白后与**规范化身份键**、**显示原文**、**个人释义**三处做子串匹配,任一命中即返回。大小写不敏感:`term` 列是二进制排序规则,因此查询先转小写再比较;`original_form` 与 `definition` 用默认排序规则。不做分词、不做前缀优化。空查询=不过滤;查询超过 64 个字符返回 400。`%`、`_`、`\` 按字面值处理(转义后传入),所以搜索 `%` 得到的是包含百分号的词条,而不是全部词条。
**筛选与分页**:`status`(新词/学习中/已知/忽略)与 `kind`(词语/短语)可单独或组合使用,并可与搜索组合;非法枚举值 400,不静默忽略。分页为 `page`(从 1 开始)与 `limit`(默认 20,最大 100),按最近更新倒序排列,返回的 `total` 是过滤后的总数;超出末页返回空列表但 `total` 不变。筛选与页码写入 URL,刷新或返回列表都保留。
**编辑规则**:编辑按词条编号进行,沿用与阅读器保存相同的领域校验——释义最长 2000 字符、例句最多 5 条且每条最长 500 字符、只有「学习中」可以带 1~7 级、其他状态等级必须为 0。**身份不可编辑**:词形、原文与「词或短语」不由客户端决定,未知字段一律 400,所以编辑不会把一条记录变成另一条。只有状态或等级真正变化时才移动复习时间(进入「学习中」按该等级的间隔排期,改回「新词」立即到期,改成「已知」或「忽略」后不再进入到期队列);只改释义或例句保留原排期。编辑不改写历史:作答次数、答对与答错计数、复习记录都保留;同一内容重复提交没有额外副作用。
**界面契约**:列表行显示「原文 / 个人释义 · 状态」(短语标注「短语」并显示等级),提供编辑入口与「开始到期复习」入口;编辑对话框在「学习中」时显示 1~7 等级选择器,其他状态不显示;保存失败保留输入与对话框。空列表提示去阅读保存,「没有匹配的词条」提供一键清除搜索与筛选。
**已知偏差与边界**:原型的编辑页顶部有「来自 A small step」(来源章节),本单**不实现**——词条按身份存储、不引用章节(#10/#11 已确认规则),因此没有可显示的来源。不包含 CSV 导入导出(X04)、复杂复习范围筛选(X11)、删除词条或批量清理(X14),也不做批量操作。
## #13 完成阅读与统计规则(2026-09-15)
**完成阅读只记已读**:标记章节已读只写一条完成记录,**不批量改变任何词语的状态或等级**(批量标已知是 X10,明确不做)。只有处理完成的章节可以标记;处理中或失败的章节返回 409,因为那不是可读过的内容。标记是显式操作,滚动到底不会自动标记。
**重复完成不重复计数**:一章一行(`chapter_id` 主键)。重复请求返回同一行并带 `duplicate=true`,**不会移动已读时间也不会增加计数**;同一内容再次标记等同于第一次的结果。
**内容版本门控**:完成记录保存标记时该章的 `content_sha256`。阅读统计只认可「记录快照=章节当前内容」且章节 `ready` 的章节,因此:
- 正文被改成新版本后,该章立刻回到未读;重新读完再标记即更新同一行,不新增第二行。
- 只改标题不影响已读;把正文改回与记录相同的内容后重新标记即可恢复。
- 章节被删除时完成记录随外键级联消失,计数同时从分子与分母减去。
**统计口径**(全部按本人与本人当前语言过滤,管理员权限不放宽归属):
- **已读章节** = 内容匹配且 `ready` 的章节数;**分母** = 同一范围内 `ready` 的章节数,处理中或失败的章节不计入分母,界面显示为「已读章节 3 / 8 章(可阅读)」,把规则显示出来。
- **已知 / 学习中 / 新词 / 忽略**分别计数,不合并;忽略单独统计,不计入已知。**已保存词条**为本人该语言的全部词条(含忽略)。
- **待复习**使用与到期复习队列完全相同的谓词(状态为新词或学习中、且 `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`,对外由反向代理转发。
**验收账号规则**:试用验证使用虚构的演练账号,密码由演练进程生成、只经环境变量传入,不写入磁盘、日志、工单或截图。
## #21 附件与播放规则(2026-09-15)
**归属与可见性**:附件属于书籍所有者。任何跨账号的读取、替换、移除或位置上报都返回 `404`;未登录返回 `401`。附件与播放进度都按账号隔离,同一本书的播放位置不共享。
**格式与上限**:音频只接受 **MP3**(ID3 标记或 MPEG 帧同步),单文件 ≤ **20 MiB**;封面只接受 **JPG、PNG、WebP**,单文件 ≤ **2 MiB**,像素 ≤ **4096×4096**。类型**按文件内容判定,不看扩展名或客户端声明的 MIME**:把 SVG 改名成 `.png`、把文本改名成 `.mp3` 都会被拒绝。超限返回 `413`,类型或内容非法返回 `400` 并给出可读原因。首版只收 MP3,其它格式待桌面与手机浏览器实测后再谈,不承诺任意格式。
**替换与失败**:替换即覆盖同一行,一本书每个类型只有一条记录。**校验通过后才写入**,因此被拒绝的上传不会损坏已存在的附件。替换音频会**清空播放位置**——旧位置对新文件没有意义;移除音频同样删除位置记录。
**播放行为**:不自动播放,必须由用户点击;提供播放/暂停、拖动进度与 0.75/1.0/1.25/1.5 倍速。**音频不与文本同步**:没有时间戳、字幕或逐句对齐,也不做转写。播放中每 5 秒、暂停时与离开页面时上报位置;服务端只做上界校验(0 ≤ 秒 ≤ 24 小时)与 upsert,并发以最后一次写入为准,不解析音频时长(时长来自浏览器)。
**传输与缓存**:附件读取需要会话,支持 HTTP Range(拖动进度只需取所需片段),并在响应里带内容摘要作为 `ETag`,命中即返回 `304`。前端用带凭据的 fetch 取字节再交给 `<img>`/`<audio>`,因此**令牌不会出现在 URL、历史记录或访问日志里**;代价是浏览器会整份取回后再播放,暂不支持渐进式流式播放。
**删除与备份**:删除书籍级联删除附件与所有账号的该书进度。附件存于数据库,所以 #15 的备份与恢复契约自动覆盖附件与进度;恢复后附件可继续读取与播放,这一点已由 2026-09-15 的恢复演练验证(逐表内容校验和一致、恢复实例按字节相同地返回音频与封面)。
**范围外**:多音轨、分章音轨、字幕与时间戳同步、逐句跟读、转写、TTS、音频转码、自动搜索封面、在线音频地址抓取、公开分享与跨用户共享。
## #37 章级附件规则(2026-09-15)
**挂载单位**:封面属于**书**(一本书一张,维持 #21 已验收的规则);音频与插图属于**章**(一章各一张)。多篇文章合并成一本书时,每章可以有自己的音频与插图,互不影响。没有附件的章节保持原样:不显示占位框、不显示播放器。
**格式与上限**:音频只接受 **MP3**(ID3 或帧同步),≤ **20 MiB**;插图与封面同规格:**JPG/PNG/WebP**,≤ **2 MiB**,像素 ≤ **4096×4096**;一律**按文件内容判定类型**,不看扩展名与客户端声明。超限 `413`、类型或内容非法 `400` 并给出可读原因;**被拒绝的上传保留旧文件**。
**替换与位置**:替换即覆盖同一章同一类型的那一行。**替换或移除音频会清空该章的播放位置**(旧位置对新文件没有意义);插图与音频互不影响,移除其一不会动另一个。
**播放位置**:粒度是**账号+章节**,不是账号+书——同一本书的不同章节各记各的。播放中每 5 秒、暂停时、离开当前章节或切换章节时各上报一次;服务端只做 0~24 小时的上界校验与 upsert,并发以最后一次写入为准,不解析音频时长。
**读取与缓存**:插图与音频都需要会话,跨账号一律 `404`、匿名 `401`;支持 **HTTP Range(206)**、`416` 与 `ETag`/`304`。前端用带凭据的 fetch 取字节再交给 `<img>`/`<audio>`,令牌不出现在 URL 中。
**界面**:书籍页的「附件」按钮打开该章的附件对话框,与「编辑」分开——改正文会重新处理章节,改附件不会。阅读页里插图固定显示在本章正文开头(章节标题与播放器条之下),不参与划词;播放器条仍为 sticky,判据改为「本章有音频」。
**删除与备份**:删除章节(#10 的事务级联)连带删除该章附件与该章所有账号的位置记录;删除书籍通过章节级联清空。附件存于数据库,因此 #15 的备份与恢复自动覆盖,恢复后按字节相同并可继续拖动。
**范围外**:多张插图、正文中间图文混排、缩略图墙式独立浏览界面、多音轨与分章音轨、字幕与时间戳同步、逐句跟读、转写、TTS、转码、自动搜图、在线音频地址、公开分享与跨用户共享。
## #37 章级附件规则(2026-09-15)
**挂载单位**:封面属于**书**(一本书一张,维持 #21 已验收的规则);音频与插图属于**章**(一章各一张)。多篇文章合并成一本书时,每章可以有自己的音频与插图,互不影响。没有附件的章节保持原样:不显示占位框、不显示播放器。
**格式与上限**:音频只接受 **MP3**(ID3 或帧同步),≤ **20 MiB**;插图与封面同规格:**JPG/PNG/WebP**,≤ **2 MiB**,像素 ≤ **4096×4096**;一律**按文件内容判定类型**,不看扩展名与客户端声明。超限 `413`、类型或内容非法 `400` 并给出可读原因;**被拒绝的上传保留旧文件**。
**替换与位置**:替换即覆盖同一章同一类型的那一行。**替换或移除音频会清空该章的播放位置**(旧位置对新文件没有意义);插图与音频互不影响,移除其一不会动另一个。
**播放位置**:粒度是**账号+章节**,不是账号+书——同一本书的不同章节各记各的。播放中每 5 秒、暂停时、离开当前章节或切换章节时各上报一次;服务端只做 0~24 小时的上界校验与 upsert,并发以最后一次写入为准,不解析音频时长。
**读取与缓存**:插图与音频都需要会话,跨账号一律 `404`、匿名 `401`;支持 **HTTP Range(206)**、`416` 与 `ETag`/`304`。前端用带凭据的 fetch 取字节再交给 `<img>`/`<audio>`,令牌不出现在 URL 中。
**界面**:书籍页的「附件」按钮打开该章的附件对话框,与「编辑」分开——改正文会重新处理章节,改附件不会;**章节列表不显示章节图片**(也不预取),章节是否有插图在附件对话框里可见。阅读页里插图以**缩略图**形式显示在本章正文开头(章节标题与播放器条之下),**点击缩略图在弹窗中查看原图**(保持比例、不裁切;Esc 关闭后正文位置不变);缩略图是按钮而不是正文的一部分,不参与划词;播放器条仍为 sticky,判据为「本章有音频」。
**删除与备份**:删除章节(#10 的事务级联)连带删除该章附件与该章所有账号的位置记录;删除书籍通过章节级联清空。附件存于数据库,因此 #15 的备份与恢复自动覆盖,恢复后按字节相同并可继续拖动。
**范围外**:多张插图、正文中间图文混排、缩略图墙式独立浏览界面、多音轨与分章音轨、字幕与时间戳同步、逐句跟读、转写、TTS、转码、自动搜图、在线音频地址、公开分享与跨用户共享。
## #37 章级附件规则(2026-09-15)
**挂载单位**:封面属于**书**(一本书一张,维持 #21 已验收的规则);音频与插图属于**章**(一章各一张)。多篇文章合并成一本书时,每章可以有自己的音频与插图,互不影响。没有附件的章节保持原样:不显示占位框、不显示播放器。
**格式与上限**:音频只接受 **MP3**(ID3 或帧同步),≤ **20 MiB**;插图与封面同规格:**JPG/PNG/WebP**,≤ **2 MiB**,像素 ≤ **4096×4096**;一律**按文件内容判定类型**,不看扩展名与客户端声明。超限 `413`、类型或内容非法 `400` 并给出可读原因;**被拒绝的上传保留旧文件**。
**替换与位置**:替换即覆盖同一章同一类型的那一行。**替换或移除音频会清空该章的播放位置**(旧位置对新文件没有意义);插图与音频互不影响,移除其一不会动另一个。
**播放位置**:粒度是**账号+章节**,不是账号+书——同一本书的不同章节各记各的。播放中每 5 秒、暂停时、离开当前章节或切换章节时各上报一次;服务端只做 0~24 小时的上界校验与 upsert,并发以最后一次写入为准,不解析音频时长。
**读取与缓存**:插图与音频都需要会话,跨账号一律 `404`、匿名 `401`;支持 **HTTP Range(206)**、`416` 与 `ETag`/`304`。前端用带凭据的 fetch 取字节再交给 `<img>`/`<audio>`,令牌不出现在 URL 中。
**界面**:书籍页的章节列表每行只保留一个「编辑」按钮,点开后一个对话框同时编辑标题、正文、本章插图与本章音频(**一个入口、一处编辑**);**章节列表不显示章节图片**(也不预取)。对话框内两种生效时机写得很清楚:**标题与正文点「保存章节」后生效;插图与音频在选中文件后立即上传**,取消不会撤销已上传的文件;改正文会重新处理章节(只改标题不会),而已上传的插图与音频挂在章节上、不随正文版本变化。阅读页里插图以**缩略图**形式显示在本章正文开头(章节标题与播放器条之下),**点击缩略图在弹窗中查看原图**(保持比例、不裁切;Esc 关闭后正文位置不变);缩略图是按钮而不是正文的一部分,不参与划词;播放器条仍为 sticky,判据为「本章有音频」。
**删除与备份**:删除章节(#10 的事务级联)连带删除该章附件与该章所有账号的位置记录;删除书籍通过章节级联清空。附件存于数据库,因此 #15 的备份与恢复自动覆盖,恢复后按字节相同并可继续拖动。
**范围外**:多张插图、正文中间图文混排、缩略图墙式独立浏览界面、多音轨与分章音轨、字幕与时间戳同步、逐句跟读、转写、TTS、转码、自动搜图、在线音频地址、公开分享与跨用户共享。
## #37 章级附件规则(2026-09-15)
**挂载单位**:封面属于**书**(一本书一张,维持 #21 已验收的规则);音频与插图属于**章**(一章各一张)。多篇文章合并成一本书时,每章可以有自己的音频与插图,互不影响。没有附件的章节保持原样:不显示占位框、不显示播放器。
**格式与上限**:音频只接受 **MP3**(ID3 或帧同步),≤ **20 MiB**;插图与封面同规格:**JPG/PNG/WebP**,≤ **2 MiB**,像素 ≤ **4096×4096**;一律**按文件内容判定类型**,不看扩展名与客户端声明。超限 `413`、类型或内容非法 `400` 并给出可读原因;**被拒绝的上传保留旧文件**。
**替换与位置**:替换即覆盖同一章同一类型的那一行。**替换或移除音频会清空该章的播放位置**(旧位置对新文件没有意义);插图与音频互不影响,移除其一不会动另一个。
**播放位置**:粒度是**账号+章节**,不是账号+书——同一本书的不同章节各记各的。播放中每 5 秒、暂停时、离开当前章节或切换章节时各上报一次;服务端只做 0~24 小时的上界校验与 upsert,并发以最后一次写入为准,不解析音频时长。
**读取与缓存**:插图与音频都需要会话,跨账号一律 `404`、匿名 `401`;支持 **HTTP Range(206)**、`416` 与 `ETag`/`304`。前端用带凭据的 fetch 取字节再交给 `<img>`/`<audio>`,令牌不出现在 URL 中。
**章节作者**:作者是**可选自由文本**(≤120 字符),随章节标题与正文一起保存,去首尾空白,留空即清空;它只用于展示(阅读页在章节标题下显示),不参与归类、检索或权限判断,也不要求与书级数据一致。
**界面**:书籍页的章节列表每行只保留一个「编辑」按钮,点开后一个对话框同时编辑标题、正文、本章插图与本章音频(**一个入口、一处编辑**);**章节列表不显示章节图片**(也不预取)。对话框内两种生效时机写得很清楚:**标题、作者与正文点「保存章节」后生效;插图与音频在选中文件后立即上传**,取消不会撤销已上传的文件;改正文会重新处理章节(只改标题不会),而已上传的插图与音频挂在章节上、不随正文版本变化。阅读页里插图以**缩略图**形式显示在本章正文开头(章节标题与播放器条之下),**点击缩略图在弹窗中查看原图**(保持比例、不裁切;Esc 关闭后正文位置不变);缩略图是按钮而不是正文的一部分,不参与划词;播放器条仍为 sticky,判据为「本章有音频」。
**删除与备份**:删除章节(#10 的事务级联)连带删除该章附件与该章所有账号的位置记录;删除书籍通过章节级联清空。附件存于数据库,因此 #15 的备份与恢复自动覆盖,恢复后按字节相同并可继续拖动。
**范围外**:多张插图、正文中间图文混排、缩略图墙式独立浏览界面、多音轨与分章音轨、字幕与时间戳同步、逐句跟读、转写、TTS、转码、自动搜图、在线音频地址、公开分享与跨用户共享。
## #37 章级附件规则(2026-09-15)
**挂载单位**:封面属于**书**(一本书一张,维持 #21 已验收的规则);音频与插图属于**章**(一章各一张)。多篇文章合并成一本书时,每章可以有自己的音频与插图,互不影响。没有附件的章节保持原样:不显示占位框、不显示播放器。
**格式与上限**:音频只接受 **MP3**(ID3 或帧同步),≤ **20 MiB**;插图与封面同规格:**JPG/PNG/WebP**,≤ **2 MiB**,像素 ≤ **4096×4096**;一律**按文件内容判定类型**,不看扩展名与客户端声明。超限 `413`、类型或内容非法 `400` 并给出可读原因;**被拒绝的上传保留旧文件**。
**替换与位置**:替换即覆盖同一章同一类型的那一行。**替换或移除音频会清空该章的播放位置**(旧位置对新文件没有意义);插图与音频互不影响,移除其一不会动另一个。
**播放位置**:粒度是**账号+章节**,不是账号+书——同一本书的不同章节各记各的。播放中每 5 秒、暂停时、离开当前章节或切换章节时各上报一次;服务端只做 0~24 小时的上界校验与 upsert,并发以最后一次写入为准,不解析音频时长。
**读取与缓存**:插图与音频都需要会话,跨账号一律 `404`、匿名 `401`;支持 **HTTP Range(206)**、`416` 与 `ETag`/`304`。前端用带凭据的 fetch 取字节再交给 `<img>`/`<audio>`,令牌不出现在 URL 中。
**书籍作者与章节作者**:两者是**互相独立**的可选自由文本(各 ≤120 字符,各自去首尾空白,留空即清空):书级作者在书籍页显示于书名右侧、在「编辑书籍」里维护;章节作者在阅读页显示于章节标题下、在章节「编辑」里维护。**章节不会从书级继承作者**,字段也不参与归类、检索或权限判断(如需继承或检索,需要单独确认)。省略作者字段的编辑请求保留原值。
**章节作者**:作者是**可选自由文本**(≤120 字符),随章节标题与正文一起保存,去首尾空白,留空即清空;它只用于展示(阅读页在章节标题下显示),不参与归类、检索或权限判断,也不要求与书级数据一致。
**界面**:书籍页的章节列表每行只保留一个「编辑」按钮,点开后一个对话框同时编辑标题、正文、本章插图与本章音频(**一个入口、一处编辑**);**章节列表不显示章节图片**(也不预取)。对话框内两种生效时机写得很清楚:**标题、作者与正文点「保存章节」后生效;插图与音频在选中文件后立即上传**,取消不会撤销已上传的文件;改正文会重新处理章节(只改标题不会),而已上传的插图与音频挂在章节上、不随正文版本变化。阅读页里插图以**缩略图**形式显示在本章正文开头(章节标题与播放器条之下),**点击缩略图在弹窗中查看原图**(保持比例、不裁切;Esc 关闭后正文位置不变);缩略图是按钮而不是正文的一部分,不参与划词;播放器条仍为 sticky,判据为「本章有音频」。
**删除与备份**:删除章节(#10 的事务级联)连带删除该章附件与该章所有账号的位置记录;删除书籍通过章节级联清空。附件存于数据库,因此 #15 的备份与恢复自动覆盖,恢复后按字节相同并可继续拖动。
**范围外**:多张插图、正文中间图文混排、缩略图墙式独立浏览界面、多音轨与分章音轨、字幕与时间戳同步、逐句跟读、转写、TTS、转码、自动搜图、在线音频地址、公开分享与跨用户共享。
## #32 版本回退规则(2026-09-16)
**正文版本可以来回切换**:把一章的正文改回曾经用过的文本,属于发布该内容版本,必须正常成功——不再出现无解释的错误,也不会因此产生重复任务行。规则如下:
- 一个内容版本对应一条任务记录:初次成为当前版本时创建,**再次成为当前版本时复用**(把该记录重置为待处理、重试次数归零、清空上次失败原因),因此反复来回切换不会累积任务行。
- **旧版本的任务不会复活新版本章节**:只有「任务内容摘要 = 章节当前内容」的任务可以被重试或处理,否则拒绝。
- 同一版本重复提交**没有副作用**:正文没变时不产生新版本,也不新增任务。
- 上次处理失败的版本在被重新发布时**重试次数归零**,因为「人再次要求」不应被此前的失败预算挡住。
- 回退到早期版本**不会恢复**当时的词条状态、复习排期或章节完成记录:正文版本与个人学习状态是分开的(#10/#11 已确认的口径)。
## 词典、音标与个人释义的边界(#40)
- **词典内容不是个人数据**:WordNet(英英)与英汉词典(ECDICT 子集 + CMUdict 音标)是全局资源,
对所有学习账号相同,管理员可分别启用或停用;停用只影响查询时的可用来源,不删除个人已保存内容。
- **个人释义与状态优先**:用户自己写下的释义、状态与等级仍按 #7/#8 的规则独立保存,不因词典新增中文释义
而被改写、合并或自动填充;面板上中文释义与英英释义只是参考内容。
- **音标来源必须可见**:IPA 转写(CMUdict)与 ECDICT 记法(仅做字符级规范化)是两种不同的可信度,
系统记录 `phoneticSource`;不确定的记法直接不显示,不用猜测结果冒充实测音标。`grok` 属此类。
- **屈折形**:`dogs` 通过 WordNet 的规则解析看到词目 `dog` 的释义属允许行为,但音标不随屈折形显示,
避免把词目的发音说成屈折形的发音。
- **子集口径**:英汉词典只收常用词(约 8.2 万条:小写单词且存在于 WordNet 词表或带考试标签),
不导入 ECDICT 全量 340 万条;漏收的词表现为"只有英英释义",不是错误。
- **许可**:ECDICT 仓库为 MIT,词典数据由公开资料整理,再分发前需自行评估来源;CMUdict 为 BSD-2;
两份许可证随仓库保存(`server/ECDICT-LICENSE.txt`、`server/CMUDICT-LICENSE.txt`),词典归档本身不提交进 Git。
术语:**英英词典**(WordNet,英语释义)、**英汉词典**(ECDICT 子集,中文释义 + 音标)、
**IPA 转写**(由 CMUdict 音素按固定映射转成)、**ECDICT 记法**(老式音标,仅做字符级规范化)、
**子集口径**(决定哪些词进入英汉词典的规则)。
+844 -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: bbd6e618f4339aa45d81bba0492045b60581a735
synchronized_at: 2026-09-11T03:59:02Z
wiki_revision: 45306a5d50c5674f512ea206739c6b2d89e88a85
synchronized_at: 2026-09-16T13:51:36Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -320,3 +320,845 @@ node --test spikes/english/view.test.mjs
主审真实API联调使用真实管理端session模块经5174代理上传;经5173代理两测试账号分别创建虚构章节并验证精确/不规则词形、Unicode原文片段、越权404/普通用户管理403、停用/重复导入启用、错误ZIP保留资源。新增测试书籍id3/4、章节id8/9归issue5_a/issue5_b,没有修改其他账号的书籍。
常驻5173一度返回空白页:Vue模块转换500、代理缺失;同代码隔离服务正常,只重启lexgo-learner加载配置后恢复,默认E2E通过,未改启动配置。桌面交互浏览器工具因旧会话失效未完成手工真实UI联调;已有项目Playwright使用模拟API,真实API验证另列。手机仅窄屏自动测试,真机证据仍未补齐。
## #7 验证与迁移(2026-09-11)
仓库根执行;Go 工具链由 `python scripts/server.py` 固定 go1.26.5。本单使用专用测试库 lexgo_test_issue7,不借用其他测试库。
| 命令 | 结果 |
|---|---|
| `go vet ./...` | 通过 |
| `LEXGO_TEST_DB_NAME=lexgo_test_issue7 python scripts/server.py test-integration` | 34 个顶层用例全部通过、0 跳过;含 #7 新增 3 个 MySQL 用例、4 个单元用例和 1 个 v4→v5 迁移用例 |
| `cd learner`:`npx vitest --run` / `npx vue-tsc --build` / `npx pnpm run build` / `npx playwright test` | 58 项单测、类型检查、构建、3 项默认 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 项与严格检查通过 |
覆盖内容:非法状态与等级边界、文本上限、词形身份与大小写合并、词形不按 lemma 合并、重复与并发保存只留一条记录、跨章节同形词状态一致、两账号互不影响、篡改 owner/language/term 被拒绝、非本人章节 404、未登录 401、未就绪章节 409、v4→v5 迁移保留既有数据与检查约束。
本机开发库 lexgo_dev 已显式从 v4 升级到 v5:升级前后 sys_user 6、lexgo_spaces 6、lexgo_sessions 8、lexgo_books 4、lexgo_chapters 9、lexgo_ingest_jobs 9、lexgo_dictionaries 1 全部不变,新增空的 lexgo_terms。旧二进制备份在忽略的 `.local/lexgo-pre-issue7.exe`。回退:停止 lexgo-api,恢复旧二进制,把 `lexgo_schema` 标记改回 4 后启动;保留 lexgo_terms 与全部既有数据,不删除数据、不重新 bootstrap。
真实链路验证:真实 Go API+真实 MySQL 共 30 项检查通过(凭据只从本机安全配置读入进程),覆盖两个虚构测试账号 issue5_a/issue5_b 的登录、保存、幂等、状态边界、跨章节一致、跨账号隔离与越权拒绝;随后用临时 Playwright 用例在真实学习端+真实 API 上以两个账号复核保存、重新加载后的高亮与预填,以及 390×844 窄屏底部面板。截图保存在本机 `.local/evidence/`(issue7-reader-desktop.png、issue7-reader-mobile-390.png、issue7-account-b.png),临时用例运行后删除。
未验证:真实手机触屏详细证据与完整备份恢复演练仍属既有缺口(#14/#15);本单只用桌面浏览器窄屏检查,不当作真机结果。
## #8 验证与迁移(2026-09-11)
仓库根执行;Go 工具链由 `python scripts/server.py` 固定 go1.26.5。本单使用专用测试库 lexgo_test_issue8,不借用其他测试库。
| 命令 | 结果 |
|---|---|
| `go vet ./...` | 通过 |
| `LEXGO_TEST_DB_NAME=lexgo_test_issue8 python scripts/server.py test-integration` | 41 个顶层用例全部通过、0 跳过;含 #8 新增 6 个复习用例与 1 个 v5→v6 迁移用例 |
| `cd learner`:`npx vitest --run` / `npx vue-tsc --build` / `npx pnpm run build` / `npx playwright test` | 71 项单测、类型检查、构建、5 项 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 项与严格检查通过 |
覆盖内容:间隔表与每个状态转换(含等级上下限)、到期边界(`due_at = now` 到期、早 1 毫秒不到期)、入队范围(已知/忽略不入队)、两账号队列互不可见、重复提交只记一次、同一词条双标签页 second 写入为 stale、作答归属 404、已知词条 409、未知评分/缺少到期时间/未知字段 400、显式等级的排期规则、幂等键唯一约束、v5→v6 保留既有词条与计数。
本机开发库 lexgo_dev 已显式从 v5 升级到 v6:升级前后 sys_user 6、lexgo_spaces 6、lexgo_sessions 5、lexgo_books 5、lexgo_chapters 10、lexgo_ingest_jobs 10、lexgo_dictionaries 1、lexgo_terms 4 全部不变;新增 `lexgo_term_reviews` 4 行(全部 `due_at = created_at`)与空的 `lexgo_review_answers`。旧二进制备份在忽略的 `.local/lexgo-pre-issue8.exe`。回退:停止 lexgo-api,恢复旧二进制,把 `lexgo_schema` 标记改回 5 后启动;新表不影响旧二进制写入个人词条,保留新表与全部既有数据,不删除数据、不重新 bootstrap。
真实链路验证:真实 Go API+真实 MySQL 共 38 项检查通过(凭据只从本机安全配置读入进程,脚本可重复运行),覆盖到期即时入队、答对升级与 1 天排期、答错立即回队、重放与 stale 不重复推进、越权与非法输入拒绝、两账号互不影响、已知词条拒绝作答;随后用临时 Playwright 用例在真实学习端+真实 API 上完成「登录→粘贴导入→点词查义→保存个人释义→到期复习作答→回到阅读器确认状态」的完整闭环,并复核 390×844 窄屏无横向溢出。截图保存在本机 `.local/evidence/`(issue8-reader-saved.png、issue8-review-revealed.png、issue8-review-summary.png、issue8-reader-reviewed.png、issue8-review-mobile-390.png),临时用例运行后删除。
运维记录:本次只有 lexgo-api(新二进制)与 lexgo-learner 被重启;学习端常驻 Vite 在长时间运行后一度对 `/src/style.css` 返回空样式表,导致 E2E 观察到 `white-space: normal`,重启后恢复,未改动代码或配置。其余实例保持 Running。
未验证:真实手机触屏详细证据与完整备份恢复演练仍属既有缺口(#14/#15);本单只用桌面浏览器窄屏检查,不当作真机结果。并发只覆盖到「双标签页同一词条」这一层,没有做多用户压测。
## #8 审核整改(R1~R3,2026-09-11)
独立审核(Claude Code)只读审阅提交 `0328505`/`ec5ec2d`,指出三处影响验收标准第 2、3 条的问题。三点均先在 `lexgo_test_issue8` 写复现用例观察到失败,再修复并转绿。
| 问题 | 现象与根因 | 修复 | 回归用例 |
|---|---|---|---|
| R2 并发同键返回 500 | 两个同时到达、带同一 `answerId` 的请求都在加锁前查不到记录;后者进入 stale 分支插入相同 `answer_key`,触发唯一键冲突返回 500 | 取得词条行锁后再用加锁读复查一次答案键,命中直接返回首次结果;stale 插入遇到 1062 也转为返回记录 | `TestMySQLReviewConcurrentReplayOfOneAnswer`(两个 goroutine 同键提交,两个都 2xx、`review_count = 1`、只有一条答案记录) |
| R3 编辑文本会重排复习 | `saveTerm` 无条件调用 `syncTermReview`,编辑释义/例句也会把 `due_at` 重算,逾期词条被挤出当天队列 | 保存前加锁读取旧行,只有新建或状态/等级实际变化才移动 `due_at`;缺行时补建排期行 | `TestMySQLReviewEditKeepsSchedule`(逾期 3 级词只改释义:`due_at` 与队列不变;改等级则重排) |
| R3 附带发现:面板保存把等级重置为 1 | 阅读器面板只提交状态不提交等级,`termLevel` 对缺省等级一律返回 1,于是 4 级词改一个错字会掉到 1 级 | 保存未提及等级时保留已获得的等级;只有进入 `学习中` 才从 1 开始 | `TestMySQLReviewPanelSaveKeepsLevel`(4 级词面板式保存后仍为 4 级,且排期不变;退出再进入学习中则从 1 开始) |
**答案契约随之明确**:作答响应 `result` 只取 `applied`/`stale`,另加 `duplicate` 布尔标记。重放返回首次结果并把 `duplicate` 置真,客户端因此可以按首次结果计数:网络把响应丢掉后点「重试提交」拿到 `duplicate=true` 的 `applied`,本轮计数正常增加,完成页不会退化成「今天没有到期词条」。已应用的作答返回 201,重放与 stale 返回 200(R1)。
**提示与注释(R4、R5)**:卡片因 `stale` 离开时页面显示 `role="status"` 提示「该词已在其他页面复习,本次未计分。」,重放且首次为 stale 时显示「该词已按上一次的评分记录,未重复计分。」;本轮只解决卡片而没有新计分时,完成页显示「本轮没有新的计分:N 个词条已在其他页面复习。」。`answerId` 的作用域注释改为「每张卡片一个,失败重试复用」,与 `answerIdFor` 的实现一致。
**流程记录(R6)**:评论 7769 的方案写的是在 `lexgo_terms` 上增加列,实际实现改为独立表 `lexgo_term_reviews`(加法迁移可重试、不对既有表做 ALTER)。该变更在实施评论 7776 与 Wiki 中说明了原因,但没有按「数据结构变化先更新工单」的要求在实施前追加变更评论;本页与上文契约按实际实现记录,方案评论中的「新增列均有默认值」以独立表为准。
整改后重跑:Go 单元与集成测试(专用库 `lexgo_test_issue8`)44 个顶层用例全部通过、0 跳过;学习端 73 项单测、类型检查、构建与 5 项 E2E 通过;管理端 31 项与 lint 通过;治理 56 项与严格检查通过;真实 API+MySQL 42 项检查通过(新增 4 项针对 R3 与重放契约);真实浏览器复核面板保存与复习闭环通过。截图 `.local/evidence/issue8-fixed-summary.png`。
## #9 验证与迁移(2026-09-11)
仓库根执行;Go 工具链由 `python scripts/server.py` 固定 go1.26.5。本单使用专用测试库 lexgo_test_issue9,不借用其他测试库。本单**不改动数据库结构**,所以没有迁移步骤,回退只需换回旧二进制。
| 命令 | 结果 |
|---|---|
| `go vet ./...` | 通过 |
| `LEXGO_TEST_DB_NAME=lexgo_test_issue9 python scripts/server.py test-integration` | 50 个顶层用例全部通过、0 跳过;含 #9 新增 6 个上传用例 |
| `cd learner`:`npx vitest --run` / `npx vue-tsc --build` / `npx pnpm run build` / `npx playwright test` | 80 项单测、类型检查、构建、6 项 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 项与严格检查通过 |
覆盖内容:有效 UTF-8(含 CRLF、制表符、弯引号、em dash、省略号、emoji、组合字符)字节级往返、UTF-8 BOM 剥离且不进原文、只有 BOM、非法 UTF-8、Latin-1、UTF-16 大小端、NUL 字节、空文件、只有空白、超限与恰好边界(2 MiB、100000 码点)、缺 `file`、缺标题、缺或错误 `language`、缺或过短 `requestId`、未知字段、追加路径携带 `language`、非 multipart 请求、未登录、恶意文件名不影响存储、重复上传只产生一章、同编号换内容 409、追加他人书籍 404、两账号隔离、上传与粘贴共用同一任务管线。
真实链路验证:真实 Go API+真实 MySQL 共 26 项检查通过(凭据只从本机安全配置读入进程),覆盖有效文件与阅读器原文逐字节一致、BOM 不进入原文、五类无效文件、越权与未登录拒绝、两账号隔离、恶意文件名不泄漏;随后用临时 Playwright 用例在真实学习端完成「登录→切换 TXT→选择真实 UTF-8 文件→上传→处理中就绪→阅读器原文逐字符一致」的闭环,并复核 UTF-16 文件在浏览器预检阶段被拒。截图保存在本机 `.local/evidence/`(issue9-invalid-encoding.png、issue9-upload-processing.png、issue9-reader.png),临时用例运行后删除。
未验证:真实手机触屏详细证据与完整备份恢复演练仍属既有缺口(#14/#15);本单只用桌面浏览器检查。大文件并发上传只按单槽并发门设计,没有做多用户压力测试。UTF-16/GB18030 转码与按空行自动分章不在本单。
## #10 验证与迁移(2026-09-11)
仓库根执行;Go 工具链由 `python scripts/server.py` 固定 go1.26.5。本单使用专用测试库 lexgo_test_issue9(沿用上一单的库),不借用其他测试库。本单**不改动数据库结构**,没有迁移步骤,回退只需换回旧二进制。
| 命令 | 结果 |
|---|---|
| `go vet ./...` | 通过 |
| `LEXGO_TEST_DB_NAME=lexgo_test_issue9 python scripts/server.py test-integration` | 59 个顶层用例全部通过、0 跳过;含 #10 新增 9 个用例 |
| `cd learner`:`npx vitest --run` / `npx vue-tsc --build` / `npx pnpm run build` / `npx playwright test` | 94 项单测、类型检查、构建、7 项 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 项与严格检查通过 |
覆盖内容:改名不改状态也不新建任务、标题/正文校验、正文变化产生新版本且保留章节编号、**处理中改正文后旧任务只标 `superseded` 且不触碰新版本**、旧版本任务重试 409、重复保存与改回原内容不新建任务、内容与版本不一致时按 `content_changed` 失败、恢复扫描作废过期版本且不重排新版本、编辑用原文对任意状态可读、删除章节后序号连续且导航正确、删除书籍级联清理章节与任务、删除期间在途任务不复活内容、并发删除同一章一个成功一个 404、重复删除 404、跨用户改名/编辑/删除/读原文一律 404、未登录 401、个人词条与复习排期在删除后完全保留。
真实链路验证:真实 Go API+真实 MySQL 共 36 项检查通过(凭据只从本机安全配置读入进程,脚本可重复运行并自行清理 fixture),覆盖改名、编辑产生新版本并重新处理、重复保存幂等、删除章节重排序号、删除书籍级联、个人词条与复习队列在删除后保留、越权与非法输入拒绝;随后用临时 Playwright 用例在真实学习端完成「导入→改名→编辑正文→新版就绪→删除章节→删除书籍→书库提示」的闭环,并核对阅读器原文等于新版本。截图保存在本机 `.local/evidence/`(issue10-book-after-edit.png、issue10-chapter-deleted.png、issue10-book-deleted.png),临时用例运行后删除。
未验证:真实手机触屏详细证据与完整备份恢复演练仍属既有缺口(#14/#15);本单只用桌面浏览器检查。并发只覆盖「同一章节并发删除」与「处理中编辑」两类,没有做多用户压力测试。浏览器 textarea 会把该章的 CRLF 归一为 LF,属已知边界,已记入业务规则页。
## #11 验证与迁移(2026-09-11)
仓库根执行;Go 工具链由 `python scripts/server.py` 固定 go1.26.5。本单使用专用测试库 lexgo_test_issue9,不借用其他测试库。**本单不新增数据库列或表**,schema 保持 v6,没有迁移步骤,回退只需换回旧二进制。
| 命令 | 结果 |
|---|---|
| `go vet ./...` | 通过 |
| `LEXGO_TEST_DB_NAME=lexgo_test_issue9 python scripts/server.py test-integration` | 64 个顶层用例全部通过、0 跳过;含 #11 新增 5 个短语用例 |
| `cd learner`:`npx vitest --run` / `npx vue-tsc --build` / `npx pnpm run build` / `npx playwright test` | 106 项单测、类型检查、构建、11 项 E2E 全部通过(含 #11 新增 12 单测与 4 项 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 项与严格检查通过 |
覆盖内容:身份键的标点/换行/大小写/弯撇号归一、词数上限(12 与 13)、身份键与片段长度上限、切进单词被拒绝(服务端)、单词语义不被当成短语、跨章节匹配(同一短语在不同章节写法不同仍是同一条记录,两章都高亮,第一次出现两次)、最左最长与输入顺序无关、短语覆盖内部单词而不改动单词记录、短语进同一到期队列并作答(201 应用、重复提交回放)、越权 404 与未登录 401、编辑正文后不再出现则不报错且词条与排期保留、删除章节后词条保留;前端纯函数层覆盖整词对齐、内部保留、端点按词调整不反向、命中优先级与区间换算;E2E 覆盖**真实鼠标拖选**(Chromium 真实输入)→ 短语面板 → 保存 → 两次出现同时高亮 → 端点调整 → 点击已保存短语 → 复习整段挖空。
真实链路验证:真实 Go API+真实 MySQL 共 30 项检查通过(凭据只从本机安全配置读入进程,脚本自建两章 fixture 并在结束前删除书籍)。覆盖保存与身份键、显示原文保留标点、跨章节同一条记录与两处高亮、span 两端必须是词、短语内的单词仍是独立词条、单词/切词/伪造身份/越权/未登录的拒绝、到期队列与作答(含重复提交回放)、编辑正文后不再高亮但条目与排期保留、删除章节后条目保留。随后用临时 Playwright 用例在真实学习端完成「导入 → 选择范围 → 保存短语 → 两处高亮 → 点击已保存短语 → 复习整段挖空 → 清理 fixture」闭环,并用程序化选区在同一真实页面上验证范围映射与服务端身份一致。
未验证与已知限制:真实手机手柄与滚动的手感仍是 #4 起的既有缺口,本单只用桌面浏览器检查。**Playwright 的合成鼠标拖拽在真实页面上不会扩展原生选区**(在同一浏览器里对 mock 页面是成功的,程序化选区在真实页面也能唤起面板),因此真实链路的范围构建改用真实点击 + Shift 点击,连续拖选由 mock E2E 与程序化选区覆盖;真人鼠标拖选与真机手柄仍需人工复核。短语只在同一学习者与语言内匹配,不跨账号共享。
## #12 验证与迁移(2026-09-11)
仓库根执行;Go 工具链由 `python scripts/server.py` 固定 go1.26.5。本单使用专用测试库 lexgo_test_issue9,不借用其他测试库。**本单不新增数据库列或表**,schema 保持 v6,没有迁移步骤,回退只需换回旧二进制。
| 命令 | 结果 |
|---|---|
| `go vet ./...` | 通过 |
| `LEXGO_TEST_DB_NAME=lexgo_test_issue9 python scripts/server.py test-integration` | 69 个顶层用例全部通过、0 跳过;含 #12 新增 4 个用例 |
| `cd learner`:`npx vitest --run` / `npx vue-tsc --build` / `npx pnpm run build` / `npx playwright test` | 120 项单测、类型检查、构建、13 项 E2E 全部通过(新增 13 单测与 2 项 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 项与严格检查通过 |
覆盖内容:查询解析与边界(未知键、重复键、`page=0`、`limit=101`、非法状态与类型、查询超长)、LIKE 通配符转义、空查询与去空白、三处命中(身份键/显示原文/中文释义)、大小写不敏感、搜索与状态/类型筛选的组合、分页(首/末/越界页与 `total` 稳定、排序稳定)、只返回本人与当前语言、编辑(只改文本不动排期、改状态重排、进入学习中按等级排期、已知或忽略立刻离开到期队列、改回新词立即到期、计数与作答记录保留、同内容重复提交无副作用)、等级与状态边界、身份不可编辑、空编辑与未知字段 400、越权 404 与未登录 401;前端覆盖列表与分页、搜索与筛选(含 URL 恢复)、空态与无结果清除、编辑对话框的等级选择器、保存失败保留输入、旧响应丢弃。
真实链路验证:真实 Go API+真实 MySQL 共 40 项检查通过(凭据只从本机安全配置读入进程,脚本可重复运行并自行恢复 fixture),覆盖列表字段与排序、大小写不敏感搜索、中文释义搜索、通配符字面值、类型与状态筛选组合、分页与越界页、参数拒绝、两账号隔离、未登录、编辑的排期规则与身份不可编辑。随后用临时 Playwright 用例在真实学习端完成「进入生词本 → 按类型筛选 → 搜索 → 编辑释义 → 保存 → 刷新后筛选仍保留 → 还原释义」闭环。截图保存在本机 `.local/evidence/`(issue12-vocab-list.png、issue12-vocab-none.png、issue12-vocab-edit.png、issue12-real-list.png、issue12-real-search.png、issue12-real-edit.png),临时用例运行后删除。
未验证:真实手机触屏与滚动证据仍属 #4/#11 起的既有缺口(由 #14 承接);本单只用桌面浏览器检查。搜索只做子串匹配,未做分词或相关度排序;大词汇量的性能未做专门压测(单页上限 100,列表查询使用 `owner_id + language` 前缀的既有索引)。
## #13 迁移与验证(2026-09-15)
本单新增 schema v7(`lexgo_chapter_progress`),需要**显式**迁移:`python scripts/server.py migrate`。迁移只创建一张新表,不修改既有表与数据;回退时把 `lexgo_schema.version` 写回 6 并丢弃该表即可,业务数据不受影响(本机回退二进制保存在忽略的 `.local/lexgo-pre-issue13.exe`)。
开发库 lexgo_dev 迁移前后逐表计数一致(`sys_user` 6、`lexgo_books` 27、`lexgo_chapters` 33、`lexgo_ingest_jobs` 42、`lexgo_terms` 12、`lexgo_term_reviews` 12、`lexgo_review_answers` 55、`lexgo_dictionaries` 1、`lexgo_login_logs` 137、`lexgo_operation_logs` 5、`lexgo_sessions` 28),schema 6→7,快照见 `.local/issue13-before.json` 与 `.local/issue13-after.json`。
| 命令 | 结果 |
|---|---|
| `go vet ./...` | 通过 |
| `LEXGO_TEST_DB_NAME=lexgo_test_issue13 python scripts/server.py test-integration` | 70 个顶层用例全部通过、0 跳过(基线 67;本单新增 3 个用例) |
| `cd learner`:`npx vitest --run` / `npx vue-tsc --build` / `npx pnpm run build` / `npx playwright test` | 130 项单测、类型检查、构建、15 项 E2E 通过(本单新增 10 单测与 2 项 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 项与严格检查通过 |
覆盖内容:完成章节的幂等(第二次不移动时间、只有一行)、非 ready 章节 409、他人与不存在 404、未登录 401、阅读与阅读器/书籍列表的已读状态一致、正文新版本后回到未读(记录行保留、更新时间)、只改标题保持已读、章节删除后计数与记录同时消失、已知/忽略/学习中/新词分别计数、已保存词条总数、`dueNow` 与复习队列 `total` 一致、每本书的已读进度、另一账号与另一语言不串数据、迁移 v6→v7 与「回滚标记后重新升级」、新表一章一行的唯一性与级联删除。
真实链路验证:真实 Go API 与真实 MySQL 共 37 项检查通过(`.local/verify-issue13-api.py`,可重复运行,自建 fixture 书籍并在结尾删除),其中页面数字与 SQL 直查逐项比对(已达到/总可读章节、待复习、四种词条状态、已保存总数)全部相等;随后用临时 Playwright 用例在真实学习端完成「粘贴章节 → 处理完成 → 标记已读 → 书籍列表显示已读 → 进度页数字 → 刷新后一致 → 再次标记不重复计数」,并用 `.local/tmp/check-issue13-page.py` 把浏览器上显示的数字与数据库逐项比对(全部一致)后清理 fixture。截图保存在本机 `.local/evidence/`(issue13-reader-unread/read、issue13-progress 为 mock 流程;issue13-real-* 为真实链路),临时用例运行后删除。
**本单顺带记录一个既有缺陷**:编辑章节正文回到曾经用过的版本会返回 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)尚未实现,恢复范围目前只覆盖数据库。
## #21 附件功能验证(2026-09-15)
### 迁移
schema v7 → v8:新增 `lexgo_book_attachments` 与 `lexgo_playback_positions` 两张表,**不修改既有表**。开发库迁移前后逐表业务计数一致,schema 记到 8,两张新表为空(快照 `.local/issue13-before21.json`、`.local/issue13-after21.json`);回退时把 `lexgo_schema.version` 写回 7 并恢复上一版二进制(备份 `.local/lexgo-pre-issue21.exe`)。
| 命令 | 结果 |
|---|---|
| `go vet ./...` | 通过,`gofmt -l` 无输出 |
| `LEXGO_TEST_DB_NAME=lexgo_test_issue13 python scripts/server.py test-integration` | **86 项顶层用例全部通过、0 跳过**(本单新增 7 项:类型嗅探、上限与像素、WebP 维度、位置边界、上传/读取/Range/替换/移除/级联的集成用例、v7→v8 迁移) |
| `cd learner`:`npx vitest --run` / `npx vue-tsc --build` / `npx pnpm run build` / `npx playwright test` | 157 项单测、类型检查、构建、**25 项 E2E**(桌面 21 + 移动 4)全部通过;本单新增 10 单测与 3 项 E2E |
| `cd admin`:`npx pnpm test` / `npx pnpm lint` | 31 项与 lint 通过(管理端无改动) |
| `python -m unittest discover -s tests` / `harness.py check --strict` / `sync --check` | 65 项、严格检查、镜像一致 |
### 真实链路(41 项检查全部通过)
真实 API+真实 MySQL:上传**真实 PNG**(461 字节)与**真实三秒 MP3**(ffmpeg 生成,18407 字节)→ 数据库中的 `byte_size` 与 `OCTET_LENGTH(bytes)` 一致、音频 sha256 与上传文件一致 → 书籍详情与书库列表报告 `attachments`、`coverVersion`、`hasAudio` → 封面按字节返回且带 `ETag`(`If-None-Match` 得 `304`)→ 音频按字节返回、`Accept-Ranges` 存在、`Range: bytes=100-999` 得 `206` 且切片正确、不可满足的 Range 得 `416` → 越权读取/替换/上报位置全部 `404`、匿名 `401` → SVG 改名封面、文本改名音频均 `400`,21 MiB 音频 `413`,且**被拒绝后原文件仍在** → 位置写入与回读、负值 `400`、无音频的书 `404` → 替换音频后位置归零且仍只有一行 → 移除音频、删除书籍后附件与位置级联清空。证据 `.local/issue21-api-evidence.json`。
### 恢复演练(含附件,22 项检查全部通过)
在 #15 的恢复契约上扩展:建一本带真实封面与音频的 fixture 书籍 → 用 **Go 二进制** `lexgo backup`(manifest 已包含两张新表)→ 恢复到空库 `lexgo_test_restore_attach` → `lexgo verify --manifest` 通过且新增的「附件都指向存在的书」「播放位置都指向存在的书」检查在内 → **15 张表的逐表内容校验和与源库一致** → 用恢复库起第二个实例:同一账号可登录、书籍仍报告两个附件、位置仍是恢复前的值、**音频与封面按字节相同地返回**、恢复实例上 Range 仍得 `206`、越权仍 `404` → 删除演练库与 fixture 书籍,开发库仍是 schema 8。证据 `.local/issue21-restore-evidence.json`。
### 顺带修掉的既有问题
`e2e/phrase.spec.ts` 的连续选词用例在并行全量跑时约 6 次失败 2 次(合成鼠标拖拽偶尔少选一个词)。修法:每次重试都重新读取词的位置(面板打开会改变布局,缓存的坐标会一直拖错)、松开前留 80ms 让浏览器扩展选区、把重试条件改成**断言期望的词数**而不是「面板出现了」、重试前关掉部分选中的面板。修完连续 6 次全量跑全部通过;若选择逻辑真的退化,4 次重试仍会失败,不会掩盖真实问题。
### 未验证
- **浏览器是否真的解码并播放音频:本机验证用桩替换了媒体元素**(合成的 MP3 帧不足以证明解码)。真实 MP3 的听感、手机扬声器/蓝牙、后台播放与锁屏控制需要人工确认。
- 未验证 Safari/Firefox(项目既有测试只在 Chromium 上跑);WebP 只验证了容器维度解析,未在各浏览器实测显示。
- 20 MiB 上限下的播放启动延迟未测;附件进库对 dump 体积与恢复耗时的影响只在小数据量下观察。
- 未做音频转码与格式探测,非 MP3 一律拒绝。
## #37 章级附件验证(2026-09-15)
### 迁移
schema v8 → v9:新增 `lexgo_chapter_attachments` 与 `lexgo_chapter_playback_positions`,并执行一条幂等清理语句删除 `lexgo_book_attachments` 中 `kind='audio'` 的历史行(测试期数据,不做存量迁移)。开发库迁移前后逐表业务计数一致,schema 记到 9,两张新表为空、书级音频行为 0(快照 `.local/issue13-before37.json`、`.local/issue13-after37.json`)。回退:把 `lexgo_schema.version` 写回 8 并恢复 `.local/lexgo-pre-issue37.exe`,但**被清空的书级音频测试数据找不回**。
| 命令 | 结果 |
|---|---|
| `go vet ./...` / `gofmt -l` | 通过、无输出 |
| `LEXGO_TEST_DB_NAME=lexgo_test_issue13 python scripts/server.py test-integration` | **87 项顶层用例通过、0 跳过**(本单新增/改写:章级附件与位置的完整集成用例、封面仍为书级、上传请求形态校验、v8→v9 迁移与回退重升级、v7→v8 测试改为用原始 SQL 写已退役的表) |
| `cd learner`:`vitest` / `vue-tsc` / `build` / `playwright test` | 157 项单测、类型检查、构建、**26 项 E2E**(桌面 22 + 移动 4)全部通过 |
| 治理 / 镜像 | 65 项、`check --strict`、`sync --check` 通过 |
| 真实 API+MySQL | **52 项检查通过**(见下) |
| #15 恢复演练(第三次) | **22 项检查通过**(见下) |
### 真实链路(52 项)
用一本**两章**的虚构书验证「附件属于章而不是书」:只给第一章上传真实的 PNG 插图与三秒 MP3 音频 → 数据库里两张行与章节对应、`byte_size` 与 `OCTET_LENGTH(bytes)` 一致、音频 sha256 与上传文件一致、书级音频行为 0 → 章节列表与阅读器都带 `illustrationVersion`/`audioVersion`,第二章为空串且位置为 0 → 插图按字节返回并带 `ETag`(`If-None-Match` 得 `304`)→ 音频按字节返回、`Accept-Ranges` 存在、`Range: bytes=200-399` 得 `206` 且切片正确、不可满足的 Range 得 `416` → **书级音频与播放接口已下线(404)** → 位置写入后章节列表回读一致、第二章仍为 0、负值 400、无音频的章 404 → 替换音频后位置归零且仍一行 → SVG 伪装插图、文本伪装音频 400、21 MiB 音频 413 且原文件保留 → 越权读取/替换/上报 404、匿名 401 → 移除音频清掉位置但插图不受影响 → 删除章节与删除书籍级联清空。证据 `.local/issue37-api-evidence.json`。
### 恢复演练(第三次,22 项)
带章级附件的 fixture → Go 二进制 `lexgo backup`(manifest 已含两张新表)→ 恢复到空库 `lexgo_test_restore_chapter` → `lexgo verify --manifest` 通过且包含「章节附件都指向存在的章节」「章节播放位置都指向存在的章节」→ **17 张表逐表内容校验和与源库一致** → 用恢复库起实例:同一账号可登录、章节仍报告两个文件、位置仍是恢复前的值、**音频与插图按字节相同地返回**、Range 仍得 206、越权仍 404 → 演练库与 fixture 删除,开发库仍是 schema 9。证据 `.local/issue37-restore-evidence.json`。
### 插图交互修订(2026-09-15,按用户要求)
书籍页章节列表去掉插图列与列表预取;阅读页改为缩略图(高 120px 的按钮)并在点击后于弹窗中显示原图。后端与 schema 无变化。验证:学习端 157 单测与 26 项 E2E 通过(断言章节列表无 `<img>` 且不请求 `/illustration`、缩略图是按钮、点击后弹窗内为对象 URL 的图片);真实链路在 issue5_a 自己的一本书上完成——列表 `<img>` 为 0、缩略图 ≤130px、弹窗内图片大于缩略图且 `naturalWidth > 0`、Esc 关闭后正文仍可见,随后移除 fixture 插图复原开发数据(截图 `issue37-real-book-no-image.png`、`issue37-real-reader-thumbnail.png`、`issue37-real-illustration-dialog.png`)。示例地址 `/books/5` 属于其他账号,请求返回 404,实际使用该账号自己的书。
### 插图交互修订(2026-09-15,按用户要求)
书籍页章节列表去掉插图列与列表预取;阅读页改为缩略图(高 120px 的按钮)并在点击后于弹窗中显示原图。后端与 schema 无变化。验证:学习端 157 单测与 26 项 E2E 通过(断言章节列表无 `<img>` 且不请求 `/illustration`、缩略图是按钮、点击后弹窗内为对象 URL 的图片);真实链路在 issue5_a 自己的一本书上完成——列表 `<img>` 为 0、缩略图 ≤130px、弹窗内图片大于缩略图且 `naturalWidth > 0`、Esc 关闭后正文仍可见,随后移除 fixture 插图复原开发数据(截图 `issue37-real-book-no-image.png`、`issue37-real-reader-thumbnail.png`、`issue37-real-illustration-dialog.png`)。示例地址 `/books/5` 属于其他账号,请求返回 404,实际使用该账号自己的书。
### 附件并入章节编辑对话框(2026-09-15,按用户要求)
章节列表每行只留一个「编辑」;原「章节附件」对话框被移除,插图与音频的上传/替换/移除移入章节编辑对话框(与标题、正文同处),对话框内明确写出「标题与正文点保存后生效;插图与音频选中后立即上传,取消不会撤销」。后端与 schema 仍无变化。验证:学习端 157 单测与 26 项 E2E 全通过(新断言:章节行只有一个按钮;编辑对话框同时含标题、正文、插图与音频控件与生效时机说明;上传后仍可用「保存章节」提交标题与正文);真实链路确认章节行按钮数为 1、对话框中四类控件齐备、在编辑框内上传后状态立即变为「已设置」而标题仍等待保存、取消后进入阅读页仍为缩略图+弹窗,最后移除 fixture 插图(截图 `issue37-real-chapter-editor.png`)。
### 对话框浮层缺陷修复(2026-09-15,用户发现)
用户报告「编辑区显示在章节列表下面」。查明原因:`learner/src/main.ts` **从未导入 `element-plus/es/components/dialog/style/css` 与 `message-box/style/css`**,因此自 #10 起所有 `ElDialog` 与 `ElMessageBox` 都退化成文档流中的普通块(探针实测:移除导入时对话框 `position: static`、`top` 正好等于章节列表底部;恢复导入后 `.el-overlay` 为 `fixed`、对话框居中且遮罩为 `rgba(0,0,0,0.5)`)。修复:在 `main.ts` 补上两行样式导入。
**验证盲区**:此前所有 UI 验证只断言「元素可见 / DOM 结构」,`toBeVisible()` 对普通块同样成立,所以单元测试与 E2E 全绿也没暴露它。现已补长期回归:`learner/e2e/overlay.ts` 提供 `expectModalOverlay` 与 `expectMessageBoxOverlay`,断言对话框位于 `.el-overlay`(`position: fixed`)、位置在视口内且横向不溢出;`attachments.spec.ts`(章节编辑对话框、插图弹窗)与 `edit.spec.ts`(书名对话框、删除确认)都调用它。真实链路再次确认:章节编辑对话框与删除确认均为固定浮层(截图 `issue37-real-dialog-modal.png`、`issue37-real-confirm-modal.png`)。
### 作者字段与编辑对话框紧凑化(2026-09-15,按用户要求)
按用户要求:①「章节标题」标签与输入框同行,下面新增「作者」标签与输入框;②精简插图/音频的提示文字,把高度让给正文编辑框。
- **schema v10**:`lexgo_chapters` 增加 `author VARCHAR(120) NOT NULL DEFAULT ''`。由 Go 条件步骤 `addChapterAuthorColumn`(先查 `information_schema` 再 `ALTER TABLE`)在语句列表之后执行,保持「部分迁移可重试 / 回退标记后可重新升级」;新建库的 v3 语句也带该列。开发库迁移后 schema = 10,既有 34 个章节的 author 均为空串(无数据变化)。
- **接口**:章节编辑接受 `author`(可选、trim、≤120 字符、空串清空),`ChapterSummary`/`ChapterSource` 都返回它;仅改作者不会重新处理章节(状态保持不变)。
- **界面**:`.field-row` 把标签与输入框放同一行(标题、作者各一行);正文 textarea 提升到 18 行;插图与音频压成各一两行提示(规格写在下方一行);阅读页在标题下显示非空作者(`data-testid="chapter-author-line"`)。
- **验证**:Go 89 项顶层用例(新增 `TestMySQLChapterAuthorRoundTrip` 与 `TestMigrationFromV9AddsChapterAuthor`,后者覆盖「旧库缺列 → 迁移补列 → 回退标记 → 重新升级」);学习端 157 单测与 26 项 E2E 通过;真实链路实测:标题标签与输入框中线偏差 <8px、作者行位于标题下方且标签同行、正文高度 415px(原约 250px)、保存作者后阅读页显示、清空后该行消失(截图 `issue37-real-editor-layout.png`、`issue37-real-reader-author.png`)。
### 书级作者与书籍页精简(2026-09-15,按用户要求)
按用户要求:①书籍页去掉「封面」「书籍封面」「JPG…4096×4096」「音频与插图按章节设置…」四段文字,把空间让给章节列表;②「编辑书名」改为**「编辑书籍」**并可编辑**书名与作者**;③书名右侧显示当前书籍作者。
- **schema v11**:`lexgo_books.author VARCHAR(120) NOT NULL DEFAULT ''`,复用 v10 的条件加列助手(改名 `addAuthorColumn`,表名取自本文件常量)。开发库迁移后 schema = 11,既有 25 本书的 author 均为空串;回退件 `.local/lexgo-pre-issue37c.exe`(回退写回版本号 10 即可)。
- **接口**:`PATCH /api/v1/books/:id` 接受可选 `author`(省略保留、空串清空、trim、≤120),`BookSummary`/`BookRef` 返回它;书籍编辑不再只改书名。
- **界面**:封面区块变成一行(预览 + 上传/替换 + 移除);`.title-line` 在书名右侧显示作者(未设置不显示);「编辑书籍」对话框用同行标签编辑书名与作者。
- **验证**:Go **91 项顶层用例**(新增 `TestMySQLBookAuthorRoundTrip` 与 `TestMigrationFromV10AddsBookAuthor`);学习端 157 单测与 26 项 E2E 通过;真实链路实测封面行高 **68px**、章节列表紧随其后(间距 18px)、四段被删文字都不再出现、「编辑书籍」对话框含作者输入、保存后作者显示在书名右侧(同一行、位于书名之后)并在刷新后保留、清空后标签消失(截图 `issue37-real-book-page.png`)。
### 验收后的最终状态(2026-09-16)
用户回复「#37 通过验收」;PR #38 已 fast-forward-only 合入 main(merge commit `728f7d6`),工单关闭。最终 schema 为 **v11**(v9 章级附件、v10 章节作者、v11 书籍作者)。经确认**未实现**的两项:书库卡片显示书级作者、章节作者留空时回落显示书级作者;数据层已就绪(`BookSummary`/`BookRef` 与 `ChapterSummary` 都返回作者),需要时另开小工单即可。
### 未验证
- 真机与真实浏览器解码播放(媒体元素在测试中被桩替换)、真机听感与兼容性仍未验证(#21 起的既有缺口)。
- Safari/Firefox 未测;渐进式流式播放仍未做(服务端 Range 已实现)。
- 迁移会清空书级音频测试数据且回退到 v8 找不回;当前无真实用户,工单已记录。
- 已有书籍/章节的存量内容不受影响,但**没有做「把既有书级音频迁到章级」的转换**(无真实数据)。
## #37 章级附件验证(2026-09-15)
### 迁移
schema v8 → v9:新增 `lexgo_chapter_attachments` 与 `lexgo_chapter_playback_positions`,并执行一条幂等清理语句删除 `lexgo_book_attachments` 中 `kind='audio'` 的历史行(测试期数据,不做存量迁移)。开发库迁移前后逐表业务计数一致,schema 记到 9,两张新表为空、书级音频行为 0(快照 `.local/issue13-before37.json`、`.local/issue13-after37.json`)。回退:把 `lexgo_schema.version` 写回 8 并恢复 `.local/lexgo-pre-issue37.exe`,但**被清空的书级音频测试数据找不回**。
| 命令 | 结果 |
|---|---|
| `go vet ./...` / `gofmt -l` | 通过、无输出 |
| `LEXGO_TEST_DB_NAME=lexgo_test_issue13 python scripts/server.py test-integration` | **87 项顶层用例通过、0 跳过**(本单新增/改写:章级附件与位置的完整集成用例、封面仍为书级、上传请求形态校验、v8→v9 迁移与回退重升级、v7→v8 测试改为用原始 SQL 写已退役的表) |
| `cd learner`:`vitest` / `vue-tsc` / `build` / `playwright test` | 157 项单测、类型检查、构建、**26 项 E2E**(桌面 22 + 移动 4)全部通过 |
| 治理 / 镜像 | 65 项、`check --strict`、`sync --check` 通过 |
| 真实 API+MySQL | **52 项检查通过**(见下) |
| #15 恢复演练(第三次) | **22 项检查通过**(见下) |
### 真实链路(52 项)
用一本**两章**的虚构书验证「附件属于章而不是书」:只给第一章上传真实的 PNG 插图与三秒 MP3 音频 → 数据库里两张行与章节对应、`byte_size` 与 `OCTET_LENGTH(bytes)` 一致、音频 sha256 与上传文件一致、书级音频行为 0 → 章节列表与阅读器都带 `illustrationVersion`/`audioVersion`,第二章为空串且位置为 0 → 插图按字节返回并带 `ETag`(`If-None-Match` 得 `304`)→ 音频按字节返回、`Accept-Ranges` 存在、`Range: bytes=200-399` 得 `206` 且切片正确、不可满足的 Range 得 `416` → **书级音频与播放接口已下线(404)** → 位置写入后章节列表回读一致、第二章仍为 0、负值 400、无音频的章 404 → 替换音频后位置归零且仍一行 → SVG 伪装插图、文本伪装音频 400、21 MiB 音频 413 且原文件保留 → 越权读取/替换/上报 404、匿名 401 → 移除音频清掉位置但插图不受影响 → 删除章节与删除书籍级联清空。证据 `.local/issue37-api-evidence.json`。
### 恢复演练(第三次,22 项)
带章级附件的 fixture → Go 二进制 `lexgo backup`(manifest 已含两张新表)→ 恢复到空库 `lexgo_test_restore_chapter` → `lexgo verify --manifest` 通过且包含「章节附件都指向存在的章节」「章节播放位置都指向存在的章节」→ **17 张表逐表内容校验和与源库一致** → 用恢复库起实例:同一账号可登录、章节仍报告两个文件、位置仍是恢复前的值、**音频与插图按字节相同地返回**、Range 仍得 206、越权仍 404 → 演练库与 fixture 删除,开发库仍是 schema 9。证据 `.local/issue37-restore-evidence.json`。
### 插图交互修订(2026-09-15,按用户要求)
书籍页章节列表去掉插图列与列表预取;阅读页改为缩略图(高 120px 的按钮)并在点击后于弹窗中显示原图。后端与 schema 无变化。验证:学习端 157 单测与 26 项 E2E 通过(断言章节列表无 `<img>` 且不请求 `/illustration`、缩略图是按钮、点击后弹窗内为对象 URL 的图片);真实链路在 issue5_a 自己的一本书上完成——列表 `<img>` 为 0、缩略图 ≤130px、弹窗内图片大于缩略图且 `naturalWidth > 0`、Esc 关闭后正文仍可见,随后移除 fixture 插图复原开发数据(截图 `issue37-real-book-no-image.png`、`issue37-real-reader-thumbnail.png`、`issue37-real-illustration-dialog.png`)。示例地址 `/books/5` 属于其他账号,请求返回 404,实际使用该账号自己的书。
### 未验证
- 真机与真实浏览器解码播放(媒体元素在测试中被桩替换)、真机听感与兼容性仍未验证(#21 起的既有缺口)。
- Safari/Firefox 未测;渐进式流式播放仍未做(服务端 Range 已实现)。
- 迁移会清空书级音频测试数据且回退到 v8 找不回;当前无真实用户,工单已记录。
- 已有书籍/章节的存量内容不受影响,但**没有做「把既有书级音频迁到章级」的转换**(无真实数据)。
## #37 章级附件验证(2026-09-15)
### 迁移
schema v8 → v9:新增 `lexgo_chapter_attachments` 与 `lexgo_chapter_playback_positions`,并执行一条幂等清理语句删除 `lexgo_book_attachments` 中 `kind='audio'` 的历史行(测试期数据,不做存量迁移)。开发库迁移前后逐表业务计数一致,schema 记到 9,两张新表为空、书级音频行为 0(快照 `.local/issue13-before37.json`、`.local/issue13-after37.json`)。回退:把 `lexgo_schema.version` 写回 8 并恢复 `.local/lexgo-pre-issue37.exe`,但**被清空的书级音频测试数据找不回**。
| 命令 | 结果 |
|---|---|
| `go vet ./...` / `gofmt -l` | 通过、无输出 |
| `LEXGO_TEST_DB_NAME=lexgo_test_issue13 python scripts/server.py test-integration` | **87 项顶层用例通过、0 跳过**(本单新增/改写:章级附件与位置的完整集成用例、封面仍为书级、上传请求形态校验、v8→v9 迁移与回退重升级、v7→v8 测试改为用原始 SQL 写已退役的表) |
| `cd learner`:`vitest` / `vue-tsc` / `build` / `playwright test` | 157 项单测、类型检查、构建、**26 项 E2E**(桌面 22 + 移动 4)全部通过 |
| 治理 / 镜像 | 65 项、`check --strict`、`sync --check` 通过 |
| 真实 API+MySQL | **52 项检查通过**(见下) |
| #15 恢复演练(第三次) | **22 项检查通过**(见下) |
### 真实链路(52 项)
用一本**两章**的虚构书验证「附件属于章而不是书」:只给第一章上传真实的 PNG 插图与三秒 MP3 音频 → 数据库里两张行与章节对应、`byte_size` 与 `OCTET_LENGTH(bytes)` 一致、音频 sha256 与上传文件一致、书级音频行为 0 → 章节列表与阅读器都带 `illustrationVersion`/`audioVersion`,第二章为空串且位置为 0 → 插图按字节返回并带 `ETag`(`If-None-Match` 得 `304`)→ 音频按字节返回、`Accept-Ranges` 存在、`Range: bytes=200-399` 得 `206` 且切片正确、不可满足的 Range 得 `416` → **书级音频与播放接口已下线(404)** → 位置写入后章节列表回读一致、第二章仍为 0、负值 400、无音频的章 404 → 替换音频后位置归零且仍一行 → SVG 伪装插图、文本伪装音频 400、21 MiB 音频 413 且原文件保留 → 越权读取/替换/上报 404、匿名 401 → 移除音频清掉位置但插图不受影响 → 删除章节与删除书籍级联清空。证据 `.local/issue37-api-evidence.json`。
### 恢复演练(第三次,22 项)
带章级附件的 fixture → Go 二进制 `lexgo backup`(manifest 已含两张新表)→ 恢复到空库 `lexgo_test_restore_chapter` → `lexgo verify --manifest` 通过且包含「章节附件都指向存在的章节」「章节播放位置都指向存在的章节」→ **17 张表逐表内容校验和与源库一致** → 用恢复库起实例:同一账号可登录、章节仍报告两个文件、位置仍是恢复前的值、**音频与插图按字节相同地返回**、Range 仍得 206、越权仍 404 → 演练库与 fixture 删除,开发库仍是 schema 9。证据 `.local/issue37-restore-evidence.json`。
### 插图交互修订(2026-09-15,按用户要求)
书籍页章节列表去掉插图列与列表预取;阅读页改为缩略图(高 120px 的按钮)并在点击后于弹窗中显示原图。后端与 schema 无变化。验证:学习端 157 单测与 26 项 E2E 通过(断言章节列表无 `<img>` 且不请求 `/illustration`、缩略图是按钮、点击后弹窗内为对象 URL 的图片);真实链路在 issue5_a 自己的一本书上完成——列表 `<img>` 为 0、缩略图 ≤130px、弹窗内图片大于缩略图且 `naturalWidth > 0`、Esc 关闭后正文仍可见,随后移除 fixture 插图复原开发数据(截图 `issue37-real-book-no-image.png`、`issue37-real-reader-thumbnail.png`、`issue37-real-illustration-dialog.png`)。示例地址 `/books/5` 属于其他账号,请求返回 404,实际使用该账号自己的书。
### 插图交互修订(2026-09-15,按用户要求)
书籍页章节列表去掉插图列与列表预取;阅读页改为缩略图(高 120px 的按钮)并在点击后于弹窗中显示原图。后端与 schema 无变化。验证:学习端 157 单测与 26 项 E2E 通过(断言章节列表无 `<img>` 且不请求 `/illustration`、缩略图是按钮、点击后弹窗内为对象 URL 的图片);真实链路在 issue5_a 自己的一本书上完成——列表 `<img>` 为 0、缩略图 ≤130px、弹窗内图片大于缩略图且 `naturalWidth > 0`、Esc 关闭后正文仍可见,随后移除 fixture 插图复原开发数据(截图 `issue37-real-book-no-image.png`、`issue37-real-reader-thumbnail.png`、`issue37-real-illustration-dialog.png`)。示例地址 `/books/5` 属于其他账号,请求返回 404,实际使用该账号自己的书。
### 附件并入章节编辑对话框(2026-09-15,按用户要求)
章节列表每行只留一个「编辑」;原「章节附件」对话框被移除,插图与音频的上传/替换/移除移入章节编辑对话框(与标题、正文同处),对话框内明确写出「标题与正文点保存后生效;插图与音频选中后立即上传,取消不会撤销」。后端与 schema 仍无变化。验证:学习端 157 单测与 26 项 E2E 全通过(新断言:章节行只有一个按钮;编辑对话框同时含标题、正文、插图与音频控件与生效时机说明;上传后仍可用「保存章节」提交标题与正文);真实链路确认章节行按钮数为 1、对话框中四类控件齐备、在编辑框内上传后状态立即变为「已设置」而标题仍等待保存、取消后进入阅读页仍为缩略图+弹窗,最后移除 fixture 插图(截图 `issue37-real-chapter-editor.png`)。
### 未验证
- 真机与真实浏览器解码播放(媒体元素在测试中被桩替换)、真机听感与兼容性仍未验证(#21 起的既有缺口)。
- Safari/Firefox 未测;渐进式流式播放仍未做(服务端 Range 已实现)。
- 迁移会清空书级音频测试数据且回退到 v8 找不回;当前无真实用户,工单已记录。
- 已有书籍/章节的存量内容不受影响,但**没有做「把既有书级音频迁到章级」的转换**(无真实数据)。
## #37 章级附件验证(2026-09-15)
### 迁移
schema v8 → v9:新增 `lexgo_chapter_attachments` 与 `lexgo_chapter_playback_positions`,并执行一条幂等清理语句删除 `lexgo_book_attachments` 中 `kind='audio'` 的历史行(测试期数据,不做存量迁移)。开发库迁移前后逐表业务计数一致,schema 记到 9,两张新表为空、书级音频行为 0(快照 `.local/issue13-before37.json`、`.local/issue13-after37.json`)。回退:把 `lexgo_schema.version` 写回 8 并恢复 `.local/lexgo-pre-issue37.exe`,但**被清空的书级音频测试数据找不回**。
| 命令 | 结果 |
|---|---|
| `go vet ./...` / `gofmt -l` | 通过、无输出 |
| `LEXGO_TEST_DB_NAME=lexgo_test_issue13 python scripts/server.py test-integration` | **87 项顶层用例通过、0 跳过**(本单新增/改写:章级附件与位置的完整集成用例、封面仍为书级、上传请求形态校验、v8→v9 迁移与回退重升级、v7→v8 测试改为用原始 SQL 写已退役的表) |
| `cd learner`:`vitest` / `vue-tsc` / `build` / `playwright test` | 157 项单测、类型检查、构建、**26 项 E2E**(桌面 22 + 移动 4)全部通过 |
| 治理 / 镜像 | 65 项、`check --strict`、`sync --check` 通过 |
| 真实 API+MySQL | **52 项检查通过**(见下) |
| #15 恢复演练(第三次) | **22 项检查通过**(见下) |
### 真实链路(52 项)
用一本**两章**的虚构书验证「附件属于章而不是书」:只给第一章上传真实的 PNG 插图与三秒 MP3 音频 → 数据库里两张行与章节对应、`byte_size` 与 `OCTET_LENGTH(bytes)` 一致、音频 sha256 与上传文件一致、书级音频行为 0 → 章节列表与阅读器都带 `illustrationVersion`/`audioVersion`,第二章为空串且位置为 0 → 插图按字节返回并带 `ETag`(`If-None-Match` 得 `304`)→ 音频按字节返回、`Accept-Ranges` 存在、`Range: bytes=200-399` 得 `206` 且切片正确、不可满足的 Range 得 `416` → **书级音频与播放接口已下线(404)** → 位置写入后章节列表回读一致、第二章仍为 0、负值 400、无音频的章 404 → 替换音频后位置归零且仍一行 → SVG 伪装插图、文本伪装音频 400、21 MiB 音频 413 且原文件保留 → 越权读取/替换/上报 404、匿名 401 → 移除音频清掉位置但插图不受影响 → 删除章节与删除书籍级联清空。证据 `.local/issue37-api-evidence.json`。
### 恢复演练(第三次,22 项)
带章级附件的 fixture → Go 二进制 `lexgo backup`(manifest 已含两张新表)→ 恢复到空库 `lexgo_test_restore_chapter` → `lexgo verify --manifest` 通过且包含「章节附件都指向存在的章节」「章节播放位置都指向存在的章节」→ **17 张表逐表内容校验和与源库一致** → 用恢复库起实例:同一账号可登录、章节仍报告两个文件、位置仍是恢复前的值、**音频与插图按字节相同地返回**、Range 仍得 206、越权仍 404 → 演练库与 fixture 删除,开发库仍是 schema 9。证据 `.local/issue37-restore-evidence.json`。
### 插图交互修订(2026-09-15,按用户要求)
书籍页章节列表去掉插图列与列表预取;阅读页改为缩略图(高 120px 的按钮)并在点击后于弹窗中显示原图。后端与 schema 无变化。验证:学习端 157 单测与 26 项 E2E 通过(断言章节列表无 `<img>` 且不请求 `/illustration`、缩略图是按钮、点击后弹窗内为对象 URL 的图片);真实链路在 issue5_a 自己的一本书上完成——列表 `<img>` 为 0、缩略图 ≤130px、弹窗内图片大于缩略图且 `naturalWidth > 0`、Esc 关闭后正文仍可见,随后移除 fixture 插图复原开发数据(截图 `issue37-real-book-no-image.png`、`issue37-real-reader-thumbnail.png`、`issue37-real-illustration-dialog.png`)。示例地址 `/books/5` 属于其他账号,请求返回 404,实际使用该账号自己的书。
### 插图交互修订(2026-09-15,按用户要求)
书籍页章节列表去掉插图列与列表预取;阅读页改为缩略图(高 120px 的按钮)并在点击后于弹窗中显示原图。后端与 schema 无变化。验证:学习端 157 单测与 26 项 E2E 通过(断言章节列表无 `<img>` 且不请求 `/illustration`、缩略图是按钮、点击后弹窗内为对象 URL 的图片);真实链路在 issue5_a 自己的一本书上完成——列表 `<img>` 为 0、缩略图 ≤130px、弹窗内图片大于缩略图且 `naturalWidth > 0`、Esc 关闭后正文仍可见,随后移除 fixture 插图复原开发数据(截图 `issue37-real-book-no-image.png`、`issue37-real-reader-thumbnail.png`、`issue37-real-illustration-dialog.png`)。示例地址 `/books/5` 属于其他账号,请求返回 404,实际使用该账号自己的书。
### 附件并入章节编辑对话框(2026-09-15,按用户要求)
章节列表每行只留一个「编辑」;原「章节附件」对话框被移除,插图与音频的上传/替换/移除移入章节编辑对话框(与标题、正文同处),对话框内明确写出「标题与正文点保存后生效;插图与音频选中后立即上传,取消不会撤销」。后端与 schema 仍无变化。验证:学习端 157 单测与 26 项 E2E 全通过(新断言:章节行只有一个按钮;编辑对话框同时含标题、正文、插图与音频控件与生效时机说明;上传后仍可用「保存章节」提交标题与正文);真实链路确认章节行按钮数为 1、对话框中四类控件齐备、在编辑框内上传后状态立即变为「已设置」而标题仍等待保存、取消后进入阅读页仍为缩略图+弹窗,最后移除 fixture 插图(截图 `issue37-real-chapter-editor.png`)。
### 对话框浮层缺陷修复(2026-09-15,用户发现)
用户报告「编辑区显示在章节列表下面」。查明原因:`learner/src/main.ts` **从未导入 `element-plus/es/components/dialog/style/css` 与 `message-box/style/css`**,因此自 #10 起所有 `ElDialog` 与 `ElMessageBox` 都退化成文档流中的普通块(探针实测:移除导入时对话框 `position: static`、`top` 正好等于章节列表底部;恢复导入后 `.el-overlay` 为 `fixed`、对话框居中且遮罩为 `rgba(0,0,0,0.5)`)。修复:在 `main.ts` 补上两行样式导入。
**验证盲区**:此前所有 UI 验证只断言「元素可见 / DOM 结构」,`toBeVisible()` 对普通块同样成立,所以单元测试与 E2E 全绿也没暴露它。现已补长期回归:`learner/e2e/overlay.ts` 提供 `expectModalOverlay` 与 `expectMessageBoxOverlay`,断言对话框位于 `.el-overlay`(`position: fixed`)、位置在视口内且横向不溢出;`attachments.spec.ts`(章节编辑对话框、插图弹窗)与 `edit.spec.ts`(书名对话框、删除确认)都调用它。真实链路再次确认:章节编辑对话框与删除确认均为固定浮层(截图 `issue37-real-dialog-modal.png`、`issue37-real-confirm-modal.png`)。
### 未验证
- 真机与真实浏览器解码播放(媒体元素在测试中被桩替换)、真机听感与兼容性仍未验证(#21 起的既有缺口)。
- Safari/Firefox 未测;渐进式流式播放仍未做(服务端 Range 已实现)。
- 迁移会清空书级音频测试数据且回退到 v8 找不回;当前无真实用户,工单已记录。
- 已有书籍/章节的存量内容不受影响,但**没有做「把既有书级音频迁到章级」的转换**(无真实数据)。
## #37 章级附件验证(2026-09-15)
### 迁移
schema v8 → v9:新增 `lexgo_chapter_attachments` 与 `lexgo_chapter_playback_positions`,并执行一条幂等清理语句删除 `lexgo_book_attachments` 中 `kind='audio'` 的历史行(测试期数据,不做存量迁移)。开发库迁移前后逐表业务计数一致,schema 记到 9,两张新表为空、书级音频行为 0(快照 `.local/issue13-before37.json`、`.local/issue13-after37.json`)。回退:把 `lexgo_schema.version` 写回 8 并恢复 `.local/lexgo-pre-issue37.exe`,但**被清空的书级音频测试数据找不回**。
| 命令 | 结果 |
|---|---|
| `go vet ./...` / `gofmt -l` | 通过、无输出 |
| `LEXGO_TEST_DB_NAME=lexgo_test_issue13 python scripts/server.py test-integration` | **87 项顶层用例通过、0 跳过**(本单新增/改写:章级附件与位置的完整集成用例、封面仍为书级、上传请求形态校验、v8→v9 迁移与回退重升级、v7→v8 测试改为用原始 SQL 写已退役的表) |
| `cd learner`:`vitest` / `vue-tsc` / `build` / `playwright test` | 157 项单测、类型检查、构建、**26 项 E2E**(桌面 22 + 移动 4)全部通过 |
| 治理 / 镜像 | 65 项、`check --strict`、`sync --check` 通过 |
| 真实 API+MySQL | **52 项检查通过**(见下) |
| #15 恢复演练(第三次) | **22 项检查通过**(见下) |
### 真实链路(52 项)
用一本**两章**的虚构书验证「附件属于章而不是书」:只给第一章上传真实的 PNG 插图与三秒 MP3 音频 → 数据库里两张行与章节对应、`byte_size` 与 `OCTET_LENGTH(bytes)` 一致、音频 sha256 与上传文件一致、书级音频行为 0 → 章节列表与阅读器都带 `illustrationVersion`/`audioVersion`,第二章为空串且位置为 0 → 插图按字节返回并带 `ETag`(`If-None-Match` 得 `304`)→ 音频按字节返回、`Accept-Ranges` 存在、`Range: bytes=200-399` 得 `206` 且切片正确、不可满足的 Range 得 `416` → **书级音频与播放接口已下线(404)** → 位置写入后章节列表回读一致、第二章仍为 0、负值 400、无音频的章 404 → 替换音频后位置归零且仍一行 → SVG 伪装插图、文本伪装音频 400、21 MiB 音频 413 且原文件保留 → 越权读取/替换/上报 404、匿名 401 → 移除音频清掉位置但插图不受影响 → 删除章节与删除书籍级联清空。证据 `.local/issue37-api-evidence.json`。
### 恢复演练(第三次,22 项)
带章级附件的 fixture → Go 二进制 `lexgo backup`(manifest 已含两张新表)→ 恢复到空库 `lexgo_test_restore_chapter` → `lexgo verify --manifest` 通过且包含「章节附件都指向存在的章节」「章节播放位置都指向存在的章节」→ **17 张表逐表内容校验和与源库一致** → 用恢复库起实例:同一账号可登录、章节仍报告两个文件、位置仍是恢复前的值、**音频与插图按字节相同地返回**、Range 仍得 206、越权仍 404 → 演练库与 fixture 删除,开发库仍是 schema 9。证据 `.local/issue37-restore-evidence.json`。
### 插图交互修订(2026-09-15,按用户要求)
书籍页章节列表去掉插图列与列表预取;阅读页改为缩略图(高 120px 的按钮)并在点击后于弹窗中显示原图。后端与 schema 无变化。验证:学习端 157 单测与 26 项 E2E 通过(断言章节列表无 `<img>` 且不请求 `/illustration`、缩略图是按钮、点击后弹窗内为对象 URL 的图片);真实链路在 issue5_a 自己的一本书上完成——列表 `<img>` 为 0、缩略图 ≤130px、弹窗内图片大于缩略图且 `naturalWidth > 0`、Esc 关闭后正文仍可见,随后移除 fixture 插图复原开发数据(截图 `issue37-real-book-no-image.png`、`issue37-real-reader-thumbnail.png`、`issue37-real-illustration-dialog.png`)。示例地址 `/books/5` 属于其他账号,请求返回 404,实际使用该账号自己的书。
### 插图交互修订(2026-09-15,按用户要求)
书籍页章节列表去掉插图列与列表预取;阅读页改为缩略图(高 120px 的按钮)并在点击后于弹窗中显示原图。后端与 schema 无变化。验证:学习端 157 单测与 26 项 E2E 通过(断言章节列表无 `<img>` 且不请求 `/illustration`、缩略图是按钮、点击后弹窗内为对象 URL 的图片);真实链路在 issue5_a 自己的一本书上完成——列表 `<img>` 为 0、缩略图 ≤130px、弹窗内图片大于缩略图且 `naturalWidth > 0`、Esc 关闭后正文仍可见,随后移除 fixture 插图复原开发数据(截图 `issue37-real-book-no-image.png`、`issue37-real-reader-thumbnail.png`、`issue37-real-illustration-dialog.png`)。示例地址 `/books/5` 属于其他账号,请求返回 404,实际使用该账号自己的书。
### 附件并入章节编辑对话框(2026-09-15,按用户要求)
章节列表每行只留一个「编辑」;原「章节附件」对话框被移除,插图与音频的上传/替换/移除移入章节编辑对话框(与标题、正文同处),对话框内明确写出「标题与正文点保存后生效;插图与音频选中后立即上传,取消不会撤销」。后端与 schema 仍无变化。验证:学习端 157 单测与 26 项 E2E 全通过(新断言:章节行只有一个按钮;编辑对话框同时含标题、正文、插图与音频控件与生效时机说明;上传后仍可用「保存章节」提交标题与正文);真实链路确认章节行按钮数为 1、对话框中四类控件齐备、在编辑框内上传后状态立即变为「已设置」而标题仍等待保存、取消后进入阅读页仍为缩略图+弹窗,最后移除 fixture 插图(截图 `issue37-real-chapter-editor.png`)。
### 对话框浮层缺陷修复(2026-09-15,用户发现)
用户报告「编辑区显示在章节列表下面」。查明原因:`learner/src/main.ts` **从未导入 `element-plus/es/components/dialog/style/css` 与 `message-box/style/css`**,因此自 #10 起所有 `ElDialog` 与 `ElMessageBox` 都退化成文档流中的普通块(探针实测:移除导入时对话框 `position: static`、`top` 正好等于章节列表底部;恢复导入后 `.el-overlay` 为 `fixed`、对话框居中且遮罩为 `rgba(0,0,0,0.5)`)。修复:在 `main.ts` 补上两行样式导入。
**验证盲区**:此前所有 UI 验证只断言「元素可见 / DOM 结构」,`toBeVisible()` 对普通块同样成立,所以单元测试与 E2E 全绿也没暴露它。现已补长期回归:`learner/e2e/overlay.ts` 提供 `expectModalOverlay` 与 `expectMessageBoxOverlay`,断言对话框位于 `.el-overlay`(`position: fixed`)、位置在视口内且横向不溢出;`attachments.spec.ts`(章节编辑对话框、插图弹窗)与 `edit.spec.ts`(书名对话框、删除确认)都调用它。真实链路再次确认:章节编辑对话框与删除确认均为固定浮层(截图 `issue37-real-dialog-modal.png`、`issue37-real-confirm-modal.png`)。
### 作者字段与编辑对话框紧凑化(2026-09-15,按用户要求)
按用户要求:①「章节标题」标签与输入框同行,下面新增「作者」标签与输入框;②精简插图/音频的提示文字,把高度让给正文编辑框。
- **schema v10**:`lexgo_chapters` 增加 `author VARCHAR(120) NOT NULL DEFAULT ''`。由 Go 条件步骤 `addChapterAuthorColumn`(先查 `information_schema` 再 `ALTER TABLE`)在语句列表之后执行,保持「部分迁移可重试 / 回退标记后可重新升级」;新建库的 v3 语句也带该列。开发库迁移后 schema = 10,既有 34 个章节的 author 均为空串(无数据变化)。
- **接口**:章节编辑接受 `author`(可选、trim、≤120 字符、空串清空),`ChapterSummary`/`ChapterSource` 都返回它;仅改作者不会重新处理章节(状态保持不变)。
- **界面**:`.field-row` 把标签与输入框放同一行(标题、作者各一行);正文 textarea 提升到 18 行;插图与音频压成各一两行提示(规格写在下方一行);阅读页在标题下显示非空作者(`data-testid="chapter-author-line"`)。
- **验证**:Go 89 项顶层用例(新增 `TestMySQLChapterAuthorRoundTrip` 与 `TestMigrationFromV9AddsChapterAuthor`,后者覆盖「旧库缺列 → 迁移补列 → 回退标记 → 重新升级」);学习端 157 单测与 26 项 E2E 通过;真实链路实测:标题标签与输入框中线偏差 <8px、作者行位于标题下方且标签同行、正文高度 415px(原约 250px)、保存作者后阅读页显示、清空后该行消失(截图 `issue37-real-editor-layout.png`、`issue37-real-reader-author.png`)。
### 未验证
- 真机与真实浏览器解码播放(媒体元素在测试中被桩替换)、真机听感与兼容性仍未验证(#21 起的既有缺口)。
- Safari/Firefox 未测;渐进式流式播放仍未做(服务端 Range 已实现)。
- 迁移会清空书级音频测试数据且回退到 v8 找不回;当前无真实用户,工单已记录。
- 已有书籍/章节的存量内容不受影响,但**没有做「把既有书级音频迁到章级」的转换**(无真实数据)。
## #37 章级附件验证(2026-09-15)
### 迁移
schema v8 → v9:新增 `lexgo_chapter_attachments` 与 `lexgo_chapter_playback_positions`,并执行一条幂等清理语句删除 `lexgo_book_attachments` 中 `kind='audio'` 的历史行(测试期数据,不做存量迁移)。开发库迁移前后逐表业务计数一致,schema 记到 9,两张新表为空、书级音频行为 0(快照 `.local/issue13-before37.json`、`.local/issue13-after37.json`)。回退:把 `lexgo_schema.version` 写回 8 并恢复 `.local/lexgo-pre-issue37.exe`,但**被清空的书级音频测试数据找不回**。
| 命令 | 结果 |
|---|---|
| `go vet ./...` / `gofmt -l` | 通过、无输出 |
| `LEXGO_TEST_DB_NAME=lexgo_test_issue13 python scripts/server.py test-integration` | **87 项顶层用例通过、0 跳过**(本单新增/改写:章级附件与位置的完整集成用例、封面仍为书级、上传请求形态校验、v8→v9 迁移与回退重升级、v7→v8 测试改为用原始 SQL 写已退役的表) |
| `cd learner`:`vitest` / `vue-tsc` / `build` / `playwright test` | 157 项单测、类型检查、构建、**26 项 E2E**(桌面 22 + 移动 4)全部通过 |
| 治理 / 镜像 | 65 项、`check --strict`、`sync --check` 通过 |
| 真实 API+MySQL | **52 项检查通过**(见下) |
| #15 恢复演练(第三次) | **22 项检查通过**(见下) |
### 真实链路(52 项)
用一本**两章**的虚构书验证「附件属于章而不是书」:只给第一章上传真实的 PNG 插图与三秒 MP3 音频 → 数据库里两张行与章节对应、`byte_size` 与 `OCTET_LENGTH(bytes)` 一致、音频 sha256 与上传文件一致、书级音频行为 0 → 章节列表与阅读器都带 `illustrationVersion`/`audioVersion`,第二章为空串且位置为 0 → 插图按字节返回并带 `ETag`(`If-None-Match` 得 `304`)→ 音频按字节返回、`Accept-Ranges` 存在、`Range: bytes=200-399` 得 `206` 且切片正确、不可满足的 Range 得 `416` → **书级音频与播放接口已下线(404)** → 位置写入后章节列表回读一致、第二章仍为 0、负值 400、无音频的章 404 → 替换音频后位置归零且仍一行 → SVG 伪装插图、文本伪装音频 400、21 MiB 音频 413 且原文件保留 → 越权读取/替换/上报 404、匿名 401 → 移除音频清掉位置但插图不受影响 → 删除章节与删除书籍级联清空。证据 `.local/issue37-api-evidence.json`。
### 恢复演练(第三次,22 项)
带章级附件的 fixture → Go 二进制 `lexgo backup`(manifest 已含两张新表)→ 恢复到空库 `lexgo_test_restore_chapter` → `lexgo verify --manifest` 通过且包含「章节附件都指向存在的章节」「章节播放位置都指向存在的章节」→ **17 张表逐表内容校验和与源库一致** → 用恢复库起实例:同一账号可登录、章节仍报告两个文件、位置仍是恢复前的值、**音频与插图按字节相同地返回**、Range 仍得 206、越权仍 404 → 演练库与 fixture 删除,开发库仍是 schema 9。证据 `.local/issue37-restore-evidence.json`。
### 插图交互修订(2026-09-15,按用户要求)
书籍页章节列表去掉插图列与列表预取;阅读页改为缩略图(高 120px 的按钮)并在点击后于弹窗中显示原图。后端与 schema 无变化。验证:学习端 157 单测与 26 项 E2E 通过(断言章节列表无 `<img>` 且不请求 `/illustration`、缩略图是按钮、点击后弹窗内为对象 URL 的图片);真实链路在 issue5_a 自己的一本书上完成——列表 `<img>` 为 0、缩略图 ≤130px、弹窗内图片大于缩略图且 `naturalWidth > 0`、Esc 关闭后正文仍可见,随后移除 fixture 插图复原开发数据(截图 `issue37-real-book-no-image.png`、`issue37-real-reader-thumbnail.png`、`issue37-real-illustration-dialog.png`)。示例地址 `/books/5` 属于其他账号,请求返回 404,实际使用该账号自己的书。
### 插图交互修订(2026-09-15,按用户要求)
书籍页章节列表去掉插图列与列表预取;阅读页改为缩略图(高 120px 的按钮)并在点击后于弹窗中显示原图。后端与 schema 无变化。验证:学习端 157 单测与 26 项 E2E 通过(断言章节列表无 `<img>` 且不请求 `/illustration`、缩略图是按钮、点击后弹窗内为对象 URL 的图片);真实链路在 issue5_a 自己的一本书上完成——列表 `<img>` 为 0、缩略图 ≤130px、弹窗内图片大于缩略图且 `naturalWidth > 0`、Esc 关闭后正文仍可见,随后移除 fixture 插图复原开发数据(截图 `issue37-real-book-no-image.png`、`issue37-real-reader-thumbnail.png`、`issue37-real-illustration-dialog.png`)。示例地址 `/books/5` 属于其他账号,请求返回 404,实际使用该账号自己的书。
### 附件并入章节编辑对话框(2026-09-15,按用户要求)
章节列表每行只留一个「编辑」;原「章节附件」对话框被移除,插图与音频的上传/替换/移除移入章节编辑对话框(与标题、正文同处),对话框内明确写出「标题与正文点保存后生效;插图与音频选中后立即上传,取消不会撤销」。后端与 schema 仍无变化。验证:学习端 157 单测与 26 项 E2E 全通过(新断言:章节行只有一个按钮;编辑对话框同时含标题、正文、插图与音频控件与生效时机说明;上传后仍可用「保存章节」提交标题与正文);真实链路确认章节行按钮数为 1、对话框中四类控件齐备、在编辑框内上传后状态立即变为「已设置」而标题仍等待保存、取消后进入阅读页仍为缩略图+弹窗,最后移除 fixture 插图(截图 `issue37-real-chapter-editor.png`)。
### 对话框浮层缺陷修复(2026-09-15,用户发现)
用户报告「编辑区显示在章节列表下面」。查明原因:`learner/src/main.ts` **从未导入 `element-plus/es/components/dialog/style/css` 与 `message-box/style/css`**,因此自 #10 起所有 `ElDialog` 与 `ElMessageBox` 都退化成文档流中的普通块(探针实测:移除导入时对话框 `position: static`、`top` 正好等于章节列表底部;恢复导入后 `.el-overlay` 为 `fixed`、对话框居中且遮罩为 `rgba(0,0,0,0.5)`)。修复:在 `main.ts` 补上两行样式导入。
**验证盲区**:此前所有 UI 验证只断言「元素可见 / DOM 结构」,`toBeVisible()` 对普通块同样成立,所以单元测试与 E2E 全绿也没暴露它。现已补长期回归:`learner/e2e/overlay.ts` 提供 `expectModalOverlay` 与 `expectMessageBoxOverlay`,断言对话框位于 `.el-overlay`(`position: fixed`)、位置在视口内且横向不溢出;`attachments.spec.ts`(章节编辑对话框、插图弹窗)与 `edit.spec.ts`(书名对话框、删除确认)都调用它。真实链路再次确认:章节编辑对话框与删除确认均为固定浮层(截图 `issue37-real-dialog-modal.png`、`issue37-real-confirm-modal.png`)。
### 作者字段与编辑对话框紧凑化(2026-09-15,按用户要求)
按用户要求:①「章节标题」标签与输入框同行,下面新增「作者」标签与输入框;②精简插图/音频的提示文字,把高度让给正文编辑框。
- **schema v10**:`lexgo_chapters` 增加 `author VARCHAR(120) NOT NULL DEFAULT ''`。由 Go 条件步骤 `addChapterAuthorColumn`(先查 `information_schema` 再 `ALTER TABLE`)在语句列表之后执行,保持「部分迁移可重试 / 回退标记后可重新升级」;新建库的 v3 语句也带该列。开发库迁移后 schema = 10,既有 34 个章节的 author 均为空串(无数据变化)。
- **接口**:章节编辑接受 `author`(可选、trim、≤120 字符、空串清空),`ChapterSummary`/`ChapterSource` 都返回它;仅改作者不会重新处理章节(状态保持不变)。
- **界面**:`.field-row` 把标签与输入框放同一行(标题、作者各一行);正文 textarea 提升到 18 行;插图与音频压成各一两行提示(规格写在下方一行);阅读页在标题下显示非空作者(`data-testid="chapter-author-line"`)。
- **验证**:Go 89 项顶层用例(新增 `TestMySQLChapterAuthorRoundTrip` 与 `TestMigrationFromV9AddsChapterAuthor`,后者覆盖「旧库缺列 → 迁移补列 → 回退标记 → 重新升级」);学习端 157 单测与 26 项 E2E 通过;真实链路实测:标题标签与输入框中线偏差 <8px、作者行位于标题下方且标签同行、正文高度 415px(原约 250px)、保存作者后阅读页显示、清空后该行消失(截图 `issue37-real-editor-layout.png`、`issue37-real-reader-author.png`)。
### 书级作者与书籍页精简(2026-09-15,按用户要求)
按用户要求:①书籍页去掉「封面」「书籍封面」「JPG…4096×4096」「音频与插图按章节设置…」四段文字,把空间让给章节列表;②「编辑书名」改为**「编辑书籍」**并可编辑**书名与作者**;③书名右侧显示当前书籍作者。
- **schema v11**:`lexgo_books.author VARCHAR(120) NOT NULL DEFAULT ''`,复用 v10 的条件加列助手(改名 `addAuthorColumn`,表名取自本文件常量)。开发库迁移后 schema = 11,既有 25 本书的 author 均为空串;回退件 `.local/lexgo-pre-issue37c.exe`(回退写回版本号 10 即可)。
- **接口**:`PATCH /api/v1/books/:id` 接受可选 `author`(省略保留、空串清空、trim、≤120),`BookSummary`/`BookRef` 返回它;书籍编辑不再只改书名。
- **界面**:封面区块变成一行(预览 + 上传/替换 + 移除);`.title-line` 在书名右侧显示作者(未设置不显示);「编辑书籍」对话框用同行标签编辑书名与作者。
- **验证**:Go **91 项顶层用例**(新增 `TestMySQLBookAuthorRoundTrip` 与 `TestMigrationFromV10AddsBookAuthor`);学习端 157 单测与 26 项 E2E 通过;真实链路实测封面行高 **68px**、章节列表紧随其后(间距 18px)、四段被删文字都不再出现、「编辑书籍」对话框含作者输入、保存后作者显示在书名右侧(同一行、位于书名之后)并在刷新后保留、清空后标签消失(截图 `issue37-real-book-page.png`)。
### 未验证
- 真机与真实浏览器解码播放(媒体元素在测试中被桩替换)、真机听感与兼容性仍未验证(#21 起的既有缺口)。
- Safari/Firefox 未测;渐进式流式播放仍未做(服务端 Range 已实现)。
- 迁移会清空书级音频测试数据且回退到 v8 找不回;当前无真实用户,工单已记录。
- 已有书籍/章节的存量内容不受影响,但**没有做「把既有书级音频迁到章级」的转换**(无真实数据)。
## #32 修复与验证(2026-09-16)
### 根因与修复
编辑路径用 `contentSHA("edit:<章节>:<内容摘要>")` 派生任务键,与 `lexgo_ingest_jobs.uq_job_request (owner_id, request_key)` 相撞:把正文改回曾经用过的版本时派生出已存在的键,`Create` 触发 MySQL 1062,事务整体回滚并冒泡为通用 500。新增 `stageEditJob`:先按 `(owner_id, chapter_id, content_sha256)` 锁定查询该版本的任务行,找到就复用(重置为 pending、attempts=0、清空失败原因),找不到才用派生键创建。**无 schema 变化**,因此不需要迁移;回退件 `.local/lexgo-pre-issue32.exe`。
### 验证
| 项 | 结果 |
|---|---|
| `go vet ./...` / `gofmt -l` | 通过、无输出 |
| `LEXGO_TEST_DB_NAME=lexgo_test_issue13 python scripts/server.py test-integration` | **92 项顶层用例通过、0 跳过**;新增 `TestMySQLChapterEditBackToAPreviousVersion` 覆盖 A→B→A→B(此前第三步 500)、「一章一版本一行任务」、被复用的行干净重启(pending、attempts=0、无 finished_at/error_reason)、失败版本再次成为当前版本时被复活、章节最终为 ready 且正文为最后一次编辑的内容、读者按该内容返回、正文未改动时不新增任务 |
| 真实 API+MySQL | **21 项检查通过**(`.local/issue32-api-evidence.json`):在开发实例上对一本 fixture 书籍执行 A→B→A→B,三步全部 200(此前第三步 500)、每步都 report versionChanged 并最终 ready、该章只有两条任务行且都以 ready 收尾、没有任务停在 pending、读者返回最后一次编辑的正文、章节内容摘要等于某个任务版本、再切回另一版本仍为 200 且仍是两行、删除 fixture 书籍后任务级联清空 |
| 治理 | `python -m unittest discover -s tests` 65 项与 `harness.py check --strict` 通过 |
### 未验证
- 只验证了「单章来回切换」;多章并发编辑、超大文本反复切换与移动端上的编辑体验未单独压测。
- 与编辑器交互无关:本单只改后端编排,前端在 #10 已实现「正文改版会重新处理」的提示。
## 英汉词典的准备、导入与验证(#40)
```powershell
# 1) 显式准备(需要网络,只在此步骤下载;产物不入库)
python scripts/dict_prepare.py
# 可用 --cache-dir .local/dictionaries/cache 复用已下载的源,--keep-going 在计数漂移时仍产出
# 打印条目数、三类音标计数、产物字节数与 sha256
# 2) 显式迁移(v12 增加 provider 并放开单行约束)
python scripts/server.py migrate
# 3) 导入:管理端「英语词典」页选择"英汉释义"后上传 .local/dictionaries/zh-dict-v1.zip
# 命令行等价:POST /api/v1/dictionaries/import(multipart: name/language/version/source/format + file)
# 导入失败或校验不通过时保留原有词典,不会替换成半成品。
# 4) 查询验证
curl -s http://127.0.0.1:8000/api/v1/dictionaries # items[].provider 应为 wordnet / ecdict
python .local/verify-issue40-api.py # 28 项真实 API 检查
```
- 资源路径:准备好的归档 `.local/dictionaries/zh-dict-v1.zip`(约 3.0 MB);WordNet 夹具沿用
`.local/nlp-resources/wordnet.zip`。测试在夹具缺失时跳过,不会静默通过。
- 测试命令:
`LEXGO_TEST_DB_NAME=lexgo_test_issue40 python scripts/server.py test-integration`(Go 96 项)、
`cd learner && npx vitest --run`(159 项)、`npx playwright test`(26 项,含 390×844 触摸视口)、
`cd admin && npx pnpm test`(32 项)。
- 迁移回退:把 `lexgo_schema.version` 写回 11、删除 id=2 的词典行即可;旧二进制缺少 `provider` 字段的
读语句不受影响(新列有默认值),但旧二进制见到 v12 会拒绝启动并要求显式迁移,这是有意的保护。
- 排错:`provider` 不存在 → 未执行 migrate;导入 400 且提示版本不符 → 归档不是当前 pin 生成的
(重新运行 `dict_prepare.py`);查词只有英英释义 → 英汉词典未导入或被停用。
## 验证登录有效期(#42)
```powershell
# 固定值本身在测试里断言,改动会让测试立刻失败
cd server; go test ./app/lexgo/ -run TestMySQLSessionLifetimeIsThirtyDays -v
# 集成测试:到期前仍可用、到期后被拒绝(按 SessionLifetime 计算,不写死小时数)
LEXGO_TEST_DB_NAME=lexgo_test_issue42 python scripts/server.py test-integration
```
- `TestMySQLSessionLifetimeIsThirtyDays` 校验三件事:常量等于 30 天、登录响应 `expiresAt` 与库内
`expires_at` 一致、库内只存在摘要(原始令牌查不到);`TestMySQLAccountIsolationAndRevocation`
在 `SessionLifetime - 1h` 仍成功、再前进 2 小时被拒。
- 手工排查:`SELECT owner_id, expires_at FROM lexgo_sessions ORDER BY expires_at DESC;`。
若登录后立刻 401,先确认服务器时间与 `expires_at` 都以 UTC 存储(`DATETIME` 不存时区)。
- 旧的 8 小时会话不受影响:各自按自己的 `expires_at` 自然到期,不需要数据迁移。
+109 -2
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: 099f1c803ffb69edc3fe07582d22dcf9a57647a1
synchronized_at: 2026-09-11T03:59:10Z
wiki_revision: 5c7b0b4942b2c10edadf2f2b84686675267d323e
synchronized_at: 2026-09-16T05:27:09Z
<!-- gitea-wiki-mirror:end -->
# 产品需求总览
@@ -239,3 +239,110 @@ Anki(U20)、YouTube/Jellyfin 远程字幕(U07/A08)、全量旧实例迁
## #6 交付范围更新(2026-09-11)
用户确认全Go后,英语词典配置与阅读器点词查义已实现待验收:共享WordNet3.0英语释义、管理员导入/启停、本人章节点击/键盘查词、加载/无结果/资源不可用/网络失败/关闭状态。词形结果是规则候选,不提供上下文词性消歧。个人释义输入是未保存临时草稿,#7才持久化。沿用已验收v1;手机底部45dvh面板自动测试通过,真机缺口保留。#5已关闭并合入main,#6尚不关闭。
## #7 交付范围更新(2026-09-11)
F07 的个人词语记录已于 2026-09-11 通过用户验收:阅读器可以保存与修改个人释义、例句和状态(新词/学习中/已知/忽略),同一词形在本人其他章节显示相同状态与高亮,两个账号的数据互不影响。个人释义与共享 WordNet 词典分开存储,个人释义不进入审计日志。schema 升级为 v5,新增 lexgo_terms。
仍未实现并留给后续工单:词汇库搜索与编辑(#12)、短语(#11)、到期复习与等级推进(#8)、阅读完成与进度(#13)、TXT 导入(#9)、书籍章节编辑删除(#10)。词形候选仍不提供上下文消歧,个人学习状态不按候选合并;例句不自动关联原文句子。
## #8 交付范围更新(2026-09-11)
F10 的单词到期复习已于 2026-09-11 通过用户验收(包含独立审核整改 R1~R3):按固定间隔表取本人当前语言的到期词条,正面显示词与挖空例句,显示答案后按「认识/答对」「不认识/答错」「再学一次」评分;答对升级并排下次复习,答错降级并立即回到本轮,再学一次不改等级并回到本轮。重复提交、网络重发与双标签页都不会重复更新次数和间隔;已知与忽略的词条不入队。管理端无改动。
仍未实现并留给后续工单:短语复习(#11)、词汇库搜索与编辑(#12)、阅读完成与进度统计(#13)、TXT 导入(#9)、书籍章节编辑删除(#10)。复习范围筛选与策略配置(X11)、练习模式(X08)、FSRS 仍在范围外。
## #9 交付范围更新(2026-09-11)
F03 的 TXT 文件导入已于 2026-09-13 通过用户验收:学习端导入页新增「粘贴文本 / TXT 文件」来源切换,选择 UTF-8 的 .txt 文件后经大小、空文件与编码校验进入与粘贴相同的处理与阅读流程,失败可重试。只支持 UTF-8(允许可选 BOM)且不替换损坏字符;UTF-16 与其他编码会被明确拒绝;文件只在内存中解码、不写临时文件,客户端文件名不参与任何路径也不入库;重复上传同一文件只产生一章。schema 无变化。
仍未实现并留给后续工单:书籍与章节的编辑删除(#10)、短语选择与保存(#11)、词汇库搜索与编辑(#12)、阅读完成与进度(#13)、桌面与手机体验补齐(#14)、自托管试用交付与完整恢复(#15)。EPUB/PDF/字幕、UTF-16 转码、按空行自动分章与断点续传不在本单范围。
## #10 交付范围更新(2026-09-11)
F01 的编辑与删除已于 2026-09-14 通过用户验收:学习端可改书名、改章节标题、编辑章节正文并按新版本重新处理,可用确认弹窗删除章节或整本书。编辑正文产生明确版本,旧处理结果被标为 `superseded` 而不覆盖新版本;删除在事务内完成并重排剩余章节序号,个人词条、复习排期与作答记录一律保留。本次没有数据库结构变化。
仍未实现并留给后续工单:短语选择与保存(#11)、词汇库搜索与编辑(#12)、阅读完成与进度(#13)、桌面与手机体验补齐(#14)、自托管试用交付与完整恢复(#15)。封面与音频附件(#21)、回收站/撤销、批量操作、章节跨书移动与语言变更不在本单范围。
## #11 交付范围更新(2026-09-11)
F08 的短语学习与 F10 的短语复习已于 2026-09-14 通过用户验收:阅读器支持连续选择(桌面原生拖选、手机系统手势、面板按词调整端点),保存个人释义、例句与状态;短语与单词共用同一张表、同一到期队列与同一套幂等作答,同一短语跨章节只存一条记录并在出现处高亮;复习卡片把整段短语挖成一个空。范围规则(整词对齐、内部标点保留、最多 12 词、最左最长重叠)与失效引用回退(高亮消失、学习记录保留)已固化。本次没有数据库结构变化。
仍未实现并留给后续工单:词汇库搜索与编辑(#12,含短语编辑界面)、阅读完成与进度(#13)、桌面与手机体验补齐(#14)、自托管试用交付与完整恢复(#15)。短语自动合并同义形式、上下文词性消歧、短语跨书移动、批量编辑与真机手柄精细手感不在本单范围。
## #12 交付范围更新(2026-09-11)
F09 的词汇库已于 2026-09-15 通过用户验收:生词本分页列出本人保存的词语与短语,可按关键词(匹配词形、显示原文或个人释义,大小写不敏感、通配符按字面值)与状态/类型筛选,可编辑释义、例句、状态与 1~7 学习等级,提供「开始到期复习」入口、空态与「没有匹配的词条 + 清除搜索与筛选」。编辑沿用与阅读器相同的领域校验与排期规则:身份不可编辑、只有状态或等级变化才移动复习时间、历史作答记录与计数保留;筛选与页码写入 URL,刷新或返回列表都保留。原型编辑页的「来自某章节」不实现(词条按身份存储、不引用章节)。本次没有数据库结构变化。
仍未实现并留给后续工单:阅读完成与进度(#13)、桌面与手机体验补齐(#14)、自托管试用交付与完整恢复(#15)、书籍音频与封面附件(#21)、书库列表优化(#24)。CSV 导入导出(X04)、复杂复习范围筛选(X11)、删除词条或批量清理(X14)不在本轮范围。
## #13 交付范围更新(2026-09-15)
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,已验收并合入 main)
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)仍待确认修复方案。
## #21 交付状态更新(2026-09-15,已验收并合入 main)
F 范围之外的 MVP 增补项「书籍音频与封面附件」已实现,待用户验收:每本书可选一张封面与一段 MP3,书库显示封面,书籍页可上传/替换/移除,阅读页播放器支持播放、拖动与倍速,不自动播放也不与文本同步;播放位置按账号与书籍保存,替换或移除音频时重置;附件按文件内容校验类型(不看扩展名),音频 ≤20 MiB、封面 ≤2 MiB 且 ≤4096×4096;附件读取需会话并支持 HTTP Range。schema 升到 v8(两张新表,需显式迁移)。附件存于数据库,因此包含在 #15 的备份与完整恢复范围内,恢复演练已验证附件按字节恢复且仍可读取与拖动。
非目标保持不变:多音轨、分章音轨、字幕与时间戳同步、逐句跟读、转写、TTS、转码、自动搜索封面、在线音频地址抓取、公开分享与跨用户共享。**浏览器真实解码播放(测试中媒体元素被桩替换)与真机听感仍需人工确认**;Safari/Firefox 未验证。其余待办:书籍音频与封面已在 #21 落地,剩余 #24 书库列表优化与缺陷 #32。
## #37 交付状态更新(2026-09-15)
用户的真实场景「多篇文章合并成一本书、每章各有音频,并为每章加插图」已实现,待用户验收:音频与插图改为**章节级**(一章各一张),封面维持**书级**;阅读页在正文上方显示本章插图,播放器只在本章有音频时出现;书籍页每个章节行显示插图缩略图与「附件」入口,附件对话框与正文编辑分开;播放位置粒度改为**账号+章节**,切换章节时上报一次。类型按文件内容判定(MP3、JPG/PNG/WebP),音频 ≤20 MiB、图片 ≤2 MiB 且 ≤4096×4096,读取需会话并支持 HTTP Range 与 ETag。schema 升到 v9(两张新表 + 幂等清空书级音频历史行,需显式迁移);**书级音频接口已下线**。附件仍存于数据库,因此 #15 的备份恢复自动覆盖,第三次恢复演练验证了附件按字节恢复且 Range 可用。
**已知代价**:#21 交付的书级音频功能整体作废(其测试期数据在迁移中清空且回退无法找回);前端实现与播放器组件被复用,未推倒重做。下次再调整附件挂载粒度前应先确认真实使用场景。**真机听感与浏览器真实解码播放仍未验证**。其余待办:#24 书库列表优化与缺陷 #32。
## #37 交付状态更新(2026-09-15)
用户的真实场景「多篇文章合并成一本书、每章各有音频,并为每章加插图」已实现,待用户验收:音频与插图改为**章节级**(一章各一张),封面维持**书级**;阅读页在正文上方显示本章插图缩略图、点击后在弹窗里查看原图(章节列表不显示章节图片),播放器只在本章有音频时出现;书籍页每个章节行显示插图缩略图与「附件」入口,附件对话框与正文编辑分开;播放位置粒度改为**账号+章节**,切换章节时上报一次。类型按文件内容判定(MP3、JPG/PNG/WebP),音频 ≤20 MiB、图片 ≤2 MiB 且 ≤4096×4096,读取需会话并支持 HTTP Range 与 ETag。schema 升到 v9(两张新表 + 幂等清空书级音频历史行,需显式迁移);**书级音频接口已下线**。附件仍存于数据库,因此 #15 的备份恢复自动覆盖,第三次恢复演练验证了附件按字节恢复且 Range 可用。
**已知代价**:#21 交付的书级音频功能整体作废(其测试期数据在迁移中清空且回退无法找回);前端实现与播放器组件被复用,未推倒重做。下次再调整附件挂载粒度前应先确认真实使用场景。**真机听感与浏览器真实解码播放仍未验证**。其余待办:#24 书库列表优化与缺陷 #32。
## #37 交付状态更新(2026-09-15)
用户的真实场景「多篇文章合并成一本书、每章各有音频,并为每章加插图」已实现,待用户验收:音频与插图改为**章节级**(一章各一张),封面维持**书级**;阅读页在正文上方显示本章插图缩略图、点击后在弹窗里查看原图(章节列表不显示章节图片),播放器只在本章有音频时出现;书籍页章节列表每行只有一个「编辑」入口,标题、正文、插图与音频在同一对话框内编辑(标题与正文点保存后生效,文件选中后立即上传);播放位置粒度改为**账号+章节**,切换章节时上报一次。类型按文件内容判定(MP3、JPG/PNG/WebP),音频 ≤20 MiB、图片 ≤2 MiB 且 ≤4096×4096,读取需会话并支持 HTTP Range 与 ETag。schema 升到 v9(两张新表 + 幂等清空书级音频历史行,需显式迁移);**书级音频接口已下线**。附件仍存于数据库,因此 #15 的备份恢复自动覆盖,第三次恢复演练验证了附件按字节恢复且 Range 可用。
**已知代价**:#21 交付的书级音频功能整体作废(其测试期数据在迁移中清空且回退无法找回);前端实现与播放器组件被复用,未推倒重做。下次再调整附件挂载粒度前应先确认真实使用场景。**真机听感与浏览器真实解码播放仍未验证**。其余待办:#24 书库列表优化与缺陷 #32。
## #37 交付状态更新(2026-09-15)
用户的真实场景「多篇文章合并成一本书、每章各有音频,并为每章加插图」已实现,待用户验收:音频与插图改为**章节级**(一章各一张),封面维持**书级**;阅读页在正文上方显示本章插图缩略图、点击后在弹窗里查看原图(章节列表不显示章节图片),播放器只在本章有音频时出现;书籍页章节列表每行只有一个「编辑」入口,标题、作者、正文、插图与音频在同一对话框内编辑(标题/作者/正文点保存后生效,文件选中后立即上传);章节作者为可选自由文本(≤120 字符),阅读页在标题下显示;schema 为 v10;播放位置粒度改为**账号+章节**,切换章节时上报一次。类型按文件内容判定(MP3、JPG/PNG/WebP),音频 ≤20 MiB、图片 ≤2 MiB 且 ≤4096×4096,读取需会话并支持 HTTP Range 与 ETag。schema 升到 v9(两张新表 + 幂等清空书级音频历史行,需显式迁移);**书级音频接口已下线**。附件仍存于数据库,因此 #15 的备份恢复自动覆盖,第三次恢复演练验证了附件按字节恢复且 Range 可用。
**已知代价**:#21 交付的书级音频功能整体作废(其测试期数据在迁移中清空且回退无法找回);前端实现与播放器组件被复用,未推倒重做。下次再调整附件挂载粒度前应先确认真实使用场景。**真机听感与浏览器真实解码播放仍未验证**。其余待办:#24 书库列表优化与缺陷 #32。
## #37 交付状态更新(2026-09-15)
用户的真实场景「多篇文章合并成一本书、每章各有音频,并为每章加插图」已实现,待用户验收:音频与插图改为**章节级**(一章各一张),封面维持**书级**;阅读页在正文上方显示本章插图缩略图、点击后在弹窗里查看原图(章节列表不显示章节图片),播放器只在本章有音频时出现;书籍页章节列表每行只有一个「编辑」入口,标题、作者、正文、插图与音频在同一对话框内编辑(标题/作者/正文点保存后生效,文件选中后立即上传);**书籍作者**与**章节作者**是两份互相独立的可选自由文本(各 ≤120 字符,留空即清空),书级作者显示在书籍页书名右侧并由「编辑书籍」维护,章节作者显示在阅读页标题下;schema 为 v11;播放位置粒度改为**账号+章节**,切换章节时上报一次。类型按文件内容判定(MP3、JPG/PNG/WebP),音频 ≤20 MiB、图片 ≤2 MiB 且 ≤4096×4096,读取需会话并支持 HTTP Range 与 ETag。schema 升到 v9(两张新表 + 幂等清空书级音频历史行,需显式迁移);**书级音频接口已下线**。附件仍存于数据库,因此 #15 的备份恢复自动覆盖,第三次恢复演练验证了附件按字节恢复且 Range 可用。
**已知代价**:#21 交付的书级音频功能整体作废(其测试期数据在迁移中清空且回退无法找回);前端实现与播放器组件被复用,未推倒重做。下次再调整附件挂载粒度前应先确认真实使用场景。**真机听感与浏览器真实解码播放仍未验证**。其余待办:#24 书库列表优化与缺陷 #32。
## #37 交付状态更新(2026-09-15,已验收并合入 main)
用户的真实场景「多篇文章合并成一本书、每章各有音频,并为每章加插图」已实现,待用户验收:音频与插图改为**章节级**(一章各一张),封面维持**书级**;阅读页在正文上方显示本章插图缩略图、点击后在弹窗里查看原图(章节列表不显示章节图片),播放器只在本章有音频时出现;书籍页章节列表每行只有一个「编辑」入口,标题、作者、正文、插图与音频在同一对话框内编辑(标题/作者/正文点保存后生效,文件选中后立即上传);**书籍作者**与**章节作者**是两份互相独立的可选自由文本(各 ≤120 字符,留空即清空),书级作者显示在书籍页书名右侧并由「编辑书籍」维护,章节作者显示在阅读页标题下;schema 为 v11;播放位置粒度改为**账号+章节**,切换章节时上报一次。类型按文件内容判定(MP3、JPG/PNG/WebP),音频 ≤20 MiB、图片 ≤2 MiB 且 ≤4096×4096,读取需会话并支持 HTTP Range 与 ETag。schema 升到 v9(两张新表 + 幂等清空书级音频历史行,需显式迁移);**书级音频接口已下线**。附件仍存于数据库,因此 #15 的备份恢复自动覆盖,第三次恢复演练验证了附件按字节恢复且 Range 可用。
**已知代价**:#21 交付的书级音频功能整体作废(其测试期数据在迁移中清空且回退无法找回);前端实现与播放器组件被复用,未推倒重做。下次再调整附件挂载粒度前应先确认真实使用场景。**真机听感与浏览器真实解码播放仍未验证**。其余待办:#24 书库列表优化与缺陷 #32。
## F06 词典范围扩展:英汉释义与规范音标(#40)
用户 2026-09-16 提出「找个英中词典,单词有发音」,并澄清"发音"指**音标**(举例「grok 的 /gra:k/」),
随后确认三项决策:约 5 万词量级的常用子集、规范 IPA 音标、允许多词典并存(schema v12)。
已交付范围:点词后同时看到**中文释义**(ECDICT 子集,约 8.2 万条)与**音标**
(优先 CMUdict IPA 转写,其余保留 ECDICT 记法并标注来源),英英释义仍作为补充;
两本词典可分别启停,个人释义与学习状态规则不变。
非目标:发音**音频**、整句翻译、汉英方向(中→英)、词频统计界面。F 系列其余功能与 X 系列范围不受影响。
+230
View File
@@ -0,0 +1,230 @@
<!-- 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: 2fa9c30e08fc14346d9960178a425a8ee3564a3a
synchronized_at: 2026-09-16T05:27:09Z
<!-- 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` 与模拟触摸视口。
- 未启用定时备份、监控与告警;备份由人工触发。
- **封面(书级)与音频、插图(章级)都存进数据库**,因此 dump 自动包含它们与播放位置,恢复后仍可按字节读取并拖动;**附件会让 dump 变大**(单音频上限 20 MiB,图片上限 2 MiB),容量与备份体积要按试用量估算。#37 起附件挂在章节上,书级音频接口已下线。
- 附件读取需要会话并支持 HTTP Range;客户端用带凭据的 fetch 取字节后交给媒体元素,令牌不出现在 URL 里,但浏览器会整份取回后再播放(未做渐进式流式播放)。
- 恢复演练是单机顺序执行,未验证大库恢复耗时与磁盘空间上限。
- 接口级的两账号闭环与越权验证需要 HTTP 客户端,目前只在 Python 工具通道提供;Go 二进制提供数据库层的备份、恢复与校验。
- 备份仍调用 `mysqldump`:自己实现一致性导出风险更高,因此部署机需要 MySQL 客户端而不只是服务端。
## 备份体积与词典资源(#40)
英汉词典归档(约 3.0 MB)与 WordNet(约 10.3 MB)都以整包形式存在 `lexgo_dictionaries.archive`,
因此 `lexgo backup` 的 dump 天然包含两本词典,恢复后无需重新下载或重新准备词典。
- 备份/恢复流程与 #15 相同,不需要新增参数;`lexgo verify` 检查的仍是同一套清单。
- 词典归档由 `scripts/dict_prepare.py` 在本机从 sha256 pin 的源生成,**产物不入库**;
恢复演练时若需要重新准备,必须重新下载并在准备阶段校验 sha256。
- 迁移到 v12 后旧二进制会被拒绝启动(见本地开发页),因此回退二进制必须先回退 schema 版本标记。
- 未做:定时备份、多主机、HTTPS 的缺口沿用 #15 记录,不因本单变化。
+37 -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: 241fa63d8141ed9f1992421346d42f9b87453b0a
synchronized_at: 2026-09-11T03:58:55Z
wiki_revision: e6e3b2a93c4d4f3a809872e8260c3776923c7623
synchronized_at: 2026-09-16T13:51:35Z
<!-- gitea-wiki-mirror:end -->
# LexGo 文档入口
@@ -69,4 +69,38 @@ Quant-UX 原型 v1 已通过用户验收。[桌面预览](https://qux.ilapage.cn
## 当前进度(2026-09-11)
#5已验收,#17/#19/#20/#22/#23按依赖顺序合入main。#6已按用户确认的全Go方向实现英语词典与阅读点词,待验收;原文/账户隔离保留。正式NLP不使用Python服务。管理端“英语词典”导入指定WordNet3.0,学习端打开本人章节即可查词。个人释义当前不保存,后续#7实现。详见#6工单、架构和本地开发页面。
#5已验收,#17/#19/#20/#22/#23按依赖顺序合入main。#6已按用户确认的全Go方向实现英语词典与阅读点词,2026-09-11通过用户验收,PR #25已合入main;原文/账户隔离保留。正式NLP不使用Python服务。管理端“英语词典”导入指定WordNet3.0,学习端打开本人章节即可查词。个人释义与学习状态已实现持久化(#7,待用户验收)。详见#6工单、架构和本地开发页面。
#7 个人词条已实现并于 2026-09-11 通过用户验收(schema v5 新增 lexgo_terms):阅读器可保存释义、例句与状态,同一词形在本人其他章节显示一致高亮,两个账号数据独立;保存幂等,跨账号与篡改身份均被拒绝;PR #26 已 fast-forward-only 合入 main。
#8 单词到期复习已于 2026-09-11 通过用户验收(schema v6 新增 lexgo_term_reviews 与 lexgo_review_answers):固定间隔表(1/2/4/7/15/30/60 天)、答对升级、答错或再学立即回队、已知与忽略不入队;重复提交、网络重发与双标签页都只记账一次,到期判定用 UTC 绝对时刻而不引入本地日边界。学习端新增「到期复习」入口,卡片正面显示词与挖空例句、答案面显示个人释义,并有完成页与空队列页。独立审核指出的并发同键 500、编辑文本重排复习与面板保存重置等级三项已整改并复测;PR #27 已 fast-forward-only 合入 main。第 3 阶段「首条学习闭环」#5~#8 全部验收。
#9 TXT 文件导入已于 2026-09-13 通过用户验收:导入页新增「粘贴文本 / TXT 文件」来源切换,只接受 UTF-8(允许可选 BOM)且不替换损坏字符,UTF-16 与其他编码会被明确拒绝;文件只在内存中解码、不写临时文件,客户端文件名不参与任何路径也不入库;上传与粘贴共用同一分章、任务与幂等规则,重复上传同一文件只产生一章。本次没有数据库结构变化;PR #28 已 fast-forward-only 合入 main。
#10 编辑与删除书籍章节已于 2026-09-14 通过用户验收:可改书名、改章节标题、编辑章节正文并按新版本重新处理,也可用确认弹窗删除章节或整本书。编辑正文产生明确版本,旧处理结果会被标为 superseded 而不覆盖新版本;删除在事务内完成并重排剩余章节序号,已保存的个人词条、复习排期与作答记录一律保留。本次没有数据库结构变化;旧处理结果不会覆盖新版本,PR #29 已 fast-forward-only 合入 main。
#11 短语选择、保存与复习已于 2026-09-14 通过用户验收:在正文中连续选择一个范围(桌面原生拖选、手机系统手柄、面板端点按钮与 Shift 点击调整),保存个人释义与状态;短语与单词共用同一张表、同一到期队列与同一套幂等作答,因此同一短语在不同章节只存一条记录并在出现的每处高亮,复习卡片把整段短语挖成一个空。短语与单词重叠时短语高亮覆盖、单词数据不变;编辑正文后短语不再出现时不报错,词条与复习排期保留。本次没有数据库结构变化;用户确认 kind 与词数由身份键派生,PR #30 已 fast-forward-only 合入 main。
#12 词汇库的搜索、筛选与编辑已于 2026-09-15 通过用户验收:生词本分页列出本人保存的词语与短语,可按关键词(匹配词形、原文或个人释义,大小写不敏感)与状态/类型筛选,并可编辑释义、例句、状态与 1~7 学习等级。编辑沿用与阅读器相同的领域规则:身份不可改、只有状态或等级变化才重排复习时间、历史作答记录保留;筛选与页码写入 URL,刷新或返回列表都保留。原型的「来自某章节」不实现,因为词条按身份存储、不引用章节。本次没有数据库结构变化;PR #31 已 fast-forward-only 合入 main。
#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 自托管试用交付已于 2026-09-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 项);纯二进制安装另有 9 项演练、完整恢复演练 27 项、附件版恢复演练 22 项。真实回滚(升级后切回旧二进制)、HTTPS、多机与定时备份仍未验证,已写入部署页的已知限制。PR #35 已 fast-forward-only 合入 main。
#21 书籍音频与封面附件已于 2026-09-15 通过用户验收:每本书可以上传一张封面(JPG/PNG/WebP,≤2 MiB,≤4096×4096)和一段 MP3(≤20 MiB);书库卡片显示封面,书籍页新增「音频与封面」区块可上传、替换、移除,阅读页在正文上方显示常驻播放器(播放/暂停、拖动进度、0.75–1.5 倍速),不自动播放、不与文本同步。播放位置按账号与书籍保存,替换或移除音频会重置位置;附件与进度按账号隔离,读取需要会话并支持 HTTP Range。附件存于 MySQL,因此 #15 的备份恢复天然覆盖:2026-09-15 的恢复演练验证了恢复后附件按字节相同地返回且 Range 仍可用。真实浏览器是否解码播放(媒体元素在测试中被桩替换)以及真机听感仍需人工确认。
#37 章节级音频与章节插图已于 2026-09-16 通过用户验收:每章可以上传一段 MP3(≤20 MiB)与一张插图(JPG/PNG/WebP,≤2 MiB、≤4096×4096),封面仍按书;阅读页在正文上方显示本章插图的缩略图、点击后在弹窗里查看原图(书籍页章节列表不显示章节图片),播放器只在本章有音频时出现且不与文本同步,播放位置按「账号+章节」保存并在切换章节时上报;书籍页的封面压成一行紧凑控件(预览+上传/替换/移除),书名右侧显示书级作者,「编辑书籍」可改书名与作者,章节列表每行只有一个「编辑」,点开后在同一对话框里编辑标题、作者、正文与本章插图、音频(标题/作者/正文点保存后生效,文件选中后立即上传;阅读页在标题下显示作者)。类型按文件内容判定,读取需会话并支持 HTTP Range 与 ETag;附件存于数据库,包含在 #15 的备份恢复范围内(第三次恢复演练已验证按字节恢复)。schema 已升到 v11(v9 章级附件、v10 章节作者、v11 书籍作者,均需显式迁移),书级音频接口已下线,其实测试期数据在迁移中清空;PR #38 已 fast-forward-only 合入 main。真机听感与浏览器真实解码播放仍需人工确认;书库卡片暂不显示书级作者、章节作者不继承书级作者(两项经确认未实现,如需另开工单)。
#37 章节级音频与章节插图已于 2026-09-15 实施完成,待用户验收:每章可以上传一段 MP3(≤20 MiB)与一张插图(JPG/PNG/WebP,≤2 MiB、≤4096×4096),封面仍按书;阅读页在正文上方显示本章插图的缩略图、点击后在弹窗里查看原图(书籍页章节列表不显示章节图片),播放器只在本章有音频时出现且不与文本同步,播放位置按「账号+章节」保存并在切换章节时上报;书籍页章节列表显示插图缩略图与「附件」入口,附件编辑与正文编辑分开。类型按文件内容判定,读取需会话并支持 HTTP Range 与 ETag;附件存于数据库,包含在 #15 的备份恢复范围内(第三次恢复演练已验证按字节恢复)。schema v9 需显式迁移,书级音频接口已下线,其实测试期数据在迁移中清空。真机听感与浏览器真实解码播放仍需人工确认。
#37 章节级音频与章节插图已于 2026-09-15 实施完成,待用户验收:每章可以上传一段 MP3(≤20 MiB)与一张插图(JPG/PNG/WebP,≤2 MiB、≤4096×4096),封面仍按书;阅读页在正文上方显示本章插图的缩略图、点击后在弹窗里查看原图(书籍页章节列表不显示章节图片),播放器只在本章有音频时出现且不与文本同步,播放位置按「账号+章节」保存并在切换章节时上报;书籍页章节列表每行只有一个「编辑」,点开后在同一对话框里编辑标题、正文与本章插图、音频(标题与正文点保存后生效,文件选中后立即上传)。类型按文件内容判定,读取需会话并支持 HTTP Range 与 ETag;附件存于数据库,包含在 #15 的备份恢复范围内(第三次恢复演练已验证按字节恢复)。schema v9 需显式迁移,书级音频接口已下线,其实测试期数据在迁移中清空。真机听感与浏览器真实解码播放仍需人工确认。
#37 章节级音频与章节插图已于 2026-09-15 实施完成,待用户验收:每章可以上传一段 MP3(≤20 MiB)与一张插图(JPG/PNG/WebP,≤2 MiB、≤4096×4096),封面仍按书;阅读页在正文上方显示本章插图的缩略图、点击后在弹窗里查看原图(书籍页章节列表不显示章节图片),播放器只在本章有音频时出现且不与文本同步,播放位置按「账号+章节」保存并在切换章节时上报;书籍页章节列表每行只有一个「编辑」,点开后在同一对话框里编辑标题、作者、正文与本章插图、音频(标题/作者/正文点保存后生效,文件选中后立即上传;阅读页在标题下显示作者)。类型按文件内容判定,读取需会话并支持 HTTP Range 与 ETag;附件存于数据库,包含在 #15 的备份恢复范围内(第三次恢复演练已验证按字节恢复)。schema v9 需显式迁移,书级音频接口已下线,其实测试期数据在迁移中清空。真机听感与浏览器真实解码播放仍需人工确认。
#37 章节级音频与章节插图已于 2026-09-15 实施完成,待用户验收:每章可以上传一段 MP3(≤20 MiB)与一张插图(JPG/PNG/WebP,≤2 MiB、≤4096×4096),封面仍按书;阅读页在正文上方显示本章插图的缩略图、点击后在弹窗里查看原图(书籍页章节列表不显示章节图片),播放器只在本章有音频时出现且不与文本同步,播放位置按「账号+章节」保存并在切换章节时上报;书籍页的封面压成一行紧凑控件(预览+上传/替换/移除),书名右侧显示书级作者,「编辑书籍」可改书名与作者,章节列表每行只有一个「编辑」,点开后在同一对话框里编辑标题、作者、正文与本章插图、音频(标题/作者/正文点保存后生效,文件选中后立即上传;阅读页在标题下显示作者)。类型按文件内容判定,读取需会话并支持 HTTP Range 与 ETag;附件存于数据库,包含在 #15 的备份恢复范围内(第三次恢复演练已验证按字节恢复)。schema v9 需显式迁移,书级音频接口已下线,其实测试期数据在迁移中清空。真机听感与浏览器真实解码播放仍需人工确认。
#40 英汉词典与规范音标已于 2026-09-16 实现并进入待验收:新增第二本英语词典——ECDICT 常用子集(约 8.2 万条中文释义,3.0 MB)与 CMUdict 转写的 IPA 音标。音标分三层来源:能由 CMUdict 音素按固定映射转写的用真 IPA(35,799 条),否则保留 ECDICT 记法仅做字符级规范化并标注来源(30,580 条),两者都不可靠就不显示(16,342 条,例如 `grok`);含 `^` 等丢失首音的记法整条丢弃,不做猜测。schema 升到 v12(放开 `lexgo_dictionaries` 单行 CHECK、增加 `provider` 列,两步都带条件守卫、可重放),英英与英汉词典可分别启停,查词时中文释义在前、英英释义随后,屈折形经 WordNet lemma 解析后也能看到中文释义但不会复制词目音标。归档由 `scripts/dict_prepare.py` 从 sha256 pin 的三个源显式准备(运行时不联网,产物不入库,仓库只保存 pin 与两份许可证),整包存进 MySQL 因而包含在 #15 的备份范围内。测试:Go 96 项、学习端 159 单测与 26 项 e2e(含 390×844 触摸视口)、管理端 32 项、真实 API 28 项检查;PR #41 已合入 main,并由用户于 2026-09-16 验收通过。发音音频、整句翻译与汉英方向不在本单范围。
#42 登录有效期已于 2026-09-16 由 8 小时改为 **30 天**(学习端与管理端共用同一登录接口,一处生效):有效期提取为具名常量 `SessionLifetime`,仍然是**绝对**有效期、不做滑动续期,撤销规则不变(退出只撤销当前会话,改密码/停用/重置撤销该账号全部会话,服务端只存 SHA-256 摘要)。代价是令牌泄露后的可用窗口放大约 90 倍;设备/会话列表与"退出其他设备"未包含在本单,如需另开工单。无数据库结构变化,旧会话按各自到期时间自然过渡;测试:Go 92 项(新增 1 项锁定 30 天与摘要存储,并把过期断言改为按常量计算);PR #43 已合入 main,并由用户于 2026-09-16 验收通过。
+239
View File
@@ -0,0 +1,239 @@
import { expect, test, type Page } from '@playwright/test'
import { expectModalOverlay } from './overlay'
// Book audio and covers against a mocked API: upload, replace, remove, and the reader's player.
const user = { id: 42, username: 'fictional-attachment', role: 'learner' }
const book = { id: 7, title: 'Fictional reader', language: 'en' }
const chapterText = 'Curiosity opens the first door.\nThe second door stays closed.\n'
const chapter = {
id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', status: 'ready', charCount: chapterText.length,
errorReason: '', errorMessage: '', jobId: 9, readAt: null, createdAt: '', updatedAt: '',
}
const pngBytes = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==",
"base64",
)
interface Attachment { kind: 'audio' | 'cover' | 'illustration'; mime: string; byteSize: number; version: string; updatedAt: string }
async function mockApi(page: Page, options: { audio?: boolean; cover?: boolean } = {}): Promise<{ posted: string[]; deleted: string[]; positions: number[] }> {
let audio: Attachment | null = options.audio === false ? null : { kind: 'audio', mime: 'audio/mpeg', byteSize: 4096, version: 'audio-sha', updatedAt: '2026-09-15T00:00:00Z' }
let cover: Attachment | null = options.cover === false ? null : { kind: 'cover', mime: 'image/png', byteSize: pngBytes.length, version: 'cover-sha', updatedAt: '2026-09-15T00:00:00Z' }
let illustration: Attachment | null = options.cover === false ? null : { kind: 'illustration', mime: 'image/png', byteSize: pngBytes.length, version: 'illustration-sha', updatedAt: '2026-09-15T00:00:00Z' }
let playbackSeconds = 0
const posted: string[] = []
const deleted: string[] = []
const positions: number[] = []
await page.route('**/api/v1/**', async route => {
const url = new URL(route.request().url())
const path = url.pathname
const method = route.request().method()
const json = (data: unknown, status = 200) => route.fulfill({ status, json: { code: 200, data } })
const attachments = () => ({ cover })
const chapterFiles = () => ({ ...chapter, illustrationVersion: illustration?.version ?? '', audioVersion: audio?.version ?? '', playbackSeconds })
if (path === '/api/v1/login') return json({ token: 'fictional-session', user })
if (path === '/api/v1/me') return json(user)
if (path === '/api/v1/space') return json({ ownerId: user.id, language: 'en' })
if (path === '/api/v1/books' && method === 'GET') {
return json({ items: [{ ...book, chapterCount: 1, pendingCount: 0, processingCount: 0, readyCount: 1, failedCount: 0, coverVersion: cover?.version ?? '', hasAudio: audio !== null, createdAt: '', updatedAt: '' }] })
}
if (path === '/api/v1/books/7' && method === 'GET') return json({ book: { ...book, attachments: attachments() }, chapters: [chapterFiles()] })
const attachmentPath = path === '/api/v1/books/7/cover' ? 'cover'
: path === '/api/v1/chapters/3/illustration' ? 'illustration'
: path === '/api/v1/chapters/3/audio' ? 'audio' : null
if (attachmentPath !== null) {
const kind = attachmentPath as 'cover' | 'illustration' | 'audio'
if (method === 'GET') {
const body = kind === 'audio' ? Buffer.concat([Buffer.from('ID3'), Buffer.alloc(4096)]) : pngBytes
return route.fulfill({ status: 200, headers: { 'Content-Type': kind === 'audio' ? 'audio/mpeg' : 'image/png' }, body })
}
if (method === 'POST') {
posted.push(kind)
// The mock answers exactly like the backend: the new version replaces the old one.
const next: Attachment = { kind, mime: kind === 'audio' ? 'audio/mpeg' : 'image/png', byteSize: 2048, version: `${kind}-new`, updatedAt: '2026-09-15T01:00:00Z' }
if (kind === 'cover') cover = next
else if (kind === 'illustration') illustration = next
else { audio = next; playbackSeconds = 0 }
return json({ attachment: next })
}
if (method === 'DELETE') {
deleted.push(kind)
if (kind === 'cover') cover = null
else if (kind === 'illustration') illustration = null
else audio = null
return json({ deleted: true })
}
}
if (path === '/api/v1/chapters/3/playback' && method === 'PUT') {
const body = route.request().postDataJSON() as { positionSeconds: number }
positions.push(body.positionSeconds)
playbackSeconds = body.positionSeconds
return json({ playbackSeconds })
}
if (path === '/api/v1/chapters/3/tokens') {
let offset = 0
const tokens = (chapterText.match(/[A-Za-z]+|\s+|[^A-Za-z\s]+/g) ?? []).map(piece => {
const start = offset
offset += piece.length
return { text: piece, start, end: offset, startUtf16: start, endUtf16: offset, kind: /^[A-Za-z]+$/.test(piece) ? 'word' : 'space' }
})
return json({ textSha256: 'sha', tokens })
}
if (path === '/api/v1/chapters/3/source') {
return json({ source: { id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', text: chapterText, status: 'ready', contentSha256: 'sha', charCount: chapterText.length } })
}
if (path === '/api/v1/chapters/3' && method === 'PATCH') return json({ chapter: chapterFiles(), job: null, versionChanged: false })
if (path === '/api/v1/chapters/3') return json({ book: { ...book, attachments: attachments() }, chapter: { ...chapterFiles(), contentSha256: 'sha', originalText: chapterText }, navigation: { previousChapterId: null, nextChapterId: null } })
return json({})
})
return { posted, deleted, positions }
}
async function login(page: Page): Promise<void> {
await page.addInitScript(() => sessionStorage.setItem('lexgo-learner-token', 'fictional-session'))
await page.goto('/')
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
}
test('the library shows the cover and the chapter dialog manages its files', async ({ page }) => {
const calls = await mockApi(page)
await login(page)
await expect(page.getByTestId('book-cover')).toBeVisible()
await page.screenshot({ path: '../.local/evidence/issue21-library-cover.png' })
await page.getByRole('link', { name: 'Fictional reader', exact: true }).click()
await expect(page.getByTestId('attachments')).toBeVisible()
// The cover block is one compact row: preview plus actions, no headings or size hints.
await expect(page.getByTestId('cover-preview')).toBeVisible()
await expect(page.locator('.cover-row')).not.toContainText('像素不超过')
await expect(page.locator('.chapter-meta')).toContainText('有音频')
// The chapter row carries no image: it only has the audio meta and the attachment entry point.
await expect(page.getByTestId('chapter-thumb-3')).toHaveCount(0)
await expect(page.locator('.chapter-thumb')).toHaveCount(0)
// One entry point per row: the editor holds the title, the text and both files.
await expect(page.getByTestId('chapter-attachments-3')).toHaveCount(0)
await page.screenshot({ path: '../.local/evidence/issue37-chapter-row.png' })
await page.getByTestId('edit-chapter-3').click()
// A real modal, not content appended below the chapter list.
await expectModalOverlay(page, page.getByTestId('chapter-dialog'))
await expect(page.locator('#chapter-title')).toBeVisible()
await expect(page.getByTestId('chapter-author')).toBeVisible()
await expect(page.locator('.field-row label[for="chapter-author"]')).toBeVisible()
await expect(page.getByTestId('attachment-timing')).toContainText('立即上传')
await expect(page.getByTestId('illustration-state')).toContainText('已设置')
await expect(page.getByTestId('chapter-audio-state')).toContainText('已设置')
await page.screenshot({ path: '../.local/evidence/issue37-attachment-dialog.png' })
// Replacing the illustration and the audio goes to the chapter endpoints.
await page.setInputFiles('[data-testid="illustration-input"]', { name: 'chapter.png', mimeType: 'image/png', buffer: pngBytes })
await expect(page.getByTestId('book-notice')).toContainText('插图已更新')
await page.setInputFiles('[data-testid="chapter-audio-input"]', { name: 'track.mp3', mimeType: 'audio/mpeg', buffer: Buffer.concat([Buffer.from('ID3'), Buffer.alloc(2048)]) })
await expect(page.getByTestId('book-notice')).toContainText('音频已更新')
expect(calls.posted).toEqual(['illustration', 'audio'])
// A file of the wrong type is refused in the page, before any request.
await page.setInputFiles('[data-testid="chapter-audio-input"]', { name: 'song.wav', mimeType: 'audio/wav', buffer: Buffer.from('RIFF....') })
await expect(page.locator('[data-testid="chapter-dialog"] .field-error')).toContainText('音频只支持 MP3')
expect(calls.posted).toEqual(['illustration', 'audio'])
// Removing the audio asks first and then reports the empty state.
await page.getByTestId('chapter-audio-remove').click()
await page.getByRole('button', { name: '确认移除' }).click()
await expect(page.getByTestId('chapter-audio-state')).toContainText('未设置')
expect(calls.deleted).toEqual(['audio'])
})
test('the reader plays the audio without starting it and remembers the position', async ({ page }) => {
// The fixture bytes are not a decodable MP3, so the media element itself is stubbed: the test is
// about the player's behaviour, not about Chromium decoding a synthetic file.
await page.addInitScript(() => {
const media = HTMLMediaElement.prototype as unknown as Record<string, unknown>
Object.defineProperty(media, 'duration', { configurable: true, get: () => 180 })
// paused has to follow the stub, otherwise the player would try to play again instead of pausing.
Object.defineProperty(media, 'paused', {
configurable: true,
get(this: HTMLMediaElement & { __playing?: boolean }) { return !this.__playing },
})
Object.defineProperty(media, 'play', {
configurable: true,
value(this: HTMLMediaElement & { __playing?: boolean }) {
this.__playing = true
this.dispatchEvent(new Event('play'))
return Promise.resolve()
},
})
Object.defineProperty(media, 'pause', {
configurable: true,
value(this: HTMLMediaElement & { __playing?: boolean }) {
this.__playing = false
this.dispatchEvent(new Event('pause'))
},
})
Object.defineProperty(media, 'load', {
configurable: true,
value(this: HTMLMediaElement) { this.dispatchEvent(new Event('loadedmetadata')) },
})
})
const calls = await mockApi(page)
await login(page)
await page.goto('/chapters/3')
await expect(page.locator('.reader-text')).toBeVisible()
// The chapter's illustration is a thumbnail above the text; clicking it shows the original.
const thumbnail = page.getByTestId('chapter-illustration')
await expect(thumbnail).toBeVisible()
await expect(thumbnail).toContainText('点击查看大图')
await thumbnail.click()
await expectModalOverlay(page, page.getByTestId('illustration-dialog'))
await expect(page.getByTestId('illustration-dialog').locator('img')).toHaveAttribute('src', /^blob:/)
await page.screenshot({ path: '../.local/evidence/issue37-illustration-dialog.png' })
await page.keyboard.press('Escape')
await expect(page.getByTestId('illustration-dialog')).toBeHidden()
const player = page.getByTestId('audio-player')
await expect(player).toBeVisible()
// The player is sticky above the text so the controls stay reachable while reading.
await expect(player).toHaveCSS('position', 'sticky')
await expect(player).toContainText('0:00')
await page.screenshot({ path: '../.local/evidence/issue21-reader-player.png' })
// Nothing plays until the button is pressed.
await expect(page.getByTestId('audio-toggle')).toHaveAttribute('aria-label', '播放音频')
await page.getByTestId('audio-toggle').click()
await expect(page.getByTestId('audio-toggle')).toHaveAttribute('aria-label', '暂停音频')
await page.screenshot({ path: '../.local/evidence/issue21-reader-playing.png' })
// Seeking moves the position and pausing reports it to the book.
// A range input cannot be filled, so the value is set the way dragging the handle would.
await page.getByTestId('audio-seek').evaluate((element, value) => {
const input = element as HTMLInputElement
input.value = String(value)
input.dispatchEvent(new Event('input', { bubbles: true }))
}, 12)
await page.getByTestId('audio-toggle').click()
await expect.poll(() => calls.positions.length).toBeGreaterThan(0)
// The speed control states which rate is active.
await page.getByTestId('audio-rate-1.5').click()
await expect(page.getByTestId('audio-rate-1.5')).toHaveAttribute('aria-pressed', 'true')
})
test('a book without attachments shows placeholders and no player', async ({ page }) => {
await mockApi(page, { audio: false, cover: false })
await login(page)
await expect(page.getByTestId('book-cover')).toHaveCount(0)
// The management sections stay: adding a file must be possible before one exists.
await page.getByRole('link', { name: 'Fictional reader', exact: true }).click()
await expect(page.getByTestId('attachments')).toBeVisible()
await expect(page.getByTestId('cover-preview')).toHaveCount(0)
await expect(page.getByTestId('cover-input')).toBeAttached()
await expect(page.getByTestId('chapter-thumb-3')).toHaveCount(0)
await page.getByTestId('edit-chapter-3').click()
await expect(page.getByTestId('illustration-state')).toContainText('未设置')
await expect(page.getByTestId('chapter-audio-state')).toContainText('未设置')
await page.goto('/chapters/3')
await expect(page.locator('.reader-text')).toBeVisible()
await expect(page.getByTestId('audio-player')).toHaveCount(0)
await expect(page.getByTestId('chapter-illustration')).toHaveCount(0)
})
+125
View File
@@ -0,0 +1,125 @@
import { expect, test, type Page } from '@playwright/test'
import { expectMessageBoxOverlay, expectModalOverlay } from './overlay'
// Renaming, editing and deleting through the real dialogs of the accepted prototype, against
// a mocked API.
test('rename the book, edit a chapter into a new version and delete both', async ({ page }) => {
const user = { id: 42, username: 'fictional-editor', role: 'learner' }
const timestamps = { createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' }
const first = { id: 9, bookId: 1, ordinal: 1, title: 'First chapter', status: 'ready', charCount: 24, errorReason: '', errorMessage: '', jobId: 5, ...timestamps }
const second = { id: 10, bookId: 1, ordinal: 2, title: 'Second chapter', status: 'ready', charCount: 12, errorReason: '', errorMessage: '', jobId: 6, ...timestamps }
let bookTitle = 'A small step'
let chapters = [first, second]
let firstText = 'Mira opened the workshop.\n'
let processing = false
const requests: string[] = []
const listBook = () => ({
book: { id: 1, title: bookTitle, language: 'en' },
chapters: chapters.map(item => (item.id === 9 && processing ? { ...item, status: 'processing' } : item)),
})
await page.route('**/api/v1/**', async route => {
const path = new URL(route.request().url()).pathname
const method = route.request().method()
requests.push(`${method} ${path}`)
let data: unknown = null
let status = 200
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' && method === 'GET') {
data = { items: bookTitle === '' ? [] : [{ id: 1, title: bookTitle, language: 'en', chapterCount: chapters.length, pendingCount: 0, processingCount: 0, readyCount: chapters.length, failedCount: 0, ...timestamps }] }
} else if (path === '/api/v1/books/1' && method === 'PATCH') {
bookTitle = (route.request().postDataJSON() as { title: string }).title
data = { book: { id: 1, title: bookTitle, language: 'en' } }
} else if (path === '/api/v1/books/1' && method === 'DELETE') {
chapters = []
bookTitle = ''
data = { deleted: { bookId: 1, chapters: 2, remaining: 0 } }
} else if (path === '/api/v1/books/1') data = listBook()
else if (path === '/api/v1/chapters/9/source') data = { source: { id: 9, bookId: 1, ordinal: 1, title: first.title, text: firstText, status: 'ready', contentSha256: 'sha-a', charCount: [...firstText].length } }
else if (path === '/api/v1/chapters/9' && method === 'PATCH') {
const body = route.request().postDataJSON() as { title?: string; text?: string }
const changed = body.text !== undefined && body.text !== firstText
if (body.title !== undefined) first.title = body.title
if (body.author !== undefined) first.author = body.author
if (changed) {
firstText = body.text as string
processing = true
first.status = 'pending'
setTimeout(() => { processing = false; first.status = 'ready' }, 400)
}
status = 200
data = { chapter: first, job: changed ? { id: 7, bookId: 1, chapterId: 9, status: 'pending', attempts: 0, errorReason: '', errorMessage: '', ...timestamps } : null, versionChanged: changed }
} else if (path === '/api/v1/chapters/9' && method === 'DELETE') {
chapters = chapters.filter(item => item.id !== 9).map((item, index) => ({ ...item, ordinal: index + 1 }))
data = { deleted: { chapterId: 9, bookId: 1, remaining: chapters.length } }
}
await route.fulfill({ status, json: { code: 200, data } })
})
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 page.getByRole('link', { name: 'A small step' }).click()
await expect(page.getByRole('heading', { name: 'A small step' })).toBeVisible()
// The subtitle no longer claims a system cover: covers are attachments now (#21).
await expect(page.getByText('语言 英语')).toBeVisible()
await expect(page.getByTestId('attachments')).toBeVisible()
// Rename the book through the dialog.
await page.getByTestId('edit-book').click()
await expect(page.getByRole('dialog', { name: '编辑书籍' })).toBeVisible()
await expectModalOverlay(page, page.getByTestId('book-dialog'))
await expect(page.getByLabel('书名', { exact: true })).toHaveValue('A small step')
await page.getByLabel('书名', { exact: true }).fill('A long step')
await page.getByTestId('save-book').click()
await expect(page.getByTestId('book-notice')).toContainText('书籍信息已更新')
await expect(page.getByRole('heading', { name: 'A long step' })).toBeVisible()
// Edit the chapter text: the new version re-processes and later becomes ready again.
// The chapter editor is the main writing surface, so on a wide window it must use that width and
// a tall body instead of a fixed 620px box.
await page.setViewportSize({ width: 1280, height: 900 })
await page.getByTestId('edit-chapter-9').click()
await expectModalOverlay(page, page.getByTestId('chapter-dialog'))
await expect(page.getByLabel('章节标题', { exact: true })).toHaveValue('First chapter')
await expect(page.getByLabel('正文', { exact: true })).toHaveValue(firstText)
const dialog = await page.getByTestId('chapter-dialog').boundingBox()
expect(dialog!.width, 'the chapter editor follows the window').toBeGreaterThan(900)
const body = await page.getByLabel('正文', { exact: true }).boundingBox()
expect(body!.height, 'the text area is tall enough to write in').toBeGreaterThan(400)
const noOverflow = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, inner: window.innerWidth }))
expect(noOverflow.scroll, 'the wide editor must not overflow').toBeLessThanOrEqual(noOverflow.inner)
await page.setViewportSize({ width: 1280, height: 900 })
await page.getByLabel('正文', { exact: true }).fill('A replacement body.\n')
await page.getByTestId('save-chapter').click()
await expect(page.getByTestId('book-notice')).toContainText('已保存为新版本,正在重新处理')
// The saved version is queued first and becomes readable again when the worker finishes.
await expect(page.locator('.chapter-row').first()).toContainText('待处理')
await expect(page.locator('.chapter-row').first()).toContainText('已就绪', { timeout: 10000 })
// Deleting a chapter asks first, then reports the remaining count.
await page.getByTestId('edit-chapter-9').click()
await page.getByTestId('delete-chapter').click()
await expect(page.locator('.el-message-box__message').last()).toContainText('本章正文将被删除,已保存词条保留')
await page.locator('.el-message-box').last().getByRole('button', { name: '取消' }).click()
expect(requests.filter(entry => entry === 'DELETE /api/v1/chapters/9')).toHaveLength(0)
await page.getByTestId('delete-chapter').click()
// A confirmation is a modal overlay too, not text appended to the page.
await expectMessageBoxOverlay(page)
await page.locator('.el-message-box').last().getByRole('button', { name: '确认删除章节' }).click()
await expect(page.getByTestId('book-notice')).toContainText('章节已删除 · 剩余 1 章')
await expect(page.locator('.chapter-row')).toHaveCount(1)
// Deleting the book asks first and returns to the library without it.
await page.getByTestId('delete-book').click()
await expect(page.locator('.el-message-box__message').last()).toContainText('已保存的生词和短语将保留')
await page.locator('.el-message-box').last().getByRole('button', { name: '确认删除' }).click()
await expect(page).toHaveURL(/\?deleted=\d+$/)
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
await expect(page.getByTestId('library-notice')).toContainText('书籍已删除 · 已保存的生词和短语仍保留在生词本')
await expect(page.getByRole('link', { name: 'A long step' })).toHaveCount(0)
})
+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')
})
+77
View File
@@ -0,0 +1,77 @@
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 reader where they were: the tapped word stays on screen. The page
// releases the space it reserved for the sheet, so the browser may clamp the scroll by a little
// when the chapter end is already in view; what must not happen is losing sight of the word.
const word = page.locator('.reader-word').nth(1)
await page.getByRole('button', { name: '关闭释义' }).tap()
await expect(panel).toHaveCount(0)
await expect(word).toBeInViewport()
})
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')
})
+119
View File
@@ -0,0 +1,119 @@
// 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' }
// The phone audit also covers the attachment surfaces: a book cover plus a chapter illustration
// and audio, which is where those files live since #37.
const pngBytes = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==",
"base64",
)
const attachments = {
cover: { kind: 'cover', mime: 'image/png', byteSize: pngBytes.length, version: 'cover-sha', updatedAt: '2026-09-15T00:00:00Z' },
}
const chapterWithFiles = { ...chapter, illustrationVersion: 'illustration-sha', audioVersion: 'audio-sha', playbackSeconds: 65 }
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, coverVersion: attachments.cover.version, hasAudio: true }] }
else if (path === '/api/v1/books/7/cover' || path === '/api/v1/chapters/3/illustration' || path === '/api/v1/chapters/3/audio') {
const body = path.endsWith('illustration') || path.endsWith('cover')
? pngBytes
: Buffer.concat([Buffer.from('ID3'), Buffer.alloc(4096)])
const type = path.endsWith('illustration') || path.endsWith('cover') ? 'image/png' : 'audio/mpeg'
await route.fulfill({ status: 200, headers: { 'Content-Type': type }, body })
return
} else if (path === '/api/v1/chapters/3/playback') data = { playbackSeconds: 65 }
else if (path === '/api/v1/books/7') data = { book: { ...book, attachments }, chapters: [chapterWithFiles] }
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: { ...book, attachments }, chapter: { ...chapterWithFiles, 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' })
})
+42
View File
@@ -0,0 +1,42 @@
import { expect, type Locator, type Page } from '@playwright/test'
/**
* Asserts that a dialog is a modal overlay rather than a block in the document flow.
*
* A missing Element Plus stylesheet is invisible to `toBeVisible()` and to the unit tests, but it
* turns every dialog into content appended after the list it was opened from — which is exactly how
* a reader reported it. These checks need a real browser with the real stylesheets.
*/
export async function expectModalOverlay(page: Page, dialog: Locator): Promise<void> {
await expect(dialog).toBeVisible()
const layout = await dialog.evaluate(element => {
const box = element.getBoundingClientRect()
const overlay = element.closest('.el-overlay')
return {
overlay: overlay ? getComputedStyle(overlay).position : 'none',
position: getComputedStyle(element).position,
top: Math.round(box.top),
left: Math.round(box.left),
width: Math.round(box.width),
viewportHeight: window.innerHeight,
viewportWidth: window.innerWidth,
}
})
expect(layout.overlay, 'the dialog must render inside a fixed overlay').toBe('fixed')
expect(layout.position, 'the dialog itself is positioned by the overlay').not.toBe('static')
// It is laid out over the viewport, not below the page content that opened it.
expect(layout.top, 'the dialog must start inside the viewport').toBeGreaterThanOrEqual(0)
expect(layout.top, 'the dialog must not be pushed below the viewport').toBeLessThan(layout.viewportHeight)
expect(layout.viewportWidth - (layout.left + layout.width), 'the dialog must fit horizontally').toBeGreaterThanOrEqual(-2)
}
/** Asserts that a message box is a modal overlay as well. */
export async function expectMessageBoxOverlay(page: Page): Promise<void> {
const box = page.locator('.el-message-box')
await expect(box).toBeVisible()
const overlay = await box.evaluate(element => {
const wrapper = element.closest('.el-overlay')
return wrapper ? getComputedStyle(wrapper).position : 'none'
})
expect(overlay, 'the confirmation must render inside a fixed overlay').toBe('fixed')
}
+159
View File
@@ -0,0 +1,159 @@
import { expect, test, type Page } from '@playwright/test'
// Real mouse selection over the rendered chapter, against a mocked API: the interaction #4
// verified is exercised as a continuous drag, not through a preset button.
const original = 'Take a small step\nevery day.\n'
const fragments: [string, 'word' | 'space' | 'punctuation'][] = [
['Take', 'word'], [' ', 'space'], ['a', 'word'], [' ', 'space'], ['small', 'word'], [' ', 'space'],
['step', 'word'], ['\n', 'space'], ['every', 'word'], [' ', 'space'], ['day', 'word'], ['.', 'punctuation'], ['\n', 'space'],
]
let cp = 0
let utf16 = 0
const tokens = fragments.map(([text, kind]) => {
const token = { text, kind, start: cp, end: cp + [...text].length, startUtf16: utf16, endUtf16: utf16 + text.length }
cp = token.end
utf16 = token.endUtf16
return token
})
const book = { id: 1, title: 'Phrase chapter', language: 'en' }
const timestamps = { createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' }
const chapter = { id: 55, bookId: 1, ordinal: 1, title: 'Phrase chapter', status: 'ready', charCount: [...original].length, errorReason: '', errorMessage: '', jobId: 5, contentSha256: 'fictional-sha', originalText: original, ...timestamps }
async function openChapter(page: Page, options: { phrases?: unknown[]; onPhrase?: (body: Record<string, unknown>) => void } = {}) {
const user = { id: 42, username: 'fictional-phrase', role: 'learner' }
await page.addInitScript(() => sessionStorage.setItem('lexgo-learner-token', 'fictional-session'))
await page.route('**/api/v1/**', async route => {
const path = new URL(route.request().url()).pathname
const method = route.request().method()
let data: unknown = null
let status = 200
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/chapters/55/tokens') data = { textSha256: 'fictional-sha', tokens, phrases: options.phrases ?? [] }
else if (path === '/api/v1/chapters/55') data = { book, chapter, navigation: { previousChapterId: null, nextChapterId: null } }
else if (path === '/api/v1/lookup') data = { status: 'not_found', query: 'Take', matchedForm: null, candidates: [], entries: [] }
else if (path === '/api/v1/phrases' && method === 'POST') {
options.onPhrase?.(route.request().postDataJSON() as Record<string, unknown>)
status = 201
data = {
term: { id: 9, term: 'take a small', originalForm: 'Take a small', definition: '拿一小步', examples: [], status: 'new', level: 0, kind: 'phrase', wordCount: 3 },
created: true,
}
} else if (path === '/api/v1/terms/9') data = { term: { id: 9, term: 'take a small', originalForm: 'Take a small', definition: '拿一小步', examples: ['Take a small step.'], status: 'new', level: 0, kind: 'phrase', wordCount: 3 } }
await route.fulfill({ status, json: { code: 200, data } })
})
await page.goto('/chapters/55')
await expect(page.locator('.reader-text')).toBeVisible()
}
/**
* Drags from the centre of one word to the centre of another, like a person selecting text.
*
* A synthetic drag sometimes stops one word short or collapses entirely in Chromium, especially
* while other tests load the same dev server, so the drag is repeated until the highlighted run
* has the expected number of words. The retry condition is the property the test is about, not
* just "a panel appeared": a real regression in selection handling still fails all attempts.
*/
async function dragWords(page: Page, from: string, to: string, words: number) {
const word = page.locator('.reader-word')
const start = word.filter({ hasText: new RegExp(`^${from}$`) }).first()
const end = word.filter({ hasText: new RegExp(`^${to}$`) }).first()
const range = page.getByTestId('phrase-range')
for (let attempt = 1; attempt <= 4; attempt += 1) {
// The boxes are read again on every attempt: an open panel reflows the page, so a cached
// coordinate would drag to the wrong place and keep the retry failing for the same reason.
const startBox = (await start.boundingBox())!
const endBox = (await end.boundingBox())!
const startX = startBox.x + startBox.width / 2
const startY = startBox.y + startBox.height / 2
await page.mouse.move(startX, startY)
await page.mouse.down()
// A couple of pixels first: the browser only starts extending the selection once the pointer
// has actually moved, and a single jump can be treated as a click.
await page.mouse.move(startX + 3, startY, { steps: 2 })
await page.mouse.move(endBox.x + endBox.width / 2, endBox.y + endBox.height / 2, { steps: 12 })
// Under load the browser may not have extended the selection by the time the button is
// released, which is what produces a run that stops one word short.
await page.waitForTimeout(80)
await page.mouse.up()
try {
await expect(range).toContainText(`短语 · ${words} 个单词`, { timeout: 2500 })
return
} catch (reason) {
if (attempt === 4) throw reason
// Close whatever panel the partial selection opened, so the next attempt starts clean.
await page.keyboard.press('Escape')
await expect(page.locator('.lookup-panel')).toHaveCount(0)
}
}
}
test('drag a continuous phrase, save it and highlight it', async ({ page }) => {
let body: Record<string, unknown> | undefined
await openChapter(page, { onPhrase: value => { body = value } })
await dragWords(page, 'Take', 'small', 3)
const range = page.getByTestId('phrase-range')
await expect(range).toContainText('短语 · 3 个单词')
// A phrase that is not stored yet is labelled as a new entry.
await expect(page.locator('.lookup-panel')).toContainText('新词条')
await page.getByLabel(/^我的释义/).fill('拿一小步')
await page.screenshot({ path: '../.local/evidence/issue11-phrase-selected.png' })
await page.getByTestId('term-save').click()
await expect(page.getByText('已保存 · 新词')).toBeVisible()
// The code point range covers the whole run including its interior separator.
expect(body).toMatchObject({ chapterId: 55, start: 0, end: 12, definition: '拿一小步', status: 'new' })
// The saved phrase is underlined in the text.
await expect(page.locator('.reader-word.is-phrase')).toHaveCount(3)
})
test('adjust the endpoints by whole words from the panel', async ({ page }) => {
await openChapter(page)
await dragWords(page, 'Take', 'small', 3)
await expect(page.getByTestId('phrase-range')).toContainText('3 个单词')
await page.getByTestId('range-end-right').click()
await expect(page.getByTestId('phrase-range')).toContainText('4 个单词')
await page.getByTestId('range-start-right').click()
await expect(page.getByTestId('phrase-range')).toContainText('3 个单词')
await page.getByTestId('range-start-left').click()
await expect(page.getByTestId('phrase-range')).toContainText('4 个单词')
})
test('open a saved phrase from its highlight', async ({ page }) => {
await openChapter(page, { phrases: [{ id: 9, status: 'new', wordCount: 3, startToken: 0, endToken: 4 }] })
await expect(page.locator('.reader-word.is-phrase')).toHaveCount(3)
await page.locator('.reader-word.is-phrase').first().click()
await expect(page.getByTestId('phrase-range')).toContainText('已保存')
await expect(page.getByLabel(/^我的释义/)).toHaveValue('拿一小步')
await page.screenshot({ path: '../.local/evidence/issue11-phrase-saved.png' })
})
test('a phrase review card masks the whole run as one blank', async ({ page }) => {
const user = { id: 42, username: 'fictional-phrase', role: 'learner' }
await page.addInitScript(() => sessionStorage.setItem('lexgo-learner-token', 'fictional-session'))
await page.route('**/api/v1/**', async route => {
const path = new URL(route.request().url()).pathname
let data: unknown = null
if (path === '/api/v1/me') data = user
else if (path === '/api/v1/space') data = { ownerId: user.id, language: 'en' }
else if (path === '/api/v1/reviews/queue') {
data = {
items: [{
id: 9, term: 'a small step', originalForm: 'a small step', definition: '一小步',
examples: ['Take a small step, every day.'], status: 'new', level: 0, kind: 'phrase', wordCount: 3,
dueAt: '2026-01-01T00:00:00Z', reviewCount: 0,
}],
total: 1,
}
}
await route.fulfill({ json: { code: 200, data } })
})
await page.goto('/review')
await expect(page.getByTestId('review-position')).toContainText('到期复习 · 1 / 1')
await expect(page.getByText('Take _____, every day.')).toBeVisible()
await expect(page.getByText('短语 · 3 个单词')).toBeVisible()
await page.getByTestId('review-reveal').click()
await expect(page.getByTestId('review-definition')).toHaveText('一小步')
await page.screenshot({ path: '../.local/evidence/issue11-phrase-review.png' })
})
+104
View File
@@ -0,0 +1,104 @@
import { expect, test, type Page } from '@playwright/test'
// Completing a chapter and reading the progress page, against a mocked API.
test('marking a chapter read shows up in the book and on the progress page once', async ({ page }) => {
const user = { id: 42, username: 'fictional-progress', role: 'learner' }
const chapter = {
id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', status: 'ready', charCount: 31,
errorReason: '', errorMessage: '', jobId: 9, readAt: null as string | null, createdAt: '', updatedAt: '',
}
let completions = 0
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: [{ id: 7, title: 'Fictional reader', language: 'en', chapterCount: 1, pendingCount: 0, processingCount: 0, readyCount: 1, failedCount: 0, createdAt: '', updatedAt: '' }] }
else if (path === '/api/v1/books/7') data = { book: { id: 7, title: 'Fictional reader', language: 'en' }, chapters: [chapter] }
else if (path === '/api/v1/chapters/3/tokens') data = { tokens: [{ start: 0, end: 9, text: 'Curiosity', kind: 'word' }] }
else if (path === '/api/v1/chapters/3' && method === 'GET') {
data = { book: { id: 7, title: 'Fictional reader', language: 'en' }, chapter: { ...chapter, contentSha256: 'sha', originalText: 'Curiosity opens the first door.\n' }, navigation: { previousChapterId: null, nextChapterId: null } }
} else if (path === '/api/v1/chapters/3/complete') {
completions += 1
const readAt = '2026-09-15T03:04:05Z'
chapter.readAt = readAt
data = { progress: { chapterId: 3, bookId: 7, read: true, readAt, duplicate: completions > 1 } }
} else if (path === '/api/v1/progress') {
data = {
readChapters: chapter.readAt ? 1 : 0, totalChapters: 1,
knownTerms: 2, learningTerms: 1, newTerms: 3, ignoredTerms: 1, savedTerms: 7,
dueNow: chapter.readAt ? 1 : 4,
books: [{ id: 7, title: 'Fictional reader', readChapters: chapter.readAt ? 1 : 0, totalChapters: 1 }],
}
} else if (path === '/api/v1/reviews/queue') data = { items: [], total: 0 }
await route.fulfill({ json: { code: 200, data } })
})
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()
// The reader offers the explicit completion action and says what it does and does not do.
await page.goto('/chapters/3')
await expect(page.getByRole('heading', { name: 'Fictional chapter' })).toBeVisible()
await expect(page.getByTestId('mark-read')).toHaveText('标记本章已读')
await expect(page.locator('.chapter-complete')).toContainText('只记录已读,不改变词语状态')
await page.screenshot({ path: '../.local/evidence/issue13-reader-unread.png' })
// Completing shows the reading time, and repeating is answered without counting twice.
await page.getByTestId('mark-read').click()
await expect(page.getByTestId('chapter-read-state')).toContainText('本章已读')
await expect(page.getByTestId('complete-notice')).toContainText('已记为读完本章')
await page.screenshot({ path: '../.local/evidence/issue13-reader-read.png' })
await page.getByTestId('mark-read').click()
await expect(page.getByTestId('complete-notice')).toContainText('没有重复计数')
// The book list shows which chapters are read.
await page.goto('/books/7')
await expect(page.getByTestId('chapter-read-chip').first()).toHaveText('已读')
// The progress page reports the reading total and keeps the four word statuses apart.
await page.getByRole('link', { name: '进度' }).click()
await expect(page).toHaveURL(/\/progress/)
await expect(page.getByTestId('stat-read')).toContainText('1 / 1')
await expect(page.getByTestId('stat-known')).toContainText('2')
await expect(page.getByTestId('stat-ignored')).toContainText('1')
await expect(page.getByTestId('stat-due')).toContainText('1')
await expect(page.getByTestId('progress-books')).toContainText('Fictional reader')
await page.screenshot({ path: '../.local/evidence/issue13-progress.png' })
// Reloading keeps the same numbers: they come from the server, not from the page.
await page.reload()
await expect(page.getByTestId('stat-read')).toContainText('1 / 1')
expect(completions).toBe(2)
})
test('a fresh space explains the empty progress instead of showing zeros alone', async ({ page }) => {
const user = { id: 42, username: 'fictional-progress', role: 'learner' }
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: [] }
else if (path === '/api/v1/progress') {
data = { readChapters: 0, totalChapters: 0, knownTerms: 0, learningTerms: 0, newTerms: 0, ignoredTerms: 0, savedTerms: 0, dueNow: 0, books: [] }
}
await route.fulfill({ json: { code: 200, data } })
})
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 page.goto('/progress')
await expect(page.getByTestId('stat-read')).toContainText('0 / 0')
await expect(page.getByTestId('progress-no-books')).toContainText('还没有书')
await expect(page.getByTestId('stat-ignored')).toContainText('0')
})
+71 -9
View File
@@ -9,6 +9,8 @@ test('paste English text, watch a chapter finish processing, then read it verbat
const timestamps = { createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' }
// The worker reports the fresh chapter as processing until the worker settles it.
let status: 'processing' | 'ready' = 'processing'
// The personal record the learner saves during this run, served back on reload.
let savedTerm: { id: number; term: string; originalForm: string; definition: string; examples: string[]; status: string; level: number } | null = null
const chapterPayload = () => ({
id: 55,
bookId: book.id,
@@ -59,10 +61,30 @@ test('paste English text, watch a chapter finish processing, then read it verbat
offset += text.length
return { text, start, end: offset, startUtf16: start, endUtf16: offset, kind: /^[A-Za-z]+$/.test(text) ? 'word' : /^\s+$/.test(text) ? 'space' : 'punctuation' }
})
data = { textSha256: 'fictional-sha256', tokens }
} else if (path === '/api/v1/lookup') {
data = {
textSha256: 'fictional-sha256',
tokens: tokens.map(token => savedTerm && token.text === 'First'
? { ...token, term: { id: savedTerm.id, status: savedTerm.status, level: savedTerm.level } }
: token),
}
} else if (path === '/api/v1/terms' && method === 'POST') {
const body = route.request().postDataJSON() as { chapterId: number; start: number; end: number; definition: string; examples: string[]; status: string }
expect(body).toEqual({ chapterId: 55, start: 0, end: 5, definition: '虚构的个人释义', examples: ['A fictional example.'], status: 'new' })
savedTerm = { id: 9, term: 'first', originalForm: 'First', definition: body.definition, examples: body.examples, status: body.status, level: 0 }
statusCode = 201
data = { term: savedTerm, created: true }
} else if (path === '/api/v1/terms/9') data = { term: savedTerm }
else if (path === '/api/v1/lookup') {
expect(route.request().postDataJSON()).toEqual({ chapterId: 55, start: 0, end: 5 })
data = { status: 'exact', query: 'first', matchedForm: 'first', candidates: [], entries: [{ lemma: 'first', pos: 'adjective', definition: 'Coming before all others.', examples: ['The first fictional chapter.'] }] }
data = {
status: 'exact', query: 'first', matchedForm: 'first', candidates: [], phonetic: 'fɜːst', phoneticSource: 'cmudict',
resources: [{ name: '简明英汉字典增强版(ECDICT)+ CMUdict 音标', version: 'ecdict-1.0.28+cmudict-0.7b' }, { name: 'Princeton WordNet', version: '3.0' }],
resource: { name: 'Princeton WordNet', version: '3.0' },
entries: [
{ lemma: 'first', pos: '', definition: '', examples: [], translation: 'a. 第一的, 最早的\nn. 开始, 第一', provider: 'ecdict' },
{ lemma: 'first', pos: 'adjective', definition: 'Coming before all others.', examples: ['The first fictional chapter.'], provider: 'wordnet' },
],
}
}
await route.fulfill({ status: statusCode, json: { code: 200, data } })
})
@@ -100,25 +122,65 @@ test('paste English text, watch a chapter finish processing, then read it verbat
expect(await readerText.evaluate(element => getComputedStyle(element).whiteSpace)).toBe('pre-wrap')
await expect(page.getByRole('button', { name: '上一章' })).toBeDisabled()
await expect(page.getByRole('button', { name: '下一章' })).toBeDisabled()
const word = page.getByRole('button', { name: '查询 First', exact: true })
const word = page.locator('.reader-word').first()
await expect(word).toHaveAttribute('aria-label', '查询 First')
await word.focus()
await word.press('Enter')
await expect(page.getByText('Coming before all others.')).toBeVisible()
// The merged dictionary answers with the IPA phonetic and the Chinese gloss first, then the
// English detail from WordNet; both directories are named in the footnote.
await expect(page.getByTestId('lookup-phonetic')).toHaveText('/fɜːst/')
await expect(page.getByTestId('lookup-translation')).toContainText('第一的')
const senses = page.locator('.lookup-senses li')
await expect(senses.nth(0)).toHaveAttribute('data-provider', 'ecdict')
await expect(senses.nth(1)).toHaveAttribute('data-provider', 'wordnet')
await expect(page.locator('.lookup-panel')).toContainText('Princeton WordNet')
expect(await readerText.evaluate(element => element.textContent)).toBe(pasted)
await page.getByLabel('个人释义 未保存').fill('虚构的本地草稿')
await page.getByRole('button', { name: '关闭释义' }).press('Escape')
await expect(page.locator('.lookup-panel')).toHaveCount(0)
await expect(word).toBeFocused()
// The reading and editing surfaces must grow with the window instead of staying at a fixed
// 820px column, and no intermediate width may overflow sideways (the blind spot before this).
for (const width of [768, 1024, 1280]) {
await page.setViewportSize({ width, height: 900 })
await page.waitForTimeout(60)
const overflow = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, inner: window.innerWidth }))
expect(overflow.scroll, `reader must not overflow at ${width}px`).toBeLessThanOrEqual(overflow.inner)
}
await page.setViewportSize({ width: 1280, height: 900 })
await page.waitForTimeout(60)
const wideBody = await page.locator('.reader-body').boundingBox()
expect(wideBody!.width, 'the text column follows the window').toBeGreaterThan(820)
// Browser narrow viewport check only; this is not real-device acceptance.
await page.setViewportSize({ width: 390, height: 844 })
await word.click()
await expect(page.getByText('Coming before all others.')).toBeVisible()
await expect(page.getByTestId('lookup-phonetic')).toHaveText('/fɜːst/')
const panelBounds = await page.locator('.lookup-panel').boundingBox()
expect(panelBounds!.y + panelBounds!.height).toBeLessThanOrEqual(845)
expect(panelBounds!.height).toBeLessThanOrEqual(844 * 0.46)
expect(await page.locator('.lookup-panel').evaluate(element => getComputedStyle(element).position)).toBe('fixed')
const wordBounds = await word.boundingBox()
expect(wordBounds!.y + wordBounds!.height).toBeLessThanOrEqual(panelBounds!.y)
expect(await page.getByLabel('个人释义 未保存').inputValue()).toBe('')
expect(await page.getByLabel('我的释义 新词条').inputValue()).toBe('')
// Save a personal record: definition, example and the default status.
await page.setViewportSize({ width: 1280, height: 900 })
await page.getByLabel('我的释义 新词条').fill('虚构的个人释义')
await page.getByLabel('例句 每行一条,最多 5 条').fill('A fictional example.')
await page.getByRole('button', { name: '保存到生词本' }).click()
await expect(page.getByText('已保存 · 新词')).toBeVisible()
await expect(word).toHaveClass(/is-new/)
expect(await readerText.evaluate(element => element.textContent)).toBe(pasted)
await page.getByRole('button', { name: '关闭释义' }).press('Escape')
await expect(page.locator('.lookup-panel')).toHaveCount(0)
await expect(word).toBeFocused()
// Returning to the chapter shows the same state and the stored text.
await page.reload()
const reloadedWord = page.locator('.reader-word').first()
await expect(reloadedWord).toHaveAttribute('aria-label', '查询 First,已保存')
await expect(reloadedWord).toHaveClass(/is-new/)
await reloadedWord.click()
await expect(page.getByLabel('我的释义 已保存')).toHaveValue('虚构的个人释义')
await expect(page.getByLabel('例句 每行一条,最多 5 条')).toHaveValue('A fictional example.')
await expect(page.getByRole('radio', { name: '新词' })).toBeChecked()
})
+120
View File
@@ -0,0 +1,120 @@
import { expect, test } from '@playwright/test'
// The review round against a mocked API: queue, reveal, grades, relearn and the summary.
test('review the due words, requeue a missed one and finish the round', async ({ page }) => {
const user = { id: 42, username: 'fictional-reviewer', role: 'learner' }
const first = {
id: 7, term: 'curiosity', originalForm: 'curiosity', definition: '好奇心;求知欲',
examples: ['Learning begins with curiosity.'], status: 'new', level: 0,
dueAt: '2026-09-11T10:00:00Z', reviewCount: 0,
}
const second = {
id: 8, term: 'step', originalForm: 'step', definition: '一步',
examples: ['Take a small step, every day.'], status: 'learning', level: 2,
dueAt: '2026-09-11T10:00:00Z', reviewCount: 4,
}
// The wrong answer puts its word back in the same round, exactly as the server does.
const submitted: { grade: string; answerId: string }[] = []
let requeued = false
await page.route('**/api/v1/**', async route => {
const path = new URL(route.request().url()).pathname
const method = route.request().method()
let data: unknown = null
let status = 200
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: requeued ? [second] : [first, second], total: requeued ? 1 : 2 }
} else if (path.endsWith('/answers') && method === 'POST') {
const body = route.request().postDataJSON() as { answerId: string; grade: string; expectedDueAt: string }
submitted.push({ grade: body.grade, answerId: body.answerId })
expect(body.answerId).toMatch(/^[0-9a-f-]{36}$/)
const term = path.includes('8') ? second : first
// The client answers the card it was shown, so it echoes that card's due time.
expect(body.expectedDueAt).toBe(term.dueAt)
const wrong = body.grade === 'wrong'
requeued = wrong
const dueAtAfter = wrong ? '2026-09-11T10:05:00Z' : '2026-09-12T10:00:00Z'
term.dueAt = dueAtAfter
status = 201
data = {
result: 'applied', grade: body.grade, requeued: wrong,
statusBefore: term.status, statusAfter: wrong ? term.status : 'learning',
levelBefore: term.level, levelAfter: wrong ? 1 : term.level + 1,
dueAtBefore: body.expectedDueAt, dueAtAfter,
item: { ...term, dueAt: dueAtAfter, reviewCount: term.reviewCount + 1 },
}
}
await route.fulfill({ status, json: { code: 200, data } })
})
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()
// The library links into the due queue.
await page.getByRole('link', { name: '到期复习' }).click()
await expect(page).toHaveURL(/\/review$/)
await expect(page.getByTestId('review-position')).toHaveText('到期复习 · 1 / 2')
await expect(page.getByRole('heading', { name: 'curiosity' })).toBeVisible()
// The answer stays hidden, and the example shows a blank instead of the word.
await expect(page.getByText('Learning begins with _____.')).toBeVisible()
await expect(page.getByTestId('review-definition')).toHaveCount(0)
await page.getByTestId('review-reveal').click()
await expect(page.getByTestId('review-definition')).toHaveText('好奇心;求知欲')
await expect(page.getByTestId('review-correct')).toBeFocused()
await page.getByTestId('review-correct').click()
// The second word is learning level 2 and can be sent back into the round.
await expect(page.getByTestId('review-position')).toHaveText('到期复习 · 2 / 2')
await expect(page.getByText('学习中 · 等级 2')).toBeVisible()
await page.getByTestId('review-reveal').click()
await expect(page.getByTestId('review-definition')).toHaveText('一步')
await page.getByTestId('review-wrong').click()
// Requeued: the same word asks again and the round grows instead of pretending it ended.
await expect(page.getByTestId('review-position')).toHaveText('到期复习 · 3 / 3')
await expect(page.getByTestId('review-definition')).toHaveCount(0)
await page.getByTestId('review-reveal').click()
await page.getByTestId('review-correct').click()
await expect(page.getByTestId('review-summary')).toContainText('复习了 2 个词条 · 共 3 次作答')
await expect(page.getByTestId('review-summary')).toContainText('答对 2 · 答错或再学 1')
expect(submitted.map(entry => entry.grade)).toEqual(['correct', 'wrong', 'correct'])
expect(new Set(submitted.map(entry => entry.answerId)).size).toBe(3)
await page.getByTestId('review-finish').click()
await expect(page).toHaveURL(/\/$/)
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
})
test('an empty due queue says so instead of showing a card', async ({ page }) => {
const user = { id: 42, username: 'fictional-reviewer', role: 'learner' }
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: [] }
else if (path === '/api/v1/reviews/queue') data = { items: [], total: 0 }
await route.fulfill({ json: { code: 200, data } })
})
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 page.goto('/review')
await expect(page.getByTestId('review-empty')).toContainText('今天没有到期词条')
await expect(page.getByTestId('review-card')).toHaveCount(0)
// The narrow layout keeps the grading controls reachable without horizontal overflow.
await page.setViewportSize({ width: 390, height: 844 })
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(overflow).toBeLessThanOrEqual(0)
})
+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)
})
+76
View File
@@ -0,0 +1,76 @@
import { expect, test } from '@playwright/test'
// The TXT upload path against a mocked API: pre-checks, multipart body and the hand-off to
// the same processing screen the paste path uses.
test('upload a UTF-8 TXT file and open the created book', async ({ page }) => {
const user = { id: 42, username: 'fictional-uploader', role: 'learner' }
const book = { id: 1, title: 'Studio Notes', language: 'en' }
const timestamps = { createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' }
const pasted = 'Mira opened the workshop.\r\n\r\n\tThe sign read “A small step…”\n'
let uploaded = false
await page.route('**/api/v1/**', async route => {
const path = new URL(route.request().url()).pathname
const method = route.request().method()
let data: unknown = null
let status = 200
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' && method === 'GET') data = { items: uploaded ? [{ ...book, chapterCount: 1, pendingCount: 0, processingCount: 0, readyCount: 1, failedCount: 0, ...timestamps }] : [] }
else if (path === '/api/v1/books/upload') {
// The upload must arrive as multipart: the fields and the file are inspected directly.
expect(route.request().headers()['content-type']).toContain('multipart/form-data')
const raw = route.request().postData() ?? ''
expect(raw).toContain('name="requestId"')
expect(raw).toContain('name="language"')
expect(raw).toContain('name="title"')
expect(raw).toContain('Studio Notes')
expect(raw).toContain('filename="notes.txt"')
expect(raw).toContain('Mira opened the workshop.')
uploaded = true
status = 201
data = {
book,
chapter: { id: 9, bookId: 1, ordinal: 1, title: 'Studio Notes', status: 'pending', charCount: [...pasted].length, errorReason: '', errorMessage: '', jobId: 5, ...timestamps },
job: { id: 5, bookId: 1, chapterId: 9, status: 'pending', attempts: 0, errorReason: '', errorMessage: '', ...timestamps },
duplicate: false,
}
} else if (path === '/api/v1/books/1') data = { book, chapters: [{ id: 9, bookId: 1, ordinal: 1, title: 'Studio Notes', status: 'ready', charCount: [...pasted].length, errorReason: '', errorMessage: '', jobId: 5, ...timestamps }] }
else if (path === '/api/v1/chapters/9') data = { book, chapter: { id: 9, bookId: 1, ordinal: 1, title: 'Studio Notes', status: 'ready', charCount: [...pasted].length, errorReason: '', errorMessage: '', jobId: 5, contentSha256: 'fictional-sha', originalText: pasted, ...timestamps }, navigation: { previousChapterId: null, nextChapterId: null } }
await route.fulfill({ status, json: { code: 200, data } })
})
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()
await page.getByRole('button', { name: '导入内容' }).click()
// Element Plus hides the native radio behind a styled span, so the label is what a person
// clicks; the input still carries the checked state.
await page.locator('label.el-radio', { hasText: 'TXT 文件' }).click()
await expect(page.getByRole('radio', { name: 'TXT 文件' })).toBeChecked()
await expect(page.locator('textarea#text')).toHaveCount(0)
await expect(page.getByText('仅支持 UTF-8')).toBeVisible()
// An unusable file is refused in the browser, before any request is made.
await page.setInputFiles('[data-testid="file-input"]', { name: 'notes.md', mimeType: 'text/markdown', buffer: Buffer.from('# heading\n') })
await expect(page.getByText('请选择 .txt 文件。')).toBeVisible()
// A UTF-8 file is accepted and its metadata is shown; the title comes from the file name.
await page.setInputFiles('[data-testid="file-input"]', { name: 'notes.txt', mimeType: 'text/plain', buffer: Buffer.from(pasted, 'utf8') })
await expect(page.getByTestId('file-info')).toContainText('notes.txt · UTF-8')
await expect(page.getByLabel('标题')).toHaveValue('notes')
await page.getByLabel('标题').fill('Studio Notes')
await page.getByRole('button', { name: '上传并处理' }).click()
await expect(page).toHaveURL(/\/books\/1$/)
await expect(page.getByText('已就绪')).toBeVisible()
await page.getByRole('link', { name: 'Studio Notes' }).click()
const readerText = page.locator('.reader-text')
await expect(readerText).toBeVisible()
// The uploaded bytes reached the reader unchanged.
expect(await readerText.evaluate(element => element.textContent)).toBe(pasted)
})
+117
View File
@@ -0,0 +1,117 @@
import { expect, test, type Page } from '@playwright/test'
// The vocabulary list against a mocked API: search, filter, edit and the review entry.
test('search, filter and edit the saved words and phrases', async ({ page }) => {
const user = { id: 42, username: 'fictional-vocab', role: 'learner' }
const entries = [
{ id: 1, term: 'curiosity', originalForm: 'curiosity', definition: '好奇心;求知欲', examples: ['Learning begins with curiosity.'], status: 'new', level: 0, kind: 'word', wordCount: 1, updatedAt: '2026-01-02T00:00:00Z' },
{ id: 2, term: 'a small step', originalForm: 'a small step', definition: '一小步', examples: ['Take a small step.'], status: 'learning', level: 3, kind: 'phrase', wordCount: 3, updatedAt: '2026-01-03T00:00:00Z' },
]
let list = [...entries]
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/terms' && method === 'GET') {
const query = (url.searchParams.get('query') ?? '').toLowerCase()
const status = url.searchParams.get('status') ?? ''
const kind = url.searchParams.get('kind') ?? ''
const page = Number(url.searchParams.get('page') ?? 1)
const limit = Number(url.searchParams.get('limit') ?? 20)
const filtered = list.filter(item =>
(!query || item.term.includes(query) || item.originalForm.toLowerCase().includes(query) || item.definition.includes(query))
&& (!status || item.status === status) && (!kind || item.kind === kind))
// The server orders by most recently updated first; the mock must not invent another order.
const ordered = [...filtered].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || b.id - a.id)
data = { items: ordered.slice((page - 1) * limit, page * limit), total: ordered.length, page, limit }
} else if (path.startsWith('/api/v1/terms/') && method === 'PATCH') {
const body = route.request().postDataJSON() as Partial<typeof entries[number]>
const id = Number(path.split('/').pop())
list = list.map(item => (item.id === id ? { ...item, ...body } : item))
data = { term: list.find(item => item.id === id) }
}
await route.fulfill({ json: { code: 200, data } })
})
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()
// The vocabulary list is reachable from the navigation and lists both kinds.
await page.getByRole('link', { name: '生词本' }).click()
await expect(page).toHaveURL(/\/vocab/)
await expect(page.locator('.vocab-row')).toHaveCount(2)
await expect(page.locator('.vocab-row').first()).toContainText('a small step')
await expect(page.locator('.vocab-row').first()).toContainText('短语 · 学习中 · 等级 3')
await expect(page.locator('.vocab-row').last()).toContainText('curiosity')
await expect(page.getByText('保存的词语与短语,共 2 条')).toBeVisible()
await page.screenshot({ path: '../.local/evidence/issue12-vocab-list.png' })
// Searching keeps the filters in the URL and narrows the list.
await page.getByLabel('搜索词 / 释义').fill('small')
await page.getByTestId('vocab-search').click()
await expect(page.locator('.vocab-row')).toHaveCount(1)
await expect(page).toHaveURL(/query=small/)
await page.getByLabel('搜索词 / 释义').fill('zzzz')
await page.getByTestId('vocab-search').click()
await expect(page.getByTestId('vocab-none')).toContainText('没有匹配的词条')
await page.screenshot({ path: '../.local/evidence/issue12-vocab-none.png' })
await page.getByTestId('vocab-clear').click()
await expect(page.locator('.vocab-row')).toHaveCount(2)
// The status filter applies immediately.
await page.getByTestId('vocab-status').click()
await page.getByRole('option', { name: '新词' }).click()
await expect(page.locator('.vocab-row')).toHaveCount(1)
await expect(page.locator('.vocab-row').first()).toContainText('curiosity')
await expect(page).toHaveURL(/status=new/)
await page.getByTestId('vocab-status').click()
await page.getByRole('option', { name: '全部状态' }).click()
await expect(page.locator('.vocab-row')).toHaveCount(2)
// Editing keeps the filters and offers a level selector for a learning entry.
await page.getByTestId('vocab-edit-2').click()
await expect(page.getByLabel('我的释义')).toHaveValue('一小步')
await expect(page.getByTestId('term-level')).toBeVisible()
await page.getByLabel('我的释义').fill('一小步(改)')
await page.getByTestId('term-level').click()
await page.getByRole('option', { name: '5 级' }).click()
await page.screenshot({ path: '../.local/evidence/issue12-vocab-edit.png' })
await page.getByTestId('vocab-save').click()
await expect(page.getByTestId('vocab-notice')).toContainText('已保存')
await expect(page.locator('.vocab-row').first()).toContainText('一小步(改)')
await expect(page.locator('.vocab-row').first()).toContainText('等级 5')
await expect(page.getByLabel('搜索词 / 释义')).toBeVisible()
// The list links into the due review.
await expect(page.locator('.page-actions').getByRole('link', { name: '开始到期复习' })).toHaveAttribute('href', '/review')
})
test('an empty vocabulary invites reading instead of showing an empty list', async ({ page }) => {
const user = { id: 42, username: 'fictional-vocab', role: 'learner' }
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: [] }
else if (path === '/api/v1/terms') data = { items: [], total: 0, page: 1, limit: 20 }
await route.fulfill({ json: { code: 200, data } })
})
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 page.goto('/vocab')
await expect(page.getByTestId('vocab-empty')).toContainText('生词本还是空的')
await expect(page.locator('.vocab-row')).toHaveCount(0)
})
+14 -1
View File
@@ -8,7 +8,20 @@ export default defineConfig({
forbidOnly: !!process.env.CI,
retries: 0,
reporter: 'list',
// Every spec loads the whole SPA from the shared dev server, so a slow first module graph
// 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 },
})
+307
View File
@@ -0,0 +1,307 @@
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, type Router } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import AudioPlayer from '../components/AudioPlayer.vue'
import BookView from '../views/BookView.vue'
import LibraryView from '../views/LibraryView.vue'
import ReaderView from '../views/ReaderView.vue'
import { useLibraryStore } from '../stores/library'
import { useSessionStore } from '../stores/session'
const user = { id: 42, username: 'fictional-attachment', role: 'learner' as const }
const cover = { kind: 'cover' as const, mime: 'image/png', byteSize: 1234, version: 'cover-sha', updatedAt: '2026-09-15T00:00:00Z' }
const book = { id: 7, title: 'Fictional reader', author: '', language: 'en' }
const pngBytes = new Uint8Array([1, 2, 3, 4])
const baseChapter = {
id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', author: '', status: 'ready' as const, charCount: 31,
errorReason: '', errorMessage: '', jobId: 9, readAt: null, createdAt: '', updatedAt: '',
}
let wrapper: VueWrapper | undefined
/** jsdom has no object URLs, so the two functions are added to the existing URL object. */
function stubObjectUrls(): void {
let counter = 0
;(URL as unknown as Record<string, unknown>).createObjectURL = vi.fn(() => `blob:fictional-${++counter}`)
;(URL as unknown as Record<string, unknown>).revokeObjectURL = vi.fn()
}
function putBackObjectUrls(): void {
delete (URL as unknown as Record<string, unknown>).createObjectURL
delete (URL as unknown as Record<string, unknown>).revokeObjectURL
}
function stubMedia(): { play: MockInstance; pause: MockInstance } {
const play = vi.spyOn(HTMLMediaElement.prototype, 'play').mockImplementation(async () => undefined)
const pause = vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined)
return { play, pause }
}
/** Sets the files of a hidden input the way a file picker would, then fires the change event. */
async function pickFile(view: VueWrapper, testid: string, file: File): Promise<void> {
const input = view.get(`[data-testid="${testid}"]`).element as HTMLInputElement
Object.defineProperty(input, 'files', { value: [file], configurable: true })
await view.get(`[data-testid="${testid}"]`).trigger('change')
await flushPromises()
}
interface Fixture {
chapter: typeof baseChapter & { illustrationVersion?: string; audioVersion?: string; playbackSeconds?: number }
requests: string[]
}
/** An API mock whose chapter carries an illustration, audio and a stored position. */
function mockApi(options: { illustration?: boolean; audio?: boolean; playbackSeconds?: number } = {}): Fixture & { fetchMock: MockInstance } {
const chapter = {
...baseChapter,
illustrationVersion: options.illustration === false ? '' : 'illustration-sha',
audioVersion: options.audio === false ? '' : 'audio-sha',
playbackSeconds: options.playbackSeconds ?? 65,
}
const requests: string[] = []
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const url = String(input)
const method = String(init?.method ?? 'GET')
requests.push(`${method} ${url}`)
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
if (url.endsWith('/me')) return ok(user)
if (url.endsWith('/space')) return ok({ ownerId: user.id, language: 'en' })
if (url.endsWith('/books') && method === 'GET') {
return ok({ items: [{ ...book, chapterCount: 1, pendingCount: 0, processingCount: 0, readyCount: 1, failedCount: 0, coverVersion: 'cover-sha', hasAudio: false, createdAt: '', updatedAt: '' }] })
}
if (url.endsWith('/books/7/cover') || url.endsWith('/chapters/3/audio') || url.endsWith('/chapters/3/illustration')
|| url.endsWith('/chapters/3/playback')) {
if (method === 'GET') {
return new Response(pngBytes, { status: 200, headers: { 'Content-Type': 'application/octet-stream' } })
}
return ok({ attachment: { kind: url.includes('illustration') ? 'illustration' : 'audio', mime: 'application/octet-stream', byteSize: 2048, version: 'new-sha', updatedAt: '' }, deleted: true, playbackSeconds: 12 })
}
if (url.endsWith('/books/7')) return ok({ book: { ...book, attachments: { cover } }, chapters: [chapter] })
if (url.endsWith('/chapters/3/source')) {
return ok({ source: { id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', text: 'Curiosity opens the first door.' + String.fromCharCode(10), status: 'ready', contentSha256: 'sha', charCount: 31 } })
}
if (url.endsWith('/chapters/3') && method === 'PATCH') return ok({ chapter: baseChapter, job: null, versionChanged: false })
if (url.includes('/chapters/3/tokens')) return ok({ textSha256: 'sha', tokens: [] })
if (url.endsWith('/chapters/3')) {
return ok({ book: { ...book, attachments: { cover } }, chapter: { ...chapter, contentSha256: 'sha', originalText: 'Curiosity opens the first door.\n' }, navigation: { previousChapterId: null, nextChapterId: null } })
}
return ok({})
})
return { chapter, requests, fetchMock }
}
async function mountWith(component: unknown, path: string): Promise<{ view: VueWrapper; router: Router }> {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: LibraryView },
{ path: '/books/:id', component: BookView },
{ path: '/chapters/:id', component: ReaderView },
{ path: '/vocab', component: { template: '<div />' } },
{ path: '/review', component: { template: '<div />' } },
{ path: '/progress', component: { template: '<div />' } },
{ path: '/import', component: { template: '<div />' } },
],
})
await router.push(path)
await router.isReady()
wrapper = mount(component as never, { attachTo: document.body, global: { plugins: [router] } })
await flushPromises()
await flushPromises()
return { view: wrapper, router }
}
describe('book cover in the library', () => {
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()); stubObjectUrls(); useSessionStore().user = { ...user } })
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); putBackObjectUrls(); wrapper?.unmount() })
it('shows the stored cover and loads its bytes once', async () => {
const { fetchMock } = mockApi()
const { view } = await mountWith(LibraryView, '/')
await vi.waitFor(() => expect(useLibraryStore().coverUrls[7]).toBeTruthy())
await flushPromises()
expect(view.get('[data-testid="book-cover"]').attributes('src')).toMatch(/^blob:fictional-/)
expect(fetchMock.mock.calls.filter(([url]) => String(url).includes('/books/7/cover'))).toHaveLength(1)
})
it('keeps the default cover when the book has none', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async input => {
const url = String(input)
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
if (url.endsWith('/me')) return ok(user)
if (url.endsWith('/space')) return ok({ ownerId: user.id, language: 'en' })
if (url.endsWith('/books')) return ok({ items: [{ ...book, chapterCount: 0, pendingCount: 0, processingCount: 0, readyCount: 0, failedCount: 0, createdAt: '', updatedAt: '' }] })
return ok({})
})
const { view } = await mountWith(LibraryView, '/')
await flushPromises()
expect(view.find('[data-testid="book-cover"]').exists()).toBe(false)
expect(view.get('.book-cover-default').text()).toBe('F')
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/cover'))).toBe(false)
})
})
describe('chapter attachments on the book page', () => {
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()); stubObjectUrls(); useSessionStore().user = { ...user } })
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); putBackObjectUrls(); wrapper?.unmount() })
it('shows the cover and the chapter audio state without any chapter image', async () => {
const { fetchMock } = mockApi()
const { view } = await mountWith(BookView, '/books/7')
// The cover block is one compact row now: preview and actions, no headings or hints.
await vi.waitFor(() => expect(useLibraryStore().coverUrls[7]).toBeTruthy())
await flushPromises()
expect(view.find('[data-testid="cover-preview"]').exists()).toBe(true)
expect(view.find('[data-testid="cover-input"]').exists()).toBe(true)
expect(view.text()).not.toContain('书籍封面')
expect(view.text()).not.toContain('像素不超过')
await flushPromises()
// The chapter list shows no illustration and does not fetch one.
expect(view.find('[data-testid="chapter-thumb-3"]').exists()).toBe(false)
expect(view.find('.chapter-thumb').exists()).toBe(false)
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/illustration'))).toBe(false)
expect(view.get('.chapter-meta').text()).toContain('有音频')
expect(view.get('.chapter-meta').text()).toContain('上次 1:05')
// The book-level audio row is gone: audio lives in the chapter editor now, and the row has
// exactly one entry point.
expect(view.text()).not.toContain('音频 · 3.4 MB')
expect(view.find('[data-testid="chapter-attachments-3"]').exists()).toBe(false)
expect(view.find('[data-testid="edit-chapter-3"]').exists()).toBe(true)
})
it('edits the title, the text and both files in one chapter editor', async () => {
const { fetchMock } = mockApi({ illustration: false, audio: false, playbackSeconds: 0 })
const { view } = await mountWith(BookView, '/books/7')
await view.get('[data-testid="edit-chapter-3"]').trigger('click')
await flushPromises()
// One dialog holds the title, the text and both files.
expect(view.find('[data-testid="chapter-dialog"]').exists()).toBe(true)
expect(view.find('#chapter-title').exists()).toBe(true)
// The author shares the label row layout and is optional.
expect(view.find('[data-testid="chapter-author"]').exists()).toBe(true)
expect(view.find('.field-row label[for="chapter-author"]').exists()).toBe(true)
expect(view.find('#chapter-text').exists()).toBe(true)
expect(view.get('[data-testid="attachment-timing"]').text()).toContain('立即上传')
// The file hints are one short line each, so the height goes to the text editor.
expect(view.get('.attachment-block').text()).toContain('≤2 MiB')
expect(view.text()).toContain('≤4096×4096')
expect(view.text()).not.toContain('在阅读页显示为缩略图')
expect(view.get('[data-testid="illustration-state"]').text()).toBe('未设置')
expect(view.get('[data-testid="chapter-audio-state"]').text()).toBe('未设置')
await pickFile(view, 'illustration-input', new File([pngBytes], 'chapter.png', { type: 'image/png' }))
expect(fetchMock.mock.calls.some(([url, init]) => String(url).endsWith('/chapters/3/illustration') && String(init?.method) === 'POST')).toBe(true)
await pickFile(view, 'chapter-audio-input', new File([new Uint8Array(64)], 'track.mp3', { type: 'audio/mpeg' }))
expect(fetchMock.mock.calls.some(([url, init]) => String(url).endsWith('/chapters/3/audio') && String(init?.method) === 'POST')).toBe(true)
expect(view.get('[data-testid="book-notice"]').text()).toContain('音频已更新')
// The text is still saved by its own button, inside the same dialog.
await view.get('#chapter-title').setValue('Fictional chapter renamed')
await view.get('[data-testid="save-chapter"]').trigger('click')
await flushPromises()
expect(fetchMock.mock.calls.some(([url, init]) => String(url).includes('/chapters/3') && String(init?.method) === 'PATCH')).toBe(true)
})
it('refuses a wrong type before uploading and removes after confirmation', async () => {
const { fetchMock } = mockApi()
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never)
const { view } = await mountWith(BookView, '/books/7')
await view.get('[data-testid="edit-chapter-3"]').trigger('click')
await flushPromises()
await pickFile(view, 'chapter-audio-input', new File([new Uint8Array(16)], 'song.wav', { type: 'audio/wav' }))
expect(view.get('.field-error').text()).toContain('音频只支持 MP3')
expect(fetchMock.mock.calls.filter(([, init]) => String(init?.method) === 'POST')).toHaveLength(0)
await view.get('[data-testid="illustration-remove"]').trigger('click')
await flushPromises()
const deleted = fetchMock.mock.calls.find(([, init]) => String(init?.method) === 'DELETE')
expect(String(deleted?.[0])).toContain('/chapters/3/illustration')
expect(view.get('[data-testid="illustration-state"]').text()).toBe('未设置')
})
})
describe('audio player component', () => {
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); wrapper?.unmount() })
it('formats the clock, changes the rate and reports an error', async () => {
stubMedia()
const positions: number[] = []
wrapper = mount(AudioPlayer, { props: { src: 'blob:fictional', initialPosition: 65, onPosition: (value: number) => positions.push(value) } })
await flushPromises()
expect(wrapper.get('[data-testid="audio-clock"]').text()).toContain('1:05')
await wrapper.get('[data-testid="audio-rate-1.5"]').trigger('click')
expect(wrapper.get('[data-testid="audio-rate-1.5"]').attributes('aria-pressed')).toBe('true')
const element = wrapper.get('[data-testid="audio-element"]').element as HTMLAudioElement
expect(element.playbackRate).toBe(1.5)
element.currentTime = 42
await wrapper.get('[data-testid="audio-element"]').trigger('pause')
expect(positions).toEqual([42])
await wrapper.get('[data-testid="audio-element"]').trigger('error')
expect(wrapper.get('[data-testid="audio-error"]').text()).toContain('音频加载失败')
})
it('never starts on its own and tolerates a rejected play', async () => {
const { play } = stubMedia()
play.mockRejectedValueOnce(new Error('blocked'))
wrapper = mount(AudioPlayer, { props: { src: 'blob:fictional', initialPosition: 0, onPosition: () => undefined } })
await flushPromises()
expect(play).not.toHaveBeenCalled()
await wrapper.get('[data-testid="audio-toggle"]').trigger('click')
await flushPromises()
expect(play).toHaveBeenCalledTimes(1)
expect(wrapper.get('[data-testid="audio-error"]').text()).toContain('无法播放')
})
})
describe('chapter illustration and player in the reader', () => {
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()); stubObjectUrls(); stubMedia(); useSessionStore().user = { ...user } })
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); putBackObjectUrls(); wrapper?.unmount() })
it('shows a chapter thumbnail above the text and opens the original in a dialog', async () => {
mockApi()
const { view } = await mountWith(ReaderView, '/chapters/3')
expect(view.find('.reader-text').exists()).toBe(true)
await vi.waitFor(() => expect(useLibraryStore().audioUrl).not.toBe(''))
await flushPromises()
const thumb = view.get('[data-testid="chapter-illustration"]')
expect(thumb.element.tagName).toBe('BUTTON')
expect(thumb.attributes('aria-label')).toContain('插图大图')
expect(thumb.find('img').exists()).toBe(true)
// The thumbnail sits between the heading and the text, and the dialog starts closed.
const html = view.html()
expect(html.indexOf('chapter-illustration')).toBeLessThan(html.indexOf('reader-workspace'))
expect(document.querySelector('[data-testid="illustration-dialog"]')).toBeNull()
await thumb.trigger('click')
await flushPromises()
const dialog = document.querySelector('[data-testid="illustration-dialog"]')
expect(dialog).not.toBeNull()
expect(dialog?.querySelector('img')?.getAttribute('src')).toMatch(/^blob:fictional-/)
expect(view.find('[data-testid="audio-player"]').exists()).toBe(true)
expect(useLibraryStore().audioChapterId).toBe(3)
})
it('stays silent for a chapter without audio or illustration', async () => {
mockApi({ audio: false, illustration: false, playbackSeconds: 0 })
const { view } = await mountWith(ReaderView, '/chapters/3')
await flushPromises()
expect(view.find('[data-testid="audio-player"]').exists()).toBe(false)
expect(view.find('[data-testid="chapter-illustration"]').exists()).toBe(false)
})
it('sends the position to the chapter instead of only keeping it locally', async () => {
const { fetchMock } = mockApi()
const { view } = await mountWith(ReaderView, '/chapters/3')
const element = view.get('[data-testid="audio-element"]').element as HTMLAudioElement
element.currentTime = 77
await view.get('[data-testid="audio-element"]').trigger('pause')
await flushPromises()
const reported = fetchMock.mock.calls.find(([, init]) => String(init?.method) === 'PUT')
expect(String(reported?.[0])).toContain('/api/v1/chapters/3/playback')
expect(JSON.parse(String(reported?.[1]?.body))).toEqual({ positionSeconds: 77 })
})
})
+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')
})
})
+243
View File
@@ -0,0 +1,243 @@
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, type Router } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import BookView from '../views/BookView.vue'
import { useLibraryStore, type ChapterSource, type ChapterSummary } from '../stores/library'
import { useSessionStore } from '../stores/session'
const user = { id: 42, username: 'fictional-editor', role: 'learner' as const }
const book = { id: 1, title: 'A small step', author: '', language: 'en' }
const timestamps = { createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' }
const chapter = (overrides: Partial<ChapterSummary> = {}): ChapterSummary => ({
id: 9, bookId: 1, ordinal: 1, title: 'First chapter', author: '', status: 'ready', charCount: 12,
errorReason: '', errorMessage: '', jobId: 5, ...timestamps, ...overrides,
})
const source: ChapterSource = { id: 9, bookId: 1, ordinal: 1, title: 'First chapter', author: '', text: 'Mira opened the workshop.\n', status: 'ready', contentSha256: 'sha-a', charCount: 24 }
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
const fail = (msg: string, status = 400) => new Response(JSON.stringify({ code: status, msg }), { status })
let wrapper: VueWrapper | undefined
function stub(name: string) {
return { template: `<div>${name}</div>` }
}
async function viewAt(path: string): Promise<Router> {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: stub('LibraryStub') },
{ path: '/books/:id', component: stub('BookStub') },
{ path: '/import', component: stub('ImportStub') },
],
})
await router.push(path)
await router.isReady()
return router
}
/** Routes the book page's own calls; a test can override any of them. */
function mockApi(overrides: Record<string, (init?: RequestInit) => Response | Promise<Response>> = {}): MockInstance {
return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const url = String(input)
const method = String((init as RequestInit | undefined)?.method ?? 'GET')
for (const [key, handler] of Object.entries(overrides)) {
if (url.includes(key)) return handler(init as RequestInit)
}
if (url.endsWith('/books/1') && method === 'GET') return ok({ book, chapters: [chapter()] })
if (url.includes('/chapters/9/source')) return ok({ source })
// A rename answers with the stored book, and a delete reports what it removed.
if (url.endsWith('/books/1') && method === 'PATCH') {
const body = JSON.parse(String((init as RequestInit | undefined)?.body ?? '{}')) as { title?: string; author?: string }
return ok({ book: { ...book, title: body.title ?? book.title, author: body.author ?? book.author } })
}
if (url.endsWith('/books/1') && method === 'DELETE') return ok({ deleted: { bookId: 1, chapters: 3 } })
if (url.endsWith('/books/1')) return ok({ book })
if (url.includes('/chapters/9')) return ok({ chapter: chapter(), job: null, versionChanged: false })
return ok({})
})
}
async function openBook() {
useSessionStore().user = { ...user }
const router = await viewAt('/books/1')
wrapper = mount(BookView, { attachTo: document.body, global: { plugins: [router] } })
await flushPromises()
return { view: wrapper, router }
}
describe('book editing store', () => {
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear() })
afterEach(() => { vi.restoreAllMocks() })
it('saves the title and the author and keeps the list entry in step', async () => {
const fetchMock = mockApi()
const library = useLibraryStore()
library.books = [{ ...book, chapterCount: 1, pendingCount: 0, processingCount: 0, readyCount: 1, failedCount: 0, ...timestamps }]
library.book = { ...book }
const renamed = await library.updateBook(1, { title: ' New name ', author: ' Fictional author ' })
expect(renamed.title).toBe('New name')
expect(renamed.author).toBe('Fictional author')
expect(library.book?.title).toBe('New name')
expect(library.books[0]?.title).toBe('New name')
expect(library.books[0]?.author).toBe('Fictional author')
const call = fetchMock.mock.calls.find(([, init]) => (init as RequestInit | undefined)?.method === 'PATCH')!
expect(String(call[0])).toBe('/api/v1/books/1')
expect(JSON.parse(String((call[1] as RequestInit).body))).toEqual({ title: 'New name', author: 'Fictional author' })
})
it('refuses an invalid title before calling the API', async () => {
const fetchMock = mockApi()
const library = useLibraryStore()
await expect(library.updateBook(1, { title: ' ', author: '' })).rejects.toThrow('请填写标题')
expect(fetchMock).not.toHaveBeenCalled()
})
it('applies a new chapter version and drops the stale reader text', async () => {
mockApi({ '/chapters/9': () => ok({ chapter: chapter({ status: 'pending', title: 'Edited' }), job: { id: 6, bookId: 1, chapterId: 9, status: 'pending', attempts: 0, errorReason: '', errorMessage: '', ...timestamps }, versionChanged: true }) })
const library = useLibraryStore()
library.chapters = [chapter()]
library.chapter = { ...chapter(), contentSha256: 'sha-a', originalText: 'old text' }
const result = await library.updateChapter(9, { title: 'Edited', text: 'new text' })
expect(result.versionChanged).toBe(true)
expect(library.chapters[0]).toMatchObject({ status: 'pending', title: 'Edited', jobId: 6 })
// The reader must not keep showing text that is no longer the stored version.
expect(library.chapter?.originalText).toBeUndefined()
})
it('keeps the reader text when only the title changed', async () => {
mockApi({ '/chapters/9': () => ok({ chapter: chapter({ title: 'Renamed' }), job: null, versionChanged: false }) })
const library = useLibraryStore()
library.chapters = [chapter()]
library.chapter = { ...chapter(), contentSha256: 'sha-a', originalText: 'kept text' }
await library.updateChapter(9, { title: 'Renamed' })
expect(library.chapter?.originalText).toBe('kept text')
expect(library.chapter?.title).toBe('Renamed')
})
it('deletes a chapter, reloads the book and removes a deleted book from the list', async () => {
mockApi({
'/chapters/9': () => ok({ deleted: { chapterId: 9, bookId: 1, remaining: 2 } }),
'/books/1': (init) => (init?.method === 'DELETE'
? ok({ deleted: { bookId: 1, chapters: 3 } })
: ok({ book, chapters: [] })),
})
const library = useLibraryStore()
library.books = [{ ...book, chapterCount: 3, pendingCount: 0, processingCount: 0, readyCount: 3, failedCount: 0, ...timestamps }]
library.book = { ...book }
library.chapters = [chapter(), chapter({ id: 10, ordinal: 2 }), chapter({ id: 11, ordinal: 3 })]
const deleted = await library.deleteChapter(9)
expect(deleted.remaining).toBe(2)
expect(library.chapters.map(item => item.id)).toEqual([])
expect(library.book?.id).toBe(1)
const removed = await library.deleteBook(1)
expect(removed.chapters).toBe(3)
expect(library.books).toHaveLength(0)
expect(library.book).toBeNull()
})
it('reads the editable source of any chapter state', async () => {
mockApi({ '/chapters/9/source': () => ok({ source: { ...source, status: 'failed' } }) })
const library = useLibraryStore()
const loaded = await library.loadChapterSource(9)
expect(loaded.text).toBe(source.text)
expect(loaded.status).toBe('failed')
})
})
describe('book editing view', () => {
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear(); vi.restoreAllMocks() })
afterEach(() => { wrapper?.unmount(); wrapper = undefined; useLibraryStore().stopPolling() })
it('renames the book through the dialog and reports it', async () => {
const fetchMock = mockApi()
const { view } = await openBook()
await view.get('[data-testid="edit-book"]').trigger('click'); await flushPromises()
const input = view.get('#book-title')
expect((input.element as HTMLInputElement).value).toBe('A small step')
await input.setValue(' Edited name ')
// The same dialog also carries the book author.
expect(view.find('[data-testid="book-author-input"]').exists()).toBe(true)
await view.get('[data-testid="book-author-input"]').setValue(' Fictional author ')
await view.get('[data-testid="save-book"]').trigger('click'); await flushPromises()
const patch = fetchMock.mock.calls.find(([, init]) => (init as RequestInit | undefined)?.method === 'PATCH')!
expect(JSON.parse(String((patch[1] as RequestInit).body))).toEqual({ title: 'Edited name', author: 'Fictional author' })
expect(view.get('[data-testid="book-notice"]').text()).toContain('书籍信息已更新')
expect(view.get('[data-testid="book-author"]').text()).toBe('Fictional author')
})
it('keeps the dialog open with the server message when renaming fails', async () => {
mockApi({ '/books/1': (init) => (init?.method === 'PATCH' ? fail('书名已存在') : ok({ book, chapters: [chapter()] })) })
const { view } = await openBook()
await view.get('[data-testid="edit-book"]').trigger('click'); await flushPromises()
await view.get('#book-title').setValue('Rejected')
await view.get('[data-testid="save-book"]').trigger('click'); await flushPromises()
expect(view.text()).toContain('书名已存在')
expect(view.get('#book-title')).toBeTruthy()
})
it('asks before deleting the book and does nothing when the learner cancels', async () => {
const fetchMock = mockApi()
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockRejectedValue('cancel')
const { view } = await openBook()
await view.get('[data-testid="delete-book"]').trigger('click'); await flushPromises()
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('已保存的生词和短语将保留'), '删除书籍', expect.anything())
expect(fetchMock.mock.calls.some(([, init]) => (init as RequestInit | undefined)?.method === 'DELETE')).toBe(false)
})
it('deletes the book after confirmation and returns to the library', async () => {
const fetchMock = mockApi()
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never)
const { view, router } = await openBook()
await view.get('[data-testid="delete-book"]').trigger('click'); await flushPromises()
expect(fetchMock.mock.calls.some(([url, init]) => String(url).endsWith('/books/1') && (init as RequestInit | undefined)?.method === 'DELETE')).toBe(true)
expect(router.currentRoute.value.path).toBe('/')
expect(router.currentRoute.value.query.deleted).toBe('3')
})
it('edits a chapter: loads the source, saves title and text and reports the new version', async () => {
const fetchMock = mockApi({ '/chapters/9': (init) => (init?.method === 'PATCH'
? ok({ chapter: chapter({ status: 'pending', title: 'Edited chapter' }), job: { id: 6, bookId: 1, chapterId: 9, status: 'pending', attempts: 0, errorReason: '', errorMessage: '', ...timestamps }, versionChanged: true })
: ok({ source })) })
const { view } = await openBook()
await view.get('[data-testid="edit-chapter-9"]').trigger('click'); await flushPromises()
expect((view.get('#chapter-title').element as HTMLInputElement).value).toBe('First chapter')
expect((view.get('#chapter-text').element as HTMLTextAreaElement).value).toBe(source.text)
await view.get('#chapter-title').setValue('Edited chapter')
await view.get('#chapter-text').setValue('A new body.\n')
await view.get('[data-testid="save-chapter"]').trigger('click'); await flushPromises()
const patch = fetchMock.mock.calls.find(([url, init]) => String(url).endsWith('/chapters/9') && (init as RequestInit | undefined)?.method === 'PATCH')!
expect(JSON.parse(String((patch[1] as RequestInit).body))).toEqual({ title: 'Edited chapter', author: '', text: 'A new body.' + String.fromCharCode(10) })
expect(view.get('[data-testid="book-notice"]').text()).toContain('已保存为新版本,正在重新处理')
})
it('refuses an empty chapter body locally and keeps the dialog', async () => {
const fetchMock = mockApi()
const { view } = await openBook()
await view.get('[data-testid="edit-chapter-9"]').trigger('click'); await flushPromises()
await view.get('#chapter-text').setValue(' \n\t ')
await view.get('[data-testid="save-chapter"]').trigger('click'); await flushPromises()
expect(view.text()).toContain('请粘贴要导入的英文正文。')
expect(fetchMock.mock.calls.some(([, init]) => (init as RequestInit | undefined)?.method === 'PATCH')).toBe(false)
})
it('deletes a chapter after confirmation and reports the remaining count', async () => {
const fetchMock = mockApi({ '/chapters/9': (init) => (init?.method === 'DELETE' ? ok({ deleted: { chapterId: 9, bookId: 1, remaining: 2 } }) : ok({ source })) })
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never)
const { view } = await openBook()
await view.get('[data-testid="edit-chapter-9"]').trigger('click'); await flushPromises()
await view.get('[data-testid="delete-chapter"]').trigger('click'); await flushPromises()
expect(fetchMock.mock.calls.some(([url, init]) => String(url).endsWith('/chapters/9') && (init as RequestInit | undefined)?.method === 'DELETE')).toBe(true)
expect(view.get('[data-testid="book-notice"]').text()).toContain('章节已删除 · 剩余 2 章')
// Element Plus keeps a closed dialog in the DOM, so the closed state is what matters.
expect(view.get('[data-testid="chapter-dialog"]').isVisible()).toBe(false)
})
it('shows an empty chapter list with the import hint', async () => {
mockApi({ '/books/1': () => ok({ book, chapters: [] }) })
const { view } = await openBook()
expect(view.get('[data-testid="empty-chapters"]').text()).toContain('这一本书还没有章节')
expect(view.get('[data-testid="delete-book"]')).toBeTruthy()
})
})
+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)
})
})
+2 -1
View File
@@ -13,7 +13,7 @@ import { useSessionStore } from '../stores/session'
// All accounts, books and texts in these tests are deliberately fictitious.
const user = { id: 7, username: 'fictional-reader', role: 'learner' as const }
const book = { id: 1, title: '虚构样例书', language: 'en' }
const book = { id: 1, title: '虚构样例书', author: '', language: 'en' }
const navigation = { previousChapterId: null, nextChapterId: null }
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }), { status: 200 })
@@ -24,6 +24,7 @@ const chapter = (overrides: Partial<ChapterDetail> = {}): ChapterDetail => ({
id: 55,
bookId: 1,
ordinal: 1,
author: '',
title: '第一篇',
status: 'pending',
charCount: 120,
+142 -24
View File
@@ -1,16 +1,16 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
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 { createMemoryHistory, createRouter } from 'vue-router'
import ReaderView from '../views/ReaderView.vue'
import { useSessionStore } from '../stores/session'
import { useReaderLookup } from '../composables/useReaderLookup'
import { useReaderLookup, type SavedTerm } from '../composables/useReaderLookup'
import { effectScope, ref } from 'vue'
import type { ChapterDetail } from '../stores/library'
// Fictitious text includes astral, combining, CRLF and repeated whitespace.
const original = '😀 Cats\r\n café!'
const fragments = [['😀', 'punctuation'], [' ', 'space'], ['Cats', 'word'], ['\r\n ', 'space'], ['café', 'word'], ['!', 'punctuation']]
const original = '😀 Cats\r\n café!'
const fragments = [['😀', 'punctuation'], [' ', 'space'], ['Cats', 'word'], ['\r\n ', 'space'], ['café', 'word'], ['!', 'punctuation']]
let cp = 0, utf16 = 0
const tokens = fragments.map(([text = '', kind]) => {
const token = { text, kind, start: cp, end: cp + [...text].length, startUtf16: utf16, endUtf16: utf16 + text.length }
@@ -20,11 +20,25 @@ const tokens = fragments.map(([text = '', kind]) => {
const chapter = { id: 55, bookId: 1, title: '虚构章节', status: 'ready', originalText: original, contentSha256: 'same-sha' }
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
const result = (query: string, status = 'exact') => ({ status, query, matchedForm: query, candidates: [], entries: status === 'exact' ? [{ lemma: query, pos: 'noun', definition: `Definition of ${query}`, examples: ['A fictional example.'] }] : [] })
const savedTerm = (overrides: Partial<SavedTerm> = {}): SavedTerm => ({
id: 7, term: 'cats', originalForm: 'Cats', definition: '猫', examples: ['A fictional example.'], status: 'learning', level: 2,
kind: 'word', wordCount: 1, ...overrides,
})
let wrapper: VueWrapper | undefined
async function open(mockLookup: (body: Record<string, number>) => Promise<Response> = async () => ok(result('cat')), tokenData: unknown = { textSha256: 'same-sha', tokens }) {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, options) => {
if (String(input).endsWith('/tokens')) return ok(tokenData)
if (String(input).endsWith('/lookup')) return mockLookup(JSON.parse(String(options?.body)))
interface OpenOptions {
lookup?: (body: Record<string, number>) => Promise<Response>
tokenData?: unknown
termRead?: () => Promise<Response>
termWrite?: (body: Record<string, unknown>) => Promise<Response>
}
async function open(options: OpenOptions = {}) {
const { lookup = async () => ok(result('cat')), tokenData = { textSha256: 'same-sha', tokens } } = options
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const url = String(input)
if (url.endsWith('/tokens')) return ok(tokenData)
if (url.endsWith('/lookup')) return lookup(JSON.parse(String(init?.body)))
if (url.includes('/terms/')) return options.termRead ? options.termRead() : ok({ term: savedTerm() })
if (url.endsWith('/terms')) return options.termWrite ? options.termWrite(JSON.parse(String(init?.body))) : ok({ term: savedTerm(), created: true })
return ok({ chapter, navigation: { previousChapterId: null, nextChapterId: 56 } })
})
useSessionStore().user = { id: 42, username: 'fictional', role: 'learner' }
@@ -34,6 +48,12 @@ async function open(mockLookup: (body: Record<string, number>) => Promise<Respon
await flushPromises()
return { fetchMock, router, view: wrapper }
}
const callsTo = (fetchMock: MockInstance, suffix: string) => fetchMock.mock.calls.filter(call => String(call[0]).endsWith(suffix))
// Personal state is attached to the word itself, never to a space or punctuation token.
const withTerm = (text: string, term: { id: number; status: string; level: number }) => ({
textSha256: 'same-sha',
tokens: tokens.map(token => token.text === text ? { ...token, term } : token),
})
describe('reader word lookup', () => {
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear() })
afterEach(() => { wrapper?.unmount(); wrapper = undefined; vi.restoreAllMocks() })
@@ -43,38 +63,70 @@ describe('reader word lookup', () => {
const words = view.findAll('.reader-word')
expect(words).toHaveLength(2)
await words[0]!.trigger('click'); await flushPromises()
const call = fetchMock.mock.calls.find(([url]) => String(url).endsWith('/lookup'))!
const call = callsTo(fetchMock, '/lookup')[0]!
expect(call[1]?.method).toBe('POST')
expect(JSON.parse(String(call[1]?.body))).toEqual({ chapterId: 55, start: 2, end: 6 })
expect(view.text()).toContain('Definition of cat')
expect(view.text()).toContain('A fictional example.')
})
it('discards older word responses and clears the selection draft', async () => {
it('shows the IPA phonetic and the Chinese gloss ahead of the English detail', async () => {
const merged = {
status: 'exact', query: 'cat', matchedForm: 'cat', candidates: ['cat'], phonetic: 'kæt', phoneticSource: 'cmudict',
resources: [{ name: '简明英汉字典增强版', version: 'ecdict-1.0.28+cmudict-0.7b' }, { name: 'Princeton WordNet', version: '3.0' }],
resource: { name: 'Princeton WordNet', version: '3.0' },
entries: [
{ lemma: 'cat', pos: '', definition: '', examples: [], translation: 'n. 猫, 恶妇\nvi. 呕吐', phonetic: 'kæt', phoneticSource: 'cmudict', provider: 'ecdict' },
{ lemma: 'cat', pos: 'n', definition: 'feline animal', examples: [], provider: 'wordnet' },
],
}
const { view } = await open({ lookup: async () => ok(merged) })
await view.get('.reader-word').trigger('click'); await flushPromises()
expect(view.get('[data-testid="lookup-phonetic"]').text()).toBe('/kæt/')
expect(view.get('[data-testid="lookup-translation"]').text()).toContain('猫')
expect(view.text()).toContain('feline animal')
const senses = view.findAll('.lookup-senses li')
expect(senses[0]!.attributes('data-provider')).toBe('ecdict')
expect(senses[1]!.attributes('data-provider')).toBe('wordnet')
expect(view.text()).toContain('简明英汉字典增强版')
expect(view.text()).toContain('Princeton WordNet')
})
it('shows no phonetic for an inflected form resolved to a headword', async () => {
const inflected = {
status: 'lemma', query: 'cats', matchedForm: 'cat', candidates: ['cat'],
entries: [{ lemma: 'cat', pos: '', definition: '', examples: [], translation: 'n. 猫', provider: 'ecdict' }],
}
const { view } = await open({ lookup: async () => ok(inflected) })
await view.get('.reader-word').trigger('click'); await flushPromises()
expect(view.find('[data-testid="lookup-phonetic"]').exists()).toBe(false)
expect(view.get('[data-testid="lookup-translation"]').text()).toContain('猫')
expect(view.text()).toContain('词形匹配')
})
it('discards older word responses and clears the selection form', async () => {
let finish!: (response: Response) => void
const { view } = await open(body => body.start === 2 ? new Promise(resolve => { finish = resolve }) : Promise.resolve(ok(result('cafe', 'not_found'))))
const { view } = await open({ lookup: body => body.start === 2 ? new Promise(resolve => { finish = resolve }) : Promise.resolve(ok(result('cafe', 'not_found'))) })
await view.findAll('.reader-word')[0]!.trigger('click')
expect(view.text()).toContain('正在查询')
await view.findAll('.reader-word')[1]!.trigger('click'); await flushPromises()
await view.get('textarea').setValue('虚构私人草稿')
await view.get('#term-definition').setValue('虚构私人草稿')
finish(ok(result('OLD'))); await flushPromises()
expect(view.text()).not.toContain('Definition of OLD')
expect(view.text()).toContain('未找到释义')
expect(view.text()).toContain('未保存')
expect(view.text()).toContain('新词条')
await view.findAll('.reader-word')[0]!.trigger('click')
expect((view.get('textarea').element as HTMLTextAreaElement).value).toBe('')
expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('')
})
it.each(['resource_missing', 'not_found', 'error'])('keeps reading and permits a local draft and retry for %s', async status => {
const { view, fetchMock } = await open(async () => { if (status === 'error') throw new Error('暂时无法查询'); return ok(result('cat', status)) })
it.each(['resource_missing', 'not_found', 'error'])('keeps reading and permits a saved word and retry for %s', async status => {
const { view, fetchMock } = await open({ lookup: async () => { if (status === 'error') throw new Error('暂时无法查询'); return ok(result('cat', status)) } })
await view.get('.reader-word').trigger('click'); await flushPromises()
await view.get('textarea').setValue('仅当前词的虚构草稿')
await view.get('#term-definition').setValue('仅当前词的虚构释义')
expect(view.get('.reader-text').element.textContent).toBe(original)
expect(view.text()).toContain('未保存')
expect(fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/lookup'))).toHaveLength(1)
expect(view.get('[data-testid="term-save"]').attributes('disabled')).toBeUndefined()
expect(callsTo(fetchMock, '/lookup')).toHaveLength(1)
await view.get('[data-testid="lookup-retry"]').trigger('click'); await flushPromises()
expect(fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/lookup'))).toHaveLength(2)
expect(callsTo(fetchMock, '/lookup')).toHaveLength(2)
})
it.each([{ textSha256: 'wrong', tokens }, { textSha256: 'same-sha', tokens: tokens.slice(1) }])('falls back to original when tokens do not match', async data => {
const { view } = await open(undefined, data)
const { view } = await open({ tokenData: data })
expect(view.get('.reader-text').element.textContent).toBe(original)
expect(view.find('.reader-word').exists()).toBe(false)
expect(view.find('[data-testid="tokens-retry"]').exists()).toBe(true)
@@ -105,7 +157,7 @@ describe('reader word lookup', () => {
expect(view.findAll('.reader-word')).toHaveLength(2)
})
it('does not accept a late token response after identity changes', async () => {
const { view, fetchMock } = await open(undefined, { textSha256: 'wrong', tokens })
const { view, fetchMock } = await open({ tokenData: { textSha256: 'wrong', tokens } })
let finish!: (response: Response) => void
fetchMock.mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
await view.get('[data-testid="tokens-retry"]').trigger('click')
@@ -130,7 +182,7 @@ describe('reader word lookup', () => {
expect(scrollTo).toHaveBeenCalledWith({ top: 0, left: 0, behavior: 'instant' })
scope.stop()
})
it('preserves a later manual reading scroll when closing the sheet', () => {
it('preserves a later manual reading scroll when closing the sheet', async () => {
useSessionStore().user = { id: 42, username: 'fictional', role: 'learner' }
vi.spyOn(globalThis, 'fetch').mockResolvedValue(ok(result('cat')))
let scrollY = 100
@@ -154,7 +206,7 @@ describe('reader word lookup', () => {
})
it.each(['logout', 'chapter', 'unmount'])('invalidates pending lookup on %s', async action => {
let finish!: (response: Response) => void
const { view, router } = await open(() => new Promise(resolve => { finish = resolve }))
const { view, router } = await open({ lookup: () => new Promise(resolve => { finish = resolve }) })
await view.get('.reader-word').trigger('click')
if (action === 'logout') await useSessionStore().logout()
else if (action === 'chapter') await router.push('/chapters/56')
@@ -165,3 +217,69 @@ describe('reader word lookup', () => {
expect(view.text()).not.toContain('Definition of OLD')
})
})
describe('personal word records', () => {
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear() })
afterEach(() => { wrapper?.unmount(); wrapper = undefined; vi.restoreAllMocks() })
it('saves the definition, examples and status, then marks the word in the text', async () => {
let body: Record<string, unknown> | undefined
const { view, fetchMock } = await open({ termWrite: async value => { body = value; return ok({ term: savedTerm({ status: 'learning', level: 2 }), created: true }) } })
await view.findAll('.reader-word')[0]!.trigger('click'); await flushPromises()
await view.get('#term-definition').setValue(' 猫 ')
await view.get('#term-examples').setValue('A fictional example.\n\n Second line ')
await view.findAll('input[type="radio"]')[1]!.setValue()
await view.get('[data-testid="term-save"]').trigger('click'); await flushPromises()
expect(body).toEqual({ chapterId: 55, start: 2, end: 6, definition: ' 猫 ', examples: ['A fictional example.', 'Second line'], status: 'learning' })
expect(view.text()).toContain('已保存 · 学习中')
expect(view.findAll('.reader-word')[0]!.classes()).toContain('is-learning')
expect(callsTo(fetchMock, '/terms')).toHaveLength(1)
})
it('keeps the typed input and reports the failure when saving fails', async () => {
const { view } = await open({ termWrite: async () => { throw new Error('保存暂时不可用') } })
await view.findAll('.reader-word')[0]!.trigger('click'); await flushPromises()
await view.get('#term-definition').setValue('虚构释义')
await view.get('[data-testid="term-save"]').trigger('click'); await flushPromises()
expect(view.text()).toContain('保存暂时不可用')
expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('虚构释义')
expect(view.find('.lookup-panel').exists()).toBe(true)
expect(view.findAll('.reader-word')[0]!.classes()).not.toContain('is-new')
})
it('loads the stored record when a saved word is opened and marks it in the text', async () => {
const tokenData = withTerm('Cats', { id: 7, status: 'known', level: 0 })
const { view, fetchMock } = await open({ tokenData, termRead: async () => ok({ term: savedTerm({ status: 'known', level: 0 }) }) })
expect(view.findAll('.reader-word')[0]!.classes()).toContain('is-known')
expect(view.findAll('.reader-word')[0]!.attributes('aria-label')).toContain('已保存')
await view.findAll('.reader-word')[0]!.trigger('click'); await flushPromises()
expect(callsTo(fetchMock, '/terms/7')).toHaveLength(1)
expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('猫')
expect((view.get('#term-examples').element as HTMLTextAreaElement).value).toBe('A fictional example.')
expect((view.findAll('input[type="radio"]')[2]!.element as HTMLInputElement).checked).toBe(true)
expect(view.text()).toContain('已保存')
})
it('blocks saving until a failed read of the stored record is retried', async () => {
const tokenData = withTerm('Cats', { id: 7, status: 'learning', level: 1 })
let attempts = 0
const { view, fetchMock } = await open({ tokenData, termRead: async () => { attempts++; if (attempts === 1) throw new Error('已保存的内容暂时无法读取'); return ok({ term: savedTerm() }) } })
await view.findAll('.reader-word')[0]!.trigger('click'); await flushPromises()
expect(view.text()).toContain('已保存的内容暂时无法读取')
expect(view.get('[data-testid="term-save"]').attributes('disabled')).toBeDefined()
await view.get('.reader-word').trigger('click'); await flushPromises()
expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('猫')
expect(view.get('[data-testid="term-save"]').attributes('disabled')).toBeUndefined()
expect(callsTo(fetchMock, '/terms')).toHaveLength(0)
})
it('drops the previous account words and highlights after an identity change', async () => {
const { view, fetchMock } = await open({ termWrite: async () => ok({ term: savedTerm({ status: 'new', level: 0 }), created: true }) })
await view.findAll('.reader-word')[0]!.trigger('click'); await flushPromises()
await view.get('[data-testid="term-save"]').trigger('click'); await flushPromises()
expect(view.findAll('.reader-word')[0]!.classes()).toContain('is-new')
fetchMock.mockResolvedValueOnce(ok({ textSha256: 'same-sha', tokens }))
useSessionStore().user = { id: 43, username: 'other-fictional', role: 'learner' }
await flushPromises()
expect(view.find('.lookup-panel').exists()).toBe(false)
// The previous account's words and chapter are gone: the reader falls back to
// nothing until the new identity loads its own chapter.
expect(view.find('.reader-word').exists()).toBe(false)
expect(view.find('.reader-text').exists()).toBe(false)
expect(view.text()).not.toContain('已保存')
})
})
+186
View File
@@ -0,0 +1,186 @@
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 { useSessionStore } from '../stores/session'
import type { PhraseSpan, ReaderToken } from '../composables/useReaderLookup'
// The same fixture shape the phrase tests use on the server side.
const original = 'Take a small, step\nevery day.\nMira took a small step again.\n'
const fragments: [string, ReaderToken['kind']][] = [
['Take', 'word'], [' ', 'space'], ['a', 'word'], [' ', 'space'], ['small', 'word'], [',', 'punctuation'],
[' ', 'space'], ['step', 'word'], ['\n', 'space'], ['every', 'word'], [' ', 'space'], ['day', 'word'],
['.', 'punctuation'], ['\n', 'space'], ['Mira', 'word'], [' ', 'space'], ['took', 'word'], [' ', 'space'],
['a', 'word'], [' ', 'space'], ['small', 'word'], [' ', 'space'], ['step', 'word'], [' ', 'space'],
['again', 'word'], ['.', 'punctuation'], ['\n', 'space'],
]
let cp = 0
let utf16 = 0
const tokens: ReaderToken[] = fragments.map(([text, kind]) => {
const token: ReaderToken = { text, kind, start: cp, end: cp + [...text].length, startUtf16: utf16, endUtf16: utf16 + text.length }
cp = token.end
utf16 = token.endUtf16
return token
})
const chapter = { id: 55, bookId: 1, title: '虚构章节', status: 'ready', originalText: original, contentSha256: 'same-sha' }
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
const savedPhrase = {
id: 7, term: 'a small step', originalForm: 'a small, step', definition: '一小步',
examples: ['Take a small step, every day.'], status: 'new', level: 0, kind: 'phrase', wordCount: 3,
}
let wrapper: VueWrapper | undefined
interface OpenOptions {
phrases?: PhraseSpan[]
tokens?: ReaderToken[]
original?: string
save?: (body: Record<string, unknown>) => Promise<Response>
termRead?: () => Promise<Response>
}
async function open(options: OpenOptions = {}) {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const url = String(input)
if (url.endsWith('/tokens')) return ok({ textSha256: 'same-sha', tokens: options.tokens ?? tokens, phrases: options.phrases ?? [] })
if (url.endsWith('/lookup')) return ok({ status: 'not_found', query: 'a', matchedForm: null, candidates: [], entries: [] })
if (url.includes('/terms/')) return options.termRead ? options.termRead() : ok({ term: savedPhrase })
if (url.endsWith('/phrases') || url.endsWith('/terms')) {
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
if (options.save) return options.save(body)
return ok({ term: savedPhrase, created: true })
}
// A test that supplies its own tokens must also supply the matching chapter text.
const text2 = options.original ?? original
return ok({ chapter: { ...chapter, originalText: text2 }, navigation: { previousChapterId: null, nextChapterId: 56 } })
})
useSessionStore().user = { id: 42, username: 'fictional', role: 'learner' }
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/chapters/:id', component: ReaderView }, { path: '/', component: { template: '<div />' } }] })
await router.push('/chapters/55')
wrapper = mount(ReaderView, { attachTo: document.body, global: { plugins: [router] } })
await flushPromises()
return { fetchMock, router, view: wrapper }
}
/** Clicks one word and shift-clicks the next matching word after it to extend the range. */
async function shiftSelect(view: VueWrapper, first: string, last: string) {
const words = view.findAll('.reader-word')
const startIndex = words.findIndex(item => item.text() === first)
const endIndex = words.findIndex((item, index) => index > startIndex && item.text() === last)
await words[startIndex]!.trigger('click')
await flushPromises()
await words[endIndex]!.trigger('click', { shiftKey: true })
await flushPromises()
}
describe('phrase selection and panel', () => {
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear() })
afterEach(() => { wrapper?.unmount(); wrapper = undefined; vi.restoreAllMocks() })
it('builds a phrase from a shift-click range and saves it through the phrases endpoint', async () => {
let body: Record<string, unknown> | undefined
const { view } = await open({ save: async value => { body = value; return ok({ term: savedPhrase, created: true }) } })
await shiftSelect(view, 'a', 'step')
// The panel names the phrase and shows how many words it holds.
expect(view.get('[data-testid="phrase-range"]').text()).toContain('短语 · 3 个单词')
expect(view.text()).toContain('新词条')
// A phrase has no dictionary lookup section.
expect(view.find('[data-testid="lookup-retry"]').exists()).toBe(false)
await view.get('#term-definition').setValue('一小步')
await view.get('[data-testid="term-save"]').trigger('click')
await flushPromises()
// The selection travels as code point offsets of the whole phrase, punctuation included.
expect(body).toMatchObject({ chapterId: 55, start: 5, end: 18, definition: '一小步', status: 'new' })
expect(view.text()).toContain('已保存')
// The saved phrase highlights immediately.
expect(view.findAll('.reader-word').some(item => item.classes().includes('is-phrase'))).toBe(true)
})
it('adjusts both ends by whole words from the panel', async () => {
const { view } = await open()
await shiftSelect(view, 'a', 'step')
expect(view.get('[data-testid="phrase-range"]').text()).toContain('3 个单词')
await view.get('[data-testid="range-start-right"]').trigger('click')
await flushPromises()
expect(view.get('[data-testid="phrase-range"]').text()).toContain('2 个单词')
// Moving the start back restores the three-word phrase.
await view.get('[data-testid="range-start-left"]').trigger('click')
await flushPromises()
expect(view.get('[data-testid="phrase-range"]').text()).toContain('3 个单词')
// A two-word phrase cannot shrink further, so the shrinking buttons are disabled.
await view.get('[data-testid="range-start-right"]').trigger('click')
await flushPromises()
expect(view.get('[data-testid="phrase-range"]').text()).toContain('2 个单词')
expect(view.get('[data-testid="range-start-right"]').attributes('disabled')).toBeDefined()
expect(view.get('[data-testid="range-end-left"]').attributes('disabled')).toBeDefined()
// Growing it is still possible on either side.
expect(view.get('[data-testid="range-start-left"]').attributes('disabled')).toBeUndefined()
expect(view.get('[data-testid="range-end-right"]').attributes('disabled')).toBeUndefined()
})
it('opens the saved phrase when its highlighted range is clicked', async () => {
const span: PhraseSpan = { id: 7, status: 'new', wordCount: 3, startToken: 2, endToken: 7 }
const { view, fetchMock } = await open({ phrases: [span] })
const inside = view.findAll('.reader-word').find(item => item.text() === 'small')!
expect(inside.classes()).toContain('is-phrase')
await inside.trigger('click')
await flushPromises()
// The stored entry is read through the same route a word uses, and the panel edits it.
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/terms/7'))).toBe(true)
expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('一小步')
expect(view.get('[data-testid="phrase-range"]').text()).toContain('已保存')
})
it('stops claiming a stored phrase once its range is adjusted', async () => {
const span: PhraseSpan = { id: 7, status: 'new', wordCount: 3, startToken: 2, endToken: 7 }
const { view } = await open({ phrases: [span] })
await view.findAll('.reader-word').find(item => item.text() === 'small')!.trigger('click')
await flushPromises()
expect(view.get('[data-testid="phrase-range"]').text()).toContain('已保存')
await view.get('[data-testid="range-end-right"]').trigger('click')
await flushPromises()
// The adjusted range is another identity, so the panel no longer says it is stored, while
// the text the learner already had stays in the form.
expect(view.get('.lookup-panel').text()).toContain('新词条')
expect(view.get('.lookup-panel').text()).not.toContain('已保存')
expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('一小步')
})
it('refuses a range longer than the phrase limit and keeps the panel closed', async () => {
// One word more than a phrase may hold.
const longTokens: ReaderToken[] = []
const words: string[] = []
let cursor = 0
for (let index = 0; index < 14; index++) {
const word = `w${index}`
words.push(word)
longTokens.push({ text: word, start: cursor, end: cursor + word.length, startUtf16: cursor, endUtf16: cursor + word.length, kind: 'word' })
cursor += word.length
longTokens.push({ text: ' ', start: cursor, end: cursor + 1, startUtf16: cursor, endUtf16: cursor + 1, kind: 'space' })
cursor += 1
}
const { view } = await open({ tokens: longTokens, original: longTokens.map(token => token.text).join('') })
await shiftSelect(view, words[0]!, words[13]!)
expect(view.get('[data-testid="range-notice"]').text()).toContain('短语最多 12 个单词')
expect(view.find('[data-testid="phrase-range"]').exists()).toBe(false)
})
it('closes the phrase panel with Escape and restores the reading position', async () => {
const { view } = await open()
await shiftSelect(view, 'a', 'step')
expect(view.find('[data-testid="phrase-range"]').exists()).toBe(true)
await view.get('.lookup-panel').trigger('keydown', { key: 'Escape' })
await flushPromises()
expect(view.find('.lookup-panel').exists()).toBe(false)
})
it('keeps the typed phrase text when saving fails', async () => {
const { view } = await open({ save: async () => new Response(JSON.stringify({ code: 400, msg: '短语过长,请缩短选择范围' }), { status: 400 }) })
await shiftSelect(view, 'a', 'step')
await view.get('#term-definition').setValue('虚构释义')
await view.get('[data-testid="term-save"]').trigger('click')
await flushPromises()
expect(view.text()).toContain('短语过长,请缩短选择范围')
expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('虚构释义')
})
})
+251
View File
@@ -0,0 +1,251 @@
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, type Router } from 'vue-router'
import ProgressView from '../views/ProgressView.vue'
import ReaderView from '../views/ReaderView.vue'
import { useLibraryStore } from '../stores/library'
import { useProgressStore, type LearnerProgress } from '../stores/progress'
import { useSessionStore } from '../stores/session'
const user = { id: 42, username: 'fictional-progress', role: 'learner' as const }
const snapshot = (overrides: Partial<LearnerProgress> = {}): LearnerProgress => ({
readChapters: 2, totalChapters: 5, knownTerms: 4, learningTerms: 3, newTerms: 6, ignoredTerms: 1,
savedTerms: 14, dueNow: 2, books: [{ id: 7, title: 'Fictional reader', readChapters: 2, totalChapters: 5 }],
...overrides,
})
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
let wrapper: VueWrapper | undefined
function mockApi(data: LearnerProgress, onComplete?: () => Response): MockInstance {
return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const url = String(input)
if (url.includes('/progress')) return ok(data)
if (url.includes('/complete') && String(init?.method) === 'POST') {
return onComplete ? onComplete() : ok({ progress: { chapterId: 3, bookId: 7, read: true, readAt: '2026-09-15T03:04:05Z', duplicate: false } })
}
return ok({})
})
}
async function mountProgress(path = '/progress', data = snapshot()) {
mockApi(data)
useSessionStore().user = { ...user }
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div>LibraryStub</div>' } },
{ path: '/review', component: { template: '<div>ReviewStub</div>' } },
{ path: '/books/:id', component: { template: '<div>BookStub</div>' } },
{ path: '/progress', component: ProgressView },
],
})
await router.push(path)
await router.isReady()
wrapper = mount(ProgressView, { attachTo: document.body, global: { plugins: [router] } })
await flushPromises()
await flushPromises()
return { view: wrapper, router }
}
describe('progress store', () => {
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear() })
afterEach(() => { vi.restoreAllMocks() })
it('reads one snapshot and keeps the four word statuses apart', async () => {
mockApi(snapshot())
useSessionStore().user = { ...user }
const progress = useProgressStore()
await progress.load()
expect(progress.loaded).toBe(true)
expect(progress.data.readChapters).toBe(2)
expect(progress.data.totalChapters).toBe(5)
expect(progress.readLabel).toBe('2 / 5 章(可阅读)')
// The statuses are never merged into one number.
expect([progress.data.knownTerms, progress.data.learningTerms, progress.data.newTerms, progress.data.ignoredTerms])
.toEqual([4, 3, 6, 1])
expect(progress.data.ignoredTerms).toBe(1)
expect(progress.data.dueNow).toBe(2)
expect(progress.data.books).toHaveLength(1)
expect(progress.hasBooks).toBe(true)
})
it('keeps the last snapshot when a refresh fails and reports the error', async () => {
useSessionStore().user = { ...user }
const progress = useProgressStore()
mockApi(snapshot())
await progress.load()
vi.restoreAllMocks()
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => new Response(JSON.stringify({ code: 500, msg: '服务器暂时不可用' }), { status: 500 }))
await progress.load()
expect(progress.error).not.toBe('')
expect(progress.data.readChapters).toBe(2)
})
it('drops another account\'s snapshot on a switch', async () => {
useSessionStore().user = { ...user }
const progress = useProgressStore()
mockApi(snapshot())
await progress.load()
expect(progress.loaded).toBe(true)
useSessionStore().user = { id: 99, username: 'other', role: 'learner' as const }
expect(progress.loaded).toBe(false)
expect(progress.data.totalChapters).toBe(0)
expect(progress.data.books).toEqual([])
})
it('ignores a late reply from the previous account', async () => {
useSessionStore().user = { ...user }
const progress = useProgressStore()
let release: (() => void) | null = null
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
await new Promise<void>(resolve => { release = () => resolve() })
return ok(snapshot())
})
const pending = progress.load()
useSessionStore().user = { id: 99, username: 'other', role: 'learner' as const }
release!()
await pending
expect(progress.data.totalChapters).toBe(0)
expect(progress.error).toBe('')
})
})
describe('progress view', () => {
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear() })
afterEach(() => { vi.restoreAllMocks(); wrapper?.unmount() })
it('shows the counts, the per-book reading and the review entry', async () => {
const { view } = await mountProgress()
expect(view.get('[data-testid="stat-read"]').text()).toContain('2 / 5')
expect(view.get('[data-testid="stat-due"]').text()).toContain('2')
expect(view.get('[data-testid="stat-known"]').text()).toContain('4')
expect(view.get('[data-testid="stat-learning"]').text()).toContain('3')
expect(view.get('[data-testid="stat-new"]').text()).toContain('6')
expect(view.get('[data-testid="stat-ignored"]').text()).toContain('1')
expect(view.get('[data-testid="progress-books"]').text()).toContain('Fictional reader')
expect(view.get('[data-testid="progress-books"]').text()).toContain('2 / 5 章已读')
expect(view.get('[data-testid="progress-saved"]').text()).toContain('14')
expect(view.find('.page-actions a[href="/review"]').exists()).toBe(true)
})
it('invites reading when there is no book yet', async () => {
const { view } = await mountProgress('/progress', snapshot({ books: [], readChapters: 0, totalChapters: 0 }))
expect(view.get('[data-testid="progress-no-books"]').text()).toContain('还没有书')
expect(view.find('[data-testid="progress-books"]').exists()).toBe(false)
expect(view.get('[data-testid="stat-read"]').text()).toContain('0 / 0')
})
it('offers a retry when the first load fails', async () => {
useSessionStore().user = { ...user }
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => new Response(JSON.stringify({ code: 500, msg: '服务器暂时不可用' }), { status: 500 }))
const router = await viewAtWith(ProgressView)
wrapper = mount(ProgressView, { attachTo: document.body, global: { plugins: [router] } })
await flushPromises()
await flushPromises()
expect(wrapper.get('[role="alert"]').text()).toContain('服务器暂时不可用')
fetchMock.mockImplementation(async () => ok(snapshot()))
await wrapper.get('[data-testid="progress-retry"]').trigger('click')
await flushPromises()
await flushPromises()
expect(wrapper.get('[data-testid="stat-read"]').text()).toContain('2 / 5')
})
})
async function viewAtWith(component: unknown): Promise<Router> {
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: { template: '<div>LibraryStub</div>' } }, { path: '/progress', component: component as never }],
})
await router.push('/progress')
await router.isReady()
return router
}
describe('library completion action', () => {
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear() })
afterEach(() => { vi.restoreAllMocks(); wrapper?.unmount() })
it('marks the open chapter read without touching the word state', async () => {
useSessionStore().user = { ...user }
const library = useLibraryStore()
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const url = String(input)
if (url.includes('/chapters/3') && !url.includes('complete')) {
return ok({
book: { id: 7, title: 'Fictional reader', author: '', language: 'en' },
chapter: { id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', author: '', status: 'ready', charCount: 31, errorReason: '', errorMessage: '', jobId: 9, readAt: null, createdAt: '', updatedAt: '', contentSha256: 'sha', originalText: 'Curiosity opens the first door.\n' },
navigation: { previousChapterId: null, nextChapterId: null },
})
}
if (url.includes('/complete')) {
return ok({ progress: { chapterId: 3, bookId: 7, read: true, readAt: '2026-09-15T03:04:05Z', duplicate: false } })
}
return ok({})
})
await library.loadChapter(3)
expect(library.chapter?.readAt ?? null).toBeNull()
const result = await library.markChapterRead(3)
expect(result.read).toBe(true)
expect(library.chapter?.readAt).toBe('2026-09-15T03:04:05Z')
// Nothing about the chapter's words changes: the request carries no status or level.
const completion = fetchMock.mock.calls.find(([url]) => String(url).includes('/complete'))!
expect(String(completion[1]?.method)).toBe('POST')
expect(completion[1]?.body).toBeUndefined()
expect(library.chapter?.status).toBe('ready')
})
it('reports a failure without pretending the chapter is read', async () => {
useSessionStore().user = { ...user }
const library = useLibraryStore()
library.chapter = { id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', author: '', status: 'ready', charCount: 31, errorReason: '', errorMessage: '', jobId: 9, readAt: null, createdAt: '', updatedAt: '', contentSha256: 'sha', originalText: 'Curiosity opens the first door.\n' }
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => new Response(JSON.stringify({ code: 409, msg: '只有处理完成的章节可以标记已读' }), { status: 409 }))
await expect(library.markChapterRead(3)).rejects.toThrow('只有处理完成的章节可以标记已读')
expect(library.chapter.readAt ?? null).toBeNull()
expect(library.completingChapterId).toBeNull()
})
it('marks the reading state in the reader and repeats without counting twice', async () => {
useSessionStore().user = { ...user }
const library = useLibraryStore()
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: { template: '<div>LibraryStub</div>' } }, { path: '/chapters/:id', component: ReaderView }],
})
await router.push('/chapters/3')
await router.isReady()
let calls = 0
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const url = String(input)
if (url.includes('/complete')) {
calls += 1
return ok({ progress: { chapterId: 3, bookId: 7, read: true, readAt: '2026-09-15T03:04:05Z', duplicate: calls > 1 } })
}
if (url.includes('/tokens')) return ok({ tokens: [] })
if (url.includes('/chapters/3')) {
return ok({
book: { id: 7, title: 'Fictional reader', author: '', language: 'en' },
chapter: { id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', author: '', status: 'ready', charCount: 31, errorReason: '', errorMessage: '', jobId: 9, readAt: null, createdAt: '', updatedAt: '', contentSha256: 'sha', originalText: 'Curiosity opens the first door.\n' },
navigation: { previousChapterId: null, nextChapterId: null },
})
}
return ok({})
})
wrapper = mount(ReaderView, { attachTo: document.body, global: { plugins: [router] } })
await flushPromises()
await flushPromises()
expect(wrapper.get('[data-testid="mark-read"]').text()).toBe('标记本章已读')
await wrapper.get('[data-testid="mark-read"]').trigger('click')
await flushPromises()
expect(wrapper.get('[data-testid="chapter-read-state"]').text()).toContain('本章已读')
expect(wrapper.get('[data-testid="complete-notice"]').text()).toContain('已记为读完本章')
await wrapper.get('[data-testid="mark-read"]').trigger('click')
await flushPromises()
expect(calls).toBe(2)
expect(wrapper.get('[data-testid="complete-notice"]').text()).toContain('没有重复计数')
// The reader stays usable: the text and the chapter navigation are still on screen.
expect(wrapper.find('.reader-nav').exists()).toBe(true)
})
})
+112
View File
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest'
import { adjustTokenRange, MAX_PHRASE_WORDS, normalizeTokenRange, phrasesAt, rangeOfSpan, rangeOfWords, wordIndices } from '../composables/readerRange'
import type { ReaderToken } from '../composables/useReaderLookup'
// The same text #4 verified: interior punctuation, a line break inside the phrase, a repeated
// phrase and a combining accent.
const original = 'Take a small, step\nevery day.\nMira took a small step again.\n'
const tokens: ReaderToken[] = [
{ text: 'Take', start: 0, end: 4, startUtf16: 0, endUtf16: 4, kind: 'word' },
{ text: ' ', start: 4, end: 5, startUtf16: 4, endUtf16: 5, kind: 'space' },
{ text: 'a', start: 5, end: 6, startUtf16: 5, endUtf16: 6, kind: 'word' },
{ text: ' ', start: 6, end: 7, startUtf16: 6, endUtf16: 7, kind: 'space' },
{ text: 'small', start: 7, end: 12, startUtf16: 7, endUtf16: 12, kind: 'word' },
{ text: ',', start: 12, end: 13, startUtf16: 12, endUtf16: 13, kind: 'punctuation' },
{ text: ' ', start: 13, end: 14, startUtf16: 13, endUtf16: 14, kind: 'space' },
{ text: 'step', start: 14, end: 18, startUtf16: 14, endUtf16: 18, kind: 'word' },
{ text: '\n', start: 18, end: 19, startUtf16: 18, endUtf16: 19, kind: 'space' },
{ text: 'every', start: 19, end: 24, startUtf16: 19, endUtf16: 24, kind: 'word' },
{ text: ' ', start: 24, end: 25, startUtf16: 24, endUtf16: 25, kind: 'space' },
{ text: 'day', start: 25, end: 28, startUtf16: 25, endUtf16: 28, kind: 'word' },
{ text: '.', start: 28, end: 29, startUtf16: 28, endUtf16: 29, kind: 'punctuation' },
{ text: '\n', start: 29, end: 30, startUtf16: 29, endUtf16: 30, kind: 'space' },
{ text: 'Mira', start: 30, end: 34, startUtf16: 30, endUtf16: 34, kind: 'word' },
{ text: ' ', start: 34, end: 35, startUtf16: 34, endUtf16: 35, kind: 'space' },
{ text: 'took', start: 35, end: 39, startUtf16: 35, endUtf16: 39, kind: 'word' },
{ text: ' ', start: 39, end: 40, startUtf16: 39, endUtf16: 40, kind: 'space' },
{ text: 'a', start: 40, end: 41, startUtf16: 40, endUtf16: 41, kind: 'word' },
{ text: ' ', start: 41, end: 42, startUtf16: 41, endUtf16: 42, kind: 'space' },
{ text: 'small', start: 42, end: 47, startUtf16: 42, endUtf16: 47, kind: 'word' },
{ text: ' ', start: 47, end: 48, startUtf16: 47, endUtf16: 48, kind: 'space' },
{ text: 'step', start: 48, end: 52, startUtf16: 48, endUtf16: 52, kind: 'word' },
{ text: ' ', start: 52, end: 53, startUtf16: 52, endUtf16: 53, kind: 'space' },
{ text: 'again', start: 53, end: 58, startUtf16: 53, endUtf16: 58, kind: 'word' },
{ text: '.', start: 58, end: 59, startUtf16: 58, endUtf16: 59, kind: 'punctuation' },
{ text: '\n', start: 59, end: 60, startUtf16: 59, endUtf16: 60, kind: 'space' },
]
describe('reader phrase ranges', () => {
it('keeps interior punctuation and line breaks in the selected text', () => {
const range = normalizeTokenRange(tokens, original, 2, 7)
expect(range).not.toBeNull()
expect(range).toMatchObject({ firstWord: 2, lastWord: 7, start: 5, end: 18, wordCount: 3 })
expect(range!.text).toBe('a small, step')
// The second occurrence keeps its own spelling and offsets.
const second = normalizeTokenRange(tokens, original, 18, 22)
expect(second!.text).toBe('a small step')
expect(second!.start).toBe(40)
})
it('aligns both ends to whole words and never selects one word as a phrase', () => {
// A selection that starts on a separator begins at the next whole word, and one that ends
// on a separator ends at the previous one: words outside the selection stay untouched.
expect(normalizeTokenRange(tokens, original, 3, 7)!.firstWord).toBe(4)
expect(normalizeTokenRange(tokens, original, 2, 5)!.lastWord).toBe(4)
expect(normalizeTokenRange(tokens, original, 2, 7)!.text).toBe('a small, step')
expect(normalizeTokenRange(tokens, original, 2, 3)).toBeNull()
expect(normalizeTokenRange(tokens, original, 5, 5)).toBeNull()
expect(normalizeTokenRange(tokens, original, -1, 7)).toBeNull()
expect(normalizeTokenRange(tokens, original, 2, tokens.length + 5)).toBeNull()
})
it('reverses a backwards selection and refuses more than twelve words', () => {
const range = normalizeTokenRange(tokens, original, 7, 2)
expect(range).toMatchObject({ firstWord: 2, lastWord: 7 })
// The fixture is exactly twelve words, which is still a phrase.
expect(normalizeTokenRange(tokens, original, 0, tokens.length - 1)!.wordCount).toBe(MAX_PHRASE_WORDS)
const longer: ReaderToken[] = []
let offset = 0
for (let index = 0; index < MAX_PHRASE_WORDS + 1; index++) {
const word = `word${index}`
longer.push({ text: word, start: offset, end: offset + word.length, startUtf16: offset, endUtf16: offset + word.length, kind: 'word' })
offset += word.length
longer.push({ text: ' ', start: offset, end: offset + 1, startUtf16: offset, endUtf16: offset + 1, kind: 'space' })
offset += 1
}
expect(normalizeTokenRange(longer, longer.map(token => token.text).join(''), 0, longer.length - 1)).toBeNull()
})
it('moves one end by whole words and never inverts the range', () => {
const range = normalizeTokenRange(tokens, original, 2, 7)!
const shorterStart = adjustTokenRange(tokens, original, range, 'start', 1)
expect(shorterStart).toMatchObject({ firstWord: 4, lastWord: 7, wordCount: 2 })
expect(shorterStart!.text).toBe('small, step')
// The start can move left while a word remains before it, and not past the first word.
expect(adjustTokenRange(tokens, original, range, 'start', -1)).toMatchObject({ firstWord: 0, lastWord: 7 })
expect(adjustTokenRange(tokens, original, rangeOfWords(tokens, original, 0, 7)!, 'start', -1)).toBeNull()
const longerEnd = adjustTokenRange(tokens, original, range, 'end', 1)
expect(longerEnd).toMatchObject({ firstWord: 2, lastWord: 9, wordCount: 4 })
expect(longerEnd!.text).toBe('a small, step\nevery')
const back = adjustTokenRange(tokens, original, longerEnd!, 'end', -1)!
expect(back.text).toBe(range.text)
// Moving the end before the start is refused even when the direction is legal.
const twoWords = rangeOfWords(tokens, original, 2, 4)!
expect(adjustTokenRange(tokens, original, twoWords, 'end', -1)).toBeNull()
})
it('finds the phrase covering a word and builds its range again', () => {
const phrases = [
{ id: 1, startToken: 2, endToken: 7, status: 'new', wordCount: 3 },
{ id: 2, startToken: 4, endToken: 7, status: 'learning', wordCount: 2 },
]
// Overlapping spans: the longer phrase wins for a word both cover.
expect(phrasesAt(tokens, phrases, 4)?.id).toBe(1)
expect(phrasesAt(tokens, phrases, 7)?.id).toBe(1)
expect(phrasesAt(tokens, phrases, 0)).toBeNull()
const span = rangeOfSpan(tokens, original, { startToken: 2, endToken: 7 })!
expect(span.text).toBe('a small, step')
expect(rangeOfWords(tokens, original, 4, 7)!.text).toBe('small, step')
// A span that covers a single word is not a phrase.
expect(rangeOfSpan(tokens, original, { startToken: 2, endToken: 2 })).toBeNull()
})
})
+255
View File
@@ -0,0 +1,255 @@
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 { createMemoryHistory, createRouter } from 'vue-router'
import ReviewView from '../views/ReviewView.vue'
import { useSessionStore } from '../stores/session'
import { clozeSentence, maskedPrompt, useReviewStore, type ReviewItem } from '../stores/review'
const item = (overrides: Partial<ReviewItem> = {}): ReviewItem => ({
id: 7, term: 'dogs', originalForm: 'Dogs', definition: '狗', examples: ['Dogs went home.'],
status: 'new', level: 0, kind: 'word', wordCount: 1, dueAt: '2026-09-11T10:00:00Z', reviewCount: 0, ...overrides,
})
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
const answer = (overrides: Record<string, unknown> = {}) => ({
result: 'applied', grade: 'correct', requeued: false, statusBefore: 'new', statusAfter: 'learning',
levelBefore: 0, levelAfter: 1, dueAtBefore: '2026-09-11T10:00:00Z', dueAtAfter: '2026-09-12T10:00:00Z',
item: item({ status: 'learning', level: 1, dueAt: '2026-09-12T10:00:00Z', reviewCount: 1 }), ...overrides,
})
const answerCalls = (fetchMock: MockInstance) => fetchMock.mock.calls.filter(call => String(call[0]).includes('/answers'))
let wrapper: VueWrapper | undefined
describe('review prompt', () => {
it('masks a whole phrase as one blank and keeps the word rule for words', () => {
const phrase = item({ kind: 'phrase', wordCount: 3, term: 'a small step', originalForm: 'a small step', examples: ['Take a small step, every day.'] })
expect(maskedPrompt(phrase)).toBe('Take _____, every day.')
expect(maskedPrompt(item({ ...phrase, examples: ['Take a small,\nstep today.'] }))).toBe('Take _____ today.')
// A phrase that is not in the example is left alone, and a word still masks itself.
expect(maskedPrompt(item({ ...phrase, examples: ['Nothing to mask here.'] }))).toBe('Nothing to mask here.')
expect(maskedPrompt(item({ term: 'curiosity', originalForm: 'curiosity', examples: ['Learning begins with curiosity.'] }))).toBe('Learning begins with _____.')
})
it('masks the word in the first example and keeps other sentences usable', () => {
expect(clozeSentence(item())).toBe('_____ went home.')
expect(clozeSentence(item({ term: "isn't", originalForm: "Isn't", examples: ["It isn’t over."] }))).toBe('It _____ over.')
expect(clozeSentence(item({ examples: ['No matching word here.'] }))).toBe('No matching word here.')
expect(clozeSentence(item({ examples: [] }))).toBeNull()
expect(clozeSentence(item({ term: 'dog', originalForm: 'Dog', examples: ['Dogs are not the saved word.'] }))).toBe('Dogs are not the saved word.')
})
})
describe('review store', () => {
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear(); useSessionStore().user = { id: 42, username: 'fictional', role: 'learner' } })
afterEach(() => { vi.restoreAllMocks() })
it('loads the due queue and answers a word correctly', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
if (String(input).endsWith('/reviews/queue')) return ok({ items: [item()], total: 1 })
return ok(answer())
})
const review = useReviewStore()
await review.load()
expect(review.queue).toHaveLength(1)
expect(review.current?.id).toBe(7)
expect(review.empty).toBe(false)
review.reveal()
expect(review.revealed).toBe(true)
await review.answer('correct')
const body = JSON.parse(String(answerCalls(fetchMock)[0]![1]?.body))
expect(body).toEqual({ answerId: expect.stringMatching(/^[0-9a-f-]{36}$/), grade: 'correct', expectedDueAt: '2026-09-11T10:00:00Z' })
expect(review.queue).toHaveLength(0)
expect(review.answered).toBe(1)
expect(review.correctCount).toBe(1)
expect(review.finished).toBe(true)
expect(review.revealed).toBe(false)
})
it('requeues a word that is not recognised and counts it as wrong', async () => {
const wrong = answer({ grade: 'wrong', requeued: true, levelAfter: 0, statusAfter: 'new', dueAtAfter: '2026-09-11T10:05:00Z', item: item({ dueAt: '2026-09-11T10:05:00Z' }) })
vi.spyOn(globalThis, 'fetch').mockImplementation(async input => String(input).endsWith('/reviews/queue') ? ok({ items: [item()], total: 1 }) : ok(wrong))
const review = useReviewStore()
await review.load()
await review.answer('wrong')
expect(review.queue).toHaveLength(1)
expect(review.queue[0]!.dueAt).toBe('2026-09-11T10:05:00Z')
expect(review.wrongCount).toBe(1)
expect(review.correctCount).toBe(0)
expect(review.wordsReviewed).toBe(1)
expect(review.finished).toBe(false)
})
it('treats a stale answer as no new score but finishes the round instead of reporting an empty queue', async () => {
vi.spyOn(globalThis, 'fetch').mockImplementation(async input => String(input).endsWith('/reviews/queue') ? ok({ items: [item()], total: 1 }) : ok(answer({ result: 'stale', requeued: false })))
const review = useReviewStore()
await review.load()
await review.answer('correct')
expect(review.queue).toHaveLength(0)
expect(review.answered).toBe(0)
expect(review.correctCount).toBe(0)
expect(review.resolved).toBe(1)
expect(review.empty).toBe(false)
expect(review.finished).toBe(true)
expect(review.notice).toContain('已在其他页面复习')
})
it('counts a replay of this client own answer after a lost response', async () => {
let sent = 0
vi.spyOn(globalThis, 'fetch').mockImplementation(async input => {
if (String(input).endsWith('/reviews/queue')) return ok({ items: [item()], total: 1 })
sent += 1
// The first response never reaches the client, the retry reports the recorded answer.
if (sent === 1) throw new Error('网络中断')
return ok(answer({ result: 'applied', duplicate: true }))
})
const review = useReviewStore()
await review.load()
await review.answer('correct')
expect(review.error).toContain('网络中断')
expect(review.answered).toBe(0)
await review.answer('correct')
expect(review.answered).toBe(1)
expect(review.correctCount).toBe(1)
expect(review.wordsReviewed).toBe(1)
expect(review.empty).toBe(false)
expect(review.finished).toBe(true)
expect(review.notice).toBe('')
})
it('keeps the card and the same answer id when a submission fails, then retries once', async () => {
let fail = true
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async input => {
if (String(input).endsWith('/reviews/queue')) return ok({ items: [item()], total: 1 })
if (fail) { fail = false; throw new Error('评分暂时无法提交') }
return ok(answer())
})
const review = useReviewStore()
await review.load()
await review.answer('correct')
expect(review.error).toContain('评分暂时无法提交')
expect(review.queue).toHaveLength(1)
expect(review.answered).toBe(0)
await review.answer('correct')
const calls = answerCalls(fetchMock)
const ids = calls.map(call => JSON.parse(String(call[1]?.body)).answerId)
expect(ids).toHaveLength(2)
expect(ids[0]).toBe(ids[1])
expect(review.queue).toHaveLength(0)
expect(review.answered).toBe(1)
})
it('reports a failed queue load without pretending the queue is empty', async () => {
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('网络暂不可用'))
const review = useReviewStore()
await review.load()
expect(review.error).toContain('网络暂不可用')
expect(review.finished).toBe(false)
expect(review.empty).toBe(false)
})
it('drops the previous account queue and counters when the identity changes', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(ok({ items: [item()], total: 1 }))
const review = useReviewStore()
await review.load()
expect(review.queue).toHaveLength(1)
useSessionStore().user = { id: 43, username: 'other-fictional', role: 'learner' }
await flushPromises()
expect(review.queue).toHaveLength(0)
expect(review.current).toBeNull()
expect(review.finished).toBe(false)
expect(review.empty).toBe(false)
})
it('reports the words still due beyond the fetched page', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(ok({ items: [item()], total: 3 }))
const review = useReviewStore()
await review.load()
expect(review.pending).toBe(2)
})
})
async function open(queueResult: unknown, onAnswer: (body: Record<string, unknown>) => Response | Promise<Response> = () => ok(answer())) {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
if (String(input).endsWith('/reviews/queue')) {
if (queueResult instanceof Error) throw queueResult
return ok(queueResult)
}
return onAnswer(JSON.parse(String(init?.body)))
})
useSessionStore().user = { id: 42, username: 'fictional', role: 'learner' }
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }, { path: '/review', component: ReviewView }] })
await router.push('/review')
wrapper = mount(ReviewView, { attachTo: document.body, global: { plugins: [router] } })
await flushPromises()
return { view: wrapper, router, fetchMock }
}
describe('review page', () => {
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear() })
afterEach(() => { wrapper?.unmount(); wrapper = undefined; vi.restoreAllMocks() })
it('shows the masked example, hides the definition until the answer is revealed, then grades it', async () => {
const { view, fetchMock } = await open({ items: [item()], total: 2 })
expect(view.get('[data-testid="review-position"]').text()).toBe('到期复习 · 1 / 1')
expect(view.text()).toContain('Dogs')
expect(view.text()).toContain('_____ went home.')
expect(view.find('[data-testid="review-definition"]').exists()).toBe(false)
expect(document.activeElement).toBe(view.get('[data-testid="review-reveal"]').element)
await view.get('[data-testid="review-reveal"]').trigger('click'); await flushPromises()
expect(view.get('[data-testid="review-definition"]').text()).toContain('狗')
expect(document.activeElement).toBe(view.get('[data-testid="review-correct"]').element)
await view.get('[data-testid="review-correct"]').trigger('click'); await flushPromises()
expect(answerCalls(fetchMock)).toHaveLength(1)
expect(view.get('[data-testid="review-summary"]').text()).toContain('1 个词条')
})
it('keeps the card and offers a retry when a grade fails', async () => {
const { view } = await open({ items: [item()], total: 1 }, () => { throw new Error('评分暂时无法提交,请重试。') })
await view.get('[data-testid="review-reveal"]').trigger('click'); await flushPromises()
await view.get('[data-testid="review-wrong"]').trigger('click'); await flushPromises()
expect(view.find('[data-testid="review-card"]').exists()).toBe(true)
expect(view.text()).toContain('评分暂时无法提交')
expect(view.find('[data-testid="review-retry"]').exists()).toBe(true)
expect(view.find('[data-testid="review-summary"]').exists()).toBe(false)
})
it('reports an empty queue and a load failure without pretending anything was reviewed', async () => {
const emptyRun = await open({ items: [], total: 0 })
expect(emptyRun.view.get('[data-testid="review-empty"]').text()).toContain('今天没有到期词条')
await emptyRun.view.get('[data-testid="review-finish"]').trigger('click'); await flushPromises()
expect(emptyRun.router.currentRoute.value.path).toBe('/')
wrapper?.unmount(); wrapper = undefined
// A failed load is not an empty queue: it shows the error and a retry.
const failing = await open(new Error('复习队列暂时无法加载'))
expect(failing.view.get('[data-testid="review-reload"]').text()).toContain('重试')
expect(failing.view.text()).toContain('复习队列暂时无法加载')
expect(failing.view.find('[data-testid="review-empty"]').exists()).toBe(false)
vi.restoreAllMocks()
vi.spyOn(globalThis, 'fetch').mockResolvedValue(ok({ items: [item()], total: 1 }))
await failing.view.get('[data-testid="review-reload"]').trigger('click'); await flushPromises()
expect(failing.view.get('[data-testid="review-position"]').text()).toBe('到期复习 · 1 / 1')
})
it('offers the remaining due words after a round finishes', async () => {
const { view } = await open({ items: [item()], total: 3 })
await view.get('[data-testid="review-reveal"]').trigger('click'); await flushPromises()
await view.get('[data-testid="review-correct"]').trigger('click'); await flushPromises()
expect(view.get('[data-testid="review-more"]').text()).toContain('继续复习')
expect(view.text()).toContain('还有 2 个词条到期')
})
it('tells the learner when a card was already reviewed elsewhere', async () => {
const { view } = await open({ items: [item()], total: 1 }, () => ok(answer({ result: 'stale', duplicate: false })))
await view.get('[data-testid="review-reveal"]').trigger('click'); await flushPromises()
await view.get('[data-testid="review-correct"]').trigger('click'); await flushPromises()
expect(view.get('[data-testid="review-notice"]').text()).toContain('已在其他页面复习')
expect(view.get('[data-testid="review-summary"]').text()).toContain('本轮没有新的计分')
})
it('ends the round without submitting anything', async () => {
const { view, router, fetchMock } = await open({ items: [item()], total: 1 })
await view.get('[data-testid="review-end"]').trigger('click'); await flushPromises()
expect(router.currentRoute.value.path).toBe('/')
expect(answerCalls(fetchMock)).toHaveLength(0)
})
})
+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)
})
})
+223
View File
@@ -0,0 +1,223 @@
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, type Router } from 'vue-router'
import ImportView from '../views/ImportView.vue'
import { fileProblem, fileSizeLabel, TXT_MAX_BYTES, useLibraryStore } from '../stores/library'
import { useSessionStore } from '../stores/session'
const user = { id: 42, username: 'fictional-uploader', role: 'learner' as const }
const book = { id: 1, title: 'Uploaded Book', language: 'en' }
const chapter = { id: 9, bookId: 1, ordinal: 1, title: 'Uploaded Book', status: 'pending', charCount: 12, errorReason: '', errorMessage: '', jobId: 5, createdAt: '', updatedAt: '' }
const timestamps = { createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' }
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
let wrapper: VueWrapper | undefined
function stub(name: string) {
return { template: `<div>${name}</div>` }
}
async function viewAt(path: string): Promise<Router> {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: stub('LibraryStub') },
{ path: '/import', component: stub('ImportStub') },
{ path: '/books/:id', component: stub('BookStub') },
],
})
await router.push(path)
await router.isReady()
return router
}
/** jsdom has no file picker, so the input's files are set directly before dispatching change. */
async function chooseFile(view: VueWrapper, file: File | null): Promise<void> {
const input = view.get('[data-testid="file-input"]')
Object.defineProperty(input.element, 'files', { value: file ? [file] : [], configurable: true })
await input.trigger('change')
await flushPromises()
}
/** The TXT toggle is an Element Plus radio group; its hidden input carries the value. */
async function useTxt(view: VueWrapper): Promise<void> {
for (const input of view.findAll('input[type="radio"]')) {
if ((input.element as HTMLInputElement).value === 'txt') {
await input.setValue()
await flushPromises()
return
}
}
throw new Error('TXT 文件 toggle not found')
}
function uploadResponse() {
return ok({ book, chapter, job: { id: 5, bookId: 1, chapterId: 9, status: 'pending', attempts: 0, errorReason: '', errorMessage: '', ...timestamps }, duplicate: false })
}
describe('txt upload pre-checks', () => {
it('mirrors the server limits for the file that was picked', () => {
expect(fileProblem({ name: 'reading.txt', size: 2048 })).toBe('')
expect(fileProblem({ name: 'READING.TXT', size: 1 })).toBe('')
expect(fileProblem({ name: 'reading.md', size: 2048 })).toContain('.txt')
expect(fileProblem({ name: 'empty.txt', size: 0 })).toContain('空的')
expect(fileProblem({ name: 'big.txt', size: TXT_MAX_BYTES + 1 })).toContain('2 MiB')
expect(fileProblem({ name: 'limit.txt', size: TXT_MAX_BYTES })).toBe('')
expect(fileSizeLabel(512)).toBe('512 B')
expect(fileSizeLabel(2048)).toBe('2 KB')
expect(fileSizeLabel(1.5 * 1024 * 1024)).toBe('1.5 MB')
})
})
describe('txt upload view', () => {
beforeEach(() => {
setActivePinia(createPinia())
sessionStorage.clear()
vi.restoreAllMocks()
useSessionStore().user = { ...user }
})
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
useLibraryStore().stopPolling()
})
it('shows the file picker instead of the textarea and reports the picked file', async () => {
const router = await viewAt('/import')
wrapper = mount(ImportView, { global: { plugins: [router] } })
await flushPromises()
// Paste mode keeps the textarea; TXT mode replaces it with the picker.
expect(wrapper.find('textarea#text').exists()).toBe(true)
expect(wrapper.find('[data-testid="file-input"]').exists()).toBe(false)
await useTxt(wrapper)
expect(wrapper.find('textarea#text').exists()).toBe(false)
expect(wrapper.find('[data-testid="file-input"]').exists()).toBe(true)
expect(wrapper.text()).toContain('仅支持 UTF-8')
await chooseFile(wrapper, new File(['Mira opened the workshop.\n'], 'reading.txt', { type: 'text/plain' }))
expect(wrapper.get('[data-testid="file-info"]').text()).toBe('reading.txt · UTF-8 · 26 B')
// The title is prefilled from the file name and stays editable.
expect((wrapper.get('input#title').element as HTMLInputElement).value).toBe('reading')
})
it('refuses a file the server would refuse, before anything is sent', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
const router = await viewAt('/import')
wrapper = mount(ImportView, { global: { plugins: [router] } })
await flushPromises()
await useTxt(wrapper)
await chooseFile(wrapper, new File(['# not a txt file\n'], 'notes.md'))
expect(wrapper.text()).toContain('请选择 .txt 文件。')
// A file that is not valid UTF-8 is rejected by the preview decode.
await chooseFile(wrapper, new File([new Uint8Array([0x63, 0x61, 0x66, 0xe9, 0x0a])], 'latin1.txt'))
expect(wrapper.text()).toContain('文件不是 UTF-8 编码')
await wrapper.find('input#title').setValue('Latin One')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(fetchMock).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('文件不是 UTF-8 编码')
// No file at all is also refused locally.
await chooseFile(wrapper, null)
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(fetchMock).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('请选择要导入的 TXT 文件。')
})
it('uploads the file as multipart and opens the created book', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(uploadResponse())
const router = await viewAt('/import')
wrapper = mount(ImportView, { global: { plugins: [router] } })
await flushPromises()
await useTxt(wrapper)
const file = new File(['Mira opened the workshop.\n'], 'reading.txt', { type: 'text/plain' })
await chooseFile(wrapper, file)
await wrapper.find('input#title').setValue('上传的虚构章节')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0]!
expect(String(url)).toBe('/api/v1/books/upload')
expect(init?.method).toBe('POST')
// A multipart body must not be replaced by JSON and must keep the browser's boundary.
expect(init?.body).toBeInstanceOf(FormData)
expect((init?.headers as Record<string, string>)['Content-Type']).toBeUndefined()
const body = init?.body as FormData
expect(body.get('requestId')).toMatch(/^[0-9a-f-]{36}$/)
expect(body.get('title')).toBe('上传的虚构章节')
expect(body.get('language')).toBe('en')
expect((body.get('file') as File).name).toBe('reading.txt')
expect(await (body.get('file') as File).text()).toBe('Mira opened the workshop.\n')
expect(router.currentRoute.value.path).toBe('/books/1')
// The form starts clean, so the same file is not submitted twice by accident.
expect((wrapper.get('[data-testid="file-input"]').element as HTMLInputElement).value).toBe('')
})
it('reuses one request id when the same upload is retried after a failure', async () => {
let attempt = 0
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
attempt += 1
if (attempt === 1) throw new Error('上传中断')
return uploadResponse()
})
const router = await viewAt('/import')
wrapper = mount(ImportView, { global: { plugins: [router] } })
await flushPromises()
await useTxt(wrapper)
await chooseFile(wrapper, new File(['Body.\n'], 'retry.txt'))
await wrapper.find('input#title').setValue('Retry Upload')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain('上传中断')
// The picked file stays selected so the learner can retry without choosing it again.
expect(wrapper.get('[data-testid="file-info"]').text()).toContain('retry.txt')
await wrapper.find('form').trigger('submit')
await flushPromises()
const ids = fetchMock.mock.calls.map(call => (call[1]?.body as FormData).get('requestId'))
expect(ids).toHaveLength(2)
expect(ids[0]).toBe(ids[1])
expect(router.currentRoute.value.path).toBe('/books/1')
})
it('appends an uploaded chapter to a chosen book without a language field', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const url = String(input)
if (url.endsWith('/books')) return ok({ items: [{ ...book, chapterCount: 1, pendingCount: 0, processingCount: 0, readyCount: 1, failedCount: 0, ...timestamps }] })
expect(url).toBe('/api/v1/books/1/chapters/upload')
const body = init?.body as FormData
expect(body.get('language')).toBeNull()
return ok({ chapter: { ...chapter, id: 10, ordinal: 2 }, job: { id: 6, bookId: 1, chapterId: 10, status: 'pending', attempts: 0, errorReason: '', errorMessage: '', ...timestamps }, duplicate: false })
})
const router = await viewAt('/import?book=1')
wrapper = mount(ImportView, { global: { plugins: [router] } })
await flushPromises()
await useTxt(wrapper)
await chooseFile(wrapper, new File(['Second chapter.\n'], 'second.txt'))
await wrapper.find('input#title').setValue('Appended Chapter')
await wrapper.find('form').trigger('submit')
await flushPromises()
const posts = fetchMock.mock.calls.filter(call => String(call[0]).includes('/upload'))
expect(posts).toHaveLength(1)
expect(router.currentRoute.value.path).toBe('/books/1')
})
it('keeps the server message and the form when the upload is rejected', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ code: 400, msg: '文件不是 UTF-8 编码,请另存为 UTF-8 后重试' }), { status: 400 }))
const router = await viewAt('/import')
wrapper = mount(ImportView, { global: { plugins: [router] } })
await flushPromises()
await useTxt(wrapper)
await chooseFile(wrapper, new File(['Body.\n'], 'server-rejects.txt'))
await wrapper.find('input#title').setValue('Server Rejects')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain('文件不是 UTF-8 编码,请另存为 UTF-8 后重试')
expect(wrapper.get('[data-testid="file-info"]').text()).toContain('server-rejects.txt')
expect(router.currentRoute.value.path).toBe('/import')
})
})
+259
View File
@@ -0,0 +1,259 @@
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, type Router } from 'vue-router'
import { ElSelect } from 'element-plus'
import VocabView from '../views/VocabView.vue'
import { useSessionStore } from '../stores/session'
import { VOCAB_PAGE_SIZE, useVocabularyStore, type VocabularyTerm } from '../stores/vocabulary'
const user = { id: 42, username: 'fictional-vocab', role: 'learner' as const }
const word = (overrides: Partial<VocabularyTerm> = {}): VocabularyTerm => ({
id: 1, term: 'curiosity', originalForm: 'curiosity', definition: '好奇心;求知欲', examples: ['Learning begins with curiosity.'],
status: 'new', level: 0, kind: 'word', wordCount: 1, updatedAt: '2026-01-02T00:00:00Z', ...overrides,
})
const phrase = (overrides: Partial<VocabularyTerm> = {}): VocabularyTerm => ({
id: 2, term: 'a small step', originalForm: 'a small step', definition: '一小步', examples: ['Take a small step.'],
status: 'learning', level: 3, kind: 'phrase', wordCount: 3, updatedAt: '2026-01-03T00:00:00Z', ...overrides,
})
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
let wrapper: VueWrapper | undefined
function listCalls(fetchMock: MockInstance): URLSearchParams[] {
return fetchMock.mock.calls
.filter(([url]) => String(url).includes('/terms?'))
.map(([url]) => new URLSearchParams(String(url).split('?')[1]))
}
function mockApi(items: VocabularyTerm[], total = items.length, onPatch?: (body: Record<string, unknown>) => VocabularyTerm): MockInstance {
return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const url = String(input)
if (url.includes('/terms?') || url.endsWith('/terms')) {
const params = new URLSearchParams(url.split('?')[1] ?? '')
const query = (params.get('query') ?? '').toLowerCase()
const status = params.get('status') ?? ''
const kind = params.get('kind') ?? ''
const page = Number(params.get('page') ?? 1)
const limit = Number(params.get('limit') ?? VOCAB_PAGE_SIZE)
const filtered = items.filter(item =>
(!query || item.term.includes(query) || item.originalForm.toLowerCase().includes(query) || item.definition.includes(query))
&& (!status || item.status === status) && (!kind || item.kind === kind))
return ok({ items: filtered.slice((page - 1) * limit, page * limit), total: Math.max(total, filtered.length), page, limit })
}
if (url.includes('/terms/') && String(init?.method) === 'PATCH') {
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
const base = items.find(item => url.endsWith(`/terms/${item.id}`)) ?? word()
return ok({ term: onPatch ? onPatch(body) : { ...base, ...body } })
}
return ok({})
})
}
async function viewAt(path: string): Promise<Router> {
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: { template: '<div>LibraryStub</div>' } }, { path: '/vocab', component: VocabView }, { path: '/review', component: { template: '<div>ReviewStub</div>' } }],
})
await router.push(path)
await router.isReady()
return router
}
async function openVocab(path = '/vocab') {
useSessionStore().user = { ...user }
const router = await viewAt(path)
wrapper = mount(VocabView, { attachTo: document.body, global: { plugins: [router] } })
// The list lands one microtask after mount; flush twice so the first assertion sees it.
await flushPromises()
await flushPromises()
return { view: wrapper, router }
}
describe('vocabulary store', () => {
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear() })
afterEach(() => { vi.restoreAllMocks() })
it('loads one page with the current filters and reports paging state', async () => {
const fetchMock = mockApi([word(), phrase()], 45)
useSessionStore().user = { ...user }
const vocabulary = useVocabularyStore()
await vocabulary.load()
const params = listCalls(fetchMock)[0]!
expect(params.get('page')).toBe('1')
expect(params.get('limit')).toBe(String(VOCAB_PAGE_SIZE))
expect(params.get('query')).toBeNull()
expect(vocabulary.items).toHaveLength(2)
expect(vocabulary.total).toBe(45)
expect(vocabulary.totalPages).toBe(3)
})
it('applies search and filters from the first page and can clear them', async () => {
const fetchMock = mockApi([word(), phrase()])
useSessionStore().user = { ...user }
const vocabulary = useVocabularyStore()
vocabulary.page = 3
vocabulary.query = 'small'
vocabulary.status = 'learning'
vocabulary.kind = 'phrase'
await vocabulary.apply()
expect(vocabulary.page).toBe(1)
const params = listCalls(fetchMock)[0]!
expect(params.get('query')).toBe('small')
expect(params.get('status')).toBe('learning')
expect(params.get('kind')).toBe('phrase')
expect(vocabulary.items.map(item => item.id)).toEqual([2])
await vocabulary.clearFilters()
expect(vocabulary.hasFilters).toBe(false)
expect(vocabulary.items).toHaveLength(2)
})
it('clamps paging to the available pages', async () => {
const fetchMock = mockApi([word()], 5)
useSessionStore().user = { ...user }
const vocabulary = useVocabularyStore()
await vocabulary.load()
await vocabulary.goToPage(99)
expect(vocabulary.page).toBe(1)
expect(listCalls(fetchMock)).toHaveLength(1)
})
it('keeps the list when loading fails and reports the message', async () => {
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('网络暂不可用'))
useSessionStore().user = { ...user }
const vocabulary = useVocabularyStore()
await vocabulary.load()
expect(vocabulary.error).toContain('网络暂不可用')
expect(vocabulary.items).toHaveLength(0)
})
it('replaces the edited row and drops the previous account state', async () => {
mockApi([word(), phrase()], 2, body => ({ ...word(), definition: String(body.definition), status: 'learning', level: Number(body.level ?? 1) }))
useSessionStore().user = { ...user }
const vocabulary = useVocabularyStore()
await vocabulary.load()
const updated = await vocabulary.updateTerm(1, { definition: '好奇心(改)', examples: [], status: 'learning', level: 4 })
expect(updated.level).toBe(4)
expect(vocabulary.items[0]).toMatchObject({ definition: '好奇心(改)', status: 'learning', level: 4 })
useSessionStore().user = { id: 43, username: 'other-fictional', role: 'learner' }
await flushPromises()
expect(vocabulary.items).toHaveLength(0)
expect(vocabulary.total).toBe(0)
})
})
describe('vocabulary view', () => {
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear(); vi.restoreAllMocks() })
afterEach(() => { wrapper?.unmount(); wrapper = undefined })
it('searches and filters through the toolbar and shows the server result', async () => {
const fetchMock = mockApi([word(), phrase()], 2)
const { view } = await openVocab()
expect(view.findAll('.vocab-row')).toHaveLength(2)
// The phrase row carries its kind and level.
expect(view.text()).toContain('短语 · 学习中 · 等级 3')
await view.get('#vocab-query').setValue('small')
await view.get('[data-testid="vocab-search"]').trigger('click')
await flushPromises()
expect(listCalls(fetchMock)[listCalls(fetchMock).length - 1]!.get('query')).toBe('small')
expect(view.findAll('.vocab-row')).toHaveLength(1)
expect(view.text()).toContain('一小步')
})
it('filters by status immediately and mirrors everything into the URL', async () => {
const fetchMock = mockApi([word(), phrase()], 2)
const { view, router } = await openVocab()
const statusSelect = view.findAllComponents(ElSelect)[0]!
statusSelect.vm.$emit('update:modelValue', 'learning')
statusSelect.vm.$emit('change', 'learning')
await flushPromises()
await flushPromises()
expect(listCalls(fetchMock)[listCalls(fetchMock).length - 1]!.get('status')).toBe('learning')
expect(view.findAll('.vocab-row')).toHaveLength(1)
expect(router.currentRoute.value.query.status).toBe('learning')
})
it('restores the filters written in the URL', async () => {
const fetchMock = mockApi([word(), phrase()], 2)
const { view } = await openVocab('/vocab?query=curio&kind=word&page=1')
expect(listCalls(fetchMock)[0]!.get('query')).toBe('curio')
expect(listCalls(fetchMock)[0]!.get('kind')).toBe('word')
expect((view.get('#vocab-query').element as HTMLInputElement).value).toBe('curio')
expect(view.findAll('.vocab-row')).toHaveLength(1)
})
it('offers clearing the filters when nothing matches, and an empty state otherwise', async () => {
mockApi([word()], 1)
const { view } = await openVocab()
await view.get('#vocab-query').setValue('zzzz')
await view.get('[data-testid="vocab-search"]').trigger('click')
await flushPromises()
expect(view.get('[data-testid="vocab-none"]').text()).toContain('没有匹配的词条')
await view.get('[data-testid="vocab-clear"]').trigger('click')
await flushPromises()
expect(view.findAll('.vocab-row')).toHaveLength(1)
wrapper?.unmount()
mockApi([], 0)
const empty = await openVocab()
expect(empty.view.get('[data-testid="vocab-empty"]').text()).toContain('生词本还是空的')
})
it('edits an entry with a level selector and keeps the filters after saving', async () => {
const fetchMock = mockApi([word(), phrase()], 2, body => ({ ...phrase(), definition: String(body.definition), status: 'learning', level: Number(body.level ?? 3) }))
const { view } = await openVocab()
const statusSelect = view.findAllComponents(ElSelect)[0]!
statusSelect.vm.$emit('update:modelValue', 'learning')
statusSelect.vm.$emit('change', 'learning')
await flushPromises()
await view.get('[data-testid="vocab-edit-2"]').trigger('click')
await flushPromises()
// The dialog is prefilled and shows the level selector for a learning entry.
expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('一小步')
expect(view.find('[data-testid="term-level"]').exists()).toBe(true)
await view.get('#term-definition').setValue('一小步(改)')
await view.get('[data-testid="vocab-save"]').trigger('click')
await flushPromises()
const patch = fetchMock.mock.calls.find(([, init]) => String((init as RequestInit | undefined)?.method) === 'PATCH')!
expect(JSON.parse(String((patch[1] as RequestInit).body))).toMatchObject({ definition: '一小步(改)', status: 'learning', level: 3 })
expect(view.get('[data-testid="vocab-notice"]').text()).toContain('已保存')
expect(view.get('#vocab-status')).toBeTruthy()
expect(view.findAll('.vocab-row')).toHaveLength(1)
})
it('hides the level selector for other statuses and keeps the input when saving fails', async () => {
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
if (String(init?.method) === 'PATCH') return new Response(JSON.stringify({ code: 400, msg: '学习等级须为 1~7' }), { status: 400 })
return ok({ items: [word()], total: 1 })
})
const { view } = await openVocab()
await view.get('[data-testid="vocab-edit-1"]').trigger('click')
await flushPromises()
expect(view.find('[data-testid="term-level"]').exists()).toBe(false)
await view.get('#term-definition').setValue('改过的释义')
await view.get('[data-testid="vocab-save"]').trigger('click')
await flushPromises()
expect(view.text()).toContain('学习等级须为 1~7')
expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('改过的释义')
expect(view.find('[data-testid="vocab-dialog"]').isVisible()).toBe(true)
})
it('pages forward and back with the server totals', async () => {
// Three real pages of entries, so paging back and forth has content both ways.
const many = Array.from({ length: 45 }, (_, index) => word({ id: index + 1, term: `word ${index + 1}` }))
const fetchMock = mockApi(many, 45)
const { view } = await openVocab()
expect(view.get('.vocab-paging').text()).toContain('第 1 / 3 页')
expect(view.findAll('.vocab-row')).toHaveLength(VOCAB_PAGE_SIZE)
await view.get('[data-testid="vocab-next"]').trigger('click')
await flushPromises()
expect(listCalls(fetchMock)[listCalls(fetchMock).length - 1]!.get('page')).toBe('2')
await view.get('[data-testid="vocab-prev"]').trigger('click')
await flushPromises()
expect(listCalls(fetchMock)[listCalls(fetchMock).length - 1]!.get('page')).toBe('1')
})
it('links to the due review from the list', async () => {
mockApi([word()], 1)
const { view } = await openVocab()
expect(view.get('.page-actions a[href="/review"]').text()).toContain('开始到期复习')
})
})
+184
View File
@@ -0,0 +1,184 @@
<script setup lang="ts">
import { computed, onScopeDispose, ref, watch } from 'vue'
import { ElButton } from 'element-plus'
/**
* The audio player for one book. It only appears when the book has audio, never starts on its own,
* and remembers the position per account so reading can continue where it stopped. Nothing here
* aligns the audio with the text: the two are independent, as the ticket requires.
*/
const props = defineProps<{
src: string
/** Where this account stopped last time, in seconds. */
initialPosition: number
/** Called with the position while playing, when pausing and when leaving. */
onPosition: (seconds: number) => void
}>()
const audio = ref<HTMLAudioElement | null>(null)
const playing = ref(false)
const position = ref(props.initialPosition)
const duration = ref(0)
const rate = ref(1)
const error = ref('')
const rates = [0.75, 1, 1.25, 1.5]
const positionLabel = computed(() => formatClock(position.value))
const durationLabel = computed(() => (duration.value > 0 ? formatClock(duration.value) : '--:--'))
function formatClock(seconds: number): string {
const total = Math.max(0, Math.floor(seconds))
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`
}
async function toggle(): Promise<void> {
const element = audio.value
if (!element) return
error.value = ''
if (element.paused) {
try {
await element.play()
} catch {
// A rejected play() is usually a blocked autoplay attempt or a missing file.
error.value = '音频无法播放,请重试。'
playing.value = false
}
return
}
element.pause()
}
function onLoaded(): void {
const element = audio.value
if (!element) return
duration.value = Number.isFinite(element.duration) ? element.duration : 0
// The stored position is applied once the metadata is known, so seeking actually lands.
if (props.initialPosition > 0 && props.initialPosition < duration.value) {
element.currentTime = props.initialPosition
}
}
function onSeek(event: Event): void {
const element = audio.value
if (!element) return
element.currentTime = Number((event.target as HTMLInputElement).value)
position.value = element.currentTime
}
function setRate(value: number): void {
rate.value = value
if (audio.value) audio.value.playbackRate = value
}
/** Reports the position on a fixed cadence while playing, so a long listen is not lost. */
let reportTimer: number | undefined
function onTimeUpdate(): void {
const element = audio.value
if (!element) return
position.value = element.currentTime
if (reportTimer !== undefined) return
reportTimer = window.setInterval(() => {
reportTimer = undefined
if (audio.value && !audio.value.paused) props.onPosition(audio.value.currentTime)
startReporting()
}, 5000)
}
function startReporting(): void {
if (reportTimer !== undefined) return
reportTimer = window.setInterval(() => {
if (audio.value && !audio.value.paused) props.onPosition(audio.value.currentTime)
}, 5000)
}
function stopReporting(): void {
if (reportTimer !== undefined) {
window.clearInterval(reportTimer)
reportTimer = undefined
}
}
function onPause(): void {
playing.value = false
stopReporting()
if (audio.value) props.onPosition(audio.value.currentTime)
}
function onPlay(): void {
playing.value = true
startReporting()
}
function retry(): void {
error.value = ''
const element = audio.value
if (element) {
element.load()
void toggle()
}
}
watch(() => props.src, () => {
error.value = ''
position.value = props.initialPosition
playing.value = false
})
// Leaving the page stores the last position, so the same book continues where it stopped.
onScopeDispose(() => {
stopReporting()
if (audio.value) props.onPosition(audio.value.currentTime)
})
</script>
<template>
<section class="audio-player" aria-label="音频播放" data-testid="audio-player">
<ElButton
circle
type="primary"
data-testid="audio-toggle"
:aria-label="playing ? '暂停音频' : '播放音频'"
@click="toggle"
>{{ playing ? '❚❚' : '▶' }}</ElButton>
<span class="audio-clock" data-testid="audio-clock">{{ positionLabel }} / {{ durationLabel }}</span>
<input
class="audio-range"
type="range"
min="0"
:max="duration > 0 ? duration : 0"
step="1"
:value="position"
aria-label="播放进度"
data-testid="audio-seek"
:disabled="duration === 0"
@input="onSeek"
/>
<div class="audio-rates" role="group" aria-label="播放速度">
<button
v-for="value in rates"
:key="value"
type="button"
class="audio-rate"
:class="{ 'is-active': rate === value }"
:aria-pressed="rate === value"
:data-testid="`audio-rate-${value}`"
@click="setRate(value)"
>{{ value }}×</button>
</div>
<span v-if="error" role="alert" class="audio-error" data-testid="audio-error">{{ error }}
<ElButton text size="small" data-testid="audio-retry" @click="retry">重试</ElButton>
</span>
<audio
ref="audio"
:src="src"
preload="metadata"
data-testid="audio-element"
@loadedmetadata="onLoaded"
@timeupdate="onTimeUpdate"
@play="onPlay"
@pause="onPause"
@ended="onPause"
@error="error = '音频加载失败。'"
/>
</section>
</template>
@@ -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>
+62 -13
View File
@@ -1,10 +1,32 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { ElButton } from 'element-plus'
import type { LookupResult } from '../composables/useReaderLookup'
defineProps<{ word: string; result: LookupResult | null; loading: boolean; error: string }>()
const draft = defineModel<string>('draft', { required: true })
const emit = defineEmits<{ close: []; retry: []; resize: [top: number] }>()
import TermFormFields from './TermFormFields.vue'
import { type LookupResult, type TermStatus } from '../composables/useReaderLookup'
defineProps<{
word: string
// Set when the selection is a phrase: the panel edits that phrase instead of a word.
phrase: { wordCount: number; stored: boolean } | null
adjust: { startLeft: boolean; startRight: boolean; endLeft: boolean; endRight: boolean } | null
result: LookupResult | null
loading: boolean
error: string
saving: boolean
saveError: string
saved: string
savedTermId: number | null
prefilling: boolean
prefillError: string
canSave: boolean
}>()
const definition = defineModel<string>('definition', { required: true })
const examples = defineModel<string>('examples', { required: true })
const status = defineModel<TermStatus>('status', { required: true })
// The reader panel keeps the four statuses only, so the level field exists for the shared form
// but is never shown here and never sent: the server keeps the earned level.
const level = ref(1)
const emit = defineEmits<{ close: []; retry: []; save: []; resize: [top: number]; adjust: [edge: 'start' | 'end', direction: -1 | 1] }>()
const heading = ref<HTMLElement | null>(null)
const panel = ref<HTMLElement | null>(null)
let observer: ResizeObserver | undefined
@@ -26,10 +48,22 @@ onUnmounted(() => { observer?.disconnect(); window.removeEventListener('resize',
<template>
<aside ref="panel" class="lookup-panel" aria-labelledby="lookup-heading" @keydown.esc.stop.prevent="$emit('close')">
<header class="lookup-heading">
<h2 id="lookup-heading" ref="heading" tabindex="-1">{{ word }}</h2>
<h2 id="lookup-heading" ref="heading" tabindex="-1">
{{ word }}
<span v-if="result?.phonetic" class="lookup-phonetic" data-testid="lookup-phonetic" lang="en">/{{ result.phonetic }}/</span>
</h2>
<ElButton text aria-label="关闭释义" @click="$emit('close')">关闭</ElButton>
</header>
<div class="lookup-content" aria-live="polite" :aria-busy="loading">
<div v-if="phrase" class="lookup-range" data-testid="phrase-range">
<p class="subtle">短语 · {{ phrase.wordCount }} 个单词{{ phrase.stored ? ' · 已保存' : '' }}</p>
<div class="lookup-range-actions">
<ElButton size="small" data-testid="range-start-left" :disabled="!adjust?.startLeft" @click="$emit('adjust', 'start', -1)">起点 ←</ElButton>
<ElButton size="small" data-testid="range-start-right" :disabled="!adjust?.startRight" @click="$emit('adjust', 'start', 1)">起点 →</ElButton>
<ElButton size="small" data-testid="range-end-left" :disabled="!adjust?.endLeft" @click="$emit('adjust', 'end', -1)">终点 ←</ElButton>
<ElButton size="small" data-testid="range-end-right" :disabled="!adjust?.endRight" @click="$emit('adjust', 'end', 1)">终点 →</ElButton>
</div>
</div>
<div v-if="!phrase" class="lookup-content" aria-live="polite" :aria-busy="loading">
<p v-if="loading" role="status" class="subtle">正在查询…</p>
<p v-else-if="error" role="alert" class="lookup-message">{{ error }}</p>
<template v-else-if="result">
@@ -37,19 +71,34 @@ onUnmounted(() => { observer?.disconnect(); window.removeEventListener('resize',
<p v-else-if="result.status === 'not_found'" class="lookup-message">未找到释义。</p>
<p v-if="result.status === 'lemma'" class="subtle">词形匹配:{{ result.matchedForm }}(规则候选)</p>
<ol v-if="result.entries.length" class="lookup-senses">
<li v-for="(entry, index) in result.entries" :key="index">
<p class="sense-heading"><strong>{{ entry.lemma }}</strong> <span>{{ entry.pos }}</span></p>
<p lang="en">{{ entry.definition }}</p>
<li v-for="(entry, index) in result.entries" :key="index" :data-provider="entry.provider || ''">
<p class="sense-heading"><strong>{{ entry.lemma }}</strong> <span v-if="entry.pos">{{ entry.pos }}</span></p>
<p v-if="entry.translation" class="lookup-translation" data-testid="lookup-translation">{{ entry.translation }}</p>
<p v-if="entry.definition" lang="en">{{ entry.definition }}</p>
<blockquote v-for="(example, exampleIndex) in entry.examples" :key="exampleIndex" lang="en">{{ example }}</blockquote>
</li>
</ol>
<p v-if="result.resource" class="subtle">{{ result.resource.name }} · {{ result.resource.version }}</p>
<p v-if="result.resources?.length || result.resource" class="subtle">
{{ (result.resources?.length ? result.resources : [result.resource!]).map(item => `${item.name} · ${item.version}`).join(';') }}
</p>
</template>
<ElButton v-if="!loading && (error || result?.status === 'resource_missing' || result?.status === 'not_found')" data-testid="lookup-retry" @click="$emit('retry')">重试查询</ElButton>
</div>
<div class="lookup-draft">
<label for="definition-draft">个人释义 <span>未保存</span></label>
<textarea id="definition-draft" v-model="draft" rows="3" placeholder="记下当前词的释义" />
<TermFormFields
v-model:definition="definition"
v-model:examples="examples"
v-model:status="status"
v-model:level="level"
:hint="savedTermId ? '已保存' : '新词条'"
:disabled="saving || prefilling || !!prefillError"
/>
<p v-if="prefilling" role="status" class="subtle">正在读取已保存的内容…</p>
<p v-else-if="prefillError" role="alert" class="lookup-message">{{ prefillError }}</p>
<p v-if="saved" role="status" class="lookup-saved">✓ {{ saved }}</p>
<p v-if="saveError" role="alert" class="lookup-message">{{ saveError }}</p>
<div class="lookup-actions">
<ElButton type="primary" data-testid="term-save" :loading="saving" :disabled="!canSave" @click="$emit('save')">保存到生词本</ElButton>
<ElButton text @click="$emit('close')">关闭,继续阅读</ElButton>
</div>
</aside>
</template>
+43 -5
View File
@@ -1,10 +1,48 @@
<script setup lang="ts">
import type { ReaderToken } from '../composables/useReaderLookup'
defineProps<{ tokens: ReaderToken[]; original: string; selectedStart?: number }>()
const emit = defineEmits<{ select: [token: ReaderToken, element: HTMLElement] }>()
function select(token: ReaderToken, event: Event) { emit('select', token, event.currentTarget as HTMLElement) }
import { termStatusOf, type PhraseSpan, type ReaderToken } from '../composables/useReaderLookup'
defineProps<{ tokens: ReaderToken[]; phrases: PhraseSpan[]; original: string; selectedStart?: number; phraseStart?: number | null }>()
const emit = defineEmits<{
select: [token: ReaderToken, element: HTMLElement, extend: boolean]
selectPhrase: [span: PhraseSpan, element: HTMLElement]
}>()
// A token belongs to the phrase that covers it; the spans come from the server match, so the
// reader never re-derives which words form a phrase.
function spanOf(phrases: PhraseSpan[], index: number): PhraseSpan | null {
let best: PhraseSpan | null = null
for (const phrase of phrases) {
if (index < phrase.startToken || index > phrase.endToken) continue
if (best === null || phrase.endToken - phrase.startToken > best.endToken - best.startToken) best = phrase
}
return best
}
function select(token: ReaderToken, event: Event, phrase: PhraseSpan | null): void {
const element = event.currentTarget as HTMLElement
if (phrase && !(event as MouseEvent).shiftKey) {
emit('selectPhrase', phrase, element)
return
}
emit('select', token, element, (event as MouseEvent).shiftKey === true)
}
</script>
<template>
<article class="reader-text"><template v-if="tokens.length"><template v-for="token in tokens" :key="token.start"><span v-if="token.kind === 'word'" role="button" tabindex="0" class="reader-word" :class="{ 'is-selected': selectedStart === token.start }" :aria-label="`查询 ${token.text}`" :aria-pressed="selectedStart === token.start" @click="select(token, $event)" @keydown.enter.prevent="select(token, $event)" @keydown.space.prevent="select(token, $event)">{{ token.text }}</span><template v-else>{{ token.text }}</template></template></template><template v-else>{{ original }}</template></article>
<article class="reader-text"><template v-if="tokens.length"><template v-for="(token, index) in tokens" :key="token.start"><span
class="reader-token"
:data-token-index="index"
:class="[
token.kind === 'word' ? 'reader-word' : 'reader-separator',
token.kind === 'word' && termStatusOf(token) ? `is-${termStatusOf(token)}` : '',
spanOf(phrases, index) ? `is-phrase is-phrase-${spanOf(phrases, index)!.status}` : '',
spanOf(phrases, index)?.startToken === index ? 'is-phrase-start' : '',
spanOf(phrases, index)?.endToken === index ? 'is-phrase-end' : '',
{ 'is-selected': token.kind === 'word' && selectedStart === token.start, 'is-phrase-selected': spanOf(phrases, index)?.id === phraseStart },
]"
v-bind="token.kind === 'word' ? { role: 'button', tabindex: '0', 'aria-pressed': selectedStart === token.start } : {}"
:aria-label="token.kind === 'word' ? (spanOf(phrases, index) ? `短语中的 ${token.text}` : termStatusOf(token) ? `查询 ${token.text},已保存` : `查询 ${token.text}`) : undefined"
@click="select(token, $event, token.kind === 'word' ? spanOf(phrases, index) : null)"
@keydown.enter.prevent="token.kind === 'word' ? select(token, $event, spanOf(phrases, index)) : undefined"
@keydown.space.prevent="token.kind === 'word' ? select(token, $event, spanOf(phrases, index)) : undefined"
>{{ token.text }}</span></template></template><template v-else>{{ original }}</template></article>
</template>
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import { ElButton } from 'element-plus'
import { maskedPrompt, type ReviewItem } from '../stores/review'
const props = defineProps<{ item: ReviewItem; position: number; total: number; revealed: boolean; busy: boolean; error: string }>()
const emit = defineEmits<{ reveal: []; grade: [grade: 'correct' | 'wrong' | 'again']; end: []; retry: [] }>()
// The word and its masked example are enough to answer; the definition stays hidden.
const prompt = () => maskedPrompt(props.item)
const revealButton = ref<{ $el?: HTMLElement } | null>(null)
const correctButton = ref<{ $el?: HTMLElement } | null>(null)
// Keyboard users should reach the next action without tabbing through the page again.
watch(() => props.item.id, async () => {
await nextTick()
if (!props.revealed) revealButton.value?.$el?.focus()
}, { immediate: true })
watch(() => props.revealed, async value => {
if (!value) return
await nextTick()
correctButton.value?.$el?.focus()
})
</script>
<template>
<section class="review-card" aria-labelledby="review-word" data-testid="review-card">
<p class="subtle" data-testid="review-position">到期复习 · {{ position }} / {{ total }}</p>
<h2 id="review-word" tabindex="-1" class="review-word">{{ item.originalForm }}</h2>
<p v-if="prompt()" class="review-example" lang="en">{{ prompt() }}</p>
<p class="subtle">{{ item.kind === 'phrase' ? `短语 · ${item.wordCount} 个单词 · ` : '' }}{{ item.status === 'learning' ? `学习中 · 等级 ${item.level}` : '新词 · 尚未复习' }}</p>
<p v-if="error" role="alert" class="notice">{{ error }}</p>
<template v-if="!revealed">
<ElButton ref="revealButton" type="primary" data-testid="review-reveal" :disabled="busy || !!error" @click="emit('reveal')">显示答案</ElButton>
</template>
<template v-else>
<div class="review-answer">
<p class="review-definition" data-testid="review-definition">{{ item.definition || '(未填写个人释义)' }}</p>
<blockquote v-for="(example, index) in item.examples" :key="index" lang="en">{{ example }}</blockquote>
</div>
<div class="review-grades">
<ElButton ref="correctButton" type="primary" data-testid="review-correct" :loading="busy" @click="emit('grade', 'correct')">认识 / 答对</ElButton>
<ElButton data-testid="review-wrong" :disabled="busy" @click="emit('grade', 'wrong')">不认识 / 答错</ElButton>
<ElButton data-testid="review-again" :disabled="busy" @click="emit('grade', 'again')">再学一次</ElButton>
</div>
</template>
<p v-if="error" class="review-actions"><ElButton data-testid="review-retry" @click="emit('retry')">重试提交</ElButton></p>
<p class="review-actions"><ElButton text data-testid="review-end" @click="emit('end')">结束本次复习</ElButton></p>
</section>
</template>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ElInput, ElOption, ElRadio, ElRadioGroup, ElSelect } from 'element-plus'
import { TERM_STATUSES, type TermStatus } from '../composables/useReaderLookup'
// The reader panel and the vocabulary dialog edit the same fields, so they share this form
// instead of keeping two copies of the same rules.
const props = defineProps<{ disabled?: boolean; showLevel?: boolean; hint?: string }>()
const definition = defineModel<string>('definition', { required: true })
const examples = defineModel<string>('examples', { required: true })
const status = defineModel<TermStatus>('status', { required: true })
const level = defineModel<number>('level', { required: true })
const levelVisible = computed(() => props.showLevel === true && status.value === 'learning')
</script>
<template>
<div class="term-fields">
<p class="lookup-term-title">学习状态</p>
<ElRadioGroup v-model="status" :disabled="disabled" aria-label="学习状态">
<ElRadio v-for="item in TERM_STATUSES" :key="item.value" :value="item.value">{{ item.label }}</ElRadio>
</ElRadioGroup>
<p v-if="levelVisible" class="term-level">
<label for="term-level">学习等级</label>
<ElSelect id="term-level" v-model="level" :disabled="disabled" class="term-level-select" data-testid="term-level">
<ElOption v-for="value in 7" :key="value" :label="`${value} 级`" :value="value" />
</ElSelect>
</p>
<p v-else-if="status === 'learning'" class="subtle">学习中按 1~7 级记录,等级由复习推进。</p>
<label for="term-definition">我的释义 <span v-if="hint">{{ hint }}</span></label>
<ElInput id="term-definition" v-model="definition" type="textarea" :rows="3" :disabled="disabled" placeholder="记下当前词的释义" />
<label for="term-examples">例句 <span>每行一条,最多 5 条</span></label>
<ElInput id="term-examples" v-model="examples" type="textarea" :rows="2" :disabled="disabled" placeholder="可选,每行一条" />
</div>
</template>
+98
View File
@@ -0,0 +1,98 @@
import type { ReaderToken } from './useReaderLookup'
/** A phrase selection over the server's own tokens, or a candidate the reader can adjust. */
export interface TokenRange {
/** Token indices of the first and last word of the phrase. */
firstWord: number
lastWord: number
/** Code point range of the whole phrase inside the original text. */
start: number
end: number
/** The original text exactly as the chapter holds it. */
text: string
wordCount: number
}
export const MAX_PHRASE_WORDS = 12
/** Indices of the word tokens, in reading order. */
export function wordIndices(tokens: ReaderToken[]): number[] {
const indices: number[] = []
for (let index = 0; index < tokens.length; index++) {
if (tokens[index]!.kind === 'word') indices.push(index)
}
return indices
}
/**
* Builds a phrase range from two token indices, aligning both ends to whole words and
* keeping interior punctuation and line breaks in the text. It mirrors the rules verified in
* #4: separators at the edges are skipped, interior ones survive, and a single word is not a
* phrase.
*/
export function normalizeTokenRange(tokens: ReaderToken[], original: string, anchor: number, focus: number): TokenRange | null {
if (!Number.isInteger(anchor) || !Number.isInteger(focus)) return null
const from = Math.min(anchor, focus)
const to = Math.max(anchor, focus)
if (from < 0 || to >= tokens.length) return null
let firstWord = -1
let lastWord = -1
for (let index = from; index <= to; index++) {
if (tokens[index]!.kind !== 'word') continue
if (firstWord < 0) firstWord = index
lastWord = index
}
if (firstWord < 0 || lastWord < 0 || firstWord === lastWord) return null
return rangeOfWords(tokens, original, firstWord, lastWord)
}
/** The phrase range for a known first and last word token. */
export function rangeOfWords(tokens: ReaderToken[], original: string, firstWord: number, lastWord: number): TokenRange | null {
const first = tokens[firstWord]
const last = tokens[lastWord]
if (!first || !last || first.kind !== 'word' || last.kind !== 'word' || lastWord < firstWord) return null
const wordCount = wordIndices(tokens.slice(firstWord, lastWord + 1)).length
if (wordCount < 2 || wordCount > MAX_PHRASE_WORDS) return null
const text = [...original].slice(first.start, last.end).join('')
if (!text) return null
return { firstWord, lastWord, start: first.start, end: last.end, text, wordCount }
}
/**
* Moves one end of a phrase by whole words, skipping separators and never inverting the range.
* Returns the adjusted range, or null when the move is impossible.
*/
export function adjustTokenRange(tokens: ReaderToken[], original: string, range: TokenRange, edge: 'start' | 'end', direction: -1 | 1): TokenRange | null {
const words = wordIndices(tokens)
const positionOf = (tokenIndex: number) => words.indexOf(tokenIndex)
const firstPosition = positionOf(range.firstWord)
const lastPosition = positionOf(range.lastWord)
if (firstPosition < 0 || lastPosition < 0) return null
if (edge === 'start') {
const next = firstPosition + direction
if (next < 0 || next > lastPosition - 1) return null
return rangeOfWords(tokens, original, words[next]!, range.lastWord)
}
const next = lastPosition + direction
if (next >= words.length || next < firstPosition + 1) return null
return rangeOfWords(tokens, original, range.firstWord, words[next]!)
}
/** The saved phrases that cover a word token, longest first, so a click prefers the phrase. */
export function phrasesAt(tokens: ReaderToken[], phrases: { id: number; startToken: number; endToken: number }[], tokenIndex: number): { id: number; startToken: number; endToken: number } | null {
let best: { id: number; startToken: number; endToken: number } | null = null
for (const phrase of phrases) {
if (tokenIndex < phrase.startToken || tokenIndex > phrase.endToken) continue
if (best === null || phrase.endToken - phrase.startToken > best.endToken - best.startToken) best = phrase
}
return best
}
/** The phrase range a saved span covers, used when a highlighted phrase is clicked. */
export function rangeOfSpan(tokens: ReaderToken[], original: string, span: { startToken: number; endToken: number }): TokenRange | null {
const words = wordIndices(tokens.slice(span.startToken, span.endToken + 1))
if (words.length < 2) return null
const firstWord = span.startToken + words[0]!
const lastWord = span.startToken + words[words.length - 1]!
return rangeOfWords(tokens, original, firstWord, lastWord)
}
@@ -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))
}
+215 -10
View File
@@ -1,17 +1,79 @@
import { onScopeDispose, ref, watch, type Ref } from 'vue'
import { computed, onScopeDispose, ref, watch, type Ref } from 'vue'
import type { ChapterDetail } from '../stores/library'
import { useSessionStore } from '../stores/session'
import { adjustTokenRange, type TokenRange } from './readerRange'
export interface ReaderToken { text: string; start: number; end: number; startUtf16: number; endUtf16: number; kind: 'word' | 'space' | 'punctuation' }
export type TermStatus = 'new' | 'learning' | 'known' | 'ignored'
export interface TokenTerm { id: number; status: TermStatus; level: number }
export interface ReaderToken {
text: string
start: number
end: number
startUtf16: number
endUtf16: number
kind: 'word' | 'space' | 'punctuation'
term?: TokenTerm | null
}
/** One saved phrase occurrence inside the chapter, in token indices. */
export interface PhraseSpan { id: number; status: TermStatus; wordCount: number; startToken: number; endToken: number }
export interface LookupEntry {
lemma: string
pos: string
definition: string
examples: string[]
/** Chinese gloss and phonetic, present when the 英汉 dictionary is enabled. */
translation?: string
phonetic?: string
/** "cmudict" for a CMUdict IPA transcription, "ecdict" for the ECDICT notation. */
phoneticSource?: string
provider?: string
}
export interface LookupResult {
status: 'exact' | 'lemma' | 'not_found' | 'resource_missing'
query: string
matchedForm: string | null
candidates: string[]
entries: { lemma: string; pos: string; definition: string; examples: string[] }[]
entries: LookupEntry[]
resource?: { name: string; version: string }
resources?: { name: string; version: string }[]
/** The headword phonetic, repeated at the top level so the panel can show it next to the word. */
phonetic?: string
phoneticSource?: string
}
export interface SavedTerm {
id: number
term: string
originalForm: string
definition: string
examples: string[]
status: TermStatus
level: number
kind: 'word' | 'phrase'
wordCount: number
}
interface TermResponse { term: SavedTerm }
interface TokenResponse { textSha256: string; tokens: ReaderToken[]; phrases?: PhraseSpan[] }
// The four learner-visible states and the level rule behind them. Only a learning
// entry carries a level, so every other status reports level 0.
export const TERM_STATUSES: { value: TermStatus; label: string }[] = [
{ value: 'new', label: '新词' },
{ value: 'learning', label: '学习中' },
{ value: 'known', label: '已知' },
{ value: 'ignored', label: '忽略' },
]
export function termStatusLabel(status: TermStatus): string {
return TERM_STATUSES.find(item => item.value === status)?.label ?? status
}
/** Unknown or malformed personal state never becomes a highlight class. */
export function termStatusOf(token: ReaderToken): TermStatus | null {
const term = token.term
if (!term || typeof term.id !== 'number' || term.id <= 0) return null
return TERM_STATUSES.some(item => item.value === term.status) ? term.status : null
}
interface TokenResponse { textSha256: string; tokens: ReaderToken[] }
// Validate every coordinate before enabling selection; original text is always the fallback.
function matchesChapter(data: TokenResponse, chapter: ChapterDetail): boolean {
@@ -31,18 +93,48 @@ function matchesChapter(data: TokenResponse, chapter: ChapterDetail): boolean {
export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
const session = useSessionStore()
const tokens = ref<ReaderToken[]>([])
const phrases = ref<PhraseSpan[]>([])
const tokensError = ref('')
const tokensLoading = ref(false)
const selected = ref<ReaderToken | null>(null)
// A phrase selection: either a drag over several words or a click on a saved phrase span.
const range = ref<TokenRange | null>(null)
const rangeTermId = ref<number | null>(null)
const result = ref<LookupResult | null>(null)
const loading = ref(false)
const error = ref('')
const draft = ref('')
let tokenSequence = 0, lookupSequence = 0
// The learner's own record for the selected word.
const definition = ref('')
const examples = ref('')
const status = ref<TermStatus>('new')
const savedTermId = ref<number | null>(null)
const prefilling = ref(false)
const prefillError = ref('')
const saving = ref(false)
const saveError = ref('')
const saved = ref('')
let tokenSequence = 0, lookupSequence = 0, termSequence = 0, saveSequence = 0
let origin: HTMLElement | null = null
let scrollBeforeAdjustment: { top: number; left: number } | null = null
let lastAdjustment: { top: number; left: number } | null = null
// Sending is blocked until the stored text is on screen, so a slow or failed read
// can never let an empty form overwrite what the learner already wrote.
const canSave = computed(() => (selected.value !== null || range.value !== null) && !saving.value && !prefilling.value && !prefillError.value)
function exampleLines(): string[] {
return examples.value.split('\n').map(line => line.trim()).filter(Boolean)
}
function applyTerm(next: TokenTerm) {
savedTermId.value = next.id
const index = tokens.value.findIndex(token => token.start === selected.value?.start)
const target = index >= 0 ? tokens.value[index] : undefined
if (target) tokens.value[index] = { ...target, term: next }
}
function stillAtAdjustment() {
return lastAdjustment !== null && Math.abs(window.scrollY - lastAdjustment.top) <= 2
&& Math.abs(window.scrollX - lastAdjustment.left) <= 2
@@ -61,11 +153,23 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
function close(restoreFocus = true) {
lookupSequence++
termSequence++
saveSequence++
selected.value = null
range.value = null
rangeTermId.value = null
result.value = null
loading.value = false
error.value = ''
draft.value = ''
definition.value = ''
examples.value = ''
status.value = 'new'
savedTermId.value = null
prefilling.value = false
prefillError.value = ''
saving.value = false
saveError.value = ''
saved.value = ''
if (restoreFocus && origin?.isConnected) origin.focus({ preventScroll: true })
if (restoreFocus && scrollBeforeAdjustment && stillAtAdjustment()) window.scrollTo({ ...scrollBeforeAdjustment, behavior: 'instant' })
scrollBeforeAdjustment = null
@@ -75,6 +179,7 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
function reset() {
tokenSequence++
tokens.value = []
phrases.value = []
tokensError.value = ''
tokensLoading.value = false
close(false)
@@ -90,16 +195,18 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
if (seq !== tokenSequence) return
if (!matchesChapter(data, current)) throw new Error('分词与正文不一致,请重试。')
tokens.value = data.tokens
phrases.value = Array.isArray(data.phrases) ? data.phrases : []
} catch (reason) {
if (seq !== tokenSequence) return
tokens.value = []
phrases.value = []
tokensError.value = reason instanceof Error ? reason.message : '单词暂时无法加载。'
} finally { if (seq === tokenSequence) tokensLoading.value = false }
}
async function lookup() {
const current = chapter.value
const token = selected.value
if (!session.user || !current || !token) return
if (!session.user || !current || !token || range.value) return
const seq = ++lookupSequence
result.value = null
error.value = ''
@@ -111,18 +218,116 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
if (seq === lookupSequence) error.value = reason instanceof Error ? reason.message : '暂时无法查询,请重试。'
} finally { if (seq === lookupSequence) loading.value = false }
}
// Opening a word the learner already saved loads that record, so the panel shows
// the stored text and the same id other chapters show.
async function loadTerm(id: number) {
const seq = ++termSequence
prefilling.value = true
prefillError.value = ''
try {
const data = await session.request<TermResponse>(`terms/${id}`)
if (seq !== termSequence) return
definition.value = data.term.definition
examples.value = data.term.examples.join('\n')
status.value = data.term.status
applyTerm({ id: data.term.id, status: data.term.status, level: data.term.level })
} catch (reason) {
if (seq !== termSequence) return
prefillError.value = reason instanceof Error ? reason.message : '已保存的内容暂时无法读取。'
} finally { if (seq === termSequence) prefilling.value = false }
}
async function save() {
const current = chapter.value
const selection = range.value
const token = selected.value
if (!session.user || !current || (!selection && !token) || !canSave.value) return
const seq = ++saveSequence
saving.value = true
saveError.value = ''
saved.value = ''
const span = selection ?? token!
const path = selection ? 'phrases' : 'terms'
try {
const data = await session.request<TermResponse & { created: boolean }>(path, 'POST', {
chapterId: current.id, start: span.start, end: span.end,
definition: definition.value, examples: exampleLines(), status: status.value,
})
if (seq !== saveSequence) return
definition.value = data.term.definition
examples.value = data.term.examples.join('\n')
status.value = data.term.status
applyTerm({ id: data.term.id, status: data.term.status, level: data.term.level })
if (selection) {
// The saved phrase must highlight here without waiting for another token load.
rangeTermId.value = data.term.id
const savedSpan = {
id: data.term.id, status: data.term.status, wordCount: selection.wordCount,
startToken: selection.firstWord, endToken: selection.lastWord,
}
const existing = phrases.value.findIndex(item => item.id === data.term.id)
phrases.value = existing >= 0
? phrases.value.map((item, index) => (index === existing ? savedSpan : item))
: [...phrases.value, savedSpan]
}
saved.value = `已保存 · ${termStatusLabel(data.term.status)}`
} catch (reason) {
if (seq !== saveSequence) return
saveError.value = reason instanceof Error ? reason.message : '保存失败,请稍后重试。'
} finally { if (seq === saveSequence) saving.value = false }
}
function select(token: ReaderToken, element: HTMLElement) {
close(false)
origin = element
selected.value = token
const existing = termStatusOf(token)
if (existing !== null && token.term) {
status.value = existing
void loadTerm(token.term.id)
}
void lookup()
}
/**
* Opens the phrase form for a dragged range or for a saved phrase span. A phrase is one
* entry in the same table as a word, so an existing phrase is read through the same route.
*/
function selectRange(next: TokenRange, element: HTMLElement | null, termId: number | null = null) {
close(false)
origin = element
range.value = next
rangeTermId.value = termId
if (termId !== null) void loadTerm(termId)
}
/** Moves one end of the phrase by whole words; the panel keeps the selection in sync. */
function adjustRange(edge: 'start' | 'end', direction: -1 | 1) {
const current = range.value
const currentChapter = chapter.value
if (!current || !currentChapter) return
const next = adjustTokenRange(tokens.value, currentChapter.originalText ?? '', current, edge, direction)
if (!next) return
// The adjusted range is a different identity, so the panel must stop claiming that the
// stored entry is what is on screen: both the phrase link and the entry the form was loaded
// from are dropped. The typed text stays, and saving it writes the phrase that matches the
// current range (which may update another entry, never two).
rangeTermId.value = null
savedTermId.value = null
range.value = next
}
/** True when the panel is editing an entry that is already stored. */
const rangeStored = computed(() => rangeTermId.value !== null)
watch(() => [chapter.value?.id, chapter.value?.status, chapter.value?.contentSha256, chapter.value?.originalText], () => {
reset()
void loadTokens()
}, { immediate: true, flush: 'sync' })
// Watch the identity object, including clear → login for the same account.
// Watch the identity object, including clear → login for the same account: leaving
// or switching an account drops every word saved by the previous one.
watch(() => session.user, reset, { flush: 'sync' })
onScopeDispose(reset)
return { tokens, tokensError, tokensLoading, selected, result, loading, error, draft, loadTokens, lookup, select, close, reset, keepSelectionVisible }
return {
tokens, phrases, tokensError, tokensLoading, selected, range, rangeTermId, rangeStored, result, loading, error,
definition, examples, status, savedTermId, prefilling, prefillError, saving, saveError, saved, canSave,
loadTokens, lookup, loadTerm, save, select, selectRange, adjustRange, close, reset, keepSelectionVisible,
}
}
@@ -0,0 +1,80 @@
import { onScopeDispose, type Ref } from 'vue'
/**
* Maps the browser's own selection onto the server tokens. Nothing here intercepts touch or
* pointer movement: a mouse drag and the phone's system selection handles both produce a
* selectionchange, which is the interaction #4 verified.
*/
export function tokenIndexOf(node: Node | null): number {
if (!node) return -1
const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : (node as Element)
const holder = element?.closest?.('[data-token-index]')
if (!holder) return -1
const value = Number((holder as HTMLElement).dataset.tokenIndex)
return Number.isInteger(value) ? value : -1
}
export interface TextSelectionOptions {
/** The element the selection has to start inside. */
container: Ref<HTMLElement | null>
/** Called with the first and last token index of a non-empty selection inside the reader. */
onSelect: (anchor: number, focus: number) => void
}
/**
* Watches the native selection and reports token indices. A collapsed selection (a plain
* click) reports nothing, so clicking a word keeps opening the word panel.
*/
export function useTextSelection(options: TextSelectionOptions) {
let timer: number | undefined
let disposed = false
function read(): void {
if (disposed) return
const root = options.container.value
const selection = window.getSelection?.()
if (!root || !selection || selection.rangeCount === 0 || selection.isCollapsed) return
const range = selection.getRangeAt(0)
if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return
const anchor = tokenIndexOf(range.startContainer)
const focus = tokenIndexOf(range.endContainer)
if (anchor < 0 || focus < 0) return
options.onSelect(Math.min(anchor, focus), Math.max(anchor, focus))
}
// The debounce matches the verified spike: it lets the browser finish a drag before the
// range is read, and it never blocks scrolling on a touch device.
function schedule(): void {
if (timer !== undefined) window.clearTimeout(timer)
timer = window.setTimeout(() => { timer = undefined; read() }, 100)
}
function pointerUp(event: PointerEvent): void {
// Only a pointerup inside the reader can finish a selection; a click on the panel must not
// re-read a range the learner already adjusted there.
const root = options.container.value
const target = event.target as Node | null
if (!root || !target || !root.contains(target)) return
if (timer !== undefined) window.clearTimeout(timer)
// A pointerup arrives before the browser finalises the range, so the read waits a tick.
timer = window.setTimeout(() => { timer = undefined; read() }, 0)
}
// Both listeners live on the document: the reader body only exists once a chapter is ready,
// so a listener bound to the element at setup time would miss every later selection.
document.addEventListener('selectionchange', schedule)
document.addEventListener('pointerup', pointerUp)
onScopeDispose(() => {
disposed = true
if (timer !== undefined) window.clearTimeout(timer)
document.removeEventListener('selectionchange', schedule)
document.removeEventListener('pointerup', pointerUp)
})
return {
/** Drops the native highlight, e.g. when the panel closes. */
clear() { window.getSelection?.()?.removeAllRanges() },
read,
}
}
+6
View File
@@ -8,6 +8,12 @@ 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/dialog/style/css'
import 'element-plus/es/components/message-box/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')
+3
View File
@@ -6,6 +6,9 @@ const router = createRouter({
routes: [
{ path: '/login', name: 'login', component: () => import('../views/LoginView.vue') },
{ path: '/', name: 'library', meta: { private: true }, component: () => import('../views/LibraryView.vue') },
{ path: '/review', name: 'review', meta: { private: true }, component: () => import('../views/ReviewView.vue') },
{ path: '/vocab', name: 'vocab', meta: { private: true }, component: () => import('../views/VocabView.vue') },
{ path: '/progress', name: 'progress', meta: { private: true }, component: () => import('../views/ProgressView.vue') },
{ path: '/import', name: 'import', meta: { private: true }, component: () => import('../views/ImportView.vue') },
{ path: '/books/:id', name: 'book', meta: { private: true }, component: () => import('../views/BookView.vue') },
{ path: '/chapters/:id', name: 'chapter', meta: { private: true }, component: () => import('../views/ReaderView.vue') },
+492 -3
View File
@@ -4,9 +4,44 @@ import { ApiError, useSessionStore } from './session'
export type ChapterStatus = 'pending' | 'processing' | 'ready' | 'failed'
export interface BookRef { id: number; title: string; language: string }
export type AttachmentKind = 'audio' | 'cover'
/** One stored attachment: metadata only, the bytes are fetched from the attachment endpoint. */
export interface BookAttachment {
kind: AttachmentKind
mime: string
byteSize: number
/** Content digest, used as the cache key so a replaced file is fetched again. */
version: string
updatedAt: string
}
/** The book keeps its cover; audio and illustrations belong to a chapter. */
export interface BookAttachments {
cover: BookAttachment | null
}
export interface ChapterAttachment {
kind: 'audio' | 'illustration'
mime: string
byteSize: number
/** Content digest, used as the cache key so a replaced file is fetched again. */
version: string
updatedAt: string
}
export interface BookRef {
id: number
title: string
/** Optional free text for the book itself; chapters carry their own author. */
author: string
language: string
attachments?: BookAttachments | null
}
export interface BookSummary extends BookRef {
coverVersion?: string
hasAudio?: boolean
chapterCount: number
pendingCount: number
processingCount: number
@@ -21,12 +56,22 @@ export interface ChapterSummary {
bookId: number
ordinal: number
title: string
/** Optional free text the learner can set while editing the chapter. */
author: string
status: ChapterStatus
charCount: number
errorReason: string
errorMessage: string
// Present wherever a chapter appears; null while the job id is unknown.
jobId: number | null
// Set only when this completion still matches the chapter's current content; a chapter
// edited into a new version reads as unread again.
readAt?: string | null
/** Content digests of this chapter's files; empty when that file is absent. */
illustrationVersion?: string
audioVersion?: string
/** Where this account stopped in this chapter, in seconds. */
playbackSeconds?: number
createdAt: string
updatedAt: string
}
@@ -51,6 +96,31 @@ export interface Job {
export interface ChapterNavigation { previousChapterId: number | null; nextChapterId: number | null }
/** The editable text of one owned chapter; separate from the ready-only reader payload. */
export interface ChapterSource {
id: number
bookId: number
ordinal: number
title: string
author: string
text: string
status: ChapterStatus
contentSha256: string
charCount: number
}
export interface ChapterEdit { chapter: ChapterSummary; job: Job | null; versionChanged: boolean }
export interface DeletionResult { bookId?: number; chapterId?: number; chapters?: number; remaining: number }
/** The answer to completing one chapter; a repeat answers with the same mark. */
export interface ChapterCompletion {
chapterId: number
bookId: number
read: boolean
readAt: string
duplicate: boolean
}
export type SubmitTarget = { mode: 'new' } | { mode: 'append'; bookId: number }
export interface SubmitInput { title: string; text: string; target: SubmitTarget }
@@ -58,6 +128,8 @@ export interface SubmitInput { title: string; text: string; target: SubmitTarget
export const POLL_INTERVAL_MS = 1500
export const TITLE_MAX_LENGTH = 120
export const TEXT_MAX_CODE_POINTS = 100000
// Mirrors the server limit for one TXT upload.
export const TXT_MAX_BYTES = 2 * 1024 * 1024
export const NOT_FOUND_MESSAGE = '内容不存在。'
export const LANGUAGE_LABEL = '英语'
export const LANGUAGE_CODE = 'en'
@@ -92,6 +164,24 @@ export function textProblem(text: string): string {
return ''
}
/**
* Client-side pre-check for a TXT upload. The server validates the file again and stays the
* only authority; this only tells the learner about an obviously unusable choice earlier.
*/
export function fileProblem(file: { name: string; size: number }): string {
if (!/\.txt$/i.test(file.name)) return '请选择 .txt 文件。'
if (file.size === 0) return '文件是空的,请选择包含英文正文的 UTF-8 TXT。'
if (file.size > TXT_MAX_BYTES) return 'TXT 文件不能超过 2 MiB。'
return ''
}
/** A readable size for the selected file, e.g. `2 KB` or `1.5 MB`. */
export function fileSizeLabel(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
/** A failed chapter can be retried as soon as the API told us its job id. */
export function canRetry(chapter: Pick<ChapterSummary, 'status' | 'jobId'>): boolean {
return chapter.status === 'failed' && chapter.jobId !== null
@@ -112,6 +202,7 @@ interface SubmitBookBody { requestId: string; title: string; text: string; langu
// An appended chapter owns the language of its book, so the append contract has no language
// field; the server rejects unknown fields, and a client that sends one gets HTTP 400.
interface SubmitChapterBody { requestId: string; title: string; text: string }
export interface UploadInput { title: string; target: SubmitTarget; file: File }
interface Created { bookId: number; chapter: ChapterSummary }
function emptyNavigation(): ChapterNavigation {
@@ -139,6 +230,10 @@ export const useLibraryStore = defineStore('library', () => {
const submitting = ref(false)
const submitError = ref('')
const retryingChapterId = ref<number | null>(null)
const completingChapterId = ref<number | null>(null)
const uploadingKind = ref<string | null>(null)
/** Which book or chapter the current upload belongs to, so only that row shows progress. */
const uploadingId = ref<number | null>(null)
/** Reader text exists only for ready chapters and is never taken from a cache. */
const readerText = computed(() => (chapter.value?.status === 'ready' ? chapter.value.originalText ?? '' : ''))
@@ -229,6 +324,9 @@ export const useLibraryStore = defineStore('library', () => {
if (seq !== booksSeq || isStale(version, owner)) return
const items = result?.items
books.value = Array.isArray(items) ? items : []
for (const book of books.value) {
if (book.coverVersion) void loadCover(book.id, book.coverVersion)
}
} catch (reason) {
if (seq !== booksSeq || isStale(version, owner)) return
booksError.value = failureMessage(reason, '书库暂时无法加载,请稍后重试。')
@@ -251,6 +349,11 @@ export const useLibraryStore = defineStore('library', () => {
book.value = result.book
chapters.value = Array.isArray(result.chapters) ? result.chapters : []
bookError.value = ''
// The book is always present in this response; the guard also covers a stale server reply.
const cover = result.book?.attachments?.cover
if (cover) void loadCover(result.book.id, cover.version)
// Illustrations are loaded by the reader only: the chapter list does not show them, so the
// list does not fetch every image of the book.
schedulePolling()
} catch (reason) {
if (seq !== bookSeq || isStale(version, owner)) return
@@ -292,6 +395,17 @@ export const useLibraryStore = defineStore('library', () => {
chapter.value = result.chapter
navigation.value = result.navigation ?? emptyNavigation()
chapterError.value = ''
// The chapter's own files: an illustration to show and, when present, the audio to play.
const illustration = result.chapter.illustrationVersion
if (illustration) void loadChapterIllustration(result.chapter.id, illustration)
const audio = result.chapter.audioVersion
if (audio) {
// A failure here is shown by the player itself; the chapter stays readable without audio.
void loadChapterAudio(result.chapter.id, audio).catch(() => undefined)
} else {
audioUrl.value = ''
audioChapterId.value = null
}
schedulePolling()
} catch (reason) {
if (seq !== chapterSeq || isStale(version, owner)) return
@@ -365,6 +479,129 @@ export const useLibraryStore = defineStore('library', () => {
}
}
/**
* Uploads one TXT file. The file and the title share the request id discipline of a paste,
* so a repeated upload of the same file answers with the chapter it already created.
*/
async function upload(input: UploadInput): Promise<number> {
const title = input.title.trim()
const problem = titleProblem(title) || fileProblem(input.file)
if (problem) {
submitError.value = problem
throw new Error(problem)
}
const key = `upload\n${title}\n${input.file.name}\n${input.file.size}\n${input.file.lastModified}`
if (key !== submissionKey || submissionRequestId === '') {
submissionKey = key
submissionRequestId = crypto.randomUUID()
}
const requestId = submissionRequestId
const form = new FormData()
form.append('requestId', requestId)
form.append('title', title)
// Only the new-book contract carries a language; appending inherits the book's language.
const path = input.target.mode === 'new' ? 'books/upload' : `books/${input.target.bookId}/chapters/upload`
if (input.target.mode === 'new') form.append('language', LANGUAGE_CODE)
form.append('file', input.file, input.file.name)
const version = generation
const owner = ownerId()
submitting.value = true
submitError.value = ''
try {
const result = await session.request<{ book?: BookRef; chapter: ChapterSummary }>(path, 'POST', form)
if (isStale(version, owner)) throw new Error('登录状态已变化,请重新提交。')
submissionKey = ''
submissionRequestId = ''
return result.book?.id ?? result.chapter.bookId
} catch (reason) {
if (!isStale(version, owner)) submitError.value = failureMessage(reason, '上传失败,请稍后重试。')
throw reason instanceof Error ? reason : new Error('上传失败,请稍后重试。')
} finally {
if (!isStale(version, owner)) submitting.value = false
}
}
/** Saves the title (and the optional author) of one owned book; the reply is the stored book. */
async function updateBook(id: number, input: { title: string; author: string }): Promise<BookRef> {
const version = generation
const owner = ownerId()
const problem = titleProblem(input.title)
if (problem) throw new Error(problem)
const result = await session.request<{ book: BookRef }>(`books/${id}`, 'PATCH', {
title: input.title.trim(), author: input.author.trim(),
})
if (!isStale(version, owner)) {
book.value = result.book
const listed = books.value.find(item => item.id === id)
if (listed) {
listed.title = result.book.title
listed.author = result.book.author
}
}
return result.book
}
/**
* Saves a chapter title and/or text. A changed text becomes a new version and re-processes;
* the same text answered again changes nothing.
*/
async function updateChapter(id: number, input: { title?: string; author?: string; text?: string }): Promise<ChapterEdit> {
const version = generation
const owner = ownerId()
if (input.title !== undefined) {
const problem = titleProblem(input.title)
if (problem) throw new Error(problem)
}
if (input.text !== undefined) {
const problem = textProblem(input.text)
if (problem) throw new Error(problem)
}
const body: { title?: string; author?: string; text?: string } = {}
if (input.title !== undefined) body.title = input.title.trim()
if (input.author !== undefined) body.author = input.author.trim()
if (input.text !== undefined) body.text = input.text
const result = await session.request<ChapterEdit>(`chapters/${id}`, 'PATCH', body)
if (isStale(version, owner)) return result
const merged = { ...result.chapter, jobId: result.job?.id ?? result.chapter.jobId }
applyChapterSummary(merged)
if (chapter.value !== null && chapter.value.id === id && result.versionChanged) {
// The new version is not readable yet, so the reader must drop the previous text.
chapter.value = { ...chapter.value, ...merged, originalText: undefined }
}
schedulePolling()
return result
}
/** Reads the editable text of one owned chapter, in any processing state. */
async function loadChapterSource(id: number): Promise<ChapterSource> {
const result = await session.request<{ source: ChapterSource }>(`chapters/${id}/source`)
return result.source
}
/** Deletes one owned book with its chapters and jobs; personal records stay. */
async function deleteBook(id: number): Promise<DeletionResult> {
const version = generation
const owner = ownerId()
const result = await session.request<{ deleted: DeletionResult }>(`books/${id}`, 'DELETE')
if (!isStale(version, owner)) {
books.value = books.value.filter(item => item.id !== id)
if (book.value?.id === id) closeBook()
}
return result.deleted
}
/** Deletes one owned chapter and closes the gap in the chapter order. */
async function deleteChapter(id: number): Promise<DeletionResult> {
const version = generation
const owner = ownerId()
const result = await session.request<{ deleted: DeletionResult }>(`chapters/${id}`, 'DELETE')
if (isStale(version, owner)) return result.deleted
chapters.value = chapters.value.filter(item => item.id !== id)
if (book.value !== null) await loadBook(book.value.id, { silent: true })
return result.deleted
}
/** The job id comes from the chapter itself, wherever that chapter was loaded from. */
function jobIdOf(chapterId: number): number | null {
const target = chapters.value.find(item => item.id === chapterId)
@@ -409,6 +646,250 @@ export const useLibraryStore = defineStore('library', () => {
}
}
/**
* Uploads or replaces the cover of an owned book. The reply is the stored metadata, which is
* written back into the open book and into the library list so a cover appears without a reload.
*/
async function uploadCover(bookId: number, file: File): Promise<BookAttachment> {
return await sendAttachment(`books/${bookId}/cover`, bookId, 'cover', file) as BookAttachment
}
/** Removes the cover of an owned book. */
async function deleteCover(bookId: number): Promise<void> {
return dropAttachment(`books/${bookId}/cover`, bookId, 'cover')
}
/** Uploads or replaces one file of an owned chapter: its audio or its illustration. */
async function uploadChapterFile(chapterId: number, kind: ChapterAttachment['kind'], file: File): Promise<ChapterAttachment> {
const folder = kind === 'illustration' ? 'illustration' : 'audio'
return await sendAttachment(`chapters/${chapterId}/${folder}`, chapterId, kind, file) as ChapterAttachment
}
/** Removes one file of an owned chapter; removing the audio also clears the stored position. */
async function deleteChapterFile(chapterId: number, kind: ChapterAttachment['kind']): Promise<void> {
const folder = kind === 'illustration' ? 'illustration' : 'audio'
return dropAttachment(`chapters/${chapterId}/${folder}`, chapterId, kind)
}
/** Shared upload path for a book cover and for chapter files. */
async function sendAttachment(url: string, id: number, kind: string, file: File): Promise<unknown> {
const version = generation
const owner = ownerId()
uploadingKind.value = kind
uploadingId.value = id
try {
const form = new FormData()
form.append('file', file, file.name)
const result = await session.request<{ attachment: BookAttachment & ChapterAttachment }>(url, 'POST', form)
if (!isStale(version, owner)) applyAttachment(id, kind, result.attachment)
return result.attachment
} catch (reason) {
if (isStale(version, owner)) throw reason instanceof Error ? reason : new Error('上传失败,请稍后重试。')
throw reason instanceof Error ? reason : new Error('上传失败,请稍后重试。')
} finally {
if (!isStale(version, owner)) {
uploadingKind.value = null
uploadingId.value = null
}
}
}
/** Shared delete path for a book cover and for chapter files. */
async function dropAttachment(url: string, id: number, kind: string): Promise<void> {
const version = generation
const owner = ownerId()
uploadingKind.value = kind
uploadingId.value = id
try {
await session.request<{ deleted: boolean }>(url, 'DELETE')
if (!isStale(version, owner)) applyAttachment(id, kind, null)
} catch (reason) {
if (isStale(version, owner)) throw reason instanceof Error ? reason : new Error('删除失败,请稍后重试。')
throw reason instanceof Error ? reason : new Error('删除失败,请稍后重试。')
} finally {
if (!isStale(version, owner)) {
uploadingKind.value = null
uploadingId.value = null
}
}
}
/** Writes an attachment change into every view that shows that book or chapter. */
function applyAttachment(id: number, kind: string, attachment: BookAttachment | ChapterAttachment | null): void {
if (kind === 'cover') {
const cover = attachment as BookAttachment | null
if (book.value?.id === id) book.value = { ...book.value, attachments: { cover } }
const listed = books.value.find(item => item.id === id)
if (listed) listed.coverVersion = cover?.version ?? ''
forgetCover(id)
if (cover) void loadCover(id, cover.version)
return
}
// A chapter file: the chapter list entry and the open reader both carry the version.
const file = attachment as ChapterAttachment | null
const field: 'illustrationVersion' | 'audioVersion' = kind === 'illustration' ? 'illustrationVersion' : 'audioVersion'
const listed = chapters.value.find(item => item.id === id)
if (listed) {
listed[field] = file?.version ?? ''
// The stored position belonged to the replaced file, so nothing is restored for the new one.
if (kind === 'audio') listed.playbackSeconds = 0
}
const open = chapter.value !== null && chapter.value.id === id ? chapter.value : null
if (open) {
open[field] = file?.version ?? ''
if (kind === 'audio') open.playbackSeconds = 0
}
if (kind === 'audio') {
loadedVersions.delete(`chapter-audio:${id}`)
audioUrl.value = ''
audioChapterId.value = null
if (file) void loadChapterAudio(id, file.version).catch(() => undefined)
return
}
forgetIllustration(id)
if (file) void loadChapterIllustration(id, file.version)
}
/**
* Reports the position of one chapter from the value already loaded. The reader calls this when it
* leaves a chapter, so the stored position matches what the person actually heard.
*/
function reportChapterPlayback(chapterId: number, seconds?: number): void {
const listed = chapters.value.find(item => item.id === chapterId)
const open = chapter.value !== null && chapter.value.id === chapterId ? chapter.value : null
const value = seconds ?? open?.playbackSeconds ?? listed?.playbackSeconds ?? 0
if (value <= 0) return
void saveChapterPlayback(chapterId, value).catch(() => undefined)
}
/** Remembers where this account stopped in one chapter, so reading can continue later. */
async function saveChapterPlayback(chapterId: number, seconds: number): Promise<void> {
const version = generation
const owner = ownerId()
const result = await session.request<{ playbackSeconds: number }>(
`chapters/${chapterId}/playback`, 'PUT', { positionSeconds: Math.max(0, Math.floor(seconds)) })
if (isStale(version, owner)) return
const listed = chapters.value.find(item => item.id === chapterId)
if (listed) listed.playbackSeconds = result.playbackSeconds
if (chapter.value !== null && chapter.value.id === chapterId) chapter.value.playbackSeconds = result.playbackSeconds
}
/** Object URLs handed to <img>/<audio>; the token never appears in a URL the browser sees. */
const coverUrls = ref<Record<number, string>>({})
const illustrationUrls = ref<Record<number, string>>({})
const audioUrl = ref('')
/** Which chapter the loaded audio belongs to, so a stale player is never shown. */
const audioChapterId = ref<number | null>(null)
const liveBlobUrls = new Set<string>()
const loadedVersions = new Map<string, string>()
function rememberBlob(blob: Blob): string {
const url = URL.createObjectURL(blob)
liveBlobUrls.add(url)
return url
}
function releaseBlobs(): void {
for (const url of liveBlobUrls) URL.revokeObjectURL(url)
liveBlobUrls.clear()
loadedVersions.clear()
coverUrls.value = {}
illustrationUrls.value = {}
audioUrl.value = ''
audioChapterId.value = null
}
/** Loads a cover for the library list, once per version. */
async function loadCover(bookId: number, version: string): Promise<void> {
if (!version || loadedVersions.get(`cover:${bookId}`) === version) return
try {
const blob = await session.requestBlob(`books/${bookId}/cover`)
if (!session.user) return
loadedVersions.set(`cover:${bookId}`, version)
coverUrls.value = { ...coverUrls.value, [bookId]: rememberBlob(blob) }
} catch {
// A cover that cannot be read simply keeps the default styling.
}
}
/** Loads the illustration of a chapter, once per version, for the chapter row and the reader. */
async function loadChapterIllustration(chapterId: number, version: string): Promise<void> {
if (!version || loadedVersions.get(`illustration:${chapterId}`) === version) return
try {
const blob = await session.requestBlob(`chapters/${chapterId}/illustration`)
if (!session.user) return
loadedVersions.set(`illustration:${chapterId}`, version)
illustrationUrls.value = { ...illustrationUrls.value, [chapterId]: rememberBlob(blob) }
} catch {
// An illustration that cannot be read simply keeps the default styling.
}
}
/** Loads the audio of one chapter, once per version, for the player in the reader. */
async function loadChapterAudio(chapterId: number, version: string): Promise<void> {
if (!version || loadedVersions.get(`chapter-audio:${chapterId}`) === version) return
try {
const blob = await session.requestBlob(`chapters/${chapterId}/audio`)
if (!session.user) return
loadedVersions.set(`chapter-audio:${chapterId}`, version)
audioUrl.value = rememberBlob(blob)
audioChapterId.value = chapterId
} catch (reason) {
throw reason instanceof Error ? reason : new Error('音频暂时无法加载。')
}
}
/** Drops one cached cover so a replaced file is fetched again. */
function forgetCover(bookId: number): void {
loadedVersions.delete(`cover:${bookId}`)
const url = coverUrls.value[bookId]
if (!url) return
URL.revokeObjectURL(url)
liveBlobUrls.delete(url)
const next = { ...coverUrls.value }
delete next[bookId]
coverUrls.value = next
}
/** Drops one cached illustration so a replaced file is fetched again. */
function forgetIllustration(chapterId: number): void {
loadedVersions.delete(`illustration:${chapterId}`)
const url = illustrationUrls.value[chapterId]
if (!url) return
URL.revokeObjectURL(url)
liveBlobUrls.delete(url)
const next = { ...illustrationUrls.value }
delete next[chapterId]
illustrationUrls.value = next
}
/**
* Marks one owned chapter as read. Completing only records the reading fact: word statuses and
* levels are left alone. A repeated request answers with the same mark instead of counting
* twice, and a chapter whose text was replaced is completed again for the new version.
*/
async function markChapterRead(chapterId: number): Promise<ChapterCompletion> {
const version = generation
const owner = ownerId()
completingChapterId.value = chapterId
try {
const result = await session.request<{ progress: ChapterCompletion }>(`chapters/${chapterId}/complete`, 'POST')
if (!isStale(version, owner)) {
// The same mark reaches the chapter list and the open reader; nothing else changes
// about the chapter, so no other field is touched.
const listed = chapters.value.find(item => item.id === chapterId)
if (listed) listed.readAt = result.progress.readAt
if (chapter.value !== null && chapter.value.id === chapterId) chapter.value.readAt = result.progress.readAt
}
return result.progress
} catch (reason) {
if (isStale(version, owner)) throw reason instanceof Error ? reason : new Error('标记失败,请稍后重试。')
throw reason instanceof Error ? reason : new Error('标记失败,请稍后重试。')
} finally {
if (!isStale(version, owner)) completingChapterId.value = null
}
}
/**
* Releases the book view so polling stops when the page is left. The request sequence is
* advanced first, so a response that arrives after this call cannot repopulate the view or
@@ -452,6 +933,10 @@ export const useLibraryStore = defineStore('library', () => {
submitting.value = false
submitError.value = ''
retryingChapterId.value = null
completingChapterId.value = null
uploadingKind.value = null
uploadingId.value = null
releaseBlobs()
submissionKey = ''
submissionRequestId = ''
}
@@ -460,8 +945,12 @@ export const useLibraryStore = defineStore('library', () => {
books, booksLoading, booksError,
book, chapters, bookLoading, bookError,
chapter, chapterBook, navigation, chapterLoading, chapterError,
submitting, submitError, retryingChapterId, readerText,
loadBooks, loadBook, loadChapter, submit, retryChapter,
submitting, submitError, retryingChapterId, completingChapterId, readerText,
loadBooks, loadBook, loadChapter, submit, upload, retryChapter, markChapterRead,
uploadCover, deleteCover, uploadChapterFile, deleteChapterFile, saveChapterPlayback, reportChapterPlayback,
uploadingKind, uploadingId,
coverUrls, illustrationUrls, audioUrl, audioChapterId, loadCover, loadChapterIllustration, loadChapterAudio,
updateBook, updateChapter, loadChapterSource, deleteBook, deleteChapter,
stopPolling, closeBook, closeChapter, reset,
}
})
+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 }) }
})
+102
View File
@@ -0,0 +1,102 @@
import { computed, ref, watch } from 'vue'
import { defineStore } from 'pinia'
import { ApiError, useSessionStore } from './session'
/** One book's share of the reading total. */
export interface BookProgress {
id: number
title: string
readChapters: number
totalChapters: number
}
/**
* The learner's own basic progress for the current language. Reading counts only chapters that
* can be read, and the four word statuses are reported apart, so "known" and "ignored" are never
* folded into one number.
*/
export interface LearnerProgress {
readChapters: number
totalChapters: number
knownTerms: number
learningTerms: number
newTerms: number
ignoredTerms: number
savedTerms: number
dueNow: number
books: BookProgress[]
}
function emptyProgress(): LearnerProgress {
return {
readChapters: 0, totalChapters: 0, knownTerms: 0, learningTerms: 0,
newTerms: 0, ignoredTerms: 0, savedTerms: 0, dueNow: 0, books: [],
}
}
/**
* The progress page reads one snapshot from the server. Every number is counted server-side under
* the caller's own identity, so the page never adds up rows it happens to have loaded.
*/
export const useProgressStore = defineStore('progress', () => {
const session = useSessionStore()
const data = ref<LearnerProgress>(emptyProgress())
const loading = ref(false)
const error = ref('')
const loaded = ref(false)
let sequence = 0
let generation = 0
const hasBooks = computed(() => data.value.books.length > 0)
const readLabel = computed(() => `${data.value.readChapters} / ${data.value.totalChapters} 章(可阅读)`)
watch(() => session.user?.id ?? null, (next, previous) => {
if (next !== previous) reset()
}, { flush: 'sync' })
function ownerId(): number | null {
return session.user?.id ?? null
}
function isStale(version: number, owner: number | null): boolean {
return version !== generation || owner !== ownerId()
}
async function load(): Promise<void> {
if (!session.user) return
const version = generation
const owner = ownerId()
const seq = ++sequence
loading.value = true
error.value = ''
try {
const result = await session.request<LearnerProgress>('progress')
if (seq !== sequence || isStale(version, owner)) return
data.value = {
...emptyProgress(),
...result,
books: Array.isArray(result?.books) ? result.books : [],
}
loaded.value = true
} catch (reason) {
if (seq !== sequence || isStale(version, owner)) return
// Keep whatever is already on screen; a failed refresh is not a claim about the records.
error.value = reason instanceof ApiError
? reason.message
: '进度暂时无法加载,请稍后重试。'
} finally {
if (seq === sequence && !isStale(version, owner)) loading.value = false
}
}
function reset(): void {
generation++
sequence++
data.value = emptyProgress()
loading.value = false
error.value = ''
loaded.value = false
}
return { data, loading, error, loaded, hasBooks, readLabel, load, reset }
})
+251
View File
@@ -0,0 +1,251 @@
import { computed, ref, watch } from 'vue'
import { defineStore } from 'pinia'
import { useSessionStore } from './session'
export type ReviewGrade = 'correct' | 'wrong' | 'again'
// The outcome of an answer: applied when the word moved, stale when another screen had
// already reviewed it. A replayed answer repeats the outcome it was given first.
export type ReviewResult = 'applied' | 'stale'
export interface ReviewItem {
id: number
term: string
originalForm: string
definition: string
examples: string[]
status: 'new' | 'learning'
level: number
kind: 'word' | 'phrase'
wordCount: number
dueAt: string
reviewCount: number
}
export interface ReviewAnswerResult {
result: ReviewResult
duplicate: boolean
grade: ReviewGrade
requeued: boolean
statusBefore: string
statusAfter: string
levelBefore: number
levelAfter: number
dueAtBefore: string
dueAtAfter: string
item: ReviewItem
}
interface QueueResponse { items: ReviewItem[]; total: number }
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/**
* The prompt shown before the answer: the first personal example with the word masked.
* A word without examples is shown on its own; the dictionary is never consulted here.
*/
export function clozeSentence(item: ReviewItem): string | null {
const line = item.examples[0]
if (!line) return null
const forms = [...new Set([item.term, item.originalForm].filter(Boolean))]
let masked = line
for (const form of forms) {
// Whole word, any case, and either apostrophe form, so the mask matches how the
// learner typed the sentence.
const pattern = new RegExp(`(?<![\\p{L}\\p{M}])${escapeRegExp(form).replace(/'/g, "['’]")}(?![\\p{L}\\p{M}])`, 'giu')
masked = masked.replace(pattern, '_____')
}
return masked
}
/**
* A phrase is masked as one blank covering the whole run, which is what the accepted prototype
* shows. Interior separators of any non-letter run are allowed, matching the server's phrase
* identity rule.
*/
export function maskedPrompt(item: ReviewItem): string | null {
const line = item.examples[0]
if (!line) return null
if (item.kind !== 'phrase') return clozeSentence(item)
const forms = item.term.split(' ').filter(Boolean)
if (forms.length < 2) return clozeSentence(item)
const parts = forms.map(form => escapeRegExp(form).replace(/'/g, "['’]"))
const pattern = new RegExp(parts.join(`[\\p{L}\\p{M}]*?[^\\p{L}\\p{M}]+[\\p{L}\\p{M}]*?`), 'iu')
return line.replace(pattern, '_____')
}
export const useReviewStore = defineStore('review', () => {
const session = useSessionStore()
const queue = ref<ReviewItem[]>([])
const loading = ref(false)
const error = ref('')
const busy = ref(false)
const revealed = ref(false)
const answered = ref(0)
const correctCount = ref(0)
const wrongCount = ref(0)
// Distinct words in this round: a requeued word is answered again but is one word.
const wordsReviewed = ref(0)
// Cards this round took off the queue, counted for every outcome, and the reason a card
// left without a new score.
const resolved = ref(0)
const notice = ref('')
const seen = new Set<number>()
// Words due beyond the fetched page, reported by the server for this round.
const pending = ref(0)
let started = ref(false)
// One answer id per card: a retry of a failed submission reuses its key, so the server
// answers the retry from the first outcome instead of counting the same action twice.
let attempt: { itemId: number; answerId: string } | null = null
let sequence = 0
let generation = 0
const current = computed<ReviewItem | null>(() => queue.value[0] ?? null)
// A round that resolved cards is finished even when every answer turned out to be a
// replay or a stale submission; only a round that never had a card is empty.
const finished = computed(() => started.value && !loading.value && queue.value.length === 0 && resolved.value > 0)
const empty = computed(() => started.value && !loading.value && queue.value.length === 0 && resolved.value === 0)
watch(() => session.user?.id ?? null, (next, previous) => {
if (next !== previous) reset()
}, { flush: 'sync' })
function ownerId(): number | null {
return session.user?.id ?? null
}
function isStale(version: number, owner: number | null): boolean {
return version !== generation || ownerId() !== owner
}
function reset(): void {
generation++
sequence++
queue.value = []
loading.value = false
error.value = ''
busy.value = false
revealed.value = false
answered.value = 0
correctCount.value = 0
wrongCount.value = 0
wordsReviewed.value = 0
resolved.value = 0
notice.value = ''
pending.value = 0
started.value = false
attempt = null
seen.clear()
}
async function load(newRound = true): Promise<void> {
const version = generation
const owner = ownerId()
const seq = ++sequence
loading.value = true
error.value = ''
revealed.value = false
notice.value = ''
attempt = null
try {
const result = await session.request<QueueResponse>('reviews/queue')
if (seq !== sequence || isStale(version, owner)) return
const items = Array.isArray(result?.items) ? result.items : []
queue.value = items
const total = typeof result?.total === 'number' ? result.total : items.length
pending.value = Math.max(0, total - items.length)
// A fresh round starts at zero; continuing with the next page of due words keeps
// the counters of the round the learner is already in.
if (newRound) {
answered.value = 0
correctCount.value = 0
wrongCount.value = 0
wordsReviewed.value = 0
resolved.value = 0
seen.clear()
}
started.value = true
} catch (reason) {
if (seq !== sequence || isStale(version, owner)) return
error.value = reason instanceof Error ? reason.message : '复习队列暂时无法加载,请稍后重试。'
} finally {
if (seq === sequence && !isStale(version, owner)) loading.value = false
}
}
function answerIdFor(item: ReviewItem): string {
if (!attempt || attempt.itemId !== item.id) attempt = { itemId: item.id, answerId: crypto.randomUUID() }
return attempt.answerId
}
function reveal(): void {
revealed.value = true
}
/**
* Sends one grade for the current card. A failed request keeps the card and its input
* so the learner can retry; the same answer id is reused on that retry.
*/
async function answer(grade: ReviewGrade): Promise<void> {
const item = current.value
if (!item || busy.value || !session.user) return
const version = generation
const owner = ownerId()
const seq = sequence
busy.value = true
error.value = ''
try {
const result = await session.request<ReviewAnswerResult>(`reviews/${item.id}/answers`, 'POST', {
answerId: answerIdFor(item), grade, expectedDueAt: item.dueAt,
})
if (seq !== sequence || isStale(version, owner)) return
applyResult(item, result)
} catch (reason) {
if (seq !== sequence || isStale(version, owner)) return
error.value = reason instanceof Error ? reason.message : '评分暂时无法提交,请重试。'
} finally {
if (seq === sequence && !isStale(version, owner)) busy.value = false
}
}
function applyResult(item: ReviewItem, result: ReviewAnswerResult): void {
// The card leaves the round in every outcome; a stale or replayed answer is the same
// user action seen twice, not a second review.
queue.value = queue.value.filter(entry => entry.id !== item.id)
attempt = null
revealed.value = false
resolved.value += 1
if (result.result === 'applied') {
// A replay repeats the first outcome, and the first attempt may be this client's own
// submission whose response was lost, so it counts exactly like that attempt.
notice.value = ''
answered.value += 1
if (!seen.has(item.id)) {
seen.add(item.id)
wordsReviewed.value += 1
}
if (result.grade === 'correct') correctCount.value += 1
else wrongCount.value += 1
} else {
notice.value = result.duplicate
? '该词已按上一次的评分记录,未重复计分。'
: '该词已在其他页面复习,本次未计分。'
}
if (result.requeued) {
const next = result.item ?? { ...item, level: result.levelAfter, status: result.statusAfter as ReviewItem['status'], dueAt: result.dueAtAfter }
queue.value = [...queue.value, { ...next, dueAt: result.dueAtAfter, level: result.levelAfter, status: result.statusAfter as ReviewItem['status'] }]
}
}
// Load the next page of due words, keeping the counters of the round in progress.
async function continueRound(): Promise<void> {
await load(false)
}
return {
queue, current, loading, error, busy, revealed, answered, correctCount, wrongCount, wordsReviewed, resolved,
notice, pending, finished, empty, load, reveal, answer, continueRound, reset,
}
})
+28 -3
View File
@@ -36,10 +36,12 @@ export const useSessionStore = defineStore('session', () => {
}
async function request<T>(path: string, method = 'GET', body?: unknown, auth = token, version = generation): Promise<T> {
// A multipart body carries its own content type with the boundary, so it is sent as is.
const multipart = body instanceof FormData
const response = await fetch(`/api/v1/${path}`, {
method,
headers: { ...(auth ? { Authorization: `Bearer ${auth}` } : {}), ...(body ? { 'Content-Type': 'application/json' } : {}) },
body: body ? JSON.stringify(body) : undefined,
headers: { ...(auth ? { Authorization: `Bearer ${auth}` } : {}), ...(body && !multipart ? { 'Content-Type': 'application/json' } : {}) },
body: body ? (multipart ? body : JSON.stringify(body)) : undefined,
cache: 'no-store',
})
if (response.status === 401 && version === generation) {
@@ -51,6 +53,29 @@ export const useSessionStore = defineStore('session', () => {
return result.data as T
}
/**
* Fetches binary content with the same session as every other request. A media element cannot
* carry an Authorization header, so the bytes are fetched here and handed to the page as a blob
* URL instead of putting the token into a URL where it could be logged or shared.
*/
async function requestBlob(path: string): Promise<Blob> {
const response = await fetch(`/api/v1/${path}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
cache: 'no-store',
})
if (!response.ok) {
let message = '附件暂时无法加载。'
try {
const result = await response.json()
if (result?.msg) message = result.msg
} catch {
// A non-JSON error body keeps the default message.
}
throw new ApiError(message, response.status)
}
return response.blob()
}
async function login(username: string, password: string) {
clear()
initialized = true
@@ -102,5 +127,5 @@ export const useSessionStore = defineStore('session', () => {
if (previousToken) await request<null>('logout', 'POST', undefined, previousToken, -1)
}
return { user, space, notice, login, restore, logout, loadSpace, request }
return { user, space, notice, login, restore, logout, loadSpace, request, requestBlob }
})
+154
View File
@@ -0,0 +1,154 @@
import { computed, ref, watch } from 'vue'
import { defineStore } from 'pinia'
import { useSessionStore } from './session'
import type { TermStatus } from '../composables/useReaderLookup'
/** One entry of the vocabulary list, exactly as the term endpoints return it. */
export interface VocabularyTerm {
id: number
term: string
originalForm: string
definition: string
examples: string[]
status: TermStatus
level: number
kind: 'word' | 'phrase'
wordCount: number
updatedAt: string
}
export interface VocabularyEdit {
definition: string
examples: string[]
status: TermStatus
level?: number
}
export const VOCAB_PAGE_SIZE = 20
/**
* The vocabulary list is one server page at a time. Filters live in the store so that opening
* and closing the edit dialog, or coming back from the reader, keeps what the learner searched
* for; the view mirrors them into the URL for reloads.
*/
export const useVocabularyStore = defineStore('vocabulary', () => {
const session = useSessionStore()
const items = ref<VocabularyTerm[]>([])
const total = ref(0)
const page = ref(1)
const query = ref('')
const status = ref<TermStatus | ''>('')
const kind = ref<'word' | 'phrase' | ''>('')
const loading = ref(false)
const error = ref('')
const saving = ref(false)
const saveError = ref('')
let sequence = 0
let generation = 0
const hasFilters = computed(() => query.value !== '' || status.value !== '' || kind.value !== '')
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / VOCAB_PAGE_SIZE)))
watch(() => session.user?.id ?? null, (next, previous) => {
if (next !== previous) reset()
}, { flush: 'sync' })
function ownerId(): number | null {
return session.user?.id ?? null
}
function isStale(version: number, owner: number | null): boolean {
return version !== generation || ownerId() !== owner
}
function reset(): void {
generation++
sequence++
items.value = []
total.value = 0
page.value = 1
query.value = ''
status.value = ''
kind.value = ''
loading.value = false
error.value = ''
saving.value = false
saveError.value = ''
}
/** Loads the current page with the current filters. */
async function load(): Promise<void> {
const version = generation
const owner = ownerId()
const seq = ++sequence
loading.value = true
error.value = ''
try {
const params = new URLSearchParams()
if (query.value.trim()) params.set('query', query.value.trim())
if (status.value) params.set('status', status.value)
if (kind.value) params.set('kind', kind.value)
params.set('page', String(page.value))
params.set('limit', String(VOCAB_PAGE_SIZE))
const result = await session.request<{ items?: VocabularyTerm[]; total?: number }>(`terms?${params.toString()}`)
if (seq !== sequence || isStale(version, owner)) return
items.value = Array.isArray(result?.items) ? result.items : []
total.value = typeof result?.total === 'number' ? result.total : items.value.length
} catch (reason) {
if (seq !== sequence || isStale(version, owner)) return
error.value = reason instanceof Error ? reason.message : '词汇库暂时无法加载,请稍后重试。'
} finally {
if (seq === sequence && !isStale(version, owner)) loading.value = false
}
}
/** Applying a filter always starts from the first page. */
async function apply(): Promise<void> {
page.value = 1
await load()
}
async function goToPage(next: number): Promise<void> {
const target = Math.min(Math.max(1, next), totalPages.value)
if (target === page.value) return
page.value = target
await load()
}
async function clearFilters(): Promise<void> {
query.value = ''
status.value = ''
kind.value = ''
await apply()
}
/**
* Saves one entry through the same endpoint the reader uses. Identity is not editable, and the
* server applies the same status/level and schedule rules as a save from the reader.
*/
async function updateTerm(id: number, input: VocabularyEdit): Promise<VocabularyTerm> {
const version = generation
const owner = ownerId()
saving.value = true
saveError.value = ''
const body: Record<string, unknown> = { definition: input.definition, examples: input.examples, status: input.status }
if (input.status === 'learning' && input.level !== undefined) body.level = input.level
try {
const result = await session.request<{ term: VocabularyTerm }>(`terms/${id}`, 'PATCH', body)
if (!isStale(version, owner)) {
items.value = items.value.map(item => (item.id === id ? result.term : item))
}
return result.term
} catch (reason) {
if (!isStale(version, owner)) saveError.value = reason instanceof Error ? reason.message : '保存失败,请稍后重试。'
throw reason instanceof Error ? reason : new Error('保存失败,请稍后重试。')
} finally {
if (!isStale(version, owner)) saving.value = false
}
}
return {
items, total, page, query, status, kind, loading, error, saving, saveError,
hasFilters, totalPages, load, apply, goToPage, clearFilters, updateTerm, reset,
}
})
+329 -64
View File
@@ -1,123 +1,309 @@
: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-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: 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: 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: 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: 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: 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-page.has-lookup { max-width: 1120px; }
.reader-page { max-width: min(1040px, 92vw); }
.chapter-author { margin: -6px 0 0; color: var(--text-muted); font-size: 14px; }
.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: min(1240px, 96vw); }
.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; }
.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; }
/* Saved words keep one style per status everywhere they appear. */
.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-draft { border-top: 1px solid #e0e3d8; padding-top: 18px; margin-top: 20px; }
.lookup-draft label { display: flex; justify-content: space-between; gap: 10px; font-size: 14px; }
.lookup-draft label span { color: #8b794e; font-size: 12px; }
.lookup-draft textarea { width: 100%; resize: vertical; padding: 12px; margin-top: 10px; font: inherit; line-height: 1.6; color: #233d31; border: 1px solid #d6dccf; border-radius: 8px; background: #fffefa; }
.lookup-draft textarea:focus-visible { outline: 2px solid #bc803d; outline-offset: 2px; }
.sense-heading span { color: var(--text-muted); font-size: 13px; }
.lookup-phonetic { color: var(--text-muted); font-size: 15px; font-weight: 400; margin-left: 8px; }
.lookup-translation { white-space: pre-line; }
.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 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: 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; }
/* The chapter body is the main editing surface, so it grows with the window instead of staying
fixed at its rows attribute; the dialog is sized by viewport so a small laptop still fits. */
.chapter-text-input .el-textarea__inner { min-height: 62vh; line-height: 1.9; }
.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; }
.vocab-search { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 22px; }
.vocab-search label { font-size: 14px; }
.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 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: 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 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 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: 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 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) {
.login-page { grid-template-columns: 1fr; }
.welcome { padding: 28px; }
@@ -138,10 +324,89 @@ a.chapter-name:hover { color: #315c43; text-decoration: underline; }
.import-form { padding: 20px; }
.chapter-row { grid-template-columns: 30px minmax(0, 1fr); align-items: start; row-gap: 10px; padding: 15px 16px; }
.chapter-row .status-chip, .chapter-row .el-button { grid-column: 2; justify-self: start; }
.reader-text { font-size: 16px; line-height: 1.95; }
.chapter-complete { flex-direction: column; align-items: stretch; }
.chapter-complete .el-button { width: 100%; }
.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; }
/* Chapter illustration: a small thumbnail that opens the original in a dialog. */
.illustration-thumb { display: inline-flex; flex-direction: column; align-items: flex-start; gap: 6px; margin: 0 0 18px; padding: 8px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); cursor: zoom-in; }
.illustration-thumb img { display: block; height: 120px; width: auto; max-width: 100%; border-radius: 8px; object-fit: cover; }
.illustration-thumb:hover { border-color: var(--accent); }
.illustration-thumb:focus-visible { outline: 3px solid var(--accent-marker); outline-offset: 3px; }
.illustration-hint { font-size: 12px; color: var(--text-muted); }
.illustration-full { display: block; max-width: min(88vw, 1200px); max-height: 78vh; width: auto; height: auto; object-fit: contain; }
/* The book page keeps the cover control to one compact row so the chapter list gets the space. */
.cover-row { display: flex; align-items: center; gap: 14px; margin: 0 0 18px; }
.cover-preview { flex: 0 0 120px; aspect-ratio: 16 / 9; border-radius: 10px; overflow: hidden; background: var(--accent-soft); }
.cover-preview img { width: 100%; height: 100%; object-fit: cover; display: block; }
.cover-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.cover-actions input[type='file'] { position: absolute; width: 1px; height: 1px; opacity: 0; overflow: hidden; }
.cover-actions label { position: relative; cursor: pointer; border: 1px solid var(--border-strong); border-radius: 20px; padding: 0 14px; background: var(--surface-raised); }
.cover-actions label.is-busy { opacity: 0.6; cursor: progress; }
/* The book title shows its author next to it. */
.title-line { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
.title-line h1 { margin: 14px 0 6px; }
.book-author { color: var(--text-muted); font-size: 15px; }
/* Book covers: the card shows the stored image or a default block built from the title. */
.book-cover { display: block; aspect-ratio: 16 / 9; border-radius: 10px; overflow: hidden; background: var(--accent-soft); text-decoration: none; }
.book-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
.book-cover-default { display: grid; place-items: center; width: 100%; height: 100%; font-family: Georgia, serif; font-size: 40px; color: var(--accent); }
/* Book attachments: metadata, preview and the upload controls. */
.attachments { margin-top: 26px; padding: 22px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); }
.attachments h2 { margin: 0 0 16px; font-size: 19px; }
.attachment-row { display: flex; gap: 18px; align-items: flex-start; padding: 14px 0; border-top: 1px solid var(--border); }
.attachment-row:first-of-type { border-top: 0; padding-top: 0; }
.attachment-preview { flex: 0 0 160px; aspect-ratio: 16 / 9; border-radius: 8px; overflow: hidden; background: var(--accent-soft); }
.attachment-preview img { width: 100%; height: 100%; object-fit: cover; display: block; }
.attachment-info { min-width: 0; flex: 1; }
.attachment-label { margin: 0 0 4px; font-weight: 600; }
.attachment-info .subtle { margin: 4px 0 0; }
/* A label and its input share one line, so the dialog spends its height on the text being edited. */
.field-row { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
.field-row label { flex: 0 0 76px; margin: 0; font-size: 14px; }
.field-row .el-input { flex: 1; }
/* One compact line per attachment: label, state, actions, then the size rule underneath. */
.attachment-block { display: flex; gap: 14px; align-items: flex-start; margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border); }
.attachment-block .attachment-preview { flex: 0 0 96px; aspect-ratio: 16 / 9; border-radius: 8px; overflow: hidden; background: var(--accent-soft); }
.attachment-block .attachment-info { min-width: 0; flex: 1; }
.attachment-block .attachment-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 0; }
.attachment-block .attachment-label { font-weight: 600; }
.attachment-block .subtle { margin: 2px 0 0; }
.attachment-heading { margin: 22px 0 4px; font-size: 15px; font-weight: 600; color: var(--text-strong); }
.attachment-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 10px 0 0; }
.attachment-actions input[type='file'] { position: absolute; width: 1px; height: 1px; opacity: 0; overflow: hidden; }
.attachment-actions label { position: relative; cursor: pointer; border: 1px solid var(--border-strong); border-radius: 20px; padding: 0 14px; background: var(--surface-raised); }
.attachment-actions label.is-busy { opacity: 0.6; cursor: progress; }
/* Audio player: sticky so the controls stay reachable while the chapter is read. */
.audio-player { position: sticky; top: 0; z-index: 5; display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-bottom: 18px; padding: 12px 16px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); box-shadow: 0 2px 10px #233d3114; }
.audio-clock { font-size: 13px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
.audio-range { flex: 1 1 160px; min-width: 120px; accent-color: var(--accent); }
.audio-rates { display: flex; gap: 4px; }
.audio-rate { min-width: 44px; min-height: 32px; border: 1px solid var(--border-strong); border-radius: 16px; background: var(--surface-raised); color: var(--text); font-size: 12px; cursor: pointer; }
.audio-rate.is-active { border-color: var(--accent); color: var(--accent); font-weight: 600; }
.audio-error { display: inline-flex; align-items: center; gap: 6px; color: var(--notice-text); font-size: 13px; }
.audio-player audio { display: none; }
+396 -5
View File
@@ -1,17 +1,206 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { ElButton } from 'element-plus'
import { canRetry, statusLabel, useLibraryStore } from '../stores/library'
import { ElButton, ElDialog, ElInput, ElMessageBox } from 'element-plus'
import { canRetry, fileSizeLabel, statusLabel, TEXT_MAX_CODE_POINTS, textProblem, titleProblem, useLibraryStore, type ChapterSource, type ChapterSummary } from '../stores/library'
import { useSessionStore } from '../stores/session'
import DisplaySettings from '../components/DisplaySettings.vue'
const session = useSessionStore()
const library = useLibraryStore()
const route = useRoute()
const router = useRouter()
const retryError = ref('')
const notice = ref('')
// Renaming the book.
const bookDialog = ref(false)
const bookTitle = ref('')
const bookAuthor = ref('')
const bookError = ref('')
// Editing one chapter: title and text, loaded from the source endpoint.
const chapterDialog = ref(false)
const chapterSource = ref<ChapterSource | null>(null)
const chapterTitle = ref('')
const chapterAuthor = ref('')
const chapterText = ref('')
const chapterError = ref('')
const chapterLoading = ref(false)
const saving = ref(false)
const bookId = computed(() => Number(route.params.id))
const chapterLength = computed(() => [...chapterText.value].length)
const coverError = ref('')
// Errors for the chapter's files, shown inside the chapter editor next to the file they belong to.
const illustrationError = ref('')
const audioError = ref('')
const COVER_RULES = { types: ['image/jpeg', 'image/png', 'image/webp'], maxBytes: 2 << 20, label: '封面只支持 JPG、PNG 或 WebP 图片,且不超过 2 MiB。' }
const ILLUSTRATION_RULES = { types: ['image/jpeg', 'image/png', 'image/webp'], maxBytes: 2 << 20, label: '插图只支持 JPG、PNG 或 WebP 图片,且不超过 2 MiB。' }
const AUDIO_RULES = { types: ['audio/mpeg'], maxBytes: 20 << 20, label: '音频只支持 MP3,且不超过 20 MiB。' }
const coverState = computed(() => {
const cover = library.book?.attachments?.cover
if (!cover) return '未设置(显示默认封面)'
return `${imageLabel(cover.mime)} · ${fileSizeLabel(cover.byteSize)}`
})
function imageLabel(mime: string): string {
if (mime === 'image/png') return 'PNG'
if (mime === 'image/webp') return 'WebP'
return 'JPG'
}
function clock(seconds: number): string {
const total = Math.max(0, Math.floor(seconds))
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`
}
/**
* The meta line of one chapter row: audio and its position only. The illustration is deliberately
* not shown here (it is read inside the chapter), so the row stays compact and the list does not
* fetch every image.
*/
function chapterMeta(item: ChapterSummary): string {
const parts: string[] = []
if (item.audioVersion) {
parts.push('有音频')
if (item.playbackSeconds) parts.push(`上次 ${clock(item.playbackSeconds)}`)
}
return parts.join(' · ')
}
function attachmentSummary(item: ChapterSummary | null): string {
if (!item) return ''
const files: string[] = []
if (item.illustrationVersion) files.push(`插图 ${fileSizeLabel(0)}` .trim())
return files.join(' · ')
}
/** The state line of one attachment inside the dialog. */
function fileState(item: ChapterSummary | null, kind: 'illustration' | 'audio'): string {
if (!item) return '未设置'
const version = kind === 'illustration' ? item.illustrationVersion : item.audioVersion
if (!version) return '未设置'
if (kind === 'illustration') return '已设置'
const position = item.playbackSeconds ?? 0
return position > 0 ? `已设置 · 上次播放位置 ${clock(position)}` : '已设置'
}
/** The browser reports a type, but the server checks the bytes: this only avoids an obvious mistake. */
function reject(rules: { types: readonly string[]; maxBytes: number; label: string }, file: File): string {
if (file.size === 0) return '文件为空,请重新选择。'
if (file.size > rules.maxBytes) return rules.label
if (file.type && !rules.types.includes(file.type)) return rules.label
return ''
}
async function uploadCoverFile(file: File): Promise<void> {
if (library.book === null) return
coverError.value = ''
const problem = reject(COVER_RULES, file)
if (problem) {
coverError.value = problem
return
}
try {
await library.uploadCover(library.book.id, file)
notice.value = '封面已更新。'
} catch (reason) {
coverError.value = reason instanceof Error ? reason.message : '上传失败,请稍后重试。'
}
}
function onCoverPicked(event: Event): void {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (file) void uploadCoverFile(file)
}
async function removeCover(): Promise<void> {
if (library.book === null) return
try {
await ElMessageBox.confirm('移除这本书的封面?', '移除封面', {
confirmButtonText: '确认移除', cancelButtonText: '取消', type: 'warning',
})
} catch { return }
coverError.value = ''
try {
await library.deleteCover(library.book.id)
notice.value = '封面已移除。'
} catch (reason) {
coverError.value = reason instanceof Error ? reason.message : '移除失败,请稍后重试。'
}
}
/**
* The chapter being edited, read from the list so an upload is reflected immediately. The editor
* holds the title and the text, and the same dialog also manages the chapter's illustration and
* audio: one place to edit a chapter, with one entry point per row.
*/
const attachmentLive = computed(() => {
const id = chapterSource.value?.id
if (id === undefined) return null
return library.chapters.find(item => item.id === id) ?? null
})
async function uploadChapterFile(kind: 'illustration' | 'audio', file: File): Promise<void> {
const chapter = attachmentLive.value
if (!chapter) return
const rules = kind === 'illustration' ? ILLUSTRATION_RULES : AUDIO_RULES
if (kind === 'illustration') illustrationError.value = ''
else audioError.value = ''
const problem = reject(rules, file)
if (problem) {
if (kind === 'illustration') illustrationError.value = problem
else audioError.value = problem
return
}
try {
await library.uploadChapterFile(chapter.id, kind, file)
notice.value = kind === 'illustration' ? '插图已更新。' : '音频已更新,播放位置已重置。'
} catch (reason) {
const message = reason instanceof Error ? reason.message : '上传失败,请稍后重试。'
if (kind === 'illustration') illustrationError.value = message
else audioError.value = message
}
}
function onIllustrationPicked(event: Event): void {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (file) void uploadChapterFile('illustration', file)
}
function onChapterAudioPicked(event: Event): void {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (file) void uploadChapterFile('audio', file)
}
async function removeChapterFile(kind: 'illustration' | 'audio'): Promise<void> {
const chapter = attachmentLive.value
if (!chapter) return
const question = kind === 'illustration' ? '移除这一章的插图?' : '移除这一章的音频?播放位置会一起清除。'
try {
await ElMessageBox.confirm(question, kind === 'illustration' ? '移除插图' : '移除音频', {
confirmButtonText: '确认移除', cancelButtonText: '取消', type: 'warning',
})
} catch { return }
if (kind === 'illustration') illustrationError.value = ''
else audioError.value = ''
try {
await library.deleteChapterFile(chapter.id, kind)
notice.value = kind === 'illustration' ? '插图已移除。' : '音频已移除。'
} catch (reason) {
const message = reason instanceof Error ? reason.message : '移除失败,请稍后重试。'
if (kind === 'illustration') illustrationError.value = message
else audioError.value = message
}
}
async function load() {
retryError.value = ''
@@ -24,6 +213,101 @@ async function retry(chapterId: number) {
catch (reason) { retryError.value = reason instanceof Error ? reason.message : '重试失败,请稍后重试。' }
}
function openBookDialog() {
bookTitle.value = library.book?.title ?? ''
bookAuthor.value = library.book?.author ?? ''
bookError.value = ''
bookDialog.value = true
}
async function saveBookTitle() {
if (saving.value || library.book === null) return
bookError.value = titleProblem(bookTitle.value)
if (bookError.value) return
saving.value = true
try {
await library.updateBook(library.book.id, { title: bookTitle.value, author: bookAuthor.value })
bookDialog.value = false
notice.value = '书籍信息已更新。'
} catch (reason) {
bookError.value = reason instanceof Error ? reason.message : '保存失败,请稍后重试。'
} finally {
saving.value = false
}
}
async function openChapterDialog(chapterId: number) {
chapterDialog.value = true
chapterLoading.value = true
chapterError.value = ''
illustrationError.value = ''
audioError.value = ''
chapterSource.value = null
try {
const source = await library.loadChapterSource(chapterId)
chapterSource.value = source
chapterTitle.value = source.title
chapterAuthor.value = source.author
chapterText.value = source.text
} catch (reason) {
chapterError.value = reason instanceof Error ? reason.message : '章节内容暂时无法加载,请稍后重试。'
} finally {
chapterLoading.value = false
}
}
async function saveChapter() {
const source = chapterSource.value
if (saving.value || source === null) return
chapterError.value = titleProblem(chapterTitle.value) || textProblem(chapterText.value)
if (chapterError.value) return
saving.value = true
try {
const edited = await library.updateChapter(source.id, {
title: chapterTitle.value, author: chapterAuthor.value, text: chapterText.value,
})
chapterDialog.value = false
notice.value = edited.versionChanged ? '章节已保存为新版本,正在重新处理。' : '章节已保存。'
} catch (reason) {
chapterError.value = reason instanceof Error ? reason.message : '保存失败,请稍后重试。'
} finally {
saving.value = false
}
}
async function confirmDeleteBook() {
if (library.book === null) return
const id = library.book.id
try {
await ElMessageBox.confirm('删除这本书及其章节?已保存的生词和短语将保留。', '删除书籍', {
confirmButtonText: '确认删除', cancelButtonText: '取消,保留书籍', type: 'warning',
})
} catch { return }
try {
const deleted = await library.deleteBook(id)
await router.replace(`/?deleted=${deleted.chapters ?? 0}`)
} catch (reason) {
retryError.value = reason instanceof Error ? reason.message : '删除失败,请稍后重试。'
}
}
async function confirmDeleteChapter(chapterId: number, ordinal: number) {
try {
await ElMessageBox.confirm(`删除第 ${ordinal} 章?本章正文将被删除,已保存词条保留。`, '删除章节', {
confirmButtonText: '确认删除章节', cancelButtonText: '取消', type: 'warning',
})
} catch { return }
try {
const deleted = await library.deleteChapter(chapterId)
// The chapter no longer exists, so the dialog closes and the list reports the remainder.
chapterDialog.value = false
chapterSource.value = null
notice.value = `章节已删除 · 剩余 ${deleted.remaining} 章`
} catch (reason) {
retryError.value = reason instanceof Error ? reason.message : '删除失败,请稍后重试。'
}
}
async function logout() {
try { await session.logout() }
catch { session.notice = '已退出此设备。服务器暂时无法连接,请稍后重试。' }
@@ -40,8 +324,9 @@ onUnmounted(() => library.closeBook())
<div v-if="session.user">
<header class="site-header">
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink></nav>
<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>
@@ -56,13 +341,33 @@ onUnmounted(() => library.closeBook())
<template v-else-if="library.book">
<div class="page-title">
<div>
<h1>{{ library.book.title }}</h1>
<div class="title-line">
<h1>{{ library.book.title }}</h1>
<span v-if="library.book.author" class="book-author" data-testid="book-author">{{ library.book.author }}</span>
</div>
<p class="subtle">{{ library.chapters.length }} 个章节 · 语言 英语</p>
</div>
<div class="page-actions">
<ElButton data-testid="edit-book" @click="openBookDialog">编辑书籍</ElButton>
<RouterLink :to="`/import?book=${library.book.id}`" class="link-button">追加章节</RouterLink>
<ElButton type="danger" plain data-testid="delete-book" @click="confirmDeleteBook">删除书籍</ElButton>
</div>
</div>
<p v-if="notice" role="status" class="chapter-notice" data-testid="book-notice">{{ notice }}</p>
<section class="cover-row" aria-label="书籍封面" data-testid="attachments">
<div class="cover-preview">
<img v-if="library.coverUrls[library.book.id]" :src="library.coverUrls[library.book.id]" alt="当前封面" data-testid="cover-preview" />
<span v-else class="book-cover-default" aria-hidden="true">{{ library.book.title.slice(0, 1) }}</span>
</div>
<div class="cover-actions">
<label class="link-button" :class="{ 'is-busy': library.uploadingKind === 'cover' }">
{{ library.book.attachments?.cover ? '替换封面' : '上传封面' }}
<input type="file" accept="image/jpeg,image/png,image/webp" data-testid="cover-input" :disabled="library.uploadingKind !== null" @change="onCoverPicked" />
</label>
<ElButton v-if="library.book.attachments?.cover" size="small" text :disabled="library.uploadingKind !== null" data-testid="cover-remove" @click="removeCover">移除</ElButton>
<span v-if="coverError" role="alert" class="field-error">{{ coverError }}</span>
</div>
</section>
<p v-if="retryError" role="alert" class="notice">{{ retryError }}</p>
<ul v-if="library.chapters.length" class="chapter-list" aria-label="章节列表">
<li v-for="item in library.chapters" :key="item.id" class="chapter-row">
@@ -72,9 +377,11 @@ onUnmounted(() => library.closeBook())
<span v-else class="chapter-name">{{ item.title }}</span>
<p class="chapter-meta">
{{ item.charCount }} 字符
<template v-if="chapterMeta(item)"> · {{ chapterMeta(item) }}</template>
<template v-if="item.status === 'failed' && item.errorMessage"> · {{ item.errorMessage }}</template>
</p>
</div>
<span v-if="item.status === 'ready' && item.readAt" class="status-chip status-read" data-testid="chapter-read-chip">已读</span>
<span class="status-chip" :class="`status-${item.status}`">{{ statusLabel(item.status) }}</span>
<ElButton
v-if="canRetry(item)"
@@ -82,13 +389,97 @@ onUnmounted(() => library.closeBook())
:loading="library.retryingChapterId === item.id"
@click="retry(item.id)"
>重试</ElButton>
<ElButton size="small" :data-testid="`edit-chapter-${item.id}`" @click="openChapterDialog(item.id)">编辑</ElButton>
</li>
</ul>
<section v-else class="empty-library" aria-label="章节列表">
<section v-else class="empty-library" aria-label="章节列表" data-testid="empty-chapters">
<h2>这一本书还没有章节</h2>
<p class="subtle">粘贴一段英文即可生成第一章。</p>
</section>
</template>
<ElDialog v-model="bookDialog" title="编辑书籍" width="560" data-testid="book-dialog">
<div class="field-row">
<label for="book-title">书名</label>
<ElInput id="book-title" v-model="bookTitle" type="text" maxlength="200" :disabled="saving" />
</div>
<div class="field-row">
<label for="book-author">作者</label>
<ElInput id="book-author" v-model="bookAuthor" type="text" maxlength="120" placeholder="可留空" :disabled="saving" data-testid="book-author-input" />
</div>
<p v-if="bookError" role="alert" class="field-error">{{ bookError }}</p>
<template #footer>
<ElButton @click="bookDialog = false">取消</ElButton>
<ElButton type="primary" :loading="saving" data-testid="save-book" @click="saveBookTitle">保存修改</ElButton>
</template>
</ElDialog>
<ElDialog v-model="chapterDialog" title="编辑章节" width="min(1180px, 94vw)" data-testid="chapter-dialog">
<p v-if="chapterLoading" role="status" class="loading">正在加载…</p>
<template v-else-if="chapterSource">
<div class="field-row">
<label for="chapter-title">章节标题</label>
<ElInput id="chapter-title" v-model="chapterTitle" type="text" maxlength="200" :disabled="saving" />
</div>
<div class="field-row">
<label for="chapter-author">作者</label>
<ElInput id="chapter-author" v-model="chapterAuthor" type="text" maxlength="120" placeholder="可留空" :disabled="saving" data-testid="chapter-author" />
</div>
<label for="chapter-text">正文</label>
<ElInput id="chapter-text" v-model="chapterText" type="textarea" class="chapter-text-input" :rows="18" :disabled="saving" />
<p class="counter">{{ chapterLength }} / {{ TEXT_MAX_CODE_POINTS }} 字符 · 保存后重新处理这一章</p>
<div class="attachment-block">
<div class="attachment-preview">
<img v-if="attachmentLive && library.illustrationUrls[attachmentLive.id]" :src="library.illustrationUrls[attachmentLive.id]" alt="当前插图" data-testid="illustration-preview" />
<span v-else class="book-cover-default" aria-hidden="true">图</span>
</div>
<div class="attachment-info">
<div class="attachment-actions">
<span class="attachment-label">插图</span>
<span class="subtle" data-testid="illustration-state">{{ attachmentLive ? fileState(attachmentLive, 'illustration') : '未设置' }}</span>
<label class="link-button" :class="{ 'is-busy': library.uploadingKind === 'illustration' }">
{{ attachmentLive?.illustrationVersion ? '替换' : '上传' }}
<input type="file" accept="image/jpeg,image/png,image/webp" data-testid="illustration-input" :disabled="library.uploadingKind !== null" @change="onIllustrationPicked" />
</label>
<ElButton v-if="attachmentLive?.illustrationVersion" size="small" text :disabled="library.uploadingKind !== null" data-testid="illustration-remove" @click="removeChapterFile('illustration')">移除</ElButton>
</div>
<p v-if="illustrationError" role="alert" class="field-error">{{ illustrationError }}</p>
<p class="subtle">JPG/PNG/WebP · ≤2 MiB · ≤4096×4096</p>
</div>
</div>
<div class="attachment-block">
<div class="attachment-info">
<div class="attachment-actions">
<span class="attachment-label">音频</span>
<span class="subtle" data-testid="chapter-audio-state">{{ attachmentLive ? fileState(attachmentLive, 'audio') : '未设置' }}</span>
<label class="link-button" :class="{ 'is-busy': library.uploadingKind === 'audio' }">
{{ attachmentLive?.audioVersion ? '替换' : '上传' }}
<input type="file" accept="audio/mpeg,.mp3" data-testid="chapter-audio-input" :disabled="library.uploadingKind !== null" @change="onChapterAudioPicked" />
</label>
<ElButton v-if="attachmentLive?.audioVersion" size="small" text :disabled="library.uploadingKind !== null" data-testid="chapter-audio-remove" @click="removeChapterFile('audio')">移除</ElButton>
</div>
<p v-if="audioError" role="alert" class="field-error">{{ audioError }}</p>
<p class="subtle">MP3 · ≤20 MiB · 替换或移除会重置位置</p>
</div>
</div>
<p class="subtle" data-testid="attachment-timing">标题、作者与正文点保存后生效;插图与音频选中文件后立即上传。</p>
</template>
<p v-if="chapterError" role="alert" class="field-error">{{ chapterError }}</p>
<template #footer>
<ElButton data-testid="cancel-chapter" @click="chapterDialog = false">取消</ElButton>
<ElButton
v-if="chapterSource"
type="danger"
plain
:disabled="saving"
data-testid="delete-chapter"
@click="confirmDeleteChapter(chapterSource.id, chapterSource.ordinal)"
>删除章节</ElButton>
<ElButton type="primary" :loading="saving" :disabled="chapterLoading || !chapterSource" data-testid="save-chapter" @click="saveChapter">保存章节</ElButton>
</template>
</ElDialog>
</main>
</div>
</template>
+72 -7
View File
@@ -2,8 +2,9 @@
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { ElButton, ElInput, ElOption, ElRadio, ElRadioGroup, ElSelect } from 'element-plus'
import { LANGUAGE_LABEL, TEXT_MAX_CODE_POINTS, textProblem, titleProblem, useLibraryStore, type SubmitTarget } from '../stores/library'
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()
@@ -14,13 +15,50 @@ const title = ref('')
const text = ref('')
const mode = ref<'new' | 'append'>('new')
const bookId = ref<number | undefined>(undefined)
// The accepted prototype offers both sources on one screen; the server treats them the same.
const source = ref<'paste' | 'txt'>('paste')
const file = ref<File | null>(null)
const fileInfo = ref<{ name: string; encoding: string; size: string } | null>(null)
const titleError = ref('')
const textError = ref('')
const fileError = ref('')
const bookError = ref('')
const length = computed(() => [...text.value].length)
const busy = computed(() => library.submitting)
/** The browser pre-check only replaces the server, it never replaces its verdict. */
async function readFile(selected: File): Promise<void> {
fileError.value = ''
fileInfo.value = null
const problem = fileProblem(selected)
if (problem) {
file.value = null
fileError.value = problem
return
}
try {
const decoder = new TextDecoder('utf-8', { fatal: true })
decoder.decode(await selected.arrayBuffer())
} catch {
file.value = null
fileError.value = '文件不是 UTF-8 编码,请另存为 UTF-8 后重试。'
return
}
file.value = selected
fileInfo.value = { name: selected.name, encoding: 'UTF-8', size: fileSizeLabel(selected.size) }
// A file usually defines the title; the learner can still change it before submitting.
if (!title.value.trim()) title.value = selected.name.replace(/\.txt$/i, '').slice(0, 120)
}
function onFile(event: Event): void {
const selected = (event.target as HTMLInputElement).files?.[0]
file.value = null
fileInfo.value = null
fileError.value = ''
if (selected) void readFile(selected)
}
function requestedBookId(): number | undefined {
const raw = Array.isArray(route.query.book) ? route.query.book[0] : route.query.book
if (typeof raw !== 'string' || !/^\d+$/.test(raw)) return undefined
@@ -43,23 +81,35 @@ watch(mode, value => {
watch(title, () => { titleError.value = '' })
watch(text, () => { textError.value = '' })
// Switching source clears the other source's complaints and its result.
watch(source, () => {
titleError.value = ''
textError.value = ''
fileError.value = ''
library.submitError = ''
})
async function submit() {
if (busy.value) return
titleError.value = titleProblem(title.value)
textError.value = textProblem(text.value)
textError.value = source.value === 'paste' ? textProblem(text.value) : ''
fileError.value = source.value === 'txt' && !file.value ? (fileError.value || '请选择要导入的 TXT 文件。') : fileError.value
bookError.value = mode.value === 'append' && bookId.value === undefined ? '请选择要追加的书籍。' : ''
if (titleError.value || textError.value || bookError.value) return
if (titleError.value || textError.value || fileError.value || bookError.value) return
const target: SubmitTarget = mode.value === 'append' && bookId.value !== undefined
? { mode: 'append', bookId: bookId.value }
: { mode: 'new' }
try {
const createdBookId = await library.submit({ title: title.value, text: text.value, target })
const createdBookId = source.value === 'txt' && file.value
? await library.upload({ title: title.value, target, file: file.value })
: await library.submit({ title: title.value, text: text.value, target })
// A response that arrives after the user left this page must not navigate them back.
if (disposed) return
// The requestId was consumed by this submission, so the form starts clean.
title.value = ''
text.value = ''
file.value = null
fileInfo.value = null
await router.replace(`/books/${createdBookId}`)
} catch {
// library.submitError already carries the server message for the template.
@@ -78,8 +128,9 @@ onUnmounted(() => {
<div v-if="session.user">
<header class="site-header">
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink></nav>
<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>
@@ -92,6 +143,13 @@ onUnmounted(() => {
</div>
</div>
<form class="import-form" novalidate @submit.prevent="submit">
<div class="field">
<span class="field-label">导入方式</span>
<ElRadioGroup v-model="source" :disabled="busy" aria-label="导入方式">
<ElRadio value="paste">粘贴文本</ElRadio>
<ElRadio value="txt">TXT 文件</ElRadio>
</ElRadioGroup>
</div>
<div class="field">
<span class="field-label">语言</span>
<p class="fixed-value">{{ LANGUAGE_LABEL }}</p>
@@ -116,15 +174,22 @@ onUnmounted(() => {
<p v-if="library.booksError" role="alert" class="field-error">{{ library.booksError }}</p>
<p v-if="bookError" role="alert" class="field-error">{{ bookError }}</p>
</div>
<div class="field">
<div v-if="source === 'paste'" class="field">
<label for="text">正文</label>
<ElInput id="text" v-model="text" type="textarea" :rows="12" placeholder="在此粘贴英文正文…" :disabled="busy" />
<p class="counter">{{ length }} / {{ TEXT_MAX_CODE_POINTS }} 字符</p>
<p v-if="textError" role="alert" class="field-error">{{ textError }}</p>
</div>
<div v-else class="field">
<label for="file">TXT 文件</label>
<input id="file" data-testid="file-input" type="file" accept=".txt,text/plain" :disabled="busy" @change="onFile" />
<p v-if="fileInfo" class="counter" data-testid="file-info">{{ fileInfo.name }} · {{ fileInfo.encoding }} · {{ fileInfo.size }}</p>
<p v-else class="subtle">仅支持 UTF-8 的 .txt 文件,最多 2 MiB;文件不会保存在服务器上。</p>
<p v-if="fileError" role="alert" class="field-error">{{ fileError }}</p>
</div>
<p v-if="library.submitError" role="alert" class="notice">{{ library.submitError }}</p>
<div class="form-actions">
<ElButton type="primary" native-type="submit" :loading="busy" :disabled="busy">开始处理</ElButton>
<ElButton type="primary" native-type="submit" :loading="busy" :disabled="busy">{{ source === 'txt' ? '上传并处理' : '开始处理' }}</ElButton>
<RouterLink to="/" class="subtle">返回书库</RouterLink>
</div>
</form>
+24 -3
View File
@@ -1,15 +1,23 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
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'
const session = useSessionStore()
const library = useLibraryStore()
const router = useRouter()
const route = useRoute()
const loading = ref(true)
const error = ref('')
// A deletion returns here with the count it removed, so the learner sees what happened.
const deletedNotice = computed(() => {
const raw = Array.isArray(route.query.deleted) ? route.query.deleted[0] : route.query.deleted
if (typeof raw !== 'string' || !/^\d+$/.test(raw)) return ''
return '书籍已删除 · 已保存的生词和短语仍保留在生词本。'
})
async function load() {
loading.value = true
error.value = ''
@@ -31,8 +39,9 @@ onMounted(load)
<div v-if="session.user">
<header class="site-header">
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
<nav aria-label="学习导航"><RouterLink to="/" class="active-nav">我的书库</RouterLink></nav>
<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>
@@ -45,6 +54,7 @@ onMounted(load)
<ElButton type="primary" @click="router.push('/import')">导入内容</ElButton>
</div>
</div>
<p v-if="deletedNotice" role="status" class="chapter-notice" data-testid="library-notice">{{ deletedNotice }}</p>
<p v-if="loading" role="status" class="loading">正在加载…</p>
<div v-else-if="error || library.booksError" class="notice">
<p role="alert">{{ error || library.booksError }}</p>
@@ -52,6 +62,17 @@ onMounted(load)
</div>
<ul v-else-if="library.books.length" class="book-grid" aria-label="书籍列表">
<li v-for="item in library.books" :key="item.id" class="book-card">
<!-- The cover repeats the title link, so it is hidden from assistive technology and from
keyboard order: the title link next to it is the accessible way into the book. -->
<RouterLink
:to="`/books/${item.id}`"
class="book-cover"
aria-hidden="true"
tabindex="-1"
>
<img v-if="library.coverUrls[item.id]" :src="library.coverUrls[item.id]" alt="" data-testid="book-cover" />
<span v-else class="book-cover-default">{{ item.title.slice(0, 1) }}</span>
</RouterLink>
<RouterLink :to="`/books/${item.id}`" class="book-title">{{ item.title }}</RouterLink>
<p class="subtle">{{ item.chapterCount }} 个章节</p>
<p v-if="statusSummary(item)" class="status-summary">{{ statusSummary(item) }}</p>
+99
View File
@@ -0,0 +1,99 @@
<script setup lang="ts">
import { onMounted } from 'vue'
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()
const router = useRouter()
onMounted(() => { void progress.load() })
async function logout() {
try { await session.logout() }
catch { session.notice = '已退出此设备。服务器暂时无法连接,请稍后重试。' }
finally { await router.replace('/login') }
}
</script>
<template>
<div v-if="session.user">
<header class="site-header">
<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>
</header>
<main class="page progress-page">
<div class="page-title">
<div>
<h1>我的进度</h1>
<p class="subtle">只统计你自己的记录,按当前学习语言(英语)汇总。</p>
</div>
<div class="page-actions">
<RouterLink to="/review" class="link-button">开始到期复习</RouterLink>
<ElButton :loading="progress.loading" data-testid="progress-reload" @click="progress.load()">刷新</ElButton>
</div>
</div>
<p v-if="progress.loading && !progress.loaded" role="status" class="loading">正在加载…</p>
<div v-else-if="progress.error && !progress.loaded" class="notice">
<p role="alert">{{ progress.error }}</p>
<ElButton data-testid="progress-retry" @click="progress.load()">重试</ElButton>
</div>
<template v-else>
<p v-if="progress.error" role="alert" class="notice">{{ progress.error }}</p>
<section class="progress-stats" aria-label="学习统计">
<article class="stat-card" data-testid="stat-read">
<h2>已读章节</h2>
<p class="stat-value">{{ progress.data.readChapters }} / {{ progress.data.totalChapters }}</p>
<p class="subtle">章(可阅读)</p>
</article>
<article class="stat-card" data-testid="stat-due">
<h2>待复习</h2>
<p class="stat-value">{{ progress.data.dueNow }}</p>
<p class="subtle">个词条现在到期</p>
</article>
<article class="stat-card" data-testid="stat-known">
<h2>已知词</h2>
<p class="stat-value">{{ progress.data.knownTerms }}</p>
<p class="subtle">标记为已认识</p>
</article>
<article class="stat-card" data-testid="stat-learning">
<h2>学习中</h2>
<p class="stat-value">{{ progress.data.learningTerms }}</p>
<p class="subtle">按间隔复习</p>
</article>
<article class="stat-card" data-testid="stat-new">
<h2>新词</h2>
<p class="stat-value">{{ progress.data.newTerms }}</p>
<p class="subtle">刚保存,还没作答</p>
</article>
<article class="stat-card" data-testid="stat-ignored">
<h2>忽略</h2>
<p class="stat-value">{{ progress.data.ignoredTerms }}</p>
<p class="subtle">单独统计,不计入已知</p>
</article>
</section>
<section class="progress-books" aria-label="每本书的阅读进度">
<h2>每本书的阅读</h2>
<ul v-if="progress.hasBooks" class="progress-book-list" data-testid="progress-books">
<li v-for="item in progress.data.books" :key="item.id" class="progress-book-row">
<RouterLink :to="`/books/${item.id}`" class="chapter-name">{{ item.title }}</RouterLink>
<span class="subtle">{{ item.readChapters }} / {{ item.totalChapters }} 章已读</span>
</li>
</ul>
<p v-else class="subtle" data-testid="progress-no-books">还没有书。导入或粘贴一段英语,读完后在这里看到进度。</p>
<p class="subtle" data-testid="progress-saved">已保存词条 {{ progress.data.savedTerms }} 个(含忽略)。</p>
</section>
</template>
</main>
</div>
</template>
+249 -10
View File
@@ -1,11 +1,16 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { ElButton } from 'element-plus'
import { ElButton, ElDialog } from 'element-plus'
import { canRetry, statusLabel, useLibraryStore } from '../stores/library'
import { useSessionStore } from '../stores/session'
import { useReaderLookup } from '../composables/useReaderLookup'
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 AudioPlayer from '../components/AudioPlayer.vue'
import DisplaySettings from '../components/DisplaySettings.vue'
import LookupPanel from '../components/LookupPanel.vue'
const session = useSessionStore()
@@ -13,6 +18,8 @@ const library = useLibraryStore()
const route = useRoute()
const router = useRouter()
const retryError = ref('')
const rangeNotice = ref('')
const readerBody = ref<HTMLElement | null>(null)
const chapterId = computed(() => Number(route.params.id))
const chapter = computed(() => library.chapter)
@@ -20,10 +27,142 @@ const lookup = useReaderLookup(chapter)
// Retry uses the job id the chapter carries, no matter where it was loaded from.
const retryable = computed(() => chapter.value !== null && canRetry(chapter.value))
/** A dragged selection becomes a phrase; a single word stays a plain word lookup. */
function pickRange(anchor: number, focus: number, element: HTMLElement | null): void {
const text = library.readerText
const count = wordIndices(lookup.tokens.value.slice(anchor, focus + 1)).length
if (count < 2) return
if (count > MAX_PHRASE_WORDS) {
rangeNotice.value = `短语最多 ${MAX_PHRASE_WORDS} 个单词,请缩短选择范围。`
return
}
const range = normalizeTokenRange(lookup.tokens.value, text, anchor, focus)
if (!range) return
rangeNotice.value = ''
lookup.selectRange(range, element)
}
const selection = useTextSelection({ container: readerBody, onSelect: (anchor, focus) => pickRange(anchor, focus, readerBody.value) })
/** Shift-clicking a word extends the phrase from the word that was picked first. */
function selectToken(token: ReaderToken, element: HTMLElement, extend: boolean): void {
rangeNotice.value = ''
const anchor = lookup.selected.value
if (extend && anchor) {
const from = Number(element.dataset.tokenIndex)
const first = lookup.tokens.value.findIndex(item => item.start === anchor.start)
if (from >= 0 && first >= 0) {
pickRange(Math.min(from, first), Math.max(from, first), element)
return
}
}
lookup.select(token, element)
}
/** Clicking inside a saved phrase edits the phrase instead of the word under the cursor. */
function selectPhrase(span: PhraseSpan, element: HTMLElement): void {
const range = rangeOfSpan(lookup.tokens.value, library.readerText, span)
if (!range) return
rangeNotice.value = ''
lookup.selectRange(range, element, span.id)
}
const adjustOptions = computed(() => {
const current = lookup.range.value
if (!current) return null
const text = library.readerText
return {
startLeft: adjustTokenRange(lookup.tokens.value, text, current, 'start', -1) !== null,
startRight: adjustTokenRange(lookup.tokens.value, text, current, 'start', 1) !== null,
endLeft: adjustTokenRange(lookup.tokens.value, text, current, 'end', -1) !== null,
endRight: adjustTokenRange(lookup.tokens.value, text, current, 'end', 1) !== null,
}
})
const panelWord = computed(() => lookup.range.value?.text ?? lookup.selected.value?.text ?? '')
function closePanel(): void {
lookup.close()
selection.clear()
}
let lastChapterId: number | null = null
async function load() {
lookup.reset()
retryError.value = ''
rangeNotice.value = ''
// Switching chapters reports the position of the chapter that is being left.
if (lastChapterId !== null && lastChapterId !== chapterId.value) library.reportChapterPlayback(lastChapterId)
lastChapterId = chapterId.value
await library.loadChapter(chapterId.value)
restorePosition()
}
// The player belongs to the chapter that is open: the loaded audio is only shown when it belongs to
// this chapter, so switching chapters never reuses the previous file.
const audioSource = computed(() => (library.audioChapterId === chapter.value?.id ? library.audioUrl : ''))
const audioStart = computed(() => chapter.value?.playbackSeconds ?? 0)
const illustrationSource = computed(() => (chapter.value ? library.illustrationUrls[chapter.value.id] ?? '' : ''))
// The chapter shows a thumbnail; the original opens in a dialog so a large image never takes over
// the reading column.
const illustrationOpen = ref(false)
const illustrationTitle = computed(() => (chapter.value ? `${chapter.value.title} · 插图` : '插图'))
function rememberPlayback(seconds: number): void {
const chapterId = chapter.value?.id
if (chapterId === undefined) return
// The reported value is not awaited: leaving the page must not wait for the network.
void library.saveChapterPlayback(chapterId, seconds).catch(() => undefined)
}
// 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() {
@@ -33,6 +172,29 @@ async function retry() {
catch (reason) { retryError.value = reason instanceof Error ? reason.message : '重试失败,请稍后重试。' }
}
// Completing records only that this chapter was read. Word statuses and levels are untouched.
const completeNotice = ref('')
const readAt = computed(() => chapter.value?.readAt ?? null)
const readLabel = computed(() => (readAt.value ? `本章已读 · ${formatReadAt(readAt.value)}` : ''))
function formatReadAt(value: string): string {
const at = new Date(value)
if (Number.isNaN(at.getTime())) return '已记录'
const pad = (part: number) => String(part).padStart(2, '0')
return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())} ${pad(at.getHours())}:${pad(at.getMinutes())}`
}
async function complete() {
if (chapter.value === null) return
completeNotice.value = ''
try {
const result = await library.markChapterRead(chapter.value.id)
completeNotice.value = result.duplicate ? '本章已经标记过已读,没有重复计数。' : '已记为读完本章。'
} catch (reason) {
completeNotice.value = reason instanceof Error ? reason.message : '标记失败,请稍后重试。'
}
}
// Switching chapters reuses this component; only the route param changes.
function goTo(id: number | null) {
if (id === null) return
@@ -45,23 +207,33 @@ 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>
<div v-if="session.user">
<header class="site-header">
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink></nav>
<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>
</header>
<main class="page reader-page" :class="{ 'has-lookup': lookup.selected.value }" @keydown.esc="lookup.close()">
<main class="page reader-page" :class="{ 'has-lookup': lookup.selected.value || lookup.range.value }" @keydown.esc="closePanel">
<p v-if="library.chapterLoading && !chapter" role="status" class="loading">正在加载…</p>
<div v-else-if="library.chapterError" class="notice">
<p role="alert">{{ library.chapterError }}</p>
@@ -74,10 +246,17 @@ onUnmounted(() => library.closeChapter())
<RouterLink v-if="library.chapterBook" :to="`/books/${library.chapterBook.id}`">{{ library.chapterBook.title }}</RouterLink>
<span v-else>章节</span>
</p>
<AudioPlayer
v-if="audioSource"
:src="audioSource"
:initial-position="audioStart"
:on-position="rememberPlayback"
/>
<div class="page-title">
<h1>{{ chapter.title }}</h1>
<span class="status-chip" :class="`status-${chapter.status}`">{{ statusLabel(chapter.status) }}</span>
</div>
<p v-if="chapter.author" class="chapter-author" data-testid="chapter-author-line">{{ chapter.author }}</p>
<div v-if="chapter.status === 'failed'" class="notice">
<p role="alert">{{ chapter.errorMessage || '这一章处理失败。' }}</p>
<ElButton v-if="retryable" :loading="library.retryingChapterId === chapter.id" @click="retry">重试处理</ElButton>
@@ -85,20 +264,80 @@ onUnmounted(() => library.closeChapter())
</div>
<p v-else-if="chapter.status !== 'ready'" role="status" class="processing-hint">这一章还在{{ statusLabel(chapter.status) }},页面会自动刷新。</p>
<p v-if="retryError" role="alert" class="notice">{{ retryError }}</p>
<p v-if="rangeNotice" role="alert" class="notice" data-testid="range-notice">{{ rangeNotice }}</p>
<button
v-if="chapter.status === 'ready' && illustrationSource"
type="button"
class="illustration-thumb"
data-testid="chapter-illustration"
:aria-label="`查看《${chapter.title}》的插图大图`"
@click="illustrationOpen = true"
>
<img :src="illustrationSource" alt="本章插图缩略图" />
<span class="illustration-hint">点击查看大图</span>
</button>
<ElDialog v-model="illustrationOpen" :title="illustrationTitle" width="auto" data-testid="illustration-dialog">
<img class="illustration-full" :src="illustrationSource" alt="本章插图" />
</ElDialog>
<div v-if="chapter.status === 'ready'" class="reader-workspace">
<div class="reader-body">
<ReaderTokens :tokens="lookup.tokens.value" :original="library.readerText" :selected-start="lookup.selected.value?.start" @select="lookup.select" />
<div class="reader-body" ref="readerBody">
<ReaderTokens
:tokens="lookup.tokens.value"
:phrases="lookup.phrases.value"
:original="library.readerText"
:selected-start="lookup.selected.value?.start"
:phrase-start="lookup.rangeTermId.value"
@select="selectToken"
@select-phrase="selectPhrase"
/>
<div v-if="lookup.tokensError.value" class="tokens-notice">
<p role="status">{{ lookup.tokensError.value }}</p>
<ElButton data-testid="tokens-retry" :loading="lookup.tokensLoading.value" @click="lookup.loadTokens">重试加载单词</ElButton>
</div>
</div>
<LookupPanel v-if="lookup.selected.value" :word="lookup.selected.value.text" :result="lookup.result.value" :loading="lookup.loading.value" :error="lookup.error.value" v-model:draft="lookup.draft.value" @close="lookup.close()" @retry="lookup.lookup" @resize="lookup.keepSelectionVisible" />
<LookupPanel
v-if="lookup.selected.value || lookup.range.value"
:word="panelWord"
:phrase="lookup.range.value ? { wordCount: lookup.range.value.wordCount, stored: lookup.rangeStored.value } : null"
:adjust="adjustOptions"
:result="lookup.result.value"
:loading="lookup.loading.value"
:error="lookup.error.value"
:saving="lookup.saving.value"
:save-error="lookup.saveError.value"
:saved="lookup.saved.value"
:saved-term-id="lookup.savedTermId.value"
:prefilling="lookup.prefilling.value"
:prefill-error="lookup.prefillError.value"
:can-save="lookup.canSave.value"
v-model:definition="lookup.definition.value"
v-model:examples="lookup.examples.value"
v-model:status="lookup.status.value"
@close="closePanel"
@retry="lookup.lookup"
@save="lookup.save"
@adjust="(edge, direction) => { lookup.adjustRange(edge, direction); selection.clear() }"
@resize="lookup.keepSelectionVisible"
/>
</div>
<nav class="reader-nav" aria-label="章节切换">
<ElButton :disabled="library.navigation.previousChapterId === null" @click="goTo(library.navigation.previousChapterId)">上一章</ElButton>
<ElButton :disabled="library.navigation.nextChapterId === null" @click="goTo(library.navigation.nextChapterId)">下一章</ElButton>
</nav>
<section v-if="chapter.status === 'ready'" class="chapter-complete" aria-label="完成本章">
<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
type="primary"
:loading="library.completingChapterId === chapter.id"
data-testid="mark-read"
@click="complete"
>{{ readAt ? '再次标记已读' : '标记本章已读' }}</ElButton>
</section>
</template>
</main>
</div>
+94
View File
@@ -0,0 +1,94 @@
<script setup lang="ts">
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'
const session = useSessionStore()
const review = useReviewStore()
const router = useRouter()
function endReview() { void router.push('/') }
// Retrying reuses the grade of the failed attempt; the store keeps the answer id.
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() }
catch { session.notice = '已退出此设备。服务器暂时无法连接,请稍后重试。' }
finally { await router.replace('/login') }
}
// The template guards for a card before rendering it; this keeps the prop type strict.
const requireItem = (value: ReviewItem | null): ReviewItem => value as ReviewItem
</script>
<template>
<div v-if="session.user">
<header class="site-header">
<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>
</header>
<main class="page review-page">
<h1>到期复习</h1>
<p v-if="review.notice" role="status" class="review-notice" data-testid="review-notice">{{ review.notice }}</p>
<p v-if="review.loading" role="status" class="loading">正在加载…</p>
<div v-else-if="review.error && !review.current" class="notice">
<p role="alert">{{ review.error }}</p>
<ElButton data-testid="review-reload" @click="review.load()">重试</ElButton>
</div>
<section v-else-if="review.finished" class="review-summary" data-testid="review-summary">
<h2>本次复习完成</h2>
<p v-if="review.answered">复习了 {{ review.wordsReviewed }} 个词条 · 共 {{ review.answered }} 次作答</p>
<p v-else class="subtle">本轮没有新的计分:{{ review.resolved }} 个词条已在其他页面复习。</p>
<p class="subtle">答对 {{ review.correctCount }} · 答错或再学 {{ review.wrongCount }} · 已更新复习计划</p>
<p v-if="review.pending" role="status" class="notice">还有 {{ review.pending }} 个词条到期。</p>
<div class="review-grades">
<ElButton v-if="review.pending" type="primary" data-testid="review-more" @click="review.continueRound()">继续复习</ElButton>
<ElButton data-testid="review-finish" @click="endReview">继续阅读</ElButton>
</div>
</section>
<section v-else-if="review.empty" class="review-summary" data-testid="review-empty">
<h2>今天没有到期词条</h2>
<p class="subtle">读一篇文章,积累下一次的词汇。</p>
<ElButton data-testid="review-finish" @click="endReview">返回书库</ElButton>
</section>
<ReviewCard
v-else-if="review.current"
:item="requireItem(review.current)"
:position="review.answered + 1"
:total="review.answered + review.queue.length"
:revealed="review.revealed"
:busy="review.busy"
:error="review.error"
@reveal="review.reveal()"
@grade="grade"
@end="endReview"
@retry="retry"
/>
<p class="shortcut-hint" data-testid="review-shortcuts">键盘:空格 显示答案 · 1 答对 · 2 答错 · 3 再学一次</p>
</main>
</div>
</template>
+209
View File
@@ -0,0 +1,209 @@
<script setup lang="ts">
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'
import { useSessionStore } from '../stores/session'
const session = useSessionStore()
const vocabulary = useVocabularyStore()
const route = useRoute()
const router = useRouter()
const search = ref('')
const editing = ref<VocabularyTerm | null>(null)
const definition = ref('')
const examples = ref('')
const status = ref<TermStatus>('new')
const level = ref(1)
const formError = ref('')
const notice = ref('')
const queryString = computed(() => ({ query: search.value.trim(), status: vocabulary.status, kind: vocabulary.kind, page: vocabulary.page }))
const levelLabel = (value: number) => (value === 0 ? '' : ` · 等级 ${value}`)
const rowLabel = (item: VocabularyTerm) => `${item.originalForm} / ${item.definition || '(未填写释义)'} · ${statusLabel(item)}`
const statusLabel = (item: VocabularyTerm) => {
const label = TERM_STATUSES.find(entry => entry.value === item.status)?.label ?? item.status
return item.kind === 'phrase' ? `短语 · ${label}${levelLabel(item.level)}` : `${label}${levelLabel(item.level)}`
}
function readFilters(): void {
const read = (value: unknown) => (Array.isArray(value) ? value[0] : value)
const query = read(route.query.query)
const statusValue = read(route.query.status)
const kindValue = read(route.query.kind)
const page = Number(read(route.query.page) ?? 1)
search.value = typeof query === 'string' ? query : ''
vocabulary.query = search.value
vocabulary.status = TERM_STATUSES.some(entry => entry.value === statusValue) ? (statusValue as TermStatus) : ''
vocabulary.kind = kindValue === 'word' || kindValue === 'phrase' ? kindValue : ''
vocabulary.page = Number.isSafeInteger(page) && page >= 1 ? page : 1
}
async function load(): Promise<void> {
await vocabulary.load()
}
async function submitSearch(): Promise<void> {
vocabulary.query = search.value
await vocabulary.apply()
}
async function clearFilters(): Promise<void> {
search.value = ''
await vocabulary.clearFilters()
}
function openEditor(item: VocabularyTerm): void {
editing.value = item
definition.value = item.definition
examples.value = item.examples.join('\n')
status.value = item.status
level.value = item.level > 0 ? item.level : 1
formError.value = ''
notice.value = ''
vocabulary.saveError = ''
}
function closeEditor(): void {
editing.value = null
formError.value = ''
}
async function save(): Promise<void> {
const item = editing.value
if (!item || vocabulary.saving) return
formError.value = textProblem(definition.value) && '' // an empty definition is allowed
if (formError.value) return
try {
const examples2 = examples.value.split('\n').map(line => line.trim()).filter(Boolean)
const updated = await vocabulary.updateTerm(item.id, {
definition: definition.value,
examples: examples2,
status: status.value,
level: status.value === 'learning' ? level.value : undefined,
})
editing.value = null
notice.value = `已保存 · ${statusLabel(updated)}`
} catch {
// vocabulary.saveError already carries the server message for the dialog.
}
}
async function logout() {
try { await session.logout() }
catch { session.notice = '已退出此设备。服务器暂时无法连接,请稍后重试。' }
finally { await router.replace('/login') }
}
onMounted(async () => {
readFilters()
await load()
})
// The URL mirrors the filters so a reload or a return keeps what the learner searched for.
watch(() => [search.value, vocabulary.status, vocabulary.kind, vocabulary.page], () => {
void router.replace({ path: '/vocab', query: Object.fromEntries(Object.entries(queryString.value).filter(([, value]) => value !== '' && value !== undefined).map(([key, value]) => [key, String(value)])) })
})
</script>
<template>
<div v-if="session.user">
<header class="site-header">
<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>
</header>
<main class="page vocab-page">
<div class="page-title">
<div>
<h1>生词本</h1>
<p class="subtle">保存的词语与短语,共 {{ vocabulary.total }} 条。</p>
</div>
<div class="page-actions">
<RouterLink to="/review" class="link-button">开始到期复习</RouterLink>
</div>
</div>
<form class="vocab-search" novalidate @submit.prevent="submitSearch">
<label for="vocab-query">搜索词 / 释义</label>
<ElInput id="vocab-query" v-model="search" type="text" placeholder="输入词语或释义中的一部分" :disabled="vocabulary.loading" />
<ElButton type="primary" native-type="submit" :loading="vocabulary.loading" data-testid="vocab-search">搜索</ElButton>
<label for="vocab-status">状态</label>
<ElSelect id="vocab-status" v-model="vocabulary.status" class="vocab-filter" data-testid="vocab-status" @change="vocabulary.apply()">
<ElOption label="全部状态" value="" />
<ElOption v-for="item in TERM_STATUSES" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
<label for="vocab-kind">类型</label>
<ElSelect id="vocab-kind" v-model="vocabulary.kind" class="vocab-filter" data-testid="vocab-kind" @change="vocabulary.apply()">
<ElOption label="全部类型" value="" />
<ElOption label="词语" value="word" />
<ElOption label="短语" value="phrase" />
</ElSelect>
</form>
<p v-if="notice" role="status" class="chapter-notice" data-testid="vocab-notice">{{ notice }}</p>
<p v-if="vocabulary.loading" role="status" class="loading">正在加载…</p>
<div v-else-if="vocabulary.error" class="notice">
<p role="alert">{{ vocabulary.error }}</p>
<ElButton @click="load">重试</ElButton>
</div>
<template v-else-if="vocabulary.items.length">
<ul class="vocab-list" aria-label="词条列表">
<li v-for="item in vocabulary.items" :key="item.id" class="vocab-row">
<div class="vocab-info">
<p class="vocab-term">{{ item.originalForm }} <span class="subtle">/</span> {{ item.definition || '(未填写释义)' }}</p>
<p class="chapter-meta">
<span class="status-chip" :class="`status-${item.status}`">{{ statusLabel(item) }}</span>
<template v-if="item.examples.length"> · {{ item.examples[0] }}</template>
</p>
</div>
<ElButton size="small" :data-testid="`vocab-edit-${item.id}`" @click="openEditor(item)">编辑</ElButton>
</li>
</ul>
<nav v-if="vocabulary.totalPages > 1" class="vocab-paging" aria-label="分页">
<ElButton :disabled="vocabulary.page <= 1" data-testid="vocab-prev" @click="vocabulary.goToPage(vocabulary.page - 1)">上一页</ElButton>
<span class="subtle">第 {{ vocabulary.page }} / {{ vocabulary.totalPages }} 页</span>
<ElButton :disabled="vocabulary.page >= vocabulary.totalPages" data-testid="vocab-next" @click="vocabulary.goToPage(vocabulary.page + 1)">下一页</ElButton>
</nav>
</template>
<section v-else-if="vocabulary.hasFilters" class="empty-library" aria-label="搜索无结果" data-testid="vocab-none">
<h2>没有匹配的词条</h2>
<p class="subtle">试试其他关键词,或清除筛选。</p>
<ElButton data-testid="vocab-clear" @click="clearFilters">清除搜索与筛选</ElButton>
</section>
<section v-else class="empty-library" aria-label="空生词本" data-testid="vocab-empty">
<h2>生词本还是空的</h2>
<p class="subtle">阅读时点词或选择短语,保存后会出现在这里。</p>
<RouterLink to="/" class="link-button">返回书库</RouterLink>
</section>
<ElDialog :model-value="editing !== null" title="编辑词条" width="620" data-testid="vocab-dialog" @update:model-value="closeEditor">
<template v-if="editing">
<p class="subtle">{{ editing.originalForm }}{{ editing.kind === 'phrase' ? ` · 短语 · ${editing.wordCount} 个单词` : ' · 词语' }}</p>
<TermFormFields
v-model:definition="definition"
v-model:examples="examples"
v-model:status="status"
v-model:level="level"
:show-level="true"
:disabled="vocabulary.saving"
/>
<p v-if="formError" role="alert" class="field-error">{{ formError }}</p>
<p v-if="vocabulary.saveError" role="alert" class="field-error">{{ vocabulary.saveError }}</p>
</template>
<template #footer>
<ElButton data-testid="vocab-cancel" @click="closeEditor">取消</ElButton>
<ElButton type="primary" :loading="vocabulary.saving" data-testid="vocab-save" @click="save">保存修改</ElButton>
</template>
</ElDialog>
</main>
</div>
</template>
+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())
+350
View File
@@ -0,0 +1,350 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Build the prepared 英汉词典 resource for LexGo (#40).
This is tooling, not product runtime: the Go server only imports the prepared ZIP.
Two pinned sources are combined:
* ECDICT StarDict (MIT) -> Chinese glosses and a fallback phonetic notation
* CMUdict (BSD-2) -> ARPAbet phonemes transcribed to IPA
The subset is limited to lowercase single-word headwords that either exist in the
WordNet lemma list (pinned in server/wordnet-resource.json) or carry an ECDICT
exam tag, which keeps the resource at a few MiB instead of the full 340 万条.
Everything is verified: source sha256, subset size within the pinned tolerance and
the phonetic character set (no character outside the IPA alphabet may survive).
Usage:
python scripts/dict_prepare.py # download + build into .local/dictionaries
python scripts/dict_prepare.py --cache-dir DIR # reuse already downloaded sources
python scripts/dict_prepare.py --keep-going # still write the artifact if counts drift
"""
from __future__ import annotations
import argparse
import gzip
import hashlib
import json
import re
import struct
import sys
import urllib.error
import urllib.parse
import urllib.request
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PIN_FILE = ROOT / "server" / "zh-dictionary-resource.json"
DEFAULT_OUT = ROOT / ".local" / "dictionaries" / "zh-dict-v1.zip"
DEFAULT_CACHE = ROOT / ".local" / "dictionaries" / "cache"
EXAM_TAG_RE = re.compile(r"\((?:[^)]*?(研|四|六|托|专|雅|高|中|初))[^)]*?\)")
PHONETIC_RE = re.compile(r"^\*?\[([^\]\u4e00-\u9fff]*)\]")
CJK_RE = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]")
SPACE_RE = re.compile(r"\s+")
# ARPAbet (CMUdict, American English) -> IPA, a fixed documented convention.
ARPABET_CONSONANTS = {
"B": "b", "CH": "t͡ʃ", "D": "d", "DH": "ð", "F": "f", "G": "ɡ", "HH": "h",
"JH": "d͡ʒ", "K": "k", "L": "l", "M": "m", "N": "n", "NG": "ŋ", "P": "p",
"R": "ɹ", "S": "s", "SH": "ʃ", "T": "t", "TH": "θ", "V": "v", "W": "w",
"Y": "j", "Z": "z", "ZH": "ʒ",
}
ARPABET_VOWELS = {
"AA": ("ɑ", "ː"), "AE": ("æ", ""), "AH": ("ʌ", ""), "AO": ("ɔ", "ː"),
"AW": ("a", "ʊ"), "AY": ("a", "ɪ"), "EH": ("e", ""), "ER": ("ɜ", "ː"),
"EY": ("e", "ɪ"), "IH": ("ɪ", ""), "IY": ("i", "ː"), "OW": ("o", "ʊ"),
"OY": ("ɔ", "ɪ"), "UH": ("ʊ", ""), "UW": ("u", "ː"),
}
STRESS_MARKS = {"1": "ˈ", "2": "ˌ", "0": ""}
# Only character-level substitutions whose meaning is verifiable from the data.
ECDICT_CHAR_MAP = {
"'": "ˈ", ",": "ˌ", ".": "ˌ", "ˊ": "ˈ", ":": "ː", "ә": "ə", "ε": "e", # ә is U+04D9, ε U+03B5
" ": "", "-": "", "=": "", ";": "", "^": "", # ^ is a stray separator mid-word
}
IPA_ALPHABET = set(
"abcdefghijklmnopqrstuvwxyz"
"æɑɒɔəɜɛɪʊʌʃʒθðŋɹɡɚɝt͡ʃd͡ʒ"
"ːˈˌ"
)
def log(message: str) -> None:
print(message, flush=True)
def digest(path: Path) -> str:
hasher = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
hasher.update(block)
return hasher.hexdigest()
def download(source: dict, cache_dir: Path) -> Path:
"""Download a pinned source, trying every mirror, and verify sha256 + size."""
name = source["name"]
target = cache_dir / source_filename(source["source"])
urls = [source["source"]] + list(source.get("mirrors", []))
if target.exists() and digest(target) == source["sha256"]:
log(f"[cache] {name}: {target.name} ({target.stat().st_size} 字节, sha256 匹配)")
return target
last_error = None
for url in urls:
log(f"[download] {name}: {url}")
try:
request = urllib.request.Request(url, headers={"User-Agent": "lexgo-dict-prepare/1"})
with urllib.request.urlopen(request, timeout=300) as response, target.open("wb") as handle:
while True:
block = response.read(1024 * 256)
if not block:
break
handle.write(block)
except (urllib.error.URLError, TimeoutError, OSError) as error:
last_error = error
log(f"[download] {name}: 失败 {type(error).__name__}: {error}")
continue
found = digest(target)
if found == source["sha256"]:
log(f"[download] {name}: 完成 {target.stat().st_size} 字节, sha256 匹配")
return target
log(f"[download] {name}: sha256 不匹配 ({found[:16]}… != {source['sha256'][:16]}…),尝试下一个通道")
raise SystemExit(f"{name}: 所有通道都无法取得通过校验的资源(最后错误 {last_error})")
def source_filename(url: str) -> str:
return Path(urllib.parse.urlparse(url).path).name
def wordnet_lemmas(archive: Path) -> set:
lemmas = set()
with zipfile.ZipFile(archive) as bundle:
for name in bundle.namelist():
if "/index." not in name:
continue
for line in bundle.read(name).decode("latin-1").splitlines():
if not line.strip() or line.startswith(" "):
continue
lemmas.add(line.split(" ", 1)[0].lower().replace("_", " "))
return lemmas
def cmudict_entries(path: Path) -> dict:
entries = {}
for line in path.read_text(encoding="latin-1").splitlines():
if line.startswith(";;;") or not line.strip():
continue
head, _, rest = line.partition(" ")
head = head.split("(", 1)[0].lower()
if head and rest.strip() and head not in entries:
entries[head] = rest.split()
return entries
def stardict_entries(archive: Path):
with zipfile.ZipFile(archive) as bundle:
names = bundle.namelist()
ifo_name = next((n for n in names if n.endswith(".ifo")), None)
idx_name = next((n for n in names if n.endswith(".idx")), None)
dict_name = next((n for n in names if n.endswith(".dict")), None)
if not (ifo_name and idx_name and dict_name):
raise SystemExit("ECDICT 归档缺少 .ifo/.idx/.dict")
ifo = bundle.read(ifo_name).decode("utf-8", "replace")
sequence = ""
for line in ifo.splitlines():
if line.startswith("sametypesequence="):
sequence = line.split("=", 1)[1].strip()
if sequence != "m":
raise SystemExit(f"未预期的 StarDict sametypesequence: {sequence!r}(只处理纯文本 m)")
index = bundle.read(idx_name)
payload = bundle.read(dict_name)
offset = 0
while offset < len(index):
end = index.index(b"\x00", offset)
word = index[offset:end].decode("utf-8", "replace")
start, size = struct.unpack(">II", index[end + 1:end + 9])
yield word, payload[start:start + size].decode("utf-8", "replace")
offset = end + 9
def arpabet_to_ipa(phonemes) -> str:
vowel_count = sum(1 for phoneme in phonemes if phoneme.rstrip("012").strip() in ARPABET_VOWELS)
parts = []
for phoneme in phonemes:
stress = ""
base = phoneme
if phoneme and phoneme[-1].isdigit():
base, digit = phoneme[:-1], phoneme[-1]
stress = STRESS_MARKS.get(digit, "")
if base in ARPABET_VOWELS and vowel_count == 1:
# A monosyllable carries no stress mark in IPA (cat is /kæt/, not /kˈæt/).
stress = ""
if base in ARPABET_CONSONANTS:
parts.append(ARPABET_CONSONANTS[base])
elif base in ARPABET_VOWELS:
head, tail = ARPABET_VOWELS[base]
if stress == "":
# Unstressed vowels are short: curiosity ends /əti/, not /ətiː/.
tail = ""
if base == "AH":
head = "ə"
elif base == "ER":
head = "ɚ"
parts.append(stress + head + tail)
else:
return ""
return "".join(parts)
def normalise_ecdict_phonetic(raw: str) -> str:
text = raw.strip()
# A phonetic that starts with the stray caret has lost its first sound (grok is stored as
# "^rɔk"): showing "rɔk" would be a wrong transcription, so the whole value is dropped.
if text.startswith("^"):
return ""
for old, new in ECDICT_CHAR_MAP.items():
text = text.replace(old, new)
return SPACE_RE.sub("", text)
def build_subset(lemmas: set, exam_entries) -> dict:
subset = {}
for word, body in exam_entries:
lower = word.lower()
if " " in word or not lower.isalpha() or not lower.islower() or lower in subset:
continue
if lower not in lemmas and not EXAM_TAG_RE.search(body):
continue
subset[lower] = body
return subset
def translation_of(body: str) -> str:
lines = []
for line in body.splitlines():
stripped = PHONETIC_RE.sub("", line).strip()
if not stripped or not CJK_RE.search(stripped):
continue
# "(研四六托 4518/5059)" is an exam-list rank and "[时态] guarded, guarding" is a word-form
# list: neither is a meaning, so they stay out of the gloss.
if stripped.startswith("(") and stripped.endswith(")"):
continue
if stripped.startswith("[时态]"):
continue
lines.append(stripped)
return "\n".join(lines)
def main() -> int:
parser = argparse.ArgumentParser(description="构建 LexGo 英汉词典资源 (#40)")
parser.add_argument("--out", default=str(DEFAULT_OUT))
parser.add_argument("--cache-dir", default=str(DEFAULT_CACHE))
parser.add_argument("--keep-going", action="store_true", help="计数漂移时仍产出,只在 manifest 标记")
args = parser.parse_args()
pin = json.loads(PIN_FILE.read_text(encoding="utf-8"))
sources = {source["role"]: source for source in pin["sources"]}
cache_dir = Path(args.cache_dir)
cache_dir.mkdir(parents=True, exist_ok=True)
ecdict_zip = download(sources["chinese-gloss"], cache_dir)
cmudict_file = download(sources["ipa-phonetic"], cache_dir)
wordnet_zip = download(sources["subset-wordlist"], cache_dir)
log("[parse] WordNet lemma 词表")
lemmas = wordnet_lemmas(wordnet_zip)
log(f"[parse] WordNet lemma: {len(lemmas)}")
log("[parse] CMUdict 音素")
cmu = cmudict_entries(cmudict_file)
log(f"[parse] CMUdict 词条: {len(cmu)}")
log("[build] 裁剪 ECDICT 子集")
subset = build_subset(lemmas, stardict_entries(ecdict_zip))
log(f"[build] 子集词条: {len(subset)}")
records = []
stats = {"cmudict": 0, "ecdict": 0, "none": 0, "droppedPhonetic": 0, "empty": 0}
offenders = {}
for word in sorted(subset):
body = subset[word]
translation = translation_of(body)
phonetic = ""
source_name = ""
if word in cmu:
phonetic = arpabet_to_ipa(cmu[word])
source_name = "cmudict" if phonetic else ""
if not phonetic:
match = PHONETIC_RE.match(body)
candidate = normalise_ecdict_phonetic(match.group(1)) if match else ""
if candidate:
phonetic = candidate
source_name = "ecdict"
if phonetic:
unknown = sorted({char for char in phonetic if char not in IPA_ALPHABET})
if unknown:
stats["droppedPhonetic"] += 1
for char in unknown:
offenders[char] = offenders.get(char, 0) + 1
phonetic = ""
source_name = ""
if not translation and not phonetic:
# An entry that lost both its gloss and its phonetic answers nothing, so it is dropped
# instead of occupying a lookup slot with a bare headword.
stats["empty"] += 1
continue
stats[source_name or "none"] += 1
records.append({
"w": word,
"t": translation,
"p": phonetic,
"ps": source_name,
})
if offenders:
log(f"[check] 丢弃的非 IPA 字符: {sorted((c, n) for c, n in offenders.items())}")
if stats["droppedPhonetic"]:
log(f"[check] 因字符不合法丢弃音标 {stats['droppedPhonetic']} 条(不做语义猜测)")
if stats["empty"]:
log(f"[check] 既无释义也无音标丢弃 {stats['empty']} 条")
expected = pin["subset"]["expectedEntries"]
tolerance = pin["subset"]["toleranceEntries"]
drift = abs(len(records) - expected)
log(f"[check] 条目数 {len(records)}(pin 期望 {expected} ± {tolerance})")
if drift > tolerance and not args.keep_going:
raise SystemExit("子集条目数超出 pin 容差,拒绝产出;确认数据源变化后用 --keep-going 重新评估")
if len(records) == 0:
raise SystemExit("子集为空,拒绝产出")
manifest = {
"format": pin["format"],
"version": pin["version"],
"language": pin["language"],
"provider": pin["provider"],
"entries": len(records),
"phonetic": {"cmudict": stats["cmudict"], "ecdict": stats["ecdict"], "none": stats["none"]},
"droppedPhonetic": stats["droppedPhonetic"],
"droppedEmpty": stats["empty"],
"subsetRule": pin["subset"]["rule"],
"sources": [
{"name": s["name"], "role": s["role"], "sha256": s["sha256"], "license": s["license"]}
for s in pin["sources"]
],
"preparedBy": "scripts/dict_prepare.py",
}
destination = Path(args.out)
destination.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(destination, "w", zipfile.ZIP_DEFLATED) as bundle:
payload = "\n".join(json.dumps(record, ensure_ascii=False, sort_keys=True) for record in records)
bundle.writestr("entries.jsonl.gz", gzip.compress(payload.encode("utf-8")))
bundle.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2))
log(f"[write] {destination} ({destination.stat().st_size} 字节)")
log(f"[write] sha256 {digest(destination)}")
log(f"[stats] CMUdict IPA {stats['cmudict']} / ECDICT 记法 {stats['ecdict']} / 无音标 {stats['none']}")
return 0
if __name__ == "__main__":
sys.exit(main())
+791
View File
@@ -0,0 +1,791 @@
"""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",
"lexgo_book_attachments", "lexgo_playback_positions",
"lexgo_chapter_attachments", "lexgo_chapter_playback_positions",
]
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)
add("附件都指向存在的书", int(scalar(database, "SELECT COUNT(*) FROM lexgo_book_attachments a LEFT JOIN lexgo_books b ON b.id=a.book_id WHERE b.id IS NULL", "0") or 0) == 0)
add("附件归属与书归属一致", int(scalar(database, "SELECT COUNT(*) FROM lexgo_book_attachments a JOIN lexgo_books b ON b.id=a.book_id WHERE a.owner_id<>b.owner_id", "0") or 0) == 0)
add("播放位置都指向存在的书", int(scalar(database, "SELECT COUNT(*) FROM lexgo_playback_positions p LEFT JOIN lexgo_books b ON b.id=p.book_id WHERE b.id IS NULL", "0") or 0) == 0)
add("章节附件都指向存在的章节", int(scalar(database, "SELECT COUNT(*) FROM lexgo_chapter_attachments a LEFT JOIN lexgo_chapters c ON c.id=a.chapter_id WHERE c.id IS NULL", "0") or 0) == 0)
add("章节附件归属与章节归属一致", int(scalar(database, "SELECT COUNT(*) FROM lexgo_chapter_attachments a JOIN lexgo_chapters c ON c.id=a.chapter_id WHERE a.owner_id<>c.owner_id", "0") or 0) == 0)
add("章节播放位置都指向存在的章节", int(scalar(database, "SELECT COUNT(*) FROM lexgo_chapter_playback_positions p LEFT JOIN lexgo_chapters c ON c.id=p.chapter_id WHERE c.id IS NULL", "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())
+33
View File
@@ -0,0 +1,33 @@
Copyright (C) 1993-2015 Carnegie Mellon University. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
The contents of this file are deemed to be source code.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in
the documentation and/or other materials provided with the
distribution.
This work was supported in part by funding from the Defense Advanced
Research Projects Agency, the Office of Naval Research and the National
Science Foundation of the United States of America, and by member
companies of the Carnegie Mellon Sphinx Speech Consortium. We acknowledge
the contributions of many volunteers to the expansion and improvement of
this dictionary.
THIS SOFTWARE IS PROVIDED BY CARNEGIE MELLON UNIVERSITY ``AS IS'' AND
ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL CARNEGIE MELLON UNIVERSITY
NOR ITS EMPLOYEES BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Linwei
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+641
View File
@@ -0,0 +1,641 @@
package lexgo
import (
"bytes"
"encoding/binary"
"errors"
"image"
_ "image/jpeg" // dimension checks for covers
_ "image/png"
"io"
"mime/multipart"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
admin "go-admin/app/admin/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// A book may carry one audio track and one cover image. Both live in MySQL next to everything else,
// so a dump is still a complete backup, the owner check is the same as for any other private data,
// and deleting a book removes its attachments with it. Nothing is written to disk: an uploaded file
// never becomes a path, and the client file name is only used for display.
const (
// Audio and illustration belong to a chapter; the cover belongs to the book.
attachmentAudio = "audio"
attachmentIllustration = "illustration"
attachmentCover = "cover"
maxAudioBytes = 20 << 20
maxCoverBytes = 2 << 20
// Covers are downscaled by the browser, so an upload larger than this is refused instead of
// being stored at a size nothing will ever display.
maxCoverPixels = 4096
// A playback position beyond this is treated as a client mistake rather than a real position.
maxPlaybackSeconds = 24 * 60 * 60
)
// BookAttachment is one binary file belonging to a book. book_id plus kind are unique, so replacing
// a file updates one row instead of accumulating versions.
type BookAttachment struct {
BookID int64 `gorm:"primaryKey"`
Kind string `gorm:"primaryKey"`
OwnerID int `json:"-"`
Mime string `json:"mime"`
ByteSize int `json:"byteSize"`
SHA256 string `json:"sha256"`
Bytes []byte `json:"-"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (BookAttachment) TableName() string { return "lexgo_book_attachments" }
// ChapterAttachment is one binary file belonging to a chapter: its audio or its illustration.
// chapter_id plus kind are unique, so replacing a file updates one row instead of accumulating
// versions, and deleting a chapter removes its files with it.
type ChapterAttachment struct {
ChapterID int64 `gorm:"primaryKey"`
Kind string `gorm:"primaryKey"`
OwnerID int `json:"-"`
Mime string `json:"mime"`
ByteSize int `json:"byteSize"`
SHA256 string `json:"sha256"`
Bytes []byte `json:"-"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (ChapterAttachment) TableName() string { return "lexgo_chapter_attachments" }
// ChapterPlaybackPosition remembers where one learner stopped in one chapter. It is separate from
// the attachment row: the file belongs to the chapter, the position belongs to the reader.
type ChapterPlaybackPosition struct {
OwnerID int `gorm:"primaryKey"`
ChapterID int64 `gorm:"primaryKey"`
PositionSeconds int `json:"positionSeconds"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (ChapterPlaybackPosition) TableName() string { return "lexgo_chapter_playback_positions" }
// BookAttachmentView is what the client sees: metadata and a version for cache invalidation, never
// the bytes.
type BookAttachmentView struct {
Kind string `json:"kind"`
Mime string `json:"mime"`
ByteSize int `json:"byteSize"`
Version string `json:"version"`
UpdatedAt time.Time `json:"updatedAt"`
}
// BookAttachmentsView is what the book page shows. Audio and its position moved to the chapter,
// so only the cover remains at book level.
type BookAttachmentsView struct {
Cover *BookAttachmentView `json:"cover"`
}
// sniffAttachment checks the actual bytes instead of trusting a file name or a declared type.
func sniffAttachment(kind string, data []byte) (string, error) {
switch kind {
case attachmentAudio:
if isMP3(data) {
return "audio/mpeg", nil
}
return "", failure(400, "只支持 MP3 音频,请转换为 MP3 后重试")
case attachmentCover, attachmentIllustration:
mime := sniffImage(data)
if mime == "" {
if kind == attachmentIllustration {
return "", failure(400, "插图只支持 JPG、PNG 或 WebP 图片")
}
return "", failure(400, "封面只支持 JPG、PNG 或 WebP 图片")
}
return mime, nil
}
return "", failure(400, "附件类型无效")
}
// isMP3 accepts both an ID3-tagged file and a bare frame: "ID3" or a frame sync of 11 bits.
func isMP3(data []byte) bool {
if len(data) < 4 {
return false
}
if bytes.HasPrefix(data, []byte("ID3")) {
return true
}
return data[0] == 0xFF && data[1]&0xE0 == 0xE0
}
func sniffImage(data []byte) string {
switch {
case bytes.HasPrefix(data, []byte{0xFF, 0xD8, 0xFF}):
return "image/jpeg"
case bytes.HasPrefix(data, []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}):
return "image/png"
case len(data) >= 12 && bytes.HasPrefix(data, []byte("RIFF")) && bytes.Equal(data[8:12], []byte("WEBP")):
return "image/webp"
}
return ""
}
// coverDimensions returns the pixel size of a supported image. JPEG and PNG are decoded by the
// standard library; WebP is read from its own header because no decoder is imported for it.
func coverDimensions(mime string, data []byte) (int, int, bool) {
if mime == "image/webp" {
return webpDimensions(data)
}
config, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return 0, 0, false
}
return config.Width, config.Height, true
}
func webpDimensions(data []byte) (int, int, bool) {
if len(data) < 30 {
return 0, 0, false
}
chunk := string(data[12:16])
switch chunk {
case "VP8X":
width := int(data[24]) | int(data[25])<<8 | int(data[26])<<16
height := int(data[27]) | int(data[28])<<8 | int(data[29])<<16
return width + 1, height + 1, true
case "VP8 ":
// The frame header starts after the 10-byte chunk header; the 14-bit dimensions sit 6 bytes in.
if len(data) < 30 {
return 0, 0, false
}
width := int(binary.LittleEndian.Uint16(data[26:28]) & 0x3FFF)
height := int(binary.LittleEndian.Uint16(data[28:30]) & 0x3FFF)
return width, height, width > 0 && height > 0
case "VP8L":
if len(data) < 25 {
return 0, 0, false
}
bits := binary.LittleEndian.Uint32(data[21:25])
width := int(bits&0x3FFF) + 1
height := int((bits>>14)&0x3FFF) + 1
return width, height, true
}
return 0, 0, false
}
// validateAttachment enforces the size, type and pixel limits before anything is stored.
func validateAttachment(kind string, data []byte) (string, error) {
label := "封面"
if kind == attachmentIllustration {
label = "插图"
}
switch kind {
case attachmentAudio:
if len(data) == 0 {
return "", failure(400, "音频文件为空")
}
if len(data) > maxAudioBytes {
return "", failure(400, "音频文件不能超过 20 MiB")
}
case attachmentCover, attachmentIllustration:
if len(data) == 0 {
return "", failure(400, label+"图片为空")
}
if len(data) > maxCoverBytes {
return "", failure(400, label+"图片不能超过 2 MiB")
}
default:
return "", failure(400, "附件类型无效")
}
mime, err := sniffAttachment(kind, data)
if err != nil {
return "", err
}
if kind != attachmentAudio {
width, height, ok := coverDimensions(mime, data)
if !ok {
return "", failure(400, "无法识别该图片,请换一张 JPG、PNG 或 WebP")
}
if width > maxCoverPixels || height > maxCoverPixels {
return "", failure(400, label+"图片不能超过 4096×4096 像素")
}
}
return mime, nil
}
// readAttachmentUpload reads a single "file" part plus no other fields, with the byte limit applied
// to the request body itself so an oversized upload is rejected while it is still arriving.
func readAttachmentUpload(c *gin.Context, limit int64) ([]byte, error) {
bad := failure(400, "请选择一个文件后重试")
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit+(64<<10))
reader, err := c.Request.MultipartReader()
if err != nil {
return nil, bad
}
var content []byte
for {
part, err := reader.NextPart()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
if tooLarge(err) {
return nil, failure(413, "文件超过允许的大小")
}
return nil, bad
}
if part.FormName() != "file" {
part.Close()
return nil, bad
}
if content != nil {
part.Close()
return nil, bad
}
content, err = readPart(part, limit)
part.Close()
if err != nil {
return nil, err
}
}
if content == nil {
return nil, bad
}
return content, nil
}
func readPart(part *multipart.Part, limit int64) ([]byte, error) {
content, err := io.ReadAll(io.LimitReader(part, limit+1))
if err != nil {
if tooLarge(err) {
return nil, failure(413, "文件超过允许的大小")
}
return nil, failure(400, "文件读取失败,请重试")
}
if len(content) == 0 {
return nil, failure(400, "文件为空")
}
if int64(len(content)) > limit {
return nil, failure(413, "文件超过允许的大小")
}
return content, nil
}
func tooLarge(err error) bool {
var maxErr *http.MaxBytesError
return errors.As(err, &maxErr) || strings.Contains(err.Error(), "http: request body too large")
}
// SaveAttachment validates and stores one file for a book the caller owns. A replacement updates the
// same row only after the new file passed every check, so a rejected upload leaves the old one in
// place.
func SaveAttachment(tx *gorm.DB, owner int, bookID int64, kind string, data []byte, now time.Time) (BookAttachmentView, error) {
if kind != attachmentCover {
return BookAttachmentView{}, failure(404, "附件不存在")
}
if err := lockOwnedBook(tx, owner, bookID, &Book{}); err != nil {
return BookAttachmentView{}, err
}
mime, err := validateAttachment(kind, data)
if err != nil {
return BookAttachmentView{}, err
}
ts := stamp(now)
row := BookAttachment{BookID: bookID, Kind: kind, OwnerID: owner, Mime: mime,
ByteSize: len(data), SHA256: contentSHA(string(data)), Bytes: data, CreatedAt: ts, UpdatedAt: ts}
// The identity stays (book_id, kind); the file and its metadata move forward together.
if err = tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "book_id"}, {Name: "kind"}},
DoUpdates: clause.AssignmentColumns([]string{"owner_id", "mime", "byte_size", "sha256", "bytes", "updated_at"}),
}).Create(&row).Error; err != nil {
return BookAttachmentView{}, err
}
return attachmentView(row), nil
}
func attachmentView(row BookAttachment) BookAttachmentView {
return BookAttachmentView{Kind: row.Kind, Mime: row.Mime, ByteSize: row.ByteSize,
Version: row.SHA256, UpdatedAt: row.UpdatedAt}
}
// DeleteAttachment removes the cover of a book.
func DeleteAttachment(tx *gorm.DB, owner int, bookID int64, kind string, now time.Time) error {
if kind != attachmentCover {
return failure(404, "附件不存在")
}
if err := lockOwnedBook(tx, owner, bookID, &Book{}); err != nil {
return err
}
result := tx.Where("book_id = ? AND owner_id = ? AND kind = ?", bookID, owner, kind).Delete(&BookAttachment{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return failure(404, "附件不存在")
}
return nil
}
// BookAttachmentFile returns one stored file for the caller's own book.
func BookAttachmentFile(tx *gorm.DB, owner int, bookID int64, kind string) (BookAttachment, error) {
var row BookAttachment
err := tx.Where("book_id = ? AND owner_id = ? AND kind = ?", bookID, owner, kind).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return BookAttachment{}, failure(404, "附件不存在")
}
return row, err
}
// attachmentsByBook loads the attachment metadata for a list of books, so a book list can show
// covers without a query per row.
func attachmentsByBook(tx *gorm.DB, owner int, bookIDs []int64) (map[int64]map[string]BookAttachmentView, error) {
found := map[int64]map[string]BookAttachmentView{}
if len(bookIDs) == 0 {
return found, nil
}
var rows []BookAttachment
if err := tx.Select("book_id", "kind", "mime", "byte_size", "sha256", "updated_at").
Where("owner_id = ? AND book_id IN ?", owner, bookIDs).Find(&rows).Error; err != nil {
return found, err
}
for _, row := range rows {
if found[row.BookID] == nil {
found[row.BookID] = map[string]BookAttachmentView{}
}
found[row.BookID][row.Kind] = attachmentView(row)
}
return found, nil
}
// ChapterAttachmentView is one chapter file as the client sees it: metadata and a version for
// cache invalidation, never the bytes.
type ChapterAttachmentView struct {
Kind string `json:"kind"`
Mime string `json:"mime"`
ByteSize int `json:"byteSize"`
Version string `json:"version"`
UpdatedAt time.Time `json:"updatedAt"`
}
// chapterAttachmentViews loads the attachment metadata for a list of chapters, so a chapter list can
// show illustrations and audio flags without a query per row.
func chapterAttachmentViews(tx *gorm.DB, owner int, chapterIDs []int64) (map[int64]map[string]ChapterAttachmentView, error) {
found := map[int64]map[string]ChapterAttachmentView{}
if len(chapterIDs) == 0 {
return found, nil
}
var rows []ChapterAttachment
if err := tx.Select("chapter_id", "kind", "mime", "byte_size", "sha256", "updated_at").
Where("owner_id = ? AND chapter_id IN ?", owner, chapterIDs).Find(&rows).Error; err != nil {
return found, err
}
for _, row := range rows {
if found[row.ChapterID] == nil {
found[row.ChapterID] = map[string]ChapterAttachmentView{}
}
found[row.ChapterID][row.Kind] = ChapterAttachmentView{Kind: row.Kind, Mime: row.Mime,
ByteSize: row.ByteSize, Version: row.SHA256, UpdatedAt: row.UpdatedAt}
}
return found, nil
}
// chapterPlaybackSeconds reads this learner's position per chapter.
func chapterPlaybackSeconds(tx *gorm.DB, owner int, chapterIDs []int64) (map[int64]int, error) {
positions := map[int64]int{}
if len(chapterIDs) == 0 {
return positions, nil
}
var rows []ChapterPlaybackPosition
if err := tx.Where("owner_id = ? AND chapter_id IN ?", owner, chapterIDs).Find(&rows).Error; err != nil {
return positions, err
}
for _, row := range rows {
positions[row.ChapterID] = row.PositionSeconds
}
return positions, nil
}
// BookAttachmentsFor is the view the book page uses: the cover only. Audio and its position belong
// to a chapter now.
func BookAttachmentsFor(tx *gorm.DB, owner int, bookID int64) (BookAttachmentsView, error) {
view := BookAttachmentsView{}
found, err := attachmentsByBook(tx, owner, []int64{bookID})
if err != nil {
return view, err
}
if cover, ok := found[bookID][attachmentCover]; ok {
view.Cover = &cover
}
return view, nil
}
// SaveChapterAttachment validates and stores one file for a chapter the caller owns. A replacement
// updates the same row only after the new file passed every check, so a rejected upload leaves the
// old one in place, and replacing the audio drops the position that belonged to the old file.
func SaveChapterAttachment(tx *gorm.DB, owner int, chapterID int64, kind string, data []byte, now time.Time) (ChapterAttachmentView, error) {
if kind != attachmentAudio && kind != attachmentIllustration {
return ChapterAttachmentView{}, failure(404, "附件不存在")
}
var chapter Chapter
if err := lockOwnedChapter(tx, owner, chapterID, &chapter); err != nil {
return ChapterAttachmentView{}, err
}
mime, err := validateAttachment(kind, data)
if err != nil {
return ChapterAttachmentView{}, err
}
ts := stamp(now)
row := ChapterAttachment{ChapterID: chapterID, Kind: kind, OwnerID: owner, Mime: mime,
ByteSize: len(data), SHA256: contentSHA(string(data)), Bytes: data, CreatedAt: ts, UpdatedAt: ts}
if err = tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "chapter_id"}, {Name: "kind"}},
DoUpdates: clause.AssignmentColumns([]string{"owner_id", "mime", "byte_size", "sha256", "bytes", "updated_at"}),
}).Create(&row).Error; err != nil {
return ChapterAttachmentView{}, err
}
if kind == attachmentAudio {
if err = tx.Where("owner_id = ? AND chapter_id = ?", owner, chapterID).Delete(&ChapterPlaybackPosition{}).Error; err != nil {
return ChapterAttachmentView{}, err
}
}
return ChapterAttachmentView{Kind: row.Kind, Mime: row.Mime, ByteSize: row.ByteSize,
Version: row.SHA256, UpdatedAt: row.UpdatedAt}, nil
}
// DeleteChapterAttachment removes one chapter file, and an audio removal drops the position too.
func DeleteChapterAttachment(tx *gorm.DB, owner int, chapterID int64, kind string, now time.Time) error {
if kind != attachmentAudio && kind != attachmentIllustration {
return failure(404, "附件不存在")
}
var chapter Chapter
if err := lockOwnedChapter(tx, owner, chapterID, &chapter); err != nil {
return err
}
result := tx.Where("chapter_id = ? AND owner_id = ? AND kind = ?", chapterID, owner, kind).Delete(&ChapterAttachment{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return failure(404, "附件不存在")
}
if kind == attachmentAudio {
return tx.Where("owner_id = ? AND chapter_id = ?", owner, chapterID).Delete(&ChapterPlaybackPosition{}).Error
}
return nil
}
// ChapterAttachmentFile returns one stored chapter file for the caller's own chapter.
func ChapterAttachmentFile(tx *gorm.DB, owner int, chapterID int64, kind string) (ChapterAttachment, error) {
var row ChapterAttachment
err := tx.Where("chapter_id = ? AND owner_id = ? AND kind = ?", chapterID, owner, kind).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return ChapterAttachment{}, failure(404, "附件不存在")
}
return row, err
}
// SaveChapterPlaybackPosition remembers where the learner stopped in one chapter. Only the caller's
// own position moves, and a chapter without audio is refused so a stale client cannot create rows.
func SaveChapterPlaybackPosition(tx *gorm.DB, owner int, chapterID int64, seconds int, now time.Time) (int, error) {
if seconds < 0 || seconds > maxPlaybackSeconds {
return 0, failure(400, "播放位置无效")
}
var chapter Chapter
if err := lockOwnedChapter(tx, owner, chapterID, &chapter); err != nil {
return 0, err
}
var audio int64
if err := tx.Model(&ChapterAttachment{}).Where("chapter_id = ? AND kind = ?", chapterID, attachmentAudio).Count(&audio).Error; err != nil {
return 0, err
}
if audio == 0 {
return 0, failure(404, "这一章没有音频")
}
ts := stamp(now)
row := ChapterPlaybackPosition{OwnerID: owner, ChapterID: chapterID, PositionSeconds: seconds, UpdatedAt: ts}
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "owner_id"}, {Name: "chapter_id"}},
DoUpdates: clause.AssignmentColumns([]string{"position_seconds", "updated_at"}),
}).Create(&row).Error; err != nil {
return 0, err
}
return seconds, nil
}
func registerAttachmentRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context, *gorm.DB, admin.SysUser) (any, error)) gin.HandlerFunc, now func() time.Time) {
// The book keeps the cover; audio, illustration and the playback position are chapter scoped.
v.POST("/books/:id/cover", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
if len(c.Request.URL.Query()) > 0 {
return nil, failure(400, "附件上传不接受查询参数")
}
id, err := pathID(c, "书籍不存在")
if err != nil {
return nil, err
}
data, err := readAttachmentUpload(c, maxCoverBytes)
if err != nil {
return nil, err
}
view, err := SaveAttachment(tx, u.UserId, id, attachmentCover, data, now())
if err != nil {
return nil, err
}
return gin.H{"attachment": view}, nil
}))
v.DELETE("/books/:id/cover", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
id, err := pathID(c, "书籍不存在")
if err != nil {
return nil, err
}
if err = DeleteAttachment(tx, u.UserId, id, attachmentCover, now()); err != nil {
return nil, err
}
return gin.H{"deleted": true}, nil
}))
v.GET("/books/:id/cover", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
id, err := pathID(c, "书籍不存在")
if err != nil {
return nil, err
}
row, err := BookAttachmentFile(tx, u.UserId, id, attachmentCover)
if err != nil {
return nil, err
}
return binaryResponse{ContentType: row.Mime, Bytes: row.Bytes, FileName: "cover",
ModifiedAt: row.UpdatedAt, ETag: `"` + row.SHA256 + `"`}, nil
}))
upload := func(kind string) gin.HandlerFunc {
limit := int64(maxAudioBytes)
if kind == attachmentIllustration {
limit = maxCoverBytes
}
return protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
if len(c.Request.URL.Query()) > 0 {
return nil, failure(400, "附件上传不接受查询参数")
}
id, err := pathID(c, "章节不存在")
if err != nil {
return nil, err
}
data, err := readAttachmentUpload(c, limit)
if err != nil {
return nil, err
}
view, err := SaveChapterAttachment(tx, u.UserId, id, kind, data, now())
if err != nil {
return nil, err
}
return gin.H{"attachment": view}, nil
})
}
v.POST("/chapters/:id/audio", upload(attachmentAudio))
v.POST("/chapters/:id/illustration", upload(attachmentIllustration))
for kind, name := range map[string]string{attachmentAudio: "audio", attachmentIllustration: "illustration"} {
attachmentKind, fileName := kind, name
v.DELETE("/chapters/:id/"+fileName, protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
id, err := pathID(c, "章节不存在")
if err != nil {
return nil, err
}
if err = DeleteChapterAttachment(tx, u.UserId, id, attachmentKind, now()); err != nil {
return nil, err
}
return gin.H{"deleted": true}, nil
}))
v.GET("/chapters/:id/"+fileName, protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
id, err := pathID(c, "章节不存在")
if err != nil {
return nil, err
}
row, err := ChapterAttachmentFile(tx, u.UserId, id, attachmentKind)
if err != nil {
return nil, err
}
return binaryResponse{ContentType: row.Mime, Bytes: row.Bytes, FileName: fileName,
ModifiedAt: row.UpdatedAt, ETag: `"` + row.SHA256 + `"`}, nil
}))
}
v.PUT("/chapters/:id/playback", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
id, err := pathID(c, "章节不存在")
if err != nil {
return nil, err
}
var input struct {
PositionSeconds *int `json:"positionSeconds"`
}
if err = decode(c, &input); err != nil {
return nil, err
}
if input.PositionSeconds == nil {
return nil, failure(400, "请提供播放位置")
}
seconds, err := SaveChapterPlaybackPosition(tx, u.UserId, id, *input.PositionSeconds, now())
if err != nil {
return nil, err
}
return gin.H{"playbackSeconds": seconds}, nil
}))
}
+543
View File
@@ -0,0 +1,543 @@
package lexgo
import (
"bytes"
"encoding/json"
"fmt"
"image"
"image/color"
"image/jpeg"
"image/png"
"mime/multipart"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// ---------------------------------------------------------------- unit rules
func testPNG(t *testing.T, width, height int) []byte {
t.Helper()
canvas := image.NewRGBA(image.Rect(0, 0, width, height))
canvas.Set(0, 0, color.RGBA{R: 10, G: 20, B: 30, A: 255})
var buffer bytes.Buffer
if err := png.Encode(&buffer, canvas); err != nil {
t.Fatal(err)
}
return buffer.Bytes()
}
func testJPEG(t *testing.T, width, height int) []byte {
t.Helper()
canvas := image.NewRGBA(image.Rect(0, 0, width, height))
canvas.Set(0, 0, color.RGBA{R: 200, G: 100, B: 50, A: 255})
var buffer bytes.Buffer
if err := jpeg.Encode(&buffer, canvas, nil); err != nil {
t.Fatal(err)
}
return buffer.Bytes()
}
// testWebP builds the smallest possible VP8X header: the sniffing and dimension code reads the
// container header, and no decoder is needed for that.
func testWebP(width, height int) []byte {
data := make([]byte, 30)
copy(data[0:4], "RIFF")
copy(data[8:12], "WEBP")
copy(data[12:16], "VP8X")
// The stored value is size-1, little endian, three bytes each.
w, h := width-1, height-1
data[24], data[25], data[26] = byte(w), byte(w>>8), byte(w>>16)
data[27], data[28], data[29] = byte(h), byte(h>>8), byte(h>>16)
return data
}
func testMP3(payload int) []byte {
data := make([]byte, 4+payload)
copy(data, "ID3")
for i := 4; i < len(data); i++ {
data[i] = byte(i % 251)
}
return data
}
func TestAttachmentTypeSniffing(t *testing.T) {
cases := []struct {
name string
kind string
data []byte
mime string
ok bool
}{
{"id3 audio", attachmentAudio, testMP3(64), "audio/mpeg", true},
{"bare frame", attachmentAudio, []byte{0xFF, 0xFB, 0x90, 0x00}, "audio/mpeg", true},
{"wav is not mp3", attachmentAudio, append([]byte("RIFF"), make([]byte, 40)...), "", false},
{"text renamed", attachmentAudio, []byte("this is not audio at all.........."), "", false},
{"png cover", attachmentCover, testPNG(t, 8, 8), "image/png", true},
{"png illustration", attachmentIllustration, testPNG(t, 8, 8), "image/png", true},
{"gif illustration", attachmentIllustration, []byte("GIF89a"), "", false},
{"jpeg cover", attachmentCover, testJPEG(t, 8, 8), "image/jpeg", true},
{"webp cover", attachmentCover, testWebP(8, 8), "image/webp", true},
{"gif is not accepted", attachmentCover, []byte("GIF89a"), "", false},
{"svg is not accepted", attachmentCover, []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`), "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mime, err := sniffAttachment(tc.kind, tc.data)
if tc.ok && (err != nil || mime != tc.mime) {
t.Fatalf("got %q %v, want %q", mime, err, tc.mime)
}
if !tc.ok && err == nil {
t.Fatalf("expected a rejection, got %q", mime)
}
})
}
}
func TestAttachmentLimits(t *testing.T) {
if _, err := validateAttachment(attachmentIllustration, make([]byte, maxCoverBytes+1)); err == nil {
t.Fatal("an oversized illustration must be refused")
}
if _, err := validateAttachment(attachmentAudio, make([]byte, maxAudioBytes+1)); err == nil {
t.Fatal("an oversized audio file must be refused")
}
if _, err := validateAttachment(attachmentCover, make([]byte, maxCoverBytes+1)); err == nil {
t.Fatal("an oversized cover must be refused")
}
if _, err := validateAttachment(attachmentAudio, nil); err == nil {
t.Fatal("an empty file must be refused")
}
// A cover wider than the pixel limit is refused even though its bytes are small.
wide := testPNG(t, maxCoverPixels+1, 4)
if len(wide) > maxCoverBytes {
t.Skip("the generated image is larger than the byte limit; the pixel rule is covered below")
}
if _, err := validateAttachment(attachmentCover, wide); err == nil {
t.Fatal("a cover above the pixel limit must be refused")
}
// A JPEG inside the limit is accepted, one above it is refused.
inside := testJPEG(t, 4000, 20)
if len(inside) > maxCoverBytes {
t.Skip("the generated JPEG is larger than the byte limit")
}
if _, err := validateAttachment(attachmentCover, inside); err != nil {
t.Fatalf("a 4000 pixel wide cover is inside the limit: %v", err)
}
tooWide := testJPEG(t, maxCoverPixels+1, 8)
if len(tooWide) <= maxCoverBytes {
if _, err := validateAttachment(attachmentCover, tooWide); err == nil {
t.Fatal("a cover wider than the pixel limit must be refused")
}
}
if _, err := validateAttachment("other", testPNG(t, 8, 8)); err == nil {
t.Fatal("an unknown kind must be refused")
}
}
func TestWebPDimensions(t *testing.T) {
width, height, ok := webpDimensions(testWebP(320, 200))
if !ok || width != 320 || height != 200 {
t.Fatalf("VP8X parsed as %dx%d (%v)", width, height, ok)
}
if _, _, ok = webpDimensions([]byte("RIFF____WEBP nothing here")); ok {
t.Fatal("an unknown chunk must not parse")
}
if _, _, ok = webpDimensions(testPNG(t, 8, 8)); ok {
t.Fatal("a PNG must not be read as WebP")
}
}
func TestPlaybackPositionBounds(t *testing.T) {
// The bound is checked before any database work, so a nil transaction is fine here.
if _, err := SaveChapterPlaybackPosition(nil, 1, 1, -1, time.Now()); err == nil {
t.Fatal("a negative position must be refused")
}
if _, err := SaveChapterPlaybackPosition(nil, 1, 1, maxPlaybackSeconds+1, time.Now()); err == nil {
t.Fatal("an absurd position must be refused")
}
}
// ---------------------------------------------------------------- integration
func uploadAttachment(t *testing.T, r *gin.Engine, token string, path string, data []byte) (int, string, ChapterAttachmentView) {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", "fictional-upload.bin")
if err != nil {
t.Fatal(err)
}
if _, err = part.Write(data); err != nil {
t.Fatal(err)
}
if err = writer.Close(); err != nil {
t.Fatal(err)
}
request := httptest.NewRequest("POST", path, bytes.NewReader(body.Bytes()))
request.Header.Set("Content-Type", writer.FormDataContentType())
if token != "" {
request.Header.Set("Authorization", "Bearer "+token)
}
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request)
var envelope struct {
Code int `json:"code"`
Msg string
Data struct {
Attachment ChapterAttachmentView
}
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("invalid JSON from %s (status %d): %s", path, recorder.Code, recorder.Body.String()[:min(120, recorder.Body.Len())])
}
return recorder.Code, envelope.Msg, envelope.Data.Attachment
}
// fetchRaw reads a binary endpoint without decoding the JSON envelope.
func fetchRaw(t *testing.T, r *gin.Engine, token, path string, headers map[string]string) *httptest.ResponseRecorder {
t.Helper()
request := httptest.NewRequest("GET", path, nil)
if token != "" {
request.Header.Set("Authorization", "Bearer "+token)
}
for key, value := range headers {
request.Header.Set(key, value)
}
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request)
return recorder
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func bookDetailOf(t *testing.T, r *gin.Engine, token string, bookID int64) (BookRef, []ChapterSummary) {
t.Helper()
code, _, data := callRaw(t, r, "GET", fmt.Sprintf("/api/v1/books/%d", bookID), token, nil)
if code != 200 {
t.Fatalf("book detail status %d", code)
}
var payload struct {
Book BookRef
Chapters []ChapterSummary
}
if err := json.Unmarshal(data, &payload); err != nil {
t.Fatal(err)
}
return payload.Book, payload.Chapters
}
func chapterInList(t *testing.T, r *gin.Engine, token string, bookID, chapterID int64) ChapterSummary {
t.Helper()
_, chapters := bookDetailOf(t, r, token, bookID)
for _, chapter := range chapters {
if chapter.ID == chapterID {
return chapter
}
}
t.Fatalf("chapter %d is missing from the book list", chapterID)
return ChapterSummary{}
}
func readerChapter(t *testing.T, r *gin.Engine, token string, chapterID int64) ChapterView {
t.Helper()
code, _, data := callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d", chapterID), token, nil)
if code != 200 {
t.Fatalf("reader status %d", code)
}
var payload struct {
Chapter ChapterView
}
if err := json.Unmarshal(data, &payload); err != nil {
t.Fatal(err)
}
return payload.Chapter
}
func attachmentRows(t *testing.T, db *gorm.DB, chapterID int64, kind string) int64 {
t.Helper()
var rows int64
if err := db.Model(&ChapterAttachment{}).Where("chapter_id = ? AND kind = ?", chapterID, kind).Count(&rows).Error; err != nil {
t.Fatal(err)
}
return rows
}
// TestMySQLChapterAttachmentsAndPlayback covers the chapter attachments: illustration and audio per
// chapter, authenticated Range reads, replacement, the per-chapter position, ownership and cascade.
func TestMySQLChapterAttachmentsAndPlayback(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
other := newLearner(t, r, owner.Token)
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "chapter-attach-0001", "title": "Fictional chapter attachments",
"text": "Curiosity opens the first door.\n", "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
drainIngest(t, db)
bookID, chapterID := pasted.Chapter.BookID, pasted.Chapter.ID
// A chapter without attachments reports nothing and serves 404.
if listed := chapterInList(t, r, learner.Token, bookID, chapterID); listed.IllustrationVersion != "" || listed.AudioVersion != "" || listed.PlaybackSeconds != 0 {
t.Fatalf("a new chapter must have no attachments: %+v", listed)
}
if reader := readerChapter(t, r, learner.Token, chapterID); reader.IllustrationVersion != "" || reader.AudioVersion != "" {
t.Fatalf("the reader must report no attachments: %+v", reader)
}
for _, path := range []string{"audio", "illustration"} {
if recorder := fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/%s", chapterID, path), nil); recorder.Code != 404 {
t.Fatalf("reading a missing %s must be 404, got %d", path, recorder.Code)
}
}
// The illustration is stored, listed with a version and served byte for byte.
illustration := testPNG(t, 60, 34)
code, msg, view := uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/illustration", chapterID), illustration)
if code != 200 || view.Kind != attachmentIllustration || view.Mime != "image/png" || view.Version != contentSHA(string(illustration)) {
t.Fatalf("illustration upload: %d %s %+v", code, msg, view)
}
if listed := chapterInList(t, r, learner.Token, bookID, chapterID); listed.IllustrationVersion != view.Version || listed.AudioVersion != "" {
t.Fatalf("the chapter list must carry the illustration version: %+v", listed)
}
recorder := fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/illustration", chapterID), nil)
if recorder.Code != 200 || !bytes.Equal(recorder.Body.Bytes(), illustration) || recorder.Header().Get("Content-Type") != "image/png" {
t.Fatalf("illustration read: %d %s", recorder.Code, recorder.Header().Get("Content-Type"))
}
etag := recorder.Header().Get("ETag")
if recorder = fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/illustration", chapterID), map[string]string{"If-None-Match": etag}); recorder.Code != 304 {
t.Fatalf("a matching ETag must answer 304, got %d", recorder.Code)
}
// The audio is stored and served in full and by range.
audio := testMP3(4096)
code, msg, audioView := uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), audio)
if code != 200 || audioView.Kind != attachmentAudio || audioView.Mime != "audio/mpeg" {
t.Fatalf("audio upload: %d %s %+v", code, msg, audioView)
}
if listed := chapterInList(t, r, learner.Token, bookID, chapterID); listed.AudioVersion != audioView.Version {
t.Fatalf("the chapter list must carry the audio version: %+v", listed)
}
if reader := readerChapter(t, r, learner.Token, chapterID); reader.AudioVersion != audioView.Version || reader.IllustrationVersion != view.Version {
t.Fatalf("the reader must carry both versions: %+v", reader)
}
recorder = fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), nil)
if recorder.Code != 200 || !bytes.Equal(recorder.Body.Bytes(), audio) || recorder.Header().Get("Accept-Ranges") == "" {
t.Fatalf("audio read: %d ranges=%q", recorder.Code, recorder.Header().Get("Accept-Ranges"))
}
recorder = fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), map[string]string{"Range": "bytes=100-199"})
if recorder.Code != 206 || recorder.Body.Len() != 100 || !bytes.Equal(recorder.Body.Bytes(), audio[100:200]) {
t.Fatalf("a range request must answer 206 with the slice, got %d with %d bytes", recorder.Code, recorder.Body.Len())
}
if recorder = fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), map[string]string{"Range": "bytes=999999-"}); recorder.Code != 416 {
t.Fatalf("an unsatisfiable range must answer 416, got %d", recorder.Code)
}
// The book-level audio endpoints are retired: the route no longer exists.
if code, _, _ := callRaw(t, r, "POST", fmt.Sprintf("/api/v1/books/%d/audio", bookID), learner.Token, nil); code != 404 {
t.Fatalf("the book-level audio route must be gone, got %d", code)
}
if code, _, _ := callRaw(t, r, "PUT", fmt.Sprintf("/api/v1/books/%d/playback", bookID), learner.Token, map[string]any{"positionSeconds": 5}); code != 404 {
t.Fatalf("the book-level playback route must be gone, got %d", code)
}
if book, _ := bookDetailOf(t, r, learner.Token, bookID); book.Attachments != nil && book.Attachments.Cover != nil {
t.Fatal("the book view must only carry the cover")
}
// The position is per chapter and only moves for the caller.
code, msg, _ = callRaw(t, r, "PUT", fmt.Sprintf("/api/v1/chapters/%d/playback", chapterID), learner.Token, map[string]any{"positionSeconds": 125})
if code != 200 {
t.Fatalf("position update: %d %s", code, msg)
}
if listed := chapterInList(t, r, learner.Token, bookID, chapterID); listed.PlaybackSeconds != 125 {
t.Fatalf("the chapter list must report the position, got %d", listed.PlaybackSeconds)
}
if reader := readerChapter(t, r, learner.Token, chapterID); reader.PlaybackSeconds != 125 {
t.Fatalf("the reader must report the position, got %d", reader.PlaybackSeconds)
}
if code, _, _ := callRaw(t, r, "PUT", fmt.Sprintf("/api/v1/chapters/%d/playback", chapterID), learner.Token, map[string]any{"positionSeconds": -5}); code != 400 {
t.Fatal("a negative position must be refused")
}
if code, _, _ := callRaw(t, r, "PUT", fmt.Sprintf("/api/v1/chapters/%d/playback", chapterID), learner.Token, map[string]any{}); code != 400 {
t.Fatal("a position request without a value must be refused")
}
// Replacing the audio drops the old position and keeps one row.
smaller := testMP3(2048)
code, msg, replaced := uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), smaller)
if code != 200 || replaced.ByteSize != len(smaller) || replaced.Version == audioView.Version {
t.Fatalf("audio replacement: %d %s %+v", code, msg, replaced)
}
if listed := chapterInList(t, r, learner.Token, bookID, chapterID); listed.PlaybackSeconds != 0 {
t.Fatalf("a replacement must reset the position, got %d", listed.PlaybackSeconds)
}
if rows := attachmentRows(t, db, chapterID, attachmentAudio); rows != 1 {
t.Fatalf("a replacement must keep one row, got %d", rows)
}
// A rejected upload leaves the stored files untouched.
if code, _, _ = uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), []byte("not audio at all")); code != 400 {
t.Fatalf("text as audio must be refused, got %d", code)
}
if code, _, _ = uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/illustration", chapterID), []byte("<svg xmlns='http://www.w3.org/2000/svg'></svg>")); code != 400 {
t.Fatalf("svg as illustration must be refused, got %d", code)
}
big := make([]byte, maxAudioBytes+1024)
copy(big, "ID3")
if code, _, _ = uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), big); code != 413 {
t.Fatalf("an oversized audio must be refused with 413, got %d", code)
}
if code, _, _ = uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/illustration", chapterID), testPNG(t, maxCoverPixels+1, 4)); code != 400 {
t.Fatalf("an oversized illustration must be refused, got %d", code)
}
recorder = fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), nil)
if !bytes.Equal(recorder.Body.Bytes(), smaller) {
t.Fatal("a refused upload must keep the previous audio")
}
recorder = fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/illustration", chapterID), nil)
if !bytes.Equal(recorder.Body.Bytes(), illustration) {
t.Fatal("a refused upload must keep the previous illustration")
}
// Ownership: another account cannot read, replace, remove or report on this chapter.
if recorder = fetchRaw(t, r, other.Token, fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), nil); recorder.Code != 404 {
t.Fatalf("another account reading the audio must be 404, got %d", recorder.Code)
}
if recorder = fetchRaw(t, r, "", fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), nil); recorder.Code != 401 {
t.Fatalf("an anonymous read must be 401, got %d", recorder.Code)
}
if code, _, _ = uploadAttachment(t, r, other.Token, fmt.Sprintf("/api/v1/chapters/%d/illustration", chapterID), testPNG(t, 8, 8)); code != 404 {
t.Fatalf("another account uploading an illustration must be 404, got %d", code)
}
if code, _, _ = callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), other.Token, nil); code != 404 {
t.Fatalf("another account deleting the audio must be 404, got %d", code)
}
if code, _, _ = callRaw(t, r, "PUT", fmt.Sprintf("/api/v1/chapters/%d/playback", chapterID), other.Token, map[string]any{"positionSeconds": 10}); code != 404 {
t.Fatalf("another account reporting a position must be 404, got %d", code)
}
if code, _, _ = callRaw(t, r, "PUT", fmt.Sprintf("/api/v1/chapters/%d/playback", chapterID), "", map[string]any{"positionSeconds": 10}); code != 401 {
t.Fatal("reporting a position needs a session")
}
// Removing the audio removes the position with it, and the illustration is separate.
code, _, _ = callRaw(t, r, "PUT", fmt.Sprintf("/api/v1/chapters/%d/playback", chapterID), learner.Token, map[string]any{"positionSeconds": 42})
if code != 200 {
t.Fatalf("position before removal %d", code)
}
if code, _, _ = callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), learner.Token, nil); code != 200 {
t.Fatalf("audio removal %d", code)
}
var positions int64
if err := db.Model(&ChapterPlaybackPosition{}).Where("chapter_id = ?", chapterID).Count(&positions).Error; err != nil || positions != 0 {
t.Fatalf("removing the audio must remove the position: %d %v", positions, err)
}
if code, _, _ = callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), learner.Token, nil); code != 404 {
t.Fatal("removing a missing attachment must be 404")
}
if rows := attachmentRows(t, db, chapterID, attachmentIllustration); rows != 1 {
t.Fatalf("removing the audio must not touch the illustration: %d", rows)
}
if recorder = fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/illustration", chapterID), nil); recorder.Code != 200 {
t.Fatal("the illustration must still be readable")
}
// Deleting the chapter takes its remaining attachment with it.
code, msg, _ = callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d", chapterID), learner.Token, nil)
if code != 200 {
t.Fatalf("chapter delete: %d %s", code, msg)
}
var attachments int64
if err := db.Model(&ChapterAttachment{}).Where("chapter_id = ?", chapterID).Count(&attachments).Error; err != nil || attachments != 0 {
t.Fatalf("deleting a chapter must remove its attachments: %d %v", attachments, err)
}
}
// TestMySQLBookCoverAndChapterUploadRules covers the cover behaviour that stayed on the book plus the
// request shapes that must not be stored for a chapter.
func TestMySQLBookCoverAndChapterUploadRules(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
other := newLearner(t, r, owner.Token)
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "chapter-attach-0002", "title": "Fictional attachment rules",
"text": "Curiosity opens the first door.\n", "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
drainIngest(t, db)
bookID, chapterID := pasted.Chapter.BookID, pasted.Chapter.ID
// The cover stays a book-level attachment with the same caching behaviour.
cover := testPNG(t, 40, 25)
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, _ := writer.CreateFormFile("file", "cover.png")
part.Write(cover)
writer.Close()
request := httptest.NewRequest("POST", fmt.Sprintf("/api/v1/books/%d/cover", bookID), bytes.NewReader(body.Bytes()))
request.Header.Set("Content-Type", writer.FormDataContentType())
request.Header.Set("Authorization", "Bearer "+learner.Token)
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request)
if recorder.Code != 200 {
t.Fatalf("cover upload: %d", recorder.Code)
}
book, _ := bookDetailOf(t, r, learner.Token, bookID)
if book.Attachments == nil || book.Attachments.Cover == nil || book.Attachments.Cover.Version != contentSHA(string(cover)) {
t.Fatalf("the book must report its cover: %+v", book.Attachments)
}
code, _, listed := callRaw(t, r, "GET", "/api/v1/books", learner.Token, nil)
if code != 200 {
t.Fatalf("book list %d", code)
}
var page struct {
Items []BookSummary
}
if err := json.Unmarshal(listed, &page); err != nil {
t.Fatal(err)
}
if len(page.Items) != 1 || page.Items[0].CoverVersion != contentSHA(string(cover)) {
t.Fatalf("the book list must carry the cover version: %+v", page.Items)
}
if fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d/cover", bookID), nil).Code != 200 {
t.Fatal("the cover must be readable")
}
if fetchRaw(t, r, other.Token, fmt.Sprintf("/api/v1/books/%d/cover", bookID), nil).Code != 404 {
t.Fatal("another account must not read the cover")
}
if code, _, _ := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/books/%d", bookID), learner.Token, nil); code != 200 {
t.Fatal("the book must be deletable")
}
var covers int64
if err := db.Model(&BookAttachment{}).Where("book_id = ?", bookID).Count(&covers).Error; err != nil || covers != 0 {
t.Fatalf("deleting the book must remove its cover: %d %v", covers, err)
}
// Request shapes that must not be stored.
if code, _, _ = uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/illustration", chapterID), nil); code != 400 {
t.Fatalf("an empty upload must be refused, got %d", code)
}
if code, _, _ = uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), []byte{}); code != 400 {
t.Fatalf("an empty audio must be refused, got %d", code)
}
if code, _, _ = uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/audio?extra=1", chapterID), testMP3(16)); code != 400 {
t.Fatal("an upload with query parameters must be refused")
}
if code, _, _ = uploadAttachment(t, r, learner.Token, "/api/v1/chapters/999999/audio", testMP3(16)); code != 404 {
t.Fatal("an unknown chapter must be 404")
}
if code, _, _ = uploadAttachment(t, r, "", fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), testMP3(16)); code != 401 {
t.Fatal("uploading needs a session")
}
var stored int64
if err := db.Model(&ChapterAttachment{}).Where("chapter_id = ?", chapterID).Count(&stored).Error; err != nil || stored != 0 {
t.Fatalf("nothing may be stored for this chapter: %d %v", stored, err)
}
}
+253
View File
@@ -0,0 +1,253 @@
package lexgo
import (
"archive/zip"
"bufio"
"bytes"
"compress/gzip"
"encoding/json"
"errors"
"io"
"path"
"strings"
"unicode"
)
// The prepared 英汉 resource is built by scripts/dict_prepare.py from two pinned sources and is
// imported as a whole; the server only validates and reads it. Chinese glosses and IPA phonetics
// are additive to WordNet, so they live in their own corpus with their own provider identity.
const (
ChineseDictionaryFormat = "lexgo-zh-dict-v1"
ChineseDictionaryVersion = "ecdict-1.0.28+cmudict-0.7b"
ChineseDictionarySource = "https://github.com/skywind3000/ECDICT/releases/download/1.0.28/ecdict-stardict-28.zip"
ChineseDictionarySHA = "" // verified from the archive manifest, see chineseDictionarySources
chineseEntryLimit = 200000
)
// Pinned source checksums: an imported archive must declare exactly these, so a hand-edited or
// unknown resource cannot be loaded as if it were the reviewed one.
var chineseDictionarySources = map[string]string{
"chinese-gloss": "c707d0f3ded6ec79b96466da4a1574e074703da5af9c120fbad97f9cb08c6f2c",
"ipa-phonetic": "81917843c7f44ce2b094ac63873c2c7a4cf802040792c455ba3ca406891c3d22",
"subset-wordlist": "cbda5ea6eef7f36a97a43d4a75f85e07fccbb4f23657d27b4ccbc93e2646ab59",
}
type chineseManifest struct {
Format string `json:"format"`
Version string `json:"version"`
Language string `json:"language"`
Provider string `json:"provider"`
Entries int `json:"entries"`
PreparedBy string `json:"preparedBy"`
Sources []struct {
Name string `json:"name"`
Role string `json:"role"`
SHA256 string `json:"sha256"`
License string `json:"license"`
} `json:"sources"`
}
type chineseRecord struct {
Word string `json:"w"`
Translation string `json:"t"`
Phonetic string `json:"p"`
Source string `json:"ps"`
}
// ChineseDictionary is an immutable lookup table for one imported resource.
type ChineseDictionary struct {
entries map[string]DictionaryEntry
EntryCount int
}
func (d *ChineseDictionary) Lookup(query string) LookupResult {
key := normalizeWord(query)
result := LookupResult{Status: "not_found", Query: query, Candidates: []string{}, Entries: []DictionaryEntry{}}
entry, ok := d.entries[key]
if !ok {
return result
}
form := key
result.Status = "exact"
result.MatchedForm = &form
result.Candidates = []string{form}
result.Entries = []DictionaryEntry{entry}
result.Phonetic = entry.Phonetic
result.PhoneticSource = entry.PhoneticSource
return result
}
// readChineseArchive bounds every declared and inflated size before reading, mirroring the WordNet
// loader so a hostile archive cannot exhaust memory.
func readChineseArchive(raw []byte) (map[string][]byte, error) {
if len(raw) > maxDictionaryZip {
return nil, errors.New("archive too large")
}
reader, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
if err != nil {
return nil, err
}
if len(reader.File) > 8 {
return nil, errors.New("too many archive members")
}
files := map[string][]byte{}
total := int64(0)
for _, f := range reader.File {
name := f.Name
if strings.Contains(name, "\\") || path.IsAbs(name) || strings.Contains(name, ":") || path.Clean(name) != strings.TrimSuffix(name, "/") || (name != "entries.jsonl.gz" && name != "manifest.json") {
return nil, errors.New("unsafe archive member")
}
if !f.FileInfo().IsDir() && !f.Mode().IsRegular() {
return nil, errors.New("unsupported archive member")
}
if _, seen := files[name]; seen {
return nil, errors.New("duplicate archive member")
}
if f.UncompressedSize64 > maxDictionaryInflated || total+int64(f.UncompressedSize64) > maxDictionaryInflated {
return nil, errors.New("inflated archive too large")
}
rc, err := f.Open()
if err != nil {
return nil, err
}
data, err := io.ReadAll(io.LimitReader(rc, maxDictionaryInflated-total+1))
rc.Close()
if err != nil {
return nil, err
}
total += int64(len(data))
if total > maxDictionaryInflated {
return nil, errors.New("inflated archive too large")
}
files[name] = data
}
if len(files["entries.jsonl.gz"]) == 0 || len(files["manifest.json"]) == 0 {
return nil, errors.New("missing required member")
}
return files, nil
}
// ParseChineseDictionary accepts only an archive whose manifest matches the reviewed format,
// version and source checksums. No member is ever extracted to disk.
func ParseChineseDictionary(raw []byte) (*ChineseDictionary, error) {
if len(raw) == 0 || len(raw) > maxDictionaryZip {
return nil, errors.New("archive size out of range")
}
files, err := readChineseArchive(raw)
if err != nil {
return nil, err
}
var manifest chineseManifest
if err = json.Unmarshal(files["manifest.json"], &manifest); err != nil {
return nil, errors.New("invalid manifest")
}
if manifest.Format != ChineseDictionaryFormat || manifest.Version != ChineseDictionaryVersion || manifest.Language != "en" || manifest.Provider != "ecdict" {
return nil, errors.New("unexpected dictionary manifest")
}
if len(manifest.Sources) != len(chineseDictionarySources) {
return nil, errors.New("unexpected dictionary sources")
}
for _, source := range manifest.Sources {
if expected, ok := chineseDictionarySources[source.Role]; !ok || expected != source.SHA256 {
return nil, errors.New("unreviewed dictionary source")
}
}
reader, err := gzip.NewReader(bytes.NewReader(files["entries.jsonl.gz"]))
if err != nil {
return nil, errors.New("invalid entry payload")
}
defer reader.Close()
dictionary := &ChineseDictionary{entries: map[string]DictionaryEntry{}}
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, 4096), 1<<20)
for scanner.Scan() {
line := scanner.Bytes()
if len(bytes.TrimSpace(line)) == 0 {
continue
}
var record chineseRecord
if err = json.Unmarshal(line, &record); err != nil {
return nil, errors.New("invalid entry record")
}
word := strings.TrimSpace(record.Word)
if word == "" || word != normalizeWord(word) || !isDictionaryHeadword(word) || record.Translation == "" && record.Phonetic == "" {
return nil, errors.New("invalid entry")
}
if _, exists := dictionary.entries[word]; exists {
return nil, errors.New("duplicate entry")
}
if len(dictionary.entries) >= chineseEntryLimit {
return nil, errors.New("too many entries")
}
dictionary.entries[word] = DictionaryEntry{
Lemma: word,
Translation: record.Translation,
Phonetic: record.Phonetic,
PhoneticSource: record.Source,
Provider: "ecdict",
}
}
if err = scanner.Err(); err != nil {
return nil, err
}
if len(dictionary.entries) == 0 || manifest.Entries != len(dictionary.entries) {
return nil, errors.New("entry count mismatch")
}
dictionary.EntryCount = len(dictionary.entries)
return dictionary, nil
}
// mergeLookups keeps every provider's senses in caller order: the Chinese gloss first because it
// answers "what does this mean" for the learner, WordNet afterwards as English detail.
func mergeLookups(query string, results []LookupResult) LookupResult {
merged := LookupResult{Status: "not_found", Query: query, Candidates: []string{}, Entries: []DictionaryEntry{}}
for _, result := range results {
if result.Status == "exact" {
merged.Status = "exact"
} else if result.Status == "lemma" && merged.Status == "not_found" {
merged.Status = "lemma"
}
for _, candidate := range result.Candidates {
if !containsString(merged.Candidates, candidate) {
merged.Candidates = append(merged.Candidates, candidate)
}
}
if merged.MatchedForm == nil && result.MatchedForm != nil {
form := *result.MatchedForm
merged.MatchedForm = &form
}
for _, entry := range result.Entries {
if len(merged.Entries) >= 12 {
break
}
// A phonetic belongs to the headword: an inflected form resolved through WordNet's
// morphology shows the gloss but not the headword's transcription.
if entry.Phonetic != "" && merged.Phonetic == "" && entry.Lemma == normalizeWord(query) {
merged.Phonetic = entry.Phonetic
merged.PhoneticSource = entry.PhoneticSource
}
merged.Entries = append(merged.Entries, entry)
}
}
return merged
}
// isDictionaryHeadword keeps the stored key shape predictable: letters with the apostrophe and
// hyphen that appear in real headwords, nothing that could smuggle in markup or a sentence.
func isDictionaryHeadword(word string) bool {
for _, r := range word {
if !unicode.IsLetter(r) && r != '\'' && r != '-' {
return false
}
}
return true
}
func containsString(values []string, wanted string) bool {
for _, value := range values {
if value == wanted {
return true
}
}
return false
}
+351
View File
@@ -0,0 +1,351 @@
package lexgo
import (
"archive/zip"
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"mime/multipart"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
admin "go-admin/app/admin/models"
)
const chineseFixturePath = "../../../.local/dictionaries/zh-dict-v1.zip"
func chineseFixture(t *testing.T) []byte {
t.Helper()
raw, err := os.ReadFile(chineseFixturePath)
if os.IsNotExist(err) {
t.Skip("prepare the 英汉 fixture with python scripts/dict_prepare.py")
}
if err != nil {
t.Fatal(err)
}
return raw
}
// buildChineseArchive writes a synthetic archive so rejection paths can be exercised without a
// hand-edited copy of the real 3 MiB resource.
func buildChineseArchive(t *testing.T, manifest map[string]any, entries []map[string]any) []byte {
t.Helper()
if _, ok := manifest["format"]; !ok {
manifest["format"] = ChineseDictionaryFormat
}
if _, ok := manifest["version"]; !ok {
manifest["version"] = ChineseDictionaryVersion
}
if _, ok := manifest["language"]; !ok {
manifest["language"] = "en"
}
if _, ok := manifest["provider"]; !ok {
manifest["provider"] = "ecdict"
}
if _, ok := manifest["entries"]; !ok {
manifest["entries"] = len(entries)
}
if _, ok := manifest["sources"]; !ok {
manifest["sources"] = []map[string]string{
{"name": "ECDICT StarDict", "role": "chinese-gloss", "sha256": chineseDictionarySources["chinese-gloss"], "license": "ECDICT-LICENSE.txt"},
{"name": "CMUdict", "role": "ipa-phonetic", "sha256": chineseDictionarySources["ipa-phonetic"], "license": "CMUDICT-LICENSE.txt"},
{"name": "Princeton WordNet", "role": "subset-wordlist", "sha256": chineseDictionarySources["subset-wordlist"], "license": "WORDNET-LICENSE.txt"},
}
}
lines := make([]string, 0, len(entries))
for _, entry := range entries {
encoded, err := json.Marshal(entry)
if err != nil {
t.Fatal(err)
}
lines = append(lines, string(encoded))
}
var payload bytes.Buffer
writer := gzip.NewWriter(&payload)
writer.Write([]byte(strings.Join(lines, "\n")))
writer.Close()
var archive bytes.Buffer
bundle := zip.NewWriter(&archive)
for name, content := range map[string][]byte{
"manifest.json": mustJSON(t, manifest),
"entries.jsonl.gz": payload.Bytes(),
} {
part, err := bundle.Create(name)
if err != nil {
t.Fatal(err)
}
part.Write(content)
}
if err := bundle.Close(); err != nil {
t.Fatal(err)
}
return archive.Bytes()
}
func mustJSON(t *testing.T, value any) []byte {
t.Helper()
encoded, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
return encoded
}
func TestChineseDictionaryParseAndLookup(t *testing.T) {
dictionary, err := ParseChineseDictionary(chineseFixture(t))
if err != nil {
t.Fatal(err)
}
if dictionary.EntryCount != 82721 {
t.Fatalf("entry count %d, want the pinned 82721", dictionary.EntryCount)
}
got := dictionary.Lookup("curiosity")
if got.Status != "exact" || got.Phonetic != "kjˌʊɹiˈɑːsəti" || got.PhoneticSource != "cmudict" {
t.Fatalf("curiosity %+v", got)
}
if len(got.Entries) != 1 || got.Entries[0].Provider != "ecdict" || !strings.Contains(got.Entries[0].Translation, "好奇心") {
t.Fatalf("curiosity entry %+v", got.Entries)
}
if got.Entries[0].Definition != "" || len(got.Entries[0].Examples) != 0 {
t.Fatal("the Chinese resource must not invent English senses")
}
// The reader sends the selected word, so a different case must still resolve.
if upper := dictionary.Lookup("Curiosity"); upper.Status != "exact" || upper.Phonetic != "kjˌʊɹiˈɑːsəti" {
t.Fatalf("case-insensitive query %+v", upper)
}
// A word whose phonetic is unreliable keeps its gloss and shows no transcription.
if grok := dictionary.Lookup("grok"); grok.Status != "exact" || grok.Phonetic != "" || len(grok.Entries) != 1 {
t.Fatalf("grok %+v", grok)
}
if grok := dictionary.Lookup("grok"); !strings.Contains(grok.Entries[0].Translation, "神入") {
t.Fatalf("grok gloss %q", grok.Entries[0].Translation)
}
if miss := dictionary.Lookup("zzzznotaword"); miss.Status != "not_found" || len(miss.Entries) != 0 {
t.Fatalf("miss %+v", miss)
}
}
func TestChineseDictionaryRejectsUnreviewedArchive(t *testing.T) {
entry := []map[string]any{{"w": "grok", "t": "v. 神入", "p": "ɡrɒk", "ps": "cmudict"}}
for name, manifest := range map[string]map[string]any{
"format": {"format": "wordnet-3.0-zip"},
"version": {"version": "ecdict-9.9.9"},
"count": {"entries": 5},
"source": {"sources": []map[string]string{{"name": "ECDICT StarDict", "role": "chinese-gloss", "sha256": strings.Repeat("0", 64), "license": "ECDICT-LICENSE.txt"}}},
"role": {"sources": []map[string]string{{"name": "ECDICT StarDict", "role": "unknown-role", "sha256": chineseDictionarySources["chinese-gloss"], "license": "ECDICT-LICENSE.txt"}}},
} {
if _, err := ParseChineseDictionary(buildChineseArchive(t, manifest, entry)); err == nil {
t.Fatalf("%s: unreviewed archive accepted", name)
}
}
// The reviewed shape still parses, so the rejection cases above are not vacuous.
if _, err := ParseChineseDictionary(buildChineseArchive(t, map[string]any{}, entry)); err != nil {
t.Fatalf("reviewed archive rejected: %v", err)
}
for name, entrySet := range map[string][]map[string]any{
"duplicate": {{"w": "grok", "t": "a", "p": "", "ps": ""}, {"w": "grok", "t": "b", "p": "", "ps": ""}},
"empty": {{"w": "grok", "t": "", "p": "", "ps": ""}},
"notAWord": {{"w": "grok!", "t": "a", "p": "", "ps": ""}},
"upperCase": {{"w": "Grok", "t": "a", "p": "", "ps": ""}},
} {
if _, err := ParseChineseDictionary(buildChineseArchive(t, map[string]any{}, entrySet)); err == nil {
t.Fatalf("%s: invalid entry accepted", name)
}
}
}
func TestMergeLookupsOrdersGlossBeforeEnglishDetail(t *testing.T) {
chinese := LookupResult{Status: "exact", Query: "dog", Candidates: []string{"dog"}, MatchedForm: form("dog"), Phonetic: "dɔɡ", PhoneticSource: "cmudict", Entries: []DictionaryEntry{{Lemma: "dog", Translation: "n. 狗", Phonetic: "dɔɡ", PhoneticSource: "cmudict", Provider: "ecdict"}}}
english := LookupResult{Status: "exact", Query: "dog", Candidates: []string{"dog"}, MatchedForm: form("dog"), Entries: []DictionaryEntry{{Lemma: "dog", POS: "n", Definition: "a domesticated canine", Provider: "wordnet"}}}
merged := mergeLookups("dog", []LookupResult{chinese, english})
if merged.Status != "exact" || len(merged.Entries) != 2 || merged.Entries[0].Provider != "ecdict" || merged.Entries[1].Provider != "wordnet" {
t.Fatalf("merged %+v", merged)
}
if merged.Phonetic != "dɔɡ" || merged.PhoneticSource != "cmudict" {
t.Fatalf("phonetic %+v", merged)
}
// A headword phonetic must not be shown for an inflected form resolved by the other provider.
inflected := mergeLookups("dogs", []LookupResult{{Status: "lemma", Query: "dogs", MatchedForm: form("dog"), Candidates: []string{"dog"}, Entries: []DictionaryEntry{{Lemma: "dog", Translation: "n. 狗", Phonetic: "dɔɡ", Provider: "ecdict"}}}})
if inflected.Phonetic != "" || inflected.Status != "lemma" {
t.Fatalf("inflected %+v", inflected)
}
lemma := mergeLookups("dogs", []LookupResult{
{Status: "lemma", Query: "dogs", MatchedForm: form("dog"), Candidates: []string{"dog"}, Entries: []DictionaryEntry{{Lemma: "dog", POS: "n", Definition: "canine", Provider: "wordnet"}}},
{Status: "not_found", Query: "dogs"},
})
if lemma.Status != "lemma" || len(lemma.Entries) != 1 || lemma.MatchedForm == nil || *lemma.MatchedForm != "dog" {
t.Fatalf("lemma only %+v", lemma)
}
empty := mergeLookups("dogs", []LookupResult{{Status: "not_found", Query: "dogs"}})
if empty.Status != "not_found" || len(empty.Entries) != 0 {
t.Fatalf("empty %+v", empty)
}
}
func form(value string) *string { return &value }
func importResourceAPI(t *testing.T, r *gin.Engine, token string, fields map[string]string, raw []byte, filename string) (int, DictionaryImportResult) {
t.Helper()
var body bytes.Buffer
w := multipart.NewWriter(&body)
for key, value := range fields {
if err := w.WriteField(key, value); err != nil {
t.Fatal(err)
}
}
part, err := w.CreateFormFile("file", filename)
if err != nil {
t.Fatal(err)
}
part.Write(raw)
w.Close()
req := httptest.NewRequest("POST", "/api/v1/dictionaries/import", &body)
req.Header.Set("Content-Type", w.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+token)
response := httptest.NewRecorder()
r.ServeHTTP(response, req)
var envelope struct {
Data json.RawMessage `json:"data"`
}
json.Unmarshal(response.Body.Bytes(), &envelope)
var result DictionaryImportResult
json.Unmarshal(envelope.Data, &result)
if response.Code != 200 {
t.Logf("import %s response %s", filename, response.Body.String())
}
return response.Code, result
}
// TestMergedDictionaryLookupAPI imports both resources into the dedicated test database and checks
// the learner-visible merge: Chinese gloss and phonetic first, English detail after, with each
// dictionary independently switchable.
func TestMergedDictionaryLookupAPI(t *testing.T) {
db := testDB(t)
wordnetRaw, err := os.ReadFile("../../../.local/nlp-resources/wordnet.zip")
if os.IsNotExist(err) {
t.Skip("prepare WordNet fixture")
}
if err != nil {
t.Fatal(err)
}
chineseRaw := chineseFixture(t)
if err := db.Exec("DELETE FROM lexgo_dictionaries").Error; err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Exec("DELETE FROM lexgo_dictionaries") })
r := Router(db, time.Now)
adminUser := admin.SysUser{Username: randomName("zhdict"), Password: fixturePassword, RoleId: 1, Status: "2"}
learner := admin.SysUser{Username: randomName("zhlearner"), Password: fixturePassword, RoleId: 2, Status: "2"}
for _, user := range []*admin.SysUser{&adminUser, &learner} {
if err := db.Create(user).Error; err != nil {
t.Fatal(err)
}
}
adminToken := loginToken(t, r, adminUser.Username, fixturePassword)
learnerToken := loginToken(t, r, learner.Username, fixturePassword)
if code, result := importResourceAPI(t, r, adminToken, map[string]string{"name": "Princeton WordNet", "language": "en", "version": WordNetVersion, "source": WordNetSource, "format": "wordnet-3.0-zip"}, wordnetRaw, "wordnet.zip"); code != 200 || result.Resource.Provider != "wordnet" || result.Resource.ID != WordNetSlot || result.Resource.EntryCount == 0 {
t.Fatalf("wordnet import %d %+v", code, result)
}
code, imported := importResourceAPI(t, r, adminToken, map[string]string{"name": "简明英汉字典增强版", "language": "en", "version": ChineseDictionaryVersion, "source": ChineseDictionarySource, "format": ChineseDictionaryFormat}, chineseRaw, "zh-dict-v1.zip")
if code != 200 || imported.Resource.Provider != "ecdict" || imported.Resource.ID != ChineseSlot || imported.Resource.EntryCount != 82721 {
t.Fatalf("chinese import %d %+v", code, imported)
}
// Wrong metadata must not occupy the slot.
if code, _ := importResourceAPI(t, r, adminToken, map[string]string{"name": "unknown", "language": "en", "version": "1.0", "source": ChineseDictionarySource, "format": ChineseDictionaryFormat}, chineseRaw, "zh-dict-v1.zip"); code != 400 {
t.Fatalf("unreviewed metadata accepted: %d", code)
}
book := Book{OwnerID: learner.UserId, Title: "Fictional", Language: "en", CreatedAt: stamp(time.Now()), UpdatedAt: stamp(time.Now())}
if err := db.Create(&book).Error; err != nil {
t.Fatal(err)
}
original := "Dogs want curiosity."
chapter := Chapter{BookID: book.ID, OwnerID: learner.UserId, Ordinal: 1, Title: "Fictional", OriginalText: original, ContentSHA256: contentSHA(original), CharCount: len([]rune(original)), Status: statusReady, CreatedAt: stamp(time.Now()), UpdatedAt: stamp(time.Now())}
if err := db.Create(&chapter).Error; err != nil {
t.Fatal(err)
}
_, data := callAPI(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d/tokens", chapter.ID), learnerToken, nil)
var tokens ChapterTokens
json.Unmarshal(data, &tokens)
ranges := map[string][2]int{}
for _, token := range tokens.Tokens {
if token.Text == "Dogs" || token.Text == "curiosity" || token.Text == "went" {
ranges[token.Text] = [2]int{token.StartUtf16, token.EndUtf16}
}
}
if _, ok := ranges["Dogs"]; !ok {
t.Fatalf("token ranges missing: %s", string(data))
}
lookup := func(word string) (int, LookupResult) {
span := ranges[word]
code, payload := callAPI(t, r, "POST", "/api/v1/lookup", learnerToken, map[string]any{"chapterId": chapter.ID, "start": span[0], "end": span[1]})
var got LookupResult
json.Unmarshal(payload, &got)
return code, got
}
code, got := lookup("curiosity")
if code != 200 || got.Status != "exact" || got.Phonetic != "kjˌʊɹiˈɑːsəti" || got.PhoneticSource != "cmudict" {
t.Fatalf("curiosity %d %+v", code, got)
}
if len(got.Entries) < 2 || got.Entries[0].Provider != "ecdict" || !strings.Contains(got.Entries[0].Translation, "好奇心") || got.Entries[len(got.Entries)-1].Provider != "wordnet" {
t.Fatalf("curiosity merge %+v", got.Entries)
}
if len(got.Resources) != 2 || got.Resource == nil || got.Resource.Name != "Princeton WordNet" {
t.Fatalf("curiosity resources %+v", got.Resources)
}
if got.Entries[0].Definition != "" {
t.Fatal("the Chinese sense must not carry an English definition")
}
// "Dogs" is not a headword: WordNet resolves the inflection and the gloss follows it, without
// copying the headword's transcription onto the inflected form.
code, plural := lookup("Dogs")
if code != 200 || plural.Status != "lemma" || plural.Phonetic != "" {
t.Fatalf("Dogs %d %+v", code, plural)
}
if len(plural.Entries) == 0 || plural.Entries[0].Provider != "ecdict" || !strings.Contains(plural.Entries[0].Translation, "狗") {
t.Fatalf("Dogs gloss %+v", plural.Entries)
}
// Each dictionary is switchable on its own.
if code, _ := callAPI(t, r, "PATCH", fmt.Sprintf("/api/v1/dictionaries/%d", ChineseSlot), adminToken, map[string]bool{"enabled": false}); code != 200 {
t.Fatalf("disable chinese %d", code)
}
if code, english := lookup("curiosity"); code != 200 || english.Phonetic != "" || hasProviderEntry(english.Entries, "ecdict") || len(english.Resources) != 1 {
t.Fatalf("english only %d %+v", code, english)
}
// Bring the Chinese dictionary back before switching the other one off, so each step proves
// that exactly one provider is answering.
if code, _ := callAPI(t, r, "PATCH", fmt.Sprintf("/api/v1/dictionaries/%d", ChineseSlot), adminToken, map[string]bool{"enabled": true}); code != 200 {
t.Fatalf("re-enable chinese %d", code)
}
if code, _ := callAPI(t, r, "PATCH", fmt.Sprintf("/api/v1/dictionaries/%d", WordNetSlot), adminToken, map[string]bool{"enabled": false}); code != 200 {
t.Fatalf("disable wordnet %d", code)
}
if code, only := lookup("curiosity"); code != 200 || !hasProviderEntry(only.Entries, "ecdict") || only.Phonetic != "kjˌʊɹiˈɑːsəti" || only.Resource == nil || only.Resource.Name == "Princeton WordNet" {
t.Fatalf("chinese only %d %+v", code, only)
}
if code, _ := callAPI(t, r, "PATCH", fmt.Sprintf("/api/v1/dictionaries/%d", ChineseSlot), adminToken, map[string]bool{"enabled": false}); code != 200 {
t.Fatalf("disable both %d", code)
}
if code, none := lookup("curiosity"); code != 200 || none.Status != "resource_missing" || len(none.Resources) != 0 {
t.Fatalf("both disabled %d %+v", code, none)
}
// Re-enabling after a cold restart proves the archive is reloaded from MySQL, not from memory.
if code, _ := callAPI(t, r, "PATCH", fmt.Sprintf("/api/v1/dictionaries/%d", ChineseSlot), adminToken, map[string]bool{"enabled": true}); code != 200 {
t.Fatalf("re-enable %d", code)
}
r = Router(db, time.Now)
if code, cold := lookup("curiosity"); code != 200 || cold.Phonetic != "kjˌʊɹiˈɑːsəti" {
t.Fatalf("cold chinese %d %+v", code, cold)
}
if code, listed := callAPI(t, r, "GET", "/api/v1/dictionaries", adminToken, nil); code != 200 || !bytes.Contains(listed, []byte(`"provider":"ecdict"`)) {
t.Fatalf("list %d %s", code, string(listed))
}
}
+243 -5
View File
@@ -2,6 +2,7 @@ package lexgo
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
@@ -60,7 +61,7 @@ func Migrate(db *gorm.DB) error {
if err = conn.QueryRowContext(ctx, "SELECT version,product FROM lexgo_schema WHERE id=1").Scan(&current, &product); err != nil {
return err
}
if product != "lexgo" || current < 0 || current > schemaVersion {
if product != "lexgo" || current < 0 || current > SchemaVersion {
return errors.New("unknown schema version")
}
// Each known version contributes its own statements; the version row advances only
@@ -75,6 +76,21 @@ func Migrate(db *gorm.DB) error {
if current < 4 {
statements = append(statements, schemaV4Statements...)
}
if current < 5 {
statements = append(statements, schemaV5Statements...)
}
if current < 6 {
statements = append(statements, schemaV6Statements...)
}
if current < 7 {
statements = append(statements, schemaV7Statements...)
}
if current < 8 {
statements = append(statements, schemaV8Statements...)
}
if current < 9 {
statements = append(statements, schemaV9Statements...)
}
for i, s := range statements {
if _, err = conn.ExecContext(ctx, s); err != nil {
var sqlErr *driver.MySQLError
@@ -84,11 +100,231 @@ func Migrate(db *gorm.DB) error {
return fmt.Errorf("migration statement %d failed", i+1)
}
}
_, err = conn.ExecContext(ctx, fmt.Sprintf("UPDATE lexgo_schema SET version=%d WHERE id=1", schemaVersion))
// The author columns are added conditionally after the statements above: MySQL has no
// ADD COLUMN IF NOT EXISTS, an older database only gains its book and chapter tables in those
// statements, and the version row is still advanced only once everything succeeded, so a
// partially applied or rolled-back migration can be replayed.
if current < 10 {
if err = addAuthorColumn(ctx, conn, "lexgo_chapters"); err != nil {
return err
}
}
if current < 11 {
if err = addAuthorColumn(ctx, conn, "lexgo_books"); err != nil {
return err
}
}
// v12 lets a second English dictionary live beside WordNet: the single-slot CHECK on
// lexgo_dictionaries is dropped and a provider column names each resource. Both steps are
// conditional because MySQL has no DROP CHECK IF EXISTS and an older database lacks the column,
// and the version row still advances only after everything succeeded.
if current < 12 {
if err = addDictionaryProvider(ctx, conn); err != nil {
return err
}
if err = dropDictionarySingleSlotCheck(ctx, conn); err != nil {
return err
}
}
_, err = conn.ExecContext(ctx, fmt.Sprintf("UPDATE lexgo_schema SET version=%d WHERE id=1", SchemaVersion))
return err
}
const schemaVersion = 4
// SchemaVersion is the version an explicit migration leaves behind, and the
// version the server requires before it starts.
const SchemaVersion = 12
// addDictionaryProvider adds the provider column when it is missing, so replaying the migration
// stays safe. Existing rows keep the WordNet slot through the default value.
func addDictionaryProvider(ctx context.Context, conn *sql.Conn) error {
var exists int
if err := conn.QueryRowContext(ctx,
"SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lexgo_dictionaries' AND COLUMN_NAME = 'provider'").Scan(&exists); err != nil {
return err
}
if exists > 0 {
return nil
}
_, err := conn.ExecContext(ctx, "ALTER TABLE lexgo_dictionaries ADD COLUMN provider VARCHAR(32) NOT NULL DEFAULT 'wordnet'")
return err
}
// dropDictionarySingleSlotCheck removes the CHECK (id = 1) constraint of v1..v11 so more than one
// dictionary row can exist. MySQL names such a constraint automatically, so it is looked up and
// skipped when it is already gone.
func dropDictionarySingleSlotCheck(ctx context.Context, conn *sql.Conn) error {
rows, err := conn.QueryContext(ctx,
"SELECT tc.CONSTRAINT_NAME, cc.CHECK_CLAUSE FROM information_schema.TABLE_CONSTRAINTS tc "+
"JOIN information_schema.CHECK_CONSTRAINTS cc ON cc.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA AND cc.CONSTRAINT_NAME = tc.CONSTRAINT_NAME "+
"WHERE tc.TABLE_SCHEMA = DATABASE() AND tc.TABLE_NAME = 'lexgo_dictionaries' AND tc.CONSTRAINT_TYPE = 'CHECK'")
if err != nil {
return err
}
names := []string{}
for rows.Next() {
var name, clause string
if err = rows.Scan(&name, &clause); err != nil {
rows.Close()
return err
}
compact := strings.ReplaceAll(strings.ReplaceAll(clause, "`", ""), " ", "")
if strings.Contains(compact, "id=1") {
names = append(names, name)
}
}
rows.Close()
if err = rows.Err(); err != nil {
return err
}
for _, name := range names {
if _, err = conn.ExecContext(ctx, "ALTER TABLE lexgo_dictionaries DROP CHECK `"+name+"`"); err != nil {
return err
}
}
return nil
}
func addAuthorColumn(ctx context.Context, conn *sql.Conn, table string) error {
var exists int
if err := conn.QueryRowContext(ctx,
"SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = 'author'", table).Scan(&exists); err != nil {
return err
}
if exists > 0 {
return nil
}
_, err := conn.ExecContext(ctx, "ALTER TABLE `"+table+"` ADD COLUMN author VARCHAR(120) NOT NULL DEFAULT ''")
return err
}
// v9 moves audio and adds illustrations to the chapter, where a multi-article book needs them, and
// keeps the cover on the book. Both new tables are additive, so the version marker is the only
// thing a binary rollback has to change. The last statement removes the retired book-level audio
// rows; it is idempotent and part of the same version, so a retry after a partial run leaves the
// same result.
var schemaV9Statements = []string{
`CREATE TABLE IF NOT EXISTS lexgo_chapter_attachments (
chapter_id BIGINT UNSIGNED NOT NULL,
kind VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
owner_id BIGINT NOT NULL,
mime VARCHAR(64) NOT NULL, byte_size INT NOT NULL,
sha256 CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
bytes MEDIUMBLOB NOT NULL,
created_at DATETIME(3) NOT NULL, updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (chapter_id, kind),
CHECK (kind IN ('audio','illustration')),
FOREIGN KEY (chapter_id) REFERENCES lexgo_chapters(id) ON DELETE CASCADE,
FOREIGN KEY (owner_id) REFERENCES sys_user(user_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS lexgo_chapter_playback_positions (
owner_id BIGINT NOT NULL, chapter_id BIGINT UNSIGNED NOT NULL,
position_seconds INT NOT NULL DEFAULT 0, updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (owner_id, chapter_id),
CHECK (position_seconds >= 0),
FOREIGN KEY (owner_id) REFERENCES sys_user(user_id) ON DELETE CASCADE,
FOREIGN KEY (chapter_id) REFERENCES lexgo_chapters(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
// Book-level audio is retired: the file belongs to a chapter now. Only the test-era rows can
// exist here, and the ticket records that they are not migrated.
`DELETE FROM lexgo_book_attachments WHERE kind = 'audio'`,
}
// v8 adds the optional book attachments and the per-learner playback position. Both are additive
// tables, so an older binary can still be rolled back to by writing the version marker back.
// The file bytes live in the database on purpose: a dump stays a complete backup, the ownership
// check is the same as for every other private row, and deleting a book cannot leave a file behind.
var schemaV8Statements = []string{
`CREATE TABLE IF NOT EXISTS lexgo_book_attachments (
book_id BIGINT UNSIGNED NOT NULL,
kind VARCHAR(8) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
owner_id BIGINT NOT NULL,
mime VARCHAR(64) NOT NULL, byte_size INT NOT NULL,
sha256 CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
bytes MEDIUMBLOB NOT NULL,
created_at DATETIME(3) NOT NULL, updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (book_id, kind),
CHECK (kind IN ('audio','cover')),
FOREIGN KEY (book_id) REFERENCES lexgo_books(id) ON DELETE CASCADE,
FOREIGN KEY (owner_id) REFERENCES sys_user(user_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS lexgo_playback_positions (
owner_id BIGINT NOT NULL, book_id BIGINT UNSIGNED NOT NULL,
position_seconds INT NOT NULL DEFAULT 0, updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (owner_id, book_id),
CHECK (position_seconds >= 0),
FOREIGN KEY (owner_id) REFERENCES sys_user(user_id) ON DELETE CASCADE,
FOREIGN KEY (book_id) REFERENCES lexgo_books(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
}
// v7 records one learner's own reading completion per chapter. A chapter belongs to exactly
// one owner, so the chapter id alone identifies the row and repeating the completion cannot
// add a second one. read_sha256 snapshots the chapter content the person actually read: when
// the text is replaced by a new version the mark stops matching and the chapter counts as
// unread again, without deleting the row or touching any word state.
var schemaV7Statements = []string{
`CREATE TABLE IF NOT EXISTS lexgo_chapter_progress (
chapter_id BIGINT UNSIGNED PRIMARY KEY,
owner_id BIGINT NOT NULL, book_id BIGINT UNSIGNED NOT NULL,
language VARCHAR(16) NOT NULL DEFAULT 'en',
read_sha256 CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
read_at DATETIME(3) NOT NULL, created_at DATETIME(3) NOT NULL, updated_at DATETIME(3) NOT NULL,
INDEX idx_chapter_progress_owner (owner_id, language, read_at, chapter_id),
FOREIGN KEY (chapter_id) REFERENCES lexgo_chapters(id) ON DELETE CASCADE,
FOREIGN KEY (book_id) REFERENCES lexgo_books(id) ON DELETE CASCADE,
FOREIGN KEY (owner_id) REFERENCES sys_user(user_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
}
var schemaV6Statements = []string{
`CREATE TABLE IF NOT EXISTS lexgo_term_reviews (
term_id BIGINT UNSIGNED PRIMARY KEY,
owner_id BIGINT NOT NULL, language VARCHAR(16) NOT NULL DEFAULT 'en',
due_at DATETIME(3) NOT NULL, review_count INT NOT NULL DEFAULT 0,
correct_count INT NOT NULL DEFAULT 0, wrong_count INT NOT NULL DEFAULT 0,
last_reviewed_at DATETIME(3) NULL,
INDEX idx_term_review_due (owner_id, language, due_at, term_id),
FOREIGN KEY (term_id) REFERENCES lexgo_terms(id) ON DELETE CASCADE,
FOREIGN KEY (owner_id) REFERENCES sys_user(user_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`INSERT IGNORE INTO lexgo_term_reviews (term_id, owner_id, language, due_at)
SELECT id, owner_id, language, created_at FROM lexgo_terms`,
`CREATE TABLE IF NOT EXISTS lexgo_review_answers (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
owner_id BIGINT NOT NULL,
answer_key CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
term_id BIGINT UNSIGNED NOT NULL, grade VARCHAR(16) NOT NULL, result VARCHAR(16) NOT NULL,
status_before VARCHAR(16) NOT NULL, status_after VARCHAR(16) NOT NULL,
level_before TINYINT NOT NULL, level_after TINYINT NOT NULL,
due_at_before DATETIME(3) NOT NULL, due_at_after DATETIME(3) NOT NULL,
requeued BOOLEAN NOT NULL DEFAULT FALSE, created_at DATETIME(3) NOT NULL,
UNIQUE KEY uq_review_answer (owner_id, answer_key),
INDEX idx_review_answer_owner (owner_id, created_at, id), INDEX idx_review_answer_term (term_id, created_at),
CHECK (grade IN ('correct','wrong','again')), CHECK (result IN ('applied','stale')),
FOREIGN KEY (owner_id) REFERENCES sys_user(user_id) ON DELETE CASCADE,
FOREIGN KEY (term_id) REFERENCES lexgo_terms(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
}
// v5 adds one learner's own records for a word form. Identity is the normalized
// form under the owner's language, so re-saving the same word updates one row
// instead of creating a second, conflicting record. utf8mb4_bin keeps the key
// byte-exact: the Go side normalizes, and an accent-insensitive collation must
// not fold "resume" and "résumé" into the same entry.
var schemaV5Statements = []string{
`CREATE TABLE IF NOT EXISTS lexgo_terms (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, owner_id BIGINT NOT NULL,
language VARCHAR(16) NOT NULL DEFAULT 'en',
term VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
original_form VARCHAR(191) NOT NULL,
definition TEXT NOT NULL, examples TEXT NOT NULL,
status VARCHAR(16) NOT NULL, level TINYINT NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL, updated_at DATETIME(3) NOT NULL,
UNIQUE KEY uq_term_identity (owner_id, language, term),
CONSTRAINT ck_term_status CHECK (status IN ('new','learning','known','ignored')),
CONSTRAINT ck_term_level CHECK ((status = 'learning' AND level BETWEEN 1 AND 7) OR (status <> 'learning' AND level = 0)),
FOREIGN KEY (owner_id) REFERENCES sys_user(user_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
}
var schemaV4Statements = []string{
`CREATE TABLE IF NOT EXISTS lexgo_dictionaries (
@@ -141,7 +377,8 @@ var schemaV2Statements = []string{
var schemaV3Statements = []string{
`CREATE TABLE IF NOT EXISTS lexgo_books (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, owner_id BIGINT NOT NULL,
title VARCHAR(120) NOT NULL, language VARCHAR(16) NOT NULL DEFAULT 'en',
title VARCHAR(120) NOT NULL, author VARCHAR(120) NOT NULL DEFAULT '',
language VARCHAR(16) NOT NULL DEFAULT 'en',
created_at DATETIME(3) NOT NULL, updated_at DATETIME(3) NOT NULL,
INDEX (owner_id, updated_at, id),
FOREIGN KEY (owner_id) REFERENCES sys_user(user_id) ON DELETE CASCADE
@@ -149,6 +386,7 @@ var schemaV3Statements = []string{
`CREATE TABLE IF NOT EXISTS lexgo_chapters (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, book_id BIGINT UNSIGNED NOT NULL, owner_id BIGINT NOT NULL,
ordinal INT NOT NULL, title VARCHAR(120) NOT NULL,
author VARCHAR(120) NOT NULL DEFAULT '',
original_text MEDIUMTEXT NOT NULL, char_count INT NOT NULL DEFAULT 0,
content_sha256 CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'pending', error_reason VARCHAR(32) NOT NULL DEFAULT '',
@@ -177,7 +415,7 @@ func CheckSchema(db *gorm.DB) error {
Version int
Product string
}
if err := db.Table("lexgo_schema").Where("id=1").First(&r).Error; err != nil || r.Version != schemaVersion || r.Product != "lexgo" {
if err := db.Table("lexgo_schema").Where("id=1").First(&r).Error; err != nil || r.Version != SchemaVersion || r.Product != "lexgo" {
return errors.New("run the explicit migration before starting")
}
return nil
+181 -54
View File
@@ -4,6 +4,7 @@ import (
"errors"
"io"
"net/http"
"sort"
"strconv"
"strings"
"sync"
@@ -17,10 +18,12 @@ import (
"gorm.io/gorm/clause"
)
// ID 1 is the sole English corpus slot. Keeping the validated ZIP in MySQL
// makes ordinary database backups include the resource needed after a restart.
// Slot 1 holds the reviewed WordNet corpus and slot 2 the prepared 英汉 resource. The provider
// column names what each row is, because a second English dictionary now lives beside the first.
// Keeping both in MySQL means ordinary database backups include every resource needed at restart.
type DictionaryResource struct {
ID int64 `gorm:"primaryKey;autoIncrement:false"`
Provider string
Name string
Language string
Version string
@@ -37,6 +40,7 @@ func (DictionaryResource) TableName() string { return "lexgo_dictionaries" }
type DictionaryView struct {
ID int64 `json:"id"`
Provider string `json:"provider"`
Name string `json:"name"`
Language string `json:"language"`
Version string `json:"version"`
@@ -50,7 +54,7 @@ type DictionaryView struct {
}
func dictionaryView(r DictionaryResource, status string) DictionaryView {
return DictionaryView{r.ID, r.Name, r.Language, r.Version, r.Source, r.Format, status, r.Enabled, r.SHA256, r.EntryCount, r.UpdatedAt}
return DictionaryView{r.ID, r.Provider, r.Name, r.Language, r.Version, r.Source, r.Format, status, r.Enabled, r.SHA256, r.EntryCount, r.UpdatedAt}
}
type DictionaryImportResult struct {
@@ -58,38 +62,59 @@ type DictionaryImportResult struct {
Duplicate bool `json:"duplicate"`
}
type ChapterTokens struct {
TextSHA256 string `json:"textSha256"`
Tokens []TextToken `json:"tokens"`
TextSHA256 string `json:"textSha256"`
Tokens []TextToken `json:"tokens"`
Phrases []PhraseSpan `json:"phrases"`
}
// Each router keeps at most one immutable parsed corpus; no private chapter or
// Each router keeps at most one immutable parsed corpus per resource slot; no private chapter or
// lookup data enters the cache. The mutex also coalesces simultaneous cold loads.
type corpus interface {
Lookup(query string) LookupResult
}
type cachedCorpus struct {
sha string
corpus corpus
}
type dictionaryCache struct {
mu sync.Mutex
sha string
engine *WordNet
loaded map[int64]cachedCorpus
}
func (cache *dictionaryCache) load(tx *gorm.DB, r DictionaryResource) (*WordNet, error) {
// parseResource dispatches on the stored format, so WordNet and the 英汉 resource can coexist
// without either parser knowing about the other's layout.
func parseResource(resource DictionaryResource, archive []byte) (corpus, error) {
switch resource.Format {
case ChineseDictionaryFormat:
return ParseChineseDictionary(archive)
case "wordnet-3.0-zip":
return ParseWordNet(archive)
default:
return nil, errors.New("unsupported resource format")
}
}
func (cache *dictionaryCache) load(tx *gorm.DB, r DictionaryResource) (corpus, error) {
cache.mu.Lock()
defer cache.mu.Unlock()
if r.SHA256 != WordNetSHA {
return nil, errors.New("unsupported resource checksum")
}
if cache.sha == r.SHA256 && cache.engine != nil {
return cache.engine, nil
if cached, ok := cache.loaded[r.ID]; ok && cached.sha == r.SHA256 && cached.corpus != nil {
return cached.corpus, nil
}
var stored DictionaryResource
if err := tx.Select("id", "archive").Where("id = ? AND sha256 = ?", r.ID, r.SHA256).First(&stored).Error; err != nil {
return nil, err
}
engine, err := ParseWordNet(stored.Archive)
parsed, err := parseResource(r, stored.Archive)
if err != nil {
return nil, err
}
cache.sha = r.SHA256
cache.engine = engine
return engine, nil
if cache.loaded == nil {
cache.loaded = map[int64]cachedCorpus{}
}
cache.loaded[r.ID] = cachedCorpus{sha: r.SHA256, corpus: parsed}
return parsed, nil
}
func readyOwnedChapter(tx *gorm.DB, owner int, id int64) (Chapter, error) {
@@ -108,7 +133,7 @@ func readyOwnedChapter(tx *gorm.DB, owner int, id int64) (Chapter, error) {
}
func readDictionaryUpload(c *gin.Context) (DictionaryResource, error) {
bad := failure(400, "词典上传无效,请使用指定的 WordNet 3.0 ZIP 和完整资源信息")
bad := failure(400, "词典上传无效,请使用指定的词典 ZIP 和完整资源信息")
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxDictionaryZip+(64<<10))
reader, err := c.Request.MultipartReader()
if err != nil {
@@ -153,11 +178,50 @@ func readDictionaryUpload(c *gin.Context) (DictionaryResource, error) {
}
part.Close()
}
name := strings.TrimSpace(fields["name"])
if name == "" || utf8.RuneCountInString(name) > 120 || strings.IndexFunc(name, unicode.IsControl) >= 0 || fields["language"] != "en" || fields["version"] != "3.0" || fields["format"] != "wordnet-3.0-zip" || fields["source"] != WordNetSource || len(archive) == 0 {
return DictionaryResource{}, bad
return newDictionaryResource(fields, archive)
}
// supportedDictionaries lists every reviewed resource an operator may import. The 英汉 archive is
// not downloaded from the internet at import time: scripts/dict_prepare.py builds it from the
// pinned sources and the operator imports that ZIP.
func supportedDictionaries() []gin.H {
return []gin.H{
{"provider": "wordnet", "name": "Princeton WordNet", "language": "en", "version": WordNetVersion, "format": "wordnet-3.0-zip", "source": WordNetSource, "sha256": WordNetSHA, "downloadable": true, "description": "英语释义"},
{"provider": "ecdict", "name": "简明英汉字典增强版(ECDICT)+ CMUdict 音标", "language": "en", "version": ChineseDictionaryVersion, "format": ChineseDictionaryFormat, "source": ChineseDictionarySource, "sha256": "", "downloadable": false, "description": "中文释义与 IPA 音标,由 scripts/dict_prepare.py 生成"},
}
}
// newDictionaryResource validates the multipart fields against the reviewed specification of the
// declared format, so an unreviewed archive can never occupy a dictionary slot.
func newDictionaryResource(fields map[string]string, archive []byte) (DictionaryResource, error) {
name := strings.TrimSpace(fields["name"])
if name == "" || utf8.RuneCountInString(name) > 120 || strings.IndexFunc(name, unicode.IsControl) >= 0 || fields["language"] != "en" || len(archive) == 0 {
return DictionaryResource{}, failure(400, "词典上传无效,请使用指定的词典 ZIP 和完整资源信息")
}
switch fields["format"] {
case "wordnet-3.0-zip":
if fields["version"] != WordNetVersion || fields["source"] != WordNetSource {
return DictionaryResource{}, failure(400, "词典上传无效,请使用指定的词典 ZIP 和完整资源信息")
}
resource := DictionaryResource{ID: WordNetSlot, Provider: "wordnet", Name: name, Language: "en", Version: WordNetVersion, Source: WordNetSource, Format: "wordnet-3.0-zip", SHA256: WordNetSHA, Enabled: true, Archive: archive}
parsed, err := ParseWordNet(archive)
if err != nil {
return DictionaryResource{}, failure(400, "词典文件校验失败,请选择指定的 WordNet 3.0 ZIP")
}
resource.EntryCount = parsed.EntryCount
return resource, nil
case ChineseDictionaryFormat:
if fields["version"] != ChineseDictionaryVersion || fields["source"] != ChineseDictionarySource {
return DictionaryResource{}, failure(400, "词典上传无效,请使用 dict_prepare.py 生成的英汉词典 ZIP(版本 "+ChineseDictionaryVersion+")")
}
parsed, err := ParseChineseDictionary(archive)
if err != nil {
return DictionaryResource{}, failure(400, "英汉词典文件校验失败,请使用 dict_prepare.py 生成的 ZIP")
}
return DictionaryResource{ID: ChineseSlot, Provider: "ecdict", Name: name, Language: "en", Version: ChineseDictionaryVersion, Source: ChineseDictionarySource, Format: ChineseDictionaryFormat, SHA256: contentSHA(string(archive)), EntryCount: parsed.EntryCount, Enabled: true, Archive: archive}, nil
default:
return DictionaryResource{}, failure(400, "词典上传无效,请使用指定的词典 ZIP 和完整资源信息")
}
return DictionaryResource{ID: 1, Name: name, Language: "en", Version: "3.0", Source: WordNetSource, Format: "wordnet-3.0-zip", SHA256: WordNetSHA, Enabled: true, Archive: archive}, nil
}
func registerDictionaryRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context, *gorm.DB, admin.SysUser) (any, error)) gin.HandlerFunc, now func() time.Time) {
@@ -182,7 +246,7 @@ func registerDictionaryRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.C
}
items = append(items, dictionaryView(row, status))
}
return gin.H{"items": items, "supported": gin.H{"name": "Princeton WordNet", "language": "en", "version": "3.0", "format": "wordnet-3.0-zip", "source": WordNetSource, "sha256": WordNetSHA}}, nil
return gin.H{"items": items, "supported": supportedDictionaries()}, nil
}))
v.POST("/dictionaries/import", protect(true, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
select {
@@ -195,11 +259,6 @@ func registerDictionaryRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.C
if err != nil {
return nil, err
}
engine, err := ParseWordNet(resource.Archive)
if err != nil {
return nil, failure(400, "词典文件校验失败,请选择指定的 WordNet 3.0 ZIP")
}
resource.EntryCount = engine.EntryCount
resource.UpdatedAt = stamp(now())
// INSERT ... ON CONFLICT followed by a locking read serializes even the first
// concurrent import. A validated replacement and its metadata commit together.
@@ -208,12 +267,12 @@ func registerDictionaryRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.C
return nil, created.Error
}
var existing DictionaryResource
if err = tx.Omit("archive").Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = 1").First(&existing).Error; err != nil {
if err = tx.Omit("archive").Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", resource.ID).First(&existing).Error; err != nil {
return nil, err
}
duplicate := created.RowsAffected == 0 && existing.SHA256 == resource.SHA256
if created.RowsAffected == 0 {
if err = tx.Model(&DictionaryResource{}).Where("id = 1").Select("name", "language", "version", "source", "format", "sha256", "entry_count", "enabled", "archive", "updated_at").Updates(&resource).Error; err != nil {
if err = tx.Model(&DictionaryResource{}).Where("id = ?", resource.ID).Select("provider", "name", "language", "version", "source", "format", "sha256", "entry_count", "enabled", "archive", "updated_at").Updates(&resource).Error; err != nil {
return nil, err
}
existing = resource
@@ -226,7 +285,7 @@ func registerDictionaryRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.C
}))
v.PATCH("/dictionaries/:id", protect(true, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id != 1 {
if err != nil || id <= 0 {
return nil, failure(404, "词典不存在")
}
var input struct {
@@ -272,7 +331,21 @@ func registerDictionaryRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.C
if err != nil {
return nil, err
}
return ChapterTokens{chapter.ContentSHA256, Tokenize(chapter.OriginalText)}, nil
tokens := Tokenize(chapter.OriginalText)
language, err := languageOf(tx, u.UserId)
if err != nil {
return nil, err
}
// The learner's own saved words and phrases are part of the chapter view, so the
// reader shows the same status here as everywhere else.
if err = attachTerms(tx, u.UserId, language, tokens); err != nil {
return nil, err
}
phrases, err := phrasesForChapter(tx, u.UserId, language, tokens)
if err != nil {
return nil, err
}
return ChapterTokens{chapter.ContentSHA256, tokens, phrases}, nil
}))
v.POST("/lookup", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
var input struct {
@@ -290,31 +363,85 @@ func registerDictionaryRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.C
if input.Start == nil || input.End == nil {
return nil, failure(400, "请选择完整单词")
}
query := ""
for _, token := range Tokenize(chapter.OriginalText) {
if token.Kind == "word" && token.Start == *input.Start && token.End == *input.End {
query = token.Text
break
}
}
if query == "" || utf8.RuneCountInString(query) > 128 {
return nil, failure(400, "请选择不超过 128 个字符的完整单词")
}
missing := LookupResult{Status: "resource_missing", Query: query, Candidates: []string{}, Entries: []DictionaryEntry{}}
var resource DictionaryResource
err = tx.Omit("archive").Where("id = 1 AND enabled = ?", true).First(&resource).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return missing, nil
}
query, err := wordAtRange(chapter, *input.Start, *input.End)
if err != nil {
return nil, err
}
engine, err := cache.load(tx, resource)
if err != nil {
missing := LookupResult{Status: "resource_missing", Query: query, Candidates: []string{}, Entries: []DictionaryEntry{}}
var resources []DictionaryResource
if err = tx.Omit("archive").Where("language = ? AND enabled = ?", "en", true).Order("id").Find(&resources).Error; err != nil {
return nil, err
}
if len(resources) == 0 {
return missing, nil
}
result := engine.Lookup(query)
result.Resource = &LookupResource{Name: resource.Name, Version: resource.Version}
return result, nil
// The Chinese gloss answers "what does this mean" first; WordNet follows as English detail.
// A disabled or unreadable resource is skipped, so one missing dictionary degrades to the
// other instead of failing the whole lookup.
sort.SliceStable(resources, func(i, j int) bool { return providerRank(resources[i].Provider) < providerRank(resources[j].Provider) })
results := make([]LookupResult, 0, len(resources))
loaded := make([]LookupResource, 0, len(resources))
loadedCorpora := make([]struct {
resource DictionaryResource
corpus corpus
}, 0, len(resources))
var primary *LookupResource
for _, resource := range resources {
engine, loadErr := cache.load(tx, resource)
if loadErr != nil {
continue
}
results = append(results, engine.Lookup(query))
loaded = append(loaded, LookupResource{Name: resource.Name, Version: resource.Version})
loadedCorpora = append(loadedCorpora, struct {
resource DictionaryResource
corpus corpus
}{resource, engine})
if resource.Provider == "wordnet" && primary == nil {
primary = &LookupResource{Name: resource.Name, Version: resource.Version}
}
}
if len(results) == 0 {
return missing, nil
}
merged := mergeLookups(query, results)
merged.Resources = loaded
if primary == nil {
primary = &loaded[0]
}
merged.Resource = primary
// WordNet resolves inflections (dogs -> dog); the Chinese gloss is stored per headword, so
// the resolved form is looked up there too. The gloss is shown, but its phonetic is not
// copied to the inflection because it belongs to the headword.
if merged.MatchedForm != nil && *merged.MatchedForm != merged.Query && !hasProviderEntry(merged.Entries, "ecdict") {
for _, item := range loadedCorpora {
if item.resource.Provider != "ecdict" {
continue
}
lemma := item.corpus.Lookup(*merged.MatchedForm)
if lemma.Status == "exact" && len(lemma.Entries) > 0 {
lemma.Entries[0].Lemma = *merged.MatchedForm
merged.Entries = append(lemma.Entries, merged.Entries...)
}
}
}
return merged, nil
}))
}
func hasProviderEntry(entries []DictionaryEntry, provider string) bool {
for _, entry := range entries {
if entry.Provider == provider {
return true
}
}
return false
}
// providerRank orders providers for display: the Chinese gloss first, then English detail.
func providerRank(provider string) int {
if provider == "ecdict" {
return 0
}
return 1
}
+362
View File
@@ -0,0 +1,362 @@
package lexgo
import (
"errors"
"fmt"
"strings"
"time"
"unicode/utf8"
"github.com/gin-gonic/gin"
admin "go-admin/app/admin/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// Editing and deleting are strictly owner scoped. Nothing here touches personal terms,
// review rows or answers: those belong to the learner, not to a book or a chapter.
type BookUpdateInput struct {
Title string `json:"title"`
Author *string `json:"author"`
}
type ChapterUpdateInput struct {
Title *string `json:"title"`
Author *string `json:"author"`
Text *string `json:"text"`
}
// ChapterSource is the editable text of one owned chapter. It is separate from the reader
// contract, which only exposes text once a chapter is ready, so a failed chapter can be
// corrected and submitted again.
type ChapterSource struct {
ID int64 `json:"id"`
BookID int64 `json:"bookId"`
Ordinal int `json:"ordinal"`
Title string `json:"title"`
Author string `json:"author"`
Text string `json:"text"`
Status string `json:"status"`
ContentSHA256 string `json:"contentSha256"`
CharCount int `json:"charCount"`
}
type ChapterEdit struct {
Chapter ChapterSummary `json:"chapter"`
Job *JobView `json:"job"`
VersionChanged bool `json:"versionChanged"`
}
type DeletionResult struct {
BookID int64 `json:"bookId,omitempty"`
ChapterID int64 `json:"chapterId,omitempty"`
Chapters int `json:"chapters,omitempty"`
Remaining int `json:"remaining"`
}
// editTitle validates a title with the same rules a paste uses, so a renamed book or chapter
// stays within the limits the list and reader already rely on.
func editTitle(raw string) (string, error) {
title := strings.TrimSpace(raw)
if title == "" {
return "", failure(400, "请填写标题")
}
if utf8.RuneCountInString(title) > maxTitleRunes {
return "", failure(400, "标题最多 120 个字符")
}
return title, nil
}
// editAuthor validates the optional author of a chapter. An empty author clears the field, which is
// why it is trimmed instead of required.
func editAuthor(raw string) (string, error) {
author := strings.TrimSpace(raw)
if utf8.RuneCountInString(author) > maxTitleRunes {
return "", failure(400, "作者最多 120 个字符")
}
return author, nil
}
// stageEditJob returns the job that owns one content version of a chapter.
//
// One chapter and one content digest describe one version, so the row that already describes it is
// reused instead of adding another one. That matters because the derived request key of a version
// that has been edited before already exists: creating it again hit the unique key and failed the
// whole edit with a generic 500, so a learner could never return to a previous text. Reusing is also
// correct for the version a chapter was pasted with, whose row carries the paste request key.
func stageEditJob(tx *gorm.DB, owner int, chapter Chapter, sha string, ts time.Time) (IngestJob, error) {
var existing IngestJob
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("owner_id = ? AND chapter_id = ? AND content_sha256 = ?", owner, chapter.ID, sha).
Order("id ASC").First(&existing).Error
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
key := contentSHA(fmt.Sprintf("edit:%d:%s", chapter.ID, sha))
created := IngestJob{OwnerID: owner, BookID: chapter.BookID, ChapterID: chapter.ID,
RequestKey: key, ContentSHA256: sha, Status: statusPending, CreatedAt: ts, UpdatedAt: ts}
if err := tx.Create(&created).Error; err != nil {
return IngestJob{}, err
}
return created, nil
case err != nil:
return IngestJob{}, err
}
// Publishing the same version again — edited back, or retried after it failed — reuses the row and
// restarts its attempt budget, so a person asking again is not blocked by the earlier failure.
if err = tx.Model(&IngestJob{}).Where("id = ?", existing.ID).
Updates(map[string]any{"status": statusPending, "attempts": 0, "error_reason": "",
"updated_at": ts, "finished_at": nil}).Error; err != nil {
return IngestJob{}, err
}
existing.Status = statusPending
existing.Attempts = 0
existing.ErrorReason = ""
existing.FinishedAt = nil
existing.UpdatedAt = ts
return existing, nil
}
// lockOwnedChapter returns the caller's chapter or reports it as missing, so another
// account's chapter id is never confirmed to exist.
func lockOwnedChapter(tx *gorm.DB, owner int, chapterID int64, chapter *Chapter) error {
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND owner_id = ?", chapterID, owner).First(chapter).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return failure(404, "章节不存在")
}
return err
}
func RenameBook(tx *gorm.DB, owner int, bookID int64, input BookUpdateInput, now time.Time) (BookRef, error) {
title, err := editTitle(input.Title)
if err != nil {
return BookRef{}, err
}
var book Book
if err = lockOwnedBook(tx, owner, bookID, &book); err != nil {
return BookRef{}, err
}
book.Title = title
ts := stamp(now)
updates := map[string]any{"title": title, "updated_at": ts}
// The author is optional; omitting it leaves whatever the book already had.
if input.Author != nil {
author, err := editAuthor(*input.Author)
if err != nil {
return BookRef{}, err
}
book.Author = author
updates["author"] = author
}
book.UpdatedAt = ts
if err = tx.Model(&Book{}).Where("id = ? AND owner_id = ?", book.ID, owner).
Updates(updates).Error; err != nil {
return BookRef{}, err
}
return bookRef(book), nil
}
func ChapterEditSource(tx *gorm.DB, owner int, chapterID int64) (ChapterSource, error) {
var chapter Chapter
if err := tx.Where("id = ? AND owner_id = ?", chapterID, owner).First(&chapter).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ChapterSource{}, failure(404, "章节不存在")
}
return ChapterSource{}, err
}
return ChapterSource{
ID: chapter.ID, BookID: chapter.BookID, Ordinal: chapter.Ordinal, Title: chapter.Title, Author: chapter.Author,
Text: chapter.OriginalText, Status: chapter.Status, ContentSHA256: chapter.ContentSHA256,
CharCount: chapter.CharCount,
}, nil
}
// UpdateChapter renames a chapter and, when the text really changed, stores it as a new
// version and queues a job for it. Only a changed version re-processes: a repeated save of
// the same text is idempotent, and a title-only edit never touches the processing state.
func UpdateChapter(tx *gorm.DB, owner int, chapterID int64, input ChapterUpdateInput, now time.Time) (ChapterEdit, error) {
if input.Title == nil && input.Text == nil && input.Author == nil {
return ChapterEdit{}, failure(400, "请选择要修改的内容")
}
var chapter Chapter
if err := lockOwnedChapter(tx, owner, chapterID, &chapter); err != nil {
return ChapterEdit{}, err
}
ts := stamp(now)
updates := map[string]any{"updated_at": ts}
if input.Title != nil {
title, err := editTitle(*input.Title)
if err != nil {
return ChapterEdit{}, err
}
chapter.Title = title
updates["title"] = title
}
if input.Author != nil {
author, err := editAuthor(*input.Author)
if err != nil {
return ChapterEdit{}, err
}
chapter.Author = author
updates["author"] = author
}
var job *IngestJob
if input.Text != nil {
text := *input.Text
if _, sha, count, err := validatePaste(chapter.Title, text); err != nil {
return ChapterEdit{}, err
} else if sha != chapter.ContentSHA256 {
// A new version replaces the text and owns the chapter's state from here on.
chapter.OriginalText, chapter.ContentSHA256, chapter.CharCount = text, sha, count
chapter.Status, chapter.ErrorReason = statusPending, ""
updates["original_text"] = text
updates["content_sha256"] = sha
updates["char_count"] = count
updates["status"] = statusPending
updates["error_reason"] = ""
staged, err := stageEditJob(tx, owner, chapter, sha, ts)
if err != nil {
return ChapterEdit{}, err
}
job = &staged
}
}
if err := tx.Model(&Chapter{}).Where("id = ? AND owner_id = ?", chapter.ID, owner).Updates(updates).Error; err != nil {
return ChapterEdit{}, err
}
result := ChapterEdit{VersionChanged: job != nil}
if job != nil {
view := jobView(*job)
result.Job = &view
}
jobID := int64(0)
if job != nil {
jobID = job.ID
}
summary := chapterSummaryWithJob(chapter, &jobID)
// An edit can end the match between the stored completion and the chapter content, so the
// response resolves the read state instead of leaving a stale mark for the client to show.
if marks, err := readAtByChapter(tx, owner, []int64{chapter.ID}); err != nil {
return ChapterEdit{}, err
} else if at, ok := marks[chapter.ID]; ok {
summary.ReadAt = &at
}
result.Chapter = summary
return result, nil
}
// DeleteBook removes the caller's book with its chapters and their jobs in one transaction.
// Personal terms, review schedules and answers are not touched: they belong to the learner.
func DeleteBook(tx *gorm.DB, owner int, bookID int64) (DeletionResult, error) {
var book Book
if err := lockOwnedBook(tx, owner, bookID, &book); err != nil {
return DeletionResult{}, err
}
var chapters int64
if err := tx.Model(&Chapter{}).Where("book_id = ? AND owner_id = ?", book.ID, owner).Count(&chapters).Error; err != nil {
return DeletionResult{}, err
}
// Chapters and their jobs go with the book through the foreign keys.
if err := tx.Where("id = ? AND owner_id = ?", book.ID, owner).Delete(&Book{}).Error; err != nil {
return DeletionResult{}, err
}
return DeletionResult{BookID: book.ID, Chapters: int(chapters)}, nil
}
// DeleteChapter removes one owned chapter with its jobs and closes the gap in the chapter
// order, so "剩余 N 章" and the reader navigation stay contiguous.
func DeleteChapter(tx *gorm.DB, owner int, chapterID int64) (DeletionResult, error) {
var chapter Chapter
if err := lockOwnedChapter(tx, owner, chapterID, &chapter); err != nil {
return DeletionResult{}, err
}
// Lock the book too: two concurrent deletions in one book must not renumber each other.
var book Book
if err := lockOwnedBook(tx, owner, chapter.BookID, &book); err != nil {
return DeletionResult{}, err
}
if err := tx.Where("id = ? AND owner_id = ?", chapter.ID, owner).Delete(&Chapter{}).Error; err != nil {
return DeletionResult{}, err
}
// Ordering ascending decrements each row into a slot the previous row just freed, which
// keeps the unique (book_id, ordinal) key satisfied throughout.
if err := tx.Exec("UPDATE lexgo_chapters SET ordinal = ordinal - 1 WHERE book_id = ? AND owner_id = ? AND ordinal > ? ORDER BY ordinal ASC",
chapter.BookID, owner, chapter.Ordinal).Error; err != nil {
return DeletionResult{}, err
}
var remaining int64
if err := tx.Model(&Chapter{}).Where("book_id = ? AND owner_id = ?", chapter.BookID, owner).Count(&remaining).Error; err != nil {
return DeletionResult{}, err
}
return DeletionResult{ChapterID: chapter.ID, BookID: chapter.BookID, Remaining: int(remaining)}, nil
}
func registerEditRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context, *gorm.DB, admin.SysUser) (any, error)) gin.HandlerFunc, now func() time.Time) {
v.PATCH("/books/:id", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
id, err := pathID(c, "书籍不存在")
if err != nil {
return nil, err
}
var input BookUpdateInput
if err = decode(c, &input); err != nil {
return nil, err
}
book, err := RenameBook(tx, u.UserId, id, input, now())
if err != nil {
return nil, err
}
return gin.H{"book": book}, nil
}))
v.DELETE("/books/:id", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
id, err := pathID(c, "书籍不存在")
if err != nil {
return nil, err
}
result, err := DeleteBook(tx, u.UserId, id)
if err != nil {
return nil, err
}
return gin.H{"deleted": result}, nil
}))
v.GET("/chapters/:id/source", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
if c.Request.URL.RawQuery != "" {
return nil, failure(400, "正文接口不接受查询参数")
}
id, err := pathID(c, "章节不存在")
if err != nil {
return nil, err
}
source, err := ChapterEditSource(tx, u.UserId, id)
if err != nil {
return nil, err
}
return gin.H{"source": source}, nil
}))
v.PATCH("/chapters/:id", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
id, err := pathID(c, "章节不存在")
if err != nil {
return nil, err
}
var input ChapterUpdateInput
if err = decode(c, &input); err != nil {
return nil, err
}
edited, err := UpdateChapter(tx, u.UserId, id, input, now())
if err != nil {
return nil, err
}
return edited, nil
}))
v.DELETE("/chapters/:id", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
id, err := pathID(c, "章节不存在")
if err != nil {
return nil, err
}
result, err := DeleteChapter(tx, u.UserId, id)
if err != nil {
return nil, err
}
return gin.H{"deleted": result}, nil
}))
}
+842
View File
@@ -0,0 +1,842 @@
package lexgo
import (
"encoding/json"
"fmt"
"strings"
"sync"
"testing"
"time"
"github.com/gin-gonic/gin"
)
const editFixtureText = "Mira opened the workshop.\nThe sign read “A small step…”\n"
func TestEditTitleRules(t *testing.T) {
if title, err := editTitle(" A small step "); err != nil || title != "A small step" {
t.Fatalf("trimmed title: %q %v", title, err)
}
if _, err := editTitle(" "); err == nil {
t.Fatal("an empty title must be rejected")
}
if _, err := editTitle(strings.Repeat("a", maxTitleRunes+1)); err == nil {
t.Fatal("a title over the limit must be rejected")
}
if _, err := editTitle(strings.Repeat("a", maxTitleRunes)); err != nil {
t.Fatalf("the exact title limit must be accepted: %v", err)
}
}
func patchResource(t *testing.T, r *gin.Engine, token, path string, body any) (int, string, json.RawMessage) {
t.Helper()
return callRaw(t, r, "PATCH", path, token, body)
}
func existingTitle(t *testing.T, r *gin.Engine, token string, bookID int64) string {
t.Helper()
code, detail := bookDetail(t, r, token, bookID)
if code != 200 {
t.Fatalf("book detail status %d", code)
}
return detail.Book.Title
}
// TestMySQLRenameBookAndChapter covers renaming only: the text and the processing state stay
// exactly as they were.
func TestMySQLRenameBookAndChapter(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
other := newLearner(t, r, owner.Token)
drainIngest(t, db)
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "rename-fixture-0001", "title": "Before Rename", "text": editFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste status %d", code)
}
drainIngest(t, db)
before := chapterRow(t, db, pasted.Chapter.ID)
code, msg, data := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d", pasted.Book.ID), map[string]string{"title": " After Rename "})
if code != 200 {
t.Fatalf("rename book status %d (%s)", code, msg)
}
var renamed struct {
Book struct {
ID int64
Title string
}
}
json.Unmarshal(data, &renamed)
if renamed.Book.Title != "After Rename" || existingTitle(t, r, learner.Token, pasted.Book.ID) != "After Rename" {
t.Fatalf("renamed book %+v", renamed.Book)
}
code, msg, data = patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), map[string]string{"title": "Chapter Two"})
if code != 200 {
t.Fatalf("rename chapter status %d (%s)", code, msg)
}
var edited ChapterEdit
json.Unmarshal(data, &edited)
if edited.Chapter.Title != "Chapter Two" || edited.VersionChanged || edited.Job != nil {
t.Fatalf("rename must not re-process: %+v", edited)
}
after := chapterRow(t, db, pasted.Chapter.ID)
if after.Title != "Chapter Two" || after.Status != statusReady || after.ContentSHA256 != before.ContentSHA256 || after.OriginalText != before.OriginalText {
t.Fatalf("rename changed the content state: %+v", after)
}
var jobs int64
db.Model(&IngestJob{}).Where("chapter_id = ?", pasted.Chapter.ID).Count(&jobs)
if jobs != 1 {
t.Fatalf("a rename created %d jobs", jobs)
}
// Validation and ownership.
for _, body := range []map[string]string{{"title": " "}, {"title": strings.Repeat("a", maxTitleRunes+1)}} {
if code, _, _ := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d", pasted.Book.ID), body); code != 400 {
t.Fatalf("invalid rename %v accepted", body)
}
}
if code, _, _ := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d", pasted.Book.ID), map[string]any{"title": "x", "ownerId": 9}); code != 400 {
t.Fatal("an unknown field must be rejected")
}
if code, _, _ := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d", pasted.Book.ID), map[string]any{"name": "x"}); code != 400 {
t.Fatal("a missing title must be rejected")
}
if code, _, _ := patchResource(t, r, other.Token, fmt.Sprintf("/api/v1/books/%d", pasted.Book.ID), map[string]string{"title": "Stolen"}); code != 404 {
t.Fatal("another account must not rename this book")
}
if code, _, _ := patchResource(t, r, other.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), map[string]string{"title": "Stolen"}); code != 404 {
t.Fatal("another account must not rename this chapter")
}
if code, _, _ := patchResource(t, r, "", fmt.Sprintf("/api/v1/books/%d", pasted.Book.ID), map[string]string{"title": "Anonymous"}); code != 401 {
t.Fatal("renaming requires a session")
}
if title := existingTitle(t, r, learner.Token, pasted.Book.ID); title != "After Rename" {
t.Fatalf("a rejected rename changed the book: %q", title)
}
}
// TestMySQLChapterEditVersioning is the core of the ticket: an edit creates a new version, and
// the older run must not fail or publish over it.
func TestMySQLChapterEditVersioning(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
drainIngest(t, db)
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "edit-fixture-0001", "title": "Editable", "text": editFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste status %d", code)
}
// The first run is claimed but not finished, so the edit lands while it is in flight.
clock := time.Now().UTC().Truncate(time.Millisecond)
job, claimed, err := ClaimNextIngestJob(db, clock)
if err != nil || !claimed || job.ChapterID != pasted.Chapter.ID {
t.Fatalf("claim (claimed=%v): %v", claimed, err)
}
newText := "Mira reopened the workshop.\r\n\r\nA newer version of the text.\n"
code, msg, data := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), map[string]string{"text": newText})
if code != 200 {
t.Fatalf("edit status %d (%s)", code, msg)
}
var edited ChapterEdit
json.Unmarshal(data, &edited)
if !edited.VersionChanged || edited.Job == nil || edited.Chapter.Status != statusPending {
t.Fatalf("edit must queue a new version: %+v", edited)
}
row := chapterRow(t, db, pasted.Chapter.ID)
if row.OriginalText != newText || row.ContentSHA256 != contentSHA(newText) || row.Status != statusPending || row.CharCount != len([]rune(newText)) {
t.Fatalf("stored version: %+v", row)
}
if row.ID != pasted.Chapter.ID {
t.Fatal("an edit must keep the chapter id, so the reading entry stays the same")
}
// The in-flight run belongs to the previous version: it must not touch the chapter.
if err := FinishIngestJob(t.Context(), db, job, clock.Add(time.Second)); err != nil {
t.Fatalf("finishing the older run must not fail: %v", err)
}
var stale IngestJob
if err := db.First(&stale, job.ID).Error; err != nil {
t.Fatal(err)
}
if stale.Status != statusFailed || stale.ErrorReason != reasonSuperseded {
t.Fatalf("the older run must be marked superseded: %+v", stale)
}
if row = chapterRow(t, db, pasted.Chapter.ID); row.Status != statusPending || row.ErrorReason != "" || row.OriginalText != newText {
t.Fatalf("the older run changed the newer version: %+v", row)
}
// Retrying the superseded job is refused instead of reprocessing old text.
code, _, _ = callRaw(t, r, "POST", fmt.Sprintf("/api/v1/jobs/%d/retry", stale.ID), learner.Token, nil)
if code != 409 {
t.Fatalf("retrying a superseded job: %d", code)
}
// The new version publishes normally and the reader shows the new text.
drainIngest(t, db)
code, reader := readChapter(t, r, learner.Token, pasted.Chapter.ID)
if code != 200 || reader.Chapter.Status != statusReady || reader.Chapter.OriginalText != newText {
t.Fatalf("new version: status %d, %+v", code, reader.Chapter)
}
// Saving the same text again is not a new version.
var jobsBefore int64
db.Model(&IngestJob{}).Where("chapter_id = ?", pasted.Chapter.ID).Count(&jobsBefore)
code, _, data = patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), map[string]string{"text": newText})
json.Unmarshal(data, &edited)
if code != 200 || edited.VersionChanged || edited.Job != nil {
t.Fatalf("an unchanged text must not create a version: %+v", edited)
}
var jobsAfter int64
db.Model(&IngestJob{}).Where("chapter_id = ?", pasted.Chapter.ID).Count(&jobsAfter)
if jobsAfter != jobsBefore {
t.Fatalf("an unchanged text created a job: %d -> %d", jobsBefore, jobsAfter)
}
if row = chapterRow(t, db, pasted.Chapter.ID); row.Status != statusReady {
t.Fatalf("an unchanged save changed the status: %+v", row)
}
// Text rules match a paste, and an empty body is refused.
for _, body := range []map[string]string{{"text": " \n\t "}, {"text": strings.Repeat("a", maxChapterRunes+1)}} {
if code, _, _ := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), body); code != 400 {
t.Fatalf("invalid text %v accepted", body)
}
}
if code, _, _ := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), map[string]string{}); code != 400 {
t.Fatal("an empty edit must be rejected")
}
if code, _, _ := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), map[string]any{"text": "Body.\n", "ownerId": 9}); code != 400 {
t.Fatal("an unknown field must be rejected")
}
}
func TestMySQLChapterSourceAnyStatus(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
other := newLearner(t, r, owner.Token)
drainIngest(t, db)
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "source-fixture-0001", "title": "Source", "text": editFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste status %d", code)
}
readSource := func(token string, chapterID int64) (int, ChapterSource) {
code, _, data := callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d/source", chapterID), token, nil)
var payload struct {
Source ChapterSource
}
if len(data) > 0 {
json.Unmarshal(data, &payload)
}
return code, payload.Source
}
// A pending chapter has no reading text, but its edit source is available to its owner.
if code, source := readSource(learner.Token, pasted.Chapter.ID); code != 200 || source.Text != editFixtureText || source.Status != statusPending || source.ContentSHA256 != contentSHA(editFixtureText) {
t.Fatalf("pending source: %d %+v", code, source)
}
drainIngest(t, db)
if code, source := readSource(learner.Token, pasted.Chapter.ID); code != 200 || source.Status != statusReady || source.CharCount != len([]rune(editFixtureText)) {
t.Fatalf("ready source: %d %+v", code, source)
}
if code, _ := readSource(other.Token, pasted.Chapter.ID); code != 404 {
t.Fatal("another account must not read this source")
}
if code, _, _ := callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d/source?id=1", pasted.Chapter.ID), learner.Token, nil); code != 400 {
t.Fatal("the source endpoint must reject query parameters")
}
if code, _ := readSource(learner.Token, 999999); code != 404 {
t.Fatal("an unknown chapter must be 404")
}
}
// TestMySQLDeleteChapterRenumbers closes the gap in the order and keeps personal records.
func TestMySQLDeleteChapterRenumbers(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
other := newLearner(t, r, owner.Token)
drainIngest(t, db)
code, first := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "delete-fixture-0001", "title": "Three Chapters", "text": editFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("first paste %d", code)
}
second := pasteInto(t, r, learner.Token, first.Book.ID, "delete-fixture-0002", "Second", "Second chapter body.\n")
third := pasteInto(t, r, learner.Token, first.Book.ID, "delete-fixture-0003", "Third", "Third chapter body.\n")
drainIngest(t, db)
// One personal word in the chapter that will be deleted, plus one in a surviving chapter.
saved := saveWord(t, r, learner.Token, second.Chapter.ID, 0, 6, termStatusNew, nil)
survivor := saveWord(t, r, learner.Token, first.Chapter.ID, 0, 4, termStatusNew, nil)
before := termReviewRow(t, db, saved.Term.ID)
code, msg, data := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d", second.Chapter.ID), learner.Token, nil)
if code != 200 {
t.Fatalf("delete chapter status %d (%s)", code, msg)
}
var deleted struct {
Deleted DeletionResult
}
json.Unmarshal(data, &deleted)
if deleted.Deleted.ChapterID != second.Chapter.ID || deleted.Deleted.Remaining != 2 || deleted.Deleted.BookID != first.Book.ID {
t.Fatalf("delete result: %+v", deleted.Deleted)
}
// The remaining chapters are contiguous and in the original order.
code, detail := bookDetail(t, r, learner.Token, first.Book.ID)
if code != 200 || len(detail.Chapters) != 2 {
t.Fatalf("book detail after delete: %d %+v", code, detail.Chapters)
}
if detail.Chapters[0].ID != first.Chapter.ID || detail.Chapters[0].Ordinal != 1 || detail.Chapters[1].ID != third.Chapter.ID || detail.Chapters[1].Ordinal != 2 {
t.Fatalf("renumbered chapters: %+v", detail.Chapters)
}
// Navigation follows the new order, and the deleted chapter is gone with its job.
code, reader := readChapter(t, r, learner.Token, first.Chapter.ID)
if code != 200 || reader.Navigation.NextChapterID == nil || *reader.Navigation.NextChapterID != third.Chapter.ID {
t.Fatalf("navigation after delete: %d %+v", code, reader.Navigation)
}
if code, _ := readChapter(t, r, learner.Token, second.Chapter.ID); code != 404 {
t.Fatal("a deleted chapter must be gone")
}
var jobs int64
db.Model(&IngestJob{}).Where("chapter_id = ?", second.Chapter.ID).Count(&jobs)
if jobs != 0 {
t.Fatalf("the deleted chapter kept %d jobs", jobs)
}
// Personal records survive the deletion, including the schedule.
var terms int64
db.Model(&Term{}).Where("id IN ?", []int64{saved.Term.ID, survivor.Term.ID}).Count(&terms)
var reviews int64
db.Model(&TermReview{}).Where("term_id = ?", saved.Term.ID).Count(&reviews)
if terms != 2 || reviews != 1 {
t.Fatalf("deleting a chapter changed personal records: terms=%d reviews=%d", terms, reviews)
}
if after := termReviewRow(t, db, saved.Term.ID); !after.DueAt.Equal(before.DueAt) || after.ReviewCount != before.ReviewCount {
t.Fatalf("the review schedule changed: %+v -> %+v", before, after)
}
// Repeated and foreign deletions.
if code, _, _ := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d", second.Chapter.ID), learner.Token, nil); code != 404 {
t.Fatal("a repeated delete must be 404")
}
if code, _, _ := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d", first.Chapter.ID), other.Token, nil); code != 404 {
t.Fatal("another account must not delete this chapter")
}
if code, _, _ := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d", first.Chapter.ID), "", nil); code != 401 {
t.Fatal("deleting requires a session")
}
if code, detail := bookDetail(t, r, learner.Token, first.Book.ID); code != 200 || len(detail.Chapters) != 2 {
t.Fatalf("a rejected delete changed the book: %+v", detail.Chapters)
}
}
// pasteInto appends one chapter to an owned book.
func pasteInto(t *testing.T, r *gin.Engine, token string, bookID int64, requestID, title, text string) pasteResponse {
t.Helper()
code, pasted := pasteChapter(t, r, token, bookID, map[string]string{"requestId": requestID, "title": title, "text": text})
if code != 201 {
t.Fatalf("append %s status %d", title, code)
}
return pasted
}
func TestMySQLDeleteBookKeepsPersonalRecords(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
other := newLearner(t, r, owner.Token)
drainIngest(t, db)
code, first := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "delete-book-0001", "title": "Doomed Book", "text": editFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
pasteInto(t, r, learner.Token, first.Book.ID, "delete-book-0002", "Second", "Second chapter body.\n")
drainIngest(t, db)
saved := saveWord(t, r, learner.Token, first.Chapter.ID, 0, 4, termStatusNew, nil)
survivorBook := pasteInto(t, r, learner.Token, first.Book.ID, "delete-book-0003", "Third", "Third body.\n")
otherBook := func() int64 {
code, kept := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "delete-book-keep", "title": "Kept Book", "text": "Kept body.\n", "language": "en"})
if code != 201 {
t.Fatalf("kept book %d", code)
}
return kept.Book.ID
}()
drainIngest(t, db)
code, msg, data := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/books/%d", first.Book.ID), learner.Token, nil)
if code != 200 {
t.Fatalf("delete book status %d (%s)", code, msg)
}
var deleted struct {
Deleted DeletionResult
}
json.Unmarshal(data, &deleted)
if deleted.Deleted.BookID != first.Book.ID || deleted.Deleted.Chapters != 3 {
t.Fatalf("delete result: %+v", deleted.Deleted)
}
var books, chapters, jobs int64
db.Model(&Book{}).Where("id = ?", first.Book.ID).Count(&books)
db.Model(&Chapter{}).Where("book_id = ?", first.Book.ID).Count(&chapters)
db.Model(&IngestJob{}).Where("book_id = ?", first.Book.ID).Count(&jobs)
if books != 0 || chapters != 0 || jobs != 0 {
t.Fatalf("the deleted book left rows: books=%d chapters=%d jobs=%d", books, chapters, jobs)
}
// The other book of the same account is untouched.
if code, detail := bookDetail(t, r, learner.Token, otherBook); code != 200 || len(detail.Chapters) != 1 {
t.Fatalf("the other book changed: %d %+v", code, detail.Chapters)
}
// Personal records and their schedule survive.
var terms, reviews int64
db.Model(&Term{}).Where("owner_id = ?", learner.ID).Count(&terms)
db.Model(&TermReview{}).Where("term_id = ?", saved.Term.ID).Count(&reviews)
if terms != 1 || reviews != 1 {
t.Fatalf("deleting a book changed personal records: terms=%d reviews=%d", terms, reviews)
}
code, queue := reviewQueue(t, r, learner.Token)
if code != 200 || len(queue.Items) != 1 || queue.Items[0].ID != saved.Term.ID {
t.Fatalf("the saved word left the review queue: %d %+v", code, queue.Items)
}
// The deleted chapter and book are no longer readable, and repeats are 404.
if code, _ := readChapter(t, r, learner.Token, survivorBook.Chapter.ID); code != 404 {
t.Fatal("a chapter of the deleted book must be gone")
}
if code, _, _ := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/books/%d", first.Book.ID), learner.Token, nil); code != 404 {
t.Fatal("a repeated delete must be 404")
}
if code, _, _ := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/books/%d", otherBook), other.Token, nil); code != 404 {
t.Fatal("another account must not delete this book")
}
_, list := bookList(t, r, other.Token)
if len(list.Items) != 0 {
t.Fatalf("the other account must not see this library: %+v", list.Items)
}
}
// TestMySQLDeleteDuringProcessing proves a deleted chapter cannot come back through a run
// that was already in flight.
func TestMySQLDeleteDuringProcessing(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
drainIngest(t, db)
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "delete-processing-01", "title": "In Flight", "text": editFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
clock := time.Now().UTC().Truncate(time.Millisecond)
job, claimed, err := ClaimNextIngestJob(db, clock)
if err != nil || !claimed {
t.Fatalf("claim: %v", err)
}
if code, msg, _ := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), learner.Token, nil); code != 200 {
t.Fatalf("deleting a processing chapter: %d (%s)", code, msg)
}
// The in-flight run finds nothing to publish and reports no error.
if err := FinishIngestJob(t.Context(), db, job, clock.Add(time.Second)); err != nil {
t.Fatalf("finishing a run for a deleted chapter: %v", err)
}
var chapters, jobs int64
db.Model(&Chapter{}).Where("id = ?", pasted.Chapter.ID).Count(&chapters)
db.Model(&IngestJob{}).Where("id = ?", job.ID).Count(&jobs)
if chapters != 0 || jobs != 0 {
t.Fatalf("a deleted chapter came back: chapters=%d jobs=%d", chapters, jobs)
}
// The book stays, with no chapters and a clear empty state for the client.
code, detail := bookDetail(t, r, learner.Token, pasted.Book.ID)
if code != 200 || len(detail.Chapters) != 0 {
t.Fatalf("book after deleting its only chapter: %d %+v", code, detail.Chapters)
}
}
// TestMySQLConcurrentChapterDelete checks two deletions of one chapter in one book.
func TestMySQLConcurrentChapterDelete(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
drainIngest(t, db)
code, first := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "race-delete-0001", "title": "Race", "text": editFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
pasteInto(t, r, learner.Token, first.Book.ID, "race-delete-0002", "Second", "Second body.\n")
drainIngest(t, db)
var wg sync.WaitGroup
codes := make(chan int, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c, _, _ := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d", first.Chapter.ID), learner.Token, nil)
codes <- c
}()
}
wg.Wait()
close(codes)
ok, missing := 0, 0
for c := range codes {
switch c {
case 200:
ok++
case 404:
missing++
}
}
if ok != 1 || missing != 1 {
t.Fatalf("concurrent deletes: ok=%d missing=%d", ok, missing)
}
// The single surviving chapter keeps ordinal 1.
var remaining []Chapter
db.Where("book_id = ?", first.Book.ID).Order("ordinal ASC").Find(&remaining)
if len(remaining) != 1 || remaining[0].Ordinal != 1 {
t.Fatalf("ordinals after concurrent delete: %+v", remaining)
}
}
// TestMySQLRecoverySkipsSupersededJobs proves the recovery sweep leaves a newer version alone.
func TestMySQLRecoverySkipsSupersededJobs(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
drainIngest(t, db)
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "recovery-fix-0001", "title": "Recovery", "text": editFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
clock := time.Now().UTC().Truncate(time.Millisecond)
job, claimed, err := ClaimNextIngestJob(db, clock)
if err != nil || !claimed {
t.Fatalf("claim: %v", err)
}
newText := "A replacement body.\n"
if code, msg, _ := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), map[string]string{"text": newText}); code != 200 {
t.Fatalf("edit status %d (%s)", code, msg)
}
// The old job looks stale to the sweep; it must be abandoned, not requeued.
requeued, err := RequeueStaleIngestJobs(db, clock.Add(ingestStaleAfter+time.Second))
if err != nil {
t.Fatal(err)
}
if requeued != 0 {
t.Fatalf("the sweep requeued %d superseded job(s)", requeued)
}
var stale IngestJob
if err := db.First(&stale, job.ID).Error; err != nil {
t.Fatal(err)
}
if stale.Status != statusFailed || stale.ErrorReason != reasonSuperseded {
t.Fatalf("the sweep left the old job %+v", stale)
}
if row := chapterRow(t, db, pasted.Chapter.ID); row.Status != statusPending || row.OriginalText != newText {
t.Fatalf("the sweep changed the new version: %+v", row)
}
drainIngest(t, db)
if row := chapterRow(t, db, pasted.Chapter.ID); row.Status != statusReady || row.OriginalText != newText {
t.Fatalf("the new version did not publish: %+v", row)
}
}
// TestMySQLChapterAuthorRoundTrip covers the optional author: it is saved with the chapter, shown by
// the chapter list and the reader, trimmed, capped, and clearable.
func TestMySQLChapterAuthorRoundTrip(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "chapter-author-0001", "title": "Fictional author", "text": "Curiosity opens the first door.\n", "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
drainIngest(t, db)
chapterID := pasted.Chapter.ID
// A new chapter has no author, and the source reports it as empty for the editor.
code, _, data := callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d/source", chapterID), learner.Token, nil)
if code != 200 {
t.Fatalf("source %d", code)
}
var source struct {
Source ChapterSource
}
if err := json.Unmarshal(data, &source); err != nil {
t.Fatal(err)
}
if source.Source.Author != "" {
t.Fatalf("a new chapter must have no author, got %q", source.Source.Author)
}
// An author-only edit is a real edit, and it keeps the author around the trim.
code, _, data = callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/chapters/%d", chapterID), learner.Token,
map[string]string{"author": " 虚构作者 "})
if code != 200 {
t.Fatalf("author edit %d", code)
}
var edited struct {
Chapter ChapterSummary
}
if err := json.Unmarshal(data, &edited); err != nil {
t.Fatal(err)
}
if edited.Chapter.Author != "虚构作者" {
t.Fatalf("the author must be trimmed, got %q", edited.Chapter.Author)
}
// A text-free edit must not touch the content version.
if edited.Chapter.Status != statusReady {
t.Fatalf("an author edit must not reprocess the chapter: %+v", edited.Chapter)
}
// The chapter list and the reader both carry it.
_, chapters := bookDetailOf(t, r, learner.Token, pasted.Chapter.BookID)
if len(chapters) != 1 || chapters[0].Author != "虚构作者" {
t.Fatalf("the chapter list must carry the author: %+v", chapters)
}
if reader := readerChapter(t, r, learner.Token, chapterID); reader.Author != "虚构作者" {
t.Fatalf("the reader must carry the author: %+v", reader)
}
// The rules: at most 120 characters, and an empty author clears the field.
if code, _, _ := callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/chapters/%d", chapterID), learner.Token,
map[string]string{"author": strings.Repeat("a", 121)}); code != 400 {
t.Fatal("an over-long author must be rejected")
}
if code, _, _ := callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/chapters/%d", chapterID), learner.Token,
map[string]string{"author": strings.Repeat("a", 120)}); code != 200 {
t.Fatal("a 120 character author must be accepted")
}
if code, _, _ := callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/chapters/%d", chapterID), learner.Token,
map[string]string{"author": " "}); code != 200 {
t.Fatal("clearing the author must be allowed")
}
_, chapters = bookDetailOf(t, r, learner.Token, pasted.Chapter.BookID)
if chapters[0].Author != "" {
t.Fatalf("a blank author must clear the field, got %q", chapters[0].Author)
}
// Ownership and the other rules of this endpoint still apply.
other := newLearner(t, r, owner.Token)
if code, _, _ := callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/chapters/%d", chapterID), other.Token, map[string]string{"author": "stolen"}); code != 404 {
t.Fatal("another account must not set the author")
}
if code, _, _ := callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/chapters/%d", chapterID), learner.Token, map[string]any{}); code != 400 {
t.Fatal("an empty edit must still be rejected")
}
}
// TestMySQLBookAuthorRoundTrip covers the book author: it is saved with the title, list and detail
// carry it, it is trimmed and capped, and an empty author clears it.
func TestMySQLBookAuthorRoundTrip(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "book-author-0001", "title": "Fictional book author", "text": "Curiosity opens the first door." + string(rune(10)), "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
drainIngest(t, db)
bookID := pasted.Chapter.BookID
// A new book has no author.
book, _ := bookDetailOf(t, r, learner.Token, bookID)
if book.Author != "" {
t.Fatalf("a new book must have no author, got %q", book.Author)
}
// The title and the author are saved together, and the author is trimmed.
code, msg, data := callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/books/%d", bookID), learner.Token,
map[string]any{"title": "Fictional book author", "author": " 虚构书籍作者 "})
if code != 200 {
t.Fatalf("book edit: %d %s", code, msg)
}
var edited struct {
Book BookRef
}
if err := json.Unmarshal(data, &edited); err != nil {
t.Fatal(err)
}
if edited.Book.Author != "虚构书籍作者" || edited.Book.Title != "Fictional book author" {
t.Fatalf("book edit result: %+v", edited.Book)
}
code, _, listed := callRaw(t, r, "GET", "/api/v1/books", learner.Token, nil)
if code != 200 {
t.Fatalf("book list %d", code)
}
var page struct {
Items []BookSummary
}
if err := json.Unmarshal(listed, &page); err != nil {
t.Fatal(err)
}
if len(page.Items) != 1 || page.Items[0].Author != "虚构书籍作者" {
t.Fatalf("the book list must carry the author: %+v", page.Items)
}
book, _ = bookDetailOf(t, r, learner.Token, bookID)
if book.Author != "虚构书籍作者" {
t.Fatalf("the book detail must carry the author: %+v", book)
}
// The reader response carries the book too, so a chapter page can show it if needed.
code, _, reader := callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), learner.Token, nil)
if code != 200 {
t.Fatalf("reader %d", code)
}
var read struct {
Book BookRef
}
if err := json.Unmarshal(reader, &read); err != nil {
t.Fatal(err)
}
if read.Book.Author != "虚构书籍作者" {
t.Fatalf("the reader response must carry the book author: %+v", read.Book)
}
// Omitting the author keeps it, a blank author clears it, and the length is capped.
if code, _, _ := callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/books/%d", bookID), learner.Token,
map[string]any{"title": "Fictional book author"}); code != 200 {
t.Fatal("a title-only edit must be accepted")
}
if book, _ = bookDetailOf(t, r, learner.Token, bookID); book.Author != "虚构书籍作者" {
t.Fatalf("omitting the author must keep it, got %q", book.Author)
}
if code, _, _ := callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/books/%d", bookID), learner.Token,
map[string]any{"title": "Fictional book author", "author": " "}); code != 200 {
t.Fatal("clearing the author must be allowed")
}
if book, _ = bookDetailOf(t, r, learner.Token, bookID); book.Author != "" {
t.Fatalf("a blank author must clear the field, got %q", book.Author)
}
if code, _, _ := callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/books/%d", bookID), learner.Token,
map[string]any{"title": "Fictional book author", "author": strings.Repeat("a", 121)}); code != 400 {
t.Fatal("an over-long author must be rejected")
}
// Ownership still applies.
other := newLearner(t, r, owner.Token)
if code, _, _ := callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/books/%d", bookID), other.Token,
map[string]any{"title": "stolen", "author": "stolen"}); code != 404 {
t.Fatal("another account must not edit the book")
}
}
// editChapterText edits the body of one owned chapter through the API and fails on any error.
func editChapterText(t *testing.T, r *gin.Engine, token string, chapterID int64, text string) ChapterEdit {
t.Helper()
code, msg, data := callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/chapters/%d", chapterID), token, map[string]string{"text": text})
if code != 200 {
t.Fatalf("editing back to a used version must succeed, got %d (%s)", code, msg)
}
var edited ChapterEdit
if err := json.Unmarshal(data, &edited); err != nil {
t.Fatal(err)
}
return edited
}
// TestMySQLChapterEditBackToAPreviousVersion covers the defect reported in #32: the request key of an
// edit job is derived from the content, so returning to a text used before must reuse that job instead
// of colliding with its unique key. Every step of A -> B -> A -> B has to succeed, the chapter has to
// end up holding the last text, and each version has to keep exactly one job row.
func TestMySQLChapterEditBackToAPreviousVersion(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
first := "Curiosity opens the first door.\n"
second := "Curiosity opens a different door.\n"
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "edit-version-0001", "title": "Fictional versions", "text": first, "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
drainIngest(t, db)
chapterID := pasted.Chapter.ID
firstSHA, secondSHA := contentSHA(first), contentSHA(second)
// A -> B.
edited := editChapterText(t, r, learner.Token, chapterID, second)
if !edited.VersionChanged || edited.Chapter.Status != statusPending || edited.Job == nil {
t.Fatalf("the first edit must start a new version: %+v", edited)
}
// The response carries the client-facing job view; the version it describes is read back from the
// row itself.
var secondJob IngestJob
if err := db.Where("chapter_id = ? AND content_sha256 = ?", chapterID, secondSHA).First(&secondJob).Error; err != nil {
t.Fatalf("the new version needs its own job: %v", err)
}
if err := db.Model(&Chapter{}).Where("id = ?", chapterID).Update("status", statusReady).Error; err != nil {
t.Fatal(err)
}
// B -> A: this is the step that used to answer 500.
edited = editChapterText(t, r, learner.Token, chapterID, first)
if !edited.VersionChanged || edited.Job == nil {
t.Fatalf("editing back must publish the earlier version: %+v", edited)
}
// The row that already described the pasted version is reused, so no second row appears for it.
var rows int64
if err := db.Model(&IngestJob{}).Where("chapter_id = ?", chapterID).Count(&rows).Error; err != nil || rows != 2 {
t.Fatalf("one row per version is expected, got %d (%v)", rows, err)
}
var reused IngestJob
if err := db.Where("chapter_id = ? AND content_sha256 = ?", chapterID, firstSHA).First(&reused).Error; err != nil {
t.Fatal(err)
}
if reused.Status != statusPending || reused.Attempts != 0 || reused.FinishedAt != nil || reused.ErrorReason != "" {
t.Fatalf("a reused job must restart cleanly: %+v", reused)
}
if err := db.Model(&Chapter{}).Where("id = ?", chapterID).Update("status", statusReady).Error; err != nil {
t.Fatal(err)
}
// A -> B again, and this time the B job is the one being reused.
edited = editChapterText(t, r, learner.Token, chapterID, second)
if !edited.VersionChanged || edited.Job == nil {
t.Fatalf("switching between versions must keep working: %+v", edited)
}
if err := db.Model(&IngestJob{}).Where("chapter_id = ?", chapterID).Count(&rows).Error; err != nil || rows != 2 {
t.Fatalf("still one row per version, got %d (%v)", rows, err)
}
// A version whose job had failed is revived when it becomes current again.
if err := db.Model(&IngestJob{}).Where("chapter_id = ? AND content_sha256 = ?", chapterID, secondSHA).
Updates(map[string]any{"status": statusFailed, "attempts": maxIngestAttempts, "error_reason": reasonAttemptsExhausted}).Error; err != nil {
t.Fatal(err)
}
editChapterText(t, r, learner.Token, chapterID, first)
edited = editChapterText(t, r, learner.Token, chapterID, second)
if edited.Job == nil || edited.Job.Attempts != 0 || edited.Job.Status != statusPending {
t.Fatalf("a failed version must be revived when it becomes current again: %+v", edited.Job)
}
// The pipeline finishes the reused job and the chapter holds the last text.
drainIngest(t, db)
var chapter Chapter
if err := db.Where("id = ?", chapterID).First(&chapter).Error; err != nil {
t.Fatal(err)
}
if chapter.Status != statusReady || chapter.ContentSHA256 != secondSHA || chapter.OriginalText != second {
t.Fatalf("the chapter must hold the last edited text: %+v", chapter)
}
code, _, data := callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d", chapterID), learner.Token, nil)
if code != 200 {
t.Fatalf("reader %d", code)
}
var payload struct {
Chapter ChapterView
}
if err := json.Unmarshal(data, &payload); err != nil {
t.Fatal(err)
}
if payload.Chapter.OriginalText != second {
t.Fatalf("the reader must serve the last text: %q", payload.Chapter.OriginalText)
}
// An unchanged text is still not a new version.
code, _, _ = callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/chapters/%d", chapterID), learner.Token, map[string]string{"text": second})
if code != 200 {
t.Fatalf("saving the same text must succeed, got %d", code)
}
if err := db.Model(&IngestJob{}).Where("chapter_id = ?", chapterID).Count(&rows).Error; err != nil || rows != 2 {
t.Fatalf("an unchanged text must not add a job, got %d (%v)", rows, err)
}
}
+58 -12
View File
@@ -47,13 +47,19 @@ func requeueStaleIngestJobs(db *gorm.DB, now time.Time, staleAfter time.Duration
cutoff := stamp(now.Add(-staleAfter))
var requeued int64
err := db.Transaction(func(tx *gorm.DB) error {
if err := abandonSupersededJobs(tx, ts); err != nil {
return err
}
if err := exhaustIngestJobs(tx, ts); err != nil {
return err
}
stale := []int64{}
if err := tx.Model(&IngestJob{}).
Where("status = ? AND attempts < ? AND updated_at <= ?", statusProcessing, maxIngestAttempts, cutoff).
Pluck("id", &stale).Error; err != nil {
// Only jobs that still describe the chapter's current version may be requeued: an
// interrupted run of an older version must not pull the newer text back into processing.
if err := tx.Table("lexgo_ingest_jobs AS j").
Joins("JOIN lexgo_chapters AS c ON c.id = j.chapter_id AND c.content_sha256 = j.content_sha256").
Where("j.status = ? AND j.attempts < ? AND j.updated_at <= ?", statusProcessing, maxIngestAttempts, cutoff).
Pluck("j.id", &stale).Error; err != nil {
return err
}
if len(stale) == 0 {
@@ -111,10 +117,21 @@ func ClaimNextIngestJob(db *gorm.DB, now time.Time) (IngestJob, bool, error) {
ts := stamp(now)
var job IngestJob
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("status = ? AND attempts < ?", statusPending, maxIngestAttempts).
Order("id ASC").First(&job).Error; err != nil {
if err := abandonSupersededJobs(tx, ts); err != nil {
return err
}
// A job may only process the version it was created for, so the chapter join is part
// of the claim and an edited chapter is never dragged back to processing.
if err := tx.Table("lexgo_ingest_jobs AS j").
Select("j.id, j.owner_id, j.book_id, j.chapter_id, j.request_key, j.content_sha256, j.status, j.attempts, j.error_reason, j.created_at, j.updated_at, j.finished_at").
Joins("JOIN lexgo_chapters AS c ON c.id = j.chapter_id AND c.content_sha256 = j.content_sha256").
Where("j.status = ? AND j.attempts < ?", statusPending, maxIngestAttempts).
Order("j.id ASC").Limit(1).Find(&job).Error; err != nil {
return err
}
if job.ID == 0 {
return errNoIngestJob
}
claim := tx.Model(&IngestJob{}).Where("id = ? AND status = ?", job.ID, statusPending).
Updates(map[string]any{"status": statusProcessing, "attempts": gorm.Expr("attempts + 1"), "updated_at": ts})
if claim.Error != nil {
@@ -123,7 +140,7 @@ func ClaimNextIngestJob(db *gorm.DB, now time.Time) (IngestJob, bool, error) {
if claim.RowsAffected != 1 {
return errJobTaken
}
if err := tx.Model(&Chapter{}).Where("id = ? AND owner_id = ?", job.ChapterID, job.OwnerID).
if err := tx.Model(&Chapter{}).Where("id = ? AND owner_id = ? AND content_sha256 = ?", job.ChapterID, job.OwnerID, job.ContentSHA256).
Updates(map[string]any{"status": statusProcessing, "updated_at": ts}).Error; err != nil {
return err
}
@@ -132,7 +149,7 @@ func ClaimNextIngestJob(db *gorm.DB, now time.Time) (IngestJob, bool, error) {
job.UpdatedAt = ts
return nil
})
if errors.Is(err, gorm.ErrRecordNotFound) || errors.Is(err, errJobTaken) {
if errors.Is(err, gorm.ErrRecordNotFound) || errors.Is(err, errJobTaken) || errors.Is(err, errNoIngestJob) {
return IngestJob{}, false, nil
}
if err != nil {
@@ -143,6 +160,9 @@ func ClaimNextIngestJob(db *gorm.DB, now time.Time) (IngestJob, bool, error) {
var errJobTaken = errors.New("ingestion job already claimed")
// errNoIngestJob reports an empty queue, which is not a failure.
var errNoIngestJob = errors.New("no ingestion job to claim")
// FinishIngestJob validates the persisted chapter and publishes it, or records a fixed
// failure reason. The check runs again here because a worker must not trust that content
// reached the table through the paste API.
@@ -150,12 +170,27 @@ func FinishIngestJob(ctx context.Context, db *gorm.DB, job IngestJob, now time.T
ts := stamp(now)
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var chapter Chapter
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND owner_id = ?", job.ChapterID, job.OwnerID).First(&chapter).Error; err != nil {
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND owner_id = ?", job.ChapterID, job.OwnerID).First(&chapter).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
// The chapter was deleted while this run was in flight; the cascade removed its
// jobs too, so there is nothing left to publish.
return nil
}
if err != nil {
return err
}
if chapter.ContentSHA256 != job.ContentSHA256 {
// The chapter moved to a newer version: publish nothing and leave its state, which
// belongs to the newer job, untouched.
return tx.Model(&IngestJob{}).Where("id = ?", job.ID).Updates(map[string]any{
"status": statusFailed, "error_reason": reasonSuperseded, "updated_at": ts, "finished_at": ts}).Error
}
var book Book
if err := tx.Where("id = ? AND owner_id = ?", job.BookID, job.OwnerID).First(&book).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
if reason := unprocessableReason(book, chapter, job); reason != "" {
@@ -185,14 +220,25 @@ func unprocessableReason(book Book, chapter Chapter, job IngestJob) string {
if utf8.RuneCountInString(chapter.OriginalText) > maxChapterRunes {
return reasonTooLong
}
// The job accepted a specific content version; a chapter changed after submission is a
// different paste and must be submitted again rather than silently processed.
if contentSHA(chapter.OriginalText) != job.ContentSHA256 {
// The stored text and its stored version must agree. The job-versus-chapter version check
// ran before this function, so a direct write that changed the text without updating its
// version is the remaining case, and it is a different paste rather than this version.
if contentSHA(chapter.OriginalText) != chapter.ContentSHA256 {
return reasonContentChanged
}
return ""
}
// abandonSupersededJobs fails jobs whose version is no longer the chapter's version. They
// must never publish or fail the chapter, because another job owns its current state. It is
// one multi-table statement: GORM's Updates does not carry a Joins clause into an UPDATE.
func abandonSupersededJobs(tx *gorm.DB, ts time.Time) error {
return tx.Exec(`UPDATE lexgo_ingest_jobs j JOIN lexgo_chapters c ON c.id = j.chapter_id
SET j.status = ?, j.error_reason = ?, j.updated_at = ?, j.finished_at = ?
WHERE j.status IN (?, ?) AND j.content_sha256 <> c.content_sha256`,
statusFailed, reasonSuperseded, ts, ts, statusPending, statusProcessing).Error
}
// ProcessIngestJobs drains up to limit pending jobs. Claiming and finishing each use their
// own transaction, so an interrupted run simply leaves a job for recovery.
func ProcessIngestJobs(ctx context.Context, db *gorm.DB, now func() time.Time, limit int) (int, error) {
+56 -1
View File
@@ -163,6 +163,55 @@ func TestMySQLLoginUsesStoredPasswordWithoutChangingCreationPolicy(t *testing.T)
}
}
// TestMySQLSessionLifetimeIsThirtyDays pins the login lifetime that both clients share. It checks
// the API response, the stored row and the privacy rule (only a digest is persisted) so changing the
// value without updating this test is impossible.
func TestMySQLSessionLifetimeIsThirtyDays(t *testing.T) {
if SessionLifetime != 30*24*time.Hour {
t.Fatalf("session lifetime %s, want 30 days", SessionLifetime)
}
db := testDB(t)
u := admin.SysUser{Username: randomName("sesslife"), Password: fixturePassword, RoleId: 2, Status: "2"}
if err := db.Create(&u).Error; err != nil {
t.Fatal(err)
}
r := Router(db, time.Now)
before := time.Now()
code, data := callAPI(t, r, "POST", "/api/v1/login", "", map[string]string{"username": u.Username, "password": fixturePassword})
if code != 200 {
t.Fatalf("login status %d", code)
}
var login struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expiresAt"`
}
if err := json.Unmarshal(data, &login); err != nil {
t.Fatal(err)
}
granted := login.ExpiresAt.Sub(before)
if granted < SessionLifetime-time.Minute || granted > SessionLifetime+time.Minute {
t.Fatalf("granted %s, want about %s", granted, SessionLifetime)
}
var stored Session
if err := db.Where("token_hash = ?", digest(login.Token)).First(&stored).Error; err != nil {
t.Fatal("session row missing")
}
if stored.ExpiresAt.Sub(stored.ExpiresAt.Truncate(time.Millisecond)) != 0 {
t.Fatal("stored expiry lost precision")
}
if delta := login.ExpiresAt.Sub(stored.ExpiresAt); delta > time.Millisecond || delta < -time.Millisecond {
t.Fatalf("stored expiry %s differs from the response by %s", stored.ExpiresAt, delta)
}
var raw int64
db.Table("lexgo_sessions").Where("token_hash = ?", login.Token).Count(&raw)
if raw != 0 {
t.Fatal("raw token persisted")
}
if code, _ = callAPI(t, r, "GET", "/api/v1/me", login.Token, nil); code != 200 {
t.Fatalf("fresh session rejected: %d", code)
}
}
func TestMySQLAccountIsolationAndRevocation(t *testing.T) {
db := testDB(t)
if err := Migrate(db); err != nil {
@@ -281,7 +330,13 @@ func TestMySQLAccountIsolationAndRevocation(t *testing.T) {
t.Fatal("old password accepted")
}
loginToken(t, r, users[0], "replacement-fixture-pass")
clock = clock.Add(9 * time.Hour)
// The lifetime is absolute, so a session is still valid just before it and gone right after;
// asserting against SessionLifetime keeps this test honest when the value changes.
clock = clock.Add(SessionLifetime - time.Hour)
if code, _ = callAPI(t, r, "GET", "/api/v1/me", tokenB, nil); code != 200 {
t.Fatal("session must still be valid before its lifetime ends", code)
}
clock = clock.Add(2 * time.Hour)
code, _ = callAPI(t, r, "GET", "/api/v1/me", tokenB, nil)
if code != 401 {
t.Fatal("expired session valid")
+134 -30
View File
@@ -30,7 +30,9 @@ const (
reasonTooLong = "too_long"
reasonEmptyText = "empty_text"
reasonContentChanged = "content_changed"
reasonAttemptsExhausted = "attempts_exhausted"
// reasonSuperseded marks a job whose chapter already moved to a newer content version.
reasonSuperseded = "superseded"
reasonAttemptsExhausted = "attempts_exhausted"
)
const (
@@ -53,6 +55,8 @@ func reasonMessage(reason string) string {
return "内容超过单章上限(100000 个字符)"
case reasonEmptyText:
return "章节内容为空"
case reasonSuperseded:
return "章节内容已更新为新版本,本次处理已作废"
case reasonContentChanged:
return "内容在处理前发生变化,请重新提交"
case reasonAttemptsExhausted:
@@ -63,9 +67,11 @@ func reasonMessage(reason string) string {
}
type Book struct {
ID int64 `gorm:"primaryKey"`
OwnerID int
Title string
ID int64 `gorm:"primaryKey"`
OwnerID int
Title string
// Author is optional free text for the book itself; a chapter has its own author.
Author string
Language string
CreatedAt time.Time
UpdatedAt time.Time
@@ -74,11 +80,13 @@ type Book struct {
func (Book) TableName() string { return "lexgo_books" }
type Chapter struct {
ID int64 `gorm:"primaryKey"`
BookID int64
OwnerID int
Ordinal int
Title string
ID int64 `gorm:"primaryKey"`
BookID int64
OwnerID int
Ordinal int
Title string
// Author is optional free text the learner can set when editing a chapter.
Author string
OriginalText string
CharCount int
ContentSHA256 string
@@ -108,39 +116,58 @@ type IngestJob struct {
func (IngestJob) TableName() string { return "lexgo_ingest_jobs" }
type BookSummary struct {
ID int64 `json:"id"`
Title string `json:"title"`
Language string `json:"language"`
ChapterCount int `json:"chapterCount"`
PendingCount int `json:"pendingCount"`
ProcessingCount int `json:"processingCount"`
ReadyCount int `json:"readyCount"`
FailedCount int `json:"failedCount"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID int64 `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
Language string `json:"language"`
ChapterCount int `json:"chapterCount"`
PendingCount int `json:"pendingCount"`
ProcessingCount int `json:"processingCount"`
ReadyCount int `json:"readyCount"`
FailedCount int `json:"failedCount"`
// CoverVersion lets the library show a cover and refresh it exactly when the file changes.
CoverVersion string `json:"coverVersion,omitempty"`
HasAudio bool `json:"hasAudio"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type BookRef struct {
ID int64 `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
Language string `json:"language"`
// Attachments is filled by the endpoints that show a book, so a cover can be rendered from the
// list without a request per row.
Attachments *BookAttachmentsView `json:"attachments,omitempty"`
}
func bookRef(book Book) BookRef { return BookRef{book.ID, book.Title, book.Language} }
func bookRef(book Book) BookRef {
return BookRef{ID: book.ID, Title: book.Title, Author: book.Author, Language: book.Language}
}
type ChapterSummary struct {
ID int64 `json:"id"`
BookID int64 `json:"bookId"`
Ordinal int `json:"ordinal"`
Title string `json:"title"`
Author string `json:"author"`
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"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
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"`
// IllustrationVersion and AudioVersion are content digests; they are always present and empty
// when that file is absent, so a client never has to tell "missing" from "empty".
IllustrationVersion string `json:"illustrationVersion"`
AudioVersion string `json:"audioVersion"`
PlaybackSeconds int `json:"playbackSeconds"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type ChapterView struct {
@@ -154,7 +181,7 @@ type ChapterView struct {
func chapterSummary(c Chapter) ChapterSummary { return chapterSummaryWithJob(c, nil) }
func chapterSummaryWithJob(c Chapter, jobID *int64) ChapterSummary {
return ChapterSummary{ID: c.ID, BookID: c.BookID, Ordinal: c.Ordinal, Title: c.Title,
return ChapterSummary{ID: c.ID, BookID: c.BookID, Ordinal: c.Ordinal, Title: c.Title, Author: c.Author,
Status: c.Status, CharCount: c.CharCount, JobID: jobID, ErrorReason: c.ErrorReason,
ErrorMessage: reasonMessage(c.ErrorReason), CreatedAt: c.CreatedAt, UpdatedAt: c.UpdatedAt}
}
@@ -478,12 +505,26 @@ func ListBooks(db *gorm.DB, owner int) ([]BookSummary, error) {
items := make([]BookSummary, 0, len(books))
ids := make([]int64, 0, len(books))
for _, b := range books {
items = append(items, BookSummary{ID: b.ID, Title: b.Title, Language: b.Language, CreatedAt: b.CreatedAt, UpdatedAt: b.UpdatedAt})
items = append(items, BookSummary{ID: b.ID, Title: b.Title, Author: b.Author, Language: b.Language, CreatedAt: b.CreatedAt, UpdatedAt: b.UpdatedAt})
ids = append(ids, b.ID)
}
if len(ids) == 0 {
return items, nil
}
// Attachment metadata is loaded for the whole page at once, so a cover in the list costs one
// extra query instead of one per book.
found, err := attachmentsByBook(db, owner, ids)
if err != nil {
return nil, err
}
for i, item := range items {
if cover, ok := found[item.ID][attachmentCover]; ok {
items[i].CoverVersion = cover.Version
}
if _, ok := found[item.ID][attachmentAudio]; ok {
items[i].HasAudio = true
}
}
type row struct {
BookID int64
Status string
@@ -518,6 +559,16 @@ func ListBooks(db *gorm.DB, owner int) ([]BookSummary, error) {
return items, nil
}
// attachToBookRef fills the attachment metadata for one book the caller owns.
func attachToBookRef(db *gorm.DB, owner int, ref *BookRef) error {
view, err := BookAttachmentsFor(db, owner, ref.ID)
if err != nil {
return err
}
ref.Attachments = &view
return nil
}
func BookDetail(db *gorm.DB, owner int, bookID int64) (BookRef, []ChapterSummary, error) {
var book Book
if err := db.Where("id = ? AND owner_id = ?", bookID, owner).First(&book).Error; err != nil {
@@ -539,14 +590,37 @@ func BookDetail(db *gorm.DB, owner int, bookID int64) (BookRef, []ChapterSummary
if err != nil {
return BookRef{}, nil, err
}
marks, err := readAtByChapter(db, owner, ids)
if err != nil {
return BookRef{}, nil, err
}
attachments, err := chapterAttachmentViews(db, owner, ids)
if err != nil {
return BookRef{}, nil, err
}
positions, err := chapterPlaybackSeconds(db, owner, ids)
if err != nil {
return BookRef{}, nil, err
}
for _, c := range chapters {
var jobID *int64
if id, ok := jobs[c.ID]; ok {
jobID = &id
}
items = append(items, chapterSummaryWithJob(c, jobID))
summary := chapterSummaryWithJob(c, jobID)
if at, ok := marks[c.ID]; ok {
summary.ReadAt = &at
}
summary.IllustrationVersion = attachments[c.ID][attachmentIllustration].Version
summary.AudioVersion = attachments[c.ID][attachmentAudio].Version
summary.PlaybackSeconds = positions[c.ID]
items = append(items, summary)
}
return bookRef(book), items, nil
ref := bookRef(book)
if err = attachToBookRef(db, owner, &ref); err != nil {
return BookRef{}, nil, err
}
return ref, items, nil
}
// jobIDsByChapter maps chapters to their ingestion job so a client can retry a failed chapter
@@ -604,7 +678,32 @@ func ChapterDetail(db *gorm.DB, owner int, chapterID int64) (ReaderResponse, err
if id, ok := jobs[chapter.ID]; ok {
jobID = &id
}
return ReaderResponse{Book: bookRef(book), Chapter: chapterView(chapter, jobID), Navigation: navigation}, nil
marks, err := readAtByChapter(db, owner, []int64{chapter.ID})
if err != nil {
return ReaderResponse{}, err
}
view := chapterView(chapter, jobID)
if at, ok := marks[chapter.ID]; ok {
view.ReadAt = &at
}
// The reader shows the chapter's illustration and audio, so this response carries the chapter's
// attachment metadata; the book keeps its cover.
ref := bookRef(book)
if err = attachToBookRef(db, owner, &ref); err != nil {
return ReaderResponse{}, err
}
attachments, err := chapterAttachmentViews(db, owner, []int64{chapter.ID})
if err != nil {
return ReaderResponse{}, err
}
positions, err := chapterPlaybackSeconds(db, owner, []int64{chapter.ID})
if err != nil {
return ReaderResponse{}, err
}
view.IllustrationVersion = attachments[chapter.ID][attachmentIllustration].Version
view.AudioVersion = attachments[chapter.ID][attachmentAudio].Version
view.PlaybackSeconds = positions[chapter.ID]
return ReaderResponse{Book: ref, Chapter: view, Navigation: navigation}, nil
}
func JobDetail(db *gorm.DB, owner int, jobID int64) (JobView, error) {
@@ -640,6 +739,11 @@ func RetryIngestJob(db *gorm.DB, owner int, jobID int64, now time.Time) (JobView
Where("id = ? AND owner_id = ?", job.ChapterID, owner).First(&chapter).Error; err != nil {
return err
}
// A job of an older content version is gone for good: retrying it would process text
// the chapter no longer holds, so the newer job owns the chapter instead.
if chapter.ContentSHA256 != job.ContentSHA256 {
return failure(409, "该任务对应的是旧版本,请刷新后重试当前版本")
}
if err := tx.Model(&IngestJob{}).Where("id = ?", job.ID).
Updates(map[string]any{"status": statusPending, "error_reason": "", "attempts": 0,
"updated_at": ts, "finished_at": nil}).Error; err != nil {
+570 -2
View File
@@ -136,8 +136,8 @@ func TestMigrationFromV2PreservesExistingData(t *testing.T) {
}
var version int
db.Raw("SELECT version FROM lexgo_schema WHERE id=1").Scan(&version)
if version != schemaVersion {
t.Fatalf("schema version %d after re-upgrade, want %d", version, schemaVersion)
if version != SchemaVersion {
t.Fatalf("schema version %d after re-upgrade, want %d", version, SchemaVersion)
}
db.Table("sys_user").Where("user_id = 7").Count(&users)
if users != 1 {
@@ -145,6 +145,232 @@ func TestMigrationFromV2PreservesExistingData(t *testing.T) {
}
}
func TestMigrationFromV4KeepsLibraryAndAddsPersonalTerms(t *testing.T) {
db := emptyMigrationDB(t)
statements := []string{"CREATE TABLE lexgo_schema (id INT PRIMARY KEY,version INT,product VARCHAR(32))", "INSERT INTO lexgo_schema VALUES (1,4,'lexgo')"}
statements = append(statements, schemaV2Statements...)
statements = append(statements, schemaV3Statements...)
statements = append(statements, schemaV4Statements...)
for _, statement := range statements {
if err := db.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
if err := db.Exec("INSERT INTO sys_user (user_id,username,password,role_id) VALUES (9,'fixture_v4','fictional-not-a-real-hash',2)").Error; err != nil {
t.Fatal(err)
}
now := stamp(time.Now())
book := Book{OwnerID: 9, Title: "Fictional migration", Language: "en", CreatedAt: now, UpdatedAt: now}
if err := db.Create(&book).Error; err != nil {
t.Fatal(err)
}
original := "😀 Original e\u0301\r\n"
chapter := Chapter{BookID: book.ID, OwnerID: 9, Ordinal: 1, Title: "Fixture", OriginalText: original, ContentSHA256: contentSHA(original), Status: statusReady, CharCount: len([]rune(original)), CreatedAt: now, UpdatedAt: now}
if err := db.Create(&chapter).Error; err != nil {
t.Fatal(err)
}
// A v4 database has no provider column, so the fixture is written with the columns that
// existed then; the migration adds provider and the default names it as the WordNet slot.
if err := db.Exec("INSERT INTO lexgo_dictionaries (id,name,language,version,source,format,sha256,entry_count,enabled,archive,updated_at) VALUES (1,?,?,?,?,?,?,?,?,?,?)",
"Fixture WordNet", "en", "3.0", WordNetSource, "wordnet-3.0-zip", strings.Repeat("a", 64), 5, true, []byte("fixture archive"), now).Error; err != nil {
t.Fatal(err)
}
if err := CheckSchema(db); err == nil {
t.Fatal("old schema accepted before explicit migration")
}
if err := Migrate(db); err != nil {
t.Fatal(err)
}
if err := CheckSchema(db); err != nil {
t.Fatal(err)
}
var restored Chapter
if err := db.First(&restored, chapter.ID).Error; err != nil || restored.OriginalText != original || restored.ContentSHA256 != chapter.ContentSHA256 {
t.Fatal("migration changed original chapter", err)
}
var restoredResource DictionaryResource
if err := db.Omit("archive").First(&restoredResource, 1).Error; err != nil || restoredResource.SHA256 != strings.Repeat("a", 64) || restoredResource.EntryCount != 5 || restoredResource.Provider != "wordnet" {
t.Fatal("migration changed the shared dictionary resource", err)
}
var terms int64
if err := db.Model(&Term{}).Count(&terms).Error; err != nil || terms != 0 {
t.Fatal("migration must create an empty personal term table", terms, err)
}
// The new table is usable and enforces the documented status boundary.
valid := Term{OwnerID: 9, Language: "en", Term: "curiosity", OriginalForm: "Curiosity", Status: termStatusNew, CreatedAt: now, UpdatedAt: now}
if err := db.Create(&valid).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&Term{OwnerID: 9, Language: "en", Term: "forged", OriginalForm: "forged", Status: "deleted", CreatedAt: now, UpdatedAt: now}).Error; err == nil {
t.Fatal("an unknown status must be rejected by the schema check")
}
if err := db.Create(&Term{OwnerID: 9, Language: "en", Term: "leveled", OriginalForm: "leveled", Status: termStatusKnown, Level: 5, CreatedAt: now, UpdatedAt: now}).Error; err == nil {
t.Fatal("a level outside 0~7 must be rejected by the schema check")
}
// Rolling the marker back for a binary rollback and upgrading again keeps rows.
if err := db.Exec("UPDATE lexgo_schema SET version=4 WHERE id=1").Error; err != nil {
t.Fatal(err)
}
if err := Migrate(db); err != nil {
t.Fatal(err)
}
if err := db.Model(&Term{}).Count(&terms).Error; err != nil || terms != 1 {
t.Fatal("re-upgrade changed existing personal terms", terms, err)
}
}
func TestMigrationFromV5AddsReviewScheduling(t *testing.T) {
db := emptyMigrationDB(t)
statements := []string{"CREATE TABLE lexgo_schema (id INT PRIMARY KEY,version INT,product VARCHAR(32))", "INSERT INTO lexgo_schema VALUES (1,5,'lexgo')"}
statements = append(statements, schemaV2Statements...)
statements = append(statements, schemaV3Statements...)
statements = append(statements, schemaV4Statements...)
statements = append(statements, schemaV5Statements...)
for _, statement := range statements {
if err := db.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
if err := db.Exec("INSERT INTO sys_user (user_id,username,password,role_id) VALUES (11,'fixture_v5','fictional-not-a-real-hash',2)").Error; err != nil {
t.Fatal(err)
}
saved := stamp(time.Now())
term := Term{OwnerID: 11, Language: "en", Term: "curiosity", OriginalForm: "Curiosity", Definition: "虚构释义", Examples: "Fictional example.", Status: termStatusLearning, Level: 2, CreatedAt: saved, UpdatedAt: saved}
if err := db.Create(&term).Error; err != nil {
t.Fatal(err)
}
if err := CheckSchema(db); err == nil {
t.Fatal("old schema accepted before explicit migration")
}
if err := Migrate(db); err != nil {
t.Fatal(err)
}
if err := CheckSchema(db); err != nil {
t.Fatal(err)
}
// The learner's own term is untouched and has never been reviewed.
var restored Term
if err := db.First(&restored, term.ID).Error; err != nil || restored.Definition != term.Definition || restored.Level != 2 || restored.Status != termStatusLearning {
t.Fatal("migration changed the personal term", err)
}
// An existing saved word enters the queue immediately: due at the moment it was saved.
var review TermReview
if err := db.Where("term_id = ?", term.ID).First(&review).Error; err != nil {
t.Fatal("existing terms need a schedule row", err)
}
if !review.DueAt.Equal(saved) || review.ReviewCount != 0 || review.CorrectCount != 0 || review.WrongCount != 0 || review.Language != "en" {
t.Fatalf("schedule row: %#v", review)
}
var answers int64
if err := db.Model(&ReviewAnswer{}).Count(&answers).Error; err != nil || answers != 0 {
t.Fatal("migration must create an empty attempt log", answers, err)
}
// A second identical answer row is rejected by the unique answer key.
digest, err := requestKey("migration-fixture-answer")
if err != nil {
t.Fatal(err)
}
record := ReviewAnswer{OwnerID: 11, AnswerKey: digest, TermID: term.ID, Grade: reviewGradeCorrect, Result: "applied", StatusBefore: termStatusNew, StatusAfter: termStatusLearning, LevelBefore: 0, LevelAfter: 1, DueAtBefore: saved, DueAtAfter: saved, CreatedAt: saved}
if err := db.Create(&record).Error; err != nil {
t.Fatal(err)
}
duplicate := record
duplicate.ID = 0
if err := db.Create(&duplicate).Error; err == nil {
t.Fatal("the answer key must be unique per account")
}
// Rolling the marker back for a binary rollback and upgrading again keeps the rows.
if err := db.Exec("UPDATE lexgo_schema SET version=5 WHERE id=1").Error; err != nil {
t.Fatal(err)
}
if err := Migrate(db); err != nil {
t.Fatal(err)
}
var reviews, attempts int64
if err := db.Model(&TermReview{}).Count(&reviews).Error; err != nil || reviews != 1 {
t.Fatalf("re-upgrade must not duplicate schedule rows: %d %v", reviews, err)
}
if err := db.Model(&ReviewAnswer{}).Count(&attempts).Error; err != nil || attempts != 1 {
t.Fatalf("re-upgrade must keep the attempt log: %d %v", attempts, err)
}
}
func TestMigrationFromV6AddsChapterProgress(t *testing.T) {
db := emptyMigrationDB(t)
statements := []string{"CREATE TABLE lexgo_schema (id INT PRIMARY KEY,version INT,product VARCHAR(32))", "INSERT INTO lexgo_schema VALUES (1,6,'lexgo')"}
statements = append(statements, schemaV2Statements...)
statements = append(statements, schemaV3Statements...)
statements = append(statements, schemaV4Statements...)
statements = append(statements, schemaV5Statements...)
statements = append(statements, schemaV6Statements...)
for _, statement := range statements {
if err := db.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
if err := db.Exec("INSERT INTO sys_user (user_id,username,password,role_id) VALUES (21,'fixture_v6','fictional-not-a-real-hash',2)").Error; err != nil {
t.Fatal(err)
}
saved := stamp(time.Now())
book := Book{OwnerID: 21, Title: "Fictional v6 book", Language: "en", CreatedAt: saved, UpdatedAt: saved}
if err := db.Create(&book).Error; err != nil {
t.Fatal(err)
}
text := "Curiosity opens the first door.\n"
chapter := Chapter{BookID: book.ID, OwnerID: 21, Ordinal: 1, Title: "Fictional v6", OriginalText: text,
ContentSHA256: contentSHA(text), CharCount: len([]rune(text)), Status: statusReady, CreatedAt: saved, UpdatedAt: saved}
if err := db.Create(&chapter).Error; err != nil {
t.Fatal(err)
}
// Nothing is read before the migration: a chapter never carries a mark by default.
if err := CheckSchema(db); err == nil {
t.Fatal("old schema accepted before explicit migration")
}
if err := Migrate(db); err != nil {
t.Fatal(err)
}
if err := CheckSchema(db); err != nil {
t.Fatal(err)
}
var books, chapters int64
if err := db.Model(&Book{}).Count(&books).Error; err != nil || books != 1 {
t.Fatalf("migration changed the library: %d %v", books, err)
}
if err := db.Model(&Chapter{}).Count(&chapters).Error; err != nil || chapters != 1 {
t.Fatalf("migration changed the chapters: %d %v", chapters, err)
}
var rows int64
if err := db.Model(&ChapterProgress{}).Count(&rows).Error; err != nil || rows != 0 {
t.Fatalf("an upgrade must not invent reading progress: %d %v", rows, err)
}
// One chapter holds one completion row, and the row follows the chapter on delete.
progress := ChapterProgress{ChapterID: chapter.ID, OwnerID: 21, BookID: book.ID, Language: "en",
ReadSHA256: chapter.ContentSHA256, ReadAt: saved, CreatedAt: saved, UpdatedAt: saved}
if err := db.Create(&progress).Error; err != nil {
t.Fatal(err)
}
duplicate := progress
if err := db.Create(&duplicate).Error; err == nil {
t.Fatal("a chapter must hold only one completion row")
}
// Rolling the marker back for a binary rollback and upgrading again keeps the rows.
if err := db.Exec("UPDATE lexgo_schema SET version=6 WHERE id=1").Error; err != nil {
t.Fatal(err)
}
if err := Migrate(db); err != nil {
t.Fatal(err)
}
if err := db.Model(&ChapterProgress{}).Count(&rows).Error; err != nil || rows != 1 {
t.Fatalf("re-upgrade must keep the completion: %d %v", rows, err)
}
if err := db.Where("id = ?", chapter.ID).Delete(&Chapter{}).Error; err != nil {
t.Fatal(err)
}
if err := db.Model(&ChapterProgress{}).Count(&rows).Error; err != nil || rows != 0 {
t.Fatalf("deleting a chapter must drop its completion: %d %v", rows, err)
}
}
func TestMigrationRefusesUnownedOrUnsupportedSchema(t *testing.T) {
for _, tc := range []struct{ name, marker string }{
{"empty_marker", ""},
@@ -223,3 +449,345 @@ func TestMigrationFromV3PreservesLibraryAndJobs(t *testing.T) {
t.Fatal("migration must create empty resource table", resources, err)
}
}
func TestMigrationFromV7AddsBookAttachments(t *testing.T) {
db := emptyMigrationDB(t)
statements := []string{"CREATE TABLE lexgo_schema (id INT PRIMARY KEY,version INT,product VARCHAR(32))", "INSERT INTO lexgo_schema VALUES (1,7,'lexgo')"}
statements = append(statements, schemaV2Statements...)
statements = append(statements, schemaV3Statements...)
statements = append(statements, schemaV4Statements...)
statements = append(statements, schemaV5Statements...)
statements = append(statements, schemaV6Statements...)
statements = append(statements, schemaV7Statements...)
for _, statement := range statements {
if err := db.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
if err := db.Exec("INSERT INTO sys_user (user_id,username,password,role_id) VALUES (31,'fixture_v7','fictional-not-a-real-hash',2)").Error; err != nil {
t.Fatal(err)
}
saved := stamp(time.Now())
book := Book{OwnerID: 31, Title: "Fictional v7 book", Language: "en", CreatedAt: saved, UpdatedAt: saved}
if err := db.Create(&book).Error; err != nil {
t.Fatal(err)
}
if err := CheckSchema(db); err == nil {
t.Fatal("old schema accepted before explicit migration")
}
if err := Migrate(db); err != nil {
t.Fatal(err)
}
if err := CheckSchema(db); err != nil {
t.Fatal(err)
}
// The upgrade starts with no attachments: nothing is invented for existing books.
var attachments int64
if err := db.Model(&BookAttachment{}).Count(&attachments).Error; err != nil || attachments != 0 {
t.Fatalf("an upgrade must not create attachments: %d %v", attachments, err)
}
// One audio and one cover per book, and a second row of the same kind is rejected.
entry := BookAttachment{BookID: book.ID, Kind: attachmentAudio, OwnerID: 31, Mime: "audio/mpeg",
ByteSize: 4, SHA256: contentSHA("ID3"), Bytes: []byte("ID3x"), CreatedAt: saved, UpdatedAt: saved}
if err := db.Create(&entry).Error; err != nil {
t.Fatal(err)
}
duplicate := entry
duplicate.Mime = "audio/mpeg"
if err := db.Create(&duplicate).Error; err == nil {
t.Fatal("a book must hold only one attachment per kind")
}
// A closed set of kinds is enforced by the table itself.
unknown := entry
unknown.Kind = "video"
if err := db.Create(&unknown).Error; err == nil {
t.Fatal("an unknown attachment kind must be rejected")
}
// A cover row, so a later check can show that retiring book-level audio leaves covers alone.
if err := db.Exec("INSERT INTO lexgo_book_attachments (book_id, kind, owner_id, mime, byte_size, sha256, bytes, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)",
book.ID, attachmentCover, 31, "image/png", 4, contentSHA("png"), []byte("png!"), saved, saved).Error; err != nil {
t.Fatal(err)
}
// The v8 table is written with raw SQL: the Go type for it was retired in v9, but the migration
// this test covers is the one that created it.
if err := db.Exec("INSERT INTO lexgo_playback_positions (owner_id, book_id, position_seconds, updated_at) VALUES (?,?,?,?)",
31, book.ID, 12, saved).Error; err != nil {
t.Fatal(err)
}
// Rolling the marker back for a binary rollback and upgrading again keeps the rows.
if err := db.Exec("UPDATE lexgo_schema SET version=7 WHERE id=1").Error; err != nil {
t.Fatal(err)
}
if err := Migrate(db); err != nil {
t.Fatal(err)
}
// Re-upgrading now also applies v9, which retires book-level audio: the audio row this test
// created is removed by that step while the cover row stays.
var audioLeft int64
if err := db.Model(&BookAttachment{}).Where("kind = ?", attachmentAudio).Count(&audioLeft).Error; err != nil || audioLeft != 0 {
t.Fatalf("re-upgrading into v9 must clear book-level audio: %d %v", audioLeft, err)
}
if err := db.Model(&BookAttachment{}).Where("kind = ?", attachmentCover).Count(&attachments).Error; err != nil || attachments != 1 {
t.Fatalf("re-upgrade must keep the cover: %d %v", attachments, err)
}
// The retired position table keeps its row: it is no longer written, but nothing deletes it.
var positions int64
if err := db.Table("lexgo_playback_positions").Where("book_id = ?", book.ID).Count(&positions).Error; err != nil || positions != 1 {
t.Fatalf("re-upgrade must keep the retired position row: %d %v", positions, err)
}
// Deleting the book removes both, so no attachment or position can outlive its book.
if err := db.Where("id = ?", book.ID).Delete(&Book{}).Error; err != nil {
t.Fatal(err)
}
if err := db.Model(&BookAttachment{}).Count(&attachments).Error; err != nil || attachments != 0 {
t.Fatalf("deleting a book must remove its attachments: %d %v", attachments, err)
}
if err := db.Table("lexgo_playback_positions").Where("book_id = ?", book.ID).Count(&positions).Error; err != nil || positions != 0 {
t.Fatalf("deleting a book must remove its positions: %d %v", positions, err)
}
}
func TestMigrationFromV8AddsChapterAttachments(t *testing.T) {
db := emptyMigrationDB(t)
statements := []string{"CREATE TABLE lexgo_schema (id INT PRIMARY KEY,version INT,product VARCHAR(32))", "INSERT INTO lexgo_schema VALUES (1,8,'lexgo')"}
statements = append(statements, schemaV2Statements...)
statements = append(statements, schemaV3Statements...)
statements = append(statements, schemaV4Statements...)
statements = append(statements, schemaV5Statements...)
statements = append(statements, schemaV6Statements...)
statements = append(statements, schemaV7Statements...)
statements = append(statements, schemaV8Statements...)
for _, statement := range statements {
if err := db.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
if err := db.Exec("INSERT INTO sys_user (user_id,username,password,role_id) VALUES (41,'fixture_v8','fictional-not-a-real-hash',2)").Error; err != nil {
t.Fatal(err)
}
saved := stamp(time.Now())
book := Book{OwnerID: 41, Title: "Fictional v8 book", Language: "en", CreatedAt: saved, UpdatedAt: saved}
if err := db.Create(&book).Error; err != nil {
t.Fatal(err)
}
text := "Curiosity opens the first door.\n"
chapter := Chapter{BookID: book.ID, OwnerID: 41, Ordinal: 1, Title: "Fictional v8",
OriginalText: text, ContentSHA256: contentSHA(text), CharCount: len([]rune(text)), Status: statusReady,
CreatedAt: saved, UpdatedAt: saved}
if err := db.Create(&chapter).Error; err != nil {
t.Fatal(err)
}
// A book-level audio row and a cover row, as the retired version would have left them.
if err := db.Exec("INSERT INTO lexgo_book_attachments (book_id, kind, owner_id, mime, byte_size, sha256, bytes, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)",
book.ID, attachmentAudio, 41, "audio/mpeg", 4, contentSHA("ID3x"), []byte("ID3x"), saved, saved).Error; err != nil {
t.Fatal(err)
}
if err := db.Exec("INSERT INTO lexgo_book_attachments (book_id, kind, owner_id, mime, byte_size, sha256, bytes, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)",
book.ID, attachmentCover, 41, "image/png", 4, contentSHA("png"), []byte("png!"), saved, saved).Error; err != nil {
t.Fatal(err)
}
if err := CheckSchema(db); err == nil {
t.Fatal("old schema accepted before explicit migration")
}
if err := Migrate(db); err != nil {
t.Fatal(err)
}
if err := CheckSchema(db); err != nil {
t.Fatal(err)
}
// The upgrade removes the retired book-level audio rows and keeps the cover.
var audioRows int64
if err := db.Model(&BookAttachment{}).Where("kind = ?", attachmentAudio).Count(&audioRows).Error; err != nil || audioRows != 0 {
t.Fatalf("the migration must clear book-level audio: %d %v", audioRows, err)
}
var coverRows int64
if err := db.Model(&BookAttachment{}).Where("kind = ?", attachmentCover).Count(&coverRows).Error; err != nil || coverRows != 1 {
t.Fatalf("the migration must keep the cover: %d %v", coverRows, err)
}
// The new tables start empty and hold one row per chapter and kind.
var chapterRows int64
if err := db.Model(&ChapterAttachment{}).Count(&chapterRows).Error; err != nil || chapterRows != 0 {
t.Fatalf("an upgrade must not invent chapter attachments: %d %v", chapterRows, err)
}
entry := ChapterAttachment{ChapterID: chapter.ID, Kind: attachmentAudio, OwnerID: 41, Mime: "audio/mpeg",
ByteSize: 4, SHA256: contentSHA("ID3x"), Bytes: []byte("ID3x"), CreatedAt: saved, UpdatedAt: saved}
if err := db.Create(&entry).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&entry).Error; err == nil {
t.Fatal("a chapter must hold only one attachment per kind")
}
unknown := entry
unknown.Kind = "video"
if err := db.Create(&unknown).Error; err == nil {
t.Fatal("an unknown attachment kind must be rejected")
}
position := ChapterPlaybackPosition{OwnerID: 41, ChapterID: chapter.ID, PositionSeconds: 7, UpdatedAt: saved}
if err := db.Create(&position).Error; err != nil {
t.Fatal(err)
}
// Rolling the marker back for a binary rollback and upgrading again keeps the rows.
if err := db.Exec("UPDATE lexgo_schema SET version=8 WHERE id=1").Error; err != nil {
t.Fatal(err)
}
if err := Migrate(db); err != nil {
t.Fatal(err)
}
if err := db.Model(&ChapterAttachment{}).Count(&chapterRows).Error; err != nil || chapterRows != 1 {
t.Fatalf("re-upgrade must keep the chapter attachment: %d %v", chapterRows, err)
}
var positions int64
if err := db.Model(&ChapterPlaybackPosition{}).Count(&positions).Error; err != nil || positions != 1 {
t.Fatalf("re-upgrade must keep the chapter position: %d %v", positions, err)
}
// Deleting the chapter removes both, so nothing can outlive its chapter.
if err := db.Where("id = ?", chapter.ID).Delete(&Chapter{}).Error; err != nil {
t.Fatal(err)
}
if err := db.Model(&ChapterAttachment{}).Count(&chapterRows).Error; err != nil || chapterRows != 0 {
t.Fatalf("deleting a chapter must remove its attachments: %d %v", chapterRows, err)
}
if err := db.Model(&ChapterPlaybackPosition{}).Count(&positions).Error; err != nil || positions != 0 {
t.Fatalf("deleting a chapter must remove its positions: %d %v", positions, err)
}
}
func TestMigrationFromV9AddsChapterAuthor(t *testing.T) {
db := emptyMigrationDB(t)
statements := []string{"CREATE TABLE lexgo_schema (id INT PRIMARY KEY,version INT,product VARCHAR(32))", "INSERT INTO lexgo_schema VALUES (1,9,'lexgo')"}
statements = append(statements, schemaV2Statements...)
statements = append(statements, schemaV3Statements...)
statements = append(statements, schemaV4Statements...)
statements = append(statements, schemaV5Statements...)
statements = append(statements, schemaV6Statements...)
statements = append(statements, schemaV7Statements...)
statements = append(statements, schemaV8Statements...)
statements = append(statements, schemaV9Statements...)
for _, statement := range statements {
if err := db.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
// The v9 chapter table has no author column, which is what this migration adds.
if err := db.Exec("ALTER TABLE lexgo_chapters DROP COLUMN author").Error; err == nil {
// A fresh v3 table already contains the column, so it is dropped to model an older database.
t.Log("dropped the author column created by the v3 statements")
}
if err := db.Exec("INSERT INTO sys_user (user_id,username,password,role_id) VALUES (51,'fixture_v9','fictional-not-a-real-hash',2)").Error; err != nil {
t.Fatal(err)
}
saved := stamp(time.Now())
book := Book{OwnerID: 51, Title: "Fictional v9 book", Language: "en", CreatedAt: saved, UpdatedAt: saved}
if err := db.Create(&book).Error; err != nil {
t.Fatal(err)
}
text := "Curiosity opens the first door.\n"
// The row is written with raw SQL: this database models a version before the author column
// existed, so the Go struct that carries the field cannot be used yet.
if err := db.Exec("INSERT INTO lexgo_chapters (book_id, owner_id, ordinal, title, original_text, char_count, content_sha256, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)",
book.ID, 51, 1, "Fictional v9", text, len([]rune(text)), contentSHA(text), statusReady, saved, saved).Error; err != nil {
t.Fatal(err)
}
var chapter Chapter
if err := db.Where("book_id = ?", book.ID).First(&chapter).Error; err != nil {
t.Fatal(err)
}
if err := CheckSchema(db); err == nil {
t.Fatal("old schema accepted before explicit migration")
}
if err := Migrate(db); err != nil {
t.Fatal(err)
}
if err := CheckSchema(db); err != nil {
t.Fatal(err)
}
// The column exists, is empty for existing rows, and survives a rolled-back marker.
var restored Chapter
if err := db.Where("id = ?", chapter.ID).First(&restored).Error; err != nil {
t.Fatal(err)
}
if restored.Author != "" || restored.Title != chapter.Title {
t.Fatalf("migration changed the chapter: %+v", restored)
}
if err := db.Exec("UPDATE lexgo_chapters SET author = ? WHERE id = ?", "虚构作者", chapter.ID).Error; err != nil {
t.Fatal(err)
}
if err := db.Exec("UPDATE lexgo_schema SET version=9 WHERE id=1").Error; err != nil {
t.Fatal(err)
}
// Re-running the migration must not fail on the column that is already there.
if err := Migrate(db); err != nil {
t.Fatal(err)
}
if err := db.Where("id = ?", chapter.ID).First(&restored).Error; err != nil {
t.Fatal(err)
}
if restored.Author != "虚构作者" {
t.Fatalf("re-upgrade must keep the author, got %q", restored.Author)
}
}
func TestMigrationFromV10AddsBookAuthor(t *testing.T) {
db := emptyMigrationDB(t)
statements := []string{"CREATE TABLE lexgo_schema (id INT PRIMARY KEY,version INT,product VARCHAR(32))", "INSERT INTO lexgo_schema VALUES (1,10,'lexgo')"}
statements = append(statements, schemaV2Statements...)
statements = append(statements, schemaV3Statements...)
statements = append(statements, schemaV4Statements...)
statements = append(statements, schemaV5Statements...)
statements = append(statements, schemaV6Statements...)
statements = append(statements, schemaV7Statements...)
statements = append(statements, schemaV8Statements...)
statements = append(statements, schemaV9Statements...)
for _, statement := range statements {
if err := db.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
// Model the database v10 left behind: a book table without the author column.
if err := db.Exec("ALTER TABLE lexgo_books DROP COLUMN author").Error; err == nil {
t.Log("dropped the author column created by the v3 statements")
}
if err := db.Exec("INSERT INTO sys_user (user_id,username,password,role_id) VALUES (61,'fixture_v10','fictional-not-a-real-hash',2)").Error; err != nil {
t.Fatal(err)
}
saved := stamp(time.Now())
if err := db.Exec("INSERT INTO lexgo_books (id, owner_id, title, language, created_at, updated_at) VALUES (?,?,?,?,?,?)",
900, 61, "Fictional v10 book", "en", saved, saved).Error; err != nil {
t.Fatal(err)
}
if err := CheckSchema(db); err == nil {
t.Fatal("old schema accepted before explicit migration")
}
if err := Migrate(db); err != nil {
t.Fatal(err)
}
if err := CheckSchema(db); err != nil {
t.Fatal(err)
}
var book Book
if err := db.Where("id = ?", 900).First(&book).Error; err != nil {
t.Fatal(err)
}
if book.Author != "" || book.Title != "Fictional v10 book" {
t.Fatalf("migration changed the book: %+v", book)
}
if err := db.Exec("UPDATE lexgo_books SET author = ? WHERE id = ?", "虚构书籍作者", 900).Error; err != nil {
t.Fatal(err)
}
// Replaying the migration must not fail on the column that is already there.
if err := db.Exec("UPDATE lexgo_schema SET version=10 WHERE id=1").Error; err != nil {
t.Fatal(err)
}
if err := Migrate(db); err != nil {
t.Fatal(err)
}
if err := db.Where("id = ?", 900).First(&book).Error; err != nil {
t.Fatal(err)
}
if book.Author != "虚构书籍作者" {
t.Fatalf("re-upgrade must keep the book author, got %q", book.Author)
}
}
+624
View File
@@ -0,0 +1,624 @@
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",
"lexgo_book_attachments", "lexgo_playback_positions",
"lexgo_chapter_attachments", "lexgo_chapter_playback_positions",
}
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
}
// Attachments and playback positions must belong to a book that still exists.
attachments, positions := table("lexgo_book_attachments"), table("lexgo_playback_positions")
if err := linked("SELECT COUNT(*) FROM "+attachments+" a LEFT JOIN "+book+" b ON b.id = a.book_id WHERE b.id IS NULL", "附件都指向存在的书"); err != nil {
return checks, err
}
if err := linked("SELECT COUNT(*) FROM "+attachments+" a JOIN "+book+" b ON b.id = a.book_id WHERE a.owner_id <> b.owner_id", "附件归属与书归属一致"); err != nil {
return checks, err
}
if err := linked("SELECT COUNT(*) FROM "+positions+" p LEFT JOIN "+book+" b ON b.id = p.book_id WHERE b.id IS NULL", "播放位置都指向存在的书"); err != nil {
return checks, err
}
// Chapter attachments and their positions must belong to a chapter that still exists.
chapter_attachments, chapter_positions := table("lexgo_chapter_attachments"), table("lexgo_chapter_playback_positions")
if err := linked("SELECT COUNT(*) FROM "+chapter_attachments+" a LEFT JOIN "+chapter+" c ON c.id = a.chapter_id WHERE c.id IS NULL", "章节附件都指向存在的章节"); err != nil {
return checks, err
}
if err := linked("SELECT COUNT(*) FROM "+chapter_attachments+" a JOIN "+chapter+" c ON c.id = a.chapter_id WHERE a.owner_id <> c.owner_id", "章节附件归属与章节归属一致"); err != nil {
return checks, err
}
if err := linked("SELECT COUNT(*) FROM "+chapter_positions+" p LEFT JOIN "+chapter+" c ON c.id = p.chapter_id WHERE c.id IS NULL", "章节播放位置都指向存在的章节"); 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")
}
}
+246
View File
@@ -0,0 +1,246 @@
package lexgo
import (
"sort"
"strings"
"time"
"unicode/utf8"
"github.com/gin-gonic/gin"
admin "go-admin/app/admin/models"
"gorm.io/gorm"
)
// A phrase is a run of consecutive words. Its identity is the ordered normalized word forms
// joined by single spaces, so interior punctuation, line breaks and extra whitespace never
// split one phrase into two records, and a phrase key can never collide with a word key.
const (
maxPhraseWords = 12
minPhraseWords = 2
maxPhraseKey = 128
maxPhraseSource = 191
)
// PhraseSpan is one saved phrase occurrence inside a chapter, expressed in token indices so
// the reader can style a run without re-deriving the match.
type PhraseSpan struct {
ID int64 `json:"id"`
Status string `json:"status"`
WordCount int `json:"wordCount"`
StartToken int `json:"startToken"`
EndToken int `json:"endToken"`
}
// phraseWords returns the word tokens fully inside a requested code point range. A range that
// cuts into a word is rejected instead of silently dropping it, so the client always learns
// that its selection was not a whole-word range.
func phraseWords(tokens []TextToken, start, end int) ([]TextToken, error) {
bad := failure(400, "请选择至少两个连续的完整单词")
if start < 0 || end <= start {
return nil, bad
}
words := make([]TextToken, 0, 4)
for _, token := range tokens {
if token.End <= start || token.Start >= end {
continue
}
if token.Kind != "word" {
continue
}
if token.Start < start || token.End > end {
return nil, failure(400, "请选择完整的单词,不要切在单词中间")
}
words = append(words, token)
}
if len(words) < minPhraseWords {
return nil, bad
}
if len(words) > maxPhraseWords {
return nil, failure(400, "短语最多 12 个单词")
}
return words, nil
}
// phraseKey is the identity of a phrase: the ordered normalized word forms joined by spaces.
func phraseKey(words []TextToken) string {
forms := make([]string, 0, len(words))
for _, word := range words {
forms = append(forms, normalizeWord(word.Text))
}
return strings.Join(forms, " ")
}
// phraseSource is the exact original text of the selection, punctuation and line breaks
// included, used for display only.
func phraseSource(text string, words []TextToken) string {
if len(words) == 0 {
return ""
}
runes := []rune(text)
start, end := words[0].Start, words[len(words)-1].End
if start < 0 || end > len(runes) || start >= end {
return ""
}
return string(runes[start:end])
}
// phraseMatches finds every saved phrase occurrence in a chapter. Candidates are ordered by
// their start and then longest first, and the leftmost-longest non-overlapping set is kept,
// which is the display rule #4 verified; nothing stored is modified by matching.
func phraseMatches(tokens []TextToken, phrases []Term) []PhraseSpan {
if len(phrases) == 0 {
return []PhraseSpan{}
}
// Index the phrases by their first word so a chapter only compares what can match.
words := make([]int, 0, len(tokens))
for index, token := range tokens {
if token.Kind == "word" {
words = append(words, index)
}
}
byFirst := map[string][]Term{}
for _, phrase := range phrases {
forms := strings.Split(phrase.Term, " ")
if len(forms) < minPhraseWords {
continue
}
byFirst[forms[0]] = append(byFirst[forms[0]], phrase)
}
type candidate struct {
startToken int
endToken int
phrase Term
}
found := make([]candidate, 0, 8)
for position, tokenIndex := range words {
first := normalizeWord(tokens[tokenIndex].Text)
for _, phrase := range byFirst[first] {
forms := strings.Split(phrase.Term, " ")
if position+len(forms) > len(words) {
continue
}
matched := true
for offset, form := range forms {
if normalizeWord(tokens[words[position+offset]].Text) != form {
matched = false
break
}
}
if matched {
found = append(found, candidate{words[position], words[position+len(forms)-1], phrase})
}
}
}
sort.SliceStable(found, func(i, j int) bool {
if found[i].startToken != found[j].startToken {
return found[i].startToken < found[j].startToken
}
if found[i].endToken != found[j].endToken {
return found[i].endToken > found[j].endToken
}
return found[i].phrase.ID < found[j].phrase.ID
})
spans := make([]PhraseSpan, 0, len(found))
lastEnd := -1
for _, item := range found {
if item.startToken <= lastEnd {
continue
}
spans = append(spans, PhraseSpan{
ID: item.phrase.ID, Status: item.phrase.Status, WordCount: termWordCount(item.phrase.Term),
StartToken: item.startToken, EndToken: item.endToken,
})
lastEnd = item.endToken
}
return spans
}
// phrasesForChapter loads the caller's phrases and matches them against one chapter.
func phrasesForChapter(tx *gorm.DB, owner int, language string, tokens []TextToken) ([]PhraseSpan, error) {
var phrases []Term
// A phrase key always contains a space and a word key never does, so this is the whole
// filter; it needs no column.
if err := tx.Select("id", "term", "status").
Where("owner_id = ? AND language = ? AND term LIKE ?", owner, language, "% %").
Find(&phrases).Error; err != nil {
return nil, err
}
return phraseMatches(tokens, phrases), nil
}
type PhraseInput struct {
ChapterID int64 `json:"chapterId"`
Start *int `json:"start"`
End *int `json:"end"`
Definition string `json:"definition"`
Examples []string `json:"examples"`
Status string `json:"status"`
Level *int `json:"level"`
}
// SavePhrase derives the identity from the server's own tokens of an owned ready chapter, so a
// client can neither name a phrase it did not select nor forge an owner or language. Saving
// reuses the term table and its idempotent upsert, so one phrase stays one record with one
// review schedule.
func SavePhrase(tx *gorm.DB, owner int, language string, input PhraseInput, now time.Time) (TermSave, error) {
if input.Start == nil || input.End == nil {
return TermSave{}, failure(400, "请选择至少两个连续的完整单词")
}
chapter, err := readyOwnedChapter(tx, owner, input.ChapterID)
if err != nil {
return TermSave{}, err
}
words, err := phraseWords(Tokenize(chapter.OriginalText), *input.Start, *input.End)
if err != nil {
return TermSave{}, err
}
key := phraseKey(words)
if utf8.RuneCountInString(key) > maxPhraseKey {
return TermSave{}, failure(400, "短语过长,请缩短选择范围")
}
source := phraseSource(chapter.OriginalText, words)
if source == "" || utf8.RuneCountInString(source) > maxPhraseSource {
return TermSave{}, failure(400, "短语原文过长,请缩短选择范围")
}
level, err := termLevel(input.Status, input.Level, Term{}, false)
if err != nil {
return TermSave{}, err
}
definition, examples, err := termContent(input.Definition, input.Examples)
if err != nil {
return TermSave{}, err
}
previous, exists, err := previousTerm(tx, owner, language, key)
if err != nil {
return TermSave{}, err
}
if exists && termKind(previous.Term) != termKindPhrase {
return TermSave{}, failure(409, "该内容已被记录为单词")
}
if exists {
level, err = termLevel(input.Status, input.Level, previous, true)
if err != nil {
return TermSave{}, err
}
}
fields := TermFields{
Definition: definition, Examples: examples, Status: input.Status, Level: level,
PreviousStatus: previous.Status, PreviousLevel: previous.Level, Exists: exists,
}
// The identity is the word-form key; the original selection is only the display text.
return saveTerm(tx, owner, language, key, source, fields, now)
}
func registerPhraseRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context, *gorm.DB, admin.SysUser) (any, error)) gin.HandlerFunc, now func() time.Time) {
v.POST("/phrases", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
var input PhraseInput
if err := decode(c, &input); err != nil {
return nil, err
}
language, err := languageOf(tx, u.UserId)
if err != nil {
return nil, err
}
return SavePhrase(tx, u.UserId, language, input, now())
}))
}
+415
View File
@@ -0,0 +1,415 @@
package lexgo
import (
"encoding/json"
"fmt"
"strings"
"testing"
)
// phraseFixtureText holds the cases #4 verified: interior punctuation, a line break inside a
// phrase, a repeated phrase and a combining accent.
const phraseFixtureText = "Take a small, step\nevery day.\nMira took a small step again.\n"
func phraseTokens(text string) []TextToken {
return Tokenize(text)
}
func spanOf(t *testing.T, tokens []TextToken, words ...string) (int, int) {
t.Helper()
var first, last TextToken
found := 0
for _, token := range tokens {
if token.Kind != "word" {
continue
}
if found < len(words) && normalizeWord(token.Text) == normalizeWord(words[found]) {
if found == 0 {
first = token
}
last = token
found++
}
}
if found != len(words) {
t.Fatalf("could not find %v in %q", words, text(tokens))
}
return first.Start, last.End
}
func text(tokens []TextToken) string {
var builder strings.Builder
for _, token := range tokens {
builder.WriteString(token.Text)
}
return builder.String()
}
// TestPhraseIdentityRules pins the range and identity rules of a phrase.
func TestPhraseIdentityRules(t *testing.T) {
tokens := phraseTokens(phraseFixtureText)
// Interior punctuation and a line break stay in the display text but not in the key.
start, end := spanOf(t, tokens, "a", "small", "step")
words, err := phraseWords(tokens, start, end)
if err != nil || len(words) != 3 {
t.Fatalf("phrase words: %v %v", words, err)
}
if key := phraseKey(words); key != "a small step" {
t.Fatalf("key %q, want %q", key, "a small step")
}
if source := phraseSource(phraseFixtureText, words); source != "a small, step\nevery day."[:0]+"a small, step" {
t.Fatalf("source %q", source)
}
// The same words separated by different punctuation are one identity.
secondTokens := phraseTokens("Mira took a small step again.")
secondStart, secondEnd := spanOf(t, secondTokens, "a", "small", "step")
other, err := phraseWords(secondTokens, secondStart, secondEnd)
if err != nil {
t.Fatal(err)
}
if phraseKey(other) != "a small step" {
t.Fatalf("a differently spaced phrase must share the key: %q", phraseKey(other))
}
// Case and curly apostrophes fold, exactly like word identity.
if key := phraseKey(mustWords(t, "Don’t Look Back")); key != "don't look back" {
t.Fatalf("normalized phrase key: %q", key)
}
// Endpoint alignment: a range that cuts into a word is refused instead of dropping it.
if _, err := phraseWords(tokens, start+3, end); err == nil {
t.Fatal("a range cutting into a word must be rejected")
}
if _, err := phraseWords(tokens, start, end-2); err == nil {
t.Fatal("a range cutting into the last word must be rejected")
}
// A range that starts on a separator simply begins at the next whole word.
trimmed, err := phraseWords(tokens, start+1, end)
if err != nil || len(trimmed) != 2 || normalizeWord(trimmed[0].Text) != "small" {
t.Fatalf("a separator boundary must select whole words: %v %v", trimmed, err)
}
// Surrounding whitespace and punctuation are allowed on both ends.
if _, err := phraseWords(tokens, start-1, end+1); err != nil {
t.Fatalf("surrounding separators must be accepted: %v", err)
}
// A single word, an empty range and a punctuation-only range are not phrases.
if _, err := phraseWords(tokens, words[0].Start, words[0].End); err == nil {
t.Fatal("one word is not a phrase")
}
if _, err := phraseWords(tokens, 0, 0); err == nil {
t.Fatal("an empty range is not a phrase")
}
if _, err := phraseWords(tokens, -1, 4); err == nil {
t.Fatal("a negative range is not a phrase")
}
// Limits: at most 12 words, and a key within the stored column.
long := make([]TextToken, 0, maxPhraseWords+1)
position := 0
for index := 0; index < maxPhraseWords+1; index++ {
word := "word"
long = append(long, TextToken{Text: word, Start: position, End: position + len(word), Kind: "word"})
position += len(word) + 1
}
if _, err := phraseWords(long, 0, position); err == nil {
t.Fatal("more than 12 words must be rejected")
}
if _, err := phraseWords(long[:maxPhraseWords], 0, len(long[maxPhraseWords-1].Text)+long[maxPhraseWords-1].Start); err != nil {
t.Fatalf("exactly 12 words must be accepted: %v", err)
}
}
// TestWordKeysNeverContainSpaces locks the premise the derived kind relies on: a word token
// never contains a space, so the identity key alone tells a word from a phrase and no column is
// needed for it.
func TestWordKeysNeverContainSpaces(t *testing.T) {
samples := []string{phraseFixtureText, "a b\tc\r\nd", "don’t stop-BELIEVING.", "🙂 café e\u0301 42"}
checked := 0
for _, sample := range samples {
for _, token := range Tokenize(sample) {
if token.Kind != "word" {
continue
}
checked++
if strings.ContainsAny(normalizeWord(token.Text), " \t\r\n") {
t.Fatalf("word key %q contains whitespace", normalizeWord(token.Text))
}
}
}
if checked < 8 {
t.Fatalf("expected several word tokens, checked %d", checked)
}
// The derived kind therefore agrees with the shape of the key in both directions.
if termKind("a small step") != termKindPhrase || termWordCount("a small step") != 3 {
t.Fatal("a key with spaces must be a three-word phrase")
}
if termKind("curiosity") != termKindWord || termWordCount("curiosity") != 1 {
t.Fatal("a key without spaces must be a word")
}
}
func mustWords(t *testing.T, text string) []TextToken {
t.Helper()
words, err := phraseWords(phraseTokens(text), 0, len([]rune(text)))
if err != nil {
t.Fatal(err)
}
return words
}
// TestPhraseMatchingAndOverlap pins the cross-chapter match and the leftmost-longest display.
func TestPhraseMatchingAndOverlap(t *testing.T) {
tokens := phraseTokens(phraseFixtureText)
step := Term{ID: 1, Term: "a small step", Status: termStatusNew}
small := Term{ID: 2, Term: "small step", Status: termStatusLearning}
spans := phraseMatches(tokens, []Term{step, small})
if len(spans) != 2 {
t.Fatalf("expected one occurrence of each phrase, got %+v", spans)
}
// The longer phrase starts first, so it wins and the shorter one is dropped where they
// overlap; the later occurrence of "a small step" is a separate match.
if spans[0].ID != step.ID || spans[1].ID != step.ID {
t.Fatalf("leftmost-longest rule: %+v", spans)
}
if spans[0].StartToken >= spans[1].StartToken {
t.Fatalf("spans must be ordered: %+v", spans)
}
// A phrase that no longer occurs is simply absent, and nothing is mutated.
if len(phraseMatches(phraseTokens("Nothing to see here."), []Term{step})) != 0 {
t.Fatal("a phrase that does not occur must not match")
}
// Two different phrases that both match at the same place keep a stable choice.
spans = phraseMatches(tokens, []Term{small, step})
if len(spans) != 2 || spans[0].ID != step.ID {
t.Fatalf("order of input must not change the display: %+v", spans)
}
// A single-word entry never participates as a phrase.
if len(phraseMatches(tokens, []Term{{ID: 9, Term: "step"}})) != 0 {
t.Fatal("a single-word key is not a phrase match")
}
}
// TestMySQLPhraseSaveAndCrossChapterHighlight is the core path: saving a phrase, finding it
// again in another chapter, and keeping one record per identity.
func TestMySQLPhraseSaveAndCrossChapterHighlight(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
other := newLearner(t, r, owner.Token)
drainIngest(t, db)
code, first := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "phrase-fixture-0001", "title": "Phrase chapter", "text": phraseFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
// A second chapter with the same phrase in a different form.
code, second := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "phrase-fixture-0002", "title": "Another chapter", "text": "Mira took a small step again.\n", "language": "en"})
if code != 201 {
t.Fatalf("second paste %d", code)
}
drainIngest(t, db)
tokens := phraseTokens(phraseFixtureText)
start, end := spanOf(t, tokens, "a", "small", "step")
body := map[string]any{
"chapterId": first.Chapter.ID, "start": start, "end": end,
"definition": "一小步", "examples": []string{"Take a small step, every day."}, "status": termStatusNew,
}
code, msg, data := callRaw(t, r, "POST", "/api/v1/phrases", learner.Token, body)
if code != 201 {
t.Fatalf("save phrase %d (%s)", code, msg)
}
var saved struct {
Term TermView
Created bool
}
json.Unmarshal(data, &saved)
if saved.Term.Kind != "phrase" || saved.Term.WordCount != 3 || saved.Term.Term != "a small step" {
t.Fatalf("saved phrase %+v", saved.Term)
}
if saved.Term.OriginalForm != "a small, step" {
t.Fatalf("the display text keeps the original punctuation: %q", saved.Term.OriginalForm)
}
// The same phrase saved from the other chapter, written differently, stays one record.
otherTokens := phraseTokens("Mira took a small step again.\n")
otherStart, otherEnd := spanOf(t, otherTokens, "a", "small", "step")
code, msg, data = callRaw(t, r, "POST", "/api/v1/phrases", learner.Token, map[string]any{
"chapterId": second.Chapter.ID, "start": otherStart, "end": otherEnd, "definition": "一小步(更新)", "status": termStatusNew,
})
if code != 200 {
t.Fatalf("repeat phrase %d (%s)", code, msg)
}
var again struct {
Term TermView
Created bool
}
json.Unmarshal(data, &again)
if again.Created || again.Term.ID != saved.Term.ID || again.Term.OriginalForm != "a small step" {
t.Fatalf("one phrase must stay one record: %+v", again)
}
var count int64
// A phrase key always contains a space, so the key shape is the whole filter.
db.Model(&Term{}).Where("owner_id = ? AND term LIKE ?", learner.ID, "% %").Count(&count)
if count != 1 {
t.Fatalf("phrase records: %d", count)
}
// Both chapters highlight the phrase, including the one where it was not saved. The first
// chapter holds the phrase twice with different interior punctuation.
wantOccurrences := map[int64]int{first.Chapter.ID: 2, second.Chapter.ID: 1}
for chapterID, want := range wantOccurrences {
code, _, data = callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d/tokens", chapterID), learner.Token, nil)
var analyzed ChapterTokens
json.Unmarshal(data, &analyzed)
if code != 200 || len(analyzed.Phrases) != want {
t.Fatalf("chapter %d phrases: %d %+v", chapterID, code, analyzed.Phrases)
}
for _, span := range analyzed.Phrases {
if span.ID != saved.Term.ID || span.WordCount != 3 || span.EndToken <= span.StartToken {
t.Fatalf("chapter %d span %+v", chapterID, span)
}
if analyzed.Tokens[span.StartToken].Kind != "word" || analyzed.Tokens[span.EndToken].Kind != "word" {
t.Fatalf("a phrase must span words: %+v", span)
}
}
}
// Another account sees no phrase at all.
code, _, data = callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d/tokens", first.Chapter.ID), other.Token, nil)
if code != 404 {
t.Fatalf("another account reading this chapter: %d", code)
}
// The phrase joins the review queue with its kind, and answers like a word.
code, queue := reviewQueue(t, r, learner.Token)
if code != 200 || len(queue.Items) != 1 || queue.Items[0].Kind != "phrase" || queue.Items[0].WordCount != 3 {
t.Fatalf("phrase queue: %d %+v", code, queue.Items)
}
code, applied := answerReview(t, r, learner.Token, saved.Term.ID, answerBody("phrase-answer-0001", reviewGradeCorrect, queue.Items[0].DueAt))
if code != 201 || applied.Result != "applied" || applied.LevelAfter != 1 || applied.Item.Kind != "phrase" {
t.Fatalf("phrase answer: %d %+v", code, applied)
}
// The word-level highlight is unaffected: the same words are still ordinary words.
code, _, data = callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d/tokens", first.Chapter.ID), learner.Token, nil)
var withPhrase ChapterTokens
json.Unmarshal(data, &withPhrase)
for _, index := range []int{withPhrase.Phrases[0].StartToken, withPhrase.Phrases[0].EndToken} {
if withPhrase.Tokens[index].Term != nil {
t.Fatal("a phrase must not create word-level records")
}
}
}
// TestMySQLPhraseRulesAndIsolation covers the rejected shapes and ownership.
func TestMySQLPhraseRulesAndIsolation(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
other := newLearner(t, r, owner.Token)
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "phrase-rules-0001", "title": "Phrase rules", "text": phraseFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
drainIngest(t, db)
tokens := phraseTokens(phraseFixtureText)
start, end := spanOf(t, tokens, "a", "small", "step")
singleStart, singleEnd := wordsAt(t, tokens, "a")
base := map[string]any{"chapterId": pasted.Chapter.ID, "start": start, "end": end, "status": termStatusNew}
if code, _, _ := callRaw(t, r, "POST", "/api/v1/phrases", other.Token, base); code != 404 {
t.Fatal("another account must not save a phrase in this chapter")
}
if code, _, _ := callRaw(t, r, "POST", "/api/v1/phrases", "", base); code != 401 {
t.Fatal("saving a phrase needs a session")
}
invalid := []map[string]any{
{"chapterId": pasted.Chapter.ID, "start": singleStart, "end": singleEnd, "status": termStatusNew},
{"chapterId": pasted.Chapter.ID, "end": end, "status": termStatusNew},
{"chapterId": pasted.Chapter.ID, "start": start + 3, "end": end, "status": termStatusNew},
{"chapterId": pasted.Chapter.ID, "start": start, "end": end, "status": "deleted"},
{"chapterId": pasted.Chapter.ID, "start": start, "end": end, "status": termStatusKnown, "level": 3},
{"chapterId": pasted.Chapter.ID, "start": start, "end": end, "status": termStatusNew, "definition": strings.Repeat("a", termDefinitionLimit+1)},
{"chapterId": pasted.Chapter.ID, "start": start, "end": end, "status": termStatusNew, "term": "forged"},
{"chapterId": pasted.Chapter.ID, "start": start, "end": end, "status": termStatusNew, "ownerId": 9},
{"chapterId": 999999, "start": start, "end": end, "status": termStatusNew},
}
for _, body := range invalid {
if code, msg, _ := callRaw(t, r, "POST", "/api/v1/phrases", learner.Token, body); code != 400 && code != 404 {
t.Fatalf("invalid phrase %v -> %d (%s)", body, code, msg)
}
}
var count int64
db.Model(&Term{}).Where("owner_id = ?", learner.ID).Count(&count)
if count != 0 {
t.Fatalf("a rejected phrase was stored: %d", count)
}
// A phrase in a chapter that is not ready is refused, exactly like a word.
if err := db.Model(&Chapter{}).Where("id = ?", pasted.Chapter.ID).Update("status", statusPending).Error; err != nil {
t.Fatal(err)
}
if code, _, _ := callRaw(t, r, "POST", "/api/v1/phrases", learner.Token, base); code != 409 {
t.Fatal("a pending chapter must refuse a phrase")
}
}
func wordsAt(t *testing.T, tokens []TextToken, word string) (int, int) {
t.Helper()
start, end := spanOf(t, tokens, word)
return start, end
}
// TestMySQLPhraseSurvivesTextEditsAndDeletion locks the fallback rule for stale references.
func TestMySQLPhraseSurvivesTextEditsAndDeletion(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "phrase-survive-0001", "title": "Phrase survival", "text": phraseFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
drainIngest(t, db)
tokens := phraseTokens(phraseFixtureText)
start, end := spanOf(t, tokens, "a", "small", "step")
code, msg, data := callRaw(t, r, "POST", "/api/v1/phrases", learner.Token, map[string]any{
"chapterId": pasted.Chapter.ID, "start": start, "end": end, "definition": "一小步", "status": termStatusNew})
if code != 201 {
t.Fatalf("save phrase %d (%s)", code, msg)
}
var saved struct {
Term TermView
}
json.Unmarshal(data, &saved)
// Editing the text so the phrase no longer occurs: the entry and its schedule stay.
if code, msg, _ := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), map[string]string{"text": "Nothing left of it.\n"}); code != 200 {
t.Fatalf("edit chapter %d (%s)", code, msg)
}
drainIngest(t, db)
code, _, data = callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d/tokens", pasted.Chapter.ID), learner.Token, nil)
var analyzed ChapterTokens
json.Unmarshal(data, &analyzed)
if code != 200 || len(analyzed.Phrases) != 0 {
t.Fatalf("a phrase that no longer occurs must not highlight: %d %+v", code, analyzed.Phrases)
}
if code, _, _ := callRaw(t, r, "GET", fmt.Sprintf("/api/v1/terms/%d", saved.Term.ID), learner.Token, nil); code != 200 {
t.Fatal("the entry must survive a text edit")
}
code, queue := reviewQueue(t, r, learner.Token)
if code != 200 || len(queue.Items) != 1 {
t.Fatalf("the schedule must survive a text edit: %d %+v", code, queue.Items)
}
// Deleting the chapter keeps the entry and its schedule too.
if code, msg, _ := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), learner.Token, nil); code != 200 {
t.Fatalf("delete chapter %d (%s)", code, msg)
}
if code, _, _ := callRaw(t, r, "GET", fmt.Sprintf("/api/v1/terms/%d", saved.Term.ID), learner.Token, nil); code != 200 {
t.Fatal("the entry must survive a chapter deletion")
}
code, queue = reviewQueue(t, r, learner.Token)
if code != 200 || len(queue.Items) != 1 || queue.Items[0].Kind != "phrase" {
t.Fatalf("the schedule must survive a chapter deletion: %d %+v", code, queue.Items)
}
}
+244
View File
@@ -0,0 +1,244 @@
package lexgo
import (
"errors"
"time"
"github.com/gin-gonic/gin"
admin "go-admin/app/admin/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// ChapterProgress is one learner's reading completion for one chapter. The chapter id is the
// primary key: a chapter belongs to exactly one owner, so repeating the completion updates the
// same row instead of adding a second one. read_sha256 snapshots the content that was actually
// read, which is what keeps the counters honest after the text is replaced.
type ChapterProgress struct {
ChapterID int64 `gorm:"primaryKey"`
OwnerID int
BookID int64
Language string
ReadSHA256 string
ReadAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
func (ChapterProgress) TableName() string { return "lexgo_chapter_progress" }
// CompletionResult answers a completion request. Duplicate reports that this chapter already
// held a mark for the same content, so nothing moved and the counters did not change.
type CompletionResult struct {
ChapterID int64 `json:"chapterId"`
BookID int64 `json:"bookId"`
Read bool `json:"read"`
ReadAt time.Time `json:"readAt"`
Duplicate bool `json:"duplicate"`
}
// CompleteChapter marks one owned chapter as read. Only a ready chapter can be completed: a
// chapter that is still processing or failed was never readable, so a mark would be a claim
// about text nobody saw. Completing a chapter never changes word status or levels, which is the
// boundary between F11 and the X10 batch-to-known switch.
func CompleteChapter(tx *gorm.DB, owner int, chapterID int64, now time.Time) (CompletionResult, error) {
ts := stamp(now)
var result CompletionResult
err := tx.Transaction(func(inner *gorm.DB) error {
var chapter Chapter
if err := inner.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND owner_id = ?", chapterID, owner).First(&chapter).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return failure(404, "章节不存在")
}
return err
}
if chapter.Status != statusReady {
return failure(409, "只有处理完成的章节可以标记已读")
}
language, err := languageOf(inner, owner)
if err != nil {
return err
}
var existing ChapterProgress
err = inner.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("chapter_id = ?", chapter.ID).First(&existing).Error
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
row := ChapterProgress{ChapterID: chapter.ID, OwnerID: owner, BookID: chapter.BookID,
Language: language, ReadSHA256: chapter.ContentSHA256, ReadAt: ts, CreatedAt: ts, UpdatedAt: ts}
if err := inner.Create(&row).Error; err != nil {
return err
}
result = CompletionResult{ChapterID: chapter.ID, BookID: chapter.BookID, Read: true, ReadAt: ts}
case err != nil:
return err
default:
if existing.OwnerID != owner {
return failure(404, "章节不存在")
}
// The same content marked again keeps the original date, so a repeated request cannot
// move the timestamp or count twice.
if existing.ReadSHA256 == chapter.ContentSHA256 {
result = CompletionResult{ChapterID: chapter.ID, BookID: chapter.BookID, Read: true,
ReadAt: existing.ReadAt, Duplicate: true}
return nil
}
// A new content version was read: the one row moves forward to the text the person
// just finished instead of accumulating a second completion.
if err := inner.Model(&ChapterProgress{}).Where("chapter_id = ?", chapter.ID).
Updates(map[string]any{"read_sha256": chapter.ContentSHA256, "read_at": ts, "updated_at": ts,
"book_id": chapter.BookID, "language": language}).Error; err != nil {
return err
}
result = CompletionResult{ChapterID: chapter.ID, BookID: chapter.BookID, Read: true, ReadAt: ts}
}
return nil
})
if err != nil {
return CompletionResult{}, err
}
return result, nil
}
// readAtByChapter lists the chapters of one owner whose completion still matches the content
// they hold and which are ready to read. A chapter edited into a newer version therefore reads
// as unread again without deleting the row, and a chapter being reprocessed carries no mark.
func readAtByChapter(db *gorm.DB, owner int, chapterIDs []int64) (map[int64]time.Time, error) {
marks := make(map[int64]time.Time)
if len(chapterIDs) == 0 {
return marks, nil
}
var rows []struct {
ChapterID int64
ReadAt time.Time
ReadSHA256 string
ContentSHA256 string
}
if err := db.Table("lexgo_chapter_progress AS p").
Select("p.chapter_id, p.read_at, p.read_sha256, c.content_sha256").
Joins("JOIN lexgo_chapters AS c ON c.id = p.chapter_id").
Where("p.owner_id = ? AND c.owner_id = ? AND c.status = ? AND p.chapter_id IN ?", owner, owner, statusReady, chapterIDs).
Scan(&rows).Error; err != nil {
return marks, err
}
for _, row := range rows {
if row.ReadSHA256 == row.ContentSHA256 {
marks[row.ChapterID] = row.ReadAt
}
}
return marks, nil
}
// BookProgress is one book's share of the reading total.
type BookProgress struct {
ID int64 `json:"id"`
Title string `json:"title"`
ReadChapters int `json:"readChapters"`
TotalChapters int `json:"totalChapters"`
}
// LearnerProgress is the learner's own basic progress for the current language. Reading counts
// only chapters that can actually be read, and the word counts keep the four statuses apart so
// "known" and "ignored" are never merged into one number.
type LearnerProgress struct {
ReadChapters int `json:"readChapters"`
TotalChapters int `json:"totalChapters"`
KnownTerms int `json:"knownTerms"`
LearningTerms int `json:"learningTerms"`
NewTerms int `json:"newTerms"`
IgnoredTerms int `json:"ignoredTerms"`
SavedTerms int `json:"savedTerms"`
DueNow int `json:"dueNow"`
Books []BookProgress `json:"books"`
}
// LearnerProgressFor counts the caller's own reading and word state for one language. Every
// number is owner and language scoped; an administrator role does not widen the filter. The due
// count uses the same query builder as the review queue, so the progress page and the queue
// cannot drift apart.
func LearnerProgressFor(db *gorm.DB, owner int, language string, now time.Time) (LearnerProgress, error) {
progress := LearnerProgress{Books: []BookProgress{}}
// Reading covers ready chapters of the owner's books in this language. A chapter still being
// processed is excluded from both sides of the ratio instead of making the total unreachable.
readable := func() *gorm.DB {
return db.Table("lexgo_chapters AS c").
Joins("JOIN lexgo_books AS b ON b.id = c.book_id").
Where("c.owner_id = ? AND b.owner_id = ? AND b.language = ?", owner, owner, language).
Where("c.status = ?", statusReady)
}
completions := "LEFT JOIN lexgo_chapter_progress AS p ON p.chapter_id = c.id AND p.owner_id = c.owner_id AND p.read_sha256 = c.content_sha256"
readSum := "COALESCE(SUM(CASE WHEN p.chapter_id IS NULL THEN 0 ELSE 1 END),0) AS chapter_read"
var totals struct {
Total int
Read int `gorm:"column:chapter_read"`
}
if err := readable().Select("COUNT(*) AS total, " + readSum).Joins(completions).Scan(&totals).Error; err != nil {
return progress, err
}
progress.TotalChapters, progress.ReadChapters = totals.Total, totals.Read
var books []struct {
ID int64
Title string
Total int
Read int `gorm:"column:chapter_read"`
}
if err := readable().
Select("b.id, b.title, COUNT(*) AS total, " + readSum).
Joins(completions).Group("b.id, b.title").Order("b.id ASC").Scan(&books).Error; err != nil {
return progress, err
}
for _, book := range books {
progress.Books = append(progress.Books, BookProgress{ID: book.ID, Title: book.Title,
ReadChapters: book.Read, TotalChapters: book.Total})
}
var counts []struct {
Status string
Total int
}
if err := db.Table("lexgo_terms").Select("status, COUNT(*) AS total").
Where("owner_id = ? AND language = ?", owner, language).
Group("status").Scan(&counts).Error; err != nil {
return progress, err
}
for _, row := range counts {
progress.SavedTerms += row.Total
switch row.Status {
case termStatusKnown:
progress.KnownTerms = row.Total
case termStatusLearning:
progress.LearningTerms = row.Total
case termStatusNew:
progress.NewTerms = row.Total
case termStatusIgnored:
progress.IgnoredTerms = row.Total
}
}
var due int64
if err := dueTermsQuery(db, owner, language, now).Count(&due).Error; err != nil {
return progress, err
}
progress.DueNow = int(due)
return progress, nil
}
func registerProgressRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context, *gorm.DB, admin.SysUser) (any, error)) gin.HandlerFunc, now func() time.Time) {
v.POST("/chapters/:id/complete", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
id, err := pathID(c, "章节不存在")
if err != nil {
return nil, err
}
result, err := CompleteChapter(tx, u.UserId, id, now())
if err != nil {
return nil, err
}
return gin.H{"progress": result}, nil
}))
v.GET("/progress", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
language, err := languageOf(tx, u.UserId)
if err != nil {
return nil, err
}
return LearnerProgressFor(tx, u.UserId, language, now())
}))
}
+360
View File
@@ -0,0 +1,360 @@
package lexgo
import (
"encoding/json"
"fmt"
"testing"
"time"
"github.com/gin-gonic/gin"
admin "go-admin/app/admin/models"
"gorm.io/gorm"
)
func completeChapter(t *testing.T, r *gin.Engine, token string, chapterID int64) (int, CompletionResult) {
t.Helper()
code, data := callAPI(t, r, "POST", fmt.Sprintf("/api/v1/chapters/%d/complete", chapterID), token, nil)
var payload struct {
Progress CompletionResult
}
if data != nil {
json.Unmarshal(data, &payload)
}
return code, payload.Progress
}
func learnerProgress(t *testing.T, r *gin.Engine, token string) (int, LearnerProgress) {
t.Helper()
code, data := callAPI(t, r, "GET", "/api/v1/progress", token, nil)
var progress LearnerProgress
if data != nil {
json.Unmarshal(data, &progress)
}
return code, progress
}
// bookChapters reads one book through the API so the test sees the same read state a page sees.
func bookChapters(t *testing.T, r *gin.Engine, token string, bookID int64) map[int64]*time.Time {
t.Helper()
code, data := callAPI(t, r, "GET", fmt.Sprintf("/api/v1/books/%d", bookID), token, nil)
if code != 200 {
t.Fatalf("book detail %d", code)
}
var payload struct {
Chapters []ChapterSummary
}
if err := json.Unmarshal(data, &payload); err != nil {
t.Fatalf("book detail payload: %v", err)
}
marks := make(map[int64]*time.Time, len(payload.Chapters))
for _, chapter := range payload.Chapters {
marks[chapter.ID] = chapter.ReadAt
}
return marks
}
func chapterProgressRows(t *testing.T, db *gorm.DB, chapterID int64) int64 {
t.Helper()
var rows int64
if err := db.Model(&ChapterProgress{}).Where("chapter_id = ?", chapterID).Count(&rows).Error; err != nil {
t.Fatal(err)
}
return rows
}
// TestMySQLChapterCompletionIsIdempotentAndContentBound covers marking a chapter read: one row
// per chapter, a stable timestamp on a repeat, and a new content version reading as unread.
func TestMySQLChapterCompletionIsIdempotentAndContentBound(t *testing.T) {
db := testDB(t)
clock := time.Now().UTC().Truncate(time.Millisecond)
r := Router(db, func() time.Time { return clock })
learner := admin.SysUser{Username: randomName("prog"), Password: fixturePassword, RoleId: 2, Status: "2"}
if err := db.Create(&learner).Error; err != nil {
t.Fatal(err)
}
token := loginToken(t, r, learner.Username, fixturePassword)
paste := func(requestID, text string) pasteResponse {
code, pasted := pasteBook(t, r, token, map[string]string{"requestId": requestID, "title": "Fictional progress", "text": text, "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
drainIngest(t, db)
return pasted
}
first := paste("progress-complete-01", "Curiosity opens the first door.\n")
second := paste("progress-complete-02", "A second chapter waits here.\n")
// A ready chapter can be completed, and the response describes the new mark.
code, done := completeChapter(t, r, token, first.Chapter.ID)
if code != 200 || !done.Read || done.Duplicate || done.ChapterID != first.Chapter.ID {
t.Fatalf("first completion: %d %+v", code, done)
}
if !done.ReadAt.Equal(stamp(clock)) {
t.Fatalf("read at %v, want %v", done.ReadAt, stamp(clock))
}
rows := chapterProgressRows(t, db, first.Chapter.ID)
if rows != 1 {
t.Fatalf("one chapter must hold one completion row, got %d", rows)
}
// Repeating the request answers the same way and does not move the stored timestamp, even
// after the clock moved on.
clock = clock.Add(5 * time.Minute)
code, again := completeChapter(t, r, token, first.Chapter.ID)
if code != 200 || !again.Duplicate || !again.ReadAt.Equal(done.ReadAt) {
t.Fatalf("repeated completion: %d %+v (first %+v)", code, again, done)
}
if rows := chapterProgressRows(t, db, first.Chapter.ID); rows != 1 {
t.Fatalf("a repeat must not add a row, got %d", rows)
}
// The book list and the reader show the mark, and the progress page counts it.
marks := bookChapters(t, r, token, first.Chapter.BookID)
if marks[first.Chapter.ID] == nil || marks[second.Chapter.ID] != nil {
t.Fatalf("book marks: %+v", marks)
}
var reader struct {
Chapter ChapterView
}
if code, data := callAPI(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d", first.Chapter.ID), token, nil); code != 200 {
t.Fatalf("reader %d", code)
} else {
json.Unmarshal(data, &reader)
}
if reader.Chapter.ReadAt == nil {
t.Fatal("the reader must carry the read state")
}
if code, progress := learnerProgress(t, r, token); code != 200 || progress.ReadChapters != 1 || progress.TotalChapters != 2 {
t.Fatalf("progress after one chapter: %d %+v", code, progress)
}
// Replacing the text creates a new version, so the chapter reads as unread again while the
// stored completion row stays: it describes the text that was finished, not a counter.
if code, edited := editChapter(t, r, token, first.Chapter.ID, map[string]string{"text": "Curiosity opens a different door.\n"}); code != 200 || edited.Chapter.ReadAt != nil {
t.Fatalf("editing the text must end the mark: %d %+v", code, edited.Chapter)
}
if marks := bookChapters(t, r, token, first.Chapter.BookID); marks[first.Chapter.ID] != nil {
t.Fatal("an edited chapter must read as unread in the list")
}
if code, progress := learnerProgress(t, r, token); code != 200 || progress.ReadChapters != 0 {
t.Fatalf("an edited chapter must not count as read: %d %+v", code, progress)
}
if rows := chapterProgressRows(t, db, first.Chapter.ID); rows != 1 {
t.Fatalf("the completion row must survive an edit, got %d", rows)
}
// Completing the new version moves the same row forward instead of accumulating a second.
drainIngest(t, db)
code, reread := completeChapter(t, r, token, first.Chapter.ID)
if code != 200 || reread.Duplicate || !reread.ReadAt.Equal(stamp(clock)) {
t.Fatalf("re-reading the new version: %d %+v", code, reread)
}
if rows := chapterProgressRows(t, db, first.Chapter.ID); rows != 1 {
t.Fatalf("re-reading must reuse the row, got %d", rows)
}
if code, progress := learnerProgress(t, r, token); code != 200 || progress.ReadChapters != 1 {
t.Fatalf("progress after re-reading: %d %+v", code, progress)
}
// Renaming the chapter keeps the mark: the reader saw this text.
if code, edited := editChapter(t, r, token, first.Chapter.ID, map[string]string{"title": "Fictional renamed"}); code != 200 || edited.Chapter.ReadAt == nil {
t.Fatalf("a title edit must keep the mark: %d %+v", code, edited.Chapter)
}
// A chapter still being processed cannot be completed, and the mark needs a ready chapter.
pending := paste("progress-complete-03", "A third chapter arrives later.\n")
if err := db.Model(&Chapter{}).Where("id = ?", pending.Chapter.ID).
Updates(map[string]any{"status": statusPending, "updated_at": stamp(clock)}).Error; err != nil {
t.Fatal(err)
}
if code, _ := completeChapter(t, r, token, pending.Chapter.ID); code != 409 {
t.Fatalf("a pending chapter must be refused, got %d", code)
}
// Deleting a chapter takes its completion row with it, so the counters follow the library.
// Every paste created its own book, so this deletion empties its book.
if code, deleted := deleteChapter(t, r, token, first.Chapter.ID); code != 200 || deleted.Remaining != 0 {
t.Fatalf("delete chapter: %d %+v", code, deleted)
}
if rows := chapterProgressRows(t, db, first.Chapter.ID); rows != 0 {
t.Fatalf("deleting a chapter must drop its completion, got %d rows", rows)
}
// Ownership: another account cannot complete or observe this chapter, and anonymous callers
// are refused before anything is read.
_, adminEngine, adminAccount := libraryFixture(t)
other := newLearner(t, adminEngine, adminAccount.Token)
if code, _ := completeChapter(t, r, other.Token, second.Chapter.ID); code != 404 {
t.Fatalf("another account must not complete this chapter, got %d", code)
}
if code, progress := learnerProgress(t, r, other.Token); code != 200 || progress.ReadChapters != 0 || progress.TotalChapters != 0 {
t.Fatalf("another account must count nothing: %d %+v", code, progress)
}
if code, _ := completeChapter(t, r, "", second.Chapter.ID); code != 401 {
t.Fatal("completing needs a session")
}
if code, _ := completeChapter(t, r, token, 999999); code != 404 {
t.Fatal("an unknown chapter must be 404")
}
if code, _ := completeChapter(t, r, token, 0); code != 404 {
t.Fatal("a malformed chapter id must be 404")
}
if code, _ := learnerProgress(t, r, ""); code != 401 {
t.Fatal("the progress page needs a session")
}
}
// TestMySQLProgressCountsMatchRecords checks that every number on the progress page comes from
// the caller's own records, that the four word statuses stay apart, and that the due count is
// the same rule the review queue uses.
func TestMySQLProgressCountsMatchRecords(t *testing.T) {
db := testDB(t)
clock := time.Now().UTC().Truncate(time.Millisecond)
r := Router(db, func() time.Time { return clock })
learner, token, _ := termFixture(t, db, r, "Curiosity opens the first door.\n", "The second door is closed.\n", "A third chapter waits.\n")
var chapters []Chapter
if err := db.Where("owner_id = ?", learner.UserId).Order("ordinal ASC").Find(&chapters).Error; err != nil {
t.Fatal(err)
}
// The third chapter is still being processed, so it belongs to neither side of the ratio.
if err := db.Model(&Chapter{}).Where("id = ?", chapters[2].ID).
Updates(map[string]any{"status": statusPending, "updated_at": stamp(clock)}).Error; err != nil {
t.Fatal(err)
}
// Four words in four statuses, one of them answered correctly so it is no longer due.
known := saveWord(t, r, token, chapters[0].ID, 0, 9, termStatusNew, nil)
ignored := saveWord(t, r, token, chapters[0].ID, 10, 15, termStatusNew, nil)
fresh := saveWord(t, r, token, chapters[0].ID, 20, 25, termStatusNew, nil)
learningLevel := 1
learning := saveWord(t, r, token, chapters[1].ID, 4, 10, termStatusLearning, &learningLevel)
answered := saveWord(t, r, token, chapters[1].ID, 11, 15, termStatusNew, nil)
if learning.Term.Status != termStatusLearning || fresh.Term.Status != termStatusNew || answered.Term.Status != termStatusNew {
t.Fatalf("fixture statuses: %+v %+v %+v", learning.Term, fresh.Term, answered.Term)
}
if code, knownTerm := editTerm(t, r, token, known.Term.ID, map[string]any{"status": termStatusKnown}); code != 200 || knownTerm.Level != 0 {
t.Fatalf("known: %d %+v", code, knownTerm)
}
if code, ignoredTerm := editTerm(t, r, token, ignored.Term.ID, map[string]any{"status": termStatusIgnored}); code != 200 || ignoredTerm.Level != 0 {
t.Fatalf("ignored: %d %+v", code, ignoredTerm)
}
// The learning word is scheduled a day ahead, so the queue holds the two still-new words.
_, queue := reviewQueue(t, r, token)
if len(queue.Items) != 2 {
t.Fatalf("fixture queue: %+v", queue.Items)
}
target := queue.Items[0]
for _, item := range queue.Items {
if item.ID == answered.Term.ID {
target = item
}
}
if target.ID != answered.Term.ID {
t.Fatalf("the answered word must be due: %+v", queue.Items)
}
if code, applied := answerReview(t, r, token, target.ID, answerBody("progress-count-answer-1", reviewGradeCorrect, target.DueAt)); code != 201 || applied.LevelAfter != 1 {
t.Fatalf("answer: %d %+v", code, applied)
}
if code, marked := completeChapter(t, r, token, chapters[0].ID); code != 200 || !marked.Read {
t.Fatalf("complete: %d %+v", code, marked)
}
code, progress := learnerProgress(t, r, token)
if code != 200 {
t.Fatalf("progress %d", code)
}
// Two readable chapters, one of them read; the processed third was flipped to pending.
if progress.TotalChapters != 2 || progress.ReadChapters != 1 {
t.Fatalf("reading counts: %+v", progress)
}
// Word counts: one known, one ignored, two learning (the answered word advanced) and one
// still new; nothing is merged.
if progress.KnownTerms != 1 || progress.IgnoredTerms != 1 || progress.LearningTerms != 2 || progress.NewTerms != 1 || progress.SavedTerms != 5 {
t.Fatalf("word counts: %+v", progress)
}
// The due count is exactly the review queue's total, computed by the same query.
if _, queue = reviewQueue(t, r, token); progress.DueNow != queue.Total {
t.Fatalf("due now %d, queue total %d", progress.DueNow, queue.Total)
}
// Per book: one readable chapter, one read, and the book is the caller's own.
if len(progress.Books) != 1 || progress.Books[0].ID != chapters[0].BookID || progress.Books[0].TotalChapters != 2 || progress.Books[0].ReadChapters != 1 {
t.Fatalf("book progress: %+v", progress.Books)
}
if progress.Books[0].Title == "" {
t.Fatal("the book row needs a title")
}
// The numbers are the records: the same counting done straight against the tables agrees.
var readable, read int64
if err := db.Table("lexgo_chapters AS c").Joins("JOIN lexgo_books AS b ON b.id = c.book_id").
Where("c.owner_id = ? AND b.owner_id = ? AND b.language = ? AND c.status = ?", learner.UserId, learner.UserId, "en", statusReady).
Count(&readable).Error; err != nil {
t.Fatal(err)
}
if err := db.Table("lexgo_chapter_progress AS p").Joins("JOIN lexgo_chapters AS c ON c.id = p.chapter_id").
Where("p.owner_id = ? AND c.status = ? AND p.read_sha256 = c.content_sha256", learner.UserId, statusReady).
Count(&read).Error; err != nil {
t.Fatal(err)
}
if int64(progress.TotalChapters) != readable || int64(progress.ReadChapters) != read {
t.Fatalf("progress %d/%d, records %d/%d", progress.ReadChapters, progress.TotalChapters, read, readable)
}
// The counters follow the library: deleting a read chapter removes it from both sides.
if code, deleted := deleteChapter(t, r, token, chapters[0].ID); code != 200 || deleted.Remaining != 2 {
t.Fatalf("delete: %d %+v", code, deleted)
}
if code, after := learnerProgress(t, r, token); code != 200 || after.ReadChapters != 0 || after.TotalChapters != 1 {
t.Fatalf("progress after deletion: %d %+v", code, after)
}
// Another account counts only its own records, and a different study language does not pick
// up the English chapters of the same instance.
_, adminEngine, adminAccount := libraryFixture(t)
other := newLearner(t, adminEngine, adminAccount.Token)
if code, blank := learnerProgress(t, r, other.Token); code != 200 || blank.SavedTerms != 0 || blank.TotalChapters != 0 || blank.DueNow != 0 || len(blank.Books) != 0 {
t.Fatalf("another account must start empty: %d %+v", code, blank)
}
if err := db.Model(&Space{}).Where("owner_id = ?", other.ID).Update("language", "fr").Error; err != nil {
t.Fatal(err)
}
otherBook := Book{OwnerID: other.ID, Title: "Fictional French book", Language: "en", CreatedAt: stamp(clock), UpdatedAt: stamp(clock)}
if err := db.Create(&otherBook).Error; err != nil {
t.Fatal(err)
}
otherChapter := Chapter{BookID: otherBook.ID, OwnerID: other.ID, Ordinal: 1, Title: "Fictional English",
OriginalText: "Curiosity.\n", ContentSHA256: contentSHA("Curiosity.\n"), CharCount: 11, Status: statusReady,
CreatedAt: stamp(clock), UpdatedAt: stamp(clock)}
if err := db.Create(&otherChapter).Error; err != nil {
t.Fatal(err)
}
if code, isolated := learnerProgress(t, r, other.Token); code != 200 || isolated.TotalChapters != 0 || isolated.ReadChapters != 0 {
t.Fatalf("a French space must not count English chapters: %d %+v", code, isolated)
}
// The same account in English still sees its own numbers.
if code, mine := learnerProgress(t, r, token); code != 200 || mine.SavedTerms != 5 {
t.Fatalf("the owner keeps their own numbers: %d %+v", code, mine)
}
}
func deleteChapter(t *testing.T, r *gin.Engine, token string, chapterID int64) (int, DeletionResult) {
t.Helper()
code, data := callAPI(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d", chapterID), token, nil)
var payload struct {
Deleted DeletionResult
}
if data != nil {
json.Unmarshal(data, &payload)
}
return code, payload.Deleted
}
func editChapter(t *testing.T, r *gin.Engine, token string, chapterID int64, body map[string]string) (int, ChapterEdit) {
t.Helper()
code, data := callAPI(t, r, "PATCH", fmt.Sprintf("/api/v1/chapters/%d", chapterID), token, body)
var edited ChapterEdit
if data != nil {
json.Unmarshal(data, &edited)
}
return code, edited
}
+424
View File
@@ -0,0 +1,424 @@
package lexgo
import (
"errors"
"strconv"
"time"
"github.com/gin-gonic/gin"
driver "github.com/go-sql-driver/mysql"
admin "go-admin/app/admin/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// The three answers the accepted review prototype offers.
const (
reviewGradeCorrect = "correct"
reviewGradeWrong = "wrong"
reviewGradeAgain = "again"
)
const (
maxReviewLevel = 7
// One queue page is enough for a daily round; the rest is fetched after finishing it.
reviewQueueLimit = 50
)
// reviewIntervals is the fixed schedule applied after a correct answer, indexed by
// level-1. This is a fixed table, not FSRS: the decision table and its samples live in
// the business rules page.
var reviewIntervals = [maxReviewLevel]int{1, 2, 4, 7, 15, 30, 60}
var reviewGrades = map[string]bool{
reviewGradeCorrect: true,
reviewGradeWrong: true,
reviewGradeAgain: true,
}
// TermReview is the scheduling state of one personal term, kept in its own table so a
// migration never alters the table that holds the learner's own text.
type TermReview struct {
TermID int64 `gorm:"primaryKey"`
OwnerID int
Language string
DueAt time.Time
ReviewCount int
CorrectCount int
WrongCount int
LastReviewedAt *time.Time
}
func (TermReview) TableName() string { return "lexgo_term_reviews" }
// ReviewAnswer records one attempt. The unique answer key is what makes a repeated
// request, a double click or a network resend update nothing a second time.
type ReviewAnswer struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
OwnerID int
AnswerKey string `gorm:"column:answer_key"`
TermID int64
Grade string
Result string
StatusBefore string `gorm:"column:status_before"`
StatusAfter string `gorm:"column:status_after"`
LevelBefore int `gorm:"column:level_before"`
LevelAfter int `gorm:"column:level_after"`
DueAtBefore time.Time `gorm:"column:due_at_before"`
DueAtAfter time.Time `gorm:"column:due_at_after"`
Requeued bool
CreatedAt time.Time
}
func (ReviewAnswer) TableName() string { return "lexgo_review_answers" }
type ReviewItem struct {
ID int64 `json:"id"`
Term string `json:"term"`
OriginalForm string `json:"originalForm"`
Definition string `json:"definition"`
Examples []string `json:"examples"`
Status string `json:"status"`
Level int `json:"level"`
Kind string `json:"kind"`
WordCount int `json:"wordCount"`
DueAt time.Time `json:"dueAt"`
ReviewCount int `json:"reviewCount"`
}
type ReviewQueue struct {
Items []ReviewItem `json:"items"`
Total int `json:"total"`
}
type ReviewAnswerInput struct {
AnswerID string `json:"answerId"`
Grade string `json:"grade"`
ExpectedDueAt time.Time `json:"expectedDueAt"`
}
type ReviewAnswerResult struct {
// Result is the outcome of this answer: applied when the term moved, stale when another
// screen already advanced it. A replayed answer repeats the outcome it was given first.
Result string `json:"result"`
// Duplicate reports that this answer key was already recorded and nothing changed now.
Duplicate bool `json:"duplicate"`
Grade string `json:"grade"`
Requeued bool `json:"requeued"`
StatusBefore string `json:"statusBefore"`
StatusAfter string `json:"statusAfter"`
LevelBefore int `json:"levelBefore"`
LevelAfter int `json:"levelAfter"`
DueAtBefore time.Time `json:"dueAtBefore"`
DueAtAfter time.Time `json:"dueAtAfter"`
Item ReviewItem `json:"item"`
}
// reviewState is the part of a term a grade moves.
type reviewState struct {
Status string
Level int
DueAt time.Time
}
func nextDue(level int, now time.Time) time.Time {
if level < 1 {
level = 1
}
if level > maxReviewLevel {
level = maxReviewLevel
}
return stamp(now.AddDate(0, 0, reviewIntervals[level-1]))
}
// applyGrade advances one term by exactly one answer. It returns the next state and
// whether the item belongs to the current round again.
func applyGrade(before reviewState, grade string, now time.Time) (reviewState, bool, error) {
switch grade {
case reviewGradeCorrect:
level := before.Level + 1
if level > maxReviewLevel {
level = maxReviewLevel
}
return reviewState{Status: termStatusLearning, Level: level, DueAt: nextDue(level, now)}, false, nil
case reviewGradeWrong:
next := reviewState{Status: before.Status, Level: before.Level}
// Only a learning word loses a level; a new word stays new until it is answered
// correctly for the first time.
if before.Status == termStatusLearning {
next.Level = before.Level - 1
if next.Level < 1 {
next.Level = 1
}
}
next.DueAt = stamp(now)
return next, true, nil
case reviewGradeAgain:
return reviewState{Status: before.Status, Level: before.Level, DueAt: stamp(now)}, true, nil
default:
return before, false, failure(400, "评分无效")
}
}
func reviewItem(term Term, review TermReview) ReviewItem {
return ReviewItem{
ID: term.ID, Term: term.Term, OriginalForm: term.OriginalForm, Definition: term.Definition,
Examples: splitExamples(term.Examples), Status: term.Status, Level: term.Level,
Kind: termKind(term.Term), WordCount: termWordCount(term.Term),
DueAt: review.DueAt, ReviewCount: review.ReviewCount,
}
}
// dueTermsQuery is the single definition of "due now": a saved word or phrase that is still
// new or learning and whose scheduled instant is not in the future. The progress page counts
// the same predicate as the review queue, so the two numbers cannot drift apart.
func dueTermsQuery(tx *gorm.DB, owner int, language string, now time.Time) *gorm.DB {
return tx.Table("lexgo_terms AS t").
Joins("JOIN lexgo_term_reviews AS r ON r.term_id = t.id").
Where("t.owner_id = ? AND r.owner_id = ? AND t.language = ? AND r.language = ?", owner, owner, language, language).
Where("t.status IN ?", []string{termStatusNew, termStatusLearning}).
Where("r.due_at <= ?", stamp(now))
}
// ReviewQueueFor returns the caller's due words for the current language. Known and
// ignored words stay out of the queue, and the queue is an absolute-instant comparison:
// an item is due as soon as due_at is not in the future.
func ReviewQueueFor(tx *gorm.DB, owner int, language string, now time.Time) (ReviewQueue, error) {
queue := ReviewQueue{Items: []ReviewItem{}}
due := func() *gorm.DB { return dueTermsQuery(tx, owner, language, now) }
var total int64
if err := due().Count(&total).Error; err != nil {
return queue, err
}
queue.Total = int(total)
var rows []struct {
ID int64
Term string
OriginalForm string
Definition string
Examples string
Status string
Level int
DueAt time.Time
ReviewCount int
}
if err := due().
Select("t.id, t.term, t.original_form, t.definition, t.examples, t.status, t.level, r.due_at, r.review_count").
Order("r.due_at, t.id").Limit(reviewQueueLimit).Scan(&rows).Error; err != nil {
return queue, err
}
for _, row := range rows {
queue.Items = append(queue.Items, ReviewItem{
ID: row.ID, Term: row.Term, OriginalForm: row.OriginalForm, Definition: row.Definition,
Examples: splitExamples(row.Examples), Status: row.Status, Level: row.Level,
Kind: termKind(row.Term), WordCount: termWordCount(row.Term),
DueAt: row.DueAt, ReviewCount: row.ReviewCount,
})
}
return queue, nil
}
// syncTermReview keeps the scheduling row in step with a saved term. A new word is due
// immediately so saving it starts the loop; a word saved straight into a level waits for
// that level's interval, so a manual level is not silently re-queued today. When the caller
// does not ask for a reschedule, only a missing row is created and the existing date and
// counters stay untouched.
func syncTermReview(tx *gorm.DB, term Term, reschedule bool, now time.Time) error {
due := stamp(now)
if term.Status == termStatusLearning && term.Level >= 1 {
due = nextDue(term.Level, now)
}
var existing TermReview
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("term_id = ?", term.ID).First(&existing).Error
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
return tx.Create(&TermReview{TermID: term.ID, OwnerID: term.OwnerID, Language: term.Language, DueAt: due}).Error
case err != nil:
return err
case reschedule:
return tx.Model(&TermReview{}).Where("term_id = ?", term.ID).Update("due_at", due).Error
}
return nil
}
func lockedReview(tx *gorm.DB, owner int, term Term) (TermReview, error) {
var review TermReview
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("term_id = ? AND owner_id = ?", term.ID, owner).First(&review).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
// A term written before this table existed is due at the moment it was saved.
review = TermReview{TermID: term.ID, OwnerID: owner, Language: term.Language, DueAt: stamp(term.CreatedAt)}
if err = tx.Create(&review).Error; err != nil {
return review, err
}
return review, nil
}
return review, err
}
// lockedAnswerKey reads one recorded answer under a lock. It is called again after the term
// row is locked, because a locking read sees the newest committed row while the plain read
// before it may still see this transaction's snapshot.
func lockedAnswerKey(tx *gorm.DB, owner int, key string) (ReviewAnswer, error) {
var stored ReviewAnswer
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("owner_id = ? AND answer_key = ?", owner, key).First(&stored).Error
return stored, err
}
// AnswerReview applies one grade to one owned term exactly once. A repeated answer key
// returns the first outcome, and an answer whose due time no longer matches (another tab
// already advanced the term) is recorded as stale without moving anything.
func AnswerReview(tx *gorm.DB, owner int, termID int64, input ReviewAnswerInput, now time.Time) (ReviewAnswerResult, error) {
if !reviewGrades[input.Grade] {
return ReviewAnswerResult{}, failure(400, "评分无效")
}
if input.ExpectedDueAt.IsZero() {
return ReviewAnswerResult{}, failure(400, "请提交队列中的到期时间")
}
key, err := requestKey(input.AnswerID)
if err != nil {
return ReviewAnswerResult{}, err
}
if stored, err := lockedAnswerKey(tx, owner, key); err == nil {
return replayedAnswer(tx, stored, termID)
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return ReviewAnswerResult{}, err
}
// Ownership first: another account's term and a missing term answer identically.
var term Term
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND owner_id = ?", termID, owner).First(&term).Error; errors.Is(err, gorm.ErrRecordNotFound) {
return ReviewAnswerResult{}, failure(404, "词条不存在")
} else if err != nil {
return ReviewAnswerResult{}, err
}
// Simultaneous submissions of one answer id are serialized by the term lock above; the
// second one now sees the recorded answer and reports it instead of counting again.
if stored, err := lockedAnswerKey(tx, owner, key); err == nil {
return replayedAnswer(tx, stored, termID)
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return ReviewAnswerResult{}, err
}
review, err := lockedReview(tx, owner, term)
if err != nil {
return ReviewAnswerResult{}, err
}
if term.Status != termStatusNew && term.Status != termStatusLearning {
return ReviewAnswerResult{}, failure(409, "该词条已不在复习队列,请刷新队列")
}
before := reviewState{Status: term.Status, Level: term.Level, DueAt: review.DueAt}
if !input.ExpectedDueAt.Equal(review.DueAt) {
return staleAnswer(tx, owner, term, review, input, now)
}
next, requeued, err := applyGrade(before, input.Grade, now)
if err != nil {
return ReviewAnswerResult{}, err
}
when := stamp(now)
if err := tx.Model(&Term{}).Where("id = ? AND owner_id = ?", term.ID, owner).
Updates(map[string]any{"status": next.Status, "level": next.Level, "updated_at": when}).Error; err != nil {
return ReviewAnswerResult{}, err
}
counters := map[string]any{
"due_at": next.DueAt, "review_count": gorm.Expr("review_count + 1"),
"last_reviewed_at": when,
}
// "again" is not a correct answer either: the word was not recognised this time.
if input.Grade == reviewGradeCorrect {
counters["correct_count"] = gorm.Expr("correct_count + 1")
} else {
counters["wrong_count"] = gorm.Expr("wrong_count + 1")
}
if err := tx.Model(&TermReview{}).Where("term_id = ? AND owner_id = ?", term.ID, owner).Updates(counters).Error; err != nil {
return ReviewAnswerResult{}, err
}
term.Status, term.Level, term.UpdatedAt = next.Status, next.Level, when
review.DueAt, review.ReviewCount, review.LastReviewedAt = next.DueAt, review.ReviewCount+1, &when
record := ReviewAnswer{
OwnerID: owner, AnswerKey: key, TermID: term.ID, Grade: input.Grade, Result: "applied",
StatusBefore: before.Status, StatusAfter: next.Status, LevelBefore: before.Level, LevelAfter: next.Level,
DueAtBefore: before.DueAt, DueAtAfter: next.DueAt, Requeued: requeued, CreatedAt: when,
}
if err := tx.Create(&record).Error; err != nil {
return ReviewAnswerResult{}, err
}
return ReviewAnswerResult{
Result: "applied", Grade: input.Grade, Requeued: requeued,
StatusBefore: before.Status, StatusAfter: next.Status, LevelBefore: before.Level, LevelAfter: next.Level,
DueAtBefore: before.DueAt, DueAtAfter: next.DueAt, Item: reviewItem(term, review),
}, nil
}
// staleAnswer records that this attempt changed nothing because the term had already
// moved on. It is a normal outcome of two open tabs, not an error.
func staleAnswer(tx *gorm.DB, owner int, term Term, review TermReview, input ReviewAnswerInput, now time.Time) (ReviewAnswerResult, error) {
key, err := requestKey(input.AnswerID)
if err != nil {
return ReviewAnswerResult{}, err
}
record := ReviewAnswer{
OwnerID: owner, AnswerKey: key, TermID: term.ID, Grade: input.Grade, Result: "stale",
StatusBefore: term.Status, StatusAfter: term.Status, LevelBefore: term.Level, LevelAfter: term.Level,
DueAtBefore: review.DueAt, DueAtAfter: review.DueAt, CreatedAt: stamp(now),
}
if err := tx.Create(&record).Error; err != nil {
// Losing a race to another transaction that recorded this very answer id is not an
// error: report the outcome that was stored first.
var duplicate *driver.MySQLError
if errors.As(err, &duplicate) && duplicate.Number == 1062 {
if stored, readErr := lockedAnswerKey(tx, owner, key); readErr == nil {
return replayedAnswer(tx, stored, term.ID)
}
}
return ReviewAnswerResult{}, err
}
return ReviewAnswerResult{
Result: "stale", Grade: input.Grade, Requeued: false,
StatusBefore: term.Status, StatusAfter: term.Status, LevelBefore: term.Level, LevelAfter: term.Level,
DueAtBefore: review.DueAt, DueAtAfter: review.DueAt, Item: reviewItem(term, review),
}, nil
}
// replayedAnswer reports the outcome already recorded for this answer key. Nothing is
// advanced a second time, so a resend cannot change counts or intervals.
func replayedAnswer(tx *gorm.DB, stored ReviewAnswer, termID int64) (ReviewAnswerResult, error) {
if stored.TermID != termID {
return ReviewAnswerResult{}, failure(400, "请求编号已用于其他词条")
}
var term Term
if err := tx.Where("id = ? AND owner_id = ?", stored.TermID, stored.OwnerID).First(&term).Error; err != nil {
return ReviewAnswerResult{}, err
}
var review TermReview
if err := tx.Where("term_id = ? AND owner_id = ?", stored.TermID, stored.OwnerID).First(&review).Error; err != nil {
return ReviewAnswerResult{}, err
}
return ReviewAnswerResult{
Result: stored.Result, Duplicate: true, Grade: stored.Grade, Requeued: stored.Requeued,
StatusBefore: stored.StatusBefore, StatusAfter: stored.StatusAfter,
LevelBefore: stored.LevelBefore, LevelAfter: stored.LevelAfter,
DueAtBefore: stored.DueAtBefore, DueAtAfter: stored.DueAtAfter, Item: reviewItem(term, review),
}, nil
}
func registerReviewRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context, *gorm.DB, admin.SysUser) (any, error)) gin.HandlerFunc, now func() time.Time) {
v.GET("/reviews/queue", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
if c.Request.URL.RawQuery != "" {
return nil, failure(400, "复习队列不接受查询参数")
}
language, err := languageOf(tx, u.UserId)
if err != nil {
return nil, err
}
return ReviewQueueFor(tx, u.UserId, language, now())
}))
v.POST("/reviews/:termId/answers", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
termID, err := strconv.ParseInt(c.Param("termId"), 10, 64)
if err != nil || termID <= 0 {
return nil, failure(404, "词条不存在")
}
var input ReviewAnswerInput
if err := decode(c, &input); err != nil {
return nil, err
}
return AnswerReview(tx, u.UserId, termID, input, now())
}))
}
+501
View File
@@ -0,0 +1,501 @@
package lexgo
import (
"encoding/json"
"fmt"
"sync"
"testing"
"time"
"github.com/gin-gonic/gin"
admin "go-admin/app/admin/models"
"gorm.io/gorm"
)
// TestReviewDecisionTable pins the fixed schedule and every state transition. It is the
// design evidence the ticket asks for, so a change to the table must fail here first.
func TestReviewDecisionTable(t *testing.T) {
levels := []int{1, 2, 3, 4, 5, 6, 7}
days := []int{1, 2, 4, 7, 15, 30, 60}
if len(reviewIntervals) != len(levels) {
t.Fatalf("interval table has %d entries, want %d", len(reviewIntervals), len(levels))
}
now := time.Date(2026, 9, 11, 10, 30, 0, 0, time.UTC)
for index, level := range levels {
if reviewIntervals[level-1] != days[index] {
t.Fatalf("level %d interval %d, want %d", level, reviewIntervals[level-1], days[index])
}
next, requeued, err := applyGrade(reviewState{Status: termStatusLearning, Level: level, DueAt: now}, reviewGradeCorrect, now)
want := level + 1
if want > maxReviewLevel {
want = maxReviewLevel
}
if err != nil || requeued || next.Level != want || !next.DueAt.Equal(now.AddDate(0, 0, reviewIntervals[want-1])) || next.Status != termStatusLearning {
t.Fatalf("level %d correct: %#v requeued=%v err=%v", level, next, requeued, err)
}
}
cases := []struct {
name string
before reviewState
grade string
status string
level int
dueAt time.Time
requeued bool
}{
{"new correct becomes learning level 1", reviewState{Status: termStatusNew}, reviewGradeCorrect, termStatusLearning, 1, now.AddDate(0, 0, 1), false},
{"new wrong stays new and requeues", reviewState{Status: termStatusNew}, reviewGradeWrong, termStatusNew, 0, now, true},
{"new again stays new and requeues", reviewState{Status: termStatusNew}, reviewGradeAgain, termStatusNew, 0, now, true},
{"learning 4 wrong drops to 3", reviewState{Status: termStatusLearning, Level: 4}, reviewGradeWrong, termStatusLearning, 3, now, true},
{"learning 1 wrong floors at 1", reviewState{Status: termStatusLearning, Level: 1}, reviewGradeWrong, termStatusLearning, 1, now, true},
{"learning 7 wrong drops to 6", reviewState{Status: termStatusLearning, Level: 7}, reviewGradeWrong, termStatusLearning, 6, now, true},
{"again keeps the level", reviewState{Status: termStatusLearning, Level: 3}, reviewGradeAgain, termStatusLearning, 3, now, true},
{"level 7 correct stays at 7", reviewState{Status: termStatusLearning, Level: 7}, reviewGradeCorrect, termStatusLearning, 7, now.AddDate(0, 0, 60), false},
}
for _, tc := range cases {
before := tc.before
before.DueAt = now
next, requeued, err := applyGrade(before, tc.grade, now)
if err != nil || requeued != tc.requeued || next.Status != tc.status || next.Level != tc.level || !next.DueAt.Equal(tc.dueAt) {
t.Fatalf("%s: %#v requeued=%v err=%v", tc.name, next, requeued, err)
}
}
if _, _, err := applyGrade(reviewState{Status: termStatusNew, DueAt: now}, "maybe", now); err == nil {
t.Fatal("an unknown grade must be rejected")
}
}
// reviewFixture builds one learner, one book and one ready chapter under a clock the
// test can move, so due boundaries are exact.
func reviewFixture(t *testing.T, db *gorm.DB, text string) (*gin.Engine, *time.Time, admin.SysUser, string, Chapter) {
t.Helper()
clock := time.Now().UTC().Truncate(time.Millisecond)
r := Router(db, func() time.Time { return clock })
user, token, chapters := termFixture(t, db, r, text)
return r, &clock, user, token, chapters[0]
}
func reviewQueue(t *testing.T, r *gin.Engine, token string) (int, ReviewQueue) {
t.Helper()
code, data := callAPI(t, r, "GET", "/api/v1/reviews/queue", token, nil)
var queue ReviewQueue
if data != nil {
json.Unmarshal(data, &queue)
}
return code, queue
}
func answerReview(t *testing.T, r *gin.Engine, token string, termID int64, body map[string]any) (int, ReviewAnswerResult) {
t.Helper()
code, data := callAPI(t, r, "POST", fmt.Sprintf("/api/v1/reviews/%d/answers", termID), token, body)
var result ReviewAnswerResult
if data != nil {
json.Unmarshal(data, &result)
}
return code, result
}
func answerBody(answerID, grade string, dueAt time.Time) map[string]any {
return map[string]any{"answerId": answerID, "grade": grade, "expectedDueAt": dueAt.UTC().Format(time.RFC3339Nano)}
}
// saveWord saves one word span of an owned chapter and returns the stored record.
func saveWord(t *testing.T, r *gin.Engine, token string, chapterID int64, start, end int, status string, level *int) TermSave {
t.Helper()
body := map[string]any{"chapterId": chapterID, "start": start, "end": end, "definition": "虚构释义", "status": status}
if level != nil {
body["level"] = *level
}
code, saved := saveTermAPI(t, r, token, body)
if code != 201 && code != 200 {
t.Fatalf("save word %d-%d: %d %#v", start, end, code, saved)
}
return saved
}
func termReviewRow(t *testing.T, db *gorm.DB, termID int64) TermReview {
t.Helper()
var review TermReview
if err := db.Where("term_id = ?", termID).First(&review).Error; err != nil {
t.Fatal(err)
}
return review
}
// TestMySQLReviewQueueDueBoundaryAndScope covers the due boundary, the queue scope and
// per-account isolation.
func TestMySQLReviewQueueDueBoundaryAndScope(t *testing.T) {
db := testDB(t)
r, clock, _, token, chapter := reviewFixture(t, db, "Dogs went home.")
_, otherToken, otherChapters := termFixture(t, db, r, "Dogs went home.")
dogs := saveWord(t, r, token, chapter.ID, 0, 4, termStatusNew, nil)
// A saved word is due immediately: its due time is exactly the save time.
code, queue := reviewQueue(t, r, token)
if code != 200 || queue.Total != 1 || len(queue.Items) != 1 || queue.Items[0].ID != dogs.Term.ID {
t.Fatalf("a new word must be due at once: %d %#v", code, queue)
}
item := queue.Items[0]
if !item.DueAt.Equal(*clock) || item.Status != termStatusNew || item.Level != 0 || item.ReviewCount != 0 || item.Examples == nil {
t.Fatalf("queue item: %#v", item)
}
if !termReviewRow(t, db, dogs.Term.ID).DueAt.Equal(*clock) {
t.Fatal("the schedule row must hold the save time")
}
// due_at == now is due; one millisecond earlier is not.
*clock = item.DueAt.Add(-time.Millisecond)
if _, early := reviewQueue(t, r, token); early.Total != 0 {
t.Fatalf("an item due later must stay out of the queue: %#v", early)
}
*clock = item.DueAt.Add(time.Millisecond)
if _, late := reviewQueue(t, r, token); late.Total != 1 {
t.Fatal("an overdue item must be due")
}
// Another account saves the same word in its own chapter without touching this queue.
otherDogs := saveWord(t, r, otherToken, otherChapters[0].ID, 0, 4, termStatusNew, nil)
if otherDogs.Term.ID == dogs.Term.ID {
t.Fatal("two accounts must own separate records for the same word")
}
code, mine := reviewQueue(t, r, token)
if code != 200 || len(mine.Items) != 1 || mine.Items[0].ID != dogs.Term.ID {
t.Fatalf("queues must not mix accounts: %d %#v", code, mine)
}
code, theirs := reviewQueue(t, r, otherToken)
if code != 200 || len(theirs.Items) != 1 || theirs.Items[0].ID != otherDogs.Term.ID {
t.Fatalf("the other account must see its own queue: %d %#v", code, theirs)
}
// Known and ignored words never enter the queue; returning to new makes it due again.
for _, status := range []string{termStatusKnown, termStatusIgnored} {
if code, _ := saveTermAPI(t, r, token, map[string]any{"chapterId": chapter.ID, "start": 0, "end": 4, "status": status}); code != 200 {
t.Fatalf("marking %s failed", status)
}
if _, scoped := reviewQueue(t, r, token); scoped.Total != 0 {
t.Fatalf("%s must stay out of the queue: %#v", status, scoped)
}
}
if code, _ := saveTermAPI(t, r, token, map[string]any{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusNew}); code != 200 {
t.Fatal("returning to a new word failed")
}
if _, again := reviewQueue(t, r, token); again.Total != 1 {
t.Fatalf("a new word must be due again: %#v", again)
}
if code, _ := callAPI(t, r, "GET", "/api/v1/reviews/queue?status=new", token, nil); code != 400 {
t.Fatal("the queue must reject query parameters")
}
if code, _ := callAPI(t, r, "GET", "/api/v1/reviews/queue", "", nil); code != 401 {
t.Fatal("the queue requires a session")
}
}
// TestMySQLReviewAnswerTransitions answers one word three times and checks the term, the
// counters and the recorded attempt after each grade.
func TestMySQLReviewAnswerTransitions(t *testing.T) {
db := testDB(t)
r, clock, user, token, chapter := reviewFixture(t, db, "Dogs went home.")
dogs := saveWord(t, r, token, chapter.ID, 0, 4, termStatusNew, nil)
termID := dogs.Term.ID
// Correct: a new word becomes learning level 1 and leaves the round.
_, queue := reviewQueue(t, r, token)
code, first := answerReview(t, r, token, termID, answerBody("answer-correct-0001", reviewGradeCorrect, queue.Items[0].DueAt))
if code != 201 || first.Result != "applied" || first.Requeued {
t.Fatalf("correct answer: %d %#v", code, first)
}
if first.StatusAfter != termStatusLearning || first.LevelAfter != 1 || first.Item.Level != 1 || first.Item.ReviewCount != 1 {
t.Fatalf("correct transition: %#v", first)
}
if !first.DueAtAfter.Equal(clock.AddDate(0, 0, 1)) || !first.DueAtBefore.Equal(*clock) {
t.Fatalf("correct schedule: %#v", first)
}
if _, empty := reviewQueue(t, r, token); empty.Total != 0 {
t.Fatal("a correctly answered word must leave the queue until it is due again")
}
// One interval later the word is due again; a wrong answer requeues it immediately.
// Sessions last eight hours of the injected clock, so the jump forward needs a login.
*clock = first.DueAtAfter
token = loginToken(t, r, user.Username, fixturePassword)
_, queue = reviewQueue(t, r, token)
if queue.Total != 1 || queue.Items[0].Level != 1 || queue.Items[0].ReviewCount != 1 {
t.Fatalf("second round queue: %#v", queue)
}
code, wrong := answerReview(t, r, token, termID, answerBody("answer-wrong-000002", reviewGradeWrong, queue.Items[0].DueAt))
if code != 201 || !wrong.Requeued || wrong.LevelAfter != 1 || wrong.StatusAfter != termStatusLearning || !wrong.DueAtAfter.Equal(*clock) {
t.Fatalf("wrong answer: %d %#v", code, wrong)
}
_, queue = reviewQueue(t, r, token)
if queue.Total != 1 || queue.Items[0].Level != 1 {
t.Fatalf("requeued item: %#v", queue)
}
code, again := answerReview(t, r, token, termID, answerBody("answer-again-000003", reviewGradeAgain, queue.Items[0].DueAt))
if code != 201 || !again.Requeued || again.LevelBefore != 1 || again.LevelAfter != 1 {
t.Fatalf("again answer: %d %#v", code, again)
}
// Counters and the attempt log accumulated exactly three answers.
review := termReviewRow(t, db, termID)
if review.ReviewCount != 3 || review.CorrectCount != 1 || review.WrongCount != 2 || review.LastReviewedAt == nil {
t.Fatalf("counters: %#v", review)
}
var term Term
if err := db.First(&term, termID).Error; err != nil || term.Status != termStatusLearning || term.Level != 1 || term.Definition != "虚构释义" {
t.Fatalf("the learner's own text must not change: %#v %v", term, err)
}
var answers int64
if err := db.Model(&ReviewAnswer{}).Where("owner_id = ?", user.UserId).Count(&answers).Error; err != nil || answers != 3 {
t.Fatalf("attempt log: %d %v", answers, err)
}
var logged ReviewAnswer
if err := db.Where("term_id = ? AND grade = ?", termID, reviewGradeCorrect).First(&logged).Error; err != nil {
t.Fatal(err)
}
if logged.Result != "applied" || logged.StatusBefore != termStatusNew || logged.LevelAfter != 1 || logged.Requeued {
t.Fatalf("logged attempt: %#v", logged)
}
}
// TestMySQLReviewAnswerIdempotencyAndStaleTabs is the core of the acceptance: a resend
// must not count twice, and a second tab must not advance a term that already moved.
func TestMySQLReviewAnswerIdempotencyAndStaleTabs(t *testing.T) {
db := testDB(t)
r, clock, _, token, chapter := reviewFixture(t, db, "Dogs went home.")
dogs := saveWord(t, r, token, chapter.ID, 0, 4, termStatusNew, nil)
termID := dogs.Term.ID
_, queue := reviewQueue(t, r, token)
seen := queue.Items[0].DueAt
// Two tabs answer the same word with different answer ids but the same seen due time.
code, applied := answerReview(t, r, token, termID, answerBody("tab-one-answer-0001", reviewGradeCorrect, seen))
if code != 201 || applied.Result != "applied" {
t.Fatalf("first tab: %d %#v", code, applied)
}
code, stale := answerReview(t, r, token, termID, answerBody("tab-two-answer-0002", reviewGradeCorrect, seen))
if code != 200 || stale.Result != "stale" || stale.Duplicate || stale.Requeued {
t.Fatalf("second tab must not advance the term: %d %#v", code, stale)
}
if stale.LevelAfter != applied.LevelAfter || !stale.DueAtAfter.Equal(applied.DueAtAfter) {
t.Fatalf("a stale answer changed state: %#v", stale)
}
// A resend of the first answer returns the first outcome, flagged as a replay, without
// another update. The client can therefore count it exactly like the original answer.
code, duplicate := answerReview(t, r, token, termID, answerBody("tab-one-answer-0001", reviewGradeCorrect, seen))
if code != 200 || duplicate.Result != "applied" || !duplicate.Duplicate {
t.Fatalf("resend: %d %#v", code, duplicate)
}
if duplicate.LevelAfter != applied.LevelAfter || !duplicate.DueAtAfter.Equal(applied.DueAtAfter) || duplicate.Item.ReviewCount != 1 {
t.Fatalf("a resend changed state: %#v", duplicate)
}
// Counters moved exactly once, and the replay is not a new attempt row: the applied
// answer and the stale one are two rows, the duplicate returns the first row.
review := termReviewRow(t, db, termID)
if review.ReviewCount != 1 || review.CorrectCount != 1 || review.WrongCount != 0 {
t.Fatalf("counters after three attempts: %#v", review)
}
var attempts int64
if err := db.Model(&ReviewAnswer{}).Where("term_id = ?", termID).Count(&attempts).Error; err != nil || attempts != 2 {
t.Fatalf("each distinct attempt must be recorded once: %d %v", attempts, err)
}
// Reusing an answer id for another word is a client error, not a new answer.
went := saveWord(t, r, token, chapter.ID, 5, 9, termStatusNew, nil)
code, _ = answerReview(t, r, token, went.Term.ID, answerBody("tab-one-answer-0001", reviewGradeCorrect, *clock))
if code != 400 {
t.Fatalf("a reused answer id must be rejected: %d", code)
}
// A genuinely new answer for the other word still works.
code, fresh := answerReview(t, r, token, went.Term.ID, answerBody("went-answer-000004", reviewGradeCorrect, *clock))
if code != 201 || fresh.LevelAfter != 1 {
t.Fatalf("a later answer must work: %d %#v", code, fresh)
}
}
// TestMySQLReviewAnswerErrorsAndOwnership covers the rejected shapes of the answer
// endpoint.
func TestMySQLReviewAnswerErrorsAndOwnership(t *testing.T) {
db := testDB(t)
r, clock, _, token, chapter := reviewFixture(t, db, "Dogs went home.")
_, otherToken, _ := termFixture(t, db, r, "Dogs went home.")
dogs := saveWord(t, r, token, chapter.ID, 0, 4, termStatusNew, nil)
termID := dogs.Term.ID
seen := *clock
if code, _ := answerReview(t, r, otherToken, termID, answerBody("foreign-answer-0001", reviewGradeCorrect, seen)); code != 404 {
t.Fatal("another account's term must be 404")
}
if code, _ := answerReview(t, r, token, 999999, answerBody("missing-answer-0002", reviewGradeCorrect, seen)); code != 404 {
t.Fatal("an unknown term must be 404")
}
for _, id := range []string{"0", "-3", "abc"} {
if code, _ := callAPI(t, r, "POST", "/api/v1/reviews/"+id+"/answers", token, answerBody("bad-id-000003", reviewGradeCorrect, seen)); code != 404 {
t.Fatalf("invalid term id %s: %d", id, code)
}
}
for _, body := range []map[string]any{
{"answerId": "grade-missing-0001", "expectedDueAt": seen.Format(time.RFC3339Nano)},
{"answerId": "grade-unknown-0002", "grade": "maybe", "expectedDueAt": seen.Format(time.RFC3339Nano)},
answerBody("short", reviewGradeCorrect, seen),
{"answerId": "due-missing-0003", "grade": reviewGradeCorrect},
{"answerId": "extra-field-0004", "grade": reviewGradeCorrect, "expectedDueAt": seen.Format(time.RFC3339Nano), "level": 7},
} {
if code, _ := answerReview(t, r, token, termID, body); code != 400 {
t.Fatalf("invalid body %v must be 400: %d", body, code)
}
}
if code, _ := callAPI(t, r, "POST", fmt.Sprintf("/api/v1/reviews/%d/answers", termID), "", answerBody("anonymous-000001", reviewGradeCorrect, seen)); code != 401 {
t.Fatal("answering requires a session")
}
// A word marked as known in another screen is no longer reviewable.
if code, _ := saveTermAPI(t, r, token, map[string]any{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusKnown}); code != 200 {
t.Fatal("marking the word known failed")
}
code, result := answerReview(t, r, token, termID, answerBody("known-answer-0005", reviewGradeCorrect, seen))
if code != 409 || result.Result != "" {
t.Fatalf("a known word must refuse an answer: %d %#v", code, result)
}
if review := termReviewRow(t, db, termID); review.ReviewCount != 0 {
t.Fatalf("a refused answer must not count: %#v", review)
}
}
// TestMySQLReviewScheduleOnManualStatus checks the documented rule for words saved with
// an explicit status: new is due at once, learning waits for its level's interval.
func TestMySQLReviewScheduleOnManualStatus(t *testing.T) {
db := testDB(t)
r, clock, _, token, chapter := reviewFixture(t, db, "Dogs went home.")
level := 3
learning := saveWord(t, r, token, chapter.ID, 0, 4, termStatusLearning, &level)
if review := termReviewRow(t, db, learning.Term.ID); !review.DueAt.Equal(clock.AddDate(0, 0, 4)) {
t.Fatalf("a manual level must wait for its interval: %v", review.DueAt)
}
if _, queue := reviewQueue(t, r, token); queue.Total != 0 {
t.Fatalf("a manually leveled word is not due today: %#v", queue)
}
// Changing it back to a new word makes it due immediately again.
if code, _ := saveTermAPI(t, r, token, map[string]any{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusNew}); code != 200 {
t.Fatal("resetting the status failed")
}
if _, queue := reviewQueue(t, r, token); queue.Total != 1 || queue.Items[0].Level != 0 {
t.Fatalf("a new word is due at once: %#v", queue)
}
}
// TestMySQLReviewConcurrentReplayOfOneAnswer reproduces the review finding that two
// simultaneous submissions of one answer id must both get the recorded outcome.
func TestMySQLReviewConcurrentReplayOfOneAnswer(t *testing.T) {
db := testDB(t)
r, clock, _, token, chapter := reviewFixture(t, db, "Dogs went home.")
dogs := saveWord(t, r, token, chapter.ID, 0, 4, termStatusNew, nil)
_, queue := reviewQueue(t, r, token)
seen := queue.Items[0].DueAt
body := answerBody("concurrent-answer-01", reviewGradeCorrect, seen)
var wg sync.WaitGroup
codes := make(chan int, 2)
results := make(chan string, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
code, data := callAPI(t, r, "POST", fmt.Sprintf("/api/v1/reviews/%d/answers", dogs.Term.ID), token, body)
var result ReviewAnswerResult
if data != nil {
json.Unmarshal(data, &result)
}
codes <- code
results <- result.Result
}()
}
wg.Wait()
close(codes)
close(results)
ok, bad := 0, 0
for code := range codes {
if code == 200 || code == 201 {
ok++
} else {
bad++
t.Logf("status %d", code)
}
}
kinds := []string{}
for kind := range results {
kinds = append(kinds, kind)
}
if ok != 2 || bad != 0 {
t.Fatalf("both submissions must succeed: ok=%d bad=%d kinds=%v clock=%v", ok, bad, kinds, *clock)
}
review := termReviewRow(t, db, dogs.Term.ID)
if review.ReviewCount != 1 {
t.Fatalf("concurrent replay counted %d times", review.ReviewCount)
}
var attempts int64
if err := db.Model(&ReviewAnswer{}).Where("term_id = ?", dogs.Term.ID).Count(&attempts).Error; err != nil || attempts != 1 {
t.Fatalf("concurrent replay logged %d attempts", attempts)
}
}
// TestMySQLReviewEditKeepsSchedule reproduces the review finding that editing only the
// learner's own text must not move a review date (decision D2).
func TestMySQLReviewEditKeepsSchedule(t *testing.T) {
db := testDB(t)
r, clock, _, token, chapter := reviewFixture(t, db, "Dogs went home.")
level := 3
learning := saveWord(t, r, token, chapter.ID, 0, 4, termStatusLearning, &level)
// Make the word overdue, as it would be after a missed day.
overdue := clock.AddDate(0, 0, -5)
if err := db.Model(&TermReview{}).Where("term_id = ?", learning.Term.ID).Update("due_at", overdue).Error; err != nil {
t.Fatal(err)
}
// Editing only the personal text must keep the schedule exactly as it was.
code, saved := saveTermAPI(t, r, token, map[string]any{"chapterId": chapter.ID, "start": 0, "end": 4, "definition": "改过的释义", "status": termStatusLearning, "level": 3})
if code != 200 || saved.Term.Level != 3 {
t.Fatalf("text edit: %d %#v", code, saved)
}
if review := termReviewRow(t, db, learning.Term.ID); !review.DueAt.Equal(overdue) {
t.Fatalf("editing the text moved the due time: %v want %v", review.DueAt, overdue)
}
if _, queue := reviewQueue(t, r, token); queue.Total != 1 {
t.Fatalf("an overdue word must stay due after a text edit: %#v", queue)
}
// Changing the level is a scheduling action and does move the due time.
if code, _ := saveTermAPI(t, r, token, map[string]any{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusLearning, "level": 5}); code != 200 {
t.Fatal("level change failed")
}
if review := termReviewRow(t, db, learning.Term.ID); !review.DueAt.Equal(clock.AddDate(0, 0, 15)) {
t.Fatalf("a level change must reschedule: %v", review.DueAt)
}
}
// TestMySQLReviewPanelSaveKeepsLevel reproduces the related defect found while checking the
// review: the reader panel saves a status without a level, and that must not reset a level
// the learner already earned.
func TestMySQLReviewPanelSaveKeepsLevel(t *testing.T) {
db := testDB(t)
r, clock, _, token, chapter := reviewFixture(t, db, "Dogs went home.")
level := 4
learning := saveWord(t, r, token, chapter.ID, 0, 4, termStatusLearning, &level)
overdue := clock.AddDate(0, 0, -2)
if err := db.Model(&TermReview{}).Where("term_id = ?", learning.Term.ID).Update("due_at", overdue).Error; err != nil {
t.Fatal(err)
}
// This is exactly what the reader panel sends: status only, no level.
code, saved := saveTermAPI(t, r, token, map[string]any{"chapterId": chapter.ID, "start": 0, "end": 4, "definition": "改过的释义", "status": termStatusLearning})
if code != 200 || saved.Term.Level != 4 {
t.Fatalf("a panel save must keep the learned level: %d %#v", code, saved)
}
if review := termReviewRow(t, db, learning.Term.ID); !review.DueAt.Equal(overdue) {
t.Fatalf("a panel save moved the due time: %v want %v", review.DueAt, overdue)
}
// Leaving learning and re-entering it is a real transition and starts at level 1.
if code, _ := saveTermAPI(t, r, token, map[string]any{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusNew}); code != 200 {
t.Fatal("reset to new failed")
}
code, again := saveTermAPI(t, r, token, map[string]any{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusLearning})
if code != 200 || again.Term.Level != 1 {
t.Fatalf("re-entering learning starts at 1: %d %#v", code, again)
}
}
+58 -8
View File
@@ -1,6 +1,7 @@
package lexgo
import (
"bytes"
"encoding/json"
"errors"
"github.com/gin-gonic/gin"
@@ -130,11 +131,25 @@ func Router(db *gorm.DB, now func() time.Time) *gin.Engine {
status = 201
}
// A repeated paste is answered from the first result, so it is not a new resource.
if c.Request.Method == "POST" && (c.FullPath() == "/api/v1/books" || c.FullPath() == "/api/v1/books/:id/chapters") {
// A pasted or uploaded chapter is one resource; a repeated submit is not.
if c.Request.Method == "POST" && (c.FullPath() == "/api/v1/books" || c.FullPath() == "/api/v1/books/:id/chapters" ||
c.FullPath() == "/api/v1/books/upload" || c.FullPath() == "/api/v1/books/:id/chapters/upload") {
if paste, ok := data.(PasteResult); ok && !paste.Duplicate {
status = 201
}
}
// Saving the same entry again is an update of one record, not a new resource.
if c.Request.Method == "POST" && (c.FullPath() == "/api/v1/terms" || c.FullPath() == "/api/v1/phrases") {
if saved, ok := data.(TermSave); ok && saved.Created {
status = 201
}
}
// A replayed or stale answer changed nothing, so it is not a new answer.
if c.Request.Method == "POST" && c.FullPath() == "/api/v1/reviews/:termId/answers" {
if answer, ok := data.(ReviewAnswerResult); ok && answer.Result == "applied" && !answer.Duplicate {
status = 201
}
}
respond(c, status, data, err)
}
}
@@ -219,13 +234,6 @@ func Router(db *gorm.DB, now func() time.Time) *gin.Engine {
}
return updateAccount(tx, id, u.UserId, input)
}))
pathID := func(c *gin.Context, message string) (int64, error) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
return 0, failure(404, message)
}
return id, nil
}
v.POST("/books", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
var input PasteBookInput
if err := decodeLimit(c, &input, maxPasteBodyBytes); err != nil {
@@ -295,10 +303,24 @@ func Router(db *gorm.DB, now func() time.Time) *gin.Engine {
return gin.H{"job": job, "chapter": chapter}, nil
}))
registerDictionaryRoutes(v, protect, now)
registerTermRoutes(v, protect, now)
registerReviewRoutes(v, protect, now)
registerUploadRoutes(v, protect, now)
registerEditRoutes(v, protect, now)
r.NoRoute(func(c *gin.Context) { respond(c, 404, nil, failure(404, "页面或接口不存在")) })
return r
}
// pathID reads a positive numeric path parameter; a malformed id is reported like a
// missing resource so it cannot be used to probe for other accounts' rows.
func pathID(c *gin.Context, message string) (int64, error) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
return 0, failure(404, message)
}
return id, nil
}
func decode(c *gin.Context, value any) error { return decodeLimit(c, value, maxJSONBodyBytes) }
func decodeLimit(c *gin.Context, value any, limit int64) error {
@@ -320,6 +342,18 @@ func decodeLimit(c *gin.Context, value any, limit int64) error {
}
return nil
}
// binaryResponse lets a handler answer with bytes instead of the JSON envelope: an attachment is
// served with its own content type, and ServeContent needs the raw response writer so it can apply
// Range requests, conditional requests and the correct status codes itself.
type binaryResponse struct {
ContentType string
Bytes []byte
FileName string
ModifiedAt time.Time
ETag string
}
func respond(c *gin.Context, status int, data any, err error) {
if err != nil {
var e *apiError
@@ -330,6 +364,22 @@ func respond(c *gin.Context, status int, data any, err error) {
c.JSON(500, gin.H{"code": 500, "msg": "服务暂不可用,请稍后再试"})
return
}
if binary, ok := data.(binaryResponse); ok {
c.Header("Content-Type", binary.ContentType)
if binary.ETag != "" {
c.Header("ETag", binary.ETag)
// A versioned attachment is private and may be cached until its version changes.
c.Header("Cache-Control", "private, max-age=0, must-revalidate")
if c.GetHeader("If-None-Match") == binary.ETag {
c.Status(http.StatusNotModified)
return
}
}
// ServeContent answers Range requests with 206, a bad range with 416, and honours
// If-Modified-Since through the modification time.
http.ServeContent(c.Writer, c.Request, binary.FileName, binary.ModifiedAt, bytes.NewReader(binary.Bytes))
return
}
c.JSON(status, gin.H{"code": 200, "data": data})
}
+6 -1
View File
@@ -38,6 +38,11 @@ type Space struct {
func (Space) TableName() string { return "lexgo_spaces" }
// SessionLifetime is how long a login lasts. It is absolute, not sliding: using the app does not
// extend it, so a stolen token cannot be kept alive forever. Both the learner and the admin client
// authenticate through the same login endpoint, so one value covers them.
const SessionLifetime = 30 * 24 * time.Hour
type Session struct {
TokenHash string `gorm:"primaryKey"`
OwnerID int
@@ -162,7 +167,7 @@ func login(db *gorm.DB, now time.Time, name, password string) (LoginResult, erro
return err
}
token := hex.EncodeToString(b)
expiry := now.Add(8 * time.Hour)
expiry := now.Add(SessionLifetime)
if err = tx.Where("owner_id = ? AND expires_at <= ?", u.UserId, now).Delete(&Session{}).Error; err != nil {
return err
}
+411
View File
@@ -0,0 +1,411 @@
package lexgo
import (
"errors"
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/gin-gonic/gin"
admin "go-admin/app/admin/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// The four learner-visible statuses are stored explicitly instead of the upstream
// merged status/level code. The mapping stays available for CSV export and for
// migrating an existing LinguaCafe instance: new=2, ignored=1, known=0, and
// learning level N=-N. A level is only meaningful while learning, so every other
// status keeps level 0 and the database check constraints repeat that rule.
const (
termStatusNew = "new"
termStatusLearning = "learning"
termStatusKnown = "known"
termStatusIgnored = "ignored"
)
// One table holds both entries: a word and a phrase differ in kind and word count, and a
// phrase key always contains a space, so the identity keys cannot collide.
const (
termKindWord = "word"
termKindPhrase = "phrase"
)
const (
termFormLimit = 128
termDefinitionLimit = 2000
termExampleLimit = 500
termExamplesLimit = 5
termLevelMax = 7
// One chapter may contain many distinct words, so identity lookups are batched
// instead of sending an unbounded IN list.
termLookupBatch = 500
)
var termStatuses = map[string]bool{
termStatusNew: true,
termStatusLearning: true,
termStatusKnown: true,
termStatusIgnored: true,
}
// Term is one learner's own record for a word form. Identity is the normalized
// form: the original spelling is kept for display only.
type Term struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
OwnerID int
Language string
Term string
OriginalForm string `gorm:"column:original_form"`
Definition string
Examples string
Status string
Level int
CreatedAt time.Time
UpdatedAt time.Time
}
// Kind and word count need no columns: a phrase key is the ordered normalized word forms
// joined by single spaces, and a word form can never contain a space, so the identity key
// itself carries both facts. One helper keeps that invariant in a single place.
func termKind(key string) string {
if strings.Contains(key, " ") {
return termKindPhrase
}
return termKindWord
}
func termWordCount(key string) int {
if !strings.Contains(key, " ") {
return 1
}
return len(strings.Split(key, " "))
}
func (Term) TableName() string { return "lexgo_terms" }
type TermView struct {
ID int64 `json:"id"`
Language string `json:"language"`
Term string `json:"term"`
OriginalForm string `json:"originalForm"`
Definition string `json:"definition"`
Examples []string `json:"examples"`
Status string `json:"status"`
Level int `json:"level"`
Kind string `json:"kind"`
WordCount int `json:"wordCount"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// TokenTerm is what a reader token needs to render its own highlight: the entry
// id for the follow-up read, and enough state to style the word.
type TokenTerm struct {
ID int64 `json:"id"`
Status string `json:"status"`
Level int `json:"level"`
}
type TermSave struct {
Term TermView `json:"term"`
Created bool `json:"created"`
}
// splitExamples returns the stored examples as a list; the column holds one per line.
func splitExamples(text string) []string {
if text == "" {
return []string{}
}
return strings.Split(text, "\n")
}
func termView(t Term) TermView {
return TermView{
ID: t.ID, Language: t.Language, Term: t.Term, OriginalForm: t.OriginalForm, Definition: t.Definition,
Examples: splitExamples(t.Examples), Status: t.Status, Level: t.Level,
Kind: termKind(t.Term), WordCount: termWordCount(t.Term),
CreatedAt: t.CreatedAt, UpdatedAt: t.UpdatedAt,
}
}
// termLevel enforces the documented status/level boundary: only a learning entry carries a
// level, every other status must leave the level at 0, and entering learning starts at 1.
// A save that does not mention a level keeps the level the learner already earned, so
// editing a definition can never roll a word back to level 1.
func termLevel(status string, level *int, previous Term, exists bool) (int, error) {
if !termStatuses[status] {
return 0, failure(400, "词语状态无效")
}
if status == termStatusLearning {
if level != nil && *level != 0 {
if *level < 1 || *level > termLevelMax {
return 0, failure(400, "学习等级须为 1~7")
}
return *level, nil
}
if exists && previous.Status == termStatusLearning && previous.Level >= 1 {
return previous.Level, nil
}
return 1, nil
}
if level != nil && *level != 0 {
return 0, failure(400, "只有学习中的词语可以设置等级")
}
return 0, nil
}
func hasControlRune(text string, allowNewline bool) bool {
for _, r := range text {
if allowNewline && (r == '\n' || r == '\t') {
continue
}
if unicode.IsControl(r) {
return true
}
}
return false
}
// termContent validates the learner's own text and returns it in storage form:
// the definition as typed (trimmed) and examples joined by newline.
func termContent(definition string, examples []string) (string, string, error) {
definition = strings.TrimSpace(definition)
if utf8.RuneCountInString(definition) > termDefinitionLimit {
return "", "", failure(400, "个人释义不能超过 2000 个字符")
}
if hasControlRune(definition, true) {
return "", "", failure(400, "个人释义包含不支持的字符")
}
if len(examples) > termExamplesLimit {
return "", "", failure(400, "例句不能超过 5 条")
}
cleaned := make([]string, 0, len(examples))
for _, example := range examples {
example = strings.TrimSpace(example)
if example == "" {
return "", "", failure(400, "例句不能为空行")
}
if utf8.RuneCountInString(example) > termExampleLimit {
return "", "", failure(400, "每条例句不能超过 500 个字符")
}
if hasControlRune(example, false) {
return "", "", failure(400, "例句包含不支持的字符")
}
cleaned = append(cleaned, example)
}
return definition, strings.Join(cleaned, "\n"), nil
}
// wordAtRange returns the word the learner actually selected. Identity is always
// derived from the server's own tokens, so a client cannot name a word, user or
// language that it did not read from this chapter.
func wordAtRange(chapter Chapter, start, end int) (string, error) {
for _, token := range Tokenize(chapter.OriginalText) {
if token.Kind == "word" && token.Start == start && token.End == end {
if utf8.RuneCountInString(token.Text) <= termFormLimit {
return token.Text, nil
}
break
}
}
return "", failure(400, "请选择不超过 128 个字符的完整单词")
}
// languageOf reads the owner's study language. A missing space row falls back to
// the English default that account creation and login already establish.
func languageOf(tx *gorm.DB, owner int) (string, error) {
var space Space
err := tx.Where("owner_id = ?", owner).First(&space).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return "en", nil
}
if err != nil {
return "", err
}
return space.Language, nil
}
// previousTerm reads the row this save is about to change under a lock, so the level and
// the review schedule can be compared with what the learner already had.
func previousTerm(tx *gorm.DB, owner int, language, term string) (Term, bool, error) {
var existing Term
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("owner_id = ? AND language = ? AND term = ?", owner, language, term).First(&existing).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return Term{}, false, nil
}
if err != nil {
return Term{}, false, err
}
return existing, true, nil
}
// saveTerm writes one identity with INSERT ... ON DUPLICATE KEY UPDATE: a repeated
// save updates the same row instead of adding a second, conflicting record, and
// two concurrent saves of the same word still leave exactly one.
// saveTerm stores one identity: key is the canonical form used for lookup, display is what the
// learner sees. For a word both are the word form; for a phrase the key joins normalized word
// forms and the display keeps the original punctuation.
func saveTerm(tx *gorm.DB, owner int, language, key, display string, fields TermFields, now time.Time) (TermSave, error) {
when := stamp(now)
row := Term{
OwnerID: owner, Language: language, Term: key, OriginalForm: display,
Definition: fields.Definition, Examples: fields.Examples, Status: fields.Status, Level: fields.Level,
CreatedAt: when, UpdatedAt: when,
}
// MySQL reports affected rows 1 for an insert and 0 or 2 for an update, so the
// counter distinguishes "created" from "saved again" without a second read.
insert := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "owner_id"}, {Name: "language"}, {Name: "term"}},
DoUpdates: clause.Assignments(map[string]any{
"original_form": row.OriginalForm, "definition": row.Definition, "examples": row.Examples,
"status": row.Status, "level": row.Level, "updated_at": row.UpdatedAt,
}),
}).Create(&row)
if insert.Error != nil {
return TermSave{}, insert.Error
}
var stored Term
if err := tx.Where("owner_id = ? AND language = ? AND term = ?", owner, language, row.Term).First(&stored).Error; err != nil {
return TermSave{}, err
}
// A saved word always owns a schedule row, but the date only moves for a new word or a
// real status/level change: editing a definition must not push a word out of today's
// queue (decision D2).
reschedule := !fields.Exists || fields.PreviousStatus != stored.Status || fields.PreviousLevel != stored.Level
if err := syncTermReview(tx, stored, reschedule, now); err != nil {
return TermSave{}, err
}
return TermSave{termView(stored), insert.RowsAffected == 1}, nil
}
// attachTerms marks the tokens this learner already saved. Matching is by
// normalized form across the whole vocabulary, so a word saved in another chapter
// is highlighted here with the same status.
func attachTerms(tx *gorm.DB, owner int, language string, tokens []TextToken) error {
keys := make([]string, 0, 16)
seen := map[string]bool{}
for _, token := range tokens {
if token.Kind != "word" || utf8.RuneCountInString(token.Text) > termFormLimit {
continue
}
key := normalizeWord(token.Text)
if !seen[key] {
seen[key] = true
keys = append(keys, key)
}
}
byKey := map[string]TokenTerm{}
for start := 0; start < len(keys); start += termLookupBatch {
end := min(start+termLookupBatch, len(keys))
var rows []Term
if err := tx.Select("id", "term", "status", "level").
Where("owner_id = ? AND language = ? AND term IN ?", owner, language, keys[start:end]).
Find(&rows).Error; err != nil {
return err
}
for _, row := range rows {
byKey[row.Term] = TokenTerm{ID: row.ID, Status: row.Status, Level: row.Level}
}
}
for i := range tokens {
if tokens[i].Kind != "word" {
continue
}
if term, ok := byKey[normalizeWord(tokens[i].Text)]; ok {
value := term
tokens[i].Term = &value
}
}
return nil
}
// TermFields carries the validated learner text and state into storage, together with the
// status and level the row had before this save.
type TermFields struct {
Definition string
Examples string
Status string
Level int
PreviousStatus string
PreviousLevel int
Exists bool
}
type TermInput struct {
ChapterID int64 `json:"chapterId"`
Start *int `json:"start"`
End *int `json:"end"`
Definition string `json:"definition"`
Examples []string `json:"examples"`
Status string `json:"status"`
Level *int `json:"level"`
}
// registerTermRoutes exposes the learner's own word records. Nothing here is
// written to the audit log: personal learning content stays out of it, and the
// account-level audit already covers administrative changes.
func registerTermRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context, *gorm.DB, admin.SysUser) (any, error)) gin.HandlerFunc, now func() time.Time) {
v.POST("/terms", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
var input TermInput
if err := decode(c, &input); err != nil {
return nil, err
}
if input.Start == nil || input.End == nil {
return nil, failure(400, "请选择完整单词")
}
chapter, err := readyOwnedChapter(tx, u.UserId, input.ChapterID)
if err != nil {
return nil, err
}
word, err := wordAtRange(chapter, *input.Start, *input.End)
if err != nil {
return nil, err
}
language, err := languageOf(tx, u.UserId)
if err != nil {
return nil, err
}
// The previous row decides whether a missing level keeps the earned one and whether
// the review date may move at all.
previous, exists, err := previousTerm(tx, u.UserId, language, normalizeWord(word))
if err != nil {
return nil, err
}
level, err := termLevel(input.Status, input.Level, previous, exists)
if err != nil {
return nil, err
}
definition, examples, err := termContent(input.Definition, input.Examples)
if err != nil {
return nil, err
}
fields := TermFields{
Definition: definition, Examples: examples, Status: input.Status, Level: level,
PreviousStatus: previous.Status, PreviousLevel: previous.Level, Exists: exists,
}
return saveTerm(tx, u.UserId, language, normalizeWord(word), word, fields, now())
}))
v.GET("/terms/:id", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
return nil, failure(404, "词条不存在")
}
// Another account's id and a missing id answer identically, so the response
// never confirms that someone else's entry exists.
var term Term
if err := tx.Where("id = ? AND owner_id = ?", id, u.UserId).First(&term).Error; errors.Is(err, gorm.ErrRecordNotFound) {
return nil, failure(404, "词条不存在")
} else if err != nil {
return nil, err
}
return gin.H{"term": termView(term)}, nil
}))
registerPhraseRoutes(v, protect, now)
registerVocabularyRoutes(v, protect, now)
registerProgressRoutes(v, protect, now)
registerAttachmentRoutes(v, protect, now)
}
+378
View File
@@ -0,0 +1,378 @@
package lexgo
import (
"encoding/json"
"fmt"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
admin "go-admin/app/admin/models"
"gorm.io/gorm"
)
func TestTermLevelBoundary(t *testing.T) {
level := func(v int) *int { return &v }
// The stored state the save is about to change: a level-4 learning word.
stored := Term{Status: termStatusLearning, Level: 4}
cases := []struct {
status string
level *int
previous Term
exists bool
want int
ok bool
}{
{termStatusNew, nil, Term{}, false, 0, true},
{termStatusNew, level(0), Term{}, false, 0, true},
{termStatusNew, level(1), Term{}, false, 0, false},
{termStatusKnown, level(0), stored, true, 0, true},
{termStatusKnown, level(3), stored, true, 0, false},
{termStatusIgnored, level(0), stored, true, 0, true},
{termStatusIgnored, level(-1), stored, true, 0, false},
{termStatusLearning, nil, Term{}, false, 1, true},
{termStatusLearning, level(0), Term{}, false, 1, true},
{termStatusLearning, level(1), stored, true, 1, true},
{termStatusLearning, level(7), stored, true, 7, true},
{termStatusLearning, level(8), stored, true, 0, false},
{termStatusLearning, level(-1), stored, true, 0, false},
{"", nil, stored, true, 0, false},
{"Learning", nil, stored, true, 0, false},
{"deleted", nil, stored, true, 0, false},
}
// A save that does not mention a level keeps the earned one instead of resetting it.
cases = append(cases, struct {
status string
level *int
previous Term
exists bool
want int
ok bool
}{termStatusLearning, nil, stored, true, 4, true})
for _, tc := range cases {
got, err := termLevel(tc.status, tc.level, tc.previous, tc.exists)
if tc.ok && (err != nil || got != tc.want) {
t.Fatalf("%s/%v: got %d, %v; want %d", tc.status, tc.level, got, err, tc.want)
}
if !tc.ok && err == nil {
t.Fatalf("%s/%v: invalid state accepted as %d", tc.status, tc.level, got)
}
}
}
func TestTermContentRules(t *testing.T) {
definition, examples, err := termContent(" 好奇\t心\n求知 ", []string{" A fictional line. ", "Second line"})
if err != nil || definition != "好奇\t心\n求知" || examples != "A fictional line.\nSecond line" {
t.Fatalf("trim and join: %q %q %v", definition, examples, err)
}
if definition, examples, err = termContent(" ", nil); err != nil || definition != "" || examples != "" {
t.Fatalf("an empty definition is allowed: %q %q %v", definition, examples, err)
}
rejected := []struct {
name string
def string
examples []string
}{
{"definition too long", strings.Repeat("字", termDefinitionLimit+1), nil},
{"definition control character", "a\u0007b", nil},
{"empty example", "ok", []string{"fine", " "}},
{"example with newline", "ok", []string{"two\nlines"}},
{"example too long", "ok", []string{strings.Repeat("a", termExampleLimit+1)}},
{"too many examples", "ok", []string{"1", "2", "3", "4", "5", "6"}},
}
for _, tc := range rejected {
if _, _, err := termContent(tc.def, tc.examples); err == nil {
t.Fatalf("%s was accepted", tc.name)
}
}
if _, _, err := termContent(strings.Repeat("字", termDefinitionLimit), []string{strings.Repeat("a", termExampleLimit)}); err != nil {
t.Fatalf("boundary values must be accepted: %v", err)
}
}
func TestWordAtRangeUsesServerTokens(t *testing.T) {
chapter := Chapter{OriginalText: "Cats went. Dogs"}
for _, tc := range []struct {
start, end int
want string
}{
{0, 4, "Cats"},
{5, 9, "went"},
{11, 15, "Dogs"},
} {
got, err := wordAtRange(chapter, tc.start, tc.end)
if err != nil || got != tc.want {
t.Fatalf("%d-%d: got %q %v, want %q", tc.start, tc.end, got, err, tc.want)
}
}
for _, span := range [][2]int{{0, 3}, {0, 5}, {4, 5}, {9, 10}, {10, 11}, {0, 15}, {-1, 3}, {12, 15}, {0, 0}} {
if _, err := wordAtRange(chapter, span[0], span[1]); err == nil {
t.Fatalf("range %v accepted as a word", span)
}
}
long := "a" + strings.Repeat("b", termFormLimit)
if _, err := wordAtRange(Chapter{OriginalText: long}, 0, len([]rune(long))); err == nil {
t.Fatal("over-long word accepted")
}
}
func TestTermViewSplitsExamples(t *testing.T) {
base := Term{ID: 3, Language: "en", Term: "curiosity", OriginalForm: "Curiosity", Status: termStatusNew}
if got := termView(base); len(got.Examples) != 0 || got.Examples == nil {
t.Fatalf("empty examples must serialize as []: %#v", got.Examples)
}
base.Examples = "One.\nTwo."
if got := termView(base); len(got.Examples) != 2 || got.Examples[1] != "Two." {
t.Fatalf("examples: %#v", got.Examples)
}
}
// termFixture creates one learner with a private book holding one ready chapter per
// text. Fictional content only; nothing here touches another account's data.
func termFixture(t *testing.T, db *gorm.DB, r *gin.Engine, texts ...string) (admin.SysUser, string, []Chapter) {
t.Helper()
u := admin.SysUser{Username: randomName("term"), Password: fixturePassword, RoleId: 2, Status: "2"}
if err := db.Create(&u).Error; err != nil {
t.Fatal("fixture learner creation failed")
}
now := stamp(time.Now())
book := Book{OwnerID: u.UserId, Title: "Fictional terms", Language: "en", CreatedAt: now, UpdatedAt: now}
if err := db.Create(&book).Error; err != nil {
t.Fatal(err)
}
var chapters []Chapter
for index, text := range texts {
chapter := Chapter{BookID: book.ID, OwnerID: u.UserId, Ordinal: index + 1, Title: fmt.Sprintf("Fictional %d", index+1),
OriginalText: text, ContentSHA256: contentSHA(text), CharCount: len([]rune(text)), Status: statusReady, CreatedAt: now, UpdatedAt: now}
if err := db.Create(&chapter).Error; err != nil {
t.Fatal(err)
}
chapters = append(chapters, chapter)
}
return u, loginToken(t, r, u.Username, fixturePassword), chapters
}
func saveTermAPI(t *testing.T, r *gin.Engine, token string, body map[string]any) (int, TermSave) {
t.Helper()
code, data := callAPI(t, r, "POST", "/api/v1/terms", token, body)
var saved TermSave
if data != nil {
json.Unmarshal(data, &saved)
}
return code, saved
}
func chapterTokens(t *testing.T, r *gin.Engine, token string, chapterID int64) (int, ChapterTokens) {
t.Helper()
code, data := callAPI(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d/tokens", chapterID), token, nil)
var analyzed ChapterTokens
if data != nil {
json.Unmarshal(data, &analyzed)
}
return code, analyzed
}
// wordTokens returns only the selectable words, so a test can talk about the third
// word instead of the sixth token.
func wordTokens(tokens []TextToken) []TextToken {
words := make([]TextToken, 0, len(tokens))
for _, token := range tokens {
if token.Kind == "word" {
words = append(words, token)
}
}
return words
}
// TestMySQLTermIdentityAndCrossChapterConsistency covers the acceptance items that
// one saved word stays one record, that case is not a second word, that another
// inflection is its own entry, and that another chapter shows the same state.
func TestMySQLTermIdentityAndCrossChapterConsistency(t *testing.T) {
db := testDB(t)
r := Router(db, time.Now)
learner, token, chapters := termFixture(t, db, r, "Dogs dogs dog.", "Dogs elsewhere")
chapter, elsewhere := chapters[0], chapters[1]
code, saved := saveTermAPI(t, r, token, map[string]any{
"chapterId": chapter.ID, "start": 0, "end": 4, "definition": "狗", "examples": []string{"Fictional dogs."}, "status": termStatusNew,
})
if code != 201 || !saved.Created || saved.Term.ID == 0 {
t.Fatalf("first save: %d %#v", code, saved)
}
if saved.Term.Term != "dogs" || saved.Term.OriginalForm != "Dogs" || saved.Term.Language != "en" || saved.Term.Level != 0 {
t.Fatalf("stored identity: %#v", saved.Term)
}
first := saved.Term.ID
// The exact same save is idempotent: same record, same status code, no duplicate.
code, repeated := saveTermAPI(t, r, token, map[string]any{
"chapterId": chapter.ID, "start": 0, "end": 4, "definition": "狗", "examples": []string{"Fictional dogs."}, "status": termStatusNew,
})
if code != 200 || repeated.Created || repeated.Term.ID != first {
t.Fatalf("identical repeat must update one record: %d %#v", code, repeated)
}
// Repeating the save updates the same record instead of adding a second one.
code, again := saveTermAPI(t, r, token, map[string]any{
"chapterId": chapter.ID, "start": 0, "end": 4, "definition": "狗;犬", "examples": []string{}, "status": termStatusLearning, "level": 3,
})
if code != 200 || again.Created || again.Term.ID != first || again.Term.Definition != "狗;犬" || again.Term.Status != termStatusLearning || again.Term.Level != 3 {
t.Fatalf("repeated save: %d %#v", code, again)
}
// The same word in another case is the same identity.
code, lower := saveTermAPI(t, r, token, map[string]any{
"chapterId": chapter.ID, "start": 5, "end": 9, "definition": "狗", "status": termStatusKnown,
})
if code != 200 || lower.Created || lower.Term.ID != first || lower.Term.OriginalForm != "dogs" || lower.Term.Level != 0 {
t.Fatalf("case folded identity: %d %#v", code, lower)
}
// A different inflection stays its own entry instead of merging by lemma.
code, inflection := saveTermAPI(t, r, token, map[string]any{
"chapterId": chapter.ID, "start": 10, "end": 13, "definition": "狗(单数)", "status": termStatusNew,
})
if code != 201 || inflection.Term.ID == first {
t.Fatalf("inflection must be a separate entry: %d %#v", code, inflection)
}
var owned int64
if err := db.Model(&Term{}).Where("owner_id = ?", learner.UserId).Count(&owned).Error; err != nil || owned != 2 {
t.Fatalf("one word must stay one record: count=%d err=%v", owned, err)
}
// The status saved here is the status the reader shows for this chapter.
code, tokens := chapterTokens(t, r, token, chapter.ID)
words := wordTokens(tokens.Tokens)
if code != 200 || len(words) != 3 {
t.Fatalf("tokens: %d %#v", code, tokens.Tokens)
}
if words[0].Term == nil || words[0].Term.ID != first || words[0].Term.Status != termStatusKnown {
t.Fatalf("first token term: %#v", words[0])
}
if words[2].Term == nil || words[2].Term.ID != inflection.Term.ID {
t.Fatalf("second entry token term: %#v", words[2])
}
for _, token := range tokens.Tokens {
if token.Kind != "word" && token.Term != nil {
t.Fatalf("spaces and punctuation must not carry a term: %#v", token)
}
}
// Another chapter of the same learner shows the same entry without saving again.
code, crossTokens := chapterTokens(t, r, token, elsewhere.ID)
crossWords := wordTokens(crossTokens.Tokens)
if code != 200 || len(crossWords) != 2 || crossWords[0].Term == nil || crossWords[0].Term.ID != first || crossWords[0].Term.Status != termStatusKnown {
t.Fatalf("cross-chapter highlight: %d %#v", code, crossTokens.Tokens)
}
// Another account sees the same text with no personal state at all.
_, otherToken, otherChapters := termFixture(t, db, r, "Dogs elsewhere")
code, otherTokens := chapterTokens(t, r, otherToken, otherChapters[0].ID)
otherWords := wordTokens(otherTokens.Tokens)
if code != 200 || len(otherWords) != 2 || otherWords[0].Term != nil {
t.Fatalf("another account must not see a term: %d %#v", code, otherTokens.Tokens)
}
if code, _ := chapterTokens(t, r, otherToken, chapter.ID); code != 404 {
t.Fatalf("foreign chapter must be 404: %d", code)
}
}
// TestMySQLTermIsolationAndInputRules covers ownership, tampered input and the
// status/level boundary through the real HTTP surface.
func TestMySQLTermIsolationAndInputRules(t *testing.T) {
db := testDB(t)
r := Router(db, time.Now)
_, ownerToken, chapters := termFixture(t, db, r, "Cats went.")
chapter := chapters[0]
otherUser, otherToken, _ := termFixture(t, db, r, "Cats went.")
code, saved := saveTermAPI(t, r, ownerToken, map[string]any{"chapterId": chapter.ID, "start": 0, "end": 4, "definition": "猫", "status": termStatusNew})
if code != 201 {
t.Fatalf("owner save: %d", code)
}
if code, _ := callAPI(t, r, "GET", fmt.Sprintf("/api/v1/terms/%d", saved.Term.ID), ownerToken, nil); code != 200 {
t.Fatalf("owner read: %d", code)
}
if code, _ := callAPI(t, r, "GET", fmt.Sprintf("/api/v1/terms/%d", saved.Term.ID), otherToken, nil); code != 404 {
t.Fatalf("foreign read must be 404: %d", code)
}
for _, id := range []string{"0", "-1", "abc", "99999999999999999999"} {
if code, _ := callAPI(t, r, "GET", "/api/v1/terms/"+id, ownerToken, nil); code != 404 {
t.Fatalf("invalid id %s: %d", id, code)
}
}
if code, _ := callAPI(t, r, "POST", "/api/v1/terms", "", map[string]any{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusNew}); code != 401 {
t.Fatal("anonymous save must be 401")
}
if code, _ := saveTermAPI(t, r, otherToken, map[string]any{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusNew}); code != 404 {
t.Fatal("saving into a foreign chapter must be 404")
}
// A client cannot name the owner, the language, the word or the record.
for _, body := range []map[string]any{
{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusNew, "ownerId": otherUser.UserId},
{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusNew, "language": "fr"},
{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusNew, "term": "forged"},
{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusNew, "id": 1},
} {
if code, _ := saveTermAPI(t, r, ownerToken, body); code != 400 {
t.Fatalf("tampered input %v must be 400: %d", body, code)
}
}
// Invalid or partial ranges, an unknown status and a forbidden level.
for _, body := range []map[string]any{
{"chapterId": chapter.ID, "start": 0, "status": termStatusNew},
{"chapterId": chapter.ID, "start": 0, "end": 3, "status": termStatusNew},
{"chapterId": chapter.ID, "start": 1, "end": 4, "status": termStatusNew},
{"chapterId": chapter.ID, "start": 4, "end": 5, "status": termStatusNew},
{"chapterId": chapter.ID, "start": 0, "end": 4, "status": "deleted"},
{"chapterId": chapter.ID, "start": 0, "end": 4},
{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusKnown, "level": 4},
{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusLearning, "level": 8},
{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusNew, "definition": strings.Repeat("a", termDefinitionLimit+1)},
{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusNew, "examples": []string{"1", "2", "3", "4", "5", "6"}},
} {
if code, _ := saveTermAPI(t, r, ownerToken, body); code != 400 {
t.Fatalf("invalid input %v must be 400: %d", body, code)
}
}
// Entering learning without a level starts at 1 and still updates the same entry.
code, learning := saveTermAPI(t, r, ownerToken, map[string]any{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusLearning})
if code != 200 || learning.Term.ID != saved.Term.ID || learning.Term.Level != 1 {
t.Fatalf("learning default level: %d %#v", code, learning)
}
// A chapter that is not ready refuses the save instead of storing a word.
if err := db.Model(&Chapter{}).Where("id = ?", chapter.ID).Update("status", statusPending).Error; err != nil {
t.Fatal(err)
}
if code, _ := saveTermAPI(t, r, ownerToken, map[string]any{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusNew}); code != 409 {
t.Fatal("pending chapter must be 409")
}
// An unknown chapter id never confirms whether it belongs to someone else.
if code, _ := saveTermAPI(t, r, ownerToken, map[string]any{"chapterId": 999999, "start": 0, "end": 4, "status": termStatusNew}); code != 404 {
t.Fatal("unknown chapter must be 404")
}
}
// TestMySQLTermTextRoundTrip checks the learner's own text, including characters
// that are never normalized away.
func TestMySQLTermTextRoundTrip(t *testing.T) {
db := testDB(t)
r := Router(db, time.Now)
_, token, chapters := termFixture(t, db, r, "Café Dogs")
code, saved := saveTermAPI(t, r, token, map[string]any{
"chapterId": chapters[0].ID, "start": 0, "end": 4,
"definition": "咖啡\n附带说明", "examples": []string{"A fictional example.", "😀 second"},
"status": termStatusLearning, "level": 7,
})
if code != 201 {
t.Fatalf("save: %d", code)
}
code, data := callAPI(t, r, "GET", fmt.Sprintf("/api/v1/terms/%d", saved.Term.ID), token, nil)
if code != 200 {
t.Fatalf("read: %d", code)
}
var response struct {
Term TermView `json:"term"`
}
json.Unmarshal(data, &response)
if response.Term.Definition != "咖啡\n附带说明" || len(response.Term.Examples) != 2 || response.Term.Examples[1] != "😀 second" || response.Term.Level != 7 {
t.Fatalf("round trip: %#v", response.Term)
}
}
+183
View File
@@ -0,0 +1,183 @@
package lexgo
import (
"bytes"
"errors"
"io"
"net/http"
"time"
"unicode/utf8"
"github.com/gin-gonic/gin"
admin "go-admin/app/admin/models"
"gorm.io/gorm"
)
// A TXT upload is decoded in memory and handed to the same paste pipeline. Nothing is
// written to disk: there is no temporary file to leak, and the client file name never
// becomes a path, so it cannot reach outside the request.
const maxTextUploadBytes = 2 << 20
var (
utf8BOM = []byte{0xEF, 0xBB, 0xBF}
utf16BOMBig = []byte{0xFE, 0xFF}
utf16BOMSmall = []byte{0xFF, 0xFE}
)
// decodeTextUpload turns an uploaded TXT into the exact text the reader will show. Only
// UTF-8 is accepted: an optional BOM is removed before the text is validated, and any byte
// that is not valid UTF-8 rejects the file instead of being replaced, so a chapter never
// contains a substitute character the learner did not write.
func decodeTextUpload(raw []byte) (string, error) {
if len(raw) > maxTextUploadBytes {
return "", failure(400, "TXT 文件不能超过 2 MiB")
}
if bytes.HasPrefix(raw, utf16BOMBig) || bytes.HasPrefix(raw, utf16BOMSmall) {
return "", failure(400, "文件是 UTF-16 编码,请另存为 UTF-8 后重试")
}
raw = bytes.TrimPrefix(raw, utf8BOM)
if !utf8.Valid(raw) {
return "", failure(400, "文件不是 UTF-8 编码,请另存为 UTF-8 后重试")
}
if bytes.IndexByte(raw, 0) >= 0 {
return "", failure(400, "文件包含无法处理的字符,请另存为纯文本后重试")
}
return string(raw), nil
}
type textUpload struct {
RequestID string
Title string
Language string
Text string
}
// readTextUpload parses the multipart submission: one file part plus a whitelist of text
// fields. The uploaded name is never read, not even for validation, because it only serves
// display in the browser.
func readTextUpload(c *gin.Context, allowLanguage bool) (textUpload, error) {
bad := failure(400, "TXT 上传无效,请选择 UTF-8 的 .txt 文件并填写标题")
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxTextUploadBytes+(64<<10))
reader, err := c.Request.MultipartReader()
if err != nil {
return textUpload{}, bad
}
fields := map[string]string{}
var content []byte
for {
part, err := reader.NextPart()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return textUpload{}, uploadBodyError(err, bad)
}
name := part.FormName()
if name == "file" {
if content != nil {
part.Close()
return textUpload{}, bad
}
content, err = io.ReadAll(io.LimitReader(part, maxTextUploadBytes+1))
part.Close()
if err != nil {
return textUpload{}, uploadBodyError(err, bad)
}
if len(content) == 0 || len(content) > maxTextUploadBytes {
return textUpload{}, failure(400, "TXT 文件不能为空且不能超过 2 MiB")
}
continue
}
if !textUploadField(name, allowLanguage) {
part.Close()
return textUpload{}, bad
}
if _, exists := fields[name]; exists {
part.Close()
return textUpload{}, bad
}
value, readErr := io.ReadAll(io.LimitReader(part, 1025))
part.Close()
if readErr != nil {
return textUpload{}, uploadBodyError(readErr, bad)
}
if len(value) > 1024 || !utf8.Valid(value) {
return textUpload{}, bad
}
fields[name] = string(value)
}
if content == nil {
return textUpload{}, bad
}
text, err := decodeTextUpload(content)
if err != nil {
return textUpload{}, err
}
return textUpload{RequestID: fields["requestId"], Title: fields["title"], Language: fields["language"], Text: text}, nil
}
func textUploadField(name string, allowLanguage bool) bool {
switch name {
case "requestId", "title":
return true
case "language":
return allowLanguage
default:
return false
}
}
// An oversized body is reported as a size problem, not as an invalid upload.
func uploadBodyError(err error, bad error) error {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
return failure(400, "TXT 文件不能超过 2 MiB")
}
return bad
}
// registerUploadRoutes reuses the paste pipeline: the decoded file becomes the same
// PasteBook/PasteChapter input, so idempotency, ownership and the ingest job behave exactly
// as they do for pasted text. One upload at a time keeps concurrent large decodes bounded.
func registerUploadRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context, *gorm.DB, admin.SysUser) (any, error)) gin.HandlerFunc, now func() time.Time) {
gate := make(chan struct{}, 1)
enter := func() error {
select {
case gate <- struct{}{}:
return nil
default:
return failure(429, "已有文件正在上传,请稍后重试")
}
}
leave := func() { <-gate }
v.POST("/books/upload", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
if err := enter(); err != nil {
return nil, err
}
defer leave()
upload, err := readTextUpload(c, true)
if err != nil {
return nil, err
}
return PasteBook(tx, u.UserId, now(), PasteBookInput{
RequestID: upload.RequestID, Title: upload.Title, Text: upload.Text, Language: upload.Language,
})
}))
v.POST("/books/:id/chapters/upload", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
bookID, err := pathID(c, "书籍不存在")
if err != nil {
return nil, err
}
if err := enter(); err != nil {
return nil, err
}
defer leave()
upload, err := readTextUpload(c, false)
if err != nil {
return nil, err
}
return PasteChapter(tx, u.UserId, bookID, now(), PasteChapterInput{
RequestID: upload.RequestID, Title: upload.Title, Text: upload.Text,
})
}))
}
+376
View File
@@ -0,0 +1,376 @@
package lexgo
import (
"bytes"
"encoding/json"
"fmt"
"mime/multipart"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
// uploadFixture is the same character set the paste contract preserves, written as the bytes
// a file would hold: CRLF and LF, a tab, curly quotes, an em dash, an ellipsis, an emoji, a
// combining acute accent, a trailing space run and an empty final line.
const uploadFixture = "Mira opened the workshop.\r\n\r\n\tThe sign read “A small step…” — café e\u0301 🙂\r\nTrailing spaces here: \n\n"
// uploadFile posts one multipart TXT submission. A nil file means "no file part", and an
// empty name means "no file name", so the rejection paths stay testable.
func uploadFile(t *testing.T, r *gin.Engine, token, path, fileName string, content []byte, fields map[string]string) (int, string, pasteResponse) {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
names := make([]string, 0, len(fields))
for name := range fields {
names = append(names, name)
}
// Field order must be stable so a failure message is reproducible.
for _, name := range []string{"requestId", "title", "language"} {
if value, ok := fields[name]; ok {
if err := writer.WriteField(name, value); err != nil {
t.Fatal(err)
}
names = removeString(names, name)
}
}
for _, name := range names {
if err := writer.WriteField(name, fields[name]); err != nil {
t.Fatal(err)
}
}
if content != nil {
part, err := writer.CreateFormFile("file", fileName)
if err != nil {
t.Fatal(err)
}
if _, err = part.Write(content); err != nil {
t.Fatal(err)
}
}
writer.Close()
request := httptest.NewRequest("POST", path, &body)
request.Header.Set("Content-Type", writer.FormDataContentType())
if token != "" {
request.Header.Set("Authorization", "Bearer "+token)
}
response := httptest.NewRecorder()
r.ServeHTTP(response, request)
var envelope struct {
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(response.Body.Bytes(), &envelope); err != nil {
t.Fatalf("upload response for %s: %v", path, err)
}
var out pasteResponse
if len(envelope.Data) > 0 {
json.Unmarshal(envelope.Data, &out)
}
return response.Code, envelope.Msg, out
}
func removeString(values []string, target string) []string {
result := values[:0]
for _, value := range values {
if value != target {
result = append(result, value)
}
}
return result
}
func TestDecodeTextUploadRules(t *testing.T) {
// Valid UTF-8 is returned exactly as received, including unusual but legal characters.
if text, err := decodeTextUpload([]byte(uploadFixture)); err != nil || text != uploadFixture {
t.Fatalf("valid file: %q %v", text, err)
}
// A UTF-8 BOM is removed and never becomes part of the original text.
withBOM := append(append([]byte{}, utf8BOM...), []byte("BOM before text\n")...)
if text, err := decodeTextUpload(withBOM); err != nil || text != "BOM before text\n" {
t.Fatalf("BOM file: %q %v", text, err)
}
// Only a BOM leaves an empty text, which the paste rules then reject.
if text, err := decodeTextUpload(append([]byte{}, utf8BOM...)); err != nil || text != "" {
t.Fatalf("BOM only: %q %v", text, err)
}
rejected := []struct {
name string
content []byte
message string
}{
{"invalid UTF-8", []byte{0x41, 0x80, 0x42}, "UTF-8"},
{"latin-1 text", []byte("caf\xe9 plain\n"), "UTF-8"},
{"UTF-16 little endian", []byte{0xFF, 0xFE, 0x41, 0x00}, "UTF-16"},
{"UTF-16 big endian", []byte{0xFE, 0xFF, 0x00, 0x41}, "UTF-16"},
{"NUL byte", []byte("text\x00more"), "无法处理"},
}
for _, tc := range rejected {
text, err := decodeTextUpload(tc.content)
if err == nil || text != "" {
t.Fatalf("%s was accepted as %q", tc.name, text)
}
api, ok := err.(*apiError)
if !ok || api.status != 400 || !strings.Contains(api.message, tc.message) {
t.Fatalf("%s message: %v", tc.name, err)
}
}
// The size boundary is exact on both sides, and the byte limit is checked before decoding.
if _, err := decodeTextUpload(bytes.Repeat([]byte("a"), maxTextUploadBytes)); err != nil {
t.Fatalf("a file at the size limit must be accepted: %v", err)
}
if _, err := decodeTextUpload(bytes.Repeat([]byte("a"), maxTextUploadBytes+1)); err == nil {
t.Fatal("a file over the size limit must be rejected")
}
// The paste rules still bound one chapter, so the byte limit cannot smuggle in more text.
if _, _, _, err := validatePaste("title", strings.Repeat("a", maxChapterRunes)); err != nil {
t.Fatalf("the exact chapter limit must be accepted: %v", err)
}
if _, _, _, err := validatePaste("title", strings.Repeat("a", maxChapterRunes+1)); err == nil {
t.Fatal("the chapter code point limit must still apply to uploaded text")
}
}
// TestMySQLTextUploadImportPath covers the accepted file: it creates the same book, chapter
// and job as a paste, and the reader text equals the file byte for byte.
func TestMySQLTextUploadImportPath(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
drainIngest(t, db)
fields := map[string]string{"requestId": "upload-fixture-0001", "title": "The Workshop Upload", "language": "en"}
code, msg, uploaded := uploadFile(t, r, learner.Token, "/api/v1/books/upload", "workshop.txt", []byte(uploadFixture), fields)
if code != 201 {
t.Fatalf("upload status %d (%s)", code, msg)
}
if uploaded.Book == nil || uploaded.Book.Title != "The Workshop Upload" || uploaded.Book.Language != "en" {
t.Fatalf("unexpected book %+v", uploaded.Book)
}
if uploaded.Chapter.Ordinal != 1 || uploaded.Chapter.Status != statusPending || uploaded.Duplicate {
t.Fatalf("unexpected chapter %+v", uploaded.Chapter)
}
// The worker publishes the chapter, and the reader shows exactly the file content.
drainIngest(t, db)
code, ready := readChapter(t, r, learner.Token, uploaded.Chapter.ID)
if code != 200 || ready.Chapter.Status != statusReady || ready.Chapter.OriginalText != uploadFixture {
t.Fatalf("reader text: status %d, %+v", code, ready.Chapter)
}
if want := len([]rune(uploadFixture)); ready.Chapter.CharCount != want {
t.Fatalf("charCount %d, want %d", ready.Chapter.CharCount, want)
}
// A UTF-8 BOM is stripped, so the reader never shows it.
bomFields := map[string]string{"requestId": "upload-bom-000002", "title": "BOM Upload", "language": "en"}
withBOM := append(append([]byte{}, utf8BOM...), []byte("Plain text with BOM.\n")...)
code, msg, bom := uploadFile(t, r, learner.Token, "/api/v1/books/upload", "bom.txt", withBOM, bomFields)
if code != 201 {
t.Fatalf("BOM upload status %d (%s)", code, msg)
}
drainIngest(t, db)
if code, read := readChapter(t, r, learner.Token, bom.Chapter.ID); code != 200 || read.Chapter.OriginalText != "Plain text with BOM.\n" {
t.Fatalf("BOM text: status %d, %q", code, read.Chapter.OriginalText)
}
}
// TestMySQLTextUploadIdempotencyAndAppend reuses the paste job rules: one file yields one
// chapter, a repeated upload answers with that chapter, and the same request id with other
// content is a conflict.
func TestMySQLTextUploadIdempotencyAndAppend(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
other := newLearner(t, r, owner.Token)
drainIngest(t, db)
fields := map[string]string{"requestId": "upload-repeat-0001", "title": "Repeat Upload", "language": "en"}
code, msg, first := uploadFile(t, r, learner.Token, "/api/v1/books/upload", "repeat.txt", []byte("First upload body.\n"), fields)
if code != 201 {
t.Fatalf("first upload %d (%s)", code, msg)
}
code, msg, again := uploadFile(t, r, learner.Token, "/api/v1/books/upload", "repeat.txt", []byte("First upload body.\n"), fields)
if code != 200 || !again.Duplicate || again.Chapter.ID != first.Chapter.ID {
t.Fatalf("repeat upload %d (%s): %+v", code, msg, again)
}
var chapters int64
db.Model(&Chapter{}).Where("book_id = ?", first.Book.ID).Count(&chapters)
if chapters != 1 {
t.Fatalf("a repeated upload created %d chapters", chapters)
}
// The same request id with other content is a conflict, not a second chapter.
code, msg, _ = uploadFile(t, r, learner.Token, "/api/v1/books/upload", "repeat.txt", []byte("Different body.\n"), fields)
if code != 409 {
t.Fatalf("changed content status %d (%s)", code, msg)
}
// Appending uses the same rules and the same job pipeline.
appendFields := map[string]string{"requestId": "upload-append-0002", "title": "Second Chapter"}
path := fmt.Sprintf("/api/v1/books/%d/chapters/upload", first.Book.ID)
code, msg, appended := uploadFile(t, r, learner.Token, path, "second.txt", []byte("A single plain paragraph.\n"), appendFields)
if code != 201 {
t.Fatalf("append upload %d (%s)", code, msg)
}
if appended.Chapter.Ordinal != 2 || appended.Chapter.BookID != first.Book.ID {
t.Fatalf("unexpected appended chapter %+v", appended.Chapter)
}
code, msg, againAppend := uploadFile(t, r, learner.Token, path, "second.txt", []byte("A single plain paragraph.\n"), appendFields)
if code != 200 || !againAppend.Duplicate || againAppend.Chapter.ID != appended.Chapter.ID {
t.Fatalf("repeated append %d (%s): %+v", code, msg, againAppend)
}
// The append endpoint does not accept a language field: the book owns the language.
code, msg, _ = uploadFile(t, r, learner.Token, path, "second.txt", []byte("Another body.\n"),
map[string]string{"requestId": "upload-append-lang-0004", "title": "Second Chapter", "language": "en"})
if code != 400 {
t.Fatalf("append with a language field: status %d (%s)", code, msg)
}
// Another account cannot append into this book, and never learns whether it exists.
code, foreignMsg, _ := uploadFile(t, r, other.Token, path, "second.txt", []byte("Foreign body.\n"), map[string]string{"requestId": "upload-foreign-0003", "title": "Foreign"})
if code != 404 {
t.Fatalf("foreign append status %d (%s)", code, foreignMsg)
}
// The other account's own library stays empty.
_, list := bookList(t, r, other.Token)
if len(list.Items) != 0 {
t.Fatalf("the other account must not see this book: %+v", list.Items)
}
}
// TestMySQLTextUploadRejectsInvalidSubmissions covers the validation surface, including the
// client file name, which is never used as a path.
func TestMySQLTextUploadRejectsInvalidSubmissions(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
drainIngest(t, db)
base := map[string]string{"requestId": "upload-invalid-0001", "title": "Invalid Upload", "language": "en"}
cases := []struct {
name string
fileName string
content []byte
fields map[string]string
status int
}{
{"no file part", "", nil, base, 400},
{"empty file", "empty.txt", []byte{}, base, 400},
{"empty file part", "empty.txt", []byte{}, base, 400},
{"whitespace only", "blank.txt", []byte(" \n\t\n"), base, 400},
{"BOM only", "bom.txt", append([]byte{}, utf8BOM...), base, 400},
{"invalid UTF-8", "latin1.txt", []byte("caf\xe9\n"), base, 400},
{"UTF-16 file", "unicode.txt", []byte{0xFF, 0xFE, 0x41, 0x00}, base, 400},
{"oversized file", "big.txt", bytes.Repeat([]byte("a"), maxTextUploadBytes+1), base, 400},
{"missing request id", "text.txt", []byte("Body.\n"), map[string]string{"title": "Invalid Upload", "language": "en"}, 400},
{"missing title", "text.txt", []byte("Body.\n"), map[string]string{"requestId": "upload-invalid-0002", "language": "en"}, 400},
{"unsupported language", "text.txt", []byte("Body.\n"), map[string]string{"requestId": "upload-invalid-0004", "title": "Invalid Upload", "language": "fr"}, 400},
{"short request id", "text.txt", []byte("Body.\n"), map[string]string{"requestId": "short", "title": "Invalid Upload", "language": "en"}, 400},
{"unknown field", "text.txt", []byte("Body.\n"), map[string]string{"requestId": "upload-invalid-0005", "title": "Invalid Upload", "language": "en", "ownerId": "9"}, 400},
}
for _, tc := range cases {
code, msg, _ := uploadFile(t, r, learner.Token, "/api/v1/books/upload", tc.fileName, tc.content, tc.fields)
if code != tc.status {
t.Fatalf("%s: status %d (%s), want %d", tc.name, code, msg, tc.status)
}
if msg == "" {
t.Fatalf("%s: rejection without a readable message", tc.name)
}
}
// No rejected submission left a book behind.
_, list := bookList(t, r, learner.Token)
if len(list.Items) != 0 {
t.Fatalf("rejected uploads created books: %+v", list.Items)
}
// A missing language field follows the paste rule and defaults to English.
code, msg, defaulted := uploadFile(t, r, learner.Token, "/api/v1/books/upload", "default.txt", []byte("Body without language.\n"),
map[string]string{"requestId": "upload-default-lang-0007", "title": "Default Language"})
if code != 201 || defaulted.Book == nil || defaulted.Book.Language != "en" {
t.Fatalf("missing language must default to English: status %d (%s) book %+v", code, msg, defaulted.Book)
}
// The uploaded name is only a display string: a traversal-shaped name changes nothing.
hostile := map[string]string{"requestId": "upload-hostile-0006", "title": "Hostile Name", "language": "en"}
code, msg, uploaded := uploadFile(t, r, learner.Token, "/api/v1/books/upload", `..\..\windows\system32\evil.txt`, []byte("Hostile but harmless.\n"), hostile)
if code != 201 {
t.Fatalf("hostile name status %d (%s)", code, msg)
}
var chapter Chapter
if err := db.First(&chapter, uploaded.Chapter.ID).Error; err != nil {
t.Fatal(err)
}
var book Book
if err := db.First(&book, uploaded.Book.ID).Error; err != nil {
t.Fatal(err)
}
for _, field := range []string{chapter.Title, chapter.OriginalText, book.Title} {
if strings.Contains(field, "evil") || strings.Contains(field, "system32") || strings.Contains(field, `..`) {
t.Fatalf("the uploaded name leaked into stored data: %q", field)
}
}
// An unrelated content type is not a multipart upload.
request := httptest.NewRequest("POST", "/api/v1/books/upload", strings.NewReader(`{"requestId":"json-upload-0001"}`))
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer "+learner.Token)
response := httptest.NewRecorder()
r.ServeHTTP(response, request)
if response.Code != 400 {
t.Fatalf("JSON body accepted as an upload: %d", response.Code)
}
// Uploading without a session is rejected before any parsing.
code, _, _ = uploadFile(t, r, "", "/api/v1/books/upload", "text.txt", []byte("Body.\n"), base)
if code != 401 {
t.Fatalf("anonymous upload status %d", code)
}
}
// TestMySQLTextUploadKeepsChapterLimit proves the byte cap cannot bypass the one-chapter
// code point rule, and that a large but legal file is stored completely.
func TestMySQLTextUploadKeepsChapterLimit(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
drainIngest(t, db)
// Exactly at the chapter limit: accepted, and the stored text keeps its full length.
atLimit := strings.Repeat("a", maxChapterRunes-1) + "\n"
fields := map[string]string{"requestId": "upload-limit-0001", "title": "At The Limit", "language": "en"}
code, msg, uploaded := uploadFile(t, r, learner.Token, "/api/v1/books/upload", "limit.txt", []byte(atLimit), fields)
if code != 201 {
t.Fatalf("upload at the chapter limit %d (%s)", code, msg)
}
drainIngest(t, db)
if code, read := readChapter(t, r, learner.Token, uploaded.Chapter.ID); code != 200 || read.Chapter.OriginalText != atLimit {
t.Fatalf("chapter at the limit: status %d, length %d", code, len([]rune(read.Chapter.OriginalText)))
}
// One code point more is rejected by the same rule that already applies to a paste.
overLimit := strings.Repeat("a", maxChapterRunes+1)
code, msg, _ = uploadFile(t, r, learner.Token, "/api/v1/books/upload", "over.txt", []byte(overLimit), map[string]string{"requestId": "upload-limit-0002", "title": "Over The Limit", "language": "en"})
if code != 400 || !strings.Contains(msg, "100000") {
t.Fatalf("upload over the chapter limit: status %d (%s)", code, msg)
}
}
// TestMySQLTextUploadAndPasteShareOnePipeline checks the two entry points cannot produce a
// second chapter for the same submitted content when the client stays on one request id.
func TestMySQLTextUploadAndPasteShareOnePipeline(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
drainIngest(t, db)
fields := map[string]string{"requestId": "upload-shared-0001", "title": "Shared Pipeline", "language": "en"}
code, msg, uploaded := uploadFile(t, r, learner.Token, "/api/v1/books/upload", "shared.txt", []byte("Shared body.\n"), fields)
if code != 201 {
t.Fatalf("upload %d (%s)", code, msg)
}
// The same request id through the paste endpoint answers with the uploaded chapter.
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "upload-shared-0001", "title": "Shared Pipeline", "text": "Shared body.\n", "language": "en"})
if code != 200 || !pasted.Duplicate || pasted.Chapter.ID != uploaded.Chapter.ID {
t.Fatalf("paste after upload %d: %+v", code, pasted)
}
var chapters int64
db.Model(&Chapter{}).Where("book_id = ?", uploaded.Book.ID).Count(&chapters)
if chapters != 1 {
t.Fatalf("the two entry points created %d chapters", chapters)
}
}
+213
View File
@@ -0,0 +1,213 @@
package lexgo
import (
"errors"
"net/url"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/gin-gonic/gin"
admin "go-admin/app/admin/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// The vocabulary list reuses the audit list's paging contract: a whitelisted query, page >= 1,
// a bounded limit and a rejection instead of a silent default for anything malformed.
const (
maxVocabularyPageSize = 100
maxVocabularyPage = 1000000
maxVocabularyQuery = 64
)
type TermListFilter struct {
Query string
Status string
Kind string
Page int
Limit int
}
type TermListPage struct {
Items []TermView `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
}
// escapeLike turns a value into a literal LIKE operand. Without it a query of "%" would match
// every entry, which is a wrong answer rather than a syntax error.
func escapeLike(value string) string {
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(value)
}
func parseTermListQuery(q url.Values) (TermListFilter, error) {
f := TermListFilter{Page: 1, Limit: 20}
bad := failure(400, "词汇库筛选参数无效")
for key, values := range q {
if len(values) != 1 {
return f, bad
}
switch key {
case "query", "status", "kind", "page", "limit":
default:
return f, bad
}
}
for key, dest := range map[string]*int{"page": &f.Page, "limit": &f.Limit} {
if value := q.Get(key); value != "" {
n, err := strconv.Atoi(value)
if err != nil || n < 1 {
return f, bad
}
*dest = n
}
}
if f.Limit > maxVocabularyPageSize || f.Page > maxVocabularyPage {
return f, bad
}
if f.Status = q.Get("status"); f.Status != "" && !termStatuses[f.Status] {
return f, bad
}
if f.Kind = q.Get("kind"); f.Kind != "" && f.Kind != termKindWord && f.Kind != termKindPhrase {
return f, bad
}
f.Query = strings.TrimSpace(q.Get("query"))
if utf8.RuneCountInString(f.Query) > maxVocabularyQuery {
return f, bad
}
return f, nil
}
// ListTerms returns one page of the caller's own entries for one language. The identity key,
// the original spelling and the personal definition are all searched, and the key is compared
// lower case because that column uses a binary collation.
func ListTerms(tx *gorm.DB, owner int, language string, f TermListFilter) (TermListPage, error) {
query := func() *gorm.DB {
rows := tx.Model(&Term{}).Where("owner_id = ? AND language = ?", owner, language)
if f.Status != "" {
rows = rows.Where("status = ?", f.Status)
}
// A phrase key always contains a space and a word key never does.
if f.Kind == termKindWord {
rows = rows.Where("term NOT LIKE ?", "% %")
}
if f.Kind == termKindPhrase {
rows = rows.Where("term LIKE ?", "% %")
}
if f.Query != "" {
key := "%" + escapeLike(strings.ToLower(f.Query)) + "%"
text := "%" + escapeLike(f.Query) + "%"
rows = rows.Where("term LIKE ? OR original_form LIKE ? OR definition LIKE ?", key, text, text)
}
return rows
}
page := TermListPage{Items: []TermView{}, Page: f.Page, Limit: f.Limit}
var total int64
if err := query().Count(&total).Error; err != nil {
return page, err
}
page.Total = int(total)
var rows []Term
if err := query().Order("updated_at DESC, id DESC").
Offset((f.Page - 1) * f.Limit).Limit(f.Limit).Find(&rows).Error; err != nil {
return page, err
}
for _, row := range rows {
page.Items = append(page.Items, termView(row))
}
return page, nil
}
type TermUpdateInput struct {
Definition *string `json:"definition"`
Examples *[]string `json:"examples"`
Status *string `json:"status"`
Level *int `json:"level"`
}
// UpdateTerm edits one owned entry by id. Identity cannot be edited: the key, the original
// spelling and the kind stay as stored, so the entry keeps matching the same text. Only a real
// status or level change moves the review schedule, exactly like a save from the reader.
func UpdateTerm(tx *gorm.DB, owner int, termID int64, input TermUpdateInput, now time.Time) (TermView, error) {
if input.Definition == nil && input.Examples == nil && input.Status == nil && input.Level == nil {
return TermView{}, failure(400, "请选择要修改的内容")
}
var term Term
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND owner_id = ?", termID, owner).First(&term).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return TermView{}, failure(404, "词条不存在")
}
return TermView{}, err
}
definition, examples := term.Definition, splitExamples(term.Examples)
if input.Definition != nil {
definition = *input.Definition
}
if input.Examples != nil {
examples = *input.Examples
}
definition, exampleText, err := termContent(definition, examples)
if err != nil {
return TermView{}, err
}
status := term.Status
if input.Status != nil {
status = *input.Status
}
// A level on its own is validated against the status the entry already has.
level, err := termLevel(status, input.Level, term, true)
if err != nil {
return TermView{}, err
}
when := stamp(now)
updates := map[string]any{"definition": definition, "examples": exampleText, "updated_at": when}
reschedule := status != term.Status || level != term.Level
if reschedule {
updates["status"] = status
updates["level"] = level
}
if err := tx.Model(&Term{}).Where("id = ? AND owner_id = ?", term.ID, owner).Updates(updates).Error; err != nil {
return TermView{}, err
}
term.Definition, term.Examples, term.UpdatedAt = definition, exampleText, when
if reschedule {
term.Status, term.Level = status, level
}
if err := syncTermReview(tx, term, reschedule, now); err != nil {
return TermView{}, err
}
return termView(term), nil
}
func registerVocabularyRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context, *gorm.DB, admin.SysUser) (any, error)) gin.HandlerFunc, now func() time.Time) {
v.GET("/terms", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
filter, err := parseTermListQuery(c.Request.URL.Query())
if err != nil {
return nil, err
}
language, err := languageOf(tx, u.UserId)
if err != nil {
return nil, err
}
return ListTerms(tx, u.UserId, language, filter)
}))
v.PATCH("/terms/:id", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
id, err := pathID(c, "词条不存在")
if err != nil {
return nil, err
}
var input TermUpdateInput
if err := decode(c, &input); err != nil {
return nil, err
}
term, err := UpdateTerm(tx, u.UserId, id, input, now())
if err != nil {
return nil, err
}
return gin.H{"term": term}, nil
}))
}
+285
View File
@@ -0,0 +1,285 @@
package lexgo
import (
"encoding/json"
"fmt"
"net/url"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
)
func vocabularyList(t *testing.T, r *gin.Engine, token, query string) (int, TermListPage) {
t.Helper()
path := "/api/v1/terms"
if query != "" {
path += "?" + query
}
code, _, data := callRaw(t, r, "GET", path, token, nil)
var page TermListPage
if len(data) > 0 {
json.Unmarshal(data, &page)
}
return code, page
}
func editTerm(t *testing.T, r *gin.Engine, token string, termID int64, body map[string]any) (int, TermView) {
t.Helper()
code, _, data := callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/terms/%d", termID), token, body)
var payload struct {
Term TermView
}
if len(data) > 0 {
json.Unmarshal(data, &payload)
}
return code, payload.Term
}
func savePhraseAPI(t *testing.T, r *gin.Engine, token string, chapterID int64, text string, words []string, status string, level *int) TermSave {
t.Helper()
tokens := phraseTokens(text)
start, end := spanOf(t, tokens, words...)
body := map[string]any{"chapterId": chapterID, "start": start, "end": end, "definition": "短语释义", "status": status}
if level != nil {
body["level"] = *level
}
code, msg, data := callRaw(t, r, "POST", "/api/v1/phrases", token, body)
if code != 201 && code != 200 {
t.Fatalf("save phrase %d (%s)", code, msg)
}
var saved struct {
Term TermView
Created bool
}
json.Unmarshal(data, &saved)
return TermSave{Term: saved.Term, Created: saved.Created}
}
// TestMySQLVocabularyListSearchFiltersAndPaging covers the list contract end to end.
func TestMySQLVocabularyListSearchFiltersAndPaging(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
other := newLearner(t, r, owner.Token)
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "vocab-list-0001", "title": "Vocabulary list", "text": phraseFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
drainIngest(t, db)
take := saveWord(t, r, learner.Token, pasted.Chapter.ID, 0, 4, termStatusNew, nil)
level := 2
phrase := savePhraseAPI(t, r, learner.Token, pasted.Chapter.ID, phraseFixtureText, []string{"a", "small", "step"}, termStatusLearning, &level)
code, page := vocabularyList(t, r, learner.Token, "")
if code != 200 || page.Total != 2 || len(page.Items) != 2 || page.Page != 1 || page.Limit != 20 {
t.Fatalf("list: %d %+v", code, page)
}
// The most recently updated entry comes first, and each row carries its kind.
if page.Items[0].ID != phrase.Term.ID || page.Items[0].Kind != "phrase" || page.Items[0].WordCount != 3 {
t.Fatalf("order and kind: %+v", page.Items)
}
if page.Items[1].ID != take.Term.ID || page.Items[1].Kind != "word" || page.Items[1].WordCount != 1 {
t.Fatalf("word row: %+v", page.Items[1])
}
// Search matches the identity key, and case does not matter even though the key column is
// binary collated.
code, page = vocabularyList(t, r, learner.Token, url.Values{"query": {"SMALL"}}.Encode())
if code != 200 || page.Total != 1 || page.Items[0].ID != phrase.Term.ID {
t.Fatalf("search by key: %d %+v", code, page)
}
// Search matches the personal definition, including Chinese text.
code, page = vocabularyList(t, r, learner.Token, url.Values{"query": {"短语"}}.Encode())
if code != 200 || page.Total != 1 || page.Items[0].ID != phrase.Term.ID {
t.Fatalf("search by definition: %d %+v", code, page)
}
// Search matches the original spelling with its interior punctuation.
code, page = vocabularyList(t, r, learner.Token, url.Values{"query": {"a small, step"}}.Encode())
if code != 200 || page.Total != 1 {
t.Fatalf("search by original form: %d %+v", code, page)
}
// A wildcard is a literal, not a pattern.
for _, literal := range []string{"%", "_", "sma_ll"} {
code, page = vocabularyList(t, r, learner.Token, url.Values{"query": {literal}}.Encode())
if code != 200 || page.Total != 0 {
t.Fatalf("wildcard %q must not match: %d %+v", literal, code, page)
}
}
// Filters combine with search and with each other.
cases := []struct {
query string
want []int64
}{
{url.Values{"status": {termStatusNew}}.Encode(), []int64{take.Term.ID}},
{url.Values{"kind": {termKindPhrase}}.Encode(), []int64{phrase.Term.ID}},
{url.Values{"kind": {termKindWord}, "status": {termStatusLearning}}.Encode(), []int64{}},
{url.Values{"kind": {termKindPhrase}, "status": {termStatusLearning}, "query": {"small"}}.Encode(), []int64{phrase.Term.ID}},
{url.Values{"kind": {termKindPhrase}, "query": {"take"}}.Encode(), []int64{}},
}
for _, tc := range cases {
code, page = vocabularyList(t, r, learner.Token, tc.query)
got := make([]int64, 0, len(page.Items))
for _, item := range page.Items {
got = append(got, item.ID)
}
if code != 200 || len(got) != len(tc.want) {
t.Fatalf("%s -> %d %+v", tc.query, code, page)
}
for index := range tc.want {
if got[index] != tc.want[index] {
t.Fatalf("%s -> %+v, want %+v", tc.query, got, tc.want)
}
}
}
// Paging: one row per page, a page past the end is empty, and total stays the full count.
code, first := vocabularyList(t, r, learner.Token, "limit=1&page=1")
code2, second := vocabularyList(t, r, learner.Token, "limit=1&page=2")
code3, beyond := vocabularyList(t, r, learner.Token, "limit=1&page=9")
if code != 200 || code2 != 200 || code3 != 200 || first.Total != 2 || second.Total != 2 || beyond.Total != 2 {
t.Fatalf("paging totals: %+v %+v %+v", first, second, beyond)
}
if len(first.Items) != 1 || len(second.Items) != 1 || first.Items[0].ID == second.Items[0].ID {
t.Fatalf("paging pages: %+v %+v", first.Items, second.Items)
}
if len(beyond.Items) != 0 {
t.Fatalf("a page past the end must be empty: %+v", beyond.Items)
}
// Rejected parameters, and the caller's own entries only.
for _, query := range []string{"limit=101", "page=0", "status=deleted", "kind=other", "other=1", "page=1&page=2"} {
if code, _ := vocabularyList(t, r, learner.Token, query); code != 400 {
t.Fatalf("%s must be rejected, got %d", query, code)
}
}
if code, page := vocabularyList(t, r, other.Token, ""); code != 200 || page.Total != 0 {
t.Fatalf("another account must see nothing: %d %+v", code, page)
}
if code, _ := vocabularyList(t, r, "", ""); code != 401 {
t.Fatal("the list needs a session")
}
}
// TestMySQLVocabularyEditKeepsDomainRules covers editing by id: the same domain rules and the
// same schedule rule as a save from the reader.
func TestMySQLVocabularyEditKeepsDomainRules(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
other := newLearner(t, r, owner.Token)
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "vocab-edit-0001", "title": "Vocabulary edit", "text": phraseFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
drainIngest(t, db)
saved := saveWord(t, r, learner.Token, pasted.Chapter.ID, 0, 4, termStatusNew, nil)
termID := saved.Term.ID
before := termReviewRow(t, db, termID)
// Editing only the text keeps the schedule, the level and the counters untouched.
code, edited := editTerm(t, r, learner.Token, termID, map[string]any{"definition": "拿起;接受", "examples": []string{"Take a small step."}})
if code != 200 || edited.Definition != "拿起;接受" || len(edited.Examples) != 1 {
t.Fatalf("text edit: %d %+v", code, edited)
}
if edited.Level != 0 || edited.Status != termStatusNew || edited.Kind != "word" || edited.WordCount != 1 {
t.Fatalf("a text edit changed the state: %+v", edited)
}
after := termReviewRow(t, db, termID)
if !after.DueAt.Equal(before.DueAt) || after.ReviewCount != before.ReviewCount || after.CorrectCount != before.CorrectCount {
t.Fatalf("a text edit moved the schedule: %+v -> %+v", before, after)
}
// Entering learning with a level reschedules by that level's interval and leaves today's queue.
clock := time.Now().UTC()
code, learned := editTerm(t, r, learner.Token, termID, map[string]any{"status": termStatusLearning, "level": 5})
if code != 200 || learned.Level != 5 || learned.Status != termStatusLearning {
t.Fatalf("learning edit: %d %+v", code, learned)
}
after = termReviewRow(t, db, termID)
if after.DueAt.Before(clock.AddDate(0, 0, 15).Add(-time.Minute)) {
t.Fatalf("level 5 must schedule 15 days out: %v", after.DueAt)
}
if code, queue := reviewQueue(t, r, learner.Token); code != 200 || len(queue.Items) != 0 {
t.Fatalf("a scheduled entry must leave today's queue: %d %+v", code, queue.Items)
}
// A level without learning, and an unknown status, are refused by the shared rules.
if code, _ := editTerm(t, r, learner.Token, termID, map[string]any{"status": termStatusKnown, "level": 3}); code != 400 {
t.Fatal("a level outside learning must be rejected")
}
if code, _ := editTerm(t, r, learner.Token, termID, map[string]any{"status": "deleted"}); code != 400 {
t.Fatal("an unknown status must be rejected")
}
if code, _ := editTerm(t, r, learner.Token, termID, map[string]any{"definition": strings.Repeat("a", termDefinitionLimit+1)}); code != 400 {
t.Fatal("an over-long definition must be rejected")
}
if code, _ := editTerm(t, r, learner.Token, termID, map[string]any{"examples": []string{"1", "2", "3", "4", "5", "6"}}); code != 400 {
t.Fatal("too many examples must be rejected")
}
if code, _ := editTerm(t, r, learner.Token, termID, map[string]any{"term": "forged"}); code != 400 {
t.Fatal("identity must not be editable")
}
if code, _ := editTerm(t, r, learner.Token, termID, map[string]any{"ownerId": 9}); code != 400 {
t.Fatal("an unknown field must be rejected")
}
if code, _ := editTerm(t, r, learner.Token, termID, map[string]any{}); code != 400 {
t.Fatal("an empty edit must be rejected")
}
// Marking it known takes it out of the queue; going back to a new word makes it due again.
if code, known := editTerm(t, r, learner.Token, termID, map[string]any{"status": termStatusKnown}); code != 200 || known.Level != 0 {
t.Fatalf("known edit: %d %+v", code, known)
}
if code, queue := reviewQueue(t, r, learner.Token); code != 200 || len(queue.Items) != 0 {
t.Fatalf("a known entry must stay out of the queue: %d %+v", code, queue.Items)
}
if code, fresh := editTerm(t, r, learner.Token, termID, map[string]any{"status": termStatusNew}); code != 200 || fresh.Level != 0 {
t.Fatalf("back to new: %d %+v", code, fresh)
}
if code, queue := reviewQueue(t, r, learner.Token); code != 200 || len(queue.Items) != 1 || queue.Items[0].ID != termID {
t.Fatalf("a new entry must be due at once: %d %+v", code, queue.Items)
}
// Answering once, then editing, keeps the history and does not re-count anything.
_, queue := reviewQueue(t, r, learner.Token)
if code, applied := answerReview(t, r, learner.Token, termID, answerBody("vocab-edit-answer-01", reviewGradeCorrect, queue.Items[0].DueAt)); code != 201 || applied.LevelAfter != 1 {
t.Fatalf("answer: %d %+v", code, applied)
}
counted := termReviewRow(t, db, termID)
if code, edited := editTerm(t, r, learner.Token, termID, map[string]any{"definition": "拿起(改过)"}); code != 200 || edited.Level != 1 {
t.Fatalf("edit after an answer: %d %+v", code, edited)
}
if now := termReviewRow(t, db, termID); now.ReviewCount != counted.ReviewCount || now.CorrectCount != counted.CorrectCount || !now.DueAt.Equal(counted.DueAt) {
t.Fatalf("an edit must not rewrite history: %+v -> %+v", counted, now)
}
// The same edit twice is answered the same way with no further side effects.
code, again := editTerm(t, r, learner.Token, termID, map[string]any{"definition": "拿起(改过)"})
if code != 200 || again.Definition != "拿起(改过)" || !termReviewRow(t, db, termID).DueAt.Equal(counted.DueAt) {
t.Fatalf("a repeated edit: %d %+v", code, again)
}
// Ownership and input shape.
if code, _ := editTerm(t, r, other.Token, termID, map[string]any{"definition": "stolen"}); code != 404 {
t.Fatal("another account must not edit this entry")
}
if code, _ := editTerm(t, r, learner.Token, 999999, map[string]any{"definition": "x"}); code != 404 {
t.Fatal("an unknown entry must be 404")
}
if code, _ := editTerm(t, r, "", termID, map[string]any{"definition": "x"}); code != 401 {
t.Fatal("editing needs a session")
}
// A phrase is edited through the same endpoint and keeps its kind and word count.
phraseLevel := 1
phrase := savePhraseAPI(t, r, learner.Token, pasted.Chapter.ID, phraseFixtureText, []string{"a", "small", "step"}, termStatusNew, nil)
code, editedPhrase := editTerm(t, r, learner.Token, phrase.Term.ID, map[string]any{"status": termStatusLearning, "level": phraseLevel, "definition": "一小步(改)"})
if code != 200 || editedPhrase.Kind != "phrase" || editedPhrase.WordCount != 3 || editedPhrase.Level != phraseLevel {
t.Fatalf("phrase edit: %d %+v", code, editedPhrase)
}
}
+27 -7
View File
@@ -16,16 +16,24 @@ import (
const WordNetSHA = "cbda5ea6eef7f36a97a43d4a75f85e07fccbb4f23657d27b4ccbc93e2646ab59"
const WordNetSource = "https://raw.githubusercontent.com/nltk/nltk_data/96f9b3252457a2b97e52aec64c3dfceeb5c312d5/packages/corpora/wordnet.zip"
const WordNetVersion = "3.0"
// Dictionary slots are fixed so an operator always knows which row holds which resource.
const (
WordNetSlot = 1
ChineseSlot = 2
)
const maxDictionaryZip = 32 << 20
const maxDictionaryInflated = 128 << 20
type TextToken struct {
Text string `json:"text"`
Start int `json:"start"`
End int `json:"end"`
StartUtf16 int `json:"startUtf16"`
EndUtf16 int `json:"endUtf16"`
Kind string `json:"kind"`
Text string `json:"text"`
Start int `json:"start"`
End int `json:"end"`
StartUtf16 int `json:"startUtf16"`
EndUtf16 int `json:"endUtf16"`
Kind string `json:"kind"`
Term *TokenTerm `json:"term,omitempty"`
}
// Tokenize never normalizes text. Letters begin words; marks continue them and
@@ -61,7 +69,7 @@ func Tokenize(text string) []TextToken {
utf16Offset++
}
}
result = append(result, TextToken{string(runes[start:i]), start, i, start16, utf16Offset, kind})
result = append(result, TextToken{Text: string(runes[start:i]), Start: start, End: i, StartUtf16: start16, EndUtf16: utf16Offset, Kind: kind})
}
return result
}
@@ -75,6 +83,11 @@ type DictionaryEntry struct {
POS string `json:"pos"`
Definition string `json:"definition"`
Examples []string `json:"examples"`
// The Chinese gloss and phonetic come from the 英汉 resource and are empty for WordNet senses.
Translation string `json:"translation,omitempty"`
Phonetic string `json:"phonetic,omitempty"`
PhoneticSource string `json:"phoneticSource,omitempty"`
Provider string `json:"provider,omitempty"`
}
type LookupResult struct {
Status string `json:"status"`
@@ -83,6 +96,12 @@ type LookupResult struct {
Candidates []string `json:"candidates"`
Entries []DictionaryEntry `json:"entries"`
Resource *LookupResource `json:"resource,omitempty"`
Resources []LookupResource `json:"resources,omitempty"`
// Phonetic repeats the first phonetic found among the entries so a reader can show it next to
// the word without walking the senses; PhoneticSource records whether it is a CMUdict IPA
// transcription or the ECDICT notation kept with character-level normalisation only.
Phonetic string `json:"phonetic,omitempty"`
PhoneticSource string `json:"phoneticSource,omitempty"`
}
type LookupResource struct {
Name string `json:"name"`
@@ -299,6 +318,7 @@ func (wn *WordNet) Lookup(query string) LookupResult {
}
entry := part.data[offset]
entry.Lemma = strings.ReplaceAll(form, "_", " ")
entry.Provider = "wordnet"
result.Entries = append(result.Entries, entry)
}
}

Some files were not shown because too many files have changed in this diff Show More