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:
@@ -0,0 +1,26 @@
|
||||
package com.ga.external.push;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Firebase Cloud Messaging(FCM) HTTP v1 설정.
|
||||
* <p>
|
||||
* application.yml 의 <code>app.adapter.fcm.*</code> 로 주입.
|
||||
* <b>운영 전제</b>: Firebase 프로젝트 + 서비스계정. Access Token(OAuth2, scope
|
||||
* https://www.googleapis.com/auth/firebase.messaging)은 서비스계정으로 주기 발급하여 주입한다
|
||||
* (재발급은 운영 책임). 필수 값이 비어 있으면 어댑터가 fail-fast 한다.
|
||||
*/
|
||||
@Data
|
||||
@ConfigurationProperties(prefix = "app.adapter.fcm")
|
||||
public class FcmProperties {
|
||||
|
||||
/** FCM HTTP v1 베이스 URL. */
|
||||
private String baseUrl = "https://fcm.googleapis.com";
|
||||
|
||||
/** Firebase 프로젝트 ID. */
|
||||
private String projectId;
|
||||
|
||||
/** OAuth2 Access Token (Bearer, firebase.messaging scope). 운영에서 주기 재발급해 주입. */
|
||||
private String accessToken;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.ga.external.push;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.ga.common.adapter.push.PushAdapter;
|
||||
import com.ga.common.adapter.push.PushRequest;
|
||||
import com.ga.common.adapter.push.PushResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Firebase Cloud Messaging(FCM) HTTP v1 어댑터 — 실구현.
|
||||
* <p>
|
||||
* 활성 조건: <code>app.adapter.push=fcm</code> (기본은 mock). 키는 {@link FcmProperties} 로 외부화.
|
||||
* <code>POST /v1/projects/{projectId}/messages:send</code> 에 Bearer 토큰으로 발송.
|
||||
* 응답의 <code>name</code>(projects/{id}/messages/{msgId})을 messageId 로 반환.
|
||||
* 필수 설정 누락 시 fail-fast. 외부호출 감사로그는 ga-api 의 AOP가 남긴다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@ConditionalOnProperty(prefix = "app.adapter", name = "push", havingValue = "fcm")
|
||||
@EnableConfigurationProperties(FcmProperties.class)
|
||||
public class FcmPushAdapter implements PushAdapter {
|
||||
|
||||
private final FcmProperties props;
|
||||
private final RestClient client;
|
||||
|
||||
public FcmPushAdapter(FcmProperties props) {
|
||||
this.props = props;
|
||||
this.client = RestClient.builder().baseUrl(props.getBaseUrl()).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public PushResponse send(PushRequest request) {
|
||||
require(props.getProjectId(), "app.adapter.fcm.project-id");
|
||||
require(props.getAccessToken(), "app.adapter.fcm.access-token");
|
||||
require(request.getDeviceToken(), "deviceToken");
|
||||
|
||||
Map<String, Object> notification = new LinkedHashMap<>();
|
||||
notification.put("title", request.getTitle() == null ? "" : request.getTitle());
|
||||
notification.put("body", request.getBody() == null ? "" : request.getBody());
|
||||
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
if (request.getRefType() != null) data.put("refType", request.getRefType());
|
||||
if (request.getRefId() != null) data.put("refId", String.valueOf(request.getRefId()));
|
||||
|
||||
Map<String, Object> message = new LinkedHashMap<>();
|
||||
message.put("token", request.getDeviceToken());
|
||||
message.put("notification", notification);
|
||||
if (!data.isEmpty()) message.put("data", data);
|
||||
|
||||
Map<String, Object> body = Map.of("message", message);
|
||||
|
||||
try {
|
||||
JsonNode res = client.post()
|
||||
.uri("/v1/projects/{projectId}/messages:send", props.getProjectId())
|
||||
.header("Authorization", "Bearer " + props.getAccessToken())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(body)
|
||||
.retrieve()
|
||||
.body(JsonNode.class);
|
||||
String name = res == null ? "" : res.path("name").asText("");
|
||||
boolean ok = StringUtils.hasText(name);
|
||||
return PushResponse.builder()
|
||||
.messageId(name)
|
||||
.sent(ok)
|
||||
.resultCode(ok ? "0000" : "ERROR")
|
||||
.resultMessage(ok ? "sent" : "no message name")
|
||||
.sentAt(LocalDateTime.now())
|
||||
.build();
|
||||
} catch (RestClientException e) {
|
||||
log.warn("[FCM] 발송 실패 refType={} refId={} err={}",
|
||||
request.getRefType(), request.getRefId(), e.getMessage());
|
||||
return PushResponse.builder()
|
||||
.sent(false).resultCode("ERROR").resultMessage(e.getMessage())
|
||||
.sentAt(LocalDateTime.now()).build();
|
||||
}
|
||||
}
|
||||
|
||||
private void require(String value, String key) {
|
||||
if (!StringUtils.hasText(value)) {
|
||||
throw new IllegalStateException("FCM 설정 누락: " + key + " 가 필요합니다.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.ga.external.push;
|
||||
|
||||
import com.ga.common.adapter.push.PushAdapter;
|
||||
import com.ga.common.adapter.push.PushRequest;
|
||||
import com.ga.common.adapter.push.PushResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 푸시 Mock 구현 (기본).
|
||||
* 활성 조건: app.adapter.push=mock (기본). 실제 FCM 통합 시 yml 값을 'fcm' 으로 변경.
|
||||
* 항상 sent=true 반환, 토큰은 앞부분만 로그.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@ConditionalOnProperty(prefix = "app.adapter", name = "push", havingValue = "mock", matchIfMissing = true)
|
||||
public class MockPushAdapter implements PushAdapter {
|
||||
|
||||
@Override
|
||||
public PushResponse send(PushRequest request) {
|
||||
String id = "MOCK-PUSH-" + UUID.randomUUID();
|
||||
log.info("[MOCK PUSH] token={} title={} refType={} refId={} id={}",
|
||||
maskToken(request.getDeviceToken()), request.getTitle(),
|
||||
request.getRefType(), request.getRefId(), id);
|
||||
return PushResponse.builder()
|
||||
.messageId(id).sent(true)
|
||||
.resultCode("0000").resultMessage("MOCK sent")
|
||||
.sentAt(LocalDateTime.now()).build();
|
||||
}
|
||||
|
||||
private String maskToken(String token) {
|
||||
if (token == null) return null;
|
||||
return token.length() <= 8 ? "****" : token.substring(0, 8) + "****";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user