Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 870615219b | |||
| 594bdf9f26 | |||
| 70d84a322d | |||
| c4a93db84b |
+106
@@ -0,0 +1,106 @@
|
||||
# 매치 상세 동기화 구조
|
||||
|
||||
전적검색의 매치 기록(맵/참가자 상세) 데이터를 넥슨 오픈 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 크론 수동 트리거 | 개발 편의 | 개발 환경 전용 트리거 엔드포인트 |
|
||||
@@ -25,4 +25,10 @@ export class MatchItemDto {
|
||||
|
||||
@ApiProperty({ example: 3 })
|
||||
assist!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '매치 맵 — 상세 캐시 보유 시에만 값 존재, 미보유면 빈 문자열(백그라운드 백필)',
|
||||
example: '제3보급창고',
|
||||
})
|
||||
matchMap!: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Column, Entity, PrimaryColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
// 매치 상세 적재 실패 기록 — 넥슨이 상세를 끝내 제공하지 않는 매치(삭제/만료 등)의
|
||||
// 무한 재시도를 막기 위해 실패 횟수를 누적한다. 한도 초과 시 백필 대상에서 제외.
|
||||
// 적재에 성공하면 행을 삭제해 기록을 정리한다.
|
||||
@Entity('sa_match_detail_failures')
|
||||
export class SaMatchDetailFailureEntity {
|
||||
@PrimaryColumn({ type: 'varchar', length: 40 })
|
||||
matchId!: string;
|
||||
|
||||
// 누적 실패 횟수
|
||||
@Column({ type: 'int', default: 0 })
|
||||
failCount!: number;
|
||||
|
||||
// 마지막 실패 사유 (로그 추적용)
|
||||
@Column({ type: 'varchar', length: 200, default: '' })
|
||||
lastError!: string;
|
||||
|
||||
// 마지막 시도 시각
|
||||
@UpdateDateColumn()
|
||||
lastTriedAt!: Date;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { SaMatchService } from './sa-match.service';
|
||||
import { SaMatchEntity } from './entities/sa-match.entity';
|
||||
import { SaMatchDetailEntity } from './entities/sa-match-detail.entity';
|
||||
import { SaMatchParticipantEntity } from './entities/sa-match-participant.entity';
|
||||
import { SaMatchDetailFailureEntity } from './entities/sa-match-detail-failure.entity';
|
||||
import { SaUserEntity } from '../sa-user/entities/sa-user.entity';
|
||||
|
||||
// 매치 모듈 — 목록(기본 누적 + 일일 크론) + 상세(더보기 lazy). 넥슨 호출은 전역 NexonService 사용.
|
||||
@@ -16,6 +17,7 @@ import { SaUserEntity } from '../sa-user/entities/sa-user.entity';
|
||||
SaMatchEntity,
|
||||
SaMatchDetailEntity,
|
||||
SaMatchParticipantEntity,
|
||||
SaMatchDetailFailureEntity,
|
||||
SaUserEntity,
|
||||
]),
|
||||
],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { In, MoreThanOrEqual, Repository } from 'typeorm';
|
||||
|
||||
import { NexonService } from '../../shared/nexon/nexon.service';
|
||||
import { SaUserEntity } from '../sa-user/entities/sa-user.entity';
|
||||
@@ -11,6 +11,7 @@ import { ParticipantDto } from './dto/participant.dto';
|
||||
import { SaMatchEntity } from './entities/sa-match.entity';
|
||||
import { SaMatchDetailEntity } from './entities/sa-match-detail.entity';
|
||||
import { SaMatchParticipantEntity } from './entities/sa-match-participant.entity';
|
||||
import { SaMatchDetailFailureEntity } from './entities/sa-match-detail-failure.entity';
|
||||
|
||||
// 넥슨 API 응답 타입
|
||||
interface MatchApiItem {
|
||||
@@ -67,6 +68,17 @@ 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;
|
||||
|
||||
// 백필 중복 실행 방지 — 진행 중인 matchId 집합
|
||||
private readonly backfillInFlight = new Set<string>();
|
||||
|
||||
constructor(
|
||||
@InjectRepository(SaMatchEntity)
|
||||
@@ -75,12 +87,14 @@ export class SaMatchService {
|
||||
private readonly detailRepo: Repository<SaMatchDetailEntity>,
|
||||
@InjectRepository(SaMatchParticipantEntity)
|
||||
private readonly participantRepo: Repository<SaMatchParticipantEntity>,
|
||||
@InjectRepository(SaMatchDetailFailureEntity)
|
||||
private readonly failureRepo: Repository<SaMatchDetailFailureEntity>,
|
||||
@InjectRepository(SaUserEntity)
|
||||
private readonly userRepo: Repository<SaUserEntity>,
|
||||
private readonly nexon: NexonService,
|
||||
) {}
|
||||
|
||||
/** 매치 목록 조회 — 최신분 누적 후 DB에서 반환. */
|
||||
/** 매치 목록 조회 — 최신분 누적 후 DB에서 반환. 맵은 상세 캐시에서 조인해 채운다. */
|
||||
async getMatches(
|
||||
ouid: string,
|
||||
matchType: string,
|
||||
@@ -92,6 +106,38 @@ export class SaMatchService {
|
||||
where: { ouid, matchType },
|
||||
order: { dateMatch: 'DESC' },
|
||||
});
|
||||
|
||||
// 상세 캐시(sa_match_details)에 있는 맵 이름 매핑 — 없으면 빈 문자열
|
||||
const ids = saved.map((m) => m.matchId);
|
||||
const details = ids.length
|
||||
? await this.detailRepo.find({
|
||||
where: { matchId: In(ids) },
|
||||
select: ['matchId', 'matchMap'],
|
||||
})
|
||||
: [];
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
return saved.map((m) => ({
|
||||
matchId: m.matchId,
|
||||
matchType: m.matchType,
|
||||
@@ -101,9 +147,33 @@ export class SaMatchService {
|
||||
kill: m.kill,
|
||||
death: m.death,
|
||||
assist: m.assist,
|
||||
matchMap: mapById.get(m.matchId) ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
/** 상세 미보유 매치를 비동기로 적재 (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));
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/** 넥슨 매치 목록 API → 신규분 누적(upsert). 실패는 격리(기존 데이터 유지). */
|
||||
private async syncMatches(
|
||||
ouid: string,
|
||||
@@ -153,9 +223,93 @@ export class SaMatchService {
|
||||
await this.syncMatches(user.ouid, type, SaMatchService.DEFAULT_MODE);
|
||||
}
|
||||
}
|
||||
await this.backfillMissingDetails();
|
||||
this.logger.log('일일 매치 동기화 완료');
|
||||
}
|
||||
|
||||
/**
|
||||
* 크론 백필 대상 matchId 목록 — 상세 미보유 매치를 최신순으로 수집.
|
||||
* 실패 한도 초과 매치는 제외하고, 개발 환경은 넥슨 일일 쿼터 보호를 위해
|
||||
* 유저당 한도(CRON_PER_USER_BACKFILL_LIMIT)를 적용한다.
|
||||
*/
|
||||
private async getBackfillTargets(): Promise<string[]> {
|
||||
const rows = await this.matchRepo
|
||||
.createQueryBuilder('m')
|
||||
.leftJoin(SaMatchDetailEntity, 'd', 'd.matchId = m.matchId')
|
||||
.leftJoin(SaMatchDetailFailureEntity, 'f', 'f.matchId = m.matchId')
|
||||
.where('d.matchId IS NULL')
|
||||
.andWhere('(f.matchId IS NULL OR f.failCount < :maxFail)', {
|
||||
maxFail: SaMatchService.MAX_DETAIL_FAILURES,
|
||||
})
|
||||
.select('m.matchId', 'matchId')
|
||||
.addSelect('m.ouid', 'ouid')
|
||||
.orderBy('m.dateMatch', 'DESC')
|
||||
.getRawMany<{ matchId: string; ouid: string }>();
|
||||
|
||||
const perUserLimit = SaMatchService.CRON_PER_USER_BACKFILL_LIMIT;
|
||||
const seen = new Set<string>();
|
||||
const countByUser = new Map<string, number>();
|
||||
const targets: string[] = [];
|
||||
for (const { matchId, ouid } of rows) {
|
||||
// 같은 매치를 여러 유저가 공유할 수 있으므로 matchId 기준 중복 제거
|
||||
if (seen.has(matchId)) continue;
|
||||
if (perUserLimit !== null) {
|
||||
const used = countByUser.get(ouid) ?? 0;
|
||||
if (used >= perUserLimit) continue;
|
||||
countByUser.set(ouid, used + 1);
|
||||
}
|
||||
seen.add(matchId);
|
||||
targets.push(matchId);
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
/** 상세(맵) 미보유 매치를 최신순으로 백필 — 크론 전용. (개발: 유저당 한도, 운영: 전량) */
|
||||
private async backfillMissingDetails(): Promise<void> {
|
||||
const targets = await this.getBackfillTargets();
|
||||
if (!targets.length) return;
|
||||
|
||||
const perUserLimit = SaMatchService.CRON_PER_USER_BACKFILL_LIMIT;
|
||||
this.logger.log(
|
||||
`매치 상세 백필 시작 (${targets.length}건${perUserLimit !== null ? `, 유저당 ${perUserLimit}건 한도` : ''})`,
|
||||
);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 상세 적재 실패 기록 — 횟수를 누적해 한도 초과 시 백필 대상에서 제외한다. */
|
||||
private async recordDetailFailure(
|
||||
matchId: string,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const prev = await this.failureRepo.findOne({ where: { matchId } });
|
||||
await this.failureRepo.save({
|
||||
matchId,
|
||||
failCount: (prev?.failCount ?? 0) + 1,
|
||||
lastError: message.slice(0, 200),
|
||||
});
|
||||
} catch (error) {
|
||||
// 기록 실패는 백필 흐름을 끊지 않는다 (다음 시도에서 재기록)
|
||||
this.logger.warn(
|
||||
`상세 실패 기록 실패(matchId=${matchId}): ${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 매치 상세 조회 — DB에 적재돼 있으면 반환, 없으면 더보기 시점에 1회 API 적재 후 반환. */
|
||||
async getMatchDetail(matchId: string): Promise<MatchDetailDto> {
|
||||
const cached = await this.detailRepo.findOne({ where: { matchId } });
|
||||
@@ -199,6 +353,9 @@ export class SaMatchService {
|
||||
await m.save(parts);
|
||||
});
|
||||
|
||||
// 적재 성공 — 과거 실패 기록이 있었다면 정리 (일시 장애 후 복구된 매치)
|
||||
await this.failureRepo.delete({ matchId });
|
||||
|
||||
const header = await this.detailRepo.findOne({ where: { matchId } });
|
||||
const parts = await this.participantRepo.find({ where: { matchId } });
|
||||
return this.toDetailDto(header!, parts);
|
||||
|
||||
@@ -55,15 +55,22 @@ api.interceptors.response.use(
|
||||
// 성공 시 data 만 언래핑하여 반환
|
||||
return payload.data as never
|
||||
}
|
||||
// HTTP 200 이지만 비즈니스 로직상 실패인 경우
|
||||
// HTTP 200 이지만 비즈니스 로직상 실패인 경우 — 서버 메시지 우선, 없으면 코드 사전 매핑
|
||||
const errCode = payload.error?.code || 'BIZ_001'
|
||||
showErrorUI(ErrorCodeLexicon[errCode] ?? ErrorCodeLexicon.SYS_001 ?? '일시적인 시스템 오류가 발생했습니다.')
|
||||
showErrorUI(
|
||||
payload.error?.message
|
||||
|| ErrorCodeLexicon[errCode]
|
||||
|| ErrorCodeLexicon.SYS_001
|
||||
|| '일시적인 시스템 오류가 발생했습니다.',
|
||||
)
|
||||
return Promise.reject(payload.error)
|
||||
}
|
||||
return payload as never
|
||||
},
|
||||
(error) => {
|
||||
let errCode = 'SYS_001'
|
||||
// 백엔드 Exception Filter 가 내려준 사용자용 메시지 (점검 안내 등) — 있으면 우선 노출
|
||||
let serverMessage: string | undefined
|
||||
|
||||
if (error?.response) {
|
||||
const status: number = error.response.status
|
||||
@@ -71,13 +78,19 @@ api.interceptors.response.use(
|
||||
|
||||
if (payload?.error?.code) {
|
||||
errCode = payload.error.code
|
||||
serverMessage = payload.error.message
|
||||
} else if (status === 401) errCode = 'AUTH_001'
|
||||
else if (status === 403) errCode = 'AUTH_003'
|
||||
else if (status === 400 || status === 422) errCode = 'VAL_001'
|
||||
else if (status === 404) errCode = 'RES_001'
|
||||
}
|
||||
|
||||
showErrorUI(ErrorCodeLexicon[errCode] ?? ErrorCodeLexicon.SYS_001 ?? '일시적인 시스템 오류가 발생했습니다.')
|
||||
showErrorUI(
|
||||
serverMessage
|
||||
|| ErrorCodeLexicon[errCode]
|
||||
|| ErrorCodeLexicon.SYS_001
|
||||
|| '일시적인 시스템 오류가 발생했습니다.',
|
||||
)
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface SaMatchItem {
|
||||
kill: number
|
||||
death: number
|
||||
assist: number
|
||||
// 매치 맵 — 상세 캐시 보유 시에만 값 존재, 미보유면 빈 문자열
|
||||
matchMap: string
|
||||
}
|
||||
|
||||
// 매치 상세 참가자
|
||||
|
||||
@@ -123,7 +123,7 @@ function adaptMatches(items: SaMatchItem[]): MatchRecord[] {
|
||||
matchId: m.matchId,
|
||||
mode: TYPE_LABEL[m.matchType] ?? m.matchType,
|
||||
win: m.matchResult === '1',
|
||||
map: '',
|
||||
map: m.matchMap ?? '',
|
||||
k: m.kill,
|
||||
d: m.death,
|
||||
a: m.assist,
|
||||
|
||||
@@ -15,10 +15,22 @@ const tab = ref('전체')
|
||||
// 다른 유저로 전환 시 탭 초기화
|
||||
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))
|
||||
|
||||
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))
|
||||
const hasMore = computed(() => ms.value.length > limit.value)
|
||||
|
||||
function showMore() {
|
||||
limit.value += PAGE_SIZE
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -46,10 +58,10 @@ const wins = computed(() => ms.value.filter((m) => m.win).length)
|
||||
</div>
|
||||
|
||||
<MatchRow
|
||||
v-for="(m, i) in ms"
|
||||
:key="i"
|
||||
v-for="(m, i) in visible"
|
||||
:key="m.matchId || i"
|
||||
:m="m"
|
||||
:last="i === ms.length - 1"
|
||||
:last="!hasMore && i === visible.length - 1"
|
||||
/>
|
||||
<div
|
||||
v-if="ms.length === 0"
|
||||
@@ -57,6 +69,13 @@ const wins = computed(() => ms.value.filter((m) => m.win).length)
|
||||
>
|
||||
해당 모드의 최근 매치가 없어요.
|
||||
</div>
|
||||
<button
|
||||
v-if="hasMore"
|
||||
class="mh__more"
|
||||
@click="showMore"
|
||||
>
|
||||
더보기 <span class="mh__more-count num">{{ visible.length }} / {{ ms.length }}</span>
|
||||
</button>
|
||||
</UiPanel>
|
||||
</template>
|
||||
|
||||
@@ -97,4 +116,26 @@ const wins = computed(() => ms.value.filter((m) => m.win).length)
|
||||
font-size: 0.8125rem;
|
||||
color: var(--t3);
|
||||
}
|
||||
.mh__more {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 11px 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--surf);
|
||||
cursor: pointer;
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.78125rem;
|
||||
font-weight: 700;
|
||||
color: var(--t2);
|
||||
}
|
||||
.mh__more:hover {
|
||||
background: #f7f8f9;
|
||||
}
|
||||
.mh__more-count {
|
||||
margin-left: 4px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--t3);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -20,7 +20,6 @@ const selRankNo = computed(() => (scope.value === '통합' ? props.p.rankNoTotal
|
||||
const stats = computed(() => [
|
||||
{ lab: '승률', val: props.p.wr + '%', col: 'var(--ink)' },
|
||||
{ lab: '킬뎃', val: SA.kdPct(props.p.kda) + '%', col: 'var(--point)' },
|
||||
{ lab: '헤드샷', val: props.p.hs + '%', col: 'var(--ink)' },
|
||||
{ lab: '라이플', val: props.p.rifle + '%', col: 'var(--ink)' },
|
||||
{ lab: '스나', val: props.p.sniper + '%', col: 'var(--ink)' },
|
||||
{ lab: '특수', val: props.p.special + '%', col: 'var(--ink)' },
|
||||
@@ -217,7 +216,7 @@ const stats = computed(() => [
|
||||
}
|
||||
.ps__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(9, 1fr);
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.ps__stat {
|
||||
|
||||
Reference in New Issue
Block a user