-
Notifications
You must be signed in to change notification settings - Fork 0
feat: FCM 푸시 발송 인프라 및 토큰 등록/해제 API 구현 #78
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| package com.piuda.callcare.domain.fcmtoken.controller; | ||
|
|
||
| import org.springframework.http.ResponseEntity; | ||
| import org.springframework.security.core.annotation.AuthenticationPrincipal; | ||
| import org.springframework.web.bind.annotation.DeleteMapping; | ||
| import org.springframework.web.bind.annotation.PostMapping; | ||
| import org.springframework.web.bind.annotation.RequestBody; | ||
| import org.springframework.web.bind.annotation.RequestMapping; | ||
| import org.springframework.web.bind.annotation.RequestParam; | ||
| import org.springframework.web.bind.annotation.RestController; | ||
|
|
||
| import com.piuda.callcare.domain.fcmtoken.dto.request.FcmTokenRegisterRequest; | ||
| import com.piuda.callcare.domain.fcmtoken.dto.response.FcmTokenResponse; | ||
| import com.piuda.callcare.domain.fcmtoken.service.command.FcmTokenCommandService; | ||
| import com.piuda.callcare.global.common.response.ApiResponse; | ||
| import com.piuda.callcare.global.common.response.ResponseUtils; | ||
|
|
||
| import io.swagger.v3.oas.annotations.Operation; | ||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||
| import jakarta.validation.Valid; | ||
| import lombok.RequiredArgsConstructor; | ||
|
|
||
| @Tag(name = "FcmToken", description = "FCM 푸시 토큰 등록/해제 API") | ||
| @RestController | ||
| @RequestMapping("/api/fcm-tokens") | ||
| @RequiredArgsConstructor | ||
| public class FcmTokenController { | ||
|
|
||
| private final FcmTokenCommandService fcmTokenCommandService; | ||
|
|
||
| @Operation( | ||
| summary = "FCM 토큰 등록", | ||
| description = "로그인 직후와 토큰 갱신 시 호출합니다. 같은 토큰을 다시 보내도 행이 늘어나지 않고 같은 fcmTokenId가 반환됩니다." | ||
| ) | ||
| @PostMapping | ||
| public ResponseEntity<ApiResponse<FcmTokenResponse>> register( | ||
| @AuthenticationPrincipal Long userId, | ||
| @RequestBody @Valid FcmTokenRegisterRequest request | ||
| ) { | ||
| return ResponseUtils.created(fcmTokenCommandService.register(userId, request)); | ||
| } | ||
|
|
||
| @Operation( | ||
| summary = "FCM 토큰 해제", | ||
| description = "로그아웃 시 호출해 해당 기기를 푸시 발송 대상에서 제외합니다. 이미 해제됐거나 없는 토큰이어도 200을 반환합니다." | ||
| ) | ||
| @DeleteMapping | ||
| public ResponseEntity<ApiResponse<Void>> deactivate( | ||
| @AuthenticationPrincipal Long userId, | ||
| @RequestParam String token | ||
| ) { | ||
| fcmTokenCommandService.deactivate(userId, token); | ||
| return ResponseUtils.ok(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| package com.piuda.callcare.domain.fcmtoken.converter; | ||
|
|
||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import com.piuda.callcare.domain.fcmtoken.dto.response.FcmTokenResponse; | ||
| import com.piuda.callcare.domain.fcmtoken.entity.FcmToken; | ||
|
|
||
| @Component | ||
| public class FcmTokenConverter { | ||
|
|
||
| // FcmToken → FcmTokenResponse (등록 응답용) | ||
| public FcmTokenResponse toResponse(FcmToken fcmToken) { | ||
| return new FcmTokenResponse(fcmToken.getId(), fcmToken.getIsActive()); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| package com.piuda.callcare.domain.fcmtoken.dto.request; | ||
|
|
||
| import com.piuda.callcare.domain.fcmtoken.enums.DeviceType; | ||
|
|
||
| import io.swagger.v3.oas.annotations.media.Schema; | ||
| import jakarta.validation.constraints.NotBlank; | ||
| import jakarta.validation.constraints.NotNull; | ||
|
|
||
| @Schema(description = "FCM 토큰 등록 요청") | ||
| public record FcmTokenRegisterRequest( | ||
|
|
||
| @Schema(description = "Firebase SDK가 기기에 발급한 FCM 등록 토큰") | ||
| @NotBlank String token, | ||
|
|
||
| @Schema(description = "기기 종류 (ANDROID / IOS / WEB)") | ||
| @NotNull DeviceType deviceType | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| ) {} | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| package com.piuda.callcare.domain.fcmtoken.dto.response; | ||
|
|
||
| import io.swagger.v3.oas.annotations.media.Schema; | ||
|
|
||
| @Schema(description = "FCM 토큰 등록 응답") | ||
| public record FcmTokenResponse( | ||
|
|
||
| @Schema(description = "FCM 토큰 ID — 같은 토큰을 다시 등록하면 같은 값이 반환됩니다") | ||
| Long fcmTokenId, | ||
|
|
||
| @Schema(description = "발송 대상 여부") | ||
| Boolean isActive | ||
| ) {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,26 @@ | ||
| package com.piuda.callcare.domain.fcmtoken.repository; | ||
|
|
||
| import java.util.Collection; | ||
| import java.util.List; | ||
| import java.util.Optional; | ||
|
|
||
| import com.piuda.callcare.domain.fcmtoken.entity.FcmToken; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
| import org.springframework.data.jpa.repository.Modifying; | ||
| import org.springframework.data.jpa.repository.Query; | ||
| import org.springframework.data.repository.query.Param; | ||
|
|
||
| public interface FcmTokenRepository extends JpaRepository<FcmToken, Long> { | ||
| } | ||
|
|
||
| // multicast 발송 대상: 한 수신자의 활성 토큰 전체(폰+태블릿 등 기기 여러 대) | ||
| List<FcmToken> findByUser_IdAndIsActiveTrue(Long userId); | ||
|
|
||
| // 등록/해제 시 기존 토큰 조회 — 같은 토큰은 항상 한 행이므로 단건 | ||
| Optional<FcmToken> findByToken(String token); | ||
|
|
||
| // 발송 결과로 판별된 무효 토큰 정리. 발송이 트랜잭션 밖에서 일어나 토큰이 준영속 상태이므로 | ||
| // dirty checking 대신 id 기준 벌크 UPDATE로 반영한다. | ||
| @Modifying | ||
| @Query("UPDATE FcmToken t SET t.isActive = false WHERE t.id IN :ids") | ||
| void deactivateAllByIdIn(@Param("ids") Collection<Long> ids); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| package com.piuda.callcare.domain.fcmtoken.service.command; | ||
|
|
||
| import java.util.Optional; | ||
|
|
||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| import com.piuda.callcare.domain.fcmtoken.converter.FcmTokenConverter; | ||
| import com.piuda.callcare.domain.fcmtoken.dto.request.FcmTokenRegisterRequest; | ||
| import com.piuda.callcare.domain.fcmtoken.dto.response.FcmTokenResponse; | ||
| import com.piuda.callcare.domain.fcmtoken.entity.FcmToken; | ||
| import com.piuda.callcare.domain.fcmtoken.repository.FcmTokenRepository; | ||
| import com.piuda.callcare.domain.user.entity.User; | ||
| import com.piuda.callcare.domain.user.repository.UserRepository; | ||
| import com.piuda.callcare.global.exception.CallCareException; | ||
| import com.piuda.callcare.global.exception.ErrorCode; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
|
|
||
| /** | ||
| * FCM 토큰 등록/해제. | ||
| * <p> | ||
| * 발송({@code FcmSendService})은 활성 토큰만 대상으로 하므로, 이 서비스가 그 대상 목록의 입구다. | ||
| */ | ||
| @Slf4j | ||
| @Service | ||
| @RequiredArgsConstructor | ||
| @Transactional | ||
| public class FcmTokenCommandService { | ||
|
|
||
| private final FcmTokenRepository fcmTokenRepository; | ||
| private final UserRepository userRepository; | ||
| private final FcmTokenConverter fcmTokenConverter; | ||
|
|
||
| /** | ||
| * 토큰 등록 — 같은 토큰 값은 항상 한 행만 유지한다(upsert). | ||
| * 이미 있으면 소유자·기기 종류를 갱신하고 재활성화한다. 한 기기에서 계정을 바꿔 로그인하면 | ||
| * 소유자가 바뀌어야 이전 사용자에게 갈 푸시가 새 사용자 폰에 뜨지 않는다. | ||
| */ | ||
| public FcmTokenResponse register(Long userId, FcmTokenRegisterRequest request) { | ||
| User user = userRepository.findById(userId) | ||
| .orElseThrow(() -> new CallCareException(ErrorCode.USER_NOT_FOUND)); | ||
|
|
||
| FcmToken fcmToken = fcmTokenRepository.findByToken(request.token()) | ||
| .map(existing -> { | ||
| logIfOwnerChanged(existing, user); | ||
| existing.renew(user, request.deviceType()); // dirty checking | ||
| return existing; | ||
| }) | ||
| .orElseGet(() -> fcmTokenRepository.save(FcmToken.builder() | ||
| .user(user) | ||
| .token(request.token()) | ||
| .deviceType(request.deviceType()) | ||
| .build())); | ||
|
Comment on lines
+45
to
+55
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift 토큰 등록을 원자적으로 처리하세요. 동일한 새 토큰의 등록 요청이 동시에 실행되면, 두 트랜잭션이 모두 빈 결과를 읽고 DB upsert를 사용하거나, unique-key 충돌 후 별도 트랜잭션에서 기존 행을 다시 조회해 갱신하세요. 같은 트랜잭션에서 예외를 잡고 재조회하면 rollback-only 상태가 될 수 있습니다. 동시 등록 테스트도 추가하세요. 🤖 Prompt for AI Agents |
||
|
|
||
| return fcmTokenConverter.toResponse(fcmToken); | ||
| } | ||
|
|
||
| /** | ||
| * 토큰 해제(로그아웃·기기 제거) — 멱등 no-op. | ||
| * 없는 토큰이나 남의 토큰이면 예외 없이 아무것도 하지 않는다. 로그아웃은 재시도되는 경로라 | ||
| * "이미 해제됨"이 오류가 아니고, 예외로 응답하면 토큰 존재 여부가 외부에 드러난다. | ||
| */ | ||
| public void deactivate(Long userId, String token) { | ||
| Optional<FcmToken> found = fcmTokenRepository.findByToken(token); | ||
| if (found.isEmpty()) { | ||
| return; | ||
| } | ||
|
|
||
| FcmToken fcmToken = found.get(); | ||
| if (!fcmToken.getUser().getId().equals(userId)) { | ||
| log.warn("[FCM] 다른 사용자의 토큰 해제 시도 — 무시합니다 (requesterId={}, tokenId={})", userId, fcmToken.getId()); | ||
| return; | ||
| } | ||
|
|
||
| fcmToken.deactivate(); // dirty checking | ||
| } | ||
|
|
||
| // 기기 소유자가 바뀌는 시점을 남긴다. 오배송 신고가 들어왔을 때 "이 기기가 언제 누구에게 넘어갔나"가 | ||
| // 1차 단서인데, 소유자 교체는 renew 안에서 조용히 일어나 추적할 흔적이 없다. | ||
| private void logIfOwnerChanged(FcmToken existing, User newOwner) { | ||
| Long previousUserId = existing.getUser().getId(); | ||
| if (!previousUserId.equals(newOwner.getId())) { | ||
| log.info("[FCM] 기기 소유자 변경 — 토큰을 새 사용자로 재할당합니다 (tokenId={}, previousUserId={}, newUserId={})", | ||
| existing.getId(), previousUserId, newOwner.getId()); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| package com.piuda.callcare.global.config.fcm; | ||
|
|
||
| import java.io.IOException; | ||
| import java.io.InputStream; | ||
| import java.nio.file.Files; | ||
| import java.nio.file.Path; | ||
|
|
||
| import org.springframework.beans.factory.annotation.Value; | ||
| import org.springframework.context.annotation.Bean; | ||
| import org.springframework.context.annotation.Configuration; | ||
| import org.springframework.util.StringUtils; | ||
|
|
||
| import com.google.auth.oauth2.GoogleCredentials; | ||
| import com.google.firebase.FirebaseApp; | ||
| import com.google.firebase.FirebaseOptions; | ||
| import com.google.firebase.messaging.FirebaseMessaging; | ||
|
|
||
| import lombok.extern.slf4j.Slf4j; | ||
|
|
||
| /** | ||
| * Firebase Admin SDK 초기화 설정. | ||
| * <p> | ||
| * 서비스 계정 키 경로({@code fcm.service-account-key-path})만 설정값으로 받고, 키 파일 자체는 커밋하지 않는다. | ||
| * 키가 비어 있거나 파일이 없으면 초기화를 건너뛰고 경고만 남긴다 — | ||
| * 즉 <b>키 없이도 앱은 정상 기동</b>되며, 발송 시점에만 "FCM 미설정"으로 처리된다({@link FcmSendService}). | ||
| * <p> | ||
| * 키가 없을 때 이 빈은 {@code null}(NullBean)이 되므로, 소비자는 반드시 | ||
| * {@code ObjectProvider<FirebaseMessaging>}로 접근해야 한다. | ||
| */ | ||
| @Slf4j | ||
| @Configuration | ||
| public class FcmConfig { | ||
|
|
||
| @Value("${fcm.service-account-key-path:}") | ||
| private String serviceAccountKeyPath; | ||
|
|
||
| @Bean | ||
| public FirebaseMessaging firebaseMessaging() { | ||
| if (!StringUtils.hasText(serviceAccountKeyPath)) { | ||
| log.warn("[FCM] fcm.service-account-key-path 가 비어 있어 초기화를 건너뜁니다. 푸시 발송은 비활성화됩니다."); | ||
| return null; | ||
| } | ||
|
|
||
| Path keyPath = Path.of(serviceAccountKeyPath); | ||
| if (!Files.exists(keyPath)) { | ||
| log.warn("[FCM] 서비스 계정 키 파일이 없어 초기화를 건너뜁니다 (path={}). 푸시 발송은 비활성화됩니다.", | ||
| serviceAccountKeyPath); | ||
| return null; | ||
| } | ||
|
|
||
| try (InputStream serviceAccount = Files.newInputStream(keyPath)) { | ||
| FirebaseApp app = FirebaseApp.getApps().isEmpty() | ||
| ? FirebaseApp.initializeApp(FirebaseOptions.builder() | ||
| .setCredentials(GoogleCredentials.fromStream(serviceAccount)) | ||
| .build()) | ||
| : FirebaseApp.getInstance(); | ||
| log.info("[FCM] Firebase 초기화 성공 (path={})", serviceAccountKeyPath); | ||
| return FirebaseMessaging.getInstance(app); | ||
| } catch (IOException e) { | ||
| log.error("[FCM] Firebase 초기화 실패 (path={}): {}", serviceAccountKeyPath, e.getMessage(), e); | ||
| return null; | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| package com.piuda.callcare.global.config.fcm; | ||
|
|
||
| /** | ||
| * FCM 푸시 수신 대상 1명. | ||
| * <p> | ||
| * {@code userId}로 활성 토큰을 조회하고, {@code seniorId}는 발송 이력(Notification)에 필요하다 | ||
| * (Notification의 senior_id가 NOT NULL — 이 앱의 푸시는 항상 "보호자에게 특정 어르신에 대해" 보낸다). | ||
| */ | ||
| public record FcmRecipient(Long userId, Long seniorId) { | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.