feat: add vocabulary review planning

This commit is contained in:
Codex
2026-06-28 23:55:01 +08:00
parent dfb383d281
commit fbcfa5127e
8 changed files with 406 additions and 7 deletions

View File

@@ -13,8 +13,10 @@ import {
submitPracticeSessionRoute,
toggleFavoriteQuestionRoute,
toggleFavoriteWordRoute,
reviewWordRoute,
updateWordProgressRoute,
wordProgressRoute,
wordReviewPlanRoute,
wordStatsRoute,
wrongQuestionReviewPlanRoute,
wrongQuestionsRoute,
@@ -36,6 +38,8 @@ export const learningRoutes: RouteDefinition[] = [
['POST', '/api/learning/wrong-questions/resolve', resolveWrongQuestionRoute],
['GET', '/api/learning/vocabulary/progress', wordProgressRoute],
['POST', '/api/learning/vocabulary/progress', updateWordProgressRoute],
['GET', '/api/learning/vocabulary/review-plan', wordReviewPlanRoute],
['POST', '/api/learning/vocabulary/review', reviewWordRoute],
['GET', '/api/learning/vocabulary/favorites', favoriteWordsRoute],
['POST', '/api/learning/vocabulary/favorites', toggleFavoriteWordRoute],
['GET', '/api/learning/vocabulary/stats', wordStatsRoute],

View File

@@ -145,6 +145,18 @@ interface SectionStat {
sortOrder: number;
}
interface WordProgressReviewRow {
id: string;
wordId: string;
status: string;
correctCount: number;
wrongCount: number;
reviewCount: number;
correctStreak: number;
easeFactor: string | number;
nextReviewDate: string | null;
}
interface PracticeAssembly {
mode: string;
targetType: string | null;
@@ -1644,6 +1656,67 @@ function normalizeWordStatus(value: string) {
return 'learning';
}
function normalizeWordReviewResult(value: string) {
if (['correct', 'known', 'mastered'].includes(value)) return 'known';
if (['wrong', 'unknown', 'again'].includes(value)) return 'unknown';
return 'unknown';
}
function computeWordReviewSchedule(input: {
result: 'known' | 'unknown';
correctCount: number;
wrongCount: number;
reviewCount: number;
correctStreak: number;
easeFactor: number;
}) {
const known = input.result === 'known';
const correctCount = input.correctCount + (known ? 1 : 0);
const wrongCount = input.wrongCount + (known ? 0 : 1);
const reviewCount = input.reviewCount + 1;
const correctStreak = known ? input.correctStreak + 1 : 0;
const easeFactor = Math.max(1.3, Math.min(3, input.easeFactor + (known ? 0.08 : -0.2)));
const intervalDays = known
? correctStreak <= 1
? 1
: correctStreak === 2
? 3
: correctStreak === 3
? 7
: Math.min(60, Math.round(7 * Math.pow(easeFactor, correctStreak - 3)))
: 0;
const status = known
? correctStreak >= 4
? 'mastered'
: 'reviewing'
: 'learning';
const dueLevel = known
? status === 'mastered'
? 'mastered'
: correctStreak <= 1
? 'soon'
: 'later'
: 'again';
const nextReviewDate = new Date();
if (known) {
nextReviewDate.setDate(nextReviewDate.getDate() + intervalDays);
} else {
nextReviewDate.setHours(nextReviewDate.getHours() + 6);
}
return {
correctCount,
wrongCount,
reviewCount,
correctStreak,
easeFactor: round2(easeFactor),
intervalDays,
status,
dueLevel,
nextReviewDate: nextReviewDate.toISOString(),
};
}
export async function wordProgressRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);
@@ -1655,6 +1728,9 @@ export async function wordProgressRoute(ctx: RequestContext) {
`
select p.id, p.word_id as "wordId", p.status,
p.correct_count as "correctCount", p.wrong_count as "wrongCount",
p.review_count as "reviewCount", p.correct_streak as "correctStreak",
p.ease_factor as "easeFactor", p.last_result as "lastResult",
p.due_level as "dueLevel",
p.last_review_date as "lastReviewDate", p.next_review_date as "nextReviewDate",
p.created_at as "createdAt", p.updated_at as "updatedAt",
w.unit_id as "unitId", w.word, w.phonetic, w.meaning
@@ -1672,6 +1748,216 @@ export async function wordProgressRoute(ctx: RequestContext) {
return { items };
}
export async function wordReviewPlanRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);
const unitId = stringParam(ctx, 'unitId');
const reviewLimit = intParam(ctx, 'reviewLimit', 30, 200);
const newLimit = intParam(ctx, 'newLimit', 20, 100);
const dueWords = await query(
`
select p.word_id as "wordId", p.status,
p.correct_count as "correctCount", p.wrong_count as "wrongCount",
p.review_count as "reviewCount", p.correct_streak as "correctStreak",
p.ease_factor as "easeFactor", p.due_level as "dueLevel",
p.last_review_date as "lastReviewDate", p.next_review_date as "nextReviewDate",
w.unit_id as "unitId", w.word, w.phonetic, w.meaning,
w.example, w.example_translation as "exampleTranslation",
w.difficulty, w.tags
from public.user_word_progress p
join public.vocabulary_words w on w.id = p.word_id and w.tenant_id = p.tenant_id
where p.tenant_id = $1
and p.user_id = $2
and w.is_active = true
and ($3::uuid is null or w.unit_id = $3::uuid)
and (
p.next_review_date is null
or p.next_review_date <= now()
or p.due_level in ('again', 'new')
)
and p.status <> 'mastered'
order by
case p.due_level when 'again' then 0 when 'new' then 1 when 'soon' then 2 else 3 end,
p.next_review_date asc nulls first,
p.wrong_count desc,
w.sort_order asc
limit $4
`,
[tenantId, userId, unitId || null, reviewLimit],
);
const newWords = await query(
`
select w.id as "wordId", 'new' as status,
0 as "correctCount", 0 as "wrongCount", 0 as "reviewCount",
0 as "correctStreak", 2.5 as "easeFactor", 'new' as "dueLevel",
null::timestamptz as "lastReviewDate", null::timestamptz as "nextReviewDate",
w.unit_id as "unitId", w.word, w.phonetic, w.meaning,
w.example, w.example_translation as "exampleTranslation",
w.difficulty, w.tags
from public.vocabulary_words w
where w.tenant_id = $1
and w.is_active = true
and ($2::uuid is null or w.unit_id = $2::uuid)
and not exists (
select 1 from public.user_word_progress p
where p.tenant_id = w.tenant_id
and p.user_id = $3
and p.word_id = w.id
)
order by w.sort_order asc, w.created_at asc
limit $4
`,
[tenantId, unitId || null, userId, newLimit],
);
return {
item: {
unitId: unitId || null,
reviewLimit,
newLimit,
dueCount: dueWords.length,
newCount: newWords.length,
totalPlanned: dueWords.length + newWords.length,
dueWords,
newWords,
words: [...dueWords, ...newWords],
},
};
}
export async function reviewWordRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx, body);
const wordId = requiredString(body, 'wordId');
const result = normalizeWordReviewResult(optionalString(body, 'result') || optionalString(body, 'answerResult'));
const item = await transaction(async client => {
const word = await client.query<{ id: string }>(
`
select id from public.vocabulary_words
where tenant_id = $1 and id = $2 and is_active = true
limit 1
`,
[tenantId, wordId],
);
if (!word.rows[0]) throw new HttpError(404, 'Vocabulary word not found', 'WORD_NOT_FOUND');
const existing = await client.query<WordProgressReviewRow>(
`
select id, word_id as "wordId", status,
correct_count as "correctCount", wrong_count as "wrongCount",
review_count as "reviewCount", correct_streak as "correctStreak",
ease_factor as "easeFactor", next_review_date as "nextReviewDate"
from public.user_word_progress
where tenant_id = $1 and user_id = $2 and word_id = $3
for update
`,
[tenantId, userId, wordId],
);
const current = existing.rows[0] || {
id: '',
wordId,
status: 'new',
correctCount: 0,
wrongCount: 0,
reviewCount: 0,
correctStreak: 0,
easeFactor: 2.5,
nextReviewDate: null,
};
const schedule = computeWordReviewSchedule({
result,
correctCount: Number(current.correctCount || 0),
wrongCount: Number(current.wrongCount || 0),
reviewCount: Number(current.reviewCount || 0),
correctStreak: Number(current.correctStreak || 0),
easeFactor: finiteNumber(current.easeFactor, 2.5),
});
const progress = await client.query(
`
insert into public.user_word_progress (
tenant_id, user_id, word_id, status, correct_count, wrong_count,
review_count, correct_streak, ease_factor, last_result, due_level,
last_review_date, next_review_date, metadata
)
values (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10, $11,
now(), $12::timestamptz, $13::jsonb
)
on conflict (tenant_id, user_id, word_id)
do update set status = excluded.status,
correct_count = excluded.correct_count,
wrong_count = excluded.wrong_count,
review_count = excluded.review_count,
correct_streak = excluded.correct_streak,
ease_factor = excluded.ease_factor,
last_result = excluded.last_result,
due_level = excluded.due_level,
last_review_date = now(),
next_review_date = excluded.next_review_date,
metadata = public.user_word_progress.metadata || excluded.metadata,
updated_at = now()
returning id, word_id as "wordId", status,
correct_count as "correctCount", wrong_count as "wrongCount",
review_count as "reviewCount", correct_streak as "correctStreak",
ease_factor as "easeFactor", last_result as "lastResult",
due_level as "dueLevel",
last_review_date as "lastReviewDate", next_review_date as "nextReviewDate",
created_at as "createdAt", updated_at as "updatedAt"
`,
[
tenantId,
userId,
wordId,
schedule.status,
schedule.correctCount,
schedule.wrongCount,
schedule.reviewCount,
schedule.correctStreak,
schedule.easeFactor,
result,
schedule.dueLevel,
schedule.nextReviewDate,
JSON.stringify({
source: 'review_word',
result,
intervalDays: schedule.intervalDays,
reviewedAt: new Date().toISOString(),
}),
],
);
await client.query(
`
update public.student_profiles
set mastered_words_count = (
select count(*) from public.user_word_progress
where tenant_id = $1 and user_id = $2 and status = 'mastered'
),
updated_at = now()
where tenant_id = $1 and user_id = $2
`,
[tenantId, userId],
);
return {
...progress.rows[0],
review: {
result,
intervalDays: schedule.intervalDays,
nextReviewDate: schedule.nextReviewDate,
},
};
});
return { item };
}
export async function updateWordProgressRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = await tenantIdFrom(ctx);
@@ -1681,6 +1967,8 @@ export async function updateWordProgressRoute(ctx: RequestContext) {
const correctDelta = Math.max(0, optionalInteger(body, 'correctDelta', status === 'mastered' ? 1 : 0));
const wrongDelta = Math.max(0, optionalInteger(body, 'wrongDelta', 0));
const nextReviewDate = optionalString(body, 'nextReviewDate') || null;
const lastResult = correctDelta > 0 ? 'known' : wrongDelta > 0 ? 'unknown' : null;
const dueLevel = status === 'mastered' ? 'mastered' : wrongDelta > 0 ? 'again' : status === 'new' ? 'new' : 'soon';
const item = await transaction(async client => {
const word = await client.query<{ id: string }>(
@@ -1697,22 +1985,30 @@ export async function updateWordProgressRoute(ctx: RequestContext) {
`
insert into public.user_word_progress (
tenant_id, user_id, word_id, status, correct_count, wrong_count,
review_count, correct_streak, last_result, due_level,
last_review_date, next_review_date
)
values ($1, $2, $3, $4, $5, $6, now(), $7::timestamptz)
values ($1, $2, $3, $4, $5, $6, case when $5::integer + $6::integer > 0 then 1 else 0 end, $8, $9, $10, now(), $7::timestamptz)
on conflict (tenant_id, user_id, word_id)
do update set status = excluded.status,
correct_count = public.user_word_progress.correct_count + $5,
wrong_count = public.user_word_progress.wrong_count + $6,
review_count = public.user_word_progress.review_count + case when $5::integer + $6::integer > 0 then 1 else 0 end,
correct_streak = case when $5 > 0 then public.user_word_progress.correct_streak + 1 when $6 > 0 then 0 else public.user_word_progress.correct_streak end,
last_result = coalesce(excluded.last_result, public.user_word_progress.last_result),
due_level = excluded.due_level,
last_review_date = now(),
next_review_date = coalesce(excluded.next_review_date, public.user_word_progress.next_review_date),
updated_at = now()
returning id, word_id as "wordId", status,
correct_count as "correctCount", wrong_count as "wrongCount",
review_count as "reviewCount", correct_streak as "correctStreak",
ease_factor as "easeFactor", last_result as "lastResult",
due_level as "dueLevel",
last_review_date as "lastReviewDate", next_review_date as "nextReviewDate",
created_at as "createdAt", updated_at as "updatedAt"
`,
[tenantId, userId, wordId, status, correctDelta, wrongDelta, nextReviewDate],
[tenantId, userId, wordId, status, correctDelta, wrongDelta, nextReviewDate, correctDelta > 0 ? 1 : 0, lastResult, dueLevel],
);
await client.query(

View File

@@ -67,7 +67,7 @@
| --- | --- | --- |
| 单词单元/单词列表 | 可联调 | `/api/catalog/vocabulary-units``vocabulary-words` |
| 单词进度/收藏/统计 | 可联调 | `/api/learning/vocabulary/*` |
| 艾宾浩斯复习算法 | 待补齐 | 当前有 next_review 字段基础,缺完整算法和每日计划 |
| 单词复习算法/每日计划 | 可联调 | `GET /api/learning/vocabulary/review-plan``POST /api/learning/vocabulary/review`;后端计算 `nextReviewDate`、连续正确、掌握状态和待复习计划 |
| 知识手册目录/内容 | 可联调 | `/api/catalog/handbook-*` |
| 知识手册 JSON 导入 | 可联调 | `/api/tenant-content/imports/*/handbook` |
| 分数线字段/院校/专业/记录/趋势 | 可联调 | `/api/scoreline/*` |

View File

@@ -26,7 +26,7 @@
| 错题本 | 用户 stats/错题逻辑 | 已覆盖 | 错题列表、移出错题、复习计划和 `wrong_review` 后端组卷已覆盖;后续补更细的间隔复习算法 |
| 收藏夹 | `WordFavoritesPage.tsx`、题目收藏 | 已覆盖 | 题目和单词收藏已有 |
| 题目视频 | `VideoPlayer.tsx` | 部分覆盖 | 题目视频查询、播放签名、SVIP/次数扣减、播放日志已有;缺深度防盗链、动态水印、播放统计报表 |
| 背单词 | `VocabularyPage.tsx``VocabularyQuiz.tsx` | 部分覆盖 | 单词列表/进度/收藏/统计已有;缺完整艾宾浩斯算法、每日计划、收藏练习细节 |
| 背单词 | `VocabularyPage.tsx``VocabularyQuiz.tsx` | 部分覆盖 | 单词列表进度收藏统计、每日计划和后端复习调度已覆盖;后续补收藏练习体验、发音策略和更精细的间隔算法参数 |
| 知识手册 | `Handbook*.tsx` | 已覆盖 | 前端需做好 Markdown/公式/图片渲染和搜索体验 |
| 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势已有;缺批量导入和复杂筛选优化 |
| 商城/SVIP | `Store.tsx``SvipModal.tsx` | 部分覆盖 | 套餐/订单/权益/激活码已有;缺真实支付、优惠券抵扣 |

View File

@@ -79,7 +79,8 @@
6. 学习统计
- 已完成免费额度、练习访问事件、模考交卷评分报告、练习历史、正确率趋势、题型分布、错题复习计划。
- 继续补单词复习算法、每日计划、排行榜
- 已完成单词复习算法、每日计划和复习上报
- 继续补排行榜。
- 继续补模考排名、断点续练、复盘体验。
7. 数据看板
@@ -144,7 +145,7 @@
4. 背单词
- 单元列表、单词列表
- 学习状态、收藏、统计
- 后续补复习算法
- 每日新词、复习计划、认识/不认识上报
5. 知识手册
- 手册入口、章节、小节、知识点阅读

View File

@@ -144,7 +144,7 @@ tenant:<tenantId>:theme
| 练习历史/统计 | `GET /api/learning/practice-sessions/history``GET /api/learning/stats``GET /api/learning/trend` |
| 题目视频 | `GET /api/questions/{questionId}/videos``POST /api/questions/videos/batch``POST /api/videos/play` |
| 背单词 | `/api/catalog/vocabulary-units``/api/catalog/vocabulary-words` |
| 单词进度 | `/api/learning/vocabulary/progress``/api/learning/vocabulary/stats` |
| 单词进度/计划 | `/api/learning/vocabulary/progress``/api/learning/vocabulary/stats``/api/learning/vocabulary/review-plan``POST /api/learning/vocabulary/review` |
| 单词收藏 | `/api/learning/vocabulary/favorites` |
| 知识手册 | `/api/catalog/handbook-subjects``handbook-chapters``handbook-entries` |
| 分数线 | `/api/scoreline/fields``schools``majors``records``trend``years` |
@@ -293,6 +293,52 @@ tenant:<tenantId>:theme
- 收藏夹复习同理可调用 `POST /api/learning/practice-sessions`body 为 `{ "mode": "favorite_review", "questionLimit": 20 }`
- 趋势图以接口返回日期桶为准,缺失日期后端会补 0不需要前端补点。
### 背单词计划与复习上报
背单词页面分三类数据:单元列表、每日计划、单词进度。前端不需要计算下次复习日期,只提交“认识/不认识”,由后端统一更新 `nextReviewDate`、连续正确、掌握状态和每日复习计划。
取今日计划:
```http
GET /api/learning/vocabulary/review-plan?unitId=<unitId>&reviewLimit=30&newLimit=20
```
响应关键字段:
```json
{
"item": {
"dueCount": 3,
"newCount": 20,
"totalPlanned": 23,
"dueWords": [{ "wordId": "...", "status": "reviewing", "dueLevel": "soon" }],
"newWords": [{ "wordId": "...", "status": "new", "dueLevel": "new" }],
"words": []
}
}
```
上报单词复习结果:
```json
{
"wordId": "00000000-0000-0000-0000-000000000812",
"result": "known"
}
```
`result` 可传:
- `known`:认识/答对。
- `unknown`:不认识/答错。
前端处理规则:
- `review-plan.words` 是本轮学习队列;卡片翻转、上一个、跳转、收藏状态属于前端交互。
- 每个单词点击“认识/不认识”后调用 `POST /api/learning/vocabulary/review`
- 返回的 `status``dueLevel``nextReviewDate` 作为后续展示依据,不在前端重算间隔。
- 旧的 `POST /api/learning/vocabulary/progress` 保留给兼容和后台手工修正;普通学习流优先用 `vocabulary/review`
## 视频播放契约
题目视频分为 `free``svip``video_quota` 三种访问模式。列表接口只用于展示标题、封面、时长、访问模式和试看秒数;除免费公开视频外,列表和搜索接口不会返回可播放 URL。

View File

@@ -876,12 +876,33 @@ async function testVocabulary() {
const stats = await request('/api/learning/vocabulary/stats', { query: { unitId: ids.vocabularyUnit } });
assert.ok(stats.item?.totalWords >= 1, 'word stats should count smoke word');
const reviewUnknown = await request('/api/learning/vocabulary/review', {
method: 'POST',
body: { userId: USER_ID, wordId: ids.vocabularyWord, result: 'unknown' },
});
assert.equal(reviewUnknown.item?.lastResult, 'unknown', 'word review should persist unknown result');
assert.equal(reviewUnknown.item?.dueLevel, 'again', 'unknown word should be scheduled again');
const reviewPlan = await request('/api/learning/vocabulary/review-plan', {
query: { unitId: ids.vocabularyUnit, reviewLimit: 10, newLimit: 10 },
});
assert.ok(Array.isArray(reviewPlan.item?.words), 'word review plan should return planned words');
assert.ok(reviewPlan.item?.words?.some(item => item.wordId === ids.vocabularyWord), 'word review plan should include due smoke word');
const progress = await request('/api/learning/vocabulary/progress', {
method: 'POST',
body: { userId: USER_ID, wordId: ids.vocabularyWord, status: 'mastered', correctDelta: 1 },
});
assert.equal(progress.item?.status, 'mastered', 'word progress should update to mastered');
const reviewKnown = await request('/api/learning/vocabulary/review', {
method: 'POST',
body: { userId: USER_ID, wordId: ids.vocabularyWord, result: 'known' },
});
assert.equal(reviewKnown.item?.lastResult, 'known', 'word review should persist known result');
assert.ok(reviewKnown.item?.reviewCount >= 1, 'word review should increment review count');
assert.ok(reviewKnown.item?.nextReviewDate, 'word review should compute next review date');
const favorite = await request('/api/learning/vocabulary/favorites', {
method: 'POST',
body: { userId: USER_ID, wordId: ids.vocabularyWord, favorite: true },

View File

@@ -0,0 +1,31 @@
alter table public.user_word_progress
add column if not exists review_count integer not null default 0,
add column if not exists correct_streak integer not null default 0,
add column if not exists ease_factor numeric(4,2) not null default 2.50,
add column if not exists last_result text check (last_result in ('correct', 'wrong', 'known', 'unknown')),
add column if not exists due_level text not null default 'new' check (due_level in ('new', 'again', 'soon', 'later', 'mastered')),
add column if not exists metadata jsonb not null default '{}'::jsonb;
do $$
begin
if not exists (select 1 from pg_constraint where conname = 'user_word_progress_review_count_check') then
alter table public.user_word_progress
add constraint user_word_progress_review_count_check check (review_count >= 0);
end if;
if not exists (select 1 from pg_constraint where conname = 'user_word_progress_correct_streak_check') then
alter table public.user_word_progress
add constraint user_word_progress_correct_streak_check check (correct_streak >= 0);
end if;
if not exists (select 1 from pg_constraint where conname = 'user_word_progress_ease_factor_check') then
alter table public.user_word_progress
add constraint user_word_progress_ease_factor_check check (ease_factor >= 1.30 and ease_factor <= 3.00);
end if;
end $$;
create index if not exists idx_user_word_progress_due
on public.user_word_progress(tenant_id, user_id, next_review_date, status, due_level);
create index if not exists idx_vocabulary_words_unit_order
on public.vocabulary_words(tenant_id, unit_id, is_active, sort_order, created_at);