diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..0e4f020 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,15 @@ +--- +name: feature issue about: "이슈 갈 겨 ~~~~~ \U0001F3C4‍♂️" +title: '' +labels: '' +assignees: '' +--- + +## 📌 Feature Issue + + +## 📝 To-do + + +- [ ] +- [ ] diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..88dec87 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,11 @@ +## ✒️ 관련 이슈번호 + +- Closes #536 + +## 🔑 Key Changes + +1. 내용 + - 설명 + +## 📢 To Reviewers +- diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..93c1d27 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +application.yml +application-local.yml +application-dev.yml +application-prod.yml diff --git a/blossom-api/README.md b/blossom-api/README.md new file mode 100644 index 0000000..b8837da --- /dev/null +++ b/blossom-api/README.md @@ -0,0 +1,9 @@ +# muyaho-api + +> 애플리케이션 모듈 계층 (API) + +## 하위 모듈 + +- blossom-common +- blossom-domain +- blossom-external diff --git a/blossom-api/build.gradle b/blossom-api/build.gradle new file mode 100644 index 0000000..6216bc9 --- /dev/null +++ b/blossom-api/build.gradle @@ -0,0 +1,14 @@ +dependencies { + implementation project(':blossom-domain') + implementation project(':blossom-external') + + // spring mvc + implementation 'org.springframework.boot:spring-boot-starter-web' + + // redis + implementation "org.springframework.boot:spring-boot-starter-data-redis" + implementation "org.springframework.session:spring-session-data-redis" + + // swagger + implementation 'org.springdoc:springdoc-openapi-ui:1.5.4' +} diff --git a/blossom-api/src/main/java/com/seoultech/blossom/api/ApiApplication.java b/blossom-api/src/main/java/com/seoultech/blossom/api/ApiApplication.java new file mode 100644 index 0000000..f808aa2 --- /dev/null +++ b/blossom-api/src/main/java/com/seoultech/blossom/api/ApiApplication.java @@ -0,0 +1,19 @@ +package com.seoultech.blossom.api; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +import com.seoultech.blossom.domain.BlossomDomainRoot; +import com.seoultech.blossom.external.BlossomExternalRoot; + +@SpringBootApplication(scanBasePackageClasses = { + BlossomDomainRoot.class, + BlossomExternalRoot.class +}) +public class ApiApplication { + + public static void main(String[] args) { + SpringApplication.run(ApiApplication.class, args); + } + +} diff --git a/blossom-api/src/main/java/com/seoultech/blossom/api/config/WebConfig.java b/blossom-api/src/main/java/com/seoultech/blossom/api/config/WebConfig.java new file mode 100644 index 0000000..a652749 --- /dev/null +++ b/blossom-api/src/main/java/com/seoultech/blossom/api/config/WebConfig.java @@ -0,0 +1,60 @@ +package com.seoultech.blossom.api.config; + +import java.util.List; + +import org.springframework.context.MessageSource; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.support.ReloadableResourceBundleMessageSource; +import org.springframework.validation.Validator; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import com.seoultech.blossom.api.config.interceptor.auth.AuthInterceptor; +import com.seoultech.blossom.api.config.resolver.UserIdResolver; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Configuration +public class WebConfig implements WebMvcConfigurer { + + private final AuthInterceptor authInterceptor; + private final UserIdResolver userIdResolver; + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins("*") + .allowedMethods("GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(authInterceptor); + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(userIdResolver); + } + + @Bean + public MessageSource validationMessageSource() { + ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource(); + messageSource.setBasename("classpath:/messages/validation"); + messageSource.setDefaultEncoding("UTF-8"); + return messageSource; + } + + @Override + public Validator getValidator() { + LocalValidatorFactoryBean bean = new LocalValidatorFactoryBean(); + bean.setValidationMessageSource(validationMessageSource()); + return bean; + } + +} diff --git a/blossom-api/src/main/java/com/seoultech/blossom/api/config/filter/FilterConfig.java b/blossom-api/src/main/java/com/seoultech/blossom/api/config/filter/FilterConfig.java new file mode 100644 index 0000000..218d808 --- /dev/null +++ b/blossom-api/src/main/java/com/seoultech/blossom/api/config/filter/FilterConfig.java @@ -0,0 +1,20 @@ +package com.seoultech.blossom.api.config.filter; + +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +@Configuration +public class FilterConfig { + + @Profile({"dev", "prod"}) + @Bean + public FilterRegistrationBean requestLoggingFilter() { + FilterRegistrationBean filter = new FilterRegistrationBean<>(new RequestLoggingFilter()); + filter.addUrlPatterns("/api/*"); + filter.setOrder(1); + return filter; + } + +} diff --git a/blossom-api/src/main/java/com/seoultech/blossom/api/config/filter/RequestLoggingFilter.java b/blossom-api/src/main/java/com/seoultech/blossom/api/config/filter/RequestLoggingFilter.java new file mode 100644 index 0000000..bd08a1f --- /dev/null +++ b/blossom-api/src/main/java/com/seoultech/blossom/api/config/filter/RequestLoggingFilter.java @@ -0,0 +1,90 @@ +package com.seoultech.blossom.api.config.filter; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Map; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.web.util.ContentCachingRequestWrapper; +import org.springframework.web.util.ContentCachingResponseWrapper; +import org.springframework.web.util.WebUtils; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class RequestLoggingFilter implements Filter { + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws + IOException, + ServletException { + ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper((HttpServletRequest)request); + ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper( + (HttpServletResponse)response); + + long start = System.currentTimeMillis(); + chain.doFilter(requestWrapper, responseWrapper); + long end = System.currentTimeMillis(); + + log.info("\n" + + "[REQUEST] {} - {} {} - {}s\n" + + "Headers : {}\n" + + "Request : {}\n" + + "Response : {}\n", + ((HttpServletRequest)request).getMethod(), ((HttpServletRequest)request).getRequestURI(), + responseWrapper.getStatus(), (end - start) / 1000.0, + getHeaders((HttpServletRequest)request), + getRequestBody(requestWrapper), + getResponseBody(responseWrapper)); + } + + private Map getHeaders(HttpServletRequest request) { + Map headerMap = new HashMap<>(); + + Enumeration headerArray = request.getHeaderNames(); + while (headerArray.hasMoreElements()) { + String headerName = headerArray.nextElement(); + headerMap.put(headerName, request.getHeader(headerName)); + } + return headerMap; + } + + private String getRequestBody(ContentCachingRequestWrapper request) { + ContentCachingRequestWrapper wrapper = WebUtils.getNativeRequest(request, ContentCachingRequestWrapper.class); + if (wrapper != null) { + byte[] buf = wrapper.getContentAsByteArray(); + if (buf.length > 0) { + try { + return new String(buf, 0, buf.length, wrapper.getCharacterEncoding()); + } catch (UnsupportedEncodingException e) { + return " - "; + } + } + } + return " - "; + } + + private String getResponseBody(final HttpServletResponse response) throws IOException { + String payload = null; + ContentCachingResponseWrapper wrapper = WebUtils.getNativeResponse(response, + ContentCachingResponseWrapper.class); + if (wrapper != null) { + byte[] buf = wrapper.getContentAsByteArray(); + if (buf.length > 0) { + payload = new String(buf, 0, buf.length, wrapper.getCharacterEncoding()); + wrapper.copyBodyToResponse(); + } + } + return payload == null ? " - " : payload; + } + +} diff --git a/blossom-api/src/main/java/com/seoultech/blossom/api/config/interceptor/auth/Auth.java b/blossom-api/src/main/java/com/seoultech/blossom/api/config/interceptor/auth/Auth.java new file mode 100644 index 0000000..22a23de --- /dev/null +++ b/blossom-api/src/main/java/com/seoultech/blossom/api/config/interceptor/auth/Auth.java @@ -0,0 +1,11 @@ +package com.seoultech.blossom.api.config.interceptor.auth; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Auth { +} diff --git a/blossom-api/src/main/java/com/seoultech/blossom/api/config/interceptor/auth/AuthInterceptor.java b/blossom-api/src/main/java/com/seoultech/blossom/api/config/interceptor/auth/AuthInterceptor.java new file mode 100644 index 0000000..d0b9bf7 --- /dev/null +++ b/blossom-api/src/main/java/com/seoultech/blossom/api/config/interceptor/auth/AuthInterceptor.java @@ -0,0 +1,34 @@ +package com.seoultech.blossom.api.config.interceptor.auth; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +import com.seoultech.blossom.common.constant.JwtKey; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Component +public class AuthInterceptor implements HandlerInterceptor { + + private final LoginCheckHandler loginCheckHandler; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + if (!(handler instanceof HandlerMethod)) { + return true; + } + HandlerMethod handlerMethod = (HandlerMethod)handler; + Auth auth = handlerMethod.getMethodAnnotation(Auth.class); + if (auth == null) { + return true; + } + Long userId = loginCheckHandler.getUserId(request); + request.setAttribute(JwtKey.USER_ID, userId); + return true; + } +} diff --git a/blossom-api/src/main/java/com/seoultech/blossom/api/config/interceptor/auth/LoginCheckHandler.java b/blossom-api/src/main/java/com/seoultech/blossom/api/config/interceptor/auth/LoginCheckHandler.java new file mode 100644 index 0000000..f768fdd --- /dev/null +++ b/blossom-api/src/main/java/com/seoultech/blossom/api/config/interceptor/auth/LoginCheckHandler.java @@ -0,0 +1,32 @@ +package com.seoultech.blossom.api.config.interceptor.auth; + +import javax.servlet.http.HttpServletRequest; + +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import com.seoultech.blossom.common.exception.UnAuthorizedException; +import com.seoultech.blossom.common.util.JwtUtils; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Component +public class LoginCheckHandler { + + private final JwtUtils jwtUtils; + + public Long getUserId(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + String accessToken = bearerToken.substring("Bearer ".length()); + if (jwtUtils.validateToken(accessToken)) { + Long userId = jwtUtils.getUserIdFromJwt(accessToken); + if (userId != null) { + return userId; + } + } + } + throw new UnAuthorizedException(String.format("잘못된 JWT (%s) 입니다.", bearerToken)); + } +} diff --git a/blossom-api/src/main/java/com/seoultech/blossom/api/config/redis/RedisConfig.java b/blossom-api/src/main/java/com/seoultech/blossom/api/config/redis/RedisConfig.java new file mode 100644 index 0000000..ea683e6 --- /dev/null +++ b/blossom-api/src/main/java/com/seoultech/blossom/api/config/redis/RedisConfig.java @@ -0,0 +1,40 @@ +package com.seoultech.blossom.api.config.redis; + +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.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.session.data.redis.config.ConfigureRedisAction; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Configuration +@EnableRedisRepositories +public class RedisConfig { + + private final RedisProperties redisProperties; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(redisProperties.getHost(), redisProperties.getPort()); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + return redisTemplate; + } + + @Bean + public ConfigureRedisAction configureRedisAction() { + return ConfigureRedisAction.NO_OP; + } +} diff --git a/blossom-api/src/main/java/com/seoultech/blossom/api/config/resolver/UserId.java b/blossom-api/src/main/java/com/seoultech/blossom/api/config/resolver/UserId.java new file mode 100644 index 0000000..0d13f76 --- /dev/null +++ b/blossom-api/src/main/java/com/seoultech/blossom/api/config/resolver/UserId.java @@ -0,0 +1,11 @@ +package com.seoultech.blossom.api.config.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 UserId { +} diff --git a/blossom-api/src/main/java/com/seoultech/blossom/api/config/resolver/UserIdResolver.java b/blossom-api/src/main/java/com/seoultech/blossom/api/config/resolver/UserIdResolver.java new file mode 100644 index 0000000..117f54a --- /dev/null +++ b/blossom-api/src/main/java/com/seoultech/blossom/api/config/resolver/UserIdResolver.java @@ -0,0 +1,35 @@ +package com.seoultech.blossom.api.config.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 com.seoultech.blossom.api.config.interceptor.auth.Auth; +import com.seoultech.blossom.common.constant.JwtKey; +import com.seoultech.blossom.common.exception.InternalServerException; + +@Component +public class UserIdResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(UserId.class) && Long.class.equals(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + if (parameter.getMethodAnnotation(Auth.class) == null) { + throw new InternalServerException("인증이 필요한 컨트롤러 입니다. @Auth 어노테이션을 붙여주세요."); + } + Object object = webRequest.getAttribute(JwtKey.USER_ID, 0); + if (object == null) { + throw new InternalServerException( + String.format("USER_ID를 가져오지 못했습니다. (%s - %s)", parameter.getClass(), parameter.getMethod())); + } + return object; + } +} diff --git a/blossom-api/src/main/java/com/seoultech/blossom/api/config/swagger/SwaggerConfig.java b/blossom-api/src/main/java/com/seoultech/blossom/api/config/swagger/SwaggerConfig.java new file mode 100644 index 0000000..1a6c22a --- /dev/null +++ b/blossom-api/src/main/java/com/seoultech/blossom/api/config/swagger/SwaggerConfig.java @@ -0,0 +1,31 @@ +package com.seoultech.blossom.api.config.swagger; + +import org.springdoc.core.SpringDocUtils; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.seoultech.blossom.api.config.resolver.UserId; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityScheme; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI customOpenAPI() { + return new OpenAPI().components(new Components().addSecuritySchemes("Authorization", new SecurityScheme() + .type(SecurityScheme.Type.HTTP).scheme("bearer").bearerFormat("JWT") + .in(SecurityScheme.In.HEADER).name("Authorization"))) + .info(new Info() + .title("Blossom API Server") + .description("Blossom API Docs")); + } + + static { + SpringDocUtils.getConfig().addAnnotationsToIgnore(UserId.class); + } + +} diff --git a/blossom-api/src/main/java/com/seoultech/blossom/api/controller/advice/ControllerAdvice.java b/blossom-api/src/main/java/com/seoultech/blossom/api/controller/advice/ControllerAdvice.java new file mode 100644 index 0000000..ce9696b --- /dev/null +++ b/blossom-api/src/main/java/com/seoultech/blossom/api/controller/advice/ControllerAdvice.java @@ -0,0 +1,142 @@ +package com.seoultech.blossom.api.controller.advice; + +import static com.seoultech.blossom.common.exception.ErrorCode.*; + +import java.util.Objects; + +import org.springframework.http.HttpStatus; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.BindException; +import org.springframework.web.HttpMediaTypeException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.ServletRequestBindingException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import com.fasterxml.jackson.databind.exc.InvalidFormatException; +import com.seoultech.blossom.common.dto.ApiResponse; +import com.seoultech.blossom.common.exception.BadGatewayException; +import com.seoultech.blossom.common.exception.ConflictException; +import com.seoultech.blossom.common.exception.ErrorCode; +import com.seoultech.blossom.common.exception.ForbiddenException; +import com.seoultech.blossom.common.exception.NotFoundException; +import com.seoultech.blossom.common.exception.UnAuthorizedException; +import com.seoultech.blossom.common.exception.ValidationException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@RestControllerAdvice +public class ControllerAdvice { + + /** + * 400 BadRequest + */ + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(BindException.class) + protected ApiResponse handleBadRequest(BindException exception) { + log.error(exception.getMessage(), exception); + return ApiResponse.error(VALIDATION_EXCEPTION, + Objects.requireNonNull(exception.getBindingResult().getFieldError()).getDefaultMessage()); + } + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler({ + HttpMessageNotReadableException.class, + InvalidFormatException.class, + ServletRequestBindingException.class + }) + protected ApiResponse handleInvalidFormatException(final Exception exception) { + log.error(exception.getMessage(), exception); + return ApiResponse.error(ErrorCode.VALIDATION_EXCEPTION); + } + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(ValidationException.class) + protected ApiResponse handleValidationException(final ValidationException exception) { + log.error(exception.getMessage(), exception); + return ApiResponse.error(exception.getErrorCode()); + } + + /** + * 401 UnAuthorized + */ + @ResponseStatus(HttpStatus.UNAUTHORIZED) + @ExceptionHandler(UnAuthorizedException.class) + protected ApiResponse handleUnAuthorizedException(final UnAuthorizedException exception) { + log.error(exception.getMessage(), exception); + return ApiResponse.error(exception.getErrorCode()); + } + + /** + * 403 Forbidden + */ + @ResponseStatus(HttpStatus.FORBIDDEN) + @ExceptionHandler(ForbiddenException.class) + protected ApiResponse handleForbiddenException(final ForbiddenException exception) { + log.error(exception.getMessage(), exception); + return ApiResponse.error(exception.getErrorCode()); + } + + /** + * 404 NotFound + */ + @ResponseStatus(HttpStatus.NOT_FOUND) + @ExceptionHandler(NotFoundException.class) + protected ApiResponse handleNotFoundException(final NotFoundException exception) { + log.error(exception.getMessage(), exception); + return ApiResponse.error(exception.getErrorCode()); + } + + /** + * 405 Method Not Supported + */ + @ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED) + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + protected ApiResponse handleHttpRequestMethodNotSupportedException( + HttpRequestMethodNotSupportedException exception) { + return ApiResponse.error(METHOD_NOT_ALLOWED_EXCEPTION); + } + + /** + * 409 Conflict + */ + @ResponseStatus(HttpStatus.CONFLICT) + @ExceptionHandler(ConflictException.class) + protected ApiResponse handleConflictException(final ConflictException exception) { + log.error(exception.getMessage(), exception); + return ApiResponse.error(exception.getErrorCode()); + } + + /** + * 415 UnSupported Media Type + */ + @ResponseStatus(HttpStatus.UNSUPPORTED_MEDIA_TYPE) + @ExceptionHandler(HttpMediaTypeException.class) + protected ApiResponse handleHttpMediaTypeException(final HttpMediaTypeException exception) { + return ApiResponse.error(UNSUPPORTED_MEDIA_TYPE); + } + + /** + * 502 Bad Gateway + */ + @ResponseStatus(HttpStatus.BAD_GATEWAY) + @ExceptionHandler(BadGatewayException.class) + protected ApiResponse handleBadGatewayException(final BadGatewayException exception) { + log.error(exception.getMessage(), exception); + return ApiResponse.error(exception.getErrorCode()); + } + + /** + * 500 Internal Server Error + */ + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + @ExceptionHandler(Exception.class) + protected ApiResponse handleException(final Exception exception) { + log.error(exception.getMessage(), exception); + return ApiResponse.error(INTERNAL_SERVER_EXCEPTION); + } +} diff --git a/blossom-api/src/main/resources/messages/validation.properties b/blossom-api/src/main/resources/messages/validation.properties new file mode 100644 index 0000000..e69de29 diff --git a/blossom-api/src/main/resources/sql/data.sql b/blossom-api/src/main/resources/sql/data.sql new file mode 100644 index 0000000..e69de29 diff --git a/blossom-api/src/main/resources/sql/schema.sql b/blossom-api/src/main/resources/sql/schema.sql new file mode 100644 index 0000000..f854550 --- /dev/null +++ b/blossom-api/src/main/resources/sql/schema.sql @@ -0,0 +1,10 @@ +DROP TABLE IF EXISTS user; + +CREATE TABLE user +( + id bigint auto_increment primary key, + social_id varchar(300) not null, + social_type varchar(30) not null, + created_at datetime(6) not null, + modified_at datetime(6) not null +); diff --git a/blossom-common/README.md b/blossom-common/README.md new file mode 100644 index 0000000..ceb2d7a --- /dev/null +++ b/blossom-common/README.md @@ -0,0 +1,12 @@ +# blossom-common + +> 공통 모듈 계층 + +- Constant +- API Response DTO +- Exception +- Utility + +## 하위 모듈 + +- X diff --git a/blossom-common/build.gradle b/blossom-common/build.gradle new file mode 100644 index 0000000..8c20644 --- /dev/null +++ b/blossom-common/build.gradle @@ -0,0 +1,12 @@ +bootJar { enabled = false } +jar { enabled = true } + +dependencies { + // redis + implementation "org.springframework.boot:spring-boot-starter-data-redis" + + // jwt + implementation group: "io.jsonwebtoken", name: "jjwt-api", version: "0.11.2" + implementation group: "io.jsonwebtoken", name: "jjwt-impl", version: "0.11.2" + implementation group: "io.jsonwebtoken", name: "jjwt-jackson", version: "0.11.2" +} diff --git a/blossom-common/src/main/java/com/seoultech/blossom/common/constant/JwtKey.java b/blossom-common/src/main/java/com/seoultech/blossom/common/constant/JwtKey.java new file mode 100644 index 0000000..2b8be2e --- /dev/null +++ b/blossom-common/src/main/java/com/seoultech/blossom/common/constant/JwtKey.java @@ -0,0 +1,10 @@ +package com.seoultech.blossom.common.constant; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class JwtKey { + + public static final String USER_ID = "USER_ID"; +} diff --git a/blossom-common/src/main/java/com/seoultech/blossom/common/constant/RedisKey.java b/blossom-common/src/main/java/com/seoultech/blossom/common/constant/RedisKey.java new file mode 100644 index 0000000..62db2e3 --- /dev/null +++ b/blossom-common/src/main/java/com/seoultech/blossom/common/constant/RedisKey.java @@ -0,0 +1,6 @@ +package com.seoultech.blossom.common.constant; + +public final class RedisKey { + + public static final String REFRESH_TOKEN = "RT:"; +} diff --git a/blossom-common/src/main/java/com/seoultech/blossom/common/dto/ApiResponse.java b/blossom-common/src/main/java/com/seoultech/blossom/common/dto/ApiResponse.java new file mode 100644 index 0000000..839a8dd --- /dev/null +++ b/blossom-common/src/main/java/com/seoultech/blossom/common/dto/ApiResponse.java @@ -0,0 +1,34 @@ +package com.seoultech.blossom.common.dto; + +import com.seoultech.blossom.common.exception.ErrorCode; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@ToString +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class ApiResponse { + + public static final ApiResponse SUCCESS = success(""); + private String code; + private String message; + private T data; + + public static ApiResponse success(T data) { + return new ApiResponse<>("", "", data); + } + + public static ApiResponse error(ErrorCode errorCode) { + return new ApiResponse<>(errorCode.getCode(), errorCode.getMessage(), null); + } + + public static ApiResponse error(ErrorCode errorCode, String message) { + return new ApiResponse<>(errorCode.getCode(), message, null); + } + +} diff --git a/blossom-common/src/main/java/com/seoultech/blossom/common/exception/BadGatewayException.java b/blossom-common/src/main/java/com/seoultech/blossom/common/exception/BadGatewayException.java new file mode 100644 index 0000000..b99888e --- /dev/null +++ b/blossom-common/src/main/java/com/seoultech/blossom/common/exception/BadGatewayException.java @@ -0,0 +1,9 @@ +package com.seoultech.blossom.common.exception; + +public class BadGatewayException extends BlossomException { + + public BadGatewayException(String message) { + super(message, ErrorCode.BAD_GATEWAY_EXCEPTION); + } + +} diff --git a/blossom-common/src/main/java/com/seoultech/blossom/common/exception/BlossomException.java b/blossom-common/src/main/java/com/seoultech/blossom/common/exception/BlossomException.java new file mode 100644 index 0000000..b70ad10 --- /dev/null +++ b/blossom-common/src/main/java/com/seoultech/blossom/common/exception/BlossomException.java @@ -0,0 +1,19 @@ +package com.seoultech.blossom.common.exception; + +import lombok.Getter; + +@Getter +public abstract class BlossomException extends RuntimeException { + + private ErrorCode errorCode; + + public BlossomException(String message, ErrorCode errorCode) { + super(message); + this.errorCode = errorCode; + } + + public BlossomException(String message) { + super(message); + } + +} diff --git a/blossom-common/src/main/java/com/seoultech/blossom/common/exception/ConflictException.java b/blossom-common/src/main/java/com/seoultech/blossom/common/exception/ConflictException.java new file mode 100644 index 0000000..77f6b1b --- /dev/null +++ b/blossom-common/src/main/java/com/seoultech/blossom/common/exception/ConflictException.java @@ -0,0 +1,13 @@ +package com.seoultech.blossom.common.exception; + +public class ConflictException extends BlossomException { + + public ConflictException(String message) { + super(message, ErrorCode.CONFLICT_EXCEPTION); + } + + public ConflictException(String message, ErrorCode errorCode) { + super(message, errorCode); + } + +} diff --git a/blossom-common/src/main/java/com/seoultech/blossom/common/exception/ErrorCode.java b/blossom-common/src/main/java/com/seoultech/blossom/common/exception/ErrorCode.java new file mode 100644 index 0000000..ee97b5f --- /dev/null +++ b/blossom-common/src/main/java/com/seoultech/blossom/common/exception/ErrorCode.java @@ -0,0 +1,34 @@ +package com.seoultech.blossom.common.exception; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public enum ErrorCode { + + // Common + UNAUTHORIZED_EXCEPTION("C001", "토큰이 만료되었습니다. 다시 로그인 해주세요."), + NOT_FOUND_EXCEPTION("C002", "존재하지 않습니다."), + VALIDATION_EXCEPTION("C003", "잘못된 요청입니다."), + CONFLICT_EXCEPTION("C004", "이미 존재합니다."), + INTERNAL_SERVER_EXCEPTION("C005", "서버 내부에서 에러가 발생하였습니다."), + METHOD_NOT_ALLOWED_EXCEPTION("C006", "지원하지 않는 메소드입니다."), + BAD_GATEWAY_EXCEPTION("C007", "외부 연동 중 에러가 발생하였습니다."), + FORBIDDEN_EXCEPTION("C008", "허용하지 않는 접근입니다."), + UNSUPPORTED_MEDIA_TYPE("C009", "허용하지 않는 미디어 타입입니다."), + + // Validation Exception + VALIDATION_INVALID_TOKEN_EXCEPTION("V001", "잘못된 토큰입니다."); + + // Forbidden Exception + + // NotFound Exception + + // Conflict Exception + + private final String code; + private final String message; + +} diff --git a/blossom-common/src/main/java/com/seoultech/blossom/common/exception/ForbiddenException.java b/blossom-common/src/main/java/com/seoultech/blossom/common/exception/ForbiddenException.java new file mode 100644 index 0000000..6318e91 --- /dev/null +++ b/blossom-common/src/main/java/com/seoultech/blossom/common/exception/ForbiddenException.java @@ -0,0 +1,13 @@ +package com.seoultech.blossom.common.exception; + +public class ForbiddenException extends BlossomException { + + public ForbiddenException(String message) { + super(message, ErrorCode.FORBIDDEN_EXCEPTION); + } + + public ForbiddenException(String message, ErrorCode errorCode) { + super(message, errorCode); + } + +} diff --git a/blossom-common/src/main/java/com/seoultech/blossom/common/exception/InternalServerException.java b/blossom-common/src/main/java/com/seoultech/blossom/common/exception/InternalServerException.java new file mode 100644 index 0000000..9a0c9d6 --- /dev/null +++ b/blossom-common/src/main/java/com/seoultech/blossom/common/exception/InternalServerException.java @@ -0,0 +1,13 @@ +package com.seoultech.blossom.common.exception; + +public class InternalServerException extends BlossomException { + + public InternalServerException(String message) { + super(message, ErrorCode.INTERNAL_SERVER_EXCEPTION); + } + + public InternalServerException(String message, ErrorCode errorCode) { + super(message, errorCode); + } + +} diff --git a/blossom-common/src/main/java/com/seoultech/blossom/common/exception/NotFoundException.java b/blossom-common/src/main/java/com/seoultech/blossom/common/exception/NotFoundException.java new file mode 100644 index 0000000..73e95aa --- /dev/null +++ b/blossom-common/src/main/java/com/seoultech/blossom/common/exception/NotFoundException.java @@ -0,0 +1,13 @@ +package com.seoultech.blossom.common.exception; + +public class NotFoundException extends BlossomException { + + public NotFoundException(String message) { + super(message, ErrorCode.NOT_FOUND_EXCEPTION); + } + + public NotFoundException(String message, ErrorCode errorCode) { + super(message, errorCode); + } + +} diff --git a/blossom-common/src/main/java/com/seoultech/blossom/common/exception/UnAuthorizedException.java b/blossom-common/src/main/java/com/seoultech/blossom/common/exception/UnAuthorizedException.java new file mode 100644 index 0000000..fcdb180 --- /dev/null +++ b/blossom-common/src/main/java/com/seoultech/blossom/common/exception/UnAuthorizedException.java @@ -0,0 +1,9 @@ +package com.seoultech.blossom.common.exception; + +public class UnAuthorizedException extends BlossomException { + + public UnAuthorizedException(String message) { + super(message, ErrorCode.UNAUTHORIZED_EXCEPTION); + } + +} diff --git a/blossom-common/src/main/java/com/seoultech/blossom/common/exception/ValidationException.java b/blossom-common/src/main/java/com/seoultech/blossom/common/exception/ValidationException.java new file mode 100644 index 0000000..1838313 --- /dev/null +++ b/blossom-common/src/main/java/com/seoultech/blossom/common/exception/ValidationException.java @@ -0,0 +1,13 @@ +package com.seoultech.blossom.common.exception; + +public class ValidationException extends BlossomException { + + public ValidationException(String message) { + super(message, ErrorCode.VALIDATION_EXCEPTION); + } + + public ValidationException(String message, ErrorCode errorCode) { + super(message, errorCode); + } + +} diff --git a/blossom-common/src/main/java/com/seoultech/blossom/common/util/JwtUtils.java b/blossom-common/src/main/java/com/seoultech/blossom/common/util/JwtUtils.java new file mode 100644 index 0000000..a73f777 --- /dev/null +++ b/blossom-common/src/main/java/com/seoultech/blossom/common/util/JwtUtils.java @@ -0,0 +1,101 @@ +package com.seoultech.blossom.common.util; + +import java.security.Key; +import java.util.Date; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import com.seoultech.blossom.common.constant.JwtKey; +import com.seoultech.blossom.common.constant.RedisKey; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.io.DecodingException; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +public class JwtUtils { + + private final RedisTemplate redisTemplate; + private static final long ACCESS_TOKEN_EXPIRE_TIME = 365 * 24 * 60 * 60 * 1000L; // 1년 + private static final long REFRESH_TOKEN_EXPIRE_TIME = 365 * 24 * 60 * 60 * 1000L; // 1년 + private static final long EXPIRED_TIME = 1L; + + private final Key secretKey; + + public JwtUtils(@Value("${jwt.secret}") String secretKey, RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + this.secretKey = Keys.hmacShaKeyFor(keyBytes); + } + + public List createTokenInfo(Long userId) { + + long now = (new Date()).getTime(); + Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME); + Date refreshTokenExpiresIn = new Date(now + REFRESH_TOKEN_EXPIRE_TIME); + + // Access Token 생성 + String accessToken = Jwts.builder() + .claim(JwtKey.USER_ID, userId) + .setExpiration(accessTokenExpiresIn) + .signWith(secretKey, SignatureAlgorithm.HS512) + .compact(); + + // Refresh Token 생성 + String refreshToken = Jwts.builder() + .setExpiration(refreshTokenExpiresIn) + .signWith(secretKey, SignatureAlgorithm.HS512) + .compact(); + + redisTemplate.opsForValue() + .set(RedisKey.REFRESH_TOKEN + userId, refreshToken, REFRESH_TOKEN_EXPIRE_TIME, TimeUnit.MILLISECONDS); + + return List.of(accessToken, refreshToken); + } + + public void expireRefreshToken(Long userId) { + redisTemplate.opsForValue().set(RedisKey.REFRESH_TOKEN + userId, "", EXPIRED_TIME, TimeUnit.MILLISECONDS); + } + + public boolean validateToken(String token) { + try { + Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token); + return true; + } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException | DecodingException e) { + log.warn("Invalid JWT Token", e); + } catch (ExpiredJwtException e) { + log.warn("Expired JWT Token", e); + } catch (UnsupportedJwtException e) { + log.warn("Unsupported JWT Token", e); + } catch (IllegalArgumentException e) { + log.warn("JWT claims string is empty.", e); + } catch (Exception e) { + log.error("Unhandled JWT exception", e); + } + return false; + } + + public Long getUserIdFromJwt(String accessToken) { + return parseClaims(accessToken).get(JwtKey.USER_ID, Long.class); + } + + private Claims parseClaims(String accessToken) { + try { + return Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(accessToken).getBody(); + } catch (ExpiredJwtException e) { + return e.getClaims(); + } + } +} diff --git a/blossom-domain/README.md b/blossom-domain/README.md new file mode 100644 index 0000000..2642485 --- /dev/null +++ b/blossom-domain/README.md @@ -0,0 +1,10 @@ +# muyaho-domain + +> 도메인 모듈 계층 + +- Domain +- Repository + +## 하위 모듈 + +- X diff --git a/blossom-domain/build.gradle b/blossom-domain/build.gradle new file mode 100644 index 0000000..ae52224 --- /dev/null +++ b/blossom-domain/build.gradle @@ -0,0 +1,34 @@ +bootJar { enabled = false } +jar { enabled = true } + +dependencies { + // spring data jpa + api group: "org.springframework.boot", name: "spring-boot-starter-data-jpa", version: "2.3.8.RELEASE" + + // mysql + runtimeOnly "mysql:mysql-connector-java" + + // jackson + compile group: "com.fasterxml.jackson.core", name: "jackson-databind", version: "2.9.10" + + // querydsl + implementation "com.querydsl:querydsl-jpa" + implementation "com.querydsl:querydsl-core" + annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties["querydsl.version"]}:jpa" + annotationProcessor "jakarta.persistence:jakarta.persistence-api:2.2.3" + annotationProcessor "jakarta.annotation:jakarta.annotation-api:1.3.5" +} + +def queryDslDir = "build/querydsl/generated" + +sourceSets { + main.java.srcDirs += [queryDslDir] +} + +tasks.withType(JavaCompile) { + options.annotationProcessorGeneratedSourcesDirectory = file(queryDslDir) +} + +clean.doLast { + file(queryDslDir).deleteDir() +} diff --git a/blossom-domain/src/main/java/com/seoultech/blossom/domain/BlossomDomainRoot.java b/blossom-domain/src/main/java/com/seoultech/blossom/domain/BlossomDomainRoot.java new file mode 100644 index 0000000..3f3ae08 --- /dev/null +++ b/blossom-domain/src/main/java/com/seoultech/blossom/domain/BlossomDomainRoot.java @@ -0,0 +1,9 @@ +package com.seoultech.blossom.domain; + +import org.springframework.context.annotation.ComponentScan; + +@ComponentScan(basePackageClasses = { + BlossomDomainRoot.class +}) +public interface BlossomDomainRoot { +} diff --git a/blossom-domain/src/main/java/com/seoultech/blossom/domain/config/jpa/JpaConfig.java b/blossom-domain/src/main/java/com/seoultech/blossom/domain/config/jpa/JpaConfig.java new file mode 100644 index 0000000..0d943f3 --- /dev/null +++ b/blossom-domain/src/main/java/com/seoultech/blossom/domain/config/jpa/JpaConfig.java @@ -0,0 +1,16 @@ +package com.seoultech.blossom.domain.config.jpa; + +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +import com.seoultech.blossom.domain.BlossomDomainRoot; + +@Configuration +@EntityScan(basePackageClasses = {BlossomDomainRoot.class}) +@EnableJpaRepositories(basePackageClasses = {BlossomDomainRoot.class}) +@EnableJpaAuditing +public class JpaConfig { + +} diff --git a/blossom-domain/src/main/java/com/seoultech/blossom/domain/config/querydsl/QueryDslConfig.java b/blossom-domain/src/main/java/com/seoultech/blossom/domain/config/querydsl/QueryDslConfig.java new file mode 100644 index 0000000..68eaa71 --- /dev/null +++ b/blossom-domain/src/main/java/com/seoultech/blossom/domain/config/querydsl/QueryDslConfig.java @@ -0,0 +1,22 @@ +package com.seoultech.blossom.domain.config.querydsl; + +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.querydsl.jpa.impl.JPAQueryFactory; + +@Configuration +public class QueryDslConfig { + + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory queryFactory() { + return new JPAQueryFactory(entityManager); + } + +} diff --git a/blossom-domain/src/main/java/com/seoultech/blossom/domain/domain/common/BaseEntity.java b/blossom-domain/src/main/java/com/seoultech/blossom/domain/domain/common/BaseEntity.java new file mode 100644 index 0000000..787bfe2 --- /dev/null +++ b/blossom-domain/src/main/java/com/seoultech/blossom/domain/domain/common/BaseEntity.java @@ -0,0 +1,32 @@ +package com.seoultech.blossom.domain.domain.common; + +import java.time.LocalDateTime; + +import javax.persistence.Column; +import javax.persistence.EntityListeners; +import javax.persistence.MappedSuperclass; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import lombok.Getter; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public class BaseEntity { + + @CreatedDate + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul") + @Column + private LocalDateTime createdAt; + + @LastModifiedDate + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul") + @Column + private LocalDateTime modifiedAt; + +} diff --git a/blossom-domain/src/main/java/com/seoultech/blossom/domain/domain/user/SocialInfo.java b/blossom-domain/src/main/java/com/seoultech/blossom/domain/domain/user/SocialInfo.java new file mode 100644 index 0000000..cb2aba0 --- /dev/null +++ b/blossom-domain/src/main/java/com/seoultech/blossom/domain/domain/user/SocialInfo.java @@ -0,0 +1,34 @@ +package com.seoultech.blossom.domain.domain.user; + +import javax.persistence.Column; +import javax.persistence.Embeddable; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; + +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode +@Embeddable +public class SocialInfo { + + @Column(nullable = false, length = 300) + private String socialId; + + @Column(nullable = false, length = 30) + @Enumerated(EnumType.STRING) + private UserSocialType socialType; + + private SocialInfo(String socialId, UserSocialType socialType) { + this.socialId = socialId; + this.socialType = socialType; + } + + public static SocialInfo of(String socialId, UserSocialType socialType) { + return new SocialInfo(socialId, socialType); + } +} diff --git a/blossom-domain/src/main/java/com/seoultech/blossom/domain/domain/user/User.java b/blossom-domain/src/main/java/com/seoultech/blossom/domain/domain/user/User.java new file mode 100644 index 0000000..47fc2ae --- /dev/null +++ b/blossom-domain/src/main/java/com/seoultech/blossom/domain/domain/user/User.java @@ -0,0 +1,30 @@ +package com.seoultech.blossom.domain.domain.user; + +import javax.persistence.Embedded; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; + +import com.seoultech.blossom.domain.domain.common.BaseEntity; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(access = AccessLevel.PRIVATE) +public class User extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Embedded + private SocialInfo socialInfo; +} diff --git a/blossom-domain/src/main/java/com/seoultech/blossom/domain/domain/user/UserSocialType.java b/blossom-domain/src/main/java/com/seoultech/blossom/domain/domain/user/UserSocialType.java new file mode 100644 index 0000000..f7a5d16 --- /dev/null +++ b/blossom-domain/src/main/java/com/seoultech/blossom/domain/domain/user/UserSocialType.java @@ -0,0 +1,13 @@ +package com.seoultech.blossom.domain.domain.user; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public enum UserSocialType { + KAKAO("카카오톡"); + + private final String value; +} diff --git a/blossom-domain/src/main/java/com/seoultech/blossom/domain/domain/user/repository/UserRepository.java b/blossom-domain/src/main/java/com/seoultech/blossom/domain/domain/user/repository/UserRepository.java new file mode 100644 index 0000000..3f2ca70 --- /dev/null +++ b/blossom-domain/src/main/java/com/seoultech/blossom/domain/domain/user/repository/UserRepository.java @@ -0,0 +1,8 @@ +package com.seoultech.blossom.domain.domain.user.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.seoultech.blossom.domain.domain.user.User; + +public interface UserRepository extends JpaRepository, UserRepositoryCustom { +} diff --git a/blossom-domain/src/main/java/com/seoultech/blossom/domain/domain/user/repository/UserRepositoryCustom.java b/blossom-domain/src/main/java/com/seoultech/blossom/domain/domain/user/repository/UserRepositoryCustom.java new file mode 100644 index 0000000..cb34e14 --- /dev/null +++ b/blossom-domain/src/main/java/com/seoultech/blossom/domain/domain/user/repository/UserRepositoryCustom.java @@ -0,0 +1,4 @@ +package com.seoultech.blossom.domain.domain.user.repository; + +public interface UserRepositoryCustom { +} diff --git a/blossom-domain/src/main/java/com/seoultech/blossom/domain/domain/user/repository/UserRepositoryImpl.java b/blossom-domain/src/main/java/com/seoultech/blossom/domain/domain/user/repository/UserRepositoryImpl.java new file mode 100644 index 0000000..622b5f6 --- /dev/null +++ b/blossom-domain/src/main/java/com/seoultech/blossom/domain/domain/user/repository/UserRepositoryImpl.java @@ -0,0 +1,12 @@ +package com.seoultech.blossom.domain.domain.user.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class UserRepositoryImpl implements UserRepositoryCustom { + + private final JPAQueryFactory queryFactory; + +} diff --git a/blossom-external/README.md b/blossom-external/README.md new file mode 100644 index 0000000..a9e1895 --- /dev/null +++ b/blossom-external/README.md @@ -0,0 +1,7 @@ +# muyaho-external + +> 외부 API 연동 모듈 + +## 하위 모듈 + +- blossom-common diff --git a/blossom-external/build.gradle b/blossom-external/build.gradle new file mode 100644 index 0000000..1416097 --- /dev/null +++ b/blossom-external/build.gradle @@ -0,0 +1,9 @@ +bootJar { enabled = false } +jar { enabled = true } + +dependencies { + api project(":blossom-common") + + // spring webflux + implementation "org.springframework.boot:spring-boot-starter-webflux" +} diff --git a/blossom-external/src/main/java/com/seoultech/blossom/external/BlossomExternalRoot.java b/blossom-external/src/main/java/com/seoultech/blossom/external/BlossomExternalRoot.java new file mode 100644 index 0000000..0fc704f --- /dev/null +++ b/blossom-external/src/main/java/com/seoultech/blossom/external/BlossomExternalRoot.java @@ -0,0 +1,9 @@ +package com.seoultech.blossom.external; + +import org.springframework.context.annotation.ComponentScan; + +@ComponentScan(basePackageClasses = { + BlossomExternalRoot.class, +}) +public interface BlossomExternalRoot { +} diff --git a/blossom-external/src/main/java/com/seoultech/blossom/external/client/auth/kakao/KakaoApiCaller.java b/blossom-external/src/main/java/com/seoultech/blossom/external/client/auth/kakao/KakaoApiCaller.java new file mode 100644 index 0000000..4e0324b --- /dev/null +++ b/blossom-external/src/main/java/com/seoultech/blossom/external/client/auth/kakao/KakaoApiCaller.java @@ -0,0 +1,9 @@ +package com.seoultech.blossom.external.client.auth.kakao; + +import com.seoultech.blossom.external.client.auth.kakao.dto.response.KakaoProfileResponse; + +public interface KakaoApiCaller { + + KakaoProfileResponse getProfileInfo(String accessToken); + +} diff --git a/blossom-external/src/main/java/com/seoultech/blossom/external/client/auth/kakao/WebClientKakaoCaller.java b/blossom-external/src/main/java/com/seoultech/blossom/external/client/auth/kakao/WebClientKakaoCaller.java new file mode 100644 index 0000000..3ac86c9 --- /dev/null +++ b/blossom-external/src/main/java/com/seoultech/blossom/external/client/auth/kakao/WebClientKakaoCaller.java @@ -0,0 +1,34 @@ +package com.seoultech.blossom.external.client.auth.kakao; + +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +import com.seoultech.blossom.common.exception.BadGatewayException; +import com.seoultech.blossom.common.exception.ValidationException; +import com.seoultech.blossom.external.client.auth.kakao.dto.response.KakaoProfileResponse; + +import lombok.RequiredArgsConstructor; +import reactor.core.publisher.Mono; + +@RequiredArgsConstructor +@Component +public class WebClientKakaoCaller implements KakaoApiCaller { + + private final WebClient webClient; + + @Override + public KakaoProfileResponse getProfileInfo(String accessToken) { + return webClient.get() + .uri("https://kapi.kakao.com/v2/user/me") + .headers(headers -> headers.setBearerAuth(accessToken)) + .retrieve() + .onStatus(HttpStatus::is4xxClientError, clientResponse -> + Mono.error(new ValidationException(String.format("잘못된 카카오 액세스 토큰 (%s) 입니다.", accessToken)))) + .onStatus(HttpStatus::is5xxServerError, clientResponse -> + Mono.error(new BadGatewayException("카카오 로그인 연동 중 에러가 발생하였습니다."))) + .bodyToMono(KakaoProfileResponse.class) + .block(); + } + +} diff --git a/blossom-external/src/main/java/com/seoultech/blossom/external/client/auth/kakao/dto/response/KakaoProfileResponse.java b/blossom-external/src/main/java/com/seoultech/blossom/external/client/auth/kakao/dto/response/KakaoProfileResponse.java new file mode 100644 index 0000000..4c7a2cf --- /dev/null +++ b/blossom-external/src/main/java/com/seoultech/blossom/external/client/auth/kakao/dto/response/KakaoProfileResponse.java @@ -0,0 +1,22 @@ +package com.seoultech.blossom.external.client.auth.kakao.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.PropertyNamingStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@ToString +@Getter +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy.class) +public class KakaoProfileResponse { + + private String id; + +} diff --git a/blossom-external/src/main/java/com/seoultech/blossom/external/config/webclient/WebClientConfig.java b/blossom-external/src/main/java/com/seoultech/blossom/external/config/webclient/WebClientConfig.java new file mode 100644 index 0000000..0e17d19 --- /dev/null +++ b/blossom-external/src/main/java/com/seoultech/blossom/external/config/webclient/WebClientConfig.java @@ -0,0 +1,38 @@ +package com.seoultech.blossom.external.config.webclient; + +import java.util.concurrent.TimeUnit; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.reactive.function.client.ExchangeStrategies; +import org.springframework.web.reactive.function.client.WebClient; + +import io.netty.channel.ChannelOption; +import io.netty.handler.timeout.ReadTimeoutHandler; +import reactor.netty.http.client.HttpClient; +import reactor.netty.tcp.TcpClient; + +@Configuration +public class WebClientConfig { + + @Bean + public WebClient webClient() { + return WebClient.builder() + .exchangeStrategies(ExchangeStrategies.builder() + .codecs(configurer -> configurer + .defaultCodecs() + .maxInMemorySize(16 * 1024 * 1024)) + .build()) + .clientConnector(new ReactorClientHttpConnector( + HttpClient.from( + TcpClient.create() + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 20000) + .doOnConnected(conn -> + conn.addHandler(new ReadTimeoutHandler(20000, TimeUnit.MILLISECONDS)) + ) + ) + )).build(); + } + +} diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..64c37db --- /dev/null +++ b/build.gradle @@ -0,0 +1,46 @@ +buildscript { + ext { + springBootVersion = "2.3.8.RELEASE" + } + + repositories { + mavenCentral() + } + + dependencies { + classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}") + } +} + +subprojects { + group = "com.seoultech" + + apply plugin: "java-library" + apply plugin: "org.springframework.boot" + apply plugin: "io.spring.dependency-management" + + sourceCompatibility = "11" + + repositories { + mavenCentral() + } + + configurations { + compileOnly { + extendsFrom annotationProcessor + } + } + + dependencies { + implementation "org.springframework.boot:spring-boot-starter-validation" + testImplementation("org.springframework.boot:spring-boot-starter-test") + + // lombok + compileOnly "org.projectlombok:lombok" + annotationProcessor "org.projectlombok:lombok" + } + + test { + useJUnitPlatform() + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1deab88 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ +version: '3' + +services: + + mysql: + image: mysql/mysql-server:5.7 + container_name: blossom-mysql + environment: + MYSQL_ROOT_HOST: '%' + MYSQL_USER: "blossom" + MYSQL_PASSWORD: "blossom" + MYSQL_DATABASE: "blossom" + ports: + - "3316:3306" + command: + - "mysqld" + - "--character-set-server=utf8mb4" + - "--collation-server=utf8mb4_unicode_ci" + + redis: + image: redis:alpine + container_name: blossom-redis + command: redis-server --port 6379 + hostname: redis_boot + labels: + - "name=redis" + - "mode=standalone" + ports: + - 6389:6379 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..249e583 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..442d913 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.3-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..a69d9cb --- /dev/null +++ b/gradlew @@ -0,0 +1,240 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..53a6b23 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,91 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..59b52bc --- /dev/null +++ b/settings.gradle @@ -0,0 +1,6 @@ +rootProject.name = 'blossom-server' +include 'blossom-common' +include 'blossom-domain' +include 'blossom-external' +include 'blossom-api' +