Implement soft-delete for user accounts and add account purge job
- 회원 탈퇴 시 즉시 삭제 대신 `active: false` 및 `deactivatedAt`을 기록하여 30일간 데이터를 유예하는 소프트 삭제 방식을 도입했습니다. - 경기 취소 시 투표를 삭제하는 대신 `cancelled: true`로 마킹하여 참여 흔적을 보존하고, 통계 집계에서만 제외하도록 로직을 변경했습니다. - 유예 기간이 지난 비활성 계정을 영구적으로 파기하는 `accountPurge` 스케줄러를 추가했습니다.
This commit is contained in:
parent
e980410858
commit
4d1a46e587
@ -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",
|
||||
|
||||
@ -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) | 작업 |
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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<ScoreboardUserEntry[]> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<string[]> {
|
||||
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<voi
|
||||
email: input.email,
|
||||
provider: input.provider,
|
||||
knowledgeLevel: input.knowledgeLevel,
|
||||
active: true,
|
||||
createdAt: FieldValue.serverTimestamp(),
|
||||
};
|
||||
if (input.photoUrl) doc.photoUrl = input.photoUrl;
|
||||
|
||||
@ -122,6 +122,14 @@ export async function deleteGameVotes(gameId: string): Promise<void> {
|
||||
await rtdb.ref(`/votes/${gameId}`).remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* 유저의 날짜별 투표 인덱스 전체(`/userVotes/{uid}`)를 제거한다.
|
||||
* 탈퇴 비활성화 시 아카이브가 비활성 계정을 판정하지 않도록 정리한다.
|
||||
*/
|
||||
export async function deleteUserVoteIndex(uid: string): Promise<void> {
|
||||
await rtdb.ref(`/userVotes/${uid}`).remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* 유저별 일자 인덱스에서 특정 경기 항목을 제거한다.
|
||||
* 취소/리컨실리 경로에서 아카이브 대상에서 제외하기 위해 사용.
|
||||
|
||||
15
src/scheduled/accountPurge.ts
Normal file
15
src/scheduled/accountPurge.ts
Normal file
@ -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`);
|
||||
}
|
||||
);
|
||||
@ -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<string, RawVote>;
|
||||
@ -29,10 +30,10 @@ type DayVotes = Record<string, RawVote>;
|
||||
/**
|
||||
* 미판정 경기들을 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<DayVotes> {
|
||||
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" 보존).
|
||||
|
||||
@ -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 ?
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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<UserProfile> {
|
||||
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<void> {
|
||||
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<string[]> {
|
||||
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<UserProfile> {
|
||||
const user = await getUser(token.uid);
|
||||
if (!user) {
|
||||
if (!user || user.active === false) {
|
||||
throw new HttpError(404, "user not found", "USER_NOT_FOUND");
|
||||
}
|
||||
|
||||
|
||||
@ -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<string, null> = {};
|
||||
// 삭제하지 않고 무효 마킹 — 기록 화면에 "픽했지만 취소"로 남고,
|
||||
// 판정·승률 집계에서는 제외된다.
|
||||
const updates: Record<string, VoteEntry> = {};
|
||||
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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -26,6 +26,8 @@ async function seedUser(u: SeedUser): Promise<void> {
|
||||
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);
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user