From 86b0da7c2d94a43f32feabe083717a3dc29ee6da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Mon, 18 May 2026 14:19:39 +0900 Subject: [PATCH] Remove attendance reminder system and optimize attendance logic. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 출석 독려 푸시 알림 기능과 관련 스케줄러(attendanceReminder)를 삭제했습니다.(클라이언트로 이관) - 유저 데이터에서 알림 슬롯 필드를 제거하고, 출석 시 수행하던 슬롯 계산 및 저장 로직을 제거했습니다. - 주간 보너스 확인 로직을 리팩토링하고, getMonth 함수 내 비동기 데이터 조회를 병렬화하여 성능을 개선했습니다. --- src/index.ts | 1 - src/scheduled/attendanceReminder.ts | 141 ----------------------- src/services/attendanceService.ts | 61 ++-------- src/types/panit.ts | 2 - tests/services/attendanceService.test.ts | 19 --- 5 files changed, 12 insertions(+), 212 deletions(-) delete mode 100644 src/scheduled/attendanceReminder.ts diff --git a/src/index.ts b/src/index.ts index dd77530..2ed548a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,5 +12,4 @@ export { debug } from "./handlers/debugHandlers"; export { attendance } from "./handlers/attendanceHandlers"; export { kboDailyRefresh } from "./scheduled/kboRefresh"; export { dailyArchive } from "./scheduled/dailyArchive"; -export { attendanceReminder } from "./scheduled/attendanceReminder"; export { onGameCompleted } from "./triggers/onGameCompleted"; diff --git a/src/scheduled/attendanceReminder.ts b/src/scheduled/attendanceReminder.ts deleted file mode 100644 index 77ff690..0000000 --- a/src/scheduled/attendanceReminder.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { logger } from "firebase-functions"; -import { onSchedule } from "firebase-functions/scheduler"; -import { FieldValue } from "firebase-admin/firestore"; -import { getMessaging } from "firebase-admin/messaging"; -import { firestore } from "../firebase"; -import { monthOf, todayKst } from "../types/dateString"; -import type { AttendanceMonthDoc, User } from "../types/panit"; - -const PUSH_TYPE = "attendance_reminder"; -const PUSH_ROUTE = "/attendance"; -const MULTICAST_BATCH_SIZE = 500; - -/** - * KST 기준 현재 슬롯("HH:MM"). 분 단위는 0/30으로 floor. - */ -function currentSlot(now: Date): string { - const kst = new Date(now.getTime() + 9 * 60 * 60 * 1000); - const hh = String(kst.getUTCHours()).padStart(2, "0"); - const mm = kst.getUTCMinutes() < 30 ? "00" : "30"; - return `${hh}:${mm}`; -} - -interface ReminderTarget { - uid: string; - fcmToken: string; -} - -/** - * 이번 슬롯에 미출석 푸시를 보낼 대상자를 산출한다. - * - * 조건: `attendanceReminderSlot == slot` AND `notifications.attendance == true` - * AND fcmToken 보유 AND 오늘 attendance 문서에 todayDay 미포함. - * - * 1차 쿼리는 슬롯 매칭만 하고 나머지는 메모리에서 필터한다 (composite index 회피). - */ -async function collectTargets( - slot: string, - todayDay: number, - monthKey: string -): Promise { - const snap = await firestore - .collection("users") - .where("attendanceReminderSlot", "==", slot) - .get(); - - const targets: ReminderTarget[] = []; - await Promise.all( - snap.docs.map(async (doc) => { - const user = doc.data() as Partial; - if (user.notifications?.attendance !== true) return; - if (!user.fcmToken) return; - - const monthSnap = await doc.ref - .collection("attendance") - .doc(monthKey) - .get(); - const days = monthSnap.exists - ? (monthSnap.data() as AttendanceMonthDoc).days - : []; - if (days.includes(todayDay)) return; - - targets.push({ uid: doc.id, fcmToken: user.fcmToken }); - }) - ); - return targets; -} - -/** - * 무효 토큰 응답 코드. 해당 사용자의 fcmToken 필드를 제거한다. - */ -const INVALID_TOKEN_CODES = new Set([ - "messaging/registration-token-not-registered", - "messaging/invalid-registration-token", - "messaging/invalid-argument", -]); - -async function sendBatch(targets: ReminderTarget[]): Promise { - if (targets.length === 0) return; - - const messaging = getMessaging(); - const response = await messaging.sendEachForMulticast({ - tokens: targets.map((t) => t.fcmToken), - data: { type: PUSH_TYPE, route: PUSH_ROUTE }, - }); - - const invalidUids: string[] = []; - response.responses.forEach((res, idx) => { - if (!res.success) { - const code = res.error?.code ?? "unknown"; - logger.warn( - `attendanceReminder: send failed uid=${targets[idx].uid} code=${code}` - ); - if (INVALID_TOKEN_CODES.has(code)) invalidUids.push(targets[idx].uid); - } - }); - - if (invalidUids.length > 0) { - await Promise.all( - invalidUids.map((uid) => - firestore - .collection("users") - .doc(uid) - .update({ fcmToken: FieldValue.delete() }) - .catch((err) => { - logger.error(`failed to clear fcmToken uid=${uid}`, err); - }) - ) - ); - } - - logger.info( - `attendanceReminder: sent=${response.successCount} failed=${response.failureCount} cleared=${invalidUids.length}` - ); -} - -export async function runAttendanceReminder(now: Date): Promise { - const slot = currentSlot(now); - const today = todayKst(); - const todayDay = Number(today.slice(8, 10)); - const monthKey = monthOf(today); - - const targets = await collectTargets(slot, todayDay, monthKey); - logger.info( - `attendanceReminder slot=${slot} today=${today} targets=${targets.length}` - ); - - for (let i = 0; i < targets.length; i += MULTICAST_BATCH_SIZE) { - await sendBatch(targets.slice(i, i + MULTICAST_BATCH_SIZE)); - } -} - -export const attendanceReminder = onSchedule( - { - schedule: "*/30 10-22 * * *", - timeZone: "Asia/Seoul", - region: "asia-northeast3", - }, - async () => { - await runAttendanceReminder(new Date()); - } -); diff --git a/src/services/attendanceService.ts b/src/services/attendanceService.ts index 792962a..fe13dad 100644 --- a/src/services/attendanceService.ts +++ b/src/services/attendanceService.ts @@ -33,9 +33,6 @@ const DAILY_AMOUNT = 10; const WEEKLY_BONUS_AMOUNT = 50; const MONTHLY_BONUS_AMOUNT = 100; -const SLOT_MIN_HOUR = 10; -const SLOT_MAX_HOUR = 22; - export interface CheckInBody { clientAttemptedAt?: unknown; clientIdempotencyKey?: unknown; @@ -91,49 +88,16 @@ function parseIdempotencyKey(value: unknown): string { } /** - * KST 기준 시각을 30분 단위로 round-up하여 "HH:MM" 슬롯 문자열을 만든다. - * 슬롯은 [SLOT_MIN_HOUR:00, SLOT_MAX_HOUR:00] 범위로 클립된다. - * - * 예: 14:23 → "14:30", 22:35 → "22:00"(클립), 09:10 → "10:00"(클립). + * 일요일 출석 시점에 호출. 같은 ISO 주의 월~토 6일이 모두 출석되었는지 검사. + * 일요일 자체는 호출자(Sunday 게이트)가 이미 보장하므로 검사하지 않는다. */ -export function reminderSlot(serverNow: Date): string { - const kstMs = serverNow.getTime() + 9 * 60 * 60 * 1000; - const utcWallClock = new Date(kstMs); - let hour = utcWallClock.getUTCHours(); - const minute = utcWallClock.getUTCMinutes(); - let slotMinute: number; - if (minute === 0) { - slotMinute = 0; - } else if (minute <= 30) { - slotMinute = 30; - } else { - hour += 1; - slotMinute = 0; - } - if (hour < SLOT_MIN_HOUR) { - hour = SLOT_MIN_HOUR; - slotMinute = 0; - } else if (hour > SLOT_MAX_HOUR || (hour === SLOT_MAX_HOUR && slotMinute > 0)) { - hour = SLOT_MAX_HOUR; - slotMinute = 0; - } - return `${String(hour).padStart(2, "0")}:${String(slotMinute).padStart(2, "0")}`; -} - -/** - * 그 주의 월~토 6일이 모두 days 배열(이번 달)에 있는지 검사. - * today는 일요일이라고 가정 (호출자 책임). - * - * 같은 달 안에 주(월~일)가 온전히 들어 있어야만 true 반환 — 주가 월 경계를 - * 걸치는 경우(예: 6/1 일요일)에는 false. - */ -function isWeekFullyAttended(days: number[], todayDay: number): boolean { - if (todayDay < 7) return false; +function isPriorSixDaysAttended(days: number[], sundayDay: number): boolean { + if (sundayDay < 7) return false; // 그 달 첫 주: 월~토가 이전 달이라 불가능 const set = new Set(days); - for (let d = todayDay - 6; d < todayDay; d++) { + for (let d = sundayDay - 6; d < sundayDay; d++) { if (!set.has(d)) return false; } - return set.has(todayDay); + return true; } function sortedInsert(days: number[], day: number): number[] { @@ -167,9 +131,7 @@ export async function checkIn( const today = toDateString(serverNowDate); const month = monthOf(today); const todayDay = Number(today.slice(8, 10)); - const slot = reminderSlot(serverNowDate); - const userRef = firestore.collection("users").doc(token.uid); const monthRef = monthDocRef(token.uid, month); return firestore.runTransaction(async (tx) => { @@ -226,10 +188,10 @@ export async function checkIn( refDay: todayDay, }); - // 2) weekly_bonus: 일요일 + 그 주 월~토 모두 출석 + // 2) weekly_bonus: 출석일이 일요일 + 그 주 월~토 모두 출석 if ( dayOfWeekKst(today) === 0 && - isWeekFullyAttended(newDays, todayDay) + isPriorSixDaysAttended(newDays, todayDay) ) { runningBalance += WEEKLY_BONUS_AMOUNT; awards.push({ @@ -279,7 +241,6 @@ export async function checkIn( lastResult: result, }; tx.set(monthRef, monthPatch, { merge: true }); - tx.set(userRef, { attendanceReminderSlot: slot }, { merge: true }); return result; }); @@ -297,11 +258,13 @@ export async function getMonth( ); } const month = monthRaw; - const doc = await getMonthDoc(token.uid, month); + const [doc, balance] = await Promise.all([ + getMonthDoc(token.uid, month), + getLatestBalance(token.uid), + ]); if (!doc) { throw new HttpError(404, "month not found", "MONTH_NOT_FOUND"); } - const balance = await getLatestBalance(token.uid); return { month, attendedDays: doc.days, diff --git a/src/types/panit.ts b/src/types/panit.ts index 25dc471..165fa70 100644 --- a/src/types/panit.ts +++ b/src/types/panit.ts @@ -105,8 +105,6 @@ export interface User { notifications?: NotificationsMap; /** 마지막 로그인 기기의 FCM 토큰. 클라가 직접 덮어쓰기. */ fcmToken?: string; - /** 미출석 푸시 슬롯("HH:MM"). 출석 시점에 갱신, 푸시 스케줄 쿼리용 비정규화. */ - attendanceReminderSlot?: string; /** * 마지막 판정(`applyDailyJudgmentTx`) 시점 기준의 연속 참여일. diff --git a/tests/services/attendanceService.test.ts b/tests/services/attendanceService.test.ts index f5a5351..c74a9c5 100644 --- a/tests/services/attendanceService.test.ts +++ b/tests/services/attendanceService.test.ts @@ -240,25 +240,6 @@ describe("attendanceService", () => { // weekly 3건 × 50 = 150. daily 28 × 10 = 280. monthly 100. 총 530. expect(result.balanceAfter).toBe(530); }); - - it("attendanceReminderSlot이 user doc에 30분 round-up 후 저장된다", async () => { - const now = kstDate("2026-05-12T14:23:11+09:00"); - vi.setSystemTime(now); - - await checkIn(fakeToken(), bodyAt(now)); - - const userSnap = await firestore.collection("users").doc(uid).get(); - expect(userSnap.data()?.attendanceReminderSlot).toBe("14:30"); - }); - - it("늦은 시각(22:35)은 22:00으로 클립된다", async () => { - const now = kstDate("2026-05-12T22:35:00+09:00"); - vi.setSystemTime(now); - await checkIn(fakeToken(), bodyAt(now)); - - const userSnap = await firestore.collection("users").doc(uid).get(); - expect(userSnap.data()?.attendanceReminderSlot).toBe("22:00"); - }); }); describe("getMonth", () => {