Add draw prediction support to vote pipeline

- selectedTeamCode에 무승부 예약 코드 'DRAW' 허용 (sideOf가 draw 진영 반환)
- RTDB /votes/{gameId}/counts에 drawCount 추가, 투표 요약 응답에 포함
- 채점 규칙 변경: 무승부 경기는 'DRAW' 투표만 적중, 팀 투표는 오답 (기존: 전원 적중 처리)
- 무승부 경기(completed + winningTeamCode 없음)도 onGameCompleted 트리거에서 즉시 채점 (기존: dailyArchive 리컨실까지 지연)
- 어드민 수동 종료(POST /admin/game/end)에 winningTeamCode: 'DRAW' 지원
- VOTE_FLOW.md 갱신, voteRepository 무승부 투표/카운트 재분배 테스트 추가
This commit is contained in:
윤정민 2026-07-10 16:51:56 +09:00
parent de35ef21ce
commit 9daba193e4
8 changed files with 92 additions and 38 deletions

View File

@ -12,8 +12,8 @@
│ update │ update
onGameCompleted (Firestore trigger) onGameCompleted (Firestore trigger)
│ ├─ status → "completed" + winningTeamCode: processGameEndWithGame │ ├─ status → "completed": processGameEndWithGame (무승부 포함)
│ └─ status → "cancelled": 투표/인덱스 정리 │ └─ status → "cancelled": 투표/인덱스 정리
RTDB: /userVotes/{uid}/{date}/{gameId}.result 기록 + /votes/{gameId} 삭제 RTDB: /userVotes/{uid}/{date}/{gameId}.result 기록 + /votes/{gameId} 삭제
@ -39,11 +39,13 @@
클라이언트 → `POST /prediction``createPrediction` · `src/handlers/predictionHandlers.ts:32` 클라이언트 → `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 | | Path | Value |
|---|---| |---|---|
| `/votes/{gameId}/counts/{homeCount\|awayCount}` | `increment(1)` | | `/votes/{gameId}/counts/{homeCount\|awayCount\|drawCount}` | `increment(1)` |
| `/votes/{gameId}/users/{uid}` | `{ team }` | | `/votes/{gameId}/users/{uid}` | `{ team }` |
| `/userVotes/{uid}/{date}/{gameId}` | `{ team }` | | `/userVotes/{uid}/{date}/{gameId}` | `{ team }` |
@ -65,11 +67,14 @@
`onDocumentUpdated("games/{gameId}")`에서 `before`/`after` 비교로 분기: `onDocumentUpdated("games/{gameId}")`에서 `before`/`after` 비교로 분기:
### 4-a. completed 전이 → 결과 반영 ### 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`): `processGameEndWithGame(gameId, after)` (`src/services/gameResultService.ts`):
1. `getAllUserVotes(gameId)` — RTDB `/votes/{gameId}/users` 조회 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}` 제거 (집계 데이터는 이후 불필요) 3. `deleteGameVotes(gameId)``/votes/{gameId}` 제거 (집계 데이터는 이후 불필요)
4. 전 유저 `invalidateStats` 4. 전 유저 `invalidateStats`
@ -90,7 +95,7 @@
1. 각 유저의 모든 경기 투표가 `result` 또는 `cancelled` 보유인지 확인 (`allJudged`) 1. 각 유저의 모든 경기 투표가 `result` 또는 `cancelled` 보유인지 확인 (`allJudged`)
2. **리컨실리에이션**: 미판정 경기가 있으면 `getGame`으로 Firestore 조회 후 분기 2. **리컨실리에이션**: 미판정 경기가 있으면 `getGame`으로 Firestore 조회 후 분기
- `completed` + `winningTeamCode` `processGameEndWithGame` 즉석 호출(트리거 누락 자가치유) - `completed``processGameEndWithGame` 즉석 호출(트리거 누락 자가치유, 무승부 동일 규칙)
- `cancelled` → 해당 vote 항목 무효(`cancelled: true`) 마킹 - `cancelled` → 해당 vote 항목 무효(`cancelled: true`) 마킹
- `scheduled`/`live` → warn 로그 + 유저 스킵 (실데이터 이슈) - `scheduled`/`live` → warn 로그 + 유저 스킵 (실데이터 이슈)
3. 모두 정리된 유저만 `voteHistory``setDay(uid, date, { data })` 저장 — 무효표는 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()` - 로컬: `firebase functions:shell``dailyArchive()` 또는 `kboDailyRefresh()`
- 배포: `gcloud scheduler jobs run firebase-schedule-<name>-asia-northeast3 --location=asia-northeast3` 또는 GCP 콘솔 - 배포: `gcloud scheduler jobs run firebase-schedule-<name>-asia-northeast3 --location=asia-northeast3` 또는 GCP 콘솔

