diff --git a/src/main/java/com/first/flash/account/member/application/ReportService.java b/src/main/java/com/first/flash/account/member/application/ReportService.java index 67892ac8..bf7657c2 100644 --- a/src/main/java/com/first/flash/account/member/application/ReportService.java +++ b/src/main/java/com/first/flash/account/member/application/ReportService.java @@ -25,8 +25,9 @@ public MemberReportResponse reportMember(final Long reportedContentId, UUID reporterId = AuthUtil.getId(); Member reporter = memberService.findById(reporterId); MemberReport memberReport = MemberReport.reportContent(request.reason(), reporter, - reportedContentId); - reportRepository.save(memberReport); - return MemberReportResponse.toDto(reportedContentId, request.reason()); + reportedContentId, request.contentType()); + MemberReport savedReport = reportRepository.save(memberReport); + return MemberReportResponse.toDto(savedReport.getReportedContentId(), + savedReport.getContentType(), savedReport.getReason()); } } diff --git a/src/main/java/com/first/flash/account/member/application/dto/MemberReportRequest.java b/src/main/java/com/first/flash/account/member/application/dto/MemberReportRequest.java index 90ff2035..db564259 100644 --- a/src/main/java/com/first/flash/account/member/application/dto/MemberReportRequest.java +++ b/src/main/java/com/first/flash/account/member/application/dto/MemberReportRequest.java @@ -1,7 +1,14 @@ package com.first.flash.account.member.application.dto; +import com.first.flash.account.member.domain.ContentType; +import com.first.flash.global.annotation.ValidEnum; import jakarta.validation.constraints.NotEmpty; -public record MemberReportRequest(@NotEmpty(message = "신고 사유는 필수입니다.") String reason) { +public record MemberReportRequest(@NotEmpty(message = "신고 사유는 필수입니다.") String reason, + @ValidEnum(enumClass = ContentType.class) ContentType contentType) { + public MemberReportRequest(final String reason, final ContentType contentType) { + this.reason = reason; + this.contentType = contentType != null ? contentType : ContentType.SOLUTION; + } } diff --git a/src/main/java/com/first/flash/account/member/application/dto/MemberReportResponse.java b/src/main/java/com/first/flash/account/member/application/dto/MemberReportResponse.java index 94e916dc..87923b55 100644 --- a/src/main/java/com/first/flash/account/member/application/dto/MemberReportResponse.java +++ b/src/main/java/com/first/flash/account/member/application/dto/MemberReportResponse.java @@ -1,8 +1,11 @@ package com.first.flash.account.member.application.dto; -public record MemberReportResponse(Long reportedContentId, String reason) { +import com.first.flash.account.member.domain.ContentType; - public static MemberReportResponse toDto(final Long reportedContentId, final String reason) { - return new MemberReportResponse(reportedContentId, reason); +public record MemberReportResponse(Long reportedContentId, String contentType, String reason) { + + public static MemberReportResponse toDto(final Long reportedContentId, + final ContentType contentType, final String reason) { + return new MemberReportResponse(reportedContentId, contentType.name(), reason); } } diff --git a/src/main/java/com/first/flash/account/member/domain/ContentType.java b/src/main/java/com/first/flash/account/member/domain/ContentType.java new file mode 100644 index 00000000..d84eedfb --- /dev/null +++ b/src/main/java/com/first/flash/account/member/domain/ContentType.java @@ -0,0 +1,8 @@ +package com.first.flash.account.member.domain; + +import lombok.ToString; + +@ToString +public enum ContentType { + SOLUTION, COMMENT; +} diff --git a/src/main/java/com/first/flash/account/member/domain/MemberReport.java b/src/main/java/com/first/flash/account/member/domain/MemberReport.java index 729ecb6c..280fc350 100644 --- a/src/main/java/com/first/flash/account/member/domain/MemberReport.java +++ b/src/main/java/com/first/flash/account/member/domain/MemberReport.java @@ -3,6 +3,8 @@ import com.first.flash.global.domain.BaseEntity; import jakarta.persistence.CascadeType; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -27,15 +29,19 @@ public class MemberReport extends BaseEntity { @JoinColumn(name = "reporterId") private Member reporter; private Long reportedContentId; + @Enumerated(EnumType.STRING) + private ContentType contentType; - private MemberReport(final String reason, final Member reporter, final Long reportedContentId) { + private MemberReport(final String reason, final Member reporter, final Long reportedContentId, + final ContentType contentType) { this.reason = reason; this.reporter = reporter; this.reportedContentId = reportedContentId; + this.contentType = contentType; } public static MemberReport reportContent(final String reason, final Member reporter, - final Long reportedContentId) { - return new MemberReport(reason, reporter, reportedContentId); + final Long reportedContentId, final ContentType contentType) { + return new MemberReport(reason, reporter, reportedContentId, contentType); } } diff --git a/src/main/java/com/first/flash/climbing/problem/application/ProblemEventHandler.java b/src/main/java/com/first/flash/climbing/problem/application/ProblemEventHandler.java index 3eb02335..44c3a089 100644 --- a/src/main/java/com/first/flash/climbing/problem/application/ProblemEventHandler.java +++ b/src/main/java/com/first/flash/climbing/problem/application/ProblemEventHandler.java @@ -4,6 +4,7 @@ import com.first.flash.climbing.sector.domain.SectorExpiredEvent; import com.first.flash.climbing.sector.domain.SectorInfoUpdatedEvent; import com.first.flash.climbing.sector.domain.SectorRemovalDateUpdatedEvent; +import com.first.flash.climbing.solution.domain.PerceivedDifficultySetEvent; import com.first.flash.climbing.solution.domain.SolutionDeletedEvent; import com.first.flash.climbing.solution.domain.SolutionSavedEvent; import lombok.RequiredArgsConstructor; @@ -39,7 +40,7 @@ public void updateProblemSolutionInfo(final SolutionSavedEvent event) { @EventListener @Transactional public void updateProblemDeletedSolutionInfo(final SolutionDeletedEvent event) { - problemsService.updateProblemDeletedSolutionInfo(event.getProblemId()); + problemsService.updateProblemDeletedSolutionInfo(event.getProblemId(), event.getPerceivedDifficulty()); } @EventListener @@ -54,4 +55,10 @@ public void updateQueryProblemInfo(final SectorInfoUpdatedEvent event) { public void confirmProblemId(final ProblemIdConfirmRequestedEvent event) { problemReadService.findProblemById(event.getProblemId()); } + + @EventListener + @Transactional + public void updatePerceivedDifficulty(final PerceivedDifficultySetEvent event) { + problemsService.addPerceivedDifficulty(event.getProblemId(), event.getPerceivedDifficulty()); + } } diff --git a/src/main/java/com/first/flash/climbing/problem/application/ProblemReadService.java b/src/main/java/com/first/flash/climbing/problem/application/ProblemReadService.java index afd019dc..1dac8299 100644 --- a/src/main/java/com/first/flash/climbing/problem/application/ProblemReadService.java +++ b/src/main/java/com/first/flash/climbing/problem/application/ProblemReadService.java @@ -31,8 +31,8 @@ public class ProblemReadService { private final ProblemRepository problemRepository; public ProblemsResponseDto findAll(final Long gymId, final String cursor, - final String sortByRequest, final Integer size, - final List difficulty, final List sector, final Boolean hasSolution) { + final String sortByRequest, final Integer size, final List difficulty, + final List sector, final Boolean hasSolution, final Boolean isHoney) { ProblemCursor prevProblemCursor = ProblemCursor.decode(cursor); ProblemSortBy problemSortBy = ProblemSortBy.from(sortByRequest); @@ -40,7 +40,7 @@ public ProblemsResponseDto findAll(final Long gymId, final String cursor, List queryProblems = queryProblemRepository.findAll(prevProblemCursor, problemSortBy, size, - gymId, difficulty, sector, hasSolution); + gymId, difficulty, sector, hasSolution, isHoney); String nextCursor = getNextCursor(problemSortBy, size, queryProblems); return ProblemsResponseDto.of(queryProblems, nextCursor); } diff --git a/src/main/java/com/first/flash/climbing/problem/application/ProblemsService.java b/src/main/java/com/first/flash/climbing/problem/application/ProblemsService.java index 4269fceb..0685d7ce 100644 --- a/src/main/java/com/first/flash/climbing/problem/application/ProblemsService.java +++ b/src/main/java/com/first/flash/climbing/problem/application/ProblemsService.java @@ -1,5 +1,6 @@ package com.first.flash.climbing.problem.application; +import com.first.flash.climbing.problem.application.dto.ProblemDetailResponseDto; import com.first.flash.climbing.problem.domain.ProblemRepository; import com.first.flash.climbing.problem.domain.QueryProblem; import com.first.flash.climbing.problem.domain.QueryProblemRepository; @@ -37,9 +38,10 @@ public void updateProblemSolutionInfo(final UUID problemId) { } @Transactional - public void updateProblemDeletedSolutionInfo(final UUID problemId) { + public void updateProblemDeletedSolutionInfo(final UUID problemId, final Integer perceivedDifficulty) { QueryProblem queryProblem = problemReadService.findQueryProblemById(problemId); queryProblem.decrementSolutionCount(); + queryProblem.subtractPerceivedDifficulty(perceivedDifficulty); } @Transactional @@ -47,4 +49,17 @@ public void updateQueryProblemInfo(final Long sectorId, final String sectorName, final LocalDate settingDate) { queryProblemRepository.updateQueryProblemInfo(sectorId, sectorName, settingDate); } + + @Transactional + public void addPerceivedDifficulty(final UUID problemId, final Integer perceivedDifficulty) { + QueryProblem queryProblem = problemReadService.findQueryProblemById(problemId); + queryProblem.addPerceivedDifficulty(perceivedDifficulty); + } + + @Transactional + public ProblemDetailResponseDto setPerceivedDifficulty(final UUID problemId, final Integer perceivedDifficulty) { + QueryProblem queryProblem = problemReadService.findQueryProblemById(problemId); + queryProblem.setPerceivedDifficulty(perceivedDifficulty); + return ProblemDetailResponseDto.of(queryProblem); + } } diff --git a/src/main/java/com/first/flash/climbing/problem/application/dto/ProblemDetailResponseDto.java b/src/main/java/com/first/flash/climbing/problem/application/dto/ProblemDetailResponseDto.java index 8551914f..b3d8a606 100644 --- a/src/main/java/com/first/flash/climbing/problem/application/dto/ProblemDetailResponseDto.java +++ b/src/main/java/com/first/flash/climbing/problem/application/dto/ProblemDetailResponseDto.java @@ -7,13 +7,13 @@ public record ProblemDetailResponseDto(UUID id, String sector, String difficulty, LocalDate settingDate, LocalDate removalDate, boolean isFakeRemovalDate, boolean hasSolution, - String imageUrl, String gymName, String imageSource) { + String imageUrl, String gymName, String imageSource, Boolean isHoney) { public static ProblemDetailResponseDto of(final QueryProblem queryProblem) { return new ProblemDetailResponseDto(queryProblem.getId(), queryProblem.getSectorName(), queryProblem.getDifficultyName(), queryProblem.getSettingDate(), queryProblem.getRemovalDate(), queryProblem.getIsFakeRemovalDate(), queryProblem.getHasSolution(), queryProblem.getImageUrl(), queryProblem.getGymName(), - queryProblem.getImageSource()); + queryProblem.getImageSource(), queryProblem.isHoney()); } } diff --git a/src/main/java/com/first/flash/climbing/problem/application/dto/ProblemPerceivedDifficultyRequestDto.java b/src/main/java/com/first/flash/climbing/problem/application/dto/ProblemPerceivedDifficultyRequestDto.java new file mode 100644 index 00000000..9f482d19 --- /dev/null +++ b/src/main/java/com/first/flash/climbing/problem/application/dto/ProblemPerceivedDifficultyRequestDto.java @@ -0,0 +1,8 @@ +package com.first.flash.climbing.problem.application.dto; + +import jakarta.validation.constraints.NotNull; + +public record ProblemPerceivedDifficultyRequestDto( + @NotNull(message = "변경할 체감 난이도 수치는 필수입니다.") Integer perceivedDifficulty) { + +} diff --git a/src/main/java/com/first/flash/climbing/problem/application/dto/ProblemResponseDto.java b/src/main/java/com/first/flash/climbing/problem/application/dto/ProblemResponseDto.java index 917d8674..84193b17 100644 --- a/src/main/java/com/first/flash/climbing/problem/application/dto/ProblemResponseDto.java +++ b/src/main/java/com/first/flash/climbing/problem/application/dto/ProblemResponseDto.java @@ -5,12 +5,12 @@ import java.util.UUID; public record ProblemResponseDto(UUID id, String sector, String difficulty, LocalDate settingDate, - LocalDate removalDate, boolean hasSolution, String imageUrl) { + LocalDate removalDate, boolean hasSolution, String imageUrl, Boolean isHoney) { public static ProblemResponseDto toDto(QueryProblem queryProblem) { return new ProblemResponseDto(queryProblem.getId(), queryProblem.getSectorName(), queryProblem.getDifficultyName(), queryProblem.getSettingDate(), queryProblem.getRemovalDate(), queryProblem.getHasSolution(), - queryProblem.getImageUrl()); + queryProblem.getImageUrl(), queryProblem.isHoney()); } } diff --git a/src/main/java/com/first/flash/climbing/problem/domain/ProblemsCreateService.java b/src/main/java/com/first/flash/climbing/problem/domain/ProblemsCreateService.java index 8fcf8aeb..650529fc 100644 --- a/src/main/java/com/first/flash/climbing/problem/domain/ProblemsCreateService.java +++ b/src/main/java/com/first/flash/climbing/problem/domain/ProblemsCreateService.java @@ -16,6 +16,7 @@ public class ProblemsCreateService { private static final Boolean DEFAULT_HAS_SOLUTION = false; private static final Integer INITIAL_SOLUTION_COUNT = 0; private static final Long INITIAL_RECOMMENDATION_VALUE = 0L; + private static final Integer INITIAL_PERCEIVED_DIFFICULTY_VALUE = 0; private final UUIDGenerator uuidGenerator; @@ -36,6 +37,7 @@ public QueryProblem createQueryProblem(final ClimbingGym climbingGym, final Sect .views(problem.getViews()) .isExpired(problem.getIsExpired()) .hasSolution(DEFAULT_HAS_SOLUTION) + .perceivedDifficulty(INITIAL_PERCEIVED_DIFFICULTY_VALUE) .recommendationValue(INITIAL_RECOMMENDATION_VALUE) .solutionCount(INITIAL_SOLUTION_COUNT) .isFakeRemovalDate(sector.getRemovalInfo().getIsFakeRemovalDate()) diff --git a/src/main/java/com/first/flash/climbing/problem/domain/QueryProblem.java b/src/main/java/com/first/flash/climbing/problem/domain/QueryProblem.java index 0a07df63..65583af8 100644 --- a/src/main/java/com/first/flash/climbing/problem/domain/QueryProblem.java +++ b/src/main/java/com/first/flash/climbing/problem/domain/QueryProblem.java @@ -12,6 +12,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; import lombok.ToString; @Entity @@ -42,6 +43,8 @@ public class QueryProblem { private Integer views; private Boolean isExpired; private Integer solutionCount; + @Setter + private Integer perceivedDifficulty; private Long recommendationValue; private Boolean hasSolution; private Boolean isFakeRemovalDate; @@ -79,6 +82,18 @@ public void decrementSolutionCount() { calculateRecommendationValue(); } + public void addPerceivedDifficulty(final Integer value) { + perceivedDifficulty += value; + } + + public void subtractPerceivedDifficulty(final Integer value) { + perceivedDifficulty -= value; + } + + public Boolean isHoney() { + return perceivedDifficulty < 0; + } + private void enableSolution() { if (!hasSolution) { hasSolution = true; diff --git a/src/main/java/com/first/flash/climbing/problem/domain/QueryProblemRepository.java b/src/main/java/com/first/flash/climbing/problem/domain/QueryProblemRepository.java index 6a5bb96a..b0aea189 100644 --- a/src/main/java/com/first/flash/climbing/problem/domain/QueryProblemRepository.java +++ b/src/main/java/com/first/flash/climbing/problem/domain/QueryProblemRepository.java @@ -15,7 +15,7 @@ public interface QueryProblemRepository { List findAll(final ProblemCursor preProblemCursor, final ProblemSortBy problemSortBy, final int size, final Long gymId, final List difficulty, final List sector, - final Boolean hasSolution); + final Boolean hasSolution, final Boolean isHoney); void updateRemovalDateBySectorId(final Long sectorId, final LocalDate removalDate); diff --git a/src/main/java/com/first/flash/climbing/problem/infrastructure/QueryProblemQueryDslRepository.java b/src/main/java/com/first/flash/climbing/problem/infrastructure/QueryProblemQueryDslRepository.java index d79b1abd..508292e0 100644 --- a/src/main/java/com/first/flash/climbing/problem/infrastructure/QueryProblemQueryDslRepository.java +++ b/src/main/java/com/first/flash/climbing/problem/infrastructure/QueryProblemQueryDslRepository.java @@ -25,11 +25,11 @@ public class QueryProblemQueryDslRepository { public List findAll(final ProblemCursor prevProblemCursor, final ProblemSortBy problemSortBy, final int size, final Long gymId, final List difficulty, final List sector, - final Boolean hasSolution) { + final Boolean hasSolution, final Boolean isHoney) { return queryFactory .selectFrom(queryProblem) .where(notExpired(), cursorCondition(prevProblemCursor), inGym(gymId), inSectors(sector), - inDifficulties(difficulty), hasSolution(hasSolution)) + inDifficulties(difficulty), hasSolution(hasSolution), isHoneyCondition(isHoney)) .orderBy(sortItem(problemSortBy), queryProblem.id.desc()) .limit(size) .fetch(); @@ -63,6 +63,13 @@ private BooleanExpression inGym(final Long gymId) { return queryProblem.gymId.eq(gymId); } + private BooleanExpression isHoneyCondition(final Boolean isHoney) { + if (Boolean.TRUE.equals(isHoney)) { + return queryProblem.perceivedDifficulty.lt(0); + } + return null; + } + private BooleanExpression cursorCondition(final ProblemCursor prevProblemCursor) { if (Objects.isNull(prevProblemCursor) || Objects.isNull(prevProblemCursor.cursorValue())) { return null; diff --git a/src/main/java/com/first/flash/climbing/problem/infrastructure/QueryProblemRepositoryImpl.java b/src/main/java/com/first/flash/climbing/problem/infrastructure/QueryProblemRepositoryImpl.java index 4df93ad2..b804ae28 100644 --- a/src/main/java/com/first/flash/climbing/problem/infrastructure/QueryProblemRepositoryImpl.java +++ b/src/main/java/com/first/flash/climbing/problem/infrastructure/QueryProblemRepositoryImpl.java @@ -31,9 +31,9 @@ public Optional findById(final UUID id) { @Override public List findAll(final ProblemCursor prevProblemCursor, final ProblemSortBy problemSortBy, final int size, final Long gymId, final List difficulty, final List sector, - final Boolean hasSolution) { + final Boolean hasSolution, final Boolean isHoney) { return queryProblemQueryDslRepository.findAll(prevProblemCursor, problemSortBy, size, - gymId, difficulty, sector, hasSolution); + gymId, difficulty, sector, hasSolution, isHoney); } @Override diff --git a/src/main/java/com/first/flash/climbing/problem/ui/ProblemController.java b/src/main/java/com/first/flash/climbing/problem/ui/ProblemController.java index fa9429a7..fd4a6ce5 100644 --- a/src/main/java/com/first/flash/climbing/problem/ui/ProblemController.java +++ b/src/main/java/com/first/flash/climbing/problem/ui/ProblemController.java @@ -2,8 +2,10 @@ import com.first.flash.climbing.problem.application.ProblemReadService; import com.first.flash.climbing.problem.application.ProblemsSaveService; +import com.first.flash.climbing.problem.application.ProblemsService; import com.first.flash.climbing.problem.application.dto.ProblemCreateResponseDto; import com.first.flash.climbing.problem.application.dto.ProblemDetailResponseDto; +import com.first.flash.climbing.problem.application.dto.ProblemPerceivedDifficultyRequestDto; import com.first.flash.climbing.problem.application.dto.ProblemsResponseDto; import com.first.flash.climbing.problem.domain.dto.ProblemCreateRequestDto; import io.swagger.v3.oas.annotations.Operation; @@ -20,6 +22,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -36,6 +39,7 @@ public class ProblemController { private final ProblemsSaveService problemsSaveService; private final ProblemReadService problemReadService; + private final ProblemsService problemsService; @Operation(summary = "문제 생성", description = "특정 섹터에 문제 생성") @ApiResponses(value = { @@ -78,10 +82,11 @@ public ResponseEntity findAllProblems( @RequestParam(defaultValue = DEFAULT_SIZE, required = false) final int size, @RequestParam(required = false) final List difficulty, @RequestParam(required = false) final List sector, - @RequestParam(name = "has-solution", required = false) final Boolean hasSolution) { + @RequestParam(name = "has-solution", required = false) final Boolean hasSolution, + @RequestParam(name = "is-honey", required = false) final Boolean isHoney) { return ResponseEntity.ok( problemReadService.findAll(gymId, cursor, sortBy, size, difficulty, sector, - hasSolution)); + hasSolution, isHoney)); } @Operation(summary = "문제 단건 조회", description = "특정 문제의 정보 조회") @@ -98,4 +103,24 @@ public ResponseEntity findProblemById( @PathVariable final UUID problemId) { return ResponseEntity.ok(problemReadService.viewProblems(problemId)); } + + @Operation(summary = "문제 체감 난이도 수정", description = "특정 문제의 체감 난이도 수정") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공적으로 문제 정보 수정함", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetailResponseDto.class))), + @ApiResponse(responseCode = "400", description = "유효하지 않은 요청 형식", + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "요청값 누락", value = "{\"perceivedDifficulty\": \"변경할 체감 난이도 수치는 필수입니다.\"}") + })), + @ApiResponse(responseCode = "404", description = "리소스를 찾을 수 없음", + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "문제 없음", value = "{\"error\": \"아이디가 0190c558-9063-7050-b4fc-eb421e3236b3인 문제를 찾을 수 없습니다.\"}") + })) + }) + @PatchMapping("/admin/problems/{problemId}/perceivedDifficulty") + public ResponseEntity changePerceivedDifficulty( + @PathVariable final UUID problemId, + @Valid @RequestBody final ProblemPerceivedDifficultyRequestDto requestDto) { + return ResponseEntity.ok(problemsService.setPerceivedDifficulty(problemId, requestDto.perceivedDifficulty())); + } } diff --git a/src/main/java/com/first/flash/climbing/solution/application/SolutionCommentService.java b/src/main/java/com/first/flash/climbing/solution/application/SolutionCommentService.java new file mode 100644 index 00000000..e9d48275 --- /dev/null +++ b/src/main/java/com/first/flash/climbing/solution/application/SolutionCommentService.java @@ -0,0 +1,91 @@ +package com.first.flash.climbing.solution.application; + +import com.first.flash.account.member.application.MemberService; +import com.first.flash.account.member.domain.Member; +import com.first.flash.climbing.solution.application.dto.SolutionCommentCreateRequestDto; +import com.first.flash.climbing.solution.application.dto.SolutionCommentCreateResponseDto; +import com.first.flash.climbing.solution.application.dto.SolutionCommentResponseDto; +import com.first.flash.climbing.solution.application.dto.SolutionCommentUpdateRequestDto; +import com.first.flash.climbing.solution.application.dto.SolutionCommentsResponseDto; +import com.first.flash.climbing.solution.domain.Solution; +import com.first.flash.climbing.solution.domain.SolutionComment; +import com.first.flash.climbing.solution.exception.exceptions.SolutionCommentAccessDeniedException; +import com.first.flash.climbing.solution.exception.exceptions.SolutionCommentNotFoundException; +import com.first.flash.climbing.solution.infrastructure.SolutionCommentRepository; +import com.first.flash.global.util.AuthUtil; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SolutionCommentService { + + private final MemberService memberService; + private final SolutionCommentRepository solutionCommentRepository; + private final SolutionService solutionService; + + @Transactional + public SolutionCommentCreateResponseDto createComment(final Long solutionId, + final SolutionCommentCreateRequestDto request) { + UUID id = AuthUtil.getId(); + Member member = memberService.findById(id); + + Solution solution = solutionService.findSolutionById(solutionId); + SolutionComment solutionComment = SolutionComment.of(request.content(), + member.getNickName(), member.getProfileImageUrl(), member.getId(), solution); + SolutionComment savedSolutionComment = solutionCommentRepository.save(solutionComment); + + return SolutionCommentCreateResponseDto.toDto(savedSolutionComment); + } + + public SolutionComment findById(final Long id) { + return solutionCommentRepository.findById(id) + .orElseThrow( + () -> new SolutionCommentNotFoundException(id)); + } + + public SolutionCommentsResponseDto findBySolutionId(final Long solutionId) { + List comments = solutionService.findSolutionById(solutionId) + .getComments(); + List commentsResponse = comments.stream() + .map( + SolutionCommentResponseDto::toDto) + .toList(); + return SolutionCommentsResponseDto.from(commentsResponse); + } + + @Transactional + public SolutionCommentResponseDto updateComment(final Long commentId, + final SolutionCommentUpdateRequestDto request) { + SolutionComment comment = findById(commentId); + if (!AuthUtil.isSameId(comment.getCommenterDetail().getCommenterId())) { + throw new SolutionCommentAccessDeniedException(); + } + comment.updateContent(request.content()); + return SolutionCommentResponseDto.toDto(comment); + } + + @Transactional + public void deleteComment(final Long commentId) { + SolutionComment comment = findById(commentId); + if (!AuthUtil.isSameId(comment.getCommenterDetail().getCommenterId())) { + throw new SolutionCommentAccessDeniedException(); + } + solutionCommentRepository.delete(comment); + } + + @Transactional + public void deleteByCommenterId(final UUID commenterId) { + solutionCommentRepository.deleteByCommenterId(commenterId); + } + + @Transactional + public void updateCommenterInfo(final UUID commenterId, final String nickName, + final String profileImageUrl) { + solutionCommentRepository.updateCommenterInfo(commenterId, nickName, profileImageUrl); + } +} diff --git a/src/main/java/com/first/flash/climbing/solution/application/SolutionEventHandler.java b/src/main/java/com/first/flash/climbing/solution/application/SolutionEventHandler.java index ca528e71..15a5175f 100644 --- a/src/main/java/com/first/flash/climbing/solution/application/SolutionEventHandler.java +++ b/src/main/java/com/first/flash/climbing/solution/application/SolutionEventHandler.java @@ -13,17 +13,22 @@ public class SolutionEventHandler { private final SolutionSaveService solutionSaveService; private final SolutionService solutionService; + private final SolutionCommentService solutionCommentService; @EventListener @Transactional public void updateSolutionInfo(final MemberInfoUpdatedEvent event) { solutionSaveService.updateUploaderInfo(event.getMemberId(), event.getNickName(), event.getInstagramId(), event.getProfileImageUrl()); + + solutionCommentService.updateCommenterInfo(event.getMemberId(), event.getNickName(), + event.getProfileImageUrl()); } @EventListener @Transactional public void deleteSolution(final MemberDeletedEvent event) { solutionService.deleteByUploaderId(event.getMemberId()); + solutionCommentService.deleteByCommenterId(event.getMemberId()); } } diff --git a/src/main/java/com/first/flash/climbing/solution/application/SolutionSaveService.java b/src/main/java/com/first/flash/climbing/solution/application/SolutionSaveService.java index 341e5cee..a3eeb401 100644 --- a/src/main/java/com/first/flash/climbing/solution/application/SolutionSaveService.java +++ b/src/main/java/com/first/flash/climbing/solution/application/SolutionSaveService.java @@ -2,8 +2,10 @@ import com.first.flash.account.member.application.MemberService; import com.first.flash.account.member.domain.Member; -import com.first.flash.climbing.solution.application.dto.SolutionResponseDto; +import com.first.flash.climbing.solution.application.dto.SolutionWriteResponseDto; import com.first.flash.climbing.solution.application.dto.UnregisteredMemberSolutionCreateRequest; +import com.first.flash.climbing.solution.domain.PerceivedDifficulty; +import com.first.flash.climbing.solution.domain.PerceivedDifficultySetEvent; import com.first.flash.climbing.solution.domain.Solution; import com.first.flash.climbing.solution.domain.SolutionRepository; import com.first.flash.climbing.solution.domain.SolutionSavedEvent; @@ -24,17 +26,20 @@ public class SolutionSaveService { private final SolutionRepository solutionRepository; @Transactional - public SolutionResponseDto saveSolution(final UUID problemId, + public SolutionWriteResponseDto saveSolution(final UUID problemId, final SolutionCreateRequestDto createRequestDto) { UUID id = AuthUtil.getId(); Member member = memberService.findById(id); + PerceivedDifficulty perceivedDifficulty = createRequestDto.perceivedDifficulty(); Solution solution = Solution.of(member.getNickName(), createRequestDto.review(), member.getInstagramId(), createRequestDto.videoUrl(), problemId, member.getId(), - member.getProfileImageUrl()); + member.getProfileImageUrl(), perceivedDifficulty); + Events.raise(PerceivedDifficultySetEvent.of(solution.getProblemId(), perceivedDifficulty.getValue())); + Solution savedSolution = solutionRepository.save(solution); Events.raise(SolutionSavedEvent.of(savedSolution.getProblemId())); - return SolutionResponseDto.toDto(savedSolution); + return SolutionWriteResponseDto.toDto(savedSolution); } @Transactional @@ -44,16 +49,19 @@ public void updateUploaderInfo(final UUID uploaderId, final String nickName, } @Transactional - public SolutionResponseDto saveUnregisteredMemberSolution(final UUID problemId, + public SolutionWriteResponseDto saveUnregisteredMemberSolution(final UUID problemId, final UnregisteredMemberSolutionCreateRequest requestDto) { UUID id = AuthUtil.getId(); Member member = memberService.findById(id); + PerceivedDifficulty perceivedDifficulty = requestDto.perceivedDifficulty(); Solution solution = Solution.of(requestDto.nickName(), requestDto.review(), requestDto.instagramId(), requestDto.videoUrl(), problemId, member.getId(), - requestDto.profileImageUrl()); + requestDto.profileImageUrl(), perceivedDifficulty); + Solution savedSolution = solutionRepository.save(solution); + Events.raise(PerceivedDifficultySetEvent.of(solution.getProblemId(), perceivedDifficulty.getValue())); Events.raise(SolutionSavedEvent.of(savedSolution.getProblemId())); - return SolutionResponseDto.toDto(savedSolution); + return SolutionWriteResponseDto.toDto(savedSolution); } } diff --git a/src/main/java/com/first/flash/climbing/solution/application/SolutionService.java b/src/main/java/com/first/flash/climbing/solution/application/SolutionService.java index c5228c9f..630afc0e 100644 --- a/src/main/java/com/first/flash/climbing/solution/application/SolutionService.java +++ b/src/main/java/com/first/flash/climbing/solution/application/SolutionService.java @@ -3,14 +3,16 @@ import com.first.flash.account.member.application.BlockService; import com.first.flash.climbing.gym.domian.ClimbingGymIdConfirmRequestedEvent; import com.first.flash.climbing.problem.domain.ProblemIdConfirmRequestedEvent; -import com.first.flash.climbing.solution.application.dto.MySolutionsResponseDto; -import com.first.flash.climbing.solution.application.dto.SolutionResponseDto; import com.first.flash.climbing.solution.application.dto.SolutionUpdateRequestDto; +import com.first.flash.climbing.solution.application.dto.SolutionWriteResponseDto; import com.first.flash.climbing.solution.application.dto.SolutionsPageResponseDto; import com.first.flash.climbing.solution.application.dto.SolutionsResponseDto; +import com.first.flash.climbing.solution.domain.PerceivedDifficulty; +import com.first.flash.climbing.solution.domain.PerceivedDifficultySetEvent; import com.first.flash.climbing.solution.domain.Solution; import com.first.flash.climbing.solution.domain.SolutionDeletedEvent; import com.first.flash.climbing.solution.domain.SolutionRepository; +import com.first.flash.climbing.solution.domain.dto.SolutionResponseDto; import com.first.flash.climbing.solution.exception.exceptions.SolutionAccessDeniedException; import com.first.flash.climbing.solution.exception.exceptions.SolutionNotFoundException; import com.first.flash.climbing.solution.infrastructure.dto.DetailSolutionDto; @@ -46,11 +48,9 @@ public SolutionsResponseDto findAllSolutionsByProblemId(final UUID problemId) { Events.raise(ProblemIdConfirmRequestedEvent.of(problemId)); List blockedMembers = blockService.findBlockedMembers(); List solutions = solutionRepository.findAllByProblemId(problemId, - blockedMembers) - .stream() - .map(SolutionResponseDto::toDto) + blockedMembers).stream() + .map(SolutionResponseDto::from) .toList(); - return SolutionsResponseDto.of(solutions); } @@ -68,28 +68,36 @@ public SolutionsPageResponseDto findMySolutions(final String cursor, final int s } @Transactional - public SolutionResponseDto updateContent(final Long id, + public SolutionWriteResponseDto updateContent(final Long id, final SolutionUpdateRequestDto requestDto) { Solution solution = solutionRepository.findById(id) .orElseThrow(() -> new SolutionNotFoundException(id)); + validateUploader(solution); - UUID uploaderId = solution.getUploaderDetail().getUploaderId(); - validateUploader(uploaderId); + PerceivedDifficulty newPerceivedDifficulty = requestDto.perceivedDifficulty(); + PerceivedDifficulty oldPerceivedDifficulty = solution.getSolutionDetail().getPerceivedDifficulty(); + int difficultyDifference = newPerceivedDifficulty.calculateDifferenceFrom(oldPerceivedDifficulty); + + solution.updateContentInfo(requestDto.review(), requestDto.videoUrl(), newPerceivedDifficulty); - solution.updateContentInfo(requestDto.review(), requestDto.videoUrl()); + Events.raise(PerceivedDifficultySetEvent.of( + solution.getProblemId(), + difficultyDifference + )); - return SolutionResponseDto.toDto(solution); + return SolutionWriteResponseDto.toDto(solution); } @Transactional public void deleteSolution(final Long id) { Solution solution = solutionRepository.findById(id) .orElseThrow(() -> new SolutionNotFoundException(id)); - UUID uploaderId = solution.getUploaderDetail().getUploaderId(); - validateUploader(uploaderId); + validateUploader(solution); solutionRepository.deleteById(id); - Events.raise(SolutionDeletedEvent.of(solution.getProblemId())); + + PerceivedDifficulty perceivedDifficulty = solution.getSolutionDetail().getPerceivedDifficulty(); + Events.raise(SolutionDeletedEvent.of(solution.getProblemId(), perceivedDifficulty.getValue())); } @Transactional @@ -110,7 +118,8 @@ private boolean hasNextCursor(final int size, final List solution return solutions.size() != size; } - private void validateUploader(final UUID uploaderId) { + private void validateUploader(final Solution solution) { + UUID uploaderId = solution.getUploaderDetail().getUploaderId(); if (!AuthUtil.isSameId(uploaderId)) { throw new SolutionAccessDeniedException(); } diff --git a/src/main/java/com/first/flash/climbing/solution/application/dto/SolutionCommentCreateRequestDto.java b/src/main/java/com/first/flash/climbing/solution/application/dto/SolutionCommentCreateRequestDto.java new file mode 100644 index 00000000..3f08064b --- /dev/null +++ b/src/main/java/com/first/flash/climbing/solution/application/dto/SolutionCommentCreateRequestDto.java @@ -0,0 +1,7 @@ +package com.first.flash.climbing.solution.application.dto; + +import jakarta.validation.constraints.NotEmpty; + +public record SolutionCommentCreateRequestDto(@NotEmpty String content) { + +} diff --git a/src/main/java/com/first/flash/climbing/solution/application/dto/SolutionCommentCreateResponseDto.java b/src/main/java/com/first/flash/climbing/solution/application/dto/SolutionCommentCreateResponseDto.java new file mode 100644 index 00000000..799979d0 --- /dev/null +++ b/src/main/java/com/first/flash/climbing/solution/application/dto/SolutionCommentCreateResponseDto.java @@ -0,0 +1,16 @@ +package com.first.flash.climbing.solution.application.dto; + +import com.first.flash.climbing.solution.domain.SolutionComment; +import java.util.UUID; + +public record SolutionCommentCreateResponseDto(Long id, String content, UUID commenterId, + String nickName, String profileImageUrl) { + + public static SolutionCommentCreateResponseDto toDto( + final SolutionComment comment) { + return new SolutionCommentCreateResponseDto(comment.getId(), comment.getContent(), + comment.getCommenterDetail().getCommenterId(), + comment.getCommenterDetail().getCommenter(), + comment.getCommenterDetail().getProfileImageUrl()); + } +} diff --git a/src/main/java/com/first/flash/climbing/solution/application/dto/SolutionCommentResponseDto.java b/src/main/java/com/first/flash/climbing/solution/application/dto/SolutionCommentResponseDto.java new file mode 100644 index 00000000..22e8e904 --- /dev/null +++ b/src/main/java/com/first/flash/climbing/solution/application/dto/SolutionCommentResponseDto.java @@ -0,0 +1,18 @@ +package com.first.flash.climbing.solution.application.dto; + +import com.first.flash.climbing.solution.domain.SolutionComment; +import com.first.flash.global.util.AuthUtil; +import java.util.UUID; + +public record SolutionCommentResponseDto(Long id, String content, UUID commenterId, String nickName, + String profileImageUrl, boolean isMine) { + + public static SolutionCommentResponseDto toDto(final SolutionComment solutionComment) { + return new SolutionCommentResponseDto(solutionComment.getId(), solutionComment.getContent(), + solutionComment.getCommenterDetail().getCommenterId(), + solutionComment.getCommenterDetail().getCommenter(), + solutionComment.getCommenterDetail().getProfileImageUrl(), + solutionComment.getCommenterDetail().getCommenterId().equals(AuthUtil.getId()) + ); + } +} diff --git a/src/main/java/com/first/flash/climbing/solution/application/dto/SolutionCommentUpdateRequestDto.java b/src/main/java/com/first/flash/climbing/solution/application/dto/SolutionCommentUpdateRequestDto.java new file mode 100644 index 00000000..8e1b94dc --- /dev/null +++ b/src/main/java/com/first/flash/climbing/solution/application/dto/SolutionCommentUpdateRequestDto.java @@ -0,0 +1,7 @@ +package com.first.flash.climbing.solution.application.dto; + +import jakarta.validation.constraints.NotNull; + +public record SolutionCommentUpdateRequestDto(@NotNull String content) { + +} diff --git a/src/main/java/com/first/flash/climbing/solution/application/dto/SolutionCommentsResponseDto.java b/src/main/java/com/first/flash/climbing/solution/application/dto/SolutionCommentsResponseDto.java new file mode 100644 index 00000000..b53dc0df --- /dev/null +++ b/src/main/java/com/first/flash/climbing/solution/application/dto/SolutionCommentsResponseDto.java @@ -0,0 +1,11 @@ +package com.first.flash.climbing.solution.application.dto; + +import java.util.List; + +public record SolutionCommentsResponseDto(List comments) { + + public static SolutionCommentsResponseDto from( + final List commentsResponse) { + return new SolutionCommentsResponseDto(commentsResponse); + } +} diff --git a/src/main/java/com/first/flash/climbing/solution/application/dto/SolutionUpdateRequestDto.java b/src/main/java/com/first/flash/climbing/solution/application/dto/SolutionUpdateRequestDto.java index b8a7fa44..427c2c5e 100644 --- a/src/main/java/com/first/flash/climbing/solution/application/dto/SolutionUpdateRequestDto.java +++ b/src/main/java/com/first/flash/climbing/solution/application/dto/SolutionUpdateRequestDto.java @@ -1,10 +1,15 @@ package com.first.flash.climbing.solution.application.dto; +import com.first.flash.climbing.solution.domain.PerceivedDifficulty; +import com.first.flash.global.annotation.ValidEnum; import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; public record SolutionUpdateRequestDto( @NotEmpty(message = "비디오 URL은 필수입니다.") String videoUrl, - @Size(max = 500, message = "리뷰는 최대 500자까지 가능합니다.") String review) { + @Size(max = 500, message = "리뷰는 최대 500자까지 가능합니다.") String review, + @NotNull(message = "체감 난이도는 필수입니다.") + @ValidEnum(enumClass = PerceivedDifficulty.class) PerceivedDifficulty perceivedDifficulty) { } diff --git a/src/main/java/com/first/flash/climbing/solution/application/dto/SolutionResponseDto.java b/src/main/java/com/first/flash/climbing/solution/application/dto/SolutionWriteResponseDto.java similarity index 65% rename from src/main/java/com/first/flash/climbing/solution/application/dto/SolutionResponseDto.java rename to src/main/java/com/first/flash/climbing/solution/application/dto/SolutionWriteResponseDto.java index cfebd4af..b6c7360d 100644 --- a/src/main/java/com/first/flash/climbing/solution/application/dto/SolutionResponseDto.java +++ b/src/main/java/com/first/flash/climbing/solution/application/dto/SolutionWriteResponseDto.java @@ -6,18 +6,17 @@ import com.first.flash.global.util.AuthUtil; import java.util.UUID; -public record SolutionResponseDto(Long id, String uploader, String review, String instagramId, - String videoUrl, UUID uploaderId, Boolean isUploader, - String profileImageUrl) { +public record SolutionWriteResponseDto(Long id, String uploader, String review, String instagramId, + String videoUrl, UUID uploaderId, Boolean isUploader, + String profileImageUrl) { - public static SolutionResponseDto toDto(final Solution solution) { + public static SolutionWriteResponseDto toDto(final Solution solution) { SolutionDetail solutionDetail = solution.getSolutionDetail(); UploaderDetail uploaderDetail = solution.getUploaderDetail(); - UUID uploaderId = uploaderDetail.getUploaderId(); Boolean isUploader = AuthUtil.isSameId(uploaderId); - return new SolutionResponseDto(solution.getId(), uploaderDetail.getUploader(), + return new SolutionWriteResponseDto(solution.getId(), uploaderDetail.getUploader(), solutionDetail.getReview(), uploaderDetail.getInstagramId(), solutionDetail.getVideoUrl(), uploaderDetail.getUploaderId(), isUploader, uploaderDetail.getProfileImageUrl()); diff --git a/src/main/java/com/first/flash/climbing/solution/application/dto/SolutionsResponseDto.java b/src/main/java/com/first/flash/climbing/solution/application/dto/SolutionsResponseDto.java index 70bab455..785c58e9 100644 --- a/src/main/java/com/first/flash/climbing/solution/application/dto/SolutionsResponseDto.java +++ b/src/main/java/com/first/flash/climbing/solution/application/dto/SolutionsResponseDto.java @@ -1,5 +1,6 @@ package com.first.flash.climbing.solution.application.dto; +import com.first.flash.climbing.solution.domain.dto.SolutionResponseDto; import java.util.List; public record SolutionsResponseDto(List solutions, diff --git a/src/main/java/com/first/flash/climbing/solution/application/dto/UnregisteredMemberSolutionCreateRequest.java b/src/main/java/com/first/flash/climbing/solution/application/dto/UnregisteredMemberSolutionCreateRequest.java index f339856f..ead35334 100644 --- a/src/main/java/com/first/flash/climbing/solution/application/dto/UnregisteredMemberSolutionCreateRequest.java +++ b/src/main/java/com/first/flash/climbing/solution/application/dto/UnregisteredMemberSolutionCreateRequest.java @@ -1,11 +1,16 @@ package com.first.flash.climbing.solution.application.dto; +import com.first.flash.climbing.solution.domain.PerceivedDifficulty; +import com.first.flash.global.annotation.ValidEnum; import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; public record UnregisteredMemberSolutionCreateRequest( @NotEmpty(message = "닉네임은 필수입니다.") String nickName, @NotEmpty(message = "인스타그램 아이디는 필수입니다.") String instagramId, String review, String profileImageUrl, - @NotEmpty(message = "비디오 URL은 필수입니다.") String videoUrl) { + @NotEmpty(message = "비디오 URL은 필수입니다.") String videoUrl, + @NotNull(message = "체감 난이도는 필수입니다.") + @ValidEnum(enumClass = PerceivedDifficulty.class) PerceivedDifficulty perceivedDifficulty) { } diff --git a/src/main/java/com/first/flash/climbing/solution/domain/CommenterDetail.java b/src/main/java/com/first/flash/climbing/solution/domain/CommenterDetail.java new file mode 100644 index 00000000..d577e23a --- /dev/null +++ b/src/main/java/com/first/flash/climbing/solution/domain/CommenterDetail.java @@ -0,0 +1,32 @@ +package com.first.flash.climbing.solution.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import java.util.UUID; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@Embeddable +@NoArgsConstructor +@EqualsAndHashCode +@Getter +@ToString +public class CommenterDetail { + + @Column(columnDefinition = "BINARY(16)") + private UUID commenterId; + private String commenter; + private String profileImageUrl; + + protected CommenterDetail(final UUID commenterId, final String commenter, final String profileImageUrl) { + this.commenterId = commenterId; + this.commenter = commenter; + this.profileImageUrl = profileImageUrl; + } + + public static CommenterDetail of(final UUID commenterId, final String commenter, final String profileImageUrl) { + return new CommenterDetail(commenterId, commenter, profileImageUrl); + } +} diff --git a/src/main/java/com/first/flash/climbing/solution/domain/PerceivedDifficulty.java b/src/main/java/com/first/flash/climbing/solution/domain/PerceivedDifficulty.java new file mode 100644 index 00000000..8dbd5c77 --- /dev/null +++ b/src/main/java/com/first/flash/climbing/solution/domain/PerceivedDifficulty.java @@ -0,0 +1,47 @@ +package com.first.flash.climbing.solution.domain; + +import com.first.flash.climbing.solution.exception.exceptions.PerceivedDifficultyNotFoundException; +import lombok.AllArgsConstructor; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum PerceivedDifficulty { + EASY(-1, "쉬움"), + NORMAL(0, "보통"), + HARD(1, "어려움"); + + private final Integer value; + private final String label; + + @JsonValue + public String getLabel() { + return label; + } + + @JsonCreator + public static PerceivedDifficulty fromString(final String label) { + for (PerceivedDifficulty difficulty : values()) { + if (difficulty.label.equals(label)) { + return difficulty; + } + } + throw new PerceivedDifficultyNotFoundException(label); + } + + public static PerceivedDifficulty fromValue(int value) { + for (PerceivedDifficulty difficulty : values()) { + if (difficulty.value == value) { + return difficulty; + } + } + throw new IllegalArgumentException("Unknown value: " + value); + } + + public int calculateDifferenceFrom(PerceivedDifficulty other) { + return this.value - other.value; + } +} \ No newline at end of file diff --git a/src/main/java/com/first/flash/climbing/solution/domain/PerceivedDifficultyConverter.java b/src/main/java/com/first/flash/climbing/solution/domain/PerceivedDifficultyConverter.java new file mode 100644 index 00000000..71f39e01 --- /dev/null +++ b/src/main/java/com/first/flash/climbing/solution/domain/PerceivedDifficultyConverter.java @@ -0,0 +1,18 @@ +package com.first.flash.climbing.solution.domain; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = true) +public class PerceivedDifficultyConverter implements AttributeConverter { + + @Override + public Integer convertToDatabaseColumn(PerceivedDifficulty attribute) { + return attribute != null ? attribute.getValue() : null; + } + + @Override + public PerceivedDifficulty convertToEntityAttribute(Integer dbData) { + return dbData != null ? PerceivedDifficulty.fromValue(dbData) : null; + } +} diff --git a/src/main/java/com/first/flash/climbing/solution/domain/PerceivedDifficultySetEvent.java b/src/main/java/com/first/flash/climbing/solution/domain/PerceivedDifficultySetEvent.java new file mode 100644 index 00000000..3d3402e5 --- /dev/null +++ b/src/main/java/com/first/flash/climbing/solution/domain/PerceivedDifficultySetEvent.java @@ -0,0 +1,18 @@ +package com.first.flash.climbing.solution.domain; + +import com.first.flash.global.event.Event; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class PerceivedDifficultySetEvent extends Event { + + private UUID problemId; + private Integer perceivedDifficulty; + + public static PerceivedDifficultySetEvent of(final UUID problemId, final Integer perceivedDifficulty) { + return new PerceivedDifficultySetEvent(problemId, perceivedDifficulty); + } +} diff --git a/src/main/java/com/first/flash/climbing/solution/domain/Solution.java b/src/main/java/com/first/flash/climbing/solution/domain/Solution.java index f8d67812..bf0e43ce 100644 --- a/src/main/java/com/first/flash/climbing/solution/domain/Solution.java +++ b/src/main/java/com/first/flash/climbing/solution/domain/Solution.java @@ -3,11 +3,15 @@ import com.first.flash.climbing.solution.domain.vo.SolutionDetail; import com.first.flash.climbing.solution.domain.vo.UploaderDetail; import com.first.flash.global.domain.BaseEntity; +import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import java.util.ArrayList; +import java.util.List; import java.util.UUID; import lombok.AllArgsConstructor; import lombok.Getter; @@ -31,12 +35,15 @@ public class Solution extends BaseEntity { private SolutionDetail solutionDetail; private UploaderDetail uploaderDetail; private Long optionalWeight; + @OneToMany(mappedBy = "solution", cascade = CascadeType.ALL, orphanRemoval = true) + @ToString.Exclude + private List comments = new ArrayList<>(); protected Solution(final String uploader, final String review, final String instagramId, final String videoUrl, final UUID problemId, final UUID uploaderId, - final String profileImageUrl) { + final String profileImageUrl, final PerceivedDifficulty perceivedDifficulty) { - this.solutionDetail = SolutionDetail.of(review, videoUrl); + this.solutionDetail = SolutionDetail.of(review, videoUrl, perceivedDifficulty); this.uploaderDetail = UploaderDetail.of(uploaderId, uploader, instagramId, profileImageUrl); this.optionalWeight = DEFAULT_OPTIONAL_WEIGHT; this.problemId = problemId; @@ -44,10 +51,10 @@ protected Solution(final String uploader, final String review, final String inst public static Solution of(final String uploader, final String review, final String instagramId, final String videoUrl, final UUID problemId, final UUID uploaderId, - final String profileImageUrl) { + final String profileImageUrl, final PerceivedDifficulty perceivedDifficulty) { return new Solution(uploader, review, instagramId, videoUrl, problemId, uploaderId, - profileImageUrl); + profileImageUrl, perceivedDifficulty); } public void updateUploaderInfo(final String uploader, final String instagramId, @@ -57,7 +64,7 @@ public void updateUploaderInfo(final String uploader, final String instagramId, this.uploaderDetail = UploaderDetail.of(uploaderId, uploader, instagramId, profileImageUrl); } - public void updateContentInfo(final String review, final String videoUrl) { - this.solutionDetail = SolutionDetail.of(review, videoUrl); + public void updateContentInfo(final String review, final String videoUrl, final PerceivedDifficulty perceivedDifficulty) { + this.solutionDetail = SolutionDetail.of(review, videoUrl, perceivedDifficulty); } } diff --git a/src/main/java/com/first/flash/climbing/solution/domain/SolutionComment.java b/src/main/java/com/first/flash/climbing/solution/domain/SolutionComment.java new file mode 100644 index 00000000..5b48e4a6 --- /dev/null +++ b/src/main/java/com/first/flash/climbing/solution/domain/SolutionComment.java @@ -0,0 +1,51 @@ +package com.first.flash.climbing.solution.domain; + +import com.first.flash.global.domain.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 java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@Entity +@NoArgsConstructor +@AllArgsConstructor +@Getter +@ToString +public class SolutionComment extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column + private String content; + + private CommenterDetail commenterDetail; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "solution_id", nullable = false) + private Solution solution; + + protected SolutionComment(final String content, final CommenterDetail commenter, final Solution solution) { + this.content = content; + this.commenterDetail = commenter; + this.solution = solution; + } + + public static SolutionComment of(final String content, final String commenter, final String profileImage, final UUID commenterId, final Solution solution) { + return new SolutionComment(content, CommenterDetail.of(commenterId, commenter, profileImage), solution); + } + + public void updateContent(final String content) { + this.content = content; + } +} diff --git a/src/main/java/com/first/flash/climbing/solution/domain/SolutionDeletedEvent.java b/src/main/java/com/first/flash/climbing/solution/domain/SolutionDeletedEvent.java index 0882f03d..74d27d8f 100644 --- a/src/main/java/com/first/flash/climbing/solution/domain/SolutionDeletedEvent.java +++ b/src/main/java/com/first/flash/climbing/solution/domain/SolutionDeletedEvent.java @@ -10,9 +10,10 @@ public class SolutionDeletedEvent extends Event { private UUID problemId; + private Integer perceivedDifficulty; - public static SolutionDeletedEvent of(final UUID problemId) { - return new SolutionDeletedEvent(problemId); + public static SolutionDeletedEvent of(final UUID problemId, final Integer perceivedDifficulty) { + return new SolutionDeletedEvent(problemId, perceivedDifficulty); } } diff --git a/src/main/java/com/first/flash/climbing/solution/domain/SolutionRepository.java b/src/main/java/com/first/flash/climbing/solution/domain/SolutionRepository.java index 5c622cb8..9c2611af 100644 --- a/src/main/java/com/first/flash/climbing/solution/domain/SolutionRepository.java +++ b/src/main/java/com/first/flash/climbing/solution/domain/SolutionRepository.java @@ -2,6 +2,7 @@ import com.first.flash.climbing.solution.infrastructure.dto.DetailSolutionDto; import com.first.flash.climbing.solution.infrastructure.dto.MySolutionDto; +import com.first.flash.climbing.solution.infrastructure.dto.SolutionRepositoryResponseDto; import com.first.flash.climbing.solution.infrastructure.paging.SolutionCursor; import java.util.List; import java.util.Optional; @@ -13,7 +14,8 @@ public interface SolutionRepository { Optional findById(final Long id); - List findAllByProblemId(final UUID problemId, final List blockedMembers); + List findAllByProblemId(final UUID problemId, + final List blockedMembers); void deleteById(final Long id); diff --git a/src/main/java/com/first/flash/climbing/solution/domain/dto/SolutionCreateRequestDto.java b/src/main/java/com/first/flash/climbing/solution/domain/dto/SolutionCreateRequestDto.java index e46f087a..621ea90b 100644 --- a/src/main/java/com/first/flash/climbing/solution/domain/dto/SolutionCreateRequestDto.java +++ b/src/main/java/com/first/flash/climbing/solution/domain/dto/SolutionCreateRequestDto.java @@ -1,10 +1,18 @@ package com.first.flash.climbing.solution.domain.dto; +import com.first.flash.climbing.solution.domain.PerceivedDifficulty; +import com.first.flash.global.annotation.ValidEnum; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.Size; public record SolutionCreateRequestDto( @NotEmpty(message = "비디오 URL은 필수입니다.") String videoUrl, - @Size(max = 500, message = "리뷰는 최대 500자까지 가능합니다.") String review) { + @Size(max = 500, message = "리뷰는 최대 500자까지 가능합니다.") String review, + @ValidEnum(enumClass = PerceivedDifficulty.class) PerceivedDifficulty perceivedDifficulty) { + public SolutionCreateRequestDto(final String videoUrl, final String review, final PerceivedDifficulty perceivedDifficulty) { + this.videoUrl = videoUrl; + this.review = review; + this.perceivedDifficulty = perceivedDifficulty != null ? perceivedDifficulty : PerceivedDifficulty.NORMAL; + } } diff --git a/src/main/java/com/first/flash/climbing/solution/domain/dto/SolutionResponseDto.java b/src/main/java/com/first/flash/climbing/solution/domain/dto/SolutionResponseDto.java new file mode 100644 index 00000000..2e9c3b8e --- /dev/null +++ b/src/main/java/com/first/flash/climbing/solution/domain/dto/SolutionResponseDto.java @@ -0,0 +1,19 @@ +package com.first.flash.climbing.solution.domain.dto; + +import com.first.flash.climbing.solution.infrastructure.dto.SolutionRepositoryResponseDto; +import com.first.flash.global.util.AuthUtil; +import java.util.UUID; + +public record SolutionResponseDto(Long id, String uploader, String review, String instagramId, + String videoUrl, UUID uploaderId, Boolean isUploader, + String profileImageUrl, Long commentCount) { + + public static SolutionResponseDto from( + final SolutionRepositoryResponseDto repositoryResponseDto) { + return new SolutionResponseDto(repositoryResponseDto.id(), repositoryResponseDto.uploader(), + repositoryResponseDto.review(), repositoryResponseDto.instagramId(), + repositoryResponseDto.videoUrl(), repositoryResponseDto.uploaderId(), + AuthUtil.isSameId(repositoryResponseDto.uploaderId()), + repositoryResponseDto.profileImageUrl(), repositoryResponseDto.commentCount()); + } +} diff --git a/src/main/java/com/first/flash/climbing/solution/domain/vo/SolutionDetail.java b/src/main/java/com/first/flash/climbing/solution/domain/vo/SolutionDetail.java index 0d464125..6fba659d 100644 --- a/src/main/java/com/first/flash/climbing/solution/domain/vo/SolutionDetail.java +++ b/src/main/java/com/first/flash/climbing/solution/domain/vo/SolutionDetail.java @@ -1,5 +1,8 @@ package com.first.flash.climbing.solution.domain.vo; +import com.first.flash.climbing.solution.domain.PerceivedDifficulty; +import com.first.flash.climbing.solution.domain.PerceivedDifficultyConverter; +import jakarta.persistence.Convert; import jakarta.persistence.Embeddable; import lombok.EqualsAndHashCode; import lombok.Getter; @@ -15,13 +18,16 @@ public class SolutionDetail { private String review; private String videoUrl; + @Convert(converter = PerceivedDifficultyConverter.class) + private PerceivedDifficulty perceivedDifficulty; - protected SolutionDetail(final String review, final String videoUrl) { + protected SolutionDetail(final String review, final String videoUrl, final PerceivedDifficulty perceivedDifficulty) { this.review = review; this.videoUrl = videoUrl; + this.perceivedDifficulty = perceivedDifficulty; } - public static SolutionDetail of(final String review, final String videoUrl) { - return new SolutionDetail(review, videoUrl); + public static SolutionDetail of(final String review, final String videoUrl, final PerceivedDifficulty perceivedDifficulty) { + return new SolutionDetail(review, videoUrl, perceivedDifficulty); } } diff --git a/src/main/java/com/first/flash/climbing/solution/exception/SolutionExceptionHandler.java b/src/main/java/com/first/flash/climbing/solution/exception/SolutionExceptionHandler.java index 535c6bd1..5588019a 100644 --- a/src/main/java/com/first/flash/climbing/solution/exception/SolutionExceptionHandler.java +++ b/src/main/java/com/first/flash/climbing/solution/exception/SolutionExceptionHandler.java @@ -1,6 +1,9 @@ package com.first.flash.climbing.solution.exception; +import com.first.flash.climbing.solution.exception.exceptions.PerceivedDifficultyNotFoundException; import com.first.flash.climbing.solution.exception.exceptions.SolutionAccessDeniedException; +import com.first.flash.climbing.solution.exception.exceptions.SolutionCommentAccessDeniedException; +import com.first.flash.climbing.solution.exception.exceptions.SolutionCommentNotFoundException; import com.first.flash.climbing.solution.exception.exceptions.SolutionNotFoundException; import com.first.flash.global.dto.ErrorResponseDto; import org.springframework.http.HttpStatus; @@ -14,16 +17,38 @@ public class SolutionExceptionHandler { @ExceptionHandler(SolutionNotFoundException.class) public ResponseEntity handleSolutionNotFoundException( final SolutionNotFoundException exception) { - ErrorResponseDto errorResponse = new ErrorResponseDto(exception.getMessage()); - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(errorResponse); + return getResponseWithStatus(HttpStatus.NOT_FOUND, exception); } @ExceptionHandler(SolutionAccessDeniedException.class) public ResponseEntity handleSolutionAccessDeniedException( final SolutionAccessDeniedException exception) { + return getResponseWithStatus(HttpStatus.FORBIDDEN, exception); + } + + @ExceptionHandler(SolutionCommentAccessDeniedException.class) + public ResponseEntity handleSolutionCommentAccessDeniedException( + final SolutionCommentAccessDeniedException exception) { + return getResponseWithStatus(HttpStatus.FORBIDDEN, exception); + } + + @ExceptionHandler(SolutionCommentNotFoundException.class) + public ResponseEntity handleSolutionCommentNotFoundException( + final SolutionCommentNotFoundException exception) { + return getResponseWithStatus(HttpStatus.NOT_FOUND, exception); + } + + @ExceptionHandler(PerceivedDifficultyNotFoundException.class) + public ResponseEntity handlePerceivedDifficultyNotFoundException( + final PerceivedDifficultyNotFoundException exception) { + return getResponseWithStatus(HttpStatus.NOT_FOUND, exception); + } + + private ResponseEntity getResponseWithStatus(final HttpStatus httpStatus, + final RuntimeException exception) { ErrorResponseDto errorResponse = new ErrorResponseDto(exception.getMessage()); - return ResponseEntity.status(HttpStatus.FORBIDDEN) + return ResponseEntity.status(httpStatus) .body(errorResponse); } + } diff --git a/src/main/java/com/first/flash/climbing/solution/exception/exceptions/PerceivedDifficultyNotFoundException.java b/src/main/java/com/first/flash/climbing/solution/exception/exceptions/PerceivedDifficultyNotFoundException.java new file mode 100644 index 00000000..6cf3426a --- /dev/null +++ b/src/main/java/com/first/flash/climbing/solution/exception/exceptions/PerceivedDifficultyNotFoundException.java @@ -0,0 +1,8 @@ +package com.first.flash.climbing.solution.exception.exceptions; + +public class PerceivedDifficultyNotFoundException extends RuntimeException { + + public PerceivedDifficultyNotFoundException(final String label) { + super(String.format("해당하는 체감 난이도가 없습니다: %s", label)); + } +} \ No newline at end of file diff --git a/src/main/java/com/first/flash/climbing/solution/exception/exceptions/SolutionCommentAccessDeniedException.java b/src/main/java/com/first/flash/climbing/solution/exception/exceptions/SolutionCommentAccessDeniedException.java new file mode 100644 index 00000000..b4a2af09 --- /dev/null +++ b/src/main/java/com/first/flash/climbing/solution/exception/exceptions/SolutionCommentAccessDeniedException.java @@ -0,0 +1,8 @@ +package com.first.flash.climbing.solution.exception.exceptions; + +public class SolutionCommentAccessDeniedException extends RuntimeException { + + public SolutionCommentAccessDeniedException() { + super("해당 댓글에 접근할 권한이 없습니다."); + } +} diff --git a/src/main/java/com/first/flash/climbing/solution/exception/exceptions/SolutionCommentNotFoundException.java b/src/main/java/com/first/flash/climbing/solution/exception/exceptions/SolutionCommentNotFoundException.java new file mode 100644 index 00000000..882bf258 --- /dev/null +++ b/src/main/java/com/first/flash/climbing/solution/exception/exceptions/SolutionCommentNotFoundException.java @@ -0,0 +1,8 @@ +package com.first.flash.climbing.solution.exception.exceptions; + +public class SolutionCommentNotFoundException extends RuntimeException { + + public SolutionCommentNotFoundException(final Long id) { + super(String.format("아이디가 %s인 댓글을 찾을 수 없습니다.", id)); + } +} diff --git a/src/main/java/com/first/flash/climbing/solution/infrastructure/SolutionCommentJpaRepository.java b/src/main/java/com/first/flash/climbing/solution/infrastructure/SolutionCommentJpaRepository.java new file mode 100644 index 00000000..57aed8b6 --- /dev/null +++ b/src/main/java/com/first/flash/climbing/solution/infrastructure/SolutionCommentJpaRepository.java @@ -0,0 +1,17 @@ +package com.first.flash.climbing.solution.infrastructure; + +import com.first.flash.climbing.solution.domain.SolutionComment; +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SolutionCommentJpaRepository extends JpaRepository { + + SolutionComment save(final SolutionComment solutionComment); + + List findBySolutionId(final Long solutionId); + + void deleteByCommenterDetailCommenterId(final UUID commenterId); + + void delete(final SolutionComment comment); +} diff --git a/src/main/java/com/first/flash/climbing/solution/infrastructure/SolutionCommentQueryDslRepository.java b/src/main/java/com/first/flash/climbing/solution/infrastructure/SolutionCommentQueryDslRepository.java new file mode 100644 index 00000000..802e35db --- /dev/null +++ b/src/main/java/com/first/flash/climbing/solution/infrastructure/SolutionCommentQueryDslRepository.java @@ -0,0 +1,25 @@ +package com.first.flash.climbing.solution.infrastructure; + +import static com.first.flash.climbing.solution.domain.QSolutionComment.solutionComment; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class SolutionCommentQueryDslRepository { + + private final JPAQueryFactory jpaQueryFactory; + + + public void updateCommenterInfo(final UUID commenterId, final String nickName, + final String profileImageUrl) { + jpaQueryFactory.update(solutionComment) + .set(solutionComment.commenterDetail.commenter, nickName) + .set(solutionComment.commenterDetail.profileImageUrl, profileImageUrl) + .where(solutionComment.commenterDetail.commenterId.eq(commenterId)) + .execute(); + } +} diff --git a/src/main/java/com/first/flash/climbing/solution/infrastructure/SolutionCommentRepository.java b/src/main/java/com/first/flash/climbing/solution/infrastructure/SolutionCommentRepository.java new file mode 100644 index 00000000..0f6eeb1c --- /dev/null +++ b/src/main/java/com/first/flash/climbing/solution/infrastructure/SolutionCommentRepository.java @@ -0,0 +1,23 @@ +package com.first.flash.climbing.solution.infrastructure; + +import com.first.flash.climbing.solution.domain.SolutionComment; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.stereotype.Repository; + +@Repository +public interface SolutionCommentRepository { + + SolutionComment save(final SolutionComment solutionComment); + + Optional findById(final Long id); + + List findBySolutionId(final Long solutionId); + + void deleteByCommenterId(final UUID commenterId); + + void updateCommenterInfo(final UUID commenterId, final String nickName, final String profileImageUrl); + + void delete(final SolutionComment comment); +} diff --git a/src/main/java/com/first/flash/climbing/solution/infrastructure/SolutionCommentRepositoryImpl.java b/src/main/java/com/first/flash/climbing/solution/infrastructure/SolutionCommentRepositoryImpl.java new file mode 100644 index 00000000..0f9976cc --- /dev/null +++ b/src/main/java/com/first/flash/climbing/solution/infrastructure/SolutionCommentRepositoryImpl.java @@ -0,0 +1,47 @@ +package com.first.flash.climbing.solution.infrastructure; + +import com.first.flash.climbing.solution.domain.SolutionComment; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class SolutionCommentRepositoryImpl implements SolutionCommentRepository { + + private final SolutionCommentJpaRepository solutionCommentJpaRepository; + private final SolutionCommentQueryDslRepository solutionCommentQueryDslRepository; + + @Override + public SolutionComment save(final SolutionComment solutionComment) { + return solutionCommentJpaRepository.save(solutionComment); + } + + @Override + public Optional findById(final Long id) { + return solutionCommentJpaRepository.findById(id); + } + + @Override + public List findBySolutionId(final Long solutionId) { + return solutionCommentJpaRepository.findBySolutionId(solutionId); + } + + @Override + public void deleteByCommenterId(final UUID commenterId) { + solutionCommentJpaRepository.deleteByCommenterDetailCommenterId(commenterId); + } + + @Override + public void updateCommenterInfo(final UUID commenterId, final String nickName, + final String profileImageUrl) { + solutionCommentQueryDslRepository.updateCommenterInfo(commenterId, nickName, profileImageUrl); + } + + @Override + public void delete(final SolutionComment comment) { + solutionCommentJpaRepository.delete(comment); + } +} diff --git a/src/main/java/com/first/flash/climbing/solution/infrastructure/SolutionQueryDslRepository.java b/src/main/java/com/first/flash/climbing/solution/infrastructure/SolutionQueryDslRepository.java index 02587a31..151c819d 100644 --- a/src/main/java/com/first/flash/climbing/solution/infrastructure/SolutionQueryDslRepository.java +++ b/src/main/java/com/first/flash/climbing/solution/infrastructure/SolutionQueryDslRepository.java @@ -2,12 +2,12 @@ import static com.first.flash.climbing.problem.domain.QQueryProblem.queryProblem; import static com.first.flash.climbing.solution.domain.QSolution.solution; +import static com.first.flash.climbing.solution.domain.QSolutionComment.solutionComment; -import com.first.flash.climbing.solution.domain.Solution; import com.first.flash.climbing.solution.infrastructure.dto.DetailSolutionDto; import com.first.flash.climbing.solution.infrastructure.dto.MySolutionDto; +import com.first.flash.climbing.solution.infrastructure.dto.SolutionRepositoryResponseDto; import com.first.flash.climbing.solution.infrastructure.paging.SolutionCursor; -import com.querydsl.core.types.Predicate; import com.querydsl.core.types.Projections; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQueryFactory; @@ -24,12 +24,21 @@ public class SolutionQueryDslRepository { private final JPAQueryFactory jpaQueryFactory; - public List findAllExcludedBlockedMembers(final UUID problemId, + public List findAllExcludedBlockedMembers(final UUID problemId, final List memberIds) { - return jpaQueryFactory.selectFrom(solution) + return jpaQueryFactory.select(Projections.constructor(SolutionRepositoryResponseDto.class, + solution.id, solution.uploaderDetail.uploader, solution.solutionDetail.review, + solution.uploaderDetail.instagramId, solution.solutionDetail.videoUrl, + solution.uploaderDetail.uploaderId, solution.uploaderDetail.profileImageUrl, + solutionComment.count() + )) + .from(solution) + .leftJoin(solutionComment) + .on(solution.id.eq(solutionComment.solution.id)) .where(solution.problemId.eq(problemId) - .and(solution.uploaderDetail.uploaderId - .notIn(memberIds))).fetch(); + .and(notInBlockedMembers(memberIds))) + .groupBy(solution.id) + .fetch(); } public List findByUploaderId(final UUID uploaderId, @@ -37,15 +46,18 @@ public List findByUploaderId(final UUID uploaderId, final List difficulty) { return jpaQueryFactory.select(Projections.constructor(MySolutionDto.class, solution.id, queryProblem.gymName, queryProblem.sectorName, - queryProblem.difficultyName, queryProblem.imageUrl, solution.createdAt + queryProblem.difficultyName, queryProblem.imageUrl, solutionComment.count(), + solution.createdAt )) .from(solution) .innerJoin(queryProblem) .on(solution.problemId.eq(queryProblem.id)) - .fetchJoin() + .leftJoin(solutionComment) + .on(solution.id.eq(solutionComment.solution.id)) .where(solution.uploaderDetail.uploaderId.eq(uploaderId), inGym(gymId), inDifficulties(difficulty), cursorCondition(prevSolutionCursor)) + .groupBy(solution.id) .orderBy(solution.createdAt.desc()) .limit(size) .fetch(); @@ -64,18 +76,21 @@ public void updateUploaderInfo(final UUID uploaderId, final String nickName, public DetailSolutionDto findDetailSolutionById(final Long solutionId) { return jpaQueryFactory.select(Projections.constructor(DetailSolutionDto.class, solution.id, solution.solutionDetail.videoUrl, queryProblem.gymName, - queryProblem.sectorName, solution.solutionDetail.review, queryProblem.difficultyName, + queryProblem.sectorName, solution.solutionDetail.review, + queryProblem.difficultyName, solutionComment.count(), solution.solutionDetail.perceivedDifficulty, queryProblem.removalDate, queryProblem.settingDate, solution.createdAt )) .from(solution) .innerJoin(queryProblem) .on(solution.problemId.eq(queryProblem.id)) + .leftJoin(solutionComment) + .on(solution.id.eq(solutionComment.solution.id)) .where(solution.id.eq(solutionId)) - .fetchJoin() + .groupBy(solution.id) .fetchOne(); } - private Predicate cursorCondition(final SolutionCursor prevSolutionCursor) { + private BooleanExpression cursorCondition(final SolutionCursor prevSolutionCursor) { if (Objects.isNull(prevSolutionCursor) || Objects.isNull(prevSolutionCursor.cursorValue())) { return null; @@ -96,4 +111,11 @@ private BooleanExpression inDifficulties(final List difficulty) { } return queryProblem.difficultyName.in(difficulty); } + + private BooleanExpression notInBlockedMembers(final List memberIds) { + if (memberIds.isEmpty()) { + return null; + } + return solution.uploaderDetail.uploaderId.notIn(memberIds); + } } diff --git a/src/main/java/com/first/flash/climbing/solution/infrastructure/SolutionRepositoryImpl.java b/src/main/java/com/first/flash/climbing/solution/infrastructure/SolutionRepositoryImpl.java index c1ee5993..551c12c4 100644 --- a/src/main/java/com/first/flash/climbing/solution/infrastructure/SolutionRepositoryImpl.java +++ b/src/main/java/com/first/flash/climbing/solution/infrastructure/SolutionRepositoryImpl.java @@ -4,6 +4,7 @@ import com.first.flash.climbing.solution.domain.SolutionRepository; import com.first.flash.climbing.solution.infrastructure.dto.DetailSolutionDto; import com.first.flash.climbing.solution.infrastructure.dto.MySolutionDto; +import com.first.flash.climbing.solution.infrastructure.dto.SolutionRepositoryResponseDto; import com.first.flash.climbing.solution.infrastructure.paging.SolutionCursor; import java.util.List; import java.util.Optional; @@ -29,11 +30,8 @@ public Optional findById(final Long id) { } @Override - public List findAllByProblemId(final UUID problemId, + public List findAllByProblemId(final UUID problemId, final List blockedMembers) { - if (blockedMembers.isEmpty()) { - return solutionJpaRepository.findByProblemId(problemId); - } return solutionQueryDslRepository.findAllExcludedBlockedMembers( problemId, blockedMembers); } @@ -61,7 +59,8 @@ public void deleteByUploaderId(final UUID memberId) { } @Override - public List findMySolutions(final UUID myId, final SolutionCursor prevSolutionCursor, + public List findMySolutions(final UUID myId, + final SolutionCursor prevSolutionCursor, final int size, final Long gymId, final List difficulty) { return solutionQueryDslRepository.findByUploaderId(myId, prevSolutionCursor, size, gymId, difficulty); diff --git a/src/main/java/com/first/flash/climbing/solution/infrastructure/dto/DetailSolutionDto.java b/src/main/java/com/first/flash/climbing/solution/infrastructure/dto/DetailSolutionDto.java index d2d898ce..cd20c0e7 100644 --- a/src/main/java/com/first/flash/climbing/solution/infrastructure/dto/DetailSolutionDto.java +++ b/src/main/java/com/first/flash/climbing/solution/infrastructure/dto/DetailSolutionDto.java @@ -1,10 +1,10 @@ package com.first.flash.climbing.solution.infrastructure.dto; +import com.first.flash.climbing.solution.domain.PerceivedDifficulty; import java.time.LocalDate; import java.time.LocalDateTime; public record DetailSolutionDto(Long solutionId, String videoUrl, String gymName, String sectorName, - String review, String difficultyName, LocalDate removalDate, - LocalDate settingDate, LocalDateTime uploadedAt) { - + String review, String difficultyName, Long commentsCount, PerceivedDifficulty perceivedDifficulty, + LocalDate removalDate, LocalDate settingDate, LocalDateTime uploadedAt) { } diff --git a/src/main/java/com/first/flash/climbing/solution/infrastructure/dto/MySolutionDto.java b/src/main/java/com/first/flash/climbing/solution/infrastructure/dto/MySolutionDto.java index 74e47dcb..91aed245 100644 --- a/src/main/java/com/first/flash/climbing/solution/infrastructure/dto/MySolutionDto.java +++ b/src/main/java/com/first/flash/climbing/solution/infrastructure/dto/MySolutionDto.java @@ -3,7 +3,7 @@ import java.time.LocalDateTime; public record MySolutionDto(Long solutionId, String gymName, String sectorName, - String difficultyName, String problemImageUrl, + String difficultyName, String problemImageUrl, Long commentsCount, LocalDateTime uploadedAt) { } diff --git a/src/main/java/com/first/flash/climbing/solution/infrastructure/dto/SolutionRepositoryResponseDto.java b/src/main/java/com/first/flash/climbing/solution/infrastructure/dto/SolutionRepositoryResponseDto.java new file mode 100644 index 00000000..4ac9c745 --- /dev/null +++ b/src/main/java/com/first/flash/climbing/solution/infrastructure/dto/SolutionRepositoryResponseDto.java @@ -0,0 +1,9 @@ +package com.first.flash.climbing.solution.infrastructure.dto; + +import java.util.UUID; + +public record SolutionRepositoryResponseDto(Long id, String uploader, String review, + String instagramId, String videoUrl, UUID uploaderId, + String profileImageUrl, Long commentCount) { + +} diff --git a/src/main/java/com/first/flash/climbing/solution/ui/SolutionCommentController.java b/src/main/java/com/first/flash/climbing/solution/ui/SolutionCommentController.java new file mode 100644 index 00000000..8662a75d --- /dev/null +++ b/src/main/java/com/first/flash/climbing/solution/ui/SolutionCommentController.java @@ -0,0 +1,122 @@ +package com.first.flash.climbing.solution.ui; + +import com.first.flash.climbing.solution.application.SolutionCommentService; +import com.first.flash.climbing.solution.application.dto.SolutionCommentCreateRequestDto; +import com.first.flash.climbing.solution.application.dto.SolutionCommentCreateResponseDto; +import com.first.flash.climbing.solution.application.dto.SolutionCommentResponseDto; +import com.first.flash.climbing.solution.application.dto.SolutionCommentUpdateRequestDto; +import com.first.flash.climbing.solution.application.dto.SolutionCommentsResponseDto; +import com.first.flash.climbing.solution.infrastructure.dto.SolutionRepositoryResponseDto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +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.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "solutions", description = "해설 관리 API") +@RestController +@RequiredArgsConstructor +public class SolutionCommentController { + + private final SolutionCommentService solutionCommentService; + + @Operation(summary = "해설 댓글 생성", description = "특정 해설에 대한 댓글 생성") + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "성공적으로 댓글 생성", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = SolutionCommentCreateResponseDto.class))), + @ApiResponse(responseCode = "400", description = "유효하지 않은 요청 형식", + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "요청값 누락", value = "{\"error\": \"content는 필수입니다.\"}"), + })), + @ApiResponse(responseCode = "404", description = "해설을 찾을 수 없음", + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "해설 없음", value = "{\"error\": \"아이디가 1인 해설을 찾을 수 없습니다.\"}") + })) + }) + @PostMapping("/solutions/{solutionId}/comments") + public ResponseEntity createSolutionComment( + @PathVariable final Long solutionId, + @RequestBody @Valid final SolutionCommentCreateRequestDto request) { + SolutionCommentCreateResponseDto comment = solutionCommentService + .createComment(solutionId, request); + return ResponseEntity.status(HttpStatus.CREATED) + .body(comment); + } + + @Operation(summary = "댓글 조회", description = "특정 해설에 대한 댓글 조회") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공적으로 댓글을 조회함", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = SolutionCommentsResponseDto.class))), + @ApiResponse(responseCode = "404", description = "해설을 찾을 수 없음", + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "해설 없음", value = "{\"error\": \"아이디가 1인 해설을 찾을 수 없습니다.\"}") + })) + }) + @GetMapping("/solutions/{solutionId}/comments") + public ResponseEntity getSolutionComments( + @PathVariable final Long solutionId) { + SolutionCommentsResponseDto comments = solutionCommentService + .findBySolutionId(solutionId); + return ResponseEntity.ok(comments); + } + + @Operation(summary = "댓글 수정", description = "내 댓글 수정") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공적으로 댓글을 수정함", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = SolutionRepositoryResponseDto.class))), + @ApiResponse(responseCode = "400", description = "유효하지 않은 요청 형식", + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "요청값 누락", value = "{\"error\": \"content는 필수입니다.\"}"), + })), + @ApiResponse(responseCode = "403", description = "본인의 댓글이 아님", + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "수정 권한 없음", value = "{\"error\": \"해당 댓글에 접근할 권한이 없습니다.\"}"), + })), + @ApiResponse(responseCode = "404", description = "리소스를 찾을 수 없음", + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "댓글 없음", value = "{\"error\": \"아이디가 1인 댓글을 찾을 수 없습니다.\"}") + })) + }) + @PatchMapping("/comments/{commentId}") + public ResponseEntity updateComment( + @PathVariable final Long commentId, + @RequestBody @Valid final SolutionCommentUpdateRequestDto request) { + SolutionCommentResponseDto response = solutionCommentService.updateComment(commentId, + request); + return ResponseEntity.status(HttpStatus.OK) + .body(response); + } + + @Operation(summary = "댓글 삭제", description = "특정 댓글 삭제") + @ApiResponses(value = { + @ApiResponse(responseCode = "204", description = "성공적으로 댓글을 삭제함", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "403", description = "본인의 댓글이 아님", + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "삭제 권한 없음", value = "{\"error\": \"해당 댓글에 접근할 권한이 없습니다.\"}"), + })), + @ApiResponse(responseCode = "404", description = "리소스를 찾을 수 없음", + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "댓글 없음", value = "{\"error\": \"아이디가 1인 댓글을 찾을 수 없습니다.\"}") + })) + }) + @DeleteMapping("/comments/{commentId}") + public ResponseEntity deleteComment( + @PathVariable final Long commentId) { + solutionCommentService.deleteComment(commentId); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/first/flash/climbing/solution/ui/SolutionController.java b/src/main/java/com/first/flash/climbing/solution/ui/SolutionController.java index 7664dd1e..594003d6 100644 --- a/src/main/java/com/first/flash/climbing/solution/ui/SolutionController.java +++ b/src/main/java/com/first/flash/climbing/solution/ui/SolutionController.java @@ -2,8 +2,8 @@ import com.first.flash.climbing.solution.application.SolutionSaveService; import com.first.flash.climbing.solution.application.SolutionService; -import com.first.flash.climbing.solution.application.dto.SolutionResponseDto; import com.first.flash.climbing.solution.application.dto.SolutionUpdateRequestDto; +import com.first.flash.climbing.solution.application.dto.SolutionWriteResponseDto; import com.first.flash.climbing.solution.application.dto.SolutionsPageResponseDto; import com.first.flash.climbing.solution.application.dto.SolutionsResponseDto; import com.first.flash.climbing.solution.application.dto.UnregisteredMemberSolutionCreateRequest; @@ -92,7 +92,7 @@ public ResponseEntity getSolutions(@PathVariable final UUI @Operation(summary = "해설 업로드", description = "특정 문제에 대한 해설 업로드") @ApiResponses(value = { @ApiResponse(responseCode = "201", description = "성공적으로 해설을 업로드함", - content = @Content(mediaType = "application/json", schema = @Schema(implementation = SolutionResponseDto.class))), + content = @Content(mediaType = "application/json", schema = @Schema(implementation = SolutionWriteResponseDto.class))), @ApiResponse(responseCode = "400", description = "유효하지 않은 요청 형식", content = @Content(mediaType = "application/json", examples = { @ExampleObject(name = "요청값 누락", value = "{\"videoUrl\": \"비디오 URL은 필수입니다.\"}"), @@ -103,7 +103,7 @@ public ResponseEntity getSolutions(@PathVariable final UUI })) }) @PostMapping("problems/{problemId}/solutions") - public ResponseEntity createSolution(@PathVariable final UUID problemId, + public ResponseEntity createSolution(@PathVariable final UUID problemId, @Valid @RequestBody final SolutionCreateRequestDto solutionCreateRequestDto) { return ResponseEntity.status(HttpStatus.CREATED) @@ -115,7 +115,7 @@ public ResponseEntity createSolution(@PathVariable final UU @Operation(summary = "없는 유저의 영상으로 해설 업로드", description = "없는 유저의 영상으로 해설 업로드") @ApiResponses(value = { @ApiResponse(responseCode = "201", description = "성공적으로 해설을 업로드함", - content = @Content(mediaType = "application/json", schema = @Schema(implementation = SolutionResponseDto.class))), + content = @Content(mediaType = "application/json", schema = @Schema(implementation = SolutionWriteResponseDto.class))), @ApiResponse(responseCode = "400", description = "유효하지 않은 요청 형식", content = @Content(mediaType = "application/json", examples = { @ExampleObject(name = "요청값 누락", value = "{\"videoUrl\": \"비디오 URL은 필수입니다.\"}"), @@ -126,7 +126,7 @@ public ResponseEntity createSolution(@PathVariable final UU })) }) @PostMapping("admin/problems/{problemId}/solutions") - public ResponseEntity createUnregisteredMemberSolution( + public ResponseEntity createUnregisteredMemberSolution( @PathVariable final UUID problemId, @Valid @RequestBody final UnregisteredMemberSolutionCreateRequest createRequestDto) { @@ -139,7 +139,7 @@ public ResponseEntity createUnregisteredMemberSolution( @Operation(summary = "해설 수정", description = "특정 해설 정보 수정") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "성공적으로 해설을 수정함", - content = @Content(mediaType = "application/json", schema = @Schema(implementation = SolutionResponseDto.class))), + content = @Content(mediaType = "application/json", schema = @Schema(implementation = SolutionWriteResponseDto.class))), @ApiResponse(responseCode = "400", description = "유효하지 않은 요청 형식", content = @Content(mediaType = "application/json", examples = { @ExampleObject(name = "요청값 누락", value = "{\"videoUrl\": \"비디오 URL은 필수입니다.\"}"), @@ -154,7 +154,7 @@ public ResponseEntity createUnregisteredMemberSolution( })) }) @PatchMapping("/solutions/{solutionId}") - public ResponseEntity updateSolution(@PathVariable Long solutionId, + public ResponseEntity updateSolution(@PathVariable Long solutionId, @Valid @RequestBody final SolutionUpdateRequestDto solutionUpdateRequestDto) { return ResponseEntity.status(HttpStatus.OK) .body( diff --git a/src/main/java/com/first/flash/global/config/SecurityConfig.java b/src/main/java/com/first/flash/global/config/SecurityConfig.java index 4f5880cf..09a329fe 100644 --- a/src/main/java/com/first/flash/global/config/SecurityConfig.java +++ b/src/main/java/com/first/flash/global/config/SecurityConfig.java @@ -32,7 +32,8 @@ public class SecurityConfig { "/auth/login", "/swagger-ui/*", "/v1/api-docs/**", - "/flash-climbing-answer-health/**" + "/flash-climbing-answer-health/**", + "/versions" }; private static final String COMPLETE_REGISTRATION = "/members"; private static final String MARKETING_CONSENT = "/members/marketing-consent"; diff --git a/src/main/java/com/first/flash/global/exception/GlobalExceptionHandler.java b/src/main/java/com/first/flash/global/exception/GlobalExceptionHandler.java index ed2095c2..09a44d68 100644 --- a/src/main/java/com/first/flash/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/first/flash/global/exception/GlobalExceptionHandler.java @@ -3,6 +3,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -28,4 +29,11 @@ public ResponseEntity handleHttpRequestMethodNotSupportedException( return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED) .body(exception.getMessage()); } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException ex) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body("요청 본문이 누락되었습니다."); + } } diff --git a/src/main/java/com/first/flash/version/application/VersionService.java b/src/main/java/com/first/flash/version/application/VersionService.java new file mode 100644 index 00000000..270b332d --- /dev/null +++ b/src/main/java/com/first/flash/version/application/VersionService.java @@ -0,0 +1,22 @@ +package com.first.flash.version.application; + +import java.util.NoSuchElementException; +import com.first.flash.version.infrastructure.VersionJpaRepository; +import com.first.flash.version.application.dto.VersionResponseDto; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class VersionService { + + private final VersionJpaRepository versionJpaRepository; + + public VersionResponseDto getLatestVersions() { + return versionJpaRepository.findTopByOrderByIdDesc() + .map(version -> new VersionResponseDto(version.getAndroid(), version.getIos())) + .orElseThrow(() -> new NoSuchElementException("No version information available")); + } +} \ No newline at end of file diff --git a/src/main/java/com/first/flash/version/application/dto/VersionResponseDto.java b/src/main/java/com/first/flash/version/application/dto/VersionResponseDto.java new file mode 100644 index 00000000..bcfbba5f --- /dev/null +++ b/src/main/java/com/first/flash/version/application/dto/VersionResponseDto.java @@ -0,0 +1,4 @@ +package com.first.flash.version.application.dto; + +public record VersionResponseDto(String android, String ios) { +} \ No newline at end of file diff --git a/src/main/java/com/first/flash/version/domain/AppVersion.java b/src/main/java/com/first/flash/version/domain/AppVersion.java new file mode 100644 index 00000000..acff2535 --- /dev/null +++ b/src/main/java/com/first/flash/version/domain/AppVersion.java @@ -0,0 +1,24 @@ +package com.first.flash.version.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@Entity +@NoArgsConstructor +@AllArgsConstructor +@Getter +@ToString +public class AppVersion { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private String android; + private String ios; + +} diff --git a/src/main/java/com/first/flash/version/infrastructure/VersionJpaRepository.java b/src/main/java/com/first/flash/version/infrastructure/VersionJpaRepository.java new file mode 100644 index 00000000..94ff9efa --- /dev/null +++ b/src/main/java/com/first/flash/version/infrastructure/VersionJpaRepository.java @@ -0,0 +1,10 @@ +package com.first.flash.version.infrastructure; + +import com.first.flash.version.domain.AppVersion; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + +public interface VersionJpaRepository extends JpaRepository { + + Optional findTopByOrderByIdDesc(); +} \ No newline at end of file diff --git a/src/main/java/com/first/flash/version/ui/VersionController.java b/src/main/java/com/first/flash/version/ui/VersionController.java new file mode 100644 index 00000000..4a997a8e --- /dev/null +++ b/src/main/java/com/first/flash/version/ui/VersionController.java @@ -0,0 +1,36 @@ +package com.first.flash.version.ui; + +import com.first.flash.climbing.solution.application.dto.SolutionsPageResponseDto; +import com.first.flash.version.application.VersionService; +import com.first.flash.version.application.dto.VersionResponseDto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +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 lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "versions", description = "앱 버전 정보 API") +@RestController +@RequestMapping +@RequiredArgsConstructor +public class VersionController { + + private final VersionService versionService; + + @Operation(summary = "버전 정보 조회", description = "앱 최신 버전 정보 조회") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공적으로 조회함", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = VersionResponseDto.class))) + }) + @GetMapping("versions") + public ResponseEntity getLatestVersions() { + VersionResponseDto versionInfo = versionService.getLatestVersions(); + return ResponseEntity.ok(versionInfo); + } +} \ No newline at end of file