From 4d1a46e5872f80a01acde24bfb3f5086b8536bc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Thu, 2 Jul 2026 11:29:40 +0900 Subject: [PATCH] Implement soft-delete for user accounts and add account purge job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 회원 탈퇴 시 즉시 삭제 대신 `active: false` 및 `deactivatedAt`을 기록하여 30일간 데이터를 유예하는 소프트 삭제 방식을 도입했습니다. - 경기 취소 시 투표를 삭제하는 대신 `cancelled: true`로 마킹하여 참여 흔적을 보존하고, 통계 집계에서만 제외하도록 로직을 변경했습니다. - 유예 기간이 지난 비활성 계정을 영구적으로 파기하는 `accountPurge` 스케줄러를 추가했습니다. --- firestore.indexes.json | 42 ++++++++++++++++ src/VOTE_FLOW.md | 21 ++++++-- src/handlers/predictionHandlers.ts | 14 +++++- src/index.ts | 1 + src/repositories/userRepository.ts | 39 +++++++++++++-- src/repositories/voteRepository.ts | 8 +++ src/scheduled/accountPurge.ts | 15 ++++++ src/scheduled/dailyArchive.ts | 26 ++++++---- src/services/chatToolService.ts | 4 +- src/services/statsService.ts | 2 + src/services/userService.ts | 64 +++++++++++++++++++++--- src/triggers/onGameCompleted.ts | 16 ++++-- src/types/panit.ts | 23 ++++++++- tests/services/scoreboardService.test.ts | 12 +++-- tests/services/userService.test.ts | 43 ++++++++++++++-- 15 files changed, 287 insertions(+), 43 deletions(-) create mode 100644 src/scheduled/accountPurge.ts diff --git a/firestore.indexes.json b/firestore.indexes.json index b0f1f4b..bb0d07d 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -55,6 +55,48 @@ { "fieldPath": "tierPoints", "order": "DESCENDING" } ] }, + { + "collectionGroup": "users", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "active", "order": "ASCENDING" }, + { "fieldPath": "tierPoints", "order": "DESCENDING" } + ] + }, + { + "collectionGroup": "users", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "active", "order": "ASCENDING" }, + { "fieldPath": "tierPoints", "order": "ASCENDING" } + ] + }, + { + "collectionGroup": "users", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "favoriteTeamCode", "order": "ASCENDING" }, + { "fieldPath": "active", "order": "ASCENDING" }, + { "fieldPath": "tierPoints", "order": "DESCENDING" } + ] + }, + { + "collectionGroup": "users", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "favoriteTeamCode", "order": "ASCENDING" }, + { "fieldPath": "active", "order": "ASCENDING" }, + { "fieldPath": "tierPoints", "order": "ASCENDING" } + ] + }, + { + "collectionGroup": "users", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "active", "order": "ASCENDING" }, + { "fieldPath": "deactivatedAt", "order": "ASCENDING" } + ] + }, { "collectionGroup": "users", "queryScope": "COLLECTION", diff --git a/src/VOTE_FLOW.md b/src/VOTE_FLOW.md index 104e3a9..b2d38db 100644 --- a/src/VOTE_FLOW.md +++ b/src/VOTE_FLOW.md @@ -73,9 +73,10 @@ 3. `deleteGameVotes(gameId)` — `/votes/{gameId}` 제거 (집계 데이터는 이후 불필요) 4. 전 유저 `invalidateStats` -### 4-b. cancelled 전이 → 정리 +### 4-b. cancelled 전이 → 무효 처리 `before.status !== "cancelled" && after.status === "cancelled"`일 때: -- 전 유저 `/userVotes/{uid}/{date}/{gameId}` 제거 +- 전 유저 `/userVotes/{uid}/{date}/{gameId}`를 `{ team, cancelled: true }`로 무효 마킹 + (참여 흔적 보존 — 판정·승률 집계에서는 제외) - `/votes/{gameId}` 제거 - 투표했던 유저들 `invalidateStats` @@ -87,18 +88,28 @@ 어제 날짜(`daysAgoKst(1)`)의 `/userVotes` 스캔: -1. 각 유저의 모든 경기 투표가 `result` 보유인지 확인 (`allJudged`) +1. 각 유저의 모든 경기 투표가 `result` 또는 `cancelled` 보유인지 확인 (`allJudged`) 2. **리컨실리에이션**: 미판정 경기가 있으면 `getGame`으로 Firestore 조회 후 분기 - `completed` + `winningTeamCode` → `processGameEndWithGame` 즉석 호출(트리거 누락 자가치유) - - `cancelled` → 해당 vote 항목 삭제 + - `cancelled` → 해당 vote 항목 무효(`cancelled: true`) 마킹 - `scheduled`/`live` → warn 로그 + 유저 스킵 (실데이터 이슈) -3. 모두 정리된 유저만 `voteHistory` 에 `setDay(uid, date, { data })` 저장 +3. 모두 정리된 유저만 `voteHistory` 에 `setDay(uid, date, { data })` 저장 — 무효표는 + `result` 없이 `cancelled: true`로 포함되고 판정·승률 집계에서 제외 4. RTDB `/userVotes/{uid}/{date}` 제거 + `invalidateStats` ## 6. 통계 응답 `stats` 핸들러 → `statsService`가 캐시 미스면 **`voteHistory` + 오늘 `/userVotes`**를 합산해 계산, 캐시 저장. +## 스코어보드 불변식 + +리스트(top10·totalCount)는 사전계산 스냅샷(RTDB `/scoreboardCache/{날짜}`), 내 순위(me)는 +요청 시점 라이브 카운트다. 둘이 항상 일치하는 근거는 **"tierPoints는 새벽 판정에서만 +변하고, 변경 직후 `precomputeScoreboardCache`가 반드시 실행된다"**는 불변식뿐이다. +백필·어드민 보정 등 파이프라인 밖에서 tierPoints를 변경했다면 반드시 +`GET /debug/dailyArchive`(또는 `precomputeScoreboardCache` 직접 호출)로 재계산할 것. +탈퇴(비활성화)도 이 불변식에 포함되어 `deleteMe`가 재계산을 호출한다. + ## 타이밍 요약 | 시각(KST) | 작업 | diff --git a/src/handlers/predictionHandlers.ts b/src/handlers/predictionHandlers.ts index 65bda9b..0c5274b 100644 --- a/src/handlers/predictionHandlers.ts +++ b/src/handlers/predictionHandlers.ts @@ -9,6 +9,16 @@ import { listGamesByDate, } from "../services/predictionService"; import { getScoreboard } from "../services/scoreboardService"; +import { kboTodayKst } from "../types/dateString"; + +/** + * `date` 쿼리 파라미터 해석. + * 생략·빈값·`today` 센티넬은 모두 KBO 기준일(03:00 KST 컷오프)로 치환한다. + */ +function resolveDateParam(raw: unknown): string { + const s = String(raw ?? ""); + return s === "" || s === "today" ? kboTodayKst() : s; +} export const prediction = onRequest(async (req, res) => { const segs = req.path.replace(/^\/+|\/+$/g, "").split("/"); @@ -16,7 +26,7 @@ export const prediction = onRequest(async (req, res) => { try { if (tail === "games" && req.method === "GET") { - const date = String(req.query.date ?? ""); + const date = resolveDateParam(req.query.date); const games = await listGamesByDate(date); res.status(200).json({ date, games }); return; @@ -57,7 +67,7 @@ export const prediction = onRequest(async (req, res) => { } if (req.method === "GET") { const uid = await requireAuth(req); - const date = String(req.query.date ?? ""); + const date = resolveDateParam(req.query.date); const result = await getMyVotes(uid, date); res.status(200).json(result); return; diff --git a/src/index.ts b/src/index.ts index 73cf41e..b36a3d5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,4 +13,5 @@ export { attendance } from "./handlers/attendanceHandlers"; export { chat } from "./handlers/chatHandlers"; export { kboDailyRefresh } from "./scheduled/kboRefresh"; export { dailyArchive } from "./scheduled/dailyArchive"; +export { accountPurge } from "./scheduled/accountPurge"; export { onGameCompleted } from "./triggers/onGameCompleted"; diff --git a/src/repositories/userRepository.ts b/src/repositories/userRepository.ts index 25baae8..86424a2 100644 --- a/src/repositories/userRepository.ts +++ b/src/repositories/userRepository.ts @@ -1,4 +1,4 @@ -import { FieldValue } from "firebase-admin/firestore"; +import { FieldValue, Timestamp } from "firebase-admin/firestore"; import { firestore } from "../firebase"; import type { DailyJudgment, @@ -85,7 +85,8 @@ export interface ScoreboardUserEntry { /** * `tierPoints` 상위 N명을 조회한다. `teamCode` 지정 시 해당 팀을 응원하는 - * 유저로 한정한다. `tierPoints`가 없는 유저는 결과에 포함되지 않는다. + * 유저로 한정한다. `tierPoints`가 없거나 0 이하인 유저(벤치워머)는 결과에 + * 포함되지 않는다 — `countRankedUsers`(분모)와 "랭킹 대상" 정의를 공유한다. * `rankSnapshot`도 함께 가져와 delta 계산에 사용 가능. */ export async function listTopByTierPoints( @@ -94,7 +95,11 @@ export async function listTopByTierPoints( ): Promise { let query: FirebaseFirestore.Query = firestore.collection(COLLECTION); if (teamCode) query = query.where("favoriteTeamCode", "==", teamCode); + // 비활성화(탈퇴) 계정은 랭킹에서 제외. + query = query.where("active", "==", true); const snap = await query + // 0pt는 랭킹 미노출 — 리스트 인원과 totalCount 분모가 정의상 일치한다. + .where("tierPoints", ">", 0) .orderBy("tierPoints", "desc") .limit(limit) .select( @@ -129,7 +134,11 @@ export async function countUsersAboveTierPoints( ): Promise { let query: FirebaseFirestore.Query = firestore.collection(COLLECTION); if (teamCode) query = query.where("favoriteTeamCode", "==", teamCode); - const agg = await query.where("tierPoints", ">", threshold).count().get(); + const agg = await query + .where("active", "==", true) + .where("tierPoints", ">", threshold) + .count() + .get(); return agg.data().count; } @@ -140,10 +149,31 @@ export async function countUsersAboveTierPoints( export async function countRankedUsers(teamCode?: TeamCode): Promise { let query: FirebaseFirestore.Query = firestore.collection(COLLECTION); if (teamCode) query = query.where("favoriteTeamCode", "==", teamCode); - const agg = await query.where("tierPoints", ">", 0).count().get(); + const agg = await query + .where("active", "==", true) + .where("tierPoints", ">", 0) + .count() + .get(); return agg.data().count; } +/** + * `cutoff` 이전에 비활성화된 계정 uid 목록. `accountPurge`의 파기 대상 조회용. + */ +export async function listDeactivatedBefore( + cutoff: Date, + limit = 100 +): Promise { + const snap = await firestore + .collection(COLLECTION) + .where("active", "==", false) + .where("deactivatedAt", "<=", Timestamp.fromDate(cutoff)) + .limit(limit) + .select() + .get(); + return snap.docs.map((d) => d.id); +} + /** * displayName으로 유저의 uid를 조회한다. 없으면 `null`. * 닉네임 유니크성 검증용 fallback. @@ -169,6 +199,7 @@ export async function createUser(uid: string, input: RegisterInput): Promise { await rtdb.ref(`/votes/${gameId}`).remove(); } +/** + * 유저의 날짜별 투표 인덱스 전체(`/userVotes/{uid}`)를 제거한다. + * 탈퇴 비활성화 시 아카이브가 비활성 계정을 판정하지 않도록 정리한다. + */ +export async function deleteUserVoteIndex(uid: string): Promise { + await rtdb.ref(`/userVotes/${uid}`).remove(); +} + /** * 유저별 일자 인덱스에서 특정 경기 항목을 제거한다. * 취소/리컨실리 경로에서 아카이브 대상에서 제외하기 위해 사용. diff --git a/src/scheduled/accountPurge.ts b/src/scheduled/accountPurge.ts new file mode 100644 index 0000000..0a2f823 --- /dev/null +++ b/src/scheduled/accountPurge.ts @@ -0,0 +1,15 @@ +import { onSchedule } from "firebase-functions/scheduler"; +import { logger } from "firebase-functions"; +import { purgeExpiredAccounts } from "../services/userService"; + +/** + * 매일 04:30 KST — 비활성화(탈퇴) 후 유예 기간(30일)이 지난 계정을 영구 파기한다. + * kboDailyRefresh(02:00)·dailyArchive(03:00)와 시간대를 겹치지 않게 배치. + */ +export const accountPurge = onSchedule( + { schedule: "30 4 * * *", timeZone: "Asia/Seoul", region: "asia-northeast3" }, + async () => { + const purged = await purgeExpiredAccounts(); + logger.info(`accountPurge done: ${purged.length} accounts purged`); + } +); diff --git a/src/scheduled/dailyArchive.ts b/src/scheduled/dailyArchive.ts index 6a26b92..68cf528 100644 --- a/src/scheduled/dailyArchive.ts +++ b/src/scheduled/dailyArchive.ts @@ -10,7 +10,6 @@ import { } from "../services/rankSnapshotService"; import { todayKst } from "../types/dateString"; import { getGame, createGameDayCache } from "../repositories/gameRepository"; -import { deleteUserVoteGame } from "../repositories/voteRepository"; import { processGameEndWithGame } from "../services/gameResultService"; import type { RankSnapshot, VoteHistoryDoc } from "../types/panit"; import { @@ -22,6 +21,8 @@ import { interface RawVote { team: string; result?: boolean; + /** 경기 취소로 무효 처리된 투표 — 판정에서 제외하되 기록에는 남긴다. */ + cancelled?: boolean; } type DayVotes = Record; @@ -29,10 +30,10 @@ type DayVotes = Record; /** * 미판정 경기들을 games 문서로 조회해 자가치유한다. * - completed + winningTeamCode: 즉석 processGameEndWithGame 호출 후 result 재주입. - * - cancelled: 유저 인덱스에서 제거. + * - cancelled: 무효(cancelled) 마킹 — 판정에서 빠지되 참여 흔적은 보존. * - 그 외: warn 로그 후 그대로 둔다(진짜 미판정). * - * 반환: 갱신된 dayVotes (result 채워졌거나 cancelled 제거된 상태). + * 반환: 갱신된 dayVotes (result 채워졌거나 cancelled 마킹된 상태). */ async function reconcileDayVotes( uid: string, @@ -41,7 +42,7 @@ async function reconcileDayVotes( ): Promise { const result: DayVotes = { ...dayVotes }; for (const [gameId, vote] of Object.entries(dayVotes)) { - if (vote.result !== undefined) continue; + if (vote.result !== undefined || vote.cancelled) continue; const game = await getGame(gameId); if (!game) { @@ -65,11 +66,11 @@ async function reconcileDayVotes( } catch (err) { logger.error(`reconcile: processGameEnd failed ${gameId}`, err); } - // 취소 경기 뒤늦게 감지: 아카이브 대상에서 제외해 allJudged 통과를 허용. + // 취소 경기 뒤늦게 감지: 무효 마킹으로 allJudged 통과를 허용하되 + // "픽했지만 취소됨"이라는 참여 흔적은 보존한다. } else if (game.status === "cancelled") { - await deleteUserVoteGame(uid, date, gameId); - delete result[gameId]; - logger.info(`reconcile: dropped cancelled ${gameId} (uid=${uid})`); + result[gameId] = { team: vote.team, cancelled: true }; + logger.info(`reconcile: voided cancelled ${gameId} (uid=${uid})`); } else { logger.warn( `reconcile: ${gameId} still ${game.status} (uid=${uid}, date=${date})` @@ -109,7 +110,7 @@ export async function runDailyArchive( if (!dayVotes) continue; const hasUnjudged = Object.values(dayVotes).some( - (v) => v.result === undefined + (v) => v.result === undefined && !v.cancelled ); if (hasUnjudged) { dayVotes = await reconcileDayVotes(uid, date, dayVotes); @@ -118,6 +119,11 @@ export async function runDailyArchive( const data: VoteHistoryDoc["data"] = []; let allJudged = true; for (const [gameId, vote] of Object.entries(dayVotes)) { + // 취소 무효표: 판정 계산에는 안 들어가지만 참여 흔적으로 기록에 남긴다. + if (vote.cancelled) { + data.push({ gameId, team: vote.team, cancelled: true }); + continue; + } if (vote.result === undefined) { allJudged = false; break; @@ -129,7 +135,7 @@ export async function runDailyArchive( continue; } - // 리컨실 결과 모든 경기가 cancelled로 제거된 경우에도 skip 판정은 남겨 + // 리컨실 결과 모든 경기가 취소 무효 처리된 경우에도 skip 판정은 남겨 // 스트릭 유지 로직이 동작하도록 judgeDay를 호출한다. // 판정 전 rank를 계산해(=현재 tierPoints 기준) judgeDay 트랜잭션에 함께 기록한다. // 판정 트랜잭션과 같은 patch로 묶여 user doc write가 1회로 준다("판정 전 rank" 보존). diff --git a/src/services/chatToolService.ts b/src/services/chatToolService.ts index edfdc5b..c91240d 100644 --- a/src/services/chatToolService.ts +++ b/src/services/chatToolService.ts @@ -253,7 +253,9 @@ export function formatPredictionBreakdown( ` ${g.awayScore}:${g.homeScore}` : ""; const pick = teamLabel(e.team as TeamCode); - return `- ${matchup}${score} → ${pick} 픽: ${e.result ? "적중" : "오답"}`; + // 취소 경기 무효표는 result가 없다 — 오답으로 말하지 않는다. + const outcome = e.cancelled ? "경기 취소(무효)" : e.result ? "적중" : "오답"; + return `- ${matchup}${score} → ${pick} 픽: ${outcome}`; }); const summary = doc.correctCount != null && doc.completedCount != null ? diff --git a/src/services/statsService.ts b/src/services/statsService.ts index 3bafbac..5b55c72 100644 --- a/src/services/statsService.ts +++ b/src/services/statsService.ts @@ -85,6 +85,8 @@ function aggregate(entries: Array<{ date: DateString; doc: VoteHistoryDoc }>): { let correct = 0; for (const {doc} of entries) { for (const v of doc.data) { + // 취소 경기 무효표(result 없음)는 승률·예측 수 집계에서 제외. + if (typeof v.result !== "boolean") continue; total += 1; if (v.result) correct += 1; } diff --git a/src/services/userService.ts b/src/services/userService.ts index 07798b3..ab50cf2 100644 --- a/src/services/userService.ts +++ b/src/services/userService.ts @@ -1,5 +1,6 @@ import type { DecodedIdToken } from "firebase-admin/auth"; import { FieldValue, Timestamp } from "firebase-admin/firestore"; +import { logger } from "firebase-functions"; import { HttpError } from "../middleware/errors"; import { auth } from "../firebase"; import { @@ -7,6 +8,7 @@ import { deleteUser, findUidByDisplayName, getUser, + listDeactivatedBefore, updateUser, } from "../repositories/userRepository"; import { @@ -15,6 +17,10 @@ import { reserveNickname, verifyReservation, } from "../repositories/nicknameRepository"; +import { deleteUserVoteIndex } from "../repositories/voteRepository"; +import { invalidateStats } from "./statsService"; +import { precomputeScoreboardCache } from "./rankSnapshotService"; +import { todayKst } from "../types/dateString"; import { KnowledgeLevel, NotificationKey, @@ -109,7 +115,8 @@ function samePhotoUrl(a: string | undefined, b: string | undefined): boolean { */ export async function getMe(token: DecodedIdToken): Promise { const user = await getUser(token.uid); - if (!user) { + // 비활성화(탈퇴) 계정은 미존재로 취급 — 잔여 토큰으로 접근해도 온보딩으로 유도. + if (!user || user.active === false) { throw new HttpError(404, "user not found", "USER_NOT_FOUND"); } @@ -188,23 +195,68 @@ export async function createMe( return profile; } +/** 비활성화 후 영구 파기까지의 유예 기간(일). 개인정보처리방침의 보존 기간과 일치해야 한다. */ +export const PURGE_GRACE_DAYS = 30; + /** - * 현재 로그인한 유저의 Firestore 문서(+ 하위 컬렉션)와 Firebase Auth 계정을 삭제한다. - * 존재하지 않으면 404 + `USER_NOT_FOUND`. + * 현재 로그인한 유저를 비활성화(소프트 삭제)한다. 존재하지 않으면 404. + * + * - user 문서는 남기고 `active: false` + `deactivatedAt`만 기록한다. 닉네임 점유는 + * 문서의 displayName으로 자연히 유지되고, 유예 기간 후 `accountPurge`가 파기한다. + * - Firebase Auth 계정은 즉시 삭제 — 같은 소셜 계정으로 재로그인하면 **새 uid**가 + * 발급되어 신규 가입(온보딩)으로 진입한다(재가입 정책: 기존 기록과 단절). + * - 랭킹 쿼리는 `active` 필터로 제외되고, 당일 스코어보드 캐시도 즉시 재계산한다. */ export async function deleteMe(token: DecodedIdToken): Promise { const existing = await getUser(token.uid); - if (!existing) { + if (!existing || existing.active === false) { throw new HttpError(404, "user not found", "USER_NOT_FOUND"); } - await deleteUser(token.uid); + await updateUser(token.uid, { + active: false, + deactivatedAt: FieldValue.serverTimestamp(), + }); await releaseReservation(token.uid); + // 아카이브가 비활성 계정을 판정하지 않도록 진행 중 투표 인덱스와 통계 캐시를 + // 정리한다. (경기별 투표 카운트는 익명 집계라 그대로 둔다.) + await deleteUserVoteIndex(token.uid); + await invalidateStats(token.uid).catch(() => undefined); try { await auth.deleteUser(token.uid); } catch (err) { const code = (err as { code?: string }).code; if (code !== "auth/user-not-found") throw err; } + // 사전계산된 랭킹 리스트에서 즉시 제외. 실패해도 다음 새벽 재계산이 정리한다. + try { + await precomputeScoreboardCache(todayKst()); + } catch (err) { + logger.error( + `deactivate: precomputeScoreboardCache failed uid=${token.uid}`, + err + ); + } +} + +/** + * 비활성화 후 유예 기간이 지난 계정을 영구 파기한다(문서+하위 컬렉션). + * 문서가 사라지면 닉네임 점유도 함께 풀린다. `now`는 테스트 주입용. + * + * @returns 파기된 uid 목록 + */ +export async function purgeExpiredAccounts( + now: Date = new Date() +): Promise { + const cutoff = new Date( + now.getTime() - PURGE_GRACE_DAYS * 24 * 60 * 60 * 1000 + ); + const uids = await listDeactivatedBefore(cutoff); + for (const uid of uids) { + await deleteUser(uid); + await releaseReservation(uid); + await invalidateStats(uid).catch(() => undefined); + } + return uids; } // ── 이름 변경 조건 ────────────────────────────────────────────── @@ -247,7 +299,7 @@ export async function updateMe( body: UpdateMeBody ): Promise { const user = await getUser(token.uid); - if (!user) { + if (!user || user.active === false) { throw new HttpError(404, "user not found", "USER_NOT_FOUND"); } diff --git a/src/triggers/onGameCompleted.ts b/src/triggers/onGameCompleted.ts index c682d80..a6a4c45 100644 --- a/src/triggers/onGameCompleted.ts +++ b/src/triggers/onGameCompleted.ts @@ -8,14 +8,15 @@ import { } from "../repositories/voteRepository"; import {invalidateStats} from "../services/statsService"; import {fromTimestamp} from "../types/dateString"; -import type {Game} from "../types/panit"; +import type {Game, VoteEntry} from "../types/panit"; /** * `games/{gameId}` 문서 업데이트를 받아 유저 투표를 정리하는 Firestore 트리거. * * 두 전이 경로를 커버한다: * - status → `completed` (+ winningTeamCode): `processGameEndWithGame`로 result 기록. - * - status → `cancelled`: 해당 경기의 유저 인덱스/카운트를 제거하여 아카이브 대상에서 제외. + * - status → `cancelled`: 유저 인덱스를 무효(cancelled) 마킹하고 실시간 카운트를 제거 + * — 판정·집계에서 빠지되 "픽했지만 취소됨"이라는 참여 흔적은 남는다. * * 전이 가드(이전 상태와 비교)로 동일 문서 재업데이트 시 중복 실행을 방지한다. */ @@ -49,9 +50,14 @@ export const onGameCompleted = onDocumentUpdated( const uids = Object.keys(votes); if (uids.length > 0) { - const updates: Record = {}; + // 삭제하지 않고 무효 마킹 — 기록 화면에 "픽했지만 취소"로 남고, + // 판정·승률 집계에서는 제외된다. + const updates: Record = {}; for (const uid of uids) { - updates[`/userVotes/${uid}/${date}/${gameId}`] = null; + updates[`/userVotes/${uid}/${date}/${gameId}`] = { + team: votes[uid].team, + cancelled: true, + }; } await rtdb.ref().update(updates); } @@ -59,7 +65,7 @@ export const onGameCompleted = onDocumentUpdated( await Promise.all( uids.map((uid) => invalidateStats(uid).catch(() => undefined)) ); - logger.info(`onGameCancelled: ${gameId} cleared ${uids.length} votes`); + logger.info(`onGameCancelled: ${gameId} voided ${uids.length} votes`); } catch (err) { logger.error(`onGameCancelled failed for ${gameId}`, err); } diff --git a/src/types/panit.ts b/src/types/panit.ts index df080ed..4131cdf 100644 --- a/src/types/panit.ts +++ b/src/types/panit.ts @@ -106,6 +106,16 @@ export interface User { /** 마지막 로그인 기기의 FCM 토큰. 클라가 직접 덮어쓰기. */ fcmToken?: string; + /** + * 계정 활성 여부. 탈퇴 시 삭제 대신 false(비활성화)로 전환된다 — 문서·기록은 + * 유예 기간 동안 보존되고, 랭킹 쿼리(`active == true` 필터)에서만 제외된다. + * 신규 가입 시 true로 생성되며, 필드가 없는 문서는 랭킹 쿼리에 잡히지 않으므로 + * 기존 유저는 백필이 필요하다. + */ + active?: boolean; + /** 비활성화 시각. 유예 기간(30일) 경과 시 `accountPurge` 배치가 영구 파기한다. */ + deactivatedAt?: Timestamp; + /** * 마지막 판정(`applyDailyJudgmentTx`) 시점 기준의 연속 참여일. * @@ -120,6 +130,14 @@ export interface User { currentStreak?: number; highestStreak?: number; + + /** + * 적중 누적 포인트(티어·스코어보드 축). 쓰기는 `applyDailyJudgmentTx`가 원칙. + * + * ⚠️ 불변식: 이 값을 변경하는 **모든 경로**(백필·어드민 보정 포함)는 마지막에 + * `precomputeScoreboardCache`를 호출해야 한다. 스코어보드 리스트는 사전계산 + * 스냅샷, 내 순위는 라이브 계산이라 재계산 없이 값만 바꾸면 둘이 어긋난다. + */ tierPoints?: number; tickets?: TicketMap; @@ -179,10 +197,13 @@ export interface Game { export interface VoteEntry { team: string; result?: boolean; + /** 경기 취소로 무효 처리된 투표 — 참여 흔적만 남고 판정·집계에서 제외된다. */ + cancelled?: boolean; } export interface VoteHistoryDoc { - data: Array<{ gameId: string; team: string; result: boolean }>; + /** `result` 없이 `cancelled: true`인 항목은 취소 경기 무효표 — 판정·집계 제외. */ + data: Array<{ gameId: string; team: string; result?: boolean; cancelled?: boolean }>; judgment?: DailyJudgment; correctCount?: number; completedCount?: number; diff --git a/tests/services/scoreboardService.test.ts b/tests/services/scoreboardService.test.ts index 56c7c59..2cf9d51 100644 --- a/tests/services/scoreboardService.test.ts +++ b/tests/services/scoreboardService.test.ts @@ -26,6 +26,8 @@ async function seedUser(u: SeedUser): Promise { email: `${u.uid}@example.com`, provider: "google", knowledgeLevel: "casual", + // 신규 가입 기본값과 동일 — 랭킹 쿼리의 active 필터를 통과해야 한다. + active: true, }; if (u.tierPoints !== undefined) doc.tierPoints = u.tierPoints; if (u.favoriteTeamCode) doc.favoriteTeamCode = u.favoriteTeamCode; @@ -229,15 +231,15 @@ describe("rankSnapshotService.snapshotRankForUser", () => { it("overall/team rank를 rankSnapshot에 기록한다", async () => { await firestore.collection("users").doc("a").set({ displayName: "A", email: "a@e", provider: "google", knowledgeLevel: "casual", - tierPoints: 200, favoriteTeamCode: TeamCode.LG, + active: true, tierPoints: 200, favoriteTeamCode: TeamCode.LG, }); await firestore.collection("users").doc("b").set({ displayName: "B", email: "b@e", provider: "google", knowledgeLevel: "casual", - tierPoints: 100, favoriteTeamCode: TeamCode.LG, + active: true, tierPoints: 100, favoriteTeamCode: TeamCode.LG, }); await firestore.collection("users").doc("c").set({ displayName: "C", email: "c@e", provider: "google", knowledgeLevel: "casual", - tierPoints: 150, favoriteTeamCode: TeamCode.KT, + active: true, tierPoints: 150, favoriteTeamCode: TeamCode.KT, }); await snapshotRankForUser("b", "2026-04-22" as DateString); @@ -255,7 +257,7 @@ describe("rankSnapshotService.snapshotRankForUser", () => { it("tierPoints=0이면 스냅샷을 남기지 않는다", async () => { await firestore.collection("users").doc("zero").set({ displayName: "Z", email: "z@e", provider: "google", knowledgeLevel: "casual", - tierPoints: 0, + active: true, tierPoints: 0, }); await snapshotRankForUser("zero", "2026-04-22" as DateString); @@ -267,7 +269,7 @@ describe("rankSnapshotService.snapshotRankForUser", () => { it("favoriteTeamCode가 없으면 team 필드 없이 overall만 기록", async () => { await firestore.collection("users").doc("solo").set({ displayName: "S", email: "s@e", provider: "google", knowledgeLevel: "casual", - tierPoints: 50, + active: true, tierPoints: 50, }); await snapshotRankForUser("solo", "2026-04-22" as DateString); diff --git a/tests/services/userService.test.ts b/tests/services/userService.test.ts index 593e296..1675fd4 100644 --- a/tests/services/userService.test.ts +++ b/tests/services/userService.test.ts @@ -6,6 +6,8 @@ import { createMe, deleteMe, getMe, + purgeExpiredAccounts, + PURGE_GRACE_DAYS, updateMe, } from "../../src/services/userService"; import { HttpError } from "../../src/middleware/errors"; @@ -132,8 +134,8 @@ describe("userService", () => { }); }); - describe("deleteMe", () => { - it("유저 문서와 하위 컬렉션을 삭제한다", async () => { + describe("deleteMe (비활성화)", () => { + it("문서는 남기고 active:false + deactivatedAt만 기록한다 (기록·닉네임 점유 보존)", async () => { await createMeWithReservation(); await firestore .collection("users").doc(uid) @@ -142,11 +144,19 @@ describe("userService", () => { await deleteMe(fakeToken()); - await expect(getMe(fakeToken())).rejects.toMatchObject({ code: "USER_NOT_FOUND" }); + const snap = await firestore.collection("users").doc(uid).get(); + expect(snap.exists).toBe(true); + expect(snap.data()?.active).toBe(false); + expect(snap.data()?.deactivatedAt).toBeTruthy(); + // 기록은 파기 전까지 보존된다. const sub = await firestore .collection("users").doc(uid) .collection("voteHistory").get(); - expect(sub.empty).toBe(true); + expect(sub.empty).toBe(false); + // 비활성 계정은 조회 API에서 미존재로 취급 → 재가입(온보딩) 유도. + await expect(getMe(fakeToken())).rejects.toMatchObject({ code: "USER_NOT_FOUND" }); + // 이미 비활성화된 계정의 중복 탈퇴 요청도 404. + await expect(deleteMe(fakeToken())).rejects.toMatchObject({ code: "USER_NOT_FOUND" }); }); it("미존재 유저는 404 + USER_NOT_FOUND", async () => { @@ -157,6 +167,31 @@ describe("userService", () => { code: "USER_NOT_FOUND", }); }); + + it("유예 기간이 지난 계정만 purge가 영구 파기한다", async () => { + await createMeWithReservation(); + await firestore + .collection("users").doc(uid) + .collection("voteHistory").doc("2026-04-12") + .set({ data: [] }); + await deleteMe(fakeToken()); + + // 유예 기간 내 → 파기 대상 아님. + expect(await purgeExpiredAccounts()).toEqual([]); + + // 유예 기간 +1일 시점 → 파기. + const later = new Date( + Date.now() + (PURGE_GRACE_DAYS + 1) * 24 * 60 * 60 * 1000 + ); + expect(await purgeExpiredAccounts(later)).toEqual([uid]); + + const snap = await firestore.collection("users").doc(uid).get(); + expect(snap.exists).toBe(false); + const sub = await firestore + .collection("users").doc(uid) + .collection("voteHistory").get(); + expect(sub.empty).toBe(true); + }); }); describe("checkNickname", () => {