diff --git a/build.gradle b/build.gradle index 279ab39..a5af9b9 100644 --- a/build.gradle +++ b/build.gradle @@ -1,38 +1,39 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.2.3' - id 'io.spring.dependency-management' version '1.1.4' + id 'java' + id 'org.springframework.boot' version '3.2.3' + id 'io.spring.dependency-management' version '1.1.4' } group = 'site' version = '0.0.1-SNAPSHOT' java { - sourceCompatibility = '17' + sourceCompatibility = '17' } configurations { - compileOnly { - extendsFrom annotationProcessor - } + compileOnly { + extendsFrom annotationProcessor + } } repositories { - mavenCentral() + mavenCentral() } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springframework.boot:spring-boot-starter-web' - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-test' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform() } jar { - enabled = false + enabled = false } diff --git a/src/main/java/site/youtogether/config/RedisConfig.java b/src/main/java/site/youtogether/config/RedisConfig.java new file mode 100644 index 0000000..34664e1 --- /dev/null +++ b/src/main/java/site/youtogether/config/RedisConfig.java @@ -0,0 +1,34 @@ +package site.youtogether.config; + +import org.springframework.boot.autoconfigure.data.redis.RedisProperties; +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.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class RedisConfig { + + private final RedisProperties redisProperties; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(redisProperties.getHost(), redisProperties.getPort()); + } + + @Bean + public RedisTemplate redisStringTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + + return redisTemplate; + } + +} diff --git a/src/main/java/site/youtogether/config/WebConfig.java b/src/main/java/site/youtogether/config/WebConfig.java new file mode 100644 index 0000000..f49cd9d --- /dev/null +++ b/src/main/java/site/youtogether/config/WebConfig.java @@ -0,0 +1,23 @@ +package site.youtogether.config; + +import java.util.List; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import lombok.RequiredArgsConstructor; +import site.youtogether.resolver.AddressArgumentResolver; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final AddressArgumentResolver addressArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(addressArgumentResolver); + } + +} diff --git a/src/main/java/site/youtogether/exception/CustomException.java b/src/main/java/site/youtogether/exception/CustomException.java new file mode 100644 index 0000000..d491267 --- /dev/null +++ b/src/main/java/site/youtogether/exception/CustomException.java @@ -0,0 +1,17 @@ +package site.youtogether.exception; + +import org.springframework.http.HttpStatus; + +import lombok.Getter; + +@Getter +public class CustomException extends RuntimeException { + + private final HttpStatus status; + + public CustomException(ErrorType errorType) { + super(errorType.getMessage()); + this.status = errorType.getStatus(); + } + +} diff --git a/src/main/java/site/youtogether/exception/ErrorType.java b/src/main/java/site/youtogether/exception/ErrorType.java new file mode 100644 index 0000000..4f60eb3 --- /dev/null +++ b/src/main/java/site/youtogether/exception/ErrorType.java @@ -0,0 +1,21 @@ +package site.youtogether.exception; + +import org.springframework.http.HttpStatus; + +import lombok.Getter; + +@Getter +public enum ErrorType { + + // Room + SINGLE_ROOM_PARTICIPATION_VIOLATION(HttpStatus.BAD_REQUEST, "하나의 방에만 참가할 수 있습니다"); + + private final HttpStatus status; + private final String message; + + ErrorType(HttpStatus status, String message) { + this.status = status; + this.message = message; + } + +} diff --git a/src/main/java/site/youtogether/exception/GlobalExceptionHandler.java b/src/main/java/site/youtogether/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..38f01bb --- /dev/null +++ b/src/main/java/site/youtogether/exception/GlobalExceptionHandler.java @@ -0,0 +1,51 @@ +package site.youtogether.exception; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.context.support.DefaultMessageSourceResolvable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.BindException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import site.youtogether.util.api.ApiResponse; +import site.youtogether.util.api.ResponseResult; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(BindException.class) + public ResponseEntity> handleBindException(BindException exception) { + return ResponseEntity.badRequest() + .body(ApiResponse.of(HttpStatus.BAD_REQUEST, ResponseResult.EXCEPTION_OCCURRED, + exception.getBindingResult().getFieldErrors().stream() + .collect(Collectors.groupingBy(FieldError::getField)) + .entrySet().stream() + .map(error -> { + Map fieldError = new LinkedHashMap<>(); + fieldError.put("type", error.getKey()); + fieldError.put("message", error.getValue().stream() + .map(DefaultMessageSourceResolvable::getDefaultMessage) + .collect(Collectors.joining(", "))); + return fieldError; + }) + ) + ); + } + + @ExceptionHandler(CustomException.class) + public ResponseEntity> handleCustomException(CustomException customException) { + Map error = new LinkedHashMap<>(2); + error.put("type", customException.getClass().getSimpleName()); + error.put("message", customException.getMessage()); + + return ResponseEntity.badRequest() + .body(ApiResponse.of(customException.getStatus(), ResponseResult.EXCEPTION_OCCURRED, List.of(error))); + } + +} diff --git a/src/main/java/site/youtogether/exception/room/SingleRoomParticipationViolationException.java b/src/main/java/site/youtogether/exception/room/SingleRoomParticipationViolationException.java new file mode 100644 index 0000000..47834bf --- /dev/null +++ b/src/main/java/site/youtogether/exception/room/SingleRoomParticipationViolationException.java @@ -0,0 +1,12 @@ +package site.youtogether.exception.room; + +import site.youtogether.exception.CustomException; +import site.youtogether.exception.ErrorType; + +public class SingleRoomParticipationViolationException extends CustomException { + + public SingleRoomParticipationViolationException() { + super(ErrorType.SINGLE_ROOM_PARTICIPATION_VIOLATION); + } + +} diff --git a/src/main/java/site/youtogether/resolver/Address.java b/src/main/java/site/youtogether/resolver/Address.java new file mode 100644 index 0000000..acc6793 --- /dev/null +++ b/src/main/java/site/youtogether/resolver/Address.java @@ -0,0 +1,11 @@ +package site.youtogether.resolver; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface Address { +} diff --git a/src/main/java/site/youtogether/resolver/AddressArgumentResolver.java b/src/main/java/site/youtogether/resolver/AddressArgumentResolver.java new file mode 100644 index 0000000..0786da7 --- /dev/null +++ b/src/main/java/site/youtogether/resolver/AddressArgumentResolver.java @@ -0,0 +1,32 @@ +package site.youtogether.resolver; + +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import jakarta.servlet.http.HttpServletRequest; + +@Component +public class AddressArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + boolean hasAddressAnnotation = parameter.hasParameterAnnotation(Address.class); + boolean isStringType = String.class.isAssignableFrom(parameter.getParameterType()); + + return hasAddressAnnotation && isStringType; + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) throws Exception { + HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); + + assert request != null; + return request.getLocalAddr(); + } + +} diff --git a/src/main/java/site/youtogether/room/Room.java b/src/main/java/site/youtogether/room/Room.java new file mode 100644 index 0000000..9407a0b --- /dev/null +++ b/src/main/java/site/youtogether/room/Room.java @@ -0,0 +1,29 @@ +package site.youtogether.room; + +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; + +import lombok.Builder; +import lombok.Getter; +import site.youtogether.util.RandomUtil; + +@RedisHash(value = "room") +@Getter +public class Room { + + @Id + private final String code; + + private final String title; + private final int capacity; + private final String password; + + @Builder + public Room(String title, int capacity, String password) { + this.code = RandomUtil.generateRoomCode(); + this.title = title; + this.capacity = capacity; + this.password = password; + } + +} diff --git a/src/main/java/site/youtogether/room/application/RoomService.java b/src/main/java/site/youtogether/room/application/RoomService.java new file mode 100644 index 0000000..4fcb394 --- /dev/null +++ b/src/main/java/site/youtogether/room/application/RoomService.java @@ -0,0 +1,51 @@ +package site.youtogether.room.application; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import site.youtogether.exception.room.SingleRoomParticipationViolationException; +import site.youtogether.room.Room; +import site.youtogether.room.dto.RoomCode; +import site.youtogether.room.dto.RoomSettings; +import site.youtogether.room.infrastructure.RedisStorage; +import site.youtogether.room.infrastructure.RoomStorage; +import site.youtogether.user.Role; +import site.youtogether.user.User; +import site.youtogether.user.infrastructure.UserStorage; +import site.youtogether.util.RandomUtil; + +@Service +@RequiredArgsConstructor +public class RoomService { + + private final RedisStorage redisStorage; + private final RoomStorage roomStorage; + private final UserStorage userStorage; + + public RoomCode create(String address, RoomSettings roomSettings) { + if (redisStorage.existsInActiveAddress(address)) { + throw new SingleRoomParticipationViolationException(); + } + + Room room = Room.builder() + .title(roomSettings.getTitle()) + .capacity(roomSettings.getCapacity()) + .password(roomSettings.getPassword()) + .build(); + + User host = User.builder() + .address(address) + .nickname(RandomUtil.generateUserNickname()) + .role(Role.HOST) + .build(); + + roomStorage.save(room); + userStorage.save(host); + + redisStorage.addActiveAddress(address); + redisStorage.addParticipant(room.getCode(), address); + + return new RoomCode(room); + } + +} diff --git a/src/main/java/site/youtogether/room/dto/RoomCode.java b/src/main/java/site/youtogether/room/dto/RoomCode.java new file mode 100644 index 0000000..44c8cbb --- /dev/null +++ b/src/main/java/site/youtogether/room/dto/RoomCode.java @@ -0,0 +1,15 @@ +package site.youtogether.room.dto; + +import lombok.Getter; +import site.youtogether.room.Room; + +@Getter +public class RoomCode { + + private final String roomCode; + + public RoomCode(Room room) { + this.roomCode = room.getCode(); + } + +} diff --git a/src/main/java/site/youtogether/room/dto/RoomSettings.java b/src/main/java/site/youtogether/room/dto/RoomSettings.java new file mode 100644 index 0000000..2697e4c --- /dev/null +++ b/src/main/java/site/youtogether/room/dto/RoomSettings.java @@ -0,0 +1,23 @@ +package site.youtogether.room.dto; + +import org.hibernate.validator.constraints.Range; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Getter; + +@Getter +public class RoomSettings { + + @NotBlank + @Size(min = 1, max = 30) + private String title; + + @Range(min = 2, max = 10) + private int capacity; + + @Pattern(regexp = "^[0-9a-zA-Z]{5,10}$") + private String password; + +} diff --git a/src/main/java/site/youtogether/room/infrastructure/RedisStorage.java b/src/main/java/site/youtogether/room/infrastructure/RedisStorage.java new file mode 100644 index 0000000..8120fbb --- /dev/null +++ b/src/main/java/site/youtogether/room/infrastructure/RedisStorage.java @@ -0,0 +1,29 @@ +package site.youtogether.room.infrastructure; + +import static site.youtogether.util.AppConstants.*; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class RedisStorage { + + private final RedisTemplate redisStringTemplate; + + public Boolean existsInActiveAddress(String address) { + return redisStringTemplate.opsForSet().isMember(ACTIVE_ADDRESS_KEY, address); + } + + public void addActiveAddress(String address) { + redisStringTemplate.opsForSet().add(ACTIVE_ADDRESS_KEY, address); + } + + public void addParticipant(String roomCode, String address) { + String participantKey = PARTICIPANTS_KEY_PREFIX + roomCode; + redisStringTemplate.opsForSet().add(participantKey, address); + } + +} diff --git a/src/main/java/site/youtogether/room/infrastructure/RoomStorage.java b/src/main/java/site/youtogether/room/infrastructure/RoomStorage.java new file mode 100644 index 0000000..8ab5572 --- /dev/null +++ b/src/main/java/site/youtogether/room/infrastructure/RoomStorage.java @@ -0,0 +1,11 @@ +package site.youtogether.room.infrastructure; + +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +import site.youtogether.room.Room; + +@Repository +public interface RoomStorage extends CrudRepository { + +} diff --git a/src/main/java/site/youtogether/room/presentation/RoomController.java b/src/main/java/site/youtogether/room/presentation/RoomController.java new file mode 100644 index 0000000..86606c0 --- /dev/null +++ b/src/main/java/site/youtogether/room/presentation/RoomController.java @@ -0,0 +1,30 @@ +package site.youtogether.room.presentation; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import site.youtogether.resolver.Address; +import site.youtogether.room.application.RoomService; +import site.youtogether.room.dto.RoomCode; +import site.youtogether.room.dto.RoomSettings; +import site.youtogether.util.api.ApiResponse; +import site.youtogether.util.api.ResponseResult; + +@RestController +@RequiredArgsConstructor +public class RoomController { + + private final RoomService roomService; + + @PostMapping("/rooms") + public ResponseEntity> createRoom(@Address String address, @Valid @RequestBody RoomSettings roomSettings) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.created(ResponseResult.ROOM_CREATION_SUCCESS, roomService.create(address, roomSettings))); + } + +} diff --git a/src/main/java/site/youtogether/user/Role.java b/src/main/java/site/youtogether/user/Role.java new file mode 100644 index 0000000..a1dc456 --- /dev/null +++ b/src/main/java/site/youtogether/user/Role.java @@ -0,0 +1,7 @@ +package site.youtogether.user; + +public enum Role { + + HOST, MANAGER, EDITOR, GUEST, VIEWER + +} diff --git a/src/main/java/site/youtogether/user/User.java b/src/main/java/site/youtogether/user/User.java new file mode 100644 index 0000000..23b473b --- /dev/null +++ b/src/main/java/site/youtogether/user/User.java @@ -0,0 +1,26 @@ +package site.youtogether.user; + +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; + +import lombok.Builder; +import lombok.Getter; + +@RedisHash(value = "user") +@Getter +public class User { + + @Id + private final String address; + + private final String nickname; + private final Role role; + + @Builder + public User(String address, String nickname, Role role) { + this.address = address; + this.nickname = nickname; + this.role = role; + } + +} diff --git a/src/main/java/site/youtogether/user/infrastructure/UserStorage.java b/src/main/java/site/youtogether/user/infrastructure/UserStorage.java new file mode 100644 index 0000000..d3b797a --- /dev/null +++ b/src/main/java/site/youtogether/user/infrastructure/UserStorage.java @@ -0,0 +1,11 @@ +package site.youtogether.user.infrastructure; + +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +import site.youtogether.user.User; + +@Repository +public interface UserStorage extends CrudRepository { + +} diff --git a/src/main/java/site/youtogether/util/AppConstants.java b/src/main/java/site/youtogether/util/AppConstants.java new file mode 100644 index 0000000..1eec7f4 --- /dev/null +++ b/src/main/java/site/youtogether/util/AppConstants.java @@ -0,0 +1,14 @@ +package site.youtogether.util; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class AppConstants { + + public static final String ACTIVE_ADDRESS_KEY = "active"; + public static final String ROOM_KEY_PREFIX = "room:"; + public static final String USER_KEY_PREFIX = "user:"; + public static final String PARTICIPANTS_KEY_PREFIX = "participants:"; + +} diff --git a/src/main/java/site/youtogether/util/RandomUtil.java b/src/main/java/site/youtogether/util/RandomUtil.java new file mode 100644 index 0000000..ee55342 --- /dev/null +++ b/src/main/java/site/youtogether/util/RandomUtil.java @@ -0,0 +1,41 @@ +package site.youtogether.util; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class RandomUtil { + + /** + * generate random room code + * length is 10 + * using a-z A-Z 0-9 + */ + public static String generateRoomCode() { + String randomString = UUID.randomUUID().toString().replaceAll("-", ""); + + return randomString.substring(0, 10); + } + + /** + * generate random user nickname + * sample list size is 20 + */ + public static String generateUserNickname() { + List samples = List.of( + "MysticTiger", "SilverPhoenix", "ElectricWanderer", "CrimsonDragon", "EmeraldSpecter", + "MidnightRider", "VelvetWhisperer", "CosmicStrider", "SolarGoddess", "ArcticShadow", + "EnigmaticSphinx", "ScarletSorcerer", "CelestialWatcher", "LunarJester", "SapphireDreamer", + "GoldenGlider", "CrimsonFalcon", "EchoingWhisper", "EmberPhoenix", "RadiantRebel" + ); + + int randomIndex = ThreadLocalRandom.current().nextInt(samples.size()); + + return samples.get(randomIndex); + } + +} diff --git a/src/main/java/site/youtogether/util/api/ApiResponse.java b/src/main/java/site/youtogether/util/api/ApiResponse.java new file mode 100644 index 0000000..fc58825 --- /dev/null +++ b/src/main/java/site/youtogether/util/api/ApiResponse.java @@ -0,0 +1,34 @@ +package site.youtogether.util.api; + +import org.springframework.http.HttpStatus; + +import lombok.Getter; + +@Getter +public class ApiResponse { + + private final int code; + private final String status; + private final String result; + private final T data; + + public ApiResponse(HttpStatus status, ResponseResult result, T data) { + this.code = status.value(); + this.status = status.getReasonPhrase(); + this.result = result.getDescription(); + this.data = data; + } + + public static ApiResponse of(HttpStatus status, ResponseResult result, T data) { + return new ApiResponse<>(status, result, data); + } + + public static ApiResponse ok(ResponseResult result, T data) { + return new ApiResponse<>(HttpStatus.OK, result, data); + } + + public static ApiResponse created(ResponseResult result, T data) { + return new ApiResponse<>(HttpStatus.CREATED, result, data); + } + +} diff --git a/src/main/java/site/youtogether/util/api/ResponseResult.java b/src/main/java/site/youtogether/util/api/ResponseResult.java new file mode 100644 index 0000000..c615ec1 --- /dev/null +++ b/src/main/java/site/youtogether/util/api/ResponseResult.java @@ -0,0 +1,20 @@ +package site.youtogether.util.api; + +import lombok.Getter; + +@Getter +public enum ResponseResult { + + // Common + EXCEPTION_OCCURRED("예외가 발생했습니다"), + + // Room + ROOM_CREATION_SUCCESS("방 생성에 성공했습니다"); + + private final String description; + + ResponseResult(String description) { + this.description = description; + } + +}