Compare commits

...
Author SHA1 Message Date
ila 728f7d6c1e feat: 书籍页精简封面、可编辑书名与作者、标题旁显示作者 (#37)
- schema v11:lexgo_books 增加 author VARCHAR(120) NOT NULL DEFAULT '',复用 v10 的条件加列
  助手(改名 addAuthorColumn,表名取自本文件常量),保持迁移可重放;新建库 v3 语句也带该列
- PATCH /api/v1/books/:id 接受可选 author(省略保留、空串清空、trim、≤120),BookSummary 与
  BookRef 返回它;「编辑书名」改为「编辑书籍」,对话框同行编辑书名与作者
- 书籍页封面压成一行紧凑控件(预览+上传/替换/移除),去掉「封面」「书籍封面」规格与
  「音频与插图按章节设置」等文字,把高度让给章节列表;书名右侧显示书级作者(未设置不显示)
- 测试:Go 91 项(新增书级作者往返与 v10→v11 迁移用例)、学习端 157 单测与 26 项 E2E
- 真实链路:封面行高 68px、删掉的四段文字不再出现、作者显示在书名同一行右侧且刷新后保留
- 文档:Architecture / Business-Rules / Local-Development / Requirements / Home 同步
2026-09-15 23:10:36 +08:00
ila 3fe9bcb058 feat: 章节增加可选作者字段,编辑对话框更紧凑 (#37)
- schema v10:lexgo_chapters 增加 author VARCHAR(120) NOT NULL DEFAULT '';由 Go 条件步骤
  addChapterAuthorColumn(先查 information_schema 再 ALTER)在语句列表之后执行,保持
  「部分迁移可重试 / 回退标记后可重新升级」,新建库的 v3 语句也带该列
- 章节编辑接受 author(可选、trim、≤120 字符、空串清空),仅改作者不重新处理章节;
  ChapterSummary 与 ChapterSource 都返回它,阅读页在标题下显示非空作者
- 对话框:标题与作者改成「标签在左、输入框在右」的同行排版,正文编辑框加高到 18 行,
  插图与音频压缩为各一两行(规格写在下方),时机说明合并为一行
- 测试:Go 89 项(新增作者往返与 v9→v10 迁移用例)、学习端 157 单测与 26 项 E2E
- 真实链路:标签同行偏差 <8px、正文高度 415px、保存作者后阅读页显示、清空后消失
- 文档:Architecture / Business-Rules / Local-Development / Requirements / Home 同步
2026-09-15 22:49:56 +08:00
ila c22f9c3f06 fix: 导入 Element Plus 对话框与确认框样式,弹窗不再是文档流块 (#37)
- main.ts 补上 dialog 与 message-box 的样式导入:自 #10 起 ElDialog/ElMessageBox 一直
  缺少样式,退化成列表后面的普通块(用户报告「编辑区显示在章节列表下面」)
- 新增 e2e/overlay.ts:expectModalOverlay / expectMessageBoxOverlay 断言弹窗位于
  .el-overlay(position: fixed)、在视口内且横向不溢出
- attachments.spec.ts(章节编辑对话框、插图弹窗)与 edit.spec.ts(书名对话框、删除确认)
  都改用该断言,堵住「只看可见性、不看是否浮层」的验证盲区
- 真实链路确认:对话框遮罩为 rgba(0,0,0,0.5)、居中于视口;确认框同样为固定浮层
- 文档:Local-Development 记录缺陷、根因、修复与新增回归断言
2026-09-15 22:22:50 +08:00
ila 771c3df976 feat: 章节附件并入章节编辑对话框 (#37)
- 章节列表每行只保留「编辑」;移除「附件」按钮与独立的附件对话框
- 章节编辑对话框同时编辑标题、正文、本章插图与本章音频,并明确写出生效时机:
  标题与正文点「保存章节」后生效,插图与音频在选中文件后立即上传、取消不撤销
- 无插图/音频的章节仍显示「未设置」,后端口与 schema 无变化(仍 v9)
- 测试:157 单测与 26 项 E2E 通过(新断言:章节行只有一个按钮、对话框含四类控件与时机说明、
  对话框内上传后仍可用保存按钮提交文本);真实链路确认按钮数为 1、上传即时生效、取消后阅读页正常
- 文档:Architecture / Business-Rules / Local-Development / Requirements / Home 同步
2026-09-15 22:14:21 +08:00
ila 3ab26afe4a feat: 章节插图改为阅读页缩略图与弹窗看原图 (#37)
- 书籍页章节列表去掉插图列,并不再为列表预取图片(省掉每章一次请求)
- 阅读页把插图换成高 120px 的缩略图按钮(可 Tab、回车/Space 打开),点击后在对话框里
  按原图显示(最大 min(88vw,1200px) × 78vh,保持比例),Esc 关闭后正文位置不变
- 无插图的章节仍不显示占位;后端与 schema 无变化(仍 v9)
- 测试:157 单测与 26 项 E2E 通过(新断言:列表无 <img> 且不请求 /illustration、缩略图是按钮、
  弹窗内为对象 URL 图片);真实链路验证列表无图、缩略图 ≤130px、弹窗原图 naturalWidth>0
- 文档:Architecture / Business-Rules / Local-Development / Requirements / Home 同步修订
2026-09-15 21:36:00 +08:00
ila 0fd661e061 feat: 音频与插图改为章节级,封面维持书级 (#37)
- schema v9:lexgo_chapter_attachments(chapter_id+kind 唯一,MEDIUMBLOB)与
  lexgo_chapter_playback_positions(owner_id+chapter_id),并用幂等语句清空书级音频历史行
- 书级音频与播放位置接口下线(路由不再注册);封面仍是书级且行为不变
- 章节级接口:POST/DELETE/GET /chapters/:id/{audio,illustration}、PUT /chapters/:id/playback;
  章节列表与阅读器响应带 illustrationVersion/audioVersion/playbackSeconds(始终存在)
- 复用 #21 的类型嗅探、尺寸校验、ServeContent Range/ETag 包装与“先校验后写入”流程
- 学习端:封面区块收窄、章节行新增缩略图与「附件」入口、独立的章节附件对话框、
  阅读页正文上方插图与章级播放器(audioChapterId 防止复用上一章音频)、切换章节上报位置
- 测试:Go 87 项(含 v8→v9 迁移与回退、书级接口已下线、章级附件集成)、学习端 157 单测与
  26 项 E2E、真实 API 52 项、#15 恢复演练第三次 22 项
- 文档:Architecture / Business-Rules / Local-Development / Requirements / Home /
  Deployment 改为章级口径
2026-09-15 21:08:43 +08:00
ila 8a25b7be8b docs: record issue 15 and 21 acceptance and merge (#15, #21)
- 两个 PR 已按顺序 fast-forward-only 合入 main:#35(#15 自托管交付)、#36(#21 附件)
- Wiki:Architecture 合并重复的 #15 段落并标记 #15/#21 已验收,Requirements 与 Home 同步,
  Project-Profile 追加当前工程状态
- README 反映验收结果,并记录章级音频与插图的新工单
- docs/ 由 harness.py sync 导出,revision:Architecture 08472d2c、Product-Requirements 2d656357、
  Home eef77f39、Project-Profile 2599afbb
2026-09-15 20:25:44 +08:00
31 changed files with 2474 additions and 706 deletions
+1
View File
@@ -287,6 +287,7 @@ MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才
- #4 独立阅读选择小样位于 `spikes/selection/`,`python spikes/selection/serve.py` 默认仅本机 5184。桌面鼠标/键盘与 11 项测试已验证,#4 已获用户验收并关闭;真实手机长按/手柄/滚动详细证据仍未提供;禁止把窄屏桌面当作真机验收。Intl.Segmenter 只用于 UI 范围验证,不替代 #3 NLP;释义保存只在内存。固定 LinguaCafe 源码对照和与 v1 的差异记录见架构 Wiki。
- 2026-09-11 用户确认正式 NLP/词典采用全 Go。#5 已验收并合入 main;#6 使用 Go WordNet 解析和词形候选、Go Unicode 原文分片、schema v4 共享词典资源表,不调用 Python NLP。WordNet 3.0 ZIP 来源与摘要见 `server/wordnet-resource.json`,许可保留在 `server/WORDNET-LICENSE.txt`。词形候选不等于上下文消歧,不自动合并个人学习状态;#3 Python 小样只保留历史验证。当前词典仅英语释义,个人释义输入为临时草稿,持久化归 #7。
- 2026-09-11 用户确认 #7 个人词条口径(三项由 Agent 定案):身份为「学习者+语言+规范化词形」,大小写合并但**不按 lemma/候选合并**(`dog` 与 `dogs` 是两条记录);首次保存默认「新词」;状态为 新词/学习中/已知/忽略,只有「学习中」带 1~7 级,对应原版 stage 2/1/0/-1~-7;例句只保存手输内容,不自动关联原文句子。schema v5 新增 `lexgo_terms`(唯一键加状态/等级检查约束),个人释义与共享词典分离且不进入审计日志;等级编辑 UI 归 #8/#12。
- 2026-09-15 用户确认 #37 章级附件口径:封面维持**书级**;音频与插图改为**章节级**(一章各一张,插图固定显示在该章正文开头、可复用为章节列表缩略图);播放位置粒度改为**账号+章节**(播放中每 5 秒、暂停、离开或切换章节时上报,替换/移除音频时重置);类型与上限沿用 #21(MP3 ≤20 MiB;图片 JPG/PNG/WebP ≤2 MiB 且 ≤4096×4096,按内容判定);读取需会话并支持 Range/206、416、ETag/304;**书级音频接口与书级播放位置接口下线**,schema v9 新增 `lexgo_chapter_attachments` 与 `lexgo_chapter_playback_positions` 并用幂等语句清空书级音频历史行(测试期数据,不迁移、回退找不回);删除章节级联清空章级附件与位置。范围外:多图与图文混排、缩略图墙、多音轨、字幕同步、转写、TTS、转码、自动搜图、公开分享。
- 2026-09-15 用户确认 #21 附件口径:每本书可选**一张封面**(JPG/PNG/WebP,≤2 MiB,≤4096×4096)与**一段 MP3**(≤20 MiB),**按文件内容 magic bytes 判定类型、不看扩展名**;附件与播放位置**存 MySQL**(dump 即完整备份、属主校验一致、删书无孤儿文件);读取需会话并支持 **HTTP Range(206)**与 ETag/304,界面用带凭据的 fetch + 对象 URL(**令牌不进 URL**,代价是无渐进式流式播放);不自动播放、**不与文本同步**、不解析时长;播放位置按账号+书籍保存(播放中每 5 秒与暂停/离开上报),**替换或移除音频时重置**;跨账号一律 404;schema v8 新增两张表(可重放 DDL,回退写回版本号 7)。范围外:多音轨/分章音轨、字幕同步、逐句跟读、转写、TTS、转码、自动搜图、在线音频地址、公开分享。**浏览器是否真实解码播放尚未验证**(测试中媒体元素被桩替换),真机听感待人工确认。
- 2026-09-15 用户确认 #15 交付口径:只交付本机可复现的安装/备份/恢复材料并在本机演练,**不对外部署、不创建 release/tag、不邀请用户**;生产入口与 HTTPS 只写入文档;前端由反向代理托管 dist(不改后端代码);试用实例从**空库**开始、管理员由显式 bootstrap 建立、不带默认密码;备份=MySQL 全库 dump + 环境配置(凭据只存运维密码库,不进仓库/日志),不新增定时备份;性能用人造数据集实测并写明环境,只作观察不给承诺。备份/恢复规则:`restore` 必须 `--confirm`、默认只写空库、覆盖需 `--force`、库名必须含 lexgo 且不能是系统库、拒绝带 CREATE DATABASE/USE 的 dump,恢复前后比对源库逐表内容校验和。**附件(#21)尚未实施,恢复契约目前只覆盖数据库**;真实回滚、HTTPS、多机与定时备份仍未验证。部署与运维规则见 Wiki 页 `Deployment-and-Operations`(镜像 `docs/11-deployment-and-operations.md`)。
- 2026-09-15 用户确认 #14 显示与键盘口径:`theme ∈ {浅色,深色,跟随系统}`(默认跟随系统)与正文字号 `{标准,大,特大}`(1.0/1.15/1.3)**按账号保存在本机** `lexgo-learner-display:<账号 id>`,切换账号即换成该账号偏好或默认,退出回到默认,**不跨设备同步**;字号经 `--reader-font-scale` 只作用于阅读面(正文、释义内容、复习卡),不做全局缩放;深色用 `html[data-theme]` + Element Plus 的 `html.dark`,`style.css` 的 `:root` 是文件内仅有的颜色字面量;阅读位置按账号+章节保存滚动比例与该章 `content_sha256`,**正文换新版本后不恢复**;复习页 `空格`/`Enter` 显示答案、`1/2/3` 评分,输入类控件与聚焦按钮的按键不被劫持,带修饰键不拦截;移动验证用 390×844+`hasTouch` 的 Playwright `mobile` 项目(桌面项目 `testIgnore: mobile-*`),**真机长按选择与手感仍需人工确认**,不得用模拟设备结果冒充真机。本单无 schema 与接口变化。
+2 -2
View File
@@ -8,7 +8,7 @@
- [英语分词与离线词典验证小样](spikes/english/README.md)(#3 已验收,独立本机入口)
- [阅读选择验证小样](spikes/selection/README.md)(#4 已验收,真机详细测试证据缺口保留)
- [项目档案](docs/00-project-profile.md) · [需求总览](docs/09-product-requirements-overview.md)
- [工作量估算](docs/10-workload-estimate.md):#2、#3、#4、#5、#6、#7、#8、#9、#10、#11、#12、#13、#14、#18 已验收,原规划中的 #5 已完成;#15 与 #21 已实现待验收;#24 待排期;缺陷 #32 待确认修复方案;后续结合集成结果重估,旧全量研究仅供参考。
- [工作量估算](docs/10-workload-estimate.md):#2、#3、#4、#5、#6、#7、#8、#9、#10、#11、#12、#13、#14、#18 已验收,原规划中的 #5 已完成;#15 与 #21 已验收;#37(章级音频与章节插图)已实现待验收;#24 待排期;缺陷 #32 待确认修复方案;后续结合集成结果重估,旧全量研究仅供参考。
- [四阶段实施总览 #16](https://git.ilapage.cn/OPC/lexgo/issues/16):14 张单元工单,工程基础 → 技术验证 → 首条学习闭环 → 补齐 MVP;原型 v1 已获用户验收。两端使用账号(用户名)+密码登录,不要求邮箱。
- [原型工单 #1](https://git.ilapage.cn/OPC/lexgo/issues/1):Quant-UX 桌面/手机原型 v1,预览入口与审核记录见工单及需求总览。
- 运维命令:`./lexgo migrate|bootstrap|serve|backup|restore|verify`(纯二进制),开发便利与交叉验证走 `python scripts/server.py …` / `python scripts/ops.py …`
@@ -16,7 +16,7 @@
## 本地工程基础
运行前在 MySQL 8 中准备项目专用空库,复制 `.env.example` 为忽略的 `.env.local` 并填写本机连接及初始管理员密码。首次执行 `python scripts/server.py migrate`、`python scripts/server.py bootstrap`(等价纯二进制路径:`./server/lexgo migrate` / `bootstrap`);随后执行 `python scripts/server.py serve`。两端分别通过 `npx --yes pnpm@9.15.1 --dir admin dev`、`npx --yes pnpm@9.15.1 --dir learner dev` 启动,首次需安装锁定依赖。书籍封面与音频附件(#21)存于数据库,包含在 `scripts/ops.py backup` 与 `./server/lexgo backup` 的备份范围内。
运行前在 MySQL 8 中准备项目专用空库,复制 `.env.example` 为忽略的 `.env.local` 并填写本机连接及初始管理员密码。首次执行 `python scripts/server.py migrate`、`python scripts/server.py bootstrap`(等价纯二进制路径:`./server/lexgo migrate` / `bootstrap`);随后执行 `python scripts/server.py serve`。两端分别通过 `npx --yes pnpm@9.15.1 --dir admin dev`、`npx --yes pnpm@9.15.1 --dir learner dev` 启动,首次需安装锁定依赖。书籍封面(书级)与章节音频、插图(章级)都存于数据库,包含在 `scripts/ops.py backup` 与 `./server/lexgo backup` 的备份范围内。
本地入口:学习端 http://127.0.0.1:5173,管理端 http://127.0.0.1:5174。完整安装与测试命令见[开发与验证](docs/04-local-development-and-verification.md)。账号使用用户名,无需邮箱;没有随代码交付的默认密码。
+10 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Project-Profile
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Project-Profile.-
wiki_revision: 5cf8e02e2248e93b1255ad85fb41153984c84cd1
synchronized_at: 2026-09-15T07:44:49Z
wiki_revision: 2599afbb5e7cb47655fabe372596ca50cacb7a60
synchronized_at: 2026-09-15T12:25:15Z
<!-- gitea-wiki-mirror:end -->
# LexGo 项目档案
@@ -178,3 +178,11 @@ server 是 go-admin 的选用模块接入:原样保留 SysUser、SysDept、必
## #14 当前工程状态(2026-09-15)
#13已通过用户验收并合入main。#14桌面与手机体验、显示偏好与键盘操作已按用户确认的契约实现,并于2026-09-15通过用户验收,PR #34已fast-forward-only合入main。本单没有数据库与接口变化。学习端站点头部新增「显示」控件:主题可选浅色、深色或跟随系统,正文字号可选标准、大或特大(1.0/1.15/1.3),两者按账号保存在本机(键名含账号id),切换账号立即换成该账号自己的偏好或默认、退出登录回到默认、非法存储值回退默认,且不跨设备同步;字号只作用于阅读面(章节正文、释义面板内容、复习卡的词与释义),不做全局缩放。样式表收敛为一套语义调色板,:root的53个变量是文件内仅有的颜色字面量,深色通过html[data-theme]与Element Plus的html.dark只覆盖变量。阅读位置按账号与章节保存滚动比例和该章content_sha256,再次打开时恢复,正文换成新版本后不再套用旧位置。复习页支持空格或Enter显示答案、1/2/3分别答对/答错/再学一次,输入框与文本域内不触发快捷键,聚焦按钮只保留它自身的空格与Enter,所有可交互元素都有可见焦点环。移动端用390×844触摸视口的Playwright项目验证:八个界面无横向溢出且都能返回,触摸滑动可滚动且不误开释义面板,深色与特大字号刷新后保持且账号间不串。第4阶段已完成#9~#14;剩余#15与#21、#24以及已定位未修复的缺陷#32。真实手机的长按选择、手柄拖动、软键盘与安全区表现仍需人工确认,本单的触摸证据来自模拟设备。
## 当前工程状态(2026-09-15)
**#15 自托管试用交付**已按用户确认的契约实现,并于2026-09-15通过用户验收,PR #35已fast-forward-only合入main。本单没有数据库与接口变化,交付三部分:运维工具(`scripts/ops.py`的依赖检查、建空库、备份、恢复、校验与两账号闭环演练,`scripts/bench.py`的写明规模性能测量)、编译进后端的纯二进制运维子命令(`lexgo backup|restore|verify`,部署机只需二进制与MySQL客户端,与工具通道共用同一份manifest格式且可互相恢复),以及新建的Wiki部署与运维页。备份的完整对象是「MySQL全库dump+环境配置」:账号、原文、词典归档、词条、复习记录、阅读进度、审计日志以及附件全部在数据库里;恢复默认只写空库、覆盖需要显式确认与`--force`、拒绝系统库与带库名切换的dump,并在恢复前后比对源库逐表内容校验和。本机演练:空库安装并建两个虚构账号走通学习闭环(19项)、纯二进制安装(9项)、完整恢复(27项)、两条通道交叉验证(19项)与附件版恢复(22项),逐表行数与内容校验和都与源库一致、源库未被改动。性能在20章/2000词条/8000条作答的人造数据集上实测并写明环境,只作观察不给承诺;**真实回滚(升级后切回旧二进制)、HTTPS、多机部署、定时备份与监控告警仍未验证**。
**#21 书籍音频与封面附件**已按用户确认的设计证据(评论8116的线框S1–S4)实现,并于2026-09-15通过用户验收,PR #36已fast-forward-only合入main。schema升到v8,新增`lexgo_book_attachments`(按`book_id+kind`唯一,音频与封面字节存`MEDIUMBLOB`)与`lexgo_playback_positions`(按`owner_id+book_id`);类型按文件头magic bytes判定(MP3/JPG/PNG/WebP,含WebP维度解析),音频≤20MiB、封面≤2MiB且≤4096×4096;上传先校验后写入,被拒绝的上传保留旧附件;读取需会话,经`http.ServeContent`提供Range/206、416、ETag/304;播放位置按账号与书籍保存,替换或移除音频时重置,跨账号一律404。学习端新增书库封面、书籍页「音频与封面」区块与阅读页常驻播放器(播放/暂停、拖动、0.75–1.5倍速、位置上报),字节经带凭据的fetch取回并转为对象URL,**令牌不进URL**。验证:Go 86项顶层用例、学习端157单测与26项E2E(连续3次全量跑通过)、真实API+MySQL 41项、附件版恢复演练22项。**浏览器是否真实解码播放未验证**(测试中媒体元素被桩替换),真机听感与兼容性待人工确认;同时修掉了#11遗留的`e2e/phrase.spec.ts`偶发失败(重试条件改为断言期望词数、重读坐标、松开前留出扩展时间)。
原MVP范围#2~#15至此全部实施并验收。此后用户提出新的使用场景(多篇网络文章合并成一本书的多章,每章各有音频并新增章节插图),已确认在#21已交付代码基础上继续改、不推倒重做;该变更的完整契约草案见#21评论8129,已另建工单#37承接。仍待处理:#24书库列表优化与缺陷#32(编辑正文回到曾用版本返回500,修复方案待确认)。
+85 -22
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: 3b785a11352776e36656da59ef9928a2cbb42694
synchronized_at: 2026-09-15T10:33:06Z
wiki_revision: 0bca600ea0dfcb18141920f448954bb9e2fa067c
synchronized_at: 2026-09-15T15:10:08Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -372,25 +372,7 @@ schema v7 新增 `lexgo_chapter_progress`:一章一行(`chapter_id` 主键
移动测试项目:`playwright.config.ts` 增加 `mobile`(390×844,`isMobile` + `hasTouch`,Chromium),桌面项目用 `testIgnore` 排除 `mobile-*.spec.ts`,移动项目用 `testMatch` 只跑它们。`e2e/mobile-fixtures.ts` 提供移动端共用的 mock、无溢出检查与经 CDP `Input.dispatchTouchEvent` 的真实触摸滑动。
## #15 自托管交付:部署拓扑与运维工具(2026-09-15)
**没有 schema 变化、没有接口变化**:本单新增运维工具、部署文档与完整演练证据。
| 新增文件 | 职责 |
|---|---|
| `scripts/ops.py` | `install-check`(依赖与资源版本)、`init-database`(建空库并提示最小权限)、`backup`(全库 dump + `manifest.json`)、`restore`(默认只写空库,写后自动校验)、`verify`(数据库完整性 + 可选两账号接口闭环)、`smoke`(在干净实例上建两个演练账号走通学习闭环) |
| `scripts/bench.py` | 在写明规模的人造数据集上测量接口耗时,输出数据量、机器信息与 p50/p95,不做容量承诺 |
| `tests/test_lexgo_ops.py` | 不需要数据库的规则测试:版本号解析、库名白名单、dump 是否带库名切换、manifest 字段白名单、审计敏感列清单 |
**一次备份包含什么**:LexGo 把所有持久数据都放在 MySQL 里——账号与会话、书籍与章节原文、导入任务、词典归档(LONGBLOB)、个人词条、复习排期与作答、阅读进度、审计日志。因此备份 = 全库 dump + `.env.local`(凭据单独从运维密码库取)。词典资源在显式导入后进入数据库,恢复即带走,运行时不下载。
**恢复的安全边界**:`restore` 必须带 `--confirm`;库名必须含 `lexgo` 且不能是系统库;目标库已有数据时默认拒绝,覆盖需要 `--force`;拒绝加载带 `CREATE DATABASE`/`USE` 的旧式 dump(那会把数据写进文件里指定的库);恢复前后比对**源库**逐表内容校验和,源库被改动就中止。
**部署文档**:新建 Wiki 页 `Deployment-and-Operations`(`docs/11-deployment-and-operations.md`,按 `docs/templates/deployment.md` 结构),并加入 `wiki-docs.json` 映射。内容含服务概览、环境要求(含「MySQL 客户端版本不得低于服务端」这条实测规则)、首次部署、配置与凭据来源、日常运维、健康检查、升级与回滚、备份与恢复、已知限制。
**视图与进程**:后端单二进制监听 `127.0.0.1:8000`;两份 SPA 由反向代理托管 `dist`,反代把 `/api/` 转发到后端并把未知路径回落到 `index.html`;本机开发用 supervisor 托管 `lexgo-api`/`lexgo-learner`/`lexgo-admin` 三个 program。
## #15 自托管交付:部署拓扑与运维工具(2026-09-15)
## #15 自托管交付:部署拓扑与运维工具(2026-09-15,已验收并合入 main)
**没有 schema 变化、没有接口变化**:本单新增运维工具、部署文档与完整演练证据。
@@ -411,7 +393,7 @@ schema v7 新增 `lexgo_chapter_progress`:一章一行(`chapter_id` 主键
**视图与进程**:后端单二进制监听 `127.0.0.1:8000`;两份 SPA 由反向代理托管 `dist`,反代把 `/api/` 转发到后端并把未知路径回落到 `index.html`;本机开发用 supervisor 托管 `lexgo-api`/`lexgo-learner`/`lexgo-admin` 三个 program。
## #21 书籍音频与封面附件(2026-09-15)
## #21 书籍音频与封面附件(2026-09-15,已验收并合入 main)
schema v8 新增两张表,都只用可重放的 `CREATE TABLE IF NOT EXISTS`:
@@ -434,3 +416,84 @@ schema v8 新增两张表,都只用可重放的 `CREATE TABLE IF NOT EXISTS`
| `GET /api/v1/books/:id`、`GET /api/v1/chapters/:id` | 书籍对象带 `attachments`:封面/音频元数据与该账号的 `playbackSeconds` |
学习端:`stores/library.ts` 用带鉴权的 `session.requestBlob` 取回字节并转成对象 URL(**不把令牌放进 URL**),库列表批量预取封面,阅读器按需取音频;`components/AudioPlayer.vue` 是播放器(播放/暂停、进度、0.75–1.5 倍速、错误重试,播放中每 5 秒与暂停/离开时上报位置);`views/LibraryView.vue` 显示封面(`aria-hidden` 的重复链接,标题链接仍是唯一可访问入口)、`views/BookView.vue` 新增「音频与封面」区块(上传/替换/移除、像素与体积提示、失败保留旧附件)、`views/ReaderView.vue` 在正文上方放常驻播放器条。
## #37 章节级音频与章节插图(2026-09-15)
**schema v9**:新增 `lexgo_chapter_attachments`(主键 `(chapter_id, kind)`,`kind ∈ {audio, illustration}`,`bytes MEDIUMBLOB`,外键级联到章节与账号)与 `lexgo_chapter_playback_positions`(主键 `(owner_id, chapter_id)`),并执行一条幂等语句 `DELETE FROM lexgo_book_attachments WHERE kind='audio'`。三张表都只用可重放的 DDL;`lexgo_book_attachments` 与 `lexgo_playback_positions` 保留结构(不删表、不改列),书级音频停止写入。
| 层 | 变化 |
|---|---|
| 书级 | 只剩**封面**:`kind='cover'`、`GET/POST/DELETE /api/v1/books/:id/cover`、`coverVersion` 缓存失效机制全部保持原样 |
| 章节级 | `POST/DELETE/GET /api/v1/chapters/:id/audio` 与 `.../illustration`、`PUT /api/v1/chapters/:id/playback` |
| 退役 | `POST/DELETE/GET /api/v1/books/:id/audio`、`PUT /api/v1/books/:id/playback`(路由不再注册,返回 404) |
| 响应 | `ChapterSummary` 增加 `illustrationVersion`、`audioVersion`、`playbackSeconds`,字段始终存在(无文件时为空串),书籍详情的章节列表与阅读器响应都带上它们;书的 `attachments` 只剩 `cover` |
复用不变:`sniffAttachment`(magic bytes 判定)、`validateAttachment`(音频 ≤20 MiB;图片 ≤2 MiB 且 ≤4096×4096)、`coverDimensions`(JPEG/PNG 用标准库、WebP 读容器头)、`binaryResponse` + `http.ServeContent`(Range/206、416、ETag/304)、上传「先校验后写入、失败保留旧文件」的流程,以及 `chapterAttachmentViews`/`chapterPlaybackSeconds` 的批量读取(一次查询喂整个章节列表)。
学习端:`stores/library.ts` 用 `uploadChapterFile`/`deleteChapterFile`/`saveChapterPlayback`/`reportChapterPlayback` 替换了书级音频动作,并新增 `illustrationUrls`(按章节)与 `audioChapterId`(记录当前加载的音频属于哪一章,切章不会复用上一章的文件);`views/BookView.vue` 的封面区块收窄为封面并新增「章节附件」对话框(`data-testid="attachment-dialog"`:插图与音频各自的预览、状态、上传/替换/移除与规格提示),章节列表行新增缩略图列与「附件」按钮;`views/ReaderView.vue` 在正文上方渲染本章插图,播放器只在**本章**有音频时出现,离开或切换章节时上报一次位置。
## #37 章节级音频与章节插图(2026-09-15)
**schema v9**:新增 `lexgo_chapter_attachments`(主键 `(chapter_id, kind)`,`kind ∈ {audio, illustration}`,`bytes MEDIUMBLOB`,外键级联到章节与账号)与 `lexgo_chapter_playback_positions`(主键 `(owner_id, chapter_id)`),并执行一条幂等语句 `DELETE FROM lexgo_book_attachments WHERE kind='audio'`。三张表都只用可重放的 DDL;`lexgo_book_attachments` 与 `lexgo_playback_positions` 保留结构(不删表、不改列),书级音频停止写入。
| 层 | 变化 |
|---|---|
| 书级 | 只剩**封面**:`kind='cover'`、`GET/POST/DELETE /api/v1/books/:id/cover`、`coverVersion` 缓存失效机制全部保持原样 |
| 章节级 | `POST/DELETE/GET /api/v1/chapters/:id/audio` 与 `.../illustration`、`PUT /api/v1/chapters/:id/playback` |
| 退役 | `POST/DELETE/GET /api/v1/books/:id/audio`、`PUT /api/v1/books/:id/playback`(路由不再注册,返回 404) |
| 响应 | `ChapterSummary` 增加 `illustrationVersion`、`audioVersion`、`playbackSeconds`,字段始终存在(无文件时为空串),书籍详情的章节列表与阅读器响应都带上它们;书的 `attachments` 只剩 `cover` |
复用不变:`sniffAttachment`(magic bytes 判定)、`validateAttachment`(音频 ≤20 MiB;图片 ≤2 MiB 且 ≤4096×4096)、`coverDimensions`(JPEG/PNG 用标准库、WebP 读容器头)、`binaryResponse` + `http.ServeContent`(Range/206、416、ETag/304)、上传「先校验后写入、失败保留旧文件」的流程,以及 `chapterAttachmentViews`/`chapterPlaybackSeconds` 的批量读取(一次查询喂整个章节列表)。
学习端:`stores/library.ts` 用 `uploadChapterFile`/`deleteChapterFile`/`saveChapterPlayback`/`reportChapterPlayback` 替换了书级音频动作,并新增 `illustrationUrls`(按章节)与 `audioChapterId`(记录当前加载的音频属于哪一章,切章不会复用上一章的文件);`views/BookView.vue` 的封面区块收窄为封面并新增「章节附件」对话框(`data-testid="attachment-dialog"`:插图与音频各自的预览、状态、上传/替换/移除与规格提示),章节列表行新增缩略图列与「附件」按钮;`views/ReaderView.vue` 在正文上方渲染本章插图的**缩略图**(高 120px 的按钮,`data-testid="chapter-illustration"`),点击后在对话框(`data-testid="illustration-dialog"`)里按原图显示(最大 `min(88vw,1200px)` × `78vh`,保持比例不裁切),播放器只在**本章**有音频时出现,离开或切换章节时上报一次位置;**书籍页的章节列表不显示也不预取插图**(2026-09-15 按用户要求修订)。
## #37 章节级音频与章节插图(2026-09-15)
**schema v9**:新增 `lexgo_chapter_attachments`(主键 `(chapter_id, kind)`,`kind ∈ {audio, illustration}`,`bytes MEDIUMBLOB`,外键级联到章节与账号)与 `lexgo_chapter_playback_positions`(主键 `(owner_id, chapter_id)`),并执行一条幂等语句 `DELETE FROM lexgo_book_attachments WHERE kind='audio'`。三张表都只用可重放的 DDL;`lexgo_book_attachments` 与 `lexgo_playback_positions` 保留结构(不删表、不改列),书级音频停止写入。
| 层 | 变化 |
|---|---|
| 书级 | 只剩**封面**:`kind='cover'`、`GET/POST/DELETE /api/v1/books/:id/cover`、`coverVersion` 缓存失效机制全部保持原样 |
| 章节级 | `POST/DELETE/GET /api/v1/chapters/:id/audio` 与 `.../illustration`、`PUT /api/v1/chapters/:id/playback` |
| 退役 | `POST/DELETE/GET /api/v1/books/:id/audio`、`PUT /api/v1/books/:id/playback`(路由不再注册,返回 404) |
| 响应 | `ChapterSummary` 增加 `illustrationVersion`、`audioVersion`、`playbackSeconds`,字段始终存在(无文件时为空串),书籍详情的章节列表与阅读器响应都带上它们;书的 `attachments` 只剩 `cover` |
复用不变:`sniffAttachment`(magic bytes 判定)、`validateAttachment`(音频 ≤20 MiB;图片 ≤2 MiB 且 ≤4096×4096)、`coverDimensions`(JPEG/PNG 用标准库、WebP 读容器头)、`binaryResponse` + `http.ServeContent`(Range/206、416、ETag/304)、上传「先校验后写入、失败保留旧文件」的流程,以及 `chapterAttachmentViews`/`chapterPlaybackSeconds` 的批量读取(一次查询喂整个章节列表)。
学习端:`stores/library.ts` 用 `uploadChapterFile`/`deleteChapterFile`/`saveChapterPlayback`/`reportChapterPlayback` 替换了书级音频动作,并新增 `illustrationUrls`(按章节)与 `audioChapterId`(记录当前加载的音频属于哪一章,切章不会复用上一章的文件);`views/BookView.vue` 的封面区块收窄为封面,**章节列表每行只有一个「编辑」按钮**,章节编辑对话框(`data-testid="chapter-dialog"`)同时编辑标题、正文、插图与音频(插图/音频区块带预览、状态、上传/替换/移除与规格提示,并明确写出「标题与正文点保存后生效,文件选中后立即上传」),`views/ReaderView.vue` 在正文上方渲染本章插图的**缩略图**(高 120px 的按钮,`data-testid="chapter-illustration"`),点击后在对话框(`data-testid="illustration-dialog"`)里按原图显示(最大 `min(88vw,1200px)` × `78vh`,保持比例不裁切),播放器只在**本章**有音频时出现,离开或切换章节时上报一次位置;**书籍页的章节列表不显示也不预取插图**(2026-09-15 按用户要求修订两次:先改为缩略图+弹窗,再把附件并入章节编辑对话框)。
## #37 章节级音频与章节插图(2026-09-15)
**schema v10**:`lexgo_chapters` 增加可选的 `author VARCHAR(120) NOT NULL DEFAULT ''`。MySQL 没有 `ADD COLUMN IF NOT EXISTS`,因此这一列由 Go 侧的条件步骤 `addChapterAuthorColumn` 添加(先查 `information_schema`,缺列才执行 `ALTER TABLE`),并在语句列表执行**之后**运行,保证「部分迁移可重试」「回退标记后可重新升级」这两条既有性质仍然成立;新建库的 v3 语句里也直接带上该列。章节编辑接口接受 `author`(可选、去首尾空白、≤120 字符、空串即清空),`ChapterSummary` 与 `ChapterSource` 都返回它,阅读页在标题下显示非空的作者。
**schema v9**:新增 `lexgo_chapter_attachments`(主键 `(chapter_id, kind)`,`kind ∈ {audio, illustration}`,`bytes MEDIUMBLOB`,外键级联到章节与账号)与 `lexgo_chapter_playback_positions`(主键 `(owner_id, chapter_id)`),并执行一条幂等语句 `DELETE FROM lexgo_book_attachments WHERE kind='audio'`。三张表都只用可重放的 DDL;`lexgo_book_attachments` 与 `lexgo_playback_positions` 保留结构(不删表、不改列),书级音频停止写入。
| 层 | 变化 |
|---|---|
| 书级 | 只剩**封面**:`kind='cover'`、`GET/POST/DELETE /api/v1/books/:id/cover`、`coverVersion` 缓存失效机制全部保持原样 |
| 章节级 | `POST/DELETE/GET /api/v1/chapters/:id/audio` 与 `.../illustration`、`PUT /api/v1/chapters/:id/playback` |
| 退役 | `POST/DELETE/GET /api/v1/books/:id/audio`、`PUT /api/v1/books/:id/playback`(路由不再注册,返回 404) |
| 响应 | `ChapterSummary` 增加 `illustrationVersion`、`audioVersion`、`playbackSeconds`,字段始终存在(无文件时为空串),书籍详情的章节列表与阅读器响应都带上它们;书的 `attachments` 只剩 `cover` |
复用不变:`sniffAttachment`(magic bytes 判定)、`validateAttachment`(音频 ≤20 MiB;图片 ≤2 MiB 且 ≤4096×4096)、`coverDimensions`(JPEG/PNG 用标准库、WebP 读容器头)、`binaryResponse` + `http.ServeContent`(Range/206、416、ETag/304)、上传「先校验后写入、失败保留旧文件」的流程,以及 `chapterAttachmentViews`/`chapterPlaybackSeconds` 的批量读取(一次查询喂整个章节列表)。
学习端:`stores/library.ts` 用 `uploadChapterFile`/`deleteChapterFile`/`saveChapterPlayback`/`reportChapterPlayback` 替换了书级音频动作,并新增 `illustrationUrls`(按章节)与 `audioChapterId`(记录当前加载的音频属于哪一章,切章不会复用上一章的文件);`views/BookView.vue` 的封面区块收窄为封面,**章节列表每行只有一个「编辑」按钮**,章节编辑对话框(`data-testid="chapter-dialog"`)同时编辑**章节标题、作者、正文、插图与音频**:标题与作者用「标签在左、输入框在右」的同一行排版(`.field-row`),正文编辑框加高(18 行),插图与音频压缩成各一两行(标签+状态+上传/替换/移除+「JPG/PNG/WebP · ≤2 MiB · ≤4096×4096」「MP3 · ≤20 MiB · 替换或移除会重置位置」),底部一行写明时机(`data-testid="attachment-timing"`),`views/ReaderView.vue` 在正文上方渲染本章插图的**缩略图**(高 120px 的按钮,`data-testid="chapter-illustration"`),点击后在对话框(`data-testid="illustration-dialog"`)里按原图显示(最大 `min(88vw,1200px)` × `78vh`,保持比例不裁切),播放器只在**本章**有音频时出现,离开或切换章节时上报一次位置;**书籍页的章节列表不显示也不预取插图**(2026-09-15 按用户要求修订两次:先改为缩略图+弹窗,再把附件并入章节编辑对话框)。
## #37 章节级音频与章节插图(2026-09-15)
**schema v11**:`lexgo_books` 增加可选的 `author VARCHAR(120) NOT NULL DEFAULT ''`,与 v10 的章节作者共用同一个条件加列助手 `addAuthorColumn`(先查 `information_schema`,缺列才 `ALTER TABLE`,在语句列表之后执行),新建库的 v3 建表语句也带该列。`BookUpdateInput` 增加可选的 `author`(省略则保留原值、空串即清空、≤120 字符、去首尾空白),`BookSummary`/`BookRef` 都返回它;书籍编辑接口因此从「重命名」变成「编辑书籍」(书名+作者)。
**schema v10**:`lexgo_chapters` 增加可选的 `author VARCHAR(120) NOT NULL DEFAULT ''`。MySQL 没有 `ADD COLUMN IF NOT EXISTS`,因此这一列由 Go 侧的条件步骤 `addChapterAuthorColumn` 添加(先查 `information_schema`,缺列才执行 `ALTER TABLE`),并在语句列表执行**之后**运行,保证「部分迁移可重试」「回退标记后可重新升级」这两条既有性质仍然成立;新建库的 v3 语句里也直接带上该列。章节编辑接口接受 `author`(可选、去首尾空白、≤120 字符、空串即清空),`ChapterSummary` 与 `ChapterSource` 都返回它,阅读页在标题下显示非空的作者。
**schema v9**:新增 `lexgo_chapter_attachments`(主键 `(chapter_id, kind)`,`kind ∈ {audio, illustration}`,`bytes MEDIUMBLOB`,外键级联到章节与账号)与 `lexgo_chapter_playback_positions`(主键 `(owner_id, chapter_id)`),并执行一条幂等语句 `DELETE FROM lexgo_book_attachments WHERE kind='audio'`。三张表都只用可重放的 DDL;`lexgo_book_attachments` 与 `lexgo_playback_positions` 保留结构(不删表、不改列),书级音频停止写入。
| 层 | 变化 |
|---|---|
| 书级 | 只剩**封面**:`kind='cover'`、`GET/POST/DELETE /api/v1/books/:id/cover`、`coverVersion` 缓存失效机制全部保持原样 |
| 章节级 | `POST/DELETE/GET /api/v1/chapters/:id/audio` 与 `.../illustration`、`PUT /api/v1/chapters/:id/playback` |
| 退役 | `POST/DELETE/GET /api/v1/books/:id/audio`、`PUT /api/v1/books/:id/playback`(路由不再注册,返回 404) |
| 响应 | `ChapterSummary` 增加 `illustrationVersion`、`audioVersion`、`playbackSeconds`,字段始终存在(无文件时为空串),书籍详情的章节列表与阅读器响应都带上它们;书的 `attachments` 只剩 `cover` |
复用不变:`sniffAttachment`(magic bytes 判定)、`validateAttachment`(音频 ≤20 MiB;图片 ≤2 MiB 且 ≤4096×4096)、`coverDimensions`(JPEG/PNG 用标准库、WebP 读容器头)、`binaryResponse` + `http.ServeContent`(Range/206、416、ETag/304)、上传「先校验后写入、失败保留旧文件」的流程,以及 `chapterAttachmentViews`/`chapterPlaybackSeconds` 的批量读取(一次查询喂整个章节列表)。
学习端:`stores/library.ts` 用 `uploadChapterFile`/`deleteChapterFile`/`saveChapterPlayback`/`reportChapterPlayback` 替换了书级音频动作,并新增 `illustrationUrls`(按章节)与 `audioChapterId`(记录当前加载的音频属于哪一章,切章不会复用上一章的文件);`views/BookView.vue` 的书籍页把封面压成**一行紧凑控件**(`.cover-row`:预览 120px +「上传/替换封面」「移除」,不含标题、字段名与规格提示文字),把高度让给章节列表;标题行用 `.title-line` 把**书级作者**显示在书名右侧(`data-testid="book-author"`,未设置则不显示);「编辑书名」改为**「编辑书籍」**,对话框内用同一套 `.field-row` 同行排版编辑**书名与作者**。**章节列表每行只有一个「编辑」按钮**,章节编辑对话框(`data-testid="chapter-dialog"`)同时编辑**章节标题、作者、正文、插图与音频**:标题与作者用「标签在左、输入框在右」的同一行排版(`.field-row`),正文编辑框加高(18 行),插图与音频压缩成各一两行(标签+状态+上传/替换/移除+「JPG/PNG/WebP · ≤2 MiB · ≤4096×4096」「MP3 · ≤20 MiB · 替换或移除会重置位置」),底部一行写明时机(`data-testid="attachment-timing"`),`views/ReaderView.vue` 在正文上方渲染本章插图的**缩略图**(高 120px 的按钮,`data-testid="chapter-illustration"`),点击后在对话框(`data-testid="illustration-dialog"`)里按原图显示(最大 `min(88vw,1200px)` × `78vh`,保持比例不裁切),播放器只在**本章**有音频时出现,离开或切换章节时上报一次位置;**书籍页的章节列表不显示也不预取插图**(2026-09-15 按用户要求修订两次:先改为缩略图+弹窗,再把附件并入章节编辑对话框)。
+98 -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: 34a1994dae20957a68f2bd60e3a7dd97530cffac
synchronized_at: 2026-09-15T10:33:08Z
wiki_revision: b688ef278f83c8055e16f91843a239f9ea2dd492
synchronized_at: 2026-09-15T15:10:08Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -320,3 +320,99 @@ exact优先;未命中再按WordNet异常表/词尾规则查候选,词性顺
**删除与备份**:删除书籍级联删除附件与所有账号的该书进度。附件存于数据库,所以 #15 的备份与恢复契约自动覆盖附件与进度;恢复后附件可继续读取与播放,这一点已由 2026-09-15 的恢复演练验证(逐表内容校验和一致、恢复实例按字节相同地返回音频与封面)。
**范围外**:多音轨、分章音轨、字幕与时间戳同步、逐句跟读、转写、TTS、音频转码、自动搜索封面、在线音频地址抓取、公开分享与跨用户共享。
## #37 章级附件规则(2026-09-15)
**挂载单位**:封面属于**书**(一本书一张,维持 #21 已验收的规则);音频与插图属于**章**(一章各一张)。多篇文章合并成一本书时,每章可以有自己的音频与插图,互不影响。没有附件的章节保持原样:不显示占位框、不显示播放器。
**格式与上限**:音频只接受 **MP3**(ID3 或帧同步),≤ **20 MiB**;插图与封面同规格:**JPG/PNG/WebP**,≤ **2 MiB**,像素 ≤ **4096×4096**;一律**按文件内容判定类型**,不看扩展名与客户端声明。超限 `413`、类型或内容非法 `400` 并给出可读原因;**被拒绝的上传保留旧文件**。
**替换与位置**:替换即覆盖同一章同一类型的那一行。**替换或移除音频会清空该章的播放位置**(旧位置对新文件没有意义);插图与音频互不影响,移除其一不会动另一个。
**播放位置**:粒度是**账号+章节**,不是账号+书——同一本书的不同章节各记各的。播放中每 5 秒、暂停时、离开当前章节或切换章节时各上报一次;服务端只做 0~24 小时的上界校验与 upsert,并发以最后一次写入为准,不解析音频时长。
**读取与缓存**:插图与音频都需要会话,跨账号一律 `404`、匿名 `401`;支持 **HTTP Range(206)**、`416` 与 `ETag`/`304`。前端用带凭据的 fetch 取字节再交给 `<img>`/`<audio>`,令牌不出现在 URL 中。
**界面**:书籍页的「附件」按钮打开该章的附件对话框,与「编辑」分开——改正文会重新处理章节,改附件不会。阅读页里插图固定显示在本章正文开头(章节标题与播放器条之下),不参与划词;播放器条仍为 sticky,判据改为「本章有音频」。
**删除与备份**:删除章节(#10 的事务级联)连带删除该章附件与该章所有账号的位置记录;删除书籍通过章节级联清空。附件存于数据库,因此 #15 的备份与恢复自动覆盖,恢复后按字节相同并可继续拖动。
**范围外**:多张插图、正文中间图文混排、缩略图墙式独立浏览界面、多音轨与分章音轨、字幕与时间戳同步、逐句跟读、转写、TTS、转码、自动搜图、在线音频地址、公开分享与跨用户共享。
## #37 章级附件规则(2026-09-15)
**挂载单位**:封面属于**书**(一本书一张,维持 #21 已验收的规则);音频与插图属于**章**(一章各一张)。多篇文章合并成一本书时,每章可以有自己的音频与插图,互不影响。没有附件的章节保持原样:不显示占位框、不显示播放器。
**格式与上限**:音频只接受 **MP3**(ID3 或帧同步),≤ **20 MiB**;插图与封面同规格:**JPG/PNG/WebP**,≤ **2 MiB**,像素 ≤ **4096×4096**;一律**按文件内容判定类型**,不看扩展名与客户端声明。超限 `413`、类型或内容非法 `400` 并给出可读原因;**被拒绝的上传保留旧文件**。
**替换与位置**:替换即覆盖同一章同一类型的那一行。**替换或移除音频会清空该章的播放位置**(旧位置对新文件没有意义);插图与音频互不影响,移除其一不会动另一个。
**播放位置**:粒度是**账号+章节**,不是账号+书——同一本书的不同章节各记各的。播放中每 5 秒、暂停时、离开当前章节或切换章节时各上报一次;服务端只做 0~24 小时的上界校验与 upsert,并发以最后一次写入为准,不解析音频时长。
**读取与缓存**:插图与音频都需要会话,跨账号一律 `404`、匿名 `401`;支持 **HTTP Range(206)**、`416` 与 `ETag`/`304`。前端用带凭据的 fetch 取字节再交给 `<img>`/`<audio>`,令牌不出现在 URL 中。
**界面**:书籍页的「附件」按钮打开该章的附件对话框,与「编辑」分开——改正文会重新处理章节,改附件不会;**章节列表不显示章节图片**(也不预取),章节是否有插图在附件对话框里可见。阅读页里插图以**缩略图**形式显示在本章正文开头(章节标题与播放器条之下),**点击缩略图在弹窗中查看原图**(保持比例、不裁切;Esc 关闭后正文位置不变);缩略图是按钮而不是正文的一部分,不参与划词;播放器条仍为 sticky,判据为「本章有音频」。
**删除与备份**:删除章节(#10 的事务级联)连带删除该章附件与该章所有账号的位置记录;删除书籍通过章节级联清空。附件存于数据库,因此 #15 的备份与恢复自动覆盖,恢复后按字节相同并可继续拖动。
**范围外**:多张插图、正文中间图文混排、缩略图墙式独立浏览界面、多音轨与分章音轨、字幕与时间戳同步、逐句跟读、转写、TTS、转码、自动搜图、在线音频地址、公开分享与跨用户共享。
## #37 章级附件规则(2026-09-15)
**挂载单位**:封面属于**书**(一本书一张,维持 #21 已验收的规则);音频与插图属于**章**(一章各一张)。多篇文章合并成一本书时,每章可以有自己的音频与插图,互不影响。没有附件的章节保持原样:不显示占位框、不显示播放器。
**格式与上限**:音频只接受 **MP3**(ID3 或帧同步),≤ **20 MiB**;插图与封面同规格:**JPG/PNG/WebP**,≤ **2 MiB**,像素 ≤ **4096×4096**;一律**按文件内容判定类型**,不看扩展名与客户端声明。超限 `413`、类型或内容非法 `400` 并给出可读原因;**被拒绝的上传保留旧文件**。
**替换与位置**:替换即覆盖同一章同一类型的那一行。**替换或移除音频会清空该章的播放位置**(旧位置对新文件没有意义);插图与音频互不影响,移除其一不会动另一个。
**播放位置**:粒度是**账号+章节**,不是账号+书——同一本书的不同章节各记各的。播放中每 5 秒、暂停时、离开当前章节或切换章节时各上报一次;服务端只做 0~24 小时的上界校验与 upsert,并发以最后一次写入为准,不解析音频时长。
**读取与缓存**:插图与音频都需要会话,跨账号一律 `404`、匿名 `401`;支持 **HTTP Range(206)**、`416` 与 `ETag`/`304`。前端用带凭据的 fetch 取字节再交给 `<img>`/`<audio>`,令牌不出现在 URL 中。
**界面**:书籍页的章节列表每行只保留一个「编辑」按钮,点开后一个对话框同时编辑标题、正文、本章插图与本章音频(**一个入口、一处编辑**);**章节列表不显示章节图片**(也不预取)。对话框内两种生效时机写得很清楚:**标题与正文点「保存章节」后生效;插图与音频在选中文件后立即上传**,取消不会撤销已上传的文件;改正文会重新处理章节(只改标题不会),而已上传的插图与音频挂在章节上、不随正文版本变化。阅读页里插图以**缩略图**形式显示在本章正文开头(章节标题与播放器条之下),**点击缩略图在弹窗中查看原图**(保持比例、不裁切;Esc 关闭后正文位置不变);缩略图是按钮而不是正文的一部分,不参与划词;播放器条仍为 sticky,判据为「本章有音频」。
**删除与备份**:删除章节(#10 的事务级联)连带删除该章附件与该章所有账号的位置记录;删除书籍通过章节级联清空。附件存于数据库,因此 #15 的备份与恢复自动覆盖,恢复后按字节相同并可继续拖动。
**范围外**:多张插图、正文中间图文混排、缩略图墙式独立浏览界面、多音轨与分章音轨、字幕与时间戳同步、逐句跟读、转写、TTS、转码、自动搜图、在线音频地址、公开分享与跨用户共享。
## #37 章级附件规则(2026-09-15)
**挂载单位**:封面属于**书**(一本书一张,维持 #21 已验收的规则);音频与插图属于**章**(一章各一张)。多篇文章合并成一本书时,每章可以有自己的音频与插图,互不影响。没有附件的章节保持原样:不显示占位框、不显示播放器。
**格式与上限**:音频只接受 **MP3**(ID3 或帧同步),≤ **20 MiB**;插图与封面同规格:**JPG/PNG/WebP**,≤ **2 MiB**,像素 ≤ **4096×4096**;一律**按文件内容判定类型**,不看扩展名与客户端声明。超限 `413`、类型或内容非法 `400` 并给出可读原因;**被拒绝的上传保留旧文件**。
**替换与位置**:替换即覆盖同一章同一类型的那一行。**替换或移除音频会清空该章的播放位置**(旧位置对新文件没有意义);插图与音频互不影响,移除其一不会动另一个。
**播放位置**:粒度是**账号+章节**,不是账号+书——同一本书的不同章节各记各的。播放中每 5 秒、暂停时、离开当前章节或切换章节时各上报一次;服务端只做 0~24 小时的上界校验与 upsert,并发以最后一次写入为准,不解析音频时长。
**读取与缓存**:插图与音频都需要会话,跨账号一律 `404`、匿名 `401`;支持 **HTTP Range(206)**、`416` 与 `ETag`/`304`。前端用带凭据的 fetch 取字节再交给 `<img>`/`<audio>`,令牌不出现在 URL 中。
**章节作者**:作者是**可选自由文本**(≤120 字符),随章节标题与正文一起保存,去首尾空白,留空即清空;它只用于展示(阅读页在章节标题下显示),不参与归类、检索或权限判断,也不要求与书级数据一致。
**界面**:书籍页的章节列表每行只保留一个「编辑」按钮,点开后一个对话框同时编辑标题、正文、本章插图与本章音频(**一个入口、一处编辑**);**章节列表不显示章节图片**(也不预取)。对话框内两种生效时机写得很清楚:**标题、作者与正文点「保存章节」后生效;插图与音频在选中文件后立即上传**,取消不会撤销已上传的文件;改正文会重新处理章节(只改标题不会),而已上传的插图与音频挂在章节上、不随正文版本变化。阅读页里插图以**缩略图**形式显示在本章正文开头(章节标题与播放器条之下),**点击缩略图在弹窗中查看原图**(保持比例、不裁切;Esc 关闭后正文位置不变);缩略图是按钮而不是正文的一部分,不参与划词;播放器条仍为 sticky,判据为「本章有音频」。
**删除与备份**:删除章节(#10 的事务级联)连带删除该章附件与该章所有账号的位置记录;删除书籍通过章节级联清空。附件存于数据库,因此 #15 的备份与恢复自动覆盖,恢复后按字节相同并可继续拖动。
**范围外**:多张插图、正文中间图文混排、缩略图墙式独立浏览界面、多音轨与分章音轨、字幕与时间戳同步、逐句跟读、转写、TTS、转码、自动搜图、在线音频地址、公开分享与跨用户共享。
## #37 章级附件规则(2026-09-15)
**挂载单位**:封面属于**书**(一本书一张,维持 #21 已验收的规则);音频与插图属于**章**(一章各一张)。多篇文章合并成一本书时,每章可以有自己的音频与插图,互不影响。没有附件的章节保持原样:不显示占位框、不显示播放器。
**格式与上限**:音频只接受 **MP3**(ID3 或帧同步),≤ **20 MiB**;插图与封面同规格:**JPG/PNG/WebP**,≤ **2 MiB**,像素 ≤ **4096×4096**;一律**按文件内容判定类型**,不看扩展名与客户端声明。超限 `413`、类型或内容非法 `400` 并给出可读原因;**被拒绝的上传保留旧文件**。
**替换与位置**:替换即覆盖同一章同一类型的那一行。**替换或移除音频会清空该章的播放位置**(旧位置对新文件没有意义);插图与音频互不影响,移除其一不会动另一个。
**播放位置**:粒度是**账号+章节**,不是账号+书——同一本书的不同章节各记各的。播放中每 5 秒、暂停时、离开当前章节或切换章节时各上报一次;服务端只做 0~24 小时的上界校验与 upsert,并发以最后一次写入为准,不解析音频时长。
**读取与缓存**:插图与音频都需要会话,跨账号一律 `404`、匿名 `401`;支持 **HTTP Range(206)**、`416` 与 `ETag`/`304`。前端用带凭据的 fetch 取字节再交给 `<img>`/`<audio>`,令牌不出现在 URL 中。
**书籍作者与章节作者**:两者是**互相独立**的可选自由文本(各 ≤120 字符,各自去首尾空白,留空即清空):书级作者在书籍页显示于书名右侧、在「编辑书籍」里维护;章节作者在阅读页显示于章节标题下、在章节「编辑」里维护。**章节不会从书级继承作者**,字段也不参与归类、检索或权限判断(如需继承或检索,需要单独确认)。省略作者字段的编辑请求保留原值。
**章节作者**:作者是**可选自由文本**(≤120 字符),随章节标题与正文一起保存,去首尾空白,留空即清空;它只用于展示(阅读页在章节标题下显示),不参与归类、检索或权限判断,也不要求与书级数据一致。
**界面**:书籍页的章节列表每行只保留一个「编辑」按钮,点开后一个对话框同时编辑标题、正文、本章插图与本章音频(**一个入口、一处编辑**);**章节列表不显示章节图片**(也不预取)。对话框内两种生效时机写得很清楚:**标题、作者与正文点「保存章节」后生效;插图与音频在选中文件后立即上传**,取消不会撤销已上传的文件;改正文会重新处理章节(只改标题不会),而已上传的插图与音频挂在章节上、不随正文版本变化。阅读页里插图以**缩略图**形式显示在本章正文开头(章节标题与播放器条之下),**点击缩略图在弹窗中查看原图**(保持比例、不裁切;Esc 关闭后正文位置不变);缩略图是按钮而不是正文的一部分,不参与划词;播放器条仍为 sticky,判据为「本章有音频」。
**删除与备份**:删除章节(#10 的事务级联)连带删除该章附件与该章所有账号的位置记录;删除书籍通过章节级联清空。附件存于数据库,因此 #15 的备份与恢复自动覆盖,恢复后按字节相同并可继续拖动。
**范围外**:多张插图、正文中间图文混排、缩略图墙式独立浏览界面、多音轨与分章音轨、字幕与时间戳同步、逐句跟读、转写、TTS、转码、自动搜图、在线音频地址、公开分享与跨用户共享。
+279 -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: ba5e3acc746432e55c186fd3a1fdf3c158fd121b
synchronized_at: 2026-09-15T10:33:08Z
wiki_revision: 8985f93f7319b2e6a428fc4417f353a7b909d797
synchronized_at: 2026-09-15T15:10:08Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -779,3 +779,280 @@ schema v7 → v8:新增 `lexgo_book_attachments` 与 `lexgo_playback_positions
- 未验证 Safari/Firefox(项目既有测试只在 Chromium 上跑);WebP 只验证了容器维度解析,未在各浏览器实测显示。
- 20 MiB 上限下的播放启动延迟未测;附件进库对 dump 体积与恢复耗时的影响只在小数据量下观察。
- 未做音频转码与格式探测,非 MP3 一律拒绝。
## #37 章级附件验证(2026-09-15)
### 迁移
schema v8 → v9:新增 `lexgo_chapter_attachments` 与 `lexgo_chapter_playback_positions`,并执行一条幂等清理语句删除 `lexgo_book_attachments` 中 `kind='audio'` 的历史行(测试期数据,不做存量迁移)。开发库迁移前后逐表业务计数一致,schema 记到 9,两张新表为空、书级音频行为 0(快照 `.local/issue13-before37.json`、`.local/issue13-after37.json`)。回退:把 `lexgo_schema.version` 写回 8 并恢复 `.local/lexgo-pre-issue37.exe`,但**被清空的书级音频测试数据找不回**。
| 命令 | 结果 |
|---|---|
| `go vet ./...` / `gofmt -l` | 通过、无输出 |
| `LEXGO_TEST_DB_NAME=lexgo_test_issue13 python scripts/server.py test-integration` | **87 项顶层用例通过、0 跳过**(本单新增/改写:章级附件与位置的完整集成用例、封面仍为书级、上传请求形态校验、v8→v9 迁移与回退重升级、v7→v8 测试改为用原始 SQL 写已退役的表) |
| `cd learner`:`vitest` / `vue-tsc` / `build` / `playwright test` | 157 项单测、类型检查、构建、**26 项 E2E**(桌面 22 + 移动 4)全部通过 |
| 治理 / 镜像 | 65 项、`check --strict`、`sync --check` 通过 |
| 真实 API+MySQL | **52 项检查通过**(见下) |
| #15 恢复演练(第三次) | **22 项检查通过**(见下) |
### 真实链路(52 项)
用一本**两章**的虚构书验证「附件属于章而不是书」:只给第一章上传真实的 PNG 插图与三秒 MP3 音频 → 数据库里两张行与章节对应、`byte_size` 与 `OCTET_LENGTH(bytes)` 一致、音频 sha256 与上传文件一致、书级音频行为 0 → 章节列表与阅读器都带 `illustrationVersion`/`audioVersion`,第二章为空串且位置为 0 → 插图按字节返回并带 `ETag`(`If-None-Match` 得 `304`)→ 音频按字节返回、`Accept-Ranges` 存在、`Range: bytes=200-399` 得 `206` 且切片正确、不可满足的 Range 得 `416` → **书级音频与播放接口已下线(404)** → 位置写入后章节列表回读一致、第二章仍为 0、负值 400、无音频的章 404 → 替换音频后位置归零且仍一行 → SVG 伪装插图、文本伪装音频 400、21 MiB 音频 413 且原文件保留 → 越权读取/替换/上报 404、匿名 401 → 移除音频清掉位置但插图不受影响 → 删除章节与删除书籍级联清空。证据 `.local/issue37-api-evidence.json`。
### 恢复演练(第三次,22 项)
带章级附件的 fixture → Go 二进制 `lexgo backup`(manifest 已含两张新表)→ 恢复到空库 `lexgo_test_restore_chapter` → `lexgo verify --manifest` 通过且包含「章节附件都指向存在的章节」「章节播放位置都指向存在的章节」→ **17 张表逐表内容校验和与源库一致** → 用恢复库起实例:同一账号可登录、章节仍报告两个文件、位置仍是恢复前的值、**音频与插图按字节相同地返回**、Range 仍得 206、越权仍 404 → 演练库与 fixture 删除,开发库仍是 schema 9。证据 `.local/issue37-restore-evidence.json`。
### 未验证
- 真机与真实浏览器解码播放(媒体元素在测试中被桩替换)、真机听感与兼容性仍未验证(#21 起的既有缺口)。
- Safari/Firefox 未测;渐进式流式播放仍未做(服务端 Range 已实现)。
- 迁移会清空书级音频测试数据且回退到 v8 找不回;当前无真实用户,工单已记录。
- 已有书籍/章节的存量内容不受影响,但**没有做「把既有书级音频迁到章级」的转换**(无真实数据)。
## #37 章级附件验证(2026-09-15)
### 迁移
schema v8 → v9:新增 `lexgo_chapter_attachments` 与 `lexgo_chapter_playback_positions`,并执行一条幂等清理语句删除 `lexgo_book_attachments` 中 `kind='audio'` 的历史行(测试期数据,不做存量迁移)。开发库迁移前后逐表业务计数一致,schema 记到 9,两张新表为空、书级音频行为 0(快照 `.local/issue13-before37.json`、`.local/issue13-after37.json`)。回退:把 `lexgo_schema.version` 写回 8 并恢复 `.local/lexgo-pre-issue37.exe`,但**被清空的书级音频测试数据找不回**。
| 命令 | 结果 |
|---|---|
| `go vet ./...` / `gofmt -l` | 通过、无输出 |
| `LEXGO_TEST_DB_NAME=lexgo_test_issue13 python scripts/server.py test-integration` | **87 项顶层用例通过、0 跳过**(本单新增/改写:章级附件与位置的完整集成用例、封面仍为书级、上传请求形态校验、v8→v9 迁移与回退重升级、v7→v8 测试改为用原始 SQL 写已退役的表) |
| `cd learner`:`vitest` / `vue-tsc` / `build` / `playwright test` | 157 项单测、类型检查、构建、**26 项 E2E**(桌面 22 + 移动 4)全部通过 |
| 治理 / 镜像 | 65 项、`check --strict`、`sync --check` 通过 |
| 真实 API+MySQL | **52 项检查通过**(见下) |
| #15 恢复演练(第三次) | **22 项检查通过**(见下) |
### 真实链路(52 项)
用一本**两章**的虚构书验证「附件属于章而不是书」:只给第一章上传真实的 PNG 插图与三秒 MP3 音频 → 数据库里两张行与章节对应、`byte_size` 与 `OCTET_LENGTH(bytes)` 一致、音频 sha256 与上传文件一致、书级音频行为 0 → 章节列表与阅读器都带 `illustrationVersion`/`audioVersion`,第二章为空串且位置为 0 → 插图按字节返回并带 `ETag`(`If-None-Match` 得 `304`)→ 音频按字节返回、`Accept-Ranges` 存在、`Range: bytes=200-399` 得 `206` 且切片正确、不可满足的 Range 得 `416` → **书级音频与播放接口已下线(404)** → 位置写入后章节列表回读一致、第二章仍为 0、负值 400、无音频的章 404 → 替换音频后位置归零且仍一行 → SVG 伪装插图、文本伪装音频 400、21 MiB 音频 413 且原文件保留 → 越权读取/替换/上报 404、匿名 401 → 移除音频清掉位置但插图不受影响 → 删除章节与删除书籍级联清空。证据 `.local/issue37-api-evidence.json`。
### 恢复演练(第三次,22 项)
带章级附件的 fixture → Go 二进制 `lexgo backup`(manifest 已含两张新表)→ 恢复到空库 `lexgo_test_restore_chapter` → `lexgo verify --manifest` 通过且包含「章节附件都指向存在的章节」「章节播放位置都指向存在的章节」→ **17 张表逐表内容校验和与源库一致** → 用恢复库起实例:同一账号可登录、章节仍报告两个文件、位置仍是恢复前的值、**音频与插图按字节相同地返回**、Range 仍得 206、越权仍 404 → 演练库与 fixture 删除,开发库仍是 schema 9。证据 `.local/issue37-restore-evidence.json`。
### 插图交互修订(2026-09-15,按用户要求)
书籍页章节列表去掉插图列与列表预取;阅读页改为缩略图(高 120px 的按钮)并在点击后于弹窗中显示原图。后端与 schema 无变化。验证:学习端 157 单测与 26 项 E2E 通过(断言章节列表无 `<img>` 且不请求 `/illustration`、缩略图是按钮、点击后弹窗内为对象 URL 的图片);真实链路在 issue5_a 自己的一本书上完成——列表 `<img>` 为 0、缩略图 ≤130px、弹窗内图片大于缩略图且 `naturalWidth > 0`、Esc 关闭后正文仍可见,随后移除 fixture 插图复原开发数据(截图 `issue37-real-book-no-image.png`、`issue37-real-reader-thumbnail.png`、`issue37-real-illustration-dialog.png`)。示例地址 `/books/5` 属于其他账号,请求返回 404,实际使用该账号自己的书。
### 未验证
- 真机与真实浏览器解码播放(媒体元素在测试中被桩替换)、真机听感与兼容性仍未验证(#21 起的既有缺口)。
- Safari/Firefox 未测;渐进式流式播放仍未做(服务端 Range 已实现)。
- 迁移会清空书级音频测试数据且回退到 v8 找不回;当前无真实用户,工单已记录。
- 已有书籍/章节的存量内容不受影响,但**没有做「把既有书级音频迁到章级」的转换**(无真实数据)。
## #37 章级附件验证(2026-09-15)
### 迁移
schema v8 → v9:新增 `lexgo_chapter_attachments` 与 `lexgo_chapter_playback_positions`,并执行一条幂等清理语句删除 `lexgo_book_attachments` 中 `kind='audio'` 的历史行(测试期数据,不做存量迁移)。开发库迁移前后逐表业务计数一致,schema 记到 9,两张新表为空、书级音频行为 0(快照 `.local/issue13-before37.json`、`.local/issue13-after37.json`)。回退:把 `lexgo_schema.version` 写回 8 并恢复 `.local/lexgo-pre-issue37.exe`,但**被清空的书级音频测试数据找不回**。
| 命令 | 结果 |
|---|---|
| `go vet ./...` / `gofmt -l` | 通过、无输出 |
| `LEXGO_TEST_DB_NAME=lexgo_test_issue13 python scripts/server.py test-integration` | **87 项顶层用例通过、0 跳过**(本单新增/改写:章级附件与位置的完整集成用例、封面仍为书级、上传请求形态校验、v8→v9 迁移与回退重升级、v7→v8 测试改为用原始 SQL 写已退役的表) |
| `cd learner`:`vitest` / `vue-tsc` / `build` / `playwright test` | 157 项单测、类型检查、构建、**26 项 E2E**(桌面 22 + 移动 4)全部通过 |
| 治理 / 镜像 | 65 项、`check --strict`、`sync --check` 通过 |
| 真实 API+MySQL | **52 项检查通过**(见下) |
| #15 恢复演练(第三次) | **22 项检查通过**(见下) |
### 真实链路(52 项)
用一本**两章**的虚构书验证「附件属于章而不是书」:只给第一章上传真实的 PNG 插图与三秒 MP3 音频 → 数据库里两张行与章节对应、`byte_size` 与 `OCTET_LENGTH(bytes)` 一致、音频 sha256 与上传文件一致、书级音频行为 0 → 章节列表与阅读器都带 `illustrationVersion`/`audioVersion`,第二章为空串且位置为 0 → 插图按字节返回并带 `ETag`(`If-None-Match` 得 `304`)→ 音频按字节返回、`Accept-Ranges` 存在、`Range: bytes=200-399` 得 `206` 且切片正确、不可满足的 Range 得 `416` → **书级音频与播放接口已下线(404)** → 位置写入后章节列表回读一致、第二章仍为 0、负值 400、无音频的章 404 → 替换音频后位置归零且仍一行 → SVG 伪装插图、文本伪装音频 400、21 MiB 音频 413 且原文件保留 → 越权读取/替换/上报 404、匿名 401 → 移除音频清掉位置但插图不受影响 → 删除章节与删除书籍级联清空。证据 `.local/issue37-api-evidence.json`。
### 恢复演练(第三次,22 项)
带章级附件的 fixture → Go 二进制 `lexgo backup`(manifest 已含两张新表)→ 恢复到空库 `lexgo_test_restore_chapter` → `lexgo verify --manifest` 通过且包含「章节附件都指向存在的章节」「章节播放位置都指向存在的章节」→ **17 张表逐表内容校验和与源库一致** → 用恢复库起实例:同一账号可登录、章节仍报告两个文件、位置仍是恢复前的值、**音频与插图按字节相同地返回**、Range 仍得 206、越权仍 404 → 演练库与 fixture 删除,开发库仍是 schema 9。证据 `.local/issue37-restore-evidence.json`。
### 插图交互修订(2026-09-15,按用户要求)
书籍页章节列表去掉插图列与列表预取;阅读页改为缩略图(高 120px 的按钮)并在点击后于弹窗中显示原图。后端与 schema 无变化。验证:学习端 157 单测与 26 项 E2E 通过(断言章节列表无 `<img>` 且不请求 `/illustration`、缩略图是按钮、点击后弹窗内为对象 URL 的图片);真实链路在 issue5_a 自己的一本书上完成——列表 `<img>` 为 0、缩略图 ≤130px、弹窗内图片大于缩略图且 `naturalWidth > 0`、Esc 关闭后正文仍可见,随后移除 fixture 插图复原开发数据(截图 `issue37-real-book-no-image.png`、`issue37-real-reader-thumbnail.png`、`issue37-real-illustration-dialog.png`)。示例地址 `/books/5` 属于其他账号,请求返回 404,实际使用该账号自己的书。
### 插图交互修订(2026-09-15,按用户要求)
书籍页章节列表去掉插图列与列表预取;阅读页改为缩略图(高 120px 的按钮)并在点击后于弹窗中显示原图。后端与 schema 无变化。验证:学习端 157 单测与 26 项 E2E 通过(断言章节列表无 `<img>` 且不请求 `/illustration`、缩略图是按钮、点击后弹窗内为对象 URL 的图片);真实链路在 issue5_a 自己的一本书上完成——列表 `<img>` 为 0、缩略图 ≤130px、弹窗内图片大于缩略图且 `naturalWidth > 0`、Esc 关闭后正文仍可见,随后移除 fixture 插图复原开发数据(截图 `issue37-real-book-no-image.png`、`issue37-real-reader-thumbnail.png`、`issue37-real-illustration-dialog.png`)。示例地址 `/books/5` 属于其他账号,请求返回 404,实际使用该账号自己的书。
### 附件并入章节编辑对话框(2026-09-15,按用户要求)
章节列表每行只留一个「编辑」;原「章节附件」对话框被移除,插图与音频的上传/替换/移除移入章节编辑对话框(与标题、正文同处),对话框内明确写出「标题与正文点保存后生效;插图与音频选中后立即上传,取消不会撤销」。后端与 schema 仍无变化。验证:学习端 157 单测与 26 项 E2E 全通过(新断言:章节行只有一个按钮;编辑对话框同时含标题、正文、插图与音频控件与生效时机说明;上传后仍可用「保存章节」提交标题与正文);真实链路确认章节行按钮数为 1、对话框中四类控件齐备、在编辑框内上传后状态立即变为「已设置」而标题仍等待保存、取消后进入阅读页仍为缩略图+弹窗,最后移除 fixture 插图(截图 `issue37-real-chapter-editor.png`)。
### 未验证
- 真机与真实浏览器解码播放(媒体元素在测试中被桩替换)、真机听感与兼容性仍未验证(#21 起的既有缺口)。
- Safari/Firefox 未测;渐进式流式播放仍未做(服务端 Range 已实现)。
- 迁移会清空书级音频测试数据且回退到 v8 找不回;当前无真实用户,工单已记录。
- 已有书籍/章节的存量内容不受影响,但**没有做「把既有书级音频迁到章级」的转换**(无真实数据)。
## #37 章级附件验证(2026-09-15)
### 迁移
schema v8 → v9:新增 `lexgo_chapter_attachments` 与 `lexgo_chapter_playback_positions`,并执行一条幂等清理语句删除 `lexgo_book_attachments` 中 `kind='audio'` 的历史行(测试期数据,不做存量迁移)。开发库迁移前后逐表业务计数一致,schema 记到 9,两张新表为空、书级音频行为 0(快照 `.local/issue13-before37.json`、`.local/issue13-after37.json`)。回退:把 `lexgo_schema.version` 写回 8 并恢复 `.local/lexgo-pre-issue37.exe`,但**被清空的书级音频测试数据找不回**。
| 命令 | 结果 |
|---|---|
| `go vet ./...` / `gofmt -l` | 通过、无输出 |
| `LEXGO_TEST_DB_NAME=lexgo_test_issue13 python scripts/server.py test-integration` | **87 项顶层用例通过、0 跳过**(本单新增/改写:章级附件与位置的完整集成用例、封面仍为书级、上传请求形态校验、v8→v9 迁移与回退重升级、v7→v8 测试改为用原始 SQL 写已退役的表) |
| `cd learner`:`vitest` / `vue-tsc` / `build` / `playwright test` | 157 项单测、类型检查、构建、**26 项 E2E**(桌面 22 + 移动 4)全部通过 |
| 治理 / 镜像 | 65 项、`check --strict`、`sync --check` 通过 |
| 真实 API+MySQL | **52 项检查通过**(见下) |
| #15 恢复演练(第三次) | **22 项检查通过**(见下) |
### 真实链路(52 项)
用一本**两章**的虚构书验证「附件属于章而不是书」:只给第一章上传真实的 PNG 插图与三秒 MP3 音频 → 数据库里两张行与章节对应、`byte_size` 与 `OCTET_LENGTH(bytes)` 一致、音频 sha256 与上传文件一致、书级音频行为 0 → 章节列表与阅读器都带 `illustrationVersion`/`audioVersion`,第二章为空串且位置为 0 → 插图按字节返回并带 `ETag`(`If-None-Match` 得 `304`)→ 音频按字节返回、`Accept-Ranges` 存在、`Range: bytes=200-399` 得 `206` 且切片正确、不可满足的 Range 得 `416` → **书级音频与播放接口已下线(404)** → 位置写入后章节列表回读一致、第二章仍为 0、负值 400、无音频的章 404 → 替换音频后位置归零且仍一行 → SVG 伪装插图、文本伪装音频 400、21 MiB 音频 413 且原文件保留 → 越权读取/替换/上报 404、匿名 401 → 移除音频清掉位置但插图不受影响 → 删除章节与删除书籍级联清空。证据 `.local/issue37-api-evidence.json`。
### 恢复演练(第三次,22 项)
带章级附件的 fixture → Go 二进制 `lexgo backup`(manifest 已含两张新表)→ 恢复到空库 `lexgo_test_restore_chapter` → `lexgo verify --manifest` 通过且包含「章节附件都指向存在的章节」「章节播放位置都指向存在的章节」→ **17 张表逐表内容校验和与源库一致** → 用恢复库起实例:同一账号可登录、章节仍报告两个文件、位置仍是恢复前的值、**音频与插图按字节相同地返回**、Range 仍得 206、越权仍 404 → 演练库与 fixture 删除,开发库仍是 schema 9。证据 `.local/issue37-restore-evidence.json`。
### 插图交互修订(2026-09-15,按用户要求)
书籍页章节列表去掉插图列与列表预取;阅读页改为缩略图(高 120px 的按钮)并在点击后于弹窗中显示原图。后端与 schema 无变化。验证:学习端 157 单测与 26 项 E2E 通过(断言章节列表无 `<img>` 且不请求 `/illustration`、缩略图是按钮、点击后弹窗内为对象 URL 的图片);真实链路在 issue5_a 自己的一本书上完成——列表 `<img>` 为 0、缩略图 ≤130px、弹窗内图片大于缩略图且 `naturalWidth > 0`、Esc 关闭后正文仍可见,随后移除 fixture 插图复原开发数据(截图 `issue37-real-book-no-image.png`、`issue37-real-reader-thumbnail.png`、`issue37-real-illustration-dialog.png`)。示例地址 `/books/5` 属于其他账号,请求返回 404,实际使用该账号自己的书。
### 插图交互修订(2026-09-15,按用户要求)
书籍页章节列表去掉插图列与列表预取;阅读页改为缩略图(高 120px 的按钮)并在点击后于弹窗中显示原图。后端与 schema 无变化。验证:学习端 157 单测与 26 项 E2E 通过(断言章节列表无 `<img>` 且不请求 `/illustration`、缩略图是按钮、点击后弹窗内为对象 URL 的图片);真实链路在 issue5_a 自己的一本书上完成——列表 `<img>` 为 0、缩略图 ≤130px、弹窗内图片大于缩略图且 `naturalWidth > 0`、Esc 关闭后正文仍可见,随后移除 fixture 插图复原开发数据(截图 `issue37-real-book-no-image.png`、`issue37-real-reader-thumbnail.png`、`issue37-real-illustration-dialog.png`)。示例地址 `/books/5` 属于其他账号,请求返回 404,实际使用该账号自己的书。
### 附件并入章节编辑对话框(2026-09-15,按用户要求)
章节列表每行只留一个「编辑」;原「章节附件」对话框被移除,插图与音频的上传/替换/移除移入章节编辑对话框(与标题、正文同处),对话框内明确写出「标题与正文点保存后生效;插图与音频选中后立即上传,取消不会撤销」。后端与 schema 仍无变化。验证:学习端 157 单测与 26 项 E2E 全通过(新断言:章节行只有一个按钮;编辑对话框同时含标题、正文、插图与音频控件与生效时机说明;上传后仍可用「保存章节」提交标题与正文);真实链路确认章节行按钮数为 1、对话框中四类控件齐备、在编辑框内上传后状态立即变为「已设置」而标题仍等待保存、取消后进入阅读页仍为缩略图+弹窗,最后移除 fixture 插图(截图 `issue37-real-chapter-editor.png`)。
### 对话框浮层缺陷修复(2026-09-15,用户发现)
用户报告「编辑区显示在章节列表下面」。查明原因:`learner/src/main.ts` **从未导入 `element-plus/es/components/dialog/style/css` 与 `message-box/style/css`**,因此自 #10 起所有 `ElDialog` 与 `ElMessageBox` 都退化成文档流中的普通块(探针实测:移除导入时对话框 `position: static`、`top` 正好等于章节列表底部;恢复导入后 `.el-overlay` 为 `fixed`、对话框居中且遮罩为 `rgba(0,0,0,0.5)`)。修复:在 `main.ts` 补上两行样式导入。
**验证盲区**:此前所有 UI 验证只断言「元素可见 / DOM 结构」,`toBeVisible()` 对普通块同样成立,所以单元测试与 E2E 全绿也没暴露它。现已补长期回归:`learner/e2e/overlay.ts` 提供 `expectModalOverlay` 与 `expectMessageBoxOverlay`,断言对话框位于 `.el-overlay`(`position: fixed`)、位置在视口内且横向不溢出;`attachments.spec.ts`(章节编辑对话框、插图弹窗)与 `edit.spec.ts`(书名对话框、删除确认)都调用它。真实链路再次确认:章节编辑对话框与删除确认均为固定浮层(截图 `issue37-real-dialog-modal.png`、`issue37-real-confirm-modal.png`)。
### 未验证
- 真机与真实浏览器解码播放(媒体元素在测试中被桩替换)、真机听感与兼容性仍未验证(#21 起的既有缺口)。
- Safari/Firefox 未测;渐进式流式播放仍未做(服务端 Range 已实现)。
- 迁移会清空书级音频测试数据且回退到 v8 找不回;当前无真实用户,工单已记录。
- 已有书籍/章节的存量内容不受影响,但**没有做「把既有书级音频迁到章级」的转换**(无真实数据)。
## #37 章级附件验证(2026-09-15)
### 迁移
schema v8 → v9:新增 `lexgo_chapter_attachments` 与 `lexgo_chapter_playback_positions`,并执行一条幂等清理语句删除 `lexgo_book_attachments` 中 `kind='audio'` 的历史行(测试期数据,不做存量迁移)。开发库迁移前后逐表业务计数一致,schema 记到 9,两张新表为空、书级音频行为 0(快照 `.local/issue13-before37.json`、`.local/issue13-after37.json`)。回退:把 `lexgo_schema.version` 写回 8 并恢复 `.local/lexgo-pre-issue37.exe`,但**被清空的书级音频测试数据找不回**。
| 命令 | 结果 |
|---|---|
| `go vet ./...` / `gofmt -l` | 通过、无输出 |
| `LEXGO_TEST_DB_NAME=lexgo_test_issue13 python scripts/server.py test-integration` | **87 项顶层用例通过、0 跳过**(本单新增/改写:章级附件与位置的完整集成用例、封面仍为书级、上传请求形态校验、v8→v9 迁移与回退重升级、v7→v8 测试改为用原始 SQL 写已退役的表) |
| `cd learner`:`vitest` / `vue-tsc` / `build` / `playwright test` | 157 项单测、类型检查、构建、**26 项 E2E**(桌面 22 + 移动 4)全部通过 |
| 治理 / 镜像 | 65 项、`check --strict`、`sync --check` 通过 |
| 真实 API+MySQL | **52 项检查通过**(见下) |
| #15 恢复演练(第三次) | **22 项检查通过**(见下) |
### 真实链路(52 项)
用一本**两章**的虚构书验证「附件属于章而不是书」:只给第一章上传真实的 PNG 插图与三秒 MP3 音频 → 数据库里两张行与章节对应、`byte_size` 与 `OCTET_LENGTH(bytes)` 一致、音频 sha256 与上传文件一致、书级音频行为 0 → 章节列表与阅读器都带 `illustrationVersion`/`audioVersion`,第二章为空串且位置为 0 → 插图按字节返回并带 `ETag`(`If-None-Match` 得 `304`)→ 音频按字节返回、`Accept-Ranges` 存在、`Range: bytes=200-399` 得 `206` 且切片正确、不可满足的 Range 得 `416` → **书级音频与播放接口已下线(404)** → 位置写入后章节列表回读一致、第二章仍为 0、负值 400、无音频的章 404 → 替换音频后位置归零且仍一行 → SVG 伪装插图、文本伪装音频 400、21 MiB 音频 413 且原文件保留 → 越权读取/替换/上报 404、匿名 401 → 移除音频清掉位置但插图不受影响 → 删除章节与删除书籍级联清空。证据 `.local/issue37-api-evidence.json`。
### 恢复演练(第三次,22 项)
带章级附件的 fixture → Go 二进制 `lexgo backup`(manifest 已含两张新表)→ 恢复到空库 `lexgo_test_restore_chapter` → `lexgo verify --manifest` 通过且包含「章节附件都指向存在的章节」「章节播放位置都指向存在的章节」→ **17 张表逐表内容校验和与源库一致** → 用恢复库起实例:同一账号可登录、章节仍报告两个文件、位置仍是恢复前的值、**音频与插图按字节相同地返回**、Range 仍得 206、越权仍 404 → 演练库与 fixture 删除,开发库仍是 schema 9。证据 `.local/issue37-restore-evidence.json`。
### 插图交互修订(2026-09-15,按用户要求)
书籍页章节列表去掉插图列与列表预取;阅读页改为缩略图(高 120px 的按钮)并在点击后于弹窗中显示原图。后端与 schema 无变化。验证:学习端 157 单测与 26 项 E2E 通过(断言章节列表无 `<img>` 且不请求 `/illustration`、缩略图是按钮、点击后弹窗内为对象 URL 的图片);真实链路在 issue5_a 自己的一本书上完成——列表 `<img>` 为 0、缩略图 ≤130px、弹窗内图片大于缩略图且 `naturalWidth > 0`、Esc 关闭后正文仍可见,随后移除 fixture 插图复原开发数据(截图 `issue37-real-book-no-image.png`、`issue37-real-reader-thumbnail.png`、`issue37-real-illustration-dialog.png`)。示例地址 `/books/5` 属于其他账号,请求返回 404,实际使用该账号自己的书。
### 插图交互修订(2026-09-15,按用户要求)
书籍页章节列表去掉插图列与列表预取;阅读页改为缩略图(高 120px 的按钮)并在点击后于弹窗中显示原图。后端与 schema 无变化。验证:学习端 157 单测与 26 项 E2E 通过(断言章节列表无 `<img>` 且不请求 `/illustration`、缩略图是按钮、点击后弹窗内为对象 URL 的图片);真实链路在 issue5_a 自己的一本书上完成——列表 `<img>` 为 0、缩略图 ≤130px、弹窗内图片大于缩略图且 `naturalWidth > 0`、Esc 关闭后正文仍可见,随后移除 fixture 插图复原开发数据(截图 `issue37-real-book-no-image.png`、`issue37-real-reader-thumbnail.png`、`issue37-real-illustration-dialog.png`)。示例地址 `/books/5` 属于其他账号,请求返回 404,实际使用该账号自己的书。
### 附件并入章节编辑对话框(2026-09-15,按用户要求)
章节列表每行只留一个「编辑」;原「章节附件」对话框被移除,插图与音频的上传/替换/移除移入章节编辑对话框(与标题、正文同处),对话框内明确写出「标题与正文点保存后生效;插图与音频选中后立即上传,取消不会撤销」。后端与 schema 仍无变化。验证:学习端 157 单测与 26 项 E2E 全通过(新断言:章节行只有一个按钮;编辑对话框同时含标题、正文、插图与音频控件与生效时机说明;上传后仍可用「保存章节」提交标题与正文);真实链路确认章节行按钮数为 1、对话框中四类控件齐备、在编辑框内上传后状态立即变为「已设置」而标题仍等待保存、取消后进入阅读页仍为缩略图+弹窗,最后移除 fixture 插图(截图 `issue37-real-chapter-editor.png`)。
### 对话框浮层缺陷修复(2026-09-15,用户发现)
用户报告「编辑区显示在章节列表下面」。查明原因:`learner/src/main.ts` **从未导入 `element-plus/es/components/dialog/style/css` 与 `message-box/style/css`**,因此自 #10 起所有 `ElDialog` 与 `ElMessageBox` 都退化成文档流中的普通块(探针实测:移除导入时对话框 `position: static`、`top` 正好等于章节列表底部;恢复导入后 `.el-overlay` 为 `fixed`、对话框居中且遮罩为 `rgba(0,0,0,0.5)`)。修复:在 `main.ts` 补上两行样式导入。
**验证盲区**:此前所有 UI 验证只断言「元素可见 / DOM 结构」,`toBeVisible()` 对普通块同样成立,所以单元测试与 E2E 全绿也没暴露它。现已补长期回归:`learner/e2e/overlay.ts` 提供 `expectModalOverlay` 与 `expectMessageBoxOverlay`,断言对话框位于 `.el-overlay`(`position: fixed`)、位置在视口内且横向不溢出;`attachments.spec.ts`(章节编辑对话框、插图弹窗)与 `edit.spec.ts`(书名对话框、删除确认)都调用它。真实链路再次确认:章节编辑对话框与删除确认均为固定浮层(截图 `issue37-real-dialog-modal.png`、`issue37-real-confirm-modal.png`)。
### 作者字段与编辑对话框紧凑化(2026-09-15,按用户要求)
按用户要求:①「章节标题」标签与输入框同行,下面新增「作者」标签与输入框;②精简插图/音频的提示文字,把高度让给正文编辑框。
- **schema v10**:`lexgo_chapters` 增加 `author VARCHAR(120) NOT NULL DEFAULT ''`。由 Go 条件步骤 `addChapterAuthorColumn`(先查 `information_schema` 再 `ALTER TABLE`)在语句列表之后执行,保持「部分迁移可重试 / 回退标记后可重新升级」;新建库的 v3 语句也带该列。开发库迁移后 schema = 10,既有 34 个章节的 author 均为空串(无数据变化)。
- **接口**:章节编辑接受 `author`(可选、trim、≤120 字符、空串清空),`ChapterSummary`/`ChapterSource` 都返回它;仅改作者不会重新处理章节(状态保持不变)。
- **界面**:`.field-row` 把标签与输入框放同一行(标题、作者各一行);正文 textarea 提升到 18 行;插图与音频压成各一两行提示(规格写在下方一行);阅读页在标题下显示非空作者(`data-testid="chapter-author-line"`)。
- **验证**:Go 89 项顶层用例(新增 `TestMySQLChapterAuthorRoundTrip` 与 `TestMigrationFromV9AddsChapterAuthor`,后者覆盖「旧库缺列 → 迁移补列 → 回退标记 → 重新升级」);学习端 157 单测与 26 项 E2E 通过;真实链路实测:标题标签与输入框中线偏差 <8px、作者行位于标题下方且标签同行、正文高度 415px(原约 250px)、保存作者后阅读页显示、清空后该行消失(截图 `issue37-real-editor-layout.png`、`issue37-real-reader-author.png`)。
### 未验证
- 真机与真实浏览器解码播放(媒体元素在测试中被桩替换)、真机听感与兼容性仍未验证(#21 起的既有缺口)。
- Safari/Firefox 未测;渐进式流式播放仍未做(服务端 Range 已实现)。
- 迁移会清空书级音频测试数据且回退到 v8 找不回;当前无真实用户,工单已记录。
- 已有书籍/章节的存量内容不受影响,但**没有做「把既有书级音频迁到章级」的转换**(无真实数据)。
## #37 章级附件验证(2026-09-15)
### 迁移
schema v8 → v9:新增 `lexgo_chapter_attachments` 与 `lexgo_chapter_playback_positions`,并执行一条幂等清理语句删除 `lexgo_book_attachments` 中 `kind='audio'` 的历史行(测试期数据,不做存量迁移)。开发库迁移前后逐表业务计数一致,schema 记到 9,两张新表为空、书级音频行为 0(快照 `.local/issue13-before37.json`、`.local/issue13-after37.json`)。回退:把 `lexgo_schema.version` 写回 8 并恢复 `.local/lexgo-pre-issue37.exe`,但**被清空的书级音频测试数据找不回**。
| 命令 | 结果 |
|---|---|
| `go vet ./...` / `gofmt -l` | 通过、无输出 |
| `LEXGO_TEST_DB_NAME=lexgo_test_issue13 python scripts/server.py test-integration` | **87 项顶层用例通过、0 跳过**(本单新增/改写:章级附件与位置的完整集成用例、封面仍为书级、上传请求形态校验、v8→v9 迁移与回退重升级、v7→v8 测试改为用原始 SQL 写已退役的表) |
| `cd learner`:`vitest` / `vue-tsc` / `build` / `playwright test` | 157 项单测、类型检查、构建、**26 项 E2E**(桌面 22 + 移动 4)全部通过 |
| 治理 / 镜像 | 65 项、`check --strict`、`sync --check` 通过 |
| 真实 API+MySQL | **52 项检查通过**(见下) |
| #15 恢复演练(第三次) | **22 项检查通过**(见下) |
### 真实链路(52 项)
用一本**两章**的虚构书验证「附件属于章而不是书」:只给第一章上传真实的 PNG 插图与三秒 MP3 音频 → 数据库里两张行与章节对应、`byte_size` 与 `OCTET_LENGTH(bytes)` 一致、音频 sha256 与上传文件一致、书级音频行为 0 → 章节列表与阅读器都带 `illustrationVersion`/`audioVersion`,第二章为空串且位置为 0 → 插图按字节返回并带 `ETag`(`If-None-Match` 得 `304`)→ 音频按字节返回、`Accept-Ranges` 存在、`Range: bytes=200-399` 得 `206` 且切片正确、不可满足的 Range 得 `416` → **书级音频与播放接口已下线(404)** → 位置写入后章节列表回读一致、第二章仍为 0、负值 400、无音频的章 404 → 替换音频后位置归零且仍一行 → SVG 伪装插图、文本伪装音频 400、21 MiB 音频 413 且原文件保留 → 越权读取/替换/上报 404、匿名 401 → 移除音频清掉位置但插图不受影响 → 删除章节与删除书籍级联清空。证据 `.local/issue37-api-evidence.json`。
### 恢复演练(第三次,22 项)
带章级附件的 fixture → Go 二进制 `lexgo backup`(manifest 已含两张新表)→ 恢复到空库 `lexgo_test_restore_chapter` → `lexgo verify --manifest` 通过且包含「章节附件都指向存在的章节」「章节播放位置都指向存在的章节」→ **17 张表逐表内容校验和与源库一致** → 用恢复库起实例:同一账号可登录、章节仍报告两个文件、位置仍是恢复前的值、**音频与插图按字节相同地返回**、Range 仍得 206、越权仍 404 → 演练库与 fixture 删除,开发库仍是 schema 9。证据 `.local/issue37-restore-evidence.json`。
### 插图交互修订(2026-09-15,按用户要求)
书籍页章节列表去掉插图列与列表预取;阅读页改为缩略图(高 120px 的按钮)并在点击后于弹窗中显示原图。后端与 schema 无变化。验证:学习端 157 单测与 26 项 E2E 通过(断言章节列表无 `<img>` 且不请求 `/illustration`、缩略图是按钮、点击后弹窗内为对象 URL 的图片);真实链路在 issue5_a 自己的一本书上完成——列表 `<img>` 为 0、缩略图 ≤130px、弹窗内图片大于缩略图且 `naturalWidth > 0`、Esc 关闭后正文仍可见,随后移除 fixture 插图复原开发数据(截图 `issue37-real-book-no-image.png`、`issue37-real-reader-thumbnail.png`、`issue37-real-illustration-dialog.png`)。示例地址 `/books/5` 属于其他账号,请求返回 404,实际使用该账号自己的书。
### 插图交互修订(2026-09-15,按用户要求)
书籍页章节列表去掉插图列与列表预取;阅读页改为缩略图(高 120px 的按钮)并在点击后于弹窗中显示原图。后端与 schema 无变化。验证:学习端 157 单测与 26 项 E2E 通过(断言章节列表无 `<img>` 且不请求 `/illustration`、缩略图是按钮、点击后弹窗内为对象 URL 的图片);真实链路在 issue5_a 自己的一本书上完成——列表 `<img>` 为 0、缩略图 ≤130px、弹窗内图片大于缩略图且 `naturalWidth > 0`、Esc 关闭后正文仍可见,随后移除 fixture 插图复原开发数据(截图 `issue37-real-book-no-image.png`、`issue37-real-reader-thumbnail.png`、`issue37-real-illustration-dialog.png`)。示例地址 `/books/5` 属于其他账号,请求返回 404,实际使用该账号自己的书。
### 附件并入章节编辑对话框(2026-09-15,按用户要求)
章节列表每行只留一个「编辑」;原「章节附件」对话框被移除,插图与音频的上传/替换/移除移入章节编辑对话框(与标题、正文同处),对话框内明确写出「标题与正文点保存后生效;插图与音频选中后立即上传,取消不会撤销」。后端与 schema 仍无变化。验证:学习端 157 单测与 26 项 E2E 全通过(新断言:章节行只有一个按钮;编辑对话框同时含标题、正文、插图与音频控件与生效时机说明;上传后仍可用「保存章节」提交标题与正文);真实链路确认章节行按钮数为 1、对话框中四类控件齐备、在编辑框内上传后状态立即变为「已设置」而标题仍等待保存、取消后进入阅读页仍为缩略图+弹窗,最后移除 fixture 插图(截图 `issue37-real-chapter-editor.png`)。
### 对话框浮层缺陷修复(2026-09-15,用户发现)
用户报告「编辑区显示在章节列表下面」。查明原因:`learner/src/main.ts` **从未导入 `element-plus/es/components/dialog/style/css` 与 `message-box/style/css`**,因此自 #10 起所有 `ElDialog` 与 `ElMessageBox` 都退化成文档流中的普通块(探针实测:移除导入时对话框 `position: static`、`top` 正好等于章节列表底部;恢复导入后 `.el-overlay` 为 `fixed`、对话框居中且遮罩为 `rgba(0,0,0,0.5)`)。修复:在 `main.ts` 补上两行样式导入。
**验证盲区**:此前所有 UI 验证只断言「元素可见 / DOM 结构」,`toBeVisible()` 对普通块同样成立,所以单元测试与 E2E 全绿也没暴露它。现已补长期回归:`learner/e2e/overlay.ts` 提供 `expectModalOverlay` 与 `expectMessageBoxOverlay`,断言对话框位于 `.el-overlay`(`position: fixed`)、位置在视口内且横向不溢出;`attachments.spec.ts`(章节编辑对话框、插图弹窗)与 `edit.spec.ts`(书名对话框、删除确认)都调用它。真实链路再次确认:章节编辑对话框与删除确认均为固定浮层(截图 `issue37-real-dialog-modal.png`、`issue37-real-confirm-modal.png`)。
### 作者字段与编辑对话框紧凑化(2026-09-15,按用户要求)
按用户要求:①「章节标题」标签与输入框同行,下面新增「作者」标签与输入框;②精简插图/音频的提示文字,把高度让给正文编辑框。
- **schema v10**:`lexgo_chapters` 增加 `author VARCHAR(120) NOT NULL DEFAULT ''`。由 Go 条件步骤 `addChapterAuthorColumn`(先查 `information_schema` 再 `ALTER TABLE`)在语句列表之后执行,保持「部分迁移可重试 / 回退标记后可重新升级」;新建库的 v3 语句也带该列。开发库迁移后 schema = 10,既有 34 个章节的 author 均为空串(无数据变化)。
- **接口**:章节编辑接受 `author`(可选、trim、≤120 字符、空串清空),`ChapterSummary`/`ChapterSource` 都返回它;仅改作者不会重新处理章节(状态保持不变)。
- **界面**:`.field-row` 把标签与输入框放同一行(标题、作者各一行);正文 textarea 提升到 18 行;插图与音频压成各一两行提示(规格写在下方一行);阅读页在标题下显示非空作者(`data-testid="chapter-author-line"`)。
- **验证**:Go 89 项顶层用例(新增 `TestMySQLChapterAuthorRoundTrip` 与 `TestMigrationFromV9AddsChapterAuthor`,后者覆盖「旧库缺列 → 迁移补列 → 回退标记 → 重新升级」);学习端 157 单测与 26 项 E2E 通过;真实链路实测:标题标签与输入框中线偏差 <8px、作者行位于标题下方且标签同行、正文高度 415px(原约 250px)、保存作者后阅读页显示、清空后该行消失(截图 `issue37-real-editor-layout.png`、`issue37-real-reader-author.png`)。
### 书级作者与书籍页精简(2026-09-15,按用户要求)
按用户要求:①书籍页去掉「封面」「书籍封面」「JPG…4096×4096」「音频与插图按章节设置…」四段文字,把空间让给章节列表;②「编辑书名」改为**「编辑书籍」**并可编辑**书名与作者**;③书名右侧显示当前书籍作者。
- **schema v11**:`lexgo_books.author VARCHAR(120) NOT NULL DEFAULT ''`,复用 v10 的条件加列助手(改名 `addAuthorColumn`,表名取自本文件常量)。开发库迁移后 schema = 11,既有 25 本书的 author 均为空串;回退件 `.local/lexgo-pre-issue37c.exe`(回退写回版本号 10 即可)。
- **接口**:`PATCH /api/v1/books/:id` 接受可选 `author`(省略保留、空串清空、trim、≤120),`BookSummary`/`BookRef` 返回它;书籍编辑不再只改书名。
- **界面**:封面区块变成一行(预览 + 上传/替换 + 移除);`.title-line` 在书名右侧显示作者(未设置不显示);「编辑书籍」对话框用同行标签编辑书名与作者。
- **验证**:Go **91 项顶层用例**(新增 `TestMySQLBookAuthorRoundTrip` 与 `TestMigrationFromV10AddsBookAuthor`);学习端 157 单测与 26 项 E2E 通过;真实链路实测封面行高 **68px**、章节列表紧随其后(间距 18px)、四段被删文字都不再出现、「编辑书籍」对话框含作者输入、保存后作者显示在书名右侧(同一行、位于书名之后)并在刷新后保留、清空后标签消失(截图 `issue37-real-book-page.png`)。
### 未验证
- 真机与真实浏览器解码播放(媒体元素在测试中被桩替换)、真机听感与兼容性仍未验证(#21 起的既有缺口)。
- Safari/Firefox 未测;渐进式流式播放仍未做(服务端 Range 已实现)。
- 迁移会清空书级音频测试数据且回退到 v8 找不回;当前无真实用户,工单已记录。
- 已有书籍/章节的存量内容不受影响,但**没有做「把既有书级音频迁到章级」的转换**(无真实数据)。
+34 -4
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: 437d154868c23dd5b39e525d6c45e9ff13364d47
synchronized_at: 2026-09-15T10:33:08Z
wiki_revision: 21ca050a33bdbe8133c921b6b302fbfc8d8cf7fe
synchronized_at: 2026-09-15T15:10:10Z
<!-- gitea-wiki-mirror:end -->
# 产品需求总览
@@ -288,14 +288,44 @@ F12 的电脑与手机基础体验已于 2026-09-15 通过用户验收:新增
仍未实现并留给后续工单:自托管试用交付与完整恢复(#15)、书籍音频与封面附件(#21)、书库列表优化(#24),以及已定位未修复的缺陷 #32。PWA、离线能力、自定义配色、全局 UI 缩放与偏好跨设备同步明确不在范围内。**真实手机的长按选择与操作手感仍需人工确认**,本单的触摸证据来自模拟设备。
## #15 交付状态更新(2026-09-15)
## #15 交付状态更新(2026-09-15,已验收并合入 main)
B04 与 F01–F12 的集成交付已实现,待用户验收:新部署与运维 Wiki 页(首次部署、配置与凭据来源、日常运维、健康检查、升级回滚、备份恢复、已知限制),运维工具 `scripts/ops.py`(`install-check` / `init-database` / `backup` / `restore` / `verify` / `smoke`)与 `scripts/bench.py`。本机完成完整演练:空库安装并由管理员建立两个虚构演练账号走通学习闭环;备份开发库后恢复到空实例,逐表行数与内容校验和一致、源库未被改动,两账号闭环与越权校验全通过。性能在 20 章/2000 词条/8000 条作答的人造数据集上实测(p50 6–23 ms,登录 82 ms,均无错误),只作观察不给承诺。
用户已确认的范围边界:本次只交付本机可复现的安装/备份/恢复材料并在本机演练,**不对外部署、不创建发布标签、不邀请用户**;生产入口与 HTTPS 只写入文档。**真实回滚、定时备份、监控告警、多机部署与 HTTPS 仍未验证**;附件(音频/封面,#21)尚未实现,因此恢复契约目前只覆盖数据库,待 #21 落地后由本单的恢复验收纳入附件。遗留缺陷 #32(编辑正文回到曾用版本返回 500)仍待确认修复方案。
## #21 交付状态更新(2026-09-15)
## #21 交付状态更新(2026-09-15,已验收并合入 main)
F 范围之外的 MVP 增补项「书籍音频与封面附件」已实现,待用户验收:每本书可选一张封面与一段 MP3,书库显示封面,书籍页可上传/替换/移除,阅读页播放器支持播放、拖动与倍速,不自动播放也不与文本同步;播放位置按账号与书籍保存,替换或移除音频时重置;附件按文件内容校验类型(不看扩展名),音频 ≤20 MiB、封面 ≤2 MiB 且 ≤4096×4096;附件读取需会话并支持 HTTP Range。schema 升到 v8(两张新表,需显式迁移)。附件存于数据库,因此包含在 #15 的备份与完整恢复范围内,恢复演练已验证附件按字节恢复且仍可读取与拖动。
非目标保持不变:多音轨、分章音轨、字幕与时间戳同步、逐句跟读、转写、TTS、转码、自动搜索封面、在线音频地址抓取、公开分享与跨用户共享。**浏览器真实解码播放(测试中媒体元素被桩替换)与真机听感仍需人工确认**;Safari/Firefox 未验证。其余待办:书籍音频与封面已在 #21 落地,剩余 #24 书库列表优化与缺陷 #32。
## #37 交付状态更新(2026-09-15)
用户的真实场景「多篇文章合并成一本书、每章各有音频,并为每章加插图」已实现,待用户验收:音频与插图改为**章节级**(一章各一张),封面维持**书级**;阅读页在正文上方显示本章插图,播放器只在本章有音频时出现;书籍页每个章节行显示插图缩略图与「附件」入口,附件对话框与正文编辑分开;播放位置粒度改为**账号+章节**,切换章节时上报一次。类型按文件内容判定(MP3、JPG/PNG/WebP),音频 ≤20 MiB、图片 ≤2 MiB 且 ≤4096×4096,读取需会话并支持 HTTP Range 与 ETag。schema 升到 v9(两张新表 + 幂等清空书级音频历史行,需显式迁移);**书级音频接口已下线**。附件仍存于数据库,因此 #15 的备份恢复自动覆盖,第三次恢复演练验证了附件按字节恢复且 Range 可用。
**已知代价**:#21 交付的书级音频功能整体作废(其测试期数据在迁移中清空且回退无法找回);前端实现与播放器组件被复用,未推倒重做。下次再调整附件挂载粒度前应先确认真实使用场景。**真机听感与浏览器真实解码播放仍未验证**。其余待办:#24 书库列表优化与缺陷 #32。
## #37 交付状态更新(2026-09-15)
用户的真实场景「多篇文章合并成一本书、每章各有音频,并为每章加插图」已实现,待用户验收:音频与插图改为**章节级**(一章各一张),封面维持**书级**;阅读页在正文上方显示本章插图缩略图、点击后在弹窗里查看原图(章节列表不显示章节图片),播放器只在本章有音频时出现;书籍页每个章节行显示插图缩略图与「附件」入口,附件对话框与正文编辑分开;播放位置粒度改为**账号+章节**,切换章节时上报一次。类型按文件内容判定(MP3、JPG/PNG/WebP),音频 ≤20 MiB、图片 ≤2 MiB 且 ≤4096×4096,读取需会话并支持 HTTP Range 与 ETag。schema 升到 v9(两张新表 + 幂等清空书级音频历史行,需显式迁移);**书级音频接口已下线**。附件仍存于数据库,因此 #15 的备份恢复自动覆盖,第三次恢复演练验证了附件按字节恢复且 Range 可用。
**已知代价**:#21 交付的书级音频功能整体作废(其测试期数据在迁移中清空且回退无法找回);前端实现与播放器组件被复用,未推倒重做。下次再调整附件挂载粒度前应先确认真实使用场景。**真机听感与浏览器真实解码播放仍未验证**。其余待办:#24 书库列表优化与缺陷 #32。
## #37 交付状态更新(2026-09-15)
用户的真实场景「多篇文章合并成一本书、每章各有音频,并为每章加插图」已实现,待用户验收:音频与插图改为**章节级**(一章各一张),封面维持**书级**;阅读页在正文上方显示本章插图缩略图、点击后在弹窗里查看原图(章节列表不显示章节图片),播放器只在本章有音频时出现;书籍页章节列表每行只有一个「编辑」入口,标题、正文、插图与音频在同一对话框内编辑(标题与正文点保存后生效,文件选中后立即上传);播放位置粒度改为**账号+章节**,切换章节时上报一次。类型按文件内容判定(MP3、JPG/PNG/WebP),音频 ≤20 MiB、图片 ≤2 MiB 且 ≤4096×4096,读取需会话并支持 HTTP Range 与 ETag。schema 升到 v9(两张新表 + 幂等清空书级音频历史行,需显式迁移);**书级音频接口已下线**。附件仍存于数据库,因此 #15 的备份恢复自动覆盖,第三次恢复演练验证了附件按字节恢复且 Range 可用。
**已知代价**:#21 交付的书级音频功能整体作废(其测试期数据在迁移中清空且回退无法找回);前端实现与播放器组件被复用,未推倒重做。下次再调整附件挂载粒度前应先确认真实使用场景。**真机听感与浏览器真实解码播放仍未验证**。其余待办:#24 书库列表优化与缺陷 #32。
## #37 交付状态更新(2026-09-15)
用户的真实场景「多篇文章合并成一本书、每章各有音频,并为每章加插图」已实现,待用户验收:音频与插图改为**章节级**(一章各一张),封面维持**书级**;阅读页在正文上方显示本章插图缩略图、点击后在弹窗里查看原图(章节列表不显示章节图片),播放器只在本章有音频时出现;书籍页章节列表每行只有一个「编辑」入口,标题、作者、正文、插图与音频在同一对话框内编辑(标题/作者/正文点保存后生效,文件选中后立即上传);章节作者为可选自由文本(≤120 字符),阅读页在标题下显示;schema 为 v10;播放位置粒度改为**账号+章节**,切换章节时上报一次。类型按文件内容判定(MP3、JPG/PNG/WebP),音频 ≤20 MiB、图片 ≤2 MiB 且 ≤4096×4096,读取需会话并支持 HTTP Range 与 ETag。schema 升到 v9(两张新表 + 幂等清空书级音频历史行,需显式迁移);**书级音频接口已下线**。附件仍存于数据库,因此 #15 的备份恢复自动覆盖,第三次恢复演练验证了附件按字节恢复且 Range 可用。
**已知代价**:#21 交付的书级音频功能整体作废(其测试期数据在迁移中清空且回退无法找回);前端实现与播放器组件被复用,未推倒重做。下次再调整附件挂载粒度前应先确认真实使用场景。**真机听感与浏览器真实解码播放仍未验证**。其余待办:#24 书库列表优化与缺陷 #32。
## #37 交付状态更新(2026-09-15)
用户的真实场景「多篇文章合并成一本书、每章各有音频,并为每章加插图」已实现,待用户验收:音频与插图改为**章节级**(一章各一张),封面维持**书级**;阅读页在正文上方显示本章插图缩略图、点击后在弹窗里查看原图(章节列表不显示章节图片),播放器只在本章有音频时出现;书籍页章节列表每行只有一个「编辑」入口,标题、作者、正文、插图与音频在同一对话框内编辑(标题/作者/正文点保存后生效,文件选中后立即上传);**书籍作者**与**章节作者**是两份互相独立的可选自由文本(各 ≤120 字符,留空即清空),书级作者显示在书籍页书名右侧并由「编辑书籍」维护,章节作者显示在阅读页标题下;schema 为 v11;播放位置粒度改为**账号+章节**,切换章节时上报一次。类型按文件内容判定(MP3、JPG/PNG/WebP),音频 ≤20 MiB、图片 ≤2 MiB 且 ≤4096×4096,读取需会话并支持 HTTP Range 与 ETag。schema 升到 v9(两张新表 + 幂等清空书级音频历史行,需显式迁移);**书级音频接口已下线**。附件仍存于数据库,因此 #15 的备份恢复自动覆盖,第三次恢复演练验证了附件按字节恢复且 Range 可用。
**已知代价**:#21 交付的书级音频功能整体作废(其测试期数据在迁移中清空且回退无法找回);前端实现与播放器组件被复用,未推倒重做。下次再调整附件挂载粒度前应先确认真实使用场景。**真机听感与浏览器真实解码播放仍未验证**。其余待办:#24 书库列表优化与缺陷 #32。
+4 -4
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Deployment-and-Operations.-
wiki_revision: dacdad8a9385b2fde153a0dd1fe2579b57a810fd
synchronized_at: 2026-09-15T10:33:09Z
wiki_revision: b9eda78d3e71dab02dbf2e3af700c81d9c725f8d
synchronized_at: 2026-09-15T13:07:36Z
<!-- gitea-wiki-mirror:end -->
# 部署与运维
@@ -32,7 +32,7 @@ synchronized_at: 2026-09-15T10:33:09Z
| 运行时 | 后端为单个静态二进制,无需运行时依赖;构建需要 Go 1.26.5、Node 22、pnpm 9 |
| 进程托管 | supervisor(本项目开发机即为 `lexgo-api` / `lexgo-learner` / `lexgo-admin` 三个 program) |
| 日志 | supervisor 的 `stdout`/`stderr` 日志文件;应用自身不写文件日志 |
| 备份对象 | MySQL 全库(用户数据、原文、词典归档、审计、**书籍封面与音频附件及播放位置**)+ `.env.local`(单独从密码库取) |
| 备份对象 | MySQL 全库(用户数据、原文、词典归档、审计、**书籍封面以及章节音频/插图与播放位置**)+ `.env.local`(单独从密码库取) |
## 环境要求
@@ -212,7 +212,7 @@ python scripts/ops.py smoke --api http://127.0.0.1:<端口> --admin-user <管理
- **真实回滚演练未做**:本文档给出规则,但没有在真实实例上执行过「升级 → 回滚」全过程。
- 未验证 HTTPS、域名、多机与灰度部署;本机演练只用 `127.0.0.1` 与模拟触摸视口。
- 未启用定时备份、监控与告警;备份由人工触发。
- **封面与音频附件(#21)已存进数据库**,因此 dump 自动包含附件与播放位置,恢复后仍可按字节读取并拖动;**附件会让 dump 变大**(单音频上限 20 MiB,封面上限 2 MiB),容量与备份体积要按试用量估算。
- **封面(书级)与音频、插图(章级)都存进数据库**,因此 dump 自动包含它们与播放位置,恢复后仍可按字节读取并拖动;**附件会让 dump 变大**(单音频上限 20 MiB,图片上限 2 MiB),容量与备份体积要按试用量估算。#37 起附件挂在章节上,书级音频接口已下线。
- 附件读取需要会话并支持 HTTP Range;客户端用带凭据的 fetch 取字节后交给媒体元素,令牌不出现在 URL 里,但浏览器会整份取回后再播放(未做渐进式流式播放)。
- 恢复演练是单机顺序执行,未验证大库恢复耗时与磁盘空间上限。
- 接口级的两账号闭环与越权验证需要 HTTP 客户端,目前只在 Python 工具通道提供;Go 二进制提供数据库层的备份、恢复与校验。
+13 -5
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: e1e363e39ea02c26fab7f96c696ee20e60dcec60
synchronized_at: 2026-09-15T10:33:06Z
wiki_revision: da0f9a0feb70b7099edcf8adf50a18a15435dd33
synchronized_at: 2026-09-15T15:10:07Z
<!-- gitea-wiki-mirror:end -->
# LexGo 文档入口
@@ -87,8 +87,16 @@ Quant-UX 原型 v1 已通过用户验收。[桌面预览](https://qux.ilapage.cn
#14 桌面与手机体验、主题与键盘操作已于 2026-09-15 通过用户验收:新增「显示」设置(浅色/深色/跟随系统,正文标准/大/特大),偏好按账号保存在本机,切换账号不会串;阅读位置按账号与章节保留,正文换成新版本后不再套用旧位置;复习页支持空格显示答案、1/2/3 评分,输入框内不会误触快捷键,所有可交互元素有可见焦点环。手机端用 390×844 触摸视口验证八个界面无横向溢出、都能返回、触摸滑动滚动而不误开释义面板,深色与特大字号刷新后保持。本次没有数据库与接口变化;PR #34 已 fast-forward-only 合入 main。真实手机的长按选择与手感仍需人工确认。
#15 自托管试用交付已实现,待用户验收:新增运维工具(`scripts/ops.py`:依赖检查、建空库、备份、恢复、校验、两账号闭环演练;`scripts/bench.py`:写明规模的人造数据集性能测量)、新的 Wiki 部署与运维页,以及一次完整的本机演练——空库安装(建库 → 迁移 → 首次 bootstrap 建管理员 → 管理员建两个虚构演练账号 → 走通粘贴/阅读/查词/保存/复习/完成章节/进度)、备份 lexgo_dev、恢复到空实例并用第二个 API 实例完成两账号闭环与越权校验,恢复后逐表行数与内容校验和都和源库一致、源库未被改动。备份含全部用户数据(数据库)与环境配置(单独保管凭据),恢复默认只写空库、覆盖需要显式确认与 `--force`、拒绝写入系统库。性能在 20 章/2000 词条/8000 条作答的人造数据集上实测并记录环境,只作观察不给承诺。真实回滚、HTTPS、多机与定时备份仍未验证;附件(#21)尚未实施,恢复范围目前只覆盖数据库。
#15 自托管试用交付已于 2026-09-15 通过用户验收:备份、恢复与校验同时提供**纯二进制子命令**(`lexgo backup|restore|verify`,部署机只需要二进制与 MySQL 客户端)与 Python 工具通道(交叉验证与开发便利),两条通道的 dump 与 manifest 格式一致、可互相恢复;新增运维工具(`scripts/ops.py`:依赖检查、建空库、备份、恢复、校验、两账号闭环演练;`scripts/bench.py`:写明规模的人造数据集性能测量)、新的 Wiki 部署与运维页,以及一次完整的本机演练——空库安装(建库 → 迁移 → 首次 bootstrap 建管理员 → 管理员建两个虚构演练账号 → 走通粘贴/阅读/查词/保存/复习/完成章节/进度)、备份 lexgo_dev、恢复到空实例并用第二个 API 实例完成两账号闭环与越权校验,恢复后逐表行数与内容校验和都和源库一致、源库未被改动。备份含全部用户数据(数据库)与环境配置(单独保管凭据),恢复默认只写空库、覆盖需要显式确认与 `--force`、拒绝写入系统库。性能在 20 章/2000 词条/8000 条作答的人造数据集上实测并记录环境,只作观察不给承诺。两条通道已在本机交叉验证(19 项);纯二进制安装另有 9 项演练、完整恢复演练 27 项、附件版恢复演练 22 项。真实回滚(升级后切回旧二进制)、HTTPS、多机与定时备份仍未验证,已写入部署页的已知限制。PR #35 已 fast-forward-only 合入 main。
#15 自托管试用交付已实现,待用户验收:备份、恢复与校验同时提供**纯二进制子命令**(`lexgo backup|restore|verify`,部署机只需要二进制与 MySQL 客户端)与 Python 工具通道(交叉验证与开发便利),两条通道的 dump 与 manifest 格式一致、可互相恢复;新增运维工具(`scripts/ops.py`:依赖检查、建空库、备份、恢复、校验、两账号闭环演练;`scripts/bench.py`:写明规模的人造数据集性能测量)、新的 Wiki 部署与运维页,以及一次完整的本机演练——空库安装(建库 → 迁移 → 首次 bootstrap 建管理员 → 管理员建两个虚构演练账号 → 走通粘贴/阅读/查词/保存/复习/完成章节/进度)、备份 lexgo_dev、恢复到空实例并用第二个 API 实例完成两账号闭环与越权校验,恢复后逐表行数与内容校验和都和源库一致、源库未被改动。备份含全部用户数据(数据库)与环境配置(单独保管凭据),恢复默认只写空库、覆盖需要显式确认与 `--force`、拒绝写入系统库。性能在 20 章/2000 词条/8000 条作答的人造数据集上实测并记录环境,只作观察不给承诺。两条通道已在本机交叉验证(19 项)。真实回滚、HTTPS、多机与定时备份仍未验证;附件(#21)尚未实施,恢复范围目前只覆盖数据库。
#21 书籍音频与封面附件已于 2026-09-15 通过用户验收:每本书可以上传一张封面(JPG/PNG/WebP,≤2 MiB,≤4096×4096)和一段 MP3(≤20 MiB);书库卡片显示封面,书籍页新增「音频与封面」区块可上传、替换、移除,阅读页在正文上方显示常驻播放器(播放/暂停、拖动进度、0.75–1.5 倍速),不自动播放、不与文本同步。播放位置按账号与书籍保存,替换或移除音频会重置位置;附件与进度按账号隔离,读取需要会话并支持 HTTP Range。附件存于 MySQL,因此 #15 的备份恢复天然覆盖:2026-09-15 的恢复演练验证了恢复后附件按字节相同地返回且 Range 仍可用。真实浏览器是否解码播放(媒体元素在测试中被桩替换)以及真机听感仍需人工确认。
#21 书籍音频与封面附件已于 2026-09-15 实施完成,待用户验收:每本书可以上传一张封面(JPG/PNG/WebP,≤2 MiB,≤4096×4096)和一段 MP3(≤20 MiB);书库卡片显示封面,书籍页新增「音频与封面」区块可上传、替换、移除,阅读页在正文上方显示常驻播放器(播放/暂停、拖动进度、0.75–1.5 倍速),不自动播放、不与文本同步。播放位置按账号与书籍保存,替换或移除音频会重置位置;附件与进度按账号隔离,读取需要会话并支持 HTTP Range。附件存于 MySQL,因此 #15 的备份恢复天然覆盖:2026-09-15 的恢复演练验证了恢复后附件按字节相同地返回且 Range 仍可用。真实浏览器是否解码播放(媒体元素在测试中被桩替换)以及真机听感仍需人工确认。
#37 章节级音频与章节插图已于 2026-09-15 实施完成,待用户验收:每章可以上传一段 MP3(≤20 MiB)与一张插图(JPG/PNG/WebP,≤2 MiB、≤4096×4096),封面仍按书;阅读页在正文上方显示本章插图,播放器只在本章有音频时出现且不与文本同步,播放位置按「账号+章节」保存并在切换章节时上报;书籍页章节列表显示插图缩略图与「附件」入口,附件编辑与正文编辑分开。类型按文件内容判定,读取需会话并支持 HTTP Range 与 ETag;附件存于数据库,包含在 #15 的备份恢复范围内(第三次恢复演练已验证按字节恢复)。schema v9 需显式迁移,书级音频接口已下线,其实测试期数据在迁移中清空。真机听感与浏览器真实解码播放仍需人工确认。
#37 章节级音频与章节插图已于 2026-09-15 实施完成,待用户验收:每章可以上传一段 MP3(≤20 MiB)与一张插图(JPG/PNG/WebP,≤2 MiB、≤4096×4096),封面仍按书;阅读页在正文上方显示本章插图的缩略图、点击后在弹窗里查看原图(书籍页章节列表不显示章节图片),播放器只在本章有音频时出现且不与文本同步,播放位置按「账号+章节」保存并在切换章节时上报;书籍页章节列表显示插图缩略图与「附件」入口,附件编辑与正文编辑分开。类型按文件内容判定,读取需会话并支持 HTTP Range 与 ETag;附件存于数据库,包含在 #15 的备份恢复范围内(第三次恢复演练已验证按字节恢复)。schema v9 需显式迁移,书级音频接口已下线,其实测试期数据在迁移中清空。真机听感与浏览器真实解码播放仍需人工确认。
#37 章节级音频与章节插图已于 2026-09-15 实施完成,待用户验收:每章可以上传一段 MP3(≤20 MiB)与一张插图(JPG/PNG/WebP,≤2 MiB、≤4096×4096),封面仍按书;阅读页在正文上方显示本章插图的缩略图、点击后在弹窗里查看原图(书籍页章节列表不显示章节图片),播放器只在本章有音频时出现且不与文本同步,播放位置按「账号+章节」保存并在切换章节时上报;书籍页章节列表每行只有一个「编辑」,点开后在同一对话框里编辑标题、正文与本章插图、音频(标题与正文点保存后生效,文件选中后立即上传)。类型按文件内容判定,读取需会话并支持 HTTP Range 与 ETag;附件存于数据库,包含在 #15 的备份恢复范围内(第三次恢复演练已验证按字节恢复)。schema v9 需显式迁移,书级音频接口已下线,其实测试期数据在迁移中清空。真机听感与浏览器真实解码播放仍需人工确认。
#37 章节级音频与章节插图已于 2026-09-15 实施完成,待用户验收:每章可以上传一段 MP3(≤20 MiB)与一张插图(JPG/PNG/WebP,≤2 MiB、≤4096×4096),封面仍按书;阅读页在正文上方显示本章插图的缩略图、点击后在弹窗里查看原图(书籍页章节列表不显示章节图片),播放器只在本章有音频时出现且不与文本同步,播放位置按「账号+章节」保存并在切换章节时上报;书籍页章节列表每行只有一个「编辑」,点开后在同一对话框里编辑标题、作者、正文与本章插图、音频(标题/作者/正文点保存后生效,文件选中后立即上传;阅读页在标题下显示作者)。类型按文件内容判定,读取需会话并支持 HTTP Range 与 ETag;附件存于数据库,包含在 #15 的备份恢复范围内(第三次恢复演练已验证按字节恢复)。schema v9 需显式迁移,书级音频接口已下线,其实测试期数据在迁移中清空。真机听感与浏览器真实解码播放仍需人工确认。
#37 章节级音频与章节插图已于 2026-09-15 实施完成,待用户验收:每章可以上传一段 MP3(≤20 MiB)与一张插图(JPG/PNG/WebP,≤2 MiB、≤4096×4096),封面仍按书;阅读页在正文上方显示本章插图的缩略图、点击后在弹窗里查看原图(书籍页章节列表不显示章节图片),播放器只在本章有音频时出现且不与文本同步,播放位置按「账号+章节」保存并在切换章节时上报;书籍页的封面压成一行紧凑控件(预览+上传/替换/移除),书名右侧显示书级作者,「编辑书籍」可改书名与作者,章节列表每行只有一个「编辑」,点开后在同一对话框里编辑标题、作者、正文与本章插图、音频(标题/作者/正文点保存后生效,文件选中后立即上传;阅读页在标题下显示作者)。类型按文件内容判定,读取需会话并支持 HTTP Range 与 ETag;附件存于数据库,包含在 #15 的备份恢复范围内(第三次恢复演练已验证按字节恢复)。schema v9 需显式迁移,书级音频接口已下线,其实测试期数据在迁移中清空。真机听感与浏览器真实解码播放仍需人工确认。
+79 -34
View File
@@ -1,4 +1,5 @@
import { expect, test, type Page } from '@playwright/test'
import { expectModalOverlay } from './overlay'
// Book audio and covers against a mocked API: upload, replace, remove, and the reader's player.
const user = { id: 42, username: 'fictional-attachment', role: 'learner' }
@@ -8,13 +9,17 @@ const chapter = {
id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', status: 'ready', charCount: chapterText.length,
errorReason: '', errorMessage: '', jobId: 9, readAt: null, createdAt: '', updatedAt: '',
}
const pngBytes = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex')
const pngBytes = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==",
"base64",
)
interface Attachment { kind: 'audio' | 'cover'; mime: string; byteSize: number; version: string; updatedAt: string }
interface Attachment { kind: 'audio' | 'cover' | 'illustration'; mime: string; byteSize: number; version: string; updatedAt: string }
async function mockApi(page: Page, options: { audio?: boolean; cover?: boolean } = {}): Promise<{ posted: string[]; deleted: string[]; positions: number[] }> {
let audio: Attachment | null = options.audio === false ? null : { kind: 'audio', mime: 'audio/mpeg', byteSize: 4096, version: 'audio-sha', updatedAt: '2026-09-15T00:00:00Z' }
let cover: Attachment | null = options.cover === false ? null : { kind: 'cover', mime: 'image/png', byteSize: pngBytes.length, version: 'cover-sha', updatedAt: '2026-09-15T00:00:00Z' }
let illustration: Attachment | null = options.cover === false ? null : { kind: 'illustration', mime: 'image/png', byteSize: pngBytes.length, version: 'illustration-sha', updatedAt: '2026-09-15T00:00:00Z' }
let playbackSeconds = 0
const posted: string[] = []
const deleted: string[] = []
@@ -25,7 +30,8 @@ async function mockApi(page: Page, options: { audio?: boolean; cover?: boolean }
const path = url.pathname
const method = route.request().method()
const json = (data: unknown, status = 200) => route.fulfill({ status, json: { code: 200, data } })
const attachments = () => ({ cover, audio, playbackSeconds })
const attachments = () => ({ cover })
const chapterFiles = () => ({ ...chapter, illustrationVersion: illustration?.version ?? '', audioVersion: audio?.version ?? '', playbackSeconds })
if (path === '/api/v1/login') return json({ token: 'fictional-session', user })
if (path === '/api/v1/me') return json(user)
@@ -33,29 +39,34 @@ async function mockApi(page: Page, options: { audio?: boolean; cover?: boolean }
if (path === '/api/v1/books' && method === 'GET') {
return json({ items: [{ ...book, chapterCount: 1, pendingCount: 0, processingCount: 0, readyCount: 1, failedCount: 0, coverVersion: cover?.version ?? '', hasAudio: audio !== null, createdAt: '', updatedAt: '' }] })
}
if (path === '/api/v1/books/7' && method === 'GET') return json({ book: { ...book, attachments: attachments() }, chapters: [chapter] })
if (path === '/api/v1/books/7/cover' || path === '/api/v1/books/7/audio') {
const kind = path.endsWith('cover') ? 'cover' : 'audio'
if (path === '/api/v1/books/7' && method === 'GET') return json({ book: { ...book, attachments: attachments() }, chapters: [chapterFiles()] })
const attachmentPath = path === '/api/v1/books/7/cover' ? 'cover'
: path === '/api/v1/chapters/3/illustration' ? 'illustration'
: path === '/api/v1/chapters/3/audio' ? 'audio' : null
if (attachmentPath !== null) {
const kind = attachmentPath as 'cover' | 'illustration' | 'audio'
if (method === 'GET') {
const body = kind === 'cover' ? pngBytes : Buffer.concat([Buffer.from('ID3'), Buffer.alloc(4096)])
return route.fulfill({ status: 200, headers: { 'Content-Type': kind === 'cover' ? 'image/png' : 'audio/mpeg' }, body })
const body = kind === 'audio' ? Buffer.concat([Buffer.from('ID3'), Buffer.alloc(4096)]) : pngBytes
return route.fulfill({ status: 200, headers: { 'Content-Type': kind === 'audio' ? 'audio/mpeg' : 'image/png' }, body })
}
if (method === 'POST') {
posted.push(kind)
// The mock answers exactly like the backend: the new version replaces the old one.
const next: Attachment = { kind: kind as 'cover' | 'audio', mime: kind === 'cover' ? 'image/png' : 'audio/mpeg', byteSize: 2048, version: `${kind}-new`, updatedAt: '2026-09-15T01:00:00Z' }
const next: Attachment = { kind, mime: kind === 'audio' ? 'audio/mpeg' : 'image/png', byteSize: 2048, version: `${kind}-new`, updatedAt: '2026-09-15T01:00:00Z' }
if (kind === 'cover') cover = next
else if (kind === 'illustration') illustration = next
else { audio = next; playbackSeconds = 0 }
return json({ attachment: next })
}
if (method === 'DELETE') {
deleted.push(kind)
if (kind === 'cover') cover = null
else if (kind === 'illustration') illustration = null
else audio = null
return json({ deleted: true })
}
}
if (path === '/api/v1/books/7/playback' && method === 'PUT') {
if (path === '/api/v1/chapters/3/playback' && method === 'PUT') {
const body = route.request().postDataJSON() as { positionSeconds: number }
positions.push(body.positionSeconds)
playbackSeconds = body.positionSeconds
@@ -70,7 +81,11 @@ async function mockApi(page: Page, options: { audio?: boolean; cover?: boolean }
})
return json({ textSha256: 'sha', tokens })
}
if (path === '/api/v1/chapters/3') return json({ book: { ...book, attachments: attachments() }, chapter: { ...chapter, contentSha256: 'sha', originalText: chapterText }, navigation: { previousChapterId: null, nextChapterId: null } })
if (path === '/api/v1/chapters/3/source') {
return json({ source: { id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', text: chapterText, status: 'ready', contentSha256: 'sha', charCount: chapterText.length } })
}
if (path === '/api/v1/chapters/3' && method === 'PATCH') return json({ chapter: chapterFiles(), job: null, versionChanged: false })
if (path === '/api/v1/chapters/3') return json({ book: { ...book, attachments: attachments() }, chapter: { ...chapterFiles(), contentSha256: 'sha', originalText: chapterText }, navigation: { previousChapterId: null, nextChapterId: null } })
return json({})
})
return { posted, deleted, positions }
@@ -82,7 +97,7 @@ async function login(page: Page): Promise<void> {
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
}
test('the library shows the stored cover and the book page manages attachments', async ({ page }) => {
test('the library shows the cover and the chapter dialog manages its files', async ({ page }) => {
const calls = await mockApi(page)
await login(page)
await expect(page.getByTestId('book-cover')).toBeVisible()
@@ -90,29 +105,44 @@ test('the library shows the stored cover and the book page manages attachments',
await page.getByRole('link', { name: 'Fictional reader', exact: true }).click()
await expect(page.getByTestId('attachments')).toBeVisible()
await expect(page.getByTestId('cover-state')).toContainText('PNG')
await expect(page.getByTestId('audio-state')).toContainText('MP3')
await page.screenshot({ path: '../.local/evidence/issue21-attachments.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)
await expect(page.locator('.chapter-thumb')).toHaveCount(0)
// One entry point per row: the editor holds the title, the text and both files.
await expect(page.getByTestId('chapter-attachments-3')).toHaveCount(0)
await page.screenshot({ path: '../.local/evidence/issue37-chapter-row.png' })
// Replacing the cover uploads the picked file and reports the new state.
await page.setInputFiles('[data-testid="cover-input"]', { name: 'cover.png', mimeType: 'image/png', buffer: pngBytes })
await expect(page.getByTestId('book-notice')).toContainText('封面已更新')
expect(calls.posted).toEqual(['cover'])
await page.getByTestId('edit-chapter-3').click()
// A real modal, not content appended below the chapter list.
await expectModalOverlay(page, page.getByTestId('chapter-dialog'))
await expect(page.locator('#chapter-title')).toBeVisible()
await expect(page.getByTestId('chapter-author')).toBeVisible()
await expect(page.locator('.field-row label[for="chapter-author"]')).toBeVisible()
await expect(page.getByTestId('attachment-timing')).toContainText('立即上传')
await expect(page.getByTestId('illustration-state')).toContainText('已设置')
await expect(page.getByTestId('chapter-audio-state')).toContainText('已设置')
await page.screenshot({ path: '../.local/evidence/issue37-attachment-dialog.png' })
// Replacing the illustration and the audio goes to the chapter endpoints.
await page.setInputFiles('[data-testid="illustration-input"]', { name: 'chapter.png', mimeType: 'image/png', buffer: pngBytes })
await expect(page.getByTestId('book-notice')).toContainText('插图已更新')
await page.setInputFiles('[data-testid="chapter-audio-input"]', { name: 'track.mp3', mimeType: 'audio/mpeg', buffer: Buffer.concat([Buffer.from('ID3'), Buffer.alloc(2048)]) })
await expect(page.getByTestId('book-notice')).toContainText('音频已更新')
expect(calls.posted).toEqual(['illustration', 'audio'])
// A file of the wrong type is refused in the page, before any request.
await page.setInputFiles('[data-testid="audio-input"]', { name: 'song.wav', mimeType: 'audio/wav', buffer: Buffer.from('RIFF....') })
await expect(page.locator('.field-error')).toContainText('音频只支持 MP3')
expect(calls.posted).toEqual(['cover'])
await page.setInputFiles('[data-testid="chapter-audio-input"]', { name: 'song.wav', mimeType: 'audio/wav', buffer: Buffer.from('RIFF....') })
await expect(page.locator('[data-testid="chapter-dialog"] .field-error')).toContainText('音频只支持 MP3')
expect(calls.posted).toEqual(['illustration', 'audio'])
// Uploading audio clears the stored position, which the state line shows.
await page.setInputFiles('[data-testid="audio-input"]', { name: 'track.mp3', mimeType: 'audio/mpeg', buffer: Buffer.concat([Buffer.from('ID3'), Buffer.alloc(2048)]) })
await expect(page.getByTestId('audio-state')).toContainText('MP3')
expect(calls.posted).toEqual(['cover', 'audio'])
// Removing asks first and then reports the empty state.
await page.getByTestId('audio-remove').click()
// Removing the audio asks first and then reports the empty state.
await page.getByTestId('chapter-audio-remove').click()
await page.getByRole('button', { name: '确认移除' }).click()
await expect(page.getByTestId('audio-state')).toContainText('未设置')
await expect(page.getByTestId('chapter-audio-state')).toContainText('未设置')
expect(calls.deleted).toEqual(['audio'])
})
@@ -151,6 +181,16 @@ test('the reader plays the audio without starting it and remembers the position'
await login(page)
await page.goto('/chapters/3')
await expect(page.locator('.reader-text')).toBeVisible()
// The chapter's illustration is a thumbnail above the text; clicking it shows the original.
const thumbnail = page.getByTestId('chapter-illustration')
await expect(thumbnail).toBeVisible()
await expect(thumbnail).toContainText('点击查看大图')
await thumbnail.click()
await expectModalOverlay(page, page.getByTestId('illustration-dialog'))
await expect(page.getByTestId('illustration-dialog').locator('img')).toHaveAttribute('src', /^blob:/)
await page.screenshot({ path: '../.local/evidence/issue37-illustration-dialog.png' })
await page.keyboard.press('Escape')
await expect(page.getByTestId('illustration-dialog')).toBeHidden()
const player = page.getByTestId('audio-player')
await expect(player).toBeVisible()
// The player is sticky above the text so the controls stay reachable while reading.
@@ -179,16 +219,21 @@ test('the reader plays the audio without starting it and remembers the position'
await expect(page.getByTestId('audio-rate-1.5')).toHaveAttribute('aria-pressed', 'true')
})
test('a book without audio shows no player at all', async ({ page }) => {
test('a book without attachments shows placeholders and no player', async ({ page }) => {
await mockApi(page, { audio: false, cover: false })
await login(page)
await expect(page.getByTestId('book-cover')).toHaveCount(0)
// The management section stays: adding an attachment must be possible before one exists.
// 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('audio-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('未设置')
await expect(page.getByTestId('chapter-audio-state')).toContainText('未设置')
await page.goto('/chapters/3')
await expect(page.locator('.reader-text')).toBeVisible()
await expect(page.getByTestId('audio-player')).toHaveCount(0)
await expect(page.getByTestId('chapter-illustration')).toHaveCount(0)
})
+7 -1
View File
@@ -1,4 +1,5 @@
import { expect, test, type Page } from '@playwright/test'
import { expectMessageBoxOverlay, expectModalOverlay } from './overlay'
// Renaming, editing and deleting through the real dialogs of the accepted prototype, against
// a mocked API.
@@ -42,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
@@ -69,10 +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.
@@ -93,6 +97,8 @@ test('rename the book, edit a chapter into a new version and delete both', async
await page.locator('.el-message-box').last().getByRole('button', { name: '取消' }).click()
expect(requests.filter(entry => entry === 'DELETE /api/v1/chapters/9')).toHaveLength(0)
await page.getByTestId('delete-chapter').click()
// A confirmation is a modal overlay too, not text appended to the page.
await expectMessageBoxOverlay(page)
await page.locator('.el-message-box').last().getByRole('button', { name: '确认删除章节' }).click()
await expect(page.getByTestId('book-notice')).toContainText('章节已删除 · 剩余 1 章')
await expect(page.locator('.chapter-row')).toHaveCount(1)
+16 -10
View File
@@ -22,13 +22,16 @@ export async function mockLearnerApi(page: Page): Promise<void> {
errorReason: '', errorMessage: '', jobId: 9, readAt: null, createdAt: '', updatedAt: '',
}
const term = { id: 11, term: 'curiosity', originalForm: 'curiosity', definition: '好奇心;求知欲', examples: ['Curious minds ask.'], status: 'new', level: 0, kind: 'word', wordCount: 1, updatedAt: '2026-01-02T00:00:00Z' }
// The phone audit also covers the attachment surfaces, so the fixture book carries both.
const pngBytes = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex')
// The phone audit also covers the attachment surfaces: a book cover plus a chapter illustration
// and audio, which is where those files live since #37.
const pngBytes = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==",
"base64",
)
const attachments = {
cover: { kind: 'cover', mime: 'image/png', byteSize: pngBytes.length, version: 'cover-sha', updatedAt: '2026-09-15T00:00:00Z' },
audio: { kind: 'audio', mime: 'audio/mpeg', byteSize: 4096, version: 'audio-sha', updatedAt: '2026-09-15T00:00:00Z' },
playbackSeconds: 65,
}
const chapterWithFiles = { ...chapter, illustrationVersion: 'illustration-sha', audioVersion: 'audio-sha', playbackSeconds: 65 }
await page.route('**/api/v1/**', async route => {
const url = new URL(route.request().url())
@@ -38,12 +41,15 @@ export async function mockLearnerApi(page: Page): Promise<void> {
else if (path === '/api/v1/me') data = mobileUser
else if (path === '/api/v1/space') data = { ownerId: mobileUser.id, language: 'en' }
else if (path === '/api/v1/books') data = { items: [{ ...book, coverVersion: attachments.cover.version, hasAudio: true }] }
else if (path === '/api/v1/books/7/cover' || path === '/api/v1/books/7/audio') {
const body = path.endsWith('cover') ? pngBytes : Buffer.concat([Buffer.from('ID3'), Buffer.alloc(4096)])
await route.fulfill({ status: 200, headers: { 'Content-Type': path.endsWith('cover') ? 'image/png' : 'audio/mpeg' }, body })
else if (path === '/api/v1/books/7/cover' || path === '/api/v1/chapters/3/illustration' || path === '/api/v1/chapters/3/audio') {
const body = path.endsWith('illustration') || path.endsWith('cover')
? pngBytes
: Buffer.concat([Buffer.from('ID3'), Buffer.alloc(4096)])
const type = path.endsWith('illustration') || path.endsWith('cover') ? 'image/png' : 'audio/mpeg'
await route.fulfill({ status: 200, headers: { 'Content-Type': type }, body })
return
} else if (path === '/api/v1/books/7/playback') data = { playbackSeconds: 65 }
else if (path === '/api/v1/books/7') data = { book: { ...book, attachments }, chapters: [chapter] }
} else if (path === '/api/v1/chapters/3/playback') data = { playbackSeconds: 65 }
else if (path === '/api/v1/books/7') data = { book: { ...book, attachments }, chapters: [chapterWithFiles] }
else if (path === '/api/v1/chapters/3/tokens') {
const tokens: unknown[] = []
let offset = 0
@@ -58,7 +64,7 @@ export async function mockLearnerApi(page: Page): Promise<void> {
// The reader follows the tokens only while they describe the chapter's content version.
data = { textSha256: 'e'.repeat(64), tokens }
} else if (path === '/api/v1/chapters/3') {
data = { book: { ...book, attachments }, chapter: { ...chapter, contentSha256: 'e'.repeat(64), originalText: chapterText }, navigation: { previousChapterId: null, nextChapterId: null } }
data = { book: { ...book, attachments }, chapter: { ...chapterWithFiles, contentSha256: 'e'.repeat(64), originalText: chapterText }, navigation: { previousChapterId: null, nextChapterId: null } }
} else if (path === '/api/v1/terms/lookup') {
data = { query: 'curiosity', terms: [{ term: 'curiosity', entries: [{ partOfSpeech: 'noun', definition: '好奇心' }] }], candidates: [] }
} else if (path === '/api/v1/terms' && route.request().method() === 'GET') {
+42
View File
@@ -0,0 +1,42 @@
import { expect, type Locator, type Page } from '@playwright/test'
/**
* Asserts that a dialog is a modal overlay rather than a block in the document flow.
*
* A missing Element Plus stylesheet is invisible to `toBeVisible()` and to the unit tests, but it
* turns every dialog into content appended after the list it was opened from — which is exactly how
* a reader reported it. These checks need a real browser with the real stylesheets.
*/
export async function expectModalOverlay(page: Page, dialog: Locator): Promise<void> {
await expect(dialog).toBeVisible()
const layout = await dialog.evaluate(element => {
const box = element.getBoundingClientRect()
const overlay = element.closest('.el-overlay')
return {
overlay: overlay ? getComputedStyle(overlay).position : 'none',
position: getComputedStyle(element).position,
top: Math.round(box.top),
left: Math.round(box.left),
width: Math.round(box.width),
viewportHeight: window.innerHeight,
viewportWidth: window.innerWidth,
}
})
expect(layout.overlay, 'the dialog must render inside a fixed overlay').toBe('fixed')
expect(layout.position, 'the dialog itself is positioned by the overlay').not.toBe('static')
// It is laid out over the viewport, not below the page content that opened it.
expect(layout.top, 'the dialog must start inside the viewport').toBeGreaterThanOrEqual(0)
expect(layout.top, 'the dialog must not be pushed below the viewport').toBeLessThan(layout.viewportHeight)
expect(layout.viewportWidth - (layout.left + layout.width), 'the dialog must fit horizontally').toBeGreaterThanOrEqual(-2)
}
/** Asserts that a message box is a modal overlay as well. */
export async function expectMessageBoxOverlay(page: Page): Promise<void> {
const box = page.locator('.el-message-box')
await expect(box).toBeVisible()
const overlay = await box.evaluate(element => {
const wrapper = element.closest('.el-overlay')
return wrapper ? getComputedStyle(wrapper).position : 'none'
})
expect(overlay, 'the confirmation must render inside a fixed overlay').toBe('fixed')
}
+150 -92
View File
@@ -12,24 +12,18 @@ 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 audio = { kind: 'audio' as const, mime: 'audio/mpeg', byteSize: 4096, version: 'audio-sha', updatedAt: '2026-09-15T00:00:00Z' }
const book = { id: 7, title: 'Fictional reader', language: 'en' }
const chapter = {
id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', status: 'ready', charCount: 31, errorReason: '', errorMessage: '',
jobId: 9, readAt: null, createdAt: '', updatedAt: '', contentSha256: 'sha', originalText: 'Curiosity opens the first door.\n',
const book = { id: 7, title: 'Fictional reader', author: '', language: 'en' }
const pngBytes = new Uint8Array([1, 2, 3, 4])
const baseChapter = {
id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', author: '', status: 'ready' as const, charCount: 31,
errorReason: '', errorMessage: '', jobId: 9, readAt: null, createdAt: '', updatedAt: '',
}
let wrapper: VueWrapper | undefined
let createdUrls: string[]
/** jsdom has no object URLs, so the two functions are added to the existing URL object. */
function stubObjectUrls(): void {
createdUrls = []
let counter = 0
;(URL as unknown as Record<string, unknown>).createObjectURL = vi.fn(() => {
const url = `blob:fictional-${++counter}`
createdUrls.push(url)
return url
})
;(URL as unknown as Record<string, unknown>).createObjectURL = vi.fn(() => `blob:fictional-${++counter}`)
;(URL as unknown as Record<string, unknown>).revokeObjectURL = vi.fn()
}
@@ -38,6 +32,12 @@ function putBackObjectUrls(): void {
delete (URL as unknown as Record<string, unknown>).revokeObjectURL
}
function stubMedia(): { play: MockInstance; pause: MockInstance } {
const play = vi.spyOn(HTMLMediaElement.prototype, 'play').mockImplementation(async () => undefined)
const pause = vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined)
return { play, pause }
}
/** Sets the files of a hidden input the way a file picker would, then fires the change event. */
async function pickFile(view: VueWrapper, testid: string, file: File): Promise<void> {
const input = view.get(`[data-testid="${testid}"]`).element as HTMLInputElement
@@ -46,40 +46,49 @@ async function pickFile(view: VueWrapper, testid: string, file: File): Promise<v
await flushPromises()
}
function stubMedia(): { play: MockInstance; pause: MockInstance } {
const play = vi.spyOn(HTMLMediaElement.prototype, 'play').mockImplementation(async () => undefined)
const pause = vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined)
return { play, pause }
interface Fixture {
chapter: typeof baseChapter & { illustrationVersion?: string; audioVersion?: string; playbackSeconds?: number }
requests: string[]
}
/** An API mock whose book and chapter carry attachments. */
function mockApi(options: { attachments?: { cover: unknown; audio: unknown; playbackSeconds: number } } = {}): MockInstance {
const attachments = options.attachments ?? { cover, audio, playbackSeconds: 90 }
return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
/** An API mock whose chapter carries an illustration, audio and a stored position. */
function mockApi(options: { illustration?: boolean; audio?: boolean; playbackSeconds?: number } = {}): Fixture & { fetchMock: MockInstance } {
const chapter = {
...baseChapter,
illustrationVersion: options.illustration === false ? '' : 'illustration-sha',
audioVersion: options.audio === false ? '' : 'audio-sha',
playbackSeconds: options.playbackSeconds ?? 65,
}
const requests: string[] = []
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const url = String(input)
const method = String(init?.method ?? 'GET')
requests.push(`${method} ${url}`)
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
if (url.endsWith('/me')) return ok(user)
if (url.endsWith('/space')) return ok({ ownerId: user.id, language: 'en' })
if (url.endsWith('/books') && method === 'GET') {
return ok({ items: [{ ...book, chapterCount: 1, pendingCount: 0, processingCount: 0, readyCount: 1, failedCount: 0, coverVersion: attachments.cover ? 'cover-sha' : '', hasAudio: Boolean(attachments.audio), createdAt: '', updatedAt: '' }] })
return ok({ items: [{ ...book, chapterCount: 1, pendingCount: 0, processingCount: 0, readyCount: 1, failedCount: 0, coverVersion: 'cover-sha', hasAudio: false, createdAt: '', updatedAt: '' }] })
}
if (url.includes('/books/7/cover') || url.includes('/books/7/audio')) {
// A body of bytes rather than a jsdom Blob: the Response constructor cannot stream that one.
if (method === 'GET') return new Response(new Uint8Array([1, 2, 3, 4]), { status: 200, headers: { 'Content-Type': 'application/octet-stream' } })
return ok({ attachment: url.includes('cover') ? cover : audio })
if (url.endsWith('/books/7/cover') || url.endsWith('/chapters/3/audio') || url.endsWith('/chapters/3/illustration')
|| url.endsWith('/chapters/3/playback')) {
if (method === 'GET') {
return new Response(pngBytes, { status: 200, headers: { 'Content-Type': 'application/octet-stream' } })
}
return ok({ attachment: { kind: url.includes('illustration') ? 'illustration' : 'audio', mime: 'application/octet-stream', byteSize: 2048, version: 'new-sha', updatedAt: '' }, deleted: true, playbackSeconds: 12 })
}
if (url.includes('/books/7/playback')) {
const body = typeof init?.body === 'string' ? JSON.parse(init.body) : { positionSeconds: 0 }
return ok({ playbackSeconds: Number(body.positionSeconds) })
if (url.endsWith('/books/7')) return ok({ book: { ...book, attachments: { cover } }, chapters: [chapter] })
if (url.endsWith('/chapters/3/source')) {
return ok({ source: { id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', text: 'Curiosity opens the first door.' + String.fromCharCode(10), status: 'ready', contentSha256: 'sha', charCount: 31 } })
}
if (url.includes('/books/7')) return ok({ book: { ...book, attachments }, chapters: [chapter] })
if (url.endsWith('/chapters/3') && method === 'PATCH') return ok({ chapter: baseChapter, job: null, versionChanged: false })
if (url.includes('/chapters/3/tokens')) return ok({ textSha256: 'sha', tokens: [] })
if (url.includes('/chapters/3')) return ok({ book: { ...book, attachments }, chapter, navigation: { previousChapterId: null, nextChapterId: null } })
if (url.includes('/terms')) return ok({ items: [], total: 0, page: 1, limit: 20 })
if (url.includes('/progress')) return ok({})
if (url.endsWith('/chapters/3')) {
return ok({ book: { ...book, attachments: { cover } }, chapter: { ...chapter, contentSha256: 'sha', originalText: 'Curiosity opens the first door.\n' }, navigation: { previousChapterId: null, nextChapterId: null } })
}
return ok({})
})
return { chapter, requests, fetchMock }
}
async function mountWith(component: unknown, path: string): Promise<{ view: VueWrapper; router: Router }> {
@@ -103,89 +112,119 @@ async function mountWith(component: unknown, path: string): Promise<{ view: VueW
return { view: wrapper, router }
}
describe('book covers in the library', () => {
describe('book cover in the library', () => {
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()); stubObjectUrls(); useSessionStore().user = { ...user } })
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); putBackObjectUrls(); wrapper?.unmount() })
it('shows the stored cover and falls back to a default block', async () => {
mockApi()
it('shows the stored cover and loads its bytes once', async () => {
const { fetchMock } = mockApi()
const { view } = await mountWith(LibraryView, '/')
// The cover is fetched after the list answers, so the test waits for the blob instead of
// assuming that two microtask flushes are enough.
await vi.waitFor(() => expect(useLibraryStore().coverUrls[7]).toBeTruthy())
await flushPromises()
const image = view.get('[data-testid="book-cover"]')
expect(image.attributes('src')).toMatch(/^blob:fictional-/)
// The cover repeats the title link, so it is decorative and hidden from the accessibility tree.
expect(image.attributes('alt')).toBe('')
expect(view.get('.book-cover').attributes('aria-hidden')).toBe('true')
expect(useLibraryStore().coverUrls[7]).toMatch(/^blob:fictional-/)
expect(view.get('[data-testid="book-cover"]').attributes('src')).toMatch(/^blob:fictional-/)
expect(fetchMock.mock.calls.filter(([url]) => String(url).includes('/books/7/cover'))).toHaveLength(1)
})
it('keeps the default cover when the book has none', async () => {
mockApi({ attachments: { cover: null, audio: null, playbackSeconds: 0 } })
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async input => {
const url = String(input)
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
if (url.endsWith('/me')) return ok(user)
if (url.endsWith('/space')) return ok({ ownerId: user.id, language: 'en' })
if (url.endsWith('/books')) return ok({ items: [{ ...book, chapterCount: 0, pendingCount: 0, processingCount: 0, readyCount: 0, failedCount: 0, createdAt: '', updatedAt: '' }] })
return ok({})
})
const { view } = await mountWith(LibraryView, '/')
await flushPromises()
expect(view.find('[data-testid="book-cover"]').exists()).toBe(false)
expect(view.get('.book-cover-default').text()).toBe('F')
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/cover'))).toBe(false)
})
})
describe('attachments on the book page', () => {
describe('chapter attachments on the book page', () => {
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()); stubObjectUrls(); useSessionStore().user = { ...user } })
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); putBackObjectUrls(); wrapper?.unmount() })
it('describes what is stored and where the audio stopped', async () => {
mockApi()
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')
expect(view.get('[data-testid="audio-state"]').text()).toContain('MP3')
expect(view.get('[data-testid="audio-state"]').text()).toContain('1:30')
expect(view.find('[data-testid="cover-remove"]').exists()).toBe(true)
expect(view.find('[data-testid="audio-remove"]').exists()).toBe(true)
// The cover block is one compact row now: preview and actions, no headings or hints.
await vi.waitFor(() => expect(useLibraryStore().coverUrls[7]).toBeTruthy())
await flushPromises()
expect(view.find('[data-testid="cover-preview"]').exists()).toBe(true)
expect(view.find('[data-testid="cover-input"]').exists()).toBe(true)
expect(view.text()).not.toContain('书籍封面')
expect(view.text()).not.toContain('像素不超过')
await flushPromises()
// The chapter list shows no illustration and does not fetch one.
expect(view.find('[data-testid="chapter-thumb-3"]').exists()).toBe(false)
expect(view.find('.chapter-thumb').exists()).toBe(false)
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/illustration'))).toBe(false)
expect(view.get('.chapter-meta').text()).toContain('有音频')
expect(view.get('.chapter-meta').text()).toContain('上次 1:05')
// The book-level audio row is gone: audio lives in the chapter editor now, and the row has
// exactly one entry point.
expect(view.text()).not.toContain('音频 · 3.4 MB')
expect(view.find('[data-testid="chapter-attachments-3"]').exists()).toBe(false)
expect(view.find('[data-testid="edit-chapter-3"]').exists()).toBe(true)
})
it('refuses an oversized or wrong-typed file before uploading', async () => {
const fetchMock = mockApi()
it('edits the title, the text and both files in one chapter editor', async () => {
const { fetchMock } = mockApi({ illustration: false, audio: false, playbackSeconds: 0 })
const { view } = await mountWith(BookView, '/books/7')
const big = new File([new Uint8Array(3 << 20)], 'big.png', { type: 'image/png' })
await pickFile(view, 'cover-input', big)
expect(view.get('.field-error').text()).toContain('2 MiB')
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/cover') && String(url).includes('books/7'))).toBe(true) // only the initial read
expect(fetchMock.mock.calls.filter(([, init]) => String(init?.method) === 'POST')).toHaveLength(0)
await view.get('[data-testid="edit-chapter-3"]').trigger('click')
await flushPromises()
// One dialog holds the title, the text and both files.
expect(view.find('[data-testid="chapter-dialog"]').exists()).toBe(true)
expect(view.find('#chapter-title').exists()).toBe(true)
// The author shares the label row layout and is optional.
expect(view.find('[data-testid="chapter-author"]').exists()).toBe(true)
expect(view.find('.field-row label[for="chapter-author"]').exists()).toBe(true)
expect(view.find('#chapter-text').exists()).toBe(true)
expect(view.get('[data-testid="attachment-timing"]').text()).toContain('立即上传')
// The file hints are one short line each, so the height goes to the text editor.
expect(view.get('.attachment-block').text()).toContain('≤2 MiB')
expect(view.text()).toContain('≤4096×4096')
expect(view.text()).not.toContain('在阅读页显示为缩略图')
expect(view.get('[data-testid="illustration-state"]').text()).toBe('未设置')
expect(view.get('[data-testid="chapter-audio-state"]').text()).toBe('未设置')
const wrong = new File([new Uint8Array(16)], 'song.wav', { type: 'audio/wav' })
await pickFile(view, 'audio-input', wrong)
expect(view.text()).toContain('音频只支持 MP3')
await pickFile(view, 'illustration-input', new File([pngBytes], 'chapter.png', { type: 'image/png' }))
expect(fetchMock.mock.calls.some(([url, init]) => String(url).endsWith('/chapters/3/illustration') && String(init?.method) === 'POST')).toBe(true)
await pickFile(view, 'chapter-audio-input', new File([new Uint8Array(64)], 'track.mp3', { type: 'audio/mpeg' }))
expect(fetchMock.mock.calls.some(([url, init]) => String(url).endsWith('/chapters/3/audio') && String(init?.method) === 'POST')).toBe(true)
expect(view.get('[data-testid="book-notice"]').text()).toContain('音频已更新')
// The text is still saved by its own button, inside the same dialog.
await view.get('#chapter-title').setValue('Fictional chapter renamed')
await view.get('[data-testid="save-chapter"]').trigger('click')
await flushPromises()
expect(fetchMock.mock.calls.some(([url, init]) => String(url).includes('/chapters/3') && String(init?.method) === 'PATCH')).toBe(true)
})
it('uploads a replacement and reports the new state', async () => {
const fetchMock = mockApi()
const { view } = await mountWith(BookView, '/books/7')
const file = new File([new Uint8Array(2048)], 'cover.png', { type: 'image/png' })
await pickFile(view, 'cover-input', file)
const posted = fetchMock.mock.calls.find(([, init]) => String(init?.method) === 'POST')
expect(String(posted?.[0])).toContain('/api/v1/books/7/cover')
expect(posted?.[1]?.body).toBeInstanceOf(FormData)
expect(view.get('[data-testid="book-notice"]').text()).toContain('封面已更新')
})
it('removes an attachment after confirmation', async () => {
const fetchMock = mockApi()
it('refuses a wrong type before uploading and removes after confirmation', async () => {
const { fetchMock } = mockApi()
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never)
const { view } = await mountWith(BookView, '/books/7')
await view.get('[data-testid="audio-remove"]').trigger('click')
await view.get('[data-testid="edit-chapter-3"]').trigger('click')
await flushPromises()
await pickFile(view, 'chapter-audio-input', new File([new Uint8Array(16)], 'song.wav', { type: 'audio/wav' }))
expect(view.get('.field-error').text()).toContain('音频只支持 MP3')
expect(fetchMock.mock.calls.filter(([, init]) => String(init?.method) === 'POST')).toHaveLength(0)
await view.get('[data-testid="illustration-remove"]').trigger('click')
await flushPromises()
const deleted = fetchMock.mock.calls.find(([, init]) => String(init?.method) === 'DELETE')
expect(String(deleted?.[0])).toContain('/api/v1/books/7/audio')
expect(view.get('[data-testid="book-notice"]').text()).toContain('音频已移除')
// The audio state falls back to "not set" once the file is gone.
expect(view.get('[data-testid="audio-state"]').text()).toContain('未设置')
expect(String(deleted?.[0])).toContain('/chapters/3/illustration')
expect(view.get('[data-testid="illustration-state"]').text()).toBe('未设置')
})
})
describe('audio player', () => {
describe('audio player component', () => {
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); putBackObjectUrls(); wrapper?.unmount() })
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); wrapper?.unmount() })
it('formats the clock, changes the rate and reports an error', async () => {
stubMedia()
@@ -217,33 +256,52 @@ describe('audio player', () => {
})
})
describe('player inside the reader', () => {
describe('chapter illustration and player in the reader', () => {
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()); stubObjectUrls(); stubMedia(); useSessionStore().user = { ...user } })
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); putBackObjectUrls(); wrapper?.unmount() })
it('appears for a book with audio and stays away otherwise', async () => {
it('shows a chapter thumbnail above the text and opens the original in a dialog', async () => {
mockApi()
const { view } = await mountWith(ReaderView, '/chapters/3')
expect(view.find('.reader-text').exists()).toBe(true)
await vi.waitFor(() => expect(useLibraryStore().audioUrl).not.toBe(''))
await flushPromises()
expect(view.find('[data-testid="audio-player"]').exists()).toBe(true)
wrapper?.unmount()
mockApi({ attachments: { cover, audio: null, playbackSeconds: 0 } })
const second = await mountWith(ReaderView, '/chapters/3')
expect(second.view.find('[data-testid="audio-player"]').exists()).toBe(false)
const thumb = view.get('[data-testid="chapter-illustration"]')
expect(thumb.element.tagName).toBe('BUTTON')
expect(thumb.attributes('aria-label')).toContain('插图大图')
expect(thumb.find('img').exists()).toBe(true)
// The thumbnail sits between the heading and the text, and the dialog starts closed.
const html = view.html()
expect(html.indexOf('chapter-illustration')).toBeLessThan(html.indexOf('reader-workspace'))
expect(document.querySelector('[data-testid="illustration-dialog"]')).toBeNull()
await thumb.trigger('click')
await flushPromises()
const dialog = document.querySelector('[data-testid="illustration-dialog"]')
expect(dialog).not.toBeNull()
expect(dialog?.querySelector('img')?.getAttribute('src')).toMatch(/^blob:fictional-/)
expect(view.find('[data-testid="audio-player"]').exists()).toBe(true)
expect(useLibraryStore().audioChapterId).toBe(3)
})
it('sends the position to the book instead of only keeping it locally', async () => {
const fetchMock = mockApi()
it('stays silent for a chapter without audio or illustration', async () => {
mockApi({ audio: false, illustration: false, playbackSeconds: 0 })
const { view } = await mountWith(ReaderView, '/chapters/3')
await flushPromises()
expect(view.find('[data-testid="audio-player"]').exists()).toBe(false)
expect(view.find('[data-testid="chapter-illustration"]').exists()).toBe(false)
})
it('sends the position to the chapter instead of only keeping it locally', async () => {
const { fetchMock } = mockApi()
const { view } = await mountWith(ReaderView, '/chapters/3')
const element = view.get('[data-testid="audio-element"]').element as HTMLAudioElement
element.currentTime = 77
await view.get('[data-testid="audio-element"]').trigger('pause')
await flushPromises()
const reported = fetchMock.mock.calls.find(([, init]) => String(init?.method) === 'PUT')
expect(String(reported?.[0])).toContain('/api/v1/books/7/playback')
expect(String(reported?.[0])).toContain('/api/v1/chapters/3/playback')
expect(JSON.parse(String(reported?.[1]?.body))).toEqual({ positionSeconds: 77 })
})
})
+18 -12
View File
@@ -8,13 +8,13 @@ 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', status: 'ready', charCount: 12,
id: 9, bookId: 1, ordinal: 1, title: 'First chapter', author: '', status: 'ready', charCount: 12,
errorReason: '', errorMessage: '', jobId: 5, ...timestamps, ...overrides,
})
const source: ChapterSource = { id: 9, bookId: 1, ordinal: 1, title: 'First chapter', text: 'Mira opened the workshop.\n', status: 'ready', contentSha256: 'sha-a', charCount: 24 }
const source: ChapterSource = { id: 9, bookId: 1, ordinal: 1, title: 'First chapter', author: '', text: 'Mira opened the workshop.\n', status: 'ready', contentSha256: 'sha-a', charCount: 24 }
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
const fail = (msg: string, status = 400) => new Response(JSON.stringify({ code: status, msg }), { status })
let wrapper: VueWrapper | undefined
@@ -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 () => {
@@ -202,7 +208,7 @@ describe('book editing view', () => {
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(JSON.parse(String((patch[1] as RequestInit).body))).toEqual({ title: 'Edited chapter', author: '', text: 'A new body.' + String.fromCharCode(10) })
expect(view.get('[data-testid="book-notice"]').text()).toContain('已保存为新版本,正在重新处理')
})
+2 -1
View File
@@ -13,7 +13,7 @@ import { useSessionStore } from '../stores/session'
// All accounts, books and texts in these tests are deliberately fictitious.
const user = { id: 7, username: 'fictional-reader', role: 'learner' as const }
const book = { id: 1, title: '虚构样例书', language: 'en' }
const book = { id: 1, title: '虚构样例书', author: '', language: 'en' }
const navigation = { previousChapterId: null, nextChapterId: null }
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }), { status: 200 })
@@ -24,6 +24,7 @@ const chapter = (overrides: Partial<ChapterDetail> = {}): ChapterDetail => ({
id: 55,
bookId: 1,
ordinal: 1,
author: '',
title: '第一篇',
status: 'pending',
charCount: 120,
+5 -5
View File
@@ -175,8 +175,8 @@ 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' },
chapter: { id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', status: 'ready', charCount: 31, errorReason: '', errorMessage: '', jobId: 9, readAt: null, createdAt: '', updatedAt: '', contentSha256: 'sha', originalText: 'Curiosity opens the first door.\n' },
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 },
})
}
@@ -200,7 +200,7 @@ describe('library completion action', () => {
it('reports a failure without pretending the chapter is read', async () => {
useSessionStore().user = { ...user }
const library = useLibraryStore()
library.chapter = { id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', status: 'ready', charCount: 31, errorReason: '', errorMessage: '', jobId: 9, readAt: null, createdAt: '', updatedAt: '', contentSha256: 'sha', originalText: 'Curiosity opens the first door.\n' }
library.chapter = { id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', author: '', status: 'ready', charCount: 31, errorReason: '', errorMessage: '', jobId: 9, readAt: null, createdAt: '', updatedAt: '', contentSha256: 'sha', originalText: 'Curiosity opens the first door.\n' }
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => new Response(JSON.stringify({ code: 409, msg: '只有处理完成的章节可以标记已读' }), { status: 409 }))
await expect(library.markChapterRead(3)).rejects.toThrow('只有处理完成的章节可以标记已读')
expect(library.chapter.readAt ?? null).toBeNull()
@@ -226,8 +226,8 @@ 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' },
chapter: { id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', status: 'ready', charCount: 31, errorReason: '', errorMessage: '', jobId: 9, readAt: null, createdAt: '', updatedAt: '', contentSha256: 'sha', originalText: 'Curiosity opens the first door.\n' },
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 },
})
}
+2
View File
@@ -8,6 +8,8 @@ import 'element-plus/es/components/radio/style/css'
import 'element-plus/es/components/radio-group/style/css'
import 'element-plus/es/components/select/style/css'
import 'element-plus/es/components/option/style/css'
import 'element-plus/es/components/dialog/style/css'
import 'element-plus/es/components/message-box/style/css'
import 'element-plus/es/components/dropdown/style/css'
import 'element-plus/es/components/dropdown-menu/style/css'
import 'element-plus/es/components/dropdown-item/style/css'
+180 -64
View File
@@ -16,16 +16,25 @@ export interface BookAttachment {
updatedAt: string
}
/** The book keeps its cover; audio and illustrations belong to a chapter. */
export interface BookAttachments {
cover: BookAttachment | null
audio: BookAttachment | null
/** Where this account stopped in this book, in seconds. */
playbackSeconds: number
}
export interface ChapterAttachment {
kind: 'audio' | 'illustration'
mime: string
byteSize: number
/** Content digest, used as the cache key so a replaced file is fetched again. */
version: string
updatedAt: string
}
export interface BookRef {
id: number
title: string
/** Optional free text for the book itself; chapters carry their own author. */
author: string
language: string
attachments?: BookAttachments | null
}
@@ -47,6 +56,8 @@ export interface ChapterSummary {
bookId: number
ordinal: number
title: string
/** Optional free text the learner can set while editing the chapter. */
author: string
status: ChapterStatus
charCount: number
errorReason: string
@@ -56,6 +67,11 @@ export interface ChapterSummary {
// Set only when this completion still matches the chapter's current content; a chapter
// edited into a new version reads as unread again.
readAt?: string | null
/** Content digests of this chapter's files; empty when that file is absent. */
illustrationVersion?: string
audioVersion?: string
/** Where this account stopped in this chapter, in seconds. */
playbackSeconds?: number
createdAt: string
updatedAt: string
}
@@ -86,6 +102,7 @@ export interface ChapterSource {
bookId: number
ordinal: number
title: string
author: string
text: string
status: ChapterStatus
contentSha256: string
@@ -214,7 +231,9 @@ export const useLibraryStore = defineStore('library', () => {
const submitError = ref('')
const retryingChapterId = ref<number | null>(null)
const completingChapterId = ref<number | null>(null)
const uploadingKind = ref<AttachmentKind | null>(null)
const uploadingKind = ref<string | null>(null)
/** Which book or chapter the current upload belongs to, so only that row shows progress. */
const uploadingId = ref<number | null>(null)
/** Reader text exists only for ready chapters and is never taken from a cache. */
const readerText = computed(() => (chapter.value?.status === 'ready' ? chapter.value.originalText ?? '' : ''))
@@ -333,6 +352,8 @@ export const useLibraryStore = defineStore('library', () => {
// The book is always present in this response; the guard also covers a stale server reply.
const cover = result.book?.attachments?.cover
if (cover) void loadCover(result.book.id, cover.version)
// Illustrations are loaded by the reader only: the chapter list does not show them, so the
// list does not fetch every image of the book.
schedulePolling()
} catch (reason) {
if (seq !== bookSeq || isStale(version, owner)) return
@@ -374,12 +395,16 @@ export const useLibraryStore = defineStore('library', () => {
chapter.value = result.chapter
navigation.value = result.navigation ?? emptyNavigation()
chapterError.value = ''
const audio = result.book?.attachments?.audio
// The chapter's own files: an illustration to show and, when present, the audio to play.
const illustration = result.chapter.illustrationVersion
if (illustration) void loadChapterIllustration(result.chapter.id, illustration)
const audio = result.chapter.audioVersion
if (audio) {
// A failure here is shown by the player itself; the chapter stays readable without audio.
void loadAudio(result.book.id, audio.version).catch(() => undefined)
void loadChapterAudio(result.chapter.id, audio).catch(() => undefined)
} else {
audioUrl.value = ''
audioChapterId.value = null
}
schedulePolling()
} catch (reason) {
@@ -497,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
}
@@ -516,7 +546,7 @@ export const useLibraryStore = defineStore('library', () => {
* 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> {
async function updateChapter(id: number, input: { title?: string; author?: string; text?: string }): Promise<ChapterEdit> {
const version = generation
const owner = ownerId()
if (input.title !== undefined) {
@@ -527,8 +557,9 @@ export const useLibraryStore = defineStore('library', () => {
const problem = textProblem(input.text)
if (problem) throw new Error(problem)
}
const body: { title?: string; text?: string } = {}
const body: { title?: string; author?: string; text?: string } = {}
if (input.title !== undefined) body.title = input.title.trim()
if (input.author !== undefined) body.author = input.author.trim()
if (input.text !== undefined) body.text = input.text
const result = await session.request<ChapterEdit>(`chapters/${id}`, 'PATCH', body)
if (isStale(version, owner)) return result
@@ -616,83 +647,139 @@ export const useLibraryStore = defineStore('library', () => {
}
/**
* Uploads or replaces one attachment of an owned book. The reply is the stored metadata, which is
* Uploads or replaces the cover of an owned book. The reply is the stored metadata, which is
* written back into the open book and into the library list so a cover appears without a reload.
*/
async function uploadAttachment(bookId: number, kind: AttachmentKind, file: File): Promise<BookAttachment> {
async function uploadCover(bookId: number, file: File): Promise<BookAttachment> {
return await sendAttachment(`books/${bookId}/cover`, bookId, 'cover', file) as BookAttachment
}
/** Removes the cover of an owned book. */
async function deleteCover(bookId: number): Promise<void> {
return dropAttachment(`books/${bookId}/cover`, bookId, 'cover')
}
/** Uploads or replaces one file of an owned chapter: its audio or its illustration. */
async function uploadChapterFile(chapterId: number, kind: ChapterAttachment['kind'], file: File): Promise<ChapterAttachment> {
const folder = kind === 'illustration' ? 'illustration' : 'audio'
return await sendAttachment(`chapters/${chapterId}/${folder}`, chapterId, kind, file) as ChapterAttachment
}
/** Removes one file of an owned chapter; removing the audio also clears the stored position. */
async function deleteChapterFile(chapterId: number, kind: ChapterAttachment['kind']): Promise<void> {
const folder = kind === 'illustration' ? 'illustration' : 'audio'
return dropAttachment(`chapters/${chapterId}/${folder}`, chapterId, kind)
}
/** Shared upload path for a book cover and for chapter files. */
async function sendAttachment(url: string, id: number, kind: string, file: File): Promise<unknown> {
const version = generation
const owner = ownerId()
uploadingKind.value = kind
uploadingId.value = id
try {
const form = new FormData()
form.append('file', file, file.name)
const result = await session.request<{ attachment: BookAttachment }>(`books/${bookId}/${kind}`, 'POST', form)
if (!isStale(version, owner)) applyAttachment(bookId, kind, result.attachment)
const result = await session.request<{ attachment: BookAttachment & ChapterAttachment }>(url, 'POST', form)
if (!isStale(version, owner)) applyAttachment(id, kind, result.attachment)
return result.attachment
} catch (reason) {
if (isStale(version, owner)) throw reason instanceof Error ? reason : new Error('上传失败,请稍后重试。')
throw reason instanceof Error ? reason : new Error('上传失败,请稍后重试。')
} finally {
if (!isStale(version, owner)) uploadingKind.value = null
if (!isStale(version, owner)) {
uploadingKind.value = null
uploadingId.value = null
}
}
}
/** Removes one attachment of an owned book; removing the audio also clears the position. */
async function deleteAttachment(bookId: number, kind: AttachmentKind): Promise<void> {
/** Shared delete path for a book cover and for chapter files. */
async function dropAttachment(url: string, id: number, kind: string): Promise<void> {
const version = generation
const owner = ownerId()
uploadingKind.value = kind
uploadingId.value = id
try {
await session.request<{ deleted: boolean }>(`books/${bookId}/${kind}`, 'DELETE')
if (!isStale(version, owner)) applyAttachment(bookId, kind, null)
await session.request<{ deleted: boolean }>(url, 'DELETE')
if (!isStale(version, owner)) applyAttachment(id, kind, null)
} catch (reason) {
if (isStale(version, owner)) throw reason instanceof Error ? reason : new Error('删除失败,请稍后重试。')
throw reason instanceof Error ? reason : new Error('删除失败,请稍后重试。')
} finally {
if (!isStale(version, owner)) uploadingKind.value = null
if (!isStale(version, owner)) {
uploadingKind.value = null
uploadingId.value = null
}
}
}
/** Writes the attachment change into every view that shows this book. */
function applyAttachment(bookId: number, kind: AttachmentKind, attachment: BookAttachment | null): void {
const current = book.value?.id === bookId
? book.value.attachments ?? { cover: null, audio: null, playbackSeconds: 0 }
: null
if (current) {
const next: BookAttachments = { ...current, [kind]: attachment }
// A new audio file has nothing to do with the old position.
if (kind === 'audio') next.playbackSeconds = 0
book.value = { ...book.value!, attachments: next }
}
const listed = books.value.find(item => item.id === bookId)
if (listed) {
if (kind === 'cover') listed.coverVersion = attachment?.version ?? ''
else listed.hasAudio = attachment !== null
}
/** Writes an attachment change into every view that shows that book or chapter. */
function applyAttachment(id: number, kind: string, attachment: BookAttachment | ChapterAttachment | null): void {
if (kind === 'cover') {
// The cached image belongs to the previous file, so it is dropped and loaded again.
forgetCover(bookId)
if (attachment) void loadCover(bookId, attachment.version)
const cover = attachment as BookAttachment | null
if (book.value?.id === id) book.value = { ...book.value, attachments: { cover } }
const listed = books.value.find(item => item.id === id)
if (listed) listed.coverVersion = cover?.version ?? ''
forgetCover(id)
if (cover) void loadCover(id, cover.version)
return
}
// A chapter file: the chapter list entry and the open reader both carry the version.
const file = attachment as ChapterAttachment | null
const field: 'illustrationVersion' | 'audioVersion' = kind === 'illustration' ? 'illustrationVersion' : 'audioVersion'
const listed = chapters.value.find(item => item.id === id)
if (listed) {
listed[field] = file?.version ?? ''
// The stored position belonged to the replaced file, so nothing is restored for the new one.
if (kind === 'audio') listed.playbackSeconds = 0
}
const open = chapter.value !== null && chapter.value.id === id ? chapter.value : null
if (open) {
open[field] = file?.version ?? ''
if (kind === 'audio') open.playbackSeconds = 0
}
if (kind === 'audio') {
loadedVersions.delete(`audio:${bookId}`)
loadedVersions.delete(`chapter-audio:${id}`)
audioUrl.value = ''
audioChapterId.value = null
if (file) void loadChapterAudio(id, file.version).catch(() => undefined)
return
}
forgetIllustration(id)
if (file) void loadChapterIllustration(id, file.version)
}
/** Remembers where this account stopped in one book, so reading can continue later. */
async function savePlaybackPosition(bookId: number, seconds: number): Promise<void> {
/**
* Reports the position of one chapter from the value already loaded. The reader calls this when it
* leaves a chapter, so the stored position matches what the person actually heard.
*/
function reportChapterPlayback(chapterId: number, seconds?: number): void {
const listed = chapters.value.find(item => item.id === chapterId)
const open = chapter.value !== null && chapter.value.id === chapterId ? chapter.value : null
const value = seconds ?? open?.playbackSeconds ?? listed?.playbackSeconds ?? 0
if (value <= 0) return
void saveChapterPlayback(chapterId, value).catch(() => undefined)
}
/** Remembers where this account stopped in one chapter, so reading can continue later. */
async function saveChapterPlayback(chapterId: number, seconds: number): Promise<void> {
const version = generation
const owner = ownerId()
const result = await session.request<{ playbackSeconds: number }>(`books/${bookId}/playback`, 'PUT', { positionSeconds: Math.max(0, Math.floor(seconds)) })
if (!isStale(version, owner) && book.value?.id === bookId && book.value.attachments) {
book.value = { ...book.value, attachments: { ...book.value.attachments, playbackSeconds: result.playbackSeconds } }
}
const result = await session.request<{ playbackSeconds: number }>(
`chapters/${chapterId}/playback`, 'PUT', { positionSeconds: Math.max(0, Math.floor(seconds)) })
if (isStale(version, owner)) return
const listed = chapters.value.find(item => item.id === chapterId)
if (listed) listed.playbackSeconds = result.playbackSeconds
if (chapter.value !== null && chapter.value.id === chapterId) chapter.value.playbackSeconds = result.playbackSeconds
}
/** Object URLs handed to <img>/<audio>; the token never appears in a URL the browser sees. */
const coverUrls = ref<Record<number, string>>({})
const illustrationUrls = ref<Record<number, string>>({})
const audioUrl = ref('')
/** Which chapter the loaded audio belongs to, so a stale player is never shown. */
const audioChapterId = ref<number | null>(null)
const liveBlobUrls = new Set<string>()
const loadedVersions = new Map<string, string>()
@@ -707,7 +794,9 @@ export const useLibraryStore = defineStore('library', () => {
liveBlobUrls.clear()
loadedVersions.clear()
coverUrls.value = {}
illustrationUrls.value = {}
audioUrl.value = ''
audioChapterId.value = null
}
/** Loads a cover for the library list, once per version. */
@@ -723,14 +812,28 @@ export const useLibraryStore = defineStore('library', () => {
}
}
/** Loads the audio of the open book, once per version, for the player in the reader. */
async function loadAudio(bookId: number, version: string): Promise<void> {
if (!version || loadedVersions.get(`audio:${bookId}`) === version) return
/** Loads the illustration of a chapter, once per version, for the chapter row and the reader. */
async function loadChapterIllustration(chapterId: number, version: string): Promise<void> {
if (!version || loadedVersions.get(`illustration:${chapterId}`) === version) return
try {
const blob = await session.requestBlob(`books/${bookId}/audio`)
const blob = await session.requestBlob(`chapters/${chapterId}/illustration`)
if (!session.user) return
loadedVersions.set(`audio:${bookId}`, version)
loadedVersions.set(`illustration:${chapterId}`, version)
illustrationUrls.value = { ...illustrationUrls.value, [chapterId]: rememberBlob(blob) }
} catch {
// An illustration that cannot be read simply keeps the default styling.
}
}
/** Loads the audio of one chapter, once per version, for the player in the reader. */
async function loadChapterAudio(chapterId: number, version: string): Promise<void> {
if (!version || loadedVersions.get(`chapter-audio:${chapterId}`) === version) return
try {
const blob = await session.requestBlob(`chapters/${chapterId}/audio`)
if (!session.user) return
loadedVersions.set(`chapter-audio:${chapterId}`, version)
audioUrl.value = rememberBlob(blob)
audioChapterId.value = chapterId
} catch (reason) {
throw reason instanceof Error ? reason : new Error('音频暂时无法加载。')
}
@@ -740,13 +843,24 @@ export const useLibraryStore = defineStore('library', () => {
function forgetCover(bookId: number): void {
loadedVersions.delete(`cover:${bookId}`)
const url = coverUrls.value[bookId]
if (url) {
URL.revokeObjectURL(url)
liveBlobUrls.delete(url)
const next = { ...coverUrls.value }
delete next[bookId]
coverUrls.value = next
}
if (!url) return
URL.revokeObjectURL(url)
liveBlobUrls.delete(url)
const next = { ...coverUrls.value }
delete next[bookId]
coverUrls.value = next
}
/** Drops one cached illustration so a replaced file is fetched again. */
function forgetIllustration(chapterId: number): void {
loadedVersions.delete(`illustration:${chapterId}`)
const url = illustrationUrls.value[chapterId]
if (!url) return
URL.revokeObjectURL(url)
liveBlobUrls.delete(url)
const next = { ...illustrationUrls.value }
delete next[chapterId]
illustrationUrls.value = next
}
/**
@@ -821,6 +935,7 @@ export const useLibraryStore = defineStore('library', () => {
retryingChapterId.value = null
completingChapterId.value = null
uploadingKind.value = null
uploadingId.value = null
releaseBlobs()
submissionKey = ''
submissionRequestId = ''
@@ -832,9 +947,10 @@ export const useLibraryStore = defineStore('library', () => {
chapter, chapterBook, navigation, chapterLoading, chapterError,
submitting, submitError, retryingChapterId, completingChapterId, readerText,
loadBooks, loadBook, loadChapter, submit, upload, retryChapter, markChapterRead,
uploadAttachment, deleteAttachment, savePlaybackPosition, uploadingKind,
coverUrls, audioUrl, loadCover, loadAudio,
renameBook, updateChapter, loadChapterSource, deleteBook, deleteChapter,
uploadCover, deleteCover, uploadChapterFile, deleteChapterFile, saveChapterPlayback, reportChapterPlayback,
uploadingKind, uploadingId,
coverUrls, illustrationUrls, audioUrl, audioChapterId, loadCover, loadChapterIllustration, loadChapterAudio,
updateBook, updateChapter, loadChapterSource, deleteBook, deleteChapter,
stopPolling, closeBook, closeChapter, reset,
}
})
+32
View File
@@ -228,6 +228,7 @@ a.chapter-name:hover { color: var(--accent); text-decoration: underline; }
.chapter-meta { margin: 4px 0 0; color: var(--text-muted); font-size: 13px; overflow-wrap: anywhere; }
/* Reader keeps the pasted text exactly as it was, including line breaks and tabs. */
.reader-page { max-width: 820px; }
.chapter-author { margin: -6px 0 0; color: var(--text-muted); font-size: 14px; }
.reader-text { white-space: pre-wrap; overflow-wrap: break-word; margin: 26px 0 0; font-family: Georgia, 'Times New Roman', 'Microsoft YaHei', serif; font-size: calc(17px * var(--reader-font-scale)); line-height: 2; }
.processing-hint { padding: 36px 0; color: var(--text-muted); }
.reader-nav { display: flex; align-items: center; justify-content: space-between; gap: 14px; flex-wrap: wrap; margin-top: 36px; padding-top: 22px; border-top: 1px solid var(--border); }
@@ -346,6 +347,25 @@ button:focus-visible, a:focus-visible, [role='button']:focus-visible,
/* Discoverable keyboard operations on the review page. */
.shortcut-hint { margin: 22px 0 0; color: var(--text-muted); font-size: 13px; }
/* Chapter illustration: a small thumbnail that opens the original in a dialog. */
.illustration-thumb { display: inline-flex; flex-direction: column; align-items: flex-start; gap: 6px; margin: 0 0 18px; padding: 8px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); cursor: zoom-in; }
.illustration-thumb img { display: block; height: 120px; width: auto; max-width: 100%; border-radius: 8px; object-fit: cover; }
.illustration-thumb:hover { border-color: var(--accent); }
.illustration-thumb:focus-visible { outline: 3px solid var(--accent-marker); outline-offset: 3px; }
.illustration-hint { font-size: 12px; color: var(--text-muted); }
.illustration-full { display: block; max-width: min(88vw, 1200px); max-height: 78vh; width: auto; height: auto; object-fit: contain; }
/* The book page keeps the cover control to one compact row so the chapter list gets the space. */
.cover-row { display: flex; align-items: center; gap: 14px; margin: 0 0 18px; }
.cover-preview { flex: 0 0 120px; aspect-ratio: 16 / 9; border-radius: 10px; overflow: hidden; background: var(--accent-soft); }
.cover-preview img { width: 100%; height: 100%; object-fit: cover; display: block; }
.cover-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.cover-actions input[type='file'] { position: absolute; width: 1px; height: 1px; opacity: 0; overflow: hidden; }
.cover-actions label { position: relative; cursor: pointer; border: 1px solid var(--border-strong); border-radius: 20px; padding: 0 14px; background: var(--surface-raised); }
.cover-actions label.is-busy { opacity: 0.6; cursor: progress; }
/* The book title shows its author next to it. */
.title-line { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
.title-line h1 { margin: 14px 0 6px; }
.book-author { color: var(--text-muted); font-size: 15px; }
/* Book covers: the card shows the stored image or a default block built from the title. */
.book-cover { display: block; aspect-ratio: 16 / 9; border-radius: 10px; overflow: hidden; background: var(--accent-soft); text-decoration: none; }
.book-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
@@ -360,6 +380,18 @@ button:focus-visible, a:focus-visible, [role='button']:focus-visible,
.attachment-info { min-width: 0; flex: 1; }
.attachment-label { margin: 0 0 4px; font-weight: 600; }
.attachment-info .subtle { margin: 4px 0 0; }
/* A label and its input share one line, so the dialog spends its height on the text being edited. */
.field-row { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
.field-row label { flex: 0 0 76px; margin: 0; font-size: 14px; }
.field-row .el-input { flex: 1; }
/* One compact line per attachment: label, state, actions, then the size rule underneath. */
.attachment-block { display: flex; gap: 14px; align-items: flex-start; margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border); }
.attachment-block .attachment-preview { flex: 0 0 96px; aspect-ratio: 16 / 9; border-radius: 8px; overflow: hidden; background: var(--accent-soft); }
.attachment-block .attachment-info { min-width: 0; flex: 1; }
.attachment-block .attachment-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 0; }
.attachment-block .attachment-label { font-weight: 600; }
.attachment-block .subtle { margin: 2px 0 0; }
.attachment-heading { margin: 22px 0 4px; font-size: 15px; font-weight: 600; color: var(--text-strong); }
.attachment-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 10px 0 0; }
.attachment-actions input[type='file'] { position: absolute; width: 1px; height: 1px; opacity: 0; overflow: hidden; }
.attachment-actions label { position: relative; cursor: pointer; border: 1px solid var(--border-strong); border-radius: 20px; padding: 0 14px; background: var(--surface-raised); }
+256 -128
View File
@@ -2,7 +2,7 @@
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { ElButton, ElDialog, ElInput, ElMessageBox } from 'element-plus'
import { canRetry, fileSizeLabel, statusLabel, TEXT_MAX_CODE_POINTS, textProblem, titleProblem, useLibraryStore, type ChapterSource } from '../stores/library'
import { canRetry, fileSizeLabel, statusLabel, TEXT_MAX_CODE_POINTS, textProblem, titleProblem, useLibraryStore, type ChapterSource, type ChapterSummary } from '../stores/library'
import { useSessionStore } from '../stores/session'
import DisplaySettings from '../components/DisplaySettings.vue'
@@ -12,94 +12,17 @@ const route = useRoute()
const router = useRouter()
const retryError = ref('')
const notice = ref('')
const attachmentError = ref<{ cover: string; audio: string }>({ cover: '', audio: '' })
const ATTACHMENT_RULES = {
cover: { types: ['image/jpeg', 'image/png', 'image/webp'], maxBytes: 2 << 20, label: '封面只支持 JPG、PNG 或 WebP 图片,且不超过 2 MiB。' },
audio: { types: ['audio/mpeg'], maxBytes: 20 << 20, label: '音频只支持 MP3,且不超过 20 MiB。' },
} as const
const coverState = computed(() => {
const cover = library.book?.attachments?.cover
if (!cover) return '未设置(显示默认封面)'
return `${cover.mime === 'image/png' ? 'PNG' : cover.mime === 'image/webp' ? 'WebP' : 'JPG'} · ${fileSizeLabel(cover.byteSize)}`
})
const audioState = computed(() => {
const audio = library.book?.attachments?.audio
if (!audio) return '未设置(阅读时可正常使用)'
const position = library.book?.attachments?.playbackSeconds ?? 0
const detail = `${fileSizeLabel(audio.byteSize)}${position > 0 ? ` · 上次播放位置 ${Math.floor(position / 60)}:${String(position % 60).padStart(2, '0')}` : ''}`
return `MP3 · ${detail}`
})
/** The browser reports a type, but the server checks the bytes: this only avoids an obvious mistake. */
function reject(kind: 'cover' | 'audio', file: File): string {
const rules = ATTACHMENT_RULES[kind]
if (file.size === 0) return '文件为空,请重新选择。'
if (file.size > rules.maxBytes) return rules.label
if (file.type && !(rules.types as readonly string[]).includes(file.type)) return rules.label
return ''
}
async function upload(kind: 'cover' | 'audio', file: File): Promise<void> {
if (library.book === null) return
attachmentError.value = { ...attachmentError.value, [kind]: '' }
const problem = reject(kind, file)
if (problem) {
attachmentError.value = { ...attachmentError.value, [kind]: problem }
return
}
try {
await library.uploadAttachment(library.book.id, kind, file)
notice.value = kind === 'cover' ? '封面已更新。' : '音频已更新,播放位置已重置。'
} catch (reason) {
attachmentError.value = { ...attachmentError.value, [kind]: reason instanceof Error ? reason.message : '上传失败,请稍后重试。' }
}
}
function onCoverPicked(event: Event): void {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (file) void upload('cover', file)
}
function onAudioPicked(event: Event): void {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (file) void upload('audio', file)
}
async function removeAttachment(kind: 'cover' | 'audio'): Promise<void> {
if (library.book === null) return
const question = kind === 'cover' ? '移除这本书的封面?' : '移除这本书的音频?播放位置会一起清除。'
try {
await ElMessageBox.confirm(question, kind === 'cover' ? '移除封面' : '移除音频', {
confirmButtonText: '确认移除', cancelButtonText: '取消', type: 'warning',
})
} catch { return }
attachmentError.value = { ...attachmentError.value, [kind]: '' }
try {
await library.deleteAttachment(library.book.id, kind)
notice.value = kind === 'cover' ? '封面已移除。' : '音频已移除。'
} catch (reason) {
attachmentError.value = { ...attachmentError.value, [kind]: reason instanceof Error ? reason.message : '移除失败,请稍后重试。' }
}
}
function removeCover(): void { void removeAttachment('cover') }
function removeAudio(): void { void removeAttachment('audio') }
// Renaming the book.
const bookDialog = ref(false)
const bookTitle = ref('')
const bookAuthor = ref('')
const bookError = ref('')
// Editing one chapter: title and text, loaded from the source endpoint.
const chapterDialog = ref(false)
const chapterSource = ref<ChapterSource | null>(null)
const chapterTitle = ref('')
const chapterAuthor = ref('')
const chapterText = ref('')
const chapterError = ref('')
const chapterLoading = ref(false)
@@ -107,6 +30,177 @@ const saving = ref(false)
const bookId = computed(() => Number(route.params.id))
const chapterLength = computed(() => [...chapterText.value].length)
const coverError = ref('')
// Errors for the chapter's files, shown inside the chapter editor next to the file they belong to.
const illustrationError = ref('')
const audioError = ref('')
const COVER_RULES = { types: ['image/jpeg', 'image/png', 'image/webp'], maxBytes: 2 << 20, label: '封面只支持 JPG、PNG 或 WebP 图片,且不超过 2 MiB。' }
const ILLUSTRATION_RULES = { types: ['image/jpeg', 'image/png', 'image/webp'], maxBytes: 2 << 20, label: '插图只支持 JPG、PNG 或 WebP 图片,且不超过 2 MiB。' }
const AUDIO_RULES = { types: ['audio/mpeg'], maxBytes: 20 << 20, label: '音频只支持 MP3,且不超过 20 MiB。' }
const coverState = computed(() => {
const cover = library.book?.attachments?.cover
if (!cover) return '未设置(显示默认封面)'
return `${imageLabel(cover.mime)} · ${fileSizeLabel(cover.byteSize)}`
})
function imageLabel(mime: string): string {
if (mime === 'image/png') return 'PNG'
if (mime === 'image/webp') return 'WebP'
return 'JPG'
}
function clock(seconds: number): string {
const total = Math.max(0, Math.floor(seconds))
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`
}
/**
* The meta line of one chapter row: audio and its position only. The illustration is deliberately
* not shown here (it is read inside the chapter), so the row stays compact and the list does not
* fetch every image.
*/
function chapterMeta(item: ChapterSummary): string {
const parts: string[] = []
if (item.audioVersion) {
parts.push('有音频')
if (item.playbackSeconds) parts.push(`上次 ${clock(item.playbackSeconds)}`)
}
return parts.join(' · ')
}
function attachmentSummary(item: ChapterSummary | null): string {
if (!item) return ''
const files: string[] = []
if (item.illustrationVersion) files.push(`插图 ${fileSizeLabel(0)}` .trim())
return files.join(' · ')
}
/** The state line of one attachment inside the dialog. */
function fileState(item: ChapterSummary | null, kind: 'illustration' | 'audio'): string {
if (!item) return '未设置'
const version = kind === 'illustration' ? item.illustrationVersion : item.audioVersion
if (!version) return '未设置'
if (kind === 'illustration') return '已设置'
const position = item.playbackSeconds ?? 0
return position > 0 ? `已设置 · 上次播放位置 ${clock(position)}` : '已设置'
}
/** The browser reports a type, but the server checks the bytes: this only avoids an obvious mistake. */
function reject(rules: { types: readonly string[]; maxBytes: number; label: string }, file: File): string {
if (file.size === 0) return '文件为空,请重新选择。'
if (file.size > rules.maxBytes) return rules.label
if (file.type && !rules.types.includes(file.type)) return rules.label
return ''
}
async function uploadCoverFile(file: File): Promise<void> {
if (library.book === null) return
coverError.value = ''
const problem = reject(COVER_RULES, file)
if (problem) {
coverError.value = problem
return
}
try {
await library.uploadCover(library.book.id, file)
notice.value = '封面已更新。'
} catch (reason) {
coverError.value = reason instanceof Error ? reason.message : '上传失败,请稍后重试。'
}
}
function onCoverPicked(event: Event): void {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (file) void uploadCoverFile(file)
}
async function removeCover(): Promise<void> {
if (library.book === null) return
try {
await ElMessageBox.confirm('移除这本书的封面?', '移除封面', {
confirmButtonText: '确认移除', cancelButtonText: '取消', type: 'warning',
})
} catch { return }
coverError.value = ''
try {
await library.deleteCover(library.book.id)
notice.value = '封面已移除。'
} catch (reason) {
coverError.value = reason instanceof Error ? reason.message : '移除失败,请稍后重试。'
}
}
/**
* The chapter being edited, read from the list so an upload is reflected immediately. The editor
* holds the title and the text, and the same dialog also manages the chapter's illustration and
* audio: one place to edit a chapter, with one entry point per row.
*/
const attachmentLive = computed(() => {
const id = chapterSource.value?.id
if (id === undefined) return null
return library.chapters.find(item => item.id === id) ?? null
})
async function uploadChapterFile(kind: 'illustration' | 'audio', file: File): Promise<void> {
const chapter = attachmentLive.value
if (!chapter) return
const rules = kind === 'illustration' ? ILLUSTRATION_RULES : AUDIO_RULES
if (kind === 'illustration') illustrationError.value = ''
else audioError.value = ''
const problem = reject(rules, file)
if (problem) {
if (kind === 'illustration') illustrationError.value = problem
else audioError.value = problem
return
}
try {
await library.uploadChapterFile(chapter.id, kind, file)
notice.value = kind === 'illustration' ? '插图已更新。' : '音频已更新,播放位置已重置。'
} catch (reason) {
const message = reason instanceof Error ? reason.message : '上传失败,请稍后重试。'
if (kind === 'illustration') illustrationError.value = message
else audioError.value = message
}
}
function onIllustrationPicked(event: Event): void {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (file) void uploadChapterFile('illustration', file)
}
function onChapterAudioPicked(event: Event): void {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (file) void uploadChapterFile('audio', file)
}
async function removeChapterFile(kind: 'illustration' | 'audio'): Promise<void> {
const chapter = attachmentLive.value
if (!chapter) return
const question = kind === 'illustration' ? '移除这一章的插图?' : '移除这一章的音频?播放位置会一起清除。'
try {
await ElMessageBox.confirm(question, kind === 'illustration' ? '移除插图' : '移除音频', {
confirmButtonText: '确认移除', cancelButtonText: '取消', type: 'warning',
})
} catch { return }
if (kind === 'illustration') illustrationError.value = ''
else audioError.value = ''
try {
await library.deleteChapterFile(chapter.id, kind)
notice.value = kind === 'illustration' ? '插图已移除。' : '音频已移除。'
} catch (reason) {
const message = reason instanceof Error ? reason.message : '移除失败,请稍后重试。'
if (kind === 'illustration') illustrationError.value = message
else audioError.value = message
}
}
async function load() {
retryError.value = ''
@@ -121,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
}
@@ -131,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 {
@@ -145,11 +240,14 @@ async function openChapterDialog(chapterId: number) {
chapterDialog.value = true
chapterLoading.value = true
chapterError.value = ''
illustrationError.value = ''
audioError.value = ''
chapterSource.value = null
try {
const source = await library.loadChapterSource(chapterId)
chapterSource.value = source
chapterTitle.value = source.title
chapterAuthor.value = source.author
chapterText.value = source.text
} catch (reason) {
chapterError.value = reason instanceof Error ? reason.message : '章节内容暂时无法加载,请稍后重试。'
@@ -165,7 +263,9 @@ async function saveChapter() {
if (chapterError.value) return
saving.value = true
try {
const edited = await library.updateChapter(source.id, { title: chapterTitle.value, text: chapterText.value })
const edited = await library.updateChapter(source.id, {
title: chapterTitle.value, author: chapterAuthor.value, text: chapterText.value,
})
chapterDialog.value = false
notice.value = edited.versionChanged ? '章节已保存为新版本,正在重新处理。' : '章节已保存。'
} catch (reason) {
@@ -241,51 +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="attachmentError.cover" role="alert" class="field-error">{{ attachmentError.cover }}</p>
<div class="attachment-actions">
<label class="link-button" :class="{ 'is-busy': library.uploadingKind === 'cover' }">
{{ library.coverUrls[library.book.id] ? '替换封面' : '上传封面' }}
<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.coverUrls[library.book.id] || 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>
</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="attachment-row">
<div class="attachment-info">
<p class="attachment-label">音频</p>
<p class="subtle" data-testid="audio-state">{{ audioState }}</p>
<p v-if="attachmentError.audio" role="alert" class="field-error">{{ attachmentError.audio }}</p>
<div class="attachment-actions">
<label class="link-button" :class="{ 'is-busy': library.uploadingKind === 'audio' }">
{{ library.book.attachments?.audio ? '替换音频' : '上传音频' }}
<input type="file" accept="audio/mpeg,.mp3" data-testid="audio-input" :disabled="library.uploadingKind !== null" @change="onAudioPicked" />
</label>
<ElButton v-if="library.book.attachments?.audio" size="small" :disabled="library.uploadingKind !== null" data-testid="audio-remove" @click="removeAudio">移除</ElButton>
</div>
<p class="subtle">MP3,最大 20 MiB;播放不与文本同步。替换或移除后播放位置会重置。</p>
</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>
@@ -297,6 +377,7 @@ onUnmounted(() => library.closeBook())
<span v-else class="chapter-name">{{ item.title }}</span>
<p class="chapter-meta">
{{ item.charCount }} 字符
<template v-if="chapterMeta(item)"> · {{ chapterMeta(item) }}</template>
<template v-if="item.status === 'failed' && item.errorMessage"> · {{ item.errorMessage }}</template>
</p>
</div>
@@ -317,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>
@@ -331,12 +417,54 @@ onUnmounted(() => library.closeBook())
<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" />
<div class="field-row">
<label for="chapter-title">章节标题</label>
<ElInput id="chapter-title" v-model="chapterTitle" type="text" maxlength="200" :disabled="saving" />
</div>
<div class="field-row">
<label for="chapter-author">作者</label>
<ElInput id="chapter-author" v-model="chapterAuthor" type="text" maxlength="120" placeholder="可留空" :disabled="saving" data-testid="chapter-author" />
</div>
<label for="chapter-text">正文</label>
<ElInput id="chapter-text" v-model="chapterText" type="textarea" :rows="12" :disabled="saving" />
<p class="counter">{{ chapterLength }} / {{ TEXT_MAX_CODE_POINTS }} 字符</p>
<p class="subtle">保存后会重新处理这一章;只改标题不会重新处理。</p>
<ElInput id="chapter-text" v-model="chapterText" type="textarea" :rows="18" :disabled="saving" />
<p class="counter">{{ chapterLength }} / {{ TEXT_MAX_CODE_POINTS }} 字符 · 保存后重新处理这一章</p>
<div class="attachment-block">
<div class="attachment-preview">
<img v-if="attachmentLive && library.illustrationUrls[attachmentLive.id]" :src="library.illustrationUrls[attachmentLive.id]" alt="当前插图" data-testid="illustration-preview" />
<span v-else class="book-cover-default" aria-hidden="true">图</span>
</div>
<div class="attachment-info">
<div class="attachment-actions">
<span class="attachment-label">插图</span>
<span class="subtle" data-testid="illustration-state">{{ attachmentLive ? fileState(attachmentLive, 'illustration') : '未设置' }}</span>
<label class="link-button" :class="{ 'is-busy': library.uploadingKind === 'illustration' }">
{{ attachmentLive?.illustrationVersion ? '替换' : '上传' }}
<input type="file" accept="image/jpeg,image/png,image/webp" data-testid="illustration-input" :disabled="library.uploadingKind !== null" @change="onIllustrationPicked" />
</label>
<ElButton v-if="attachmentLive?.illustrationVersion" size="small" text :disabled="library.uploadingKind !== null" data-testid="illustration-remove" @click="removeChapterFile('illustration')">移除</ElButton>
</div>
<p v-if="illustrationError" role="alert" class="field-error">{{ illustrationError }}</p>
<p class="subtle">JPG/PNG/WebP · ≤2 MiB · ≤4096×4096</p>
</div>
</div>
<div class="attachment-block">
<div class="attachment-info">
<div class="attachment-actions">
<span class="attachment-label">音频</span>
<span class="subtle" data-testid="chapter-audio-state">{{ attachmentLive ? fileState(attachmentLive, 'audio') : '未设置' }}</span>
<label class="link-button" :class="{ 'is-busy': library.uploadingKind === 'audio' }">
{{ attachmentLive?.audioVersion ? '替换' : '上传' }}
<input type="file" accept="audio/mpeg,.mp3" data-testid="chapter-audio-input" :disabled="library.uploadingKind !== null" @change="onChapterAudioPicked" />
</label>
<ElButton v-if="attachmentLive?.audioVersion" size="small" text :disabled="library.uploadingKind !== null" data-testid="chapter-audio-remove" @click="removeChapterFile('audio')">移除</ElButton>
</div>
<p v-if="audioError" role="alert" class="field-error">{{ audioError }}</p>
<p class="subtle">MP3 · ≤20 MiB · 替换或移除会重置位置</p>
</div>
</div>
<p class="subtle" data-testid="attachment-timing">标题、作者与正文点保存后生效;插图与音频选中文件后立即上传。</p>
</template>
<p v-if="chapterError" role="alert" class="field-error">{{ chapterError }}</p>
<template #footer>
+33 -7
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { ElButton } from 'element-plus'
import { ElButton, ElDialog } from 'element-plus'
import { canRetry, statusLabel, useLibraryStore } from '../stores/library'
import { useSessionStore } from '../stores/session'
import { useReaderLookup, type PhraseSpan, type ReaderToken } from '../composables/useReaderLookup'
@@ -86,23 +86,34 @@ function closePanel(): void {
selection.clear()
}
let lastChapterId: number | null = null
async function load() {
lookup.reset()
retryError.value = ''
rangeNotice.value = ''
// Switching chapters reports the position of the chapter that is being left.
if (lastChapterId !== null && lastChapterId !== chapterId.value) library.reportChapterPlayback(lastChapterId)
lastChapterId = chapterId.value
await library.loadChapter(chapterId.value)
restorePosition()
}
// The player only exists for a book that has audio, and it reports the position for this account.
const audioSource = computed(() => library.audioUrl)
const audioStart = computed(() => library.chapterBook?.attachments?.playbackSeconds ?? library.book?.attachments?.playbackSeconds ?? 0)
// The player belongs to the chapter that is open: the loaded audio is only shown when it belongs to
// this chapter, so switching chapters never reuses the previous file.
const audioSource = computed(() => (library.audioChapterId === chapter.value?.id ? library.audioUrl : ''))
const audioStart = computed(() => chapter.value?.playbackSeconds ?? 0)
const illustrationSource = computed(() => (chapter.value ? library.illustrationUrls[chapter.value.id] ?? '' : ''))
// The chapter shows a thumbnail; the original opens in a dialog so a large image never takes over
// the reading column.
const illustrationOpen = ref(false)
const illustrationTitle = computed(() => (chapter.value ? `${chapter.value.title} · 插图` : '插图'))
function rememberPlayback(seconds: number): void {
const bookId = library.chapterBook?.id
if (bookId === undefined) return
const chapterId = chapter.value?.id
if (chapterId === undefined) return
// The reported value is not awaited: leaving the page must not wait for the network.
void library.savePlaybackPosition(bookId, seconds).catch(() => undefined)
void library.saveChapterPlayback(chapterId, seconds).catch(() => undefined)
}
// Reading position: remembered per account and chapter, and only while the text is the version
@@ -245,6 +256,7 @@ onUnmounted(() => {
<h1>{{ chapter.title }}</h1>
<span class="status-chip" :class="`status-${chapter.status}`">{{ statusLabel(chapter.status) }}</span>
</div>
<p v-if="chapter.author" class="chapter-author" data-testid="chapter-author-line">{{ chapter.author }}</p>
<div v-if="chapter.status === 'failed'" class="notice">
<p role="alert">{{ chapter.errorMessage || '这一章处理失败。' }}</p>
<ElButton v-if="retryable" :loading="library.retryingChapterId === chapter.id" @click="retry">重试处理</ElButton>
@@ -253,6 +265,20 @@ onUnmounted(() => {
<p v-else-if="chapter.status !== 'ready'" role="status" class="processing-hint">这一章还在{{ statusLabel(chapter.status) }},页面会自动刷新。</p>
<p v-if="retryError" role="alert" class="notice">{{ retryError }}</p>
<p v-if="rangeNotice" role="alert" class="notice" data-testid="range-notice">{{ rangeNotice }}</p>
<button
v-if="chapter.status === 'ready' && illustrationSource"
type="button"
class="illustration-thumb"
data-testid="chapter-illustration"
:aria-label="`查看《${chapter.title}》的插图大图`"
@click="illustrationOpen = true"
>
<img :src="illustrationSource" alt="本章插图缩略图" />
<span class="illustration-hint">点击查看大图</span>
</button>
<ElDialog v-model="illustrationOpen" :title="illustrationTitle" width="auto" data-testid="illustration-dialog">
<img class="illustration-full" :src="illustrationSource" alt="本章插图" />
</ElDialog>
<div v-if="chapter.status === 'ready'" class="reader-workspace">
<div class="reader-body" ref="readerBody">
<ReaderTokens
+4
View File
@@ -43,6 +43,7 @@ TABLES = [
"lexgo_books", "lexgo_chapters", "lexgo_ingest_jobs", "lexgo_dictionaries",
"lexgo_terms", "lexgo_term_reviews", "lexgo_review_answers", "lexgo_chapter_progress",
"lexgo_book_attachments", "lexgo_playback_positions",
"lexgo_chapter_attachments", "lexgo_chapter_playback_positions",
]
SCHEMA_VERSION = 7
@@ -474,6 +475,9 @@ def integrity_checks(database):
add("附件都指向存在的书", int(scalar(database, "SELECT COUNT(*) FROM lexgo_book_attachments a LEFT JOIN lexgo_books b ON b.id=a.book_id WHERE b.id IS NULL", "0") or 0) == 0)
add("附件归属与书归属一致", int(scalar(database, "SELECT COUNT(*) FROM lexgo_book_attachments a JOIN lexgo_books b ON b.id=a.book_id WHERE a.owner_id<>b.owner_id", "0") or 0) == 0)
add("播放位置都指向存在的书", int(scalar(database, "SELECT COUNT(*) FROM lexgo_playback_positions p LEFT JOIN lexgo_books b ON b.id=p.book_id WHERE b.id IS NULL", "0") or 0) == 0)
add("章节附件都指向存在的章节", int(scalar(database, "SELECT COUNT(*) FROM lexgo_chapter_attachments a LEFT JOIN lexgo_chapters c ON c.id=a.chapter_id WHERE c.id IS NULL", "0") or 0) == 0)
add("章节附件归属与章节归属一致", int(scalar(database, "SELECT COUNT(*) FROM lexgo_chapter_attachments a JOIN lexgo_chapters c ON c.id=a.chapter_id WHERE a.owner_id<>c.owner_id", "0") or 0) == 0)
add("章节播放位置都指向存在的章节", int(scalar(database, "SELECT COUNT(*) FROM lexgo_chapter_playback_positions p LEFT JOIN lexgo_chapters c ON c.id=p.chapter_id WHERE c.id IS NULL", "0") or 0) == 0)
# The audit tables must not gain a column that could hold a credential or private content.
for table in ("lexgo_login_logs", "lexgo_operation_logs"):
+228 -82
View File
@@ -24,8 +24,10 @@ import (
// and deleting a book removes its attachments with it. Nothing is written to disk: an uploaded file
// never becomes a path, and the client file name is only used for display.
const (
attachmentAudio = "audio"
attachmentCover = "cover"
// Audio and illustration belong to a chapter; the cover belongs to the book.
attachmentAudio = "audio"
attachmentIllustration = "illustration"
attachmentCover = "cover"
maxAudioBytes = 20 << 20
maxCoverBytes = 2 << 20
@@ -52,16 +54,33 @@ type BookAttachment struct {
func (BookAttachment) TableName() string { return "lexgo_book_attachments" }
// PlaybackPosition remembers where one learner stopped in one book. It is separate from the
// attachment row: the file belongs to the book, the position belongs to the reader.
type PlaybackPosition struct {
// ChapterAttachment is one binary file belonging to a chapter: its audio or its illustration.
// chapter_id plus kind are unique, so replacing a file updates one row instead of accumulating
// versions, and deleting a chapter removes its files with it.
type ChapterAttachment struct {
ChapterID int64 `gorm:"primaryKey"`
Kind string `gorm:"primaryKey"`
OwnerID int `json:"-"`
Mime string `json:"mime"`
ByteSize int `json:"byteSize"`
SHA256 string `json:"sha256"`
Bytes []byte `json:"-"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (ChapterAttachment) TableName() string { return "lexgo_chapter_attachments" }
// ChapterPlaybackPosition remembers where one learner stopped in one chapter. It is separate from
// the attachment row: the file belongs to the chapter, the position belongs to the reader.
type ChapterPlaybackPosition struct {
OwnerID int `gorm:"primaryKey"`
BookID int64 `gorm:"primaryKey"`
ChapterID int64 `gorm:"primaryKey"`
PositionSeconds int `json:"positionSeconds"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (PlaybackPosition) TableName() string { return "lexgo_playback_positions" }
func (ChapterPlaybackPosition) TableName() string { return "lexgo_chapter_playback_positions" }
// BookAttachmentView is what the client sees: metadata and a version for cache invalidation, never
// the bytes.
@@ -73,10 +92,10 @@ type BookAttachmentView struct {
UpdatedAt time.Time `json:"updatedAt"`
}
// BookAttachmentsView is what the book page shows. Audio and its position moved to the chapter,
// so only the cover remains at book level.
type BookAttachmentsView struct {
Cover *BookAttachmentView `json:"cover"`
Audio *BookAttachmentView `json:"audio"`
PlaybackSeconds int `json:"playbackSeconds"`
Cover *BookAttachmentView `json:"cover"`
}
// sniffAttachment checks the actual bytes instead of trusting a file name or a declared type.
@@ -87,9 +106,12 @@ func sniffAttachment(kind string, data []byte) (string, error) {
return "audio/mpeg", nil
}
return "", failure(400, "只支持 MP3 音频,请转换为 MP3 后重试")
case attachmentCover:
case attachmentCover, attachmentIllustration:
mime := sniffImage(data)
if mime == "" {
if kind == attachmentIllustration {
return "", failure(400, "插图只支持 JPG、PNG 或 WebP 图片")
}
return "", failure(400, "封面只支持 JPG、PNG 或 WebP 图片")
}
return mime, nil
@@ -165,6 +187,10 @@ func webpDimensions(data []byte) (int, int, bool) {
// validateAttachment enforces the size, type and pixel limits before anything is stored.
func validateAttachment(kind string, data []byte) (string, error) {
label := "封面"
if kind == attachmentIllustration {
label = "插图"
}
switch kind {
case attachmentAudio:
if len(data) == 0 {
@@ -173,12 +199,12 @@ func validateAttachment(kind string, data []byte) (string, error) {
if len(data) > maxAudioBytes {
return "", failure(400, "音频文件不能超过 20 MiB")
}
case attachmentCover:
case attachmentCover, attachmentIllustration:
if len(data) == 0 {
return "", failure(400, "封面图片为空")
return "", failure(400, label+"图片为空")
}
if len(data) > maxCoverBytes {
return "", failure(400, "封面图片不能超过 2 MiB")
return "", failure(400, label+"图片不能超过 2 MiB")
}
default:
return "", failure(400, "附件类型无效")
@@ -187,13 +213,13 @@ func validateAttachment(kind string, data []byte) (string, error) {
if err != nil {
return "", err
}
if kind == attachmentCover {
if kind != attachmentAudio {
width, height, ok := coverDimensions(mime, data)
if !ok {
return "", failure(400, "无法识别该图片,请换一张 JPG、PNG 或 WebP")
}
if width > maxCoverPixels || height > maxCoverPixels {
return "", failure(400, "封面图片不能超过 4096×4096 像素")
return "", failure(400, label+"图片不能超过 4096×4096 像素")
}
}
return mime, nil
@@ -266,7 +292,7 @@ func tooLarge(err error) bool {
// same row only after the new file passed every check, so a rejected upload leaves the old one in
// place.
func SaveAttachment(tx *gorm.DB, owner int, bookID int64, kind string, data []byte, now time.Time) (BookAttachmentView, error) {
if kind != attachmentAudio && kind != attachmentCover {
if kind != attachmentCover {
return BookAttachmentView{}, failure(404, "附件不存在")
}
if err := lockOwnedBook(tx, owner, bookID, &Book{}); err != nil {
@@ -286,12 +312,6 @@ func SaveAttachment(tx *gorm.DB, owner int, bookID int64, kind string, data []by
}).Create(&row).Error; err != nil {
return BookAttachmentView{}, err
}
if kind == attachmentAudio {
// A new file has nothing to do with the old position, so any stored position is dropped.
if err = tx.Where("owner_id = ? AND book_id = ?", owner, bookID).Delete(&PlaybackPosition{}).Error; err != nil {
return BookAttachmentView{}, err
}
}
return attachmentView(row), nil
}
@@ -300,9 +320,9 @@ func attachmentView(row BookAttachment) BookAttachmentView {
Version: row.SHA256, UpdatedAt: row.UpdatedAt}
}
// DeleteAttachment removes one file, and an audio removal also drops the stored position.
// DeleteAttachment removes the cover of a book.
func DeleteAttachment(tx *gorm.DB, owner int, bookID int64, kind string, now time.Time) error {
if kind != attachmentAudio && kind != attachmentCover {
if kind != attachmentCover {
return failure(404, "附件不存在")
}
if err := lockOwnedBook(tx, owner, bookID, &Book{}); err != nil {
@@ -315,9 +335,6 @@ func DeleteAttachment(tx *gorm.DB, owner int, bookID int64, kind string, now tim
if result.RowsAffected == 0 {
return failure(404, "附件不存在")
}
if kind == attachmentAudio {
return tx.Where("owner_id = ? AND book_id = ?", owner, bookID).Delete(&PlaybackPosition{}).Error
}
return nil
}
@@ -352,22 +369,56 @@ func attachmentsByBook(tx *gorm.DB, owner int, bookIDs []int64) (map[int64]map[s
return found, nil
}
func playbackSeconds(tx *gorm.DB, owner int, bookIDs []int64) (map[int64]int, error) {
// ChapterAttachmentView is one chapter file as the client sees it: metadata and a version for
// cache invalidation, never the bytes.
type ChapterAttachmentView struct {
Kind string `json:"kind"`
Mime string `json:"mime"`
ByteSize int `json:"byteSize"`
Version string `json:"version"`
UpdatedAt time.Time `json:"updatedAt"`
}
// chapterAttachmentViews loads the attachment metadata for a list of chapters, so a chapter list can
// show illustrations and audio flags without a query per row.
func chapterAttachmentViews(tx *gorm.DB, owner int, chapterIDs []int64) (map[int64]map[string]ChapterAttachmentView, error) {
found := map[int64]map[string]ChapterAttachmentView{}
if len(chapterIDs) == 0 {
return found, nil
}
var rows []ChapterAttachment
if err := tx.Select("chapter_id", "kind", "mime", "byte_size", "sha256", "updated_at").
Where("owner_id = ? AND chapter_id IN ?", owner, chapterIDs).Find(&rows).Error; err != nil {
return found, err
}
for _, row := range rows {
if found[row.ChapterID] == nil {
found[row.ChapterID] = map[string]ChapterAttachmentView{}
}
found[row.ChapterID][row.Kind] = ChapterAttachmentView{Kind: row.Kind, Mime: row.Mime,
ByteSize: row.ByteSize, Version: row.SHA256, UpdatedAt: row.UpdatedAt}
}
return found, nil
}
// chapterPlaybackSeconds reads this learner's position per chapter.
func chapterPlaybackSeconds(tx *gorm.DB, owner int, chapterIDs []int64) (map[int64]int, error) {
positions := map[int64]int{}
if len(bookIDs) == 0 {
if len(chapterIDs) == 0 {
return positions, nil
}
var rows []PlaybackPosition
if err := tx.Where("owner_id = ? AND book_id IN ?", owner, bookIDs).Find(&rows).Error; err != nil {
var rows []ChapterPlaybackPosition
if err := tx.Where("owner_id = ? AND chapter_id IN ?", owner, chapterIDs).Find(&rows).Error; err != nil {
return positions, err
}
for _, row := range rows {
positions[row.BookID] = row.PositionSeconds
positions[row.ChapterID] = row.PositionSeconds
}
return positions, nil
}
// BookAttachmentsFor is the view the book page uses.
// BookAttachmentsFor is the view the book page uses: the cover only. Audio and its position belong
// to a chapter now.
func BookAttachmentsFor(tx *gorm.DB, owner int, bookID int64) (BookAttachmentsView, error) {
view := BookAttachmentsView{}
found, err := attachmentsByBook(tx, owner, []int64{bookID})
@@ -377,36 +428,95 @@ func BookAttachmentsFor(tx *gorm.DB, owner int, bookID int64) (BookAttachmentsVi
if cover, ok := found[bookID][attachmentCover]; ok {
view.Cover = &cover
}
if audio, ok := found[bookID][attachmentAudio]; ok {
view.Audio = &audio
}
positions, err := playbackSeconds(tx, owner, []int64{bookID})
if err != nil {
return view, err
}
view.PlaybackSeconds = positions[bookID]
return view, nil
}
// SavePlaybackPosition remembers where the learner stopped. Only the caller's own position moves.
func SavePlaybackPosition(tx *gorm.DB, owner int, bookID int64, seconds int, now time.Time) (int, error) {
// SaveChapterAttachment validates and stores one file for a chapter the caller owns. A replacement
// updates the same row only after the new file passed every check, so a rejected upload leaves the
// old one in place, and replacing the audio drops the position that belonged to the old file.
func SaveChapterAttachment(tx *gorm.DB, owner int, chapterID int64, kind string, data []byte, now time.Time) (ChapterAttachmentView, error) {
if kind != attachmentAudio && kind != attachmentIllustration {
return ChapterAttachmentView{}, failure(404, "附件不存在")
}
var chapter Chapter
if err := lockOwnedChapter(tx, owner, chapterID, &chapter); err != nil {
return ChapterAttachmentView{}, err
}
mime, err := validateAttachment(kind, data)
if err != nil {
return ChapterAttachmentView{}, err
}
ts := stamp(now)
row := ChapterAttachment{ChapterID: chapterID, Kind: kind, OwnerID: owner, Mime: mime,
ByteSize: len(data), SHA256: contentSHA(string(data)), Bytes: data, CreatedAt: ts, UpdatedAt: ts}
if err = tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "chapter_id"}, {Name: "kind"}},
DoUpdates: clause.AssignmentColumns([]string{"owner_id", "mime", "byte_size", "sha256", "bytes", "updated_at"}),
}).Create(&row).Error; err != nil {
return ChapterAttachmentView{}, err
}
if kind == attachmentAudio {
if err = tx.Where("owner_id = ? AND chapter_id = ?", owner, chapterID).Delete(&ChapterPlaybackPosition{}).Error; err != nil {
return ChapterAttachmentView{}, err
}
}
return ChapterAttachmentView{Kind: row.Kind, Mime: row.Mime, ByteSize: row.ByteSize,
Version: row.SHA256, UpdatedAt: row.UpdatedAt}, nil
}
// DeleteChapterAttachment removes one chapter file, and an audio removal drops the position too.
func DeleteChapterAttachment(tx *gorm.DB, owner int, chapterID int64, kind string, now time.Time) error {
if kind != attachmentAudio && kind != attachmentIllustration {
return failure(404, "附件不存在")
}
var chapter Chapter
if err := lockOwnedChapter(tx, owner, chapterID, &chapter); err != nil {
return err
}
result := tx.Where("chapter_id = ? AND owner_id = ? AND kind = ?", chapterID, owner, kind).Delete(&ChapterAttachment{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return failure(404, "附件不存在")
}
if kind == attachmentAudio {
return tx.Where("owner_id = ? AND chapter_id = ?", owner, chapterID).Delete(&ChapterPlaybackPosition{}).Error
}
return nil
}
// ChapterAttachmentFile returns one stored chapter file for the caller's own chapter.
func ChapterAttachmentFile(tx *gorm.DB, owner int, chapterID int64, kind string) (ChapterAttachment, error) {
var row ChapterAttachment
err := tx.Where("chapter_id = ? AND owner_id = ? AND kind = ?", chapterID, owner, kind).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return ChapterAttachment{}, failure(404, "附件不存在")
}
return row, err
}
// SaveChapterPlaybackPosition remembers where the learner stopped in one chapter. Only the caller's
// own position moves, and a chapter without audio is refused so a stale client cannot create rows.
func SaveChapterPlaybackPosition(tx *gorm.DB, owner int, chapterID int64, seconds int, now time.Time) (int, error) {
if seconds < 0 || seconds > maxPlaybackSeconds {
return 0, failure(400, "播放位置无效")
}
if err := lockOwnedBook(tx, owner, bookID, &Book{}); err != nil {
var chapter Chapter
if err := lockOwnedChapter(tx, owner, chapterID, &chapter); err != nil {
return 0, err
}
var audio int64
if err := tx.Model(&BookAttachment{}).Where("book_id = ? AND kind = ?", bookID, attachmentAudio).Count(&audio).Error; err != nil {
if err := tx.Model(&ChapterAttachment{}).Where("chapter_id = ? AND kind = ?", chapterID, attachmentAudio).Count(&audio).Error; err != nil {
return 0, err
}
if audio == 0 {
return 0, failure(404, "这本书没有音频")
return 0, failure(404, "这一章没有音频")
}
ts := stamp(now)
row := PlaybackPosition{OwnerID: owner, BookID: bookID, PositionSeconds: seconds, UpdatedAt: ts}
row := ChapterPlaybackPosition{OwnerID: owner, ChapterID: chapterID, PositionSeconds: seconds, UpdatedAt: ts}
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "owner_id"}, {Name: "book_id"}},
Columns: []clause.Column{{Name: "owner_id"}, {Name: "chapter_id"}},
DoUpdates: clause.AssignmentColumns([]string{"position_seconds", "updated_at"}),
}).Create(&row).Error; err != nil {
return 0, err
@@ -415,37 +525,24 @@ func SavePlaybackPosition(tx *gorm.DB, owner int, bookID int64, seconds int, now
}
func registerAttachmentRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context, *gorm.DB, admin.SysUser) (any, error)) gin.HandlerFunc, now func() time.Time) {
upload := func(kind string, limit int64) gin.HandlerFunc {
return protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
if len(c.Request.URL.Query()) > 0 {
return nil, failure(400, "附件上传不接受查询参数")
}
id, err := pathID(c, "书籍不存在")
if err != nil {
return nil, err
}
data, err := readAttachmentUpload(c, limit)
if err != nil {
return nil, err
}
view, err := SaveAttachment(tx, u.UserId, id, kind, data, now())
if err != nil {
return nil, err
}
return gin.H{"attachment": view}, nil
})
}
v.POST("/books/:id/audio", upload(attachmentAudio, maxAudioBytes))
v.POST("/books/:id/cover", upload(attachmentCover, maxCoverBytes))
v.DELETE("/books/:id/audio", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
// The book keeps the cover; audio, illustration and the playback position are chapter scoped.
v.POST("/books/:id/cover", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
if len(c.Request.URL.Query()) > 0 {
return nil, failure(400, "附件上传不接受查询参数")
}
id, err := pathID(c, "书籍不存在")
if err != nil {
return nil, err
}
if err = DeleteAttachment(tx, u.UserId, id, attachmentAudio, now()); err != nil {
data, err := readAttachmentUpload(c, maxCoverBytes)
if err != nil {
return nil, err
}
return gin.H{"deleted": true}, nil
view, err := SaveAttachment(tx, u.UserId, id, attachmentCover, data, now())
if err != nil {
return nil, err
}
return gin.H{"attachment": view}, nil
}))
v.DELETE("/books/:id/cover", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
id, err := pathID(c, "书籍不存在")
@@ -457,14 +554,63 @@ func registerAttachmentRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.C
}
return gin.H{"deleted": true}, nil
}))
for kind, name := range map[string]string{attachmentAudio: "audio", attachmentCover: "cover"} {
attachmentKind, fileName := kind, name
v.GET("/books/:id/"+fileName, protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
id, err := pathID(c, "书籍不存在")
v.GET("/books/:id/cover", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
id, err := pathID(c, "书籍不存在")
if err != nil {
return nil, err
}
row, err := BookAttachmentFile(tx, u.UserId, id, attachmentCover)
if err != nil {
return nil, err
}
return binaryResponse{ContentType: row.Mime, Bytes: row.Bytes, FileName: "cover",
ModifiedAt: row.UpdatedAt, ETag: `"` + row.SHA256 + `"`}, nil
}))
upload := func(kind string) gin.HandlerFunc {
limit := int64(maxAudioBytes)
if kind == attachmentIllustration {
limit = maxCoverBytes
}
return protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
if len(c.Request.URL.Query()) > 0 {
return nil, failure(400, "附件上传不接受查询参数")
}
id, err := pathID(c, "章节不存在")
if err != nil {
return nil, err
}
row, err := BookAttachmentFile(tx, u.UserId, id, attachmentKind)
data, err := readAttachmentUpload(c, limit)
if err != nil {
return nil, err
}
view, err := SaveChapterAttachment(tx, u.UserId, id, kind, data, now())
if err != nil {
return nil, err
}
return gin.H{"attachment": view}, nil
})
}
v.POST("/chapters/:id/audio", upload(attachmentAudio))
v.POST("/chapters/:id/illustration", upload(attachmentIllustration))
for kind, name := range map[string]string{attachmentAudio: "audio", attachmentIllustration: "illustration"} {
attachmentKind, fileName := kind, name
v.DELETE("/chapters/:id/"+fileName, protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
id, err := pathID(c, "章节不存在")
if err != nil {
return nil, err
}
if err = DeleteChapterAttachment(tx, u.UserId, id, attachmentKind, now()); err != nil {
return nil, err
}
return gin.H{"deleted": true}, nil
}))
v.GET("/chapters/:id/"+fileName, protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
id, err := pathID(c, "章节不存在")
if err != nil {
return nil, err
}
row, err := ChapterAttachmentFile(tx, u.UserId, id, attachmentKind)
if err != nil {
return nil, err
}
@@ -472,8 +618,8 @@ func registerAttachmentRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.C
ModifiedAt: row.UpdatedAt, ETag: `"` + row.SHA256 + `"`}, nil
}))
}
v.PUT("/books/:id/playback", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
id, err := pathID(c, "书籍不存在")
v.PUT("/chapters/:id/playback", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
id, err := pathID(c, "章节不存在")
if err != nil {
return nil, err
}
@@ -486,7 +632,7 @@ func registerAttachmentRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.C
if input.PositionSeconds == nil {
return nil, failure(400, "请提供播放位置")
}
seconds, err := SavePlaybackPosition(tx, u.UserId, id, *input.PositionSeconds, now())
seconds, err := SaveChapterPlaybackPosition(tx, u.UserId, id, *input.PositionSeconds, now())
if err != nil {
return nil, err
}
+279 -195
View File
@@ -14,6 +14,7 @@ import (
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// ---------------------------------------------------------------- unit rules
@@ -76,6 +77,8 @@ func TestAttachmentTypeSniffing(t *testing.T) {
{"wav is not mp3", attachmentAudio, append([]byte("RIFF"), make([]byte, 40)...), "", false},
{"text renamed", attachmentAudio, []byte("this is not audio at all.........."), "", false},
{"png cover", attachmentCover, testPNG(t, 8, 8), "image/png", true},
{"png illustration", attachmentIllustration, testPNG(t, 8, 8), "image/png", true},
{"gif illustration", attachmentIllustration, []byte("GIF89a"), "", false},
{"jpeg cover", attachmentCover, testJPEG(t, 8, 8), "image/jpeg", true},
{"webp cover", attachmentCover, testWebP(8, 8), "image/webp", true},
{"gif is not accepted", attachmentCover, []byte("GIF89a"), "", false},
@@ -95,6 +98,9 @@ func TestAttachmentTypeSniffing(t *testing.T) {
}
func TestAttachmentLimits(t *testing.T) {
if _, err := validateAttachment(attachmentIllustration, make([]byte, maxCoverBytes+1)); err == nil {
t.Fatal("an oversized illustration must be refused")
}
if _, err := validateAttachment(attachmentAudio, make([]byte, maxAudioBytes+1)); err == nil {
t.Fatal("an oversized audio file must be refused")
}
@@ -146,18 +152,17 @@ func TestWebPDimensions(t *testing.T) {
func TestPlaybackPositionBounds(t *testing.T) {
// The bound is checked before any database work, so a nil transaction is fine here.
if _, err := SavePlaybackPosition(nil, 1, 1, -1, time.Now()); err == nil {
if _, err := SaveChapterPlaybackPosition(nil, 1, 1, -1, time.Now()); err == nil {
t.Fatal("a negative position must be refused")
}
if _, err := SavePlaybackPosition(nil, 1, 1, maxPlaybackSeconds+1, time.Now()); err == nil {
if _, err := SaveChapterPlaybackPosition(nil, 1, 1, maxPlaybackSeconds+1, time.Now()); err == nil {
t.Fatal("an absurd position must be refused")
}
}
// ---------------------------------------------------------------- integration
// uploadAttachment posts one multipart file to an attachment endpoint.
func uploadAttachment(t *testing.T, r *gin.Engine, token string, path string, data []byte) (int, string, BookAttachmentView) {
func uploadAttachment(t *testing.T, r *gin.Engine, token string, path string, data []byte) (int, string, ChapterAttachmentView) {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
@@ -182,7 +187,7 @@ func uploadAttachment(t *testing.T, r *gin.Engine, token string, path string, da
Code int `json:"code"`
Msg string
Data struct {
Attachment BookAttachmentView
Attachment ChapterAttachmentView
}
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
@@ -229,37 +234,264 @@ func bookDetailOf(t *testing.T, r *gin.Engine, token string, bookID int64) (Book
return payload.Book, payload.Chapters
}
// TestMySQLBookAttachmentsAndPlayback covers upload, authenticated Range reads, replacement,
// position handling, ownership and cascade deletion.
func TestMySQLBookAttachmentsAndPlayback(t *testing.T) {
func chapterInList(t *testing.T, r *gin.Engine, token string, bookID, chapterID int64) ChapterSummary {
t.Helper()
_, chapters := bookDetailOf(t, r, token, bookID)
for _, chapter := range chapters {
if chapter.ID == chapterID {
return chapter
}
}
t.Fatalf("chapter %d is missing from the book list", chapterID)
return ChapterSummary{}
}
func readerChapter(t *testing.T, r *gin.Engine, token string, chapterID int64) ChapterView {
t.Helper()
code, _, data := callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d", chapterID), token, nil)
if code != 200 {
t.Fatalf("reader status %d", code)
}
var payload struct {
Chapter ChapterView
}
if err := json.Unmarshal(data, &payload); err != nil {
t.Fatal(err)
}
return payload.Chapter
}
func attachmentRows(t *testing.T, db *gorm.DB, chapterID int64, kind string) int64 {
t.Helper()
var rows int64
if err := db.Model(&ChapterAttachment{}).Where("chapter_id = ? AND kind = ?", chapterID, kind).Count(&rows).Error; err != nil {
t.Fatal(err)
}
return rows
}
// TestMySQLChapterAttachmentsAndPlayback covers the chapter attachments: illustration and audio per
// chapter, authenticated Range reads, replacement, the per-chapter position, ownership and cascade.
func TestMySQLChapterAttachmentsAndPlayback(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
other := newLearner(t, r, owner.Token)
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "attach-0001", "title": "Fictional attachments", "text": "Curiosity opens the first door.\n", "language": "en"})
"requestId": "chapter-attach-0001", "title": "Fictional chapter attachments",
"text": "Curiosity opens the first door.\n", "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
drainIngest(t, db)
bookID := pasted.Chapter.BookID
bookID, chapterID := pasted.Chapter.BookID, pasted.Chapter.ID
// A book without attachments reports neither a cover nor audio.
book, _ := bookDetailOf(t, r, learner.Token, bookID)
if book.Attachments == nil || book.Attachments.Cover != nil || book.Attachments.Audio != nil || book.Attachments.PlaybackSeconds != 0 {
t.Fatalf("a new book must have no attachments: %+v", book.Attachments)
// A chapter without attachments reports nothing and serves 404.
if listed := chapterInList(t, r, learner.Token, bookID, chapterID); listed.IllustrationVersion != "" || listed.AudioVersion != "" || listed.PlaybackSeconds != 0 {
t.Fatalf("a new chapter must have no attachments: %+v", listed)
}
if recorder := fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d/audio", bookID), nil); recorder.Code != 404 {
t.Fatalf("reading a missing audio must be 404, got %d", recorder.Code)
if reader := readerChapter(t, r, learner.Token, chapterID); reader.IllustrationVersion != "" || reader.AudioVersion != "" {
t.Fatalf("the reader must report no attachments: %+v", reader)
}
for _, path := range []string{"audio", "illustration"} {
if recorder := fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/%s", chapterID, path), nil); recorder.Code != 404 {
t.Fatalf("reading a missing %s must be 404, got %d", path, recorder.Code)
}
}
// A cover upload stores the bytes and is visible in the list as a version.
// The illustration is stored, listed with a version and served byte for byte.
illustration := testPNG(t, 60, 34)
code, msg, view := uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/illustration", chapterID), illustration)
if code != 200 || view.Kind != attachmentIllustration || view.Mime != "image/png" || view.Version != contentSHA(string(illustration)) {
t.Fatalf("illustration upload: %d %s %+v", code, msg, view)
}
if listed := chapterInList(t, r, learner.Token, bookID, chapterID); listed.IllustrationVersion != view.Version || listed.AudioVersion != "" {
t.Fatalf("the chapter list must carry the illustration version: %+v", listed)
}
recorder := fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/illustration", chapterID), nil)
if recorder.Code != 200 || !bytes.Equal(recorder.Body.Bytes(), illustration) || recorder.Header().Get("Content-Type") != "image/png" {
t.Fatalf("illustration read: %d %s", recorder.Code, recorder.Header().Get("Content-Type"))
}
etag := recorder.Header().Get("ETag")
if recorder = fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/illustration", chapterID), map[string]string{"If-None-Match": etag}); recorder.Code != 304 {
t.Fatalf("a matching ETag must answer 304, got %d", recorder.Code)
}
// The audio is stored and served in full and by range.
audio := testMP3(4096)
code, msg, audioView := uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), audio)
if code != 200 || audioView.Kind != attachmentAudio || audioView.Mime != "audio/mpeg" {
t.Fatalf("audio upload: %d %s %+v", code, msg, audioView)
}
if listed := chapterInList(t, r, learner.Token, bookID, chapterID); listed.AudioVersion != audioView.Version {
t.Fatalf("the chapter list must carry the audio version: %+v", listed)
}
if reader := readerChapter(t, r, learner.Token, chapterID); reader.AudioVersion != audioView.Version || reader.IllustrationVersion != view.Version {
t.Fatalf("the reader must carry both versions: %+v", reader)
}
recorder = fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), nil)
if recorder.Code != 200 || !bytes.Equal(recorder.Body.Bytes(), audio) || recorder.Header().Get("Accept-Ranges") == "" {
t.Fatalf("audio read: %d ranges=%q", recorder.Code, recorder.Header().Get("Accept-Ranges"))
}
recorder = fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), map[string]string{"Range": "bytes=100-199"})
if recorder.Code != 206 || recorder.Body.Len() != 100 || !bytes.Equal(recorder.Body.Bytes(), audio[100:200]) {
t.Fatalf("a range request must answer 206 with the slice, got %d with %d bytes", recorder.Code, recorder.Body.Len())
}
if recorder = fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), map[string]string{"Range": "bytes=999999-"}); recorder.Code != 416 {
t.Fatalf("an unsatisfiable range must answer 416, got %d", recorder.Code)
}
// The book-level audio endpoints are retired: the route no longer exists.
if code, _, _ := callRaw(t, r, "POST", fmt.Sprintf("/api/v1/books/%d/audio", bookID), learner.Token, nil); code != 404 {
t.Fatalf("the book-level audio route must be gone, got %d", code)
}
if code, _, _ := callRaw(t, r, "PUT", fmt.Sprintf("/api/v1/books/%d/playback", bookID), learner.Token, map[string]any{"positionSeconds": 5}); code != 404 {
t.Fatalf("the book-level playback route must be gone, got %d", code)
}
if book, _ := bookDetailOf(t, r, learner.Token, bookID); book.Attachments != nil && book.Attachments.Cover != nil {
t.Fatal("the book view must only carry the cover")
}
// The position is per chapter and only moves for the caller.
code, msg, _ = callRaw(t, r, "PUT", fmt.Sprintf("/api/v1/chapters/%d/playback", chapterID), learner.Token, map[string]any{"positionSeconds": 125})
if code != 200 {
t.Fatalf("position update: %d %s", code, msg)
}
if listed := chapterInList(t, r, learner.Token, bookID, chapterID); listed.PlaybackSeconds != 125 {
t.Fatalf("the chapter list must report the position, got %d", listed.PlaybackSeconds)
}
if reader := readerChapter(t, r, learner.Token, chapterID); reader.PlaybackSeconds != 125 {
t.Fatalf("the reader must report the position, got %d", reader.PlaybackSeconds)
}
if code, _, _ := callRaw(t, r, "PUT", fmt.Sprintf("/api/v1/chapters/%d/playback", chapterID), learner.Token, map[string]any{"positionSeconds": -5}); code != 400 {
t.Fatal("a negative position must be refused")
}
if code, _, _ := callRaw(t, r, "PUT", fmt.Sprintf("/api/v1/chapters/%d/playback", chapterID), learner.Token, map[string]any{}); code != 400 {
t.Fatal("a position request without a value must be refused")
}
// Replacing the audio drops the old position and keeps one row.
smaller := testMP3(2048)
code, msg, replaced := uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), smaller)
if code != 200 || replaced.ByteSize != len(smaller) || replaced.Version == audioView.Version {
t.Fatalf("audio replacement: %d %s %+v", code, msg, replaced)
}
if listed := chapterInList(t, r, learner.Token, bookID, chapterID); listed.PlaybackSeconds != 0 {
t.Fatalf("a replacement must reset the position, got %d", listed.PlaybackSeconds)
}
if rows := attachmentRows(t, db, chapterID, attachmentAudio); rows != 1 {
t.Fatalf("a replacement must keep one row, got %d", rows)
}
// A rejected upload leaves the stored files untouched.
if code, _, _ = uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), []byte("not audio at all")); code != 400 {
t.Fatalf("text as audio must be refused, got %d", code)
}
if code, _, _ = uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/illustration", chapterID), []byte("<svg xmlns='http://www.w3.org/2000/svg'></svg>")); code != 400 {
t.Fatalf("svg as illustration must be refused, got %d", code)
}
big := make([]byte, maxAudioBytes+1024)
copy(big, "ID3")
if code, _, _ = uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), big); code != 413 {
t.Fatalf("an oversized audio must be refused with 413, got %d", code)
}
if code, _, _ = uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/illustration", chapterID), testPNG(t, maxCoverPixels+1, 4)); code != 400 {
t.Fatalf("an oversized illustration must be refused, got %d", code)
}
recorder = fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), nil)
if !bytes.Equal(recorder.Body.Bytes(), smaller) {
t.Fatal("a refused upload must keep the previous audio")
}
recorder = fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/illustration", chapterID), nil)
if !bytes.Equal(recorder.Body.Bytes(), illustration) {
t.Fatal("a refused upload must keep the previous illustration")
}
// Ownership: another account cannot read, replace, remove or report on this chapter.
if recorder = fetchRaw(t, r, other.Token, fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), nil); recorder.Code != 404 {
t.Fatalf("another account reading the audio must be 404, got %d", recorder.Code)
}
if recorder = fetchRaw(t, r, "", fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), nil); recorder.Code != 401 {
t.Fatalf("an anonymous read must be 401, got %d", recorder.Code)
}
if code, _, _ = uploadAttachment(t, r, other.Token, fmt.Sprintf("/api/v1/chapters/%d/illustration", chapterID), testPNG(t, 8, 8)); code != 404 {
t.Fatalf("another account uploading an illustration must be 404, got %d", code)
}
if code, _, _ = callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), other.Token, nil); code != 404 {
t.Fatalf("another account deleting the audio must be 404, got %d", code)
}
if code, _, _ = callRaw(t, r, "PUT", fmt.Sprintf("/api/v1/chapters/%d/playback", chapterID), other.Token, map[string]any{"positionSeconds": 10}); code != 404 {
t.Fatalf("another account reporting a position must be 404, got %d", code)
}
if code, _, _ = callRaw(t, r, "PUT", fmt.Sprintf("/api/v1/chapters/%d/playback", chapterID), "", map[string]any{"positionSeconds": 10}); code != 401 {
t.Fatal("reporting a position needs a session")
}
// Removing the audio removes the position with it, and the illustration is separate.
code, _, _ = callRaw(t, r, "PUT", fmt.Sprintf("/api/v1/chapters/%d/playback", chapterID), learner.Token, map[string]any{"positionSeconds": 42})
if code != 200 {
t.Fatalf("position before removal %d", code)
}
if code, _, _ = callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), learner.Token, nil); code != 200 {
t.Fatalf("audio removal %d", code)
}
var positions int64
if err := db.Model(&ChapterPlaybackPosition{}).Where("chapter_id = ?", chapterID).Count(&positions).Error; err != nil || positions != 0 {
t.Fatalf("removing the audio must remove the position: %d %v", positions, err)
}
if code, _, _ = callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), learner.Token, nil); code != 404 {
t.Fatal("removing a missing attachment must be 404")
}
if rows := attachmentRows(t, db, chapterID, attachmentIllustration); rows != 1 {
t.Fatalf("removing the audio must not touch the illustration: %d", rows)
}
if recorder = fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/illustration", chapterID), nil); recorder.Code != 200 {
t.Fatal("the illustration must still be readable")
}
// Deleting the chapter takes its remaining attachment with it.
code, msg, _ = callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d", chapterID), learner.Token, nil)
if code != 200 {
t.Fatalf("chapter delete: %d %s", code, msg)
}
var attachments int64
if err := db.Model(&ChapterAttachment{}).Where("chapter_id = ?", chapterID).Count(&attachments).Error; err != nil || attachments != 0 {
t.Fatalf("deleting a chapter must remove its attachments: %d %v", attachments, err)
}
}
// TestMySQLBookCoverAndChapterUploadRules covers the cover behaviour that stayed on the book plus the
// request shapes that must not be stored for a chapter.
func TestMySQLBookCoverAndChapterUploadRules(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
other := newLearner(t, r, owner.Token)
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "chapter-attach-0002", "title": "Fictional attachment rules",
"text": "Curiosity opens the first door.\n", "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
drainIngest(t, db)
bookID, chapterID := pasted.Chapter.BookID, pasted.Chapter.ID
// The cover stays a book-level attachment with the same caching behaviour.
cover := testPNG(t, 40, 25)
code, msg, coverView := uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d/cover", bookID), cover)
if code != 200 || coverView.Kind != attachmentCover || coverView.Mime != "image/png" || coverView.ByteSize != len(cover) {
t.Fatalf("cover upload: %d %s %+v", code, msg, coverView)
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, _ := writer.CreateFormFile("file", "cover.png")
part.Write(cover)
writer.Close()
request := httptest.NewRequest("POST", fmt.Sprintf("/api/v1/books/%d/cover", bookID), bytes.NewReader(body.Bytes()))
request.Header.Set("Content-Type", writer.FormDataContentType())
request.Header.Set("Authorization", "Bearer "+learner.Token)
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request)
if recorder.Code != 200 {
t.Fatalf("cover upload: %d", recorder.Code)
}
if coverView.Version != contentSHA(string(cover)) {
t.Fatal("the cover version must be the content digest")
book, _ := bookDetailOf(t, r, learner.Token, bookID)
if book.Attachments == nil || book.Attachments.Cover == nil || book.Attachments.Cover.Version != contentSHA(string(cover)) {
t.Fatalf("the book must report its cover: %+v", book.Attachments)
}
code, _, listed := callRaw(t, r, "GET", "/api/v1/books", learner.Token, nil)
if code != 200 {
@@ -271,189 +503,41 @@ func TestMySQLBookAttachmentsAndPlayback(t *testing.T) {
if err := json.Unmarshal(listed, &page); err != nil {
t.Fatal(err)
}
if len(page.Items) != 1 || page.Items[0].CoverVersion != coverView.Version || page.Items[0].HasAudio {
t.Fatalf("book list attachments: %+v", page.Items)
if len(page.Items) != 1 || page.Items[0].CoverVersion != contentSHA(string(cover)) {
t.Fatalf("the book list must carry the cover version: %+v", page.Items)
}
if fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d/cover", bookID), nil).Code != 200 {
t.Fatal("the cover must be readable")
}
if fetchRaw(t, r, other.Token, fmt.Sprintf("/api/v1/books/%d/cover", bookID), nil).Code != 404 {
t.Fatal("another account must not read the cover")
}
if code, _, _ := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/books/%d", bookID), learner.Token, nil); code != 200 {
t.Fatal("the book must be deletable")
}
var covers int64
if err := db.Model(&BookAttachment{}).Where("book_id = ?", bookID).Count(&covers).Error; err != nil || covers != 0 {
t.Fatalf("deleting the book must remove its cover: %d %v", covers, err)
}
// The cover is served with its own content type and can be cached by version.
recorder := fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d/cover", bookID), nil)
if recorder.Code != 200 || recorder.Header().Get("Content-Type") != "image/png" || !bytes.Equal(recorder.Body.Bytes(), cover) {
t.Fatalf("cover read: %d %s", recorder.Code, recorder.Header().Get("Content-Type"))
}
etag := recorder.Header().Get("ETag")
if etag == "" {
t.Fatal("the cover must carry an ETag")
}
if recorder = fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d/cover", bookID), map[string]string{"If-None-Match": etag}); recorder.Code != 304 {
t.Fatalf("a matching ETag must answer 304, got %d", recorder.Code)
}
// An audio upload is readable in full and by Range, which is what the player needs.
audio := testMP3(4096)
code, msg, audioView := uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d/audio", bookID), audio)
if code != 200 || audioView.Kind != attachmentAudio || audioView.Mime != "audio/mpeg" {
t.Fatalf("audio upload: %d %s %+v", code, msg, audioView)
}
book, _ = bookDetailOf(t, r, learner.Token, bookID)
if book.Attachments.Audio == nil || book.Attachments.Audio.ByteSize != len(audio) {
t.Fatalf("book detail audio: %+v", book.Attachments)
}
recorder = fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d/audio", bookID), nil)
if recorder.Code != 200 || !bytes.Equal(recorder.Body.Bytes(), audio) || recorder.Header().Get("Accept-Ranges") == "" {
t.Fatalf("audio read: %d ranges=%q bytes=%d", recorder.Code, recorder.Header().Get("Accept-Ranges"), recorder.Body.Len())
}
recorder = fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d/audio", bookID), map[string]string{"Range": "bytes=100-199"})
if recorder.Code != 206 || recorder.Body.Len() != 100 {
t.Fatalf("a range request must answer 206 with 100 bytes, got %d with %d", recorder.Code, recorder.Body.Len())
}
if contentRange := recorder.Header().Get("Content-Range"); contentRange == "" {
t.Fatal("a range answer must carry Content-Range")
}
if !bytes.Equal(recorder.Body.Bytes(), audio[100:200]) {
t.Fatal("the range must return the requested bytes")
}
recorder = fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d/audio", bookID), map[string]string{"Range": "bytes=999999-"})
if recorder.Code != 416 {
t.Fatalf("an unsatisfiable range must answer 416, got %d", recorder.Code)
}
// Playback position: only the caller's own position moves, and it is reported with the book.
code, msg, _ = callRaw(t, r, "PUT", fmt.Sprintf("/api/v1/books/%d/playback", bookID), learner.Token, map[string]any{"positionSeconds": 125})
if code != 200 {
t.Fatalf("position update: %d %s", code, msg)
}
book, _ = bookDetailOf(t, r, learner.Token, bookID)
if book.Attachments.PlaybackSeconds != 125 {
t.Fatalf("playback position %d", book.Attachments.PlaybackSeconds)
}
// Another account cannot even see the book, so its position is unreachable.
if code, _, _ = callRaw(t, r, "GET", fmt.Sprintf("/api/v1/books/%d", bookID), other.Token, nil); code != 404 {
t.Fatalf("another account reading the book must be 404, got %d", code)
}
code, _, _ = callRaw(t, r, "PUT", fmt.Sprintf("/api/v1/books/%d/playback", bookID), learner.Token, map[string]any{"positionSeconds": 300})
if code != 200 {
t.Fatalf("second position update %d", code)
}
book, _ = bookDetailOf(t, r, learner.Token, bookID)
if book.Attachments.PlaybackSeconds != 300 {
t.Fatalf("the position must be replaced, got %d", book.Attachments.PlaybackSeconds)
}
if code, _, _ := callRaw(t, r, "PUT", fmt.Sprintf("/api/v1/books/%d/playback", bookID), learner.Token, map[string]any{"positionSeconds": -5}); code != 400 {
t.Fatal("a negative position must be refused")
}
if code, _, _ := callRaw(t, r, "PUT", fmt.Sprintf("/api/v1/books/%d/playback", bookID), learner.Token, map[string]any{}); code != 400 {
t.Fatal("a position request without a value must be refused")
}
// Replacing the audio drops the old position: the new file has nothing to do with it.
smaller := testMP3(2048)
code, msg, replaced := uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d/audio", bookID), smaller)
if code != 200 || replaced.ByteSize != len(smaller) || replaced.Version == audioView.Version {
t.Fatalf("audio replacement: %d %s %+v", code, msg, replaced)
}
book, _ = bookDetailOf(t, r, learner.Token, bookID)
if book.Attachments.PlaybackSeconds != 0 {
t.Fatalf("a replacement must reset the position, got %d", book.Attachments.PlaybackSeconds)
}
var rows int64
if err := db.Model(&BookAttachment{}).Where("book_id = ? AND kind = ?", bookID, attachmentAudio).Count(&rows).Error; err != nil || rows != 1 {
t.Fatalf("a replacement must keep one row: %d %v", rows, err)
}
// A rejected upload leaves the stored file untouched.
code, _, _ = uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d/audio", bookID), []byte("not audio at all, just text"))
if code != 400 {
t.Fatalf("a text file must be refused, got %d", code)
}
recorder = fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d/audio", bookID), nil)
if !bytes.Equal(recorder.Body.Bytes(), smaller) {
t.Fatal("a refused upload must keep the previous file")
}
big := make([]byte, maxAudioBytes+1024)
copy(big, "ID3")
if code, _, _ = uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d/audio", bookID), big); code != 413 {
t.Fatalf("an oversized audio must be refused with 413, got %d", code)
}
if code, _, _ = uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d/cover", bookID), testPNG(t, maxCoverPixels+1, 4)); code != 400 {
t.Fatalf("an oversized image must be refused, got %d", code)
}
// Ownership: another account cannot read, replace, remove or report on this book.
if recorder = fetchRaw(t, r, other.Token, fmt.Sprintf("/api/v1/books/%d/audio", bookID), nil); recorder.Code != 404 {
t.Fatalf("another account reading audio must be 404, got %d", recorder.Code)
}
if recorder = fetchRaw(t, r, "", fmt.Sprintf("/api/v1/books/%d/audio", bookID), nil); recorder.Code != 401 {
t.Fatalf("an anonymous read must be 401, got %d", recorder.Code)
}
if code, _, _ = uploadAttachment(t, r, other.Token, fmt.Sprintf("/api/v1/books/%d/cover", bookID), testPNG(t, 8, 8)); code != 404 {
t.Fatalf("another account uploading a cover must be 404, got %d", code)
}
if code, _, _ = callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/books/%d/audio", bookID), other.Token, nil); code != 404 {
t.Fatalf("another account deleting audio must be 404, got %d", code)
}
if code, _, _ = callRaw(t, r, "PUT", fmt.Sprintf("/api/v1/books/%d/playback", bookID), other.Token, map[string]any{"positionSeconds": 10}); code != 404 {
t.Fatalf("another account reporting a position must be 404, got %d", code)
}
if code, _, _ = callRaw(t, r, "PUT", fmt.Sprintf("/api/v1/books/%d/playback", bookID), "", map[string]any{"positionSeconds": 10}); code != 401 {
t.Fatal("reporting a position needs a session")
}
// Removing the audio removes the position with it.
code, _, _ = callRaw(t, r, "PUT", fmt.Sprintf("/api/v1/books/%d/playback", bookID), learner.Token, map[string]any{"positionSeconds": 42})
if code != 200 {
t.Fatalf("position before removal %d", code)
}
if code, _, _ = callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/books/%d/audio", bookID), learner.Token, nil); code != 200 {
t.Fatalf("audio removal %d", code)
}
if err := db.Model(&PlaybackPosition{}).Where("book_id = ?", bookID).Count(&rows).Error; err != nil || rows != 0 {
t.Fatalf("removing the audio must remove the position: %d %v", rows, err)
}
if code, _, _ = callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/books/%d/audio", bookID), learner.Token, nil); code != 404 {
t.Fatal("removing a missing attachment must be 404")
}
// Deleting the book takes its remaining attachments with it.
code, msg, _ = callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/books/%d", bookID), learner.Token, nil)
if code != 200 {
t.Fatalf("book delete: %d %s", code, msg)
}
var attachments int64
if err := db.Model(&BookAttachment{}).Where("book_id = ?", bookID).Count(&attachments).Error; err != nil || attachments != 0 {
t.Fatalf("deleting a book must remove its attachments: %d %v", attachments, err)
}
}
// TestMySQLAttachmentUploadRejectsBadRequests covers the request shapes that must not be stored.
func TestMySQLAttachmentUploadRejectsBadRequests(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": "attach-0002", "title": "Fictional attachment rules", "text": "Curiosity opens the first door.\n", "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
drainIngest(t, db)
bookID := pasted.Chapter.BookID
if code, _, _ = uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d/cover", bookID), nil); code != 400 {
// Request shapes that must not be stored.
if code, _, _ = uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/illustration", chapterID), nil); code != 400 {
t.Fatalf("an empty upload must be refused, got %d", code)
}
if code, _, _ = uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d/audio", bookID), []byte{}); code != 400 {
if code, _, _ = uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), []byte{}); code != 400 {
t.Fatalf("an empty audio must be refused, got %d", code)
}
if code, _, _ = uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d/cover?extra=1", bookID), testPNG(t, 8, 8)); code != 400 {
if code, _, _ = uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/audio?extra=1", chapterID), testMP3(16)); code != 400 {
t.Fatal("an upload with query parameters must be refused")
}
if code, _, _ = uploadAttachment(t, r, learner.Token, "/api/v1/books/999999/cover", testPNG(t, 8, 8)); code != 404 {
t.Fatal("an unknown book must be 404")
if code, _, _ = uploadAttachment(t, r, learner.Token, "/api/v1/chapters/999999/audio", testMP3(16)); code != 404 {
t.Fatal("an unknown chapter must be 404")
}
if code, _, _ = uploadAttachment(t, r, "", fmt.Sprintf("/api/v1/books/%d/cover", bookID), testPNG(t, 8, 8)); code != 401 {
if code, _, _ = uploadAttachment(t, r, "", fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), testMP3(16)); code != 401 {
t.Fatal("uploading needs a session")
}
// The rejections must not have stored anything for this book.
var attachments int64
if err := db.Model(&BookAttachment{}).Where("book_id = ?", bookID).Count(&attachments).Error; err != nil || attachments != 0 {
t.Fatalf("nothing may be stored for this book: %d %v", attachments, err)
var stored int64
if err := db.Model(&ChapterAttachment{}).Where("chapter_id = ?", chapterID).Count(&stored).Error; err != nil || stored != 0 {
t.Fatalf("nothing may be stored for this chapter: %d %v", stored, err)
}
}
+70 -2
View File
@@ -2,6 +2,7 @@ package lexgo
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
@@ -87,6 +88,9 @@ func Migrate(db *gorm.DB) error {
if current < 8 {
statements = append(statements, schemaV8Statements...)
}
if current < 9 {
statements = append(statements, schemaV9Statements...)
}
for i, s := range statements {
if _, err = conn.ExecContext(ctx, s); err != nil {
var sqlErr *driver.MySQLError
@@ -96,13 +100,75 @@ 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 {
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
}
}
_, err = conn.ExecContext(ctx, fmt.Sprintf("UPDATE lexgo_schema SET version=%d WHERE id=1", SchemaVersion))
return err
}
// SchemaVersion is the version an explicit migration leaves behind, and the
// version the server requires before it starts.
const SchemaVersion = 8
const SchemaVersion = 11
// 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 = ? AND COLUMN_NAME = 'author'", table).Scan(&exists); err != nil {
return err
}
if exists > 0 {
return nil
}
_, err := conn.ExecContext(ctx, "ALTER TABLE `"+table+"` ADD COLUMN author VARCHAR(120) NOT NULL DEFAULT ''")
return err
}
// v9 moves audio and adds illustrations to the chapter, where a multi-article book needs them, and
// keeps the cover on the book. Both new tables are additive, so the version marker is the only
// thing a binary rollback has to change. The last statement removes the retired book-level audio
// rows; it is idempotent and part of the same version, so a retry after a partial run leaves the
// same result.
var schemaV9Statements = []string{
`CREATE TABLE IF NOT EXISTS lexgo_chapter_attachments (
chapter_id BIGINT UNSIGNED NOT NULL,
kind VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
owner_id BIGINT NOT NULL,
mime VARCHAR(64) NOT NULL, byte_size INT NOT NULL,
sha256 CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
bytes MEDIUMBLOB NOT NULL,
created_at DATETIME(3) NOT NULL, updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (chapter_id, kind),
CHECK (kind IN ('audio','illustration')),
FOREIGN KEY (chapter_id) REFERENCES lexgo_chapters(id) ON DELETE CASCADE,
FOREIGN KEY (owner_id) REFERENCES sys_user(user_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS lexgo_chapter_playback_positions (
owner_id BIGINT NOT NULL, chapter_id BIGINT UNSIGNED NOT NULL,
position_seconds INT NOT NULL DEFAULT 0, updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (owner_id, chapter_id),
CHECK (position_seconds >= 0),
FOREIGN KEY (owner_id) REFERENCES sys_user(user_id) ON DELETE CASCADE,
FOREIGN KEY (chapter_id) REFERENCES lexgo_chapters(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
// Book-level audio is retired: the file belongs to a chapter now. Only the test-era rows can
// exist here, and the ticket records that they are not migrated.
`DELETE FROM lexgo_book_attachments WHERE kind = 'audio'`,
}
// v8 adds the optional book attachments and the per-learner playback position. Both are additive
// tables, so an older binary can still be rolled back to by writing the version marker back.
@@ -253,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
@@ -261,6 +328,7 @@ var schemaV3Statements = []string{
`CREATE TABLE IF NOT EXISTS lexgo_chapters (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, book_id BIGINT UNSIGNED NOT NULL, owner_id BIGINT NOT NULL,
ordinal INT NOT NULL, title VARCHAR(120) NOT NULL,
author VARCHAR(120) NOT NULL DEFAULT '',
original_text MEDIUMTEXT NOT NULL, char_count INT NOT NULL DEFAULT 0,
content_sha256 CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'pending', error_reason VARCHAR(32) NOT NULL DEFAULT '',
+39 -7
View File
@@ -17,12 +17,14 @@ 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 {
Title *string `json:"title"`
Text *string `json:"text"`
Title *string `json:"title"`
Author *string `json:"author"`
Text *string `json:"text"`
}
// ChapterSource is the editable text of one owned chapter. It is separate from the reader
@@ -33,6 +35,7 @@ type ChapterSource struct {
BookID int64 `json:"bookId"`
Ordinal int `json:"ordinal"`
Title string `json:"title"`
Author string `json:"author"`
Text string `json:"text"`
Status string `json:"status"`
ContentSHA256 string `json:"contentSha256"`
@@ -65,6 +68,16 @@ func editTitle(raw string) (string, error) {
return title, nil
}
// editAuthor validates the optional author of a chapter. An empty author clears the field, which is
// why it is trimmed instead of required.
func editAuthor(raw string) (string, error) {
author := strings.TrimSpace(raw)
if utf8.RuneCountInString(author) > maxTitleRunes {
return "", failure(400, "作者最多 120 个字符")
}
return author, nil
}
// 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 {
@@ -86,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
@@ -103,7 +127,7 @@ func ChapterEditSource(tx *gorm.DB, owner int, chapterID int64) (ChapterSource,
return ChapterSource{}, err
}
return ChapterSource{
ID: chapter.ID, BookID: chapter.BookID, Ordinal: chapter.Ordinal, Title: chapter.Title,
ID: chapter.ID, BookID: chapter.BookID, Ordinal: chapter.Ordinal, Title: chapter.Title, Author: chapter.Author,
Text: chapter.OriginalText, Status: chapter.Status, ContentSHA256: chapter.ContentSHA256,
CharCount: chapter.CharCount,
}, nil
@@ -113,7 +137,7 @@ func ChapterEditSource(tx *gorm.DB, owner int, chapterID int64) (ChapterSource,
// 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 {
if input.Title == nil && input.Text == nil && input.Author == nil {
return ChapterEdit{}, failure(400, "请选择要修改的内容")
}
var chapter Chapter
@@ -130,6 +154,14 @@ func UpdateChapter(tx *gorm.DB, owner int, chapterID int64, input ChapterUpdateI
chapter.Title = title
updates["title"] = title
}
if input.Author != nil {
author, err := editAuthor(*input.Author)
if err != nil {
return ChapterEdit{}, err
}
chapter.Author = author
updates["author"] = author
}
var job *IngestJob
if input.Text != nil {
text := *input.Text
+178
View File
@@ -541,3 +541,181 @@ func TestMySQLRecoverySkipsSupersededJobs(t *testing.T) {
t.Fatalf("the new version did not publish: %+v", row)
}
}
// TestMySQLChapterAuthorRoundTrip covers the optional author: it is saved with the chapter, shown by
// the chapter list and the reader, trimmed, capped, and clearable.
func TestMySQLChapterAuthorRoundTrip(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "chapter-author-0001", "title": "Fictional author", "text": "Curiosity opens the first door.\n", "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
drainIngest(t, db)
chapterID := pasted.Chapter.ID
// A new chapter has no author, and the source reports it as empty for the editor.
code, _, data := callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d/source", chapterID), learner.Token, nil)
if code != 200 {
t.Fatalf("source %d", code)
}
var source struct {
Source ChapterSource
}
if err := json.Unmarshal(data, &source); err != nil {
t.Fatal(err)
}
if source.Source.Author != "" {
t.Fatalf("a new chapter must have no author, got %q", source.Source.Author)
}
// An author-only edit is a real edit, and it keeps the author around the trim.
code, _, data = callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/chapters/%d", chapterID), learner.Token,
map[string]string{"author": " 虚构作者 "})
if code != 200 {
t.Fatalf("author edit %d", code)
}
var edited struct {
Chapter ChapterSummary
}
if err := json.Unmarshal(data, &edited); err != nil {
t.Fatal(err)
}
if edited.Chapter.Author != "虚构作者" {
t.Fatalf("the author must be trimmed, got %q", edited.Chapter.Author)
}
// A text-free edit must not touch the content version.
if edited.Chapter.Status != statusReady {
t.Fatalf("an author edit must not reprocess the chapter: %+v", edited.Chapter)
}
// The chapter list and the reader both carry it.
_, chapters := bookDetailOf(t, r, learner.Token, pasted.Chapter.BookID)
if len(chapters) != 1 || chapters[0].Author != "虚构作者" {
t.Fatalf("the chapter list must carry the author: %+v", chapters)
}
if reader := readerChapter(t, r, learner.Token, chapterID); reader.Author != "虚构作者" {
t.Fatalf("the reader must carry the author: %+v", reader)
}
// The rules: at most 120 characters, and an empty author clears the field.
if code, _, _ := callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/chapters/%d", chapterID), learner.Token,
map[string]string{"author": strings.Repeat("a", 121)}); code != 400 {
t.Fatal("an over-long author must be rejected")
}
if code, _, _ := callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/chapters/%d", chapterID), learner.Token,
map[string]string{"author": strings.Repeat("a", 120)}); code != 200 {
t.Fatal("a 120 character author must be accepted")
}
if code, _, _ := callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/chapters/%d", chapterID), learner.Token,
map[string]string{"author": " "}); code != 200 {
t.Fatal("clearing the author must be allowed")
}
_, chapters = bookDetailOf(t, r, learner.Token, pasted.Chapter.BookID)
if chapters[0].Author != "" {
t.Fatalf("a blank author must clear the field, got %q", chapters[0].Author)
}
// Ownership and the other rules of this endpoint still apply.
other := newLearner(t, r, owner.Token)
if code, _, _ := callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/chapters/%d", chapterID), other.Token, map[string]string{"author": "stolen"}); code != 404 {
t.Fatal("another account must not set the author")
}
if code, _, _ := callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/chapters/%d", chapterID), learner.Token, map[string]any{}); code != 400 {
t.Fatal("an empty edit must still be rejected")
}
}
// TestMySQLBookAuthorRoundTrip covers the book author: it is saved with the title, list and detail
// carry it, it is trimmed and capped, and an empty author clears it.
func TestMySQLBookAuthorRoundTrip(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "book-author-0001", "title": "Fictional book author", "text": "Curiosity opens the first door." + string(rune(10)), "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
drainIngest(t, db)
bookID := pasted.Chapter.BookID
// A new book has no author.
book, _ := bookDetailOf(t, r, learner.Token, bookID)
if book.Author != "" {
t.Fatalf("a new book must have no author, got %q", book.Author)
}
// The title and the author are saved together, and the author is trimmed.
code, msg, data := callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/books/%d", bookID), learner.Token,
map[string]any{"title": "Fictional book author", "author": " 虚构书籍作者 "})
if code != 200 {
t.Fatalf("book edit: %d %s", code, msg)
}
var edited struct {
Book BookRef
}
if err := json.Unmarshal(data, &edited); err != nil {
t.Fatal(err)
}
if edited.Book.Author != "虚构书籍作者" || edited.Book.Title != "Fictional book author" {
t.Fatalf("book edit result: %+v", edited.Book)
}
code, _, listed := callRaw(t, r, "GET", "/api/v1/books", learner.Token, nil)
if code != 200 {
t.Fatalf("book list %d", code)
}
var page struct {
Items []BookSummary
}
if err := json.Unmarshal(listed, &page); err != nil {
t.Fatal(err)
}
if len(page.Items) != 1 || page.Items[0].Author != "虚构书籍作者" {
t.Fatalf("the book list must carry the author: %+v", page.Items)
}
book, _ = bookDetailOf(t, r, learner.Token, bookID)
if book.Author != "虚构书籍作者" {
t.Fatalf("the book detail must carry the author: %+v", book)
}
// The reader response carries the book too, so a chapter page can show it if needed.
code, _, reader := callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), learner.Token, nil)
if code != 200 {
t.Fatalf("reader %d", code)
}
var read struct {
Book BookRef
}
if err := json.Unmarshal(reader, &read); err != nil {
t.Fatal(err)
}
if read.Book.Author != "虚构书籍作者" {
t.Fatalf("the reader response must carry the book author: %+v", read.Book)
}
// Omitting the author keeps it, a blank author clears it, and the length is capped.
if code, _, _ := callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/books/%d", bookID), learner.Token,
map[string]any{"title": "Fictional book author"}); code != 200 {
t.Fatal("a title-only edit must be accepted")
}
if book, _ = bookDetailOf(t, r, learner.Token, bookID); book.Author != "虚构书籍作者" {
t.Fatalf("omitting the author must keep it, got %q", book.Author)
}
if code, _, _ := callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/books/%d", bookID), learner.Token,
map[string]any{"title": "Fictional book author", "author": " "}); code != 200 {
t.Fatal("clearing the author must be allowed")
}
if book, _ = bookDetailOf(t, r, learner.Token, bookID); book.Author != "" {
t.Fatalf("a blank author must clear the field, got %q", book.Author)
}
if code, _, _ := callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/books/%d", bookID), learner.Token,
map[string]any{"title": "Fictional book author", "author": strings.Repeat("a", 121)}); code != 400 {
t.Fatal("an over-long author must be rejected")
}
// Ownership still applies.
other := newLearner(t, r, owner.Token)
if code, _, _ := callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/books/%d", bookID), other.Token,
map[string]any{"title": "stolen", "author": "stolen"}); code != 404 {
t.Fatal("another account must not edit the book")
}
}
+50 -16
View File
@@ -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
@@ -78,11 +80,13 @@ type Book struct {
func (Book) TableName() string { return "lexgo_books" }
type Chapter struct {
ID int64 `gorm:"primaryKey"`
BookID int64
OwnerID int
Ordinal int
Title string
ID int64 `gorm:"primaryKey"`
BookID int64
OwnerID int
Ordinal int
Title string
// Author is optional free text the learner can set when editing a chapter.
Author string
OriginalText string
CharCount int
ContentSHA256 string
@@ -114,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"`
@@ -130,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.
@@ -137,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 {
@@ -145,6 +151,7 @@ type ChapterSummary struct {
BookID int64 `json:"bookId"`
Ordinal int `json:"ordinal"`
Title string `json:"title"`
Author string `json:"author"`
Status string `json:"status"`
CharCount int `json:"charCount"`
// JobID lets a client retry a failed chapter without keeping the submit response.
@@ -153,9 +160,14 @@ type ChapterSummary struct {
ErrorMessage string `json:"errorMessage"`
// ReadAt is set only where the completion was resolved and still matches the chapter's
// current content; a chapter edited into a newer version reads as unread again.
ReadAt *time.Time `json:"readAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ReadAt *time.Time `json:"readAt"`
// IllustrationVersion and AudioVersion are content digests; they are always present and empty
// when that file is absent, so a client never has to tell "missing" from "empty".
IllustrationVersion string `json:"illustrationVersion"`
AudioVersion string `json:"audioVersion"`
PlaybackSeconds int `json:"playbackSeconds"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type ChapterView struct {
@@ -169,7 +181,7 @@ type ChapterView struct {
func chapterSummary(c Chapter) ChapterSummary { return chapterSummaryWithJob(c, nil) }
func chapterSummaryWithJob(c Chapter, jobID *int64) ChapterSummary {
return ChapterSummary{ID: c.ID, BookID: c.BookID, Ordinal: c.Ordinal, Title: c.Title,
return ChapterSummary{ID: c.ID, BookID: c.BookID, Ordinal: c.Ordinal, Title: c.Title, Author: c.Author,
Status: c.Status, CharCount: c.CharCount, JobID: jobID, ErrorReason: c.ErrorReason,
ErrorMessage: reasonMessage(c.ErrorReason), CreatedAt: c.CreatedAt, UpdatedAt: c.UpdatedAt}
}
@@ -493,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 {
@@ -582,6 +594,14 @@ func BookDetail(db *gorm.DB, owner int, bookID int64) (BookRef, []ChapterSummary
if err != nil {
return BookRef{}, nil, err
}
attachments, err := chapterAttachmentViews(db, owner, ids)
if err != nil {
return BookRef{}, nil, err
}
positions, err := chapterPlaybackSeconds(db, owner, ids)
if err != nil {
return BookRef{}, nil, err
}
for _, c := range chapters {
var jobID *int64
if id, ok := jobs[c.ID]; ok {
@@ -591,6 +611,9 @@ func BookDetail(db *gorm.DB, owner int, bookID int64) (BookRef, []ChapterSummary
if at, ok := marks[c.ID]; ok {
summary.ReadAt = &at
}
summary.IllustrationVersion = attachments[c.ID][attachmentIllustration].Version
summary.AudioVersion = attachments[c.ID][attachmentAudio].Version
summary.PlaybackSeconds = positions[c.ID]
items = append(items, summary)
}
ref := bookRef(book)
@@ -663,12 +686,23 @@ func ChapterDetail(db *gorm.DB, owner int, chapterID int64) (ReaderResponse, err
if at, ok := marks[chapter.ID]; ok {
view.ReadAt = &at
}
// The reader shows the book's audio player and the stored position, so the response carries the
// attachment metadata for the book, not only for the book page.
// The reader shows the chapter's illustration and audio, so this response carries the chapter's
// attachment metadata; the book keeps its cover.
ref := bookRef(book)
if err = attachToBookRef(db, owner, &ref); err != nil {
return ReaderResponse{}, err
}
attachments, err := chapterAttachmentViews(db, owner, []int64{chapter.ID})
if err != nil {
return ReaderResponse{}, err
}
positions, err := chapterPlaybackSeconds(db, owner, []int64{chapter.ID})
if err != nil {
return ReaderResponse{}, err
}
view.IllustrationVersion = attachments[chapter.ID][attachmentIllustration].Version
view.AudioVersion = attachments[chapter.ID][attachmentAudio].Version
view.PlaybackSeconds = positions[chapter.ID]
return ReaderResponse{Book: ref, Chapter: view, Navigation: navigation}, nil
}
+266 -7
View File
@@ -501,8 +501,15 @@ func TestMigrationFromV7AddsBookAttachments(t *testing.T) {
if err := db.Create(&unknown).Error; err == nil {
t.Fatal("an unknown attachment kind must be rejected")
}
position := PlaybackPosition{OwnerID: 31, BookID: book.ID, PositionSeconds: 12, UpdatedAt: saved}
if err := db.Create(&position).Error; err != nil {
// A cover row, so a later check can show that retiring book-level audio leaves covers alone.
if err := db.Exec("INSERT INTO lexgo_book_attachments (book_id, kind, owner_id, mime, byte_size, sha256, bytes, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)",
book.ID, attachmentCover, 31, "image/png", 4, contentSHA("png"), []byte("png!"), saved, saved).Error; err != nil {
t.Fatal(err)
}
// The v8 table is written with raw SQL: the Go type for it was retired in v9, but the migration
// this test covers is the one that created it.
if err := db.Exec("INSERT INTO lexgo_playback_positions (owner_id, book_id, position_seconds, updated_at) VALUES (?,?,?,?)",
31, book.ID, 12, saved).Error; err != nil {
t.Fatal(err)
}
// Rolling the marker back for a binary rollback and upgrading again keeps the rows.
@@ -512,12 +519,19 @@ func TestMigrationFromV7AddsBookAttachments(t *testing.T) {
if err := Migrate(db); err != nil {
t.Fatal(err)
}
if err := db.Model(&BookAttachment{}).Count(&attachments).Error; err != nil || attachments != 1 {
t.Fatalf("re-upgrade must keep the attachment: %d %v", attachments, err)
// Re-upgrading now also applies v9, which retires book-level audio: the audio row this test
// created is removed by that step while the cover row stays.
var audioLeft int64
if err := db.Model(&BookAttachment{}).Where("kind = ?", attachmentAudio).Count(&audioLeft).Error; err != nil || audioLeft != 0 {
t.Fatalf("re-upgrading into v9 must clear book-level audio: %d %v", audioLeft, err)
}
if err := db.Model(&BookAttachment{}).Where("kind = ?", attachmentCover).Count(&attachments).Error; err != nil || attachments != 1 {
t.Fatalf("re-upgrade must keep the cover: %d %v", attachments, err)
}
// The retired position table keeps its row: it is no longer written, but nothing deletes it.
var positions int64
if err := db.Model(&PlaybackPosition{}).Count(&positions).Error; err != nil || positions != 1 {
t.Fatalf("re-upgrade must keep the position: %d %v", positions, err)
if err := db.Table("lexgo_playback_positions").Where("book_id = ?", book.ID).Count(&positions).Error; err != nil || positions != 1 {
t.Fatalf("re-upgrade must keep the retired position row: %d %v", positions, err)
}
// Deleting the book removes both, so no attachment or position can outlive its book.
if err := db.Where("id = ?", book.ID).Delete(&Book{}).Error; err != nil {
@@ -526,7 +540,252 @@ func TestMigrationFromV7AddsBookAttachments(t *testing.T) {
if err := db.Model(&BookAttachment{}).Count(&attachments).Error; err != nil || attachments != 0 {
t.Fatalf("deleting a book must remove its attachments: %d %v", attachments, err)
}
if err := db.Model(&PlaybackPosition{}).Count(&positions).Error; err != nil || positions != 0 {
if err := db.Table("lexgo_playback_positions").Where("book_id = ?", book.ID).Count(&positions).Error; err != nil || positions != 0 {
t.Fatalf("deleting a book must remove its positions: %d %v", positions, err)
}
}
func TestMigrationFromV8AddsChapterAttachments(t *testing.T) {
db := emptyMigrationDB(t)
statements := []string{"CREATE TABLE lexgo_schema (id INT PRIMARY KEY,version INT,product VARCHAR(32))", "INSERT INTO lexgo_schema VALUES (1,8,'lexgo')"}
statements = append(statements, schemaV2Statements...)
statements = append(statements, schemaV3Statements...)
statements = append(statements, schemaV4Statements...)
statements = append(statements, schemaV5Statements...)
statements = append(statements, schemaV6Statements...)
statements = append(statements, schemaV7Statements...)
statements = append(statements, schemaV8Statements...)
for _, statement := range statements {
if err := db.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
if err := db.Exec("INSERT INTO sys_user (user_id,username,password,role_id) VALUES (41,'fixture_v8','fictional-not-a-real-hash',2)").Error; err != nil {
t.Fatal(err)
}
saved := stamp(time.Now())
book := Book{OwnerID: 41, Title: "Fictional v8 book", Language: "en", CreatedAt: saved, UpdatedAt: saved}
if err := db.Create(&book).Error; err != nil {
t.Fatal(err)
}
text := "Curiosity opens the first door.\n"
chapter := Chapter{BookID: book.ID, OwnerID: 41, Ordinal: 1, Title: "Fictional v8",
OriginalText: text, ContentSHA256: contentSHA(text), CharCount: len([]rune(text)), Status: statusReady,
CreatedAt: saved, UpdatedAt: saved}
if err := db.Create(&chapter).Error; err != nil {
t.Fatal(err)
}
// A book-level audio row and a cover row, as the retired version would have left them.
if err := db.Exec("INSERT INTO lexgo_book_attachments (book_id, kind, owner_id, mime, byte_size, sha256, bytes, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)",
book.ID, attachmentAudio, 41, "audio/mpeg", 4, contentSHA("ID3x"), []byte("ID3x"), saved, saved).Error; err != nil {
t.Fatal(err)
}
if err := db.Exec("INSERT INTO lexgo_book_attachments (book_id, kind, owner_id, mime, byte_size, sha256, bytes, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)",
book.ID, attachmentCover, 41, "image/png", 4, contentSHA("png"), []byte("png!"), saved, saved).Error; err != nil {
t.Fatal(err)
}
if err := CheckSchema(db); err == nil {
t.Fatal("old schema accepted before explicit migration")
}
if err := Migrate(db); err != nil {
t.Fatal(err)
}
if err := CheckSchema(db); err != nil {
t.Fatal(err)
}
// The upgrade removes the retired book-level audio rows and keeps the cover.
var audioRows int64
if err := db.Model(&BookAttachment{}).Where("kind = ?", attachmentAudio).Count(&audioRows).Error; err != nil || audioRows != 0 {
t.Fatalf("the migration must clear book-level audio: %d %v", audioRows, err)
}
var coverRows int64
if err := db.Model(&BookAttachment{}).Where("kind = ?", attachmentCover).Count(&coverRows).Error; err != nil || coverRows != 1 {
t.Fatalf("the migration must keep the cover: %d %v", coverRows, err)
}
// The new tables start empty and hold one row per chapter and kind.
var chapterRows int64
if err := db.Model(&ChapterAttachment{}).Count(&chapterRows).Error; err != nil || chapterRows != 0 {
t.Fatalf("an upgrade must not invent chapter attachments: %d %v", chapterRows, err)
}
entry := ChapterAttachment{ChapterID: chapter.ID, Kind: attachmentAudio, OwnerID: 41, Mime: "audio/mpeg",
ByteSize: 4, SHA256: contentSHA("ID3x"), Bytes: []byte("ID3x"), CreatedAt: saved, UpdatedAt: saved}
if err := db.Create(&entry).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&entry).Error; err == nil {
t.Fatal("a chapter must hold only one attachment per kind")
}
unknown := entry
unknown.Kind = "video"
if err := db.Create(&unknown).Error; err == nil {
t.Fatal("an unknown attachment kind must be rejected")
}
position := ChapterPlaybackPosition{OwnerID: 41, ChapterID: chapter.ID, PositionSeconds: 7, UpdatedAt: saved}
if err := db.Create(&position).Error; err != nil {
t.Fatal(err)
}
// Rolling the marker back for a binary rollback and upgrading again keeps the rows.
if err := db.Exec("UPDATE lexgo_schema SET version=8 WHERE id=1").Error; err != nil {
t.Fatal(err)
}
if err := Migrate(db); err != nil {
t.Fatal(err)
}
if err := db.Model(&ChapterAttachment{}).Count(&chapterRows).Error; err != nil || chapterRows != 1 {
t.Fatalf("re-upgrade must keep the chapter attachment: %d %v", chapterRows, err)
}
var positions int64
if err := db.Model(&ChapterPlaybackPosition{}).Count(&positions).Error; err != nil || positions != 1 {
t.Fatalf("re-upgrade must keep the chapter position: %d %v", positions, err)
}
// Deleting the chapter removes both, so nothing can outlive its chapter.
if err := db.Where("id = ?", chapter.ID).Delete(&Chapter{}).Error; err != nil {
t.Fatal(err)
}
if err := db.Model(&ChapterAttachment{}).Count(&chapterRows).Error; err != nil || chapterRows != 0 {
t.Fatalf("deleting a chapter must remove its attachments: %d %v", chapterRows, err)
}
if err := db.Model(&ChapterPlaybackPosition{}).Count(&positions).Error; err != nil || positions != 0 {
t.Fatalf("deleting a chapter must remove its positions: %d %v", positions, err)
}
}
func TestMigrationFromV9AddsChapterAuthor(t *testing.T) {
db := emptyMigrationDB(t)
statements := []string{"CREATE TABLE lexgo_schema (id INT PRIMARY KEY,version INT,product VARCHAR(32))", "INSERT INTO lexgo_schema VALUES (1,9,'lexgo')"}
statements = append(statements, schemaV2Statements...)
statements = append(statements, schemaV3Statements...)
statements = append(statements, schemaV4Statements...)
statements = append(statements, schemaV5Statements...)
statements = append(statements, schemaV6Statements...)
statements = append(statements, schemaV7Statements...)
statements = append(statements, schemaV8Statements...)
statements = append(statements, schemaV9Statements...)
for _, statement := range statements {
if err := db.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
// The v9 chapter table has no author column, which is what this migration adds.
if err := db.Exec("ALTER TABLE lexgo_chapters DROP COLUMN author").Error; err == nil {
// A fresh v3 table already contains the column, so it is dropped to model an older database.
t.Log("dropped the author column created by the v3 statements")
}
if err := db.Exec("INSERT INTO sys_user (user_id,username,password,role_id) VALUES (51,'fixture_v9','fictional-not-a-real-hash',2)").Error; err != nil {
t.Fatal(err)
}
saved := stamp(time.Now())
book := Book{OwnerID: 51, Title: "Fictional v9 book", Language: "en", CreatedAt: saved, UpdatedAt: saved}
if err := db.Create(&book).Error; err != nil {
t.Fatal(err)
}
text := "Curiosity opens the first door.\n"
// The row is written with raw SQL: this database models a version before the author column
// existed, so the Go struct that carries the field cannot be used yet.
if err := db.Exec("INSERT INTO lexgo_chapters (book_id, owner_id, ordinal, title, original_text, char_count, content_sha256, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)",
book.ID, 51, 1, "Fictional v9", text, len([]rune(text)), contentSHA(text), statusReady, saved, saved).Error; err != nil {
t.Fatal(err)
}
var chapter Chapter
if err := db.Where("book_id = ?", book.ID).First(&chapter).Error; err != nil {
t.Fatal(err)
}
if err := CheckSchema(db); err == nil {
t.Fatal("old schema accepted before explicit migration")
}
if err := Migrate(db); err != nil {
t.Fatal(err)
}
if err := CheckSchema(db); err != nil {
t.Fatal(err)
}
// The column exists, is empty for existing rows, and survives a rolled-back marker.
var restored Chapter
if err := db.Where("id = ?", chapter.ID).First(&restored).Error; err != nil {
t.Fatal(err)
}
if restored.Author != "" || restored.Title != chapter.Title {
t.Fatalf("migration changed the chapter: %+v", restored)
}
if err := db.Exec("UPDATE lexgo_chapters SET author = ? WHERE id = ?", "虚构作者", chapter.ID).Error; err != nil {
t.Fatal(err)
}
if err := db.Exec("UPDATE lexgo_schema SET version=9 WHERE id=1").Error; err != nil {
t.Fatal(err)
}
// Re-running the migration must not fail on the column that is already there.
if err := Migrate(db); err != nil {
t.Fatal(err)
}
if err := db.Where("id = ?", chapter.ID).First(&restored).Error; err != nil {
t.Fatal(err)
}
if restored.Author != "虚构作者" {
t.Fatalf("re-upgrade must keep the author, got %q", restored.Author)
}
}
func TestMigrationFromV10AddsBookAuthor(t *testing.T) {
db := emptyMigrationDB(t)
statements := []string{"CREATE TABLE lexgo_schema (id INT PRIMARY KEY,version INT,product VARCHAR(32))", "INSERT INTO lexgo_schema VALUES (1,10,'lexgo')"}
statements = append(statements, schemaV2Statements...)
statements = append(statements, schemaV3Statements...)
statements = append(statements, schemaV4Statements...)
statements = append(statements, schemaV5Statements...)
statements = append(statements, schemaV6Statements...)
statements = append(statements, schemaV7Statements...)
statements = append(statements, schemaV8Statements...)
statements = append(statements, schemaV9Statements...)
for _, statement := range statements {
if err := db.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
// Model the database v10 left behind: a book table without the author column.
if err := db.Exec("ALTER TABLE lexgo_books DROP COLUMN author").Error; err == nil {
t.Log("dropped the author column created by the v3 statements")
}
if err := db.Exec("INSERT INTO sys_user (user_id,username,password,role_id) VALUES (61,'fixture_v10','fictional-not-a-real-hash',2)").Error; err != nil {
t.Fatal(err)
}
saved := stamp(time.Now())
if err := db.Exec("INSERT INTO lexgo_books (id, owner_id, title, language, created_at, updated_at) VALUES (?,?,?,?,?,?)",
900, 61, "Fictional v10 book", "en", saved, saved).Error; err != nil {
t.Fatal(err)
}
if err := CheckSchema(db); err == nil {
t.Fatal("old schema accepted before explicit migration")
}
if err := Migrate(db); err != nil {
t.Fatal(err)
}
if err := CheckSchema(db); err != nil {
t.Fatal(err)
}
var book Book
if err := db.Where("id = ?", 900).First(&book).Error; err != nil {
t.Fatal(err)
}
if book.Author != "" || book.Title != "Fictional v10 book" {
t.Fatalf("migration changed the book: %+v", book)
}
if err := db.Exec("UPDATE lexgo_books SET author = ? WHERE id = ?", "虚构书籍作者", 900).Error; err != nil {
t.Fatal(err)
}
// Replaying the migration must not fail on the column that is already there.
if err := db.Exec("UPDATE lexgo_schema SET version=10 WHERE id=1").Error; err != nil {
t.Fatal(err)
}
if err := Migrate(db); err != nil {
t.Fatal(err)
}
if err := db.Where("id = ?", 900).First(&book).Error; err != nil {
t.Fatal(err)
}
if book.Author != "虚构书籍作者" {
t.Fatalf("re-upgrade must keep the book author, got %q", book.Author)
}
}
+12
View File
@@ -35,6 +35,7 @@ var OpsTables = []string{
"lexgo_books", "lexgo_chapters", "lexgo_ingest_jobs", "lexgo_dictionaries",
"lexgo_terms", "lexgo_term_reviews", "lexgo_review_answers", "lexgo_chapter_progress",
"lexgo_book_attachments", "lexgo_playback_positions",
"lexgo_chapter_attachments", "lexgo_chapter_playback_positions",
}
var opsSystemSchemas = map[string]bool{
@@ -556,6 +557,17 @@ func OpsVerify(client *gorm.DB, database string, manifestPath string) ([]OpsChec
if err := linked("SELECT COUNT(*) FROM "+positions+" p LEFT JOIN "+book+" b ON b.id = p.book_id WHERE b.id IS NULL", "播放位置都指向存在的书"); err != nil {
return checks, err
}
// Chapter attachments and their positions must belong to a chapter that still exists.
chapter_attachments, chapter_positions := table("lexgo_chapter_attachments"), table("lexgo_chapter_playback_positions")
if err := linked("SELECT COUNT(*) FROM "+chapter_attachments+" a LEFT JOIN "+chapter+" c ON c.id = a.chapter_id WHERE c.id IS NULL", "章节附件都指向存在的章节"); err != nil {
return checks, err
}
if err := linked("SELECT COUNT(*) FROM "+chapter_attachments+" a JOIN "+chapter+" c ON c.id = a.chapter_id WHERE a.owner_id <> c.owner_id", "章节附件归属与章节归属一致"); err != nil {
return checks, err
}
if err := linked("SELECT COUNT(*) FROM "+chapter_positions+" p LEFT JOIN "+chapter+" c ON c.id = p.chapter_id WHERE c.id IS NULL", "章节播放位置都指向存在的章节"); err != nil {
return checks, err
}
// The audit tables must not gain a column that could hold a credential or private content.
for _, name := range []string{"lexgo_login_logs", "lexgo_operation_logs"} {