feat: 전적검색 화면, 관리자 화면 추가

This commit is contained in:
2026-06-13 03:34:23 +09:00
parent 870615219b
commit fc0352cfcb
71 changed files with 3517 additions and 676 deletions
+4
View File
@@ -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
View File
@@ -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')
+121 -60
View File
@@ -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],
+80 -3
View File
@@ -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);
+14 -2
View File
@@ -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';
+4 -1
View File
@@ -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);
+34
View File
@@ -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);
}