feat: 完成到期单词复习与幂等答题 (#8)

- schema v6 新增 lexgo_term_reviews 与 lexgo_review_answers:语句都是可重试的加法迁移,
  既有已保存词汇按 created_at 立即进入队列;旧二进制回到 v5 仍可写入个人词条
- 固定间隔表 1/2/4/7/15/30/60 天:答对升级封顶 7、答错降级最低 1、再学一次不改等级,
  答错与再学立即回到本轮;已知与忽略不入队
- GET /api/v1/reviews/queue 与 POST /api/v1/reviews/:termId/answers:按 answerId 去重、
  按 expectedDueAt 判定过期标签页,重复提交与并发都不重复更新次数和间隔
- 学习端新增 /review 路由与到期复习入口,正面挖空例句、答案面评分、完成页与空队列页
- 同步 Architecture-and-Code-Map、Business-Rules-and-Glossary、
  Local-Development-and-Verification、Product-Requirements-Overview 与 Home
This commit is contained in:
ila
2026-09-11 20:54:17 +08:00
parent 527d8af0c4
commit 0328505b77
20 changed files with 1656 additions and 18 deletions
+17 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Architecture-and-Code-Map.-
wiki_revision: 02b7fc12b222c0050652443ca1368730f35f5652
synchronized_at: 2026-09-11T12:28:55Z
wiki_revision: 0d6fb43a2662e263fc6f2fb5426b9a8fafaa4868
synchronized_at: 2026-09-11T12:54:05Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -257,3 +257,18 @@ schema v5 新增 lexgo_terms:一个学习者对一个词形一条记录。身
`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)
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/duplicate/stale)、前后状态/等级/到期时间、`requeued` 与词条新状态 |
学习端新增 `/review` 路由与书库、阅读器顶栏的「到期复习」入口;`stores/review.ts` 维护队列、本轮计数、评分与重学,`ReviewCard.vue`/`ReviewView.vue` 呈现正面(词+挖空例句)、答案面(个人释义+例句+三个评分按钮)、完成页与空队列页。一次评分对应一个 `answerId`,失败重试复用同一个;换词后重新生成。切换账号或退出登录会清空队列、计数与当前卡片。
参考:[LinguaCafe Review.vue](https://github.com/simjanos-dev/LinguaCafe/blob/c1ea298ce40c65b9dd33e9b26fd2e52fae66f2c8/resources/js/components/Review/Review.vue)。上游的随机抽卡、阶段降级与快捷键不属于本单;#8 只实现本项目的固定间隔、固定顺序与幂等作答。
+22 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Business-Rules-and-Glossary.-
wiki_revision: 71389f97df86e17699f4af930022251fa354631b
synchronized_at: 2026-09-11T08:31:32Z
wiki_revision: e59184a923910cee9283771154349397e7063fbd
synchronized_at: 2026-09-11T12:54:06Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -172,3 +172,23 @@ exact优先;未命中再按WordNet异常表/词尾规则查候选,词性顺
保存幂等由唯一键承担:重复提交同一词形只更新同一行,不产生第二条冲突记录;同一账号多端同时编辑为最后写入生效,本版不引入版本冲突拒绝。所有读写都属于会话本人:章节必须本人且已就绪,篡改 id、owner、language、term 或携带未知字段返回 400,他人编号与本人不可见编号统一 404。跨账号不共享任何数据与前端缓存,退出或切换账号后不保留前一账号的词条与高亮。
例句只保存学习者手输内容,不自动关联原文句子:当前分词只有词/空白/标点边界,没有句子切分规则。原型 v1 中「原文例句已关联到词条」是演示文案,不作为契约。等级选择器属于 #8/#12 的编辑界面,阅读器面板只提供四个状态。
## #8 复习规则(2026-09-11)
**间隔表**:答对后按新等级排期,等级上限 7。这是固定表,不是 FSRS,也不是上游「按等级选复习量少的日期」的算法。
| 等级 | 1 | 2 | 3 | 4 | 5 | 6 | 7(上限) |
|---|---|---|---|---|---|---|---|
| 下次复习 | 1 天 | 2 天 | 4 天 | 7 天 | 15 天 | 30 天 | 60 天 |
**入队范围**:只有状态 `新词` 或 `学习中` 且 `due_at ≤ now` 的词条进入队列;`已知` 与 `忽略` 不入队。新保存的词立即到期(`due_at` = 保存时刻),保存后就能复习;手动把词条改为「学习中 level N」时下次复习为 `now + 间隔[N]`,避免刚标等级就被当成到期。已在队列中的词条被改成 `已知`/`忽略` 后再次作答会被拒绝(409),排队信息由服务端裁决而不是前端过滤。
**三个评分动作**:`correct` 认识/答对 → 等级 +1(封顶 7)、按新等级排期、离开本轮;`wrong` 不认识/答错 → `学习中` 降一级(最低 1)、`新词` 保持 `新词`、`due_at = now` 立即回到本轮;`again` 再学一次 → 等级与状态不变、`due_at = now` 立即回到本轮。计数上 `correct_count` 只统计 `correct`,`wrong_count` 统计 `wrong` 与 `again`,`review_count` 统计全部已应用作答。词条的原文、个人释义与例句不因复习改变。
**时区与到期边界**:`due_at` 以 UTC 绝对时刻存储,到期判定是 `due_at ≤ now`,不引入本地日边界。理由:MVP 没有用户时区设置(属 X 系列边界),绝对时刻在多账号自托管下语义一致、没有夏令时陷阱;代价是复习时刻会随首次作答时间漂移,例如 22:00 答对的 1 天间隔词条要到次日 22:00 才到期。原型界面的「到期复习 N / M」指当前到期队列的位置与本轮卡片数,不是自然日统计。
**幂等与并发**:客户端每次作答生成一个 `answerId`,服务端以 `UNIQUE(owner_id, answer_id)` 去重。同一个 `answerId` 再次提交返回首次结果(`result=duplicate`)且不改变等级、间隔和次数;同一词条在别处已经被推进(提交回传的 `expectedDueAt` 与服务端当前 `due_at` 不一致)时记为 `result=stale`,同样不改变任何状态。因此网络重发、双击、双标签页作答都只记账一次。每次尝试都会落一条 `lexgo_review_answers`(含 `result`),这是「只记账一次」的证据,也供 #13 统计使用。
**归属与错误**:词条归属由服务端按会话裁决,`owner` 不接受客户端输入;他人词条与不存在的词条统一 404,未登录 401,未知评分/缺少 `expectedDueAt`/未知字段 400,词条已变成 `已知`/`忽略` 409。复习接口不写审计日志:答题属于私人学习内容。
**范围边界**:本单只做单词复习。短语复习归 #11,到期范围筛选与策略配置界面(X11)不做,练习模式(X08)不做,进度与「已知词/待复习」计数归 #13。#8 不改变 #7 定下的身份口径:复习状态按规范化词形归属,仍不按 WordNet lemma 候选合并。
+24 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Local-Development-and-Verification.-
wiki_revision: 919545b0d716180a49ac7dd2fe96bbeccdba0880
synchronized_at: 2026-09-11T08:31:32Z
wiki_revision: e01464ad570bbd037e813985d78b137462bca241
synchronized_at: 2026-09-11T12:54:06Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -340,3 +340,25 @@ node --test spikes/english/view.test.mjs
真实链路验证:真实 Go API+真实 MySQL 共 30 项检查通过(凭据只从本机安全配置读入进程),覆盖两个虚构测试账号 issue5_a/issue5_b 的登录、保存、幂等、状态边界、跨章节一致、跨账号隔离与越权拒绝;随后用临时 Playwright 用例在真实学习端+真实 API 上以两个账号复核保存、重新加载后的高亮与预填,以及 390×844 窄屏底部面板。截图保存在本机 `.local/evidence/`(issue7-reader-desktop.png、issue7-reader-mobile-390.png、issue7-account-b.png),临时用例运行后删除。
未验证:真实手机触屏详细证据与完整备份恢复演练仍属既有缺口(#14/#15);本单只用桌面浏览器窄屏检查,不当作真机结果。
## #8 验证与迁移(2026-09-11)
仓库根执行;Go 工具链由 `python scripts/server.py` 固定 go1.26.5。本单使用专用测试库 lexgo_test_issue8,不借用其他测试库。
| 命令 | 结果 |
|---|---|
| `go vet ./...` | 通过 |
| `LEXGO_TEST_DB_NAME=lexgo_test_issue8 python scripts/server.py test-integration` | 41 个顶层用例全部通过、0 跳过;含 #8 新增 6 个复习用例与 1 个 v5→v6 迁移用例 |
| `cd learner`:`npx vitest --run` / `npx vue-tsc --build` / `npx pnpm run build` / `npx playwright test` | 71 项单测、类型检查、构建、5 项 E2E 全部通过 |
| `cd admin`:`npx pnpm test` / `npx pnpm lint` | 31 项与 lint 通过;管理端本单无代码改动 |
| `python -m unittest discover -s tests` / `python dev_scripts/harness.py check --strict` | 56 项与严格检查通过 |
覆盖内容:间隔表与每个状态转换(含等级上下限)、到期边界(`due_at = now` 到期、早 1 毫秒不到期)、入队范围(已知/忽略不入队)、两账号队列互不可见、重复提交只记一次、同一词条双标签页 second 写入为 stale、作答归属 404、已知词条 409、未知评分/缺少到期时间/未知字段 400、显式等级的排期规则、幂等键唯一约束、v5→v6 保留既有词条与计数。
本机开发库 lexgo_dev 已显式从 v5 升级到 v6:升级前后 sys_user 6、lexgo_spaces 6、lexgo_sessions 5、lexgo_books 5、lexgo_chapters 10、lexgo_ingest_jobs 10、lexgo_dictionaries 1、lexgo_terms 4 全部不变;新增 `lexgo_term_reviews` 4 行(全部 `due_at = created_at`)与空的 `lexgo_review_answers`。旧二进制备份在忽略的 `.local/lexgo-pre-issue8.exe`。回退:停止 lexgo-api,恢复旧二进制,把 `lexgo_schema` 标记改回 5 后启动;新表不影响旧二进制写入个人词条,保留新表与全部既有数据,不删除数据、不重新 bootstrap。
真实链路验证:真实 Go API+真实 MySQL 共 38 项检查通过(凭据只从本机安全配置读入进程,脚本可重复运行),覆盖到期即时入队、答对升级与 1 天排期、答错立即回队、重放与 stale 不重复推进、越权与非法输入拒绝、两账号互不影响、已知词条拒绝作答;随后用临时 Playwright 用例在真实学习端+真实 API 上完成「登录→粘贴导入→点词查义→保存个人释义→到期复习作答→回到阅读器确认状态」的完整闭环,并复核 390×844 窄屏无横向溢出。截图保存在本机 `.local/evidence/`(issue8-reader-saved.png、issue8-review-revealed.png、issue8-review-summary.png、issue8-reader-reviewed.png、issue8-review-mobile-390.png),临时用例运行后删除。
运维记录:本次只有 lexgo-api(新二进制)与 lexgo-learner 被重启;学习端常驻 Vite 在长时间运行后一度对 `/src/style.css` 返回空样式表,导致 E2E 观察到 `white-space: normal`,重启后恢复,未改动代码或配置。其余实例保持 Running。
未验证:真实手机触屏详细证据与完整备份恢复演练仍属既有缺口(#14/#15);本单只用桌面浏览器窄屏检查,不当作真机结果。并发只覆盖到「双标签页同一词条」这一层,没有做多用户压测。
+8 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Product-Requirements-Overview
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Product-Requirements-Overview.-
wiki_revision: c9dcc7a0e9739fe17c148751662acceaed41982f
synchronized_at: 2026-09-11T12:28:55Z
wiki_revision: faafbd4c7ec98dfead97a5f1d8ead8ac7ffc9853
synchronized_at: 2026-09-11T12:54:06Z
<!-- gitea-wiki-mirror:end -->
# 产品需求总览
@@ -245,3 +245,9 @@ Anki(U20)、YouTube/Jellyfin 远程字幕(U07/A08)、全量旧实例迁
F07 的个人词语记录已于 2026-09-11 通过用户验收:阅读器可以保存与修改个人释义、例句和状态(新词/学习中/已知/忽略),同一词形在本人其他章节显示相同状态与高亮,两个账号的数据互不影响。个人释义与共享 WordNet 词典分开存储,个人释义不进入审计日志。schema 升级为 v5,新增 lexgo_terms。
仍未实现并留给后续工单:词汇库搜索与编辑(#12)、短语(#11)、到期复习与等级推进(#8)、阅读完成与进度(#13)、TXT 导入(#9)、书籍章节编辑删除(#10)。词形候选仍不提供上下文消歧,个人学习状态不按候选合并;例句不自动关联原文句子。
## #8 交付范围更新(2026-09-11)
F10 的单词到期复习已实现,待用户验收:按固定间隔表取本人当前语言的到期词条,正面显示词与挖空例句,显示答案后按「认识/答对」「不认识/答错」「再学一次」评分;答对升级并排下次复习,答错降级并立即回到本轮,再学一次不改等级并回到本轮。重复提交、网络重发与双标签页都不会重复更新次数和间隔;已知与忽略的词条不入队。管理端无改动。
仍未实现并留给后续工单:短语复习(#11)、词汇库搜索与编辑(#12)、阅读完成与进度统计(#13)、TXT 导入(#9)、书籍章节编辑删除(#10)。复习范围筛选与策略配置(X11)、练习模式(X08)、FSRS 仍在范围外。
+4 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Home
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Home
wiki_revision: d355012d0f773ab5becb8c966cecfac71e39fabe
synchronized_at: 2026-09-11T12:28:53Z
wiki_revision: e44e179cbad8e28133bc2b97d1b6f57b0bb77063
synchronized_at: 2026-09-11T12:54:05Z
<!-- gitea-wiki-mirror:end -->
# LexGo 文档入口
@@ -72,3 +72,5 @@ Quant-UX 原型 v1 已通过用户验收。[桌面预览](https://qux.ilapage.cn
#5已验收,#17/#19/#20/#22/#23按依赖顺序合入main。#6已按用户确认的全Go方向实现英语词典与阅读点词,2026-09-11通过用户验收,PR #25已合入main;原文/账户隔离保留。正式NLP不使用Python服务。管理端“英语词典”导入指定WordNet3.0,学习端打开本人章节即可查词。个人释义与学习状态已实现持久化(#7,待用户验收)。详见#6工单、架构和本地开发页面。
#7 个人词条已实现并于 2026-09-11 通过用户验收(schema v5 新增 lexgo_terms):阅读器可保存释义、例句与状态,同一词形在本人其他章节显示一致高亮,两个账号数据独立;保存幂等,跨账号与篡改身份均被拒绝;PR #26 已 fast-forward-only 合入 main。
#8 单词到期复习已实现,待用户验收(schema v6 新增 lexgo_term_reviews 与 lexgo_review_answers):固定间隔表(1/2/4/7/15/30/60 天)、答对升级、答错或再学立即回队、已知与忽略不入队;重复提交、网络重发与双标签页都只记账一次,到期判定用 UTC 绝对时刻而不引入本地日边界。学习端新增「到期复习」入口,卡片正面显示词与挖空例句、答案面显示个人释义,并有完成页与空队列页。
+120
View File
@@ -0,0 +1,120 @@
import { expect, test } from '@playwright/test'
// The review round against a mocked API: queue, reveal, grades, relearn and the summary.
test('review the due words, requeue a missed one and finish the round', async ({ page }) => {
const user = { id: 42, username: 'fictional-reviewer', role: 'learner' }
const first = {
id: 7, term: 'curiosity', originalForm: 'curiosity', definition: '好奇心;求知欲',
examples: ['Learning begins with curiosity.'], status: 'new', level: 0,
dueAt: '2026-09-11T10:00:00Z', reviewCount: 0,
}
const second = {
id: 8, term: 'step', originalForm: 'step', definition: '一步',
examples: ['Take a small step, every day.'], status: 'learning', level: 2,
dueAt: '2026-09-11T10:00:00Z', reviewCount: 4,
}
// The wrong answer puts its word back in the same round, exactly as the server does.
const submitted: { grade: string; answerId: string }[] = []
let requeued = false
await page.route('**/api/v1/**', async route => {
const path = new URL(route.request().url()).pathname
const method = route.request().method()
let data: unknown = null
let status = 200
if (path === '/api/v1/login') data = { token: 'fictional-session', user }
else if (path === '/api/v1/me') data = user
else if (path === '/api/v1/space') data = { ownerId: user.id, language: 'en' }
else if (path === '/api/v1/books') data = { items: [] }
else if (path === '/api/v1/reviews/queue') {
data = { items: requeued ? [second] : [first, second], total: requeued ? 1 : 2 }
} else if (path.endsWith('/answers') && method === 'POST') {
const body = route.request().postDataJSON() as { answerId: string; grade: string; expectedDueAt: string }
submitted.push({ grade: body.grade, answerId: body.answerId })
expect(body.answerId).toMatch(/^[0-9a-f-]{36}$/)
const term = path.includes('8') ? second : first
// The client answers the card it was shown, so it echoes that card's due time.
expect(body.expectedDueAt).toBe(term.dueAt)
const wrong = body.grade === 'wrong'
requeued = wrong
const dueAtAfter = wrong ? '2026-09-11T10:05:00Z' : '2026-09-12T10:00:00Z'
term.dueAt = dueAtAfter
status = 201
data = {
result: 'applied', grade: body.grade, requeued: wrong,
statusBefore: term.status, statusAfter: wrong ? term.status : 'learning',
levelBefore: term.level, levelAfter: wrong ? 1 : term.level + 1,
dueAtBefore: body.expectedDueAt, dueAtAfter,
item: { ...term, dueAt: dueAtAfter, reviewCount: term.reviewCount + 1 },
}
}
await route.fulfill({ status, json: { code: 200, data } })
})
await page.goto('/')
await page.getByLabel('账号').fill(user.username)
await page.getByLabel('密码', { exact: true }).fill('fictional-password')
await page.getByRole('button', { name: '登录', exact: true }).click()
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
// The library links into the due queue.
await page.getByRole('link', { name: '到期复习' }).click()
await expect(page).toHaveURL(/\/review$/)
await expect(page.getByTestId('review-position')).toHaveText('到期复习 · 1 / 2')
await expect(page.getByRole('heading', { name: 'curiosity' })).toBeVisible()
// The answer stays hidden, and the example shows a blank instead of the word.
await expect(page.getByText('Learning begins with _____.')).toBeVisible()
await expect(page.getByTestId('review-definition')).toHaveCount(0)
await page.getByTestId('review-reveal').click()
await expect(page.getByTestId('review-definition')).toHaveText('好奇心;求知欲')
await expect(page.getByTestId('review-correct')).toBeFocused()
await page.getByTestId('review-correct').click()
// The second word is learning level 2 and can be sent back into the round.
await expect(page.getByTestId('review-position')).toHaveText('到期复习 · 2 / 2')
await expect(page.getByText('学习中 · 等级 2')).toBeVisible()
await page.getByTestId('review-reveal').click()
await expect(page.getByTestId('review-definition')).toHaveText('一步')
await page.getByTestId('review-wrong').click()
// Requeued: the same word asks again and the round grows instead of pretending it ended.
await expect(page.getByTestId('review-position')).toHaveText('到期复习 · 3 / 3')
await expect(page.getByTestId('review-definition')).toHaveCount(0)
await page.getByTestId('review-reveal').click()
await page.getByTestId('review-correct').click()
await expect(page.getByTestId('review-summary')).toContainText('复习了 2 个词条 · 共 3 次作答')
await expect(page.getByTestId('review-summary')).toContainText('答对 2 · 答错或再学 1')
expect(submitted.map(entry => entry.grade)).toEqual(['correct', 'wrong', 'correct'])
expect(new Set(submitted.map(entry => entry.answerId)).size).toBe(3)
await page.getByTestId('review-finish').click()
await expect(page).toHaveURL(/\/$/)
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
})
test('an empty due queue says so instead of showing a card', async ({ page }) => {
const user = { id: 42, username: 'fictional-reviewer', role: 'learner' }
await page.route('**/api/v1/**', async route => {
const path = new URL(route.request().url()).pathname
let data: unknown = null
if (path === '/api/v1/login') data = { token: 'fictional-session', user }
else if (path === '/api/v1/me') data = user
else if (path === '/api/v1/space') data = { ownerId: user.id, language: 'en' }
else if (path === '/api/v1/books') data = { items: [] }
else if (path === '/api/v1/reviews/queue') data = { items: [], total: 0 }
await route.fulfill({ json: { code: 200, data } })
})
await page.goto('/')
await page.getByLabel('账号').fill(user.username)
await page.getByLabel('密码', { exact: true }).fill('fictional-password')
await page.getByRole('button', { name: '登录', exact: true }).click()
await page.goto('/review')
await expect(page.getByTestId('review-empty')).toContainText('今天没有到期词条')
await expect(page.getByTestId('review-card')).toHaveCount(0)
// The narrow layout keeps the grading controls reachable without horizontal overflow.
await page.setViewportSize({ width: 390, height: 844 })
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(overflow).toBeLessThanOrEqual(0)
})
+212
View File
@@ -0,0 +1,212 @@
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createMemoryHistory, createRouter } from 'vue-router'
import ReviewView from '../views/ReviewView.vue'
import { useSessionStore } from '../stores/session'
import { clozeSentence, useReviewStore, type ReviewItem } from '../stores/review'
const item = (overrides: Partial<ReviewItem> = {}): ReviewItem => ({
id: 7, term: 'dogs', originalForm: 'Dogs', definition: '狗', examples: ['Dogs went home.'],
status: 'new', level: 0, dueAt: '2026-09-11T10:00:00Z', reviewCount: 0, ...overrides,
})
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
const answer = (overrides: Record<string, unknown> = {}) => ({
result: 'applied', grade: 'correct', requeued: false, statusBefore: 'new', statusAfter: 'learning',
levelBefore: 0, levelAfter: 1, dueAtBefore: '2026-09-11T10:00:00Z', dueAtAfter: '2026-09-12T10:00:00Z',
item: item({ status: 'learning', level: 1, dueAt: '2026-09-12T10:00:00Z', reviewCount: 1 }), ...overrides,
})
const answerCalls = (fetchMock: MockInstance) => fetchMock.mock.calls.filter(call => String(call[0]).includes('/answers'))
let wrapper: VueWrapper | undefined
describe('review prompt', () => {
it('masks the word in the first example and keeps other sentences usable', () => {
expect(clozeSentence(item())).toBe('_____ went home.')
expect(clozeSentence(item({ term: "isn't", originalForm: "Isn't", examples: ["It isn’t over."] }))).toBe('It _____ over.')
expect(clozeSentence(item({ examples: ['No matching word here.'] }))).toBe('No matching word here.')
expect(clozeSentence(item({ examples: [] }))).toBeNull()
expect(clozeSentence(item({ term: 'dog', originalForm: 'Dog', examples: ['Dogs are not the saved word.'] }))).toBe('Dogs are not the saved word.')
})
})
describe('review store', () => {
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear(); useSessionStore().user = { id: 42, username: 'fictional', role: 'learner' } })
afterEach(() => { vi.restoreAllMocks() })
it('loads the due queue and answers a word correctly', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
if (String(input).endsWith('/reviews/queue')) return ok({ items: [item()], total: 1 })
return ok(answer())
})
const review = useReviewStore()
await review.load()
expect(review.queue).toHaveLength(1)
expect(review.current?.id).toBe(7)
expect(review.empty).toBe(false)
review.reveal()
expect(review.revealed).toBe(true)
await review.answer('correct')
const body = JSON.parse(String(answerCalls(fetchMock)[0]![1]?.body))
expect(body).toEqual({ answerId: expect.stringMatching(/^[0-9a-f-]{36}$/), grade: 'correct', expectedDueAt: '2026-09-11T10:00:00Z' })
expect(review.queue).toHaveLength(0)
expect(review.answered).toBe(1)
expect(review.correctCount).toBe(1)
expect(review.finished).toBe(true)
expect(review.revealed).toBe(false)
})
it('requeues a word that is not recognised and counts it as wrong', async () => {
const wrong = answer({ grade: 'wrong', requeued: true, levelAfter: 0, statusAfter: 'new', dueAtAfter: '2026-09-11T10:05:00Z', item: item({ dueAt: '2026-09-11T10:05:00Z' }) })
vi.spyOn(globalThis, 'fetch').mockImplementation(async input => String(input).endsWith('/reviews/queue') ? ok({ items: [item()], total: 1 }) : ok(wrong))
const review = useReviewStore()
await review.load()
await review.answer('wrong')
expect(review.queue).toHaveLength(1)
expect(review.queue[0]!.dueAt).toBe('2026-09-11T10:05:00Z')
expect(review.wrongCount).toBe(1)
expect(review.correctCount).toBe(0)
expect(review.wordsReviewed).toBe(1)
expect(review.finished).toBe(false)
})
it('treats a stale or replayed answer as the same action, never as a second review', async () => {
vi.spyOn(globalThis, 'fetch').mockImplementation(async input => String(input).endsWith('/reviews/queue') ? ok({ items: [item()], total: 1 }) : ok(answer({ result: 'stale', requeued: false })))
const review = useReviewStore()
await review.load()
await review.answer('correct')
expect(review.queue).toHaveLength(0)
expect(review.answered).toBe(0)
expect(review.correctCount).toBe(0)
expect(review.empty).toBe(true)
})
it('keeps the card and the same answer id when a submission fails, then retries once', async () => {
let fail = true
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async input => {
if (String(input).endsWith('/reviews/queue')) return ok({ items: [item()], total: 1 })
if (fail) { fail = false; throw new Error('评分暂时无法提交') }
return ok(answer())
})
const review = useReviewStore()
await review.load()
await review.answer('correct')
expect(review.error).toContain('评分暂时无法提交')
expect(review.queue).toHaveLength(1)
expect(review.answered).toBe(0)
await review.answer('correct')
const calls = answerCalls(fetchMock)
const ids = calls.map(call => JSON.parse(String(call[1]?.body)).answerId)
expect(ids).toHaveLength(2)
expect(ids[0]).toBe(ids[1])
expect(review.queue).toHaveLength(0)
expect(review.answered).toBe(1)
})
it('reports a failed queue load without pretending the queue is empty', async () => {
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('网络暂不可用'))
const review = useReviewStore()
await review.load()
expect(review.error).toContain('网络暂不可用')
expect(review.finished).toBe(false)
expect(review.empty).toBe(false)
})
it('drops the previous account queue and counters when the identity changes', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(ok({ items: [item()], total: 1 }))
const review = useReviewStore()
await review.load()
expect(review.queue).toHaveLength(1)
useSessionStore().user = { id: 43, username: 'other-fictional', role: 'learner' }
await flushPromises()
expect(review.queue).toHaveLength(0)
expect(review.current).toBeNull()
expect(review.finished).toBe(false)
expect(review.empty).toBe(false)
})
it('reports the words still due beyond the fetched page', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(ok({ items: [item()], total: 3 }))
const review = useReviewStore()
await review.load()
expect(review.pending).toBe(2)
})
})
async function open(queueResult: unknown, onAnswer: (body: Record<string, unknown>) => Response | Promise<Response> = () => ok(answer())) {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
if (String(input).endsWith('/reviews/queue')) {
if (queueResult instanceof Error) throw queueResult
return ok(queueResult)
}
return onAnswer(JSON.parse(String(init?.body)))
})
useSessionStore().user = { id: 42, username: 'fictional', role: 'learner' }
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }, { path: '/review', component: ReviewView }] })
await router.push('/review')
wrapper = mount(ReviewView, { attachTo: document.body, global: { plugins: [router] } })
await flushPromises()
return { view: wrapper, router, fetchMock }
}
describe('review page', () => {
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear() })
afterEach(() => { wrapper?.unmount(); wrapper = undefined; vi.restoreAllMocks() })
it('shows the masked example, hides the definition until the answer is revealed, then grades it', async () => {
const { view, fetchMock } = await open({ items: [item()], total: 2 })
expect(view.get('[data-testid="review-position"]').text()).toBe('到期复习 · 1 / 1')
expect(view.text()).toContain('Dogs')
expect(view.text()).toContain('_____ went home.')
expect(view.find('[data-testid="review-definition"]').exists()).toBe(false)
expect(document.activeElement).toBe(view.get('[data-testid="review-reveal"]').element)
await view.get('[data-testid="review-reveal"]').trigger('click'); await flushPromises()
expect(view.get('[data-testid="review-definition"]').text()).toContain('狗')
expect(document.activeElement).toBe(view.get('[data-testid="review-correct"]').element)
await view.get('[data-testid="review-correct"]').trigger('click'); await flushPromises()
expect(answerCalls(fetchMock)).toHaveLength(1)
expect(view.get('[data-testid="review-summary"]').text()).toContain('1 个词条')
})
it('keeps the card and offers a retry when a grade fails', async () => {
const { view } = await open({ items: [item()], total: 1 }, () => { throw new Error('评分暂时无法提交,请重试。') })
await view.get('[data-testid="review-reveal"]').trigger('click'); await flushPromises()
await view.get('[data-testid="review-wrong"]').trigger('click'); await flushPromises()
expect(view.find('[data-testid="review-card"]').exists()).toBe(true)
expect(view.text()).toContain('评分暂时无法提交')
expect(view.find('[data-testid="review-retry"]').exists()).toBe(true)
expect(view.find('[data-testid="review-summary"]').exists()).toBe(false)
})
it('reports an empty queue and a load failure without pretending anything was reviewed', async () => {
const emptyRun = await open({ items: [], total: 0 })
expect(emptyRun.view.get('[data-testid="review-empty"]').text()).toContain('今天没有到期词条')
await emptyRun.view.get('[data-testid="review-finish"]').trigger('click'); await flushPromises()
expect(emptyRun.router.currentRoute.value.path).toBe('/')
wrapper?.unmount(); wrapper = undefined
// A failed load is not an empty queue: it shows the error and a retry.
const failing = await open(new Error('复习队列暂时无法加载'))
expect(failing.view.get('[data-testid="review-reload"]').text()).toContain('重试')
expect(failing.view.text()).toContain('复习队列暂时无法加载')
expect(failing.view.find('[data-testid="review-empty"]').exists()).toBe(false)
vi.restoreAllMocks()
vi.spyOn(globalThis, 'fetch').mockResolvedValue(ok({ items: [item()], total: 1 }))
await failing.view.get('[data-testid="review-reload"]').trigger('click'); await flushPromises()
expect(failing.view.get('[data-testid="review-position"]').text()).toBe('到期复习 · 1 / 1')
})
it('offers the remaining due words after a round finishes', async () => {
const { view } = await open({ items: [item()], total: 3 })
await view.get('[data-testid="review-reveal"]').trigger('click'); await flushPromises()
await view.get('[data-testid="review-correct"]').trigger('click'); await flushPromises()
expect(view.get('[data-testid="review-more"]').text()).toContain('继续复习')
expect(view.text()).toContain('还有 2 个词条到期')
})
it('ends the round without submitting anything', async () => {
const { view, router, fetchMock } = await open({ items: [item()], total: 1 })
await view.get('[data-testid="review-end"]').trigger('click'); await flushPromises()
expect(router.currentRoute.value.path).toBe('/')
expect(answerCalls(fetchMock)).toHaveLength(0)
})
})
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import { ElButton } from 'element-plus'
import { clozeSentence, type ReviewItem } from '../stores/review'
const props = defineProps<{ item: ReviewItem; position: number; total: number; revealed: boolean; busy: boolean; error: string }>()
const emit = defineEmits<{ reveal: []; grade: [grade: 'correct' | 'wrong' | 'again']; end: []; retry: [] }>()
// The word and its masked example are enough to answer; the definition stays hidden.
const prompt = () => clozeSentence(props.item)
const revealButton = ref<{ $el?: HTMLElement } | null>(null)
const correctButton = ref<{ $el?: HTMLElement } | null>(null)
// Keyboard users should reach the next action without tabbing through the page again.
watch(() => props.item.id, async () => {
await nextTick()
if (!props.revealed) revealButton.value?.$el?.focus()
}, { immediate: true })
watch(() => props.revealed, async value => {
if (!value) return
await nextTick()
correctButton.value?.$el?.focus()
})
</script>
<template>
<section class="review-card" aria-labelledby="review-word" data-testid="review-card">
<p class="subtle" data-testid="review-position">到期复习 · {{ position }} / {{ total }}</p>
<h2 id="review-word" tabindex="-1" class="review-word">{{ item.originalForm }}</h2>
<p v-if="prompt()" class="review-example" lang="en">{{ prompt() }}</p>
<p class="subtle">{{ item.status === 'learning' ? `学习中 · 等级 ${item.level}` : '新词 · 尚未复习' }}</p>
<p v-if="error" role="alert" class="notice">{{ error }}</p>
<template v-if="!revealed">
<ElButton ref="revealButton" type="primary" data-testid="review-reveal" :disabled="busy || !!error" @click="emit('reveal')">显示答案</ElButton>
</template>
<template v-else>
<div class="review-answer">
<p class="review-definition" data-testid="review-definition">{{ item.definition || '(未填写个人释义)' }}</p>
<blockquote v-for="(example, index) in item.examples" :key="index" lang="en">{{ example }}</blockquote>
</div>
<div class="review-grades">
<ElButton ref="correctButton" type="primary" data-testid="review-correct" :loading="busy" @click="emit('grade', 'correct')">认识 / 答对</ElButton>
<ElButton data-testid="review-wrong" :disabled="busy" @click="emit('grade', 'wrong')">不认识 / 答错</ElButton>
<ElButton data-testid="review-again" :disabled="busy" @click="emit('grade', 'again')">再学一次</ElButton>
</div>
</template>
<p v-if="error" class="review-actions"><ElButton data-testid="review-retry" @click="emit('retry')">重试提交</ElButton></p>
<p class="review-actions"><ElButton text data-testid="review-end" @click="emit('end')">结束本次复习</ElButton></p>
</section>
</template>
+1
View File
@@ -6,6 +6,7 @@ const router = createRouter({
routes: [
{ path: '/login', name: 'login', component: () => import('../views/LoginView.vue') },
{ path: '/', name: 'library', meta: { private: true }, component: () => import('../views/LibraryView.vue') },
{ path: '/review', name: 'review', meta: { private: true }, component: () => import('../views/ReviewView.vue') },
{ path: '/import', name: 'import', meta: { private: true }, component: () => import('../views/ImportView.vue') },
{ path: '/books/:id', name: 'book', meta: { private: true }, component: () => import('../views/BookView.vue') },
{ path: '/chapters/:id', name: 'chapter', meta: { private: true }, component: () => import('../views/ReaderView.vue') },
+212
View File
@@ -0,0 +1,212 @@
import { computed, ref, watch } from 'vue'
import { defineStore } from 'pinia'
import { useSessionStore } from './session'
export type ReviewGrade = 'correct' | 'wrong' | 'again'
export type ReviewResult = 'applied' | 'duplicate' | 'stale'
export interface ReviewItem {
id: number
term: string
originalForm: string
definition: string
examples: string[]
status: 'new' | 'learning'
level: number
dueAt: string
reviewCount: number
}
export interface ReviewAnswerResult {
result: ReviewResult
grade: ReviewGrade
requeued: boolean
statusBefore: string
statusAfter: string
levelBefore: number
levelAfter: number
dueAtBefore: string
dueAtAfter: string
item: ReviewItem
}
interface QueueResponse { items: ReviewItem[]; total: number }
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/**
* The prompt shown before the answer: the first personal example with the word masked.
* A word without examples is shown on its own; the dictionary is never consulted here.
*/
export function clozeSentence(item: ReviewItem): string | null {
const line = item.examples[0]
if (!line) return null
const forms = [...new Set([item.term, item.originalForm].filter(Boolean))]
let masked = line
for (const form of forms) {
// Whole word, any case, and either apostrophe form, so the mask matches how the
// learner typed the sentence.
const pattern = new RegExp(`(?<![\\p{L}\\p{M}])${escapeRegExp(form).replace(/'/g, "['’]")}(?![\\p{L}\\p{M}])`, 'giu')
masked = masked.replace(pattern, '_____')
}
return masked
}
export const useReviewStore = defineStore('review', () => {
const session = useSessionStore()
const queue = ref<ReviewItem[]>([])
const loading = ref(false)
const error = ref('')
const busy = ref(false)
const revealed = ref(false)
const answered = ref(0)
const correctCount = ref(0)
const wrongCount = ref(0)
// Distinct words in this round: a requeued word is answered again but is one word.
const wordsReviewed = ref(0)
const seen = new Set<number>()
// Words due beyond the fetched page, reported by the server for this round.
const pending = ref(0)
let started = ref(false)
// One answer id per card and grade: a retry of the same action keeps its key, so the
// server can answer it from the first outcome instead of counting twice.
let attempt: { itemId: number; answerId: string } | null = null
let sequence = 0
let generation = 0
const current = computed<ReviewItem | null>(() => queue.value[0] ?? null)
const finished = computed(() => started.value && !loading.value && queue.value.length === 0 && answered.value > 0)
const empty = computed(() => started.value && !loading.value && queue.value.length === 0 && answered.value === 0)
watch(() => session.user?.id ?? null, (next, previous) => {
if (next !== previous) reset()
}, { flush: 'sync' })
function ownerId(): number | null {
return session.user?.id ?? null
}
function isStale(version: number, owner: number | null): boolean {
return version !== generation || ownerId() !== owner
}
function reset(): void {
generation++
sequence++
queue.value = []
loading.value = false
error.value = ''
busy.value = false
revealed.value = false
answered.value = 0
correctCount.value = 0
wrongCount.value = 0
wordsReviewed.value = 0
pending.value = 0
started.value = false
attempt = null
seen.clear()
}
async function load(newRound = true): Promise<void> {
const version = generation
const owner = ownerId()
const seq = ++sequence
loading.value = true
error.value = ''
revealed.value = false
attempt = null
try {
const result = await session.request<QueueResponse>('reviews/queue')
if (seq !== sequence || isStale(version, owner)) return
const items = Array.isArray(result?.items) ? result.items : []
queue.value = items
const total = typeof result?.total === 'number' ? result.total : items.length
pending.value = Math.max(0, total - items.length)
// A fresh round starts at zero; continuing with the next page of due words keeps
// the counters of the round the learner is already in.
if (newRound) {
answered.value = 0
correctCount.value = 0
wrongCount.value = 0
wordsReviewed.value = 0
seen.clear()
}
started.value = true
} catch (reason) {
if (seq !== sequence || isStale(version, owner)) return
error.value = reason instanceof Error ? reason.message : '复习队列暂时无法加载,请稍后重试。'
} finally {
if (seq === sequence && !isStale(version, owner)) loading.value = false
}
}
function answerIdFor(item: ReviewItem): string {
if (!attempt || attempt.itemId !== item.id) attempt = { itemId: item.id, answerId: crypto.randomUUID() }
return attempt.answerId
}
function reveal(): void {
revealed.value = true
}
/**
* Sends one grade for the current card. A failed request keeps the card and its input
* so the learner can retry; the same answer id is reused on that retry.
*/
async function answer(grade: ReviewGrade): Promise<void> {
const item = current.value
if (!item || busy.value || !session.user) return
const version = generation
const owner = ownerId()
const seq = sequence
busy.value = true
error.value = ''
try {
const result = await session.request<ReviewAnswerResult>(`reviews/${item.id}/answers`, 'POST', {
answerId: answerIdFor(item), grade, expectedDueAt: item.dueAt,
})
if (seq !== sequence || isStale(version, owner)) return
applyResult(item, result)
} catch (reason) {
if (seq !== sequence || isStale(version, owner)) return
error.value = reason instanceof Error ? reason.message : '评分暂时无法提交,请重试。'
} finally {
if (seq === sequence && !isStale(version, owner)) busy.value = false
}
}
function applyResult(item: ReviewItem, result: ReviewAnswerResult): void {
// The card leaves the round in every outcome; a stale or replayed answer is the same
// user action seen twice, not a second review.
queue.value = queue.value.filter(entry => entry.id !== item.id)
attempt = null
revealed.value = false
if (result.result === 'applied') {
answered.value += 1
if (!seen.has(item.id)) {
seen.add(item.id)
wordsReviewed.value += 1
}
if (result.grade === 'correct') correctCount.value += 1
else wrongCount.value += 1
}
if (result.requeued) {
const next = result.item ?? { ...item, level: result.levelAfter, status: result.statusAfter as ReviewItem['status'], dueAt: result.dueAtAfter }
queue.value = [...queue.value, { ...next, dueAt: result.dueAtAfter, level: result.levelAfter, status: result.statusAfter as ReviewItem['status'] }]
}
}
// Load the next page of due words, keeping the counters of the round in progress.
async function continueRound(): Promise<void> {
await load(false)
}
return {
queue, current, loading, error, busy, revealed, answered, correctCount, wrongCount, wordsReviewed, pending,
finished, empty, load, reveal, answer, continueRound, reset,
}
})
+10
View File
@@ -127,6 +127,16 @@ a.chapter-name:hover { color: #315c43; text-decoration: underline; }
.lookup-term .el-textarea textarea { font: inherit; line-height: 1.6; }
.lookup-saved { color: #2f6b45; font-size: 14px; margin: 12px 0 0; }
.lookup-actions { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
.review-page { max-width: 680px; }
.review-card, .review-summary { margin-top: 26px; padding: 28px; border: 1px solid #d9decf; border-radius: 14px; background: #fffdf8; }
.review-summary h2 { margin-top: 0; font-family: Georgia, serif; font-size: 24px; }
.review-word { margin: 12px 0; font-family: Georgia, serif; font-size: 34px; }
.review-example { margin: 12px 0; font-family: Georgia, serif; font-size: 19px; line-height: 1.8; }
.review-answer { margin: 18px 0; padding-top: 18px; border-top: 1px solid #e0e3d8; }
.review-definition { margin: 0 0 12px; font-size: 18px; }
.review-answer blockquote { border-left: 2px solid #cbd9c9; margin: 10px 0; padding-left: 12px; color: #687568; font-style: italic; }
.review-grades { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 8px; }
.review-actions { margin: 16px 0 0; }
@media (max-width: 760px) {
.login-page { grid-template-columns: 1fr; }
.welcome { padding: 28px; }
+1 -1
View File
@@ -31,7 +31,7 @@ onMounted(load)
<div v-if="session.user">
<header class="site-header">
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
<nav aria-label="学习导航"><RouterLink to="/" class="active-nav">我的书库</RouterLink></nav>
<nav aria-label="学习导航"><RouterLink to="/" class="active-nav">我的书库</RouterLink> · <RouterLink to="/review">到期复习</RouterLink></nav>
<div class="account">
<span class="account-name">{{ session.user.username }}</span>
<ElButton text @click="logout">退出登录</ElButton>
+1 -1
View File
@@ -55,7 +55,7 @@ onUnmounted(() => library.closeChapter())
<div v-if="session.user">
<header class="site-header">
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink></nav>
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/review">到期复习</RouterLink></nav>
<div class="account">
<span class="account-name">{{ session.user.username }}</span>
<ElButton text @click="logout">退出登录</ElButton>
+76
View File
@@ -0,0 +1,76 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { ElButton } from 'element-plus'
import ReviewCard from '../components/ReviewCard.vue'
import { useReviewStore, type ReviewItem } from '../stores/review'
import { useSessionStore } from '../stores/session'
const session = useSessionStore()
const review = useReviewStore()
const router = useRouter()
function endReview() { void router.push('/') }
// Retrying reuses the grade of the failed attempt; the store keeps the answer id.
let lastGrade: 'correct' | 'wrong' | 'again' = 'correct'
function grade(value: 'correct' | 'wrong' | 'again') { lastGrade = value; void review.answer(value) }
function retry() { void review.answer(lastGrade) }
onMounted(() => { void review.load() })
async function logout() {
try { await session.logout() }
catch { session.notice = '已退出此设备。服务器暂时无法连接,请稍后重试。' }
finally { await router.replace('/login') }
}
// The template guards for a card before rendering it; this keeps the prop type strict.
const requireItem = (value: ReviewItem | null): ReviewItem => value as ReviewItem
</script>
<template>
<div v-if="session.user">
<header class="site-header">
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink></nav>
<div class="account">
<span class="account-name">{{ session.user.username }}</span>
<ElButton text @click="logout">退出登录</ElButton>
</div>
</header>
<main class="page review-page">
<h1>到期复习</h1>
<p v-if="review.loading" role="status" class="loading">正在加载…</p>
<div v-else-if="review.error && !review.current" class="notice">
<p role="alert">{{ review.error }}</p>
<ElButton data-testid="review-reload" @click="review.load()">重试</ElButton>
</div>
<section v-else-if="review.finished" class="review-summary" data-testid="review-summary">
<h2>本次复习完成</h2>
<p>复习了 {{ review.wordsReviewed }} 个词条 · 共 {{ review.answered }} 次作答</p>
<p class="subtle">答对 {{ review.correctCount }} · 答错或再学 {{ review.wrongCount }} · 已更新复习计划</p>
<p v-if="review.pending" role="status" class="notice">还有 {{ review.pending }} 个词条到期。</p>
<div class="review-grades">
<ElButton v-if="review.pending" type="primary" data-testid="review-more" @click="review.continueRound()">继续复习</ElButton>
<ElButton data-testid="review-finish" @click="endReview">继续阅读</ElButton>
</div>
</section>
<section v-else-if="review.empty" class="review-summary" data-testid="review-empty">
<h2>今天没有到期词条</h2>
<p class="subtle">读一篇文章,积累下一次的词汇。</p>
<ElButton data-testid="review-finish" @click="endReview">返回书库</ElButton>
</section>
<ReviewCard
v-else-if="review.current"
:item="requireItem(review.current)"
:position="review.answered + 1"
:total="review.answered + review.queue.length"
:revealed="review.revealed"
:busy="review.busy"
:error="review.error"
@reveal="review.reveal()"
@grade="grade"
@end="endReview"
@retry="retry"
/>
</main>
</div>
</template>
+38 -1
View File
@@ -78,6 +78,9 @@ func Migrate(db *gorm.DB) error {
if current < 5 {
statements = append(statements, schemaV5Statements...)
}
if current < 6 {
statements = append(statements, schemaV6Statements...)
}
for i, s := range statements {
if _, err = conn.ExecContext(ctx, s); err != nil {
var sqlErr *driver.MySQLError
@@ -93,7 +96,41 @@ func Migrate(db *gorm.DB) error {
// SchemaVersion is the version an explicit migration leaves behind, and the
// version the server requires before it starts.
const SchemaVersion = 5
const SchemaVersion = 6
// v6 adds review scheduling as its own table instead of altering lexgo_terms: every
// statement stays additive and therefore retry-safe after a partial migration, and a
// binary restored to v5 keeps writing personal terms unchanged. Existing saved words
// enter the queue immediately, because a saved word has never been reviewed.
var schemaV6Statements = []string{
`CREATE TABLE IF NOT EXISTS lexgo_term_reviews (
term_id BIGINT UNSIGNED PRIMARY KEY,
owner_id BIGINT NOT NULL, language VARCHAR(16) NOT NULL DEFAULT 'en',
due_at DATETIME(3) NOT NULL, review_count INT NOT NULL DEFAULT 0,
correct_count INT NOT NULL DEFAULT 0, wrong_count INT NOT NULL DEFAULT 0,
last_reviewed_at DATETIME(3) NULL,
INDEX idx_term_review_due (owner_id, language, due_at, term_id),
FOREIGN KEY (term_id) REFERENCES lexgo_terms(id) ON DELETE CASCADE,
FOREIGN KEY (owner_id) REFERENCES sys_user(user_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`INSERT IGNORE INTO lexgo_term_reviews (term_id, owner_id, language, due_at)
SELECT id, owner_id, language, created_at FROM lexgo_terms`,
`CREATE TABLE IF NOT EXISTS lexgo_review_answers (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
owner_id BIGINT NOT NULL,
answer_key CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
term_id BIGINT UNSIGNED NOT NULL, grade VARCHAR(16) NOT NULL, result VARCHAR(16) NOT NULL,
status_before VARCHAR(16) NOT NULL, status_after VARCHAR(16) NOT NULL,
level_before TINYINT NOT NULL, level_after TINYINT NOT NULL,
due_at_before DATETIME(3) NOT NULL, due_at_after DATETIME(3) NOT NULL,
requeued BOOLEAN NOT NULL DEFAULT FALSE, created_at DATETIME(3) NOT NULL,
UNIQUE KEY uq_review_answer (owner_id, answer_key),
INDEX idx_review_answer_owner (owner_id, created_at, id), INDEX idx_review_answer_term (term_id, created_at),
CHECK (grade IN ('correct','wrong','again')), CHECK (result IN ('applied','stale')),
FOREIGN KEY (owner_id) REFERENCES sys_user(user_id) ON DELETE CASCADE,
FOREIGN KEY (term_id) REFERENCES lexgo_terms(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
}
// v5 adds one learner's own records for a word form. Identity is the normalized
// form under the owner's language, so re-saving the same word updates one row
+76
View File
@@ -217,6 +217,82 @@ func TestMigrationFromV4KeepsLibraryAndAddsPersonalTerms(t *testing.T) {
}
}
func TestMigrationFromV5AddsReviewScheduling(t *testing.T) {
db := emptyMigrationDB(t)
statements := []string{"CREATE TABLE lexgo_schema (id INT PRIMARY KEY,version INT,product VARCHAR(32))", "INSERT INTO lexgo_schema VALUES (1,5,'lexgo')"}
statements = append(statements, schemaV2Statements...)
statements = append(statements, schemaV3Statements...)
statements = append(statements, schemaV4Statements...)
statements = append(statements, schemaV5Statements...)
for _, statement := range statements {
if err := db.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
if err := db.Exec("INSERT INTO sys_user (user_id,username,password,role_id) VALUES (11,'fixture_v5','fictional-not-a-real-hash',2)").Error; err != nil {
t.Fatal(err)
}
saved := stamp(time.Now())
term := Term{OwnerID: 11, Language: "en", Term: "curiosity", OriginalForm: "Curiosity", Definition: "虚构释义", Examples: "Fictional example.", Status: termStatusLearning, Level: 2, CreatedAt: saved, UpdatedAt: saved}
if err := db.Create(&term).Error; err != nil {
t.Fatal(err)
}
if err := CheckSchema(db); err == nil {
t.Fatal("old schema accepted before explicit migration")
}
if err := Migrate(db); err != nil {
t.Fatal(err)
}
if err := CheckSchema(db); err != nil {
t.Fatal(err)
}
// The learner's own term is untouched and has never been reviewed.
var restored Term
if err := db.First(&restored, term.ID).Error; err != nil || restored.Definition != term.Definition || restored.Level != 2 || restored.Status != termStatusLearning {
t.Fatal("migration changed the personal term", err)
}
// An existing saved word enters the queue immediately: due at the moment it was saved.
var review TermReview
if err := db.Where("term_id = ?", term.ID).First(&review).Error; err != nil {
t.Fatal("existing terms need a schedule row", err)
}
if !review.DueAt.Equal(saved) || review.ReviewCount != 0 || review.CorrectCount != 0 || review.WrongCount != 0 || review.Language != "en" {
t.Fatalf("schedule row: %#v", review)
}
var answers int64
if err := db.Model(&ReviewAnswer{}).Count(&answers).Error; err != nil || answers != 0 {
t.Fatal("migration must create an empty attempt log", answers, err)
}
// A second identical answer row is rejected by the unique answer key.
digest, err := requestKey("migration-fixture-answer")
if err != nil {
t.Fatal(err)
}
record := ReviewAnswer{OwnerID: 11, AnswerKey: digest, TermID: term.ID, Grade: reviewGradeCorrect, Result: "applied", StatusBefore: termStatusNew, StatusAfter: termStatusLearning, LevelBefore: 0, LevelAfter: 1, DueAtBefore: saved, DueAtAfter: saved, CreatedAt: saved}
if err := db.Create(&record).Error; err != nil {
t.Fatal(err)
}
duplicate := record
duplicate.ID = 0
if err := db.Create(&duplicate).Error; err == nil {
t.Fatal("the answer key must be unique per account")
}
// Rolling the marker back for a binary rollback and upgrading again keeps the rows.
if err := db.Exec("UPDATE lexgo_schema SET version=5 WHERE id=1").Error; err != nil {
t.Fatal(err)
}
if err := Migrate(db); err != nil {
t.Fatal(err)
}
var reviews, attempts int64
if err := db.Model(&TermReview{}).Count(&reviews).Error; err != nil || reviews != 1 {
t.Fatalf("re-upgrade must not duplicate schedule rows: %d %v", reviews, err)
}
if err := db.Model(&ReviewAnswer{}).Count(&attempts).Error; err != nil || attempts != 1 {
t.Fatalf("re-upgrade must keep the attempt log: %d %v", attempts, err)
}
}
func TestMigrationRefusesUnownedOrUnsupportedSchema(t *testing.T) {
for _, tc := range []struct{ name, marker string }{
{"empty_marker", ""},
+382
View File
@@ -0,0 +1,382 @@
package lexgo
import (
"errors"
"strconv"
"time"
"github.com/gin-gonic/gin"
admin "go-admin/app/admin/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// The three answers the accepted review prototype offers.
const (
reviewGradeCorrect = "correct"
reviewGradeWrong = "wrong"
reviewGradeAgain = "again"
)
const (
maxReviewLevel = 7
// One queue page is enough for a daily round; the rest is fetched after finishing it.
reviewQueueLimit = 50
)
// reviewIntervals is the fixed schedule applied after a correct answer, indexed by
// level-1. This is a fixed table, not FSRS: the decision table and its samples live in
// the business rules page.
var reviewIntervals = [maxReviewLevel]int{1, 2, 4, 7, 15, 30, 60}
var reviewGrades = map[string]bool{
reviewGradeCorrect: true,
reviewGradeWrong: true,
reviewGradeAgain: true,
}
// TermReview is the scheduling state of one personal term, kept in its own table so a
// migration never alters the table that holds the learner's own text.
type TermReview struct {
TermID int64 `gorm:"primaryKey"`
OwnerID int
Language string
DueAt time.Time
ReviewCount int
CorrectCount int
WrongCount int
LastReviewedAt *time.Time
}
func (TermReview) TableName() string { return "lexgo_term_reviews" }
// ReviewAnswer records one attempt. The unique answer key is what makes a repeated
// request, a double click or a network resend update nothing a second time.
type ReviewAnswer struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
OwnerID int
AnswerKey string `gorm:"column:answer_key"`
TermID int64
Grade string
Result string
StatusBefore string `gorm:"column:status_before"`
StatusAfter string `gorm:"column:status_after"`
LevelBefore int `gorm:"column:level_before"`
LevelAfter int `gorm:"column:level_after"`
DueAtBefore time.Time `gorm:"column:due_at_before"`
DueAtAfter time.Time `gorm:"column:due_at_after"`
Requeued bool
CreatedAt time.Time
}
func (ReviewAnswer) TableName() string { return "lexgo_review_answers" }
type ReviewItem struct {
ID int64 `json:"id"`
Term string `json:"term"`
OriginalForm string `json:"originalForm"`
Definition string `json:"definition"`
Examples []string `json:"examples"`
Status string `json:"status"`
Level int `json:"level"`
DueAt time.Time `json:"dueAt"`
ReviewCount int `json:"reviewCount"`
}
type ReviewQueue struct {
Items []ReviewItem `json:"items"`
Total int `json:"total"`
}
type ReviewAnswerInput struct {
AnswerID string `json:"answerId"`
Grade string `json:"grade"`
ExpectedDueAt time.Time `json:"expectedDueAt"`
}
type ReviewAnswerResult struct {
Result string `json:"result"`
Grade string `json:"grade"`
Requeued bool `json:"requeued"`
StatusBefore string `json:"statusBefore"`
StatusAfter string `json:"statusAfter"`
LevelBefore int `json:"levelBefore"`
LevelAfter int `json:"levelAfter"`
DueAtBefore time.Time `json:"dueAtBefore"`
DueAtAfter time.Time `json:"dueAtAfter"`
Item ReviewItem `json:"item"`
}
// reviewState is the part of a term a grade moves.
type reviewState struct {
Status string
Level int
DueAt time.Time
}
func nextDue(level int, now time.Time) time.Time {
if level < 1 {
level = 1
}
if level > maxReviewLevel {
level = maxReviewLevel
}
return stamp(now.AddDate(0, 0, reviewIntervals[level-1]))
}
// applyGrade advances one term by exactly one answer. It returns the next state and
// whether the item belongs to the current round again.
func applyGrade(before reviewState, grade string, now time.Time) (reviewState, bool, error) {
switch grade {
case reviewGradeCorrect:
level := before.Level + 1
if level > maxReviewLevel {
level = maxReviewLevel
}
return reviewState{Status: termStatusLearning, Level: level, DueAt: nextDue(level, now)}, false, nil
case reviewGradeWrong:
next := reviewState{Status: before.Status, Level: before.Level}
// Only a learning word loses a level; a new word stays new until it is answered
// correctly for the first time.
if before.Status == termStatusLearning {
next.Level = before.Level - 1
if next.Level < 1 {
next.Level = 1
}
}
next.DueAt = stamp(now)
return next, true, nil
case reviewGradeAgain:
return reviewState{Status: before.Status, Level: before.Level, DueAt: stamp(now)}, true, nil
default:
return before, false, failure(400, "评分无效")
}
}
func reviewItem(term Term, review TermReview) ReviewItem {
return ReviewItem{
ID: term.ID, Term: term.Term, OriginalForm: term.OriginalForm, Definition: term.Definition,
Examples: splitExamples(term.Examples), Status: term.Status, Level: term.Level,
DueAt: review.DueAt, ReviewCount: review.ReviewCount,
}
}
// ReviewQueueFor returns the caller's due words for the current language. Known and
// ignored words stay out of the queue, and the queue is an absolute-instant comparison:
// an item is due as soon as due_at is not in the future.
func ReviewQueueFor(tx *gorm.DB, owner int, language string, now time.Time) (ReviewQueue, error) {
queue := ReviewQueue{Items: []ReviewItem{}}
due := func() *gorm.DB {
return tx.Table("lexgo_terms AS t").
Joins("JOIN lexgo_term_reviews AS r ON r.term_id = t.id").
Where("t.owner_id = ? AND r.owner_id = ? AND t.language = ? AND r.language = ?", owner, owner, language, language).
Where("t.status IN ?", []string{termStatusNew, termStatusLearning}).
Where("r.due_at <= ?", stamp(now))
}
var total int64
if err := due().Count(&total).Error; err != nil {
return queue, err
}
queue.Total = int(total)
var rows []struct {
ID int64
Term string
OriginalForm string
Definition string
Examples string
Status string
Level int
DueAt time.Time
ReviewCount int
}
if err := due().
Select("t.id, t.term, t.original_form, t.definition, t.examples, t.status, t.level, r.due_at, r.review_count").
Order("r.due_at, t.id").Limit(reviewQueueLimit).Scan(&rows).Error; err != nil {
return queue, err
}
for _, row := range rows {
queue.Items = append(queue.Items, ReviewItem{
ID: row.ID, Term: row.Term, OriginalForm: row.OriginalForm, Definition: row.Definition,
Examples: splitExamples(row.Examples), Status: row.Status, Level: row.Level,
DueAt: row.DueAt, ReviewCount: row.ReviewCount,
})
}
return queue, nil
}
// syncTermReview keeps the scheduling row in step with a saved term. A new word is due
// immediately so saving it starts the loop; a word saved straight into a level waits for
// that level's interval, so a manual level is not silently re-queued today.
func syncTermReview(tx *gorm.DB, term Term, now time.Time) error {
due := stamp(now)
if term.Status == termStatusLearning && term.Level >= 1 {
due = nextDue(term.Level, now)
}
row := TermReview{TermID: term.ID, OwnerID: term.OwnerID, Language: term.Language, DueAt: due}
// Counters are history and survive a status or level change.
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "term_id"}},
DoUpdates: clause.Assignments(map[string]any{"due_at": row.DueAt}),
}).Create(&row).Error
}
func lockedReview(tx *gorm.DB, owner int, term Term) (TermReview, error) {
var review TermReview
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("term_id = ? AND owner_id = ?", term.ID, owner).First(&review).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
// A term written before this table existed is due at the moment it was saved.
review = TermReview{TermID: term.ID, OwnerID: owner, Language: term.Language, DueAt: stamp(term.CreatedAt)}
if err = tx.Create(&review).Error; err != nil {
return review, err
}
return review, nil
}
return review, err
}
// AnswerReview applies one grade to one owned term exactly once. A repeated answer key
// returns the first outcome, and an answer whose due time no longer matches (another tab
// already advanced the term) is recorded as stale without moving anything.
func AnswerReview(tx *gorm.DB, owner int, termID int64, input ReviewAnswerInput, now time.Time) (ReviewAnswerResult, error) {
if !reviewGrades[input.Grade] {
return ReviewAnswerResult{}, failure(400, "评分无效")
}
if input.ExpectedDueAt.IsZero() {
return ReviewAnswerResult{}, failure(400, "请提交队列中的到期时间")
}
key, err := requestKey(input.AnswerID)
if err != nil {
return ReviewAnswerResult{}, err
}
var stored ReviewAnswer
err = tx.Where("owner_id = ? AND answer_key = ?", owner, key).First(&stored).Error
if err == nil {
if stored.TermID != termID {
return ReviewAnswerResult{}, failure(400, "请求编号已用于其他词条")
}
return replayedAnswer(tx, stored)
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return ReviewAnswerResult{}, err
}
// Ownership first: another account's term and a missing term answer identically.
var term Term
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND owner_id = ?", termID, owner).First(&term).Error; errors.Is(err, gorm.ErrRecordNotFound) {
return ReviewAnswerResult{}, failure(404, "词条不存在")
} else if err != nil {
return ReviewAnswerResult{}, err
}
review, err := lockedReview(tx, owner, term)
if err != nil {
return ReviewAnswerResult{}, err
}
if term.Status != termStatusNew && term.Status != termStatusLearning {
return ReviewAnswerResult{}, failure(409, "该词条已不在复习队列,请刷新队列")
}
before := reviewState{Status: term.Status, Level: term.Level, DueAt: review.DueAt}
if !input.ExpectedDueAt.Equal(review.DueAt) {
return staleAnswer(tx, owner, term, review, input, now)
}
next, requeued, err := applyGrade(before, input.Grade, now)
if err != nil {
return ReviewAnswerResult{}, err
}
when := stamp(now)
if err := tx.Model(&Term{}).Where("id = ? AND owner_id = ?", term.ID, owner).
Updates(map[string]any{"status": next.Status, "level": next.Level, "updated_at": when}).Error; err != nil {
return ReviewAnswerResult{}, err
}
counters := map[string]any{
"due_at": next.DueAt, "review_count": gorm.Expr("review_count + 1"),
"last_reviewed_at": when,
}
// "again" is not a correct answer either: the word was not recognised this time.
if input.Grade == reviewGradeCorrect {
counters["correct_count"] = gorm.Expr("correct_count + 1")
} else {
counters["wrong_count"] = gorm.Expr("wrong_count + 1")
}
if err := tx.Model(&TermReview{}).Where("term_id = ? AND owner_id = ?", term.ID, owner).Updates(counters).Error; err != nil {
return ReviewAnswerResult{}, err
}
term.Status, term.Level, term.UpdatedAt = next.Status, next.Level, when
review.DueAt, review.ReviewCount, review.LastReviewedAt = next.DueAt, review.ReviewCount+1, &when
record := ReviewAnswer{
OwnerID: owner, AnswerKey: key, TermID: term.ID, Grade: input.Grade, Result: "applied",
StatusBefore: before.Status, StatusAfter: next.Status, LevelBefore: before.Level, LevelAfter: next.Level,
DueAtBefore: before.DueAt, DueAtAfter: next.DueAt, Requeued: requeued, CreatedAt: when,
}
if err := tx.Create(&record).Error; err != nil {
return ReviewAnswerResult{}, err
}
return ReviewAnswerResult{
Result: "applied", Grade: input.Grade, Requeued: requeued,
StatusBefore: before.Status, StatusAfter: next.Status, LevelBefore: before.Level, LevelAfter: next.Level,
DueAtBefore: before.DueAt, DueAtAfter: next.DueAt, Item: reviewItem(term, review),
}, nil
}
// staleAnswer records that this attempt changed nothing because the term had already
// moved on. It is a normal outcome of two open tabs, not an error.
func staleAnswer(tx *gorm.DB, owner int, term Term, review TermReview, input ReviewAnswerInput, now time.Time) (ReviewAnswerResult, error) {
key, err := requestKey(input.AnswerID)
if err != nil {
return ReviewAnswerResult{}, err
}
record := ReviewAnswer{
OwnerID: owner, AnswerKey: key, TermID: term.ID, Grade: input.Grade, Result: "stale",
StatusBefore: term.Status, StatusAfter: term.Status, LevelBefore: term.Level, LevelAfter: term.Level,
DueAtBefore: review.DueAt, DueAtAfter: review.DueAt, CreatedAt: stamp(now),
}
if err := tx.Create(&record).Error; err != nil {
return ReviewAnswerResult{}, err
}
return ReviewAnswerResult{
Result: "stale", Grade: input.Grade, Requeued: false,
StatusBefore: term.Status, StatusAfter: term.Status, LevelBefore: term.Level, LevelAfter: term.Level,
DueAtBefore: review.DueAt, DueAtAfter: review.DueAt, Item: reviewItem(term, review),
}, nil
}
// replayedAnswer returns the outcome already recorded for this answer key. Nothing is
// advanced a second time, so a resend cannot change counts or intervals.
func replayedAnswer(tx *gorm.DB, stored ReviewAnswer) (ReviewAnswerResult, error) {
var term Term
if err := tx.Where("id = ? AND owner_id = ?", stored.TermID, stored.OwnerID).First(&term).Error; err != nil {
return ReviewAnswerResult{}, err
}
var review TermReview
if err := tx.Where("term_id = ? AND owner_id = ?", stored.TermID, stored.OwnerID).First(&review).Error; err != nil {
return ReviewAnswerResult{}, err
}
return ReviewAnswerResult{
Result: "duplicate", Grade: stored.Grade, Requeued: stored.Requeued,
StatusBefore: stored.StatusBefore, StatusAfter: stored.StatusAfter,
LevelBefore: stored.LevelBefore, LevelAfter: stored.LevelAfter,
DueAtBefore: stored.DueAtBefore, DueAtAfter: stored.DueAtAfter, Item: reviewItem(term, review),
}, nil
}
func registerReviewRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context, *gorm.DB, admin.SysUser) (any, error)) gin.HandlerFunc, now func() time.Time) {
v.GET("/reviews/queue", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
if c.Request.URL.RawQuery != "" {
return nil, failure(400, "复习队列不接受查询参数")
}
language, err := languageOf(tx, u.UserId)
if err != nil {
return nil, err
}
return ReviewQueueFor(tx, u.UserId, language, now())
}))
v.POST("/reviews/:termId/answers", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
termID, err := strconv.ParseInt(c.Param("termId"), 10, 64)
if err != nil || termID <= 0 {
return nil, failure(404, "词条不存在")
}
var input ReviewAnswerInput
if err := decode(c, &input); err != nil {
return nil, err
}
return AnswerReview(tx, u.UserId, termID, input, now())
}))
}
+382
View File
@@ -0,0 +1,382 @@
package lexgo
import (
"encoding/json"
"fmt"
"testing"
"time"
"github.com/gin-gonic/gin"
admin "go-admin/app/admin/models"
"gorm.io/gorm"
)
// TestReviewDecisionTable pins the fixed schedule and every state transition. It is the
// design evidence the ticket asks for, so a change to the table must fail here first.
func TestReviewDecisionTable(t *testing.T) {
levels := []int{1, 2, 3, 4, 5, 6, 7}
days := []int{1, 2, 4, 7, 15, 30, 60}
if len(reviewIntervals) != len(levels) {
t.Fatalf("interval table has %d entries, want %d", len(reviewIntervals), len(levels))
}
now := time.Date(2026, 9, 11, 10, 30, 0, 0, time.UTC)
for index, level := range levels {
if reviewIntervals[level-1] != days[index] {
t.Fatalf("level %d interval %d, want %d", level, reviewIntervals[level-1], days[index])
}
next, requeued, err := applyGrade(reviewState{Status: termStatusLearning, Level: level, DueAt: now}, reviewGradeCorrect, now)
want := level + 1
if want > maxReviewLevel {
want = maxReviewLevel
}
if err != nil || requeued || next.Level != want || !next.DueAt.Equal(now.AddDate(0, 0, reviewIntervals[want-1])) || next.Status != termStatusLearning {
t.Fatalf("level %d correct: %#v requeued=%v err=%v", level, next, requeued, err)
}
}
cases := []struct {
name string
before reviewState
grade string
status string
level int
dueAt time.Time
requeued bool
}{
{"new correct becomes learning level 1", reviewState{Status: termStatusNew}, reviewGradeCorrect, termStatusLearning, 1, now.AddDate(0, 0, 1), false},
{"new wrong stays new and requeues", reviewState{Status: termStatusNew}, reviewGradeWrong, termStatusNew, 0, now, true},
{"new again stays new and requeues", reviewState{Status: termStatusNew}, reviewGradeAgain, termStatusNew, 0, now, true},
{"learning 4 wrong drops to 3", reviewState{Status: termStatusLearning, Level: 4}, reviewGradeWrong, termStatusLearning, 3, now, true},
{"learning 1 wrong floors at 1", reviewState{Status: termStatusLearning, Level: 1}, reviewGradeWrong, termStatusLearning, 1, now, true},
{"learning 7 wrong drops to 6", reviewState{Status: termStatusLearning, Level: 7}, reviewGradeWrong, termStatusLearning, 6, now, true},
{"again keeps the level", reviewState{Status: termStatusLearning, Level: 3}, reviewGradeAgain, termStatusLearning, 3, now, true},
{"level 7 correct stays at 7", reviewState{Status: termStatusLearning, Level: 7}, reviewGradeCorrect, termStatusLearning, 7, now.AddDate(0, 0, 60), false},
}
for _, tc := range cases {
before := tc.before
before.DueAt = now
next, requeued, err := applyGrade(before, tc.grade, now)
if err != nil || requeued != tc.requeued || next.Status != tc.status || next.Level != tc.level || !next.DueAt.Equal(tc.dueAt) {
t.Fatalf("%s: %#v requeued=%v err=%v", tc.name, next, requeued, err)
}
}
if _, _, err := applyGrade(reviewState{Status: termStatusNew, DueAt: now}, "maybe", now); err == nil {
t.Fatal("an unknown grade must be rejected")
}
}
// reviewFixture builds one learner, one book and one ready chapter under a clock the
// test can move, so due boundaries are exact.
func reviewFixture(t *testing.T, db *gorm.DB, text string) (*gin.Engine, *time.Time, admin.SysUser, string, Chapter) {
t.Helper()
clock := time.Now().UTC().Truncate(time.Millisecond)
r := Router(db, func() time.Time { return clock })
user, token, chapters := termFixture(t, db, r, text)
return r, &clock, user, token, chapters[0]
}
func reviewQueue(t *testing.T, r *gin.Engine, token string) (int, ReviewQueue) {
t.Helper()
code, data := callAPI(t, r, "GET", "/api/v1/reviews/queue", token, nil)
var queue ReviewQueue
if data != nil {
json.Unmarshal(data, &queue)
}
return code, queue
}
func answerReview(t *testing.T, r *gin.Engine, token string, termID int64, body map[string]any) (int, ReviewAnswerResult) {
t.Helper()
code, data := callAPI(t, r, "POST", fmt.Sprintf("/api/v1/reviews/%d/answers", termID), token, body)
var result ReviewAnswerResult
if data != nil {
json.Unmarshal(data, &result)
}
return code, result
}
func answerBody(answerID, grade string, dueAt time.Time) map[string]any {
return map[string]any{"answerId": answerID, "grade": grade, "expectedDueAt": dueAt.UTC().Format(time.RFC3339Nano)}
}
// saveWord saves one word span of an owned chapter and returns the stored record.
func saveWord(t *testing.T, r *gin.Engine, token string, chapterID int64, start, end int, status string, level *int) TermSave {
t.Helper()
body := map[string]any{"chapterId": chapterID, "start": start, "end": end, "definition": "虚构释义", "status": status}
if level != nil {
body["level"] = *level
}
code, saved := saveTermAPI(t, r, token, body)
if code != 201 && code != 200 {
t.Fatalf("save word %d-%d: %d %#v", start, end, code, saved)
}
return saved
}
func termReviewRow(t *testing.T, db *gorm.DB, termID int64) TermReview {
t.Helper()
var review TermReview
if err := db.Where("term_id = ?", termID).First(&review).Error; err != nil {
t.Fatal(err)
}
return review
}
// TestMySQLReviewQueueDueBoundaryAndScope covers the due boundary, the queue scope and
// per-account isolation.
func TestMySQLReviewQueueDueBoundaryAndScope(t *testing.T) {
db := testDB(t)
r, clock, _, token, chapter := reviewFixture(t, db, "Dogs went home.")
_, otherToken, otherChapters := termFixture(t, db, r, "Dogs went home.")
dogs := saveWord(t, r, token, chapter.ID, 0, 4, termStatusNew, nil)
// A saved word is due immediately: its due time is exactly the save time.
code, queue := reviewQueue(t, r, token)
if code != 200 || queue.Total != 1 || len(queue.Items) != 1 || queue.Items[0].ID != dogs.Term.ID {
t.Fatalf("a new word must be due at once: %d %#v", code, queue)
}
item := queue.Items[0]
if !item.DueAt.Equal(*clock) || item.Status != termStatusNew || item.Level != 0 || item.ReviewCount != 0 || item.Examples == nil {
t.Fatalf("queue item: %#v", item)
}
if !termReviewRow(t, db, dogs.Term.ID).DueAt.Equal(*clock) {
t.Fatal("the schedule row must hold the save time")
}
// due_at == now is due; one millisecond earlier is not.
*clock = item.DueAt.Add(-time.Millisecond)
if _, early := reviewQueue(t, r, token); early.Total != 0 {
t.Fatalf("an item due later must stay out of the queue: %#v", early)
}
*clock = item.DueAt.Add(time.Millisecond)
if _, late := reviewQueue(t, r, token); late.Total != 1 {
t.Fatal("an overdue item must be due")
}
// Another account saves the same word in its own chapter without touching this queue.
otherDogs := saveWord(t, r, otherToken, otherChapters[0].ID, 0, 4, termStatusNew, nil)
if otherDogs.Term.ID == dogs.Term.ID {
t.Fatal("two accounts must own separate records for the same word")
}
code, mine := reviewQueue(t, r, token)
if code != 200 || len(mine.Items) != 1 || mine.Items[0].ID != dogs.Term.ID {
t.Fatalf("queues must not mix accounts: %d %#v", code, mine)
}
code, theirs := reviewQueue(t, r, otherToken)
if code != 200 || len(theirs.Items) != 1 || theirs.Items[0].ID != otherDogs.Term.ID {
t.Fatalf("the other account must see its own queue: %d %#v", code, theirs)
}
// Known and ignored words never enter the queue; returning to new makes it due again.
for _, status := range []string{termStatusKnown, termStatusIgnored} {
if code, _ := saveTermAPI(t, r, token, map[string]any{"chapterId": chapter.ID, "start": 0, "end": 4, "status": status}); code != 200 {
t.Fatalf("marking %s failed", status)
}
if _, scoped := reviewQueue(t, r, token); scoped.Total != 0 {
t.Fatalf("%s must stay out of the queue: %#v", status, scoped)
}
}
if code, _ := saveTermAPI(t, r, token, map[string]any{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusNew}); code != 200 {
t.Fatal("returning to a new word failed")
}
if _, again := reviewQueue(t, r, token); again.Total != 1 {
t.Fatalf("a new word must be due again: %#v", again)
}
if code, _ := callAPI(t, r, "GET", "/api/v1/reviews/queue?status=new", token, nil); code != 400 {
t.Fatal("the queue must reject query parameters")
}
if code, _ := callAPI(t, r, "GET", "/api/v1/reviews/queue", "", nil); code != 401 {
t.Fatal("the queue requires a session")
}
}
// TestMySQLReviewAnswerTransitions answers one word three times and checks the term, the
// counters and the recorded attempt after each grade.
func TestMySQLReviewAnswerTransitions(t *testing.T) {
db := testDB(t)
r, clock, user, token, chapter := reviewFixture(t, db, "Dogs went home.")
dogs := saveWord(t, r, token, chapter.ID, 0, 4, termStatusNew, nil)
termID := dogs.Term.ID
// Correct: a new word becomes learning level 1 and leaves the round.
_, queue := reviewQueue(t, r, token)
code, first := answerReview(t, r, token, termID, answerBody("answer-correct-0001", reviewGradeCorrect, queue.Items[0].DueAt))
if code != 201 || first.Result != "applied" || first.Requeued {
t.Fatalf("correct answer: %d %#v", code, first)
}
if first.StatusAfter != termStatusLearning || first.LevelAfter != 1 || first.Item.Level != 1 || first.Item.ReviewCount != 1 {
t.Fatalf("correct transition: %#v", first)
}
if !first.DueAtAfter.Equal(clock.AddDate(0, 0, 1)) || !first.DueAtBefore.Equal(*clock) {
t.Fatalf("correct schedule: %#v", first)
}
if _, empty := reviewQueue(t, r, token); empty.Total != 0 {
t.Fatal("a correctly answered word must leave the queue until it is due again")
}
// One interval later the word is due again; a wrong answer requeues it immediately.
// Sessions last eight hours of the injected clock, so the jump forward needs a login.
*clock = first.DueAtAfter
token = loginToken(t, r, user.Username, fixturePassword)
_, queue = reviewQueue(t, r, token)
if queue.Total != 1 || queue.Items[0].Level != 1 || queue.Items[0].ReviewCount != 1 {
t.Fatalf("second round queue: %#v", queue)
}
code, wrong := answerReview(t, r, token, termID, answerBody("answer-wrong-000002", reviewGradeWrong, queue.Items[0].DueAt))
if code != 201 || !wrong.Requeued || wrong.LevelAfter != 1 || wrong.StatusAfter != termStatusLearning || !wrong.DueAtAfter.Equal(*clock) {
t.Fatalf("wrong answer: %d %#v", code, wrong)
}
_, queue = reviewQueue(t, r, token)
if queue.Total != 1 || queue.Items[0].Level != 1 {
t.Fatalf("requeued item: %#v", queue)
}
code, again := answerReview(t, r, token, termID, answerBody("answer-again-000003", reviewGradeAgain, queue.Items[0].DueAt))
if code != 201 || !again.Requeued || again.LevelBefore != 1 || again.LevelAfter != 1 {
t.Fatalf("again answer: %d %#v", code, again)
}
// Counters and the attempt log accumulated exactly three answers.
review := termReviewRow(t, db, termID)
if review.ReviewCount != 3 || review.CorrectCount != 1 || review.WrongCount != 2 || review.LastReviewedAt == nil {
t.Fatalf("counters: %#v", review)
}
var term Term
if err := db.First(&term, termID).Error; err != nil || term.Status != termStatusLearning || term.Level != 1 || term.Definition != "虚构释义" {
t.Fatalf("the learner's own text must not change: %#v %v", term, err)
}
var answers int64
if err := db.Model(&ReviewAnswer{}).Where("owner_id = ?", user.UserId).Count(&answers).Error; err != nil || answers != 3 {
t.Fatalf("attempt log: %d %v", answers, err)
}
var logged ReviewAnswer
if err := db.Where("term_id = ? AND grade = ?", termID, reviewGradeCorrect).First(&logged).Error; err != nil {
t.Fatal(err)
}
if logged.Result != "applied" || logged.StatusBefore != termStatusNew || logged.LevelAfter != 1 || logged.Requeued {
t.Fatalf("logged attempt: %#v", logged)
}
}
// TestMySQLReviewAnswerIdempotencyAndStaleTabs is the core of the acceptance: a resend
// must not count twice, and a second tab must not advance a term that already moved.
func TestMySQLReviewAnswerIdempotencyAndStaleTabs(t *testing.T) {
db := testDB(t)
r, clock, _, token, chapter := reviewFixture(t, db, "Dogs went home.")
dogs := saveWord(t, r, token, chapter.ID, 0, 4, termStatusNew, nil)
termID := dogs.Term.ID
_, queue := reviewQueue(t, r, token)
seen := queue.Items[0].DueAt
// Two tabs answer the same word with different answer ids but the same seen due time.
code, applied := answerReview(t, r, token, termID, answerBody("tab-one-answer-0001", reviewGradeCorrect, seen))
if code != 201 || applied.Result != "applied" {
t.Fatalf("first tab: %d %#v", code, applied)
}
code, stale := answerReview(t, r, token, termID, answerBody("tab-two-answer-0002", reviewGradeCorrect, seen))
if code != 200 || stale.Result != "stale" || stale.Requeued {
t.Fatalf("second tab must not advance the term: %d %#v", code, stale)
}
if stale.LevelAfter != applied.LevelAfter || !stale.DueAtAfter.Equal(applied.DueAtAfter) {
t.Fatalf("a stale answer changed state: %#v", stale)
}
// A resend of the first answer returns the first outcome without another update.
code, duplicate := answerReview(t, r, token, termID, answerBody("tab-one-answer-0001", reviewGradeCorrect, seen))
if code != 200 || duplicate.Result != "duplicate" {
t.Fatalf("resend: %d %#v", code, duplicate)
}
if duplicate.LevelAfter != applied.LevelAfter || !duplicate.DueAtAfter.Equal(applied.DueAtAfter) || duplicate.Item.ReviewCount != 1 {
t.Fatalf("a resend changed state: %#v", duplicate)
}
// Counters moved exactly once, and the replay is not a new attempt row: the applied
// answer and the stale one are two rows, the duplicate returns the first row.
review := termReviewRow(t, db, termID)
if review.ReviewCount != 1 || review.CorrectCount != 1 || review.WrongCount != 0 {
t.Fatalf("counters after three attempts: %#v", review)
}
var attempts int64
if err := db.Model(&ReviewAnswer{}).Where("term_id = ?", termID).Count(&attempts).Error; err != nil || attempts != 2 {
t.Fatalf("each distinct attempt must be recorded once: %d %v", attempts, err)
}
// Reusing an answer id for another word is a client error, not a new answer.
went := saveWord(t, r, token, chapter.ID, 5, 9, termStatusNew, nil)
code, _ = answerReview(t, r, token, went.Term.ID, answerBody("tab-one-answer-0001", reviewGradeCorrect, *clock))
if code != 400 {
t.Fatalf("a reused answer id must be rejected: %d", code)
}
// A genuinely new answer for the other word still works.
code, fresh := answerReview(t, r, token, went.Term.ID, answerBody("went-answer-000004", reviewGradeCorrect, *clock))
if code != 201 || fresh.LevelAfter != 1 {
t.Fatalf("a later answer must work: %d %#v", code, fresh)
}
}
// TestMySQLReviewAnswerErrorsAndOwnership covers the rejected shapes of the answer
// endpoint.
func TestMySQLReviewAnswerErrorsAndOwnership(t *testing.T) {
db := testDB(t)
r, clock, _, token, chapter := reviewFixture(t, db, "Dogs went home.")
_, otherToken, _ := termFixture(t, db, r, "Dogs went home.")
dogs := saveWord(t, r, token, chapter.ID, 0, 4, termStatusNew, nil)
termID := dogs.Term.ID
seen := *clock
if code, _ := answerReview(t, r, otherToken, termID, answerBody("foreign-answer-0001", reviewGradeCorrect, seen)); code != 404 {
t.Fatal("another account's term must be 404")
}
if code, _ := answerReview(t, r, token, 999999, answerBody("missing-answer-0002", reviewGradeCorrect, seen)); code != 404 {
t.Fatal("an unknown term must be 404")
}
for _, id := range []string{"0", "-3", "abc"} {
if code, _ := callAPI(t, r, "POST", "/api/v1/reviews/"+id+"/answers", token, answerBody("bad-id-000003", reviewGradeCorrect, seen)); code != 404 {
t.Fatalf("invalid term id %s: %d", id, code)
}
}
for _, body := range []map[string]any{
{"answerId": "grade-missing-0001", "expectedDueAt": seen.Format(time.RFC3339Nano)},
{"answerId": "grade-unknown-0002", "grade": "maybe", "expectedDueAt": seen.Format(time.RFC3339Nano)},
answerBody("short", reviewGradeCorrect, seen),
{"answerId": "due-missing-0003", "grade": reviewGradeCorrect},
{"answerId": "extra-field-0004", "grade": reviewGradeCorrect, "expectedDueAt": seen.Format(time.RFC3339Nano), "level": 7},
} {
if code, _ := answerReview(t, r, token, termID, body); code != 400 {
t.Fatalf("invalid body %v must be 400: %d", body, code)
}
}
if code, _ := callAPI(t, r, "POST", fmt.Sprintf("/api/v1/reviews/%d/answers", termID), "", answerBody("anonymous-000001", reviewGradeCorrect, seen)); code != 401 {
t.Fatal("answering requires a session")
}
// A word marked as known in another screen is no longer reviewable.
if code, _ := saveTermAPI(t, r, token, map[string]any{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusKnown}); code != 200 {
t.Fatal("marking the word known failed")
}
code, result := answerReview(t, r, token, termID, answerBody("known-answer-0005", reviewGradeCorrect, seen))
if code != 409 || result.Result != "" {
t.Fatalf("a known word must refuse an answer: %d %#v", code, result)
}
if review := termReviewRow(t, db, termID); review.ReviewCount != 0 {
t.Fatalf("a refused answer must not count: %#v", review)
}
}
// TestMySQLReviewScheduleOnManualStatus checks the documented rule for words saved with
// an explicit status: new is due at once, learning waits for its level's interval.
func TestMySQLReviewScheduleOnManualStatus(t *testing.T) {
db := testDB(t)
r, clock, _, token, chapter := reviewFixture(t, db, "Dogs went home.")
level := 3
learning := saveWord(t, r, token, chapter.ID, 0, 4, termStatusLearning, &level)
if review := termReviewRow(t, db, learning.Term.ID); !review.DueAt.Equal(clock.AddDate(0, 0, 4)) {
t.Fatalf("a manual level must wait for its interval: %v", review.DueAt)
}
if _, queue := reviewQueue(t, r, token); queue.Total != 0 {
t.Fatalf("a manually leveled word is not due today: %#v", queue)
}
// Changing it back to a new word makes it due immediately again.
if code, _ := saveTermAPI(t, r, token, map[string]any{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusNew}); code != 200 {
t.Fatal("resetting the status failed")
}
if _, queue := reviewQueue(t, r, token); queue.Total != 1 || queue.Items[0].Level != 0 {
t.Fatalf("a new word is due at once: %#v", queue)
}
}
+7
View File
@@ -141,6 +141,12 @@ func Router(db *gorm.DB, now func() time.Time) *gin.Engine {
status = 201
}
}
// A replayed or stale answer changed nothing, so it is not a new answer.
if c.Request.Method == "POST" && c.FullPath() == "/api/v1/reviews/:termId/answers" {
if answer, ok := data.(ReviewAnswerResult); ok && answer.Result == "applied" {
status = 201
}
}
respond(c, status, data, err)
}
}
@@ -302,6 +308,7 @@ func Router(db *gorm.DB, now func() time.Time) *gin.Engine {
}))
registerDictionaryRoutes(v, protect, now)
registerTermRoutes(v, protect, now)
registerReviewRoutes(v, protect, now)
r.NoRoute(func(c *gin.Context) { respond(c, 404, nil, failure(404, "页面或接口不存在")) })
return r
}
+14 -5
View File
@@ -88,12 +88,16 @@ type TermSave struct {
Created bool `json:"created"`
}
func termView(t Term) TermView {
examples := []string{}
if t.Examples != "" {
examples = strings.Split(t.Examples, "\n")
// splitExamples returns the stored examples as a list; the column holds one per line.
func splitExamples(text string) []string {
if text == "" {
return []string{}
}
return TermView{t.ID, t.Language, t.Term, t.OriginalForm, t.Definition, examples, t.Status, t.Level, t.CreatedAt, t.UpdatedAt}
return strings.Split(text, "\n")
}
func termView(t Term) TermView {
return TermView{t.ID, t.Language, t.Term, t.OriginalForm, t.Definition, splitExamples(t.Examples), t.Status, t.Level, t.CreatedAt, t.UpdatedAt}
}
// termLevel enforces the documented status/level boundary: only a learning entry
@@ -215,6 +219,11 @@ func saveTerm(tx *gorm.DB, owner int, language, word string, fields TermFields,
if err := tx.Where("owner_id = ? AND language = ? AND term = ?", owner, language, row.Term).First(&stored).Error; err != nil {
return TermSave{}, err
}
// Saving a word also places it on the review schedule, so the loop from reading to
// reviewing needs no separate step.
if err := syncTermReview(tx, stored, now); err != nil {
return TermSave{}, err
}
return TermSave{termView(stored), insert.RowsAffected == 1}, nil
}