feat: XP/레벨/뱃지/콤보 시스템 + Progress 모듈 + 오늘의단어10개 + 퀴즈 난이도/학습단어기반
Some checks are pending
Build and Deploy / build-and-deploy (push) Waiting to run

This commit is contained in:
hyoseung930 2026-05-14 13:12:47 +09:00
parent 6c5feccebf
commit 2549334adb
9 changed files with 316 additions and 16 deletions

View File

@ -6,12 +6,15 @@ import { QuizModule } from './quiz/quiz.module';
import { QuestModule } from './quest/quest.module'; import { QuestModule } from './quest/quest.module';
import { PhrasesModule } from './phrases/phrases.module'; import { PhrasesModule } from './phrases/phrases.module';
import { GrammarModule } from './grammar/grammar.module'; import { GrammarModule } from './grammar/grammar.module';
import { ProgressModule } from './progress/progress.module';
import { Word } from './words/entities/word.entity'; import { Word } from './words/entities/word.entity';
import { Phrase } from './phrases/entities/phrase.entity'; import { Phrase } from './phrases/entities/phrase.entity';
import { GrammarLesson } from './grammar/entities/grammar.entity'; import { GrammarLesson } from './grammar/entities/grammar.entity';
import { DailyQuest } from './quest/entities/quest.entity'; import { DailyQuest } from './quest/entities/quest.entity';
import { QuizResult } from './quiz/entities/quiz-result.entity'; import { QuizResult } from './quiz/entities/quiz-result.entity';
import { Streak } from './quest/entities/streak.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({ @Module({
imports: [ imports: [
@ -22,7 +25,7 @@ import { Streak } from './quest/entities/streak.entity';
username: 'kakao', username: 'kakao',
password: '486251daKWON@', password: '486251daKWON@',
database: 'english_study', database: 'english_study',
entities: [Word, Phrase, GrammarLesson, DailyQuest, QuizResult, Streak], entities: [Word, Phrase, GrammarLesson, DailyQuest, QuizResult, Streak, UserProgress, StudiedWord],
synchronize: true, synchronize: true,
}), }),
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
@ -31,6 +34,7 @@ import { Streak } from './quest/entities/streak.entity';
QuestModule, QuestModule,
PhrasesModule, PhrasesModule,
GrammarModule, GrammarModule,
ProgressModule,
], ],
}) })
export class AppModule {} export class AppModule {}

View File

@ -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;
}

View File

@ -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 }[];
};
}

View File

@ -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);
}
}

View File

@ -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 {}

View File

@ -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<string, { id: string; name: string; emoji: string; desc: string }> = {
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<UserProgress>,
@InjectRepository(StudiedWord) private studiedRepo: Repository<StudiedWord>,
) {}
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<UserProgress> {
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<UserProgress> {
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<number[]> {
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,
};
}
}

View File

@ -4,8 +4,19 @@ import { QuizService } from './quiz.service';
@Controller('quiz') @Controller('quiz')
export class QuizController { export class QuizController {
constructor(private readonly service: QuizService) {} 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); return this.service.saveResult(body.session_id, body.score, body.total);
} }
} }

View File

@ -11,18 +11,31 @@ export class QuizService {
private wordsService: WordsService, private wordsService: WordsService,
) {} ) {}
async generateQuiz(count = 5) { async generateQuiz(count = 10, difficulty?: string, studiedWordIds?: number[]) {
const words = await this.wordsService.getQuizWords(count + 12); let pool: any[];
const selected = words.slice(0, count);
const pool = words.slice(count); 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) => { return selected.map((word) => {
const wrong = pool.sort(() => Math.random() - 0.5).slice(0, 3); const wrong = wrongPool.sort(() => Math.random() - 0.5).slice(0, 3);
const choices = [word.korean, ...wrong.map((w) => w.korean)].sort(() => Math.random() - 0.5); const choices = [word.korean, ...wrong.map((w: any) => w.korean)].sort(() => Math.random() - 0.5);
return { return {
id: word.id, id: word.id,
english: word.english, english: word.english,
korean: word.korean,
pronunciation: word.pronunciation, pronunciation: word.pronunciation,
category: word.category,
difficulty: word.difficulty,
answer: word.korean, answer: word.korean,
choices, choices,
}; };

View File

@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository, In } from 'typeorm';
import { Word } from './entities/word.entity'; import { Word } from './entities/word.entity';
@Injectable() @Injectable()
@ -12,17 +12,25 @@ export class WordsService {
return this.repo.find(); return this.repo.find();
} }
findByCategory(category: string) { findByIds(ids: number[]): Promise<Word[]> {
return this.repo.find({ where: { category } }); if (!ids.length) return Promise.resolve([]);
return this.repo.find({ where: { id: In(ids) } });
} }
async getDailyWords(): Promise<Word[]> { async getDailyWords(): Promise<Word[]> {
const today = new Date().toISOString().split('T')[0]; const today = new Date().toISOString().split('T')[0];
const existing = await this.repo.find({ where: { daily_date: today, is_daily: true } }); 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 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) { for (const w of shuffled) {
w.is_daily = true; w.is_daily = true;
w.daily_date = today; w.daily_date = today;
@ -30,8 +38,14 @@ export class WordsService {
return this.repo.save(shuffled); return this.repo.save(shuffled);
} }
async getQuizWords(count = 10): Promise<Word[]> { async getQuizWords(count = 10, difficulty?: string, ids?: number[]): Promise<Word[]> {
const all = await this.repo.find(); 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); return all.sort(() => Math.random() - 0.5).slice(0, count);
} }