45
Architecture-and-Code-Map
ila edited this page 2026-09-16 21:01:52 +08:00
This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

架构与代码地图

项目定位

Go 承担业务与后台任务,浏览器提供阅读学习界面,NLP 保留独立边界是现有分析的建议。用户区与管理区共享身份服务,但功能权限与个人数据所有权分别校验。

代码地图

现有位置 内容 能证明什么
docs/01-LinguaCafe需求提取.md、docs/linguacafe-requirements.md 两份需求研究 参考需求与上游证据索引
docs/02-Go复刻与框架选型分析.md、docs/linguacafe-go-analysis.md 两份设计建议 候选方案,含未解决差异
docs/ 核心页面 Gitea Wiki 的单向镜像 仅文档与治理准备,不是产品实现
dev_scripts/harness.py、dev_scripts/wiki_docs.py 原样复制的 DevHarness 工具 治理工具入口,无业务 API
tests/ 上游治理工具与文档结构测试 不验证阅读、NLP 或 SRS

工程入口与已实现模块见下方 #2/#18;学习领域拟定职责:identity(身份)、library(书库)、ingestion(导入)、lexicon(全局词典)、vocabulary(个人词语)、review(复习)、progress(统计)、administration(管理)。底座固定后再决定具体目录。

两条主要执行路径

目标路径一:上传文本 → 创建有所有者的导入任务 → 提取/分词/索引 → 章节就绪 → 阅读器按 token 展示原文和用户词语状态。

目标路径二:阅读保存词语或短语 → 写入个人学习状态与例句 → 查询到期词条 → 服务端计算作答后的状态 → 写入幂等事件 → 更新统计。

不可破坏的边界

  • 词典资源与个人释义分离,共享缓存不能混入个人记录。
  • Go/Python/JavaScript 的偏移契约必须统一;原文与内容版本必须保留。
  • API 与 Worker 复用业务规则;NLP 通过明确契约调用,不泄露模型内部对象到前端。
  • 任务至少可重试而不重复产出;学习事件不重复计数。
  • 共享 API/NLP/状态契约在批准后的主题 Wiki 中建立唯一来源,字段和版本尚未定案;不能以本页概要直接生成冻结接口。

管理端选型落地边界

管理端采用用户指定的 D:/github_project/goadmin,前后端固定 commit 见项目档案。后端现有入口为源仓库 main.go,业务分布于 app/cmd/common;管理前端入口为 go-admin-ui/src/main.js,页面位于 src/views,路由/API 封装位于 src/router、src/api。这些是外部参考仓库路径,尚未复制或运行于 LexGo。

当前 #2 已固定首批 LexGo 目录和身份衔接契约;通用管理能力可复用,用户与语言的数据所有权检查由业务层实现。既有分析中 gin-vue-admin 的推荐已被本次用户决策替代。

学习端与管理端的目标架构

以下是目标结构;账号、管理端与学习空空间已由 #2 实现。英语 NLP 已完成 #3 独立验证,尚未接入业务 API。

flowchart TD
    L[学习端 Vue 3 / TypeScript / Vite] --> API[共享 Go API:基于 go-admin 扩展]
    A[管理端 go-admin-ui] --> API
    API --> B[独立学习业务模块]
    B --> DB[(MySQL 8)]
    B --> W[后台任务:具体选型待验证]
    W --> N[NLP:Python 小样已验证,生产集成待实施]
交付部分 建设方式 复用与自建边界
学习端 create-vue 新建独立 SPA 工程 复用 Vue Router、Pinia、Element Plus;定制书库、阅读器、划词与复习组件
管理端 指定本地 go-admin-ui 二次开发 沿用现有 Vue CLI/Vuex 工程,按 LexGo 需要适配管理页面
共享后端 指定 go-admin 后端扩展为模块化单体 复用适配后的身份、权限与管理能力;新增 library、vocabulary、review、progress 等业务模块

两个前端分别构建,共用账号体系、API 与数据库。角色权限和数据所有权分别校验,隐藏路由不能替代服务端授权。共享业务规则由后端维护;两端不各实现一套词汇或复习状态规则。

部署方向为同域名 /app 学习端、/admin 管理端、共享 API,首期不拆业务微服务。这些前缀是部署建议,具体网关、静态资源 base、路由回退和登录方式在接口/部署设计中验证后固定。学习端独立工程不要求单独部署后端。

先按浏览器响应式界面设计电脑与手机使用;PWA 保留为后续候选,完整离线同步不在本次技术确认中。最终页面和 MVP 范围将在后续需求讨论中确定。

go-admin 模块参考与原型边界(2026-09-10)

以下来自已记录固定版本的本地源码检查,尚未执行配套构建。

