diff --git a/dateroad-api/build.gradle b/dateroad-api/build.gradle index 6fa0c7b4..731a49d5 100644 --- a/dateroad-api/build.gradle +++ b/dateroad-api/build.gradle @@ -2,6 +2,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' runtimeOnly 'org.postgresql:postgresql' implementation project(path: ':dateroad-common') diff --git a/dateroad-api/src/main/java/org/dateroad/config/RedisStreamConfig.java b/dateroad-api/src/main/java/org/dateroad/config/RedisStreamConfig.java new file mode 100644 index 00000000..d3b47bba --- /dev/null +++ b/dateroad-api/src/main/java/org/dateroad/config/RedisStreamConfig.java @@ -0,0 +1,126 @@ +package org.dateroad.config; + + +import io.lettuce.core.api.async.RedisAsyncCommands; +import io.lettuce.core.codec.StringCodec; +import io.lettuce.core.output.StatusOutput; +import io.lettuce.core.protocol.CommandArgs; +import io.lettuce.core.protocol.CommandKeyword; +import io.lettuce.core.protocol.CommandType; +import lombok.RequiredArgsConstructor; +import org.dateroad.date.repository.CourseRepository; +import org.dateroad.point.event.FreeEventListener; +import org.dateroad.point.event.pointEventListener; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.connection.stream.*; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.data.redis.stream.StreamMessageListenerContainer; +import org.springframework.data.redis.stream.Subscription; + +import java.time.Duration; +import java.util.Iterator; +import java.util.Objects; + +@Configuration +@RequiredArgsConstructor +public class RedisStreamConfig { + private final pointEventListener pointEventListener; + private final FreeEventListener freeEventListener; + @Value("${spring.data.redis.host}") + private String host; + @Value("${spring.data.redis.port}") + private int port; + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + return redisTemplate; + } + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(host, port); + } + + public void createStreamConsumerGroup(final String streamKey, final String consumerGroupName) { + // Stream이 존재 하지 않으면, MKSTREAM 옵션을 통해 만들고, ConsumerGroup또한 생성한다 + System.out.println(streamKey + consumerGroupName); + // Stream이 존재하지 않으면, MKSTREAM 옵션을 통해 스트림과 소비자 그룹을 생성 + if (Boolean.FALSE.equals(redisTemplate().hasKey(streamKey))) { + RedisAsyncCommands commands = (RedisAsyncCommands) Objects.requireNonNull( + redisTemplate() + .getConnectionFactory()) + .getConnection() + .getNativeConnection(); + + CommandArgs args = new CommandArgs<>(StringCodec.UTF8) + .add(CommandKeyword.CREATE) + .add(streamKey) + .add(consumerGroupName) + .add("0") + .add("MKSTREAM"); + // MKSTREAM 옵션을 사용하여 스트림과 그룹을 생성 + commands.dispatch(CommandType.XGROUP, new StatusOutput<>(StringCodec.UTF8), args).toCompletableFuture() + .join(); + } + // Stream 존재 시, ConsumerGroup 존재 여부 확인 후 ConsumerGroup을 생성 + else { + if (!isStreamConsumerGroupExist(streamKey, consumerGroupName)) { + redisTemplate().opsForStream().createGroup(streamKey, ReadOffset.from("0"), consumerGroupName); + } + } + } + + // ConsumerGroup 존재 여부 확인 + public boolean isStreamConsumerGroupExist(final String streamKey, final String consumerGroupName) { + Iterator iterator = redisTemplate() + .opsForStream().groups(streamKey).stream().iterator(); + + while (iterator.hasNext()) { + StreamInfo.XInfoGroup xInfoGroup = iterator.next(); + if (xInfoGroup.groupName().equals(consumerGroupName)) { + return true; + } + } + return false; + } + + @Bean + public Subscription PointSubscription() { + createStreamConsumerGroup("coursePoint", "courseGroup"); + StreamMessageListenerContainer.StreamMessageListenerContainerOptions> containerOptions = StreamMessageListenerContainer.StreamMessageListenerContainerOptions + .builder().pollTimeout(Duration.ofMillis(100)).build(); + + StreamMessageListenerContainer> container = StreamMessageListenerContainer.create( + redisConnectionFactory(), + containerOptions); + + Subscription subscription = container.receiveAutoAck(Consumer.from("courseGroup", "instance-1"), + StreamOffset.create("coursePoint", ReadOffset.lastConsumed()), pointEventListener); + container.start(); + return subscription; + } + + @Bean + public Subscription FreeSubscription() { + createStreamConsumerGroup("courseFree", "courseGroup"); + StreamMessageListenerContainer.StreamMessageListenerContainerOptions> containerOptions = StreamMessageListenerContainer.StreamMessageListenerContainerOptions + .builder().pollTimeout(Duration.ofMillis(100)).build(); + StreamMessageListenerContainer> container = StreamMessageListenerContainer.create( + redisConnectionFactory(), + containerOptions); + Subscription subscription = container.receiveAutoAck(Consumer.from("courseGroup", "instance-2"), + StreamOffset.create("courseFree", ReadOffset.lastConsumed()), freeEventListener); + container.start(); + return subscription; + } +} + diff --git a/dateroad-api/src/main/java/org/dateroad/course/api/CourseController.java b/dateroad-api/src/main/java/org/dateroad/course/api/CourseController.java index 5071ecb5..b12a6533 100644 --- a/dateroad-api/src/main/java/org/dateroad/course/api/CourseController.java +++ b/dateroad-api/src/main/java/org/dateroad/course/api/CourseController.java @@ -7,6 +7,7 @@ import org.dateroad.course.dto.request.CourseGetAllReq; import org.dateroad.course.dto.request.CourseCreateReq; import org.dateroad.course.dto.request.CoursePlaceGetReq; +import org.dateroad.course.dto.request.PointUseReq; import org.dateroad.course.dto.request.TagCreateReq; import org.dateroad.course.dto.response.CourseCreateRes; import org.dateroad.course.dto.response.CourseGetAllRes; @@ -30,7 +31,7 @@ public class CourseController { @GetMapping public ResponseEntity getAllCourse( - @ModelAttribute CourseGetAllReq courseGetAllReq + final @ModelAttribute CourseGetAllReq courseGetAllReq ) { CourseGetAllRes courseAll = courseService.getAllCourses(courseGetAllReq); return ResponseEntity.ok(courseAll); @@ -38,7 +39,7 @@ public ResponseEntity getAllCourse( @GetMapping("/date-access") public ResponseEntity getAllDataAccesCourse( - @UserId Long userId + final @UserId Long userId ) { DateAccessGetAllRes dateAccessGetAllRes = courseService.getAllDataAccessCourse(userId); return ResponseEntity.ok(dateAccessGetAllRes); @@ -60,6 +61,16 @@ public ResponseEntity createCourse( ).body(CourseCreateRes.of(course.getId())); } + @PostMapping("/{courseId}/date-access") + public ResponseEntity openCourse( + @UserId final Long userId, + @PathVariable final Long courseId, + @RequestBody final PointUseReq pointUseReq + ) { + courseService.openCourse(userId,courseId,pointUseReq); + return ResponseEntity.ok().build(); + } + @GetMapping("/{courseId}") public ResponseEntity getCourseDetail(@UserId Long userId, @PathVariable("courseId") Long courseId) { diff --git a/dateroad-api/src/main/java/org/dateroad/course/dto/request/PointUseReq.java b/dateroad-api/src/main/java/org/dateroad/course/dto/request/PointUseReq.java new file mode 100644 index 00000000..3581a04b --- /dev/null +++ b/dateroad-api/src/main/java/org/dateroad/course/dto/request/PointUseReq.java @@ -0,0 +1,20 @@ +package org.dateroad.course.dto.request; + +import lombok.AccessLevel; +import lombok.Builder; +import org.dateroad.point.domain.TransactionType; + +@Builder(access = AccessLevel.PROTECTED) +public record PointUseReq( + int point, + TransactionType type, + String description +) { + public static PointUseReq of(int point, TransactionType type, String description) { + return PointUseReq.builder() + .point(point) + .type(type) + .description(description) + .build(); + } +} diff --git a/dateroad-api/src/main/java/org/dateroad/course/facade/AsyncService.java b/dateroad-api/src/main/java/org/dateroad/course/facade/AsyncService.java index 2ee4eaed..d3d3c3b8 100644 --- a/dateroad-api/src/main/java/org/dateroad/course/facade/AsyncService.java +++ b/dateroad-api/src/main/java/org/dateroad/course/facade/AsyncService.java @@ -1,15 +1,20 @@ package org.dateroad.course.facade; +import java.util.HashMap; import java.util.List; +import java.util.Map; import lombok.AccessLevel; import lombok.RequiredArgsConstructor; import org.dateroad.Image.service.ImageService; import org.dateroad.course.dto.request.CoursePlaceGetReq; +import org.dateroad.course.dto.request.PointUseReq; import org.dateroad.course.dto.request.TagCreateReq; import org.dateroad.course.service.CoursePlaceService; import org.dateroad.course.service.CourseTagService; import org.dateroad.date.domain.Course; import org.dateroad.image.domain.Image; +import org.dateroad.user.domain.User; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -22,6 +27,8 @@ public class AsyncService { private final CoursePlaceService coursePlaceService; private final CourseTagService courseTagService; private final ImageService imageService; + private final StringRedisTemplate redisTemplate; + public Image findFirstByCourseOrderBySequenceAsc(final Course course) { return imageService.findFirstByCourseOrderBySequenceAsc(course); @@ -45,4 +52,18 @@ public void createCourseTags(final List tags, final Course course) public void createCoursePlace(final List places, final Course course) { coursePlaceService.createCoursePlace(places, course); } + + public void publishEvenUserPoint(User user, PointUseReq pointUseReq) { + Map fieldMap = new HashMap<>(); + fieldMap.put("userId", user.getId().toString()); + fieldMap.put("point", Integer.toString(pointUseReq.point())); + fieldMap.put("type", pointUseReq.type().toString()); + redisTemplate.opsForStream().add("coursePoint", fieldMap); + } + + public void publishEventUserFree(User user) { + Map fieldMap = new HashMap<>(); + fieldMap.put("userId", user.getId().toString()); + redisTemplate.opsForStream().add("courseFree", fieldMap); + } } diff --git a/dateroad-api/src/main/java/org/dateroad/course/service/CoursePaymentType.java b/dateroad-api/src/main/java/org/dateroad/course/service/CoursePaymentType.java new file mode 100644 index 00000000..28b51694 --- /dev/null +++ b/dateroad-api/src/main/java/org/dateroad/course/service/CoursePaymentType.java @@ -0,0 +1,5 @@ +package org.dateroad.course.service; + +public enum CoursePaymentType { + FREE,POINT +} diff --git a/dateroad-api/src/main/java/org/dateroad/course/service/CourseService.java b/dateroad-api/src/main/java/org/dateroad/course/service/CourseService.java index 94082bf9..765ada1a 100644 --- a/dateroad-api/src/main/java/org/dateroad/course/service/CourseService.java +++ b/dateroad-api/src/main/java/org/dateroad/course/service/CourseService.java @@ -10,6 +10,7 @@ import org.dateroad.course.dto.request.CourseGetAllReq; import org.dateroad.course.dto.request.CourseCreateReq; import org.dateroad.course.dto.request.CoursePlaceGetReq; +import org.dateroad.course.dto.request.PointUseReq; import org.dateroad.course.dto.response.CourseDtoGetRes; import org.dateroad.course.dto.response.CourseGetAllRes; import org.dateroad.course.dto.response.DateAccessGetAllRes; @@ -17,14 +18,17 @@ import org.dateroad.date.domain.Course; import org.dateroad.date.dto.response.CourseGetDetailRes; import org.dateroad.date.repository.CourseRepository; -import org.dateroad.date.service.DateRepository; +import org.dateroad.dateAccess.domain.DateAccess; import org.dateroad.dateAccess.repository.DateAccessRepository; +import org.dateroad.exception.DateRoadException; import org.dateroad.exception.EntityNotFoundException; import org.dateroad.image.domain.Image; import org.dateroad.image.repository.ImageRepository; import org.dateroad.exception.ConflictException; import org.dateroad.like.domain.Like; import org.dateroad.like.repository.LikeRepository; +import org.dateroad.point.domain.Point; +import org.dateroad.point.repository.PointRepository; import org.dateroad.place.domain.CoursePlace; import org.dateroad.place.repository.CoursePlaceRepository; import org.dateroad.tag.domain.CourseTag; @@ -45,21 +49,22 @@ public class CourseService { private final DateAccessRepository dateAccessRepository; private final UserRepository userRepository; private final AsyncService asyncService; + private final PointRepository pointRepository; private final ImageRepository imageRepository; private final CoursePlaceRepository coursePlaceRepository; private final CourseTagRepository courseTagRepository; - private final DateRepository dateRepository; - public CourseGetAllRes getAllCourses(CourseGetAllReq courseGetAllReq) { + public CourseGetAllRes getAllCourses(final CourseGetAllReq courseGetAllReq) { Specification spec = CourseSpecifications.filterByCriteria(courseGetAllReq); List courses = courseRepository.findAll(spec); List courseDtoGetResList = convertToDtoList(courses, Function.identity()); return CourseGetAllRes.of(courseDtoGetResList); } + @Transactional - public void createCourseLike(Long userId, Long courseId) { + public void createCourseLike(final Long userId, final Long courseId) { User findUser = getUser(userId); Course findCourse = getCourse(courseId); validateCourseLike(findUser, findCourse); @@ -74,14 +79,14 @@ public void deleteCourseLike(Long userId, Long courseId) { likeRepository.delete(findLike); } - private List convertToDtoList(List entities, Function converter) { + private List convertToDtoList(final List entities, final Function converter) { return entities.stream() .map(converter) .map(this::convertToDto) .collect(Collectors.toList()); } - private CourseDtoGetRes convertToDto(Course course) { + private CourseDtoGetRes convertToDto(final Course course) { int likeCount = likeRepository.countByCourse(course); Image thumbnailImage = asyncService.findFirstByCourseOrderBySequenceAsc(course); String thumbnailUrl = thumbnailImage != null ? thumbnailImage.getImageUrl() : null; @@ -97,7 +102,7 @@ private CourseDtoGetRes convertToDto(Course course) { ); } - public DateAccessGetAllRes getAllDataAccessCourse(Long userId) { + public DateAccessGetAllRes getAllDataAccessCourse(final Long userId) { List accesses = dateAccessRepository.findCoursesByUserId(userId); List courseDtoGetResList = convertToDtoList(accesses, Function.identity()); return DateAccessGetAllRes.of(courseDtoGetResList); @@ -154,6 +159,38 @@ public Course createCourse(final Long userId, final CourseCreateReq courseRegist return saveCourse; } + @Transactional + public void openCourse(final Long userId, final Long courseId, final PointUseReq pointUseReq) { + User user = getUser(userId); + Course course = getCourse(courseId); + Point point = Point.create(user, pointUseReq.point(), pointUseReq.type(), pointUseReq.description()); + CoursePaymentType coursePaymentType = validateUserFreeOrPoint(user, pointUseReq.point()); + processCoursePayment(coursePaymentType, user, point, pointUseReq); + dateAccessRepository.save(DateAccess.create(course, user)); + } + + private CoursePaymentType validateUserFreeOrPoint(final User user, final int requiredPoints) { + if (user.getFree() > 0) { + return CoursePaymentType.FREE; // User가 free를 갖고 있으면 true를 반환 + } else if (user.getTotalPoint() < requiredPoints) { + throw new DateRoadException(FailureCode.INSUFFICIENT_USER_POINTS); + } + return CoursePaymentType.POINT; + } + + public void processCoursePayment(final CoursePaymentType coursePaymentType, final User user, final Point point, + final PointUseReq pointUseReq) { + switch (coursePaymentType) { + case FREE -> { + asyncService.publishEventUserFree(user); + } + case POINT -> { + pointRepository.save(point); + asyncService.publishEvenUserPoint(user, pointUseReq); + } + } + } + public CourseGetDetailRes getCourseDetail(final Long userId, final Long courseId) { //course - courseId, title, date, start_at, description, totalCost, city, totalTime diff --git a/dateroad-api/src/main/java/org/dateroad/point/event/FreeEventListener.java b/dateroad-api/src/main/java/org/dateroad/point/event/FreeEventListener.java new file mode 100644 index 00000000..8966766a --- /dev/null +++ b/dateroad-api/src/main/java/org/dateroad/point/event/FreeEventListener.java @@ -0,0 +1,36 @@ +package org.dateroad.point.event; + +import java.util.Map; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import org.dateroad.code.FailureCode; +import org.dateroad.exception.DateRoadException; +import org.dateroad.user.domain.User; +import org.dateroad.user.repository.UserRepository; +import org.springframework.data.redis.connection.stream.MapRecord; +import org.springframework.data.redis.stream.StreamListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +public class FreeEventListener implements StreamListener> { + private final UserRepository userRepository; + + @Override + @Transactional + public void onMessage(final MapRecord message) { + Map map = message.getValue(); + Long userId = Long.valueOf(map.get("userId")); + User user = getUser(userId); + int userPoint = user.getFree(); + user.setFree(userPoint -1); + userRepository.save(user); + } + + private User getUser(Long userId) { + return userRepository.findById(userId).orElseThrow( + () -> new DateRoadException(FailureCode.USER_NOT_FOUND) + ); + } +} diff --git a/dateroad-api/src/main/java/org/dateroad/point/event/pointEventListener.java b/dateroad-api/src/main/java/org/dateroad/point/event/pointEventListener.java new file mode 100644 index 00000000..ddff2aff --- /dev/null +++ b/dateroad-api/src/main/java/org/dateroad/point/event/pointEventListener.java @@ -0,0 +1,36 @@ +package org.dateroad.point.event; + +import java.util.Map; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import org.dateroad.code.FailureCode; +import org.dateroad.exception.DateRoadException; +import org.dateroad.user.domain.User; +import org.dateroad.user.repository.UserRepository; +import org.springframework.data.redis.connection.stream.MapRecord; +import org.springframework.data.redis.stream.StreamListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +public class pointEventListener implements StreamListener> { + private final UserRepository userRepository; + + @Override + @Transactional + public void onMessage(final MapRecord message) { + Map map = message.getValue(); + Long userId = Long.valueOf(map.get("userId")); + User user = getUser(userId); + int point = Integer.parseInt(map.get("point")); // 감소시킬 포인트 + user.setTotalPoint(user.getTotalPoint() - point); + userRepository.save(user); + } + + private User getUser(Long userId) { + return userRepository.findById(userId).orElseThrow( + () -> new DateRoadException(FailureCode.USER_NOT_FOUND) + ); + } +} diff --git a/dateroad-api/src/main/java/org/dateroad/point/service/PointService.java b/dateroad-api/src/main/java/org/dateroad/point/service/PointService.java index 303ca134..ca29baf8 100644 --- a/dateroad-api/src/main/java/org/dateroad/point/service/PointService.java +++ b/dateroad-api/src/main/java/org/dateroad/point/service/PointService.java @@ -14,7 +14,6 @@ @RequiredArgsConstructor public class PointService { private final PointRepository pointRepository; - public PointGetAllRes getAllPoints(Long userId) { List points = pointRepository.findAllByUserId(userId) .stream().map(PointDto::of) @@ -29,4 +28,5 @@ public PointsDto pointTypeChecktoList(List points, TransactionType typ .map(PointDtoRes::of) .toList()); } + } diff --git a/dateroad-common/src/main/java/org/dateroad/code/FailureCode.java b/dateroad-common/src/main/java/org/dateroad/code/FailureCode.java index 5c7aa9a2..d3fe54ea 100644 --- a/dateroad-common/src/main/java/org/dateroad/code/FailureCode.java +++ b/dateroad-common/src/main/java/org/dateroad/code/FailureCode.java @@ -63,6 +63,7 @@ public enum FailureCode { NEAREST_DATE_NOT_FOUND(HttpStatus.NOT_FOUND, "e40411", "다가오는 데이트를 찾을 수 없습니다."), LIKE_NOT_FOUND(HttpStatus.NOT_FOUND, "e40412", "해당 데이트 코스에 좋아요를 찾을 수 없습니다."), + INSUFFICIENT_USER_POINTS(HttpStatus.NOT_FOUND, "e4048", "유저의 포인트가 부족합니다."), /** * 405 Method Not Allowed */ @@ -76,11 +77,11 @@ public enum FailureCode { DUPLICATE_NICKNAME(HttpStatus.CONFLICT, "e4092", "이미 존재하는 닉네임입니다."), DUPLICATE_COURSE_LIKE(HttpStatus.CONFLICT, "e4093", "해당 데이트 코스에 좋아요가 이미 존재합니다."), - + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "e5000", "서버 내부 오류입니다."); /** * 500 Internal Server Error */ - INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "e5000", "서버 내부 오류입니다."); + private final HttpStatus httpStatus; private final String code; diff --git a/dateroad-domain/src/main/java/org/dateroad/dateAccess/domain/DateAccess.java b/dateroad-domain/src/main/java/org/dateroad/dateAccess/domain/DateAccess.java index 272b9624..15602e50 100644 --- a/dateroad-domain/src/main/java/org/dateroad/dateAccess/domain/DateAccess.java +++ b/dateroad-domain/src/main/java/org/dateroad/dateAccess/domain/DateAccess.java @@ -13,6 +13,7 @@ import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.ToString; import lombok.experimental.SuperBuilder; import org.dateroad.common.BaseTimeEntity; import org.dateroad.date.domain.Course; @@ -28,7 +29,6 @@ public class DateAccess extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "date_access_id") - @NotNull private Long id; @OneToOne diff --git a/dateroad-domain/src/main/java/org/dateroad/user/domain/User.java b/dateroad-domain/src/main/java/org/dateroad/user/domain/User.java index 2a089682..bb372156 100644 --- a/dateroad-domain/src/main/java/org/dateroad/user/domain/User.java +++ b/dateroad-domain/src/main/java/org/dateroad/user/domain/User.java @@ -7,6 +7,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; import org.dateroad.common.BaseTimeEntity; import org.dateroad.tag.domain.UserTag; @@ -45,11 +46,13 @@ public class User extends BaseTimeEntity { @Builder.Default @Column(name = "free") @NotNull + @Setter private int free = 3; @Builder.Default @Column(name = "total_point") @NotNull + @Setter private int totalPoint = 0; @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)