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:
parent
d30ab16e9f
commit
ffafca1338
@ -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}` (날짜별 예측 이력)
|
||||
|
||||
|
||||
@ -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)) };
|
||||
}
|
||||
@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@ -46,7 +46,7 @@ export interface ScoreboardSelfUser {
|
||||
|
||||
/**
|
||||
* 스코어보드 응답의 "me" 계산에 필요한 필드만 fieldMask로 읽어온다.
|
||||
* 전체 user doc(streak/티켓/notifications 등) 대신 5개 필드만 전송받아 bandwidth를 줄인다.
|
||||
* 전체 user doc(streak/notifications 등) 대신 5개 필드만 전송받아 bandwidth를 줄인다.
|
||||
*
|
||||
* @returns 필요한 필드 부분집합. 문서가 없으면 `null`.
|
||||
*/
|
||||
|
||||
@ -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 - 방금 아카이브/판정이 끝난 일요일 날짜
|
||||
*/
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -34,7 +34,7 @@ import type { CheckNicknameDto, UserProfileDto } from "../types/dto/userDto";
|
||||
|
||||
/**
|
||||
* 내부 User 문서를 클라이언트 응답용 프로필로 변환한다.
|
||||
* streak/티어/티켓/판정 회계 필드는 의도적으로 제외 — 해당 데이터는 `/stats`로 서빙된다.
|
||||
* streak/티어/판정 회계 필드는 의도적으로 제외 — 해당 데이터는 `/stats`로 서빙된다.
|
||||
* (user doc의 streak은 lazy 보정 전 값이라 그대로 노출 금지.)
|
||||
* `createdAt`은 응답 경계에서 UTC ISO 문자열로 변환한다.
|
||||
*/
|
||||
|
||||
@ -3,7 +3,7 @@ import { toIsoOrUndefined } from "./iso";
|
||||
|
||||
/**
|
||||
* 어드민 콘솔 유저 요약.
|
||||
* streak/티어/티켓 등 lazy 보정이 필요한 필드는 의도적으로 제외한다
|
||||
* streak/티어 등 lazy 보정이 필요한 필드는 의도적으로 제외한다
|
||||
* (`userService.toUserProfile`와 같은 이유 — 해당 값은 /stats 경유가 원칙).
|
||||
*/
|
||||
export interface AdminUserDto {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 = 스냅샷 없음/팀 변경. */
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user