Compare commits
10 Commits
ad38a47a64
...
628053f72b
| Author | SHA1 | Date | |
|---|---|---|---|
| 628053f72b | |||
| 89e817206b | |||
| 90d6895de4 | |||
| d2df63e63c | |||
| ca5c4c69b9 | |||
| 513bf70e87 | |||
| eb44f50886 | |||
| ed573a81e0 | |||
| aa53a5b070 | |||
| a9634b7229 |
@ -15,6 +15,14 @@
|
||||
".write": false
|
||||
}
|
||||
},
|
||||
"userVotesByDate": {
|
||||
".read": false,
|
||||
".write": false
|
||||
},
|
||||
"userVotesByDateMeta": {
|
||||
".read": false,
|
||||
".write": false
|
||||
},
|
||||
"cache": {
|
||||
".read": false,
|
||||
".write": false
|
||||
|
||||
@ -17,9 +17,9 @@
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 주요 필드 | `displayName, email, provider, knowledgeLevel, photoUrl, favoriteTeamCode, createdAt` (생성), `currentStreak, highestStreak, tierPoints, lastJudgedDate, rankSnapshot, notifications` (운영 중 갱신) |
|
||||
| 주요 필드 | `displayName, email, provider, knowledgeLevel, photoUrl, favoriteTeamCode, createdAt` (생성), `currentStreak, highestStreak, tierPoints, lastJudgedDate, rankSnapshot, notifications, marketingOptIn, marketingOptInAt` (운영 중 갱신) |
|
||||
| Writer / 위치 | `createUser` `userRepository.ts:128-139` · `updateUser` `:145-150` · `applyDailyJudgmentTx` `:182-254` · `deleteUser` `:155-157` · `snapshotRankForUser` `rankSnapshotService.ts:26-54` · `settleUsers` `seasonRepository.ts`(tierPoints=0 리셋 + rankSnapshot 삭제) |
|
||||
| Trigger | 생성=POST `/user`(`createMe`, userService.ts:120) · 갱신=PATCH `/user`(`updateMe`), PATCH `/user/notifications`, **GET `/user`(`getMe`) 의 photoUrl 자동 동기화**(userService.ts:101-105) · 판정=`dailyArchive` cron→`judgeDay`→`applyDailyJudgmentTx` · rankSnapshot=`dailyArchive` cron(judge **이전**) · 시즌 리셋=`dailyArchive` cron→`maybeSettleSeason`(시즌 endDate 다음 날 1회, §1.8) · 삭제=DELETE `/user` |
|
||||
| Trigger | 생성=POST `/user`(`createMe`, userService.ts:120) · 갱신=PATCH `/user`(`updateMe` — `marketingOptIn` 포함, 동의 시각은 서버 serverTimestamp 로만 기록·철회 시 보존), PATCH `/user/notifications`(키 whitelist=`NotificationKey`: attendance/predictionRemind/jjaekTalk, **patch 키만 merge 기록**), **GET `/user`(`getMe`) 의 photoUrl 자동 동기화**(userService.ts:101-105) · 판정=`dailyArchive` cron→`judgeDay`→`applyDailyJudgmentTx` · rankSnapshot=`dailyArchive` cron(judge **이전**) · 시즌 리셋=`dailyArchive` cron→`maybeSettleSeason`(시즌 endDate 다음 날 1회, §1.8) · 삭제=DELETE `/user` |
|
||||
| Mechanism | 생성 `set({merge:false})`; 일반 갱신 `set({merge:true})`; 판정 `runTransaction`+`set(merge:true)`+`FieldValue.serverTimestamp()`(createdAt); 삭제 `recursiveDelete`(서브컬렉션 voteHistory/attendance/pointLedger 포함) |
|
||||
| 빈도/볼륨 | 가입 1회/유저 · 프로필 수정 드묾 · **rankSnapshot + 판정 = 유저당 매일 2회 write** (전체 활성 유저 N명 × 매일) |
|
||||
| 비용 관찰 | ⚠️ **`getMe`(읽기 경로)에서 토큰 사진이 다르면 매 요청 write 발생** — 사진 변경이 잦은 토큰이면 read마다 hot write. ⚠️ `rankSnapshot` write(archive 중)와 `applyDailyJudgmentTx` write가 **같은 doc을 같은 cron run에서 2번** 건드림 → 1 write로 합칠 여지. |
|
||||
|
||||
@ -14,7 +14,8 @@
|
||||
"tools:sheet": "npx tsx scripts/chat-tools-sheet.ts",
|
||||
"seed:rewards": "npx tsx scripts/seed-reward-products.ts",
|
||||
"upload:reward-assets": "npx tsx scripts/upload-reward-product-assets.ts",
|
||||
"optimize:reward-images": "npx tsx scripts/optimize-reward-product-images.ts"
|
||||
"optimize:reward-images": "npx tsx scripts/optimize-reward-product-images.ts",
|
||||
"backfill:vote-index": "npx tsx scripts/backfill-user-votes-by-date.ts"
|
||||
},
|
||||
"engines": {
|
||||
"node": "24"
|
||||
|
||||
@ -7,14 +7,19 @@
|
||||
* gcloud auth application-default login
|
||||
* 또는 GOOGLE_APPLICATION_CREDENTIALS=<서비스계정.json>
|
||||
*
|
||||
* 도구는 RTDB를 쓰지 않지만 src/firebase가 getDatabase()를 호출하므로
|
||||
* databaseURL이 없으면 import 시점에 throw한다 — 그래서 형식상 URL을 채워준다.
|
||||
* databaseURL은 반드시 **리전을 포함**해야 한다. 리전 없는 기본 호스트
|
||||
* (`...firebaseio.com`)로 붙으면 SDK가 "Database lives in a different region"만
|
||||
* 경고하고 다른 리전의 (존재하지 않는) DB를 보게 되어, 조회가 조용히 빈 결과를
|
||||
* 돌려준다 — 그 값을 사실로 믿는 스크립트는 위험한 판단을 내릴 수 있다.
|
||||
* 리전은 FIREBASE_DATABASE_REGION, 전체 URL은 FIREBASE_DATABASE_URL로 덮어쓴다.
|
||||
*/
|
||||
import { initializeApp, getApps } from "firebase-admin/app";
|
||||
|
||||
const projectId = process.env.FIREBASE_PROJECT ?? process.env.GCLOUD_PROJECT ?? "mmday-panit";
|
||||
const databaseRegion = process.env.FIREBASE_DATABASE_REGION ?? "asia-southeast1";
|
||||
const databaseURL =
|
||||
process.env.FIREBASE_DATABASE_URL ?? `https://${projectId}-default-rtdb.firebaseio.com`;
|
||||
process.env.FIREBASE_DATABASE_URL ??
|
||||
`https://${projectId}-default-rtdb.${databaseRegion}.firebasedatabase.app`;
|
||||
|
||||
if (getApps().length === 0) {
|
||||
initializeApp({ projectId, databaseURL });
|
||||
|
||||
101
scripts/backfill-user-votes-by-date.ts
Normal file
101
scripts/backfill-user-votes-by-date.ts
Normal file
@ -0,0 +1,101 @@
|
||||
/**
|
||||
* `/userVotes/{uid}/{date}/{gameId}` → `/userVotesByDate/{date}/{uid}/{gameId}` 백필.
|
||||
*
|
||||
* `dailyArchive`는 날짜별 인덱스만 읽는다(원래는 하루치를 위해 `/userVotes` 트리
|
||||
* 전체를 내려받았다). 인덱스 도입 이전에 기록된 투표는 이 스크립트로 옮겨야
|
||||
* 잡의 레거시 폴백 경로를 타지 않는다.
|
||||
*
|
||||
* 멱등하다 — 여러 번 돌려도 같은 값으로 덮어쓸 뿐이다.
|
||||
*
|
||||
* 실행:
|
||||
* npx tsx scripts/backfill-user-votes-by-date.ts # 미리보기
|
||||
* npx tsx scripts/backfill-user-votes-by-date.ts --apply # 실제 쓰기
|
||||
* npx tsx scripts/backfill-user-votes-by-date.ts --apply --mark-empty # 소스가 빈 상태에서 마커만
|
||||
*
|
||||
* ⚠️ 소스(/userVotes)가 비었을 때는 마커를 자동으로 남기지 않는다. 빈 조회는
|
||||
* "정말 투표가 없다"와 "엉뚱한 DB에 붙었다"를 구분하지 못하는데, 후자에서 마커가
|
||||
* 켜지면 이후 아카이브가 인덱스만 믿고 전 유저를 건너뛴다. 비었다는 것을 사람이
|
||||
* 확인했을 때만 --mark-empty로 명시한다.
|
||||
*/
|
||||
import "./_bootstrap";
|
||||
import { rtdb } from "../src/firebase";
|
||||
|
||||
type VoteEntry = { team: string; result?: boolean; cancelled?: boolean };
|
||||
type ByUid = Record<string, Record<string, Record<string, VoteEntry>>>;
|
||||
|
||||
/** RTDB update는 한 번에 너무 많은 경로를 담으면 실패하므로 나눠 커밋한다. */
|
||||
const CHUNK = 500;
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const apply = process.argv.includes("--apply");
|
||||
const markEmpty = process.argv.includes("--mark-empty");
|
||||
|
||||
console.log(`database: ${rtdb.app.options.databaseURL}`);
|
||||
|
||||
const snap = await rtdb.ref("/userVotes").get();
|
||||
if (!snap.exists()) {
|
||||
console.log("no /userVotes data — nothing to backfill");
|
||||
if (!apply) return;
|
||||
if (!markEmpty) {
|
||||
console.log(
|
||||
"마커를 남기지 않았다. 위 database URL이 맞고 정말 투표가 없다면 --mark-empty를 붙여 다시 실행할 것."
|
||||
);
|
||||
return;
|
||||
}
|
||||
await markBackfilled();
|
||||
return;
|
||||
}
|
||||
|
||||
const byUid = snap.val() as ByUid;
|
||||
const updates: Record<string, unknown> = {};
|
||||
let uidCount = 0;
|
||||
let entryCount = 0;
|
||||
const dates = new Set<string>();
|
||||
|
||||
for (const uid of Object.keys(byUid)) {
|
||||
const byDate = byUid[uid] ?? {};
|
||||
let touched = false;
|
||||
for (const date of Object.keys(byDate)) {
|
||||
const games = byDate[date] ?? {};
|
||||
for (const gameId of Object.keys(games)) {
|
||||
updates[`/userVotesByDate/${date}/${uid}/${gameId}`] = games[gameId];
|
||||
entryCount += 1;
|
||||
dates.add(date);
|
||||
touched = true;
|
||||
}
|
||||
}
|
||||
if (touched) uidCount += 1;
|
||||
}
|
||||
|
||||
const paths = Object.keys(updates);
|
||||
console.log(
|
||||
`${entryCount} entries / ${uidCount} uids / ${dates.size} dates` +
|
||||
(apply ? "" : " (dry run — pass --apply to write)")
|
||||
);
|
||||
if (!apply) return;
|
||||
|
||||
for (let i = 0; i < paths.length; i += CHUNK) {
|
||||
const slice: Record<string, unknown> = {};
|
||||
for (const p of paths.slice(i, i + CHUNK)) slice[p] = updates[p];
|
||||
await rtdb.ref().update(slice);
|
||||
console.log(` written ${Math.min(i + CHUNK, paths.length)}/${paths.length}`);
|
||||
}
|
||||
await markBackfilled();
|
||||
console.log("backfill complete");
|
||||
}
|
||||
|
||||
/**
|
||||
* 완료 표시. 이 값이 있으면 `dailyArchive`는 인덱스가 비어 있어도
|
||||
* "그날 투표가 없었다"로 해석하고 레거시 전체 스캔 폴백을 쓰지 않는다.
|
||||
*/
|
||||
async function markBackfilled(): Promise<void> {
|
||||
await rtdb.ref("/userVotesByDateMeta/backfilledAt").set(new Date().toISOString());
|
||||
console.log("marked /userVotesByDateMeta/backfilledAt");
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -36,6 +36,9 @@ export const prediction = onRequest(async (req, res) => {
|
||||
const date = resolveDateParam(req.query.date);
|
||||
const games = await listGamesByDate(date);
|
||||
const dto: GamesResponseDto = { date, games };
|
||||
// 무인증·전 유저 공통 응답 — CDN/브라우저에서 중복 요청을 합칠 수 있게 한다.
|
||||
// 경기 중 스코어 변동을 고려해 서버 캐시(15초)와 같은 수준으로 짧게 잡는다.
|
||||
res.set("Cache-Control", "public, max-age=15");
|
||||
res.status(200).json(dto);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -28,6 +28,8 @@ function toMeResponse(uid: string, profile: UserProfileDto): MeResponseDto {
|
||||
knowledgeLevel: profile.knowledgeLevel,
|
||||
createdAt: profile.createdAt,
|
||||
lastJudgedDate: profile.lastJudgedDate,
|
||||
notifications: profile.notifications,
|
||||
marketingOptIn: profile.marketingOptIn,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -14,13 +14,23 @@ export class MemCache<T> {
|
||||
) {}
|
||||
|
||||
get(key: string): T | null {
|
||||
return this.peek(key)?.data ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 히트 여부와 값을 함께 돌려준다.
|
||||
*
|
||||
* `get`은 캐시된 `null`·`0`·`""`를 미스와 구분할 수 없다. negative 캐싱처럼
|
||||
* falsy 값을 캐시하는 경로는 이쪽을 써야 fetcher가 매번 다시 돌지 않는다.
|
||||
*/
|
||||
peek(key: string): { data: T } | null {
|
||||
const e = this.cache.get(key);
|
||||
if (!e) return null;
|
||||
if (Date.now() > e.expiresAt) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return e.data;
|
||||
return { data: e.data };
|
||||
}
|
||||
|
||||
set(key: string, data: T): void {
|
||||
@ -44,12 +54,19 @@ export class MemCache<T> {
|
||||
this.inflight.delete(key);
|
||||
}
|
||||
|
||||
/** 전체 엔트리를 버린다. 여러 key에 영향을 주는 쓰기 이후의 일괄 무효화용. */
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
this.inflight.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 조회 → miss면 fetcher 실행 (동일 key 중복 호출 방지).
|
||||
*/
|
||||
async getOrFetch(key: string, fetcher: () => Promise<T>): Promise<T> {
|
||||
const cached = this.get(key);
|
||||
if (cached) return cached;
|
||||
// truthy 검사가 아니라 히트 여부로 판정한다 — 캐시된 null/0/""도 서빙된다.
|
||||
const cached = this.peek(key);
|
||||
if (cached) return cached.data;
|
||||
|
||||
const existing = this.inflight.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
@ -11,6 +11,16 @@ export class HttpError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Firestore ALREADY_EXISTS 판별 — `create()`를 멱등 쓰기로 쓰는 경로에서
|
||||
* "이미 존재"를 정상 분기로 처리하기 위한 것. 레포지토리·서비스 양쪽이 쓰므로
|
||||
* 계층 역전이 없는 여기에 둔다.
|
||||
*/
|
||||
export function isAlreadyExistsError(err: unknown): boolean {
|
||||
const code = (err as { code?: unknown })?.code;
|
||||
return code === 6 || code === "already-exists" || code === "ALREADY_EXISTS";
|
||||
}
|
||||
|
||||
export function sendError(res: express.Response, err: unknown): void {
|
||||
if (err instanceof HttpError) {
|
||||
const body: Record<string, unknown> = err.code
|
||||
|
||||
@ -2,7 +2,7 @@ import { randomBytes, createHash } from "node:crypto";
|
||||
import { FieldPath, Timestamp } from "firebase-admin/firestore";
|
||||
import { ServerValue } from "firebase-admin/database";
|
||||
import { firestore, rtdb } from "../firebase";
|
||||
import { HttpError } from "../middleware/errors";
|
||||
import { HttpError, isAlreadyExistsError } from "../middleware/errors";
|
||||
import { MemCache } from "../lib/memCache";
|
||||
import type {
|
||||
ChatMessageDoc,
|
||||
@ -98,8 +98,11 @@ export interface ReserveParams {
|
||||
|
||||
export type ReserveOutcome =
|
||||
| { kind: "done"; assistantMessageId: string; threadId: string }
|
||||
/** threadId는 pin된 값 — 크래시 재개 시 원래 예약의 스레드를 그대로 쓴다(§3.1 처리 5). */
|
||||
| { kind: "reserved"; threadId: string; debited: boolean; resumed: boolean };
|
||||
/**
|
||||
* threadId는 pin된 값 — 크래시 재개 시 원래 예약의 스레드를 그대로 쓴다(§3.1 처리 5).
|
||||
* `used`는 차감 반영 후 값 — 응답 조립이 쿼터 문서를 다시 읽지 않도록 함께 돌려준다.
|
||||
*/
|
||||
| { kind: "reserved"; threadId: string; debited: boolean; resumed: boolean; used: number };
|
||||
|
||||
/**
|
||||
* 멱등 예약·레이트리밋·한도 차감을 단일 Firestore 트랜잭션으로 원자 수행한다.
|
||||
@ -136,7 +139,9 @@ export async function reserveRequestTx(params: ReserveParams): Promise<ReserveOu
|
||||
const stillDebited = req.debited && !req.refunded;
|
||||
if (stillDebited || params.crisisPath) {
|
||||
tx.update(reqRef, { createdAt: now });
|
||||
return { kind: "reserved", threadId: req.threadId, debited: stillDebited, resumed: true };
|
||||
// 이 분기는 쿼터를 갱신하지 않으므로 읽은 값이 그대로 현재 값이다.
|
||||
const keptUsed = ((quotaSnap.data() ?? {}) as Partial<ChatQuotaDoc>).used ?? 0;
|
||||
return { kind: "reserved", threadId: req.threadId, debited: stillDebited, resumed: true, used: keptUsed };
|
||||
}
|
||||
const resumeQuota = (quotaSnap.data() ?? {}) as Partial<ChatQuotaDoc>;
|
||||
const resumeUsed = resumeQuota.used ?? 0;
|
||||
@ -152,7 +157,7 @@ export async function reserveRequestTx(params: ReserveParams): Promise<ReserveOu
|
||||
limit: params.limit,
|
||||
updatedAt: now,
|
||||
}, { merge: true });
|
||||
return { kind: "reserved", threadId: req.threadId, debited: true, resumed: true };
|
||||
return { kind: "reserved", threadId: req.threadId, debited: true, resumed: true, used: resumeUsed + 1 };
|
||||
}
|
||||
|
||||
const quota = (quotaSnap.data() ?? {}) as Partial<ChatQuotaDoc>;
|
||||
@ -208,7 +213,7 @@ export async function reserveRequestTx(params: ReserveParams): Promise<ReserveOu
|
||||
updatedAt: now,
|
||||
}, { merge: true });
|
||||
|
||||
return { kind: "reserved", threadId: params.threadId, debited, resumed: false };
|
||||
return { kind: "reserved", threadId: params.threadId, debited, resumed: false, used: debited ? used + 1 : used };
|
||||
});
|
||||
}
|
||||
|
||||
@ -304,6 +309,11 @@ export interface ExchangeParams {
|
||||
/** assistant 화면 이동 액션(마커에서 추출). 비었으면 미저장. */
|
||||
actions?: NavAction[];
|
||||
retentionDays: number;
|
||||
/**
|
||||
* 스레드 문서 존재 여부. `createdAt` 보존 판단에만 쓴다. 같은 요청에서 이미
|
||||
* 읽었다면 넘겨서 중복 read를 없앤다. 생략 시 직접 조회한다.
|
||||
*/
|
||||
threadExists?: boolean;
|
||||
}
|
||||
|
||||
export interface ExchangeResult {
|
||||
@ -324,7 +334,9 @@ export async function finalizeExchange(params: ExchangeParams): Promise<Exchange
|
||||
|
||||
const batch = firestore.batch();
|
||||
const tRef = threadRef(params.uid, params.threadId);
|
||||
const threadExists = (await tRef.get()).exists;
|
||||
// 호출자가 이미 스레드 문서를 읽었으면(loadHistory 경유 정상 경로) 그 값을 쓴다.
|
||||
// 위기 경로는 loadHistory를 거치지 않으므로 여기서 직접 읽는다.
|
||||
const threadExists = params.threadExists ?? (await tRef.get()).exists;
|
||||
|
||||
const userDoc: ChatMessageDoc = {
|
||||
role: "user",
|
||||
@ -459,17 +471,31 @@ export async function upsertReport(
|
||||
): Promise<void> {
|
||||
const ref = firestore.collection(REPORTS).doc(`${uid}_${messageId}`);
|
||||
const now = Timestamp.now();
|
||||
const existing = await ref.get();
|
||||
const doc: ChatReportDoc = {
|
||||
uid,
|
||||
messageId,
|
||||
reason,
|
||||
...(comment ? { comment } : {}),
|
||||
status: "open",
|
||||
createdAt: existing.exists ? (existing.data() as ChatReportDoc).createdAt : now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await ref.set(doc);
|
||||
// 신규면 create가 성공하고, 재신고면 ALREADY_EXISTS로 떨어져 merge 갱신한다.
|
||||
// createdAt 보존을 위해 사전 read를 하던 것을 대체한다 — 최초 신고는 read 0회.
|
||||
try {
|
||||
await ref.create(doc);
|
||||
} catch (err) {
|
||||
if (!isAlreadyExistsError(err)) throw err;
|
||||
// 재신고는 createdAt을 건드리지 않는다 — 최초 신고 시각을 보존.
|
||||
await ref.set({
|
||||
uid,
|
||||
messageId,
|
||||
reason,
|
||||
...(comment ? { comment } : {}),
|
||||
status: "open",
|
||||
updatedAt: now,
|
||||
}, { merge: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ── 전역 호출·토큰 카운터(RTDB, §8.3) ──
|
||||
|
||||
@ -1,10 +1,23 @@
|
||||
import { Timestamp } from "firebase-admin/firestore";
|
||||
import { firestore } from "../firebase";
|
||||
import { MemCache } from "../lib/memCache";
|
||||
import type { Game } from "../types/panit";
|
||||
import { startOfDayKst, type DateString } from "../types/dateString";
|
||||
|
||||
const COLLECTION = "games";
|
||||
|
||||
/**
|
||||
* 날짜별 games 조회 결과의 인스턴스 공유 캐시.
|
||||
*
|
||||
* 날짜별 경기 목록은 유저와 무관한 전역 데이터이므로 요청 간에 공유할 수 있다.
|
||||
* `GET /prediction/games`처럼 대부분의 트래픽이 같은 날짜(오늘)로 수렴하는 경로에서
|
||||
* 요청마다 range 쿼리를 재발행하지 않도록 한다.
|
||||
*
|
||||
* TTL이 짧은 이유: 경기 중 스코어·상태가 바뀐다. 쓰기 경로는
|
||||
* `invalidateGameDay`로 즉시 무효화하므로 TTL은 누락된 무효화의 안전망이다.
|
||||
*/
|
||||
const dayCache = new MemCache<GameWithId[]>(15_000, 64);
|
||||
|
||||
export type GameWithId = Game & { gameId: string };
|
||||
|
||||
/**
|
||||
@ -37,11 +50,41 @@ export async function listByDate(date: DateString): Promise<GameWithId[]> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 동일 날짜의 `listByDate` 결과를 메모이즈하는 캐시.
|
||||
* `listByDate`의 공유 캐시 래퍼. 요청 경로는 이쪽을 쓴다.
|
||||
*
|
||||
* 동일 날짜 동시 요청은 `MemCache`의 inflight 병합으로 단일 쿼리가 된다.
|
||||
*/
|
||||
export async function listByDateCached(date: DateString): Promise<GameWithId[]> {
|
||||
return dayCache.getOrFetch(date, () => listByDate(date));
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정 날짜의 games 캐시를 버린다. `games` 문서를 쓰는 경로에서 호출한다.
|
||||
*
|
||||
* 무효화를 빠뜨리면 최대 TTL(15초)만큼 stale 스코어가 노출되므로,
|
||||
* 경기 문서를 쓰는 새 경로를 추가할 때 함께 호출할 것.
|
||||
*/
|
||||
export function invalidateGameDay(date: DateString): void {
|
||||
dayCache.delete(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 날짜에 걸쳐 games를 쓰는 경로(월 단위 동기화 등)에서 캐시 전체를 버린다.
|
||||
* 캐시는 최대 64개 날짜뿐이라 전량 폐기 비용이 날짜 추출 비용보다 싸다.
|
||||
*/
|
||||
export function invalidateAllGameDays(): void {
|
||||
dayCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 동일 날짜의 `listByDate` 결과를 메모이즈하는 run 스코프 캐시.
|
||||
*
|
||||
* `dailyArchive`처럼 한 번의 run에서 여러 유저를 처리하며 같은 날짜의 `games`를
|
||||
* 반복 조회하는 경로에서, 날짜당 Firestore read를 1회로 줄이기 위해 쓴다.
|
||||
* Promise를 캐싱하므로 동시 호출도 단일 쿼리로 합쳐진다.
|
||||
*
|
||||
* 하위 조회는 공유 캐시(`listByDateCached`)를 거치므로, 요청마다 새로 만드는
|
||||
* 짧은 수명의 인스턴스도 실제 쿼리를 유발하지 않는다.
|
||||
*/
|
||||
export interface GameDayCache {
|
||||
listByDate(date: DateString): Promise<GameWithId[]>;
|
||||
@ -54,7 +97,7 @@ export function createGameDayCache(): GameDayCache {
|
||||
listByDate(date: DateString): Promise<GameWithId[]> {
|
||||
let p = cache.get(date);
|
||||
if (!p) {
|
||||
p = listByDate(date);
|
||||
p = listByDateCached(date);
|
||||
cache.set(date, p);
|
||||
}
|
||||
return p;
|
||||
|
||||
@ -122,9 +122,20 @@ function parseHHMM(t: string): number | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* 일자별 TTL 산출. 본 모듈 상단의 표 참조.
|
||||
* 일자별 TTL 산출.
|
||||
*
|
||||
* 과거 : 그 날 경기가 모두 종료/취소면 7d, 아니면 30s
|
||||
* 오늘 : 시작 전이면 첫 경기 시작까지(최대 1h), 그 외 30s, 모두 끝났으면 7d
|
||||
* 내일 : 6h
|
||||
* 모레 이후: 7d — 단 해당 날짜 자정을 넘기지 않는다
|
||||
*
|
||||
* 마지막 조건이 중요하다. TTL은 '쓰는 시점의 미래 거리'로만 정해지고 시간이
|
||||
* 흘러도 재평가되지 않으므로, 자르지 않으면 2~7일 앞서 캐시된 날짜가 경기
|
||||
* 당일과 그 이후까지 "경기 전" 스냅샷을 계속 내놓는다.
|
||||
* (실제 사고: 07-27에 담긴 08-01이 7d TTL로 08-03까지 살아남아, 폭염취소된
|
||||
* 경기가 달력에서 계속 `scheduled`로 보였다.)
|
||||
*/
|
||||
function dayTtlMs(yyyymmdd: string, games: ScheduleGame[]): number {
|
||||
export function dayTtlMs(yyyymmdd: string, games: ScheduleGame[]): number {
|
||||
const y = parseInt(yyyymmdd.slice(0, 4), 10);
|
||||
const m = parseInt(yyyymmdd.slice(4, 6), 10);
|
||||
const d = parseInt(yyyymmdd.slice(6, 8), 10);
|
||||
@ -148,7 +159,12 @@ function dayTtlMs(yyyymmdd: string, games: ScheduleGame[]): number {
|
||||
return allDone ? SEVEN_DAYS_MS : THIRTY_SEC_MS;
|
||||
}
|
||||
if (diffDays === 1) return SIX_HOURS_MS;
|
||||
if (diffDays >= 2) return SEVEN_DAYS_MS;
|
||||
if (diffDays >= 2) {
|
||||
// 해당 날짜 00:00 에 만료시켜, 경기 당일에는 반드시 다시 받아오게 한다.
|
||||
// 그 뒤로는 '오늘'/'과거' 분기가 실제 진행 상태에 맞는 TTL을 다시 매긴다.
|
||||
const untilDayStartMs = dayStart - now.getTime();
|
||||
return Math.min(SEVEN_DAYS_MS, Math.max(untilDayStartMs, THIRTY_SEC_MS));
|
||||
}
|
||||
|
||||
// 오늘
|
||||
if (games.length === 0) return SIX_HOURS_MS;
|
||||
@ -376,14 +392,18 @@ async function fetchScheduleSingleDay(
|
||||
let cached = (await readDayDocs([key])).get(key) ?? null;
|
||||
|
||||
if (cached === null) {
|
||||
// 미스 → 월 단위 외부 fetch 흐름이 캐시를 채우도록 호출. 결과는 사용하지 않음.
|
||||
await fetchScheduleMonth({
|
||||
// 미스 → 월 단위 리필. 리필 결과를 그대로 재사용해 day doc 재읽기를 없앤다.
|
||||
// loadMonthDayCache는 live 병합 전 원본을 돌려주므로 아래 merge가 이중 적용되지 않는다.
|
||||
const load = await loadMonthDayCache({
|
||||
year: filters.year,
|
||||
month: filters.month,
|
||||
team: filters.team,
|
||||
series: filters.series,
|
||||
});
|
||||
cached = (await readDayDocs([key])).get(key) ?? [];
|
||||
cached =
|
||||
load.kind === "bypass" ?
|
||||
load.games.filter((g) => gameDateToYmd(filters.year, g.date) === ymd) :
|
||||
load.cached.get(key) ?? [];
|
||||
}
|
||||
|
||||
const merged = await mergeLiveIntoSchedule(ymd, cached, filters.series);
|
||||
@ -396,9 +416,19 @@ async function fetchScheduleSingleDay(
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchScheduleMonth(
|
||||
type MonthDayCacheLoad =
|
||||
| { kind: "cache"; allDays: string[]; cached: Map<string, ScheduleGame[] | null> }
|
||||
| { kind: "bypass"; games: ScheduleGame[] };
|
||||
|
||||
/**
|
||||
* 월 단위 day 캐시를 확보한다(미스가 있으면 외부 fetch로 리필).
|
||||
*
|
||||
* live 병합 **전** 원본을 돌려준다 — 호출자가 필요한 범위에만 한 번 병합하도록
|
||||
* 해서 월/일 경로가 서로 다른 병합 횟수를 갖는 문제를 막는다.
|
||||
*/
|
||||
async function loadMonthDayCache(
|
||||
filters: ScheduleFilters
|
||||
): Promise<ScheduleResult> {
|
||||
): Promise<MonthDayCacheLoad> {
|
||||
const allDays = enumerateMonthDays(filters.year, filters.month);
|
||||
const keys = allDays.map((d) => dayKey(d, filters.team, filters.series));
|
||||
|
||||
@ -436,7 +466,11 @@ async function fetchScheduleMonth(
|
||||
})
|
||||
);
|
||||
|
||||
cached = await readDayDocs(keys);
|
||||
// 방금 쓴 내용은 byDate에 그대로 있다 — 전 키(28~31 doc) 재읽기 대신
|
||||
// 메모리에서 병합한다. 재읽기가 새로 가져오는 정보는 없다.
|
||||
for (const ymd of missing) {
|
||||
cached.set(dayKey(ymd, filters.team, filters.series), byDate.get(ymd) ?? []);
|
||||
}
|
||||
} finally {
|
||||
await releaseLock(lockKey);
|
||||
}
|
||||
@ -451,15 +485,26 @@ async function fetchScheduleMonth(
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
// 타임아웃 — 캐시 우회하여 직접 fetch (저장은 안 함).
|
||||
return fetchSchedule(filters);
|
||||
return { kind: "bypass", games: (await fetchSchedule(filters)).games };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: "cache", allDays, cached };
|
||||
}
|
||||
|
||||
async function fetchScheduleMonth(
|
||||
filters: ScheduleFilters
|
||||
): Promise<ScheduleResult> {
|
||||
const load = await loadMonthDayCache(filters);
|
||||
if (load.kind === "bypass") {
|
||||
return { year: filters.year, month: filters.month, games: load.games };
|
||||
}
|
||||
|
||||
const games: ScheduleGame[] = [];
|
||||
const today = todayKst().replace(/-/g, "");
|
||||
for (const ymd of allDays) {
|
||||
const list = cached.get(dayKey(ymd, filters.team, filters.series)) ?? [];
|
||||
for (const ymd of load.allDays) {
|
||||
const list = load.cached.get(dayKey(ymd, filters.team, filters.series)) ?? [];
|
||||
if (ymd === today) {
|
||||
const merged = await mergeLiveIntoSchedule(ymd, list, filters.series);
|
||||
games.push(...merged);
|
||||
|
||||
@ -130,10 +130,14 @@ export async function listTopByTierPoints(
|
||||
* 시즌 정산 전용: 페이지 단위로 나눠 읽되 결과는 전량 메모리에 올린다.
|
||||
*/
|
||||
export async function listAllRankedUsers(): Promise<
|
||||
Array<{ uid: string; tierPoints: number }>
|
||||
Array<{ uid: string; tierPoints: number; favoriteTeamCode?: TeamCode }>
|
||||
> {
|
||||
const PAGE = 500;
|
||||
const results: Array<{ uid: string; tierPoints: number }> = [];
|
||||
const results: Array<{
|
||||
uid: string;
|
||||
tierPoints: number;
|
||||
favoriteTeamCode?: TeamCode;
|
||||
}> = [];
|
||||
let last: FirebaseFirestore.QueryDocumentSnapshot | undefined;
|
||||
for (;;) {
|
||||
let query = firestore
|
||||
@ -141,14 +145,18 @@ export async function listAllRankedUsers(): Promise<
|
||||
.where("active", "==", true)
|
||||
.where("tierPoints", ">", 0)
|
||||
.orderBy("tierPoints", "desc")
|
||||
.select("tierPoints")
|
||||
// favoriteTeamCode는 팀 스코프 순위를 in-memory로 만들기 위해 함께 읽는다.
|
||||
// 프로젝션 필드 추가는 read unit에 영향이 없다(문서당 과금) — 대역폭만 늘어난다.
|
||||
.select("tierPoints", "favoriteTeamCode")
|
||||
.limit(PAGE);
|
||||
if (last) query = query.startAfter(last);
|
||||
const snap = await query.get();
|
||||
for (const d of snap.docs) {
|
||||
const data = d.data() as Partial<User>;
|
||||
results.push({
|
||||
uid: d.id,
|
||||
tierPoints: (d.data() as Partial<User>).tierPoints ?? 0,
|
||||
tierPoints: data.tierPoints ?? 0,
|
||||
...(data.favoriteTeamCode ? { favoriteTeamCode: data.favoriteTeamCode } : {}),
|
||||
});
|
||||
}
|
||||
if (snap.docs.length < PAGE) return results;
|
||||
@ -341,6 +349,12 @@ export async function applyDailyJudgmentTx(
|
||||
* 보존하려면 호출자가 트랜잭션 호출 전에 계산해 넘겨야 한다.
|
||||
*/
|
||||
rankSnapshot?: RankSnapshot;
|
||||
/**
|
||||
* 통산 집계에 더할 값 — 취소 무효표를 제외한 예측 수와 적중 수.
|
||||
* `judgment`가 skip이어도 투표 자체는 승률 모집단이므로 `correctCount`와
|
||||
* 별개로 계산해서 넘긴다.
|
||||
*/
|
||||
lifetimeDelta?: { predictions: number; correct: number };
|
||||
computePoints: (streakAfter: number) => number;
|
||||
}
|
||||
): Promise<DailyJudgmentResult> {
|
||||
@ -384,6 +398,18 @@ export async function applyDailyJudgmentTx(
|
||||
};
|
||||
// 판정 전 rank 스냅샷을 같은 patch에 합쳐 user doc write를 1회로 줄인다.
|
||||
if (input.rankSnapshot) patch.rankSnapshot = input.rankSnapshot;
|
||||
|
||||
// 통산 집계 증분 — 백필(computeStats)이 이미 세운 기준선 이후 날짜만 더한다.
|
||||
// 기준선이 없으면(미백필 유저) 아무것도 하지 않는다. 백필이 전수 스캔으로
|
||||
// 세우고 나서부터 증분이 이어진다.
|
||||
const through = user.lifetimeStatsThrough;
|
||||
if (input.lifetimeDelta && through != null && date > through) {
|
||||
patch.lifetimePredictions =
|
||||
(user.lifetimePredictions ?? 0) + input.lifetimeDelta.predictions;
|
||||
patch.lifetimeCorrect =
|
||||
(user.lifetimeCorrect ?? 0) + input.lifetimeDelta.correct;
|
||||
patch.lifetimeStatsThrough = date;
|
||||
}
|
||||
tx.set(ref, patch, { merge: true });
|
||||
|
||||
return {
|
||||
|
||||
@ -66,6 +66,7 @@ export async function submitVote(params: {
|
||||
updates[`/votes/${gameId}/counts/${side}Count`] = ServerValue.increment(1);
|
||||
updates[`/votes/${gameId}/users/${uid}`] = { team };
|
||||
updates[`/userVotes/${uid}/${date}/${gameId}`] = { team };
|
||||
updates[byDatePath(uid, date, gameId)] = { team };
|
||||
await rtdb.ref().update(updates);
|
||||
}
|
||||
|
||||
@ -93,9 +94,47 @@ export async function changeVote(params: {
|
||||
updates[`/votes/${gameId}/counts/${newSide}Count`] = ServerValue.increment(1);
|
||||
updates[`/votes/${gameId}/users/${uid}`] = { team: newTeam };
|
||||
updates[`/userVotes/${uid}/${date}/${gameId}`] = { team: newTeam };
|
||||
updates[byDatePath(uid, date, gameId)] = { team: newTeam };
|
||||
await rtdb.ref().update(updates);
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜별 투표 인덱스 경로 — `/userVotes/{uid}/{date}`의 (date, uid) 전치 미러.
|
||||
*
|
||||
* RTDB에는 부분 노드 읽기가 없어서 `dailyArchive`가 하루치를 처리하려면
|
||||
* `/userVotes` 트리 **전체**(전 유저 × 전 보존 날짜)를 내려받아야 했다.
|
||||
* 쓰기 시점에 fan-out 해 두면 잡이 `/userVotesByDate/{date}` 한 노드만 읽는다.
|
||||
*
|
||||
* ⚠️ 이 경로는 서버 전용이다(교차 유저 데이터). RTDB 규칙에서 read/write 모두 false.
|
||||
*/
|
||||
function byDatePath(uid: string, date: DateString, gameId: string): string {
|
||||
return `/userVotesByDate/${date}/${uid}/${gameId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 날짜에 투표한 전 유저의 기록을 조회한다(아카이브 전용).
|
||||
*
|
||||
* @returns `{ [uid]: { [gameId]: VoteEntry } }`. 없으면 빈 객체.
|
||||
*/
|
||||
export async function getVotesByDate(
|
||||
date: DateString
|
||||
): Promise<Record<string, Record<string, VoteEntry>>> {
|
||||
const snap = await rtdb.ref(`/userVotesByDate/${date}`).get();
|
||||
return snap.exists() ? (snap.val() as Record<string, Record<string, VoteEntry>>) : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 백필 완료 표시를 조회한다.
|
||||
*
|
||||
* 표시가 있으면 날짜별 인덱스가 전 이력을 담고 있다는 뜻이므로, 어떤 날짜가
|
||||
* 비어 있어도 그것은 "그날 투표가 없었다"는 사실이지 인덱스 누락이 아니다.
|
||||
* `dailyArchive`가 레거시 전체 스캔 폴백을 건너뛰는 근거로 쓴다.
|
||||
*/
|
||||
export async function isVoteDateIndexBackfilled(): Promise<boolean> {
|
||||
const snap = await rtdb.ref("/userVotesByDateMeta/backfilledAt").get();
|
||||
return snap.exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 유저가 특정 날짜에 한 모든 경기 투표를 조회한다.
|
||||
*
|
||||
@ -127,7 +166,13 @@ export async function deleteGameVotes(gameId: string): Promise<void> {
|
||||
* 탈퇴 비활성화 시 아카이브가 비활성 계정을 판정하지 않도록 정리한다.
|
||||
*/
|
||||
export async function deleteUserVoteIndex(uid: string): Promise<void> {
|
||||
await rtdb.ref(`/userVotes/${uid}`).remove();
|
||||
// 날짜별 미러도 함께 지운다. 어느 날짜에 기록이 있는지는 원본에만 있으므로
|
||||
// 먼저 읽어서 해당 날짜들만 정리한다(유저 1명분이라 작다).
|
||||
const snap = await rtdb.ref(`/userVotes/${uid}`).get();
|
||||
const dates = snap.exists() ? Object.keys(snap.val() as Record<string, unknown>) : [];
|
||||
const updates: Record<string, unknown> = { [`/userVotes/${uid}`]: null };
|
||||
for (const date of dates) updates[`/userVotesByDate/${date}/${uid}`] = null;
|
||||
await rtdb.ref().update(updates);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -139,5 +184,8 @@ export async function deleteUserVoteGame(
|
||||
date: DateString,
|
||||
gameId: string
|
||||
): Promise<void> {
|
||||
await rtdb.ref(`/userVotes/${uid}/${date}/${gameId}`).remove();
|
||||
await rtdb.ref().update({
|
||||
[`/userVotes/${uid}/${date}/${gameId}`]: null,
|
||||
[byDatePath(uid, date, gameId)]: null,
|
||||
});
|
||||
}
|
||||
|
||||
@ -7,12 +7,28 @@ import { judgeDay } from "../services/judgmentService";
|
||||
import {
|
||||
precomputeScoreboardCache,
|
||||
computeRankSnapshot,
|
||||
loadRankStandings,
|
||||
type RankStandings,
|
||||
} from "../services/rankSnapshotService";
|
||||
import { getUser } from "../repositories/userRepository";
|
||||
import { maybeSettleSeason } from "../services/seasonService";
|
||||
import { todayKst } from "../types/dateString";
|
||||
import { getGame, createGameDayCache } from "../repositories/gameRepository";
|
||||
import {
|
||||
getGame,
|
||||
createGameDayCache,
|
||||
type GameDayCache,
|
||||
} from "../repositories/gameRepository";
|
||||
import { processGameEndWithGame } from "../services/gameResultService";
|
||||
import { DRAW_TEAM_CODE, type RankSnapshot, type VoteHistoryDoc } from "../types/panit";
|
||||
import {
|
||||
getVotesByDate,
|
||||
isVoteDateIndexBackfilled,
|
||||
} from "../repositories/voteRepository";
|
||||
import {
|
||||
DRAW_TEAM_CODE,
|
||||
type RankSnapshot,
|
||||
type User,
|
||||
type VoteHistoryDoc,
|
||||
} from "../types/panit";
|
||||
import {
|
||||
daysAgoKst,
|
||||
type DateString,
|
||||
@ -38,13 +54,19 @@ type DayVotes = Record<string, RawVote>;
|
||||
async function reconcileDayVotes(
|
||||
uid: string,
|
||||
date: DateString,
|
||||
dayVotes: DayVotes
|
||||
dayVotes: DayVotes,
|
||||
gameCache: GameDayCache,
|
||||
healed: Set<string>
|
||||
): Promise<DayVotes> {
|
||||
const result: DayVotes = { ...dayVotes };
|
||||
// 해당 날짜 경기를 한 번에 확보한다 — 유저×미판정경기 수만큼 getGame을 치던 N+1 제거.
|
||||
const byId = new Map(
|
||||
(await gameCache.listByDate(date)).map((g) => [g.gameId, g])
|
||||
);
|
||||
for (const [gameId, vote] of Object.entries(dayVotes)) {
|
||||
if (vote.result !== undefined || vote.cancelled) continue;
|
||||
|
||||
const game = await getGame(gameId);
|
||||
const game = byId.get(gameId) ?? (await getGame(gameId));
|
||||
if (!game) {
|
||||
logger.warn(`reconcile: game not found ${gameId}, uid=${uid}`);
|
||||
continue;
|
||||
@ -55,8 +77,14 @@ async function reconcileDayVotes(
|
||||
// (`processGameEndWithGame`과 동일 규칙).
|
||||
if (game.status === "completed") {
|
||||
try {
|
||||
// `byUid`는 정지된 스냅샷이라 앞선 유저가 치유한 경기도 뒤 유저에겐 여전히
|
||||
// 미판정으로 보인다. run 스코프 Set으로 경기당 1회만 처리해, 같은 경기의
|
||||
// 투표자 전원 재처리(getAllUserVotes + RTDB update + deleteGameVotes)를 막는다.
|
||||
if (!healed.has(gameId)) {
|
||||
// 아카이브는 루프 끝에서 유저 단위로 1회 무효화하므로 경기별 fan-out은 생략.
|
||||
await processGameEndWithGame(gameId, game, { skipInvalidate: true });
|
||||
healed.add(gameId);
|
||||
}
|
||||
const isDraw = !game.winningTeamCode;
|
||||
result[gameId] = {
|
||||
team: vote.team,
|
||||
@ -99,23 +127,66 @@ export async function runDailyArchive(
|
||||
// 같은 날짜(및 결석 판정용 직전 날짜들)의 games를 유저마다 재조회하지 않도록
|
||||
// run 전체에서 1개의 날짜별 캐시를 공유한다. 날짜당 Firestore read 1회로 수렴.
|
||||
const gameCache = createGameDayCache();
|
||||
// run 중 자가치유된 경기 — 뒤따르는 유저들이 같은 경기를 재처리하지 않도록 한다.
|
||||
const healedGames = new Set<string>();
|
||||
// 순위표를 run 시작 시 1회 로드해 유저별 count aggregation(O(N×M))을 없앤다.
|
||||
// 실패 시 standings 없이 진행하면 computeRankSnapshot이 aggregation으로 폴백한다.
|
||||
let standings: RankStandings | null = null;
|
||||
try {
|
||||
const snap = await rtdb.ref("/userVotes").get();
|
||||
if (!snap.exists()) {
|
||||
standings = await loadRankStandings();
|
||||
} catch (err) {
|
||||
logger.error("loadRankStandings failed — per-user aggregation으로 폴백", err);
|
||||
}
|
||||
try {
|
||||
// 날짜별 인덱스만 읽는다 — 예전에는 하루치를 위해 `/userVotes` 트리 전체
|
||||
// (전 유저 × 전 보존 날짜)를 내려받았다.
|
||||
const byUid = (await getVotesByDate(date)) as Record<string, DayVotes>;
|
||||
let mergedFromLegacy = 0;
|
||||
// 백필이 끝났으면 인덱스가 전 이력을 담고 있으므로, 비어 있다는 것은
|
||||
// "그날 투표가 없었다"는 사실이다 — 전체 스캔으로 되돌아가지 않는다.
|
||||
// (이 가드가 없으면 월요일·비시즌 같은 무투표일마다 트리 전체를 다시 읽는다.)
|
||||
//
|
||||
// 반대로 마커가 없는 롤아웃 기간에는 인덱스가 **부분적으로만** 찼을 수 있다.
|
||||
// 미러 배포 전에 투표한 유저는 인덱스에 없고 원본에만 있는데, 같은 날 배포 후
|
||||
// 투표한 유저가 하나라도 있으면 인덱스가 비지 않는다. "비었을 때만 폴백"으로
|
||||
// 두면 그 배포 전 투표자들이 판정·보상·스트릭 없이 영구 유실된다
|
||||
// (아카이브는 매 run 다른 날짜를 처리하므로 그 날짜는 다시 열리지 않는다).
|
||||
// 그래서 마커가 없으면 항상 원본을 읽어 인덱스에 없는 uid만 보충한다.
|
||||
if (!(await isVoteDateIndexBackfilled())) {
|
||||
const legacy = await rtdb.ref("/userVotes").get();
|
||||
if (legacy.exists()) {
|
||||
const all = legacy.val() as Record<string, Record<string, DayVotes>>;
|
||||
for (const uid of Object.keys(all)) {
|
||||
const day = all[uid]?.[date];
|
||||
// 인덱스 값이 우선 — 원본은 인덱스에 없는 uid를 채우는 용도로만 쓴다.
|
||||
if (day && !(uid in byUid)) {
|
||||
byUid[uid] = day;
|
||||
mergedFromLegacy += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mergedFromLegacy > 0) {
|
||||
logger.warn(
|
||||
`dailyArchive: ${date} — 인덱스에 없는 ${mergedFromLegacy}명을 원본에서 보충했다. ` +
|
||||
"백필 스크립트(npm run backfill:vote-index -- --apply) 실행 권장"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(byUid).length === 0) {
|
||||
logger.info("no userVotes to archive");
|
||||
return { date, archived: 0, judgedUids: [] };
|
||||
}
|
||||
const byUid = snap.val() as Record<string, Record<string, DayVotes>>;
|
||||
|
||||
for (const uid of Object.keys(byUid)) {
|
||||
let dayVotes = byUid[uid]?.[date];
|
||||
let dayVotes = byUid[uid];
|
||||
if (!dayVotes) continue;
|
||||
|
||||
const hasUnjudged = Object.values(dayVotes).some(
|
||||
(v) => v.result === undefined && !v.cancelled
|
||||
);
|
||||
if (hasUnjudged) {
|
||||
dayVotes = await reconcileDayVotes(uid, date, dayVotes);
|
||||
dayVotes = await reconcileDayVotes(uid, date, dayVotes, gameCache, healedGames);
|
||||
}
|
||||
|
||||
const data: VoteHistoryDoc["data"] = [];
|
||||
@ -141,28 +212,52 @@ export async function runDailyArchive(
|
||||
// 스트릭 유지 로직이 동작하도록 judgeDay를 호출한다.
|
||||
// 판정 전 rank를 계산해(=현재 tierPoints 기준) judgeDay 트랜잭션에 함께 기록한다.
|
||||
// 판정 트랜잭션과 같은 patch로 묶여 user doc write가 1회로 준다("판정 전 rank" 보존).
|
||||
// user 문서는 여기서 1회만 읽어 rank 계산과 judgeDay가 공유한다.
|
||||
// (판정 트랜잭션 내부의 tx.get은 원자성상 필수라 남는다.)
|
||||
// 읽기 실패는 undefined로 남긴다 — null을 넘기면 judgeDay가 "유저 문서 없음"
|
||||
// 으로 해석해 결석 판정을 건너뛴다. undefined면 judgeDay가 스스로 다시 읽는다.
|
||||
let user: User | null | undefined;
|
||||
try {
|
||||
user = await getUser(uid);
|
||||
} catch (err) {
|
||||
logger.error(`getUser failed uid=${uid} — judgeDay가 재조회한다`, err);
|
||||
}
|
||||
let rankSnapshot: RankSnapshot | null = null;
|
||||
try {
|
||||
rankSnapshot = await computeRankSnapshot(uid, date);
|
||||
rankSnapshot = await computeRankSnapshot(uid, date, {
|
||||
...(user !== undefined ? { user } : {}),
|
||||
...(standings ? { standings } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`computeRankSnapshot failed uid=${uid} date=${date}`, err);
|
||||
}
|
||||
// voteHistory 기록은 judgeDay가 1회 수행한다(판정 필드 포함). 별도 선기록은 생략.
|
||||
// judgeDay가 doc을 영속화한 뒤에야 userVotes를 제거해 데이터 유실을 막는다.
|
||||
try {
|
||||
await judgeDay(uid, date, { data }, { gameCache, rankSnapshot });
|
||||
await judgeDay(uid, date, { data }, {
|
||||
gameCache,
|
||||
rankSnapshot,
|
||||
...(user !== undefined ? { userPre: user } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`judgeDay failed uid=${uid} date=${date}`, err);
|
||||
// 판정 실패 시에도 data만은 보존(기존 동작 유지) — userVotes를 곧 지우기 때문.
|
||||
await setDay(uid, date, { data }).catch(() => undefined);
|
||||
}
|
||||
await rtdb.ref(`/userVotes/${uid}/${date}`).remove();
|
||||
// 원본과 날짜별 미러를 함께 정리한다.
|
||||
await rtdb.ref().update({
|
||||
[`/userVotes/${uid}/${date}`]: null,
|
||||
[`/userVotesByDate/${date}/${uid}`]: null,
|
||||
});
|
||||
await invalidateStats(uid).catch(() => undefined);
|
||||
judgedUids.push(uid);
|
||||
archived += 1;
|
||||
}
|
||||
|
||||
logger.info(`dailyArchive done: ${archived} users archived for ${date}`);
|
||||
logger.info(
|
||||
`dailyArchive done: ${archived} users archived for ${date}` +
|
||||
(mergedFromLegacy > 0 ? ` (원본 보충 ${mergedFromLegacy}명)` : "")
|
||||
);
|
||||
return { date, archived, judgedUids };
|
||||
} finally {
|
||||
// 시즌 종료 다음 날 첫 archive: 어제(=시즌 마지막 날) 판정을 반영한 뒤
|
||||
|
||||
@ -13,15 +13,26 @@ import { daysAgoKst } from "../types/dateString";
|
||||
|
||||
const CACHE_COLLECTION = "kboCache";
|
||||
|
||||
async function invalidateByPrefix(prefix: string): Promise<void> {
|
||||
/** batch 하드 상한(500)보다 낮게 잡아 커밋이 터지지 않게 한다. */
|
||||
const DELETE_CHUNK = 400;
|
||||
|
||||
async function invalidateByPrefix(prefix: string): Promise<number> {
|
||||
const snap = await firestore
|
||||
.collection(CACHE_COLLECTION)
|
||||
.where("__name__", ">=", prefix)
|
||||
.where("__name__", "<", prefix + "\uf8ff")
|
||||
// 삭제에는 ref만 필요하다 — 문서 본문 전송을 막는다.
|
||||
.select()
|
||||
.get();
|
||||
|
||||
// 단일 batch는 500 op에서 INVALID_ARGUMENT로 던진다. 청킹하지 않으면
|
||||
// 캐시 문서가 늘어난 어느 날 이 잡 전체가 조용히 죽는다.
|
||||
for (let i = 0; i < snap.docs.length; i += DELETE_CHUNK) {
|
||||
const batch = firestore.batch();
|
||||
snap.docs.forEach((d) => batch.delete(d.ref));
|
||||
if (!snap.empty) await batch.commit();
|
||||
for (const d of snap.docs.slice(i, i + DELETE_CHUNK)) batch.delete(d.ref);
|
||||
await batch.commit();
|
||||
}
|
||||
return snap.size;
|
||||
}
|
||||
|
||||
export const kboDailyRefresh = onSchedule(
|
||||
@ -33,13 +44,15 @@ export const kboDailyRefresh = onSchedule(
|
||||
|
||||
logger.info(`KBO refresh start: ${year}-${month}`);
|
||||
|
||||
await invalidateByPrefix("rank__");
|
||||
await invalidateByPrefix("schedule_day__");
|
||||
// game_detail__는 응답 기반 동적 TTL(종료 경기 7d, 라이브 30s 등)을 이미 갖고 있어
|
||||
// 02:00 일괄 무효화 대상에서 제외한다. 일괄 삭제 시 직후 상세 조회가 한꺼번에 미스나
|
||||
// 외부 재조회 폭주를 유발하므로, TTL 자연 만료에 위임한다.
|
||||
|
||||
// game_detail__와 schedule_day__는 응답 기반 동적 TTL(종료 7d, 라이브 30s 등)을
|
||||
// 이미 갖고 있어 02:00 일괄 무효화 대상에서 제외한다. 일괄 삭제하면 직후 조회가
|
||||
// 한꺼번에 미스나 외부 재조회 폭주를 부르므로 TTL 자연 만료에 위임한다.
|
||||
// (schedule_day__의 TTL은 dayTtlMs가 부여한다 — 완료된 과거 경기일은 7일이라
|
||||
// 매일 밤 지우면 그 TTL이 통째로 무의미해진다.)
|
||||
try {
|
||||
const rankPurged = await invalidateByPrefix("rank__");
|
||||
logger.info(`invalidated ${rankPurged} rank cache docs`);
|
||||
|
||||
await fetchRankFromKbo([year]);
|
||||
await fetchScheduleFromKbo({ year, month });
|
||||
|
||||
|
||||
@ -1,13 +1,10 @@
|
||||
import { getSchedule } from "./scheduleService";
|
||||
import { getRank } from "./rankService";
|
||||
import { getStats } from "./statsService";
|
||||
import { getUserDateVotes } from "../repositories/voteRepository";
|
||||
import { getDay } from "../repositories/voteHistoryRepository";
|
||||
import {
|
||||
COMMON_SYSTEM_PROMPT,
|
||||
DEFAULT_PERSONA_BLOCK,
|
||||
DEFAULT_TEAM_PERSONAS,
|
||||
KBO_RANK_TEAM_NAMES,
|
||||
KNOWLEDGE_GUIDANCE,
|
||||
SERVER_DIRECTIVE_BLOCK,
|
||||
TEAM_DISPLAY_NAMES,
|
||||
@ -63,21 +60,24 @@ export function sanitizeDisplayName(raw: unknown): string {
|
||||
|
||||
// ── 컨텍스트 데이터 수집 ──
|
||||
|
||||
export interface UserContext {
|
||||
/**
|
||||
* 프롬프트 조립에 필요한 정체성 컨텍스트.
|
||||
*
|
||||
* 전량 `users/{uid}` 문서에서 파생되므로 조립 경로의 Firestore 조회는 0회다.
|
||||
* 매치업·선발·순위·예측·전적·통계는 컨텍스트 블록에 선주입하지 않고 도구로
|
||||
* 조회하므로(2.2 경량판), 여기에 시사 데이터를 다시 넣지 말 것.
|
||||
*/
|
||||
export interface IdentityContext {
|
||||
date: DateString;
|
||||
displayName: string;
|
||||
knowledgeLevel: KnowledgeLevel;
|
||||
teamCode: TeamCode | null;
|
||||
teamName: string | null;
|
||||
todaySchedule: string;
|
||||
todayMyPredictions: string;
|
||||
yesterdayRecap: string;
|
||||
/** 응원팀 미설정 시 null → 줄 생략. */
|
||||
recentTeamResults: string | null;
|
||||
/** 오늘 응원팀 경기 없음/미설정 시 null → 줄 생략. */
|
||||
h2hRecords: string | null;
|
||||
myStats: string;
|
||||
/** 추천 질문 노출 조건(§6.2) 공용 플래그. */
|
||||
}
|
||||
|
||||
/** 추천 질문 노출 조건(§6.2) 플래그 — GET /chat/suggestions 전용. */
|
||||
export interface SuggestionFlags {
|
||||
teamCode: TeamCode | null;
|
||||
hasYesterdayRecap: boolean;
|
||||
hasTodayTeamGame: boolean;
|
||||
hasPredictedToday: boolean;
|
||||
@ -92,178 +92,65 @@ async function safely<T>(label: string, fallback: T, task: () => Promise<T>): Pr
|
||||
}
|
||||
}
|
||||
|
||||
function matchupLabel(g: ScheduleGame): string {
|
||||
return `${g.awayTeamCode} vs ${g.homeTeamCode}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 오늘 경기 일정 포맷(2.2 {{todaySchedule}}).
|
||||
* `live`는 확정 필드만 주입하고 스코어는 제거, `completed`는 스코어 포함, `cancelled`는 취소 라벨.
|
||||
* 정체성 컨텍스트 구성 — Firestore 조회 없음.
|
||||
*
|
||||
* 호출자가 이미 로드한 `users/{uid}` 문서만으로 구성된다. 전송 경로(§3.1)와
|
||||
* 프로브가 공유한다.
|
||||
*/
|
||||
export function formatTodaySchedule(games: ScheduleGame[]): string {
|
||||
if (games.length === 0) return "오늘 경기 없음";
|
||||
const lines = games.map((g) => {
|
||||
const pitchers =
|
||||
g.awayStartingPitcher || g.homeStartingPitcher ?
|
||||
` 선발 ${g.awayStartingPitcher?.name ?? "미정"} vs ${g.homeStartingPitcher?.name ?? "미정"}` :
|
||||
"";
|
||||
const base = `${matchupLabel(g)} ${g.time} ${g.stadium}${pitchers}`;
|
||||
switch (g.status) {
|
||||
case "completed":
|
||||
return `${base} — 종료 ${g.awayScore ?? "?"}:${g.homeScore ?? "?"}`;
|
||||
case "live":
|
||||
return `${base} — 진행 중(스코어 미제공)`;
|
||||
case "cancelled":
|
||||
return `${base} — 취소${g.note ? `(${g.note})` : ""}`;
|
||||
default:
|
||||
return `${base} — 예정`;
|
||||
}
|
||||
});
|
||||
return lines.join(" / ");
|
||||
}
|
||||
|
||||
function formatRecentResults(team: TeamCode, games: ScheduleGame[]): string {
|
||||
const completed = games.filter(
|
||||
(g) =>
|
||||
g.status === "completed" &&
|
||||
g.awayScore != null &&
|
||||
g.homeScore != null &&
|
||||
(g.awayTeamCode === team || g.homeTeamCode === team),
|
||||
);
|
||||
if (completed.length === 0) return "최근 경기 정보 없음";
|
||||
const recent = completed.slice(-5);
|
||||
const lines = recent.map((g) => {
|
||||
const isAway = g.awayTeamCode === team;
|
||||
const my = isAway ? g.awayScore as number : g.homeScore as number;
|
||||
const opp = isAway ? g.homeScore as number : g.awayScore as number;
|
||||
const oppCode = isAway ? g.homeTeamCode : g.awayTeamCode;
|
||||
const result = my > opp ? "승" : my < opp ? "패" : "무";
|
||||
return `${g.date} vs ${oppCode} ${my}:${opp} ${result}`;
|
||||
});
|
||||
return lines.join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* 응원팀 최근 경기 수집 — 당월 완료 경기가 5건 미만이면 전월을 보충 조회한다
|
||||
* (월초에 "최근 5경기"가 비는 것을 방지, 페르소나 문서 2.2 {{recentTeamResults}}).
|
||||
*/
|
||||
async function fetchRecentTeamGames(y: number, m: number, team: TeamCode): Promise<ScheduleGame[]> {
|
||||
const current = (await getSchedule(y, m, team)).games;
|
||||
const completed = current.filter((g) => g.status === "completed").length;
|
||||
if (completed >= 5) return current;
|
||||
const prevY = m === 1 ? y - 1 : y;
|
||||
const prevM = m === 1 ? 12 : m - 1;
|
||||
try {
|
||||
const prev = (await getSchedule(prevY, prevM, team)).games;
|
||||
return [...prev, ...current];
|
||||
} catch {
|
||||
return current; // 전월 보충 실패는 당월만으로 degrade
|
||||
}
|
||||
}
|
||||
|
||||
/** 전체 사용자 컨텍스트를 병렬 수집한다. 각 항목 실패는 부재 표기로 대체된다. */
|
||||
export async function gatherUserContext(
|
||||
uid: string,
|
||||
user: User | null,
|
||||
config?: ChatConfig,
|
||||
): Promise<UserContext> {
|
||||
const date = todayKst();
|
||||
const [y, m, d] = date.split("-").map(Number);
|
||||
export function identityContext(user: User | null, config?: ChatConfig): IdentityContext {
|
||||
const teamCode = resolveTeamCode(user?.favoriteTeamCode);
|
||||
const knowledgeLevel = resolveKnowledgeLevel(user?.knowledgeLevel);
|
||||
const displayName = sanitizeDisplayName(user?.displayName);
|
||||
|
||||
const [todayGames, myVotes, recapDoc, teamMonthGames, rankResults, stats] = await Promise.all([
|
||||
safely<ScheduleGame[]>("todaySchedule", [], async () => (await getSchedule(y, m, undefined, undefined, d)).games),
|
||||
safely<Record<string, { team: string }>>("todayMyPredictions", {}, () => getUserDateVotes(uid, date)),
|
||||
safely("yesterdayRecap", null, async () =>
|
||||
user?.lastJudgedDate ? getDay(uid, parseDateString(user.lastJudgedDate)) : null,
|
||||
),
|
||||
safely<ScheduleGame[]>("recentTeamResults", [], async () =>
|
||||
teamCode ? fetchRecentTeamGames(y, m, teamCode) : [],
|
||||
),
|
||||
safely("h2hRecords", null, async () => (teamCode ? getRank([y]) : null)),
|
||||
safely("myStats", null, () => getStats(uid, "current")),
|
||||
]);
|
||||
|
||||
// 오늘 내 예측 — 매치업 라벨로 표기(gameId 단독보다 모델이 읽기 좋다)
|
||||
const voteEntries = Object.entries(myVotes);
|
||||
const gameById = new Map(todayGames.filter((g) => g.gameId).map((g) => [g.gameId as string, g]));
|
||||
const todayMyPredictions =
|
||||
voteEntries.length === 0 ?
|
||||
"오늘 예측 없음" :
|
||||
voteEntries
|
||||
.map(([gameId, v]) => {
|
||||
const g = gameById.get(gameId);
|
||||
return g ? `${matchupLabel(g)}: ${v.team} 선택` : `${gameId}: ${v.team} 선택`;
|
||||
})
|
||||
.join(", ");
|
||||
|
||||
// 어제(최근 채점일) 예측 결과 — 서버 채점 결과(result)를 그대로 사용, 재계산 금지
|
||||
let yesterdayRecap = "어제 예측 기록 없음";
|
||||
let hasYesterdayRecap = false;
|
||||
if (recapDoc && Array.isArray(recapDoc.data) && recapDoc.data.length > 0) {
|
||||
hasYesterdayRecap = true;
|
||||
const correct = recapDoc.data.filter((e) => e.result === true).length;
|
||||
const detail = recapDoc.data
|
||||
.map((e) => `${e.team} 선택 → ${e.result ? "적중" : "오답"}`)
|
||||
.join(", ");
|
||||
yesterdayRecap = `${user?.lastJudgedDate ?? ""} 기준 ${correct}/${recapDoc.data.length} 적중 (${detail})`;
|
||||
}
|
||||
|
||||
// 응원팀 최근 5경기(completed만)
|
||||
const recentTeamResults = teamCode ? formatRecentResults(teamCode, teamMonthGames) : null;
|
||||
|
||||
// 오늘 상대팀과의 시즌 상대 전적(vsRecords) — 오늘 응원팀 경기 없으면 줄 생략
|
||||
let h2hRecords: string | null = null;
|
||||
let hasTodayTeamGame = false;
|
||||
if (teamCode) {
|
||||
const todayTeamGame = todayGames.find(
|
||||
(g) => g.awayTeamCode === teamCode || g.homeTeamCode === teamCode,
|
||||
);
|
||||
hasTodayTeamGame = todayTeamGame != null && todayTeamGame.status !== "cancelled";
|
||||
// 취소된 경기는 "오늘 경기 없음"과 동일하게 취급 — h2h도 주입하지 않는다
|
||||
if (todayTeamGame && hasTodayTeamGame && rankResults && rankResults.length > 0) {
|
||||
const opponentCode = todayTeamGame.awayTeamCode === teamCode ?
|
||||
todayTeamGame.homeTeamCode :
|
||||
todayTeamGame.awayTeamCode;
|
||||
const myName = KBO_RANK_TEAM_NAMES[teamCode];
|
||||
const oppName = KBO_RANK_TEAM_NAMES[opponentCode as TeamCode];
|
||||
const record = rankResults[0].vsRecords.find((r) => r.team === myName);
|
||||
const wld = oppName ? record?.headToHead[oppName] : undefined;
|
||||
if (wld) {
|
||||
h2hRecords = `vs ${oppName} 시즌 ${wld.wins}승 ${wld.losses}패 ${wld.draws}무`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 내 통계
|
||||
let myStats = "통계 없음";
|
||||
if (stats) {
|
||||
const pct = (v: number) => `${Math.round(v * 100)}%`;
|
||||
myStats =
|
||||
`연속 참여 ${stats.streakDays}일, 적중률 전체 ${pct(stats.winRates.overall)} / ` +
|
||||
`주간 ${pct(stats.winRates.weekly)} / 월간 ${pct(stats.winRates.monthly)}`;
|
||||
}
|
||||
|
||||
return {
|
||||
date,
|
||||
displayName,
|
||||
knowledgeLevel,
|
||||
date: todayKst(),
|
||||
displayName: sanitizeDisplayName(user?.displayName),
|
||||
knowledgeLevel: resolveKnowledgeLevel(user?.knowledgeLevel),
|
||||
teamCode,
|
||||
// 표기명은 config로 강등(닉네임 전환) 가능 — KBO 라이선스 미확보 대비(1-2)
|
||||
teamName: teamCode ?
|
||||
config?.teamDisplayNames?.[teamCode] ?? TEAM_DISPLAY_NAMES[teamCode] :
|
||||
null,
|
||||
todaySchedule: formatTodaySchedule(todayGames),
|
||||
todayMyPredictions,
|
||||
yesterdayRecap,
|
||||
recentTeamResults,
|
||||
h2hRecords,
|
||||
myStats,
|
||||
hasYesterdayRecap,
|
||||
hasTodayTeamGame,
|
||||
hasPredictedToday: voteEntries.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 추천 질문 노출 플래그 수집(§6.2) — GET /chat/suggestions 전용.
|
||||
*
|
||||
* 응원팀 미설정이면 오늘 일정은 어떤 플래그에도 영향을 줄 수 없으므로 조회를
|
||||
* 건너뛴다. 각 항목 실패는 플래그 false로 degrade된다(추천 질문은 비핵심).
|
||||
*/
|
||||
export async function gatherSuggestionFlags(
|
||||
uid: string,
|
||||
user: User | null,
|
||||
): Promise<SuggestionFlags> {
|
||||
const date = todayKst();
|
||||
const [y, m, d] = date.split("-").map(Number);
|
||||
const teamCode = resolveTeamCode(user?.favoriteTeamCode);
|
||||
|
||||
const [todayGames, myVotes, recapDoc] = await Promise.all([
|
||||
teamCode ?
|
||||
safely<ScheduleGame[]>("todaySchedule", [], async () =>
|
||||
(await getSchedule(y, m, undefined, undefined, d)).games,
|
||||
) :
|
||||
Promise.resolve<ScheduleGame[]>([]),
|
||||
safely<Record<string, { team: string }>>("todayMyPredictions", {}, () =>
|
||||
getUserDateVotes(uid, date),
|
||||
),
|
||||
safely("yesterdayRecap", null, async () =>
|
||||
user?.lastJudgedDate ? getDay(uid, parseDateString(user.lastJudgedDate)) : null,
|
||||
),
|
||||
]);
|
||||
|
||||
const todayTeamGame = teamCode ?
|
||||
todayGames.find((g) => g.awayTeamCode === teamCode || g.homeTeamCode === teamCode) :
|
||||
undefined;
|
||||
|
||||
return {
|
||||
teamCode,
|
||||
// 취소된 경기는 "오늘 경기 없음"과 동일하게 취급한다
|
||||
hasTodayTeamGame: todayTeamGame != null && todayTeamGame.status !== "cancelled",
|
||||
hasYesterdayRecap:
|
||||
recapDoc != null && Array.isArray(recapDoc.data) && recapDoc.data.length > 0,
|
||||
hasPredictedToday: Object.keys(myVotes).length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
@ -278,10 +165,10 @@ function fill(template: string, vars: Record<string, string>): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* [블록 3] 사용자 컨텍스트 블록(경량판) — 정체성 + 오늘 경기 유무 플래그만.
|
||||
* [블록 3] 사용자 컨텍스트 블록(경량판) — 정체성만.
|
||||
* 매치업·선발·순위·예측·전적·통계는 도구로 조회하므로 여기서 선주입하지 않는다.
|
||||
*/
|
||||
export function buildUserContextBlock(ctx: UserContext): string {
|
||||
export function buildUserContextBlock(ctx: IdentityContext): string {
|
||||
return fill(USER_CONTEXT_TEMPLATE, {
|
||||
todayDate: ctx.date,
|
||||
displayName: ctx.displayName,
|
||||
@ -333,7 +220,7 @@ export interface AssembledPrompt {
|
||||
* 맨 끝에 서버 지시(위기 마커·카나리 — 기술 설계 §7.3 ②)를 덧붙인다.
|
||||
* 사용자 입력은 여기에 절대 이어붙이지 않는다 — user 롤로만 전달(§7.5).
|
||||
*/
|
||||
export function assemblePrompt(config: ChatConfig, ctx: UserContext): AssembledPrompt {
|
||||
export function assemblePrompt(config: ChatConfig, ctx: IdentityContext): AssembledPrompt {
|
||||
const style = resolveStylePack(config.stylePack);
|
||||
const common = config.systemPromptCommon.trim().length > 0 ?
|
||||
config.systemPromptCommon :
|
||||
@ -353,6 +240,6 @@ export function assemblePrompt(config: ChatConfig, ctx: UserContext): AssembledP
|
||||
}
|
||||
|
||||
/** 조립된 시스템 프롬프트 문자열만 필요할 때의 단축형. */
|
||||
export function assembleSystemPrompt(config: ChatConfig, ctx: UserContext): string {
|
||||
export function assembleSystemPrompt(config: ChatConfig, ctx: IdentityContext): string {
|
||||
return assemblePrompt(config, ctx).system;
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import { getChatConfig, invalidateChatConfigCache } from "./chatConfigService";
|
||||
import { getUser } from "../repositories/userRepository";
|
||||
import {
|
||||
assemblePrompt,
|
||||
gatherUserContext,
|
||||
identityContext,
|
||||
resolveTeamCode,
|
||||
} from "./chatContextService";
|
||||
import { buildChatTools, type ChatToolContext } from "./chatToolService";
|
||||
@ -112,7 +112,7 @@ async function buildProbeEnv(
|
||||
}
|
||||
const teamCode = resolveTeamCode(user.favoriteTeamCode);
|
||||
const date = todayKst();
|
||||
const ctx = await gatherUserContext(uid, user, config);
|
||||
const ctx = identityContext(user, config);
|
||||
const { system } = assemblePrompt(config, ctx);
|
||||
const provider = getChatProvider(config.provider);
|
||||
const teamName = teamCode ? TEAM_DISPLAY_NAMES[teamCode] ?? teamCode : "(미설정)";
|
||||
|
||||
@ -30,7 +30,13 @@ import {
|
||||
import { checkInput, checkOutput, crisisReply, detectCrisis } from "./chatFilterService";
|
||||
import { logChatEvent } from "./chatAnalyticsService";
|
||||
import { extractNavActions } from "./chatNavService";
|
||||
import { assemblePrompt, gatherUserContext, resolveTeamCode, type UserContext } from "./chatContextService";
|
||||
import {
|
||||
assemblePrompt,
|
||||
gatherSuggestionFlags,
|
||||
identityContext,
|
||||
resolveTeamCode,
|
||||
type IdentityContext,
|
||||
} from "./chatContextService";
|
||||
import { buildChatTools, withToolLabels } from "./chatToolService";
|
||||
import {
|
||||
EMPTY_REPLY_NOTICE, FALLBACK_SUGGESTION_IDS, FILTERED_REPLY, INPUT_BLOCKED_NOTICE,
|
||||
@ -78,6 +84,11 @@ async function quotaView(uid: string, date: DateString): Promise<{ used: number
|
||||
return { used: quota.used ?? 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param usedOverride 예약 트랜잭션이 이미 계산한 차감 후 `used`. 넘기면 쿼터
|
||||
* 문서를 다시 읽지 않는다. 트랜잭션이 돌지 않는 경로(replay, GET /chat/quota)는
|
||||
* 생략해서 조회하게 둔다.
|
||||
*/
|
||||
async function buildSendResult(
|
||||
uid: string,
|
||||
date: DateString,
|
||||
@ -88,8 +99,9 @@ async function buildSendResult(
|
||||
createdAt: Timestamp,
|
||||
toolCalls?: ChatToolCallInfo[],
|
||||
actions?: NavAction[],
|
||||
usedOverride?: number,
|
||||
): Promise<ChatSendResult> {
|
||||
const { used } = await quotaView(uid, date);
|
||||
const used = usedOverride ?? (await quotaView(uid, date)).used;
|
||||
return {
|
||||
messageId,
|
||||
reply,
|
||||
@ -121,14 +133,23 @@ async function replayDone(
|
||||
);
|
||||
}
|
||||
|
||||
/** 단기 문맥 윈도잉(§5.5) — 활성 스레드에서 N턴, 최대 나이, 위기/필터 제외. */
|
||||
/**
|
||||
* 단기 문맥 윈도잉(§5.5) — 활성 스레드에서 N턴, 최대 나이, 위기/필터 제외.
|
||||
*
|
||||
* `threadExists`를 함께 돌려준다 — 저장 단계(`finalizeExchange`)가 `createdAt`
|
||||
* 보존 판단을 위해 같은 문서를 다시 읽지 않도록.
|
||||
*/
|
||||
async function loadHistory(
|
||||
uid: string,
|
||||
threadId: string,
|
||||
config: ChatConfig,
|
||||
): Promise<ChatProviderMessage[]> {
|
||||
const fetchLimit = config.historyTurns * 2 + 30;
|
||||
const [thread, recentDesc] = await Promise.all([
|
||||
): Promise<{ messages: ChatProviderMessage[]; threadExists: boolean }> {
|
||||
const target = config.historyTurns * 2;
|
||||
// 위기 요청은 일일 한도를 우회하므로(§7.3) 창 안의 위기 교환쌍 수에는 상한이 없다.
|
||||
// 위기 쌍은 문서 2개를 차지하고 윈도잉에서 둘 다 빠지므로, 고정 padding은
|
||||
// 유효한 상한이 될 수 없다. 평시에는 작게 읽고, 실제로 모자랄 때만 한 번 넓힌다.
|
||||
const fetchLimit = target + 10;
|
||||
const [thread, firstPage] = await Promise.all([
|
||||
getThreadDoc(uid, threadId),
|
||||
getRecentMessages(uid, threadId, fetchLimit),
|
||||
]);
|
||||
@ -138,26 +159,38 @@ async function loadHistory(
|
||||
// 실제 문맥 분리는 스레드(팀)·턴수·나이 3가지로만 이뤄짐.
|
||||
const cutAt = thread?.historyCutAt?.toMillis() ?? 0;
|
||||
|
||||
const asc = [...recentDesc].reverse();
|
||||
|
||||
/** 나이·cut·위기/필터 제외를 적용해 사용 가능한 메시지만 시간순으로 남긴다. */
|
||||
const window = (desc: MessageWithId[]): MessageWithId[] => {
|
||||
const asc = [...desc].reverse();
|
||||
// 위기 교환 쌍 제외 — crisis assistant와 그 replyTo user 메시지(§5.5)
|
||||
const excludedUserIds = new Set<string>();
|
||||
for (const m of asc) {
|
||||
if (m.role === "assistant" && m.crisis && m.replyTo) excludedUserIds.add(m.replyTo);
|
||||
}
|
||||
|
||||
const windowed = asc.filter((m: MessageWithId) => {
|
||||
return asc.filter((m: MessageWithId) => {
|
||||
const ms = m.createdAt.toMillis();
|
||||
if (ms < minCreatedAt || ms < cutAt) return false;
|
||||
if (m.role === "assistant" && (m.crisis || m.filtered)) return false;
|
||||
if (m.role === "user" && excludedUserIds.has(m.messageId)) return false;
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const lastN = windowed.slice(-config.historyTurns * 2);
|
||||
let windowed = window(firstPage);
|
||||
// 가져온 만큼을 다 채웠는데도 목표에 못 미친다 = 제외된 분량 때문에 잘렸을 수
|
||||
// 있다는 뜻. 더 오래된 유효 메시지가 남아 있을 수 있으므로 한 번만 넓혀 재조회한다.
|
||||
// (스레드가 원래 짧아서 못 채운 경우에는 firstPage가 fetchLimit보다 작아 재조회하지 않는다.)
|
||||
if (windowed.length < target && firstPage.length === fetchLimit) {
|
||||
windowed = window(await getRecentMessages(uid, threadId, target + 40));
|
||||
}
|
||||
|
||||
const lastN = windowed.slice(-target);
|
||||
// provider 제약: 첫 메시지는 user여야 한다 — 앞쪽 assistant 잔여분 제거
|
||||
while (lastN.length > 0 && lastN[0].role === "assistant") lastN.shift();
|
||||
return lastN.map((m) => ({ role: m.role, content: m.content }));
|
||||
return {
|
||||
messages: lastN.map((m) => ({ role: m.role, content: m.content })),
|
||||
threadExists: thread != null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 비용 가드 80% 운영 알림(§8.3) — 인스턴스·날짜당 1회만 경고. */
|
||||
@ -277,20 +310,30 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
|
||||
crisis: true, crisisType: crisis.type,
|
||||
msgLen: message.length, latencyMs: Date.now() - startedAt,
|
||||
});
|
||||
return buildSendResult(uid, date, config, saved.assistantMessageId, reply, true, saved.createdAt);
|
||||
return buildSendResult(
|
||||
uid, date, config, saved.assistantMessageId, reply, true, saved.createdAt,
|
||||
undefined, undefined, outcome.used,
|
||||
);
|
||||
}
|
||||
|
||||
let saved: ExchangeResult | null = null;
|
||||
// loadHistory가 읽은 스레드 존재 여부 — 저장 단계로 넘겨 중복 read를 없앤다.
|
||||
let threadExists: boolean | undefined;
|
||||
let finalReply = "";
|
||||
let finalCrisis = false;
|
||||
let finalToolCalls: ChatToolCallInfo[] = [];
|
||||
let finalActions: NavAction[] = [];
|
||||
try {
|
||||
// 6) 컨텍스트 조립(§5)
|
||||
const ctx: UserContext = await gatherUserContext(uid, user, config);
|
||||
// 정체성만 조립한다 — 시사 데이터는 도구로 조회되므로 여기서 선조회하지 않는다
|
||||
const ctx: IdentityContext = identityContext(user, config);
|
||||
const { system, leakBody } = assemblePrompt(config, ctx);
|
||||
const history = await loadHistory(uid, activeThreadId, config);
|
||||
const messages: ChatProviderMessage[] = [...history, { role: "user", content: message }];
|
||||
threadExists = history.threadExists;
|
||||
const messages: ChatProviderMessage[] = [
|
||||
...history.messages,
|
||||
{ role: "user", content: message },
|
||||
];
|
||||
|
||||
// 7) 입력 필터 2단계(provider 모더레이션, 선택) — 차단 시 422 + 차감 복원
|
||||
const provider = getChatProvider(config.provider);
|
||||
@ -383,6 +426,7 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
|
||||
crisis: crisisOut,
|
||||
toolCalls,
|
||||
actions,
|
||||
...(threadExists !== undefined ? { threadExists } : {}),
|
||||
retentionDays: config.retentionDays,
|
||||
});
|
||||
finalReply = reply;
|
||||
@ -413,9 +457,11 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 11) 응답 반환 — 저장 성공 이후의 쿼터 재조회 실패는 복원 대상이 아니다(§6.2)
|
||||
// 11) 응답 반환 — 예약 트랜잭션이 계산한 used를 그대로 쓴다. 여기 도달하는
|
||||
// 경로에서 used를 바꾸는 것은 refund뿐인데, refund는 모두 throw로 빠진다.
|
||||
return buildSendResult(
|
||||
uid, date, config, saved.assistantMessageId, finalReply, finalCrisis, saved.createdAt, finalToolCalls, finalActions,
|
||||
uid, date, config, saved.assistantMessageId, finalReply, finalCrisis, saved.createdAt,
|
||||
finalToolCalls, finalActions, outcome.used,
|
||||
);
|
||||
}
|
||||
|
||||
@ -548,17 +594,17 @@ export async function getSuggestions(uid: string): Promise<ChatSuggestionsView>
|
||||
let priority: ChatSuggestion[] = [];
|
||||
try {
|
||||
const user = await getUser(uid);
|
||||
const ctx = await gatherUserContext(uid, user, config);
|
||||
const flags = await gatherSuggestionFlags(uid, user);
|
||||
candidates = config.suggestions.filter((s) => {
|
||||
if (s.requiresTeam && ctx.teamCode == null) return false;
|
||||
if (s.requiresTeam && flags.teamCode == null) return false;
|
||||
// 6.2 표의 "오늘 경기 없음 → Q7/Q11/Q12 제외"는 질문 문구("오늘 우리 경기")에
|
||||
// 맞춰 "응원팀의 오늘 경기 유무"로 해석해 적용한다(리그 전체 기준보다 엄격)
|
||||
if (s.requiresTodayTeamGame && !ctx.hasTodayTeamGame) return false;
|
||||
if (s.requiresYesterdayRecap && !ctx.hasYesterdayRecap) return false;
|
||||
if (s.excludeWhenPredictedToday && ctx.hasPredictedToday) return false;
|
||||
if (s.requiresTodayTeamGame && !flags.hasTodayTeamGame) return false;
|
||||
if (s.requiresYesterdayRecap && !flags.hasYesterdayRecap) return false;
|
||||
if (s.excludeWhenPredictedToday && flags.hasPredictedToday) return false;
|
||||
return true;
|
||||
});
|
||||
if (ctx.hasYesterdayRecap) {
|
||||
if (flags.hasYesterdayRecap) {
|
||||
priority = candidates.filter((s) => s.priorityWhenRecap);
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@ -2,7 +2,7 @@ import { FieldValue } from "firebase-admin/firestore";
|
||||
import { logger } from "firebase-functions";
|
||||
import { firestore, rtdb } from "../firebase";
|
||||
import { HttpError } from "../middleware/errors";
|
||||
import { getGame } from "../repositories/gameRepository";
|
||||
import { getGame, invalidateGameDay } from "../repositories/gameRepository";
|
||||
import { getAllUserVotes, deleteGameVotes } from "../repositories/voteRepository";
|
||||
import { invalidateStats } from "./statsService";
|
||||
import { fromTimestamp } from "../types/dateString";
|
||||
@ -36,6 +36,9 @@ export async function processGameEndWithGame(
|
||||
: voted === game.winningTeamCode;
|
||||
rtdbUpdates[`/userVotes/${uid}/${date}/${gameId}/team`] = voted;
|
||||
rtdbUpdates[`/userVotes/${uid}/${date}/${gameId}/result`] = result;
|
||||
// 날짜별 미러도 같은 원자 update에 포함 — 아카이브가 이쪽을 읽는다.
|
||||
rtdbUpdates[`/userVotesByDate/${date}/${uid}/${gameId}/team`] = voted;
|
||||
rtdbUpdates[`/userVotesByDate/${date}/${uid}/${gameId}/result`] = result;
|
||||
}
|
||||
if (Object.keys(rtdbUpdates).length > 0) {
|
||||
await rtdb.ref().update(rtdbUpdates);
|
||||
@ -71,5 +74,8 @@ export async function markGameEnded(
|
||||
winningTeamCode: isDraw ? FieldValue.delete() : winningTeamCode,
|
||||
endedAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
// 스코어·상태가 바뀌었으므로 해당 날짜의 games 캐시를 즉시 버린다.
|
||||
const time = (snap.data() as { time?: { toDate(): Date } }).time;
|
||||
if (time) invalidateGameDay(fromTimestamp(time));
|
||||
return { ok: true, gameId };
|
||||
}
|
||||
|
||||
@ -4,9 +4,14 @@ import {
|
||||
fetchScheduleFromKbo,
|
||||
statusFromRecord,
|
||||
} from "../repositories/kboRepository";
|
||||
import {
|
||||
invalidateAllGameDays,
|
||||
invalidateGameDay,
|
||||
} from "../repositories/gameRepository";
|
||||
import { getGameList } from "./gameListService";
|
||||
import type { GameListRecord } from "../kbo/game-list";
|
||||
import type { ScheduleGame, GameStatus } from "../kbo/schedule";
|
||||
import { parseDateString } from "../types/dateString";
|
||||
import type { Game } from "../types/panit";
|
||||
|
||||
const COLLECTION = "games";
|
||||
@ -109,7 +114,11 @@ export async function syncGamesForMonth(year: number, month: number): Promise<nu
|
||||
count++;
|
||||
});
|
||||
|
||||
if (count > 0) await batch.commit();
|
||||
if (count > 0) {
|
||||
await batch.commit();
|
||||
// 월 전체에 걸쳐 쓰므로 날짜별 무효화 대신 일괄 폐기한다.
|
||||
invalidateAllGameDays();
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
@ -196,6 +205,13 @@ export async function forceSyncDay(
|
||||
updated++;
|
||||
});
|
||||
|
||||
if (updated > 0) await batch.commit();
|
||||
if (updated > 0) {
|
||||
await batch.commit();
|
||||
invalidateGameDay(
|
||||
parseDateString(
|
||||
`${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}`
|
||||
)
|
||||
);
|
||||
}
|
||||
return { updated };
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ import {
|
||||
streakBonus,
|
||||
thresholdsFor,
|
||||
} from "../constants/judgment";
|
||||
import type { DailyJudgment, RankSnapshot, VoteHistoryDoc } from "../types/panit";
|
||||
import type { DailyJudgment, RankSnapshot, User, VoteHistoryDoc } from "../types/panit";
|
||||
import { addDays, type DateString } from "../types/dateString";
|
||||
import { settleDailyReward } from "./rewardSettlementService";
|
||||
|
||||
@ -56,12 +56,17 @@ export async function hasMissedGameDayBetween(
|
||||
* @param voteDoc - 해당 날짜의 voteHistory 도큐먼트 내용(판정 필드 미포함)
|
||||
* @param opts.gameCache - 여러 유저를 처리할 때 날짜별 games read를 공유하는 캐시
|
||||
* @param opts.rankSnapshot - 판정 직전 rank 스냅샷. 제공 시 판정 트랜잭션에 함께 기록한다.
|
||||
* @param opts.userPre - 호출자가 이미 읽은 판정 전 user 문서. 넘기면 재조회하지 않는다.
|
||||
*/
|
||||
export async function judgeDay(
|
||||
uid: string,
|
||||
date: DateString,
|
||||
voteDoc: VoteHistoryDoc,
|
||||
opts?: { gameCache?: GameDayCache; rankSnapshot?: RankSnapshot | null }
|
||||
opts?: {
|
||||
gameCache?: GameDayCache;
|
||||
rankSnapshot?: RankSnapshot | null;
|
||||
userPre?: User | null;
|
||||
}
|
||||
): Promise<void> {
|
||||
const fetch = opts?.gameCache ?? createGameDayCache();
|
||||
const games = await fetch.listByDate(date);
|
||||
@ -80,19 +85,30 @@ export async function judgeDay(
|
||||
|
||||
// 결석 여부는 휴장일을 제외하고 판단한다. (lastJudged, date) 구간에 실제
|
||||
// 경기일이 하나라도 있었는데 user가 그날 안 했으면 streak 끊김.
|
||||
const userPre = await getUser(uid);
|
||||
// 판정 트랜잭션 내부의 tx.get은 원자성(멱등 가드 + streak read-modify-write)상
|
||||
// 필수라 남긴다. 여기 read만 호출자 주입으로 제거 가능하다.
|
||||
const userPre =
|
||||
opts && "userPre" in opts ? opts.userPre : await getUser(uid);
|
||||
const lastJudgedPre = userPre?.lastJudgedDate;
|
||||
const streakBrokenIn =
|
||||
lastJudgedPre != null &&
|
||||
lastJudgedPre < addDays(date, -1) &&
|
||||
(await hasMissedGameDayBetween(lastJudgedPre, date, fetch));
|
||||
|
||||
// 통산 승률 모집단은 판정(skip 포함)과 무관하게 "결과가 확정된 투표" 전부다.
|
||||
// 취소 무효표(result 없음)는 제외한다 — aggregate()의 정의와 동일.
|
||||
const countable = voteDoc.data.filter((v) => typeof v.result === "boolean");
|
||||
|
||||
const tx = await applyDailyJudgmentTx(uid, date, {
|
||||
judgment,
|
||||
correctCount,
|
||||
completedCount,
|
||||
streakBrokenIn,
|
||||
rankSnapshot: opts?.rankSnapshot ?? undefined,
|
||||
lifetimeDelta: {
|
||||
predictions: countable.length,
|
||||
correct: countable.filter((v) => v.result).length,
|
||||
},
|
||||
computePoints: (streakAfter) => correctCount * 10 + streakBonus(streakAfter),
|
||||
});
|
||||
|
||||
@ -103,7 +119,7 @@ export async function judgeDay(
|
||||
// 정상 멱등 재실행에서는 doc이 이미 존재하므로 판정 필드를 덮어쓰지 않는다.
|
||||
const existing = await getDay(uid, date);
|
||||
if (!existing) await setDay(uid, date, voteDoc);
|
||||
await settleDailyReward(uid, date).catch((err) => logger.error(`reward settlement failed uid=${uid} date=${date}`, err));
|
||||
await settleDailyReward(uid, date, fetch).catch((err) => logger.error(`reward settlement failed uid=${uid} date=${date}`, err));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -4,7 +4,6 @@ import { HttpError } from "../middleware/errors";
|
||||
import { getOrder, listOrders, orderRef } from "../repositories/orderRepository";
|
||||
import { productRef } from "../repositories/productRepository";
|
||||
import { applyPointChangesTx } from "./pointService";
|
||||
import { getWalletTx } from "../repositories/walletRepository";
|
||||
import { PointLedgerType } from "../types/points";
|
||||
import {
|
||||
ORDER_TRANSITIONS,
|
||||
@ -72,14 +71,16 @@ export async function createOrder(uid: string, input: CreateOrderInput) {
|
||||
const snapById = new Map<string, DocumentSnapshot>(); for (const item of input.items) {
|
||||
if (!snapById.has(item.productId)) snapById.set(item.productId, await tx.get(productRef(item.productId)));
|
||||
}
|
||||
const currentWallet = await getWalletTx(tx, uid);
|
||||
const orderItems: OrderItem[] = input.items.map((item) => {
|
||||
const p = snapById.get(item.productId)!.data() as ProductDoc | undefined;
|
||||
if (!p || !p.active || !p.redeemable) throw new HttpError(409, "product unavailable", "PRODUCT_UNAVAILABLE");
|
||||
const option = resolveOrderOption(p, item.optionId);
|
||||
return { productId: item.productId, qty: item.qty, pointPrice: p.pointPrice, name: p.name, ...(option ? { optionId: option.id, optionName: option.name } : {}) };
|
||||
});
|
||||
const total = orderItems.reduce((n, i) => n + i.pointPrice * i.qty, 0); if ((currentWallet?.availableBalance ?? 0) < total) throw new HttpError(409, "insufficient balance", "INSUFFICIENT_BALANCE");
|
||||
// 잔액 검사는 applyPointChangesTx가 담당한다(pointService.ts:24가 동일한
|
||||
// HttpError(409, INSUFFICIENT_BALANCE)를 던진다). 같은 트랜잭션 안이라
|
||||
// 사전 검사를 두면 동일한 wallet 문서를 두 번 tx.get 하는 것 외에 차이가 없다.
|
||||
const total = orderItems.reduce((n, i) => n + i.pointPrice * i.qty, 0);
|
||||
// 주문은 즉시 확정 — 홀드 없이 바로 차감하고, 되돌림은 어드민 환불로만 처리한다.
|
||||
const debitTxId = `${uid}:order:${id}:debit`; const wallet = await applyPointChangesTx(tx, uid, [{ txId: debitTxId, type: PointLedgerType.OrderCapture, amount: total, orderId: id }]);
|
||||
const order: OrderDoc = { uid, items: orderItems, totalPoints: total, recipient: input.recipient, status: "confirmed", clientIdempotencyKey: input.clientIdempotencyKey, debitLedgerTxId: debitTxId, orderedAt: now, confirmedAt: now, statusHistory: [{ status: "confirmed", at: now, actor: uid }], createdAt: now, updatedAt: now };
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import { Timestamp, type Transaction } from "firebase-admin/firestore";
|
||||
import { firestore } from "../firebase";
|
||||
import { HttpError } from "../middleware/errors";
|
||||
import { HttpError, isAlreadyExistsError } from "../middleware/errors";
|
||||
import { createLedgerEntryTx } from "../repositories/pointLedgerRepository";
|
||||
import { getWalletTx, walletDocRef } from "../repositories/walletRepository";
|
||||
import { OP_BY_TYPE, PointLedgerType, type PointChange, type PointLedgerEntry, type WalletDoc } from "../types/points";
|
||||
|
||||
const TX_ID = /^[A-Za-z0-9:_-]{1,240}$/;
|
||||
export function isAlreadyExistsError(err: unknown): boolean { const code = (err as { code?: unknown })?.code; return code === 6 || code === "already-exists" || code === "ALREADY_EXISTS"; }
|
||||
export { isAlreadyExistsError };
|
||||
export async function applyPointChangesTx(tx: Transaction, uid: string, changes: PointChange[]): Promise<WalletDoc | null> {
|
||||
for (const c of changes) if (!TX_ID.test(c.txId) || !Number.isSafeInteger(c.amount) || c.amount <= 0) throw new HttpError(400, "invalid point change", "INVALID_POINT_CHANGE");
|
||||
const existing = await getWalletTx(tx, uid);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { HttpError } from "../middleware/errors";
|
||||
import { getGame, listByDate } from "../repositories/gameRepository";
|
||||
import { getGame, listByDateCached } from "../repositories/gameRepository";
|
||||
import {
|
||||
getUserVote,
|
||||
submitVote,
|
||||
@ -107,7 +107,7 @@ export async function getMyVotes(
|
||||
|
||||
export async function listGamesByDate(date: string): Promise<GameDto[]> {
|
||||
try {
|
||||
const games = await listByDate(parseDateString(date));
|
||||
const games = await listByDateCached(parseDateString(date));
|
||||
return games.map(toGameDto);
|
||||
} catch (err) {
|
||||
throw new HttpError(400, (err as Error).message);
|
||||
|
||||
@ -4,6 +4,7 @@ import {
|
||||
countRankedUsers,
|
||||
countUsersAboveTierPoints,
|
||||
getUser,
|
||||
listAllRankedUsers,
|
||||
listTopByTierPoints,
|
||||
type ScoreboardUserEntry,
|
||||
} from "../repositories/userRepository";
|
||||
@ -11,40 +12,93 @@ import { writeScope, type Scope } from "../repositories/scoreboardCacheRepositor
|
||||
import { tierOf } from "../constants/tiers";
|
||||
import { computePercentile, deltaFor } from "./scoreboardHelpers";
|
||||
import type { DateString } from "../types/dateString";
|
||||
import { TeamCode, type RankSnapshot } from "../types/panit";
|
||||
import { TeamCode, type RankSnapshot, type User } from "../types/panit";
|
||||
import type {
|
||||
ScoreboardEntry,
|
||||
ScoreboardScopeCache,
|
||||
} from "../types/scoreboard";
|
||||
|
||||
/**
|
||||
* 유저 1명의 현재 `tierPoints` 기반 rank를 count aggregation으로 계산해
|
||||
* `rankSnapshot`에 기록한다. `dailyArchive`에서 `judgeDay` **이전에** 호출해
|
||||
* 스냅샷이 "직전 상태의 rank"를 담도록 한다.
|
||||
* run 스코프 순위표 — `tierPoints`만으로 순위를 이진 탐색으로 구한다.
|
||||
*
|
||||
* `dailyArchive`처럼 유저 N명을 한 run에서 처리하는 경로에서, 유저마다 1~2회씩
|
||||
* count aggregation을 발행하던 O(N×M) 비용을 순위표 1회 로드로 대체한다.
|
||||
*/
|
||||
export interface RankStandings {
|
||||
/** 동점자 동일 순위("초과 인원 + 1"). `teamCode` 지정 시 팀 내 순위. */
|
||||
rankOf(tierPoints: number, teamCode?: TeamCode): number;
|
||||
}
|
||||
|
||||
/** 내림차순 배열에서 `threshold` 초과 원소 개수(= 첫 `<= threshold` 위치). */
|
||||
function countAbove(sortedDesc: number[], threshold: number): number {
|
||||
let lo = 0;
|
||||
let hi = sortedDesc.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (sortedDesc[mid] > threshold) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
|
||||
/** 랭킹 대상 전원 목록으로 순위표를 만든다. */
|
||||
export function buildRankStandings(
|
||||
users: Array<{ tierPoints: number; favoriteTeamCode?: TeamCode }>
|
||||
): RankStandings {
|
||||
const overall = users.map((u) => u.tierPoints).sort((a, b) => b - a);
|
||||
const byTeam = new Map<TeamCode, number[]>();
|
||||
for (const u of users) {
|
||||
if (!u.favoriteTeamCode) continue;
|
||||
const list = byTeam.get(u.favoriteTeamCode);
|
||||
if (list) list.push(u.tierPoints);
|
||||
else byTeam.set(u.favoriteTeamCode, [u.tierPoints]);
|
||||
}
|
||||
for (const list of byTeam.values()) list.sort((a, b) => b - a);
|
||||
|
||||
return {
|
||||
rankOf(tierPoints: number, teamCode?: TeamCode): number {
|
||||
const list = teamCode ? byTeam.get(teamCode) ?? [] : overall;
|
||||
return countAbove(list, tierPoints) + 1;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 랭킹 대상 전원을 읽어 순위표를 만든다. run 시작 시 1회만 호출할 것. */
|
||||
export async function loadRankStandings(): Promise<RankStandings> {
|
||||
return buildRankStandings(await listAllRankedUsers());
|
||||
}
|
||||
|
||||
/**
|
||||
* 유저 1명의 현재 `tierPoints` 기반 rank를 계산한다. `dailyArchive`에서
|
||||
* `judgeDay` **이전에** 호출해 스냅샷이 "직전 상태의 rank"를 담도록 한다.
|
||||
*
|
||||
* `tierPoints === 0`이면 스냅샷을 남기지 않는다.
|
||||
*
|
||||
* @param opts.user - 호출자가 이미 읽은 user 문서. 넘기면 재조회하지 않는다.
|
||||
* @param opts.standings - run 스코프 순위표. 넘기면 count aggregation을 쓰지 않는다.
|
||||
*/
|
||||
export async function computeRankSnapshot(
|
||||
uid: string,
|
||||
date: DateString
|
||||
date: DateString,
|
||||
opts?: { user?: User | null; standings?: RankStandings }
|
||||
): Promise<RankSnapshot | null> {
|
||||
const user = await getUser(uid);
|
||||
const user = opts && "user" in opts ? opts.user : await getUser(uid);
|
||||
if (!user) return null;
|
||||
const tierPoints = user.tierPoints ?? 0;
|
||||
if (tierPoints <= 0) return null;
|
||||
|
||||
const overallAbove = await countUsersAboveTierPoints(tierPoints);
|
||||
const standings = opts?.standings;
|
||||
const snapshot: RankSnapshot = {
|
||||
date,
|
||||
overall: overallAbove + 1,
|
||||
overall: standings ?
|
||||
standings.rankOf(tierPoints) :
|
||||
(await countUsersAboveTierPoints(tierPoints)) + 1,
|
||||
};
|
||||
|
||||
if (user.favoriteTeamCode) {
|
||||
const teamAbove = await countUsersAboveTierPoints(
|
||||
tierPoints,
|
||||
user.favoriteTeamCode
|
||||
);
|
||||
snapshot.team = teamAbove + 1;
|
||||
snapshot.team = standings ?
|
||||
standings.rankOf(tierPoints, user.favoriteTeamCode) :
|
||||
(await countUsersAboveTierPoints(tierPoints, user.favoriteTeamCode)) + 1;
|
||||
snapshot.teamCode = user.favoriteTeamCode;
|
||||
}
|
||||
|
||||
@ -132,20 +186,28 @@ async function buildScopeCache(scope: Scope): Promise<ScoreboardScopeCache> {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 크론에서 호출: overall + 10팀 각각의 top 10 / totalCount를 RTDB에 기록한다.
|
||||
* `snapshotRanksForUsers` 이후에 호출해야 rankDelta가 정확함.
|
||||
*/
|
||||
export async function precomputeScoreboardCache(
|
||||
date: DateString
|
||||
): Promise<void> {
|
||||
const scopes: Scope[] = [
|
||||
/** overall + 10팀 = 전체 11개 스코프. */
|
||||
export function allScoreboardScopes(): Scope[] {
|
||||
return [
|
||||
{ kind: "overall" },
|
||||
...Object.values(TeamCode).map(
|
||||
(teamCode) => ({ kind: "team" as const, teamCode })
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* overall + 10팀 각각의 top 10 / totalCount를 RTDB에 기록한다.
|
||||
* `snapshotRanksForUsers` 이후에 호출해야 rankDelta가 정확함.
|
||||
*
|
||||
* @param scopes 재계산할 스코프. 한 유저의 변경처럼 영향 범위가 좁을 땐 해당
|
||||
* 스코프만 넘겨 11개 전체 재계산(~110 read + 11 aggregation)을 피한다.
|
||||
* 생략 시 전체(크론 경로).
|
||||
*/
|
||||
export async function precomputeScoreboardCache(
|
||||
date: DateString,
|
||||
scopes: Scope[] = allScoreboardScopes()
|
||||
): Promise<void> {
|
||||
for (const scope of scopes) {
|
||||
try {
|
||||
const doc = await buildScopeCache(scope);
|
||||
|
||||
@ -1,20 +1,30 @@
|
||||
import { Timestamp } from "firebase-admin/firestore";
|
||||
import { firestore } from "../firebase";
|
||||
import { PREDICTION_PARTICIPATION_POINTS, PREDICTION_PERFECT_BONUS_POINTS, PREDICTION_SUCCESS_POINTS } from "../constants/points";
|
||||
import { listByDate } from "../repositories/gameRepository";
|
||||
import { createGameDayCache, type GameDayCache } from "../repositories/gameRepository";
|
||||
import { PointLedgerType, type VoteHistoryDoc } from "../types/panit";
|
||||
import type { DateString } from "../types/dateString";
|
||||
import type { PointChange } from "../types/points";
|
||||
import { applyPointChangesTx, isAlreadyExistsError } from "./pointService";
|
||||
|
||||
export type SettlementResult = "settled" | "no_history" | "already_settled" | "not_judged";
|
||||
export async function settleDailyReward(uid: string, date: DateString): Promise<{ result: SettlementResult; total: number }> {
|
||||
const eligible = (await listByDate(date)).filter((g) => g.status === "completed"); const ref = firestore.doc(`users/${uid}/voteHistory/${date}`);
|
||||
|
||||
/**
|
||||
* 하루치 예측 보상을 정산한다.
|
||||
*
|
||||
* @param gameCache - 여러 유저를 한 run에서 처리할 때 날짜별 games read를 공유하는 캐시.
|
||||
* 미지정 시 자체 생성하지만, 하위 조회가 공유 캐시를 거치므로 단독 호출도 안전하다.
|
||||
*/
|
||||
export async function settleDailyReward(uid: string, date: DateString, gameCache?: GameDayCache): Promise<{ result: SettlementResult; total: number }> {
|
||||
const ref = firestore.doc(`users/${uid}/voteHistory/${date}`);
|
||||
try {
|
||||
return await firestore.runTransaction(async (tx) => {
|
||||
const snap = await tx.get(ref); if (!snap.exists) return { result: "no_history" as const, total: 0 };
|
||||
const history = snap.data() as VoteHistoryDoc; if (history.rewardSettledAt) return { result: "already_settled" as const, total: history.rewardTotal ?? 0 };
|
||||
if (!history.judgment) return { result: "not_judged" as const, total: 0 };
|
||||
// games 조회는 full 판정에만 필요하므로 조기 return 가드 뒤에서 수행한다 —
|
||||
// no_history/already_settled/not_judged 재실행은 games read 0회로 끝난다.
|
||||
const eligible = (await (gameCache ?? createGameDayCache()).listByDate(date)).filter((g) => g.status === "completed");
|
||||
const voted = new Set(history.data.filter((v) => !v.cancelled).map((v) => v.gameId));
|
||||
const full = eligible.length > 0 && eligible.every((g) => voted.has(g.gameId)); const changes: PointChange[] = [];
|
||||
if (full) {
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import {rtdb} from "../firebase";
|
||||
import {HttpError} from "../middleware/errors";
|
||||
import {tierOf} from "../constants/tiers";
|
||||
import {getAll, getDay} from "../repositories/voteHistoryRepository";
|
||||
import {getUser} from "../repositories/userRepository";
|
||||
import {getAll, getDay, getRange} from "../repositories/voteHistoryRepository";
|
||||
import {getUser, updateUser} from "../repositories/userRepository";
|
||||
import {hasMissedGameDayBetween} from "./judgmentService";
|
||||
import type {DailyJudgment, StatsResponse, VoteHistoryDoc} from "../types/panit";
|
||||
import {EMPTY_VOTE_HISTORY_DTO, toVoteHistoryDto} from "../types/dto/statsDto";
|
||||
@ -17,6 +17,9 @@ import {
|
||||
type DateString,
|
||||
} from "../types/dateString";
|
||||
|
||||
/** 조회 가능한 가장 이른 연도 — 서비스 개시 이전은 받지 않는다. */
|
||||
const EARLIEST_STATS_YEAR = 2024;
|
||||
|
||||
type Period =
|
||||
| "current"
|
||||
| { kind: "year"; year: number }
|
||||
@ -33,18 +36,55 @@ type Period =
|
||||
* - `"2026-04-23"` → 해당 날짜가 속한 주 (화~월)
|
||||
*
|
||||
* @param p - 기간 문자열
|
||||
* @throws {HttpError} 400 — 형식이 맞지 않을 때
|
||||
* @throws {HttpError} 400 — 형식이 맞지 않거나 허용 범위를 벗어날 때
|
||||
*/
|
||||
function parsePeriod(p: string | undefined): Period {
|
||||
if (!p || p === "current") return "current";
|
||||
const today = todayKst();
|
||||
const {y: nowYear} = parseYmd(today);
|
||||
// 범위 검증 — 검증이 없으면 서로 다른 period 값을 무한히 만들어
|
||||
// 캐시를 매번 미스시키고 전수 집계를 강제할 수 있다.
|
||||
const inYearRange = (y: number) => y >= EARLIEST_STATS_YEAR && y <= nowYear;
|
||||
|
||||
const mYear = /^(\d{4})$/.exec(p);
|
||||
if (mYear) return {kind: "year", year: Number(mYear[1])};
|
||||
if (mYear) {
|
||||
const year = Number(mYear[1]);
|
||||
if (!inYearRange(year)) throw new HttpError(400, `invalid period: ${p}`);
|
||||
return {kind: "year", year};
|
||||
}
|
||||
const mMonth = /^(\d{4})-(\d{2})$/.exec(p);
|
||||
if (mMonth) return {kind: "month", year: Number(mMonth[1]), month: Number(mMonth[2])};
|
||||
const mDate = /^(\d{4})-(\d{2})-(\d{2})$/.exec(p);
|
||||
if (mDate) return {kind: "week", tuesday: tuesdayOf(parseDateString(p))};
|
||||
if (mMonth) {
|
||||
const year = Number(mMonth[1]);
|
||||
const month = Number(mMonth[2]);
|
||||
if (!inYearRange(year) || month < 1 || month > 12) {
|
||||
throw new HttpError(400, `invalid period: ${p}`);
|
||||
}
|
||||
return {kind: "month", year, month};
|
||||
}
|
||||
const mDate = /^(\d{4})-(\d{2})-(\d{2})$/.exec(p);
|
||||
if (mDate) {
|
||||
if (!inYearRange(Number(mDate[1])) || p > today) {
|
||||
throw new HttpError(400, `invalid period: ${p}`);
|
||||
}
|
||||
return {kind: "week", tuesday: tuesdayOf(parseDateString(p))};
|
||||
}
|
||||
throw new HttpError(400, `invalid period: ${p}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 키를 기간의 정규형으로 만든다.
|
||||
*
|
||||
* 원본 쿼리 문자열을 그대로 키로 쓰면 같은 주를 가리키는 7개 날짜가 7개 캐시
|
||||
* 엔트리가 된다. 화요일로 접어 키 공간을 기간 수만큼으로 제한한다.
|
||||
*/
|
||||
function periodCacheKey(period: Period): string {
|
||||
if (period === "current") return "current";
|
||||
if (period.kind === "year") return `${period.year}`;
|
||||
if (period.kind === "month") {
|
||||
return `${period.year}-${String(period.month).padStart(2, "0")}`;
|
||||
}
|
||||
return `w${period.tuesday}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `YYYY-MM-DD` 문자열을 연·월·일 숫자로 분해한다.
|
||||
@ -158,6 +198,99 @@ function weeklyResultsOf(entries: Array<{ date: DateString; doc: VoteHistoryDoc
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 통산 집계를 얻는다 — 카운터가 있으면 그대로, 없으면 1회 전수 스캔 후 백필.
|
||||
*
|
||||
* 백필은 `lifetimeStatsThrough`(집계에 포함된 마지막 날짜)를 함께 기록한다.
|
||||
* 이후 `applyDailyJudgmentTx`가 그 이후 날짜만 증분하므로 이중 계상이 없다.
|
||||
* 판정은 항상 과거 날짜를 오름차순으로 처리하므로 이 기준선은 단조 증가한다.
|
||||
*/
|
||||
async function resolveLifetime(
|
||||
uid: string,
|
||||
user: {
|
||||
lifetimePredictions?: number;
|
||||
lifetimeCorrect?: number;
|
||||
lifetimeStatsThrough?: DateString;
|
||||
lastJudgedDate?: DateString;
|
||||
} | null,
|
||||
): Promise<{ totals: { total: number; correct: number } }> {
|
||||
const through = user?.lifetimeStatsThrough;
|
||||
// 기준선이 마지막 판정일보다 뒤처져 있으면 그 사이 판정이 집계에 반영되지
|
||||
// 않은 것이므로 재스캔한다. 백필과 판정이 겹칠 때(백필 스캔이 그날 문서를
|
||||
// 보기 전에 판정 트랜잭션이 커밋되면 그 판정은 기준선이 없어 증분되지 않는다)
|
||||
// 생기는 영구 누락을 자가치유한다.
|
||||
const stale =
|
||||
through != null && user?.lastJudgedDate != null && through < user.lastJudgedDate;
|
||||
if (through != null && !stale) {
|
||||
return {
|
||||
totals: {
|
||||
total: user?.lifetimePredictions ?? 0,
|
||||
correct: user?.lifetimeCorrect ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const all = await getAll(uid);
|
||||
const totals = aggregate(all);
|
||||
|
||||
// 이력이 하나도 없으면 기준선을 세우지 않는다.
|
||||
//
|
||||
// 여기서 오늘 날짜를 넣으면, 아직 아카이브되지 않은 어제 투표가 영구히 누락된다:
|
||||
// 신규 유저가 어제 처음 투표하고 오늘 03:00 아카이브 전에 통계를 조회하면
|
||||
// voteHistory가 비어 기준선이 오늘로 잡히고, 이어지는 판정은
|
||||
// `date(어제) > through(오늘)`이 false라 증분되지 않는다. 게다가 기준선이
|
||||
// lastJudgedDate보다 뒤(미래)라서 아래 stale 검사로도 복구되지 않는다.
|
||||
// 기준선을 비워 두면 다음 조회가 다시 스캔해 정확히 백필한다(빈 컬렉션이라 비용도 없다).
|
||||
if (all.length === 0) return {totals};
|
||||
|
||||
// 기준선은 "집계에 실제로 포함된 마지막 날짜"뿐이다.
|
||||
// lastJudgedDate로 앞당기면 안 된다 — judgeDay는 applyDailyJudgmentTx(=lastJudgedDate
|
||||
// 갱신)를 setDay(=voteHistory 기록)보다 먼저 하므로, 그 사이 스캔에서는
|
||||
// lastJudgedDate가 voteHistory보다 앞서 있고 그날 예측이 집계에 없는 채로
|
||||
// 기준선만 올라간다. 재스캔이 한 번 더 도는 낭비가 영구 누락보다 낫다.
|
||||
const nextThrough = all[all.length - 1].date;
|
||||
await updateUser(uid, {
|
||||
lifetimePredictions: totals.total,
|
||||
lifetimeCorrect: totals.correct,
|
||||
lifetimeStatsThrough: nextThrough,
|
||||
}).catch((err) => {
|
||||
// 백필 실패는 조회를 막지 않는다 — 다음 호출에서 다시 스캔·재시도한다.
|
||||
console.warn(`[stats] lifetime 백필 실패 uid=${uid}`, err);
|
||||
});
|
||||
return {totals};
|
||||
}
|
||||
|
||||
/** 과거 기간 집계용 항목 — 올해 구간에 이미 들어있으면 재사용한다. */
|
||||
async function entriesForPeriod(
|
||||
uid: string,
|
||||
period: Exclude<Period, "current">,
|
||||
recent: Array<{ date: DateString; doc: VoteHistoryDoc }>,
|
||||
recentStart: DateString,
|
||||
recentEnd: DateString,
|
||||
): Promise<Array<{ date: DateString; doc: VoteHistoryDoc }>> {
|
||||
const [start, end] = periodBounds(period);
|
||||
if (start >= recentStart && end <= recentEnd) {
|
||||
return recent.filter((e) => matchesPeriod(e.date, period));
|
||||
}
|
||||
return (await getRange(uid, start, end)).filter((e) => matchesPeriod(e.date, period));
|
||||
}
|
||||
|
||||
/** 기간의 날짜 경계(양끝 포함). */
|
||||
function periodBounds(period: Exclude<Period, "current">): [DateString, DateString] {
|
||||
if (period.kind === "year") {
|
||||
return [`${period.year}-01-01` as DateString, `${period.year}-12-31` as DateString];
|
||||
}
|
||||
if (period.kind === "month") {
|
||||
const mm = String(period.month).padStart(2, "0");
|
||||
const last = new Date(Date.UTC(period.year, period.month, 0)).getUTCDate();
|
||||
return [
|
||||
`${period.year}-${mm}-01` as DateString,
|
||||
`${period.year}-${mm}-${String(last).padStart(2, "0")}` as DateString,
|
||||
];
|
||||
}
|
||||
return [period.tuesday, addDays(period.tuesday, 6)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 유저의 예측 통계를 산출한다.
|
||||
* 전체·시즌·월간·주간 승률, 연속 참여일, 주간 결과, 티어를 포함한다.
|
||||
@ -166,28 +299,39 @@ function weeklyResultsOf(entries: Array<{ date: DateString; doc: VoteHistoryDoc
|
||||
* @param period - 예측 수 집계에 사용할 기간
|
||||
*/
|
||||
async function computeStats(uid: string, period: Period): Promise<StatsResponse> {
|
||||
const [all, user] = await Promise.all([getAll(uid), getUser(uid)]);
|
||||
const overall = aggregate(all);
|
||||
|
||||
const today = todayKst();
|
||||
const {y: nowYear, m: nowMonth} = parseYmd(today);
|
||||
const thisTuesday = tuesdayOf(today);
|
||||
|
||||
const seasonEntries = all.filter((e) => matchesPeriod(e.date, {kind: "year", year: nowYear}));
|
||||
const season = aggregate(seasonEntries);
|
||||
// 시즌·월간·주간·주간결과는 모두 "올해" 구간의 부분집합이다. 이번 주가 연초를
|
||||
// 걸치면 화요일까지 앞으로 늘려 한 번의 범위 조회로 전부 덮는다.
|
||||
const recentStart = (thisTuesday < `${nowYear}-01-01` ?
|
||||
thisTuesday :
|
||||
`${nowYear}-01-01`) as DateString;
|
||||
|
||||
const monthlyEntries = all.filter((e) =>
|
||||
matchesPeriod(e.date, {kind: "month", year: nowYear, month: nowMonth})
|
||||
const [recent, user] = await Promise.all([
|
||||
getRange(uid, recentStart, today),
|
||||
getUser(uid),
|
||||
]);
|
||||
|
||||
// 통산 집계는 롤링 카운터로 얻는다. 아직 백필되지 않은 유저만 1회 전수 스캔.
|
||||
const backfilled = await resolveLifetime(uid, user);
|
||||
const overall = backfilled.totals;
|
||||
|
||||
const season = aggregate(
|
||||
recent.filter((e) => matchesPeriod(e.date, {kind: "year", year: nowYear}))
|
||||
);
|
||||
const monthly = aggregate(monthlyEntries);
|
||||
|
||||
const weeklyEntries = all.filter((e) =>
|
||||
matchesPeriod(e.date, {kind: "week", tuesday: thisTuesday})
|
||||
const monthly = aggregate(
|
||||
recent.filter((e) => matchesPeriod(e.date, {kind: "month", year: nowYear, month: nowMonth}))
|
||||
);
|
||||
const weekly = aggregate(
|
||||
recent.filter((e) => matchesPeriod(e.date, {kind: "week", tuesday: thisTuesday}))
|
||||
);
|
||||
const weekly = aggregate(weeklyEntries);
|
||||
|
||||
const periodEntries = period === "current" ? all : all.filter((e) => matchesPeriod(e.date, period));
|
||||
const periodAgg = aggregate(periodEntries);
|
||||
// "current"는 통산과 동일하므로 카운터를 재사용한다. 과거 기간은 그 구간만 읽는다.
|
||||
const periodAgg = period === "current" ?
|
||||
overall :
|
||||
aggregate(await entriesForPeriod(uid, period, recent, recentStart, today));
|
||||
|
||||
// 결석으로 streak이 끊겼는지 lazy 보정.
|
||||
// 1) `lastJudgedDate`가 어제 이후면 정상.
|
||||
@ -206,10 +350,13 @@ async function computeStats(uid: string, period: Period): Promise<StatsResponse>
|
||||
}
|
||||
}
|
||||
const storedStreak = streakBroken ? 0 : user?.currentStreak;
|
||||
const streakDays = storedStreak ?? computeStreak(all);
|
||||
// 폴백은 `currentStreak`이 아직 없는 유저(레거시·신규)에만 쓰인다. 올해 구간만
|
||||
// 보므로 해를 넘긴 연속 기록은 연초에 과소 계산될 수 있다 — 판정이 한 번이라도
|
||||
// 돌면 `currentStreak`이 채워져 이 경로를 타지 않는다.
|
||||
const streakDays = storedStreak ?? computeStreak(recent);
|
||||
const highestStreak = user?.highestStreak ?? streakDays;
|
||||
const tierPoints = user?.tierPoints ?? 0;
|
||||
const weeklyResults = weeklyResultsOf(all);
|
||||
const weeklyResults = weeklyResultsOf(recent);
|
||||
|
||||
return {
|
||||
streakDays,
|
||||
@ -245,7 +392,7 @@ function cachePath(uid: string, key: string): string {
|
||||
*/
|
||||
export async function getStats(uid: string, periodParam?: string): Promise<UserStatsDto> {
|
||||
const period = parsePeriod(periodParam);
|
||||
const key = periodParam && periodParam !== "current" ? periodParam : "current";
|
||||
const key = periodCacheKey(period);
|
||||
|
||||
// 캐시는 산출 시점의 KST 날짜(`forDate`)와 함께 저장된다.
|
||||
// 날짜가 바뀌면 streak/weeklyResults 기준이 달라지므로 무효화하고 재계산한다.
|
||||
|
||||
@ -48,6 +48,8 @@ function toUserProfile(user: User): UserProfileDto {
|
||||
knowledgeLevel: user.knowledgeLevel,
|
||||
createdAt: toIsoOrUndefined(user.createdAt),
|
||||
lastJudgedDate: user.lastJudgedDate,
|
||||
notifications: user.notifications ?? {},
|
||||
marketingOptIn: user.marketingOptIn ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
@ -191,6 +193,8 @@ export async function createMe(
|
||||
provider,
|
||||
knowledgeLevel,
|
||||
createdAt: toIso(Timestamp.now()),
|
||||
notifications: {},
|
||||
marketingOptIn: false,
|
||||
};
|
||||
if (token.picture) profile.photoUrl = token.picture;
|
||||
if (favoriteTeamCode) profile.favoriteTeamCode = favoriteTeamCode;
|
||||
@ -230,8 +234,15 @@ export async function deleteMe(token: DecodedIdToken): Promise<void> {
|
||||
if (code !== "auth/user-not-found") throw err;
|
||||
}
|
||||
// 사전계산된 랭킹 리스트에서 즉시 제외. 실패해도 다음 새벽 재계산이 정리한다.
|
||||
// 탈퇴 유저가 영향을 줄 수 있는 스코프는 overall과 본인 응원팀뿐이므로
|
||||
// 11개 전체가 아니라 그 둘만 재계산한다(`existing`은 위에서 이미 읽었다).
|
||||
try {
|
||||
await precomputeScoreboardCache(todayKst());
|
||||
await precomputeScoreboardCache(todayKst(), [
|
||||
{ kind: "overall" },
|
||||
...(existing.favoriteTeamCode ?
|
||||
[{ kind: "team" as const, teamCode: existing.favoriteTeamCode }] :
|
||||
[]),
|
||||
]);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`deactivate: precomputeScoreboardCache failed uid=${token.uid}`,
|
||||
@ -290,6 +301,7 @@ export interface UpdateMeBody {
|
||||
displayName?: unknown;
|
||||
favoriteTeamCode?: unknown;
|
||||
knowledgeLevel?: unknown;
|
||||
marketingOptIn?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -336,6 +348,22 @@ export async function updateMe(
|
||||
);
|
||||
}
|
||||
|
||||
if (body.marketingOptIn !== undefined) {
|
||||
if (typeof body.marketingOptIn !== "boolean") {
|
||||
throw new HttpError(
|
||||
400,
|
||||
"marketingOptIn must be boolean",
|
||||
"INVALID_INPUT"
|
||||
);
|
||||
}
|
||||
patch.marketingOptIn = body.marketingOptIn;
|
||||
// 동의 시각은 서버 시각이 법적 근거 — 클라가 보낸 값은 쓰지 않는다.
|
||||
// 철회(false) 시에는 동의 이력(marketingOptInAt)을 보존한다.
|
||||
if (body.marketingOptIn) {
|
||||
patch.marketingOptInAt = FieldValue.serverTimestamp();
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
throw new HttpError(400, "no fields to update", "INVALID_INPUT");
|
||||
}
|
||||
@ -357,6 +385,9 @@ export async function updateMe(
|
||||
if (typeof patch.knowledgeLevel === "string") {
|
||||
merged.knowledgeLevel = patch.knowledgeLevel as KnowledgeLevel;
|
||||
}
|
||||
if (typeof patch.marketingOptIn === "boolean") {
|
||||
merged.marketingOptIn = patch.marketingOptIn;
|
||||
}
|
||||
return toUserProfile(merged);
|
||||
}
|
||||
|
||||
@ -415,9 +446,10 @@ export async function updateNotifications(
|
||||
throw new HttpError(400, "no notification keys to update", "INVALID_INPUT");
|
||||
}
|
||||
|
||||
const merged: NotificationsMap = { ...(user.notifications ?? {}), ...patch };
|
||||
await updateUser(token.uid, { notifications: merged });
|
||||
return merged;
|
||||
// set(merge:true)가 맵을 재귀 병합하므로 patch 키만 쓴다 — 전체 맵을 다시 쓰면
|
||||
// 동시 토글이 서로의 변경을 last-write-wins로 지울 수 있다.
|
||||
await updateUser(token.uid, { notifications: patch });
|
||||
return { ...(user.notifications ?? {}), ...patch };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -17,6 +17,10 @@ export interface UserProfileDto {
|
||||
knowledgeLevel: KnowledgeLevel;
|
||||
createdAt?: string;
|
||||
lastJudgedDate?: DateString;
|
||||
/** 알림 opt-in 맵. 키는 `NotificationKey`, 미설정 키는 발송 안 함과 동일. */
|
||||
notifications: NotificationsMap;
|
||||
/** 마케팅 수신 동의 여부. 동의 시각은 서버 내부 기록(marketingOptInAt)으로만 보존. */
|
||||
marketingOptIn: boolean;
|
||||
}
|
||||
|
||||
/** `GET/POST/PATCH /user` 공통 응답. */
|
||||
|
||||
@ -45,8 +45,17 @@ export enum AttendanceResult {
|
||||
AlreadyCheckedIn = "alreadyCheckedIn",
|
||||
}
|
||||
|
||||
/**
|
||||
* `users/{uid}.notifications` 맵의 허용 키. 미설정/false 모두 "발송 안 함"인 opt-in 모델.
|
||||
* 마케팅 동의는 여기 넣지 않는다 — 동의 시각 기록이 필요해 `marketingOptIn`/`marketingOptInAt`
|
||||
* 필드가 단일 소스다.
|
||||
*/
|
||||
export enum NotificationKey {
|
||||
Attendance = "attendance",
|
||||
/** 경기 시작 전 예측 마감 임박 리마인드. */
|
||||
PredictionRemind = "predictionRemind",
|
||||
/** 짹의 톡톡(선톡·경기 결과·응원 메시지). */
|
||||
JjaekTalk = "jjaekTalk",
|
||||
}
|
||||
|
||||
export type NotificationsMap = Partial<Record<NotificationKey, boolean>>;
|
||||
@ -103,6 +112,15 @@ export interface User {
|
||||
knowledgeLevel: KnowledgeLevel;
|
||||
createdAt: Timestamp;
|
||||
notifications?: NotificationsMap;
|
||||
|
||||
/**
|
||||
* 마케팅 수신 동의. `notifications` 맵과 분리된 단일 소스 —
|
||||
* 동의 시각(marketingOptInAt) 기록이 법적 요건이라 별도 필드로 관리한다.
|
||||
* 철회 시 marketingOptIn만 false로 바꾸고 동의 이력은 보존한다.
|
||||
*/
|
||||
marketingOptIn?: boolean;
|
||||
marketingOptInAt?: Timestamp;
|
||||
|
||||
/** 마지막 로그인 기기의 FCM 토큰. 클라가 직접 덮어쓰기. */
|
||||
fcmToken?: string;
|
||||
|
||||
@ -147,6 +165,19 @@ export interface User {
|
||||
*/
|
||||
lastJudgedDate?: DateString;
|
||||
|
||||
/**
|
||||
* 통산 예측 수·적중 수 롤링 집계 — `overall` 승률을 voteHistory 전수 스캔 없이
|
||||
* 산출하기 위한 것. 취소 무효표(result 없음)는 제외한 값만 누적한다.
|
||||
*
|
||||
* `lifetimeStatsThrough`는 집계에 반영된 마지막 판정 날짜다. 세 필드는 항상
|
||||
* 함께 갱신되며, 없으면 `computeStats`가 전수 스캔으로 백필한다.
|
||||
* 증분은 `applyDailyJudgmentTx`가 `date > lifetimeStatsThrough`일 때만 수행해
|
||||
* 백필과 증분이 겹쳐 이중 계상되는 것을 막는다.
|
||||
*/
|
||||
lifetimePredictions?: number;
|
||||
lifetimeCorrect?: number;
|
||||
lifetimeStatsThrough?: DateString;
|
||||
|
||||
rankSnapshot?: RankSnapshot;
|
||||
}
|
||||
|
||||
|
||||
@ -1,12 +1,17 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { Timestamp } from "firebase-admin/firestore";
|
||||
import { firestore } from "../../src/firebase";
|
||||
import { getGame, listByDate } from "../../src/repositories/gameRepository";
|
||||
import {
|
||||
getGame,
|
||||
invalidateAllGameDays,
|
||||
listByDate,
|
||||
} from "../../src/repositories/gameRepository";
|
||||
import type { DateString } from "../../src/types/dateString";
|
||||
|
||||
describe("gameRepository", () => {
|
||||
beforeEach(async () => {
|
||||
await firestore.recursiveDelete(firestore.collection("games"));
|
||||
invalidateAllGameDays();
|
||||
});
|
||||
|
||||
it("존재하지 않는 경기는 null을 반환한다", async () => {
|
||||
|
||||
@ -7,6 +7,10 @@ import {
|
||||
getCounts,
|
||||
getUserDateVotes,
|
||||
getUserVote,
|
||||
getVotesByDate,
|
||||
isVoteDateIndexBackfilled,
|
||||
deleteUserVoteGame,
|
||||
deleteUserVoteIndex,
|
||||
submitVote,
|
||||
} from "../../src/repositories/voteRepository";
|
||||
import type { DateString } from "../../src/types/dateString";
|
||||
@ -175,3 +179,87 @@ describe("voteRepository (RTDB)", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 날짜별 인덱스는 `/userVotes`의 (date, uid) 전치 미러다. dailyArchive가 이쪽만
|
||||
* 읽으므로, 원본을 바꾸는 모든 경로가 미러도 같이 갱신해야 투표가 유실되지 않는다.
|
||||
*/
|
||||
describe("voteRepository — /userVotesByDate 미러", () => {
|
||||
beforeEach(async () => {
|
||||
await rtdb.ref("/votes").remove();
|
||||
await rtdb.ref("/userVotes").remove();
|
||||
await rtdb.ref("/userVotesByDate").remove();
|
||||
});
|
||||
|
||||
it("submitVote가 날짜별 인덱스에도 기록한다", async () => {
|
||||
await submitVote({ gameId, uid, date, side: "home", team: "LG" });
|
||||
|
||||
const byDate = await getVotesByDate(date);
|
||||
expect(byDate[uid]?.[gameId]).toEqual({ team: "LG" });
|
||||
});
|
||||
|
||||
it("여러 유저의 같은 날짜 투표가 한 노드에 모인다", async () => {
|
||||
await submitVote({ gameId, uid, date, side: "home", team: "LG" });
|
||||
await submitVote({ gameId, uid: uid2, date, side: "away", team: "KIA" });
|
||||
await submitVote({ gameId: gameId2, uid, date, side: "draw", team: "DRAW" });
|
||||
|
||||
const byDate = await getVotesByDate(date);
|
||||
expect(Object.keys(byDate).sort()).toEqual([uid, uid2].sort());
|
||||
expect(Object.keys(byDate[uid])).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("changeVote가 미러의 팀도 함께 바꾼다", async () => {
|
||||
await submitVote({ gameId, uid, date, side: "home", team: "LG" });
|
||||
await changeVote({
|
||||
gameId, uid, date, oldSide: "home", newSide: "away", newTeam: "KIA",
|
||||
});
|
||||
|
||||
const byDate = await getVotesByDate(date);
|
||||
expect(byDate[uid][gameId]).toEqual({ team: "KIA" });
|
||||
});
|
||||
|
||||
it("deleteUserVoteGame이 양쪽에서 제거한다", async () => {
|
||||
await submitVote({ gameId, uid, date, side: "home", team: "LG" });
|
||||
await submitVote({ gameId: gameId2, uid, date, side: "away", team: "KIA" });
|
||||
|
||||
await deleteUserVoteGame(uid, date, gameId);
|
||||
|
||||
expect(await getUserDateVotes(uid, date)).toEqual({ [gameId2]: { team: "KIA" } });
|
||||
const byDate = await getVotesByDate(date);
|
||||
expect(byDate[uid]).toEqual({ [gameId2]: { team: "KIA" } });
|
||||
});
|
||||
|
||||
it("deleteUserVoteIndex가 그 유저의 모든 날짜 미러를 지운다", async () => {
|
||||
const other = "2026-04-13" as DateString;
|
||||
await submitVote({ gameId, uid, date, side: "home", team: "LG" });
|
||||
await submitVote({ gameId: gameId2, uid, date: other, side: "away", team: "KIA" });
|
||||
await submitVote({ gameId, uid: uid2, date, side: "home", team: "LG" });
|
||||
|
||||
await deleteUserVoteIndex(uid);
|
||||
|
||||
expect(await getUserDateVotes(uid, date)).toEqual({});
|
||||
expect((await getVotesByDate(date))[uid]).toBeUndefined();
|
||||
expect((await getVotesByDate(other))[uid]).toBeUndefined();
|
||||
// 다른 유저 기록은 남아 있어야 한다
|
||||
expect((await getVotesByDate(date))[uid2]).toEqual({ [gameId]: { team: "LG" } });
|
||||
});
|
||||
|
||||
it("투표가 없는 날짜는 빈 객체를 돌려준다", async () => {
|
||||
expect(await getVotesByDate("2026-01-01" as DateString)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("voteRepository — 백필 완료 표시", () => {
|
||||
beforeEach(async () => {
|
||||
await rtdb.ref("/userVotesByDateMeta").remove();
|
||||
});
|
||||
|
||||
it("표시가 없으면 false", async () => {
|
||||
expect(await isVoteDateIndexBackfilled()).toBe(false);
|
||||
});
|
||||
|
||||
it("표시가 있으면 true — dailyArchive가 레거시 폴백을 건너뛰는 근거", async () => {
|
||||
await rtdb.ref("/userVotesByDateMeta/backfilledAt").set(new Date().toISOString());
|
||||
expect(await isVoteDateIndexBackfilled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
129
tests/scheduled/dailyArchive.test.ts
Normal file
129
tests/scheduled/dailyArchive.test.ts
Normal file
@ -0,0 +1,129 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { Timestamp } from "firebase-admin/firestore";
|
||||
import { firestore, rtdb } from "../../src/firebase";
|
||||
import { invalidateAllGameDays } from "../../src/repositories/gameRepository";
|
||||
import { runDailyArchive } from "../../src/scheduled/dailyArchive";
|
||||
import type { DateString } from "../../src/types/dateString";
|
||||
import type { Game, User } from "../../src/types/panit";
|
||||
|
||||
const date = "2026-05-12" as DateString;
|
||||
const gameId = "20260512HTLG0";
|
||||
|
||||
async function seedGame(): Promise<void> {
|
||||
const doc: Game = {
|
||||
time: Timestamp.fromDate(new Date(Date.UTC(2026, 4, 12, 9, 0))),
|
||||
stadium: "잠실",
|
||||
status: "completed",
|
||||
homeTeamCode: "LG",
|
||||
awayTeamCode: "HT",
|
||||
winningTeamCode: "LG",
|
||||
};
|
||||
await firestore.collection("games").doc(gameId).set(doc);
|
||||
invalidateAllGameDays();
|
||||
}
|
||||
|
||||
async function seedUser(uid: string): Promise<void> {
|
||||
const user: Partial<User> = {
|
||||
displayName: uid,
|
||||
email: `${uid}@e.com`,
|
||||
provider: "google",
|
||||
knowledgeLevel: "casual",
|
||||
active: true,
|
||||
createdAt: Timestamp.now(),
|
||||
};
|
||||
await firestore.collection("users").doc(uid).set(user);
|
||||
}
|
||||
|
||||
/** 판정이 끝난 투표를 원본에 심는다. reconcile 경로를 타지 않게 result를 채운다. */
|
||||
async function seedRawVote(uid: string): Promise<void> {
|
||||
await rtdb.ref(`/userVotes/${uid}/${date}/${gameId}`).set({ team: "LG", result: true });
|
||||
}
|
||||
|
||||
/** 날짜별 인덱스에도 심는다(= 미러 배포 이후에 투표한 유저). */
|
||||
async function seedIndexedVote(uid: string): Promise<void> {
|
||||
await rtdb.ref(`/userVotesByDate/${date}/${uid}/${gameId}`).set({ team: "LG", result: true });
|
||||
}
|
||||
|
||||
async function hasHistory(uid: string): Promise<boolean> {
|
||||
const snap = await firestore
|
||||
.collection("users").doc(uid)
|
||||
.collection("voteHistory").doc(date)
|
||||
.get();
|
||||
return snap.exists;
|
||||
}
|
||||
|
||||
describe("runDailyArchive — 날짜 인덱스 롤아웃", () => {
|
||||
beforeEach(async () => {
|
||||
await firestore.recursiveDelete(firestore.collection("users"));
|
||||
await firestore.recursiveDelete(firestore.collection("games"));
|
||||
invalidateAllGameDays();
|
||||
await rtdb.ref("/userVotes").remove();
|
||||
await rtdb.ref("/userVotesByDate").remove();
|
||||
await rtdb.ref("/userVotesByDateMeta").remove();
|
||||
await seedGame();
|
||||
});
|
||||
|
||||
/**
|
||||
* 미러 배포 당일에는 인덱스가 부분적으로만 찬다 — 배포 전 투표자는 원본에만,
|
||||
* 배포 후 투표자는 양쪽에 있다. 인덱스가 비지 않았다는 이유로 원본을 건너뛰면
|
||||
* 배포 전 투표자가 영구 유실된다(그 날짜는 다시 처리되지 않는다).
|
||||
*/
|
||||
it("인덱스가 부분적으로만 찼으면 원본에서 누락 유저를 보충한다", async () => {
|
||||
await seedUser("before-deploy");
|
||||
await seedUser("after-deploy");
|
||||
// 배포 전 투표자 — 원본에만 존재
|
||||
await seedRawVote("before-deploy");
|
||||
// 배포 후 투표자 — 원본 + 인덱스
|
||||
await seedRawVote("after-deploy");
|
||||
await seedIndexedVote("after-deploy");
|
||||
|
||||
const result = await runDailyArchive(date);
|
||||
|
||||
expect(result.archived).toBe(2);
|
||||
expect(result.judgedUids.sort()).toEqual(["after-deploy", "before-deploy"]);
|
||||
expect(await hasHistory("before-deploy")).toBe(true);
|
||||
expect(await hasHistory("after-deploy")).toBe(true);
|
||||
});
|
||||
|
||||
it("인덱스가 완전히 비어도 원본만으로 아카이브한다", async () => {
|
||||
await seedUser("legacy-only");
|
||||
await seedRawVote("legacy-only");
|
||||
|
||||
const result = await runDailyArchive(date);
|
||||
|
||||
expect(result.archived).toBe(1);
|
||||
expect(await hasHistory("legacy-only")).toBe(true);
|
||||
});
|
||||
|
||||
it("백필 마커가 있으면 인덱스만 신뢰한다(원본 전체 스캔 안 함)", async () => {
|
||||
await rtdb.ref("/userVotesByDateMeta/backfilledAt").set(new Date().toISOString());
|
||||
await seedUser("indexed");
|
||||
await seedUser("stale-raw");
|
||||
await seedIndexedVote("indexed");
|
||||
await seedRawVote("indexed");
|
||||
// 인덱스에 없는 원본 잔재 — 백필 완료 후에는 보충 대상이 아니다
|
||||
await seedRawVote("stale-raw");
|
||||
|
||||
const result = await runDailyArchive(date);
|
||||
|
||||
expect(result.judgedUids).toEqual(["indexed"]);
|
||||
expect(await hasHistory("stale-raw")).toBe(false);
|
||||
});
|
||||
|
||||
it("양쪽 모두 비어 있으면 아무것도 아카이브하지 않는다", async () => {
|
||||
const result = await runDailyArchive(date);
|
||||
expect(result.archived).toBe(0);
|
||||
expect(result.judgedUids).toEqual([]);
|
||||
});
|
||||
|
||||
it("아카이브 후 원본과 날짜별 미러를 모두 정리한다", async () => {
|
||||
await seedUser("cleanup");
|
||||
await seedRawVote("cleanup");
|
||||
await seedIndexedVote("cleanup");
|
||||
|
||||
await runDailyArchive(date);
|
||||
|
||||
expect((await rtdb.ref(`/userVotes/cleanup/${date}`).get()).exists()).toBe(false);
|
||||
expect((await rtdb.ref(`/userVotesByDate/${date}/cleanup`).get()).exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -1,56 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildUserContextBlock,
|
||||
formatTodaySchedule,
|
||||
resolveKnowledgeLevel,
|
||||
resolvePersonaBlock,
|
||||
resolveTeamCode,
|
||||
sanitizeDisplayName,
|
||||
type UserContext,
|
||||
type IdentityContext,
|
||||
} from "../../src/services/chatContextService";
|
||||
import { assembleSystemPrompt } from "../../src/services/chatContextService";
|
||||
import { DEFAULT_CHAT_CONFIG } from "../../src/services/chatConfigService";
|
||||
import { CHAT_CANARY_TOKEN } from "../../src/constants/chatPrompts";
|
||||
import { KnowledgeLevel, TeamCode } from "../../src/types/panit";
|
||||
import type { DateString } from "../../src/types/dateString";
|
||||
import type { ScheduleGame } from "../../src/types/kbo";
|
||||
|
||||
function game(overrides: Partial<ScheduleGame>): ScheduleGame {
|
||||
return {
|
||||
date: "06.12",
|
||||
dayOfWeek: "금",
|
||||
time: "18:30",
|
||||
awayTeamCode: "LG",
|
||||
homeTeamCode: "HH",
|
||||
awayScore: null,
|
||||
homeScore: null,
|
||||
status: "scheduled",
|
||||
stadium: "대전",
|
||||
broadcast: "",
|
||||
note: "",
|
||||
gameId: "20260612LGHH0",
|
||||
awayStartingPitcher: { id: 1, name: "김선발" },
|
||||
homeStartingPitcher: { id: 2, name: "박선발" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function ctx(overrides: Partial<UserContext>): UserContext {
|
||||
function ctx(overrides: Partial<IdentityContext>): IdentityContext {
|
||||
return {
|
||||
date: "2026-06-12" as DateString,
|
||||
displayName: "솔방울",
|
||||
knowledgeLevel: KnowledgeLevel.Casual,
|
||||
teamCode: TeamCode.HH,
|
||||
teamName: "한화 이글스",
|
||||
todaySchedule: "오늘 경기 없음",
|
||||
todayMyPredictions: "오늘 예측 없음",
|
||||
yesterdayRecap: "어제 예측 기록 없음",
|
||||
recentTeamResults: null,
|
||||
h2hRecords: null,
|
||||
myStats: "통계 없음",
|
||||
hasYesterdayRecap: false,
|
||||
hasTodayTeamGame: false,
|
||||
hasPredictedToday: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@ -80,29 +49,6 @@ describe("chatContextService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTodaySchedule — 결정된 사항만(2-1)", () => {
|
||||
it("진행 중 경기는 확정 필드만 주입하고 스코어를 제거한다", () => {
|
||||
const out = formatTodaySchedule([game({ status: "live", awayScore: 3, homeScore: 5 })]);
|
||||
expect(out).toContain("진행 중(스코어 미제공)");
|
||||
expect(out).not.toContain("3:5"); // 스코어 미주입
|
||||
expect(out).toContain("김선발"); // 선발 예고는 확정 정보 — 주입
|
||||
});
|
||||
|
||||
it("종료 경기는 스코어를 포함한다", () => {
|
||||
const out = formatTodaySchedule([game({ status: "completed", awayScore: 2, homeScore: 7 })]);
|
||||
expect(out).toContain("2:7");
|
||||
expect(out).toContain("종료");
|
||||
});
|
||||
|
||||
it("취소 경기는 취소 라벨로 표기한다", () => {
|
||||
expect(formatTodaySchedule([game({ status: "cancelled", note: "우천취소" })])).toContain("취소");
|
||||
});
|
||||
|
||||
it("경기 없으면 부재 표기를 쓴다", () => {
|
||||
expect(formatTodaySchedule([])).toBe("오늘 경기 없음");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildUserContextBlock(2.2 템플릿)", () => {
|
||||
it("고정 구분자로 감싸고 응원팀 미설정·선택 줄 생략을 적용한다", () => {
|
||||
const block = buildUserContextBlock(ctx({ teamCode: null, teamName: null }));
|
||||
@ -114,7 +60,7 @@ describe("chatContextService", () => {
|
||||
});
|
||||
|
||||
it("정체성(닉네임·응원팀)만 주입하고 시사 수치·일정은 넣지 않는다(경량판)", () => {
|
||||
const block = buildUserContextBlock(ctx({ hasTodayTeamGame: true }));
|
||||
const block = buildUserContextBlock(ctx({}));
|
||||
expect(block).toContain("- 사용자: 솔방울");
|
||||
expect(block).not.toContain("오늘"); // 오늘 경기·일정은 컨텍스트에 없음(도구로)
|
||||
expect(block).not.toContain("최근 5경기");
|
||||
|
||||
@ -617,6 +617,60 @@ describe("chatService", () => {
|
||||
expect(contents).toEqual(["어제 경기 봤어?", "봤지! 짜릿했어", "오늘은 어때?"]);
|
||||
expect(history[0].role).toBe("user");
|
||||
});
|
||||
|
||||
/**
|
||||
* 위기 요청은 일일 한도를 우회하므로(§7.3) 창 안의 위기 교환쌍 수에 상한이 없다.
|
||||
* 위기 쌍은 문서 2개를 먹고 윈도잉에서 둘 다 빠지므로, 1차 페이지가 위기 문서로
|
||||
* 채워지면 나이 창 안에 유효 메시지가 남아 있는데도 턴 수가 모자라게 된다.
|
||||
*/
|
||||
it("위기 쌍이 1차 페이지를 채워도 목표 턴 수를 유지한다", async () => {
|
||||
const col = messagesCol(uid, "HH");
|
||||
const now = Date.now();
|
||||
const mk = (
|
||||
id: string,
|
||||
role: "user" | "assistant",
|
||||
content: string,
|
||||
atMs: number,
|
||||
flags: Partial<ChatMessageDoc> = {},
|
||||
) =>
|
||||
col.doc(id).set({
|
||||
role,
|
||||
content,
|
||||
createdAt: Timestamp.fromMillis(atMs),
|
||||
filtered: false,
|
||||
crisis: false,
|
||||
expireAt: Timestamp.fromMillis(atMs + 1000_000),
|
||||
...flags,
|
||||
});
|
||||
|
||||
// 유효 대화 10쌍(20 doc) — 60분 전부터 42분 전까지
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const at = now - (60 - i * 2) * 60_000;
|
||||
await mk(`v${i}u`, "user", `유효질문${i}`, at);
|
||||
await mk(`v${i}a`, "assistant", `유효답변${i}`, at + 1);
|
||||
}
|
||||
// 위기 6쌍(12 doc) — 더 최근(30분 전부터). 1차 페이지(30건)를 잠식한다.
|
||||
for (let j = 0; j < 6; j++) {
|
||||
const at = now - (30 - j * 2) * 60_000;
|
||||
await mk(`c${j}u`, "user", `위기질문${j}`, at);
|
||||
await mk(`c${j}a`, "assistant", `위기안내${j}`, at + 1, {
|
||||
crisis: true,
|
||||
replyTo: `c${j}u`,
|
||||
});
|
||||
}
|
||||
|
||||
const calls = useEchoProvider();
|
||||
await sendMessage(uid, { message: "오늘은 어때?", clientMessageId: newUuid() });
|
||||
|
||||
const history = calls[0].messages;
|
||||
const contents = history.map((m) => m.content);
|
||||
// 위기 쌍은 전부 제외되고, 유효 10턴(20 doc) + 이번 입력 1건이 남아야 한다
|
||||
expect(contents.some((c) => c.startsWith("위기"))).toBe(false);
|
||||
expect(history).toHaveLength(21);
|
||||
// 2차 확장 없이는 가장 오래된 유효 쌍이 잘려 나간다
|
||||
expect(contents[0]).toBe("유효질문0");
|
||||
expect(history[0].role).toBe("user");
|
||||
});
|
||||
});
|
||||
|
||||
describe("스레드 결정 규칙(추가-1·추가-2)", () => {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { Timestamp } from "firebase-admin/firestore";
|
||||
import { firestore } from "../../src/firebase";
|
||||
import { invalidateAllGameDays } from "../../src/repositories/gameRepository";
|
||||
import {
|
||||
judgeDay,
|
||||
} from "../../src/services/judgmentService";
|
||||
@ -51,6 +52,7 @@ async function seedGames(
|
||||
const gameId = `${date.replace(/-/g, "")}G${i}`;
|
||||
const game = makeGame(date, spec.status, "LG", spec.winner ?? null);
|
||||
await firestore.collection("games").doc(gameId).set(game);
|
||||
invalidateAllGameDays();
|
||||
}
|
||||
}
|
||||
|
||||
@ -109,6 +111,8 @@ describe("judgmentService (Firestore emulator)", () => {
|
||||
beforeEach(async () => {
|
||||
await firestore.recursiveDelete(firestore.collection("users"));
|
||||
await firestore.recursiveDelete(firestore.collection("games"));
|
||||
// 테스트는 games를 직접 쓰므로 프로덕션 쓰기 경로와 같은 무효화를 수동 수행한다.
|
||||
invalidateAllGameDays();
|
||||
await seedUser();
|
||||
});
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
} from "../../src/services/scoreboardService";
|
||||
import {
|
||||
precomputeScoreboardCache,
|
||||
buildRankStandings,
|
||||
snapshotRankForUser,
|
||||
} from "../../src/services/rankSnapshotService";
|
||||
import { todayKst, type DateString } from "../../src/types/dateString";
|
||||
@ -281,3 +282,60 @@ describe("rankSnapshotService.snapshotRankForUser", () => {
|
||||
expect(snap.teamCode).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `buildRankStandings`는 유저별 count aggregation을 대체하므로, aggregation과
|
||||
* 동일한 순위 정의("초과 인원 + 1", 동점자 동일 순위)를 지켜야 한다.
|
||||
*/
|
||||
describe("rankSnapshotService.buildRankStandings", () => {
|
||||
const users = [
|
||||
{ tierPoints: 200, favoriteTeamCode: TeamCode.LG },
|
||||
{ tierPoints: 150, favoriteTeamCode: TeamCode.KT },
|
||||
{ tierPoints: 100, favoriteTeamCode: TeamCode.LG },
|
||||
{ tierPoints: 100, favoriteTeamCode: TeamCode.LG },
|
||||
{ tierPoints: 50 },
|
||||
];
|
||||
|
||||
it("overall 순위는 '초과 인원 + 1'이다", () => {
|
||||
const s = buildRankStandings(users);
|
||||
expect(s.rankOf(200)).toBe(1);
|
||||
expect(s.rankOf(150)).toBe(2);
|
||||
expect(s.rankOf(100)).toBe(3);
|
||||
expect(s.rankOf(50)).toBe(5);
|
||||
});
|
||||
|
||||
it("동점자는 같은 순위를 받는다", () => {
|
||||
const s = buildRankStandings(users);
|
||||
// 100점이 2명 → 둘 다 3위, 그 아래 50점은 5위
|
||||
expect(s.rankOf(100)).toBe(3);
|
||||
expect(s.rankOf(99)).toBe(5);
|
||||
});
|
||||
|
||||
it("팀 스코프는 해당 팀 유저만 센다", () => {
|
||||
const s = buildRankStandings(users);
|
||||
// LG: 200, 100, 100
|
||||
expect(s.rankOf(200, TeamCode.LG)).toBe(1);
|
||||
expect(s.rankOf(100, TeamCode.LG)).toBe(2);
|
||||
// KT: 150 하나뿐
|
||||
expect(s.rankOf(150, TeamCode.KT)).toBe(1);
|
||||
});
|
||||
|
||||
it("해당 팀 유저가 없으면 1위로 계산한다", () => {
|
||||
const s = buildRankStandings(users);
|
||||
expect(s.rankOf(10, TeamCode.HH)).toBe(1);
|
||||
});
|
||||
|
||||
it("빈 순위표에서도 1위를 돌려준다", () => {
|
||||
const s = buildRankStandings([]);
|
||||
expect(s.rankOf(0)).toBe(1);
|
||||
});
|
||||
|
||||
it("무작위 입력에서 선형 스캔 결과와 일치한다", () => {
|
||||
const rand = [3, 17, 17, 2, 99, 41, 41, 41, 8, 60].map((tierPoints) => ({ tierPoints }));
|
||||
const s = buildRankStandings(rand);
|
||||
for (const { tierPoints } of rand) {
|
||||
const linear = rand.filter((u) => u.tierPoints > tierPoints).length + 1;
|
||||
expect(s.rankOf(tierPoints)).toBe(linear);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { Timestamp } from "firebase-admin/firestore";
|
||||
import { firestore, rtdb } from "../../src/firebase";
|
||||
import { invalidateAllGameDays } from "../../src/repositories/gameRepository";
|
||||
import { getStats } from "../../src/services/statsService";
|
||||
import {
|
||||
addDays,
|
||||
@ -33,6 +34,7 @@ async function seedGames(
|
||||
};
|
||||
if (spec.winner) doc.winningTeamCode = spec.winner;
|
||||
await firestore.collection("games").doc(gameId).set(doc);
|
||||
invalidateAllGameDays();
|
||||
}
|
||||
}
|
||||
|
||||
@ -64,6 +66,8 @@ describe("statsService.getStats — 결석 lazy 보정", () => {
|
||||
beforeEach(async () => {
|
||||
await firestore.recursiveDelete(firestore.collection("users"));
|
||||
await firestore.recursiveDelete(firestore.collection("games"));
|
||||
// 테스트는 games를 직접 쓰므로 프로덕션 쓰기 경로와 같은 무효화를 수동 수행한다.
|
||||
invalidateAllGameDays();
|
||||
await rtdb.ref(`/cache/stats/${uid}`).remove();
|
||||
await rtdb.ref(`/userVotes/${uid}`).remove();
|
||||
});
|
||||
@ -227,3 +231,194 @@ describe("statsService.getStats — 캐시 forDate 검증", () => {
|
||||
expect(stats.streakDays).toBe(3); // user doc 기준으로 재계산
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 통산 집계는 voteHistory 전수 스캔을 대체하므로, 백필 값과 이후 증분이
|
||||
* 스캔 결과와 동일해야 한다(이중 계상·누락 없이).
|
||||
*/
|
||||
describe("statsService.getStats — 통산 롤링 집계", () => {
|
||||
beforeEach(async () => {
|
||||
await firestore.recursiveDelete(firestore.collection("users"));
|
||||
await rtdb.ref(`/cache/stats/${uid}`).remove();
|
||||
await rtdb.ref(`/userVotes/${uid}`).remove();
|
||||
});
|
||||
|
||||
async function seedHistory(
|
||||
date: DateString,
|
||||
results: Array<boolean | null>
|
||||
): Promise<void> {
|
||||
await firestore
|
||||
.collection("users").doc(uid)
|
||||
.collection("voteHistory").doc(date)
|
||||
.set({
|
||||
data: results.map((result, i) => ({
|
||||
gameId: `${date.replace(/-/g, "")}G${i}`,
|
||||
team: "LG",
|
||||
// null = 취소 무효표(result 없음) — 승률 모집단에서 제외되어야 한다
|
||||
...(result === null ? { cancelled: true } : { result }),
|
||||
})),
|
||||
judgment: "success",
|
||||
});
|
||||
}
|
||||
|
||||
it("카운터가 없으면 전수 스캔으로 산출하고 user doc에 백필한다", async () => {
|
||||
const today = todayKst();
|
||||
await seedUser();
|
||||
await seedHistory(addDays(today, -2), [true, false, true]);
|
||||
await seedHistory(addDays(today, -1), [true, null]);
|
||||
|
||||
const stats = await getStats(uid);
|
||||
|
||||
// [true,false,true] + [true, 취소] → 유효표 4건, 적중 3건 (취소 무효표 제외)
|
||||
expect(stats.totalPredictions).toBe(4);
|
||||
expect(stats.totalCorrect).toBe(3);
|
||||
expect(stats.winRates.overall).toBeCloseTo(3 / 4);
|
||||
|
||||
const user = (await firestore.collection("users").doc(uid).get()).data()!;
|
||||
expect(user.lifetimePredictions).toBe(4);
|
||||
expect(user.lifetimeCorrect).toBe(3);
|
||||
// 기준선은 스캔에 포함된 마지막 날짜
|
||||
expect(user.lifetimeStatsThrough).toBe(addDays(today, -1));
|
||||
});
|
||||
|
||||
it("백필된 카운터가 있으면 그 값을 그대로 쓴다(재스캔 없음)", async () => {
|
||||
await seedUser({
|
||||
lifetimePredictions: 40,
|
||||
lifetimeCorrect: 25,
|
||||
lifetimeStatsThrough: addDays(todayKst(), -1),
|
||||
});
|
||||
// 카운터를 쓰는지 확인하려고 이력과 어긋나는 값을 심는다
|
||||
await seedHistory(addDays(todayKst(), -2), [true]);
|
||||
|
||||
const stats = await getStats(uid);
|
||||
|
||||
expect(stats.totalPredictions).toBe(40);
|
||||
expect(stats.totalCorrect).toBe(25);
|
||||
});
|
||||
|
||||
/**
|
||||
* 이력이 없을 때 기준선을 오늘로 잡으면, 아직 아카이브되지 않은 어제 투표가
|
||||
* `date > through` 조건에 걸려 영구히 누락되고 stale 검사로도 복구되지 않는다.
|
||||
*/
|
||||
it("이력이 없으면 기준선을 세우지 않는다", async () => {
|
||||
await seedUser();
|
||||
|
||||
const stats = await getStats(uid);
|
||||
|
||||
expect(stats.totalPredictions).toBe(0);
|
||||
const user = (await firestore.collection("users").doc(uid).get()).data()!;
|
||||
expect(user.lifetimeStatsThrough).toBeUndefined();
|
||||
expect(user.lifetimePredictions).toBeUndefined();
|
||||
});
|
||||
|
||||
it("아카이브 전 조회 후 어제가 판정돼도 그날 예측이 누락되지 않는다", async () => {
|
||||
const yesterday = addDays(todayKst(), -1);
|
||||
await seedUser();
|
||||
|
||||
// 1) 어제 처음 투표한 유저가 아카이브(03:00) 전에 통계를 조회한다
|
||||
await getStats(uid);
|
||||
|
||||
// 2) 이후 아카이브가 어제를 판정해 voteHistory를 기록한다
|
||||
await seedHistory(yesterday, [true, false]);
|
||||
await firestore.collection("users").doc(uid)
|
||||
.set({ lastJudgedDate: yesterday }, { merge: true });
|
||||
await rtdb.ref(`/cache/stats/${uid}`).remove();
|
||||
|
||||
// 3) 다시 조회하면 어제 예측이 통산에 반영돼 있어야 한다
|
||||
const stats = await getStats(uid);
|
||||
|
||||
expect(stats.totalPredictions).toBe(2);
|
||||
expect(stats.totalCorrect).toBe(1);
|
||||
});
|
||||
|
||||
it("기준선을 lastJudgedDate로 앞당기지 않는다", async () => {
|
||||
const today = todayKst();
|
||||
const d1 = addDays(today, -2);
|
||||
const d2 = addDays(today, -1);
|
||||
// 판정 트랜잭션은 커밋됐지만(setDay 이전) voteHistory에는 d2가 아직 없는 상태
|
||||
await seedUser({ lastJudgedDate: d2 });
|
||||
await seedHistory(d1, [true]);
|
||||
|
||||
await getStats(uid);
|
||||
|
||||
// 기준선이 d2로 올라가면 d2 예측이 영영 집계되지 않는다 — d1이어야 한다
|
||||
const user = (await firestore.collection("users").doc(uid).get()).data()!;
|
||||
expect(user.lifetimeStatsThrough).toBe(d1);
|
||||
});
|
||||
|
||||
it("기준선이 lastJudgedDate보다 뒤처지면 재스캔해 자가치유한다", async () => {
|
||||
const today = todayKst();
|
||||
const d1 = addDays(today, -2);
|
||||
const d2 = addDays(today, -1);
|
||||
// 백필이 d1까지만 반영된 상태에서 d2 판정이 증분되지 못한 상황을 재현한다
|
||||
// (백필 스캔이 d2 문서를 보기 전에 판정 트랜잭션이 커밋되면 발생한다)
|
||||
await seedUser({
|
||||
lifetimePredictions: 1,
|
||||
lifetimeCorrect: 1,
|
||||
lifetimeStatsThrough: d1,
|
||||
lastJudgedDate: d2,
|
||||
});
|
||||
await seedHistory(d1, [true]);
|
||||
await seedHistory(d2, [true, false]);
|
||||
|
||||
const stats = await getStats(uid);
|
||||
|
||||
// 재스캔되어 d2까지 반영돼야 한다
|
||||
expect(stats.totalPredictions).toBe(3);
|
||||
expect(stats.totalCorrect).toBe(2);
|
||||
const user = (await firestore.collection("users").doc(uid).get()).data()!;
|
||||
expect(user.lifetimeStatsThrough).toBe(d2);
|
||||
});
|
||||
|
||||
it("기준선이 lastJudgedDate와 같으면 재스캔하지 않는다", async () => {
|
||||
const d = addDays(todayKst(), -1);
|
||||
await seedUser({
|
||||
lifetimePredictions: 40,
|
||||
lifetimeCorrect: 25,
|
||||
lifetimeStatsThrough: d,
|
||||
lastJudgedDate: d,
|
||||
});
|
||||
await seedHistory(d, [true]); // 카운터와 어긋나는 이력을 심어도 무시돼야 한다
|
||||
|
||||
const stats = await getStats(uid);
|
||||
|
||||
expect(stats.totalPredictions).toBe(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe("statsService.getStats — period 검증·정규화", () => {
|
||||
beforeEach(async () => {
|
||||
await firestore.recursiveDelete(firestore.collection("users"));
|
||||
await rtdb.ref(`/cache/stats/${uid}`).remove();
|
||||
await seedUser();
|
||||
});
|
||||
|
||||
it("서비스 개시 이전 연도는 거부한다", async () => {
|
||||
await expect(getStats(uid, "1999")).rejects.toThrow(/invalid period/);
|
||||
});
|
||||
|
||||
it("미래 연도는 거부한다", async () => {
|
||||
const nextYear = Number(todayKst().slice(0, 4)) + 1;
|
||||
await expect(getStats(uid, String(nextYear))).rejects.toThrow(/invalid period/);
|
||||
});
|
||||
|
||||
it("잘못된 월은 거부한다", async () => {
|
||||
const year = todayKst().slice(0, 4);
|
||||
await expect(getStats(uid, `${year}-13`)).rejects.toThrow(/invalid period/);
|
||||
});
|
||||
|
||||
it("미래 날짜는 거부한다", async () => {
|
||||
await expect(getStats(uid, addDays(todayKst(), 1))).rejects.toThrow(/invalid period/);
|
||||
});
|
||||
|
||||
it("같은 주의 서로 다른 날짜는 하나의 캐시 키로 접힌다", async () => {
|
||||
const today = todayKst();
|
||||
await getStats(uid, today);
|
||||
const keys = Object.keys(
|
||||
(await rtdb.ref(`/cache/stats/${uid}`).get()).val() ?? {}
|
||||
);
|
||||
// 원본 날짜 문자열이 아니라 주 단위 정규형(w<화요일>)으로 저장된다
|
||||
expect(keys.some((k) => k.startsWith("w"))).toBe(true);
|
||||
expect(keys).not.toContain(today);
|
||||
});
|
||||
});
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
purgeExpiredAccounts,
|
||||
PURGE_GRACE_DAYS,
|
||||
updateMe,
|
||||
updateNotifications,
|
||||
} from "../../src/services/userService";
|
||||
import { HttpError } from "../../src/middleware/errors";
|
||||
import {
|
||||
@ -109,6 +110,99 @@ describe("userService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateNotifications", () => {
|
||||
it("허용 키(attendance/predictionRemind/jjaekTalk)를 저장하고 병합해 반환한다", async () => {
|
||||
await createMeWithReservation();
|
||||
const first = await updateNotifications(fakeToken(), {
|
||||
notifications: { attendance: true, predictionRemind: true },
|
||||
});
|
||||
expect(first).toEqual({ attendance: true, predictionRemind: true });
|
||||
|
||||
// 부분 업데이트 — 미포함 키는 유지된다.
|
||||
const second = await updateNotifications(fakeToken(), {
|
||||
notifications: { jjaekTalk: true, predictionRemind: false },
|
||||
});
|
||||
expect(second).toEqual({
|
||||
attendance: true,
|
||||
predictionRemind: false,
|
||||
jjaekTalk: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("whitelist 밖 키는 400 + INVALID_INPUT (marketing 포함)", async () => {
|
||||
await createMeWithReservation();
|
||||
// marketing은 marketingOptIn 필드가 단일 소스라 notifications 맵에서 제외.
|
||||
for (const key of ["marketing", "unknown"]) {
|
||||
await expect(
|
||||
updateNotifications(fakeToken(), { notifications: { [key]: true } })
|
||||
).rejects.toMatchObject({ status: 400, code: "INVALID_INPUT" });
|
||||
}
|
||||
});
|
||||
|
||||
it("boolean이 아닌 값은 400", async () => {
|
||||
await createMeWithReservation();
|
||||
await expect(
|
||||
updateNotifications(fakeToken(), {
|
||||
notifications: { attendance: "yes" },
|
||||
})
|
||||
).rejects.toMatchObject({ status: 400, code: "INVALID_INPUT" });
|
||||
});
|
||||
|
||||
it("getMe 응답에 notifications 맵이 포함된다 (미설정이면 빈 맵)", async () => {
|
||||
await createMeWithReservation();
|
||||
expect((await getMe(fakeToken())).notifications).toEqual({});
|
||||
|
||||
await updateNotifications(fakeToken(), {
|
||||
notifications: { predictionRemind: true },
|
||||
});
|
||||
expect((await getMe(fakeToken())).notifications).toEqual({
|
||||
predictionRemind: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("마케팅 동의 (updateMe.marketingOptIn)", () => {
|
||||
async function readUserDoc() {
|
||||
const snap = await firestore.collection("users").doc(uid).get();
|
||||
return snap.data() ?? {};
|
||||
}
|
||||
|
||||
it("marketingOptIn 단독 PATCH가 성공하고 동의 시각을 서버가 기록한다", async () => {
|
||||
await createMeWithReservation();
|
||||
const u = await updateMe(fakeToken(), { marketingOptIn: true });
|
||||
expect(u.marketingOptIn).toBe(true);
|
||||
|
||||
const doc = await readUserDoc();
|
||||
expect(doc.marketingOptIn).toBe(true);
|
||||
expect(doc.marketingOptInAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("철회(false) 시 동의 이력(marketingOptInAt)은 보존된다", async () => {
|
||||
await createMeWithReservation();
|
||||
await updateMe(fakeToken(), { marketingOptIn: true });
|
||||
const optInAt = (await readUserDoc()).marketingOptInAt;
|
||||
|
||||
const u = await updateMe(fakeToken(), { marketingOptIn: false });
|
||||
expect(u.marketingOptIn).toBe(false);
|
||||
|
||||
const doc = await readUserDoc();
|
||||
expect(doc.marketingOptIn).toBe(false);
|
||||
expect(doc.marketingOptInAt).toEqual(optInAt);
|
||||
});
|
||||
|
||||
it("boolean이 아니면 400", async () => {
|
||||
await createMeWithReservation();
|
||||
await expect(
|
||||
updateMe(fakeToken(), { marketingOptIn: "yes" })
|
||||
).rejects.toMatchObject({ status: 400, code: "INVALID_INPUT" });
|
||||
});
|
||||
|
||||
it("미설정 유저의 getMe는 marketingOptIn=false", async () => {
|
||||
await createMeWithReservation();
|
||||
expect((await getMe(fakeToken())).marketingOptIn).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createMe", () => {
|
||||
it("정상 생성 시 토큰의 email/photo/provider를 사용한다", async () => {
|
||||
const u = await createMeWithReservation();
|
||||
|
||||
106
tests/unit/memCache.test.ts
Normal file
106
tests/unit/memCache.test.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MemCache } from "../../src/lib/memCache";
|
||||
|
||||
describe("MemCache", () => {
|
||||
it("TTL 내에는 캐시된 값을 반환하고 fetcher를 다시 돌리지 않는다", async () => {
|
||||
const cache = new MemCache<number>(60_000);
|
||||
let calls = 0;
|
||||
const fetcher = async () => {
|
||||
calls += 1;
|
||||
return 42;
|
||||
};
|
||||
|
||||
expect(await cache.getOrFetch("k", fetcher)).toBe(42);
|
||||
expect(await cache.getOrFetch("k", fetcher)).toBe(42);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* negative 캐싱 회귀 — truthy 검사로 히트를 판정하면 캐시된 null이 미스와
|
||||
* 구분되지 않아 fetcher가 매번 다시 돈다(상품 상세 조회의 Firestore read 증폭).
|
||||
*/
|
||||
it("캐시된 null도 히트로 취급해 fetcher를 다시 돌리지 않는다", async () => {
|
||||
const cache = new MemCache<string | null>(60_000);
|
||||
let calls = 0;
|
||||
const fetcher = async () => {
|
||||
calls += 1;
|
||||
return null;
|
||||
};
|
||||
|
||||
expect(await cache.getOrFetch("missing", fetcher)).toBeNull();
|
||||
expect(await cache.getOrFetch("missing", fetcher)).toBeNull();
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("0과 빈 문자열도 히트로 취급한다", async () => {
|
||||
const zero = new MemCache<number>(60_000);
|
||||
const empty = new MemCache<string>(60_000);
|
||||
let zeroCalls = 0;
|
||||
let emptyCalls = 0;
|
||||
|
||||
await zero.getOrFetch("z", async () => {
|
||||
zeroCalls += 1;
|
||||
return 0;
|
||||
});
|
||||
await zero.getOrFetch("z", async () => {
|
||||
zeroCalls += 1;
|
||||
return 0;
|
||||
});
|
||||
await empty.getOrFetch("e", async () => {
|
||||
emptyCalls += 1;
|
||||
return "";
|
||||
});
|
||||
await empty.getOrFetch("e", async () => {
|
||||
emptyCalls += 1;
|
||||
return "";
|
||||
});
|
||||
|
||||
expect(zeroCalls).toBe(1);
|
||||
expect(emptyCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("TTL이 지나면 다시 조회한다", async () => {
|
||||
const cache = new MemCache<number>(-1); // 즉시 만료
|
||||
let calls = 0;
|
||||
const fetcher = async () => {
|
||||
calls += 1;
|
||||
return 1;
|
||||
};
|
||||
|
||||
await cache.getOrFetch("k", fetcher);
|
||||
await cache.getOrFetch("k", fetcher);
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it("delete는 해당 키만, clear는 전부 버린다", async () => {
|
||||
const cache = new MemCache<number>(60_000);
|
||||
await cache.getOrFetch("a", async () => 1);
|
||||
await cache.getOrFetch("b", async () => 2);
|
||||
|
||||
cache.delete("a");
|
||||
expect(cache.get("a")).toBeNull();
|
||||
expect(cache.get("b")).toBe(2);
|
||||
|
||||
cache.clear();
|
||||
expect(cache.get("b")).toBeNull();
|
||||
});
|
||||
|
||||
it("동일 키 동시 호출은 fetcher를 한 번만 실행한다", async () => {
|
||||
const cache = new MemCache<number>(60_000);
|
||||
let calls = 0;
|
||||
const fetcher = async () => {
|
||||
calls += 1;
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
return 7;
|
||||
};
|
||||
|
||||
const [a, b, c] = await Promise.all([
|
||||
cache.getOrFetch("k", fetcher),
|
||||
cache.getOrFetch("k", fetcher),
|
||||
cache.getOrFetch("k", fetcher),
|
||||
]);
|
||||
|
||||
expect([a, b, c]).toEqual([7, 7, 7]);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
});
|
||||
96
tests/unit/scheduleDayTtl.test.ts
Normal file
96
tests/unit/scheduleDayTtl.test.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { dayTtlMs } from "../../src/repositories/kboRepository";
|
||||
import type { ScheduleGame, GameStatus } from "../../src/kbo/schedule";
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const SEVEN_DAYS_MS = 7 * ONE_DAY_MS;
|
||||
const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
|
||||
const THIRTY_SEC_MS = 30_000;
|
||||
|
||||
function game(status: GameStatus, time = "18:00"): ScheduleGame {
|
||||
return { status, time } as unknown as ScheduleGame;
|
||||
}
|
||||
|
||||
/** yyyymmdd 문자열의 로컬 자정 타임스탬프. dayTtlMs 와 같은 기준. */
|
||||
function localDayStart(yyyymmdd: string): number {
|
||||
const y = Number(yyyymmdd.slice(0, 4));
|
||||
const m = Number(yyyymmdd.slice(4, 6));
|
||||
const d = Number(yyyymmdd.slice(6, 8));
|
||||
return new Date(y, m - 1, d).getTime();
|
||||
}
|
||||
|
||||
describe("dayTtlMs", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("모레 이후", () => {
|
||||
it("TTL이 해당 날짜 자정을 넘기지 않는다", () => {
|
||||
// 회귀 테스트: 2026-07-27 에 담긴 08-01 스냅샷이 7d TTL로 08-03까지
|
||||
// 살아남아, 폭염취소된 경기가 달력에서 계속 `scheduled` 로 보였다.
|
||||
vi.setSystemTime(new Date(2026, 6, 27, 21, 6));
|
||||
|
||||
const ttl = dayTtlMs("20260801", [game("scheduled")]);
|
||||
|
||||
expect(Date.now() + ttl).toBe(localDayStart("20260801"));
|
||||
expect(ttl).toBeLessThan(SEVEN_DAYS_MS);
|
||||
});
|
||||
|
||||
it("7일보다 먼 날짜는 7d 로 상한을 둔다", () => {
|
||||
vi.setSystemTime(new Date(2026, 6, 27, 21, 6));
|
||||
|
||||
expect(dayTtlMs("20260901", [game("scheduled")])).toBe(SEVEN_DAYS_MS);
|
||||
});
|
||||
|
||||
it("자정 직전이어도 최소 30s 는 보장한다", () => {
|
||||
// 2026-08-01 23:59:59 → 08-03 자정까지 남은 시간은 0 에 가깝지 않지만,
|
||||
// 경계에서 0/음수 TTL 이 나오지 않는지 하한을 확인한다.
|
||||
vi.setSystemTime(new Date(2026, 7, 1, 23, 59, 59, 900));
|
||||
|
||||
expect(dayTtlMs("20260803", [game("scheduled")]))
|
||||
.toBeGreaterThanOrEqual(THIRTY_SEC_MS);
|
||||
});
|
||||
});
|
||||
|
||||
it("내일은 6h", () => {
|
||||
vi.setSystemTime(new Date(2026, 7, 1, 10, 0));
|
||||
|
||||
expect(dayTtlMs("20260802", [game("scheduled")])).toBe(SIX_HOURS_MS);
|
||||
});
|
||||
|
||||
describe("지난 날짜", () => {
|
||||
it("모두 종료/취소면 7d", () => {
|
||||
vi.setSystemTime(new Date(2026, 7, 3, 10, 0));
|
||||
|
||||
const games = [game("completed"), game("cancelled")];
|
||||
expect(dayTtlMs("20260801", games)).toBe(SEVEN_DAYS_MS);
|
||||
});
|
||||
|
||||
it("미종료 경기가 남아 있으면 30s", () => {
|
||||
vi.setSystemTime(new Date(2026, 7, 3, 10, 0));
|
||||
|
||||
const games = [game("completed"), game("scheduled")];
|
||||
expect(dayTtlMs("20260801", games)).toBe(THIRTY_SEC_MS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("오늘", () => {
|
||||
it("첫 경기 시작 전이면 시작까지만 캐시한다", () => {
|
||||
vi.setSystemTime(new Date(2026, 7, 3, 17, 30));
|
||||
|
||||
// 18:00 시작 → 30분
|
||||
expect(dayTtlMs("20260803", [game("scheduled", "18:00")]))
|
||||
.toBe(30 * 60_000);
|
||||
});
|
||||
|
||||
it("모두 끝났으면 7d", () => {
|
||||
vi.setSystemTime(new Date(2026, 7, 3, 23, 0));
|
||||
|
||||
expect(dayTtlMs("20260803", [game("completed")])).toBe(SEVEN_DAYS_MS);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user