Compare commits

..
Author SHA1 Message Date
ila 2d17231b42 docs: 将核心镜像与 main 保持一致,镜像更新统一在验收提交中完成 (#32) 2026-09-16 21:50:03 +08:00
ila 7396c037a1 docs: 按线上 Wiki 重新导出核心镜像,消除与 main 的镜像冲突 (#32) 2026-09-16 21:43:24 +08:00
ila cbb48407cd fix: 编辑回到曾经用过的正文版本复用该版本的任务 (#32)
- 根因:编辑任务键按「章节+内容摘要」派生,与 uq_job_request 唯一键相撞,
  改回曾经用过的正文时 Create 触发 MySQL 1062,整笔事务回滚并冒泡为通用 500
- 修复:新增 stageEditJob,先按 (owner_id, chapter_id, content_sha256) 锁定查询该版本的任务行,
  找到就复用并重置为 pending/attempts=0/清空失败原因,找不到才用派生键创建;
  粘贴版本的任务行同样可被复用,因此一章一个内容版本只保留一行
- 不变的门控:旧版本任务仍不能把新版本章节拉回处理(RetryIngestJob 409)、
  未改动的正文不产生新版本、同一版本重复处理幂等
- 无 schema 变化,无需迁移
- 测试:新增 TestMySQLChapterEditBackToAPreviousVersion(A→B→A→B 此前第三步 500、
  一章一版本一行、复用行干净重启、失败版本再次成为当前版本时复活、读者返回最后正文);
  Go 顶层用例 92 项;真实 API+MySQL 21 项检查通过
- 文档:Architecture / Business-Rules / Local-Development 记录规则与验证
2026-09-16 11:02:50 +08:00
ila c293a41714 docs: record issue 37 acceptance and merge (#37)
- #37 已由用户验收;PR #38 已 fast-forward-only 合入 main,merge commit 728f7d6
- Wiki:Architecture 与 Product-Requirements 标记 #37 已验收,Local-Development 追加验收后
  最终状态(schema v11、未实现的两项作者显示),Home 替换为已验收文案,Project-Profile
  追加当前工程状态
- README 与项目档案记录最终 schema 版本、作者语义、未实现项与对话框样式教训
- docs/ 由 harness.py sync 导出,revision:Architecture 3cf18f98、Product-Requirements daf31e0d、
  Local-Development 7c41d62a、Home 3af7d779、Project-Profile 39adde48
2026-09-16 10:26:38 +08:00
11 changed files with 418 additions and 24 deletions
+2
View File
@@ -287,6 +287,8 @@ 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`)。
+2 -2
View File
@@ -8,7 +8,7 @@
- [英语分词与离线词典验证小样](spikes/english/README.md)(#3 已验收,独立本机入口)
- [阅读选择验证小样](spikes/selection/README.md)(#4 已验收,真机详细测试证据缺口保留)
- [项目档案](docs/00-project-profile.md) · [需求总览](docs/09-product-requirements-overview.md)
- [工作量估算](docs/10-workload-estimate.md):#2、#3、#4、#5、#6、#7、#8、#9、#10、#11、#12、#13、#14、#18 已验收,原规划中的 #5 已完成;#15 与 #21 已验收;#37(章级音频与章节插图)已实现待验收;#24 待排期;缺陷 #32 待确认修复方案;后续结合集成结果重估,旧全量研究仅供参考。
- [工作量估算](docs/10-workload-estimate.md):#2、#3、#4、#5、#6、#7、#8、#9、#10、#11、#12、#13、#14、#18 已验收,原规划中的 #5 已完成;#15、#21 与 #37(章级音频、章节插图、章节/书籍作者)已验收;#24 待排期;缺陷 #32 待确认修复方案;后续结合集成结果重估,旧全量研究仅供参考。
- [四阶段实施总览 #16](https://git.ilapage.cn/OPC/lexgo/issues/16):14 张单元工单,工程基础 → 技术验证 → 首条学习闭环 → 补齐 MVP;原型 v1 已获用户验收。两端使用账号(用户名)+密码登录,不要求邮箱。
- [原型工单 #1](https://git.ilapage.cn/OPC/lexgo/issues/1):Quant-UX 桌面/手机原型 v1,预览入口与审核记录见工单及需求总览。
- 运维命令:`./lexgo migrate|bootstrap|serve|backup|restore|verify`(纯二进制),开发便利与交叉验证走 `python scripts/server.py …` / `python scripts/ops.py …`
@@ -16,7 +16,7 @@
## 本地工程基础
运行前在 MySQL 8 中准备项目专用空库,复制 `.env.example` 为忽略的 `.env.local` 并填写本机连接及初始管理员密码。首次执行 `python scripts/server.py migrate`、`python scripts/server.py bootstrap`(等价纯二进制路径:`./server/lexgo migrate` / `bootstrap`);随后执行 `python scripts/server.py serve`。两端分别通过 `npx --yes pnpm@9.15.1 --dir admin dev`、`npx --yes pnpm@9.15.1 --dir learner dev` 启动,首次需安装锁定依赖。书籍封面(书级)与章节音频、插图(章级)都存于数据库,包含在 `scripts/ops.py backup` 与 `./server/lexgo backup` 的备份范围内。
运行前在 MySQL 8 中准备项目专用空库,复制 `.env.example` 为忽略的 `.env.local` 并填写本机连接及初始管理员密码。首次执行 `python scripts/server.py migrate`、`python scripts/server.py bootstrap`(等价纯二进制路径:`./server/lexgo migrate` / `bootstrap`);随后执行 `python scripts/server.py serve`。两端分别通过 `npx --yes pnpm@9.15.1 --dir admin dev`、`npx --yes pnpm@9.15.1 --dir learner dev` 启动,首次需安装锁定依赖。书籍封面与书籍作者、章节音频/插图与章节作者、以及播放位置都存于数据库,包含在 `scripts/ops.py backup` 与 `./server/lexgo backup` 的备份范围内;schema 为 v11,需要显式迁移。
本地入口:学习端 http://127.0.0.1:5173,管理端 http://127.0.0.1:5174。完整安装与测试命令见[开发与验证](docs/04-local-development-and-verification.md)。账号使用用户名,无需邮箱;没有随代码交付的默认密码。
+12 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Project-Profile
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Project-Profile.-
wiki_revision: 2599afbb5e7cb47655fabe372596ca50cacb7a60
synchronized_at: 2026-09-15T12:25:15Z
wiki_revision: e61817b949693c82e74ad3d1a8adb39983a90fab
synchronized_at: 2026-09-16T05:27:04Z
<!-- gitea-wiki-mirror:end -->
# LexGo 项目档案
@@ -186,3 +186,13 @@ server 是 go-admin 的选用模块接入:原样保留 SysUser、SysDept、必
**#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。
+78 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Architecture-and-Code-Map.-
wiki_revision: 0bca600ea0dfcb18141920f448954bb9e2fa067c
synchronized_at: 2026-09-15T15:10:08Z
wiki_revision: 8cc11b2fa972babeb4756b68c0b071282cdb1960
synchronized_at: 2026-09-16T05:27:05Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -497,3 +497,79 @@ schema v8 新增两张表,都只用可重放的 `CREATE TABLE IF NOT EXISTS`
复用不变:`sniffAttachment`(magic bytes 判定)、`validateAttachment`(音频 ≤20 MiB;图片 ≤2 MiB 且 ≤4096×4096)、`coverDimensions`(JPEG/PNG 用标准库、WebP 读容器头)、`binaryResponse` + `http.ServeContent`(Range/206、416、ETag/304)、上传「先校验后写入、失败保留旧文件」的流程,以及 `chapterAttachmentViews`/`chapterPlaybackSeconds` 的批量读取(一次查询喂整个章节列表)。
学习端:`stores/library.ts` 用 `uploadChapterFile`/`deleteChapterFile`/`saveChapterPlayback`/`reportChapterPlayback` 替换了书级音频动作,并新增 `illustrationUrls`(按章节)与 `audioChapterId`(记录当前加载的音频属于哪一章,切章不会复用上一章的文件);`views/BookView.vue` 的书籍页把封面压成**一行紧凑控件**(`.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ɔɡ/`)。
+31 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Business-Rules-and-Glossary.-
wiki_revision: b688ef278f83c8055e16f91843a239f9ea2dd492
synchronized_at: 2026-09-15T15:10:08Z
wiki_revision: ba03226b60eee1c44062f4206b970a2047b8ddbc
synchronized_at: 2026-09-16T05:27:06Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -416,3 +416,32 @@ exact优先;未命中再按WordNet异常表/词尾规则查候选,词性顺
**删除与备份**:删除章节(#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 记法**(老式音标,仅做字符级规范化)、
**子集口径**(决定哪些词进入英汉词典的规则)。
+93 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Local-Development-and-Verification.-
wiki_revision: 8985f93f7319b2e6a428fc4417f353a7b909d797
synchronized_at: 2026-09-15T15:10:08Z
wiki_revision: afcd40ee530a24f924a39209c23a9dfd090114a2
synchronized_at: 2026-09-16T05:27:07Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -803,13 +803,52 @@ schema v8 → v9:新增 `lexgo_chapter_attachments` 与 `lexgo_chapter_playbac
带章级附件的 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)
### 迁移
@@ -1056,3 +1095,54 @@ schema v8 → v9:新增 `lexgo_chapter_attachments` 与 `lexgo_chapter_playbac
- 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`);查词只有英英释义 → 英汉词典未导入或被停用。
+19 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Product-Requirements-Overview
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Product-Requirements-Overview.-
wiki_revision: 21ca050a33bdbe8133c921b6b302fbfc8d8cf7fe
synchronized_at: 2026-09-15T15:10:10Z
wiki_revision: 5c7b0b4942b2c10edadf2f2b84686675267d323e
synchronized_at: 2026-09-16T05:27:09Z
<!-- gitea-wiki-mirror:end -->
# 产品需求总览
@@ -329,3 +329,20 @@ F 范围之外的 MVP 增补项「书籍音频与封面附件」已实现,待
用户的真实场景「多篇文章合并成一本书、每章各有音频,并为每章加插图」已实现,待用户验收:音频与插图改为**章节级**(一章各一张),封面维持**书级**;阅读页在正文上方显示本章插图缩略图、点击后在弹窗里查看原图(章节列表不显示章节图片),播放器只在本章有音频时出现;书籍页章节列表每行只有一个「编辑」入口,标题、作者、正文、插图与音频在同一对话框内编辑(标题/作者/正文点保存后生效,文件选中后立即上传);**书籍作者**与**章节作者**是两份互相独立的可选自由文本(各 ≤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 系列范围不受影响。
+13 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Deployment-and-Operations.-
wiki_revision: b9eda78d3e71dab02dbf2e3af700c81d9c725f8d
synchronized_at: 2026-09-15T13:07:36Z
wiki_revision: 2fa9c30e08fc14346d9960178a425a8ee3564a3a
synchronized_at: 2026-09-16T05:27:09Z
<!-- gitea-wiki-mirror:end -->
# 部署与运维
@@ -217,3 +217,14 @@ python scripts/ops.py smoke --api http://127.0.0.1:<端口> --admin-user <管理
- 恢复演练是单机顺序执行,未验证大库恢复耗时与磁盘空间上限。
- 接口级的两账号闭环与越权验证需要 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 记录,不因本单变化。
+5 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Home
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Home
wiki_revision: da0f9a0feb70b7099edcf8adf50a18a15435dd33
synchronized_at: 2026-09-15T15:10:07Z
wiki_revision: bbea927c73e51b6b5490de3cb52d42a88dfbb31d
synchronized_at: 2026-09-16T05:27:04Z
<!-- gitea-wiki-mirror:end -->
# LexGo 文档入口
@@ -91,7 +91,7 @@ Quant-UX 原型 v1 已通过用户验收。[桌面预览](https://qux.ilapage.cn
#21 书籍音频与封面附件已于 2026-09-15 通过用户验收:每本书可以上传一张封面(JPG/PNG/WebP,≤2 MiB,≤4096×4096)和一段 MP3(≤20 MiB);书库卡片显示封面,书籍页新增「音频与封面」区块可上传、替换、移除,阅读页在正文上方显示常驻播放器(播放/暂停、拖动进度、0.75–1.5 倍速),不自动播放、不与文本同步。播放位置按账号与书籍保存,替换或移除音频会重置位置;附件与进度按账号隔离,读取需要会话并支持 HTTP Range。附件存于 MySQL,因此 #15 的备份恢复天然覆盖:2026-09-15 的恢复演练验证了恢复后附件按字节相同地返回且 Range 仍可用。真实浏览器是否解码播放(媒体元素在测试中被桩替换)以及真机听感仍需人工确认。
#37 章节级音频与章节插图已于 2026-09-15 实施完成,待用户验收:每章可以上传一段 MP3(≤20 MiB)与一张插图(JPG/PNG/WebP,≤2 MiB、≤4096×4096),封面仍按书;阅读页在正文上方显示本章插图,播放器只在本章有音频时出现且不与文本同步,播放位置按「账号+章节」保存并在切换章节时上报;书籍页章节列表显示插图缩略图与「附件」入口,附件编辑与正文编辑分开。类型按文件内容判定,读取需会话并支持 HTTP Range 与 ETag;附件存于数据库,包含在 #15 的备份恢复范围内(第三次恢复演练已验证按字节恢复)。schema v9 需显式迁移,书级音频接口已下线,其实测试期数据在迁移中清空。真机听感与浏览器真实解码播放仍需人工确认。
#37 章节级音频与章节插图已于 2026-09-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 需显式迁移,书级音频接口已下线,其实测试期数据在迁移中清空。真机听感与浏览器真实解码播放仍需人工确认。
@@ -100,3 +100,5 @@ Quant-UX 原型 v1 已通过用户验收。[桌面预览](https://qux.ilapage.cn
#37 章节级音频与章节插图已于 2026-09-15 实施完成,待用户验收:每章可以上传一段 MP3(≤20 MiB)与一张插图(JPG/PNG/WebP,≤2 MiB、≤4096×4096),封面仍按书;阅读页在正文上方显示本章插图的缩略图、点击后在弹窗里查看原图(书籍页章节列表不显示章节图片),播放器只在本章有音频时出现且不与文本同步,播放位置按「账号+章节」保存并在切换章节时上报;书籍页章节列表每行只有一个「编辑」,点开后在同一对话框里编辑标题、作者、正文与本章插图、音频(标题/作者/正文点保存后生效,文件选中后立即上传;阅读页在标题下显示作者)。类型按文件内容判定,读取需会话并支持 HTTP Range 与 ETag;附件存于数据库,包含在 #15 的备份恢复范围内(第三次恢复演练已验证按字节恢复)。schema v9 需显式迁移,书级音频接口已下线,其实测试期数据在迁移中清空。真机听感与浏览器真实解码播放仍需人工确认。
#37 章节级音频与章节插图已于 2026-09-15 实施完成,待用户验收:每章可以上传一段 MP3(≤20 MiB)与一张插图(JPG/PNG/WebP,≤2 MiB、≤4096×4096),封面仍按书;阅读页在正文上方显示本章插图的缩略图、点击后在弹窗里查看原图(书籍页章节列表不显示章节图片),播放器只在本章有音频时出现且不与文本同步,播放位置按「账号+章节」保存并在切换章节时上报;书籍页的封面压成一行紧凑控件(预览+上传/替换/移除),书名右侧显示书级作者,「编辑书籍」可改书名与作者,章节列表每行只有一个「编辑」,点开后在同一对话框里编辑标题、作者、正文与本章插图、音频(标题/作者/正文点保存后生效,文件选中后立即上传;阅读页在标题下显示作者)。类型按文件内容判定,读取需会话并支持 HTTP Range 与 ETag;附件存于数据库,包含在 #15 的备份恢复范围内(第三次恢复演练已验证按字节恢复)。schema v9 需显式迁移,书级音频接口已下线,其实测试期数据在迁移中清空。真机听感与浏览器真实解码播放仍需人工确认。
#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 待合入,尚未验收。发音音频、整句翻译与汉英方向不在本单范围。
+42 -6
View File
@@ -78,6 +78,45 @@ func editAuthor(raw string) (string, error) {
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 {
@@ -176,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 {
+121
View File
@@ -719,3 +719,124 @@ func TestMySQLBookAuthorRoundTrip(t *testing.T) {
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)
}
}