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` (운영 중 갱신) |
|
| 주요 필드 | `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` · `applyWeeklyMasterTx` `:261-288` · `deleteUser` `:155-157` · `snapshotRankForUser` `rankSnapshotService.ts:26-54` |
|
| 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` · 주간티켓=일요일 archive→`applyWeeklyMasterTx` · rankSnapshot=`dailyArchive` cron(judge **이전**) · 삭제=DELETE `/user` |
|
| 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 포함) |
|
| Mechanism | 생성 `set({merge:false})`; 일반 갱신 `set({merge:true})`; 판정 `runTransaction`+`set(merge:true)`+`FieldValue.serverTimestamp()`(createdAt); 삭제 `recursiveDelete`(서브컬렉션 voteHistory/attendance/pointLedger 포함) |
|
||||||
| 빈도/볼륨 | 가입 1회/유저 · 프로필 수정 드묾 · **rankSnapshot + 판정 = 유저당 매일 2회 write** (전체 활성 유저 N명 × 매일) |
|
| 빈도/볼륨 | 가입 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.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;
|
minPoints: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 클라이언트 PredictionTier(prediction_tier.dart)와 동일한 canonical 임계값.
|
||||||
|
// 변경 시 클라이언트 게이지 표시용 minPoints도 함께 갱신할 것.
|
||||||
const TIERS: readonly TierStep[] = [
|
const TIERS: readonly TierStep[] = [
|
||||||
{ name: "bronze", minPoints: 0 },
|
{ name: "BW", minPoints: 0 }, // 벤치워머
|
||||||
{ name: "silver", minPoints: 100 },
|
{ name: "PR", minPoints: 100 }, // 유망주
|
||||||
{ name: "gold", minPoints: 300 },
|
{ name: "ST", minPoints: 300 }, // 주전
|
||||||
{ name: "platinum", minPoints: 700 },
|
{ name: "AS", minPoints: 700 }, // 올스타
|
||||||
{ name: "diamond", minPoints: 1500 },
|
{ name: "MVP", minPoints: 1500 }, // MVP
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -46,7 +46,7 @@ export interface ScoreboardSelfUser {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 스코어보드 응답의 "me" 계산에 필요한 필드만 fieldMask로 읽어온다.
|
* 스코어보드 응답의 "me" 계산에 필요한 필드만 fieldMask로 읽어온다.
|
||||||
* 전체 user doc(streak/티켓/notifications 등) 대신 5개 필드만 전송받아 bandwidth를 줄인다.
|
* 전체 user doc(streak/notifications 등) 대신 5개 필드만 전송받아 bandwidth를 줄인다.
|
||||||
*
|
*
|
||||||
* @returns 필요한 필드 부분집합. 문서가 없으면 `null`.
|
* @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));
|
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,
|
type ScoreboardUserEntry,
|
||||||
} from "../repositories/userRepository";
|
} from "../repositories/userRepository";
|
||||||
import { writeScope, type Scope } from "../repositories/scoreboardCacheRepository";
|
import { writeScope, type Scope } from "../repositories/scoreboardCacheRepository";
|
||||||
|
import { tierOf } from "../constants/tiers";
|
||||||
import { computePercentile, deltaFor } from "./scoreboardHelpers";
|
import { computePercentile, deltaFor } from "./scoreboardHelpers";
|
||||||
import type { DateString } from "../types/dateString";
|
import type { DateString } from "../types/dateString";
|
||||||
import { TeamCode, type RankSnapshot } from "../types/panit";
|
import { TeamCode, type RankSnapshot } from "../types/panit";
|
||||||
@ -107,6 +108,7 @@ function enrichTop(
|
|||||||
uid: e.uid,
|
uid: e.uid,
|
||||||
displayName: e.displayName,
|
displayName: e.displayName,
|
||||||
tierPoints: e.tierPoints,
|
tierPoints: e.tierPoints,
|
||||||
|
tier: tierOf(e.tierPoints),
|
||||||
rank,
|
rank,
|
||||||
rankDelta: deltaFor(e.rankSnapshot, rank, scope),
|
rankDelta: deltaFor(e.rankSnapshot, rank, scope),
|
||||||
percentile: computePercentile(rank, totalCount),
|
percentile: computePercentile(rank, totalCount),
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
type Scope,
|
type Scope,
|
||||||
} from "../repositories/scoreboardCacheRepository";
|
} from "../repositories/scoreboardCacheRepository";
|
||||||
import { HttpError } from "../middleware/errors";
|
import { HttpError } from "../middleware/errors";
|
||||||
|
import { tierOf } from "../constants/tiers";
|
||||||
import { computePercentile, deltaFor } from "./scoreboardHelpers";
|
import { computePercentile, deltaFor } from "./scoreboardHelpers";
|
||||||
import { todayKst } from "../types/dateString";
|
import { todayKst } from "../types/dateString";
|
||||||
import { type TeamCode } from "../types/panit";
|
import { type TeamCode } from "../types/panit";
|
||||||
@ -80,6 +81,7 @@ export async function getScoreboard(
|
|||||||
uid,
|
uid,
|
||||||
displayName: user.displayName,
|
displayName: user.displayName,
|
||||||
tierPoints: myPoints,
|
tierPoints: myPoints,
|
||||||
|
tier: tierOf(myPoints),
|
||||||
rank,
|
rank,
|
||||||
rankDelta: deltaFor(user.rankSnapshot, rank, scope),
|
rankDelta: deltaFor(user.rankSnapshot, rank, scope),
|
||||||
percentile: computePercentile(rank, cached.totalCount),
|
percentile: computePercentile(rank, cached.totalCount),
|
||||||
@ -92,7 +94,8 @@ export async function getScoreboard(
|
|||||||
const response: ScoreboardResponse = {
|
const response: ScoreboardResponse = {
|
||||||
type,
|
type,
|
||||||
totalCount: cached.totalCount,
|
totalCount: cached.totalCount,
|
||||||
top: cached.top,
|
// 배포 이전에 생성된 캐시에는 tier가 없을 수 있으므로 응답 시점에 보강한다.
|
||||||
|
top: cached.top.map((e) => ({ ...e, tier: e.tier ?? tierOf(e.tierPoints) })),
|
||||||
me,
|
me,
|
||||||
};
|
};
|
||||||
if (teamCode) response.teamCode = teamCode;
|
if (teamCode) response.teamCode = teamCode;
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import {rtdb} from "../firebase";
|
import {rtdb} from "../firebase";
|
||||||
import {HttpError} from "../middleware/errors";
|
import {HttpError} from "../middleware/errors";
|
||||||
import {computeLevel} from "../constants/levels";
|
|
||||||
import {tierOf} from "../constants/tiers";
|
import {tierOf} from "../constants/tiers";
|
||||||
import {getAll, getDay} from "../repositories/voteHistoryRepository";
|
import {getAll, getDay} from "../repositories/voteHistoryRepository";
|
||||||
import {getUser} from "../repositories/userRepository";
|
import {getUser} from "../repositories/userRepository";
|
||||||
@ -161,10 +160,10 @@ function weeklyResultsOf(entries: Array<{ date: DateString; doc: VoteHistoryDoc
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 유저의 예측 통계를 산출한다.
|
* 유저의 예측 통계를 산출한다.
|
||||||
* 전체·시즌·월간·주간 승률, 연속 참여일, 주간 결과, 레벨을 포함한다.
|
* 전체·시즌·월간·주간 승률, 연속 참여일, 주간 결과, 티어를 포함한다.
|
||||||
*
|
*
|
||||||
* @param uid - 유저 ID
|
* @param uid - 유저 ID
|
||||||
* @param period - 레벨·예측 수 집계에 사용할 기간
|
* @param period - 예측 수 집계에 사용할 기간
|
||||||
*/
|
*/
|
||||||
async function computeStats(uid: string, period: Period): Promise<StatsResponse> {
|
async function computeStats(uid: string, period: Period): Promise<StatsResponse> {
|
||||||
const [all, user] = await Promise.all([getAll(uid), getUser(uid)]);
|
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 highestStreak = user?.highestStreak ?? streakDays;
|
||||||
const tierPoints = user?.tierPoints ?? 0;
|
const tierPoints = user?.tierPoints ?? 0;
|
||||||
const weeklyResults = weeklyResultsOf(all);
|
const weeklyResults = weeklyResultsOf(all);
|
||||||
const {level, progress} = computeLevel(periodAgg.total);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
streakDays,
|
streakDays,
|
||||||
@ -226,8 +224,6 @@ async function computeStats(uid: string, period: Period): Promise<StatsResponse>
|
|||||||
totalPredictions: periodAgg.total,
|
totalPredictions: periodAgg.total,
|
||||||
totalCorrect: periodAgg.correct,
|
totalCorrect: periodAgg.correct,
|
||||||
weeklyPredictions: weekly.total,
|
weeklyPredictions: weekly.total,
|
||||||
currentLevel: level,
|
|
||||||
progress,
|
|
||||||
tier: tierOf(tierPoints),
|
tier: tierOf(tierPoints),
|
||||||
tierPoints,
|
tierPoints,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
|
|||||||
@ -34,7 +34,7 @@ import type { CheckNicknameDto, UserProfileDto } from "../types/dto/userDto";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 내부 User 문서를 클라이언트 응답용 프로필로 변환한다.
|
* 내부 User 문서를 클라이언트 응답용 프로필로 변환한다.
|
||||||
* streak/티어/티켓/판정 회계 필드는 의도적으로 제외 — 해당 데이터는 `/stats`로 서빙된다.
|
* streak/티어/판정 회계 필드는 의도적으로 제외 — 해당 데이터는 `/stats`로 서빙된다.
|
||||||
* (user doc의 streak은 lazy 보정 전 값이라 그대로 노출 금지.)
|
* (user doc의 streak은 lazy 보정 전 값이라 그대로 노출 금지.)
|
||||||
* `createdAt`은 응답 경계에서 UTC ISO 문자열로 변환한다.
|
* `createdAt`은 응답 경계에서 UTC ISO 문자열로 변환한다.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { toIsoOrUndefined } from "./iso";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 어드민 콘솔 유저 요약.
|
* 어드민 콘솔 유저 요약.
|
||||||
* streak/티어/티켓 등 lazy 보정이 필요한 필드는 의도적으로 제외한다
|
* streak/티어 등 lazy 보정이 필요한 필드는 의도적으로 제외한다
|
||||||
* (`userService.toUserProfile`와 같은 이유 — 해당 값은 /stats 경유가 원칙).
|
* (`userService.toUserProfile`와 같은 이유 — 해당 값은 /stats 경유가 원칙).
|
||||||
*/
|
*/
|
||||||
export interface AdminUserDto {
|
export interface AdminUserDto {
|
||||||
|
|||||||
@ -5,7 +5,8 @@ export type Provider = "google" | "apple" | "anonymous";
|
|||||||
|
|
||||||
export type DailyJudgment = "perfect" | "success" | "fail" | "skip";
|
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";
|
export type GameStatus = "scheduled" | "live" | "completed" | "cancelled";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -152,7 +153,7 @@ export interface User {
|
|||||||
/**
|
/**
|
||||||
* 클라이언트에 노출되는 유저 프로필.
|
* 클라이언트에 노출되는 유저 프로필.
|
||||||
*
|
*
|
||||||
* `User`에서 streak/티어/티켓/랭킹 스냅샷 등 보정이 필요하거나 내부 전용인
|
* `User`에서 streak/티어/랭킹 스냅샷 등 보정이 필요하거나 내부 전용인
|
||||||
* 필드를 제외한 형태. 해당 데이터는 `StatsResponse`(GET /stats)로 서빙된다.
|
* 필드를 제외한 형태. 해당 데이터는 `StatsResponse`(GET /stats)로 서빙된다.
|
||||||
*
|
*
|
||||||
* `lastJudgedDate`는 stale 위험이 없는(판정 트랜잭션과 함께 갱신되는) 사실값이라
|
* `lastJudgedDate`는 stale 위험이 없는(판정 트랜잭션과 함께 갱신되는) 사실값이라
|
||||||
@ -222,8 +223,6 @@ export interface StatsResponse {
|
|||||||
totalPredictions: number;
|
totalPredictions: number;
|
||||||
totalCorrect: number;
|
totalCorrect: number;
|
||||||
weeklyPredictions: number;
|
weeklyPredictions: number;
|
||||||
currentLevel: number;
|
|
||||||
progress: number;
|
|
||||||
tier: TierName;
|
tier: TierName;
|
||||||
tierPoints: number;
|
tierPoints: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import type { TeamCode } from "./panit";
|
import type { TeamCode, TierName } from "./panit";
|
||||||
|
|
||||||
export interface ScoreboardEntry {
|
export interface ScoreboardEntry {
|
||||||
uid: string;
|
uid: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
photoUrl?: string;
|
photoUrl?: string;
|
||||||
tierPoints: number;
|
tierPoints: number;
|
||||||
|
/** tierPoints에서 파생한 티어 코드 — 클라이언트는 이 값만 신뢰한다. */
|
||||||
|
tier: TierName;
|
||||||
favoriteTeamCode?: TeamCode;
|
favoriteTeamCode?: TeamCode;
|
||||||
rank: number;
|
rank: number;
|
||||||
/** 이전 rankSnapshot 대비 변화량. + 상승, − 하락, null = 스냅샷 없음/팀 변경. */
|
/** 이전 rankSnapshot 대비 변화량. + 상승, − 하락, null = 스냅샷 없음/팀 변경. */
|
||||||
|
|||||||
@ -191,9 +191,7 @@ describe("statsService.getStats — 캐시 forDate 검증", () => {
|
|||||||
totalPredictions: 0,
|
totalPredictions: 0,
|
||||||
totalCorrect: 0,
|
totalCorrect: 0,
|
||||||
weeklyPredictions: 0,
|
weeklyPredictions: 0,
|
||||||
currentLevel: 1,
|
tier: "BW",
|
||||||
progress: 0,
|
|
||||||
tier: "bronze",
|
|
||||||
tierPoints: 0,
|
tierPoints: 0,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
forDate: today,
|
forDate: today,
|
||||||
@ -217,9 +215,7 @@ describe("statsService.getStats — 캐시 forDate 검증", () => {
|
|||||||
totalPredictions: 0,
|
totalPredictions: 0,
|
||||||
totalCorrect: 0,
|
totalCorrect: 0,
|
||||||
weeklyPredictions: 0,
|
weeklyPredictions: 0,
|
||||||
currentLevel: 1,
|
tier: "BW",
|
||||||
progress: 0,
|
|
||||||
tier: "bronze",
|
|
||||||
tierPoints: 0,
|
tierPoints: 0,
|
||||||
updatedAt: Date.now() - 86_400_000,
|
updatedAt: Date.now() - 86_400_000,
|
||||||
forDate: yesterday,
|
forDate: yesterday,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user