Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2fdf405a9 | ||
|
|
42945dec0a | ||
|
|
f565c1ca26 | ||
|
|
4e4fb85d2f | ||
|
|
a412e000c0 | ||
|
|
b1b2e6c6fe | ||
|
|
c293a41714 | ||
|
|
728f7d6c1e | ||
|
|
3fe9bcb058 | ||
|
|
c22f9c3f06 | ||
|
|
771c3df976 | ||
|
|
3ab26afe4a | ||
|
|
0fd661e061 | ||
|
|
8a25b7be8b | ||
|
|
ab23ff60e5 | ||
|
|
dde2ac127a | ||
|
|
420365fc6b | ||
|
|
38d8975325 | ||
|
|
bdb38970e0 | ||
|
|
20e13e909b | ||
|
|
e069894c4e | ||
|
|
5f86f17ac4 | ||
|
|
a9952b3516 | ||
|
|
f2de395cd9 | ||
|
|
000d7e0dbf | ||
|
|
3ebec5b11b | ||
|
|
329ea7336a | ||
|
|
a658fa6fd1 | ||
|
|
b1b76cfb7c | ||
|
|
bd77aa0a46 | ||
|
|
1319c56cf8 | ||
|
|
1d19b2b92f | ||
|
|
35ed692c4b | ||
|
|
65b50d5038 | ||
|
|
fb256d4916 | ||
|
|
61d3f63c69 | ||
|
|
cd2b893bba | ||
|
|
85b8e24429 | ||
|
|
325849816e | ||
|
|
ec5ec2db35 | ||
|
|
0328505b77 | ||
|
|
527d8af0c4 | ||
|
|
a5a8c3c72d | ||
|
|
8c0946af9f | ||
|
|
33182e584b |
@@ -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`),未改写分支历史。
|
||||
|
||||
@@ -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)。账号使用用户名,无需邮箱;没有随代码交付的默认密码。
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, /停用/)
|
||||
})
|
||||
|
||||
@@ -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`),未改写分支历史。
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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 记法**(老式音标,仅做字符级规范化)、
|
||||
**子集口径**(决定哪些词进入英汉词典的规则)。
|
||||
|
||||
@@ -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` 自然到期,不需要数据迁移。
|
||||
|
||||
@@ -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 系列范围不受影响。
|
||||
|
||||
@@ -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
@@ -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 验收通过。
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 },
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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('已保存')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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('虚构释义')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
clearPosition, currentRatio, normalizeRatio, POSITION_KEY_PREFIX, positionKey, readPosition, writePosition,
|
||||
} from '../composables/readingPosition'
|
||||
import { ignoresShortcut, shortcutFor } from '../composables/reviewShortcuts'
|
||||
|
||||
const sha = 'a'.repeat(64)
|
||||
const otherSha = 'b'.repeat(64)
|
||||
|
||||
describe('reading position storage', () => {
|
||||
it('stores one position per account and chapter', () => {
|
||||
const storage = localStorage
|
||||
storage.clear()
|
||||
writePosition(storage, 42, 7, sha, 0.5, new Date('2026-09-15T03:00:00Z'))
|
||||
expect(readPosition(storage, 42, 7, sha)).toEqual({ ratio: 0.5, sha, at: '2026-09-15T03:00:00.000Z' })
|
||||
// Another chapter, another account and the signed-out state know nothing about it.
|
||||
expect(readPosition(storage, 42, 8, sha)).toBeNull()
|
||||
expect(readPosition(storage, 43, 7, sha)).toBeNull()
|
||||
expect(readPosition(storage, null, 7, sha)).toBeNull()
|
||||
expect(positionKey(null, 7)).toBeNull()
|
||||
expect(positionKey(42, 0)).toBeNull()
|
||||
expect(Object.keys(storage)).toEqual([`${POSITION_KEY_PREFIX}42:7`])
|
||||
})
|
||||
|
||||
it('drops a position taken from another content version', () => {
|
||||
const storage = localStorage
|
||||
storage.clear()
|
||||
writePosition(storage, 42, 7, sha, 0.4)
|
||||
expect(readPosition(storage, 42, 7, otherSha)).toBeNull()
|
||||
// A position without a version is never restored either.
|
||||
expect(readPosition(storage, 42, 7, '')).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores unreadable values and never restores the very top', () => {
|
||||
const storage = localStorage
|
||||
storage.clear()
|
||||
storage.setItem(`${POSITION_KEY_PREFIX}42:7`, '{broken')
|
||||
expect(readPosition(storage, 42, 7, sha)).toBeNull()
|
||||
storage.setItem(`${POSITION_KEY_PREFIX}42:7`, JSON.stringify({ ratio: 'half', sha }))
|
||||
expect(readPosition(storage, 42, 7, sha)).toBeNull()
|
||||
expect(normalizeRatio(0)).toBeNull()
|
||||
expect(normalizeRatio(-1)).toBeNull()
|
||||
expect(normalizeRatio(Number.NaN)).toBeNull()
|
||||
expect(normalizeRatio(3)).toBe(1)
|
||||
expect(normalizeRatio(0.25)).toBe(0.25)
|
||||
// A position of zero is not worth storing, so the top of a chapter needs no key at all.
|
||||
storage.clear()
|
||||
writePosition(storage, 42, 7, sha, 0)
|
||||
expect(storage.getItem(`${POSITION_KEY_PREFIX}42:7`)).toBeNull()
|
||||
writePosition(storage, 42, 7, sha, 0.5)
|
||||
clearPosition(storage, 42, 7)
|
||||
expect(storage.getItem(`${POSITION_KEY_PREFIX}42:7`)).toBeNull()
|
||||
})
|
||||
|
||||
it('turns a scroll offset into a ratio and clamps it', () => {
|
||||
expect(currentRatio({ scrollY: 0, innerHeight: 800 }, 4800)).toBe(0)
|
||||
expect(currentRatio({ scrollY: 1000, innerHeight: 800 }, 4800)).toBeCloseTo(0.25, 5)
|
||||
expect(currentRatio({ scrollY: 4000, innerHeight: 800 }, 4800)).toBe(1)
|
||||
// A chapter shorter than the window cannot be scrolled.
|
||||
expect(currentRatio({ scrollY: 0, innerHeight: 900 }, 900)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('review keyboard shortcuts', () => {
|
||||
it('maps space and enter to revealing and digits to grading', () => {
|
||||
expect(shortcutFor(' ', false)).toBe('reveal')
|
||||
expect(shortcutFor('Enter', false)).toBe('reveal')
|
||||
// Before the answer is on screen a grade key means nothing.
|
||||
expect(shortcutFor('1', false)).toBeNull()
|
||||
expect(shortcutFor('1', true)).toBe('correct')
|
||||
expect(shortcutFor('2', true)).toBe('wrong')
|
||||
expect(shortcutFor('3', true)).toBe('again')
|
||||
expect(shortcutFor('4', true)).toBeNull()
|
||||
expect(shortcutFor('q', true)).toBeNull()
|
||||
// Once revealed, space no longer reveals anything.
|
||||
expect(shortcutFor(' ', true)).toBeNull()
|
||||
})
|
||||
|
||||
it('never fires while the learner is typing or using a control', () => {
|
||||
const form = document.createElement('form')
|
||||
const input = document.createElement('input')
|
||||
const textarea = document.createElement('textarea')
|
||||
const button = document.createElement('button')
|
||||
const contenteditable = document.createElement('div')
|
||||
contenteditable.setAttribute('contenteditable', 'true')
|
||||
const link = document.createElement('a')
|
||||
link.setAttribute('href', '/vocab')
|
||||
const plain = document.createElement('p')
|
||||
form.append(input, textarea, button, contenteditable, link, plain)
|
||||
for (const element of [input, textarea, contenteditable]) {
|
||||
expect(ignoresShortcut(element, '1')).toBe(true)
|
||||
expect(ignoresShortcut(element, ' ')).toBe(true)
|
||||
}
|
||||
// A focused button or link owns Space and Enter itself, but not the digit keys.
|
||||
expect(ignoresShortcut(button, ' ')).toBe(true)
|
||||
expect(ignoresShortcut(button, 'Enter')).toBe(true)
|
||||
expect(ignoresShortcut(button, '1')).toBe(false)
|
||||
expect(ignoresShortcut(link, ' ')).toBe(true)
|
||||
expect(ignoresShortcut(link, '2')).toBe(false)
|
||||
expect(ignoresShortcut(plain, '1')).toBe(false)
|
||||
expect(ignoresShortcut(null, '1')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,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')
|
||||
})
|
||||
})
|
||||
@@ -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('开始到期复习')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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') },
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 }) }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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())
|
||||
@@ -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
@@ -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())
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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(¤t, &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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user