Cloud Functions 도메인 함수 — POP 원장 · 퀘스트 · 이클립스 · 행성 성장 · 캠페인 · 결제 · 알림 · 브리핑 · INSIDE · 추천 · 번역 · 서버 테스트

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent f483e8d94b
commit 316821e7d5
61 changed files with 14660 additions and 60 deletions
+137
View File
@@ -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';
}