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
16 changes: 8 additions & 8 deletions CONVENTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@
| Facade Service 구현체 | `Facade{Role}{Domain}ServiceImpl` | `FacadeClubFeedServiceImpl` |
| Domain Service 인터페이스 | `{Domain}Service` | `FeedService`, `ClubService` |
| Domain Service 구현체 | `General{Domain}Service` | `GeneralFeedService`, `GeneralClubService` |
| Repository | `{Entity}Repository` | `FeedRepository`, `FeedLikeRepository` |
| Repository | `{Entity}Repository` | `FeedRepository`, `FeedCommentRepository` |
| Request DTO | `{Action}{Entity}Request` | `CreateFeedRequest`, `UpdateFixZoneRequest` |
| Response DTO | `{Context}{Entity}Response` | `MyFeedPageResponse`, `CentralMyFixZoneListResponse` |
| Command DTO | `{Action}{Entity}Command` | `CreateFeedCommand`, `UpdateFormCommand` |
| Query DTO | `{Entity}{Context}Query` | `FeedPageQuery`, `ClubFeedPageQuery` |
| Exception (부모) | `{Domain}Exception` | `FeedException`, `FormException` |
| Exception (구체) | `{Description}Exception` (static inner) | `DuplicatedFeedLikeException`, `FormPeriodException` |
| Exception (구체) | `{Description}Exception` (static inner) | `FeedNotFoundException`, `FormPeriodException` |
| Repository Projection | `{QueryPurpose}Dto` (interface) | `MonthlyFeedRankingDto`, `ClubFeedRankingDto` |

---
Expand Down Expand Up @@ -243,16 +243,16 @@ public class FeedException extends CustomException {
}

