Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 2 additions & 3 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<CouponResponseDto> issueCoupon(@AuthenticationPrincipal AuthUser authUser, @PathVariable Long eventId) {
// return ResponseEntity.ok(couponService.issueCoupon(authUser, eventId));
// }
//
// @GetMapping("/v1/coupons/my")
// public ResponseEntity<Page<CouponResponseDto>> 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<CouponResponseDto> 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<CouponResponseDto> issueCoupon(@AuthenticationPrincipal AuthUser authUser, @PathVariable Long eventId) {
return ResponseEntity.ok(couponService.issueCoupon(authUser, eventId));
}

@GetMapping("/v1/coupons/my")
public ResponseEntity<Page<CouponResponseDto>> 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<CouponResponseDto> getCoupon(@AuthenticationPrincipal AuthUser authUser,@PathVariable Long couponId) {
return ResponseEntity.ok(couponService.getCoupon(authUser, couponId));
}
}
Original file line number Diff line number Diff line change
@@ -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<CouponResponseDto> getMyCoupons(AuthUser authUser, int page, int size) {
// Pageable pageable = PageRequest.of(page-1, size);
// Page<Coupon> 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<CouponResponseDto> getMyCoupons(AuthUser authUser, int page, int size) {
Pageable pageable = PageRequest.of(page-1, size);
Page<Coupon> 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()));
}
}
Original file line number Diff line number Diff line change
@@ -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<EventResponseDto> createEvent(@RequestBody EventRequestDto eventRequestDto) {
// return ResponseEntity.ok(eventService.saveEvent(eventRequestDto));
// }
//
// @GetMapping("/v1/events")
// public ResponseEntity<Page<EventResponseDto>> getEvents(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size) {
// return ResponseEntity.ok(eventService.getEvents(page, size));
// }
//
// @GetMapping("/v1/events/{eventId}")
// public ResponseEntity<EventResponseDto> getEvent(@PathVariable long eventId) {
// return ResponseEntity.ok(eventService.getEvent(eventId));
// }
//
// @PatchMapping("/v1/events/{eventId}")
// public ResponseEntity<EventResponseDto> 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<EventResponseDto> createEvent(@Valid @RequestBody EventRequestDto eventRequestDto) {
return ResponseEntity.ok(eventService.saveEvent(eventRequestDto));
}

@GetMapping("/v1/events")
public ResponseEntity<Page<EventResponseDto>> getEvents(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size) {
return ResponseEntity.ok(eventService.getEvents(page, size));
}

@GetMapping("/v1/events/{eventId}")
public ResponseEntity<EventResponseDto> getEvent(@PathVariable long eventId) {
return ResponseEntity.ok(eventService.getEvent(eventId));
}

@PreAuthorize("hasRole('ADMIN')")
@PatchMapping("/v1/events/{eventId}")
public ResponseEntity<EventResponseDto> updateEvent(@PathVariable long eventId, @Valid @RequestBody EventRequestDto eventRequestDto) {
return ResponseEntity.ok(eventService.updateEvent(eventId, eventRequestDto));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Loading