LexGo 内容 参考来源 复用边界与设计证据
账号、角色、菜单和 API 权限 后端 app/admin 的 sys_user/sys_role 路由、API、service/DTO/model;前端 src/views/admin/sys-user、sys-role 两端用户名+密码登录;账号页面沿用表格/弹窗,字段说明即可;学习接口另加强制用户归属
语言资源列表、配置 SysConfig、SysDict 与管理前端对应列表/表单 复用表单模式;SysDict 是系统枚举,不能作为自然语言词典;简单配置不另画全套原型
文本处理状态 app/jobs 的 SysJob 状态操作可参考 原版是 cron 任务管理,不能作为可靠文本队列的证据;新任务需明确持久状态、恢复和幂等
学习领域 API 与表 router/apis/service/dto/models、版本迁移机制 沿用结构,独立设计词条身份、归属、原文位置与复习事务;后端无需 UI 原型
阅读、查词、短语、复习 已验收 Quant-UX v1 与运行划词小样 使用定制学习组件,主要交互改变时补关键原型;真实触摸/连续选择必须运行验证

源码检查发现 common/actions/permission.go 允许关闭 EnableDP,且 DataScope 默认分支不添加过滤;该机制用于管理数据范围,不能单独保证私人学习数据隔离。学习接口必须强制按认证用户归属查询,不允许客户端指定用户替代认证身份。

仅当管理流程引入多步安装、处理中/失败重试等明显交互不确定性时,补关键状态原型。通常的列表、编辑、删除确认沿用 go-admin 表格、表单和弹窗,记录字段、权限、异常和验收即可。

