Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,11 @@ public CustomResponse<PostResDTO.CreatePostResponseDTO> createPost(

@GetMapping("/boards/{boardType}/posts/{postId}")
@Operation(summary = "단일 게시글 조회", description = "단일 게시글을 조회합니다.")
public CustomResponse<PostResDTO.PostPreviewDTO> getPost(@PathVariable Long postId) {
PostResDTO.PostPreviewDTO resDTO = postQueryService.getPost(postId);
public CustomResponse<PostResDTO.PostDTO> getPost(
@PathVariable Long postId,
@AuthenticationPrincipal PrincipalDetails principalDetails
) {
PostResDTO.PostDTO resDTO = postQueryService.getPost(postId, principalDetails);

return CustomResponse.onSuccess(GeneralSuccessCode.OK, resDTO);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,23 @@ public static PostResDTO.CreatePostResponseDTO toCreatePostResponseDTO(Post post
.build();
}

// Post Entity -> PostDTO
public static PostResDTO.PostDTO toPostDTO(Post post, boolean liked, boolean scraped) {
return PostResDTO.PostDTO.builder()
.id(post.getId())
.title(post.getTitle())
.content(post.getContent())
.likeNum(post.getLikeNum())
.author(post.getUser().getNickname())
.commentNum((long) post.getComments().size()) // 댓글 수
.isRead(post.isRead())
.liked(liked)
.scraped(scraped)
.createdAt(post.getCreatedAt())
.updatedAt(post.getUpdatedAt())
.build();
}

// Post Entity -> PostPreviewDTO
public static PostResDTO.PostPreviewDTO toPostPreviewDTO(Post post) {
return PostResDTO.PostPreviewDTO.builder()
Expand Down
17 changes: 17 additions & 0 deletions src/main/java/naughty/tuzamate/domain/post/dto/PostResDTO.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,23 @@ public record CreatePostResponseDTO(
){
}

@Builder
public record PostDTO(
Long id,
String title,
String content,
Long likeNum,
// 작성자 닉네임, 댓글 수 필드 추가
String author,
Long commentNum,
boolean isRead,
boolean liked,
boolean scraped,
LocalDateTime createdAt,
LocalDateTime updatedAt
){
}

@Builder
public record PostPreviewDTO(
Long id,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
package naughty.tuzamate.domain.post.service.query;

import naughty.tuzamate.auth.principal.PrincipalDetails;
import naughty.tuzamate.domain.post.dto.PostResDTO;
import naughty.tuzamate.domain.post.enums.BoardType;

public interface PostQueryService {

PostResDTO.PostPreviewDTO getPost(Long postId);
PostResDTO.PostDTO getPost(Long postId, PrincipalDetails principalDetails);
PostResDTO.PostPreviewListDTO getPostList(BoardType boardType, Long cursor, int size);
Comment on lines +3 to 10
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Decouple query interface from security type and plan for null principals

Binding the query interface to PrincipalDetails tightens coupling and makes anonymous access error-prone. Prefer passing a nullable userId (or Optional) so the query layer doesn’t depend on auth internals. Also ensure null is handled gracefully for public reads.

Apply this minimal refactor at the interface level:

-import naughty.tuzamate.auth.principal.PrincipalDetails;
@@
-    PostResDTO.PostDTO getPost(Long postId, PrincipalDetails principalDetails);
+    PostResDTO.PostDTO getPost(Long postId, Long userId);

To verify call sites were all updated after the signature change, run:


🏁 Script executed:

#!/bin/bash
# Find all `getPost(` usages and display surrounding context.
rg -nP -C3 --type=java '\bgetPost\s*\('

Length of output: 8156


Decouple PostQueryService from PrincipalDetails

Bind the query layer only to a simple, nullable userId rather than the auth principal. This avoids leaking security internals into your service API and makes anonymous reads explicit.

Please update the following:

PostQueryService.java
– Remove the PrincipalDetails import
– Change

PostResDTO.PostDTO getPost(Long postId, PrincipalDetails principalDetails);

to

PostResDTO.PostDTO getPost(Long postId, Long userId);

PostQueryServiceImpl.java
– Update the method signature to getPost(Long postId, Long userId)
– Replace all uses of principalDetails with userId (e.g. principalDetails.getMember().getId())
– Ensure you handle userId == null gracefully for public reads

PostController.java
– In the getPost handler, pass the ID (or null) instead of the principal:

Long userId = principalDetails != null
  ? principalDetails.getMember().getId()
  : null;
PostResDTO.PostDTO resDTO = postQueryService.getPost(postId, userId);
🤖 Prompt for AI Agents
In
src/main/java/naughty/tuzamate/domain/post/service/query/PostQueryService.java
lines 3-10, remove the PrincipalDetails import and change the getPost signature
from getPost(Long postId, PrincipalDetails principalDetails) to getPost(Long
postId, Long userId); update the interface accordingly. Then update
PostQueryServiceImpl.java to use getPost(Long postId, Long userId) and replace
all principalDetails usages with the userId value (e.g., replace
principalDetails.getMember().getId() with userId), ensuring code handles userId
== null for anonymous/public reads. Finally, update PostController.java getPost
handler to pass a Long userId (or null) extracted from PrincipalDetails
(principalDetails != null ? principalDetails.getMember().getId() : null) instead
of passing the principal object.

}
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
package naughty.tuzamate.domain.post.service.query;

import lombok.RequiredArgsConstructor;
import naughty.tuzamate.auth.principal.PrincipalDetails;
import naughty.tuzamate.domain.post.converter.PostConverter;
import naughty.tuzamate.domain.post.dto.PostResDTO;
import naughty.tuzamate.domain.post.entity.Post;
import naughty.tuzamate.domain.post.enums.BoardType;
import naughty.tuzamate.domain.post.repository.PostRepository;
import naughty.tuzamate.domain.postLike.repository.PostLikeRepository;
import naughty.tuzamate.domain.postScrap.repository.PostScrapRepository;
import naughty.tuzamate.domain.user.entity.User;
import naughty.tuzamate.domain.user.repository.UserRepository;
import naughty.tuzamate.global.error.GeneralErrorCode;
import naughty.tuzamate.global.error.exception.CustomException;
import org.springframework.data.domain.PageRequest;
Expand All @@ -14,24 +19,30 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.security.Principal;
import java.util.List;

@Service
@RequiredArgsConstructor
public class PostQueryServiceImpl implements PostQueryService {

private final PostRepository postRepository;
private final PostLikeRepository postLikeRepository;
private final PostScrapRepository postScrapRepository;

@Override
public PostResDTO.PostPreviewDTO getPost(Long postId) {
public PostResDTO.PostDTO getPost(Long postId, PrincipalDetails principalDetails) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new CustomException(GeneralErrorCode.NOT_FOUND_404));

if (!post.isRead()) {
post.setIsRead();
}

return PostConverter.toPostPreviewDTO(post);
boolean liked = postLikeRepository.existsByPostIdAndUserId(postId, principalDetails.getId());
boolean scraped = postScrapRepository.existsByPostIdAndUserId(postId, principalDetails.getId());

return PostConverter.toPostDTO(post, liked, scraped);
}
Comment on lines +34 to 46
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Handle null PrincipalDetails to avoid NPEs and decide on transactional semantics for isRead

  • If this endpoint allows anonymous access, principalDetails can be null, causing NPEs when calling getId().
  • You’re mutating state (post.setIsRead()) inside a GET without a transaction; unless another layer opens one, the change may not persist.

Suggested changes:

@@
-    public PostResDTO.PostDTO getPost(Long postId, PrincipalDetails principalDetails) {
+    @Transactional
+    public PostResDTO.PostDTO getPost(Long postId, PrincipalDetails principalDetails) {
@@
-        if (!post.isRead()) {
-            post.setIsRead();
-        }
+        if (!post.isRead()) {
+            post.setIsRead(); // persists due to @Transactional
+        }
@@
-        boolean liked = postLikeRepository.existsByPostIdAndUserId(postId, principalDetails.getId());
-        boolean scraped = postScrapRepository.existsByPostIdAndUserId(postId, principalDetails.getId());
+        Long userId = (principalDetails != null) ? principalDetails.getId() : null;
+        boolean liked = (userId != null) && postLikeRepository.existsByPostIdAndUserId(postId, userId);
+        boolean scraped = (userId != null) && postScrapRepository.existsByPostIdAndUserId(postId, userId);

Verification/questions:

  • Is GET meant to mutate is_read? If not, remove that mutation or handle via a separate command endpoint/event.
  • Is the endpoint authenticated-only? If yes, ensure security config enforces it; if no, the null-guard above is required to prevent NPEs.

🏁 Script executed:

#!/bin/bash
# 1. Check if getPost is already annotated with @Transactional
rg -n "@Transactional" -C2 src/main/java/naughty/tuzamate/domain/post/service/query/PostQueryServiceImpl.java

# 2. Locate all usages of getPost to find its controller and mapping
rg -nP --type java "getPost\s*\(" -C3

# 3. Inspect Spring Security configuration for anonymous access rules
rg -nP --type java "authorizeHttpRequests|authorizeRequests|permitAll|authenticated" -C5 src/main/java

Length of output: 10085


🏁 Script executed:

#!/bin/bash
# Locate the definition of allowUrls to see if GET /boards/**/posts/** is publicly accessible
rg -n "allowUrls" -C5 src/main/java/naughty/tuzamate/global/config/SecurityConfig.java

Length of output: 1118


Add @transactional to getPost and rely on enforced authentication for PrincipalDetails

  • The getPost method in PostQueryServiceImpl.java (around line 34) isn’t annotated with @transactional, so calling post.setIsRead() on a detached entity won’t be persisted.
  • SecurityConfig only permit-alls root, Swagger, and OAuth endpoints—everything else (including GET /boards/{boardType}/posts/{postId}) requires authentication—so principalDetails will never be null at runtime and a null-guard isn’t needed here.

Suggested diff (and import):

--- a/src/main/java/naughty/tuzamate/domain/post/service/query/PostQueryServiceImpl.java
+++ b/src/main/java/naughty/tuzamate/domain/post/service/query/PostQueryServiceImpl.java
@@
+import org.springframework.transaction.annotation.Transactional;
@@
-    public PostResDTO.PostDTO getPost(Long postId, PrincipalDetails principalDetails) {
+    @Transactional
+    public PostResDTO.PostDTO getPost(Long postId, PrincipalDetails principalDetails) {
@@
     if (!post.isRead()) {
         post.setIsRead();
     }

Also confirm whether mutating the read flag belongs in a GET endpoint; if not, consider moving that logic to a dedicated command/API.

📝 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
public PostResDTO.PostDTO getPost(Long postId, PrincipalDetails principalDetails) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new CustomException(GeneralErrorCode.NOT_FOUND_404));
if (!post.isRead()) {
post.setIsRead();
}
return PostConverter.toPostPreviewDTO(post);
boolean liked = postLikeRepository.existsByPostIdAndUserId(postId, principalDetails.getId());
boolean scraped = postScrapRepository.existsByPostIdAndUserId(postId, principalDetails.getId());
return PostConverter.toPostDTO(post, liked, scraped);
}
import org.springframework.transaction.annotation.Transactional;
@Transactional
public PostResDTO.PostDTO getPost(Long postId, PrincipalDetails principalDetails) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new CustomException(GeneralErrorCode.NOT_FOUND_404));
if (!post.isRead()) {
post.setIsRead();
}
boolean liked = postLikeRepository.existsByPostIdAndUserId(postId, principalDetails.getId());
boolean scraped = postScrapRepository.existsByPostIdAndUserId(postId, principalDetails.getId());
return PostConverter.toPostDTO(post, liked, scraped);
}
🤖 Prompt for AI Agents
In
src/main/java/naughty/tuzamate/domain/post/service/query/PostQueryServiceImpl.java
around lines 34 to 46, the method mutates the Post entity via post.setIsRead()
but is not annotated with @Transactional so the change on a detached entity will
not be persisted; annotate the getPost method with @Transactional (importing
org.springframework.transaction.annotation.Transactional) so the persistence
context remains open and setIsRead() is flushed, and leave out any null-guards
for PrincipalDetails since authentication is enforced by SecurityConfig; also
consider (and note to reviewers) whether flipping the read flag belongs in a GET
endpoint and move that mutation into a dedicated command/API if appropriate.


@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public interface PostLikeRepository extends JpaRepository<PostLike, Long> {

public boolean existsByPostAndUser(Post post, User user);
public PostLike findByPostAndUser(Post post, User user);
public boolean existsByPostIdAndUserId(Long postId, Long userId);

// 커서가 없는 경우 최신순 조회
@Query("select pl from PostLike pl join fetch pl.post p where pl.user.id = :userId order by pl.id desc")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public interface PostScrapRepository extends JpaRepository<PostScrap, Long> {

public boolean existsByPostAndUser(Post post, User user);
public PostScrap findByPostAndUser(Post post, User user);
public boolean existsByPostIdAndUserId(Long postId, Long userId);

// 커서가 없는 경우 최신순 조회
@Query("select ps from PostScrap ps join fetch ps.post p where ps.user.id = :userId order by ps.id desc")
Expand Down
32 changes: 17 additions & 15 deletions src/main/java/naughty/tuzamate/global/config/FireBaseConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,32 +20,34 @@
@Configuration
public class FireBaseConfig {

// String 대신 Resource 타입으로 주입받음
@Value("${firebase.service-account.path}")
private Resource serviceAccountResource;
private String serviceAccountPath;

@Bean
public FirebaseApp firebaseApp() {
try {
// 주입받은 Resource에서 바로 InputStream을 얻음
InputStream serviceAccount = serviceAccountResource.getInputStream();
try (InputStream serviceAccount = getServiceAccountStream()) {

FirebaseOptions options = FirebaseOptions.builder()
.setCredentials(GoogleCredentials.fromStream(serviceAccount))
.build();

// 앱이 이미 초기화되었는지 확인 (중복 초기화 방지)
if (FirebaseApp.getApps().isEmpty()) {
log.info("Successfully initialized firebase app");
return FirebaseApp.initializeApp(options);
} else {
return FirebaseApp.getInstance();
}
log.info(" Successfully initialized firebase app");
return FirebaseApp.initializeApp(options);

} catch (IOException exception) {
log.error("Fail to initialize firebase app: {}", exception.getMessage(), exception);
// 초기화 실패 시 null 대신 예외를 던져서 애플리케이션이 문제를 인지하게 하는 것이 더 좋습니다.
throw new RuntimeException("Failed to initialize Firebase app.", exception);
log.error(" Fail to initialize firebase app: {}", exception.getMessage(), exception);
return null;
}
}
Comment on lines +28 to +41
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

⚠️ Potential issue

Critical: Possible duplicate app initialization and null-returning @bean.

  • Initializing without checking if a default FirebaseApp already exists can throw “FirebaseApp name [DEFAULT] already exists”.
  • Returning null from a @bean will cause downstream bean creation failures (e.g., FirebaseMessaging) and is an anti-pattern.

Reintroduce the existence check and throw a runtime exception on failure instead of returning null.

Apply this diff:

-    try (InputStream serviceAccount = getServiceAccountStream()) {
-
-            FirebaseOptions options = FirebaseOptions.builder()
-                    .setCredentials(GoogleCredentials.fromStream(serviceAccount))
-                    .build();
-
-            log.info(" Successfully initialized firebase app");
-            return FirebaseApp.initializeApp(options);
-
-        } catch (IOException exception) {
-            log.error(" Fail to initialize firebase app: {}", exception.getMessage(), exception);
-            return null;
-        }
+    try (InputStream serviceAccount = getServiceAccountStream()) {
+        FirebaseOptions options = FirebaseOptions.builder()
+                .setCredentials(GoogleCredentials.fromStream(serviceAccount))
+                .build();
+
+        FirebaseApp app;
+        if (FirebaseApp.getApps().isEmpty()) {
+            app = FirebaseApp.initializeApp(options);
+            log.info("Successfully initialized Firebase app");
+        } else {
+            app = FirebaseApp.getInstance();
+            log.info("Reusing existing Firebase app");
+        }
+        return app;
+    } catch (IOException exception) {
+        log.error("Fail to initialize Firebase app: {}", exception.getMessage(), exception);
+        throw new IllegalStateException("Failed to initialize Firebase app", exception);
+    }
📝 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
try (InputStream serviceAccount = getServiceAccountStream()) {
FirebaseOptions options = FirebaseOptions.builder()
.setCredentials(GoogleCredentials.fromStream(serviceAccount))
.build();
// 앱이 이미 초기화되었는지 확인 (중복 초기화 방지)
if (FirebaseApp.getApps().isEmpty()) {
log.info("Successfully initialized firebase app");
return FirebaseApp.initializeApp(options);
} else {
return FirebaseApp.getInstance();
}
log.info(" Successfully initialized firebase app");
return FirebaseApp.initializeApp(options);
} catch (IOException exception) {
log.error("Fail to initialize firebase app: {}", exception.getMessage(), exception);
// 초기화 실패 시 null 대신 예외를 던져서 애플리케이션이 문제를 인지하게 하는 것이 더 좋습니다.
throw new RuntimeException("Failed to initialize Firebase app.", exception);
log.error(" Fail to initialize firebase app: {}", exception.getMessage(), exception);
return null;
}
}
try (InputStream serviceAccount = getServiceAccountStream()) {
FirebaseOptions options = FirebaseOptions.builder()
.setCredentials(GoogleCredentials.fromStream(serviceAccount))
.build();
FirebaseApp app;
if (FirebaseApp.getApps().isEmpty()) {
app = FirebaseApp.initializeApp(options);
log.info("Successfully initialized Firebase app");
} else {
app = FirebaseApp.getInstance();
log.info("Reusing existing Firebase app");
}
return app;
} catch (IOException exception) {
log.error("Fail to initialize Firebase app: {}", exception.getMessage(), exception);
throw new IllegalStateException("Failed to initialize Firebase app", exception);
}
🤖 Prompt for AI Agents
In src/main/java/naughty/tuzamate/global/config/FireBaseConfig.java around lines
28 to 41, the bean currently always calls FirebaseApp.initializeApp(options) and
returns null on IOException which can cause duplicate-initialization errors and
downstream bean failures; update it to first check FirebaseApp.getApps() or
FirebaseApp.getInstance() to return the existing default app if present, only
call FirebaseApp.initializeApp(options) when no default exists, and on
IOException (or any failure) throw a RuntimeException (wrapping the caught
exception) instead of returning null so the Spring context fails fast and avoids
creating dependent beans with a null FirebaseApp.


private InputStream getServiceAccountStream() throws IOException {
// 절대경로면 FileInputStream, 아니면 classpath
if (serviceAccountPath.startsWith("/") || serviceAccountPath.contains(":")) {
log.info("Using absolute path for Firebase service account: {}", serviceAccountPath);
return new FileInputStream(serviceAccountPath);
} else {
log.info("Using classpath resource for Firebase service account: {}", serviceAccountPath);
return new ClassPathResource(serviceAccountPath).getInputStream();
}
}

Expand Down
Loading