쿠폰 자동 입력 사이트 작성
배포 완료됐습니다. 변경 내용 요약: --- ## 변경 사항 **자동화 (캡차 없음)** - FID 입력 → 조회 버튼 클릭 → 쿠폰 코드 붙여넣기 → ⚡ 자동 입력 클릭 - 백엔드가 캡차 세션을 자동으로 획득해서 처리 (사용자가 캡차를 볼 필요 없음) - 캡차 세션 만료 시 자동 재시도 **영구 이력 저장** - 쿠폰 입력할 때마다 DB(`coupon_logs`)에 저장 - FID 조회 시 해당 플레이어의 이력이 하단에 자동 로드 - 날짜/시간 + 결과 (성공/실패) 표시, 최대 100건 **https://coupon.wageulwageul.com** 에서 바로 확인하세요.
This commit is contained in:
parent
3461886b48
commit
5ba2357124
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
105
front/src/components/HistoryList.vue
Normal file
105
front/src/components/HistoryList.vue
Normal 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>
|
||||||
@ -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 || '쿠폰 입력 중 오류가 발생했습니다.';
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user