From fc0352cfcbd1c6a4cf879a18ba0cc3a7fa48ba90 Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 13 Jun 2026 03:34:23 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A0=84=EC=A0=81=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4,=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MATCH_SYNC.md | 106 ----- backend/src/app.module.ts | 4 + .../common/filters/http-exception.filter.ts | 53 ++- .../interceptors/transform.interceptor.ts | 11 +- backend/src/main.ts | 20 +- .../modules/ranking/dto/ranking-item.dto.ts | 5 +- .../src/modules/ranking/ranking.service.ts | 8 +- .../src/modules/sa-map/dto/create-map.dto.ts | 11 + .../modules/sa-map/dto/map-response.dto.ts | 10 + .../src/modules/sa-map/dto/update-map.dto.ts | 6 + .../modules/sa-map/entities/sa-map.entity.ts | 22 + .../src/modules/sa-map/sa-map.controller.ts | 58 +++ backend/src/modules/sa-map/sa-map.module.ts | 14 + backend/src/modules/sa-map/sa-map.service.ts | 69 +++ .../src/modules/sa-match/dto/dev-stats.dto.ts | 31 ++ .../modules/sa-match/dto/ensure-maps.dto.ts | 24 + .../modules/sa-match/dto/match-detail.dto.ts | 5 +- .../modules/sa-match/dto/match-item.dto.ts | 8 +- .../modules/sa-match/dto/match-query.dto.ts | 19 +- .../entities/sa-match-participant.entity.ts | 7 +- .../modules/sa-match/sa-match.controller.ts | 63 ++- .../src/modules/sa-match/sa-match.service.ts | 181 ++++--- .../src/modules/sa-meta/sa-meta.controller.ts | 3 +- .../src/modules/sa-meta/sa-meta.service.ts | 4 +- .../sa-season/dto/create-season.dto.ts | 56 +++ .../sa-season/dto/season-response.dto.ts | 30 ++ .../sa-season/dto/update-season.dto.ts | 6 + .../sa-season/entities/sa-season.entity.ts | 36 ++ .../modules/sa-season/sa-season.controller.ts | 85 ++++ .../src/modules/sa-season/sa-season.module.ts | 14 + .../modules/sa-season/sa-season.service.ts | 79 +++ .../modules/sa-user/dto/clan-period.dto.ts | 35 ++ .../sa-user/dto/profile-response.dto.ts | 5 +- .../modules/sa-user/dto/season-tier.dto.ts | 19 + .../src/modules/sa-user/sa-user.controller.ts | 57 ++- backend/src/modules/sa-user/sa-user.module.ts | 3 + .../src/modules/sa-user/sa-user.service.ts | 83 +++- backend/src/modules/user/user.controller.ts | 16 +- backend/src/modules/user/user.service.ts | 5 +- backend/src/shared/utils/concurrent.ts | 34 ++ frontend/package-lock.json | 136 +++--- frontend/package.json | 4 +- frontend/src/App.vue | 5 + frontend/src/composables/useSaMap.ts | 56 +++ frontend/src/composables/useSaMatch.ts | 45 +- frontend/src/composables/useSaMeta.ts | 24 +- frontend/src/composables/useSaSeason.ts | 71 +++ frontend/src/composables/useSaUser.ts | 62 ++- frontend/src/pages/ProfilePage.vue | 404 +++++++++++++--- frontend/src/pages/admin/AdminLayout.vue | 90 ++++ frontend/src/pages/admin/AdminMapsPage.vue | 343 ++++++++++++++ frontend/src/pages/admin/AdminSeasonsPage.vue | 448 ++++++++++++++++++ frontend/src/pages/admin/MapToggleGroup.vue | 98 ++++ frontend/src/pages/home/MeCard.vue | 5 +- frontend/src/pages/home/SaRanking.vue | 5 +- frontend/src/pages/home/SeasonCard.vue | 86 +++- frontend/src/pages/profile/ClanRecords.vue | 25 +- frontend/src/pages/profile/MapRecords.vue | 55 ++- frontend/src/pages/profile/MatchHistory.vue | 118 ++++- frontend/src/pages/profile/MatchRow.vue | 18 +- frontend/src/pages/profile/ProfileSummary.vue | 106 ++++- frontend/src/pages/profile/RankedSeasons.vue | 52 +- frontend/src/pages/profile/RecentTrend.vue | 8 +- frontend/src/pages/profile/TeamTable.vue | 27 +- frontend/src/pages/profile/TrendChart.vue | 332 +++++-------- frontend/src/router/index.ts | 14 + .../src/shared/components/DevSyncButton.vue | 181 +++++++ .../src/shared/components/layout/TopBar.vue | 11 +- frontend/src/shared/mock/sa.ts | 7 +- frontend/src/shared/mock/types.ts | 21 +- frontend/src/shared/utils/format.ts | 31 ++ 71 files changed, 3517 insertions(+), 676 deletions(-) delete mode 100644 MATCH_SYNC.md create mode 100644 backend/src/modules/sa-map/dto/create-map.dto.ts create mode 100644 backend/src/modules/sa-map/dto/map-response.dto.ts create mode 100644 backend/src/modules/sa-map/dto/update-map.dto.ts create mode 100644 backend/src/modules/sa-map/entities/sa-map.entity.ts create mode 100644 backend/src/modules/sa-map/sa-map.controller.ts create mode 100644 backend/src/modules/sa-map/sa-map.module.ts create mode 100644 backend/src/modules/sa-map/sa-map.service.ts create mode 100644 backend/src/modules/sa-match/dto/dev-stats.dto.ts create mode 100644 backend/src/modules/sa-match/dto/ensure-maps.dto.ts create mode 100644 backend/src/modules/sa-season/dto/create-season.dto.ts create mode 100644 backend/src/modules/sa-season/dto/season-response.dto.ts create mode 100644 backend/src/modules/sa-season/dto/update-season.dto.ts create mode 100644 backend/src/modules/sa-season/entities/sa-season.entity.ts create mode 100644 backend/src/modules/sa-season/sa-season.controller.ts create mode 100644 backend/src/modules/sa-season/sa-season.module.ts create mode 100644 backend/src/modules/sa-season/sa-season.service.ts create mode 100644 backend/src/modules/sa-user/dto/clan-period.dto.ts create mode 100644 backend/src/modules/sa-user/dto/season-tier.dto.ts create mode 100644 backend/src/shared/utils/concurrent.ts create mode 100644 frontend/src/composables/useSaMap.ts create mode 100644 frontend/src/composables/useSaSeason.ts create mode 100644 frontend/src/pages/admin/AdminLayout.vue create mode 100644 frontend/src/pages/admin/AdminMapsPage.vue create mode 100644 frontend/src/pages/admin/AdminSeasonsPage.vue create mode 100644 frontend/src/pages/admin/MapToggleGroup.vue create mode 100644 frontend/src/shared/components/DevSyncButton.vue diff --git a/MATCH_SYNC.md b/MATCH_SYNC.md deleted file mode 100644 index 7e4e2ca..0000000 --- a/MATCH_SYNC.md +++ /dev/null @@ -1,106 +0,0 @@ -# 매치 상세 동기화 구조 - -전적검색의 매치 기록(맵/참가자 상세) 데이터를 넥슨 오픈 API에서 수집·캐시하는 구조 문서. - -> 관련 코드: `backend/src/modules/sa-match/`, `frontend/src/pages/profile/` - ---- - -## 1. 설계 원칙 - -| 원칙 | 내용 | -| ---- | ---- | -| **불변 데이터** | 끝난 매치의 상세(맵/참가자)는 절대 변하지 않는다 → **매치당 평생 1회만** 넥슨 API 호출, 이후 영원히 DB 재사용 | -| **수집이 병목, 연산은 아님** | 평균·집계는 DB에서 밀리초면 끝난다. 문제는 상세 API가 매치당 1건씩만 제공된다는 것 → "실시간 전수 조회" 대신 **점진 수집** | -| **0초 대기** | 상세 적재는 응답을 블로킹하지 않는다(fire-and-forget). 사용자는 항상 즉시 목록을 보고, 맵은 점진적으로 채워진다 | -| **쿼터 보호** | 개발 키는 일일 1,000건 한도 → 환경별 백필 상한으로 보호 | - ---- - -## 2. 데이터 구조 - -``` -sa_matches 매치 목록 — (matchId, ouid) 단위. 본인 k/d/a·결과·일시 (맵 없음 = 목록 API가 미제공) -sa_match_details 상세 헤더 — matchId 단위. 맵 이름. 행 존재 = 적재 완료 (영구 캐시) -sa_match_participants 상세 참가자 — matchId 단위. 전원 k/d/a·헤드샷·데미지 -sa_match_detail_failures 실패 기록 — matchId 단위. failCount 누적, 한도 초과 시 백필 제외 -``` - -목록 응답의 맵 이름은 `sa_matches ⟕ sa_match_details` 조인으로 채운다. 캐시 미보유 매치는 빈 문자열(`''`)로 내려가고 프론트는 `—`로 표시한다. - ---- - -## 3. 상세 적재 경로 (3가지) - -| 경로 | 트리거 | 범위 | 블로킹 | -| ---- | ------ | ---- | ------ | -| ① 행 펼치기 | 매치 행 클릭 (`GET /sa-matches/:matchId`) | 해당 1건 | 동기 (사용자가 상세를 보는 중) | -| ② 온디맨드 백필 | 매치 목록 조회 (`getMatches`) | 맵 미보유 최신 N건 | **비동기** (fire-and-forget) | -| ③ 야간 크론 | 매일 03:00 KST (`handleDailyMatchSync`) | 미보유 전량 (환경별 한도) | 백그라운드 | - -- ②: 검색 1회 = 5개 매치 타입 병렬 호출이므로 실제 최대 적재는 `한도 × 5` -- ③: 목록 동기화(전 유저 × 5타입) 후 백필. 최신순, `matchId` 중복 제거(공유 매치는 1회만) -- ②/③ 공통: `backfillInFlight` Set으로 동일 매치 동시 적재 방지 - -### 환경별 한도 - -| 항목 | 개발 | 운영 | 코드 위치 | -| ---- | ---- | ---- | --------- | -| 프론트 더보기 노출 | 4개 | 20개 | `MatchHistory.vue` (`import.meta.env.DEV`) | -| 온디맨드 백필 (타입당) | 4건 | 20건 | `BACKFILL_LIMIT` | -| 크론 백필 (유저당) | 20건 | **무제한** | `CRON_PER_USER_BACKFILL_LIMIT` | -| 넥슨 rate limit | 5건/초 | 500건/초 | env `NEXON_API_RATE_LIMIT_PER_SEC` | - -환경 분기는 백엔드 `process.env.NODE_ENV === 'production'`, 프론트 `import.meta.env.DEV` 기준. - ---- - -## 4. 실패 마킹 (무한 재시도 방지) - -넥슨이 상세를 끝내 제공하지 않는 매치(삭제/만료 등)가 매일 재시도되며 쿼터를 낭비하는 것을 막는다. - -- 백필 실패 시 `sa_match_detail_failures.failCount` 누적 + 마지막 에러 기록 -- **`MAX_DETAIL_FAILURES`(3회) 초과 매치는 백필 대상에서 제외** — 크론은 쿼리(leftJoin), 온디맨드는 후보 선정 시 제외 -- 적재 **성공 시 실패 기록 삭제** — 일시 장애로 쌓인 카운트는 복구 후 자동 정리 -- 실패 "기록" 자체의 실패는 백필 흐름을 끊지 않음 (warn 로그만) - ---- - -## 5. 성능 특성 - -### rate limiter ≠ 처리량 - -크론 백필의 실제 처리량은 rate limiter가 아니라 **순차 `await` 루프 구조**가 결정한다. - -``` -순차 루프: 동시 요청이 항상 1개 → 처리량 = 1 ÷ 호출당 왕복시간(~250ms) ≈ 초당 4건 -→ 운영 한도 500/s는 한 번도 닿지 않음 (점유율 ~0.8%) -``` - -| 규모 | 크론 소요 시간 (순차 기준) | -| ---- | ------------------------ | -| 1,000건 | ~5분 | -| 10,000건 | **~50분** (최초 캐치업 1회성. steady state는 일 신규분만 → 수 분) | - -### 검색과의 경합 — 운영 기준 없음 - -| 자원 | 크론 점유 | 한도 | 판정 | -| ---- | -------- | ---- | ---- | -| 넥슨 rate limit | ~4건/초 | 500건/초 | 0.8% — 경합 없음 | -| Node 이벤트 루프 | I/O 대기 위주 | — | `await`마다 양보, 서버 응답성 유지 | -| DB 커넥션 풀 | 1개 (순차) | 100개 | 영향 없음 | - -개발(5/s)에서는 크론의 ~4/s가 한도의 80%라 동시 검색이 몇 초 지연될 수 있으나, 유저당 20건 캡 덕분에 크론 자체가 ~수십 초면 끝난다. - -> 과거 "동기화 중 검색 차단(SyncState)" 로직이 있었으나, 위 분석(경합 사실상 없음)에 따라 제거됨. - ---- - -## 6. 향후 개선 후보 - -| 항목 | 시점 | 방법 | -| ---- | ---- | ---- | -| 크론 동시성 | 일 신규분만으로 크론이 1시간 초과 시 | 순차 루프 → 동시 8건 풀 (10,000건 ≈ 5분, API 점유 6%) | -| Redis / BullMQ | 서버 다중 인스턴스 도입 시 | 크론 분산 락 + `backfillInFlight` 공유 + 백필 큐 영속화 | -| 헤드샷/데미지 평균 | 기능 요구 시 | 상세 적재 시 본인 참가자 행을 `sa_matches`에 역정규화 → SQL AVG + 커버리지 표기("적재된 N전 / 전체 M전 기준") | -| dev 크론 수동 트리거 | 개발 편의 | 개발 환경 전용 트리거 엔드포인트 | diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 24d341a..8a7387f 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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: [ diff --git a/backend/src/common/filters/http-exception.filter.ts b/backend/src/common/filters/http-exception.filter.ts index 5db91ff..46035ab 100644 --- a/backend/src/common/filters/http-exception.filter.ts +++ b/backend/src/common/filters/http-exception.filter.ts @@ -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(); 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; } diff --git a/backend/src/common/interceptors/transform.interceptor.ts b/backend/src/common/interceptors/transform.interceptor.ts index dcaa5a8..f6b5d74 100644 --- a/backend/src/common/interceptors/transform.interceptor.ts +++ b/backend/src/common/interceptors/transform.interceptor.ts @@ -13,18 +13,19 @@ export interface StandardResponse { } @Injectable() -export class TransformInterceptor - implements NestInterceptor> -{ +export class TransformInterceptor implements NestInterceptor< + T, + StandardResponse +> { intercept( _context: ExecutionContext, next: CallHandler, ): Observable> { // 이미 Http 응답객체인 경우 등 예외처리가 필요할 수 있으나 기본적으로 data 래핑 return next.handle().pipe( - map((data) => ({ + map((data: T) => ({ success: true, - data: data !== undefined ? data : null, + data: data ?? (null as T), })), ); } diff --git a/backend/src/main.ts b/backend/src/main.ts index 8ce0756..6088102 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -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(); diff --git a/backend/src/modules/ranking/dto/ranking-item.dto.ts b/backend/src/modules/ranking/dto/ranking-item.dto.ts index ec03a50..82536ea 100644 --- a/backend/src/modules/ranking/dto/ranking-item.dto.ts +++ b/backend/src/modules/ranking/dto/ranking-item.dto.ts @@ -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' }) diff --git a/backend/src/modules/ranking/ranking.service.ts b/backend/src/modules/ranking/ranking.service.ts index 6519b8a..df78aa4 100644 --- a/backend/src/modules/ranking/ranking.service.ts +++ b/backend/src/modules/ranking/ranking.service.ts @@ -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, diff --git a/backend/src/modules/sa-map/dto/create-map.dto.ts b/backend/src/modules/sa-map/dto/create-map.dto.ts new file mode 100644 index 0000000..8fda3ee --- /dev/null +++ b/backend/src/modules/sa-map/dto/create-map.dto.ts @@ -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; +} diff --git a/backend/src/modules/sa-map/dto/map-response.dto.ts b/backend/src/modules/sa-map/dto/map-response.dto.ts new file mode 100644 index 0000000..72a34e8 --- /dev/null +++ b/backend/src/modules/sa-map/dto/map-response.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; + +// 맵 응답 DTO +export class MapResponseDto { + @ApiProperty({ example: 1 }) + id!: number; + + @ApiProperty({ example: '제3보급창고' }) + name!: string; +} diff --git a/backend/src/modules/sa-map/dto/update-map.dto.ts b/backend/src/modules/sa-map/dto/update-map.dto.ts new file mode 100644 index 0000000..6c395da --- /dev/null +++ b/backend/src/modules/sa-map/dto/update-map.dto.ts @@ -0,0 +1,6 @@ +import { PartialType } from '@nestjs/swagger'; + +import { CreateMapDto } from './create-map.dto'; + +// 맵 수정 요청 DTO (관리자) — 모든 필드 선택적 +export class UpdateMapDto extends PartialType(CreateMapDto) {} diff --git a/backend/src/modules/sa-map/entities/sa-map.entity.ts b/backend/src/modules/sa-map/entities/sa-map.entity.ts new file mode 100644 index 0000000..5ce1398 --- /dev/null +++ b/backend/src/modules/sa-map/entities/sa-map.entity.ts @@ -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; +} diff --git a/backend/src/modules/sa-map/sa-map.controller.ts b/backend/src/modules/sa-map/sa-map.controller.ts new file mode 100644 index 0000000..aa50a5f --- /dev/null +++ b/backend/src/modules/sa-map/sa-map.controller.ts @@ -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 { + return this.mapService.findAll(); + } + + // ── 관리자 전용 ───────────────────────────────────────────── + // TODO: 인증 도입 시 AdminGuard 적용 (현재는 가드 미적용) + @Post() + @ApiOperation({ summary: '[관리자] 맵 등록' }) + @ApiResponse({ status: 201, description: '등록된 맵', type: MapResponseDto }) + create(@Body() dto: CreateMapDto): Promise { + 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 { + return this.mapService.update(id, dto); + } + + @Delete(':id') + @ApiOperation({ summary: '[관리자] 맵 삭제' }) + @ApiResponse({ status: 200, description: '삭제 완료' }) + remove(@Param('id', ParseIntPipe) id: number): Promise { + return this.mapService.remove(id); + } +} diff --git a/backend/src/modules/sa-map/sa-map.module.ts b/backend/src/modules/sa-map/sa-map.module.ts new file mode 100644 index 0000000..7609bd2 --- /dev/null +++ b/backend/src/modules/sa-map/sa-map.module.ts @@ -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 {} diff --git a/backend/src/modules/sa-map/sa-map.service.ts b/backend/src/modules/sa-map/sa-map.service.ts new file mode 100644 index 0000000..e3042f3 --- /dev/null +++ b/backend/src/modules/sa-map/sa-map.service.ts @@ -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, + ) {} + + /** 맵 목록 — 이름 순. */ + async findAll(): Promise { + const rows = await this.repo.find({ order: { name: 'ASC' } }); + return rows.map((r) => this.toDto(r)); + } + + async create(dto: CreateMapDto): Promise { + 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 { + 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 { + 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 }; + } +} diff --git a/backend/src/modules/sa-match/dto/dev-stats.dto.ts b/backend/src/modules/sa-match/dto/dev-stats.dto.ts new file mode 100644 index 0000000..c1c7335 --- /dev/null +++ b/backend/src/modules/sa-match/dto/dev-stats.dto.ts @@ -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; +} diff --git a/backend/src/modules/sa-match/dto/ensure-maps.dto.ts b/backend/src/modules/sa-match/dto/ensure-maps.dto.ts new file mode 100644 index 0000000..8a6b6ac --- /dev/null +++ b/backend/src/modules/sa-match/dto/ensure-maps.dto.ts @@ -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[]; +} diff --git a/backend/src/modules/sa-match/dto/match-detail.dto.ts b/backend/src/modules/sa-match/dto/match-detail.dto.ts index a81009c..6b8f366 100644 --- a/backend/src/modules/sa-match/dto/match-detail.dto.ts +++ b/backend/src/modules/sa-match/dto/match-detail.dto.ts @@ -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보급창고' }) diff --git a/backend/src/modules/sa-match/dto/match-item.dto.ts b/backend/src/modules/sa-match/dto/match-item.dto.ts index 1e485a2..2a35f6a 100644 --- a/backend/src/modules/sa-match/dto/match-item.dto.ts +++ b/backend/src/modules/sa-match/dto/match-item.dto.ts @@ -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; diff --git a/backend/src/modules/sa-match/dto/match-query.dto.ts b/backend/src/modules/sa-match/dto/match-query.dto.ts index b01728c..19eb0ca 100644 --- a/backend/src/modules/sa-match/dto/match-query.dto.ts +++ b/backend/src/modules/sa-match/dto/match-query.dto.ts @@ -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 = '폭파미션'; diff --git a/backend/src/modules/sa-match/entities/sa-match-participant.entity.ts b/backend/src/modules/sa-match/entities/sa-match-participant.entity.ts index 01d884f..ab9be4b 100644 --- a/backend/src/modules/sa-match/entities/sa-match-participant.entity.ts +++ b/backend/src/modules/sa-match/entities/sa-match-participant.entity.ts @@ -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') diff --git a/backend/src/modules/sa-match/sa-match.controller.ts b/backend/src/modules/sa-match/sa-match.controller.ts index aaba3fd..302918a 100644 --- a/backend/src/modules/sa-match/sa-match.controller.ts +++ b/backend/src/modules/sa-match/sa-match.controller.ts @@ -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 { - 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> { + 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 { + if (process.env.NODE_ENV === 'production') { + throw new NotFoundException('요청하신 정보를 찾을 수 없습니다.'); + } + return this.saMatchService.getDevStats(); } @Get(':matchId') diff --git a/backend/src/modules/sa-match/sa-match.service.ts b/backend/src/modules/sa-match/sa-match.service.ts index ea0fd7c..743c8f4 100644 --- a/backend/src/modules/sa-match/sa-match.service.ts +++ b/backend/src/modules/sa-match/sa-match.service.ts @@ -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(); + // 백필 중복 실행 방지 + 진행 중 적재 공유 — matchId → 적재 Promise + // 같은 매치를 동시에 요청하면 새 호출 대신 진행 중인 Promise 를 await 한다(중복 넥슨 호출 방지 + 정확성). + private readonly backfillInFlight = new Map>(); 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> { + const result: Record = {}; + 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 { + 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 { + 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); } /** 상세 적재 실패 기록 — 횟수를 누적해 한도 초과 시 백필 대상에서 제외한다. */ diff --git a/backend/src/modules/sa-meta/sa-meta.controller.ts b/backend/src/modules/sa-meta/sa-meta.controller.ts index befcbdc..2ac180c 100644 --- a/backend/src/modules/sa-meta/sa-meta.controller.ts +++ b/backend/src/modules/sa-meta/sa-meta.controller.ts @@ -17,7 +17,8 @@ export class SaMetaController { }) @ApiResponse({ status: 200, - description: '종류별 메타 목록 { grade: [...], season_grade: [...], tier: [...] }', + description: + '종류별 메타 목록 { grade: [...], season_grade: [...], tier: [...] }', }) getAll(): Promise> { return this.saMetaService.getAll(); diff --git a/backend/src/modules/sa-meta/sa-meta.service.ts b/backend/src/modules/sa-meta/sa-meta.service.ts index a30ca80..fc71c46 100644 --- a/backend/src/modules/sa-meta/sa-meta.service.ts +++ b/backend/src/modules/sa-meta/sa-meta.service.ts @@ -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; } diff --git a/backend/src/modules/sa-season/dto/create-season.dto.ts b/backend/src/modules/sa-season/dto/create-season.dto.ts new file mode 100644 index 0000000..12b035d --- /dev/null +++ b/backend/src/modules/sa-season/dto/create-season.dto.ts @@ -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[]; +} diff --git a/backend/src/modules/sa-season/dto/season-response.dto.ts b/backend/src/modules/sa-season/dto/season-response.dto.ts new file mode 100644 index 0000000..2fb51e0 --- /dev/null +++ b/backend/src/modules/sa-season/dto/season-response.dto.ts @@ -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[]; +} diff --git a/backend/src/modules/sa-season/dto/update-season.dto.ts b/backend/src/modules/sa-season/dto/update-season.dto.ts new file mode 100644 index 0000000..e872d3b --- /dev/null +++ b/backend/src/modules/sa-season/dto/update-season.dto.ts @@ -0,0 +1,6 @@ +import { PartialType } from '@nestjs/swagger'; + +import { CreateSeasonDto } from './create-season.dto'; + +// 시즌 수정 요청 DTO (관리자) — 모든 필드 선택적 +export class UpdateSeasonDto extends PartialType(CreateSeasonDto) {} diff --git a/backend/src/modules/sa-season/entities/sa-season.entity.ts b/backend/src/modules/sa-season/entities/sa-season.entity.ts new file mode 100644 index 0000000..f88dd49 --- /dev/null +++ b/backend/src/modules/sa-season/entities/sa-season.entity.ts @@ -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; +} diff --git a/backend/src/modules/sa-season/sa-season.controller.ts b/backend/src/modules/sa-season/sa-season.controller.ts new file mode 100644 index 0000000..8c84d08 --- /dev/null +++ b/backend/src/modules/sa-season/sa-season.controller.ts @@ -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 { + return this.seasonService.findAll(); + } + + @Get('active') + @ApiOperation({ + summary: '현재 활성 시즌 조회', + description: + '오늘(KST) 날짜가 포함된 시즌을 반환합니다. 활성 시즌이 없으면 null.', + }) + @ApiResponse({ + status: 200, + description: '현재 활성 시즌 (없으면 null)', + type: SeasonResponseDto, + }) + getActive(): Promise { + return this.seasonService.getActiveSeason(); + } + + // ── 관리자 전용 ───────────────────────────────────────────── + // TODO: 인증 도입 시 AdminGuard 적용 (현재는 가드 미적용 — 관리자 화면 연동 시 보호 필요) + @Post() + @ApiOperation({ summary: '[관리자] 시즌 등록' }) + @ApiResponse({ + status: 201, + description: '등록된 시즌', + type: SeasonResponseDto, + }) + create(@Body() dto: CreateSeasonDto): Promise { + 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 { + return this.seasonService.update(id, dto); + } + + @Delete(':id') + @ApiOperation({ summary: '[관리자] 시즌 삭제' }) + @ApiResponse({ status: 200, description: '삭제 완료' }) + remove(@Param('id', ParseIntPipe) id: number): Promise { + return this.seasonService.remove(id); + } +} diff --git a/backend/src/modules/sa-season/sa-season.module.ts b/backend/src/modules/sa-season/sa-season.module.ts new file mode 100644 index 0000000..cdd68ac --- /dev/null +++ b/backend/src/modules/sa-season/sa-season.module.ts @@ -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 {} diff --git a/backend/src/modules/sa-season/sa-season.service.ts b/backend/src/modules/sa-season/sa-season.service.ts new file mode 100644 index 0000000..ab0567a --- /dev/null +++ b/backend/src/modules/sa-season/sa-season.service.ts @@ -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, + ) {} + + /** 시즌 목록 — 시작일 내림차순(최신 시즌 우선). */ + async findAll(): Promise { + const rows = await this.repo.find({ order: { startDate: 'DESC' } }); + return rows.map((r) => this.toDto(r)); + } + + /** + * 현재 활성 시즌 — 오늘(KST)이 [startDate, endDate]에 포함된 시즌. + * 여러 시즌이 겹치면 시작일이 가장 늦은 것을 선택. 없으면 null. + */ + async getActiveSeason(): Promise { + 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 { + const saved = await this.repo.save(this.repo.create(dto)); + return this.toDto(saved); + } + + async update(id: number, dto: UpdateSeasonDto): Promise { + 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 { + 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), + }; + } +} diff --git a/backend/src/modules/sa-user/dto/clan-period.dto.ts b/backend/src/modules/sa-user/dto/clan-period.dto.ts new file mode 100644 index 0000000..4fa0e7e --- /dev/null +++ b/backend/src/modules/sa-user/dto/clan-period.dto.ts @@ -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; +} diff --git a/backend/src/modules/sa-user/dto/profile-response.dto.ts b/backend/src/modules/sa-user/dto/profile-response.dto.ts index f217569..b81b9c7 100644 --- a/backend/src/modules/sa-user/dto/profile-response.dto.ts +++ b/backend/src/modules/sa-user/dto/profile-response.dto.ts @@ -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: '짝' }) diff --git a/backend/src/modules/sa-user/dto/season-tier.dto.ts b/backend/src/modules/sa-user/dto/season-tier.dto.ts new file mode 100644 index 0000000..1b6a519 --- /dev/null +++ b/backend/src/modules/sa-user/dto/season-tier.dto.ts @@ -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; +} diff --git a/backend/src/modules/sa-user/sa-user.controller.ts b/backend/src/modules/sa-user/sa-user.controller.ts index 99aafe4..f0d2300 100644 --- a/backend/src/modules/sa-user/sa-user.controller.ts +++ b/backend/src/modules/sa-user/sa-user.controller.ts @@ -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 { 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 { - return this.saUserService.getProfile(ouid); + @ApiResponse({ + status: 200, + description: '유저 프로필', + type: ProfileResponseDto, + }) + getProfile( + @Param('ouid') ouid: string, + @Query('refresh') refresh?: string, + ): Promise { + // 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 { + return this.saUserService.getClanPeriods(ouid); + } + + @Get(':ouid/season-tiers') + @ApiOperation({ + summary: '시즌별 티어/점수', + description: + '각 시즌 기간 내 마지막 스냅샷(시즌 종료 시점)의 솔로/파티 티어·점수를 반환합니다. 과거 시즌의 최종 티어를 일별 스냅샷에서 복원합니다.', + }) + @ApiResponse({ + status: 200, + description: '시즌별 티어/점수 목록', + type: [SeasonTierDto], + }) + getSeasonTiers(@Param('ouid') ouid: string): Promise { + return this.saUserService.getSeasonTiers(ouid); } } diff --git a/backend/src/modules/sa-user/sa-user.module.ts b/backend/src/modules/sa-user/sa-user.module.ts index a87316f..76e2908 100644 --- a/backend/src/modules/sa-user/sa-user.module.ts +++ b/backend/src/modules/sa-user/sa-user.module.ts @@ -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], diff --git a/backend/src/modules/sa-user/sa-user.service.ts b/backend/src/modules/sa-user/sa-user.service.ts index a4d8d65..e263f73 100644 --- a/backend/src/modules/sa-user/sa-user.service.ts +++ b/backend/src/modules/sa-user/sa-user.service.ts @@ -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, private readonly nexon: NexonService, + private readonly seasonService: SaSeasonService, ) {} + /** + * 시즌별 티어/점수 — 각 시즌 기간 내 **마지막 스냅샷**(시즌 종료 시점)의 값. + * 일별 크론 스냅샷을 활용해 과거 시즌의 최종 티어/점수를 보존·조회한다. + * 해당 시즌 기간에 스냅샷이 없으면(추적 이전) 결과에서 제외된다. + */ + async getSeasonTiers(ouid: string): Promise { + 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 { + /** + * 종합 프로필 조회. 기본은 DB 우선(없으면 1회 동기화). + * refresh=true 면 넥슨에서 강제 재동기화한다(전적 갱신 버튼). + */ + async getProfile(ouid: string, refresh = false): Promise { + 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 { + 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); diff --git a/backend/src/modules/user/user.controller.ts b/backend/src/modules/user/user.controller.ts index 3033f33..2c31ad2 100644 --- a/backend/src/modules/user/user.controller.ts +++ b/backend/src/modules/user/user.controller.ts @@ -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'; diff --git a/backend/src/modules/user/user.service.ts b/backend/src/modules/user/user.service.ts index 62fdca5..6fbdad2 100644 --- a/backend/src/modules/user/user.service.ts +++ b/backend/src/modules/user/user.service.ts @@ -5,7 +5,10 @@ import { CreateUserDto } from './dto/create-user.dto'; @Injectable() export class UserService { // 임시 인메모리 저장소 — Repository 도입 시 교체 - private readonly users = new Map(); + private readonly users = new Map< + number, + { id: number; email: string; name: string } + >(); findOne(id: number) { const user = this.users.get(id); diff --git a/backend/src/shared/utils/concurrent.ts b/backend/src/shared/utils/concurrent.ts new file mode 100644 index 0000000..0dfc288 --- /dev/null +++ b/backend/src/shared/utils/concurrent.ts @@ -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( + items: readonly T[], + concurrency: number, + worker: (item: T, index: number) => Promise, +): Promise { + 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); +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f066a2f..38d0046 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,10 +8,12 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "apexcharts": "^5.15.0", "axios": "^1.14.0", "pinia": "^3.0.4", "vue": "^3.5.31", - "vue-router": "^4.4.5" + "vue-router": "^4.4.5", + "vue3-apexcharts": "^1.11.1" }, "devDependencies": { "@tsconfig/node24": "^24.0.4", @@ -697,35 +699,38 @@ } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.0.tgz", + "integrity": "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==", "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz", + "integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==", "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1275,9 +1280,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1295,9 +1297,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1315,9 +1314,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1335,9 +1331,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1355,9 +1348,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1375,9 +1365,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1395,9 +1382,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1415,9 +1399,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1606,9 +1587,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1626,9 +1604,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1646,9 +1621,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1666,9 +1638,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1686,9 +1655,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1706,9 +1672,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1754,6 +1717,40 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", @@ -2765,6 +2762,12 @@ "node": ">=14" } }, + "node_modules/apexcharts": { + "version": "5.15.0", + "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.15.0.tgz", + "integrity": "sha512-ATswoiZi8wynKcwqf0eyE0Is5mBQoDpxZN3PlSVy41OrD+9GMBp2kZQRjyGK+5/j0GFjHkuAd7GKOrt5VMoPrw==", + "license": "SEE LICENSE IN LICENSE" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -4736,9 +4739,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4760,9 +4760,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4784,9 +4781,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4808,9 +4802,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6849,6 +6840,21 @@ "typescript": ">=5.0.0" } }, + "node_modules/vue3-apexcharts": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/vue3-apexcharts/-/vue3-apexcharts-1.11.1.tgz", + "integrity": "sha512-MbN3vg8bMG19wc0Lm1HkeQvODgLm56DgpIxtNUO0xpf/JCzYWVGE4jzXp2JISzy2s3Kul1yOxNQUYsLvKQ5L9g==", + "license": "see LICENSE in LICENSE", + "peerDependencies": { + "apexcharts": ">=5.10.0", + "vue": ">=3.0.0" + }, + "peerDependenciesMeta": { + "apexcharts": { + "optional": false + } + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 60f8970..8d4e02b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,10 +14,12 @@ "format": "oxfmt src/" }, "dependencies": { + "apexcharts": "^5.15.0", "axios": "^1.14.0", "pinia": "^3.0.4", "vue": "^3.5.31", - "vue-router": "^4.4.5" + "vue-router": "^4.4.5", + "vue3-apexcharts": "^1.11.1" }, "devDependencies": { "@tsconfig/node24": "^24.0.4", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index ed51c23..5d370f7 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -6,6 +6,10 @@ import AppNav from '@/shared/components/layout/AppNav.vue' import AppFooter from '@/shared/components/layout/AppFooter.vue' import FeedbackWidget from '@/shared/components/FeedbackWidget.vue' import LoginModal from '@/shared/components/LoginModal.vue' +import DevSyncButton from '@/shared/components/DevSyncButton.vue' + +// 개발 환경에서만 매치 동기화 수동 트리거 버튼 노출 +const isDev = import.meta.env.DEV diff --git a/frontend/src/composables/useSaMap.ts b/frontend/src/composables/useSaMap.ts new file mode 100644 index 0000000..b676d7a --- /dev/null +++ b/frontend/src/composables/useSaMap.ts @@ -0,0 +1,56 @@ +import { useApi } from './useApi' + +// 서든어택 맵 (백엔드 MapResponseDto) — 5v5/3v3 구분은 시즌 설정에서 정한다 +export interface SaMap { + id: number + name: string +} + +// 맵 등록/수정 입력 (id 제외) +export type MapInput = Omit + +export function useSaMap() { + const api = useApi() + + // 등록된 맵 목록 (모드→이름 순). 실패 시 빈 배열. + async function getMaps(): Promise { + try { + return (await api.get('/sa-maps')) as unknown as SaMap[] + } catch { + return [] + } + } + + // [관리자] 맵 등록. 실패 시 null (인터셉터가 에러 알림 처리). + async function createMap(input: MapInput): Promise { + try { + return (await api.post('/sa-maps', input)) as unknown as SaMap + } catch { + return null + } + } + + // [관리자] 맵 수정. 실패 시 null. + async function updateMap( + id: number, + input: Partial, + ): Promise { + try { + return (await api.patch(`/sa-maps/${id}`, input)) as unknown as SaMap + } catch { + return null + } + } + + // [관리자] 맵 삭제. 성공 시 true. + async function deleteMap(id: number): Promise { + try { + await api.delete(`/sa-maps/${id}`) + return true + } catch { + return false + } + } + + return { getMaps, createMap, updateMap, deleteMap } +} diff --git a/frontend/src/composables/useSaMatch.ts b/frontend/src/composables/useSaMatch.ts index cceceb2..cf8e41e 100644 --- a/frontend/src/composables/useSaMatch.ts +++ b/frontend/src/composables/useSaMatch.ts @@ -38,6 +38,15 @@ export interface SaMatchDetail { participants: SaParticipant[] } +// [개발 전용] 매치 데이터 적재 현황 (백엔드 DevStatsDto) +export interface DevStats { + users: number + matches: number + details: number + participants: number + failures: number +} + // 매치 타입 (넥슨 match_type 과 동일 문자열) export const MATCH_TYPES = [ '랭크전 솔로', @@ -74,5 +83,39 @@ export function useSaMatch() { } } - return { getMatches, getMatchDetail } + // 보이는 매치들의 맵 이름 일괄 확보 — 미보유분은 서버가 동기 적재 후 반환. + // 반환: matchId → 맵 이름(확보 실패 시 ''). 실패 시 빈 객체. + async function ensureMatchMaps(matchIds: string[]): Promise> { + if (!matchIds.length) return {} + try { + return (await api.post('/sa-matches/details/maps', { + matchIds, + })) as unknown as Record + } catch { + return {} + } + } + + // [개발 전용] 매치 동기화 크론 수동 트리거. 성공 시 true (서버는 백그라운드 실행). + async function devSync(): Promise { + try { + const res = (await api.post('/sa-matches/dev/sync')) as unknown as { + triggered: boolean + } + return !!res?.triggered + } catch { + return false + } + } + + // [개발 전용] 매치 데이터 적재 현황 조회. 실패 시 null. + async function devStats(): Promise { + try { + return (await api.get('/sa-matches/dev/stats')) as unknown as DevStats + } catch { + return null + } + } + + return { getMatches, getMatchDetail, ensureMatchMaps, devSync, devStats } } diff --git a/frontend/src/composables/useSaMeta.ts b/frontend/src/composables/useSaMeta.ts index 9d308f7..0443709 100644 --- a/frontend/src/composables/useSaMeta.ts +++ b/frontend/src/composables/useSaMeta.ts @@ -9,15 +9,29 @@ export interface SaMetaItem { // 종류별 메타 { grade, season_grade, tier } export type SaMetaSections = Record +// 메타는 거의 불변 — 모듈 스코프에 1회 캐시해 컴포넌트 간 공유한다(중복 요청 방지). +let metaCache: SaMetaSections | null = null +let metaPromise: Promise | null = null + export function useSaMeta() { const api = useApi() async function getMeta(): Promise { - try { - return (await api.get('/sa-meta')) as unknown as SaMetaSections - } catch { - return {} - } + if (metaCache) return metaCache + // 동시 호출은 진행 중인 Promise 를 공유 + if (metaPromise) return metaPromise + metaPromise = (async () => { + try { + const data = (await api.get('/sa-meta')) as unknown as SaMetaSections + metaCache = data + return data + } catch { + // 실패 시 캐시하지 않고 다음 호출에서 재시도 + metaPromise = null + return {} + } + })() + return metaPromise } // 종류 + 이름으로 이미지 URL 조회 (없으면 빈 문자열) diff --git a/frontend/src/composables/useSaSeason.ts b/frontend/src/composables/useSaSeason.ts new file mode 100644 index 0000000..3e43f5c --- /dev/null +++ b/frontend/src/composables/useSaSeason.ts @@ -0,0 +1,71 @@ +import { useApi } from './useApi' + +// 랭크전 시즌 (백엔드 SeasonResponseDto) +export interface SaSeason { + id: number + name: string + // 시작/종료일 (YYYY-MM-DD) + startDate: string + endDate: string + // 5v5 / 3v3 맵 목록 (관리자 설정) + maps5v5: string[] + maps3v3: string[] +} + +// 시즌 등록/수정 입력 (id 제외) +export type SeasonInput = Omit + +export function useSaSeason() { + const api = useApi() + + // 등록된 시즌 목록 (시작일 내림차순). 실패 시 빈 배열. + async function getSeasons(): Promise { + try { + return (await api.get('/sa-seasons')) as unknown as SaSeason[] + } catch { + return [] + } + } + + // 현재 활성 시즌 (오늘 포함). 없거나 실패 시 null. + async function getActiveSeason(): Promise { + try { + return (await api.get('/sa-seasons/active')) as unknown as SaSeason | null + } catch { + return null + } + } + + // [관리자] 시즌 등록. 실패 시 null (인터셉터가 에러 알림 처리). + async function createSeason(input: SeasonInput): Promise { + try { + return (await api.post('/sa-seasons', input)) as unknown as SaSeason + } catch { + return null + } + } + + // [관리자] 시즌 수정. 실패 시 null. + async function updateSeason( + id: number, + input: Partial, + ): Promise { + try { + return (await api.patch(`/sa-seasons/${id}`, input)) as unknown as SaSeason + } catch { + return null + } + } + + // [관리자] 시즌 삭제. 성공 시 true. + async function deleteSeason(id: number): Promise { + try { + await api.delete(`/sa-seasons/${id}`) + return true + } catch { + return false + } + } + + return { getSeasons, getActiveSeason, createSeason, updateSeason, deleteSeason } +} diff --git a/frontend/src/composables/useSaUser.ts b/frontend/src/composables/useSaUser.ts index 115f4ea..2ede2d9 100644 --- a/frontend/src/composables/useSaUser.ts +++ b/frontend/src/composables/useSaUser.ts @@ -33,6 +33,26 @@ export interface SaProfile { lastSyncedAt: string } +// 클랜 활동기간 (백엔드 ClanPeriodDto) — 일별 스냅샷에서 파생한 한 번의 활동 구간 +export interface ClanPeriod { + clan: string + from: string + to: string + // 진행 중 여부 (현재 소속 클랜이면 true — 종료일 대신 '진행중' 표시) + ongoing: boolean + // 가입일 추정 여부 (최초 관측 기간만 true — 실제 가입은 더 이전일 수 있음) + estimated: boolean +} + +// 시즌별 티어/점수 (백엔드 SeasonTierDto) — 시즌 종료 시점 스냅샷 기준 +export interface SeasonTier { + seasonId: number + soloTier: string + soloScore: number + partyTier: string + partyScore: number +} + export function useSaUser() { const api = useApi() const loading = ref(false) @@ -54,15 +74,49 @@ export function useSaUser() { } } - // OUID → 종합 프로필 - async function getProfile(ouid: string): Promise { + // OUID → 종합 프로필. refresh=true 면 넥슨에서 강제 재동기화(전적 갱신). + async function getProfile( + ouid: string, + refresh = false, + ): Promise { try { - return (await api.get(`/sa-users/${ouid}/profile`)) as unknown as SaProfile + return (await api.get(`/sa-users/${ouid}/profile`, { + params: refresh ? { refresh: true } : undefined, + })) as unknown as SaProfile } catch (e) { error.value = (e as Error)?.message ?? 'unknown error' return null } } - return { loading, error, searchOuid, getProfile } + // OUID → 클랜 활동기간 타임라인 (실패 시 빈 배열) + async function getClanPeriods(ouid: string): Promise { + try { + return (await api.get( + `/sa-users/${ouid}/clan-periods`, + )) as unknown as ClanPeriod[] + } catch { + return [] + } + } + + // OUID → 시즌별 티어/점수 (시즌 종료 시점 스냅샷). 실패 시 빈 배열. + async function getSeasonTiers(ouid: string): Promise { + try { + return (await api.get( + `/sa-users/${ouid}/season-tiers`, + )) as unknown as SeasonTier[] + } catch { + return [] + } + } + + return { + loading, + error, + searchOuid, + getProfile, + getClanPeriods, + getSeasonTiers, + } } diff --git a/frontend/src/pages/ProfilePage.vue b/frontend/src/pages/ProfilePage.vue index b1f680d..d540d94 100644 --- a/frontend/src/pages/ProfilePage.vue +++ b/frontend/src/pages/ProfilePage.vue @@ -1,30 +1,52 @@