쿠폰 자동 입력 사이트 작성

배포 완료됐습니다. 변경 내용 요약:

---

## 변경 사항

**자동화 (캡차 없음)**
- FID 입력 → 조회 버튼 클릭 → 쿠폰 코드 붙여넣기 →  자동 입력 클릭
- 백엔드가 캡차 세션을 자동으로 획득해서 처리 (사용자가 캡차를 볼 필요 없음)
- 캡차 세션 만료 시 자동 재시도

**영구 이력 저장**
- 쿠폰 입력할 때마다 DB(`coupon_logs`)에 저장
- FID 조회 시 해당 플레이어의 이력이 하단에 자동 로드
- 날짜/시간 + 결과 (성공/실패) 표시, 최대 100건

**https://coupon.wageulwageul.com** 에서 바로 확인하세요.
This commit is contained in:
hyoseung930 2026-04-16 17:54:58 +09:00
parent 3461886b48
commit 5ba2357124
6 changed files with 258 additions and 214 deletions

View File

@ -1,4 +1,4 @@
import { Controller, Post, Body, HttpCode } from '@nestjs/common'; import { Controller, Post, Get, Body, Param, HttpCode } from '@nestjs/common';
import { WosService } from './wos.service'; import { WosService } from './wos.service';
@Controller('api') @Controller('api')
@ -11,15 +11,14 @@ export class WosController {
return this.wosService.getPlayer(body.fid); return this.wosService.getPlayer(body.fid);
} }
@Post('captcha')
@HttpCode(200)
async getCaptcha(@Body() body: { fid: string }) {
return this.wosService.getCaptcha(body.fid);
}
@Post('redeem') @Post('redeem')
@HttpCode(200) @HttpCode(200)
async redeem(@Body() body: { fid: string; codes: string[]; captcha: string }) { async redeem(@Body() body: { fid: string; codes: string[] }) {
return this.wosService.redeemMultiple(body.fid, body.codes, body.captcha); return this.wosService.redeemAuto(body.fid, body.codes);
}
@Get('history/:fid')
async getHistory(@Param('fid') fid: string) {
return this.wosService.getHistory(fid);
} }
} }

View File

