feat: 书籍页精简封面、可编辑书名与作者、标题旁显示作者 (#37)
- schema v11:lexgo_books 增加 author VARCHAR(120) NOT NULL DEFAULT '',复用 v10 的条件加列 助手(改名 addAuthorColumn,表名取自本文件常量),保持迁移可重放;新建库 v3 语句也带该列 - PATCH /api/v1/books/:id 接受可选 author(省略保留、空串清空、trim、≤120),BookSummary 与 BookRef 返回它;「编辑书名」改为「编辑书籍」,对话框同行编辑书名与作者 - 书籍页封面压成一行紧凑控件(预览+上传/替换/移除),去掉「封面」「书籍封面」规格与 「音频与插图按章节设置」等文字,把高度让给章节列表;书名右侧显示书级作者(未设置不显示) - 测试:Go 91 项(新增书级作者往返与 v10→v11 迁移用例)、学习端 157 单测与 26 项 E2E - 真实链路:封面行高 68px、删掉的四段文字不再出现、作者显示在书名同一行右侧且刷新后保留 - 文档:Architecture / Business-Rules / Local-Development / Requirements / Home 同步
This commit was merged in pull request #38.
This commit is contained in:
@@ -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: 2ccdc108a21abe67ad77c05af59bb34bac304093
|
||||
synchronized_at: 2026-09-15T14:49:27Z
|
||||
wiki_revision: 0bca600ea0dfcb18141920f448954bb9e2fa067c
|
||||
synchronized_at: 2026-09-15T15:10:08Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -478,3 +478,22 @@ schema v8 新增两张表,都只用可重放的 `CREATE TABLE IF NOT EXISTS`
|
||||
复用不变:`sniffAttachment`(magic bytes 判定)、`validateAttachment`(音频 ≤20 MiB;图片 ≤2 MiB 且 ≤4096×4096)、`coverDimensions`(JPEG/PNG 用标准库、WebP 读容器头)、`binaryResponse` + `http.ServeContent`(Range/206、416、ETag/304)、上传「先校验后写入、失败保留旧文件」的流程,以及 `chapterAttachmentViews`/`chapterPlaybackSeconds` 的批量读取(一次查询喂整个章节列表)。
|
||||
|
||||
学习端:`stores/library.ts` 用 `uploadChapterFile`/`deleteChapterFile`/`saveChapterPlayback`/`reportChapterPlayback` 替换了书级音频动作,并新增 `illustrationUrls`(按章节)与 `audioChapterId`(记录当前加载的音频属于哪一章,切章不会复用上一章的文件);`views/BookView.vue` 的封面区块收窄为封面,**章节列表每行只有一个「编辑」按钮**,章节编辑对话框(`data-testid="chapter-dialog"`)同时编辑**章节标题、作者、正文、插图与音频**:标题与作者用「标签在左、输入框在右」的同一行排版(`.field-row`),正文编辑框加高(18 行),插图与音频压缩成各一两行(标签+状态+上传/替换/移除+「JPG/PNG/WebP · ≤2 MiB · ≤4096×4096」「MP3 · ≤20 MiB · 替换或移除会重置位置」),底部一行写明时机(`data-testid="attachment-timing"`),`views/ReaderView.vue` 在正文上方渲染本章插图的**缩略图**(高 120px 的按钮,`data-testid="chapter-illustration"`),点击后在对话框(`data-testid="illustration-dialog"`)里按原图显示(最大 `min(88vw,1200px)` × `78vh`,保持比例不裁切),播放器只在**本章**有音频时出现,离开或切换章节时上报一次位置;**书籍页的章节列表不显示也不预取插图**(2026-09-15 按用户要求修订两次:先改为缩略图+弹窗,再把附件并入章节编辑对话框)。
|
||||
|
||||
## #37 章节级音频与章节插图(2026-09-15)
|
||||
|
||||
**schema v11**:`lexgo_books` 增加可选的 `author VARCHAR(120) NOT NULL DEFAULT ''`,与 v10 的章节作者共用同一个条件加列助手 `addAuthorColumn`(先查 `information_schema`,缺列才 `ALTER TABLE`,在语句列表之后执行),新建库的 v3 建表语句也带该列。`BookUpdateInput` 增加可选的 `author`(省略则保留原值、空串即清空、≤120 字符、去首尾空白),`BookSummary`/`BookRef` 都返回它;书籍编辑接口因此从「重命名」变成「编辑书籍」(书名+作者)。
|
||||
|
||||
**schema v10**:`lexgo_chapters` 增加可选的 `author VARCHAR(120) NOT NULL DEFAULT ''`。MySQL 没有 `ADD COLUMN IF NOT EXISTS`,因此这一列由 Go 侧的条件步骤 `addChapterAuthorColumn` 添加(先查 `information_schema`,缺列才执行 `ALTER TABLE`),并在语句列表执行**之后**运行,保证「部分迁移可重试」「回退标记后可重新升级」这两条既有性质仍然成立;新建库的 v3 语句里也直接带上该列。章节编辑接口接受 `author`(可选、去首尾空白、≤120 字符、空串即清空),`ChapterSummary` 与 `ChapterSource` 都返回它,阅读页在标题下显示非空的作者。
|
||||
|
||||
**schema v9**:新增 `lexgo_chapter_attachments`(主键 `(chapter_id, kind)`,`kind ∈ {audio, illustration}`,`bytes MEDIUMBLOB`,外键级联到章节与账号)与 `lexgo_chapter_playback_positions`(主键 `(owner_id, chapter_id)`),并执行一条幂等语句 `DELETE FROM lexgo_book_attachments WHERE kind='audio'`。三张表都只用可重放的 DDL;`lexgo_book_attachments` 与 `lexgo_playback_positions` 保留结构(不删表、不改列),书级音频停止写入。
|
||||
|
||||
| 层 | 变化 |
|
||||
|---|---|
|
||||
| 书级 | 只剩**封面**:`kind='cover'`、`GET/POST/DELETE /api/v1/books/:id/cover`、`coverVersion` 缓存失效机制全部保持原样 |
|
||||
| 章节级 | `POST/DELETE/GET /api/v1/chapters/:id/audio` 与 `.../illustration`、`PUT /api/v1/chapters/:id/playback` |
|
||||
| 退役 | `POST/DELETE/GET /api/v1/books/:id/audio`、`PUT /api/v1/books/:id/playback`(路由不再注册,返回 404) |
|
||||
| 响应 | `ChapterSummary` 增加 `illustrationVersion`、`audioVersion`、`playbackSeconds`,字段始终存在(无文件时为空串),书籍详情的章节列表与阅读器响应都带上它们;书的 `attachments` 只剩 `cover` |
|
||||
|
||||
复用不变:`sniffAttachment`(magic bytes 判定)、`validateAttachment`(音频 ≤20 MiB;图片 ≤2 MiB 且 ≤4096×4096)、`coverDimensions`(JPEG/PNG 用标准库、WebP 读容器头)、`binaryResponse` + `http.ServeContent`(Range/206、416、ETag/304)、上传「先校验后写入、失败保留旧文件」的流程,以及 `chapterAttachmentViews`/`chapterPlaybackSeconds` 的批量读取(一次查询喂整个章节列表)。
|
||||
|
||||
学习端:`stores/library.ts` 用 `uploadChapterFile`/`deleteChapterFile`/`saveChapterPlayback`/`reportChapterPlayback` 替换了书级音频动作,并新增 `illustrationUrls`(按章节)与 `audioChapterId`(记录当前加载的音频属于哪一章,切章不会复用上一章的文件);`views/BookView.vue` 的书籍页把封面压成**一行紧凑控件**(`.cover-row`:预览 120px +「上传/替换封面」「移除」,不含标题、字段名与规格提示文字),把高度让给章节列表;标题行用 `.title-line` 把**书级作者**显示在书名右侧(`data-testid="book-author"`,未设置则不显示);「编辑书名」改为**「编辑书籍」**,对话框内用同一套 `.field-row` 同行排版编辑**书名与作者**。**章节列表每行只有一个「编辑」按钮**,章节编辑对话框(`data-testid="chapter-dialog"`)同时编辑**章节标题、作者、正文、插图与音频**:标题与作者用「标签在左、输入框在右」的同一行排版(`.field-row`),正文编辑框加高(18 行),插图与音频压缩成各一两行(标签+状态+上传/替换/移除+「JPG/PNG/WebP · ≤2 MiB · ≤4096×4096」「MP3 · ≤20 MiB · 替换或移除会重置位置」),底部一行写明时机(`data-testid="attachment-timing"`),`views/ReaderView.vue` 在正文上方渲染本章插图的**缩略图**(高 120px 的按钮,`data-testid="chapter-illustration"`),点击后在对话框(`data-testid="illustration-dialog"`)里按原图显示(最大 `min(88vw,1200px)` × `78vh`,保持比例不裁切),播放器只在**本章**有音频时出现,离开或切换章节时上报一次位置;**书籍页的章节列表不显示也不预取插图**(2026-09-15 按用户要求修订两次:先改为缩略图+弹窗,再把附件并入章节编辑对话框)。
|
||||
|
||||
@@ -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: 77e1f1291a201744bdbb556ed1ef7d81cef2f969
|
||||
synchronized_at: 2026-09-15T14:49:28Z
|
||||
wiki_revision: b688ef278f83c8055e16f91843a239f9ea2dd492
|
||||
synchronized_at: 2026-09-15T15:10:08Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -394,3 +394,25 @@ exact优先;未命中再按WordNet异常表/词尾规则查候选,词性顺
|
||||
**删除与备份**:删除章节(#10 的事务级联)连带删除该章附件与该章所有账号的位置记录;删除书籍通过章节级联清空。附件存于数据库,因此 #15 的备份与恢复自动覆盖,恢复后按字节相同并可继续拖动。
|
||||
|
||||
**范围外**:多张插图、正文中间图文混排、缩略图墙式独立浏览界面、多音轨与分章音轨、字幕与时间戳同步、逐句跟读、转写、TTS、转码、自动搜图、在线音频地址、公开分享与跨用户共享。
|
||||
|
||||
## #37 章级附件规则(2026-09-15)
|
||||
|
||||
**挂载单位**:封面属于**书**(一本书一张,维持 #21 已验收的规则);音频与插图属于**章**(一章各一张)。多篇文章合并成一本书时,每章可以有自己的音频与插图,互不影响。没有附件的章节保持原样:不显示占位框、不显示播放器。
|
||||
|
||||
**格式与上限**:音频只接受 **MP3**(ID3 或帧同步),≤ **20 MiB**;插图与封面同规格:**JPG/PNG/WebP**,≤ **2 MiB**,像素 ≤ **4096×4096**;一律**按文件内容判定类型**,不看扩展名与客户端声明。超限 `413`、类型或内容非法 `400` 并给出可读原因;**被拒绝的上传保留旧文件**。
|
||||
|
||||
**替换与位置**:替换即覆盖同一章同一类型的那一行。**替换或移除音频会清空该章的播放位置**(旧位置对新文件没有意义);插图与音频互不影响,移除其一不会动另一个。
|
||||
|
||||
**播放位置**:粒度是**账号+章节**,不是账号+书——同一本书的不同章节各记各的。播放中每 5 秒、暂停时、离开当前章节或切换章节时各上报一次;服务端只做 0~24 小时的上界校验与 upsert,并发以最后一次写入为准,不解析音频时长。
|
||||
|
||||
**读取与缓存**:插图与音频都需要会话,跨账号一律 `404`、匿名 `401`;支持 **HTTP Range(206)**、`416` 与 `ETag`/`304`。前端用带凭据的 fetch 取字节再交给 `<img>`/`<audio>`,令牌不出现在 URL 中。
|
||||
|
||||
**书籍作者与章节作者**:两者是**互相独立**的可选自由文本(各 ≤120 字符,各自去首尾空白,留空即清空):书级作者在书籍页显示于书名右侧、在「编辑书籍」里维护;章节作者在阅读页显示于章节标题下、在章节「编辑」里维护。**章节不会从书级继承作者**,字段也不参与归类、检索或权限判断(如需继承或检索,需要单独确认)。省略作者字段的编辑请求保留原值。
|
||||
|
||||
**章节作者**:作者是**可选自由文本**(≤120 字符),随章节标题与正文一起保存,去首尾空白,留空即清空;它只用于展示(阅读页在章节标题下显示),不参与归类、检索或权限判断,也不要求与书级数据一致。
|
||||
|
||||
**界面**:书籍页的章节列表每行只保留一个「编辑」按钮,点开后一个对话框同时编辑标题、正文、本章插图与本章音频(**一个入口、一处编辑**);**章节列表不显示章节图片**(也不预取)。对话框内两种生效时机写得很清楚:**标题、作者与正文点「保存章节」后生效;插图与音频在选中文件后立即上传**,取消不会撤销已上传的文件;改正文会重新处理章节(只改标题不会),而已上传的插图与音频挂在章节上、不随正文版本变化。阅读页里插图以**缩略图**形式显示在本章正文开头(章节标题与播放器条之下),**点击缩略图在弹窗中查看原图**(保持比例、不裁切;Esc 关闭后正文位置不变);缩略图是按钮而不是正文的一部分,不参与划词;播放器条仍为 sticky,判据为「本章有音频」。
|
||||
|
||||
**删除与备份**:删除章节(#10 的事务级联)连带删除该章附件与该章所有账号的位置记录;删除书籍通过章节级联清空。附件存于数据库,因此 #15 的备份与恢复自动覆盖,恢复后按字节相同并可继续拖动。
|
||||
|
||||
**范围外**:多张插图、正文中间图文混排、缩略图墙式独立浏览界面、多音轨与分章音轨、字幕与时间戳同步、逐句跟读、转写、TTS、转码、自动搜图、在线音频地址、公开分享与跨用户共享。
|
||||
|
||||
@@ -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: 92144cd570153b560180e195c49524fdb95a1048
|
||||
synchronized_at: 2026-09-15T14:49:28Z
|
||||
wiki_revision: 8985f93f7319b2e6a428fc4417f353a7b909d797
|
||||
synchronized_at: 2026-09-15T15:10:08Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -990,3 +990,69 @@ schema v8 → v9:新增 `lexgo_chapter_attachments` 与 `lexgo_chapter_playbac
|
||||
- Safari/Firefox 未测;渐进式流式播放仍未做(服务端 Range 已实现)。
|
||||
- 迁移会清空书级音频测试数据且回退到 v8 找不回;当前无真实用户,工单已记录。
|
||||
- 已有书籍/章节的存量内容不受影响,但**没有做「把既有书级音频迁到章级」的转换**(无真实数据)。
|
||||
|
||||
## #37 章级附件验证(2026-09-15)
|
||||
|
||||
### 迁移
|
||||
|
||||
schema v8 → v9:新增 `lexgo_chapter_attachments` 与 `lexgo_chapter_playback_positions`,并执行一条幂等清理语句删除 `lexgo_book_attachments` 中 `kind='audio'` 的历史行(测试期数据,不做存量迁移)。开发库迁移前后逐表业务计数一致,schema 记到 9,两张新表为空、书级音频行为 0(快照 `.local/issue13-before37.json`、`.local/issue13-after37.json`)。回退:把 `lexgo_schema.version` 写回 8 并恢复 `.local/lexgo-pre-issue37.exe`,但**被清空的书级音频测试数据找不回**。
|
||||
|
||||
| 命令 | 结果 |
|
||||
|---|---|
|
||||
| `go vet ./...` / `gofmt -l` | 通过、无输出 |
|
||||
| `LEXGO_TEST_DB_NAME=lexgo_test_issue13 python scripts/server.py test-integration` | **87 项顶层用例通过、0 跳过**(本单新增/改写:章级附件与位置的完整集成用例、封面仍为书级、上传请求形态校验、v8→v9 迁移与回退重升级、v7→v8 测试改为用原始 SQL 写已退役的表) |
|
||||
| `cd learner`:`vitest` / `vue-tsc` / `build` / `playwright test` | 157 项单测、类型检查、构建、**26 项 E2E**(桌面 22 + 移动 4)全部通过 |
|
||||
| 治理 / 镜像 | 65 项、`check --strict`、`sync --check` 通过 |
|
||||
| 真实 API+MySQL | **52 项检查通过**(见下) |
|
||||
| #15 恢复演练(第三次) | **22 项检查通过**(见下) |
|
||||
|
||||
### 真实链路(52 项)
|
||||
|
||||
用一本**两章**的虚构书验证「附件属于章而不是书」:只给第一章上传真实的 PNG 插图与三秒 MP3 音频 → 数据库里两张行与章节对应、`byte_size` 与 `OCTET_LENGTH(bytes)` 一致、音频 sha256 与上传文件一致、书级音频行为 0 → 章节列表与阅读器都带 `illustrationVersion`/`audioVersion`,第二章为空串且位置为 0 → 插图按字节返回并带 `ETag`(`If-None-Match` 得 `304`)→ 音频按字节返回、`Accept-Ranges` 存在、`Range: bytes=200-399` 得 `206` 且切片正确、不可满足的 Range 得 `416` → **书级音频与播放接口已下线(404)** → 位置写入后章节列表回读一致、第二章仍为 0、负值 400、无音频的章 404 → 替换音频后位置归零且仍一行 → SVG 伪装插图、文本伪装音频 400、21 MiB 音频 413 且原文件保留 → 越权读取/替换/上报 404、匿名 401 → 移除音频清掉位置但插图不受影响 → 删除章节与删除书籍级联清空。证据 `.local/issue37-api-evidence.json`。
|
||||
|
||||
### 恢复演练(第三次,22 项)
|
||||
|
||||
带章级附件的 fixture → Go 二进制 `lexgo backup`(manifest 已含两张新表)→ 恢复到空库 `lexgo_test_restore_chapter` → `lexgo verify --manifest` 通过且包含「章节附件都指向存在的章节」「章节播放位置都指向存在的章节」→ **17 张表逐表内容校验和与源库一致** → 用恢复库起实例:同一账号可登录、章节仍报告两个文件、位置仍是恢复前的值、**音频与插图按字节相同地返回**、Range 仍得 206、越权仍 404 → 演练库与 fixture 删除,开发库仍是 schema 9。证据 `.local/issue37-restore-evidence.json`。
|
||||
|
||||
### 插图交互修订(2026-09-15,按用户要求)
|
||||
|
||||
书籍页章节列表去掉插图列与列表预取;阅读页改为缩略图(高 120px 的按钮)并在点击后于弹窗中显示原图。后端与 schema 无变化。验证:学习端 157 单测与 26 项 E2E 通过(断言章节列表无 `<img>` 且不请求 `/illustration`、缩略图是按钮、点击后弹窗内为对象 URL 的图片);真实链路在 issue5_a 自己的一本书上完成——列表 `<img>` 为 0、缩略图 ≤130px、弹窗内图片大于缩略图且 `naturalWidth > 0`、Esc 关闭后正文仍可见,随后移除 fixture 插图复原开发数据(截图 `issue37-real-book-no-image.png`、`issue37-real-reader-thumbnail.png`、`issue37-real-illustration-dialog.png`)。示例地址 `/books/5` 属于其他账号,请求返回 404,实际使用该账号自己的书。
|
||||
|
||||
### 插图交互修订(2026-09-15,按用户要求)
|
||||
|
||||
书籍页章节列表去掉插图列与列表预取;阅读页改为缩略图(高 120px 的按钮)并在点击后于弹窗中显示原图。后端与 schema 无变化。验证:学习端 157 单测与 26 项 E2E 通过(断言章节列表无 `<img>` 且不请求 `/illustration`、缩略图是按钮、点击后弹窗内为对象 URL 的图片);真实链路在 issue5_a 自己的一本书上完成——列表 `<img>` 为 0、缩略图 ≤130px、弹窗内图片大于缩略图且 `naturalWidth > 0`、Esc 关闭后正文仍可见,随后移除 fixture 插图复原开发数据(截图 `issue37-real-book-no-image.png`、`issue37-real-reader-thumbnail.png`、`issue37-real-illustration-dialog.png`)。示例地址 `/books/5` 属于其他账号,请求返回 404,实际使用该账号自己的书。
|
||||
|
||||
### 附件并入章节编辑对话框(2026-09-15,按用户要求)
|
||||
|
||||
章节列表每行只留一个「编辑」;原「章节附件」对话框被移除,插图与音频的上传/替换/移除移入章节编辑对话框(与标题、正文同处),对话框内明确写出「标题与正文点保存后生效;插图与音频选中后立即上传,取消不会撤销」。后端与 schema 仍无变化。验证:学习端 157 单测与 26 项 E2E 全通过(新断言:章节行只有一个按钮;编辑对话框同时含标题、正文、插图与音频控件与生效时机说明;上传后仍可用「保存章节」提交标题与正文);真实链路确认章节行按钮数为 1、对话框中四类控件齐备、在编辑框内上传后状态立即变为「已设置」而标题仍等待保存、取消后进入阅读页仍为缩略图+弹窗,最后移除 fixture 插图(截图 `issue37-real-chapter-editor.png`)。
|
||||
|
||||
### 对话框浮层缺陷修复(2026-09-15,用户发现)
|
||||
|
||||
用户报告「编辑区显示在章节列表下面」。查明原因:`learner/src/main.ts` **从未导入 `element-plus/es/components/dialog/style/css` 与 `message-box/style/css`**,因此自 #10 起所有 `ElDialog` 与 `ElMessageBox` 都退化成文档流中的普通块(探针实测:移除导入时对话框 `position: static`、`top` 正好等于章节列表底部;恢复导入后 `.el-overlay` 为 `fixed`、对话框居中且遮罩为 `rgba(0,0,0,0.5)`)。修复:在 `main.ts` 补上两行样式导入。
|
||||
|
||||
**验证盲区**:此前所有 UI 验证只断言「元素可见 / DOM 结构」,`toBeVisible()` 对普通块同样成立,所以单元测试与 E2E 全绿也没暴露它。现已补长期回归:`learner/e2e/overlay.ts` 提供 `expectModalOverlay` 与 `expectMessageBoxOverlay`,断言对话框位于 `.el-overlay`(`position: fixed`)、位置在视口内且横向不溢出;`attachments.spec.ts`(章节编辑对话框、插图弹窗)与 `edit.spec.ts`(书名对话框、删除确认)都调用它。真实链路再次确认:章节编辑对话框与删除确认均为固定浮层(截图 `issue37-real-dialog-modal.png`、`issue37-real-confirm-modal.png`)。
|
||||
|
||||
### 作者字段与编辑对话框紧凑化(2026-09-15,按用户要求)
|
||||
|
||||
按用户要求:①「章节标题」标签与输入框同行,下面新增「作者」标签与输入框;②精简插图/音频的提示文字,把高度让给正文编辑框。
|
||||
|
||||
- **schema v10**:`lexgo_chapters` 增加 `author VARCHAR(120) NOT NULL DEFAULT ''`。由 Go 条件步骤 `addChapterAuthorColumn`(先查 `information_schema` 再 `ALTER TABLE`)在语句列表之后执行,保持「部分迁移可重试 / 回退标记后可重新升级」;新建库的 v3 语句也带该列。开发库迁移后 schema = 10,既有 34 个章节的 author 均为空串(无数据变化)。
|
||||
- **接口**:章节编辑接受 `author`(可选、trim、≤120 字符、空串清空),`ChapterSummary`/`ChapterSource` 都返回它;仅改作者不会重新处理章节(状态保持不变)。
|
||||
- **界面**:`.field-row` 把标签与输入框放同一行(标题、作者各一行);正文 textarea 提升到 18 行;插图与音频压成各一两行提示(规格写在下方一行);阅读页在标题下显示非空作者(`data-testid="chapter-author-line"`)。
|
||||
- **验证**:Go 89 项顶层用例(新增 `TestMySQLChapterAuthorRoundTrip` 与 `TestMigrationFromV9AddsChapterAuthor`,后者覆盖「旧库缺列 → 迁移补列 → 回退标记 → 重新升级」);学习端 157 单测与 26 项 E2E 通过;真实链路实测:标题标签与输入框中线偏差 <8px、作者行位于标题下方且标签同行、正文高度 415px(原约 250px)、保存作者后阅读页显示、清空后该行消失(截图 `issue37-real-editor-layout.png`、`issue37-real-reader-author.png`)。
|
||||
|
||||
### 书级作者与书籍页精简(2026-09-15,按用户要求)
|
||||
|
||||
按用户要求:①书籍页去掉「封面」「书籍封面」「JPG…4096×4096」「音频与插图按章节设置…」四段文字,把空间让给章节列表;②「编辑书名」改为**「编辑书籍」**并可编辑**书名与作者**;③书名右侧显示当前书籍作者。
|
||||
|
||||
- **schema v11**:`lexgo_books.author VARCHAR(120) NOT NULL DEFAULT ''`,复用 v10 的条件加列助手(改名 `addAuthorColumn`,表名取自本文件常量)。开发库迁移后 schema = 11,既有 25 本书的 author 均为空串;回退件 `.local/lexgo-pre-issue37c.exe`(回退写回版本号 10 即可)。
|
||||
- **接口**:`PATCH /api/v1/books/:id` 接受可选 `author`(省略保留、空串清空、trim、≤120),`BookSummary`/`BookRef` 返回它;书籍编辑不再只改书名。
|
||||
- **界面**:封面区块变成一行(预览 + 上传/替换 + 移除);`.title-line` 在书名右侧显示作者(未设置不显示);「编辑书籍」对话框用同行标签编辑书名与作者。
|
||||
- **验证**:Go **91 项顶层用例**(新增 `TestMySQLBookAuthorRoundTrip` 与 `TestMigrationFromV10AddsBookAuthor`);学习端 157 单测与 26 项 E2E 通过;真实链路实测封面行高 **68px**、章节列表紧随其后(间距 18px)、四段被删文字都不再出现、「编辑书籍」对话框含作者输入、保存后作者显示在书名右侧(同一行、位于书名之后)并在刷新后保留、清空后标签消失(截图 `issue37-real-book-page.png`)。
|
||||
|
||||
### 未验证
|
||||
|
||||
- 真机与真实浏览器解码播放(媒体元素在测试中被桩替换)、真机听感与兼容性仍未验证(#21 起的既有缺口)。
|
||||
- Safari/Firefox 未测;渐进式流式播放仍未做(服务端 Range 已实现)。
|
||||
- 迁移会清空书级音频测试数据且回退到 v8 找不回;当前无真实用户,工单已记录。
|
||||
- 已有书籍/章节的存量内容不受影响,但**没有做「把既有书级音频迁到章级」的转换**(无真实数据)。
|
||||
|
||||
@@ -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: 1fc22dc050d51e4d4013d2a58b914f7b972116ca
|
||||
synchronized_at: 2026-09-15T14:49:29Z
|
||||
wiki_revision: 21ca050a33bdbe8133c921b6b302fbfc8d8cf7fe
|
||||
synchronized_at: 2026-09-15T15:10:10Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 产品需求总览
|
||||
@@ -323,3 +323,9 @@ F 范围之外的 MVP 增补项「书籍音频与封面附件」已实现,待
|
||||
用户的真实场景「多篇文章合并成一本书、每章各有音频,并为每章加插图」已实现,待用户验收:音频与插图改为**章节级**(一章各一张),封面维持**书级**;阅读页在正文上方显示本章插图缩略图、点击后在弹窗里查看原图(章节列表不显示章节图片),播放器只在本章有音频时出现;书籍页章节列表每行只有一个「编辑」入口,标题、作者、正文、插图与音频在同一对话框内编辑(标题/作者/正文点保存后生效,文件选中后立即上传);章节作者为可选自由文本(≤120 字符),阅读页在标题下显示;schema 为 v10;播放位置粒度改为**账号+章节**,切换章节时上报一次。类型按文件内容判定(MP3、JPG/PNG/WebP),音频 ≤20 MiB、图片 ≤2 MiB 且 ≤4096×4096,读取需会话并支持 HTTP Range 与 ETag。schema 升到 v9(两张新表 + 幂等清空书级音频历史行,需显式迁移);**书级音频接口已下线**。附件仍存于数据库,因此 #15 的备份恢复自动覆盖,第三次恢复演练验证了附件按字节恢复且 Range 可用。
|
||||
|
||||
**已知代价**:#21 交付的书级音频功能整体作废(其测试期数据在迁移中清空且回退无法找回);前端实现与播放器组件被复用,未推倒重做。下次再调整附件挂载粒度前应先确认真实使用场景。**真机听感与浏览器真实解码播放仍未验证**。其余待办:#24 书库列表优化与缺陷 #32。
|
||||
|
||||
## #37 交付状态更新(2026-09-15)
|
||||
|
||||
用户的真实场景「多篇文章合并成一本书、每章各有音频,并为每章加插图」已实现,待用户验收:音频与插图改为**章节级**(一章各一张),封面维持**书级**;阅读页在正文上方显示本章插图缩略图、点击后在弹窗里查看原图(章节列表不显示章节图片),播放器只在本章有音频时出现;书籍页章节列表每行只有一个「编辑」入口,标题、作者、正文、插图与音频在同一对话框内编辑(标题/作者/正文点保存后生效,文件选中后立即上传);**书籍作者**与**章节作者**是两份互相独立的可选自由文本(各 ≤120 字符,留空即清空),书级作者显示在书籍页书名右侧并由「编辑书籍」维护,章节作者显示在阅读页标题下;schema 为 v11;播放位置粒度改为**账号+章节**,切换章节时上报一次。类型按文件内容判定(MP3、JPG/PNG/WebP),音频 ≤20 MiB、图片 ≤2 MiB 且 ≤4096×4096,读取需会话并支持 HTTP Range 与 ETag。schema 升到 v9(两张新表 + 幂等清空书级音频历史行,需显式迁移);**书级音频接口已下线**。附件仍存于数据库,因此 #15 的备份恢复自动覆盖,第三次恢复演练验证了附件按字节恢复且 Range 可用。
|
||||
|
||||
**已知代价**:#21 交付的书级音频功能整体作废(其测试期数据在迁移中清空且回退无法找回);前端实现与播放器组件被复用,未推倒重做。下次再调整附件挂载粒度前应先确认真实使用场景。**真机听感与浏览器真实解码播放仍未验证**。其余待办:#24 书库列表优化与缺陷 #32。
|
||||
|
||||
+4
-2
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Home
|
||||
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Home
|
||||
wiki_revision: dd5bc8b5423219fcc90d772bafeb56845644d16b
|
||||
synchronized_at: 2026-09-15T14:49:27Z
|
||||
wiki_revision: da0f9a0feb70b7099edcf8adf50a18a15435dd33
|
||||
synchronized_at: 2026-09-15T15:10:07Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# LexGo 文档入口
|
||||
@@ -98,3 +98,5 @@ Quant-UX 原型 v1 已通过用户验收。[桌面预览](https://qux.ilapage.cn
|
||||
#37 章节级音频与章节插图已于 2026-09-15 实施完成,待用户验收:每章可以上传一段 MP3(≤20 MiB)与一张插图(JPG/PNG/WebP,≤2 MiB、≤4096×4096),封面仍按书;阅读页在正文上方显示本章插图的缩略图、点击后在弹窗里查看原图(书籍页章节列表不显示章节图片),播放器只在本章有音频时出现且不与文本同步,播放位置按「账号+章节」保存并在切换章节时上报;书籍页章节列表每行只有一个「编辑」,点开后在同一对话框里编辑标题、正文与本章插图、音频(标题与正文点保存后生效,文件选中后立即上传)。类型按文件内容判定,读取需会话并支持 HTTP Range 与 ETag;附件存于数据库,包含在 #15 的备份恢复范围内(第三次恢复演练已验证按字节恢复)。schema v9 需显式迁移,书级音频接口已下线,其实测试期数据在迁移中清空。真机听感与浏览器真实解码播放仍需人工确认。
|
||||
|
||||
#37 章节级音频与章节插图已于 2026-09-15 实施完成,待用户验收:每章可以上传一段 MP3(≤20 MiB)与一张插图(JPG/PNG/WebP,≤2 MiB、≤4096×4096),封面仍按书;阅读页在正文上方显示本章插图的缩略图、点击后在弹窗里查看原图(书籍页章节列表不显示章节图片),播放器只在本章有音频时出现且不与文本同步,播放位置按「账号+章节」保存并在切换章节时上报;书籍页章节列表每行只有一个「编辑」,点开后在同一对话框里编辑标题、作者、正文与本章插图、音频(标题/作者/正文点保存后生效,文件选中后立即上传;阅读页在标题下显示作者)。类型按文件内容判定,读取需会话并支持 HTTP Range 与 ETag;附件存于数据库,包含在 #15 的备份恢复范围内(第三次恢复演练已验证按字节恢复)。schema v9 需显式迁移,书级音频接口已下线,其实测试期数据在迁移中清空。真机听感与浏览器真实解码播放仍需人工确认。
|
||||
|
||||
#37 章节级音频与章节插图已于 2026-09-15 实施完成,待用户验收:每章可以上传一段 MP3(≤20 MiB)与一张插图(JPG/PNG/WebP,≤2 MiB、≤4096×4096),封面仍按书;阅读页在正文上方显示本章插图的缩略图、点击后在弹窗里查看原图(书籍页章节列表不显示章节图片),播放器只在本章有音频时出现且不与文本同步,播放位置按「账号+章节」保存并在切换章节时上报;书籍页的封面压成一行紧凑控件(预览+上传/替换/移除),书名右侧显示书级作者,「编辑书籍」可改书名与作者,章节列表每行只有一个「编辑」,点开后在同一对话框里编辑标题、作者、正文与本章插图、音频(标题/作者/正文点保存后生效,文件选中后立即上传;阅读页在标题下显示作者)。类型按文件内容判定,读取需会话并支持 HTTP Range 与 ETag;附件存于数据库,包含在 #15 的备份恢复范围内(第三次恢复演练已验证按字节恢复)。schema v9 需显式迁移,书级音频接口已下线,其实测试期数据在迁移中清空。真机听感与浏览器真实解码播放仍需人工确认。
|
||||
|
||||
@@ -105,7 +105,9 @@ test('the library shows the cover and the chapter dialog manages its files', asy
|
||||
|
||||
await page.getByRole('link', { name: 'Fictional reader', exact: true }).click()
|
||||
await expect(page.getByTestId('attachments')).toBeVisible()
|
||||
await expect(page.getByTestId('cover-state')).toContainText('PNG')
|
||||
// The cover block is one compact row: preview plus actions, no headings or size hints.
|
||||
await expect(page.getByTestId('cover-preview')).toBeVisible()
|
||||
await expect(page.locator('.cover-row')).not.toContainText('像素不超过')
|
||||
await expect(page.locator('.chapter-meta')).toContainText('有音频')
|
||||
// The chapter row carries no image: it only has the audio meta and the attachment entry point.
|
||||
await expect(page.getByTestId('chapter-thumb-3')).toHaveCount(0)
|
||||
@@ -224,7 +226,8 @@ test('a book without attachments shows placeholders and no player', async ({ pag
|
||||
// The management sections stay: adding a file must be possible before one exists.
|
||||
await page.getByRole('link', { name: 'Fictional reader', exact: true }).click()
|
||||
await expect(page.getByTestId('attachments')).toBeVisible()
|
||||
await expect(page.getByTestId('cover-state')).toContainText('未设置')
|
||||
await expect(page.getByTestId('cover-preview')).toHaveCount(0)
|
||||
await expect(page.getByTestId('cover-input')).toBeAttached()
|
||||
await expect(page.getByTestId('chapter-thumb-3')).toHaveCount(0)
|
||||
await page.getByTestId('edit-chapter-3').click()
|
||||
await expect(page.getByTestId('illustration-state')).toContainText('未设置')
|
||||
|
||||
@@ -43,6 +43,7 @@ test('rename the book, edit a chapter into a new version and delete both', async
|
||||
const body = route.request().postDataJSON() as { title?: string; text?: string }
|
||||
const changed = body.text !== undefined && body.text !== firstText
|
||||
if (body.title !== undefined) first.title = body.title
|
||||
if (body.author !== undefined) first.author = body.author
|
||||
if (changed) {
|
||||
firstText = body.text as string
|
||||
processing = true
|
||||
@@ -70,11 +71,12 @@ test('rename the book, edit a chapter into a new version and delete both', async
|
||||
|
||||
// Rename the book through the dialog.
|
||||
await page.getByTestId('edit-book').click()
|
||||
await expect(page.getByRole('dialog', { name: '编辑书籍' })).toBeVisible()
|
||||
await expectModalOverlay(page, page.getByTestId('book-dialog'))
|
||||
await expect(page.getByLabel('书名', { exact: true })).toHaveValue('A small step')
|
||||
await page.getByLabel('书名', { exact: true }).fill('A long step')
|
||||
await page.getByTestId('save-book').click()
|
||||
await expect(page.getByTestId('book-notice')).toContainText('书名已更新')
|
||||
await expect(page.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.
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useSessionStore } from '../stores/session'
|
||||
|
||||
const user = { id: 42, username: 'fictional-attachment', role: 'learner' as const }
|
||||
const cover = { kind: 'cover' as const, mime: 'image/png', byteSize: 1234, version: 'cover-sha', updatedAt: '2026-09-15T00:00:00Z' }
|
||||
const book = { id: 7, title: 'Fictional reader', language: 'en' }
|
||||
const book = { id: 7, title: 'Fictional reader', author: '', language: 'en' }
|
||||
const pngBytes = new Uint8Array([1, 2, 3, 4])
|
||||
const baseChapter = {
|
||||
id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', author: '', status: 'ready' as const, charCount: 31,
|
||||
@@ -149,7 +149,13 @@ describe('chapter attachments on the book page', () => {
|
||||
it('shows the cover and the chapter audio state without any chapter image', async () => {
|
||||
const { fetchMock } = mockApi()
|
||||
const { view } = await mountWith(BookView, '/books/7')
|
||||
expect(view.get('[data-testid="cover-state"]').text()).toContain('PNG')
|
||||
// The cover block is one compact row now: preview and actions, no headings or hints.
|
||||
await vi.waitFor(() => expect(useLibraryStore().coverUrls[7]).toBeTruthy())
|
||||
await flushPromises()
|
||||
expect(view.find('[data-testid="cover-preview"]').exists()).toBe(true)
|
||||
expect(view.find('[data-testid="cover-input"]').exists()).toBe(true)
|
||||
expect(view.text()).not.toContain('书籍封面')
|
||||
expect(view.text()).not.toContain('像素不超过')
|
||||
await flushPromises()
|
||||
// The chapter list shows no illustration and does not fetch one.
|
||||
expect(view.find('[data-testid="chapter-thumb-3"]').exists()).toBe(false)
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useLibraryStore, type ChapterSource, type ChapterSummary } from '../sto
|
||||
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 book = { id: 1, title: 'A small step', author: '', language: 'en' }
|
||||
const timestamps = { createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' }
|
||||
const chapter = (overrides: Partial<ChapterSummary> = {}): ChapterSummary => ({
|
||||
id: 9, bookId: 1, ordinal: 1, title: 'First chapter', author: '', status: 'ready', charCount: 12,
|
||||
@@ -49,8 +49,8 @@ function mockApi(overrides: Record<string, (init?: RequestInit) => Response | Pr
|
||||
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 } })
|
||||
const body = JSON.parse(String((init as RequestInit | undefined)?.body ?? '{}')) as { title?: string; author?: string }
|
||||
return ok({ book: { ...book, title: body.title ?? book.title, author: body.author ?? book.author } })
|
||||
}
|
||||
if (url.endsWith('/books/1') && method === 'DELETE') return ok({ deleted: { bookId: 1, chapters: 3 } })
|
||||
if (url.endsWith('/books/1')) return ok({ book })
|
||||
@@ -71,24 +71,26 @@ describe('book editing store', () => {
|
||||
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear() })
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
it('renames a book and keeps the list entry in step', async () => {
|
||||
it('saves the title and the author and keeps the list entry in step', async () => {
|
||||
const fetchMock = mockApi()
|
||||
const library = useLibraryStore()
|
||||
library.books = [{ ...book, chapterCount: 1, pendingCount: 0, processingCount: 0, readyCount: 1, failedCount: 0, ...timestamps }]
|
||||
library.book = { ...book }
|
||||
const renamed = await library.renameBook(1, ' New name ')
|
||||
const renamed = await library.updateBook(1, { title: ' New name ', author: ' Fictional author ' })
|
||||
expect(renamed.title).toBe('New name')
|
||||
expect(renamed.author).toBe('Fictional author')
|
||||
expect(library.book?.title).toBe('New name')
|
||||
expect(library.books[0]?.title).toBe('New name')
|
||||
expect(library.books[0]?.author).toBe('Fictional author')
|
||||
const call = fetchMock.mock.calls.find(([, init]) => (init as RequestInit | undefined)?.method === 'PATCH')!
|
||||
expect(String(call[0])).toBe('/api/v1/books/1')
|
||||
expect(JSON.parse(String((call[1] as RequestInit).body))).toEqual({ title: 'New name' })
|
||||
expect(JSON.parse(String((call[1] as RequestInit).body))).toEqual({ title: 'New name', author: 'Fictional author' })
|
||||
})
|
||||
|
||||
it('refuses an invalid title before calling the API', async () => {
|
||||
const fetchMock = mockApi()
|
||||
const library = useLibraryStore()
|
||||
await expect(library.renameBook(1, ' ')).rejects.toThrow('请填写标题')
|
||||
await expect(library.updateBook(1, { title: ' ', author: '' })).rejects.toThrow('请填写标题')
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -155,10 +157,14 @@ describe('book editing view', () => {
|
||||
const input = view.get('#book-title')
|
||||
expect((input.element as HTMLInputElement).value).toBe('A small step')
|
||||
await input.setValue(' Edited name ')
|
||||
// The same dialog also carries the book author.
|
||||
expect(view.find('[data-testid="book-author-input"]').exists()).toBe(true)
|
||||
await view.get('[data-testid="book-author-input"]').setValue(' Fictional author ')
|
||||
await view.get('[data-testid="save-book"]').trigger('click'); await flushPromises()
|
||||
const patch = fetchMock.mock.calls.find(([, init]) => (init as RequestInit | undefined)?.method === 'PATCH')!
|
||||
expect(JSON.parse(String((patch[1] as RequestInit).body))).toEqual({ title: 'Edited name' })
|
||||
expect(view.get('[data-testid="book-notice"]').text()).toContain('书名已更新')
|
||||
expect(JSON.parse(String((patch[1] as RequestInit).body))).toEqual({ title: 'Edited name', author: 'Fictional author' })
|
||||
expect(view.get('[data-testid="book-notice"]').text()).toContain('书籍信息已更新')
|
||||
expect(view.get('[data-testid="book-author"]').text()).toBe('Fictional author')
|
||||
})
|
||||
|
||||
it('keeps the dialog open with the server message when renaming fails', async () => {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useSessionStore } from '../stores/session'
|
||||
|
||||
// All accounts, books and texts in these tests are deliberately fictitious.
|
||||
const user = { id: 7, username: 'fictional-reader', role: 'learner' as const }
|
||||
const book = { id: 1, title: '虚构样例书', language: 'en' }
|
||||
const book = { id: 1, title: '虚构样例书', author: '', language: 'en' }
|
||||
const navigation = { previousChapterId: null, nextChapterId: null }
|
||||
|
||||
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }), { status: 200 })
|
||||
|
||||
@@ -175,7 +175,7 @@ describe('library completion action', () => {
|
||||
const url = String(input)
|
||||
if (url.includes('/chapters/3') && !url.includes('complete')) {
|
||||
return ok({
|
||||
book: { id: 7, title: 'Fictional reader', language: 'en' },
|
||||
book: { id: 7, title: 'Fictional reader', author: '', language: 'en' },
|
||||
chapter: { id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', author: '', status: 'ready', charCount: 31, errorReason: '', errorMessage: '', jobId: 9, readAt: null, createdAt: '', updatedAt: '', contentSha256: 'sha', originalText: 'Curiosity opens the first door.\n' },
|
||||
navigation: { previousChapterId: null, nextChapterId: null },
|
||||
})
|
||||
@@ -226,7 +226,7 @@ describe('library completion action', () => {
|
||||
if (url.includes('/tokens')) return ok({ tokens: [] })
|
||||
if (url.includes('/chapters/3')) {
|
||||
return ok({
|
||||
book: { id: 7, title: 'Fictional reader', language: 'en' },
|
||||
book: { id: 7, title: 'Fictional reader', author: '', language: 'en' },
|
||||
chapter: { id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', author: '', status: 'ready', charCount: 31, errorReason: '', errorMessage: '', jobId: 9, readAt: null, createdAt: '', updatedAt: '', contentSha256: 'sha', originalText: 'Curiosity opens the first door.\n' },
|
||||
navigation: { previousChapterId: null, nextChapterId: null },
|
||||
})
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface ChapterAttachment {
|
||||
export interface BookRef {
|
||||
id: number
|
||||
title: string
|
||||
/** Optional free text for the book itself; chapters carry their own author. */
|
||||
author: string
|
||||
language: string
|
||||
attachments?: BookAttachments | null
|
||||
}
|
||||
@@ -520,17 +522,22 @@ export const useLibraryStore = defineStore('library', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** Renames one owned book; the reply is the stored book. */
|
||||
async function renameBook(id: number, title: string): Promise<BookRef> {
|
||||
/** Saves the title (and the optional author) of one owned book; the reply is the stored book. */
|
||||
async function updateBook(id: number, input: { title: string; author: string }): Promise<BookRef> {
|
||||
const version = generation
|
||||
const owner = ownerId()
|
||||
const problem = titleProblem(title)
|
||||
const problem = titleProblem(input.title)
|
||||
if (problem) throw new Error(problem)
|
||||
const result = await session.request<{ book: BookRef }>(`books/${id}`, 'PATCH', { title: title.trim() })
|
||||
const result = await session.request<{ book: BookRef }>(`books/${id}`, 'PATCH', {
|
||||
title: input.title.trim(), author: input.author.trim(),
|
||||
})
|
||||
if (!isStale(version, owner)) {
|
||||
book.value = result.book
|
||||
const listed = books.value.find(item => item.id === id)
|
||||
if (listed) listed.title = result.book.title
|
||||
if (listed) {
|
||||
listed.title = result.book.title
|
||||
listed.author = result.book.author
|
||||
}
|
||||
}
|
||||
return result.book
|
||||
}
|
||||
@@ -943,7 +950,7 @@ export const useLibraryStore = defineStore('library', () => {
|
||||
uploadCover, deleteCover, uploadChapterFile, deleteChapterFile, saveChapterPlayback, reportChapterPlayback,
|
||||
uploadingKind, uploadingId,
|
||||
coverUrls, illustrationUrls, audioUrl, audioChapterId, loadCover, loadChapterIllustration, loadChapterAudio,
|
||||
renameBook, updateChapter, loadChapterSource, deleteBook, deleteChapter,
|
||||
updateBook, updateChapter, loadChapterSource, deleteBook, deleteChapter,
|
||||
stopPolling, closeBook, closeChapter, reset,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -354,6 +354,18 @@ button:focus-visible, a:focus-visible, [role='button']:focus-visible,
|
||||
.illustration-thumb:focus-visible { outline: 3px solid var(--accent-marker); outline-offset: 3px; }
|
||||
.illustration-hint { font-size: 12px; color: var(--text-muted); }
|
||||
.illustration-full { display: block; max-width: min(88vw, 1200px); max-height: 78vh; width: auto; height: auto; object-fit: contain; }
|
||||
/* The book page keeps the cover control to one compact row so the chapter list gets the space. */
|
||||
.cover-row { display: flex; align-items: center; gap: 14px; margin: 0 0 18px; }
|
||||
.cover-preview { flex: 0 0 120px; aspect-ratio: 16 / 9; border-radius: 10px; overflow: hidden; background: var(--accent-soft); }
|
||||
.cover-preview img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.cover-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.cover-actions input[type='file'] { position: absolute; width: 1px; height: 1px; opacity: 0; overflow: hidden; }
|
||||
.cover-actions label { position: relative; cursor: pointer; border: 1px solid var(--border-strong); border-radius: 20px; padding: 0 14px; background: var(--surface-raised); }
|
||||
.cover-actions label.is-busy { opacity: 0.6; cursor: progress; }
|
||||
/* The book title shows its author next to it. */
|
||||
.title-line { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
||||
.title-line h1 { margin: 14px 0 6px; }
|
||||
.book-author { color: var(--text-muted); font-size: 15px; }
|
||||
/* Book covers: the card shows the stored image or a default block built from the title. */
|
||||
.book-cover { display: block; aspect-ratio: 16 / 9; border-radius: 10px; overflow: hidden; background: var(--accent-soft); text-decoration: none; }
|
||||
.book-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
|
||||
@@ -16,6 +16,7 @@ const notice = ref('')
|
||||
// Renaming the book.
|
||||
const bookDialog = ref(false)
|
||||
const bookTitle = ref('')
|
||||
const bookAuthor = ref('')
|
||||
const bookError = ref('')
|
||||
// Editing one chapter: title and text, loaded from the source endpoint.
|
||||
const chapterDialog = ref(false)
|
||||
@@ -214,6 +215,7 @@ async function retry(chapterId: number) {
|
||||
|
||||
function openBookDialog() {
|
||||
bookTitle.value = library.book?.title ?? ''
|
||||
bookAuthor.value = library.book?.author ?? ''
|
||||
bookError.value = ''
|
||||
bookDialog.value = true
|
||||
}
|
||||
@@ -224,9 +226,9 @@ async function saveBookTitle() {
|
||||
if (bookError.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
await library.renameBook(library.book.id, bookTitle.value)
|
||||
await library.updateBook(library.book.id, { title: bookTitle.value, author: bookAuthor.value })
|
||||
bookDialog.value = false
|
||||
notice.value = '书名已更新。'
|
||||
notice.value = '书籍信息已更新。'
|
||||
} catch (reason) {
|
||||
bookError.value = reason instanceof Error ? reason.message : '保存失败,请稍后重试。'
|
||||
} finally {
|
||||
@@ -339,37 +341,31 @@ onUnmounted(() => library.closeBook())
|
||||
<template v-else-if="library.book">
|
||||
<div class="page-title">
|
||||
<div>
|
||||
<h1>{{ library.book.title }}</h1>
|
||||
<div class="title-line">
|
||||
<h1>{{ library.book.title }}</h1>
|
||||
<span v-if="library.book.author" class="book-author" data-testid="book-author">{{ library.book.author }}</span>
|
||||
</div>
|
||||
<p class="subtle">{{ library.chapters.length }} 个章节 · 语言 英语</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<ElButton data-testid="edit-book" @click="openBookDialog">编辑书名</ElButton>
|
||||
<ElButton data-testid="edit-book" @click="openBookDialog">编辑书籍</ElButton>
|
||||
<RouterLink :to="`/import?book=${library.book.id}`" class="link-button">追加章节</RouterLink>
|
||||
<ElButton type="danger" plain data-testid="delete-book" @click="confirmDeleteBook">删除书籍</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="notice" role="status" class="chapter-notice" data-testid="book-notice">{{ notice }}</p>
|
||||
<section class="attachments" aria-label="封面与章节附件" data-testid="attachments">
|
||||
<h2>封面</h2>
|
||||
<div class="attachment-row">
|
||||
<div class="attachment-preview">
|
||||
<img v-if="library.coverUrls[library.book.id]" :src="library.coverUrls[library.book.id]" alt="当前封面" data-testid="cover-preview" />
|
||||
<span v-else class="book-cover-default" aria-hidden="true">{{ library.book.title.slice(0, 1) }}</span>
|
||||
</div>
|
||||
<div class="attachment-info">
|
||||
<p class="attachment-label">书籍封面</p>
|
||||
<p class="subtle" data-testid="cover-state">{{ coverState }}</p>
|
||||
<p v-if="coverError" role="alert" class="field-error">{{ coverError }}</p>
|
||||
<div class="attachment-actions">
|
||||
<label class="link-button" :class="{ 'is-busy': library.uploadingKind === 'cover' }">
|
||||
{{ library.book.attachments?.cover ? '替换封面' : '上传封面' }}
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp" data-testid="cover-input" :disabled="library.uploadingKind !== null" @change="onCoverPicked" />
|
||||
</label>
|
||||
<ElButton v-if="library.book.attachments?.cover" size="small" :disabled="library.uploadingKind !== null" data-testid="cover-remove" @click="removeCover">移除</ElButton>
|
||||
</div>
|
||||
<p class="subtle">JPG、PNG 或 WebP,最大 2 MiB,像素不超过 4096×4096。</p>
|
||||
<p class="subtle">音频与插图按章节设置,见下方章节列表的「附件」。</p>
|
||||
</div>
|
||||
<section class="cover-row" aria-label="书籍封面" data-testid="attachments">
|
||||
<div class="cover-preview">
|
||||
<img v-if="library.coverUrls[library.book.id]" :src="library.coverUrls[library.book.id]" alt="当前封面" data-testid="cover-preview" />
|
||||
<span v-else class="book-cover-default" aria-hidden="true">{{ library.book.title.slice(0, 1) }}</span>
|
||||
</div>
|
||||
<div class="cover-actions">
|
||||
<label class="link-button" :class="{ 'is-busy': library.uploadingKind === 'cover' }">
|
||||
{{ library.book.attachments?.cover ? '替换封面' : '上传封面' }}
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp" data-testid="cover-input" :disabled="library.uploadingKind !== null" @change="onCoverPicked" />
|
||||
</label>
|
||||
<ElButton v-if="library.book.attachments?.cover" size="small" text :disabled="library.uploadingKind !== null" data-testid="cover-remove" @click="removeCover">移除</ElButton>
|
||||
<span v-if="coverError" role="alert" class="field-error">{{ coverError }}</span>
|
||||
</div>
|
||||
</section>
|
||||
<p v-if="retryError" role="alert" class="notice">{{ retryError }}</p>
|
||||
@@ -402,10 +398,15 @@ onUnmounted(() => library.closeBook())
|
||||
</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>
|
||||
<ElDialog v-model="bookDialog" title="编辑书籍" width="460" data-testid="book-dialog">
|
||||
<div class="field-row">
|
||||
<label for="book-title">书名</label>
|
||||
<ElInput id="book-title" v-model="bookTitle" type="text" maxlength="200" :disabled="saving" />
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label for="book-author">作者</label>
|
||||
<ElInput id="book-author" v-model="bookAuthor" type="text" maxlength="120" placeholder="可留空" :disabled="saving" data-testid="book-author-input" />
|
||||
</div>
|
||||
<p v-if="bookError" role="alert" class="field-error">{{ bookError }}</p>
|
||||
<template #footer>
|
||||
<ElButton @click="bookDialog = false">取消</ElButton>
|
||||
|
||||
@@ -100,12 +100,17 @@ func Migrate(db *gorm.DB) error {
|
||||
return fmt.Errorf("migration statement %d failed", i+1)
|
||||
}
|
||||
}
|
||||
// The author columns are added conditionally after the statements above: MySQL has no
|
||||
// ADD COLUMN IF NOT EXISTS, an older database only gains its book and chapter tables in those
|
||||
// statements, and the version row is still advanced only once everything succeeded, so a
|
||||
// partially applied or rolled-back migration can be replayed.
|
||||
if current < 10 {
|
||||
// v10 adds the chapter author. MySQL has no ADD COLUMN IF NOT EXISTS, so the column is added
|
||||
// conditionally: it has to run after the statements above, because an older database only
|
||||
// gains its chapter table there, and the version row is still only advanced once everything
|
||||
// succeeded, so a rolled-back marker can be upgraded again.
|
||||
if err = addChapterAuthorColumn(ctx, conn); err != nil {
|
||||
if err = addAuthorColumn(ctx, conn, "lexgo_chapters"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if current < 11 {
|
||||
if err = addAuthorColumn(ctx, conn, "lexgo_books"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -115,20 +120,21 @@ func Migrate(db *gorm.DB) error {
|
||||
|
||||
// SchemaVersion is the version an explicit migration leaves behind, and the
|
||||
// version the server requires before it starts.
|
||||
const SchemaVersion = 10
|
||||
const SchemaVersion = 11
|
||||
|
||||
// addChapterAuthorColumn adds lexgo_chapters.author when it is missing, so replaying the migration
|
||||
// stays safe just like the CREATE TABLE IF NOT EXISTS statements around it.
|
||||
func addChapterAuthorColumn(ctx context.Context, conn *sql.Conn) error {
|
||||
// addAuthorColumn adds an optional author column when it is missing, so replaying the migration
|
||||
// stays safe just like the CREATE TABLE IF NOT EXISTS statements around it. The table name is a
|
||||
// constant from this file, never user input.
|
||||
func addAuthorColumn(ctx context.Context, conn *sql.Conn, table string) error {
|
||||
var exists int
|
||||
if err := conn.QueryRowContext(ctx,
|
||||
"SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lexgo_chapters' AND COLUMN_NAME = 'author'").Scan(&exists); err != nil {
|
||||
"SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = 'author'", table).Scan(&exists); err != nil {
|
||||
return err
|
||||
}
|
||||
if exists > 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := conn.ExecContext(ctx, "ALTER TABLE lexgo_chapters ADD COLUMN author VARCHAR(120) NOT NULL DEFAULT ''")
|
||||
_, err := conn.ExecContext(ctx, "ALTER TABLE `"+table+"` ADD COLUMN author VARCHAR(120) NOT NULL DEFAULT ''")
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -313,7 +319,8 @@ var schemaV2Statements = []string{
|
||||
var schemaV3Statements = []string{
|
||||
`CREATE TABLE IF NOT EXISTS lexgo_books (
|
||||
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, owner_id BIGINT NOT NULL,
|
||||
title VARCHAR(120) NOT NULL, language VARCHAR(16) NOT NULL DEFAULT 'en',
|
||||
title VARCHAR(120) NOT NULL, author VARCHAR(120) NOT NULL DEFAULT '',
|
||||
language VARCHAR(16) NOT NULL DEFAULT 'en',
|
||||
created_at DATETIME(3) NOT NULL, updated_at DATETIME(3) NOT NULL,
|
||||
INDEX (owner_id, updated_at, id),
|
||||
FOREIGN KEY (owner_id) REFERENCES sys_user(user_id) ON DELETE CASCADE
|
||||
|
||||
@@ -17,7 +17,8 @@ import (
|
||||
// review rows or answers: those belong to the learner, not to a book or a chapter.
|
||||
|
||||
type BookUpdateInput struct {
|
||||
Title string `json:"title"`
|
||||
Title string `json:"title"`
|
||||
Author *string `json:"author"`
|
||||
}
|
||||
|
||||
type ChapterUpdateInput struct {
|
||||
@@ -98,9 +99,20 @@ func RenameBook(tx *gorm.DB, owner int, bookID int64, input BookUpdateInput, now
|
||||
return BookRef{}, err
|
||||
}
|
||||
book.Title = title
|
||||
book.UpdatedAt = stamp(now)
|
||||
ts := stamp(now)
|
||||
updates := map[string]any{"title": title, "updated_at": ts}
|
||||
// The author is optional; omitting it leaves whatever the book already had.
|
||||
if input.Author != nil {
|
||||
author, err := editAuthor(*input.Author)
|
||||
if err != nil {
|
||||
return BookRef{}, err
|
||||
}
|
||||
book.Author = author
|
||||
updates["author"] = author
|
||||
}
|
||||
book.UpdatedAt = ts
|
||||
if err = tx.Model(&Book{}).Where("id = ? AND owner_id = ?", book.ID, owner).
|
||||
Updates(map[string]any{"title": title, "updated_at": book.UpdatedAt}).Error; err != nil {
|
||||
Updates(updates).Error; err != nil {
|
||||
return BookRef{}, err
|
||||
}
|
||||
return bookRef(book), nil
|
||||
|
||||
@@ -626,3 +626,96 @@ func TestMySQLChapterAuthorRoundTrip(t *testing.T) {
|
||||
t.Fatal("an empty edit must still be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMySQLBookAuthorRoundTrip covers the book author: it is saved with the title, list and detail
|
||||
// carry it, it is trimmed and capped, and an empty author clears it.
|
||||
func TestMySQLBookAuthorRoundTrip(t *testing.T) {
|
||||
db, r, owner := libraryFixture(t)
|
||||
learner := newLearner(t, r, owner.Token)
|
||||
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
|
||||
"requestId": "book-author-0001", "title": "Fictional book author", "text": "Curiosity opens the first door." + string(rune(10)), "language": "en"})
|
||||
if code != 201 {
|
||||
t.Fatalf("paste %d", code)
|
||||
}
|
||||
drainIngest(t, db)
|
||||
bookID := pasted.Chapter.BookID
|
||||
|
||||
// A new book has no author.
|
||||
book, _ := bookDetailOf(t, r, learner.Token, bookID)
|
||||
if book.Author != "" {
|
||||
t.Fatalf("a new book must have no author, got %q", book.Author)
|
||||
}
|
||||
|
||||
// The title and the author are saved together, and the author is trimmed.
|
||||
code, msg, data := callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/books/%d", bookID), learner.Token,
|
||||
map[string]any{"title": "Fictional book author", "author": " 虚构书籍作者 "})
|
||||
if code != 200 {
|
||||
t.Fatalf("book edit: %d %s", code, msg)
|
||||
}
|
||||
var edited struct {
|
||||
Book BookRef
|
||||
}
|
||||
if err := json.Unmarshal(data, &edited); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if edited.Book.Author != "虚构书籍作者" || edited.Book.Title != "Fictional book author" {
|
||||
t.Fatalf("book edit result: %+v", edited.Book)
|
||||
}
|
||||
code, _, listed := callRaw(t, r, "GET", "/api/v1/books", learner.Token, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("book list %d", code)
|
||||
}
|
||||
var page struct {
|
||||
Items []BookSummary
|
||||
}
|
||||
if err := json.Unmarshal(listed, &page); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page.Items) != 1 || page.Items[0].Author != "虚构书籍作者" {
|
||||
t.Fatalf("the book list must carry the author: %+v", page.Items)
|
||||
}
|
||||
book, _ = bookDetailOf(t, r, learner.Token, bookID)
|
||||
if book.Author != "虚构书籍作者" {
|
||||
t.Fatalf("the book detail must carry the author: %+v", book)
|
||||
}
|
||||
// The reader response carries the book too, so a chapter page can show it if needed.
|
||||
code, _, reader := callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), learner.Token, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("reader %d", code)
|
||||
}
|
||||
var read struct {
|
||||
Book BookRef
|
||||
}
|
||||
if err := json.Unmarshal(reader, &read); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if read.Book.Author != "虚构书籍作者" {
|
||||
t.Fatalf("the reader response must carry the book author: %+v", read.Book)
|
||||
}
|
||||
|
||||
// Omitting the author keeps it, a blank author clears it, and the length is capped.
|
||||
if code, _, _ := callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/books/%d", bookID), learner.Token,
|
||||
map[string]any{"title": "Fictional book author"}); code != 200 {
|
||||
t.Fatal("a title-only edit must be accepted")
|
||||
}
|
||||
if book, _ = bookDetailOf(t, r, learner.Token, bookID); book.Author != "虚构书籍作者" {
|
||||
t.Fatalf("omitting the author must keep it, got %q", book.Author)
|
||||
}
|
||||
if code, _, _ := callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/books/%d", bookID), learner.Token,
|
||||
map[string]any{"title": "Fictional book author", "author": " "}); code != 200 {
|
||||
t.Fatal("clearing the author must be allowed")
|
||||
}
|
||||
if book, _ = bookDetailOf(t, r, learner.Token, bookID); book.Author != "" {
|
||||
t.Fatalf("a blank author must clear the field, got %q", book.Author)
|
||||
}
|
||||
if code, _, _ := callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/books/%d", bookID), learner.Token,
|
||||
map[string]any{"title": "Fictional book author", "author": strings.Repeat("a", 121)}); code != 400 {
|
||||
t.Fatal("an over-long author must be rejected")
|
||||
}
|
||||
// Ownership still applies.
|
||||
other := newLearner(t, r, owner.Token)
|
||||
if code, _, _ := callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/books/%d", bookID), other.Token,
|
||||
map[string]any{"title": "stolen", "author": "stolen"}); code != 404 {
|
||||
t.Fatal("another account must not edit the book")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,9 +67,11 @@ func reasonMessage(reason string) string {
|
||||
}
|
||||
|
||||
type Book struct {
|
||||
ID int64 `gorm:"primaryKey"`
|
||||
OwnerID int
|
||||
Title string
|
||||
ID int64 `gorm:"primaryKey"`
|
||||
OwnerID int
|
||||
Title string
|
||||
// Author is optional free text for the book itself; a chapter has its own author.
|
||||
Author string
|
||||
Language string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
@@ -116,6 +118,7 @@ func (IngestJob) TableName() string { return "lexgo_ingest_jobs" }
|
||||
type BookSummary struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
Language string `json:"language"`
|
||||
ChapterCount int `json:"chapterCount"`
|
||||
PendingCount int `json:"pendingCount"`
|
||||
@@ -132,6 +135,7 @@ type BookSummary struct {
|
||||
type BookRef struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
Language string `json:"language"`
|
||||
// Attachments is filled by the endpoints that show a book, so a cover can be rendered from the
|
||||
// list without a request per row.
|
||||
@@ -139,7 +143,7 @@ type BookRef struct {
|
||||
}
|
||||
|
||||
func bookRef(book Book) BookRef {
|
||||
return BookRef{ID: book.ID, Title: book.Title, Language: book.Language}
|
||||
return BookRef{ID: book.ID, Title: book.Title, Author: book.Author, Language: book.Language}
|
||||
}
|
||||
|
||||
type ChapterSummary struct {
|
||||
@@ -501,7 +505,7 @@ func ListBooks(db *gorm.DB, owner int) ([]BookSummary, error) {
|
||||
items := make([]BookSummary, 0, len(books))
|
||||
ids := make([]int64, 0, len(books))
|
||||
for _, b := range books {
|
||||
items = append(items, BookSummary{ID: b.ID, Title: b.Title, Language: b.Language, CreatedAt: b.CreatedAt, UpdatedAt: b.UpdatedAt})
|
||||
items = append(items, BookSummary{ID: b.ID, Title: b.Title, Author: b.Author, Language: b.Language, CreatedAt: b.CreatedAt, UpdatedAt: b.UpdatedAt})
|
||||
ids = append(ids, b.ID)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
|
||||
@@ -727,3 +727,65 @@ func TestMigrationFromV9AddsChapterAuthor(t *testing.T) {
|
||||
t.Fatalf("re-upgrade must keep the author, got %q", restored.Author)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationFromV10AddsBookAuthor(t *testing.T) {
|
||||
db := emptyMigrationDB(t)
|
||||
statements := []string{"CREATE TABLE lexgo_schema (id INT PRIMARY KEY,version INT,product VARCHAR(32))", "INSERT INTO lexgo_schema VALUES (1,10,'lexgo')"}
|
||||
statements = append(statements, schemaV2Statements...)
|
||||
statements = append(statements, schemaV3Statements...)
|
||||
statements = append(statements, schemaV4Statements...)
|
||||
statements = append(statements, schemaV5Statements...)
|
||||
statements = append(statements, schemaV6Statements...)
|
||||
statements = append(statements, schemaV7Statements...)
|
||||
statements = append(statements, schemaV8Statements...)
|
||||
statements = append(statements, schemaV9Statements...)
|
||||
for _, statement := range statements {
|
||||
if err := db.Exec(statement).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Model the database v10 left behind: a book table without the author column.
|
||||
if err := db.Exec("ALTER TABLE lexgo_books DROP COLUMN author").Error; err == nil {
|
||||
t.Log("dropped the author column created by the v3 statements")
|
||||
}
|
||||
if err := db.Exec("INSERT INTO sys_user (user_id,username,password,role_id) VALUES (61,'fixture_v10','fictional-not-a-real-hash',2)").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saved := stamp(time.Now())
|
||||
if err := db.Exec("INSERT INTO lexgo_books (id, owner_id, title, language, created_at, updated_at) VALUES (?,?,?,?,?,?)",
|
||||
900, 61, "Fictional v10 book", "en", saved, saved).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CheckSchema(db); err == nil {
|
||||
t.Fatal("old schema accepted before explicit migration")
|
||||
}
|
||||
if err := Migrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CheckSchema(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var book Book
|
||||
if err := db.Where("id = ?", 900).First(&book).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if book.Author != "" || book.Title != "Fictional v10 book" {
|
||||
t.Fatalf("migration changed the book: %+v", book)
|
||||
}
|
||||
if err := db.Exec("UPDATE lexgo_books SET author = ? WHERE id = ?", "虚构书籍作者", 900).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Replaying the migration must not fail on the column that is already there.
|
||||
if err := db.Exec("UPDATE lexgo_schema SET version=10 WHERE id=1").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Migrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Where("id = ?", 900).First(&book).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if book.Author != "虚构书籍作者" {
|
||||
t.Fatalf("re-upgrade must keep the book author, got %q", book.Author)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user