diff --git a/src/main/java/naughty/tuzamate/domain/comment/service/FCMService.java b/src/main/java/naughty/tuzamate/domain/comment/service/FCMService.java deleted file mode 100644 index 74808c1..0000000 --- a/src/main/java/naughty/tuzamate/domain/comment/service/FCMService.java +++ /dev/null @@ -1,42 +0,0 @@ -package naughty.tuzamate.domain.comment.service; - -import com.google.firebase.messaging.FirebaseMessaging; -import com.google.firebase.messaging.FirebaseMessagingException; -import com.google.firebase.messaging.Message; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class FCMService { - - private final FirebaseMessaging firebaseMessaging; - - public void sendNotification(String title, String body, String fcmToken) { - if (title == null || body == null || fcmToken == null || fcmToken.trim().isEmpty()) { - log.warn("Invalid notification parameters - title: {}, body: {}, fcmToken: {}", title, body, fcmToken); - return; - } - log.info("Attempting to send Notification (title: {}, body: {}, fcmToken: {})", title, body, fcmToken); - send(createMessage(title, body, fcmToken)); - } - - private void send(Message message) { - try { - String response = firebaseMessaging.send(message); - log.info("Successfully send Notification: {}", response); - } catch (FirebaseMessagingException e) { - log.error("Fail to send Notification : {}", e.getMessage()); - } - } - - private Message createMessage(String title, String body, String fcmToken) { - return Message.builder() - .putData("title", title) - .putData("body", body) - .setToken(fcmToken) - .build(); - } -} diff --git a/src/main/java/naughty/tuzamate/domain/comment/service/command/CommentCommandServiceImpl.java b/src/main/java/naughty/tuzamate/domain/comment/service/command/CommentCommandServiceImpl.java index 77dfba0..75cf232 100644 --- a/src/main/java/naughty/tuzamate/domain/comment/service/command/CommentCommandServiceImpl.java +++ b/src/main/java/naughty/tuzamate/domain/comment/service/command/CommentCommandServiceImpl.java @@ -8,7 +8,6 @@ import naughty.tuzamate.domain.comment.dto.CommentResDTO; import naughty.tuzamate.domain.comment.entity.Comment; import naughty.tuzamate.domain.comment.repository.CommentRepository; -import naughty.tuzamate.domain.comment.service.FCMService; import naughty.tuzamate.domain.notification.entity.Notification; import naughty.tuzamate.domain.notification.service.NotificationService; import naughty.tuzamate.domain.post.code.PostErrorCode; @@ -21,6 +20,10 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + @Service @Transactional @RequiredArgsConstructor @@ -29,7 +32,6 @@ public class CommentCommandServiceImpl implements CommentCommandService { private final UserRepository userRepository; private final CommentRepository commentRepository; private final PostRepository postRepository; - private final FCMService fcmService; private final NotificationService notificationService; @Override @@ -53,42 +55,42 @@ public CommentResDTO.CreateCommentResponseDTO createComment( commentRepository.save(comment); - // 알림 제공 서비스 - User postWriter = post.getUser(); - User parentWriter = parent != null ? parent.getUser() : null; - - String content = comment.getContent(); - String preview = (content != null && content.length() >= 15) ? content.substring(0, 15) + "..." : (content != null ? content : ""); - String title = commentWriter.getNickname() + "님이 댓글을 남겼습니다."; + // 알림 대상 계산 + Set receivers = new LinkedHashSet<>(); - // 게시글 작성자가 아닌 사용자가 댓글을 단 경우 - if (parent == null && !postWriter.getId().equals(commentWriter.getId())) { - fcmService.sendNotification(title, preview, postWriter.getFcmToken()); - - Notification notification = Notification.builder() - .title(title) - .content(preview) - .isRead(false) - .targetId(postId) - .receiver(postWriter) - .build(); + Long postWriterId = post.getUser().getId(); + Long parentWriterId = parent != null ? parent.getUser().getId() : null; + Long writerId = commentWriter.getId(); - notificationService.saveNotification(notification); + if (!postWriterId.equals(writerId)) { + receivers.add(postWriterId); + } + if (parentWriterId != null && !parentWriterId.equals(writerId) && !parentWriterId.equals(postWriterId)) { + receivers.add(parentWriterId); } - // 댓글 작성자에게 대댓글이 달린 경우(부모 댓글 작성자와 대댓글 작성자가 다른 경우) - if (parent != null && !parentWriter.getId().equals(commentWriter.getId())) { - fcmService.sendNotification(title, preview, parentWriter.getFcmToken()); - + // 알림 저장 + (커밋 후) FCM 발송 + for (Long receiverId : receivers) { Notification notification = Notification.builder() - .title(title) - .content(preview) + .receiver(User.builder().id(receiverId).build()) + .title("새 댓글이 달렸습니다") + .content(comment.getContent()) + .targetId(post.getId()) .isRead(false) - .targetId(postId) - .receiver(parentWriter) .build(); - notificationService.saveNotification(notification); + Map data = Map.of( + "type", "COMMENT", + "postId", String.valueOf(post.getId()), + "commentId", String.valueOf(comment.getId()), + "deeplink", "myapp://post/" + post.getId() + "?commentId=" + comment.getId() + ); + + notificationService.saveAndDispatch( + notification, receiverId, + "새 댓글 알림", comment.getContent(), + data, true, "OPEN_POST" // 클릭 액션 키(프론트에 맞추기) + ); } return CommentConverter.toCreateCommentResponseDTO(comment); diff --git a/src/main/java/naughty/tuzamate/domain/notification/entity/Notification.java b/src/main/java/naughty/tuzamate/domain/notification/entity/Notification.java index 6f0a639..0052313 100644 --- a/src/main/java/naughty/tuzamate/domain/notification/entity/Notification.java +++ b/src/main/java/naughty/tuzamate/domain/notification/entity/Notification.java @@ -14,19 +14,23 @@ @NoArgsConstructor @AllArgsConstructor @Builder +@Table(indexes = { + @Index(name="idx_notification_receiver_id_id_desc", columnList = "receiver_id,id") +}) public class Notification extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @NotBlank + @Column(nullable = false) private String title; - @NotBlank + @Column(nullable = false) private String content; - private boolean isRead; + @Builder.Default + private boolean isRead = false;; @Column(name = "target_id") private Long targetId; // 관련 postId @@ -35,8 +39,8 @@ public class Notification extends BaseTimeEntity { @JoinColumn(name = "receiver_id") private User receiver; - public void updateIsRead() { - this.isRead = !isRead; - } + public void markAsRead() { this.isRead = true; } + + public void markAsUnread() { this.isRead = false; } } diff --git a/src/main/java/naughty/tuzamate/domain/notification/service/NotificationService.java b/src/main/java/naughty/tuzamate/domain/notification/service/NotificationService.java index 5cd3570..4484c51 100644 --- a/src/main/java/naughty/tuzamate/domain/notification/service/NotificationService.java +++ b/src/main/java/naughty/tuzamate/domain/notification/service/NotificationService.java @@ -1,29 +1,102 @@ package naughty.tuzamate.domain.notification.service; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import naughty.tuzamate.domain.notification.code.NotificationErrorCode; import naughty.tuzamate.domain.notification.converter.NotificationConverter; import naughty.tuzamate.domain.notification.dto.NotificationResDTO; import naughty.tuzamate.domain.notification.entity.Notification; import naughty.tuzamate.domain.notification.repository.NotificationRepository; -import naughty.tuzamate.global.error.GeneralErrorCode; +import naughty.tuzamate.domain.pushToken.FcmSender; +import naughty.tuzamate.domain.pushToken.repository.PushTokenRepository; import naughty.tuzamate.global.error.exception.CustomException; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.TransactionStatus; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.DefaultTransactionDefinition; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor +@Slf4j public class NotificationService { private final NotificationRepository notificationRepository; + private final PushTokenRepository pushTokenRepository; + private final FcmSender fcmSender; + private final PlatformTransactionManager txManager; + // 알림 저장 + 트랜잭션 커밋 후 FCM 발송 @Transactional - public void saveNotification(Notification notification) { + public void saveAndDispatch(Notification notification, Long receiverId, + String title, String body, Map data, + boolean highPriority, String clickAction) { + + // 알림 저장 notificationRepository.save(notification); + + // 커밋 이후 작업 등록 + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + List tokens = pushTokenRepository.findActiveTokensByUserId(receiverId); + if (tokens.isEmpty()) { + log.info("No active tokens for receiverId={}", receiverId); + return; + } + + try { + FcmSender.BatchResult br = fcmSender.sendToTokens( + tokens, title, body, data, highPriority, clickAction + ); + + // 커밋 이후, 영구 실패 토큰만 비활성화 + if (!br.failedTokens().isEmpty()) { + deactivateTokensRequiresNew(br.failedTokens()); + log.info("Deactivated {} tokens (receiverId={})", + br.failedTokens().size(), receiverId); + } + + log.info("FCM sent: success={}, failure={}, receiverId={}", + br.success(), br.failure(), receiverId); + + } catch (Exception e) { + // 커밋 이후 예외이므로 본 트랜잭션에 영향 없음 + log.warn("FCM send failed afterCommit. receiverId={}", receiverId, e); + } + } + }); + } + + /** + * REQUIRES_NEW 트랜잭션으로 토큰 비활성화(벌크). + * (self-invocation 회피: @Transactional 사용 대신 프로그래매틱 트랜잭션) + */ + private void deactivateTokensRequiresNew(List tokens) { + if (tokens == null || tokens.isEmpty()) return; + + List distinct = tokens.stream().distinct().collect(Collectors.toList()); + + DefaultTransactionDefinition def = new DefaultTransactionDefinition(); + def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + + TransactionStatus status = txManager.getTransaction(def); + try { + pushTokenRepository.bulkDeactivateByTokens(distinct); + txManager.commit(status); + } catch (Exception ex) { + txManager.rollback(status); + log.warn("Failed to deactivate tokens in REQUIRES_NEW tx (size={})", distinct.size(), ex); + } } public NotificationResDTO.NotificationCursorResDTO getNotificationList(Long userId, Long cursor, int size) { @@ -70,7 +143,7 @@ public NotificationResDTO.toNotificationResDTO updateIsRead(Long notificationId, Notification notification = notificationRepository.findByIdAndReceiverId(notificationId, userId) .orElseThrow(() -> new CustomException(NotificationErrorCode.NOTIFICATION_NOT_FOUND)); - notification.updateIsRead(); + notification.markAsRead(); return NotificationConverter.toNotificationDTO(notification); } @@ -82,5 +155,4 @@ public void deleteNotification(Long notificationId, Long userId) { notificationRepository.delete(notification); } - } diff --git a/src/main/java/naughty/tuzamate/domain/post/controller/PostController.java b/src/main/java/naughty/tuzamate/domain/post/controller/PostController.java index 7e25517..6222952 100644 --- a/src/main/java/naughty/tuzamate/domain/post/controller/PostController.java +++ b/src/main/java/naughty/tuzamate/domain/post/controller/PostController.java @@ -37,7 +37,7 @@ public CustomResponse createPost( return CustomResponse.onSuccess(PostSuccessCode.POST_CREATED, resDTO); } - @GetMapping("/boards/{boardType}/posts/{postId}") + @GetMapping("/boards/posts/{postId}") @Operation(summary = "단일 게시글 조회", description = "단일 게시글을 조회합니다.") public CustomResponse getPost( @PathVariable Long postId, @@ -59,7 +59,7 @@ public CustomResponse getPostList( return CustomResponse.onSuccess(PostSuccessCode.POST_OK, resDTO); } - @PatchMapping("/boards/{boardType}/posts/{postId}") + @PatchMapping("/boards/posts/{postId}") @Operation(summary = "게시글 수정", description = "게시글을 수정합니다.") public CustomResponse updatePost( @PathVariable Long postId, @@ -71,7 +71,7 @@ public CustomResponse updatePost( return CustomResponse.onSuccess(GeneralSuccessCode.OK, resDTO); } - @DeleteMapping("/boards/{boardType}/posts/{postId}") + @DeleteMapping("/boards/posts/{postId}") @Operation(summary = "게시글 삭제", description = "게시글을 삭제합니다.") public CustomResponse deletePost( @PathVariable Long postId, diff --git a/src/main/java/naughty/tuzamate/domain/pushToken/FcmSender.java b/src/main/java/naughty/tuzamate/domain/pushToken/FcmSender.java new file mode 100644 index 0000000..e9f99ff --- /dev/null +++ b/src/main/java/naughty/tuzamate/domain/pushToken/FcmSender.java @@ -0,0 +1,131 @@ +package naughty.tuzamate.domain.pushToken; + +import com.google.firebase.messaging.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Component +@RequiredArgsConstructor +@Slf4j +public class FcmSender { + + // 단일 토큰 전송: 전송/로깅만, DB 변경은 하지 않음 + public String sendToToken( + String token, String title, String body, Map data, + boolean highPriority, String clickAction + ) throws Exception { + + Message.Builder mb = Message.builder().setToken(token); + applyCommonNotification(mb, title, body); + applyCommonData(mb, data); + mb.setAndroidConfig(buildAndroidConfig(highPriority, clickAction)); + + try { + return FirebaseMessaging.getInstance().send(mb.build()); + } catch (FirebaseMessagingException e) { + log.warn("FCM single send failed token={}, code={}", token, e.getMessagingErrorCode(), e); + throw e; + } + } + + // 다중 토큰 전송 + public BatchResult sendToTokens( + List tokens, String title, String body, Map data, + boolean highPriority, String clickAction + ) throws Exception { + + if (tokens == null || tokens.isEmpty()) { + return new BatchResult(0, 0, List.of()); + } + + int totalSuccess = 0; + int totalFailure = 0; + + List permanentFailed = new ArrayList<>(); + + for (int start = 0; start < tokens.size(); start += 500) { + List chunk = tokens.subList(start, Math.min(start + 500, tokens.size())); + + MulticastMessage.Builder mb = MulticastMessage.builder().addAllTokens(chunk); + applyCommonNotification(mb, title, body); + applyCommonData(mb, data); + mb.setAndroidConfig(buildAndroidConfig(highPriority, clickAction)); + + BatchResponse response = FirebaseMessaging.getInstance().sendEachForMulticast(mb.build()); + + totalSuccess += response.getSuccessCount(); + totalFailure += response.getFailureCount(); + + List rs = response.getResponses(); + + for (int i = 0; i < rs.size(); i++) { + SendResponse r = rs.get(i); + + if (!r.isSuccessful()) { + String t = chunk.get(i); + Exception ex = r.getException(); + + if (ex instanceof FirebaseMessagingException fme) { + MessagingErrorCode code = fme.getMessagingErrorCode(); + log.warn("FCM multicast failed token={}, code={}", t, code, fme); + + // 영구 실패만 수집 (일시 실패는 수집하지 않음) + if (isPermanentFailure(fme)) { + permanentFailed.add(t); + } + } else if (ex != null) { + log.warn("FCM multicast failed token={} (non-Firebase exception)", t, ex); + } + } + } + } + + return new BatchResult(totalSuccess, totalFailure, permanentFailed); + } + + // 영구 실패(비활성화 대상) 판정 + private boolean isPermanentFailure(FirebaseMessagingException e) { + MessagingErrorCode c = e.getMessagingErrorCode(); + + return c == MessagingErrorCode.UNREGISTERED + || c == MessagingErrorCode.INVALID_ARGUMENT + || c == MessagingErrorCode.SENDER_ID_MISMATCH + || c == MessagingErrorCode.THIRD_PARTY_AUTH_ERROR; + } + + // ===== 공통 빌더 유틸 ===== + private void applyCommonNotification(Message.Builder b, String title, String body) { + if (title != null || body != null) { + b.setNotification(Notification.builder().setTitle(title).setBody(body).build()); + } + } + private void applyCommonNotification(MulticastMessage.Builder b, String title, String body) { + if (title != null || body != null) { + b.setNotification(Notification.builder().setTitle(title).setBody(body).build()); + } + } + + private void applyCommonData(Message.Builder b, Map data) { + if (data != null && !data.isEmpty()) b.putAllData(data); + } + private void applyCommonData(MulticastMessage.Builder b, Map data) { + if (data != null && !data.isEmpty()) b.putAllData(data); + } + + private AndroidConfig buildAndroidConfig(boolean highPriority, String clickAction) { + AndroidConfig.Builder ab = AndroidConfig.builder(); + if (highPriority) ab.setPriority(AndroidConfig.Priority.HIGH); + if (clickAction != null && !clickAction.isBlank()) { + ab.setNotification(AndroidNotification.builder().setClickAction(clickAction).build()); + } + return ab.build(); + } + + // 결과 DTO + public record BatchResult(int success, int failure, List failedTokens) {} +} + diff --git a/src/main/java/naughty/tuzamate/domain/pushToken/code/PushTokenErrorCode.java b/src/main/java/naughty/tuzamate/domain/pushToken/code/PushTokenErrorCode.java new file mode 100644 index 0000000..ee4e050 --- /dev/null +++ b/src/main/java/naughty/tuzamate/domain/pushToken/code/PushTokenErrorCode.java @@ -0,0 +1,17 @@ +package naughty.tuzamate.domain.pushToken.code; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import naughty.tuzamate.global.error.BaseErrorCode; +import org.springframework.http.HttpStatus; + +@AllArgsConstructor +@Getter +public enum PushTokenErrorCode implements BaseErrorCode { + TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "TOKEN40401", "해당 토큰이 존재하지 않습니다."), + TOKEN_NOT_OWNER(HttpStatus.BAD_REQUEST, "TOKEN402", "토큰 소유자가 아닙니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/naughty/tuzamate/domain/pushToken/controller/PushMessageController.java b/src/main/java/naughty/tuzamate/domain/pushToken/controller/PushMessageController.java new file mode 100644 index 0000000..8f219f2 --- /dev/null +++ b/src/main/java/naughty/tuzamate/domain/pushToken/controller/PushMessageController.java @@ -0,0 +1,48 @@ +package naughty.tuzamate.domain.pushToken.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import naughty.tuzamate.domain.pushToken.FcmSender; +import naughty.tuzamate.domain.pushToken.dto.PushTokenSendDTO; +import naughty.tuzamate.global.apiPayload.CustomResponse; +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.RestController; + +// test 용 +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/push") +// @PreAuthorize("hasRole('ADMIN')") // 운영/관리자 전용 권장 +public class PushMessageController { + + private final FcmSender fcmSender; + + @PostMapping("/token") + public CustomResponse sendToToken(@Valid @RequestBody PushTokenSendDTO.SendToTokenRequest req) throws Exception { + String messageId = fcmSender.sendToToken( + req.token(), + req.title(), + req.body(), + req.data(), + Boolean.TRUE.equals(req.highPriority()), + req.clickAction() + ); + return CustomResponse.onSuccess(messageId); + } + + @PostMapping("/tokens") + public CustomResponse sendToTokens(@Valid @RequestBody PushTokenSendDTO.SendToTokensRequest req) throws Exception { + FcmSender.BatchResult result = fcmSender.sendToTokens( + req.tokens(), + req.title(), + req.body(), + req.data(), + Boolean.TRUE.equals(req.highPriority()), + req.clickAction() + ); + return CustomResponse.onSuccess(result); + } +} + diff --git a/src/main/java/naughty/tuzamate/domain/pushToken/controller/PushTokenController.java b/src/main/java/naughty/tuzamate/domain/pushToken/controller/PushTokenController.java new file mode 100644 index 0000000..b32e7ae --- /dev/null +++ b/src/main/java/naughty/tuzamate/domain/pushToken/controller/PushTokenController.java @@ -0,0 +1,49 @@ +package naughty.tuzamate.domain.pushToken.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import naughty.tuzamate.auth.principal.PrincipalDetails; +import naughty.tuzamate.domain.pushToken.dto.PushTokenReqDTO; +import naughty.tuzamate.domain.pushToken.entity.PushToken; +import naughty.tuzamate.domain.pushToken.service.PushTokenService; +import naughty.tuzamate.global.apiPayload.CustomResponse; +import naughty.tuzamate.global.success.GeneralSuccessCode; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/push-tokens") +public class PushTokenController { + + private final PushTokenService pushTokenService; + + @PostMapping + public CustomResponse register( + @Valid @RequestBody PushTokenReqDTO.RegisterPushTokenReqDTO request, + @AuthenticationPrincipal PrincipalDetails principal + ) { + PushToken token = pushTokenService.upsert(request, principal.getId()); + + return CustomResponse.onSuccess(GeneralSuccessCode.OK, token); + } + + // 토큰 해제(로그아웃 -> deactivate = false, 탈퇴 -> deactivate = false) + @DeleteMapping + public CustomResponse unbind( + @Valid @RequestBody PushTokenReqDTO.UnbindPushTokenReqDTO request + ) { + pushTokenService.unbind(request.token(), request.deactivate()); + + return CustomResponse.onSuccess(GeneralSuccessCode.OK); + } + + @PostMapping("/heartbeat") + public CustomResponse heartbeat( + @Valid @RequestBody PushTokenReqDTO.RegisterPushTokenReqDTO req, + @AuthenticationPrincipal PrincipalDetails principal + ) { + pushTokenService.touch(req.token(), principal.getId()); + return CustomResponse.onSuccess(GeneralSuccessCode.OK); + } +} diff --git a/src/main/java/naughty/tuzamate/domain/pushToken/dto/PushTokenReqDTO.java b/src/main/java/naughty/tuzamate/domain/pushToken/dto/PushTokenReqDTO.java new file mode 100644 index 0000000..a2c7682 --- /dev/null +++ b/src/main/java/naughty/tuzamate/domain/pushToken/dto/PushTokenReqDTO.java @@ -0,0 +1,32 @@ +package naughty.tuzamate.domain.pushToken.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Data; +import naughty.tuzamate.domain.pushToken.enums.Platform; + +@Data +public class PushTokenReqDTO { + + @Builder + public record RegisterPushTokenReqDTO( + @NotBlank + String token, + + @NotNull + Platform platform, + + @NotBlank + String deviceId + ) {} + + @Builder + public record UnbindPushTokenReqDTO( + @NotBlank + String token, + + // true -> 계정 탈퇴 및 알림 기능 해제 / false -> 로그아웃 같은 경우 + boolean deactivate + ) {} +} diff --git a/src/main/java/naughty/tuzamate/domain/pushToken/dto/PushTokenSendDTO.java b/src/main/java/naughty/tuzamate/domain/pushToken/dto/PushTokenSendDTO.java new file mode 100644 index 0000000..d569434 --- /dev/null +++ b/src/main/java/naughty/tuzamate/domain/pushToken/dto/PushTokenSendDTO.java @@ -0,0 +1,26 @@ +package naughty.tuzamate.domain.pushToken.dto; + +import java.util.List; +import java.util.Map; + +public class PushTokenSendDTO { + + public record SendToTokenRequest ( + String token, + String title, + String body, + Map data, + Boolean highPriority, // null 허용 → 기본 false + String clickAction + ) {} + + public record SendToTokensRequest ( + List tokens, + String title, + String body, + Map data, + Boolean highPriority, + String clickAction + ) {} + +} \ No newline at end of file diff --git a/src/main/java/naughty/tuzamate/domain/pushToken/entity/PushToken.java b/src/main/java/naughty/tuzamate/domain/pushToken/entity/PushToken.java new file mode 100644 index 0000000..9af7d17 --- /dev/null +++ b/src/main/java/naughty/tuzamate/domain/pushToken/entity/PushToken.java @@ -0,0 +1,54 @@ +package naughty.tuzamate.domain.pushToken.entity; + +import jakarta.persistence.*; +import lombok.*; +import naughty.tuzamate.domain.pushToken.enums.Platform; +import naughty.tuzamate.global.BaseTimeEntity; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "push_token", + uniqueConstraints = @UniqueConstraint(name = "uk_push_token_token", columnNames = "token"), + indexes = { + // 한 유저가 여러 기기 사용하는 경우 빠르게 조회 가능 + @Index(name = "idx_push_token_user_id", columnList = "userId"), + @Index(name = "idx_push_token_is_active", columnList = "isActive") + }) +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PushToken extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = true) + private Long userId; + + @Column(nullable = false, length = 2048) + private String token; + + @Enumerated(EnumType.STRING) + private Platform platform; + + @Column(length = 256) + private String deviceId; + + // logout 시 false -> 재 로그인 시 true 재 변경 + @Builder.Default + @Column(nullable = false) + private Boolean isActive = false; + + // 앱을 실행할 때마다 업데이트가 되게 하기(프론트에서 해야 할 듯) + private LocalDateTime lastSeenAt; + + @PrePersist + void prePersist() { + if (lastSeenAt == null) lastSeenAt = LocalDateTime.now(); + } + +} diff --git a/src/main/java/naughty/tuzamate/domain/pushToken/enums/Platform.java b/src/main/java/naughty/tuzamate/domain/pushToken/enums/Platform.java new file mode 100644 index 0000000..b47a781 --- /dev/null +++ b/src/main/java/naughty/tuzamate/domain/pushToken/enums/Platform.java @@ -0,0 +1,3 @@ +package naughty.tuzamate.domain.pushToken.enums; + +public enum Platform { ANDROID, IOS, WEB } diff --git a/src/main/java/naughty/tuzamate/domain/pushToken/repository/PushTokenRepository.java b/src/main/java/naughty/tuzamate/domain/pushToken/repository/PushTokenRepository.java new file mode 100644 index 0000000..2d55e11 --- /dev/null +++ b/src/main/java/naughty/tuzamate/domain/pushToken/repository/PushTokenRepository.java @@ -0,0 +1,22 @@ +package naughty.tuzamate.domain.pushToken.repository; + +import naughty.tuzamate.domain.pushToken.entity.PushToken; +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; + +import java.util.List; +import java.util.Optional; + +public interface PushTokenRepository extends JpaRepository { + Optional findByToken(String token); + List findAllByUserIdAndIsActiveTrue(Long userId); + + @Query("select pt.token from PushToken pt where pt.userId = :userId and pt.isActive = true") + List findActiveTokensByUserId(@Param("userId") Long userId); + + @Modifying(clearAutomatically = true) + @Query("update PushToken pt set pt.isActive=false where pt.token in :tokens") + void bulkDeactivateByTokens(@Param("tokens") List tokens); +} diff --git a/src/main/java/naughty/tuzamate/domain/pushToken/service/PushTokenService.java b/src/main/java/naughty/tuzamate/domain/pushToken/service/PushTokenService.java new file mode 100644 index 0000000..e11da50 --- /dev/null +++ b/src/main/java/naughty/tuzamate/domain/pushToken/service/PushTokenService.java @@ -0,0 +1,72 @@ +package naughty.tuzamate.domain.pushToken.service; + +import lombok.RequiredArgsConstructor; +import naughty.tuzamate.domain.pushToken.code.PushTokenErrorCode; +import naughty.tuzamate.domain.pushToken.dto.PushTokenReqDTO; +import naughty.tuzamate.domain.pushToken.entity.PushToken; +import naughty.tuzamate.domain.pushToken.repository.PushTokenRepository; +import naughty.tuzamate.global.error.exception.CustomException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +public class PushTokenService { + private final PushTokenRepository pushTokenRepository; + + // token 이 존재하는 경우 -> update, token 이 존재하지 않는 경우 insert + @Transactional + public PushToken upsert(PushTokenReqDTO.RegisterPushTokenReqDTO request, Long userId) { + PushToken entity = pushTokenRepository.findByToken(request.token()) + // 이미 존재하는 경우 -> update + .map(exist -> { + exist.setUserId(userId); + exist.setPlatform(request.platform()); + exist.setDeviceId(request.deviceId()); + exist.setIsActive(true); + exist.setLastSeenAt(LocalDateTime.now()); + return exist; + }) + // 없는 경우 -> insert + .orElseGet(() -> PushToken.builder() + .userId(userId) + .token(request.token()) + .platform(request.platform()) + .deviceId(request.deviceId()) + .isActive(true) + .lastSeenAt(LocalDateTime.now()) + .build() + ); + + return pushTokenRepository.save(entity); + } + + // FCM 토큰을 서버에서 해제 + @Transactional + public void unbind(String token, boolean deactivate) { + PushToken pt = pushTokenRepository.findByToken(token) + .orElseThrow(() -> new CustomException(PushTokenErrorCode.TOKEN_NOT_FOUND)); + + pt.setUserId(null); + + if (deactivate) pt.setIsActive(false); + + pt.setLastSeenAt(LocalDateTime.now()); + } + + @Transactional + public void touch(String token, Long userId) { + PushToken pt = pushTokenRepository.findByToken(token) + .orElseThrow(() -> new CustomException(PushTokenErrorCode.TOKEN_NOT_FOUND)); + + if (pt.getUserId() == null || !pt.getUserId().equals(userId)) { + throw new CustomException(PushTokenErrorCode.TOKEN_NOT_OWNER); + } + + // 앱이 살아있음을 표시 + pt.setIsActive(true); + pt.setLastSeenAt(LocalDateTime.now()); + } +} diff --git a/src/main/java/naughty/tuzamate/domain/user/controller/UserController.java b/src/main/java/naughty/tuzamate/domain/user/controller/UserController.java index cafa200..6d856f2 100644 --- a/src/main/java/naughty/tuzamate/domain/user/controller/UserController.java +++ b/src/main/java/naughty/tuzamate/domain/user/controller/UserController.java @@ -48,14 +48,4 @@ public CustomResponse singUp(@RequestBody UserRequestDTO.UserSignUpDTO signUp UserResponseDTO.UserTokenDTO signUpResult = userService.signUp(signUpDTO); return CustomResponse.onSuccess(signUpResult); } - - @PostMapping("/users/fcm-token") - @Operation(summary = "fcm 토큰 발급받아 저장하는 api", - description = "프론트에서 로그인 및 회원가입시 FCM 토큰을 발행하여 이 api로 토큰을 전송하면 DB에 저장 및 업데이트") - public CustomResponse saveFcmToken(@AuthenticationPrincipal PrincipalDetails principal, - @RequestBody FcmRequestDTO token) { - userService.updateFcmToken(principal.getUser().getId(), token.getToken()); - - return CustomResponse.onSuccess(GeneralSuccessCode.OK); - } } diff --git a/src/main/java/naughty/tuzamate/domain/user/entity/User.java b/src/main/java/naughty/tuzamate/domain/user/entity/User.java index 9d0a705..7be3203 100644 --- a/src/main/java/naughty/tuzamate/domain/user/entity/User.java +++ b/src/main/java/naughty/tuzamate/domain/user/entity/User.java @@ -63,8 +63,6 @@ public class User extends BaseTimeEntity { private Long credit; // 크레딧 - @Column(name = "fcm_token") // 각 사용자마다 fcm 토큰을 발급(로그인을 시도할 때 마다 토큰 갱신) - private String fcmToken; /** * 회원의 마지막 로그아웃 이후 발급되는 토큰을 판별하기 위한 사용 * 처음엔 0, 이후 로그아웃 시 + 1 @@ -87,10 +85,6 @@ public void initProfile(String nickname, String experience) { this.experience = experience; } - public void updateFcmToken(String fcmToken) { - this.fcmToken = fcmToken; - } - public void withdraw() { this.isDeleted = true; this.nickname = null; diff --git a/src/main/java/naughty/tuzamate/domain/user/service/UserService.java b/src/main/java/naughty/tuzamate/domain/user/service/UserService.java index 7d9bb90..a1cdfd8 100644 --- a/src/main/java/naughty/tuzamate/domain/user/service/UserService.java +++ b/src/main/java/naughty/tuzamate/domain/user/service/UserService.java @@ -9,5 +9,4 @@ public interface UserService { UserResponseDTO.UserTokenDTO signUp(UserRequestDTO.UserSignUpDTO signUpDTO); - void updateFcmToken(Long userId, String token); } diff --git a/src/main/java/naughty/tuzamate/domain/user/service/UserServiceImpl.java b/src/main/java/naughty/tuzamate/domain/user/service/UserServiceImpl.java index 4ac0000..238e56a 100644 --- a/src/main/java/naughty/tuzamate/domain/user/service/UserServiceImpl.java +++ b/src/main/java/naughty/tuzamate/domain/user/service/UserServiceImpl.java @@ -74,12 +74,4 @@ public UserResponseDTO.UserTokenDTO signUp(UserRequestDTO.UserSignUpDTO signUpDT .refreshToken(jwtProvider.createRefreshToken(newUser)) .build(); } - - @Override - public void updateFcmToken(Long userId, String token) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new CustomException(GeneralErrorCode.NOT_FOUND_404)); - - user.updateFcmToken(token); - } }