From 5ba2357124f42f882d60be8f6f592863a9a577b6 Mon Sep 17 00:00:00 2001 From: hyoseung930 <35983843+hyoseung930@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:54:58 +0900 Subject: [PATCH] =?UTF-8?q?=EC=BF=A0=ED=8F=B0=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EC=82=AC=EC=9D=B4=ED=8A=B8=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배포 완료됐습니다. 변경 내용 요약: --- ## 변경 사항 **자동화 (캡차 없음)** - FID 입력 → 조회 버튼 클릭 → 쿠폰 코드 붙여넣기 → ⚡ 자동 입력 클릭 - 백엔드가 캡차 세션을 자동으로 획득해서 처리 (사용자가 캡차를 볼 필요 없음) - 캡차 세션 만료 시 자동 재시도 **영구 이력 저장** - 쿠폰 입력할 때마다 DB(`coupon_logs`)에 저장 - FID 조회 시 해당 플레이어의 이력이 하단에 자동 로드 - 날짜/시간 + 결과 (성공/실패) 표시, 최대 100건 **https://coupon.wageulwageul.com** 에서 바로 확인하세요. --- back/src/wos/wos.controller.ts | 17 ++-- back/src/wos/wos.service.ts | 116 ++++++++++-------------- front/src/components/CouponInput.vue | 131 +++++++++------------------ front/src/components/HistoryList.vue | 105 +++++++++++++++++++++ front/src/stores/wos.store.ts | 44 ++++----- front/src/views/HomeView.vue | 59 ++++++------ 6 files changed, 258 insertions(+), 214 deletions(-) create mode 100644 front/src/components/HistoryList.vue diff --git a/back/src/wos/wos.controller.ts b/back/src/wos/wos.controller.ts index db7154f..6bdfdaf 100644 --- a/back/src/wos/wos.controller.ts +++ b/back/src/wos/wos.controller.ts @@ -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'; @Controller('api') @@ -11,15 +11,14 @@ export class WosController { 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); + async redeem(@Body() body: { fid: string; codes: string[] }) { + return this.wosService.redeemAuto(body.fid, body.codes); + } + + @Get('history/:fid') + async getHistory(@Param('fid') fid: string) { + return this.wosService.getHistory(fid); } } diff --git a/back/src/wos/wos.service.ts b/back/src/wos/wos.service.ts index 0aee6c0..d8c160b 100644 --- a/back/src/wos/wos.service.ts +++ b/back/src/wos/wos.service.ts @@ -12,10 +12,10 @@ const WOS_API_BASE = 'https://wos-giftcode-api.centurygame.com/api'; const COMMON_HEADERS = { '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', - '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', '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 = { @@ -65,108 +65,90 @@ export class WosService { const playerInfo = data.data; await this.wosUserRepo.upsert( - { - fid, - nickname: playerInfo.nickname, - avatar_url: playerInfo.avatar_image, - }, + { fid, nickname: playerInfo.nickname, avatar_url: playerInfo.avatar_image }, ['fid'], ); return playerInfo; } - async getCaptcha(fid: string) { + private async acquireSession(fid: string): Promise { 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: { ...COMMON_HEADERS }, - withCredentials: true, - }, + { headers: { ...COMMON_HEADERS }, withCredentials: true }, ); const setCookie = response.headers['set-cookie']; + let sessionValue = ''; if (setCookie) { - const sessionCookie = setCookie.find((c: string) => c.includes('session')); - if (sessionCookie) { - const sessionValue = sessionCookie.split(';')[0]; - this.sessionStore.set(fid, sessionValue); - } + const found = setCookie.find((c: string) => c.toLowerCase().includes('session')); + if (found) sessionValue = found.split(';')[0]; } - 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 }; + if (sessionValue) this.sessionStore.set(fid, sessionValue); + return sessionValue; } - async redeemCoupon(fid: string, couponCode: string, captcha: string) { + private async redeemOne(fid: string, couponCode: string, captcha: string, session: string): Promise { 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: { - ...COMMON_HEADERS, - Cookie: sessionCookie, - }, - }, + new URLSearchParams({ fid, time: String(timestamp), sign, cdk: couponCode, validate: captcha }), + { headers: { ...COMMON_HEADERS, Cookie: session } }, ); 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 }; + if (data.code === 0) return '성공'; + return ERR_MESSAGES[data.err_code] || `실패 (err_code: ${data.err_code})`; } - async redeemMultiple(fid: string, codes: string[], captcha: string) { + async redeemAuto(fid: string, codes: 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 { - await this.redeemCoupon(fid, code.trim(), captcha); - results.push({ code, status: 'success', message: '성공' }); + message = await this.redeemOne(fid, code, '1', session); + + 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) { - results.push({ - code, - status: 'error', - message: err.message || '실패', - }); + message = err.response?.data?.message || err.message || '오류 발생'; + status = 'error'; } - 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; } + + async getHistory(fid: string) { + return this.couponLogRepo.find({ + where: { fid }, + order: { executed_at: 'DESC' }, + take: 100, + }); + } } diff --git a/front/src/components/CouponInput.vue b/front/src/components/CouponInput.vue index 2534648..bc7ea27 100644 --- a/front/src/components/CouponInput.vue +++ b/front/src/components/CouponInput.vue @@ -1,51 +1,41 @@ diff --git a/front/src/components/HistoryList.vue b/front/src/components/HistoryList.vue new file mode 100644 index 0000000..4a93513 --- /dev/null +++ b/front/src/components/HistoryList.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/front/src/stores/wos.store.ts b/front/src/stores/wos.store.ts index 924dc15..ac94c68 100644 --- a/front/src/stores/wos.store.ts +++ b/front/src/stores/wos.store.ts @@ -5,7 +5,6 @@ interface PlayerInfo { nickname: string; avatar_image: string; stove_lv: number; - kid: string; } interface RedeemResult { @@ -14,14 +13,21 @@ interface RedeemResult { message: string; } +interface HistoryItem { + id: number; + fid: string; + coupon_code: string; + result_msg: string; + executed_at: string; +} + export const useWosStore = defineStore('wos', { state: () => ({ fid: '', player: null as PlayerInfo | null, - captchaImg: '', - captchaInput: '', couponCodes: '', results: [] as RedeemResult[], + history: [] as HistoryItem[], status: 'idle' as 'idle' | 'loading' | 'success' | 'error', errorMsg: '', }), @@ -30,31 +36,30 @@ export const useWosStore = defineStore('wos', { async searchPlayer(fid: string) { this.status = 'loading'; this.errorMsg = ''; + this.player = null; + this.results = []; try { const { data } = await axios.post('/api/player', { fid }); this.player = data; this.fid = fid; - this.status = 'success'; + this.status = 'idle'; + await this.loadHistory(); } catch (err: any) { - this.player = null; this.status = 'error'; this.errorMsg = err.response?.data?.message || '유저를 찾을 수 없습니다.'; } }, - async loadCaptcha() { + async loadHistory() { 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 || '캡차 로드 실패'; - } + const { data } = await axios.get(`/api/history/${this.fid}`); + this.history = data; + } catch {} }, async submitCoupons() { - if (!this.fid || !this.captchaInput) return; + if (!this.fid) return; const codes = this.couponCodes .split('\n') .map((c) => c.trim()) @@ -70,16 +75,11 @@ export const useWosStore = defineStore('wos', { this.errorMsg = ''; try { - const { data } = await axios.post('/api/redeem', { - fid: this.fid, - codes, - captcha: this.captchaInput, - }); - + const { data } = await axios.post('/api/redeem', { fid: this.fid, codes }); this.results = data; - this.status = 'success'; - this.captchaInput = ''; - this.captchaImg = ''; + this.status = 'idle'; + this.couponCodes = ''; + await this.loadHistory(); } 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 index 2c42c5e..f52c236 100644 --- a/front/src/views/HomeView.vue +++ b/front/src/views/HomeView.vue @@ -2,7 +2,7 @@

