Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2fdf405a9 | ||
|
|
42945dec0a | ||
|
|
f565c1ca26 | ||
|
|
4e4fb85d2f | ||
|
|
a412e000c0 | ||
|
|
b1b2e6c6fe | ||
|
|
c293a41714 | ||
|
|
728f7d6c1e | ||
|
|
3fe9bcb058 | ||
|
|
c22f9c3f06 | ||
|
|
771c3df976 | ||
|
|
3ab26afe4a | ||
|
|
0fd661e061 | ||
|
|
8a25b7be8b | ||
|
|
ab23ff60e5 |
@@ -287,6 +287,10 @@ 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-16 用户验收 #37(六轮迭代后):章节级音频与插图、阅读页插图缩略图+弹窗看原图、书籍页章节行只有一个「编辑」并在同一对话框内编辑标题/作者/正文/插图/音频(文本点保存后生效、文件选中后立即上传)、书籍页封面压成一行紧凑控件、书名右侧显示书级作者、「编辑书籍」可改书名与作者。**schema 最终为 v11**:v9 章级附件(书级音频接口下线)、v10 章节作者、v11 书籍作者;v10/v11 用带守卫的条件加列(先查 information_schema 再 ALTER,位于语句列表之后),保持「部分迁移可重试、回退标记后可重新升级」。书籍作者与章节作者互相独立(都不继承)、均为可选自由文本(≤120 字符、留空即清空)。**未实现**:书库卡片显示书级作者、章节作者回落显示书级作者;真机听感与浏览器真实解码播放仍未验证。
|
||||
- 2026-09-16 教训(#37):`main.ts` 漏引入 Element Plus 的 dialog/message-box 样式导致自 #10 起全站对话框退化为文档流块;「只看可见性、不看是否浮层」是验证盲区,现以 `learner/e2e/overlay.ts` 的 expectModalOverlay/expectMessageBoxOverlay 断言浮层定位。
|
||||
- 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 与接口变化。
|
||||
- 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 批量标已知。
|
||||
@@ -296,3 +300,6 @@ MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才
|
||||
- 2026-09-11 用户确认 #10 编辑与删除口径:可改名、改章节标题、编辑章节正文;**只有正文变化才重新处理**,重复保存或改回原内容不新建任务,只改标题不改状态。版本键是 `content_sha256`:任务只在与章节版本一致时才能影响章节,过期版本任务标为 `error_reason=superseded` 且**完全不触碰章节**(认领、恢复扫描、重试都按版本裁决);存储文本重算 SHA 与存储 SHA 不一致时按 `content_changed` 失败。删除为事务内硬删除 + 外键级联,删章后重排序号;个人词条、复习排期与作答记录**不随删除清理**。回收站/撤销、批量操作、章节跨书移动与语言变更不在范围内。编辑器行尾归一为 LF 是已知边界。
|
||||
- 2026-09-11 用户确认 #9 TXT 导入口径:只接受 UTF-8(允许可选 BOM,解码时剥离且不进入原文),非法字节整体拒绝、不使用替换字符;UTF-16 按 BOM 识别后明确拒绝,GB18030 等按非法 UTF-8 拒绝。文件字节上限 2 MiB,之后仍套用单章 100000 码点上限;换行与空白不归一化。文件只在内存中解码、不创建临时文件,客户端文件名不参与任何路径也不入库。解码后交给既有 `PasteBook`/`PasteChapter`,分章(一次提交一章)、`requestId` 幂等与任务恢复与粘贴一致;不改 schema。EPUB/PDF/字幕、UTF-16 转码、按空行自动分章与断点续传不在范围内。
|
||||
- 2026-09-11 用户确认 #8 到期单词复习决策表:固定间隔表 1/2/4/7/15/30/60 天,答对升级封顶 7、答错降级最低 1、再学一次不改等级,答错与再学立即回队;已知/忽略不入队,新保存的词立即到期,显式「学习中 level N」排 now+间隔[N];只有新建或状态/等级实际变化才移动复习时间,编辑释义或例句保留原排期,保存未提及等级时保留已获得等级。到期判定用 UTC 绝对时刻(`due_at ≤ now`),不引入本地日边界。作答按 `answerId` 去重并以 `expectedDueAt` 判定过期标签页,重复提交、网络重发与双标签页都不得重复更新次数与间隔(作答响应 `result` 只取 applied/stale,重放另用 `duplicate` 标记并返回首次结果);`correct_count` 只计答对,`wrong_count` 计答错与再学。短语复习归 #11,进度统计归 #13,不做策略配置 UI(X11)、练习模式(X08)与 FSRS。
|
||||
- 2026-09-16 用户验收 #40 英汉词典与规范音标:ECDICT 常用子集(约 8.2 万条,MIT)提供中文释义、CMUdict(BSD-2)音标按固定 ARPAbet→IPA 映射转写,WordNet 词表用于裁剪子集;三者 sha256 pin 在 `server/zh-dictionary-resource.json`,`scripts/dict_prepare.py` 显式准备(产物不入库、运行时不联网,整包存 MySQL 因而包含在 #15 备份内)。**schema 最终为 v12**(放开 `lexgo_dictionaries` 单行 CHECK、增加 `provider` 列,两步带条件守卫、可重放;旧二进制见 v12 拒绝启动)。音标来源必须可追溯(`cmudict` 为 IPA 转写、`ecdict` 仅字符级规范化),不可靠就不显示(`grok` 属此类,`^` 丢失首音整条丢弃)。查词中文释义在前、英英释义随后,屈折形经 WordNet lemma 回查中文但不复制词目音标;两本词典可分别启停。许可文件随仓库保存,词典归档不提交。
|
||||
- 2026-09-16 用户验收 #42 登录有效期:学习端与管理端共用同一登录接口,有效期由 8 小时改为 **30 天**(具名常量 `SessionLifetime`,绝对有效期、不滑动续期)。已知代价:令牌泄露窗口放大约 90 倍;设备/会话列表与「退出其他设备」未包含,如需另开工单。撤销规则不变(退出撤销当前会话,改密码/停用/重置撤销该账号全部会话,服务端只存 SHA-256 摘要)。
|
||||
- 2026-09-16 用户验收 #32 缺陷修复:编辑正文回到曾用版本时复用该内容版本已有的处理任务行(按 `owner_id + chapter_id + content_sha256` 匹配),避免唯一键冲突;因分支带有仅改镜像的过程提交,PR #39 以 squash 方式合入 main(`f565c1c`),未改写分支历史。
|
||||
|
||||
@@ -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(章级音频、章节插图、章节/书籍作者)已验收;#32(编辑复用版本缺陷)、#40(英汉词典与规范音标)与 #42(登录有效期 30 天)已验收;#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` 启动,首次需安装锁定依赖。书籍封面与书籍作者、章节音频/插图与章节作者、以及播放位置都存于数据库,包含在 `scripts/ops.py backup` 与 `./server/lexgo backup` 的备份范围内;schema 为 v11,需要显式迁移。
|
||||
|
||||
本地入口:学习端 http://127.0.0.1:5173,管理端 http://127.0.0.1:5174。完整安装与测试命令见[开发与验证](docs/04-local-development-and-verification.md)。账号使用用户名,无需邮箱;没有随代码交付的默认密码。
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<el-alert v-if="resources.notice" :title="resources.notice" type="success" :closable="false" show-icon />
|
||||
<el-table v-loading="resources.loading" :data="resources.items" border :empty-text="resources.error ? '加载失败,请重试' : '尚未导入词典'">
|
||||
<el-table-column label="名称" prop="name" min-width="175" />
|
||||
<el-table-column label="提供者" width="100"><template #default="scope">{{ providerLabel(scope.row.provider) }}</template></el-table-column>
|
||||
<el-table-column label="语言" width="80"><template #default>英语</template></el-table-column>
|
||||
<el-table-column label="版本" prop="version" width="80" />
|
||||
<el-table-column label="来源" prop="source" min-width="160" show-overflow-tooltip />
|
||||
@@ -18,18 +19,23 @@
|
||||
<el-card class="import-card">
|
||||
<h2>导入词典</h2>
|
||||
<el-form class="dictionary-form" label-position="top" @submit.prevent="upload">
|
||||
<el-form-item label="词典类型">
|
||||
<el-select v-model="form.format" :disabled="busy" @change="syncForm">
|
||||
<el-option v-for="option in supportedOptions" :key="option.format" :label="option.name + ' · ' + option.description" :value="option.format" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="名称"><el-input v-model="form.name" maxlength="120" :disabled="busy" /></el-form-item>
|
||||
<div class="form-row">
|
||||
<el-form-item label="语言"><el-input model-value="英语" disabled /></el-form-item>
|
||||
<el-form-item label="版本"><el-input v-model="form.version" readonly /></el-form-item>
|
||||
</div>
|
||||
<el-form-item label="来源"><el-input v-model="form.source" readonly /></el-form-item>
|
||||
<el-form-item label="格式"><el-input v-model="form.format" readonly /></el-form-item>
|
||||
<el-form-item label="词典文件">
|
||||
<input id="dictionary-file" ref="fileInput" type="file" accept=".zip,application/zip" aria-label="词典文件" :disabled="busy" @change="selectFile">
|
||||
</el-form-item>
|
||||
<p class="subtle">WordNet 3.0 ZIP · 上限 32 MiB · 英语释义</p>
|
||||
<p v-if="resources.supported?.source"><a :href="downloadURL" target="_blank" rel="noopener noreferrer">下载支持的词典文件</a></p>
|
||||
<p class="subtle">{{ selectedOption?.downloadable ? '官方 ZIP · 上限 32 MiB · 英语释义' : '由 scripts/dict_prepare.py 在本机生成 · 上限 32 MiB · 中文释义与 IPA 音标' }}</p>
|
||||
<p v-if="selectedOption?.downloadable && form.source"><a :href="form.source" target="_blank" rel="noopener noreferrer">下载支持的词典文件</a></p>
|
||||
<p v-else class="subtle">先在服务器运行 python scripts/dict_prepare.py,再导入生成的 .local/dictionaries/zh-dict-v1.zip。</p>
|
||||
<p class="subtle">导入失败时保留当前词典。</p>
|
||||
<p v-if="fileError" role="alert" class="file-error">{{ fileError }}</p>
|
||||
<el-button type="primary" native-type="submit" :loading="resources.saving" :disabled="resources.loading || !file">导入并启用</el-button>
|
||||
@@ -46,17 +52,30 @@ export default {
|
||||
name: 'DictionariesView',
|
||||
components: { BasicLayout },
|
||||
data: () => ({
|
||||
resources: { items: [], supported: null, loading: false, saving: false, error: '', notice: '' },
|
||||
resources: { items: [], supported: [], loading: false, saving: false, error: '', notice: '' },
|
||||
form: { name: 'Princeton WordNet', language: 'en', version: '3.0', source: downloadURL, format: 'wordnet-3.0-zip' },
|
||||
file: null, fileError: '', downloadURL
|
||||
}),
|
||||
computed: { busy() { return this.resources.loading || this.resources.saving } },
|
||||
computed: {
|
||||
busy() { return this.resources.loading || this.resources.saving },
|
||||
supportedOptions() { return Array.isArray(this.resources.supported) ? this.resources.supported : [] },
|
||||
selectedOption() { return this.supportedOptions.find(option => option.format === this.form.format) || null }
|
||||
},
|
||||
watch: { '$store.state.generation': { flush: 'sync', handler() { this.loader.invalidate(); this.clearFile() } } },
|
||||
created() { this.loader = createDictionaryLoader(session, this.resources) },
|
||||
mounted() { this.reload() },
|
||||
beforeUnmount() { this.loader.invalidate(); this.file = null },
|
||||
methods: {
|
||||
reload() { return this.loader.load() },
|
||||
reload() { return this.loader.load().then(() => this.syncForm()) },
|
||||
providerLabel(provider) { return { wordnet: '英英释义', ecdict: '英汉释义' }[provider] || provider || '英英释义' },
|
||||
// The server owns which formats are reviewable, so the form mirrors the selected spec instead
|
||||
// of letting an operator type a version or source that the import would reject.
|
||||
syncForm() {
|
||||
const option = this.selectedOption || this.supportedOptions[0]
|
||||
if (!option) return
|
||||
this.form = { name: option.name, language: 'en', version: option.version, source: option.source, format: option.format }
|
||||
this.clearFile()
|
||||
},
|
||||
statusLabel(status) { return { ready: '可用', disabled: '已停用', unavailable: '不可用' }[status] || '不可用' },
|
||||
clearFile() { this.file = null; this.fileError = ''; if (this.$refs.fileInput) this.$refs.fileInput.value = '' },
|
||||
selectFile(event) {
|
||||
|
||||
@@ -55,3 +55,30 @@ test('page exit or session change discards late responses', async () => {
|
||||
await read
|
||||
assert.deepEqual(state.items, [])
|
||||
})
|
||||
|
||||
test('both dictionaries stay independently switchable and keep their provider', async () => {
|
||||
const { createDictionaryLoader } = await import('../src/dictionaries.mjs')
|
||||
const state = { items: [], supported: null, loading: false, saving: false, error: '', notice: '' }
|
||||
const supported = [
|
||||
{ provider: 'wordnet', name: 'Princeton WordNet', version: '3.0', format: 'wordnet-3.0-zip', source: 'https://example.invalid/wordnet.zip', downloadable: true },
|
||||
{ provider: 'ecdict', name: '简明英汉字典增强版', version: 'ecdict-1.0.28+cmudict-0.7b', format: 'lexgo-zh-dict-v1', source: 'https://example.invalid/ecdict-stardict-28.zip', downloadable: false }
|
||||
]
|
||||
const rows = [
|
||||
{ id: 1, provider: 'wordnet', name: 'Princeton WordNet', enabled: true, status: 'ready' },
|
||||
{ id: 2, provider: 'ecdict', name: '简明英汉字典增强版', enabled: true, status: 'ready' }
|
||||
]
|
||||
const session = {
|
||||
state: { generation: 1 },
|
||||
listDictionaries: async () => ({ items: rows, supported }),
|
||||
importDictionary: async () => ({ resource: rows[1], duplicate: false }),
|
||||
setDictionaryEnabled: async (id, enabled) => ({ resource: { ...rows.find(row => row.id === id), enabled } })
|
||||
}
|
||||
const loader = createDictionaryLoader(session, state)
|
||||
assert.equal(await loader.load(), true)
|
||||
assert.deepEqual(state.supported, supported)
|
||||
assert.equal(state.items.length, 2)
|
||||
assert.equal(await loader.toggle(2, false), true)
|
||||
assert.equal(state.items[1].enabled, false)
|
||||
assert.equal(state.items[0].enabled, true)
|
||||
assert.match(state.notice, /停用/)
|
||||
})
|
||||
|
||||
@@ -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: a482d5ba1fa50d2b570042376134cc0d7a67ef36
|
||||
synchronized_at: 2026-09-16T13:51:35Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# LexGo 项目档案
|
||||
@@ -178,3 +178,26 @@ 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,修复方案待确认)。
|
||||
|
||||
## 当前工程状态(2026-09-16)
|
||||
|
||||
**#37 章节级音频与章节插图**已按用户确认的线框与增量契约实现,并于 2026-09-16 通过用户验收,PR #38 已 fast-forward-only 合入 main。本单经历六轮用户驱动的迭代,最终形态是:音频与插图挂在**章节**上(一章各一张),封面留在**书**上;阅读页在正文上方显示插图**缩略图**、点击在弹窗看原图,播放器只在本章有音频时出现;书籍页章节列表每行只有一个「编辑」,编辑对话框同时管理标题、作者、正文、插图与音频(标题/作者/正文点保存后生效,文件选中后立即上传);书籍页封面压成一行紧凑控件,书名右侧显示书级作者,「编辑书籍」可改书名与作者。**schema 经历三次追加**:v9 新增 `lexgo_chapter_attachments` 与 `lexgo_chapter_playback_positions` 并幂等清空书级音频历史行(书级音频接口与播放接口下线),v10 给 `lexgo_chapters` 加可选作者,v11 给 `lexgo_books` 加可选作者;v10/v11 用带守卫的条件加列(先查 `information_schema` 再 `ALTER TABLE`,在语句列表之后执行),保持「部分迁移可重试、回退标记后可重新升级」这一项目一贯保证。书籍作者与章节作者是两份互相独立、都可选(≤120 字符、留空即清空)的自由文本,章节不继承书级作者。验证:Go 91 项顶层用例、学习端 157 单测与 26 项 E2E、治理 65 项、真实 API+MySQL 52 项、#15 恢复演练第三次 22 项(附件按章节归属、按字节恢复、Range 可用)。
|
||||
|
||||
本单期间还发现并修复了一个从 #10 起就存在的真实缺陷:`main.ts` 漏引入 Element Plus 的 `dialog` 与 `message-box` 样式,导致全站所有对话框与确认框退化成文档流块;修复后补充了断言浮层定位属性的 `expectModalOverlay`/`expectMessageBoxOverlay` 用例,并把「只看可见性、不看是否浮层」这一验证盲区写回文档。未实现的确认项:书库卡片显示书级作者、章节作者继承书级作者;真机听感与浏览器真实解码播放仍需人工确认。
|
||||
|
||||
至此 MVP 原范围 #2~#15 与增补 #21、#37 全部实施并验收;仍待处理 #24 书库列表优化与缺陷 #32。#16 的 #37 复选框已勾选。
|
||||
|
||||
- 2026-09-16 新增第二本英语词典(#40):ECDICT 常用子集提供中文释义、CMUdict 提供 IPA 转写音标,二者由 sha256 pin 的源显式准备;schema v12 放开 `lexgo_dictionaries` 单行约束并增加 `provider` 列,英英与英汉词典可分别启停。音标来源必须可追溯(IPA 转写 / ECDICT 记法),不确定时不显示。许可:ECDICT 仓库 MIT(数据由公开资料整理,再分发前需自行评估)、CMUdict BSD-2,许可证随仓库保存,词典归档不提交进 Git。
|
||||
|
||||
- 2026-09-16 登录有效期由 8 小时改为 **30 天**(#42):两端共用同一登录接口,有效期是具名常量 `SessionLifetime`,绝对有效期、不滑动续期;服务端只存令牌 SHA-256 摘要,改密码/停用/重置仍撤销该账号全部会话。设备与会话管理界面未包含,如需另建工单。
|
||||
- 2026-09-16 用户验收 #40(英汉词典与规范音标):ECDICT 常用子集提供中文释义、CMUdict 提供 IPA 转写音标,两本英语词典可分别启停。**schema 最终为 v12**(放开 `lexgo_dictionaries` 单行 CHECK、增加 `provider` 列,两步带条件守卫、可重放)。音标来源必须可追溯(`cmudict` IPA / `ecdict` 记法),不可靠就不显示。资源由 `scripts/dict_prepare.py` 从 sha256 pin 的源显式准备,产物不入库;许可为 ECDICT MIT 与 CMUdict BSD-2。
|
||||
- 2026-09-16 用户验收 #42(登录有效期):学习端与管理端共用同一登录接口,有效期由 8 小时改为 **30 天**(具名常量 `SessionLifetime`,绝对有效期、不滑动续期)。已知代价:令牌泄露窗口放大约 90 倍;设备/会话列表与"退出其他设备"未包含。
|
||||
- 2026-09-16 用户验收 #32(缺陷修复):编辑正文回到曾经用过的版本时复用该内容版本已有的处理任务行;因分支携带仅改镜像的过程提交,PR #39 以 squash 方式合入 main(`f565c1c`),未改写分支历史。
|
||||
|
||||
@@ -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: 64a83458a283469672e111f423d37d9478a3d0e3
|
||||
synchronized_at: 2026-09-16T13:51:36Z
|
||||
<!-- 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 变化、没有接口变化**:本单新增运维工具、部署文档与完整演练证据。
|
||||
|
||||
@@ -410,3 +392,193 @@ 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,已验收并合入 main)
|
||||
|
||||
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` 在正文上方放常驻播放器条。
|
||||
|
||||
## #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 按用户要求修订两次:先改为缩略图+弹窗,再把附件并入章节编辑对话框)。
|
||||
|
||||
## #37 章节级音频与章节插图(2026-09-15,已验收并合入 main)
|
||||
|
||||
**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 按用户要求修订两次:先改为缩略图+弹窗,再把附件并入章节编辑对话框)。
|
||||
|
||||
## #32 编辑任务按内容版本复用(2026-09-16,已修复并合入 main)
|
||||
|
||||
**没有 schema 变化**:修复的是编辑路径的任务编排,不是数据模型。
|
||||
|
||||
`server/app/lexgo/edit.go` 的 `stageEditJob` 取代了原先「直接创建编辑任务」的写法。一章的一个内容摘要就是一个版本,而 `lexgo_ingest_jobs.request_key` 是唯一的,编辑请求派生的键又是「章节+内容摘要」,因此同一版本第二次成为当前版本时,原写法会撞唯一键、整笔事务回滚并冒泡为通用 500。
|
||||
|
||||
| 情况 | 处理 |
|
||||
|---|---|
|
||||
| 该章还没有描述这个内容版本的任务行 | 用派生键创建新任务(`edit:<章节>:<内容摘要>`,pending) |
|
||||
| 已经有描述这个版本的行(改回曾经用过的正文、或该版本上次处理失败) | **复用该行的身份**,重置为 `pending`、`attempts = 0`、清空 `error_reason` 与 `finished_at`,刷新 `updated_at`;不新增行 |
|
||||
| 复用到的行可能是粘贴版本的任务(键是粘贴请求号) | 正确:一行描述一个内容版本,键只是历史标识;worker 只按 `status = pending` 且「任务内容摘要 = 章节当前内容」认领 |
|
||||
|
||||
不变的门控:`RetryIngestJob` 仍拒绝「任务内容摘要 ≠ 章节当前内容」的旧任务(409),所以旧版本任务不会把新版本章节拉回处理;同一版本重复处理是幂等的;未改动的正文仍然不产生新版本、不新增任务。
|
||||
|
||||
## 英汉词典与规范音标(#40,schema v12)
|
||||
|
||||
一本自托管英语学习工具需要回答"这个词是什么意思",英语学习者往往先要中文。WordNet 只给英英释义,
|
||||
因此 #40 增加第二本词典,并让两本可以分别启停。
|
||||
|
||||
### 资源与准备
|
||||
|
||||
- 数据源:ECDICT(`skywind3000/ECDICT`,Release 1.0.28 的 `ecdict-stardict-28.zip`,仓库 MIT)提供中文释义;
|
||||
CMUdict(`cmusphinx/cmudict`,BSD-2)提供 ARPAbet 音素;WordNet 词表(与 `server/wordnet-resource.json` 同一 pin)
|
||||
用于裁剪子集。
|
||||
- `server/zh-dictionary-resource.json` 记录三者的 URL、sha256、字节数与许可证文件名;**镜像只用于传输,
|
||||
身份是 sha256**,准备脚本逐个校验,不匹配即失败。
|
||||
- `scripts/dict_prepare.py`(Python,仅工具链,不是运行时代码)执行:下载并校验三个源 → 解析 StarDict `.ifo/.idx/.dict`
|
||||
与 CMUdict → 裁剪子集(小写单词,且存在于 WordNet 词表或带 ECDICT 考试标签)→ 生成音标 → 写 ZIP
|
||||
(`manifest.json` + `entries.jsonl.gz`)。产物默认落在 `.local/dictionaries/`,**不入库**。
|
||||
- 产出前强制校验:条目数与 pin 容差(±2000)、音标字符集必须全部落在 IPA 字母表内,否则拒绝产出;
|
||||
`manifest.json` 记录三类音标计数与丢弃计数。
|
||||
|
||||
### 音标来源分层(不做语义猜测)
|
||||
|
||||
1. 词在 CMUdict 中 → 按固定 ARPAbet→IPA 映射转写(`1`→`ˈ`、`2`→`ˌ`、`0` 不标;`AH0`→`ə`、`IY0`→`i`、
|
||||
`ER0`→`ɚ`;单音节不标重音,`cat` 是 `/kæt/` 而不是 `/kˈæt/`)。
|
||||
2. 否则用 ECDICT 记法做**字符级**规范化:`'`→`ˈ`、`,`/`.`→`ˌ`、`:`→`ː`、`ә`(U+04D9)→`ə`(U+0259)、
|
||||
`ε`→`e`、空格/`-`/`=`/`;`/`^` 删除;来源标记为 `ecdict`。
|
||||
3. 音标以 `^` 开头说明原记法已丢失首音(`grok` 存成 `^rɔk`),整条丢弃——宁可没有,也不给错的转写;
|
||||
出现其它非 IPA 字符同样丢弃。两个来源都没有音标就不显示。
|
||||
|
||||
导入时 `provider` 与 `phoneticSource` 一起进入条目,前端据此区分"IPA 转写"与"ECDICT 记法"。
|
||||
|
||||
### 存储与查询
|
||||
|
||||
- schema v12:`addDictionaryProvider` 增加 `provider VARCHAR(32) NOT NULL DEFAULT 'wordnet'`,
|
||||
`dropDictionarySingleSlotCheck` 从 `information_schema` 查到 `CHECK (id = 1)` 后删除。
|
||||
两步都是条件执行、可重放;版本行只在全部成功后推进,旧二进制看到 v12 会拒绝启动并要求显式迁移。
|
||||
- 槽位固定:1 = WordNet(英英),2 = ECDICT(英汉 + 音标)。归档整包存进 `lexgo_dictionaries.archive`,
|
||||
所以普通 dump 就是完整备份;`scripts/dict_prepare.py` 的产物约 3.0 MB。
|
||||
- `parseResource` 按 `format` 分派解析器;`dictionaryCache` 按资源 id 缓存已解析词典(键含 sha256)。
|
||||
- 查词合并(`mergeLookups`):中文释义条目在前、英英释义随后,`Resources` 列出全部启用词典,
|
||||
`Resource` 保持为 WordNet 以兼容既有界面;任一本缺失/损坏/停用时只用剩下那本,都不可用时
|
||||
仍是 `resource_missing`。
|
||||
- 屈折形:WordNet 的规则解析给出词目后,再用该词目回查中文词典,因此 `dogs` 也能看到"狗";
|
||||
但音标属于词目,**不复制**到屈折形上(`dogs` 不显示 `/dɔɡ/`)。
|
||||
|
||||
## 登录会话有效期(#42)
|
||||
|
||||
- 会话有效期是单一具名常量 `SessionLifetime`(`server/app/lexgo/service.go`),当前为 **30 天**;
|
||||
登录时写入 `lexgo_sessions.expires_at`,每次请求按 `expires_at > now` 校验。学习端与管理端共用
|
||||
同一登录接口,因此这一个值同时决定两端的有效期。
|
||||
- 有效期是**绝对**的,不随请求顺延;没有刷新令牌或轮换机制。撤销路径与有效期无关,保持独立:
|
||||
退出删除当前会话行,改密码、停用、重置删除该账号全部会话行,登录时顺手清理该账号已过期的行。
|
||||
- 服务端只保存令牌的 SHA-256 摘要(`token_hash` 主键),原始令牌只在客户端 sessionStorage。
|
||||
|
||||
@@ -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: 1f89692c0e24c3d2235a4cd7ad78b90512852f65
|
||||
synchronized_at: 2026-09-16T13:51:36Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -54,7 +54,7 @@ M0 固定首发语言语料、词条身份规则、短语选择与重叠规则
|
||||
|
||||
- 账号去首尾空白并转小写,3~32 位、ASCII 字母开头,可含数字、点、下划线和连字符;唯一约束阻止大小写和并发重复。密码 10~72 字节,使用 bcrypt,不填写必需邮箱。
|
||||
- 初始管理员只通过显式 bootstrap 建立;库中已有账号时拒绝再次 bootstrap,不覆盖密码。管理页面只创建学习者,不能将输入角色升级为管理员;本阶段不提供账号删除。
|
||||
- 同一账号可以登录多个会话。随机令牌仅在客户端保存,服务端存 SHA-256 摘要,8 小时过期。退出仅撤销当前会话,启停/重置会撤销目标账号全部会话;重新启用不能恢复旧会话。
|
||||
- 同一账号可以登录多个会话。随机令牌仅在客户端保存,服务端存 SHA-256 摘要,**30 天**过期(2026-09-16 由 8 小时改为 30 天,见 #42;单一常量 `SessionLifetime`,绝对有效期、不滑动续期)。退出仅撤销当前会话,启停/重置会撤销目标账号全部会话;重新启用不能恢复旧会话。
|
||||
- 每次请求读取账号当前状态与会话有效期;学习数据由认证身份确定,管理员权限不能解除学习接口的本人过滤。
|
||||
- 两端分别使用 lexgo-admin-token、lexgo-learner-token 的 sessionStorage;401、退出和账号切换清除私人状态,以代次检查阻止迟到响应恢复旧数据。管理端拒绝学习者并撤销其新会话;撤销服务异常会提示未确认,不冒充服务端退出成功。
|
||||
- 新建账号拥有英语空空间。此处没有书籍、阅读、词典或复习功能,不把空页面视为学习业务已实现。
|
||||
@@ -304,3 +304,144 @@ 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 取字节再交给 `<img>`/`<audio>`,因此**令牌不会出现在 URL、历史记录或访问日志里**;代价是浏览器会整份取回后再播放,暂不支持渐进式流式播放。
|
||||
|
||||
**删除与备份**:删除书籍级联删除附件与所有账号的该书进度。附件存于数据库,所以 #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、转码、自动搜图、在线音频地址、公开分享与跨用户共享。
|
||||
|
||||
## #32 版本回退规则(2026-09-16)
|
||||
|
||||
**正文版本可以来回切换**:把一章的正文改回曾经用过的文本,属于发布该内容版本,必须正常成功——不再出现无解释的错误,也不会因此产生重复任务行。规则如下:
|
||||
|
||||
- 一个内容版本对应一条任务记录:初次成为当前版本时创建,**再次成为当前版本时复用**(把该记录重置为待处理、重试次数归零、清空上次失败原因),因此反复来回切换不会累积任务行。
|
||||
- **旧版本的任务不会复活新版本章节**:只有「任务内容摘要 = 章节当前内容」的任务可以被重试或处理,否则拒绝。
|
||||
- 同一版本重复提交**没有副作用**:正文没变时不产生新版本,也不新增任务。
|
||||
- 上次处理失败的版本在被重新发布时**重试次数归零**,因为「人再次要求」不应被此前的失败预算挡住。
|
||||
- 回退到早期版本**不会恢复**当时的词条状态、复习排期或章节完成记录:正文版本与个人学习状态是分开的(#10/#11 已确认的口径)。
|
||||
|
||||
## 词典、音标与个人释义的边界(#40)
|
||||
|
||||
- **词典内容不是个人数据**:WordNet(英英)与英汉词典(ECDICT 子集 + CMUdict 音标)是全局资源,
|
||||
对所有学习账号相同,管理员可分别启用或停用;停用只影响查询时的可用来源,不删除个人已保存内容。
|
||||
- **个人释义与状态优先**:用户自己写下的释义、状态与等级仍按 #7/#8 的规则独立保存,不因词典新增中文释义
|
||||
而被改写、合并或自动填充;面板上中文释义与英英释义只是参考内容。
|
||||
- **音标来源必须可见**:IPA 转写(CMUdict)与 ECDICT 记法(仅做字符级规范化)是两种不同的可信度,
|
||||
系统记录 `phoneticSource`;不确定的记法直接不显示,不用猜测结果冒充实测音标。`grok` 属此类。
|
||||
- **屈折形**:`dogs` 通过 WordNet 的规则解析看到词目 `dog` 的释义属允许行为,但音标不随屈折形显示,
|
||||
避免把词目的发音说成屈折形的发音。
|
||||
- **子集口径**:英汉词典只收常用词(约 8.2 万条:小写单词且存在于 WordNet 词表或带考试标签),
|
||||
不导入 ECDICT 全量 340 万条;漏收的词表现为"只有英英释义",不是错误。
|
||||
- **许可**:ECDICT 仓库为 MIT,词典数据由公开资料整理,再分发前需自行评估来源;CMUdict 为 BSD-2;
|
||||
两份许可证随仓库保存(`server/ECDICT-LICENSE.txt`、`server/CMUDICT-LICENSE.txt`),词典归档本身不提交进 Git。
|
||||
|
||||
术语:**英英词典**(WordNet,英语释义)、**英汉词典**(ECDICT 子集,中文释义 + 音标)、
|
||||
**IPA 转写**(由 CMUdict 音素按固定映射转成)、**ECDICT 记法**(老式音标,仅做字符级规范化)、
|
||||
**子集口径**(决定哪些词进入英汉词典的规则)。
|
||||
|
||||
@@ -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: fa4c663fabf5db418104fc53b7ce00f41f5ba910
|
||||
synchronized_at: 2026-09-15T09:11:27Z
|
||||
wiki_revision: 45306a5d50c5674f512ea206739c6b2d89e88a85
|
||||
synchronized_at: 2026-09-16T13:51:36Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -746,3 +746,419 @@ Go 二进制与 Python 工具各做一次备份,并**互相恢复对方的备
|
||||
- 未做定时备份、监控与告警;备份与恢复均为人工触发。
|
||||
- 大库恢复耗时与磁盘空间上限未测;演练库与生产库规模不同。
|
||||
- 附件(封面/音频,#21)尚未实现,恢复范围目前只覆盖数据库。
|
||||
|
||||
## #21 附件功能验证(2026-09-15)
|
||||
|
||||
### 迁移
|
||||
|
||||
schema v7 → v8:新增 `lexgo_book_attachments` 与 `lexgo_playback_positions` 两张表,**不修改既有表**。开发库迁移前后逐表业务计数一致,schema 记到 8,两张新表为空(快照 `.local/issue13-before21.json`、`.local/issue13-after21.json`);回退时把 `lexgo_schema.version` 写回 7 并恢复上一版二进制(备份 `.local/lexgo-pre-issue21.exe`)。
|
||||
|
||||
| 命令 | 结果 |
|
||||
|---|---|
|
||||
| `go vet ./...` | 通过,`gofmt -l` 无输出 |
|
||||
| `LEXGO_TEST_DB_NAME=lexgo_test_issue13 python scripts/server.py test-integration` | **86 项顶层用例全部通过、0 跳过**(本单新增 7 项:类型嗅探、上限与像素、WebP 维度、位置边界、上传/读取/Range/替换/移除/级联的集成用例、v7→v8 迁移) |
|
||||
| `cd learner`:`npx vitest --run` / `npx vue-tsc --build` / `npx pnpm run build` / `npx playwright test` | 157 项单测、类型检查、构建、**25 项 E2E**(桌面 21 + 移动 4)全部通过;本单新增 10 单测与 3 项 E2E |
|
||||
| `cd admin`:`npx pnpm test` / `npx pnpm lint` | 31 项与 lint 通过(管理端无改动) |
|
||||
| `python -m unittest discover -s tests` / `harness.py check --strict` / `sync --check` | 65 项、严格检查、镜像一致 |
|
||||
|
||||
### 真实链路(41 项检查全部通过)
|
||||
|
||||
真实 API+真实 MySQL:上传**真实 PNG**(461 字节)与**真实三秒 MP3**(ffmpeg 生成,18407 字节)→ 数据库中的 `byte_size` 与 `OCTET_LENGTH(bytes)` 一致、音频 sha256 与上传文件一致 → 书籍详情与书库列表报告 `attachments`、`coverVersion`、`hasAudio` → 封面按字节返回且带 `ETag`(`If-None-Match` 得 `304`)→ 音频按字节返回、`Accept-Ranges` 存在、`Range: bytes=100-999` 得 `206` 且切片正确、不可满足的 Range 得 `416` → 越权读取/替换/上报位置全部 `404`、匿名 `401` → SVG 改名封面、文本改名音频均 `400`,21 MiB 音频 `413`,且**被拒绝后原文件仍在** → 位置写入与回读、负值 `400`、无音频的书 `404` → 替换音频后位置归零且仍只有一行 → 移除音频、删除书籍后附件与位置级联清空。证据 `.local/issue21-api-evidence.json`。
|
||||
|
||||
### 恢复演练(含附件,22 项检查全部通过)
|
||||
|
||||
在 #15 的恢复契约上扩展:建一本带真实封面与音频的 fixture 书籍 → 用 **Go 二进制** `lexgo backup`(manifest 已包含两张新表)→ 恢复到空库 `lexgo_test_restore_attach` → `lexgo verify --manifest` 通过且新增的「附件都指向存在的书」「播放位置都指向存在的书」检查在内 → **15 张表的逐表内容校验和与源库一致** → 用恢复库起第二个实例:同一账号可登录、书籍仍报告两个附件、位置仍是恢复前的值、**音频与封面按字节相同地返回**、恢复实例上 Range 仍得 `206`、越权仍 `404` → 删除演练库与 fixture 书籍,开发库仍是 schema 8。证据 `.local/issue21-restore-evidence.json`。
|
||||
|
||||
### 顺带修掉的既有问题
|
||||
|
||||
`e2e/phrase.spec.ts` 的连续选词用例在并行全量跑时约 6 次失败 2 次(合成鼠标拖拽偶尔少选一个词)。修法:每次重试都重新读取词的位置(面板打开会改变布局,缓存的坐标会一直拖错)、松开前留 80ms 让浏览器扩展选区、把重试条件改成**断言期望的词数**而不是「面板出现了」、重试前关掉部分选中的面板。修完连续 6 次全量跑全部通过;若选择逻辑真的退化,4 次重试仍会失败,不会掩盖真实问题。
|
||||
|
||||
### 未验证
|
||||
|
||||
- **浏览器是否真的解码并播放音频:本机验证用桩替换了媒体元素**(合成的 MP3 帧不足以证明解码)。真实 MP3 的听感、手机扬声器/蓝牙、后台播放与锁屏控制需要人工确认。
|
||||
- 未验证 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`。
|
||||
|
||||
### 插图交互修订(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`)。
|
||||
|
||||
### 验收后的最终状态(2026-09-16)
|
||||
|
||||
用户回复「#37 通过验收」;PR #38 已 fast-forward-only 合入 main(merge commit `728f7d6`),工单关闭。最终 schema 为 **v11**(v9 章级附件、v10 章节作者、v11 书籍作者)。经确认**未实现**的两项:书库卡片显示书级作者、章节作者留空时回落显示书级作者;数据层已就绪(`BookSummary`/`BookRef` 与 `ChapterSummary` 都返回作者),需要时另开小工单即可。
|
||||
|
||||
### 未验证
|
||||
|
||||
- 真机与真实浏览器解码播放(媒体元素在测试中被桩替换)、真机听感与兼容性仍未验证(#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 找不回;当前无真实用户,工单已记录。
|
||||
- 已有书籍/章节的存量内容不受影响,但**没有做「把既有书级音频迁到章级」的转换**(无真实数据)。
|
||||
|
||||
## #32 修复与验证(2026-09-16)
|
||||
|
||||
### 根因与修复
|
||||
|
||||
编辑路径用 `contentSHA("edit:<章节>:<内容摘要>")` 派生任务键,与 `lexgo_ingest_jobs.uq_job_request (owner_id, request_key)` 相撞:把正文改回曾经用过的版本时派生出已存在的键,`Create` 触发 MySQL 1062,事务整体回滚并冒泡为通用 500。新增 `stageEditJob`:先按 `(owner_id, chapter_id, content_sha256)` 锁定查询该版本的任务行,找到就复用(重置为 pending、attempts=0、清空失败原因),找不到才用派生键创建。**无 schema 变化**,因此不需要迁移;回退件 `.local/lexgo-pre-issue32.exe`。
|
||||
|
||||
### 验证
|
||||
|
||||
| 项 | 结果 |
|
||||
|---|---|
|
||||
| `go vet ./...` / `gofmt -l` | 通过、无输出 |
|
||||
| `LEXGO_TEST_DB_NAME=lexgo_test_issue13 python scripts/server.py test-integration` | **92 项顶层用例通过、0 跳过**;新增 `TestMySQLChapterEditBackToAPreviousVersion` 覆盖 A→B→A→B(此前第三步 500)、「一章一版本一行任务」、被复用的行干净重启(pending、attempts=0、无 finished_at/error_reason)、失败版本再次成为当前版本时被复活、章节最终为 ready 且正文为最后一次编辑的内容、读者按该内容返回、正文未改动时不新增任务 |
|
||||
| 真实 API+MySQL | **21 项检查通过**(`.local/issue32-api-evidence.json`):在开发实例上对一本 fixture 书籍执行 A→B→A→B,三步全部 200(此前第三步 500)、每步都 report versionChanged 并最终 ready、该章只有两条任务行且都以 ready 收尾、没有任务停在 pending、读者返回最后一次编辑的正文、章节内容摘要等于某个任务版本、再切回另一版本仍为 200 且仍是两行、删除 fixture 书籍后任务级联清空 |
|
||||
| 治理 | `python -m unittest discover -s tests` 65 项与 `harness.py check --strict` 通过 |
|
||||
|
||||
### 未验证
|
||||
|
||||
- 只验证了「单章来回切换」;多章并发编辑、超大文本反复切换与移动端上的编辑体验未单独压测。
|
||||
- 与编辑器交互无关:本单只改后端编排,前端在 #10 已实现「正文改版会重新处理」的提示。
|
||||
|
||||
## 英汉词典的准备、导入与验证(#40)
|
||||
|
||||
```powershell
|
||||
# 1) 显式准备(需要网络,只在此步骤下载;产物不入库)
|
||||
python scripts/dict_prepare.py
|
||||
# 可用 --cache-dir .local/dictionaries/cache 复用已下载的源,--keep-going 在计数漂移时仍产出
|
||||
# 打印条目数、三类音标计数、产物字节数与 sha256
|
||||
|
||||
# 2) 显式迁移(v12 增加 provider 并放开单行约束)
|
||||
python scripts/server.py migrate
|
||||
|
||||
# 3) 导入:管理端「英语词典」页选择"英汉释义"后上传 .local/dictionaries/zh-dict-v1.zip
|
||||
# 命令行等价:POST /api/v1/dictionaries/import(multipart: name/language/version/source/format + file)
|
||||
# 导入失败或校验不通过时保留原有词典,不会替换成半成品。
|
||||
|
||||
# 4) 查询验证
|
||||
curl -s http://127.0.0.1:8000/api/v1/dictionaries # items[].provider 应为 wordnet / ecdict
|
||||
python .local/verify-issue40-api.py # 28 项真实 API 检查
|
||||
```
|
||||
|
||||
- 资源路径:准备好的归档 `.local/dictionaries/zh-dict-v1.zip`(约 3.0 MB);WordNet 夹具沿用
|
||||
`.local/nlp-resources/wordnet.zip`。测试在夹具缺失时跳过,不会静默通过。
|
||||
- 测试命令:
|
||||
`LEXGO_TEST_DB_NAME=lexgo_test_issue40 python scripts/server.py test-integration`(Go 96 项)、
|
||||
`cd learner && npx vitest --run`(159 项)、`npx playwright test`(26 项,含 390×844 触摸视口)、
|
||||
`cd admin && npx pnpm test`(32 项)。
|
||||
- 迁移回退:把 `lexgo_schema.version` 写回 11、删除 id=2 的词典行即可;旧二进制缺少 `provider` 字段的
|
||||
读语句不受影响(新列有默认值),但旧二进制见到 v12 会拒绝启动并要求显式迁移,这是有意的保护。
|
||||
- 排错:`provider` 不存在 → 未执行 migrate;导入 400 且提示版本不符 → 归档不是当前 pin 生成的
|
||||
(重新运行 `dict_prepare.py`);查词只有英英释义 → 英汉词典未导入或被停用。
|
||||
|
||||
## 验证登录有效期(#42)
|
||||
|
||||
```powershell
|
||||
# 固定值本身在测试里断言,改动会让测试立刻失败
|
||||
cd server; go test ./app/lexgo/ -run TestMySQLSessionLifetimeIsThirtyDays -v
|
||||
# 集成测试:到期前仍可用、到期后被拒绝(按 SessionLifetime 计算,不写死小时数)
|
||||
LEXGO_TEST_DB_NAME=lexgo_test_issue42 python scripts/server.py test-integration
|
||||
```
|
||||
|
||||
- `TestMySQLSessionLifetimeIsThirtyDays` 校验三件事:常量等于 30 天、登录响应 `expiresAt` 与库内
|
||||
`expires_at` 一致、库内只存在摘要(原始令牌查不到);`TestMySQLAccountIsolationAndRevocation`
|
||||
在 `SessionLifetime - 1h` 仍成功、再前进 2 小时被拒。
|
||||
- 手工排查:`SELECT owner_id, expires_at FROM lexgo_sessions ORDER BY expires_at DESC;`。
|
||||
若登录后立刻 401,先确认服务器时间与 `expires_at` 都以 UTC 存储(`DATETIME` 不存时区)。
|
||||
- 旧的 8 小时会话不受影响:各自按自己的 `expires_at` 自然到期,不需要数据迁移。
|
||||
|
||||
@@ -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: a1847957e287c7eb1a54905e13632aeca4cd75f7
|
||||
synchronized_at: 2026-09-15T08:27:38Z
|
||||
wiki_revision: 5c7b0b4942b2c10edadf2f2b84686675267d323e
|
||||
synchronized_at: 2026-09-16T05:27:09Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 产品需求总览
|
||||
@@ -288,8 +288,61 @@ 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,已验收并合入 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。
|
||||
|
||||
## #37 交付状态更新(2026-09-15,已验收并合入 main)
|
||||
|
||||
用户的真实场景「多篇文章合并成一本书、每章各有音频,并为每章加插图」已实现,待用户验收:音频与插图改为**章节级**(一章各一张),封面维持**书级**;阅读页在正文上方显示本章插图缩略图、点击后在弹窗里查看原图(章节列表不显示章节图片),播放器只在本章有音频时出现;书籍页章节列表每行只有一个「编辑」入口,标题、作者、正文、插图与音频在同一对话框内编辑(标题/作者/正文点保存后生效,文件选中后立即上传);**书籍作者**与**章节作者**是两份互相独立的可选自由文本(各 ≤120 字符,留空即清空),书级作者显示在书籍页书名右侧并由「编辑书籍」维护,章节作者显示在阅读页标题下;schema 为 v11;播放位置粒度改为**账号+章节**,切换章节时上报一次。类型按文件内容判定(MP3、JPG/PNG/WebP),音频 ≤20 MiB、图片 ≤2 MiB 且 ≤4096×4096,读取需会话并支持 HTTP Range 与 ETag。schema 升到 v9(两张新表 + 幂等清空书级音频历史行,需显式迁移);**书级音频接口已下线**。附件仍存于数据库,因此 #15 的备份恢复自动覆盖,第三次恢复演练验证了附件按字节恢复且 Range 可用。
|
||||
|
||||
**已知代价**:#21 交付的书级音频功能整体作废(其测试期数据在迁移中清空且回退无法找回);前端实现与播放器组件被复用,未推倒重做。下次再调整附件挂载粒度前应先确认真实使用场景。**真机听感与浏览器真实解码播放仍未验证**。其余待办:#24 书库列表优化与缺陷 #32。
|
||||
|
||||
## F06 词典范围扩展:英汉释义与规范音标(#40)
|
||||
|
||||
用户 2026-09-16 提出「找个英中词典,单词有发音」,并澄清"发音"指**音标**(举例「grok 的 /gra:k/」),
|
||||
随后确认三项决策:约 5 万词量级的常用子集、规范 IPA 音标、允许多词典并存(schema v12)。
|
||||
|
||||
已交付范围:点词后同时看到**中文释义**(ECDICT 子集,约 8.2 万条)与**音标**
|
||||
(优先 CMUdict IPA 转写,其余保留 ECDICT 记法并标注来源),英英释义仍作为补充;
|
||||
两本词典可分别启停,个人释义与学习状态规则不变。
|
||||
|
||||
非目标:发音**音频**、整句翻译、汉英方向(中→英)、词频统计界面。F 系列其余功能与 X 系列范围不受影响。
|
||||
|
||||
@@ -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: 3791ef4adc5c5cc8d6e5006c719cd0ddccb15fc0
|
||||
synchronized_at: 2026-09-15T09:08:49Z
|
||||
wiki_revision: 2fa9c30e08fc14346d9960178a425a8ee3564a3a
|
||||
synchronized_at: 2026-09-16T05:27:09Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 部署与运维
|
||||
@@ -32,7 +32,7 @@ synchronized_at: 2026-09-15T09:08:49Z
|
||||
| 运行时 | 后端为单个静态二进制,无需运行时依赖;构建需要 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,19 @@ python scripts/ops.py smoke --api http://127.0.0.1:<端口> --admin-user <管理
|
||||
- **真实回滚演练未做**:本文档给出规则,但没有在真实实例上执行过「升级 → 回滚」全过程。
|
||||
- 未验证 HTTPS、域名、多机与灰度部署;本机演练只用 `127.0.0.1` 与模拟触摸视口。
|
||||
- 未启用定时备份、监控与告警;备份由人工触发。
|
||||
- **附件(封面/音频)尚未实现(工单 #21)**:本页的备份恢复范围目前只覆盖数据库;#21 落地后必须把附件存储纳入备份与恢复验收。
|
||||
- **封面(书级)与音频、插图(章级)都存进数据库**,因此 dump 自动包含它们与播放位置,恢复后仍可按字节读取并拖动;**附件会让 dump 变大**(单音频上限 20 MiB,图片上限 2 MiB),容量与备份体积要按试用量估算。#37 起附件挂在章节上,书级音频接口已下线。
|
||||
- 附件读取需要会话并支持 HTTP Range;客户端用带凭据的 fetch 取字节后交给媒体元素,令牌不出现在 URL 里,但浏览器会整份取回后再播放(未做渐进式流式播放)。
|
||||
- 恢复演练是单机顺序执行,未验证大库恢复耗时与磁盘空间上限。
|
||||
- 接口级的两账号闭环与越权验证需要 HTTP 客户端,目前只在 Python 工具通道提供;Go 二进制提供数据库层的备份、恢复与校验。
|
||||
- 备份仍调用 `mysqldump`:自己实现一致性导出风险更高,因此部署机需要 MySQL 客户端而不只是服务端。
|
||||
|
||||
## 备份体积与词典资源(#40)
|
||||
|
||||
英汉词典归档(约 3.0 MB)与 WordNet(约 10.3 MB)都以整包形式存在 `lexgo_dictionaries.archive`,
|
||||
因此 `lexgo backup` 的 dump 天然包含两本词典,恢复后无需重新下载或重新准备词典。
|
||||
|
||||
- 备份/恢复流程与 #15 相同,不需要新增参数;`lexgo verify` 检查的仍是同一套清单。
|
||||
- 词典归档由 `scripts/dict_prepare.py` 在本机从 sha256 pin 的源生成,**产物不入库**;
|
||||
恢复演练时若需要重新准备,必须重新下载并在准备阶段校验 sha256。
|
||||
- 迁移到 v12 后旧二进制会被拒绝启动(见本地开发页),因此回退二进制必须先回退 schema 版本标记。
|
||||
- 未做:定时备份、多主机、HTTPS 的缺口沿用 #15 记录,不因本单变化。
|
||||
|
||||
+18
-4
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Home
|
||||
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Home
|
||||
wiki_revision: 8c563c979bdfcacf1dda8743621b0ea00b3805f8
|
||||
synchronized_at: 2026-09-15T09:08:48Z
|
||||
wiki_revision: e6e3b2a93c4d4f3a809872e8260c3776923c7623
|
||||
synchronized_at: 2026-09-16T13:51:35Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# LexGo 文档入口
|
||||
@@ -87,6 +87,20 @@ 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 仍可用。真实浏览器是否解码播放(媒体元素在测试中被桩替换)以及真机听感仍需人工确认。
|
||||
|
||||
#37 章节级音频与章节插图已于 2026-09-16 通过用户验收:每章可以上传一段 MP3(≤20 MiB)与一张插图(JPG/PNG/WebP,≤2 MiB、≤4096×4096),封面仍按书;阅读页在正文上方显示本章插图的缩略图、点击后在弹窗里查看原图(书籍页章节列表不显示章节图片),播放器只在本章有音频时出现且不与文本同步,播放位置按「账号+章节」保存并在切换章节时上报;书籍页的封面压成一行紧凑控件(预览+上传/替换/移除),书名右侧显示书级作者,「编辑书籍」可改书名与作者,章节列表每行只有一个「编辑」,点开后在同一对话框里编辑标题、作者、正文与本章插图、音频(标题/作者/正文点保存后生效,文件选中后立即上传;阅读页在标题下显示作者)。类型按文件内容判定,读取需会话并支持 HTTP Range 与 ETag;附件存于数据库,包含在 #15 的备份恢复范围内(第三次恢复演练已验证按字节恢复)。schema 已升到 v11(v9 章级附件、v10 章节作者、v11 书籍作者,均需显式迁移),书级音频接口已下线,其实测试期数据在迁移中清空;PR #38 已 fast-forward-only 合入 main。真机听感与浏览器真实解码播放仍需人工确认;书库卡片暂不显示书级作者、章节作者不继承书级作者(两项经确认未实现,如需另开工单)。
|
||||
|
||||
#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 需显式迁移,书级音频接口已下线,其实测试期数据在迁移中清空。真机听感与浏览器真实解码播放仍需人工确认。
|
||||
|
||||
#40 英汉词典与规范音标已于 2026-09-16 实现并进入待验收:新增第二本英语词典——ECDICT 常用子集(约 8.2 万条中文释义,3.0 MB)与 CMUdict 转写的 IPA 音标。音标分三层来源:能由 CMUdict 音素按固定映射转写的用真 IPA(35,799 条),否则保留 ECDICT 记法仅做字符级规范化并标注来源(30,580 条),两者都不可靠就不显示(16,342 条,例如 `grok`);含 `^` 等丢失首音的记法整条丢弃,不做猜测。schema 升到 v12(放开 `lexgo_dictionaries` 单行 CHECK、增加 `provider` 列,两步都带条件守卫、可重放),英英与英汉词典可分别启停,查词时中文释义在前、英英释义随后,屈折形经 WordNet lemma 解析后也能看到中文释义但不会复制词目音标。归档由 `scripts/dict_prepare.py` 从 sha256 pin 的三个源显式准备(运行时不联网,产物不入库,仓库只保存 pin 与两份许可证),整包存进 MySQL 因而包含在 #15 的备份范围内。测试:Go 96 项、学习端 159 单测与 26 项 e2e(含 390×844 触摸视口)、管理端 32 项、真实 API 28 项检查;PR #41 已合入 main,并由用户于 2026-09-16 验收通过。发音音频、整句翻译与汉英方向不在本单范围。
|
||||
|
||||
#42 登录有效期已于 2026-09-16 由 8 小时改为 **30 天**(学习端与管理端共用同一登录接口,一处生效):有效期提取为具名常量 `SessionLifetime`,仍然是**绝对**有效期、不做滑动续期,撤销规则不变(退出只撤销当前会话,改密码/停用/重置撤销该账号全部会话,服务端只存 SHA-256 摘要)。代价是令牌泄露后的可用窗口放大约 90 倍;设备/会话列表与"退出其他设备"未包含在本单,如需另开工单。无数据库结构变化,旧会话按各自到期时间自然过渡;测试:Go 92 项(新增 1 项锁定 30 天与摘要存储,并把过期断言改为按常量计算);PR #43 已合入 main,并由用户于 2026-09-16 验收通过。
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
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' }
|
||||
const book = { id: 7, title: 'Fictional reader', language: 'en' }
|
||||
const chapterText = 'Curiosity opens the first door.\nThe second door stays closed.\n'
|
||||
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(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
)
|
||||
|
||||
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[] = []
|
||||
const positions: number[] = []
|
||||
|
||||
await page.route('**/api/v1/**', async route => {
|
||||
const url = new URL(route.request().url())
|
||||
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 })
|
||||
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)
|
||||
if (path === '/api/v1/space') return json({ ownerId: user.id, language: 'en' })
|
||||
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: [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 === '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, 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/chapters/3/playback' && method === 'PUT') {
|
||||
const body = route.request().postDataJSON() as { positionSeconds: number }
|
||||
positions.push(body.positionSeconds)
|
||||
playbackSeconds = body.positionSeconds
|
||||
return json({ playbackSeconds })
|
||||
}
|
||||
if (path === '/api/v1/chapters/3/tokens') {
|
||||
let offset = 0
|
||||
const tokens = (chapterText.match(/[A-Za-z]+|\s+|[^A-Za-z\s]+/g) ?? []).map(piece => {
|
||||
const start = offset
|
||||
offset += piece.length
|
||||
return { text: piece, start, end: offset, startUtf16: start, endUtf16: offset, kind: /^[A-Za-z]+$/.test(piece) ? 'word' : 'space' }
|
||||
})
|
||||
return json({ textSha256: 'sha', tokens })
|
||||
}
|
||||
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 }
|
||||
}
|
||||
|
||||
async function login(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => sessionStorage.setItem('lexgo-learner-token', 'fictional-session'))
|
||||
await page.goto('/')
|
||||
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
|
||||
}
|
||||
|
||||
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()
|
||||
await page.screenshot({ path: '../.local/evidence/issue21-library-cover.png' })
|
||||
|
||||
await page.getByRole('link', { name: 'Fictional reader', exact: true }).click()
|
||||
await expect(page.getByTestId('attachments')).toBeVisible()
|
||||
// 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' })
|
||||
|
||||
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="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'])
|
||||
|
||||
// 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('chapter-audio-state')).toContainText('未设置')
|
||||
expect(calls.deleted).toEqual(['audio'])
|
||||
})
|
||||
|
||||
test('the reader plays the audio without starting it and remembers the position', async ({ page }) => {
|
||||
// The fixture bytes are not a decodable MP3, so the media element itself is stubbed: the test is
|
||||
// about the player's behaviour, not about Chromium decoding a synthetic file.
|
||||
await page.addInitScript(() => {
|
||||
const media = HTMLMediaElement.prototype as unknown as Record<string, unknown>
|
||||
Object.defineProperty(media, 'duration', { configurable: true, get: () => 180 })
|
||||
// paused has to follow the stub, otherwise the player would try to play again instead of pausing.
|
||||
Object.defineProperty(media, 'paused', {
|
||||
configurable: true,
|
||||
get(this: HTMLMediaElement & { __playing?: boolean }) { return !this.__playing },
|
||||
})
|
||||
Object.defineProperty(media, 'play', {
|
||||
configurable: true,
|
||||
value(this: HTMLMediaElement & { __playing?: boolean }) {
|
||||
this.__playing = true
|
||||
this.dispatchEvent(new Event('play'))
|
||||
return Promise.resolve()
|
||||
},
|
||||
})
|
||||
Object.defineProperty(media, 'pause', {
|
||||
configurable: true,
|
||||
value(this: HTMLMediaElement & { __playing?: boolean }) {
|
||||
this.__playing = false
|
||||
this.dispatchEvent(new Event('pause'))
|
||||
},
|
||||
})
|
||||
Object.defineProperty(media, 'load', {
|
||||
configurable: true,
|
||||
value(this: HTMLMediaElement) { this.dispatchEvent(new Event('loadedmetadata')) },
|
||||
})
|
||||
})
|
||||
const calls = await mockApi(page)
|
||||
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.
|
||||
await expect(player).toHaveCSS('position', 'sticky')
|
||||
await expect(player).toContainText('0:00')
|
||||
await page.screenshot({ path: '../.local/evidence/issue21-reader-player.png' })
|
||||
|
||||
// Nothing plays until the button is pressed.
|
||||
await expect(page.getByTestId('audio-toggle')).toHaveAttribute('aria-label', '播放音频')
|
||||
await page.getByTestId('audio-toggle').click()
|
||||
await expect(page.getByTestId('audio-toggle')).toHaveAttribute('aria-label', '暂停音频')
|
||||
await page.screenshot({ path: '../.local/evidence/issue21-reader-playing.png' })
|
||||
|
||||
// Seeking moves the position and pausing reports it to the book.
|
||||
// A range input cannot be filled, so the value is set the way dragging the handle would.
|
||||
await page.getByTestId('audio-seek').evaluate((element, value) => {
|
||||
const input = element as HTMLInputElement
|
||||
input.value = String(value)
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}, 12)
|
||||
await page.getByTestId('audio-toggle').click()
|
||||
await expect.poll(() => calls.positions.length).toBeGreaterThan(0)
|
||||
|
||||
// The speed control states which rate is active.
|
||||
await page.getByTestId('audio-rate-1.5').click()
|
||||
await expect(page.getByTestId('audio-rate-1.5')).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
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 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-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)
|
||||
})
|
||||
@@ -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
|
||||
@@ -63,20 +65,35 @@ test('rename the book, edit a chapter into a new version and delete both', async
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
await page.getByRole('link', { name: 'A small step' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'A small step' })).toBeVisible()
|
||||
await expect(page.getByText('封面使用系统默认样式')).toBeVisible()
|
||||
// The subtitle no longer claims a system cover: covers are attachments now (#21).
|
||||
await expect(page.getByText('语言 英语')).toBeVisible()
|
||||
await expect(page.getByTestId('attachments')).toBeVisible()
|
||||
|
||||
// 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.
|
||||
// The chapter editor is the main writing surface, so on a wide window it must use that width and
|
||||
// a tall body instead of a fixed 620px box.
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
await page.getByTestId('edit-chapter-9').click()
|
||||
await expectModalOverlay(page, page.getByTestId('chapter-dialog'))
|
||||
await expect(page.getByLabel('章节标题', { exact: true })).toHaveValue('First chapter')
|
||||
await expect(page.getByLabel('正文', { exact: true })).toHaveValue(firstText)
|
||||
const dialog = await page.getByTestId('chapter-dialog').boundingBox()
|
||||
expect(dialog!.width, 'the chapter editor follows the window').toBeGreaterThan(900)
|
||||
const body = await page.getByLabel('正文', { exact: true }).boundingBox()
|
||||
expect(body!.height, 'the text area is tall enough to write in').toBeGreaterThan(400)
|
||||
const noOverflow = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, inner: window.innerWidth }))
|
||||
expect(noOverflow.scroll, 'the wide editor must not overflow').toBeLessThanOrEqual(noOverflow.inner)
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
await page.getByLabel('正文', { exact: true }).fill('A replacement body.\n')
|
||||
await page.getByTestId('save-chapter').click()
|
||||
await expect(page.getByTestId('book-notice')).toContainText('已保存为新版本,正在重新处理')
|
||||
@@ -91,6 +108,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)
|
||||
|
||||
@@ -29,11 +29,13 @@ test('a touch swipe scrolls the chapter and does not open the word panel', async
|
||||
const padding = await page.evaluate(() => getComputedStyle(document.querySelector('.reader-page') as Element).paddingBottom)
|
||||
expect(parseFloat(padding)).toBeGreaterThan(100)
|
||||
|
||||
// Closing the panel keeps the reading position instead of jumping away.
|
||||
const before = await page.evaluate(() => window.scrollY)
|
||||
// Closing the panel keeps the reader where they were: the tapped word stays on screen. The page
|
||||
// releases the space it reserved for the sheet, so the browser may clamp the scroll by a little
|
||||
// when the chapter end is already in view; what must not happen is losing sight of the word.
|
||||
const word = page.locator('.reader-word').nth(1)
|
||||
await page.getByRole('button', { name: '关闭释义' }).tap()
|
||||
await expect(panel).toHaveCount(0)
|
||||
expect(Math.abs((await page.evaluate(() => window.scrollY)) - before)).toBeLessThan(40)
|
||||
await expect(word).toBeInViewport()
|
||||
})
|
||||
|
||||
test('the display choice survives a reload and belongs to the account', async ({ page }) => {
|
||||
|
||||
@@ -22,6 +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: 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' },
|
||||
}
|
||||
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())
|
||||
@@ -30,8 +40,16 @@ export async function mockLearnerApi(page: Page): Promise<void> {
|
||||
if (path === '/api/v1/login') data = { token: 'fictional-session', user: mobileUser }
|
||||
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] }
|
||||
else if (path === '/api/v1/books/7') data = { book, chapters: [chapter] }
|
||||
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/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/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
|
||||
@@ -46,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, 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') {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
+42
-13
@@ -47,24 +47,53 @@ async function openChapter(page: Page, options: { phrases?: unknown[]; onPhrase?
|
||||
await expect(page.locator('.reader-text')).toBeVisible()
|
||||
}
|
||||
|
||||
/** Drags from the centre of one word to the centre of another, like a person selecting text. */
|
||||
async function dragWords(page: Page, from: string, to: string) {
|
||||
const words = page.locator('.reader-word')
|
||||
const start = words.filter({ hasText: new RegExp(`^${from}$`) }).first()
|
||||
const end = words.filter({ hasText: new RegExp(`^${to}$`) }).first()
|
||||
const startBox = (await start.boundingBox())!
|
||||
const endBox = (await end.boundingBox())!
|
||||
await page.mouse.move(startBox.x + startBox.width / 2, startBox.y + startBox.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(endBox.x + endBox.width / 2, endBox.y + endBox.height / 2, { steps: 8 })
|
||||
await page.mouse.up()
|
||||
/**
|
||||
* Drags from the centre of one word to the centre of another, like a person selecting text.
|
||||
*
|
||||
* A synthetic drag sometimes stops one word short or collapses entirely in Chromium, especially
|
||||
* while other tests load the same dev server, so the drag is repeated until the highlighted run
|
||||
* has the expected number of words. The retry condition is the property the test is about, not
|
||||
* just "a panel appeared": a real regression in selection handling still fails all attempts.
|
||||
*/
|
||||
async function dragWords(page: Page, from: string, to: string, words: number) {
|
||||
const word = page.locator('.reader-word')
|
||||
const start = word.filter({ hasText: new RegExp(`^${from}$`) }).first()
|
||||
const end = word.filter({ hasText: new RegExp(`^${to}$`) }).first()
|
||||
const range = page.getByTestId('phrase-range')
|
||||
for (let attempt = 1; attempt <= 4; attempt += 1) {
|
||||
// The boxes are read again on every attempt: an open panel reflows the page, so a cached
|
||||
// coordinate would drag to the wrong place and keep the retry failing for the same reason.
|
||||
const startBox = (await start.boundingBox())!
|
||||
const endBox = (await end.boundingBox())!
|
||||
const startX = startBox.x + startBox.width / 2
|
||||
const startY = startBox.y + startBox.height / 2
|
||||
await page.mouse.move(startX, startY)
|
||||
await page.mouse.down()
|
||||
// A couple of pixels first: the browser only starts extending the selection once the pointer
|
||||
// has actually moved, and a single jump can be treated as a click.
|
||||
await page.mouse.move(startX + 3, startY, { steps: 2 })
|
||||
await page.mouse.move(endBox.x + endBox.width / 2, endBox.y + endBox.height / 2, { steps: 12 })
|
||||
// Under load the browser may not have extended the selection by the time the button is
|
||||
// released, which is what produces a run that stops one word short.
|
||||
await page.waitForTimeout(80)
|
||||
await page.mouse.up()
|
||||
try {
|
||||
await expect(range).toContainText(`短语 · ${words} 个单词`, { timeout: 2500 })
|
||||
return
|
||||
} catch (reason) {
|
||||
if (attempt === 4) throw reason
|
||||
// Close whatever panel the partial selection opened, so the next attempt starts clean.
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.locator('.lookup-panel')).toHaveCount(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('drag a continuous phrase, save it and highlight it', async ({ page }) => {
|
||||
let body: Record<string, unknown> | undefined
|
||||
await openChapter(page, { onPhrase: value => { body = value } })
|
||||
|
||||
await dragWords(page, 'Take', 'small')
|
||||
await dragWords(page, 'Take', 'small', 3)
|
||||
const range = page.getByTestId('phrase-range')
|
||||
await expect(range).toContainText('短语 · 3 个单词')
|
||||
// A phrase that is not stored yet is labelled as a new entry.
|
||||
@@ -81,7 +110,7 @@ test('drag a continuous phrase, save it and highlight it', async ({ page }) => {
|
||||
|
||||
test('adjust the endpoints by whole words from the panel', async ({ page }) => {
|
||||
await openChapter(page)
|
||||
await dragWords(page, 'Take', 'small')
|
||||
await dragWords(page, 'Take', 'small', 3)
|
||||
await expect(page.getByTestId('phrase-range')).toContainText('3 个单词')
|
||||
await page.getByTestId('range-end-right').click()
|
||||
await expect(page.getByTestId('phrase-range')).toContainText('4 个单词')
|
||||
|
||||
@@ -76,7 +76,15 @@ test('paste English text, watch a chapter finish processing, then read it verbat
|
||||
} else if (path === '/api/v1/terms/9') data = { term: savedTerm }
|
||||
else if (path === '/api/v1/lookup') {
|
||||
expect(route.request().postDataJSON()).toEqual({ chapterId: 55, start: 0, end: 5 })
|
||||
data = { status: 'exact', query: 'first', matchedForm: 'first', candidates: [], entries: [{ lemma: 'first', pos: 'adjective', definition: 'Coming before all others.', examples: ['The first fictional chapter.'] }] }
|
||||
data = {
|
||||
status: 'exact', query: 'first', matchedForm: 'first', candidates: [], phonetic: 'fɜːst', phoneticSource: 'cmudict',
|
||||
resources: [{ name: '简明英汉字典增强版(ECDICT)+ CMUdict 音标', version: 'ecdict-1.0.28+cmudict-0.7b' }, { name: 'Princeton WordNet', version: '3.0' }],
|
||||
resource: { name: 'Princeton WordNet', version: '3.0' },
|
||||
entries: [
|
||||
{ lemma: 'first', pos: '', definition: '', examples: [], translation: 'a. 第一的, 最早的\nn. 开始, 第一', provider: 'ecdict' },
|
||||
{ lemma: 'first', pos: 'adjective', definition: 'Coming before all others.', examples: ['The first fictional chapter.'], provider: 'wordnet' },
|
||||
],
|
||||
}
|
||||
}
|
||||
await route.fulfill({ status: statusCode, json: { code: 200, data } })
|
||||
})
|
||||
@@ -119,12 +127,33 @@ test('paste English text, watch a chapter finish processing, then read it verbat
|
||||
await word.focus()
|
||||
await word.press('Enter')
|
||||
await expect(page.getByText('Coming before all others.')).toBeVisible()
|
||||
// The merged dictionary answers with the IPA phonetic and the Chinese gloss first, then the
|
||||
// English detail from WordNet; both directories are named in the footnote.
|
||||
await expect(page.getByTestId('lookup-phonetic')).toHaveText('/fɜːst/')
|
||||
await expect(page.getByTestId('lookup-translation')).toContainText('第一的')
|
||||
const senses = page.locator('.lookup-senses li')
|
||||
await expect(senses.nth(0)).toHaveAttribute('data-provider', 'ecdict')
|
||||
await expect(senses.nth(1)).toHaveAttribute('data-provider', 'wordnet')
|
||||
await expect(page.locator('.lookup-panel')).toContainText('Princeton WordNet')
|
||||
expect(await readerText.evaluate(element => element.textContent)).toBe(pasted)
|
||||
|
||||
// The reading and editing surfaces must grow with the window instead of staying at a fixed
|
||||
// 820px column, and no intermediate width may overflow sideways (the blind spot before this).
|
||||
for (const width of [768, 1024, 1280]) {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
await page.waitForTimeout(60)
|
||||
const overflow = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, inner: window.innerWidth }))
|
||||
expect(overflow.scroll, `reader must not overflow at ${width}px`).toBeLessThanOrEqual(overflow.inner)
|
||||
}
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
await page.waitForTimeout(60)
|
||||
const wideBody = await page.locator('.reader-body').boundingBox()
|
||||
expect(wideBody!.width, 'the text column follows the window').toBeGreaterThan(820)
|
||||
// Browser narrow viewport check only; this is not real-device acceptance.
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await word.click()
|
||||
await expect(page.getByText('Coming before all others.')).toBeVisible()
|
||||
await expect(page.getByTestId('lookup-phonetic')).toHaveText('/fɜːst/')
|
||||
const panelBounds = await page.locator('.lookup-panel').boundingBox()
|
||||
expect(panelBounds!.y + panelBounds!.height).toBeLessThanOrEqual(845)
|
||||
expect(panelBounds!.height).toBeLessThanOrEqual(844 * 0.46)
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createRouter, createMemoryHistory, type Router } from 'vue-router'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import AudioPlayer from '../components/AudioPlayer.vue'
|
||||
import BookView from '../views/BookView.vue'
|
||||
import LibraryView from '../views/LibraryView.vue'
|
||||
import ReaderView from '../views/ReaderView.vue'
|
||||
import { useLibraryStore } from '../stores/library'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
|
||||
const user = { id: 42, username: 'fictional-attachment', role: 'learner' as const }
|
||||
const cover = { kind: 'cover' as const, mime: 'image/png', byteSize: 1234, version: 'cover-sha', updatedAt: '2026-09-15T00:00:00Z' }
|
||||
const book = { id: 7, title: 'Fictional reader', 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
|
||||
|
||||
/** jsdom has no object URLs, so the two functions are added to the existing URL object. */
|
||||
function stubObjectUrls(): void {
|
||||
let counter = 0
|
||||
;(URL as unknown as Record<string, unknown>).createObjectURL = vi.fn(() => `blob:fictional-${++counter}`)
|
||||
;(URL as unknown as Record<string, unknown>).revokeObjectURL = vi.fn()
|
||||
}
|
||||
|
||||
function putBackObjectUrls(): void {
|
||||
delete (URL as unknown as Record<string, unknown>).createObjectURL
|
||||
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
|
||||
Object.defineProperty(input, 'files', { value: [file], configurable: true })
|
||||
await view.get(`[data-testid="${testid}"]`).trigger('change')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
interface Fixture {
|
||||
chapter: typeof baseChapter & { illustrationVersion?: string; audioVersion?: string; playbackSeconds?: number }
|
||||
requests: string[]
|
||||
}
|
||||
|
||||
/** 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: 'cover-sha', hasAudio: false, createdAt: '', updatedAt: '' }] })
|
||||
}
|
||||
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.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.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.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 }> {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: LibraryView },
|
||||
{ path: '/books/:id', component: BookView },
|
||||
{ path: '/chapters/:id', component: ReaderView },
|
||||
{ path: '/vocab', component: { template: '<div />' } },
|
||||
{ path: '/review', component: { template: '<div />' } },
|
||||
{ path: '/progress', component: { template: '<div />' } },
|
||||
{ path: '/import', component: { template: '<div />' } },
|
||||
],
|
||||
})
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
wrapper = mount(component as never, { attachTo: document.body, global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
return { view: wrapper, router }
|
||||
}
|
||||
|
||||
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 loads its bytes once', async () => {
|
||||
const { fetchMock } = mockApi()
|
||||
const { view } = await mountWith(LibraryView, '/')
|
||||
await vi.waitFor(() => expect(useLibraryStore().coverUrls[7]).toBeTruthy())
|
||||
await flushPromises()
|
||||
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 () => {
|
||||
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('chapter attachments on the book page', () => {
|
||||
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()); stubObjectUrls(); useSessionStore().user = { ...user } })
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); putBackObjectUrls(); wrapper?.unmount() })
|
||||
|
||||
it('shows the cover and the chapter audio state without any chapter image', async () => {
|
||||
const { fetchMock } = mockApi()
|
||||
const { view } = await mountWith(BookView, '/books/7')
|
||||
// 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('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')
|
||||
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('未设置')
|
||||
|
||||
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('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="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('/chapters/3/illustration')
|
||||
expect(view.get('[data-testid="illustration-state"]').text()).toBe('未设置')
|
||||
})
|
||||
})
|
||||
|
||||
describe('audio player component', () => {
|
||||
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); wrapper?.unmount() })
|
||||
|
||||
it('formats the clock, changes the rate and reports an error', async () => {
|
||||
stubMedia()
|
||||
const positions: number[] = []
|
||||
wrapper = mount(AudioPlayer, { props: { src: 'blob:fictional', initialPosition: 65, onPosition: (value: number) => positions.push(value) } })
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[data-testid="audio-clock"]').text()).toContain('1:05')
|
||||
await wrapper.get('[data-testid="audio-rate-1.5"]').trigger('click')
|
||||
expect(wrapper.get('[data-testid="audio-rate-1.5"]').attributes('aria-pressed')).toBe('true')
|
||||
const element = wrapper.get('[data-testid="audio-element"]').element as HTMLAudioElement
|
||||
expect(element.playbackRate).toBe(1.5)
|
||||
element.currentTime = 42
|
||||
await wrapper.get('[data-testid="audio-element"]').trigger('pause')
|
||||
expect(positions).toEqual([42])
|
||||
await wrapper.get('[data-testid="audio-element"]').trigger('error')
|
||||
expect(wrapper.get('[data-testid="audio-error"]').text()).toContain('音频加载失败')
|
||||
})
|
||||
|
||||
it('never starts on its own and tolerates a rejected play', async () => {
|
||||
const { play } = stubMedia()
|
||||
play.mockRejectedValueOnce(new Error('blocked'))
|
||||
wrapper = mount(AudioPlayer, { props: { src: 'blob:fictional', initialPosition: 0, onPosition: () => undefined } })
|
||||
await flushPromises()
|
||||
expect(play).not.toHaveBeenCalled()
|
||||
await wrapper.get('[data-testid="audio-toggle"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(play).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.get('[data-testid="audio-error"]').text()).toContain('无法播放')
|
||||
})
|
||||
})
|
||||
|
||||
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('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()
|
||||
|
||||
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('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/chapters/3/playback')
|
||||
expect(JSON.parse(String(reported?.[1]?.body))).toEqual({ positionSeconds: 77 })
|
||||
})
|
||||
})
|
||||
@@ -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('已保存为新版本,正在重新处理')
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -69,6 +69,38 @@ describe('reader word lookup', () => {
|
||||
expect(view.text()).toContain('Definition of cat')
|
||||
expect(view.text()).toContain('A fictional example.')
|
||||
})
|
||||
it('shows the IPA phonetic and the Chinese gloss ahead of the English detail', async () => {
|
||||
const merged = {
|
||||
status: 'exact', query: 'cat', matchedForm: 'cat', candidates: ['cat'], phonetic: 'kæt', phoneticSource: 'cmudict',
|
||||
resources: [{ name: '简明英汉字典增强版', version: 'ecdict-1.0.28+cmudict-0.7b' }, { name: 'Princeton WordNet', version: '3.0' }],
|
||||
resource: { name: 'Princeton WordNet', version: '3.0' },
|
||||
entries: [
|
||||
{ lemma: 'cat', pos: '', definition: '', examples: [], translation: 'n. 猫, 恶妇\nvi. 呕吐', phonetic: 'kæt', phoneticSource: 'cmudict', provider: 'ecdict' },
|
||||
{ lemma: 'cat', pos: 'n', definition: 'feline animal', examples: [], provider: 'wordnet' },
|
||||
],
|
||||
}
|
||||
const { view } = await open({ lookup: async () => ok(merged) })
|
||||
await view.get('.reader-word').trigger('click'); await flushPromises()
|
||||
expect(view.get('[data-testid="lookup-phonetic"]').text()).toBe('/kæt/')
|
||||
expect(view.get('[data-testid="lookup-translation"]').text()).toContain('猫')
|
||||
expect(view.text()).toContain('feline animal')
|
||||
const senses = view.findAll('.lookup-senses li')
|
||||
expect(senses[0]!.attributes('data-provider')).toBe('ecdict')
|
||||
expect(senses[1]!.attributes('data-provider')).toBe('wordnet')
|
||||
expect(view.text()).toContain('简明英汉字典增强版')
|
||||
expect(view.text()).toContain('Princeton WordNet')
|
||||
})
|
||||
it('shows no phonetic for an inflected form resolved to a headword', async () => {
|
||||
const inflected = {
|
||||
status: 'lemma', query: 'cats', matchedForm: 'cat', candidates: ['cat'],
|
||||
entries: [{ lemma: 'cat', pos: '', definition: '', examples: [], translation: 'n. 猫', provider: 'ecdict' }],
|
||||
}
|
||||
const { view } = await open({ lookup: async () => ok(inflected) })
|
||||
await view.get('.reader-word').trigger('click'); await flushPromises()
|
||||
expect(view.find('[data-testid="lookup-phonetic"]').exists()).toBe(false)
|
||||
expect(view.get('[data-testid="lookup-translation"]').text()).toContain('猫')
|
||||
expect(view.text()).toContain('词形匹配')
|
||||
})
|
||||
it('discards older word responses and clears the selection form', async () => {
|
||||
let finish!: (response: Response) => void
|
||||
const { view } = await open({ lookup: body => body.start === 2 ? new Promise(resolve => { finish = resolve }) : Promise.resolve(ok(result('cafe', 'not_found'))) })
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onScopeDispose, ref, watch } from 'vue'
|
||||
import { ElButton } from 'element-plus'
|
||||
|
||||
/**
|
||||
* The audio player for one book. It only appears when the book has audio, never starts on its own,
|
||||
* and remembers the position per account so reading can continue where it stopped. Nothing here
|
||||
* aligns the audio with the text: the two are independent, as the ticket requires.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
src: string
|
||||
/** Where this account stopped last time, in seconds. */
|
||||
initialPosition: number
|
||||
/** Called with the position while playing, when pausing and when leaving. */
|
||||
onPosition: (seconds: number) => void
|
||||
}>()
|
||||
|
||||
const audio = ref<HTMLAudioElement | null>(null)
|
||||
const playing = ref(false)
|
||||
const position = ref(props.initialPosition)
|
||||
const duration = ref(0)
|
||||
const rate = ref(1)
|
||||
const error = ref('')
|
||||
const rates = [0.75, 1, 1.25, 1.5]
|
||||
|
||||
const positionLabel = computed(() => formatClock(position.value))
|
||||
const durationLabel = computed(() => (duration.value > 0 ? formatClock(duration.value) : '--:--'))
|
||||
|
||||
function formatClock(seconds: number): string {
|
||||
const total = Math.max(0, Math.floor(seconds))
|
||||
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
async function toggle(): Promise<void> {
|
||||
const element = audio.value
|
||||
if (!element) return
|
||||
error.value = ''
|
||||
if (element.paused) {
|
||||
try {
|
||||
await element.play()
|
||||
} catch {
|
||||
// A rejected play() is usually a blocked autoplay attempt or a missing file.
|
||||
error.value = '音频无法播放,请重试。'
|
||||
playing.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
element.pause()
|
||||
}
|
||||
|
||||
function onLoaded(): void {
|
||||
const element = audio.value
|
||||
if (!element) return
|
||||
duration.value = Number.isFinite(element.duration) ? element.duration : 0
|
||||
// The stored position is applied once the metadata is known, so seeking actually lands.
|
||||
if (props.initialPosition > 0 && props.initialPosition < duration.value) {
|
||||
element.currentTime = props.initialPosition
|
||||
}
|
||||
}
|
||||
|
||||
function onSeek(event: Event): void {
|
||||
const element = audio.value
|
||||
if (!element) return
|
||||
element.currentTime = Number((event.target as HTMLInputElement).value)
|
||||
position.value = element.currentTime
|
||||
}
|
||||
|
||||
function setRate(value: number): void {
|
||||
rate.value = value
|
||||
if (audio.value) audio.value.playbackRate = value
|
||||
}
|
||||
|
||||
/** Reports the position on a fixed cadence while playing, so a long listen is not lost. */
|
||||
let reportTimer: number | undefined
|
||||
function onTimeUpdate(): void {
|
||||
const element = audio.value
|
||||
if (!element) return
|
||||
position.value = element.currentTime
|
||||
if (reportTimer !== undefined) return
|
||||
reportTimer = window.setInterval(() => {
|
||||
reportTimer = undefined
|
||||
if (audio.value && !audio.value.paused) props.onPosition(audio.value.currentTime)
|
||||
startReporting()
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
function startReporting(): void {
|
||||
if (reportTimer !== undefined) return
|
||||
reportTimer = window.setInterval(() => {
|
||||
if (audio.value && !audio.value.paused) props.onPosition(audio.value.currentTime)
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
function stopReporting(): void {
|
||||
if (reportTimer !== undefined) {
|
||||
window.clearInterval(reportTimer)
|
||||
reportTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function onPause(): void {
|
||||
playing.value = false
|
||||
stopReporting()
|
||||
if (audio.value) props.onPosition(audio.value.currentTime)
|
||||
}
|
||||
|
||||
function onPlay(): void {
|
||||
playing.value = true
|
||||
startReporting()
|
||||
}
|
||||
|
||||
function retry(): void {
|
||||
error.value = ''
|
||||
const element = audio.value
|
||||
if (element) {
|
||||
element.load()
|
||||
void toggle()
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.src, () => {
|
||||
error.value = ''
|
||||
position.value = props.initialPosition
|
||||
playing.value = false
|
||||
})
|
||||
|
||||
// Leaving the page stores the last position, so the same book continues where it stopped.
|
||||
onScopeDispose(() => {
|
||||
stopReporting()
|
||||
if (audio.value) props.onPosition(audio.value.currentTime)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="audio-player" aria-label="音频播放" data-testid="audio-player">
|
||||
<ElButton
|
||||
circle
|
||||
type="primary"
|
||||
data-testid="audio-toggle"
|
||||
:aria-label="playing ? '暂停音频' : '播放音频'"
|
||||
@click="toggle"
|
||||
>{{ playing ? '❚❚' : '▶' }}</ElButton>
|
||||
<span class="audio-clock" data-testid="audio-clock">{{ positionLabel }} / {{ durationLabel }}</span>
|
||||
<input
|
||||
class="audio-range"
|
||||
type="range"
|
||||
min="0"
|
||||
:max="duration > 0 ? duration : 0"
|
||||
step="1"
|
||||
:value="position"
|
||||
aria-label="播放进度"
|
||||
data-testid="audio-seek"
|
||||
:disabled="duration === 0"
|
||||
@input="onSeek"
|
||||
/>
|
||||
<div class="audio-rates" role="group" aria-label="播放速度">
|
||||
<button
|
||||
v-for="value in rates"
|
||||
:key="value"
|
||||
type="button"
|
||||
class="audio-rate"
|
||||
:class="{ 'is-active': rate === value }"
|
||||
:aria-pressed="rate === value"
|
||||
:data-testid="`audio-rate-${value}`"
|
||||
@click="setRate(value)"
|
||||
>{{ value }}×</button>
|
||||
</div>
|
||||
<span v-if="error" role="alert" class="audio-error" data-testid="audio-error">{{ error }}
|
||||
<ElButton text size="small" data-testid="audio-retry" @click="retry">重试</ElButton>
|
||||
</span>
|
||||
<audio
|
||||
ref="audio"
|
||||
:src="src"
|
||||
preload="metadata"
|
||||
data-testid="audio-element"
|
||||
@loadedmetadata="onLoaded"
|
||||
@timeupdate="onTimeUpdate"
|
||||
@play="onPlay"
|
||||
@pause="onPause"
|
||||
@ended="onPause"
|
||||
@error="error = '音频加载失败。'"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
@@ -48,7 +48,10 @@ onUnmounted(() => { observer?.disconnect(); window.removeEventListener('resize',
|
||||
<template>
|
||||
<aside ref="panel" class="lookup-panel" aria-labelledby="lookup-heading" @keydown.esc.stop.prevent="$emit('close')">
|
||||
<header class="lookup-heading">
|
||||
<h2 id="lookup-heading" ref="heading" tabindex="-1">{{ word }}</h2>
|
||||
<h2 id="lookup-heading" ref="heading" tabindex="-1">
|
||||
{{ word }}
|
||||
<span v-if="result?.phonetic" class="lookup-phonetic" data-testid="lookup-phonetic" lang="en">/{{ result.phonetic }}/</span>
|
||||
</h2>
|
||||
<ElButton text aria-label="关闭释义" @click="$emit('close')">关闭</ElButton>
|
||||
</header>
|
||||
<div v-if="phrase" class="lookup-range" data-testid="phrase-range">
|
||||
@@ -68,13 +71,16 @@ onUnmounted(() => { observer?.disconnect(); window.removeEventListener('resize',
|
||||
<p v-else-if="result.status === 'not_found'" class="lookup-message">未找到释义。</p>
|
||||
<p v-if="result.status === 'lemma'" class="subtle">词形匹配:{{ result.matchedForm }}(规则候选)</p>
|
||||
<ol v-if="result.entries.length" class="lookup-senses">
|
||||
<li v-for="(entry, index) in result.entries" :key="index">
|
||||
<p class="sense-heading"><strong>{{ entry.lemma }}</strong> <span>{{ entry.pos }}</span></p>
|
||||
<p lang="en">{{ entry.definition }}</p>
|
||||
<li v-for="(entry, index) in result.entries" :key="index" :data-provider="entry.provider || ''">
|
||||
<p class="sense-heading"><strong>{{ entry.lemma }}</strong> <span v-if="entry.pos">{{ entry.pos }}</span></p>
|
||||
<p v-if="entry.translation" class="lookup-translation" data-testid="lookup-translation">{{ entry.translation }}</p>
|
||||
<p v-if="entry.definition" lang="en">{{ entry.definition }}</p>
|
||||
<blockquote v-for="(example, exampleIndex) in entry.examples" :key="exampleIndex" lang="en">{{ example }}</blockquote>
|
||||
</li>
|
||||
</ol>
|
||||
<p v-if="result.resource" class="subtle">{{ result.resource.name }} · {{ result.resource.version }}</p>
|
||||
<p v-if="result.resources?.length || result.resource" class="subtle">
|
||||
{{ (result.resources?.length ? result.resources : [result.resource!]).map(item => `${item.name} · ${item.version}`).join(';') }}
|
||||
</p>
|
||||
</template>
|
||||
<ElButton v-if="!loading && (error || result?.status === 'resource_missing' || result?.status === 'not_found')" data-testid="lookup-retry" @click="$emit('retry')">重试查询</ElButton>
|
||||
</div>
|
||||
|
||||
@@ -17,13 +17,29 @@ export interface ReaderToken {
|
||||
}
|
||||
/** One saved phrase occurrence inside the chapter, in token indices. */
|
||||
export interface PhraseSpan { id: number; status: TermStatus; wordCount: number; startToken: number; endToken: number }
|
||||
export interface LookupEntry {
|
||||
lemma: string
|
||||
pos: string
|
||||
definition: string
|
||||
examples: string[]
|
||||
/** Chinese gloss and phonetic, present when the 英汉 dictionary is enabled. */
|
||||
translation?: string
|
||||
phonetic?: string
|
||||
/** "cmudict" for a CMUdict IPA transcription, "ecdict" for the ECDICT notation. */
|
||||
phoneticSource?: string
|
||||
provider?: string
|
||||
}
|
||||
export interface LookupResult {
|
||||
status: 'exact' | 'lemma' | 'not_found' | 'resource_missing'
|
||||
query: string
|
||||
matchedForm: string | null
|
||||
candidates: string[]
|
||||
entries: { lemma: string; pos: string; definition: string; examples: string[] }[]
|
||||
entries: LookupEntry[]
|
||||
resource?: { name: string; version: string }
|
||||
resources?: { name: string; version: string }[]
|
||||
/** The headword phonetic, repeated at the top level so the panel can show it next to the word. */
|
||||
phonetic?: string
|
||||
phoneticSource?: string
|
||||
}
|
||||
export interface SavedTerm {
|
||||
id: number
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -4,9 +4,44 @@ import { ApiError, useSessionStore } from './session'
|
||||
|
||||
export type ChapterStatus = 'pending' | 'processing' | 'ready' | 'failed'
|
||||
|
||||
export interface BookRef { id: number; title: string; language: string }
|
||||
export type AttachmentKind = 'audio' | 'cover'
|
||||
|
||||
/** One stored attachment: metadata only, the bytes are fetched from the attachment endpoint. */
|
||||
export interface BookAttachment {
|
||||
kind: AttachmentKind
|
||||
mime: string
|
||||
byteSize: number
|
||||
/** Content digest, used as the cache key so a replaced file is fetched again. */
|
||||
version: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/** The book keeps its cover; audio and illustrations belong to a chapter. */
|
||||
export interface BookAttachments {
|
||||
cover: BookAttachment | null
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export interface BookSummary extends BookRef {
|
||||
coverVersion?: string
|
||||
hasAudio?: boolean
|
||||
chapterCount: number
|
||||
pendingCount: number
|
||||
processingCount: number
|
||||
@@ -21,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
|
||||
@@ -30,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
|
||||
}
|
||||
@@ -60,6 +102,7 @@ export interface ChapterSource {
|
||||
bookId: number
|
||||
ordinal: number
|
||||
title: string
|
||||
author: string
|
||||
text: string
|
||||
status: ChapterStatus
|
||||
contentSha256: string
|
||||
@@ -188,6 +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<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 ?? '' : ''))
|
||||
@@ -278,6 +324,9 @@ export const useLibraryStore = defineStore('library', () => {
|
||||
if (seq !== booksSeq || isStale(version, owner)) return
|
||||
const items = result?.items
|
||||
books.value = Array.isArray(items) ? items : []
|
||||
for (const book of books.value) {
|
||||
if (book.coverVersion) void loadCover(book.id, book.coverVersion)
|
||||
}
|
||||
} catch (reason) {
|
||||
if (seq !== booksSeq || isStale(version, owner)) return
|
||||
booksError.value = failureMessage(reason, '书库暂时无法加载,请稍后重试。')
|
||||
@@ -300,6 +349,11 @@ export const useLibraryStore = defineStore('library', () => {
|
||||
book.value = result.book
|
||||
chapters.value = Array.isArray(result.chapters) ? result.chapters : []
|
||||
bookError.value = ''
|
||||
// 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
|
||||
@@ -341,6 +395,17 @@ export const useLibraryStore = defineStore('library', () => {
|
||||
chapter.value = result.chapter
|
||||
navigation.value = result.navigation ?? emptyNavigation()
|
||||
chapterError.value = ''
|
||||
// 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 loadChapterAudio(result.chapter.id, audio).catch(() => undefined)
|
||||
} else {
|
||||
audioUrl.value = ''
|
||||
audioChapterId.value = null
|
||||
}
|
||||
schedulePolling()
|
||||
} catch (reason) {
|
||||
if (seq !== chapterSeq || isStale(version, owner)) return
|
||||
@@ -457,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
|
||||
}
|
||||
@@ -476,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) {
|
||||
@@ -487,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
|
||||
@@ -575,6 +646,223 @@ export const useLibraryStore = defineStore('library', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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 & 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
|
||||
uploadingId.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 }>(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
|
||||
uploadingId.value = 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') {
|
||||
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(`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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }>(
|
||||
`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>()
|
||||
|
||||
function rememberBlob(blob: Blob): string {
|
||||
const url = URL.createObjectURL(blob)
|
||||
liveBlobUrls.add(url)
|
||||
return url
|
||||
}
|
||||
|
||||
function releaseBlobs(): void {
|
||||
for (const url of liveBlobUrls) URL.revokeObjectURL(url)
|
||||
liveBlobUrls.clear()
|
||||
loadedVersions.clear()
|
||||
coverUrls.value = {}
|
||||
illustrationUrls.value = {}
|
||||
audioUrl.value = ''
|
||||
audioChapterId.value = null
|
||||
}
|
||||
|
||||
/** Loads a cover for the library list, once per version. */
|
||||
async function loadCover(bookId: number, version: string): Promise<void> {
|
||||
if (!version || loadedVersions.get(`cover:${bookId}`) === version) return
|
||||
try {
|
||||
const blob = await session.requestBlob(`books/${bookId}/cover`)
|
||||
if (!session.user) return
|
||||
loadedVersions.set(`cover:${bookId}`, version)
|
||||
coverUrls.value = { ...coverUrls.value, [bookId]: rememberBlob(blob) }
|
||||
} catch {
|
||||
// A cover that cannot be read simply keeps the default styling.
|
||||
}
|
||||
}
|
||||
|
||||
/** 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(`chapters/${chapterId}/illustration`)
|
||||
if (!session.user) return
|
||||
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('音频暂时无法加载。')
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops one cached cover so a replaced file is fetched again. */
|
||||
function forgetCover(bookId: number): void {
|
||||
loadedVersions.delete(`cover:${bookId}`)
|
||||
const url = coverUrls.value[bookId]
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks one owned chapter as read. Completing only records the reading fact: word statuses and
|
||||
* levels are left alone. A repeated request answers with the same mark instead of counting
|
||||
@@ -646,6 +934,9 @@ export const useLibraryStore = defineStore('library', () => {
|
||||
submitError.value = ''
|
||||
retryingChapterId.value = null
|
||||
completingChapterId.value = null
|
||||
uploadingKind.value = null
|
||||
uploadingId.value = null
|
||||
releaseBlobs()
|
||||
submissionKey = ''
|
||||
submissionRequestId = ''
|
||||
}
|
||||
@@ -656,7 +947,10 @@ export const useLibraryStore = defineStore('library', () => {
|
||||
chapter, chapterBook, navigation, chapterLoading, chapterError,
|
||||
submitting, submitError, retryingChapterId, completingChapterId, readerText,
|
||||
loadBooks, loadBook, loadChapter, submit, upload, retryChapter, markChapterRead,
|
||||
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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -53,6 +53,29 @@ export const useSessionStore = defineStore('session', () => {
|
||||
return result.data as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches binary content with the same session as every other request. A media element cannot
|
||||
* carry an Authorization header, so the bytes are fetched here and handed to the page as a blob
|
||||
* URL instead of putting the token into a URL where it could be logged or shared.
|
||||
*/
|
||||
async function requestBlob(path: string): Promise<Blob> {
|
||||
const response = await fetch(`/api/v1/${path}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
cache: 'no-store',
|
||||
})
|
||||
if (!response.ok) {
|
||||
let message = '附件暂时无法加载。'
|
||||
try {
|
||||
const result = await response.json()
|
||||
if (result?.msg) message = result.msg
|
||||
} catch {
|
||||
// A non-JSON error body keeps the default message.
|
||||
}
|
||||
throw new ApiError(message, response.status)
|
||||
}
|
||||
return response.blob()
|
||||
}
|
||||
|
||||
async function login(username: string, password: string) {
|
||||
clear()
|
||||
initialized = true
|
||||
@@ -104,5 +127,5 @@ export const useSessionStore = defineStore('session', () => {
|
||||
if (previousToken) await request<null>('logout', 'POST', undefined, previousToken, -1)
|
||||
}
|
||||
|
||||
return { user, space, notice, login, restore, logout, loadSpace, request }
|
||||
return { user, space, notice, login, restore, logout, loadSpace, request, requestBlob }
|
||||
})
|
||||
|
||||
+67
-2
@@ -227,11 +227,12 @@ h1 { font-size: 30px; font-weight: 600; margin: 14px 0; letter-spacing: 1px; }
|
||||
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; }
|
||||
.reader-page { max-width: min(1040px, 92vw); }
|
||||
.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); }
|
||||
.reader-page.has-lookup { max-width: 1120px; }
|
||||
.reader-page.has-lookup { max-width: min(1240px, 96vw); }
|
||||
.reader-workspace { display: grid; grid-template-columns: minmax(0, 1fr); gap: 32px; align-items: start; }
|
||||
.has-lookup .reader-workspace { grid-template-columns: minmax(0, 1fr) 320px; }
|
||||
.reader-body { min-width: 0; }
|
||||
@@ -253,6 +254,8 @@ a.chapter-name:hover { color: var(--accent); text-decoration: underline; }
|
||||
.lookup-senses li { padding-left: 3px; margin-bottom: 20px; }
|
||||
.lookup-senses p { margin: 8px 0; }
|
||||
.sense-heading span { color: var(--text-muted); font-size: 13px; }
|
||||
.lookup-phonetic { color: var(--text-muted); font-size: 15px; font-weight: 400; margin-left: 8px; }
|
||||
.lookup-translation { white-space: pre-line; }
|
||||
.lookup-senses blockquote { border-left: 2px solid var(--status-ready-border); margin: 10px 0; padding-left: 12px; color: var(--text-faint); font-style: italic; }
|
||||
.lookup-range { border-bottom: 1px solid var(--border); padding-bottom: 14px; }
|
||||
.lookup-range p { margin: 0 0 10px; }
|
||||
@@ -267,6 +270,9 @@ a.chapter-name:hover { color: var(--accent); text-decoration: underline; }
|
||||
.lookup-term .el-radio-group { margin-top: 8px; flex-wrap: wrap; gap: 4px 12px; }
|
||||
.lookup-term .el-textarea { margin-top: 8px; }
|
||||
.lookup-term .el-textarea textarea { font: inherit; line-height: 1.6; }
|
||||
/* The chapter body is the main editing surface, so it grows with the window instead of staying
|
||||
fixed at its rows attribute; the dialog is sized by viewport so a small laptop still fits. */
|
||||
.chapter-text-input .el-textarea__inner { min-height: 62vh; line-height: 1.9; }
|
||||
.lookup-saved { color: var(--saved); font-size: 14px; margin: 12px 0 0; }
|
||||
.lookup-actions { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
|
||||
.review-page { max-width: 680px; }
|
||||
@@ -345,3 +351,62 @@ 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; }
|
||||
.book-cover-default { display: grid; place-items: center; width: 100%; height: 100%; font-family: Georgia, serif; font-size: 40px; color: var(--accent); }
|
||||
/* Book attachments: metadata, preview and the upload controls. */
|
||||
.attachments { margin-top: 26px; padding: 22px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); }
|
||||
.attachments h2 { margin: 0 0 16px; font-size: 19px; }
|
||||
.attachment-row { display: flex; gap: 18px; align-items: flex-start; padding: 14px 0; border-top: 1px solid var(--border); }
|
||||
.attachment-row:first-of-type { border-top: 0; padding-top: 0; }
|
||||
.attachment-preview { flex: 0 0 160px; aspect-ratio: 16 / 9; border-radius: 8px; overflow: hidden; background: var(--accent-soft); }
|
||||
.attachment-preview img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.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); }
|
||||
.attachment-actions label.is-busy { opacity: 0.6; cursor: progress; }
|
||||
/* Audio player: sticky so the controls stay reachable while the chapter is read. */
|
||||
.audio-player { position: sticky; top: 0; z-index: 5; display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-bottom: 18px; padding: 12px 16px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); box-shadow: 0 2px 10px #233d3114; }
|
||||
.audio-clock { font-size: 13px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
||||
.audio-range { flex: 1 1 160px; min-width: 120px; accent-color: var(--accent); }
|
||||
.audio-rates { display: flex; gap: 4px; }
|
||||
.audio-rate { min-width: 44px; min-height: 32px; border: 1px solid var(--border-strong); border-radius: 16px; background: var(--surface-raised); color: var(--text); font-size: 12px; cursor: pointer; }
|
||||
.audio-rate.is-active { border-color: var(--accent); color: var(--accent); font-weight: 600; }
|
||||
.audio-error { display: inline-flex; align-items: center; gap: 6px; color: var(--notice-text); font-size: 13px; }
|
||||
.audio-player audio { display: none; }
|
||||
|
||||
+261
-17
@@ -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, 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'
|
||||
|
||||
@@ -16,11 +16,13 @@ const notice = ref('')
|
||||
// Renaming the book.
|
||||
const bookDialog = ref(false)
|
||||
const bookTitle = ref('')
|
||||
const bookAuthor = ref('')
|
||||
const bookError = ref('')
|
||||
// Editing one chapter: title and text, loaded from the source endpoint.
|
||||
const chapterDialog = ref(false)
|
||||
const chapterSource = ref<ChapterSource | null>(null)
|
||||
const chapterTitle = ref('')
|
||||
const chapterAuthor = ref('')
|
||||
const chapterText = ref('')
|
||||
const chapterError = ref('')
|
||||
const chapterLoading = ref(false)
|
||||
@@ -28,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 = ''
|
||||
@@ -42,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
|
||||
}
|
||||
@@ -52,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 {
|
||||
@@ -66,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 : '章节内容暂时无法加载,请稍后重试。'
|
||||
@@ -86,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) {
|
||||
@@ -162,16 +341,33 @@ onUnmounted(() => library.closeBook())
|
||||
<template v-else-if="library.book">
|
||||
<div class="page-title">
|
||||
<div>
|
||||
<h1>{{ library.book.title }}</h1>
|
||||
<p class="subtle">{{ library.chapters.length }} 个章节 · 语言 英语 · 封面使用系统默认样式</p>
|
||||
<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="cover-row" aria-label="书籍封面" data-testid="attachments">
|
||||
<div class="cover-preview">
|
||||
<img v-if="library.coverUrls[library.book.id]" :src="library.coverUrls[library.book.id]" alt="当前封面" data-testid="cover-preview" />
|
||||
<span v-else class="book-cover-default" aria-hidden="true">{{ library.book.title.slice(0, 1) }}</span>
|
||||
</div>
|
||||
<div class="cover-actions">
|
||||
<label class="link-button" :class="{ 'is-busy': library.uploadingKind === 'cover' }">
|
||||
{{ library.book.attachments?.cover ? '替换封面' : '上传封面' }}
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp" data-testid="cover-input" :disabled="library.uploadingKind !== null" @change="onCoverPicked" />
|
||||
</label>
|
||||
<ElButton v-if="library.book.attachments?.cover" size="small" text :disabled="library.uploadingKind !== null" data-testid="cover-remove" @click="removeCover">移除</ElButton>
|
||||
<span v-if="coverError" role="alert" class="field-error">{{ coverError }}</span>
|
||||
</div>
|
||||
</section>
|
||||
<p v-if="retryError" role="alert" class="notice">{{ retryError }}</p>
|
||||
<ul v-if="library.chapters.length" class="chapter-list" aria-label="章节列表">
|
||||
<li v-for="item in library.chapters" :key="item.id" class="chapter-row">
|
||||
@@ -181,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>
|
||||
@@ -201,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="560" 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>
|
||||
@@ -212,15 +414,57 @@ onUnmounted(() => library.closeBook())
|
||||
</template>
|
||||
</ElDialog>
|
||||
|
||||
<ElDialog v-model="chapterDialog" title="编辑章节" width="620" data-testid="chapter-dialog">
|
||||
<ElDialog v-model="chapterDialog" title="编辑章节" width="min(1180px, 94vw)" 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" class="chapter-text-input" :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>
|
||||
|
||||
@@ -62,6 +62,17 @@ onMounted(load)
|
||||
</div>
|
||||
<ul v-else-if="library.books.length" class="book-grid" aria-label="书籍列表">
|
||||
<li v-for="item in library.books" :key="item.id" class="book-card">
|
||||
<!-- The cover repeats the title link, so it is hidden from assistive technology and from
|
||||
keyboard order: the title link next to it is the accessible way into the book. -->
|
||||
<RouterLink
|
||||
:to="`/books/${item.id}`"
|
||||
class="book-cover"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
>
|
||||
<img v-if="library.coverUrls[item.id]" :src="library.coverUrls[item.id]" alt="" data-testid="book-cover" />
|
||||
<span v-else class="book-cover-default">{{ item.title.slice(0, 1) }}</span>
|
||||
</RouterLink>
|
||||
<RouterLink :to="`/books/${item.id}`" class="book-title">{{ item.title }}</RouterLink>
|
||||
<p class="subtle">{{ item.chapterCount }} 个章节</p>
|
||||
<p v-if="statusSummary(item)" class="status-summary">{{ statusSummary(item) }}</p>
|
||||
|
||||
@@ -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'
|
||||
@@ -9,6 +9,7 @@ import { adjustTokenRange, MAX_PHRASE_WORDS, normalizeTokenRange, phrasesAt, ran
|
||||
import { currentRatio, readPosition, writePosition } from '../composables/readingPosition'
|
||||
import { useTextSelection } from '../composables/useTextSelection'
|
||||
import ReaderTokens from '../components/ReaderTokens.vue'
|
||||
import AudioPlayer from '../components/AudioPlayer.vue'
|
||||
import DisplaySettings from '../components/DisplaySettings.vue'
|
||||
import LookupPanel from '../components/LookupPanel.vue'
|
||||
|
||||
@@ -85,14 +86,36 @@ 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 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 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.saveChapterPlayback(chapterId, seconds).catch(() => undefined)
|
||||
}
|
||||
|
||||
// Reading position: remembered per account and chapter, and only while the text is the version
|
||||
// the position was taken from. The restore waits for the chapter to be on screen and for the
|
||||
// browser to lay the paragraph out, otherwise the saved ratio would land in the wrong place.
|
||||
@@ -223,10 +246,17 @@ onUnmounted(() => {
|
||||
<RouterLink v-if="library.chapterBook" :to="`/books/${library.chapterBook.id}`">{{ library.chapterBook.title }}</RouterLink>
|
||||
<span v-else>章节</span>
|
||||
</p>
|
||||
<AudioPlayer
|
||||
v-if="audioSource"
|
||||
:src="audioSource"
|
||||
:initial-position="audioStart"
|
||||
:on-position="rememberPlayback"
|
||||
/>
|
||||
<div class="page-title">
|
||||
<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>
|
||||
@@ -235,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
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Build the prepared 英汉词典 resource for LexGo (#40).
|
||||
|
||||
This is tooling, not product runtime: the Go server only imports the prepared ZIP.
|
||||
Two pinned sources are combined:
|
||||
|
||||
* ECDICT StarDict (MIT) -> Chinese glosses and a fallback phonetic notation
|
||||
* CMUdict (BSD-2) -> ARPAbet phonemes transcribed to IPA
|
||||
|
||||
The subset is limited to lowercase single-word headwords that either exist in the
|
||||
WordNet lemma list (pinned in server/wordnet-resource.json) or carry an ECDICT
|
||||
exam tag, which keeps the resource at a few MiB instead of the full 340 万条.
|
||||
|
||||
Everything is verified: source sha256, subset size within the pinned tolerance and
|
||||
the phonetic character set (no character outside the IPA alphabet may survive).
|
||||
|
||||
Usage:
|
||||
python scripts/dict_prepare.py # download + build into .local/dictionaries
|
||||
python scripts/dict_prepare.py --cache-dir DIR # reuse already downloaded sources
|
||||
python scripts/dict_prepare.py --keep-going # still write the artifact if counts drift
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PIN_FILE = ROOT / "server" / "zh-dictionary-resource.json"
|
||||
DEFAULT_OUT = ROOT / ".local" / "dictionaries" / "zh-dict-v1.zip"
|
||||
DEFAULT_CACHE = ROOT / ".local" / "dictionaries" / "cache"
|
||||
|
||||
EXAM_TAG_RE = re.compile(r"\((?:[^)]*?(研|四|六|托|专|雅|高|中|初))[^)]*?\)")
|
||||
PHONETIC_RE = re.compile(r"^\*?\[([^\]\u4e00-\u9fff]*)\]")
|
||||
CJK_RE = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]")
|
||||
SPACE_RE = re.compile(r"\s+")
|
||||
|
||||
# ARPAbet (CMUdict, American English) -> IPA, a fixed documented convention.
|
||||
ARPABET_CONSONANTS = {
|
||||
"B": "b", "CH": "t͡ʃ", "D": "d", "DH": "ð", "F": "f", "G": "ɡ", "HH": "h",
|
||||
"JH": "d͡ʒ", "K": "k", "L": "l", "M": "m", "N": "n", "NG": "ŋ", "P": "p",
|
||||
"R": "ɹ", "S": "s", "SH": "ʃ", "T": "t", "TH": "θ", "V": "v", "W": "w",
|
||||
"Y": "j", "Z": "z", "ZH": "ʒ",
|
||||
}
|
||||
ARPABET_VOWELS = {
|
||||
"AA": ("ɑ", "ː"), "AE": ("æ", ""), "AH": ("ʌ", ""), "AO": ("ɔ", "ː"),
|
||||
"AW": ("a", "ʊ"), "AY": ("a", "ɪ"), "EH": ("e", ""), "ER": ("ɜ", "ː"),
|
||||
"EY": ("e", "ɪ"), "IH": ("ɪ", ""), "IY": ("i", "ː"), "OW": ("o", "ʊ"),
|
||||
"OY": ("ɔ", "ɪ"), "UH": ("ʊ", ""), "UW": ("u", "ː"),
|
||||
}
|
||||
STRESS_MARKS = {"1": "ˈ", "2": "ˌ", "0": ""}
|
||||
# Only character-level substitutions whose meaning is verifiable from the data.
|
||||
ECDICT_CHAR_MAP = {
|
||||
"'": "ˈ", ",": "ˌ", ".": "ˌ", "ˊ": "ˈ", ":": "ː", "ә": "ə", "ε": "e", # ә is U+04D9, ε U+03B5
|
||||
" ": "", "-": "", "=": "", ";": "", "^": "", # ^ is a stray separator mid-word
|
||||
}
|
||||
IPA_ALPHABET = set(
|
||||
"abcdefghijklmnopqrstuvwxyz"
|
||||
"æɑɒɔəɜɛɪʊʌʃʒθðŋɹɡɚɝt͡ʃd͡ʒ"
|
||||
"ːˈˌ"
|
||||
)
|
||||
|
||||
|
||||
def log(message: str) -> None:
|
||||
print(message, flush=True)
|
||||
|
||||
|
||||
def digest(path: Path) -> str:
|
||||
hasher = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
hasher.update(block)
|
||||
return hasher.hexdigest()
|
||||
|
||||
|
||||
def download(source: dict, cache_dir: Path) -> Path:
|
||||
"""Download a pinned source, trying every mirror, and verify sha256 + size."""
|
||||
name = source["name"]
|
||||
target = cache_dir / source_filename(source["source"])
|
||||
urls = [source["source"]] + list(source.get("mirrors", []))
|
||||
if target.exists() and digest(target) == source["sha256"]:
|
||||
log(f"[cache] {name}: {target.name} ({target.stat().st_size} 字节, sha256 匹配)")
|
||||
return target
|
||||
last_error = None
|
||||
for url in urls:
|
||||
log(f"[download] {name}: {url}")
|
||||
try:
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "lexgo-dict-prepare/1"})
|
||||
with urllib.request.urlopen(request, timeout=300) as response, target.open("wb") as handle:
|
||||
while True:
|
||||
block = response.read(1024 * 256)
|
||||
if not block:
|
||||
break
|
||||
handle.write(block)
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as error:
|
||||
last_error = error
|
||||
log(f"[download] {name}: 失败 {type(error).__name__}: {error}")
|
||||
continue
|
||||
found = digest(target)
|
||||
if found == source["sha256"]:
|
||||
log(f"[download] {name}: 完成 {target.stat().st_size} 字节, sha256 匹配")
|
||||
return target
|
||||
log(f"[download] {name}: sha256 不匹配 ({found[:16]}… != {source['sha256'][:16]}…),尝试下一个通道")
|
||||
raise SystemExit(f"{name}: 所有通道都无法取得通过校验的资源(最后错误 {last_error})")
|
||||
|
||||
|
||||
def source_filename(url: str) -> str:
|
||||
return Path(urllib.parse.urlparse(url).path).name
|
||||
|
||||
|
||||
def wordnet_lemmas(archive: Path) -> set:
|
||||
lemmas = set()
|
||||
with zipfile.ZipFile(archive) as bundle:
|
||||
for name in bundle.namelist():
|
||||
if "/index." not in name:
|
||||
continue
|
||||
for line in bundle.read(name).decode("latin-1").splitlines():
|
||||
if not line.strip() or line.startswith(" "):
|
||||
continue
|
||||
lemmas.add(line.split(" ", 1)[0].lower().replace("_", " "))
|
||||
return lemmas
|
||||
|
||||
|
||||
def cmudict_entries(path: Path) -> dict:
|
||||
entries = {}
|
||||
for line in path.read_text(encoding="latin-1").splitlines():
|
||||
if line.startswith(";;;") or not line.strip():
|
||||
continue
|
||||
head, _, rest = line.partition(" ")
|
||||
head = head.split("(", 1)[0].lower()
|
||||
if head and rest.strip() and head not in entries:
|
||||
entries[head] = rest.split()
|
||||
return entries
|
||||
|
||||
|
||||
def stardict_entries(archive: Path):
|
||||
with zipfile.ZipFile(archive) as bundle:
|
||||
names = bundle.namelist()
|
||||
ifo_name = next((n for n in names if n.endswith(".ifo")), None)
|
||||
idx_name = next((n for n in names if n.endswith(".idx")), None)
|
||||
dict_name = next((n for n in names if n.endswith(".dict")), None)
|
||||
if not (ifo_name and idx_name and dict_name):
|
||||
raise SystemExit("ECDICT 归档缺少 .ifo/.idx/.dict")
|
||||
ifo = bundle.read(ifo_name).decode("utf-8", "replace")
|
||||
sequence = ""
|
||||
for line in ifo.splitlines():
|
||||
if line.startswith("sametypesequence="):
|
||||
sequence = line.split("=", 1)[1].strip()
|
||||
if sequence != "m":
|
||||
raise SystemExit(f"未预期的 StarDict sametypesequence: {sequence!r}(只处理纯文本 m)")
|
||||
index = bundle.read(idx_name)
|
||||
payload = bundle.read(dict_name)
|
||||
offset = 0
|
||||
while offset < len(index):
|
||||
end = index.index(b"\x00", offset)
|
||||
word = index[offset:end].decode("utf-8", "replace")
|
||||
start, size = struct.unpack(">II", index[end + 1:end + 9])
|
||||
yield word, payload[start:start + size].decode("utf-8", "replace")
|
||||
offset = end + 9
|
||||
|
||||
|
||||
def arpabet_to_ipa(phonemes) -> str:
|
||||
vowel_count = sum(1 for phoneme in phonemes if phoneme.rstrip("012").strip() in ARPABET_VOWELS)
|
||||
parts = []
|
||||
for phoneme in phonemes:
|
||||
stress = ""
|
||||
base = phoneme
|
||||
if phoneme and phoneme[-1].isdigit():
|
||||
base, digit = phoneme[:-1], phoneme[-1]
|
||||
stress = STRESS_MARKS.get(digit, "")
|
||||
if base in ARPABET_VOWELS and vowel_count == 1:
|
||||
# A monosyllable carries no stress mark in IPA (cat is /kæt/, not /kˈæt/).
|
||||
stress = ""
|
||||
if base in ARPABET_CONSONANTS:
|
||||
parts.append(ARPABET_CONSONANTS[base])
|
||||
elif base in ARPABET_VOWELS:
|
||||
head, tail = ARPABET_VOWELS[base]
|
||||
if stress == "":
|
||||
# Unstressed vowels are short: curiosity ends /əti/, not /ətiː/.
|
||||
tail = ""
|
||||
if base == "AH":
|
||||
head = "ə"
|
||||
elif base == "ER":
|
||||
head = "ɚ"
|
||||
parts.append(stress + head + tail)
|
||||
else:
|
||||
return ""
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def normalise_ecdict_phonetic(raw: str) -> str:
|
||||
text = raw.strip()
|
||||
# A phonetic that starts with the stray caret has lost its first sound (grok is stored as
|
||||
# "^rɔk"): showing "rɔk" would be a wrong transcription, so the whole value is dropped.
|
||||
if text.startswith("^"):
|
||||
return ""
|
||||
for old, new in ECDICT_CHAR_MAP.items():
|
||||
text = text.replace(old, new)
|
||||
return SPACE_RE.sub("", text)
|
||||
|
||||
|
||||
def build_subset(lemmas: set, exam_entries) -> dict:
|
||||
subset = {}
|
||||
for word, body in exam_entries:
|
||||
lower = word.lower()
|
||||
if " " in word or not lower.isalpha() or not lower.islower() or lower in subset:
|
||||
continue
|
||||
if lower not in lemmas and not EXAM_TAG_RE.search(body):
|
||||
continue
|
||||
subset[lower] = body
|
||||
return subset
|
||||
|
||||
|
||||
def translation_of(body: str) -> str:
|
||||
lines = []
|
||||
for line in body.splitlines():
|
||||
stripped = PHONETIC_RE.sub("", line).strip()
|
||||
if not stripped or not CJK_RE.search(stripped):
|
||||
continue
|
||||
# "(研四六托 4518/5059)" is an exam-list rank and "[时态] guarded, guarding" is a word-form
|
||||
# list: neither is a meaning, so they stay out of the gloss.
|
||||
if stripped.startswith("(") and stripped.endswith(")"):
|
||||
continue
|
||||
if stripped.startswith("[时态]"):
|
||||
continue
|
||||
lines.append(stripped)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="构建 LexGo 英汉词典资源 (#40)")
|
||||
parser.add_argument("--out", default=str(DEFAULT_OUT))
|
||||
parser.add_argument("--cache-dir", default=str(DEFAULT_CACHE))
|
||||
parser.add_argument("--keep-going", action="store_true", help="计数漂移时仍产出,只在 manifest 标记")
|
||||
args = parser.parse_args()
|
||||
|
||||
pin = json.loads(PIN_FILE.read_text(encoding="utf-8"))
|
||||
sources = {source["role"]: source for source in pin["sources"]}
|
||||
cache_dir = Path(args.cache_dir)
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ecdict_zip = download(sources["chinese-gloss"], cache_dir)
|
||||
cmudict_file = download(sources["ipa-phonetic"], cache_dir)
|
||||
wordnet_zip = download(sources["subset-wordlist"], cache_dir)
|
||||
|
||||
log("[parse] WordNet lemma 词表")
|
||||
lemmas = wordnet_lemmas(wordnet_zip)
|
||||
log(f"[parse] WordNet lemma: {len(lemmas)}")
|
||||
log("[parse] CMUdict 音素")
|
||||
cmu = cmudict_entries(cmudict_file)
|
||||
log(f"[parse] CMUdict 词条: {len(cmu)}")
|
||||
|
||||
log("[build] 裁剪 ECDICT 子集")
|
||||
subset = build_subset(lemmas, stardict_entries(ecdict_zip))
|
||||
log(f"[build] 子集词条: {len(subset)}")
|
||||
|
||||
records = []
|
||||
stats = {"cmudict": 0, "ecdict": 0, "none": 0, "droppedPhonetic": 0, "empty": 0}
|
||||
offenders = {}
|
||||
for word in sorted(subset):
|
||||
body = subset[word]
|
||||
translation = translation_of(body)
|
||||
phonetic = ""
|
||||
source_name = ""
|
||||
if word in cmu:
|
||||
phonetic = arpabet_to_ipa(cmu[word])
|
||||
source_name = "cmudict" if phonetic else ""
|
||||
if not phonetic:
|
||||
match = PHONETIC_RE.match(body)
|
||||
candidate = normalise_ecdict_phonetic(match.group(1)) if match else ""
|
||||
if candidate:
|
||||
phonetic = candidate
|
||||
source_name = "ecdict"
|
||||
if phonetic:
|
||||
unknown = sorted({char for char in phonetic if char not in IPA_ALPHABET})
|
||||
if unknown:
|
||||
stats["droppedPhonetic"] += 1
|
||||
for char in unknown:
|
||||
offenders[char] = offenders.get(char, 0) + 1
|
||||
phonetic = ""
|
||||
source_name = ""
|
||||
if not translation and not phonetic:
|
||||
# An entry that lost both its gloss and its phonetic answers nothing, so it is dropped
|
||||
# instead of occupying a lookup slot with a bare headword.
|
||||
stats["empty"] += 1
|
||||
continue
|
||||
stats[source_name or "none"] += 1
|
||||
records.append({
|
||||
"w": word,
|
||||
"t": translation,
|
||||
"p": phonetic,
|
||||
"ps": source_name,
|
||||
})
|
||||
|
||||
if offenders:
|
||||
log(f"[check] 丢弃的非 IPA 字符: {sorted((c, n) for c, n in offenders.items())}")
|
||||
if stats["droppedPhonetic"]:
|
||||
log(f"[check] 因字符不合法丢弃音标 {stats['droppedPhonetic']} 条(不做语义猜测)")
|
||||
if stats["empty"]:
|
||||
log(f"[check] 既无释义也无音标丢弃 {stats['empty']} 条")
|
||||
|
||||
expected = pin["subset"]["expectedEntries"]
|
||||
tolerance = pin["subset"]["toleranceEntries"]
|
||||
drift = abs(len(records) - expected)
|
||||
log(f"[check] 条目数 {len(records)}(pin 期望 {expected} ± {tolerance})")
|
||||
if drift > tolerance and not args.keep_going:
|
||||
raise SystemExit("子集条目数超出 pin 容差,拒绝产出;确认数据源变化后用 --keep-going 重新评估")
|
||||
if len(records) == 0:
|
||||
raise SystemExit("子集为空,拒绝产出")
|
||||
|
||||
manifest = {
|
||||
"format": pin["format"],
|
||||
"version": pin["version"],
|
||||
"language": pin["language"],
|
||||
"provider": pin["provider"],
|
||||
"entries": len(records),
|
||||
"phonetic": {"cmudict": stats["cmudict"], "ecdict": stats["ecdict"], "none": stats["none"]},
|
||||
"droppedPhonetic": stats["droppedPhonetic"],
|
||||
"droppedEmpty": stats["empty"],
|
||||
"subsetRule": pin["subset"]["rule"],
|
||||
"sources": [
|
||||
{"name": s["name"], "role": s["role"], "sha256": s["sha256"], "license": s["license"]}
|
||||
for s in pin["sources"]
|
||||
],
|
||||
"preparedBy": "scripts/dict_prepare.py",
|
||||
}
|
||||
destination = Path(args.out)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(destination, "w", zipfile.ZIP_DEFLATED) as bundle:
|
||||
payload = "\n".join(json.dumps(record, ensure_ascii=False, sort_keys=True) for record in records)
|
||||
bundle.writestr("entries.jsonl.gz", gzip.compress(payload.encode("utf-8")))
|
||||
bundle.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2))
|
||||
log(f"[write] {destination} ({destination.stat().st_size} 字节)")
|
||||
log(f"[write] sha256 {digest(destination)}")
|
||||
log(f"[stats] CMUdict IPA {stats['cmudict']} / ECDICT 记法 {stats['ecdict']} / 无音标 {stats['none']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -42,6 +42,8 @@ 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",
|
||||
"lexgo_chapter_attachments", "lexgo_chapter_playback_positions",
|
||||
]
|
||||
SCHEMA_VERSION = 7
|
||||
|
||||
@@ -470,6 +472,12 @@ 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)
|
||||
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"):
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
Copyright (C) 1993-2015 Carnegie Mellon University. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions
|
||||
are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
The contents of this file are deemed to be source code.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in
|
||||
the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
|
||||
This work was supported in part by funding from the Defense Advanced
|
||||
Research Projects Agency, the Office of Naval Research and the National
|
||||
Science Foundation of the United States of America, and by member
|
||||
companies of the Carnegie Mellon Sphinx Speech Consortium. We acknowledge
|
||||
the contributions of many volunteers to the expansion and improvement of
|
||||
this dictionary.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY CARNEGIE MELLON UNIVERSITY ``AS IS'' AND
|
||||
ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL CARNEGIE MELLON UNIVERSITY
|
||||
NOR ITS EMPLOYEES BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Linwei
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,641 @@
|
||||
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 (
|
||||
// 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
|
||||
// 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" }
|
||||
|
||||
// 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"`
|
||||
ChapterID int64 `gorm:"primaryKey"`
|
||||
PositionSeconds int `json:"positionSeconds"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
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.
|
||||
type BookAttachmentView struct {
|
||||
Kind string `json:"kind"`
|
||||
Mime string `json:"mime"`
|
||||
ByteSize int `json:"byteSize"`
|
||||
Version string `json:"version"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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, attachmentIllustration:
|
||||
mime := sniffImage(data)
|
||||
if mime == "" {
|
||||
if kind == attachmentIllustration {
|
||||
return "", failure(400, "插图只支持 JPG、PNG 或 WebP 图片")
|
||||
}
|
||||
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) {
|
||||
label := "封面"
|
||||
if kind == attachmentIllustration {
|
||||
label = "插图"
|
||||
}
|
||||
switch kind {
|
||||
case attachmentAudio:
|
||||
if len(data) == 0 {
|
||||
return "", failure(400, "音频文件为空")
|
||||
}
|
||||
if len(data) > maxAudioBytes {
|
||||
return "", failure(400, "音频文件不能超过 20 MiB")
|
||||
}
|
||||
case attachmentCover, attachmentIllustration:
|
||||
if len(data) == 0 {
|
||||
return "", failure(400, label+"图片为空")
|
||||
}
|
||||
if len(data) > maxCoverBytes {
|
||||
return "", failure(400, label+"图片不能超过 2 MiB")
|
||||
}
|
||||
default:
|
||||
return "", failure(400, "附件类型无效")
|
||||
}
|
||||
mime, err := sniffAttachment(kind, data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
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, label+"图片不能超过 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 != 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
|
||||
}
|
||||
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 the cover of a book.
|
||||
func DeleteAttachment(tx *gorm.DB, owner int, bookID int64, kind string, now time.Time) error {
|
||||
if 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, "附件不存在")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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(chapterIDs) == 0 {
|
||||
return positions, 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.ChapterID] = row.PositionSeconds
|
||||
}
|
||||
return positions, nil
|
||||
}
|
||||
|
||||
// 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})
|
||||
if err != nil {
|
||||
return view, err
|
||||
}
|
||||
if cover, ok := found[bookID][attachmentCover]; ok {
|
||||
view.Cover = &cover
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
|
||||
// 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, "播放位置无效")
|
||||
}
|
||||
var chapter Chapter
|
||||
if err := lockOwnedChapter(tx, owner, chapterID, &chapter); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var audio int64
|
||||
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, "这一章没有音频")
|
||||
}
|
||||
ts := stamp(now)
|
||||
row := ChapterPlaybackPosition{OwnerID: owner, ChapterID: chapterID, PositionSeconds: seconds, UpdatedAt: ts}
|
||||
if err := tx.Clauses(clause.OnConflict{
|
||||
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
|
||||
}
|
||||
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) {
|
||||
// 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
|
||||
}
|
||||
data, err := readAttachmentUpload(c, maxCoverBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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, "书籍不存在")
|
||||
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
|
||||
}))
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
return binaryResponse{ContentType: row.Mime, Bytes: row.Bytes, FileName: fileName,
|
||||
ModifiedAt: row.UpdatedAt, ETag: `"` + row.SHA256 + `"`}, nil
|
||||
}))
|
||||
}
|
||||
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
|
||||
}
|
||||
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 := SaveChapterPlaybackPosition(tx, u.UserId, id, *input.PositionSeconds, now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gin.H{"playbackSeconds": seconds}, nil
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
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"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------- 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},
|
||||
{"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},
|
||||
{"svg is not accepted", attachmentCover, []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`), "", 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(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")
|
||||
}
|
||||
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 := SaveChapterPlaybackPosition(nil, 1, 1, -1, time.Now()); err == nil {
|
||||
t.Fatal("a negative position must be refused")
|
||||
}
|
||||
if _, err := SaveChapterPlaybackPosition(nil, 1, 1, maxPlaybackSeconds+1, time.Now()); err == nil {
|
||||
t.Fatal("an absurd position must be refused")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- integration
|
||||
|
||||
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)
|
||||
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 ChapterAttachmentView
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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": "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, chapterID := pasted.Chapter.BookID, pasted.Chapter.ID
|
||||
|
||||
// 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 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
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)
|
||||
}
|
||||
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 {
|
||||
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 != 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)
|
||||
}
|
||||
|
||||
// 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/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/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/chapters/999999/audio", testMP3(16)); code != 404 {
|
||||
t.Fatal("an unknown chapter must be 404")
|
||||
}
|
||||
if code, _, _ = uploadAttachment(t, r, "", fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), testMP3(16)); code != 401 {
|
||||
t.Fatal("uploading needs a session")
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package lexgo
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"path"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// The prepared 英汉 resource is built by scripts/dict_prepare.py from two pinned sources and is
|
||||
// imported as a whole; the server only validates and reads it. Chinese glosses and IPA phonetics
|
||||
// are additive to WordNet, so they live in their own corpus with their own provider identity.
|
||||
const (
|
||||
ChineseDictionaryFormat = "lexgo-zh-dict-v1"
|
||||
ChineseDictionaryVersion = "ecdict-1.0.28+cmudict-0.7b"
|
||||
ChineseDictionarySource = "https://github.com/skywind3000/ECDICT/releases/download/1.0.28/ecdict-stardict-28.zip"
|
||||
ChineseDictionarySHA = "" // verified from the archive manifest, see chineseDictionarySources
|
||||
chineseEntryLimit = 200000
|
||||
)
|
||||
|
||||
// Pinned source checksums: an imported archive must declare exactly these, so a hand-edited or
|
||||
// unknown resource cannot be loaded as if it were the reviewed one.
|
||||
var chineseDictionarySources = map[string]string{
|
||||
"chinese-gloss": "c707d0f3ded6ec79b96466da4a1574e074703da5af9c120fbad97f9cb08c6f2c",
|
||||
"ipa-phonetic": "81917843c7f44ce2b094ac63873c2c7a4cf802040792c455ba3ca406891c3d22",
|
||||
"subset-wordlist": "cbda5ea6eef7f36a97a43d4a75f85e07fccbb4f23657d27b4ccbc93e2646ab59",
|
||||
}
|
||||
|
||||
type chineseManifest struct {
|
||||
Format string `json:"format"`
|
||||
Version string `json:"version"`
|
||||
Language string `json:"language"`
|
||||
Provider string `json:"provider"`
|
||||
Entries int `json:"entries"`
|
||||
PreparedBy string `json:"preparedBy"`
|
||||
Sources []struct {
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
SHA256 string `json:"sha256"`
|
||||
License string `json:"license"`
|
||||
} `json:"sources"`
|
||||
}
|
||||
|
||||
type chineseRecord struct {
|
||||
Word string `json:"w"`
|
||||
Translation string `json:"t"`
|
||||
Phonetic string `json:"p"`
|
||||
Source string `json:"ps"`
|
||||
}
|
||||
|
||||
// ChineseDictionary is an immutable lookup table for one imported resource.
|
||||
type ChineseDictionary struct {
|
||||
entries map[string]DictionaryEntry
|
||||
EntryCount int
|
||||
}
|
||||
|
||||
func (d *ChineseDictionary) Lookup(query string) LookupResult {
|
||||
key := normalizeWord(query)
|
||||
result := LookupResult{Status: "not_found", Query: query, Candidates: []string{}, Entries: []DictionaryEntry{}}
|
||||
entry, ok := d.entries[key]
|
||||
if !ok {
|
||||
return result
|
||||
}
|
||||
form := key
|
||||
result.Status = "exact"
|
||||
result.MatchedForm = &form
|
||||
result.Candidates = []string{form}
|
||||
result.Entries = []DictionaryEntry{entry}
|
||||
result.Phonetic = entry.Phonetic
|
||||
result.PhoneticSource = entry.PhoneticSource
|
||||
return result
|
||||
}
|
||||
|
||||
// readChineseArchive bounds every declared and inflated size before reading, mirroring the WordNet
|
||||
// loader so a hostile archive cannot exhaust memory.
|
||||
func readChineseArchive(raw []byte) (map[string][]byte, error) {
|
||||
if len(raw) > maxDictionaryZip {
|
||||
return nil, errors.New("archive too large")
|
||||
}
|
||||
reader, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(reader.File) > 8 {
|
||||
return nil, errors.New("too many archive members")
|
||||
}
|
||||
files := map[string][]byte{}
|
||||
total := int64(0)
|
||||
for _, f := range reader.File {
|
||||
name := f.Name
|
||||
if strings.Contains(name, "\\") || path.IsAbs(name) || strings.Contains(name, ":") || path.Clean(name) != strings.TrimSuffix(name, "/") || (name != "entries.jsonl.gz" && name != "manifest.json") {
|
||||
return nil, errors.New("unsafe archive member")
|
||||
}
|
||||
if !f.FileInfo().IsDir() && !f.Mode().IsRegular() {
|
||||
return nil, errors.New("unsupported archive member")
|
||||
}
|
||||
if _, seen := files[name]; seen {
|
||||
return nil, errors.New("duplicate archive member")
|
||||
}
|
||||
if f.UncompressedSize64 > maxDictionaryInflated || total+int64(f.UncompressedSize64) > maxDictionaryInflated {
|
||||
return nil, errors.New("inflated archive too large")
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(rc, maxDictionaryInflated-total+1))
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
total += int64(len(data))
|
||||
if total > maxDictionaryInflated {
|
||||
return nil, errors.New("inflated archive too large")
|
||||
}
|
||||
files[name] = data
|
||||
}
|
||||
if len(files["entries.jsonl.gz"]) == 0 || len(files["manifest.json"]) == 0 {
|
||||
return nil, errors.New("missing required member")
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// ParseChineseDictionary accepts only an archive whose manifest matches the reviewed format,
|
||||
// version and source checksums. No member is ever extracted to disk.
|
||||
func ParseChineseDictionary(raw []byte) (*ChineseDictionary, error) {
|
||||
if len(raw) == 0 || len(raw) > maxDictionaryZip {
|
||||
return nil, errors.New("archive size out of range")
|
||||
}
|
||||
files, err := readChineseArchive(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var manifest chineseManifest
|
||||
if err = json.Unmarshal(files["manifest.json"], &manifest); err != nil {
|
||||
return nil, errors.New("invalid manifest")
|
||||
}
|
||||
if manifest.Format != ChineseDictionaryFormat || manifest.Version != ChineseDictionaryVersion || manifest.Language != "en" || manifest.Provider != "ecdict" {
|
||||
return nil, errors.New("unexpected dictionary manifest")
|
||||
}
|
||||
if len(manifest.Sources) != len(chineseDictionarySources) {
|
||||
return nil, errors.New("unexpected dictionary sources")
|
||||
}
|
||||
for _, source := range manifest.Sources {
|
||||
if expected, ok := chineseDictionarySources[source.Role]; !ok || expected != source.SHA256 {
|
||||
return nil, errors.New("unreviewed dictionary source")
|
||||
}
|
||||
}
|
||||
reader, err := gzip.NewReader(bytes.NewReader(files["entries.jsonl.gz"]))
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid entry payload")
|
||||
}
|
||||
defer reader.Close()
|
||||
dictionary := &ChineseDictionary{entries: map[string]DictionaryEntry{}}
|
||||
scanner := bufio.NewScanner(reader)
|
||||
scanner.Buffer(make([]byte, 4096), 1<<20)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if len(bytes.TrimSpace(line)) == 0 {
|
||||
continue
|
||||
}
|
||||
var record chineseRecord
|
||||
if err = json.Unmarshal(line, &record); err != nil {
|
||||
return nil, errors.New("invalid entry record")
|
||||
}
|
||||
word := strings.TrimSpace(record.Word)
|
||||
if word == "" || word != normalizeWord(word) || !isDictionaryHeadword(word) || record.Translation == "" && record.Phonetic == "" {
|
||||
return nil, errors.New("invalid entry")
|
||||
}
|
||||
if _, exists := dictionary.entries[word]; exists {
|
||||
return nil, errors.New("duplicate entry")
|
||||
}
|
||||
if len(dictionary.entries) >= chineseEntryLimit {
|
||||
return nil, errors.New("too many entries")
|
||||
}
|
||||
dictionary.entries[word] = DictionaryEntry{
|
||||
Lemma: word,
|
||||
Translation: record.Translation,
|
||||
Phonetic: record.Phonetic,
|
||||
PhoneticSource: record.Source,
|
||||
Provider: "ecdict",
|
||||
}
|
||||
}
|
||||
if err = scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(dictionary.entries) == 0 || manifest.Entries != len(dictionary.entries) {
|
||||
return nil, errors.New("entry count mismatch")
|
||||
}
|
||||
dictionary.EntryCount = len(dictionary.entries)
|
||||
return dictionary, nil
|
||||
}
|
||||
|
||||
// mergeLookups keeps every provider's senses in caller order: the Chinese gloss first because it
|
||||
// answers "what does this mean" for the learner, WordNet afterwards as English detail.
|
||||
func mergeLookups(query string, results []LookupResult) LookupResult {
|
||||
merged := LookupResult{Status: "not_found", Query: query, Candidates: []string{}, Entries: []DictionaryEntry{}}
|
||||
for _, result := range results {
|
||||
if result.Status == "exact" {
|
||||
merged.Status = "exact"
|
||||
} else if result.Status == "lemma" && merged.Status == "not_found" {
|
||||
merged.Status = "lemma"
|
||||
}
|
||||
for _, candidate := range result.Candidates {
|
||||
if !containsString(merged.Candidates, candidate) {
|
||||
merged.Candidates = append(merged.Candidates, candidate)
|
||||
}
|
||||
}
|
||||
if merged.MatchedForm == nil && result.MatchedForm != nil {
|
||||
form := *result.MatchedForm
|
||||
merged.MatchedForm = &form
|
||||
}
|
||||
for _, entry := range result.Entries {
|
||||
if len(merged.Entries) >= 12 {
|
||||
break
|
||||
}
|
||||
// A phonetic belongs to the headword: an inflected form resolved through WordNet's
|
||||
// morphology shows the gloss but not the headword's transcription.
|
||||
if entry.Phonetic != "" && merged.Phonetic == "" && entry.Lemma == normalizeWord(query) {
|
||||
merged.Phonetic = entry.Phonetic
|
||||
merged.PhoneticSource = entry.PhoneticSource
|
||||
}
|
||||
merged.Entries = append(merged.Entries, entry)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// isDictionaryHeadword keeps the stored key shape predictable: letters with the apostrophe and
|
||||
// hyphen that appear in real headwords, nothing that could smuggle in markup or a sentence.
|
||||
func isDictionaryHeadword(word string) bool {
|
||||
for _, r := range word {
|
||||
if !unicode.IsLetter(r) && r != '\'' && r != '-' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func containsString(values []string, wanted string) bool {
|
||||
for _, value := range values {
|
||||
if value == wanted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package lexgo
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
admin "go-admin/app/admin/models"
|
||||
)
|
||||
|
||||
const chineseFixturePath = "../../../.local/dictionaries/zh-dict-v1.zip"
|
||||
|
||||
func chineseFixture(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(chineseFixturePath)
|
||||
if os.IsNotExist(err) {
|
||||
t.Skip("prepare the 英汉 fixture with python scripts/dict_prepare.py")
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// buildChineseArchive writes a synthetic archive so rejection paths can be exercised without a
|
||||
// hand-edited copy of the real 3 MiB resource.
|
||||
func buildChineseArchive(t *testing.T, manifest map[string]any, entries []map[string]any) []byte {
|
||||
t.Helper()
|
||||
if _, ok := manifest["format"]; !ok {
|
||||
manifest["format"] = ChineseDictionaryFormat
|
||||
}
|
||||
if _, ok := manifest["version"]; !ok {
|
||||
manifest["version"] = ChineseDictionaryVersion
|
||||
}
|
||||
if _, ok := manifest["language"]; !ok {
|
||||
manifest["language"] = "en"
|
||||
}
|
||||
if _, ok := manifest["provider"]; !ok {
|
||||
manifest["provider"] = "ecdict"
|
||||
}
|
||||
if _, ok := manifest["entries"]; !ok {
|
||||
manifest["entries"] = len(entries)
|
||||
}
|
||||
if _, ok := manifest["sources"]; !ok {
|
||||
manifest["sources"] = []map[string]string{
|
||||
{"name": "ECDICT StarDict", "role": "chinese-gloss", "sha256": chineseDictionarySources["chinese-gloss"], "license": "ECDICT-LICENSE.txt"},
|
||||
{"name": "CMUdict", "role": "ipa-phonetic", "sha256": chineseDictionarySources["ipa-phonetic"], "license": "CMUDICT-LICENSE.txt"},
|
||||
{"name": "Princeton WordNet", "role": "subset-wordlist", "sha256": chineseDictionarySources["subset-wordlist"], "license": "WORDNET-LICENSE.txt"},
|
||||
}
|
||||
}
|
||||
lines := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
encoded, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines = append(lines, string(encoded))
|
||||
}
|
||||
var payload bytes.Buffer
|
||||
writer := gzip.NewWriter(&payload)
|
||||
writer.Write([]byte(strings.Join(lines, "\n")))
|
||||
writer.Close()
|
||||
var archive bytes.Buffer
|
||||
bundle := zip.NewWriter(&archive)
|
||||
for name, content := range map[string][]byte{
|
||||
"manifest.json": mustJSON(t, manifest),
|
||||
"entries.jsonl.gz": payload.Bytes(),
|
||||
} {
|
||||
part, err := bundle.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
part.Write(content)
|
||||
}
|
||||
if err := bundle.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return archive.Bytes()
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, value any) []byte {
|
||||
t.Helper()
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
func TestChineseDictionaryParseAndLookup(t *testing.T) {
|
||||
dictionary, err := ParseChineseDictionary(chineseFixture(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dictionary.EntryCount != 82721 {
|
||||
t.Fatalf("entry count %d, want the pinned 82721", dictionary.EntryCount)
|
||||
}
|
||||
got := dictionary.Lookup("curiosity")
|
||||
if got.Status != "exact" || got.Phonetic != "kjˌʊɹiˈɑːsəti" || got.PhoneticSource != "cmudict" {
|
||||
t.Fatalf("curiosity %+v", got)
|
||||
}
|
||||
if len(got.Entries) != 1 || got.Entries[0].Provider != "ecdict" || !strings.Contains(got.Entries[0].Translation, "好奇心") {
|
||||
t.Fatalf("curiosity entry %+v", got.Entries)
|
||||
}
|
||||
if got.Entries[0].Definition != "" || len(got.Entries[0].Examples) != 0 {
|
||||
t.Fatal("the Chinese resource must not invent English senses")
|
||||
}
|
||||
// The reader sends the selected word, so a different case must still resolve.
|
||||
if upper := dictionary.Lookup("Curiosity"); upper.Status != "exact" || upper.Phonetic != "kjˌʊɹiˈɑːsəti" {
|
||||
t.Fatalf("case-insensitive query %+v", upper)
|
||||
}
|
||||
// A word whose phonetic is unreliable keeps its gloss and shows no transcription.
|
||||
if grok := dictionary.Lookup("grok"); grok.Status != "exact" || grok.Phonetic != "" || len(grok.Entries) != 1 {
|
||||
t.Fatalf("grok %+v", grok)
|
||||
}
|
||||
if grok := dictionary.Lookup("grok"); !strings.Contains(grok.Entries[0].Translation, "神入") {
|
||||
t.Fatalf("grok gloss %q", grok.Entries[0].Translation)
|
||||
}
|
||||
if miss := dictionary.Lookup("zzzznotaword"); miss.Status != "not_found" || len(miss.Entries) != 0 {
|
||||
t.Fatalf("miss %+v", miss)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChineseDictionaryRejectsUnreviewedArchive(t *testing.T) {
|
||||
entry := []map[string]any{{"w": "grok", "t": "v. 神入", "p": "ɡrɒk", "ps": "cmudict"}}
|
||||
for name, manifest := range map[string]map[string]any{
|
||||
"format": {"format": "wordnet-3.0-zip"},
|
||||
"version": {"version": "ecdict-9.9.9"},
|
||||
"count": {"entries": 5},
|
||||
"source": {"sources": []map[string]string{{"name": "ECDICT StarDict", "role": "chinese-gloss", "sha256": strings.Repeat("0", 64), "license": "ECDICT-LICENSE.txt"}}},
|
||||
"role": {"sources": []map[string]string{{"name": "ECDICT StarDict", "role": "unknown-role", "sha256": chineseDictionarySources["chinese-gloss"], "license": "ECDICT-LICENSE.txt"}}},
|
||||
} {
|
||||
if _, err := ParseChineseDictionary(buildChineseArchive(t, manifest, entry)); err == nil {
|
||||
t.Fatalf("%s: unreviewed archive accepted", name)
|
||||
}
|
||||
}
|
||||
// The reviewed shape still parses, so the rejection cases above are not vacuous.
|
||||
if _, err := ParseChineseDictionary(buildChineseArchive(t, map[string]any{}, entry)); err != nil {
|
||||
t.Fatalf("reviewed archive rejected: %v", err)
|
||||
}
|
||||
for name, entrySet := range map[string][]map[string]any{
|
||||
"duplicate": {{"w": "grok", "t": "a", "p": "", "ps": ""}, {"w": "grok", "t": "b", "p": "", "ps": ""}},
|
||||
"empty": {{"w": "grok", "t": "", "p": "", "ps": ""}},
|
||||
"notAWord": {{"w": "grok!", "t": "a", "p": "", "ps": ""}},
|
||||
"upperCase": {{"w": "Grok", "t": "a", "p": "", "ps": ""}},
|
||||
} {
|
||||
if _, err := ParseChineseDictionary(buildChineseArchive(t, map[string]any{}, entrySet)); err == nil {
|
||||
t.Fatalf("%s: invalid entry accepted", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeLookupsOrdersGlossBeforeEnglishDetail(t *testing.T) {
|
||||
chinese := LookupResult{Status: "exact", Query: "dog", Candidates: []string{"dog"}, MatchedForm: form("dog"), Phonetic: "dɔɡ", PhoneticSource: "cmudict", Entries: []DictionaryEntry{{Lemma: "dog", Translation: "n. 狗", Phonetic: "dɔɡ", PhoneticSource: "cmudict", Provider: "ecdict"}}}
|
||||
english := LookupResult{Status: "exact", Query: "dog", Candidates: []string{"dog"}, MatchedForm: form("dog"), Entries: []DictionaryEntry{{Lemma: "dog", POS: "n", Definition: "a domesticated canine", Provider: "wordnet"}}}
|
||||
merged := mergeLookups("dog", []LookupResult{chinese, english})
|
||||
if merged.Status != "exact" || len(merged.Entries) != 2 || merged.Entries[0].Provider != "ecdict" || merged.Entries[1].Provider != "wordnet" {
|
||||
t.Fatalf("merged %+v", merged)
|
||||
}
|
||||
if merged.Phonetic != "dɔɡ" || merged.PhoneticSource != "cmudict" {
|
||||
t.Fatalf("phonetic %+v", merged)
|
||||
}
|
||||
// A headword phonetic must not be shown for an inflected form resolved by the other provider.
|
||||
inflected := mergeLookups("dogs", []LookupResult{{Status: "lemma", Query: "dogs", MatchedForm: form("dog"), Candidates: []string{"dog"}, Entries: []DictionaryEntry{{Lemma: "dog", Translation: "n. 狗", Phonetic: "dɔɡ", Provider: "ecdict"}}}})
|
||||
if inflected.Phonetic != "" || inflected.Status != "lemma" {
|
||||
t.Fatalf("inflected %+v", inflected)
|
||||
}
|
||||
lemma := mergeLookups("dogs", []LookupResult{
|
||||
{Status: "lemma", Query: "dogs", MatchedForm: form("dog"), Candidates: []string{"dog"}, Entries: []DictionaryEntry{{Lemma: "dog", POS: "n", Definition: "canine", Provider: "wordnet"}}},
|
||||
{Status: "not_found", Query: "dogs"},
|
||||
})
|
||||
if lemma.Status != "lemma" || len(lemma.Entries) != 1 || lemma.MatchedForm == nil || *lemma.MatchedForm != "dog" {
|
||||
t.Fatalf("lemma only %+v", lemma)
|
||||
}
|
||||
empty := mergeLookups("dogs", []LookupResult{{Status: "not_found", Query: "dogs"}})
|
||||
if empty.Status != "not_found" || len(empty.Entries) != 0 {
|
||||
t.Fatalf("empty %+v", empty)
|
||||
}
|
||||
}
|
||||
|
||||
func form(value string) *string { return &value }
|
||||
|
||||
func importResourceAPI(t *testing.T, r *gin.Engine, token string, fields map[string]string, raw []byte, filename string) (int, DictionaryImportResult) {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
w := multipart.NewWriter(&body)
|
||||
for key, value := range fields {
|
||||
if err := w.WriteField(key, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
part, err := w.CreateFormFile("file", filename)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
part.Write(raw)
|
||||
w.Close()
|
||||
req := httptest.NewRequest("POST", "/api/v1/dictionaries/import", &body)
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
response := httptest.NewRecorder()
|
||||
r.ServeHTTP(response, req)
|
||||
var envelope struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
json.Unmarshal(response.Body.Bytes(), &envelope)
|
||||
var result DictionaryImportResult
|
||||
json.Unmarshal(envelope.Data, &result)
|
||||
if response.Code != 200 {
|
||||
t.Logf("import %s response %s", filename, response.Body.String())
|
||||
}
|
||||
return response.Code, result
|
||||
}
|
||||
|
||||
// TestMergedDictionaryLookupAPI imports both resources into the dedicated test database and checks
|
||||
// the learner-visible merge: Chinese gloss and phonetic first, English detail after, with each
|
||||
// dictionary independently switchable.
|
||||
func TestMergedDictionaryLookupAPI(t *testing.T) {
|
||||
db := testDB(t)
|
||||
wordnetRaw, err := os.ReadFile("../../../.local/nlp-resources/wordnet.zip")
|
||||
if os.IsNotExist(err) {
|
||||
t.Skip("prepare WordNet fixture")
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chineseRaw := chineseFixture(t)
|
||||
if err := db.Exec("DELETE FROM lexgo_dictionaries").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Exec("DELETE FROM lexgo_dictionaries") })
|
||||
r := Router(db, time.Now)
|
||||
adminUser := admin.SysUser{Username: randomName("zhdict"), Password: fixturePassword, RoleId: 1, Status: "2"}
|
||||
learner := admin.SysUser{Username: randomName("zhlearner"), Password: fixturePassword, RoleId: 2, Status: "2"}
|
||||
for _, user := range []*admin.SysUser{&adminUser, &learner} {
|
||||
if err := db.Create(user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
adminToken := loginToken(t, r, adminUser.Username, fixturePassword)
|
||||
learnerToken := loginToken(t, r, learner.Username, fixturePassword)
|
||||
|
||||
if code, result := importResourceAPI(t, r, adminToken, map[string]string{"name": "Princeton WordNet", "language": "en", "version": WordNetVersion, "source": WordNetSource, "format": "wordnet-3.0-zip"}, wordnetRaw, "wordnet.zip"); code != 200 || result.Resource.Provider != "wordnet" || result.Resource.ID != WordNetSlot || result.Resource.EntryCount == 0 {
|
||||
t.Fatalf("wordnet import %d %+v", code, result)
|
||||
}
|
||||
code, imported := importResourceAPI(t, r, adminToken, map[string]string{"name": "简明英汉字典增强版", "language": "en", "version": ChineseDictionaryVersion, "source": ChineseDictionarySource, "format": ChineseDictionaryFormat}, chineseRaw, "zh-dict-v1.zip")
|
||||
if code != 200 || imported.Resource.Provider != "ecdict" || imported.Resource.ID != ChineseSlot || imported.Resource.EntryCount != 82721 {
|
||||
t.Fatalf("chinese import %d %+v", code, imported)
|
||||
}
|
||||
// Wrong metadata must not occupy the slot.
|
||||
if code, _ := importResourceAPI(t, r, adminToken, map[string]string{"name": "unknown", "language": "en", "version": "1.0", "source": ChineseDictionarySource, "format": ChineseDictionaryFormat}, chineseRaw, "zh-dict-v1.zip"); code != 400 {
|
||||
t.Fatalf("unreviewed metadata accepted: %d", code)
|
||||
}
|
||||
|
||||
book := Book{OwnerID: learner.UserId, Title: "Fictional", Language: "en", CreatedAt: stamp(time.Now()), UpdatedAt: stamp(time.Now())}
|
||||
if err := db.Create(&book).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
original := "Dogs want curiosity."
|
||||
chapter := Chapter{BookID: book.ID, OwnerID: learner.UserId, Ordinal: 1, Title: "Fictional", OriginalText: original, ContentSHA256: contentSHA(original), CharCount: len([]rune(original)), Status: statusReady, CreatedAt: stamp(time.Now()), UpdatedAt: stamp(time.Now())}
|
||||
if err := db.Create(&chapter).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, data := callAPI(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d/tokens", chapter.ID), learnerToken, nil)
|
||||
var tokens ChapterTokens
|
||||
json.Unmarshal(data, &tokens)
|
||||
ranges := map[string][2]int{}
|
||||
for _, token := range tokens.Tokens {
|
||||
if token.Text == "Dogs" || token.Text == "curiosity" || token.Text == "went" {
|
||||
ranges[token.Text] = [2]int{token.StartUtf16, token.EndUtf16}
|
||||
}
|
||||
}
|
||||
if _, ok := ranges["Dogs"]; !ok {
|
||||
t.Fatalf("token ranges missing: %s", string(data))
|
||||
}
|
||||
lookup := func(word string) (int, LookupResult) {
|
||||
span := ranges[word]
|
||||
code, payload := callAPI(t, r, "POST", "/api/v1/lookup", learnerToken, map[string]any{"chapterId": chapter.ID, "start": span[0], "end": span[1]})
|
||||
var got LookupResult
|
||||
json.Unmarshal(payload, &got)
|
||||
return code, got
|
||||
}
|
||||
|
||||
code, got := lookup("curiosity")
|
||||
if code != 200 || got.Status != "exact" || got.Phonetic != "kjˌʊɹiˈɑːsəti" || got.PhoneticSource != "cmudict" {
|
||||
t.Fatalf("curiosity %d %+v", code, got)
|
||||
}
|
||||
if len(got.Entries) < 2 || got.Entries[0].Provider != "ecdict" || !strings.Contains(got.Entries[0].Translation, "好奇心") || got.Entries[len(got.Entries)-1].Provider != "wordnet" {
|
||||
t.Fatalf("curiosity merge %+v", got.Entries)
|
||||
}
|
||||
if len(got.Resources) != 2 || got.Resource == nil || got.Resource.Name != "Princeton WordNet" {
|
||||
t.Fatalf("curiosity resources %+v", got.Resources)
|
||||
}
|
||||
if got.Entries[0].Definition != "" {
|
||||
t.Fatal("the Chinese sense must not carry an English definition")
|
||||
}
|
||||
|
||||
// "Dogs" is not a headword: WordNet resolves the inflection and the gloss follows it, without
|
||||
// copying the headword's transcription onto the inflected form.
|
||||
code, plural := lookup("Dogs")
|
||||
if code != 200 || plural.Status != "lemma" || plural.Phonetic != "" {
|
||||
t.Fatalf("Dogs %d %+v", code, plural)
|
||||
}
|
||||
if len(plural.Entries) == 0 || plural.Entries[0].Provider != "ecdict" || !strings.Contains(plural.Entries[0].Translation, "狗") {
|
||||
t.Fatalf("Dogs gloss %+v", plural.Entries)
|
||||
}
|
||||
// Each dictionary is switchable on its own.
|
||||
if code, _ := callAPI(t, r, "PATCH", fmt.Sprintf("/api/v1/dictionaries/%d", ChineseSlot), adminToken, map[string]bool{"enabled": false}); code != 200 {
|
||||
t.Fatalf("disable chinese %d", code)
|
||||
}
|
||||
if code, english := lookup("curiosity"); code != 200 || english.Phonetic != "" || hasProviderEntry(english.Entries, "ecdict") || len(english.Resources) != 1 {
|
||||
t.Fatalf("english only %d %+v", code, english)
|
||||
}
|
||||
// Bring the Chinese dictionary back before switching the other one off, so each step proves
|
||||
// that exactly one provider is answering.
|
||||
if code, _ := callAPI(t, r, "PATCH", fmt.Sprintf("/api/v1/dictionaries/%d", ChineseSlot), adminToken, map[string]bool{"enabled": true}); code != 200 {
|
||||
t.Fatalf("re-enable chinese %d", code)
|
||||
}
|
||||
if code, _ := callAPI(t, r, "PATCH", fmt.Sprintf("/api/v1/dictionaries/%d", WordNetSlot), adminToken, map[string]bool{"enabled": false}); code != 200 {
|
||||
t.Fatalf("disable wordnet %d", code)
|
||||
}
|
||||
if code, only := lookup("curiosity"); code != 200 || !hasProviderEntry(only.Entries, "ecdict") || only.Phonetic != "kjˌʊɹiˈɑːsəti" || only.Resource == nil || only.Resource.Name == "Princeton WordNet" {
|
||||
t.Fatalf("chinese only %d %+v", code, only)
|
||||
}
|
||||
if code, _ := callAPI(t, r, "PATCH", fmt.Sprintf("/api/v1/dictionaries/%d", ChineseSlot), adminToken, map[string]bool{"enabled": false}); code != 200 {
|
||||
t.Fatalf("disable both %d", code)
|
||||
}
|
||||
if code, none := lookup("curiosity"); code != 200 || none.Status != "resource_missing" || len(none.Resources) != 0 {
|
||||
t.Fatalf("both disabled %d %+v", code, none)
|
||||
}
|
||||
// Re-enabling after a cold restart proves the archive is reloaded from MySQL, not from memory.
|
||||
if code, _ := callAPI(t, r, "PATCH", fmt.Sprintf("/api/v1/dictionaries/%d", ChineseSlot), adminToken, map[string]bool{"enabled": true}); code != 200 {
|
||||
t.Fatalf("re-enable %d", code)
|
||||
}
|
||||
r = Router(db, time.Now)
|
||||
if code, cold := lookup("curiosity"); code != 200 || cold.Phonetic != "kjˌʊɹiˈɑːsəti" {
|
||||
t.Fatalf("cold chinese %d %+v", code, cold)
|
||||
}
|
||||
if code, listed := callAPI(t, r, "GET", "/api/v1/dictionaries", adminToken, nil); code != 200 || !bytes.Contains(listed, []byte(`"provider":"ecdict"`)) {
|
||||
t.Fatalf("list %d %s", code, string(listed))
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package lexgo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -84,6 +85,12 @@ func Migrate(db *gorm.DB) error {
|
||||
if current < 7 {
|
||||
statements = append(statements, schemaV7Statements...)
|
||||
}
|
||||
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
|
||||
@@ -93,13 +100,161 @@ 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
|
||||
}
|
||||
}
|
||||
// v12 lets a second English dictionary live beside WordNet: the single-slot CHECK on
|
||||
// lexgo_dictionaries is dropped and a provider column names each resource. Both steps are
|
||||
// conditional because MySQL has no DROP CHECK IF EXISTS and an older database lacks the column,
|
||||
// and the version row still advances only after everything succeeded.
|
||||
if current < 12 {
|
||||
if err = addDictionaryProvider(ctx, conn); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = dropDictionarySingleSlotCheck(ctx, conn); 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 = 7
|
||||
const SchemaVersion = 12
|
||||
|
||||
// addDictionaryProvider adds the provider column when it is missing, so replaying the migration
|
||||
// stays safe. Existing rows keep the WordNet slot through the default value.
|
||||
func addDictionaryProvider(ctx context.Context, conn *sql.Conn) error {
|
||||
var exists int
|
||||
if err := conn.QueryRowContext(ctx,
|
||||
"SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lexgo_dictionaries' AND COLUMN_NAME = 'provider'").Scan(&exists); err != nil {
|
||||
return err
|
||||
}
|
||||
if exists > 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := conn.ExecContext(ctx, "ALTER TABLE lexgo_dictionaries ADD COLUMN provider VARCHAR(32) NOT NULL DEFAULT 'wordnet'")
|
||||
return err
|
||||
}
|
||||
|
||||
// dropDictionarySingleSlotCheck removes the CHECK (id = 1) constraint of v1..v11 so more than one
|
||||
// dictionary row can exist. MySQL names such a constraint automatically, so it is looked up and
|
||||
// skipped when it is already gone.
|
||||
func dropDictionarySingleSlotCheck(ctx context.Context, conn *sql.Conn) error {
|
||||
rows, err := conn.QueryContext(ctx,
|
||||
"SELECT tc.CONSTRAINT_NAME, cc.CHECK_CLAUSE FROM information_schema.TABLE_CONSTRAINTS tc "+
|
||||
"JOIN information_schema.CHECK_CONSTRAINTS cc ON cc.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA AND cc.CONSTRAINT_NAME = tc.CONSTRAINT_NAME "+
|
||||
"WHERE tc.TABLE_SCHEMA = DATABASE() AND tc.TABLE_NAME = 'lexgo_dictionaries' AND tc.CONSTRAINT_TYPE = 'CHECK'")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
names := []string{}
|
||||
for rows.Next() {
|
||||
var name, clause string
|
||||
if err = rows.Scan(&name, &clause); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
compact := strings.ReplaceAll(strings.ReplaceAll(clause, "`", ""), " ", "")
|
||||
if strings.Contains(compact, "id=1") {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if err = rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, name := range names {
|
||||
if _, err = conn.ExecContext(ctx, "ALTER TABLE lexgo_dictionaries DROP CHECK `"+name+"`"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
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.
|
||||
// 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
|
||||
@@ -222,7 +377,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
|
||||
@@ -230,6 +386,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 '',
|
||||
|
||||
+161
-43
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -17,10 +18,12 @@ import (
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// ID 1 is the sole English corpus slot. Keeping the validated ZIP in MySQL
|
||||
// makes ordinary database backups include the resource needed after a restart.
|
||||
// Slot 1 holds the reviewed WordNet corpus and slot 2 the prepared 英汉 resource. The provider
|
||||
// column names what each row is, because a second English dictionary now lives beside the first.
|
||||
// Keeping both in MySQL means ordinary database backups include every resource needed at restart.
|
||||
type DictionaryResource struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement:false"`
|
||||
Provider string
|
||||
Name string
|
||||
Language string
|
||||
Version string
|
||||
@@ -37,6 +40,7 @@ func (DictionaryResource) TableName() string { return "lexgo_dictionaries" }
|
||||
|
||||
type DictionaryView struct {
|
||||
ID int64 `json:"id"`
|
||||
Provider string `json:"provider"`
|
||||
Name string `json:"name"`
|
||||
Language string `json:"language"`
|
||||
Version string `json:"version"`
|
||||
@@ -50,7 +54,7 @@ type DictionaryView struct {
|
||||
}
|
||||
|
||||
func dictionaryView(r DictionaryResource, status string) DictionaryView {
|
||||
return DictionaryView{r.ID, r.Name, r.Language, r.Version, r.Source, r.Format, status, r.Enabled, r.SHA256, r.EntryCount, r.UpdatedAt}
|
||||
return DictionaryView{r.ID, r.Provider, r.Name, r.Language, r.Version, r.Source, r.Format, status, r.Enabled, r.SHA256, r.EntryCount, r.UpdatedAt}
|
||||
}
|
||||
|
||||
type DictionaryImportResult struct {
|
||||
@@ -63,34 +67,54 @@ type ChapterTokens struct {
|
||||
Phrases []PhraseSpan `json:"phrases"`
|
||||
}
|
||||
|
||||
// Each router keeps at most one immutable parsed corpus; no private chapter or
|
||||
// Each router keeps at most one immutable parsed corpus per resource slot; no private chapter or
|
||||
// lookup data enters the cache. The mutex also coalesces simultaneous cold loads.
|
||||
type dictionaryCache struct {
|
||||
mu sync.Mutex
|
||||
sha string
|
||||
engine *WordNet
|
||||
type corpus interface {
|
||||
Lookup(query string) LookupResult
|
||||
}
|
||||
|
||||
func (cache *dictionaryCache) load(tx *gorm.DB, r DictionaryResource) (*WordNet, error) {
|
||||
type cachedCorpus struct {
|
||||
sha string
|
||||
corpus corpus
|
||||
}
|
||||
|
||||
type dictionaryCache struct {
|
||||
mu sync.Mutex
|
||||
loaded map[int64]cachedCorpus
|
||||
}
|
||||
|
||||
// parseResource dispatches on the stored format, so WordNet and the 英汉 resource can coexist
|
||||
// without either parser knowing about the other's layout.
|
||||
func parseResource(resource DictionaryResource, archive []byte) (corpus, error) {
|
||||
switch resource.Format {
|
||||
case ChineseDictionaryFormat:
|
||||
return ParseChineseDictionary(archive)
|
||||
case "wordnet-3.0-zip":
|
||||
return ParseWordNet(archive)
|
||||
default:
|
||||
return nil, errors.New("unsupported resource format")
|
||||
}
|
||||
}
|
||||
|
||||
func (cache *dictionaryCache) load(tx *gorm.DB, r DictionaryResource) (corpus, error) {
|
||||
cache.mu.Lock()
|
||||
defer cache.mu.Unlock()
|
||||
if r.SHA256 != WordNetSHA {
|
||||
return nil, errors.New("unsupported resource checksum")
|
||||
}
|
||||
if cache.sha == r.SHA256 && cache.engine != nil {
|
||||
return cache.engine, nil
|
||||
if cached, ok := cache.loaded[r.ID]; ok && cached.sha == r.SHA256 && cached.corpus != nil {
|
||||
return cached.corpus, nil
|
||||
}
|
||||
var stored DictionaryResource
|
||||
if err := tx.Select("id", "archive").Where("id = ? AND sha256 = ?", r.ID, r.SHA256).First(&stored).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
engine, err := ParseWordNet(stored.Archive)
|
||||
parsed, err := parseResource(r, stored.Archive)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cache.sha = r.SHA256
|
||||
cache.engine = engine
|
||||
return engine, nil
|
||||
if cache.loaded == nil {
|
||||
cache.loaded = map[int64]cachedCorpus{}
|
||||
}
|
||||
cache.loaded[r.ID] = cachedCorpus{sha: r.SHA256, corpus: parsed}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func readyOwnedChapter(tx *gorm.DB, owner int, id int64) (Chapter, error) {
|
||||
@@ -109,7 +133,7 @@ func readyOwnedChapter(tx *gorm.DB, owner int, id int64) (Chapter, error) {
|
||||
}
|
||||
|
||||
func readDictionaryUpload(c *gin.Context) (DictionaryResource, error) {
|
||||
bad := failure(400, "词典上传无效,请使用指定的 WordNet 3.0 ZIP 和完整资源信息")
|
||||
bad := failure(400, "词典上传无效,请使用指定的词典 ZIP 和完整资源信息")
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxDictionaryZip+(64<<10))
|
||||
reader, err := c.Request.MultipartReader()
|
||||
if err != nil {
|
||||
@@ -154,11 +178,50 @@ func readDictionaryUpload(c *gin.Context) (DictionaryResource, error) {
|
||||
}
|
||||
part.Close()
|
||||
}
|
||||
name := strings.TrimSpace(fields["name"])
|
||||
if name == "" || utf8.RuneCountInString(name) > 120 || strings.IndexFunc(name, unicode.IsControl) >= 0 || fields["language"] != "en" || fields["version"] != "3.0" || fields["format"] != "wordnet-3.0-zip" || fields["source"] != WordNetSource || len(archive) == 0 {
|
||||
return DictionaryResource{}, bad
|
||||
return newDictionaryResource(fields, archive)
|
||||
}
|
||||
|
||||
// supportedDictionaries lists every reviewed resource an operator may import. The 英汉 archive is
|
||||
// not downloaded from the internet at import time: scripts/dict_prepare.py builds it from the
|
||||
// pinned sources and the operator imports that ZIP.
|
||||
func supportedDictionaries() []gin.H {
|
||||
return []gin.H{
|
||||
{"provider": "wordnet", "name": "Princeton WordNet", "language": "en", "version": WordNetVersion, "format": "wordnet-3.0-zip", "source": WordNetSource, "sha256": WordNetSHA, "downloadable": true, "description": "英语释义"},
|
||||
{"provider": "ecdict", "name": "简明英汉字典增强版(ECDICT)+ CMUdict 音标", "language": "en", "version": ChineseDictionaryVersion, "format": ChineseDictionaryFormat, "source": ChineseDictionarySource, "sha256": "", "downloadable": false, "description": "中文释义与 IPA 音标,由 scripts/dict_prepare.py 生成"},
|
||||
}
|
||||
}
|
||||
|
||||
// newDictionaryResource validates the multipart fields against the reviewed specification of the
|
||||
// declared format, so an unreviewed archive can never occupy a dictionary slot.
|
||||
func newDictionaryResource(fields map[string]string, archive []byte) (DictionaryResource, error) {
|
||||
name := strings.TrimSpace(fields["name"])
|
||||
if name == "" || utf8.RuneCountInString(name) > 120 || strings.IndexFunc(name, unicode.IsControl) >= 0 || fields["language"] != "en" || len(archive) == 0 {
|
||||
return DictionaryResource{}, failure(400, "词典上传无效,请使用指定的词典 ZIP 和完整资源信息")
|
||||
}
|
||||
switch fields["format"] {
|
||||
case "wordnet-3.0-zip":
|
||||
if fields["version"] != WordNetVersion || fields["source"] != WordNetSource {
|
||||
return DictionaryResource{}, failure(400, "词典上传无效,请使用指定的词典 ZIP 和完整资源信息")
|
||||
}
|
||||
resource := DictionaryResource{ID: WordNetSlot, Provider: "wordnet", Name: name, Language: "en", Version: WordNetVersion, Source: WordNetSource, Format: "wordnet-3.0-zip", SHA256: WordNetSHA, Enabled: true, Archive: archive}
|
||||
parsed, err := ParseWordNet(archive)
|
||||
if err != nil {
|
||||
return DictionaryResource{}, failure(400, "词典文件校验失败,请选择指定的 WordNet 3.0 ZIP")
|
||||
}
|
||||
resource.EntryCount = parsed.EntryCount
|
||||
return resource, nil
|
||||
case ChineseDictionaryFormat:
|
||||
if fields["version"] != ChineseDictionaryVersion || fields["source"] != ChineseDictionarySource {
|
||||
return DictionaryResource{}, failure(400, "词典上传无效,请使用 dict_prepare.py 生成的英汉词典 ZIP(版本 "+ChineseDictionaryVersion+")")
|
||||
}
|
||||
parsed, err := ParseChineseDictionary(archive)
|
||||
if err != nil {
|
||||
return DictionaryResource{}, failure(400, "英汉词典文件校验失败,请使用 dict_prepare.py 生成的 ZIP")
|
||||
}
|
||||
return DictionaryResource{ID: ChineseSlot, Provider: "ecdict", Name: name, Language: "en", Version: ChineseDictionaryVersion, Source: ChineseDictionarySource, Format: ChineseDictionaryFormat, SHA256: contentSHA(string(archive)), EntryCount: parsed.EntryCount, Enabled: true, Archive: archive}, nil
|
||||
default:
|
||||
return DictionaryResource{}, failure(400, "词典上传无效,请使用指定的词典 ZIP 和完整资源信息")
|
||||
}
|
||||
return DictionaryResource{ID: 1, Name: name, Language: "en", Version: "3.0", Source: WordNetSource, Format: "wordnet-3.0-zip", SHA256: WordNetSHA, Enabled: true, Archive: archive}, nil
|
||||
}
|
||||
|
||||
func registerDictionaryRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context, *gorm.DB, admin.SysUser) (any, error)) gin.HandlerFunc, now func() time.Time) {
|
||||
@@ -183,7 +246,7 @@ func registerDictionaryRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.C
|
||||
}
|
||||
items = append(items, dictionaryView(row, status))
|
||||
}
|
||||
return gin.H{"items": items, "supported": gin.H{"name": "Princeton WordNet", "language": "en", "version": "3.0", "format": "wordnet-3.0-zip", "source": WordNetSource, "sha256": WordNetSHA}}, nil
|
||||
return gin.H{"items": items, "supported": supportedDictionaries()}, nil
|
||||
}))
|
||||
v.POST("/dictionaries/import", protect(true, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
|
||||
select {
|
||||
@@ -196,11 +259,6 @@ func registerDictionaryRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.C
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
engine, err := ParseWordNet(resource.Archive)
|
||||
if err != nil {
|
||||
return nil, failure(400, "词典文件校验失败,请选择指定的 WordNet 3.0 ZIP")
|
||||
}
|
||||
resource.EntryCount = engine.EntryCount
|
||||
resource.UpdatedAt = stamp(now())
|
||||
// INSERT ... ON CONFLICT followed by a locking read serializes even the first
|
||||
// concurrent import. A validated replacement and its metadata commit together.
|
||||
@@ -209,12 +267,12 @@ func registerDictionaryRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.C
|
||||
return nil, created.Error
|
||||
}
|
||||
var existing DictionaryResource
|
||||
if err = tx.Omit("archive").Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = 1").First(&existing).Error; err != nil {
|
||||
if err = tx.Omit("archive").Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", resource.ID).First(&existing).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
duplicate := created.RowsAffected == 0 && existing.SHA256 == resource.SHA256
|
||||
if created.RowsAffected == 0 {
|
||||
if err = tx.Model(&DictionaryResource{}).Where("id = 1").Select("name", "language", "version", "source", "format", "sha256", "entry_count", "enabled", "archive", "updated_at").Updates(&resource).Error; err != nil {
|
||||
if err = tx.Model(&DictionaryResource{}).Where("id = ?", resource.ID).Select("provider", "name", "language", "version", "source", "format", "sha256", "entry_count", "enabled", "archive", "updated_at").Updates(&resource).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existing = resource
|
||||
@@ -227,7 +285,7 @@ func registerDictionaryRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.C
|
||||
}))
|
||||
v.PATCH("/dictionaries/:id", protect(true, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil || id != 1 {
|
||||
if err != nil || id <= 0 {
|
||||
return nil, failure(404, "词典不存在")
|
||||
}
|
||||
var input struct {
|
||||
@@ -310,20 +368,80 @@ func registerDictionaryRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.C
|
||||
return nil, err
|
||||
}
|
||||
missing := LookupResult{Status: "resource_missing", Query: query, Candidates: []string{}, Entries: []DictionaryEntry{}}
|
||||
var resource DictionaryResource
|
||||
err = tx.Omit("archive").Where("id = 1 AND enabled = ?", true).First(&resource).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return missing, nil
|
||||
}
|
||||
if err != nil {
|
||||
var resources []DictionaryResource
|
||||
if err = tx.Omit("archive").Where("language = ? AND enabled = ?", "en", true).Order("id").Find(&resources).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
engine, err := cache.load(tx, resource)
|
||||
if err != nil {
|
||||
if len(resources) == 0 {
|
||||
return missing, nil
|
||||
}
|
||||
result := engine.Lookup(query)
|
||||
result.Resource = &LookupResource{Name: resource.Name, Version: resource.Version}
|
||||
return result, nil
|
||||
// The Chinese gloss answers "what does this mean" first; WordNet follows as English detail.
|
||||
// A disabled or unreadable resource is skipped, so one missing dictionary degrades to the
|
||||
// other instead of failing the whole lookup.
|
||||
sort.SliceStable(resources, func(i, j int) bool { return providerRank(resources[i].Provider) < providerRank(resources[j].Provider) })
|
||||
results := make([]LookupResult, 0, len(resources))
|
||||
loaded := make([]LookupResource, 0, len(resources))
|
||||
loadedCorpora := make([]struct {
|
||||
resource DictionaryResource
|
||||
corpus corpus
|
||||
}, 0, len(resources))
|
||||
var primary *LookupResource
|
||||
for _, resource := range resources {
|
||||
engine, loadErr := cache.load(tx, resource)
|
||||
if loadErr != nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, engine.Lookup(query))
|
||||
loaded = append(loaded, LookupResource{Name: resource.Name, Version: resource.Version})
|
||||
loadedCorpora = append(loadedCorpora, struct {
|
||||
resource DictionaryResource
|
||||
corpus corpus
|
||||
}{resource, engine})
|
||||
if resource.Provider == "wordnet" && primary == nil {
|
||||
primary = &LookupResource{Name: resource.Name, Version: resource.Version}
|
||||
}
|
||||
}
|
||||
if len(results) == 0 {
|
||||
return missing, nil
|
||||
}
|
||||
merged := mergeLookups(query, results)
|
||||
merged.Resources = loaded
|
||||
if primary == nil {
|
||||
primary = &loaded[0]
|
||||
}
|
||||
merged.Resource = primary
|
||||
// WordNet resolves inflections (dogs -> dog); the Chinese gloss is stored per headword, so
|
||||
// the resolved form is looked up there too. The gloss is shown, but its phonetic is not
|
||||
// copied to the inflection because it belongs to the headword.
|
||||
if merged.MatchedForm != nil && *merged.MatchedForm != merged.Query && !hasProviderEntry(merged.Entries, "ecdict") {
|
||||
for _, item := range loadedCorpora {
|
||||
if item.resource.Provider != "ecdict" {
|
||||
continue
|
||||
}
|
||||
lemma := item.corpus.Lookup(*merged.MatchedForm)
|
||||
if lemma.Status == "exact" && len(lemma.Entries) > 0 {
|
||||
lemma.Entries[0].Lemma = *merged.MatchedForm
|
||||
merged.Entries = append(lemma.Entries, merged.Entries...)
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged, nil
|
||||
}))
|
||||
}
|
||||
|
||||
func hasProviderEntry(entries []DictionaryEntry, provider string) bool {
|
||||
for _, entry := range entries {
|
||||
if entry.Provider == provider {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// providerRank orders providers for display: the Chinese gloss first, then English detail.
|
||||
func providerRank(provider string) int {
|
||||
if provider == "ecdict" {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
+81
-13
@@ -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,55 @@ 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
|
||||
}
|
||||
|
||||
// stageEditJob returns the job that owns one content version of a chapter.
|
||||
//
|
||||
// One chapter and one content digest describe one version, so the row that already describes it is
|
||||
// reused instead of adding another one. That matters because the derived request key of a version
|
||||
// that has been edited before already exists: creating it again hit the unique key and failed the
|
||||
// whole edit with a generic 500, so a learner could never return to a previous text. Reusing is also
|
||||
// correct for the version a chapter was pasted with, whose row carries the paste request key.
|
||||
func stageEditJob(tx *gorm.DB, owner int, chapter Chapter, sha string, ts time.Time) (IngestJob, error) {
|
||||
var existing IngestJob
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("owner_id = ? AND chapter_id = ? AND content_sha256 = ?", owner, chapter.ID, sha).
|
||||
Order("id ASC").First(&existing).Error
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
key := contentSHA(fmt.Sprintf("edit:%d:%s", chapter.ID, sha))
|
||||
created := IngestJob{OwnerID: owner, BookID: chapter.BookID, ChapterID: chapter.ID,
|
||||
RequestKey: key, ContentSHA256: sha, Status: statusPending, CreatedAt: ts, UpdatedAt: ts}
|
||||
if err := tx.Create(&created).Error; err != nil {
|
||||
return IngestJob{}, err
|
||||
}
|
||||
return created, nil
|
||||
case err != nil:
|
||||
return IngestJob{}, err
|
||||
}
|
||||
// Publishing the same version again — edited back, or retried after it failed — reuses the row and
|
||||
// restarts its attempt budget, so a person asking again is not blocked by the earlier failure.
|
||||
if err = tx.Model(&IngestJob{}).Where("id = ?", existing.ID).
|
||||
Updates(map[string]any{"status": statusPending, "attempts": 0, "error_reason": "",
|
||||
"updated_at": ts, "finished_at": nil}).Error; err != nil {
|
||||
return IngestJob{}, err
|
||||
}
|
||||
existing.Status = statusPending
|
||||
existing.Attempts = 0
|
||||
existing.ErrorReason = ""
|
||||
existing.FinishedAt = nil
|
||||
existing.UpdatedAt = ts
|
||||
return existing, 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 +138,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 +166,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 +176,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 +193,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
|
||||
@@ -144,14 +215,11 @@ func UpdateChapter(tx *gorm.DB, owner int, chapterID int64, input ChapterUpdateI
|
||||
updates["char_count"] = count
|
||||
updates["status"] = statusPending
|
||||
updates["error_reason"] = ""
|
||||
// The request key is derived from chapter and version, so one version has one job.
|
||||
key := contentSHA(fmt.Sprintf("edit:%d:%s", chapter.ID, sha))
|
||||
created := IngestJob{OwnerID: owner, BookID: chapter.BookID, ChapterID: chapter.ID,
|
||||
RequestKey: key, ContentSHA256: sha, Status: statusPending, CreatedAt: ts, UpdatedAt: ts}
|
||||
if err := tx.Create(&created).Error; err != nil {
|
||||
staged, err := stageEditJob(tx, owner, chapter, sha, ts)
|
||||
if err != nil {
|
||||
return ChapterEdit{}, err
|
||||
}
|
||||
job = &created
|
||||
job = &staged
|
||||
}
|
||||
}
|
||||
if err := tx.Model(&Chapter{}).Where("id = ? AND owner_id = ?", chapter.ID, owner).Updates(updates).Error; err != nil {
|
||||
|
||||
@@ -541,3 +541,302 @@ 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")
|
||||
}
|
||||
}
|
||||
|
||||
// editChapterText edits the body of one owned chapter through the API and fails on any error.
|
||||
func editChapterText(t *testing.T, r *gin.Engine, token string, chapterID int64, text string) ChapterEdit {
|
||||
t.Helper()
|
||||
code, msg, data := callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/chapters/%d", chapterID), token, map[string]string{"text": text})
|
||||
if code != 200 {
|
||||
t.Fatalf("editing back to a used version must succeed, got %d (%s)", code, msg)
|
||||
}
|
||||
var edited ChapterEdit
|
||||
if err := json.Unmarshal(data, &edited); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return edited
|
||||
}
|
||||
|
||||
// TestMySQLChapterEditBackToAPreviousVersion covers the defect reported in #32: the request key of an
|
||||
// edit job is derived from the content, so returning to a text used before must reuse that job instead
|
||||
// of colliding with its unique key. Every step of A -> B -> A -> B has to succeed, the chapter has to
|
||||
// end up holding the last text, and each version has to keep exactly one job row.
|
||||
func TestMySQLChapterEditBackToAPreviousVersion(t *testing.T) {
|
||||
db, r, owner := libraryFixture(t)
|
||||
learner := newLearner(t, r, owner.Token)
|
||||
first := "Curiosity opens the first door.\n"
|
||||
second := "Curiosity opens a different door.\n"
|
||||
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
|
||||
"requestId": "edit-version-0001", "title": "Fictional versions", "text": first, "language": "en"})
|
||||
if code != 201 {
|
||||
t.Fatalf("paste %d", code)
|
||||
}
|
||||
drainIngest(t, db)
|
||||
chapterID := pasted.Chapter.ID
|
||||
firstSHA, secondSHA := contentSHA(first), contentSHA(second)
|
||||
|
||||
// A -> B.
|
||||
edited := editChapterText(t, r, learner.Token, chapterID, second)
|
||||
if !edited.VersionChanged || edited.Chapter.Status != statusPending || edited.Job == nil {
|
||||
t.Fatalf("the first edit must start a new version: %+v", edited)
|
||||
}
|
||||
// The response carries the client-facing job view; the version it describes is read back from the
|
||||
// row itself.
|
||||
var secondJob IngestJob
|
||||
if err := db.Where("chapter_id = ? AND content_sha256 = ?", chapterID, secondSHA).First(&secondJob).Error; err != nil {
|
||||
t.Fatalf("the new version needs its own job: %v", err)
|
||||
}
|
||||
if err := db.Model(&Chapter{}).Where("id = ?", chapterID).Update("status", statusReady).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// B -> A: this is the step that used to answer 500.
|
||||
edited = editChapterText(t, r, learner.Token, chapterID, first)
|
||||
if !edited.VersionChanged || edited.Job == nil {
|
||||
t.Fatalf("editing back must publish the earlier version: %+v", edited)
|
||||
}
|
||||
// The row that already described the pasted version is reused, so no second row appears for it.
|
||||
var rows int64
|
||||
if err := db.Model(&IngestJob{}).Where("chapter_id = ?", chapterID).Count(&rows).Error; err != nil || rows != 2 {
|
||||
t.Fatalf("one row per version is expected, got %d (%v)", rows, err)
|
||||
}
|
||||
var reused IngestJob
|
||||
if err := db.Where("chapter_id = ? AND content_sha256 = ?", chapterID, firstSHA).First(&reused).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reused.Status != statusPending || reused.Attempts != 0 || reused.FinishedAt != nil || reused.ErrorReason != "" {
|
||||
t.Fatalf("a reused job must restart cleanly: %+v", reused)
|
||||
}
|
||||
if err := db.Model(&Chapter{}).Where("id = ?", chapterID).Update("status", statusReady).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A -> B again, and this time the B job is the one being reused.
|
||||
edited = editChapterText(t, r, learner.Token, chapterID, second)
|
||||
if !edited.VersionChanged || edited.Job == nil {
|
||||
t.Fatalf("switching between versions must keep working: %+v", edited)
|
||||
}
|
||||
if err := db.Model(&IngestJob{}).Where("chapter_id = ?", chapterID).Count(&rows).Error; err != nil || rows != 2 {
|
||||
t.Fatalf("still one row per version, got %d (%v)", rows, err)
|
||||
}
|
||||
|
||||
// A version whose job had failed is revived when it becomes current again.
|
||||
if err := db.Model(&IngestJob{}).Where("chapter_id = ? AND content_sha256 = ?", chapterID, secondSHA).
|
||||
Updates(map[string]any{"status": statusFailed, "attempts": maxIngestAttempts, "error_reason": reasonAttemptsExhausted}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
editChapterText(t, r, learner.Token, chapterID, first)
|
||||
edited = editChapterText(t, r, learner.Token, chapterID, second)
|
||||
if edited.Job == nil || edited.Job.Attempts != 0 || edited.Job.Status != statusPending {
|
||||
t.Fatalf("a failed version must be revived when it becomes current again: %+v", edited.Job)
|
||||
}
|
||||
|
||||
// The pipeline finishes the reused job and the chapter holds the last text.
|
||||
drainIngest(t, db)
|
||||
var chapter Chapter
|
||||
if err := db.Where("id = ?", chapterID).First(&chapter).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if chapter.Status != statusReady || chapter.ContentSHA256 != secondSHA || chapter.OriginalText != second {
|
||||
t.Fatalf("the chapter must hold the last edited text: %+v", chapter)
|
||||
}
|
||||
code, _, data := callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d", chapterID), learner.Token, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("reader %d", code)
|
||||
}
|
||||
var payload struct {
|
||||
Chapter ChapterView
|
||||
}
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload.Chapter.OriginalText != second {
|
||||
t.Fatalf("the reader must serve the last text: %q", payload.Chapter.OriginalText)
|
||||
}
|
||||
|
||||
// An unchanged text is still not a new version.
|
||||
code, _, _ = callRaw(t, r, "PATCH", fmt.Sprintf("/api/v1/chapters/%d", chapterID), learner.Token, map[string]string{"text": second})
|
||||
if code != 200 {
|
||||
t.Fatalf("saving the same text must succeed, got %d", code)
|
||||
}
|
||||
if err := db.Model(&IngestJob{}).Where("chapter_id = ?", chapterID).Count(&rows).Error; err != nil || rows != 2 {
|
||||
t.Fatalf("an unchanged text must not add a job, got %d (%v)", rows, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +163,55 @@ func TestMySQLLoginUsesStoredPasswordWithoutChangingCreationPolicy(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMySQLSessionLifetimeIsThirtyDays pins the login lifetime that both clients share. It checks
|
||||
// the API response, the stored row and the privacy rule (only a digest is persisted) so changing the
|
||||
// value without updating this test is impossible.
|
||||
func TestMySQLSessionLifetimeIsThirtyDays(t *testing.T) {
|
||||
if SessionLifetime != 30*24*time.Hour {
|
||||
t.Fatalf("session lifetime %s, want 30 days", SessionLifetime)
|
||||
}
|
||||
db := testDB(t)
|
||||
u := admin.SysUser{Username: randomName("sesslife"), Password: fixturePassword, RoleId: 2, Status: "2"}
|
||||
if err := db.Create(&u).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := Router(db, time.Now)
|
||||
before := time.Now()
|
||||
code, data := callAPI(t, r, "POST", "/api/v1/login", "", map[string]string{"username": u.Username, "password": fixturePassword})
|
||||
if code != 200 {
|
||||
t.Fatalf("login status %d", code)
|
||||
}
|
||||
var login struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &login); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
granted := login.ExpiresAt.Sub(before)
|
||||
if granted < SessionLifetime-time.Minute || granted > SessionLifetime+time.Minute {
|
||||
t.Fatalf("granted %s, want about %s", granted, SessionLifetime)
|
||||
}
|
||||
var stored Session
|
||||
if err := db.Where("token_hash = ?", digest(login.Token)).First(&stored).Error; err != nil {
|
||||
t.Fatal("session row missing")
|
||||
}
|
||||
if stored.ExpiresAt.Sub(stored.ExpiresAt.Truncate(time.Millisecond)) != 0 {
|
||||
t.Fatal("stored expiry lost precision")
|
||||
}
|
||||
if delta := login.ExpiresAt.Sub(stored.ExpiresAt); delta > time.Millisecond || delta < -time.Millisecond {
|
||||
t.Fatalf("stored expiry %s differs from the response by %s", stored.ExpiresAt, delta)
|
||||
}
|
||||
var raw int64
|
||||
db.Table("lexgo_sessions").Where("token_hash = ?", login.Token).Count(&raw)
|
||||
if raw != 0 {
|
||||
t.Fatal("raw token persisted")
|
||||
}
|
||||
if code, _ = callAPI(t, r, "GET", "/api/v1/me", login.Token, nil); code != 200 {
|
||||
t.Fatalf("fresh session rejected: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLAccountIsolationAndRevocation(t *testing.T) {
|
||||
db := testDB(t)
|
||||
if err := Migrate(db); err != nil {
|
||||
@@ -281,7 +330,13 @@ func TestMySQLAccountIsolationAndRevocation(t *testing.T) {
|
||||
t.Fatal("old password accepted")
|
||||
}
|
||||
loginToken(t, r, users[0], "replacement-fixture-pass")
|
||||
clock = clock.Add(9 * time.Hour)
|
||||
// The lifetime is absolute, so a session is still valid just before it and gone right after;
|
||||
// asserting against SessionLifetime keeps this test honest when the value changes.
|
||||
clock = clock.Add(SessionLifetime - time.Hour)
|
||||
if code, _ = callAPI(t, r, "GET", "/api/v1/me", tokenB, nil); code != 200 {
|
||||
t.Fatal("session must still be valid before its lifetime ends", code)
|
||||
}
|
||||
clock = clock.Add(2 * time.Hour)
|
||||
code, _ = callAPI(t, r, "GET", "/api/v1/me", tokenB, nil)
|
||||
if code != 401 {
|
||||
t.Fatal("expired session valid")
|
||||
|
||||
+102
-26
@@ -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
|
||||
@@ -112,31 +116,42 @@ 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"`
|
||||
Author string `json:"author"`
|
||||
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"`
|
||||
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.
|
||||
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, Author: book.Author, Language: book.Language}
|
||||
}
|
||||
|
||||
type ChapterSummary struct {
|
||||
ID int64 `json:"id"`
|
||||
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.
|
||||
@@ -145,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 {
|
||||
@@ -161,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}
|
||||
}
|
||||
@@ -485,12 +505,26 @@ 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 {
|
||||
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 +559,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 {
|
||||
@@ -550,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 {
|
||||
@@ -559,9 +611,16 @@ 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)
|
||||
}
|
||||
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 +686,24 @@ 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 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
|
||||
}
|
||||
|
||||
func JobDetail(db *gorm.DB, owner int, jobID int64) (JobView, error) {
|
||||
|
||||
@@ -169,8 +169,10 @@ func TestMigrationFromV4KeepsLibraryAndAddsPersonalTerms(t *testing.T) {
|
||||
if err := db.Create(&chapter).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resource := DictionaryResource{ID: 1, Name: "Fixture WordNet", Language: "en", Version: "3.0", Source: WordNetSource, Format: "wordnet-3.0-zip", SHA256: strings.Repeat("a", 64), EntryCount: 5, Enabled: true, Archive: []byte("fixture archive"), UpdatedAt: now}
|
||||
if err := db.Create(&resource).Error; err != nil {
|
||||
// A v4 database has no provider column, so the fixture is written with the columns that
|
||||
// existed then; the migration adds provider and the default names it as the WordNet slot.
|
||||
if err := db.Exec("INSERT INTO lexgo_dictionaries (id,name,language,version,source,format,sha256,entry_count,enabled,archive,updated_at) VALUES (1,?,?,?,?,?,?,?,?,?,?)",
|
||||
"Fixture WordNet", "en", "3.0", WordNetSource, "wordnet-3.0-zip", strings.Repeat("a", 64), 5, true, []byte("fixture archive"), now).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CheckSchema(db); err == nil {
|
||||
@@ -187,7 +189,7 @@ func TestMigrationFromV4KeepsLibraryAndAddsPersonalTerms(t *testing.T) {
|
||||
t.Fatal("migration changed original chapter", err)
|
||||
}
|
||||
var restoredResource DictionaryResource
|
||||
if err := db.Omit("archive").First(&restoredResource, 1).Error; err != nil || restoredResource.SHA256 != resource.SHA256 || restoredResource.EntryCount != 5 {
|
||||
if err := db.Omit("archive").First(&restoredResource, 1).Error; err != nil || restoredResource.SHA256 != strings.Repeat("a", 64) || restoredResource.EntryCount != 5 || restoredResource.Provider != "wordnet" {
|
||||
t.Fatal("migration changed the shared dictionary resource", err)
|
||||
}
|
||||
var terms int64
|
||||
@@ -447,3 +449,345 @@ 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")
|
||||
}
|
||||
// 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.
|
||||
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)
|
||||
}
|
||||
// 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.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 {
|
||||
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.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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ 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",
|
||||
"lexgo_chapter_attachments", "lexgo_chapter_playback_positions",
|
||||
}
|
||||
|
||||
var opsSystemSchemas = map[string]bool{
|
||||
@@ -544,6 +546,28 @@ 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
|
||||
}
|
||||
// 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"} {
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,11 @@ type Space struct {
|
||||
|
||||
func (Space) TableName() string { return "lexgo_spaces" }
|
||||
|
||||
// SessionLifetime is how long a login lasts. It is absolute, not sliding: using the app does not
|
||||
// extend it, so a stolen token cannot be kept alive forever. Both the learner and the admin client
|
||||
// authenticate through the same login endpoint, so one value covers them.
|
||||
const SessionLifetime = 30 * 24 * time.Hour
|
||||
|
||||
type Session struct {
|
||||
TokenHash string `gorm:"primaryKey"`
|
||||
OwnerID int
|
||||
@@ -162,7 +167,7 @@ func login(db *gorm.DB, now time.Time, name, password string) (LoginResult, erro
|
||||
return err
|
||||
}
|
||||
token := hex.EncodeToString(b)
|
||||
expiry := now.Add(8 * time.Hour)
|
||||
expiry := now.Add(SessionLifetime)
|
||||
if err = tx.Where("owner_id = ? AND expires_at <= ?", u.UserId, now).Delete(&Session{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,13 @@ import (
|
||||
|
||||
const WordNetSHA = "cbda5ea6eef7f36a97a43d4a75f85e07fccbb4f23657d27b4ccbc93e2646ab59"
|
||||
const WordNetSource = "https://raw.githubusercontent.com/nltk/nltk_data/96f9b3252457a2b97e52aec64c3dfceeb5c312d5/packages/corpora/wordnet.zip"
|
||||
const WordNetVersion = "3.0"
|
||||
|
||||
// Dictionary slots are fixed so an operator always knows which row holds which resource.
|
||||
const (
|
||||
WordNetSlot = 1
|
||||
ChineseSlot = 2
|
||||
)
|
||||
const maxDictionaryZip = 32 << 20
|
||||
const maxDictionaryInflated = 128 << 20
|
||||
|
||||
@@ -76,6 +83,11 @@ type DictionaryEntry struct {
|
||||
POS string `json:"pos"`
|
||||
Definition string `json:"definition"`
|
||||
Examples []string `json:"examples"`
|
||||
// The Chinese gloss and phonetic come from the 英汉 resource and are empty for WordNet senses.
|
||||
Translation string `json:"translation,omitempty"`
|
||||
Phonetic string `json:"phonetic,omitempty"`
|
||||
PhoneticSource string `json:"phoneticSource,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
}
|
||||
type LookupResult struct {
|
||||
Status string `json:"status"`
|
||||
@@ -84,6 +96,12 @@ type LookupResult struct {
|
||||
Candidates []string `json:"candidates"`
|
||||
Entries []DictionaryEntry `json:"entries"`
|
||||
Resource *LookupResource `json:"resource,omitempty"`
|
||||
Resources []LookupResource `json:"resources,omitempty"`
|
||||
// Phonetic repeats the first phonetic found among the entries so a reader can show it next to
|
||||
// the word without walking the senses; PhoneticSource records whether it is a CMUdict IPA
|
||||
// transcription or the ECDICT notation kept with character-level normalisation only.
|
||||
Phonetic string `json:"phonetic,omitempty"`
|
||||
PhoneticSource string `json:"phoneticSource,omitempty"`
|
||||
}
|
||||
type LookupResource struct {
|
||||
Name string `json:"name"`
|
||||
@@ -300,6 +318,7 @@ func (wn *WordNet) Lookup(query string) LookupResult {
|
||||
}
|
||||
entry := part.data[offset]
|
||||
entry.Lemma = strings.ReplaceAll(form, "_", " ")
|
||||
entry.Provider = "wordnet"
|
||||
result.Entries = append(result.Entries, entry)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "简明英汉字典增强版常用子集 (ECDICT) + CMUdict 音标转写",
|
||||
"language": "en",
|
||||
"provider": "ecdict",
|
||||
"format": "lexgo-zh-dict-v1",
|
||||
"version": "ecdict-1.0.28+cmudict-0.7b",
|
||||
"sources": [
|
||||
{
|
||||
"name": "ECDICT StarDict",
|
||||
"role": "chinese-gloss",
|
||||
"source": "https://github.com/skywind3000/ECDICT/releases/download/1.0.28/ecdict-stardict-28.zip",
|
||||
"mirrors": [],
|
||||
"sha256": "c707d0f3ded6ec79b96466da4a1574e074703da5af9c120fbad97f9cb08c6f2c",
|
||||
"bytes": 70415834,
|
||||
"license": "ECDICT-LICENSE.txt",
|
||||
"licenseUrl": "https://github.com/skywind3000/ECDICT/blob/master/LICENSE",
|
||||
"note": "仓库为 MIT;词典数据由公开资料整理,随项目再分发前需自行评估来源。子集按下方 subset 规则裁剪,不导入全量 340 万条。"
|
||||
},
|
||||
{
|
||||
"name": "CMUdict",
|
||||
"role": "ipa-phonetic",
|
||||
"source": "https://raw.githubusercontent.com/cmusphinx/cmudict/master/cmudict.dict",
|
||||
"mirrors": [
|
||||
"https://cdn.jsdelivr.net/gh/cmusphinx/cmudict@master/cmudict.dict"
|
||||
],
|
||||
"sha256": "81917843c7f44ce2b094ac63873c2c7a4cf802040792c455ba3ca406891c3d22",
|
||||
"bytes": 3618488,
|
||||
"license": "CMUDICT-LICENSE.txt",
|
||||
"licenseUrl": "https://github.com/cmusphinx/cmudict/blob/master/LICENSE",
|
||||
"note": "BSD-2。ARPAbet 音素按固定映射表转写为 IPA;仅用于需要音标的词条。"
|
||||
},
|
||||
{
|
||||
"name": "Princeton WordNet",
|
||||
"role": "subset-wordlist",
|
||||
"source": "https://raw.githubusercontent.com/nltk/nltk_data/96f9b3252457a2b97e52aec64c3dfceeb5c312d5/packages/corpora/wordnet.zip",
|
||||
"mirrors": [
|
||||
"https://cdn.jsdelivr.net/gh/nltk/nltk_data@96f9b3252457a2b97e52aec64c3dfceeb5c312d5/packages/corpora/wordnet.zip"
|
||||
],
|
||||
"sha256": "cbda5ea6eef7f36a97a43d4a75f85e07fccbb4f23657d27b4ccbc93e2646ab59",
|
||||
"license": "WORDNET-LICENSE.txt",
|
||||
"note": "与 server/wordnet-resource.json 同一 pin,仅用于取 lemma 词表决定子集范围,不复制其释义。"
|
||||
}
|
||||
],
|
||||
"subset": {
|
||||
"rule": "lowcase single alphabetic headword, unique, present in the WordNet lemma list or carrying an ECDICT exam tag (研/四/六/托/专/雅/高/中/初)",
|
||||
"expectedEntries": 82721,
|
||||
"toleranceEntries": 2000,
|
||||
"measuredAt": "2026-09-16",
|
||||
"measured": {
|
||||
"entries": 82721,
|
||||
"withCmudictIpa": 35799,
|
||||
"withEcdictPhoneticOnly": 30580,
|
||||
"withoutPhonetic": 16342,
|
||||
"droppedEmpty": 45,
|
||||
"rawPayloadMiB": 8.1
|
||||
}
|
||||
},
|
||||
"phoneticRules": "prefer CMUdict ARPAbet->IPA mapping; otherwise keep the ECDICT notation with character-level normalisation only and mark the source; no phonetic rather than a guess",
|
||||
"runtime": "Go only; the prepared ZIP is explicitly imported and persisted in MySQL; no runtime download and no Python process",
|
||||
"preparedArtifact": {
|
||||
"bytes": 3003208,
|
||||
"sha256": "a4f4ab972c6e233e39909a861015c99ad7943e8db24353afc9cc9d8160dded24",
|
||||
"note": "由 scripts/dict_prepare.py 在本机生成,不入库;每次重建都会打印自己的 sha256"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user