From ffafca13383a36930d2f0cfb8dfb1700bc634848 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Thu, 23 Jul 2026 14:13:19 +0900 Subject: [PATCH] Unify prediction ranking on baseball tier codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 티어 코드를 bronze~diamond에서 야구 테마 BW/PR/ST/AS/MVP로 교체 (임계값 0/100/300/700/1500 유지), 클라이언트 동기화 주석 추가 - 누적 예측 수 기반 레벨 시스템 제거 — levels.ts 삭제, /stats 응답의 currentLevel·progress 필드 제거 - 스코어보드 top/me 엔트리에 tierPoints 파생 tier 코드 포함, 배포 이전 생성 캐시는 응답 시점에 tier 보강 - 주간 마스터 티켓 잔재 주석과 문서 표의 tickets 항목 정리, 폐기된 레벨·티켓 언급 주석 정돈 - statsService 테스트를 새 티어 코드·필드 구성으로 갱신 --- docs/data-patterns/backend-writes.md | 10 +++++----- src/constants/levels.ts | 12 ------------ src/constants/tiers.ts | 12 +++++++----- src/repositories/userRepository.ts | 2 +- src/services/judgmentService.ts | 8 -------- src/services/rankSnapshotService.ts | 2 ++ src/services/scoreboardService.ts | 5 ++++- src/services/statsService.ts | 8 ++------ src/services/userService.ts | 2 +- src/types/dto/adminUserDto.ts | 2 +- src/types/panit.ts | 7 +++---- src/types/scoreboard.ts | 4 +++- tests/services/statsService.test.ts | 8 ++------ 13 files changed, 31 insertions(+), 51 deletions(-) delete mode 100644 src/constants/levels.ts diff --git a/docs/data-patterns/backend-writes.md b/docs/data-patterns/backend-writes.md index 59ad9dc..6df6f02 100644 --- a/docs/data-patterns/backend-writes.md +++ b/docs/data-patterns/backend-writes.md @@ -17,12 +17,12 @@ | 항목 | 내용 | |---|---| -| 주요 필드 | `displayName, email, provider, knowledgeLevel, photoUrl, favoriteTeamCode, createdAt` (생성), `currentStreak, highestStreak, tierPoints, tickets{dailyAllKill,weeklyMaster}, lastJudgedDate, lastWeeklyMasterTuesday, rankSnapshot, notifications` (운영 중 갱신) | -| Writer / 위치 | `createUser` `userRepository.ts:128-139` · `updateUser` `:145-150` · `applyDailyJudgmentTx` `:182-254` · `applyWeeklyMasterTx` `:261-288` · `deleteUser` `:155-157` · `snapshotRankForUser` `rankSnapshotService.ts:26-54` | -| Trigger | 생성=POST `/user`(`createMe`, userService.ts:120) · 갱신=PATCH `/user`(`updateMe`), PATCH `/user/notifications`, **GET `/user`(`getMe`) 의 photoUrl 자동 동기화**(userService.ts:101-105) · 판정=`dailyArchive` cron→`judgeDay`→`applyDailyJudgmentTx` · 주간티켓=일요일 archive→`applyWeeklyMasterTx` · rankSnapshot=`dailyArchive` cron(judge **이전**) · 삭제=DELETE `/user` | -| Mechanism | 생성 `set({merge:false})`; 일반 갱신 `set({merge:true})`; 판정/티켓 `runTransaction`+`set(merge:true)`+`FieldValue.serverTimestamp()`(createdAt); 삭제 `recursiveDelete`(서브컬렉션 voteHistory/attendance/pointLedger 포함) | +| 주요 필드 | `displayName, email, provider, knowledgeLevel, photoUrl, favoriteTeamCode, createdAt` (생성), `currentStreak, highestStreak, tierPoints, lastJudgedDate, rankSnapshot, notifications` (운영 중 갱신) | +| Writer / 위치 | `createUser` `userRepository.ts:128-139` · `updateUser` `:145-150` · `applyDailyJudgmentTx` `:182-254` · `deleteUser` `:155-157` · `snapshotRankForUser` `rankSnapshotService.ts:26-54` | +| Trigger | 생성=POST `/user`(`createMe`, userService.ts:120) · 갱신=PATCH `/user`(`updateMe`), PATCH `/user/notifications`, **GET `/user`(`getMe`) 의 photoUrl 자동 동기화**(userService.ts:101-105) · 판정=`dailyArchive` cron→`judgeDay`→`applyDailyJudgmentTx` · rankSnapshot=`dailyArchive` cron(judge **이전**) · 삭제=DELETE `/user` | +| Mechanism | 생성 `set({merge:false})`; 일반 갱신 `set({merge:true})`; 판정 `runTransaction`+`set(merge:true)`+`FieldValue.serverTimestamp()`(createdAt); 삭제 `recursiveDelete`(서브컬렉션 voteHistory/attendance/pointLedger 포함) | | 빈도/볼륨 | 가입 1회/유저 · 프로필 수정 드묾 · **rankSnapshot + 판정 = 유저당 매일 2회 write** (전체 활성 유저 N명 × 매일) | -| 비용 관찰 | ⚠️ **`getMe`(읽기 경로)에서 토큰 사진이 다르면 매 요청 write 발생** — 사진 변경이 잦은 토큰이면 read마다 hot write. ⚠️ `rankSnapshot` write(archive 중)와 `applyDailyJudgmentTx` write가 **같은 doc을 같은 cron run에서 2번** 건드림 → 1 write로 합칠 여지. `tickets`는 매 판정마다 dailyAllKill/weeklyMaster 전체 객체를 다시 써서 변경 없는 필드도 재기록. | +| 비용 관찰 | ⚠️ **`getMe`(읽기 경로)에서 토큰 사진이 다르면 매 요청 write 발생** — 사진 변경이 잦은 토큰이면 read마다 hot write. ⚠️ `rankSnapshot` write(archive 중)와 `applyDailyJudgmentTx` write가 **같은 doc을 같은 cron run에서 2번** 건드림 → 1 write로 합칠 여지. | ### 1.2 `users/{uid}/voteHistory/{date}` (날짜별 예측 이력) diff --git a/src/constants/levels.ts b/src/constants/levels.ts deleted file mode 100644 index 5022574..0000000 --- a/src/constants/levels.ts +++ /dev/null @@ -1,12 +0,0 @@ -const LEVEL_THRESHOLDS = [0, 10, 30, 70, 150, 300, 600, 1000] as const; - -export function computeLevel(totalPredictions: number): { level: number; progress: number } { - let level = 1; - for (let i = 0; i < LEVEL_THRESHOLDS.length; i++) { - if (totalPredictions >= LEVEL_THRESHOLDS[i]) level = i + 1; - } - const min = LEVEL_THRESHOLDS[level - 1]; - const next = LEVEL_THRESHOLDS[level]; - const progress = next === undefined ? 1 : (totalPredictions - min) / (next - min); - return { level, progress: Math.max(0, Math.min(1, progress)) }; -} diff --git a/src/constants/tiers.ts b/src/constants/tiers.ts index 2cd762e..a7ea4d9 100644 --- a/src/constants/tiers.ts +++ b/src/constants/tiers.ts @@ -5,12 +5,14 @@ interface TierStep { minPoints: number; } +// 클라이언트 PredictionTier(prediction_tier.dart)와 동일한 canonical 임계값. +// 변경 시 클라이언트 게이지 표시용 minPoints도 함께 갱신할 것. const TIERS: readonly TierStep[] = [ - { name: "bronze", minPoints: 0 }, - { name: "silver", minPoints: 100 }, - { name: "gold", minPoints: 300 }, - { name: "platinum", minPoints: 700 }, - { name: "diamond", minPoints: 1500 }, + { name: "BW", minPoints: 0 }, // 벤치워머 + { name: "PR", minPoints: 100 }, // 유망주 + { name: "ST", minPoints: 300 }, // 주전 + { name: "AS", minPoints: 700 }, // 올스타 + { name: "MVP", minPoints: 1500 }, // MVP ] as const; /** diff --git a/src/repositories/userRepository.ts b/src/repositories/userRepository.ts index b83e2da..3c9ce01 100644 --- a/src/repositories/userRepository.ts +++ b/src/repositories/userRepository.ts @@ -46,7 +46,7 @@ export interface ScoreboardSelfUser { /** * 스코어보드 응답의 "me" 계산에 필요한 필드만 fieldMask로 읽어온다. - * 전체 user doc(streak/티켓/notifications 등) 대신 5개 필드만 전송받아 bandwidth를 줄인다. + * 전체 user doc(streak/notifications 등) 대신 5개 필드만 전송받아 bandwidth를 줄인다. * * @returns 필요한 필드 부분집합. 문서가 없으면 `null`. */ diff --git a/src/services/judgmentService.ts b/src/services/judgmentService.ts index 02e6708..37a6f3b 100644 --- a/src/services/judgmentService.ts +++ b/src/services/judgmentService.ts @@ -116,11 +116,3 @@ export async function judgeDay( }); await settleDailyReward(uid, date).catch((err) => logger.error(`reward settlement failed uid=${uid} date=${date}`, err)); } - -/** - * 일요일 아카이브 직후 호출되어, 해당 주(화~일) 6일의 판정이 모두 - * `success|perfect|skip` 이면 주간 마스터 티켓을 지급한다. - * - * @param uid - 유저 ID - * @param sundayDate - 방금 아카이브/판정이 끝난 일요일 날짜 - */ diff --git a/src/services/rankSnapshotService.ts b/src/services/rankSnapshotService.ts index 23386b0..f80528a 100644 --- a/src/services/rankSnapshotService.ts +++ b/src/services/rankSnapshotService.ts @@ -8,6 +8,7 @@ import { type ScoreboardUserEntry, } from "../repositories/userRepository"; import { writeScope, type Scope } from "../repositories/scoreboardCacheRepository"; +import { tierOf } from "../constants/tiers"; import { computePercentile, deltaFor } from "./scoreboardHelpers"; import type { DateString } from "../types/dateString"; import { TeamCode, type RankSnapshot } from "../types/panit"; @@ -107,6 +108,7 @@ function enrichTop( uid: e.uid, displayName: e.displayName, tierPoints: e.tierPoints, + tier: tierOf(e.tierPoints), rank, rankDelta: deltaFor(e.rankSnapshot, rank, scope), percentile: computePercentile(rank, totalCount), diff --git a/src/services/scoreboardService.ts b/src/services/scoreboardService.ts index 61dcbb5..f76a8a2 100644 --- a/src/services/scoreboardService.ts +++ b/src/services/scoreboardService.ts @@ -8,6 +8,7 @@ import { type Scope, } from "../repositories/scoreboardCacheRepository"; import { HttpError } from "../middleware/errors"; +import { tierOf } from "../constants/tiers"; import { computePercentile, deltaFor } from "./scoreboardHelpers"; import { todayKst } from "../types/dateString"; import { type TeamCode } from "../types/panit"; @@ -80,6 +81,7 @@ export async function getScoreboard( uid, displayName: user.displayName, tierPoints: myPoints, + tier: tierOf(myPoints), rank, rankDelta: deltaFor(user.rankSnapshot, rank, scope), percentile: computePercentile(rank, cached.totalCount), @@ -92,7 +94,8 @@ export async function getScoreboard( const response: ScoreboardResponse = { type, totalCount: cached.totalCount, - top: cached.top, + // 배포 이전에 생성된 캐시에는 tier가 없을 수 있으므로 응답 시점에 보강한다. + top: cached.top.map((e) => ({ ...e, tier: e.tier ?? tierOf(e.tierPoints) })), me, }; if (teamCode) response.teamCode = teamCode; diff --git a/src/services/statsService.ts b/src/services/statsService.ts index 1158d73..02c7f2e 100644 --- a/src/services/statsService.ts +++ b/src/services/statsService.ts @@ -1,6 +1,5 @@ import {rtdb} from "../firebase"; import {HttpError} from "../middleware/errors"; -import {computeLevel} from "../constants/levels"; import {tierOf} from "../constants/tiers"; import {getAll, getDay} from "../repositories/voteHistoryRepository"; import {getUser} from "../repositories/userRepository"; @@ -161,10 +160,10 @@ function weeklyResultsOf(entries: Array<{ date: DateString; doc: VoteHistoryDoc /** * 유저의 예측 통계를 산출한다. - * 전체·시즌·월간·주간 승률, 연속 참여일, 주간 결과, 레벨을 포함한다. + * 전체·시즌·월간·주간 승률, 연속 참여일, 주간 결과, 티어를 포함한다. * * @param uid - 유저 ID - * @param period - 레벨·예측 수 집계에 사용할 기간 + * @param period - 예측 수 집계에 사용할 기간 */ async function computeStats(uid: string, period: Period): Promise { const [all, user] = await Promise.all([getAll(uid), getUser(uid)]); @@ -211,7 +210,6 @@ async function computeStats(uid: string, period: Period): Promise const highestStreak = user?.highestStreak ?? streakDays; const tierPoints = user?.tierPoints ?? 0; const weeklyResults = weeklyResultsOf(all); - const {level, progress} = computeLevel(periodAgg.total); return { streakDays, @@ -226,8 +224,6 @@ async function computeStats(uid: string, period: Period): Promise totalPredictions: periodAgg.total, totalCorrect: periodAgg.correct, weeklyPredictions: weekly.total, - currentLevel: level, - progress, tier: tierOf(tierPoints), tierPoints, updatedAt: Date.now(), diff --git a/src/services/userService.ts b/src/services/userService.ts index 17e6ba2..bd818ed 100644 --- a/src/services/userService.ts +++ b/src/services/userService.ts @@ -34,7 +34,7 @@ import type { CheckNicknameDto, UserProfileDto } from "../types/dto/userDto"; /** * 내부 User 문서를 클라이언트 응답용 프로필로 변환한다. - * streak/티어/티켓/판정 회계 필드는 의도적으로 제외 — 해당 데이터는 `/stats`로 서빙된다. + * streak/티어/판정 회계 필드는 의도적으로 제외 — 해당 데이터는 `/stats`로 서빙된다. * (user doc의 streak은 lazy 보정 전 값이라 그대로 노출 금지.) * `createdAt`은 응답 경계에서 UTC ISO 문자열로 변환한다. */ diff --git a/src/types/dto/adminUserDto.ts b/src/types/dto/adminUserDto.ts index 542ba38..50869bc 100644 --- a/src/types/dto/adminUserDto.ts +++ b/src/types/dto/adminUserDto.ts @@ -3,7 +3,7 @@ import { toIsoOrUndefined } from "./iso"; /** * 어드민 콘솔 유저 요약. - * streak/티어/티켓 등 lazy 보정이 필요한 필드는 의도적으로 제외한다 + * streak/티어 등 lazy 보정이 필요한 필드는 의도적으로 제외한다 * (`userService.toUserProfile`와 같은 이유 — 해당 값은 /stats 경유가 원칙). */ export interface AdminUserDto { diff --git a/src/types/panit.ts b/src/types/panit.ts index 4c1f57c..8bf75d3 100644 --- a/src/types/panit.ts +++ b/src/types/panit.ts @@ -5,7 +5,8 @@ export type Provider = "google" | "apple" | "anonymous"; export type DailyJudgment = "perfect" | "success" | "fail" | "skip"; -export type TierName = "bronze" | "silver" | "gold" | "platinum" | "diamond"; +/** 야구 테마 티어 코드 — 벤치워머(BW) → 유망주(PR) → 주전(ST) → 올스타(AS) → MVP. */ +export type TierName = "BW" | "PR" | "ST" | "AS" | "MVP"; export type GameStatus = "scheduled" | "live" | "completed" | "cancelled"; /** @@ -152,7 +153,7 @@ export interface User { /** * 클라이언트에 노출되는 유저 프로필. * - * `User`에서 streak/티어/티켓/랭킹 스냅샷 등 보정이 필요하거나 내부 전용인 + * `User`에서 streak/티어/랭킹 스냅샷 등 보정이 필요하거나 내부 전용인 * 필드를 제외한 형태. 해당 데이터는 `StatsResponse`(GET /stats)로 서빙된다. * * `lastJudgedDate`는 stale 위험이 없는(판정 트랜잭션과 함께 갱신되는) 사실값이라 @@ -222,8 +223,6 @@ export interface StatsResponse { totalPredictions: number; totalCorrect: number; weeklyPredictions: number; - currentLevel: number; - progress: number; tier: TierName; tierPoints: number; updatedAt: number; diff --git a/src/types/scoreboard.ts b/src/types/scoreboard.ts index 2215e9c..a7ddb92 100644 --- a/src/types/scoreboard.ts +++ b/src/types/scoreboard.ts @@ -1,10 +1,12 @@ -import type { TeamCode } from "./panit"; +import type { TeamCode, TierName } from "./panit"; export interface ScoreboardEntry { uid: string; displayName: string; photoUrl?: string; tierPoints: number; + /** tierPoints에서 파생한 티어 코드 — 클라이언트는 이 값만 신뢰한다. */ + tier: TierName; favoriteTeamCode?: TeamCode; rank: number; /** 이전 rankSnapshot 대비 변화량. + 상승, − 하락, null = 스냅샷 없음/팀 변경. */ diff --git a/tests/services/statsService.test.ts b/tests/services/statsService.test.ts index f1396dd..ba3aec6 100644 --- a/tests/services/statsService.test.ts +++ b/tests/services/statsService.test.ts @@ -191,9 +191,7 @@ describe("statsService.getStats — 캐시 forDate 검증", () => { totalPredictions: 0, totalCorrect: 0, weeklyPredictions: 0, - currentLevel: 1, - progress: 0, - tier: "bronze", + tier: "BW", tierPoints: 0, updatedAt: Date.now(), forDate: today, @@ -217,9 +215,7 @@ describe("statsService.getStats — 캐시 forDate 검증", () => { totalPredictions: 0, totalCorrect: 0, weeklyPredictions: 0, - currentLevel: 1, - progress: 0, - tier: "bronze", + tier: "BW", tierPoints: 0, updatedAt: Date.now() - 86_400_000, forDate: yesterday,