From ab23ff60e5afc6fa170089476af6a2dfabf07364 Mon Sep 17 00:00:00 2001
From: QiuSW
Date: Tue, 15 Sep 2026 18:39:56 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B9=A6=E7=B1=8D=E5=B0=81=E9=9D=A2?=
=?UTF-8?q?=E4=B8=8E=E9=9F=B3=E9=A2=91=E9=99=84=E4=BB=B6=E3=80=81=E6=92=AD?=
=?UTF-8?q?=E6=94=BE=E5=99=A8=E4=B8=8E=E6=92=AD=E6=94=BE=E4=BD=8D=E7=BD=AE?=
=?UTF-8?q?=20(#21)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- schema v8:lexgo_book_attachments(按 book_id+kind 唯一,bytes MEDIUMBLOB)与
lexgo_playback_positions(owner_id+book_id 主键),只用可重放 DDL,外键级联删书
- 后端:按文件头 magic bytes 判定类型(MP3 / JPG / PNG / WebP,含 WebP 维度解析),
音频 ≤20 MiB、封面 ≤2 MiB 且 ≤4096²;上传/替换/移除;binaryResponse 交给
http.ServeContent 提供 Range 206、416、ETag 与 304;播放位置 upsert,替换音频时重置
- 学习端:带凭据的 fetch + 对象 URL(令牌不进 URL),书库封面、书籍页「音频与封面」
区块、阅读页常驻播放器(播放/暂停、进度、0.75–1.5 倍速、错误重试、位置上报)
- 测试:Go 86 项(新增附件单测/集成与 v7→v8 迁移)、学习端 157 单测与 26 项 E2E
- 顺带修掉 e2e/phrase.spec.ts 的偶发失败:重试条件改为断言期望词数、重读坐标、关面板后重试
- 文档:Architecture / Business-Rules / Local-Development / Requirements / Home /
Deployment-and-Operations(备份范围含附件与体积提示)
---
AGENTS.md | 1 +
README.md | 4 +-
docs/02-architecture-and-code-map.md | 28 +-
docs/03-business-rules-and-glossary.md | 20 +-
docs/04-local-development-and-verification.md | 37 +-
docs/09-product-requirements-overview.md | 10 +-
docs/11-deployment-and-operations.md | 9 +-
docs/README.md | 6 +-
learner/e2e/attachments.spec.ts | 194 +++++++
learner/e2e/edit.spec.ts | 4 +-
learner/e2e/mobile-display.spec.ts | 8 +-
learner/e2e/mobile-fixtures.ts | 18 +-
learner/e2e/phrase.spec.ts | 55 +-
learner/src/__tests__/attachments.spec.ts | 249 +++++++++
learner/src/components/AudioPlayer.vue | 184 +++++++
learner/src/stores/library.ts | 180 ++++++-
learner/src/stores/session.ts | 25 +-
learner/src/style.css | 28 +
learner/src/views/BookView.vue | 120 ++++-
learner/src/views/LibraryView.vue | 11 +
learner/src/views/ReaderView.vue | 18 +
scripts/ops.py | 4 +
server/app/lexgo/attachment.go | 495 ++++++++++++++++++
server/app/lexgo/attachment_test.go | 459 ++++++++++++++++
server/app/lexgo/database.go | 33 +-
server/app/lexgo/library.go | 68 ++-
server/app/lexgo/migration_test.go | 83 +++
server/app/lexgo/ops.go | 12 +
server/app/lexgo/router.go | 29 +
server/app/lexgo/terms.go | 1 +
30 files changed, 2339 insertions(+), 54 deletions(-)
create mode 100644 learner/e2e/attachments.spec.ts
create mode 100644 learner/src/__tests__/attachments.spec.ts
create mode 100644 learner/src/components/AudioPlayer.vue
create mode 100644 server/app/lexgo/attachment.go
create mode 100644 server/app/lexgo/attachment_test.go
diff --git a/AGENTS.md b/AGENTS.md
index aa30d57..23e99c7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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 用户确认 #21 附件口径:每本书可选**一张封面**(JPG/PNG/WebP,≤2 MiB,≤4096×4096)与**一段 MP3**(≤20 MiB),**按文件内容 magic bytes 判定类型、不看扩展名**;附件与播放位置**存 MySQL**(dump 即完整备份、属主校验一致、删书无孤儿文件);读取需会话并支持 **HTTP Range(206)**与 ETag/304,界面用带凭据的 fetch + 对象 URL(**令牌不进 URL**,代价是无渐进式流式播放);不自动播放、**不与文本同步**、不解析时长;播放位置按账号+书籍保存(播放中每 5 秒与暂停/离开上报),**替换或移除音频时重置**;跨账号一律 404;schema v8 新增两张表(可重放 DDL,回退写回版本号 7)。范围外:多音轨/分章音轨、字幕同步、逐句跟读、转写、TTS、转码、自动搜图、在线音频地址、公开分享。**浏览器是否真实解码播放尚未验证**(测试中媒体元素被桩替换),真机听感待人工确认。
- 2026-09-15 用户确认 #15 交付口径:只交付本机可复现的安装/备份/恢复材料并在本机演练,**不对外部署、不创建 release/tag、不邀请用户**;生产入口与 HTTPS 只写入文档;前端由反向代理托管 dist(不改后端代码);试用实例从**空库**开始、管理员由显式 bootstrap 建立、不带默认密码;备份=MySQL 全库 dump + 环境配置(凭据只存运维密码库,不进仓库/日志),不新增定时备份;性能用人造数据集实测并写明环境,只作观察不给承诺。备份/恢复规则:`restore` 必须 `--confirm`、默认只写空库、覆盖需 `--force`、库名必须含 lexgo 且不能是系统库、拒绝带 CREATE DATABASE/USE 的 dump,恢复前后比对源库逐表内容校验和。**附件(#21)尚未实施,恢复契约目前只覆盖数据库**;真实回滚、HTTPS、多机与定时备份仍未验证。部署与运维规则见 Wiki 页 `Deployment-and-Operations`(镜像 `docs/11-deployment-and-operations.md`)。
- 2026-09-15 用户确认 #14 显示与键盘口径:`theme ∈ {浅色,深色,跟随系统}`(默认跟随系统)与正文字号 `{标准,大,特大}`(1.0/1.15/1.3)**按账号保存在本机** `lexgo-learner-display:<账号 id>`,切换账号即换成该账号偏好或默认,退出回到默认,**不跨设备同步**;字号经 `--reader-font-scale` 只作用于阅读面(正文、释义内容、复习卡),不做全局缩放;深色用 `html[data-theme]` + Element Plus 的 `html.dark`,`style.css` 的 `:root` 是文件内仅有的颜色字面量;阅读位置按账号+章节保存滚动比例与该章 `content_sha256`,**正文换新版本后不恢复**;复习页 `空格`/`Enter` 显示答案、`1/2/3` 评分,输入类控件与聚焦按钮的按键不被劫持,带修饰键不拦截;移动验证用 390×844+`hasTouch` 的 Playwright `mobile` 项目(桌面项目 `testIgnore: mobile-*`),**真机长按选择与手感仍需人工确认**,不得用模拟设备结果冒充真机。本单无 schema 与接口变化。
- 2026-09-15 用户确认 #13 完成阅读与进度口径:`POST /api/v1/chapters/:id/complete` 只记已读、**不批量改变词语状态或等级**,只有 `ready` 章节可标记(其他 409),重复调用返回同一行且带 `duplicate`(不移动时间、不重复计数);完成记录保存标记时的 `content_sha256`,**正文新版本后该章回到未读**(记录保留,重读后更新同一行),只改标题不影响,章节删除随外键级联;`GET /api/v1/progress` 统计只含本人与当前语言,已读与分母都只算可阅读(`ready`)章节,已知/学习中/新词/忽略分开计数,`dueNow` 与到期复习队列共用 `dueTermsQuery` 与同一服务端时钟;schema 升到 v7(新表 `lexgo_chapter_progress`,不用 ALTER TABLE),需显式 migrate。不做每日目标、日历、难度评分、统计导出与取消已读,也不做 X10 批量标已知。
diff --git a/README.md b/README.md
index 8aebe1e..4ba5c8c 100644
--- a/README.md
+++ b/README.md
@@ -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 已实现待验收;#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`;随后执行 `python scripts/server.py serve`。两端分别通过 `npx --yes pnpm@9.15.1 --dir admin dev`、`npx --yes pnpm@9.15.1 --dir learner dev` 启动,首次需安装锁定依赖。
+运行前在 MySQL 8 中准备项目专用空库,复制 `.env.example` 为忽略的 `.env.local` 并填写本机连接及初始管理员密码。首次执行 `python scripts/server.py migrate`、`python scripts/server.py bootstrap`(等价纯二进制路径:`./server/lexgo migrate` / `bootstrap`);随后执行 `python scripts/server.py serve`。两端分别通过 `npx --yes pnpm@9.15.1 --dir admin dev`、`npx --yes pnpm@9.15.1 --dir learner dev` 启动,首次需安装锁定依赖。书籍封面与音频附件(#21)存于数据库,包含在 `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)。账号使用用户名,无需邮箱;没有随代码交付的默认密码。
diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md
index 98c342a..463dcb0 100644
--- a/docs/02-architecture-and-code-map.md
+++ b/docs/02-architecture-and-code-map.md
@@ -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: fabdaf13861b4cae9ad6cd66a5bd904c08361f73
-synchronized_at: 2026-09-15T09:08:48Z
+wiki_revision: 3b785a11352776e36656da59ef9928a2cbb42694
+synchronized_at: 2026-09-15T10:33:06Z
# 架构与代码地图
@@ -410,3 +410,27 @@ schema v7 新增 `lexgo_chapter_progress`:一章一行(`chapter_id` 主键
**两条通道**:Go 二进制提供数据库层的备份、恢复与校验(`lexgo backup|restore|verify`),Python 工具提供依赖检查、建库与 HTTP 接口级的两账号闭环(`ops.py smoke` / `verify --api`)。两者共用同一份 `LEXGO_*` 配置、同一套 manifest 与安全规则;2026-09-15 交叉验证两条通道可互相恢复对方的备份。
**视图与进程**:后端单二进制监听 `127.0.0.1:8000`;两份 SPA 由反向代理托管 `dist`,反代把 `/api/` 转发到后端并把未知路径回落到 `index.html`;本机开发用 supervisor 托管 `lexgo-api`/`lexgo-learner`/`lexgo-admin` 三个 program。
+
+## #21 书籍音频与封面附件(2026-09-15)
+
+schema v8 新增两张表,都只用可重放的 `CREATE TABLE IF NOT EXISTS`:
+
+| 表 | 结构 |
+|---|---|
+| `lexgo_book_attachments` | 主键 `(book_id, kind)`,`kind ∈ {audio, cover}`;`owner_id`、`mime`、`byte_size`、`sha256`、`bytes MEDIUMBLOB`、时间戳;外键级联到书籍与账号。一本书最多一段音频、一张封面,替换即覆盖同一行 |
+| `lexgo_playback_positions` | 主键 `(owner_id, book_id)`,`position_seconds`、`updated_at`;外键级联到账号与书籍 |
+
+**文件为什么存进 MySQL**:这样一份 dump 仍然是完整备份、附件与其它私有行走同一套属主校验、删除书籍不可能留下孤儿文件;代价是音频会增大数据库体积(单文件上限 20 MiB 已在文档写明)。
+
+`server/app/lexgo/attachment.go` 集中实现:`sniffAttachment`(按文件头 magic bytes 判定类型,MP3 接受 ID3 或帧同步,图片接受 JPG/PNG/WebP 签名)、`coverDimensions`(JPEG/PNG 用标准库解码,WebP 读 VP8X/VP8/VP8L 头)、`validateAttachment`(音频 ≤ 20 MiB、封面 ≤ 2 MiB 且 ≤ 4096×4096)、`SaveAttachment`(先校验后 upsert,替换音频同时清空进度)、`DeleteAttachment`、`BookAttachmentFile`、`BookAttachmentsFor`、`SavePlaybackPosition`。
+
+| 接口 | 行为 |
+|---|---|
+| `POST /api/v1/books/:id/audio`、`.../cover` | multipart 单文件;成功返回附件元数据;替换即覆盖;`413` 超限、`400` 类型或内容非法、他人 `404` |
+| `DELETE /api/v1/books/:id/audio`、`.../cover` | 移除附件;移除音频同时删除该账号的进度行 |
+| `GET /api/v1/books/:id/audio`、`.../cover` | 二进制响应,需会话;`respond` 支持 `binaryResponse`,交给 `http.ServeContent` 处理 **Range(206)**、`416`、`If-Modified-Since`,并按内容摘要给出 `ETag` 与 `304` |
+| `PUT /api/v1/books/:id/playback` | `{positionSeconds}`,upsert,只写本人;音频不存在时 `404` |
+| `GET /api/v1/books` | 增加 `coverVersion`(封面内容摘要,用于缓存与刷新判定)与 `hasAudio` |
+| `GET /api/v1/books/:id`、`GET /api/v1/chapters/:id` | 书籍对象带 `attachments`:封面/音频元数据与该账号的 `playbackSeconds` |
+
+学习端:`stores/library.ts` 用带鉴权的 `session.requestBlob` 取回字节并转成对象 URL(**不把令牌放进 URL**),库列表批量预取封面,阅读器按需取音频;`components/AudioPlayer.vue` 是播放器(播放/暂停、进度、0.75–1.5 倍速、错误重试,播放中每 5 秒与暂停/离开时上报位置);`views/LibraryView.vue` 显示封面(`aria-hidden` 的重复链接,标题链接仍是唯一可访问入口)、`views/BookView.vue` 新增「音频与封面」区块(上传/替换/移除、像素与体积提示、失败保留旧附件)、`views/ReaderView.vue` 在正文上方放常驻播放器条。
diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md
index aa74316..7bded2b 100644
--- a/docs/03-business-rules-and-glossary.md
+++ b/docs/03-business-rules-and-glossary.md
@@ -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: 5f2912edd09256cf5fc52059145d65918e99f9cb
-synchronized_at: 2026-09-15T08:27:38Z
+wiki_revision: 34a1994dae20957a68f2bd60e3a7dd97530cffac
+synchronized_at: 2026-09-15T10:33:08Z
# 业务规则与术语
@@ -304,3 +304,19 @@ exact优先;未命中再按WordNet异常表/词尾规则查候选,词性顺
**安装规则**:空库不含默认密码与任何演示数据;管理员只能由显式 `bootstrap` 建立,库中已有账号时拒绝再次执行、不覆盖既有管理员;应用账号只对该库拥有最小权限,不使用管理员账号运行应用;服务默认只监听 `127.0.0.1`,对外由反向代理转发。
**验收账号规则**:试用验证使用虚构的演练账号,密码由演练进程生成、只经环境变量传入,不写入磁盘、日志、工单或截图。
+
+## #21 附件与播放规则(2026-09-15)
+
+**归属与可见性**:附件属于书籍所有者。任何跨账号的读取、替换、移除或位置上报都返回 `404`;未登录返回 `401`。附件与播放进度都按账号隔离,同一本书的播放位置不共享。
+
+**格式与上限**:音频只接受 **MP3**(ID3 标记或 MPEG 帧同步),单文件 ≤ **20 MiB**;封面只接受 **JPG、PNG、WebP**,单文件 ≤ **2 MiB**,像素 ≤ **4096×4096**。类型**按文件内容判定,不看扩展名或客户端声明的 MIME**:把 SVG 改名成 `.png`、把文本改名成 `.mp3` 都会被拒绝。超限返回 `413`,类型或内容非法返回 `400` 并给出可读原因。首版只收 MP3,其它格式待桌面与手机浏览器实测后再谈,不承诺任意格式。
+
+**替换与失败**:替换即覆盖同一行,一本书每个类型只有一条记录。**校验通过后才写入**,因此被拒绝的上传不会损坏已存在的附件。替换音频会**清空播放位置**——旧位置对新文件没有意义;移除音频同样删除位置记录。
+
+**播放行为**:不自动播放,必须由用户点击;提供播放/暂停、拖动进度与 0.75/1.0/1.25/1.5 倍速。**音频不与文本同步**:没有时间戳、字幕或逐句对齐,也不做转写。播放中每 5 秒、暂停时与离开页面时上报位置;服务端只做上界校验(0 ≤ 秒 ≤ 24 小时)与 upsert,并发以最后一次写入为准,不解析音频时长(时长来自浏览器)。
+
+**传输与缓存**:附件读取需要会话,支持 HTTP Range(拖动进度只需取所需片段),并在响应里带内容摘要作为 `ETag`,命中即返回 `304`。前端用带凭据的 fetch 取字节再交给 `
`/`
+
{{ chapter.title }}
{{ statusLabel(chapter.status) }}
diff --git a/scripts/ops.py b/scripts/ops.py
index 87e570f..b9066c1 100644
--- a/scripts/ops.py
+++ b/scripts/ops.py
@@ -42,6 +42,7 @@ TABLES = [
"sys_user", "lexgo_spaces", "lexgo_sessions", "lexgo_login_logs", "lexgo_operation_logs",
"lexgo_books", "lexgo_chapters", "lexgo_ingest_jobs", "lexgo_dictionaries",
"lexgo_terms", "lexgo_term_reviews", "lexgo_review_answers", "lexgo_chapter_progress",
+ "lexgo_book_attachments", "lexgo_playback_positions",
]
SCHEMA_VERSION = 7
@@ -470,6 +471,9 @@ def integrity_checks(database):
add("等级只出现在学习中词条", int(scalar(database, "SELECT COUNT(*) FROM lexgo_terms WHERE (status='learning' AND (level<1 OR level>7)) OR (status<>'learning' AND level<>0)", "0") or 0) == 0)
add("完成记录都指向存在的章节", int(scalar(database, "SELECT COUNT(*) FROM lexgo_chapter_progress p LEFT JOIN lexgo_chapters c ON c.id=p.chapter_id WHERE c.id IS NULL", "0") or 0) == 0)
add("词条语言与所属空间一致", int(scalar(database, "SELECT COUNT(*) FROM lexgo_terms t JOIN lexgo_spaces s ON s.owner_id=t.owner_id WHERE t.language<>s.language", "0") or 0) == 0)
+ add("附件都指向存在的书", int(scalar(database, "SELECT COUNT(*) FROM lexgo_book_attachments a LEFT JOIN lexgo_books b ON b.id=a.book_id WHERE b.id IS NULL", "0") or 0) == 0)
+ add("附件归属与书归属一致", int(scalar(database, "SELECT COUNT(*) FROM lexgo_book_attachments a JOIN lexgo_books b ON b.id=a.book_id WHERE a.owner_id<>b.owner_id", "0") or 0) == 0)
+ add("播放位置都指向存在的书", int(scalar(database, "SELECT COUNT(*) FROM lexgo_playback_positions p LEFT JOIN lexgo_books b ON b.id=p.book_id WHERE b.id IS NULL", "0") or 0) == 0)
# 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"):
diff --git a/server/app/lexgo/attachment.go b/server/app/lexgo/attachment.go
new file mode 100644
index 0000000..a0ff206
--- /dev/null
+++ b/server/app/lexgo/attachment.go
@@ -0,0 +1,495 @@
+package lexgo
+
+import (
+ "bytes"
+ "encoding/binary"
+ "errors"
+ "image"
+ _ "image/jpeg" // dimension checks for covers
+ _ "image/png"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ admin "go-admin/app/admin/models"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+// A book may carry one audio track and one cover image. Both live in MySQL next to everything else,
+// so a dump is still a complete backup, the owner check is the same as for any other private data,
+// and deleting a book removes its attachments with it. Nothing is written to disk: an uploaded file
+// never becomes a path, and the client file name is only used for display.
+const (
+ attachmentAudio = "audio"
+ attachmentCover = "cover"
+
+ maxAudioBytes = 20 << 20
+ maxCoverBytes = 2 << 20
+ // Covers are downscaled by the browser, so an upload larger than this is refused instead of
+ // being stored at a size nothing will ever display.
+ maxCoverPixels = 4096
+ // A playback position beyond this is treated as a client mistake rather than a real position.
+ maxPlaybackSeconds = 24 * 60 * 60
+)
+
+// BookAttachment is one binary file belonging to a book. book_id plus kind are unique, so replacing
+// a file updates one row instead of accumulating versions.
+type BookAttachment struct {
+ BookID int64 `gorm:"primaryKey"`
+ Kind string `gorm:"primaryKey"`
+ OwnerID int `json:"-"`
+ Mime string `json:"mime"`
+ ByteSize int `json:"byteSize"`
+ SHA256 string `json:"sha256"`
+ Bytes []byte `json:"-"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
+
+func (BookAttachment) TableName() string { return "lexgo_book_attachments" }
+
+// 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 {
+ OwnerID int `gorm:"primaryKey"`
+ BookID int64 `gorm:"primaryKey"`
+ PositionSeconds int `json:"positionSeconds"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
+
+func (PlaybackPosition) TableName() string { return "lexgo_playback_positions" }
+
+// BookAttachmentView is what the client sees: metadata and a version for cache invalidation, never
+// the bytes.
+type BookAttachmentView struct {
+ Kind string `json:"kind"`
+ Mime string `json:"mime"`
+ ByteSize int `json:"byteSize"`
+ Version string `json:"version"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
+
+type BookAttachmentsView struct {
+ Cover *BookAttachmentView `json:"cover"`
+ Audio *BookAttachmentView `json:"audio"`
+ PlaybackSeconds int `json:"playbackSeconds"`
+}
+
+// sniffAttachment checks the actual bytes instead of trusting a file name or a declared type.
+func sniffAttachment(kind string, data []byte) (string, error) {
+ switch kind {
+ case attachmentAudio:
+ if isMP3(data) {
+ return "audio/mpeg", nil
+ }
+ return "", failure(400, "只支持 MP3 音频,请转换为 MP3 后重试")
+ case attachmentCover:
+ mime := sniffImage(data)
+ if mime == "" {
+ return "", failure(400, "封面只支持 JPG、PNG 或 WebP 图片")
+ }
+ return mime, nil
+ }
+ return "", failure(400, "附件类型无效")
+}
+
+// isMP3 accepts both an ID3-tagged file and a bare frame: "ID3" or a frame sync of 11 bits.
+func isMP3(data []byte) bool {
+ if len(data) < 4 {
+ return false
+ }
+ if bytes.HasPrefix(data, []byte("ID3")) {
+ return true
+ }
+ return data[0] == 0xFF && data[1]&0xE0 == 0xE0
+}
+
+func sniffImage(data []byte) string {
+ switch {
+ case bytes.HasPrefix(data, []byte{0xFF, 0xD8, 0xFF}):
+ return "image/jpeg"
+ case bytes.HasPrefix(data, []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}):
+ return "image/png"
+ case len(data) >= 12 && bytes.HasPrefix(data, []byte("RIFF")) && bytes.Equal(data[8:12], []byte("WEBP")):
+ return "image/webp"
+ }
+ return ""
+}
+
+// coverDimensions returns the pixel size of a supported image. JPEG and PNG are decoded by the
+// standard library; WebP is read from its own header because no decoder is imported for it.
+func coverDimensions(mime string, data []byte) (int, int, bool) {
+ if mime == "image/webp" {
+ return webpDimensions(data)
+ }
+ config, _, err := image.DecodeConfig(bytes.NewReader(data))
+ if err != nil {
+ return 0, 0, false
+ }
+ return config.Width, config.Height, true
+}
+
+func webpDimensions(data []byte) (int, int, bool) {
+ if len(data) < 30 {
+ return 0, 0, false
+ }
+ chunk := string(data[12:16])
+ switch chunk {
+ case "VP8X":
+ width := int(data[24]) | int(data[25])<<8 | int(data[26])<<16
+ height := int(data[27]) | int(data[28])<<8 | int(data[29])<<16
+ return width + 1, height + 1, true
+ case "VP8 ":
+ // The frame header starts after the 10-byte chunk header; the 14-bit dimensions sit 6 bytes in.
+ if len(data) < 30 {
+ return 0, 0, false
+ }
+ width := int(binary.LittleEndian.Uint16(data[26:28]) & 0x3FFF)
+ height := int(binary.LittleEndian.Uint16(data[28:30]) & 0x3FFF)
+ return width, height, width > 0 && height > 0
+ case "VP8L":
+ if len(data) < 25 {
+ return 0, 0, false
+ }
+ bits := binary.LittleEndian.Uint32(data[21:25])
+ width := int(bits&0x3FFF) + 1
+ height := int((bits>>14)&0x3FFF) + 1
+ return width, height, true
+ }
+ return 0, 0, false
+}
+
+// validateAttachment enforces the size, type and pixel limits before anything is stored.
+func validateAttachment(kind string, data []byte) (string, error) {
+ switch kind {
+ case attachmentAudio:
+ if len(data) == 0 {
+ return "", failure(400, "音频文件为空")
+ }
+ if len(data) > maxAudioBytes {
+ return "", failure(400, "音频文件不能超过 20 MiB")
+ }
+ case attachmentCover:
+ if len(data) == 0 {
+ return "", failure(400, "封面图片为空")
+ }
+ if len(data) > maxCoverBytes {
+ return "", failure(400, "封面图片不能超过 2 MiB")
+ }
+ default:
+ return "", failure(400, "附件类型无效")
+ }
+ mime, err := sniffAttachment(kind, data)
+ if err != nil {
+ return "", err
+ }
+ if kind == attachmentCover {
+ 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 mime, nil
+}
+
+// readAttachmentUpload reads a single "file" part plus no other fields, with the byte limit applied
+// to the request body itself so an oversized upload is rejected while it is still arriving.
+func readAttachmentUpload(c *gin.Context, limit int64) ([]byte, error) {
+ bad := failure(400, "请选择一个文件后重试")
+ c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit+(64<<10))
+ reader, err := c.Request.MultipartReader()
+ if err != nil {
+ return nil, bad
+ }
+ var content []byte
+ for {
+ part, err := reader.NextPart()
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ if err != nil {
+ if tooLarge(err) {
+ return nil, failure(413, "文件超过允许的大小")
+ }
+ return nil, bad
+ }
+ if part.FormName() != "file" {
+ part.Close()
+ return nil, bad
+ }
+ if content != nil {
+ part.Close()
+ return nil, bad
+ }
+ content, err = readPart(part, limit)
+ part.Close()
+ if err != nil {
+ return nil, err
+ }
+ }
+ if content == nil {
+ return nil, bad
+ }
+ return content, nil
+}
+
+func readPart(part *multipart.Part, limit int64) ([]byte, error) {
+ content, err := io.ReadAll(io.LimitReader(part, limit+1))
+ if err != nil {
+ if tooLarge(err) {
+ return nil, failure(413, "文件超过允许的大小")
+ }
+ return nil, failure(400, "文件读取失败,请重试")
+ }
+ if len(content) == 0 {
+ return nil, failure(400, "文件为空")
+ }
+ if int64(len(content)) > limit {
+ return nil, failure(413, "文件超过允许的大小")
+ }
+ return content, nil
+}
+
+func tooLarge(err error) bool {
+ var maxErr *http.MaxBytesError
+ return errors.As(err, &maxErr) || strings.Contains(err.Error(), "http: request body too large")
+}
+
+// SaveAttachment validates and stores one file for a book the caller owns. A replacement updates the
+// same row only after the new file passed every check, so a rejected upload leaves the old one in
+// place.
+func SaveAttachment(tx *gorm.DB, owner int, bookID int64, kind string, data []byte, now time.Time) (BookAttachmentView, error) {
+ if kind != attachmentAudio && kind != attachmentCover {
+ return BookAttachmentView{}, failure(404, "附件不存在")
+ }
+ if err := lockOwnedBook(tx, owner, bookID, &Book{}); err != nil {
+ return BookAttachmentView{}, err
+ }
+ mime, err := validateAttachment(kind, data)
+ if err != nil {
+ return BookAttachmentView{}, err
+ }
+ ts := stamp(now)
+ row := BookAttachment{BookID: bookID, Kind: kind, OwnerID: owner, Mime: mime,
+ ByteSize: len(data), SHA256: contentSHA(string(data)), Bytes: data, CreatedAt: ts, UpdatedAt: ts}
+ // The identity stays (book_id, kind); the file and its metadata move forward together.
+ if err = tx.Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "book_id"}, {Name: "kind"}},
+ DoUpdates: clause.AssignmentColumns([]string{"owner_id", "mime", "byte_size", "sha256", "bytes", "updated_at"}),
+ }).Create(&row).Error; err != nil {
+ return BookAttachmentView{}, err
+ }
+ 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
+}
+
+func attachmentView(row BookAttachment) BookAttachmentView {
+ return BookAttachmentView{Kind: row.Kind, Mime: row.Mime, ByteSize: row.ByteSize,
+ Version: row.SHA256, UpdatedAt: row.UpdatedAt}
+}
+
+// DeleteAttachment removes one file, and an audio removal also drops the stored position.
+func DeleteAttachment(tx *gorm.DB, owner int, bookID int64, kind string, now time.Time) error {
+ if kind != attachmentAudio && kind != attachmentCover {
+ return failure(404, "附件不存在")
+ }
+ if err := lockOwnedBook(tx, owner, bookID, &Book{}); err != nil {
+ return err
+ }
+ result := tx.Where("book_id = ? AND owner_id = ? AND kind = ?", bookID, owner, kind).Delete(&BookAttachment{})
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected == 0 {
+ return failure(404, "附件不存在")
+ }
+ if kind == attachmentAudio {
+ return tx.Where("owner_id = ? AND book_id = ?", owner, bookID).Delete(&PlaybackPosition{}).Error
+ }
+ return nil
+}
+
+// BookAttachmentFile returns one stored file for the caller's own book.
+func BookAttachmentFile(tx *gorm.DB, owner int, bookID int64, kind string) (BookAttachment, error) {
+ var row BookAttachment
+ err := tx.Where("book_id = ? AND owner_id = ? AND kind = ?", bookID, owner, kind).First(&row).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return BookAttachment{}, failure(404, "附件不存在")
+ }
+ return row, err
+}
+
+// attachmentsByBook loads the attachment metadata for a list of books, so a book list can show
+// covers without a query per row.
+func attachmentsByBook(tx *gorm.DB, owner int, bookIDs []int64) (map[int64]map[string]BookAttachmentView, error) {
+ found := map[int64]map[string]BookAttachmentView{}
+ if len(bookIDs) == 0 {
+ return found, nil
+ }
+ var rows []BookAttachment
+ if err := tx.Select("book_id", "kind", "mime", "byte_size", "sha256", "updated_at").
+ Where("owner_id = ? AND book_id IN ?", owner, bookIDs).Find(&rows).Error; err != nil {
+ return found, err
+ }
+ for _, row := range rows {
+ if found[row.BookID] == nil {
+ found[row.BookID] = map[string]BookAttachmentView{}
+ }
+ found[row.BookID][row.Kind] = attachmentView(row)
+ }
+ return found, nil
+}
+
+func playbackSeconds(tx *gorm.DB, owner int, bookIDs []int64) (map[int64]int, error) {
+ positions := map[int64]int{}
+ if len(bookIDs) == 0 {
+ return positions, nil
+ }
+ var rows []PlaybackPosition
+ if err := tx.Where("owner_id = ? AND book_id IN ?", owner, bookIDs).Find(&rows).Error; err != nil {
+ return positions, err
+ }
+ for _, row := range rows {
+ positions[row.BookID] = row.PositionSeconds
+ }
+ return positions, nil
+}
+
+// BookAttachmentsFor is the view the book page uses.
+func BookAttachmentsFor(tx *gorm.DB, owner int, bookID int64) (BookAttachmentsView, error) {
+ view := BookAttachmentsView{}
+ found, err := attachmentsByBook(tx, owner, []int64{bookID})
+ if err != nil {
+ return view, err
+ }
+ if cover, ok := found[bookID][attachmentCover]; ok {
+ view.Cover = &cover
+ }
+ 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) {
+ if seconds < 0 || seconds > maxPlaybackSeconds {
+ return 0, failure(400, "播放位置无效")
+ }
+ if err := lockOwnedBook(tx, owner, bookID, &Book{}); 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 {
+ return 0, err
+ }
+ if audio == 0 {
+ return 0, failure(404, "这本书没有音频")
+ }
+ ts := stamp(now)
+ row := PlaybackPosition{OwnerID: owner, BookID: bookID, PositionSeconds: seconds, UpdatedAt: ts}
+ if err := tx.Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "owner_id"}, {Name: "book_id"}},
+ DoUpdates: clause.AssignmentColumns([]string{"position_seconds", "updated_at"}),
+ }).Create(&row).Error; err != nil {
+ return 0, err
+ }
+ return seconds, nil
+}
+
+func registerAttachmentRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context, *gorm.DB, admin.SysUser) (any, error)) gin.HandlerFunc, now func() time.Time) {
+ 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) {
+ id, err := pathID(c, "书籍不存在")
+ if err != nil {
+ return nil, err
+ }
+ if err = DeleteAttachment(tx, u.UserId, id, attachmentAudio, now()); err != nil {
+ return nil, err
+ }
+ return gin.H{"deleted": true}, nil
+ }))
+ v.DELETE("/books/:id/cover", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
+ id, err := pathID(c, "书籍不存在")
+ if err != nil {
+ return nil, err
+ }
+ if err = DeleteAttachment(tx, u.UserId, id, attachmentCover, now()); err != nil {
+ return nil, err
+ }
+ return gin.H{"deleted": true}, nil
+ }))
+ 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, "书籍不存在")
+ if err != nil {
+ return nil, err
+ }
+ row, err := BookAttachmentFile(tx, u.UserId, id, attachmentKind)
+ if err != nil {
+ return nil, err
+ }
+ return binaryResponse{ContentType: row.Mime, Bytes: row.Bytes, FileName: fileName,
+ ModifiedAt: row.UpdatedAt, ETag: `"` + row.SHA256 + `"`}, nil
+ }))
+ }
+ v.PUT("/books/:id/playback", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
+ id, err := pathID(c, "书籍不存在")
+ if err != nil {
+ return nil, err
+ }
+ var input struct {
+ PositionSeconds *int `json:"positionSeconds"`
+ }
+ if err = decode(c, &input); err != nil {
+ return nil, err
+ }
+ if input.PositionSeconds == nil {
+ return nil, failure(400, "请提供播放位置")
+ }
+ seconds, err := SavePlaybackPosition(tx, u.UserId, id, *input.PositionSeconds, now())
+ if err != nil {
+ return nil, err
+ }
+ return gin.H{"playbackSeconds": seconds}, nil
+ }))
+}
diff --git a/server/app/lexgo/attachment_test.go b/server/app/lexgo/attachment_test.go
new file mode 100644
index 0000000..12d0950
--- /dev/null
+++ b/server/app/lexgo/attachment_test.go
@@ -0,0 +1,459 @@
+package lexgo
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "image"
+ "image/color"
+ "image/jpeg"
+ "image/png"
+ "mime/multipart"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/gin-gonic/gin"
+)
+
+// ---------------------------------------------------------------- unit rules
+
+func testPNG(t *testing.T, width, height int) []byte {
+ t.Helper()
+ canvas := image.NewRGBA(image.Rect(0, 0, width, height))
+ canvas.Set(0, 0, color.RGBA{R: 10, G: 20, B: 30, A: 255})
+ var buffer bytes.Buffer
+ if err := png.Encode(&buffer, canvas); err != nil {
+ t.Fatal(err)
+ }
+ return buffer.Bytes()
+}
+
+func testJPEG(t *testing.T, width, height int) []byte {
+ t.Helper()
+ canvas := image.NewRGBA(image.Rect(0, 0, width, height))
+ canvas.Set(0, 0, color.RGBA{R: 200, G: 100, B: 50, A: 255})
+ var buffer bytes.Buffer
+ if err := jpeg.Encode(&buffer, canvas, nil); err != nil {
+ t.Fatal(err)
+ }
+ return buffer.Bytes()
+}
+
+// testWebP builds the smallest possible VP8X header: the sniffing and dimension code reads the
+// container header, and no decoder is needed for that.
+func testWebP(width, height int) []byte {
+ data := make([]byte, 30)
+ copy(data[0:4], "RIFF")
+ copy(data[8:12], "WEBP")
+ copy(data[12:16], "VP8X")
+ // The stored value is size-1, little endian, three bytes each.
+ w, h := width-1, height-1
+ data[24], data[25], data[26] = byte(w), byte(w>>8), byte(w>>16)
+ data[27], data[28], data[29] = byte(h), byte(h>>8), byte(h>>16)
+ return data
+}
+
+func testMP3(payload int) []byte {
+ data := make([]byte, 4+payload)
+ copy(data, "ID3")
+ for i := 4; i < len(data); i++ {
+ data[i] = byte(i % 251)
+ }
+ return data
+}
+
+func TestAttachmentTypeSniffing(t *testing.T) {
+ cases := []struct {
+ name string
+ kind string
+ data []byte
+ mime string
+ ok bool
+ }{
+ {"id3 audio", attachmentAudio, testMP3(64), "audio/mpeg", true},
+ {"bare frame", attachmentAudio, []byte{0xFF, 0xFB, 0x90, 0x00}, "audio/mpeg", true},
+ {"wav is not mp3", attachmentAudio, append([]byte("RIFF"), make([]byte, 40)...), "", false},
+ {"text renamed", attachmentAudio, []byte("this is not audio at all.........."), "", false},
+ {"png cover", attachmentCover, testPNG(t, 8, 8), "image/png", true},
+ {"jpeg cover", attachmentCover, testJPEG(t, 8, 8), "image/jpeg", true},
+ {"webp cover", attachmentCover, testWebP(8, 8), "image/webp", true},
+ {"gif is not accepted", attachmentCover, []byte("GIF89a"), "", false},
+ {"svg is not accepted", attachmentCover, []byte(``), "", false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ mime, err := sniffAttachment(tc.kind, tc.data)
+ if tc.ok && (err != nil || mime != tc.mime) {
+ t.Fatalf("got %q %v, want %q", mime, err, tc.mime)
+ }
+ if !tc.ok && err == nil {
+ t.Fatalf("expected a rejection, got %q", mime)
+ }
+ })
+ }
+}
+
+func TestAttachmentLimits(t *testing.T) {
+ if _, err := validateAttachment(attachmentAudio, make([]byte, maxAudioBytes+1)); err == nil {
+ t.Fatal("an oversized audio file must be refused")
+ }
+ if _, err := validateAttachment(attachmentCover, make([]byte, maxCoverBytes+1)); err == nil {
+ t.Fatal("an oversized cover must be refused")
+ }
+ if _, err := validateAttachment(attachmentAudio, nil); err == nil {
+ t.Fatal("an empty file must be refused")
+ }
+ // A cover wider than the pixel limit is refused even though its bytes are small.
+ wide := testPNG(t, maxCoverPixels+1, 4)
+ if len(wide) > maxCoverBytes {
+ t.Skip("the generated image is larger than the byte limit; the pixel rule is covered below")
+ }
+ if _, err := validateAttachment(attachmentCover, wide); err == nil {
+ t.Fatal("a cover above the pixel limit must be refused")
+ }
+ // A JPEG inside the limit is accepted, one above it is refused.
+ inside := testJPEG(t, 4000, 20)
+ if len(inside) > maxCoverBytes {
+ t.Skip("the generated JPEG is larger than the byte limit")
+ }
+ if _, err := validateAttachment(attachmentCover, inside); err != nil {
+ t.Fatalf("a 4000 pixel wide cover is inside the limit: %v", err)
+ }
+ tooWide := testJPEG(t, maxCoverPixels+1, 8)
+ if len(tooWide) <= maxCoverBytes {
+ if _, err := validateAttachment(attachmentCover, tooWide); err == nil {
+ t.Fatal("a cover wider than the pixel limit must be refused")
+ }
+ }
+ if _, err := validateAttachment("other", testPNG(t, 8, 8)); err == nil {
+ t.Fatal("an unknown kind must be refused")
+ }
+}
+
+func TestWebPDimensions(t *testing.T) {
+ width, height, ok := webpDimensions(testWebP(320, 200))
+ if !ok || width != 320 || height != 200 {
+ t.Fatalf("VP8X parsed as %dx%d (%v)", width, height, ok)
+ }
+ if _, _, ok = webpDimensions([]byte("RIFF____WEBP nothing here")); ok {
+ t.Fatal("an unknown chunk must not parse")
+ }
+ if _, _, ok = webpDimensions(testPNG(t, 8, 8)); ok {
+ t.Fatal("a PNG must not be read as WebP")
+ }
+}
+
+func TestPlaybackPositionBounds(t *testing.T) {
+ // The bound is checked before any database work, so a nil transaction is fine here.
+ if _, err := SavePlaybackPosition(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 {
+ 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) {
+ t.Helper()
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+ part, err := writer.CreateFormFile("file", "fictional-upload.bin")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = part.Write(data); err != nil {
+ t.Fatal(err)
+ }
+ if err = writer.Close(); err != nil {
+ t.Fatal(err)
+ }
+ request := httptest.NewRequest("POST", path, bytes.NewReader(body.Bytes()))
+ request.Header.Set("Content-Type", writer.FormDataContentType())
+ if token != "" {
+ request.Header.Set("Authorization", "Bearer "+token)
+ }
+ recorder := httptest.NewRecorder()
+ r.ServeHTTP(recorder, request)
+ var envelope struct {
+ Code int `json:"code"`
+ Msg string
+ Data struct {
+ Attachment BookAttachmentView
+ }
+ }
+ if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
+ t.Fatalf("invalid JSON from %s (status %d): %s", path, recorder.Code, recorder.Body.String()[:min(120, recorder.Body.Len())])
+ }
+ return recorder.Code, envelope.Msg, envelope.Data.Attachment
+}
+
+// fetchRaw reads a binary endpoint without decoding the JSON envelope.
+func fetchRaw(t *testing.T, r *gin.Engine, token, path string, headers map[string]string) *httptest.ResponseRecorder {
+ t.Helper()
+ request := httptest.NewRequest("GET", path, nil)
+ if token != "" {
+ request.Header.Set("Authorization", "Bearer "+token)
+ }
+ for key, value := range headers {
+ request.Header.Set(key, value)
+ }
+ recorder := httptest.NewRecorder()
+ r.ServeHTTP(recorder, request)
+ return recorder
+}
+
+func min(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
+
+func bookDetailOf(t *testing.T, r *gin.Engine, token string, bookID int64) (BookRef, []ChapterSummary) {
+ t.Helper()
+ code, _, data := callRaw(t, r, "GET", fmt.Sprintf("/api/v1/books/%d", bookID), token, nil)
+ if code != 200 {
+ t.Fatalf("book detail status %d", code)
+ }
+ var payload struct {
+ Book BookRef
+ Chapters []ChapterSummary
+ }
+ if err := json.Unmarshal(data, &payload); err != nil {
+ t.Fatal(err)
+ }
+ return payload.Book, payload.Chapters
+}
+
+// TestMySQLBookAttachmentsAndPlayback covers upload, authenticated Range reads, replacement,
+// position handling, ownership and cascade deletion.
+func TestMySQLBookAttachmentsAndPlayback(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"})
+ if code != 201 {
+ t.Fatalf("paste %d", code)
+ }
+ drainIngest(t, db)
+ bookID := pasted.Chapter.BookID
+
+ // 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)
+ }
+ 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)
+ }
+
+ // A cover upload stores the bytes and is visible in the list as a version.
+ 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)
+ }
+ if coverView.Version != contentSHA(string(cover)) {
+ t.Fatal("the cover version must be the content digest")
+ }
+ code, _, listed := callRaw(t, r, "GET", "/api/v1/books", learner.Token, nil)
+ if code != 200 {
+ t.Fatalf("book list %d", code)
+ }
+ var page struct {
+ Items []BookSummary
+ }
+ if err := json.Unmarshal(listed, &page); err != nil {
+ t.Fatal(err)
+ }
+ if len(page.Items) != 1 || page.Items[0].CoverVersion != coverView.Version || page.Items[0].HasAudio {
+ t.Fatalf("book list attachments: %+v", page.Items)
+ }
+
+ // 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 {
+ 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 {
+ 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 {
+ 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, "", fmt.Sprintf("/api/v1/books/%d/cover", bookID), testPNG(t, 8, 8)); 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)
+ }
+}
diff --git a/server/app/lexgo/database.go b/server/app/lexgo/database.go
index ee3b29e..c0aeb40 100644
--- a/server/app/lexgo/database.go
+++ b/server/app/lexgo/database.go
@@ -84,6 +84,9 @@ func Migrate(db *gorm.DB) error {
if current < 7 {
statements = append(statements, schemaV7Statements...)
}
+ if current < 8 {
+ statements = append(statements, schemaV8Statements...)
+ }
for i, s := range statements {
if _, err = conn.ExecContext(ctx, s); err != nil {
var sqlErr *driver.MySQLError
@@ -99,7 +102,35 @@ func Migrate(db *gorm.DB) error {
// SchemaVersion is the version an explicit migration leaves behind, and the
// version the server requires before it starts.
-const SchemaVersion = 7
+const SchemaVersion = 8
+
+// v8 adds the optional book attachments and the per-learner playback position. Both are additive
+// tables, so an older binary can still be rolled back to by writing the version marker back.
+// The file bytes live in the database on purpose: a dump stays a complete backup, the ownership
+// check is the same as for every other private row, and deleting a book cannot leave a file behind.
+var schemaV8Statements = []string{
+ `CREATE TABLE IF NOT EXISTS lexgo_book_attachments (
+ book_id BIGINT UNSIGNED NOT NULL,
+ kind VARCHAR(8) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
+ owner_id BIGINT NOT NULL,
+ mime VARCHAR(64) NOT NULL, byte_size INT NOT NULL,
+ sha256 CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
+ bytes MEDIUMBLOB NOT NULL,
+ created_at DATETIME(3) NOT NULL, updated_at DATETIME(3) NOT NULL,
+ PRIMARY KEY (book_id, kind),
+ CHECK (kind IN ('audio','cover')),
+ FOREIGN KEY (book_id) REFERENCES lexgo_books(id) ON DELETE CASCADE,
+ FOREIGN KEY (owner_id) REFERENCES sys_user(user_id) ON DELETE CASCADE
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
+ `CREATE TABLE IF NOT EXISTS lexgo_playback_positions (
+ owner_id BIGINT NOT NULL, book_id BIGINT UNSIGNED NOT NULL,
+ position_seconds INT NOT NULL DEFAULT 0, updated_at DATETIME(3) NOT NULL,
+ PRIMARY KEY (owner_id, book_id),
+ CHECK (position_seconds >= 0),
+ FOREIGN KEY (owner_id) REFERENCES sys_user(user_id) ON DELETE CASCADE,
+ FOREIGN KEY (book_id) REFERENCES lexgo_books(id) ON DELETE CASCADE
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
+}
// v7 records one learner's own reading completion per chapter. A chapter belongs to exactly
// one owner, so the chapter id alone identifies the row and repeating the completion cannot
diff --git a/server/app/lexgo/library.go b/server/app/lexgo/library.go
index fd17b05..00e070e 100644
--- a/server/app/lexgo/library.go
+++ b/server/app/lexgo/library.go
@@ -112,25 +112,33 @@ type IngestJob struct {
func (IngestJob) TableName() string { return "lexgo_ingest_jobs" }
type BookSummary struct {
- ID int64 `json:"id"`
- Title string `json:"title"`
- Language string `json:"language"`
- ChapterCount int `json:"chapterCount"`
- PendingCount int `json:"pendingCount"`
- ProcessingCount int `json:"processingCount"`
- ReadyCount int `json:"readyCount"`
- FailedCount int `json:"failedCount"`
- CreatedAt time.Time `json:"createdAt"`
- UpdatedAt time.Time `json:"updatedAt"`
+ ID int64 `json:"id"`
+ Title string `json:"title"`
+ Language string `json:"language"`
+ ChapterCount int `json:"chapterCount"`
+ PendingCount int `json:"pendingCount"`
+ ProcessingCount int `json:"processingCount"`
+ ReadyCount int `json:"readyCount"`
+ FailedCount int `json:"failedCount"`
+ // CoverVersion lets the library show a cover and refresh it exactly when the file changes.
+ CoverVersion string `json:"coverVersion,omitempty"`
+ HasAudio bool `json:"hasAudio"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
}
type BookRef struct {
ID int64 `json:"id"`
Title string `json:"title"`
Language string `json:"language"`
+ // Attachments is filled by the endpoints that show a book, so a cover can be rendered from the
+ // list without a request per row.
+ Attachments *BookAttachmentsView `json:"attachments,omitempty"`
}
-func bookRef(book Book) BookRef { return BookRef{book.ID, book.Title, book.Language} }
+func bookRef(book Book) BookRef {
+ return BookRef{ID: book.ID, Title: book.Title, Language: book.Language}
+}
type ChapterSummary struct {
ID int64 `json:"id"`
@@ -491,6 +499,20 @@ func ListBooks(db *gorm.DB, owner int) ([]BookSummary, error) {
if len(ids) == 0 {
return items, nil
}
+ // Attachment metadata is loaded for the whole page at once, so a cover in the list costs one
+ // extra query instead of one per book.
+ found, err := attachmentsByBook(db, owner, ids)
+ if err != nil {
+ return nil, err
+ }
+ for i, item := range items {
+ if cover, ok := found[item.ID][attachmentCover]; ok {
+ items[i].CoverVersion = cover.Version
+ }
+ if _, ok := found[item.ID][attachmentAudio]; ok {
+ items[i].HasAudio = true
+ }
+ }
type row struct {
BookID int64
Status string
@@ -525,6 +547,16 @@ func ListBooks(db *gorm.DB, owner int) ([]BookSummary, error) {
return items, nil
}
+// attachToBookRef fills the attachment metadata for one book the caller owns.
+func attachToBookRef(db *gorm.DB, owner int, ref *BookRef) error {
+ view, err := BookAttachmentsFor(db, owner, ref.ID)
+ if err != nil {
+ return err
+ }
+ ref.Attachments = &view
+ return nil
+}
+
func BookDetail(db *gorm.DB, owner int, bookID int64) (BookRef, []ChapterSummary, error) {
var book Book
if err := db.Where("id = ? AND owner_id = ?", bookID, owner).First(&book).Error; err != nil {
@@ -561,7 +593,11 @@ func BookDetail(db *gorm.DB, owner int, bookID int64) (BookRef, []ChapterSummary
}
items = append(items, summary)
}
- return bookRef(book), items, nil
+ ref := bookRef(book)
+ if err = attachToBookRef(db, owner, &ref); err != nil {
+ return BookRef{}, nil, err
+ }
+ return ref, items, nil
}
// jobIDsByChapter maps chapters to their ingestion job so a client can retry a failed chapter
@@ -627,7 +663,13 @@ func ChapterDetail(db *gorm.DB, owner int, chapterID int64) (ReaderResponse, err
if at, ok := marks[chapter.ID]; ok {
view.ReadAt = &at
}
- return ReaderResponse{Book: bookRef(book), Chapter: view, Navigation: navigation}, nil
+ // 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.
+ ref := bookRef(book)
+ if err = attachToBookRef(db, owner, &ref); err != nil {
+ return ReaderResponse{}, err
+ }
+ return ReaderResponse{Book: ref, Chapter: view, Navigation: navigation}, nil
}
func JobDetail(db *gorm.DB, owner int, jobID int64) (JobView, error) {
diff --git a/server/app/lexgo/migration_test.go b/server/app/lexgo/migration_test.go
index 144700a..12728c1 100644
--- a/server/app/lexgo/migration_test.go
+++ b/server/app/lexgo/migration_test.go
@@ -447,3 +447,86 @@ func TestMigrationFromV3PreservesLibraryAndJobs(t *testing.T) {
t.Fatal("migration must create empty resource table", resources, err)
}
}
+
+func TestMigrationFromV7AddsBookAttachments(t *testing.T) {
+ db := emptyMigrationDB(t)
+ statements := []string{"CREATE TABLE lexgo_schema (id INT PRIMARY KEY,version INT,product VARCHAR(32))", "INSERT INTO lexgo_schema VALUES (1,7,'lexgo')"}
+ statements = append(statements, schemaV2Statements...)
+ statements = append(statements, schemaV3Statements...)
+ statements = append(statements, schemaV4Statements...)
+ statements = append(statements, schemaV5Statements...)
+ statements = append(statements, schemaV6Statements...)
+ statements = append(statements, schemaV7Statements...)
+ for _, statement := range statements {
+ if err := db.Exec(statement).Error; err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := db.Exec("INSERT INTO sys_user (user_id,username,password,role_id) VALUES (31,'fixture_v7','fictional-not-a-real-hash',2)").Error; err != nil {
+ t.Fatal(err)
+ }
+ saved := stamp(time.Now())
+ book := Book{OwnerID: 31, Title: "Fictional v7 book", Language: "en", CreatedAt: saved, UpdatedAt: saved}
+ if err := db.Create(&book).Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := CheckSchema(db); err == nil {
+ t.Fatal("old schema accepted before explicit migration")
+ }
+ if err := Migrate(db); err != nil {
+ t.Fatal(err)
+ }
+ if err := CheckSchema(db); err != nil {
+ t.Fatal(err)
+ }
+ // The upgrade starts with no attachments: nothing is invented for existing books.
+ var attachments int64
+ if err := db.Model(&BookAttachment{}).Count(&attachments).Error; err != nil || attachments != 0 {
+ t.Fatalf("an upgrade must not create attachments: %d %v", attachments, err)
+ }
+ // One audio and one cover per book, and a second row of the same kind is rejected.
+ entry := BookAttachment{BookID: book.ID, Kind: attachmentAudio, OwnerID: 31, Mime: "audio/mpeg",
+ ByteSize: 4, SHA256: contentSHA("ID3"), Bytes: []byte("ID3x"), CreatedAt: saved, UpdatedAt: saved}
+ if err := db.Create(&entry).Error; err != nil {
+ t.Fatal(err)
+ }
+ duplicate := entry
+ duplicate.Mime = "audio/mpeg"
+ if err := db.Create(&duplicate).Error; err == nil {
+ t.Fatal("a book must hold only one attachment per kind")
+ }
+ // A closed set of kinds is enforced by the table itself.
+ unknown := entry
+ unknown.Kind = "video"
+ if err := db.Create(&unknown).Error; err == nil {
+ t.Fatal("an unknown attachment kind must be rejected")
+ }
+ position := PlaybackPosition{OwnerID: 31, BookID: book.ID, PositionSeconds: 12, 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=7 WHERE id=1").Error; err != nil {
+ t.Fatal(err)
+ }
+ 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)
+ }
+ 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)
+ }
+ // Deleting the book removes both, so no attachment or position can outlive its book.
+ if err := db.Where("id = ?", book.ID).Delete(&Book{}).Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := db.Model(&BookAttachment{}).Count(&attachments).Error; err != nil || attachments != 0 {
+ t.Fatalf("deleting a book must remove its attachments: %d %v", attachments, err)
+ }
+ if err := db.Model(&PlaybackPosition{}).Count(&positions).Error; err != nil || positions != 0 {
+ t.Fatalf("deleting a book must remove its positions: %d %v", positions, err)
+ }
+}
diff --git a/server/app/lexgo/ops.go b/server/app/lexgo/ops.go
index 24342c2..6f56017 100644
--- a/server/app/lexgo/ops.go
+++ b/server/app/lexgo/ops.go
@@ -34,6 +34,7 @@ var OpsTables = []string{
"sys_user", "lexgo_spaces", "lexgo_sessions", "lexgo_login_logs", "lexgo_operation_logs",
"lexgo_books", "lexgo_chapters", "lexgo_ingest_jobs", "lexgo_dictionaries",
"lexgo_terms", "lexgo_term_reviews", "lexgo_review_answers", "lexgo_chapter_progress",
+ "lexgo_book_attachments", "lexgo_playback_positions",
}
var opsSystemSchemas = map[string]bool{
@@ -544,6 +545,17 @@ func OpsVerify(client *gorm.DB, database string, manifestPath string) ([]OpsChec
if err := linked("SELECT COUNT(*) FROM "+term+" t JOIN "+space+" s ON s.owner_id = t.owner_id WHERE t.language <> s.language", "词条语言与所属空间一致"); err != nil {
return checks, err
}
+ // Attachments and playback positions must belong to a book that still exists.
+ attachments, positions := table("lexgo_book_attachments"), table("lexgo_playback_positions")
+ if err := linked("SELECT COUNT(*) FROM "+attachments+" a LEFT JOIN "+book+" b ON b.id = a.book_id WHERE b.id IS NULL", "附件都指向存在的书"); err != nil {
+ return checks, err
+ }
+ if err := linked("SELECT COUNT(*) FROM "+attachments+" a JOIN "+book+" b ON b.id = a.book_id WHERE a.owner_id <> b.owner_id", "附件归属与书归属一致"); err != nil {
+ return checks, err
+ }
+ if err := linked("SELECT COUNT(*) FROM "+positions+" p LEFT JOIN "+book+" b ON b.id = p.book_id WHERE b.id IS NULL", "播放位置都指向存在的书"); err != nil {
+ return checks, err
+ }
// 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"} {
diff --git a/server/app/lexgo/router.go b/server/app/lexgo/router.go
index b1cd3c6..8d72802 100644
--- a/server/app/lexgo/router.go
+++ b/server/app/lexgo/router.go
@@ -1,6 +1,7 @@
package lexgo
import (
+ "bytes"
"encoding/json"
"errors"
"github.com/gin-gonic/gin"
@@ -341,6 +342,18 @@ func decodeLimit(c *gin.Context, value any, limit int64) error {
}
return nil
}
+
+// binaryResponse lets a handler answer with bytes instead of the JSON envelope: an attachment is
+// served with its own content type, and ServeContent needs the raw response writer so it can apply
+// Range requests, conditional requests and the correct status codes itself.
+type binaryResponse struct {
+ ContentType string
+ Bytes []byte
+ FileName string
+ ModifiedAt time.Time
+ ETag string
+}
+
func respond(c *gin.Context, status int, data any, err error) {
if err != nil {
var e *apiError
@@ -351,6 +364,22 @@ func respond(c *gin.Context, status int, data any, err error) {
c.JSON(500, gin.H{"code": 500, "msg": "服务暂不可用,请稍后再试"})
return
}
+ if binary, ok := data.(binaryResponse); ok {
+ c.Header("Content-Type", binary.ContentType)
+ if binary.ETag != "" {
+ c.Header("ETag", binary.ETag)
+ // A versioned attachment is private and may be cached until its version changes.
+ c.Header("Cache-Control", "private, max-age=0, must-revalidate")
+ if c.GetHeader("If-None-Match") == binary.ETag {
+ c.Status(http.StatusNotModified)
+ return
+ }
+ }
+ // ServeContent answers Range requests with 206, a bad range with 416, and honours
+ // If-Modified-Since through the modification time.
+ http.ServeContent(c.Writer, c.Request, binary.FileName, binary.ModifiedAt, bytes.NewReader(binary.Bytes))
+ return
+ }
c.JSON(status, gin.H{"code": 200, "data": data})
}
diff --git a/server/app/lexgo/terms.go b/server/app/lexgo/terms.go
index 215bf78..ede3ba4 100644
--- a/server/app/lexgo/terms.go
+++ b/server/app/lexgo/terms.go
@@ -407,4 +407,5 @@ func registerTermRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context
registerPhraseRoutes(v, protect, now)
registerVocabularyRoutes(v, protect, now)
registerProgressRoutes(v, protect, now)
+ registerAttachmentRoutes(v, protect, now)
}
--
2.34.1