diff --git a/.gitignore b/.gitignore index 23494a33..7e29f021 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,5 @@ out/ .vscode/ application.yml +application-test.yml .env \ No newline at end of file diff --git a/build.gradle b/build.gradle index 0e860fec..3dc338bd 100644 --- a/build.gradle +++ b/build.gradle @@ -33,11 +33,14 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9' implementation 'io.awspring.cloud:spring-cloud-starter-aws:2.4.4' + implementation 'io.awspring.cloud:spring-cloud-starter-aws-secrets-manager-config:2.4.4' implementation 'com.opencsv:opencsv:5.11.2' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' + implementation 'org.redisson:redisson-spring-boot-starter:3.27.0' //Redisson + testImplementation 'com.h2database:h2' testCompileOnly 'org.projectlombok:lombok' // 테스트 의존성 추가 testAnnotationProcessor 'org.projectlombok:lombok' diff --git a/docker-compose.yml b/docker-compose.yml index 47b78da4..d8673aef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,8 @@ services: depends_on: - mysqldb - redis + env_file: + - .env environment: - SPRING_PROFILES_ACTIVE=docker networks: @@ -61,6 +63,33 @@ services: networks: - app-tier +# prometheus: +# user: "root" +# image: prom/prometheus +# container_name: prometheus_container +# volumes: +# - /home/ubuntu/WEB5_7_3star_BE/prometheus-grafana/prometheus/config:/etc/prometheus +# - /home/ubuntu/WEB5_7_3star_BE/prometheus-grafana/prometheus/volume:/prometheus/data +# ports: +# - 9090:9090 +# command: +# - '--config.file=/etc/prometheus/prometheus.yml' +# restart: always +# networks: +# - app-tier +# +# grafana: +# user: "root" +# image: grafana/grafana +# container_name: grafana_container +# ports: +# - 3000:3000 +# volumes: +# - ./grafana/volume:/var/lib/grafana +# restart: always +# networks: +# - app-tier + volumes: mysqldb-data: driver: local diff --git a/prometheus-grafana/prometheus/config/prometheus.yml b/prometheus-grafana/prometheus/config/prometheus.yml new file mode 100644 index 00000000..7e2fd621 --- /dev/null +++ b/prometheus-grafana/prometheus/config/prometheus.yml @@ -0,0 +1,26 @@ +global: + scrape_interval: 15s + scrape_timeout: 15s + evaluation_interval: 2m + external_labels: + monitor: 'monitor' + query_log_file: query_log_file.log +rule_files: + - "rule.yml" +scrape_configs: + - job_name: 'prometheus' + scrape_interval: 10s + scrape_timeout: 10s + metrics_path: '/metrics' + honor_labels: false + honor_timestamps: false + scheme: 'http' + static_configs: + - targets: [ '43.202.206.47:9090' ] + # - targets: [ 'localhost:9090' ] + labels: + service: 'monitor-1' + - job_name: 'node' + static_configs: + # - targets: [ 'localhost:9100' ] + - targets: [ '43.202.206.47:9100' ] \ No newline at end of file diff --git a/prometheus-grafana/prometheus/config/rule.yml b/prometheus-grafana/prometheus/config/rule.yml new file mode 100644 index 00000000..ea19f411 --- /dev/null +++ b/prometheus-grafana/prometheus/config/rule.yml @@ -0,0 +1,17 @@ +groups: + - name: rule + rules: + - alert: InstanceDown + expr: up == 0 + for: 5m + labels: + severity: page + annotations: + summary: "Instance {{ $labels.instance }} down" + description: "{{ $labels.instance }} of job {{ $labels.job }} has been down for more than 5 minutes." + - alert: APIHighRequestLatency + expr: api_http_request_latencies_second{quantile="0.5"} > 1 + for: 10m + annotations: + summary: "High request latency on {{ $labels.instance }}" + description: "{{ $labels.instance }} has a median request latency above 1s (current value: {{ $value }}s)" \ No newline at end of file diff --git a/src/main/java/com/threestar/trainus/TrainUsApplication.java b/src/main/java/com/threestar/trainus/TrainUsApplication.java index dd160275..cd54d25e 100644 --- a/src/main/java/com/threestar/trainus/TrainUsApplication.java +++ b/src/main/java/com/threestar/trainus/TrainUsApplication.java @@ -1,10 +1,12 @@ package com.threestar.trainus; +import java.util.TimeZone; + +import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.context.annotation.Bean; -@EnableJpaAuditing @SpringBootApplication public class TrainUsApplication { @@ -12,4 +14,10 @@ public static void main(String[] args) { SpringApplication.run(TrainUsApplication.class, args); } + @Bean + public CommandLineRunner init() { + return args -> { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); + }; + } } diff --git a/src/main/java/com/threestar/trainus/domain/comment/controller/CommentController.java b/src/main/java/com/threestar/trainus/domain/comment/controller/CommentController.java index 761d0f1a..afd561f8 100644 --- a/src/main/java/com/threestar/trainus/domain/comment/controller/CommentController.java +++ b/src/main/java/com/threestar/trainus/domain/comment/controller/CommentController.java @@ -1,24 +1,24 @@ package com.threestar.trainus.domain.comment.controller; -import org.springframework.beans.factory.annotation.Value; +import java.util.List; + 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.ModelAttribute; 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.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import com.threestar.trainus.domain.comment.dto.CommentCreateRequestDto; import com.threestar.trainus.domain.comment.dto.CommentPageResponseDto; -import com.threestar.trainus.domain.comment.dto.CommentPageWrapperDto; import com.threestar.trainus.domain.comment.dto.CommentResponseDto; -import com.threestar.trainus.domain.comment.mapper.CommentMapper; import com.threestar.trainus.domain.comment.service.CommentService; import com.threestar.trainus.global.annotation.LoginUser; +import com.threestar.trainus.global.dto.PageRequestDto; import com.threestar.trainus.global.unit.BaseResponse; import com.threestar.trainus.global.unit.PagedResponse; @@ -34,8 +34,6 @@ public class CommentController { private final CommentService commentService; - @Value("${spring.page.size.limit}") - private int pageSizeLimit; @PostMapping("/{lessonId}") @Operation(summary = "댓글 작성", description = "레슨 ID에 해당되는 댓글을 작성합니다.") @@ -47,14 +45,11 @@ public ResponseEntity> createComment(@PathVaria @GetMapping("/{lessonId}") @Operation(summary = "댓글 조회", description = "레슨 ID에 해당되는 댓글들을 조회합니다.") - public ResponseEntity> readAll(@PathVariable Long lessonId, - @RequestParam("page") int page, - @RequestParam("pageSize") int pageSize) { - int correctPage = Math.max(page, 1); - int correctPageSize = Math.max(1, Math.min(pageSize, pageSizeLimit)); - CommentPageResponseDto commentsInfo = commentService.readAll(lessonId, correctPage, correctPageSize); - CommentPageWrapperDto comments = CommentMapper.toCommentPageWrapperDto(commentsInfo); - return PagedResponse.ok("댓글 조회 성공", comments, commentsInfo.getCount(), HttpStatus.OK); + public ResponseEntity>> readAll(@PathVariable Long lessonId, + @Valid @ModelAttribute PageRequestDto pageRequestDto) { + CommentPageResponseDto commentsInfo = commentService.readAll(lessonId, pageRequestDto.getPage(), + pageRequestDto.getLimit()); + return PagedResponse.ok("댓글 조회 성공", commentsInfo.comments(), commentsInfo.count(), HttpStatus.OK); } @DeleteMapping("/{commentId}") diff --git a/src/main/java/com/threestar/trainus/domain/comment/dto/CommentCreateRequestDto.java b/src/main/java/com/threestar/trainus/domain/comment/dto/CommentCreateRequestDto.java index aa8bade3..ab3ab3ff 100644 --- a/src/main/java/com/threestar/trainus/domain/comment/dto/CommentCreateRequestDto.java +++ b/src/main/java/com/threestar/trainus/domain/comment/dto/CommentCreateRequestDto.java @@ -2,12 +2,11 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; -import lombok.Getter; -@Getter -public class CommentCreateRequestDto { +public record CommentCreateRequestDto( @NotBlank(message = "댓글 내용은 필수입니다") @Size(max = 255, message = "댓글은 255자 이내여야 합니다.") - private String content; - private Long parentCommentId; + String content, + Long parentCommentId +) { } diff --git a/src/main/java/com/threestar/trainus/domain/comment/dto/CommentPageResponseDto.java b/src/main/java/com/threestar/trainus/domain/comment/dto/CommentPageResponseDto.java index 1082dd41..11515fa6 100644 --- a/src/main/java/com/threestar/trainus/domain/comment/dto/CommentPageResponseDto.java +++ b/src/main/java/com/threestar/trainus/domain/comment/dto/CommentPageResponseDto.java @@ -3,12 +3,10 @@ import java.util.List; import lombok.Builder; -import lombok.Getter; -@Getter @Builder -public class CommentPageResponseDto { - - private List comments; - private Integer count; +public record CommentPageResponseDto( + List comments, + Integer count +) { } diff --git a/src/main/java/com/threestar/trainus/domain/comment/dto/CommentPageWrapperDto.java b/src/main/java/com/threestar/trainus/domain/comment/dto/CommentPageWrapperDto.java deleted file mode 100644 index 38a189d7..00000000 --- a/src/main/java/com/threestar/trainus/domain/comment/dto/CommentPageWrapperDto.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.threestar.trainus.domain.comment.dto; - -import java.util.List; - -import lombok.Builder; -import lombok.Getter; - -@Getter -@Builder -public class CommentPageWrapperDto { - private List comments; -} diff --git a/src/main/java/com/threestar/trainus/domain/comment/dto/CommentResponseDto.java b/src/main/java/com/threestar/trainus/domain/comment/dto/CommentResponseDto.java index 3c3e762c..a00237fd 100644 --- a/src/main/java/com/threestar/trainus/domain/comment/dto/CommentResponseDto.java +++ b/src/main/java/com/threestar/trainus/domain/comment/dto/CommentResponseDto.java @@ -3,15 +3,15 @@ import java.time.LocalDateTime; import lombok.Builder; -import lombok.Getter; -@Getter @Builder -public class CommentResponseDto { - private Long commentId; - private Long userId; - private String content; - private Long parentCommentId; - private Boolean deleted; - private LocalDateTime createdAt; +public record CommentResponseDto( + Long commentId, + Long userId, + String nickname, + String content, + Long parentCommentId, + Boolean deleted, + LocalDateTime createdAt +) { } diff --git a/src/main/java/com/threestar/trainus/domain/comment/dto/CommentWithUserProjection.java b/src/main/java/com/threestar/trainus/domain/comment/dto/CommentWithUserProjection.java new file mode 100644 index 00000000..97261deb --- /dev/null +++ b/src/main/java/com/threestar/trainus/domain/comment/dto/CommentWithUserProjection.java @@ -0,0 +1,19 @@ +package com.threestar.trainus.domain.comment.dto; + +import java.time.LocalDateTime; + +public interface CommentWithUserProjection { + Long getCommentId(); + + String getContent(); + + Long getParentCommentId(); + + Boolean getDeleted(); + + LocalDateTime getCreatedAt(); + + Long getUserId(); + + String getNickname(); +} diff --git a/src/main/java/com/threestar/trainus/domain/comment/mapper/CommentMapper.java b/src/main/java/com/threestar/trainus/domain/comment/mapper/CommentMapper.java index 8d186e95..499acb03 100644 --- a/src/main/java/com/threestar/trainus/domain/comment/mapper/CommentMapper.java +++ b/src/main/java/com/threestar/trainus/domain/comment/mapper/CommentMapper.java @@ -3,8 +3,8 @@ import java.util.List; import com.threestar.trainus.domain.comment.dto.CommentPageResponseDto; -import com.threestar.trainus.domain.comment.dto.CommentPageWrapperDto; import com.threestar.trainus.domain.comment.dto.CommentResponseDto; +import com.threestar.trainus.domain.comment.dto.CommentWithUserProjection; import com.threestar.trainus.domain.comment.entity.Comment; public class CommentMapper { @@ -20,6 +20,19 @@ public static CommentResponseDto toCommentResponseDto(Comment comment) { .parentCommentId(comment.getParentCommentId()) .deleted(comment.getDeleted()) .createdAt(comment.getCreatedAt()) + .nickname(comment.getUser().getNickname()) + .build(); + } + + public static CommentResponseDto toCommentResponseDtoWithProjection(CommentWithUserProjection comment) { + return CommentResponseDto.builder() + .commentId(comment.getCommentId()) + .userId(comment.getUserId()) + .content(comment.getContent()) + .parentCommentId(comment.getParentCommentId()) + .deleted(comment.getDeleted()) + .createdAt(comment.getCreatedAt()) + .nickname(comment.getNickname()) .build(); } @@ -29,10 +42,4 @@ public static CommentPageResponseDto toCommentPageResponseDto(List { //create index idx_lesson_id_parent_comment_id_comment_id on comments(lesson_id, parent_comment_id asc, comment_id asc); 인덱스 통해 조회 성능 최적화 @Query(value = """ - select comments.comment_id, comments.lesson_id, comments.user_id, comments.content, - comments.parent_comment_id, comments.deleted, comments.created_at, comments.updated_at - from ( select comment_id from comments where lesson_id = :lessonId order by parent_comment_id asc, comment_id asc limit :limit offset :offset - ) t left join comments on t.comment_id = comments.comment_id + SELECT + c.comment_id AS commentId, + c.content AS content, + c.parent_comment_id AS parentCommentId, + c.deleted AS deleted, + c.created_at AS createdAt, + u.id AS userId, + u.nickname AS nickname + FROM ( + SELECT comment_id + FROM comments c + JOIN user u ON c.user_id = u.id AND u.deleted_at IS NULL + WHERE lesson_id = :lessonId + ORDER BY parent_comment_id ASC, comment_id ASC + LIMIT :limit OFFSET :offset + ) t + JOIN comments c ON t.comment_id = c.comment_id + JOIN user u ON c.user_id = u.id """, nativeQuery = true) - List findAll(@Param("lessonId") Long lessonId, @Param("offset") int offset, @Param("limit") int limit); + List findAll(@Param("lessonId") Long lessonId, @Param("offset") int offset, + @Param("limit") int limit); @Query(value = """ - select count(*) from ( select comment_id from comments where lesson_id = :lessonId limit :limit) t + SELECT count(*) FROM ( + SELECT comment_id + FROM comments c + JOIN user u ON c.user_id = u.id AND u.deleted_at IS NULL + WHERE lesson_id = :lessonId + LIMIT :limit + ) t """, nativeQuery = true) Integer count(@Param("lessonId") Long lessonId, @Param("limit") int limit); @Query(value = """ - select count(*) from (select comment_id from comments where lesson_id = :lessonId and parent_comment_id = :parentCommentId limit :limit) t + SELECT count(*) FROM ( + SELECT comment_id + FROM comments c + JOIN user u ON c.user_id = u.id AND u.deleted_at IS NULL + WHERE lesson_id = :lessonId AND parent_comment_id = :parentCommentId + LIMIT :limit + ) t """, nativeQuery = true) Long countBy(@Param("lessonId") Long lessonId, @Param("parentCommentId") Long parentCommentId, @Param("limit") int limit); diff --git a/src/main/java/com/threestar/trainus/domain/comment/service/CommentService.java b/src/main/java/com/threestar/trainus/domain/comment/service/CommentService.java index 2eb49b51..af890782 100644 --- a/src/main/java/com/threestar/trainus/domain/comment/service/CommentService.java +++ b/src/main/java/com/threestar/trainus/domain/comment/service/CommentService.java @@ -37,7 +37,7 @@ public CommentResponseDto createComment(CommentCreateRequestDto request, Long le .lesson(findLesson) .user(findUser) .deleted(false) - .content(request.getContent()) + .content(request.content()) .build(); commentRepository.saveAndFlush(newComment); //즉시 저장을 통해 commentId @@ -51,7 +51,7 @@ public CommentResponseDto createComment(CommentCreateRequestDto request, Long le } private Comment findParent(CommentCreateRequestDto request) { - Long parentCommentId = request.getParentCommentId(); + Long parentCommentId = request.parentCommentId(); if (parentCommentId == null) { return null; } @@ -65,7 +65,7 @@ private Comment findParent(CommentCreateRequestDto request) { public CommentPageResponseDto readAll(Long lessonId, int page, int pageSize) { return CommentMapper.toCommentPageResponseDto( commentRepository.findAll(lessonId, (page - 1) * pageSize, pageSize) - .stream().map(CommentMapper::toCommentResponseDto) + .stream().map(CommentMapper::toCommentResponseDtoWithProjection) .toList(), commentRepository.count(lessonId, PageLimitCalculator.calculatePageLimit(page, pageSize, 5)) //한번에 보일 수 있는 페이지 이동 갯수 5개(프론트와 협의) @@ -92,7 +92,7 @@ private boolean hasChildren(Comment comment) { private void delete(Comment comment) { commentRepository.delete(comment); if (!comment.isRoot()) { - commentRepository.findById(comment.getCommentId()) + commentRepository.findById(comment.getParentCommentId()) .filter(Comment::getDeleted) .filter(not(this::hasChildren)) .ifPresent(this::delete); diff --git a/src/main/java/com/threestar/trainus/domain/coupon/admin/controller/AdminCouponController.java b/src/main/java/com/threestar/trainus/domain/coupon/admin/controller/AdminCouponController.java index 26928fcf..ed67e22d 100644 --- a/src/main/java/com/threestar/trainus/domain/coupon/admin/controller/AdminCouponController.java +++ b/src/main/java/com/threestar/trainus/domain/coupon/admin/controller/AdminCouponController.java @@ -4,6 +4,7 @@ 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.ModelAttribute; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -25,14 +26,13 @@ import com.threestar.trainus.domain.coupon.user.entity.CouponCategory; import com.threestar.trainus.domain.coupon.user.entity.CouponStatus; import com.threestar.trainus.global.annotation.LoginUser; +import com.threestar.trainus.global.dto.PageRequestDto; import com.threestar.trainus.global.unit.BaseResponse; import com.threestar.trainus.global.unit.PagedResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; @Tag(name = "관리자 쿠폰 API", description = "관리자 쿠폰 생성,수정 및 삭제 관련 API") @@ -56,15 +56,18 @@ public ResponseEntity> createCoupon( @GetMapping @Operation(summary = "쿠폰 목록 조회", description = "관리자가 쿠폰 목록을 조회") public ResponseEntity> getCoupons( - @RequestParam(defaultValue = "1") @Min(value = 1, message = "페이지는 1 이상이어야 합니다.") - @Max(value = 1000, message = "페이지는 1000 이하여야 합니다.") int page, - @RequestParam(defaultValue = "5") @Min(value = 1, message = "limit는 1 이상이어야 합니다.") - @Max(value = 100, message = "limit는 100 이하여야 합니다.") int limit, + @Valid @ModelAttribute PageRequestDto pageRequestDto, @RequestParam(required = false) CouponStatus status, @RequestParam(required = false) CouponCategory category, @LoginUser Long loginUserId ) { - CouponListResponseDto couponsInfo = adminCouponService.getCoupons(page, limit, status, category, loginUserId); + CouponListResponseDto couponsInfo = adminCouponService.getCoupons( + pageRequestDto.getPage(), + pageRequestDto.getLimit(), + status, + category, + loginUserId + ); CouponListWrapperDto coupons = AdminCouponMapper.toCouponListWrapperDto(couponsInfo); diff --git a/src/main/java/com/threestar/trainus/domain/coupon/admin/mapper/AdminCouponMapper.java b/src/main/java/com/threestar/trainus/domain/coupon/admin/mapper/AdminCouponMapper.java index 3d77f155..e822db98 100644 --- a/src/main/java/com/threestar/trainus/domain/coupon/admin/mapper/AdminCouponMapper.java +++ b/src/main/java/com/threestar/trainus/domain/coupon/admin/mapper/AdminCouponMapper.java @@ -1,6 +1,6 @@ package com.threestar.trainus.domain.coupon.admin.mapper; -import org.springframework.data.domain.Page; +import java.util.List; import com.threestar.trainus.domain.coupon.admin.dto.CouponCreateRequestDto; import com.threestar.trainus.domain.coupon.admin.dto.CouponCreateResponseDto; @@ -57,10 +57,12 @@ public static CouponListItemDto toCouponListItemDto(Coupon coupon) { ); } - public static CouponListResponseDto toCouponListResponseDto(Page couponPage) { + public static CouponListResponseDto toCouponListResponseDto( + List coupons, int totalCount + ) { return new CouponListResponseDto( - (int)couponPage.getTotalElements(), - couponPage.getContent().stream() + totalCount, + coupons.stream() .map(AdminCouponMapper::toCouponListItemDto) .toList() ); diff --git a/src/main/java/com/threestar/trainus/domain/coupon/admin/scheduler/CouponStatusScheduler.java b/src/main/java/com/threestar/trainus/domain/coupon/admin/scheduler/CouponStatusScheduler.java new file mode 100644 index 00000000..5558f061 --- /dev/null +++ b/src/main/java/com/threestar/trainus/domain/coupon/admin/scheduler/CouponStatusScheduler.java @@ -0,0 +1,110 @@ +package com.threestar.trainus.domain.coupon.admin.scheduler; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.threestar.trainus.domain.coupon.user.entity.Coupon; +import com.threestar.trainus.domain.coupon.user.entity.CouponStatus; +import com.threestar.trainus.domain.coupon.user.entity.UserCoupon; +import com.threestar.trainus.domain.coupon.user.repository.CouponRepository; +import com.threestar.trainus.domain.coupon.user.repository.UserCouponRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CouponStatusScheduler { + + private final CouponRepository couponRepository; + private final UserCouponRepository userCouponRepository; + + /** + * 1분마다 쿠폰 상태를 확인하여 업데이트를 진행 + * - INACTIVE → ACTIVE (오픈 시간 도달) + * - ACTIVE → INACTIVE (마감 시간 도달) + */ + @Scheduled(cron = "0 * * * * *") // 1분마다 실행 + @Transactional + public void updateCouponStatus() { + LocalDateTime now = LocalDateTime.now(); + + try { + // 비활성화 → 활성화(오픈시간에 도달한 쿠폰들) + activateExpiredCoupons(now); + + // 활성화 → 비활성화(마감시간에 도달한 쿠폰들) + deactivateExpiredCoupons(now); + + } catch (Exception e) { + log.error("쿠폰 상태 업데이트 중 오류 발생", e); + } + } + + /** + * 유저쿠폰의 만료 상태를 확인하여 업데이트를 진행 + * - ACTIVE → INACTIVE (유효기간 종료) + */ + @Scheduled(cron = "0 * * * * *") // 1분 마다 실행 + @Transactional + public void updateUserCouponStatus() { + LocalDateTime now = LocalDateTime.now(); + + try { + expireUserCoupons(now); + } catch (Exception e) { + log.error("유저쿠폰 상태 업데이트 중 오류 발생", e); + } + } + + private void activateExpiredCoupons(LocalDateTime now) { + // 오픈 시간지났지만 아직 비활성화 상태인 쿠폰들 조회 + List couponsToActivate = couponRepository.findInactiveCouponsToActivate(now); + + if (!couponsToActivate.isEmpty()) { + for (Coupon coupon : couponsToActivate) { + coupon.updateStatus(CouponStatus.ACTIVE); + log.info("쿠폰 활성화: ID={}, 이름={}", coupon.getId(), coupon.getName()); + } + + couponRepository.saveAll(couponsToActivate); + log.info("총 {}개의 쿠폰이 활성화되었습니다.", couponsToActivate.size()); + } + } + + private void deactivateExpiredCoupons(LocalDateTime now) { + // 마감 시간이 지났지만 아직 활성화 상태인 쿠폰들 조회 + List couponsToDeactivate = couponRepository.findActiveCouponsToDeactivate(now); + + if (!couponsToDeactivate.isEmpty()) { + for (Coupon coupon : couponsToDeactivate) { + coupon.updateStatus(CouponStatus.INACTIVE); + log.info("쿠폰 비활성화: ID={}, 이름={}", coupon.getId(), coupon.getName()); + } + + couponRepository.saveAll(couponsToDeactivate); + log.info("총 {}개의 쿠폰이 비활성화되었습니다.", couponsToDeactivate.size()); + } + } + + private void expireUserCoupons(LocalDateTime now) { + // 유효기간이 지났지만 아직 활성화 상태인 유저쿠폰들 조회 + List userCouponsToExpire = userCouponRepository.findActiveUserCouponsToExpire(now); + + if (!userCouponsToExpire.isEmpty()) { + for (UserCoupon userCoupon : userCouponsToExpire) { + userCoupon.use(); // 만료 처리 + log.info("유저쿠폰 만료: UserID={}, CouponID={}", + userCoupon.getUser().getId(), userCoupon.getCoupon().getId()); + } + + userCouponRepository.saveAll(userCouponsToExpire); + log.info("총 {}개의 유저쿠폰이 만료되었습니다.", userCouponsToExpire.size()); + } + } +} diff --git a/src/main/java/com/threestar/trainus/domain/coupon/admin/service/AdminCouponService.java b/src/main/java/com/threestar/trainus/domain/coupon/admin/service/AdminCouponService.java index ed5d8d6f..4ac59f4e 100644 --- a/src/main/java/com/threestar/trainus/domain/coupon/admin/service/AdminCouponService.java +++ b/src/main/java/com/threestar/trainus/domain/coupon/admin/service/AdminCouponService.java @@ -1,11 +1,8 @@ package com.threestar.trainus.domain.coupon.admin.service; import java.time.LocalDateTime; +import java.util.List; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -25,6 +22,7 @@ import com.threestar.trainus.domain.user.service.UserService; import com.threestar.trainus.global.exception.domain.ErrorCode; import com.threestar.trainus.global.exception.handler.BusinessException; +import com.threestar.trainus.global.utils.PageLimitCalculator; import lombok.RequiredArgsConstructor; @@ -54,21 +52,32 @@ public CouponCreateResponseDto createCoupon(CouponCreateRequestDto request, Long //쿠폰 조회 @Transactional(readOnly = true) - public CouponListResponseDto getCoupons(int page, int limit, CouponStatus status, CouponCategory category, - Long userId) { + public CouponListResponseDto getCoupons( + int page, int limit, + CouponStatus status, CouponCategory category, + Long userId + ) { // 관리자 권한 검증 userService.validateAdminRole(userId); - Pageable pageable = PageRequest.of(page - 1, limit, Sort.by("createdAt").descending()); + // offset / countLimit 계산 + int offset = (page - 1) * limit; + int countLimit = PageLimitCalculator.calculatePageLimit(page, limit, 5); - // 조건에 따른 쿠폰 조회 - Page couponPage = couponRepository.findCouponsWithFilters(status, category, pageable); + // 쿠폰 조회 및 count + List coupons = couponRepository.findCouponsWithFilters( + status, category, offset, limit + ); - //응답 DTO 변환 - return AdminCouponMapper.toCouponListResponseDto(couponPage); + int total = couponRepository.countCouponsWithFilters( + status, category, countLimit + ); + + // 응답 DTO 변환 + return AdminCouponMapper.toCouponListResponseDto(coupons, total); } - //쿠폰 상세 조횧 + //쿠폰 상세 조회 @Transactional(readOnly = true) public CouponDetailResponseDto getCouponDetail(Long couponId, Long userId) { // 관리자 권한 검증 @@ -123,13 +132,13 @@ public CouponDeleteResponseDto deleteCoupon(Long couponId, Long userId) { throw new BusinessException(ErrorCode.INVALID_REQUEST_DATA); } - // todo : 발급된 쿠폰 수 조회 + // 발급된 쿠폰 수 조회 Long issuedCount = userCouponRepository.countByCouponId(couponId); - // todo : 삭제 가능 여부 검증 -> 지금은 필요없는데 일단은 만들어둠! + // 삭제 가능 여부 검증 (발급된 쿠폰이 있으면 예외 발생) validateCouponDeletion(coupon, issuedCount); - // 쿠폰 삭제 처리 + // 쿠폰 삭제 처리(아무도 발급받지 않은 쿠폰만) coupon.markAsDeleted(); Coupon deletedCoupon = couponRepository.save(coupon); @@ -338,7 +347,10 @@ private void updateCouponFields(Coupon coupon, CouponUpdateRequestDto request) { } private void validateCouponDeletion(Coupon coupon, Long issuedCount) { - //todo: 일단은 다 삭제가능하게 설정 해놨는데, 나중에 결제 붙으면여기에 여러 검증들을 추가할 예정 + // 발급된 쿠폰이 하나라도 있으면 삭제 불가 + if (issuedCount > 0) { + throw new BusinessException(ErrorCode.COUPON_CANNOT_DELETE_ISSUED); + } } //쿠폰id로 쿠폰을 조회하는 공통 메서드 diff --git a/src/main/java/com/threestar/trainus/domain/coupon/user/dto/CouponPageResponseDto.java b/src/main/java/com/threestar/trainus/domain/coupon/user/dto/CouponPageResponseDto.java index 38d70ba2..b5b75685 100644 --- a/src/main/java/com/threestar/trainus/domain/coupon/user/dto/CouponPageResponseDto.java +++ b/src/main/java/com/threestar/trainus/domain/coupon/user/dto/CouponPageResponseDto.java @@ -2,11 +2,7 @@ import java.util.List; -import lombok.Builder; -import lombok.Getter; - -@Getter -@Builder -public class CouponPageResponseDto { - private List coupons; +public record CouponPageResponseDto( + List coupons +) { } diff --git a/src/main/java/com/threestar/trainus/domain/coupon/user/dto/CouponResponseDto.java b/src/main/java/com/threestar/trainus/domain/coupon/user/dto/CouponResponseDto.java index a98cc149..614d1d34 100644 --- a/src/main/java/com/threestar/trainus/domain/coupon/user/dto/CouponResponseDto.java +++ b/src/main/java/com/threestar/trainus/domain/coupon/user/dto/CouponResponseDto.java @@ -5,22 +5,15 @@ import com.threestar.trainus.domain.coupon.user.entity.CouponCategory; import com.threestar.trainus.domain.coupon.user.entity.OwnedStatus; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; - -@Getter -@Builder -@AllArgsConstructor -public class CouponResponseDto { - private Long couponId; - private String couponName; - private String discountPrice; - private Integer minOrderPrice; - private LocalDateTime expirationDate; - private OwnedStatus ownedStatus; - private Integer quantity; - private CouponCategory category; - private LocalDateTime openTime; - +public record CouponResponseDto( + Long couponId, + String couponName, + String discountPrice, + Integer minOrderPrice, + LocalDateTime expirationDate, + OwnedStatus ownedStatus, + Integer quantity, + CouponCategory category, + LocalDateTime openTime +) { } diff --git a/src/main/java/com/threestar/trainus/domain/coupon/user/dto/CreateUserCouponResponseDto.java b/src/main/java/com/threestar/trainus/domain/coupon/user/dto/CreateUserCouponResponseDto.java index 77fc55b1..2b8a4cd0 100644 --- a/src/main/java/com/threestar/trainus/domain/coupon/user/dto/CreateUserCouponResponseDto.java +++ b/src/main/java/com/threestar/trainus/domain/coupon/user/dto/CreateUserCouponResponseDto.java @@ -5,17 +5,12 @@ import com.fasterxml.jackson.annotation.JsonFormat; import com.threestar.trainus.domain.coupon.user.entity.CouponStatus; -import lombok.Builder; -import lombok.Getter; - -@Getter -@Builder -public class CreateUserCouponResponseDto { - private Long couponId; - private Long userId; +public record CreateUserCouponResponseDto( + Long couponId, + Long userId, @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") - private LocalDateTime createdAt; - private LocalDateTime expirationDate; - private CouponStatus status; - + LocalDateTime createdAt, + LocalDateTime expirationDate, + CouponStatus status +) { } diff --git a/src/main/java/com/threestar/trainus/domain/coupon/user/dto/UserCouponPageResponseDto.java b/src/main/java/com/threestar/trainus/domain/coupon/user/dto/UserCouponPageResponseDto.java index 50e93089..44fa7f5e 100644 --- a/src/main/java/com/threestar/trainus/domain/coupon/user/dto/UserCouponPageResponseDto.java +++ b/src/main/java/com/threestar/trainus/domain/coupon/user/dto/UserCouponPageResponseDto.java @@ -2,11 +2,7 @@ import java.util.List; -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -public class UserCouponPageResponseDto { - private List userCoupons; +public record UserCouponPageResponseDto( + List userCoupons +) { } diff --git a/src/main/java/com/threestar/trainus/domain/coupon/user/dto/UserCouponResponseDto.java b/src/main/java/com/threestar/trainus/domain/coupon/user/dto/UserCouponResponseDto.java index 85a95019..60dc9f70 100644 --- a/src/main/java/com/threestar/trainus/domain/coupon/user/dto/UserCouponResponseDto.java +++ b/src/main/java/com/threestar/trainus/domain/coupon/user/dto/UserCouponResponseDto.java @@ -4,17 +4,13 @@ import com.threestar.trainus.domain.coupon.user.entity.CouponStatus; -import lombok.Builder; -import lombok.Getter; - -@Getter -@Builder -public class UserCouponResponseDto { - private Long couponId; - private String couponName; - private String discountPrice; - private Integer minOrderPrice; - private LocalDateTime expirationDate; - private CouponStatus status; - private LocalDateTime useDate; +public record UserCouponResponseDto( + Long couponId, + String couponName, + String discountPrice, + Integer minOrderPrice, + LocalDateTime expirationDate, + CouponStatus status, + LocalDateTime useDate +) { } diff --git a/src/main/java/com/threestar/trainus/domain/coupon/user/mapper/CouponMapper.java b/src/main/java/com/threestar/trainus/domain/coupon/user/mapper/CouponMapper.java index 33dcf26a..42e2a6c8 100644 --- a/src/main/java/com/threestar/trainus/domain/coupon/user/mapper/CouponMapper.java +++ b/src/main/java/com/threestar/trainus/domain/coupon/user/mapper/CouponMapper.java @@ -9,17 +9,17 @@ public class CouponMapper { public static CouponResponseDto toDto(Coupon coupon, boolean owned) { - return CouponResponseDto.builder() - .couponId(coupon.getId()) - .couponName(coupon.getName()) - .discountPrice(coupon.getDiscountPrice()) - .minOrderPrice(coupon.getMinOrderPrice()) - .expirationDate(coupon.getExpirationDate()) - .ownedStatus(owned ? OwnedStatus.OWNED : OwnedStatus.NOT_OWNED) - .quantity(coupon.getQuantity()) - .category(coupon.getCategory()) - .openTime(coupon.getOpenAt()) - .build(); + return new CouponResponseDto( + coupon.getId(), + coupon.getName(), + coupon.getDiscountPrice(), + coupon.getMinOrderPrice(), + coupon.getExpirationDate(), + owned ? OwnedStatus.OWNED : OwnedStatus.NOT_OWNED, + coupon.getQuantity(), + coupon.getCategory(), + coupon.getOpenAt() + ); } public static List toDtoList(List coupons, List ownedCouponIds) { diff --git a/src/main/java/com/threestar/trainus/domain/coupon/user/mapper/UserCouponMapper.java b/src/main/java/com/threestar/trainus/domain/coupon/user/mapper/UserCouponMapper.java index 7814e99c..0a48b154 100644 --- a/src/main/java/com/threestar/trainus/domain/coupon/user/mapper/UserCouponMapper.java +++ b/src/main/java/com/threestar/trainus/domain/coupon/user/mapper/UserCouponMapper.java @@ -13,26 +13,26 @@ private UserCouponMapper() { } public static CreateUserCouponResponseDto toCreateUserCouponResponseDto(UserCoupon userCoupon) { - return CreateUserCouponResponseDto.builder() - .couponId(userCoupon.getCoupon().getId()) - .userId(userCoupon.getUser().getId()) - .createdAt(userCoupon.getCreatedAt()) - .expirationDate(userCoupon.getExpirationDate()) - .status(userCoupon.getStatus()) - .build(); + return new CreateUserCouponResponseDto( + userCoupon.getCoupon().getId(), + userCoupon.getUser().getId(), + userCoupon.getCreatedAt(), + userCoupon.getExpirationDate(), + userCoupon.getStatus() + ); } public static UserCouponResponseDto toUserCouponResponseDto(UserCoupon userCoupon) { Coupon coupon = userCoupon.getCoupon(); - return UserCouponResponseDto.builder() - .couponId(coupon.getId()) - .couponName(coupon.getName()) - .discountPrice(coupon.getDiscountPrice()) - .minOrderPrice(coupon.getMinOrderPrice()) - .expirationDate(coupon.getExpirationDate()) - .status(coupon.getStatus()) - .useDate(userCoupon.getUseDate()) - .build(); + return new UserCouponResponseDto( + coupon.getId(), + coupon.getName(), + coupon.getDiscountPrice(), + coupon.getMinOrderPrice(), + coupon.getExpirationDate(), + coupon.getStatus(), + userCoupon.getUseDate() + ); } public static List toDtoList(List userCoupons) { diff --git a/src/main/java/com/threestar/trainus/domain/coupon/user/repository/CouponRepository.java b/src/main/java/com/threestar/trainus/domain/coupon/user/repository/CouponRepository.java index 5d819baa..8b22da97 100644 --- a/src/main/java/com/threestar/trainus/domain/coupon/user/repository/CouponRepository.java +++ b/src/main/java/com/threestar/trainus/domain/coupon/user/repository/CouponRepository.java @@ -53,4 +53,57 @@ Page findCouponsWithFilters( @Param("category") CouponCategory category, Pageable pageable ); + + //활성화할 쿠폰을 찾는 메서드 + // 현재는 비활성화 상태이지만, 오픈시간에 도달했으면서 마감기한이 안지난 쿠폰들 + @Query(""" + SELECT c FROM Coupon c + WHERE c.status = com.threestar.trainus.domain.coupon.user.entity.CouponStatus.INACTIVE + AND c.openAt <= :now + AND c.closeAt > :now + AND c.deletedAt IS NULL + """) + List findInactiveCouponsToActivate(@Param("now") LocalDateTime now); + + //비활성화 할 쿠폰을 찾는 메서드 + //현재 활성화 상태이지만, 마감시간이 지난 쿠폰들을 검사 + @Query(""" + SELECT c FROM Coupon c + WHERE c.status = com.threestar.trainus.domain.coupon.user.entity.CouponStatus.ACTIVE + AND c.closeAt <= :now + AND c.deletedAt IS NULL + """) + List findActiveCouponsToDeactivate(@Param("now") LocalDateTime now); + + @Query(value = """ + SELECT * + FROM coupons c + WHERE (:status IS NULL OR c.status = :status) + AND (:category IS NULL OR c.category = :category) + AND c.deleted_at IS NULL + ORDER BY c.created_at DESC + LIMIT :limit OFFSET :offset + """, nativeQuery = true) + List findCouponsWithFilters( + @Param("status") CouponStatus status, + @Param("category") CouponCategory category, + @Param("offset") int offset, + @Param("limit") int limit + ); + + @Query(value = """ + SELECT COUNT(*) FROM ( + SELECT c.id + FROM coupons c + WHERE (:status IS NULL OR c.status = :status) + AND (:category IS NULL OR c.category = :category) + AND c.deleted_at IS NULL + LIMIT :limit + ) t + """, nativeQuery = true) + int countCouponsWithFilters( + @Param("status") CouponStatus status, + @Param("category") CouponCategory category, + @Param("limit") int limit + ); } diff --git a/src/main/java/com/threestar/trainus/domain/coupon/user/repository/UserCouponRepository.java b/src/main/java/com/threestar/trainus/domain/coupon/user/repository/UserCouponRepository.java index 6e50f082..fe7ce3b6 100644 --- a/src/main/java/com/threestar/trainus/domain/coupon/user/repository/UserCouponRepository.java +++ b/src/main/java/com/threestar/trainus/domain/coupon/user/repository/UserCouponRepository.java @@ -1,5 +1,6 @@ package com.threestar.trainus.domain.coupon.user.repository; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -27,4 +28,14 @@ List findAllByUserIdAndStatusWithCoupon(@Param("userId") Long userId // 특정 사용자가 특정 쿠폰을 발급받은 수 조회 Long countByUserIdAndCouponId(Long userId, Long couponId); + + //만료시킬 유저쿠폰을 찾는 메서드 + //현재 활성화 상태이지만, 사용 유효기간이 지난 유저쿠폰들임! + @Query(""" + SELECT uc FROM UserCoupon uc + WHERE uc.status = com.threestar.trainus.domain.coupon.user.entity.CouponStatus.ACTIVE + AND uc.expirationDate <= :now + """) + List findActiveUserCouponsToExpire(@Param("now") LocalDateTime now); + } diff --git a/src/main/java/com/threestar/trainus/domain/coupon/user/service/CouponService.java b/src/main/java/com/threestar/trainus/domain/coupon/user/service/CouponService.java index ed65f20a..9ce36b87 100644 --- a/src/main/java/com/threestar/trainus/domain/coupon/user/service/CouponService.java +++ b/src/main/java/com/threestar/trainus/domain/coupon/user/service/CouponService.java @@ -20,6 +20,7 @@ import com.threestar.trainus.domain.coupon.user.repository.UserCouponRepository; import com.threestar.trainus.domain.user.entity.User; import com.threestar.trainus.domain.user.service.UserService; +import com.threestar.trainus.global.annotation.RedissonLock; import com.threestar.trainus.global.exception.domain.ErrorCode; import com.threestar.trainus.global.exception.handler.BusinessException; @@ -34,12 +35,14 @@ public class CouponService { private final UserService userService; @Transactional + @RedissonLock(value = "#couponId") public CreateUserCouponResponseDto createUserCoupon(Long userId, Long couponId) { User user = userService.getUserById(userId); - Coupon coupon = couponRepository.findByIdWithPessimisticLock(couponId) + // Coupon coupon = couponRepository.findByIdWithPessimisticLock(couponId) + // .orElseThrow(() -> new BusinessException(ErrorCode.COUPON_NOT_FOUND)); + Coupon coupon = couponRepository.findById(couponId) .orElseThrow(() -> new BusinessException(ErrorCode.COUPON_NOT_FOUND)); - - //쿠폰 발급 종료시각이 지났으면 예외처리 + // 쿠폰 발급 종료시각이 지났으면 예외처리 if (LocalDateTime.now().isAfter(coupon.getCloseAt())) { throw new BusinessException(ErrorCode.COUPON_EXPIRED); } @@ -48,11 +51,13 @@ public CreateUserCouponResponseDto createUserCoupon(Long userId, Long couponId) if (alreadyIssued) { throw new BusinessException(ErrorCode.COUPON_ALREADY_ISSUED); } + + //쿠폰 오픈 시간 전이라면 예외 처리(모든 쿠폰 공통) + if (LocalDateTime.now().isBefore(coupon.getOpenAt())) { + throw new BusinessException(ErrorCode.COUPON_NOT_YET_OPEN); + } + if (coupon.getCategory() == CouponCategory.OPEN_RUN) { - //선착순 쿠폰 발급 시 오픈시각 전이라면 예외처리 - if (LocalDateTime.now().isBefore(coupon.getOpenAt())) { - throw new BusinessException(ErrorCode.COUPON_NOT_YET_OPEN); - } //선착순 쿠폰 발급 시 수량이 소진되면 예외처리 if (coupon.getQuantity() <= 0) { throw new BusinessException(ErrorCode.COUPON_BE_EXHAUSTED); @@ -87,12 +92,10 @@ public UserCouponPageResponseDto getUserCoupons(Long userId, CouponStatus status public CouponPageResponseDto getCoupons(Long userId) { userService.validateUserExists(userId); - List dtoList = - couponRepository.findAvailableCouponsWithOwnership(userId, LocalDateTime.now()); + List dtoList = couponRepository.findAvailableCouponsWithOwnership(userId, + LocalDateTime.now()); - return CouponPageResponseDto.builder() - .coupons(dtoList) - .build(); + return new CouponPageResponseDto(dtoList); } @Transactional diff --git a/src/main/java/com/threestar/trainus/domain/file/controller/S3Controller.java b/src/main/java/com/threestar/trainus/domain/file/controller/S3Controller.java new file mode 100644 index 00000000..3cd55561 --- /dev/null +++ b/src/main/java/com/threestar/trainus/domain/file/controller/S3Controller.java @@ -0,0 +1,34 @@ +package com.threestar.trainus.domain.file.controller; + +import lombok.RequiredArgsConstructor; + +import org.springframework.http.HttpStatus; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.threestar.trainus.domain.file.dto.GetS3UrlDto; +import com.threestar.trainus.domain.file.service.S3Service; +import com.threestar.trainus.global.annotation.LoginUser; +import com.threestar.trainus.global.unit.BaseResponse; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/s3") +public class S3Controller { + private final S3Service s3Service; + + @GetMapping(value = "/posturl") + public ResponseEntity> getPostS3Url(@LoginUser Long userId, String filename) { + GetS3UrlDto response = s3Service.getPostS3Url("image/" + userId, filename); + return BaseResponse.ok("업로드용 presigned url 발급 완료", response, HttpStatus.OK); + } + + @GetMapping(value = "/geturl") + public ResponseEntity> getGetS3Url(@LoginUser Long userId, @RequestParam String key) { + GetS3UrlDto response = s3Service.getGetS3Url(key); + return BaseResponse.ok("조회용 presigned url 발급 완료", response, HttpStatus.OK); + } +} diff --git a/src/main/java/com/threestar/trainus/domain/file/dto/GetS3UrlDto.java b/src/main/java/com/threestar/trainus/domain/file/dto/GetS3UrlDto.java new file mode 100644 index 00000000..dd08654a --- /dev/null +++ b/src/main/java/com/threestar/trainus/domain/file/dto/GetS3UrlDto.java @@ -0,0 +1,22 @@ +package com.threestar.trainus.domain.file.dto; + +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@EqualsAndHashCode +public class GetS3UrlDto { + private String preSignedUrl; + private String key; + + @Builder + public GetS3UrlDto(String preSignedUrl, String key) { + this.preSignedUrl = preSignedUrl; + this.key = key; + } +} diff --git a/src/main/java/com/threestar/trainus/domain/file/service/S3Service.java b/src/main/java/com/threestar/trainus/domain/file/service/S3Service.java new file mode 100644 index 00000000..0b3cdbc9 --- /dev/null +++ b/src/main/java/com/threestar/trainus/domain/file/service/S3Service.java @@ -0,0 +1,110 @@ +package com.threestar.trainus.domain.file.service; + +import java.net.URL; +import java.util.Date; +import java.util.Map; +import java.util.UUID; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.amazonaws.HttpMethod; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.Headers; +import com.amazonaws.services.s3.model.CannedAccessControlList; +import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest; +import com.threestar.trainus.domain.file.dto.GetS3UrlDto; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class S3Service { + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + private final AmazonS3 amazonS3; + + public GetS3UrlDto getPostS3Url(String prefix, String fileName) { + if (!prefix.isEmpty()) { + fileName = createPath(prefix, fileName); + } + + GeneratePresignedUrlRequest generatePresignedUrlRequest = getGeneratePresignedUrlRequest(bucket, fileName); + URL url = amazonS3.generatePresignedUrl(generatePresignedUrlRequest); + + return GetS3UrlDto.builder() + .preSignedUrl(url.toString()) + .key(fileName) + .build(); + } + + // 파일 업로드용 Presigned URL 생성 + private GeneratePresignedUrlRequest getGeneratePresignedUrlRequest(String bucket, String fileName) { + GeneratePresignedUrlRequest generatePresignedUrlRequest = new GeneratePresignedUrlRequest(bucket, fileName) + .withMethod(HttpMethod.PUT) + .withExpiration(getPresignedUrlExpiration()); + + generatePresignedUrlRequest.addRequestParameter( + Headers.S3_CANNED_ACL, + CannedAccessControlList.PublicRead.toString() + ); + + return generatePresignedUrlRequest; + } + + //Presigned URL 유효기간 설정 + private Date getPresignedUrlExpiration() { + Date expiration = new Date(); + long expTimeMillis = expiration.getTime(); + expTimeMillis += 1000 * 60 * 10; + expiration.setTime(expTimeMillis); + + return expiration; + } + + //UUID를 사용하여 파일 고유 ID 생성 + private String createFileId() { + return UUID.randomUUID().toString(); + } + + //파일의 전체 경로 생성 + private String createPath(String prefix, String fileName) { + String fileId = createFileId(); + return String.format("%s/%s", prefix, fileId + "-" + fileName); + } + + // get 용 URL 생성 + private GeneratePresignedUrlRequest getGetGeneratePresignedUrlRequest(String key, Date expiration) { + return new GeneratePresignedUrlRequest(bucket, key) + .withMethod(HttpMethod.GET) + .withExpiration(expiration); + } + + @Transactional(readOnly = true) + public GetS3UrlDto getGetS3Url(String key) { + // url 유효기간 설정하기(1시간) + Date expiration = getExpiration(); + + // presigned url 생성하기 + GeneratePresignedUrlRequest generatePresignedUrlRequest = + getGetGeneratePresignedUrlRequest(key, expiration); + + URL url = amazonS3.generatePresignedUrl(generatePresignedUrlRequest); + + // return + return GetS3UrlDto.builder() + .preSignedUrl(url.toExternalForm()) + .key(key) + .build(); + } + + private static Date getExpiration() { + Date expiration = new Date(); + long expTimeMillis = expiration.getTime(); + expTimeMillis += 1000 * 60 * 10; // 10분으로 설정하기 + expiration.setTime(expTimeMillis); + return expiration; + } +} diff --git a/src/main/java/com/threestar/trainus/domain/lesson/student/controller/StudentLessonController.java b/src/main/java/com/threestar/trainus/domain/lesson/student/controller/StudentLessonController.java index d81e5c08..db5f6e61 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/student/controller/StudentLessonController.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/student/controller/StudentLessonController.java @@ -18,6 +18,7 @@ import com.threestar.trainus.domain.lesson.student.dto.LessonSimpleResponseDto; import com.threestar.trainus.domain.lesson.student.dto.MyLessonApplicationListResponseDto; import com.threestar.trainus.domain.lesson.student.dto.MyLessonApplicationListWrapperDto; +import com.threestar.trainus.domain.lesson.student.entity.LessonSortType; import com.threestar.trainus.domain.lesson.student.service.StudentLessonService; import com.threestar.trainus.domain.lesson.teacher.entity.Category; import com.threestar.trainus.global.annotation.LoginUser; @@ -43,18 +44,21 @@ public class StudentLessonController { private final StudentLessonService studentLessonService; @GetMapping - @Operation(summary = "레슨 검색 api", description = "category(필수, Default: \"ALL\") / search(선택) / 그외 법정동 선택 필수") + @Operation(summary = "레슨 검색 api", description = "category(선택(null 가능)) / search(선택) " + + "/ 정렬 선택 필수 / 법정동 선택 필수(리(ri) 단위 제외)") public ResponseEntity> searchLessons( @Valid @ModelAttribute PageRequestDto pageRequestDto, - @RequestParam Category category, + @RequestParam(required = false) Category category, @RequestParam(required = false) String search, @RequestParam String city, @RequestParam String district, - @RequestParam String dong + @RequestParam String dong, + @RequestParam(required = false) String ri, + @RequestParam(required = false) LessonSortType sortBy ) { LessonSearchListResponseDto serviceResponse = studentLessonService.searchLessons( - pageRequestDto.getPage(), pageRequestDto.getLimit(), category, search, city, district, dong + pageRequestDto.getPage(), pageRequestDto.getLimit(), category, search, city, district, dong, ri, sortBy ); LessonSearchListWrapperDto response = new LessonSearchListWrapperDto(serviceResponse.lessons()); diff --git a/src/main/java/com/threestar/trainus/domain/lesson/student/dto/LessonDetailResponseDto.java b/src/main/java/com/threestar/trainus/domain/lesson/student/dto/LessonDetailResponseDto.java index 8746f0bb..5d29aeaa 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/student/dto/LessonDetailResponseDto.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/student/dto/LessonDetailResponseDto.java @@ -32,6 +32,7 @@ public record LessonDetailResponseDto( String city, String district, String dong, + String ri, String addressDetail, LocalDateTime createdAt, LocalDateTime updatedAt, diff --git a/src/main/java/com/threestar/trainus/domain/lesson/student/dto/LessonSearchResponseDto.java b/src/main/java/com/threestar/trainus/domain/lesson/student/dto/LessonSearchResponseDto.java index d0b15d11..8619e4d7 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/student/dto/LessonSearchResponseDto.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/student/dto/LessonSearchResponseDto.java @@ -25,6 +25,7 @@ public record LessonSearchResponseDto( String city, String district, String dong, + String ri, LocalDateTime createdAt, List lessonImages ) { diff --git a/src/main/java/com/threestar/trainus/domain/lesson/student/entity/EmptyClass.java b/src/main/java/com/threestar/trainus/domain/lesson/student/entity/EmptyClass.java deleted file mode 100644 index 2934143c..00000000 --- a/src/main/java/com/threestar/trainus/domain/lesson/student/entity/EmptyClass.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.threestar.trainus.domain.lesson.student.entity; - -public class EmptyClass { -} diff --git a/src/main/java/com/threestar/trainus/domain/lesson/student/entity/LessonSortType.java b/src/main/java/com/threestar/trainus/domain/lesson/student/entity/LessonSortType.java new file mode 100644 index 00000000..318b5b87 --- /dev/null +++ b/src/main/java/com/threestar/trainus/domain/lesson/student/entity/LessonSortType.java @@ -0,0 +1,14 @@ +package com.threestar.trainus.domain.lesson.student.entity; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum LessonSortType { + LATEST("createdAt"), + OLDEST("createdAt"), + PRICE_HIGH("price"), + PRICE_LOW("price"); + private final String property; +} diff --git a/src/main/java/com/threestar/trainus/domain/lesson/student/mapper/LessonSearchMapper.java b/src/main/java/com/threestar/trainus/domain/lesson/student/mapper/LessonSearchMapper.java index ed56f870..15ba96fe 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/student/mapper/LessonSearchMapper.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/student/mapper/LessonSearchMapper.java @@ -40,6 +40,7 @@ public static LessonSearchResponseDto toLessonSearchResponseDto( lesson.getCity(), lesson.getDistrict(), lesson.getDong(), + lesson.getRi(), lesson.getCreatedAt(), lessonImageUrls ); diff --git a/src/main/java/com/threestar/trainus/domain/lesson/student/service/StudentLessonService.java b/src/main/java/com/threestar/trainus/domain/lesson/student/service/StudentLessonService.java index 98f1102c..e2cbe053 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/student/service/StudentLessonService.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/student/service/StudentLessonService.java @@ -1,11 +1,11 @@ package com.threestar.trainus.domain.lesson.student.service; +import java.util.Collections; import java.util.List; +import java.util.Optional; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import com.threestar.trainus.domain.lesson.student.dto.LessonApplicationResponseDto; import com.threestar.trainus.domain.lesson.student.dto.LessonDetailResponseDto; @@ -13,6 +13,7 @@ import com.threestar.trainus.domain.lesson.student.dto.LessonSearchResponseDto; import com.threestar.trainus.domain.lesson.student.dto.LessonSimpleResponseDto; import com.threestar.trainus.domain.lesson.student.dto.MyLessonApplicationListResponseDto; +import com.threestar.trainus.domain.lesson.student.entity.LessonSortType; import com.threestar.trainus.domain.lesson.student.mapper.LessonApplicationMapper; import com.threestar.trainus.domain.lesson.student.mapper.LessonApplyMapper; import com.threestar.trainus.domain.lesson.student.mapper.LessonSearchMapper; @@ -24,6 +25,7 @@ import com.threestar.trainus.domain.lesson.teacher.entity.LessonImage; import com.threestar.trainus.domain.lesson.teacher.entity.LessonParticipant; import com.threestar.trainus.domain.lesson.teacher.entity.LessonStatus; +import com.threestar.trainus.domain.lesson.teacher.entity.ParticipantStatus; import com.threestar.trainus.domain.lesson.teacher.mapper.LessonMapper; import com.threestar.trainus.domain.lesson.teacher.repository.LessonApplicationRepository; import com.threestar.trainus.domain.lesson.teacher.repository.LessonImageRepository; @@ -38,8 +40,8 @@ import com.threestar.trainus.domain.user.service.UserService; import com.threestar.trainus.global.exception.domain.ErrorCode; import com.threestar.trainus.global.exception.handler.BusinessException; +import com.threestar.trainus.global.utils.PageLimitCalculator; -import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; @Service @@ -55,43 +57,51 @@ public class StudentLessonService { private final LessonParticipantRepository lessonParticipantRepository; private final LessonApplicationRepository lessonApplicationRepository; - @Transactional + @Transactional(readOnly = true) public LessonSearchListResponseDto searchLessons( - int page, int limit, + int page, int pageSize, Category category, String search, - String city, String district, String dong + String city, String district, String dong, String ri, + LessonSortType sortBy ) { - Pageable pageable = PageRequest.of(page - 1, limit); - - Category categoryEnum = null; - if (!category.name().equalsIgnoreCase("ALL")) { - try { - categoryEnum = category; - } catch (IllegalArgumentException e) { - throw new BusinessException(ErrorCode.INVALID_CATEGORY); - } + if (sortBy == null) { + throw new BusinessException(ErrorCode.INVALID_SORT); } - Page lessonPage = lessonRepository.findBySearchConditions( - categoryEnum, city, district, dong, search, pageable + // offset/limit 계산 + int offset = (page - 1) * pageSize; + int countLimit = PageLimitCalculator.calculatePageLimit(page, pageSize, 5); + + // 카테고리 ALL 처리 + String categoryValue = (category != null && !category.name().equalsIgnoreCase("ALL")) + ? category.name() : null; + + // Lesson 목록 조회 (검색어 여부에 따라 분기) + List lessons = (search != null && !search.isEmpty()) + ? lessonRepository.findLessonsWithFullText( + categoryValue, city, district, dong, ri, search, sortBy.name(), offset, pageSize + ) + : lessonRepository.findLessonsWithoutFullText( + categoryValue, city, district, dong, ri, sortBy.name(), offset, pageSize + ); + + // count 조회 (검색어 여부에 따라 분기) + int total = (search != null && !search.isEmpty()) + ? lessonRepository.countLessonsWithFullText( + categoryValue, city, district, dong, ri, search, countLimit + ) + : lessonRepository.countLessonsWithoutFullText( + categoryValue, city, district, dong, ri, countLimit ); - // 응답 DTO 리스트 매핑 - List lessonDtos = lessonPage.getContent().stream() + // DTO 매핑 + List lessonDtos = lessons.stream() .map(lesson -> { - // 개설자 정보 조회 User leader = userService.getUserById(lesson.getLessonLeader()); - - // 프로필 이미지 Profile profile = profileRepository.findByUserId(leader.getId()) .orElseThrow(() -> new BusinessException(ErrorCode.PROFILE_NOT_FOUND)); - /* - * TODO: 프로필 공통예외처리 분리 - * */ - // 리뷰 개수, 평점 등 메타데이터 ProfileMetadataResponseDto metadata = profileMetadataService.getMetadata(leader.getId()); - // 이미지 URL 목록 List imageUrls = lessonImageRepository.findAllByLessonId(lesson.getId()).stream() .map(LessonImage::getImageUrl) .toList(); @@ -100,7 +110,7 @@ public LessonSearchListResponseDto searchLessons( }) .toList(); - return new LessonSearchListResponseDto(lessonDtos, (int)lessonPage.getTotalElements()); + return new LessonSearchListResponseDto(lessonDtos, total); } @Transactional @@ -162,9 +172,15 @@ public LessonApplicationResponseDto applyToLesson(Long lessonId, Long userId) { throw new BusinessException(ErrorCode.LESSON_NOT_AVAILABLE); } + + // 선착순 여부에 따라 저장 처리 분기 if (lesson.getOpenRun()) { - // 바로 참가자 등록, 인원수 증가 TODO 성능개선시 동시성 고려 + // 신청 시간 체크 + if (java.time.LocalDateTime.now().isBefore(lesson.getOpenTime())) { + throw new BusinessException(ErrorCode.LESSON_NOT_YET_OPEN); + } + // 바로 참가자 등록, 인원수 증가 LessonParticipant participant = LessonParticipant.builder() .lesson(lesson) .user(user) @@ -245,7 +261,6 @@ public LessonApplicationResponseDto applyToLessonWithLock(Long lessonId, Long us } } - @Transactional public void cancelLessonApplication(Long lessonId, Long userId) { // 레슨 조회 @@ -270,13 +285,13 @@ public void cancelLessonApplication(Long lessonId, Long userId) { lessonApplicationRepository.delete(application); } - @Transactional - public MyLessonApplicationListResponseDto getMyLessonApplications(Long userId, int page, int limit, - String statusStr) { + @Transactional(readOnly = true) + public MyLessonApplicationListResponseDto getMyLessonApplications( + Long userId, int page, int limit, String statusStr + ) { // status enum 변환 ApplicationStatus status = null; - if (!statusStr.equalsIgnoreCase("ALL")) { - //status value 검증 + if (!"ALL".equalsIgnoreCase(statusStr)) { try { status = ApplicationStatus.valueOf(statusStr.toUpperCase()); } catch (IllegalArgumentException e) { @@ -284,19 +299,32 @@ public MyLessonApplicationListResponseDto getMyLessonApplications(Long userId, i } } - // 페이징 정렬 - Pageable pageable = PageRequest.of(page - 1, limit); + // offset 계산 + int offset = (page - 1) * limit; - // 신청 내역 조회 - Page applicationPage = (status == null) - ? lessonApplicationRepository.findByUserId(userId, pageable) - : lessonApplicationRepository.findByUserIdAndStatus(userId, status, pageable); + // count limit 계산 (5개씩 이동 기준) + int countLimit = PageLimitCalculator.calculatePageLimit(page, limit, 5); - // DTO 변환 - return LessonApplicationMapper.toDtoListWithCount( - applicationPage.getContent(), - (int)applicationPage.getTotalElements() + // 목록 조회 + List ids = lessonApplicationRepository.findIdsByUserAndStatus( + userId, + status != null ? status.name() : null, + offset, + limit ); + + List applications = ids.isEmpty() + ? Collections.emptyList() + : lessonApplicationRepository.findAllWithFetchJoin(ids); + + // count 조회 (최대 countLimit까지만 계산) + int total = lessonApplicationRepository.countByUserAndStatus( + userId, + status != null ? status.name() : null, + countLimit + ); + + return LessonApplicationMapper.toDtoListWithCount(applications, total); } @Transactional @@ -321,9 +349,25 @@ public void cancelPayment(Long lessonId, Long userId) { @Transactional public void checkValidLessonParticipant(Lesson lesson, User user) { - boolean ifExists = lessonParticipantRepository.existsByLessonIdAndUserId(lesson.getId(), user.getId()); - if (!ifExists) { + Optional participant = lessonParticipantRepository + .findByLessonIdAndUserIdAndStatus( + lesson.getId(), + user.getId(), + ParticipantStatus.PAYMENT_PENDING + ); + + if (participant.isEmpty()) { throw new BusinessException(ErrorCode.INVALID_LESSON_PARTICIPANT); } } + + @Transactional + public void completeParticipantPayment(Long lessonId, Long userId) { + LessonParticipant participant = lessonParticipantRepository + .findByLessonIdAndUserIdAndStatus(lessonId, userId, ParticipantStatus.PAYMENT_PENDING) + .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_LESSON_PARTICIPANT)); + + participant.completePayment(); + lessonParticipantRepository.save(participant); + } } diff --git a/src/main/java/com/threestar/trainus/domain/lesson/teacher/constants/LessonConstants.java b/src/main/java/com/threestar/trainus/domain/lesson/teacher/constants/LessonConstants.java new file mode 100644 index 00000000..bae8ccce --- /dev/null +++ b/src/main/java/com/threestar/trainus/domain/lesson/teacher/constants/LessonConstants.java @@ -0,0 +1,24 @@ +package com.threestar.trainus.domain.lesson.teacher.constants; + +//레슨 관련 상수 정의 +public final class LessonConstants { + + private LessonConstants() { + // Utility class - 인스턴스 생성 방지 + } + + //시간 관련 상수 + public static final class Time { + //레슨 수정/삭제 제한 시간 (시작 전 12시간) + public static final int EDIT_DELETE_LIMIT_HOURS = 12; + } + + //참가자 수 관련 상수 + public static final class Participants { + //일반 레슨 최대 참가자 수 + public static final int MAX_NORMAL_PARTICIPANTS = 100; + + //선착순 레슨 최대 참가자 수 + public static final int MAX_OPEN_RUN_PARTICIPANTS = 10000; + } +} diff --git a/src/main/java/com/threestar/trainus/domain/lesson/teacher/dto/CreatedLessonDto.java b/src/main/java/com/threestar/trainus/domain/lesson/teacher/dto/CreatedLessonDto.java index 1ba0695e..d218dacc 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/teacher/dto/CreatedLessonDto.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/teacher/dto/CreatedLessonDto.java @@ -19,6 +19,7 @@ public record CreatedLessonDto( LessonStatus status, LocalDateTime startAt, LocalDateTime endAt, + LocalDateTime openTime, Boolean openRun, String addressDetail ) { diff --git a/src/main/java/com/threestar/trainus/domain/lesson/teacher/dto/LessonCreateRequestDto.java b/src/main/java/com/threestar/trainus/domain/lesson/teacher/dto/LessonCreateRequestDto.java index ec398c6b..e18448fc 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/teacher/dto/LessonCreateRequestDto.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/teacher/dto/LessonCreateRequestDto.java @@ -59,6 +59,9 @@ public record LessonCreateRequestDto( @Size(max = 10, message = "읍/면/동은 10자 이하여야 합니다.") String dong, + @Size(max = 10, message = "리는 10자 이하여야 합니다.") // ri 추가, NotBlank 제거 + String ri, + @NotBlank(message = "상세주소는 필수입니다.") @Size(max = 25, message = "상세주소는 25자 이하여야 합니다.") String addressDetail, diff --git a/src/main/java/com/threestar/trainus/domain/lesson/teacher/dto/LessonResponseDto.java b/src/main/java/com/threestar/trainus/domain/lesson/teacher/dto/LessonResponseDto.java index ab64a888..6a749a77 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/teacher/dto/LessonResponseDto.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/teacher/dto/LessonResponseDto.java @@ -24,6 +24,7 @@ public record LessonResponseDto( String city, String district, String dong, + String ri, String addressDetail, LessonStatus status, LocalDateTime createdAt, diff --git a/src/main/java/com/threestar/trainus/domain/lesson/teacher/dto/LessonUpdateRequestDto.java b/src/main/java/com/threestar/trainus/domain/lesson/teacher/dto/LessonUpdateRequestDto.java index cefc7971..9714cb37 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/teacher/dto/LessonUpdateRequestDto.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/teacher/dto/LessonUpdateRequestDto.java @@ -46,6 +46,9 @@ public record LessonUpdateRequestDto( @Size(max = 10, message = "읍/면/동은 10자 이하여야 합니다.") String dong, + @Size(max = 10, message = "리는 10자 이하여야 합니다.") + String ri, + @Size(max = 25, message = "상세주소는 25자 이하여야 합니다.") String addressDetail, @@ -62,7 +65,7 @@ public boolean hasBasicInfoChanges() { // 제한된 필드들은 수정(참가자 없을때만 가능) public boolean hasRestrictedChanges() { return category != null || price != null || startAt != null || endAt != null || openTime != null - || openRun != null || city != null || district != null || dong != null || addressDetail != null; + || openRun != null || city != null || district != null || dong != null || ri != null || addressDetail != null; } //시간 관련 필드 수정 체크 diff --git a/src/main/java/com/threestar/trainus/domain/lesson/teacher/dto/LessonUpdateResponseDto.java b/src/main/java/com/threestar/trainus/domain/lesson/teacher/dto/LessonUpdateResponseDto.java index 0b0ca166..a55d448b 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/teacher/dto/LessonUpdateResponseDto.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/teacher/dto/LessonUpdateResponseDto.java @@ -27,6 +27,7 @@ public record LessonUpdateResponseDto( String city, String district, String dong, + String ri, String addressDetail, LessonStatus status, LocalDateTime createdAt, diff --git a/src/main/java/com/threestar/trainus/domain/lesson/teacher/dto/ParticipantDto.java b/src/main/java/com/threestar/trainus/domain/lesson/teacher/dto/ParticipantDto.java index e9eff990..136aee0e 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/teacher/dto/ParticipantDto.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/teacher/dto/ParticipantDto.java @@ -2,6 +2,7 @@ import java.time.LocalDateTime; +import com.threestar.trainus.domain.lesson.teacher.entity.ParticipantStatus; import com.threestar.trainus.domain.profile.dto.ProfileResponseDto; import lombok.Builder; @@ -11,6 +12,7 @@ public record ParticipantDto( Long lessonApplicationId, ProfileResponseDto user, + ParticipantStatus participantStatus, LocalDateTime joinedAt ) { } diff --git a/src/main/java/com/threestar/trainus/domain/lesson/teacher/entity/Lesson.java b/src/main/java/com/threestar/trainus/domain/lesson/teacher/entity/Lesson.java index cde2226a..c19f1571 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/teacher/entity/Lesson.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/teacher/entity/Lesson.java @@ -80,6 +80,9 @@ public class Lesson extends BaseDateEntity { @Column(nullable = false, length = 10) private String dong; + @Column(length = 10) + private String ri; + @Column(nullable = false, length = 25) private String addressDetail; @@ -91,6 +94,7 @@ public Lesson(Long lessonLeader, String lessonName, String description, Integer maxParticipants, LocalDateTime startAt, LocalDateTime endAt, Integer price, Category category, LocalDateTime openTime, Boolean openRun, String city, String district, String dong, + String ri, String addressDetail) { this.lessonLeader = lessonLeader; this.lessonName = lessonName; @@ -105,6 +109,7 @@ public Lesson(Long lessonLeader, String lessonName, String description, this.city = city; this.district = district; this.dong = dong; + this.ri = ri; this.addressDetail = addressDetail; //새로 생성된 레슨은 항상 모집중 상태로 초기값 설정 this.status = LessonStatus.RECRUITING; @@ -203,7 +208,7 @@ public void updateOpenRun(Boolean openRun) { } //지역정보 수정 - public void updateLocation(String city, String district, String dong) { + public void updateLocation(String city, String district, String dong, String ri) { if (city != null && !city.trim().isEmpty()) { this.city = city; } @@ -213,6 +218,7 @@ public void updateLocation(String city, String district, String dong) { if (dong != null && !dong.trim().isEmpty()) { this.dong = dong; } + this.ri = ri; } //상세주소 수정 @@ -221,4 +227,10 @@ public void updateAddressDetail(String addressDetail) { this.addressDetail = addressDetail; } } + + //레슨 상태 수정->스케쥴러꺼 + public void updateStatus(LessonStatus status) { + this.status = status; + } + } diff --git a/src/main/java/com/threestar/trainus/domain/lesson/teacher/entity/LessonImage.java b/src/main/java/com/threestar/trainus/domain/lesson/teacher/entity/LessonImage.java index ba02a399..fc501dc4 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/teacher/entity/LessonImage.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/teacher/entity/LessonImage.java @@ -28,7 +28,7 @@ public class LessonImage { @JoinColumn(name = "lesson_id", nullable = false) private Lesson lesson; - @Column(name = "image_url", nullable = false, length = 255) + @Column(name = "image_url", nullable = false, length = 2048) private String imageUrl; @Builder diff --git a/src/main/java/com/threestar/trainus/domain/lesson/teacher/entity/LessonParticipant.java b/src/main/java/com/threestar/trainus/domain/lesson/teacher/entity/LessonParticipant.java index 82d516a2..b2a5868e 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/teacher/entity/LessonParticipant.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/teacher/entity/LessonParticipant.java @@ -6,6 +6,8 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -39,14 +41,40 @@ public class LessonParticipant { @Column(nullable = false) private LocalDateTime joinAt; + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ParticipantStatus status; + @Builder public LessonParticipant(Lesson lesson, User user) { this.lesson = lesson; this.user = user; + if (lesson.getPrice() == 0) { + this.status = ParticipantStatus.COMPLETED; + } else { + this.status = ParticipantStatus.PAYMENT_PENDING; + } } @PrePersist private void prePersist() { this.joinAt = LocalDateTime.now(); } + + // 결제 완료 시 상태 변경 메서드 + public void completePayment() { + if (this.status == ParticipantStatus.PAYMENT_PENDING) { + this.status = ParticipantStatus.COMPLETED; + } + } + + // 결제 대기 상태인지 확인하는 메서드 + public boolean isPaymentPending() { + return this.status == ParticipantStatus.PAYMENT_PENDING; + } + + // 참가 완료 상태인지 확인하는 메서드 + public boolean isCompleted() { + return this.status == ParticipantStatus.COMPLETED; + } } diff --git a/src/main/java/com/threestar/trainus/domain/lesson/teacher/entity/ParticipantStatus.java b/src/main/java/com/threestar/trainus/domain/lesson/teacher/entity/ParticipantStatus.java new file mode 100644 index 00000000..61fdfaf7 --- /dev/null +++ b/src/main/java/com/threestar/trainus/domain/lesson/teacher/entity/ParticipantStatus.java @@ -0,0 +1,9 @@ +package com.threestar.trainus.domain.lesson.teacher.entity; + +/** + * 레슨 참여 상태를 나타내는 enum(결제) + */ +public enum ParticipantStatus { + PAYMENT_PENDING, + COMPLETED, +} diff --git a/src/main/java/com/threestar/trainus/domain/lesson/teacher/mapper/CreatedLessonMapper.java b/src/main/java/com/threestar/trainus/domain/lesson/teacher/mapper/CreatedLessonMapper.java index c11b9962..bc530cd1 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/teacher/mapper/CreatedLessonMapper.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/teacher/mapper/CreatedLessonMapper.java @@ -20,6 +20,7 @@ public static CreatedLessonDto toCreatedLessonDto(Lesson lesson) { .status(lesson.getStatus()) .startAt(lesson.getStartAt()) .endAt(lesson.getEndAt()) + .openTime(lesson.getOpenTime()) .openRun(lesson.getOpenRun()) .addressDetail(lesson.getAddressDetail()) .build(); @@ -27,7 +28,7 @@ public static CreatedLessonDto toCreatedLessonDto(Lesson lesson) { // 개설한 레슨 목록과 총 레슨의 수를 응답 DTO로 변환 public static CreatedLessonListResponseDto toCreatedLessonListResponseDto( - List lessons, Long totalCount) { + List lessons, int totalCount) { // 각 레슨을 DTO로 변환 List lessonDtos = lessons.stream() @@ -37,7 +38,7 @@ public static CreatedLessonListResponseDto toCreatedLessonListResponseDto( // 응답 DTO 생성 return CreatedLessonListResponseDto.builder() .lessons(lessonDtos) - .count(totalCount.intValue()) + .count(totalCount) .build(); } diff --git a/src/main/java/com/threestar/trainus/domain/lesson/teacher/mapper/LessonApplicationMapper.java b/src/main/java/com/threestar/trainus/domain/lesson/teacher/mapper/LessonApplicationMapper.java index 0012bc47..3670f7e4 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/teacher/mapper/LessonApplicationMapper.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/teacher/mapper/LessonApplicationMapper.java @@ -30,7 +30,7 @@ public static LessonApplicationResponseDto toResponseDto(LessonApplication appli //신청자 목록과 전체 개수를 리스트 응답 DTO로 변환 public static LessonApplicationListResponseDto toListResponseDto( - List applications, Long totalCount) { + List applications, int totalCount) { // 각 신청을 응답 DTO로 변환 List applicationDtos = applications.stream() @@ -40,7 +40,7 @@ public static LessonApplicationListResponseDto toListResponseDto( // 리스트 응답 DTO 생성 return LessonApplicationListResponseDto.builder() .lessonApplications(applicationDtos) - .count(totalCount.intValue()) + .count(totalCount) .build(); } diff --git a/src/main/java/com/threestar/trainus/domain/lesson/teacher/mapper/LessonMapper.java b/src/main/java/com/threestar/trainus/domain/lesson/teacher/mapper/LessonMapper.java index 898767b5..1d3f6a26 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/teacher/mapper/LessonMapper.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/teacher/mapper/LessonMapper.java @@ -29,6 +29,7 @@ public static Lesson toEntity(LessonCreateRequestDto requestDto, User user) { .city(requestDto.city()) .district(requestDto.district()) .dong(requestDto.dong()) + .ri(requestDto.ri()) .addressDetail(requestDto.addressDetail()) .build(); } @@ -55,6 +56,7 @@ public static LessonResponseDto toResponseDto(Lesson lesson, List l .city(lesson.getCity()) .district(lesson.getDistrict()) .dong(lesson.getDong()) + .ri(lesson.getRi()) .addressDetail(lesson.getAddressDetail()) .status(lesson.getStatus()) .createdAt(lesson.getCreatedAt()) @@ -93,6 +95,7 @@ public static LessonDetailResponseDto toLessonDetailDto( .city(lesson.getCity()) .district(lesson.getDistrict()) .dong(lesson.getDong()) + .ri(lesson.getRi()) .addressDetail(lesson.getAddressDetail()) .createdAt(lesson.getCreatedAt()) .updatedAt(lesson.getUpdatedAt()) @@ -122,6 +125,7 @@ public static LessonUpdateResponseDto toUpdateResponseDto(Lesson lesson, List 참가 시간으로 사용 + .participantStatus(participant.getStatus()) + .joinedAt(participant.getJoinAt()) .build(); } //참가자 목록과 전체 개수를 응답 DTO로 변환 public static ParticipantListResponseDto toParticipantsResponseDto( - List participants, Long totalCount) { + List participants, int totalCount) { // 각 참가자를 DTO로 변환 List participantDtos = participants.stream() .map(LessonParticipantMapper::toParticipantDto) .toList(); - // 응답 DTO 생성 return ParticipantListResponseDto.builder() .lessonApplications(participantDtos) - .count(totalCount.intValue()) + .count(totalCount) .build(); } diff --git a/src/main/java/com/threestar/trainus/domain/lesson/teacher/repository/LessonApplicationRepository.java b/src/main/java/com/threestar/trainus/domain/lesson/teacher/repository/LessonApplicationRepository.java index ac398c67..8f82a8c3 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/teacher/repository/LessonApplicationRepository.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/teacher/repository/LessonApplicationRepository.java @@ -1,33 +1,120 @@ package com.threestar.trainus.domain.lesson.teacher.repository; +import java.util.List; import java.util.Optional; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.threestar.trainus.domain.lesson.teacher.entity.ApplicationStatus; import com.threestar.trainus.domain.lesson.teacher.entity.Lesson; import com.threestar.trainus.domain.lesson.teacher.entity.LessonApplication; public interface LessonApplicationRepository extends JpaRepository { - - // 특정 레슨의 신청자 목록 조회(페이징) - Page findByLesson(Lesson lesson, Pageable pageable); - - //특정 레슨의 특정 상태 신청자 목록 조회(페이징) - Page findByLessonAndStatus(Lesson lesson, ApplicationStatus status, Pageable pageable); - // 신청 이력 확인 boolean existsByLessonIdAndUserId(Long lessonId, Long userId); // 특정 레슨 신청 조회 Optional findByLessonIdAndUserId(Long lessonId, Long userId); - Page findByUserId(Long userId, Pageable pageable); + List findByUserId(Long userId); - Page findByUserIdAndStatus(Long userId, ApplicationStatus status, Pageable pageable); -} + int countByLessonAndStatus(Lesson lesson, ApplicationStatus status); + + @Query(value = """ + SELECT la.id + FROM lesson_applications la + WHERE la.user_id = :userId + AND (:status IS NULL OR la.status = :status) + ORDER BY la.created_at DESC + limit :limit OFFSET :offset + """, nativeQuery = true) + List findIdsByUserAndStatus(@Param("userId") Long userId, + @Param("status") String status, + @Param("offset") int offset, + @Param("limit") int limit); + @Query(""" + SELECT la FROM LessonApplication la + JOIN FETCH la.lesson l + JOIN FETCH la.user u + JOIN FETCH u.profile p + LEFT JOIN FETCH u.profileMetadata pm + WHERE la.id IN :ids + ORDER BY la.createdAt DESC + """) + List findAllWithFetchJoin(@Param("ids") List ids); + @Query(value = """ + SELECT count(*) + FROM ( + SELECT la.id + FROM lesson_applications la + WHERE la.user_id = :userId + AND (:status IS NULL OR la.status = :status) + limit :limit + ) t + """, nativeQuery = true) + int countByUserAndStatus(@Param("userId") Long userId, @Param("status") String status, @Param("limit") int limit); + // 모든 상태 신청자 조회 + @Query(value = """ + SELECT la.id + FROM lesson_applications la + WHERE la.lesson_id = :lessonId + ORDER BY la.created_at DESC + LIMIT :limit OFFSET :offset + """, nativeQuery = true) + List findIdsByLesson(@Param("lessonId") Long lessonId, + @Param("offset") int offset, + @Param("limit") int limit); + + @Query(""" + SELECT DISTINCT la + FROM LessonApplication la + JOIN FETCH la.user u + JOIN FETCH la.lesson l + LEFT JOIN FETCH u.profile p + LEFT JOIN FETCH u.profileMetadata pm + WHERE la.id IN :ids + ORDER BY la.createdAt DESC + """) + List findAllWithUserProfileLesson(@Param("ids") List ids); + + // 모든 상태 신청자 count (최대 countLimit까지만) + @Query(value = """ + SELECT COUNT(*) FROM ( + SELECT la.id + FROM lesson_applications la + WHERE la.lesson_id = :lessonId + LIMIT :limit + ) t + """, nativeQuery = true) + int countAllByLesson(@Param("lessonId") Long lessonId, @Param("limit") int limit); + + // 특정 상태 신청자 조회 + @Query(value = """ + SELECT la.id + FROM lesson_applications la + WHERE la.lesson_id = :lessonId + AND la.status = :status + ORDER BY la.created_at DESC + LIMIT :limit OFFSET :offset + """, nativeQuery = true) + List findIdsByLessonAndStatus(@Param("lessonId") Long lessonId, @Param("status") String status, + @Param("offset") int offset, @Param("limit") int limit); + + // 특정 상태 신청자 count + @Query(value = """ + SELECT COUNT(*) FROM ( + SELECT la.id + FROM lesson_applications la + WHERE la.lesson_id = :lessonId + AND la.status = :status + LIMIT :limit + ) t + """, nativeQuery = true) + int countAllByLessonAndStatus(@Param("lessonId") Long lessonId, @Param("status") String status, + @Param("limit") int limit); +} diff --git a/src/main/java/com/threestar/trainus/domain/lesson/teacher/repository/LessonParticipantRepository.java b/src/main/java/com/threestar/trainus/domain/lesson/teacher/repository/LessonParticipantRepository.java index 1b6c4964..7bcb0fca 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/teacher/repository/LessonParticipantRepository.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/teacher/repository/LessonParticipantRepository.java @@ -1,5 +1,6 @@ package com.threestar.trainus.domain.lesson.teacher.repository; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.Modifying; @@ -7,7 +8,9 @@ import org.springframework.data.repository.CrudRepository; import com.threestar.trainus.domain.lesson.teacher.entity.LessonParticipant; +import com.threestar.trainus.domain.lesson.teacher.entity.ParticipantStatus; +import io.lettuce.core.dynamic.annotation.Param; import jakarta.transaction.Transactional; public interface LessonParticipantRepository extends CrudRepository { @@ -22,4 +25,53 @@ public interface LessonParticipantRepository extends CrudRepository findByLessonIdAndUserIdAndStatus( + @Param("lessonId") Long lessonId, + @Param("userId") Long userId, + @Param("status") ParticipantStatus status + ); + + @Query(value = """ + SELECT lp.id + FROM lesson_participants lp + WHERE lp.lesson_id = :lessonId + ORDER BY lp.join_at ASC + LIMIT :limit OFFSET :offset + """, nativeQuery = true) + List findIdsByLesson( + @Param("lessonId") Long lessonId, + @Param("offset") int offset, + @Param("limit") int limit + ); + + @Query(""" + SELECT lp + FROM LessonParticipant lp + JOIN FETCH lp.user u + JOIN FETCH u.profile p + LEFT JOIN FETCH u.profileMetadata pm + WHERE lp.id IN :ids + ORDER BY lp.joinAt ASC + """) + List findAllWithUserAndProfile(@Param("ids") List ids); + + @Query(value = """ + SELECT COUNT(*) FROM ( + SELECT lp.id + FROM lesson_participants lp + WHERE lp.lesson_id = :lessonId + LIMIT :limit + ) t + """, nativeQuery = true) + int countAllByLesson( + @Param("lessonId") Long lessonId, + @Param("limit") int limit + ); } diff --git a/src/main/java/com/threestar/trainus/domain/lesson/teacher/repository/LessonRepository.java b/src/main/java/com/threestar/trainus/domain/lesson/teacher/repository/LessonRepository.java index 19397f8c..1392c29d 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/teacher/repository/LessonRepository.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/teacher/repository/LessonRepository.java @@ -1,6 +1,7 @@ package com.threestar.trainus.domain.lesson.teacher.repository; import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; import org.springframework.data.domain.Page; @@ -17,8 +18,6 @@ import jakarta.persistence.LockModeType; public interface LessonRepository extends JpaRepository { - //삭제되지 않은 레슨만 조회 - Optional findByIdAndDeletedAtIsNull(Long lessonId); // 중복 레슨 검증(같은 강사가 같은 이름과 시작시간으로 레슨 생성했는지 체크) @Query(""" @@ -58,14 +57,56 @@ boolean hasTimeConflictForUpdate( @Param("excludeLessonId") Long excludeLessonId ); - // 강사가 개설한 레슨 목록 조회 (페이징) - Page findByLessonLeaderAndDeletedAtIsNull(Long lessonLeader, Pageable pageable); + // 강사가 개설한 레슨 목록 조회 (전체 목록 - 탈퇴 검증용) + List findByLessonLeaderAndDeletedAtIsNull(Long lessonLeader); - // 강사가 개설한 레슨 목록 조회 (페이징+필터링) - Page findByLessonLeaderAndStatusAndDeletedAtIsNull(Long lessonLeader, LessonStatus status, - Pageable pageable); + // 시작할 레슨을 찾는 메서드 + // 모집중이거나 모집완료 상태일 때, 시작 시간이 도달한 레슨 + @Query(""" + SELECT l FROM Lesson l + WHERE l.status IN ( + com.threestar.trainus.domain.lesson.teacher.entity.LessonStatus.RECRUITING, + com.threestar.trainus.domain.lesson.teacher.entity.LessonStatus.RECRUITMENT_COMPLETED + ) + AND l.startAt <= :now + AND l.deletedAt IS NULL + """) + List findLessonsToStart(@Param("now") LocalDateTime now); - // 레슨 검색 + //완료할 레슨을 찾는 메서드 + //현재는 진행중 -> 종료시간이 지나면 종료중으로 바뀔 레슨 + @Query(""" + SELECT l FROM Lesson l + WHERE l.status = com.threestar.trainus.domain.lesson.teacher.entity.LessonStatus.IN_PROGRESS + AND l.endAt <= :now + AND l.deletedAt IS NULL + """) + List findLessonsToComplete(@Param("now") LocalDateTime now); + + //레슨ID로 레슨 조회 (비관적 락 적용) + @Lock(LockModeType.PESSIMISTIC_WRITE) // 비관적 락 적용 + @Query("SELECT l FROM Lesson l WHERE l.id = :lessonId") + Optional findByIdWithLock(@Param("lessonId") Long lessonId); + + @Query(""" + SELECT l FROM Lesson l + WHERE l.city = :city + AND l.district = :district + AND l.dong = :dong + AND (:ri IS NULL OR l.ri = :ri) + AND (:category IS NULL OR l.category = :category) + """) + Page findByLocation( + @Param("category") Category category, + @Param("city") String city, + @Param("district") String district, + @Param("dong") String dong, + @Param("ri") String ri, + Pageable pageable + ); + + // 주소로만 검색 + // LIKE 검색 @Query(""" SELECT l FROM Lesson l WHERE @@ -78,16 +119,227 @@ Page findByLessonLeaderAndStatusAndDeletedAtIsNull(Long lessonLeader, Le LOWER(l.lessonName) LIKE LOWER(CONCAT('%', :search, '%')) ) """) - Page findBySearchConditions( + Page findByLocationAndSearchWithLike( @Param("category") Category category, @Param("city") String city, @Param("district") String district, @Param("dong") String dong, + @Param("ri") String ri, @Param("search") String search, Pageable pageable ); - @Lock(LockModeType.PESSIMISTIC_WRITE) // 비관적 락 적용 - @Query("SELECT l FROM Lesson l WHERE l.id = :lessonId") - Optional findByIdWithLock(@Param("lessonId") Long lessonId); + // Full-Text 검색 최적화 (서브쿼리 JOIN 방식) + @Query( + value = """ + SELECT l.* FROM lessons l + JOIN ( + SELECT id FROM lessons + WHERE MATCH(lesson_name) AGAINST(:search IN BOOLEAN MODE) + ) AS ft ON l.id = ft.id + WHERE l.city = :city AND l.district = :district AND l.dong = :dong AND (:ri IS NULL OR l.ri = :ri) AND (:category IS NULL OR l.category = :category) + ORDER BY l.created_at DESC + """, + countQuery = """ + SELECT count(l.id) FROM lessons l + JOIN ( + SELECT id FROM lessons + WHERE MATCH(lesson_name) AGAINST(:search IN BOOLEAN MODE) + ) AS ft ON l.id = ft.id + WHERE l.city = :city AND l.district = :district AND l.dong = :dong AND (:ri IS NULL OR l.ri = :ri) AND (:category IS NULL OR l.category = :category) + ORDER BY l.created_at DESC + """, + nativeQuery = true + ) + Page findByLocationAndFullTextSearchOptimized( + @Param("category") Category category, + @Param("city") String city, + @Param("district") String district, + @Param("dong") String dong, + @Param("ri") String ri, + @Param("search") String search, + Pageable pageable + ); + + // 목록 조회 + @Query(value = """ + SELECT * + FROM lessons l + WHERE l.lesson_leader = :userId + AND (:status IS NULL OR l.status = :status) + AND l.deleted_at IS NULL + ORDER BY l.created_at DESC + LIMIT :limit OFFSET :offset + """, nativeQuery = true) + List findCreatedLessonsByUser( + @Param("userId") Long userId, + @Param("status") String status, + @Param("limit") int limit, + @Param("offset") int offset + ); + + // totalCount (상태 포함) + @Query(value = """ + SELECT COUNT(*) FROM ( + SELECT l.id + FROM lessons l + WHERE l.lesson_leader = :userId + AND l.status = :status + AND l.deleted_at IS NULL + LIMIT :limit + ) t + """, nativeQuery = true) + int countCreatedLessonsByStatus( + @Param("userId") Long userId, + @Param("status") LessonStatus status, + @Param("limit") int limit + ); + + // totalCount (모든 상태) + @Query(value = """ + SELECT COUNT(*) FROM ( + SELECT l.id + FROM lessons l + WHERE l.lesson_leader = :userId + AND l.deleted_at IS NULL + LIMIT :limit + ) t + """, nativeQuery = true) + int countCreatedLessons( + @Param("userId") Long userId, + @Param("limit") int limit + ); + + @Query(value = """ + SELECT l.* + FROM lessons l + WHERE (:category IS NULL OR l.category = :category) + AND (:city IS NULL OR l.city = :city) + AND (:district IS NULL OR l.district = :district) + AND (:dong IS NULL OR l.dong = :dong) + AND (:ri IS NULL OR l.ri = :ri) + AND MATCH(l.lesson_name) AGAINST(:search IN BOOLEAN MODE) + ORDER BY + CASE WHEN :sort = 'LATEST' THEN l.created_at END DESC, + CASE WHEN :sort = 'OLDEST' THEN l.created_at END ASC, + CASE WHEN :sort = 'PRICE_HIGH' THEN l.price END DESC, + CASE WHEN :sort = 'PRICE_LOW' THEN l.price END ASC, + l.created_at DESC + LIMIT :limit OFFSET :offset + """, nativeQuery = true) + List findLessonsWithFullText( + @Param("category") String category, + @Param("city") String city, + @Param("district") String district, + @Param("dong") String dong, + @Param("ri") String ri, + @Param("search") String search, + @Param("sort") String sort, + @Param("offset") int offset, + @Param("limit") int limit + ); + + @Query(value = """ + SELECT COUNT(*) FROM ( + SELECT l.id + FROM lessons l + WHERE (:category IS NULL OR l.category = :category) + AND (:city IS NULL OR l.city = :city) + AND (:district IS NULL OR l.district = :district) + AND (:dong IS NULL OR l.dong = :dong) + AND (:ri IS NULL OR l.ri = :ri) + AND MATCH(l.lesson_name) AGAINST(:search IN BOOLEAN MODE) + LIMIT :limit + ) t + """, nativeQuery = true) + int countLessonsWithFullText( + @Param("category") String category, + @Param("city") String city, + @Param("district") String district, + @Param("dong") String dong, + @Param("ri") String ri, + @Param("search") String search, + @Param("limit") int limit + ); + + @Query(value = """ + SELECT l.* + FROM lessons l + WHERE (:category IS NULL OR l.category = :category) + AND (:city IS NULL OR l.city = :city) + AND (:district IS NULL OR l.district = :district) + AND (:dong IS NULL OR l.dong = :dong) + AND (:ri IS NULL OR l.ri = :ri) + ORDER BY + CASE WHEN :sort = 'LATEST' THEN l.created_at END DESC, + CASE WHEN :sort = 'OLDEST' THEN l.created_at END ASC, + CASE WHEN :sort = 'PRICE_HIGH' THEN l.price END DESC, + CASE WHEN :sort = 'PRICE_LOW' THEN l.price END ASC, + l.created_at DESC + LIMIT :limit OFFSET :offset + """, nativeQuery = true) + List findLessonsWithoutFullText( + @Param("category") String category, + @Param("city") String city, + @Param("district") String district, + @Param("dong") String dong, + @Param("ri") String ri, + @Param("sort") String sort, + @Param("offset") int offset, + @Param("limit") int limit + ); + + @Query(value = """ + SELECT COUNT(*) FROM ( + SELECT l.id + FROM lessons l + WHERE (:category IS NULL OR l.category = :category) + AND (:city IS NULL OR l.city = :city) + AND (:district IS NULL OR l.district = :district) + AND (:dong IS NULL OR l.dong = :dong) + AND (:ri IS NULL OR l.ri = :ri) + LIMIT :limit + ) t + """, nativeQuery = true) + int countLessonsWithoutFullText( + @Param("category") String category, + @Param("city") String city, + @Param("district") String district, + @Param("dong") String dong, + @Param("ri") String ri, + @Param("limit") int limit + ); + + // 상태 필터 없는 전체 조회 + @Query(value = """ + SELECT * + FROM lessons l + WHERE l.lesson_leader = :userId + AND l.deleted_at IS NULL + ORDER BY l.created_at DESC + LIMIT :limit OFFSET :offset + """, nativeQuery = true) + List findCreatedLessons( + @Param("userId") Long userId, + @Param("offset") int offset, + @Param("limit") int limit + ); + + // // 상태가 있는 경우 + @Query(value = """ + SELECT * + FROM lessons l + WHERE l.lesson_leader = :userId + AND l.status = :status + AND l.deleted_at IS NULL + ORDER BY l.created_at DESC + LIMIT :limit OFFSET :offset + """, nativeQuery = true) + List findCreatedLessonsByStatus( + @Param("userId") Long userId, + @Param("status") LessonStatus status, + @Param("offset") int offset, + @Param("limit") int limit + ); + } diff --git a/src/main/java/com/threestar/trainus/domain/lesson/teacher/scheduler/LessonStatusScheduler.java b/src/main/java/com/threestar/trainus/domain/lesson/teacher/scheduler/LessonStatusScheduler.java new file mode 100644 index 00000000..550dc68f --- /dev/null +++ b/src/main/java/com/threestar/trainus/domain/lesson/teacher/scheduler/LessonStatusScheduler.java @@ -0,0 +1,77 @@ +package com.threestar.trainus.domain.lesson.teacher.scheduler; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.threestar.trainus.domain.lesson.teacher.entity.Lesson; +import com.threestar.trainus.domain.lesson.teacher.entity.LessonStatus; +import com.threestar.trainus.domain.lesson.teacher.repository.LessonRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class LessonStatusScheduler { + + private final LessonRepository lessonRepository; + + /** + * 1분마다 레슨 상태를 확인하여 업데이트를 진행 + * - 모집중/모집완료 → 진행중 (시작 시간 도달) + * - 진행중 → 완료 (종료 시간 도달) + */ + @Scheduled(cron = "0 * * * * *") //1분마다 실행 + @Transactional + public void updateLessonStatus() { + LocalDateTime now = LocalDateTime.now(); + + try { + // 모집중/모집완료 → 진행중(시작시간 도달) + startLessons(now); + + // 진행중 → 완료(종료시간 도달) + completeLessons(now); + + } catch (Exception e) { + log.error("레슨 상태 업데이트 중 오류 발생", e); + } + } + + private void startLessons(LocalDateTime now) { + // 시작 시간이 지났지만 아직 모집중이거나 모집완료 상태인 레슨들 조회 + List lessonsToStart = lessonRepository.findLessonsToStart(now); + + if (!lessonsToStart.isEmpty()) { + for (Lesson lesson : lessonsToStart) { + lesson.updateStatus(LessonStatus.IN_PROGRESS); + log.info("레슨 시작: ID={}, 이름={}, 시작시간={}", + lesson.getId(), lesson.getLessonName(), lesson.getStartAt()); + } + + lessonRepository.saveAll(lessonsToStart); + log.info("총 {}개의 레슨이 진행중 상태로 변경되었습니다.", lessonsToStart.size()); + } + } + + private void completeLessons(LocalDateTime now) { + // 종료 시간이 지났지만 아직 진행중 상태인 레슨들 조회 + List lessonsToComplete = lessonRepository.findLessonsToComplete(now); + + if (!lessonsToComplete.isEmpty()) { + for (Lesson lesson : lessonsToComplete) { + lesson.updateStatus(LessonStatus.COMPLETED); + log.info("레슨 완료: ID={}, 이름={}, 종료시간={}", + lesson.getId(), lesson.getLessonName(), lesson.getEndAt()); + } + + lessonRepository.saveAll(lessonsToComplete); + log.info("총 {}개의 레슨이 완료 상태로 변경되었습니다.", lessonsToComplete.size()); + } + } +} diff --git a/src/main/java/com/threestar/trainus/domain/lesson/teacher/service/AdminLessonService.java b/src/main/java/com/threestar/trainus/domain/lesson/teacher/service/AdminLessonService.java index 84825e58..38a03625 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/teacher/service/AdminLessonService.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/teacher/service/AdminLessonService.java @@ -1,15 +1,14 @@ package com.threestar.trainus.domain.lesson.teacher.service; import java.time.LocalDateTime; +import java.util.Collections; import java.util.List; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.threestar.trainus.domain.lesson.teacher.constants.LessonConstants.Participants; +import com.threestar.trainus.domain.lesson.teacher.constants.LessonConstants.Time; import com.threestar.trainus.domain.lesson.teacher.dto.ApplicationProcessResponseDto; import com.threestar.trainus.domain.lesson.teacher.dto.CreatedLessonListResponseDto; import com.threestar.trainus.domain.lesson.teacher.dto.LessonApplicationListResponseDto; @@ -23,6 +22,7 @@ import com.threestar.trainus.domain.lesson.teacher.entity.Lesson; import com.threestar.trainus.domain.lesson.teacher.entity.LessonApplication; import com.threestar.trainus.domain.lesson.teacher.entity.LessonImage; +import com.threestar.trainus.domain.lesson.teacher.entity.LessonParticipant; import com.threestar.trainus.domain.lesson.teacher.entity.LessonStatus; import com.threestar.trainus.domain.lesson.teacher.mapper.CreatedLessonMapper; import com.threestar.trainus.domain.lesson.teacher.mapper.LessonApplicationMapper; @@ -30,12 +30,13 @@ import com.threestar.trainus.domain.lesson.teacher.mapper.LessonParticipantMapper; import com.threestar.trainus.domain.lesson.teacher.repository.LessonApplicationRepository; import com.threestar.trainus.domain.lesson.teacher.repository.LessonImageRepository; +import com.threestar.trainus.domain.lesson.teacher.repository.LessonParticipantRepository; import com.threestar.trainus.domain.lesson.teacher.repository.LessonRepository; import com.threestar.trainus.domain.user.entity.User; -import com.threestar.trainus.domain.user.repository.UserRepository; import com.threestar.trainus.domain.user.service.UserService; import com.threestar.trainus.global.exception.domain.ErrorCode; import com.threestar.trainus.global.exception.handler.BusinessException; +import com.threestar.trainus.global.utils.PageLimitCalculator; import lombok.RequiredArgsConstructor; @@ -46,121 +47,109 @@ @RequiredArgsConstructor public class AdminLessonService { - private final LessonRepository lessonRepository; // 레슨 DB 접근 - private final LessonImageRepository lessonImageRepository; // 레슨 이미지 DB 접근 - private final UserRepository userRepository; + private final LessonRepository lessonRepository; + private final LessonImageRepository lessonImageRepository; private final LessonApplicationRepository lessonApplicationRepository; + private final LessonParticipantRepository lessonParticipantRepository; private final UserService userService; + private final LessonCreationLimitService lessonCreationLimitService; - // 새로운 레슨을 생성하는 메서드 + //레슨 생성 public LessonResponseDto createLesson(LessonCreateRequestDto requestDto, Long userId) { - // User 조회 User user = userService.getUserById(userId); - validateLessonTimes(requestDto.startAt(), requestDto.endAt()); - - // 최대 참가 인원 검증 -> 100명이하로 제한 - if (requestDto.maxParticipants() > 100) { - throw new BusinessException(ErrorCode.LESSON_MAX_PARTICIPANTS_EXCEEDED); - } - - // 동일 레슨 중복 검증(동일한 강사가 같은 이름+시간으로 레슨 생성 차단) - boolean isDuplicate = lessonRepository.existsDuplicateLesson( - userId, - requestDto.lessonName(), - requestDto.startAt() - ); - if (isDuplicate) { - throw new BusinessException(ErrorCode.DUPLICATE_LESSON); - } + // 생성시에 필요한 검증을 진행 + validateLessonCreation(requestDto, userId); - // 시간 겹침 검증(같은 강사가 동일 시간대에 여러 레슨 생성 차단) - boolean hasConflict = lessonRepository.hasTimeConflictLesson( - userId, - requestDto.startAt(), - requestDto.endAt() - ); - if (hasConflict) { - throw new BusinessException(ErrorCode.LESSON_TIME_OVERLAP); - } + //레슨 생성 제한 확인 및 쿨타임 설정 + lessonCreationLimitService.checkAndSetCreationLimit(userId); - // User 엔티티로 레슨 생성 + // 레슨 생성 및 저장 Lesson lesson = LessonMapper.toEntity(requestDto, user); - - // 레슨 저장 Lesson savedLesson = lessonRepository.save(lesson); - // 레슨 이미지 저장 + // 이미지 저장 List savedImages = saveLessonImages(savedLesson, requestDto.lessonImages()); - // 응답 DTO 반환 return LessonMapper.toResponseDto(savedLesson, savedImages); } - // 레슨 이미지들을 db에 저장하는 메서드 - private List saveLessonImages(Lesson lesson, List imageUrls) { - // 이미지가 없는 경우 빈 리스트 반환 - if (imageUrls == null || imageUrls.isEmpty()) { - return List.of(); - } - - List lessonImages = imageUrls.stream() - .map(url -> LessonImage.builder() - .lesson(lesson) - .imageUrl(url) - .build()) - .toList(); - - return lessonImageRepository.saveAll(lessonImages); + //레슨 삭제 + @Transactional + public void deleteLesson(Long lessonId, Long userId) { + Lesson lesson = validateLessonAccess(lessonId, userId); + validateLessonDeletion(lesson); + lesson.lessonDelete(); + lessonRepository.save(lesson); } - //레슨 삭제 + //레슨 수정 @Transactional - public void deleteLesson(Long lessonId, Long userId) { - // User 존재 확인 - User user = userService.getUserById(userId); - //레슨 조회 - Lesson lesson = findLessonById(lessonId); + public LessonUpdateResponseDto updateLesson(Long lessonId, LessonUpdateRequestDto requestDto, Long userId) { + Lesson lesson = validateLessonAccess(lessonId, userId); - //권한 확인 -> 레슨을 올린사람만 삭제가 가능하도록 - validateIsYourLesson(lesson, userId); + validateLessonIsRecruiting(lesson); + validateLessonTimeLimit(lesson); + validateUpdateRequest(requestDto); - //이미 삭제된 레슨인지 확인 - validateLessonNotDeleted(lesson); + boolean hasParticipants = hasApprovedParticipants(lesson); - lessonEditable(lesson); + // 수정 처리 + updateBasicInfo(lesson, requestDto); + updateMaxParticipants(lesson, requestDto, hasParticipants); + tryUpdateRestricted(lesson, requestDto, userId, lessonId, hasParticipants); - lesson.lessonDelete(); - lessonRepository.save(lesson); + // 저장 및 응답 + Lesson savedLesson = lessonRepository.save(lesson); + List updatedImages = updateLessonImages(savedLesson, requestDto.lessonImages()); + + return LessonMapper.toUpdateResponseDto(savedLesson, updatedImages); } - //레슨 신청자 목록 조회 + @Transactional(readOnly = true) public LessonApplicationListResponseDto getLessonApplications( Long lessonId, int page, int limit, String status, Long userId) { - // 레슨 존재 및 권한 확인 Lesson lesson = validateLessonAccess(lessonId, userId); + ApplicationStatus applicationStatus = toApplicationStatus(status); - // 상태 파라미터 검증 - ApplicationStatus applicationStatus = validateStatus(status); + // offset, limit 계산 + int offset = (page - 1) * limit; + int countLimit = PageLimitCalculator.calculatePageLimit(page, limit, 5); - // 페이징 설정 - Pageable pageable = PageRequest.of(page - 1, limit, Sort.by("createdAt").descending()); + List applications; + int total; - // 신청자 목록 조회 - Page applicationPage; if ("ALL".equals(status)) { - applicationPage = lessonApplicationRepository.findByLesson(lesson, pageable); + // 모든 상태 조회 + List ids = lessonApplicationRepository.findIdsByLesson( + lessonId, offset, limit + ); + + applications = ids.isEmpty() + ? Collections.emptyList() + : lessonApplicationRepository.findAllWithUserProfileLesson(ids); + + total = lessonApplicationRepository.countAllByLesson( + lesson.getId(), countLimit + ); } else { - applicationPage = lessonApplicationRepository.findByLessonAndStatus(lesson, applicationStatus, pageable); + // 특정 상태 조회 + List ids = lessonApplicationRepository.findIdsByLessonAndStatus( + lesson.getId(), applicationStatus.name(), offset, limit + ); + + applications = ids.isEmpty() + ? Collections.emptyList() + : lessonApplicationRepository.findAllWithUserProfileLesson(ids); + + total = lessonApplicationRepository.countAllByLessonAndStatus( + lesson.getId(), applicationStatus.name(), countLimit + ); } - //dto변환 - return LessonApplicationMapper.toListResponseDto( - applicationPage.getContent(), - applicationPage.getTotalElements() - ); + return LessonApplicationMapper.toListResponseDto(applications, total); } //레슨 신청 승인/거절 처리 @@ -168,348 +157,350 @@ public LessonApplicationListResponseDto getLessonApplications( public ApplicationProcessResponseDto processLessonApplication( Long lessonApplicationId, ApplicationAction action, Long userId) { - // 신청이 있는지 확인 - LessonApplication application = lessonApplicationRepository.findById(lessonApplicationId) - .orElseThrow(() -> new BusinessException(ErrorCode.LESSON_APPLICATION_NOT_FOUND)); - - // 강사 권한 확인 -> 해당 레슨의 강사인지 확인 + LessonApplication application = findApplicationById(lessonApplicationId); Lesson lesson = application.getLesson(); - validateIsYourLesson(lesson, userId); - // 이미 처리된 신청인지 확인(대기중아니라면 -> 이미 승인이나 거절처리 된거니까) - if (!application.getStatus().equals(ApplicationStatus.PENDING)) { - throw new BusinessException(ErrorCode.LESSON_APPLICATION_ALREADY_PROCESSED); - } + validateIsYourLesson(lesson, userId); + validatePending(application); - //승인/거절 처리 - if (action == ApplicationAction.APPROVED) { - validateCapacity(lesson); - application.approve(); - // 승인 시 레슨 참가자수 증가 - lesson.incrementParticipantCount(); - } else if (action == ApplicationAction.DENIED) { - application.deny(); - } + // 승인/거절 처리 + processApplication(application, lesson, action); LessonApplication savedApplication = lessonApplicationRepository.save(application); - // 응답 DTO 생성 - return ApplicationProcessResponseDto.builder() - .lessonApplicationId(savedApplication.getId()) - .userId(savedApplication.getUser().getId()) - .status(savedApplication.getStatus()) - .processedAt(savedApplication.getUpdatedAt()) - .build(); + return buildApplicationProcessResponse(savedApplication); } - //레슨 수정 - @Transactional - public LessonUpdateResponseDto updateLesson(Long lessonId, LessonUpdateRequestDto requestDto, Long userId) { - // 레슨 존재 및 권한 확인 + @Transactional(readOnly = true) + public ParticipantListResponseDto getLessonParticipants( + Long lessonId, int page, int limit, Long userId) { + + // 레슨 접근 권한 검증 Lesson lesson = validateLessonAccess(lessonId, userId); - // 레슨이 모집중 상태인지 확인 - if (lesson.getStatus() != LessonStatus.RECRUITING) { - throw new BusinessException(ErrorCode.LESSON_NOT_EDITABLE); - } + // offset & countLimit 계산 + int offset = (page - 1) * limit; + int countLimit = PageLimitCalculator.calculatePageLimit(page, limit, 5); - // 현재 참가자 수 확인 - int currentParticipants = lesson.getParticipantCount(); - boolean hasParticipants = currentParticipants > 0; + // 참가자 조회 + List ids = lessonParticipantRepository.findIdsByLesson(lessonId, offset, limit); - // 수정할 필드가 있는지 확인 - if (!requestDto.hasBasicInfoChanges() && !requestDto.hasRestrictedChanges() - && requestDto.maxParticipants() == null) { - throw new BusinessException(ErrorCode.INVALID_REQUEST_DATA); - } + List participants = ids.isEmpty() + ? Collections.emptyList() + : lessonParticipantRepository.findAllWithUserAndProfile(ids); - // 기본 정보 수정 - updateBasicInfo(lesson, requestDto); + // 전체 카운트 조회 + int totalCount = lessonParticipantRepository.countAllByLesson( + lesson.getId(), countLimit + ); - // 최대 참가 인원 수정 -> 참가자 있을 때는 증가만 가능 - if (requestDto.maxParticipants() != null) { - lesson.updateMaxParticipants(requestDto.maxParticipants(), hasParticipants); - } + return LessonParticipantMapper.toParticipantsResponseDto(participants, totalCount); + } - // 제한된 필드 수정 -> 참가자 없을 때만 가능 - if (requestDto.hasRestrictedChanges()) { - if (hasParticipants) { - throw new BusinessException(ErrorCode.LESSON_PARTICIPANTS_EXIST_RESTRICTION); - } - updateRestrictedFields(lesson, requestDto, userId, lessonId); - } + @Transactional(readOnly = true) + public CreatedLessonListResponseDto getCreatedLessons( + Long userId, int page, int limit, String status) { - Lesson savedLesson = lessonRepository.save(lesson); + // user 검증 + userService.getUserById(userId); - List updatedImages = updateLessonImagesIfNeeded(savedLesson, requestDto.lessonImages()); + // offset / countLimit 계산 + int offset = (page - 1) * limit; + int countLimit = PageLimitCalculator.calculatePageLimit(page, limit, 5); - return LessonMapper.toUpdateResponseDto(savedLesson, updatedImages); + List lessons; + int total; + + if (status != null && !status.isEmpty()) { + // 상태 값이 있는 경우 + lessons = lessonRepository.findCreatedLessonsByStatus( + userId, LessonStatus.valueOf(status), offset, limit + ); + total = lessonRepository.countCreatedLessonsByStatus( + userId, LessonStatus.valueOf(status), countLimit + ); + } else { + // 상태 필터 없는 경우 + lessons = lessonRepository.findCreatedLessons( + userId, offset, limit + ); + total = lessonRepository.countCreatedLessons( + userId, countLimit + ); + } + + return CreatedLessonMapper.toCreatedLessonListResponseDto(lessons, total); } - //레슨 참가자 목록 조회(승인된 사람들만 있음) - public ParticipantListResponseDto getLessonParticipants( - Long lessonId, int page, int limit, Long userId) { + // 검증 메서드 - // 레슨 존재 및 권한 확인 - Lesson lesson = validateLessonAccess(lessonId, userId); + //레슨 생성 시 검증 + private void validateLessonCreation(LessonCreateRequestDto requestDto, Long userId) { + validateLessonTimes(requestDto.startAt(), requestDto.endAt()); + validateMaxParticipantsByType(requestDto.maxParticipants(), requestDto.openRun()); + validateDuplicateLesson(userId, requestDto.lessonName(), requestDto.startAt()); + validateTimeConflict(userId, requestDto.startAt(), requestDto.endAt()); + } - // 페이징 설정 - Pageable pageable = PageRequest.of(page - 1, limit, Sort.by("createdAt").ascending()); + //레슨 삭제 검증 + private void validateLessonDeletion(Lesson lesson) { + validateLessonIsRecruiting(lesson); - // 승인처리(APPROVED)된 -> 레슨 신청자들만 조회 - Page participantPage = lessonApplicationRepository - .findByLessonAndStatus(lesson, ApplicationStatus.APPROVED, pageable); + if (hasApprovedParticipants(lesson)) { + throw new BusinessException(ErrorCode.LESSON_DELETE_HAS_PARTICIPANTS); + } - // dto 변환 - return LessonParticipantMapper.toParticipantsResponseDto( - participantPage.getContent(), - participantPage.getTotalElements() - ); + validateLessonTimeLimit(lesson); } - //강사가 개설한 레슨 목록 조회 - public CreatedLessonListResponseDto getCreatedLessons( - Long userId, int page, int limit, String status) { + //레슨 접근 권한 검증 + private Lesson validateLessonAccess(Long lessonId, Long userId) { + userService.getUserById(userId); + Lesson lesson = findLessonById(lessonId); + validateLessonNotDeleted(lesson); + validateIsYourLesson(lesson, userId); + return lesson; + } - // User 존재 확인 - User user = userService.getUserById(userId); + //모집중 상태 검증 + private void validateLessonIsRecruiting(Lesson lesson) { + if (lesson.getStatus() != LessonStatus.RECRUITING) { + throw new BusinessException(ErrorCode.LESSON_NOT_EDITABLE); + } + } - // 페이징 설정 - Pageable pageable = PageRequest.of(page - 1, limit, Sort.by("createdAt").descending()); + //수정/삭제는 12시 전만 가능하도록 + private void validateLessonTimeLimit(Lesson lesson) { + LocalDateTime timeLimit = lesson.getStartAt().minusHours(Time.EDIT_DELETE_LIMIT_HOURS); + if (LocalDateTime.now().isAfter(timeLimit)) { + throw new BusinessException(ErrorCode.LESSON_TIME_LIMIT_EXCEEDED); + } + } - // 레슨 상태에 따른 조회 - Page lessonPage; - if (status != null && !status.isEmpty()) { - // 레슨 상태에 따라서 필터링 - LessonStatus lessonStatus = validateLessonStatus(status); - lessonPage = lessonRepository.findByLessonLeaderAndStatusAndDeletedAtIsNull(userId, lessonStatus, pageable); - } else { - // 전체조회 - lessonPage = lessonRepository.findByLessonLeaderAndDeletedAtIsNull(userId, pageable); + //승인된 참가자 존재 여부 확인 + private boolean hasApprovedParticipants(Lesson lesson) { + return lessonApplicationRepository + .countByLessonAndStatus(lesson, ApplicationStatus.APPROVED) > 0; + } + + //강사 권한 검증 + private void validateIsYourLesson(Lesson lesson, Long userId) { + if (!lesson.getLessonLeader().equals(userId)) { + throw new BusinessException(ErrorCode.ACCESS_FORBIDDEN); } + } - // dto 변환 - return CreatedLessonMapper.toCreatedLessonListResponseDto( - lessonPage.getContent(), - lessonPage.getTotalElements() - ); + //레슨 삭제 여부 검증 + private void validateLessonNotDeleted(Lesson lesson) { + if (lesson.isDeleted()) { + throw new BusinessException(ErrorCode.LESSON_NOT_FOUND); + } } - //시간 검증: 시작시간이 종료시간보다 앞에 있는지, 시작시간이 과거가 아닌지 + //시간 검증 private void validateLessonTimes(LocalDateTime startAt, LocalDateTime endAt) { LocalDateTime now = LocalDateTime.now(); - // 시작시간이 현재시간보다 과거인지 확인 if (startAt.isBefore(now)) { throw new BusinessException(ErrorCode.LESSON_START_TIME_INVALID); } - // 종료시간이 시작시간보다 이전인지 확인 if (endAt.isBefore(startAt) || endAt.isEqual(startAt)) { throw new BusinessException(ErrorCode.LESSON_END_TIME_BEFORE_START); } } - //정원 초과 검증-> 승인 시 maxParticipants 초과하지 않는지 - private void validateCapacity(Lesson lesson) { - if (lesson.getParticipantCount() >= lesson.getMaxParticipants()) { + //참여방식에 따른 최대 인원 검증 + private void validateMaxParticipantsByType(Integer maxParticipants, Boolean openRun) { + int maxLimit = openRun ? Participants.MAX_OPEN_RUN_PARTICIPANTS : Participants.MAX_NORMAL_PARTICIPANTS; + if (maxParticipants > maxLimit) { throw new BusinessException(ErrorCode.LESSON_MAX_PARTICIPANTS_EXCEEDED); } } - //레슨 상태 검증: 이미 시작되거나 완료된 레슨 수정/삭제 방지 - private void lessonEditable(Lesson lesson) { - // 진행중이거나 완료된 레슨은 수정/삭제 불가 - if (lesson.getStatus() == LessonStatus.IN_PROGRESS || lesson.getStatus() == LessonStatus.COMPLETED) { - throw new BusinessException(ErrorCode.INVALID_LESSON_DATE); + //중복 레슨 검증 + private void validateDuplicateLesson(Long userId, String lessonName, LocalDateTime startAt) { + boolean isDuplicate = lessonRepository.existsDuplicateLesson(userId, lessonName, startAt); + if (isDuplicate) { + throw new BusinessException(ErrorCode.DUPLICATE_LESSON); } + } - // 레슨 시작 시간이 지났는지도 확인 - if (lesson.getStartAt().isBefore(LocalDateTime.now())) { - throw new BusinessException(ErrorCode.LESSON_START_TIME_INVALID); + //시간 겹침 검증 + private void validateTimeConflict(Long userId, LocalDateTime startAt, LocalDateTime endAt) { + boolean hasConflict = lessonRepository.hasTimeConflictLesson(userId, startAt, endAt); + if (hasConflict) { + throw new BusinessException(ErrorCode.LESSON_TIME_OVERLAP); } } - //레슨 조회 및 검증 - public Lesson findLessonById(Long lessonId) { - return lessonRepository.findById(lessonId) - .orElseThrow(() -> new BusinessException(ErrorCode.LESSON_NOT_FOUND)); + //정원 초과 검증 + private void validateCapacity(Lesson lesson) { + if (lesson.getParticipantCount() >= lesson.getMaxParticipants()) { + throw new BusinessException(ErrorCode.LESSON_MAX_PARTICIPANTS_EXCEEDED); + } } - public Lesson findLessonByIdWithLock(Long lessonId) { - return lessonRepository.findByIdWithLock(lessonId) - .orElseThrow(() -> new BusinessException(ErrorCode.LESSON_NOT_FOUND)); + //수정 요청 검증 + private void validateUpdateRequest(LessonUpdateRequestDto requestDto) { + if (!requestDto.hasBasicInfoChanges() && !requestDto.hasRestrictedChanges() + && requestDto.maxParticipants() == null) { + throw new BusinessException(ErrorCode.INVALID_REQUEST_DATA); + } } - //레슨 application조회 및 검증 - public LessonApplication findApplicationById(Long applicationId) { - return lessonApplicationRepository.findById(applicationId) - .orElseThrow(() -> new BusinessException(ErrorCode.LESSON_APPLICATION_NOT_FOUND)); + //신청 처리 가능 여부 검증 + private void validatePending(LessonApplication application) { + if (!application.getStatus().equals(ApplicationStatus.PENDING)) { + throw new BusinessException(ErrorCode.LESSON_APPLICATION_ALREADY_PROCESSED); + } } - //강사 권한 검증 - public void validateIsYourLesson(Lesson lesson, Long userId) { - if (!lesson.getLessonLeader().equals(userId)) { - throw new BusinessException(ErrorCode.LESSON_ACCESS_FORBIDDEN); - } + //기본 정보 수정 + private void updateBasicInfo(Lesson lesson, LessonUpdateRequestDto requestDto) { + lesson.updateLessonName(requestDto.lessonName()); + lesson.updateDescription(requestDto.description()); } - //레슨 삭제 여부 검증 - public void validateLessonNotDeleted(Lesson lesson) { - if (lesson.isDeleted()) { - throw new BusinessException(ErrorCode.LESSON_NOT_FOUND); + //최대 참가 인원 수정 + private void updateMaxParticipants(Lesson lesson, LessonUpdateRequestDto requestDto, + boolean hasParticipants) { + if (requestDto.maxParticipants() != null) { + lesson.updateMaxParticipants(requestDto.maxParticipants(), hasParticipants); } } - //레슨 접근 권한 검증 -> 올린사람(강사)가 맞는지 체크 - - private Lesson validateLessonAccess(Long lessonId, Long userId) { - // User 존재 확인 - User user = userService.getUserById(userId); - // 레슨 존재하는지 확인 - Lesson lesson = findLessonById(lessonId); + //제한된 필드 수정 + private void tryUpdateRestricted(Lesson lesson, LessonUpdateRequestDto requestDto, + Long userId, Long lessonId, boolean hasParticipants) { - // 삭제된 레슨 확인 - validateLessonNotDeleted(lesson); + if (!requestDto.hasRestrictedChanges()) { + return; //제한된 필드 수정 요청이 없다면 아무것도 안함 + } - // 강사 본인 확인 - validateIsYourLesson(lesson, userId); + if (hasParticipants) { + throw new BusinessException(ErrorCode.LESSON_PARTICIPANTS_EXIST_RESTRICTION); + } - return lesson; + updateRestrictedFields(lesson, requestDto, userId, lessonId); } - //레슨 상태 검증 - private ApplicationStatus validateStatus(String status) { - if ("ALL".equals(status)) { - return null; // ALL인 경우 null 반환해서 필터링 안함 - } + //제한된 필드 업데이트 실행 + private void updateRestrictedFields(Lesson lesson, LessonUpdateRequestDto requestDto, Long userId, Long lessonId) { + validateTimeChanges(lesson, requestDto, userId, lessonId); - try { - return ApplicationStatus.valueOf(status); - } catch (IllegalArgumentException e) { - throw new BusinessException(ErrorCode.INVALID_APPLICATION_STATUS); - } + lesson.updateCategory(requestDto.category()); + lesson.updatePrice(requestDto.price()); + lesson.updateLessonTime(requestDto.startAt(), requestDto.endAt()); + lesson.updateOpenTime(requestDto.openTime()); + lesson.updateOpenRun(requestDto.openRun()); + lesson.updateLocation(requestDto.city(), requestDto.district(), requestDto.dong(), requestDto.ri()); + lesson.updateAddressDetail(requestDto.addressDetail()); } - //레슨 신청 상태 검증 - private LessonStatus validateLessonStatus(String status) { - try { - return LessonStatus.valueOf(status); - } catch (IllegalArgumentException e) { - throw new BusinessException(ErrorCode.INVALID_LESSON_STATUS); + //시간 변경 검증 + private void validateTimeChanges(Lesson lesson, LessonUpdateRequestDto requestDto, Long userId, + Long lessonId) { + if (!requestDto.hasTimeChanges()) { + return; } - } - //참가자가 있을때 수정 검증 - private void validatePeopleInLessonUpdate(Lesson lesson, LessonUpdateRequestDto requestDto) { - // 카테고리 변경 불가 - if (!lesson.getCategory().equals(requestDto.category())) { - throw new BusinessException(ErrorCode.LESSON_PARTICIPANTS_EXIST_RESTRICTION); - } + LocalDateTime newStartAt = requestDto.startAt() != null ? requestDto.startAt() : lesson.getStartAt(); + LocalDateTime newEndAt = requestDto.endAt() != null ? requestDto.endAt() : lesson.getEndAt(); - // 가격 변경 불가 - if (!lesson.getPrice().equals(requestDto.price())) { - throw new BusinessException(ErrorCode.LESSON_PARTICIPANTS_EXIST_RESTRICTION); - } + validateLessonTimes(newStartAt, newEndAt); - // 참여방식 변경 불가 - if (!lesson.getOpenRun().equals(requestDto.openRun())) { - throw new BusinessException(ErrorCode.LESSON_PARTICIPANTS_EXIST_RESTRICTION); + boolean hasConflict = lessonRepository.hasTimeConflictForUpdate(userId, newStartAt, newEndAt, lessonId); + if (hasConflict) { + throw new BusinessException(ErrorCode.LESSON_TIME_OVERLAP); } + } - // 레슨 시간 변경 불가 - if (!lesson.getStartAt().equals(requestDto.startAt()) || !lesson.getEndAt().equals(requestDto.endAt())) { - throw new BusinessException(ErrorCode.LESSON_PARTICIPANTS_EXIST_RESTRICTION); - } + //신청 처리 + private void processApplication(LessonApplication application, Lesson lesson, ApplicationAction action) { + if (action == ApplicationAction.APPROVED) { + validateCapacity(lesson); + application.approve(); + LessonParticipant participant = LessonParticipant.builder() + .lesson(lesson) + .user(application.getUser()) + .build(); // + lessonParticipantRepository.save(participant); - // 지역 정보 변경 불가 - if (!lesson.getCity().equals(requestDto.city()) || !lesson.getDistrict().equals(requestDto.district()) - || !lesson.getDong().equals(requestDto.dong())) { - throw new BusinessException(ErrorCode.LESSON_PARTICIPANTS_EXIST_RESTRICTION); + lesson.incrementParticipantCount(); + } else if (action == ApplicationAction.DENIED) { + application.deny(); } + } - // 상세주소 변경 불가 - if (!lesson.getAddressDetail().equals(requestDto.addressDetail())) { - throw new BusinessException(ErrorCode.LESSON_PARTICIPANTS_EXIST_RESTRICTION); + //신청 상태 파싱 + private ApplicationStatus toApplicationStatus(String status) { + if ("ALL".equals(status)) { + return null; + } + try { + return ApplicationStatus.valueOf(status); + } catch (IllegalArgumentException e) { + throw new BusinessException(ErrorCode.INVALID_APPLICATION_STATUS); } } - //레슨 이미지 업데이트 - private List updateLessonImages(Lesson lesson, List imageUrls) { - // 기존 이미지 삭제 - List existingImages = lessonImageRepository.findByLesson(lesson); - lessonImageRepository.deleteAll(existingImages); - - // 새 이미지 저장 + //레슨 이미지 저장 + private List saveLessonImages(Lesson lesson, List imageUrls) { if (imageUrls == null || imageUrls.isEmpty()) { return List.of(); } - List newImages = imageUrls.stream() + List lessonImages = imageUrls.stream() .map(url -> LessonImage.builder() .lesson(lesson) .imageUrl(url) .build()) .toList(); - return lessonImageRepository.saveAll(newImages); - } - //기본 정보 수정 - - private void updateBasicInfo(Lesson lesson, LessonUpdateRequestDto requestDto) { - lesson.updateLessonName(requestDto.lessonName()); - lesson.updateDescription(requestDto.description()); - } - //제한되어 있는 필드 수정 - - private void updateRestrictedFields(Lesson lesson, LessonUpdateRequestDto requestDto, Long userId, Long lessonId) { - // 시간 관련 검증 - if (requestDto.hasTimeChanges()) { - LocalDateTime newStartAt = requestDto.startAt() != null ? requestDto.startAt() : lesson.getStartAt(); - LocalDateTime newEndAt = requestDto.endAt() != null ? requestDto.endAt() : lesson.getEndAt(); - - // 시간 검증 - validateLessonTimes(newStartAt, newEndAt); - - // 시간 겹침 검증 - boolean hasConflict = lessonRepository.hasTimeConflictForUpdate( - userId, newStartAt, newEndAt, lessonId - ); - if (hasConflict) { - throw new BusinessException(ErrorCode.LESSON_TIME_OVERLAP); - } - } - - lesson.updateCategory(requestDto.category()); - lesson.updatePrice(requestDto.price()); - lesson.updateLessonTime(requestDto.startAt(), requestDto.endAt()); - lesson.updateOpenTime(requestDto.openTime()); - lesson.updateOpenRun(requestDto.openRun()); - lesson.updateLocation(requestDto.city(), requestDto.district(), requestDto.dong()); - lesson.updateAddressDetail(requestDto.addressDetail()); + return lessonImageRepository.saveAll(lessonImages); } - //레슨 이미지 수정 - private List updateLessonImagesIfNeeded(Lesson lesson, List newImageUrls) { + //레슨 이미지 업데이트 + private List updateLessonImages(Lesson lesson, List newImageUrls) { if (newImageUrls != null) { - // 기존 이미지 삭제 List existingImages = lessonImageRepository.findByLesson(lesson); lessonImageRepository.deleteAll(existingImages); - // 새 이미지 저장 if (!newImageUrls.isEmpty()) { - List newImages = newImageUrls.stream() - .map(url -> LessonImage.builder() - .lesson(lesson) - .imageUrl(url) - .build()) - .toList(); - return lessonImageRepository.saveAll(newImages); + return saveLessonImages(lesson, newImageUrls); } return List.of(); } else { - // 이미지 수정 요청 없으면 -> 기존 이미지 유지 return lessonImageRepository.findByLesson(lesson); } } + + //신청처리 응답dto + private ApplicationProcessResponseDto buildApplicationProcessResponse(LessonApplication application) { + return ApplicationProcessResponseDto.builder() + .lessonApplicationId(application.getId()) + .userId(application.getUser().getId()) + .status(application.getStatus()) + .processedAt(application.getUpdatedAt()) + .build(); + } + + //레슨 조회 + public Lesson findLessonById(Long lessonId) { + return lessonRepository.findById(lessonId) + .orElseThrow(() -> new BusinessException(ErrorCode.LESSON_NOT_FOUND)); + } + + // develop 브랜치에서 추가된 메서드 - Lock 기능 추가 + public Lesson findLessonByIdWithLock(Long lessonId) { + return lessonRepository.findByIdWithLock(lessonId) + .orElseThrow(() -> new BusinessException(ErrorCode.LESSON_NOT_FOUND)); + } + + //레슨 신청 조회 + public LessonApplication findApplicationById(Long applicationId) { + return lessonApplicationRepository.findById(applicationId) + .orElseThrow(() -> new BusinessException(ErrorCode.LESSON_APPLICATION_NOT_FOUND)); + } } diff --git a/src/main/java/com/threestar/trainus/domain/lesson/teacher/service/LessonCreationLimitService.java b/src/main/java/com/threestar/trainus/domain/lesson/teacher/service/LessonCreationLimitService.java new file mode 100644 index 00000000..457fcf3e --- /dev/null +++ b/src/main/java/com/threestar/trainus/domain/lesson/teacher/service/LessonCreationLimitService.java @@ -0,0 +1,51 @@ +package com.threestar.trainus.domain.lesson.teacher.service; + +import java.time.Duration; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import com.threestar.trainus.global.exception.domain.ErrorCode; +import com.threestar.trainus.global.exception.handler.BusinessException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 레슨 생성 제한 서비스 + * Redis를 사용해 강사의 레슨 생성에 쿨타임을 적용 ->1분 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class LessonCreationLimitService { + + private final RedisTemplate redisTemplate; + + // 레슨 생성 쿨타임 (1분) + private static final Duration CREATION_COOLTIME = Duration.ofMinutes(1); + + //redis 키 접두사 + private static final String LESSON_CREATION_KEY_PREFIX = "lesson_creation_limit:"; + + //레슨 생성 가능 여부 확인 + 쿨타임 설정 + public void checkAndSetCreationLimit(Long userId) { + String key = generateRedisKey(userId); + + // 이미 쿨타임이 설정되어 있는지 확인 + if (redisTemplate.hasKey(key)) { + Long remainingTtl = redisTemplate.getExpire(key); + log.info("레슨 생성 제한 - 사용자 ID: {}, 남은 시간: {}초", userId, remainingTtl); + throw new BusinessException(ErrorCode.LESSON_CREATION_TOO_FREQUENT); + } + + // 쿨타임 설정 + redisTemplate.opsForValue().set(key, "restricted", CREATION_COOLTIME); + log.info("레슨 생성 쿨타임 설정 - 사용자 ID: {}, 지속시간: {}분", userId, CREATION_COOLTIME.toMinutes()); + } + + // Redis 키를 생성 + private String generateRedisKey(Long userId) { + return LESSON_CREATION_KEY_PREFIX + userId; + } +} diff --git a/src/main/java/com/threestar/trainus/domain/metadata/entity/ProfileMetadata.java b/src/main/java/com/threestar/trainus/domain/metadata/entity/ProfileMetadata.java index 779ba778..6f69aace 100644 --- a/src/main/java/com/threestar/trainus/domain/metadata/entity/ProfileMetadata.java +++ b/src/main/java/com/threestar/trainus/domain/metadata/entity/ProfileMetadata.java @@ -41,13 +41,4 @@ public class ProfileMetadata { @Column(nullable = false) private Double rating; - public void increaseReviewCount() { - this.reviewCount++; - } - - public double updateRating(double newRating) { - double totalRatingBefore = this.rating * (reviewCount - 1); - totalRatingBefore += newRating; - return totalRatingBefore / reviewCount; - } } diff --git a/src/main/java/com/threestar/trainus/domain/metadata/repository/ProfileMetadataRepository.java b/src/main/java/com/threestar/trainus/domain/metadata/repository/ProfileMetadataRepository.java index f57a4399..35434946 100644 --- a/src/main/java/com/threestar/trainus/domain/metadata/repository/ProfileMetadataRepository.java +++ b/src/main/java/com/threestar/trainus/domain/metadata/repository/ProfileMetadataRepository.java @@ -3,20 +3,12 @@ import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Lock; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; import com.threestar.trainus.domain.metadata.entity.ProfileMetadata; -import jakarta.persistence.LockModeType; - public interface ProfileMetadataRepository extends JpaRepository { Optional findByUserId(Long userId); - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("select p from ProfileMetadata p where p.user.id = :userId") - Optional findWithLockByUserId(@Param("userId") Long userId); } diff --git a/src/main/java/com/threestar/trainus/domain/metadata/scheduler/ProfileMetadataScheduler.java b/src/main/java/com/threestar/trainus/domain/metadata/scheduler/ProfileMetadataScheduler.java new file mode 100644 index 00000000..bb995264 --- /dev/null +++ b/src/main/java/com/threestar/trainus/domain/metadata/scheduler/ProfileMetadataScheduler.java @@ -0,0 +1,41 @@ +package com.threestar.trainus.domain.metadata.scheduler; + +import java.util.List; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.threestar.trainus.domain.metadata.service.ProfileMetadataService; +import com.threestar.trainus.domain.user.entity.User; +import com.threestar.trainus.domain.user.entity.UserRole; +import com.threestar.trainus.domain.user.repository.UserRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ProfileMetadataScheduler { + + private final ProfileMetadataService profileMetadataService; + private final UserRepository userRepository; + + @Scheduled(fixedRate = 180000) // 3분마다 실행 + public void updateAllProfileMetadata() { + try { + List instructors = userRepository.findByRole(UserRole.USER); + + for (User instructor : instructors) { + try { + profileMetadataService.batchUpdateMetadata(instructor.getId()); + } catch (Exception e) { + log.warn("강사 ID {}의 메타데이터 업데이트 실패: {}", instructor.getId(), e.getMessage()); + } + } + } catch (Exception e) { + log.error("프로필 메타데이터 배치 업데이트 중 오류 발생", e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/threestar/trainus/domain/metadata/service/ProfileMetadataService.java b/src/main/java/com/threestar/trainus/domain/metadata/service/ProfileMetadataService.java index 03d8a2e9..7f5b155a 100644 --- a/src/main/java/com/threestar/trainus/domain/metadata/service/ProfileMetadataService.java +++ b/src/main/java/com/threestar/trainus/domain/metadata/service/ProfileMetadataService.java @@ -7,20 +7,23 @@ import com.threestar.trainus.domain.metadata.entity.ProfileMetadata; import com.threestar.trainus.domain.metadata.mapper.ProfileMetadataMapper; import com.threestar.trainus.domain.metadata.repository.ProfileMetadataRepository; +import com.threestar.trainus.domain.review.repository.ReviewRepository; import com.threestar.trainus.domain.user.entity.User; import com.threestar.trainus.domain.user.repository.UserRepository; -import com.threestar.trainus.domain.user.service.UserService; import com.threestar.trainus.global.exception.domain.ErrorCode; import com.threestar.trainus.global.exception.handler.BusinessException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Service @RequiredArgsConstructor public class ProfileMetadataService { private final ProfileMetadataRepository profileMetadataRepository; private final UserRepository userRepository; + private final ReviewRepository reviewRepository; @Transactional public void createDefaultMetadata(User user) { @@ -39,11 +42,41 @@ public ProfileMetadataResponseDto getMetadata(Long userId) { return ProfileMetadataMapper.toResponseDto(profileMetadata, user); } + @Transactional - public void increaseReviewCountAndRating(Long userId, double newRating) { - ProfileMetadata profileMetadata = profileMetadataRepository.findWithLockByUserId(userId) + public void batchUpdateMetadata(Long userId) { + log.debug("사용자 ID {}의 메타데이터 배치 업데이트 시작", userId); + + ProfileMetadata profileMetadata = profileMetadataRepository.findByUserId(userId) .orElseThrow(() -> new BusinessException(ErrorCode.METADATA_NOT_FOUND)); - profileMetadata.increaseReviewCount(); - profileMetadata.setRating(profileMetadata.updateRating(newRating)); + + // 실제 리뷰 수와 평균 평점 계산 + Integer actualReviewCount = reviewRepository.countByRevieweeId(userId); + Double actualAverageRating = reviewRepository.findAverageRatingByRevieweeId(userId); + + if (actualAverageRating == null) { + actualAverageRating = 0.0; + } + + // 현재 값과 다르면 업데이트 + boolean updated = false; + if (!profileMetadata.getReviewCount().equals(actualReviewCount)) { + profileMetadata = ProfileMetadata.builder() + .id(profileMetadata.getId()) + .user(profileMetadata.getUser()) + .reviewCount(actualReviewCount) + .rating(actualAverageRating) + .build(); + updated = true; + } else if (!profileMetadata.getRating().equals(actualAverageRating)) { + profileMetadata.setRating(actualAverageRating); + updated = true; + } + + if (updated) { + profileMetadataRepository.save(profileMetadata); + log.debug("사용자 ID {}의 메타데이터 업데이트 완료. 리뷰수: {}, 평점: {}", + userId, actualReviewCount, actualAverageRating); + } } } diff --git a/src/main/java/com/threestar/trainus/domain/payment/controller/PaymentController.java b/src/main/java/com/threestar/trainus/domain/payment/controller/PaymentController.java index efffe8d0..9449ee9f 100644 --- a/src/main/java/com/threestar/trainus/domain/payment/controller/PaymentController.java +++ b/src/main/java/com/threestar/trainus/domain/payment/controller/PaymentController.java @@ -1,13 +1,12 @@ package com.threestar.trainus.domain.payment.controller; -import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import com.threestar.trainus.domain.payment.dto.ConfirmPaymentRequestDto; @@ -24,6 +23,7 @@ import com.threestar.trainus.domain.payment.mapper.PaymentMapper; import com.threestar.trainus.domain.payment.service.PaymentService; import com.threestar.trainus.global.annotation.LoginUser; +import com.threestar.trainus.global.dto.PageRequestDto; import com.threestar.trainus.global.unit.BaseResponse; import com.threestar.trainus.global.unit.PagedResponse; @@ -43,9 +43,6 @@ public class PaymentController { private final PaymentService paymentService; - @Value("${spring.page.size.limit}") - private int pageSizeLimit; - @PostMapping("/prepare") @Operation(summary = "결제 준비", description = "실제 결제 전 최종 가격 적용 후 결제 준비") public ResponseEntity> preparePayment( @@ -95,13 +92,10 @@ public ResponseEntity> cancel( @GetMapping("/view/success") @Operation(summary = "완료 결제 조회", description = "완료된 결제 내역 조회") public ResponseEntity> readAll( - @RequestParam("page") int page, - @RequestParam("pageSize") int pageSize, + @Valid @ModelAttribute PageRequestDto pageRequestDto, @LoginUser Long userId) { - int correctPage = Math.max(page, 1); - int correctPageSize = Math.max(1, Math.min(pageSize, pageSizeLimit)); PaymentSuccessHistoryPageDto paymentSuccessHistoryPageDto = paymentService.viewAllSuccessTransaction(userId, - correctPage, correctPageSize); + pageRequestDto.getPage(), pageRequestDto.getLimit()); PaymentSuccessPageWrapperDto payments = PaymentMapper.toPaymentSuccessPageWrapperDto( paymentSuccessHistoryPageDto); return PagedResponse.ok("성공 결제 조회 성공", payments, paymentSuccessHistoryPageDto.count(), HttpStatus.OK); @@ -110,13 +104,10 @@ public ResponseEntity> readAll( @GetMapping("/view/cancel") @Operation(summary = "취소 결제 조회", description = "취소된 결제 내역 조회") public ResponseEntity> readAllFailure(HttpSession session, - @RequestParam("page") int page, - @RequestParam("pageSize") int pageSize, + @Valid @ModelAttribute PageRequestDto pageRequestDto, @LoginUser Long userId) { - int correctPage = Math.max(page, 1); - int correctPageSize = Math.max(1, Math.min(pageSize, pageSizeLimit)); PaymentCancelHistoryPageDto paymentCancelHistoryPageDto = paymentService.viewAllFailureTransaction(userId, - correctPage, correctPageSize); + pageRequestDto.getPage(), pageRequestDto.getLimit()); PaymentCancelPageWrapperDto payments = PaymentMapper.toPaymentFailurePageWrapperDto( paymentCancelHistoryPageDto); return PagedResponse.ok("취소 결제 조회 성공", payments, paymentCancelHistoryPageDto.count(), HttpStatus.OK); diff --git a/src/main/java/com/threestar/trainus/domain/payment/entity/Payment.java b/src/main/java/com/threestar/trainus/domain/payment/entity/Payment.java index c21f4bf3..4f0fe5f2 100644 --- a/src/main/java/com/threestar/trainus/domain/payment/entity/Payment.java +++ b/src/main/java/com/threestar/trainus/domain/payment/entity/Payment.java @@ -37,7 +37,7 @@ public class Payment extends BaseDateEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long paymentId; + private Long id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) @@ -65,4 +65,17 @@ public class Payment extends BaseDateEntity { @Enumerated(EnumType.STRING) private PaymentMethod paymentMethod; + public void processPayment(int totalPrice, LocalDateTime paidAt, String method) { + this.payPrice = totalPrice; + this.payDate = paidAt; + this.status = PaymentStatus.DONE; + this.paymentMethod = PaymentMethod.fromTossMethod(method); + } + + public void cancelPayment(LocalDateTime cancelAt, int refundPrice) { + this.status = PaymentStatus.CANCELED; + this.cancelledAt = cancelAt; + this.refundPrice = refundPrice; + } + } diff --git a/src/main/java/com/threestar/trainus/domain/payment/entity/TossPayment.java b/src/main/java/com/threestar/trainus/domain/payment/entity/TossPayment.java index cb1ef177..ab5be44d 100644 --- a/src/main/java/com/threestar/trainus/domain/payment/entity/TossPayment.java +++ b/src/main/java/com/threestar/trainus/domain/payment/entity/TossPayment.java @@ -27,7 +27,7 @@ public class TossPayment { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long tossPaymentId; + private Long id; @Column(nullable = false, unique = true) private String paymentKey; diff --git a/src/main/java/com/threestar/trainus/domain/payment/mapper/PaymentMapper.java b/src/main/java/com/threestar/trainus/domain/payment/mapper/PaymentMapper.java index a96dc309..6d31fbf3 100644 --- a/src/main/java/com/threestar/trainus/domain/payment/mapper/PaymentMapper.java +++ b/src/main/java/com/threestar/trainus/domain/payment/mapper/PaymentMapper.java @@ -1,7 +1,12 @@ package com.threestar.trainus.domain.payment.mapper; +import java.time.LocalDateTime; import java.util.List; +import com.threestar.trainus.domain.coupon.user.entity.UserCoupon; +import com.threestar.trainus.domain.lesson.teacher.entity.Lesson; +import com.threestar.trainus.domain.payment.dto.PaymentResponseDto; +import com.threestar.trainus.domain.payment.dto.TossPaymentResponseDto; import com.threestar.trainus.domain.payment.dto.cancel.CancelPaymentResponseDto; import com.threestar.trainus.domain.payment.dto.cancel.PaymentCancelHistoryPageDto; import com.threestar.trainus.domain.payment.dto.cancel.PaymentCancelHistoryResponseDto; @@ -11,7 +16,10 @@ import com.threestar.trainus.domain.payment.dto.success.PaymentSuccessPageWrapperDto; import com.threestar.trainus.domain.payment.dto.success.SuccessfulPaymentResponseDto; import com.threestar.trainus.domain.payment.entity.Payment; +import com.threestar.trainus.domain.payment.entity.PaymentMethod; +import com.threestar.trainus.domain.payment.entity.PaymentStatus; import com.threestar.trainus.domain.payment.entity.TossPayment; +import com.threestar.trainus.domain.user.entity.User; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -19,6 +27,36 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public class PaymentMapper { + public static Payment toPayment(User user, Lesson lesson, String orderId, int finalPrice, UserCoupon coupon, + PaymentStatus status, PaymentMethod payMethod) { + return Payment.builder() + .user(user) + .lesson(lesson) + .orderId(orderId) + .payPrice(finalPrice) + .originPrice(lesson.getPrice()) + .payDate(LocalDateTime.now()) + .userCoupon(coupon) + .status(status) + .paymentMethod(payMethod) + .build(); + } + + public static TossPayment toTossPayment(Payment payment, TossPaymentResponseDto tossDto, LocalDateTime requestAt, + LocalDateTime paidAt, PaymentStatus status) { + return TossPayment.builder() + .payment(payment) + .paymentKey(tossDto.paymentKey()) + .orderId(tossDto.orderId()) + .amount(tossDto.totalAmount()) + .orderName(tossDto.orderName()) + .paymentStatus(status) + .paymentMethod(PaymentMethod.fromTossMethod(tossDto.method())) + .requestedAt(requestAt) + .approvedAt(paidAt) + .build(); + } + public static SuccessfulPaymentResponseDto toSuccessfulPaymentResponseDto(Payment payment) { return SuccessfulPaymentResponseDto.builder() .payPrice(payment.getPayPrice()) @@ -110,4 +148,14 @@ public static PaymentCancelPageWrapperDto toPaymentFailurePageWrapperDto(Payment .failureHistory(paymentDto.failureHistory()) .build(); } + + public static PaymentResponseDto toPaymentResponseDto(int originPrice, String lessonTitle, PaymentMethod payMethod ,int finalPrice ,String orderId) { + return PaymentResponseDto.builder() + .originPrice(originPrice) + .lessonTitle(lessonTitle) + .paymentMethod(payMethod) + .payPrice(finalPrice) + .orderId(orderId) + .build(); + } } diff --git a/src/main/java/com/threestar/trainus/domain/payment/repository/PaymentRepository.java b/src/main/java/com/threestar/trainus/domain/payment/repository/PaymentRepository.java index f6dc8dfa..0a083e7d 100644 --- a/src/main/java/com/threestar/trainus/domain/payment/repository/PaymentRepository.java +++ b/src/main/java/com/threestar/trainus/domain/payment/repository/PaymentRepository.java @@ -16,27 +16,48 @@ public interface PaymentRepository extends JpaRepository { Optional findByOrderId(String orderId); - Optional findByUserAndLessonAndUserCouponAndStatus(User user, Lesson lesson, UserCoupon coupon, PaymentStatus status); + Optional findByUserAndLessonAndUserCouponAndStatus(User user, Lesson lesson, UserCoupon coupon, + PaymentStatus status); Optional findByUserAndLessonAndUserCouponIsNullAndStatus(User user, Lesson lesson, PaymentStatus status); @Query(value = """ - select * from payments - where user_id = :userId - and status = :status - order by pay_date desc - limit :limit offset :offset - """, nativeQuery = true - ) - List findAllByUserAndStatus( + SELECT p.id FROM payments p + WHERE p.user_id = :userId + AND p.status = :status + ORDER BY p.pay_date DESC + LIMIT :limit OFFSET :offset + """, nativeQuery = true) + List findPaymentIdsByUserAndStatus( + @Param("userId") Long userId, + @Param("status") String status, + @Param("offset") int offset, + @Param("limit") int limit + ); + + @Query(value = """ + SELECT p.id FROM payments p + WHERE p.user_id = :userId + AND p.status = :status + ORDER BY p.cancelled_at DESC + LIMIT :limit OFFSET :offset + """, nativeQuery = true) + List findCancelledPaymentIdsByUserAndStatus( @Param("userId") Long userId, @Param("status") String status, @Param("offset") int offset, @Param("limit") int limit ); + @Query(""" + SELECT p FROM Payment p + LEFT JOIN FETCH p.lesson + WHERE p.id IN :ids + """) + List findAllWithAssociationsByIds(@Param("ids") List ids); + @Query(value = """ - select count(*) from (select payment_id from payments where user_id = :userId and status = :status limit :limit) t + SELECT count(*) FROM (SELECT id FROM payments WHERE user_id = :userId AND status = :status LIMIT :limit) t """, nativeQuery = true ) Integer count( diff --git a/src/main/java/com/threestar/trainus/domain/payment/repository/TossPaymentRepository.java b/src/main/java/com/threestar/trainus/domain/payment/repository/TossPaymentRepository.java index 09a6f647..283d4216 100644 --- a/src/main/java/com/threestar/trainus/domain/payment/repository/TossPaymentRepository.java +++ b/src/main/java/com/threestar/trainus/domain/payment/repository/TossPaymentRepository.java @@ -1,12 +1,21 @@ package com.threestar.trainus.domain.payment.repository; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.threestar.trainus.domain.payment.entity.TossPayment; public interface TossPaymentRepository extends JpaRepository { Optional findByOrderId(String orderId); + + @Query(""" + SELECT t FROM TossPayment t + WHERE t.orderId IN :orderIds + """) + List findAllByOrderIds(@Param("orderIds") List orderIds); } diff --git a/src/main/java/com/threestar/trainus/domain/payment/service/PaymentService.java b/src/main/java/com/threestar/trainus/domain/payment/service/PaymentService.java index 2a5944c1..7261fbdd 100644 --- a/src/main/java/com/threestar/trainus/domain/payment/service/PaymentService.java +++ b/src/main/java/com/threestar/trainus/domain/payment/service/PaymentService.java @@ -3,9 +3,12 @@ import java.time.LocalDateTime; import java.time.OffsetDateTime; import java.time.format.DateTimeFormatter; +import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.UUID; +import java.util.stream.Collectors; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -14,6 +17,7 @@ import com.threestar.trainus.domain.coupon.user.service.CouponService; import com.threestar.trainus.domain.lesson.student.service.StudentLessonService; import com.threestar.trainus.domain.lesson.teacher.entity.Lesson; +import com.threestar.trainus.domain.lesson.teacher.repository.LessonParticipantRepository; import com.threestar.trainus.domain.lesson.teacher.service.AdminLessonService; import com.threestar.trainus.domain.payment.dto.ConfirmPaymentRequestDto; import com.threestar.trainus.domain.payment.dto.PaymentClient; @@ -40,6 +44,7 @@ import com.threestar.trainus.global.exception.domain.ErrorCode; import com.threestar.trainus.global.exception.handler.BusinessException; import com.threestar.trainus.global.utils.PageLimitCalculator; +import com.threestar.trainus.global.utils.RefundPolicyUtils; import lombok.RequiredArgsConstructor; @@ -54,20 +59,9 @@ public class PaymentService { private final StudentLessonService studentLessonService; private final PaymentRepository paymentRepository; private final TossPaymentRepository tossPaymentRepository; + private final LessonParticipantRepository lessonParticipantRepository; - private static int getRefundPrice(LocalDateTime cancelTime, Payment payment) { - int refundPrice = 0; - if (cancelTime.isBefore(payment.getLesson().getStartAt().minusMonths(1))) { - refundPrice = payment.getPayPrice(); - } else if (cancelTime.isBefore(payment.getLesson().getStartAt().minusWeeks(1))) { - refundPrice = (payment.getPayPrice() * 50 / 100); - } else if (cancelTime.isBefore(payment.getLesson().getStartAt().minusDays(3))) { - refundPrice = (payment.getPayPrice() * 30 / 100); - } else { - refundPrice = (payment.getPayPrice() * 30 / 100); - } - return refundPrice; - } + private final DateTimeFormatter formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME; @Transactional public PaymentResponseDto preparePayment(PaymentRequestDto request, Long userId) { @@ -81,7 +75,6 @@ public PaymentResponseDto preparePayment(PaymentRequestDto request, Long userId) validateDuplicatedPayment(lesson, user); int discount = 0; - UserCoupon coupon = null; if (request.userCouponId() != null) { //쿠폰이 있는 주문 내역(실제 결제 진행 전) 불러오기 coupon = couponService.getValidUserCoupon(request.userCouponId(), userId); @@ -89,56 +82,35 @@ public PaymentResponseDto preparePayment(PaymentRequestDto request, Long userId) Optional existingCoupon = paymentRepository.findByUserAndLessonAndUserCouponAndStatus(user, lesson, coupon, PaymentStatus.READY); if (existingCoupon.isPresent()) { - return PaymentResponseDto.builder() - .originPrice(existingCoupon.get().getOriginPrice()) - .lessonTitle(lesson.getLessonName()) - .paymentMethod(existingCoupon.get().getPaymentMethod()) - .payPrice(existingCoupon.get().getPayPrice()) - .orderId(existingCoupon.get().getOrderId()) - .build(); + return PaymentMapper.toPaymentResponseDto(existingCoupon.get().getOriginPrice(), + lesson.getLessonName(), + existingCoupon.get().getPaymentMethod(), + existingCoupon.get().getPayPrice(), + existingCoupon.get().getOrderId()); } - discount = couponService.calculateDiscountedPrice(lesson.getPrice(), coupon); } else { Optional existingNoneCoupon = paymentRepository.findByUserAndLessonAndUserCouponIsNullAndStatus( user, lesson, PaymentStatus.READY); if (existingNoneCoupon.isPresent()) { - return PaymentResponseDto.builder() - .originPrice(existingNoneCoupon.get().getOriginPrice()) - .lessonTitle(lesson.getLessonName()) - .paymentMethod(existingNoneCoupon.get().getPaymentMethod()) - .payPrice(existingNoneCoupon.get().getPayPrice()) - .orderId(existingNoneCoupon.get().getOrderId()) - .build(); + return PaymentMapper.toPaymentResponseDto(existingNoneCoupon.get().getOriginPrice(), + lesson.getLessonName(), + existingNoneCoupon.get().getPaymentMethod(), + existingNoneCoupon.get().getPayPrice(), + existingNoneCoupon.get().getOrderId()); } } int finalPrice = Math.max(0, lesson.getPrice() - discount); - String orderId = UUID.randomUUID().toString(); - - Payment payment = Payment.builder() - .user(user) - .lesson(lesson) - .orderId(orderId) - .payPrice(finalPrice) - .originPrice(lesson.getPrice()) - .payDate(LocalDateTime.now()) - .userCoupon(coupon) - .status(PaymentStatus.READY) - .paymentMethod(PaymentMethod.CREDIT_CARD) - .build(); + Payment payment = PaymentMapper.toPayment(user, lesson, orderId, finalPrice, coupon, PaymentStatus.READY, + PaymentMethod.CREDIT_CARD); paymentRepository.save(payment); - return PaymentResponseDto.builder() - .originPrice(lesson.getPrice()) - .lessonTitle(lesson.getLessonName()) - .paymentMethod(PaymentMethod.CREDIT_CARD) - .payPrice(finalPrice) - .orderId(orderId) - .build(); + return PaymentMapper.toPaymentResponseDto(lesson.getPrice(), lesson.getLessonName(), PaymentMethod.CREDIT_CARD, + finalPrice, orderId); } @Transactional @@ -153,26 +125,18 @@ public SuccessfulPaymentResponseDto processConfirm(ConfirmPaymentRequestDto requ throw new BusinessException(ErrorCode.INVALID_PAYMENT); } - DateTimeFormatter formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME; LocalDateTime paidAt = OffsetDateTime.parse(tossResponseDto.approvedAt(), formatter).toLocalDateTime(); LocalDateTime requestAt = OffsetDateTime.parse(tossResponseDto.requestedAt(), formatter).toLocalDateTime(); - payment.setPayPrice(tossResponseDto.totalAmount()); - payment.setPayDate(paidAt); - payment.setStatus(PaymentStatus.DONE); - payment.setPaymentMethod(PaymentMethod.fromTossMethod(tossResponseDto.method())); - - TossPayment tossPayment = TossPayment.builder() - .payment(payment) - .paymentKey(tossResponseDto.paymentKey()) - .orderId(tossResponseDto.orderId()) - .amount(tossResponseDto.totalAmount()) - .orderName(tossResponseDto.orderName()) - .paymentStatus(PaymentStatus.DONE) - .paymentMethod(PaymentMethod.fromTossMethod(tossResponseDto.method())) - .requestedAt(requestAt) - .approvedAt(paidAt) - .build(); + payment.processPayment(tossResponseDto.totalAmount(), paidAt, tossResponseDto.method()); + + studentLessonService.completeParticipantPayment( + payment.getLesson().getId(), + payment.getUser().getId() + ); + + TossPayment tossPayment = PaymentMapper.toTossPayment(payment, tossResponseDto, requestAt, paidAt, + PaymentStatus.DONE); if (payment.getUserCoupon() != null) { couponService.useCoupon(payment.getUserCoupon()); @@ -190,14 +154,12 @@ public CancelPaymentResponseDto processCancel(CancelPaymentRequestDto request) { Payment payment = tossPayment.getPayment(); LocalDateTime cancelTime = LocalDateTime.now(); - DateTimeFormatter formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME; - //날짜 검증(취소 가능은 24시간 전까지) if (cancelTime.isAfter(payment.getLesson().getStartAt().minusDays(1))) { throw new BusinessException(ErrorCode.INVALID_CANCEL_DATE); } - int refundPrice = getRefundPrice(cancelTime, payment); + int refundPrice = RefundPolicyUtils.getRefundPrice(cancelTime, payment); //여기서 client 호출 TossPaymentResponseDto tossResponse = paymentClient.cancelPayment( @@ -209,9 +171,7 @@ public CancelPaymentResponseDto processCancel(CancelPaymentRequestDto request) { tossPayment.changeStatus(cancelAt, PaymentStatus.CANCELED, request.cancelReason()); tossPaymentRepository.save(tossPayment); - payment.setStatus(PaymentStatus.CANCELED); - payment.setCancelledAt(cancelAt); - payment.setRefundPrice(refundPrice); + payment.cancelPayment(cancelAt, refundPrice); // lesson 관련 데이터 업데이트(lessonRepository 삭제 및 lesson 데이터 변경) studentLessonService.cancelPayment(payment.getLesson().getId(), payment.getUser().getId()); @@ -229,12 +189,34 @@ public CancelPaymentResponseDto processCancel(CancelPaymentRequestDto request) { @Transactional(readOnly = true) public PaymentSuccessHistoryPageDto viewAllSuccessTransaction(Long userId, int page, int pageSize) { - List allSuccessPayments = paymentRepository.findAllByUserAndStatus(userId, PaymentStatus.DONE.name(), - (page - 1) * pageSize, pageSize); + List paymentIds = paymentRepository.findPaymentIdsByUserAndStatus(userId, + PaymentStatus.DONE.name(), (page - 1) * pageSize, pageSize); + + if (paymentIds.isEmpty()) { + return PaymentMapper.toPaymentSuccessHistoryPageDto(Collections.emptyList(), 0); + } + + List payments = paymentRepository.findAllWithAssociationsByIds(paymentIds); + Map map = payments.stream() + .collect(Collectors.toMap(Payment::getId, p -> p)); + List allSuccessPayments = paymentIds.stream() + .map(map::get) + .toList(); + + List orderIds = allSuccessPayments.stream() + .map(Payment::getOrderId) + .toList(); + + Map tossPaymentMap = tossPaymentRepository.findAllByOrderIds(orderIds) + .stream() + .collect(Collectors.toMap(TossPayment::getOrderId, t -> t)); + List dtoList = allSuccessPayments.stream() .map(payment -> { - TossPayment tossPayment = tossPaymentRepository.findByOrderId(payment.getOrderId()) - .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_PAYMENT)); + TossPayment tossPayment = tossPaymentMap.get(payment.getOrderId()); + if (tossPayment == null) { + throw new BusinessException(ErrorCode.INVALID_PAYMENT); + } return PaymentMapper.toPaymentSuccessHistoryResponseDto(payment, tossPayment); }) .toList(); @@ -247,13 +229,34 @@ public PaymentSuccessHistoryPageDto viewAllSuccessTransaction(Long userId, int p @Transactional(readOnly = true) public PaymentCancelHistoryPageDto viewAllFailureTransaction(Long userId, int page, int pageSize) { - List allFailurePayments = paymentRepository.findAllByUserAndStatus(userId, - PaymentStatus.CANCELED.name(), - (page - 1) * pageSize, pageSize); + List paymentIds = paymentRepository.findCancelledPaymentIdsByUserAndStatus(userId, + PaymentStatus.CANCELED.name(), (page - 1) * pageSize, pageSize); + + if (paymentIds.isEmpty()) { + return PaymentMapper.toPaymentFailureHistoryPageDto(Collections.emptyList(), 0); + } + + List payments = paymentRepository.findAllWithAssociationsByIds(paymentIds); + Map map = payments.stream() + .collect(Collectors.toMap(Payment::getId, p -> p)); + List allFailurePayments = paymentIds.stream() + .map(map::get) + .toList(); + + List orderIds = allFailurePayments.stream() + .map(Payment::getOrderId) + .toList(); + + Map tossPaymentMap = tossPaymentRepository.findAllByOrderIds(orderIds) + .stream() + .collect(Collectors.toMap(TossPayment::getOrderId, t -> t)); + List dtoList = allFailurePayments.stream() .map(payment -> { - TossPayment tossPayment = tossPaymentRepository.findByOrderId(payment.getOrderId()) - .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_PAYMENT)); + TossPayment tossPayment = tossPaymentMap.get(payment.getOrderId()); + if (tossPayment == null) { + throw new BusinessException(ErrorCode.INVALID_PAYMENT); + } return PaymentMapper.toPaymentFailureHistoryResponseDto(payment, tossPayment); }) .toList(); @@ -266,7 +269,7 @@ public PaymentCancelHistoryPageDto viewAllFailureTransaction(Long userId, int pa public void validateDuplicatedPayment(Lesson lesson, User user) { boolean alreadyPaid = paymentRepository.existsByLessonAndUserAndStatusIn( - lesson, user, List.of(PaymentStatus.DONE) + lesson, user, List.of(PaymentStatus.DONE, PaymentStatus.CANCELED) ); if (alreadyPaid) { @@ -274,4 +277,4 @@ public void validateDuplicatedPayment(Lesson lesson, User user) { } } -} \ No newline at end of file +} diff --git a/src/main/java/com/threestar/trainus/domain/profile/entity/Profile.java b/src/main/java/com/threestar/trainus/domain/profile/entity/Profile.java index 76c71623..69b63de6 100644 --- a/src/main/java/com/threestar/trainus/domain/profile/entity/Profile.java +++ b/src/main/java/com/threestar/trainus/domain/profile/entity/Profile.java @@ -33,10 +33,10 @@ public class Profile { @JoinColumn(name = "user_id", nullable = false) private User user; - @Column(length = 255) + @Column(length = 2048) private String profileImage; - @Column(length = 255) + @Column(length = 512) private String intro; public void updateProfileImage(String profileImage) { diff --git a/src/main/java/com/threestar/trainus/domain/profile/mapper/ProfileLessonMapper.java b/src/main/java/com/threestar/trainus/domain/profile/mapper/ProfileLessonMapper.java index 1159596b..2d2ed43b 100644 --- a/src/main/java/com/threestar/trainus/domain/profile/mapper/ProfileLessonMapper.java +++ b/src/main/java/com/threestar/trainus/domain/profile/mapper/ProfileLessonMapper.java @@ -31,7 +31,7 @@ public static ProfileCreatedLessonDto toProfileCreatedLessonDto(Lesson lesson) { // 개설한 레슨 목록과 총 레슨의 수를 응답 DTO로 변환 public static ProfileCreatedLessonListResponseDto toProfileCreatedLessonListResponseDto( - List lessons, Long totalCount) { + List lessons, int totalCount) { // 각 레슨을 DTO로 변환 List lessonDtos = lessons.stream() @@ -39,7 +39,7 @@ public static ProfileCreatedLessonListResponseDto toProfileCreatedLessonListResp .toList(); return ProfileCreatedLessonListResponseDto.builder() .lessons(lessonDtos) - .count(totalCount.intValue()) + .count(totalCount) .build(); } diff --git a/src/main/java/com/threestar/trainus/domain/profile/service/ProfileLessonService.java b/src/main/java/com/threestar/trainus/domain/profile/service/ProfileLessonService.java index 319e1b67..79160e40 100644 --- a/src/main/java/com/threestar/trainus/domain/profile/service/ProfileLessonService.java +++ b/src/main/java/com/threestar/trainus/domain/profile/service/ProfileLessonService.java @@ -1,9 +1,7 @@ package com.threestar.trainus.domain.profile.service; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; +import java.util.List; + import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -12,9 +10,8 @@ import com.threestar.trainus.domain.lesson.teacher.repository.LessonRepository; import com.threestar.trainus.domain.profile.dto.ProfileCreatedLessonListResponseDto; import com.threestar.trainus.domain.profile.mapper.ProfileLessonMapper; -import com.threestar.trainus.domain.user.entity.User; -import com.threestar.trainus.domain.user.repository.UserRepository; import com.threestar.trainus.domain.user.service.UserService; +import com.threestar.trainus.global.utils.PageLimitCalculator; import lombok.RequiredArgsConstructor; @@ -23,7 +20,6 @@ public class ProfileLessonService { private final LessonRepository lessonRepository; - private final UserRepository userRepository; private final UserService userService; // 특정 유저가 개설한 레슨 목록 조회 @@ -32,25 +28,29 @@ public ProfileCreatedLessonListResponseDto getUserCreatedLessons( Long userId, int page, int limit, LessonStatus status) { // User 존재 확인 - User user = userService.getUserById(userId); + userService.getUserById(userId); + + // limit 계산 + int countLimit = PageLimitCalculator.calculatePageLimit(page, limit, 5); - // 페이징 설정 -> 내림차순!! - Pageable pageable = PageRequest.of(page - 1, limit, Sort.by("createdAt").descending()); + // 리스트 조회 (Native Query) + List lessons = lessonRepository.findCreatedLessonsByUser( + userId, + status != null ? status.name() : null, + limit, + (page - 1) * limit // OFFSET + ); - // 레슨 상태에 따른 조회 - Page lessonPage; + // totalCount 조회 (Native Query) + int totalCount; if (status != null) { - // 상태에 따라 조회 가능 - lessonPage = lessonRepository.findByLessonLeaderAndStatusAndDeletedAtIsNull(userId, status, pageable); + totalCount = lessonRepository.countCreatedLessonsByStatus(userId, status, countLimit); } else { - // 삭제되지 않은 레슨만 조회 - lessonPage = lessonRepository.findByLessonLeaderAndDeletedAtIsNull(userId, pageable); + totalCount = lessonRepository.countCreatedLessons(userId, countLimit); } // DTO 변환 - return ProfileLessonMapper.toProfileCreatedLessonListResponseDto( - lessonPage.getContent(), - lessonPage.getTotalElements() - ); + return ProfileLessonMapper.toProfileCreatedLessonListResponseDto(lessons, totalCount); } + } diff --git a/src/main/java/com/threestar/trainus/domain/ranking/repository/RankingRepository.java b/src/main/java/com/threestar/trainus/domain/ranking/repository/RankingRepository.java index 2403800b..192991e4 100644 --- a/src/main/java/com/threestar/trainus/domain/ranking/repository/RankingRepository.java +++ b/src/main/java/com/threestar/trainus/domain/ranking/repository/RankingRepository.java @@ -21,7 +21,7 @@ public interface RankingRepository extends JpaRepository FROM ProfileMetadata pm JOIN pm.user u LEFT JOIN Profile p ON p.user = u - WHERE pm.reviewCount >= 20 + WHERE pm.reviewCount >= 5 ORDER BY ( (pm.rating / 5.0) * 0.5 + (LEAST(pm.reviewCount, 100) / 100.0) * 0.5 @@ -31,22 +31,25 @@ ORDER BY ( List findTopRankings(); @Query(""" - SELECT r.reviewee.id as userId, - r.reviewee.nickname as userNickname, - CAST(COUNT(r.reviewId) AS INTEGER) as reviewCount, - CAST(AVG(r.rating) AS DOUBLE) as rating, - p.profileImage as profileImage - FROM Review r - JOIN r.lesson l - JOIN r.reviewee u + SELECT pm.user.id as userId, + pm.user.nickname as userNickname, + pm.reviewCount as reviewCount, + pm.rating as rating, + p.profileImage as profileImage + FROM ProfileMetadata pm + JOIN pm.user u LEFT JOIN Profile p ON p.user = u - WHERE l.category = :category - AND r.deletedAt IS NULL - GROUP BY r.reviewee.id, r.reviewee.nickname, p.profileImage - HAVING COUNT(r.reviewId) >= 20 + WHERE pm.reviewCount >= 5 + AND EXISTS ( + SELECT 1 FROM Review r + JOIN r.lesson l + WHERE r.reviewee = u + AND l.category = :category + AND r.deletedAt IS NULL + ) ORDER BY ( - (AVG(r.rating) / 5.0) * 0.5 + - (LEAST(COUNT(r.reviewId), 100) / 100.0) * 0.5 + (pm.rating / 5.0) * 0.5 + + (LEAST(pm.reviewCount, 100) / 100.0) * 0.5 ) DESC LIMIT 10 """) diff --git a/src/main/java/com/threestar/trainus/domain/ranking/service/RankingService.java b/src/main/java/com/threestar/trainus/domain/ranking/service/RankingService.java index 7c5a3ab9..1aa408c0 100644 --- a/src/main/java/com/threestar/trainus/domain/ranking/service/RankingService.java +++ b/src/main/java/com/threestar/trainus/domain/ranking/service/RankingService.java @@ -90,14 +90,14 @@ private List calculateRankings(Category category) { private void saveToRedis(List rankings, String cacheKey) { try { String json = objectMapper.writeValueAsString(rankings); - redisTemplate.opsForValue().set(cacheKey, json, Duration.ofHours(24)); + redisTemplate.opsForValue().set(cacheKey, json, Duration.ofMinutes(11)); } catch (Exception e) { log.warn("Redis 저장 실패: {}", e.getMessage()); } } - //매 자정마다 랭킹 업데이트 - @Scheduled(cron = "0 0 0 * * *") + //매 10분마다 랭킹 업데이트 + @Scheduled(cron = "0 */10 * * * *") public void updateRankings() { log.info("랭킹 업데이트 시작"); diff --git a/src/main/java/com/threestar/trainus/domain/review/controller/ReviewController.java b/src/main/java/com/threestar/trainus/domain/review/controller/ReviewController.java index d5e9d45d..32314b64 100644 --- a/src/main/java/com/threestar/trainus/domain/review/controller/ReviewController.java +++ b/src/main/java/com/threestar/trainus/domain/review/controller/ReviewController.java @@ -1,14 +1,13 @@ package com.threestar.trainus.domain.review.controller; -import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; 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.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import com.threestar.trainus.domain.review.dto.ReviewCreateRequestDto; @@ -18,6 +17,7 @@ import com.threestar.trainus.domain.review.mapper.ReviewMapper; import com.threestar.trainus.domain.review.service.ReviewService; import com.threestar.trainus.global.annotation.LoginUser; +import com.threestar.trainus.global.dto.PageRequestDto; import com.threestar.trainus.global.unit.BaseResponse; import com.threestar.trainus.global.unit.PagedResponse; @@ -33,8 +33,6 @@ public class ReviewController { private final ReviewService reviewService; - @Value("${spring.page.size.limit}") - private int pageSizeLimit; @PostMapping("/{lessonId}") @Operation(summary = "리뷰 작성", description = "레슨 ID에 해당되는 리뷰를 작성합니다.") @@ -48,16 +46,11 @@ public ResponseEntity> createReview(@PathV @GetMapping("/{userId}") @Operation(summary = "리뷰 조회", description = "유저 ID에 해당되는 리뷰들을 조회합니다.") public ResponseEntity> readAll(@PathVariable Long userId, - @RequestParam("page") int page, - @RequestParam("pageSize") int pageSize + @Valid @ModelAttribute PageRequestDto pageRequestDto ) { - int correctPage = Math.max(page, 1); - int correctPageSize = Math.max(1, Math.min(pageSize, pageSizeLimit)); - ReviewPageResponseDto reviewsInfo = reviewService.readAll(userId, correctPage, correctPageSize); + ReviewPageResponseDto reviewsInfo = reviewService.readAll(userId, pageRequestDto.getPage(), + pageRequestDto.getLimit()); ReviewPageWrapperDto reviews = ReviewMapper.toReviewPageWrapperDto(reviewsInfo); - return PagedResponse.ok("조회가 완료됐습니다.", reviews, reviewsInfo.getCount(), HttpStatus.OK); + return PagedResponse.ok("조회가 완료됐습니다.", reviews, reviewsInfo.count(), HttpStatus.OK); } - /* - * TODO:구조 통일 - * */ } diff --git a/src/main/java/com/threestar/trainus/domain/review/dto/ReviewCreateRequestDto.java b/src/main/java/com/threestar/trainus/domain/review/dto/ReviewCreateRequestDto.java index 78795e0d..1b8f46fc 100644 --- a/src/main/java/com/threestar/trainus/domain/review/dto/ReviewCreateRequestDto.java +++ b/src/main/java/com/threestar/trainus/domain/review/dto/ReviewCreateRequestDto.java @@ -1,12 +1,12 @@ package com.threestar.trainus.domain.review.dto; import jakarta.validation.constraints.NotNull; -import lombok.Data; -@Data -public class ReviewCreateRequestDto { - private String content; +public record ReviewCreateRequestDto( + String content, @NotNull(message = "점수는 필수입니다.") - private Double rating; - private String reviewImage; + Double rating, + String reviewImage +) { + } diff --git a/src/main/java/com/threestar/trainus/domain/review/dto/ReviewCreateResponseDto.java b/src/main/java/com/threestar/trainus/domain/review/dto/ReviewCreateResponseDto.java index e4c3077b..e49f62e1 100644 --- a/src/main/java/com/threestar/trainus/domain/review/dto/ReviewCreateResponseDto.java +++ b/src/main/java/com/threestar/trainus/domain/review/dto/ReviewCreateResponseDto.java @@ -1,13 +1,12 @@ package com.threestar.trainus.domain.review.dto; import lombok.Builder; -import lombok.Getter; -@Getter @Builder -public class ReviewCreateResponseDto { - private Long reviewId; - private String content; - private Double rating; - private String reviewImage; +public record ReviewCreateResponseDto( + Long reviewId, + String content, + Double rating, + String reviewImage +) { } diff --git a/src/main/java/com/threestar/trainus/domain/review/dto/ReviewPageResponseDto.java b/src/main/java/com/threestar/trainus/domain/review/dto/ReviewPageResponseDto.java index bcb7c83a..571ff9a8 100644 --- a/src/main/java/com/threestar/trainus/domain/review/dto/ReviewPageResponseDto.java +++ b/src/main/java/com/threestar/trainus/domain/review/dto/ReviewPageResponseDto.java @@ -3,12 +3,11 @@ import java.util.List; import lombok.Builder; -import lombok.Getter; -@Getter @Builder -public class ReviewPageResponseDto { - private Long userId; - private Integer count; - private List reviews; +public record ReviewPageResponseDto( + Long userId, + Integer count, + List reviews +) { } diff --git a/src/main/java/com/threestar/trainus/domain/review/dto/ReviewPageWrapperDto.java b/src/main/java/com/threestar/trainus/domain/review/dto/ReviewPageWrapperDto.java index 5b1b1c92..26ba03b9 100644 --- a/src/main/java/com/threestar/trainus/domain/review/dto/ReviewPageWrapperDto.java +++ b/src/main/java/com/threestar/trainus/domain/review/dto/ReviewPageWrapperDto.java @@ -3,11 +3,10 @@ import java.util.List; import lombok.Builder; -import lombok.Getter; -@Getter @Builder -public class ReviewPageWrapperDto { - private Long userId; - private List reviews; +public record ReviewPageWrapperDto( + Long userId, + List reviews +) { } diff --git a/src/main/java/com/threestar/trainus/domain/review/dto/ReviewViewResponseDto.java b/src/main/java/com/threestar/trainus/domain/review/dto/ReviewViewResponseDto.java index 643940ec..4e6daaa6 100644 --- a/src/main/java/com/threestar/trainus/domain/review/dto/ReviewViewResponseDto.java +++ b/src/main/java/com/threestar/trainus/domain/review/dto/ReviewViewResponseDto.java @@ -3,18 +3,17 @@ import java.time.LocalDateTime; import lombok.Builder; -import lombok.Getter; -@Getter @Builder -public class ReviewViewResponseDto { - private Long reviewId; - private Long lessonId; - private String lessonName; - private Long reviewerId; - private String reviewerNickname; - private String reviewImage; - private String content; - private Double rating; - private LocalDateTime createdAt; +public record ReviewViewResponseDto( + Long reviewId, + Long lessonId, + String lessonName, + Long reviewerId, + String reviewerNickname, + String reviewImage, + String content, + Double rating, + LocalDateTime createdAt +) { } diff --git a/src/main/java/com/threestar/trainus/domain/review/entity/Review.java b/src/main/java/com/threestar/trainus/domain/review/entity/Review.java index 0dac51a6..8c40d589 100644 --- a/src/main/java/com/threestar/trainus/domain/review/entity/Review.java +++ b/src/main/java/com/threestar/trainus/domain/review/entity/Review.java @@ -48,10 +48,10 @@ public class Review extends BaseDateEntity { @Column(nullable = false) private Double rating; - @Column(length = 255, nullable = false) + @Column(length = 512, nullable = false) private String content; - @Column(length = 255) + @Column(length = 2048) private String image; private LocalDateTime deletedAt; diff --git a/src/main/java/com/threestar/trainus/domain/review/mapper/ReviewMapper.java b/src/main/java/com/threestar/trainus/domain/review/mapper/ReviewMapper.java index aad00943..a8640ca6 100644 --- a/src/main/java/com/threestar/trainus/domain/review/mapper/ReviewMapper.java +++ b/src/main/java/com/threestar/trainus/domain/review/mapper/ReviewMapper.java @@ -47,7 +47,7 @@ public static ReviewPageResponseDto toReviewPageResponseDto(Long userId, List { - List findByReviewee_Id(Long userId); + @Query(""" + SELECT r + FROM Review r + JOIN FETCH r.lesson l + JOIN FETCH r.reviewer u + LEFT JOIN FETCH u.profile p + LEFT JOIN FETCH u.profileMetadata pm + WHERE r.reviewee.id = :userId + """) + List findByReviewee_Id(@Param("userId") Long userId); boolean existsByReviewer_IdAndLessonId(Long reviewerId, Long lessonId); @Query(value = """ - select count(*) from (select review_id from reviews where reviewee_id = :userId limit :limit ) t + SELECT COUNT(*) FROM ( + SELECT r.review_id + FROM reviews r + JOIN user u1 ON r.reviewee_id = u1.id AND u1.deleted_at IS NULL + JOIN user u2 ON r.reviewer_id = u2.id AND u2.deleted_at IS NULL + WHERE r.reviewee_id = :userId + LIMIT :limit + ) t """, nativeQuery = true) Integer count( @Param("userId") Long userId, @Param("limit") int limit ); + + @Query("SELECT COUNT(r) FROM Review r WHERE r.reviewee.id = :revieweeId") + Integer countByRevieweeId(@Param("revieweeId") Long revieweeId); + + @Query("SELECT ROUND(AVG(r.rating), 2) FROM Review r WHERE r.reviewee.id = :revieweeId") + Double findAverageRatingByRevieweeId(@Param("revieweeId") Long revieweeId); } diff --git a/src/main/java/com/threestar/trainus/domain/review/service/ReviewService.java b/src/main/java/com/threestar/trainus/domain/review/service/ReviewService.java index 2ef82a79..dde3add8 100644 --- a/src/main/java/com/threestar/trainus/domain/review/service/ReviewService.java +++ b/src/main/java/com/threestar/trainus/domain/review/service/ReviewService.java @@ -5,6 +5,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.threestar.trainus.domain.lesson.student.service.StudentLessonService; import com.threestar.trainus.domain.lesson.teacher.entity.Lesson; import com.threestar.trainus.domain.lesson.teacher.repository.LessonParticipantRepository; import com.threestar.trainus.domain.lesson.teacher.service.AdminLessonService; @@ -31,8 +32,7 @@ public class ReviewService { private final ProfileMetadataService profileMetadataService; private final ReviewRepository reviewRepository; private final AdminLessonService adminLessonService; - private final UserRepository userRepository; - private final LessonParticipantRepository lessonParticipantRepository; + private final StudentLessonService studentLessonService; private final UserService userService; //참여자 테이블에 있는지도 검증 필요 횟수도 한번으로 제한 @@ -43,7 +43,7 @@ public ReviewCreateResponseDto createReview(ReviewCreateRequestDto reviewRequest LocalDateTime reviewEndDate = findLesson.getEndAt().plusDays(7); - if (LocalDateTime.now().isBefore(findLesson.getEndAt()) && LocalDateTime.now().isAfter(reviewEndDate)) { + if (LocalDateTime.now().isBefore(findLesson.getEndAt()) || LocalDateTime.now().isAfter(reviewEndDate)) { throw new BusinessException(ErrorCode.INVALID_REVIEW_DATE); } @@ -51,10 +51,8 @@ public ReviewCreateResponseDto createReview(ReviewCreateRequestDto reviewRequest User lessonLeader = userService.getUserById(findLesson.getLessonLeader()); //참여자 테이블 검증 추후 추가 -> lessonId 와 userId 다 갖고 있는지 - if (!lessonParticipantRepository.existsByLessonIdAndUserId(findLesson.getId(), - findUser.getId())) { - throw new BusinessException(ErrorCode.INVALID_LESSON_PARTICIPANT); - } + studentLessonService.checkValidLessonParticipant(findLesson, findUser); + if (reviewRepository.existsByReviewer_IdAndLessonId(findUser.getId(), findLesson.getId())) { throw new BusinessException(ErrorCode.INVALID_REVIEW_COUNT); } @@ -63,12 +61,12 @@ public ReviewCreateResponseDto createReview(ReviewCreateRequestDto reviewRequest .reviewer(findUser) .reviewee(lessonLeader) .lesson(findLesson) - .content(reviewRequestDto.getContent()) - .rating(reviewRequestDto.getRating()) - .image(reviewRequestDto.getReviewImage()) + .content(reviewRequestDto.content()) + .rating(reviewRequestDto.rating()) + .image(reviewRequestDto.reviewImage()) .build()); - profileMetadataService.increaseReviewCountAndRating(lessonLeader.getId(), reviewRequestDto.getRating()); + // 메타데이터 업데이트는 스케줄러에서 배치 처리 return ReviewMapper.toReviewResponseDto(newReview); } diff --git a/src/main/java/com/threestar/trainus/domain/test/controller/TestConcurrencyController.java b/src/main/java/com/threestar/trainus/domain/test/controller/TestConcurrencyController.java new file mode 100644 index 00000000..2fd627d6 --- /dev/null +++ b/src/main/java/com/threestar/trainus/domain/test/controller/TestConcurrencyController.java @@ -0,0 +1,51 @@ +package com.threestar.trainus.domain.test.controller; + +import com.threestar.trainus.domain.coupon.user.dto.CreateUserCouponResponseDto; +import com.threestar.trainus.domain.coupon.user.service.CouponService; +import com.threestar.trainus.domain.lesson.student.dto.LessonApplicationResponseDto; +import com.threestar.trainus.domain.lesson.student.service.StudentLessonService; +import com.threestar.trainus.domain.test.dto.TestRequestDto; +import com.threestar.trainus.domain.test.service.TestUserService; +import com.threestar.trainus.domain.user.entity.User; +import com.threestar.trainus.global.unit.BaseResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "동시성 테스트 API", description = "선착순 기능 테스트를 위한 API") +@RestController +@RequestMapping("/test") +@RequiredArgsConstructor +public class TestConcurrencyController { + + private final CouponService couponService; + private final StudentLessonService studentLessonService; + private final TestUserService testUserService; + + @PostMapping("/coupons/{couponId}") + @Operation(summary = "쿠폰 발급 동시성 테스트", description = "쿠폰을 발급받는 테스트 API") + public ResponseEntity> issueCouponForTest( + @PathVariable Long couponId, + @RequestBody TestRequestDto testRequestDto + ) { + User user = testUserService.findOrCreateUser(testRequestDto.getUserId()); + CreateUserCouponResponseDto responseDto = couponService.createUserCoupon(user.getId(), couponId); + return BaseResponse.ok("쿠폰 발급 완료", responseDto, HttpStatus.CREATED); + } + + @PostMapping("/lessons/{lessonId}/application") + @Operation(summary = "레슨 신청 동시성 테스트", description = "레슨을 신청하는 테스트 API") + public ResponseEntity> applyToLessonForTest( + @PathVariable Long lessonId, + @RequestBody TestRequestDto testRequestDto + ) { + User user = testUserService.findOrCreateUser(testRequestDto.getUserId()); + LessonApplicationResponseDto response = studentLessonService.applyToLessonWithLock(lessonId, user.getId()); + return BaseResponse.ok("레슨 신청 완료", response, HttpStatus.OK); + } +} diff --git a/src/main/java/com/threestar/trainus/domain/test/dto/TestRequestDto.java b/src/main/java/com/threestar/trainus/domain/test/dto/TestRequestDto.java new file mode 100644 index 00000000..99b24d87 --- /dev/null +++ b/src/main/java/com/threestar/trainus/domain/test/dto/TestRequestDto.java @@ -0,0 +1,10 @@ +package com.threestar.trainus.domain.test.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class TestRequestDto { + private Long userId; +} diff --git a/src/main/java/com/threestar/trainus/domain/test/service/TestUserService.java b/src/main/java/com/threestar/trainus/domain/test/service/TestUserService.java new file mode 100644 index 00000000..ffee861c --- /dev/null +++ b/src/main/java/com/threestar/trainus/domain/test/service/TestUserService.java @@ -0,0 +1,51 @@ +package com.threestar.trainus.domain.test.service; + +import com.threestar.trainus.domain.profile.service.ProfileFacadeService; +import com.threestar.trainus.domain.user.entity.User; +import com.threestar.trainus.domain.user.entity.UserRole; +import com.threestar.trainus.domain.user.repository.UserRepository; + +import lombok.RequiredArgsConstructor; + +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class TestUserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final ProfileFacadeService profileFacadeService; + + @Transactional + public User findOrCreateUser(Long userId) { + Optional existingUser = userRepository.findById(userId); + if (existingUser.isPresent()) { + return existingUser.get(); + } + String email = "testuser" + userId + "@example.com"; + String nickname = "testuser" + userId; + + if (userRepository.existsByEmail(email) || userRepository.existsByNickname(nickname)) { + return userRepository.findByEmail(email) + .orElseThrow(() -> new IllegalStateException("테스트 유저 생성 실패")); + } + + String encodedPassword = passwordEncoder.encode("password"); + User newUser = User.builder() + .email(email) + .password(encodedPassword) + .nickname(nickname) + .role(UserRole.USER) + .build(); + + User savedUser = userRepository.save(newUser); + profileFacadeService.createDefaultProfile(savedUser); + + return savedUser; + } +} diff --git a/src/main/java/com/threestar/trainus/domain/user/controller/UserController.java b/src/main/java/com/threestar/trainus/domain/user/controller/UserController.java index d19bbfd4..01639515 100644 --- a/src/main/java/com/threestar/trainus/domain/user/controller/UserController.java +++ b/src/main/java/com/threestar/trainus/domain/user/controller/UserController.java @@ -2,6 +2,7 @@ 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.PostMapping; @@ -111,4 +112,14 @@ public ResponseEntity> getCurrentUser( UserInfoResponseDto response = userService.getCurrentUserInfo(loginUserId); return BaseResponse.ok("사용자 정보 조회가 완료되었습니다.", response, HttpStatus.OK); } + + @DeleteMapping("/withdraw") + @Operation(summary = "회원탈퇴 api", description = "회원탈퇴 처리 및 관련 데이터 정리") + public ResponseEntity> withdraw( + @LoginUser Long loginUserId, + HttpSession session + ) { + userService.withdraw(loginUserId, session); + return BaseResponse.okOnlyStatus(HttpStatus.NO_CONTENT); + } } diff --git a/src/main/java/com/threestar/trainus/domain/user/dto/LoginResponseDto.java b/src/main/java/com/threestar/trainus/domain/user/dto/LoginResponseDto.java index 2302c156..bd34597a 100644 --- a/src/main/java/com/threestar/trainus/domain/user/dto/LoginResponseDto.java +++ b/src/main/java/com/threestar/trainus/domain/user/dto/LoginResponseDto.java @@ -1,9 +1,11 @@ package com.threestar.trainus.domain.user.dto; -public record LoginResponseDto( +import com.threestar.trainus.domain.user.entity.UserRole; +public record LoginResponseDto( Long id, String email, - String nickname + String nickname, + UserRole role ) { } diff --git a/src/main/java/com/threestar/trainus/domain/user/dto/UserInfoResponseDto.java b/src/main/java/com/threestar/trainus/domain/user/dto/UserInfoResponseDto.java index bdb861f5..30e02ccc 100644 --- a/src/main/java/com/threestar/trainus/domain/user/dto/UserInfoResponseDto.java +++ b/src/main/java/com/threestar/trainus/domain/user/dto/UserInfoResponseDto.java @@ -1,7 +1,11 @@ package com.threestar.trainus.domain.user.dto; +import com.threestar.trainus.domain.user.entity.UserRole; + public record UserInfoResponseDto( Long userId, - String nickname + String nickname, + String email, + UserRole role ) { } diff --git a/src/main/java/com/threestar/trainus/domain/user/entity/User.java b/src/main/java/com/threestar/trainus/domain/user/entity/User.java index 20b1e0fd..1db5ef72 100644 --- a/src/main/java/com/threestar/trainus/domain/user/entity/User.java +++ b/src/main/java/com/threestar/trainus/domain/user/entity/User.java @@ -4,6 +4,8 @@ import java.util.ArrayList; import java.util.List; +import org.hibernate.annotations.SQLRestriction; + import com.threestar.trainus.domain.coupon.user.entity.UserCoupon; import com.threestar.trainus.domain.metadata.entity.ProfileMetadata; import com.threestar.trainus.domain.profile.entity.Profile; @@ -32,6 +34,7 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @Builder @AllArgsConstructor +@SQLRestriction("deleted_at IS NULL") //삭제되지 않은 데이터만 기본적으로 조회하는 필터 public class User extends BaseDateEntity { @Id @@ -65,4 +68,13 @@ public class User extends BaseDateEntity { public void updatePassword(String newPassword) { this.password = newPassword; } + + public void withdraw() { + this.deletedAt = LocalDateTime.now(); + } + + public void anonymizePersonalData(String anonymizedNickname) { + this.nickname = anonymizedNickname; + } + } diff --git a/src/main/java/com/threestar/trainus/domain/user/mapper/UserMapper.java b/src/main/java/com/threestar/trainus/domain/user/mapper/UserMapper.java index 531812da..1561b853 100644 --- a/src/main/java/com/threestar/trainus/domain/user/mapper/UserMapper.java +++ b/src/main/java/com/threestar/trainus/domain/user/mapper/UserMapper.java @@ -36,14 +36,17 @@ public static LoginResponseDto toLoginResponseDto(User user) { return new LoginResponseDto( user.getId(), user.getEmail(), - user.getNickname() + user.getNickname(), + user.getRole() ); } public static UserInfoResponseDto toUserInfoResponseDto(User user) { return new UserInfoResponseDto( user.getId(), - user.getNickname() + user.getNickname(), + user.getEmail(), + user.getRole() ); } } diff --git a/src/main/java/com/threestar/trainus/domain/user/repository/UserRepository.java b/src/main/java/com/threestar/trainus/domain/user/repository/UserRepository.java index 530e20cf..0acbdbc2 100644 --- a/src/main/java/com/threestar/trainus/domain/user/repository/UserRepository.java +++ b/src/main/java/com/threestar/trainus/domain/user/repository/UserRepository.java @@ -1,10 +1,12 @@ package com.threestar.trainus.domain.user.repository; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import com.threestar.trainus.domain.user.entity.User; +import com.threestar.trainus.domain.user.entity.UserRole; public interface UserRepository extends JpaRepository { @@ -14,9 +16,6 @@ public interface UserRepository extends JpaRepository { Optional findByEmail(String email); - // 닉네임으로 사용자 찾기 - Optional findByNickname(String nickname); - - // 삭제되지 않은 사용자만 조회 - Optional findByIdAndDeletedAtIsNull(Long id); + List findByRole(UserRole role); } + diff --git a/src/main/java/com/threestar/trainus/domain/user/service/UserService.java b/src/main/java/com/threestar/trainus/domain/user/service/UserService.java index fdcde1f0..a309ebc7 100644 --- a/src/main/java/com/threestar/trainus/domain/user/service/UserService.java +++ b/src/main/java/com/threestar/trainus/domain/user/service/UserService.java @@ -1,6 +1,8 @@ package com.threestar.trainus.domain.user.service; +import java.time.LocalDateTime; import java.util.Collections; +import java.util.List; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; @@ -8,6 +10,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.threestar.trainus.domain.lesson.teacher.entity.Lesson; +import com.threestar.trainus.domain.lesson.teacher.entity.LessonApplication; +import com.threestar.trainus.domain.lesson.teacher.entity.LessonStatus; +import com.threestar.trainus.domain.lesson.teacher.repository.LessonApplicationRepository; +import com.threestar.trainus.domain.lesson.teacher.repository.LessonRepository; import com.threestar.trainus.domain.profile.service.ProfileFacadeService; import com.threestar.trainus.domain.user.dto.LoginRequestDto; import com.threestar.trainus.domain.user.dto.LoginResponseDto; @@ -24,7 +31,9 @@ import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Service @RequiredArgsConstructor public class UserService { @@ -33,6 +42,8 @@ public class UserService { private final PasswordEncoder passwordEncoder; private final ProfileFacadeService facadeService; private final EmailVerificationService emailVerificationService; + private final LessonRepository lessonRepository; + private final LessonApplicationRepository lessonApplicationRepository; @Transactional public SignupResponseDto signup(SignupRequestDto request) { @@ -107,7 +118,7 @@ public void validateUserExists(Long userId) { public void validateAdminRole(Long userId) { User user = getUserById(userId); if (user.getRole() != UserRole.ADMIN) { - throw new BusinessException(ErrorCode.AUTHENTICATION_REQUIRED); + throw new BusinessException(ErrorCode.ACCESS_FORBIDDEN); } } @@ -116,7 +127,7 @@ public void validateAdminRole(Long userId) { public User getAdminUser(Long userId) { User user = getUserById(userId); if (user.getRole() != UserRole.ADMIN) { - throw new BusinessException(ErrorCode.AUTHENTICATION_REQUIRED); + throw new BusinessException(ErrorCode.ACCESS_FORBIDDEN); } return user; } @@ -140,6 +151,66 @@ public void updatePassword(PasswordUpdateDto request, Long userId) { userRepository.save(user); } + @Transactional + public void withdraw(Long userId, HttpSession session) { + User user = getUserById(userId); + + //강사는 탈퇴 불가 (레슨 있을 시) + validateInstructorCanWithdraw(user); + + //사용자의 활성 레슨 신청이 있으면 탈퇴 불가 + validateUserHasNoActiveApplications(user); + + //개인정보 비식별화 + String anonymizedNickname = "탈퇴한사용자" + user.getId(); + + user.anonymizePersonalData( anonymizedNickname); + + user.withdraw(); + userRepository.save(user); + + invalidateUserSession(session); + } + + private void validateInstructorCanWithdraw(User user) { + List instructorLessons = lessonRepository.findByLessonLeaderAndDeletedAtIsNull(user.getId()); + + if (!instructorLessons.isEmpty()) { + throw new BusinessException(ErrorCode.INSTRUCTOR_HAS_LESSONS); + } + } + + private void validateUserHasNoActiveApplications(User user) { + List applications = lessonApplicationRepository.findByUserId(user.getId()); + + if (applications.isEmpty()) { + return; + } + + LocalDateTime now = LocalDateTime.now(); + long activeApplicationsCount = applications.stream() + .filter(application -> application.getLesson().getStartAt().isAfter(now)) + .count(); + + if (activeApplicationsCount > 0) { + throw new BusinessException(ErrorCode.USER_HAS_ACTIVE_APPLICATIONS); + } + } + + private void invalidateUserSession(HttpSession session) { + try { + // 세션 무효화 + if (session != null) { + session.invalidate(); + } + + // Spring Security 컨텍스트 정리 + SecurityContextHolder.clearContext(); + } catch (Exception e) { + // 세션 무효화 실패해도 탈퇴는 진행 + } + } + @Transactional(readOnly = true) public UserInfoResponseDto getCurrentUserInfo(Long userId) { User user = getUserById(userId); diff --git a/src/main/java/com/threestar/trainus/global/annotation/RedissonLock.java b/src/main/java/com/threestar/trainus/global/annotation/RedissonLock.java new file mode 100644 index 00000000..6ca33486 --- /dev/null +++ b/src/main/java/com/threestar/trainus/global/annotation/RedissonLock.java @@ -0,0 +1,19 @@ +package com.threestar.trainus.global.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface RedissonLock { + + String value(); //Lock 이름 + + long waitTime() default 5000L; //Lock을 획득을 시도하는 최대 시간 ms + + long leaseTime() default 2000L; //락을 획득한 후, 점유하는 최대 시간 ms +} diff --git a/src/main/java/com/threestar/trainus/global/config/CorsConfig.java b/src/main/java/com/threestar/trainus/global/config/CorsConfig.java index 806d7c6f..3ecd5403 100644 --- a/src/main/java/com/threestar/trainus/global/config/CorsConfig.java +++ b/src/main/java/com/threestar/trainus/global/config/CorsConfig.java @@ -12,9 +12,9 @@ public void addCorsMappings(CorsRegistry registry) { .allowedOrigins("http://localhost:3000", "http://localhost:8080", "http://localhost:8031", - "http://43.202.206.47:8080", - "http://43.202.206.47:3000", - "http://43.202.206.47:8031") + "http://15.165.184.145:8080", + "http://15.165.184.145:3000", + "http://15.165.184.145:8031") .allowCredentials(true) // 쿠키 허용 .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") .allowedHeaders("*"); diff --git a/src/main/java/com/threestar/trainus/global/config/JpaAuditingConfig.java b/src/main/java/com/threestar/trainus/global/config/JpaAuditingConfig.java new file mode 100644 index 00000000..0d8c3b88 --- /dev/null +++ b/src/main/java/com/threestar/trainus/global/config/JpaAuditingConfig.java @@ -0,0 +1,9 @@ +package com.threestar.trainus.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@EnableJpaAuditing +@Configuration +public class JpaAuditingConfig { +} \ No newline at end of file diff --git a/src/main/java/com/threestar/trainus/global/config/MockDataInitializer.java b/src/main/java/com/threestar/trainus/global/config/MockDataInitializer.java index bf343a8e..5d0604c0 100644 --- a/src/main/java/com/threestar/trainus/global/config/MockDataInitializer.java +++ b/src/main/java/com/threestar/trainus/global/config/MockDataInitializer.java @@ -116,11 +116,11 @@ private List createInstructors() { profile.updateProfileIntro(instructorNames[i] + "입니다. 최고의 레슨을 제공합니다!"); profileRepository.save(profile); - // ProfileMetadata 생성 (랭킹용 데이터) + // ProfileMetadata 초기값으로 생성 (스케줄러 테스트용) ProfileMetadata metadata = ProfileMetadata.builder() .user(savedInstructor) - .reviewCount(generateReviewCount()) - .rating(generateRating()) + .reviewCount(0) + .rating(0.0) .build(); profileMetadataRepository.save(metadata); @@ -217,9 +217,6 @@ private List createLessons(List instructors) { private void createReviews(List instructors, List students, List lessons) { for (User instructor : instructors) { - ProfileMetadata metadata = profileMetadataRepository.findByUserId(instructor.getId()) - .orElseThrow(); - // 해당 강사의 레슨들 찾기 List instructorLessons = lessons.stream() .filter(lesson -> lesson.getLessonLeader().equals(instructor.getId())) @@ -229,17 +226,16 @@ private void createReviews(List instructors, List students, List auth .requestMatchers("/api/v1/users/**", "/api/lessons/test-auth", "/swagger-ui/**", "/v3/api-docs/**", - "/api/v1/profiles/**", "/api/v1/lessons/**", "/api/v1/comments/**", "/api/v1/reviews/**" - , "/api/v1/rankings/**", "/api/v1/payments/**") + "/api/v1/profiles/**", "/api/v1/lessons/**", "/api/v1/comments/**", "/api/v1/reviews/**", + "/api/v1/rankings/**", "/api/v1/payments/**", "/test/**") .permitAll() + .requestMatchers("/api/v1/admin/**") + .hasRole("ADMIN") .anyRequest() .authenticated() ) diff --git a/src/main/java/com/threestar/trainus/global/config/security/SessionAuthenticationFilter.java b/src/main/java/com/threestar/trainus/global/config/security/SessionAuthenticationFilter.java index 4dd372d5..d6c7524b 100644 --- a/src/main/java/com/threestar/trainus/global/config/security/SessionAuthenticationFilter.java +++ b/src/main/java/com/threestar/trainus/global/config/security/SessionAuthenticationFilter.java @@ -2,21 +2,35 @@ import java.io.IOException; import java.util.Collections; +import java.util.List; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; +import com.threestar.trainus.domain.user.entity.User; +import com.threestar.trainus.domain.user.repository.UserRepository; + +import lombok.extern.slf4j.Slf4j; + import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; +@Slf4j @Component public class SessionAuthenticationFilter extends OncePerRequestFilter { + private final UserRepository userRepository; + + public SessionAuthenticationFilter(UserRepository userRepository) { + this.userRepository = userRepository; + } + @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { @@ -27,10 +41,21 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse Long userId = (Long)session.getAttribute("LOGIN_USER"); if (userId != null && SecurityContextHolder.getContext().getAuthentication() == null) { - UsernamePasswordAuthenticationToken authToken = - new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList()); + // 사용자 정보 조회하여 권한 설정 + User user = userRepository.findById(userId).orElse(null); + if (user != null) { + List authorities = List.of( + new SimpleGrantedAuthority("ROLE_" + user.getRole().name()) + ); + + log.info("사용자 인증 설정: userId={}, role={}, authorities={}", + userId, user.getRole(), authorities); + + UsernamePasswordAuthenticationToken authToken = + new UsernamePasswordAuthenticationToken(userId, null, authorities); - SecurityContextHolder.getContext().setAuthentication(authToken); + SecurityContextHolder.getContext().setAuthentication(authToken); + } } } diff --git a/src/main/java/com/threestar/trainus/global/dto/PageRequestDto.java b/src/main/java/com/threestar/trainus/global/dto/PageRequestDto.java index 28a715fb..c82193e0 100644 --- a/src/main/java/com/threestar/trainus/global/dto/PageRequestDto.java +++ b/src/main/java/com/threestar/trainus/global/dto/PageRequestDto.java @@ -4,8 +4,10 @@ import jakarta.validation.constraints.Min; import lombok.Getter; import lombok.RequiredArgsConstructor; +import lombok.Setter; @Getter +@Setter // @ModelAttribute 바인딩을 위한 setter, 외부노출X @RequiredArgsConstructor public class PageRequestDto { diff --git a/src/main/java/com/threestar/trainus/global/exception/domain/ErrorCode.java b/src/main/java/com/threestar/trainus/global/exception/domain/ErrorCode.java index 8cda2818..d225ab20 100644 --- a/src/main/java/com/threestar/trainus/global/exception/domain/ErrorCode.java +++ b/src/main/java/com/threestar/trainus/global/exception/domain/ErrorCode.java @@ -18,6 +18,9 @@ public enum ErrorCode { // 401 AUTHENTICATION_REQUIRED(HttpStatus.UNAUTHORIZED, "인증이 필요한 요청입니다. 로그인 해주세요."), + //403 + ACCESS_FORBIDDEN(HttpStatus.FORBIDDEN, "권한이 없습니다."), + // 404 LESSON_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 레슨을 찾을 수 없습니다."), USER_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 사용자를 찾을 수 없습니다."), @@ -33,7 +36,8 @@ public enum ErrorCode { COUPON_EXPIRED(HttpStatus.BAD_REQUEST, "쿠폰 발급 기간이 종료되었습니다."), COUPON_NOT_YET_OPEN(HttpStatus.BAD_REQUEST, "아직 발급이 시작되지 않은 쿠폰입니다."), COUPON_BE_EXHAUSTED(HttpStatus.BAD_REQUEST, "수량이 소진된 쿠폰입니다."), - + COUPON_CANNOT_DELETE_ISSUED(HttpStatus.BAD_REQUEST, "발급된 쿠폰이 있어 삭제할 수 없습니다. 상태를 비활성화로 변경해주세요."), + COUPON_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "이미 삭제된 쿠폰입니다."), //404 COUPON_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 쿠폰을 찾을 수 없습니다."), @@ -67,9 +71,14 @@ public enum ErrorCode { LESSON_CREATOR_CANNOT_APPLY(HttpStatus.BAD_REQUEST, "레슨 개설자는 자신이 개설한 레슨에 참여 신청할 수 없습니다."), CANNOT_CANCEL_APPROVED_APPLICATION(HttpStatus.BAD_REQUEST, "승인된 신청은 취소할 수 없습니다."), INVALID_CATEGORY(HttpStatus.BAD_REQUEST, "잘못된 카테고리입니다."), + INVALID_SORT(HttpStatus.BAD_REQUEST, "잘못된 정렬입니다."), LESSON_NOT_EDITABLE(HttpStatus.BAD_REQUEST, "수정할 수 없는 상태의 레슨입니다. 모집중 상태의 레슨만 수정 가능합니다."), LESSON_MAX_PARTICIPANTS_CANNOT_DECREASE(HttpStatus.BAD_REQUEST, "참가자가 있는 레슨은 최대 참가 인원을 줄일 수 없습니다."), LESSON_PARTICIPANTS_EXIST_RESTRICTION(HttpStatus.BAD_REQUEST, "참가자가 있어 해당 필드는 수정할 수 없습니다."), + LESSON_DELETE_STATUS_INVALID(HttpStatus.BAD_REQUEST, "모집중인 레슨만 삭제할 수 있습니다."), + LESSON_DELETE_HAS_PARTICIPANTS(HttpStatus.BAD_REQUEST, "참가자가 있는 레슨은 삭제할 수 없습니다."), + LESSON_TIME_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST, "레슨 시작 12시간 전이므로 수정/삭제할 수 없습니다."), + LESSON_CREATION_TOO_FREQUENT(HttpStatus.TOO_MANY_REQUESTS, "레슨 생성은 1분에 1번만 가능합니다. 잠시 후 다시 시도해주세요."), // 403 Forbidden LESSON_DELETE_FORBIDDEN(HttpStatus.FORBIDDEN, "레슨 삭제 권한이 없습니다. 강사만 삭제할 수 있습니다."), @@ -83,6 +92,7 @@ public enum ErrorCode { DUPLICATE_LESSON(HttpStatus.CONFLICT, "동일한 이름과 시간으로 이미 생성된 레슨이 있습니다."), LESSON_TIME_OVERLAP(HttpStatus.CONFLICT, "해당 시간대에 이미 다른 레슨이 예정되어 있습니다."), LESSON_NOT_AVAILABLE(HttpStatus.BAD_REQUEST, "신청 불가능한 상태의 레슨입니다."), + LESSON_NOT_YET_OPEN(HttpStatus.BAD_REQUEST, "아직 신청 가능한 시간이 아닙니다."), // 409 ALREADY_APPLIED(HttpStatus.CONFLICT, "이미 신청한 레슨입니다."), @@ -112,6 +122,10 @@ public enum ErrorCode { EMAIL_SEND_FAILED(HttpStatus.BAD_REQUEST, "이메일 발송을 실패했습니다."), EMAIL_SEND_TOO_FREQUENT(HttpStatus.TOO_MANY_REQUESTS, "이메일 발송은 1분에 한 번만 가능합니다."), + + INSTRUCTOR_HAS_LESSONS(HttpStatus.BAD_REQUEST, "등록된 레슨이 있는 강사는 탈퇴할 수 없습니다. 먼저 모든 레슨을 삭제해주세요."), + + USER_HAS_ACTIVE_APPLICATIONS(HttpStatus.BAD_REQUEST, "참여 중인 레슨이 있어 탈퇴할 수 없습니다."), /* * Profile : 프로필 관련 예외처리 */ diff --git a/src/main/java/com/threestar/trainus/global/utils/CustomSpringELParser.java b/src/main/java/com/threestar/trainus/global/utils/CustomSpringELParser.java new file mode 100644 index 00000000..e45fabec --- /dev/null +++ b/src/main/java/com/threestar/trainus/global/utils/CustomSpringELParser.java @@ -0,0 +1,19 @@ +package com.threestar.trainus.global.utils; + +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; + +public class CustomSpringELParser { + + public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) { + SpelExpressionParser parser = new SpelExpressionParser(); + StandardEvaluationContext context = new StandardEvaluationContext(); + + for (int i = 0; i < parameterNames.length; i++) { + context.setVariable(parameterNames[i], args[i]); + } + + return parser.parseExpression(key).getValue(context, Object.class); + } + +} diff --git a/src/main/java/com/threestar/trainus/global/utils/RedssionLockAspect.java b/src/main/java/com/threestar/trainus/global/utils/RedssionLockAspect.java new file mode 100644 index 00000000..affb7db2 --- /dev/null +++ b/src/main/java/com/threestar/trainus/global/utils/RedssionLockAspect.java @@ -0,0 +1,59 @@ +package com.threestar.trainus.global.utils; + +import java.lang.reflect.Method; +import java.util.concurrent.TimeUnit; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import com.threestar.trainus.global.annotation.RedissonLock; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Aspect +@Order(1) +@Component +@RequiredArgsConstructor +public class RedssionLockAspect { + + private final RedissonClient redissonClient; + + @Around("@annotation(com.threestar.trainus.global.annotation.RedissonLock)") + public Object redissonLock(ProceedingJoinPoint joinPoint) throws Throwable { + MethodSignature signature = (MethodSignature)joinPoint.getSignature(); + Method method = signature.getMethod(); + RedissonLock annotation = method.getAnnotation(RedissonLock.class); + String lockKey = + method.getName() + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), + joinPoint.getArgs(), annotation.value()); + + RLock lock = redissonClient.getFairLock(lockKey); + + try { + boolean lockable = lock.tryLock(annotation.waitTime(), annotation.leaseTime(), TimeUnit.MILLISECONDS); + if (!lockable) { + log.info("Lock 획득 실패={}", lockKey); + return null; + } + log.info("로직 수행"); + return joinPoint.proceed(); + } catch (InterruptedException e) { + log.info("에러 발생"); + throw e; + } finally { + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + log.info("락 해제 완료={}", lockKey); + } + } + + } +} diff --git a/src/main/java/com/threestar/trainus/global/utils/RefundPolicyUtils.java b/src/main/java/com/threestar/trainus/global/utils/RefundPolicyUtils.java new file mode 100644 index 00000000..c5b71343 --- /dev/null +++ b/src/main/java/com/threestar/trainus/global/utils/RefundPolicyUtils.java @@ -0,0 +1,21 @@ +package com.threestar.trainus.global.utils; + +import java.time.LocalDateTime; + +import com.threestar.trainus.domain.payment.entity.Payment; + +public class RefundPolicyUtils { + public static int getRefundPrice(LocalDateTime cancelTime, Payment payment) { + int refundPrice = 0; + if (cancelTime.isBefore(payment.getLesson().getStartAt().minusMonths(1))) { + refundPrice = payment.getPayPrice(); + } else if (cancelTime.isBefore(payment.getLesson().getStartAt().minusWeeks(1))) { + refundPrice = (payment.getPayPrice() * 50 / 100); + } else if (cancelTime.isBefore(payment.getLesson().getStartAt().minusDays(3))) { + refundPrice = (payment.getPayPrice() * 30 / 100); + } else { + refundPrice = (payment.getPayPrice() * 30 / 100); + } + return refundPrice; + } +} diff --git a/src/test/java/com/threestar/trainus/coupon/user/UserCouponControllerTests.java b/src/test/java/com/threestar/trainus/coupon/user/UserCouponControllerTests.java new file mode 100644 index 00000000..fcaeac41 --- /dev/null +++ b/src/test/java/com/threestar/trainus/coupon/user/UserCouponControllerTests.java @@ -0,0 +1,174 @@ +package com.threestar.trainus.coupon.user; + +import static org.mockito.BDDMockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.threestar.trainus.domain.coupon.user.controller.CouponController; +import com.threestar.trainus.domain.coupon.user.dto.CouponPageResponseDto; +import com.threestar.trainus.domain.coupon.user.dto.CouponResponseDto; +import com.threestar.trainus.domain.coupon.user.dto.CreateUserCouponResponseDto; +import com.threestar.trainus.domain.coupon.user.dto.UserCouponPageResponseDto; +import com.threestar.trainus.domain.coupon.user.dto.UserCouponResponseDto; +import com.threestar.trainus.domain.coupon.user.entity.CouponCategory; +import com.threestar.trainus.domain.coupon.user.entity.CouponStatus; +import com.threestar.trainus.domain.coupon.user.entity.OwnedStatus; +import com.threestar.trainus.domain.coupon.user.service.CouponService; +import com.threestar.trainus.global.config.security.SessionAuthenticationFilter; +import com.threestar.trainus.global.resolver.LoginUserArgumentResolver; + +@WebMvcTest(controllers = CouponController.class, excludeFilters = { + @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = SessionAuthenticationFilter.class) +}) +@AutoConfigureMockMvc(addFilters = false) //시큐리티 인증 생략 +public class UserCouponControllerTests { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private CouponService couponService; + + @MockitoBean + private LoginUserArgumentResolver loginUserArgumentResolver; + + private static final Long userId = 1L; + + @BeforeEach + void setUp() throws Exception { + given(loginUserArgumentResolver.supportsParameter((any()))) + .willReturn(true); + given(loginUserArgumentResolver.resolveArgument(any(), any(), any(), any())) + .willReturn(userId); + } + + @Test + @DisplayName("쿠폰 발급 API 테스트") + void createUserCoupon() throws Exception { + Long couponId = 100L; + CreateUserCouponResponseDto responseDto = new CreateUserCouponResponseDto( + couponId, + userId, + LocalDateTime.now(), + LocalDateTime.now().plusDays(30), + CouponStatus.ACTIVE + ); + + given(couponService.createUserCoupon(userId, couponId)) + .willReturn(responseDto); + + mockMvc.perform(post("/api/v1/coupons/{couponId}", couponId) + .with(csrf())) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.message").value("쿠폰 발급 완료")) + .andExpect(jsonPath("$.data.couponId").value(couponId)) + .andExpect(jsonPath("$.data.status").value(CouponStatus.ACTIVE.toString())); + } + + @Test + @DisplayName("내 쿠폰 목록 조회 API 테스트") + void getUserCoupons() throws Exception { + List userCouponList = List.of( + new UserCouponResponseDto( + 100L, + "신규 회원 할인 쿠폰", + "5000", + 30000, + LocalDateTime.now().plusDays(30), + CouponStatus.ACTIVE, + null + ), + new UserCouponResponseDto( + 101L, + "VIP 회원 할인 쿠폰", + "10000", + 50000, + LocalDateTime.now().plusDays(15), + CouponStatus.ACTIVE, + LocalDateTime.now().minusDays(1) + ), + new UserCouponResponseDto( + 102L, + "기간 만료 쿠폰", + "3000", + 20000, + LocalDateTime.now().minusDays(5), + CouponStatus.INACTIVE, + null + ) + ); + UserCouponPageResponseDto dto = new UserCouponPageResponseDto(userCouponList); + + given(couponService.getUserCoupons(userId, null)) + .willReturn(dto); + + mockMvc.perform(get("/api/v1/coupons/my-coupons")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("사용자 보유 쿠폰 조회 성공")) + .andExpect(jsonPath("$.data.userCoupons[0].couponId").value(100L)) + .andExpect(jsonPath("$.data.userCoupons[1].couponId").value(101L)) + .andExpect(jsonPath("$.data.userCoupons[2].couponId").value(102L)); + + } + + @Test + @DisplayName("발급 가능 쿠폰 목록 조회 API 테스트") + void getCoupons() throws Exception { + Long couponId = 100L; + + CouponPageResponseDto dto = new CouponPageResponseDto( + List.of( + new CouponResponseDto( + 100L, + "신규 회원 할인 쿠폰", + "5000", + 30000, + LocalDateTime.now().plusDays(30), + OwnedStatus.NOT_OWNED, + 100, + CouponCategory.OPEN_RUN, + LocalDateTime.now() + ), + new CouponResponseDto( + 101L, + "VIP 회원 할인 쿠폰", + "10000", + 50000, + LocalDateTime.now().plusDays(10), + OwnedStatus.OWNED, + 50, + CouponCategory.OPEN_RUN, + LocalDateTime.now().minusDays(1) + ) + ) + ); + + given(couponService.getCoupons(userId)) + .willReturn(dto); + + mockMvc.perform(get("/api/v1/coupons")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("발급가능한 쿠폰 조회 성공")) + .andExpect(jsonPath("$.data.coupons[0].couponId").value(couponId)); + + } +} diff --git a/src/test/java/com/threestar/trainus/coupon/CouponServiceTests.java b/src/test/java/com/threestar/trainus/coupon/user/UserCouponServiceConcurrencyTests.java similarity index 65% rename from src/test/java/com/threestar/trainus/coupon/CouponServiceTests.java rename to src/test/java/com/threestar/trainus/coupon/user/UserCouponServiceConcurrencyTests.java index b894817a..fd78e892 100644 --- a/src/test/java/com/threestar/trainus/coupon/CouponServiceTests.java +++ b/src/test/java/com/threestar/trainus/coupon/user/UserCouponServiceConcurrencyTests.java @@ -1,4 +1,4 @@ -package com.threestar.trainus.coupon; +package com.threestar.trainus.coupon.user; import static org.assertj.core.api.AssertionsForClassTypes.*; @@ -8,16 +8,16 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.TestPropertySource; +import org.springframework.util.StopWatch; import com.threestar.trainus.domain.coupon.user.entity.Coupon; import com.threestar.trainus.domain.coupon.user.entity.CouponCategory; @@ -30,20 +30,12 @@ import com.threestar.trainus.domain.user.repository.UserRepository; import com.threestar.trainus.global.exception.handler.BusinessException; +import lombok.extern.slf4j.Slf4j; + +@Slf4j @SpringBootTest -@TestPropertySource(properties = { - "logging.level.root=ERROR", - "logging.level.com.threestar.trainus.coupon=INFO", - "logging.level.org.springframework=ERROR", - "logging.level.org.hibernate=ERROR", - "logging.level.com.zaxxer.hikari=ERROR", - "spring.jpa.show-sql=false", - "logging.level.org.hibernate.SQL=ERROR", - "logging.level.org.hibernate.type.descriptor.sql=ERROR" -}) -class CouponServiceTests { - - private static final Logger log = LoggerFactory.getLogger(CouponServiceTests.class); +class UserCouponServiceConcurrencyTests { + @Autowired CouponService couponService; @@ -56,35 +48,32 @@ class CouponServiceTests { @Autowired UserCouponRepository userCouponRepository; + private static final int COUPON_QUANTITY = 300; + private static final int CONCURRENT_USERS = 3000; List users; Coupon coupon; @BeforeEach void setUp() { - // 기존 데이터 정리 - userCouponRepository.deleteAll(); - couponRepository.deleteAll(); - userRepository.deleteAll(); - - // 1000명의 유저 생성 users = new ArrayList<>(); - for (int i = 0; i < 10000; i++) { + for (int i = 0; i < CONCURRENT_USERS; i++) { User user = userRepository.save( User.builder() .email("user" + i + "@test.com") - .password("test1234") + .password("12341234") .nickname("user" + i) .role(UserRole.USER) .build() ); users.add(user); + userRepository.flush(); } - // 재고 100장의 쿠폰 생성 + // 쿠폰 생성 coupon = couponRepository.save( Coupon.builder() .name("선착순 쿠폰") - .quantity(100) + .quantity(COUPON_QUANTITY) .category(CouponCategory.OPEN_RUN) .status(CouponStatus.ACTIVE) .discountPrice("1000") @@ -94,12 +83,6 @@ void setUp() { .closeAt(LocalDateTime.now().plusDays(1)) .build() ); - - // 데이터가 실제로 저장되었는지 확인 - System.out.println("사용자 수: " + userRepository.count()); - System.out.println("쿠폰 수: " + couponRepository.count()); - System.out.println("쿠폰 ID: " + coupon.getId()); - System.out.println("첫 번째 사용자 ID: " + users.get(0).getId()); } @AfterEach @@ -111,20 +94,30 @@ void tearDown() { } @Test - @DisplayName("1000명의 유저가 동시 요청해도 100장만 발급된다") + @DisplayName("동시 요청 시 - 비관적 락 적용: 발급된 쿠폰 수량만큼 발급") void 쿠폰_동시_발급_테스트() throws InterruptedException { - int threadCount = 10000; - ExecutorService executorService = Executors.newFixedThreadPool(32); - CountDownLatch latch = new CountDownLatch(threadCount); + ExecutorService executor = Executors.newFixedThreadPool(100); + CountDownLatch latch = new CountDownLatch(CONCURRENT_USERS); - for (int i = 0; i < threadCount; i++) { + AtomicInteger successCount = new AtomicInteger(); + AtomicInteger failCount = new AtomicInteger(); + + StopWatch stopWatch = new StopWatch(); + stopWatch.start(); + + for (int i = 0; i < CONCURRENT_USERS; i++) { final int idx = i; - executorService.submit(() -> { + executor.submit(() -> { try { + couponService.createUserCoupon(users.get(idx).getId(), coupon.getId()); - } catch (BusinessException e) { + log.info("발급 성공 - id: {} | 발급된 쿠폰 수량: {}", idx, + userCouponRepository.countByCouponId(coupon.getId())); + successCount.incrementAndGet(); } catch (Exception e) { + failCount.incrementAndGet(); + log.error("발급 실패 - id: {} | message: {}", idx, e.getMessage()); } finally { latch.countDown(); } @@ -132,15 +125,21 @@ void tearDown() { } latch.await(); - executorService.shutdown(); + stopWatch.stop(); + executor.shutdown(); + + long issuedCount = userCouponRepository.countByCouponId(coupon.getId()); + int remainingQuantity = couponRepository.findById(coupon.getId()).orElseThrow().getQuantity(); + + log.info("총 소요 시간(ms): {}", stopWatch.getTotalTimeMillis()); + log.info("요청 총 수: {}", CONCURRENT_USERS); + log.info("성공 요청 수: {}", successCount.get()); + log.info("실패 요청 수: {}", failCount.get()); + log.info("DB 기준 발급 수 (userCoupon): {}", issuedCount); + log.info("남은 수량: {}", remainingQuantity); - Long issuedCount = userCouponRepository.countByCouponId(coupon.getId()); - Integer leftQuantity = couponRepository.findById(coupon.getId()).get().getQuantity(); - log.info("===== 테스트 결과 ====="); - log.info("총 발급된 쿠폰 수: {}", issuedCount); - log.info("쿠폰 남은 수량: {}", leftQuantity); - assertThat(issuedCount).isEqualTo(100L); - assertThat(leftQuantity).isEqualTo(0); + Assertions.assertEquals(COUPON_QUANTITY, issuedCount, "정확한 수량만큼 발급돼야 함"); + Assertions.assertEquals(successCount.get(), COUPON_QUANTITY, "성공 요청 수도 수량과 같아야 함"); } @Test @@ -182,4 +181,4 @@ void tearDown() { assertThat(issuedCount).isEqualTo(1L); } -} \ No newline at end of file +} diff --git a/src/test/java/com/threestar/trainus/coupon/user/UserCouponServiceRedissonLockTests.java b/src/test/java/com/threestar/trainus/coupon/user/UserCouponServiceRedissonLockTests.java new file mode 100644 index 00000000..1c5b33e5 --- /dev/null +++ b/src/test/java/com/threestar/trainus/coupon/user/UserCouponServiceRedissonLockTests.java @@ -0,0 +1,139 @@ +package com.threestar.trainus.coupon.user; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.util.StopWatch; + +import com.threestar.trainus.domain.coupon.user.entity.Coupon; +import com.threestar.trainus.domain.coupon.user.entity.CouponCategory; +import com.threestar.trainus.domain.coupon.user.entity.CouponStatus; +import com.threestar.trainus.domain.coupon.user.repository.CouponRepository; +import com.threestar.trainus.domain.coupon.user.repository.UserCouponRepository; +import com.threestar.trainus.domain.coupon.user.service.CouponService; +import com.threestar.trainus.domain.user.entity.User; +import com.threestar.trainus.domain.user.entity.UserRole; +import com.threestar.trainus.domain.user.repository.UserRepository; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@SpringBootTest +public class UserCouponServiceRedissonLockTests { + + @Autowired + CouponService couponService; + + @Autowired + CouponRepository couponRepository; + + @Autowired + UserRepository userRepository; + + @Autowired + UserCouponRepository userCouponRepository; + + private static final int COUPON_QUANTITY = 300; + private static final int CONCURRENT_USERS = 3000; + List users; + Coupon coupon; + + @BeforeEach + void setUp() { + users = new ArrayList<>(); + for (int i = 0; i < CONCURRENT_USERS; i++) { + User user = userRepository.save( + User.builder() + .email("user" + i + "@test.com") + .password("12341234") + .nickname("user" + i) + .role(UserRole.USER) + .build() + ); + users.add(user); + userRepository.flush(); + } + + // 쿠폰 생성 + coupon = couponRepository.save( + Coupon.builder() + .name("Redis 분산락 쿠폰") + .quantity(COUPON_QUANTITY) + .category(CouponCategory.OPEN_RUN) + .status(CouponStatus.ACTIVE) + .discountPrice("1000") + .minOrderPrice(20000) + .expirationDate(LocalDateTime.now().plusDays(1)) + .openAt(LocalDateTime.now().minusMinutes(10)) + .closeAt(LocalDateTime.now().plusDays(1)) + .build() + ); + } + + @AfterEach + void tearDown() { + userCouponRepository.deleteAll(); + couponRepository.deleteAll(); + userRepository.deleteAll(); + } + + @Test + @DisplayName("동시 요청 시 - Redis 분산락 적용: 발급된 쿠폰 수량만큼 발급") + void 쿠폰_동시_발급_테스트() throws InterruptedException { + ExecutorService executor = Executors.newFixedThreadPool(100); + CountDownLatch latch = new CountDownLatch(CONCURRENT_USERS); + + AtomicInteger successCount = new AtomicInteger(); + AtomicInteger failCount = new AtomicInteger(); + + StopWatch stopWatch = new StopWatch(); + stopWatch.start(); + + for (int i = 0; i < CONCURRENT_USERS; i++) { + final int idx = i; + + executor.submit(() -> { + try { + couponService.createUserCoupon(users.get(idx).getId(), coupon.getId()); + log.info("[성공] userId: {} | 현재 발급 수: {}", idx, + userCouponRepository.countByCouponId(coupon.getId())); + successCount.incrementAndGet(); + } catch (Exception e) { + failCount.incrementAndGet(); + log.error("[실패] userId: {} | message: {}", idx, e.getMessage()); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executor.shutdown(); + stopWatch.stop(); + + long issuedCount = userCouponRepository.countByCouponId(coupon.getId()); + int remainingQuantity = couponRepository.findById(coupon.getId()).orElseThrow().getQuantity(); + + log.info("총 소요 시간(ms): {}", stopWatch.getTotalTimeMillis()); + log.info("총 요청 수: {}", CONCURRENT_USERS); + log.info("성공 요청 수: {}", successCount.get()); + log.info("실패 요청 수: {}", failCount.get()); + log.info("DB 기준 발급 수(userCoupon): {}", issuedCount); + log.info("남은 수량: {}", remainingQuantity); + + Assertions.assertEquals(COUPON_QUANTITY, issuedCount, "정확한 수량만큼 발급돼야 함"); + Assertions.assertEquals(successCount.get(), COUPON_QUANTITY, "성공 요청 수도 수량과 같아야 함"); + } +} diff --git a/src/test/java/com/threestar/trainus/coupon/user/UserCouponServiceTests.java b/src/test/java/com/threestar/trainus/coupon/user/UserCouponServiceTests.java new file mode 100644 index 00000000..abda2d5e --- /dev/null +++ b/src/test/java/com/threestar/trainus/coupon/user/UserCouponServiceTests.java @@ -0,0 +1,280 @@ +package com.threestar.trainus.coupon.user; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.annotation.Import; + +import com.threestar.trainus.domain.coupon.user.dto.CouponPageResponseDto; +import com.threestar.trainus.domain.coupon.user.dto.CouponResponseDto; +import com.threestar.trainus.domain.coupon.user.dto.CreateUserCouponResponseDto; +import com.threestar.trainus.domain.coupon.user.dto.UserCouponPageResponseDto; +import com.threestar.trainus.domain.coupon.user.entity.Coupon; +import com.threestar.trainus.domain.coupon.user.entity.CouponCategory; +import com.threestar.trainus.domain.coupon.user.entity.CouponStatus; +import com.threestar.trainus.domain.coupon.user.entity.UserCoupon; +import com.threestar.trainus.domain.coupon.user.repository.CouponRepository; +import com.threestar.trainus.domain.coupon.user.repository.UserCouponRepository; +import com.threestar.trainus.domain.coupon.user.service.CouponService; +import com.threestar.trainus.domain.user.entity.User; +import com.threestar.trainus.domain.user.entity.UserRole; +import com.threestar.trainus.domain.user.service.UserService; +import com.threestar.trainus.global.config.JpaAuditingConfig; +import com.threestar.trainus.global.exception.domain.ErrorCode; +import com.threestar.trainus.global.exception.handler.BusinessException; + +@ExtendWith(MockitoExtension.class) +@Import(JpaAuditingConfig.class) +public class UserCouponServiceTests { + + @Mock + private UserCouponRepository userCouponRepository; + + @Mock + private CouponRepository couponRepository; + + @Mock + private UserService userService; + + @InjectMocks + private CouponService couponService; + + @Test + void createUserCoupon_정상_발급() { + Long userId = 1L; + Long couponId = 2L; + + User user = createMockUser(); + + Coupon coupon = Coupon.builder() + .id(couponId) + .category(CouponCategory.NORMAL) + .closeAt(LocalDateTime.now().plusDays(1)) + .expirationDate(LocalDateTime.now().plusDays(30)) + .openAt(LocalDateTime.now()) + .build(); + + given(userService.getUserById(userId)) + .willReturn(user); + given(couponRepository.findByIdWithPessimisticLock(couponId)) + .willReturn(Optional.of(coupon)); + given(userCouponRepository.existsByUserIdAndCouponId(userId, couponId)) + .willReturn(false); + + CreateUserCouponResponseDto responseDto = couponService.createUserCoupon(userId, couponId); + + assertThat(responseDto).isNotNull(); + assertThat(responseDto.couponId()).isEqualTo(couponId); + then(userCouponRepository).should().save(any(UserCoupon.class)); + + } + + @Test + void createUserCoupon_종료시각_지나면_예외처리() { + Long userId = 1L; + Long couponId = 2L; + + User user = createMockUser(); + + Coupon coupon = Coupon.builder() + .id(couponId) + .category(CouponCategory.NORMAL) + .closeAt(LocalDateTime.now().minusDays(1)) + .expirationDate(LocalDateTime.now().plusDays(30)) + .build(); + + given(userService.getUserById(userId)) + .willReturn(user); + given(couponRepository.findByIdWithPessimisticLock(couponId)) + .willReturn(Optional.of(coupon)); + + assertThatThrownBy(() -> couponService.createUserCoupon(userId, couponId)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(ErrorCode.COUPON_EXPIRED.getMessage()); + } + + @Test + void createUserCoupon_중복발급_예외처리() { + Long userId = 1L; + Long couponId = 2L; + + User user = createMockUser(); + + Coupon coupon = Coupon.builder() + .id(couponId) + .category(CouponCategory.NORMAL) + .closeAt(LocalDateTime.now().plusDays(1)) + .expirationDate(LocalDateTime.now().plusDays(30)) + .build(); + + given(userService.getUserById(userId)) + .willReturn(user); + given(couponRepository.findByIdWithPessimisticLock(couponId)) + .willReturn(Optional.of(coupon)); + given(userCouponRepository.existsByUserIdAndCouponId(userId, couponId)) + .willReturn(true); + + assertThatThrownBy(() -> couponService.createUserCoupon(userId, couponId)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(ErrorCode.COUPON_ALREADY_ISSUED.getMessage()); + + } + + @Test + void createUserCoupon_선착순쿠폰_오픈전_예외처리() { + Long userId = 1L; + Long couponId = 2L; + + User user = createMockUser(); + + Coupon coupon = Coupon.builder() + .id(couponId) + .openAt(LocalDateTime.now().plusDays(1)) + .category(CouponCategory.OPEN_RUN) + .closeAt(LocalDateTime.now().plusDays(1)) + .expirationDate(LocalDateTime.now().plusDays(30)) + .build(); + + given(userService.getUserById(userId)) + .willReturn(user); + given(couponRepository.findByIdWithPessimisticLock(couponId)) + .willReturn(Optional.of(coupon)); + + assertThatThrownBy(() -> couponService.createUserCoupon(userId, couponId)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(ErrorCode.COUPON_NOT_YET_OPEN.getMessage()); + } + + @Test + void createUserCoupon_수량소진_예외() { + Long userId = 1L; + Long couponId = 2L; + + User user = createMockUser(); + + Coupon coupon = Coupon.builder() + .id(couponId) + .quantity(0) + .openAt(LocalDateTime.now().minusDays(1)) + .category(CouponCategory.OPEN_RUN) + .closeAt(LocalDateTime.now().plusDays(1)) + .expirationDate(LocalDateTime.now().plusDays(30)) + .build(); + + given(userService.getUserById(userId)) + .willReturn(user); + given(couponRepository.findByIdWithPessimisticLock(couponId)) + .willReturn(Optional.of(coupon)); + assertThatThrownBy(() -> couponService.createUserCoupon(userId, couponId)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(ErrorCode.COUPON_BE_EXHAUSTED.getMessage()); + } + + @Test + void getUserCoupons_상태없는_성공케이스() { + Long userId = 1L; + User user = createMockUser(); + + Coupon coupon = Coupon.builder() + .id(1L) + .name("10% 할인 쿠폰") + .discountPrice("10%") + .minOrderPrice(10000) + .expirationDate(LocalDateTime.now().plusDays(7)) + .quantity(100) + .category(CouponCategory.NORMAL) + .openAt(LocalDateTime.now().minusDays(1)) + .closeAt(LocalDateTime.now().plusDays(3)) + .build(); + + UserCoupon userCoupon = new UserCoupon(user, coupon, coupon.getExpirationDate()); + + willDoNothing().given(userService).validateUserExists(userId); + + given(userCouponRepository.findAllByUserIdWithCoupon(userId)) + .willReturn(List.of(userCoupon)); + + UserCouponPageResponseDto dto = couponService.getUserCoupons(userId, null); + + assertThat(dto).isNotNull(); + assertThat(dto.userCoupons()).hasSize(1); + } + + @Test + void getUserCoupons_상태있는_성공케이스() { + Long userId = 1L; + CouponStatus status = CouponStatus.INACTIVE; + User user = createMockUser(); + + Coupon coupon = Coupon.builder() + .id(1L) + .name("10% 할인 쿠폰") + .discountPrice("10%") + .minOrderPrice(10000) + .expirationDate(LocalDateTime.now().plusDays(7)) + .quantity(100) + .category(CouponCategory.NORMAL) + .openAt(LocalDateTime.now().minusDays(1)) + .closeAt(LocalDateTime.now().plusDays(3)) + .status(CouponStatus.INACTIVE) + .build(); + + Coupon coupon2 = Coupon.builder() + .id(2L) + .name("10% 할인 쿠폰") + .discountPrice("10%") + .minOrderPrice(10000) + .expirationDate(LocalDateTime.now().plusDays(7)) + .quantity(100) + .category(CouponCategory.NORMAL) + .openAt(LocalDateTime.now().minusDays(1)) + .closeAt(LocalDateTime.now().plusDays(3)) + .status(CouponStatus.ACTIVE) + .build(); + + UserCoupon userCoupon = new UserCoupon(user, coupon, coupon.getExpirationDate()); + willDoNothing().given(userService).validateUserExists(userId); + given(userCouponRepository.findAllByUserIdAndStatusWithCoupon(userId, status)) + .willReturn(List.of(userCoupon)); + + UserCouponPageResponseDto dto = couponService.getUserCoupons(userId, status); + + assertThat(dto).isNotNull(); + assertThat(dto.userCoupons()).hasSize(1); + assertThat(dto.userCoupons().get(0).couponId()).isEqualTo(coupon.getId()); + } + + @Test + void getCoupons_성공_테스트() { + Long userId = 1L; + + List dummyDtoList = List.of(mock(CouponResponseDto.class)); + willDoNothing().given(userService).validateUserExists(userId); + given(couponRepository.findAvailableCouponsWithOwnership(eq(userId), any())).willReturn(dummyDtoList); + + // when + CouponPageResponseDto result = couponService.getCoupons(userId); + + // then + assertThat(result).isNotNull(); + assertThat(result.coupons()).hasSize(1); + } + + private User createMockUser() { + return User.builder() + .email("user@test.com") + .password("test1234") + .nickname("user") + .role(UserRole.USER) + .build(); + } +} diff --git a/src/test/java/com/threestar/trainus/domain/comment/controller/CommentControllerTest.java b/src/test/java/com/threestar/trainus/domain/comment/controller/CommentControllerTest.java index a5a0082e..ff237415 100644 --- a/src/test/java/com/threestar/trainus/domain/comment/controller/CommentControllerTest.java +++ b/src/test/java/com/threestar/trainus/domain/comment/controller/CommentControllerTest.java @@ -94,9 +94,7 @@ void tearDown() { @Test void create_comment() throws Exception { - CommentCreateRequestDto request = new CommentCreateRequestDto(); - request.setParentCommentId(null); - request.setContent("테스트 부모 댓글"); + CommentCreateRequestDto request = new CommentCreateRequestDto("테스트 부모 댓글", null); MockHttpSession session = new MockHttpSession(); session.setAttribute("LOGIN_USER", userId); @@ -129,22 +127,31 @@ void readAll_test() throws Exception { // 부모 댓글 1 - user Long parentId1 = createComment(null, "댓글1", lessonId, user.getId()); createComment(parentId1, "대댓글1", lessonId, user2.getId()); + createComment(parentId1, "대댓글2", lessonId, user2.getId()); + createComment(parentId1, "대댓글3", lessonId, user2.getId()); // 부모 댓글 2 - user2 Long parentId2 = createComment(null, "댓글2", lessonId, user2.getId()); createComment(parentId2, "대댓글2", lessonId, user3.getId()); createComment(parentId1, "대댓글2", lessonId, user3.getId()); + createComment(parentId2, "대댓글3", lessonId, user3.getId()); + createComment(parentId2, "대댓글4", lessonId, user3.getId()); + createComment(parentId2, "대댓글5", lessonId, user3.getId()); // 부모 댓글 3 - user3 Long parentId3 = createComment(null, "댓글3", lessonId, user3.getId()); createComment(parentId3, "대댓글1", lessonId, user.getId()); createComment(parentId2, "대댓글1", lessonId, user.getId()); createComment(parentId3, "대댓글2", lessonId, user2.getId()); + createComment(parentId3, "대댓글3", lessonId, user2.getId()); + createComment(parentId3, "대댓글5", lessonId, user2.getId()); + createComment(parentId3, "대댓글5", lessonId, user2.getId()); + createComment(parentId3, "대댓글5", lessonId, user2.getId()); mockMvc.perform(get("/api/v1/comments/" + lesson.getId()) - .param("page", "2") - .param("pageSize", "5")) + .param("page", "1") + .param("pageSize", "3")) .andDo(print()) .andExpect(status().isOk()) .andExpect(jsonPath("$.message").value("댓글 조회 성공")); @@ -155,9 +162,7 @@ void readAll_test() throws Exception { } private Long createComment(Long parentId, String content, Long lessonId, Long userId) throws Exception { - CommentCreateRequestDto request = new CommentCreateRequestDto(); - request.setParentCommentId(parentId); - request.setContent(content); + CommentCreateRequestDto request = new CommentCreateRequestDto(content, parentId); MockHttpSession session = new MockHttpSession(); session.setAttribute("LOGIN_USER", userId); diff --git a/src/test/java/com/threestar/trainus/domain/coupon/admin/scheduler/CouponStatusSchedulerTest.java b/src/test/java/com/threestar/trainus/domain/coupon/admin/scheduler/CouponStatusSchedulerTest.java new file mode 100644 index 00000000..8c673d90 --- /dev/null +++ b/src/test/java/com/threestar/trainus/domain/coupon/admin/scheduler/CouponStatusSchedulerTest.java @@ -0,0 +1,122 @@ +package com.threestar.trainus.domain.coupon.admin.scheduler; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.threestar.trainus.domain.coupon.user.entity.Coupon; +import com.threestar.trainus.domain.coupon.user.entity.CouponCategory; +import com.threestar.trainus.domain.coupon.user.entity.CouponStatus; +import com.threestar.trainus.domain.coupon.user.entity.UserCoupon; +import com.threestar.trainus.domain.coupon.user.repository.CouponRepository; +import com.threestar.trainus.domain.coupon.user.repository.UserCouponRepository; +import com.threestar.trainus.domain.user.entity.User; + +@ExtendWith(MockitoExtension.class) +class CouponStatusSchedulerTest { + @Mock + private CouponRepository couponRepository; + + @Mock + private UserCouponRepository userCouponRepository; + + @InjectMocks + private CouponStatusScheduler couponStatusScheduler; + + @Test + @DisplayName("비활성화된 쿠폰을 활성화하는 스케줄러 테스트") + void updateCouponStatus_ActivateCoupons() { + LocalDateTime now = LocalDateTime.now(); + + Coupon inactiveCoupon = Coupon.builder() + .name("테스트 쿠폰") + .status(CouponStatus.INACTIVE) + .category(CouponCategory.NORMAL) + .openAt(now.minusHours(1)) // 1시간 전에 오픈 + .closeAt(now.plusDays(1)) // 내일 마감 + .build(); + + given(couponRepository.findInactiveCouponsToActivate(any(LocalDateTime.class))) + .willReturn(List.of(inactiveCoupon)); + given(couponRepository.findActiveCouponsToDeactivate(any(LocalDateTime.class))) + .willReturn(List.of()); + + couponStatusScheduler.updateCouponStatus(); + + verify(couponRepository).findInactiveCouponsToActivate(any(LocalDateTime.class)); + verify(couponRepository).findActiveCouponsToDeactivate(any(LocalDateTime.class)); + verify(couponRepository).saveAll(anyList()); + } + + @Test + @DisplayName("활성화된 쿠폰을 비활성화하는 스케줄러 테스트") + void updateCouponStatus_DeactivateCoupons() { + LocalDateTime now = LocalDateTime.now(); + + Coupon activeCoupon = Coupon.builder() + .name("테스트 쿠폰") + .status(CouponStatus.ACTIVE) + .category(CouponCategory.NORMAL) + .openAt(now.minusDays(7)) // 7일 전에 오픈 + .closeAt(now.minusHours(1)) // 1시간 전에 마감 + .build(); + + given(couponRepository.findInactiveCouponsToActivate(any(LocalDateTime.class))) + .willReturn(List.of()); + given(couponRepository.findActiveCouponsToDeactivate(any(LocalDateTime.class))) + .willReturn(List.of(activeCoupon)); + + couponStatusScheduler.updateCouponStatus(); + + verify(couponRepository).findInactiveCouponsToActivate(any(LocalDateTime.class)); + verify(couponRepository).findActiveCouponsToDeactivate(any(LocalDateTime.class)); + verify(couponRepository).saveAll(anyList()); + } + + @Test + @DisplayName("만료된 유저쿠폰을 처리하는 스케줄러 테스트") + void updateUserCouponStatus_ExpireCoupons() { + LocalDateTime now = LocalDateTime.now(); + + User user = User.builder().id(1L).build(); + Coupon coupon = Coupon.builder() + .name("테스트 쿠폰") + .status(CouponStatus.ACTIVE) + .category(CouponCategory.NORMAL) + .build(); + + UserCoupon expiredUserCoupon = new UserCoupon(user, coupon, now.minusHours(1)); // 1시간 전에 만료 + + given(userCouponRepository.findActiveUserCouponsToExpire(any(LocalDateTime.class))) + .willReturn(List.of(expiredUserCoupon)); + + couponStatusScheduler.updateUserCouponStatus(); + + verify(userCouponRepository).findActiveUserCouponsToExpire(any(LocalDateTime.class)); + verify(userCouponRepository).saveAll(anyList()); + } + + @Test + @DisplayName("처리할 쿠폰이 없는 경우 스케줄러 테스트") + void updateCouponStatus_NoCouponsToProcess() { + given(couponRepository.findInactiveCouponsToActivate(any(LocalDateTime.class))) + .willReturn(List.of()); + given(couponRepository.findActiveCouponsToDeactivate(any(LocalDateTime.class))) + .willReturn(List.of()); + + couponStatusScheduler.updateCouponStatus(); + + verify(couponRepository).findInactiveCouponsToActivate(any(LocalDateTime.class)); + verify(couponRepository).findActiveCouponsToDeactivate(any(LocalDateTime.class)); + verify(couponRepository, never()).saveAll(anyList()); + } +} diff --git a/src/test/java/com/threestar/trainus/domain/coupon/admin/service/AdminCouponServiceTest.java b/src/test/java/com/threestar/trainus/domain/coupon/admin/service/AdminCouponServiceTest.java new file mode 100644 index 00000000..17728098 --- /dev/null +++ b/src/test/java/com/threestar/trainus/domain/coupon/admin/service/AdminCouponServiceTest.java @@ -0,0 +1,335 @@ +package com.threestar.trainus.domain.coupon.admin.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.threestar.trainus.domain.coupon.admin.dto.CouponCreateRequestDto; +import com.threestar.trainus.domain.coupon.admin.dto.CouponCreateResponseDto; +import com.threestar.trainus.domain.coupon.user.entity.Coupon; +import com.threestar.trainus.domain.coupon.user.entity.CouponCategory; +import com.threestar.trainus.domain.coupon.user.entity.CouponStatus; +import com.threestar.trainus.domain.coupon.user.repository.CouponRepository; +import com.threestar.trainus.domain.coupon.user.repository.UserCouponRepository; +import com.threestar.trainus.domain.user.service.UserService; +import com.threestar.trainus.global.exception.domain.ErrorCode; +import com.threestar.trainus.global.exception.handler.BusinessException; + +@ExtendWith(MockitoExtension.class) +class AdminCouponServiceTest { + + @Mock + private CouponRepository couponRepository; + + @Mock + private UserCouponRepository userCouponRepository; + + @Mock + private UserService userService; + + @InjectMocks + private AdminCouponService adminCouponService; + + @Test + @DisplayName("정상적인 쿠폰 생성 테스트") + void createCoupon_Success() { + Long userId = 1L; + LocalDateTime now = LocalDateTime.now(); + + CouponCreateRequestDto request = new CouponCreateRequestDto( + "테스트 쿠폰", + now.plusDays(30), + "5000", + 10000, + CouponStatus.ACTIVE, + 100, + CouponCategory.NORMAL, + now, + now.plusDays(7) + ); + + Coupon savedCoupon = Coupon.builder() + .name("테스트 쿠폰") + .expirationDate(now.plusDays(30)) + .discountPrice("5000") + .minOrderPrice(10000) + .status(CouponStatus.ACTIVE) + .quantity(100) + .category(CouponCategory.NORMAL) + .openAt(now) + .closeAt(now.plusDays(7)) + .build(); + + given(couponRepository.save(any(Coupon.class))).willReturn(savedCoupon); + + CouponCreateResponseDto response = adminCouponService.createCoupon(request, userId); + + assertThat(response).isNotNull(); + assertThat(response.couponName()).isEqualTo("테스트 쿠폰"); + verify(userService).validateAdminRole(userId); + verify(couponRepository).save(any(Coupon.class)); + } + + @Test + @DisplayName("할인가격 형식이 잘못된 경우 예외 발생") + void createCoupon_InvalidDiscountPrice() { + Long userId = 1L; + LocalDateTime now = LocalDateTime.now(); + + CouponCreateRequestDto request = new CouponCreateRequestDto( + "테스트 쿠폰", + now.plusDays(30), + "잘못된형식", // 잘못된 할인가격 + 10000, + CouponStatus.ACTIVE, + 100, + CouponCategory.NORMAL, + now, + now.plusDays(7) + ); + + assertThatThrownBy(() -> adminCouponService.createCoupon(request, userId)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_REQUEST_DATA); + } + + @Test + @DisplayName("쿠폰 상세 조회 성공") + void getCouponDetail_Success() { + Long couponId = 1L; + Long userId = 1L; + + Coupon coupon = Coupon.builder() + .name("테스트 쿠폰") + .discountPrice("5000") + .minOrderPrice(10000) + .status(CouponStatus.ACTIVE) + .quantity(100) + .category(CouponCategory.NORMAL) + .build(); + + given(couponRepository.findById(couponId)).willReturn(Optional.of(coupon)); + given(userCouponRepository.countByCouponId(couponId)).willReturn(10L); + + var response = adminCouponService.getCouponDetail(couponId, userId); + + assertThat(response).isNotNull(); + assertThat(response.couponName()).isEqualTo("테스트 쿠폰"); + assertThat(response.issuedCount()).isEqualTo(10); + verify(userService).validateAdminRole(userId); + } + + @Test + @DisplayName("존재하지 않는 쿠폰 조회시 예외 발생") + void getCouponDetail_NotFound() { + Long couponId = 999L; + Long userId = 1L; + + given(couponRepository.findById(couponId)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> adminCouponService.getCouponDetail(couponId, userId)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_REQUEST_DATA); + } + + @Test + @DisplayName("쿠폰 삭제 성공") + void deleteCoupon_Success() { + Long couponId = 1L; + Long userId = 1L; + + Coupon coupon = Coupon.builder() + .name("테스트 쿠폰") + .status(CouponStatus.ACTIVE) + .build(); + + given(couponRepository.findById(couponId)).willReturn(Optional.of(coupon)); + given(userCouponRepository.countByCouponId(couponId)).willReturn(0L); + given(couponRepository.save(any(Coupon.class))).willReturn(coupon); + + var response = adminCouponService.deleteCoupon(couponId, userId); + + assertThat(response).isNotNull(); + assertThat(response.couponName()).isEqualTo("테스트 쿠폰"); + verify(userService).validateAdminRole(userId); + verify(couponRepository).save(coupon); + } + + @Test + @DisplayName("이미 삭제된 쿠폰 삭제시 예외 발생") + void deleteCoupon_AlreadyDeleted() { + Long couponId = 1L; + Long userId = 1L; + + Coupon coupon = Coupon.builder() + .name("테스트 쿠폰") + .status(CouponStatus.ACTIVE) + .build(); + + coupon.markAsDeleted(); + + given(couponRepository.findById(couponId)).willReturn(Optional.of(coupon)); + + assertThatThrownBy(() -> adminCouponService.deleteCoupon(couponId, userId)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_REQUEST_DATA); + } + + @Test + @DisplayName("발급되지 않은 쿠폰 삭제 성공") + void deleteCoupon_Success_NoIssuedCoupons() { + Long couponId = 1L; + Long userId = 1L; + + Coupon coupon = Coupon.builder() + .name("테스트 쿠폰") + .status(CouponStatus.ACTIVE) + .discountPrice("5000") + .minOrderPrice(10000) + .category(CouponCategory.NORMAL) + .build(); + + given(couponRepository.findById(couponId)).willReturn(Optional.of(coupon)); + given(userCouponRepository.countByCouponId(couponId)).willReturn(0L); // 발급된 쿠폰 없음 + given(couponRepository.save(any(Coupon.class))).willReturn(coupon); + + var response = adminCouponService.deleteCoupon(couponId, userId); + + assertThat(response).isNotNull(); + assertThat(response.couponName()).isEqualTo("테스트 쿠폰"); + verify(userService).validateAdminRole(userId); + verify(couponRepository).save(coupon); + assertThat(coupon.isDeleted()).isTrue(); // 삭제 상태 확인 + } + + @Test + @DisplayName("발급된 쿠폰이 있는 경우 삭제 실패") + void deleteCoupon_Fail_HasIssuedCoupons() { + Long couponId = 1L; + Long userId = 1L; + + Coupon coupon = Coupon.builder() + .name("테스트 쿠폰") + .status(CouponStatus.ACTIVE) + .discountPrice("5000") + .minOrderPrice(10000) + .category(CouponCategory.NORMAL) + .build(); + + given(couponRepository.findById(couponId)).willReturn(Optional.of(coupon)); + given(userCouponRepository.countByCouponId(couponId)).willReturn(5L); // 5명이 발급받음 + + assertThatThrownBy(() -> adminCouponService.deleteCoupon(couponId, userId)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.COUPON_CANNOT_DELETE_ISSUED); + + verify(userService).validateAdminRole(userId); + verify(couponRepository, never()).save(any(Coupon.class)); // 저장되지 않음 + assertThat(coupon.isDeleted()).isFalse(); // 삭제되지 않음 + } + + @Test + @DisplayName("이미 삭제된 쿠폰 삭제 시도 시 실패") + void deleteCoupon_Fail_AlreadyDeleted() { + Long couponId = 1L; + Long userId = 1L; + + Coupon coupon = Coupon.builder() + .name("테스트 쿠폰") + .status(CouponStatus.ACTIVE) + .discountPrice("5000") + .minOrderPrice(10000) + .category(CouponCategory.NORMAL) + .build(); + + coupon.markAsDeleted(); // 미리 삭제 상태로 만듦 + + given(couponRepository.findById(couponId)).willReturn(Optional.of(coupon)); + + assertThatThrownBy(() -> adminCouponService.deleteCoupon(couponId, userId)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_REQUEST_DATA); + + verify(userService).validateAdminRole(userId); + verify(userCouponRepository, never()).countByCouponId(anyLong()); // 발급 수량 조회하지 않음 + } + + @Test + @DisplayName("존재하지 않는 쿠폰 삭제 시도 시 실패") + void deleteCoupon_Fail_CouponNotFound() { + Long couponId = 999L; + Long userId = 1L; + + given(couponRepository.findById(couponId)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> adminCouponService.deleteCoupon(couponId, userId)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_REQUEST_DATA); + + verify(userService).validateAdminRole(userId); + verify(userCouponRepository, never()).countByCouponId(anyLong()); + } + + @Test + @DisplayName("선착순 쿠폰 - 발급되지 않은 경우 삭제 성공") + void deleteCoupon_Success_OpenRunCoupon_NoIssued() { + Long couponId = 1L; + Long userId = 1L; + + Coupon openRunCoupon = Coupon.builder() + .name("선착순 쿠폰") + .status(CouponStatus.ACTIVE) + .discountPrice("10%") + .minOrderPrice(50000) + .quantity(100) + .category(CouponCategory.OPEN_RUN) + .build(); + + given(couponRepository.findById(couponId)).willReturn(Optional.of(openRunCoupon)); + given(userCouponRepository.countByCouponId(couponId)).willReturn(0L); // 아무도 발급받지 않음 + given(couponRepository.save(any(Coupon.class))).willReturn(openRunCoupon); + + var response = adminCouponService.deleteCoupon(couponId, userId); + + assertThat(response).isNotNull(); + assertThat(response.couponName()).isEqualTo("선착순 쿠폰"); + verify(couponRepository).save(openRunCoupon); + assertThat(openRunCoupon.isDeleted()).isTrue(); + } + + @Test + @DisplayName("선착순 쿠폰 - 발급된 경우 삭제 실패") + void deleteCoupon_Fail_OpenRunCoupon_HasIssued() { + Long couponId = 1L; + Long userId = 1L; + + Coupon openRunCoupon = Coupon.builder() + .name("선착순 쿠폰") + .status(CouponStatus.ACTIVE) + .discountPrice("10%") + .minOrderPrice(50000) + .quantity(100) + .category(CouponCategory.OPEN_RUN) + .build(); + + given(couponRepository.findById(couponId)).willReturn(Optional.of(openRunCoupon)); + given(userCouponRepository.countByCouponId(couponId)).willReturn(50L); // 50명이 발급받음 + + assertThatThrownBy(() -> adminCouponService.deleteCoupon(couponId, userId)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.COUPON_CANNOT_DELETE_ISSUED); + + verify(couponRepository, never()).save(any(Coupon.class)); + assertThat(openRunCoupon.isDeleted()).isFalse(); + } +} diff --git a/src/test/java/com/threestar/trainus/domain/lesson/student/LessonSearchPerformanceTest.java b/src/test/java/com/threestar/trainus/domain/lesson/student/LessonSearchPerformanceTest.java new file mode 100644 index 00000000..b1a770bf --- /dev/null +++ b/src/test/java/com/threestar/trainus/domain/lesson/student/LessonSearchPerformanceTest.java @@ -0,0 +1,181 @@ +package com.threestar.trainus.domain.lesson.student; + +import com.threestar.trainus.domain.lesson.teacher.entity.Category; +import com.threestar.trainus.domain.lesson.teacher.entity.Lesson; +import com.threestar.trainus.domain.lesson.teacher.repository.LessonRepository; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; // Sort import 추가 +import org.springframework.test.context.ActiveProfiles; +import org.springframework.util.StopWatch; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Random; + +@Slf4j +@SpringBootTest +@ActiveProfiles("test") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class LessonSearchPerformanceTest { + + @Autowired + private LessonRepository lessonRepository; + + @PersistenceContext + private EntityManager entityManager; + + //초기 데이터 생성용 + private static final int DATA_SIZE = 200000; + private static final String SEARCH_KEYWORD = "요가"; + private static final int INIT_MODE = 2; + + @BeforeAll + void setUp() { + if (INIT_MODE == 0) { + // 동일 주소 데이터 생성 + log.info("테스트 데이터 생성을 시작 (총 {}건)", DATA_SIZE); + List lessons = new ArrayList<>(); + Random random = new Random(); + String[] cities = {"서울특별시"}; + String[] districts = {"강남구"}; + String[] dongs = {"역삼동"}; + String[] lessonNames = new String[100]; + for (int j = 0; j < 100; j++) { + if (j == 0) { + lessonNames[j] = "강력한 요가"; + } else { + lessonNames[j] = "일반 레슨 " + j; + } + } + for (int i = 0; i < DATA_SIZE; i++) { + lessons.add(Lesson.builder() + .lessonLeader(1L) + .lessonName(lessonNames[i % lessonNames.length] + " " + i) + .description("테스트 설명 " + i) + .maxParticipants(20) + .startAt(LocalDateTime.now().plusDays(10)) + .endAt(LocalDateTime.now().plusDays(20)) + .price(50000) + .category(Category.values()[random.nextInt(Category.values().length)]) + .openTime(LocalDateTime.now()) + .openRun(true) + .city(cities[0]) + .district(districts[0]) + .dong(dongs[0]) + .addressDetail("상세 주소 " + i) + .build()); + } + lessonRepository.saveAll(lessons); + log.info("테스트 데이터 생성이 완료"); + } else if (INIT_MODE == 1) { + // 다른 주소 데이터 생성 + log.info("테스트 데이터 생성을 시작 (총 {}건)", DATA_SIZE); + List lessons = new ArrayList<>(); + Random random = new Random(); + + String[] cities = new String[10]; + String[] districts = new String[10]; + String[] dongs = new String[10]; + String[] ris = new String[10]; + for (int i = 0; i < 10; i++) { + cities[i] = "도시" + i; + districts[i] = "구" + i; + dongs[i] = "동" + i; + ris[i] = "리" + i; + } + + String[] lessonNames = new String[100]; + for (int j = 0; j < 100; j++) { + if (j == 0) { + lessonNames[j] = "강력한 요가"; + } else { + lessonNames[j] = "일반 레슨 " + j; + } + } + + for (int i = 0; i < DATA_SIZE; i++) { + lessons.add(Lesson.builder() + .lessonLeader(1L) + .lessonName(lessonNames[i % lessonNames.length] + " " + i) + .description("테스트 설명 " + i) + .maxParticipants(20) + .startAt(LocalDateTime.now().plusDays(10)) + .endAt(LocalDateTime.now().plusDays(20)) + .price(50000) + .category(Category.values()[random.nextInt(Category.values().length)]) + .openTime(LocalDateTime.now()) + .openRun(true) + .city(cities[random.nextInt(cities.length)]) + .district(districts[random.nextInt(districts.length)]) + .dong(dongs[random.nextInt(dongs.length)]) + .ri(ris[random.nextInt(ris.length)]) + .addressDetail("상세 주소 " + i) + .build()); + } + lessonRepository.saveAll(lessons); + log.info("테스트 데이터 생성이 완료"); + } else { + log.info("데이터를 생성하지 않습니다."); + } + } + + @Test + @DisplayName("성능 측정: LIKE 검색") + void searchWithLike() { + + StopWatch stopWatch = new StopWatch(); + stopWatch.start(); + + Page result = lessonRepository.findByLocationAndSearchWithLike( + null,"서울특별시", "강남구", "역삼동", null, SEARCH_KEYWORD, PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")) + ); + + stopWatch.stop(); + log.info("[LIKE 검색] 검색어: {}, 총 {}건 조회, 실행 시간: {} ms", SEARCH_KEYWORD, result.getTotalElements(), + stopWatch.getTotalTimeMillis()); + } + + @Test + @DisplayName("성능 측정: Full-Text 검색 최적화 (서브쿼리 JOIN 방식)") + void searchWithOptimizedFullText() { + + StopWatch stopWatch = new StopWatch(); + stopWatch.start(); + + Page result = lessonRepository.findByLocationAndFullTextSearchOptimized( + null, "서울특별시", "강남구", "역삼동", SEARCH_KEYWORD, null, PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "created_at")) + ); + + stopWatch.stop(); + log.info("[Full-Text 최적화 검색] 검색어: {}, 총 {}건 조회, 실행 시간: {} ms", SEARCH_KEYWORD, result.getTotalElements(), + stopWatch.getTotalTimeMillis()); + } + + @Test + @DisplayName("성능 측정: 지역으로만 검색") + void searchByLocation() { + + StopWatch stopWatch = new StopWatch(); + stopWatch.start(); + + Page result = lessonRepository.findByLocation( + null, "도시5", "구5", "동5", "리5", PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")) + ); + + stopWatch.stop(); + log.info("[지역으로만 검색] 총 {}건 조회, 실행 시간: {} ms", result.getTotalElements(), + stopWatch.getTotalTimeMillis()); + } +} \ No newline at end of file diff --git a/src/test/java/com/threestar/trainus/domain/lesson/teacher/scheduler/LessonStatusSchedulerTest.java b/src/test/java/com/threestar/trainus/domain/lesson/teacher/scheduler/LessonStatusSchedulerTest.java new file mode 100644 index 00000000..15d862e5 --- /dev/null +++ b/src/test/java/com/threestar/trainus/domain/lesson/teacher/scheduler/LessonStatusSchedulerTest.java @@ -0,0 +1,157 @@ +package com.threestar.trainus.domain.lesson.teacher.scheduler; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.threestar.trainus.domain.lesson.teacher.entity.Category; +import com.threestar.trainus.domain.lesson.teacher.entity.Lesson; +import com.threestar.trainus.domain.lesson.teacher.repository.LessonRepository; + +@ExtendWith(MockitoExtension.class) +class LessonStatusSchedulerTest { + @Mock + private LessonRepository lessonRepository; + + @InjectMocks + private LessonStatusScheduler lessonStatusScheduler; + + @Test + @DisplayName("시작할 레슨들을 진행중으로 변경하는 스케줄러 테스트") + void updateLessonStatus_StartLessons() { + LocalDateTime now = LocalDateTime.now(); + + Lesson lessonToStart = Lesson.builder() + .lessonLeader(1L) + .lessonName("테스트 레슨") + .description("테스트 설명") + .category(Category.GYM) + .price(30000) + .maxParticipants(10) + .startAt(now.minusMinutes(30)) // 30분 전에 시작 + .endAt(now.plusHours(1)) + .openRun(false) + .city("서울시") + .district("강남구") + .dong("역삼동") + .addressDetail("테스트 주소") + .build(); + + given(lessonRepository.findLessonsToStart(any(LocalDateTime.class))) + .willReturn(List.of(lessonToStart)); + given(lessonRepository.findLessonsToComplete(any(LocalDateTime.class))) + .willReturn(List.of()); + + lessonStatusScheduler.updateLessonStatus(); + + verify(lessonRepository).findLessonsToStart(any(LocalDateTime.class)); + verify(lessonRepository).findLessonsToComplete(any(LocalDateTime.class)); + verify(lessonRepository).saveAll(anyList()); + } + + @Test + @DisplayName("완료할 레슨들을 완료 상태로 변경하는 스케줄러 테스트") + void updateLessonStatus_CompleteLessons() { + LocalDateTime now = LocalDateTime.now(); + + Lesson lessonToComplete = Lesson.builder() + .lessonLeader(1L) + .lessonName("테스트 레슨") + .description("테스트 설명") + .category(Category.GYM) + .price(30000) + .maxParticipants(10) + .startAt(now.minusHours(2)) // 2시간 전에 시작 + .endAt(now.minusMinutes(30)) // 30분 전에 종료 + .openRun(false) + .city("서울시") + .district("강남구") + .dong("역삼동") + .addressDetail("테스트 주소") + .build(); + + given(lessonRepository.findLessonsToStart(any(LocalDateTime.class))) + .willReturn(List.of()); + given(lessonRepository.findLessonsToComplete(any(LocalDateTime.class))) + .willReturn(List.of(lessonToComplete)); + + lessonStatusScheduler.updateLessonStatus(); + + verify(lessonRepository).findLessonsToStart(any(LocalDateTime.class)); + verify(lessonRepository).findLessonsToComplete(any(LocalDateTime.class)); + verify(lessonRepository).saveAll(anyList()); + } + + @Test + @DisplayName("처리할 레슨이 없는 경우 스케줄러 테스트") + void updateLessonStatus_NoLessonsToProcess() { + given(lessonRepository.findLessonsToStart(any(LocalDateTime.class))) + .willReturn(List.of()); + given(lessonRepository.findLessonsToComplete(any(LocalDateTime.class))) + .willReturn(List.of()); + + lessonStatusScheduler.updateLessonStatus(); + + verify(lessonRepository).findLessonsToStart(any(LocalDateTime.class)); + verify(lessonRepository).findLessonsToComplete(any(LocalDateTime.class)); + verify(lessonRepository, never()).saveAll(anyList()); + } + + @Test + @DisplayName("시작과 완료를 동시에 처리하는 스케줄러 테스트") + void updateLessonStatus_StartAndCompleteLessons() { + LocalDateTime now = LocalDateTime.now(); + + Lesson lessonToStart = Lesson.builder() + .lessonLeader(1L) + .lessonName("시작할 레슨") + .description("테스트 설명") + .category(Category.GYM) + .price(30000) + .maxParticipants(10) + .startAt(now.minusMinutes(10)) // 10분 전에 시작 + .endAt(now.plusHours(1)) + .openRun(false) + .city("서울시") + .district("강남구") + .dong("역삼동") + .addressDetail("테스트 주소") + .build(); + + Lesson lessonToComplete = Lesson.builder() + .lessonLeader(2L) + .lessonName("완료할 레슨") + .description("테스트 설명") + .category(Category.YOGA) + .price(25000) + .maxParticipants(8) + .startAt(now.minusHours(2)) + .endAt(now.minusMinutes(10)) // 10분 전에 종료 + .openRun(false) + .city("서울시") + .district("강남구") + .dong("역삼동") + .addressDetail("테스트 주소") + .build(); + + given(lessonRepository.findLessonsToStart(any(LocalDateTime.class))) + .willReturn(List.of(lessonToStart)); + given(lessonRepository.findLessonsToComplete(any(LocalDateTime.class))) + .willReturn(List.of(lessonToComplete)); + + lessonStatusScheduler.updateLessonStatus(); + + verify(lessonRepository).findLessonsToStart(any(LocalDateTime.class)); + verify(lessonRepository).findLessonsToComplete(any(LocalDateTime.class)); + verify(lessonRepository, times(2)).saveAll(anyList()); // 시작과 완료 각각 한 번씩 + } +} diff --git a/src/test/java/com/threestar/trainus/domain/lesson/teacher/service/AdminLessonServiceTest.java b/src/test/java/com/threestar/trainus/domain/lesson/teacher/service/AdminLessonServiceTest.java new file mode 100644 index 00000000..940f7942 --- /dev/null +++ b/src/test/java/com/threestar/trainus/domain/lesson/teacher/service/AdminLessonServiceTest.java @@ -0,0 +1,334 @@ +package com.threestar.trainus.domain.lesson.teacher.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.threestar.trainus.domain.lesson.teacher.dto.LessonCreateRequestDto; +import com.threestar.trainus.domain.lesson.teacher.dto.LessonResponseDto; +import com.threestar.trainus.domain.lesson.teacher.entity.ApplicationAction; +import com.threestar.trainus.domain.lesson.teacher.entity.ApplicationStatus; +import com.threestar.trainus.domain.lesson.teacher.entity.Category; +import com.threestar.trainus.domain.lesson.teacher.entity.Lesson; +import com.threestar.trainus.domain.lesson.teacher.entity.LessonApplication; +import com.threestar.trainus.domain.lesson.teacher.entity.LessonParticipant; +import com.threestar.trainus.domain.lesson.teacher.repository.LessonApplicationRepository; +import com.threestar.trainus.domain.lesson.teacher.repository.LessonImageRepository; +import com.threestar.trainus.domain.lesson.teacher.repository.LessonParticipantRepository; +import com.threestar.trainus.domain.lesson.teacher.repository.LessonRepository; +import com.threestar.trainus.domain.user.entity.User; +import com.threestar.trainus.domain.user.entity.UserRole; +import com.threestar.trainus.domain.user.service.UserService; +import com.threestar.trainus.global.exception.domain.ErrorCode; +import com.threestar.trainus.global.exception.handler.BusinessException; + +@ExtendWith(MockitoExtension.class) +public class AdminLessonServiceTest { + @Mock + private LessonRepository lessonRepository; + + @Mock + private LessonImageRepository lessonImageRepository; + + @Mock + private LessonApplicationRepository lessonApplicationRepository; + + @Mock + private UserService userService; + + @InjectMocks + private AdminLessonService adminLessonService; + + @Mock + private LessonParticipantRepository lessonParticipantRepository; + + @Mock + private LessonCreationLimitService lessonCreationLimitService; + + @Test + @DisplayName("정상적인 레슨 생성 테스트") + void createLesson_Success() { + Long userId = 1L; + LocalDateTime now = LocalDateTime.now(); + + LessonCreateRequestDto request = new LessonCreateRequestDto( + "테스트 레슨", + "레슨 설명", + Category.GYM, + 30000, + 10, + now.plusDays(1), + now.plusDays(1).plusHours(2), + null, + false, + "서울시", + "강남구", + "역삼동", + "", + "테스트 주소", + List.of() + ); + + User user = User.builder() + .id(userId) + .email("test@test.com") + .nickname("테스트유저") + .role(UserRole.USER) + .build(); + + Lesson savedLesson = Lesson.builder() + .lessonLeader(userId) + .lessonName("테스트 레슨") + .description("레슨 설명") + .category(Category.GYM) + .price(30000) + .maxParticipants(10) + .startAt(now.plusDays(1)) + .endAt(now.plusDays(1).plusHours(2)) + .openRun(false) + .city("서울시") + .district("강남구") + .dong("역삼동") + .addressDetail("테스트 주소") + .build(); + + given(userService.getUserById(userId)).willReturn(user); + given(lessonRepository.existsDuplicateLesson(anyLong(), anyString(), any(LocalDateTime.class))) + .willReturn(false); + given(lessonRepository.hasTimeConflictLesson(anyLong(), any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(false); + given(lessonRepository.save(any(Lesson.class))).willReturn(savedLesson); + + willDoNothing().given(lessonCreationLimitService).checkAndSetCreationLimit(userId); + + LessonResponseDto response = adminLessonService.createLesson(request, userId); + + assertThat(response).isNotNull(); + assertThat(response.lessonName()).isEqualTo("테스트 레슨"); + assertThat(response.lessonLeader()).isEqualTo(userId); + verify(lessonRepository).save(any(Lesson.class)); + + verify(lessonCreationLimitService).checkAndSetCreationLimit(userId); + } + + @Test + @DisplayName("시작 시간이 과거인 경우 예외 발생") + void createLesson_InvalidStartTime() { + Long userId = 1L; + LocalDateTime pastTime = LocalDateTime.now().minusHours(1); + + LessonCreateRequestDto request = new LessonCreateRequestDto( + "테스트 레슨", + "레슨 설명", + Category.GYM, + 30000, + 10, + pastTime, // 과거 시간 + pastTime.plusHours(2), + null, + false, + "서울시", + "강남구", + "역삼동", + "", + "테스트 주소", + List.of() + ); + + User user = User.builder() + .id(userId) + .email("test@test.com") + .nickname("테스트유저") + .role(UserRole.USER) + .build(); + + given(userService.getUserById(userId)).willReturn(user); + + assertThatThrownBy(() -> adminLessonService.createLesson(request, userId)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.LESSON_START_TIME_INVALID); + } + + @Test + @DisplayName("중복 레슨 생성시 예외 발생") + void createLesson_DuplicateLesson() { + Long userId = 1L; + LocalDateTime now = LocalDateTime.now(); + + LessonCreateRequestDto request = new LessonCreateRequestDto( + "테스트 레슨", + "레슨 설명", + Category.GYM, + 30000, + 10, + now.plusDays(1), + now.plusDays(1).plusHours(2), + null, + false, + "서울시", + "강남구", + "역삼동", + "", + "테스트 주소", + List.of() + ); + + User user = User.builder() + .id(userId) + .email("test@test.com") + .nickname("테스트유저") + .role(UserRole.USER) + .build(); + + given(userService.getUserById(userId)).willReturn(user); + given(lessonRepository.existsDuplicateLesson(anyLong(), anyString(), any(LocalDateTime.class))) + .willReturn(true); // 중복 레슨 존재 + + assertThatThrownBy(() -> adminLessonService.createLesson(request, userId)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.DUPLICATE_LESSON); + } + + @Test + @DisplayName("레슨 삭제 성공") + void deleteLesson_Success() { + Long lessonId = 1L; + Long userId = 1L; + LocalDateTime futureTime = LocalDateTime.now().plusDays(1); + + Lesson lesson = Lesson.builder() + .lessonLeader(userId) + .lessonName("테스트 레슨") + .description("테스트 설명") + .category(Category.GYM) + .price(30000) + .maxParticipants(10) + .startAt(futureTime) + .endAt(futureTime.plusHours(2)) + .openRun(false) + .city("서울시") + .district("강남구") + .dong("역삼동") + .addressDetail("테스트 주소") + .build(); + + given(userService.getUserById(userId)).willReturn(User.builder() + .id(userId) + .email("test@test.com") + .nickname("테스트유저") + .role(UserRole.USER) + .build()); + given(lessonRepository.findById(lessonId)).willReturn(Optional.of(lesson)); + given(lessonApplicationRepository.countByLessonAndStatus(eq(lesson), eq(ApplicationStatus.APPROVED))) + .willReturn(0); + given(lessonRepository.save(any(Lesson.class))).willReturn(lesson); + + adminLessonService.deleteLesson(lessonId, userId); + + verify(lessonRepository).save(lesson); + assertThat(lesson.isDeleted()).isTrue(); + } + + @Test + @DisplayName("다른 사용자의 레슨 삭제시 예외 발생") + void deleteLesson_AccessForbidden() { + Long lessonId = 1L; + Long userId = 1L; + Long otherUserId = 2L; + LocalDateTime futureTime = LocalDateTime.now().plusDays(1); + + Lesson lesson = Lesson.builder() + .lessonLeader(otherUserId) // 다른 사용자의 레슨 + .lessonName("테스트 레슨") + .description("테스트 설명") + .category(Category.GYM) + .price(30000) + .maxParticipants(10) + .startAt(futureTime) + .endAt(futureTime.plusHours(2)) + .openRun(false) + .city("서울시") + .district("강남구") + .dong("역삼동") + .addressDetail("테스트 주소") + .build(); + + given(userService.getUserById(userId)).willReturn(User.builder() + .id(userId) + .email("test@test.com") + .nickname("테스트유저") + .role(UserRole.USER) + .build()); + given(lessonRepository.findById(lessonId)).willReturn(Optional.of(lesson)); + + assertThatThrownBy(() -> adminLessonService.deleteLesson(lessonId, userId)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.ACCESS_FORBIDDEN); + } + + @Test + @DisplayName("레슨 신청 승인 성공") + void processLessonApplication_Approve_Success() { + Long applicationId = 1L; + Long userId = 1L; + + User user = User.builder().id(2L).email("user@test.com").nickname("유저").role(UserRole.USER).build(); + Lesson lesson = Lesson.builder() + .lessonLeader(userId) + .lessonName("테스트 레슨") + .description("테스트 설명") + .category(Category.GYM) + .price(30000) + .maxParticipants(10) + .startAt(LocalDateTime.now().plusDays(1)) + .endAt(LocalDateTime.now().plusDays(1).plusHours(2)) + .openRun(false) + .city("서울시") + .district("강남구") + .dong("역삼동") + .addressDetail("테스트 주소") + .build(); + + LessonApplication application = LessonApplication.builder() + .user(user) + .lesson(lesson) + .build(); + + given(lessonApplicationRepository.findById(applicationId)).willReturn(Optional.of(application)); + given(lessonApplicationRepository.save(any(LessonApplication.class))).willReturn(application); + given(lessonParticipantRepository.save(any(LessonParticipant.class))).willReturn(any()); + + var response = adminLessonService.processLessonApplication(applicationId, ApplicationAction.APPROVED, userId); + + assertThat(response).isNotNull(); + assertThat(response.status()).isEqualTo(ApplicationStatus.APPROVED); + verify(lessonApplicationRepository).save(application); + + verify(lessonParticipantRepository).save(any(LessonParticipant.class)); + } + + @Test + @DisplayName("존재하지 않는 레슨 신청 처리시 예외 발생") + void processLessonApplication_NotFound() { + Long applicationId = 999L; + Long userId = 1L; + + given(lessonApplicationRepository.findById(applicationId)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + adminLessonService.processLessonApplication(applicationId, ApplicationAction.APPROVED, userId)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.LESSON_APPLICATION_NOT_FOUND); + } +} diff --git a/src/test/java/com/threestar/trainus/domain/lesson/teacher/service/LessonCreationLimitServiceTest.java b/src/test/java/com/threestar/trainus/domain/lesson/teacher/service/LessonCreationLimitServiceTest.java new file mode 100644 index 00000000..6f99448c --- /dev/null +++ b/src/test/java/com/threestar/trainus/domain/lesson/teacher/service/LessonCreationLimitServiceTest.java @@ -0,0 +1,64 @@ +package com.threestar.trainus.domain.lesson.teacher.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.time.Duration; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import com.threestar.trainus.global.exception.domain.ErrorCode; +import com.threestar.trainus.global.exception.handler.BusinessException; + +@ExtendWith(MockitoExtension.class) +class LessonCreationLimitServiceTest { + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + @InjectMocks + private LessonCreationLimitService lessonCreationLimitService; + + @Test + @DisplayName("첫 레슨 생성 시 제한 없이 성공한다") + void checkAndSetCreationLimit_FirstTime_Success() { + Long userId = 1L; + String expectedKey = "lesson_creation_limit:" + userId; + + when(redisTemplate.hasKey(expectedKey)).thenReturn(false); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + + assertThatCode(() -> lessonCreationLimitService.checkAndSetCreationLimit(userId)) + .doesNotThrowAnyException(); + + verify(redisTemplate).hasKey(expectedKey); + verify(valueOperations).set(expectedKey, "restricted", Duration.ofMinutes(1)); + } + + @Test + @DisplayName("쿨타임이 남아있을 때 레슨 생성을 제한한다") + void checkAndSetCreationLimit_WithinCooltime_ThrowsException() { + Long userId = 1L; + String expectedKey = "lesson_creation_limit:" + userId; + + when(redisTemplate.hasKey(expectedKey)).thenReturn(true); + when(redisTemplate.getExpire(expectedKey)).thenReturn(30L); // 30초 남음 + + assertThatThrownBy(() -> lessonCreationLimitService.checkAndSetCreationLimit(userId)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException)e).getErrorCode()) + .isEqualTo(ErrorCode.LESSON_CREATION_TOO_FREQUENT); + + verify(redisTemplate).hasKey(expectedKey); + verify(redisTemplate, never()).opsForValue(); + } +} diff --git a/src/test/java/com/threestar/trainus/domain/metadata/service/ProfileMetadataServiceTest.java b/src/test/java/com/threestar/trainus/domain/metadata/service/ProfileMetadataServiceTest.java new file mode 100644 index 00000000..e3105193 --- /dev/null +++ b/src/test/java/com/threestar/trainus/domain/metadata/service/ProfileMetadataServiceTest.java @@ -0,0 +1,198 @@ +package com.threestar.trainus.domain.metadata.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.threestar.trainus.domain.metadata.dto.ProfileMetadataResponseDto; +import com.threestar.trainus.domain.metadata.entity.ProfileMetadata; +import com.threestar.trainus.domain.metadata.repository.ProfileMetadataRepository; +import com.threestar.trainus.domain.review.repository.ReviewRepository; +import com.threestar.trainus.domain.user.entity.User; +import com.threestar.trainus.domain.user.entity.UserRole; +import com.threestar.trainus.domain.user.repository.UserRepository; +import com.threestar.trainus.global.exception.domain.ErrorCode; +import com.threestar.trainus.global.exception.handler.BusinessException; + +@ExtendWith(MockitoExtension.class) +class ProfileMetadataServiceTest { + + @InjectMocks + private ProfileMetadataService profileMetadataService; + + @Mock + private ProfileMetadataRepository profileMetadataRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private ReviewRepository reviewRepository; + + @Nested + @DisplayName("배치 메타데이터 업데이트 테스트") + class BatchUpdateMetadataTest { + + @Test + @DisplayName("성공 - 메타데이터 업데이트 필요한 경우") + void batchUpdateMetadata_success_needsUpdate() { + // given + Long userId = 1L; + User user = User.builder() + .id(userId) + .email("test@test.com") + .nickname("테스트유저") + .role(UserRole.USER) + .build(); + + ProfileMetadata metadata = ProfileMetadata.builder() + .id(1L) + .user(user) + .reviewCount(0) + .rating(0.0) + .build(); + + given(profileMetadataRepository.findByUserId(userId)).willReturn(Optional.of(metadata)); + given(reviewRepository.countByRevieweeId(userId)).willReturn(5); + given(reviewRepository.findAverageRatingByRevieweeId(userId)).willReturn(4.2); + + // when + profileMetadataService.batchUpdateMetadata(userId); + + // then + verify(profileMetadataRepository).save(any(ProfileMetadata.class)); + } + + @Test + @DisplayName("성공 - 업데이트 불필요한 경우") + void batchUpdateMetadata_success_noUpdateNeeded() { + // given + Long userId = 1L; + User user = User.builder() + .id(userId) + .email("test@test.com") + .nickname("테스트유저") + .role(UserRole.USER) + .build(); + + ProfileMetadata metadata = ProfileMetadata.builder() + .id(1L) + .user(user) + .reviewCount(5) + .rating(4.2) + .build(); + + given(profileMetadataRepository.findByUserId(userId)).willReturn(Optional.of(metadata)); + given(reviewRepository.countByRevieweeId(userId)).willReturn(5); + given(reviewRepository.findAverageRatingByRevieweeId(userId)).willReturn(4.2); + + // when + profileMetadataService.batchUpdateMetadata(userId); + + // then + verify(profileMetadataRepository, never()).save(any()); + } + + @Test + @DisplayName("실패 - 메타데이터 없음") + void batchUpdateMetadata_fail_metadataNotFound() { + // given + Long userId = 1L; + given(profileMetadataRepository.findByUserId(userId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> profileMetadataService.batchUpdateMetadata(userId)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.METADATA_NOT_FOUND); + } + + @Test + @DisplayName("성공 - 평점이 null인 경우 0.0으로 처리") + void batchUpdateMetadata_success_nullRating() { + // given + Long userId = 1L; + User user = User.builder() + .id(userId) + .email("test@test.com") + .nickname("테스트유저") + .role(UserRole.USER) + .build(); + + ProfileMetadata metadata = ProfileMetadata.builder() + .id(1L) + .user(user) + .reviewCount(5) + .rating(4.0) + .build(); + + given(profileMetadataRepository.findByUserId(userId)).willReturn(Optional.of(metadata)); + given(reviewRepository.countByRevieweeId(userId)).willReturn(0); + given(reviewRepository.findAverageRatingByRevieweeId(userId)).willReturn(null); + + // when + profileMetadataService.batchUpdateMetadata(userId); + + // then + verify(profileMetadataRepository).save(any(ProfileMetadata.class)); + } + } + + @Nested + @DisplayName("메타데이터 조회 테스트") + class GetMetadataTest { + + @Test + @DisplayName("성공 - 메타데이터 조회") + void getMetadata_success() { + // given + Long userId = 1L; + User user = User.builder() + .id(userId) + .email("test@test.com") + .nickname("테스트유저") + .role(UserRole.USER) + .build(); + + ProfileMetadata metadata = ProfileMetadata.builder() + .id(1L) + .user(user) + .reviewCount(10) + .rating(4.5) + .build(); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(profileMetadataRepository.findByUserId(userId)).willReturn(Optional.of(metadata)); + + // when + ProfileMetadataResponseDto result = profileMetadataService.getMetadata(userId); + + // then + assertThat(result).isNotNull(); + } + + @Test + @DisplayName("실패 - 사용자 없음") + void getMetadata_fail_userNotFound() { + // given + Long userId = 1L; + given(userRepository.findById(userId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> profileMetadataService.getMetadata(userId)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.USER_NOT_FOUND); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/threestar/trainus/domain/profile/entity/ProfileTest.java b/src/test/java/com/threestar/trainus/domain/profile/entity/ProfileTest.java new file mode 100644 index 00000000..61053884 --- /dev/null +++ b/src/test/java/com/threestar/trainus/domain/profile/entity/ProfileTest.java @@ -0,0 +1,65 @@ +package com.threestar.trainus.domain.profile.entity; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.threestar.trainus.domain.profile.entity.Profile; +import com.threestar.trainus.domain.user.entity.User; +import com.threestar.trainus.domain.user.entity.UserRole; + +class ProfileTest { + + @Test + @DisplayName("프로필 이미지를 업데이트할 수 있다") + void updateProfileImage_shouldUpdateImage() { + // given + User user = User.builder() + .email("test@example.com") + .password("password") + .nickname("testUser") + .role(UserRole.USER) + .build(); + + Profile profile = Profile.builder() + .user(user) + .profileImage("oldImage.jpg") + .intro("old intro") + .build(); + + String newImage = "newImage.jpg"; + + // when + profile.updateProfileImage(newImage); + + // then + assertThat(profile.getProfileImage()).isEqualTo(newImage); + } + + @Test + @DisplayName("프로필 소개를 업데이트할 수 있다") + void updateProfileIntro_shouldUpdateIntro() { + // given + User user = User.builder() + .email("test@example.com") + .password("password") + .nickname("testUser") + .role(UserRole.USER) + .build(); + + Profile profile = Profile.builder() + .user(user) + .profileImage("image.jpg") + .intro("old intro") + .build(); + + String newIntro = "new introduction"; + + // when + profile.updateProfileIntro(newIntro); + + // then + assertThat(profile.getIntro()).isEqualTo(newIntro); + } +} \ No newline at end of file diff --git a/src/test/java/com/threestar/trainus/domain/review/controller/ReviewControllerTest.java b/src/test/java/com/threestar/trainus/domain/review/controller/ReviewControllerTest.java index c4d39750..6a5dc45b 100644 --- a/src/test/java/com/threestar/trainus/domain/review/controller/ReviewControllerTest.java +++ b/src/test/java/com/threestar/trainus/domain/review/controller/ReviewControllerTest.java @@ -124,10 +124,7 @@ void createReview() throws Exception { .rating(0.0D) .build()); - ReviewCreateRequestDto request = new ReviewCreateRequestDto(); - request.setContent("테스트 리뷰1"); - request.setRating(3.5D); - request.setReviewImage("https://example.com/image.png"); + ReviewCreateRequestDto request = new ReviewCreateRequestDto("테스트 리뷰1", 3.5D,"https://example.com/image.png" ); MockHttpSession session = new MockHttpSession(); session.setAttribute("LOGIN_USER", reviewer1.getId()); @@ -155,10 +152,7 @@ void createReview() throws Exception { Assertions.assertEquals(1, profileMetadata.getReviewCount()); Assertions.assertEquals(3.5D, profileMetadata.getRating()); - ReviewCreateRequestDto request2 = new ReviewCreateRequestDto(); - request2.setContent("테스트 리뷰2"); - request2.setRating(4.5D); - request2.setReviewImage("https://example.com/image.png"); + ReviewCreateRequestDto request2 = new ReviewCreateRequestDto("테스트 리뷰2", 4.5D, "https://example.com/image.png"); session.setAttribute("LOGIN_USER", reviewer2.getId()); diff --git a/src/test/java/com/threestar/trainus/domain/user/entity/UserTest.java b/src/test/java/com/threestar/trainus/domain/user/entity/UserTest.java new file mode 100644 index 00000000..3f0efd51 --- /dev/null +++ b/src/test/java/com/threestar/trainus/domain/user/entity/UserTest.java @@ -0,0 +1,70 @@ +package com.threestar.trainus.domain.user.entity; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.threestar.trainus.domain.user.entity.User; +import com.threestar.trainus.domain.user.entity.UserRole; + +class UserTest { + + // @Test + // @DisplayName("사용자 탈퇴 시 deletedAt이 설정된다") + // void withdraw_shouldSetDeletedAt() { + // // given + // User user = User.builder() + // .email("test@example.com") + // .password("encodedPassword") + // .nickname("testUser") + // .role(UserRole.USER) + // .build(); + // + // // when + // LocalDateTime beforeWithdraw = LocalDateTime.now(); + // user.withdraw(); + // LocalDateTime afterWithdraw = LocalDateTime.now(); + // + // // then + // assertThat(user.getDeletedAt()).isNotNull(); + // assertThat(user.getDeletedAt()).isBetween(beforeWithdraw, afterWithdraw); + // } + + @Test + @DisplayName("비밀번호 업데이트가 정상적으로 동작한다") + void updatePassword_shouldUpdatePassword() { + // given + User user = User.builder() + .email("test@example.com") + .password("oldPassword") + .nickname("testUser") + .role(UserRole.USER) + .build(); + + String newPassword = "newEncodedPassword"; + + // when + user.updatePassword(newPassword); + + // then + assertThat(user.getPassword()).isEqualTo(newPassword); + } + + @Test + @DisplayName("사용자가 ADMIN 역할을 가질 수 있다") + void user_canHaveAdminRole() { + // given & when + User adminUser = User.builder() + .email("admin@example.com") + .password("adminPassword") + .nickname("admin") + .role(UserRole.ADMIN) + .build(); + + // then + assertThat(adminUser.getRole()).isEqualTo(UserRole.ADMIN); + } +} \ No newline at end of file diff --git a/src/test/java/com/threestar/trainus/domain/user/service/UserServiceTest.java b/src/test/java/com/threestar/trainus/domain/user/service/UserServiceTest.java new file mode 100644 index 00000000..d9e07560 --- /dev/null +++ b/src/test/java/com/threestar/trainus/domain/user/service/UserServiceTest.java @@ -0,0 +1,336 @@ +package com.threestar.trainus.domain.user.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +import com.threestar.trainus.domain.profile.service.ProfileFacadeService; +import com.threestar.trainus.domain.user.dto.LoginRequestDto; +import com.threestar.trainus.domain.user.dto.LoginResponseDto; +import com.threestar.trainus.domain.user.dto.PasswordUpdateDto; +import com.threestar.trainus.domain.user.dto.SignupRequestDto; +import com.threestar.trainus.domain.user.dto.SignupResponseDto; +import com.threestar.trainus.domain.user.entity.User; +import com.threestar.trainus.domain.user.entity.UserRole; +import com.threestar.trainus.domain.user.repository.UserRepository; +import com.threestar.trainus.global.exception.domain.ErrorCode; +import com.threestar.trainus.global.exception.handler.BusinessException; + +import jakarta.servlet.http.HttpSession; + +@ExtendWith(MockitoExtension.class) +@DisplayName("UserService 테스트") +class UserServiceTest { + + @InjectMocks + private UserService userService; + + @Mock + private UserRepository userRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @Mock + private ProfileFacadeService facadeService; + + @Mock + private EmailVerificationService emailVerificationService; + + @Mock + private HttpSession session; + + @Nested + @DisplayName("회원가입") + class SignupTest { + + @Test + @DisplayName("성공 - 정상적인 회원가입") + void signup_success() { + // given + SignupRequestDto request = new SignupRequestDto("test@email.com", "password123", "testUser"); + String encodedPassword = "encodedPassword"; + User savedUser = User.builder() + .id(1L) + .email("test@email.com") + .password(encodedPassword) + .nickname("testUser") + .role(UserRole.USER) + .build(); + + given(emailVerificationService.isEmailVerified(request.email())).willReturn(true); + given(userRepository.existsByEmail(request.email())).willReturn(false); + given(userRepository.existsByNickname(request.nickname())).willReturn(false); + given(passwordEncoder.encode(request.password())).willReturn(encodedPassword); + given(userRepository.save(any(User.class))).willReturn(savedUser); + + // when + SignupResponseDto result = userService.signup(request); + + // then + assertThat(result).isNotNull(); + assertThat(result.id()).isEqualTo(1L); + assertThat(result.email()).isEqualTo("test@email.com"); + assertThat(result.nickname()).isEqualTo("testUser"); + assertThat(result.userRole()).isEqualTo(UserRole.USER); + + verify(facadeService).createDefaultProfile(savedUser); + } + + @Test + @DisplayName("실패 - 이메일 인증 안됨") + void signup_fail_emailNotVerified() { + // given + SignupRequestDto request = new SignupRequestDto("test@email.com", "password123", "testUser"); + given(emailVerificationService.isEmailVerified(request.email())).willReturn(false); + + // when & then + assertThatThrownBy(() -> userService.signup(request)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.EMAIL_NOT_VERIFIED); + } + + @Test + @DisplayName("실패 - 이메일 중복") + void signup_fail_emailExists() { + // given + SignupRequestDto request = new SignupRequestDto("test@email.com", "password123", "testUser"); + given(emailVerificationService.isEmailVerified(request.email())).willReturn(true); + given(userRepository.existsByEmail(request.email())).willReturn(true); + + // when & then + assertThatThrownBy(() -> userService.signup(request)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.EMAIL_ALREADY_EXISTS); + } + + @Test + @DisplayName("실패 - 닉네임 중복") + void signup_fail_nicknameExists() { + // given + SignupRequestDto request = new SignupRequestDto("test@email.com", "password123", "testUser"); + given(emailVerificationService.isEmailVerified(request.email())).willReturn(true); + given(userRepository.existsByEmail(request.email())).willReturn(false); + given(userRepository.existsByNickname(request.nickname())).willReturn(true); + + // when & then + assertThatThrownBy(() -> userService.signup(request)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.NICKNAME_ALREADY_EXISTS); + } + } + + @Nested + @DisplayName("로그인") + class LoginTest { + + @Test + @DisplayName("성공 - 정상적인 로그인") + void login_success() { + // given + LoginRequestDto request = new LoginRequestDto("test@email.com", "password123"); + User user = User.builder() + .id(1L) + .email("test@email.com") + .password("encodedPassword") + .nickname("testUser") + .role(UserRole.USER) + .build(); + + given(userRepository.findByEmail(request.email())).willReturn(Optional.of(user)); + given(passwordEncoder.matches(request.password(), user.getPassword())).willReturn(true); + + // when + LoginResponseDto result = userService.login(request, session); + + // then + assertThat(result).isNotNull(); + assertThat(result.id()).isEqualTo(1L); + assertThat(result.email()).isEqualTo("test@email.com"); + assertThat(result.nickname()).isEqualTo("testUser"); + + verify(session).setAttribute("LOGIN_USER", 1L); + } + + @Test + @DisplayName("실패 - 존재하지 않는 이메일") + void login_fail_userNotFound() { + // given + LoginRequestDto request = new LoginRequestDto("test@email.com", "password123"); + given(userRepository.findByEmail(request.email())).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> userService.login(request, session)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_CREDENTIALS); + } + + @Test + @DisplayName("실패 - 비밀번호 불일치") + void login_fail_passwordMismatch() { + // given + LoginRequestDto request = new LoginRequestDto("test@email.com", "wrongPassword"); + User user = User.builder() + .email("test@email.com") + .password("encodedPassword") + .build(); + + given(userRepository.findByEmail(request.email())).willReturn(Optional.of(user)); + given(passwordEncoder.matches(request.password(), user.getPassword())).willReturn(false); + + // when & then + assertThatThrownBy(() -> userService.login(request, session)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_CREDENTIALS); + } + } + + @Nested + @DisplayName("비밀번호 변경") + class UpdatePasswordTest { + + @Test + @DisplayName("성공 - 정상적인 비밀번호 변경") + void updatePassword_success() { + // given + Long userId = 1L; + PasswordUpdateDto request = new PasswordUpdateDto("currentPwd", "newPwd123", "newPwd123"); + User user = User.builder() + .id(userId) + .password("encodedCurrentPwd") + .build(); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(passwordEncoder.matches(request.currentPassword(), user.getPassword())).willReturn(true); + given(passwordEncoder.encode(request.newPassword())).willReturn("encodedNewPwd"); + + // when + userService.updatePassword(request, userId); + + // then + verify(userRepository).save(user); + verify(passwordEncoder).encode(request.newPassword()); + } + + @Test + @DisplayName("실패 - 새 비밀번호 확인 불일치") + void updatePassword_fail_confirmPasswordMismatch() { + // given + Long userId = 1L; + PasswordUpdateDto request = new PasswordUpdateDto("currentPwd", "newPwd123", "differentPwd"); + + // when & then + assertThatThrownBy(() -> userService.updatePassword(request, userId)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_REQUEST_DATA); + } + + @Test + @DisplayName("실패 - 현재 비밀번호 불일치") + void updatePassword_fail_currentPasswordMismatch() { + // given + Long userId = 1L; + PasswordUpdateDto request = new PasswordUpdateDto("wrongCurrentPwd", "newPwd123", "newPwd123"); + User user = User.builder() + .id(userId) + .password("encodedCurrentPwd") + .build(); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(passwordEncoder.matches(request.currentPassword(), user.getPassword())).willReturn(false); + + // when & then + assertThatThrownBy(() -> userService.updatePassword(request, userId)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_REQUEST_DATA); + } + } + + @Nested + @DisplayName("닉네임 중복 체크") + class CheckNicknameTest { + + @Test + @DisplayName("성공 - 사용 가능한 닉네임") + void checkNickname_success() { + // given + String nickname = "availableNickname"; + given(userRepository.existsByNickname(nickname)).willReturn(false); + + // when & then + assertThatNoException().isThrownBy(() -> userService.checkNickname(nickname)); + } + + @Test + @DisplayName("실패 - 이미 존재하는 닉네임") + void checkNickname_fail_exists() { + // given + String nickname = "existingNickname"; + given(userRepository.existsByNickname(nickname)).willReturn(true); + + // when & then + assertThatThrownBy(() -> userService.checkNickname(nickname)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.NICKNAME_ALREADY_EXISTS); + } + } + + @Nested + @DisplayName("관리자 권한 검증") + class AdminValidationTest { + + @Test + @DisplayName("성공 - 관리자 사용자") + void validateAdminRole_success() { + // given + Long userId = 1L; + User adminUser = User.builder() + .id(userId) + .role(UserRole.ADMIN) + .build(); + + given(userRepository.findById(userId)).willReturn(Optional.of(adminUser)); + + // when & then + assertThatNoException().isThrownBy(() -> userService.validateAdminRole(userId)); + } + + @Test + @DisplayName("실패 - 일반 사용자") + void validateAdminRole_fail_notAdmin() { + // given + Long userId = 1L; + User normalUser = User.builder() + .id(userId) + .role(UserRole.USER) + .build(); + + given(userRepository.findById(userId)).willReturn(Optional.of(normalUser)); + + // when & then + assertThatThrownBy(() -> userService.validateAdminRole(userId)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.AUTHENTICATION_REQUIRED); + } + } +} \ No newline at end of file