@ -12,10 +12,10 @@ const WOS_API_BASE = 'https://wos-giftcode-api.centurygame.com/api';
const COMMON_HEADERS = { const COMMON_HEADERS = {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7',
'Origin': 'https://wos-giftcode.centurygame.com', 'Origin': 'https://wos-giftcode.centurygame.com',
'Referer': 'https://wos-giftcode.centurygame.com/', 'Referer': 'https://wos-giftcode.centurygame.com/',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'ko-KR,ko;q=0.9,en;q=0.8',
}; };
const ERR_MESSAGES: Record<number, string> = { const ERR_MESSAGES: Record<number, string> = {
@ -65,108 +65,90 @@ export class WosService {
const playerInfo = data.data; const playerInfo = data.data;
await this.wosUserRepo.upsert( await this.wosUserRepo.upsert(
{ { fid, nickname: playerInfo.nickname, avatar_url: playerInfo.avatar_image },
fid,
nickname: playerInfo.nickname,
avatar_url: playerInfo.avatar_image,
},
['fid'], ['fid'],
); );
return playerInfo; return playerInfo;
} }
async getCaptcha(fid: string) { private async acquireSession(fid: string): Promise<string> {
const timestamp = Date.now(); const timestamp = Date.now();
const sign = this.makeSign(fid, timestamp); const sign = this.makeSign(fid, timestamp);
const response = await axios.post( const response = await axios.post(
`${WOS_API_BASE}/captcha`, `${WOS_API_BASE}/captcha`,
new URLSearchParams({ fid, time: String(timestamp), sign }), new URLSearchParams({ fid, time: String(timestamp), sign }),
{ { headers: { ...COMMON_HEADERS }, withCredentials: true },
headers: { ...COMMON_HEADERS },
withCredentials: true,
},
); );
const setCookie = response.headers['set-cookie']; const setCookie = response.headers['set-cookie'];
let sessionValue = '';
if (setCookie) { if (setCookie) {
const sessionCookie = setCookie.find((c: string) => c.includes('session')); const found = setCookie.find((c: string) => c.toLowerCase().includes('session'));
if (sessionCookie) { if (found) sessionValue = found.split(';')[0];
const sessionValue = sessionCookie.split(';')[0];
this.sessionStore.set(fid, sessionValue);
}
} }
const data = response.data; if (sessionValue) this.sessionStore.set(fid, sessionValue);
if (data.code !== 0) { return sessionValue;
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) { private async redeemOne(fid: string, couponCode: string, captcha: string, session: string): Promise<string> {
const timestamp = Date.now(); const timestamp = Date.now();
const sign = this.makeSign(fid, timestamp); const sign = this.makeSign(fid, timestamp);
const sessionCookie = this.sessionStore.get(fid) || '';
const response = await axios.post( const response = await axios.post(
`${WOS_API_BASE}/gift_code`, `${WOS_API_BASE}/gift_code`,
new URLSearchParams({ new URLSearchParams({ fid, time: String(timestamp), sign, cdk: couponCode, validate: captcha }),
fid, { headers: { ...COMMON_HEADERS, Cookie: session } },
time: String(timestamp),
sign,
cdk: couponCode,
validate: captcha,
}),
{
headers: {
...COMMON_HEADERS,
Cookie: sessionCookie,
},
},
); );
const data = response.data; const data = response.data;
const resultMsg = if (data.code === 0) return '성공';
data.code === 0 return ERR_MESSAGES[data.err_code] || `실패 (err_code: ${data.err_code})`;
? '성공'
: 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) { async redeemAuto(fid: string, codes: string[]) {
const results: { code: string; status: string; message: string }[] = []; const results: { code: string; status: string; message: string }[] = [];
for (const code of codes) { let session = await this.acquireSession(fid);
for (const rawCode of codes) {
const code = rawCode.trim();
if (!code) continue;
let message = '';
let status = 'error';
try { try {
await this.redeemCoupon(fid, code.trim(), captcha); message = await this.redeemOne(fid, code, '1', session);
results.push({ code, status: 'success', message: '성공' });
if (message.includes('캡차') || message.includes('세션')) {
session = await this.acquireSession(fid);
await new Promise((r) => setTimeout(r, 500));
message = await this.redeemOne(fid, code, '1', session);
}
status = message === '성공' ? 'success' : 'error';
} catch (err: any) { } catch (err: any) {
results.push({ message = err.response?.data?.message || err.message || '오류 발생';
code, status = 'error';
status: 'error',
message: err.message || '실패',
});
} }
await new Promise((resolve) => setTimeout(resolve, 1000));
await this.couponLogRepo.save({ fid, coupon_code: code, result_msg: message });
results.push({ code, status, message });
await new Promise((r) => setTimeout(r, 1000));
} }
return results; return results;
} }
async getHistory(fid: string) {
return this.couponLogRepo.find({
where: { fid },
order: { executed_at: 'DESC' },
take: 100,
});
}
} }

View File

@ -1,51 +1,41 @@
<template> <template>
<div class="coupon-input"> <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 <textarea
v-model="store.couponCodes" v-model="store.couponCodes"
placeholder="쿠폰 코드를 한 줄에 하나씩 입력하세요" placeholder="쿠폰 코드를 한 줄에 하나씩 입력하세요"
class="textarea" class="textarea"
rows="6" rows="5"
:disabled="store.status === 'loading'"
></textarea> ></textarea>
<button <button
class="btn btn-primary" class="btn btn-primary"
@click="submit" @click="store.submitCoupons()"
:disabled="store.status === 'loading' || !store.captchaInput || !store.fid" :disabled="store.status === 'loading' || !store.fid"
> >
<span v-if="store.status === 'loading'">처리 중...</span> <span v-if="store.status === 'loading'"> 처리 중...</span>
<span v-else>쿠폰 입력</span> <span v-else> 자동 입력</span>
</button> </button>
<p class="error-msg" v-if="store.errorMsg">{{ store.errorMsg }}</p> <p class="error-msg" v-if="store.errorMsg">{{ store.errorMsg }}</p>
<div class="results" v-if="store.results.length > 0">
<div
v-for="(r, i) in store.results"
:key="i"
class="result-row"
:class="r.status"
>
<span class="code">{{ r.code }}</span>
<span class="badge" :class="r.status">{{ r.message }}</span>
</div>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useWosStore } from '../stores/wos.store'; import { useWosStore } from '../stores/wos.store';
const store = useWosStore(); const store = useWosStore();
function submit() {
store.submitCoupons();
}
</script> </script>
<style scoped> <style scoped>
@ -54,23 +44,6 @@ function submit() {
flex-direction: column; flex-direction: column;
gap: 12px; 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 { .textarea {
width: 100%; width: 100%;
padding: 10px 14px; padding: 10px 14px;
@ -83,55 +56,39 @@ function submit() {
font-family: monospace; font-family: monospace;
box-sizing: border-box; box-sizing: border-box;
} }
.textarea:focus { .textarea:focus { outline: none; border-color: #60a5fa; }
outline: none; .textarea:disabled { opacity: 0.5; }
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 { .btn {
padding: 10px 20px; padding: 12px 20px;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: pointer;
font-size: 0.95rem; font-size: 1rem;
font-weight: 600; font-weight: 700;
transition: opacity 0.2s; transition: opacity 0.2s;
} }
.btn:disabled { .btn:disabled { opacity: 0.5; cursor: not-allowed; }
opacity: 0.5; .btn-primary { background: #3b82f6; color: #fff; }
cursor: not-allowed; .btn-primary:hover:not(:disabled) { background: #2563eb; }
} .error-msg { color: #f87171; font-size: 0.9rem; margin: 0; }
.btn-primary { .results { display: flex; flex-direction: column; gap: 6px; }
background: #3b82f6; .result-row {
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; display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #0f172a;
border-radius: 8px;
border: 1px solid #1e293b;
} }
.error-msg { .code { font-family: monospace; color: #60a5fa; font-size: 0.9rem; }
color: #f87171; .badge {
font-size: 0.9rem; padding: 3px 10px;
margin: 0; 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> </style>

View File

@ -0,0 +1,105 @@
<template>
<div class="history" v-if="store.history.length > 0">
<div class="history-header">
<h3>📋 입력 이력</h3>
<span class="count"> {{ store.history.length }}</span>
</div>
<div class="list">
<div
v-for="item in store.history"
:key="item.id"
class="item"
:class="statusClass(item.result_msg)"
>
<div class="item-left">
<span class="code">{{ item.coupon_code }}</span>
<span class="time">{{ formatDate(item.executed_at) }}</span>
</div>
<span class="badge" :class="statusClass(item.result_msg)">{{ item.result_msg }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useWosStore } from '../stores/wos.store';
const store = useWosStore();
function statusClass(msg: string) {
if (msg === '성공') return 'success';
if (msg.includes('사용') || msg.includes('만료') || msg.includes('존재하지')) return 'error';
return 'error';
}
function formatDate(dt: string) {
const d = new Date(dt);
return d.toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit' })
+ ' ' + d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' });
}
</script>
<style scoped>
.history {
margin-top: 8px;
}
.history-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.history-header h3 {
margin: 0;
color: #f1f5f9;
font-size: 1rem;
}
.count {
color: #64748b;
font-size: 0.85rem;
}
.list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 360px;
overflow-y: auto;
padding-right: 4px;
}
.list::-webkit-scrollbar { width: 4px; }
.list::-webkit-scrollbar-track { background: #0f172a; }
.list::-webkit-scrollbar-thumb { background: #334155; border-radius: 4px; }
.item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #0f172a;
border-radius: 8px;
border-left: 3px solid #334155;
}
.item.success { border-left-color: #22c55e; }
.item.error { border-left-color: #ef4444; }
.item-left {
display: flex;
flex-direction: column;
gap: 2px;
}
.code {
font-family: monospace;
color: #cbd5e1;
font-size: 0.9rem;
}
.time {
color: #475569;
font-size: 0.75rem;
}
.badge {
padding: 3px 10px;
border-radius: 20px;
font-size: 0.78rem;
font-weight: 600;
white-space: nowrap;
}
.badge.success { background: #166534; color: #4ade80; }
.badge.error { background: #7f1d1d; color: #f87171; }
</style>

View File

@ -5,7 +5,6 @@ interface PlayerInfo {
nickname: string; nickname: string;
avatar_image: string; avatar_image: string;
stove_lv: number; stove_lv: number;
kid: string;
} }
interface RedeemResult { interface RedeemResult {
@ -14,14 +13,21 @@ interface RedeemResult {
message: string; message: string;
} }
interface HistoryItem {
id: number;
fid: string;
coupon_code: string;
result_msg: string;
executed_at: string;
}
export const useWosStore = defineStore('wos', { export const useWosStore = defineStore('wos', {
state: () => ({ state: () => ({
fid: '', fid: '',
player: null as PlayerInfo | null, player: null as PlayerInfo | null,
captchaImg: '',
captchaInput: '',
couponCodes: '', couponCodes: '',
results: [] as RedeemResult[], results: [] as RedeemResult[],
history: [] as HistoryItem[],
status: 'idle' as 'idle' | 'loading' | 'success' | 'error', status: 'idle' as 'idle' | 'loading' | 'success' | 'error',
errorMsg: '', errorMsg: '',
}), }),
@ -30,31 +36,30 @@ export const useWosStore = defineStore('wos', {
async searchPlayer(fid: string) { async searchPlayer(fid: string) {
this.status = 'loading'; this.status = 'loading';
this.errorMsg = ''; this.errorMsg = '';
this.player = null;
this.results = [];
try { try {
const { data } = await axios.post('/api/player', { fid }); const { data } = await axios.post('/api/player', { fid });
this.player = data; this.player = data;
this.fid = fid; this.fid = fid;
this.status = 'success'; this.status = 'idle';
await this.loadHistory();
} catch (err: any) { } catch (err: any) {
this.player = null;
this.status = 'error'; this.status = 'error';
this.errorMsg = err.response?.data?.message || '유저를 찾을 수 없습니다.'; this.errorMsg = err.response?.data?.message || '유저를 찾을 수 없습니다.';
} }
}, },
async loadCaptcha() { async loadHistory() {
if (!this.fid) return; if (!this.fid) return;
try { try {
const { data } = await axios.post('/api/captcha', { fid: this.fid }); const { data } = await axios.get(`/api/history/${this.fid}`);
this.captchaImg = data.img ? `data:image/png;base64,${data.img}` : ''; this.history = data;
this.captchaInput = ''; } catch {}
} catch (err: any) {
this.errorMsg = err.response?.data?.message || '캡차 로드 실패';
}
}, },
async submitCoupons() { async submitCoupons() {
if (!this.fid || !this.captchaInput) return; if (!this.fid) return;
const codes = this.couponCodes const codes = this.couponCodes
.split('\n') .split('\n')
.map((c) => c.trim()) .map((c) => c.trim())
@ -70,16 +75,11 @@ export const useWosStore = defineStore('wos', {
this.errorMsg = ''; this.errorMsg = '';
try { try {
const { data } = await axios.post('/api/redeem', { const { data } = await axios.post('/api/redeem', { fid: this.fid, codes });
fid: this.fid,
codes,
captcha: this.captchaInput,
});
this.results = data; this.results = data;
this.status = 'success'; this.status = 'idle';
this.captchaInput = ''; this.couponCodes = '';
this.captchaImg = ''; await this.loadHistory();
} catch (err: any) { } catch (err: any) {
this.status = 'error'; this.status = 'error';
this.errorMsg = err.response?.data?.message || '쿠폰 입력 중 오류가 발생했습니다.'; this.errorMsg = err.response?.data?.message || '쿠폰 입력 중 오류가 발생했습니다.';

View File

@ -2,7 +2,7 @@
<div class="home"> <div class="home">
<header> <header>
<h1> WOS 쿠폰 자동 입력</h1> <h1> WOS 쿠폰 자동 입력</h1>
<p class="subtitle">Whiteout Survival 쿠폰 자동화 시스템</p> <p class="subtitle">FID 입력 쿠폰 코드를 넣으면 자동으로 처리됩니다</p>
</header> </header>
<div class="card"> <div class="card">
@ -13,9 +13,11 @@
placeholder="FID (플레이어 ID) 입력" placeholder="FID (플레이어 ID) 입력"
class="input" class="input"
@keydown.enter="searchPlayer" @keydown.enter="searchPlayer"
:disabled="store.status === 'loading'"
/> />
<button class="btn btn-primary" @click="searchPlayer" :disabled="store.status === 'loading'"> <button class="btn btn-primary" @click="searchPlayer" :disabled="store.status === 'loading'">
조회 <span v-if="store.status === 'loading' && !store.player">조회 중...</span>
<span v-else>조회</span>
</button> </button>
</div> </div>
<p class="error-msg" v-if="store.status === 'error' && !store.player">{{ store.errorMsg }}</p> <p class="error-msg" v-if="store.status === 'error' && !store.player">{{ store.errorMsg }}</p>
@ -24,10 +26,14 @@
<UserCard v-if="store.player" :player="store.player" :fid="store.fid" /> <UserCard v-if="store.player" :player="store.player" :fid="store.fid" />
<div class="card" v-if="store.player"> <div class="card" v-if="store.player">
<h3 class="section-title">쿠폰 입력</h3>
<CouponInput /> <CouponInput />
</div> </div>
<ResultTable :results="store.results" /> <div class="card" v-if="store.player">
<HistoryList />
<p class="empty-history" v-if="store.history.length === 0">아직 입력 이력이 없습니다.</p>
</div>
</div> </div>
</template> </template>
@ -36,7 +42,7 @@ import { ref } from 'vue';
import { useWosStore } from '../stores/wos.store'; import { useWosStore } from '../stores/wos.store';
import UserCard from '../components/UserCard.vue'; import UserCard from '../components/UserCard.vue';
import CouponInput from '../components/CouponInput.vue'; import CouponInput from '../components/CouponInput.vue';
import ResultTable from '../components/ResultTable.vue'; import HistoryList from '../components/HistoryList.vue';
const store = useWosStore(); const store = useWosStore();
const fidInput = ref(''); const fidInput = ref('');
@ -51,27 +57,35 @@ async function searchPlayer() {
.home { .home {
max-width: 640px; max-width: 640px;
margin: 0 auto; margin: 0 auto;
padding: 32px 16px; padding: 32px 16px 60px;
} }
header { header {
text-align: center; text-align: center;
margin-bottom: 32px; margin-bottom: 28px;
} }
header h1 { header h1 {
font-size: 2rem; font-size: 1.9rem;
color: #f1f5f9; color: #f1f5f9;
margin: 0 0 8px; margin: 0 0 8px;
} }
.subtitle { .subtitle {
color: #64748b; color: #64748b;
margin: 0; margin: 0;
font-size: 0.9rem;
} }
.card { .card {
background: #1e293b; background: #1e293b;
border: 1px solid #334155; border: 1px solid #334155;
border-radius: 12px; border-radius: 12px;
padding: 20px; padding: 20px;
margin-bottom: 20px; margin-bottom: 16px;
}
.section-title {
margin: 0 0 14px;
color: #94a3b8;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.05em;
} }
.search-row { .search-row {
display: flex; display: flex;
@ -86,10 +100,8 @@ header h1 {
color: #f1f5f9; color: #f1f5f9;
font-size: 0.95rem; font-size: 0.95rem;
} }
.input:focus { .input:focus { outline: none; border-color: #60a5fa; }
outline: none; .input:disabled { opacity: 0.5; }
border-color: #60a5fa;
}
.btn { .btn {
padding: 10px 20px; padding: 10px 20px;
border: none; border: none;
@ -97,22 +109,11 @@ header h1 {
cursor: pointer; cursor: pointer;
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 600; font-weight: 600;
transition: opacity 0.2s; white-space: nowrap;
}
.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;
} }
.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; }
.empty-history { color: #475569; font-size: 0.9rem; margin: 0; text-align: center; }
</style> </style>