forked from wangziqi/gongxue-base
feat: add vocabulary review planning
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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/*` |
|
||||
|
||||
@@ -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` | 部分覆盖 | 套餐/订单/权益/激活码已有;缺真实支付、优惠券抵扣 |
|
||||
|
||||
@@ -79,7 +79,8 @@
|
||||
|
||||
6. 学习统计
|
||||
- 已完成免费额度、练习访问事件、模考交卷评分报告、练习历史、正确率趋势、题型分布、错题复习计划。
|
||||
- 继续补单词复习算法、每日计划、排行榜。
|
||||
- 已完成单词复习算法、每日计划和复习上报。
|
||||
- 继续补排行榜。
|
||||
- 继续补模考排名、断点续练、复盘体验。
|
||||
|
||||
7. 数据看板
|
||||
@@ -144,7 +145,7 @@
|
||||
4. 背单词
|
||||
- 单元列表、单词列表
|
||||
- 学习状态、收藏、统计
|
||||
- 后续补复习算法
|
||||
- 每日新词、复习计划、认识/不认识上报
|
||||
|
||||
5. 知识手册
|
||||
- 手册入口、章节、小节、知识点阅读
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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 },
|
||||
|
||||
31
supabase/migrations/202606210014_vocabulary_review_plan.sql
Normal file
31
supabase/migrations/202606210014_vocabulary_review_plan.sql
Normal 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);
|
||||
Reference in New Issue
Block a user