feat: initial english learning backend

This commit is contained in:
hyoseung 2026-05-12 13:11:19 +09:00
commit ad897dc1e1
27 changed files with 579 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
dist/
*.js.map
.env

5
nest-cli.json Normal file
View File

@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src"
}

29
package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "english-back",
"version": "1.0.0",
"scripts": {
"start": "nest start",
"start:dev": "nest start --watch",
"build": "nest build"
},
"dependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/typeorm": "^10.0.0",
"@nestjs/schedule": "^4.0.0",
"typeorm": "^0.3.0",
"mysql2": "^3.0.0",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"typescript": "^5.0.0",
"ts-node": "^10.9.1",
"@types/node": "^20.0.0"
}
}

36
src/app.module.ts Normal file
View File

@ -0,0 +1,36 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ScheduleModule } from '@nestjs/schedule';
import { WordsModule } from './words/words.module';
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 { 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';
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'mysql',
host: 'localhost',
port: 3306,
username: 'kakao',
password: '486251daKWON@',
database: 'english_study',
entities: [Word, Phrase, GrammarLesson, DailyQuest, QuizResult, Streak],
synchronize: true,
}),
ScheduleModule.forRoot(),
WordsModule,
QuizModule,
QuestModule,
PhrasesModule,
GrammarModule,
],
})
export class AppModule {}

View File

@ -0,0 +1,22 @@
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity('grammar_lessons')
export class GrammarLesson {
@PrimaryGeneratedColumn()
id: number;
@Column()
title: string;
@Column({ type: 'text' })
explanation: string;
@Column({ type: 'json' })
examples: { en: string; ko: string }[];
@Column({ default: 'beginner' })
level: string;
@Column({ default: 1 })
order_num: number;
}

View File

@ -0,0 +1,9 @@
import { Controller, Get, Param } from '@nestjs/common';
import { GrammarService } from './grammar.service';
@Controller('grammar')
export class GrammarController {
constructor(private readonly service: GrammarService) {}
@Get() findAll() { return this.service.findAll(); }
@Get(':id') findOne(@Param('id') id: string) { return this.service.findOne(+id); }
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { GrammarLesson } from './entities/grammar.entity';
import { GrammarController } from './grammar.controller';
import { GrammarService } from './grammar.service';
@Module({
imports: [TypeOrmModule.forFeature([GrammarLesson])],
controllers: [GrammarController],
providers: [GrammarService],
})
export class GrammarModule {}

View File

@ -0,0 +1,11 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { GrammarLesson } from './entities/grammar.entity';
@Injectable()
export class GrammarService {
constructor(@InjectRepository(GrammarLesson) private repo: Repository<GrammarLesson>) {}
findAll() { return this.repo.find({ order: { order_num: 'ASC' } }); }
findOne(id: number) { return this.repo.findOne({ where: { id } }); }
}

11
src/main.ts Normal file
View File

@ -0,0 +1,11 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({ origin: '*' });
app.setGlobalPrefix('api');
await app.listen(3011);
console.log('English Learning API running on port 3011');
}
bootstrap();

View File

@ -0,0 +1,19 @@
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity('phrases')
export class Phrase {
@PrimaryGeneratedColumn()
id: number;
@Column()
english: string;
@Column()
korean: string;
@Column({ default: 'greeting' })
category: string;
@Column({ nullable: true })
situation: string;
}

View File

@ -0,0 +1,10 @@
import { Controller, Get, Query } from '@nestjs/common';
import { PhrasesService } from './phrases.service';
@Controller('phrases')
export class PhrasesController {
constructor(private readonly service: PhrasesService) {}
@Get() findAll(@Query('category') category?: string) { return this.service.findAll(category); }
@Get('categories') getCategories() { return this.service.getCategories(); }
@Get('daily') getDaily() { return this.service.getDailyPhrases(); }
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Phrase } from './entities/phrase.entity';
import { PhrasesController } from './phrases.controller';
import { PhrasesService } from './phrases.service';
@Module({
imports: [TypeOrmModule.forFeature([Phrase])],
controllers: [PhrasesController],
providers: [PhrasesService],
exports: [PhrasesService],
})
export class PhrasesModule {}

View File

