diff --git a/ga-api/src/main/java/com/ga/api/aop/ExternalCallLoggingAspect.java b/ga-api/src/main/java/com/ga/api/aop/ExternalCallLoggingAspect.java index aceec60..d626526 100644 --- a/ga-api/src/main/java/com/ga/api/aop/ExternalCallLoggingAspect.java +++ b/ga-api/src/main/java/com/ga/api/aop/ExternalCallLoggingAspect.java @@ -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(); diff --git a/ga-api/src/main/java/com/ga/api/controller/mobile/MobileMeController.java b/ga-api/src/main/java/com/ga/api/controller/mobile/MobileMeController.java index bb0e774..c6a8719 100644 --- a/ga-api/src/main/java/com/ga/api/controller/mobile/MobileMeController.java +++ b/ga-api/src/main/java/com/ga/api/controller/mobile/MobileMeController.java @@ -119,4 +119,18 @@ public class MobileMeController { public ApiResponse markAllRead() { return ApiResponse.ok(service.markAllNotificationsRead()); } + + /** 푸시용 디바이스 토큰 등록. body: {token, platform?} */ + @PostMapping("/device-tokens") + public ApiResponse registerDeviceToken(@RequestBody java.util.Map body) { + service.registerDeviceToken(body.get("token"), body.get("platform")); + return ApiResponse.ok(); + } + + /** 디바이스 토큰 해제(로그아웃 등). body: {token} */ + @PostMapping("/device-tokens/unregister") + public ApiResponse unregisterDeviceToken(@RequestBody java.util.Map body) { + service.unregisterDeviceToken(body.get("token")); + return ApiResponse.ok(); + } } diff --git a/ga-api/src/main/java/com/ga/api/service/mobile/MobileMeService.java b/ga-api/src/main/java/com/ga/api/service/mobile/MobileMeService.java index 2812e1f..1e7fb46 100644 --- a/ga-api/src/main/java/com/ga/api/service/mobile/MobileMeService.java +++ b/ga-api/src/main/java/com/ga/api/service/mobile/MobileMeService.java @@ -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() { diff --git a/ga-api/src/main/java/com/ga/api/service/push/PushDispatchService.java b/ga-api/src/main/java/com/ga/api/service/push/PushDispatchService.java new file mode 100644 index 0000000..ff7a124 --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/push/PushDispatchService.java @@ -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; + +/** + * 사용자 단위 푸시 디스패치. + *

+ * 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 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()); + } + } +} diff --git a/ga-api/src/main/java/com/ga/api/service/withdraw/WithdrawBankSettleService.java b/ga-api/src/main/java/com/ga/api/service/withdraw/WithdrawBankSettleService.java index e9e4851..4894a39 100644 --- a/ga-api/src/main/java/com/ga/api/service/withdraw/WithdrawBankSettleService.java +++ b/ga-api/src/main/java/com/ga/api/service/withdraw/WithdrawBankSettleService.java @@ -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()); } diff --git a/ga-api/src/main/resources/application.yml b/ga-api/src/main/resources/application.yml index ab7e96d..1677192 100644 --- a/ga-api/src/main/resources/application.yml +++ b/ga-api/src/main/resources/application.yml @@ -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} diff --git a/ga-api/src/test/java/com/ga/external/AdapterFailFastTest.java b/ga-api/src/test/java/com/ga/external/AdapterFailFastTest.java index c79f485..4fb0513 100644 --- a/ga-api/src/test/java/com/ga/external/AdapterFailFastTest.java +++ b/ga-api/src/test/java/com/ga/external/AdapterFailFastTest.java @@ -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"); + } } diff --git a/ga-common/src/main/java/com/ga/common/adapter/push/PushAdapter.java b/ga-common/src/main/java/com/ga/common/adapter/push/PushAdapter.java new file mode 100644 index 0000000..d1217d6 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/adapter/push/PushAdapter.java @@ -0,0 +1,11 @@ +package com.ga.common.adapter.push; + +/** + * 모바일 푸시 알림 외부연동 어댑터. + * 구현체는 ga-external-adapter 모듈에 위치(Mock / FCM). + */ +public interface PushAdapter { + + /** 단건 디바이스 토큰으로 푸시 발송. */ + PushResponse send(PushRequest request); +} diff --git a/ga-common/src/main/java/com/ga/common/adapter/push/PushRequest.java b/ga-common/src/main/java/com/ga/common/adapter/push/PushRequest.java new file mode 100644 index 0000000..03d426a --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/adapter/push/PushRequest.java @@ -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 +} diff --git a/ga-common/src/main/java/com/ga/common/adapter/push/PushResponse.java b/ga-common/src/main/java/com/ga/common/adapter/push/PushResponse.java new file mode 100644 index 0000000..67294ef --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/adapter/push/PushResponse.java @@ -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; +} diff --git a/ga-common/src/main/resources/db/migration/V107__디바이스토큰.sql b/ga-common/src/main/resources/db/migration/V107__디바이스토큰.sql new file mode 100644 index 0000000..63dadb8 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V107__디바이스토큰.sql @@ -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); diff --git a/ga-core/src/main/java/com/ga/core/mapper/notice/UserDeviceTokenMapper.java b/ga-core/src/main/java/com/ga/core/mapper/notice/UserDeviceTokenMapper.java new file mode 100644 index 0000000..708aed4 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/mapper/notice/UserDeviceTokenMapper.java @@ -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 selectActiveByUser(@Param("userId") Long userId); + + /** 토큰 비활성화(로그아웃/만료). user_id 검증 포함. */ + int deactivate(@Param("token") String token, @Param("userId") Long userId); +} diff --git a/ga-core/src/main/java/com/ga/core/vo/notice/UserDeviceTokenVO.java b/ga-core/src/main/java/com/ga/core/vo/notice/UserDeviceTokenVO.java new file mode 100644 index 0000000..3303c7f --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/notice/UserDeviceTokenVO.java @@ -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; +} diff --git a/ga-core/src/main/resources/mapper/notice/UserDeviceTokenMapper.xml b/ga-core/src/main/resources/mapper/notice/UserDeviceTokenMapper.xml new file mode 100644 index 0000000..58dbd96 --- /dev/null +++ b/ga-core/src/main/resources/mapper/notice/UserDeviceTokenMapper.xml @@ -0,0 +1,33 @@ + + + + + + + + + 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() + + + + + + UPDATE user_device_token + SET is_active = 'N', updated_at = NOW() + WHERE token = #{token} AND user_id = #{userId} + + + diff --git a/ga-external-adapter/src/main/java/com/ga/external/push/FcmProperties.java b/ga-external-adapter/src/main/java/com/ga/external/push/FcmProperties.java new file mode 100644 index 0000000..0b4c9d2 --- /dev/null +++ b/ga-external-adapter/src/main/java/com/ga/external/push/FcmProperties.java @@ -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 설정. + *