已实现工程入口(#2)

路径 职责
server/cmd/lexgo/main.go 配置连接、migrate/bootstrap/serve 命令;默认只监听 127.0.0.1:8000
server/app/admin/models、server/common/models 按 upstream.json 原样选用的 go-admin 模型和约定
server/app/lexgo/database.go MySQL 8 显式版本迁移、所有权标记、启动检查
server/app/lexgo/service.go 用户名校验、账号创建、bcrypt、随机会话摘要和撤销
server/app/lexgo/router.go JSON 请求、角色和归属授权、API 路由和安全错误响应
server/app/lexgo/*_test.go 真实 MySQL 隔离、撤销、并发创建、迁移拒绝/恢复、bootstrap 验证
admin/src/views、admin/src/session.mjs 基于 go-admin-ui 的账号页面与管理端会话
learner/src/views、learner/src/stores/session.ts 独立学习端登录与私人空空间、会话及迟到响应隔离
scripts/server.py 安全读取本机 .env.local,传入子进程;不输出秘密

账号 API v1

请求使用 JSON;受保护接口使用 Authorization: Bearer。成功结构 {code:200,data:...},创建账号 HTTP 201;失败使用实际 HTTP 400/401/403/404/409/429/500 和 {code:状态码,msg:必要提示}。响应 Cache-Control:no-store。

方法与路径 行为和权限
POST /api/v1/login username/password,返回 token、expiresAt、user;不要求邮箱
GET /api/v1/me 当前登录者 id/username/role/disabled
POST /api/v1/logout 撤销当前会话
GET /api/v1/space 当前用户英语空间;拒绝查询参数指定用户
GET /api/v1/spaces/:id 仅本人的空间可读,他人编号返回 404
GET /api/v1/accounts 管理员查看账号列表;只输出必要字段
POST /api/v1/accounts 管理员创建 learner;拒绝客户端 role 等未知字段
PATCH /api/v1/accounts/:id 管理员启停或重置 learner 密码;不能修改自己或其他管理员
GET /healthz 检查数据库连接,仅返回健康状态

schema v1:sys_user 保留选用 go-admin 模型字段,唯一小写用户名;lexgo_spaces 以 owner_id 为主键;lexgo_sessions 保存 token_hash/owner_id/expires_at;lexgo_schema 记录版本与产品所有权。服务启动不自动迁移。迁移仅接受空库或合法已有 LexGo marker,拒绝空 marker、其他产品、负版本与未来版本;版本 0 可重试部分迁移,版本 1 幂等。

凭据字段只存在本地环境和必要数据库哈希中。后台账号密码更新使用表/字段更新,避免上游 BeforeUpdate hook 对已有哈希再次加密。账号行再会话行的锁顺序用于串行化撤销与请求;API 在事务提交后才返回成功。

登录与操作审计(#18)

server/app/lexgo/audit.go 定义两类白名单字段日志、筛选分页、失败记录和过期清理;router.go 在登录/账号操作边界接入。登录成功时会话与日志同一事务;账号操作成功时业务写入与日志同一事务;失败时先回滚业务,再以独立、有 3 秒超时的事务写失败记录。日志写入失败返回通用 500,不输出数据库原始错误或凭据。

database.go 显式迁移至 v2,两张新增表均以 created_at/id 建立排序清理索引,账号字段建查询索引,无业务表级联删除。cmd/lexgo/main.go 的服务进程在启动和每小时执行审计清理,每次最多运行一分钟、每批删除 1000 条,仅影响过期审计记录。

admin/src/views/AuditLogs.vue 通过 kind 复用登录/操作列表;audit-logs.mjs 负责筛选编码和请求序号,session.mjs 继续进行管理员及会话 generation 校验。切换页面/账号清空日志,普通翻页保留总数,防止分页组件跳回第一页。菜单与标题按当前路由显示。

英语分词与本地词典验证(#3,已验收)

spikes/english/ 是独立可运行验证小样,不是学习端生产功能。推荐后续采用 Python 3.12.12、spaCy 3.8.7、en_core_web_sm 3.8.0(保留 tok2vec/tagger/attribute_ruler/lemmatizer,停用 parser/ner)和 NLTK 3.9.2 读取 WordNet 3.0。Go 继续管理用户、权限、任务和持久数据,后续通过显式契约调用 NLP;本单未新增 Go API、MySQL 表或常驻部署实例。

文件 作用
engine.py 原文分词、lemma、三个位置单位、直接/lemma 查词;只读本地资源
app.py、index.html、app.mjs、view.mjs、style.css loopback 临时 HTTP 小样、输入/阅读/查词结果;单进程串行,输入不落盘
resources.json、setup_resources.py、requirements.lock 固定版本、来源和 SHA256;显式联网准备,运行期无自动下载
test_engine.py、test_app.py、view.test.mjs 真实模型离线验证、HTTP 边界与浏览器偏移/迟到响应测试
benchmark.py、benchmark-result.json 虚构语料的候选对照、长文/查询实测及环境样本

WordNet 使用 ZIP 内原始 index/data/exception 文件,不使用 SysDict 或新增业务库。NLTK 默认 synsets 会隐式词形还原,本小样直接读取其固定版本索引以区分 exact 和显式 lemma;禁用依赖全局 corpus 的 OMW 跨版本映射,只接受 WordNet 3.0。升级 NLTK 或词典时必须重跑契约测试。

候选比较:正则分词+WordNet 默认名词 morphology 依赖少、速度快,但不具备上下文判断,缩写和词性歧义处理弱;纯 Go 规则同样需要自行维护这些语言规则。本次 spaCy 在 12 个明确样例中答对 11 个,基线 6 个,因此推荐保留独立 Python NLP 边界。样例量不足以证明总体准确率;不宣称部署或正式阅读功能已完成。

#4 阅读选择小样与 LinguaCafe 对照

小样位于 spikes/selection:serve.py 只提供白名单静态文件,app.mjs 负责 DOM 原生选择/键盘/面板状态,range.mjs 负责原文范围和匹配,fixtures.mjs 提供两章虚构文本。保存仅在内存 Map,刷新清空,不接入账号、MySQL、正式词典、复习或 #21 附件。使用 Intl.Segmenter 的词与字形边界验证 UI,不能替代 #3 的 spaCy 结果;正式阅读器必须以章节原文、内容版本与 NLP tokens 为共同基准。

参考版本固定 LinguaCafe c1ea298ce40c65b9dd33e9b26fd2e52fae66f2c8。本次实际读取以下源码;未启动 LinguaCafe、未亲测其浏览器或手机行为。下表“参考行为”是源码证据;LexGo 方案是独立实现与取舍,不应标为上游已经验证的体验。

项目 参考行为与来源 LexGo 采用及保留差异
阅读器 TextBlockGroup.vue L56–100 用词/短语 stage、selected、hover 等状态;InteractiveTextStyling.scss L20–126 由主题决定颜色。TextReaderChapterList.vue L15–43 展示章节统计及已处理章节阅读入口;TextReader.vue L195–214 提供阅读完成后的书库/下一章 保留正文上下文、词语状态与选中区分、章节切换;小样以两章和学习中/已认识/忽略验证,章节统计及已读持久化仍属后续功能。侧栏/底部面板为 LexGo 布局,不宣称复制上游排版
查词面板 VocabularyBox.vue L124–169、374–404 区分 Translation 与字典搜索,新短语需 Save phrase;TextBlockGroup.vue L1351–1380 失选时自动保存单词/已有短语。VocabularySearchBox.vue L149–179 及保存 catch 未证明完整错误反馈 沿用 v1 的词典释义/我的释义、明确保存、关闭继续阅读。LexGo 关闭不自动保存;保存后可见反馈,无词典/无结果仍可手填。小样用固定虚构释义与故障状态,不把它当成真实词典/网络重试验证
短语与键盘 TextBlockGroup.vue L399–599 自定义鼠标范围;L374–457 为手机 500ms 长按及后续触摸移动,选区开始后阻止默认滚动;L1071–1202 的 Shift+方向键跳高亮词,而非扩展范围,Esc 失选 LexGo 采用原生鼠标拖选、手机长按/系统手柄,不拦截 touchmove;起止端点按钮可调整。←/→ 相邻词,Shift+←/→ 扩缩范围,Esc 取消。原型的预设短语按钮被真实正文选区替代,属于 #4 明确要求的验证;手机手柄是否与底部面板冲突仍待真机
复习 Review.vue L270–345、559–692 为 Reveal→I was correct/Again,正确移除卡、Again 保留并随机抽剩余卡,最后一张正确完成;ReviewHotkeyInformationDialog.vue L14–24 提供快捷键。练习模式不写状态 已验收 v1 保留中文显答、答对/答错、再学与完成,单词/短语分别有状态。上游随机下一卡、阶段降级和快捷键不是本单已实现行为;#8 再明确调度、重学和幂等,#4 不新增复习引擎

源码链接:

上游 LICENSE 为 GPL v3,本单只核对并描述行为,没有移植源码。#1 评论 7497 的四项对照登记由本节补充;原型 v1 的保存/关闭主要流程保持,不更改其历史验收记录,不声称已经在 Quant-UX 新建修订版。范围调整与原生手柄作为可运行小样验证,若真机结果导致主要流程变化,应先更新关键原型状态并由用户确认。

粘贴导入、章节与阅读(#5,schema v3)

#5 实现了目标路径一的第一段可运行链路:粘贴英语文本 → 持久导入任务 → 固定分章 → 处理中/就绪 → 本人阅读原文。Go 单进程同时承担 API 与后台任务。本单不接入 Python NLP,token、lemma 与词典索引仍待 #6,路线未决边界见业务规则页。

路径 职责
server/app/lexgo/database.go schema v3 显式迁移:lexgo_books、lexgo_chapters、lexgo_ingest_jobs;按版本累加语句,版本行只在全部语句成功后推进
server/app/lexgo/library.go 粘贴校验与固定分章、书籍/章节/任务写入、本人归属查询、重试与请求幂等
server/app/lexgo/ingest.go 任务声明 claim、处理完成、固定失败原因、启动恢复
server/app/lexgo/router.go 新增书籍/章节/任务路由;粘贴请求使用独立的 4 MiB 体积上限
server/cmd/lexgo/main.go serve 启动时恢复遗留任务,并按秒轮询处理待处理任务
learner/src/stores/library.ts、views/ImportView.vue、BookView.vue、ReaderView.vue 粘贴导入、书库与章节状态、失败重试、原文阅读

任务状态为 pending → processing → ready/failed,章节与任务共用同一套状态词。声明与完成分属两个事务:声明一经提交,即使进程随即退出,也只会留下可被启动恢复重新入队的 processing 记录。

粘贴导入 API v1(#5)

方法与路径 行为和权限
POST /api/v1/books {requestId,title,text,language?};创建书籍+首个章节+导入任务;重复 requestId 返回首次结果(HTTP 200,duplicate=true)
POST /api/v1/books/:id/chapters 向本人书籍追加一个章节
GET /api/v1/books 本人书库与章节状态计数;拒绝查询参数,避免用参数替换认证身份
GET /api/v1/books/:id 本人书籍与章节列表,含 jobId 与可读失败原因
GET /api/v1/chapters/:id 本人章节详情;仅 ready 时返回 originalText,并附带前后章节编号
GET /api/v1/jobs/:id 本人任务状态、尝试次数与失败原因
POST /api/v1/jobs/:id/retry 仅失败任务可重试;复用同一章节,不新建章节

所有接口按认证身份过滤 owner_id;他人书籍、章节或任务编号统一返回 404,管理员角色也不能解除学习数据的本人归属过滤。后台任务只使用任务行内的 owner_id,不接受客户端用户编号;请求体含未知字段(例如 ownerId)直接返回 400。

schema v3

lexgo_books(owner_id, title, language)、lexgo_chapters(book_id, owner_id, ordinal, title, original_text MEDIUMTEXT, char_count, content_sha256, status, error_reason) 与 lexgo_ingest_jobs(owner_id, book_id, chapter_id, request_key, content_sha256, status, attempts, error_reason, finished_at)。owner_id 在章节与任务上冗余存放,使任何查询都能直接按认证身份过滤而不依赖连接;UNIQUE(book_id, ordinal) 与 UNIQUE(owner_id, request_key) 分别阻止重复章节与重复提交。启动检查要求版本 3,服务不自动迁移。

并发重复提交:请求命中 request_key 唯一键冲突后,用加锁读读取已提交结果,因为该请求事务的快照早于并发提交;因此两个并发相同提交只会产生一个章节,另一个得到 duplicate=true 的首次结果。

#5 审核整改(R1~R4,2026-09-11)

提交见工单 #5 的整改评论;本条记录实现与验证方式。

  • 追加契约(R1):学习端把新建与追加拆成两个请求类型,追加不发送 language;后端保持严格解码,并新增回归测试断言“追加带 language 返回 400、不带则 201”,学习端单测断言追加请求体只有 requestId/title/text。
  • 运行期任务恢复(R2):server/app/lexgo/ingest.go 的恢复逻辑合并为一处——启动恢复使用阈值 0,运行期每轮清扫使用 15 秒阈值并把超过 5 次尝试的任务置为 failed(原因码 attempts_exhausted);cmd/lexgo/main.go 的 worker 每秒先清扫再处理,日志分别说明“已重新入队”与“本批未完成、等待下一次清扫”,不再声称已完成实际跳过的重试。
  • 离页作废在途请求(R3):closeBook/closeChapter 推进请求序号并清理 loading;ImportView 记录是否已卸载,卸载后的成功响应不再触发跳转。
  • 重试自愈(R4):retryChapter 先把重试返回的章节状态应用到列表与阅读器并重新安排轮询,再做静默刷新。

验证:Go 全量用例 20 项通过(新增运行期恢复与尝试上限两项);学习端单测 38 项通过,其中 7 项在整改前的代码上复现失败;真实联调确认追加路径可用、被中断的任务在运行中被自动恢复(约 0.5 秒,无需重启)、重试在首次刷新失败后仍自动显示最终结果。

全 Go 正式架构决定(2026-09-11,#6)

用户已明确选择全 Go:正式英语分词、原文位置映射、本地词典解析和词形候选查询由 Go 后端完成,不运行 Python NLP 服务。此前“Python 建议/全 Go 未决”仅为历史决策记录,由本决定覆盖;spikes/english 保留历史验证,不接入产品。#6 按该方向实施,当前方案见工单最新启动评论;WordNet 3.0 仍为首个资源(英语释义),词形规则候选不等同于 spaCy 上下文消歧,原文及个人学习状态不按候选合并。

#6 全 Go 词典与阅读器(2026-09-11,已验收并合入 main)

server/app/lexgo/wordnet.go 负责固定 WordNet ZIP 校验/内存解析、Unicode 分词及词形候选;dictionary.go 负责资源与章节查词 API;database.go schema v4 新增单槽共享资源表 lexgo_dictionaries(元数据、enabled、SHA、ZIP LONGBLOB),已有学习数据不改写。每个 Router 按 SHA 缓存一个不可变词典,查询先读资源元数据,缓存未命中才读取 ZIP;进程重启从数据库恢复,不需要 Python NLP 或额外资源目录。

接口 权限与输入/输出
GET /api/v1/dictionaries 管理员;items + supported,状态 ready/disabled/unavailable,不返回 archive 或本机路径
POST /api/v1/dictionaries/import 管理员;multipart name/language/version/source/format/file;返回 resource + duplicate;格式/来源/版本固定
PATCH /api/v1/dictionaries/:id 管理员;{enabled:boolean};返回 resource
GET /api/v1/chapters/:id/tokens 本人 ready 章节;{textSha256,tokens:[{text,start,end,startUtf16,endUtf16,kind}]}
POST /api/v1/lookup {chapterId,start,end},cp半开范围;本人ready完整单词;返回 status/query/matchedForm/candidates/entries/resource

管理端 Dictionaries.vue + dictionaries.mjs + session multipart 方法,复用 go-admin 导航/表单与身份失效保护。学习端 useReaderLookup.ts 校验原文片段/SHA/所有位置,ReaderTokens.vue 渲染可聚焦单词,LookupPanel.vue 展示释义及临时个人草稿。桌面侧栏,手机固定底部45dvh面板;关闭恢复焦点,仅无后续手动滚动时恢复自动调整前位置。旧响应在换词/换章/退出/离页后失效。

参考:WordNet 数据格式、词形规则。#3 仅历史实验,#6 不调用其实验服务。

#7 个人词条与阅读器状态(2026-09-11,已验收并合入 main)

schema v5 新增 lexgo_terms:一个学习者对一个词形一条记录。身份键为 (owner_id, language, term),term 是 Go 侧 normalizeWord 的结果(NFC、小写、弯撇号转直撇号),列使用 utf8mb4_bin,避免折叠 resume/résumé;original_form 保存最近一次保存的原词形供显示。definition/examples 是学习者自己的文本,例句按行存储;共享词典仍只在 lexgo_dictionaries,两者不混存。status 与 level 由数据库检查约束守住:只有 learning 允许 1~7,其他状态必须为 0。

接口 权限与输入/输出
POST /api/v1/terms 本人;{chapterId,start,end,definition,examples[],status,level?};服务端按 #6 同一套 token 范围反推词形,owner/language/term 一律不接受客户端输入;唯一键 upsert,重复保存更新同一行;首次 201、更新 200,返回 {term,created}
GET /api/v1/terms/:id 本人;他人编号与不存在编号统一 404,不泄露存在性
GET /api/v1/chapters/:id/tokens 在原响应上为 word 片段增加可选 term:{id,status,level};服务端按本章词形分批(每批 500)查本人词条,其他章节保存的同形词同样命中

server/app/lexgo/terms.go 负责身份、状态/等级边界、文本上限、upsert 与章节点词状态;database.go 提供 v5;dictionary.go 的 tokens 与 lookup 共用 wordAtRange,保存与查询必须落在同一个完整单词范围上,所以客户端无法命名自己没有读到的词。个人词条不写审计日志。

学习端 useReaderLookup.ts 在原有查询状态上增加个人释义、例句、状态、已保存编号、预填与保存;打开已保存词先读 GET /terms/:id,读取失败时禁用保存,避免用空表单覆盖原内容。LookupPanel.vue 提供状态单选、释义与例句输入、保存与简短反馈;ReaderTokens.vue 按状态高亮 is-new/is-learning/is-known/is-ignored。换词、换章、离页、退出或切换账号都会清空表单、状态与高亮。管理端无改动。

#8 复习调度与答题(2026-09-11,已验收并合入 main)

schema v6 新增 lexgo_term_reviews(每个个人词条一行排期:due_at、review_count、correct_count、wrong_count、last_reviewed_at)与 lexgo_review_answers(每次作答一条:answer_key、评分、状态/等级/间隔前后值、result、requeued、时间)。两条语句都是 CREATE TABLE IF NOT EXISTS 加 INSERT IGNORE ... SELECT,所以迁移是可重试的加法迁移;旧二进制回到 v5 仍可继续写 lexgo_terms,不需要改动个人词条表本身。既有已保存词汇在迁移中按 due_at = created_at 进入队列。

server/app/lexgo/review.go 负责间隔表、队列、评分转换、幂等与并发;terms.go 的保存路径通过 syncTermReview 维护排期行(新词 立即到期,显式 学习中 level N 排 now + 间隔[N]),计数在状态或等级变化时保留。

接口 权限与输入/输出
GET /api/v1/reviews/queue 本人+当前语言;仅 新词/学习中 且 due_at ≤ now,按 due_at, id 排序、最多 50 条;返回 {items, total},total 是全部到期数;不接受查询参数
POST /api/v1/reviews/:termId/answers {answerId, grade: correct|wrong|again, expectedDueAt};应用成功 201,重放或过期 200;返回首次结果 result(applied/stale) 与 duplicate 标记、前后状态/等级/到期时间、requeued 与词条新状态;加锁后再次读取答案键,所以并发的同键提交也返回记录而不是报错

学习端新增 /review 路由与书库、阅读器顶栏的「到期复习」入口;stores/review.ts 维护队列、本轮计数、评分与重学,ReviewCard.vue/ReviewView.vue 呈现正面(词+挖空例句)、答案面(个人释义+例句+三个评分按钮)、完成页与空队列页。一次评分对应一个 answerId,失败重试复用同一个;换词后重新生成。切换账号或退出登录会清空队列、计数与当前卡片。

参考:LinguaCafe Review.vue。上游的随机抽卡、阶段降级与快捷键不属于本单;#8 只实现本项目的固定间隔、固定顺序与幂等作答。

#9 TXT 上传导入(2026-09-11,已验收并合入 main)

server/app/lexgo/upload.go 负责把上传的 TXT 解码后交给与粘贴相同的核心:解码、multipart 解析与两条路由,schema 无变化(沿用 #5 的 lexgo_books/lexgo_chapters/lexgo_ingest_jobs)。文件只在内存中存在,不写临时文件,客户端文件名不参与任何路径也不入库。

接口 权限与输入/输出
POST /api/v1/books/upload 本人;multipart:requestId、title、language(可省略,省略即英语)、file;新建书籍与首章
POST /api/v1/books/:id/chapters/upload 本人且本人书籍;multipart:requestId、title、file;追加一章;不接受 language

字段白名单之外的字段、重复字段、缺失 file、非 multipart 请求都返回 400;201 新建、200 重复、409 同编号换内容、404 他人书籍、401 未登录、429 已有文件正在上传(单槽并发门)。响应体与粘贴路径同为 PasteResult,所以学习端复用同一套跳转与轮询逻辑。

解码规则见业务规则页;实现上 decodeTextUpload 先按 UTF-16 BOM 识别并给出针对性提示,再剥离可选 UTF-8 BOM,然后用 utf8.Valid 整体校验,最后交给 validatePaste(非空、≤100000 码点)。因此上传与粘贴共享同一分章与任务规则:一次提交一章,requestId + 内容 SHA 幂等,worker 只发布已落库的原文。

学习端 ImportView.vue 增加「粘贴文本 / TXT 文件」来源切换(沿用已验收 v1 的切换与状态行),stores/library.ts 增加 upload() 与 fileProblem/fileSizeLabel,session.request 支持 FormData(multipart 请求不再被 JSON 化,边界由浏览器提供)。客户端预检只提前反馈,服务端结论为最终结论。

#10 编辑与删除书籍章节(2026-09-11,已验收并合入 main)

server/app/lexgo/edit.go 提供改名、编辑与删除;ingest.go 增加版本门控。schema 无变化:chapters.content_sha256 与 jobs.content_sha256 就是版本键,新增的 superseded 复用现有 error_reason 列。

接口 权限与输入/输出
PATCH /api/v1/books/:id 本人;{title};返回 {book}
GET /api/v1/chapters/:id/source 本人任意状态;返回 {source:{id,bookId,ordinal,title,text,status,contentSha256,charCount}},供编辑使用;不接受查询参数
PATCH /api/v1/chapters/:id 本人;{title?,text?};返回 {chapter,job,versionChanged};正文变化才新建任务
DELETE /api/v1/books/:id 本人;返回 {deleted:{bookId,chapters}};FC 级联删除章节与任务
DELETE /api/v1/chapters/:id 本人;返回 {deleted:{chapterId,bookId,remaining}};删除后重排序号

版本门控(本单修掉的缺陷):任务只在 job.content_sha256 == chapter.content_sha256 时才能影响章节。认领任务时用 JOIN 只取版本匹配的行,并先把过期版本任务一次性标为 failed/superseded;发布前再比对一次,不匹配就只把任务标为 superseded 并完全不触碰章节;恢复扫描同样先作废过期版本任务、只重排版本匹配的中断任务;重试接口拒绝版本不匹配的任务(409)。删除期间在途任务找不到章节时视为无事可做(级联已删除其任务行)。

unprocessableReason 保留一条内容一致性检查:存储的正文重新计算出的 SHA 必须等于该章节存储的 SHA,用于兜住绕过 API 的直接写入(content_changed),与版本门控互不重复。

学习端 BookView.vue 增加书名编辑对话框、章节编辑对话框(标题 + 正文,正文来自 source 接口)与两处确认弹窗(ElMessageBox),章节行增加「编辑」入口;LibraryView.vue 在书库被删后显示「书籍已删除 · 已保存的生词和短语仍保留在生词本。」;stores/library.ts 增加 renameBook、updateChapter、loadChapterSource、deleteBook、deleteChapter。正文编辑通过浏览器 textarea 输入,因此该章的行尾统一为 LF(粘贴与 TXT 导入仍保留原始 CRLF)。

#11 短语选择、保存与复习(2026-09-11,已验收并合入 main)

短语与单词共用一张表和一套复习机制:lexgo_terms 的 term 列存身份键,单词键不含空格、短语键以空格分隔,所以「词或短语」不需要额外列,也不需要第二套排期/队列/作答逻辑。kind 与词数由身份键在服务端派生(termKind/termWordCount),视图与队列项随响应返回。

server/app/lexgo/phrase.go:

部分 职责
phraseWords 从本人 ready 章节的 token 里取完全落在选区内的词;切进单词的范围直接 400,不静默丢弃;2~12 个词
phraseKey / phraseSource 身份键=按顺序的规范化词形以单个空格连接;显示片段=选区原文(内部标点与换行保留)
phraseMatches 跨章节匹配:按首词分组后顺序比对词形,候选按 (起点, 长度降序, id) 排序并取最左最长的互不重叠集合
phrasesForChapter 按 term LIKE '% %' 取本人短语并匹配,供 tokens 响应使用
SavePhrase 由服务端推导身份后走与单词相同的 saveTerm upsert;kind 冲突返回 409
接口 说明
POST /api/v1/phrases {chapterId,start,end,definition,examples[],status,level?};本人 ready 章节;服务端推导词序列与身份,不接受客户端身份;首次 201、重复 200
GET /api/v1/terms/:id 复用;响应增加 kind 与 wordCount
GET /api/v1/chapters/:id/tokens 响应增加 phrases:[{id,status,wordCount,startToken,endToken}]
GET /api/v1/reviews/queue 队列项增加 kind 与 wordCount;短语与单词同一队列、同一作答接口

学习端:composables/readerRange.ts 是纯函数层(整词对齐、内部保留、端点按词调整、命中优先级、区间换算),composables/useTextSelection.ts 监听 selectionchange(100ms 去抖)与 document 的 pointerup 读取浏览器原生选区并映射为 token 索引,不拦截 touchmove、不 preventDefault;ReaderTokens.vue 为每个 token 输出 data-token-index 与短语区间样式;ReaderView.vue 负责把选区变成短语、shift 点击扩展、以及面板端点调整;LookupPanel.vue 增加短语标题与四个端点按钮;复习卡用 maskedPrompt 把整段短语挖成一个空。已保存短语点击优先打开短语面板,单词数据不受影响。

#12 词汇库:搜索、筛选与编辑(2026-09-11,已验收并合入 main)

server/app/lexgo/vocabulary.go 提供只读列表与按 id 编辑,不改 schema(沿用 #11 的「身份键派生 kind/词数」)。

接口 权限与输入/输出
GET /api/v1/terms 本人+当前语言;query、status、kind、page、limit;返回 {items,total,page,limit};默认 page=1、limit=20,上限 100;排序 updated_at DESC, id DESC
PATCH /api/v1/terms/:id 本人;{definition?,examples?,status?,level?};返回 {term};身份字段不接受输入

参数校验沿用审计列表的既有约定:查询键白名单、重复键与未知键 400、page ≥ 1、limit ≤ 100、枚举值非法 400。搜索同时匹配规范化身份键、显示原文与个人释义;因为 term 使用 utf8mb4_bin,查询会先转小写再比较;%、_、\ 经 escapeLike 转义后作为字面值,避免 % 命中全部。kind 通过键是否含空格判断(term NOT LIKE '% %' / term LIKE '% %')。

编辑复用同一套领域规则:termContent 校验释义与例句上限、termLevel 校验状态与等级边界(只有 learning 带 1~7 级),只有状态或等级变化才调用 syncTermReview 重排(reschedule=false 时只补建缺失的排期行),因此只改文本不动排期、历史作答记录与计数保留。

学习端 stores/vocabulary.ts 保存列表状态与筛选(打开/关闭编辑对话框、从阅读器返回都不会丢),views/VocabView.vue 提供搜索框与显式搜索按钮、状态与类型筛选、分页、空态与「没有匹配的词条 + 清除搜索与筛选」,并把筛选与页码同步到 /vocab?...(router.replace)。释义/例句/状态/等级四个字段抽成 components/TermFormFields.vue,与阅读器面板共用;等级选择器只在词汇库编辑对话框中出现(阅读器面板仍只有四个状态)。

#13 章节完成与个人基础进度(2026-09-15,已验收并合入 main)

schema v7 新增 lexgo_chapter_progress:一章一行(chapter_id 主键),带 owner_id、book_id、language、read_sha256(标记时该章 content_sha256 的快照)、read_at 与时间戳,外键级联到章节、书籍与账号。仍然只加新表,不用 ALTER TABLE ADD COLUMN,保持每个版本 DDL 可重放。

接口 权限与输入/输出
POST /api/v1/chapters/:id/complete 本人;只有 ready 章节可标记,否则 409;重复调用返回同一行并带 duplicate;返回 {progress:{chapterId,bookId,read,readAt,duplicate}}
GET /api/v1/progress 本人当前语言;返回 {readChapters,totalChapters,knownTerms,learningTerms,newTerms,ignoredTerms,savedTerms,dueNow,books[]}

server/app/lexgo/progress.go 集中实现:CompleteChapter(事务内锁定章节,缺行则插入,内容相同则原样返回,内容不同则把同一行推进到新版本)、readAtByChapter(批量取章节的已读时间,只认可「标记快照=当前内容」且章节 ready)、LearnerProgressFor(阅读分母与分子都只算 ready 章节,词条按四种状态分组,dueNow 复用 dueTermsQuery)。review.go 抽出 dueTermsQuery,到期复习队列和进度页共用同一谓词与同一服务端时钟,避免两处漂移。ChapterSummary 增加 readAt 字段,BookDetail、ChapterDetail 与章节编辑响应都会解析它,所以列表、阅读器和编辑后的状态一致。

学习端新增 /progress 页面与导航「进度」(stores/progress.ts、views/ProgressView.vue):已读章节、待复习、已知词、学习中、新词、忽略六张卡片,加每本书的已读进度与进入书籍的链接、已保存词条总数、刷新与失败重试、无书时的引导。阅读器正文下方新增显式的「标记本章已读」区块(ReaderView.vue,data-testid="mark-read"),显示已读时间与重复标记提示;书籍页章节列表对已读章节显示「已读」标记(BookView.vue)。stores/library.ts 增加 markChapterRead,只把 readAt 写回章节列表与打开中的阅读器,不动其他字段。

#14 桌面与手机体验、主题与键盘(2026-09-15,已验收并合入 main)

没有 schema 变化、没有新接口:本单只改学习端与样式。事实先行:用带触摸的 390×844 移动视口逐个打开登录/书库/导入/书籍/阅读/查词面板/生词本/复习/进度,scrollWidth 全部等于 innerWidth,没有溢出元素——前几单的响应式基础成立,本单把它固化成自动化移动测试,并补上缺失的偏好与键盘操作。

新增文件 职责
learner/src/stores/preferences.ts 主题(浅色/深色/跟随系统)与正文字号(标准/大/特大);按账号存在 lexgo-learner-display:<账号 id>
learner/src/components/DisplaySettings.vue 站点头部统一的「显示」下拉,展示当前值并可键盘操作
learner/src/composables/readingPosition.ts 按账号+章节保存滚动比例与该章 content_sha256
learner/src/composables/reviewShortcuts.ts 复习页键盘映射与「不抢输入框」的判断

style.css 收敛为一套语义调色板::root 里 53 个变量是文件内仅有的颜色字面量,其余规则全部走 var(...);html[data-theme='dark'] 覆盖同一批变量,html.dark 同时映射 Element Plus 的暗色变量(main.ts 引入 element-plus/theme-chalk/dark/css-vars.css),所以页面与组件跟随同一个选择。字号经 --reader-font-scale 只作用于阅读面(章节正文、释义面板内容、复习卡的词与释义),不做全局缩放。ReaderView.vue 在章节就绪后把正文下方的位置恢复到上次比例(正文版本变化则不恢复,等待可滚动后再应用,最多约 1 秒后放弃),并在滚动时按 400ms 防抖保存。ReviewView.vue 加了 useReviewShortcuts 与一行快捷键提示;DisplaySettings 出现在七个学习页面的头部。

移动测试项目: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,已验收并合入 main)

没有 schema 变化、没有接口变化:本单新增运维工具、部署文档与完整演练证据。

新增文件 职责
server/cmd/lexgo(追加子命令) backup、restore、verify:与既有 migrate/bootstrap/serve/audit-cleanup 同一入口,部署机只需要二进制与 MySQL 客户端;备份仍调用 mysqldump,dump 与 manifest 格式与工具通道完全一致,可互相读取
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 客户端版本不得低于服务端」这条实测规则)、首次部署、配置与凭据来源、日常运维、健康检查、升级与回滚、备份与恢复、已知限制。

两条通道: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。