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:
@@ -52,6 +52,11 @@ public class ExternalCallLoggingAspect {
|
||||
return around(jp, "MESSAGE");
|
||||
}
|
||||
|
||||
@Around("execution(* com.ga.common.adapter.push.PushAdapter+.*(..))")
|
||||
public Object aroundPush(ProceedingJoinPoint jp) throws Throwable {
|
||||
return around(jp, "PUSH");
|
||||
}
|
||||
|
||||
private Object around(ProceedingJoinPoint jp, String callType) throws Throwable {
|
||||
long start = System.currentTimeMillis();
|
||||
ExternalCallLogVO vo = new ExternalCallLogVO();
|
||||
|
||||
@@ -119,4 +119,18 @@ public class MobileMeController {
|
||||
public ApiResponse<Integer> markAllRead() {
|
||||
return ApiResponse.ok(service.markAllNotificationsRead());
|
||||
}
|
||||
|
||||
/** 푸시용 디바이스 토큰 등록. body: {token, platform?} */
|
||||
@PostMapping("/device-tokens")
|
||||
public ApiResponse<Void> registerDeviceToken(@RequestBody java.util.Map<String, String> body) {
|
||||
service.registerDeviceToken(body.get("token"), body.get("platform"));
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
|
||||
/** 디바이스 토큰 해제(로그아웃 등). body: {token} */
|
||||
@PostMapping("/device-tokens/unregister")
|
||||
public ApiResponse<Void> unregisterDeviceToken(@RequestBody java.util.Map<String, String> body) {
|
||||
service.unregisterDeviceToken(body.get("token"));
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,25 @@ public class MobileMeService {
|
||||
private final SettleMasterMapper settleMasterMapper;
|
||||
private final WithdrawRequestService withdrawRequestService;
|
||||
private final CommissionStatementService statementService;
|
||||
private final com.ga.core.mapper.notice.UserDeviceTokenMapper deviceTokenMapper;
|
||||
|
||||
/** 푸시용 디바이스 토큰 등록(UPSERT). 토큰은 본인(userId) 귀속. */
|
||||
@Transactional
|
||||
public void registerDeviceToken(String token, String platform) {
|
||||
if (token == null || token.isBlank()) throw new BizException(ErrorCode.INVALID_PARAMETER, "token");
|
||||
com.ga.core.vo.notice.UserDeviceTokenVO vo = new com.ga.core.vo.notice.UserDeviceTokenVO();
|
||||
vo.setUserId(currentUserId());
|
||||
vo.setToken(token);
|
||||
vo.setPlatform(platform == null || platform.isBlank() ? "WEB" : platform);
|
||||
deviceTokenMapper.upsert(vo);
|
||||
}
|
||||
|
||||
/** 디바이스 토큰 해제(로그아웃/만료). 본인 토큰만. */
|
||||
@Transactional
|
||||
public void unregisterDeviceToken(String token) {
|
||||
if (token == null || token.isBlank()) throw new BizException(ErrorCode.INVALID_PARAMETER, "token");
|
||||
deviceTokenMapper.deactivate(token, currentUserId());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public MeSummary summary() {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.ga.api.service.push;
|
||||
|
||||
import com.ga.common.adapter.push.PushAdapter;
|
||||
import com.ga.common.adapter.push.PushRequest;
|
||||
import com.ga.core.mapper.notice.UserDeviceTokenMapper;
|
||||
import com.ga.core.vo.notice.UserDeviceTokenVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 사용자 단위 푸시 디스패치.
|
||||
* <p>
|
||||
* user_notification 발행 지점에서 호출해, 해당 사용자의 활성 디바이스 토큰 전부에 best-effort 푸시.
|
||||
* 푸시 실패가 본 트랜잭션(알림 발행/정산)을 깨지 않도록 모든 예외를 흡수한다.
|
||||
* 실제 발송은 {@link PushAdapter} 구현(mock/fcm, app.adapter.push)로 위임.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PushDispatchService {
|
||||
|
||||
private final PushAdapter pushAdapter;
|
||||
private final UserDeviceTokenMapper deviceTokenMapper;
|
||||
|
||||
/** 사용자의 모든 활성 디바이스에 푸시 (best-effort). */
|
||||
public void sendToUser(Long userId, String title, String body, String refType, Long refId) {
|
||||
if (userId == null) return;
|
||||
try {
|
||||
List<UserDeviceTokenVO> tokens = deviceTokenMapper.selectActiveByUser(userId);
|
||||
for (UserDeviceTokenVO t : tokens) {
|
||||
try {
|
||||
pushAdapter.send(PushRequest.builder()
|
||||
.deviceToken(t.getToken())
|
||||
.title(title)
|
||||
.body(body)
|
||||
.refType(refType)
|
||||
.refId(refId)
|
||||
.refUserId(userId)
|
||||
.build());
|
||||
} catch (Exception e) {
|
||||
log.warn("[PUSH] 발송 실패 userId={} tokenId={}: {}", userId, t.getTokenId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[PUSH] 디스패치 실패 userId={}: {}", userId, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.ga.api.service.withdraw;
|
||||
|
||||
import com.ga.api.service.push.PushDispatchService;
|
||||
import com.ga.common.adapter.bank.BankTransferAdapter;
|
||||
import com.ga.common.adapter.bank.BankTransferResponse;
|
||||
import com.ga.common.adapter.bank.BankTransferStatus;
|
||||
@@ -38,6 +39,7 @@ public class WithdrawBankSettleService {
|
||||
private final WithdrawRequestMapper mapper;
|
||||
private final BankTransferAdapter bankTransferAdapter;
|
||||
private final UserNotificationMapper notificationMapper;
|
||||
private final PushDispatchService pushDispatchService;
|
||||
|
||||
@Value("${app.bank-settle.limit:50}")
|
||||
private int batchLimit;
|
||||
@@ -105,6 +107,9 @@ public class WithdrawBankSettleService {
|
||||
+ ". 재시도 처리됩니다.");
|
||||
}
|
||||
notificationMapper.insert(n);
|
||||
// 인앱 알림과 함께 디바이스 푸시도 best-effort 발송
|
||||
pushDispatchService.sendToUser(userId, n.getTitle(), n.getContent(),
|
||||
NotificationRefType.WITHDRAW.name(), vo.getRequestId());
|
||||
} catch (Exception e) {
|
||||
log.warn("[BANK-SETTLE] 알림 발행 실패 requestId={}: {}", vo.getRequestId(), e.getMessage());
|
||||
}
|
||||
|
||||
@@ -61,6 +61,12 @@ app:
|
||||
adapter:
|
||||
bank: ${ADAPTER_BANK:mock}
|
||||
message: ${ADAPTER_MESSAGE:mock}
|
||||
push: ${ADAPTER_PUSH:mock}
|
||||
# FCM HTTP v1 (push=fcm 일 때 사용). 운영 시 projectId/accessToken 주입.
|
||||
fcm:
|
||||
base-url: ${FCM_BASE_URL:https://fcm.googleapis.com}
|
||||
project-id: ${FCM_PROJECT_ID:}
|
||||
access-token: ${FCM_ACCESS_TOKEN:}
|
||||
# KFTC 오픈뱅킹 입금이체 (bank=kftc 일 때 사용). 운영 시 토큰/약정계좌 주입.
|
||||
kftc:
|
||||
base-url: ${KFTC_BASE_URL:https://openapi.openbanking.or.kr}
|
||||
|
||||
@@ -3,10 +3,13 @@ package com.ga.external;
|
||||
import com.ga.common.adapter.bank.BankTransferRequest;
|
||||
import com.ga.common.adapter.message.MessageChannel;
|
||||
import com.ga.common.adapter.message.MessageRequest;
|
||||
import com.ga.common.adapter.push.PushRequest;
|
||||
import com.ga.external.bank.KftcBankTransferAdapter;
|
||||
import com.ga.external.bank.KftcProperties;
|
||||
import com.ga.external.message.ToastMessageAdapter;
|
||||
import com.ga.external.message.ToastProperties;
|
||||
import com.ga.external.push.FcmProperties;
|
||||
import com.ga.external.push.FcmPushAdapter;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -69,4 +72,18 @@ class AdapterFailFastTest {
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("app.adapter.kftc");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("FCM 푸시: projectId/토큰 누락 시 fail-fast")
|
||||
void fcm_failFast() {
|
||||
FcmPushAdapter adapter = new FcmPushAdapter(new FcmProperties());
|
||||
PushRequest req = PushRequest.builder()
|
||||
.deviceToken("dummy-token")
|
||||
.title("제목").body("내용")
|
||||
.refType("WITHDRAW").refId(1L)
|
||||
.build();
|
||||
assertThatThrownBy(() -> adapter.send(req))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("app.adapter.fcm");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.ga.common.adapter.push;
|
||||
|
||||
/**
|
||||
* 모바일 푸시 알림 외부연동 어댑터.
|
||||
* 구현체는 ga-external-adapter 모듈에 위치(Mock / FCM).
|
||||
*/
|
||||
public interface PushAdapter {
|
||||
|
||||
/** 단건 디바이스 토큰으로 푸시 발송. */
|
||||
PushResponse send(PushRequest request);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.ga.common.adapter.push;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public class PushRequest {
|
||||
private String deviceToken; // FCM 등록 토큰
|
||||
private String title;
|
||||
private String body;
|
||||
private String refType; // WITHDRAW/APPROVAL/SETTLE 등 (data payload)
|
||||
private Long refId;
|
||||
private Long refUserId; // 추적용 user_id
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.ga.common.adapter.push;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public class PushResponse {
|
||||
private String messageId; // 외부(FCM) 발급 message name/id
|
||||
private boolean sent;
|
||||
private String resultCode;
|
||||
private String resultMessage;
|
||||
private LocalDateTime sentAt;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
-- V107: user_device_token — 푸시 알림용 디바이스(FCM) 토큰 등록
|
||||
-- 사용자(설계사 PWA 등)가 발급받은 FCM 토큰을 저장. 알림 발생 시 활성 토큰으로 푸시 발송.
|
||||
|
||||
CREATE TABLE user_device_token (
|
||||
token_id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(user_id),
|
||||
token VARCHAR(512) NOT NULL,
|
||||
platform VARCHAR(20) NOT NULL DEFAULT 'WEB', -- WEB / ANDROID / IOS
|
||||
is_active CHAR(1) NOT NULL DEFAULT 'Y',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP,
|
||||
UNIQUE (token),
|
||||
CHECK (platform IN ('WEB','ANDROID','IOS')),
|
||||
CHECK (is_active IN ('Y','N'))
|
||||
);
|
||||
|
||||
COMMENT ON TABLE user_device_token IS '푸시 알림용 디바이스 토큰 — 사용자별 FCM 등록 토큰';
|
||||
COMMENT ON COLUMN user_device_token.user_id IS '소유 사용자 FK';
|
||||
COMMENT ON COLUMN user_device_token.token IS 'FCM 등록 토큰(또는 웹푸시 endpoint)';
|
||||
COMMENT ON COLUMN user_device_token.platform IS '플랫폼 (WEB/ANDROID/IOS)';
|
||||
COMMENT ON COLUMN user_device_token.is_active IS '활성 여부 — 만료/로그아웃 시 N';
|
||||
|
||||
CREATE INDEX idx_udt_user ON user_device_token(user_id, is_active);
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.ga.core.mapper.notice;
|
||||
|
||||
import com.ga.core.vo.notice.UserDeviceTokenVO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* user_device_token Mapper (V107).
|
||||
*/
|
||||
@Mapper
|
||||
public interface UserDeviceTokenMapper {
|
||||
|
||||
/** 토큰 등록(UPSERT) — 동일 token 재등록 시 user/플랫폼 갱신 + 활성화. */
|
||||
int upsert(UserDeviceTokenVO vo);
|
||||
|
||||
/** 사용자의 활성 토큰 목록 — 푸시 디스패치용. */
|
||||
List<UserDeviceTokenVO> selectActiveByUser(@Param("userId") Long userId);
|
||||
|
||||
/** 토큰 비활성화(로그아웃/만료). user_id 검증 포함. */
|
||||
int deactivate(@Param("token") String token, @Param("userId") Long userId);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.ga.core.vo.notice;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* user_device_token — 푸시 알림용 디바이스 토큰 (V107)
|
||||
*/
|
||||
@Data
|
||||
public class UserDeviceTokenVO {
|
||||
private Long tokenId;
|
||||
private Long userId;
|
||||
private String token;
|
||||
/** WEB / ANDROID / IOS */
|
||||
private String platform;
|
||||
/** Y / N */
|
||||
private String isActive;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ga.core.mapper.notice.UserDeviceTokenMapper">
|
||||
|
||||
<resultMap id="VOMap" type="com.ga.core.vo.notice.UserDeviceTokenVO"/>
|
||||
|
||||
<!-- 동일 토큰 재등록 시 소유자/플랫폼 갱신 + 재활성화 -->
|
||||
<insert id="upsert" useGeneratedKeys="true" keyProperty="tokenId">
|
||||
INSERT INTO user_device_token (user_id, token, platform, is_active)
|
||||
VALUES (#{userId}, #{token}, #{platform}, 'Y')
|
||||
ON CONFLICT (token) DO UPDATE
|
||||
SET user_id = EXCLUDED.user_id,
|
||||
platform = EXCLUDED.platform,
|
||||
is_active = 'Y',
|
||||
updated_at = NOW()
|
||||
</insert>
|
||||
|
||||
<select id="selectActiveByUser" resultMap="VOMap">
|
||||
SELECT token_id, user_id, token, platform, is_active, created_at, updated_at
|
||||
FROM user_device_token
|
||||
WHERE user_id = #{userId}
|
||||
AND is_active = 'Y'
|
||||
ORDER BY updated_at DESC NULLS LAST, created_at DESC
|
||||
</select>
|
||||
|
||||
<update id="deactivate">
|
||||
UPDATE user_device_token
|
||||
SET is_active = 'N', updated_at = NOW()
|
||||
WHERE token = #{token} AND user_id = #{userId}
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
@@ -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) + "****";
|
||||
}
|
||||
}
|
||||
@@ -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,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 가 토스트 처리
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user