diff --git a/.gitignore b/.gitignore index 6ff3d5de..9b084d21 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,7 @@ out/ ### 환경 변수 ### .env + +###claude### +.claude +.serena diff --git a/src/main/java/devkor/com/teamcback/domain/bookmark/dto/response/CreateBookmarkRes.java b/src/main/java/devkor/com/teamcback/domain/bookmark/dto/response/CreateBookmarkRes.java index 739c2e46..9bc06b9a 100644 --- a/src/main/java/devkor/com/teamcback/domain/bookmark/dto/response/CreateBookmarkRes.java +++ b/src/main/java/devkor/com/teamcback/domain/bookmark/dto/response/CreateBookmarkRes.java @@ -1,9 +1,6 @@ package devkor.com.teamcback.domain.bookmark.dto.response; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import devkor.com.teamcback.domain.bookmark.entity.Bookmark; -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Getter; @JsonIgnoreProperties public class CreateBookmarkRes { diff --git a/src/main/java/devkor/com/teamcback/domain/bookmark/service/BookmarkService.java b/src/main/java/devkor/com/teamcback/domain/bookmark/service/BookmarkService.java index 2045c924..e98282a1 100644 --- a/src/main/java/devkor/com/teamcback/domain/bookmark/service/BookmarkService.java +++ b/src/main/java/devkor/com/teamcback/domain/bookmark/service/BookmarkService.java @@ -45,7 +45,6 @@ public class BookmarkService { * 즐겨찾기 업데이트 */ @Transactional - @UpdateScore(addScore = 1) public CreateBookmarkRes createBookmark(Long userId, CreateBookmarkReq req) { User user = findUser(userId); diff --git a/src/main/java/devkor/com/teamcback/domain/common/repository/FileRepository.java b/src/main/java/devkor/com/teamcback/domain/common/repository/FileRepository.java index 801044f1..928507f8 100644 --- a/src/main/java/devkor/com/teamcback/domain/common/repository/FileRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/common/repository/FileRepository.java @@ -16,4 +16,6 @@ public interface FileRepository extends JpaRepository, CustomFileRep List findTop3AllByFileUuidOrderBySortNumAsc(String fileUuid); File findByFileUuidAndSortNum(String fileUuid, Long sortNum); + + boolean existsByFileUuid(String fileUuid); } diff --git a/src/main/java/devkor/com/teamcback/domain/review/controller/ReviewController.java b/src/main/java/devkor/com/teamcback/domain/review/controller/ReviewController.java index 87f31e04..9ab4d4e8 100644 --- a/src/main/java/devkor/com/teamcback/domain/review/controller/ReviewController.java +++ b/src/main/java/devkor/com/teamcback/domain/review/controller/ReviewController.java @@ -68,7 +68,6 @@ public CommonResponse> getReviewPlaceDetailImages( return CommonResponse.success(reviewService.getReviewPlaceDetailImages(placeId, lastFileId)); } - // TODO: 리뷰 작성 시 포인트 부여 @Operation(summary = "리뷰 작성", description = "식당, 카페에 대한 리뷰를 작성") @ApiResponses(value = { @@ -114,7 +113,6 @@ public CommonResponse modifyReview( return CommonResponse.success(reviewService.modifyReview(userDetail.getUser().getUserId(), reviewId, modifyReviewReq)); } - // TODO: 리뷰 삭제 시 포인트 제거 @Operation(summary = "리뷰 삭제", description = "식당, 카페에 대한 리뷰를 삭제") @ApiResponses(value = { diff --git a/src/main/java/devkor/com/teamcback/domain/review/dto/response/CreateReviewRes.java b/src/main/java/devkor/com/teamcback/domain/review/dto/response/CreateReviewRes.java index 14fc29e0..25be1dd5 100644 --- a/src/main/java/devkor/com/teamcback/domain/review/dto/response/CreateReviewRes.java +++ b/src/main/java/devkor/com/teamcback/domain/review/dto/response/CreateReviewRes.java @@ -1,13 +1,27 @@ package devkor.com.teamcback.domain.review.dto.response; +import devkor.com.teamcback.global.response.ScoreUpdateResponse; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; +import lombok.Setter; @Schema(description = "리뷰 생성 응답 dto") @Getter -public class CreateReviewRes { +public class CreateReviewRes implements ScoreUpdateResponse { private Long reviewId; + @Setter + @Schema(description = "레벨업 여부") + private boolean isLevelUp; + + @Setter + @Schema(description = "현재 점수") + private Long currentScore; + + @Setter + @Schema(description = "점수 획득 여부") + private boolean scoreGained; + public CreateReviewRes(Long id) { this.reviewId = id; } diff --git a/src/main/java/devkor/com/teamcback/domain/review/dto/response/DeleteReviewRes.java b/src/main/java/devkor/com/teamcback/domain/review/dto/response/DeleteReviewRes.java index cad485c2..91b6aa67 100644 --- a/src/main/java/devkor/com/teamcback/domain/review/dto/response/DeleteReviewRes.java +++ b/src/main/java/devkor/com/teamcback/domain/review/dto/response/DeleteReviewRes.java @@ -1,9 +1,21 @@ package devkor.com.teamcback.domain.review.dto.response; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import devkor.com.teamcback.global.response.ScoreUpdateResponse; import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; +@Setter @Schema(description = "리뷰 삭제 응답 dto") -@JsonIgnoreProperties -public class DeleteReviewRes { +@Getter +public class DeleteReviewRes implements ScoreUpdateResponse { + + @Schema(description = "레벨업 여부") + private boolean isLevelUp; + + @Schema(description = "현재 점수") + private Long currentScore; + + @Schema(description = "점수 획득 여부 (삭제 시에는 항상 false)") + private boolean scoreGained; } diff --git a/src/main/java/devkor/com/teamcback/domain/review/dto/response/ModifyReviewRes.java b/src/main/java/devkor/com/teamcback/domain/review/dto/response/ModifyReviewRes.java index fdd545cb..ccf2d5fb 100644 --- a/src/main/java/devkor/com/teamcback/domain/review/dto/response/ModifyReviewRes.java +++ b/src/main/java/devkor/com/teamcback/domain/review/dto/response/ModifyReviewRes.java @@ -1,13 +1,27 @@ package devkor.com.teamcback.domain.review.dto.response; +import devkor.com.teamcback.global.response.ScoreUpdateResponse; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; +import lombok.Setter; @Schema(description = "리뷰 수정 응답 dto") @Getter -public class ModifyReviewRes { +public class ModifyReviewRes implements ScoreUpdateResponse { private Long reviewId; + @Setter + @Schema(description = "레벨업 여부") + private boolean isLevelUp; + + @Setter + @Schema(description = "현재 점수") + private Long currentScore; + + @Setter + @Schema(description = "점수 획득 여부") + private boolean scoreGained; + public ModifyReviewRes(Long id) { this.reviewId = id; } diff --git a/src/main/java/devkor/com/teamcback/domain/review/entity/Review.java b/src/main/java/devkor/com/teamcback/domain/review/entity/Review.java index b0df7e31..ae963792 100644 --- a/src/main/java/devkor/com/teamcback/domain/review/entity/Review.java +++ b/src/main/java/devkor/com/teamcback/domain/review/entity/Review.java @@ -38,7 +38,7 @@ public class Review extends BaseEntity { @Setter @Column(nullable = false) - private boolean isReported = false; + private boolean isReported = false; // 신고 여부 @ManyToOne @JoinColumn(name = "user_id") diff --git a/src/main/java/devkor/com/teamcback/domain/review/repository/ReviewRepository.java b/src/main/java/devkor/com/teamcback/domain/review/repository/ReviewRepository.java index cdb794a8..eaeaaadd 100644 --- a/src/main/java/devkor/com/teamcback/domain/review/repository/ReviewRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/review/repository/ReviewRepository.java @@ -2,12 +2,19 @@ import devkor.com.teamcback.domain.place.entity.Place; import devkor.com.teamcback.domain.review.entity.Review; +import devkor.com.teamcback.domain.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; +import java.time.LocalDateTime; import java.util.List; public interface ReviewRepository extends JpaRepository { List findAllByPlaceOrderByCreatedAtDesc(Place place); + /** + * 특정 사용자가 특정 장소에 특정 기간 내 작성한 리뷰 존재 여부 확인 + * (같은 장소 하루 1개 리뷰 제한용) + */ + boolean existsByUserAndPlaceAndCreatedAtBetween(User user, Place place, LocalDateTime start, LocalDateTime end); } diff --git a/src/main/java/devkor/com/teamcback/domain/review/service/ReviewService.java b/src/main/java/devkor/com/teamcback/domain/review/service/ReviewService.java index 3a17e1d0..e3e288b0 100644 --- a/src/main/java/devkor/com/teamcback/domain/review/service/ReviewService.java +++ b/src/main/java/devkor/com/teamcback/domain/review/service/ReviewService.java @@ -18,6 +18,7 @@ import devkor.com.teamcback.domain.search.dto.response.SearchPlaceReviewTagRes; import devkor.com.teamcback.domain.user.entity.User; import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.global.annotation.UpdateScore; import devkor.com.teamcback.global.exception.exception.GlobalException; import devkor.com.teamcback.global.response.ResultCode; import devkor.com.teamcback.infra.s3.FilePath; @@ -128,9 +129,17 @@ public List getReviewPlaceDetailImages(Long placeId, Long /** * 리뷰 작성 + * - 기본 점수: +3점 (별점) + * - 한줄평 10글자 이상: +7점 + * - 사진 1장 이상: +3점 + * - 같은 장소 하루 1개 리뷰 제한 */ @Transactional + @UpdateScore(dynamic = true) public CreateReviewRes createReview(Long userId, Long placeId, CreateReviewReq createReviewReq) { + // 한줄평 길이 검증 (작성했다면 10글자 이상) + validateCommentLength(createReviewReq.getComment()); + // 사용자 검색 User user = findUserById(userId); @@ -175,9 +184,15 @@ public GetReviewRes getReview(Long reviewId) { /** * 리뷰 수정 + * - 한줄평/사진 추가 시 추가 점수 부여 + * - 한줄평/사진 제거 시 해당 점수 차감 */ @Transactional + @UpdateScore(dynamic = true) public ModifyReviewRes modifyReview(Long userId, Long reviewId, @Valid ModifyReviewReq modifyReviewReq) { + // 한줄평 길이 검증 (작성했다면 10글자 이상) + validateCommentLength(modifyReviewReq.getComment()); + // 사용자 검색 User user = findUserById(userId); @@ -210,8 +225,10 @@ public ModifyReviewRes modifyReview(Long userId, Long reviewId, @Valid ModifyRev /** * 리뷰 삭제 + * - 삭제한 리뷰의 점수만큼 차감 (최소 0점) */ @Transactional + @UpdateScore(dynamic = true) public DeleteReviewRes deleteReview(Long userId, Long reviewId) { // 사용자 검색 User user = findUserById(userId); @@ -283,6 +300,15 @@ private void validateUser(User user, Review review) { } } + /** + * 한줄평 길이 검증 (작성했다면 10글자 이상) + */ + private void validateCommentLength(String comment) { + if(comment != null && !comment.isBlank() && comment.length() < 10) { + throw new GlobalException(ResultCode.COMMENT_TOO_SHORT); + } + } + /** * 리뷰 태그 저장 */ diff --git a/src/main/java/devkor/com/teamcback/domain/suggestion/dto/response/CreateSuggestionRes.java b/src/main/java/devkor/com/teamcback/domain/suggestion/dto/response/CreateSuggestionRes.java index 5c55cbb1..983c9cda 100644 --- a/src/main/java/devkor/com/teamcback/domain/suggestion/dto/response/CreateSuggestionRes.java +++ b/src/main/java/devkor/com/teamcback/domain/suggestion/dto/response/CreateSuggestionRes.java @@ -1,13 +1,25 @@ package devkor.com.teamcback.domain.suggestion.dto.response; import devkor.com.teamcback.domain.suggestion.entity.Suggestion; +import devkor.com.teamcback.global.response.ScoreUpdateResponse; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; +import lombok.Setter; @Schema(description = "건의 생성 완료") @Getter -public class CreateSuggestionRes { +public class CreateSuggestionRes implements ScoreUpdateResponse { private Long suggestionId; + @Setter + @Schema(description = "레벨업 여부", example = "false") + private boolean isLevelUp; + @Setter + @Schema(description = "현재 점수", example = "15") + private Long currentScore; + @Setter + @Schema(description = "점수 획득 여부", example = "true") + private boolean scoreGained; + public CreateSuggestionRes(Suggestion suggestion) { this.suggestionId = suggestion.getId(); } diff --git a/src/main/java/devkor/com/teamcback/domain/suggestion/repository/SuggestionRepository.java b/src/main/java/devkor/com/teamcback/domain/suggestion/repository/SuggestionRepository.java index 826ffb7d..9672f763 100644 --- a/src/main/java/devkor/com/teamcback/domain/suggestion/repository/SuggestionRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/suggestion/repository/SuggestionRepository.java @@ -3,6 +3,7 @@ import devkor.com.teamcback.domain.suggestion.entity.Suggestion; import devkor.com.teamcback.domain.suggestion.entity.SuggestionType; import devkor.com.teamcback.domain.user.entity.User; +import java.time.LocalDateTime; import java.util.List; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -16,4 +17,6 @@ public interface SuggestionRepository extends JpaRepository { Page findBySuggestionTypeAndIsSolved(Pageable pageable, SuggestionType type, Boolean isSolved); List findByUser(User user); + + long countByUserAndCreatedAtBetween(User user, LocalDateTime start, LocalDateTime end); } diff --git a/src/main/java/devkor/com/teamcback/domain/suggestion/service/SuggestionService.java b/src/main/java/devkor/com/teamcback/domain/suggestion/service/SuggestionService.java index 6ac266c3..285b3672 100644 --- a/src/main/java/devkor/com/teamcback/domain/suggestion/service/SuggestionService.java +++ b/src/main/java/devkor/com/teamcback/domain/suggestion/service/SuggestionService.java @@ -45,7 +45,7 @@ public class SuggestionService { * 건의 생성 */ @Transactional - @UpdateScore(addScore = 3) + @UpdateScore(addScore = 10) public CreateSuggestionRes createSuggestion(Long userId, CreateSuggestionReq req, List images) { User user = null; if(userId != null) user = findUser(userId); diff --git a/src/main/java/devkor/com/teamcback/domain/vote/dto/response/SaveVoteRecordRes.java b/src/main/java/devkor/com/teamcback/domain/vote/dto/response/SaveVoteRecordRes.java index 2b08c45d..65908cc9 100644 --- a/src/main/java/devkor/com/teamcback/domain/vote/dto/response/SaveVoteRecordRes.java +++ b/src/main/java/devkor/com/teamcback/domain/vote/dto/response/SaveVoteRecordRes.java @@ -1,9 +1,22 @@ package devkor.com.teamcback.domain.vote.dto.response; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import devkor.com.teamcback.global.response.ScoreUpdateResponse; import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; +@Getter @Schema(description = "투표 내용 저장 완료") @JsonIgnoreProperties -public class SaveVoteRecordRes { +public class SaveVoteRecordRes implements ScoreUpdateResponse { + @Setter + @Schema(description = "레벨업 여부", example = "false") + private boolean isLevelUp; + @Setter + @Schema(description = "현재 점수", example = "15") + private Long currentScore; + @Setter + @Schema(description = "점수 획득 여부", example = "true") + private boolean scoreGained; } diff --git a/src/main/java/devkor/com/teamcback/domain/vote/service/VoteService.java b/src/main/java/devkor/com/teamcback/domain/vote/service/VoteService.java index 3608b0d3..9d4f508b 100644 --- a/src/main/java/devkor/com/teamcback/domain/vote/service/VoteService.java +++ b/src/main/java/devkor/com/teamcback/domain/vote/service/VoteService.java @@ -60,7 +60,7 @@ public GetVoteRes getVoteByPlace(Long voteTopicId, Long placeId) { /** * 투표 저장 */ - @UpdateScore(addScore = 3) + @UpdateScore(addScore = 5) @Transactional public SaveVoteRecordRes saveVoteRecord(Long userId, SaveVoteRecordReq req) { if(userId == null) throw new GlobalException(FORBIDDEN); diff --git a/src/main/java/devkor/com/teamcback/global/annotation/UpdateScore.java b/src/main/java/devkor/com/teamcback/global/annotation/UpdateScore.java index 50fcba2d..b9360de4 100644 --- a/src/main/java/devkor/com/teamcback/global/annotation/UpdateScore.java +++ b/src/main/java/devkor/com/teamcback/global/annotation/UpdateScore.java @@ -9,5 +9,12 @@ @Retention(value = RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface UpdateScore { - int addScore(); + int addScore() default 0; + + /** + * 동적 점수 계산 여부 + * true인 경우 addScore 무시하고 Request 내용 기반으로 점수 계산 + * (리뷰 생성/수정/삭제 등에서 사용) + */ + boolean dynamic() default false; } diff --git a/src/main/java/devkor/com/teamcback/global/aop/UpdateScoreAspect.java b/src/main/java/devkor/com/teamcback/global/aop/UpdateScoreAspect.java index 9eb9c9d0..20c2b77f 100644 --- a/src/main/java/devkor/com/teamcback/global/aop/UpdateScoreAspect.java +++ b/src/main/java/devkor/com/teamcback/global/aop/UpdateScoreAspect.java @@ -1,7 +1,14 @@ package devkor.com.teamcback.global.aop; -import devkor.com.teamcback.domain.bookmark.dto.request.CreateBookmarkReq; -import devkor.com.teamcback.domain.bookmark.repository.UserBookmarkLogRepository; +import devkor.com.teamcback.domain.common.repository.FileRepository; +import devkor.com.teamcback.domain.place.entity.Place; +import devkor.com.teamcback.domain.place.repository.PlaceRepository; +import devkor.com.teamcback.domain.review.dto.request.CreateReviewReq; +import devkor.com.teamcback.domain.review.dto.request.ModifyReviewReq; +import devkor.com.teamcback.domain.review.entity.Review; +import devkor.com.teamcback.domain.review.repository.ReviewRepository; +import devkor.com.teamcback.domain.suggestion.dto.request.CreateSuggestionReq; +import devkor.com.teamcback.domain.suggestion.repository.SuggestionRepository; import devkor.com.teamcback.domain.user.entity.Level; import devkor.com.teamcback.domain.user.entity.User; import devkor.com.teamcback.domain.user.repository.UserRepository; @@ -9,6 +16,7 @@ import devkor.com.teamcback.domain.vote.repository.VoteRecordRepository; import devkor.com.teamcback.global.annotation.UpdateScore; import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.response.ScoreUpdateResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; @@ -17,10 +25,12 @@ import org.aspectj.lang.reflect.MethodSignature; import org.springframework.stereotype.Component; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.Arrays; import java.util.Comparator; -import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_USER; +import static devkor.com.teamcback.global.response.ResultCode.*; @Slf4j @Aspect @@ -29,22 +39,45 @@ public class UpdateScoreAspect { private final UserRepository userRepository; - private final UserBookmarkLogRepository userBookmarkLogRepository; + private final SuggestionRepository suggestionRepository; private final VoteRecordRepository voteRecordRepository; + private final ReviewRepository reviewRepository; + private final PlaceRepository placeRepository; + private final FileRepository fileRepository; + + // 리뷰 점수 상수 + private static final int REVIEW_BASE_SCORE = 3; // 기본 점수 (별점) + private static final int REVIEW_COMMENT_SCORE = 7; // 한줄평 추가 점수 + private static final int REVIEW_IMAGE_SCORE = 3; // 사진 추가 점수 + private static final int REVIEW_COMMENT_MIN_LENGTH = 10; // 한줄평 최소 글자 수 @Around("@annotation(updateScore)") public Object updateScore(ProceedingJoinPoint joinPoint, UpdateScore updateScore) throws Throwable { - int addScore = updateScore.addScore(); - - Object[] args = joinPoint.getArgs(); // 변수값 - String[] paramNames = ((MethodSignature) joinPoint.getSignature()).getParameterNames(); // 변수명 + Object[] args = joinPoint.getArgs(); + String[] paramNames = ((MethodSignature) joinPoint.getSignature()).getParameterNames(); + String methodName = joinPoint.getSignature().getName(); // User 정보 찾기 User user = getUser(args, paramNames); + // 동적 점수 계산 모드 + if (updateScore.dynamic()) { + return handleDynamicScore(joinPoint, user, args, paramNames, methodName); + } + + // 기존 고정 점수 모드 + return handleFixedScore(joinPoint, updateScore.addScore(), user, args); + } + + /** + * 기존 고정 점수 처리 (Vote, Suggestion 등) + */ + private Object handleFixedScore(ProceedingJoinPoint joinPoint, int addScore, User user, Object[] args) throws Throwable { // 점수 갱신 불가 확인 - if(!checkUpdatable(user, args)) { - return joinPoint.proceed(); + if (!checkUpdatable(user, args)) { + Object result = joinPoint.proceed(); + injectScoreInfo(result, user, false, false); + return result; } // 비지니스 로직 수행 @@ -53,15 +86,202 @@ public Object updateScore(ProceedingJoinPoint joinPoint, UpdateScore updateScore result = joinPoint.proceed(); } catch (Exception e) { log.info("비지니스 로직에서 예외가 발생했습니다."); - throw e; // 기존 흐름 유지 + throw e; } // 점수 증가 increaseScore(user, addScore); + // 점수 정보 주입 + injectScoreInfo(result, user, user.isUpgraded(), true); + + return result; + } + + /** + * 동적 점수 처리 (Review 등) + */ + private Object handleDynamicScore(ProceedingJoinPoint joinPoint, User user, Object[] args, String[] paramNames, String methodName) throws Throwable { + if (user == null) { + log.warn("User 정보를 찾을 수 없습니다."); + Object result = joinPoint.proceed(); + injectScoreInfo(result, null, false, false); + return result; + } + + // 리뷰 생성 + for (int i = 0; i < args.length; i++) { + if (args[i] instanceof CreateReviewReq req) { + return handleReviewCreate(joinPoint, user, args, paramNames, req); + } + } + + // 리뷰 수정 + for (int i = 0; i < args.length; i++) { + if (args[i] instanceof ModifyReviewReq req) { + Long reviewId = getReviewId(args, paramNames); + return handleReviewModify(joinPoint, user, reviewId, req); + } + } + + // 리뷰 삭제 (메서드 이름으로 판단) + if (methodName.equals("deleteReview")) { + Long reviewId = getReviewId(args, paramNames); + return handleReviewDelete(joinPoint, user, reviewId); + } + + // 기타 동적 점수 케이스 (확장용) + Object result = joinPoint.proceed(); + injectScoreInfo(result, user, false, false); return result; } + /** + * 리뷰 생성 시 점수 처리 + */ + private Object handleReviewCreate(ProceedingJoinPoint joinPoint, User user, Object[] args, String[] paramNames, CreateReviewReq req) throws Throwable { + // placeId 추출 + Long placeId = getPlaceId(args, paramNames); + Place place = placeRepository.findById(placeId) + .orElseThrow(() -> new GlobalException(NOT_FOUND_PLACE)); + + // 중복 체크: 같은 장소에 오늘 이미 리뷰 작성했는지 + LocalDateTime startOfDay = LocalDate.now().atStartOfDay(); + LocalDateTime endOfDay = startOfDay.plusDays(1); + if (reviewRepository.existsByUserAndPlaceAndCreatedAtBetween(user, place, startOfDay, endOfDay)) { + throw new GlobalException(ALREADY_REVIEWED_TODAY); + } + + // 비즈니스 로직 수행 + Object result; + try { + result = joinPoint.proceed(); + } catch (Exception e) { + log.info("비지니스 로직에서 예외가 발생했습니다."); + throw e; + } + + // 동적 점수 계산 + int addScore = calculateReviewScore(req.getComment(), req.getImages() != null && !req.getImages().isEmpty()); + + // 점수 증가 + increaseScore(user, addScore); + + // 점수 정보 주입 + injectScoreInfo(result, user, user.isUpgraded(), true); + + return result; + } + + /** + * 리뷰 수정 시 점수 처리 + */ + private Object handleReviewModify(ProceedingJoinPoint joinPoint, User user, Long reviewId, ModifyReviewReq req) throws Throwable { + // 기존 리뷰 조회 + Review review = reviewRepository.findById(reviewId) + .orElseThrow(() -> new GlobalException(NOT_FOUND_REVIEW)); + + // 기존 점수 계산 + boolean hadImages = fileRepository.existsByFileUuid(review.getFileUuid()); + int oldScore = calculateReviewScore(review.getComment(), hadImages); + + // 비즈니스 로직 수행 + Object result; + try { + result = joinPoint.proceed(); + } catch (Exception e) { + log.info("비지니스 로직에서 예외가 발생했습니다."); + throw e; + } + + // 새 점수 계산 + int newScore = calculateReviewScore(req.getComment(), req.getImages() != null && !req.getImages().isEmpty()); + + // 점수 차이 계산 및 적용 + int scoreDiff = newScore - oldScore; + if (scoreDiff != 0) { + updateScoreWithDiff(user, scoreDiff); + injectScoreInfo(result, user, user.isUpgraded(), scoreDiff > 0); + } else { + injectScoreInfo(result, user, false, false); + } + + return result; + } + + /** + * 리뷰 삭제 시 점수 처리 + */ + private Object handleReviewDelete(ProceedingJoinPoint joinPoint, User user, Long reviewId) throws Throwable { + // 삭제 전 리뷰 조회 (삭제 후에는 조회 불가) + Review review = reviewRepository.findById(reviewId) + .orElseThrow(() -> new GlobalException(NOT_FOUND_REVIEW)); + + // 기존 점수 계산 + boolean hadImages = fileRepository.existsByFileUuid(review.getFileUuid()); + int scoreToDeduct = calculateReviewScore(review.getComment(), hadImages); + + // 비즈니스 로직 수행 (리뷰 삭제) + Object result; + try { + result = joinPoint.proceed(); + } catch (Exception e) { + log.info("비지니스 로직에서 예외가 발생했습니다."); + throw e; + } + + // 점수 감소 + updateScoreWithDiff(user, -scoreToDeduct); + + // 점수 정보 주입 (삭제 시 scoreGained는 항상 false) + injectScoreInfo(result, user, user.isUpgraded(), false); + + return result; + } + + /** + * 리뷰 점수 계산 + */ + private int calculateReviewScore(String comment, boolean hasImages) { + int score = REVIEW_BASE_SCORE; // 기본 점수 (별점) + + // 한줄평 점수 (10글자 이상) + if (comment != null && comment.length() >= REVIEW_COMMENT_MIN_LENGTH) { + score += REVIEW_COMMENT_SCORE; + } + + // 사진 점수 + if (hasImages) { + score += REVIEW_IMAGE_SCORE; + } + + return score; + } + + /** + * 파라미터에서 placeId 추출 + */ + private Long getPlaceId(Object[] args, String[] paramNames) { + for (int i = 0; i < args.length; i++) { + if (paramNames[i].equals("placeId") && args[i] instanceof Long) { + return (Long) args[i]; + } + } + throw new GlobalException(NOT_FOUND_PLACE); + } + + /** + * 파라미터에서 reviewId 추출 + */ + private Long getReviewId(Object[] args, String[] paramNames) { + for (int i = 0; i < args.length; i++) { + if (paramNames[i].equals("reviewId") && args[i] instanceof Long) { + return (Long) args[i]; + } + } + throw new GlobalException(NOT_FOUND_REVIEW); + } + private User getUser(Object[] args, String[] paramNames) { User user = null; for (int i = 0; i < args.length; i++) { @@ -85,9 +305,12 @@ private boolean checkUpdatable(User user, Object[] args) { // 기타 조건 확인 for (Object arg : args) { - // 북마크 추가 시 점수 증가 여부 확인 (중복 로그 확인) - if (arg instanceof CreateBookmarkReq req) { - if (userBookmarkLogRepository.existsByUserAndLocationIdAndLocationType(user, req.getLocationId(), req.getLocationType())) { + // 건의 작성 시 하루 2회까지만 점수 지급 + if (arg instanceof CreateSuggestionReq) { + LocalDateTime startOfDay = LocalDate.now().atStartOfDay(); + LocalDateTime endOfDay = startOfDay.plusDays(1); + long todayCount = suggestionRepository.countByUserAndCreatedAtBetween(user, startOfDay, endOfDay); + if (todayCount >= 2) { return false; } } @@ -101,6 +324,14 @@ private boolean checkUpdatable(User user, Object[] args) { return true; } + private void injectScoreInfo(Object result, User user, boolean isLevelUp, boolean scoreGained) { + if (result instanceof ScoreUpdateResponse response && user != null) { + response.setLevelUp(isLevelUp); + response.setCurrentScore(user.getScore()); + response.setScoreGained(scoreGained); + } + } + public void increaseScore(User user, int addScore) { long newScore = user.getScore() + addScore; // 전후 레벨 계산 @@ -112,6 +343,21 @@ public void increaseScore(User user, int addScore) { user.updateScore(newScore, isChanged); } + /** + * 점수 차이만큼 업데이트 (증가 또는 감소) + */ + private void updateScoreWithDiff(User user, int scoreDiff) { + long newScore = Math.max(0, user.getScore() + scoreDiff); // 최소 0점 + + // 전후 레벨 계산 + Level beforeLv = getLevel(user.getScore()); + Level afterLv = getLevel(newScore); + + // 변했으면 true + boolean isChanged = beforeLv != afterLv; + user.updateScore(newScore, isChanged); + } + private Level getLevel(Long score) { // score >= minScore 인 경우 중 가장 높은 레벨 반환 return Arrays.stream(Level.values()) diff --git a/src/main/java/devkor/com/teamcback/global/response/ResultCode.java b/src/main/java/devkor/com/teamcback/global/response/ResultCode.java index 1c253ecd..bad5cd71 100644 --- a/src/main/java/devkor/com/teamcback/global/response/ResultCode.java +++ b/src/main/java/devkor/com/teamcback/global/response/ResultCode.java @@ -98,6 +98,8 @@ public enum ResultCode { // 리뷰 15000번대 NOT_FOUND_REVIEW_TAG(HttpStatus.NOT_FOUND, 15000, "리뷰 태그를 찾을 수 없습니다."), NOT_FOUND_REVIEW(HttpStatus.NOT_FOUND, 15001, "리뷰를 찾을 수 없습니다."), + ALREADY_REVIEWED_TODAY(HttpStatus.CONFLICT, 15002, "오늘 이미 해당 장소에 리뷰를 작성했습니다."), + COMMENT_TOO_SHORT(HttpStatus.BAD_REQUEST, 15003, "한줄평은 10글자 이상 작성해주세요."), // 신고 16000번대 NOT_FOUND_REPORT(HttpStatus.NOT_FOUND, 16000, "신고를 찾을 수 없습니다."); diff --git a/src/main/java/devkor/com/teamcback/global/response/ScoreUpdateResponse.java b/src/main/java/devkor/com/teamcback/global/response/ScoreUpdateResponse.java new file mode 100644 index 00000000..f28559fd --- /dev/null +++ b/src/main/java/devkor/com/teamcback/global/response/ScoreUpdateResponse.java @@ -0,0 +1,7 @@ +package devkor.com.teamcback.global.response; + +public interface ScoreUpdateResponse { + void setLevelUp(boolean isLevelUp); + void setCurrentScore(Long currentScore); + void setScoreGained(boolean scoreGained); +}