diff --git a/src/VOTE_FLOW.md b/src/VOTE_FLOW.md index b2d38db..597eaa8 100644 --- a/src/VOTE_FLOW.md +++ b/src/VOTE_FLOW.md @@ -12,8 +12,8 @@ │ update ▼ onGameCompleted (Firestore trigger) - │ ├─ status → "completed" + winningTeamCode: processGameEndWithGame - │ └─ status → "cancelled": 투표/인덱스 정리 + │ ├─ status → "completed": processGameEndWithGame (무승부 포함) + │ └─ status → "cancelled": 투표/인덱스 정리 ▼ RTDB: /userVotes/{uid}/{date}/{gameId}.result 기록 + /votes/{gameId} 삭제 │ @@ -39,11 +39,13 @@ 클라이언트 → `POST /prediction` → `createPrediction` · `src/handlers/predictionHandlers.ts:32` -`submitVote` (`src/repositories/voteRepository.ts:56`)가 RTDB 원자 업데이트: +`selectedTeamCode`는 홈/원정 팀 코드 또는 무승부 예약 코드 `DRAW`(`DRAW_TEAM_CODE`). + +`submitVote` (`src/repositories/voteRepository.ts`)가 RTDB 원자 업데이트: | Path | Value | |---|---| -| `/votes/{gameId}/counts/{homeCount\|awayCount}` | `increment(1)` | +| `/votes/{gameId}/counts/{homeCount\|awayCount\|drawCount}` | `increment(1)` | | `/votes/{gameId}/users/{uid}` | `{ team }` | | `/userVotes/{uid}/{date}/{gameId}` | `{ team }` | @@ -65,11 +67,14 @@ `onDocumentUpdated("games/{gameId}")`에서 `before`/`after` 비교로 분기: ### 4-a. completed 전이 → 결과 반영 -가드: `before.status !== "completed" && after.status === "completed" && winningTeamCode` (멱등성). +가드: `before.status !== "completed" && after.status === "completed"` (멱등성). +`winningTeamCode`가 없는 completed는 무승부로 즉시 처리한다. `processGameEndWithGame(gameId, after)` (`src/services/gameResultService.ts`): 1. `getAllUserVotes(gameId)` — RTDB `/votes/{gameId}/users` 조회 -2. 각 유저별 `/userVotes/{uid}/{date}/{gameId}`에 `team` + `result: team === winningTeamCode` 기록 +2. 각 유저별 `/userVotes/{uid}/{date}/{gameId}`에 `team` + `result` 기록 + - 승부가 난 경기: `result = team === winningTeamCode` (무승부 투표는 오답) + - 무승부(`winningTeamCode` 없음): `result = team === "DRAW"` (무승부 투표만 적중) 3. `deleteGameVotes(gameId)` — `/votes/{gameId}` 제거 (집계 데이터는 이후 불필요) 4. 전 유저 `invalidateStats` @@ -90,7 +95,7 @@ 1. 각 유저의 모든 경기 투표가 `result` 또는 `cancelled` 보유인지 확인 (`allJudged`) 2. **리컨실리에이션**: 미판정 경기가 있으면 `getGame`으로 Firestore 조회 후 분기 - - `completed` + `winningTeamCode` → `processGameEndWithGame` 즉석 호출(트리거 누락 자가치유) + - `completed` → `processGameEndWithGame` 즉석 호출(트리거 누락 자가치유, 무승부 동일 규칙) - `cancelled` → 해당 vote 항목 무효(`cancelled: true`) 마킹 - `scheduled`/`live` → warn 로그 + 유저 스킵 (실데이터 이슈) 3. 모두 정리된 유저만 `voteHistory` 에 `setDay(uid, date, { data })` 저장 — 무효표는 @@ -121,7 +126,7 @@ ## 수동 운영 경로 -- **특정 경기 즉시 종료 처리**: `POST /admin/game/end { gameId, winningTeamCode }` (`adminHandlers.ts`) — 관리자 인증 필요. Firestore update를 통해 자동 트리거가 돈다. +- **특정 경기 즉시 종료 처리**: `POST /admin/game/end { gameId, winningTeamCode }` (`adminHandlers.ts`) — 관리자 인증 필요. Firestore update를 통해 자동 트리거가 돈다. 무승부는 `winningTeamCode: "DRAW"`로 호출(문서에는 winningTeamCode 없이 completed 기록). - **스케줄 강제 실행**: - 로컬: `firebase functions:shell` → `dailyArchive()` 또는 `kboDailyRefresh()` - 배포: `gcloud scheduler jobs run firebase-schedule--asia-northeast3 --location=asia-northeast3` 또는 GCP 콘솔 diff --git a/src/repositories/voteRepository.ts b/src/repositories/voteRepository.ts index f2c5131..db3e1c3 100644 --- a/src/repositories/voteRepository.ts +++ b/src/repositories/voteRepository.ts @@ -1,6 +1,6 @@ import { ServerValue } from "firebase-admin/database"; import { rtdb } from "../firebase"; -import type { VoteEntry } from "../types/panit"; +import type { VoteEntry, VoteSide } from "../types/panit"; import type { DateString } from "../types/dateString"; /** @@ -16,18 +16,19 @@ export async function getUserVote(gameId: string, uid: string): Promise { +): Promise<{ homeCount: number; awayCount: number; drawCount: number }> { const snap = await rtdb.ref(`/votes/${gameId}/counts`).get(); const val = snap.val() ?? {}; return { homeCount: Number(val.homeCount ?? 0), awayCount: Number(val.awayCount ?? 0), + drawCount: Number(val.drawCount ?? 0), }; } @@ -50,20 +51,19 @@ export async function getAllUserVotes( * @param params.gameId - 경기 ID * @param params.uid - 투표한 유저 ID * @param params.date - 경기 날짜 (유저별 날짜 인덱스 경로에 사용) - * @param params.side - 투표 진영 (`home` | `away`) - * @param params.team - 투표한 팀 코드 + * @param params.side - 투표 진영 (`home` | `away` | `draw`) + * @param params.team - 투표한 팀 코드 (무승부는 `DRAW`) */ export async function submitVote(params: { gameId: string; uid: string; date: DateString; - side: "home" | "away"; + side: VoteSide; team: string; }): Promise { const { gameId, uid, date, side, team } = params; - const counterKey = side === "home" ? "homeCount" : "awayCount"; const updates: Record = {}; - updates[`/votes/${gameId}/counts/${counterKey}`] = ServerValue.increment(1); + updates[`/votes/${gameId}/counts/${side}Count`] = ServerValue.increment(1); updates[`/votes/${gameId}/users/${uid}`] = { team }; updates[`/userVotes/${uid}/${date}/${gameId}`] = { team }; await rtdb.ref().update(updates); @@ -77,14 +77,14 @@ export async function submitVote(params: { * @param params.date - 경기 날짜 * @param params.oldSide - 기존 투표 진영 * @param params.newSide - 변경할 진영 - * @param params.newTeam - 변경할 팀 코드 + * @param params.newTeam - 변경할 팀 코드 (무승부는 `DRAW`) */ export async function changeVote(params: { gameId: string; uid: string; date: DateString; - oldSide: "home" | "away"; - newSide: "home" | "away"; + oldSide: VoteSide; + newSide: VoteSide; newTeam: string; }): Promise { const { gameId, uid, date, oldSide, newSide, newTeam } = params; diff --git a/src/scheduled/dailyArchive.ts b/src/scheduled/dailyArchive.ts index 68cf528..c91d715 100644 --- a/src/scheduled/dailyArchive.ts +++ b/src/scheduled/dailyArchive.ts @@ -11,7 +11,7 @@ import { import { todayKst } from "../types/dateString"; import { getGame, createGameDayCache } from "../repositories/gameRepository"; import { processGameEndWithGame } from "../services/gameResultService"; -import type { RankSnapshot, VoteHistoryDoc } from "../types/panit"; +import { DRAW_TEAM_CODE, type RankSnapshot, type VoteHistoryDoc } from "../types/panit"; import { dayOfWeek, daysAgoKst, @@ -51,8 +51,8 @@ async function reconcileDayVotes( } // 트리거가 실패/유실되었거나 배포 전 상태 전이가 일어난 경우의 자가치유 경로. - // `winningTeamCode`가 없으면 무승부 — `processGameEndWithGame`이 양 팀 투표 모두 - // result=true로 처리한다. + // `winningTeamCode`가 없으면 무승부 — 무승부('DRAW') 투표만 적중으로 처리한다 + // (`processGameEndWithGame`과 동일 규칙). if (game.status === "completed") { try { // 아카이브는 루프 끝에서 유저 단위로 1회 무효화하므로 경기별 fan-out은 생략. @@ -60,7 +60,9 @@ async function reconcileDayVotes( const isDraw = !game.winningTeamCode; result[gameId] = { team: vote.team, - result: isDraw ? true : vote.team === game.winningTeamCode, + result: isDraw + ? vote.team === DRAW_TEAM_CODE + : vote.team === game.winningTeamCode, }; logger.info(`reconcile: judged ${gameId} on the fly (uid=${uid})`); } catch (err) { diff --git a/src/services/gameResultService.ts b/src/services/gameResultService.ts index 3b3cfc4..c8b4dd8 100644 --- a/src/services/gameResultService.ts +++ b/src/services/gameResultService.ts @@ -6,7 +6,7 @@ import { getGame } from "../repositories/gameRepository"; import { getAllUserVotes, deleteGameVotes } from "../repositories/voteRepository"; import { invalidateStats } from "./statsService"; import { fromTimestamp } from "../types/dateString"; -import type { Game } from "../types/panit"; +import { DRAW_TEAM_CODE, type Game } from "../types/panit"; export async function processGameEnd(gameId: string): Promise<{ processed: number }> { const game = await getGame(gameId); @@ -25,12 +25,15 @@ export async function processGameEndWithGame( const votes = await getAllUserVotes(gameId); const uids = Object.keys(votes); - // 무승부(`winningTeamCode` 없음): 양 팀 투표 모두 적중(`result: true`)으로 처리. + // 무승부(`winningTeamCode` 없음): 무승부('DRAW') 투표만 적중, 팀 투표는 오답. + // 승부가 난 경기: 승리 팀 투표만 적중 — 무승부 투표는 자연히 오답이 된다. const isDraw = !game.winningTeamCode; const rtdbUpdates: Record = {}; for (const uid of uids) { const voted = votes[uid].team; - const result = isDraw ? true : voted === game.winningTeamCode; + const result = isDraw + ? voted === DRAW_TEAM_CODE + : voted === game.winningTeamCode; rtdbUpdates[`/userVotes/${uid}/${date}/${gameId}/team`] = voted; rtdbUpdates[`/userVotes/${uid}/${date}/${gameId}/result`] = result; } @@ -60,9 +63,12 @@ export async function markGameEnded( const ref = firestore.collection("games").doc(gameId); const snap = await ref.get(); if (!snap.exists) throw new HttpError(404, `game not found: ${gameId}`); + // 'DRAW'는 무승부 수동 처리 — winningTeamCode 없이 completed로 기록한다 + // (판정 규칙상 "winningTeamCode 없음 = 무승부"이므로 남아있는 값도 지운다). + const isDraw = winningTeamCode === DRAW_TEAM_CODE; await ref.update({ status: "completed", - winningTeamCode, + winningTeamCode: isDraw ? FieldValue.delete() : winningTeamCode, endedAt: FieldValue.serverTimestamp(), }); return { ok: true, gameId }; diff --git a/src/services/predictionService.ts b/src/services/predictionService.ts index a434fff..4a55e76 100644 --- a/src/services/predictionService.ts +++ b/src/services/predictionService.ts @@ -7,11 +7,11 @@ import { getCounts, getUserDateVotes, } from "../repositories/voteRepository"; -import type { Game, VoteEntry } from "../types/panit"; +import { DRAW_TEAM_CODE, type Game, type VoteEntry, type VoteSide } from "../types/panit"; import { fromTimestamp, parseDateString, type DateString } from "../types/dateString"; import { MemCache } from "../lib/memCache"; -type SummaryCounts = { homeCount: number; awayCount: number }; +type SummaryCounts = { homeCount: number; awayCount: number; drawCount: number }; const summaryCache = new MemCache(5_000); @@ -19,7 +19,8 @@ function gameDate(game: Game): DateString { return fromTimestamp(game.time); } -function sideOf(game: Game, teamCode: string): "home" | "away" { +function sideOf(game: Game, teamCode: string): VoteSide { + if (teamCode === DRAW_TEAM_CODE) return "draw"; if (teamCode === game.homeTeamCode) return "home"; if (teamCode === game.awayTeamCode) return "away"; throw new HttpError(400, `teamCode ${teamCode} not in game ${game.homeTeamCode}/${game.awayTeamCode}`); @@ -107,7 +108,7 @@ export async function listGamesByDate(date: string): Promise { export async function getSummary( gameId: string -): Promise<{ homeCount: number; awayCount: number }> { +): Promise<{ homeCount: number; awayCount: number; drawCount: number }> { if (!gameId) throw new HttpError(400, "gameId required"); return summaryCache.getOrFetch(gameId, () => getCounts(gameId)); } diff --git a/src/triggers/onGameCompleted.ts b/src/triggers/onGameCompleted.ts index a6a4c45..97668a3 100644 --- a/src/triggers/onGameCompleted.ts +++ b/src/triggers/onGameCompleted.ts @@ -29,9 +29,11 @@ export const onGameCompleted = onDocumentUpdated( const gameId = event.params.gameId; - // 게임이 completed로 전이되었을때 (어떤 게임이 끝났음으로 처리되었을 때) + // 게임이 completed로 전이되었을때 (어떤 게임이 끝났음으로 처리되었을 때). + // winningTeamCode가 없으면 무승부 — 무승부('DRAW') 투표만 적중 처리되므로 + // 무승부 경기도 즉시 처리한다(과거엔 dailyArchive 리컨실까지 미뤘음). const becameCompleted = before?.status !== "completed" && after.status === "completed"; - if (becameCompleted && after.winningTeamCode) { + if (becameCompleted) { try { const {processed} = await processGameEndWithGame(gameId, after); logger.info(`onGameCompleted: ${gameId} processed ${processed} votes`); diff --git a/src/types/panit.ts b/src/types/panit.ts index 4131cdf..7f23260 100644 --- a/src/types/panit.ts +++ b/src/types/panit.ts @@ -8,6 +8,15 @@ export type DailyJudgment = "perfect" | "success" | "fail" | "skip"; export type TierName = "bronze" | "silver" | "gold" | "platinum" | "diamond"; export type GameStatus = "scheduled" | "live" | "completed" | "cancelled"; +/** + * 무승부 예측의 예약 코드. 실제 팀 코드(TeamCode)와 겹치지 않는 센티넬로, + * `selectedTeamCode`/`VoteEntry.team`에 그대로 저장·반환된다. + */ +export const DRAW_TEAM_CODE = "DRAW"; + +/** 투표 진영 — 홈 승리 / 원정 승리 / 무승부. counts 키(`{side}Count`)와 1:1. */ +export type VoteSide = "home" | "away" | "draw"; + export enum TeamCode { KT = "KT", NC = "NC", diff --git a/tests/repositories/voteRepository.test.ts b/tests/repositories/voteRepository.test.ts index 11f9ca1..313914a 100644 --- a/tests/repositories/voteRepository.test.ts +++ b/tests/repositories/voteRepository.test.ts @@ -35,7 +35,7 @@ describe("voteRepository (RTDB)", () => { console.log("[home vote] userVote:", userVote); console.log("[home vote] dateVotes:", dateVotes); - expect(counts).toEqual({ homeCount: 1, awayCount: 0 }); + expect(counts).toEqual({ homeCount: 1, awayCount: 0, drawCount: 0 }); expect(userVote).toEqual({ team: "LG" }); expect(dateVotes[gameId]).toEqual({ team: "LG" }); }); @@ -45,7 +45,17 @@ describe("voteRepository (RTDB)", () => { const counts = await getCounts(gameId); console.log("[away vote] counts:", counts); - expect(counts).toEqual({ homeCount: 0, awayCount: 1 }); + expect(counts).toEqual({ homeCount: 0, awayCount: 1, drawCount: 0 }); + }); + + it("무승부 투표 시 drawCount만 증가하고 team은 DRAW로 저장된다", async () => { + await submitVote({ gameId, uid, date, side: "draw", team: "DRAW" }); + + const counts = await getCounts(gameId); + const userVote = await getUserVote(gameId, uid); + console.log("[draw vote] counts:", counts); + expect(counts).toEqual({ homeCount: 0, awayCount: 0, drawCount: 1 }); + expect(userVote).toEqual({ team: "DRAW" }); }); }); @@ -69,10 +79,29 @@ describe("voteRepository (RTDB)", () => { console.log("[change] 변경 후 counts:", counts); console.log("[change] userVote:", userVote); - expect(counts).toEqual({ homeCount: 0, awayCount: 1 }); + expect(counts).toEqual({ homeCount: 0, awayCount: 1, drawCount: 0 }); expect(userVote).toEqual({ team: "KIA" }); expect(dateVotes[gameId]).toEqual({ team: "KIA" }); }); + + it("home→draw 변경 시 drawCount로 재분배된다", async () => { + await submitVote({ gameId, uid, date, side: "home", team: "LG" }); + + await changeVote({ + gameId, + uid, + date, + oldSide: "home", + newSide: "draw", + newTeam: "DRAW", + }); + + const counts = await getCounts(gameId); + const userVote = await getUserVote(gameId, uid); + console.log("[change→draw] counts:", counts); + expect(counts).toEqual({ homeCount: 0, awayCount: 0, drawCount: 1 }); + expect(userVote).toEqual({ team: "DRAW" }); + }); }); describe("getUserVote", () => { @@ -86,7 +115,7 @@ describe("voteRepository (RTDB)", () => { it("counts 노드가 없으면 0으로 채워 반환한다", async () => { const result = await getCounts("nonexistent-game"); console.log("[counts-empty]", result); - expect(result).toEqual({ homeCount: 0, awayCount: 0 }); + expect(result).toEqual({ homeCount: 0, awayCount: 0, drawCount: 0 }); }); }); @@ -140,7 +169,7 @@ describe("voteRepository (RTDB)", () => { console.log("[delete] users:", users); console.log("[delete] dateVotes (남아있음):", dateVotes); - expect(counts).toEqual({ homeCount: 0, awayCount: 0 }); + expect(counts).toEqual({ homeCount: 0, awayCount: 0, drawCount: 0 }); expect(users).toEqual({}); expect(dateVotes[gameId]).toEqual({ team: "LG" }); });