Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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")
Expand All @@ -56,15 +56,18 @@ public ResponseEntity<BaseResponse<CouponCreateResponseDto>> createCoupon(
@GetMapping
@Operation(summary = "쿠폰 목록 조회", description = "관리자가 쿠폰 목록을 조회")
public ResponseEntity<PagedResponse<CouponListWrapperDto>> 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);

Expand Down
Original file line number Diff line number Diff line change
@@ -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<Coupon> 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<Coupon> 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<UserCoupon> 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());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -123,13 +123,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);

Expand Down Expand Up @@ -338,7 +338,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로 쿠폰을 조회하는 공통 메서드
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,25 @@ Page<Coupon> 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<Coupon> 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<Coupon> findActiveCouponsToDeactivate(@Param("now") LocalDateTime now);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.threestar.trainus.domain.coupon.user.repository;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;

Expand Down Expand Up @@ -27,4 +28,14 @@ List<UserCoupon> 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<UserCoupon> findActiveUserCouponsToExpire(@Param("now") LocalDateTime now);

}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -221,4 +221,10 @@ public void updateAddressDetail(String addressDetail) {
this.addressDetail = addressDetail;
}
}

//레슨 상태 수정->스케쥴러꺼
public void updateStatus(LessonStatus status) {
this.status = status;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,14 @@
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<LessonApplication, Long> {

// 특정 레슨의 신청자 목록 조회(페이징)
Page<LessonApplication> findByLesson(Lesson lesson, Pageable pageable);

//특정 레슨의 특정 상태 신청자 목록 조회(페이징)
Page<LessonApplication> findByLessonAndStatus(Lesson lesson, ApplicationStatus status, Pageable pageable);

// 신청 이력 확인
boolean existsByLessonIdAndUserId(Long lessonId, Long userId);

Expand All @@ -27,7 +22,46 @@ public interface LessonApplicationRepository extends JpaRepository<LessonApplica
Page<LessonApplication> findByUserId(Long userId, Pageable pageable);

Page<LessonApplication> findByUserIdAndStatus(Long userId, ApplicationStatus status, Pageable pageable);
}

int countByLessonAndStatus(Lesson lesson, ApplicationStatus status);

// 모든 상태 신청자 조회
@Query("""
SELECT la FROM LessonApplication la
JOIN FETCH la.user u
JOIN FETCH u.profile
WHERE la.lesson = :lesson
ORDER BY la.createdAt DESC
""")
Page<LessonApplication> findByLessonWithUserAndProfile(
@Param("lesson") Lesson lesson,
Pageable pageable
);

// 특정 상태 신청자 조회
@Query("""
SELECT la FROM LessonApplication la
JOIN FETCH la.user u
JOIN FETCH u.profile
WHERE la.lesson = :lesson AND la.status = :status
ORDER BY la.createdAt DESC
""")
Page<LessonApplication> findByLessonAndStatusWithUserAndProfile(
@Param("lesson") Lesson lesson,
@Param("status") ApplicationStatus status,
Pageable pageable
);

// 승인된 참가자 조회 - 참가자 목록용
@Query("""
SELECT la FROM LessonApplication la
JOIN FETCH la.user u
JOIN FETCH u.profile
WHERE la.lesson = :lesson AND la.status = 'APPROVED'
ORDER BY la.createdAt ASC
""")
Page<LessonApplication> findApprovedParticipantsWithUserAndProfile(
@Param("lesson") Lesson lesson,
Pageable pageable
);
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -58,10 +59,10 @@ boolean hasTimeConflictForUpdate(
@Param("excludeLessonId") Long excludeLessonId
);

// 강사가 개설한 레슨 목록 조회 (페이징)
// 강사가 개설한 레슨 전체 목록 상태에 따라 필터링해서 조회(모집중인것만...이런식으로)
Page<Lesson> findByLessonLeaderAndDeletedAtIsNull(Long lessonLeader, Pageable pageable);

// 강사가 개설한 레슨 목록 조회 (페이징+필터링)
// 강사가 개설한 레슨 전체 목록 조회
Page<Lesson> findByLessonLeaderAndStatusAndDeletedAtIsNull(Long lessonLeader, LessonStatus status,
Pageable pageable);

Expand All @@ -87,6 +88,29 @@ Page<Lesson> findBySearchConditions(
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<Lesson> 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<Lesson> findLessonsToComplete(@Param("now") LocalDateTime now);

@Lock(LockModeType.PESSIMISTIC_WRITE) // 비관적 락 적용
@Query("SELECT l FROM Lesson l WHERE l.id = :lessonId")
Optional<Lesson> findByIdWithLock(@Param("lessonId") Long lessonId);
Expand Down
Loading
Loading