Remove attendance reminder system and optimize attendance logic.
- 출석 독려 푸시 알림 기능과 관련 스케줄러(attendanceReminder)를 삭제했습니다.(클라이언트로 이관) - 유저 데이터에서 알림 슬롯 필드를 제거하고, 출석 시 수행하던 슬롯 계산 및 저장 로직을 제거했습니다. - 주간 보너스 확인 로직을 리팩토링하고, getMonth 함수 내 비동기 데이터 조회를 병렬화하여 성능을 개선했습니다.
This commit is contained in:
parent
eb17177f77
commit
86b0da7c2d
@ -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";
|
||||
|
||||
@ -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());
|
||||
}
|
||||
);
|
||||
@ -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,
|
||||
|
||||
@ -105,8 +105,6 @@ export interface User {
|
||||
notifications?: NotificationsMap;
|
||||
/** 마지막 로그인 기기의 FCM 토큰. 클라가 직접 덮어쓰기. */
|
||||
fcmToken?: string;
|
||||
/** 미출석 푸시 슬롯("HH:MM"). 출석 시점에 갱신, 푸시 스케줄 쿼리용 비정규화. */
|
||||
attendanceReminderSlot?: string;
|
||||
|
||||
/**
|
||||
* 마지막 판정(`applyDailyJudgmentTx`) 시점 기준의 연속 참여일.
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user