|
|
|
@@ -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);
|
|
|
|
|