Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
@@ -0,0 +1,23 @@
package com.campus.campus.domain.councilpost.application.dto.response;

import java.time.LocalDateTime;

import io.swagger.v3.oas.annotations.media.Schema;

public record GetLikedPostResponse(
@Schema(description = "게시글 id", example = "1")
Long postId,

@Schema(description = "게시글 이름", example = "투썸 제휴")
String title,

@Schema(description = "게시글 장소", example = "투썸 플레이스")
String place,

@Schema(description = "시간(끝나는 시간 or 행사날짜)", example = "2026-01-10T18:00:00")
LocalDateTime dateTime,

@Schema(description = "썸네일 image url", example = "https://www.example.com.png")
String thumbnailImageUrl
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.campus.campus.domain.councilpost.application.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;

public record LikePostResponse(
@Schema(name = "좋아요 누른 사용자 id", example = "1")
Long userId,

@Schema(name = "좋아요 누를 게시글의 id", example = "1")
Long postId,

@Schema(name = "좋아요 여부", example = "true")
boolean liked
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@
import org.springframework.stereotype.Component;

import com.campus.campus.domain.council.domain.entity.StudentCouncil;
import com.campus.campus.domain.councilpost.application.dto.response.GetLikedPostResponse;
import com.campus.campus.domain.councilpost.application.dto.response.LikePostResponse;
import com.campus.campus.domain.councilpost.application.dto.response.PostListItemResponse;
import com.campus.campus.domain.councilpost.application.dto.request.PostRequest;
import com.campus.campus.domain.councilpost.application.dto.response.PostResponse;
import com.campus.campus.domain.councilpost.domain.entity.LikePost;
import com.campus.campus.domain.councilpost.domain.entity.PostImage;
import com.campus.campus.domain.councilpost.domain.entity.StudentCouncilPost;
import com.campus.campus.domain.user.domain.entity.User;

import lombok.RequiredArgsConstructor;

Expand Down Expand Up @@ -59,8 +63,26 @@ public PostResponse toPostResponse(StudentCouncilPost post, List<String> images,
return builder.build();
}

public StudentCouncilPost createStudentCouncilPost(StudentCouncil writer, PostRequest dto, LocalDateTime startDateTime,
LocalDateTime endDateTime) {
public LikePostResponse toLikePostResponse(User user, StudentCouncilPost post, boolean liked) {
return new LikePostResponse(
user.getId(),
post.getId(),
liked
);
}

public GetLikedPostResponse toGetLikedPostResponse(StudentCouncilPost post) {
return new GetLikedPostResponse(
post.getId(),
post.getTitle(),
post.getPlace(),
post.isEvent() ? post.getStartDateTime() : post.getEndDateTime(),
post.getThumbnailImageUrl()
);
}

public StudentCouncilPost createStudentCouncilPost(StudentCouncil writer, PostRequest dto,
LocalDateTime startDateTime, LocalDateTime endDateTime) {
return StudentCouncilPost.builder()
.writer(writer)
.category(dto.category())
Expand All @@ -80,4 +102,11 @@ public PostImage createPostImage(StudentCouncilPost post, String imageUrl) {
.imageUrl(imageUrl)
.build();
}

public LikePost createLikePost(User user, StudentCouncilPost post) {
return LikePost.builder()
.post(post)
.user(user)
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
Expand All @@ -14,6 +15,8 @@
import com.campus.campus.domain.council.application.exception.StudentCouncilNotFoundException;
import com.campus.campus.domain.council.domain.entity.StudentCouncil;
import com.campus.campus.domain.council.domain.repository.StudentCouncilRepository;
import com.campus.campus.domain.councilpost.application.dto.response.GetLikedPostResponse;
import com.campus.campus.domain.councilpost.application.dto.response.LikePostResponse;
import com.campus.campus.domain.councilpost.application.dto.response.NormalizedDateTime;
import com.campus.campus.domain.councilpost.application.dto.response.PostListItemResponse;
import com.campus.campus.domain.councilpost.application.dto.request.PostRequest;
Expand All @@ -24,11 +27,16 @@
import com.campus.campus.domain.councilpost.application.exception.PostOciImageDeleteFailedException;
import com.campus.campus.domain.councilpost.application.exception.ThumbnailRequiredException;
import com.campus.campus.domain.councilpost.application.mapper.StudentCouncilPostMapper;
import com.campus.campus.domain.councilpost.domain.entity.LikePost;
import com.campus.campus.domain.councilpost.domain.entity.PostCategory;
import com.campus.campus.domain.councilpost.domain.entity.PostImage;
import com.campus.campus.domain.councilpost.domain.entity.StudentCouncilPost;
import com.campus.campus.domain.councilpost.domain.repository.LikePostRepository;
import com.campus.campus.domain.councilpost.domain.repository.PostImageRepository;
import com.campus.campus.domain.councilpost.domain.repository.StudentCouncilPostRepository;
import com.campus.campus.domain.user.application.exception.UserNotFoundException;
import com.campus.campus.domain.user.domain.entity.User;
import com.campus.campus.domain.user.domain.repository.UserRepository;
import com.campus.campus.global.oci.application.service.PresignedUrlService;

import lombok.RequiredArgsConstructor;
Expand All @@ -42,6 +50,8 @@ public class StudentCouncilPostService {
private final StudentCouncilPostRepository postRepository;
private final StudentCouncilRepository studentCouncilRepository;
private final PostImageRepository postImageRepository;
private final LikePostRepository likePostRepository;
private final UserRepository userRepository;
private final PresignedUrlService presignedUrlService;
private final StudentCouncilPostMapper studentCouncilPostMapper;

Expand Down Expand Up @@ -130,6 +140,42 @@ public Page<PostListItemResponse> findUpcomingEvents(int page, int size, Long cu
);
}

@Transactional
public LikePostResponse toggleLikePost(Long userId, Long postId) {
User user = userRepository.findByIdAndDeletedAtIsNull(userId)
.orElseThrow(UserNotFoundException::new);

StudentCouncilPost post = postRepository.findById(postId)
.orElseThrow(PostNotFoundException::new);

Optional<LikePost> likePost = likePostRepository.findByUserIdAndPost_Id(userId, postId);
boolean isLike;
if (likePost.isPresent()) {
likePostRepository.delete(likePost.get());
isLike = false;
} else {
LikePost newLikePost = studentCouncilPostMapper.createLikePost(user, post);
likePostRepository.save(newLikePost);
isLike = true;
}

return studentCouncilPostMapper.toLikePostResponse(user, post, isLike);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

fd -t f "LikePostRepository.java"

Repository: our-campUS/our-campUS-BE

Length of output: 160


🏁 Script executed:

fd -t f "LikePost.java"

Repository: our-campUS/our-campUS-BE

Length of output: 146


🏁 Script executed:

fd -t f "StudentCouncilPostService.java" --exec wc -l {} \;

Repository: our-campUS/our-campUS-BE

Length of output: 175


🏁 Script executed:

cat -n src/main/java/com/campus/campus/domain/councilpost/domain/repository/LikePostRepository.java

Repository: our-campUS/our-campUS-BE

Length of output: 1331


🏁 Script executed:

cat -n src/main/java/com/campus/campus/domain/councilpost/domain/entity/LikePost.java

Repository: our-campUS/our-campUS-BE

Length of output: 1550


🏁 Script executed:

sed -n '140,170p' src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostService.java

Repository: our-campUS/our-campUS-BE

Length of output: 1118


🏁 Script executed:

fd -t f "\.sql" -o -t f "*migration*" -o -t f "*schema*" | head -20

Repository: our-campUS/our-campUS-BE

Length of output: 184


🏁 Script executed:

rg "UniqueConstraint" src/main/java/com/campus/campus/domain/councilpost/domain/entity/

Repository: our-campUS/our-campUS-BE

Length of output: 50


🏁 Script executed:

sed -n '1,50p' src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostService.java

Repository: our-campUS/our-campUS-BE

Length of output: 2931


🏁 Script executed:

find . -name "*.sql" -type f 2>/dev/null | head -20

Repository: our-campUS/our-campUS-BE

Length of output: 50


🏁 Script executed:

find . -path "*/db/migration*" -type f 2>/dev/null | head -20

Repository: our-campUS/our-campUS-BE

Length of output: 50


🏁 Script executed:

rg "like_post" -i --type sql | head -30

Repository: our-campUS/our-campUS-BE

Length of output: 216


🏁 Script executed:

find . -name "*Flyway*" -o -name "*flyway*" -o -name "*liquibase*" 2>/dev/null | head -10

Repository: our-campUS/our-campUS-BE

Length of output: 50


🏁 Script executed:

rg "DataIntegrityViolationException|UniqueConstraint" src/main/java/ -A 2 -B 2

Repository: our-campUS/our-campUS-BE

Length of output: 3306


🏁 Script executed:

rg "toggleLikePost|LikePost" src/main/java/com/campus/campus/domain/councilpost/application/controller/ -A 5 -B 2

Repository: our-campUS/our-campUS-BE

Length of output: 182


🏁 Script executed:

find src/main/java -path "*councilpost*controller*" -type f

Repository: our-campUS/our-campUS-BE

Length of output: 50


🏁 Script executed:

find src/main/java -path "*council*controller*" -type f

Repository: our-campUS/our-campUS-BE

Length of output: 50


🏁 Script executed:

rg "toggleLikePost" src/main/java/ -A 5 -B 2

Repository: our-campUS/our-campUS-BE

Length of output: 2318


toggleLikePost 메서드의 동시성 문제를 해결하세요.

현재 구현은 race condition이 발생할 수 있습니다. 두 요청이 동시에 좋아요 존재 여부를 조회한 후, 둘 다 새로운 좋아요를 생성하려 할 수 있습니다.

이를 해결하려면:

  1. LikePost 엔티티의 @TableUniqueConstraint를 추가하세요 (예: @UniqueConstraint(columnNames = {"user_id", "post_id"}))
  2. 중복 생성 시 발생하는 DataIntegrityViolationExceptiontoggleLikePost 메서드에서 처리하세요

참고로 LikedPlace 엔티티와 PlaceService에서 이와 동일한 패턴을 사용하고 있습니다.

🤖 Prompt for AI Agents
In
@src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostService.java
around lines 143 - 163, The toggleLikePost method in StudentCouncilPostService
has a race condition when two requests concurrently try to create a LikePost;
add a unique DB constraint on LikePost (annotate the LikePost entity @Table with
@UniqueConstraint(columnNames = {"user_id","post_id"}) to enforce uniqueness)
and modify toggleLikePost to catch DataIntegrityViolationException (thrown by
likePostRepository.save) and treat it as "already liked" instead of failing
(i.e., on catch, set isLike = true and continue to return the LikePostResponse);
keep existing delete path unchanged and reference methods/classes:
toggleLikePost, LikePost entity, likePostRepository, studentCouncilPostMapper,
StudentCouncilPostService.


@Transactional(readOnly = true)
public Page<GetLikedPostResponse> findLikedPosts(PostCategory category, int page, int size, Long userId) {
userRepository.findByIdAndDeletedAtIsNull(userId)
.orElseThrow(UserNotFoundException::new);

Pageable pageable = PageRequest.of(Math.max(page - 1, 0), size);

Page<LikePost> likedPosts = likePostRepository.findLikedPosts(userId, category, pageable);

return likedPosts.map(likePost ->
studentCouncilPostMapper.toGetLikedPostResponse(likePost.getPost())
);
}

@Transactional
public void delete(Long councilId, Long postId) {
studentCouncilRepository.findByIdAndManagerApprovedIsTrueAndDeletedAtIsNull(councilId)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.campus.campus.domain.councilpost.domain.entity;

import static jakarta.persistence.FetchType.*;

import com.campus.campus.domain.user.domain.entity.User;
import com.campus.campus.global.entity.BaseEntity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Table(name = "like_posts", uniqueConstraints = {
@UniqueConstraint(columnNames = {"user_id", "post_id"})
})
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class LikePost extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "like_post_id")
private Long likePostId;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;

@ManyToOne(fetch = LAZY)
@JoinColumn(name = "post_id")
private StudentCouncilPost post;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.campus.campus.domain.councilpost.domain.repository;

import java.util.Optional;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import com.campus.campus.domain.councilpost.domain.entity.LikePost;
import com.campus.campus.domain.councilpost.domain.entity.PostCategory;

public interface LikePostRepository extends JpaRepository<LikePost, Long> {
Optional<LikePost> findByUserIdAndPost_Id(Long userId, Long postId);

@EntityGraph(attributePaths = {"post", "post.writer"})
@Query("""
SELECT lp FROM LikePost lp
JOIN lp.post p
WHERE lp.user.id = :userId
AND (:category IS NULL OR p.category = :category)
ORDER BY lp.createdAt DESC
""")
Page<LikePost> findLikedPosts(@Param("userId") Long userId, @Param("category") PostCategory category,
Pageable pageable);
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,14 @@
import org.springframework.web.bind.annotation.RestController;

import com.campus.campus.domain.councilpost.application.dto.request.PostRequest;
import com.campus.campus.domain.councilpost.application.dto.response.GetLikedPostResponse;
import com.campus.campus.domain.councilpost.application.dto.response.LikePostResponse;
import com.campus.campus.domain.councilpost.application.dto.response.PostListItemResponse;
import com.campus.campus.domain.councilpost.application.dto.response.PostResponse;
import com.campus.campus.domain.councilpost.application.service.StudentCouncilPostService;
import com.campus.campus.domain.councilpost.domain.entity.PostCategory;
import com.campus.campus.global.annotation.CurrentCouncilId;
import com.campus.campus.global.annotation.CurrentUserId;
import com.campus.campus.global.common.response.CommonResponse;

import io.swagger.v3.oas.annotations.Operation;
Expand Down Expand Up @@ -173,4 +176,27 @@ public CommonResponse<Page<PostListItemResponse>> getUpcomingEvents(
postService.findUpcomingEvents(page, size, councilId)
);
}

@PostMapping("/{postId}/like")
@PreAuthorize("hasRole('USER')")
@Operation(summary = "학생회 게시글 좋아요 토글")
public CommonResponse<LikePostResponse> togglePostLike(@PathVariable Long postId, @CurrentUserId Long userId) {
LikePostResponse response = postService.toggleLikePost(userId, postId);

return CommonResponse.success(StudentCouncilPostResponseCode.POST_LIKE_SUCCESS, response);
}

@GetMapping("/likes")
@PreAuthorize("hasRole('USER')")
@Operation(summary = "관심 학생회 게시글 목록 조회")
public CommonResponse<Page<GetLikedPostResponse>> getLikedPosts(
@RequestParam(required = false) PostCategory category,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "3") int size,
@CurrentUserId Long userId
) {
Page<GetLikedPostResponse> responses = postService.findLikedPosts(category, page, size, userId);

return CommonResponse.success(StudentCouncilPostResponseCode.POST_LIST_READ_SUCCESS, responses);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ public enum StudentCouncilPostResponseCode implements ResponseCodeInterface {
POST_READ_SUCCESS(200, HttpStatus.OK, "게시글 조회에 성공했습니다."),
POST_LIST_READ_SUCCESS(200, HttpStatus.OK, "게시글 목록 조회에 성공했습니다."),
POST_UPDATE_SUCCESS(200, HttpStatus.OK, "게시글 수정에 성공했습니다."),
POST_DELETE_SUCCESS(200, HttpStatus.OK, "게시글 삭제에 성공했습니다.");
POST_DELETE_SUCCESS(200, HttpStatus.OK, "게시글 삭제에 성공했습니다."),
POST_LIKE_SUCCESS(200, HttpStatus.OK, "게시글 좋아요 처리에 성공했습니다.");

private final int code;
private final HttpStatus status;
Expand Down