Remove attendance reminder system and optimize attendance logic.

- 출석 독려 푸시 알림 기능과 관련 스케줄러(attendanceReminder)를 삭제했습니다.(클라이언트로 이관)
- 유저 데이터에서 알림 슬롯 필드를 제거하고, 출석 시 수행하던 슬롯 계산 및 저장 로직을 제거했습니다.
- 주간 보너스 확인 로직을 리팩토링하고, getMonth 함수 내 비동기 데이터 조회를 병렬화하여 성능을 개선했습니다.
This commit is contained in:
윤정민 2026-05-18 14:19:39 +09:00
parent eb17177f77
commit 86b0da7c2d
5 changed files with 12 additions and 212 deletions

View File

@ -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";

View File

@ -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<ReminderTarget[]> {
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<User>;
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<void> {
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<void> {
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());
}
);

View File

@ -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,

View File

@ -105,8 +105,6 @@ export interface User {
notifications?: NotificationsMap;
/** 마지막 로그인 기기의 FCM 토큰. 클라가 직접 덮어쓰기. */
fcmToken?: string;
/** 미출석 푸시 슬롯("HH:MM"). 출석 시점에 갱신, 푸시 스케줄 쿼리용 비정규화. */
attendanceReminderSlot?: string;
/**
* (`applyDailyJudgmentTx`) .

View File

@ -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", () => {