From dc9861df3881c1fdf7fcd455a0f386a55e825f1d Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Mon, 5 Oct 2026 23:24:02 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=EB=B3=84=20?= =?UTF-8?q?=EA=B0=9C=EC=9D=B8=20Push=20=EB=B0=9C=EC=86=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/firebase/functions/src/index.ts | 2 +- app/firebase/functions/src/notify/inbox.ts | 33 ++++++++ app/firebase/functions/src/notify/push.ts | 63 ++++++++++++++- app/firebase/functions/test/push_user.test.ts | 76 +++++++++++++++++++ 4 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 app/firebase/functions/test/push_user.test.ts diff --git a/app/firebase/functions/src/index.ts b/app/firebase/functions/src/index.ts index 8c56f1e..7637125 100644 --- a/app/firebase/functions/src/index.ts +++ b/app/firebase/functions/src/index.ts @@ -52,7 +52,7 @@ export { fnTranslationSubmit, fnTranslationMark, fnTranslationSettle, } from './account/translation.js'; export { fnBriefingIngest, fnBriefingPublish, fnBriefingReject } from './briefing/briefing.js'; -export { fnInboxEclipse, fnInboxHeroDrop, fnInboxQuestDone } from './notify/inbox.js'; +export { fnInboxEclipse, fnInboxHeroDrop, fnInboxQuestDone, fnPushInbox } from './notify/inbox.js'; export { fnPlanetEvolutionRecord, fnAdminPlanetEvolutionRun } from './fandom/evolution.js'; export { fnPlanetHistory } from './fandom/history.js'; diff --git a/app/firebase/functions/src/notify/inbox.ts b/app/firebase/functions/src/notify/inbox.ts index e637f11..3c06ed5 100644 --- a/app/firebase/functions/src/notify/inbox.ts +++ b/app/firebase/functions/src/notify/inbox.ts @@ -2,6 +2,8 @@ import { FieldValue, getFirestore } from 'firebase-admin/firestore'; import { onDocumentCreated, onDocumentUpdated, onDocumentWritten } from 'firebase-functions/v2/firestore'; import { logger } from 'firebase-functions'; +import { PUSH_CATEGORIES, sendUserPush, type UserPushOutcome } from './push.js'; + /** * 알림함(`users/{uid}/notifications`) 항목 생성 — Figma `229:11265` 홈/알림 · 명세 `518:88353`. * @@ -106,6 +108,37 @@ export async function notifyQuestDone( }); } +/** + * 알림함 항목 1건 → 그 사용자 단말로 Push 1건 — C-45 이벤트별 개인 Push(이클립스 발생 · 방어 · + * 영웅의 선물 · 퀘스트 완료). 알림함에 쌓이는 사건이 곧 개인 Push 대상이라 경로를 하나로 둔다 — + * 3차에 INSIDE · 캠페인 댓글 알림이 알림함에 생기면 Push 도 같이 나간다. + * + * 문구는 알림함 항목과 같다(Figma `229:11265`). 제목 자리는 사용자 노출 브랜드(FANUZU · 공통 기준 4항). + * 구분(`type`)이 꺼져 있으면 보내지 않는다([sendUserPush]). 문서 ID 가 사건마다 고정이라 생성 + * 트리거가 한 번만 돈다. 재시도는 두지 않는다 — 재전달 시 같은 Push 가 두 번 갈 수 있다. + * ponytail: 회원 전원 알림(이클립스 · 영웅의 선물)은 회원 수만큼 함수가 돈다. 수십만이 되면 토큰 묶음 발송으로. + */ +export async function pushInboxItem(uid: string, item: Partial): Promise { + const category = item.type; + if (!category || !PUSH_CATEGORIES.includes(category) || !item.title) return null; + return sendUserPush(uid, { + category, + title: 'FANUZU', + body: item.title, + data: { + kind: item.eventType ?? '', + ...(item.targetPath ? { targetPath: item.targetPath } : {}), + }, + }); +} + +export const fnPushInbox = onDocumentCreated( + { document: 'users/{uid}/notifications/{id}' }, + async (event) => { + await pushInboxItem(event.params.uid, (event.data?.data() ?? {}) as Partial); + }, +); + export const fnInboxEclipse = onDocumentUpdated( { document: 'fandoms/{fandomId}/eclipseRounds/{roundId}', retry: true }, async (event) => { diff --git a/app/firebase/functions/src/notify/push.ts b/app/firebase/functions/src/notify/push.ts index 303cd77..014ca08 100644 --- a/app/firebase/functions/src/notify/push.ts +++ b/app/firebase/functions/src/notify/push.ts @@ -1,5 +1,5 @@ import { getMessaging } from 'firebase-admin/messaging'; -import { Timestamp, type DocumentSnapshot } from 'firebase-admin/firestore'; +import { FieldValue, Timestamp, getFirestore, type DocumentSnapshot } from 'firebase-admin/firestore'; import { logger } from 'firebase-functions'; /** @@ -64,6 +64,67 @@ export async function sendFandomPush(message: PushMessage): Promise { } } +/** 사용자 단위 발송 결과. */ +export type UserPushOutcome = 'sent' | 'off' | 'no-token' | 'failed'; + +/** 단말에서 지워진 토큰 — 다시 보내도 영영 실패하므로 사용자 문서에서 걷어 낸다. */ +const DEAD_TOKEN_CODES = new Set([ + 'messaging/registration-token-not-registered', + 'messaging/invalid-registration-token', +]); + +/** 이 구분을 받는가 — 설정 화면(Figma `554:24062`)과 같은 기본값(Marketing 만 OFF). 필수는 끌 수 없다. */ +export function pushAllowed(prefs: unknown, category: PushCategory): boolean { + if (category === 'essential') return true; + const v = (prefs && typeof prefs === 'object' ? prefs as Record : {})[category]; + return typeof v === 'boolean' ? v : category !== 'marketing'; +} + +/** + * 사용자 한 명의 단말 전부에 한 건 — C-45 「사용자 세그먼트 타게팅」 · 이벤트별 개인 Push. + * + * 토픽 발송과 달리 서버가 수신자를 고르므로 **알림 설정을 서버가 지킨다.** 설정 화면이 쓰는 + * `users/{uid}.notificationPrefs` 를 읽어 꺼진 구분은 보내지 않는다 — B-77 「OFF 카테고리 + * 발송 0건」. 토큰은 앱이 쌓는 `users/{uid}.fcmTokens`(단말마다 하나). + * + * 발송 실패는 호출자를 깨뜨리지 않는다([sendFandomPush] 와 같은 이유). + */ +export async function sendUserPush( + uid: string, + message: Omit & { fandomId?: string }, +): Promise { + const userRef = getFirestore().doc(`users/${uid}`); + const user = (await userRef.get()).data() ?? {}; + if (!pushAllowed(user.notificationPrefs, message.category)) return 'off'; + const tokens = Array.isArray(user.fcmTokens) + ? [...new Set((user.fcmTokens as unknown[]).filter((t): t is string => typeof t === 'string' && t.length > 0))] + : []; + if (tokens.length === 0) return 'no-token'; + try { + const result = await getMessaging().sendEachForMulticast({ + tokens, + notification: { title: message.title, body: message.body }, + data: { + category: message.category, + ...(message.fandomId ? { fandomId: message.fandomId } : {}), + ...message.data, + }, + android: { priority: 'high' }, + apns: { payload: { aps: { sound: 'default' } } }, + }); + const dead = tokens.filter((_, i) => { + const code = result.responses[i]?.error?.code; + return code !== undefined && DEAD_TOKEN_CODES.has(code); + }); + if (dead.length > 0) await userRef.update({ fcmTokens: FieldValue.arrayRemove(...dead) }); + logger.info('[push] 사용자 발송', { uid, success: result.successCount, failure: result.failureCount }); + return result.successCount > 0 ? 'sent' : 'failed'; + } catch (error) { + logger.error('[push] 사용자 발송 실패', { uid, message: (error as Error).message }); + return 'failed'; + } +} + /** * 이클립스 사전 알림 — 원장 **C-22** 「발생 3분 전 푸시」 · C-24 「푸시 = 시작 − 3분」. * diff --git a/app/firebase/functions/test/push_user.test.ts b/app/firebase/functions/test/push_user.test.ts new file mode 100644 index 0000000..bbf9d94 --- /dev/null +++ b/app/firebase/functions/test/push_user.test.ts @@ -0,0 +1,76 @@ +import { afterAll, beforeAll, beforeEach, expect, it, vi } from 'vitest'; +import { getFirestore } from 'firebase-admin/firestore'; + +/** C-45 이벤트별 개인 Push — 알림 설정 존중 · 단말 토큰 · 죽은 토큰 정리. FCM 은 가짜로 바꾼다. */ +const sent: Array<{ tokens: string[]; notification: unknown; data: Record }> = []; +vi.mock('firebase-admin/messaging', () => ({ + getMessaging: () => ({ + sendEachForMulticast: async (m: { tokens: string[]; notification: unknown; data: Record }) => { + sent.push(m); + const responses = m.tokens.map((t) => t === 'dead' + ? { success: false, error: { code: 'messaging/registration-token-not-registered' } } + : { success: true }); + return { + responses, + successCount: responses.filter((r) => r.success).length, + failureCount: responses.filter((r) => !r.success).length, + }; + }, + }), +})); + +const { pushAllowed } = await import('../src/notify/push.js'); +const { pushInboxItem } = await import('../src/notify/inbox.js'); +const { bootAdmin, shutdownAdmin } = await import('./support.js'); + +const U = 'test-push-user'; +const quest = { + eventType: 'quest' as const, type: 'essential' as const, + title: '[만보기] 퀘스트를 완료했어요!', source: '퀘스트', targetPath: 'quests/q1', +}; +const eclipse = { + eventType: 'eclipse' as const, type: 'fandomEvent' as const, + title: '내 행성의 이클립스가 발생했어요.', source: 'BTS', +}; + +beforeAll(() => { bootAdmin(); }); +beforeEach(async () => { + sent.length = 0; + await getFirestore().recursiveDelete(getFirestore().doc(`users/${U}`)); +}); +afterAll(async () => { + await getFirestore().recursiveDelete(getFirestore().doc(`users/${U}`)); + await shutdownAdmin(); +}); + +it('기본값은 Marketing 만 OFF · 서비스 필수는 끌 수 없다 (B-77)', () => { + expect(pushAllowed(undefined, 'fandomEvent')).toBe(true); + expect(pushAllowed(undefined, 'marketing')).toBe(false); + expect(pushAllowed({ fandomEvent: false }, 'fandomEvent')).toBe(false); + expect(pushAllowed({ essential: false }, 'essential')).toBe(true); + expect(pushAllowed({ marketing: true }, 'marketing')).toBe(true); +}); + +it('알림함 항목 문구 그대로 그 사용자 단말 전부에 보내고 죽은 토큰은 걷어 낸다', async () => { + await getFirestore().doc(`users/${U}`).set({ fcmTokens: ['t1', 'dead', 't1'] }); + expect(await pushInboxItem(U, quest)).toBe('sent'); + expect(sent).toHaveLength(1); + expect(sent[0]!.tokens).toEqual(['t1', 'dead']); + expect(sent[0]!.notification).toEqual({ title: 'FANUZU', body: '[만보기] 퀘스트를 완료했어요!' }); + expect(sent[0]!.data).toMatchObject({ category: 'essential', kind: 'quest', targetPath: 'quests/q1' }); + expect((await getFirestore().doc(`users/${U}`).get()).get('fcmTokens')).toEqual(['t1', 't1']); +}); + +it('설정에서 끈 구분은 보내지 않는다 — OFF 발송 0건', async () => { + await getFirestore().doc(`users/${U}`).set({ fcmTokens: ['t1'], notificationPrefs: { fandomEvent: false } }); + expect(await pushInboxItem(U, eclipse)).toBe('off'); + expect(await pushInboxItem(U, quest)).toBe('sent'); + expect(sent).toHaveLength(1); +}); + +it('토큰이 없으면 보내지 않는다 · 구분을 모르는 항목은 건너뛴다', async () => { + await getFirestore().doc(`users/${U}`).set({}); + expect(await pushInboxItem(U, eclipse)).toBe('no-token'); + expect(await pushInboxItem(U, { title: 'x' })).toBeNull(); + expect(sent).toHaveLength(0); +});