// 구체적 예외 — static final inner class
public static final class DuplicatedFeedLikeException extends FeedException {
private static final String MESSAGE = "이미 좋아요한 피드입니다.";
public DuplicatedFeedLikeException() {
super(MESSAGE, BAD_REQUEST.value());
public static final class FeedNotFoundException extends FeedException {
private static final String MESSAGE = "존재하지 않는 피드입니다.";
public FeedNotFoundException() {
super(MESSAGE, NOT_FOUND.value());
}
}
}

// 사용
throw new FeedException.DuplicatedFeedLikeException();
throw new FeedException.FeedNotFoundException();
```

| 예외 유형 | 패턴 | HTTP 코드 |
Expand Down Expand Up @@ -302,4 +302,4 @@ public void createFeed(CreateFeedRequest request, PrincipalDetails principalDeta
| Service Impl 네이밍 | Impl 유무 혼재 | **항상 Impl suffix 붙이기** |
| Exception 메시지 | 상수 vs 인라인 혼재 | **`private static final String MESSAGE`로 상수화** |
| Boolean 필드 네이밍 | `is` / `has` 혼재 | **엔티티 상태 → `is`, 보유 여부 → `has`** |
| FeedLike soft delete | unique constraint 충돌 | **FeedLike만 예외적으로 hard delete** |
| FeedComment hard delete | 댓글 삭제 시 물리 삭제 | **FeedComment만 예외적으로 hard delete** |
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import static org.springframework.http.HttpMethod.DELETE;
import static org.springframework.http.HttpMethod.GET;
import static org.springframework.http.HttpMethod.PATCH;
import static org.springframework.http.HttpMethod.POST;

import ddingdong.ddingdongBE.auth.service.JwtAuthService;
Expand Down Expand Up @@ -59,12 +60,14 @@ public SecurityFilterChain filterChain(HttpSecurity http, JwtAuthService authSer
.requestMatchers(POST,
API_PREFIX + "/forms/{formId}/applications",
API_PREFIX + "/pair-game/appliers",
API_PREFIX + "/feeds/*/likes",
API_PREFIX + "/feeds/*/comments"
)
.permitAll()
.requestMatchers(PATCH,
API_PREFIX + "/feeds/*/likes"
)
.permitAll()
.requestMatchers(DELETE,
API_PREFIX + "/feeds/*/likes",
API_PREFIX + "/feeds/*/comments/*"
)
.permitAll()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package ddingdong.ddingdongBE.common.exception;

import static org.springframework.http.HttpStatus.CONFLICT;
import static org.springframework.http.HttpStatus.FORBIDDEN;
import static org.springframework.http.HttpStatus.NOT_FOUND;

Expand All @@ -10,24 +9,6 @@ public FeedException(String message, int errorCode) {
super(message, errorCode);
}

public static final class DuplicatedFeedLikeException extends FeedException {

private static final String MESSAGE = "이미 좋아요한 피드입니다.";

public DuplicatedFeedLikeException() {
super(MESSAGE, CONFLICT.value());
}
}

public static final class FeedLikeNotFoundException extends FeedException {

private static final String MESSAGE = "좋아요 기록이 존재하지 않습니다.";

public FeedLikeNotFoundException() {
super(MESSAGE, NOT_FOUND.value());
}
}

public static final class CommentNotFoundException extends FeedException {

private static final String MESSAGE = "존재하지 않는 댓글입니다.";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
package ddingdong.ddingdongBE.domain.feed.api;

import static ddingdong.ddingdongBE.common.constant.ValidationConstants.UUID_V4_REGEXP;

import ddingdong.ddingdongBE.domain.feed.controller.dto.request.CreateFeedLikeRequest;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;

Expand All @@ -21,30 +19,13 @@ public interface FeedLikeApi {

@Operation(summary = "피드 좋아요 API")
@ApiResponses({
@ApiResponse(responseCode = "201", description = "피드 좋아요 성공"),
@ApiResponse(responseCode = "400", description = "유효하지 않은 UUID 형식"),
@ApiResponse(responseCode = "404", description = "피드 없음"),
@ApiResponse(responseCode = "409", description = "이미 좋아요한 피드")
})
@ResponseStatus(HttpStatus.CREATED)
@PostMapping("/{feedId}/likes")
void createLike(
@PathVariable("feedId") Long feedId,
@Pattern(regexp = UUID_V4_REGEXP, message = "유효하지 않은 UUID v4 형식입니다.")
@RequestHeader("X-Anonymous-UUID") String uuid
);

@Operation(summary = "피드 좋아요 취소 API")
@ApiResponses({
@ApiResponse(responseCode = "204", description = "피드 좋아요 취소 성공"),
@ApiResponse(responseCode = "400", description = "유효하지 않은 UUID 형식"),
@ApiResponse(responseCode = "404", description = "좋아요 기록 없음")
@ApiResponse(responseCode = "204", description = "피드 좋아요 성공"),
@ApiResponse(responseCode = "400", description = "좋아요 횟수 초과 (최대 100)")
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

@ApiResponse(400) 설명이 유효성 검사 케이스를 일부만 기술합니다

현재 설명 "좋아요 횟수 초과 (최대 100)"@Max 위반만 설명하지만, count < 1(@Min 위반)이나 count = null(@NotNull 위반) 시에도 동일하게 400이 반환됩니다.

✏️ 수정 제안
-        `@ApiResponse`(responseCode = "400", description = "좋아요 횟수 초과 (최대 100)")
+        `@ApiResponse`(responseCode = "400", description = "유효하지 않은 좋아요 횟수 (1 이상 100 이하)")
📝 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
@ApiResponse(responseCode = "400", description = "좋아요 횟수 초과 (최대 100)")
`@ApiResponse`(responseCode = "400", description = "유효하지 않은 좋아요 횟수 (1 이상 100 이하)")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/ddingdong/ddingdongBE/domain/feed/api/FeedLikeApi.java` at line
23, The `@ApiResponse`(responseCode = "400") description in FeedLikeApi currently
only mentions the `@Max` violation ("좋아요 횟수 초과 (최대 100)") but not other validation
failures; update the 400 response description for the relevant controller method
or class (FeedLikeApi / the method handling like count) to cover all validation
cases (e.g., null, <1, >100) or add separate `@ApiResponse` entries for each
validation case so the OpenAPI docs accurately reflect `@NotNull`, `@Min`(1) and
`@Max`(100) validation failures—refer to the existing `@ApiResponse` annotation on
FeedLikeApi and modify its description accordingly.

})
Comment on lines 20 to 24
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

@ApiResponses404 응답이 누락되어 있습니다

