feat: 编辑和删除本人书籍与章节 (#10)

- PATCH /books/:id 改名、GET /chapters/:id/source 读取编辑用原文(任意状态)、
  PATCH /chapters/:id 改标题与正文、DELETE /books/:id 与 DELETE /chapters/:id
- 版本门控:任务只在 job.content_sha256 与章节版本一致时才能影响章节;过期版本任务
  被标为 superseded 且完全不触碰章节,认领与恢复扫描跳过并作废它们,重试旧版本任务 409
- 只有正文变化才重新处理:重复保存或改回原内容不新建任务;只改标题不改状态
- 删除在事务内硬删除并沿用外键级联,章节删除后重排序号;个人词条、复习排期与作答记录保留
- 处理中删除章节后,在途任务不再发布也不报错;并发删除同一章由书籍行锁序列化
- 学习端:书名与章节编辑对话框、删除确认弹窗、章节行编辑入口、书库删除提示
- Wiki 记录 Architecture、Business-Rules、Local-Development 与需求更新
This commit is contained in:
ila
2026-09-13 23:20:52 +08:00
parent 65b50d5038
commit 35ed692c4b
16 changed files with 1565 additions and 29 deletions
+20 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Architecture-and-Code-Map.-
wiki_revision: 5e22c6023b065287ac47d92e6b8748e34d21763b
synchronized_at: 2026-09-13T14:36:36Z
wiki_revision: ce95236e4c8de27c1063448571cfc528c895f937
synchronized_at: 2026-09-13T15:20:16Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -287,3 +287,21 @@ schema v6 新增 `lexgo_term_reviews`(每个个人词条一行排期:`due_at
解码规则见业务规则页;实现上 `decodeTextUpload` 先按 UTF-16 BOM 识别并给出针对性提示,再剥离可选 UTF-8 BOM,然后用 `utf8.Valid` 整体校验,最后交给 `validatePaste`(非空、≤100000 码点)。因此上传与粘贴共享同一分章与任务规则:一次提交一章,`requestId` + 内容 SHA 幂等,worker 只发布已落库的原文。
学习端 `ImportView.vue` 增加「粘贴文本 / TXT 文件」来源切换(沿用已验收 v1 的切换与状态行),`stores/library.ts` 增加 `upload()` 与 `fileProblem`/`fileSizeLabel`,`session.request` 支持 `FormData`(multipart 请求不再被 JSON 化,边界由浏览器提供)。客户端预检只提前反馈,服务端结论为最终结论。
## #10 编辑与删除书籍章节(2026-09-11)
`server/app/lexgo/edit.go` 提供改名、编辑与删除;`ingest.go` 增加版本门控。**schema 无变化**:`chapters.content_sha256` 与 `jobs.content_sha256` 就是版本键,新增的 `superseded` 复用现有 `error_reason` 列。
| 接口 | 权限与输入/输出 |
|---|---|
| PATCH /api/v1/books/:id | 本人;`{title}`;返回 `{book}` |
| GET /api/v1/chapters/:id/source | 本人任意状态;返回 `{source:{id,bookId,ordinal,title,text,status,contentSha256,charCount}}`,供编辑使用;不接受查询参数 |
| PATCH /api/v1/chapters/:id | 本人;`{title?,text?}`;返回 `{chapter,job,versionChanged}`;正文变化才新建任务 |
| DELETE /api/v1/books/:id | 本人;返回 `{deleted:{bookId,chapters}}`;FC 级联删除章节与任务 |
| DELETE /api/v1/chapters/:id | 本人;返回 `{deleted:{chapterId,bookId,remaining}}`;删除后重排序号 |
**版本门控**(本单修掉的缺陷):任务只在 `job.content_sha256 == chapter.content_sha256` 时才能影响章节。认领任务时用 JOIN 只取版本匹配的行,并先把过期版本任务一次性标为 `failed/superseded`;发布前再比对一次,不匹配就只把任务标为 `superseded` 并**完全不触碰章节**;恢复扫描同样先作废过期版本任务、只重排版本匹配的中断任务;重试接口拒绝版本不匹配的任务(409)。删除期间在途任务找不到章节时视为无事可做(级联已删除其任务行)。
`unprocessableReason` 保留一条内容一致性检查:存储的正文重新计算出的 SHA 必须等于该章节存储的 SHA,用于兜住绕过 API 的直接写入(`content_changed`),与版本门控互不重复。
学习端 `BookView.vue` 增加书名编辑对话框、章节编辑对话框(标题 + 正文,正文来自 source 接口)与两处确认弹窗(`ElMessageBox`),章节行增加「编辑」入口;`LibraryView.vue` 在书库被删后显示「书籍已删除 · 已保存的生词和短语仍保留在生词本。」;`stores/library.ts` 增加 `renameBook`、`updateChapter`、`loadChapterSource`、`deleteBook`、`deleteChapter`。正文编辑通过浏览器 textarea 输入,因此该章的行尾统一为 LF(粘贴与 TXT 导入仍保留原始 CRLF)。
+22 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Business-Rules-and-Glossary.-
wiki_revision: 76289713c11902383031764c90ae9da90bd0ce07
synchronized_at: 2026-09-11T15:36:44Z
wiki_revision: 96eec89f74db744d8807baa24b1d1d611ff6bf8f
synchronized_at: 2026-09-13T15:20:16Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -206,3 +206,23 @@ exact优先;未命中再按WordNet异常表/词尾规则查候选,词性顺
**导入与幂等**:上传与粘贴共用同一套规则——一次提交一章,`requestId` + 内容 SHA 保证重复上传同一文件只产生一章(返回第一次的章节并标记 `duplicate`),同一 `requestId` 换成其他内容返回 409。任务状态、失败重试与崩溃恢复沿用 #5 的任务机制,不新增状态。标题规则与粘贴完全相同(去空白后非空、≤120 字符);省略 `language` 时默认英语,与粘贴一致;追加章节不接受 `language`。
**范围边界**:不包含 EPUB、PDF、字幕与其他文件格式;不做按空行自动分章;不做 UTF-16/GB18030 转码;不做断点续传;不把来源文件名持久化(若将来需要「导入来源」溯源,另立范围)。
## #10 编辑、版本与删除规则(2026-09-11)
**可编辑内容**:书名、章节标题、章节正文。只有正文变化才重新处理;只改标题不改变处理状态,也不新建任务。
**版本规则**:`chapters.content_sha256` 是章节的版本键,`jobs.content_sha256` 是任务被创建时对应的版本。任务只能在版本匹配时影响章节:
- 过期版本的任务被标为 `error_reason=superseded`(「章节内容已更新为新版本,本次处理已作废」),**不会**把章节标成失败、也不会发布旧文本;界面上失败章节的重试按钮只出现于当前版本的任务。
- 任务重试要求版本匹配,否则 409,避免把当前章节拉回旧版本再失败一次。
- 重复保存同一正文不是新版本:不新建任务、不改变状态。改回原内容(内容相同)同样不触发处理。
- 编辑章节正文会保留章节编号与阅读入口(URL 不变),章节状态回到待处理,处理完成后原文即新版本。
**存储文本与版本一致性**:章节存储的正文重新计算出的 SHA 必须等于存储的 SHA;出现不一致(绕过 API 的直接写入)时按 `content_changed` 失败,不发布不确定内容。
**删除规则**:删除在事务内**硬删除**,并沿用现有外键级联清理:书籍 → 章节 → 任务。删除章节后剩余章节序号重排为连续(原型显示「剩余 N 章」,导航按序号取值);并发删除同一章由书籍行锁序列化,结果是一个成功、一个 404。重复删除返回 404,不把「已经不存在」当成成功。恢复路径是数据库备份与完整恢复(#15 演练范围),产品不提供回收站或撤销。
**个人学习记录保留**:删除书籍或章节**不删除**个人词条、复习排期与作答记录,因为它们按学习者归属、不引用章节;界面也没有「来自某章节」的引用(例句是学习者输入的副本)。所以删除只影响书籍、章节与处理任务,这一点在原型确认文案中就写明:「本章正文将被删除,已保存词条保留」「删除这本书及其章节?已保存的生词和短语将保留」。
**归属与边界**:改名、编辑、删除、读取编辑用原文都严格按会话归属;他人资源与不存在资源统一 404,未登录 401,空标题/空正文/超长文本/未知字段 400。学习端正文编辑框的行尾会统一为 LF(浏览器 textarea 行为),粘贴与 TXT 导入路径仍然保留原始 CRLF 与空白。
**范围边界**:不做封面与音频附件(#21)、不做回收站/撤销、不做批量操作、不做章节跨书移动、不做语言变更。
+20 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Local-Development-and-Verification.-
wiki_revision: 8c0886a74112ea7bff734c04775b56c571797c3d
synchronized_at: 2026-09-11T15:36:44Z
wiki_revision: 251fb5de71b8ba75da3cba6eee41454d5bbd22df
synchronized_at: 2026-09-13T15:20:16Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -398,3 +398,21 @@ node --test spikes/english/view.test.mjs
真实链路验证:真实 Go API+真实 MySQL 共 26 项检查通过(凭据只从本机安全配置读入进程),覆盖有效文件与阅读器原文逐字节一致、BOM 不进入原文、五类无效文件、越权与未登录拒绝、两账号隔离、恶意文件名不泄漏;随后用临时 Playwright 用例在真实学习端完成「登录→切换 TXT→选择真实 UTF-8 文件→上传→处理中就绪→阅读器原文逐字符一致」的闭环,并复核 UTF-16 文件在浏览器预检阶段被拒。截图保存在本机 `.local/evidence/`(issue9-invalid-encoding.png、issue9-upload-processing.png、issue9-reader.png),临时用例运行后删除。
未验证:真实手机触屏详细证据与完整备份恢复演练仍属既有缺口(#14/#15);本单只用桌面浏览器检查。大文件并发上传只按单槽并发门设计,没有做多用户压力测试。UTF-16/GB18030 转码与按空行自动分章不在本单。
## #10 验证与迁移(2026-09-11)
仓库根执行;Go 工具链由 `python scripts/server.py` 固定 go1.26.5。本单使用专用测试库 lexgo_test_issue9(沿用上一单的库),不借用其他测试库。本单**不改动数据库结构**,没有迁移步骤,回退只需换回旧二进制。
| 命令 | 结果 |
|---|---|
| `go vet ./...` | 通过 |
| `LEXGO_TEST_DB_NAME=lexgo_test_issue9 python scripts/server.py test-integration` | 59 个顶层用例全部通过、0 跳过;含 #10 新增 9 个用例 |
| `cd learner`:`npx vitest --run` / `npx vue-tsc --build` / `npx pnpm run build` / `npx playwright test` | 94 项单测、类型检查、构建、7 项 E2E 全部通过 |
| `cd admin`:`npx pnpm test` / `npx pnpm lint` | 31 项与 lint 通过;管理端本单无代码改动 |
| `python -m unittest discover -s tests` / `python dev_scripts/harness.py check --strict` | 56 项与严格检查通过 |
覆盖内容:改名不改状态也不新建任务、标题/正文校验、正文变化产生新版本且保留章节编号、**处理中改正文后旧任务只标 `superseded` 且不触碰新版本**、旧版本任务重试 409、重复保存与改回原内容不新建任务、内容与版本不一致时按 `content_changed` 失败、恢复扫描作废过期版本且不重排新版本、编辑用原文对任意状态可读、删除章节后序号连续且导航正确、删除书籍级联清理章节与任务、删除期间在途任务不复活内容、并发删除同一章一个成功一个 404、重复删除 404、跨用户改名/编辑/删除/读原文一律 404、未登录 401、个人词条与复习排期在删除后完全保留。
真实链路验证:真实 Go API+真实 MySQL 共 36 项检查通过(凭据只从本机安全配置读入进程,脚本可重复运行并自行清理 fixture),覆盖改名、编辑产生新版本并重新处理、重复保存幂等、删除章节重排序号、删除书籍级联、个人词条与复习队列在删除后保留、越权与非法输入拒绝;随后用临时 Playwright 用例在真实学习端完成「导入→改名→编辑正文→新版就绪→删除章节→删除书籍→书库提示」的闭环,并核对阅读器原文等于新版本。截图保存在本机 `.local/evidence/`(issue10-book-after-edit.png、issue10-chapter-deleted.png、issue10-book-deleted.png),临时用例运行后删除。
未验证:真实手机触屏详细证据与完整备份恢复演练仍属既有缺口(#14/#15);本单只用桌面浏览器检查。并发只覆盖「同一章节并发删除」与「处理中编辑」两类,没有做多用户压力测试。浏览器 textarea 会把该章的 CRLF 归一为 LF,属已知边界,已记入业务规则页。
+8 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Product-Requirements-Overview
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Product-Requirements-Overview.-
wiki_revision: 9bb9f570044fb7c9e04c5f1bd04beeaf89840d86
synchronized_at: 2026-09-13T14:36:37Z
wiki_revision: 82d714b949d38f20586fbe42797b2dec6b0f19f1
synchronized_at: 2026-09-13T15:20:17Z
<!-- gitea-wiki-mirror:end -->
# 产品需求总览
@@ -257,3 +257,9 @@ F10 的单词到期复习已于 2026-09-11 通过用户验收(包含独立审
F03 的 TXT 文件导入已于 2026-09-13 通过用户验收:学习端导入页新增「粘贴文本 / TXT 文件」来源切换,选择 UTF-8 的 .txt 文件后经大小、空文件与编码校验进入与粘贴相同的处理与阅读流程,失败可重试。只支持 UTF-8(允许可选 BOM)且不替换损坏字符;UTF-16 与其他编码会被明确拒绝;文件只在内存中解码、不写临时文件,客户端文件名不参与任何路径也不入库;重复上传同一文件只产生一章。schema 无变化。
仍未实现并留给后续工单:书籍与章节的编辑删除(#10)、短语选择与保存(#11)、词汇库搜索与编辑(#12)、阅读完成与进度(#13)、桌面与手机体验补齐(#14)、自托管试用交付与完整恢复(#15)。EPUB/PDF/字幕、UTF-16 转码、按空行自动分章与断点续传不在本单范围。
## #10 交付范围更新(2026-09-11)
F01 的编辑与删除已实现,待用户验收:学习端可改书名、改章节标题、编辑章节正文并按新版本重新处理,可用确认弹窗删除章节或整本书。编辑正文产生明确版本,旧处理结果被标为 `superseded` 而不覆盖新版本;删除在事务内完成并重排剩余章节序号,个人词条、复习排期与作答记录一律保留。本次没有数据库结构变化。
仍未实现并留给后续工单:短语选择与保存(#11)、词汇库搜索与编辑(#12)、阅读完成与进度(#13)、桌面与手机体验补齐(#14)、自托管试用交付与完整恢复(#15)。封面与音频附件(#21)、回收站/撤销、批量操作、章节跨书移动与语言变更不在本单范围。
+4 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Home
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Home
wiki_revision: 03e9280eedbd7506ec567cef61a54745873d71e2
synchronized_at: 2026-09-13T14:36:36Z
wiki_revision: ea264da6747bd141ec2d67011830320452563415
synchronized_at: 2026-09-13T15:20:16Z
<!-- gitea-wiki-mirror:end -->
# LexGo 文档入口
@@ -76,3 +76,5 @@ Quant-UX 原型 v1 已通过用户验收。[桌面预览](https://qux.ilapage.cn
#8 单词到期复习已于 2026-09-11 通过用户验收(schema v6 新增 lexgo_term_reviews 与 lexgo_review_answers):固定间隔表(1/2/4/7/15/30/60 天)、答对升级、答错或再学立即回队、已知与忽略不入队;重复提交、网络重发与双标签页都只记账一次,到期判定用 UTC 绝对时刻而不引入本地日边界。学习端新增「到期复习」入口,卡片正面显示词与挖空例句、答案面显示个人释义,并有完成页与空队列页。独立审核指出的并发同键 500、编辑文本重排复习与面板保存重置等级三项已整改并复测;PR #27 已 fast-forward-only 合入 main。第 3 阶段「首条学习闭环」#5~#8 全部验收。
#9 TXT 文件导入已于 2026-09-13 通过用户验收:导入页新增「粘贴文本 / TXT 文件」来源切换,只接受 UTF-8(允许可选 BOM)且不替换损坏字符,UTF-16 与其他编码会被明确拒绝;文件只在内存中解码、不写临时文件,客户端文件名不参与任何路径也不入库;上传与粘贴共用同一分章、任务与幂等规则,重复上传同一文件只产生一章。本次没有数据库结构变化;PR #28 已 fast-forward-only 合入 main。
#10 编辑与删除书籍章节已实现,待用户验收:可改书名、改章节标题、编辑章节正文并按新版本重新处理,也可用确认弹窗删除章节或整本书。编辑正文产生明确版本,旧处理结果会被标为 superseded 而不覆盖新版本;删除在事务内完成并重排剩余章节序号,已保存的个人词条、复习排期与作答记录一律保留。本次没有数据库结构变化。
+106
View File
@@ -0,0 +1,106 @@
import { expect, test, type Page } from '@playwright/test'
// Renaming, editing and deleting through the real dialogs of the accepted prototype, against
// a mocked API.
test('rename the book, edit a chapter into a new version and delete both', async ({ page }) => {
const user = { id: 42, username: 'fictional-editor', role: 'learner' }
const timestamps = { createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' }
const first = { id: 9, bookId: 1, ordinal: 1, title: 'First chapter', status: 'ready', charCount: 24, errorReason: '', errorMessage: '', jobId: 5, ...timestamps }
const second = { id: 10, bookId: 1, ordinal: 2, title: 'Second chapter', status: 'ready', charCount: 12, errorReason: '', errorMessage: '', jobId: 6, ...timestamps }
let bookTitle = 'A small step'
let chapters = [first, second]
let firstText = 'Mira opened the workshop.\n'
let processing = false
const requests: string[] = []
const listBook = () => ({
book: { id: 1, title: bookTitle, language: 'en' },
chapters: chapters.map(item => (item.id === 9 && processing ? { ...item, status: 'processing' } : item)),
})
await page.route('**/api/v1/**', async route => {
const path = new URL(route.request().url()).pathname
const method = route.request().method()
requests.push(`${method} ${path}`)
let data: unknown = null
let status = 200
if (path === '/api/v1/login') data = { token: 'fictional-session', user }
else if (path === '/api/v1/me') data = user
else if (path === '/api/v1/space') data = { ownerId: user.id, language: 'en' }
else if (path === '/api/v1/books' && method === 'GET') {
data = { items: bookTitle === '' ? [] : [{ id: 1, title: bookTitle, language: 'en', chapterCount: chapters.length, pendingCount: 0, processingCount: 0, readyCount: chapters.length, failedCount: 0, ...timestamps }] }
} else if (path === '/api/v1/books/1' && method === 'PATCH') {
bookTitle = (route.request().postDataJSON() as { title: string }).title
data = { book: { id: 1, title: bookTitle, language: 'en' } }
} else if (path === '/api/v1/books/1' && method === 'DELETE') {
chapters = []
bookTitle = ''
data = { deleted: { bookId: 1, chapters: 2, remaining: 0 } }
} else if (path === '/api/v1/books/1') data = listBook()
else if (path === '/api/v1/chapters/9/source') data = { source: { id: 9, bookId: 1, ordinal: 1, title: first.title, text: firstText, status: 'ready', contentSha256: 'sha-a', charCount: [...firstText].length } }
else if (path === '/api/v1/chapters/9' && method === 'PATCH') {
const body = route.request().postDataJSON() as { title?: string; text?: string }
const changed = body.text !== undefined && body.text !== firstText
if (body.title !== undefined) first.title = body.title
if (changed) {
firstText = body.text as string
processing = true
first.status = 'pending'
setTimeout(() => { processing = false; first.status = 'ready' }, 400)
}
status = 200
data = { chapter: first, job: changed ? { id: 7, bookId: 1, chapterId: 9, status: 'pending', attempts: 0, errorReason: '', errorMessage: '', ...timestamps } : null, versionChanged: changed }
} else if (path === '/api/v1/chapters/9' && method === 'DELETE') {
chapters = chapters.filter(item => item.id !== 9).map((item, index) => ({ ...item, ordinal: index + 1 }))
data = { deleted: { chapterId: 9, bookId: 1, remaining: chapters.length } }
}
await route.fulfill({ status, json: { code: 200, data } })
})
await page.goto('/')
await page.getByLabel('账号').fill(user.username)
await page.getByLabel('密码', { exact: true }).fill('fictional-password')
await page.getByRole('button', { name: '登录', exact: true }).click()
await page.getByRole('link', { name: 'A small step' }).click()
await expect(page.getByRole('heading', { name: 'A small step' })).toBeVisible()
await expect(page.getByText('封面使用系统默认样式')).toBeVisible()
// Rename the book through the dialog.
await page.getByTestId('edit-book').click()
await expect(page.getByLabel('书名', { exact: true })).toHaveValue('A small step')
await page.getByLabel('书名', { exact: true }).fill('A long step')
await page.getByTestId('save-book').click()
await expect(page.getByTestId('book-notice')).toContainText('书名已更新')
await expect(page.getByRole('heading', { name: 'A long step' })).toBeVisible()
// Edit the chapter text: the new version re-processes and later becomes ready again.
await page.getByTestId('edit-chapter-9').click()
await expect(page.getByLabel('章节标题', { exact: true })).toHaveValue('First chapter')
await expect(page.getByLabel('正文', { exact: true })).toHaveValue(firstText)
await page.getByLabel('正文', { exact: true }).fill('A replacement body.\n')
await page.getByTestId('save-chapter').click()
await expect(page.getByTestId('book-notice')).toContainText('已保存为新版本,正在重新处理')
// The saved version is queued first and becomes readable again when the worker finishes.
await expect(page.locator('.chapter-row').first()).toContainText('待处理')
await expect(page.locator('.chapter-row').first()).toContainText('已就绪', { timeout: 10000 })
// Deleting a chapter asks first, then reports the remaining count.
await page.getByTestId('edit-chapter-9').click()
await page.getByTestId('delete-chapter').click()
await expect(page.locator('.el-message-box__message').last()).toContainText('本章正文将被删除,已保存词条保留')
await page.locator('.el-message-box').last().getByRole('button', { name: '取消' }).click()
expect(requests.filter(entry => entry === 'DELETE /api/v1/chapters/9')).toHaveLength(0)
await page.getByTestId('delete-chapter').click()
await page.locator('.el-message-box').last().getByRole('button', { name: '确认删除章节' }).click()
await expect(page.getByTestId('book-notice')).toContainText('章节已删除 · 剩余 1 章')
await expect(page.locator('.chapter-row')).toHaveCount(1)
// Deleting the book asks first and returns to the library without it.
await page.getByTestId('delete-book').click()
await expect(page.locator('.el-message-box__message').last()).toContainText('已保存的生词和短语将保留')
await page.locator('.el-message-box').last().getByRole('button', { name: '确认删除' }).click()
await expect(page).toHaveURL(/\?deleted=\d+$/)
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
await expect(page.getByTestId('library-notice')).toContainText('书籍已删除 · 已保存的生词和短语仍保留在生词本')
await expect(page.getByRole('link', { name: 'A long step' })).toHaveCount(0)
})
+237
View File
@@ -0,0 +1,237 @@
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory, type Router } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import BookView from '../views/BookView.vue'
import { useLibraryStore, type ChapterSource, type ChapterSummary } from '../stores/library'
import { useSessionStore } from '../stores/session'
const user = { id: 42, username: 'fictional-editor', role: 'learner' as const }
const book = { id: 1, title: 'A small step', language: 'en' }
const timestamps = { createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' }
const chapter = (overrides: Partial<ChapterSummary> = {}): ChapterSummary => ({
id: 9, bookId: 1, ordinal: 1, title: 'First chapter', status: 'ready', charCount: 12,
errorReason: '', errorMessage: '', jobId: 5, ...timestamps, ...overrides,
})
const source: ChapterSource = { id: 9, bookId: 1, ordinal: 1, title: 'First chapter', text: 'Mira opened the workshop.\n', status: 'ready', contentSha256: 'sha-a', charCount: 24 }
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
const fail = (msg: string, status = 400) => new Response(JSON.stringify({ code: status, msg }), { status })
let wrapper: VueWrapper | undefined
function stub(name: string) {
return { template: `<div>${name}</div>` }
}
async function viewAt(path: string): Promise<Router> {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: stub('LibraryStub') },
{ path: '/books/:id', component: stub('BookStub') },
{ path: '/import', component: stub('ImportStub') },
],
})
await router.push(path)
await router.isReady()
return router
}
/** Routes the book page's own calls; a test can override any of them. */
function mockApi(overrides: Record<string, (init?: RequestInit) => Response | Promise<Response>> = {}): MockInstance {
return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const url = String(input)
const method = String((init as RequestInit | undefined)?.method ?? 'GET')
for (const [key, handler] of Object.entries(overrides)) {
if (url.includes(key)) return handler(init as RequestInit)
}
if (url.endsWith('/books/1') && method === 'GET') return ok({ book, chapters: [chapter()] })
if (url.includes('/chapters/9/source')) return ok({ source })
// A rename answers with the stored book, and a delete reports what it removed.
if (url.endsWith('/books/1') && method === 'PATCH') {
const body = JSON.parse(String((init as RequestInit | undefined)?.body ?? '{}')) as { title?: string }
return ok({ book: { ...book, title: body.title ?? book.title } })
}
if (url.endsWith('/books/1') && method === 'DELETE') return ok({ deleted: { bookId: 1, chapters: 3 } })
if (url.endsWith('/books/1')) return ok({ book })
if (url.includes('/chapters/9')) return ok({ chapter: chapter(), job: null, versionChanged: false })
return ok({})
})
}
async function openBook() {
useSessionStore().user = { ...user }
const router = await viewAt('/books/1')
wrapper = mount(BookView, { attachTo: document.body, global: { plugins: [router] } })
await flushPromises()
return { view: wrapper, router }
}
describe('book editing store', () => {
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear() })
afterEach(() => { vi.restoreAllMocks() })
it('renames a book and keeps the list entry in step', async () => {
const fetchMock = mockApi()
const library = useLibraryStore()
library.books = [{ ...book, chapterCount: 1, pendingCount: 0, processingCount: 0, readyCount: 1, failedCount: 0, ...timestamps }]
library.book = { ...book }
const renamed = await library.renameBook(1, ' New name ')
expect(renamed.title).toBe('New name')
expect(library.book?.title).toBe('New name')
expect(library.books[0]?.title).toBe('New name')
const call = fetchMock.mock.calls.find(([, init]) => (init as RequestInit | undefined)?.method === 'PATCH')!
expect(String(call[0])).toBe('/api/v1/books/1')
expect(JSON.parse(String((call[1] as RequestInit).body))).toEqual({ title: 'New name' })
})
it('refuses an invalid title before calling the API', async () => {
const fetchMock = mockApi()
const library = useLibraryStore()
await expect(library.renameBook(1, ' ')).rejects.toThrow('请填写标题')
expect(fetchMock).not.toHaveBeenCalled()
})
it('applies a new chapter version and drops the stale reader text', async () => {
mockApi({ '/chapters/9': () => ok({ chapter: chapter({ status: 'pending', title: 'Edited' }), job: { id: 6, bookId: 1, chapterId: 9, status: 'pending', attempts: 0, errorReason: '', errorMessage: '', ...timestamps }, versionChanged: true }) })
const library = useLibraryStore()
library.chapters = [chapter()]
library.chapter = { ...chapter(), contentSha256: 'sha-a', originalText: 'old text' }
const result = await library.updateChapter(9, { title: 'Edited', text: 'new text' })
expect(result.versionChanged).toBe(true)
expect(library.chapters[0]).toMatchObject({ status: 'pending', title: 'Edited', jobId: 6 })
// The reader must not keep showing text that is no longer the stored version.
expect(library.chapter?.originalText).toBeUndefined()
})
it('keeps the reader text when only the title changed', async () => {
mockApi({ '/chapters/9': () => ok({ chapter: chapter({ title: 'Renamed' }), job: null, versionChanged: false }) })
const library = useLibraryStore()
library.chapters = [chapter()]
library.chapter = { ...chapter(), contentSha256: 'sha-a', originalText: 'kept text' }
await library.updateChapter(9, { title: 'Renamed' })
expect(library.chapter?.originalText).toBe('kept text')
expect(library.chapter?.title).toBe('Renamed')
})
it('deletes a chapter, reloads the book and removes a deleted book from the list', async () => {
mockApi({
'/chapters/9': () => ok({ deleted: { chapterId: 9, bookId: 1, remaining: 2 } }),
'/books/1': (init) => (init?.method === 'DELETE'
? ok({ deleted: { bookId: 1, chapters: 3 } })
: ok({ book, chapters: [] })),
})
const library = useLibraryStore()
library.books = [{ ...book, chapterCount: 3, pendingCount: 0, processingCount: 0, readyCount: 3, failedCount: 0, ...timestamps }]
library.book = { ...book }
library.chapters = [chapter(), chapter({ id: 10, ordinal: 2 }), chapter({ id: 11, ordinal: 3 })]
const deleted = await library.deleteChapter(9)
expect(deleted.remaining).toBe(2)
expect(library.chapters.map(item => item.id)).toEqual([])
expect(library.book?.id).toBe(1)
const removed = await library.deleteBook(1)
expect(removed.chapters).toBe(3)
expect(library.books).toHaveLength(0)
expect(library.book).toBeNull()
})
it('reads the editable source of any chapter state', async () => {
mockApi({ '/chapters/9/source': () => ok({ source: { ...source, status: 'failed' } }) })
const library = useLibraryStore()
const loaded = await library.loadChapterSource(9)
expect(loaded.text).toBe(source.text)
expect(loaded.status).toBe('failed')
})
})
describe('book editing view', () => {
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear(); vi.restoreAllMocks() })
afterEach(() => { wrapper?.unmount(); wrapper = undefined; useLibraryStore().stopPolling() })
it('renames the book through the dialog and reports it', async () => {
const fetchMock = mockApi()
const { view } = await openBook()
await view.get('[data-testid="edit-book"]').trigger('click'); await flushPromises()
const input = view.get('#book-title')
expect((input.element as HTMLInputElement).value).toBe('A small step')
await input.setValue(' Edited name ')
await view.get('[data-testid="save-book"]').trigger('click'); await flushPromises()
const patch = fetchMock.mock.calls.find(([, init]) => (init as RequestInit | undefined)?.method === 'PATCH')!
expect(JSON.parse(String((patch[1] as RequestInit).body))).toEqual({ title: 'Edited name' })
expect(view.get('[data-testid="book-notice"]').text()).toContain('书名已更新')
})
it('keeps the dialog open with the server message when renaming fails', async () => {
mockApi({ '/books/1': (init) => (init?.method === 'PATCH' ? fail('书名已存在') : ok({ book, chapters: [chapter()] })) })
const { view } = await openBook()
await view.get('[data-testid="edit-book"]').trigger('click'); await flushPromises()
await view.get('#book-title').setValue('Rejected')
await view.get('[data-testid="save-book"]').trigger('click'); await flushPromises()
expect(view.text()).toContain('书名已存在')
expect(view.get('#book-title')).toBeTruthy()
})
it('asks before deleting the book and does nothing when the learner cancels', async () => {
const fetchMock = mockApi()
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockRejectedValue('cancel')
const { view } = await openBook()
await view.get('[data-testid="delete-book"]').trigger('click'); await flushPromises()
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('已保存的生词和短语将保留'), '删除书籍', expect.anything())
expect(fetchMock.mock.calls.some(([, init]) => (init as RequestInit | undefined)?.method === 'DELETE')).toBe(false)
})
it('deletes the book after confirmation and returns to the library', async () => {
const fetchMock = mockApi()
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never)
const { view, router } = await openBook()
await view.get('[data-testid="delete-book"]').trigger('click'); await flushPromises()
expect(fetchMock.mock.calls.some(([url, init]) => String(url).endsWith('/books/1') && (init as RequestInit | undefined)?.method === 'DELETE')).toBe(true)
expect(router.currentRoute.value.path).toBe('/')
expect(router.currentRoute.value.query.deleted).toBe('3')
})
it('edits a chapter: loads the source, saves title and text and reports the new version', async () => {
const fetchMock = mockApi({ '/chapters/9': (init) => (init?.method === 'PATCH'
? ok({ chapter: chapter({ status: 'pending', title: 'Edited chapter' }), job: { id: 6, bookId: 1, chapterId: 9, status: 'pending', attempts: 0, errorReason: '', errorMessage: '', ...timestamps }, versionChanged: true })
: ok({ source })) })
const { view } = await openBook()
await view.get('[data-testid="edit-chapter-9"]').trigger('click'); await flushPromises()
expect((view.get('#chapter-title').element as HTMLInputElement).value).toBe('First chapter')
expect((view.get('#chapter-text').element as HTMLTextAreaElement).value).toBe(source.text)
await view.get('#chapter-title').setValue('Edited chapter')
await view.get('#chapter-text').setValue('A new body.\n')
await view.get('[data-testid="save-chapter"]').trigger('click'); await flushPromises()
const patch = fetchMock.mock.calls.find(([url, init]) => String(url).endsWith('/chapters/9') && (init as RequestInit | undefined)?.method === 'PATCH')!
expect(JSON.parse(String((patch[1] as RequestInit).body))).toEqual({ title: 'Edited chapter', text: 'A new body.\n' })
expect(view.get('[data-testid="book-notice"]').text()).toContain('已保存为新版本,正在重新处理')
})
it('refuses an empty chapter body locally and keeps the dialog', async () => {
const fetchMock = mockApi()
const { view } = await openBook()
await view.get('[data-testid="edit-chapter-9"]').trigger('click'); await flushPromises()
await view.get('#chapter-text').setValue(' \n\t ')
await view.get('[data-testid="save-chapter"]').trigger('click'); await flushPromises()
expect(view.text()).toContain('请粘贴要导入的英文正文。')
expect(fetchMock.mock.calls.some(([, init]) => (init as RequestInit | undefined)?.method === 'PATCH')).toBe(false)
})
it('deletes a chapter after confirmation and reports the remaining count', async () => {
const fetchMock = mockApi({ '/chapters/9': (init) => (init?.method === 'DELETE' ? ok({ deleted: { chapterId: 9, bookId: 1, remaining: 2 } }) : ok({ source })) })
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never)
const { view } = await openBook()
await view.get('[data-testid="edit-chapter-9"]').trigger('click'); await flushPromises()
await view.get('[data-testid="delete-chapter"]').trigger('click'); await flushPromises()
expect(fetchMock.mock.calls.some(([url, init]) => String(url).endsWith('/chapters/9') && (init as RequestInit | undefined)?.method === 'DELETE')).toBe(true)
expect(view.get('[data-testid="book-notice"]').text()).toContain('章节已删除 · 剩余 2 章')
// Element Plus keeps a closed dialog in the DOM, so the closed state is what matters.
expect(view.get('[data-testid="chapter-dialog"]').isVisible()).toBe(false)
})
it('shows an empty chapter list with the import hint', async () => {
mockApi({ '/books/1': () => ok({ book, chapters: [] }) })
const { view } = await openBook()
expect(view.get('[data-testid="empty-chapters"]').text()).toContain('这一本书还没有章节')
expect(view.get('[data-testid="delete-book"]')).toBeTruthy()
})
})
+90
View File
@@ -51,6 +51,21 @@ export interface Job {
export interface ChapterNavigation { previousChapterId: number | null; nextChapterId: number | null }
/** The editable text of one owned chapter; separate from the ready-only reader payload. */
export interface ChapterSource {
id: number
bookId: number
ordinal: number
title: string
text: string
status: ChapterStatus
contentSha256: string
charCount: number
}
export interface ChapterEdit { chapter: ChapterSummary; job: Job | null; versionChanged: boolean }
export interface DeletionResult { bookId?: number; chapterId?: number; chapters?: number; remaining: number }
export type SubmitTarget = { mode: 'new' } | { mode: 'append'; bookId: number }
export interface SubmitInput { title: string; text: string; target: SubmitTarget }
@@ -429,6 +444,80 @@ export const useLibraryStore = defineStore('library', () => {
}
}
/** Renames one owned book; the reply is the stored book. */
async function renameBook(id: number, title: string): Promise<BookRef> {
const version = generation
const owner = ownerId()
const problem = titleProblem(title)
if (problem) throw new Error(problem)
const result = await session.request<{ book: BookRef }>(`books/${id}`, 'PATCH', { title: title.trim() })
if (!isStale(version, owner)) {
book.value = result.book
const listed = books.value.find(item => item.id === id)
if (listed) listed.title = result.book.title
}
return result.book
}
/**
* Saves a chapter title and/or text. A changed text becomes a new version and re-processes;
* the same text answered again changes nothing.
*/
async function updateChapter(id: number, input: { title?: string; text?: string }): Promise<ChapterEdit> {
const version = generation
const owner = ownerId()
if (input.title !== undefined) {
const problem = titleProblem(input.title)
if (problem) throw new Error(problem)
}
if (input.text !== undefined) {
const problem = textProblem(input.text)
if (problem) throw new Error(problem)
}
const body: { title?: string; text?: string } = {}
if (input.title !== undefined) body.title = input.title.trim()
if (input.text !== undefined) body.text = input.text
const result = await session.request<ChapterEdit>(`chapters/${id}`, 'PATCH', body)
if (isStale(version, owner)) return result
const merged = { ...result.chapter, jobId: result.job?.id ?? result.chapter.jobId }
applyChapterSummary(merged)
if (chapter.value !== null && chapter.value.id === id && result.versionChanged) {
// The new version is not readable yet, so the reader must drop the previous text.
chapter.value = { ...chapter.value, ...merged, originalText: undefined }
}
schedulePolling()
return result
}
/** Reads the editable text of one owned chapter, in any processing state. */
async function loadChapterSource(id: number): Promise<ChapterSource> {
const result = await session.request<{ source: ChapterSource }>(`chapters/${id}/source`)
return result.source
}
/** Deletes one owned book with its chapters and jobs; personal records stay. */
async function deleteBook(id: number): Promise<DeletionResult> {
const version = generation
const owner = ownerId()
const result = await session.request<{ deleted: DeletionResult }>(`books/${id}`, 'DELETE')
if (!isStale(version, owner)) {
books.value = books.value.filter(item => item.id !== id)
if (book.value?.id === id) closeBook()
}
return result.deleted
}
/** Deletes one owned chapter and closes the gap in the chapter order. */
async function deleteChapter(id: number): Promise<DeletionResult> {
const version = generation
const owner = ownerId()
const result = await session.request<{ deleted: DeletionResult }>(`chapters/${id}`, 'DELETE')
if (isStale(version, owner)) return result.deleted
chapters.value = chapters.value.filter(item => item.id !== id)
if (book.value !== null) await loadBook(book.value.id, { silent: true })
return result.deleted
}
/** The job id comes from the chapter itself, wherever that chapter was loaded from. */
function jobIdOf(chapterId: number): number | null {
const target = chapters.value.find(item => item.id === chapterId)
@@ -526,6 +615,7 @@ export const useLibraryStore = defineStore('library', () => {
chapter, chapterBook, navigation, chapterLoading, chapterError,
submitting, submitError, retryingChapterId, readerText,
loadBooks, loadBook, loadChapter, submit, upload, retryChapter,
renameBook, updateChapter, loadChapterSource, deleteBook, deleteChapter,
stopPolling, closeBook, closeChapter, reset,
}
})
+2
View File
@@ -128,6 +128,8 @@ a.chapter-name:hover { color: #315c43; text-decoration: underline; }
.lookup-saved { color: #2f6b45; font-size: 14px; margin: 12px 0 0; }
.lookup-actions { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
.review-page { max-width: 680px; }
.chapter-notice { margin: 12px 0 0; padding: 10px 14px; border: 1px solid #d9decf; border-radius: 8px; background: #fbf7ee; color: #6b5b3e; }
.chapter-list .chapter-row { flex-wrap: wrap; }
.review-notice { margin: 10px 0 0; padding: 10px 14px; border: 1px solid #d9decf; border-radius: 8px; background: #fbf7ee; color: #6b5b3e; }
.review-card, .review-summary { margin-top: 26px; padding: 28px; border: 1px solid #d9decf; border-radius: 14px; background: #fffdf8; }
.review-summary h2 { margin-top: 0; font-family: Georgia, serif; font-size: 24px; }
+148 -4
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { ElButton } from 'element-plus'
import { canRetry, statusLabel, useLibraryStore } from '../stores/library'
import { ElButton, ElDialog, ElInput, ElMessageBox } from 'element-plus'
import { canRetry, statusLabel, TEXT_MAX_CODE_POINTS, textProblem, titleProblem, useLibraryStore, type ChapterSource } from '../stores/library'
import { useSessionStore } from '../stores/session'
const session = useSessionStore()
@@ -10,8 +10,23 @@ const library = useLibraryStore()
const route = useRoute()
const router = useRouter()
const retryError = ref('')
const notice = ref('')
// Renaming the book.
const bookDialog = ref(false)
const bookTitle = ref('')
const bookError = ref('')
// Editing one chapter: title and text, loaded from the source endpoint.
const chapterDialog = ref(false)
const chapterSource = ref<ChapterSource | null>(null)
const chapterTitle = ref('')
const chapterText = ref('')
const chapterError = ref('')
const chapterLoading = ref(false)
const saving = ref(false)
const bookId = computed(() => Number(route.params.id))
const chapterLength = computed(() => [...chapterText.value].length)
async function load() {
retryError.value = ''
@@ -24,6 +39,95 @@ async function retry(chapterId: number) {
catch (reason) { retryError.value = reason instanceof Error ? reason.message : '重试失败,请稍后重试。' }
}
function openBookDialog() {
bookTitle.value = library.book?.title ?? ''
bookError.value = ''
bookDialog.value = true
}
async function saveBookTitle() {
if (saving.value || library.book === null) return
bookError.value = titleProblem(bookTitle.value)
if (bookError.value) return
saving.value = true
try {
await library.renameBook(library.book.id, bookTitle.value)
bookDialog.value = false
notice.value = '书名已更新。'
} catch (reason) {
bookError.value = reason instanceof Error ? reason.message : '保存失败,请稍后重试。'
} finally {
saving.value = false
}
}
async function openChapterDialog(chapterId: number) {
chapterDialog.value = true
chapterLoading.value = true
chapterError.value = ''
chapterSource.value = null
try {
const source = await library.loadChapterSource(chapterId)
chapterSource.value = source
chapterTitle.value = source.title
chapterText.value = source.text
} catch (reason) {
chapterError.value = reason instanceof Error ? reason.message : '章节内容暂时无法加载,请稍后重试。'
} finally {
chapterLoading.value = false
}
}
async function saveChapter() {
const source = chapterSource.value
if (saving.value || source === null) return
chapterError.value = titleProblem(chapterTitle.value) || textProblem(chapterText.value)
if (chapterError.value) return
saving.value = true
try {
const edited = await library.updateChapter(source.id, { title: chapterTitle.value, text: chapterText.value })
chapterDialog.value = false
notice.value = edited.versionChanged ? '章节已保存为新版本,正在重新处理。' : '章节已保存。'
} catch (reason) {
chapterError.value = reason instanceof Error ? reason.message : '保存失败,请稍后重试。'
} finally {
saving.value = false
}
}
async function confirmDeleteBook() {
if (library.book === null) return
const id = library.book.id
try {
await ElMessageBox.confirm('删除这本书及其章节?已保存的生词和短语将保留。', '删除书籍', {
confirmButtonText: '确认删除', cancelButtonText: '取消,保留书籍', type: 'warning',
})
} catch { return }
try {
const deleted = await library.deleteBook(id)
await router.replace(`/?deleted=${deleted.chapters ?? 0}`)
} catch (reason) {
retryError.value = reason instanceof Error ? reason.message : '删除失败,请稍后重试。'
}
}
async function confirmDeleteChapter(chapterId: number, ordinal: number) {
try {
await ElMessageBox.confirm(`删除第 ${ordinal} 章?本章正文将被删除,已保存词条保留。`, '删除章节', {
confirmButtonText: '确认删除章节', cancelButtonText: '取消', type: 'warning',
})
} catch { return }
try {
const deleted = await library.deleteChapter(chapterId)
// The chapter no longer exists, so the dialog closes and the list reports the remainder.
chapterDialog.value = false
chapterSource.value = null
notice.value = `章节已删除 · 剩余 ${deleted.remaining} 章`
} catch (reason) {
retryError.value = reason instanceof Error ? reason.message : '删除失败,请稍后重试。'
}
}
async function logout() {
try { await session.logout() }
catch { session.notice = '已退出此设备。服务器暂时无法连接,请稍后重试。' }
@@ -57,12 +161,15 @@ onUnmounted(() => library.closeBook())
<div class="page-title">
<div>
<h1>{{ library.book.title }}</h1>
<p class="subtle">{{ library.chapters.length }} 个章节 · 语言 英语</p>
<p class="subtle">{{ library.chapters.length }} 个章节 · 语言 英语 · 封面使用系统默认样式</p>
</div>
<div class="page-actions">
<ElButton data-testid="edit-book" @click="openBookDialog">编辑书名</ElButton>
<RouterLink :to="`/import?book=${library.book.id}`" class="link-button">追加章节</RouterLink>
<ElButton type="danger" plain data-testid="delete-book" @click="confirmDeleteBook">删除书籍</ElButton>
</div>
</div>
<p v-if="notice" role="status" class="chapter-notice" data-testid="book-notice">{{ notice }}</p>
<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">
@@ -82,13 +189,50 @@ onUnmounted(() => library.closeBook())
:loading="library.retryingChapterId === item.id"
@click="retry(item.id)"
>重试</ElButton>
<ElButton size="small" :data-testid="`edit-chapter-${item.id}`" @click="openChapterDialog(item.id)">编辑</ElButton>
</li>
</ul>
<section v-else class="empty-library" aria-label="章节列表">
<section v-else class="empty-library" aria-label="章节列表" data-testid="empty-chapters">
<h2>这一本书还没有章节</h2>
<p class="subtle">粘贴一段英文即可生成第一章。</p>
</section>
</template>
<ElDialog v-model="bookDialog" title="编辑书名" width="420" data-testid="book-dialog">
<label for="book-title">书名</label>
<ElInput id="book-title" v-model="bookTitle" type="text" maxlength="200" :disabled="saving" />
<p class="subtle">封面使用系统默认样式。</p>
<p v-if="bookError" role="alert" class="field-error">{{ bookError }}</p>
<template #footer>
<ElButton @click="bookDialog = false">取消</ElButton>
<ElButton type="primary" :loading="saving" data-testid="save-book" @click="saveBookTitle">保存修改</ElButton>
</template>
</ElDialog>
<ElDialog v-model="chapterDialog" title="编辑章节" width="620" data-testid="chapter-dialog">
<p v-if="chapterLoading" role="status" class="loading">正在加载…</p>
<template v-else-if="chapterSource">
<label for="chapter-title">章节标题</label>
<ElInput id="chapter-title" v-model="chapterTitle" type="text" maxlength="200" :disabled="saving" />
<label for="chapter-text">正文</label>
<ElInput id="chapter-text" v-model="chapterText" type="textarea" :rows="12" :disabled="saving" />
<p class="counter">{{ chapterLength }} / {{ TEXT_MAX_CODE_POINTS }} 字符</p>
<p class="subtle">保存后会重新处理这一章;只改标题不会重新处理。</p>
</template>
<p v-if="chapterError" role="alert" class="field-error">{{ chapterError }}</p>
<template #footer>
<ElButton data-testid="cancel-chapter" @click="chapterDialog = false">取消</ElButton>
<ElButton
v-if="chapterSource"
type="danger"
plain
:disabled="saving"
data-testid="delete-chapter"
@click="confirmDeleteChapter(chapterSource.id, chapterSource.ordinal)"
>删除章节</ElButton>
<ElButton type="primary" :loading="saving" :disabled="chapterLoading || !chapterSource" data-testid="save-chapter" @click="saveChapter">保存章节</ElButton>
</template>
</ElDialog>
</main>
</div>
</template>
+10 -2
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { computed, onMounted, ref } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { ElButton } from 'element-plus'
import BookMark from '../components/BookMark.vue'
import { statusSummary, useLibraryStore } from '../stores/library'
@@ -8,8 +8,15 @@ import { useSessionStore } from '../stores/session'
const session = useSessionStore()
const library = useLibraryStore()
const router = useRouter()
const route = useRoute()
const loading = ref(true)
const error = ref('')
// A deletion returns here with the count it removed, so the learner sees what happened.
const deletedNotice = computed(() => {
const raw = Array.isArray(route.query.deleted) ? route.query.deleted[0] : route.query.deleted
if (typeof raw !== 'string' || !/^\d+$/.test(raw)) return ''
return '书籍已删除 · 已保存的生词和短语仍保留在生词本。'
})
async function load() {
loading.value = true
error.value = ''
@@ -45,6 +52,7 @@ onMounted(load)
<ElButton type="primary" @click="router.push('/import')">导入内容</ElButton>
</div>
</div>
<p v-if="deletedNotice" role="status" class="chapter-notice" data-testid="library-notice">{{ deletedNotice }}</p>
<p v-if="loading" role="status" class="loading">正在加载…</p>
<div v-else-if="error || library.booksError" class="notice">
<p role="alert">{{ error || library.booksError }}</p>
+286
View File
@@ -0,0 +1,286 @@
package lexgo
import (
"errors"
"fmt"
"strings"
"time"
"unicode/utf8"
"github.com/gin-gonic/gin"
admin "go-admin/app/admin/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// Editing and deleting are strictly owner scoped. Nothing here touches personal terms,
// review rows or answers: those belong to the learner, not to a book or a chapter.
type BookUpdateInput struct {
Title string `json:"title"`
}
type ChapterUpdateInput struct {
Title *string `json:"title"`
Text *string `json:"text"`
}
// ChapterSource is the editable text of one owned chapter. It is separate from the reader
// contract, which only exposes text once a chapter is ready, so a failed chapter can be
// corrected and submitted again.
type ChapterSource struct {
ID int64 `json:"id"`
BookID int64 `json:"bookId"`
Ordinal int `json:"ordinal"`
Title string `json:"title"`
Text string `json:"text"`
Status string `json:"status"`
ContentSHA256 string `json:"contentSha256"`
CharCount int `json:"charCount"`
}
type ChapterEdit struct {
Chapter ChapterSummary `json:"chapter"`
Job *JobView `json:"job"`
VersionChanged bool `json:"versionChanged"`
}
type DeletionResult struct {
BookID int64 `json:"bookId,omitempty"`
ChapterID int64 `json:"chapterId,omitempty"`
Chapters int `json:"chapters,omitempty"`
Remaining int `json:"remaining"`
}
// editTitle validates a title with the same rules a paste uses, so a renamed book or chapter
// stays within the limits the list and reader already rely on.
func editTitle(raw string) (string, error) {
title := strings.TrimSpace(raw)
if title == "" {
return "", failure(400, "请填写标题")
}
if utf8.RuneCountInString(title) > maxTitleRunes {
return "", failure(400, "标题最多 120 个字符")
}
return title, nil
}
// lockOwnedChapter returns the caller's chapter or reports it as missing, so another
// account's chapter id is never confirmed to exist.
func lockOwnedChapter(tx *gorm.DB, owner int, chapterID int64, chapter *Chapter) error {
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND owner_id = ?", chapterID, owner).First(chapter).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return failure(404, "章节不存在")
}
return err
}
func RenameBook(tx *gorm.DB, owner int, bookID int64, input BookUpdateInput, now time.Time) (BookRef, error) {
title, err := editTitle(input.Title)
if err != nil {
return BookRef{}, err
}
var book Book
if err = lockOwnedBook(tx, owner, bookID, &book); err != nil {
return BookRef{}, err
}
book.Title = title
book.UpdatedAt = stamp(now)
if err = tx.Model(&Book{}).Where("id = ? AND owner_id = ?", book.ID, owner).
Updates(map[string]any{"title": title, "updated_at": book.UpdatedAt}).Error; err != nil {
return BookRef{}, err
}
return bookRef(book), nil
}
func ChapterEditSource(tx *gorm.DB, owner int, chapterID int64) (ChapterSource, error) {
var chapter Chapter
if err := tx.Where("id = ? AND owner_id = ?", chapterID, owner).First(&chapter).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ChapterSource{}, failure(404, "章节不存在")
}
return ChapterSource{}, err
}
return ChapterSource{
ID: chapter.ID, BookID: chapter.BookID, Ordinal: chapter.Ordinal, Title: chapter.Title,
Text: chapter.OriginalText, Status: chapter.Status, ContentSHA256: chapter.ContentSHA256,
CharCount: chapter.CharCount,
}, nil
}
// UpdateChapter renames a chapter and, when the text really changed, stores it as a new
// version and queues a job for it. Only a changed version re-processes: a repeated save of
// the same text is idempotent, and a title-only edit never touches the processing state.
func UpdateChapter(tx *gorm.DB, owner int, chapterID int64, input ChapterUpdateInput, now time.Time) (ChapterEdit, error) {
if input.Title == nil && input.Text == nil {
return ChapterEdit{}, failure(400, "请选择要修改的内容")
}
var chapter Chapter
if err := lockOwnedChapter(tx, owner, chapterID, &chapter); err != nil {
return ChapterEdit{}, err
}
ts := stamp(now)
updates := map[string]any{"updated_at": ts}
if input.Title != nil {
title, err := editTitle(*input.Title)
if err != nil {
return ChapterEdit{}, err
}
chapter.Title = title
updates["title"] = title
}
var job *IngestJob
if input.Text != nil {
text := *input.Text
if _, sha, count, err := validatePaste(chapter.Title, text); err != nil {
return ChapterEdit{}, err
} else if sha != chapter.ContentSHA256 {
// A new version replaces the text and owns the chapter's state from here on.
chapter.OriginalText, chapter.ContentSHA256, chapter.CharCount = text, sha, count
chapter.Status, chapter.ErrorReason = statusPending, ""
updates["original_text"] = text
updates["content_sha256"] = sha
updates["char_count"] = count
updates["status"] = statusPending
updates["error_reason"] = ""
// The request key is derived from chapter and version, so one version has one job.
key := contentSHA(fmt.Sprintf("edit:%d:%s", chapter.ID, sha))
created := IngestJob{OwnerID: owner, BookID: chapter.BookID, ChapterID: chapter.ID,
RequestKey: key, ContentSHA256: sha, Status: statusPending, CreatedAt: ts, UpdatedAt: ts}
if err := tx.Create(&created).Error; err != nil {
return ChapterEdit{}, err
}
job = &created
}
}
if err := tx.Model(&Chapter{}).Where("id = ? AND owner_id = ?", chapter.ID, owner).Updates(updates).Error; err != nil {
return ChapterEdit{}, err
}
result := ChapterEdit{VersionChanged: job != nil}
if job != nil {
view := jobView(*job)
result.Job = &view
}
jobID := int64(0)
if job != nil {
jobID = job.ID
}
result.Chapter = chapterSummaryWithJob(chapter, &jobID)
return result, nil
}
// DeleteBook removes the caller's book with its chapters and their jobs in one transaction.
// Personal terms, review schedules and answers are not touched: they belong to the learner.
func DeleteBook(tx *gorm.DB, owner int, bookID int64) (DeletionResult, error) {
var book Book
if err := lockOwnedBook(tx, owner, bookID, &book); err != nil {
return DeletionResult{}, err
}
var chapters int64
if err := tx.Model(&Chapter{}).Where("book_id = ? AND owner_id = ?", book.ID, owner).Count(&chapters).Error; err != nil {
return DeletionResult{}, err
}
// Chapters and their jobs go with the book through the foreign keys.
if err := tx.Where("id = ? AND owner_id = ?", book.ID, owner).Delete(&Book{}).Error; err != nil {
return DeletionResult{}, err
}
return DeletionResult{BookID: book.ID, Chapters: int(chapters)}, nil
}
// DeleteChapter removes one owned chapter with its jobs and closes the gap in the chapter
// order, so "剩余 N 章" and the reader navigation stay contiguous.
func DeleteChapter(tx *gorm.DB, owner int, chapterID int64) (DeletionResult, error) {
var chapter Chapter
if err := lockOwnedChapter(tx, owner, chapterID, &chapter); err != nil {
return DeletionResult{}, err
}
// Lock the book too: two concurrent deletions in one book must not renumber each other.
var book Book
if err := lockOwnedBook(tx, owner, chapter.BookID, &book); err != nil {
return DeletionResult{}, err
}
if err := tx.Where("id = ? AND owner_id = ?", chapter.ID, owner).Delete(&Chapter{}).Error; err != nil {
return DeletionResult{}, err
}
// Ordering ascending decrements each row into a slot the previous row just freed, which
// keeps the unique (book_id, ordinal) key satisfied throughout.
if err := tx.Exec("UPDATE lexgo_chapters SET ordinal = ordinal - 1 WHERE book_id = ? AND owner_id = ? AND ordinal > ? ORDER BY ordinal ASC",
chapter.BookID, owner, chapter.Ordinal).Error; err != nil {
return DeletionResult{}, err
}
var remaining int64
if err := tx.Model(&Chapter{}).Where("book_id = ? AND owner_id = ?", chapter.BookID, owner).Count(&remaining).Error; err != nil {
return DeletionResult{}, err
}
return DeletionResult{ChapterID: chapter.ID, BookID: chapter.BookID, Remaining: int(remaining)}, nil
}
func registerEditRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context, *gorm.DB, admin.SysUser) (any, error)) gin.HandlerFunc, now func() time.Time) {
v.PATCH("/books/:id", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
id, err := pathID(c, "书籍不存在")
if err != nil {
return nil, err
}
var input BookUpdateInput
if err = decode(c, &input); err != nil {
return nil, err
}
book, err := RenameBook(tx, u.UserId, id, input, now())
if err != nil {
return nil, err
}
return gin.H{"book": book}, nil
}))
v.DELETE("/books/:id", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
id, err := pathID(c, "书籍不存在")
if err != nil {
return nil, err
}
result, err := DeleteBook(tx, u.UserId, id)
if err != nil {
return nil, err
}
return gin.H{"deleted": result}, nil
}))
v.GET("/chapters/:id/source", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
if c.Request.URL.RawQuery != "" {
return nil, failure(400, "正文接口不接受查询参数")
}
id, err := pathID(c, "章节不存在")
if err != nil {
return nil, err
}
source, err := ChapterEditSource(tx, u.UserId, id)
if err != nil {
return nil, err
}
return gin.H{"source": source}, nil
}))
v.PATCH("/chapters/:id", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
id, err := pathID(c, "章节不存在")
if err != nil {
return nil, err
}
var input ChapterUpdateInput
if err = decode(c, &input); err != nil {
return nil, err
}
edited, err := UpdateChapter(tx, u.UserId, id, input, now())
if err != nil {
return nil, err
}
return edited, nil
}))
v.DELETE("/chapters/:id", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
id, err := pathID(c, "章节不存在")
if err != nil {
return nil, err
}
result, err := DeleteChapter(tx, u.UserId, id)
if err != nil {
return nil, err
}
return gin.H{"deleted": result}, nil
}))
}
+543
View File
@@ -0,0 +1,543 @@
package lexgo
import (
"encoding/json"
"fmt"
"strings"
"sync"
"testing"
"time"
"github.com/gin-gonic/gin"
)
const editFixtureText = "Mira opened the workshop.\nThe sign read “A small step…”\n"
func TestEditTitleRules(t *testing.T) {
if title, err := editTitle(" A small step "); err != nil || title != "A small step" {
t.Fatalf("trimmed title: %q %v", title, err)
}
if _, err := editTitle(" "); err == nil {
t.Fatal("an empty title must be rejected")
}
if _, err := editTitle(strings.Repeat("a", maxTitleRunes+1)); err == nil {
t.Fatal("a title over the limit must be rejected")
}
if _, err := editTitle(strings.Repeat("a", maxTitleRunes)); err != nil {
t.Fatalf("the exact title limit must be accepted: %v", err)
}
}
func patchResource(t *testing.T, r *gin.Engine, token, path string, body any) (int, string, json.RawMessage) {
t.Helper()
return callRaw(t, r, "PATCH", path, token, body)
}
func existingTitle(t *testing.T, r *gin.Engine, token string, bookID int64) string {
t.Helper()
code, detail := bookDetail(t, r, token, bookID)
if code != 200 {
t.Fatalf("book detail status %d", code)
}
return detail.Book.Title
}
// TestMySQLRenameBookAndChapter covers renaming only: the text and the processing state stay
// exactly as they were.
func TestMySQLRenameBookAndChapter(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
other := newLearner(t, r, owner.Token)
drainIngest(t, db)
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "rename-fixture-0001", "title": "Before Rename", "text": editFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste status %d", code)
}
drainIngest(t, db)
before := chapterRow(t, db, pasted.Chapter.ID)
code, msg, data := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d", pasted.Book.ID), map[string]string{"title": " After Rename "})
if code != 200 {
t.Fatalf("rename book status %d (%s)", code, msg)
}
var renamed struct {
Book struct {
ID int64
Title string
}
}
json.Unmarshal(data, &renamed)
if renamed.Book.Title != "After Rename" || existingTitle(t, r, learner.Token, pasted.Book.ID) != "After Rename" {
t.Fatalf("renamed book %+v", renamed.Book)
}
code, msg, data = patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), map[string]string{"title": "Chapter Two"})
if code != 200 {
t.Fatalf("rename chapter status %d (%s)", code, msg)
}
var edited ChapterEdit
json.Unmarshal(data, &edited)
if edited.Chapter.Title != "Chapter Two" || edited.VersionChanged || edited.Job != nil {
t.Fatalf("rename must not re-process: %+v", edited)
}
after := chapterRow(t, db, pasted.Chapter.ID)
if after.Title != "Chapter Two" || after.Status != statusReady || after.ContentSHA256 != before.ContentSHA256 || after.OriginalText != before.OriginalText {
t.Fatalf("rename changed the content state: %+v", after)
}
var jobs int64
db.Model(&IngestJob{}).Where("chapter_id = ?", pasted.Chapter.ID).Count(&jobs)
if jobs != 1 {
t.Fatalf("a rename created %d jobs", jobs)
}
// Validation and ownership.
for _, body := range []map[string]string{{"title": " "}, {"title": strings.Repeat("a", maxTitleRunes+1)}} {
if code, _, _ := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d", pasted.Book.ID), body); code != 400 {
t.Fatalf("invalid rename %v accepted", body)
}
}
if code, _, _ := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d", pasted.Book.ID), map[string]any{"title": "x", "ownerId": 9}); code != 400 {
t.Fatal("an unknown field must be rejected")
}
if code, _, _ := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d", pasted.Book.ID), map[string]any{"name": "x"}); code != 400 {
t.Fatal("a missing title must be rejected")
}
if code, _, _ := patchResource(t, r, other.Token, fmt.Sprintf("/api/v1/books/%d", pasted.Book.ID), map[string]string{"title": "Stolen"}); code != 404 {
t.Fatal("another account must not rename this book")
}
if code, _, _ := patchResource(t, r, other.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), map[string]string{"title": "Stolen"}); code != 404 {
t.Fatal("another account must not rename this chapter")
}
if code, _, _ := patchResource(t, r, "", fmt.Sprintf("/api/v1/books/%d", pasted.Book.ID), map[string]string{"title": "Anonymous"}); code != 401 {
t.Fatal("renaming requires a session")
}
if title := existingTitle(t, r, learner.Token, pasted.Book.ID); title != "After Rename" {
t.Fatalf("a rejected rename changed the book: %q", title)
}
}
// TestMySQLChapterEditVersioning is the core of the ticket: an edit creates a new version, and
// the older run must not fail or publish over it.
func TestMySQLChapterEditVersioning(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
drainIngest(t, db)
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "edit-fixture-0001", "title": "Editable", "text": editFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste status %d", code)
}
// The first run is claimed but not finished, so the edit lands while it is in flight.
clock := time.Now().UTC().Truncate(time.Millisecond)
job, claimed, err := ClaimNextIngestJob(db, clock)
if err != nil || !claimed || job.ChapterID != pasted.Chapter.ID {
t.Fatalf("claim (claimed=%v): %v", claimed, err)
}
newText := "Mira reopened the workshop.\r\n\r\nA newer version of the text.\n"
code, msg, data := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), map[string]string{"text": newText})
if code != 200 {
t.Fatalf("edit status %d (%s)", code, msg)
}
var edited ChapterEdit
json.Unmarshal(data, &edited)
if !edited.VersionChanged || edited.Job == nil || edited.Chapter.Status != statusPending {
t.Fatalf("edit must queue a new version: %+v", edited)
}
row := chapterRow(t, db, pasted.Chapter.ID)
if row.OriginalText != newText || row.ContentSHA256 != contentSHA(newText) || row.Status != statusPending || row.CharCount != len([]rune(newText)) {
t.Fatalf("stored version: %+v", row)
}
if row.ID != pasted.Chapter.ID {
t.Fatal("an edit must keep the chapter id, so the reading entry stays the same")
}
// The in-flight run belongs to the previous version: it must not touch the chapter.
if err := FinishIngestJob(t.Context(), db, job, clock.Add(time.Second)); err != nil {
t.Fatalf("finishing the older run must not fail: %v", err)
}
var stale IngestJob
if err := db.First(&stale, job.ID).Error; err != nil {
t.Fatal(err)
}
if stale.Status != statusFailed || stale.ErrorReason != reasonSuperseded {
t.Fatalf("the older run must be marked superseded: %+v", stale)
}
if row = chapterRow(t, db, pasted.Chapter.ID); row.Status != statusPending || row.ErrorReason != "" || row.OriginalText != newText {
t.Fatalf("the older run changed the newer version: %+v", row)
}
// Retrying the superseded job is refused instead of reprocessing old text.
code, _, _ = callRaw(t, r, "POST", fmt.Sprintf("/api/v1/jobs/%d/retry", stale.ID), learner.Token, nil)
if code != 409 {
t.Fatalf("retrying a superseded job: %d", code)
}
// The new version publishes normally and the reader shows the new text.
drainIngest(t, db)
code, reader := readChapter(t, r, learner.Token, pasted.Chapter.ID)
if code != 200 || reader.Chapter.Status != statusReady || reader.Chapter.OriginalText != newText {
t.Fatalf("new version: status %d, %+v", code, reader.Chapter)
}
// Saving the same text again is not a new version.
var jobsBefore int64
db.Model(&IngestJob{}).Where("chapter_id = ?", pasted.Chapter.ID).Count(&jobsBefore)
code, _, data = patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), map[string]string{"text": newText})
json.Unmarshal(data, &edited)
if code != 200 || edited.VersionChanged || edited.Job != nil {
t.Fatalf("an unchanged text must not create a version: %+v", edited)
}
var jobsAfter int64
db.Model(&IngestJob{}).Where("chapter_id = ?", pasted.Chapter.ID).Count(&jobsAfter)
if jobsAfter != jobsBefore {
t.Fatalf("an unchanged text created a job: %d -> %d", jobsBefore, jobsAfter)
}
if row = chapterRow(t, db, pasted.Chapter.ID); row.Status != statusReady {
t.Fatalf("an unchanged save changed the status: %+v", row)
}
// Text rules match a paste, and an empty body is refused.
for _, body := range []map[string]string{{"text": " \n\t "}, {"text": strings.Repeat("a", maxChapterRunes+1)}} {
if code, _, _ := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), body); code != 400 {
t.Fatalf("invalid text %v accepted", body)
}
}
if code, _, _ := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), map[string]string{}); code != 400 {
t.Fatal("an empty edit must be rejected")
}
if code, _, _ := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), map[string]any{"text": "Body.\n", "ownerId": 9}); code != 400 {
t.Fatal("an unknown field must be rejected")
}
}
func TestMySQLChapterSourceAnyStatus(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
other := newLearner(t, r, owner.Token)
drainIngest(t, db)
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "source-fixture-0001", "title": "Source", "text": editFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste status %d", code)
}
readSource := func(token string, chapterID int64) (int, ChapterSource) {
code, _, data := callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d/source", chapterID), token, nil)
var payload struct {
Source ChapterSource
}
if len(data) > 0 {
json.Unmarshal(data, &payload)
}
return code, payload.Source
}
// A pending chapter has no reading text, but its edit source is available to its owner.
if code, source := readSource(learner.Token, pasted.Chapter.ID); code != 200 || source.Text != editFixtureText || source.Status != statusPending || source.ContentSHA256 != contentSHA(editFixtureText) {
t.Fatalf("pending source: %d %+v", code, source)
}
drainIngest(t, db)
if code, source := readSource(learner.Token, pasted.Chapter.ID); code != 200 || source.Status != statusReady || source.CharCount != len([]rune(editFixtureText)) {
t.Fatalf("ready source: %d %+v", code, source)
}
if code, _ := readSource(other.Token, pasted.Chapter.ID); code != 404 {
t.Fatal("another account must not read this source")
}
if code, _, _ := callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d/source?id=1", pasted.Chapter.ID), learner.Token, nil); code != 400 {
t.Fatal("the source endpoint must reject query parameters")
}
if code, _ := readSource(learner.Token, 999999); code != 404 {
t.Fatal("an unknown chapter must be 404")
}
}
// TestMySQLDeleteChapterRenumbers closes the gap in the order and keeps personal records.
func TestMySQLDeleteChapterRenumbers(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
other := newLearner(t, r, owner.Token)
drainIngest(t, db)
code, first := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "delete-fixture-0001", "title": "Three Chapters", "text": editFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("first paste %d", code)
}
second := pasteInto(t, r, learner.Token, first.Book.ID, "delete-fixture-0002", "Second", "Second chapter body.\n")
third := pasteInto(t, r, learner.Token, first.Book.ID, "delete-fixture-0003", "Third", "Third chapter body.\n")
drainIngest(t, db)
// One personal word in the chapter that will be deleted, plus one in a surviving chapter.
saved := saveWord(t, r, learner.Token, second.Chapter.ID, 0, 6, termStatusNew, nil)
survivor := saveWord(t, r, learner.Token, first.Chapter.ID, 0, 4, termStatusNew, nil)
before := termReviewRow(t, db, saved.Term.ID)
code, msg, data := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d", second.Chapter.ID), learner.Token, nil)
if code != 200 {
t.Fatalf("delete chapter status %d (%s)", code, msg)
}
var deleted struct {
Deleted DeletionResult
}
json.Unmarshal(data, &deleted)
if deleted.Deleted.ChapterID != second.Chapter.ID || deleted.Deleted.Remaining != 2 || deleted.Deleted.BookID != first.Book.ID {
t.Fatalf("delete result: %+v", deleted.Deleted)
}
// The remaining chapters are contiguous and in the original order.
code, detail := bookDetail(t, r, learner.Token, first.Book.ID)
if code != 200 || len(detail.Chapters) != 2 {
t.Fatalf("book detail after delete: %d %+v", code, detail.Chapters)
}
if detail.Chapters[0].ID != first.Chapter.ID || detail.Chapters[0].Ordinal != 1 || detail.Chapters[1].ID != third.Chapter.ID || detail.Chapters[1].Ordinal != 2 {
t.Fatalf("renumbered chapters: %+v", detail.Chapters)
}
// Navigation follows the new order, and the deleted chapter is gone with its job.
code, reader := readChapter(t, r, learner.Token, first.Chapter.ID)
if code != 200 || reader.Navigation.NextChapterID == nil || *reader.Navigation.NextChapterID != third.Chapter.ID {
t.Fatalf("navigation after delete: %d %+v", code, reader.Navigation)
}
if code, _ := readChapter(t, r, learner.Token, second.Chapter.ID); code != 404 {
t.Fatal("a deleted chapter must be gone")
}
var jobs int64
db.Model(&IngestJob{}).Where("chapter_id = ?", second.Chapter.ID).Count(&jobs)
if jobs != 0 {
t.Fatalf("the deleted chapter kept %d jobs", jobs)
}
// Personal records survive the deletion, including the schedule.
var terms int64
db.Model(&Term{}).Where("id IN ?", []int64{saved.Term.ID, survivor.Term.ID}).Count(&terms)
var reviews int64
db.Model(&TermReview{}).Where("term_id = ?", saved.Term.ID).Count(&reviews)
if terms != 2 || reviews != 1 {
t.Fatalf("deleting a chapter changed personal records: terms=%d reviews=%d", terms, reviews)
}
if after := termReviewRow(t, db, saved.Term.ID); !after.DueAt.Equal(before.DueAt) || after.ReviewCount != before.ReviewCount {
t.Fatalf("the review schedule changed: %+v -> %+v", before, after)
}
// Repeated and foreign deletions.
if code, _, _ := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d", second.Chapter.ID), learner.Token, nil); code != 404 {
t.Fatal("a repeated delete must be 404")
}
if code, _, _ := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d", first.Chapter.ID), other.Token, nil); code != 404 {
t.Fatal("another account must not delete this chapter")
}
if code, _, _ := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d", first.Chapter.ID), "", nil); code != 401 {
t.Fatal("deleting requires a session")
}
if code, detail := bookDetail(t, r, learner.Token, first.Book.ID); code != 200 || len(detail.Chapters) != 2 {
t.Fatalf("a rejected delete changed the book: %+v", detail.Chapters)
}
}
// pasteInto appends one chapter to an owned book.
func pasteInto(t *testing.T, r *gin.Engine, token string, bookID int64, requestID, title, text string) pasteResponse {
t.Helper()
code, pasted := pasteChapter(t, r, token, bookID, map[string]string{"requestId": requestID, "title": title, "text": text})
if code != 201 {
t.Fatalf("append %s status %d", title, code)
}
return pasted
}
func TestMySQLDeleteBookKeepsPersonalRecords(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
other := newLearner(t, r, owner.Token)
drainIngest(t, db)
code, first := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "delete-book-0001", "title": "Doomed Book", "text": editFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
pasteInto(t, r, learner.Token, first.Book.ID, "delete-book-0002", "Second", "Second chapter body.\n")
drainIngest(t, db)
saved := saveWord(t, r, learner.Token, first.Chapter.ID, 0, 4, termStatusNew, nil)
survivorBook := pasteInto(t, r, learner.Token, first.Book.ID, "delete-book-0003", "Third", "Third body.\n")
otherBook := func() int64 {
code, kept := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "delete-book-keep", "title": "Kept Book", "text": "Kept body.\n", "language": "en"})
if code != 201 {
t.Fatalf("kept book %d", code)
}
return kept.Book.ID
}()
drainIngest(t, db)
code, msg, data := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/books/%d", first.Book.ID), learner.Token, nil)
if code != 200 {
t.Fatalf("delete book status %d (%s)", code, msg)
}
var deleted struct {
Deleted DeletionResult
}
json.Unmarshal(data, &deleted)
if deleted.Deleted.BookID != first.Book.ID || deleted.Deleted.Chapters != 3 {
t.Fatalf("delete result: %+v", deleted.Deleted)
}
var books, chapters, jobs int64
db.Model(&Book{}).Where("id = ?", first.Book.ID).Count(&books)
db.Model(&Chapter{}).Where("book_id = ?", first.Book.ID).Count(&chapters)
db.Model(&IngestJob{}).Where("book_id = ?", first.Book.ID).Count(&jobs)
if books != 0 || chapters != 0 || jobs != 0 {
t.Fatalf("the deleted book left rows: books=%d chapters=%d jobs=%d", books, chapters, jobs)
}
// The other book of the same account is untouched.
if code, detail := bookDetail(t, r, learner.Token, otherBook); code != 200 || len(detail.Chapters) != 1 {
t.Fatalf("the other book changed: %d %+v", code, detail.Chapters)
}
// Personal records and their schedule survive.
var terms, reviews int64
db.Model(&Term{}).Where("owner_id = ?", learner.ID).Count(&terms)
db.Model(&TermReview{}).Where("term_id = ?", saved.Term.ID).Count(&reviews)
if terms != 1 || reviews != 1 {
t.Fatalf("deleting a book changed personal records: terms=%d reviews=%d", terms, reviews)
}
code, queue := reviewQueue(t, r, learner.Token)
if code != 200 || len(queue.Items) != 1 || queue.Items[0].ID != saved.Term.ID {
t.Fatalf("the saved word left the review queue: %d %+v", code, queue.Items)
}
// The deleted chapter and book are no longer readable, and repeats are 404.
if code, _ := readChapter(t, r, learner.Token, survivorBook.Chapter.ID); code != 404 {
t.Fatal("a chapter of the deleted book must be gone")
}
if code, _, _ := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/books/%d", first.Book.ID), learner.Token, nil); code != 404 {
t.Fatal("a repeated delete must be 404")
}
if code, _, _ := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/books/%d", otherBook), other.Token, nil); code != 404 {
t.Fatal("another account must not delete this book")
}
_, list := bookList(t, r, other.Token)
if len(list.Items) != 0 {
t.Fatalf("the other account must not see this library: %+v", list.Items)
}
}
// TestMySQLDeleteDuringProcessing proves a deleted chapter cannot come back through a run
// that was already in flight.
func TestMySQLDeleteDuringProcessing(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
drainIngest(t, db)
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "delete-processing-01", "title": "In Flight", "text": editFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
clock := time.Now().UTC().Truncate(time.Millisecond)
job, claimed, err := ClaimNextIngestJob(db, clock)
if err != nil || !claimed {
t.Fatalf("claim: %v", err)
}
if code, msg, _ := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), learner.Token, nil); code != 200 {
t.Fatalf("deleting a processing chapter: %d (%s)", code, msg)
}
// The in-flight run finds nothing to publish and reports no error.
if err := FinishIngestJob(t.Context(), db, job, clock.Add(time.Second)); err != nil {
t.Fatalf("finishing a run for a deleted chapter: %v", err)
}
var chapters, jobs int64
db.Model(&Chapter{}).Where("id = ?", pasted.Chapter.ID).Count(&chapters)
db.Model(&IngestJob{}).Where("id = ?", job.ID).Count(&jobs)
if chapters != 0 || jobs != 0 {
t.Fatalf("a deleted chapter came back: chapters=%d jobs=%d", chapters, jobs)
}
// The book stays, with no chapters and a clear empty state for the client.
code, detail := bookDetail(t, r, learner.Token, pasted.Book.ID)
if code != 200 || len(detail.Chapters) != 0 {
t.Fatalf("book after deleting its only chapter: %d %+v", code, detail.Chapters)
}
}
// TestMySQLConcurrentChapterDelete checks two deletions of one chapter in one book.
func TestMySQLConcurrentChapterDelete(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
drainIngest(t, db)
code, first := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "race-delete-0001", "title": "Race", "text": editFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
pasteInto(t, r, learner.Token, first.Book.ID, "race-delete-0002", "Second", "Second body.\n")
drainIngest(t, db)
var wg sync.WaitGroup
codes := make(chan int, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c, _, _ := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d", first.Chapter.ID), learner.Token, nil)
codes <- c
}()
}
wg.Wait()
close(codes)
ok, missing := 0, 0
for c := range codes {
switch c {
case 200:
ok++
case 404:
missing++
}
}
if ok != 1 || missing != 1 {
t.Fatalf("concurrent deletes: ok=%d missing=%d", ok, missing)
}
// The single surviving chapter keeps ordinal 1.
var remaining []Chapter
db.Where("book_id = ?", first.Book.ID).Order("ordinal ASC").Find(&remaining)
if len(remaining) != 1 || remaining[0].Ordinal != 1 {
t.Fatalf("ordinals after concurrent delete: %+v", remaining)
}
}
// TestMySQLRecoverySkipsSupersededJobs proves the recovery sweep leaves a newer version alone.
func TestMySQLRecoverySkipsSupersededJobs(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
drainIngest(t, db)
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "recovery-fix-0001", "title": "Recovery", "text": editFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
clock := time.Now().UTC().Truncate(time.Millisecond)
job, claimed, err := ClaimNextIngestJob(db, clock)
if err != nil || !claimed {
t.Fatalf("claim: %v", err)
}
newText := "A replacement body.\n"
if code, msg, _ := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), map[string]string{"text": newText}); code != 200 {
t.Fatalf("edit status %d (%s)", code, msg)
}
// The old job looks stale to the sweep; it must be abandoned, not requeued.
requeued, err := RequeueStaleIngestJobs(db, clock.Add(ingestStaleAfter+time.Second))
if err != nil {
t.Fatal(err)
}
if requeued != 0 {
t.Fatalf("the sweep requeued %d superseded job(s)", requeued)
}
var stale IngestJob
if err := db.First(&stale, job.ID).Error; err != nil {
t.Fatal(err)
}
if stale.Status != statusFailed || stale.ErrorReason != reasonSuperseded {
t.Fatalf("the sweep left the old job %+v", stale)
}
if row := chapterRow(t, db, pasted.Chapter.ID); row.Status != statusPending || row.OriginalText != newText {
t.Fatalf("the sweep changed the new version: %+v", row)
}
drainIngest(t, db)
if row := chapterRow(t, db, pasted.Chapter.ID); row.Status != statusReady || row.OriginalText != newText {
t.Fatalf("the new version did not publish: %+v", row)
}
}
+58 -12
View File
@@ -47,13 +47,19 @@ func requeueStaleIngestJobs(db *gorm.DB, now time.Time, staleAfter time.Duration
cutoff := stamp(now.Add(-staleAfter))
var requeued int64
err := db.Transaction(func(tx *gorm.DB) error {
if err := abandonSupersededJobs(tx, ts); err != nil {
return err
}
if err := exhaustIngestJobs(tx, ts); err != nil {
return err
}
stale := []int64{}
if err := tx.Model(&IngestJob{}).
Where("status = ? AND attempts < ? AND updated_at <= ?", statusProcessing, maxIngestAttempts, cutoff).
Pluck("id", &stale).Error; err != nil {
// Only jobs that still describe the chapter's current version may be requeued: an
// interrupted run of an older version must not pull the newer text back into processing.
if err := tx.Table("lexgo_ingest_jobs AS j").
Joins("JOIN lexgo_chapters AS c ON c.id = j.chapter_id AND c.content_sha256 = j.content_sha256").
Where("j.status = ? AND j.attempts < ? AND j.updated_at <= ?", statusProcessing, maxIngestAttempts, cutoff).
Pluck("j.id", &stale).Error; err != nil {
return err
}
if len(stale) == 0 {
@@ -111,10 +117,21 @@ func ClaimNextIngestJob(db *gorm.DB, now time.Time) (IngestJob, bool, error) {
ts := stamp(now)
var job IngestJob
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("status = ? AND attempts < ?", statusPending, maxIngestAttempts).
Order("id ASC").First(&job).Error; err != nil {
if err := abandonSupersededJobs(tx, ts); err != nil {
return err
}
// A job may only process the version it was created for, so the chapter join is part
// of the claim and an edited chapter is never dragged back to processing.
if err := tx.Table("lexgo_ingest_jobs AS j").
Select("j.id, j.owner_id, j.book_id, j.chapter_id, j.request_key, j.content_sha256, j.status, j.attempts, j.error_reason, j.created_at, j.updated_at, j.finished_at").
Joins("JOIN lexgo_chapters AS c ON c.id = j.chapter_id AND c.content_sha256 = j.content_sha256").
Where("j.status = ? AND j.attempts < ?", statusPending, maxIngestAttempts).
Order("j.id ASC").Limit(1).Find(&job).Error; err != nil {
return err
}
if job.ID == 0 {
return errNoIngestJob
}
claim := tx.Model(&IngestJob{}).Where("id = ? AND status = ?", job.ID, statusPending).
Updates(map[string]any{"status": statusProcessing, "attempts": gorm.Expr("attempts + 1"), "updated_at": ts})
if claim.Error != nil {
@@ -123,7 +140,7 @@ func ClaimNextIngestJob(db *gorm.DB, now time.Time) (IngestJob, bool, error) {
if claim.RowsAffected != 1 {
return errJobTaken
}
if err := tx.Model(&Chapter{}).Where("id = ? AND owner_id = ?", job.ChapterID, job.OwnerID).
if err := tx.Model(&Chapter{}).Where("id = ? AND owner_id = ? AND content_sha256 = ?", job.ChapterID, job.OwnerID, job.ContentSHA256).
Updates(map[string]any{"status": statusProcessing, "updated_at": ts}).Error; err != nil {
return err
}
@@ -132,7 +149,7 @@ func ClaimNextIngestJob(db *gorm.DB, now time.Time) (IngestJob, bool, error) {
job.UpdatedAt = ts
return nil
})
if errors.Is(err, gorm.ErrRecordNotFound) || errors.Is(err, errJobTaken) {
if errors.Is(err, gorm.ErrRecordNotFound) || errors.Is(err, errJobTaken) || errors.Is(err, errNoIngestJob) {
return IngestJob{}, false, nil
}
if err != nil {
@@ -143,6 +160,9 @@ func ClaimNextIngestJob(db *gorm.DB, now time.Time) (IngestJob, bool, error) {
var errJobTaken = errors.New("ingestion job already claimed")
// errNoIngestJob reports an empty queue, which is not a failure.
var errNoIngestJob = errors.New("no ingestion job to claim")
// FinishIngestJob validates the persisted chapter and publishes it, or records a fixed
// failure reason. The check runs again here because a worker must not trust that content
// reached the table through the paste API.
@@ -150,12 +170,27 @@ func FinishIngestJob(ctx context.Context, db *gorm.DB, job IngestJob, now time.T
ts := stamp(now)
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var chapter Chapter
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND owner_id = ?", job.ChapterID, job.OwnerID).First(&chapter).Error; err != nil {
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND owner_id = ?", job.ChapterID, job.OwnerID).First(&chapter).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
// The chapter was deleted while this run was in flight; the cascade removed its
// jobs too, so there is nothing left to publish.
return nil
}
if err != nil {
return err
}
if chapter.ContentSHA256 != job.ContentSHA256 {
// The chapter moved to a newer version: publish nothing and leave its state, which
// belongs to the newer job, untouched.
return tx.Model(&IngestJob{}).Where("id = ?", job.ID).Updates(map[string]any{
"status": statusFailed, "error_reason": reasonSuperseded, "updated_at": ts, "finished_at": ts}).Error
}
var book Book
if err := tx.Where("id = ? AND owner_id = ?", job.BookID, job.OwnerID).First(&book).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
if reason := unprocessableReason(book, chapter, job); reason != "" {
@@ -185,14 +220,25 @@ func unprocessableReason(book Book, chapter Chapter, job IngestJob) string {
if utf8.RuneCountInString(chapter.OriginalText) > maxChapterRunes {
return reasonTooLong
}
// The job accepted a specific content version; a chapter changed after submission is a
// different paste and must be submitted again rather than silently processed.
if contentSHA(chapter.OriginalText) != job.ContentSHA256 {
// The stored text and its stored version must agree. The job-versus-chapter version check
// ran before this function, so a direct write that changed the text without updating its
// version is the remaining case, and it is a different paste rather than this version.
if contentSHA(chapter.OriginalText) != chapter.ContentSHA256 {
return reasonContentChanged
}
return ""
}
// abandonSupersededJobs fails jobs whose version is no longer the chapter's version. They
// must never publish or fail the chapter, because another job owns its current state. It is
// one multi-table statement: GORM's Updates does not carry a Joins clause into an UPDATE.
func abandonSupersededJobs(tx *gorm.DB, ts time.Time) error {
return tx.Exec(`UPDATE lexgo_ingest_jobs j JOIN lexgo_chapters c ON c.id = j.chapter_id
SET j.status = ?, j.error_reason = ?, j.updated_at = ?, j.finished_at = ?
WHERE j.status IN (?, ?) AND j.content_sha256 <> c.content_sha256`,
statusFailed, reasonSuperseded, ts, ts, statusPending, statusProcessing).Error
}
// ProcessIngestJobs drains up to limit pending jobs. Claiming and finishing each use their
// own transaction, so an interrupted run simply leaves a job for recovery.
func ProcessIngestJobs(ctx context.Context, db *gorm.DB, now func() time.Time, limit int) (int, error) {
+10 -1
View File
@@ -30,7 +30,9 @@ const (
reasonTooLong = "too_long"
reasonEmptyText = "empty_text"
reasonContentChanged = "content_changed"
reasonAttemptsExhausted = "attempts_exhausted"
// reasonSuperseded marks a job whose chapter already moved to a newer content version.
reasonSuperseded = "superseded"
reasonAttemptsExhausted = "attempts_exhausted"
)
const (
@@ -53,6 +55,8 @@ func reasonMessage(reason string) string {
return "内容超过单章上限(100000 个字符)"
case reasonEmptyText:
return "章节内容为空"
case reasonSuperseded:
return "章节内容已更新为新版本,本次处理已作废"
case reasonContentChanged:
return "内容在处理前发生变化,请重新提交"
case reasonAttemptsExhausted:
@@ -640,6 +644,11 @@ func RetryIngestJob(db *gorm.DB, owner int, jobID int64, now time.Time) (JobView
Where("id = ? AND owner_id = ?", job.ChapterID, owner).First(&chapter).Error; err != nil {
return err
}
// A job of an older content version is gone for good: retrying it would process text
// the chapter no longer holds, so the newer job owns the chapter instead.
if chapter.ContentSHA256 != job.ContentSHA256 {
return failure(409, "该任务对应的是旧版本,请刷新后重试当前版本")
}
if err := tx.Model(&IngestJob{}).Where("id = ?", job.ID).
Updates(map[string]any{"status": statusPending, "error_reason": "", "attempts": 0,
"updated_at": ts, "finished_at": nil}).Error; err != nil {
+1
View File
@@ -305,6 +305,7 @@ func Router(db *gorm.DB, now func() time.Time) *gin.Engine {
registerTermRoutes(v, protect, now)
registerReviewRoutes(v, protect, now)
registerUploadRoutes(v, protect, now)
registerEditRoutes(v, protect, now)
r.NoRoute(func(c *gin.Context) { respond(c, 404, nil, failure(404, "页面或接口不存在")) })
return r
}