@ -0,0 +1,20 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Phrase } from './entities/phrase.entity';
@Injectable()
export class PhrasesService {
constructor(@InjectRepository(Phrase) private repo: Repository<Phrase>) {}
findAll(category?: string) {
if (category) return this.repo.find({ where: { category } });
return this.repo.find();
}
getCategories() {
return this.repo.createQueryBuilder('p').select('DISTINCT p.category', 'category').getRawMany();
}
async getDailyPhrases(): Promise<Phrase[]> {
const all = await this.repo.find();
return all.sort(() => Math.random() - 0.5).slice(0, 3);
}
}

View File

@ -0,0 +1,25 @@
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity('daily_quests')
export class DailyQuest {
@PrimaryGeneratedColumn()
id: number;
@Column()
session_id: string;
@Column({ type: 'date' })
quest_date: string;
@Column({ default: false })
word_done: boolean;
@Column({ default: false })
phrase_done: boolean;
@Column({ default: false })
quiz_done: boolean;
@Column({ nullable: true })
quiz_score: number;
}

View File

@ -0,0 +1,22 @@
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity('streaks')
export class Streak {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
session_id: string;
@Column({ default: 0 })
current_streak: number;
@Column({ default: 0 })
max_streak: number;
@Column({ type: 'date', nullable: true })
last_date: string;
@Column({ type: 'json', nullable: true })
history: string[];
}

View File

@ -0,0 +1,27 @@
import { Controller, Get, Post, Body, Param, Query } from '@nestjs/common';
import { QuestService } from './quest.service';
@Controller('quest')
export class QuestController {
constructor(private readonly service: QuestService) {}
@Get('today')
getToday(@Query('session') session: string) {
return this.service.getOrCreateQuest(session || 'default');
}
@Post('complete')
complete(@Body() body: { session_id: string; type: 'word' | 'phrase' | 'quiz'; score?: number }) {
return this.service.completeQuest(body.session_id, body.type, body.score);
}
@Get('streak')
getStreak(@Query('session') session: string) {
return this.service.getStreak(session || 'default');
}
@Get('history')
getHistory(@Query('session') session: string) {
return this.service.getHistory(session || 'default');
}
}

14
src/quest/quest.module.ts Normal file
View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DailyQuest } from './entities/quest.entity';
import { Streak } from './entities/streak.entity';
import { QuestController } from './quest.controller';
import { QuestService } from './quest.service';
@Module({
imports: [TypeOrmModule.forFeature([DailyQuest, Streak])],
controllers: [QuestController],
providers: [QuestService],
exports: [QuestService],
})
export class QuestModule {}

View File

@ -0,0 +1,72 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DailyQuest } from './entities/quest.entity';
import { Streak } from './entities/streak.entity';
@Injectable()
export class QuestService {
constructor(
@InjectRepository(DailyQuest) private questRepo: Repository<DailyQuest>,
@InjectRepository(Streak) private streakRepo: Repository<Streak>,
) {}
async getOrCreateQuest(session_id: string): Promise<DailyQuest> {
const today = new Date().toISOString().split('T')[0];
let quest = await this.questRepo.findOne({ where: { session_id, quest_date: today } });
if (!quest) {
quest = this.questRepo.create({ session_id, quest_date: today });
await this.questRepo.save(quest);
}
return quest;
}
async completeQuest(session_id: string, type: 'word' | 'phrase' | 'quiz', score?: number): Promise<DailyQuest> {
const quest = await this.getOrCreateQuest(session_id);
if (type === 'word') quest.word_done = true;
if (type === 'phrase') quest.phrase_done = true;
if (type === 'quiz') { quest.quiz_done = true; if (score !== undefined) quest.quiz_score = score; }
await this.questRepo.save(quest);
if (quest.word_done && quest.phrase_done && quest.quiz_done) {
await this.updateStreak(session_id);
}
return quest;
}
async getStreak(session_id: string): Promise<Streak> {
let streak = await this.streakRepo.findOne({ where: { session_id } });
if (!streak) {
streak = this.streakRepo.create({ session_id, history: [] });
await this.streakRepo.save(streak);
}
return streak;
}
async updateStreak(session_id: string): Promise<Streak> {
const today = new Date().toISOString().split('T')[0];
let streak = await this.getStreak(session_id);
if (streak.last_date === today) return streak;
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const yest = yesterday.toISOString().split('T')[0];
if (streak.last_date === yest) {
streak.current_streak += 1;
} else {
streak.current_streak = 1;
}
streak.max_streak = Math.max(streak.max_streak, streak.current_streak);
streak.last_date = today;
if (!streak.history) streak.history = [];
if (!streak.history.includes(today)) streak.history.push(today);
return this.streakRepo.save(streak);
}
async getHistory(session_id: string) {
const quests = await this.questRepo.find({ where: { session_id } });
const streak = await this.getStreak(session_id);
return { quests, streak };
}
}

