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}
|
||||
|
||||
Reference in New Issue
Block a user