initial commit
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { WosModule } from './wos/wos.module';
|
||||
import { CrawlerModule } from './crawler/crawler.module';
|
||||
import { WosUser } from './entities/wos-user.entity';
|
||||
import { CouponLog } from './entities/coupon-log.entity';
|
||||
import { WosCoupon } from './entities/wos-coupon.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ScheduleModule.forRoot(),
|
||||
TypeOrmModule.forRoot({
|
||||
type: 'mysql',
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: Number(process.env.DB_PORT) || 3306,
|
||||
username: process.env.DB_USERNAME || 'kakao',
|
||||
password: process.env.DB_PASSWORD || '486251daKWON@',
|
||||
database: process.env.DB_DATABASE || 'kakao',
|
||||
entities: [WosUser, CouponLog, WosCoupon],
|
||||
synchronize: true,
|
||||
}),
|
||||
WosModule,
|
||||
CrawlerModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Controller, Post, HttpCode } from '@nestjs/common';
|
||||
import { CrawlerService } from './crawler.service';
|
||||
|
||||
@Controller('api/crawler')
|
||||
export class CrawlerController {
|
||||
constructor(private readonly crawlerService: CrawlerService) {}
|
||||
|
||||
@Post('trigger')
|
||||
@HttpCode(200)
|
||||
async triggerCrawl() {
|
||||
return this.crawlerService.crawlWiki();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { CrawlerController } from './crawler.controller';
|
||||
import { CrawlerService } from './crawler.service';
|
||||
import { WosCoupon } from '../entities/wos-coupon.entity';
|
||||
import { WosModule } from '../wos/wos.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([WosCoupon]), WosModule],
|
||||
controllers: [CrawlerController],
|
||||
providers: [CrawlerService],
|
||||
})
|
||||
export class CrawlerModule {}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import axios from 'axios';
|
||||
import { WosCoupon } from '../entities/wos-coupon.entity';
|
||||
import { WosService } from '../wos/wos.service';
|
||||
|
||||
const WIKI_URL = 'https://www.whiteoutsurvival.wiki/giftcodes/';
|
||||
|
||||
@Injectable()
|
||||
export class CrawlerService {
|
||||
private readonly logger = new Logger(CrawlerService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(WosCoupon)
|
||||
private wosCouponRepo: Repository<WosCoupon>,
|
||||
private readonly wosService: WosService,
|
||||
) {}
|
||||
|
||||
private extractCodesFromHtml(html: string): string[] {
|
||||
const found: string[] = [];
|
||||
const pattern = /<span[^>]*class="code"[^>]*>([^<]+)<\/span>/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(html)) !== null) {
|
||||
const code = match[1].trim();
|
||||
if (code.length > 0) found.push(code);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
async crawlWiki(): Promise<{ found: number; added: string[] }> {
|
||||
this.logger.log('Wiki 쿠폰 크롤링 시작...');
|
||||
|
||||
let html: string;
|
||||
try {
|
||||
const resp = await axios.get(WIKI_URL, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
'Accept': 'text/html',
|
||||
},
|
||||
timeout: 10000,
|
||||
});
|
||||
html = resp.data as string;
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Wiki 요청 실패: ${err.message}`);
|
||||
return { found: 0, added: [] };
|
||||
}
|
||||
|
||||
const wikiCodes = this.extractCodesFromHtml(html);
|
||||
this.logger.log(`Wiki에서 ${wikiCodes.length}개 코드 발견`);
|
||||
|
||||
if (wikiCodes.length === 0) return { found: 0, added: [] };
|
||||
|
||||
const existingCoupons = await this.wosCouponRepo.find();
|
||||
const existingCodes = new Set(existingCoupons.map((c) => c.code));
|
||||
|
||||
const addedCodes: string[] = [];
|
||||
|
||||
for (const code of wikiCodes) {
|
||||
if (existingCodes.has(code)) continue;
|
||||
|
||||
this.logger.log(`새 쿠폰 발견: ${code}`);
|
||||
try {
|
||||
await this.wosService.addCoupon(`Wiki: ${code}`, code);
|
||||
existingCodes.add(code);
|
||||
addedCodes.push(code);
|
||||
this.logger.log(`쿠폰 추가 완료: ${code}`);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`쿠폰 추가 실패 [${code}]: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (addedCodes.length > 0) {
|
||||
await this.sendDiscordNotification(addedCodes);
|
||||
}
|
||||
|
||||
this.logger.log(`Wiki 크롤링 완료 - 신규 쿠폰: ${addedCodes.length}개`);
|
||||
return { found: wikiCodes.length, added: addedCodes };
|
||||
}
|
||||
|
||||
private async sendDiscordNotification(codes: string[]): Promise<void> {
|
||||
const webhookUrl = process.env.DISCORD_WEBHOOK_URL;
|
||||
if (!webhookUrl) return;
|
||||
|
||||
const codeList = codes.map((c) => `\`${c}\``).join(', ');
|
||||
const payload = {
|
||||
username: 'WOS 쿠폰 봇',
|
||||
avatar_url: 'https://wos-giftcode.centurygame.com/favicon.ico',
|
||||
embeds: [
|
||||
{
|
||||
title: '🎁 새 WOS 쿠폰 발견!',
|
||||
description: `Wiki에서 새로운 쿠폰 **${codes.length}개**를 찾았어요!\n\n${codeList}`,
|
||||
color: 0x00b0f4,
|
||||
footer: { text: 'WOS 쿠폰 자동 크롤러 | whiteoutsurvival.wiki' },
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
try {
|
||||
await axios.post(webhookUrl, payload, { timeout: 5000 });
|
||||
this.logger.log(`Discord 알림 전송 완료 (${codes.length}개 쿠폰)`);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Discord 알림 실패: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@Cron('0 0 * * * *')
|
||||
async scheduledCrawl() {
|
||||
await this.crawlWiki();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity('coupon_logs')
|
||||
export class CouponLog {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ length: 20 })
|
||||
fid: string;
|
||||
|
||||
@Column({ length: 50 })
|
||||
coupon_code: string;
|
||||
|
||||
@Column({ length: 100, nullable: true })
|
||||
result_msg: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
executed_at: Date;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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;
|
||||
|
||||
@Column({ default: false })
|
||||
is_expired: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity('wos_users')
|
||||
export class WosUser {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ unique: true, length: 20 })
|
||||
fid: string;
|
||||
|
||||
@Column({ length: 50, nullable: true })
|
||||
nickname: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
avatar_url: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
app.enableCors({
|
||||
origin: '*',
|
||||
methods: ['GET', 'POST'],
|
||||
allowedHeaders: ['Content-Type'],
|
||||
});
|
||||
|
||||
const port = process.env.APP_PORT || 3000;
|
||||
await app.listen(port);
|
||||
console.log(`Server running on port ${port}`);
|
||||
}
|
||||
bootstrap();
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Controller, Post, Get, Delete, Patch, Body, Param, Query, HttpCode } from '@nestjs/common';
|
||||
import { WosService } from './wos.service';
|
||||
|
||||
@Controller('api')
|
||||
export class WosController {
|
||||
constructor(private readonly wosService: WosService) {}
|
||||
|
||||
@Post('player')
|
||||
@HttpCode(200)
|
||||
async getPlayer(@Body() body: { fid: string }) {
|
||||
return this.wosService.getPlayer(body.fid);
|
||||
}
|
||||
|
||||
@Post('redeem-all')
|
||||
@HttpCode(200)
|
||||
async redeemAll(@Body() body: { fid: string }) {
|
||||
return this.wosService.redeemAll(body.fid);
|
||||
}
|
||||
|
||||
@Post('redeem')
|
||||
@HttpCode(200)
|
||||
async redeem(@Body() body: { fid: string }) {
|
||||
return this.wosService.redeemAll(body.fid);
|
||||
}
|
||||
|
||||
@Get('users')
|
||||
async listUsers() {
|
||||
return this.wosService.listUsers();
|
||||
}
|
||||
|
||||
@Get('users/search')
|
||||
async searchUsers(@Query('q') q: string) {
|
||||
if (!q || q.trim().length === 0) return [];
|
||||
return this.wosService.searchUsersByNickname(q.trim());
|
||||
}
|
||||
|
||||
@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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
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, WosCoupon])],
|
||||
controllers: [WosController],
|
||||
providers: [WosService],
|
||||
exports: [WosService],
|
||||
})
|
||||
export class WosModule {}
|
||||
@@ -0,0 +1,304 @@
|
||||
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<WosUser>,
|
||||
@InjectRepository(CouponLog)
|
||||
private couponLogRepo: Repository<CouponLog>,
|
||||
@InjectRepository(WosCoupon)
|
||||
private wosCouponRepo: Repository<WosCoupon>,
|
||||
) {}
|
||||
|
||||
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<string> {
|
||||
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,
|
||||
maxRetries = 5,
|
||||
): Promise<{ success: boolean; message: string; err_code?: number }> {
|
||||
const ts1 = this.now();
|
||||
const playerSign = this.makePlayerSign(fid, ts1);
|
||||
await axios.post(
|
||||
`${WOS_API_BASE}/player`,
|
||||
new URLSearchParams({ fid, time: String(ts1), sign: playerSign }),
|
||||
{ headers: { ...COMMON_HEADERS } },
|
||||
);
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
let captchaCode: string;
|
||||
try {
|
||||
captchaCode = await this.solveCaptcha(fid);
|
||||
} catch (err: any) {
|
||||
return { success: false, message: `캡차 오류: ${err.message}` };
|
||||
}
|
||||
|
||||
const ts2 = this.now();
|
||||
const giftSign = this.makeGiftSign(fid, ts2, code, captchaCode);
|
||||
const response = await axios.post(
|
||||
`${WOS_API_BASE}/gift_code`,
|
||||
new URLSearchParams({ fid, time: String(ts2), 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) {
|
||||
if (attempt < maxRetries) {
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
continue;
|
||||
}
|
||||
return { success: false, message: `캡차 인식 실패 (${maxRetries}회 시도)`, err_code: data.err_code };
|
||||
}
|
||||
return { success: false, message: data.msg || `실패 (${data.err_code})`, err_code: data.err_code };
|
||||
}
|
||||
|
||||
return { success: false, message: '알 수 없는 오류' };
|
||||
}
|
||||
|
||||
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 = { 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, 1500));
|
||||
}
|
||||
|
||||
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
|
||||
? { 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, 1500));
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user