feat: 전적검색 화면, 관리자 화면 추가
This commit is contained in:
@@ -16,6 +16,8 @@ import { RankingModule } from './modules/ranking/ranking.module';
|
||||
import { SaUserModule } from './modules/sa-user/sa-user.module';
|
||||
import { SaMetaModule } from './modules/sa-meta/sa-meta.module';
|
||||
import { SaMatchModule } from './modules/sa-match/sa-match.module';
|
||||
import { SaSeasonModule } from './modules/sa-season/sa-season.module';
|
||||
import { SaMapModule } from './modules/sa-map/sa-map.module';
|
||||
import { NexonModule } from './shared/nexon/nexon.module';
|
||||
|
||||
@Module({
|
||||
@@ -73,6 +75,8 @@ import { NexonModule } from './shared/nexon/nexon.module';
|
||||
SaUserModule,
|
||||
SaMetaModule,
|
||||
SaMatchModule,
|
||||
SaSeasonModule,
|
||||
SaMapModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
|
||||
@@ -13,33 +13,52 @@ import { Response } from 'express';
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(HttpExceptionFilter.name);
|
||||
|
||||
// HTTP 상태코드 → (에러코드, 메시지) 매핑. 숫자 키 룩업이라 enum 비교가 없다.
|
||||
private static readonly STATUS_MAP: Record<
|
||||
number,
|
||||
{ code: string; message: string }
|
||||
> = {
|
||||
[HttpStatus.UNAUTHORIZED]: {
|
||||
code: 'AUTH_001',
|
||||
message: '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
|
||||
},
|
||||
[HttpStatus.FORBIDDEN]: {
|
||||
code: 'AUTH_003',
|
||||
message: '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.',
|
||||
},
|
||||
[HttpStatus.NOT_FOUND]: {
|
||||
code: 'RES_001',
|
||||
message: '요청하신 정보나 페이지를 찾을 수 없습니다.',
|
||||
},
|
||||
[HttpStatus.BAD_REQUEST]: {
|
||||
code: 'VAL_001',
|
||||
message:
|
||||
'입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
|
||||
},
|
||||
[HttpStatus.UNPROCESSABLE_ENTITY]: {
|
||||
code: 'VAL_001',
|
||||
message:
|
||||
'입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
|
||||
},
|
||||
};
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
|
||||
let status: number = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let code = 'SYS_001';
|
||||
let message = '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.';
|
||||
let message =
|
||||
'일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.';
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
|
||||
if (status === HttpStatus.UNAUTHORIZED) {
|
||||
code = 'AUTH_001';
|
||||
message = '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.';
|
||||
} else if (status === HttpStatus.FORBIDDEN) {
|
||||
code = 'AUTH_003';
|
||||
message = '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.';
|
||||
} else if (status === HttpStatus.NOT_FOUND) {
|
||||
code = 'RES_001';
|
||||
message = '요청하신 정보나 페이지를 찾을 수 없습니다.';
|
||||
} else if (
|
||||
status === HttpStatus.BAD_REQUEST ||
|
||||
status === HttpStatus.UNPROCESSABLE_ENTITY
|
||||
) {
|
||||
code = 'VAL_001';
|
||||
message = '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)';
|
||||
const mapped = HttpExceptionFilter.STATUS_MAP[status];
|
||||
if (mapped) {
|
||||
code = mapped.code;
|
||||
message = mapped.message;
|
||||
} else {
|
||||
code = 'BIZ_001';
|
||||
message = '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.';
|
||||
@@ -50,7 +69,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
exceptionResponse !== null &&
|
||||
'message' in exceptionResponse
|
||||
) {
|
||||
const responseMessage = (exceptionResponse as { message: unknown }).message;
|
||||
const responseMessage = exceptionResponse.message;
|
||||
if (typeof responseMessage === 'string') {
|
||||
message = responseMessage;
|
||||
}
|
||||
|
||||
@@ -13,18 +13,19 @@ export interface StandardResponse<T> {
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TransformInterceptor<T>
|
||||
implements NestInterceptor<T, StandardResponse<T>>
|
||||
{
|
||||
export class TransformInterceptor<T> implements NestInterceptor<
|
||||
T,
|
||||
StandardResponse<T>
|
||||
> {
|
||||
intercept(
|
||||
_context: ExecutionContext,
|
||||
next: CallHandler,
|
||||
): Observable<StandardResponse<T>> {
|
||||
// 이미 Http 응답객체인 경우 등 예외처리가 필요할 수 있으나 기본적으로 data 래핑
|
||||
return next.handle().pipe(
|
||||
map((data) => ({
|
||||
map((data: T) => ({
|
||||
success: true,
|
||||
data: data !== undefined ? data : null,
|
||||
data: data ?? (null as T),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
+14
-6
@@ -5,7 +5,11 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { WinstonModule } from 'nest-winston';
|
||||
import helmet from 'helmet';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import type { Request as ExpressRequest, Response as ExpressResponse } from 'express';
|
||||
import type {
|
||||
Application,
|
||||
Request as ExpressRequest,
|
||||
Response as ExpressResponse,
|
||||
} from 'express';
|
||||
|
||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
||||
@@ -17,7 +21,7 @@ async function bootstrap() {
|
||||
});
|
||||
|
||||
// 리버스 프록시(Nginx, Docker 배포 환경 등) 뒤에서 올바른 클라이언트 IP 식별 지원을 위해 설정합니다.
|
||||
const expressApp = app.getHttpAdapter().getInstance();
|
||||
const expressApp = app.getHttpAdapter().getInstance() as Application;
|
||||
expressApp.set('trust proxy', 1);
|
||||
|
||||
// 글로벌 prefix — 모든 라우트가 /api/* 로 정규화됨 (Flutter, Web 양쪽 baseURL과 일치)
|
||||
@@ -66,8 +70,9 @@ async function bootstrap() {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'SYS_001',
|
||||
message: '일시적인 시스템 오류가 발생했습니다. (요청 한도 초과) 잠시 후 다시 시도해 주세요.', // 글로벌 통합 에러 포맷
|
||||
}
|
||||
message:
|
||||
'일시적인 시스템 오류가 발생했습니다. (요청 한도 초과) 잠시 후 다시 시도해 주세요.', // 글로벌 통합 에러 포맷
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -98,6 +103,9 @@ async function bootstrap() {
|
||||
const port = Number(process.env.PORT ?? 3000);
|
||||
await app.listen(port, '0.0.0.0');
|
||||
Logger.log(`🚀 Backend running on http://localhost:${port}/api`, 'Bootstrap');
|
||||
Logger.log(`📘 Swagger docs at http://localhost:${port}/api-docs`, 'Bootstrap');
|
||||
Logger.log(
|
||||
`📘 Swagger docs at http://localhost:${port}/api-docs`,
|
||||
'Bootstrap',
|
||||
);
|
||||
}
|
||||
bootstrap();
|
||||
void bootstrap();
|
||||
|
||||
@@ -23,7 +23,10 @@ export class RankingItemDto {
|
||||
@ApiProperty({ description: '킬데스', example: '56.1%' })
|
||||
kd!: string;
|
||||
|
||||
@ApiProperty({ description: '전적/참여판수', example: '118540승 32289패 24무' })
|
||||
@ApiProperty({
|
||||
description: '전적/참여판수',
|
||||
example: '118540승 32289패 24무',
|
||||
})
|
||||
record!: string;
|
||||
|
||||
@ApiProperty({ description: 'RP/클랜RP', example: '5,063' })
|
||||
|
||||
@@ -41,7 +41,9 @@ export class RankingService implements OnModuleInit {
|
||||
// 카테고리별 보관 개수 (홈 랭킹 카드 TOP 10)
|
||||
private static readonly PER_CATEGORY = 10;
|
||||
// AJAX 엔드포인트 호출용 헤더
|
||||
private static readonly AJAX_HEADERS = { 'X-Requested-With': 'XMLHttpRequest' };
|
||||
private static readonly AJAX_HEADERS = {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
};
|
||||
|
||||
constructor(
|
||||
@InjectRepository(SaRankingEntity)
|
||||
@@ -380,7 +382,9 @@ export class RankingService implements OnModuleInit {
|
||||
this.dataRows($).each((i, tr) => {
|
||||
const row = $(tr);
|
||||
const tds = row.find('> td');
|
||||
const name = this.txt(tds.eq(2).find('a b').first()) || this.txt(tds.eq(2).find('a').first());
|
||||
const name =
|
||||
this.txt(tds.eq(2).find('a b').first()) ||
|
||||
this.txt(tds.eq(2).find('a').first());
|
||||
if (!name) return;
|
||||
out.push({
|
||||
rankNo: i + 1,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
// 맵 등록 요청 DTO (관리자)
|
||||
export class CreateMapDto {
|
||||
@ApiProperty({ description: '맵 이름', example: '제3보급창고' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '맵 이름을 입력해 주세요.' })
|
||||
@MaxLength(50)
|
||||
name!: string;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 맵 응답 DTO
|
||||
export class MapResponseDto {
|
||||
@ApiProperty({ example: 1 })
|
||||
id!: number;
|
||||
|
||||
@ApiProperty({ example: '제3보급창고' })
|
||||
name!: string;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
|
||||
import { CreateMapDto } from './create-map.dto';
|
||||
|
||||
// 맵 수정 요청 DTO (관리자) — 모든 필드 선택적
|
||||
export class UpdateMapDto extends PartialType(CreateMapDto) {}
|
||||
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
// 서든어택 맵 — 관리자가 이름으로 등록. (5v5/3v3 구분은 시즌 설정에서 토글로 정한다)
|
||||
@Entity('sa_maps')
|
||||
@Index(['name'], { unique: true })
|
||||
export class SaMapEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
// 맵 이름 (매치 상세의 matchMap 과 동일 문자열이어야 전적 연동 가능)
|
||||
@Column({ type: 'varchar', length: 50 })
|
||||
name!: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { SaMapService } from './sa-map.service';
|
||||
import { CreateMapDto } from './dto/create-map.dto';
|
||||
import { UpdateMapDto } from './dto/update-map.dto';
|
||||
import { MapResponseDto } from './dto/map-response.dto';
|
||||
|
||||
@ApiTags('SA Map')
|
||||
@Controller('sa-maps')
|
||||
export class SaMapController {
|
||||
constructor(private readonly mapService: SaMapService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: '맵 목록 조회',
|
||||
description: '등록된 서든어택 맵 목록을 모드→이름 순으로 반환합니다.',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '맵 목록', type: [MapResponseDto] })
|
||||
findAll(): Promise<MapResponseDto[]> {
|
||||
return this.mapService.findAll();
|
||||
}
|
||||
|
||||
// ── 관리자 전용 ─────────────────────────────────────────────
|
||||
// TODO: 인증 도입 시 AdminGuard 적용 (현재는 가드 미적용)
|
||||
@Post()
|
||||
@ApiOperation({ summary: '[관리자] 맵 등록' })
|
||||
@ApiResponse({ status: 201, description: '등록된 맵', type: MapResponseDto })
|
||||
create(@Body() dto: CreateMapDto): Promise<MapResponseDto> {
|
||||
return this.mapService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: '[관리자] 맵 수정' })
|
||||
@ApiResponse({ status: 200, description: '수정된 맵', type: MapResponseDto })
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateMapDto,
|
||||
): Promise<MapResponseDto> {
|
||||
return this.mapService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: '[관리자] 맵 삭제' })
|
||||
@ApiResponse({ status: 200, description: '삭제 완료' })
|
||||
remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||
return this.mapService.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { SaMapEntity } from './entities/sa-map.entity';
|
||||
import { SaMapService } from './sa-map.service';
|
||||
import { SaMapController } from './sa-map.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([SaMapEntity])],
|
||||
controllers: [SaMapController],
|
||||
providers: [SaMapService],
|
||||
exports: [SaMapService],
|
||||
})
|
||||
export class SaMapModule {}
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { QueryFailedError, Repository } from 'typeorm';
|
||||
|
||||
import { SaMapEntity } from './entities/sa-map.entity';
|
||||
import { CreateMapDto } from './dto/create-map.dto';
|
||||
import { UpdateMapDto } from './dto/update-map.dto';
|
||||
import { MapResponseDto } from './dto/map-response.dto';
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 서든어택 맵 — 관리자 등록 CRUD. 시즌 설정에서 모드별 토글 목록으로 사용된다.
|
||||
// ────────────────────────────────────────────────────────────
|
||||
@Injectable()
|
||||
export class SaMapService {
|
||||
constructor(
|
||||
@InjectRepository(SaMapEntity)
|
||||
private readonly repo: Repository<SaMapEntity>,
|
||||
) {}
|
||||
|
||||
/** 맵 목록 — 이름 순. */
|
||||
async findAll(): Promise<MapResponseDto[]> {
|
||||
const rows = await this.repo.find({ order: { name: 'ASC' } });
|
||||
return rows.map((r) => this.toDto(r));
|
||||
}
|
||||
|
||||
async create(dto: CreateMapDto): Promise<MapResponseDto> {
|
||||
try {
|
||||
const saved = await this.repo.save(this.repo.create(dto));
|
||||
return this.toDto(saved);
|
||||
} catch (e) {
|
||||
// 이름 unique 충돌
|
||||
if (e instanceof QueryFailedError) {
|
||||
throw new ConflictException('이미 등록된 맵 이름입니다.');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: number, dto: UpdateMapDto): Promise<MapResponseDto> {
|
||||
const map = await this.repo.findOne({ where: { id } });
|
||||
if (!map) {
|
||||
throw new NotFoundException('요청하신 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
Object.assign(map, dto);
|
||||
try {
|
||||
return this.toDto(await this.repo.save(map));
|
||||
} catch (e) {
|
||||
if (e instanceof QueryFailedError) {
|
||||
throw new ConflictException('이미 등록된 맵 이름입니다.');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: number): Promise<void> {
|
||||
const res = await this.repo.delete({ id });
|
||||
if (!res.affected) {
|
||||
throw new NotFoundException('요청하신 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(r: SaMapEntity): MapResponseDto {
|
||||
return { id: r.id, name: r.name };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// [개발 전용] 매치 데이터 적재 현황 DTO — 테이블별 행 수
|
||||
export class DevStatsDto {
|
||||
@ApiProperty({
|
||||
description: '등록 유저 수 (sa_users) — 상세 백필 캡은 유저당 적용',
|
||||
example: 2,
|
||||
})
|
||||
users!: number;
|
||||
|
||||
@ApiProperty({ description: '매치 목록 행 수 (sa_matches)', example: 1200 })
|
||||
matches!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '상세 헤더 행 수 (sa_match_details = 맵 적재 완료 매치)',
|
||||
example: 340,
|
||||
})
|
||||
details!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '참가자 행 수 (sa_match_participants)',
|
||||
example: 3400,
|
||||
})
|
||||
participants!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '상세 적재 실패 누적 행 수 (sa_match_detail_failures)',
|
||||
example: 5,
|
||||
})
|
||||
failures!: number;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsString,
|
||||
} from 'class-validator';
|
||||
|
||||
// 매치 맵 일괄 확보 요청 DTO — 화면에 보이는 매치들의 상세를 동기 적재하기 위해 사용
|
||||
export class EnsureMapsDto {
|
||||
@ApiProperty({
|
||||
description: '맵 이름을 확보할 매치 ID 목록',
|
||||
type: [String],
|
||||
example: ['match_id_1', 'match_id_2'],
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayNotEmpty({ message: 'matchIds를 1개 이상 입력해 주세요.' })
|
||||
// 화면에 보이는 매치만 대상이므로 과도한 요청을 막기 위해 상한을 둔다
|
||||
@ArrayMaxSize(100, {
|
||||
message: 'matchIds는 한 번에 최대 100개까지 요청할 수 있습니다.',
|
||||
})
|
||||
@IsString({ each: true })
|
||||
matchIds!: string[];
|
||||
}
|
||||
@@ -13,7 +13,10 @@ export class MatchDetailDto {
|
||||
@ApiProperty({ example: '폭파미션' })
|
||||
matchMode!: string;
|
||||
|
||||
@ApiProperty({ description: '매치 일시(ISO)', example: '2026-06-07T05:50:24.532Z' })
|
||||
@ApiProperty({
|
||||
description: '매치 일시(ISO)',
|
||||
example: '2026-06-07T05:50:24.532Z',
|
||||
})
|
||||
dateMatch!: string;
|
||||
|
||||
@ApiProperty({ description: '매치 맵', example: '제3보급창고' })
|
||||
|
||||
@@ -11,7 +11,10 @@ export class MatchItemDto {
|
||||
@ApiProperty({ example: '폭파미션' })
|
||||
matchMode!: string;
|
||||
|
||||
@ApiProperty({ description: '매치 일시(ISO)', example: '2026-06-07T05:50:24.532Z' })
|
||||
@ApiProperty({
|
||||
description: '매치 일시(ISO)',
|
||||
example: '2026-06-07T05:50:24.532Z',
|
||||
})
|
||||
dateMatch!: string;
|
||||
|
||||
@ApiProperty({ description: '매치 결과 코드(본인 기준)', example: '1' })
|
||||
@@ -27,7 +30,8 @@ export class MatchItemDto {
|
||||
assist!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '매치 맵 — 상세 캐시 보유 시에만 값 존재, 미보유면 빈 문자열(백그라운드 백필)',
|
||||
description:
|
||||
'매치 맵 — 상세 캐시 보유 시에만 값 존재, 미보유면 빈 문자열(백그라운드 백필)',
|
||||
example: '제3보급창고',
|
||||
})
|
||||
matchMap!: string;
|
||||
|
||||
@@ -3,7 +3,10 @@ import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
// 매치 목록 조회 쿼리 DTO
|
||||
export class MatchQueryDto {
|
||||
@ApiProperty({ description: '유저 OUID', example: '9b2852e25a85deebc5ce4173b1ccc1e6' })
|
||||
@ApiProperty({
|
||||
description: '유저 OUID',
|
||||
example: '9b2852e25a85deebc5ce4173b1ccc1e6',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'ouid를 입력해 주세요.' })
|
||||
ouid!: string;
|
||||
@@ -11,13 +14,23 @@ export class MatchQueryDto {
|
||||
@ApiProperty({
|
||||
description: '매치 타입',
|
||||
example: '랭크전 솔로',
|
||||
enum: ['퀵매치 클랜전', '클랜 랭크전', '랭크전 솔로', '랭크전 파티', '토너먼트'],
|
||||
enum: [
|
||||
'퀵매치 클랜전',
|
||||
'클랜 랭크전',
|
||||
'랭크전 솔로',
|
||||
'랭크전 파티',
|
||||
'토너먼트',
|
||||
],
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'match_type을 입력해 주세요.' })
|
||||
match_type!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '매치 모드', example: '폭파미션', default: '폭파미션' })
|
||||
@ApiPropertyOptional({
|
||||
description: '매치 모드',
|
||||
example: '폭파미션',
|
||||
default: '폭파미션',
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
match_mode: string = '폭파미션';
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||
|
||||
// 매치 상세 참가자 (lazy) — 한 매치의 양 팀 전원 성적. match-detail API 로 적재.
|
||||
@Entity('sa_match_participants')
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { SaMatchService } from './sa-match.service';
|
||||
import { MatchQueryDto } from './dto/match-query.dto';
|
||||
import { MatchItemDto } from './dto/match-item.dto';
|
||||
import { MatchDetailDto } from './dto/match-detail.dto';
|
||||
import { EnsureMapsDto } from './dto/ensure-maps.dto';
|
||||
import { DevStatsDto } from './dto/dev-stats.dto';
|
||||
|
||||
@ApiTags('SA Match')
|
||||
@Controller('sa-matches')
|
||||
@@ -19,7 +29,56 @@ export class SaMatchController {
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '매치 목록', type: [MatchItemDto] })
|
||||
getMatches(@Query() dto: MatchQueryDto): Promise<MatchItemDto[]> {
|
||||
return this.saMatchService.getMatches(dto.ouid, dto.match_type, dto.match_mode);
|
||||
return this.saMatchService.getMatches(
|
||||
dto.ouid,
|
||||
dto.match_type,
|
||||
dto.match_mode,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('details/maps')
|
||||
@ApiOperation({
|
||||
summary: '매치 맵 일괄 확보 (보이는 매치)',
|
||||
description:
|
||||
'화면에 표시되는 매치들의 맵 이름을 반환합니다. 캐시 미보유 매치는 상세를 동기로 적재한 뒤 반환하므로, 응답 시점에 보이는 매치는 맵이 채워져 있습니다.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'matchId → 맵 이름 맵 (확보 실패 매치는 빈 문자열)',
|
||||
})
|
||||
ensureMaps(@Body() dto: EnsureMapsDto): Promise<Record<string, string>> {
|
||||
return this.saMatchService.ensureDetailMaps(dto.matchIds);
|
||||
}
|
||||
|
||||
@Post('dev/sync')
|
||||
@ApiOperation({
|
||||
summary: '[개발 전용] 매치 동기화 수동 트리거',
|
||||
description:
|
||||
'야간 크론(전 유저 목록 동기화 + 상세 백필)을 즉시 백그라운드로 실행합니다. 운영 환경에서는 비활성화됩니다.',
|
||||
})
|
||||
@ApiResponse({ status: 201, description: '트리거 결과' })
|
||||
devSync(): { triggered: boolean } {
|
||||
// 운영에서는 노출하지 않는다 (쿼터/부하 보호)
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new NotFoundException('요청하신 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
// 응답을 막지 않도록 백그라운드 실행 (fire-and-forget)
|
||||
void this.saMatchService.handleDailyMatchSync();
|
||||
return { triggered: true };
|
||||
}
|
||||
|
||||
@Get('dev/stats')
|
||||
@ApiOperation({
|
||||
summary: '[개발 전용] 매치 데이터 적재 현황',
|
||||
description:
|
||||
'매치 목록/상세/참가자/실패 테이블의 행 수를 반환합니다. dev/sync 진행 확인용. 운영 환경에서는 비활성화됩니다.',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '적재 현황', type: DevStatsDto })
|
||||
devStats(): Promise<DevStatsDto> {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new NotFoundException('요청하신 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
return this.saMatchService.getDevStats();
|
||||
}
|
||||
|
||||
@Get(':matchId')
|
||||
|
||||
@@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, MoreThanOrEqual, Repository } from 'typeorm';
|
||||
|
||||
import { NexonService } from '../../shared/nexon/nexon.service';
|
||||
import { runWithConcurrency } from '../../shared/utils/concurrent';
|
||||
import { SaUserEntity } from '../sa-user/entities/sa-user.entity';
|
||||
import { MatchItemDto } from './dto/match-item.dto';
|
||||
import { MatchDetailDto } from './dto/match-detail.dto';
|
||||
@@ -68,17 +69,22 @@ export class SaMatchService {
|
||||
'랭크전 파티',
|
||||
'토너먼트',
|
||||
];
|
||||
// 목록 조회 시 상세(맵) 백필 한도 — 프론트 더보기 노출 개수와 동일 (개발 4 / 운영 20)
|
||||
private static readonly BACKFILL_LIMIT =
|
||||
process.env.NODE_ENV === 'production' ? 20 : 4;
|
||||
// 크론 백필 유저당 한도 — 개발은 넥슨 일일 쿼터(1,000건/일) 보호를 위해 20건, 운영은 무제한(null)
|
||||
private static readonly CRON_PER_USER_BACKFILL_LIMIT: number | null =
|
||||
process.env.NODE_ENV === 'production' ? null : 20;
|
||||
// 상세 적재 실패 허용 횟수 — 초과한 매치(넥슨 미제공 추정)는 백필 대상에서 영구 제외
|
||||
private static readonly MAX_DETAIL_FAILURES = 3;
|
||||
// 상세 백필 동시 워커 수 — env MATCH_BACKFILL_CONCURRENCY (기본: 운영 16 / 개발 2)
|
||||
// 각 호출은 NexonService 전역 rate limiter 를 통과하므로 동시성을 높여도 초당 한도는 안전하게 유지된다.
|
||||
private static readonly BACKFILL_CONCURRENCY = ((): number => {
|
||||
const raw = Number(process.env.MATCH_BACKFILL_CONCURRENCY);
|
||||
if (Number.isFinite(raw) && raw > 0) return Math.floor(raw);
|
||||
return process.env.NODE_ENV === 'production' ? 16 : 2;
|
||||
})();
|
||||
|
||||
// 백필 중복 실행 방지 — 진행 중인 matchId 집합
|
||||
private readonly backfillInFlight = new Set<string>();
|
||||
// 백필 중복 실행 방지 + 진행 중 적재 공유 — matchId → 적재 Promise
|
||||
// 같은 매치를 동시에 요청하면 새 호출 대신 진행 중인 Promise 를 await 한다(중복 넥슨 호출 방지 + 정확성).
|
||||
private readonly backfillInFlight = new Map<string, Promise<void>>();
|
||||
|
||||
constructor(
|
||||
@InjectRepository(SaMatchEntity)
|
||||
@@ -94,6 +100,26 @@ export class SaMatchService {
|
||||
private readonly nexon: NexonService,
|
||||
) {}
|
||||
|
||||
/** [개발 전용] 매치 데이터 적재 현황 — 테이블별 행 수. */
|
||||
async getDevStats(): Promise<{
|
||||
users: number;
|
||||
matches: number;
|
||||
details: number;
|
||||
participants: number;
|
||||
failures: number;
|
||||
}> {
|
||||
const [users, matches, details, participants, failures] = await Promise.all(
|
||||
[
|
||||
this.userRepo.count(),
|
||||
this.matchRepo.count(),
|
||||
this.detailRepo.count(),
|
||||
this.participantRepo.count(),
|
||||
this.failureRepo.count(),
|
||||
],
|
||||
);
|
||||
return { users, matches, details, participants, failures };
|
||||
}
|
||||
|
||||
/** 매치 목록 조회 — 최신분 누적 후 DB에서 반환. 맵은 상세 캐시에서 조인해 채운다. */
|
||||
async getMatches(
|
||||
ouid: string,
|
||||
@@ -117,27 +143,9 @@ export class SaMatchService {
|
||||
: [];
|
||||
const mapById = new Map(details.map((d) => [d.matchId, d.matchMap]));
|
||||
|
||||
// 맵 미보유 최근 매치는 백그라운드 백필 (응답은 기다리지 않음)
|
||||
const missingIds = saved
|
||||
.filter((m) => !mapById.has(m.matchId))
|
||||
.map((m) => m.matchId);
|
||||
if (missingIds.length) {
|
||||
// 실패 한도를 초과한 매치(넥슨 미제공 추정)는 제외해 쿼터 낭비를 막는다
|
||||
const dead = await this.failureRepo.find({
|
||||
where: {
|
||||
matchId: In(missingIds),
|
||||
failCount: MoreThanOrEqual(SaMatchService.MAX_DETAIL_FAILURES),
|
||||
},
|
||||
select: ['matchId'],
|
||||
});
|
||||
const deadSet = new Set(dead.map((f) => f.matchId));
|
||||
this.backfillDetails(
|
||||
missingIds
|
||||
.filter((id) => !deadSet.has(id))
|
||||
.slice(0, SaMatchService.BACKFILL_LIMIT),
|
||||
);
|
||||
}
|
||||
|
||||
// 상세(맵) 적재는 조회 시 자동 백필하지 않는다.
|
||||
// - 보이는 매치는 프론트가 ensureDetailMaps(경로 ②)로 동기 확보(— 노출 방지)
|
||||
// - 그 외 미보유분은 야간 크론(handleDailyMatchSync)이 전량 적재
|
||||
return saved.map((m) => ({
|
||||
matchId: m.matchId,
|
||||
matchType: m.matchType,
|
||||
@@ -151,27 +159,93 @@ export class SaMatchService {
|
||||
}));
|
||||
}
|
||||
|
||||
/** 상세 미보유 매치를 비동기로 적재 (fire-and-forget). 중복/실패는 격리. */
|
||||
private backfillDetails(matchIds: string[]): void {
|
||||
const targets = matchIds.filter((id) => !this.backfillInFlight.has(id));
|
||||
if (!targets.length) return;
|
||||
targets.forEach((id) => this.backfillInFlight.add(id));
|
||||
/**
|
||||
* 주어진 매치들의 맵 이름을 확보해 반환한다. (화면에 보이는 매치 전용 — 동기)
|
||||
* - 캐시 보유분은 그대로 사용하고, 미보유분은 상세를 **기다렸다가** 적재한다.
|
||||
* - 실패 한도 초과 매치(넥슨 영구 미제공 추정)는 호출하지 않고 빈 문자열로 둔다.
|
||||
* - 반환: matchId → 맵 이름(확보 실패 시 '')
|
||||
*/
|
||||
async ensureDetailMaps(matchIds: string[]): Promise<Record<string, string>> {
|
||||
const result: Record<string, string> = {};
|
||||
const ids = Array.from(new Set(matchIds.filter((id) => id)));
|
||||
if (!ids.length) return result;
|
||||
|
||||
void (async () => {
|
||||
for (const id of targets) {
|
||||
try {
|
||||
// 호출 간격은 NexonService 전역 rate limiter 가 조절한다
|
||||
await this.getMatchDetail(id);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`매치 상세 백필 실패(matchId=${id}): ${(error as Error).message}`,
|
||||
);
|
||||
await this.recordDetailFailure(id, (error as Error).message);
|
||||
} finally {
|
||||
this.backfillInFlight.delete(id);
|
||||
}
|
||||
// 1) 이미 캐시된 헤더의 맵 사용
|
||||
const cached = await this.detailRepo.find({
|
||||
where: { matchId: In(ids) },
|
||||
select: ['matchId', 'matchMap'],
|
||||
});
|
||||
cached.forEach((c) => (result[c.matchId] = c.matchMap));
|
||||
const cachedSet = new Set(cached.map((c) => c.matchId));
|
||||
|
||||
// 2) 미보유분 중 실패 한도 초과 매치는 제외(쿼터 낭비 방지)
|
||||
const missing = ids.filter((id) => !cachedSet.has(id));
|
||||
if (missing.length) {
|
||||
const dead = await this.failureRepo.find({
|
||||
where: {
|
||||
matchId: In(missing),
|
||||
failCount: MoreThanOrEqual(SaMatchService.MAX_DETAIL_FAILURES),
|
||||
},
|
||||
select: ['matchId'],
|
||||
});
|
||||
const deadSet = new Set(dead.map((f) => f.matchId));
|
||||
const toFetch = missing.filter((id) => !deadSet.has(id));
|
||||
|
||||
// 3) 동기 적재 — 보이는 매치라 응답 전에 끝까지 기다린다(동시 워커 풀 사용)
|
||||
await this.runBackfill(toFetch);
|
||||
|
||||
// 4) 적재 결과 재조회 (동시 적재로 채워진 분까지 모두 반영)
|
||||
const filled = await this.detailRepo.find({
|
||||
where: { matchId: In(missing) },
|
||||
select: ['matchId', 'matchMap'],
|
||||
});
|
||||
filled.forEach((c) => (result[c.matchId] = c.matchMap));
|
||||
}
|
||||
|
||||
// 끝내 확보하지 못한 매치는 빈 문자열로 명시
|
||||
for (const id of ids) if (!(id in result)) result[id] = '';
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 매치 상세를 동시 워커 풀(BACKFILL_CONCURRENCY)로 적재한다. (크론·동기확보 공통)
|
||||
* - 진행 중인 매치는 건너뛰지 않고 그 Promise 를 await 한다 → 동기 확보(②) 시 누락 없음
|
||||
* - 개별 실패는 격리(실패 기록 누적)되어 전체 흐름을 끊지 않는다
|
||||
* - 각 호출은 NexonService 전역 rate limiter 를 통과하므로 초당 한도를 넘지 않는다
|
||||
*/
|
||||
private async runBackfill(matchIds: string[]): Promise<void> {
|
||||
const targets = matchIds.filter((id) => id);
|
||||
if (!targets.length) return;
|
||||
|
||||
await runWithConcurrency(
|
||||
targets,
|
||||
SaMatchService.BACKFILL_CONCURRENCY,
|
||||
(id) => this.fetchDetailOnce(id),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 매치 상세를 1회만 적재한다. 같은 매치를 동시에 요청하면 진행 중인 Promise 를 공유한다.
|
||||
* 실패는 격리(실패 기록 누적)하여 호출 측 흐름을 끊지 않는다.
|
||||
*/
|
||||
private fetchDetailOnce(matchId: string): Promise<void> {
|
||||
const existing = this.backfillInFlight.get(matchId);
|
||||
if (existing) return existing;
|
||||
|
||||
const task = (async () => {
|
||||
try {
|
||||
await this.getMatchDetail(matchId);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`매치 상세 백필 실패(matchId=${matchId}): ${(error as Error).message}`,
|
||||
);
|
||||
await this.recordDetailFailure(matchId, (error as Error).message);
|
||||
} finally {
|
||||
this.backfillInFlight.delete(matchId);
|
||||
}
|
||||
})();
|
||||
this.backfillInFlight.set(matchId, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
/** 넥슨 매치 목록 API → 신규분 누적(upsert). 실패는 격리(기존 데이터 유지). */
|
||||
@@ -271,23 +345,10 @@ export class SaMatchService {
|
||||
|
||||
const perUserLimit = SaMatchService.CRON_PER_USER_BACKFILL_LIMIT;
|
||||
this.logger.log(
|
||||
`매치 상세 백필 시작 (${targets.length}건${perUserLimit !== null ? `, 유저당 ${perUserLimit}건 한도` : ''})`,
|
||||
`매치 상세 백필 시작 (${targets.length}건${perUserLimit !== null ? `, 유저당 ${perUserLimit}건 한도` : ''}, 동시 ${SaMatchService.BACKFILL_CONCURRENCY})`,
|
||||
);
|
||||
for (const matchId of targets) {
|
||||
// 온디맨드 백필과 같은 매치를 동시에 적재하지 않도록 가드
|
||||
if (this.backfillInFlight.has(matchId)) continue;
|
||||
this.backfillInFlight.add(matchId);
|
||||
try {
|
||||
await this.getMatchDetail(matchId);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`매치 상세 백필 실패(matchId=${matchId}): ${(error as Error).message}`,
|
||||
);
|
||||
await this.recordDetailFailure(matchId, (error as Error).message);
|
||||
} finally {
|
||||
this.backfillInFlight.delete(matchId);
|
||||
}
|
||||
}
|
||||
// 온디맨드와 동일한 동시 워커 풀로 적재(중복 매치는 backfillInFlight 가 가드)
|
||||
await this.runBackfill(targets);
|
||||
}
|
||||
|
||||
/** 상세 적재 실패 기록 — 횟수를 누적해 한도 초과 시 백필 대상에서 제외한다. */
|
||||
|
||||
@@ -17,7 +17,8 @@ export class SaMetaController {
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '종류별 메타 목록 { grade: [...], season_grade: [...], tier: [...] }',
|
||||
description:
|
||||
'종류별 메타 목록 { grade: [...], season_grade: [...], tier: [...] }',
|
||||
})
|
||||
getAll(): Promise<Record<string, MetaItemDto[]>> {
|
||||
return this.saMetaService.getAll();
|
||||
|
||||
@@ -91,7 +91,9 @@ export class SaMetaService implements OnModuleInit {
|
||||
.filter((it) => it.name);
|
||||
|
||||
if (!items.length) {
|
||||
this.logger.warn(`[${src.type}] 메타 결과가 비어 건너뜁니다(기존 유지).`);
|
||||
this.logger.warn(
|
||||
`[${src.type}] 메타 결과가 비어 건너뜁니다(기존 유지).`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsArray,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
// 시즌 등록 요청 DTO (관리자)
|
||||
export class CreateSeasonDto {
|
||||
@ApiProperty({ description: '시즌명', example: '2026 시즌 1' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '시즌명을 입력해 주세요.' })
|
||||
@MaxLength(50)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '시즌 시작일 (YYYY-MM-DD)',
|
||||
example: '2026-01-01',
|
||||
})
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/, {
|
||||
message: '시작일은 YYYY-MM-DD 형식이어야 합니다.',
|
||||
})
|
||||
startDate!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '시즌 종료일 (YYYY-MM-DD)',
|
||||
example: '2026-03-31',
|
||||
})
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/, {
|
||||
message: '종료일은 YYYY-MM-DD 형식이어야 합니다.',
|
||||
})
|
||||
endDate!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '5v5 맵 목록',
|
||||
type: [String],
|
||||
example: ['제3보급창고', '웨어하우스'],
|
||||
})
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
@IsString({ each: true })
|
||||
maps5v5?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '3v3 맵 목록',
|
||||
type: [String],
|
||||
example: ['포트', '컨테이너'],
|
||||
})
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
@IsString({ each: true })
|
||||
maps3v3?: string[];
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 시즌 응답 DTO
|
||||
export class SeasonResponseDto {
|
||||
@ApiProperty({ example: 1 })
|
||||
id!: number;
|
||||
|
||||
@ApiProperty({ example: '2026 시즌 1' })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ description: '시작일 (YYYY-MM-DD)', example: '2026-01-01' })
|
||||
startDate!: string;
|
||||
|
||||
@ApiProperty({ description: '종료일 (YYYY-MM-DD)', example: '2026-03-31' })
|
||||
endDate!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '5v5 맵 목록',
|
||||
type: [String],
|
||||
example: ['제3보급창고', '웨어하우스'],
|
||||
})
|
||||
maps5v5!: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '3v3 맵 목록',
|
||||
type: [String],
|
||||
example: ['포트', '컨테이너'],
|
||||
})
|
||||
maps3v3!: string[];
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
|
||||
import { CreateSeasonDto } from './create-season.dto';
|
||||
|
||||
// 시즌 수정 요청 DTO (관리자) — 모든 필드 선택적
|
||||
export class UpdateSeasonDto extends PartialType(CreateSeasonDto) {}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
// 랭크전 시즌 — 관리자가 등록하는 시즌 기간 정의.
|
||||
// 시즌별 전적은 이 기간(startDate~endDate)으로 매치를 버킷팅해 집계한다.
|
||||
@Entity('sa_seasons')
|
||||
export class SaSeasonEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
// 시즌명 (예: '2026 시즌 1')
|
||||
@Column({ type: 'varchar', length: 50 })
|
||||
name!: string;
|
||||
|
||||
// 시즌 시작일 (YYYY-MM-DD)
|
||||
@Column({ type: 'date' })
|
||||
startDate!: string;
|
||||
|
||||
// 시즌 종료일 (YYYY-MM-DD)
|
||||
@Column({ type: 'date' })
|
||||
endDate!: string;
|
||||
|
||||
// 5v5 / 3v3 맵 목록 — 관리자가 시즌별로 설정. (simple-array: 콤마 구분 텍스트로 저장)
|
||||
@Column({ type: 'simple-array', nullable: true })
|
||||
maps5v5!: string[] | null;
|
||||
|
||||
@Column({ type: 'simple-array', nullable: true })
|
||||
maps3v3!: string[] | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { SaSeasonService } from './sa-season.service';
|
||||
import { CreateSeasonDto } from './dto/create-season.dto';
|
||||
import { UpdateSeasonDto } from './dto/update-season.dto';
|
||||
import { SeasonResponseDto } from './dto/season-response.dto';
|
||||
|
||||
@ApiTags('SA Season')
|
||||
@Controller('sa-seasons')
|
||||
export class SaSeasonController {
|
||||
constructor(private readonly seasonService: SaSeasonService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: '시즌 목록 조회',
|
||||
description: '등록된 랭크전 시즌 목록을 시작일 내림차순으로 반환합니다.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '시즌 목록',
|
||||
type: [SeasonResponseDto],
|
||||
})
|
||||
findAll(): Promise<SeasonResponseDto[]> {
|
||||
return this.seasonService.findAll();
|
||||
}
|
||||
|
||||
@Get('active')
|
||||
@ApiOperation({
|
||||
summary: '현재 활성 시즌 조회',
|
||||
description:
|
||||
'오늘(KST) 날짜가 포함된 시즌을 반환합니다. 활성 시즌이 없으면 null.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '현재 활성 시즌 (없으면 null)',
|
||||
type: SeasonResponseDto,
|
||||
})
|
||||
getActive(): Promise<SeasonResponseDto | null> {
|
||||
return this.seasonService.getActiveSeason();
|
||||
}
|
||||
|
||||
// ── 관리자 전용 ─────────────────────────────────────────────
|
||||
// TODO: 인증 도입 시 AdminGuard 적용 (현재는 가드 미적용 — 관리자 화면 연동 시 보호 필요)
|
||||
@Post()
|
||||
@ApiOperation({ summary: '[관리자] 시즌 등록' })
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: '등록된 시즌',
|
||||
type: SeasonResponseDto,
|
||||
})
|
||||
create(@Body() dto: CreateSeasonDto): Promise<SeasonResponseDto> {
|
||||
return this.seasonService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: '[관리자] 시즌 수정' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '수정된 시즌',
|
||||
type: SeasonResponseDto,
|
||||
})
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateSeasonDto,
|
||||
): Promise<SeasonResponseDto> {
|
||||
return this.seasonService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: '[관리자] 시즌 삭제' })
|
||||
@ApiResponse({ status: 200, description: '삭제 완료' })
|
||||
remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||
return this.seasonService.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { SaSeasonEntity } from './entities/sa-season.entity';
|
||||
import { SaSeasonService } from './sa-season.service';
|
||||
import { SaSeasonController } from './sa-season.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([SaSeasonEntity])],
|
||||
controllers: [SaSeasonController],
|
||||
providers: [SaSeasonService],
|
||||
exports: [SaSeasonService],
|
||||
})
|
||||
export class SaSeasonModule {}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { SaSeasonEntity } from './entities/sa-season.entity';
|
||||
import { CreateSeasonDto } from './dto/create-season.dto';
|
||||
import { UpdateSeasonDto } from './dto/update-season.dto';
|
||||
import { SeasonResponseDto } from './dto/season-response.dto';
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 랭크전 시즌 — 관리자가 등록/수정/삭제하는 시즌 기간 정의.
|
||||
// 시즌별 전적 집계(프론트)는 이 목록의 기간으로 매치를 버킷팅한다.
|
||||
// ────────────────────────────────────────────────────────────
|
||||
@Injectable()
|
||||
export class SaSeasonService {
|
||||
constructor(
|
||||
@InjectRepository(SaSeasonEntity)
|
||||
private readonly repo: Repository<SaSeasonEntity>,
|
||||
) {}
|
||||
|
||||
/** 시즌 목록 — 시작일 내림차순(최신 시즌 우선). */
|
||||
async findAll(): Promise<SeasonResponseDto[]> {
|
||||
const rows = await this.repo.find({ order: { startDate: 'DESC' } });
|
||||
return rows.map((r) => this.toDto(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 활성 시즌 — 오늘(KST)이 [startDate, endDate]에 포함된 시즌.
|
||||
* 여러 시즌이 겹치면 시작일이 가장 늦은 것을 선택. 없으면 null.
|
||||
*/
|
||||
async getActiveSeason(): Promise<SeasonResponseDto | null> {
|
||||
const today = this.todayKst();
|
||||
const row = await this.repo
|
||||
.createQueryBuilder('s')
|
||||
.where('s.startDate <= :today AND s.endDate >= :today', { today })
|
||||
.orderBy('s.startDate', 'DESC')
|
||||
.getOne();
|
||||
return row ? this.toDto(row) : null;
|
||||
}
|
||||
|
||||
/** KST 기준 오늘 날짜 (YYYY-MM-DD) */
|
||||
private todayKst(): string {
|
||||
const kst = new Date(Date.now() + 9 * 60 * 60 * 1000);
|
||||
return kst.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
async create(dto: CreateSeasonDto): Promise<SeasonResponseDto> {
|
||||
const saved = await this.repo.save(this.repo.create(dto));
|
||||
return this.toDto(saved);
|
||||
}
|
||||
|
||||
async update(id: number, dto: UpdateSeasonDto): Promise<SeasonResponseDto> {
|
||||
const season = await this.repo.findOne({ where: { id } });
|
||||
if (!season) {
|
||||
throw new NotFoundException('요청하신 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
Object.assign(season, dto);
|
||||
return this.toDto(await this.repo.save(season));
|
||||
}
|
||||
|
||||
async remove(id: number): Promise<void> {
|
||||
const res = await this.repo.delete({ id });
|
||||
if (!res.affected) {
|
||||
throw new NotFoundException('요청하신 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(r: SaSeasonEntity): SeasonResponseDto {
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
startDate: r.startDate,
|
||||
endDate: r.endDate,
|
||||
// simple-array 는 빈 배열을 ['']로 되읽을 수 있어 빈 문자열을 거른다
|
||||
maps5v5: (r.maps5v5 ?? []).filter((m) => m),
|
||||
maps3v3: (r.maps3v3 ?? []).filter((m) => m),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 클랜 활동기간 DTO — 일별 스냅샷(clanName 시계열)에서 파생한 한 번의 활동 구간(run)
|
||||
export class ClanPeriodDto {
|
||||
@ApiProperty({ description: '클랜명', example: '바디' })
|
||||
clan!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'활동 시작일(YYYY-MM-DD). estimated=true면 최초 조회일(하한 — 실제 가입은 더 이전일 수 있음)',
|
||||
example: '2026-06-12',
|
||||
})
|
||||
from!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'활동 종료일(YYYY-MM-DD). 진행 중이면 오늘(표시는 ongoing 으로 판단)',
|
||||
example: '2026-06-20',
|
||||
})
|
||||
to!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'진행 중 여부 — 현재 소속 클랜이면 true(종료일 대신 "진행중"으로 표시)',
|
||||
example: true,
|
||||
})
|
||||
ongoing!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'가입일 추정 여부 — 최초 관측 시점이라 실제 가입일은 더 이전일 수 있음(최초 기간만 true)',
|
||||
example: true,
|
||||
})
|
||||
estimated!: boolean;
|
||||
}
|
||||
@@ -8,7 +8,10 @@ export class ProfileResponseDto {
|
||||
@ApiProperty({ example: 'Hack' })
|
||||
userName!: string;
|
||||
|
||||
@ApiProperty({ description: '계정 생성일(ISO)', example: '2020-02-02T23:15:59.330Z' })
|
||||
@ApiProperty({
|
||||
description: '계정 생성일(ISO)',
|
||||
example: '2020-02-02T23:15:59.330Z',
|
||||
})
|
||||
dateCreate!: string;
|
||||
|
||||
@ApiProperty({ description: '칭호', example: '짝' })
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 시즌별 티어/점수 — 시즌 기간 내 마지막 스냅샷(시즌 종료 시점) 기준
|
||||
export class SeasonTierDto {
|
||||
@ApiProperty({ description: '시즌 ID', example: 1 })
|
||||
seasonId!: number;
|
||||
|
||||
@ApiProperty({ description: '솔로 랭크전 티어', example: '다이아몬드' })
|
||||
soloTier!: string;
|
||||
|
||||
@ApiProperty({ description: '솔로 랭크전 점수(RP)', example: 1850 })
|
||||
soloScore!: number;
|
||||
|
||||
@ApiProperty({ description: '파티 랭크전 티어', example: '플래티넘' })
|
||||
partyTier!: string;
|
||||
|
||||
@ApiProperty({ description: '파티 랭크전 점수(RP)', example: 1600 })
|
||||
partyScore!: number;
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { SaUserService } from './sa-user.service';
|
||||
import { SearchUserDto } from './dto/search-user.dto';
|
||||
import { OuidResponseDto } from './dto/ouid-response.dto';
|
||||
import { ProfileResponseDto } from './dto/profile-response.dto';
|
||||
import { ClanPeriodDto } from './dto/clan-period.dto';
|
||||
import { SeasonTierDto } from './dto/season-tier.dto';
|
||||
|
||||
@ApiTags('SA User')
|
||||
@Controller('sa-users')
|
||||
@@ -17,8 +19,15 @@ export class SaUserController {
|
||||
description:
|
||||
'닉네임으로 넥슨 오픈 API에서 OUID를 조회합니다. 결과는 DB에 저장되며, 신규 유저는 프로필도 즉시 동기화됩니다. 이후 매일 크론으로 자동 갱신됩니다.',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '조회된 OUID', type: OuidResponseDto })
|
||||
@ApiResponse({ status: 404, description: '해당 닉네임의 유저를 찾을 수 없음' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '조회된 OUID',
|
||||
type: OuidResponseDto,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 404,
|
||||
description: '해당 닉네임의 유저를 찾을 수 없음',
|
||||
})
|
||||
search(@Query() dto: SearchUserDto): Promise<OuidResponseDto> {
|
||||
return this.saUserService.searchOuid(dto.user_name);
|
||||
}
|
||||
@@ -29,8 +38,46 @@ export class SaUserController {
|
||||
description:
|
||||
'OUID로 종합 프로필(기본/계급/티어/최근동향)을 반환합니다. DB에 없으면 1회 동기화 후 반환합니다.',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '유저 프로필', type: ProfileResponseDto })
|
||||
getProfile(@Param('ouid') ouid: string): Promise<ProfileResponseDto> {
|
||||
return this.saUserService.getProfile(ouid);
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '유저 프로필',
|
||||
type: ProfileResponseDto,
|
||||
})
|
||||
getProfile(
|
||||
@Param('ouid') ouid: string,
|
||||
@Query('refresh') refresh?: string,
|
||||
): Promise<ProfileResponseDto> {
|
||||
// refresh=true 면 넥슨에서 강제 재동기화 (전적 갱신)
|
||||
return this.saUserService.getProfile(ouid, refresh === 'true');
|
||||
}
|
||||
|
||||
@Get(':ouid/clan-periods')
|
||||
@ApiOperation({
|
||||
summary: '클랜 활동기간 타임라인',
|
||||
description:
|
||||
'일별 스냅샷에서 파생한 클랜 활동기간 목록을 반환합니다. 최초 기간은 가입일 추정(estimated=true)이며, 이후 클랜 변경은 정확히 분리됩니다.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '클랜 활동기간 목록',
|
||||
type: [ClanPeriodDto],
|
||||
})
|
||||
getClanPeriods(@Param('ouid') ouid: string): Promise<ClanPeriodDto[]> {
|
||||
return this.saUserService.getClanPeriods(ouid);
|
||||
}
|
||||
|
||||
@Get(':ouid/season-tiers')
|
||||
@ApiOperation({
|
||||
summary: '시즌별 티어/점수',
|
||||
description:
|
||||
'각 시즌 기간 내 마지막 스냅샷(시즌 종료 시점)의 솔로/파티 티어·점수를 반환합니다. 과거 시즌의 최종 티어를 일별 스냅샷에서 복원합니다.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '시즌별 티어/점수 목록',
|
||||
type: [SeasonTierDto],
|
||||
})
|
||||
getSeasonTiers(@Param('ouid') ouid: string): Promise<SeasonTierDto[]> {
|
||||
return this.saUserService.getSeasonTiers(ouid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ import { SaUserService } from './sa-user.service';
|
||||
import { SaUserEntity } from './entities/sa-user.entity';
|
||||
import { SaUserProfileEntity } from './entities/sa-user-profile.entity';
|
||||
import { SaUserSnapshotEntity } from './entities/sa-user-snapshot.entity';
|
||||
import { SaSeasonModule } from '../sa-season/sa-season.module';
|
||||
|
||||
// 서든어택 유저 모듈 — OUID 검색 + 종합 프로필 동기화(매일 크론) + 일별 이력
|
||||
// 넥슨 호출은 전역 NexonModule(NexonService)을 주입해 사용한다.
|
||||
// 시즌별 티어 스냅샷 조회를 위해 SaSeasonModule(SaSeasonService)을 주입한다.
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
@@ -16,6 +18,7 @@ import { SaUserSnapshotEntity } from './entities/sa-user-snapshot.entity';
|
||||
SaUserProfileEntity,
|
||||
SaUserSnapshotEntity,
|
||||
]),
|
||||
SaSeasonModule,
|
||||
],
|
||||
controllers: [SaUserController],
|
||||
providers: [SaUserService],
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Between, Repository } from 'typeorm';
|
||||
|
||||
import { NexonService } from '../../shared/nexon/nexon.service';
|
||||
import { SaSeasonService } from '../sa-season/sa-season.service';
|
||||
import { OuidResponseDto } from './dto/ouid-response.dto';
|
||||
import { ProfileResponseDto } from './dto/profile-response.dto';
|
||||
import { ClanPeriodDto } from './dto/clan-period.dto';
|
||||
import { SeasonTierDto } from './dto/season-tier.dto';
|
||||
import { SaUserEntity } from './entities/sa-user.entity';
|
||||
import { SaUserProfileEntity } from './entities/sa-user-profile.entity';
|
||||
import { SaUserSnapshotEntity } from './entities/sa-user-snapshot.entity';
|
||||
@@ -66,8 +69,35 @@ export class SaUserService {
|
||||
@InjectRepository(SaUserSnapshotEntity)
|
||||
private readonly snapshotRepo: Repository<SaUserSnapshotEntity>,
|
||||
private readonly nexon: NexonService,
|
||||
private readonly seasonService: SaSeasonService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 시즌별 티어/점수 — 각 시즌 기간 내 **마지막 스냅샷**(시즌 종료 시점)의 값.
|
||||
* 일별 크론 스냅샷을 활용해 과거 시즌의 최종 티어/점수를 보존·조회한다.
|
||||
* 해당 시즌 기간에 스냅샷이 없으면(추적 이전) 결과에서 제외된다.
|
||||
*/
|
||||
async getSeasonTiers(ouid: string): Promise<SeasonTierDto[]> {
|
||||
const seasons = await this.seasonService.findAll();
|
||||
const out: SeasonTierDto[] = [];
|
||||
for (const s of seasons) {
|
||||
const snap = await this.snapshotRepo.findOne({
|
||||
where: { ouid, snapshotDate: Between(s.startDate, s.endDate) },
|
||||
order: { snapshotDate: 'DESC' },
|
||||
select: ['soloTier', 'soloScore', 'partyTier', 'partyScore'],
|
||||
});
|
||||
if (!snap) continue;
|
||||
out.push({
|
||||
seasonId: s.id,
|
||||
soloTier: snap.soloTier,
|
||||
soloScore: snap.soloScore,
|
||||
partyTier: snap.partyTier,
|
||||
partyScore: snap.partyScore,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 닉네임으로 OUID 조회. ouid 기준으로 정규화하므로 닉네임 변경/양도가 일어나도
|
||||
* 행이 중복되지 않는다. 신규 유저는 프로필도 즉시 동기화한다.
|
||||
@@ -113,8 +143,14 @@ export class SaUserService {
|
||||
return { ouid, userName };
|
||||
}
|
||||
|
||||
/** 종합 프로필 조회 (DB 우선, 없으면 1회 동기화). */
|
||||
async getProfile(ouid: string): Promise<ProfileResponseDto> {
|
||||
/**
|
||||
* 종합 프로필 조회. 기본은 DB 우선(없으면 1회 동기화).
|
||||
* refresh=true 면 넥슨에서 강제 재동기화한다(전적 갱신 버튼).
|
||||
*/
|
||||
async getProfile(ouid: string, refresh = false): Promise<ProfileResponseDto> {
|
||||
if (refresh) {
|
||||
return this.toDto(await this.syncProfile(ouid));
|
||||
}
|
||||
const profile =
|
||||
(await this.profileRepo.findOne({ where: { ouid } })) ??
|
||||
(await this.syncProfile(ouid));
|
||||
@@ -214,6 +250,47 @@ export class SaUserService {
|
||||
this.logger.log(`일일 유저 프로필 동기화 완료 (${ok}/${users.length})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 클랜 활동기간 타임라인 — 일별 스냅샷(clanName 시계열)에서 파생한다.
|
||||
* - 연속 동일 clanName 을 한 활동기간(run)으로 묶는다. (clanName '' = 무소속 → 제외)
|
||||
* - run.to = 다음 세그먼트 시작일(없으면 오늘) → 변경 시점에 자동으로 기간이 갈린다.
|
||||
* - 최초 세그먼트만 estimated=true (최초 관측 시점이라 실제 가입일은 더 이전일 수 있음).
|
||||
*/
|
||||
async getClanPeriods(ouid: string): Promise<ClanPeriodDto[]> {
|
||||
const snaps = await this.snapshotRepo.find({
|
||||
where: { ouid },
|
||||
order: { snapshotDate: 'ASC' },
|
||||
select: ['snapshotDate', 'clanName'],
|
||||
});
|
||||
if (!snaps.length) return [];
|
||||
|
||||
// 연속 동일 clanName 세그먼트로 압축
|
||||
const segs: { clan: string; from: string }[] = [];
|
||||
for (const s of snaps) {
|
||||
const clan = s.clanName ?? '';
|
||||
const last = segs[segs.length - 1];
|
||||
if (!last || last.clan !== clan)
|
||||
segs.push({ clan, from: s.snapshotDate });
|
||||
}
|
||||
|
||||
// 비어있지 않은 클랜 세그먼트 → 활동기간 run
|
||||
const today = this.todayKst();
|
||||
const runs: ClanPeriodDto[] = [];
|
||||
for (let i = 0; i < segs.length; i++) {
|
||||
if (!segs[i].clan) continue;
|
||||
// 마지막 세그먼트(현재 clanName) = 진행 중. to 는 오늘이지만 진행중으로 표시한다.
|
||||
const ongoing = i === segs.length - 1;
|
||||
runs.push({
|
||||
clan: segs[i].clan,
|
||||
from: segs[i].from,
|
||||
to: ongoing ? today : segs[i + 1].from,
|
||||
ongoing,
|
||||
estimated: i === 0,
|
||||
});
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
/** KST 기준 오늘 날짜 (YYYY-MM-DD) */
|
||||
private todayKst(): string {
|
||||
const kst = new Date(Date.now() + 9 * 60 * 60 * 1000);
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { Body, Controller, Get, Param, ParseIntPipe, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { UserService } from './user.service';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@ import { CreateUserDto } from './dto/create-user.dto';
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
// 임시 인메모리 저장소 — Repository 도입 시 교체
|
||||
private readonly users = new Map<number, { id: number; email: string; name: string }>();
|
||||
private readonly users = new Map<
|
||||
number,
|
||||
{ id: number; email: string; name: string }
|
||||
>();
|
||||
|
||||
findOne(id: number) {
|
||||
const user = this.users.get(id);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// 동시성 유틸 — 작업 목록을 바운디드 워커 풀로 처리한다.
|
||||
|
||||
/**
|
||||
* items 를 최대 concurrency 개씩 동시에 처리한다(바운디드 워커 풀).
|
||||
*
|
||||
* - 워커 수만큼만 동시에 in-flight 가 되며, 끝나는 즉시 다음 항목을 가져간다.
|
||||
* - worker 내부에서 발생한 예외는 호출 측이 직접 처리해야 한다(아래 throw 시 전체 중단).
|
||||
* 개별 실패를 격리하려면 worker 안에서 try/catch 로 감쌀 것.
|
||||
* - 외부 rate limiter(예: NexonService)와 함께 쓰면 동시성을 높여도 초당 한도는 안전하게 유지된다.
|
||||
*
|
||||
* @param items 처리할 항목 목록
|
||||
* @param concurrency 동시 워커 수 (1 이상, 항목 수로 상한)
|
||||
* @param worker 각 항목을 처리하는 비동기 함수
|
||||
*/
|
||||
export async function runWithConcurrency<T>(
|
||||
items: readonly T[],
|
||||
concurrency: number,
|
||||
worker: (item: T, index: number) => Promise<void>,
|
||||
): Promise<void> {
|
||||
if (items.length === 0) return;
|
||||
const limit = Math.max(1, Math.min(Math.floor(concurrency), items.length));
|
||||
|
||||
// 모든 워커가 공유하는 커서 — JS 단일 스레드라 cursor++ 는 원자적으로 동작한다.
|
||||
let cursor = 0;
|
||||
const runners = Array.from({ length: limit }, async () => {
|
||||
for (;;) {
|
||||
const index = cursor++;
|
||||
if (index >= items.length) return;
|
||||
await worker(items[index], index);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(runners);
|
||||
}
|
||||
Reference in New Issue
Block a user