Unify prediction ranking on baseball tier codes

- 티어 코드를 bronze~diamond에서 야구 테마 BW/PR/ST/AS/MVP로 교체 (임계값 0/100/300/700/1500 유지), 클라이언트 동기화 주석 추가
- 누적 예측 수 기반 레벨 시스템 제거 — levels.ts 삭제, /stats 응답의 currentLevel·progress 필드 제거
- 스코어보드 top/me 엔트리에 tierPoints 파생 tier 코드 포함, 배포 이전 생성 캐시는 응답 시점에 tier 보강
- 주간 마스터 티켓 잔재 주석과 문서 표의 tickets 항목 정리, 폐기된 레벨·티켓 언급 주석 정돈
- statsService 테스트를 새 티어 코드·필드 구성으로 갱신
This commit is contained in:
윤정민 2026-07-23 14:13:19 +09:00
parent d30ab16e9f
commit ffafca1338
13 changed files with 31 additions and 51 deletions

View File

@ -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}` (날짜별 예측 이력)

View File

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

View File

@ -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;
/**

View File

@ -46,7 +46,7 @@ export interface ScoreboardSelfUser {
/**
* "me" fieldMask로 .
* user doc(streak//notifications ) 5 bandwidth를 .
* user doc(streak/notifications ) 5 bandwidth를 .
*
* @returns . `null`.
*/

View File

@ -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 - /
*/

View File

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

View File

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

View File

@ -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<StatsResponse> {
const [all, user] = await Promise.all([getAll(uid), getUser(uid)]);
@ -211,7 +210,6 @@ async function computeStats(uid: string, period: Period): Promise<StatsResponse>
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<StatsResponse>
totalPredictions: periodAgg.total,
totalCorrect: periodAgg.correct,
weeklyPredictions: weekly.total,
currentLevel: level,
progress,
tier: tierOf(tierPoints),
tierPoints,
updatedAt: Date.now(),

View File

@ -34,7 +34,7 @@ import type { CheckNicknameDto, UserProfileDto } from "../types/dto/userDto";
/**
* User .
* streak/// `/stats` .
* streak// `/stats` .
* (user doc의 streak은 lazy .)
* `createdAt` UTC ISO .
*/

View File

@ -3,7 +3,7 @@ import { toIsoOrUndefined } from "./iso";
/**
* .
* streak// lazy
* streak/ lazy
* (`userService.toUserProfile` /stats ).
*/
export interface AdminUserDto {

View File

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

View File

@ -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 = 스냅샷 없음/팀 변경. */

View File

@ -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,