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:
parent
de35ef21ce
commit
9daba193e4
@ -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-<name>-asia-northeast3 --location=asia-northeast3` 또는 GCP 콘솔
|
||||
|
||||
@ -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<VoteEntr
|
||||
}
|
||||
|
||||
/**
|
||||
* 경기의 홈/원정 투표 카운트를 조회한다. 값이 없으면 0으로 채운다.
|
||||
* 경기의 홈/원정/무승부 투표 카운트를 조회한다. 값이 없으면 0으로 채운다.
|
||||
*
|
||||
* @param gameId - 경기 ID
|
||||
*/
|
||||
export async function getCounts(
|
||||
gameId: string
|
||||
): Promise<{ homeCount: number; awayCount: number }> {
|
||||
): 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<void> {
|
||||
const { gameId, uid, date, side, team } = params;
|
||||
const counterKey = side === "home" ? "homeCount" : "awayCount";
|
||||
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[`/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<void> {
|
||||
const { gameId, uid, date, oldSide, newSide, newTeam } = params;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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<string, unknown> = {};
|
||||
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 };
|
||||
|
||||
@ -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<SummaryCounts>(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<GameWithId[]> {
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
@ -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`);
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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" });
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user