From b883239e27bc5a3db36f97fd427fab53daccbff3 Mon Sep 17 00:00:00 2001 From: peridot Date: Thu, 27 Mar 2025 16:01:59 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix(coupon):=20=EC=88=98=EC=A0=95=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20=EB=B0=98=EC=98=81=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/coupon/service/CouponService.java | 4 +++- .../domain/event/controller/EventController.java | 5 +++-- .../eightyage/domain/event/entity/Event.java | 9 +++++++++ .../domain/event/service/EventService.java | 15 +++++---------- 4 files changed, 20 insertions(+), 13 deletions(-) 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 81a2d66..1cfd7da 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 @@ -27,9 +27,11 @@ public class CouponService { private final EventService eventService; private final StringRedisTemplate stringRedisTemplate; + private static final String EVENT_QUANTITIY_PREFIX = "event:quantity:"; + public CouponResponseDto issueCoupon(AuthUser authUser, Long eventId) { // 수량 우선 차감 - Long remain = stringRedisTemplate.opsForValue().decrement("event:quantity:" + eventId); + Long remain = stringRedisTemplate.opsForValue().decrement(EVENT_QUANTITIY_PREFIX + eventId); if (remain == null || remain < 0) { // atomic? `DESC`? throw new BadRequestException(ErrorMessage.COUPON_OUT_OF_STOCK.getMessage()); } 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 index 7c12b97..9a38ff6 100644 --- a/src/main/java/com/example/eightyage/domain/event/controller/EventController.java +++ b/src/main/java/com/example/eightyage/domain/event/controller/EventController.java @@ -3,6 +3,7 @@ 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; @@ -16,7 +17,7 @@ public class EventController { private final EventService eventService; @PostMapping("/v1/events") - public ResponseEntity createEvent(@RequestBody EventRequestDto eventRequestDto) { + public ResponseEntity createEvent(@Valid @RequestBody EventRequestDto eventRequestDto) { return ResponseEntity.ok(eventService.saveEvent(eventRequestDto)); } @@ -31,7 +32,7 @@ public ResponseEntity getEvent(@PathVariable long eventId) { } @PatchMapping("/v1/events/{eventId}") - public ResponseEntity updateEvent(@PathVariable long eventId, @RequestBody EventRequestDto eventRequestDto) { + 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/entity/Event.java b/src/main/java/com/example/eightyage/domain/event/entity/Event.java index 4f71301..7b2b560 100644 --- a/src/main/java/com/example/eightyage/domain/event/entity/Event.java +++ b/src/main/java/com/example/eightyage/domain/event/entity/Event.java @@ -60,4 +60,13 @@ public void update(EventRequestDto eventRequestDto) { 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/service/EventService.java b/src/main/java/com/example/eightyage/domain/event/service/EventService.java index a96f6e1..9af0bfc 100644 --- a/src/main/java/com/example/eightyage/domain/event/service/EventService.java +++ b/src/main/java/com/example/eightyage/domain/event/service/EventService.java @@ -73,15 +73,10 @@ public EventResponseDto updateEvent(long eventId, EventRequestDto eventRequestDt } private void checkEventState(Event event) { - LocalDateTime now = LocalDateTime.now(); - EventState newState = - ( (event.getStartDate().isBefore(now) || event.getStartDate().isEqual(now)) && - (event.getEndDate().isAfter(now) || event.getEndDate().isEqual(now)) ) - ? EventState.VALID - : EventState.INVALID; - - if (event.getState() != newState) { - event.setState(newState); + EventState prevState = event.getState(); + event.updateStateAt(LocalDateTime.now()); + + if(event.getState() != prevState) { eventRepository.save(event); } } @@ -89,7 +84,7 @@ private void checkEventState(Event event) { public Event getValidEventOrThrow(Long eventId) { Event event = findByIdOrElseThrow(eventId); - checkEventState(event); + event.updateStateAt(LocalDateTime.now()); if(event.getState() != EventState.VALID) { throw new BadRequestException(ErrorMessage.INVALID_EVENT_PERIOD.getMessage()); From e698d600b030e799d1346ce009af2d56d776a0d1 Mon Sep 17 00:00:00 2001 From: peridot Date: Fri, 28 Mar 2025 10:19:21 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(coupon)=20lock=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 +- .../domain/coupon/service/CouponService.java | 52 ++++++++++++++----- .../global/config/RedissonConfig.java | 19 +++++++ .../global/exception/ErrorMessage.java | 1 + 4 files changed, 61 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/example/eightyage/global/config/RedissonConfig.java diff --git a/build.gradle b/build.gradle index 73d3541..8f5ea31 100644 --- a/build.gradle +++ b/build.gradle @@ -47,8 +47,9 @@ dependencies { // spring cloud AWS S3 implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3:3.3.0' - // redis + // redis & redisson implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.redisson:redisson:3.23.5' } tasks.named('test') { 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 1cfd7da..fe3219d 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 @@ -13,12 +13,16 @@ 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 CouponService { @@ -28,26 +32,48 @@ public class 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 CouponResponseDto issueCoupon(AuthUser authUser, Long eventId) { - // 수량 우선 차감 - Long remain = stringRedisTemplate.opsForValue().decrement(EVENT_QUANTITIY_PREFIX + eventId); - if (remain == null || remain < 0) { // atomic? `DESC`? - throw new BadRequestException(ErrorMessage.COUPON_OUT_OF_STOCK.getMessage()); - } - Event event = eventService.getValidEventOrThrow(eventId); + RLock rLock = redissonClient.getLock(EVENT_LOCK_PREFIX + eventId); + boolean isLocked = false; - if(couponRepository.existsByUserIdAndEventId(authUser.getUserId(), eventId)) { - throw new BadRequestException(ErrorMessage.COUPON_ALREADY_ISSUED.getMessage()); - } + try { + isLocked = rLock.tryLock(3, 10, TimeUnit.SECONDS); // 3초 안에 락을 획득, 10초 뒤에는 자동 해제 - // 쿠폰 발급 및 저장 - Coupon coupon = Coupon.create(User.fromAuthUser(authUser),event); + if (!isLocked) { + throw new BadRequestException(ErrorMessage.CAN_NOT_ACCESS.getMessage()); // 락 획득 실패 + } - couponRepository.save(coupon); + // 락 획득 -> 임계 구역 진입 + // 쿠폰 수량 우선 차감 + Long remain = stringRedisTemplate.opsForValue().decrement(EVENT_QUANTITIY_PREFIX + eventId); + if (remain == 0 || remain < 0) { + throw new BadRequestException(ErrorMessage.COUPON_OUT_OF_STOCK.getMessage()); + } - return coupon.toDto(); + Event event = eventService.getValidEventOrThrow(eventId); + + if (couponRepository.existsByUserIdAndEventId(authUser.getUserId(), eventId)) { + throw new BadRequestException(ErrorMessage.COUPON_ALREADY_ISSUED.getMessage()); + } + + // 쿠폰 발급 및 저장 + Coupon coupon = Coupon.create(User.fromAuthUser(authUser),event); + couponRepository.save(coupon); + + return coupon.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) { diff --git a/src/main/java/com/example/eightyage/global/config/RedissonConfig.java b/src/main/java/com/example/eightyage/global/config/RedissonConfig.java new file mode 100644 index 0000000..4586fb3 --- /dev/null +++ b/src/main/java/com/example/eightyage/global/config/RedissonConfig.java @@ -0,0 +1,19 @@ +package com.example.eightyage.global.config; + +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RedissonConfig { + + @Bean + public RedissonClient redisson() { + Config config = new Config(); + config.useSingleServer() + .setAddress("redis://localhost:6379"); + return Redisson.create(); + } +} diff --git a/src/main/java/com/example/eightyage/global/exception/ErrorMessage.java b/src/main/java/com/example/eightyage/global/exception/ErrorMessage.java index f319ae2..ee23e98 100644 --- a/src/main/java/com/example/eightyage/global/exception/ErrorMessage.java +++ b/src/main/java/com/example/eightyage/global/exception/ErrorMessage.java @@ -28,6 +28,7 @@ public enum ErrorMessage { EVENT_NOT_FOUND("이벤트를 찾을 수 없습니다."), INVALID_EVENT_PERIOD("이벤트 기간이 아닙니다."), + CAN_NOT_ACCESS("잠시후 다시 시도해주세요"), COUPON_ALREADY_ISSUED("이미 쿠폰 발급 받은 사용자입니다."), COUPON_OUT_OF_STOCK("쿠폰 수량이 소진되었습니다."), COUPON_NOT_FOUND("쿠폰을 찾을 수 없습니다."), From 9dea156abd59c93811442f4c208e568ab605e9aa Mon Sep 17 00:00:00 2001 From: peridot Date: Fri, 28 Mar 2025 17:45:09 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(coupon)=20rlock=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../coupon/controller/CouponController.java | 74 +++++++++---------- .../domain/coupon/service/CouponService.java | 13 ++-- .../event/controller/EventController.java | 3 + .../domain/event/service/EventService.java | 2 - 4 files changed, 45 insertions(+), 47 deletions(-) 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 c1e1939..cdc3b69 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,38 +1,36 @@ -//package com.example.eightyage.domain.coupon.controller; -// -//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 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.*; -// -//import java.util.List; -// -//@RestController -//@RequestMapping("/api") -//@RequiredArgsConstructor -//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)); -// } -// -// @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/{couponId}") -// public ResponseEntity getCoupon(@AuthenticationPrincipal AuthUser authUser,@PathVariable Long couponId) { -// return ResponseEntity.ok(couponService.getCoupon(authUser, couponId)); -// } -//} +package com.example.eightyage.domain.coupon.controller; + +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 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 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)); + } + + @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/{couponId}") + public ResponseEntity getCoupon(@AuthenticationPrincipal AuthUser authUser,@PathVariable Long couponId) { + return ResponseEntity.ok(couponService.getCoupon(authUser, couponId)); + } +} 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 fe3219d..7f45cde 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 @@ -47,19 +47,18 @@ public CouponResponseDto issueCoupon(AuthUser authUser, Long eventId) { throw new BadRequestException(ErrorMessage.CAN_NOT_ACCESS.getMessage()); // 락 획득 실패 } - // 락 획득 -> 임계 구역 진입 - // 쿠폰 수량 우선 차감 - Long remain = stringRedisTemplate.opsForValue().decrement(EVENT_QUANTITIY_PREFIX + eventId); - if (remain == 0 || remain < 0) { - throw new BadRequestException(ErrorMessage.COUPON_OUT_OF_STOCK.getMessage()); - } - Event event = eventService.getValidEventOrThrow(eventId); if (couponRepository.existsByUserIdAndEventId(authUser.getUserId(), eventId)) { throw new BadRequestException(ErrorMessage.COUPON_ALREADY_ISSUED.getMessage()); } + 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); + // 쿠폰 발급 및 저장 Coupon coupon = Coupon.create(User.fromAuthUser(authUser),event); couponRepository.save(coupon); 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 index 9a38ff6..17c8a81 100644 --- a/src/main/java/com/example/eightyage/domain/event/controller/EventController.java +++ b/src/main/java/com/example/eightyage/domain/event/controller/EventController.java @@ -7,6 +7,7 @@ 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 @@ -16,6 +17,7 @@ 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)); @@ -31,6 +33,7 @@ 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/service/EventService.java b/src/main/java/com/example/eightyage/domain/event/service/EventService.java index 9af0bfc..f7d38a6 100644 --- a/src/main/java/com/example/eightyage/domain/event/service/EventService.java +++ b/src/main/java/com/example/eightyage/domain/event/service/EventService.java @@ -24,7 +24,6 @@ public class EventService { private final EventRepository eventRepository; private final StringRedisTemplate stringRedisTemplate; - @Secured("ADMIN") public EventResponseDto saveEvent(EventRequestDto eventRequestDto) { Event event = new Event( eventRequestDto.getName(), @@ -61,7 +60,6 @@ public EventResponseDto getEvent(long eventId) { return event.toDto(); } - @Secured("ADMIN") public EventResponseDto updateEvent(long eventId, EventRequestDto eventRequestDto) { Event event = findByIdOrElseThrow(eventId);