- 短语与单词共用 lexgo_terms:身份键为按序规范化词形以空格连接,单词键不含空格, 因此 kind 与词数由身份键派生,不需要新列或第二套复习逻辑 - POST /api/v1/phrases 由服务端从本人 ready 章节推导词序列与身份,切进单词的范围 400; 章节 tokens 增加 phrases 区间,队列项增加 kind/wordCount - 跨章节匹配按连续词形比对,重叠取最左最长;短语高亮覆盖内部单词但不修改单词数据 - 学习端新增 readerRange 纯函数层与 useTextSelection(原生拖选 + 手机手柄,不拦截 touchmove),面板提供短语标题与按词调整端点的按钮,复习卡把整段短语挖成一个空 - Wiki 记录 Architecture、Business-Rules、Local-Development 与需求更新
36 KiB
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: cc1522fceb8e420cee17f509101f61986b3d7ff9 synchronized_at: 2026-09-14T14:21:32Z
架构与代码地图
项目定位
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)
短语与单词共用一张表和一套复习机制: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 把整段短语挖成一个空。已保存短语点击优先打开短语面板,单词数据不受影响。