Cloud Functions 도메인 함수 — POP 원장 · 퀘스트 · 이클립스 · 행성 성장 · 캠페인 · 결제 · 알림 · 브리핑 · INSIDE · 추천 · 번역 · 서버 테스트
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
import { getMessaging } from 'firebase-admin/messaging';
|
||||
import { Timestamp, type DocumentSnapshot } from 'firebase-admin/firestore';
|
||||
import { logger } from 'firebase-functions';
|
||||
|
||||
/**
|
||||
* FCM 발송 — 정책 원장 B-77 「Push 알림」 5개 구분의 서버 쪽 절반.
|
||||
*
|
||||
* **구분별로 토픽을 따로 둔다.** 한 팬덤 토픽에 다섯 구분을 몰아 보내면 사용자가
|
||||
* 「AI Briefing 만 끄기」를 골랐을 때 서버가 그것을 걸러 낼 방법이 없다. FCM 토픽
|
||||
* 발송은 수신자 목록을 서버가 만들지 않기 때문이다. 구분마다 토픽을 나누고 앱이
|
||||
* 자기 설정대로 구독·해지하면 **OFF 구분의 발송은 구조적으로 0 건**이 된다
|
||||
* (B-77 검증 기준 「OFF 카테고리 발송 0건」).
|
||||
*
|
||||
* `marketing` 은 앱이 기본 OFF 로 두어 구독하지 않는다(별첨 B 「별도 동의 기본 OFF」).
|
||||
*/
|
||||
export const PUSH_CATEGORIES = [
|
||||
'essential', 'fandomEvent', 'briefing', 'community', 'marketing',
|
||||
] as const;
|
||||
|
||||
export type PushCategory = typeof PUSH_CATEGORIES[number];
|
||||
|
||||
/**
|
||||
* FCM 토픽 이름은 `[a-zA-Z0-9-_.~%]+` 만 받는다. 팬덤 ID 에 그 밖의 문자가 섞이면
|
||||
* 발송이 `invalid-argument` 로 떨어지는데, 오류 문구가 토픽을 가리키지 않아
|
||||
* 원인을 찾기 어렵다. 그래서 여기서 한 번 걸러 둔다.
|
||||
*/
|
||||
export function fandomTopic(fandomId: string, category: PushCategory): string {
|
||||
return `fandom_${fandomId.replace(/[^a-zA-Z0-9\-_.~%]/g, '-')}_${category}`;
|
||||
}
|
||||
|
||||
export interface PushMessage {
|
||||
fandomId: string;
|
||||
category: PushCategory;
|
||||
title: string;
|
||||
body: string;
|
||||
/** 앱이 탭 시 어디로 갈지 판단하는 값. 문자열만 실을 수 있다(FCM 규약). */
|
||||
data?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 팬덤 토픽 한 건 발송.
|
||||
*
|
||||
* **발송 실패가 호출자를 깨뜨리지 않게 한다.** 브리핑 게시와 이클립스 집계가
|
||||
* 푸시 실패로 롤백되면 데이터는 맞는데 화면이 비는 상태가 된다. 실패는 로그로
|
||||
* 남기고 `false` 를 돌려준다.
|
||||
*/
|
||||
export async function sendFandomPush(message: PushMessage): Promise<boolean> {
|
||||
const topic = fandomTopic(message.fandomId, message.category);
|
||||
try {
|
||||
const id = await getMessaging().send({
|
||||
topic,
|
||||
notification: { title: message.title, body: message.body },
|
||||
data: { category: message.category, fandomId: message.fandomId, ...message.data },
|
||||
android: { priority: 'high' },
|
||||
// iOS 는 APNs 키가 들어오기 전까지 실제로 전달되지 않는다(별첨 A 계정 이전
|
||||
// 대기). 코드 경로는 지금 맞춰 두어야 키가 들어온 날 수정이 없다.
|
||||
apns: { payload: { aps: { sound: 'default' } } },
|
||||
});
|
||||
logger.info('[push] 발송', { topic, messageId: id });
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('[push] 발송 실패', { topic, message: (error as Error).message });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이클립스 사전 알림 — 원장 **C-22** 「발생 3분 전 푸시」 · C-24 「푸시 = 시작 − 3분」.
|
||||
*
|
||||
* `fn-eclipse-trigger` 가 회차의 `warnAt`(시작 − `warnSeconds`) 이 지난 첫 점검에서
|
||||
* 부른다. 활성화 뒤에 보내면 예고가 아니라 사후 통지가 된다.
|
||||
*
|
||||
* - `alertPushedAt` 이 있으면 보내지 않는다 — 1 분 주기 점검이 도배하지 않는다.
|
||||
* - 시작 시각이 이미 지났으면 보내지 않고 표시만 남긴다 — 배포 직후 첫 점검이나
|
||||
* 스케줄러 지연으로 늦게 본 회차에 「곧 시작」을 소급 발송하지 않는다.
|
||||
* - 취소된 회차(C-28)에는 보내지 않는다.
|
||||
*
|
||||
* `alertPushedAt` 에 **판정 시각 [now]** 를 적는다 — 발송 시각 = 시작 − 3분을
|
||||
* 회차 문서만으로 검증할 수 있게 한다.
|
||||
*/
|
||||
export type EclipseAlertOutcome =
|
||||
/** 예고를 보냈다. */
|
||||
| 'sent'
|
||||
/** 보내려 했으나 FCM 발송이 실패했다. 표시는 남겼다. */
|
||||
| 'failed'
|
||||
/** 이미 보냈거나 보낼 대상이 아니다(없음 · 취소). */
|
||||
| 'already'
|
||||
/** 예고 시각 · 시작 시각을 몰라 보내지 않았다. */
|
||||
| 'no-start'
|
||||
/** 아직 예고 시각 전이다. */
|
||||
| 'too-early'
|
||||
/** 시작 시각이 지나 예고로서 의미가 없다. */
|
||||
| 'too-old';
|
||||
|
||||
export async function notifyEclipseWarning(
|
||||
eclipse: DocumentSnapshot,
|
||||
now: Date = new Date(),
|
||||
): Promise<EclipseAlertOutcome> {
|
||||
if (!eclipse.exists || eclipse.get('alertPushedAt') || eclipse.get('status') === 'cancelled') {
|
||||
return 'already';
|
||||
}
|
||||
const warnAt = eclipse.get('warnAt') as Timestamp | undefined;
|
||||
const startAt = eclipse.get('startAt') as Timestamp | undefined;
|
||||
if (!warnAt || !startAt) {
|
||||
logger.debug('[push] 예고 시각이 없어 보내지 않습니다', { path: eclipse.ref.path });
|
||||
return 'no-start';
|
||||
}
|
||||
if (now.getTime() < warnAt.toMillis()) return 'too-early';
|
||||
const mark = () => eclipse.ref.set({ alertPushedAt: Timestamp.fromDate(now) }, { merge: true });
|
||||
if (now.getTime() >= startAt.toMillis()) {
|
||||
await mark();
|
||||
logger.info('[push] 시작 시각이 지나 예고를 건너뜁니다', { path: eclipse.ref.path });
|
||||
return 'too-old';
|
||||
}
|
||||
|
||||
// `fandoms/{fandomId}/eclipseRounds/{roundId}` 이므로 조부모가 팬덤 문서다.
|
||||
const fandomRef = eclipse.ref.parent.parent;
|
||||
if (!fandomRef) {
|
||||
logger.error('[push] 이클립스 경로가 팬덤 아래가 아닙니다', { path: eclipse.ref.path });
|
||||
return 'no-start';
|
||||
}
|
||||
const name = ((await fandomRef.get()).get('name') as string | undefined) ?? fandomRef.id;
|
||||
const minutes = Math.max(1, Math.round((startAt.toMillis() - now.getTime()) / 60_000));
|
||||
|
||||
const sent = await sendFandomPush({
|
||||
fandomId: fandomRef.id,
|
||||
category: 'fandomEvent',
|
||||
title: '이클립스가 곧 시작됩니다',
|
||||
body: `${minutes}분 뒤 ${name} 행성에 이클립스가 찾아옵니다. 함께 흔들어 행성을 지켜 주세요.`,
|
||||
data: { kind: 'eclipse', eclipseId: eclipse.id },
|
||||
});
|
||||
|
||||
// 발송 성공 여부와 무관하게 표시한다. 실패를 이유로 남겨 두면 1 분마다 같은
|
||||
// 실패를 반복하고, 복구된 순간 지난 이클립스 예고가 한꺼번에 나간다.
|
||||
await mark();
|
||||
return sent ? 'sent' : 'failed';
|
||||
}
|
||||
Reference in New Issue
Block a user