쿠폰 자동 입력 사이트 작성
## 적용된 변경사항
### 백엔드
- **`wos_coupons` 테이블에 `is_expired` 컬럼 추가** (TypeORM synchronize로 자동 생성)
- **`redeemAll` 로직 개선**:
- 이미 `성공` / `이미 수령` 로그 있는 쿠폰 → 자동 **스킵** (재지급 안 함)
- `err_code: 40008` (RECEIVED) → **성공** 처리 (`이미 수령` 메시지)
- `err_code: 40007` (TIME ERROR) → DB에서 `is_expired = true, is_active = false` 업데이트
- **매일 오전 9시 자동 실행** (`@Cron('0 0 9 * * *')`) - 저장된 모든 유저에게 미수령 쿠폰 자동 지급
### 프론트엔드
- **삭제 버튼 제거**
- 만료된 쿠폰은 **보라색 "만료" 배지** 표시, 토글 버튼 숨김
- 활성 쿠폰 카운트에서 만료 제외
This commit is contained in:
parent
3da1077ea7
commit
14956ac9ec
5303
back/package-lock.json
generated
Normal file
5303
back/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -12,17 +12,18 @@
|
|||||||
"@nestjs/common": "^10.0.0",
|
"@nestjs/common": "^10.0.0",
|
||||||
"@nestjs/core": "^10.0.0",
|
"@nestjs/core": "^10.0.0",
|
||||||
"@nestjs/platform-express": "^10.0.0",
|
"@nestjs/platform-express": "^10.0.0",
|
||||||
|
"@nestjs/schedule": "^6.1.3",
|
||||||
"@nestjs/typeorm": "^10.0.0",
|
"@nestjs/typeorm": "^10.0.0",
|
||||||
"typeorm": "^0.3.0",
|
|
||||||
"mysql2": "^3.0.0",
|
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
"mysql2": "^3.0.0",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rxjs": "^7.8.0"
|
"rxjs": "^7.8.0",
|
||||||
|
"typeorm": "^0.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.0.0",
|
"@nestjs/cli": "^10.0.0",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
"typescript": "^5.0.0",
|
"ts-node": "^10.9.0",
|
||||||
"ts-node": "^10.9.0"
|
"typescript": "^5.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { WosModule } from './wos/wos.module';
|
import { WosModule } from './wos/wos.module';
|
||||||
import { WosUser } from './entities/wos-user.entity';
|
import { WosUser } from './entities/wos-user.entity';
|
||||||
@ -7,6 +8,7 @@ import { WosCoupon } from './entities/wos-coupon.entity';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
ScheduleModule.forRoot(),
|
||||||
TypeOrmModule.forRoot({
|
TypeOrmModule.forRoot({
|
||||||
type: 'mysql',
|
type: 'mysql',
|
||||||
host: process.env.DB_HOST || 'localhost',
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
|||||||
@ -14,6 +14,9 @@ export class WosCoupon {
|
|||||||
@Column({ default: true })
|
@Column({ default: true })
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
|
||||||
|
@Column({ default: false })
|
||||||
|
is_expired: boolean;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
|
import { Injectable, HttpException, HttpStatus, Logger } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository, In } from 'typeorm';
|
||||||
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import { WosUser } from '../entities/wos-user.entity';
|
import { WosUser } from '../entities/wos-user.entity';
|
||||||
@ -19,18 +20,13 @@ const COMMON_HEADERS = {
|
|||||||
'Accept-Language': 'ko-KR,ko;q=0.9,en;q=0.8',
|
'Accept-Language': 'ko-KR,ko;q=0.9,en;q=0.8',
|
||||||
};
|
};
|
||||||
|
|
||||||
const ERR_MESSAGES: Record<number, string> = {
|
const SUCCESS_MSG = '성공';
|
||||||
20000: '쿠폰 코드가 존재하지 않습니다.',
|
const ALREADY_MSG = '이미 수령';
|
||||||
20001: '이미 사용된 쿠폰입니다.',
|
|
||||||
20002: '만료된 쿠폰입니다.',
|
|
||||||
20003: '캡차 인증에 실패했습니다.',
|
|
||||||
40001: '존재하지 않는 플레이어 ID입니다.',
|
|
||||||
40004: '캡차 입력이 올바르지 않습니다.',
|
|
||||||
40014: '캡차 세션이 만료되었습니다. 다시 시도해주세요.',
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WosService {
|
export class WosService {
|
||||||
|
private readonly logger = new Logger(WosService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(WosUser)
|
@InjectRepository(WosUser)
|
||||||
private wosUserRepo: Repository<WosUser>,
|
private wosUserRepo: Repository<WosUser>,
|
||||||
@ -58,7 +54,7 @@ export class WosService {
|
|||||||
const data = response.data;
|
const data = response.data;
|
||||||
if (data.code !== 0) {
|
if (data.code !== 0) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
ERR_MESSAGES[data.err_code] || `오류가 발생했습니다. (${data.err_code})`,
|
data.msg || `오류가 발생했습니다. (${data.err_code})`,
|
||||||
HttpStatus.BAD_REQUEST,
|
HttpStatus.BAD_REQUEST,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -96,49 +92,70 @@ export class WosService {
|
|||||||
return sessionValue;
|
return sessionValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async redeemOne(fid: string, code: string, captcha: string, session: string): Promise<string> {
|
private async redeemOne(
|
||||||
|
fid: string,
|
||||||
|
code: string,
|
||||||
|
session: string,
|
||||||
|
): Promise<{ success: boolean; message: string; err_code?: number }> {
|
||||||
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}/gift_code`,
|
`${WOS_API_BASE}/gift_code`,
|
||||||
new URLSearchParams({ fid, time: String(timestamp), sign, cdk: code, validate: captcha }),
|
new URLSearchParams({ fid, time: String(timestamp), sign, cdk: code, validate: '1' }),
|
||||||
{ headers: { ...COMMON_HEADERS, Cookie: session } },
|
{ headers: { ...COMMON_HEADERS, Cookie: session } },
|
||||||
);
|
);
|
||||||
|
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
if (data.code === 0) return '성공';
|
if (data.code === 0) return { success: true, message: SUCCESS_MSG };
|
||||||
return ERR_MESSAGES[data.err_code] || `실패 (err_code: ${data.err_code})`;
|
return { success: false, message: data.msg || `실패 (${data.err_code})`, err_code: data.err_code };
|
||||||
}
|
}
|
||||||
|
|
||||||
async redeemAll(fid: string) {
|
async redeemAll(fid: string) {
|
||||||
const activeCoupons = await this.wosCouponRepo.find({ where: { is_active: true } });
|
const activeCoupons = await this.wosCouponRepo.find({
|
||||||
|
where: { is_active: true, is_expired: false },
|
||||||
|
});
|
||||||
if (activeCoupons.length === 0) return [];
|
if (activeCoupons.length === 0) return [];
|
||||||
|
|
||||||
|
const existingLogs = await this.couponLogRepo.find({
|
||||||
|
where: { fid, result_msg: In([SUCCESS_MSG, ALREADY_MSG]) },
|
||||||
|
});
|
||||||
|
const alreadySucceeded = new Set(existingLogs.map((l) => l.coupon_code));
|
||||||
|
|
||||||
const results: { name: string; code: string; status: string; message: string }[] = [];
|
const results: { name: string; code: string; status: string; message: string }[] = [];
|
||||||
let session = await this.acquireSession(fid);
|
let session = await this.acquireSession(fid);
|
||||||
|
|
||||||
for (const coupon of activeCoupons) {
|
for (const coupon of activeCoupons) {
|
||||||
let message = '';
|
if (alreadySucceeded.has(coupon.code)) {
|
||||||
let status = 'error';
|
results.push({ name: coupon.name, code: coupon.code, status: 'skipped', message: '이미 지급 완료' });
|
||||||
|
continue;
|
||||||
try {
|
|
||||||
message = await this.redeemOne(fid, coupon.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, coupon.code, '1', session);
|
|
||||||
}
|
|
||||||
|
|
||||||
status = message === '성공' ? 'success' : 'error';
|
|
||||||
} catch (err: any) {
|
|
||||||
message = err.message || '오류 발생';
|
|
||||||
status = 'error';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.couponLogRepo.save({ fid, coupon_code: coupon.code, result_msg: message });
|
let result: { success: boolean; message: string; err_code?: number };
|
||||||
results.push({ name: coupon.name, code: coupon.code, status, message });
|
try {
|
||||||
|
result = await this.redeemOne(fid, coupon.code, session);
|
||||||
|
|
||||||
|
if (result.err_code === 40014 || result.err_code === 40004) {
|
||||||
|
session = await this.acquireSession(fid);
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
result = await this.redeemOne(fid, coupon.code, session);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
result = { success: false, message: err.message || '오류 발생' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.err_code === 40008) {
|
||||||
|
result = { success: true, message: ALREADY_MSG, err_code: 40008 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.err_code === 40007) {
|
||||||
|
await this.wosCouponRepo.update(coupon.id, { is_expired: true, is_active: false });
|
||||||
|
result.message = '만료된 쿠폰';
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = result.success ? 'success' : 'error';
|
||||||
|
await this.couponLogRepo.save({ fid, coupon_code: coupon.code, result_msg: result.message });
|
||||||
|
results.push({ name: coupon.name, code: coupon.code, status, message: result.message });
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 1000));
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
}
|
}
|
||||||
@ -146,6 +163,22 @@ export class WosService {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Cron('0 0 9 * * *')
|
||||||
|
async scheduledRedeemAll() {
|
||||||
|
this.logger.log('자동 쿠폰 지급 시작...');
|
||||||
|
const users = await this.wosUserRepo.find();
|
||||||
|
for (const user of users) {
|
||||||
|
try {
|
||||||
|
await this.redeemAll(user.fid);
|
||||||
|
this.logger.log(`[${user.fid}] ${user.nickname} 지급 완료`);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[${user.fid}] 지급 실패: ${err.message}`);
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
|
}
|
||||||
|
this.logger.log('자동 쿠폰 지급 완료');
|
||||||
|
}
|
||||||
|
|
||||||
async getHistory(fid: string) {
|
async getHistory(fid: string) {
|
||||||
return this.couponLogRepo.find({
|
return this.couponLogRepo.find({
|
||||||
where: { fid },
|
where: { fid },
|
||||||
@ -167,9 +200,10 @@ export class WosService {
|
|||||||
if (exists) {
|
if (exists) {
|
||||||
exists.name = name;
|
exists.name = name;
|
||||||
exists.is_active = true;
|
exists.is_active = true;
|
||||||
|
exists.is_expired = false;
|
||||||
return this.wosCouponRepo.save(exists);
|
return this.wosCouponRepo.save(exists);
|
||||||
}
|
}
|
||||||
return this.wosCouponRepo.save({ name, code, is_active: true });
|
return this.wosCouponRepo.save({ name, code, is_active: true, is_expired: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
async toggleCoupon(id: number, is_active: boolean) {
|
async toggleCoupon(id: number, is_active: boolean) {
|
||||||
|
|||||||
1584
front/package-lock.json
generated
Normal file
1584
front/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -12,16 +12,26 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="coupon-list" v-if="store.coupons.length > 0">
|
<div class="coupon-list" v-if="store.coupons.length > 0">
|
||||||
<div v-for="c in store.coupons" :key="c.id" class="coupon-item" :class="{ inactive: !c.is_active }">
|
<div
|
||||||
|
v-for="c in store.coupons"
|
||||||
|
:key="c.id"
|
||||||
|
class="coupon-item"
|
||||||
|
:class="{ inactive: !c.is_active, expired: c.is_expired }"
|
||||||
|
>
|
||||||
<div class="coupon-info">
|
<div class="coupon-info">
|
||||||
<span class="coupon-name">{{ c.name }}</span>
|
<span class="coupon-name">{{ c.name }}</span>
|
||||||
<span class="coupon-code">{{ c.code }}</span>
|
<span class="coupon-code">{{ c.code }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="coupon-actions">
|
<div class="coupon-actions">
|
||||||
<button class="btn-toggle" :class="c.is_active ? 'on' : 'off'" @click="store.toggleCoupon(c.id, !c.is_active)">
|
<span v-if="c.is_expired" class="badge badge-expired">만료</span>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="btn-toggle"
|
||||||
|
:class="c.is_active ? 'on' : 'off'"
|
||||||
|
@click="store.toggleCoupon(c.id, !c.is_active)"
|
||||||
|
>
|
||||||
{{ c.is_active ? '활성' : '비활성' }}
|
{{ c.is_active ? '활성' : '비활성' }}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-delete" @click="remove(c.id)">삭제</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -37,7 +47,7 @@ const store = useWosStore();
|
|||||||
const newName = ref('');
|
const newName = ref('');
|
||||||
const newCode = ref('');
|
const newCode = ref('');
|
||||||
|
|
||||||
const activeCoupons = computed(() => store.coupons.filter((c) => c.is_active).length);
|
const activeCoupons = computed(() => store.coupons.filter((c) => c.is_active && !c.is_expired).length);
|
||||||
|
|
||||||
async function add() {
|
async function add() {
|
||||||
if (!newName.value.trim() || !newCode.value.trim()) return;
|
if (!newName.value.trim() || !newCode.value.trim()) return;
|
||||||
@ -45,11 +55,6 @@ async function add() {
|
|||||||
newName.value = '';
|
newName.value = '';
|
||||||
newCode.value = '';
|
newCode.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remove(id: number) {
|
|
||||||
if (!confirm('삭제하시겠습니까?')) return;
|
|
||||||
await store.deleteCoupon(id);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -95,6 +100,7 @@ async function remove(id: number) {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.coupon-item.inactive { border-left-color: #334155; opacity: 0.6; }
|
.coupon-item.inactive { border-left-color: #334155; opacity: 0.6; }
|
||||||
|
.coupon-item.expired { border-left-color: #7c3aed; opacity: 0.5; }
|
||||||
.coupon-info { display: flex; flex-direction: column; gap: 2px; flex: 1; }
|
.coupon-info { display: flex; flex-direction: column; gap: 2px; flex: 1; }
|
||||||
.coupon-name { color: #f1f5f9; font-size: 0.9rem; font-weight: 500; }
|
.coupon-name { color: #f1f5f9; font-size: 0.9rem; font-weight: 500; }
|
||||||
.coupon-code { color: #60a5fa; font-family: monospace; font-size: 0.82rem; }
|
.coupon-code { color: #60a5fa; font-family: monospace; font-size: 0.82rem; }
|
||||||
@ -109,7 +115,7 @@ async function remove(id: number) {
|
|||||||
}
|
}
|
||||||
.btn-toggle.on { background: #166534; color: #4ade80; }
|
.btn-toggle.on { background: #166534; color: #4ade80; }
|
||||||
.btn-toggle.off { background: #374151; color: #9ca3af; }
|
.btn-toggle.off { background: #374151; color: #9ca3af; }
|
||||||
.btn-delete { background: none; border: none; color: #ef4444; cursor: pointer; font-size: 0.8rem; padding: 3px 6px; }
|
.badge { padding: 3px 10px; border-radius: 20px; font-size: 0.78rem; font-weight: 600; }
|
||||||
.btn-delete:hover { color: #dc2626; }
|
.badge-expired { background: #3b0764; color: #c084fc; }
|
||||||
.empty { color: #475569; font-size: 0.9rem; margin: 0; text-align: center; }
|
.empty { color: #475569; font-size: 0.9rem; margin: 0; text-align: center; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -27,6 +27,7 @@ export interface Coupon {
|
|||||||
name: string;
|
name: string;
|
||||||
code: string;
|
code: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
is_expired: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user