From fbcfa5127ef3b6e765b03fdb715dd0976310b283 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 28 Jun 2026 23:55:01 +0800 Subject: [PATCH] feat: add vocabulary review planning --- apps/api/src/features/learning/index.ts | 4 + apps/api/src/features/learning/routes.ts | 300 +++++++++++++++++- docs/refactor/backend-capability-status.md | 2 +- docs/refactor/legacy-feature-gap-matrix.md | 2 +- docs/refactor/next-development-todo.md | 5 +- docs/refactor/taro-frontend-integration.md | 48 ++- scripts/api-integration-test.js | 21 ++ .../202606210014_vocabulary_review_plan.sql | 31 ++ 8 files changed, 406 insertions(+), 7 deletions(-) create mode 100644 supabase/migrations/202606210014_vocabulary_review_plan.sql diff --git a/apps/api/src/features/learning/index.ts b/apps/api/src/features/learning/index.ts index 2368010c..a427d9b2 100644 --- a/apps/api/src/features/learning/index.ts +++ b/apps/api/src/features/learning/index.ts @@ -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], diff --git a/apps/api/src/features/learning/routes.ts b/apps/api/src/features/learning/routes.ts index 50cfc439..26c558e2 100644 --- a/apps/api/src/features/learning/routes.ts +++ b/apps/api/src/features/learning/routes.ts @@ -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( + ` + 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( diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index d10329b8..6d2828e7 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -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/*` | diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index b27f3df3..500f625d 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -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` | 部分覆盖 | 套餐/订单/权益/激活码已有;缺真实支付、优惠券抵扣 | diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 6ba7487d..8ca669d3 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -79,7 +79,8 @@ 6. 学习统计 - 已完成免费额度、练习访问事件、模考交卷评分报告、练习历史、正确率趋势、题型分布、错题复习计划。 - - 继续补单词复习算法、每日计划、排行榜。 + - 已完成单词复习算法、每日计划和复习上报。 + - 继续补排行榜。 - 继续补模考排名、断点续练、复盘体验。 7. 数据看板 @@ -144,7 +145,7 @@ 4. 背单词 - 单元列表、单词列表 - 学习状态、收藏、统计 - - 后续补复习算法 + - 每日新词、复习计划、认识/不认识上报 5. 知识手册 - 手册入口、章节、小节、知识点阅读 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 7ac9bd65..239f60fb 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -144,7 +144,7 @@ tenant::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::theme - 收藏夹复习同理可调用 `POST /api/learning/practice-sessions`,body 为 `{ "mode": "favorite_review", "questionLimit": 20 }`。 - 趋势图以接口返回日期桶为准,缺失日期后端会补 0,不需要前端补点。 +### 背单词计划与复习上报 + +背单词页面分三类数据:单元列表、每日计划、单词进度。前端不需要计算下次复习日期,只提交“认识/不认识”,由后端统一更新 `nextReviewDate`、连续正确、掌握状态和每日复习计划。 + +取今日计划: + +```http +GET /api/learning/vocabulary/review-plan?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。 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 073bc4aa..2b7f07a8 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -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 }, diff --git a/supabase/migrations/202606210014_vocabulary_review_plan.sql b/supabase/migrations/202606210014_vocabulary_review_plan.sql new file mode 100644 index 00000000..01a854e6 --- /dev/null +++ b/supabase/migrations/202606210014_vocabulary_review_plan.sql @@ -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);