Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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<Long> 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<String, String> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public CustomResponse<PostResDTO.CreatePostResponseDTO> createPost(
return CustomResponse.onSuccess(PostSuccessCode.POST_CREATED, resDTO);
}

@GetMapping("/boards/{boardType}/posts/{postId}")
@GetMapping("/boards/posts/{postId}")
@Operation(summary = "단일 게시글 조회", description = "단일 게시글을 조회합니다.")
public CustomResponse<PostResDTO.PostDTO> getPost(
@PathVariable Long postId,
Expand All @@ -59,7 +59,7 @@ public CustomResponse<PostResDTO.PostPreviewListDTO> getPostList(
return CustomResponse.onSuccess(PostSuccessCode.POST_OK, resDTO);
}

@PatchMapping("/boards/{boardType}/posts/{postId}")
@PatchMapping("/boards/posts/{postId}")
@Operation(summary = "게시글 수정", description = "게시글을 수정합니다.")
public CustomResponse<PostResDTO.UpdatePostResponseDTO> updatePost(
@PathVariable Long postId,
Expand All @@ -71,7 +71,7 @@ public CustomResponse<PostResDTO.UpdatePostResponseDTO> updatePost(
return CustomResponse.onSuccess(GeneralSuccessCode.OK, resDTO);
}

@DeleteMapping("/boards/{boardType}/posts/{postId}")
@DeleteMapping("/boards/posts/{postId}")
@Operation(summary = "게시글 삭제", description = "게시글을 삭제합니다.")
public CustomResponse<PostResDTO.DeletePostResponseDTO> deletePost(
@PathVariable Long postId,
Expand Down
131 changes: 131 additions & 0 deletions src/main/java/naughty/tuzamate/domain/pushToken/FcmSender.java
Original file line number Diff line number Diff line change
@@ -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<String, String> data,
boolean highPriority, String clickAction
) throws Exception {

Comment on lines +20 to +21
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Narrow throws to FirebaseMessagingException.

Avoid throws Exception in public API.

-    ) throws Exception {
+    ) throws FirebaseMessagingException {
@@
-    ) throws Exception {
+    ) throws FirebaseMessagingException {

Also applies to: 39-40

🤖 Prompt for AI Agents
In src/main/java/naughty/tuzamate/domain/pushToken/FcmSender.java around lines
20-21 and 39-40, the public methods currently declare "throws Exception"; narrow
these to the specific FirebaseMessagingException: change the method signatures
to "throws FirebaseMessagingException", add the appropriate import, and remove
the generic Exception from the throws clause. Update any internal try/catch to
handle other checked exceptions locally (wrap or convert to runtime) or
translate them into FirebaseMessagingException as appropriate, and adjust
callers/tests to handle the narrower checked 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;
}
}
Comment on lines +27 to +33
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Mask tokens in logs (PII/sensitive).

Avoid logging raw FCM tokens.

Apply:

-            log.warn("FCM single send failed token={}, code={}", token, e.getMessagingErrorCode(), e);
+            log.warn("FCM single send failed token={}, code={}", maskToken(token), e.getMessagingErrorCode(), e);
@@
-                        log.warn("FCM multicast failed token={}, code={}", t, code, fme);
+                        log.warn("FCM multicast failed token={}, code={}", maskToken(t), code, fme);
@@
-                        log.warn("FCM multicast failed token={} (non-Firebase exception)", t, ex);
+                        log.warn("FCM multicast failed token={} (non-Firebase exception)", maskToken(t), ex);

Add helper inside the class:

private String maskToken(String t) {
    if (t == null) return "null";
    int n = t.length();
    if (n <= 10) return "****" + t;
    return t.substring(0, 4) + "..." + t.substring(n - 4);
}

Also applies to: 72-82

🤖 Prompt for AI Agents
In src/main/java/naughty/tuzamate/domain/pushToken/FcmSender.java around lines
27-33 (and also apply same change to lines 72-82), the code logs raw FCM tokens
which is sensitive; add a private helper method maskToken(String t) that returns
"null" for null, "****"+t for length <=10, otherwise first 4 chars + "..." +
last 4 chars, then replace occurrences of token in log calls with
maskToken(token) (and any other token variables in those log lines) so logs do
not contain the full token.


// 다중 토큰 전송
public BatchResult sendToTokens(
List<String> tokens, String title, String body, Map<String, String> 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<String> permanentFailed = new ArrayList<>();

for (int start = 0; start < tokens.size(); start += 500) {
List<String> chunk = tokens.subList(start, Math.min(start + 500, tokens.size()));

Comment on lines +41 to +52
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Filter null/blank tokens before chunking to avoid runtime errors.

addAllTokens with null/blank entries can fail.

-        if (tokens == null || tokens.isEmpty()) {
+        if (tokens == null || tokens.isEmpty()) {
             return new BatchResult(0, 0, List.of());
         }
 
+        // filter invalid entries; keep order and duplicates if desired
+        tokens = tokens.stream()
+                .filter(t -> t != null && !t.isBlank())
+                .toList();
+        if (tokens.isEmpty()) {
+            return new BatchResult(0, 0, List.of());
+        }
+
         int totalSuccess = 0;
         int totalFailure = 0;
 
         List<String> permanentFailed = new ArrayList<>();
 
-        for (int start = 0; start < tokens.size(); start += 500) {
+        for (int start = 0; start < tokens.size(); start += 500) {
             List<String> chunk = tokens.subList(start, Math.min(start + 500, tokens.size()));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (tokens == null || tokens.isEmpty()) {
return new BatchResult(0, 0, List.of());
}
int totalSuccess = 0;
int totalFailure = 0;
List<String> permanentFailed = new ArrayList<>();
for (int start = 0; start < tokens.size(); start += 500) {
List<String> chunk = tokens.subList(start, Math.min(start + 500, tokens.size()));
if (tokens == null || tokens.isEmpty()) {
return new BatchResult(0, 0, List.of());
}
// filter invalid entries; keep order and duplicates if desired
tokens = tokens.stream()
.filter(t -> t != null && !t.isBlank())
.toList();
if (tokens.isEmpty()) {
return new BatchResult(0, 0, List.of());
}
int totalSuccess = 0;
int totalFailure = 0;
List<String> permanentFailed = new ArrayList<>();
for (int start = 0; start < tokens.size(); start += 500) {
List<String> chunk = tokens.subList(start, Math.min(start + 500, tokens.size()));
🤖 Prompt for AI Agents
In src/main/java/naughty/tuzamate/domain/pushToken/FcmSender.java around lines
41 to 52, filter the incoming tokens list to remove nulls and
blank/whitespace-only strings before doing the 500-size chunking to prevent
runtime errors; create a new filtered list (e.g., trim and exclude null/empty
values), replace references to the original tokens with the filtered list, and
update the early-return to check the filtered list is empty so the chunk loop
never processes invalid entries.

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<SendResponse> 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<String, String> data) {
if (data != null && !data.isEmpty()) b.putAllData(data);
}
private void applyCommonData(MulticastMessage.Builder b, Map<String, String> 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<String> failedTokens) {}
}

Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +11 to +16
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

HTTP status/code mismatch for NOT_OWNER.

Use 403 (Forbidden) and align the app code string with your 404 scheme.

-    TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "TOKEN40401", "해당 토큰이 존재하지 않습니다."),
-    TOKEN_NOT_OWNER(HttpStatus.BAD_REQUEST, "TOKEN402", "토큰 소유자가 아닙니다.");
+    TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "TOKEN40401", "해당 토큰이 존재하지 않습니다."),
+    TOKEN_NOT_OWNER(HttpStatus.FORBIDDEN, "TOKEN40301", "토큰 소유자가 아닙니다.");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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;
TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "TOKEN40401", "해당 토큰이 존재하지 않습니다."),
TOKEN_NOT_OWNER(HttpStatus.FORBIDDEN, "TOKEN40301", "토큰 소유자가 아닙니다.");
private final HttpStatus status;
private final String code;
private final String message;
🤖 Prompt for AI Agents
In src/main/java/naughty/tuzamate/domain/pushToken/code/PushTokenErrorCode.java
around lines 11 to 16, the TOKEN_NOT_OWNER enum entry uses
HttpStatus.BAD_REQUEST and an inconsistent code string; change the HttpStatus to
HttpStatus.FORBIDDEN and update the application code string to follow the
existing scheme (e.g. "TOKEN40301") so it matches the 403 status, leaving the
message text intact.

}
Original file line number Diff line number Diff line change
@@ -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 {
Comment on lines +13 to +18
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Lock down “test” push endpoints.

These endpoints can spam arbitrary tokens if exposed. Gate them.

+import org.springframework.security.access.prepost.PreAuthorize;
@@
-// test 용
 @RestController
 @RequiredArgsConstructor
 @RequestMapping("/api/push")
-// @PreAuthorize("hasRole('ADMIN')") // 운영/관리자 전용 권장
+@PreAuthorize("hasRole('ADMIN')")
 public class PushMessageController {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// test 용
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/push")
// @PreAuthorize("hasRole('ADMIN')") // 운영/관리자 전용 권장
public class PushMessageController {
import org.springframework.security.access.prepost.PreAuthorize;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/push")
@PreAuthorize("hasRole('ADMIN')")
public class PushMessageController {
🤖 Prompt for AI Agents
In
src/main/java/naughty/tuzamate/domain/pushToken/controller/PushMessageController.java
around lines 13 to 18, the controller is left open for "test" use and can send
push messages to arbitrary tokens; re-lock it by restoring a security annotation
and ensuring method security is enabled: add @PreAuthorize("hasRole('ADMIN')")
(or equivalent role check) to the controller or each endpoint, ensure your
application has method security enabled (e.g., @EnableMethodSecurity or
@EnableGlobalMethodSecurity in your security config), and remove or restrict any
test-only endpoints/mappings (or move them behind an internal-only path/profile)
so only authorized admin users can call these APIs.


private final FcmSender fcmSender;

@PostMapping("/token")
public CustomResponse<String> 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<FcmSender.BatchResult> 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);
}
}

Loading
Loading