Compare commits

...

1 Commits

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