Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b18f9cc4a5 | ||
|
|
a55708cd37 | ||
|
|
ed5da31888 | ||
|
|
8d4a095590 |
@@ -273,9 +273,10 @@ MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才
|
||||
|
||||
- F01~F12 已确认进入 MVP,X 系列本轮不纳入;用户指定 Quant-UX 原型,原型审核后才拆实施工单。默认学习语言已确认英语,原型语料为虚构样例;不导出本地 HTML。
|
||||
- 原型尽量减少说明文字,与目标页面一致:产品页只保留实际字段、操作与必要反馈;功能编号、模拟边界和审核说明放在独立导览或工单。
|
||||
- 当前 Quant-UX v1 已获用户验收(工单 #1 评论 7498),实施总览为 #16、单元工单为 #2~#15;LinguaCafe 对照与真实划词验证由 #4 承接,不能记作已完成。
|
||||
- 当前 Quant-UX v1 已获用户验收(工单 #1 评论 7498),实施总览为 #16、单元工单为 #2~#15;LinguaCafe 源码对照与桌面划词验证已由 #4 交付并获用户验收;真机详细测试证据缺口仍保留。
|
||||
- 学习端和管理端均使用账号(用户名)+密码登录,账号不要求邮箱格式,邮箱不作为必填登录标识;后端独立校验管理权限与本人学习数据归属。
|
||||
- 已验证 MySQL 8.4.3,本机 127.0.0.1:3308;开发库 lexgo_dev、测试库 lexgo_test_issue2。密码只从环境或忽略的 .env.local 读取。迁移测试只能使用 lexgo_test_ 前缀专用库,不能借用其他数据库。
|
||||
- 后端命令使用 `python scripts/server.py migrate|bootstrap|serve|build|test|test-integration`;仅显式 migrate 修改表。bootstrap 只接受尚无账号的 LexGo 库,不覆盖已有管理员。Go 1.26.5、Node 22.22.1、pnpm 9.15.1;两端分别构建。
|
||||
- #18 登录日志与操作审计已通过用户验收:schema v2 显式迁移;日志只保存白名单字段,禁止保存凭据、请求/响应正文及私人学习内容。仅管理员查询,默认保留 90 天;启动/每小时及 `python scripts/server.py audit-cleanup` 仅清理两张审计表的过期记录。
|
||||
- #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。
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
面向自托管场景的阅读式语言学习项目,规划提供内容导入、阅读查词、词汇与短语、复习和实例管理。
|
||||
|
||||
已确认:**DevHarness 轻量模式、MySQL 8、go-admin 管理端**。工程基础 #2 已通过验收:两端用户名登录、学习账号管理、可撤销会话和本人英语空空间。管理端基于指定 go-admin/go-admin-ui 选用模块,学习端为独立 Vue 3 + TypeScript + Vite 工程,共用 Go 后端和 MySQL 8.4.3。#18 登录日志与操作审计已通过用户验收,支持管理员查询和 90 天保留清理。阅读、导入、词典与复习尚未实现。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 粘贴导入与章节原文阅读已实现,待用户验收;点词查词、词典、词语状态与复习仍未实现。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、#18 已验收,剩余 #4~#15 规划参考 43~70 人日;后续结合划词验证重估,旧全量研究仅供参考。
|
||||
- [工作量估算](docs/10-workload-estimate.md):#2、#3、#4、#18 已验收,剩余 #5~#15 与新增 #21 规划参考 44~71 人日;后续结合集成结果重估,旧全量研究仅供参考。
|
||||
- [四阶段实施总览 #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)
|
||||
@@ -18,7 +19,7 @@
|
||||
|
||||
本地入口:学习端 http://127.0.0.1:5173,管理端 http://127.0.0.1:5174。完整安装与测试命令见[开发与验证](docs/04-local-development-and-verification.md)。账号使用用户名,无需邮箱;没有随代码交付的默认密码。
|
||||
|
||||
本机三个服务已由 `D:/supervisord/supervisord.conf` 中的 lexgo-learner、lexgo-admin、lexgo-api 托管,使用时不要重复手动启动同端口。当前 schema v2;从 #2 升级时停止 API,执行 build、migrate 后再启动。审计过期清理可执行 `python scripts/server.py audit-cleanup`,只影响超过 90 天的日志。
|
||||
本机三个服务已由 `D:/supervisord/supervisord.conf` 中的 lexgo-learner、lexgo-admin、lexgo-api 托管,使用时不要重复手动启动同端口。当前 schema v3;从 #2/#18 升级时停止 API,执行 build、migrate 后再启动。审计过期清理可执行 `python scripts/server.py audit-cleanup`,只影响超过 90 天的日志。
|
||||
|
||||
## 文档与治理
|
||||
|
||||
|
||||
@@ -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: fac5f2dcf94e57d85afbc2d043fc1272aba8d54b
|
||||
synchronized_at: 2026-09-10T13:50:46Z
|
||||
wiki_revision: f981b86a7c6e5d2e353142d3006dcd6e0636c3ee
|
||||
synchronized_at: 2026-09-11T03:01:48Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -148,3 +148,76 @@ admin/src/views/AuditLogs.vue 通过 kind 复用登录/操作列表;audit-logs
|
||||
WordNet 使用 ZIP 内原始 index/data/exception 文件,不使用 SysDict 或新增业务库。NLTK 默认 synsets 会隐式词形还原,本小样直接读取其固定版本索引以区分 exact 和显式 lemma;禁用依赖全局 corpus 的 OMW 跨版本映射,只接受 WordNet 3.0。升级 NLTK 或词典时必须重跑契约测试。
|
||||
|
||||
候选比较:正则分词+WordNet 默认名词 morphology 依赖少、速度快,但不具备上下文判断,缩写和词性歧义处理弱;纯 Go 规则同样需要自行维护这些语言规则。本次 spaCy 在 12 个明确样例中答对 11 个,基线 6 个,因此推荐保留独立 Python NLP 边界。样例量不足以证明总体准确率;不宣称部署或正式阅读功能已完成。
|
||||
|
||||
|
||||
## #4 阅读选择小样与 LinguaCafe 对照
|
||||
|
||||
小样位于 spikes/selection:serve.py 只提供白名单静态文件,app.mjs 负责 DOM 原生选择/键盘/面板状态,range.mjs 负责原文范围和匹配,fixtures.mjs 提供两章虚构文本。保存仅在内存 Map,刷新清空,不接入账号、MySQL、正式词典、复习或 #21 附件。使用 Intl.Segmenter 的词与字形边界验证 UI,不能替代 #3 的 spaCy 结果;正式阅读器必须以章节原文、内容版本与 NLP tokens 为共同基准。
|
||||
|
||||
参考版本固定 LinguaCafe c1ea298ce40c65b9dd33e9b26fd2e52fae66f2c8。本次实际读取以下源码;未启动 LinguaCafe、未亲测其浏览器或手机行为。下表“参考行为”是源码证据;LexGo 方案是独立实现与取舍,不应标为上游已经验证的体验。
|
||||
|
||||
| 项目 | 参考行为与来源 | LexGo 采用及保留差异 |
|
||||
|---|---|---|
|
||||
| 阅读器 | TextBlockGroup.vue L56–100 用词/短语 stage、selected、hover 等状态;InteractiveTextStyling.scss L20–126 由主题决定颜色。TextReaderChapterList.vue L15–43 展示章节统计及已处理章节阅读入口;TextReader.vue L195–214 提供阅读完成后的书库/下一章 | 保留正文上下文、词语状态与选中区分、章节切换;小样以两章和学习中/已认识/忽略验证,章节统计及已读持久化仍属后续功能。侧栏/底部面板为 LexGo 布局,不宣称复制上游排版 |
|
||||
| 查词面板 | VocabularyBox.vue L124–169、374–404 区分 Translation 与字典搜索,新短语需 Save phrase;TextBlockGroup.vue L1351–1380 失选时自动保存单词/已有短语。VocabularySearchBox.vue L149–179 及保存 catch 未证明完整错误反馈 | 沿用 v1 的词典释义/我的释义、明确保存、关闭继续阅读。LexGo 关闭不自动保存;保存后可见反馈,无词典/无结果仍可手填。小样用固定虚构释义与故障状态,不把它当成真实词典/网络重试验证 |
|
||||
| 短语与键盘 | TextBlockGroup.vue L399–599 自定义鼠标范围;L374–457 为手机 500ms 长按及后续触摸移动,选区开始后阻止默认滚动;L1071–1202 的 Shift+方向键跳高亮词,而非扩展范围,Esc 失选 | LexGo 采用原生鼠标拖选、手机长按/系统手柄,不拦截 touchmove;起止端点按钮可调整。←/→ 相邻词,Shift+←/→ 扩缩范围,Esc 取消。原型的预设短语按钮被真实正文选区替代,属于 #4 明确要求的验证;手机手柄是否与底部面板冲突仍待真机 |
|
||||
| 复习 | Review.vue L270–345、559–692 为 Reveal→I was correct/Again,正确移除卡、Again 保留并随机抽剩余卡,最后一张正确完成;ReviewHotkeyInformationDialog.vue L14–24 提供快捷键。练习模式不写状态 | 已验收 v1 保留中文显答、答对/答错、再学与完成,单词/短语分别有状态。上游随机下一卡、阶段降级和快捷键不是本单已实现行为;#8 再明确调度、重学和幂等,#4 不新增复习引擎 |
|
||||
|
||||
源码链接:
|
||||
- [resources/js/components/Text/TextBlockGroup.vue](https://github.com/simjanos-dev/LinguaCafe/blob/c1ea298ce40c65b9dd33e9b26fd2e52fae66f2c8/resources/js/components/Text/TextBlockGroup.vue)
|
||||
- [resources/sass/Text/InteractiveTextStyling.scss](https://github.com/simjanos-dev/LinguaCafe/blob/c1ea298ce40c65b9dd33e9b26fd2e52fae66f2c8/resources/sass/Text/InteractiveTextStyling.scss)
|
||||
- [resources/js/components/TextReader/TextReaderChapterList.vue](https://github.com/simjanos-dev/LinguaCafe/blob/c1ea298ce40c65b9dd33e9b26fd2e52fae66f2c8/resources/js/components/TextReader/TextReaderChapterList.vue)
|
||||
- [resources/js/components/TextReader/TextReader.vue](https://github.com/simjanos-dev/LinguaCafe/blob/c1ea298ce40c65b9dd33e9b26fd2e52fae66f2c8/resources/js/components/TextReader/TextReader.vue)
|
||||
- [resources/js/components/Text/VocabularyBox.vue](https://github.com/simjanos-dev/LinguaCafe/blob/c1ea298ce40c65b9dd33e9b26fd2e52fae66f2c8/resources/js/components/Text/VocabularyBox.vue)
|
||||
- [resources/js/components/Text/VocabularySearchBox.vue](https://github.com/simjanos-dev/LinguaCafe/blob/c1ea298ce40c65b9dd33e9b26fd2e52fae66f2c8/resources/js/components/Text/VocabularySearchBox.vue)
|
||||
- [resources/js/components/TextReader/TextReaderHotkeyInformationDialog.vue](https://github.com/simjanos-dev/LinguaCafe/blob/c1ea298ce40c65b9dd33e9b26fd2e52fae66f2c8/resources/js/components/TextReader/TextReaderHotkeyInformationDialog.vue)
|
||||
- [resources/js/components/Review/Review.vue](https://github.com/simjanos-dev/LinguaCafe/blob/c1ea298ce40c65b9dd33e9b26fd2e52fae66f2c8/resources/js/components/Review/Review.vue)
|
||||
- [resources/js/components/Review/ReviewHotkeyInformationDialog.vue](https://github.com/simjanos-dev/LinguaCafe/blob/c1ea298ce40c65b9dd33e9b26fd2e52fae66f2c8/resources/js/components/Review/ReviewHotkeyInformationDialog.vue)
|
||||
|
||||
上游 LICENSE 为 GPL v3,本单只核对并描述行为,没有移植源码。#1 评论 7497 的四项对照登记由本节补充;原型 v1 的保存/关闭主要流程保持,不更改其历史验收记录,不声称已经在 Quant-UX 新建修订版。范围调整与原生手柄作为可运行小样验证,若真机结果导致主要流程变化,应先更新关键原型状态并由用户确认。
|
||||
|
||||
## 粘贴导入、章节与阅读(#5,schema v3)
|
||||
|
||||
#5 实现了目标路径一的第一段可运行链路:粘贴英语文本 → 持久导入任务 → 固定分章 → 处理中/就绪 → 本人阅读原文。Go 单进程同时承担 API 与后台任务。本单不接入 Python NLP,token、lemma 与词典索引仍待 #6,路线未决边界见业务规则页。
|
||||
|
||||
| 路径 | 职责 |
|
||||
|---|---|
|
||||
| server/app/lexgo/database.go | schema v3 显式迁移:lexgo_books、lexgo_chapters、lexgo_ingest_jobs;按版本累加语句,版本行只在全部语句成功后推进 |
|
||||
| server/app/lexgo/library.go | 粘贴校验与固定分章、书籍/章节/任务写入、本人归属查询、重试与请求幂等 |
|
||||
| server/app/lexgo/ingest.go | 任务声明 claim、处理完成、固定失败原因、启动恢复 |
|
||||
| server/app/lexgo/router.go | 新增书籍/章节/任务路由;粘贴请求使用独立的 4 MiB 体积上限 |
|
||||
| server/cmd/lexgo/main.go | serve 启动时恢复遗留任务,并按秒轮询处理待处理任务 |
|
||||
| learner/src/stores/library.ts、views/ImportView.vue、BookView.vue、ReaderView.vue | 粘贴导入、书库与章节状态、失败重试、原文阅读 |
|
||||
|
||||
任务状态为 pending → processing → ready/failed,章节与任务共用同一套状态词。声明与完成分属两个事务:声明一经提交,即使进程随即退出,也只会留下可被启动恢复重新入队的 processing 记录。
|
||||
|
||||
### 粘贴导入 API v1(#5)
|
||||
|
||||
| 方法与路径 | 行为和权限 |
|
||||
|---|---|
|
||||
| POST /api/v1/books | {requestId,title,text,language?};创建书籍+首个章节+导入任务;重复 requestId 返回首次结果(HTTP 200,duplicate=true) |
|
||||
| POST /api/v1/books/:id/chapters | 向本人书籍追加一个章节 |
|
||||
| GET /api/v1/books | 本人书库与章节状态计数;拒绝查询参数,避免用参数替换认证身份 |
|
||||
| GET /api/v1/books/:id | 本人书籍与章节列表,含 jobId 与可读失败原因 |
|
||||
| GET /api/v1/chapters/:id | 本人章节详情;仅 ready 时返回 originalText,并附带前后章节编号 |
|
||||
| GET /api/v1/jobs/:id | 本人任务状态、尝试次数与失败原因 |
|
||||
| POST /api/v1/jobs/:id/retry | 仅失败任务可重试;复用同一章节,不新建章节 |
|
||||
|
||||
所有接口按认证身份过滤 owner_id;他人书籍、章节或任务编号统一返回 404,管理员角色也不能解除学习数据的本人归属过滤。后台任务只使用任务行内的 owner_id,不接受客户端用户编号;请求体含未知字段(例如 ownerId)直接返回 400。
|
||||
|
||||
### schema v3
|
||||
|
||||
lexgo_books(owner_id, title, language)、lexgo_chapters(book_id, owner_id, ordinal, title, original_text MEDIUMTEXT, char_count, content_sha256, status, error_reason) 与 lexgo_ingest_jobs(owner_id, book_id, chapter_id, request_key, content_sha256, status, attempts, error_reason, finished_at)。owner_id 在章节与任务上冗余存放,使任何查询都能直接按认证身份过滤而不依赖连接;UNIQUE(book_id, ordinal) 与 UNIQUE(owner_id, request_key) 分别阻止重复章节与重复提交。启动检查要求版本 3,服务不自动迁移。
|
||||
|
||||
并发重复提交:请求命中 request_key 唯一键冲突后,用加锁读读取已提交结果,因为该请求事务的快照早于并发提交;因此两个并发相同提交只会产生一个章节,另一个得到 duplicate=true 的首次结果。
|
||||
|
||||
## #5 审核整改(R1~R4,2026-09-11)
|
||||
|
||||
提交见工单 #5 的整改评论;本条记录实现与验证方式。
|
||||
|
||||
- 追加契约(R1):学习端把新建与追加拆成两个请求类型,追加不发送 language;后端保持严格解码,并新增回归测试断言“追加带 language 返回 400、不带则 201”,学习端单测断言追加请求体只有 requestId/title/text。
|
||||
- 运行期任务恢复(R2):`server/app/lexgo/ingest.go` 的恢复逻辑合并为一处——启动恢复使用阈值 0,运行期每轮清扫使用 15 秒阈值并把超过 5 次尝试的任务置为 failed(原因码 attempts_exhausted);`cmd/lexgo/main.go` 的 worker 每秒先清扫再处理,日志分别说明“已重新入队”与“本批未完成、等待下一次清扫”,不再声称已完成实际跳过的重试。
|
||||
- 离页作废在途请求(R3):`closeBook`/`closeChapter` 推进请求序号并清理 loading;`ImportView` 记录是否已卸载,卸载后的成功响应不再触发跳转。
|
||||
- 重试自愈(R4):`retryChapter` 先把重试返回的章节状态应用到列表与阅读器并重新安排轮询,再做静默刷新。
|
||||
|
||||
验证:Go 全量用例 20 项通过(新增运行期恢复与尝试上限两项);学习端单测 38 项通过,其中 7 项在整改前的代码上复现失败;真实联调确认追加路径可用、被中断的任务在运行中被自动恢复(约 0.5 秒,无需重启)、重试在首次刷新失败后仍自动显示最终结果。
|
||||
|
||||
@@ -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: 493544b5fd723c028d9173ecf590b66fea72c20a
|
||||
synchronized_at: 2026-09-10T12:58:34Z
|
||||
wiki_revision: 4bd843d1f0134f5688407b7072d98333b40b6da1
|
||||
synchronized_at: 2026-09-11T03:01:48Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -99,3 +99,41 @@ M0 固定首发语言语料、词条身份规则、短语选择与重叠规则
|
||||
POST /lookup 接收 {surface,lemma?}。查词键单独 casefold/NFC/弯撇号转 ASCII,不改变原文位置;先精确查询 surface,再尝试调用方提供的 lemma。结果 status 为 exact、lemma、not_found 或 resource_missing,含 matched_form 和最多 12 条 entries(lemma/pos/definition/examples)。`dog` 直接命中;点击 `went` 可用上下文 lemma `go` 回退;手动只输入 `went` 不猜词性而返回未找到。词典缺失和模型缺失分别标识 wordnet/model,不能伪装成查无结果。
|
||||
|
||||
实验查询无用户状态、写入或缓存私人输入;重复查询确定性返回,原文哈希可检测文本版本变化,但尚未定义生产 token ID、任务幂等或个人词语合并规则。lemma 不等于学习状态身份,禁止自动合并原词/词元。WordNet 仅英英释义,按 n/v/a/r 与原生 sense 顺序截取,不做上下文义项排序、翻译或发音;`The leaves fell.` 的 leaves 实测被模型错误还原为 leave,此限制保留供后续用户选择/修正方案参考。
|
||||
|
||||
|
||||
## #4 阅读选择与重叠匹配建议
|
||||
|
||||
位置始终引用当前章节不变的原文,半开区间 [start,end)。浏览器 DOM Range 的 start/end 先按正文文本节点累计为 UTF-16,再扩展至触及的完整可选择词元;反向拖选得到同一规范区间。首尾空白/纯标点不独立成词,选区内部标点、空白和换行原样保留。emoji 字形可选,代理对、组合重音和 ZWJ 不拆开;cp/utf8/utf16 均与 #3 单位约定一致。
|
||||
|
||||
推荐以章节 ID、内容版本、原文区间标识一次出现,不使用短语文字定位所有实例。重复出现的 a small step 保持不同 offsets;查找采用区分大小写的精确原文匹配,并要求完整词元端点,不擅自折叠空白或 NFC。例:`a small step; a small step` 两次区间分别为 [0,12)、[14,26)。跨行 `small\nstep` 不与 `small step` 自动等同。
|
||||
|
||||
重叠匹配保留所有候选;只在显示层采用“起点靠前优先,同起点长范围优先,跳过已占用区间”,不合并或删除词条身份。例如 a small step 与 small step 都可保存,前者优先显示;当前用户选区高亮优先于已保存状态。#11 正式集成前再依据实际语言 tokens 验证候选索引与长度限制,本小样不继承未经实测的上游 14 词上限。
|
||||
|
||||
桌面鼠标/手机长按使用原生 Selection;正文中键盘 ←/→ 选相邻词,Shift+←/→ 从锚点扩缩连续范围,起点/终点按钮可用 Tab/Enter。Esc 或关闭清除选区及未保存编辑,保留滚动和键盘阅读位置;切换章节清除选区,当前页面内分别记住章节滚动位置。纯标点的新选区清除旧面板,防止操作上一个词。个人释义必须显式保存,状态变化也随保存提交;本小样仅写内存。
|
||||
|
||||
手机方案保留浏览器原生长按、选择手柄和滚动,面板最多占底部 42dvh,正文有底部阅读余量;键盘定位用滚动边距避开面板。未获得真实手机结果,不能判定手柄、系统菜单、虚拟键盘或触摸滚动冲突已经解决。
|
||||
|
||||
## #5 粘贴分章、任务与阅读规则 v1
|
||||
|
||||
用户于 2026-09-10 确认两项边界(工单 #5 评论 7644):本单按 Go 处理、不接入 Python;一次粘贴等于一个章节。
|
||||
|
||||
- 固定分章规则:一次粘贴产生一个章节,不按空行或长度自动再分。新建书籍时书籍标题与首章标题同为提交的标题;追加时标题即新章节标题。分章规则变化属于需求变化,必须重新确认。
|
||||
- 长度与校验:标题去首尾空白后 1~120 个字符;正文必须含至少一个非空白字符;正文上限 100000 Unicode code point,超出返回 400;语言当前只接受 en。
|
||||
- 原文保真:正文按收到的字符串原样保存与返回,不做 NFC、大小写、换行或空白归一化;页面使用 white-space: pre-wrap 展示,制表符、连续空格与空行保持可见。处理完成前不返回原文。
|
||||
- 归属:书籍、章节与任务都记录认证账号的 owner_id;他人编号返回 404;管理员角色不解除学习数据的本人归属;后台任务只使用任务行的 owner,不信任客户端用户编号。
|
||||
- 任务状态:pending、processing、ready、failed,章节与任务共用同一词表。失败时返回固定原因码加可读中文提示,错误字段不保存正文。
|
||||
- 固定失败原因:unsupported_language、too_long、empty_text、content_changed。前三种只能由其他写入路径产生(例如语言调整或后续编辑功能);content_changed 表示章节内容在处理前被改动,属于过期任务,必须重新提交,或恢复为提交时的内容后重试。
|
||||
- 幂等:客户端 requestId 与账号构成唯一键。同一 requestId 配同标题同正文的重复提交返回首次结果,不新建章节;同一 requestId 配不同标题或正文返回 409;并发重复提交同样只产生一个章节。重试复用原章节,只增加尝试次数。
|
||||
- 恢复:声明与完成分属两个事务。进程在声明后退出时,重启把 processing 的章节与任务放回 pending 并保留尝试次数,不产生重复章节。
|
||||
- 未决边界:Go+Python NLP 与全 Go 路线尚未确认。本单只做 Go 校验、分章与发布,不产生 token、lemma 或词典索引;正式接入前必须由用户确认路线,再定义生产 token 契约。
|
||||
- 已知限制:本单未设置每账号书籍数量或总容量配额,只限制单次正文与请求体大小;删除书籍/章节属 #10,导入失败不会自动重试,只在启动时恢复被中断的 processing 任务。
|
||||
|
||||
## #5 审核整改(R1~R4,2026-09-11)
|
||||
|
||||
工单 #5 的整改记录见该工单评论;本节只记录长期有效的契约变化。
|
||||
|
||||
- 追加章节沿用所属书籍的语言:`POST /api/v1/books/:id/chapters` 的请求体只有 requestId、title、text,不接受 language;服务端仍拒绝未知字段,客户端发 language 会得到 400。新建书籍的 `POST /api/v1/books` 才带 language。学习端已按此拆分请求体,避免两个契约共用同一结构。
|
||||
- 导入任务的自动重试有上限:同一任务被 worker 领取的次数达到 5 次后,任务与章节转为 failed,原因码 `attempts_exhausted`,提示“处理多次失败,请重试或重新提交”。人工重试(POST /jobs/:id/retry)会重置尝试次数,因此人工操作不受该上限阻塞。
|
||||
- 运行期恢复不依赖重启:除了启动时的恢复,运行中的服务每次轮询都会把停留在 processing 且超过 15 秒的任务放回 pending,因此“领取已提交、完成事务失败”不会让章节永久卡在处理中。该阈值必须长于正常的领取到完成窗口;重复处理同一任务不会产生第二个章节,因为任务从不创建章节。
|
||||
- 学习端离开页面时作废在途请求:目录页与阅读页在关闭时推进各自的请求序号,晚到的响应不会写回状态或重启轮询;导入页在提交过程中离开后,晚到的成功响应不会把用户导航回书籍页。
|
||||
- 重试结果立即生效:重试被接受后先把返回的 pending 状态写入界面并继续轮询,因此紧随其后的一次刷新失败不会让页面停在处理失败。
|
||||
|
||||
@@ -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: a7b674447980d341dddeb7ad778672915f4f5b75
|
||||
synchronized_at: 2026-09-10T13:50:49Z
|
||||
wiki_revision: 3bce5fae964b1050513c37319262679814d68f14
|
||||
synchronized_at: 2026-09-11T03:01:49Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -228,3 +228,80 @@ node --test spikes/english/view.test.mjs
|
||||
|
||||
|
||||
#3 于 2026-09-10T21:50:14+08:00 通过用户验收(工单评论 7609),独立小样验证完成。此前实测结果和限制保持;本次验收未重跑测试。
|
||||
|
||||
|
||||
## #4 阅读选择验证
|
||||
|
||||
启动:`python spikes/selection/serve.py`,本机 http://127.0.0.1:5184/。默认仅 loopback,提供白名单静态文件,无目录列表或写入接口。虚构英文两章,任意词/连续短语可选;模拟释义与保存仅页面内有效。完整操作及真机清单见 spikes/selection/README.md;本单不配置 supervisor。
|
||||
|
||||
自动测试:`node --test spikes/selection/range.test.mjs spikes/selection/ui.test.mjs`。Node 22.22.1;范围测试无外部依赖,DOM 测试复用 learner 锁定的 jsdom(需要时先 `pnpm --dir learner install --frozen-lockfile`)。11 项通过,覆盖原文分区、反选、词边界、跨行/标点、组合字符/emoji/ZWJ、三位置单位、重复/重叠、关闭后键盘续读、标点取消旧选区、DOM Range 映射与显式保存。关闭丢失键盘位置及标点残留均复现旧代码失败,再验证修正通过。JS 语法检查通过。
|
||||
|
||||
桌面 Chrome 实际验证:鼠标拖选 `Mira opened the workshop`,原生选择与正文高亮相等;按钮扩展终点后可包含 before;键盘连续选择及关闭后下一词续读正常;模拟保存与词典不可用提示正常。章节中段直接点击关闭前后 scrollY 同为 909.5999755859375。自动化 locator.click 会在事件之前滚动到按钮,因此用基于可见坐标的直接点击复核;页面“验证设置”记录事件内关闭前后位置,避免将工具预滚动误判为页面变化。
|
||||
|
||||
桌面窄屏约 390×844 CSS 像素检查无横向溢出,底部面板正常;键盘选中词底部 464.4、面板顶部 489.5,未被遮挡。此为桌面浏览器宽度检查,不是移动系统或触摸模拟结果。视口已恢复。
|
||||
|
||||
**真机项未通过:尚未取得实际手机浏览器结果。** 需要用户记录设备/系统/浏览器版本、长按/手柄扩缩跨行范围、普通滚动、底部面板遮挡、虚拟键盘以及关闭位置,回填 #4。可在明确进行测试时以 `--host` 指定电脑实际局域网 IPv4,手机同网访问;不自动放行防火墙或开放公网。用户已明确验收 #4 并关闭工单;该详细测试证据缺口仍保留,不能将其改记为已执行通过。
|
||||
|
||||
独立规格及代码评审已修复已知问题;没有真实账号、生产数据、词典联网或 SRS 测试。停止小样即可回退,现有学习端/管理端/数据库不变。#21 附件不在本次范围。
|
||||
|
||||
|
||||
#4 用户验收记录:2026-09-10T22:51:52+08:00 用户确认“#4通过验收”(评论 7636)。未补充手机型号/浏览器/操作记录,未重跑测试,未自动合并 PR。后续正式移动端集成应补真机回归。
|
||||
|
||||
## #5 粘贴导入与章节阅读(schema v3)
|
||||
|
||||
升级步骤(本机,仓库根执行):停止 lexgo-api → `python scripts/server.py build` → `python scripts/server.py migrate` → 启动 lexgo-api。lexgo_dev 已从 v2 升到 v3,新增 lexgo_books、lexgo_chapters、lexgo_ingest_jobs;迁移前后 sys_user 4、lexgo_spaces 4、lexgo_sessions 3、lexgo_login_logs 23、lexgo_operation_logs 1 完全一致。托管实例重启后 /healthz 返回 200,两端首页仍为 200,lexgo-admin 与 lexgo-learner 的 PID 未变化。
|
||||
|
||||
回退:停止 API,把 lexgo_schema 中 id=1 的版本从 3 改回 2,并恢复上一二进制;三张新表保留不删除,旧程序不读写它们。重新升级时显式 migrate 重新执行 IF NOT EXISTS 语句即可;集成测试覆盖 v2→v3 的既有数据保留与 v2 标记下的重复迁移。未在开发库演练回退。
|
||||
|
||||
学习端入口:http://127.0.0.1:5173 → 登录 → 我的书库 → 粘贴文本导入 → 章节就绪后进入阅读。
|
||||
|
||||
测试命令与结果(仓库根执行;本单使用专用库 lexgo_test_issue5,不借用其他测试库):
|
||||
|
||||
| 命令 | 本次结果 |
|
||||
|---|---|
|
||||
| `python scripts/server.py test-integration`(LEXGO_TEST_DB_NAME=lexgo_test_issue5) | 全部通过:18 个顶层用例,其中 #5 新增 8 个(7 个书库/章节/任务/阅读 + 1 个 v2→v3 数据保留),另含 12 个子用例;既有 10 个用例保持通过 |
|
||||
| `npx --yes pnpm@9.15.1 --dir learner test:unit --run` | 3 个文件 31 项通过(session 9、library 16、reading 6) |
|
||||
| `npx --yes pnpm@9.15.1 --dir learner build` | vue-tsc 类型检查与 vite 构建通过,退出码 0 |
|
||||
| `npx --yes pnpm@9.15.1 --dir learner test:e2e` | 3 项通过(既有 auth 2 项 + 新增 reading 1 项,均为虚构 API 响应) |
|
||||
|
||||
### 真实 API + MySQL 实测(2026-09-10,lexgo_dev)
|
||||
|
||||
使用本单新建的虚构账号 issue5_a、issue5_b,口令只保存在忽略的 .local/issue5-accounts.json;未改动 admin、dev、learner_a、learner_b。脚本 .local/verify-issue5-api.ps1 只在本机运行,不输出口令。
|
||||
|
||||
- 粘贴:HTTP 201,章节与任务均为 pending,charCount 99。
|
||||
- 处理:实测状态序列 pending → ready,约 1132 ms(后台任务每秒轮询);job attempts=1。
|
||||
- 阅读:originalText 与提交正文逐字符相等,CRLF、制表符、弯引号、破折号、省略号、é 加组合重音、emoji、行尾空格与空行全部保留;sha256 前缀 ce7357ea22a3。
|
||||
- 幂等:同一 requestId 重复提交 HTTP 200、duplicate=true、章节与任务编号不变;同一 requestId 换正文 HTTP 409;书库仍为 1 本。
|
||||
- 隔离:issue5_b 读取 issue5_a 的书籍、章节、任务以及追加、重试全部 404;请求体带 ownerId 与查询参数 ownerId 均 400;issue5_b 书库为空。
|
||||
- 追加与阅读导航:新章节 ordinal=2,处理后就绪,前后章节编号互相指向。
|
||||
- 校验:空标题、纯空白正文、非 en 语言、缺少 requestId、超过 100000 code point 分别返回 400 与可读中文提示。
|
||||
- 遗留 fixture:lexgo_dev 中 issue5_a 名下 1 本虚构书、2 个就绪章节(bookId=1,章节 1、2)。
|
||||
|
||||
浏览器实测:真实学习端 + 真实 API + 真实 MySQL 联测(临时 Playwright 用例,运行后删除):issue5_a 登录 → 书库显示既有虚构书与“导入内容”入口 → 导入页粘贴含空行、制表符、行尾空格与 emoji 的正文 → 书库页由“处理中”变为“已就绪” → 阅读页 article.reader-text 的 textContent 与粘贴正文逐字符相等、computed white-space 为 pre-wrap → “下一章”切换到第二章且正文精确相等 → 390×844 视口下横向溢出 0 px。同一轮还运行了 3 项虚构 API 的既有 e2e,共 4 项通过。
|
||||
|
||||
截图保存在本机 .local/evidence/(library.png、book-after-paste.png、reader-desktop.png、reader-mobile-390.png)并作为工单附件上传,便于人工目视复核;本次会话模型不能读取图片,截图未经 Agent 目视检查,功能断言来自上面的程序化检查。
|
||||
|
||||
未验证:处理失败到重试的用户界面路径只由集成测试覆盖(无法通过 API 主动制造处理失败);真实手机浏览器长按、手柄、滚动与虚拟键盘仍属 #4 缺口,本次只用桌面浏览器窄屏检查,不能当作真机结果;Python NLP 未接入,token、lemma 与词典仍为 #3 小样范围;生产并发、容量、备份恢复与部署不在本单范围。
|
||||
|
||||
## #5 审核整改验证(R1~R4,2026-09-11)
|
||||
|
||||
整改提交与完整证据见工单 #5 的整改评论。本次复核命令与结果(仓库根执行,专用测试库 lexgo_test_issue5):
|
||||
|
||||
| 命令 | 本次结果 |
|
||||
|---|---|
|
||||
| `python scripts/server.py test-integration` | 20 个顶层用例全部通过(新增 `TestMySQLIngestRecoveryWithoutRestart`、`TestMySQLIngestAttemptsAreBoundedAndManualRetryRestarts`) |
|
||||
| `npx --yes pnpm@9.15.1 --dir learner test:unit --run` | 38 项通过(library 21、reading 8、session 9) |
|
||||
| `npx --yes pnpm@9.15.1 --dir learner build` | 通过(vue-tsc + vite) |
|
||||
| `npx --yes pnpm@9.15.1 --dir learner test:e2e` | 3 项通过(虚构 API 响应) |
|
||||
|
||||
回归测试的有效性:新增的前端 7 项用例先在整改前的 `library.ts`/`ImportView.vue` 上运行并实际失败(追加发送 language、离页后响应写回、重试后停在失败),改回修复版本后全部通过。
|
||||
|
||||
真实联调(lexgo_dev,虚构账号 issue5_a):
|
||||
|
||||
- R1 追加:真实学习端从书籍页进入“追加章节”,提交后回到书籍页,新章节就绪后可阅读;抓取到的请求体只有 requestId、title、text,无 language;正文逐字符相等。
|
||||
- R2 恢复:新建章节后用 SQL 把任务与章节置为 processing 且 updated_at 早于阈值(UTC 时间),**不重启服务**,运行期清扫在 544 ms 内把任务重新入队并发布为就绪,章节与任务编号不变,正文逐字符相等。
|
||||
- R4 重试:SQL 制造真实失败任务(content_changed)后,在浏览器点击“重试”并中断其后的第一次刷新请求,页面立即由“处理失败”变为“处理中”,随后自行变为“已就绪”,无需手工刷新。
|
||||
|
||||
注意:MySQL 会话时区为 SYSTEM(本机为 UTC+8),而服务按 UTC 存储 DATETIME;核对任务时间时使用 UTC_TIMESTAMP 而不是 NOW(),否则会出现 8 小时的假偏差。
|
||||
|
||||
未在本轮验证:R3 的真实浏览器时序(离页与响应同时发生)只由单测覆盖;真机手机证据仍属 #4 缺口。
|
||||
|
||||
@@ -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: db82eeb1d509b8d3c4a2204143cfb0e829b6b083
|
||||
synchronized_at: 2026-09-10T12:43:32Z
|
||||
wiki_revision: 125df697d3d45dee098616d25970bb65a96727cd
|
||||
synchronized_at: 2026-09-10T16:35:19Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 产品需求总览
|
||||
@@ -223,3 +223,9 @@ Anki(U20)、YouTube/Jellyfin 远程字幕(U07/A08)、全量旧实例迁
|
||||
## 试用前管理能力增补
|
||||
|
||||
用户于 2026-09-10 验收 #2,并批准新增 #18 登录日志与操作审计:两个管理员列表、查询筛选分页、必要字段记录、权限及 90 天保留清理。沿用现有 go-admin 管理布局,无需独立高保真原型。已通过用户验收,纳入 #16“邀请用户试用前完成”;不替代 #3 英语分词和 #4 阅读选择验证,也不引入其他 go-admin 默认模块。
|
||||
|
||||
## 实施进度增补(#5)
|
||||
|
||||
2026-09-10:#5“粘贴英语文本,处理后进入本人章节阅读”已实现并待用户验收,覆盖 F01 基础(书库与章节)、F02(粘贴导入)、F04 基础(处理状态与失败重试)与 F05 原文(可读原文与章节切换)。本节取代此前“阅读、导入尚未实现”的表述:粘贴导入与原文阅读已实现;点词查词、词典、个人词语状态、复习与统计仍未实现(#6~#15)。schema 升级为 v3,新增 lexgo_books、lexgo_chapters、lexgo_ingest_jobs。
|
||||
|
||||
范围边界不变:本单按 Go 处理,不接入 Python NLP,因此不产生 token、lemma 或词典索引;Go+Python NLP 与全 Go 路线仍未确认,正式接入前必须由用户确认。真机手机详细证据仍缺失(#4 缺口保持)。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Workload-Estimate
|
||||
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Workload-Estimate.-
|
||||
wiki_revision: 15f80d261369ab18138df0b8b8872fb17520eb97
|
||||
synchronized_at: 2026-09-10T13:51:03Z
|
||||
wiki_revision: f43e3793edfa02d319d5a1d566e082d3f3a1361d
|
||||
synchronized_at: 2026-09-10T14:52:36Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# LexGo MVP 工作量估算
|
||||
@@ -165,3 +165,8 @@ Quant-UX 桌面和手机原型 v1 已建立,待人工审核;这不计为产
|
||||
## #3 验收后的剩余计划
|
||||
|
||||
2026-09-10T21:50:14+08:00 用户验收 #3。从此前 46~75 人日扣除该单原计划 3~5 人日后,#4~#15 剩余规划参考为 43~70 人日。#2、#3、#18 均已验收;这不是实际消耗工时,后续仍需结合划词验证重估。
|
||||
|
||||
|
||||
## #4 验收与附件增补后的剩余计划
|
||||
|
||||
2026-09-10T22:51:52+08:00 用户验收 #4。原范围从43~70扣除本单3~5后,#5~#15剩余40~65人日;加已建单 #21 书籍音频/封面初估4~6后,当前剩余规划44~71人日。是计划参考,不代表实际耗时;#21仍待原型与限制细化。
|
||||
|
||||
+8
-3
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Home
|
||||
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Home
|
||||
wiki_revision: 428a6c6bcf5b147210299c5b37fde2d738646b95
|
||||
synchronized_at: 2026-09-10T13:50:42Z
|
||||
wiki_revision: 5b936b75caeec89cd54f7147c842f0b66f901307
|
||||
synchronized_at: 2026-09-10T16:35:18Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# LexGo 文档入口
|
||||
@@ -59,4 +59,9 @@ Quant-UX 原型 v1 已通过用户验收。[桌面预览](https://qux.ilapage.cn
|
||||
日志审计 #18 于 2026-09-10T20:40:04+08:00 获用户验收并关闭;#16 已更新完成索引。该验收时 #3/#4 尚未开始。
|
||||
|
||||
|
||||
英语分词/原文定位/本地词典 #3 独立可运行小样已通过用户验收并关闭。代码与复现命令位于 spikes/english;临时入口 http://127.0.0.1:5183/。推荐 Python spaCy 英语模型与 WordNet 3.0 的离线组合;尚未接入正式学习端,#4 划词验证仍未开始。
|
||||
英语分词/原文定位/本地词典 #3 独立可运行小样已通过用户验收并关闭。代码与复现命令位于 spikes/english;临时入口 http://127.0.0.1:5183/。推荐 Python spaCy 英语模型与 WordNet 3.0 的离线组合;尚未接入正式学习端。
|
||||
|
||||
|
||||
#4 阅读选择小样已实现桌面鼠标/键盘、范围调整与原文位置验证,入口 http://127.0.0.1:5184/;固定版本 LinguaCafe 四项源码对照已记录。用户已验收并关闭 #4;真实手机详细测试证据仍缺失,详见本地验证页。#21 仍待实施。
|
||||
|
||||
#5 粘贴导入与章节阅读已实现,待用户验收:schema v3 新增 books/chapters/ingest_jobs,学习端具备粘贴导入、书库、处理状态与原文阅读;查词、词典与复习仍未实现。本单按 Go 处理,Go+Python NLP 与全 Go 路线仍未决,正式接入前须用户确认。
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test('paste English text, watch a chapter finish processing, then read it verbatim', async ({ page }) => {
|
||||
const user = { id: 42, username: 'fictional-reader', role: 'learner' }
|
||||
const book = { id: 1, title: '虚构样例书', language: 'en' }
|
||||
const chapterTitle = '虚构样例第一章'
|
||||
// Line breaks, a tab and repeated spaces must survive the whole round trip.
|
||||
const pasted = 'First line of the chapter.\n\tIndented line.\nTwo spaces kept.\n\nLast line.\n'
|
||||
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'
|
||||
const chapterPayload = () => ({
|
||||
id: 55,
|
||||
bookId: book.id,
|
||||
ordinal: 1,
|
||||
title: chapterTitle,
|
||||
status,
|
||||
charCount: [...pasted].length,
|
||||
errorReason: '',
|
||||
errorMessage: '',
|
||||
jobId: 7,
|
||||
...timestamps,
|
||||
})
|
||||
|
||||
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 statusCode = 200
|
||||
if (path === '/api/v1/login') {
|
||||
expect(route.request().postDataJSON()).toEqual({ username: user.username, password: 'fictional-password' })
|
||||
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: [{ ...book, chapterCount: 0, pendingCount: 0, processingCount: 0, readyCount: 0, failedCount: 0, ...timestamps }] }
|
||||
} else if (path === '/api/v1/books' && method === 'POST') {
|
||||
const body = route.request().postDataJSON() as { requestId: string; title: string; text: string; language: string }
|
||||
expect(body.requestId).toMatch(/^[0-9a-f-]{36}$/)
|
||||
expect(body).toMatchObject({ title: chapterTitle, text: pasted, language: 'en' })
|
||||
statusCode = 201
|
||||
data = {
|
||||
book,
|
||||
chapter: chapterPayload(),
|
||||
job: { id: 7, bookId: book.id, chapterId: 55, status, attempts: 0, errorReason: '', errorMessage: '', ...timestamps },
|
||||
duplicate: false,
|
||||
}
|
||||
} else if (path === '/api/v1/books/1') data = { book, chapters: [chapterPayload()] }
|
||||
else if (path === '/api/v1/chapters/55') {
|
||||
data = {
|
||||
book,
|
||||
chapter: { ...chapterPayload(), contentSha256: 'fictional-sha256', ...(status === 'ready' ? { originalText: pasted } : {}) },
|
||||
navigation: { previousChapterId: null, nextChapterId: null },
|
||||
}
|
||||
}
|
||||
await route.fulfill({ status: statusCode, 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()
|
||||
|
||||
// The library lists the caller's book.
|
||||
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: book.title })).toBeVisible()
|
||||
|
||||
// Paste text through the import form.
|
||||
await page.getByRole('button', { name: '导入内容' }).click()
|
||||
await expect(page.getByRole('heading', { name: '导入英文内容' })).toBeVisible()
|
||||
await page.getByLabel('标题').fill(chapterTitle)
|
||||
await page.getByLabel('正文').fill(pasted)
|
||||
await page.getByRole('button', { name: '开始处理' }).click()
|
||||
|
||||
// The new book opens with the chapter still processing…
|
||||
await expect(page).toHaveURL(/\/books\/1$/)
|
||||
await expect(page.getByText('处理中')).toBeVisible()
|
||||
|
||||
// …and the browser poll turns it ready without a page reload.
|
||||
status = 'ready'
|
||||
await expect(page.getByText('已就绪')).toBeVisible({ timeout: 15000 })
|
||||
|
||||
// Open the chapter and check the pasted text survived verbatim.
|
||||
await page.getByRole('link', { name: chapterTitle }).click()
|
||||
await expect(page).toHaveURL(/\/chapters\/55$/)
|
||||
const readerText = page.locator('.reader-text')
|
||||
await expect(readerText).toBeVisible()
|
||||
expect(await readerText.evaluate(element => element.textContent)).toBe(pasted)
|
||||
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()
|
||||
})
|
||||
@@ -0,0 +1,445 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import {
|
||||
NOT_FOUND_MESSAGE,
|
||||
POLL_INTERVAL_MS,
|
||||
TEXT_MAX_CODE_POINTS,
|
||||
canRetry,
|
||||
statusSummary,
|
||||
useLibraryStore,
|
||||
type ChapterDetail,
|
||||
} from '../stores/library'
|
||||
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 navigation = { previousChapterId: null, nextChapterId: null }
|
||||
|
||||
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }), { status: 200 })
|
||||
const created = (data: unknown) => new Response(JSON.stringify({ code: 200, data }), { status: 201 })
|
||||
const httpError = (status: number, msg: string) => new Response(JSON.stringify({ code: status, msg }), { status })
|
||||
|
||||
const chapter = (overrides: Partial<ChapterDetail> = {}): ChapterDetail => ({
|
||||
id: 55,
|
||||
bookId: 1,
|
||||
ordinal: 1,
|
||||
title: '第一篇',
|
||||
status: 'pending',
|
||||
charCount: 120,
|
||||
errorReason: '',
|
||||
errorMessage: '',
|
||||
contentSha256: 'fictional-sha256',
|
||||
jobId: 7,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const summary = (overrides: Partial<Record<string, number | string>> = {}) => ({
|
||||
...book,
|
||||
chapterCount: 0,
|
||||
pendingCount: 0,
|
||||
processingCount: 0,
|
||||
readyCount: 0,
|
||||
failedCount: 0,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const job = { id: 7, bookId: 1, chapterId: 55, status: 'pending', attempts: 0, errorReason: '', errorMessage: '', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' }
|
||||
|
||||
const fetchMock = () => vi.mocked(globalThis.fetch)
|
||||
const paths = () => fetchMock().mock.calls.map(([input]) => String(input))
|
||||
const bodyOf = (index: number): Record<string, unknown> => JSON.parse(String(fetchMock().mock.calls[index]?.[1]?.body)) as Record<string, unknown>
|
||||
|
||||
async function signIn() {
|
||||
fetchMock().mockResolvedValueOnce(ok({ token: 'fictional-token', expiresAt: '2030-01-01', user }))
|
||||
const session = useSessionStore()
|
||||
await session.login(user.username, 'fictional-password')
|
||||
return session
|
||||
}
|
||||
|
||||
describe('learner library store', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
setActivePinia(createPinia())
|
||||
vi.restoreAllMocks()
|
||||
// Any request a test did not expect fails loudly instead of hanging.
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(input => {
|
||||
throw new Error(`unexpected request: ${String(input)}`)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Never let a polling timer outlive its test.
|
||||
useLibraryStore().stopPolling()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('loads the library and summarises the count fields the API reports', async () => {
|
||||
await signIn()
|
||||
const library = useLibraryStore()
|
||||
fetchMock().mockResolvedValueOnce(ok({ items: [summary({ chapterCount: 5, readyCount: 2, processingCount: 1, pendingCount: 1, failedCount: 1 })] }))
|
||||
|
||||
await library.loadBooks()
|
||||
|
||||
expect(library.books).toHaveLength(1)
|
||||
expect(library.booksLoading).toBe(false)
|
||||
expect(library.booksError).toBe('')
|
||||
expect(statusSummary(library.books[0]!)).toBe('已就绪 2 · 处理中 1 · 待处理 1 · 失败 1')
|
||||
expect(paths()).toEqual(['/api/v1/login', '/api/v1/books'])
|
||||
expect(fetchMock().mock.calls[1]?.[1]?.headers).toMatchObject({ Authorization: 'Bearer fictional-token' })
|
||||
})
|
||||
|
||||
it('shows pending and processing separately instead of deriving one from a total', async () => {
|
||||
// processingCount is strictly "processing" now, so pendingCount must be read as given.
|
||||
const queued = summary({ chapterCount: 3, readyCount: 1, processingCount: 0, pendingCount: 2, failedCount: 0 })
|
||||
expect(statusSummary(queued)).toBe('已就绪 1 · 待处理 2')
|
||||
expect(statusSummary(queued)).not.toContain('处理中')
|
||||
const done = summary({ chapterCount: 1, readyCount: 1 })
|
||||
expect(statusSummary(done)).toBe('已就绪 1')
|
||||
expect(statusSummary(summary({ chapterCount: 0 }))).toBe('')
|
||||
})
|
||||
|
||||
it('loads a book detail whose chapters carry the job id used for retry', async () => {
|
||||
await signIn()
|
||||
const library = useLibraryStore()
|
||||
fetchMock().mockResolvedValueOnce(ok({ book, chapters: [chapter({ status: 'failed', jobId: 7 }), chapter({ id: 56, status: 'failed', jobId: null })] }))
|
||||
|
||||
await library.loadBook(1)
|
||||
|
||||
expect(library.book?.title).toBe('虚构样例书')
|
||||
expect(library.chapters).toHaveLength(2)
|
||||
expect(paths()[1]).toBe('/api/v1/books/1')
|
||||
// The chapter itself carries the job id, even for a freshly loaded book.
|
||||
expect(library.chapters[0]?.jobId).toBe(7)
|
||||
expect(canRetry(library.chapters[0]!)).toBe(true)
|
||||
// A null job id means the chapter has nothing to retry yet.
|
||||
expect(canRetry(library.chapters[1]!)).toBe(false)
|
||||
})
|
||||
|
||||
it('retries a failed chapter loaded fresh from the book detail, without any submit in this session', async () => {
|
||||
vi.useFakeTimers()
|
||||
await signIn()
|
||||
const library = useLibraryStore()
|
||||
// No submit() call: this is a plain reload, the old workaround would hide retry here.
|
||||
fetchMock().mockResolvedValueOnce(ok({ book, chapters: [chapter({ status: 'failed', errorMessage: '无法解析正文。', jobId: 7 })] }))
|
||||
await library.loadBook(1)
|
||||
|
||||
fetchMock()
|
||||
.mockResolvedValueOnce(ok({ job: { ...job, status: 'pending', attempts: 1 }, chapter: { id: 55, bookId: 1, jobId: 7 } }))
|
||||
.mockResolvedValueOnce(ok({ book, chapters: [chapter({ jobId: 7 })] }))
|
||||
await library.retryChapter(55)
|
||||
|
||||
expect(paths()).toContain('/api/v1/jobs/7/retry')
|
||||
expect(library.retryingChapterId).toBeNull()
|
||||
expect(library.chapters[0]?.status).toBe('pending')
|
||||
})
|
||||
|
||||
it('pastes a new book and reads the job id from the created chapter', async () => {
|
||||
await signIn()
|
||||
const library = useLibraryStore()
|
||||
fetchMock().mockResolvedValueOnce(created({ book, chapter: chapter(), job, duplicate: false }))
|
||||
|
||||
const bookId = await library.submit({ title: ' 第一篇 ', text: 'Hello world.\nSecond line.', target: { mode: 'new' } })
|
||||
|
||||
expect(bookId).toBe(1)
|
||||
expect(library.submitting).toBe(false)
|
||||
expect(library.submitError).toBe('')
|
||||
expect(paths()[1]).toBe('/api/v1/books')
|
||||
expect(bodyOf(1)).toEqual({ requestId: expect.any(String), title: '第一篇', text: 'Hello world.\nSecond line.', language: 'en' })
|
||||
})
|
||||
|
||||
it('appends to an existing book through the chapter endpoint', async () => {
|
||||
await signIn()
|
||||
const library = useLibraryStore()
|
||||
fetchMock().mockResolvedValueOnce(created({ chapter: chapter(), job, duplicate: false }))
|
||||
|
||||
const bookId = await library.submit({ title: '第二篇', text: 'Another text.', target: { mode: 'append', bookId: 1 } })
|
||||
|
||||
expect(bookId).toBe(1)
|
||||
expect(paths()[1]).toBe('/api/v1/books/1/chapters')
|
||||
})
|
||||
|
||||
it('reuses one requestId while the same unsent content keeps failing', async () => {
|
||||
await signIn()
|
||||
const library = useLibraryStore()
|
||||
const input = { title: '第一篇', text: 'Hello world.', target: { mode: 'new' as const } }
|
||||
fetchMock()
|
||||
.mockResolvedValueOnce(httpError(500, '服务器开小差了'))
|
||||
.mockResolvedValueOnce(created({ book, chapter: chapter(), job, duplicate: false }))
|
||||
|
||||
await expect(library.submit(input)).rejects.toThrow('服务器开小差了')
|
||||
expect(library.submitError).toBe('服务器开小差了')
|
||||
await library.submit(input)
|
||||
|
||||
// One chapter, not two: the retry of unchanged content reuses the requestId.
|
||||
expect(bodyOf(2).requestId).toBe(bodyOf(1).requestId)
|
||||
})
|
||||
|
||||
it('uses a fresh requestId after a successful submit and after the content changes', async () => {
|
||||
await signIn()
|
||||
const library = useLibraryStore()
|
||||
fetchMock()
|
||||
.mockResolvedValueOnce(created({ book, chapter: chapter(), job, duplicate: false }))
|
||||
.mockResolvedValueOnce(created({ book, chapter: chapter({ id: 56 }), job, duplicate: false }))
|
||||
.mockResolvedValueOnce(created({ book, chapter: chapter({ id: 57 }), job, duplicate: false }))
|
||||
|
||||
await library.submit({ title: '第一篇', text: 'Hello world.', target: { mode: 'new' } })
|
||||
await library.submit({ title: '第一篇', text: 'Hello world.', target: { mode: 'new' } })
|
||||
await library.submit({ title: '第一篇', text: 'Hello world changed.', target: { mode: 'new' } })
|
||||
|
||||
expect(bodyOf(2).requestId).not.toBe(bodyOf(1).requestId)
|
||||
expect(bodyOf(3).requestId).not.toBe(bodyOf(2).requestId)
|
||||
})
|
||||
|
||||
it('rejects invalid input before sending anything', async () => {
|
||||
await signIn()
|
||||
const library = useLibraryStore()
|
||||
const sent = paths().length
|
||||
|
||||
await expect(library.submit({ title: ' ', text: 'Hello.', target: { mode: 'new' } })).rejects.toThrow('请填写标题。')
|
||||
await expect(library.submit({ title: '标题', text: ' \n\t ', target: { mode: 'new' } })).rejects.toThrow('请粘贴要导入的英文正文。')
|
||||
await expect(library.submit({ title: 'x'.repeat(121), text: 'Hello.', target: { mode: 'new' } })).rejects.toThrow('标题不能超过 120 个字符。')
|
||||
await expect(library.submit({ title: '标题', text: 'a'.repeat(TEXT_MAX_CODE_POINTS + 1), target: { mode: 'new' } })).rejects.toThrow(`正文不能超过 ${TEXT_MAX_CODE_POINTS} 个字符。`)
|
||||
|
||||
expect(paths().length).toBe(sent)
|
||||
expect(library.submitError).toBe(`正文不能超过 ${TEXT_MAX_CODE_POINTS} 个字符。`)
|
||||
})
|
||||
|
||||
it('measures the text limit in Unicode code points', async () => {
|
||||
await signIn()
|
||||
const library = useLibraryStore()
|
||||
// 100000 astral characters are 200000 UTF-16 units but still within the limit.
|
||||
fetchMock().mockResolvedValueOnce(created({ book, chapter: chapter(), job, duplicate: false }))
|
||||
|
||||
await expect(library.submit({ title: '标题', text: '😀'.repeat(TEXT_MAX_CODE_POINTS), target: { mode: 'new' } })).resolves.toBe(1)
|
||||
expect([...('😀'.repeat(TEXT_MAX_CODE_POINTS))].length).toBe(TEXT_MAX_CODE_POINTS)
|
||||
})
|
||||
|
||||
it('polls a pending chapter until it is ready and then stops', async () => {
|
||||
vi.useFakeTimers()
|
||||
await signIn()
|
||||
const library = useLibraryStore()
|
||||
fetchMock().mockResolvedValueOnce(ok({ book, chapter: chapter(), navigation }))
|
||||
|
||||
await library.loadChapter(55)
|
||||
expect(library.chapter?.status).toBe('pending')
|
||||
expect(library.readerText).toBe('')
|
||||
|
||||
fetchMock().mockResolvedValueOnce(ok({ book, chapter: chapter({ status: 'ready', originalText: 'Hello\nworld.' }), navigation }))
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS)
|
||||
|
||||
expect(library.chapter?.status).toBe('ready')
|
||||
expect(library.readerText).toBe('Hello\nworld.')
|
||||
|
||||
const settled = paths().length
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 3)
|
||||
expect(paths().length).toBe(settled)
|
||||
})
|
||||
|
||||
it('retries a failed chapter through the chapter job id and resumes polling', async () => {
|
||||
vi.useFakeTimers()
|
||||
await signIn()
|
||||
const library = useLibraryStore()
|
||||
fetchMock().mockResolvedValueOnce(ok({ book, chapter: chapter({ status: 'failed', errorReason: 'decode_error', errorMessage: '无法解析正文。', jobId: 7 }), navigation }))
|
||||
|
||||
await library.loadChapter(55)
|
||||
expect(library.chapter?.status).toBe('failed')
|
||||
expect(canRetry(library.chapter!)).toBe(true)
|
||||
|
||||
fetchMock()
|
||||
.mockResolvedValueOnce(ok({ job: { ...job, status: 'pending', attempts: 1 }, chapter: { id: 55, bookId: 1, jobId: 7 } }))
|
||||
.mockResolvedValueOnce(ok({ book, chapter: chapter(), navigation }))
|
||||
await library.retryChapter(55)
|
||||
|
||||
expect(paths()).toContain('/api/v1/jobs/7/retry')
|
||||
expect(library.retryingChapterId).toBeNull()
|
||||
expect(library.chapter?.status).toBe('pending')
|
||||
|
||||
// The retry restarts polling for the chapter it re-queued.
|
||||
const before = paths().length
|
||||
fetchMock().mockResolvedValueOnce(ok({ book, chapter: chapter({ status: 'ready', originalText: 'Hello world.' }), navigation }))
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS)
|
||||
expect(paths().length).toBe(before + 1)
|
||||
expect(library.readerText).toBe('Hello world.')
|
||||
})
|
||||
|
||||
it('refuses to retry a chapter whose job id is null', async () => {
|
||||
await signIn()
|
||||
const library = useLibraryStore()
|
||||
fetchMock().mockResolvedValueOnce(ok({ book, chapter: chapter({ status: 'failed', jobId: null }), navigation }))
|
||||
await library.loadChapter(55)
|
||||
|
||||
expect(canRetry(library.chapter!)).toBe(false)
|
||||
await expect(library.retryChapter(55)).rejects.toThrow('这一章暂时没有可重试的任务编号。')
|
||||
expect(paths()).toEqual(['/api/v1/login', '/api/v1/chapters/55'])
|
||||
})
|
||||
|
||||
it('reports another account id as 内容不存在 and stops polling for it', async () => {
|
||||
vi.useFakeTimers()
|
||||
await signIn()
|
||||
const library = useLibraryStore()
|
||||
fetchMock().mockResolvedValueOnce(httpError(404, 'chapter not found'))
|
||||
|
||||
await library.loadChapter(99)
|
||||
expect(library.chapter).toBeNull()
|
||||
expect(library.chapterError).toBe(NOT_FOUND_MESSAGE)
|
||||
|
||||
fetchMock().mockResolvedValueOnce(httpError(404, 'book not found'))
|
||||
await library.loadBook(99)
|
||||
expect(library.book).toBeNull()
|
||||
expect(library.bookError).toBe(NOT_FOUND_MESSAGE)
|
||||
|
||||
const settled = paths().length
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4)
|
||||
expect(paths().length).toBe(settled)
|
||||
})
|
||||
|
||||
it('stops polling and ignores a late response once the session is cleared', async () => {
|
||||
vi.useFakeTimers()
|
||||
const session = await signIn()
|
||||
const library = useLibraryStore()
|
||||
fetchMock().mockResolvedValueOnce(ok({ book, chapter: chapter(), navigation }))
|
||||
await library.loadChapter(55)
|
||||
expect(library.chapter).not.toBeNull()
|
||||
|
||||
let finish!: (response: Response) => void
|
||||
fetchMock().mockImplementationOnce(() => new Promise<Response>(resolve => { finish = resolve }))
|
||||
fetchMock().mockResolvedValueOnce(ok(null))
|
||||
const late = library.loadChapter(55, { silent: true })
|
||||
const logout = session.logout()
|
||||
|
||||
finish(ok({ book, chapter: chapter({ status: 'ready', originalText: 'Late text.' }), navigation }))
|
||||
await late
|
||||
await logout
|
||||
|
||||
expect(session.user).toBeNull()
|
||||
expect(library.chapter).toBeNull()
|
||||
expect(library.readerText).toBe('')
|
||||
|
||||
const settled = paths().length
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4)
|
||||
expect(paths().length).toBe(settled)
|
||||
})
|
||||
|
||||
it('keeps newer state when an older request answers later', async () => {
|
||||
await signIn()
|
||||
const library = useLibraryStore()
|
||||
let finish!: (response: Response) => void
|
||||
fetchMock().mockImplementationOnce(() => new Promise<Response>(resolve => { finish = resolve }))
|
||||
fetchMock().mockResolvedValueOnce(ok({ items: [summary({ title: '较新的标题' })] }))
|
||||
|
||||
const stale = library.loadBooks()
|
||||
await library.loadBooks()
|
||||
finish(ok({ items: [summary({ title: '过期的标题' })] }))
|
||||
await stale
|
||||
|
||||
expect(library.books).toHaveLength(1)
|
||||
expect(library.books[0]?.title).toBe('较新的标题')
|
||||
})
|
||||
|
||||
// Regression R1: the append contract has no language field and the server rejects unknown
|
||||
// fields, so a client that sent one could never append.
|
||||
it('sends the language only when creating a book, never when appending', async () => {
|
||||
await signIn()
|
||||
const library = useLibraryStore()
|
||||
fetchMock().mockResolvedValueOnce(created({ book, chapter: chapter({ id: 54 }) }))
|
||||
await library.submit({ title: ' 新书 ', text: 'New book text.', target: { mode: 'new' } })
|
||||
expect(bodyOf(1)).toEqual({ requestId: expect.any(String), title: '新书', text: 'New book text.', language: 'en' })
|
||||
|
||||
fetchMock().mockResolvedValueOnce(created({ chapter: chapter({ id: 55, ordinal: 2 }) }))
|
||||
await library.submit({ title: '第二篇', text: 'Appended text.', target: { mode: 'append', bookId: 1 } })
|
||||
expect(String(fetchMock().mock.calls[2]?.[0])).toBe('/api/v1/books/1/chapters')
|
||||
expect(bodyOf(2)).toEqual({ requestId: expect.any(String), title: '第二篇', text: 'Appended text.' })
|
||||
expect(bodyOf(2)).not.toHaveProperty('language')
|
||||
})
|
||||
|
||||
// Regression R3: leaving a view must invalidate its in-flight request.
|
||||
it('ignores a book response that arrives after the book view was closed', async () => {
|
||||
vi.useFakeTimers()
|
||||
await signIn()
|
||||
const library = useLibraryStore()
|
||||
let finish!: (response: Response) => void
|
||||
fetchMock().mockImplementationOnce(() => new Promise<Response>(resolve => { finish = resolve }))
|
||||
|
||||
const pending = library.loadBook(1)
|
||||
library.closeBook()
|
||||
finish(ok({ book, chapters: [chapter()] }))
|
||||
await pending
|
||||
|
||||
expect(library.book).toBeNull()
|
||||
expect(library.chapters).toEqual([])
|
||||
expect(library.bookLoading).toBe(false)
|
||||
const settled = paths().length
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 3)
|
||||
expect(paths().length).toBe(settled)
|
||||
})
|
||||
|
||||
it('ignores a chapter response that arrives after the reader was closed', async () => {
|
||||
vi.useFakeTimers()
|
||||
await signIn()
|
||||
const library = useLibraryStore()
|
||||
let finish!: (response: Response) => void
|
||||
fetchMock().mockImplementationOnce(() => new Promise<Response>(resolve => { finish = resolve }))
|
||||
|
||||
const pending = library.loadChapter(55)
|
||||
library.closeChapter()
|
||||
finish(ok({ book, chapter: chapter({ status: 'ready', originalText: 'Late text.' }), navigation }))
|
||||
await pending
|
||||
|
||||
expect(library.chapter).toBeNull()
|
||||
expect(library.readerText).toBe('')
|
||||
expect(library.chapterLoading).toBe(false)
|
||||
const settled = paths().length
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 3)
|
||||
expect(paths().length).toBe(settled)
|
||||
})
|
||||
|
||||
// Regression R4: an accepted retry must be visible and tracked even if the refresh fails.
|
||||
it('keeps tracking a retried chapter when the first refresh fails', async () => {
|
||||
vi.useFakeTimers()
|
||||
await signIn()
|
||||
const library = useLibraryStore()
|
||||
fetchMock().mockResolvedValueOnce(ok({ book, chapter: chapter({ status: 'failed', errorReason: 'content_changed', errorMessage: '内容在处理前发生变化。', jobId: 7 }), navigation }))
|
||||
await library.loadChapter(55)
|
||||
expect(library.chapter?.status).toBe('failed')
|
||||
|
||||
fetchMock()
|
||||
.mockResolvedValueOnce(ok({ job: { ...job, status: 'pending' }, chapter: chapter({ status: 'pending' }) }))
|
||||
.mockRejectedValueOnce(new Error('network down'))
|
||||
await library.retryChapter(55)
|
||||
|
||||
expect(paths()).toContain('/api/v1/jobs/7/retry')
|
||||
expect(library.chapter?.status).toBe('pending')
|
||||
expect(library.readerText).toBe('')
|
||||
|
||||
// The next poll still tracks the queued chapter and shows the final result.
|
||||
fetchMock().mockResolvedValueOnce(ok({ book, chapter: chapter({ status: 'ready', originalText: 'Recovered text.' }), navigation }))
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS)
|
||||
expect(library.chapter?.status).toBe('ready')
|
||||
expect(library.readerText).toBe('Recovered text.')
|
||||
})
|
||||
|
||||
it('keeps tracking a retried chapter from the book page when the first refresh fails', async () => {
|
||||
vi.useFakeTimers()
|
||||
await signIn()
|
||||
const library = useLibraryStore()
|
||||
fetchMock().mockResolvedValueOnce(ok({ book, chapters: [chapter({ status: 'failed', errorReason: 'content_changed', errorMessage: '内容在处理前发生变化。' })] }))
|
||||
await library.loadBook(1)
|
||||
expect(library.chapters[0]?.status).toBe('failed')
|
||||
|
||||
fetchMock()
|
||||
.mockResolvedValueOnce(ok({ job: { ...job, status: 'pending' }, chapter: chapter({ status: 'pending' }) }))
|
||||
.mockRejectedValueOnce(new Error('network down'))
|
||||
await library.retryChapter(55)
|
||||
|
||||
expect(library.chapters[0]?.status).toBe('pending')
|
||||
|
||||
fetchMock().mockResolvedValueOnce(ok({ book, chapters: [chapter({ status: 'ready' })] }))
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS)
|
||||
expect(library.chapters[0]?.status).toBe('ready')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,265 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createMemoryHistory, createRouter, type Router } from 'vue-router'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import BookView from '../views/BookView.vue'
|
||||
import ImportView from '../views/ImportView.vue'
|
||||
import ReaderView from '../views/ReaderView.vue'
|
||||
import { useLibraryStore, type ChapterStatus } from '../stores/library'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
|
||||
// All accounts, books and texts in these tests are deliberately fictitious.
|
||||
const user = { id: 42, username: 'fictional-reader', role: 'learner' as const }
|
||||
const book = { id: 1, title: '虚构样例书', language: 'en' }
|
||||
const navigation = { previousChapterId: null, nextChapterId: null }
|
||||
const pasted = 'First line.\n\tIndented line.\nTwo spaces kept.\n\nLast line.\n'
|
||||
|
||||
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }), { status: 200 })
|
||||
const created = (data: unknown) => new Response(JSON.stringify({ code: 200, data }), { status: 201 })
|
||||
|
||||
function chapter(status: ChapterStatus, extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 55,
|
||||
bookId: 1,
|
||||
ordinal: 1,
|
||||
title: '第一篇',
|
||||
status,
|
||||
charCount: 120,
|
||||
errorReason: '',
|
||||
errorMessage: '',
|
||||
jobId: 7,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
const stub = (name: string) => defineComponent({ name, render: () => h('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') },
|
||||
{ path: '/chapters/:id', component: stub('ChapterStub') },
|
||||
],
|
||||
})
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
return router
|
||||
}
|
||||
|
||||
function signIn() {
|
||||
useSessionStore().user = { ...user }
|
||||
}
|
||||
|
||||
describe('learner reading views', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
setActivePinia(createPinia())
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useLibraryStore().stopPolling()
|
||||
})
|
||||
|
||||
it('shows inline validation and sends nothing for an empty import form', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
signIn()
|
||||
const router = await viewAt('/import')
|
||||
const wrapper = mount(ImportView, { global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('form').trigger('submit')
|
||||
expect(wrapper.text()).toContain('请填写标题。')
|
||||
expect(wrapper.text()).toContain('请粘贴要导入的英文正文。')
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
|
||||
await wrapper.find('input#title').setValue(' ')
|
||||
await wrapper.find('textarea#text').setValue(' \n\t ')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
expect(wrapper.text()).toContain('请填写标题。')
|
||||
expect(wrapper.text()).toContain('请粘贴要导入的英文正文。')
|
||||
|
||||
await wrapper.find('input#title').setValue('虚构样例第一章')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
expect(wrapper.text()).not.toContain('请填写标题。')
|
||||
expect(wrapper.text()).toContain('请粘贴要导入的英文正文。')
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('submits valid pasted text and opens the created book', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(created({ book, chapter: chapter('pending'), job: { id: 7 }, duplicate: false }))
|
||||
signIn()
|
||||
const router = await viewAt('/import')
|
||||
const wrapper = mount(ImportView, { global: { plugins: [router] } })
|
||||
// Element Plus assigns the input ids on mount, so wait for the first update.
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('input#title').setValue('虚构样例第一章')
|
||||
await wrapper.find('textarea#text').setValue(pasted)
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({ title: '虚构样例第一章', text: pasted, language: 'en' })
|
||||
expect(router.currentRoute.value.path).toBe('/books/1')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('renders a ready chapter verbatim, keeping line breaks, tabs and repeated spaces', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(ok({ book, chapter: chapter('ready', { originalText: pasted, contentSha256: 'fictional' }), navigation }))
|
||||
signIn()
|
||||
const router = await viewAt('/chapters/55')
|
||||
const wrapper = mount(ReaderView, { global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
|
||||
const article = wrapper.find('.reader-text')
|
||||
expect(article.exists()).toBe(true)
|
||||
expect(article.element.textContent).toBe(pasted)
|
||||
expect(wrapper.text()).toContain('已就绪')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows the failure message of a failed chapter and no text', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(ok({ book, chapter: chapter('failed', { errorReason: 'decode_error', errorMessage: '无法解析正文,请检查编码。', jobId: null }), navigation }))
|
||||
signIn()
|
||||
const router = await viewAt('/chapters/55')
|
||||
const wrapper = mount(ReaderView, { global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('处理失败')
|
||||
expect(wrapper.text()).toContain('无法解析正文,请检查编码。')
|
||||
expect(wrapper.find('.reader-text').exists()).toBe(false)
|
||||
// A null job id means there is nothing to retry yet.
|
||||
expect(wrapper.text()).toContain('这一章暂时没有可重试的任务编号。')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('offers retry on a failed chapter through its own job id', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(ok({ book, chapter: chapter('failed', { errorMessage: '解析失败。', jobId: 7 }), navigation }))
|
||||
signIn()
|
||||
const router = await viewAt('/chapters/55')
|
||||
const wrapper = mount(ReaderView, { global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('解析失败。')
|
||||
const retryButton = wrapper.get('.notice .el-button')
|
||||
expect(retryButton.text()).toContain('重试处理')
|
||||
await retryButton.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
// No prior submit in this session: the job id comes from the chapter payload.
|
||||
const retryCall = fetchMock.mock.calls.find(([input]) => String(input).endsWith('/jobs/7/retry'))
|
||||
expect(retryCall?.[1]?.method).toBe('POST')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('lists chapters with their status labels and links only ready chapters', async () => {
|
||||
const chapters = [
|
||||
chapter('pending', { id: 55, ordinal: 1, title: '第一篇', jobId: 7 }),
|
||||
chapter('processing', { id: 56, ordinal: 2, title: '第二篇', jobId: 8 }),
|
||||
chapter('ready', { id: 57, ordinal: 3, title: '第三篇', jobId: 9 }),
|
||||
chapter('failed', { id: 58, ordinal: 4, title: '第四篇', errorMessage: '解析失败。', jobId: null }),
|
||||
chapter('failed', { id: 59, ordinal: 5, title: '第五篇', errorMessage: '编码错误。', jobId: 10 }),
|
||||
]
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(input => {
|
||||
if (String(input).endsWith('/jobs/10/retry')) {
|
||||
return Promise.resolve(ok({ job: { id: 10, status: 'pending', attempts: 1 }, chapter: { id: 59, bookId: 1, jobId: 10 } }))
|
||||
}
|
||||
return Promise.resolve(ok({ book, chapters }))
|
||||
})
|
||||
signIn()
|
||||
const router = await viewAt('/books/1')
|
||||
const wrapper = mount(BookView, { global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
|
||||
const text = wrapper.text()
|
||||
expect(text).toContain('待处理')
|
||||
expect(text).toContain('处理中')
|
||||
expect(text).toContain('已就绪')
|
||||
expect(text).toContain('处理失败')
|
||||
expect(text).toContain('解析失败。')
|
||||
expect(text).toContain('编码错误。')
|
||||
expect(wrapper.find('a[href="/chapters/57"]').exists()).toBe(true)
|
||||
expect(wrapper.find('a[href="/chapters/55"]').exists()).toBe(false)
|
||||
|
||||
// Retry is offered only for the failed chapter that carries a job id.
|
||||
const retryButtons = wrapper.findAll('.chapter-row .el-button').filter(button => button.text().includes('重试'))
|
||||
expect(retryButtons).toHaveLength(1)
|
||||
await retryButtons[0]!.trigger('click')
|
||||
await flushPromises()
|
||||
const retryCall = fetchMock.mock.calls.find(([input]) => String(input).endsWith('/jobs/10/retry'))
|
||||
expect(retryCall?.[1]?.method).toBe('POST')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
// Regression R1: appending must not send the language field that the append contract rejects.
|
||||
it('appends to an existing book through the chapter endpoint without a language field', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(ok({
|
||||
items: [{
|
||||
...book,
|
||||
chapterCount: 1,
|
||||
pendingCount: 0,
|
||||
processingCount: 0,
|
||||
readyCount: 1,
|
||||
failedCount: 0,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
}],
|
||||
}))
|
||||
.mockResolvedValueOnce(created({ chapter: chapter('pending', { id: 56, ordinal: 2 }), job: { id: 8 } }))
|
||||
signIn()
|
||||
const router = await viewAt('/import?book=1')
|
||||
const wrapper = mount(ImportView, { global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('input#title').setValue('第二篇')
|
||||
await wrapper.find('textarea#text').setValue(pasted)
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
const appendCall = fetchMock.mock.calls.find(([input]) => String(input).endsWith('/books/1/chapters'))
|
||||
expect(appendCall?.[1]?.method).toBe('POST')
|
||||
const body = JSON.parse(String(appendCall?.[1]?.body)) as Record<string, unknown>
|
||||
expect(body).toEqual({ requestId: expect.any(String), title: '第二篇', text: pasted })
|
||||
expect(body).not.toHaveProperty('language')
|
||||
expect(router.currentRoute.value.path).toBe('/books/1')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
// Regression R3: a submit that finishes after the user left the page must not navigate back.
|
||||
it('does not navigate after the user left the import page during a submit', async () => {
|
||||
let finish!: (response: Response) => void
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
.mockImplementationOnce(() => new Promise<Response>(resolve => { finish = resolve }))
|
||||
signIn()
|
||||
const router = await viewAt('/import')
|
||||
const wrapper = mount(ImportView, { global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('input#title').setValue('虚构样例第一章')
|
||||
await wrapper.find('textarea#text').setValue(pasted)
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
// The user leaves the page while the request is still open.
|
||||
wrapper.unmount()
|
||||
await router.push('/')
|
||||
await flushPromises()
|
||||
|
||||
finish(created({ book, chapter: chapter('pending'), job: { id: 7 } }))
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/')
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,10 @@ import App from './App.vue'
|
||||
import router from './router'
|
||||
import 'element-plus/es/components/button/style/css'
|
||||
import 'element-plus/es/components/input/style/css'
|
||||
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 './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: '/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') },
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||
],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ApiError, useSessionStore } from './session'
|
||||
|
||||
export type ChapterStatus = 'pending' | 'processing' | 'ready' | 'failed'
|
||||
|
||||
export interface BookRef { id: number; title: string; language: string }
|
||||
|
||||
export interface BookSummary extends BookRef {
|
||||
chapterCount: number
|
||||
pendingCount: number
|
||||
processingCount: number
|
||||
readyCount: number
|
||||
failedCount: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface ChapterSummary {
|
||||
id: number
|
||||
bookId: number
|
||||
ordinal: number
|
||||
title: string
|
||||
status: ChapterStatus
|
||||
charCount: number
|
||||
errorReason: string
|
||||
errorMessage: string
|
||||
// Present wherever a chapter appears; null while the job id is unknown.
|
||||
jobId: number | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface ChapterDetail extends ChapterSummary {
|
||||
contentSha256: string
|
||||
// Present only for ready chapters; never cached or faked for other statuses.
|
||||
originalText?: string
|
||||
}
|
||||
|
||||
export interface Job {
|
||||
id: number
|
||||
bookId: number
|
||||
chapterId: number
|
||||
status: ChapterStatus
|
||||
attempts: number
|
||||
errorReason: string
|
||||
errorMessage: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface ChapterNavigation { previousChapterId: number | null; nextChapterId: number | null }
|
||||
|
||||
export type SubmitTarget = { mode: 'new' } | { mode: 'append'; bookId: number }
|
||||
|
||||
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
|
||||
export const NOT_FOUND_MESSAGE = '内容不存在。'
|
||||
export const LANGUAGE_LABEL = '英语'
|
||||
export const LANGUAGE_CODE = 'en'
|
||||
|
||||
const CHAPTER_STATUS_LABELS: Record<ChapterStatus, string> = {
|
||||
pending: '待处理',
|
||||
processing: '处理中',
|
||||
ready: '已就绪',
|
||||
failed: '处理失败',
|
||||
}
|
||||
|
||||
export function statusLabel(status: ChapterStatus): string {
|
||||
return CHAPTER_STATUS_LABELS[status]
|
||||
}
|
||||
|
||||
function isUnsettled(status: ChapterStatus): boolean {
|
||||
return status === 'pending' || status === 'processing'
|
||||
}
|
||||
|
||||
/** Mirrors the server rule: non-empty after trim and at most 120 characters. */
|
||||
export function titleProblem(title: string): string {
|
||||
const trimmed = title.trim()
|
||||
if (!trimmed) return '请填写标题。'
|
||||
if ([...trimmed].length > TITLE_MAX_LENGTH) return `标题不能超过 ${TITLE_MAX_LENGTH} 个字符。`
|
||||
return ''
|
||||
}
|
||||
|
||||
/** Mirrors the server rule: at least one non-whitespace character, at most 100000 code points. */
|
||||
export function textProblem(text: string): string {
|
||||
if (!text.trim()) return '请粘贴要导入的英文正文。'
|
||||
if ([...text].length > TEXT_MAX_CODE_POINTS) return `正文不能超过 ${TEXT_MAX_CODE_POINTS} 个字符。`
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/** Compact one-line status summary for a book card, e.g. `已就绪 2 · 处理中 1 · 待处理 1 · 失败 1`. */
|
||||
export function statusSummary(book: BookSummary): string {
|
||||
const parts: string[] = []
|
||||
if (book.readyCount > 0) parts.push(`已就绪 ${book.readyCount}`)
|
||||
if (book.processingCount > 0) parts.push(`处理中 ${book.processingCount}`)
|
||||
if (book.pendingCount > 0) parts.push(`待处理 ${book.pendingCount}`)
|
||||
if (book.failedCount > 0) parts.push(`失败 ${book.failedCount}`)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
interface LoadOptions { silent?: boolean }
|
||||
interface SubmitBookBody { requestId: string; title: string; text: string; language: 'en' }
|
||||
// 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 }
|
||||
interface Created { bookId: number; chapter: ChapterSummary }
|
||||
|
||||
function emptyNavigation(): ChapterNavigation {
|
||||
return { previousChapterId: null, nextChapterId: null }
|
||||
}
|
||||
|
||||
export const useLibraryStore = defineStore('library', () => {
|
||||
const session = useSessionStore()
|
||||
|
||||
const books = ref<BookSummary[]>([])
|
||||
const booksLoading = ref(false)
|
||||
const booksError = ref('')
|
||||
|
||||
const book = ref<BookRef | null>(null)
|
||||
const chapters = ref<ChapterSummary[]>([])
|
||||
const bookLoading = ref(false)
|
||||
const bookError = ref('')
|
||||
|
||||
const chapter = ref<ChapterDetail | null>(null)
|
||||
const chapterBook = ref<BookRef | null>(null)
|
||||
const navigation = ref<ChapterNavigation>(emptyNavigation())
|
||||
const chapterLoading = ref(false)
|
||||
const chapterError = ref('')
|
||||
|
||||
const submitting = ref(false)
|
||||
const submitError = ref('')
|
||||
const retryingChapterId = 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 ?? '' : ''))
|
||||
|
||||
// Every request is tagged with a generation and the owning account so that a
|
||||
// late response can never repopulate the view after logout or an account switch.
|
||||
let generation = 0
|
||||
let booksSeq = 0
|
||||
let bookSeq = 0
|
||||
let chapterSeq = 0
|
||||
let pollTimer: number | undefined
|
||||
// One requestId per unsent form content: a double click or a repeat submit of
|
||||
// unchanged content must create one chapter, not two.
|
||||
let submissionKey = ''
|
||||
let submissionRequestId = ''
|
||||
|
||||
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 failureMessage(reason: unknown, fallback: string): string {
|
||||
return reason instanceof Error && reason.message ? reason.message : fallback
|
||||
}
|
||||
|
||||
function isNotFound(reason: unknown): boolean {
|
||||
return reason instanceof ApiError && reason.status === 404
|
||||
}
|
||||
|
||||
function stopPolling(): void {
|
||||
if (pollTimer !== undefined) {
|
||||
window.clearTimeout(pollTimer)
|
||||
pollTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function needsPolling(): boolean {
|
||||
return (book.value !== null && chapters.value.some(item => isUnsettled(item.status)))
|
||||
|| (chapter.value !== null && isUnsettled(chapter.value.status))
|
||||
}
|
||||
|
||||
/** Schedules the next refresh, or stops polling when nothing is pending anymore. */
|
||||
function schedulePolling(): void {
|
||||
if (!needsPolling()) {
|
||||
stopPolling()
|
||||
return
|
||||
}
|
||||
if (pollTimer !== undefined || !session.user) return
|
||||
pollTimer = window.setTimeout(() => {
|
||||
pollTimer = undefined
|
||||
void poll()
|
||||
}, POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
async function poll(): Promise<void> {
|
||||
if (!session.user) {
|
||||
stopPolling()
|
||||
return
|
||||
}
|
||||
const version = generation
|
||||
const owner = ownerId()
|
||||
const bookId = book.value !== null && chapters.value.some(item => isUnsettled(item.status)) ? book.value.id : null
|
||||
const chapterId = chapter.value !== null && isUnsettled(chapter.value.status) ? chapter.value.id : null
|
||||
if (bookId !== null) await loadBook(bookId, { silent: true })
|
||||
if (chapterId !== null) await loadChapter(chapterId, { silent: true })
|
||||
if (isStale(version, owner)) {
|
||||
stopPolling()
|
||||
return
|
||||
}
|
||||
schedulePolling()
|
||||
}
|
||||
|
||||
async function loadBooks(): Promise<void> {
|
||||
const version = generation
|
||||
const owner = ownerId()
|
||||
const seq = ++booksSeq
|
||||
booksLoading.value = true
|
||||
booksError.value = ''
|
||||
try {
|
||||
const result = await session.request<{ items?: BookSummary[] } | null>('books')
|
||||
if (seq !== booksSeq || isStale(version, owner)) return
|
||||
const items = result?.items
|
||||
books.value = Array.isArray(items) ? items : []
|
||||
} catch (reason) {
|
||||
if (seq !== booksSeq || isStale(version, owner)) return
|
||||
booksError.value = failureMessage(reason, '书库暂时无法加载,请稍后重试。')
|
||||
} finally {
|
||||
if (seq === booksSeq && !isStale(version, owner)) booksLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBook(id: number, options: LoadOptions = {}): Promise<void> {
|
||||
const version = generation
|
||||
const owner = ownerId()
|
||||
const seq = ++bookSeq
|
||||
if (!options.silent) {
|
||||
bookLoading.value = true
|
||||
bookError.value = ''
|
||||
}
|
||||
try {
|
||||
const result = await session.request<{ book: BookRef; chapters?: ChapterSummary[] }>(`books/${id}`)
|
||||
if (seq !== bookSeq || isStale(version, owner)) return
|
||||
book.value = result.book
|
||||
chapters.value = Array.isArray(result.chapters) ? result.chapters : []
|
||||
bookError.value = ''
|
||||
schedulePolling()
|
||||
} catch (reason) {
|
||||
if (seq !== bookSeq || isStale(version, owner)) return
|
||||
if (isNotFound(reason)) {
|
||||
// Another account's id never resolves for this caller: report it and stop
|
||||
// instead of polling a resource that will not appear.
|
||||
book.value = null
|
||||
chapters.value = []
|
||||
bookError.value = NOT_FOUND_MESSAGE
|
||||
stopPolling()
|
||||
return
|
||||
}
|
||||
// A failed background refresh keeps the data already on screen; the next
|
||||
// tick tries again and the user still sees the last known state.
|
||||
if (!options.silent) bookError.value = failureMessage(reason, '书籍暂时无法加载,请稍后重试。')
|
||||
} finally {
|
||||
if (seq === bookSeq && !isStale(version, owner)) bookLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChapter(id: number, options: LoadOptions = {}): Promise<void> {
|
||||
const version = generation
|
||||
const owner = ownerId()
|
||||
const seq = ++chapterSeq
|
||||
if (!options.silent) {
|
||||
chapterLoading.value = true
|
||||
chapterError.value = ''
|
||||
// Never keep the previous chapter's text under a new chapter id.
|
||||
if (chapter.value !== null && chapter.value.id !== id) {
|
||||
chapter.value = null
|
||||
chapterBook.value = null
|
||||
navigation.value = emptyNavigation()
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = await session.request<{ book: BookRef; chapter: ChapterDetail; navigation?: ChapterNavigation }>(`chapters/${id}`)
|
||||
if (seq !== chapterSeq || isStale(version, owner)) return
|
||||
chapterBook.value = result.book
|
||||
chapter.value = result.chapter
|
||||
navigation.value = result.navigation ?? emptyNavigation()
|
||||
chapterError.value = ''
|
||||
schedulePolling()
|
||||
} catch (reason) {
|
||||
if (seq !== chapterSeq || isStale(version, owner)) return
|
||||
if (isNotFound(reason)) {
|
||||
chapter.value = null
|
||||
chapterBook.value = null
|
||||
navigation.value = emptyNavigation()
|
||||
chapterError.value = NOT_FOUND_MESSAGE
|
||||
stopPolling()
|
||||
return
|
||||
}
|
||||
if (!options.silent) chapterError.value = failureMessage(reason, '章节暂时无法加载,请稍后重试。')
|
||||
} finally {
|
||||
if (seq === chapterSeq && !isStale(version, owner)) chapterLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function submissionKeyOf(target: SubmitTarget, title: string, text: string): string {
|
||||
return target.mode === 'new' ? `new\n${title}\n${text}` : `append:${target.bookId}\n${title}\n${text}`
|
||||
}
|
||||
|
||||
async function createBook(body: SubmitBookBody): Promise<Created> {
|
||||
const result = await session.request<{ book: BookRef; chapter: ChapterSummary }>('books', 'POST', body)
|
||||
return { bookId: result.book.id, chapter: result.chapter }
|
||||
}
|
||||
|
||||
async function appendChapter(bookId: number, body: SubmitChapterBody): Promise<Created> {
|
||||
const result = await session.request<{ chapter: ChapterSummary }>(`books/${bookId}/chapters`, 'POST', body)
|
||||
return { bookId: result.chapter.bookId, chapter: result.chapter }
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits pasted text. Returns the book id to open on success and throws on
|
||||
* failure; `submitError` always carries the message shown to the user.
|
||||
*/
|
||||
async function submit(input: SubmitInput): Promise<number> {
|
||||
const title = input.title.trim()
|
||||
const text = input.text
|
||||
const problem = titleProblem(title) || textProblem(text)
|
||||
if (problem) {
|
||||
submitError.value = problem
|
||||
throw new Error(problem)
|
||||
}
|
||||
|
||||
const key = submissionKeyOf(input.target, title, text)
|
||||
if (key !== submissionKey || submissionRequestId === '') {
|
||||
submissionKey = key
|
||||
submissionRequestId = crypto.randomUUID()
|
||||
}
|
||||
const requestId = submissionRequestId
|
||||
|
||||
const version = generation
|
||||
const owner = ownerId()
|
||||
submitting.value = true
|
||||
submitError.value = ''
|
||||
try {
|
||||
// Only the new-book contract carries a language; appending inherits the book's language.
|
||||
const created = input.target.mode === 'new'
|
||||
? await createBook({ requestId, title, text, language: LANGUAGE_CODE })
|
||||
: await appendChapter(input.target.bookId, { requestId, title, text })
|
||||
if (isStale(version, owner)) throw new Error('登录状态已变化,请重新提交。')
|
||||
// The content was accepted; a later submit must use a fresh requestId.
|
||||
submissionKey = ''
|
||||
submissionRequestId = ''
|
||||
return created.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
|
||||
}
|
||||
}
|
||||
|
||||
/** 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)
|
||||
?? (chapter.value?.id === chapterId ? chapter.value : null)
|
||||
return target?.jobId ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a chapter summary coming from any response to the chapter list entry and to the
|
||||
* open reader, so a queued chapter is never displayed with the state or text it had before.
|
||||
*/
|
||||
function applyChapterSummary(summary: ChapterSummary): void {
|
||||
const index = chapters.value.findIndex(item => item.id === summary.id)
|
||||
if (index >= 0) chapters.value[index] = { ...chapters.value[index], ...summary }
|
||||
if (chapter.value !== null && chapter.value.id === summary.id) {
|
||||
const originalText = summary.status === 'ready' ? chapter.value.originalText : undefined
|
||||
chapter.value = { ...chapter.value, ...summary, originalText }
|
||||
}
|
||||
}
|
||||
|
||||
async function retryChapter(chapterId: number): Promise<void> {
|
||||
const jobId = jobIdOf(chapterId)
|
||||
if (jobId === null) throw new Error('这一章暂时没有可重试的任务编号。')
|
||||
const version = generation
|
||||
const owner = ownerId()
|
||||
retryingChapterId.value = chapterId
|
||||
try {
|
||||
const result = await session.request<{ job: Job; chapter: ChapterSummary }>(`jobs/${jobId}/retry`, 'POST')
|
||||
if (isStale(version, owner)) return
|
||||
// The retry is accepted, so show the queued chapter and keep tracking it even if the
|
||||
// refresh below fails: a failed silent refresh must not freeze the view on the old error.
|
||||
applyChapterSummary(result.chapter)
|
||||
schedulePolling()
|
||||
// Refresh whatever is on screen to pick up the newest job state.
|
||||
if (book.value !== null && book.value.id === result.chapter.bookId) await loadBook(result.chapter.bookId, { silent: true })
|
||||
if (chapter.value !== null && chapter.value.id === result.chapter.id) await loadChapter(result.chapter.id, { silent: true })
|
||||
} catch (reason) {
|
||||
if (isStale(version, owner)) return
|
||||
throw reason instanceof Error ? reason : new Error('重试失败,请稍后重试。')
|
||||
} finally {
|
||||
if (!isStale(version, owner)) retryingChapterId.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
|
||||
* restart polling for a page the user already left.
|
||||
*/
|
||||
function closeBook(): void {
|
||||
bookSeq++
|
||||
book.value = null
|
||||
chapters.value = []
|
||||
bookLoading.value = false
|
||||
bookError.value = ''
|
||||
schedulePolling()
|
||||
}
|
||||
|
||||
/** Releases the reader view, invalidating in-flight loads the same way. */
|
||||
function closeChapter(): void {
|
||||
chapterSeq++
|
||||
chapter.value = null
|
||||
chapterBook.value = null
|
||||
navigation.value = emptyNavigation()
|
||||
chapterLoading.value = false
|
||||
chapterError.value = ''
|
||||
schedulePolling()
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
generation++
|
||||
stopPolling()
|
||||
books.value = []
|
||||
booksLoading.value = false
|
||||
booksError.value = ''
|
||||
book.value = null
|
||||
chapters.value = []
|
||||
bookLoading.value = false
|
||||
bookError.value = ''
|
||||
chapter.value = null
|
||||
chapterBook.value = null
|
||||
navigation.value = emptyNavigation()
|
||||
chapterLoading.value = false
|
||||
chapterError.value = ''
|
||||
submitting.value = false
|
||||
submitError.value = ''
|
||||
retryingChapterId.value = null
|
||||
submissionKey = ''
|
||||
submissionRequestId = ''
|
||||
}
|
||||
|
||||
return {
|
||||
books, booksLoading, booksError,
|
||||
book, chapters, bookLoading, bookError,
|
||||
chapter, chapterBook, navigation, chapterLoading, chapterError,
|
||||
submitting, submitError, retryingChapterId, readerText,
|
||||
loadBooks, loadBook, loadChapter, submit, retryChapter,
|
||||
stopPolling, closeBook, closeChapter, reset,
|
||||
}
|
||||
})
|
||||
@@ -2,6 +2,17 @@ import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const TOKEN_KEY = 'lexgo-learner-token'
|
||||
// Carries the real HTTP status alongside the server message so callers can tell
|
||||
// "this id does not exist for me" (404) from a transient failure without
|
||||
// re-parsing the envelope. The message itself is unchanged.
|
||||
export class ApiError extends Error {
|
||||
readonly status: number
|
||||
constructor(message: string, status: number) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
interface User { id: number; username: string; role: 'admin' | 'learner' }
|
||||
interface Space { ownerId: number; language: 'en' }
|
||||
interface Login { token: string; expiresAt: string; user: User }
|
||||
@@ -36,7 +47,7 @@ export const useSessionStore = defineStore('session', () => {
|
||||
notice.value = '登录已失效,请重新登录。'
|
||||
}
|
||||
const result = await response.json()
|
||||
if (!response.ok || result.code !== 200) throw new Error(result.msg || '请求失败,请稍后重试。')
|
||||
if (!response.ok || result.code !== 200) throw new ApiError(result.msg || '请求失败,请稍后重试。', response.status)
|
||||
return result.data as T
|
||||
}
|
||||
|
||||
@@ -91,5 +102,5 @@ export const useSessionStore = defineStore('session', () => {
|
||||
if (previousToken) await request<null>('logout', 'POST', undefined, previousToken, -1)
|
||||
}
|
||||
|
||||
return { user, space, notice, login, restore, logout, loadSpace }
|
||||
return { user, space, notice, login, restore, logout, loadSpace, request }
|
||||
})
|
||||
|
||||
@@ -51,6 +51,50 @@ h1 { font-size: 30px; font-weight: 600; margin: 14px 0; letter-spacing: 1px; }
|
||||
.empty-library .book-mark { color: #6e8967; 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; }
|
||||
/* 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; }
|
||||
/* 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 .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; }
|
||||
/* 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; }
|
||||
/* Import form. */
|
||||
.import-form { max-width: 720px; background: #fffdf8; border: 1px solid #e0e3d8; 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; }
|
||||
.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-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; }
|
||||
/* 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; }
|
||||
@media (max-width: 760px) {
|
||||
.login-page { grid-template-columns: 1fr; }
|
||||
.welcome { padding: 28px; }
|
||||
@@ -62,4 +106,15 @@ h1 { font-size: 30px; font-weight: 600; margin: 14px 0; letter-spacing: 1px; }
|
||||
.site-header nav { order: 3; flex-basis: 100%; padding-top: 8px; }
|
||||
.library { margin-top: 32px; padding: 0 20px; }
|
||||
h1 { font-size: 26px; }
|
||||
/* Single column, tap-friendly controls and no horizontal overflow. */
|
||||
.page { margin-top: 32px; padding: 0 20px; }
|
||||
.page-title { align-items: flex-start; }
|
||||
.library-title { align-items: flex-start; }
|
||||
.library-actions { width: 100%; justify-content: space-between; }
|
||||
.book-grid { grid-template-columns: 1fr; }
|
||||
.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; }
|
||||
.reader-nav .el-button { flex: 1; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<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 { useSessionStore } from '../stores/session'
|
||||
|
||||
const session = useSessionStore()
|
||||
const library = useLibraryStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const retryError = ref('')
|
||||
|
||||
const bookId = computed(() => Number(route.params.id))
|
||||
|
||||
async function load() {
|
||||
retryError.value = ''
|
||||
await library.loadBook(bookId.value)
|
||||
}
|
||||
|
||||
async function retry(chapterId: number) {
|
||||
retryError.value = ''
|
||||
try { await library.retryChapter(chapterId) }
|
||||
catch (reason) { retryError.value = reason instanceof Error ? reason.message : '重试失败,请稍后重试。' }
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try { await session.logout() }
|
||||
catch { session.notice = '已退出此设备。服务器暂时无法连接,请稍后重试。' }
|
||||
finally { await router.replace('/login') }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
watch(bookId, () => { void load() })
|
||||
// Leaving the page releases the book so polling stops.
|
||||
onUnmounted(() => library.closeBook())
|
||||
</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>
|
||||
<div class="account">
|
||||
<span class="account-name">{{ session.user.username }}</span>
|
||||
<ElButton text @click="logout">退出登录</ElButton>
|
||||
</div>
|
||||
</header>
|
||||
<main class="page">
|
||||
<p class="breadcrumb"><RouterLink to="/">我的书库</RouterLink><span aria-hidden="true">/</span><span>{{ library.book?.title ?? '书籍' }}</span></p>
|
||||
<p v-if="library.bookLoading && !library.book" role="status" class="loading">正在加载…</p>
|
||||
<div v-else-if="library.bookError" class="notice">
|
||||
<p role="alert">{{ library.bookError }}</p>
|
||||
<ElButton @click="load">重试</ElButton>
|
||||
</div>
|
||||
<template v-else-if="library.book">
|
||||
<div class="page-title">
|
||||
<div>
|
||||
<h1>{{ library.book.title }}</h1>
|
||||
<p class="subtle">{{ library.chapters.length }} 个章节 · 语言 英语</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<RouterLink :to="`/import?book=${library.book.id}`" class="link-button">追加章节</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<span class="chapter-ordinal">{{ item.ordinal }}</span>
|
||||
<div class="chapter-info">
|
||||
<RouterLink v-if="item.status === 'ready'" :to="`/chapters/${item.id}`" class="chapter-name">{{ item.title }}</RouterLink>
|
||||
<span v-else class="chapter-name">{{ item.title }}</span>
|
||||
<p class="chapter-meta">
|
||||
{{ item.charCount }} 字符
|
||||
<template v-if="item.status === 'failed' && item.errorMessage"> · {{ item.errorMessage }}</template>
|
||||
</p>
|
||||
</div>
|
||||
<span class="status-chip" :class="`status-${item.status}`">{{ statusLabel(item.status) }}</span>
|
||||
<ElButton
|
||||
v-if="canRetry(item)"
|
||||
size="small"
|
||||
:loading="library.retryingChapterId === item.id"
|
||||
@click="retry(item.id)"
|
||||
>重试</ElButton>
|
||||
</li>
|
||||
</ul>
|
||||
<section v-else class="empty-library" aria-label="章节列表">
|
||||
<h2>这一本书还没有章节</h2>
|
||||
<p class="subtle">粘贴一段英文即可生成第一章。</p>
|
||||
</section>
|
||||
</template>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
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 { useSessionStore } from '../stores/session'
|
||||
|
||||
const session = useSessionStore()
|
||||
const library = useLibraryStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const title = ref('')
|
||||
const text = ref('')
|
||||
const mode = ref<'new' | 'append'>('new')
|
||||
const bookId = ref<number | undefined>(undefined)
|
||||
const titleError = ref('')
|
||||
const textError = ref('')
|
||||
const bookError = ref('')
|
||||
|
||||
const length = computed(() => [...text.value].length)
|
||||
const busy = computed(() => library.submitting)
|
||||
|
||||
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
|
||||
const value = Number(raw)
|
||||
return Number.isSafeInteger(value) && value > 0 ? value : undefined
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// `?book=<id>` preselects "append to an existing book".
|
||||
const preselect = requestedBookId()
|
||||
if (preselect === undefined) return
|
||||
mode.value = 'append'
|
||||
bookId.value = preselect
|
||||
})
|
||||
|
||||
watch(mode, value => {
|
||||
// The select can only list the caller's own books.
|
||||
if (value === 'append') void library.loadBooks()
|
||||
})
|
||||
|
||||
watch(title, () => { titleError.value = '' })
|
||||
watch(text, () => { textError.value = '' })
|
||||
|
||||
async function submit() {
|
||||
if (busy.value) return
|
||||
titleError.value = titleProblem(title.value)
|
||||
textError.value = textProblem(text.value)
|
||||
bookError.value = mode.value === 'append' && bookId.value === undefined ? '请选择要追加的书籍。' : ''
|
||||
if (titleError.value || textError.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 })
|
||||
// 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 = ''
|
||||
await router.replace(`/books/${createdBookId}`)
|
||||
} catch {
|
||||
// library.submitError already carries the server message for the template.
|
||||
}
|
||||
}
|
||||
|
||||
// The form may still be awaiting its submit when the user navigates away.
|
||||
let disposed = false
|
||||
onUnmounted(() => {
|
||||
disposed = true
|
||||
library.submitError = ''
|
||||
})
|
||||
</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>
|
||||
<div class="account">
|
||||
<span class="account-name">{{ session.user.username }}</span>
|
||||
</div>
|
||||
</header>
|
||||
<main class="page">
|
||||
<p class="breadcrumb"><RouterLink to="/">我的书库</RouterLink><span aria-hidden="true">/</span><span>导入内容</span></p>
|
||||
<div class="page-title">
|
||||
<div>
|
||||
<h1>导入英文内容</h1>
|
||||
<p class="subtle">粘贴英文正文,提交后系统会自动切分并处理章节。</p>
|
||||
</div>
|
||||
</div>
|
||||
<form class="import-form" novalidate @submit.prevent="submit">
|
||||
<div class="field">
|
||||
<span class="field-label">语言</span>
|
||||
<p class="fixed-value">{{ LANGUAGE_LABEL }}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="title">标题</label>
|
||||
<ElInput id="title" v-model="title" type="text" maxlength="200" placeholder="例如:虚构样例第一章" :disabled="busy" />
|
||||
<p v-if="titleError" role="alert" class="field-error">{{ titleError }}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">导入到</span>
|
||||
<ElRadioGroup v-model="mode" :disabled="busy" aria-label="导入目标">
|
||||
<ElRadio value="new">新建书籍</ElRadio>
|
||||
<ElRadio value="append">追加到已有书籍</ElRadio>
|
||||
</ElRadioGroup>
|
||||
</div>
|
||||
<div v-if="mode === 'append'" class="field">
|
||||
<label for="book">选择书籍</label>
|
||||
<ElSelect id="book" v-model="bookId" placeholder="请选择要追加的书籍" :disabled="busy" class="book-select">
|
||||
<ElOption v-for="item in library.books" :key="item.id" :label="item.title" :value="item.id" />
|
||||
</ElSelect>
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
<RouterLink to="/" class="subtle">返回书库</RouterLink>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3,8 +3,10 @@ import { onMounted, ref } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { ElButton } from 'element-plus'
|
||||
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 loading = ref(true)
|
||||
const error = ref('')
|
||||
@@ -13,7 +15,9 @@ async function load() {
|
||||
error.value = ''
|
||||
try { await session.loadSpace() }
|
||||
catch (reason) { error.value = reason instanceof Error ? reason.message : '暂时无法加载,请重试。' }
|
||||
finally { loading.value = false }
|
||||
// The book list keeps its own error so one failing call still shows a retry.
|
||||
if (!error.value) await library.loadBooks()
|
||||
loading.value = false
|
||||
}
|
||||
async function logout() {
|
||||
try { await session.logout() }
|
||||
@@ -34,11 +38,28 @@ onMounted(load)
|
||||
</div>
|
||||
</header>
|
||||
<main class="library">
|
||||
<div class="library-title"><div><h1>我的书库</h1><p class="subtle">你的阅读与学习,从这里开始。</p></div><span class="language">英语</span></div>
|
||||
<div class="library-title">
|
||||
<div><h1>我的书库</h1><p class="subtle">你的阅读与学习,从这里开始。</p></div>
|
||||
<div class="library-actions">
|
||||
<span class="language">英语</span>
|
||||
<ElButton type="primary" @click="router.push('/import')">导入内容</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="loading" role="status" class="loading">正在加载…</p>
|
||||
<div v-else-if="error" class="notice"><p role="alert">{{ error }}</p><ElButton @click="load">重试</ElButton></div>
|
||||
<section v-else-if="session.space" class="empty-library" aria-label="书库内容">
|
||||
<BookMark /><h2>书库还是空的</h2><p class="subtle">这里将收纳你的阅读内容。</p>
|
||||
<div v-else-if="error || library.booksError" class="notice">
|
||||
<p role="alert">{{ error || library.booksError }}</p>
|
||||
<ElButton @click="load">重试</ElButton>
|
||||
</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">
|
||||
<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>
|
||||
<p v-else class="subtle">尚未导入章节</p>
|
||||
</li>
|
||||
</ul>
|
||||
<section v-else class="empty-library" aria-label="书库内容">
|
||||
<BookMark /><h2>书库还是空的</h2><p class="subtle">粘贴一段英文,开始你的第一篇阅读。</p>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<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 { useSessionStore } from '../stores/session'
|
||||
|
||||
const session = useSessionStore()
|
||||
const library = useLibraryStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const retryError = ref('')
|
||||
|
||||
const chapterId = computed(() => Number(route.params.id))
|
||||
const chapter = computed(() => library.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))
|
||||
|
||||
async function load() {
|
||||
retryError.value = ''
|
||||
await library.loadChapter(chapterId.value)
|
||||
}
|
||||
|
||||
async function retry() {
|
||||
if (chapter.value === null) return
|
||||
retryError.value = ''
|
||||
try { await library.retryChapter(chapter.value.id) }
|
||||
catch (reason) { retryError.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
|
||||
void router.push(`/chapters/${id}`)
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try { await session.logout() }
|
||||
catch { session.notice = '已退出此设备。服务器暂时无法连接,请稍后重试。' }
|
||||
finally { await router.replace('/login') }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
watch(chapterId, () => { void load() })
|
||||
// Leaving the page releases the chapter so polling stops.
|
||||
onUnmounted(() => 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>
|
||||
<div class="account">
|
||||
<span class="account-name">{{ session.user.username }}</span>
|
||||
<ElButton text @click="logout">退出登录</ElButton>
|
||||
</div>
|
||||
</header>
|
||||
<main class="page reader-page">
|
||||
<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>
|
||||
<ElButton @click="load">重试</ElButton>
|
||||
</div>
|
||||
<template v-else-if="chapter">
|
||||
<p class="breadcrumb">
|
||||
<RouterLink to="/">我的书库</RouterLink>
|
||||
<span aria-hidden="true">/</span>
|
||||
<RouterLink v-if="library.chapterBook" :to="`/books/${library.chapterBook.id}`">{{ library.chapterBook.title }}</RouterLink>
|
||||
<span v-else>章节</span>
|
||||
</p>
|
||||
<div class="page-title">
|
||||
<h1>{{ chapter.title }}</h1>
|
||||
<span class="status-chip" :class="`status-${chapter.status}`">{{ statusLabel(chapter.status) }}</span>
|
||||
</div>
|
||||
<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>
|
||||
<p v-else class="subtle">这一章暂时没有可重试的任务编号。</p>
|
||||
</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>
|
||||
<article v-if="chapter.status === 'ready'" class="reader-text">{{ library.readerText }}</article>
|
||||
<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>
|
||||
</template>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -4,10 +4,11 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
driver "github.com/go-sql-driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
driver "github.com/go-sql-driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Migrate takes a connection-scoped lock. Only an empty or LexGo-owned schema is accepted.
|
||||
@@ -59,44 +60,17 @@ 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 > 2 {
|
||||
if product != "lexgo" || current < 0 || current > schemaVersion {
|
||||
return errors.New("unknown schema version")
|
||||
}
|
||||
if current == 2 {
|
||||
return nil
|
||||
// Each known version contributes its own statements; the version row advances only
|
||||
// after every statement succeeded, so a partially applied migration can be retried.
|
||||
statements := make([]string, 0, 16)
|
||||
if current < 2 {
|
||||
statements = append(statements, schemaV2Statements...)
|
||||
}
|
||||
statements := []string{
|
||||
`CREATE TABLE IF NOT EXISTS sys_user (
|
||||
user_id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL UNIQUE,
|
||||
password VARCHAR(128) NOT NULL, nick_name VARCHAR(128) NOT NULL DEFAULT '', phone VARCHAR(11) NOT NULL DEFAULT '',
|
||||
role_id INT NOT NULL, salt VARCHAR(255) NOT NULL DEFAULT '', avatar VARCHAR(255) NOT NULL DEFAULT '',
|
||||
sex VARCHAR(255) NOT NULL DEFAULT '', email VARCHAR(128) NOT NULL DEFAULT '', dept_id BIGINT NOT NULL DEFAULT 0,
|
||||
post_id BIGINT NOT NULL DEFAULT 0, remark VARCHAR(255) NOT NULL DEFAULT '', status VARCHAR(4) NOT NULL DEFAULT '2',
|
||||
create_by BIGINT NOT NULL DEFAULT 0, update_by BIGINT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME(3) NULL, updated_at DATETIME(3) NULL, deleted_at DATETIME(3) NULL,
|
||||
CHECK (role_id IN (1,2)), CHECK (status IN ('1','2'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS lexgo_spaces (
|
||||
owner_id BIGINT PRIMARY KEY, language VARCHAR(16) NOT NULL DEFAULT 'en',
|
||||
FOREIGN KEY (owner_id) REFERENCES sys_user(user_id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS lexgo_sessions (
|
||||
token_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
|
||||
owner_id BIGINT NOT NULL, expires_at DATETIME(3) NOT NULL,
|
||||
INDEX (owner_id), INDEX (expires_at),
|
||||
FOREIGN KEY (owner_id) REFERENCES sys_user(user_id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS lexgo_login_logs (
|
||||
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL DEFAULT '',
|
||||
result VARCHAR(16) NOT NULL, reason VARCHAR(32) NOT NULL, ip VARCHAR(45) NOT NULL DEFAULT '',
|
||||
created_at DATETIME(3) NOT NULL, INDEX(created_at,id), INDEX(username,created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS lexgo_operation_logs (
|
||||
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, actor_id BIGINT NOT NULL, actor_username VARCHAR(32) NOT NULL,
|
||||
target_id BIGINT NOT NULL DEFAULT 0, target_username VARCHAR(32) NOT NULL DEFAULT '',
|
||||
action VARCHAR(32) NOT NULL, result VARCHAR(16) NOT NULL, reason VARCHAR(32) NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL, INDEX(created_at,id), INDEX(actor_username,created_at), INDEX(target_username,created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
if current < 3 {
|
||||
statements = append(statements, schemaV3Statements...)
|
||||
}
|
||||
for i, s := range statements {
|
||||
if _, err = conn.ExecContext(ctx, s); err != nil {
|
||||
@@ -107,16 +81,89 @@ func Migrate(db *gorm.DB) error {
|
||||
return fmt.Errorf("migration statement %d failed", i+1)
|
||||
}
|
||||
}
|
||||
_, err = conn.ExecContext(ctx, "UPDATE lexgo_schema SET version=2 WHERE id=1")
|
||||
_, err = conn.ExecContext(ctx, fmt.Sprintf("UPDATE lexgo_schema SET version=%d WHERE id=1", schemaVersion))
|
||||
return err
|
||||
}
|
||||
|
||||
const schemaVersion = 3
|
||||
|
||||
var schemaV2Statements = []string{
|
||||
`CREATE TABLE IF NOT EXISTS sys_user (
|
||||
user_id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL UNIQUE,
|
||||
password VARCHAR(128) NOT NULL, nick_name VARCHAR(128) NOT NULL DEFAULT '', phone VARCHAR(11) NOT NULL DEFAULT '',
|
||||
role_id INT NOT NULL, salt VARCHAR(255) NOT NULL DEFAULT '', avatar VARCHAR(255) NOT NULL DEFAULT '',
|
||||
sex VARCHAR(255) NOT NULL DEFAULT '', email VARCHAR(128) NOT NULL DEFAULT '', dept_id BIGINT NOT NULL DEFAULT 0,
|
||||
post_id BIGINT NOT NULL DEFAULT 0, remark VARCHAR(255) NOT NULL DEFAULT '', status VARCHAR(4) NOT NULL DEFAULT '2',
|
||||
create_by BIGINT NOT NULL DEFAULT 0, update_by BIGINT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME(3) NULL, updated_at DATETIME(3) NULL, deleted_at DATETIME(3) NULL,
|
||||
CHECK (role_id IN (1,2)), CHECK (status IN ('1','2'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS lexgo_spaces (
|
||||
owner_id BIGINT PRIMARY KEY, language VARCHAR(16) NOT NULL DEFAULT 'en',
|
||||
FOREIGN KEY (owner_id) REFERENCES sys_user(user_id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS lexgo_sessions (
|
||||
token_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
|
||||
owner_id BIGINT NOT NULL, expires_at DATETIME(3) NOT NULL,
|
||||
INDEX (owner_id), INDEX (expires_at),
|
||||
FOREIGN KEY (owner_id) REFERENCES sys_user(user_id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS lexgo_login_logs (
|
||||
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL DEFAULT '',
|
||||
result VARCHAR(16) NOT NULL, reason VARCHAR(32) NOT NULL, ip VARCHAR(45) NOT NULL DEFAULT '',
|
||||
created_at DATETIME(3) NOT NULL, INDEX(created_at,id), INDEX(username,created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS lexgo_operation_logs (
|
||||
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, actor_id BIGINT NOT NULL, actor_username VARCHAR(32) NOT NULL,
|
||||
target_id BIGINT NOT NULL DEFAULT 0, target_username VARCHAR(32) NOT NULL DEFAULT '',
|
||||
action VARCHAR(32) NOT NULL, result VARCHAR(16) NOT NULL, reason VARCHAR(32) NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL, INDEX(created_at,id), INDEX(actor_username,created_at), INDEX(target_username,created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
}
|
||||
|
||||
// v3 adds the private library: books, chapters with byte-exact original text, and
|
||||
// persistent ingestion jobs. owner_id is denormalized onto chapters and jobs so every
|
||||
// query can filter by the authenticated identity without joining.
|
||||
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',
|
||||
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
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`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,
|
||||
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 '',
|
||||
created_at DATETIME(3) NOT NULL, updated_at DATETIME(3) NOT NULL,
|
||||
UNIQUE KEY uq_chapter_ordinal (book_id, ordinal), INDEX (owner_id, id),
|
||||
CHECK (status IN ('pending','processing','ready','failed')),
|
||||
FOREIGN KEY (book_id) REFERENCES lexgo_books(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS lexgo_ingest_jobs (
|
||||
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, owner_id BIGINT NOT NULL,
|
||||
book_id BIGINT UNSIGNED NOT NULL, chapter_id BIGINT UNSIGNED NOT NULL,
|
||||
request_key CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
content_sha256 CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'pending', attempts INT NOT NULL DEFAULT 0,
|
||||
error_reason VARCHAR(32) NOT NULL DEFAULT '',
|
||||
created_at DATETIME(3) NOT NULL, updated_at DATETIME(3) NOT NULL, finished_at DATETIME(3) NULL,
|
||||
UNIQUE KEY uq_job_request (owner_id, request_key), INDEX (status, id),
|
||||
CHECK (status IN ('pending','processing','ready','failed')),
|
||||
FOREIGN KEY (book_id) REFERENCES lexgo_books(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (chapter_id) REFERENCES lexgo_chapters(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
}
|
||||
|
||||
func CheckSchema(db *gorm.DB) error {
|
||||
var r struct {
|
||||
Version int
|
||||
Product string
|
||||
}
|
||||
if err := db.Table("lexgo_schema").Where("id=1").First(&r).Error; err != nil || r.Version != 2 || 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
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
package lexgo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// The ingestion worker is deliberately small: the fixed paste rule stores one chapter per
|
||||
// submit, so "processing" only validates the persisted content and publishes the chapter.
|
||||
// Claiming and finishing are separate transactions on purpose. A durable claim means a
|
||||
// chapter observed as processing stays recoverable, whether the process stops or only the
|
||||
// finishing transaction fails.
|
||||
|
||||
// Recovery is one mechanism used from two places: the startup pass treats every processing row
|
||||
// as abandoned, while the running worker sweeps rows that have been processing longer than any
|
||||
// legitimate claim-to-finish window. A job that keeps failing ends in a readable failure
|
||||
// instead of looping forever, and a retried job never creates a second chapter.
|
||||
const (
|
||||
maxIngestAttempts = 5
|
||||
// ingestStaleAfter must stay longer than the longest legitimate claim-to-finish window,
|
||||
// otherwise a healthy job could be processed twice. Reprocessing is harmless for content
|
||||
// because a job never creates a chapter, only publishes the one it was created with.
|
||||
ingestStaleAfter = 15 * time.Second
|
||||
)
|
||||
|
||||
// RecoverIngestJobs requeues jobs and chapters left in processing by an unclean stop. It runs
|
||||
// once before the worker starts, for a single-instance deployment.
|
||||
func RecoverIngestJobs(db *gorm.DB, now time.Time) (int64, error) {
|
||||
return requeueStaleIngestJobs(db, now, 0)
|
||||
}
|
||||
|
||||
// RequeueStaleIngestJobs recovers jobs whose finishing transaction did not complete, so a
|
||||
// running service does not depend on a restart to make progress again.
|
||||
func RequeueStaleIngestJobs(db *gorm.DB, now time.Time) (int64, error) {
|
||||
return requeueStaleIngestJobs(db, now, ingestStaleAfter)
|
||||
}
|
||||
|
||||
func requeueStaleIngestJobs(db *gorm.DB, now time.Time, staleAfter time.Duration) (int64, error) {
|
||||
ts := stamp(now)
|
||||
cutoff := stamp(now.Add(-staleAfter))
|
||||
var requeued int64
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
if len(stale) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := setIngestChapterStatus(tx, stale, statusPending, "", ts); err != nil {
|
||||
return err
|
||||
}
|
||||
result := tx.Model(&IngestJob{}).Where("id IN ?", stale).
|
||||
Updates(map[string]any{"status": statusPending, "updated_at": ts})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
requeued = result.RowsAffected
|
||||
return nil
|
||||
})
|
||||
return requeued, err
|
||||
}
|
||||
|
||||
// exhaustIngestJobs fails jobs that used up the attempt budget, so nothing can stay queued or
|
||||
// claimed forever. The recorded reason is readable and a manual retry is still allowed.
|
||||
func exhaustIngestJobs(tx *gorm.DB, ts time.Time) error {
|
||||
var exhausted []int64
|
||||
if err := tx.Model(&IngestJob{}).
|
||||
Where("status IN ? AND attempts >= ?", []string{statusPending, statusProcessing}, maxIngestAttempts).
|
||||
Pluck("id", &exhausted).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(exhausted) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := setIngestChapterStatus(tx, exhausted, statusFailed, reasonAttemptsExhausted, ts); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&IngestJob{}).Where("id IN ?", exhausted).Updates(map[string]any{
|
||||
"status": statusFailed, "error_reason": reasonAttemptsExhausted, "updated_at": ts, "finished_at": ts}).Error
|
||||
}
|
||||
|
||||
// setIngestChapterStatus mirrors a job outcome onto the chapters it owns.
|
||||
func setIngestChapterStatus(tx *gorm.DB, jobIDs []int64, status, reason string, ts time.Time) error {
|
||||
var chapterIDs []int64
|
||||
if err := tx.Model(&IngestJob{}).Where("id IN ?", jobIDs).Pluck("chapter_id", &chapterIDs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(chapterIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return tx.Model(&Chapter{}).Where("id IN ?", chapterIDs).
|
||||
Updates(map[string]any{"status": status, "error_reason": reason, "updated_at": ts}).Error
|
||||
}
|
||||
|
||||
// ClaimNextIngestJob takes the oldest pending job with attempts left and marks it processing in
|
||||
// its own transaction. The guarded update means only one worker can own a job.
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
return claim.Error
|
||||
}
|
||||
if claim.RowsAffected != 1 {
|
||||
return errJobTaken
|
||||
}
|
||||
if err := tx.Model(&Chapter{}).Where("id = ? AND owner_id = ?", job.ChapterID, job.OwnerID).
|
||||
Updates(map[string]any{"status": statusProcessing, "updated_at": ts}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
job.Status = statusProcessing
|
||||
job.Attempts++
|
||||
job.UpdatedAt = ts
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) || errors.Is(err, errJobTaken) {
|
||||
return IngestJob{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return IngestJob{}, false, err
|
||||
}
|
||||
return job, true, nil
|
||||
}
|
||||
|
||||
var errJobTaken = errors.New("ingestion job already claimed")
|
||||
|
||||
// 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.
|
||||
func FinishIngestJob(ctx context.Context, db *gorm.DB, job IngestJob, now time.Time) error {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
var book Book
|
||||
if err := tx.Where("id = ? AND owner_id = ?", job.BookID, job.OwnerID).First(&book).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if reason := unprocessableReason(book, chapter, job); reason != "" {
|
||||
if err := tx.Model(&IngestJob{}).Where("id = ?", job.ID).Updates(map[string]any{
|
||||
"status": statusFailed, "error_reason": reason, "updated_at": ts, "finished_at": ts}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&Chapter{}).Where("id = ?", chapter.ID).Updates(map[string]any{
|
||||
"status": statusFailed, "error_reason": reason, "updated_at": ts}).Error
|
||||
}
|
||||
if err := tx.Model(&Chapter{}).Where("id = ?", chapter.ID).Updates(map[string]any{
|
||||
"status": statusReady, "char_count": utf8.RuneCountInString(chapter.OriginalText), "updated_at": ts}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&IngestJob{}).Where("id = ?", job.ID).Updates(map[string]any{
|
||||
"status": statusReady, "error_reason": "", "updated_at": ts, "finished_at": ts}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func unprocessableReason(book Book, chapter Chapter, job IngestJob) string {
|
||||
if book.Language != "en" {
|
||||
return reasonUnsupportedLanguage
|
||||
}
|
||||
if strings.TrimFunc(chapter.OriginalText, unicode.IsSpace) == "" {
|
||||
return reasonEmptyText
|
||||
}
|
||||
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 {
|
||||
return reasonContentChanged
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 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) {
|
||||
processed := 0
|
||||
for i := 0; i < limit; i++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return processed, err
|
||||
}
|
||||
job, claimed, err := ClaimNextIngestJob(db.WithContext(ctx), now())
|
||||
if err != nil {
|
||||
return processed, err
|
||||
}
|
||||
if !claimed {
|
||||
return processed, nil
|
||||
}
|
||||
if err = FinishIngestJob(ctx, db, job, now()); err != nil {
|
||||
return processed, err
|
||||
}
|
||||
processed++
|
||||
}
|
||||
return processed, nil
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
package lexgo
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
driver "github.com/go-sql-driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// Chapter and job share one status vocabulary so the client renders either without mapping.
|
||||
const (
|
||||
statusPending = "pending"
|
||||
statusProcessing = "processing"
|
||||
statusReady = "ready"
|
||||
statusFailed = "failed"
|
||||
)
|
||||
|
||||
// Fixed worker-side failure reasons. Only these codes are stored; the readable text is
|
||||
// produced at the API boundary so no user content can leak into an error field.
|
||||
const (
|
||||
reasonUnsupportedLanguage = "unsupported_language"
|
||||
reasonTooLong = "too_long"
|
||||
reasonEmptyText = "empty_text"
|
||||
reasonContentChanged = "content_changed"
|
||||
reasonAttemptsExhausted = "attempts_exhausted"
|
||||
)
|
||||
|
||||
const (
|
||||
maxChapterRunes = 100000
|
||||
maxTitleRunes = 120
|
||||
maxBooksPerList = 200
|
||||
// A 100000 code point paste stays well inside this even with JSON escaping; the limit
|
||||
// exists only so an oversized body is rejected before it is decoded.
|
||||
maxPasteBodyBytes = 4 << 20
|
||||
maxJSONBodyBytes = 16 * 1024
|
||||
)
|
||||
|
||||
var requestIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,64}$`)
|
||||
|
||||
func reasonMessage(reason string) string {
|
||||
switch reason {
|
||||
case reasonUnsupportedLanguage:
|
||||
return "当前版本只支持英语内容"
|
||||
case reasonTooLong:
|
||||
return "内容超过单章上限(100000 个字符)"
|
||||
case reasonEmptyText:
|
||||
return "章节内容为空"
|
||||
case reasonContentChanged:
|
||||
return "内容在处理前发生变化,请重新提交"
|
||||
case reasonAttemptsExhausted:
|
||||
return "处理多次失败,请重试或重新提交"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
type Book struct {
|
||||
ID int64 `gorm:"primaryKey"`
|
||||
OwnerID int
|
||||
Title string
|
||||
Language string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (Book) TableName() string { return "lexgo_books" }
|
||||
|
||||
type Chapter struct {
|
||||
ID int64 `gorm:"primaryKey"`
|
||||
BookID int64
|
||||
OwnerID int
|
||||
Ordinal int
|
||||
Title string
|
||||
OriginalText string
|
||||
CharCount int
|
||||
ContentSHA256 string
|
||||
Status string
|
||||
ErrorReason string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (Chapter) TableName() string { return "lexgo_chapters" }
|
||||
|
||||
type IngestJob struct {
|
||||
ID int64 `gorm:"primaryKey"`
|
||||
OwnerID int
|
||||
BookID int64
|
||||
ChapterID int64
|
||||
RequestKey string
|
||||
ContentSHA256 string
|
||||
Status string
|
||||
Attempts int
|
||||
ErrorReason string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
FinishedAt *time.Time
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type BookRef struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Language string `json:"language"`
|
||||
}
|
||||
|
||||
func bookRef(book Book) BookRef { return BookRef{book.ID, book.Title, book.Language} }
|
||||
|
||||
type ChapterSummary struct {
|
||||
ID int64 `json:"id"`
|
||||
BookID int64 `json:"bookId"`
|
||||
Ordinal int `json:"ordinal"`
|
||||
Title string `json:"title"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type ChapterView struct {
|
||||
ChapterSummary
|
||||
ContentSHA256 string `json:"contentSha256"`
|
||||
// OriginalText is returned only for a ready chapter, so unprocessed content cannot be
|
||||
// rendered as readable text by the client.
|
||||
OriginalText string `json:"originalText,omitempty"`
|
||||
}
|
||||
|
||||
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,
|
||||
Status: c.Status, CharCount: c.CharCount, JobID: jobID, ErrorReason: c.ErrorReason,
|
||||
ErrorMessage: reasonMessage(c.ErrorReason), CreatedAt: c.CreatedAt, UpdatedAt: c.UpdatedAt}
|
||||
}
|
||||
|
||||
func chapterView(c Chapter, jobID *int64) ChapterView {
|
||||
view := ChapterView{ChapterSummary: chapterSummaryWithJob(c, jobID), ContentSHA256: c.ContentSHA256}
|
||||
if c.Status == statusReady {
|
||||
view.OriginalText = c.OriginalText
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
type JobView struct {
|
||||
ID int64 `json:"id"`
|
||||
BookID int64 `json:"bookId"`
|
||||
ChapterID int64 `json:"chapterId"`
|
||||
Status string `json:"status"`
|
||||
Attempts int `json:"attempts"`
|
||||
ErrorReason string `json:"errorReason"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func jobView(j IngestJob) JobView {
|
||||
return JobView{j.ID, j.BookID, j.ChapterID, j.Status, j.Attempts, j.ErrorReason, reasonMessage(j.ErrorReason), j.CreatedAt, j.UpdatedAt}
|
||||
}
|
||||
|
||||
type Navigation struct {
|
||||
PreviousChapterID *int64 `json:"previousChapterId"`
|
||||
NextChapterID *int64 `json:"nextChapterId"`
|
||||
}
|
||||
|
||||
// ReaderResponse carries everything the reader needs for one chapter of the caller's own book.
|
||||
type ReaderResponse struct {
|
||||
Book BookRef `json:"book"`
|
||||
Chapter ChapterView `json:"chapter"`
|
||||
Navigation Navigation `json:"navigation"`
|
||||
}
|
||||
|
||||
type PasteResult struct {
|
||||
Book *BookRef `json:"book,omitempty"`
|
||||
Chapter ChapterSummary `json:"chapter"`
|
||||
Job JobView `json:"job"`
|
||||
Duplicate bool `json:"duplicate"`
|
||||
}
|
||||
|
||||
type PasteBookInput struct {
|
||||
RequestID string `json:"requestId"`
|
||||
Title string `json:"title"`
|
||||
Text string `json:"text"`
|
||||
Language string `json:"language"`
|
||||
}
|
||||
|
||||
type PasteChapterInput struct {
|
||||
RequestID string `json:"requestId"`
|
||||
Title string `json:"title"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
func stamp(now time.Time) time.Time { return now.UTC().Truncate(time.Millisecond) }
|
||||
|
||||
func contentSHA(text string) string {
|
||||
v := sha256.Sum256([]byte(text))
|
||||
return hex.EncodeToString(v[:])
|
||||
}
|
||||
|
||||
func requestKey(requestID string) (string, error) {
|
||||
if !requestIDPattern.MatchString(requestID) {
|
||||
return "", failure(400, "请求编号须为 8~64 位字母、数字、下划线或连字符")
|
||||
}
|
||||
return contentSHA(requestID), nil
|
||||
}
|
||||
|
||||
// validatePaste applies the fixed paste rules: a title within the cap, at least one
|
||||
// non-space character, and at most maxChapterRunes code points. The text itself is stored
|
||||
// exactly as received, so whitespace, punctuation and line breaks survive unchanged.
|
||||
func validatePaste(title, text string) (string, string, int, error) {
|
||||
name := strings.TrimSpace(title)
|
||||
if name == "" {
|
||||
return "", "", 0, failure(400, "请填写标题")
|
||||
}
|
||||
if utf8.RuneCountInString(name) > maxTitleRunes {
|
||||
return "", "", 0, failure(400, "标题最多 120 个字符")
|
||||
}
|
||||
if strings.TrimFunc(text, unicode.IsSpace) == "" {
|
||||
return "", "", 0, failure(400, "请输入正文内容")
|
||||
}
|
||||
count := utf8.RuneCountInString(text)
|
||||
if count > maxChapterRunes {
|
||||
return "", "", 0, failure(400, "正文超过单章上限(100000 个字符)")
|
||||
}
|
||||
return name, contentSHA(text), count, nil
|
||||
}
|
||||
|
||||
// PasteBook creates one book with its first chapter and the ingestion job. The paste and the
|
||||
// job are written in one transaction, so a rejected submit leaves no book behind.
|
||||
func PasteBook(db *gorm.DB, owner int, now time.Time, input PasteBookInput) (PasteResult, error) {
|
||||
title, sha, count, err := validatePaste(input.Title, input.Text)
|
||||
if err != nil {
|
||||
return PasteResult{}, err
|
||||
}
|
||||
if input.Language != "" && input.Language != "en" {
|
||||
return PasteResult{}, failure(400, "当前版本只支持英语内容")
|
||||
}
|
||||
key, err := requestKey(input.RequestID)
|
||||
if err != nil {
|
||||
return PasteResult{}, err
|
||||
}
|
||||
ts := stamp(now)
|
||||
var result PasteResult
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
existing, found, err := jobByRequest(tx, owner, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if found {
|
||||
reused, err := reusePaste(tx, existing, title, sha)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result = reused
|
||||
return nil
|
||||
}
|
||||
book := Book{OwnerID: owner, Title: title, Language: "en", CreatedAt: ts, UpdatedAt: ts}
|
||||
if err = tx.Create(&book).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
chapter := Chapter{BookID: book.ID, OwnerID: owner, Ordinal: 1, Title: title,
|
||||
OriginalText: input.Text, CharCount: count, ContentSHA256: sha,
|
||||
Status: statusPending, CreatedAt: ts, UpdatedAt: ts}
|
||||
if err = tx.Create(&chapter).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
job := IngestJob{OwnerID: owner, BookID: book.ID, ChapterID: chapter.ID, RequestKey: key,
|
||||
ContentSHA256: sha, Status: statusPending, CreatedAt: ts, UpdatedAt: ts}
|
||||
if err = tx.Create(&job).Error; err != nil {
|
||||
return pasteInsertError(err)
|
||||
}
|
||||
ref := bookRef(book)
|
||||
result = PasteResult{Book: &ref, Chapter: chapterSummaryWithJob(chapter, &job.ID), Job: jobView(job)}
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, errRequestReuse) {
|
||||
return reusePasteByRequest(db, owner, key, title, sha)
|
||||
}
|
||||
if err != nil {
|
||||
return PasteResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// PasteChapter appends one chapter to a book the caller already owns. The book row is locked
|
||||
// so two appends cannot claim the same ordinal.
|
||||
func PasteChapter(db *gorm.DB, owner int, bookID int64, now time.Time, input PasteChapterInput) (PasteResult, error) {
|
||||
title, sha, count, err := validatePaste(input.Title, input.Text)
|
||||
if err != nil {
|
||||
return PasteResult{}, err
|
||||
}
|
||||
key, err := requestKey(input.RequestID)
|
||||
if err != nil {
|
||||
return PasteResult{}, err
|
||||
}
|
||||
ts := stamp(now)
|
||||
var result PasteResult
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
var book Book
|
||||
if err = lockOwnedBook(tx, owner, bookID, &book); err != nil {
|
||||
return err
|
||||
}
|
||||
existing, found, err := jobByRequest(tx, owner, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if found {
|
||||
if existing.BookID != book.ID {
|
||||
return failure(409, "该请求编号已用于其他内容")
|
||||
}
|
||||
reused, err := reusePaste(tx, existing, title, sha)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result = reused
|
||||
return nil
|
||||
}
|
||||
var last int
|
||||
row := tx.Model(&Chapter{}).Where("book_id = ?", book.ID).Select("COALESCE(MAX(ordinal),0)").Row()
|
||||
if err = row.Scan(&last); err != nil {
|
||||
return err
|
||||
}
|
||||
chapter := Chapter{BookID: book.ID, OwnerID: owner, Ordinal: last + 1, Title: title,
|
||||
OriginalText: input.Text, CharCount: count, ContentSHA256: sha,
|
||||
Status: statusPending, CreatedAt: ts, UpdatedAt: ts}
|
||||
if err = tx.Create(&chapter).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
job := IngestJob{OwnerID: owner, BookID: book.ID, ChapterID: chapter.ID, RequestKey: key,
|
||||
ContentSHA256: sha, Status: statusPending, CreatedAt: ts, UpdatedAt: ts}
|
||||
if err = tx.Create(&job).Error; err != nil {
|
||||
return pasteInsertError(err)
|
||||
}
|
||||
if err = tx.Model(&Book{}).Where("id = ?", book.ID).Update("updated_at", ts).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result = PasteResult{Chapter: chapterSummaryWithJob(chapter, &job.ID), Job: jobView(job)}
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, errRequestReuse) {
|
||||
return reusePasteByRequest(db, owner, key, title, sha)
|
||||
}
|
||||
if err != nil {
|
||||
return PasteResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var errRequestReuse = errors.New("ingestion request already accepted")
|
||||
|
||||
// pasteInsertError turns a unique-key conflict on the job insert into a request reuse. The
|
||||
// transaction must be abandoned: a concurrent submit that already committed is invisible to
|
||||
// this transaction's snapshot, and its rows are re-read with locking reads below.
|
||||
func pasteInsertError(err error) error {
|
||||
var dup *driver.MySQLError
|
||||
if errors.As(err, &dup) && dup.Number == 1062 {
|
||||
return errRequestReuse
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func reusePasteByRequest(db *gorm.DB, owner int, key, title, sha string) (PasteResult, error) {
|
||||
var result PasteResult
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
job, found, err := jobByRequestLatest(tx, owner, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
// The conflict came from another unique key, not from a repeated request id.
|
||||
return failure(409, "提交冲突,请重试")
|
||||
}
|
||||
result, err = reusePaste(tx, job, title, sha)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return PasteResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func jobByRequest(tx *gorm.DB, owner int, key string) (IngestJob, bool, error) {
|
||||
return jobQuery(tx, owner, key, false)
|
||||
}
|
||||
|
||||
// jobByRequestLatest uses a locking read, which sees the latest committed row instead of this
|
||||
// transaction's older snapshot. It is required after a duplicate-key conflict: only the
|
||||
// competing transaction's commit can cause that conflict, and its rows are newer than the
|
||||
// snapshot this request already took.
|
||||
func jobByRequestLatest(tx *gorm.DB, owner int, key string) (IngestJob, bool, error) {
|
||||
return jobQuery(tx, owner, key, true)
|
||||
}
|
||||
|
||||
func jobQuery(tx *gorm.DB, owner int, key string, latest bool) (IngestJob, bool, error) {
|
||||
var job IngestJob
|
||||
query := tx
|
||||
if latest {
|
||||
query = tx.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
}
|
||||
err := query.Where("owner_id = ? AND request_key = ?", owner, key).First(&job).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return job, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return job, false, err
|
||||
}
|
||||
return job, true, nil
|
||||
}
|
||||
|
||||
// reusePaste answers a repeated submit with the first result instead of creating a second
|
||||
// chapter. A reused request id with different content or title is a conflict, not a retry.
|
||||
// Its reads are locking reads so the same answer works right after a duplicate-key conflict.
|
||||
func reusePaste(tx *gorm.DB, job IngestJob, title, sha string) (PasteResult, 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 {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return PasteResult{}, failure(409, "该请求已提交过,请刷新后查看结果")
|
||||
}
|
||||
return PasteResult{}, err
|
||||
}
|
||||
if chapter.ContentSHA256 != sha || chapter.Title != title {
|
||||
return PasteResult{}, failure(409, "该请求编号已用于其他内容")
|
||||
}
|
||||
var book Book
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id = ? AND owner_id = ?", job.BookID, job.OwnerID).First(&book).Error; err != nil {
|
||||
return PasteResult{}, err
|
||||
}
|
||||
ref := bookRef(book)
|
||||
result := PasteResult{Chapter: chapterSummaryWithJob(chapter, &job.ID), Job: jobView(job), Duplicate: true}
|
||||
if chapter.Ordinal == 1 {
|
||||
result.Book = &ref
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func lockOwnedBook(tx *gorm.DB, owner int, bookID int64, book *Book) error {
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id = ? AND owner_id = ?", bookID, owner).First(book).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// Another user's book and a missing book are indistinguishable to the caller.
|
||||
return failure(404, "书籍不存在")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func ListBooks(db *gorm.DB, owner int) ([]BookSummary, error) {
|
||||
var books []Book
|
||||
if err := db.Where("owner_id = ?", owner).Order("updated_at DESC, id DESC").Limit(maxBooksPerList).Find(&books).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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})
|
||||
ids = append(ids, b.ID)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return items, nil
|
||||
}
|
||||
type row struct {
|
||||
BookID int64
|
||||
Status string
|
||||
Total int
|
||||
}
|
||||
var rows []row
|
||||
if err := db.Model(&Chapter{}).Select("book_id, status, COUNT(*) AS total").
|
||||
Where("owner_id = ? AND book_id IN ?", owner, ids).Group("book_id, status").Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
index := make(map[int64]int, len(items))
|
||||
for i, item := range items {
|
||||
index[item.ID] = i
|
||||
}
|
||||
for _, r := range rows {
|
||||
i, ok := index[r.BookID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
items[i].ChapterCount += r.Total
|
||||
switch r.Status {
|
||||
case statusReady:
|
||||
items[i].ReadyCount += r.Total
|
||||
case statusProcessing:
|
||||
items[i].ProcessingCount += r.Total
|
||||
case statusFailed:
|
||||
items[i].FailedCount += r.Total
|
||||
default:
|
||||
items[i].PendingCount += r.Total
|
||||
}
|
||||
}
|
||||
return items, 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 {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return BookRef{}, nil, failure(404, "书籍不存在")
|
||||
}
|
||||
return BookRef{}, nil, err
|
||||
}
|
||||
var chapters []Chapter
|
||||
if err := db.Where("book_id = ? AND owner_id = ?", book.ID, owner).Order("ordinal ASC").Find(&chapters).Error; err != nil {
|
||||
return BookRef{}, nil, err
|
||||
}
|
||||
items := make([]ChapterSummary, 0, len(chapters))
|
||||
ids := make([]int64, 0, len(chapters))
|
||||
for _, c := range chapters {
|
||||
ids = append(ids, c.ID)
|
||||
}
|
||||
jobs, err := jobIDsByChapter(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))
|
||||
}
|
||||
return bookRef(book), items, nil
|
||||
}
|
||||
|
||||
// jobIDsByChapter maps chapters to their ingestion job so a client can retry a failed chapter
|
||||
// without having kept the original submit response.
|
||||
func jobIDsByChapter(db *gorm.DB, owner int, chapterIDs []int64) (map[int64]int64, error) {
|
||||
ids := make(map[int64]int64, len(chapterIDs))
|
||||
if len(chapterIDs) == 0 {
|
||||
return ids, nil
|
||||
}
|
||||
var jobs []IngestJob
|
||||
if err := db.Select("id", "chapter_id").Where("owner_id = ? AND chapter_id IN ?", owner, chapterIDs).
|
||||
Order("id ASC").Find(&jobs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Ascending order keeps the newest id if a chapter somehow has more than one job.
|
||||
for _, j := range jobs {
|
||||
ids[j.ChapterID] = j.ID
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// ChapterDetail resolves a chapter strictly inside the caller's own books and returns the
|
||||
// original text only once the chapter is ready.
|
||||
func ChapterDetail(db *gorm.DB, owner int, chapterID int64) (ReaderResponse, error) {
|
||||
var chapter Chapter
|
||||
if err := db.Where("id = ? AND owner_id = ?", chapterID, owner).First(&chapter).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ReaderResponse{}, failure(404, "章节不存在")
|
||||
}
|
||||
return ReaderResponse{}, err
|
||||
}
|
||||
var book Book
|
||||
if err := db.Where("id = ? AND owner_id = ?", chapter.BookID, owner).First(&book).Error; err != nil {
|
||||
return ReaderResponse{}, err
|
||||
}
|
||||
navigation := Navigation{}
|
||||
var previous, next Chapter
|
||||
if err := db.Where("book_id = ? AND owner_id = ? AND ordinal < ?", book.ID, owner, chapter.Ordinal).
|
||||
Order("ordinal DESC").First(&previous).Error; err == nil {
|
||||
navigation.PreviousChapterID = &previous.ID
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ReaderResponse{}, err
|
||||
}
|
||||
if err := db.Where("book_id = ? AND owner_id = ? AND ordinal > ?", book.ID, owner, chapter.Ordinal).
|
||||
Order("ordinal ASC").First(&next).Error; err == nil {
|
||||
navigation.NextChapterID = &next.ID
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ReaderResponse{}, err
|
||||
}
|
||||
jobs, err := jobIDsByChapter(db, owner, []int64{chapter.ID})
|
||||
if err != nil {
|
||||
return ReaderResponse{}, err
|
||||
}
|
||||
var jobID *int64
|
||||
if id, ok := jobs[chapter.ID]; ok {
|
||||
jobID = &id
|
||||
}
|
||||
return ReaderResponse{Book: bookRef(book), Chapter: chapterView(chapter, jobID), Navigation: navigation}, nil
|
||||
}
|
||||
|
||||
func JobDetail(db *gorm.DB, owner int, jobID int64) (JobView, error) {
|
||||
var job IngestJob
|
||||
if err := db.Where("id = ? AND owner_id = ?", jobID, owner).First(&job).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return JobView{}, failure(404, "任务不存在")
|
||||
}
|
||||
return JobView{}, err
|
||||
}
|
||||
return jobView(job), nil
|
||||
}
|
||||
|
||||
// RetryIngestJob requeues a failed job on the same chapter, so a retry can never create a
|
||||
// second chapter for one paste. An explicit retry also restarts the attempt budget, because a
|
||||
// person asking again should not be blocked by the bound that stops automatic loops.
|
||||
func RetryIngestJob(db *gorm.DB, owner int, jobID int64, now time.Time) (JobView, ChapterSummary, error) {
|
||||
ts := stamp(now)
|
||||
var job IngestJob
|
||||
var chapter Chapter
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id = ? AND owner_id = ?", jobID, owner).First(&job).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return failure(404, "任务不存在")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if job.Status != statusFailed {
|
||||
return failure(409, "只有失败的任务可以重试")
|
||||
}
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id = ? AND owner_id = ?", job.ChapterID, owner).First(&chapter).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&Chapter{}).Where("id = ?", chapter.ID).
|
||||
Updates(map[string]any{"status": statusPending, "error_reason": "", "updated_at": ts}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
job.Status = statusPending
|
||||
job.ErrorReason = ""
|
||||
job.Attempts = 0
|
||||
job.FinishedAt = nil
|
||||
job.UpdatedAt = ts
|
||||
chapter.Status = statusPending
|
||||
chapter.ErrorReason = ""
|
||||
chapter.UpdatedAt = ts
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return JobView{}, ChapterSummary{}, err
|
||||
}
|
||||
return jobView(job), chapterSummaryWithJob(chapter, &job.ID), nil
|
||||
}
|
||||
@@ -0,0 +1,914 @@
|
||||
package lexgo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
admin "go-admin/app/admin/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Fictional English fixture with the characters the paste contract must preserve exactly:
|
||||
// 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 fixturePastedText = "Mira opened the workshop.\r\n\r\n\tThe sign read “A small step…” — café e\u0301 🙂\r\nTrailing spaces here: \n\n"
|
||||
|
||||
type libraryAccount struct {
|
||||
ID int
|
||||
Username string
|
||||
Token string
|
||||
}
|
||||
|
||||
type pasteResponse struct {
|
||||
Book *struct {
|
||||
ID int64
|
||||
Title string
|
||||
Language string
|
||||
}
|
||||
Chapter struct {
|
||||
ID int64
|
||||
BookID int64
|
||||
Ordinal int
|
||||
Title string
|
||||
Status string
|
||||
CharCount int
|
||||
ErrorReason string
|
||||
ErrorMessage string
|
||||
}
|
||||
Job struct {
|
||||
ID int64
|
||||
BookID int64
|
||||
ChapterID int64
|
||||
Status string
|
||||
Attempts int
|
||||
ErrorReason string
|
||||
ErrorMessage string
|
||||
}
|
||||
Duplicate bool
|
||||
}
|
||||
|
||||
type readerResponse struct {
|
||||
Book struct {
|
||||
ID int64
|
||||
Title string
|
||||
Language string
|
||||
}
|
||||
Chapter struct {
|
||||
ID int64
|
||||
BookID int64
|
||||
Ordinal int
|
||||
Title string
|
||||
Status string
|
||||
CharCount int
|
||||
ErrorReason string
|
||||
ErrorMessage string
|
||||
ContentSHA256 string
|
||||
OriginalText string
|
||||
JobID *int64
|
||||
}
|
||||
Navigation struct {
|
||||
PreviousChapterID *int64
|
||||
NextChapterID *int64
|
||||
}
|
||||
}
|
||||
|
||||
type bookDetailResponse struct {
|
||||
Book struct {
|
||||
ID int64
|
||||
Title string
|
||||
Language string
|
||||
}
|
||||
Chapters []struct {
|
||||
ID int64
|
||||
Ordinal int
|
||||
Title string
|
||||
Status string
|
||||
CharCount int
|
||||
JobID *int64
|
||||
ErrorMessage string
|
||||
}
|
||||
}
|
||||
|
||||
type bookListResponse struct {
|
||||
Items []struct {
|
||||
ID int64
|
||||
Title string
|
||||
ChapterCount int
|
||||
PendingCount int
|
||||
ProcessingCount int
|
||||
ReadyCount int
|
||||
FailedCount int
|
||||
}
|
||||
}
|
||||
|
||||
// callRaw keeps the API message, which is how a readable failure reason is asserted.
|
||||
func callRaw(t *testing.T, r *gin.Engine, method, path, token string, body any) (int, string, json.RawMessage) {
|
||||
t.Helper()
|
||||
b, _ := json.Marshal(body)
|
||||
q := httptest.NewRequest(method, path, bytes.NewReader(b))
|
||||
q.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
q.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, q)
|
||||
var e struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &e); err != nil {
|
||||
t.Fatalf("invalid JSON for %s %s (status %d)", method, path, w.Code)
|
||||
}
|
||||
return w.Code, e.Msg, e.Data
|
||||
}
|
||||
|
||||
func libraryFixture(t *testing.T) (*gorm.DB, *gin.Engine, libraryAccount) {
|
||||
t.Helper()
|
||||
db := testDB(t)
|
||||
owner := admin.SysUser{Username: randomName("admin"), Password: fixturePassword, RoleId: 1, Status: "2"}
|
||||
if err := db.Create(&owner).Error; err != nil {
|
||||
t.Fatal("fixture admin creation failed")
|
||||
}
|
||||
r := Router(db, time.Now)
|
||||
return db, r, libraryAccount{owner.UserId, owner.Username, loginToken(t, r, owner.Username, fixturePassword)}
|
||||
}
|
||||
|
||||
func newLearner(t *testing.T, r *gin.Engine, adminToken string) libraryAccount {
|
||||
t.Helper()
|
||||
name := randomName("lib")
|
||||
code, msg, data := callRaw(t, r, "POST", "/api/v1/accounts", adminToken, map[string]string{"username": name, "password": fixturePassword})
|
||||
if code != 201 {
|
||||
t.Fatalf("create learner status %d (%s)", code, msg)
|
||||
}
|
||||
var created struct {
|
||||
ID int
|
||||
Username string
|
||||
}
|
||||
json.Unmarshal(data, &created)
|
||||
return libraryAccount{created.ID, created.Username, loginToken(t, r, name, fixturePassword)}
|
||||
}
|
||||
|
||||
func pasteBook(t *testing.T, r *gin.Engine, token string, body any) (int, pasteResponse) {
|
||||
t.Helper()
|
||||
code, msg, data := callRaw(t, r, "POST", "/api/v1/books", token, body)
|
||||
var out pasteResponse
|
||||
if len(data) > 0 {
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
t.Fatalf("paste response: %v", err)
|
||||
}
|
||||
}
|
||||
if code >= 400 && msg == "" {
|
||||
t.Fatalf("paste failed with status %d and no message", code)
|
||||
}
|
||||
return code, out
|
||||
}
|
||||
|
||||
func pasteChapter(t *testing.T, r *gin.Engine, token string, bookID int64, body any) (int, pasteResponse) {
|
||||
t.Helper()
|
||||
code, msg, data := callRaw(t, r, "POST", fmt.Sprintf("/api/v1/books/%d/chapters", bookID), token, body)
|
||||
var out pasteResponse
|
||||
if len(data) > 0 {
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
t.Fatalf("paste chapter response: %v", err)
|
||||
}
|
||||
}
|
||||
if code >= 400 && msg == "" {
|
||||
t.Fatalf("paste chapter failed with status %d and no message", code)
|
||||
}
|
||||
return code, out
|
||||
}
|
||||
|
||||
func readChapter(t *testing.T, r *gin.Engine, token string, chapterID int64) (int, readerResponse) {
|
||||
t.Helper()
|
||||
code, _, data := callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d", chapterID), token, nil)
|
||||
var out readerResponse
|
||||
if len(data) > 0 {
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
t.Fatalf("reader response: %v", err)
|
||||
}
|
||||
}
|
||||
return code, out
|
||||
}
|
||||
|
||||
func bookDetail(t *testing.T, r *gin.Engine, token string, bookID int64) (int, bookDetailResponse) {
|
||||
t.Helper()
|
||||
code, _, data := callRaw(t, r, "GET", fmt.Sprintf("/api/v1/books/%d", bookID), token, nil)
|
||||
var out bookDetailResponse
|
||||
if len(data) > 0 {
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
t.Fatalf("book response: %v", err)
|
||||
}
|
||||
}
|
||||
return code, out
|
||||
}
|
||||
|
||||
func bookList(t *testing.T, r *gin.Engine, token string) (int, bookListResponse) {
|
||||
t.Helper()
|
||||
code, _, data := callRaw(t, r, "GET", "/api/v1/books", token, nil)
|
||||
var out bookListResponse
|
||||
if len(data) > 0 {
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
t.Fatalf("book list response: %v", err)
|
||||
}
|
||||
}
|
||||
return code, out
|
||||
}
|
||||
|
||||
func drainIngest(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if _, err := ProcessIngestJobs(t.Context(), db, time.Now, 50); err != nil {
|
||||
t.Fatalf("ingestion failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func chapterRow(t *testing.T, db *gorm.DB, id int64) Chapter {
|
||||
t.Helper()
|
||||
var c Chapter
|
||||
if err := db.Where("id = ?", id).First(&c).Error; err != nil {
|
||||
t.Fatalf("chapter row: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func jobRow(t *testing.T, db *gorm.DB, id int64) IngestJob {
|
||||
t.Helper()
|
||||
var j IngestJob
|
||||
if err := db.Where("id = ?", id).First(&j).Error; err != nil {
|
||||
t.Fatalf("job row: %v", err)
|
||||
}
|
||||
return j
|
||||
}
|
||||
|
||||
func TestMySQLPasteToReaderFullPath(t *testing.T) {
|
||||
db, r, owner := libraryFixture(t)
|
||||
learner := newLearner(t, r, owner.Token)
|
||||
// Empty the queue so the claim below takes this test's own job.
|
||||
drainIngest(t, db)
|
||||
|
||||
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
|
||||
"requestId": "fixture-request-full-path", "title": "The Workshop", "text": fixturePastedText, "language": "en"})
|
||||
if code != 201 {
|
||||
t.Fatalf("paste status %d, want 201", code)
|
||||
}
|
||||
if pasted.Book == nil || pasted.Book.ID == 0 || pasted.Book.Title != "The Workshop" || pasted.Book.Language != "en" {
|
||||
t.Fatalf("unexpected book %+v", pasted.Book)
|
||||
}
|
||||
if pasted.Chapter.Ordinal != 1 || pasted.Chapter.BookID != pasted.Book.ID || pasted.Chapter.Title != "The Workshop" {
|
||||
t.Fatalf("unexpected chapter %+v", pasted.Chapter)
|
||||
}
|
||||
if pasted.Chapter.Status != statusPending || pasted.Job.Status != statusPending || pasted.Duplicate {
|
||||
t.Fatalf("paste must queue a pending job, got chapter %q job %q", pasted.Chapter.Status, pasted.Job.Status)
|
||||
}
|
||||
if want := utf8.RuneCountInString(fixturePastedText); pasted.Chapter.CharCount != want {
|
||||
t.Fatalf("charCount %d, want %d", pasted.Chapter.CharCount, want)
|
||||
}
|
||||
|
||||
// A queued chapter has no readable text yet.
|
||||
code, pending := readChapter(t, r, learner.Token, pasted.Chapter.ID)
|
||||
if code != 200 || pending.Chapter.Status != statusPending || pending.Chapter.OriginalText != "" {
|
||||
t.Fatalf("pending chapter must not expose text: status %d, %+v", code, pending.Chapter)
|
||||
}
|
||||
_, queued := bookList(t, r, learner.Token)
|
||||
if len(queued.Items) != 1 || queued.Items[0].PendingCount != 1 || queued.Items[0].ProcessingCount != 0 || queued.Items[0].ReadyCount != 0 {
|
||||
t.Fatalf("queued book counts %+v", queued.Items)
|
||||
}
|
||||
|
||||
// Processing is a durable state: a claim survives a crash and is observable in between.
|
||||
job, claimed, err := ClaimNextIngestJob(db, time.Now())
|
||||
if err != nil || !claimed {
|
||||
t.Fatalf("claim failed (claimed=%v): %v", claimed, err)
|
||||
}
|
||||
if job.ID != pasted.Job.ID || job.Attempts != 1 {
|
||||
t.Fatalf("claimed job %+v, want id %d with 1 attempt", job, pasted.Job.ID)
|
||||
}
|
||||
code, processing := readChapter(t, r, learner.Token, pasted.Chapter.ID)
|
||||
if code != 200 || processing.Chapter.Status != statusProcessing || processing.Chapter.OriginalText != "" {
|
||||
t.Fatalf("processing chapter must not expose text: status %d, %+v", code, processing.Chapter)
|
||||
}
|
||||
if err = FinishIngestJob(t.Context(), db, job, time.Now()); err != nil {
|
||||
t.Fatalf("finish failed: %v", err)
|
||||
}
|
||||
|
||||
code, ready := readChapter(t, r, learner.Token, pasted.Chapter.ID)
|
||||
if code != 200 || ready.Chapter.Status != statusReady {
|
||||
t.Fatalf("ready chapter status %d %q", code, ready.Chapter.Status)
|
||||
}
|
||||
if ready.Chapter.OriginalText != fixturePastedText {
|
||||
t.Fatalf("original text changed:\n got %q\nwant %q", ready.Chapter.OriginalText, fixturePastedText)
|
||||
}
|
||||
if ready.Chapter.ContentSHA256 != contentSHA(fixturePastedText) {
|
||||
t.Fatal("content hash mismatch")
|
||||
}
|
||||
if ready.Chapter.CharCount != utf8.RuneCountInString(fixturePastedText) {
|
||||
t.Fatalf("charCount %d after processing", ready.Chapter.CharCount)
|
||||
}
|
||||
if ready.Navigation.PreviousChapterID != nil || ready.Navigation.NextChapterID != nil {
|
||||
t.Fatal("single chapter must not navigate")
|
||||
}
|
||||
if ready.Book.ID != pasted.Book.ID || ready.Book.Title != "The Workshop" {
|
||||
t.Fatalf("unexpected reader book %+v", ready.Book)
|
||||
}
|
||||
|
||||
code, _, data := callRaw(t, r, "GET", fmt.Sprintf("/api/v1/jobs/%d", pasted.Job.ID), learner.Token, nil)
|
||||
var jobView struct {
|
||||
Job struct {
|
||||
Status string
|
||||
Attempts int
|
||||
ChapterID int64
|
||||
}
|
||||
}
|
||||
json.Unmarshal(data, &jobView)
|
||||
if code != 200 || jobView.Job.Status != statusReady || jobView.Job.Attempts != 1 || jobView.Job.ChapterID != pasted.Chapter.ID {
|
||||
t.Fatalf("job view %+v (status %d)", jobView.Job, code)
|
||||
}
|
||||
|
||||
// Appending keeps the fixed rule: one paste, one more chapter.
|
||||
code, appended := pasteChapter(t, r, learner.Token, pasted.Book.ID, map[string]string{
|
||||
"requestId": "fixture-request-append", "title": "Second Chapter", "text": "A single plain paragraph.\n"})
|
||||
if code != 201 || appended.Chapter.Ordinal != 2 || appended.Book != nil {
|
||||
t.Fatalf("append status %d chapter %+v book %+v", code, appended.Chapter, appended.Book)
|
||||
}
|
||||
drainIngest(t, db)
|
||||
code, second := readChapter(t, r, learner.Token, appended.Chapter.ID)
|
||||
if code != 200 || second.Chapter.Status != statusReady || second.Chapter.OriginalText != "A single plain paragraph.\n" {
|
||||
t.Fatalf("appended chapter %+v", second.Chapter)
|
||||
}
|
||||
if second.Navigation.PreviousChapterID == nil || *second.Navigation.PreviousChapterID != pasted.Chapter.ID || second.Navigation.NextChapterID != nil {
|
||||
t.Fatalf("appended navigation %+v", second.Navigation)
|
||||
}
|
||||
code, first := readChapter(t, r, learner.Token, pasted.Chapter.ID)
|
||||
if code != 200 || first.Navigation.NextChapterID == nil || *first.Navigation.NextChapterID != appended.Chapter.ID {
|
||||
t.Fatalf("first chapter navigation %+v", first.Navigation)
|
||||
}
|
||||
|
||||
code, detail := bookDetail(t, r, learner.Token, pasted.Book.ID)
|
||||
if code != 200 || len(detail.Chapters) != 2 || detail.Chapters[0].Status != statusReady || detail.Chapters[1].Ordinal != 2 {
|
||||
t.Fatalf("book detail %+v", detail)
|
||||
}
|
||||
// The chapter list carries the job id, which is what a client needs to retry a failure.
|
||||
if detail.Chapters[0].JobID == nil || *detail.Chapters[0].JobID != pasted.Job.ID {
|
||||
t.Fatalf("book detail chapter job id %+v, want %d", detail.Chapters[0].JobID, pasted.Job.ID)
|
||||
}
|
||||
if ready.Chapter.JobID == nil || *ready.Chapter.JobID != pasted.Job.ID {
|
||||
t.Fatalf("reader chapter job id %+v, want %d", ready.Chapter.JobID, pasted.Job.ID)
|
||||
}
|
||||
code, list := bookList(t, r, learner.Token)
|
||||
if code != 200 || len(list.Items) != 1 || list.Items[0].ChapterCount != 2 || list.Items[0].ReadyCount != 2 || list.Items[0].FailedCount != 0 {
|
||||
t.Fatalf("book list %+v", list)
|
||||
}
|
||||
if list.Items[0].PendingCount != 0 || list.Items[0].ProcessingCount != 0 {
|
||||
t.Fatalf("published book must have no queued chapter: %+v", list.Items[0])
|
||||
}
|
||||
if list.Items[0].ID != pasted.Book.ID {
|
||||
t.Fatal("book list must only contain the caller's own book")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLIngestFailureReasonsAndRetry(t *testing.T) {
|
||||
db, r, owner := libraryFixture(t)
|
||||
learner := newLearner(t, r, owner.Token)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
reason string
|
||||
mutate func(book *Book, chapter *Chapter, job *IngestJob)
|
||||
}{
|
||||
{"valid", "", func(*Book, *Chapter, *IngestJob) {}},
|
||||
{"unsupported_language", reasonUnsupportedLanguage, func(book *Book, _ *Chapter, _ *IngestJob) {
|
||||
book.Language = "de"
|
||||
}},
|
||||
{"empty_text", reasonEmptyText, func(_ *Book, chapter *Chapter, job *IngestJob) {
|
||||
chapter.OriginalText = " \r\n\t "
|
||||
chapter.ContentSHA256 = contentSHA(chapter.OriginalText)
|
||||
job.ContentSHA256 = chapter.ContentSHA256
|
||||
}},
|
||||
{"too_long", reasonTooLong, func(_ *Book, chapter *Chapter, job *IngestJob) {
|
||||
chapter.OriginalText = strings.Repeat("a", maxChapterRunes+1)
|
||||
chapter.ContentSHA256 = contentSHA(chapter.OriginalText)
|
||||
job.ContentSHA256 = chapter.ContentSHA256
|
||||
}},
|
||||
{"content_changed", reasonContentChanged, func(_ *Book, chapter *Chapter, _ *IngestJob) {
|
||||
chapter.OriginalText = "Mira opened"
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
|
||||
"requestId": randomName("case"), "title": "Case " + tc.name, "text": fixturePastedText})
|
||||
if code != 201 {
|
||||
t.Fatalf("paste status %d", code)
|
||||
}
|
||||
// These are the states another writing path could leave behind; the worker must
|
||||
// re-validate persisted content instead of trusting the paste API.
|
||||
book := Book{ID: pasted.Book.ID, OwnerID: learner.ID, Language: "en"}
|
||||
chapter := chapterRow(t, db, pasted.Chapter.ID)
|
||||
job := jobRow(t, db, pasted.Job.ID)
|
||||
tc.mutate(&book, &chapter, &job)
|
||||
if err := db.Model(&Book{}).Where("id = ?", book.ID).Update("language", book.Language).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&Chapter{}).Where("id = ?", chapter.ID).
|
||||
Updates(map[string]any{"original_text": chapter.OriginalText, "content_sha256": chapter.ContentSHA256}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&IngestJob{}).Where("id = ?", job.ID).
|
||||
Update("content_sha256", job.ContentSHA256).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
drainIngest(t, db)
|
||||
code, read := readChapter(t, r, learner.Token, pasted.Chapter.ID)
|
||||
if code != 200 {
|
||||
t.Fatalf("read status %d", code)
|
||||
}
|
||||
if tc.reason == "" {
|
||||
if read.Chapter.Status != statusReady || read.Chapter.OriginalText != fixturePastedText {
|
||||
t.Fatalf("valid content must reach ready, got %+v", read.Chapter)
|
||||
}
|
||||
return
|
||||
}
|
||||
if read.Chapter.Status != statusFailed || read.Chapter.ErrorReason != tc.reason {
|
||||
t.Fatalf("chapter %q with reason %q, want failed/%s", read.Chapter.Status, read.Chapter.ErrorReason, tc.reason)
|
||||
}
|
||||
// The readable reason is produced by the API and never stores user content.
|
||||
if read.Chapter.ErrorMessage == "" || strings.ContainsAny(read.Chapter.ErrorMessage, "\r\n") {
|
||||
t.Fatalf("unreadable failure message %q", read.Chapter.ErrorMessage)
|
||||
}
|
||||
if read.Chapter.OriginalText != "" {
|
||||
t.Fatal("failed chapter must not expose text")
|
||||
}
|
||||
var detail bookDetailResponse
|
||||
_, detail = bookDetail(t, r, learner.Token, pasted.Book.ID)
|
||||
if detail.Chapters[0].Status != statusFailed || detail.Chapters[0].ErrorMessage == "" {
|
||||
t.Fatalf("book detail must show the failure: %+v", detail.Chapters[0])
|
||||
}
|
||||
// Retry through the job id the chapter list exposes, which is the client's only path.
|
||||
if detail.Chapters[0].JobID == nil || *detail.Chapters[0].JobID != pasted.Job.ID {
|
||||
t.Fatalf("failed chapter must expose its job id: %+v", detail.Chapters[0])
|
||||
}
|
||||
code, _, _ = callRaw(t, r, "POST", fmt.Sprintf("/api/v1/jobs/%d/retry", *detail.Chapters[0].JobID), learner.Token, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("retry status %d, want 200", code)
|
||||
}
|
||||
// Retrying reuses the same chapter: no second chapter for one paste.
|
||||
var count int64
|
||||
db.Model(&Chapter{}).Where("book_id = ?", pasted.Book.ID).Count(&count)
|
||||
if count != 1 {
|
||||
t.Fatalf("retry created %d chapters, want 1", count)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLRetryAfterContentRestoredPublishesSameChapter(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": "fixture-retry", "title": "Retry Book", "text": fixturePastedText})
|
||||
if code != 201 {
|
||||
t.Fatalf("paste status %d", code)
|
||||
}
|
||||
// Simulate a chapter whose stored text was replaced before processing.
|
||||
if err := db.Model(&Chapter{}).Where("id = ?", pasted.Chapter.ID).
|
||||
Update("original_text", "Mira opened").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
drainIngest(t, db)
|
||||
code, failed := readChapter(t, r, learner.Token, pasted.Chapter.ID)
|
||||
if code != 200 || failed.Chapter.Status != statusFailed || failed.Chapter.ErrorReason != reasonContentChanged {
|
||||
t.Fatalf("expected content_changed failure, got %+v", failed.Chapter)
|
||||
}
|
||||
code, msg, _ := callRaw(t, r, "POST", fmt.Sprintf("/api/v1/jobs/%d/retry", pasted.Job.ID), learner.Token, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("retry status %d (%s)", code, msg)
|
||||
}
|
||||
// A pending retry exposes no text and does not create a new chapter.
|
||||
code, pending := readChapter(t, r, learner.Token, pasted.Chapter.ID)
|
||||
if code != 200 || pending.Chapter.Status != statusPending || pending.Chapter.OriginalText != "" {
|
||||
t.Fatalf("retry must return the chapter to pending, got %+v", pending.Chapter)
|
||||
}
|
||||
var count int64
|
||||
db.Model(&Chapter{}).Where("book_id = ?", pasted.Book.ID).Count(&count)
|
||||
if count != 1 {
|
||||
t.Fatalf("retry created %d chapters, want 1", count)
|
||||
}
|
||||
// Simulate the content being restored to what was submitted, then retry to completion.
|
||||
if err := db.Model(&Chapter{}).Where("id = ?", pasted.Chapter.ID).
|
||||
Update("original_text", fixturePastedText).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
drainIngest(t, db)
|
||||
code, ready := readChapter(t, r, learner.Token, pasted.Chapter.ID)
|
||||
if code != 200 || ready.Chapter.Status != statusReady || ready.Chapter.OriginalText != fixturePastedText {
|
||||
t.Fatalf("retry did not publish the same chapter: %+v", ready.Chapter)
|
||||
}
|
||||
job := jobRow(t, db, pasted.Job.ID)
|
||||
// A manual retry restarts the attempt budget, so this processing is attempt 1 again.
|
||||
if job.Attempts != 1 {
|
||||
t.Fatalf("attempts %d after a manual retry, want 1", job.Attempts)
|
||||
}
|
||||
code, msg, _ = callRaw(t, r, "POST", fmt.Sprintf("/api/v1/jobs/%d/retry", pasted.Job.ID), learner.Token, nil)
|
||||
if code != 409 || msg == "" {
|
||||
t.Fatalf("retry of a ready job status %d (%s), want 409 with a message", code, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLRepeatedPasteIsIdempotent(t *testing.T) {
|
||||
db, r, owner := libraryFixture(t)
|
||||
learner := newLearner(t, r, owner.Token)
|
||||
body := map[string]string{"requestId": "fixture-request-repeat", "title": "Repeat", "text": fixturePastedText}
|
||||
|
||||
code, first := pasteBook(t, r, learner.Token, body)
|
||||
if code != 201 || first.Duplicate {
|
||||
t.Fatalf("first paste status %d duplicate %v", code, first.Duplicate)
|
||||
}
|
||||
code, second := pasteBook(t, r, learner.Token, body)
|
||||
if code != 200 || !second.Duplicate {
|
||||
t.Fatalf("repeat paste status %d duplicate %v, want 200 with duplicate", code, second.Duplicate)
|
||||
}
|
||||
if second.Chapter.ID != first.Chapter.ID || second.Job.ID != first.Job.ID || second.Book.ID != first.Book.ID {
|
||||
t.Fatalf("repeat paste returned different rows: %+v vs %+v", second, first)
|
||||
}
|
||||
var books, chapters int64
|
||||
db.Model(&Book{}).Where("owner_id = ?", learner.ID).Count(&books)
|
||||
db.Model(&Chapter{}).Where("owner_id = ?", learner.ID).Count(&chapters)
|
||||
if books != 1 || chapters != 1 {
|
||||
t.Fatalf("repeat paste created books=%d chapters=%d, want 1/1", books, chapters)
|
||||
}
|
||||
|
||||
// The same request id with different content is a conflict, not a silent reuse.
|
||||
for name, changed := range map[string]map[string]string{
|
||||
"different text": {"requestId": "fixture-request-repeat", "title": "Repeat", "text": "Mira opened the workshop."},
|
||||
"different title": {"requestId": "fixture-request-repeat", "title": "Another", "text": fixturePastedText},
|
||||
} {
|
||||
code, msg, _ := callRaw(t, r, "POST", "/api/v1/books", learner.Token, changed)
|
||||
if code != 409 || msg == "" {
|
||||
t.Fatalf("%s: status %d (%s), want 409", name, code, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Two concurrent submits of one request id create exactly one chapter.
|
||||
name := randomName("race")
|
||||
body = map[string]string{"requestId": name, "title": "Race", "text": "A small step; a small step"}
|
||||
var wg sync.WaitGroup
|
||||
type attempt struct {
|
||||
code int
|
||||
msg string
|
||||
}
|
||||
results := make(chan attempt, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
code, msg, _ := callRaw(t, r, "POST", "/api/v1/books", learner.Token, body)
|
||||
results <- attempt{code, msg}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
created, reused := 0, 0
|
||||
for result := range results {
|
||||
switch result.code {
|
||||
case 201:
|
||||
created++
|
||||
case 200:
|
||||
reused++
|
||||
default:
|
||||
t.Fatalf("concurrent paste status %d (%s)", result.code, result.msg)
|
||||
}
|
||||
}
|
||||
if created != 1 || reused != 1 {
|
||||
t.Fatalf("concurrent paste created=%d reused=%d", created, reused)
|
||||
}
|
||||
db.Model(&Chapter{}).Where("owner_id = ?", learner.ID).Count(&chapters)
|
||||
if chapters != 2 {
|
||||
t.Fatalf("concurrent paste left %d chapters, want 2", chapters)
|
||||
}
|
||||
|
||||
// A request id already used by another book cannot be replayed by appending, while a
|
||||
// fresh request id appends normally.
|
||||
var raceBook Book
|
||||
if err := db.Where("owner_id = ? AND title = ?", learner.ID, "Race").First(&raceBook).Error; err != nil {
|
||||
t.Fatalf("race book: %v", err)
|
||||
}
|
||||
code, msg, _ := callRaw(t, r, "POST", fmt.Sprintf("/api/v1/books/%d/chapters", first.Book.ID), learner.Token,
|
||||
map[string]string{"requestId": name, "title": "Append", "text": "A second chapter."})
|
||||
if code != 409 || msg == "" {
|
||||
t.Fatalf("cross-book request id status %d (%s), want 409", code, msg)
|
||||
}
|
||||
code, appended := pasteChapter(t, r, learner.Token, first.Book.ID, map[string]string{
|
||||
"requestId": randomName("append"), "title": "Append", "text": "A second chapter."})
|
||||
if code != 201 || appended.Chapter.Ordinal != 2 {
|
||||
t.Fatalf("append status %d chapter %+v", code, appended.Chapter)
|
||||
}
|
||||
if raceBook.ID == first.Book.ID {
|
||||
t.Fatal("idempotency fixtures must use different books")
|
||||
}
|
||||
// Append owns the language of its book, so the field is not part of that contract and the
|
||||
// strict decoder rejects it. The learner client must therefore not send it (regression R1).
|
||||
code, msg, _ = callRaw(t, r, "POST", fmt.Sprintf("/api/v1/books/%d/chapters", first.Book.ID), learner.Token,
|
||||
map[string]string{"requestId": randomName("append"), "title": "Strict", "text": "Strict contract.", "language": "en"})
|
||||
if code != 400 || msg == "" {
|
||||
t.Fatalf("append with language status %d (%s), want 400", code, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLLibraryIsolationAndOwnership(t *testing.T) {
|
||||
db, r, owner := libraryFixture(t)
|
||||
a := newLearner(t, r, owner.Token)
|
||||
b := newLearner(t, r, owner.Token)
|
||||
|
||||
code, pasted := pasteBook(t, r, a.Token, map[string]string{
|
||||
"requestId": "fixture-request-isolation", "title": "Private Book", "text": "Only A may read this."})
|
||||
if code != 201 {
|
||||
t.Fatalf("paste status %d", code)
|
||||
}
|
||||
// Force a failed job so the retry path is checked for another account too.
|
||||
if err := db.Model(&IngestJob{}).Where("id = ?", pasted.Job.ID).Updates(map[string]any{"status": statusFailed, "error_reason": reasonContentChanged}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var book Book
|
||||
var chapter Chapter
|
||||
var job IngestJob
|
||||
db.Where("id = ?", pasted.Book.ID).First(&book)
|
||||
db.Where("id = ?", pasted.Chapter.ID).First(&chapter)
|
||||
db.Where("id = ?", pasted.Job.ID).First(&job)
|
||||
if book.OwnerID != a.ID || chapter.OwnerID != a.ID || job.OwnerID != a.ID {
|
||||
t.Fatal("stored rows must belong to the authenticated account")
|
||||
}
|
||||
if chapter.BookID != book.ID || job.ChapterID != chapter.ID {
|
||||
t.Fatal("job and chapter must stay linked to the book")
|
||||
}
|
||||
|
||||
for name, token := range map[string]string{"other learner": b.Token, "administrator": owner.Token} {
|
||||
for _, path := range []string{
|
||||
fmt.Sprintf("/api/v1/books/%d", book.ID),
|
||||
fmt.Sprintf("/api/v1/chapters/%d", chapter.ID),
|
||||
fmt.Sprintf("/api/v1/jobs/%d", job.ID),
|
||||
} {
|
||||
code, _, _ := callRaw(t, r, "GET", path, token, nil)
|
||||
if code != 404 {
|
||||
t.Fatalf("%s GET %s status %d, want 404", name, path, code)
|
||||
}
|
||||
}
|
||||
code, _, _ = callRaw(t, r, "POST", fmt.Sprintf("/api/v1/books/%d/chapters", book.ID), token,
|
||||
map[string]string{"requestId": randomName("intruder"), "title": "Intruder", "text": "Intruder text."})
|
||||
if code != 404 {
|
||||
t.Fatalf("%s append status %d, want 404", name, code)
|
||||
}
|
||||
code, _, _ = callRaw(t, r, "POST", fmt.Sprintf("/api/v1/jobs/%d/retry", job.ID), token, nil)
|
||||
if code != 404 {
|
||||
t.Fatalf("%s retry status %d, want 404", name, code)
|
||||
}
|
||||
_, list := bookList(t, r, token)
|
||||
if len(list.Items) != 0 {
|
||||
t.Fatalf("%s sees %d books", name, len(list.Items))
|
||||
}
|
||||
}
|
||||
|
||||
// The caller's identity comes from the session, never from the request body or query.
|
||||
for _, payload := range []map[string]any{
|
||||
{"requestId": randomName("owner"), "title": "Spoof", "text": "Spoofed owner.", "ownerId": b.ID},
|
||||
{"requestId": randomName("owner"), "title": "Spoof", "text": "Spoofed owner.", "userId": b.ID},
|
||||
} {
|
||||
code, msg, _ := callRaw(t, r, "POST", "/api/v1/books", a.Token, payload)
|
||||
if code != 400 || msg == "" {
|
||||
t.Fatalf("client-supplied owner status %d (%s), want 400", code, msg)
|
||||
}
|
||||
}
|
||||
code, msg, _ := callRaw(t, r, "GET", fmt.Sprintf("/api/v1/books?ownerId=%d", b.ID), a.Token, nil)
|
||||
if code != 400 || msg == "" {
|
||||
t.Fatalf("query owner override status %d (%s), want 400", code, msg)
|
||||
}
|
||||
var books int64
|
||||
db.Model(&Book{}).Where("owner_id = ?", a.ID).Count(&books)
|
||||
if books != 1 {
|
||||
t.Fatalf("rejected requests created %d books", books)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLIngestRecoveryWithoutRestart(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": "fixture-in-service-recovery", "title": "In-service recovery", "text": fixturePastedText})
|
||||
if code != 201 {
|
||||
t.Fatalf("paste status %d", code)
|
||||
}
|
||||
job, claimed, err := ClaimNextIngestJob(db, time.Now())
|
||||
if err != nil || !claimed || job.ID != pasted.Job.ID {
|
||||
t.Fatalf("claim failed (claimed=%v, job=%d): %v", claimed, job.ID, err)
|
||||
}
|
||||
|
||||
// The finishing transaction fails (context cancellation stands in for a timeout or a
|
||||
// database error). The claim is already committed, so the job stays processing.
|
||||
canceled, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
if err = FinishIngestJob(canceled, db, job, time.Now()); err == nil {
|
||||
t.Fatal("a canceled finishing transaction must report an error")
|
||||
}
|
||||
var stuck IngestJob
|
||||
if err = db.Where("id = ?", pasted.Job.ID).First(&stuck).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stuck.Status != statusProcessing {
|
||||
t.Fatalf("job status %q after a failed finish, want processing", stuck.Status)
|
||||
}
|
||||
// A manual retry cannot rescue it: only failed jobs are accepted.
|
||||
code, msg, _ := callRaw(t, r, "POST", fmt.Sprintf("/api/v1/jobs/%d/retry", pasted.Job.ID), learner.Token, nil)
|
||||
if code != 409 || msg == "" {
|
||||
t.Fatalf("retry of a processing job status %d (%s), want 409", code, msg)
|
||||
}
|
||||
|
||||
// A sweep that is too early must leave a healthy claim alone.
|
||||
claimedAt := stuck.UpdatedAt
|
||||
if _, err = RequeueStaleIngestJobs(db, claimedAt.Add(time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if jobRow(t, db, pasted.Job.ID).Status != statusProcessing {
|
||||
t.Fatal("a fresh claim must not be requeued")
|
||||
}
|
||||
// Once the claim is older than the stale window, the running service recovers it.
|
||||
if _, err = RequeueStaleIngestJobs(db, claimedAt.Add(ingestStaleAfter+time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recovered := jobRow(t, db, pasted.Job.ID)
|
||||
if recovered.Status != statusPending || recovered.Attempts != 1 || recovered.FinishedAt != nil {
|
||||
t.Fatalf("recovered job %+v", recovered)
|
||||
}
|
||||
if chapterRow(t, db, pasted.Chapter.ID).Status != statusPending {
|
||||
t.Fatal("recovered chapter must be pending")
|
||||
}
|
||||
// Recovery reuses the same rows: no second chapter, same task id.
|
||||
var chapters int64
|
||||
db.Model(&Chapter{}).Where("book_id = ?", pasted.Book.ID).Count(&chapters)
|
||||
if chapters != 1 {
|
||||
t.Fatalf("recovery left %d chapters, want 1", chapters)
|
||||
}
|
||||
drainIngest(t, db)
|
||||
code, ready := readChapter(t, r, learner.Token, pasted.Chapter.ID)
|
||||
if code != 200 || ready.Chapter.Status != statusReady || ready.Chapter.OriginalText != fixturePastedText {
|
||||
t.Fatalf("recovered chapter %+v", ready.Chapter)
|
||||
}
|
||||
if ready.Chapter.ID != pasted.Chapter.ID || ready.Chapter.JobID == nil || *ready.Chapter.JobID != pasted.Job.ID {
|
||||
t.Fatal("recovery must keep the original chapter and task ids")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLIngestAttemptsAreBoundedAndManualRetryRestarts(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": "fixture-attempt-budget", "title": "Attempt budget", "text": fixturePastedText})
|
||||
if code != 201 {
|
||||
t.Fatalf("paste status %d", code)
|
||||
}
|
||||
// Spend the whole budget without any worker running.
|
||||
if err := db.Model(&IngestJob{}).Where("id = ?", pasted.Job.ID).Update("attempts", maxIngestAttempts).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, claimed, err := ClaimNextIngestJob(db, time.Now()); err != nil || claimed {
|
||||
t.Fatalf("claim claimed=%v (%v), want no claim once the budget is used", claimed, err)
|
||||
}
|
||||
if _, err := RequeueStaleIngestJobs(db, time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exhausted := jobRow(t, db, pasted.Job.ID)
|
||||
if exhausted.Status != statusFailed || exhausted.ErrorReason != reasonAttemptsExhausted {
|
||||
t.Fatalf("exhausted job %+v", exhausted)
|
||||
}
|
||||
code, read := readChapter(t, r, learner.Token, pasted.Chapter.ID)
|
||||
if code != 200 || read.Chapter.Status != statusFailed || read.Chapter.ErrorReason != reasonAttemptsExhausted {
|
||||
t.Fatalf("exhausted chapter %+v", read.Chapter)
|
||||
}
|
||||
if read.Chapter.ErrorMessage == "" || read.Chapter.OriginalText != "" {
|
||||
t.Fatalf("exhausted chapter must fail readably without text: %+v", read.Chapter)
|
||||
}
|
||||
// The manual retry is still available and restarts the attempt budget.
|
||||
code, msg, _ := callRaw(t, r, "POST", fmt.Sprintf("/api/v1/jobs/%d/retry", pasted.Job.ID), learner.Token, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("manual retry status %d (%s), want 200", code, msg)
|
||||
}
|
||||
retried := jobRow(t, db, pasted.Job.ID)
|
||||
if retried.Status != statusPending || retried.Attempts != 0 || retried.ErrorReason != "" {
|
||||
t.Fatalf("retried job %+v", retried)
|
||||
}
|
||||
drainIngest(t, db)
|
||||
code, ready := readChapter(t, r, learner.Token, pasted.Chapter.ID)
|
||||
if code != 200 || ready.Chapter.Status != statusReady || ready.Chapter.ErrorReason != "" {
|
||||
t.Fatalf("chapter after manual retry %+v", ready.Chapter)
|
||||
}
|
||||
if ready.Chapter.ID != pasted.Chapter.ID {
|
||||
t.Fatal("manual retry must reuse the same chapter")
|
||||
}
|
||||
var chapters int64
|
||||
db.Model(&Chapter{}).Where("book_id = ?", pasted.Book.ID).Count(&chapters)
|
||||
if chapters != 1 {
|
||||
t.Fatalf("attempt recovery left %d chapters, want 1", chapters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLIngestRecoveryAfterRestart(t *testing.T) {
|
||||
db, r, owner := libraryFixture(t)
|
||||
learner := newLearner(t, r, owner.Token)
|
||||
// The test database is shared with other cases, so empty the queue first: the claim below
|
||||
// must take this test's own job, not a leftover one.
|
||||
drainIngest(t, db)
|
||||
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
|
||||
"requestId": "fixture-recovery", "title": "Recovery", "text": fixturePastedText})
|
||||
if code != 201 {
|
||||
t.Fatalf("paste status %d", code)
|
||||
}
|
||||
// A crash between claim and finish leaves rows in processing.
|
||||
job, claimed, err := ClaimNextIngestJob(db, time.Now())
|
||||
if err != nil || !claimed {
|
||||
t.Fatalf("claim failed (claimed=%v): %v", claimed, err)
|
||||
}
|
||||
if job.ID != pasted.Job.ID {
|
||||
t.Fatalf("claimed job %d, want this test's job %d", job.ID, pasted.Job.ID)
|
||||
}
|
||||
if chapterRow(t, db, pasted.Chapter.ID).Status != statusProcessing || jobRow(t, db, pasted.Job.ID).Status != statusProcessing {
|
||||
t.Fatal("claim must persist the processing state")
|
||||
}
|
||||
requeued, err := RecoverIngestJobs(db, time.Now())
|
||||
if err != nil || requeued < 1 {
|
||||
t.Fatalf("recovery requeued %d (%v), want at least this test's job", requeued, err)
|
||||
}
|
||||
pending := jobRow(t, db, pasted.Job.ID)
|
||||
if pending.Status != statusPending || pending.Attempts != 1 || pending.FinishedAt != nil {
|
||||
t.Fatalf("recovered job %+v", pending)
|
||||
}
|
||||
if chapterRow(t, db, pasted.Chapter.ID).Status != statusPending {
|
||||
t.Fatal("recovered chapter must be pending")
|
||||
}
|
||||
drainIngest(t, db)
|
||||
code, ready := readChapter(t, r, learner.Token, pasted.Chapter.ID)
|
||||
if code != 200 || ready.Chapter.Status != statusReady || ready.Chapter.OriginalText != fixturePastedText {
|
||||
t.Fatalf("recovered chapter %+v", ready.Chapter)
|
||||
}
|
||||
if jobRow(t, db, pasted.Job.ID).Attempts != 2 {
|
||||
t.Fatal("the recovered job must be processed as a second attempt")
|
||||
}
|
||||
var chapters int64
|
||||
db.Model(&Chapter{}).Where("book_id = ?", pasted.Book.ID).Count(&chapters)
|
||||
if chapters != 1 {
|
||||
t.Fatalf("recovery left %d chapters, want 1", chapters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLPasteRejectsInvalidInputAndLimits(t *testing.T) {
|
||||
db, r, owner := libraryFixture(t)
|
||||
learner := newLearner(t, r, owner.Token)
|
||||
|
||||
longTitle := strings.Repeat("T", maxTitleRunes+1)
|
||||
overLimit := strings.Repeat("a", maxChapterRunes+1)
|
||||
cases := []struct {
|
||||
name string
|
||||
payload map[string]string
|
||||
}{
|
||||
{"missing_request_id", map[string]string{"title": "T", "text": "Text."}},
|
||||
{"short_request_id", map[string]string{"requestId": "short", "title": "T", "text": "Text."}},
|
||||
{"empty_title", map[string]string{"requestId": randomName("invalid"), "title": " ", "text": "Text."}},
|
||||
{"long_title", map[string]string{"requestId": randomName("invalid"), "title": longTitle, "text": "Text."}},
|
||||
{"empty_text", map[string]string{"requestId": randomName("invalid"), "title": "T", "text": " \r\n\t "}},
|
||||
{"unsupported_language", map[string]string{"requestId": randomName("invalid"), "title": "T", "text": "Text.", "language": "zh"}},
|
||||
{"over_limit", map[string]string{"requestId": randomName("invalid"), "title": "T", "text": overLimit}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
code, msg, _ := callRaw(t, r, "POST", "/api/v1/books", learner.Token, tc.payload)
|
||||
if code != 400 || msg == "" {
|
||||
t.Fatalf("status %d (%s), want 400 with a message", code, msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
// The boundary itself is accepted, and an oversized body is refused before decoding.
|
||||
code, accepted := pasteBook(t, r, learner.Token, map[string]string{
|
||||
"requestId": "fixture-boundary-limit", "title": "At the limit", "text": strings.Repeat("a", maxChapterRunes)})
|
||||
if code != 201 || accepted.Chapter.CharCount != maxChapterRunes {
|
||||
t.Fatalf("boundary paste status %d charCount %d", code, accepted.Chapter.CharCount)
|
||||
}
|
||||
code, msg, _ := callRaw(t, r, "POST", "/api/v1/books", learner.Token, map[string]string{
|
||||
"requestId": "fixture-body-limit", "title": "Too large", "text": strings.Repeat("a", maxPasteBodyBytes)})
|
||||
if code != 400 || !strings.Contains(msg, "过大") {
|
||||
t.Fatalf("oversized body status %d (%s), want 400 with 过大", code, msg)
|
||||
}
|
||||
var books, chapters int64
|
||||
db.Model(&Book{}).Where("owner_id = ?", learner.ID).Count(&books)
|
||||
db.Model(&Chapter{}).Where("owner_id = ?", learner.ID).Count(&chapters)
|
||||
if books != 1 || chapters != 1 {
|
||||
t.Fatalf("rejected input created books=%d chapters=%d", books, chapters)
|
||||
}
|
||||
}
|
||||
@@ -88,11 +88,68 @@ func emptyMigrationDB(t *testing.T) *gorm.DB {
|
||||
return db
|
||||
}
|
||||
|
||||
func TestMigrationFromV2PreservesExistingData(t *testing.T) {
|
||||
db := emptyMigrationDB(t)
|
||||
// Build a v2 database by hand: this is the state a deployed instance is in before #5.
|
||||
if err := db.Exec("CREATE TABLE lexgo_schema (id INT PRIMARY KEY,version INT,product VARCHAR(32))").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec("INSERT INTO lexgo_schema VALUES (1,2,'lexgo')").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, statement := range schemaV2Statements {
|
||||
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 (7,'fixture_v2','x',2)").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec("INSERT INTO lexgo_spaces (owner_id,language) VALUES (7,'en')").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Migrate(db); err != nil {
|
||||
t.Fatalf("v2 to v3 migration failed: %v", err)
|
||||
}
|
||||
if err := CheckSchema(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, table := range []string{"lexgo_books", "lexgo_chapters", "lexgo_ingest_jobs"} {
|
||||
var count int64
|
||||
db.Raw("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name=?", table).Scan(&count)
|
||||
if count != 1 {
|
||||
t.Fatalf("migration did not create %s", table)
|
||||
}
|
||||
}
|
||||
var users, spaces int64
|
||||
db.Table("sys_user").Where("user_id = 7").Count(&users)
|
||||
db.Table("lexgo_spaces").Where("owner_id = 7").Count(&spaces)
|
||||
if users != 1 || spaces != 1 {
|
||||
t.Fatalf("migration changed existing rows: users=%d spaces=%d", users, spaces)
|
||||
}
|
||||
// A rollback marker set back to 2 can be upgraded again without touching data.
|
||||
if err := db.Exec("UPDATE lexgo_schema SET version=2 WHERE id=1").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Migrate(db); err != nil {
|
||||
t.Fatalf("re-upgrade failed: %v", err)
|
||||
}
|
||||
var version int
|
||||
db.Raw("SELECT version FROM lexgo_schema WHERE id=1").Scan(&version)
|
||||
if version != 3 {
|
||||
t.Fatalf("schema version %d after re-upgrade, want 3", version)
|
||||
}
|
||||
db.Table("sys_user").Where("user_id = 7").Count(&users)
|
||||
if users != 1 {
|
||||
t.Fatal("re-upgrade changed existing rows")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationRefusesUnownedOrUnsupportedSchema(t *testing.T) {
|
||||
for _, tc := range []struct{ name, marker string }{
|
||||
{"empty_marker", ""},
|
||||
{"negative_version", "INSERT INTO lexgo_schema VALUES (1,-1,'lexgo')"},
|
||||
{"future_version", "INSERT INTO lexgo_schema VALUES (1,3,'lexgo')"},
|
||||
{"future_version", "INSERT INTO lexgo_schema VALUES (1,4,'lexgo')"},
|
||||
{"wrong_product", "INSERT INTO lexgo_schema VALUES (1,0,'another-app')"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
@@ -129,6 +129,12 @@ func Router(db *gorm.DB, now func() time.Time) *gin.Engine {
|
||||
if c.Request.Method == "POST" && c.FullPath() == "/api/v1/accounts" {
|
||||
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") {
|
||||
if paste, ok := data.(PasteResult); ok && !paste.Duplicate {
|
||||
status = 201
|
||||
}
|
||||
}
|
||||
respond(c, status, data, err)
|
||||
}
|
||||
}
|
||||
@@ -213,18 +219,99 @@ 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 {
|
||||
return nil, err
|
||||
}
|
||||
return PasteBook(tx, u.UserId, now(), input)
|
||||
}))
|
||||
v.GET("/books", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
|
||||
if c.Request.URL.RawQuery != "" {
|
||||
return nil, failure(400, "书库不接受查询参数")
|
||||
}
|
||||
items, err := ListBooks(tx, u.UserId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gin.H{"items": items}, nil
|
||||
}))
|
||||
v.GET("/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
|
||||
}
|
||||
book, chapters, err := BookDetail(tx, u.UserId, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gin.H{"book": book, "chapters": chapters}, nil
|
||||
}))
|
||||
v.POST("/books/:id/chapters", 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 PasteChapterInput
|
||||
if err := decodeLimit(c, &input, maxPasteBodyBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return PasteChapter(tx, u.UserId, id, now(), input)
|
||||
}))
|
||||
v.GET("/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
|
||||
}
|
||||
return ChapterDetail(tx, u.UserId, id)
|
||||
}))
|
||||
v.GET("/jobs/: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
|
||||
}
|
||||
job, err := JobDetail(tx, u.UserId, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gin.H{"job": job}, nil
|
||||
}))
|
||||
v.POST("/jobs/:id/retry", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
|
||||
id, err := pathID(c, "任务不存在")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
job, chapter, err := RetryIngestJob(tx, u.UserId, id, now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gin.H{"job": job, "chapter": chapter}, nil
|
||||
}))
|
||||
r.NoRoute(func(c *gin.Context) { respond(c, 404, nil, failure(404, "页面或接口不存在")) })
|
||||
return r
|
||||
}
|
||||
|
||||
func decode(c *gin.Context, value any) error {
|
||||
func decode(c *gin.Context, value any) error { return decodeLimit(c, value, maxJSONBodyBytes) }
|
||||
|
||||
func decodeLimit(c *gin.Context, value any, limit int64) error {
|
||||
if !strings.HasPrefix(c.GetHeader("Content-Type"), "application/json") {
|
||||
return failure(400, "请使用 JSON 请求")
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16*1024)
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
|
||||
d := json.NewDecoder(c.Request.Body)
|
||||
d.DisallowUnknownFields()
|
||||
if d.Decode(value) != nil {
|
||||
if err := d.Decode(value); err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
return failure(400, "内容过大,请减少后重试")
|
||||
}
|
||||
return failure(400, "请求内容无效")
|
||||
}
|
||||
if d.Decode(new(any)) != io.EOF {
|
||||
|
||||
@@ -82,7 +82,7 @@ func run() error {
|
||||
if err = lexgo.Migrate(db); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("LexGo schema version 2 ready")
|
||||
fmt.Println("LexGo schema version 3 ready")
|
||||
return nil
|
||||
}
|
||||
if err = lexgo.CheckSchema(db); err != nil {
|
||||
@@ -112,6 +112,36 @@ func run() error {
|
||||
srv := &http.Server{Addr: addr, Handler: lexgo.Router(db, time.Now), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 1 << 20}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
// A single instance owns the worker. Startup recovery returns chapters left in processing
|
||||
// by an unclean stop, and the running loop sweeps jobs whose finishing transaction failed,
|
||||
// so a transient database problem does not need a restart.
|
||||
if _, err = lexgo.RecoverIngestJobs(db, time.Now()); err != nil {
|
||||
return errors.New("ingestion recovery failed")
|
||||
}
|
||||
go func() {
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
jobCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
requeued, recoverErr := lexgo.RequeueStaleIngestJobs(db, time.Now())
|
||||
if recoverErr != nil {
|
||||
if ctx.Err() == nil {
|
||||
log.Print("ingestion recovery failed; the next sweep retries it")
|
||||
}
|
||||
} else if requeued > 0 && ctx.Err() == nil {
|
||||
log.Printf("requeued %d interrupted ingestion job(s)", requeued)
|
||||
}
|
||||
if _, err := lexgo.ProcessIngestJobs(jobCtx, db, time.Now, 20); err != nil && ctx.Err() == nil {
|
||||
log.Print("ingestion batch stopped before finishing; the claimed job stays processing until the next sweep requeues it")
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
ticker := time.NewTicker(time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# #4 阅读选择技术验证
|
||||
|
||||
独立小样,默认英语;虚构章节与释义,保存仅在当前页面内有效,刷新即清空。不是生产阅读器,不连接账号、数据库、词典服务或复习调度。
|
||||
|
||||
从仓库根目录运行:
|
||||
|
||||
```powershell
|
||||
python spikes/selection/serve.py
|
||||
```
|
||||
|
||||
打开 <http://127.0.0.1:5184/>。点词或用鼠标拖选连续文本;使用面板起点/终点按钮调整。正文获得焦点后,←/→ 选择相邻词,Shift+←/→ 调整连续范围,Escape 取消;关闭面板保留阅读位置和键盘续读位置。修改释义后必须点击保存;关闭不会自动保存。
|
||||
|
||||
测试(Node 22.22.1;DOM 测试复用学习端锁定的 jsdom 开发依赖):
|
||||
|
||||
```powershell
|
||||
pnpm --dir learner install --frozen-lockfile
|
||||
node --test spikes/selection/range.test.mjs spikes/selection/ui.test.mjs
|
||||
node --check spikes/selection/app.mjs
|
||||
```
|
||||
|
||||
`range.mjs` 的 Intl.Segmenter 仅用于验证 UI 选择边界,不替代 #3 的 spaCy 分词。正式集成时必须使用章节原文、内容版本和 NLP token 映射。代码没有复制 LinguaCafe 实现;固定上游版本与四项对照见 [架构 Wiki](https://git.ilapage.cn/OPC/lexgo/wiki/Architecture-and-Code-Map),范围/重叠策略见 [业务规则 Wiki](https://git.ilapage.cn/OPC/lexgo/wiki/Business-Rules-and-Glossary)。
|
||||
|
||||
## 手机测试证据(待补充)
|
||||
|
||||
用户已确认 #4 验收通过,工单已关闭;没有提供具体手机测试记录,以下证据缺口保留。390 CSS 像素的桌面窄屏检查不是真机结果。需要使用实际手机浏览器记录设备、系统、浏览器版本和以下结果:
|
||||
|
||||
1. 普通上下滑动正文,不应意外选词或阻止滚动。
|
||||
2. 长按单词,用系统选择手柄扩展/缩短跨行短语;正文反馈、面板文字和“选中范围”一致。特别检查靠近底部面板的位置,系统菜单/手柄是否被遮挡。
|
||||
3. 使用面板起点/终点按钮调整,验证 emoji、组合字符、连字符及两次出现的 `a small step` 有不同位置。
|
||||
4. 关闭面板继续阅读,打开“验证设置与快捷键”核对关闭前后的滚动位置;输入释义时检查虚拟键盘与面板滚动。
|
||||
5. 测试词典无结果/资源不可用,仍可填写虚构释义、显式保存并继续阅读。
|
||||
|
||||
默认服务器只监听本机。手机需与电脑处于同一可信局域网,在明确进行手机测试时,用电脑的实际局域网 IPv4 地址启动:
|
||||
|
||||
```powershell
|
||||
python spikes/selection/serve.py --host <电脑局域网IPv4地址>
|
||||
```
|
||||
|
||||
手机打开 `http://<电脑局域网IPv4地址>:5184/`。只提供本目录白名单静态文件,不提供目录列表、私人内容或写入接口。若系统防火墙阻止访问,先由用户确认网络放行范围;不自动修改防火墙或发布公网。测试完停止进程。后续真机回归结果回填相关实施工单,并引用 #4 的证据缺口。
|
||||
@@ -0,0 +1,120 @@
|
||||
import {tokenize,normalizeRange,pointSelection,adjustRange,occurrences,resolveOverlaps} from './range.mjs'
|
||||
import {chapters} from './fixtures.mjs'
|
||||
|
||||
const $=id=>document.getElementById(id)
|
||||
const reader=$('reader'), panel=$('panel'), saved=new Map(), positions=new Map()
|
||||
const meanings={'curiosity':'好奇心;求知欲','a small step':'一小步','small step':'小小的一步','learning':'学习','café':'咖啡馆'}
|
||||
let chapterIndex=0,tokens=[],nodes=[],selected=null,anchor=null,focus=null,changingChapter=false,timer
|
||||
|
||||
function chapter(){return chapters[chapterIndex]}
|
||||
function clearNative(){window.getSelection()?.removeAllRanges()}
|
||||
function paint(){
|
||||
const candidates=[]
|
||||
for(const [text,item] of saved) for(const range of occurrences(chapter().text,text)) candidates.push({...range,state:item.state})
|
||||
const visible=resolveOverlaps(candidates)
|
||||
nodes.forEach((node,index)=>{
|
||||
const token=tokens[index]
|
||||
const match=visible.find(range=>token.start>=range.start && token.end<=range.end)
|
||||
node.className=(token.word?'word ':'')+(match?.state || '')
|
||||
node.classList.toggle('selected',!!selected && token.start>=selected.start && token.end<=selected.end)
|
||||
})
|
||||
}
|
||||
function dictionary(){
|
||||
if(!selected)return
|
||||
const mode=$('dictionary-mode').value
|
||||
$('dictionary').textContent=mode==='missing'?'该语言词典暂不可用,仍可填写释义。':mode==='empty'?'未找到释义,可手动填写。':meanings[selected.text.toLocaleLowerCase('en')] || '未找到释义,可手动填写。'
|
||||
}
|
||||
function choose(range,{resetAnchor=true}={}){
|
||||
if(!range)return
|
||||
if(selected?.start===range.start && selected?.end===range.end)return
|
||||
selected=range
|
||||
if(resetAnchor){anchor=range.first;focus=range.last}
|
||||
panel.hidden=false;$('empty').hidden=true
|
||||
$('term').textContent=range.text
|
||||
$('kind').textContent=range.first===range.last?'单词':'短语'
|
||||
const item=saved.get(range.text)
|
||||
$('meaning').value=item?.meaning || ''
|
||||
$('word-state').value=item?.state || 'learning'
|
||||
$('feedback').textContent=''
|
||||
$('range-output').textContent=JSON.stringify({chapter:chapter().id,text:range.text,utf16:[range.start,range.end],code_point:[range.start_cp,range.end_cp],utf8:[range.start_utf8,range.end_utf8]},null,2)
|
||||
dictionary();paint()
|
||||
document.querySelectorAll('[data-edge]').forEach(button=>{
|
||||
const next=adjustRange(chapter().text,tokens,range,button.dataset.edge,Number(button.dataset.direction))
|
||||
button.disabled=next.start===range.start && next.end===range.end
|
||||
})
|
||||
}
|
||||
function close(){
|
||||
const scroll=window.scrollY
|
||||
selected=null;anchor=focus
|
||||
clearTimeout(timer);clearNative();panel.hidden=true;$('empty').hidden=false
|
||||
paint();reader.focus({preventScroll:true});window.scrollTo({top:scroll,behavior:'instant'})
|
||||
$('event-status').textContent=`关闭前 ${scroll},关闭后 ${window.scrollY}`
|
||||
}
|
||||
function readNativeSelection(){
|
||||
const selection=window.getSelection()
|
||||
if(changingChapter || !selection || selection.isCollapsed || !selection.rangeCount)return false
|
||||
const range=selection.getRangeAt(0)
|
||||
if(!reader.contains(range.startContainer)||!reader.contains(range.endContainer))return false
|
||||
const prefix=document.createRange();prefix.selectNodeContents(reader);prefix.setEnd(range.startContainer,range.startOffset)
|
||||
const start=prefix.toString().length
|
||||
prefix.setEnd(range.endContainer,range.endOffset)
|
||||
const end=prefix.toString().length
|
||||
const normalized=normalizeRange(chapter().text,tokens,start,end)
|
||||
if(!normalized){
|
||||
selected=null;panel.hidden=true;$('empty').hidden=false;paint()
|
||||
return false
|
||||
}
|
||||
choose(normalized)
|
||||
return true
|
||||
}
|
||||
function renderChapter(){
|
||||
changingChapter=true;clearTimeout(timer);clearNative();selected=null;anchor=null;focus=null
|
||||
$('title').textContent=chapter().title;$('chapter-number').textContent=`${chapterIndex+1} / ${chapters.length}`
|
||||
$('previous').disabled=chapterIndex===0;$('next').disabled=chapterIndex===chapters.length-1
|
||||
tokens=tokenize(chapter().text)
|
||||
nodes=tokens.map((token,index)=>{const span=document.createElement('span');span.textContent=token.text;span.dataset.index=String(index);return span})
|
||||
reader.replaceChildren(...nodes)
|
||||
if(reader.textContent!==chapter().text)throw new Error('Original text reconstruction failed')
|
||||
panel.hidden=true;$('empty').hidden=false;paint();changingChapter=false
|
||||
}
|
||||
reader.addEventListener('click',event=>{
|
||||
if(readNativeSelection())return
|
||||
const node=event.target.closest('[data-index]')
|
||||
if(node)choose(pointSelection(chapter().text,tokens,Number(node.dataset.index)))
|
||||
})
|
||||
// Native selection remains in charge of mouse dragging and mobile long-press handles.
|
||||
// No touchmove preventDefault, pointer capture or text-node replacement on selection.
|
||||
document.addEventListener('selectionchange',()=>{clearTimeout(timer);timer=setTimeout(readNativeSelection,100)})
|
||||
reader.addEventListener('pointerup',()=>{clearTimeout(timer);timer=setTimeout(readNativeSelection,0)})
|
||||
reader.addEventListener('keydown',event=>{
|
||||
if(event.key==='Escape'){event.preventDefault();close();return}
|
||||
if(!['ArrowLeft','ArrowRight'].includes(event.key)||event.ctrlKey||event.metaKey||event.altKey)return
|
||||
event.preventDefault();clearNative()
|
||||
const direction=event.key==='ArrowLeft'?-1:1
|
||||
let next=focus===null?(direction===1?0:tokens.length-1):focus+direction
|
||||
while(next>=0 && next<tokens.length && !tokens[next].word)next+=direction
|
||||
if(next<0||next>=tokens.length)return
|
||||
if(!event.shiftKey||anchor===null){anchor=next;focus=next;choose(pointSelection(chapter().text,tokens,next),{resetAnchor:false})}
|
||||
else {focus=next;choose(normalizeRange(chapter().text,tokens,Math.min(tokens[anchor].start,tokens[focus].start),Math.max(tokens[anchor].end,tokens[focus].end)),{resetAnchor:false})}
|
||||
// Keyboard users can navigate a long chapter without losing the active word.
|
||||
nodes[next].scrollIntoView({block:'nearest',inline:'nearest',behavior:'instant'})
|
||||
})
|
||||
document.addEventListener('keydown',event=>{if(event.key==='Escape' && !panel.hidden && event.target!==reader){event.preventDefault();close()}})
|
||||
document.querySelectorAll('[data-edge]').forEach(button=>button.addEventListener('click',()=>{
|
||||
if(!selected)return
|
||||
clearNative();choose(adjustRange(chapter().text,tokens,selected,button.dataset.edge,Number(button.dataset.direction)))
|
||||
}))
|
||||
$('close').addEventListener('click',close)
|
||||
$('save').addEventListener('click',()=>{
|
||||
if(!selected)return
|
||||
const meaning=$('meaning').value.trim()
|
||||
if(!meaning){$('feedback').textContent='请填写释义。';return}
|
||||
saved.set(selected.text,{meaning,state:$('word-state').value})
|
||||
$('feedback').textContent='已保存 · 本次页面内有效';paint()
|
||||
})
|
||||
$('dictionary-mode').addEventListener('change',dictionary)
|
||||
for(const [id,delta] of [['previous',-1],['next',1]])$(id).addEventListener('click',()=>{
|
||||
positions.set(chapter().id,window.scrollY);chapterIndex+=delta;renderChapter()
|
||||
reader.focus({preventScroll:true});window.scrollTo({top:positions.get(chapter().id)||0,behavior:'instant'})
|
||||
})
|
||||
renderChapter()
|
||||
@@ -0,0 +1,54 @@
|
||||
// Fictional public test material, never copied from a learner's library.
|
||||
export const chapters = [
|
||||
{
|
||||
id: 'lantern-1',
|
||||
title: 'The Lantern Workshop',
|
||||
text: `Mira opened the workshop before sunrise. A folded map lay beside the window, and a small step separated the quiet room from the garden. Her curiosity had brought her here. Today she would build a lantern that could travel across the valley.
|
||||
|
||||
“Begin with a small step,” her friend Rowan said. He placed a paper frame on the table. Mira examined the thin edges, turned the frame around, and found a tiny mark near the corner. The mark looked like a star, but its five points were not quite equal.
|
||||
|
||||
Outside, rain tapped against the glass. Inside, the workshop smelled of cedar and warm tea. They measured the paper twice. Then they measured it again, because a hurried answer could turn an ordinary mistake into a long afternoon of repairs.
|
||||
|
||||
Mira wrote a note: keep the frame light, keep the corners strong, and leave enough room for the light. Rowan read the note aloud. Neither of them knew whether the lantern would fly, but both wanted to understand why it might.
|
||||
|
||||
Their first attempt leaned to the left. The second leaned to the right. At the third attempt, Mira laughed. “A well-known problem,” she said, “can still offer an unexpected lesson.” She did not throw the old frames away. Each one showed a different part of the problem.
|
||||
|
||||
By noon the rain had stopped. A narrow line of sunlight crossed the wooden floor. Mira moved the lantern into the light, and the paper shone like a pale orange leaf. Her curiosity was no smaller than it had been in the morning.
|
||||
|
||||
On the shelf was a postcard from an imaginary traveler: “Hello 🙂! The café has a sign that reads café. Our paper astronaut 👩🚀 is ready for the journey. A second sign says 中文.” Mira kept every character exactly as it was written, including the accent that followed its letter.
|
||||
|
||||
The last instruction covered two lines:
|
||||
take a small step
|
||||
and watch what changes.
|
||||
Rowan pointed at the break between them. A pause was useful, he thought; it gave the reader time to notice the words on either side. The paper itself had not lost a single letter.
|
||||
|
||||
In the garden, a light breeze lifted the grass. Mira carried the lantern to the gate and waited. She could hear a bird in the old pear tree, a cart on the distant road, and Rowan counting softly behind her. She held the frame with both hands until the counting stopped.
|
||||
|
||||
The lantern rose a little, turned slowly, and settled beside the gate. It had traveled less than a meter. Mira smiled anyway. For a first journey, a small step was enough. Tomorrow they would return to the workshop, read their notes, and try again.`,
|
||||
},
|
||||
{
|
||||
id: 'lantern-2',
|
||||
title: 'Across the Quiet Valley',
|
||||
text: `The next morning, Rowan arrived with a blue notebook. He had drawn the valley as a series of gentle curves. A small bridge crossed the river near the mill, and a winding path connected the bridge to the workshop. Mira studied the drawing before she touched the lantern.
|
||||
|
||||
“Yesterday was a small step,” she said. “Today we should learn what the wind is doing.” They tied a ribbon to the gate. It pointed toward the river, fell still, and then lifted toward the hills. The wind had more than one answer.
|
||||
|
||||
They waited beneath the pear tree. Waiting felt different when there was something to observe. Rowan noted the direction of each gust. Mira counted the seconds between them. After a while, the blank page held a pattern that neither of them had expected.
|
||||
|
||||
A neighbor passed with an empty basket. She asked why they were staring at a ribbon. Mira explained the experiment, and the neighbor offered a practical suggestion: walk to the bridge before making another lantern. The river often changed the air near its banks.
|
||||
|
||||
At the bridge, the breeze was cooler. Water slipped around the stones with a soft, uneven sound. Rowan opened the notebook to a fresh page. Mira held the ribbon above the railing and watched its end flicker. Their curiosity made an ordinary walk feel like a journey.
|
||||
|
||||
There was no single perfect direction. Near the bank, the ribbon pointed downstream. At the center of the bridge, it twisted back toward the trees. They wrote down both observations. An answer that left out the difficult part would not help them tomorrow.
|
||||
|
||||
On their way home, they stopped beside a weather-beaten sign. Its painted words had faded, but the letters were still clear: a small step can begin a long journey. Underneath, someone had drawn a smiling face 🙂 and a tiny astronaut 👩🚀 beside an imaginary moon.
|
||||
|
||||
Back at the workshop, Mira compared the old paper frames. One was too heavy at its base. Another had a loose corner. She chose the simplest frame and changed only one thing. Rowan recorded the change before they carried the lantern outside.
|
||||
|
||||
This time it floated beyond the gate. For a moment, the orange paper rested against the blue sky like a leaf on clear water. Then the breeze shifted, and the lantern drifted gently into the grass. Nothing had broken. They could try again with the same frame.
|
||||
|
||||
Mira did not call the experiment a failure. The notebook now held a direction, a time, and a careful description of the flight. They had learned something they could test. That was enough to make the next question smaller and more useful.
|
||||
|
||||
When evening came, they closed the window and cleared the table. The map remained beside the notebook. Tomorrow might bring a longer flight, or another surprising turn. Either way, they would begin with a small step, pay attention, and preserve the story exactly as it happened.`,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,8 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>LexGo · 阅读选择验证</title><link rel="stylesheet" href="/style.css"></head>
|
||||
<body><header><a class="brand" href="/">LexGo<span>.</span></a><span>英语 · 阅读选择验证</span></header>
|
||||
<main><div class="book"><div><p class="eyebrow">A SMALL STEP, EVERY DAY</p><h1 id="title"></h1></div><nav aria-label="章节"><button id="previous" class="quiet">上一章</button><span id="chapter-number"></span><button id="next" class="quiet">下一章</button></nav></div>
|
||||
<div class="layout"><section class="paper"><p class="hint" id="reader-help">点词查词,划选连续短语。</p><article id="reader" tabindex="0" aria-label="章节正文" aria-describedby="reader-help"></article></section>
|
||||
<aside class="side"><p id="empty">选择一个词或短语</p><section id="panel" aria-label="查词面板" hidden><div class="panel-top"><span id="kind"></span><button id="close" class="quiet" aria-label="关闭,继续阅读">关闭</button></div><h2 id="term"></h2><div class="range-controls" aria-label="调整选择范围"><span>起点</span><button data-edge="start" data-direction="-1" aria-label="起点前移">−</button><button data-edge="start" data-direction="1" aria-label="起点后移">+</button><span>终点</span><button data-edge="end" data-direction="-1" aria-label="终点前移">−</button><button data-edge="end" data-direction="1" aria-label="终点后移">+</button></div><p id="dictionary"></p><label for="meaning">我的释义</label><textarea id="meaning" rows="2" maxlength="500"></textarea><label for="word-state">状态</label><select id="word-state"><option value="learning">学习中</option><option value="known">已认识</option><option value="ignored">忽略</option></select><button id="save">保存到生词本</button><p id="feedback" role="status" aria-live="polite"></p><details><summary>选中范围</summary><output id="range-output"></output></details></section></aside></div>
|
||||
<details class="settings"><summary>验证设置与快捷键</summary><p>正文获得焦点后:← / → 选择相邻词;Shift + ← / → 调整连续范围;Escape 取消。范围按钮也可用 Tab、Enter 操作。</p><label for="dictionary-mode">词典状态</label><select id="dictionary-mode"><option value="ready">正常</option><option value="empty">无结果</option><option value="missing">资源不可用</option></select><p>虚构文本与释义,保存仅在本次页面内有效。刷新即清空。</p><p id="event-status" role="status"></p></details>
|
||||
</main><footer>交互验证小样 · 不保存私人数据</footer><script type="module" src="/app.mjs"></script></body></html>
|
||||
@@ -0,0 +1,85 @@
|
||||
// UI selection boundaries only: this does not replace the #3 NLP tokenizer.
|
||||
const words = new Intl.Segmenter('en', { granularity: 'word' });
|
||||
const graphemes = new Intl.Segmenter('en', { granularity: 'grapheme' });
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
export function tokenize(text) {
|
||||
const tokens = [];
|
||||
for (const part of words.segment(text)) {
|
||||
if (part.isWordLike) {
|
||||
tokens.push({ text: part.segment, start: part.index, end: part.index + part.segment.length, word: true });
|
||||
} else {
|
||||
for (const item of graphemes.segment(part.segment)) {
|
||||
const start = part.index + item.index;
|
||||
tokens.push({ text: item.segment, start, end: start + item.segment.length, word: /\p{Extended_Pictographic}/u.test(item.segment) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function selection(text, tokens, first, last) {
|
||||
const start = tokens[first].start;
|
||||
const end = tokens[last].end;
|
||||
return {
|
||||
start, end, text: text.slice(start, end), first, last,
|
||||
start_cp: [...text.slice(0, start)].length,
|
||||
end_cp: [...text.slice(0, end)].length,
|
||||
start_utf8: encoder.encode(text.slice(0, start)).length,
|
||||
end_utf8: encoder.encode(text.slice(0, end)).length,
|
||||
start_utf16: start, end_utf16: end,
|
||||
};
|
||||
}
|
||||
|
||||
// Inputs and outputs use half-open UTF-16 offsets into the unchanged original.
|
||||
export function normalizeRange(text, tokens, start, end) {
|
||||
if (![start, end].every(n => Number.isInteger(n) && n >= 0 && n <= text.length) || start === end) return null;
|
||||
const low = Math.min(start, end);
|
||||
const high = Math.max(start, end);
|
||||
let first = -1;
|
||||
let last = -1;
|
||||
tokens.forEach((token, index) => {
|
||||
if (token.word && token.start < high && token.end > low) {
|
||||
if (first === -1) first = index;
|
||||
last = index;
|
||||
}
|
||||
});
|
||||
return first === -1 ? null : selection(text, tokens, first, last);
|
||||
}
|
||||
|
||||
export function pointSelection(text, tokens, index) {
|
||||
return Number.isInteger(index) && tokens[index]?.word ? selection(text, tokens, index, index) : null;
|
||||
}
|
||||
|
||||
// direction -1/+1 moves a boundary left/right; it never collapses a selection.
|
||||
export function adjustRange(text, tokens, range, edge, direction) {
|
||||
if (!range || !['start', 'end'].includes(edge) || ![-1, 1].includes(direction)) return range;
|
||||
let next = (edge === 'start' ? range.first : range.last) + direction;
|
||||
while (next >= 0 && next < tokens.length && !tokens[next].word) next += direction;
|
||||
if (next < 0 || next >= tokens.length) return range;
|
||||
const first = edge === 'start' ? next : range.first;
|
||||
const last = edge === 'end' ? next : range.last;
|
||||
return first > last ? range : selection(text, tokens, first, last);
|
||||
}
|
||||
|
||||
export function occurrences(text, needle) {
|
||||
if (!needle) return [];
|
||||
const tokens = tokenize(text);
|
||||
const result = [];
|
||||
for (let start = text.indexOf(needle); start !== -1; start = text.indexOf(needle, start + 1)) {
|
||||
const end = start + needle.length;
|
||||
const range = normalizeRange(text, tokens, start, end);
|
||||
if (range?.start === start && range.end === end) result.push(range);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Display precedence only. Saved occurrence identities remain separate.
|
||||
export function resolveOverlaps(ranges) {
|
||||
const sorted = [...ranges].sort((a, b) => a.start - b.start || b.end - a.end);
|
||||
const result = [];
|
||||
for (const range of sorted) {
|
||||
if (range.end > range.start && (!result.length || range.start >= result.at(-1).end)) result.push(range);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { tokenize, normalizeRange, pointSelection, adjustRange, occurrences, resolveOverlaps } from './range.mjs';
|
||||
|
||||
test('partition preserves original Unicode, whitespace and punctuation exactly', () => {
|
||||
const text = 'Hi, cafe\u0301!\n🙂 👩🚀 well-known 中文';
|
||||
const tokens = tokenize(text);
|
||||
assert.equal(tokens.map(t => t.text).join(''), text);
|
||||
let end = 0;
|
||||
for (const t of tokens) {
|
||||
assert.equal(t.start, end);
|
||||
assert.equal(text.slice(t.start, t.end), t.text);
|
||||
end = t.end;
|
||||
}
|
||||
assert.deepEqual(tokens.filter(t => t.word).map(t => t.text), ['Hi', 'cafe\u0301', '🙂', '👩🚀', 'well', 'known', '中文']);
|
||||
});
|
||||
|
||||
test('reverse partial selection aligns words and preserves interior punctuation/newlines', () => {
|
||||
const text = ' bright,\nsmall step! ';
|
||||
const tokens = tokenize(text);
|
||||
const range = normalizeRange(text, tokens, 18, 4);
|
||||
assert.equal(range?.text, 'bright,\nsmall step');
|
||||
assert.equal(range.start, 2);
|
||||
assert.equal(range.end, 20);
|
||||
assert.deepEqual(normalizeRange(text, tokens, 0, text.length), range);
|
||||
});
|
||||
|
||||
test('collapsed, whitespace, punctuation and invalid bounds do not select neighbors', () => {
|
||||
const text = 'a, b';
|
||||
const tokens = tokenize(text);
|
||||
for (const [a, b] of [[1, 1], [1, 3], [-1, 2], [0, 5], [NaN, 3], [0.5, 3]]) {
|
||||
assert.equal(normalizeRange(text, tokens, a, b), null);
|
||||
}
|
||||
});
|
||||
|
||||
test('selection cannot cut a surrogate pair, ZWJ or combining grapheme', () => {
|
||||
const text = '🙂 e\u0301 👩🚀';
|
||||
const tokens = tokenize(text);
|
||||
const first = normalizeRange(text, tokens, 1, 2);
|
||||
assert.equal(first?.text, '🙂');
|
||||
const accent = normalizeRange(text, tokens, 4, 5);
|
||||
assert.equal(accent?.text, 'e\u0301');
|
||||
const astronaut = normalizeRange(text, tokens, 8, 9);
|
||||
assert.equal(astronaut?.text, '👩🚀');
|
||||
assert.deepEqual([astronaut.start_cp, astronaut.end_cp, astronaut.start_utf8, astronaut.end_utf8, astronaut.start_utf16, astronaut.end_utf16], [5, 8, 9, 20, 6, 11]);
|
||||
});
|
||||
|
||||
test('point selection and boundary adjustment skip separators and never invert', () => {
|
||||
const text = 'one, two\nthree';
|
||||
const tokens = tokenize(text);
|
||||
const middle = tokens.findIndex(t => t.text === 'two');
|
||||
const one = pointSelection(text, tokens, middle);
|
||||
assert.equal(one?.text, 'two');
|
||||
assert.equal(pointSelection(text, tokens, middle - 1), null);
|
||||
const left = adjustRange(text, tokens, one, 'start', -1);
|
||||
assert.equal(left?.text, 'one, two');
|
||||
const all = adjustRange(text, tokens, left, 'end', 1);
|
||||
assert.equal(all?.text, text);
|
||||
assert.equal(adjustRange(text, tokens, all, 'start', 1)?.text, 'two\nthree');
|
||||
assert.equal(adjustRange(text, tokens, all, 'end', -1)?.text, 'one, two');
|
||||
assert.deepEqual(adjustRange(text, tokens, one, 'start', 1), one);
|
||||
assert.deepEqual(adjustRange(text, tokens, one, 'end', -1), one);
|
||||
assert.deepEqual(adjustRange(text, tokens, all, 'start', -1), all);
|
||||
assert.deepEqual(adjustRange(text, tokens, all, 'end', 1), all);
|
||||
});
|
||||
|
||||
test('occurrences preserve distinct offsets, case and exact combining form', () => {
|
||||
const text = 'a small step; a small step. A small step. steps e\u0301 é';
|
||||
assert.deepEqual(occurrences(text, 'a small step').map(r => [r.start, r.end]), [[0, 12], [14, 26]]);
|
||||
assert.equal(occurrences(text, 'step').length, 3);
|
||||
assert.equal(occurrences(text, 'e\u0301').length, 1);
|
||||
assert.equal(occurrences(text, 'é').length, 1);
|
||||
assert.equal(occurrences(text, 'small st').length, 0);
|
||||
assert.deepEqual(occurrences(text, ''), []);
|
||||
});
|
||||
|
||||
test('overlapping occurrences remain available; display picks leftmost longest without mutation', () => {
|
||||
assert.deepEqual(occurrences('a a a', 'a a').map(r => r.start), [0, 2]);
|
||||
const ranges = [{ start: 2, end: 9, id: 'later' }, { start: 0, end: 3, id: 'short' }, { start: 0, end: 5, id: 'long' }, { start: 5, end: 7, id: 'next' }];
|
||||
const before = structuredClone(ranges);
|
||||
assert.deepEqual(resolveOverlaps(ranges).map(r => r.id), ['long', 'next']);
|
||||
assert.deepEqual(ranges, before);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Serve only the fictional reading experiment; default is loopback."""
|
||||
import argparse
|
||||
import ipaddress
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
FILES = {'/': ('index.html', 'text/html'), '/style.css': ('style.css', 'text/css'),
|
||||
'/app.mjs': ('app.mjs', 'text/javascript'), '/range.mjs': ('range.mjs', 'text/javascript'),
|
||||
'/fixtures.mjs': ('fixtures.mjs', 'text/javascript')}
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
timeout = 10
|
||||
|
||||
def log_message(self, *_args):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
item = FILES.get(self.path)
|
||||
data = (ROOT / item[0]).read_bytes() if item else b'Not found'
|
||||
self.send_response(200 if item else 404)
|
||||
self.send_header('Content-Type', (item[1] if item else 'text/plain') + '; charset=utf-8')
|
||||
self.send_header('Content-Length', str(len(data)))
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
self.send_header('X-Content-Type-Options', 'nosniff')
|
||||
self.send_header('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'none'; frame-ancestors 'none'; base-uri 'none'")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--host', default='127.0.0.1', help='Numeric local address; LAN testing is opt-in')
|
||||
parser.add_argument('--port', type=int, default=5184)
|
||||
args = parser.parse_args()
|
||||
ipaddress.IPv4Address(args.host)
|
||||
with HTTPServer((args.host, args.port), Handler) as server:
|
||||
print(f'LexGo selection experiment: http://{args.host}:{server.server_port}/', flush=True)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
@@ -0,0 +1 @@
|
||||
:root{font-family:"Segoe UI","Microsoft YaHei",sans-serif;color:#203d33;background:#f6f4ed;--green:#285c49;--line:#dbe1d8}*{box-sizing:border-box}body{margin:0}header{height:76px;background:#fff;border-bottom:1px solid var(--line);padding:0 5%;display:flex;align-items:center;justify-content:space-between}header>span{color:#6c7971;font-size:13px}.brand{color:inherit;text-decoration:none;font-size:28px;font-weight:750;letter-spacing:-1px}.brand span{color:#538461}main{max-width:1220px;margin:36px auto;padding:0 24px}.book{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:25px}.eyebrow{font-size:11px;letter-spacing:2px;color:#738276}h1{font-size:26px;font-weight:600;margin:10px 0}nav{display:flex;gap:12px;align-items:center;white-space:nowrap}button,input,textarea,select{font:inherit}button{cursor:pointer;border:1px solid var(--line);border-radius:5px;padding:9px 14px;background:var(--green);color:#fff;touch-action:manipulation}button:disabled{opacity:.4;cursor:default}.quiet{background:#fff;color:var(--green)}button:focus-visible,select:focus-visible,textarea:focus-visible,article:focus-visible{outline:2px solid #588364;outline-offset:3px}.layout{display:grid;grid-template-columns:minmax(0,1fr) 310px;gap:24px;align-items:start}.paper{background:#fff;border:1px solid var(--line);border-radius:8px;padding:32px 38px}.hint{font-size:12px;color:#738276;margin:0 0 22px}article{font:22px/1.95 Georgia,"Times New Roman",serif;white-space:pre-wrap;overflow-wrap:anywhere;user-select:text;-webkit-user-select:text;touch-action:auto;outline-offset:10px}article span.word{cursor:text}article .learning{background:#f5e7b7}article .known{box-shadow:inset 0 -2px #a2c1ab}article .ignored{color:#849086}article .selected{background:#d7e9b3;box-shadow:inset 0 -2px #56743d}article ::selection{background:#bad59a;color:#18321e}.side{position:sticky;top:18px;min-height:100px}#empty{font-size:14px;color:#859186;padding:20px;text-align:center}#panel{background:#fff;border:1px solid var(--line);border-radius:8px;padding:22px}.panel-top{display:flex;align-items:center;justify-content:space-between;color:#7b897f;font-size:12px}h2{font:27px/1.4 Georgia,serif;margin:14px 0;overflow-wrap:anywhere;max-height:120px;overflow:auto}.range-controls{display:flex;align-items:center;gap:5px;font-size:12px;flex-wrap:wrap}.range-controls button{min-width:32px;padding:6px;background:#f5f7ef;color:var(--green)}.range-controls span:nth-of-type(2){margin-left:8px}#dictionary{font-size:14px;line-height:1.6;border-top:1px solid var(--line);padding-top:16px}label{display:block;font-size:12px;color:#667b6d;margin:16px 0 7px}textarea,select{width:100%;border:1px solid var(--line);border-radius:4px;padding:9px;background:white;color:inherit}textarea{resize:vertical}#save{width:100%;margin-top:18px}#feedback{min-height:20px;font-size:13px;color:#467345}details{font-size:12px;color:#748276}summary{cursor:pointer}output{display:block;white-space:pre-wrap;overflow-wrap:anywhere;font:11px/1.5 Consolas,monospace;margin-top:10px}.settings{margin-top:28px;max-width:720px;line-height:1.8}.settings select{max-width:240px}footer{text-align:center;font-size:12px;color:#839083;padding:30px}[hidden]{display:none!important}@media(max-width:760px){header{height:60px}.book{display:block}nav{margin-top:18px;justify-content:space-between}main{padding:0 14px;margin-top:22px}.layout{display:block}.paper{padding:24px 20px}article{font-size:20px;line-height:1.95}article .word{scroll-margin-bottom:45dvh}body{padding-bottom:360px}.side{position:static;min-height:0}#empty{display:none}#panel{position:fixed;bottom:0;left:0;right:0;z-index:2;max-height:42dvh;overflow:auto;overscroll-behavior:contain;border-radius:14px 14px 0 0;padding:16px 20px;box-shadow:0 -4px 20px #233c3314}.panel-top{position:sticky;top:-16px;background:#fff;padding:5px 0}h2{font-size:23px;max-height:65px}.range-controls button{min-height:40px;min-width:40px}}
|
||||
@@ -0,0 +1,53 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {readFile} from 'node:fs/promises'
|
||||
import {JSDOM} from '../../learner/node_modules/jsdom/lib/api.js'
|
||||
|
||||
let serial=0
|
||||
async function page(run){
|
||||
const html=await readFile(new URL('./index.html',import.meta.url),'utf8')
|
||||
const dom=new JSDOM(html,{url:'http://127.0.0.1:5184/'})
|
||||
globalThis.window=dom.window;globalThis.document=dom.window.document
|
||||
dom.window.scrollTo=()=>{}
|
||||
dom.window.HTMLElement.prototype.scrollIntoView=()=>{}
|
||||
await import(`./app.mjs?test=${++serial}`)
|
||||
const $=id=>document.getElementById(id)
|
||||
try{await run($,dom.window)}finally{await new Promise(resolve=>setTimeout(resolve,120));dom.window.close()}
|
||||
}
|
||||
|
||||
test('close preserves keyboard cursor: next arrow continues at the adjacent word',()=>page(($,w)=>{
|
||||
const span=[...$('reader').children].find(n=>n.textContent==='workshop')
|
||||
span.click();assert.equal($('term').textContent,'workshop')
|
||||
$('close').click();assert.equal($('panel').hidden,true)
|
||||
$('reader').dispatchEvent(new w.KeyboardEvent('keydown',{key:'ArrowRight',bubbles:true}))
|
||||
assert.equal($('term').textContent,'before')
|
||||
}))
|
||||
test('native punctuation-only selection clears stale word and cannot save it',()=>page(async($,w)=>{
|
||||
[...$('reader').children].find(n=>n.textContent==='curiosity').click()
|
||||
const dot=[...$('reader').children].find(n=>n.textContent==='.')
|
||||
const range=document.createRange();range.selectNodeContents(dot)
|
||||
w.getSelection().removeAllRanges();w.getSelection().addRange(range)
|
||||
document.dispatchEvent(new w.Event('selectionchange'))
|
||||
await new Promise(resolve=>setTimeout(resolve,130))
|
||||
assert.equal($('panel').hidden,true)
|
||||
assert.equal($('reader').querySelectorAll('.selected').length,0)
|
||||
}))
|
||||
test('native selection maps DOM text through spans and expands to full token endpoints',()=>page(async($,w)=>{
|
||||
const spans=[...$('reader').children]
|
||||
const start=spans.find(n=>n.textContent==='Mira'),end=spans.find(n=>n.textContent==='workshop')
|
||||
const range=document.createRange();range.setStart(start.firstChild,2);range.setEnd(end.firstChild,3)
|
||||
w.getSelection().removeAllRanges();w.getSelection().addRange(range)
|
||||
document.dispatchEvent(new w.Event('selectionchange'))
|
||||
await new Promise(resolve=>setTimeout(resolve,130))
|
||||
assert.equal($('term').textContent,'Mira opened the workshop')
|
||||
const result=JSON.parse($('range-output').textContent)
|
||||
assert.equal($('reader').textContent.slice(...result.utf16),result.text)
|
||||
}))
|
||||
test('explicit save updates repeated words; unsaved edits are discarded on close',()=>page(($)=>{
|
||||
const words=[...$('reader').children].filter(n=>n.textContent==='curiosity')
|
||||
words[0].click();$('meaning').value='虚构释义';$('save').click()
|
||||
assert.ok(words.every(n=>n.classList.contains('learning')))
|
||||
$('meaning').value='未保存修改';$('close').click();words[1].click()
|
||||
assert.equal($('meaning').value,'虚构释义')
|
||||
$('next').click();assert.equal($('panel').hidden,true)
|
||||
}))
|
||||
Reference in New Issue
Block a user