From 1b43b874a1257452d846a7ba9fe2703102db8f02 Mon Sep 17 00:00:00 2001 From: hyoseung930 <35983843+hyoseung930@users.noreply.github.com> Date: Thu, 16 Apr 2026 18:03:45 +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 완료됐습니다. 변경 내용 요약: --- ## 변경 사항 ### 버그 수정 - TypeORM `upsert` → MySQL 호환 `findOne + update/save` 방식으로 수정 → 500 에러 해결 ### 신규 기능 **쿠폰 관리 (관리자)** - **https://coupon.wageulwageul.com/admin** 에서 쿠폰 추가/삭제/활성화 토글 - 쿠폰 코드 + 이름 저장 (예: `ABC123` / `신년 이벤트`) **자동 전체 지급 (사용자)** - FID 입력 → 조회 → **⚡ 전체 쿠폰 자동 지급** 버튼 클릭 - DB에 등록된 활성 쿠폰을 전부 자동으로 시도 - 결과(쿠폰명 + 코드 + 성공/실패)가 바로 표시되고 DB에 영구 저장 - 이력은 FID별로 조회 시 자동 로드 **사용 흐름:** 1. `/admin` 에서 쿠폰 등록 2. 사용자는 `/` 에서 FID 입력 → 버튼 한 번으로 모든 쿠폰 자동 지급 --- back/src/app.module.ts | 3 +- back/src/entities/coupon.entity.ts | 19 +++ back/src/entities/wos-coupon.entity.ts | 19 +++ back/src/wos/wos.controller.ts | 29 ++++- back/src/wos/wos.module.ts | 3 +- back/src/wos/wos.service.ts | 68 ++++++---- front/src/App.vue | 11 +- front/src/components/CouponInput.vue | 95 +++++--------- front/src/components/CouponManager.vue | 115 +++++++++++++++++ front/src/stores/wos.store.ts | 68 ++++++---- front/src/views/AdminView.vue | 167 +++++++++++++++++++++++++ front/src/views/HomeView.vue | 47 +++---- 12 files changed, 494 insertions(+), 150 deletions(-) create mode 100644 back/src/entities/coupon.entity.ts create mode 100644 back/src/entities/wos-coupon.entity.ts create mode 100644 front/src/components/CouponManager.vue create mode 100644 front/src/views/AdminView.vue diff --git a/back/src/app.module.ts b/back/src/app.module.ts index c49f697..14ec8ff 100644 --- a/back/src/app.module.ts +++ b/back/src/app.module.ts @@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { WosModule } from './wos/wos.module'; import { WosUser } from './entities/wos-user.entity'; import { CouponLog } from './entities/coupon-log.entity'; +import { WosCoupon } from './entities/wos-coupon.entity'; @Module({ imports: [ @@ -13,7 +14,7 @@ import { CouponLog } from './entities/coupon-log.entity'; username: process.env.DB_USERNAME || 'kakao', password: process.env.DB_PASSWORD || '486251daKWON@', database: process.env.DB_DATABASE || 'kakao', - entities: [WosUser, CouponLog], + entities: [WosUser, CouponLog, WosCoupon], synchronize: true, }), WosModule, diff --git a/back/src/entities/coupon.entity.ts b/back/src/entities/coupon.entity.ts new file mode 100644 index 0000000..4ee02f2 --- /dev/null +++ b/back/src/entities/coupon.entity.ts @@ -0,0 +1,19 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm'; + +@Entity('coupons') +export class Coupon { + @PrimaryGeneratedColumn() + id: number; + + @Column({ unique: true, length: 100 }) + code: string; + + @Column({ length: 100, nullable: true }) + name: string; + + @Column({ default: true }) + is_active: boolean; + + @CreateDateColumn() + created_at: Date; +} diff --git a/back/src/entities/wos-coupon.entity.ts b/back/src/entities/wos-coupon.entity.ts new file mode 100644 index 0000000..bae7a62 --- /dev/null +++ b/back/src/entities/wos-coupon.entity.ts @@ -0,0 +1,19 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm'; + +@Entity('wos_coupons') +export class WosCoupon { + @PrimaryGeneratedColumn() + id: number; + + @Column({ length: 100 }) + name: string; + + @Column({ unique: true, length: 50 }) + code: string; + + @Column({ default: true }) + is_active: boolean; + + @CreateDateColumn() + created_at: Date; +} diff --git a/back/src/wos/wos.controller.ts b/back/src/wos/wos.controller.ts index 6bdfdaf..5b94db8 100644 --- a/back/src/wos/wos.controller.ts +++ b/back/src/wos/wos.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Post, Get, Body, Param, HttpCode } from '@nestjs/common'; +import { Controller, Post, Get, Delete, Patch, Body, Param, HttpCode } from '@nestjs/common'; import { WosService } from './wos.service'; @Controller('api') @@ -11,14 +11,35 @@ export class WosController { return this.wosService.getPlayer(body.fid); } - @Post('redeem') + @Post('redeem-all') @HttpCode(200) - async redeem(@Body() body: { fid: string; codes: string[] }) { - return this.wosService.redeemAuto(body.fid, body.codes); + async redeemAll(@Body() body: { fid: string }) { + return this.wosService.redeemAll(body.fid); } @Get('history/:fid') async getHistory(@Param('fid') fid: string) { return this.wosService.getHistory(fid); } + + @Get('coupons') + async listCoupons() { + return this.wosService.listCoupons(); + } + + @Post('coupons') + @HttpCode(200) + async addCoupon(@Body() body: { name: string; code: string }) { + return this.wosService.addCoupon(body.name, body.code); + } + + @Patch('coupons/:id') + async toggleCoupon(@Param('id') id: string, @Body() body: { is_active: boolean }) { + return this.wosService.toggleCoupon(Number(id), body.is_active); + } + + @Delete('coupons/:id') + async deleteCoupon(@Param('id') id: string) { + return this.wosService.deleteCoupon(Number(id)); + } } diff --git a/back/src/wos/wos.module.ts b/back/src/wos/wos.module.ts index b4f0147..8d481f6 100644 --- a/back/src/wos/wos.module.ts +++ b/back/src/wos/wos.module.ts @@ -4,9 +4,10 @@ import { WosController } from './wos.controller'; import { WosService } from './wos.service'; import { WosUser } from '../entities/wos-user.entity'; import { CouponLog } from '../entities/coupon-log.entity'; +import { WosCoupon } from '../entities/wos-coupon.entity'; @Module({ - imports: [TypeOrmModule.forFeature([WosUser, CouponLog])], + imports: [TypeOrmModule.forFeature([WosUser, CouponLog, WosCoupon])], controllers: [WosController], providers: [WosService], }) diff --git a/back/src/wos/wos.service.ts b/back/src/wos/wos.service.ts index d8c160b..f50cb08 100644 --- a/back/src/wos/wos.service.ts +++ b/back/src/wos/wos.service.ts @@ -5,6 +5,7 @@ import axios from 'axios'; import * as crypto from 'crypto'; import { WosUser } from '../entities/wos-user.entity'; import { CouponLog } from '../entities/coupon-log.entity'; +import { WosCoupon } from '../entities/wos-coupon.entity'; const SECRET = 'tB87#kPtkxqOS2'; const WOS_API_BASE = 'https://wos-giftcode-api.centurygame.com/api'; @@ -30,13 +31,13 @@ const ERR_MESSAGES: Record = { @Injectable() export class WosService { - private sessionStore: Map = new Map(); - constructor( @InjectRepository(WosUser) private wosUserRepo: Repository, @InjectRepository(CouponLog) private couponLogRepo: Repository, + @InjectRepository(WosCoupon) + private wosCouponRepo: Repository, ) {} private makeSign(fid: string, timestamp: number): string { @@ -64,10 +65,14 @@ export class WosService { const playerInfo = data.data; - await this.wosUserRepo.upsert( - { fid, nickname: playerInfo.nickname, avatar_url: playerInfo.avatar_image }, - ['fid'], - ); + const existing = await this.wosUserRepo.findOne({ where: { fid } }); + if (existing) { + existing.nickname = playerInfo.nickname; + existing.avatar_url = playerInfo.avatar_image; + await this.wosUserRepo.save(existing); + } else { + await this.wosUserRepo.save({ fid, nickname: playerInfo.nickname, avatar_url: playerInfo.avatar_image }); + } return playerInfo; } @@ -88,18 +93,16 @@ export class WosService { const found = setCookie.find((c: string) => c.toLowerCase().includes('session')); if (found) sessionValue = found.split(';')[0]; } - - if (sessionValue) this.sessionStore.set(fid, sessionValue); return sessionValue; } - private async redeemOne(fid: string, couponCode: string, captcha: string, session: string): Promise { + private async redeemOne(fid: string, code: string, captcha: string, session: string): Promise { const timestamp = Date.now(); const sign = this.makeSign(fid, timestamp); const response = await axios.post( `${WOS_API_BASE}/gift_code`, - new URLSearchParams({ fid, time: String(timestamp), sign, cdk: couponCode, validate: captcha }), + new URLSearchParams({ fid, time: String(timestamp), sign, cdk: code, validate: captcha }), { headers: { ...COMMON_HEADERS, Cookie: session } }, ); @@ -108,35 +111,34 @@ export class WosService { return ERR_MESSAGES[data.err_code] || `실패 (err_code: ${data.err_code})`; } - async redeemAuto(fid: string, codes: string[]) { - const results: { code: string; status: string; message: string }[] = []; + async redeemAll(fid: string) { + const activeCoupons = await this.wosCouponRepo.find({ where: { is_active: true } }); + if (activeCoupons.length === 0) return []; + const results: { name: string; code: string; status: string; message: string }[] = []; let session = await this.acquireSession(fid); - for (const rawCode of codes) { - const code = rawCode.trim(); - if (!code) continue; - + for (const coupon of activeCoupons) { let message = ''; let status = 'error'; try { - message = await this.redeemOne(fid, code, '1', session); + 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, code, '1', session); + message = await this.redeemOne(fid, coupon.code, '1', session); } status = message === '성공' ? 'success' : 'error'; } catch (err: any) { - message = err.response?.data?.message || err.message || '오류 발생'; + message = err.message || '오류 발생'; status = 'error'; } - await this.couponLogRepo.save({ fid, coupon_code: code, result_msg: message }); - results.push({ code, status, message }); + await this.couponLogRepo.save({ fid, coupon_code: coupon.code, result_msg: message }); + results.push({ name: coupon.name, code: coupon.code, status, message }); await new Promise((r) => setTimeout(r, 1000)); } @@ -151,4 +153,28 @@ export class WosService { take: 100, }); } + + async listCoupons() { + return this.wosCouponRepo.find({ order: { created_at: 'DESC' } }); + } + + async addCoupon(name: string, code: string) { + const exists = await this.wosCouponRepo.findOne({ where: { code } }); + if (exists) { + exists.name = name; + exists.is_active = true; + return this.wosCouponRepo.save(exists); + } + return this.wosCouponRepo.save({ name, code, is_active: true }); + } + + async toggleCoupon(id: number, is_active: boolean) { + await this.wosCouponRepo.update(id, { is_active }); + return this.wosCouponRepo.findOne({ where: { id } }); + } + + async deleteCoupon(id: number) { + await this.wosCouponRepo.delete(id); + return { success: true }; + } } diff --git a/front/src/App.vue b/front/src/App.vue index 586d8d9..34d14d3 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -1,17 +1,20 @@ diff --git a/front/src/components/CouponManager.vue b/front/src/components/CouponManager.vue new file mode 100644 index 0000000..3604077 --- /dev/null +++ b/front/src/components/CouponManager.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/front/src/stores/wos.store.ts b/front/src/stores/wos.store.ts index ac94c68..c47a571 100644 --- a/front/src/stores/wos.store.ts +++ b/front/src/stores/wos.store.ts @@ -8,6 +8,7 @@ interface PlayerInfo { } interface RedeemResult { + name: string; code: string; status: 'success' | 'error' | 'pending'; message: string; @@ -21,13 +22,21 @@ interface HistoryItem { executed_at: string; } +export interface Coupon { + id: number; + name: string; + code: string; + is_active: boolean; + created_at: string; +} + export const useWosStore = defineStore('wos', { state: () => ({ fid: '', player: null as PlayerInfo | null, - couponCodes: '', results: [] as RedeemResult[], history: [] as HistoryItem[], + coupons: [] as Coupon[], status: 'idle' as 'idle' | 'loading' | 'success' | 'error', errorMsg: '', }), @@ -42,14 +51,25 @@ export const useWosStore = defineStore('wos', { const { data } = await axios.post('/api/player', { fid }); this.player = data; this.fid = fid; + await Promise.all([this.loadHistory(), this.redeemAll()]); this.status = 'idle'; - await this.loadHistory(); } catch (err: any) { this.status = 'error'; this.errorMsg = err.response?.data?.message || '유저를 찾을 수 없습니다.'; } }, + async redeemAll() { + if (!this.fid) return; + try { + const { data } = await axios.post('/api/redeem-all', { fid: this.fid }); + this.results = data; + await this.loadHistory(); + } catch (err: any) { + this.errorMsg = err.response?.data?.message || '쿠폰 지급 중 오류 발생'; + } + }, + async loadHistory() { if (!this.fid) return; try { @@ -58,32 +78,26 @@ export const useWosStore = defineStore('wos', { } catch {} }, - async submitCoupons() { - if (!this.fid) 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 = ''; - + async loadCoupons() { try { - const { data } = await axios.post('/api/redeem', { fid: this.fid, codes }); - this.results = data; - this.status = 'idle'; - this.couponCodes = ''; - await this.loadHistory(); - } catch (err: any) { - this.status = 'error'; - this.errorMsg = err.response?.data?.message || '쿠폰 입력 중 오류가 발생했습니다.'; - } + const { data } = await axios.get('/api/coupons'); + this.coupons = data; + } catch {} + }, + + async addCoupon(name: string, code: string) { + await axios.post('/api/coupons', { name, code }); + await this.loadCoupons(); + }, + + async toggleCoupon(id: number, is_active: boolean) { + await axios.patch(`/api/coupons/${id}`, { is_active }); + await this.loadCoupons(); + }, + + async deleteCoupon(id: number) { + await axios.delete(`/api/coupons/${id}`); + await this.loadCoupons(); }, }, }); diff --git a/front/src/views/AdminView.vue b/front/src/views/AdminView.vue new file mode 100644 index 0000000..40a1635 --- /dev/null +++ b/front/src/views/AdminView.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/front/src/views/HomeView.vue b/front/src/views/HomeView.vue index f52c236..3e52f47 100644 --- a/front/src/views/HomeView.vue +++ b/front/src/views/HomeView.vue @@ -2,7 +2,7 @@

⚔️ WOS 쿠폰 자동 입력

-

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

+

FID를 입력하면 등록된 모든 쿠폰을 자동으로 지급합니다

@@ -16,17 +16,17 @@ :disabled="store.status === 'loading'" />
-

{{ store.errorMsg }}

+

{{ store.errorMsg }}

-

쿠폰 입력

+

지급 결과

@@ -34,19 +34,26 @@

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

+ +
+ +