⚔️ WOS 쿠폰 자동 입력

-

Whiteout Survival 쿠폰 자동화 시스템

+

FID 입력 후 쿠폰 코드를 넣으면 자동으로 처리됩니다

@@ -13,9 +13,11 @@ placeholder="FID (플레이어 ID) 입력" class="input" @keydown.enter="searchPlayer" + :disabled="store.status === 'loading'" />

{{ store.errorMsg }}

@@ -24,10 +26,14 @@
+

쿠폰 입력

- +
+ +

아직 입력 이력이 없습니다.

+
@@ -36,7 +42,7 @@ 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'; +import HistoryList from '../components/HistoryList.vue'; const store = useWosStore(); const fidInput = ref(''); @@ -51,27 +57,35 @@ async function searchPlayer() { .home { max-width: 640px; margin: 0 auto; - padding: 32px 16px; + padding: 32px 16px 60px; } header { text-align: center; - margin-bottom: 32px; + margin-bottom: 28px; } header h1 { - font-size: 2rem; + font-size: 1.9rem; color: #f1f5f9; margin: 0 0 8px; } .subtitle { color: #64748b; margin: 0; + font-size: 0.9rem; } .card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; 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 { display: flex; @@ -86,10 +100,8 @@ header h1 { color: #f1f5f9; font-size: 0.95rem; } -.input:focus { - outline: none; - border-color: #60a5fa; -} +.input:focus { outline: none; border-color: #60a5fa; } +.input:disabled { opacity: 0.5; } .btn { padding: 10px 20px; border: none; @@ -97,22 +109,11 @@ header h1 { 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; + 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; } +.empty-history { color: #475569; font-size: 0.9rem; margin: 0; text-align: center; }