쿠폰 자동 입력 사이트 작성

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

---

## 📁 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
+22
View 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
View 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
View 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
View 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
View 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
View 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
View 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>