쿠폰 자동 입력 사이트 작성
완성됐습니다. 전체 구조:
---
## 📁 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:
commit
8ea197098d
6
back/.env.example
Normal file
6
back/.env.example
Normal file
@ -0,0 +1,6 @@
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USERNAME=kakao
|
||||
DB_PASSWORD=486251daKWON@
|
||||
DB_DATABASE=kakao
|
||||
APP_PORT=3000
|
||||
6
back/.gitignore
vendored
Normal file
6
back/.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
.cache/
|
||||
*.log
|
||||
.env
|
||||
8
back/nest-cli.json
Normal file
8
back/nest-cli.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
28
back/package.json
Normal file
28
back/package.json
Normal 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
back/src/app.module.ts
Normal file
22
back/src/app.module.ts
Normal 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
back/src/entities/coupon-log.entity.ts
Normal file
19
back/src/entities/coupon-log.entity.ts
Normal 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
back/src/entities/wos-user.entity.ts
Normal file
19
back/src/entities/wos-user.entity.ts
Normal 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
back/src/main.ts
Normal file
17
back/src/main.ts
Normal 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
back/src/wos/wos.controller.ts
Normal file
25
back/src/wos/wos.controller.ts
Normal 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
back/src/wos/wos.module.ts
Normal file
13
back/src/wos/wos.module.ts
Normal 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
back/src/wos/wos.service.ts
Normal file
162
back/src/wos/wos.service.ts
Normal 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
back/tsconfig.json
Normal file
21
back/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
6
front/.gitignore
vendored
Normal file
6
front/.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
.cache/
|
||||
*.log
|
||||
.env
|
||||
12
front/index.html
Normal file
12
front/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WOS 쿠폰 자동 입력</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
19
front/package.json
Normal file
19
front/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
22
front/src/App.vue
Normal file
22
front/src/App.vue
Normal file
@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<HomeView />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import HomeView from './views/HomeView.vue';
|
||||
</script>
|
||||
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: #0f172a;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
color: #f1f5f9;
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
137
front/src/components/CouponInput.vue
Normal file
137
front/src/components/CouponInput.vue
Normal file
@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div class="coupon-input">
|
||||
<div class="captcha-section" v-if="store.captchaImg">
|
||||
<img :src="store.captchaImg" alt="캡차" class="captcha-img" />
|
||||
<div class="captcha-row">
|
||||
<input
|
||||
v-model="store.captchaInput"
|
||||
type="text"
|
||||
placeholder="캡차 입력"
|
||||
class="input captcha-field"
|
||||
@keydown.enter="submit"
|
||||
/>
|
||||
<button class="btn btn-secondary" @click="store.loadCaptcha">새로고침</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="captcha-load" v-else>
|
||||
<button class="btn btn-secondary" @click="store.loadCaptcha" :disabled="!store.fid">
|
||||
캡차 불러오기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
v-model="store.couponCodes"
|
||||
placeholder="쿠폰 코드를 한 줄에 하나씩 입력하세요"
|
||||
class="textarea"
|
||||
rows="6"
|
||||
></textarea>
|
||||
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click="submit"
|
||||
:disabled="store.status === 'loading' || !store.captchaInput || !store.fid"
|
||||
>
|
||||
<span v-if="store.status === 'loading'">처리 중...</span>
|
||||
<span v-else>쿠폰 입력</span>
|
||||
</button>
|
||||
|
||||
<p class="error-msg" v-if="store.errorMsg">{{ store.errorMsg }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useWosStore } from '../stores/wos.store';
|
||||
const store = useWosStore();
|
||||
|
||||
function submit() {
|
||||
store.submitCoupons();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.coupon-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.captcha-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.captcha-img {
|
||||
border-radius: 8px;
|
||||
border: 1px solid #334155;
|
||||
background: #0f172a;
|
||||
}
|
||||
.captcha-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.captcha-field {
|
||||
flex: 1;
|
||||
}
|
||||
.textarea {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
color: #f1f5f9;
|
||||
font-size: 0.95rem;
|
||||
resize: vertical;
|
||||
font-family: monospace;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.textarea:focus {
|
||||
outline: none;
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
.input {
|
||||
padding: 8px 12px;
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
color: #f1f5f9;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #334155;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #475569;
|
||||
}
|
||||
.captcha-load {
|
||||
display: flex;
|
||||
}
|
||||
.error-msg {
|
||||
color: #f87171;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
82
front/src/components/ResultTable.vue
Normal file
82
front/src/components/ResultTable.vue
Normal file
@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="result-table" v-if="results.length > 0">
|
||||
<h3 class="title">입력 결과</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>쿠폰 코드</th>
|
||||
<th>결과</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(r, i) in results" :key="i" :class="r.status">
|
||||
<td>{{ i + 1 }}</td>
|
||||
<td class="code">{{ r.code }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="r.status">{{ r.message }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
results: { code: string; status: string; message: string }[];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.result-table {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.title {
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 10px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
th {
|
||||
background: #1e293b;
|
||||
color: #94a3b8;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
td {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
tr:hover td {
|
||||
background: #1e293b;
|
||||
}
|
||||
.code {
|
||||
font-family: monospace;
|
||||
color: #60a5fa;
|
||||
}
|
||||
.badge {
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge.success {
|
||||
background: #166534;
|
||||
color: #4ade80;
|
||||
}
|
||||
.badge.error {
|
||||
background: #7f1d1d;
|
||||
color: #f87171;
|
||||
}
|
||||
.badge.pending {
|
||||
background: #1e3a5f;
|
||||
color: #93c5fd;
|
||||
}
|
||||
</style>
|
||||
55
front/src/components/UserCard.vue
Normal file
55
front/src/components/UserCard.vue
Normal file
@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="user-card" v-if="player">
|
||||
<img :src="player.avatar_image" alt="아바타" class="avatar" @error="onImgError" />
|
||||
<div class="info">
|
||||
<h3>{{ player.nickname }}</h3>
|
||||
<p>용광로 레벨: <strong>{{ player.stove_lv }}</strong></p>
|
||||
<p>ID: <span class="fid">{{ fid }}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
player: { nickname: string; avatar_image: string; stove_lv: number } | null;
|
||||
fid: string;
|
||||
}>();
|
||||
|
||||
function onImgError(e: Event) {
|
||||
(e.target as HTMLImageElement).src = 'https://via.placeholder.com/64';
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: #1e293b;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #334155;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid #60a5fa;
|
||||
}
|
||||
.info h3 {
|
||||
margin: 0 0 4px;
|
||||
color: #f1f5f9;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.info p {
|
||||
margin: 2px 0;
|
||||
color: #94a3b8;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.fid {
|
||||
color: #60a5fa;
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
7
front/src/main.ts
Normal file
7
front/src/main.ts
Normal file
@ -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');
|
||||
89
front/src/stores/wos.store.ts
Normal file
89
front/src/stores/wos.store.ts
Normal file
@ -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 || '쿠폰 입력 중 오류가 발생했습니다.';
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
118
front/src/views/HomeView.vue
Normal file
118
front/src/views/HomeView.vue
Normal file
@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div class="home">
|
||||
<header>
|
||||
<h1>⚔️ WOS 쿠폰 자동 입력</h1>
|
||||
<p class="subtitle">Whiteout Survival 쿠폰 자동화 시스템</p>
|
||||
</header>
|
||||
|
||||
<div class="card">
|
||||
<div class="search-row">
|
||||
<input
|
||||
v-model="fidInput"
|
||||
type="text"
|
||||
placeholder="FID (플레이어 ID) 입력"
|
||||
class="input"
|
||||
@keydown.enter="searchPlayer"
|
||||
/>
|
||||
<button class="btn btn-primary" @click="searchPlayer" :disabled="store.status === 'loading'">
|
||||
조회
|
||||
</button>
|
||||
</div>
|
||||
<p class="error-msg" v-if="store.status === 'error' && !store.player">{{ store.errorMsg }}</p>
|
||||
</div>
|
||||
|
||||
<UserCard v-if="store.player" :player="store.player" :fid="store.fid" />
|
||||
|
||||
<div class="card" v-if="store.player">
|
||||
<CouponInput />
|
||||
</div>
|
||||
|
||||
<ResultTable :results="store.results" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useWosStore } from '../stores/wos.store';
|
||||
import UserCard from '../components/UserCard.vue';
|
||||
import CouponInput from '../components/CouponInput.vue';
|
||||
import ResultTable from '../components/ResultTable.vue';
|
||||
|
||||
const store = useWosStore();
|
||||
const fidInput = ref('');
|
||||
|
||||
async function searchPlayer() {
|
||||
if (!fidInput.value.trim()) return;
|
||||
await store.searchPlayer(fidInput.value.trim());
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 16px;
|
||||
}
|
||||
header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
header h1 {
|
||||
font-size: 2rem;
|
||||
color: #f1f5f9;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.subtitle {
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
.card {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.search-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.input {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
color: #f1f5f9;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
.error-msg {
|
||||
color: #f87171;
|
||||
font-size: 0.9rem;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
</style>
|
||||
17
front/tsconfig.json
Normal file
17
front/tsconfig.json
Normal file
@ -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"]
|
||||
}
|
||||
15
front/vite.config.ts
Normal file
15
front/vite.config.ts
Normal file
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user