View File

@ -0,0 +1,19 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
@Entity('quiz_results')
export class QuizResult {
@PrimaryGeneratedColumn()
id: number;
@Column()
session_id: string;
@Column()
score: number;
@Column()
total: number;
@CreateDateColumn()
created_at: Date;
}

View File

@ -0,0 +1,11 @@
import { Controller, Get, Post, Body, Query } from '@nestjs/common';
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 }) {
return this.service.saveResult(body.session_id, body.score, body.total);
}
}

14
src/quiz/quiz.module.ts Normal file
View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { QuizResult } from './entities/quiz-result.entity';
import { QuizController } from './quiz.controller';
import { QuizService } from './quiz.service';
import { WordsModule } from '../words/words.module';
@Module({
imports: [TypeOrmModule.forFeature([QuizResult]), WordsModule],
controllers: [QuizController],
providers: [QuizService],
exports: [QuizService],
})
export class QuizModule {}

35
src/quiz/quiz.service.ts Normal file
View File

@ -0,0 +1,35 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { QuizResult } from './entities/quiz-result.entity';
import { WordsService } from '../words/words.service';
@Injectable()
export class QuizService {
constructor(
@InjectRepository(QuizResult) private repo: Repository<QuizResult>,
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);
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);
return {
id: word.id,
english: word.english,
pronunciation: word.pronunciation,
answer: word.korean,
choices,
};
});
}
saveResult(session_id: string, score: number, total: number) {
return this.repo.save({ session_id, score, total });
}
}

View File

@ -0,0 +1,34 @@
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity('words')
export class Word {
@PrimaryGeneratedColumn()
id: number;
@Column()
english: string;
@Column()
korean: string;
@Column({ nullable: true })
pronunciation: string;
@Column({ default: 'general' })
category: string;
@Column({ default: 'beginner' })
difficulty: string;
@Column({ nullable: true, type: 'text' })
example_en: string;
@Column({ nullable: true, type: 'text' })
example_ko: string;
@Column({ default: false })
is_daily: boolean;
@Column({ type: 'date', nullable: true })
daily_date: string;
}

View File

@ -0,0 +1,27 @@
import { Controller, Get, Query } from '@nestjs/common';
import { WordsService } from './words.service';
@Controller('words')
export class WordsController {
constructor(private readonly service: WordsService) {}
@Get()
findAll(@Query('category') category?: string) {
return this.service.findAll(category);
}
@Get('daily')
getDaily() {
return this.service.getDailyWords();
}
@Get('quiz')
getQuizWords(@Query('count') count?: string) {
return this.service.getQuizWords(count ? +count : 10);
}
@Get('categories')
getCategories() {
return this.service.getCategories();
}
}

13
src/words/words.module.ts Normal file
View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Word } from './entities/word.entity';
import { WordsController } from './words.controller';
import { WordsService } from './words.service';
@Module({
imports: [TypeOrmModule.forFeature([Word])],
controllers: [WordsController],
providers: [WordsService],
exports: [WordsService],
})
export class WordsModule {}

View File

@ -0,0 +1,44 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Word } from './entities/word.entity';
@Injectable()
export class WordsService {
constructor(@InjectRepository(Word) private repo: Repository<Word>) {}
findAll(category?: string) {
if (category) return this.repo.find({ where: { category } });
return this.repo.find();
}
findByCategory(category: string) {
return this.repo.find({ where: { category } });
}
async getDailyWords(): Promise<Word[]> {
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;
const all = await this.repo.find();
const shuffled = all.sort(() => Math.random() - 0.5).slice(0, 5);
for (const w of shuffled) {
w.is_daily = true;
w.daily_date = today;
}
return this.repo.save(shuffled);
}
async getQuizWords(count = 10): Promise<Word[]> {
const all = await this.repo.find();
return all.sort(() => Math.random() - 0.5).slice(0, count);
}
getCategories() {
return this.repo
.createQueryBuilder('w')
.select('DISTINCT w.category', 'category')
.getRawMany();
}
}

21
tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2017",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
}
}