import { Injectable, HttpException, HttpStatus, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In } from 'typeorm'; import { Cron } from '@nestjs/schedule'; 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'; const OCR_SERVER = 'http://127.0.0.1:5001'; 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', '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 SUCCESS_MSG = '성공'; const ALREADY_MSG = '이미 수령'; @Injectable() export class WosService { private readonly logger = new Logger(WosService.name); constructor( @InjectRepository(WosUser) private wosUserRepo: Repository, @InjectRepository(CouponLog) private couponLogRepo: Repository, @InjectRepository(WosCoupon) private wosCouponRepo: Repository, ) {} private now(): number { return Date.now(); } private makePlayerSign(fid: string, timestamp: number): string { const raw = `fid=${fid}&time=${timestamp}${SECRET}`; return crypto.createHash('md5').update(raw).digest('hex'); } private makeCaptchaSign(fid: string, timestamp: number): string { const raw = `fid=${fid}&init=0&time=${timestamp}${SECRET}`; return crypto.createHash('md5').update(raw).digest('hex'); } private makeGiftSign(fid: string, timestamp: number, cdk: string, captchaCode: string): string { const raw = `captcha_code=${captchaCode}&cdk=${cdk}&fid=${fid}&time=${timestamp}${SECRET}`; return crypto.createHash('md5').update(raw).digest('hex'); } private async solveCaptcha(fid: string): Promise { const ts = this.now(); const sign = this.makeCaptchaSign(fid, ts); const resp = await axios.post( `${WOS_API_BASE}/captcha`, new URLSearchParams({ fid, time: String(ts), sign, init: '0' }), { headers: { ...COMMON_HEADERS } }, ); if (resp.data.code !== 0) throw new Error(`Captcha 요청 실패: ${resp.data.msg}`); const imgB64: string = resp.data.data.img; const imgData = imgB64.includes(',') ? imgB64.split(',')[1] : imgB64; const imgBuffer = Buffer.from(imgData, 'base64'); const ocrResp = await axios.post(OCR_SERVER, imgBuffer, { headers: { 'Content-Type': 'application/octet-stream' }, timeout: 5000, }); return String(ocrResp.data).trim().toLowerCase(); } async getPlayer(fid: string) { const timestamp = this.now(); const sign = this.makePlayerSign(fid, timestamp); const response = await axios.post( `${WOS_API_BASE}/player`, new URLSearchParams({ fid, time: String(timestamp), sign }), { headers: { ...COMMON_HEADERS } }, ); const data = response.data; if (data.code !== 0) { throw new HttpException( data.msg || `오류가 발생했습니다. (${data.err_code})`, HttpStatus.BAD_REQUEST, ); } const playerInfo = data.data; 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; } private async redeemOne( fid: string, code: string, ): Promise<{ success: boolean; message: string; err_code?: number }> { let attempt = 0; while (true) { attempt++; if (attempt > 1) { this.logger.warn(`[${fid}] ${code} 캡차 오인식 (${attempt - 1}회), 30초 대기 후 재시도`); await new Promise((r) => setTimeout(r, 30000)); } let captchaCode: string; try { captchaCode = await this.solveCaptcha(fid); } catch (err: any) { return { success: false, message: `캡차 오류: ${err.message}` }; } const ts = this.now(); const giftSign = this.makeGiftSign(fid, ts, code, captchaCode); const response = await axios.post( `${WOS_API_BASE}/gift_code`, new URLSearchParams({ fid, time: String(ts), sign: giftSign, cdk: code, captcha_code: captchaCode }), { headers: { ...COMMON_HEADERS } }, ); const data = response.data; this.logger.log(`[${fid}] ${code} attempt=${attempt} captcha=${captchaCode} → code=${data.code} err=${data.err_code} msg=${data.msg}`); if (data.code === 0) return { success: true, message: SUCCESS_MSG }; if (data.err_code === 40103) continue; return { success: false, message: data.msg || `실패 (${data.err_code})`, err_code: data.err_code, }; } } async redeemAll(fid: string) { const activeCoupons = await this.wosCouponRepo.find({ where: { is_expired: false }, }); 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 }[] = []; for (const coupon of activeCoupons) { if (alreadySucceeded.has(coupon.code)) { results.push({ name: coupon.name, code: coupon.code, status: 'skipped', message: '이미 지급 완료' }); continue; } let result: { success: boolean; message: string; err_code?: number }; try { result = await this.redeemOne(fid, coupon.code); } catch (err: any) { result = { success: false, message: err.message || '오류 발생' }; } if (result.err_code === 40008 || result.err_code === 40011 || result.err_code === 40005) { result = { success: true, message: ALREADY_MSG, err_code: result.err_code }; } 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, 5000)); } 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) { return this.couponLogRepo.find({ where: { fid }, order: { executed_at: 'DESC' }, take: 100, }); } async listUsers() { return this.wosUserRepo.find({ order: { created_at: 'DESC' } }); } async searchUsersByNickname(nickname: string) { return this.wosUserRepo .createQueryBuilder('u') .where('u.nickname LIKE :q', { q: `%${nickname}%` }) .orderBy('u.nickname', 'ASC') .limit(20) .getMany(); } 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; exists.is_expired = false; await this.wosCouponRepo.save(exists); } else { await this.wosCouponRepo.save({ name, code, is_active: true, is_expired: false }); } this.redeemCouponForAllUsers(code).catch((err) => this.logger.error(`신규 쿠폰 전체 지급 실패: ${err.message}`), ); return this.wosCouponRepo.findOne({ where: { code } }); } private async redeemCouponForAllUsers(code: string) { const users = await this.wosUserRepo.find(); for (const user of users) { const alreadyDone = await this.couponLogRepo.findOne({ where: { fid: user.fid, coupon_code: code, result_msg: In([SUCCESS_MSG, ALREADY_MSG]) }, }); if (alreadyDone) continue; try { const result = await this.redeemOne(user.fid, code); const finalResult = result.err_code === 40008 || result.err_code === 40011 || result.err_code === 40005 ? { success: true, message: ALREADY_MSG } : result; if (result.err_code === 40007) { const coupon = await this.wosCouponRepo.findOne({ where: { code } }); if (coupon) await this.wosCouponRepo.update(coupon.id, { is_expired: true, is_active: false }); await this.couponLogRepo.save({ fid: user.fid, coupon_code: code, result_msg: '만료된 쿠폰' }); break; } await this.couponLogRepo.save({ fid: user.fid, coupon_code: code, result_msg: finalResult.message }); this.logger.log(`[${user.fid}] ${code} → ${finalResult.message}`); } catch (err: any) { this.logger.error(`[${user.fid}] ${code} 지급 실패: ${err.message}`); } await new Promise((r) => setTimeout(r, 5000)); } } 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 }; } }