Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FEAT] 포인트 사용 [유저 데이트 코스 열람] API - #65 #70

Merged
merged 11 commits into from
Jul 11, 2024
1 change: 1 addition & 0 deletions dateroad-api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
126 changes: 126 additions & 0 deletions dateroad-api/src/main/java/org/dateroad/config/RedisStreamConfig.java
Original file line number Diff line number Diff line change
@@ -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<String, Object> redisTemplate() {
RedisTemplate<String, Object> 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<String, String> commands = (RedisAsyncCommands<String, String>) Objects.requireNonNull(
redisTemplate()
.getConnectionFactory())
.getConnection()
.getNativeConnection();

CommandArgs<String, String> 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<StreamInfo.XInfoGroup> 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<String, MapRecord<String, String, String>> containerOptions = StreamMessageListenerContainer.StreamMessageListenerContainerOptions
.builder().pollTimeout(Duration.ofMillis(100)).build();

StreamMessageListenerContainer<String, MapRecord<String, String, String>> 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<String, MapRecord<String, String, String>> containerOptions = StreamMessageListenerContainer.StreamMessageListenerContainerOptions
.builder().pollTimeout(Duration.ofMillis(100)).build();
StreamMessageListenerContainer<String, MapRecord<String, String, String>> container = StreamMessageListenerContainer.create(
redisConnectionFactory(),
containerOptions);
Subscription subscription = container.receiveAutoAck(Consumer.from("courseGroup", "instance-2"),
StreamOffset.create("courseFree", ReadOffset.lastConsumed()), freeEventListener);
container.start();
return subscription;
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -30,15 +31,15 @@ public class CourseController {

@GetMapping
public ResponseEntity<CourseGetAllRes> getAllCourse(
@ModelAttribute CourseGetAllReq courseGetAllReq
final @ModelAttribute CourseGetAllReq courseGetAllReq
) {
CourseGetAllRes courseAll = courseService.getAllCourses(courseGetAllReq);
return ResponseEntity.ok(courseAll);
}

@GetMapping("/date-access")
public ResponseEntity<DateAccessGetAllRes> getAllDataAccesCourse(
@UserId Long userId
final @UserId Long userId
) {
DateAccessGetAllRes dateAccessGetAllRes = courseService.getAllDataAccessCourse(userId);
return ResponseEntity.ok(dateAccessGetAllRes);
Expand All @@ -60,6 +61,16 @@ public ResponseEntity<CourseCreateRes> createCourse(
).body(CourseCreateRes.of(course.getId()));
}

@PostMapping("/{courseId}/date-access")
public ResponseEntity<Void> openCourse(
@UserId final Long userId,
@PathVariable final Long courseId,
@RequestBody final PointUseReq pointUseReq
) {
courseService.openCourse(userId,courseId,pointUseReq);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

띄어쓰기요..

return ResponseEntity.ok().build();
}

@GetMapping("/{courseId}")
public ResponseEntity<CourseGetDetailRes> getCourseDetail(@UserId Long userId,
@PathVariable("courseId") Long courseId) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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);
Expand All @@ -45,4 +52,18 @@ public void createCourseTags(final List<TagCreateReq> tags, final Course course)
public void createCoursePlace(final List<CoursePlaceGetReq> places, final Course course) {
coursePlaceService.createCoursePlace(places, course);
}

public void publishEvenUserPoint(User user, PointUseReq pointUseReq) {
Map<String, Object> 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<String, Object> fieldMap = new HashMap<>();
fieldMap.put("userId", user.getId().toString());
redisTemplate.opsForStream().add("courseFree", fieldMap);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package org.dateroad.course.service;

public enum CoursePaymentType {
FREE,POINT
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,25 @@
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;
import org.dateroad.course.facade.AsyncService;
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;
Expand All @@ -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<Course> spec = CourseSpecifications.filterByCriteria(courseGetAllReq);
List<Course> courses = courseRepository.findAll(spec);
List<CourseDtoGetRes> 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);
Expand All @@ -74,14 +79,14 @@ public void deleteCourseLike(Long userId, Long courseId) {
likeRepository.delete(findLike);
}

private <T> List<CourseDtoGetRes> convertToDtoList(List<T> entities, Function<T, Course> converter) {
private <T> List<CourseDtoGetRes> convertToDtoList(final List<T> entities, final Function<T, Course> 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;
Expand All @@ -97,7 +102,7 @@ private CourseDtoGetRes convertToDto(Course course) {
);
}

public DateAccessGetAllRes getAllDataAccessCourse(Long userId) {
public DateAccessGetAllRes getAllDataAccessCourse(final Long userId) {
List<Course> accesses = dateAccessRepository.findCoursesByUserId(userId);
List<CourseDtoGetRes> courseDtoGetResList = convertToDtoList(accesses, Function.identity());
return DateAccessGetAllRes.of(courseDtoGetResList);
Expand Down Expand Up @@ -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);
}
Comment on lines +183 to +190
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

성능측면으로 비교군이 적을 경우 switch보다 if가 낫다고 합니다!ㅎㅎ

https://thinkpro.tistory.com/132

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

엇 예전에는 if-else 가 성능적으로 좋았지만 현재는 그렇게 차이가 나지 않고 switch를 쓰는게 더 효과적이라고 합니다!
저도 보고 찾아보다가 알게되어서 공유합니다!!
https://backendcode.tistory.com/212

}
}

public CourseGetDetailRes getCourseDetail(final Long userId, final Long courseId) {

//course - courseId, title, date, start_at, description, totalCost, city, totalTime
Expand Down
Loading
Loading