View File

@ -1,6 +1,6 @@
import { ServerValue } from "firebase-admin/database"; import { ServerValue } from "firebase-admin/database";
import { rtdb } from "../firebase"; import { rtdb } from "../firebase";
import type { VoteEntry } from "../types/panit"; import type { VoteEntry, VoteSide } from "../types/panit";
import type { DateString } from "../types/dateString"; import type { DateString } from "../types/dateString";
/** /**
@ -16,18 +16,19 @@ export async function getUserVote(gameId: string, uid: string): Promise<VoteEntr
} }
/** /**
* / . 0 . * // . 0 .
* *
* @param gameId - ID * @param gameId - ID
*/ */
export async function getCounts( export async function getCounts(
gameId: string gameId: string
): Promise<{ homeCount: number; awayCount: number }> { ): Promise<{ homeCount: number; awayCount: number; drawCount: number }> {
const snap = await rtdb.ref(`/votes/${gameId}/counts`).get(); const snap = await rtdb.ref(`/votes/${gameId}/counts`).get();
const val = snap.val() ?? {}; const val = snap.val() ?? {};
return { return {
homeCount: Number(val.homeCount ?? 0), homeCount: Number(val.homeCount ?? 0),
awayCount: Number(val.awayCount ?? 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.gameId - ID
* @param params.uid - ID * @param params.uid - ID
* @param params.date - ( ) * @param params.date - ( )
* @param params.side - (`home` | `away`) * @param params.side - (`home` | `away` | `draw`)
* @param params.team - * @param params.team - ( `DRAW`)
*/ */
export async function submitVote(params: { export async function submitVote(params: {
gameId: string; gameId: string;
uid: string; uid: string;
date: DateString; date: DateString;
side: "home" | "away"; side: VoteSide;
team: string; team: string;
}): Promise<void> { }): Promise<void> {
const { gameId, uid, date, side, team } = params; const { gameId, uid, date, side, team } = params;
const counterKey = side === "home" ? "homeCount" : "awayCount";
const updates: Record<string, unknown> = {}; const updates: Record<string, unknown> = {};
updates[`/votes/${gameId}/counts/${counterKey}`] = ServerValue.increment(1); updates[`/votes/${gameId}/counts/${side}Count`] = ServerValue.increment(1);
updates[`/votes/${gameId}/users/${uid}`] = { team }; updates[`/votes/${gameId}/users/${uid}`] = { team };
updates[`/userVotes/${uid}/${date}/${gameId}`] = { team }; updates[`/userVotes/${uid}/${date}/${gameId}`] = { team };
await rtdb.ref().update(updates); await rtdb.ref().update(updates);
@ -77,14 +77,14 @@ export async function submitVote(params: {
* @param params.date - * @param params.date -
* @param params.oldSide - * @param params.oldSide -
* @param params.newSide - * @param params.newSide -
* @param params.newTeam - * @param params.newTeam - ( `DRAW`)
*/ */
export async function changeVote(params: { export async function changeVote(params: {
gameId: string; gameId: string;
uid: string; uid: string;
date: DateString; date: DateString;
oldSide: "home" | "away"; oldSide: VoteSide;
newSide: "home" | "away"; newSide: VoteSide;
newTeam: string; newTeam: string;
}): Promise<void> { }): Promise<void> {
const { gameId, uid, date, oldSide, newSide, newTeam } = params; const { gameId, uid, date, oldSide, newSide, newTeam } = params;

View File

@ -11,7 +11,7 @@ import {
import { todayKst } from "../types/dateString"; import { todayKst } from "../types/dateString";
import { getGame, createGameDayCache } from "../repositories/gameRepository"; import { getGame, createGameDayCache } from "../repositories/gameRepository";
import { processGameEndWithGame } from "../services/gameResultService"; import { processGameEndWithGame } from "../services/gameResultService";
import type { RankSnapshot, VoteHistoryDoc } from "../types/panit"; import { DRAW_TEAM_CODE, type RankSnapshot, type VoteHistoryDoc } from "../types/panit";
import { import {
dayOfWeek, dayOfWeek,
daysAgoKst, daysAgoKst,
@ -51,8 +51,8 @@ async function reconcileDayVotes(
} }
// 트리거가 실패/유실되었거나 배포 전 상태 전이가 일어난 경우의 자가치유 경로. // 트리거가 실패/유실되었거나 배포 전 상태 전이가 일어난 경우의 자가치유 경로.
// `winningTeamCode`가 없으면 무승부 — `processGameEndWithGame`이 양 팀 투표 모두 // `winningTeamCode`가 없으면 무승부 — 무승부('DRAW') 투표만 적중으로 처리한다
// result=true로 처리한다. // (`processGameEndWithGame`과 동일 규칙).
if (game.status === "completed") { if (game.status === "completed") {
try { try {
// 아카이브는 루프 끝에서 유저 단위로 1회 무효화하므로 경기별 fan-out은 생략. // 아카이브는 루프 끝에서 유저 단위로 1회 무효화하므로 경기별 fan-out은 생략.
@ -60,7 +60,9 @@ async function reconcileDayVotes(
const isDraw = !game.winningTeamCode; const isDraw = !game.winningTeamCode;
result[gameId] = { result[gameId] = {
team: vote.team, 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})`); logger.info(`reconcile: judged ${gameId} on the fly (uid=${uid})`);
} catch (err) { } catch (err) {

View File

@ -6,7 +6,7 @@ import { getGame } from "../repositories/gameRepository";
import { getAllUserVotes, deleteGameVotes } from "../repositories/voteRepository"; import { getAllUserVotes, deleteGameVotes } from "../repositories/voteRepository";
import { invalidateStats } from "./statsService"; import { invalidateStats } from "./statsService";
import { fromTimestamp } from "../types/dateString"; 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 }> { export async function processGameEnd(gameId: string): Promise<{ processed: number }> {
const game = await getGame(gameId); const game = await getGame(gameId);
@ -25,12 +25,15 @@ export async function processGameEndWithGame(
const votes = await getAllUserVotes(gameId); const votes = await getAllUserVotes(gameId);
const uids = Object.keys(votes); const uids = Object.keys(votes);
// 무승부(`winningTeamCode` 없음): 양 팀 투표 모두 적중(`result: true`)으로 처리. // 무승부(`winningTeamCode` 없음): 무승부('DRAW') 투표만 적중, 팀 투표는 오답.
// 승부가 난 경기: 승리 팀 투표만 적중 — 무승부 투표는 자연히 오답이 된다.
const isDraw = !game.winningTeamCode; const isDraw = !game.winningTeamCode;
const rtdbUpdates: Record<string, unknown> = {}; const rtdbUpdates: Record<string, unknown> = {};
for (const uid of uids) { for (const uid of uids) {
const voted = votes[uid].team; 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}/team`] = voted;
rtdbUpdates[`/userVotes/${uid}/${date}/${gameId}/result`] = result; rtdbUpdates[`/userVotes/${uid}/${date}/${gameId}/result`] = result;
} }
@ -60,9 +63,12 @@ export async function markGameEnded(
const ref = firestore.collection("games").doc(gameId); const ref = firestore.collection("games").doc(gameId);
const snap = await ref.get(); const snap = await ref.get();
if (!snap.exists) throw new HttpError(404, `game not found: ${gameId}`); if (!snap.exists) throw new HttpError(404, `game not found: ${gameId}`);
// 'DRAW'는 무승부 수동 처리 — winningTeamCode 없이 completed로 기록한다
// (판정 규칙상 "winningTeamCode 없음 = 무승부"이므로 남아있는 값도 지운다).
const isDraw = winningTeamCode === DRAW_TEAM_CODE;
await ref.update({ await ref.update({
status: "completed", status: "completed",
winningTeamCode, winningTeamCode: isDraw ? FieldValue.delete() : winningTeamCode,
endedAt: FieldValue.serverTimestamp(), endedAt: FieldValue.serverTimestamp(),
}); });
return { ok: true, gameId }; return { ok: true, gameId };

View File

@ -7,11 +7,11 @@ import {
getCounts, getCounts,
getUserDateVotes, getUserDateVotes,
} from "../repositories/voteRepository"; } 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 { fromTimestamp, parseDateString, type DateString } from "../types/dateString";
import { MemCache } from "../lib/memCache"; import { MemCache } from "../lib/memCache";
type SummaryCounts = { homeCount: number; awayCount: number }; type SummaryCounts = { homeCount: number; awayCount: number; drawCount: number };
const summaryCache = new MemCache<SummaryCounts>(5_000); const summaryCache = new MemCache<SummaryCounts>(5_000);
@ -19,7 +19,8 @@ function gameDate(game: Game): DateString {
return fromTimestamp(game.time); 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.homeTeamCode) return "home";
if (teamCode === game.awayTeamCode) return "away"; if (teamCode === game.awayTeamCode) return "away";
throw new HttpError(400, `teamCode ${teamCode} not in game ${game.homeTeamCode}/${game.awayTeamCode}`); throw new HttpError(400, `teamCode ${teamCode} not in game ${game.homeTeamCode}/${game.awayTeamCode}`);
@ -107,7 +108,7 @@ export async function listGamesByDate(date: string): Promise<GameWithId[]> {
export async function getSummary( export async function getSummary(
gameId: string gameId: string
): Promise<{ homeCount: number; awayCount: number }> { ): Promise<{ homeCount: number; awayCount: number; drawCount: number }> {
if (!gameId) throw new HttpError(400, "gameId required"); if (!gameId) throw new HttpError(400, "gameId required");
return summaryCache.getOrFetch(gameId, () => getCounts(gameId)); return summaryCache.getOrFetch(gameId, () => getCounts(gameId));
} }

View File

@ -29,9 +29,11 @@ export const onGameCompleted = onDocumentUpdated(
const gameId = event.params.gameId; const gameId = event.params.gameId;
// 게임이 completed로 전이되었을때 (어떤 게임이 끝났음으로 처리되었을 때) // 게임이 completed로 전이되었을때 (어떤 게임이 끝났음으로 처리되었을 때).
// winningTeamCode가 없으면 무승부 — 무승부('DRAW') 투표만 적중 처리되므로
// 무승부 경기도 즉시 처리한다(과거엔 dailyArchive 리컨실까지 미뤘음).
const becameCompleted = before?.status !== "completed" && after.status === "completed"; const becameCompleted = before?.status !== "completed" && after.status === "completed";
if (becameCompleted && after.winningTeamCode) { if (becameCompleted) {
try { try {
const {processed} = await processGameEndWithGame(gameId, after); const {processed} = await processGameEndWithGame(gameId, after);
logger.info(`onGameCompleted: ${gameId} processed ${processed} votes`); logger.info(`onGameCompleted: ${gameId} processed ${processed} votes`);

View File

@ -8,6 +8,15 @@ export type DailyJudgment = "perfect" | "success" | "fail" | "skip";
export type TierName = "bronze" | "silver" | "gold" | "platinum" | "diamond"; export type TierName = "bronze" | "silver" | "gold" | "platinum" | "diamond";
export type GameStatus = "scheduled" | "live" | "completed" | "cancelled"; 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 { export enum TeamCode {
KT = "KT", KT = "KT",
NC = "NC", NC = "NC",

View File

@ -35,7 +35,7 @@ describe("voteRepository (RTDB)", () => {
console.log("[home vote] userVote:", userVote); console.log("[home vote] userVote:", userVote);
console.log("[home vote] dateVotes:", dateVotes); 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(userVote).toEqual({ team: "LG" });
expect(dateVotes[gameId]).toEqual({ team: "LG" }); expect(dateVotes[gameId]).toEqual({ team: "LG" });
}); });
@ -45,7 +45,17 @@ describe("voteRepository (RTDB)", () => {
const counts = await getCounts(gameId); const counts = await getCounts(gameId);
console.log("[away vote] counts:", counts); 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] 변경 후 counts:", counts);
console.log("[change] userVote:", userVote); 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(userVote).toEqual({ team: "KIA" });
expect(dateVotes[gameId]).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", () => { describe("getUserVote", () => {
@ -86,7 +115,7 @@ describe("voteRepository (RTDB)", () => {
it("counts 노드가 없으면 0으로 채워 반환한다", async () => { it("counts 노드가 없으면 0으로 채워 반환한다", async () => {
const result = await getCounts("nonexistent-game"); const result = await getCounts("nonexistent-game");
console.log("[counts-empty]", result); 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] users:", users);
console.log("[delete] dateVotes (남아있음):", dateVotes); 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(users).toEqual({});
expect(dateVotes[gameId]).toEqual({ team: "LG" }); expect(dateVotes[gameId]).toEqual({ team: "LG" });
}); });