Table of Contents
- 架构与代码地图
- 项目定位
- 代码地图
- 两条主要执行路径
- 不可破坏的边界
- 管理端选型落地边界
- 学习端与管理端的目标架构
- go-admin 模块参考与原型边界(2026-09-10)
- 已实现工程入口(#2)
- 登录与操作审计(#18)
- 英语分词与本地词典验证(#3,已验收)
- #4 阅读选择小样与 LinguaCafe 对照
- 粘贴导入、章节与阅读(#5,schema v3)
- #5 审核整改(R1~R4,2026-09-11)
- 全 Go 正式架构决定(2026-09-11,#6)
- #6 全 Go 词典与阅读器(2026-09-11,已验收并合入 main)
- #7 个人词条与阅读器状态(2026-09-11,已验收并合入 main)
- #8 复习调度与答题(2026-09-11,已验收并合入 main)
- #9 TXT 上传导入(2026-09-11,已验收并合入 main)
- #10 编辑与删除书籍章节(2026-09-11,已验收并合入 main)
- #11 短语选择、保存与复习(2026-09-11,已验收并合入 main)
- #12 词汇库:搜索、筛选与编辑(2026-09-11,已验收并合入 main)
- #13 章节完成与个人基础进度(2026-09-15,已验收并合入 main)
- #14 桌面与手机体验、主题与键盘(2026-09-15,已验收并合入 main)
- #15 自托管交付:部署拓扑与运维工具(2026-09-15,已验收并合入 main)
- #21 书籍音频与封面附件(2026-09-15,已验收并合入 main)
- #37 章节级音频与章节插图(2026-09-15)
- #37 章节级音频与章节插图(2026-09-15)
- #37 章节级音频与章节插图(2026-09-15)
- #37 章节级音频与章节插图(2026-09-15)
- #37 章节级音频与章节插图(2026-09-15)
- #37 章节级音频与章节插图(2026-09-15,已验收并合入 main)
- #32 编辑任务按内容版本复用(2026-09-16,已修复并合入 main)
- 英汉词典与规范音标(#40,schema v12)
- 登录会话有效期(#42)
架构与代码地图
项目定位
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 不新增复习引擎 |
源码链接:
- resources/js/components/Text/TextBlockGroup.vue
- resources/sass/Text/InteractiveTextStyling.scss
- resources/js/components/TextReader/TextReaderChapterList.vue
- resources/js/components/TextReader/TextReader.vue
- resources/js/components/Text/VocabularyBox.vue
- resources/js/components/Text/VocabularySearchBox.vue
- resources/js/components/TextReader/TextReaderHotkeyInformationDialog.vue
- resources/js/components/Review/Review.vue
- resources/js/components/Review/ReviewHotkeyInformationDialog.vue
上游 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记录三类音标计数与丢弃计数。
音标来源分层(不做语义猜测)
- 词在 CMUdict 中 → 按固定 ARPAbet→IPA 映射转写(
1→ˈ、2→ˌ、0不标;AH0→ə、IY0→i、ER0→ɚ;单音节不标重音,cat是/kæt/而不是/kˈæt/)。 - 否则用 ECDICT 记法做字符级规范化:
'→ˈ、,/.→ˌ、:→ː、ә(U+04D9)→ə(U+0259)、ε→e、空格/-/=/;/^删除;来源标记为ecdict。 - 音标以
^开头说明原记法已丢失首音(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。