diff --git a/src/main/java/com/example/eightyage/domain/coupon/controller/CouponController.java b/src/main/java/com/example/eightyage/domain/coupon/controller/CouponController.java index cdc3b69..5757dab 100644 --- a/src/main/java/com/example/eightyage/domain/coupon/controller/CouponController.java +++ b/src/main/java/com/example/eightyage/domain/coupon/controller/CouponController.java @@ -1,12 +1,13 @@ package com.example.eightyage.domain.coupon.controller; +import com.example.eightyage.domain.coupon.dto.request.CouponRequestDto; import com.example.eightyage.domain.coupon.dto.response.CouponResponseDto; import com.example.eightyage.domain.coupon.service.CouponService; -import com.example.eightyage.global.dto.AuthUser; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; @RestController @@ -16,21 +17,25 @@ public class CouponController { private final CouponService couponService; - @PostMapping("/v1/events/{eventId}/coupons") - public ResponseEntity issueCoupon(@AuthenticationPrincipal AuthUser authUser, @PathVariable Long eventId) { - return ResponseEntity.ok(couponService.issueCoupon(authUser, eventId)); + @PreAuthorize("hasRole('ADMIN')") + @PostMapping("/v1/coupons") + public ResponseEntity createCoupon(@Valid @RequestBody CouponRequestDto couponRequestDto) { + return ResponseEntity.ok(couponService.saveCoupon(couponRequestDto)); } - @GetMapping("/v1/coupons/my") - public ResponseEntity> getMyCoupons( - @AuthenticationPrincipal AuthUser authUser, - @RequestParam(defaultValue = "1") int page, - @RequestParam(defaultValue = "10") int size) { - return ResponseEntity.ok(couponService.getMyCoupons(authUser, page, size)); + @GetMapping("/v1/coupons") + public ResponseEntity> getCoupons(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size) { + return ResponseEntity.ok(couponService.getCoupons(page, size)); } @GetMapping("/v1/coupons/{couponId}") - public ResponseEntity getCoupon(@AuthenticationPrincipal AuthUser authUser,@PathVariable Long couponId) { - return ResponseEntity.ok(couponService.getCoupon(authUser, couponId)); + public ResponseEntity getCoupon(@PathVariable long couponId) { + return ResponseEntity.ok(couponService.getCoupon(couponId)); + } + + @PreAuthorize("hasRole('ADMIN')") + @PatchMapping("/v1/coupons/{couponId}") + public ResponseEntity updateCoupon(@PathVariable long couponId, @Valid @RequestBody CouponRequestDto couponRequestDto) { + return ResponseEntity.ok(couponService.updateCoupon(couponId, couponRequestDto)); } } diff --git a/src/main/java/com/example/eightyage/domain/coupon/controller/IssuedCouponController.java b/src/main/java/com/example/eightyage/domain/coupon/controller/IssuedCouponController.java new file mode 100644 index 0000000..d37aeea --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/coupon/controller/IssuedCouponController.java @@ -0,0 +1,36 @@ +package com.example.eightyage.domain.coupon.controller; + +import com.example.eightyage.domain.coupon.dto.response.IssuedCouponResponseDto; +import com.example.eightyage.domain.coupon.service.IssuedCouponService; +import com.example.eightyage.global.dto.AuthUser; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class IssuedCouponController { + + private final IssuedCouponService issuedCouponService; + + @PostMapping("/v1/coupons/{couponId}/issues") + public ResponseEntity issueCoupon(@AuthenticationPrincipal AuthUser authUser, @PathVariable Long couponId) { + return ResponseEntity.ok(issuedCouponService.issueCoupon(authUser, couponId)); + } + + @GetMapping("/v1/coupons/my") + public ResponseEntity> getMyCoupons( + @AuthenticationPrincipal AuthUser authUser, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size) { + return ResponseEntity.ok(issuedCouponService.getMyCoupons(authUser, page, size)); + } + + @GetMapping("/v1/coupons/{issuedCouponId}") + public ResponseEntity getCoupon(@AuthenticationPrincipal AuthUser authUser, @PathVariable Long issuedCouponId) { + return ResponseEntity.ok(issuedCouponService.getCoupon(authUser, issuedCouponId)); + } +} diff --git a/src/main/java/com/example/eightyage/domain/coupon/couponstate/CouponState.java b/src/main/java/com/example/eightyage/domain/coupon/couponstate/CouponState.java new file mode 100644 index 0000000..1106d56 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/coupon/couponstate/CouponState.java @@ -0,0 +1,6 @@ +package com.example.eightyage.domain.coupon.couponstate; + +public enum CouponState { + VALID, + INVALID +} diff --git a/src/main/java/com/example/eightyage/domain/coupon/dto/request/.gitkeep b/src/main/java/com/example/eightyage/domain/coupon/dto/request/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/example/eightyage/domain/event/dto/request/EventRequestDto.java b/src/main/java/com/example/eightyage/domain/coupon/dto/request/CouponRequestDto.java similarity index 90% rename from src/main/java/com/example/eightyage/domain/event/dto/request/EventRequestDto.java rename to src/main/java/com/example/eightyage/domain/coupon/dto/request/CouponRequestDto.java index 381bd19..6615147 100644 --- a/src/main/java/com/example/eightyage/domain/event/dto/request/EventRequestDto.java +++ b/src/main/java/com/example/eightyage/domain/coupon/dto/request/CouponRequestDto.java @@ -1,4 +1,4 @@ -package com.example.eightyage.domain.event.dto.request; +package com.example.eightyage.domain.coupon.dto.request; import com.example.eightyage.global.dto.ValidationMessage; import jakarta.validation.constraints.Min; @@ -13,7 +13,7 @@ @Getter @NoArgsConstructor @AllArgsConstructor -public class EventRequestDto { +public class CouponRequestDto { @NotBlank(message = ValidationMessage.NOT_BLANK_EVENT_NAME) private String name; diff --git a/src/main/java/com/example/eightyage/domain/coupon/dto/response/CouponResponseDto.java b/src/main/java/com/example/eightyage/domain/coupon/dto/response/CouponResponseDto.java index d1eca80..3bb6bd8 100644 --- a/src/main/java/com/example/eightyage/domain/coupon/dto/response/CouponResponseDto.java +++ b/src/main/java/com/example/eightyage/domain/coupon/dto/response/CouponResponseDto.java @@ -1,6 +1,6 @@ package com.example.eightyage.domain.coupon.dto.response; -import com.example.eightyage.domain.coupon.entity.CouponState; +import com.example.eightyage.domain.coupon.couponstate.CouponState; import lombok.Getter; import java.time.LocalDateTime; @@ -8,22 +8,20 @@ @Getter public class CouponResponseDto { - private final String couponCode; + private final String name; + private final String description; + private final int quantity; + private final LocalDateTime startDate; + private final LocalDateTime endDate; private final CouponState state; - private final String username; - private final String eventname; - private final LocalDateTime startAt; - private final LocalDateTime endAt; - public CouponResponseDto(String couponCode, CouponState state, - String username, String eventname, - LocalDateTime startAt, LocalDateTime endAt) { - this.couponCode = couponCode; + public CouponResponseDto(String name, String description, int quantity, LocalDateTime startDate, LocalDateTime endDate, CouponState state) { + this.name = name; + this.description = description; + this.quantity = quantity; + this.startDate = startDate; + this.endDate = endDate; this.state = state; - this.username = username; - this.eventname = eventname; - this.startAt = startAt; - this.endAt = endAt; } } diff --git a/src/main/java/com/example/eightyage/domain/coupon/dto/response/IssuedCouponResponseDto.java b/src/main/java/com/example/eightyage/domain/coupon/dto/response/IssuedCouponResponseDto.java new file mode 100644 index 0000000..c97fb99 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/coupon/dto/response/IssuedCouponResponseDto.java @@ -0,0 +1,29 @@ +package com.example.eightyage.domain.coupon.dto.response; + +import com.example.eightyage.domain.coupon.status.Status; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class IssuedCouponResponseDto { + + private final String serialCode; + private final Status status; + private final String username; + private final String eventname; + + private final LocalDateTime startAt; + private final LocalDateTime endAt; + + public IssuedCouponResponseDto(String serialCode, Status status, + String username, String eventname, + LocalDateTime startAt, LocalDateTime endAt) { + this.serialCode = serialCode; + this.status = status; + this.username = username; + this.eventname = eventname; + this.startAt = startAt; + this.endAt = endAt; + } +} diff --git a/src/main/java/com/example/eightyage/domain/coupon/entity/Coupon.java b/src/main/java/com/example/eightyage/domain/coupon/entity/Coupon.java index 76b1b10..8699d08 100644 --- a/src/main/java/com/example/eightyage/domain/coupon/entity/Coupon.java +++ b/src/main/java/com/example/eightyage/domain/coupon/entity/Coupon.java @@ -1,56 +1,66 @@ package com.example.eightyage.domain.coupon.entity; +import com.example.eightyage.domain.coupon.couponstate.CouponState; +import com.example.eightyage.domain.coupon.dto.request.CouponRequestDto; import com.example.eightyage.domain.coupon.dto.response.CouponResponseDto; -import com.example.eightyage.domain.event.entity.Event; -import com.example.eightyage.domain.user.entity.User; import com.example.eightyage.global.entity.TimeStamped; -import com.example.eightyage.global.util.RandomCodeGenerator; import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import java.time.LocalDateTime; + @Entity -@Builder @Getter @NoArgsConstructor -@AllArgsConstructor public class Coupon extends TimeStamped { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - - @Column(unique = true) - private String couponCode; - + private String name; + private String description; + private int quantity; + @Column(name="start_at") + private LocalDateTime startDate; + @Column(name = "end_at") + private LocalDateTime endDate; @Enumerated(EnumType.STRING) private CouponState state; - @ManyToOne(fetch = FetchType.LAZY) - private User user; - - @ManyToOne(fetch = FetchType.LAZY) - private Event event; - - public static Coupon create(User user, Event event) { - return Coupon.builder() - .couponCode(RandomCodeGenerator.generateCouponCode(10)) - .state(CouponState.VALID) - .user(user) - .event(event) - .build(); + public Coupon(String name, String description, int quantity, LocalDateTime startDate, LocalDateTime endDate) { + this.name = name; + this.description = description; + this.quantity = quantity; + this.startDate = startDate; + this.endDate = endDate; } public CouponResponseDto toDto() { return new CouponResponseDto( - this.couponCode, - this.state, - this.user.getNickname(), - this.event.getName(), - this.event.getStartDate(), - this.event.getEndDate() + this.getName(), + this.getDescription(), + this.getQuantity(), + this.getStartDate(), + this.getEndDate(), + this.getState() ); } + + public void update(CouponRequestDto couponRequestDto) { + this.name = couponRequestDto.getName(); + this.description = couponRequestDto.getDescription(); + this.quantity = couponRequestDto.getQuantity(); + this.startDate = couponRequestDto.getStartDate(); + this.endDate = couponRequestDto.getEndDate(); + } + + public boolean isValidAt(LocalDateTime time) { + return (startDate.isBefore(time) || startDate.isEqual(time)) && (endDate.isAfter(time) || endDate.isEqual(time)); + } + + public void updateStateAt(LocalDateTime time) { + CouponState newState = isValidAt(time) ? CouponState.VALID : CouponState.INVALID; + this.state = newState; + } } diff --git a/src/main/java/com/example/eightyage/domain/coupon/entity/CouponState.java b/src/main/java/com/example/eightyage/domain/coupon/entity/CouponState.java deleted file mode 100644 index 221a935..0000000 --- a/src/main/java/com/example/eightyage/domain/coupon/entity/CouponState.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.example.eightyage.domain.coupon.entity; - -public enum CouponState { - VALID, - INVALID -} diff --git a/src/main/java/com/example/eightyage/domain/coupon/entity/IssuedCoupon.java b/src/main/java/com/example/eightyage/domain/coupon/entity/IssuedCoupon.java new file mode 100644 index 0000000..0926367 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/coupon/entity/IssuedCoupon.java @@ -0,0 +1,56 @@ +package com.example.eightyage.domain.coupon.entity; + +import com.example.eightyage.domain.coupon.dto.response.IssuedCouponResponseDto; +import com.example.eightyage.domain.coupon.status.Status; +import com.example.eightyage.domain.user.entity.User; +import com.example.eightyage.global.entity.TimeStamped; +import com.example.eightyage.global.util.RandomCodeGenerator; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class IssuedCoupon extends TimeStamped { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true) + private String serialCode; + + @Enumerated(EnumType.STRING) + private Status status; + + @ManyToOne(fetch = FetchType.LAZY) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + private Coupon coupon; + + public static IssuedCoupon create(User user, Coupon coupon) { + return IssuedCoupon.builder() + .serialCode(RandomCodeGenerator.generateCouponCode(10)) + .status(Status.VALID) + .user(user) + .coupon(coupon) + .build(); + } + + public IssuedCouponResponseDto toDto() { + return new IssuedCouponResponseDto( + this.serialCode, + this.status, + this.user.getNickname(), + this.coupon.getName(), + this.coupon.getStartDate(), + this.coupon.getEndDate() + ); + } +} diff --git a/src/main/java/com/example/eightyage/domain/coupon/repository/CouponRepository.java b/src/main/java/com/example/eightyage/domain/coupon/repository/CouponRepository.java index a6c5dbd..d617d00 100644 --- a/src/main/java/com/example/eightyage/domain/coupon/repository/CouponRepository.java +++ b/src/main/java/com/example/eightyage/domain/coupon/repository/CouponRepository.java @@ -1,16 +1,9 @@ package com.example.eightyage.domain.coupon.repository; import com.example.eightyage.domain.coupon.entity.Coupon; -import com.example.eightyage.domain.coupon.entity.CouponState; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; -import java.util.List; - @Repository public interface CouponRepository extends JpaRepository { - boolean existsByUserIdAndEventId(Long userId, Long eventId); - Page findAllByUserIdAndState(Long userId, CouponState state, Pageable pageable); } diff --git a/src/main/java/com/example/eightyage/domain/coupon/repository/IssuedCouponRepository.java b/src/main/java/com/example/eightyage/domain/coupon/repository/IssuedCouponRepository.java new file mode 100644 index 0000000..e7b97da --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/coupon/repository/IssuedCouponRepository.java @@ -0,0 +1,14 @@ +package com.example.eightyage.domain.coupon.repository; + +import com.example.eightyage.domain.coupon.entity.IssuedCoupon; +import com.example.eightyage.domain.coupon.status.Status; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface IssuedCouponRepository extends JpaRepository { + boolean existsByUserIdAndCouponId(Long userId, Long couponId); + Page findAllByUserIdAndStatus(Long userId, Status status, Pageable pageable); +} diff --git a/src/main/java/com/example/eightyage/domain/coupon/service/CouponService.java b/src/main/java/com/example/eightyage/domain/coupon/service/CouponService.java index 7f45cde..b71ddd1 100644 --- a/src/main/java/com/example/eightyage/domain/coupon/service/CouponService.java +++ b/src/main/java/com/example/eightyage/domain/coupon/service/CouponService.java @@ -1,103 +1,97 @@ package com.example.eightyage.domain.coupon.service; +import com.example.eightyage.domain.coupon.dto.request.CouponRequestDto; import com.example.eightyage.domain.coupon.dto.response.CouponResponseDto; import com.example.eightyage.domain.coupon.entity.Coupon; -import com.example.eightyage.domain.coupon.entity.CouponState; +import com.example.eightyage.domain.coupon.couponstate.CouponState; import com.example.eightyage.domain.coupon.repository.CouponRepository; -import com.example.eightyage.domain.event.entity.Event; -import com.example.eightyage.domain.event.service.EventService; -import com.example.eightyage.domain.user.entity.User; -import com.example.eightyage.global.dto.AuthUser; import com.example.eightyage.global.exception.BadRequestException; import com.example.eightyage.global.exception.ErrorMessage; -import com.example.eightyage.global.exception.ForbiddenException; -import com.example.eightyage.global.exception.NotFoundException; import lombok.RequiredArgsConstructor; -import org.redisson.api.RLock; -import org.redisson.api.RedissonClient; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; -import java.util.concurrent.TimeUnit; +import java.time.LocalDateTime; @Service @RequiredArgsConstructor public class CouponService { private final CouponRepository couponRepository; - private final EventService eventService; private final StringRedisTemplate stringRedisTemplate; - private static final String EVENT_QUANTITIY_PREFIX = "event:quantity:"; - private static final String EVENT_LOCK_PREFIX = "event:lock:"; - private final RedissonClient redissonClient; + public CouponResponseDto saveCoupon(CouponRequestDto couponRequestDto) { + Coupon coupon = new Coupon( + couponRequestDto.getName(), + couponRequestDto.getDescription(), + couponRequestDto.getQuantity(), + couponRequestDto.getStartDate(), + couponRequestDto.getEndDate() + ); - public CouponResponseDto issueCoupon(AuthUser authUser, Long eventId) { + checkCouponState(coupon); - RLock rLock = redissonClient.getLock(EVENT_LOCK_PREFIX + eventId); - boolean isLocked = false; + Coupon savedCoupon = couponRepository.save(coupon); - try { - isLocked = rLock.tryLock(3, 10, TimeUnit.SECONDS); // 3초 안에 락을 획득, 10초 뒤에는 자동 해제 + stringRedisTemplate.opsForValue().set("event:quantity:" + savedCoupon.getId(), String.valueOf(savedCoupon.getQuantity())); - if (!isLocked) { - throw new BadRequestException(ErrorMessage.CAN_NOT_ACCESS.getMessage()); // 락 획득 실패 - } + return savedCoupon.toDto(); + } - Event event = eventService.getValidEventOrThrow(eventId); + public Page getCoupons(int page, int size) { + Pageable pageable = PageRequest.of(page-1, size); + Page events = couponRepository.findAll(pageable); - if (couponRepository.existsByUserIdAndEventId(authUser.getUserId(), eventId)) { - throw new BadRequestException(ErrorMessage.COUPON_ALREADY_ISSUED.getMessage()); - } + // 모든 events들 checkState로 state 상태 갱신하기 + events.forEach(this::checkCouponState); - Long remain = Long.parseLong(stringRedisTemplate.opsForValue().get(EVENT_QUANTITIY_PREFIX + eventId)); - if (remain == 0 || remain < 0) { - throw new BadRequestException(ErrorMessage.COUPON_OUT_OF_STOCK.getMessage()); - } - stringRedisTemplate.opsForValue().decrement(EVENT_QUANTITIY_PREFIX + eventId); + return events.map(Coupon::toDto); + } - // 쿠폰 발급 및 저장 - Coupon coupon = Coupon.create(User.fromAuthUser(authUser),event); - couponRepository.save(coupon); + public CouponResponseDto getCoupon(long couponId) { + Coupon coupon = findByIdOrElseThrow(couponId); - return coupon.toDto(); + checkCouponState(coupon); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new BadRequestException(ErrorMessage.INTERNAL_SERVER_ERROR.getMessage()); - } finally { - if (isLocked) { - rLock.unlock(); - } - } + return coupon.toDto(); } - public Page getMyCoupons(AuthUser authUser, int page, int size) { - Pageable pageable = PageRequest.of(page-1, size); - Page coupons = couponRepository.findAllByUserIdAndState(authUser.getUserId(), CouponState.VALID, pageable); + public CouponResponseDto updateCoupon(long couponId, CouponRequestDto couponRequestDto) { + Coupon coupon = findByIdOrElseThrow(couponId); + + coupon.update(couponRequestDto); - return coupons.map(Coupon::toDto); + checkCouponState(coupon); + + return coupon.toDto(); } - public CouponResponseDto getCoupon(AuthUser authUser, Long couponId) { - Coupon coupon = findByIdOrElseThrow(couponId); + private void checkCouponState(Coupon coupon) { + CouponState prevState = coupon.getState(); + coupon.updateStateAt(LocalDateTime.now()); - if(!coupon.getUser().equals(User.fromAuthUser(authUser))) { - throw new ForbiddenException(ErrorMessage.COUPON_FORBIDDEN.getMessage()); + if(coupon.getState() != prevState) { + couponRepository.save(coupon); } + } - if(!coupon.getState().equals(CouponState.VALID)) { - throw new BadRequestException(ErrorMessage.COUPON_ALREADY_USED.getMessage()); + public Coupon getValidCouponOrThrow(Long couponId) { + Coupon coupon = findByIdOrElseThrow(couponId); + + coupon.updateStateAt(LocalDateTime.now()); + + if(coupon.getState() != CouponState.VALID) { + throw new BadRequestException(ErrorMessage.INVALID_EVENT_PERIOD.getMessage()); } - return coupon.toDto(); + return coupon; } public Coupon findByIdOrElseThrow(Long couponId) { return couponRepository.findById(couponId) - .orElseThrow(() -> new NotFoundException(ErrorMessage.COUPON_NOT_FOUND.getMessage())); + .orElseThrow(() -> new BadRequestException(ErrorMessage.EVENT_NOT_FOUND.getMessage())); } } diff --git a/src/main/java/com/example/eightyage/domain/coupon/service/IssuedCouponService.java b/src/main/java/com/example/eightyage/domain/coupon/service/IssuedCouponService.java new file mode 100644 index 0000000..913d046 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/coupon/service/IssuedCouponService.java @@ -0,0 +1,102 @@ +package com.example.eightyage.domain.coupon.service; + +import com.example.eightyage.domain.coupon.dto.response.IssuedCouponResponseDto; +import com.example.eightyage.domain.coupon.entity.IssuedCoupon; +import com.example.eightyage.domain.coupon.repository.IssuedCouponRepository; +import com.example.eightyage.domain.coupon.entity.Coupon; +import com.example.eightyage.domain.coupon.status.Status; +import com.example.eightyage.domain.user.entity.User; +import com.example.eightyage.global.dto.AuthUser; +import com.example.eightyage.global.exception.BadRequestException; +import com.example.eightyage.global.exception.ErrorMessage; +import com.example.eightyage.global.exception.ForbiddenException; +import com.example.eightyage.global.exception.NotFoundException; +import lombok.RequiredArgsConstructor; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +public class IssuedCouponService { + + private final IssuedCouponRepository issuedCouponRepository; + private final CouponService couponService; + private final StringRedisTemplate stringRedisTemplate; + + private static final String EVENT_QUANTITIY_PREFIX = "event:quantity:"; + private static final String EVENT_LOCK_PREFIX = "event:lock:"; + private final RedissonClient redissonClient; + + public IssuedCouponResponseDto issueCoupon(AuthUser authUser, Long couponId) { + + RLock rLock = redissonClient.getLock(EVENT_LOCK_PREFIX + couponId); + boolean isLocked = false; + + try { + isLocked = rLock.tryLock(3, 10, TimeUnit.SECONDS); // 3초 안에 락을 획득, 10초 뒤에는 자동 해제 + + if (!isLocked) { + throw new BadRequestException(ErrorMessage.CAN_NOT_ACCESS.getMessage()); // 락 획득 실패 + } + + Coupon coupon = couponService.getValidCouponOrThrow(couponId); + + if (issuedCouponRepository.existsByUserIdAndCouponId(authUser.getUserId(), couponId)) { + throw new BadRequestException(ErrorMessage.COUPON_ALREADY_ISSUED.getMessage()); + } + + Long remain = Long.parseLong(stringRedisTemplate.opsForValue().get(EVENT_QUANTITIY_PREFIX + couponId)); + if (remain == 0 || remain < 0) { + throw new BadRequestException(ErrorMessage.COUPON_OUT_OF_STOCK.getMessage()); + } + stringRedisTemplate.opsForValue().decrement(EVENT_QUANTITIY_PREFIX + couponId); + + // 쿠폰 발급 및 저장 + IssuedCoupon issuedCoupon = IssuedCoupon.create(User.fromAuthUser(authUser), coupon); + issuedCouponRepository.save(issuedCoupon); + + return issuedCoupon.toDto(); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new BadRequestException(ErrorMessage.INTERNAL_SERVER_ERROR.getMessage()); + } finally { + if (isLocked) { + rLock.unlock(); + } + } + } + + public Page getMyCoupons(AuthUser authUser, int page, int size) { + Pageable pageable = PageRequest.of(page-1, size); + Page coupons = issuedCouponRepository.findAllByUserIdAndStatus(authUser.getUserId(), Status.VALID, pageable); + + return coupons.map(IssuedCoupon::toDto); + } + + public IssuedCouponResponseDto getCoupon(AuthUser authUser, Long issuedCouponId) { + IssuedCoupon issuedCoupon = findByIdOrElseThrow(issuedCouponId); + + if(!issuedCoupon.getUser().equals(User.fromAuthUser(authUser))) { + throw new ForbiddenException(ErrorMessage.COUPON_FORBIDDEN.getMessage()); + } + + if(issuedCoupon.getStatus().equals(Status.INVALID)) { + throw new BadRequestException(ErrorMessage.COUPON_ALREADY_USED.getMessage()); + } + + return issuedCoupon.toDto(); + } + + public IssuedCoupon findByIdOrElseThrow(Long issuedCouponId) { + return issuedCouponRepository.findById(issuedCouponId) + .orElseThrow(() -> new NotFoundException(ErrorMessage.COUPON_NOT_FOUND.getMessage())); + } +} diff --git a/src/main/java/com/example/eightyage/domain/coupon/status/Status.java b/src/main/java/com/example/eightyage/domain/coupon/status/Status.java new file mode 100644 index 0000000..cfd21f3 --- /dev/null +++ b/src/main/java/com/example/eightyage/domain/coupon/status/Status.java @@ -0,0 +1,6 @@ +package com.example.eightyage.domain.coupon.status; + +public enum Status { + VALID, + INVALID, +} diff --git a/src/main/java/com/example/eightyage/domain/event/controller/EventController.java b/src/main/java/com/example/eightyage/domain/event/controller/EventController.java deleted file mode 100644 index 17c8a81..0000000 --- a/src/main/java/com/example/eightyage/domain/event/controller/EventController.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.example.eightyage.domain.event.controller; - -import com.example.eightyage.domain.event.dto.request.EventRequestDto; -import com.example.eightyage.domain.event.dto.response.EventResponseDto; -import com.example.eightyage.domain.event.service.EventService; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/api") -@RequiredArgsConstructor -public class EventController { - - private final EventService eventService; - - @PreAuthorize("hasRole('ADMIN')") - @PostMapping("/v1/events") - public ResponseEntity createEvent(@Valid @RequestBody EventRequestDto eventRequestDto) { - return ResponseEntity.ok(eventService.saveEvent(eventRequestDto)); - } - - @GetMapping("/v1/events") - public ResponseEntity> getEvents(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size) { - return ResponseEntity.ok(eventService.getEvents(page, size)); - } - - @GetMapping("/v1/events/{eventId}") - public ResponseEntity getEvent(@PathVariable long eventId) { - return ResponseEntity.ok(eventService.getEvent(eventId)); - } - - @PreAuthorize("hasRole('ADMIN')") - @PatchMapping("/v1/events/{eventId}") - public ResponseEntity updateEvent(@PathVariable long eventId, @Valid @RequestBody EventRequestDto eventRequestDto) { - return ResponseEntity.ok(eventService.updateEvent(eventId, eventRequestDto)); - } -} diff --git a/src/main/java/com/example/eightyage/domain/event/dto/response/EventResponseDto.java b/src/main/java/com/example/eightyage/domain/event/dto/response/EventResponseDto.java deleted file mode 100644 index 98edeb2..0000000 --- a/src/main/java/com/example/eightyage/domain/event/dto/response/EventResponseDto.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.example.eightyage.domain.event.dto.response; - -import com.example.eightyage.domain.event.entity.EventState; -import lombok.Getter; - -import java.time.LocalDateTime; - -@Getter -public class EventResponseDto { - - private final String name; - private final String description; - private final int quantity; - private final LocalDateTime startDate; - private final LocalDateTime endDate; - private final EventState state; - - - public EventResponseDto(String name, String description, int quantity, LocalDateTime startDate, LocalDateTime endDate, EventState state) { - this.name = name; - this.description = description; - this.quantity = quantity; - this.startDate = startDate; - this.endDate = endDate; - this.state = state; - } -} diff --git a/src/main/java/com/example/eightyage/domain/event/entity/Event.java b/src/main/java/com/example/eightyage/domain/event/entity/Event.java deleted file mode 100644 index 7b2b560..0000000 --- a/src/main/java/com/example/eightyage/domain/event/entity/Event.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.example.eightyage.domain.event.entity; - -import com.example.eightyage.domain.event.dto.request.EventRequestDto; -import com.example.eightyage.domain.event.dto.response.EventResponseDto; -import com.example.eightyage.global.entity.TimeStamped; -import jakarta.persistence.*; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; - -@Entity -@Getter -@NoArgsConstructor -public class Event extends TimeStamped { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - private String name; - private String description; - private int quantity; - - @Column(name="start_at") - private LocalDateTime startDate; - @Column(name = "end_at") - private LocalDateTime endDate; - - @Enumerated(EnumType.STRING) - private EventState state; - - public Event(String name, String description, int quantity, LocalDateTime startDate, LocalDateTime endDate) { - this.name = name; - this.description = description; - this.quantity = quantity; - this.startDate = startDate; - this.endDate = endDate; - } - - public void setState(EventState state) { - this.state = state; - } - - public EventResponseDto toDto() { - return new EventResponseDto( - this.getName(), - this.getDescription(), - this.getQuantity(), - this.getStartDate(), - this.getEndDate(), - this.getState() - ); - } - - public void update(EventRequestDto eventRequestDto) { - this.name = eventRequestDto.getName(); - this.description = eventRequestDto.getDescription(); - this.quantity = eventRequestDto.getQuantity(); - this.startDate = eventRequestDto.getStartDate(); - this.endDate = eventRequestDto.getEndDate(); - } - - public boolean isValidAt(LocalDateTime time) { - return (startDate.isBefore(time) || startDate.isEqual(time)) && (endDate.isAfter(time) || endDate.isEqual(time)); - } - - public void updateStateAt(LocalDateTime time) { - EventState newState = isValidAt(time) ? EventState.VALID : EventState.INVALID; - this.state = newState; - } -} diff --git a/src/main/java/com/example/eightyage/domain/event/entity/EventState.java b/src/main/java/com/example/eightyage/domain/event/entity/EventState.java deleted file mode 100644 index 75bb82f..0000000 --- a/src/main/java/com/example/eightyage/domain/event/entity/EventState.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.example.eightyage.domain.event.entity; - -public enum EventState { - VALID, - INVALID -} diff --git a/src/main/java/com/example/eightyage/domain/event/repository/EventRepository.java b/src/main/java/com/example/eightyage/domain/event/repository/EventRepository.java deleted file mode 100644 index f6ee989..0000000 --- a/src/main/java/com/example/eightyage/domain/event/repository/EventRepository.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.example.eightyage.domain.event.repository; - -import com.example.eightyage.domain.event.entity.Event; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -@Repository -public interface EventRepository extends JpaRepository { -} diff --git a/src/main/java/com/example/eightyage/domain/event/service/EventService.java b/src/main/java/com/example/eightyage/domain/event/service/EventService.java deleted file mode 100644 index f7d38a6..0000000 --- a/src/main/java/com/example/eightyage/domain/event/service/EventService.java +++ /dev/null @@ -1,98 +0,0 @@ -package com.example.eightyage.domain.event.service; - -import com.example.eightyage.domain.event.dto.request.EventRequestDto; -import com.example.eightyage.domain.event.dto.response.EventResponseDto; -import com.example.eightyage.domain.event.entity.Event; -import com.example.eightyage.domain.event.entity.EventState; -import com.example.eightyage.domain.event.repository.EventRepository; -import com.example.eightyage.global.exception.BadRequestException; -import com.example.eightyage.global.exception.ErrorMessage; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.security.access.annotation.Secured; -import org.springframework.stereotype.Service; - -import java.time.LocalDateTime; - -@Service -@RequiredArgsConstructor -public class EventService { - - private final EventRepository eventRepository; - private final StringRedisTemplate stringRedisTemplate; - - public EventResponseDto saveEvent(EventRequestDto eventRequestDto) { - Event event = new Event( - eventRequestDto.getName(), - eventRequestDto.getDescription(), - eventRequestDto.getQuantity(), - eventRequestDto.getStartDate(), - eventRequestDto.getEndDate() - ); - - checkEventState(event); - - Event savedEvent = eventRepository.save(event); - - stringRedisTemplate.opsForValue().set("event:quantity:" + savedEvent.getId(), String.valueOf(savedEvent.getQuantity())); - - return savedEvent.toDto(); - } - - public Page getEvents(int page, int size) { - Pageable pageable = PageRequest.of(page-1, size); - Page events = eventRepository.findAll(pageable); - - // 모든 events들 checkState로 state 상태 갱신하기 - events.forEach(this::checkEventState); - - return events.map(Event::toDto); - } - - public EventResponseDto getEvent(long eventId) { - Event event = findByIdOrElseThrow(eventId); - - checkEventState(event); - - return event.toDto(); - } - - public EventResponseDto updateEvent(long eventId, EventRequestDto eventRequestDto) { - Event event = findByIdOrElseThrow(eventId); - - event.update(eventRequestDto); - - checkEventState(event); - - return event.toDto(); - } - - private void checkEventState(Event event) { - EventState prevState = event.getState(); - event.updateStateAt(LocalDateTime.now()); - - if(event.getState() != prevState) { - eventRepository.save(event); - } - } - - public Event getValidEventOrThrow(Long eventId) { - Event event = findByIdOrElseThrow(eventId); - - event.updateStateAt(LocalDateTime.now()); - - if(event.getState() != EventState.VALID) { - throw new BadRequestException(ErrorMessage.INVALID_EVENT_PERIOD.getMessage()); - } - - return event; - } - - public Event findByIdOrElseThrow(Long eventId) { - return eventRepository.findById(eventId) - .orElseThrow(() -> new BadRequestException(ErrorMessage.EVENT_NOT_FOUND.getMessage())); - } -}