+ * application.yml 의 app.adapter.fcm.* 로 주입. + * 운영 전제: 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; +} diff --git a/ga-external-adapter/src/main/java/com/ga/external/push/FcmPushAdapter.java b/ga-external-adapter/src/main/java/com/ga/external/push/FcmPushAdapter.java new file mode 100644 index 0000000..3908258 --- /dev/null +++ b/ga-external-adapter/src/main/java/com/ga/external/push/FcmPushAdapter.java @@ -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 어댑터 — 실구현. + *

+ * 활성 조건: app.adapter.push=fcm (기본은 mock). 키는 {@link FcmProperties} 로 외부화. + * POST /v1/projects/{projectId}/messages:send 에 Bearer 토큰으로 발송. + * 응답의 name(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 notification = new LinkedHashMap<>(); + notification.put("title", request.getTitle() == null ? "" : request.getTitle()); + notification.put("body", request.getBody() == null ? "" : request.getBody()); + + Map data = new LinkedHashMap<>(); + if (request.getRefType() != null) data.put("refType", request.getRefType()); + if (request.getRefId() != null) data.put("refId", String.valueOf(request.getRefId())); + + Map message = new LinkedHashMap<>(); + message.put("token", request.getDeviceToken()); + message.put("notification", notification); + if (!data.isEmpty()) message.put("data", data); + + Map 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 + " 가 필요합니다."); + } + } +} diff --git a/ga-external-adapter/src/main/java/com/ga/external/push/MockPushAdapter.java b/ga-external-adapter/src/main/java/com/ga/external/push/MockPushAdapter.java new file mode 100644 index 0000000..f88d2c1 --- /dev/null +++ b/ga-external-adapter/src/main/java/com/ga/external/push/MockPushAdapter.java @@ -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) + "****"; + } +} diff --git a/ga-mobile-pwa/src/api/push.ts b/ga-mobile-pwa/src/api/push.ts new file mode 100644 index 0000000..9641e5a --- /dev/null +++ b/ga-mobile-pwa/src/api/push.ts @@ -0,0 +1,12 @@ +import api, { unwrap } from './request'; + +/** + * 푸시 디바이스 토큰 등록/해제 API. + * 서버측 푸시 파이프라인(PushAdapter/PushDispatchService)과 연동. + */ +export const pushApi = { + register: (token: string, platform = 'WEB') => + unwrap(api.post('/api/mobile/me/device-tokens', { token, platform })), + unregister: (token: string) => + unwrap(api.post('/api/mobile/me/device-tokens/unregister', { token })), +}; diff --git a/ga-mobile-pwa/src/pages/Login.tsx b/ga-mobile-pwa/src/pages/Login.tsx index c6ca2d6..6d5d0b9 100644 --- a/ga-mobile-pwa/src/pages/Login.tsx +++ b/ga-mobile-pwa/src/pages/Login.tsx @@ -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 가 토스트 처리 diff --git a/ga-mobile-pwa/src/push/initPush.ts b/ga-mobile-pwa/src/push/initPush.ts new file mode 100644 index 0000000..cfb6219 --- /dev/null +++ b/ga-mobile-pwa/src/push/initPush.ts @@ -0,0 +1,52 @@ +import { pushApi } from '@/api/push'; + +/** + * 푸시 알림 초기화 (config-gated). + * + * 활성 조건: 빌드 환경변수 `VITE_FCM_*` 가 모두 설정된 경우에만 동작한다. + * 미설정 시 no-op (개발/미연동 환경에서 안전). + * + * 운영 활성화 절차: + * 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 { + 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); + } +}