Compare commits
1 Commits
870615219b
...
ttipo
| Author | SHA1 | Date | |
|---|---|---|---|
| fc0352cfcb |
-106
@@ -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 크론 수동 트리거 | 개발 편의 | 개발 환경 전용 트리거 엔드포인트 |
|
||||
@@ -16,6 +16,8 @@ import { RankingModule } from './modules/ranking/ranking.module';
|
||||
import { SaUserModule } from './modules/sa-user/sa-user.module';
|
||||
import { SaMetaModule } from './modules/sa-meta/sa-meta.module';
|
||||
import { SaMatchModule } from './modules/sa-match/sa-match.module';
|
||||
import { SaSeasonModule } from './modules/sa-season/sa-season.module';
|
||||
import { SaMapModule } from './modules/sa-map/sa-map.module';
|
||||
import { NexonModule } from './shared/nexon/nexon.module';
|
||||
|
||||
@Module({
|
||||
@@ -73,6 +75,8 @@ import { NexonModule } from './shared/nexon/nexon.module';
|
||||
SaUserModule,
|
||||
SaMetaModule,
|
||||
SaMatchModule,
|
||||
SaSeasonModule,
|
||||
SaMapModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
|
||||
@@ -13,33 +13,52 @@ import { Response } from 'express';
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(HttpExceptionFilter.name);
|
||||
|
||||
// HTTP 상태코드 → (에러코드, 메시지) 매핑. 숫자 키 룩업이라 enum 비교가 없다.
|
||||
private static readonly STATUS_MAP: Record<
|
||||
number,
|
||||
{ code: string; message: string }
|
||||
> = {
|
||||
[HttpStatus.UNAUTHORIZED]: {
|
||||
code: 'AUTH_001',
|
||||
message: '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
|
||||
},
|
||||
[HttpStatus.FORBIDDEN]: {
|
||||
code: 'AUTH_003',
|
||||
message: '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.',
|
||||
},
|
||||
[HttpStatus.NOT_FOUND]: {
|
||||
code: 'RES_001',
|
||||
message: '요청하신 정보나 페이지를 찾을 수 없습니다.',
|
||||
},
|
||||
[HttpStatus.BAD_REQUEST]: {
|
||||
code: 'VAL_001',
|
||||
message:
|
||||
'입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
|
||||
},
|
||||
[HttpStatus.UNPROCESSABLE_ENTITY]: {
|
||||
code: 'VAL_001',
|
||||
message:
|
||||
'입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
|
||||
},
|
||||
};
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
|
||||
let status: number = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let code = 'SYS_001';
|
||||
let message = '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.';
|
||||
let message =
|
||||
'일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.';
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
|
||||
if (status === HttpStatus.UNAUTHORIZED) {
|
||||
code = 'AUTH_001';
|
||||
message = '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.';
|
||||
} else if (status === HttpStatus.FORBIDDEN) {
|
||||
code = 'AUTH_003';
|
||||
message = '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.';
|
||||
} else if (status === HttpStatus.NOT_FOUND) {
|
||||
code = 'RES_001';
|
||||
message = '요청하신 정보나 페이지를 찾을 수 없습니다.';
|
||||
} else if (
|
||||
status === HttpStatus.BAD_REQUEST ||
|
||||
status === HttpStatus.UNPROCESSABLE_ENTITY
|
||||
) {
|
||||
code = 'VAL_001';
|
||||
message = '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)';
|
||||
const mapped = HttpExceptionFilter.STATUS_MAP[status];
|
||||
if (mapped) {
|
||||
code = mapped.code;
|
||||
message = mapped.message;
|
||||
} else {
|
||||
code = 'BIZ_001';
|
||||
message = '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.';
|
||||
@@ -50,7 +69,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
exceptionResponse !== null &&
|
||||
'message' in exceptionResponse
|
||||
) {
|
||||
const responseMessage = (exceptionResponse as { message: unknown }).message;
|
||||
const responseMessage = exceptionResponse.message;
|
||||
if (typeof responseMessage === 'string') {
|
||||
message = responseMessage;
|
||||
}
|
||||
|
||||
@@ -13,18 +13,19 @@ export interface StandardResponse<T> {
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TransformInterceptor<T>
|
||||
implements NestInterceptor<T, StandardResponse<T>>
|
||||
{
|
||||
export class TransformInterceptor<T> implements NestInterceptor<
|
||||
T,
|
||||
StandardResponse<T>
|
||||
> {
|
||||
intercept(
|
||||
_context: ExecutionContext,
|
||||
next: CallHandler,
|
||||
): Observable<StandardResponse<T>> {
|
||||
// 이미 Http 응답객체인 경우 등 예외처리가 필요할 수 있으나 기본적으로 data 래핑
|
||||
return next.handle().pipe(
|
||||
map((data) => ({
|
||||
map((data: T) => ({
|
||||
success: true,
|
||||
data: data !== undefined ? data : null,
|
||||
data: data ?? (null as T),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
+14
-6
@@ -5,7 +5,11 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { WinstonModule } from 'nest-winston';
|
||||
import helmet from 'helmet';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import type { Request as ExpressRequest, Response as ExpressResponse } from 'express';
|
||||
import type {
|
||||
Application,
|
||||
Request as ExpressRequest,
|
||||
Response as ExpressResponse,
|
||||
} from 'express';
|
||||
|
||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
||||
@@ -17,7 +21,7 @@ async function bootstrap() {
|
||||
});
|
||||
|
||||
// 리버스 프록시(Nginx, Docker 배포 환경 등) 뒤에서 올바른 클라이언트 IP 식별 지원을 위해 설정합니다.
|
||||
const expressApp = app.getHttpAdapter().getInstance();
|
||||
const expressApp = app.getHttpAdapter().getInstance() as Application;
|
||||
expressApp.set('trust proxy', 1);
|
||||
|
||||
// 글로벌 prefix — 모든 라우트가 /api/* 로 정규화됨 (Flutter, Web 양쪽 baseURL과 일치)
|
||||
@@ -66,8 +70,9 @@ async function bootstrap() {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'SYS_001',
|
||||
message: '일시적인 시스템 오류가 발생했습니다. (요청 한도 초과) 잠시 후 다시 시도해 주세요.', // 글로벌 통합 에러 포맷
|
||||
}
|
||||
message:
|
||||
'일시적인 시스템 오류가 발생했습니다. (요청 한도 초과) 잠시 후 다시 시도해 주세요.', // 글로벌 통합 에러 포맷
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -98,6 +103,9 @@ async function bootstrap() {
|
||||
const port = Number(process.env.PORT ?? 3000);
|
||||
await app.listen(port, '0.0.0.0');
|
||||
Logger.log(`🚀 Backend running on http://localhost:${port}/api`, 'Bootstrap');
|
||||
Logger.log(`📘 Swagger docs at http://localhost:${port}/api-docs`, 'Bootstrap');
|
||||
Logger.log(
|
||||
`📘 Swagger docs at http://localhost:${port}/api-docs`,
|
||||
'Bootstrap',
|
||||
);
|
||||
}
|
||||
bootstrap();
|
||||
void bootstrap();
|
||||
|
||||
@@ -23,7 +23,10 @@ export class RankingItemDto {
|
||||
@ApiProperty({ description: '킬데스', example: '56.1%' })
|
||||
kd!: string;
|
||||
|
||||
@ApiProperty({ description: '전적/참여판수', example: '118540승 32289패 24무' })
|
||||
@ApiProperty({
|
||||
description: '전적/참여판수',
|
||||
example: '118540승 32289패 24무',
|
||||
})
|
||||
record!: string;
|
||||
|
||||
@ApiProperty({ description: 'RP/클랜RP', example: '5,063' })
|
||||
|
||||
@@ -41,7 +41,9 @@ export class RankingService implements OnModuleInit {
|
||||
// 카테고리별 보관 개수 (홈 랭킹 카드 TOP 10)
|
||||
private static readonly PER_CATEGORY = 10;
|
||||
// AJAX 엔드포인트 호출용 헤더
|
||||
private static readonly AJAX_HEADERS = { 'X-Requested-With': 'XMLHttpRequest' };
|
||||
private static readonly AJAX_HEADERS = {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
};
|
||||
|
||||
constructor(
|
||||
@InjectRepository(SaRankingEntity)
|
||||
@@ -380,7 +382,9 @@ export class RankingService implements OnModuleInit {
|
||||
this.dataRows($).each((i, tr) => {
|
||||
const row = $(tr);
|
||||
const tds = row.find('> td');
|
||||
const name = this.txt(tds.eq(2).find('a b').first()) || this.txt(tds.eq(2).find('a').first());
|
||||
const name =
|
||||
this.txt(tds.eq(2).find('a b').first()) ||
|
||||
this.txt(tds.eq(2).find('a').first());
|
||||
if (!name) return;
|
||||
out.push({
|
||||
rankNo: i + 1,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
// 맵 등록 요청 DTO (관리자)
|
||||
export class CreateMapDto {
|
||||
@ApiProperty({ description: '맵 이름', example: '제3보급창고' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '맵 이름을 입력해 주세요.' })
|
||||
@MaxLength(50)
|
||||
name!: string;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 맵 응답 DTO
|
||||
export class MapResponseDto {
|
||||
@ApiProperty({ example: 1 })
|
||||
id!: number;
|
||||
|
||||
@ApiProperty({ example: '제3보급창고' })
|
||||
name!: string;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
|
||||
import { CreateMapDto } from './create-map.dto';
|
||||
|
||||
// 맵 수정 요청 DTO (관리자) — 모든 필드 선택적
|
||||
export class UpdateMapDto extends PartialType(CreateMapDto) {}
|
||||
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
// 서든어택 맵 — 관리자가 이름으로 등록. (5v5/3v3 구분은 시즌 설정에서 토글로 정한다)
|
||||
@Entity('sa_maps')
|
||||
@Index(['name'], { unique: true })
|
||||
export class SaMapEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
// 맵 이름 (매치 상세의 matchMap 과 동일 문자열이어야 전적 연동 가능)
|
||||
@Column({ type: 'varchar', length: 50 })
|
||||
name!: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { SaMapService } from './sa-map.service';
|
||||
import { CreateMapDto } from './dto/create-map.dto';
|
||||
import { UpdateMapDto } from './dto/update-map.dto';
|
||||
import { MapResponseDto } from './dto/map-response.dto';
|
||||
|
||||
@ApiTags('SA Map')
|
||||
@Controller('sa-maps')
|
||||
export class SaMapController {
|
||||
constructor(private readonly mapService: SaMapService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: '맵 목록 조회',
|
||||
description: '등록된 서든어택 맵 목록을 모드→이름 순으로 반환합니다.',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '맵 목록', type: [MapResponseDto] })
|
||||
findAll(): Promise<MapResponseDto[]> {
|
||||
return this.mapService.findAll();
|
||||
}
|
||||
|
||||
// ── 관리자 전용 ─────────────────────────────────────────────
|
||||
// TODO: 인증 도입 시 AdminGuard 적용 (현재는 가드 미적용)
|
||||
@Post()
|
||||
@ApiOperation({ summary: '[관리자] 맵 등록' })
|
||||
@ApiResponse({ status: 201, description: '등록된 맵', type: MapResponseDto })
|
||||
create(@Body() dto: CreateMapDto): Promise<MapResponseDto> {
|
||||
return this.mapService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: '[관리자] 맵 수정' })
|
||||
@ApiResponse({ status: 200, description: '수정된 맵', type: MapResponseDto })
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateMapDto,
|
||||
): Promise<MapResponseDto> {
|
||||
return this.mapService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: '[관리자] 맵 삭제' })
|
||||
@ApiResponse({ status: 200, description: '삭제 완료' })
|
||||
remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||
return this.mapService.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { SaMapEntity } from './entities/sa-map.entity';
|
||||
import { SaMapService } from './sa-map.service';
|
||||
import { SaMapController } from './sa-map.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([SaMapEntity])],
|
||||
controllers: [SaMapController],
|
||||
providers: [SaMapService],
|
||||
exports: [SaMapService],
|
||||
})
|
||||
export class SaMapModule {}
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { QueryFailedError, Repository } from 'typeorm';
|
||||
|
||||
import { SaMapEntity } from './entities/sa-map.entity';
|
||||
import { CreateMapDto } from './dto/create-map.dto';
|
||||
import { UpdateMapDto } from './dto/update-map.dto';
|
||||
import { MapResponseDto } from './dto/map-response.dto';
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 서든어택 맵 — 관리자 등록 CRUD. 시즌 설정에서 모드별 토글 목록으로 사용된다.
|
||||
// ────────────────────────────────────────────────────────────
|
||||
@Injectable()
|
||||
export class SaMapService {
|
||||
constructor(
|
||||
@InjectRepository(SaMapEntity)
|
||||
private readonly repo: Repository<SaMapEntity>,
|
||||
) {}
|
||||
|
||||
/** 맵 목록 — 이름 순. */
|
||||
async findAll(): Promise<MapResponseDto[]> {
|
||||
const rows = await this.repo.find({ order: { name: 'ASC' } });
|
||||
return rows.map((r) => this.toDto(r));
|
||||
}
|
||||
|
||||
async create(dto: CreateMapDto): Promise<MapResponseDto> {
|
||||
try {
|
||||
const saved = await this.repo.save(this.repo.create(dto));
|
||||
return this.toDto(saved);
|
||||
} catch (e) {
|
||||
// 이름 unique 충돌
|
||||
if (e instanceof QueryFailedError) {
|
||||
throw new ConflictException('이미 등록된 맵 이름입니다.');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: number, dto: UpdateMapDto): Promise<MapResponseDto> {
|
||||
const map = await this.repo.findOne({ where: { id } });
|
||||
if (!map) {
|
||||
throw new NotFoundException('요청하신 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
Object.assign(map, dto);
|
||||
try {
|
||||
return this.toDto(await this.repo.save(map));
|
||||
} catch (e) {
|
||||
if (e instanceof QueryFailedError) {
|
||||
throw new ConflictException('이미 등록된 맵 이름입니다.');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: number): Promise<void> {
|
||||
const res = await this.repo.delete({ id });
|
||||
if (!res.affected) {
|
||||
throw new NotFoundException('요청하신 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(r: SaMapEntity): MapResponseDto {
|
||||
return { id: r.id, name: r.name };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// [개발 전용] 매치 데이터 적재 현황 DTO — 테이블별 행 수
|
||||
export class DevStatsDto {
|
||||
@ApiProperty({
|
||||
description: '등록 유저 수 (sa_users) — 상세 백필 캡은 유저당 적용',
|
||||
example: 2,
|
||||
})
|
||||
users!: number;
|
||||
|
||||
@ApiProperty({ description: '매치 목록 행 수 (sa_matches)', example: 1200 })
|
||||
matches!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '상세 헤더 행 수 (sa_match_details = 맵 적재 완료 매치)',
|
||||
example: 340,
|
||||
})
|
||||
details!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '참가자 행 수 (sa_match_participants)',
|
||||
example: 3400,
|
||||
})
|
||||
participants!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '상세 적재 실패 누적 행 수 (sa_match_detail_failures)',
|
||||
example: 5,
|
||||
})
|
||||
failures!: number;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsString,
|
||||
} from 'class-validator';
|
||||
|
||||
// 매치 맵 일괄 확보 요청 DTO — 화면에 보이는 매치들의 상세를 동기 적재하기 위해 사용
|
||||
export class EnsureMapsDto {
|
||||
@ApiProperty({
|
||||
description: '맵 이름을 확보할 매치 ID 목록',
|
||||
type: [String],
|
||||
example: ['match_id_1', 'match_id_2'],
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayNotEmpty({ message: 'matchIds를 1개 이상 입력해 주세요.' })
|
||||
// 화면에 보이는 매치만 대상이므로 과도한 요청을 막기 위해 상한을 둔다
|
||||
@ArrayMaxSize(100, {
|
||||
message: 'matchIds는 한 번에 최대 100개까지 요청할 수 있습니다.',
|
||||
})
|
||||
@IsString({ each: true })
|
||||
matchIds!: string[];
|
||||
}
|
||||
@@ -13,7 +13,10 @@ export class MatchDetailDto {
|
||||
@ApiProperty({ example: '폭파미션' })
|
||||
matchMode!: string;
|
||||
|
||||
@ApiProperty({ description: '매치 일시(ISO)', example: '2026-06-07T05:50:24.532Z' })
|
||||
@ApiProperty({
|
||||
description: '매치 일시(ISO)',
|
||||
example: '2026-06-07T05:50:24.532Z',
|
||||
})
|
||||
dateMatch!: string;
|
||||
|
||||
@ApiProperty({ description: '매치 맵', example: '제3보급창고' })
|
||||
|
||||
@@ -11,7 +11,10 @@ export class MatchItemDto {
|
||||
@ApiProperty({ example: '폭파미션' })
|
||||
matchMode!: string;
|
||||
|
||||
@ApiProperty({ description: '매치 일시(ISO)', example: '2026-06-07T05:50:24.532Z' })
|
||||
@ApiProperty({
|
||||
description: '매치 일시(ISO)',
|
||||
example: '2026-06-07T05:50:24.532Z',
|
||||
})
|
||||
dateMatch!: string;
|
||||
|
||||
@ApiProperty({ description: '매치 결과 코드(본인 기준)', example: '1' })
|
||||
@@ -27,7 +30,8 @@ export class MatchItemDto {
|
||||
assist!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '매치 맵 — 상세 캐시 보유 시에만 값 존재, 미보유면 빈 문자열(백그라운드 백필)',
|
||||
description:
|
||||
'매치 맵 — 상세 캐시 보유 시에만 값 존재, 미보유면 빈 문자열(백그라운드 백필)',
|
||||
example: '제3보급창고',
|
||||
})
|
||||
matchMap!: string;
|
||||
|
||||
@@ -3,7 +3,10 @@ import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
// 매치 목록 조회 쿼리 DTO
|
||||
export class MatchQueryDto {
|
||||
@ApiProperty({ description: '유저 OUID', example: '9b2852e25a85deebc5ce4173b1ccc1e6' })
|
||||
@ApiProperty({
|
||||
description: '유저 OUID',
|
||||
example: '9b2852e25a85deebc5ce4173b1ccc1e6',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'ouid를 입력해 주세요.' })
|
||||
ouid!: string;
|
||||
@@ -11,13 +14,23 @@ export class MatchQueryDto {
|
||||
@ApiProperty({
|
||||
description: '매치 타입',
|
||||
example: '랭크전 솔로',
|
||||
enum: ['퀵매치 클랜전', '클랜 랭크전', '랭크전 솔로', '랭크전 파티', '토너먼트'],
|
||||
enum: [
|
||||
'퀵매치 클랜전',
|
||||
'클랜 랭크전',
|
||||
'랭크전 솔로',
|
||||
'랭크전 파티',
|
||||
'토너먼트',
|
||||
],
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'match_type을 입력해 주세요.' })
|
||||
match_type!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '매치 모드', example: '폭파미션', default: '폭파미션' })
|
||||
@ApiPropertyOptional({
|
||||
description: '매치 모드',
|
||||
example: '폭파미션',
|
||||
default: '폭파미션',
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
match_mode: string = '폭파미션';
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||
|
||||
// 매치 상세 참가자 (lazy) — 한 매치의 양 팀 전원 성적. match-detail API 로 적재.
|
||||
@Entity('sa_match_participants')
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { SaMatchService } from './sa-match.service';
|
||||
import { MatchQueryDto } from './dto/match-query.dto';
|
||||
import { MatchItemDto } from './dto/match-item.dto';
|
||||
import { MatchDetailDto } from './dto/match-detail.dto';
|
||||
import { EnsureMapsDto } from './dto/ensure-maps.dto';
|
||||
import { DevStatsDto } from './dto/dev-stats.dto';
|
||||
|
||||
@ApiTags('SA Match')
|
||||
@Controller('sa-matches')
|
||||
@@ -19,7 +29,56 @@ export class SaMatchController {
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '매치 목록', type: [MatchItemDto] })
|
||||
getMatches(@Query() dto: MatchQueryDto): Promise<MatchItemDto[]> {
|
||||
return this.saMatchService.getMatches(dto.ouid, dto.match_type, dto.match_mode);
|
||||
return this.saMatchService.getMatches(
|
||||
dto.ouid,
|
||||
dto.match_type,
|
||||
dto.match_mode,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('details/maps')
|
||||
@ApiOperation({
|
||||
summary: '매치 맵 일괄 확보 (보이는 매치)',
|
||||
description:
|
||||
'화면에 표시되는 매치들의 맵 이름을 반환합니다. 캐시 미보유 매치는 상세를 동기로 적재한 뒤 반환하므로, 응답 시점에 보이는 매치는 맵이 채워져 있습니다.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'matchId → 맵 이름 맵 (확보 실패 매치는 빈 문자열)',
|
||||
})
|
||||
ensureMaps(@Body() dto: EnsureMapsDto): Promise<Record<string, string>> {
|
||||
return this.saMatchService.ensureDetailMaps(dto.matchIds);
|
||||
}
|
||||
|
||||
@Post('dev/sync')
|
||||
@ApiOperation({
|
||||
summary: '[개발 전용] 매치 동기화 수동 트리거',
|
||||
description:
|
||||
'야간 크론(전 유저 목록 동기화 + 상세 백필)을 즉시 백그라운드로 실행합니다. 운영 환경에서는 비활성화됩니다.',
|
||||
})
|
||||
@ApiResponse({ status: 201, description: '트리거 결과' })
|
||||
devSync(): { triggered: boolean } {
|
||||
// 운영에서는 노출하지 않는다 (쿼터/부하 보호)
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new NotFoundException('요청하신 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
// 응답을 막지 않도록 백그라운드 실행 (fire-and-forget)
|
||||
void this.saMatchService.handleDailyMatchSync();
|
||||
return { triggered: true };
|
||||
}
|
||||
|
||||
@Get('dev/stats')
|
||||
@ApiOperation({
|
||||
summary: '[개발 전용] 매치 데이터 적재 현황',
|
||||
description:
|
||||
'매치 목록/상세/참가자/실패 테이블의 행 수를 반환합니다. dev/sync 진행 확인용. 운영 환경에서는 비활성화됩니다.',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '적재 현황', type: DevStatsDto })
|
||||
devStats(): Promise<DevStatsDto> {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new NotFoundException('요청하신 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
return this.saMatchService.getDevStats();
|
||||
}
|
||||
|
||||
@Get(':matchId')
|
||||
|
||||
@@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, MoreThanOrEqual, Repository } from 'typeorm';
|
||||
|
||||
import { NexonService } from '../../shared/nexon/nexon.service';
|
||||
import { runWithConcurrency } from '../../shared/utils/concurrent';
|
||||
import { SaUserEntity } from '../sa-user/entities/sa-user.entity';
|
||||
import { MatchItemDto } from './dto/match-item.dto';
|
||||
import { MatchDetailDto } from './dto/match-detail.dto';
|
||||
@@ -68,17 +69,22 @@ export class SaMatchService {
|
||||
'랭크전 파티',
|
||||
'토너먼트',
|
||||
];
|
||||
// 목록 조회 시 상세(맵) 백필 한도 — 프론트 더보기 노출 개수와 동일 (개발 4 / 운영 20)
|
||||
private static readonly BACKFILL_LIMIT =
|
||||
process.env.NODE_ENV === 'production' ? 20 : 4;
|
||||
// 크론 백필 유저당 한도 — 개발은 넥슨 일일 쿼터(1,000건/일) 보호를 위해 20건, 운영은 무제한(null)
|
||||
private static readonly CRON_PER_USER_BACKFILL_LIMIT: number | null =
|
||||
process.env.NODE_ENV === 'production' ? null : 20;
|
||||
// 상세 적재 실패 허용 횟수 — 초과한 매치(넥슨 미제공 추정)는 백필 대상에서 영구 제외
|
||||
private static readonly MAX_DETAIL_FAILURES = 3;
|
||||
// 상세 백필 동시 워커 수 — env MATCH_BACKFILL_CONCURRENCY (기본: 운영 16 / 개발 2)
|
||||
// 각 호출은 NexonService 전역 rate limiter 를 통과하므로 동시성을 높여도 초당 한도는 안전하게 유지된다.
|
||||
private static readonly BACKFILL_CONCURRENCY = ((): number => {
|
||||
const raw = Number(process.env.MATCH_BACKFILL_CONCURRENCY);
|
||||
if (Number.isFinite(raw) && raw > 0) return Math.floor(raw);
|
||||
return process.env.NODE_ENV === 'production' ? 16 : 2;
|
||||
})();
|
||||
|
||||
// 백필 중복 실행 방지 — 진행 중인 matchId 집합
|
||||
private readonly backfillInFlight = new Set<string>();
|
||||
// 백필 중복 실행 방지 + 진행 중 적재 공유 — matchId → 적재 Promise
|
||||
// 같은 매치를 동시에 요청하면 새 호출 대신 진행 중인 Promise 를 await 한다(중복 넥슨 호출 방지 + 정확성).
|
||||
private readonly backfillInFlight = new Map<string, Promise<void>>();
|
||||
|
||||
constructor(
|
||||
@InjectRepository(SaMatchEntity)
|
||||
@@ -94,6 +100,26 @@ export class SaMatchService {
|
||||
private readonly nexon: NexonService,
|
||||
) {}
|
||||
|
||||
/** [개발 전용] 매치 데이터 적재 현황 — 테이블별 행 수. */
|
||||
async getDevStats(): Promise<{
|
||||
users: number;
|
||||
matches: number;
|
||||
details: number;
|
||||
participants: number;
|
||||
failures: number;
|
||||
}> {
|
||||
const [users, matches, details, participants, failures] = await Promise.all(
|
||||
[
|
||||
this.userRepo.count(),
|
||||
this.matchRepo.count(),
|
||||
this.detailRepo.count(),
|
||||
this.participantRepo.count(),
|
||||
this.failureRepo.count(),
|
||||
],
|
||||
);
|
||||
return { users, matches, details, participants, failures };
|
||||
}
|
||||
|
||||
/** 매치 목록 조회 — 최신분 누적 후 DB에서 반환. 맵은 상세 캐시에서 조인해 채운다. */
|
||||
async getMatches(
|
||||
ouid: string,
|
||||
@@ -117,27 +143,9 @@ export class SaMatchService {
|
||||
: [];
|
||||
const mapById = new Map(details.map((d) => [d.matchId, d.matchMap]));
|
||||
|
||||
// 맵 미보유 최근 매치는 백그라운드 백필 (응답은 기다리지 않음)
|
||||
const missingIds = saved
|
||||
.filter((m) => !mapById.has(m.matchId))
|
||||
.map((m) => m.matchId);
|
||||
if (missingIds.length) {
|
||||
// 실패 한도를 초과한 매치(넥슨 미제공 추정)는 제외해 쿼터 낭비를 막는다
|
||||
const dead = await this.failureRepo.find({
|
||||
where: {
|
||||
matchId: In(missingIds),
|
||||
failCount: MoreThanOrEqual(SaMatchService.MAX_DETAIL_FAILURES),
|
||||
},
|
||||
select: ['matchId'],
|
||||
});
|
||||
const deadSet = new Set(dead.map((f) => f.matchId));
|
||||
this.backfillDetails(
|
||||
missingIds
|
||||
.filter((id) => !deadSet.has(id))
|
||||
.slice(0, SaMatchService.BACKFILL_LIMIT),
|
||||
);
|
||||
}
|
||||
|
||||
// 상세(맵) 적재는 조회 시 자동 백필하지 않는다.
|
||||
// - 보이는 매치는 프론트가 ensureDetailMaps(경로 ②)로 동기 확보(— 노출 방지)
|
||||
// - 그 외 미보유분은 야간 크론(handleDailyMatchSync)이 전량 적재
|
||||
return saved.map((m) => ({
|
||||
matchId: m.matchId,
|
||||
matchType: m.matchType,
|
||||
@@ -151,27 +159,93 @@ export class SaMatchService {
|
||||
}));
|
||||
}
|
||||
|
||||
/** 상세 미보유 매치를 비동기로 적재 (fire-and-forget). 중복/실패는 격리. */
|
||||
private backfillDetails(matchIds: string[]): void {
|
||||
const targets = matchIds.filter((id) => !this.backfillInFlight.has(id));
|
||||
if (!targets.length) return;
|
||||
targets.forEach((id) => this.backfillInFlight.add(id));
|
||||
/**
|
||||
* 주어진 매치들의 맵 이름을 확보해 반환한다. (화면에 보이는 매치 전용 — 동기)
|
||||
* - 캐시 보유분은 그대로 사용하고, 미보유분은 상세를 **기다렸다가** 적재한다.
|
||||
* - 실패 한도 초과 매치(넥슨 영구 미제공 추정)는 호출하지 않고 빈 문자열로 둔다.
|
||||
* - 반환: matchId → 맵 이름(확보 실패 시 '')
|
||||
*/
|
||||
async ensureDetailMaps(matchIds: string[]): Promise<Record<string, string>> {
|
||||
const result: Record<string, string> = {};
|
||||
const ids = Array.from(new Set(matchIds.filter((id) => id)));
|
||||
if (!ids.length) return result;
|
||||
|
||||
void (async () => {
|
||||
for (const id of targets) {
|
||||
try {
|
||||
// 호출 간격은 NexonService 전역 rate limiter 가 조절한다
|
||||
await this.getMatchDetail(id);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`매치 상세 백필 실패(matchId=${id}): ${(error as Error).message}`,
|
||||
);
|
||||
await this.recordDetailFailure(id, (error as Error).message);
|
||||
} finally {
|
||||
this.backfillInFlight.delete(id);
|
||||
}
|
||||
// 1) 이미 캐시된 헤더의 맵 사용
|
||||
const cached = await this.detailRepo.find({
|
||||
where: { matchId: In(ids) },
|
||||
select: ['matchId', 'matchMap'],
|
||||
});
|
||||
cached.forEach((c) => (result[c.matchId] = c.matchMap));
|
||||
const cachedSet = new Set(cached.map((c) => c.matchId));
|
||||
|
||||
// 2) 미보유분 중 실패 한도 초과 매치는 제외(쿼터 낭비 방지)
|
||||
const missing = ids.filter((id) => !cachedSet.has(id));
|
||||
if (missing.length) {
|
||||
const dead = await this.failureRepo.find({
|
||||
where: {
|
||||
matchId: In(missing),
|
||||
failCount: MoreThanOrEqual(SaMatchService.MAX_DETAIL_FAILURES),
|
||||
},
|
||||
select: ['matchId'],
|
||||
});
|
||||
const deadSet = new Set(dead.map((f) => f.matchId));
|
||||
const toFetch = missing.filter((id) => !deadSet.has(id));
|
||||
|
||||
// 3) 동기 적재 — 보이는 매치라 응답 전에 끝까지 기다린다(동시 워커 풀 사용)
|
||||
await this.runBackfill(toFetch);
|
||||
|
||||
// 4) 적재 결과 재조회 (동시 적재로 채워진 분까지 모두 반영)
|
||||
const filled = await this.detailRepo.find({
|
||||
where: { matchId: In(missing) },
|
||||
select: ['matchId', 'matchMap'],
|
||||
});
|
||||
filled.forEach((c) => (result[c.matchId] = c.matchMap));
|
||||
}
|
||||
|
||||
// 끝내 확보하지 못한 매치는 빈 문자열로 명시
|
||||
for (const id of ids) if (!(id in result)) result[id] = '';
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 매치 상세를 동시 워커 풀(BACKFILL_CONCURRENCY)로 적재한다. (크론·동기확보 공통)
|
||||
* - 진행 중인 매치는 건너뛰지 않고 그 Promise 를 await 한다 → 동기 확보(②) 시 누락 없음
|
||||
* - 개별 실패는 격리(실패 기록 누적)되어 전체 흐름을 끊지 않는다
|
||||
* - 각 호출은 NexonService 전역 rate limiter 를 통과하므로 초당 한도를 넘지 않는다
|
||||
*/
|
||||
private async runBackfill(matchIds: string[]): Promise<void> {
|
||||
const targets = matchIds.filter((id) => id);
|
||||
if (!targets.length) return;
|
||||
|
||||
await runWithConcurrency(
|
||||
targets,
|
||||
SaMatchService.BACKFILL_CONCURRENCY,
|
||||
(id) => this.fetchDetailOnce(id),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 매치 상세를 1회만 적재한다. 같은 매치를 동시에 요청하면 진행 중인 Promise 를 공유한다.
|
||||
* 실패는 격리(실패 기록 누적)하여 호출 측 흐름을 끊지 않는다.
|
||||
*/
|
||||
private fetchDetailOnce(matchId: string): Promise<void> {
|
||||
const existing = this.backfillInFlight.get(matchId);
|
||||
if (existing) return existing;
|
||||
|
||||
const task = (async () => {
|
||||
try {
|
||||
await this.getMatchDetail(matchId);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`매치 상세 백필 실패(matchId=${matchId}): ${(error as Error).message}`,
|
||||
);
|
||||
await this.recordDetailFailure(matchId, (error as Error).message);
|
||||
} finally {
|
||||
this.backfillInFlight.delete(matchId);
|
||||
}
|
||||
})();
|
||||
this.backfillInFlight.set(matchId, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
/** 넥슨 매치 목록 API → 신규분 누적(upsert). 실패는 격리(기존 데이터 유지). */
|
||||
@@ -271,23 +345,10 @@ export class SaMatchService {
|
||||
|
||||
const perUserLimit = SaMatchService.CRON_PER_USER_BACKFILL_LIMIT;
|
||||
this.logger.log(
|
||||
`매치 상세 백필 시작 (${targets.length}건${perUserLimit !== null ? `, 유저당 ${perUserLimit}건 한도` : ''})`,
|
||||
`매치 상세 백필 시작 (${targets.length}건${perUserLimit !== null ? `, 유저당 ${perUserLimit}건 한도` : ''}, 동시 ${SaMatchService.BACKFILL_CONCURRENCY})`,
|
||||
);
|
||||
for (const matchId of targets) {
|
||||
// 온디맨드 백필과 같은 매치를 동시에 적재하지 않도록 가드
|
||||
if (this.backfillInFlight.has(matchId)) continue;
|
||||
this.backfillInFlight.add(matchId);
|
||||
try {
|
||||
await this.getMatchDetail(matchId);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`매치 상세 백필 실패(matchId=${matchId}): ${(error as Error).message}`,
|
||||
);
|
||||
await this.recordDetailFailure(matchId, (error as Error).message);
|
||||
} finally {
|
||||
this.backfillInFlight.delete(matchId);
|
||||
}
|
||||
}
|
||||
// 온디맨드와 동일한 동시 워커 풀로 적재(중복 매치는 backfillInFlight 가 가드)
|
||||
await this.runBackfill(targets);
|
||||
}
|
||||
|
||||
/** 상세 적재 실패 기록 — 횟수를 누적해 한도 초과 시 백필 대상에서 제외한다. */
|
||||
|
||||
@@ -17,7 +17,8 @@ export class SaMetaController {
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '종류별 메타 목록 { grade: [...], season_grade: [...], tier: [...] }',
|
||||
description:
|
||||
'종류별 메타 목록 { grade: [...], season_grade: [...], tier: [...] }',
|
||||
})
|
||||
getAll(): Promise<Record<string, MetaItemDto[]>> {
|
||||
return this.saMetaService.getAll();
|
||||
|
||||
@@ -91,7 +91,9 @@ export class SaMetaService implements OnModuleInit {
|
||||
.filter((it) => it.name);
|
||||
|
||||
if (!items.length) {
|
||||
this.logger.warn(`[${src.type}] 메타 결과가 비어 건너뜁니다(기존 유지).`);
|
||||
this.logger.warn(
|
||||
`[${src.type}] 메타 결과가 비어 건너뜁니다(기존 유지).`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsArray,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
// 시즌 등록 요청 DTO (관리자)
|
||||
export class CreateSeasonDto {
|
||||
@ApiProperty({ description: '시즌명', example: '2026 시즌 1' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '시즌명을 입력해 주세요.' })
|
||||
@MaxLength(50)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '시즌 시작일 (YYYY-MM-DD)',
|
||||
example: '2026-01-01',
|
||||
})
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/, {
|
||||
message: '시작일은 YYYY-MM-DD 형식이어야 합니다.',
|
||||
})
|
||||
startDate!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '시즌 종료일 (YYYY-MM-DD)',
|
||||
example: '2026-03-31',
|
||||
})
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/, {
|
||||
message: '종료일은 YYYY-MM-DD 형식이어야 합니다.',
|
||||
})
|
||||
endDate!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '5v5 맵 목록',
|
||||
type: [String],
|
||||
example: ['제3보급창고', '웨어하우스'],
|
||||
})
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
@IsString({ each: true })
|
||||
maps5v5?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '3v3 맵 목록',
|
||||
type: [String],
|
||||
example: ['포트', '컨테이너'],
|
||||
})
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
@IsString({ each: true })
|
||||
maps3v3?: string[];
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 시즌 응답 DTO
|
||||
export class SeasonResponseDto {
|
||||
@ApiProperty({ example: 1 })
|
||||
id!: number;
|
||||
|
||||
@ApiProperty({ example: '2026 시즌 1' })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ description: '시작일 (YYYY-MM-DD)', example: '2026-01-01' })
|
||||
startDate!: string;
|
||||
|
||||
@ApiProperty({ description: '종료일 (YYYY-MM-DD)', example: '2026-03-31' })
|
||||
endDate!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '5v5 맵 목록',
|
||||
type: [String],
|
||||
example: ['제3보급창고', '웨어하우스'],
|
||||
})
|
||||
maps5v5!: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '3v3 맵 목록',
|
||||
type: [String],
|
||||
example: ['포트', '컨테이너'],
|
||||
})
|
||||
maps3v3!: string[];
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
|
||||
import { CreateSeasonDto } from './create-season.dto';
|
||||
|
||||
// 시즌 수정 요청 DTO (관리자) — 모든 필드 선택적
|
||||
export class UpdateSeasonDto extends PartialType(CreateSeasonDto) {}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
// 랭크전 시즌 — 관리자가 등록하는 시즌 기간 정의.
|
||||
// 시즌별 전적은 이 기간(startDate~endDate)으로 매치를 버킷팅해 집계한다.
|
||||
@Entity('sa_seasons')
|
||||
export class SaSeasonEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
// 시즌명 (예: '2026 시즌 1')
|
||||
@Column({ type: 'varchar', length: 50 })
|
||||
name!: string;
|
||||
|
||||
// 시즌 시작일 (YYYY-MM-DD)
|
||||
@Column({ type: 'date' })
|
||||
startDate!: string;
|
||||
|
||||
// 시즌 종료일 (YYYY-MM-DD)
|
||||
@Column({ type: 'date' })
|
||||
endDate!: string;
|
||||
|
||||
// 5v5 / 3v3 맵 목록 — 관리자가 시즌별로 설정. (simple-array: 콤마 구분 텍스트로 저장)
|
||||
@Column({ type: 'simple-array', nullable: true })
|
||||
maps5v5!: string[] | null;
|
||||
|
||||
@Column({ type: 'simple-array', nullable: true })
|
||||
maps3v3!: string[] | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { SaSeasonService } from './sa-season.service';
|
||||
import { CreateSeasonDto } from './dto/create-season.dto';
|
||||
import { UpdateSeasonDto } from './dto/update-season.dto';
|
||||
import { SeasonResponseDto } from './dto/season-response.dto';
|
||||
|
||||
@ApiTags('SA Season')
|
||||
@Controller('sa-seasons')
|
||||
export class SaSeasonController {
|
||||
constructor(private readonly seasonService: SaSeasonService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: '시즌 목록 조회',
|
||||
description: '등록된 랭크전 시즌 목록을 시작일 내림차순으로 반환합니다.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '시즌 목록',
|
||||
type: [SeasonResponseDto],
|
||||
})
|
||||
findAll(): Promise<SeasonResponseDto[]> {
|
||||
return this.seasonService.findAll();
|
||||
}
|
||||
|
||||
@Get('active')
|
||||
@ApiOperation({
|
||||
summary: '현재 활성 시즌 조회',
|
||||
description:
|
||||
'오늘(KST) 날짜가 포함된 시즌을 반환합니다. 활성 시즌이 없으면 null.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '현재 활성 시즌 (없으면 null)',
|
||||
type: SeasonResponseDto,
|
||||
})
|
||||
getActive(): Promise<SeasonResponseDto | null> {
|
||||
return this.seasonService.getActiveSeason();
|
||||
}
|
||||
|
||||
// ── 관리자 전용 ─────────────────────────────────────────────
|
||||
// TODO: 인증 도입 시 AdminGuard 적용 (현재는 가드 미적용 — 관리자 화면 연동 시 보호 필요)
|
||||
@Post()
|
||||
@ApiOperation({ summary: '[관리자] 시즌 등록' })
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: '등록된 시즌',
|
||||
type: SeasonResponseDto,
|
||||
})
|
||||
create(@Body() dto: CreateSeasonDto): Promise<SeasonResponseDto> {
|
||||
return this.seasonService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: '[관리자] 시즌 수정' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '수정된 시즌',
|
||||
type: SeasonResponseDto,
|
||||
})
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateSeasonDto,
|
||||
): Promise<SeasonResponseDto> {
|
||||
return this.seasonService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: '[관리자] 시즌 삭제' })
|
||||
@ApiResponse({ status: 200, description: '삭제 완료' })
|
||||
remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||
return this.seasonService.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { SaSeasonEntity } from './entities/sa-season.entity';
|
||||
import { SaSeasonService } from './sa-season.service';
|
||||
import { SaSeasonController } from './sa-season.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([SaSeasonEntity])],
|
||||
controllers: [SaSeasonController],
|
||||
providers: [SaSeasonService],
|
||||
exports: [SaSeasonService],
|
||||
})
|
||||
export class SaSeasonModule {}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { SaSeasonEntity } from './entities/sa-season.entity';
|
||||
import { CreateSeasonDto } from './dto/create-season.dto';
|
||||
import { UpdateSeasonDto } from './dto/update-season.dto';
|
||||
import { SeasonResponseDto } from './dto/season-response.dto';
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 랭크전 시즌 — 관리자가 등록/수정/삭제하는 시즌 기간 정의.
|
||||
// 시즌별 전적 집계(프론트)는 이 목록의 기간으로 매치를 버킷팅한다.
|
||||
// ────────────────────────────────────────────────────────────
|
||||
@Injectable()
|
||||
export class SaSeasonService {
|
||||
constructor(
|
||||
@InjectRepository(SaSeasonEntity)
|
||||
private readonly repo: Repository<SaSeasonEntity>,
|
||||
) {}
|
||||
|
||||
/** 시즌 목록 — 시작일 내림차순(최신 시즌 우선). */
|
||||
async findAll(): Promise<SeasonResponseDto[]> {
|
||||
const rows = await this.repo.find({ order: { startDate: 'DESC' } });
|
||||
return rows.map((r) => this.toDto(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 활성 시즌 — 오늘(KST)이 [startDate, endDate]에 포함된 시즌.
|
||||
* 여러 시즌이 겹치면 시작일이 가장 늦은 것을 선택. 없으면 null.
|
||||
*/
|
||||
async getActiveSeason(): Promise<SeasonResponseDto | null> {
|
||||
const today = this.todayKst();
|
||||
const row = await this.repo
|
||||
.createQueryBuilder('s')
|
||||
.where('s.startDate <= :today AND s.endDate >= :today', { today })
|
||||
.orderBy('s.startDate', 'DESC')
|
||||
.getOne();
|
||||
return row ? this.toDto(row) : null;
|
||||
}
|
||||
|
||||
/** KST 기준 오늘 날짜 (YYYY-MM-DD) */
|
||||
private todayKst(): string {
|
||||
const kst = new Date(Date.now() + 9 * 60 * 60 * 1000);
|
||||
return kst.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
async create(dto: CreateSeasonDto): Promise<SeasonResponseDto> {
|
||||
const saved = await this.repo.save(this.repo.create(dto));
|
||||
return this.toDto(saved);
|
||||
}
|
||||
|
||||
async update(id: number, dto: UpdateSeasonDto): Promise<SeasonResponseDto> {
|
||||
const season = await this.repo.findOne({ where: { id } });
|
||||
if (!season) {
|
||||
throw new NotFoundException('요청하신 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
Object.assign(season, dto);
|
||||
return this.toDto(await this.repo.save(season));
|
||||
}
|
||||
|
||||
async remove(id: number): Promise<void> {
|
||||
const res = await this.repo.delete({ id });
|
||||
if (!res.affected) {
|
||||
throw new NotFoundException('요청하신 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(r: SaSeasonEntity): SeasonResponseDto {
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
startDate: r.startDate,
|
||||
endDate: r.endDate,
|
||||
// simple-array 는 빈 배열을 ['']로 되읽을 수 있어 빈 문자열을 거른다
|
||||
maps5v5: (r.maps5v5 ?? []).filter((m) => m),
|
||||
maps3v3: (r.maps3v3 ?? []).filter((m) => m),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 클랜 활동기간 DTO — 일별 스냅샷(clanName 시계열)에서 파생한 한 번의 활동 구간(run)
|
||||
export class ClanPeriodDto {
|
||||
@ApiProperty({ description: '클랜명', example: '바디' })
|
||||
clan!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'활동 시작일(YYYY-MM-DD). estimated=true면 최초 조회일(하한 — 실제 가입은 더 이전일 수 있음)',
|
||||
example: '2026-06-12',
|
||||
})
|
||||
from!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'활동 종료일(YYYY-MM-DD). 진행 중이면 오늘(표시는 ongoing 으로 판단)',
|
||||
example: '2026-06-20',
|
||||
})
|
||||
to!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'진행 중 여부 — 현재 소속 클랜이면 true(종료일 대신 "진행중"으로 표시)',
|
||||
example: true,
|
||||
})
|
||||
ongoing!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'가입일 추정 여부 — 최초 관측 시점이라 실제 가입일은 더 이전일 수 있음(최초 기간만 true)',
|
||||
example: true,
|
||||
})
|
||||
estimated!: boolean;
|
||||
}
|
||||
@@ -8,7 +8,10 @@ export class ProfileResponseDto {
|
||||
@ApiProperty({ example: 'Hack' })
|
||||
userName!: string;
|
||||
|
||||
@ApiProperty({ description: '계정 생성일(ISO)', example: '2020-02-02T23:15:59.330Z' })
|
||||
@ApiProperty({
|
||||
description: '계정 생성일(ISO)',
|
||||
example: '2020-02-02T23:15:59.330Z',
|
||||
})
|
||||
dateCreate!: string;
|
||||
|
||||
@ApiProperty({ description: '칭호', example: '짝' })
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 시즌별 티어/점수 — 시즌 기간 내 마지막 스냅샷(시즌 종료 시점) 기준
|
||||
export class SeasonTierDto {
|
||||
@ApiProperty({ description: '시즌 ID', example: 1 })
|
||||
seasonId!: number;
|
||||
|
||||
@ApiProperty({ description: '솔로 랭크전 티어', example: '다이아몬드' })
|
||||
soloTier!: string;
|
||||
|
||||
@ApiProperty({ description: '솔로 랭크전 점수(RP)', example: 1850 })
|
||||
soloScore!: number;
|
||||
|
||||
@ApiProperty({ description: '파티 랭크전 티어', example: '플래티넘' })
|
||||
partyTier!: string;
|
||||
|
||||
@ApiProperty({ description: '파티 랭크전 점수(RP)', example: 1600 })
|
||||
partyScore!: number;
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { SaUserService } from './sa-user.service';
|
||||
import { SearchUserDto } from './dto/search-user.dto';
|
||||
import { OuidResponseDto } from './dto/ouid-response.dto';
|
||||
import { ProfileResponseDto } from './dto/profile-response.dto';
|
||||
import { ClanPeriodDto } from './dto/clan-period.dto';
|
||||
import { SeasonTierDto } from './dto/season-tier.dto';
|
||||
|
||||
@ApiTags('SA User')
|
||||
@Controller('sa-users')
|
||||
@@ -17,8 +19,15 @@ export class SaUserController {
|
||||
description:
|
||||
'닉네임으로 넥슨 오픈 API에서 OUID를 조회합니다. 결과는 DB에 저장되며, 신규 유저는 프로필도 즉시 동기화됩니다. 이후 매일 크론으로 자동 갱신됩니다.',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '조회된 OUID', type: OuidResponseDto })
|
||||
@ApiResponse({ status: 404, description: '해당 닉네임의 유저를 찾을 수 없음' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '조회된 OUID',
|
||||
type: OuidResponseDto,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 404,
|
||||
description: '해당 닉네임의 유저를 찾을 수 없음',
|
||||
})
|
||||
search(@Query() dto: SearchUserDto): Promise<OuidResponseDto> {
|
||||
return this.saUserService.searchOuid(dto.user_name);
|
||||
}
|
||||
@@ -29,8 +38,46 @@ export class SaUserController {
|
||||
description:
|
||||
'OUID로 종합 프로필(기본/계급/티어/최근동향)을 반환합니다. DB에 없으면 1회 동기화 후 반환합니다.',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '유저 프로필', type: ProfileResponseDto })
|
||||
getProfile(@Param('ouid') ouid: string): Promise<ProfileResponseDto> {
|
||||
return this.saUserService.getProfile(ouid);
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '유저 프로필',
|
||||
type: ProfileResponseDto,
|
||||
})
|
||||
getProfile(
|
||||
@Param('ouid') ouid: string,
|
||||
@Query('refresh') refresh?: string,
|
||||
): Promise<ProfileResponseDto> {
|
||||
// refresh=true 면 넥슨에서 강제 재동기화 (전적 갱신)
|
||||
return this.saUserService.getProfile(ouid, refresh === 'true');
|
||||
}
|
||||
|
||||
@Get(':ouid/clan-periods')
|
||||
@ApiOperation({
|
||||
summary: '클랜 활동기간 타임라인',
|
||||
description:
|
||||
'일별 스냅샷에서 파생한 클랜 활동기간 목록을 반환합니다. 최초 기간은 가입일 추정(estimated=true)이며, 이후 클랜 변경은 정확히 분리됩니다.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '클랜 활동기간 목록',
|
||||
type: [ClanPeriodDto],
|
||||
})
|
||||
getClanPeriods(@Param('ouid') ouid: string): Promise<ClanPeriodDto[]> {
|
||||
return this.saUserService.getClanPeriods(ouid);
|
||||
}
|
||||
|
||||
@Get(':ouid/season-tiers')
|
||||
@ApiOperation({
|
||||
summary: '시즌별 티어/점수',
|
||||
description:
|
||||
'각 시즌 기간 내 마지막 스냅샷(시즌 종료 시점)의 솔로/파티 티어·점수를 반환합니다. 과거 시즌의 최종 티어를 일별 스냅샷에서 복원합니다.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '시즌별 티어/점수 목록',
|
||||
type: [SeasonTierDto],
|
||||
})
|
||||
getSeasonTiers(@Param('ouid') ouid: string): Promise<SeasonTierDto[]> {
|
||||
return this.saUserService.getSeasonTiers(ouid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ import { SaUserService } from './sa-user.service';
|
||||
import { SaUserEntity } from './entities/sa-user.entity';
|
||||
import { SaUserProfileEntity } from './entities/sa-user-profile.entity';
|
||||
import { SaUserSnapshotEntity } from './entities/sa-user-snapshot.entity';
|
||||
import { SaSeasonModule } from '../sa-season/sa-season.module';
|
||||
|
||||
// 서든어택 유저 모듈 — OUID 검색 + 종합 프로필 동기화(매일 크론) + 일별 이력
|
||||
// 넥슨 호출은 전역 NexonModule(NexonService)을 주입해 사용한다.
|
||||
// 시즌별 티어 스냅샷 조회를 위해 SaSeasonModule(SaSeasonService)을 주입한다.
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
@@ -16,6 +18,7 @@ import { SaUserSnapshotEntity } from './entities/sa-user-snapshot.entity';
|
||||
SaUserProfileEntity,
|
||||
SaUserSnapshotEntity,
|
||||
]),
|
||||
SaSeasonModule,
|
||||
],
|
||||
controllers: [SaUserController],
|
||||
providers: [SaUserService],
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Between, Repository } from 'typeorm';
|
||||
|
||||
import { NexonService } from '../../shared/nexon/nexon.service';
|
||||
import { SaSeasonService } from '../sa-season/sa-season.service';
|
||||
import { OuidResponseDto } from './dto/ouid-response.dto';
|
||||
import { ProfileResponseDto } from './dto/profile-response.dto';
|
||||
import { ClanPeriodDto } from './dto/clan-period.dto';
|
||||
import { SeasonTierDto } from './dto/season-tier.dto';
|
||||
import { SaUserEntity } from './entities/sa-user.entity';
|
||||
import { SaUserProfileEntity } from './entities/sa-user-profile.entity';
|
||||
import { SaUserSnapshotEntity } from './entities/sa-user-snapshot.entity';
|
||||
@@ -66,8 +69,35 @@ export class SaUserService {
|
||||
@InjectRepository(SaUserSnapshotEntity)
|
||||
private readonly snapshotRepo: Repository<SaUserSnapshotEntity>,
|
||||
private readonly nexon: NexonService,
|
||||
private readonly seasonService: SaSeasonService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 시즌별 티어/점수 — 각 시즌 기간 내 **마지막 스냅샷**(시즌 종료 시점)의 값.
|
||||
* 일별 크론 스냅샷을 활용해 과거 시즌의 최종 티어/점수를 보존·조회한다.
|
||||
* 해당 시즌 기간에 스냅샷이 없으면(추적 이전) 결과에서 제외된다.
|
||||
*/
|
||||
async getSeasonTiers(ouid: string): Promise<SeasonTierDto[]> {
|
||||
const seasons = await this.seasonService.findAll();
|
||||
const out: SeasonTierDto[] = [];
|
||||
for (const s of seasons) {
|
||||
const snap = await this.snapshotRepo.findOne({
|
||||
where: { ouid, snapshotDate: Between(s.startDate, s.endDate) },
|
||||
order: { snapshotDate: 'DESC' },
|
||||
select: ['soloTier', 'soloScore', 'partyTier', 'partyScore'],
|
||||
});
|
||||
if (!snap) continue;
|
||||
out.push({
|
||||
seasonId: s.id,
|
||||
soloTier: snap.soloTier,
|
||||
soloScore: snap.soloScore,
|
||||
partyTier: snap.partyTier,
|
||||
partyScore: snap.partyScore,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 닉네임으로 OUID 조회. ouid 기준으로 정규화하므로 닉네임 변경/양도가 일어나도
|
||||
* 행이 중복되지 않는다. 신규 유저는 프로필도 즉시 동기화한다.
|
||||
@@ -113,8 +143,14 @@ export class SaUserService {
|
||||
return { ouid, userName };
|
||||
}
|
||||
|
||||
/** 종합 프로필 조회 (DB 우선, 없으면 1회 동기화). */
|
||||
async getProfile(ouid: string): Promise<ProfileResponseDto> {
|
||||
/**
|
||||
* 종합 프로필 조회. 기본은 DB 우선(없으면 1회 동기화).
|
||||
* refresh=true 면 넥슨에서 강제 재동기화한다(전적 갱신 버튼).
|
||||
*/
|
||||
async getProfile(ouid: string, refresh = false): Promise<ProfileResponseDto> {
|
||||
if (refresh) {
|
||||
return this.toDto(await this.syncProfile(ouid));
|
||||
}
|
||||
const profile =
|
||||
(await this.profileRepo.findOne({ where: { ouid } })) ??
|
||||
(await this.syncProfile(ouid));
|
||||
@@ -214,6 +250,47 @@ export class SaUserService {
|
||||
this.logger.log(`일일 유저 프로필 동기화 완료 (${ok}/${users.length})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 클랜 활동기간 타임라인 — 일별 스냅샷(clanName 시계열)에서 파생한다.
|
||||
* - 연속 동일 clanName 을 한 활동기간(run)으로 묶는다. (clanName '' = 무소속 → 제외)
|
||||
* - run.to = 다음 세그먼트 시작일(없으면 오늘) → 변경 시점에 자동으로 기간이 갈린다.
|
||||
* - 최초 세그먼트만 estimated=true (최초 관측 시점이라 실제 가입일은 더 이전일 수 있음).
|
||||
*/
|
||||
async getClanPeriods(ouid: string): Promise<ClanPeriodDto[]> {
|
||||
const snaps = await this.snapshotRepo.find({
|
||||
where: { ouid },
|
||||
order: { snapshotDate: 'ASC' },
|
||||
select: ['snapshotDate', 'clanName'],
|
||||
});
|
||||
if (!snaps.length) return [];
|
||||
|
||||
// 연속 동일 clanName 세그먼트로 압축
|
||||
const segs: { clan: string; from: string }[] = [];
|
||||
for (const s of snaps) {
|
||||
const clan = s.clanName ?? '';
|
||||
const last = segs[segs.length - 1];
|
||||
if (!last || last.clan !== clan)
|
||||
segs.push({ clan, from: s.snapshotDate });
|
||||
}
|
||||
|
||||
// 비어있지 않은 클랜 세그먼트 → 활동기간 run
|
||||
const today = this.todayKst();
|
||||
const runs: ClanPeriodDto[] = [];
|
||||
for (let i = 0; i < segs.length; i++) {
|
||||
if (!segs[i].clan) continue;
|
||||
// 마지막 세그먼트(현재 clanName) = 진행 중. to 는 오늘이지만 진행중으로 표시한다.
|
||||
const ongoing = i === segs.length - 1;
|
||||
runs.push({
|
||||
clan: segs[i].clan,
|
||||
from: segs[i].from,
|
||||
to: ongoing ? today : segs[i + 1].from,
|
||||
ongoing,
|
||||
estimated: i === 0,
|
||||
});
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
/** KST 기준 오늘 날짜 (YYYY-MM-DD) */
|
||||
private todayKst(): string {
|
||||
const kst = new Date(Date.now() + 9 * 60 * 60 * 1000);
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { Body, Controller, Get, Param, ParseIntPipe, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { UserService } from './user.service';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@ import { CreateUserDto } from './dto/create-user.dto';
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
// 임시 인메모리 저장소 — Repository 도입 시 교체
|
||||
private readonly users = new Map<number, { id: number; email: string; name: string }>();
|
||||
private readonly users = new Map<
|
||||
number,
|
||||
{ id: number; email: string; name: string }
|
||||
>();
|
||||
|
||||
findOne(id: number) {
|
||||
const user = this.users.get(id);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// 동시성 유틸 — 작업 목록을 바운디드 워커 풀로 처리한다.
|
||||
|
||||
/**
|
||||
* items 를 최대 concurrency 개씩 동시에 처리한다(바운디드 워커 풀).
|
||||
*
|
||||
* - 워커 수만큼만 동시에 in-flight 가 되며, 끝나는 즉시 다음 항목을 가져간다.
|
||||
* - worker 내부에서 발생한 예외는 호출 측이 직접 처리해야 한다(아래 throw 시 전체 중단).
|
||||
* 개별 실패를 격리하려면 worker 안에서 try/catch 로 감쌀 것.
|
||||
* - 외부 rate limiter(예: NexonService)와 함께 쓰면 동시성을 높여도 초당 한도는 안전하게 유지된다.
|
||||
*
|
||||
* @param items 처리할 항목 목록
|
||||
* @param concurrency 동시 워커 수 (1 이상, 항목 수로 상한)
|
||||
* @param worker 각 항목을 처리하는 비동기 함수
|
||||
*/
|
||||
export async function runWithConcurrency<T>(
|
||||
items: readonly T[],
|
||||
concurrency: number,
|
||||
worker: (item: T, index: number) => Promise<void>,
|
||||
): Promise<void> {
|
||||
if (items.length === 0) return;
|
||||
const limit = Math.max(1, Math.min(Math.floor(concurrency), items.length));
|
||||
|
||||
// 모든 워커가 공유하는 커서 — JS 단일 스레드라 cursor++ 는 원자적으로 동작한다.
|
||||
let cursor = 0;
|
||||
const runners = Array.from({ length: limit }, async () => {
|
||||
for (;;) {
|
||||
const index = cursor++;
|
||||
if (index >= items.length) return;
|
||||
await worker(items[index], index);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(runners);
|
||||
}
|
||||
Generated
+71
-65
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -20,6 +24,7 @@ import LoginModal from '@/shared/components/LoginModal.vue'
|
||||
<!-- 전역 오버레이 -->
|
||||
<FeedbackWidget />
|
||||
<LoginModal />
|
||||
<DevSyncButton v-if="isDev" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useApi } from './useApi'
|
||||
|
||||
// 서든어택 맵 (백엔드 MapResponseDto) — 5v5/3v3 구분은 시즌 설정에서 정한다
|
||||
export interface SaMap {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
// 맵 등록/수정 입력 (id 제외)
|
||||
export type MapInput = Omit<SaMap, 'id'>
|
||||
|
||||
export function useSaMap() {
|
||||
const api = useApi()
|
||||
|
||||
// 등록된 맵 목록 (모드→이름 순). 실패 시 빈 배열.
|
||||
async function getMaps(): Promise<SaMap[]> {
|
||||
try {
|
||||
return (await api.get('/sa-maps')) as unknown as SaMap[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// [관리자] 맵 등록. 실패 시 null (인터셉터가 에러 알림 처리).
|
||||
async function createMap(input: MapInput): Promise<SaMap | null> {
|
||||
try {
|
||||
return (await api.post('/sa-maps', input)) as unknown as SaMap
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// [관리자] 맵 수정. 실패 시 null.
|
||||
async function updateMap(
|
||||
id: number,
|
||||
input: Partial<MapInput>,
|
||||
): Promise<SaMap | null> {
|
||||
try {
|
||||
return (await api.patch(`/sa-maps/${id}`, input)) as unknown as SaMap
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// [관리자] 맵 삭제. 성공 시 true.
|
||||
async function deleteMap(id: number): Promise<boolean> {
|
||||
try {
|
||||
await api.delete(`/sa-maps/${id}`)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return { getMaps, createMap, updateMap, deleteMap }
|
||||
}
|
||||
@@ -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<Record<string, string>> {
|
||||
if (!matchIds.length) return {}
|
||||
try {
|
||||
return (await api.post('/sa-matches/details/maps', {
|
||||
matchIds,
|
||||
})) as unknown as Record<string, string>
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
// [개발 전용] 매치 동기화 크론 수동 트리거. 성공 시 true (서버는 백그라운드 실행).
|
||||
async function devSync(): Promise<boolean> {
|
||||
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<DevStats | null> {
|
||||
try {
|
||||
return (await api.get('/sa-matches/dev/stats')) as unknown as DevStats
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return { getMatches, getMatchDetail, ensureMatchMaps, devSync, devStats }
|
||||
}
|
||||
|
||||
@@ -9,15 +9,29 @@ export interface SaMetaItem {
|
||||
// 종류별 메타 { grade, season_grade, tier }
|
||||
export type SaMetaSections = Record<string, SaMetaItem[]>
|
||||
|
||||
// 메타는 거의 불변 — 모듈 스코프에 1회 캐시해 컴포넌트 간 공유한다(중복 요청 방지).
|
||||
let metaCache: SaMetaSections | null = null
|
||||
let metaPromise: Promise<SaMetaSections> | null = null
|
||||
|
||||
export function useSaMeta() {
|
||||
const api = useApi()
|
||||
|
||||
async function getMeta(): Promise<SaMetaSections> {
|
||||
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 조회 (없으면 빈 문자열)
|
||||
|
||||
@@ -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<SaSeason, 'id'>
|
||||
|
||||
export function useSaSeason() {
|
||||
const api = useApi()
|
||||
|
||||
// 등록된 시즌 목록 (시작일 내림차순). 실패 시 빈 배열.
|
||||
async function getSeasons(): Promise<SaSeason[]> {
|
||||
try {
|
||||
return (await api.get('/sa-seasons')) as unknown as SaSeason[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 활성 시즌 (오늘 포함). 없거나 실패 시 null.
|
||||
async function getActiveSeason(): Promise<SaSeason | null> {
|
||||
try {
|
||||
return (await api.get('/sa-seasons/active')) as unknown as SaSeason | null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// [관리자] 시즌 등록. 실패 시 null (인터셉터가 에러 알림 처리).
|
||||
async function createSeason(input: SeasonInput): Promise<SaSeason | null> {
|
||||
try {
|
||||
return (await api.post('/sa-seasons', input)) as unknown as SaSeason
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// [관리자] 시즌 수정. 실패 시 null.
|
||||
async function updateSeason(
|
||||
id: number,
|
||||
input: Partial<SeasonInput>,
|
||||
): Promise<SaSeason | null> {
|
||||
try {
|
||||
return (await api.patch(`/sa-seasons/${id}`, input)) as unknown as SaSeason
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// [관리자] 시즌 삭제. 성공 시 true.
|
||||
async function deleteSeason(id: number): Promise<boolean> {
|
||||
try {
|
||||
await api.delete(`/sa-seasons/${id}`)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return { getSeasons, getActiveSeason, createSeason, updateSeason, deleteSeason }
|
||||
}
|
||||
@@ -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<boolean>(false)
|
||||
@@ -54,15 +74,49 @@ export function useSaUser() {
|
||||
}
|
||||
}
|
||||
|
||||
// OUID → 종합 프로필
|
||||
async function getProfile(ouid: string): Promise<SaProfile | null> {
|
||||
// OUID → 종합 프로필. refresh=true 면 넥슨에서 강제 재동기화(전적 갱신).
|
||||
async function getProfile(
|
||||
ouid: string,
|
||||
refresh = false,
|
||||
): Promise<SaProfile | null> {
|
||||
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<ClanPeriod[]> {
|
||||
try {
|
||||
return (await api.get(
|
||||
`/sa-users/${ouid}/clan-periods`,
|
||||
)) as unknown as ClanPeriod[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// OUID → 시즌별 티어/점수 (시즌 종료 시점 스냅샷). 실패 시 빈 배열.
|
||||
async function getSeasonTiers(ouid: string): Promise<SeasonTier[]> {
|
||||
try {
|
||||
return (await api.get(
|
||||
`/sa-users/${ouid}/season-tiers`,
|
||||
)) as unknown as SeasonTier[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
searchOuid,
|
||||
getProfile,
|
||||
getClanPeriods,
|
||||
getSeasonTiers,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
// 전적검색(프로필) 페이지 — 검색 서브헤더 + 요약 + 전적/통계 탭. sa-profile.jsx ProfileScreen 포팅.
|
||||
// UI 구조는 기존(mock 디자인) 그대로 유지하고, 데이터만 넥슨 실데이터를 어댑터로 변환해 주입한다.
|
||||
import { ref, watch } from 'vue'
|
||||
import { defineAsyncComponent, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useSaUser, type SaProfile } from '@/composables/useSaUser'
|
||||
import {
|
||||
useSaUser,
|
||||
type SaProfile,
|
||||
type ClanPeriod,
|
||||
type SeasonTier,
|
||||
} from '@/composables/useSaUser'
|
||||
import { useSaMatch, MATCH_TYPES, type SaMatchItem } from '@/composables/useSaMatch'
|
||||
import { useSaMeta, type SaMetaSections } from '@/composables/useSaMeta'
|
||||
import { useSaSeason, type SaSeason } from '@/composables/useSaSeason'
|
||||
import { makeRank } from '@/shared/utils/adapt'
|
||||
import type { Player, MatchRecord, RankedRecord, ClanRecord } from '@/shared/mock/types'
|
||||
import { timeAgo, toKstDate } from '@/shared/utils/format'
|
||||
import type {
|
||||
Player,
|
||||
MatchRecord,
|
||||
RankedRecord,
|
||||
ClanRecord,
|
||||
ModeRecord,
|
||||
MapRecord,
|
||||
TrendPoint,
|
||||
} from '@/shared/mock/types'
|
||||
import SearchBar from '@/shared/components/SearchBar.vue'
|
||||
import ProfileSummary from '@/pages/profile/ProfileSummary.vue'
|
||||
import RankedSeasons from '@/pages/profile/RankedSeasons.vue'
|
||||
import ClanRecords from '@/pages/profile/ClanRecords.vue'
|
||||
import MatchHistory from '@/pages/profile/MatchHistory.vue'
|
||||
import RecentTrend from '@/pages/profile/RecentTrend.vue'
|
||||
import Companions from '@/pages/profile/Companions.vue'
|
||||
import TrendChart from '@/pages/profile/TrendChart.vue'
|
||||
// '자주 함께한 유저'는 정확성 보장 불가로 비활성화 — 매치 상세 참가자에 ouid가 없어
|
||||
// 동반자(특히 개명 시)를 신뢰성 있게 식별·병합할 수 없음. Open API에 참가자 ouid 제공 시 복구.
|
||||
// import Companions from '@/pages/profile/Companions.vue'
|
||||
// 차트 라이브러리(ApexCharts)가 무거우므로 통계 탭에서 렌더될 때만 비동기 로드
|
||||
const TrendChart = defineAsyncComponent(
|
||||
() => import('@/pages/profile/TrendChart.vue'),
|
||||
)
|
||||
import ModeRecords from '@/pages/profile/ModeRecords.vue'
|
||||
import MapRecords from '@/pages/profile/MapRecords.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const { searchOuid, getProfile } = useSaUser()
|
||||
const { searchOuid, getProfile, getClanPeriods, getSeasonTiers } = useSaUser()
|
||||
const { getMatches } = useSaMatch()
|
||||
const { getMeta, imageOf } = useSaMeta()
|
||||
const { getSeasons } = useSaSeason()
|
||||
|
||||
const loading = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const notFound = ref(false)
|
||||
const p = ref<Player | null>(null)
|
||||
const tab = ref('전적')
|
||||
@@ -38,16 +60,6 @@ const TYPE_LABEL: Record<string, string> = {
|
||||
토너먼트: '토너먼트',
|
||||
}
|
||||
|
||||
// ISO → 'MM.DD HH:mm' (KST)
|
||||
function fmtAgo(iso: string): string {
|
||||
const kst = new Date(new Date(iso).getTime() + 9 * 3600 * 1000)
|
||||
const mm = String(kst.getUTCMonth() + 1).padStart(2, '0')
|
||||
const dd = String(kst.getUTCDate()).padStart(2, '0')
|
||||
const hh = String(kst.getUTCHours()).padStart(2, '0')
|
||||
const mi = String(kst.getUTCMinutes()).padStart(2, '0')
|
||||
return `${mm}.${dd} ${hh}:${mi}`
|
||||
}
|
||||
|
||||
// 킬뎃 %(recentKdRate)를 kda 값으로 역산 (ProfileSummary 가 SA.kdPct 로 다시 %로 환산하므로)
|
||||
function kdaFromPct(pct: number): number {
|
||||
const r = Math.min(99, Math.max(0, pct))
|
||||
@@ -86,36 +98,155 @@ function aggRanked(
|
||||
}
|
||||
}
|
||||
|
||||
// 클랜전 전적 — 클랜이 있으면 클랜전(퀵매치 클랜전 + 클랜 랭크전) 매치를 집계.
|
||||
// 전적이 없으면 0으로 표기, 클랜이 없으면 빈 목록.
|
||||
function aggClanRecords(items: SaMatchItem[], clanName: string): ClanRecord[] {
|
||||
if (!clanName) return []
|
||||
const clanTypes = ['퀵매치 클랜전', '클랜 랭크전']
|
||||
const f = items.filter((m) => clanTypes.includes(m.matchType))
|
||||
const matches = f.length
|
||||
const w = f.filter((x) => x.matchResult === '1').length
|
||||
const l = matches - w
|
||||
let kdSum = 0
|
||||
f.forEach((x) => {
|
||||
const t = x.kill + x.death
|
||||
kdSum += t ? (x.kill / t) * 100 : 0
|
||||
// 시즌별 랭크전 전적 구성.
|
||||
// - 시즌이 등록돼 있으면: 각 시즌 [startDate, endDate]에 드는 매치만 버킷팅해 집계.
|
||||
// 티어/티어점수는 현재 시즌=라이브 프로필, 과거 시즌=시즌 종료 스냅샷(seasonTiers). 둘 다 없으면 '—'.
|
||||
// - 시즌이 없으면: 보유 매치 전체를 단일 '현재 시즌'으로 (기존 동작 폴백).
|
||||
function buildRankedSeasons(
|
||||
pr: SaProfile,
|
||||
matches: SaMatchItem[],
|
||||
meta: SaMetaSections,
|
||||
seasons: SaSeason[],
|
||||
seasonTiers: SeasonTier[],
|
||||
): RankedSeason[] {
|
||||
// 솔로/파티 티어·점수를 받아 한 구간의 랭크전 집계 생성
|
||||
const mk = (
|
||||
items: SaMatchItem[],
|
||||
soloTier: string,
|
||||
soloScore: number,
|
||||
partyTier: string,
|
||||
partyScore: number,
|
||||
) => ({
|
||||
solo: aggRanked(
|
||||
items,
|
||||
'랭크전 솔로',
|
||||
soloTier,
|
||||
soloScore,
|
||||
soloTier ? imageOf(meta, 'tier', soloTier) : '',
|
||||
),
|
||||
party: aggRanked(
|
||||
items,
|
||||
'랭크전 파티',
|
||||
partyTier,
|
||||
partyScore,
|
||||
partyTier ? imageOf(meta, 'tier', partyTier) : '',
|
||||
),
|
||||
// 클랜 랭크전 — 티어/티어점수 API 미제공이라 항상 빈값
|
||||
clan: aggRanked(items, '클랜 랭크전', '', 0, ''),
|
||||
})
|
||||
// 현재 가입된 클랜이므로 가입 1회로 카운트. 가입일은 알 수 없어 '????',
|
||||
// 종료는 현재(활동중) 년월(yyyy-mm)로 표기한다.
|
||||
const kst = new Date(Date.now() + 9 * 60 * 60 * 1000)
|
||||
const nowYm = `${kst.getUTCFullYear()}-${String(kst.getUTCMonth() + 1).padStart(2, '0')}`
|
||||
return [
|
||||
{
|
||||
clan: clanName,
|
||||
tag: clanName.slice(0, 4),
|
||||
|
||||
if (!seasons.length) {
|
||||
return [
|
||||
{
|
||||
season: '현재 시즌',
|
||||
current: true,
|
||||
hasTier: true,
|
||||
...mk(matches, pr.soloTier, pr.soloScore, pr.partyTier, pr.partyScore),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const today = toKstDate(new Date())
|
||||
const tierBySeason = new Map(seasonTiers.map((t) => [t.seasonId, t]))
|
||||
return seasons.map((s) => {
|
||||
// 매치 일시(UTC 저장)를 KST 일자로 변환해 시즌 경계와 같은 기준으로 비교
|
||||
const inSeason = matches.filter((m) => {
|
||||
const d = toKstDate(m.dateMatch)
|
||||
return d >= s.startDate && d <= s.endDate
|
||||
})
|
||||
const isCurrent = today >= s.startDate && today <= s.endDate
|
||||
const t = tierBySeason.get(s.id)
|
||||
// 현재 시즌은 라이브 프로필 티어, 과거 시즌은 시즌 종료 스냅샷 티어, 없으면 빈값('—')
|
||||
const soloTier = isCurrent ? pr.soloTier : (t?.soloTier ?? '')
|
||||
const soloScore = isCurrent ? pr.soloScore : (t?.soloScore ?? 0)
|
||||
const partyTier = isCurrent ? pr.partyTier : (t?.partyTier ?? '')
|
||||
const partyScore = isCurrent ? pr.partyScore : (t?.partyScore ?? 0)
|
||||
return {
|
||||
season: s.name,
|
||||
current: isCurrent,
|
||||
hasTier: isCurrent || !!t,
|
||||
...mk(inSeason, soloTier, soloScore, partyTier, partyScore),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 랭크전 통산 — 기간(시즌) 무관 전체 랭크전 매치 합산. 티어/점수는 통산에 없으므로 빈값.
|
||||
function buildRankedCareer(matches: SaMatchItem[]): RankedSeason {
|
||||
return {
|
||||
season: '통산',
|
||||
current: false,
|
||||
hasTier: false,
|
||||
solo: aggRanked(matches, '랭크전 솔로', '', 0, ''),
|
||||
party: aggRanked(matches, '랭크전 파티', '', 0, ''),
|
||||
clan: aggRanked(matches, '클랜 랭크전', '', 0, ''),
|
||||
}
|
||||
}
|
||||
|
||||
// 두 'YYYY-MM-DD' 사이의 개월 수(근사)
|
||||
function monthsBetween(from: string, to: string): number {
|
||||
const [fy, fm] = from.split('-').map(Number)
|
||||
const [ty, tm] = to.split('-').map(Number)
|
||||
if (!fy || !ty) return 0
|
||||
return Math.max(0, (ty - fy) * 12 + (tm - fm))
|
||||
}
|
||||
|
||||
// 클랜전 전적 — 스냅샷에서 파생한 클랜 활동기간(periods) 기준으로 집계.
|
||||
// 활동기간 [from, to] 안의 '퀵매치 클랜전'(클랜 랭크전 제외) 매치만 엄격하게 날짜 필터링한다.
|
||||
// 가입일은 추정(estimated)이라 조회일 이전 매치는 자연히 제외된다.
|
||||
function buildClanRecords(items: SaMatchItem[], periods: ClanPeriod[]): ClanRecord[] {
|
||||
if (!periods.length) return []
|
||||
|
||||
// 클랜별로 활동기간(run) 묶기
|
||||
const byClan = new Map<string, ClanPeriod[]>()
|
||||
for (const p of periods) {
|
||||
const arr = byClan.get(p.clan) ?? []
|
||||
arr.push(p)
|
||||
byClan.set(p.clan, arr)
|
||||
}
|
||||
|
||||
// 퀵매치 클랜전만 (클랜 랭크전 제외)
|
||||
const clanMatches = items.filter((m) => m.matchType === '퀵매치 클랜전')
|
||||
// ISO 일시가 활동기간 [from, to](KST 일자 기준, 양끝 포함)에 드는지
|
||||
const inRun = (iso: string, r: ClanPeriod) => {
|
||||
const d = toKstDate(iso)
|
||||
return d >= r.from && d <= r.to
|
||||
}
|
||||
|
||||
const records: ClanRecord[] = []
|
||||
for (const [clan, runs] of byClan) {
|
||||
const f = clanMatches.filter((m) => runs.some((r) => inRun(m.dateMatch, r)))
|
||||
const matches = f.length
|
||||
const w = f.filter((x) => x.matchResult === '1').length
|
||||
const l = matches - w
|
||||
let kdSum = 0
|
||||
f.forEach((x) => {
|
||||
const t = x.kill + x.death
|
||||
kdSum += t ? (x.kill / t) * 100 : 0
|
||||
})
|
||||
records.push({
|
||||
clan,
|
||||
tag: clan.slice(0, 4),
|
||||
matches,
|
||||
w,
|
||||
l,
|
||||
wr: matches ? Math.round((w / matches) * 100) : 0,
|
||||
kd: matches ? Math.round(kdSum / matches) : 0,
|
||||
periods: [{ from: '????', to: nowYm, months: 0 }],
|
||||
},
|
||||
]
|
||||
periods: runs
|
||||
.slice()
|
||||
.sort((a, b) => b.from.localeCompare(a.from))
|
||||
.map((r) => ({
|
||||
from: r.from,
|
||||
to: r.to,
|
||||
months: monthsBetween(r.from, r.to),
|
||||
ongoing: r.ongoing,
|
||||
estimated: r.estimated,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
// 최근 활동(가장 늦은 from) 순으로 정렬
|
||||
records.sort((a, b) => b.periods[0].from.localeCompare(a.periods[0].from))
|
||||
return records
|
||||
}
|
||||
|
||||
function adaptMatches(items: SaMatchItem[]): MatchRecord[] {
|
||||
@@ -127,23 +258,132 @@ function adaptMatches(items: SaMatchItem[]): MatchRecord[] {
|
||||
k: m.kill,
|
||||
d: m.death,
|
||||
a: m.assist,
|
||||
ago: fmtAgo(m.dateMatch),
|
||||
ago: timeAgo(m.dateMatch),
|
||||
winSide: 'blue',
|
||||
teams: { blue: [], red: [] },
|
||||
}))
|
||||
}
|
||||
|
||||
// 모드별 전적 — 매치 타입별 집계. (게임 모드는 폭파미션만 수집하므로 매치 타입 기준)
|
||||
// 랭크전 3종(솔로/파티/클랜 랭크전)은 0전이어도 항상 표시하고, 고정 순서로 정렬한다.
|
||||
const MODE_ORDER = ['솔로 랭크전', '파티 랭크전', '클랜 랭크전', '클랜전', '토너먼트']
|
||||
const MODE_ALWAYS = ['솔로 랭크전', '파티 랭크전', '클랜 랭크전']
|
||||
function buildModeRecords(matches: SaMatchItem[]): ModeRecord[] {
|
||||
const byType = new Map<string, { matches: number; w: number; kdSum: number }>()
|
||||
// 랭크전 3종은 0전이어도 표기되도록 미리 시드
|
||||
for (const name of MODE_ALWAYS) byType.set(name, { matches: 0, w: 0, kdSum: 0 })
|
||||
for (const m of matches) {
|
||||
const label = TYPE_LABEL[m.matchType] ?? m.matchType
|
||||
const cur = byType.get(label) ?? { matches: 0, w: 0, kdSum: 0 }
|
||||
cur.matches++
|
||||
if (m.matchResult === '1') cur.w++
|
||||
const t = m.kill + m.death
|
||||
cur.kdSum += t ? (m.kill / t) * 100 : 0
|
||||
byType.set(label, cur)
|
||||
}
|
||||
const orderIdx = (name: string) => {
|
||||
const i = MODE_ORDER.indexOf(name)
|
||||
return i === -1 ? MODE_ORDER.length : i
|
||||
}
|
||||
return [...byType.entries()]
|
||||
.map(([name, s]) => ({
|
||||
name,
|
||||
matches: s.matches,
|
||||
w: s.w,
|
||||
l: s.matches - s.w,
|
||||
wr: s.matches ? Math.round((s.w / s.matches) * 100) : 0,
|
||||
kd: s.matches ? Math.round(s.kdSum / s.matches) : 0,
|
||||
}))
|
||||
.sort((a, b) => orderIdx(a.name) - orderIdx(b.name))
|
||||
}
|
||||
|
||||
// 맵별 전적 — 상세 적재된 매치의 matchMap 으로 집계(맵 미보유=상세 미적재 매치는 제외).
|
||||
// 매치 많은 순. 커버리지는 상세 적재분에 한정(크론이 쌓일수록 완전해짐).
|
||||
function buildMapRecords(matches: SaMatchItem[]): MapRecord[] {
|
||||
const byMap = new Map<string, { matches: number; w: number; kdSum: number }>()
|
||||
for (const m of matches) {
|
||||
const map = m.matchMap
|
||||
if (!map) continue
|
||||
const cur = byMap.get(map) ?? { matches: 0, w: 0, kdSum: 0 }
|
||||
cur.matches++
|
||||
if (m.matchResult === '1') cur.w++
|
||||
const t = m.kill + m.death
|
||||
cur.kdSum += t ? (m.kill / t) * 100 : 0
|
||||
byMap.set(map, cur)
|
||||
}
|
||||
return [...byMap.entries()]
|
||||
.map(([name, s]) => ({
|
||||
name,
|
||||
matches: s.matches,
|
||||
w: s.w,
|
||||
l: s.matches - s.w,
|
||||
wr: s.matches ? Math.round((s.w / s.matches) * 100) : 0,
|
||||
kd: s.matches ? Math.round(s.kdSum / s.matches) : 0,
|
||||
}))
|
||||
.sort((a, b) => b.matches - a.matches)
|
||||
}
|
||||
|
||||
// 최근 동향 — 최근 매치를 20경기씩 묶어 버킷별 승률·킬뎃 추이(최대 200경기=10버킷).
|
||||
// 20경기 미만의 자투리 버킷은 제외(승률/킬뎃 신뢰도). 결과는 오래된→최신 순(차트 좌→우).
|
||||
function buildTrend(matches: SaMatchItem[]): TrendPoint[] {
|
||||
const BUCKET = 20
|
||||
const MAX = 200
|
||||
const recent = matches.slice(0, MAX) // 최신순(DESC)
|
||||
const fullCount = Math.floor(recent.length / BUCKET) * BUCKET
|
||||
const points: TrendPoint[] = []
|
||||
for (let i = 0; i < fullCount; i += BUCKET) {
|
||||
const chunk = recent.slice(i, i + BUCKET) // 최신쪽 20경기
|
||||
const w = chunk.filter((m) => m.matchResult === '1').length
|
||||
let kdSum = 0
|
||||
chunk.forEach((m) => {
|
||||
const t = m.kill + m.death
|
||||
kdSum += t ? (m.kill / t) * 100 : 0
|
||||
})
|
||||
points.push({
|
||||
wr: Math.round((w / BUCKET) * 100),
|
||||
kd: Math.round(kdSum / BUCKET),
|
||||
label: `${i + 1}-${i + BUCKET}`, // 최신 기준 경기 범위 (예: 1-20 = 최근 20경기)
|
||||
})
|
||||
}
|
||||
// points 는 최신→과거 순(i=0 이 최신) → 차트는 좌=과거이므로 뒤집는다
|
||||
return points.reverse()
|
||||
}
|
||||
|
||||
// 접속률 — 최근 90일 중 매치 기록이 있는 날(=접속·플레이한 날)의 비율(%).
|
||||
// 우리가 가진 가장 정확한 활동 신호가 '그 날 매치 존재' 여부다(스냅샷은 우리 크론 기록이라 활동 신호가 아님).
|
||||
function calcConnRate(matches: SaMatchItem[]): number {
|
||||
const WINDOW = 90
|
||||
const today = toKstDate(new Date())
|
||||
const from = toKstDate(new Date(Date.now() - (WINDOW - 1) * 24 * 60 * 60 * 1000))
|
||||
const days = new Set<string>()
|
||||
for (const m of matches) {
|
||||
const d = toKstDate(m.dateMatch)
|
||||
if (d >= from && d <= today) days.add(d)
|
||||
}
|
||||
return Math.round((days.size / WINDOW) * 100)
|
||||
}
|
||||
|
||||
// 넥슨 프로필/매치 → 기존 UI가 기대하는 mock Player 형태로 변환.
|
||||
// 넥슨이 제공하지 않는 항목(집계/추세/함께한 유저 등)은 빈 값으로 두고 2차에서 채운다.
|
||||
function adaptPlayer(pr: SaProfile, matches: SaMatchItem[], meta: SaMetaSections): Player {
|
||||
function adaptPlayer(
|
||||
pr: SaProfile,
|
||||
matches: SaMatchItem[],
|
||||
meta: SaMetaSections,
|
||||
clanPeriods: ClanPeriod[],
|
||||
seasons: SaSeason[],
|
||||
seasonTiers: SeasonTier[],
|
||||
): Player {
|
||||
const gradeImg = imageOf(meta, 'grade', pr.grade)
|
||||
const seasonImg = imageOf(meta, 'season_grade', pr.seasonGrade)
|
||||
const totalRank = makeRank(pr.grade, gradeImg)
|
||||
const seasonRank = makeRank(pr.seasonGrade, seasonImg)
|
||||
return {
|
||||
name: pr.userName,
|
||||
clan: pr.clanName || null,
|
||||
rank: makeRank(pr.grade, gradeImg),
|
||||
rankTotal: makeRank(pr.grade, gradeImg),
|
||||
rankSeason: makeRank(pr.seasonGrade, seasonImg),
|
||||
// 대표 계급 — 시즌 계급 우선(시즌 미배치 시 통합 계급으로 폴백)
|
||||
rank: pr.seasonGrade ? seasonRank : totalRank,
|
||||
rankTotal: totalRank,
|
||||
rankSeason: seasonRank,
|
||||
rankNoTotal: pr.gradeRanking,
|
||||
rankNoSeason: pr.seasonGradeRanking,
|
||||
level: 0,
|
||||
@@ -157,61 +397,83 @@ function adaptPlayer(pr: SaProfile, matches: SaMatchItem[], meta: SaMetaSections
|
||||
special: Math.round(pr.recentSpecialRate),
|
||||
manner: 0,
|
||||
mannerGrade: pr.mannerGrade,
|
||||
connRate: 0,
|
||||
connRate: calcConnRate(matches),
|
||||
views: 0,
|
||||
contrib: 0,
|
||||
matches: adaptMatches(matches),
|
||||
rankedSeasons: [
|
||||
{
|
||||
season: '현재 시즌',
|
||||
solo: aggRanked(matches, '랭크전 솔로', pr.soloTier, pr.soloScore, imageOf(meta, 'tier', pr.soloTier)),
|
||||
party: aggRanked(matches, '랭크전 파티', pr.partyTier, pr.partyScore, imageOf(meta, 'tier', pr.partyTier)),
|
||||
},
|
||||
],
|
||||
clanRecords: aggClanRecords(matches, pr.clanName),
|
||||
modeRecords: [],
|
||||
mapRecords: [],
|
||||
trend: { daily: [], weekly: [] },
|
||||
rankedSeasons: buildRankedSeasons(pr, matches, meta, seasons, seasonTiers),
|
||||
rankedCareer: buildRankedCareer(matches),
|
||||
clanRecords: buildClanRecords(matches, clanPeriods),
|
||||
modeRecords: buildModeRecords(matches),
|
||||
mapRecords: buildMapRecords(matches),
|
||||
trend: buildTrend(matches),
|
||||
together: { games: 0, w: 0, l: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
// opts.soft: 페이지를 비우지 않고 갱신(전적 갱신) / opts.force: 넥슨에서 프로필 강제 재동기화
|
||||
async function load(opts: { force?: boolean; soft?: boolean } = {}) {
|
||||
const name = (route.params.name as string)?.trim()
|
||||
if (!name) return
|
||||
loading.value = true
|
||||
notFound.value = false
|
||||
p.value = null
|
||||
if (opts.soft) {
|
||||
refreshing.value = true
|
||||
} else {
|
||||
loading.value = true
|
||||
notFound.value = false
|
||||
p.value = null
|
||||
}
|
||||
|
||||
const res = await searchOuid(name)
|
||||
if (!res) {
|
||||
notFound.value = true
|
||||
if (!opts.soft) notFound.value = true
|
||||
loading.value = false
|
||||
refreshing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const results = await Promise.all([
|
||||
getProfile(res.ouid),
|
||||
getProfile(res.ouid, opts.force),
|
||||
getMeta(),
|
||||
getClanPeriods(res.ouid),
|
||||
getSeasons(),
|
||||
getSeasonTiers(res.ouid),
|
||||
...MATCH_TYPES.map((t) => getMatches(res.ouid, t)),
|
||||
])
|
||||
const profile = results[0] as SaProfile | null
|
||||
const meta = results[1] as SaMetaSections
|
||||
const matchLists = results.slice(2) as SaMatchItem[][]
|
||||
const clanPeriods = results[2] as ClanPeriod[]
|
||||
const seasons = results[3] as SaSeason[]
|
||||
const seasonTiers = results[4] as SeasonTier[]
|
||||
const matchLists = results.slice(5) as SaMatchItem[][]
|
||||
if (!profile) {
|
||||
notFound.value = true
|
||||
if (!opts.soft) notFound.value = true
|
||||
loading.value = false
|
||||
refreshing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const allMatches = matchLists
|
||||
.flat()
|
||||
.sort((a, b) => b.dateMatch.localeCompare(a.dateMatch))
|
||||
p.value = adaptPlayer(profile, allMatches, meta)
|
||||
p.value = adaptPlayer(
|
||||
profile,
|
||||
allMatches,
|
||||
meta,
|
||||
clanPeriods,
|
||||
seasons,
|
||||
seasonTiers,
|
||||
)
|
||||
loading.value = false
|
||||
refreshing.value = false
|
||||
}
|
||||
|
||||
watch(() => route.params.name, load, { immediate: true })
|
||||
// 전적 갱신 — 페이지를 유지한 채(soft) 넥슨에서 강제 재동기화(force)
|
||||
function onRefresh() {
|
||||
if (refreshing.value) return
|
||||
void load({ force: true, soft: true })
|
||||
}
|
||||
|
||||
watch(() => route.params.name, () => load(), { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -240,7 +502,11 @@ watch(() => route.params.name, load, { immediate: true })
|
||||
</div>
|
||||
|
||||
<template v-else-if="p">
|
||||
<ProfileSummary :p="p" />
|
||||
<ProfileSummary
|
||||
:p="p"
|
||||
:refreshing="refreshing"
|
||||
@refresh="onRefresh"
|
||||
/>
|
||||
|
||||
<!-- 서브탭 -->
|
||||
<div class="pf__tabs">
|
||||
@@ -271,7 +537,9 @@ watch(() => route.params.name, load, { immediate: true })
|
||||
</div>
|
||||
<div class="pf__col pf__col--side">
|
||||
<RecentTrend :p="p" />
|
||||
<!-- '자주 함께한 유저' 비활성화 — 정확성 보장 불가(참가자 ouid 부재). 추후 대체 콘텐츠 예정
|
||||
<Companions :p="p" />
|
||||
-->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
// 관리자 대시보드 셸 — 좌측 사이드바(섹션 네비) + 우측 콘텐츠 슬롯. 풀 너비.
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
const NAV = [
|
||||
{ to: '/admin/maps', label: '맵 관리' },
|
||||
{ to: '/admin/seasons', label: '시즌 관리' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="adm">
|
||||
<aside class="adm__side">
|
||||
<div class="adm__brand">
|
||||
관리자
|
||||
</div>
|
||||
<nav class="adm__nav">
|
||||
<RouterLink
|
||||
v-for="n in NAV"
|
||||
:key="n.to"
|
||||
:to="n.to"
|
||||
class="adm__navitem"
|
||||
>
|
||||
{{ n.label }}
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</aside>
|
||||
<main class="adm__main">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.adm {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
width: 100%;
|
||||
padding: 1.5rem 1.75rem 2.75rem;
|
||||
}
|
||||
.adm__side {
|
||||
flex-shrink: 0;
|
||||
width: 12rem;
|
||||
}
|
||||
.adm__brand {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
color: var(--t3);
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0 0.75rem 0.625rem;
|
||||
}
|
||||
.adm__nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.adm__navitem {
|
||||
display: block;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
color: var(--t2);
|
||||
text-decoration: none;
|
||||
}
|
||||
.adm__navitem:hover {
|
||||
background: #f1f2f4;
|
||||
}
|
||||
.adm__navitem.router-link-active {
|
||||
background: var(--ink);
|
||||
color: #fff;
|
||||
}
|
||||
.adm__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.adm {
|
||||
flex-direction: column;
|
||||
padding: 1.25rem 1rem 2.5rem;
|
||||
}
|
||||
.adm__side {
|
||||
width: 100%;
|
||||
}
|
||||
.adm__nav {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,343 @@
|
||||
<script setup lang="ts">
|
||||
// 관리자 — 서든어택 맵 이름 등록/수정/삭제. 5v5/3v3 구분은 시즌 설정에서 토글로 정한다.
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useSaMap, type SaMap } from '@/composables/useSaMap'
|
||||
import AdminLayout from './AdminLayout.vue'
|
||||
import UiPanel from '@/shared/components/UiPanel.vue'
|
||||
|
||||
const { getMaps, createMap, updateMap, deleteMap } = useSaMap()
|
||||
|
||||
const maps = ref<SaMap[]>([])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const editingId = ref<number | null>(null)
|
||||
const name = ref('')
|
||||
|
||||
const isEditing = computed(() => editingId.value !== null)
|
||||
|
||||
async function loadMaps() {
|
||||
loading.value = true
|
||||
maps.value = await getMaps()
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
editingId.value = null
|
||||
name.value = ''
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
function startEdit(m: SaMap) {
|
||||
editingId.value = m.id
|
||||
name.value = m.name
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (saving.value) return
|
||||
if (!name.value.trim()) {
|
||||
error.value = '맵 이름을 입력해 주세요.'
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
const input = { name: name.value.trim() }
|
||||
const ok = isEditing.value
|
||||
? await updateMap(editingId.value as number, input)
|
||||
: await createMap(input)
|
||||
saving.value = false
|
||||
if (ok) {
|
||||
resetForm()
|
||||
await loadMaps()
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(m: SaMap) {
|
||||
if (!window.confirm(`'${m.name}' 맵을 삭제할까요?`)) return
|
||||
const ok = await deleteMap(m.id)
|
||||
if (ok) {
|
||||
if (editingId.value === m.id) resetForm()
|
||||
await loadMaps()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadMaps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminLayout>
|
||||
<header class="adm__head">
|
||||
<h1 class="adm__title">
|
||||
맵 관리
|
||||
</h1>
|
||||
<p class="adm__desc">
|
||||
서든어택 맵 이름을 등록합니다. 5v5 / 3v3 구분은 시즌 설정에서 이 목록을 토글로 선택합니다.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="adm__grid">
|
||||
<!-- 등록/수정 폼 -->
|
||||
<UiPanel
|
||||
:title="isEditing ? '맵 수정' : '맵 등록'"
|
||||
en="MAP"
|
||||
body-pad="18px 20px"
|
||||
>
|
||||
<form
|
||||
class="adm__form"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<div class="adm__field">
|
||||
<label
|
||||
class="adm__label"
|
||||
for="map-name"
|
||||
>맵 이름</label>
|
||||
<input
|
||||
id="map-name"
|
||||
v-model="name"
|
||||
class="adm__input"
|
||||
type="text"
|
||||
placeholder="예: 제3보급창고"
|
||||
maxlength="50"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
v-if="error"
|
||||
class="adm__error"
|
||||
role="alert"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<div class="adm__actions">
|
||||
<button
|
||||
v-if="isEditing"
|
||||
type="button"
|
||||
class="adm__btn"
|
||||
@click="resetForm"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="adm__btn adm__btn--primary"
|
||||
:disabled="saving"
|
||||
>
|
||||
{{ saving ? '저장 중…' : isEditing ? '수정 저장' : '등록' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</UiPanel>
|
||||
|
||||
<!-- 맵 목록 -->
|
||||
<UiPanel
|
||||
title="등록된 맵"
|
||||
en="LIST"
|
||||
body-pad="0"
|
||||
>
|
||||
<template #right>
|
||||
<span class="adm__count">{{ maps.length }}개</span>
|
||||
</template>
|
||||
<div
|
||||
v-if="loading"
|
||||
class="adm__empty"
|
||||
>
|
||||
불러오는 중…
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!maps.length"
|
||||
class="adm__empty"
|
||||
>
|
||||
등록된 맵이 없습니다.
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="rectable-scroll"
|
||||
>
|
||||
<table class="rectable map__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="map__th-no">
|
||||
#
|
||||
</th>
|
||||
<th>맵</th>
|
||||
<th class="ta-r">
|
||||
관리
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(m, i) in maps"
|
||||
:key="m.id"
|
||||
:class="{
|
||||
'map__row--alt': i % 2 === 1,
|
||||
'map__row--editing': editingId === m.id,
|
||||
}"
|
||||
>
|
||||
<td class="td-num map__no">
|
||||
{{ i + 1 }}
|
||||
</td>
|
||||
<td class="td-strong">
|
||||
{{ m.name }}
|
||||
</td>
|
||||
<td class="ta-r map__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="map__link"
|
||||
@click="startEdit(m)"
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="map__link map__link--danger"
|
||||
@click="remove(m)"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</UiPanel>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.adm__head {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.adm__title {
|
||||
margin: 0;
|
||||
font-size: 1.375rem;
|
||||
font-weight: 800;
|
||||
color: var(--ink);
|
||||
}
|
||||
.adm__desc {
|
||||
margin: 0.375rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--t3);
|
||||
}
|
||||
.adm__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 22rem 1fr;
|
||||
gap: 1.25rem;
|
||||
align-items: start;
|
||||
}
|
||||
.adm__count {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--t3);
|
||||
font-weight: 600;
|
||||
}
|
||||
.adm__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
.adm__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.adm__label {
|
||||
font-size: 0.71875rem;
|
||||
font-weight: 700;
|
||||
color: var(--t2);
|
||||
}
|
||||
.adm__input {
|
||||
padding: 0.5rem 0.625rem;
|
||||
border: 1px solid var(--line2);
|
||||
border-radius: var(--r-xs);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ink);
|
||||
background: var(--surf);
|
||||
}
|
||||
.adm__input:focus-visible {
|
||||
outline: 2px solid var(--point);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.adm__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.adm__btn {
|
||||
padding: 0.5rem 0.875rem;
|
||||
border: 1px solid var(--line2);
|
||||
border-radius: var(--r-xs);
|
||||
background: var(--surf);
|
||||
color: var(--t2);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.adm__btn--primary {
|
||||
border: none;
|
||||
background: var(--ink);
|
||||
color: #fff;
|
||||
}
|
||||
.adm__btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.adm__error {
|
||||
margin: 0;
|
||||
font-size: 0.78125rem;
|
||||
font-weight: 600;
|
||||
color: var(--negative);
|
||||
}
|
||||
.adm__empty {
|
||||
padding: 1.75rem 0;
|
||||
text-align: center;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--t3);
|
||||
}
|
||||
/* 맵 목록 표 — 전적검색 모드별 통계(.rectable)와 동일 디자인 */
|
||||
.map__table tbody tr {
|
||||
border-bottom: 1px solid var(--line3);
|
||||
}
|
||||
.map__table tbody tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.map__row--alt {
|
||||
background: #fbfbfc;
|
||||
}
|
||||
.map__row--editing {
|
||||
background: color-mix(in srgb, var(--point) 8%, var(--surf));
|
||||
}
|
||||
.map__th-no,
|
||||
.map__no {
|
||||
width: 2.5rem;
|
||||
color: var(--t3);
|
||||
}
|
||||
.map__actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.map__link {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.78125rem;
|
||||
font-weight: 700;
|
||||
color: var(--t2);
|
||||
padding: 0.25rem 0.375rem;
|
||||
}
|
||||
.map__link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.map__link--danger {
|
||||
color: var(--negative);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.adm__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,448 @@
|
||||
<script setup lang="ts">
|
||||
// 관리자 — 시즌 관리(등록/수정/삭제 + 5v5/3v3 맵 토글 선택). 맵은 '맵 관리'에 등록된 목록에서 토글.
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useSaSeason, type SaSeason } from '@/composables/useSaSeason'
|
||||
import { useSaMap, type SaMap } from '@/composables/useSaMap'
|
||||
import AdminLayout from './AdminLayout.vue'
|
||||
import MapToggleGroup from './MapToggleGroup.vue'
|
||||
import UiPanel from '@/shared/components/UiPanel.vue'
|
||||
|
||||
const { getSeasons, createSeason, updateSeason, deleteSeason } = useSaSeason()
|
||||
const { getMaps } = useSaMap()
|
||||
|
||||
const seasons = ref<SaSeason[]>([])
|
||||
const allMaps = ref<SaMap[]>([])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// 폼 상태 — editingId 가 null 이면 등록, 값이 있으면 수정 모드
|
||||
const editingId = ref<number | null>(null)
|
||||
const name = ref('')
|
||||
const startDate = ref('')
|
||||
const endDate = ref('')
|
||||
const maps5v5 = ref<string[]>([])
|
||||
const maps3v3 = ref<string[]>([])
|
||||
|
||||
const isEditing = computed(() => editingId.value !== null)
|
||||
|
||||
async function loadSeasons() {
|
||||
loading.value = true
|
||||
seasons.value = await getSeasons()
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
editingId.value = null
|
||||
name.value = ''
|
||||
startDate.value = ''
|
||||
endDate.value = ''
|
||||
maps5v5.value = []
|
||||
maps3v3.value = []
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
function startEdit(s: SaSeason) {
|
||||
editingId.value = s.id
|
||||
name.value = s.name
|
||||
startDate.value = s.startDate
|
||||
endDate.value = s.endDate
|
||||
maps5v5.value = [...s.maps5v5]
|
||||
maps3v3.value = [...s.maps3v3]
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
function validate(): boolean {
|
||||
if (!name.value.trim()) {
|
||||
error.value = '시즌명을 입력해 주세요.'
|
||||
return false
|
||||
}
|
||||
if (!startDate.value || !endDate.value) {
|
||||
error.value = '시작일과 종료일을 모두 선택해 주세요.'
|
||||
return false
|
||||
}
|
||||
if (endDate.value < startDate.value) {
|
||||
error.value = '종료일은 시작일보다 빠를 수 없습니다.'
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (saving.value || !validate()) return
|
||||
saving.value = true
|
||||
const input = {
|
||||
name: name.value.trim(),
|
||||
startDate: startDate.value,
|
||||
endDate: endDate.value,
|
||||
maps5v5: maps5v5.value,
|
||||
maps3v3: maps3v3.value,
|
||||
}
|
||||
const ok = isEditing.value
|
||||
? await updateSeason(editingId.value as number, input)
|
||||
: await createSeason(input)
|
||||
saving.value = false
|
||||
if (ok) {
|
||||
resetForm()
|
||||
await loadSeasons()
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(s: SaSeason) {
|
||||
if (!window.confirm(`'${s.name}' 시즌을 삭제할까요?`)) return
|
||||
const ok = await deleteSeason(s.id)
|
||||
if (ok) {
|
||||
if (editingId.value === s.id) resetForm()
|
||||
await loadSeasons()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
allMaps.value = await getMaps()
|
||||
await loadSeasons()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminLayout>
|
||||
<header class="adm__head">
|
||||
<h1 class="adm__title">
|
||||
시즌 관리
|
||||
</h1>
|
||||
<p class="adm__desc">
|
||||
랭크전 시즌 기간과 5v5 / 3v3 맵을 등록하면, 시즌별 전적 집계의 기준이 됩니다.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="adm__grid">
|
||||
<!-- 등록/수정 폼 -->
|
||||
<UiPanel
|
||||
:title="isEditing ? '시즌 수정' : '시즌 등록'"
|
||||
en="SEASON"
|
||||
body-pad="18px 20px"
|
||||
>
|
||||
<form
|
||||
class="adm__form"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<div class="adm__field">
|
||||
<label
|
||||
class="adm__label"
|
||||
for="season-name"
|
||||
>시즌명</label>
|
||||
<input
|
||||
id="season-name"
|
||||
v-model="name"
|
||||
class="adm__input"
|
||||
type="text"
|
||||
placeholder="예: 2026 시즌 1"
|
||||
maxlength="50"
|
||||
>
|
||||
</div>
|
||||
<div class="adm__dates">
|
||||
<div class="adm__field">
|
||||
<label
|
||||
class="adm__label"
|
||||
for="season-start"
|
||||
>시작일</label>
|
||||
<input
|
||||
id="season-start"
|
||||
v-model="startDate"
|
||||
class="adm__input"
|
||||
type="date"
|
||||
>
|
||||
</div>
|
||||
<div class="adm__field">
|
||||
<label
|
||||
class="adm__label"
|
||||
for="season-end"
|
||||
>종료일</label>
|
||||
<input
|
||||
id="season-end"
|
||||
v-model="endDate"
|
||||
class="adm__input"
|
||||
type="date"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MapToggleGroup
|
||||
v-model="maps5v5"
|
||||
label="5v5 맵"
|
||||
:available="allMaps"
|
||||
/>
|
||||
<MapToggleGroup
|
||||
v-model="maps3v3"
|
||||
label="3v3 맵"
|
||||
:available="allMaps"
|
||||
/>
|
||||
|
||||
<p
|
||||
v-if="error"
|
||||
class="adm__error"
|
||||
role="alert"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<div class="adm__actions">
|
||||
<button
|
||||
v-if="isEditing"
|
||||
type="button"
|
||||
class="adm__btn"
|
||||
@click="resetForm"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="adm__btn adm__btn--primary"
|
||||
:disabled="saving"
|
||||
>
|
||||
{{ saving ? '저장 중…' : isEditing ? '수정 저장' : '등록' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</UiPanel>
|
||||
|
||||
<!-- 시즌 목록 -->
|
||||
<UiPanel
|
||||
title="등록된 시즌"
|
||||
en="LIST"
|
||||
body-pad="14px 16px"
|
||||
>
|
||||
<template #right>
|
||||
<span class="adm__count">{{ seasons.length }}개</span>
|
||||
</template>
|
||||
<div
|
||||
v-if="loading"
|
||||
class="adm__empty"
|
||||
>
|
||||
불러오는 중…
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!seasons.length"
|
||||
class="adm__empty"
|
||||
>
|
||||
등록된 시즌이 없습니다.
|
||||
</div>
|
||||
<table
|
||||
v-else
|
||||
class="adm__table"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>시즌명</th>
|
||||
<th>기간</th>
|
||||
<th class="ta-c">
|
||||
맵 (5v5 / 3v3)
|
||||
</th>
|
||||
<th class="adm__th-actions">
|
||||
관리
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="s in seasons"
|
||||
:key="s.id"
|
||||
:class="{ 'adm__row--editing': editingId === s.id }"
|
||||
>
|
||||
<td class="adm__td-name">
|
||||
{{ s.name }}
|
||||
</td>
|
||||
<td class="num adm__td-period">
|
||||
{{ s.startDate }} ~ {{ s.endDate }}
|
||||
</td>
|
||||
<td class="ta-c num">
|
||||
{{ s.maps5v5.length }} / {{ s.maps3v3.length }}
|
||||
</td>
|
||||
<td class="adm__td-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="adm__link"
|
||||
@click="startEdit(s)"
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="adm__link adm__link--danger"
|
||||
@click="remove(s)"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</UiPanel>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.adm__head {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.adm__title {
|
||||
margin: 0;
|
||||
font-size: 1.375rem;
|
||||
font-weight: 800;
|
||||
color: var(--ink);
|
||||
}
|
||||
.adm__desc {
|
||||
margin: 0.375rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--t3);
|
||||
}
|
||||
.adm__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 26rem 1fr;
|
||||
gap: 1.25rem;
|
||||
align-items: start;
|
||||
}
|
||||
.adm__count {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--t3);
|
||||
font-weight: 600;
|
||||
}
|
||||
.adm__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
.adm__dates {
|
||||
display: flex;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
.adm__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.adm__label {
|
||||
font-size: 0.71875rem;
|
||||
font-weight: 700;
|
||||
color: var(--t2);
|
||||
}
|
||||
.adm__input {
|
||||
padding: 0.5rem 0.625rem;
|
||||
border: 1px solid var(--line2);
|
||||
border-radius: var(--r-xs);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ink);
|
||||
background: var(--surf);
|
||||
}
|
||||
.adm__input:focus-visible {
|
||||
outline: 2px solid var(--point);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.adm__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.adm__btn {
|
||||
padding: 0.5rem 0.875rem;
|
||||
border: 1px solid var(--line2);
|
||||
border-radius: var(--r-xs);
|
||||
background: var(--surf);
|
||||
color: var(--t2);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.adm__btn--primary {
|
||||
border: none;
|
||||
background: var(--ink);
|
||||
color: #fff;
|
||||
}
|
||||
.adm__btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.adm__error {
|
||||
margin: 0;
|
||||
font-size: 0.78125rem;
|
||||
font-weight: 600;
|
||||
color: var(--negative);
|
||||
}
|
||||
.adm__empty {
|
||||
padding: 1.75rem 0;
|
||||
text-align: center;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--t3);
|
||||
}
|
||||
.adm__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.adm__table th {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
color: var(--t3);
|
||||
text-align: left;
|
||||
padding: 0.5rem 0.5rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.ta-c {
|
||||
text-align: center;
|
||||
}
|
||||
.ta-r {
|
||||
text-align: right;
|
||||
}
|
||||
/* 헤더는 `.adm__table th`(text-align:left)가 특이도상 우선하므로 동일 이상으로 재지정 */
|
||||
.adm__table th.ta-c {
|
||||
text-align: center;
|
||||
}
|
||||
.adm__table th.adm__th-actions {
|
||||
text-align: right;
|
||||
}
|
||||
.adm__table td {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ink);
|
||||
padding: 0.625rem 0.5rem;
|
||||
border-bottom: 1px solid var(--line3);
|
||||
}
|
||||
.adm__row--editing {
|
||||
background: #f7f8f9;
|
||||
}
|
||||
.adm__td-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.adm__td-period {
|
||||
color: var(--t2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.adm__td-actions {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.adm__link {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.78125rem;
|
||||
font-weight: 700;
|
||||
color: var(--t2);
|
||||
padding: 0.25rem 0.375rem;
|
||||
}
|
||||
.adm__link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.adm__link--danger {
|
||||
color: var(--negative);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.adm__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
// 맵 토글 그룹 — 등록된 맵(available)을 칩으로 보여주고 선택 토글. v-model: 선택된 맵 이름 string[]
|
||||
interface Props {
|
||||
label: string
|
||||
available: { name: string }[]
|
||||
modelValue: string[]
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: string[]): void
|
||||
}>()
|
||||
|
||||
function toggle(name: string) {
|
||||
const sel = props.modelValue
|
||||
emit(
|
||||
'update:modelValue',
|
||||
sel.includes(name) ? sel.filter((n) => n !== name) : [...sel, name],
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mtg">
|
||||
<div class="mtg__label">
|
||||
{{ label }}
|
||||
<span class="mtg__count">{{ modelValue.length }} / {{ available.length }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="!available.length"
|
||||
class="mtg__empty"
|
||||
>
|
||||
등록된 맵이 없습니다. ‘맵 관리’에서 먼저 등록하세요.
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="mtg__chips"
|
||||
>
|
||||
<button
|
||||
v-for="m in available"
|
||||
:key="m.name"
|
||||
type="button"
|
||||
class="mtg__chip"
|
||||
:class="{ 'mtg__chip--on': modelValue.includes(m.name) }"
|
||||
:aria-pressed="modelValue.includes(m.name)"
|
||||
@click="toggle(m.name)"
|
||||
>
|
||||
{{ m.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mtg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.mtg__label {
|
||||
font-size: 0.71875rem;
|
||||
font-weight: 700;
|
||||
color: var(--t2);
|
||||
}
|
||||
.mtg__count {
|
||||
margin-left: 0.25rem;
|
||||
color: var(--point);
|
||||
}
|
||||
.mtg__empty {
|
||||
font-size: 0.71875rem;
|
||||
color: var(--t3);
|
||||
}
|
||||
.mtg__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
/* 미선택 = 기본 태그(홈 '시즌 현황' 맵 태그 .sc__map 룩) / 선택 = 브랜드 컬러 채움 */
|
||||
.mtg__chip {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: var(--r-xs);
|
||||
border: 1px solid var(--line2);
|
||||
background: var(--surf);
|
||||
color: var(--t2);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.71875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mtg__chip:hover {
|
||||
border-color: var(--point);
|
||||
}
|
||||
.mtg__chip--on {
|
||||
border-color: var(--point);
|
||||
background: var(--point);
|
||||
color: var(--point-ink);
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
@@ -10,7 +10,8 @@ import RankBadge from '@/shared/components/RankBadge.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const me = SA.me
|
||||
const scope = ref('통합')
|
||||
// 계급 표시는 시즌 계급 우선 — 기본 탭을 '시즌'으로 둔다
|
||||
const scope = ref('시즌')
|
||||
|
||||
const rankNo = computed(() => (scope.value === '통합' ? me.rankNoTotal : me.rankNoSeason))
|
||||
const selRank = computed(() => (scope.value === '통합' ? me.rankTotal : me.rankSeason))
|
||||
@@ -35,7 +36,7 @@ function open() {
|
||||
<template #right>
|
||||
<SegTabs
|
||||
v-model="scope"
|
||||
:tabs="['통합', '시즌']"
|
||||
:tabs="['시즌', '통합']"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -7,8 +7,9 @@ import { useRanking, type RankingItem } from '@/composables/useRanking'
|
||||
import UiPanel from '@/shared/components/UiPanel.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const CATS = ['통합 계급', '시즌 계급', '랭크전', '공식 클랜', '클랜']
|
||||
const cat = ref('통합 계급')
|
||||
// 계급 랭킹은 시즌 계급 우선 — 기본 카테고리를 '시즌 계급'으로 둔다
|
||||
const CATS = ['시즌 계급', '통합 계급', '랭크전', '공식 클랜', '클랜']
|
||||
const cat = ref('시즌 계급')
|
||||
const mode = ref('솔로')
|
||||
|
||||
const sections = ref<Record<string, RankingItem[]>>({})
|
||||
|
||||
@@ -1,34 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
// 시즌 현황 — 진행률 + 시즌 맵(5v5/3v3). sa-home.jsx SeasonCard 포팅.
|
||||
import { computed, ref } from 'vue'
|
||||
import { SA } from '@/shared/mock/sa'
|
||||
// 시즌 현황 — 진행 중인 시즌(관리자 등록)의 진행률 + 시즌 맵(5v5/3v3). 활성 시즌이 없으면 빈 상태.
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useSaSeason, type SaSeason } from '@/composables/useSaSeason'
|
||||
import { toKstDate } from '@/shared/utils/format'
|
||||
import UiPanel from '@/shared/components/UiPanel.vue'
|
||||
|
||||
const s = SA.season
|
||||
const mapTab = ref('5vs5')
|
||||
const maps = computed(() => (mapTab.value === '5vs5' ? s.maps5v5 : s.maps3v3))
|
||||
const { getActiveSeason } = useSaSeason()
|
||||
const season = ref<SaSeason | null>(null)
|
||||
const mapTab = ref<'5vs5' | '3vs3'>('5vs5')
|
||||
|
||||
const maps = computed<string[]>(() => {
|
||||
const s = season.value
|
||||
if (!s) return []
|
||||
return mapTab.value === '5vs5' ? s.maps5v5 : s.maps3v3
|
||||
})
|
||||
|
||||
// 진행률(%) — 시작~종료 중 오늘 위치
|
||||
const progress = computed(() => {
|
||||
const s = season.value
|
||||
if (!s) return 0
|
||||
const start = new Date(s.startDate).getTime()
|
||||
const end = new Date(s.endDate).getTime()
|
||||
const now = Date.now()
|
||||
if (now <= start) return 0
|
||||
if (now >= end) return 100
|
||||
return Math.round(((now - start) / (end - start)) * 100)
|
||||
})
|
||||
|
||||
// 종료까지 남은 일수 (KST 일자 차이)
|
||||
const dday = computed(() => {
|
||||
const s = season.value
|
||||
if (!s) return ''
|
||||
const today = toKstDate(new Date())
|
||||
const diff = Math.round(
|
||||
(new Date(s.endDate).getTime() - new Date(today).getTime()) / 86400000,
|
||||
)
|
||||
if (diff > 0) return `D-${diff}`
|
||||
if (diff === 0) return 'D-DAY'
|
||||
return '종료'
|
||||
})
|
||||
|
||||
// 시즌 배지 — 이름에서 '시즌 N' 또는 끝 숫자 추출 (없으면 SE)
|
||||
const badge = computed(() => {
|
||||
const name = season.value?.name ?? ''
|
||||
const m = name.match(/시즌\s*(\d+)/) ?? name.match(/(\d+)\s*$/)
|
||||
return m ? 'S' + m[1] : 'SE'
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
season.value = await getActiveSeason()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiPanel
|
||||
v-if="season"
|
||||
title="시즌 현황"
|
||||
en="SEASON"
|
||||
body-pad="14px 14px"
|
||||
>
|
||||
<template #right>
|
||||
<span class="sc__dday num">{{ s.dday }}</span>
|
||||
<span class="sc__dday num">{{ dday }}</span>
|
||||
</template>
|
||||
|
||||
<div class="sc__head">
|
||||
<div class="sc__badge num">
|
||||
S2
|
||||
{{ badge }}
|
||||
</div>
|
||||
<div class="sc__id">
|
||||
<div class="sc__name">
|
||||
{{ s.name }}
|
||||
{{ season.name }}
|
||||
</div>
|
||||
<div class="sc__period">
|
||||
{{ s.period }} 종료
|
||||
{{ season.endDate }} 종료
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -36,11 +80,11 @@ const maps = computed(() => (mapTab.value === '5vs5' ? s.maps5v5 : s.maps3v3))
|
||||
<div class="sc__bar">
|
||||
<div
|
||||
class="sc__fill"
|
||||
:style="{ width: s.progress + '%' }"
|
||||
:style="{ width: progress + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="sc__progress">
|
||||
<span>시즌 진행</span><span class="num sc__pct">{{ s.progress }}%</span>
|
||||
<span>시즌 진행</span><span class="num sc__pct">{{ progress }}%</span>
|
||||
</div>
|
||||
|
||||
<div class="sc__maps">
|
||||
@@ -54,19 +98,28 @@ const maps = computed(() => (mapTab.value === '5vs5' ? s.maps5v5 : s.maps3v3))
|
||||
:key="t"
|
||||
class="sc__toggle-btn num"
|
||||
:class="{ 'sc__toggle-btn--on': mapTab === t, 'sc__toggle-btn--sep': i > 0 }"
|
||||
@click="mapTab = t"
|
||||
@click="mapTab = t as '5vs5' | '3vs3'"
|
||||
>
|
||||
{{ t }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sc__maplist">
|
||||
<div
|
||||
v-if="maps.length"
|
||||
class="sc__maplist"
|
||||
>
|
||||
<span
|
||||
v-for="m in maps"
|
||||
:key="m"
|
||||
class="sc__map"
|
||||
>{{ m }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="sc__maps-empty"
|
||||
>
|
||||
등록된 {{ mapTab }} 맵이 없습니다.
|
||||
</div>
|
||||
</div>
|
||||
</UiPanel>
|
||||
</template>
|
||||
@@ -184,4 +237,9 @@ const maps = computed(() => (mapTab.value === '5vs5' ? s.maps5v5 : s.maps3v3))
|
||||
color: var(--t2);
|
||||
font-weight: 500;
|
||||
}
|
||||
.sc__maps-empty {
|
||||
font-size: 0.71875rem;
|
||||
color: var(--t3);
|
||||
padding: 4px 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -122,9 +122,17 @@ function recStr(d: ClanRecord) {
|
||||
:size="14"
|
||||
stroke="var(--t3)"
|
||||
/>
|
||||
<span class="cr__period-range num">{{ pd.from }} ~ {{ pd.to }}</span>
|
||||
<span class="cr__period-range num">{{ pd.from }} ~ <span
|
||||
v-if="pd.ongoing"
|
||||
class="cr__period-now"
|
||||
>진행중</span><template v-else>{{ pd.to }}</template></span>
|
||||
<span
|
||||
v-if="pd.months > 0"
|
||||
v-if="pd.estimated"
|
||||
class="cr__period-est"
|
||||
title="최초 조회 시점 기준 — 실제 가입일은 더 이전일 수 있습니다"
|
||||
>가입일 추정</span>
|
||||
<span
|
||||
v-if="pd.months > 0 && !pd.ongoing"
|
||||
class="cr__period-months"
|
||||
>약 {{ pd.months }}개월</span>
|
||||
</div>
|
||||
@@ -234,8 +242,21 @@ function recStr(d: ClanRecord) {
|
||||
color: var(--ink);
|
||||
font-weight: 700;
|
||||
}
|
||||
.cr__period-now {
|
||||
color: var(--point);
|
||||
font-weight: 700;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
.cr__period-months {
|
||||
font-size: 0.75rem;
|
||||
color: var(--t3);
|
||||
}
|
||||
.cr__period-est {
|
||||
font-size: 0.65625rem;
|
||||
font-weight: 700;
|
||||
color: var(--t3);
|
||||
border: 1px solid var(--line2);
|
||||
border-radius: 3px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,7 +13,21 @@ const props = defineProps<Props>()
|
||||
const CAP = 8
|
||||
const showAll = ref(false)
|
||||
const all = props.p.mapRecords
|
||||
const list = computed(() => (showAll.value ? all : all.slice(0, CAP)))
|
||||
|
||||
// 정렬 기준 — 매치수 / 승률 / 킬뎃 (모두 내림차순)
|
||||
type SortKey = 'matches' | 'wr' | 'kd'
|
||||
const SORTS: { key: SortKey; label: string }[] = [
|
||||
{ key: 'matches', label: '매치수' },
|
||||
{ key: 'wr', label: '승률' },
|
||||
{ key: 'kd', label: '킬뎃' },
|
||||
]
|
||||
const sortKey = ref<SortKey>('matches')
|
||||
const sorted = computed(() =>
|
||||
[...all].sort((a, b) => b[sortKey.value] - a[sortKey.value]),
|
||||
)
|
||||
const list = computed(() =>
|
||||
showAll.value ? sorted.value : sorted.value.slice(0, CAP),
|
||||
)
|
||||
|
||||
function recStr(d: MapRecord) {
|
||||
return `${d.matches}전 ${d.w}승 ${d.l}패`
|
||||
@@ -27,7 +41,23 @@ function recStr(d: MapRecord) {
|
||||
body-pad="0"
|
||||
>
|
||||
<template #right>
|
||||
<span class="mr__hint">매치 많은 순 · {{ all.length }}개 맵</span>
|
||||
<div
|
||||
class="mr__sorts"
|
||||
role="group"
|
||||
aria-label="맵별 전적 정렬"
|
||||
>
|
||||
<button
|
||||
v-for="s in SORTS"
|
||||
:key="s.key"
|
||||
type="button"
|
||||
class="mr__sort"
|
||||
:class="{ 'mr__sort--on': sortKey === s.key }"
|
||||
:aria-pressed="sortKey === s.key"
|
||||
@click="sortKey = s.key"
|
||||
>
|
||||
{{ s.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="rectable-scroll">
|
||||
@@ -95,10 +125,25 @@ function recStr(d: MapRecord) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mr__hint {
|
||||
.mr__sorts {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.mr__sort {
|
||||
padding: 4px 9px;
|
||||
border-radius: var(--r-pill);
|
||||
border: 1px solid var(--line2);
|
||||
background: var(--surf);
|
||||
color: var(--t2);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.6875rem;
|
||||
color: var(--t3);
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mr__sort--on {
|
||||
border-color: var(--point);
|
||||
background: var(--point);
|
||||
color: var(--point-ink);
|
||||
}
|
||||
.rectable tbody tr {
|
||||
border-bottom: 1px solid var(--line3);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
// 매치 기록 — 모드 필터 + 매치 행 목록. sa-profile-sections.jsx MatchHistory 포팅.
|
||||
// 보이는 매치는 맵 이름을 반드시 갖도록, 노출 전에 상세(맵)를 동기로 확보한다.
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { Player } from '@/shared/mock/types'
|
||||
import { useSaMatch } from '@/composables/useSaMatch'
|
||||
import type { Player, MatchRecord } from '@/shared/mock/types'
|
||||
import UiPanel from '@/shared/components/UiPanel.vue'
|
||||
import MatchRow from './MatchRow.vue'
|
||||
|
||||
@@ -10,6 +12,8 @@ interface Props {
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const { ensureMatchMaps } = useSaMatch()
|
||||
|
||||
const TABS = ['전체', '클랜전', '솔로 랭크전', '파티 랭크전', '클랜 랭크전', '토너먼트']
|
||||
const tab = ref('전체')
|
||||
// 다른 유저로 전환 시 탭 초기화
|
||||
@@ -18,18 +22,65 @@ watch(() => props.p.name, () => (tab.value = '전체'))
|
||||
// 더보기 페이지 크기 — 개발 4개 / 운영 20개
|
||||
const PAGE_SIZE = import.meta.env.DEV ? 4 : 20
|
||||
const limit = ref(PAGE_SIZE)
|
||||
// 유저/탭 전환 시 노출 개수 초기화
|
||||
watch([() => props.p.name, tab], () => (limit.value = PAGE_SIZE))
|
||||
|
||||
// 확보된 맵 캐시 (matchId → 맵). 빈 문자열도 '조회 완료'로 간주해 재요청을 막는다.
|
||||
const resolvedMaps = ref<Record<string, string>>({})
|
||||
// 첫 페이지(또는 탭 전환) 상세 적재 대기 상태 — 완료 전까지 섹션 로딩 표시
|
||||
const ready = ref(false)
|
||||
// 더보기 적재 진행 상태
|
||||
const moreLoading = ref(false)
|
||||
|
||||
const ms = computed(() =>
|
||||
tab.value === '전체' ? props.p.matches : props.p.matches.filter((m) => m.mode === tab.value),
|
||||
)
|
||||
const wins = computed(() => ms.value.filter((m) => m.win).length)
|
||||
const visible = computed(() => ms.value.slice(0, limit.value))
|
||||
|
||||
// 표시용 매치 — 확보된 맵을 주입(원본 map 이 비어 있으면 resolvedMaps 로 채움)
|
||||
const visible = computed<MatchRecord[]>(() =>
|
||||
ms.value.slice(0, limit.value).map((m) => ({
|
||||
...m,
|
||||
map: m.map || resolvedMaps.value[m.matchId] || '',
|
||||
})),
|
||||
)
|
||||
const hasMore = computed(() => ms.value.length > limit.value)
|
||||
|
||||
function showMore() {
|
||||
// 주어진 매치 목록 중 맵 미확보분의 상세를 적재해 resolvedMaps 에 반영
|
||||
async function ensureMaps(list: MatchRecord[]) {
|
||||
const ids = list
|
||||
.filter((m) => m.matchId && !m.map && !(m.matchId in resolvedMaps.value))
|
||||
.map((m) => m.matchId)
|
||||
if (!ids.length) return
|
||||
const maps = await ensureMatchMaps(ids)
|
||||
// 조회 결과 반영 — 끝내 맵이 없으면 ''로 마킹해 무한 재요청 방지
|
||||
const next = { ...resolvedMaps.value }
|
||||
for (const id of ids) next[id] = maps[id] ?? ''
|
||||
resolvedMaps.value = next
|
||||
}
|
||||
|
||||
// 첫 페이지 적재 — 완료 전까지 ready=false (섹션 로딩 표시)
|
||||
async function loadFirst() {
|
||||
ready.value = false
|
||||
await ensureMaps(ms.value.slice(0, limit.value))
|
||||
ready.value = true
|
||||
}
|
||||
|
||||
// 유저/탭 전환 시 노출 개수 초기화 + 첫 페이지 재적재
|
||||
watch(
|
||||
[() => props.p.name, tab],
|
||||
() => {
|
||||
limit.value = PAGE_SIZE
|
||||
void loadFirst()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 더보기 — 다음 페이지 맵을 먼저 적재한 뒤 노출(— 깜빡임 방지)
|
||||
async function showMore() {
|
||||
if (moreLoading.value) return
|
||||
moreLoading.value = true
|
||||
await ensureMaps(ms.value.slice(limit.value, limit.value + PAGE_SIZE))
|
||||
limit.value += PAGE_SIZE
|
||||
moreLoading.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -57,25 +108,40 @@ function showMore() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<MatchRow
|
||||
v-for="(m, i) in visible"
|
||||
:key="m.matchId || i"
|
||||
:m="m"
|
||||
:last="!hasMore && i === visible.length - 1"
|
||||
/>
|
||||
<!-- 첫 페이지 상세(맵) 적재 완료 전까지 섹션 로딩 -->
|
||||
<div
|
||||
v-if="ms.length === 0"
|
||||
class="mh__empty"
|
||||
v-if="!ready"
|
||||
class="mh__loading"
|
||||
>
|
||||
해당 모드의 최근 매치가 없어요.
|
||||
매치 정보를 불러오는 중...
|
||||
</div>
|
||||
<button
|
||||
v-if="hasMore"
|
||||
class="mh__more"
|
||||
@click="showMore"
|
||||
>
|
||||
더보기 <span class="mh__more-count num">{{ visible.length }} / {{ ms.length }}</span>
|
||||
</button>
|
||||
<template v-else>
|
||||
<MatchRow
|
||||
v-for="(m, i) in visible"
|
||||
:key="m.matchId || i"
|
||||
:m="m"
|
||||
:last="!hasMore && i === visible.length - 1"
|
||||
/>
|
||||
<div
|
||||
v-if="ms.length === 0"
|
||||
class="mh__empty"
|
||||
>
|
||||
해당 모드의 최근 매치가 없어요.
|
||||
</div>
|
||||
<button
|
||||
v-if="hasMore"
|
||||
class="mh__more"
|
||||
:disabled="moreLoading"
|
||||
@click="showMore"
|
||||
>
|
||||
<template v-if="moreLoading">
|
||||
불러오는 중...
|
||||
</template>
|
||||
<template v-else>
|
||||
더보기 <span class="mh__more-count num">{{ visible.length }} / {{ ms.length }}</span>
|
||||
</template>
|
||||
</button>
|
||||
</template>
|
||||
</UiPanel>
|
||||
</template>
|
||||
|
||||
@@ -116,6 +182,12 @@ function showMore() {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--t3);
|
||||
}
|
||||
.mh__loading {
|
||||
padding: 26px 0;
|
||||
text-align: center;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--t3);
|
||||
}
|
||||
.mh__more {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -132,6 +204,10 @@ function showMore() {
|
||||
.mh__more:hover {
|
||||
background: #f7f8f9;
|
||||
}
|
||||
.mh__more:disabled {
|
||||
cursor: default;
|
||||
color: var(--t3);
|
||||
}
|
||||
.mh__more-count {
|
||||
margin-left: 4px;
|
||||
font-size: 0.6875rem;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// 상세(맵/참가자)는 클릭(더보기) 시 넥슨 match-detail API로 lazy 로드한다.
|
||||
import { computed, ref } from 'vue'
|
||||
import { useSaMatch, type SaParticipant } from '@/composables/useSaMatch'
|
||||
import { useSaMeta, type SaMetaSections } from '@/composables/useSaMeta'
|
||||
import { makeRank } from '@/shared/utils/adapt'
|
||||
import type { MatchRecord, RosterPlayer } from '@/shared/mock/types'
|
||||
import KdaChip from '@/shared/components/KdaChip.vue'
|
||||
@@ -16,6 +17,7 @@ interface Props {
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const { getMatchDetail } = useSaMatch()
|
||||
const { getMeta, imageOf } = useSaMeta()
|
||||
const open = ref(false)
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
@@ -29,9 +31,10 @@ const pct = computed(() => {
|
||||
})
|
||||
|
||||
// 매치 상세 참가자 → 기존 UI 로스터(RosterPlayer)
|
||||
function toRoster(p: SaParticipant): RosterPlayer {
|
||||
// 계급은 시즌 계급 기준이며, 메타에서 시즌 계급 이미지를 해석해 뱃지에 표시한다.
|
||||
function toRoster(p: SaParticipant, meta: SaMetaSections): RosterPlayer {
|
||||
return {
|
||||
rank: makeRank(p.seasonGrade),
|
||||
rank: makeRank(p.seasonGrade, imageOf(meta, 'season_grade', p.seasonGrade)),
|
||||
name: p.userName,
|
||||
isOwner: false,
|
||||
k: p.kill,
|
||||
@@ -47,11 +50,16 @@ async function toggle() {
|
||||
// 처음 펼칠 때만 상세 1회 로드 (matchId 없으면 스킵)
|
||||
if (!open.value || loaded.value || !props.m.matchId) return
|
||||
loading.value = true
|
||||
const detail = await getMatchDetail(props.m.matchId)
|
||||
// 상세와 메타(계급 이미지 해석용)를 함께 로드 — 메타는 캐시되어 사실상 1회만 호출
|
||||
const [detail, meta] = await Promise.all([getMatchDetail(props.m.matchId), getMeta()])
|
||||
if (detail) {
|
||||
mapName.value = detail.matchMap || props.m.map
|
||||
winTeam.value = detail.participants.filter((x) => x.matchResult === '1').map(toRoster)
|
||||
loseTeam.value = detail.participants.filter((x) => x.matchResult !== '1').map(toRoster)
|
||||
winTeam.value = detail.participants
|
||||
.filter((x) => x.matchResult === '1')
|
||||
.map((p) => toRoster(p, meta))
|
||||
loseTeam.value = detail.participants
|
||||
.filter((x) => x.matchResult !== '1')
|
||||
.map((p) => toRoster(p, meta))
|
||||
}
|
||||
loaded.value = true
|
||||
loading.value = false
|
||||
|
||||
@@ -10,14 +10,27 @@ import AppIcon from '@/shared/components/AppIcon.vue'
|
||||
|
||||
interface Props {
|
||||
p: Player
|
||||
// 전적 갱신 진행 중 여부
|
||||
refreshing?: boolean
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const scope = ref('통합')
|
||||
const emit = defineEmits<{
|
||||
(e: 'refresh'): void
|
||||
}>()
|
||||
|
||||
// 계급 표시는 시즌 계급 우선 — 기본 탭을 '시즌'으로 둔다
|
||||
const scope = ref('시즌')
|
||||
const selRank = computed(() => (scope.value === '통합' ? props.p.rankTotal : props.p.rankSeason))
|
||||
const selRankNo = computed(() => (scope.value === '통합' ? props.p.rankNoTotal : props.p.rankNoSeason))
|
||||
|
||||
const stats = computed(() => [
|
||||
interface Stat {
|
||||
lab: string
|
||||
val: string
|
||||
col: string
|
||||
tip?: string
|
||||
}
|
||||
const stats = computed<Stat[]>(() => [
|
||||
{ lab: '승률', val: props.p.wr + '%', col: 'var(--ink)' },
|
||||
{ lab: '킬뎃', val: SA.kdPct(props.p.kda) + '%', col: 'var(--point)' },
|
||||
{ lab: '라이플', val: props.p.rifle + '%', col: 'var(--ink)' },
|
||||
@@ -25,7 +38,12 @@ const stats = computed(() => [
|
||||
{ lab: '특수', val: props.p.special + '%', col: 'var(--ink)' },
|
||||
{ lab: '랭킹', val: SA.fmt(selRankNo.value) + '위', col: 'var(--point)' },
|
||||
{ lab: '매너', val: props.p.mannerGrade ?? '-', col: 'var(--ink)' },
|
||||
{ lab: '접속률', val: props.p.connRate + '%', col: 'var(--ink)' },
|
||||
{
|
||||
lab: '접속률',
|
||||
val: props.p.connRate + '%',
|
||||
col: 'var(--ink)',
|
||||
tip: '최근 90일 중 매치 기록이 있는 날의 비율입니다. (집계된 매치 기준)',
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
@@ -39,7 +57,7 @@ const stats = computed(() => [
|
||||
<div class="ps__rank">
|
||||
<SegTabs
|
||||
v-model="scope"
|
||||
:tabs="['통합', '시즌']"
|
||||
:tabs="['시즌', '통합']"
|
||||
/>
|
||||
<div class="ps__badge">
|
||||
<RankBadge
|
||||
@@ -76,12 +94,17 @@ const stats = computed(() => [
|
||||
stroke="#fff"
|
||||
/> 즐겨찾기
|
||||
</button>
|
||||
<button class="ps__btn ps__btn--ghost">
|
||||
<button
|
||||
class="ps__btn ps__btn--ghost"
|
||||
:disabled="props.refreshing"
|
||||
@click="emit('refresh')"
|
||||
>
|
||||
<AppIcon
|
||||
name="refresh"
|
||||
:size="14"
|
||||
stroke="#3a4049"
|
||||
/> 전적 갱신
|
||||
:class="{ 'ps__btn-spin': props.refreshing }"
|
||||
/> {{ props.refreshing ? '갱신 중…' : '전적 갱신' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,7 +133,19 @@ const stats = computed(() => [
|
||||
:class="{ 'ps__stat--alt': i % 2 === 0, 'ps__stat--sep': i > 0 }"
|
||||
>
|
||||
<div class="ps__stat-lab">
|
||||
{{ s.lab }}
|
||||
{{ s.lab }}<span
|
||||
v-if="s.tip"
|
||||
class="ps__tip"
|
||||
tabindex="0"
|
||||
role="note"
|
||||
:aria-label="`${s.lab}: ${s.tip}`"
|
||||
>
|
||||
<span
|
||||
class="ps__tip-icon"
|
||||
aria-hidden="true"
|
||||
>?</span>
|
||||
<span class="ps__tip-box">{{ s.tip }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="ps__stat-val num"
|
||||
@@ -209,6 +244,18 @@ const stats = computed(() => [
|
||||
color: #3a4049;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ps__btn:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: default;
|
||||
}
|
||||
.ps__btn-spin {
|
||||
animation: ps-spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes ps-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.ps__rings {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
@@ -236,6 +283,51 @@ const stats = computed(() => [
|
||||
font-weight: 600;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.ps__tip {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 3px;
|
||||
cursor: help;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.ps__tip-icon {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
border-radius: 50%;
|
||||
background: var(--line2);
|
||||
color: var(--surf);
|
||||
font-size: 0.5625rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
.ps__tip-box {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 0.375rem);
|
||||
right: 0;
|
||||
width: 11.25rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
background: var(--ink);
|
||||
color: #fff;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.45;
|
||||
text-align: left;
|
||||
border-radius: var(--r-xs);
|
||||
box-shadow: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.25);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.12s;
|
||||
z-index: 20;
|
||||
}
|
||||
.ps__tip:hover .ps__tip-box,
|
||||
.ps__tip:focus .ps__tip-box,
|
||||
.ps__tip:focus-visible .ps__tip-box {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
.ps__stat-val {
|
||||
font-weight: 700;
|
||||
font-size: 0.90625rem;
|
||||
|
||||
@@ -13,12 +13,15 @@ interface Props {
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const seasons = props.p.rankedSeasons
|
||||
const sel = ref(seasons[0]?.season ?? '통산')
|
||||
// 기본 선택 — 현재 활성 시즌 우선, 없으면 가장 최근 시즌
|
||||
const sel = ref((seasons.find((s) => s.current) ?? seasons[0])?.season ?? '통산')
|
||||
const isAll = computed(() => sel.value === '통산')
|
||||
const cur = computed(() => seasons.find((s) => s.season === sel.value) ?? seasons[0])
|
||||
// 티어/티어점수 표기 — 현재 시즌(라이브) 또는 종료 스냅샷 보유 과거 시즌만. 통산은 '—'
|
||||
const showTier = computed(() => !isAll.value && !!cur.value?.hasTier)
|
||||
|
||||
// 통산 집계(매치 가중 평균)
|
||||
function agg(key: 'solo' | 'party') {
|
||||
function agg(key: 'solo' | 'party' | 'clan') {
|
||||
let M = 0, W = 0, L = 0, kdW = 0, hsW = 0
|
||||
seasons.forEach((s) => {
|
||||
const d = s[key]
|
||||
@@ -37,11 +40,24 @@ function agg(key: 'solo' | 'party') {
|
||||
} as RankedRecord
|
||||
}
|
||||
|
||||
const rows = computed<[string, RankedRecord][]>(() => {
|
||||
// 행 메타 — 클랜 랭크전은 티어/티어점수 데이터가 없어 항상 '—'로 표기(hasTier=false)
|
||||
interface RankRow {
|
||||
label: string
|
||||
rec: RankedRecord
|
||||
hasTier: boolean
|
||||
}
|
||||
// 통산 집계값 선택 — 실데이터는 rankedCareer(기간 무관 전체 합산), 없으면(mock) 시즌 합산
|
||||
function careerRec(key: 'solo' | 'party' | 'clan'): RankedRecord {
|
||||
return props.p.rankedCareer ? props.p.rankedCareer[key] : agg(key)
|
||||
}
|
||||
const rows = computed<RankRow[]>(() => {
|
||||
const c = cur.value
|
||||
const pick = (key: 'solo' | 'party' | 'clan') =>
|
||||
isAll.value ? careerRec(key) : (c?.[key] as RankedRecord)
|
||||
return [
|
||||
['솔로', isAll.value ? agg('solo') : (c?.solo as RankedRecord)],
|
||||
['파티', isAll.value ? agg('party') : (c?.party as RankedRecord)],
|
||||
{ label: '솔로', rec: pick('solo'), hasTier: true },
|
||||
{ label: '파티', rec: pick('party'), hasTier: true },
|
||||
{ label: '클랜', rec: pick('clan'), hasTier: false },
|
||||
]
|
||||
})
|
||||
|
||||
@@ -104,43 +120,43 @@ function recStr(d: RankedRecord) {
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="([label, d], i) in rows"
|
||||
:key="label"
|
||||
v-for="(row, i) in rows"
|
||||
:key="row.label"
|
||||
:class="{ 'rs__row--alt': i % 2 === 1 }"
|
||||
>
|
||||
<td class="td-strong">
|
||||
{{ label }} 랭크전
|
||||
{{ row.label }} 랭크전
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
v-if="isAll"
|
||||
v-if="!showTier || !row.hasTier"
|
||||
class="rs__dash"
|
||||
>—</span>
|
||||
<TierChip
|
||||
v-else
|
||||
:name="d.tier"
|
||||
:div="d.div"
|
||||
:image="d.tierImage"
|
||||
:name="row.rec.tier"
|
||||
:div="row.rec.div"
|
||||
:image="row.rec.tierImage"
|
||||
/>
|
||||
</td>
|
||||
<td class="ta-r td-num">
|
||||
<span
|
||||
v-if="isAll"
|
||||
v-if="!showTier || !row.hasTier"
|
||||
class="rs__dash"
|
||||
>—</span>
|
||||
<span v-else>{{ SA.fmt(d.points) }}<span class="rs__unit"> RP</span></span>
|
||||
<span v-else>{{ SA.fmt(row.rec.points) }}<span class="rs__unit"> RP</span></span>
|
||||
</td>
|
||||
<td class="ta-r td-num">
|
||||
{{ d.matches }}
|
||||
{{ row.rec.matches }}
|
||||
</td>
|
||||
<td class="ta-r td-rec">
|
||||
{{ recStr(d) }}
|
||||
{{ recStr(row.rec) }}
|
||||
</td>
|
||||
<td class="ta-r">
|
||||
<WrCell :wr="d.wr" />
|
||||
<WrCell :wr="row.rec.wr" />
|
||||
</td>
|
||||
<td class="ta-r td-kd">
|
||||
{{ d.kd }}%
|
||||
{{ row.rec.kd }}%
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -10,7 +10,9 @@ interface Props {
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const recent = computed(() => props.p.matches.slice(0, 12))
|
||||
// 최근 N전 기준 (승/패 흐름은 1줄 10개씩 표시)
|
||||
const RECENT_LIMIT = 20
|
||||
const recent = computed(() => props.p.matches.slice(0, RECENT_LIMIT))
|
||||
const wins = computed(() => recent.value.filter((m) => m.win).length)
|
||||
const losses = computed(() => recent.value.length - wins.value)
|
||||
const wr = computed(() => Math.round((wins.value / recent.value.length) * 100))
|
||||
@@ -79,11 +81,11 @@ const flow = computed(() => recent.value.slice().reverse())
|
||||
color: var(--negative);
|
||||
}
|
||||
.rt__flow {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(10, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
.rt__cell {
|
||||
flex: 1;
|
||||
height: 20px;
|
||||
border-radius: 3px;
|
||||
display: grid;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
// 매치 상세 팀 테이블 — 승/패 팀 로스터. sa-profile-sections.jsx TeamTable 포팅.
|
||||
import { useRouter } from 'vue-router'
|
||||
import { SA } from '@/shared/mock/sa'
|
||||
import type { RosterPlayer } from '@/shared/mock/types'
|
||||
import RankBadge from '@/shared/components/RankBadge.vue'
|
||||
@@ -11,9 +12,17 @@ interface Props {
|
||||
}
|
||||
defineProps<Props>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function kdPct(pl: RosterPlayer) {
|
||||
return Math.round((pl.k / (pl.k + pl.d)) * 100)
|
||||
}
|
||||
|
||||
// 참가자 닉네임 클릭 시 해당 유저 전적으로 이동 (이름이 없으면 무시)
|
||||
function openPlayer(name: string) {
|
||||
if (!name) return
|
||||
router.push({ name: 'profile', params: { name } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -66,7 +75,12 @@ function kdPct(pl: RosterPlayer) {
|
||||
/>
|
||||
<span
|
||||
class="tt__pname"
|
||||
:class="{ 'tt__pname--link': pl.name }"
|
||||
:style="{ fontWeight: pl.isOwner ? 800 : 600, color: pl.isOwner ? color : 'var(--ink)' }"
|
||||
:role="pl.name ? 'link' : undefined"
|
||||
:tabindex="pl.name ? 0 : undefined"
|
||||
@click="openPlayer(pl.name)"
|
||||
@keydown.enter="openPlayer(pl.name)"
|
||||
>
|
||||
<span
|
||||
v-if="pl.isOwner"
|
||||
@@ -87,7 +101,7 @@ function kdPct(pl: RosterPlayer) {
|
||||
{{ kdPct(pl) }}%
|
||||
</td>
|
||||
<td class="tt__td ta-r num tt__hs">
|
||||
{{ pl.hs }}%
|
||||
{{ pl.hs }}
|
||||
</td>
|
||||
<td class="tt__td ta-r num tt__dmg">
|
||||
{{ SA.fmt(pl.dmg) }}
|
||||
@@ -169,6 +183,17 @@ tbody tr:last-child .tt__td {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.tt__pname--link {
|
||||
cursor: pointer;
|
||||
}
|
||||
.tt__pname--link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.tt__pname--link:focus-visible {
|
||||
outline: 2px solid var(--point);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.tt__me {
|
||||
font-size: 0.59375rem;
|
||||
font-weight: 800;
|
||||
|
||||
@@ -1,53 +1,113 @@
|
||||
<script setup lang="ts">
|
||||
// 최근 동향 추세 그래프 — 승률/킬뎃 시계열 (일별/주별). sa-profile-sections.jsx TrendChart 포팅.
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Player, TrendPoint } from '@/shared/mock/types'
|
||||
// 최근 동향 추세 그래프 — 20경기 단위 버킷의 승률/킬뎃 추이(최대 200경기). ApexCharts 사용.
|
||||
// 툴팁에 각 구간의 승률·킬뎃 값과 직전 구간 대비 증감(▲/▼)을 함께 표기한다.
|
||||
import { computed } from 'vue'
|
||||
import type { ApexOptions } from 'apexcharts'
|
||||
import VueApexCharts from 'vue3-apexcharts'
|
||||
import type { Player } from '@/shared/mock/types'
|
||||
import UiPanel from '@/shared/components/UiPanel.vue'
|
||||
import SegTabs from '@/shared/components/SegTabs.vue'
|
||||
|
||||
interface Props {
|
||||
p: Player
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const period = ref('주별')
|
||||
const data = computed<TrendPoint[]>(() =>
|
||||
period.value === '일별' ? props.p.trend.daily : props.p.trend.weekly,
|
||||
)
|
||||
const WR_COLOR = '#2f6fed'
|
||||
const KD_COLOR = '#48515f'
|
||||
const UP = '#16a34a'
|
||||
const DOWN = '#e5484d'
|
||||
const MUTED = '#949aa3'
|
||||
|
||||
const W = 720, H = 250, padL = 38, padR = 16, padT = 18, padB = 34
|
||||
const iw = W - padL - padR
|
||||
const ih = H - padT - padB
|
||||
const data = computed(() => props.p.trend)
|
||||
const n = computed(() => data.value.length)
|
||||
|
||||
function x(i: number) {
|
||||
return padL + (n.value === 1 ? iw / 2 : (i / (n.value - 1)) * iw)
|
||||
}
|
||||
function y(v: number) {
|
||||
return padT + (1 - (v - 20) / (90 - 20)) * ih
|
||||
}
|
||||
function path(key: 'wr' | 'kd') {
|
||||
return data.value.map((d, i) => `${i ? 'L' : 'M'}${x(i).toFixed(1)} ${y(d[key]).toFixed(1)}`).join(' ')
|
||||
}
|
||||
const areaWr = computed(() => {
|
||||
const last = n.value - 1
|
||||
return `${path('wr')} L${x(last).toFixed(1)} ${(padT + ih).toFixed(1)} L${x(0).toFixed(1)} ${(padT + ih).toFixed(1)} Z`
|
||||
})
|
||||
const series = computed(() => [
|
||||
{ name: '승률', data: data.value.map((d) => d.wr) },
|
||||
{ name: '킬뎃', data: data.value.map((d) => d.kd) },
|
||||
])
|
||||
|
||||
const grid = [20, 40, 60, 80]
|
||||
const last = computed(() => data.value[n.value - 1] as TrendPoint)
|
||||
const first = computed(() => data.value[0] as TrendPoint)
|
||||
const labelStep = computed(() => Math.ceil(n.value / 8))
|
||||
|
||||
function delta(key: 'wr' | 'kd') {
|
||||
return last.value[key] - first.value[key]
|
||||
// 직전 구간 대비 증감 배지 HTML (첫 구간은 비교 대상 없음)
|
||||
function deltaBadge(val: number, prev: number | undefined): string {
|
||||
if (prev === undefined) return ''
|
||||
const d = val - prev
|
||||
const arrow = d > 0 ? '▲' : d < 0 ? '▼' : '–'
|
||||
const color = d > 0 ? UP : d < 0 ? DOWN : MUTED
|
||||
return `<span style="color:${color};font-weight:700;margin-left:6px">${arrow}${Math.abs(d)}</span>`
|
||||
}
|
||||
const xLabels = computed(() =>
|
||||
data.value
|
||||
.map((d, i) => ({ d, i }))
|
||||
.filter(({ i }) => i % labelStep.value === 0 || i === n.value - 1),
|
||||
)
|
||||
const points = computed(() => data.value.map((d, i) => ({ d, i, isLast: i === n.value - 1 })))
|
||||
|
||||
function tipRow(
|
||||
label: string,
|
||||
color: string,
|
||||
val: number,
|
||||
prev: number | undefined,
|
||||
): string {
|
||||
return `<div style="display:flex;align-items:center;gap:6px;margin-top:4px">
|
||||
<span style="width:8px;height:8px;border-radius:2px;background:${color};flex-shrink:0"></span>
|
||||
<span style="color:#6b7280">${label}</span>
|
||||
<b style="color:#1f2937;margin-left:auto">${val}%</b>${deltaBadge(val, prev)}
|
||||
</div>`
|
||||
}
|
||||
|
||||
const options = computed<ApexOptions>(() => ({
|
||||
chart: {
|
||||
type: 'line',
|
||||
height: 280,
|
||||
fontFamily: 'inherit',
|
||||
toolbar: { show: false },
|
||||
zoom: { enabled: false },
|
||||
animations: { enabled: true, speed: 350 },
|
||||
},
|
||||
colors: [WR_COLOR, KD_COLOR],
|
||||
stroke: { curve: 'smooth', width: 3 },
|
||||
markers: {
|
||||
size: 4,
|
||||
strokeWidth: 2,
|
||||
strokeColors: '#fff',
|
||||
hover: { size: 7 },
|
||||
},
|
||||
dataLabels: { enabled: false },
|
||||
grid: {
|
||||
borderColor: '#eceef1',
|
||||
padding: { left: 6, right: 10, top: 0 },
|
||||
},
|
||||
legend: {
|
||||
position: 'top',
|
||||
horizontalAlign: 'left',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
markers: { size: 6 },
|
||||
itemMargin: { horizontal: 10 },
|
||||
},
|
||||
xaxis: {
|
||||
categories: data.value.map((d) => d.label),
|
||||
axisBorder: { show: false },
|
||||
axisTicks: { show: false },
|
||||
tooltip: { enabled: false },
|
||||
labels: { style: { colors: MUTED, fontSize: '11px' } },
|
||||
},
|
||||
yaxis: {
|
||||
// 0~100 고정 대신 데이터 범위에 맞춰 자동 스케일 → 구간별 등락이 잘 보이게 (정확한 값은 툴팁)
|
||||
forceNiceScale: true,
|
||||
labels: {
|
||||
style: { colors: MUTED, fontSize: '11px' },
|
||||
formatter: (v: number) => `${Math.round(v)}`,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
shared: true,
|
||||
intersect: false,
|
||||
custom: ({ dataPointIndex }: { dataPointIndex: number }) => {
|
||||
const cur = data.value[dataPointIndex]
|
||||
if (!cur) return ''
|
||||
const prev = dataPointIndex > 0 ? data.value[dataPointIndex - 1] : undefined
|
||||
return `<div style="padding:8px 11px;font-size:12px;font-family:inherit;min-width:9.5rem">
|
||||
<div style="font-weight:700;color:#1f2937">${cur.label}경기</div>
|
||||
${tipRow('승률', WR_COLOR, cur.wr, prev?.wr)}
|
||||
${tipRow('킬뎃', KD_COLOR, cur.kd, prev?.kd)}
|
||||
</div>`
|
||||
},
|
||||
},
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -57,193 +117,35 @@ const points = computed(() => data.value.map((d, i) => ({ d, i, isLast: i === n.
|
||||
body-pad="14px 16px 16px"
|
||||
>
|
||||
<template #right>
|
||||
<SegTabs
|
||||
v-model="period"
|
||||
:tabs="['일별', '주별']"
|
||||
/>
|
||||
<span class="tc__cap">20경기 단위 · 오른쪽이 최신</span>
|
||||
</template>
|
||||
|
||||
<div class="tc__legend">
|
||||
<div class="tc__chip">
|
||||
<span class="tc__line tc__line--wr" />
|
||||
<span class="tc__lab">승률</span>
|
||||
<span class="num tc__val tc__val--wr">{{ last.wr }}%</span>
|
||||
<span
|
||||
class="num tc__delta"
|
||||
:class="delta('wr') >= 0 ? 'tc__delta--up' : 'tc__delta--down'"
|
||||
>
|
||||
{{ delta('wr') >= 0 ? '▲' : '▼' }}{{ Math.abs(delta('wr')) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="tc__chip">
|
||||
<span class="tc__line tc__line--kd" />
|
||||
<span class="tc__lab">킬뎃</span>
|
||||
<span class="num tc__val tc__val--kd">{{ last.kd }}%</span>
|
||||
<span
|
||||
class="num tc__delta"
|
||||
:class="delta('kd') >= 0 ? 'tc__delta--up' : 'tc__delta--down'"
|
||||
>
|
||||
{{ delta('kd') >= 0 ? '▲' : '▼' }}{{ Math.abs(delta('kd')) }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="tc__period">{{ period === '일별' ? '최근 14일' : '최근 10주' }} 추이</span>
|
||||
</div>
|
||||
|
||||
<svg
|
||||
:viewBox="`0 0 ${W} ${H}`"
|
||||
class="tc__svg"
|
||||
<VueApexCharts
|
||||
v-if="n"
|
||||
type="line"
|
||||
height="280"
|
||||
:options="options"
|
||||
:series="series"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="tc__empty"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="wrGrad"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stop-color="var(--accent-blue)"
|
||||
stop-opacity="0.16"
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stop-color="var(--accent-blue)"
|
||||
stop-opacity="0"
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g
|
||||
v-for="g in grid"
|
||||
:key="g"
|
||||
>
|
||||
<line
|
||||
:x1="padL"
|
||||
:y1="y(g)"
|
||||
:x2="W - padR"
|
||||
:y2="y(g)"
|
||||
stroke="#eceef1"
|
||||
stroke-width="1"
|
||||
/>
|
||||
<text
|
||||
:x="padL - 8"
|
||||
:y="y(g) + 3.5"
|
||||
text-anchor="end"
|
||||
font-size="10.5"
|
||||
fill="#949aa3"
|
||||
font-family="'Chakra Petch',sans-serif"
|
||||
>{{ g }}</text>
|
||||
</g>
|
||||
<text
|
||||
v-for="{ d, i } in xLabels"
|
||||
:key="'lab' + i"
|
||||
:x="x(i)"
|
||||
:y="H - 12"
|
||||
text-anchor="middle"
|
||||
font-size="10.5"
|
||||
fill="#949aa3"
|
||||
>{{ d.label }}</text>
|
||||
<path
|
||||
:d="areaWr"
|
||||
fill="url(#wrGrad)"
|
||||
/>
|
||||
<path
|
||||
:d="path('wr')"
|
||||
fill="none"
|
||||
stroke="var(--accent-blue)"
|
||||
stroke-width="2.5"
|
||||
stroke-linejoin="round"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
:d="path('kd')"
|
||||
fill="none"
|
||||
stroke="var(--point)"
|
||||
stroke-width="2.5"
|
||||
stroke-linejoin="round"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<g
|
||||
v-for="{ d, i, isLast } in points"
|
||||
:key="'p' + i"
|
||||
>
|
||||
<circle
|
||||
:cx="x(i)"
|
||||
:cy="y(d.wr)"
|
||||
:r="isLast ? 4 : 2.6"
|
||||
fill="#fff"
|
||||
stroke="var(--accent-blue)"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<circle
|
||||
:cx="x(i)"
|
||||
:cy="y(d.kd)"
|
||||
:r="isLast ? 4 : 2.6"
|
||||
fill="#fff"
|
||||
stroke="var(--point)"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
최근 동향은 20경기 이상부터 표시됩니다.
|
||||
</div>
|
||||
</UiPanel>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tc__legend {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 10px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
.tc__chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
.tc__line {
|
||||
width: 11px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.tc__line--wr {
|
||||
background: var(--accent-blue);
|
||||
}
|
||||
.tc__line--kd {
|
||||
background: var(--point);
|
||||
}
|
||||
.tc__lab {
|
||||
font-size: 0.75rem;
|
||||
color: var(--t2);
|
||||
font-weight: 600;
|
||||
}
|
||||
.tc__val {
|
||||
font-weight: 700;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.tc__val--wr {
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
.tc__val--kd {
|
||||
color: var(--point);
|
||||
}
|
||||
.tc__delta {
|
||||
font-weight: 700;
|
||||
font-size: 0.71875rem;
|
||||
}
|
||||
.tc__delta--up {
|
||||
color: var(--up);
|
||||
}
|
||||
.tc__delta--down {
|
||||
color: var(--negative);
|
||||
}
|
||||
.tc__period {
|
||||
margin-left: auto;
|
||||
.tc__cap {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--t3);
|
||||
font-weight: 600;
|
||||
}
|
||||
.tc__svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
.tc__empty {
|
||||
padding: 2.5rem 0;
|
||||
text-align: center;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--t3);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -32,6 +32,20 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/pages/ClanHomePage.vue'),
|
||||
meta: { requiresAuth: false },
|
||||
},
|
||||
{
|
||||
// 관리자 — 시즌 관리 (TODO: 인증 도입 시 관리자 권한 가드 적용)
|
||||
path: '/admin/seasons',
|
||||
name: 'admin-seasons',
|
||||
component: () => import('@/pages/admin/AdminSeasonsPage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
// 관리자 — 맵 관리
|
||||
path: '/admin/maps',
|
||||
name: 'admin-maps',
|
||||
component: () => import('@/pages/admin/AdminMapsPage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'not-found',
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
<script setup lang="ts">
|
||||
// 개발 전용 — 매치 동기화 크론을 수동 트리거하고 적재 현황을 보여주는 플로팅 위젯.
|
||||
// App.vue 에서 import.meta.env.DEV 일 때만 마운트된다.
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useSaMatch, type DevStats } from '@/composables/useSaMatch'
|
||||
|
||||
const { devSync, devStats } = useSaMatch()
|
||||
const loading = ref(false)
|
||||
const status = ref('')
|
||||
const stats = ref<DevStats | null>(null)
|
||||
// 동기화 직전 상세 적재 수 — 이후 증가분(델타) 표기용
|
||||
const beforeDetails = ref(0)
|
||||
const showDelta = ref(false)
|
||||
const timers: ReturnType<typeof setTimeout>[] = []
|
||||
|
||||
function clearTimers() {
|
||||
timers.forEach((t) => clearTimeout(t))
|
||||
timers.length = 0
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
const s = await devStats()
|
||||
if (s) stats.value = s
|
||||
}
|
||||
|
||||
const delta = computed(() =>
|
||||
showDelta.value && stats.value ? stats.value.details - beforeDetails.value : 0,
|
||||
)
|
||||
|
||||
async function run() {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
status.value = ''
|
||||
showDelta.value = false
|
||||
// 동기화 직전 현황을 새로 조회해 기준값을 고정한다 (델타가 절대값이 되는 것을 방지).
|
||||
await loadStats()
|
||||
beforeDetails.value = stats.value?.details ?? 0
|
||||
|
||||
const ok = await devSync()
|
||||
loading.value = false
|
||||
status.value = ok ? '동기화 시작됨' : '실패'
|
||||
|
||||
clearTimers()
|
||||
if (ok) {
|
||||
// 백그라운드 진행을 반영하기 위해 잠시 후 현황을 몇 차례 갱신한다.
|
||||
showDelta.value = true
|
||||
;[3000, 8000, 15000].forEach((d) => timers.push(setTimeout(loadStats, d)))
|
||||
}
|
||||
// dev/sync 는 fire-and-forget — 완료 신호가 없으므로 상태 텍스트는 트리거 확인용으로만
|
||||
// 잠시 띄우고 자동 제거한다. 실제 적재 결과는 현황의 +N(델타)로 확인.
|
||||
timers.push(setTimeout(() => (status.value = ''), 4000))
|
||||
}
|
||||
|
||||
onMounted(loadStats)
|
||||
onUnmounted(clearTimers)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="devsync">
|
||||
<div class="devsync__stat">
|
||||
<span>유저 <b>{{ stats?.users ?? '—' }}</b></span>
|
||||
<span class="devsync__sep">·</span>
|
||||
<span>상세 <b>{{ stats?.details ?? '—' }}</b></span>
|
||||
<span
|
||||
v-if="delta > 0"
|
||||
class="devsync__delta"
|
||||
>+{{ delta }}</span>
|
||||
<span class="devsync__sep">·</span>
|
||||
<span>목록 <b>{{ stats?.matches ?? '—' }}</b></span>
|
||||
<span
|
||||
v-if="stats && stats.failures > 0"
|
||||
class="devsync__fail"
|
||||
>실패 {{ stats.failures }}</span>
|
||||
</div>
|
||||
<div class="devsync__row">
|
||||
<button
|
||||
type="button"
|
||||
class="devsync__btn"
|
||||
:disabled="loading"
|
||||
aria-label="개발 전용: 매치 동기화 크론 수동 실행"
|
||||
title="개발 전용: 매치 동기화 크론 수동 실행"
|
||||
@click="run"
|
||||
>
|
||||
{{ loading ? '동기화 중…' : 'DEV 동기화' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="devsync__refresh"
|
||||
aria-label="현황 새로고침"
|
||||
title="현황 새로고침"
|
||||
@click="loadStats"
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
<span
|
||||
v-if="status"
|
||||
class="devsync__status"
|
||||
:class="{ 'devsync__status--err': status === '실패' }"
|
||||
role="status"
|
||||
>
|
||||
{{ status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.devsync {
|
||||
position: fixed;
|
||||
left: 1rem;
|
||||
bottom: 1rem;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
background: var(--surf);
|
||||
border: 1px solid var(--line2);
|
||||
border-radius: var(--r-sm);
|
||||
box-shadow: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.devsync__stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.71875rem;
|
||||
color: var(--t2);
|
||||
}
|
||||
.devsync__stat b {
|
||||
color: var(--ink);
|
||||
font-weight: 700;
|
||||
}
|
||||
.devsync__delta {
|
||||
color: var(--point);
|
||||
font-weight: 700;
|
||||
}
|
||||
.devsync__sep {
|
||||
color: var(--t3);
|
||||
}
|
||||
.devsync__fail {
|
||||
color: var(--negative);
|
||||
font-weight: 600;
|
||||
}
|
||||
.devsync__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.devsync__btn {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: none;
|
||||
border-radius: var(--r-xs);
|
||||
background: var(--dark);
|
||||
color: #fff;
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.devsync__btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.devsync__refresh {
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: 1px solid var(--line2);
|
||||
border-radius: var(--r-xs);
|
||||
background: var(--surf);
|
||||
color: var(--t2);
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.devsync__status {
|
||||
font-size: 0.71875rem;
|
||||
font-weight: 700;
|
||||
color: var(--point);
|
||||
}
|
||||
.devsync__status--err {
|
||||
color: var(--negative);
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
// 상단 유틸 바 — 어두운 바. 팔레트 피커 + 로그인 + 문의.
|
||||
// 상단 유틸 바 — 어두운 바. 팔레트 피커 + 관리자 + 로그인 + 문의.
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useUiStore } from '@/stores/ui.store'
|
||||
import PalettePicker from '@/shared/components/PalettePicker.vue'
|
||||
|
||||
@@ -11,6 +12,13 @@ const ui = useUiStore()
|
||||
<div class="topbar__inner">
|
||||
<PalettePicker on-dark />
|
||||
<span class="topbar__sep" />
|
||||
<RouterLink
|
||||
to="/admin/seasons"
|
||||
class="topbar__link"
|
||||
>
|
||||
관리자
|
||||
</RouterLink>
|
||||
<span class="topbar__sep" />
|
||||
<button
|
||||
class="topbar__link"
|
||||
@click="ui.openLogin()"
|
||||
@@ -57,6 +65,7 @@ const ui = useUiStore()
|
||||
border: none;
|
||||
font-family: var(--font-body);
|
||||
padding: 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
.topbar__link:hover {
|
||||
color: #fff;
|
||||
|
||||
@@ -171,7 +171,7 @@ function makeRankedSeasons(r: () => number): RankedSeason[] {
|
||||
points: ri(r, 800, 2400),
|
||||
}
|
||||
}
|
||||
return seasons.map((season) => ({ season, solo: mk(), party: mk() }))
|
||||
return seasons.map((season) => ({ season, solo: mk(), party: mk(), clan: mk() }))
|
||||
}
|
||||
|
||||
function makeRecords(r: () => number, names: string[]) {
|
||||
@@ -243,10 +243,7 @@ function makePlayer(seed: PlayerSeed): Player {
|
||||
}),
|
||||
modeRecords: makeRecords(r, MODE_NAMES),
|
||||
mapRecords: makeRecords(r, [...MAPS_5V5, ...MAPS_3V3]),
|
||||
trend: {
|
||||
daily: makeTrend(r, 14, false),
|
||||
weekly: makeTrend(r, 12, true),
|
||||
},
|
||||
trend: makeTrend(r, 10, false),
|
||||
together: (() => {
|
||||
const games = ri(r, 8, 120)
|
||||
const w = ri(r, Math.floor(games * 0.4), Math.floor(games * 0.6))
|
||||
|
||||
@@ -95,6 +95,10 @@ export interface ClanRecord {
|
||||
from: string
|
||||
to: string
|
||||
months: number
|
||||
/** 진행 중 여부 (현재 소속 클랜 — 종료일 대신 '진행중' 표시) */
|
||||
ongoing?: boolean
|
||||
/** 가입일 추정 여부 (최초 관측 기간 — 실제 가입은 더 이전일 수 있음) */
|
||||
estimated?: boolean
|
||||
}[]
|
||||
}
|
||||
|
||||
@@ -145,13 +149,19 @@ export interface RankedRecord {
|
||||
export interface RankedSeason {
|
||||
/** 시즌 식별자 (예: '2024 S1') */
|
||||
season: string
|
||||
/** 현재 진행 중인 시즌 여부 — 기본 선택용 */
|
||||
current?: boolean
|
||||
/** 티어/티어점수 표기 가능 여부 — 현재 시즌(라이브) 또는 종료 스냅샷 보유 과거 시즌만 true */
|
||||
hasTier?: boolean
|
||||
/** 솔로 랭크전 전적 */
|
||||
solo: RankedRecord
|
||||
/** 파티 랭크전 전적 */
|
||||
party: RankedRecord
|
||||
/** 클랜 랭크전 전적 (티어/티어점수 API 미제공 — 매치 기록으로 집계) */
|
||||
clan: RankedRecord
|
||||
}
|
||||
|
||||
/** 추세 그래프 1포인트 — Player.trend.daily / weekly 원소 */
|
||||
/** 추세 그래프 1포인트 — Player.trend 원소 (20경기 버킷) */
|
||||
export interface TrendPoint {
|
||||
/** 승률(%) */
|
||||
wr: number
|
||||
@@ -209,17 +219,16 @@ export interface Player {
|
||||
matches: MatchRecord[]
|
||||
/** 랭크전 시즌 기록 */
|
||||
rankedSeasons: RankedSeason[]
|
||||
/** 랭크전 통산 — 기간 무관 전체 랭크전 매치 합산 (없으면 시즌 합산으로 폴백) */
|
||||
rankedCareer?: RankedSeason
|
||||
/** 클랜별 전적 */
|
||||
clanRecords: ClanRecord[]
|
||||
/** 모드별 전적 */
|
||||
modeRecords: ModeRecord[]
|
||||
/** 맵별 전적 */
|
||||
mapRecords: MapRecord[]
|
||||
/** 추세 (일별/주별) */
|
||||
trend: {
|
||||
daily: TrendPoint[]
|
||||
weekly: TrendPoint[]
|
||||
}
|
||||
/** 최근 동향 — 20경기 단위 버킷(최대 200경기)의 승률·킬뎃 추이. 오래된→최신 순 */
|
||||
trend: TrendPoint[]
|
||||
/** 함께한 전적 (친구 목록 등에서 사용) */
|
||||
together: {
|
||||
games: number
|
||||
|
||||
@@ -18,3 +18,34 @@ export function formatDate(input: Date | string): string {
|
||||
const dd = String(date.getDate()).padStart(2, '0')
|
||||
return `${yyyy}-${mm}-${dd}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Date 또는 ISO 문자열을 KST(UTC+9) 기준 'YYYY-MM-DD'로 변환.
|
||||
* 시즌/클랜 기간(관리자·스냅샷이 KST 일자)과 매치 일시(UTC 저장)를 같은 기준으로 비교할 때 사용.
|
||||
*/
|
||||
export function toKstDate(input: Date | string): string {
|
||||
const date = typeof input === 'string' ? new Date(input) : input
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
return new Date(date.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/**
|
||||
* Date 또는 ISO 문자열을 현재 기준 상대 시간으로 변환
|
||||
* (예: '방금 전', '5분 전', '3시간 전', '2일 전', '4개월 전', '1년 전')
|
||||
* 상대 시간은 절대 시각 차이로 계산하므로 타임존 보정이 필요 없다.
|
||||
*/
|
||||
export function timeAgo(input: Date | string): string {
|
||||
const date = typeof input === 'string' ? new Date(input) : input
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
const sec = Math.floor((Date.now() - date.getTime()) / 1000)
|
||||
if (sec < 60) return '방금 전'
|
||||
const min = Math.floor(sec / 60)
|
||||
if (min < 60) return `${min}분 전`
|
||||
const hour = Math.floor(min / 60)
|
||||
if (hour < 24) return `${hour}시간 전`
|
||||
const day = Math.floor(hour / 24)
|
||||
if (day < 30) return `${day}일 전`
|
||||
const month = Math.floor(day / 30)
|
||||
if (month < 12) return `${month}개월 전`
|
||||
return `${Math.floor(month / 12)}년 전`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user