diff --git a/src/app.module.ts b/src/app.module.ts index 7bdcd79..0b32b28 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -6,12 +6,15 @@ import { QuizModule } from './quiz/quiz.module'; import { QuestModule } from './quest/quest.module'; import { PhrasesModule } from './phrases/phrases.module'; import { GrammarModule } from './grammar/grammar.module'; +import { ProgressModule } from './progress/progress.module'; import { Word } from './words/entities/word.entity'; import { Phrase } from './phrases/entities/phrase.entity'; import { GrammarLesson } from './grammar/entities/grammar.entity'; import { DailyQuest } from './quest/entities/quest.entity'; import { QuizResult } from './quiz/entities/quiz-result.entity'; import { Streak } from './quest/entities/streak.entity'; +import { UserProgress } from './progress/entities/user-progress.entity'; +import { StudiedWord } from './progress/entities/studied-word.entity'; @Module({ imports: [ @@ -22,7 +25,7 @@ import { Streak } from './quest/entities/streak.entity'; username: 'kakao', password: '486251daKWON@', database: 'english_study', - entities: [Word, Phrase, GrammarLesson, DailyQuest, QuizResult, Streak], + entities: [Word, Phrase, GrammarLesson, DailyQuest, QuizResult, Streak, UserProgress, StudiedWord], synchronize: true, }), ScheduleModule.forRoot(), @@ -31,6 +34,7 @@ import { Streak } from './quest/entities/streak.entity'; QuestModule, PhrasesModule, GrammarModule, + ProgressModule, ], }) export class AppModule {} diff --git a/src/progress/entities/studied-word.entity.ts b/src/progress/entities/studied-word.entity.ts new file mode 100644 index 0000000..ca83fb1 --- /dev/null +++ b/src/progress/entities/studied-word.entity.ts @@ -0,0 +1,19 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm'; + +@Entity('studied_words') +export class StudiedWord { + @PrimaryGeneratedColumn() + id: number; + + @Column() + session_id: string; + + @Column() + word_id: number; + + @Column({ type: 'date' }) + studied_date: string; + + @CreateDateColumn() + created_at: Date; +} diff --git a/src/progress/entities/user-progress.entity.ts b/src/progress/entities/user-progress.entity.ts new file mode 100644 index 0000000..7368f74 --- /dev/null +++ b/src/progress/entities/user-progress.entity.ts @@ -0,0 +1,33 @@ +import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; + +@Entity('user_progress') +export class UserProgress { + @PrimaryGeneratedColumn() + id: number; + + @Column({ unique: true }) + session_id: string; + + @Column({ default: 0 }) + xp: number; + + @Column({ default: 1 }) + level: number; + + @Column({ default: 0 }) + combo: number; + + @Column({ default: 0 }) + max_combo: number; + + @Column({ type: 'json', nullable: true }) + badges: string[]; + + @Column({ type: 'json', nullable: true }) + stats: { + total_words_studied: number; + total_quizzes: number; + total_correct: number; + wrong_words: { id: number; english: string; korean: string; count: number }[]; + }; +} diff --git a/src/progress/progress.controller.ts b/src/progress/progress.controller.ts new file mode 100644 index 0000000..210ef78 --- /dev/null +++ b/src/progress/progress.controller.ts @@ -0,0 +1,32 @@ +import { Controller, Get, Post, Body, Query } from '@nestjs/common'; +import { ProgressService } from './progress.service'; + +@Controller('progress') +export class ProgressController { + constructor(private readonly service: ProgressService) {} + + @Get('stats') + getStats(@Query('session') session: string) { + return this.service.getStats(session || 'default'); + } + + @Post('word') + recordWord(@Body() body: { session_id: string; word_id: number }) { + return this.service.recordWordStudied(body.session_id, body.word_id); + } + + @Post('quiz-answer') + recordAnswer(@Body() body: { session_id: string; word_id: number; is_correct: boolean; word?: { english: string; korean: string } }) { + return this.service.recordQuizAnswer(body.session_id, body.word_id, body.is_correct, body.word); + } + + @Post('quiz-complete') + recordComplete(@Body() body: { session_id: string; score: number; total: number }) { + return this.service.recordQuizComplete(body.session_id, body.score, body.total); + } + + @Get('studied-words') + getStudiedWords(@Query('session') session: string, @Query('date') date?: string) { + return this.service.getStudiedWordIds(session || 'default', date); + } +} diff --git a/src/progress/progress.module.ts b/src/progress/progress.module.ts new file mode 100644 index 0000000..c00c151 --- /dev/null +++ b/src/progress/progress.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { UserProgress } from './entities/user-progress.entity'; +import { StudiedWord } from './entities/studied-word.entity'; +import { ProgressController } from './progress.controller'; +import { ProgressService } from './progress.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([UserProgress, StudiedWord])], + controllers: [ProgressController], + providers: [ProgressService], + exports: [ProgressService], +}) +export class ProgressModule {} diff --git a/src/progress/progress.service.ts b/src/progress/progress.service.ts new file mode 100644 index 0000000..4ed45f7 --- /dev/null +++ b/src/progress/progress.service.ts @@ -0,0 +1,160 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { UserProgress } from './entities/user-progress.entity'; +import { StudiedWord } from './entities/studied-word.entity'; + +const LEVEL_THRESHOLDS = [0, 100, 250, 500, 900, 1400, 2100, 3000, 4200, 6000, 10000]; +const BADGES: Record = { + first_study: { id: 'first_study', name: '첫 걸음', emoji: '🌱', desc: '첫 단어 ν•™μŠ΅ μ™„λ£Œ' }, + streak_3: { id: 'streak_3', name: '3일 연속', emoji: 'πŸ”₯', desc: '3일 연속 ν•™μŠ΅' }, + streak_7: { id: 'streak_7', name: '일주일', emoji: 'πŸ’Ž', desc: '7일 연속 ν•™μŠ΅' }, + quiz_perfect: { id: 'quiz_perfect', name: 'λ§Œμ μ™•', emoji: 'πŸ†', desc: 'ν€΄μ¦ˆ 만점' }, + combo_5: { id: 'combo_5', name: '5콀보', emoji: '⚑', desc: '5연속 μ •λ‹΅' }, + combo_10: { id: 'combo_10', name: '10콀보', emoji: '🎯', desc: '10연속 μ •λ‹΅' }, + words_50: { id: 'words_50', name: '단어 50', emoji: 'πŸ“š', desc: '단어 50개 ν•™μŠ΅' }, + words_100: { id: 'words_100', name: '단어 100', emoji: 'πŸŽ“', desc: '단어 100개 ν•™μŠ΅' }, + words_500: { id: 'words_500', name: '단어 500', emoji: 'πŸ‘‘', desc: '단어 500개 ν•™μŠ΅' }, +}; + +@Injectable() +export class ProgressService { + constructor( + @InjectRepository(UserProgress) private progressRepo: Repository, + @InjectRepository(StudiedWord) private studiedRepo: Repository, + ) {} + + private calcLevel(xp: number): number { + let lv = 1; + for (let i = 1; i < LEVEL_THRESHOLDS.length; i++) { + if (xp >= LEVEL_THRESHOLDS[i]) lv = i + 1; + else break; + } + return lv; + } + + async getOrCreate(session_id: string): Promise { + let p = await this.progressRepo.findOne({ where: { session_id } }); + if (!p) { + p = this.progressRepo.create({ + session_id, + badges: [], + stats: { total_words_studied: 0, total_quizzes: 0, total_correct: 0, wrong_words: [] }, + }); + await this.progressRepo.save(p); + } + if (!p.stats) { + p.stats = { total_words_studied: 0, total_quizzes: 0, total_correct: 0, wrong_words: [] }; + } + if (!p.badges) p.badges = []; + return p; + } + + async recordWordStudied(session_id: string, word_id: number): Promise<{ progress: UserProgress; xpGained: number; newBadges: string[] }> { + const today = new Date().toISOString().split('T')[0]; + const existing = await this.studiedRepo.findOne({ where: { session_id, word_id, studied_date: today } }); + if (!existing) { + await this.studiedRepo.save({ session_id, word_id, studied_date: today }); + } + + const p = await this.getOrCreate(session_id); + const newBadges: string[] = []; + p.stats.total_words_studied++; + + const xpGained = 10; + p.xp += xpGained; + p.level = this.calcLevel(p.xp); + + const checks = [ + { count: 1, badge: 'first_study' }, + { count: 50, badge: 'words_50' }, + { count: 100, badge: 'words_100' }, + { count: 500, badge: 'words_500' }, + ]; + for (const c of checks) { + if (p.stats.total_words_studied === c.count && !p.badges.includes(c.badge)) { + p.badges.push(c.badge); + newBadges.push(c.badge); + } + } + + await this.progressRepo.save(p); + return { progress: p, xpGained, newBadges }; + } + + async recordQuizAnswer( + session_id: string, + word_id: number, + is_correct: boolean, + word_data?: { english: string; korean: string }, + ): Promise<{ progress: UserProgress; newBadges: string[]; xpGained: number }> { + const p = await this.getOrCreate(session_id); + const newBadges: string[] = []; + let xpGained = 0; + + if (is_correct) { + p.combo++; + if (p.combo > p.max_combo) p.max_combo = p.combo; + const comboBonus = Math.min(p.combo, 10) * 2; + xpGained = 20 + comboBonus; + p.xp += xpGained; + p.stats.total_correct++; + + if (p.combo === 5 && !p.badges.includes('combo_5')) { + p.badges.push('combo_5'); + newBadges.push('combo_5'); + } + if (p.combo === 10 && !p.badges.includes('combo_10')) { + p.badges.push('combo_10'); + newBadges.push('combo_10'); + } + } else { + p.combo = 0; + xpGained = 5; + p.xp += xpGained; + if (word_data) { + const ww = p.stats.wrong_words || []; + const idx = ww.findIndex((w) => w.id === word_id); + if (idx >= 0) ww[idx].count++; + else ww.push({ id: word_id, english: word_data.english, korean: word_data.korean, count: 1 }); + p.stats.wrong_words = ww.sort((a, b) => b.count - a.count).slice(0, 50); + } + } + + p.stats.total_quizzes++; + p.level = this.calcLevel(p.xp); + await this.progressRepo.save(p); + return { progress: p, newBadges, xpGained }; + } + + async recordQuizComplete(session_id: string, score: number, total: number): Promise { + const p = await this.getOrCreate(session_id); + if (score === total && !p.badges.includes('quiz_perfect')) { + p.badges.push('quiz_perfect'); + } + return this.progressRepo.save(p); + } + + async getStudiedWordIds(session_id: string, date?: string): Promise { + const d = date || new Date().toISOString().split('T')[0]; + const rows = await this.studiedRepo.find({ where: { session_id, studied_date: d } }); + return rows.map((r) => r.word_id); + } + + async getStats(session_id: string) { + const p = await this.getOrCreate(session_id); + const currentLevelXp = LEVEL_THRESHOLDS[p.level - 1] || 0; + const nextLevelXp = LEVEL_THRESHOLDS[p.level] || LEVEL_THRESHOLDS[LEVEL_THRESHOLDS.length - 1]; + const allBadges = Object.values(BADGES).map((b) => ({ ...b, earned: p.badges.includes(b.id) })); + + return { + ...p, + currentLevelXp, + nextLevelXp, + xpProgress: p.xp - currentLevelXp, + xpRange: nextLevelXp - currentLevelXp, + xpToNextLevel: nextLevelXp - p.xp, + allBadges, + }; + } +} diff --git a/src/quiz/quiz.controller.ts b/src/quiz/quiz.controller.ts index 414ce6f..2a62c0a 100644 --- a/src/quiz/quiz.controller.ts +++ b/src/quiz/quiz.controller.ts @@ -4,8 +4,19 @@ import { QuizService } from './quiz.service'; @Controller('quiz') export class QuizController { constructor(private readonly service: QuizService) {} - @Get('generate') generate(@Query('count') count?: string) { return this.service.generateQuiz(count ? +count : 5); } - @Post('result') saveResult(@Body() body: { session_id: string; score: number; total: number }) { + + @Get('generate') + generate( + @Query('count') count?: string, + @Query('difficulty') difficulty?: string, + @Query('studied') studied?: string, + ) { + const ids = studied ? studied.split(',').map(Number).filter(Boolean) : undefined; + return this.service.generateQuiz(count ? +count : 10, difficulty, ids); + } + + @Post('result') + saveResult(@Body() body: { session_id: string; score: number; total: number }) { return this.service.saveResult(body.session_id, body.score, body.total); } } diff --git a/src/quiz/quiz.service.ts b/src/quiz/quiz.service.ts index 93df5b6..59d0e22 100644 --- a/src/quiz/quiz.service.ts +++ b/src/quiz/quiz.service.ts @@ -11,18 +11,31 @@ export class QuizService { private wordsService: WordsService, ) {} - async generateQuiz(count = 5) { - const words = await this.wordsService.getQuizWords(count + 12); - const selected = words.slice(0, count); - const pool = words.slice(count); + async generateQuiz(count = 10, difficulty?: string, studiedWordIds?: number[]) { + let pool: any[]; + + if (studiedWordIds && studiedWordIds.length >= 4) { + const studied = await this.wordsService.findByIds(studiedWordIds); + const extra = await this.wordsService.getQuizWords(count + 20, difficulty); + const extraFiltered = extra.filter((w) => !studiedWordIds.includes(w.id)); + pool = [...studied, ...extraFiltered].sort(() => Math.random() - 0.5); + } else { + pool = (await this.wordsService.getQuizWords(count + 20, difficulty)).sort(() => Math.random() - 0.5); + } + + const selected = pool.slice(0, count); + const wrongPool = pool.slice(count); return selected.map((word) => { - const wrong = pool.sort(() => Math.random() - 0.5).slice(0, 3); - const choices = [word.korean, ...wrong.map((w) => w.korean)].sort(() => Math.random() - 0.5); + const wrong = wrongPool.sort(() => Math.random() - 0.5).slice(0, 3); + const choices = [word.korean, ...wrong.map((w: any) => w.korean)].sort(() => Math.random() - 0.5); return { id: word.id, english: word.english, + korean: word.korean, pronunciation: word.pronunciation, + category: word.category, + difficulty: word.difficulty, answer: word.korean, choices, }; diff --git a/src/words/words.service.ts b/src/words/words.service.ts index 3db1bd1..fec5683 100644 --- a/src/words/words.service.ts +++ b/src/words/words.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Repository, In } from 'typeorm'; import { Word } from './entities/word.entity'; @Injectable() @@ -12,17 +12,25 @@ export class WordsService { return this.repo.find(); } - findByCategory(category: string) { - return this.repo.find({ where: { category } }); + findByIds(ids: number[]): Promise { + if (!ids.length) return Promise.resolve([]); + return this.repo.find({ where: { id: In(ids) } }); } async getDailyWords(): Promise { const today = new Date().toISOString().split('T')[0]; const existing = await this.repo.find({ where: { daily_date: today, is_daily: true } }); - if (existing.length >= 5) return existing; + if (existing.length >= 10) return existing.slice(0, 10); + + await this.repo + .createQueryBuilder() + .update(Word) + .set({ is_daily: false, daily_date: null }) + .where('daily_date = :today AND is_daily = true', { today }) + .execute(); const all = await this.repo.find(); - const shuffled = all.sort(() => Math.random() - 0.5).slice(0, 5); + const shuffled = all.sort(() => Math.random() - 0.5).slice(0, 10); for (const w of shuffled) { w.is_daily = true; w.daily_date = today; @@ -30,8 +38,14 @@ export class WordsService { return this.repo.save(shuffled); } - async getQuizWords(count = 10): Promise { - const all = await this.repo.find(); + async getQuizWords(count = 10, difficulty?: string, ids?: number[]): Promise { + let query = this.repo.createQueryBuilder('w'); + if (difficulty) query = query.where('w.difficulty = :difficulty', { difficulty }); + if (ids && ids.length) { + const clause = difficulty ? 'andWhere' : 'where'; + query = query[clause]('w.id IN (:...ids)', { ids }); + } + const all = await query.getMany(); return all.sort(() => Math.random() - 0.5).slice(0, count); }