diff --git a/build.gradle b/build.gradle index 774b822..8a0998b 100644 --- a/build.gradle +++ b/build.gradle @@ -54,11 +54,10 @@ dependencies { // env implementation 'io.github.cdimascio:java-dotenv:5.2.2' - // redis + // redis & redisson implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.redisson:redisson:3.23.5' - // env - implementation 'io.github.cdimascio:java-dotenv:5.2.2' } tasks.named('test') { 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 2852a82..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 @@ -1,76 +1,103 @@ -//package com.example.eightyage.domain.coupon.service; -// -//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.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.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; -// -//@Service -//@RequiredArgsConstructor -//public class CouponService { -// -// private final CouponRepository couponRepository; -// private final EventService eventService; -// private final StringRedisTemplate stringRedisTemplate; -// -// public CouponResponseDto issueCoupon(AuthUser authUser, Long eventId) { -// // 수량 우선 차감 -// Long remain = stringRedisTemplate.opsForValue().decrement("event:quantity:" + eventId); -// if (remain == null || remain < 0) { // atomic? `DESC`? -// 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()); -// } -// -// // 쿠폰 발급 및 저장 -// Coupon coupon = Coupon.create(User.fromAuthUser(authUser),event); -// -// couponRepository.save(coupon); -// -// 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); -// -// return coupons.map(Coupon::toDto); -// } -// -// public CouponResponseDto getCoupon(AuthUser authUser, Long couponId) { -// Coupon coupon = findByIdOrElseThrow(couponId); -// -// if(!coupon.getUser().equals(User.fromAuthUser(authUser))) { -// throw new ForbiddenException(ErrorMessage.COUPON_FORBIDDEN.getMessage()); -// } -// -// if(!coupon.getState().equals(CouponState.VALID)) { -// throw new BadRequestException(ErrorMessage.COUPON_ALREADY_USED.getMessage()); -// } -// -// return coupon.toDto(); -// } -// -// public Coupon findByIdOrElseThrow(Long couponId) { -// return couponRepository.findById(couponId) -// .orElseThrow(() -> new NotFoundException(ErrorMessage.COUPON_NOT_FOUND.getMessage())); -// } -//} +package com.example.eightyage.domain.coupon.service; + +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.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; + +@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 issueCoupon(AuthUser authUser, Long eventId) { + + RLock rLock = redissonClient.getLock(EVENT_LOCK_PREFIX + eventId); + boolean isLocked = false; + + try { + isLocked = rLock.tryLock(3, 10, TimeUnit.SECONDS); // 3초 안에 락을 획득, 10초 뒤에는 자동 해제 + + if (!isLocked) { + throw new BadRequestException(ErrorMessage.CAN_NOT_ACCESS.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); + + 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) { + Pageable pageable = PageRequest.of(page-1, size); + Page coupons = couponRepository.findAllByUserIdAndState(authUser.getUserId(), CouponState.VALID, pageable); + + return coupons.map(Coupon::toDto); + } + + public CouponResponseDto getCoupon(AuthUser authUser, Long couponId) { + Coupon coupon = findByIdOrElseThrow(couponId); + + if(!coupon.getUser().equals(User.fromAuthUser(authUser))) { + throw new ForbiddenException(ErrorMessage.COUPON_FORBIDDEN.getMessage()); + } + + if(!coupon.getState().equals(CouponState.VALID)) { + throw new BadRequestException(ErrorMessage.COUPON_ALREADY_USED.getMessage()); + } + + return coupon.toDto(); + } + + public Coupon findByIdOrElseThrow(Long couponId) { + return couponRepository.findById(couponId) + .orElseThrow(() -> new NotFoundException(ErrorMessage.COUPON_NOT_FOUND.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 3d3104c..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 @@ -1,37 +1,41 @@ -//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 lombok.RequiredArgsConstructor; -//import org.springframework.data.domain.Page; -//import org.springframework.http.ResponseEntity; -//import org.springframework.web.bind.annotation.*; -// -//@RestController -//@RequestMapping("/api") -//@RequiredArgsConstructor -//public class EventController { -// -// private final EventService eventService; -// -// @PostMapping("/v1/events") -// public ResponseEntity createEvent(@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)); -// } -// -// @PatchMapping("/v1/events/{eventId}") -// public ResponseEntity updateEvent(@PathVariable long eventId, @RequestBody EventRequestDto eventRequestDto) { -// return ResponseEntity.ok(eventService.updateEvent(eventId, eventRequestDto)); -// } -//} +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/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 539a7d8..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 @@ -1,105 +1,98 @@ -//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; -// -// @Secured("ADMIN") -// 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(); -// } -// -// @Secured("ADMIN") -// public EventResponseDto updateEvent(long eventId, EventRequestDto eventRequestDto) { -// Event event = findByIdOrElseThrow(eventId); -// -// event.update(eventRequestDto); -// -// checkEventState(event); -// -// return event.toDto(); -// } -// -// 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); -// eventRepository.save(event); -// } -// } -// -// public Event getValidEventOrThrow(Long eventId) { -// Event event = findByIdOrElseThrow(eventId); -// -// checkEventState(event); -// -// 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())); -// } -//} +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())); + } +} 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("쿠폰을 찾을 수 없습니다."),