feat: 푸시 알림 파이프라인 — PushAdapter(FCM HTTP v1) + 디바이스토큰 등록 + 디스패치

마지막 외부연동 영역(푸시)을 채널=FCM HTTP v1 로 확정해 서버측 완전 구현.
Toast/KFTC 와 동일 패턴: 실 API·config-gated(app.adapter.push, 기본 mock)·fail-fast.

- ga-common: PushAdapter 인터페이스 + PushRequest/PushResponse
- ga-external-adapter: MockPushAdapter(기본) + FcmPushAdapter(POST /v1/projects/{id}/messages:send,
  Bearer, notification+data 매핑, name→messageId) + FcmProperties. 의존성 추가 없음.
- V107 user_device_token(user_id/token/platform/is_active, UNIQUE token) + UserDeviceTokenVO/Mapper(upsert/selectActiveByUser/deactivate)
- ga-api:
  - PushDispatchService.sendToUser — 사용자 활성 토큰 전부에 best-effort 푸시(예외 흡수)
  - MobileMe: POST /api/mobile/me/device-tokens (등록 upsert) + /unregister (본인검증 deactivate)
  - WithdrawBankSettleService.notify 에 푸시 디스패치 연동(출금 SETTLED/FAILED 시 인앱+푸시)
  - ExternalCallLoggingAspect 에 PushAdapter 포인트컷 추가(외부호출 감사로그)
  - application.yml app.adapter.push + app.adapter.fcm.* 설정
- ga-mobile-pwa: api/push.ts(register/unregister) + push/initPush.ts(config-gated, VITE_FCM_* 미설정 시 no-op) + 로그인 성공 시 initPush() 훅
- AdapterFailFastTest 에 FCM fail-fast 케이스 추가

검증: 전체 ./gradlew build(test) SUCCESSFUL, PWA 빌드 OK, Flyway V107 success,
디바이스토큰 등록/해제/검증 라이브 e2e PASS(등록200·해제200·is_active=N·빈토큰400).
실 푸시 발송은 Firebase 프로젝트(서버 FCM_PROJECT_ID/ACCESS_TOKEN + PWA VITE_FCM_* + firebase SDK) 주입 후 가능.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
GA Pro
2026-05-31 21:01:58 +09:00
parent 78ffd5a56a
commit 204e3b6975
20 changed files with 484 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
import api, { unwrap } from './request';
/**
* 푸시 디바이스 토큰 등록/해제 API.
* 서버측 푸시 파이프라인(PushAdapter/PushDispatchService)과 연동.
*/
export const pushApi = {
register: (token: string, platform = 'WEB') =>
unwrap<void>(api.post('/api/mobile/me/device-tokens', { token, platform })),
unregister: (token: string) =>
unwrap<void>(api.post('/api/mobile/me/device-tokens/unregister', { token })),
};
+2
View File
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { Button, Form, Input, NavBar, Toast } from 'antd-mobile';
import { useLocation, useNavigate } from 'react-router-dom';
import { authApi } from '@/api/auth';
import { initPush } from '@/push/initPush';
import { useAuthStore } from '@/stores/authStore';
export default function Login() {
@@ -19,6 +20,7 @@ export default function Login() {
userId: 0, loginId: values.loginId,
});
Toast.show({ icon: 'success', content: '로그인 성공' });
void initPush(); // 푸시 토큰 등록 (config-gated, 미설정 시 no-op)
navigate(location.state?.from ?? '/', { replace: true });
} catch {
// request interceptor 가 토스트 처리
+52
View File
@@ -0,0 +1,52 @@
import { pushApi } from '@/api/push';
/**
* 푸시 알림 초기화 (config-gated).
*
* 활성 조건: 빌드 환경변수 `VITE_FCM_*` 가 모두 설정된 경우에만 동작한다.
* 미설정 시 no-op (개발/미연동 환경에서 안전).
*
* <b>운영 활성화 절차</b>:
* 1) Firebase 프로젝트 생성 → 웹 앱 등록 → 설정값을 .env 의 VITE_FCM_* 로 주입
* 2) `npm i firebase` 후 아래 TODO 블록의 firebase/messaging 초기화 활성화
* 3) getToken(VAPID key)으로 FCM 토큰 발급 → pushApi.register(token) 호출
* 4) public/firebase-messaging-sw.js 서비스워커 배치
*
* 서버측(PushAdapter=fcm + FCM_PROJECT_ID/FCM_ACCESS_TOKEN)과 함께 활성화해야 실제 발송된다.
*/
export async function initPush(): Promise<void> {
const cfg = {
apiKey: import.meta.env.VITE_FCM_API_KEY,
projectId: import.meta.env.VITE_FCM_PROJECT_ID,
appId: import.meta.env.VITE_FCM_APP_ID,
messagingSenderId: import.meta.env.VITE_FCM_SENDER_ID,
vapidKey: import.meta.env.VITE_FCM_VAPID_KEY,
};
// 환경변수 미설정 → 푸시 비활성 (no-op)
if (!cfg.apiKey || !cfg.projectId || !cfg.appId || !cfg.vapidKey) {
if (import.meta.env.DEV) {
// eslint-disable-next-line no-console
console.info('[push] VITE_FCM_* 미설정 — 푸시 비활성(서버는 mock).');
}
return;
}
if (!('serviceWorker' in navigator) || !('Notification' in window)) return;
try {
const permission = await Notification.requestPermission();
if (permission !== 'granted') return;
// TODO(운영): `npm i firebase` 후 활성화.
// const { initializeApp } = await import('firebase/app');
// const { getMessaging, getToken } = await import('firebase/messaging');
// const appFb = initializeApp(cfg);
// const messaging = getMessaging(appFb);
// const token = await getToken(messaging, { vapidKey: cfg.vapidKey });
// if (token) await pushApi.register(token, 'WEB');
void pushApi; // 등록 API 는 토큰 발급 후 호출 (위 TODO 활성화 시)
} catch (e) {
if (import.meta.env.DEV) console.warn('[push] init 실패', e);
}
}