commit 8ea197098d829ca23c5e3c860045f9d08fe93021 Author: hyoseung930 <35983843+hyoseung930@users.noreply.github.com> Date: Thu Apr 16 17:25:52 2026 +0900 쿠폰 자동 입력 사이트 작성 완성됐습니다. 전체 구조: --- ## 📁 autoCoupon 프로젝트 구조 ### `autoCoupon/back/` (NestJS) - **[./autoCoupon/back/src/main.ts](./autoCoupon/back/src/main.ts)** — 진입점, CORS 설정, 포트 3000 - **[./autoCoupon/back/src/app.module.ts](./autoCoupon/back/src/app.module.ts)** — TypeORM MySQL 연결 (`kakao` DB) - **[./autoCoupon/back/src/wos/wos.service.ts](./autoCoupon/back/src/wos/wos.service.ts)** — MD5 sign 생성, 세션(쿠키) 메모리 저장, 쿠폰 순차 입력(1초 딜레이) - **[./autoCoupon/back/src/wos/wos.controller.ts](./autoCoupon/back/src/wos/wos.controller.ts)** — `POST /api/player`, `/api/captcha`, `/api/redeem` - **[./autoCoupon/back/src/entities/](./autoCoupon/back/src/entities/)** — `wos_users`, `coupon_logs` 테이블 (TypeORM synchronize: true로 자동 생성) ### `autoCoupon/front/` (Vue 3 + Pinia) - **[./autoCoupon/front/src/stores/wos.store.ts](./autoCoupon/front/src/stores/wos.store.ts)** — Pinia store (플레이어 조회, 캡차, 쿠폰 제출) - **[./autoCoupon/front/src/components/UserCard.vue](./autoCoupon/front/src/components/UserCard.vue)** — 닉네임, 용광로 레벨, 아바타 카드 - **[./autoCoupon/front/src/components/CouponInput.vue](./autoCoupon/front/src/components/CouponInput.vue)** — 캡차 + 쿠폰 일괄 입력 (줄바꿈 구분) - **[./autoCoupon/front/src/components/ResultTable.vue](./autoCoupon/front/src/components/ResultTable.vue)** — 쿠폰별 성공/실패 결과 테이블 --- ## 시작 방법 **백엔드:** ```bash cd autoCoupon/back cp .env.example .env npm install npm run start:dev ``` **프론트엔드:** ```bash cd autoCoupon/front npm install npm run dev ``` > **배포 시** Nginx에서 `/` → Vue `dist/`, `/api` → `localhost:3000` 으로 프록시 설정하면 됩니다. diff --git a/back/.env.example b/back/.env.example new file mode 100644 index 0000000..d0c6fef --- /dev/null +++ b/back/.env.example @@ -0,0 +1,6 @@ +DB_HOST=localhost +DB_PORT=3306 +DB_USERNAME=kakao +DB_PASSWORD=486251daKWON@ +DB_DATABASE=kakao +APP_PORT=3000 diff --git a/back/.gitignore b/back/.gitignore new file mode 100644 index 0000000..8e23a73 --- /dev/null +++ b/back/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +build/ +.cache/ +*.log +.env diff --git a/back/nest-cli.json b/back/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/back/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/back/package.json b/back/package.json new file mode 100644 index 0000000..5a5594b --- /dev/null +++ b/back/package.json @@ -0,0 +1,28 @@ +{ + "name": "auto-coupon-back", + "version": "1.0.0", + "description": "WOS 쿠폰 자동 입력 NestJS 백엔드", + "scripts": { + "build": "nest build", + "start": "nest start", + "start:dev": "nest start --watch", + "start:prod": "node dist/main" + }, + "dependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0", + "@nestjs/platform-express": "^10.0.0", + "@nestjs/typeorm": "^10.0.0", + "typeorm": "^0.3.0", + "mysql2": "^3.0.0", + "axios": "^1.6.0", + "reflect-metadata": "^0.1.13", + "rxjs": "^7.8.0" + }, + "devDependencies": { + "@nestjs/cli": "^10.0.0", + "@types/node": "^20.0.0", + "typescript": "^5.0.0", + "ts-node": "^10.9.0" + } +} diff --git a/back/src/app.module.ts b/back/src/app.module.ts new file mode 100644 index 0000000..c49f697 --- /dev/null +++ b/back/src/app.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { WosModule } from './wos/wos.module'; +import { WosUser } from './entities/wos-user.entity'; +import { CouponLog } from './entities/coupon-log.entity'; + +@Module({ + imports: [ + TypeOrmModule.forRoot({ + type: 'mysql', + host: process.env.DB_HOST || 'localhost', + port: Number(process.env.DB_PORT) || 3306, + username: process.env.DB_USERNAME || 'kakao', + password: process.env.DB_PASSWORD || '486251daKWON@', + database: process.env.DB_DATABASE || 'kakao', + entities: [WosUser, CouponLog], + synchronize: true, + }), + WosModule, + ], +}) +export class AppModule {} diff --git a/back/src/entities/coupon-log.entity.ts b/back/src/entities/coupon-log.entity.ts new file mode 100644 index 0000000..f2cc2b2 --- /dev/null +++ b/back/src/entities/coupon-log.entity.ts @@ -0,0 +1,19 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm'; + +@Entity('coupon_logs') +export class CouponLog { + @PrimaryGeneratedColumn() + id: number; + + @Column({ length: 20 }) + fid: string; + + @Column({ length: 50 }) + coupon_code: string; + + @Column({ length: 100, nullable: true }) + result_msg: string; + + @CreateDateColumn() + executed_at: Date; +} diff --git a/back/src/entities/wos-user.entity.ts b/back/src/entities/wos-user.entity.ts new file mode 100644 index 0000000..c40f9a7 --- /dev/null +++ b/back/src/entities/wos-user.entity.ts @@ -0,0 +1,19 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm'; + +@Entity('wos_users') +export class WosUser { + @PrimaryGeneratedColumn() + id: number; + + @Column({ unique: true, length: 20 }) + fid: string; + + @Column({ length: 50, nullable: true }) + nickname: string; + + @Column({ type: 'text', nullable: true }) + avatar_url: string; + + @CreateDateColumn() + created_at: Date; +} diff --git a/back/src/main.ts b/back/src/main.ts new file mode 100644 index 0000000..f99cb60 --- /dev/null +++ b/back/src/main.ts @@ -0,0 +1,17 @@ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + app.enableCors({ + origin: '*', + methods: ['GET', 'POST'], + allowedHeaders: ['Content-Type'], + }); + + const port = process.env.APP_PORT || 3000; + await app.listen(port); + console.log(`Server running on port ${port}`); +} +bootstrap(); diff --git a/back/src/wos/wos.controller.ts b/back/src/wos/wos.controller.ts new file mode 100644 index 0000000..db7154f --- /dev/null +++ b/back/src/wos/wos.controller.ts @@ -0,0 +1,25 @@ +import { Controller, Post, Body, HttpCode } from '@nestjs/common'; +import { WosService } from './wos.service'; + +@Controller('api') +export class WosController { + constructor(private readonly wosService: WosService) {} + + @Post('player') + @HttpCode(200) + async getPlayer(@Body() body: { fid: string }) { + return this.wosService.getPlayer(body.fid); + } + + @Post('captcha') + @HttpCode(200) + async getCaptcha(@Body() body: { fid: string }) { + return this.wosService.getCaptcha(body.fid); + } + + @Post('redeem') + @HttpCode(200) + async redeem(@Body() body: { fid: string; codes: string[]; captcha: string }) { + return this.wosService.redeemMultiple(body.fid, body.codes, body.captcha); + } +} diff --git a/back/src/wos/wos.module.ts b/back/src/wos/wos.module.ts new file mode 100644 index 0000000..b4f0147 --- /dev/null +++ b/back/src/wos/wos.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { WosController } from './wos.controller'; +import { WosService } from './wos.service'; +import { WosUser } from '../entities/wos-user.entity'; +import { CouponLog } from '../entities/coupon-log.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([WosUser, CouponLog])], + controllers: [WosController], + providers: [WosService], +}) +export class WosModule {} diff --git a/back/src/wos/wos.service.ts b/back/src/wos/wos.service.ts new file mode 100644 index 0000000..a649e0d --- /dev/null +++ b/back/src/wos/wos.service.ts @@ -0,0 +1,162 @@ +import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import axios from 'axios'; +import * as crypto from 'crypto'; +import { WosUser } from '../entities/wos-user.entity'; +import { CouponLog } from '../entities/coupon-log.entity'; + +const SECRET = 'tB87#kPtkxqOS2'; +const WOS_API_BASE = 'https://wos-giftcode-api.centurygame.com/api'; + +const ERR_MESSAGES: Record = { + 20000: '쿠폰 코드가 존재하지 않습니다.', + 20001: '이미 사용된 쿠폰입니다.', + 20002: '만료된 쿠폰입니다.', + 20003: '캡차 인증에 실패했습니다.', + 40014: '캡차 세션이 만료되었습니다. 다시 시도해주세요.', + 40004: '캡차 입력이 올바르지 않습니다.', +}; + +@Injectable() +export class WosService { + private sessionStore: Map = new Map(); + + constructor( + @InjectRepository(WosUser) + private wosUserRepo: Repository, + @InjectRepository(CouponLog) + private couponLogRepo: Repository, + ) {} + + private makeSign(fid: string, timestamp: number): string { + const raw = `fid=${fid}&time=${timestamp}${SECRET}`; + return crypto.createHash('md5').update(raw).digest('hex'); + } + + async getPlayer(fid: string) { + const timestamp = Date.now(); + const sign = this.makeSign(fid, timestamp); + + const response = await axios.post( + `${WOS_API_BASE}/player`, + new URLSearchParams({ fid, time: String(timestamp), sign }), + { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }, + ); + + const data = response.data; + if (data.code !== 0) { + throw new HttpException( + ERR_MESSAGES[data.err_code] || `오류가 발생했습니다. (${data.err_code})`, + HttpStatus.BAD_REQUEST, + ); + } + + const playerInfo = data.data; + + await this.wosUserRepo.upsert( + { + fid, + nickname: playerInfo.nickname, + avatar_url: playerInfo.avatar_image, + }, + ['fid'], + ); + + return playerInfo; + } + + async getCaptcha(fid: string) { + const timestamp = Date.now(); + const sign = this.makeSign(fid, timestamp); + + const response = await axios.post( + `${WOS_API_BASE}/captcha`, + new URLSearchParams({ fid, time: String(timestamp), sign }), + { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + withCredentials: true, + }, + ); + + const setCookie = response.headers['set-cookie']; + if (setCookie) { + const sessionCookie = setCookie.find((c: string) => c.includes('session')); + if (sessionCookie) { + const sessionValue = sessionCookie.split(';')[0]; + this.sessionStore.set(fid, sessionValue); + } + } + + const data = response.data; + if (data.code !== 0) { + throw new HttpException( + ERR_MESSAGES[data.err_code] || `캡차 로드 실패 (${data.err_code})`, + HttpStatus.BAD_REQUEST, + ); + } + + return { img: data.data?.img, captcha_id: data.data?.captcha_id }; + } + + async redeemCoupon(fid: string, couponCode: string, captcha: string) { + const timestamp = Date.now(); + const sign = this.makeSign(fid, timestamp); + const sessionCookie = this.sessionStore.get(fid) || ''; + + const response = await axios.post( + `${WOS_API_BASE}/gift_code`, + new URLSearchParams({ + fid, + time: String(timestamp), + sign, + cdk: couponCode, + validate: captcha, + }), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Cookie: sessionCookie, + }, + }, + ); + + const data = response.data; + const resultMsg = + data.code === 0 + ? '성공' + : ERR_MESSAGES[data.err_code] || `실패 (err_code: ${data.err_code})`; + + await this.couponLogRepo.save({ + fid, + coupon_code: couponCode, + result_msg: resultMsg, + }); + + if (data.code !== 0) { + throw new HttpException(resultMsg, HttpStatus.BAD_REQUEST); + } + + return { message: resultMsg }; + } + + async redeemMultiple(fid: string, codes: string[], captcha: string) { + const results: { code: string; status: string; message: string }[] = []; + + for (const code of codes) { + try { + await this.redeemCoupon(fid, code.trim(), captcha); + results.push({ code, status: 'success', message: '성공' }); + } catch (err: any) { + results.push({ + code, + status: 'error', + message: err.message || '실패', + }); + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + + return results; + } +} diff --git a/back/tsconfig.json b/back/tsconfig.json new file mode 100644 index 0000000..95f5641 --- /dev/null +++ b/back/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": false, + "noImplicitAny": false, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false + } +} diff --git a/front/.gitignore b/front/.gitignore new file mode 100644 index 0000000..8e23a73 --- /dev/null +++ b/front/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +build/ +.cache/ +*.log +.env diff --git a/front/index.html b/front/index.html new file mode 100644 index 0000000..a15e480 --- /dev/null +++ b/front/index.html @@ -0,0 +1,12 @@ + + + + + + WOS 쿠폰 자동 입력 + + +
+ + + diff --git a/front/package.json b/front/package.json new file mode 100644 index 0000000..69ae9c8 --- /dev/null +++ b/front/package.json @@ -0,0 +1,19 @@ +{ + "name": "auto-coupon-front", + "version": "1.0.0", + "description": "WOS 쿠폰 자동 입력 Vue3 프론트엔드", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.4.0", + "pinia": "^2.1.0", + "axios": "^1.6.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.0.0" + } +} diff --git a/front/src/App.vue b/front/src/App.vue new file mode 100644 index 0000000..586d8d9 --- /dev/null +++ b/front/src/App.vue @@ -0,0 +1,22 @@ + + + + + diff --git a/front/src/components/CouponInput.vue b/front/src/components/CouponInput.vue new file mode 100644 index 0000000..2534648 --- /dev/null +++ b/front/src/components/CouponInput.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/front/src/components/ResultTable.vue b/front/src/components/ResultTable.vue new file mode 100644 index 0000000..6a2c27d --- /dev/null +++ b/front/src/components/ResultTable.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/front/src/components/UserCard.vue b/front/src/components/UserCard.vue new file mode 100644 index 0000000..817cf46 --- /dev/null +++ b/front/src/components/UserCard.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/front/src/main.ts b/front/src/main.ts new file mode 100644 index 0000000..56a060d --- /dev/null +++ b/front/src/main.ts @@ -0,0 +1,7 @@ +import { createApp } from 'vue'; +import { createPinia } from 'pinia'; +import App from './App.vue'; + +const app = createApp(App); +app.use(createPinia()); +app.mount('#app'); diff --git a/front/src/stores/wos.store.ts b/front/src/stores/wos.store.ts new file mode 100644 index 0000000..924dc15 --- /dev/null +++ b/front/src/stores/wos.store.ts @@ -0,0 +1,89 @@ +import { defineStore } from 'pinia'; +import axios from 'axios'; + +interface PlayerInfo { + nickname: string; + avatar_image: string; + stove_lv: number; + kid: string; +} + +interface RedeemResult { + code: string; + status: 'success' | 'error' | 'pending'; + message: string; +} + +export const useWosStore = defineStore('wos', { + state: () => ({ + fid: '', + player: null as PlayerInfo | null, + captchaImg: '', + captchaInput: '', + couponCodes: '', + results: [] as RedeemResult[], + status: 'idle' as 'idle' | 'loading' | 'success' | 'error', + errorMsg: '', + }), + + actions: { + async searchPlayer(fid: string) { + this.status = 'loading'; + this.errorMsg = ''; + try { + const { data } = await axios.post('/api/player', { fid }); + this.player = data; + this.fid = fid; + this.status = 'success'; + } catch (err: any) { + this.player = null; + this.status = 'error'; + this.errorMsg = err.response?.data?.message || '유저를 찾을 수 없습니다.'; + } + }, + + async loadCaptcha() { + if (!this.fid) return; + try { + const { data } = await axios.post('/api/captcha', { fid: this.fid }); + this.captchaImg = data.img ? `data:image/png;base64,${data.img}` : ''; + this.captchaInput = ''; + } catch (err: any) { + this.errorMsg = err.response?.data?.message || '캡차 로드 실패'; + } + }, + + async submitCoupons() { + if (!this.fid || !this.captchaInput) return; + const codes = this.couponCodes + .split('\n') + .map((c) => c.trim()) + .filter((c) => c.length > 0); + + if (codes.length === 0) { + this.errorMsg = '쿠폰 코드를 입력해주세요.'; + return; + } + + this.results = codes.map((code) => ({ code, status: 'pending', message: '처리 중...' })); + this.status = 'loading'; + this.errorMsg = ''; + + try { + const { data } = await axios.post('/api/redeem', { + fid: this.fid, + codes, + captcha: this.captchaInput, + }); + + this.results = data; + this.status = 'success'; + this.captchaInput = ''; + this.captchaImg = ''; + } catch (err: any) { + this.status = 'error'; + this.errorMsg = err.response?.data?.message || '쿠폰 입력 중 오류가 발생했습니다.'; + } + }, + }, +}); diff --git a/front/src/views/HomeView.vue b/front/src/views/HomeView.vue new file mode 100644 index 0000000..2c42c5e --- /dev/null +++ b/front/src/views/HomeView.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/front/tsconfig.json b/front/tsconfig.json new file mode 100644 index 0000000..aadd2fa --- /dev/null +++ b/front/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "strict": true + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/front/vite.config.ts b/front/vite.config.ts new file mode 100644 index 0000000..6ac7a7d --- /dev/null +++ b/front/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; + +export default defineConfig({ + plugins: [vue()], + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:3000', + changeOrigin: true, + }, + }, + }, +});