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 { attendance } from "./handlers/attendanceHandlers";
|
||||||
export { kboDailyRefresh } from "./scheduled/kboRefresh";
|
export { kboDailyRefresh } from "./scheduled/kboRefresh";
|
||||||
export { dailyArchive } from "./scheduled/dailyArchive";
|
export { dailyArchive } from "./scheduled/dailyArchive";
|
||||||
export { attendanceReminder } from "./scheduled/attendanceReminder";
|
|
||||||
export { onGameCompleted } from "./triggers/onGameCompleted";
|
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 WEEKLY_BONUS_AMOUNT = 50;
|
||||||
const MONTHLY_BONUS_AMOUNT = 100;
|
const MONTHLY_BONUS_AMOUNT = 100;
|
||||||
|
|
||||||
const SLOT_MIN_HOUR = 10;
|
|
||||||
const SLOT_MAX_HOUR = 22;
|
|
||||||
|
|
||||||
export interface CheckInBody {
|
export interface CheckInBody {
|
||||||
clientAttemptedAt?: unknown;
|
clientAttemptedAt?: unknown;
|
||||||
clientIdempotencyKey?: unknown;
|
clientIdempotencyKey?: unknown;
|
||||||
@ -91,49 +88,16 @@ function parseIdempotencyKey(value: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KST 기준 시각을 30분 단위로 round-up하여 "HH:MM" 슬롯 문자열을 만든다.
|
* 일요일 출석 시점에 호출. 같은 ISO 주의 월~토 6일이 모두 출석되었는지 검사.
|
||||||
* 슬롯은 [SLOT_MIN_HOUR:00, SLOT_MAX_HOUR:00] 범위로 클립된다.
|
* 일요일 자체는 호출자(Sunday 게이트)가 이미 보장하므로 검사하지 않는다.
|
||||||
*
|
|
||||||
* 예: 14:23 → "14:30", 22:35 → "22:00"(클립), 09:10 → "10:00"(클립).
|
|
||||||
*/
|
*/
|
||||||
export function reminderSlot(serverNow: Date): string {
|
function isPriorSixDaysAttended(days: number[], sundayDay: number): boolean {
|
||||||
const kstMs = serverNow.getTime() + 9 * 60 * 60 * 1000;
|
if (sundayDay < 7) return false; // 그 달 첫 주: 월~토가 이전 달이라 불가능
|
||||||
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;
|
|
||||||
const set = new Set(days);
|
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;
|
if (!set.has(d)) return false;
|
||||||
}
|
}
|
||||||
return set.has(todayDay);
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortedInsert(days: number[], day: number): number[] {
|
function sortedInsert(days: number[], day: number): number[] {
|
||||||
@ -167,9 +131,7 @@ export async function checkIn(
|
|||||||
const today = toDateString(serverNowDate);
|
const today = toDateString(serverNowDate);
|
||||||
const month = monthOf(today);
|
const month = monthOf(today);
|
||||||
const todayDay = Number(today.slice(8, 10));
|
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);
|
const monthRef = monthDocRef(token.uid, month);
|
||||||
|
|
||||||
return firestore.runTransaction(async (tx) => {
|
return firestore.runTransaction(async (tx) => {
|
||||||
@ -226,10 +188,10 @@ export async function checkIn(
|
|||||||
refDay: todayDay,
|
refDay: todayDay,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2) weekly_bonus: 일요일 + 그 주 월~토 모두 출석
|
// 2) weekly_bonus: 출석일이 일요일 + 그 주 월~토 모두 출석
|
||||||
if (
|
if (
|
||||||
dayOfWeekKst(today) === 0 &&
|
dayOfWeekKst(today) === 0 &&
|
||||||
isWeekFullyAttended(newDays, todayDay)
|
isPriorSixDaysAttended(newDays, todayDay)
|
||||||
) {
|
) {
|
||||||
runningBalance += WEEKLY_BONUS_AMOUNT;
|
runningBalance += WEEKLY_BONUS_AMOUNT;
|
||||||
awards.push({
|
awards.push({
|
||||||
@ -279,7 +241,6 @@ export async function checkIn(
|
|||||||
lastResult: result,
|
lastResult: result,
|
||||||
};
|
};
|
||||||
tx.set(monthRef, monthPatch, { merge: true });
|
tx.set(monthRef, monthPatch, { merge: true });
|
||||||
tx.set(userRef, { attendanceReminderSlot: slot }, { merge: true });
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
@ -297,11 +258,13 @@ export async function getMonth(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const month = monthRaw;
|
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) {
|
if (!doc) {
|
||||||
throw new HttpError(404, "month not found", "MONTH_NOT_FOUND");
|
throw new HttpError(404, "month not found", "MONTH_NOT_FOUND");
|
||||||
}
|
}
|
||||||
const balance = await getLatestBalance(token.uid);
|
|
||||||
return {
|
return {
|
||||||
month,
|
month,
|
||||||
attendedDays: doc.days,
|
attendedDays: doc.days,
|
||||||
|
|||||||
@ -105,8 +105,6 @@ export interface User {
|
|||||||
notifications?: NotificationsMap;
|
notifications?: NotificationsMap;
|
||||||
/** 마지막 로그인 기기의 FCM 토큰. 클라가 직접 덮어쓰기. */
|
/** 마지막 로그인 기기의 FCM 토큰. 클라가 직접 덮어쓰기. */
|
||||||
fcmToken?: string;
|
fcmToken?: string;
|
||||||
/** 미출석 푸시 슬롯("HH:MM"). 출석 시점에 갱신, 푸시 스케줄 쿼리용 비정규화. */
|
|
||||||
attendanceReminderSlot?: string;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 마지막 판정(`applyDailyJudgmentTx`) 시점 기준의 연속 참여일.
|
* 마지막 판정(`applyDailyJudgmentTx`) 시점 기준의 연속 참여일.
|
||||||
|
|||||||
@ -240,25 +240,6 @@ describe("attendanceService", () => {
|
|||||||
// weekly 3건 × 50 = 150. daily 28 × 10 = 280. monthly 100. 총 530.
|
// weekly 3건 × 50 = 150. daily 28 × 10 = 280. monthly 100. 총 530.
|
||||||
expect(result.balanceAfter).toBe(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", () => {
|
describe("getMonth", () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user