FeedLikeController.createLikefeedService.getById(feedId)를 호출하고, 피드가 없으면 NotFoundException → 404를 반환합니다(FeedLikeControllerE2ETest.createLike_nonExistentFeed에서 404 검증 통과). 그러나 @ApiResponses에 404 케이스가 없어 OpenAPI 스펙이 실제 동작과 불일치합니다.

📄 수정 제안
 `@ApiResponses`({
         `@ApiResponse`(responseCode = "204", description = "피드 좋아요 성공"),
-        `@ApiResponse`(responseCode = "400", description = "좋아요 횟수 초과 (최대 100)")
+        `@ApiResponse`(responseCode = "400", description = "좋아요 횟수 초과 (최대 100)"),
+        `@ApiResponse`(responseCode = "404", description = "피드 없음")
 })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/ddingdong/ddingdongBE/domain/feed/api/FeedLikeApi.java` around
lines 20 - 24, The OpenAPI annotations in FeedLikeApi are missing the 404
response that the controller actually returns; update the `@ApiResponses` on the
FeedLikeApi (where `@Operation`("피드 좋아요 API") is declared) to include an
`@ApiResponse`(responseCode = "404", description = "피드 없음") so the spec matches
runtime behavior (FeedLikeController.createLike -> feedService.getById(feedId)
throws NotFoundException). Ensure the new 404 entry uses the same annotation
style as the existing 204/400 entries.

@ResponseStatus(HttpStatus.NO_CONTENT)
@DeleteMapping("/{feedId}/likes")
void deleteLike(
@PatchMapping("/{feedId}/likes")
void createLike(
@PathVariable("feedId") Long feedId,
@Pattern(regexp = UUID_V4_REGEXP, message = "유효하지 않은 UUID v4 형식입니다.")
@RequestHeader("X-Anonymous-UUID") String uuid
@RequestBody @Valid CreateFeedLikeRequest request
);
}
Original file line number Diff line number Diff line change
@@ -1,25 +1,20 @@
package ddingdong.ddingdongBE.domain.feed.controller;

import ddingdong.ddingdongBE.domain.feed.api.FeedLikeApi;
import ddingdong.ddingdongBE.domain.feed.service.FeedLikeService;
import ddingdong.ddingdongBE.domain.feed.controller.dto.request.CreateFeedLikeRequest;
import ddingdong.ddingdongBE.domain.feed.service.FeedService;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RestController;

@Validated
@RestController
@RequiredArgsConstructor
public class FeedLikeController implements FeedLikeApi {

private final FeedLikeService feedLikeService;
private final FeedService feedService;

@Override
public void createLike(Long feedId, String uuid) {
feedLikeService.create(feedId, uuid);
}

@Override
public void deleteLike(Long feedId, String uuid) {
feedLikeService.delete(feedId, uuid);
public void createLike(Long feedId, CreateFeedLikeRequest request) {
feedService.getById(feedId);
feedService.addLikeCount(feedId, request.count());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package ddingdong.ddingdongBE.domain.feed.controller.dto.request;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;

public record CreateFeedLikeRequest(
@NotNull(message = "count는 null이 될 수 없습니다.")
@Min(value = 1, message = "count는 1 이상이어야 합니다.")
@Max(value = 100, message = "count는 100 이하여야 합니다.")
Integer count
) {

}
Original file line number Diff line number Diff line change
Expand Up @@ -43,16 +43,20 @@ public class Feed extends BaseEntity {
@Column(nullable = false)
private long viewCount;

@Column(nullable = false)
private long likeCount;

@Column(name = "deleted_at")
private LocalDateTime deletedAt;

@Builder
private Feed(Long id, String activityContent, Club club, FeedType feedType, long viewCount) {
private Feed(Long id, String activityContent, Club club, FeedType feedType, long viewCount, long likeCount) {
this.id = id;
this.activityContent = activityContent;
this.club = club;
this.feedType = feedType;
this.viewCount = viewCount;
this.likeCount = likeCount;
}

public boolean isImage() {
Expand Down

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -69,23 +69,22 @@ Slice<Feed> getAllFeedPage(
@Query(value = "UPDATE feed SET view_count = view_count + 1 WHERE id = :feedId", nativeQuery = true)
void incrementViewCount(@Param("feedId") Long feedId);

@Modifying(clearAutomatically = true)
@Query(value = "UPDATE feed SET like_count = like_count + :count WHERE id = :feedId", nativeQuery = true)
void addLikeCount(@Param("feedId") Long feedId, @Param("count") int count);

@Query(value = """
SELECT c.id AS clubId,
c.name AS clubName,
COUNT(f.id) AS feedCount,
COALESCE(SUM(f.view_count), 0) AS viewCount,
COALESCE(SUM(sub_like.like_cnt), 0) AS likeCount,
COALESCE(SUM(f.like_count), 0) AS likeCount,
COALESCE(SUM(sub_comment.comment_cnt), 0) AS commentCount
FROM club c
LEFT JOIN feed f ON f.club_id = c.id
AND f.deleted_at IS NULL
AND YEAR(f.created_at) = :year
AND MONTH(f.created_at) = :month
LEFT JOIN (
SELECT fl.feed_id, COUNT(*) AS like_cnt
FROM feed_like fl
GROUP BY fl.feed_id
) sub_like ON sub_like.feed_id = f.id
LEFT JOIN (
SELECT fc.feed_id, COUNT(*) AS comment_cnt
FROM feed_comment fc
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import ddingdong.ddingdongBE.domain.feed.entity.Feed;
import ddingdong.ddingdongBE.domain.feed.repository.FeedCommentRepository;
import ddingdong.ddingdongBE.domain.feed.repository.FeedLikeRepository;
import ddingdong.ddingdongBE.domain.feed.repository.dto.FeedCountDto;
import ddingdong.ddingdongBE.domain.feed.service.dto.query.ClubFeedPageQuery;
import ddingdong.ddingdongBE.domain.feed.service.dto.query.ClubProfileQuery;
Expand All @@ -12,7 +11,6 @@
import ddingdong.ddingdongBE.domain.feed.service.dto.query.FeedQuery;
import ddingdong.ddingdongBE.domain.feed.service.dto.query.FeedPageQuery;
import ddingdong.ddingdongBE.domain.feed.service.dto.query.PagingQuery;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
Expand All @@ -28,9 +26,7 @@ public class FacadeFeedService {

private final FeedService feedService;
private final FeedFileService feedFileService;
private final FeedLikeService feedLikeService;
private final FeedCommentService feedCommentService;
private final FeedLikeRepository feedLikeRepository;
private final FeedCommentRepository feedCommentRepository;

public ClubFeedPageQuery getFeedPageByClub(Long clubId, int size, Long currentCursorId) {
Expand Down Expand Up @@ -63,7 +59,7 @@ public FeedQuery getById(Long feedId) {
Feed feed = feedService.getById(feedId);
ClubProfileQuery clubProfileQuery = feedFileService.extractClubInfo(feed.getClub());
FeedFileInfoQuery feedFileInfoQuery = feedFileService.extractFeedFileInfo(feed);
long likeCount = feedLikeService.countByFeedId(feedId);
long likeCount = feed.getLikeCount();
long commentCount = feedCommentService.countByFeedId(feedId);
List<FeedCommentQuery> comments = feedCommentService.getAllByFeedId(feedId);
return FeedQuery.of(feed, clubProfileQuery, feedFileInfoQuery, likeCount, commentCount, comments);
Expand All @@ -79,8 +75,8 @@ private List<FeedListQuery> buildFeedListQueriesWithCounts(List<Feed> feeds) {
return feedListQueries;
}

Map<Long, Long> likeCountMap = feedLikeRepository.countsByFeedIds(feedIds).stream()
.collect(Collectors.toMap(FeedCountDto::getFeedId, FeedCountDto::getCnt));
Map<Long, Long> likeCountMap = feeds.stream()
.collect(Collectors.toMap(Feed::getId, Feed::getLikeCount));
Map<Long, Long> commentCountMap = feedCommentRepository.countsByFeedIds(feedIds).stream()
.collect(Collectors.toMap(FeedCountDto::getFeedId, FeedCountDto::getCnt));

Expand Down
Loading