쿠폰 자동 입력 사이트 작성

완성됐습니다. 전체 구조:

---

## 📁 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` 으로 프록시 설정하면 됩니다.
This commit is contained in:
hyoseung930
2026-04-16 17:25:52 +09:00
commit 8ea197098d
24 changed files with 925 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
DB_HOST=localhost
DB_PORT=3306
DB_USERNAME=kakao
DB_PASSWORD=486251daKWON@
DB_DATABASE=kakao
APP_PORT=3000
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
build/
.cache/
*.log
.env
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+28
View File
@@ -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"
}
}
+22
View File
@@ -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 {}
+19
View File
@@ -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;
}
+19
View File
@@ -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;
}
+17
View File
@@ -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();
+25
View File
@@ -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);
}
}
+13
View File
@@ -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 {}
+162
View File
@@ -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<number, string> = {
20000: '쿠폰 코드가 존재하지 않습니다.',
20001: '이미 사용된 쿠폰입니다.',
20002: '만료된 쿠폰입니다.',
20003: '캡차 인증에 실패했습니다.',
40014: '캡차 세션이 만료되었습니다. 다시 시도해주세요.',
40004: '캡차 입력이 올바르지 않습니다.',
};
@Injectable()
export class WosService {
private sessionStore: Map<string, string> = new Map();
constructor(
@InjectRepository(WosUser)
private wosUserRepo: Repository<WosUser>,
@InjectRepository(CouponLog)
private couponLogRepo: Repository<CouponLog>,
) {}
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;
}
}
+21
View File
@@ -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
}
}