From e6ef3f0671018cc2716d4a19243edfd9fb81f5d7 Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 11 Jun 2025 11:01:53 +0900 Subject: [PATCH] add: init project --- Common module should not have bootJar | 8 - ai-recommend/build.gradle | 8 +- .../controller/RecommendationController.java | 2 +- .../src/main/resources/application.yml | 6 + build.gradle | 54 +--- common/build.gradle | 21 ++ .../common/config/SecurityConfig.java | 62 ++--- .../common/config/SwaggerConfig.java | 32 +-- .../smarketing/common/dto/PageResponse.java | 36 ++- .../exception/GlobalExceptionHandler.java | 138 +++------- .../security/JwtAuthenticationFilter.java | 45 ++-- .../common/security/JwtTokenProvider.java | 120 ++++----- gradle/wrapper/gradle-wrapper.jar | Bin 43705 -> 0 bytes gradle/wrapper/gradle-wrapper.properties | 7 - gradlew | 251 ------------------ gradlew.bat | 94 ------- marketing-content/build.gradle | 2 +- .../src/main/resources/application.yml | 4 + member/build.gradle | 5 +- .../smarketing/member/config/JpaConfig.java | 13 + .../smarketing/member/config/RedisConfig.java | 53 ++++ .../member/controller/AuthController.java | 26 +- .../member/controller/MemberController.java | 80 ++++-- .../member/dto/DuplicateCheckResponse.java | 41 ++- .../smarketing/member/dto/LoginRequest.java | 19 +- .../smarketing/member/dto/LoginResponse.java | 35 ++- .../member/dto/PasswordValidationRequest.java | 15 +- .../member/dto/RegisterRequest.java | 54 ++-- .../member/dto/TokenRefreshRequest.java | 13 +- .../smarketing/member/dto/TokenResponse.java | 16 +- .../member/dto/ValidationResponse.java | 44 ++- .../won/smarketing/member/entity/Member.java | 106 ++------ .../member/repository/MemberRepository.java | 6 +- .../member/service/AuthService.java | 18 +- .../member/service/AuthServiceImpl.java | 143 ++-------- .../member/service/MemberService.java | 26 +- .../member/service/MemberServiceImpl.java | 113 +++++--- member/src/main/resources/application.yml | 33 +-- .../dto/DetailedMarketingTipResponse.java | 0 .../presentation/dto/ErrorResponseDto.java | 3 +- .../dto/MarketingTipGenerationRequest.java | 0 .../presentation/dto/MarketingTipRequest.java | 0 .../dto/MarketingTipResponse.java | 0 .../presentation/dto/StoreInfoDto.java | 0 .../presentation/dto/WeatherInfoDto.java | 0 settings.gradle | 1 - store/build.gradle | 1 + .../smarketing/store/config/JpaConfig.java | 28 ++ .../store/dto/MenuCreateRequest.java | 52 ++-- .../smarketing/store/dto/MenuResponse.java | 36 +-- .../smarketing/store/dto/SalesResponse.java | 22 +- .../store/dto/StoreCreateRequest.java | 91 +++---- .../smarketing/store/dto/StoreResponse.java | 62 ++--- .../store/dto/StoreUpdateRequest.java | 71 +++-- .../com/won/smarketing/store/entity/Menu.java | 91 +++---- .../won/smarketing/store/entity/Sales.java | 1 + .../won/smarketing/store/entity/Store.java | 161 ++++------- .../store/repository/StoreRepository.java | 27 +- .../smarketing/store/service/MenuService.java | 10 +- .../store/service/SalesService.java | 6 +- .../store/service/SalesServiceImpl.java | 48 ++-- .../store/service/StoreService.java | 19 +- .../store/service/StoreServiceImpl.java | 101 ++++--- 63 files changed, 1088 insertions(+), 1492 deletions(-) delete mode 100644 Common module should not have bootJar create mode 100644 common/build.gradle delete mode 100644 gradle/wrapper/gradle-wrapper.jar delete mode 100644 gradle/wrapper/gradle-wrapper.properties delete mode 100644 gradlew delete mode 100644 gradlew.bat create mode 100644 member/src/main/java/com/won/smarketing/member/config/JpaConfig.java create mode 100644 member/src/main/java/com/won/smarketing/member/config/RedisConfig.java rename {ai-recommend => recommend}/src/main/java/com/won/smarketing/recommend/presentation/dto/DetailedMarketingTipResponse.java (100%) rename {ai-recommend => recommend}/src/main/java/com/won/smarketing/recommend/presentation/dto/ErrorResponseDto.java (99%) rename {ai-recommend => recommend}/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipGenerationRequest.java (100%) rename {ai-recommend => recommend}/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipRequest.java (100%) rename {ai-recommend => recommend}/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java (100%) rename {ai-recommend => recommend}/src/main/java/com/won/smarketing/recommend/presentation/dto/StoreInfoDto.java (100%) rename {ai-recommend => recommend}/src/main/java/com/won/smarketing/recommend/presentation/dto/WeatherInfoDto.java (100%) create mode 100644 store/src/main/java/com/won/smarketing/store/config/JpaConfig.java diff --git a/Common module should not have bootJar b/Common module should not have bootJar deleted file mode 100644 index 0b49c8c..0000000 --- a/Common module should not have bootJar +++ /dev/null @@ -1,8 +0,0 @@ -tasks.getByName('bootJar') { - enabled = false -} - -tasks.getByName('jar') { - enabled = true - archiveClassifier = '' -} diff --git a/ai-recommend/build.gradle b/ai-recommend/build.gradle index 6306f15..34e83d1 100644 --- a/ai-recommend/build.gradle +++ b/ai-recommend/build.gradle @@ -1,10 +1,16 @@ dependencies { implementation project(':common') - + implementation 'com.mysql:mysql-connector-j' + // HTTP Client for external API implementation 'org.springframework.boot:spring-boot-starter-webflux' } +//external: +// ai: +// api-url: ${EXTERNAL_AI_URL:https://api.openai.com/v1} +// api-key: ${EXTERNAL_AI_KEY:your-api-key} + bootJar { archiveFileName = "ai-recommend-service.jar" } diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java index 182ee6e..fedc727 100644 --- a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java +++ b/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java @@ -6,11 +6,11 @@ import com.won.smarketing.recommend.presentation.dto.MarketingTipRequest; import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import javax.validation.Valid; /** * AI 마케팅 추천을 위한 REST API 컨트롤러 diff --git a/ai-recommend/src/main/resources/application.yml b/ai-recommend/src/main/resources/application.yml index 6604bab..8a6cb92 100644 --- a/ai-recommend/src/main/resources/application.yml +++ b/ai-recommend/src/main/resources/application.yml @@ -19,6 +19,12 @@ spring: dialect: org.hibernate.dialect.PostgreSQLDialect format_sql: true +ai: + service: + url: ${AI_SERVICE_URL:http://localhost:8080/ai} + timeout: ${AI_SERVICE_TIMEOUT:30000} + + external: claude-ai: api-key: ${CLAUDE_AI_API_KEY:your-claude-api-key} diff --git a/build.gradle b/build.gradle index 36a1ec0..ef2c19b 100644 --- a/build.gradle +++ b/build.gradle @@ -1,53 +1,5 @@ plugins { - id 'org.springframework.boot' version '3.4.0' apply false - id 'io.spring.dependency-management' version '1.1.4' apply false id 'java' -} - -subprojects { - apply plugin: 'java' - apply plugin: 'org.springframework.boot' - apply plugin: 'io.spring.dependency-management' - - group = 'com.won.smarketing' - version = '0.0.1-SNAPSHOT' - sourceCompatibility = '21' - - repositories { - mavenCentral() - } - - dependencies { - // Spring Boot Starters - implementation 'org.springframework.boot:spring-boot-starter' - implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-security' - implementation 'org.springframework.boot:spring-boot-starter-actuator' - implementation 'org.springframework.boot:spring-boot-starter-data-redis' - - // Database - runtimeOnly 'org.postgresql:postgresql' - - // Swagger - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.4.0' - - // JWT - implementation 'io.jsonwebtoken:jjwt-api:0.11.5' - runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' - runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' - - // Lombok - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' - - // Test - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.security:spring-security-test' - } - - test { - useJUnitPlatform() - } -} + id 'org.springframework.boot' version '3.4.0' + id 'io.spring.dependency-management' version '1.1.4' +} \ No newline at end of file diff --git a/common/build.gradle b/common/build.gradle new file mode 100644 index 0000000..b1e8282 --- /dev/null +++ b/common/build.gradle @@ -0,0 +1,21 @@ +dependencies { + api 'org.springframework.boot:spring-boot-starter-web' + api 'org.springframework.boot:spring-boot-starter-data-jpa' + api 'org.springframework.boot:spring-boot-starter-security' + api 'org.springframework.boot:spring-boot-starter-validation' + api 'org.springframework.boot:spring-boot-starter-data-redis' + api 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' + api 'io.jsonwebtoken:jjwt-api:0.12.3' + api 'io.jsonwebtoken:jjwt-impl:0.12.3' + api 'io.jsonwebtoken:jjwt-jackson:0.12.3' + api 'org.projectlombok:lombok' +} + +jar { + enabled = true + archiveClassifier = '' +} + +bootJar { + enabled = false +} diff --git a/common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java b/common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java index 834b3bc..5c61143 100644 --- a/common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java +++ b/common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java @@ -20,7 +20,7 @@ import java.util.Arrays; /** * Spring Security 설정 클래스 - * 인증, 인가, CORS 등 보안 관련 설정 + * JWT 기반 인증 및 CORS 설정 */ @Configuration @EnableWebSecurity @@ -30,17 +30,7 @@ public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; /** - * 패스워드 인코더 Bean 설정 - * - * @return BCrypt 패스워드 인코더 - */ - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } - - /** - * Security Filter Chain 설정 + * Spring Security 필터 체인 설정 * * @param http HttpSecurity 객체 * @return SecurityFilterChain @@ -49,43 +39,43 @@ public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http - .csrf(AbstractHttpConfigurer::disable) - .cors(cors -> cors.configurationSource(corsConfigurationSource())) - .sessionManagement(session -> - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests(auth -> auth - .requestMatchers( - "/api/member/register", - "/api/member/check-duplicate", - "/api/member/validate-password", - "/api/auth/login", - "/swagger-ui/**", - "/swagger-ui.html", - "/api-docs/**", - "/actuator/**" - ).permitAll() - .anyRequest().authenticated() - ) - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + .csrf(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/auth/**", "/api/member/register", "/api/member/check-duplicate/**", + "/api/member/validate-password", "/swagger-ui/**", "/v3/api-docs/**", + "/swagger-resources/**", "/webjars/**").permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } + /** + * 패스워드 인코더 빈 등록 + * + * @return BCryptPasswordEncoder + */ + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + /** * CORS 설정 * - * @return CORS 설정 소스 + * @return CorsConfigurationSource */ @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOriginPatterns(Arrays.asList("*")); - configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); - configuration.setAllowedHeaders(Arrays.asList("authorization", "content-type", "x-auth-token")); - configuration.setExposedHeaders(Arrays.asList("x-auth-token")); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(Arrays.asList("*")); configuration.setAllowCredentials(true); - configuration.setMaxAge(3600L); - + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; diff --git a/common/src/main/java/com/won/smarketing/common/config/SwaggerConfig.java b/common/src/main/java/com/won/smarketing/common/config/SwaggerConfig.java index 00fcce0..fb21909 100644 --- a/common/src/main/java/com/won/smarketing/common/config/SwaggerConfig.java +++ b/common/src/main/java/com/won/smarketing/common/config/SwaggerConfig.java @@ -9,8 +9,8 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; /** - * Swagger 설정 클래스 - * API 문서화를 위한 OpenAPI 설정 + * Swagger OpenAPI 설정 클래스 + * API 문서화 및 JWT 인증 설정 */ @Configuration public class SwaggerConfig { @@ -18,24 +18,26 @@ public class SwaggerConfig { /** * OpenAPI 설정 * - * @return OpenAPI 인스턴스 + * @return OpenAPI 객체 */ @Bean public OpenAPI openAPI() { - String securitySchemeName = "bearerAuth"; + String jwtSchemeName = "jwtAuth"; + SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwtSchemeName); + Components components = new Components() + .addSecuritySchemes(jwtSchemeName, new SecurityScheme() + .name(jwtSchemeName) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT")); + return new OpenAPI() .info(new Info() - .title("AI 마케팅 서비스 API") - .description("소상공인을 위한 맞춤형 AI 마케팅 솔루션 API 문서") - .version("v1.0.0")) - .addSecurityItem(new SecurityRequirement().addList(securitySchemeName)) - .components(new Components() - .addSecuritySchemes(securitySchemeName, - new SecurityScheme() - .name(securitySchemeName) - .type(SecurityScheme.Type.HTTP) - .scheme("bearer") - .bearerFormat("JWT"))); + .title("스마케팅 API") + .description("소상공인을 위한 AI 마케팅 서비스 API") + .version("1.0.0")) + .addSecurityItem(securityRequirement) + .components(components); } } diff --git a/common/src/main/java/com/won/smarketing/common/dto/PageResponse.java b/common/src/main/java/com/won/smarketing/common/dto/PageResponse.java index af2b74a..ab77b3f 100644 --- a/common/src/main/java/com/won/smarketing/common/dto/PageResponse.java +++ b/common/src/main/java/com/won/smarketing/common/dto/PageResponse.java @@ -9,22 +9,22 @@ import lombok.NoArgsConstructor; import java.util.List; /** - * 페이지네이션 응답 DTO - * 페이지 단위 조회 결과를 담는 공통 형식 + * 페이징 응답 DTO + * 페이징된 데이터 응답에 사용되는 공통 형식 * - * @param 페이지 내용의 데이터 타입 + * @param 응답 데이터 타입 */ @Data @NoArgsConstructor @AllArgsConstructor @Builder -@Schema(description = "페이지네이션 응답") +@Schema(description = "페이징 응답") public class PageResponse { - @Schema(description = "페이지 내용") + @Schema(description = "페이지 컨텐츠", example = "[...]") private List content; - @Schema(description = "현재 페이지 번호", example = "0") + @Schema(description = "페이지 번호 (0부터 시작)", example = "0") private int pageNumber; @Schema(description = "페이지 크기", example = "20") @@ -41,4 +41,28 @@ public class PageResponse { @Schema(description = "마지막 페이지 여부", example = "false") private boolean last; + + /** + * 성공적인 페이징 응답 생성 + * + * @param content 페이지 컨텐츠 + * @param pageNumber 페이지 번호 + * @param pageSize 페이지 크기 + * @param totalElements 전체 요소 수 + * @param 데이터 타입 + * @return 페이징 응답 + */ + public static PageResponse of(List content, int pageNumber, int pageSize, long totalElements) { + int totalPages = (int) Math.ceil((double) totalElements / pageSize); + + return PageResponse.builder() + .content(content) + .pageNumber(pageNumber) + .pageSize(pageSize) + .totalElements(totalElements) + .totalPages(totalPages) + .first(pageNumber == 0) + .last(pageNumber >= totalPages - 1) + .build(); + } } diff --git a/common/src/main/java/com/won/smarketing/common/exception/GlobalExceptionHandler.java b/common/src/main/java/com/won/smarketing/common/exception/GlobalExceptionHandler.java index 05ff103..d2da2b8 100644 --- a/common/src/main/java/com/won/smarketing/common/exception/GlobalExceptionHandler.java +++ b/common/src/main/java/com/won/smarketing/common/exception/GlobalExceptionHandler.java @@ -2,21 +2,18 @@ package com.won.smarketing.common.exception; import com.won.smarketing.common.dto.ApiResponse; import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.validation.BindException; -import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; -import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; -import java.nio.file.AccessDeniedException; +import java.util.HashMap; +import java.util.Map; /** - * 전역 예외 처리 핸들러 - * 애플리케이션에서 발생하는 모든 예외를 처리하여 일관된 응답 형식 제공 + * 전역 예외 처리기 + * 애플리케이션 전반의 예외를 통일된 형식으로 처리 */ @Slf4j @RestControllerAdvice @@ -31,121 +28,52 @@ public class GlobalExceptionHandler { @ExceptionHandler(BusinessException.class) public ResponseEntity> handleBusinessException(BusinessException ex) { log.warn("Business exception occurred: {}", ex.getMessage()); - ErrorCode errorCode = ex.getErrorCode(); + return ResponseEntity - .status(errorCode.getHttpStatus()) - .body(ApiResponse.error(errorCode.getHttpStatus().value(), ex.getMessage())); + .status(ex.getErrorCode().getHttpStatus()) + .body(ApiResponse.error( + ex.getErrorCode().getHttpStatus().value(), + ex.getMessage() + )); } /** - * 유효성 검증 예외 처리 (@Valid 애노테이션) + * 입력값 검증 예외 처리 * - * @param ex 유효성 검증 예외 + * @param ex 입력값 검증 예외 * @return 오류 응답 */ @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity> handleValidationException(MethodArgumentNotValidException ex) { + public ResponseEntity>> handleValidationException( + MethodArgumentNotValidException ex) { log.warn("Validation exception occurred: {}", ex.getMessage()); - String errorMessage = ex.getBindingResult() - .getFieldErrors() - .stream() - .findFirst() - .map(error -> error.getDefaultMessage()) - .orElse("유효성 검증에 실패했습니다."); - return ResponseEntity - .status(HttpStatus.BAD_REQUEST) - .body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), errorMessage)); + Map errors = new HashMap<>(); + ex.getBindingResult().getAllErrors().forEach(error -> { + String fieldName = ((FieldError) error).getField(); + String errorMessage = error.getDefaultMessage(); + errors.put(fieldName, errorMessage); + }); + + return ResponseEntity.badRequest() + .body(ApiResponse.>builder() + .status(400) + .message("입력값 검증에 실패했습니다.") + .data(errors) + .build()); } /** - * 바인딩 예외 처리 - * - * @param ex 바인딩 예외 - * @return 오류 응답 - */ - @ExceptionHandler(BindException.class) - public ResponseEntity> handleBindException(BindException ex) { - log.warn("Bind exception occurred: {}", ex.getMessage()); - String errorMessage = ex.getBindingResult() - .getFieldErrors() - .stream() - .findFirst() - .map(error -> error.getDefaultMessage()) - .orElse("요청 데이터 바인딩에 실패했습니다."); - - return ResponseEntity - .status(HttpStatus.BAD_REQUEST) - .body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), errorMessage)); - } - - /** - * 타입 불일치 예외 처리 - * - * @param ex 타입 불일치 예외 - * @return 오류 응답 - */ - @ExceptionHandler(MethodArgumentTypeMismatchException.class) - public ResponseEntity> handleTypeMismatchException(MethodArgumentTypeMismatchException ex) { - log.warn("Type mismatch exception occurred: {}", ex.getMessage()); - return ResponseEntity - .status(HttpStatus.BAD_REQUEST) - .body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), "잘못된 타입의 값입니다.")); - } - - /** - * 필수 파라미터 누락 예외 처리 - * - * @param ex 필수 파라미터 누락 예외 - * @return 오류 응답 - */ - @ExceptionHandler(MissingServletRequestParameterException.class) - public ResponseEntity> handleMissingParameterException(MissingServletRequestParameterException ex) { - log.warn("Missing parameter exception occurred: {}", ex.getMessage()); - return ResponseEntity - .status(HttpStatus.BAD_REQUEST) - .body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), "필수 요청 파라미터가 누락되었습니다: " + ex.getParameterName())); - } - - /** - * HTTP 메서드 불일치 예외 처리 - * - * @param ex HTTP 메서드 불일치 예외 - * @return 오류 응답 - */ - @ExceptionHandler(HttpRequestMethodNotSupportedException.class) - public ResponseEntity> handleMethodNotSupportedException(HttpRequestMethodNotSupportedException ex) { - log.warn("Method not supported exception occurred: {}", ex.getMessage()); - return ResponseEntity - .status(HttpStatus.METHOD_NOT_ALLOWED) - .body(ApiResponse.error(HttpStatus.METHOD_NOT_ALLOWED.value(), "허용되지 않은 HTTP 메서드입니다.")); - } - - /** - * 접근 거부 예외 처리 - * - * @param ex 접근 거부 예외 - * @return 오류 응답 - */ - @ExceptionHandler(AccessDeniedException.class) - public ResponseEntity> handleAccessDeniedException(AccessDeniedException ex) { - log.warn("Access denied exception occurred: {}", ex.getMessage()); - return ResponseEntity - .status(HttpStatus.FORBIDDEN) - .body(ApiResponse.error(HttpStatus.FORBIDDEN.value(), "접근이 거부되었습니다.")); - } - - /** - * 기타 모든 예외 처리 + * 일반적인 예외 처리 * * @param ex 예외 * @return 오류 응답 */ @ExceptionHandler(Exception.class) - public ResponseEntity> handleGenericException(Exception ex) { + public ResponseEntity> handleException(Exception ex) { log.error("Unexpected exception occurred", ex); - return ResponseEntity - .status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(ApiResponse.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "서버 내부 오류가 발생했습니다.")); + + return ResponseEntity.internalServerError() + .body(ApiResponse.error(500, "서버 내부 오류가 발생했습니다.")); } } diff --git a/common/src/main/java/com/won/smarketing/common/security/JwtAuthenticationFilter.java b/common/src/main/java/com/won/smarketing/common/security/JwtAuthenticationFilter.java index 26aee46..16381bd 100644 --- a/common/src/main/java/com/won/smarketing/common/security/JwtAuthenticationFilter.java +++ b/common/src/main/java/com/won/smarketing/common/security/JwtAuthenticationFilter.java @@ -7,8 +7,8 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; @@ -18,7 +18,7 @@ import java.util.Collections; /** * JWT 인증 필터 - * 요청 헤더에서 JWT 토큰을 추출하여 인증 처리 + * HTTP 요청에서 JWT 토큰을 추출하고 인증 처리 */ @Slf4j @Component @@ -30,44 +30,53 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private static final String BEARER_PREFIX = "Bearer "; /** - * JWT 토큰 인증 처리 + * JWT 토큰 기반 인증 필터링 * * @param request HTTP 요청 * @param response HTTP 응답 * @param filterChain 필터 체인 * @throws ServletException 서블릿 예외 - * @throws IOException I/O 예외 + * @throws IOException IO 예외 */ @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - // 요청 헤더에서 JWT 토큰 추출 - String token = resolveToken(request); - - // 토큰이 있고 유효한 경우 인증 정보 설정 - if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) { - String userId = jwtTokenProvider.getUserIdFromToken(token); - Authentication authentication = new UsernamePasswordAuthenticationToken( - userId, null, Collections.emptyList()); - SecurityContextHolder.getContext().setAuthentication(authentication); - log.debug("Security context에 '{}' 인증 정보를 저장했습니다.", userId); + try { + String jwt = getJwtFromRequest(request); + + if (StringUtils.hasText(jwt) && jwtTokenProvider.validateToken(jwt)) { + String userId = jwtTokenProvider.getUserIdFromToken(jwt); + + // 사용자 인증 정보 설정 + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList()); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + SecurityContextHolder.getContext().setAuthentication(authentication); + log.debug("User '{}' authenticated successfully", userId); + } + } catch (Exception ex) { + log.error("Could not set user authentication in security context", ex); } filterChain.doFilter(request, response); } /** - * 요청 헤더에서 JWT 토큰 추출 + * HTTP 요청에서 JWT 토큰 추출 * * @param request HTTP 요청 - * @return JWT 토큰 (Bearer 접두사 제거) + * @return JWT 토큰 (Bearer 접두사 제거된) */ - private String resolveToken(HttpServletRequest request) { + private String getJwtFromRequest(HttpServletRequest request) { String bearerToken = request.getHeader(AUTHORIZATION_HEADER); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { return bearerToken.substring(BEARER_PREFIX.length()); } + return null; } } diff --git a/common/src/main/java/com/won/smarketing/common/security/JwtTokenProvider.java b/common/src/main/java/com/won/smarketing/common/security/JwtTokenProvider.java index 86de936..bd3966b 100644 --- a/common/src/main/java/com/won/smarketing/common/security/JwtTokenProvider.java +++ b/common/src/main/java/com/won/smarketing/common/security/JwtTokenProvider.java @@ -1,9 +1,6 @@ package com.won.smarketing.common.security; -import com.won.smarketing.common.exception.BusinessException; -import com.won.smarketing.common.exception.ErrorCode; import io.jsonwebtoken.*; -import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -13,67 +10,65 @@ import javax.crypto.SecretKey; import java.util.Date; /** - * JWT 토큰 생성 및 검증 유틸리티 - * Access Token과 Refresh Token 관리 + * JWT 토큰 생성 및 검증을 담당하는 클래스 + * 액세스 토큰과 리프레시 토큰의 생성, 검증, 파싱 기능 제공 */ @Slf4j @Component public class JwtTokenProvider { - private final SecretKey key; + private final SecretKey secretKey; private final long accessTokenValidityTime; private final long refreshTokenValidityTime; /** * JWT 토큰 프로바이더 생성자 * - * @param secretKey JWT 서명에 사용할 비밀키 - * @param accessTokenValidityTime Access Token 유효 시간 (밀리초) - * @param refreshTokenValidityTime Refresh Token 유효 시간 (밀리초) + * @param secret JWT 서명에 사용할 비밀키 + * @param accessTokenValidityTime 액세스 토큰 유효시간 (밀리초) + * @param refreshTokenValidityTime 리프레시 토큰 유효시간 (밀리초) */ - public JwtTokenProvider( - @Value("${jwt.secret-key}") String secretKey, - @Value("${jwt.access-token-validity}") long accessTokenValidityTime, - @Value("${jwt.refresh-token-validity}") long refreshTokenValidityTime) { - byte[] keyBytes = Decoders.BASE64.decode(secretKey); - this.key = Keys.hmacShaKeyFor(keyBytes); + public JwtTokenProvider(@Value("${jwt.secret}") String secret, + @Value("${jwt.access-token-validity}") long accessTokenValidityTime, + @Value("${jwt.refresh-token-validity}") long refreshTokenValidityTime) { + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes()); this.accessTokenValidityTime = accessTokenValidityTime; this.refreshTokenValidityTime = refreshTokenValidityTime; } /** - * Access Token 생성 + * 액세스 토큰 생성 * * @param userId 사용자 ID - * @return Access Token + * @return 생성된 액세스 토큰 */ public String generateAccessToken(String userId) { - long now = System.currentTimeMillis(); - Date validity = new Date(now + accessTokenValidityTime); + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + accessTokenValidityTime); return Jwts.builder() .setSubject(userId) - .setIssuedAt(new Date(now)) - .setExpiration(validity) - .signWith(key, SignatureAlgorithm.HS256) + .setIssuedAt(now) + .setExpiration(expiryDate) + .signWith(secretKey) .compact(); } /** - * Refresh Token 생성 + * 리프레시 토큰 생성 * * @param userId 사용자 ID - * @return Refresh Token + * @return 생성된 리프레시 토큰 */ public String generateRefreshToken(String userId) { - long now = System.currentTimeMillis(); - Date validity = new Date(now + refreshTokenValidityTime); + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + refreshTokenValidityTime); return Jwts.builder() .setSubject(userId) - .setIssuedAt(new Date(now)) - .setExpiration(validity) - .signWith(key, SignatureAlgorithm.HS256) + .setIssuedAt(now) + .setExpiration(expiryDate) + .signWith(secretKey) .compact(); } @@ -84,67 +79,48 @@ public class JwtTokenProvider { * @return 사용자 ID */ public String getUserIdFromToken(String token) { - Claims claims = parseClaims(token); + Claims claims = Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody(); + return claims.getSubject(); } /** * 토큰 유효성 검증 * - * @param token JWT 토큰 + * @param token 검증할 토큰 * @return 유효성 여부 */ public boolean validateToken(String token) { try { - parseClaims(token); + Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token); return true; - } catch (ExpiredJwtException e) { - log.warn("Expired JWT token: {}", e.getMessage()); - throw new BusinessException(ErrorCode.TOKEN_EXPIRED); - } catch (UnsupportedJwtException e) { - log.warn("Unsupported JWT token: {}", e.getMessage()); - throw new BusinessException(ErrorCode.INVALID_TOKEN); - } catch (MalformedJwtException e) { - log.warn("Malformed JWT token: {}", e.getMessage()); - throw new BusinessException(ErrorCode.INVALID_TOKEN); - } catch (SecurityException e) { - log.warn("Invalid JWT signature: {}", e.getMessage()); - throw new BusinessException(ErrorCode.INVALID_TOKEN); - } catch (IllegalArgumentException e) { - log.warn("JWT token compact of handler are invalid: {}", e.getMessage()); - throw new BusinessException(ErrorCode.INVALID_TOKEN); + } catch (SecurityException ex) { + log.error("Invalid JWT signature: {}", ex.getMessage()); + } catch (MalformedJwtException ex) { + log.error("Invalid JWT token: {}", ex.getMessage()); + } catch (ExpiredJwtException ex) { + log.error("Expired JWT token: {}", ex.getMessage()); + } catch (UnsupportedJwtException ex) { + log.error("Unsupported JWT token: {}", ex.getMessage()); + } catch (IllegalArgumentException ex) { + log.error("JWT claims string is empty: {}", ex.getMessage()); } + return false; } /** - * 토큰에서 Claims 추출 + * 액세스 토큰 유효시간 반환 * - * @param token JWT 토큰 - * @return Claims - */ - private Claims parseClaims(String token) { - return Jwts.parserBuilder() - .setSigningKey(key) - .build() - .parseClaimsJws(token) - .getBody(); - } - - /** - * Access Token 유효 시간 반환 - * - * @return Access Token 유효 시간 (밀리초) + * @return 액세스 토큰 유효시간 (밀리초) */ public long getAccessTokenValidityTime() { return accessTokenValidityTime; } - - /** - * Refresh Token 유효 시간 반환 - * - * @return Refresh Token 유효 시간 (밀리초) - */ - public long getRefreshTokenValidityTime() { - return refreshTokenValidityTime; - } } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 9bbc975c742b298b441bfb90dbc124400a3751b9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 43705 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 DHx0+F diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 37f853b..0000000 --- a/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,7 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip -networkTimeout=10000 -validateDistributionUrl=true -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew deleted file mode 100644 index faf9300..0000000 --- a/gradlew +++ /dev/null @@ -1,251 +0,0 @@ -#!/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. -# -# SPDX-License-Identifier: Apache-2.0 -# - -############################################################################## -# -# 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/HEAD/platforms/jvm/plugins-application/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 - -# This is normally unused -# shellcheck disable=SC2034 -APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit - -# 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 - if ! command -v java >/dev/null 2>&1 - then - 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 -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - 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 - - -# 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"' - -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. - -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 deleted file mode 100644 index 9b42019..0000000 --- a/gradlew.bat +++ /dev/null @@ -1,94 +0,0 @@ -@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 -@rem SPDX-License-Identifier: Apache-2.0 -@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=. -@rem This is normally unused -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. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -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/marketing-content/build.gradle b/marketing-content/build.gradle index bb3b8ea..4fab010 100644 --- a/marketing-content/build.gradle +++ b/marketing-content/build.gradle @@ -1,6 +1,6 @@ dependencies { implementation project(':common') - + implementation 'com.mysql:mysql-connector-j' // HTTP Client for external AI API implementation 'org.springframework.boot:spring-boot-starter-webflux' } diff --git a/marketing-content/src/main/resources/application.yml b/marketing-content/src/main/resources/application.yml index 6d8cbfc..82f02a3 100644 --- a/marketing-content/src/main/resources/application.yml +++ b/marketing-content/src/main/resources/application.yml @@ -18,6 +18,10 @@ spring: hibernate: dialect: org.hibernate.dialect.PostgreSQLDialect format_sql: true +ai: + service: + url: ${AI_SERVICE_URL:http://localhost:8080/ai} + timeout: ${AI_SERVICE_TIMEOUT:30000} external: claude-ai: diff --git a/member/build.gradle b/member/build.gradle index d375e00..043e34a 100644 --- a/member/build.gradle +++ b/member/build.gradle @@ -1,7 +1,4 @@ dependencies { implementation project(':common') -} - -bootJar { - archiveFileName = "member-service.jar" + implementation 'com.mysql:mysql-connector-j' } diff --git a/member/src/main/java/com/won/smarketing/member/config/JpaConfig.java b/member/src/main/java/com/won/smarketing/member/config/JpaConfig.java new file mode 100644 index 0000000..4d5037a --- /dev/null +++ b/member/src/main/java/com/won/smarketing/member/config/JpaConfig.java @@ -0,0 +1,13 @@ +package com.won.smarketing.member.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +/** + * JPA 설정 클래스 + * JPA Auditing 기능 활성화 + */ +@Configuration +@EnableJpaAuditing +public class JpaConfig { +} diff --git a/member/src/main/java/com/won/smarketing/member/config/RedisConfig.java b/member/src/main/java/com/won/smarketing/member/config/RedisConfig.java new file mode 100644 index 0000000..045285b --- /dev/null +++ b/member/src/main/java/com/won/smarketing/member/config/RedisConfig.java @@ -0,0 +1,53 @@ +package com.won.smarketing.member.config; + +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.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +/** + * Redis 설정 클래스 + * Redis 연결 및 RedisTemplate 설정 + */ +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String redisHost; + + @Value("${spring.data.redis.port}") + private int redisPort; + + /** + * Redis 연결 팩토리 설정 + * + * @return RedisConnectionFactory + */ + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(redisHost, redisPort); + } + + /** + * RedisTemplate 설정 + * + * @return RedisTemplate + */ + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + + // 직렬화 설정 + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + redisTemplate.setHashKeySerializer(new StringRedisSerializer()); + redisTemplate.setHashValueSerializer(new StringRedisSerializer()); + + return redisTemplate; + } +} + diff --git a/member/src/main/java/com/won/smarketing/member/controller/AuthController.java b/member/src/main/java/com/won/smarketing/member/controller/AuthController.java index 15e3ba7..d3b1155 100644 --- a/member/src/main/java/com/won/smarketing/member/controller/AuthController.java +++ b/member/src/main/java/com/won/smarketing/member/controller/AuthController.java @@ -1,25 +1,21 @@ package com.won.smarketing.member.controller; import com.won.smarketing.common.dto.ApiResponse; -import com.won.smarketing.member.dto.LoginRequest; -import com.won.smarketing.member.dto.LoginResponse; -import com.won.smarketing.member.dto.LogoutRequest; -import com.won.smarketing.member.dto.TokenRefreshRequest; -import com.won.smarketing.member.dto.TokenResponse; +import com.won.smarketing.member.dto.*; import com.won.smarketing.member.service.AuthService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import jakarta.validation.Valid; /** - * 인증/인가를 위한 REST API 컨트롤러 + * 인증을 위한 REST API 컨트롤러 * 로그인, 로그아웃, 토큰 갱신 기능 제공 */ -@Tag(name = "인증/인가", description = "로그인, 로그아웃, 토큰 관리 API") +@Tag(name = "인증 관리", description = "로그인, 로그아웃, 토큰 관리 API") @RestController @RequestMapping("/api/auth") @RequiredArgsConstructor @@ -28,12 +24,12 @@ public class AuthController { private final AuthService authService; /** - * 로그인 인증 + * 로그인 * * @param request 로그인 요청 정보 - * @return JWT 토큰 정보 + * @return 로그인 성공 응답 (토큰 포함) */ - @Operation(summary = "로그인", description = "사용자 인증 후 JWT 토큰을 발급합니다.") + @Operation(summary = "로그인", description = "사용자 ID와 패스워드로 로그인합니다.") @PostMapping("/login") public ResponseEntity> login(@Valid @RequestBody LoginRequest request) { LoginResponse response = authService.login(request); @@ -41,12 +37,12 @@ public class AuthController { } /** - * 로그아웃 처리 + * 로그아웃 * * @param request 로그아웃 요청 정보 * @return 로그아웃 성공 응답 */ - @Operation(summary = "로그아웃", description = "사용자를 로그아웃하고 토큰을 무효화합니다.") + @Operation(summary = "로그아웃", description = "리프레시 토큰을 무효화하여 로그아웃합니다.") @PostMapping("/logout") public ResponseEntity> logout(@Valid @RequestBody LogoutRequest request) { authService.logout(request.getRefreshToken()); @@ -57,9 +53,9 @@ public class AuthController { * 토큰 갱신 * * @param request 토큰 갱신 요청 정보 - * @return 새로운 JWT 토큰 정보 + * @return 새로운 토큰 정보 */ - @Operation(summary = "토큰 갱신", description = "Refresh Token을 사용하여 새로운 Access Token을 발급합니다.") + @Operation(summary = "토큰 갱신", description = "리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급받습니다.") @PostMapping("/refresh") public ResponseEntity> refresh(@Valid @RequestBody TokenRefreshRequest request) { TokenResponse response = authService.refresh(request.getRefreshToken()); diff --git a/member/src/main/java/com/won/smarketing/member/controller/MemberController.java b/member/src/main/java/com/won/smarketing/member/controller/MemberController.java index e47078c..e73728d 100644 --- a/member/src/main/java/com/won/smarketing/member/controller/MemberController.java +++ b/member/src/main/java/com/won/smarketing/member/controller/MemberController.java @@ -9,15 +9,15 @@ import com.won.smarketing.member.service.MemberService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import jakarta.validation.Valid; /** * 회원 관리를 위한 REST API 컨트롤러 - * 회원가입, ID 중복 확인, 패스워드 유효성 검증 기능 제공 + * 회원가입, 중복 확인, 패스워드 검증 기능 제공 */ @Tag(name = "회원 관리", description = "회원가입 및 회원 정보 관리 API") @RestController @@ -28,10 +28,10 @@ public class MemberController { private final MemberService memberService; /** - * 회원가입 처리 + * 회원가입 * * @param request 회원가입 요청 정보 - * @return 회원가입 성공/실패 응답 + * @return 회원가입 성공 응답 */ @Operation(summary = "회원가입", description = "새로운 회원을 등록합니다.") @PostMapping("/register") @@ -41,34 +41,80 @@ public class MemberController { } /** - * ID 중복 확인 + * 사용자 ID 중복 확인 * * @param userId 확인할 사용자 ID - * @return 중복 여부 응답 + * @return 중복 확인 결과 */ - @Operation(summary = "ID 중복 확인", description = "사용자 ID의 중복 여부를 확인합니다.") - @GetMapping("/check-duplicate") - public ResponseEntity> checkDuplicate( + @Operation(summary = "사용자 ID 중복 확인", description = "사용자 ID의 중복 여부를 확인합니다.") + @GetMapping("/check-duplicate/user-id") + public ResponseEntity> checkUserIdDuplicate( @Parameter(description = "확인할 사용자 ID", required = true) @RequestParam String userId) { + boolean isDuplicate = memberService.checkDuplicate(userId); - DuplicateCheckResponse response = DuplicateCheckResponse.builder() - .isDuplicate(isDuplicate) - .message(isDuplicate ? "이미 사용 중인 ID입니다." : "사용 가능한 ID입니다.") - .build(); + DuplicateCheckResponse response = isDuplicate + ? DuplicateCheckResponse.duplicate("이미 사용 중인 사용자 ID입니다.") + : DuplicateCheckResponse.available("사용 가능한 사용자 ID입니다."); + + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * 이메일 중복 확인 + * + * @param email 확인할 이메일 + * @return 중복 확인 결과 + */ + @Operation(summary = "이메일 중복 확인", description = "이메일의 중복 여부를 확인합니다.") + @GetMapping("/check-duplicate/email") + public ResponseEntity> checkEmailDuplicate( + @Parameter(description = "확인할 이메일", required = true) + @RequestParam String email) { + + boolean isDuplicate = memberService.checkEmailDuplicate(email); + DuplicateCheckResponse response = isDuplicate + ? DuplicateCheckResponse.duplicate("이미 사용 중인 이메일입니다.") + : DuplicateCheckResponse.available("사용 가능한 이메일입니다."); + + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * 사업자번호 중복 확인 + * + * @param businessNumber 확인할 사업자번호 + * @return 중복 확인 결과 + */ + @Operation(summary = "사업자번호 중복 확인", description = "사업자번호의 중복 여부를 확인합니다.") + @GetMapping("/check-duplicate/business-number") + public ResponseEntity> checkBusinessNumberDuplicate( + @Parameter(description = "확인할 사업자번호", required = true) + @RequestParam String businessNumber) { + + boolean isDuplicate = memberService.checkBusinessNumberDuplicate(businessNumber); + DuplicateCheckResponse response = isDuplicate + ? DuplicateCheckResponse.duplicate("이미 등록된 사업자번호입니다.") + : DuplicateCheckResponse.available("사용 가능한 사업자번호입니다."); + return ResponseEntity.ok(ApiResponse.success(response)); } /** * 패스워드 유효성 검증 * - * @param request 패스워드 유효성 검증 요청 - * @return 유효성 검증 결과 + * @param request 패스워드 검증 요청 + * @return 패스워드 검증 결과 */ - @Operation(summary = "패스워드 유효성 검증", description = "패스워드가 보안 규칙을 만족하는지 확인합니다.") + @Operation(summary = "패스워드 검증", description = "패스워드가 규칙을 만족하는지 확인합니다.") @PostMapping("/validate-password") - public ResponseEntity> validatePassword(@Valid @RequestBody PasswordValidationRequest request) { + public ResponseEntity> validatePassword( + @Valid @RequestBody PasswordValidationRequest request) { + ValidationResponse response = memberService.validatePassword(request.getPassword()); return ResponseEntity.ok(ApiResponse.success(response)); } } + + + diff --git a/member/src/main/java/com/won/smarketing/member/dto/DuplicateCheckResponse.java b/member/src/main/java/com/won/smarketing/member/dto/DuplicateCheckResponse.java index 99d1763..cf9e56b 100644 --- a/member/src/main/java/com/won/smarketing/member/dto/DuplicateCheckResponse.java +++ b/member/src/main/java/com/won/smarketing/member/dto/DuplicateCheckResponse.java @@ -7,19 +7,48 @@ import lombok.Data; import lombok.NoArgsConstructor; /** - * ID 중복 확인 응답 DTO - * 사용자 ID 중복 여부 확인 결과 + * 중복 확인 응답 DTO + * 사용자 ID, 이메일 등의 중복 확인 결과를 전달합니다. */ @Data @NoArgsConstructor @AllArgsConstructor @Builder -@Schema(description = "ID 중복 확인 응답") +@Schema(description = "중복 확인 응답") public class DuplicateCheckResponse { - + @Schema(description = "중복 여부", example = "false") private boolean isDuplicate; - - @Schema(description = "확인 결과 메시지", example = "사용 가능한 ID입니다.") + + @Schema(description = "메시지", example = "사용 가능한 ID입니다.") private String message; + + /** + * 중복된 경우의 응답 생성 + * + * @param message 메시지 + * @return 중복 응답 + */ + public static DuplicateCheckResponse duplicate(String message) { + return DuplicateCheckResponse.builder() + .isDuplicate(true) + .message(message) + .build(); + } + + /** + * 사용 가능한 경우의 응답 생성 + * + * @param message 메시지 + * @return 사용 가능 응답 + */ + public static DuplicateCheckResponse available(String message) { + return DuplicateCheckResponse.builder() + .isDuplicate(false) + .message(message) + .build(); + } } + + + diff --git a/member/src/main/java/com/won/smarketing/member/dto/LoginRequest.java b/member/src/main/java/com/won/smarketing/member/dto/LoginRequest.java index 7c304d0..d55ee0a 100644 --- a/member/src/main/java/com/won/smarketing/member/dto/LoginRequest.java +++ b/member/src/main/java/com/won/smarketing/member/dto/LoginRequest.java @@ -1,29 +1,26 @@ package com.won.smarketing.member.dto; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; -import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import javax.validation.constraints.NotBlank; - /** * 로그인 요청 DTO - * 로그인 시 필요한 사용자 ID와 패스워드 정보 + * 로그인 시 필요한 정보를 전달합니다. */ @Data @NoArgsConstructor @AllArgsConstructor -@Builder -@Schema(description = "로그인 요청 정보") +@Schema(description = "로그인 요청") public class LoginRequest { - - @Schema(description = "사용자 ID", example = "testuser", required = true) - @NotBlank(message = "사용자 ID는 필수입니다.") + + @Schema(description = "사용자 ID", example = "user123", required = true) + @NotBlank(message = "사용자 ID는 필수입니다") private String userId; - + @Schema(description = "패스워드", example = "password123!", required = true) - @NotBlank(message = "패스워드는 필수입니다.") + @NotBlank(message = "패스워드는 필수입니다") private String password; } diff --git a/member/src/main/java/com/won/smarketing/member/dto/LoginResponse.java b/member/src/main/java/com/won/smarketing/member/dto/LoginResponse.java index 5769e70..3c71e94 100644 --- a/member/src/main/java/com/won/smarketing/member/dto/LoginResponse.java +++ b/member/src/main/java/com/won/smarketing/member/dto/LoginResponse.java @@ -8,21 +8,40 @@ import lombok.NoArgsConstructor; /** * 로그인 응답 DTO - * 로그인 성공 시 반환되는 JWT 토큰 정보 + * 로그인 성공 시 토큰 정보를 전달합니다. */ @Data @NoArgsConstructor @AllArgsConstructor @Builder -@Schema(description = "로그인 응답 정보") +@Schema(description = "로그인 응답") public class LoginResponse { - - @Schema(description = "Access Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + + @Schema(description = "액세스 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") private String accessToken; - - @Schema(description = "Refresh Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + + @Schema(description = "리프레시 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") private String refreshToken; - - @Schema(description = "토큰 만료 시간 (밀리초)", example = "900000") + + @Schema(description = "토큰 만료 시간 (초)", example = "3600") private long expiresIn; + + @Schema(description = "사용자 정보") + private UserInfo userInfo; + + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "사용자 정보") + public static class UserInfo { + @Schema(description = "사용자 ID", example = "user123") + private String userId; + + @Schema(description = "이름", example = "홍길동") + private String name; + + @Schema(description = "이메일", example = "user@example.com") + private String email; + } } diff --git a/member/src/main/java/com/won/smarketing/member/dto/PasswordValidationRequest.java b/member/src/main/java/com/won/smarketing/member/dto/PasswordValidationRequest.java index f4a015b..b2d96aa 100644 --- a/member/src/main/java/com/won/smarketing/member/dto/PasswordValidationRequest.java +++ b/member/src/main/java/com/won/smarketing/member/dto/PasswordValidationRequest.java @@ -1,25 +1,22 @@ package com.won.smarketing.member.dto; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; -import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import javax.validation.constraints.NotBlank; - /** - * 패스워드 유효성 검증 요청 DTO - * 패스워드 보안 규칙 확인을 위한 요청 정보 + * 패스워드 검증 요청 DTO + * 패스워드 규칙 검증을 위한 요청 정보를 전달합니다. */ @Data @NoArgsConstructor @AllArgsConstructor -@Builder -@Schema(description = "패스워드 유효성 검증 요청") +@Schema(description = "패스워드 검증 요청") public class PasswordValidationRequest { - + @Schema(description = "검증할 패스워드", example = "password123!", required = true) - @NotBlank(message = "패스워드는 필수입니다.") + @NotBlank(message = "패스워드는 필수입니다") private String password; } diff --git a/member/src/main/java/com/won/smarketing/member/dto/RegisterRequest.java b/member/src/main/java/com/won/smarketing/member/dto/RegisterRequest.java index efd99d8..8543029 100644 --- a/member/src/main/java/com/won/smarketing/member/dto/RegisterRequest.java +++ b/member/src/main/java/com/won/smarketing/member/dto/RegisterRequest.java @@ -1,49 +1,49 @@ package com.won.smarketing.member.dto; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; -import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import javax.validation.constraints.Email; -import javax.validation.constraints.NotBlank; -import javax.validation.constraints.Pattern; -import javax.validation.constraints.Size; - /** * 회원가입 요청 DTO - * 회원가입 시 필요한 정보를 담는 데이터 전송 객체 + * 회원가입 시 필요한 정보를 전달합니다. */ @Data @NoArgsConstructor @AllArgsConstructor -@Builder -@Schema(description = "회원가입 요청 정보") +@Schema(description = "회원가입 요청") public class RegisterRequest { - - @Schema(description = "사용자 ID", example = "testuser", required = true) - @NotBlank(message = "사용자 ID는 필수입니다.") - @Size(min = 4, max = 20, message = "사용자 ID는 4자 이상 20자 이하여야 합니다.") - @Pattern(regexp = "^[a-zA-Z0-9]+$", message = "사용자 ID는 영문과 숫자만 가능합니다.") + + @Schema(description = "사용자 ID", example = "user123", required = true) + @NotBlank(message = "사용자 ID는 필수입니다") + @Size(min = 4, max = 20, message = "사용자 ID는 4-20자 사이여야 합니다") + @Pattern(regexp = "^[a-zA-Z0-9]+$", message = "사용자 ID는 영문과 숫자만 사용 가능합니다") private String userId; - + @Schema(description = "패스워드", example = "password123!", required = true) - @NotBlank(message = "패스워드는 필수입니다.") + @NotBlank(message = "패스워드는 필수입니다") + @Size(min = 8, max = 20, message = "패스워드는 8-20자 사이여야 합니다") + @Pattern(regexp = "^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]+$", + message = "패스워드는 영문, 숫자, 특수문자를 포함해야 합니다") private String password; - + @Schema(description = "이름", example = "홍길동", required = true) - @NotBlank(message = "이름은 필수입니다.") - @Size(max = 100, message = "이름은 100자 이하여야 합니다.") + @NotBlank(message = "이름은 필수입니다") + @Size(max = 50, message = "이름은 50자 이하여야 합니다") private String name; - - @Schema(description = "사업자 번호", example = "123-45-67890", required = true) - @NotBlank(message = "사업자 번호는 필수입니다.") - @Pattern(regexp = "^\\d{3}-\\d{2}-\\d{5}$", message = "사업자 번호 형식이 올바르지 않습니다.") + + @Schema(description = "사업자등록번호", example = "123-45-67890") + @Pattern(regexp = "^\\d{3}-\\d{2}-\\d{5}$", message = "사업자등록번호 형식이 올바르지 않습니다 (000-00-00000)") private String businessNumber; - - @Schema(description = "이메일", example = "test@example.com", required = true) - @NotBlank(message = "이메일은 필수입니다.") - @Email(message = "올바른 이메일 형식이 아닙니다.") + + @Schema(description = "이메일", example = "user@example.com", required = true) + @NotBlank(message = "이메일은 필수입니다") + @Email(message = "이메일 형식이 올바르지 않습니다") + @Size(max = 100, message = "이메일은 100자 이하여야 합니다") private String email; } diff --git a/member/src/main/java/com/won/smarketing/member/dto/TokenRefreshRequest.java b/member/src/main/java/com/won/smarketing/member/dto/TokenRefreshRequest.java index f62226b..7278ab5 100644 --- a/member/src/main/java/com/won/smarketing/member/dto/TokenRefreshRequest.java +++ b/member/src/main/java/com/won/smarketing/member/dto/TokenRefreshRequest.java @@ -1,25 +1,22 @@ package com.won.smarketing.member.dto; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; -import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import javax.validation.constraints.NotBlank; - /** * 토큰 갱신 요청 DTO - * Refresh Token을 사용한 토큰 갱신 요청 정보 + * 리프레시 토큰을 사용한 액세스 토큰 갱신 요청 정보를 전달합니다. */ @Data @NoArgsConstructor @AllArgsConstructor -@Builder @Schema(description = "토큰 갱신 요청") public class TokenRefreshRequest { - - @Schema(description = "Refresh Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", required = true) - @NotBlank(message = "Refresh Token은 필수입니다.") + + @Schema(description = "리프레시 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", required = true) + @NotBlank(message = "리프레시 토큰은 필수입니다") private String refreshToken; } diff --git a/member/src/main/java/com/won/smarketing/member/dto/TokenResponse.java b/member/src/main/java/com/won/smarketing/member/dto/TokenResponse.java index a0cbf85..a750def 100644 --- a/member/src/main/java/com/won/smarketing/member/dto/TokenResponse.java +++ b/member/src/main/java/com/won/smarketing/member/dto/TokenResponse.java @@ -8,21 +8,21 @@ import lombok.NoArgsConstructor; /** * 토큰 응답 DTO - * 토큰 갱신 시 반환되는 새로운 JWT 토큰 정보 + * 토큰 갱신 시 새로운 토큰 정보를 전달합니다. */ @Data @NoArgsConstructor @AllArgsConstructor @Builder -@Schema(description = "토큰 응답 정보") +@Schema(description = "토큰 응답") public class TokenResponse { - - @Schema(description = "새로운 Access Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + + @Schema(description = "새로운 액세스 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") private String accessToken; - - @Schema(description = "새로운 Refresh Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + + @Schema(description = "새로운 리프레시 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") private String refreshToken; - - @Schema(description = "토큰 만료 시간 (밀리초)", example = "900000") + + @Schema(description = "토큰 만료 시간 (초)", example = "3600") private long expiresIn; } diff --git a/member/src/main/java/com/won/smarketing/member/dto/ValidationResponse.java b/member/src/main/java/com/won/smarketing/member/dto/ValidationResponse.java index 5c56b39..4808fec 100644 --- a/member/src/main/java/com/won/smarketing/member/dto/ValidationResponse.java +++ b/member/src/main/java/com/won/smarketing/member/dto/ValidationResponse.java @@ -9,22 +9,50 @@ import lombok.NoArgsConstructor; import java.util.List; /** - * 유효성 검증 응답 DTO - * 패스워드 유효성 검증 결과 정보 + * 검증 응답 DTO + * 패스워드 등의 검증 결과를 전달합니다. */ @Data @NoArgsConstructor @AllArgsConstructor @Builder -@Schema(description = "유효성 검증 응답") +@Schema(description = "검증 응답") public class ValidationResponse { - + @Schema(description = "유효성 여부", example = "true") private boolean isValid; - - @Schema(description = "검증 결과 메시지", example = "유효한 패스워드입니다.") + + @Schema(description = "메시지", example = "사용 가능한 패스워드입니다.") private String message; - - @Schema(description = "오류 목록") + + @Schema(description = "오류 목록", example = "[\"영문이 포함되어야 합니다\", \"숫자가 포함되어야 합니다\"]") private List errors; + + /** + * 유효한 경우의 응답 생성 + * + * @param message 메시지 + * @return 유효 응답 + */ + public static ValidationResponse valid(String message) { + return ValidationResponse.builder() + .isValid(true) + .message(message) + .build(); + } + + /** + * 유효하지 않은 경우의 응답 생성 + * + * @param message 메시지 + * @param errors 오류 목록 + * @return 무효 응답 + */ + public static ValidationResponse invalid(String message, List errors) { + return ValidationResponse.builder() + .isValid(false) + .message(message) + .errors(errors) + .build(); + } } diff --git a/member/src/main/java/com/won/smarketing/member/entity/Member.java b/member/src/main/java/com/won/smarketing/member/entity/Member.java index d3adcc1..55c5c7c 100644 --- a/member/src/main/java/com/won/smarketing/member/entity/Member.java +++ b/member/src/main/java/com/won/smarketing/member/entity/Member.java @@ -1,87 +1,29 @@ package com.won.smarketing.member.entity; import jakarta.persistence.*; -import lombok.*; - -import java.time.LocalDateTime; - -/** - * 회원 정보를 나타내는 엔티티 - * 사용자 ID, 패스워드, 이름, 사업자 번호, 이메일 정보 저장 - */ -@Entity -@Table(name = "members") -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@Builder -public class Member { - - /** - * 회원 고유 식별자 +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstruct +재시도 +Y +계속 +편집 +Member 서비스 모든 클래스 구현 +코드 ∙ 버전 2 + /** + * 사용자 ID로 회원 조회 + * + * @param userId 사용자 ID + * @return 회원 정보 (Optional) */ - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - + Optional findByUserId(String userId); + /** - * 사용자 ID (로그인용) - */ - @Column(name = "user_id", unique = true, nullable = false, length = 50) - private String userId; - - /** - * 암호화된 패스워드 - */ - @Column(name = "password", nullable = false) - private String password; - - /** - * 회원 이름 - */ - @Column(name = "name", nullable = false, length = 100) - private String name; - - /** - * 사업자 번호 - */ - @Column(name = "business_number", unique = true, nullable = false, length = 20) - private String businessNumber; - - /** - * 이메일 주소 - */ - @Column(name = "email", unique = true, nullable = false) - private String email; - - /** - * 회원 생성 시각 - */ - @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; - - /** - * 회원 정보 수정 시각 - */ - @Column(name = "updated_at", nullable = false) - private LocalDateTime updatedAt; - - /** - * 엔티티 저장 전 실행되는 메서드 - * 생성 시각과 수정 시각을 현재 시각으로 설정 - */ - @PrePersist - protected void onCreate() { - createdAt = LocalDateTime.now(); - updatedAt = LocalDateTime.now(); - } - - /** - * 엔티티 업데이트 전 실행되는 메서드 - * 수정 시각을 현재 시각으로 갱신 - */ - @PreUpdate - protected void onUpdate() { - updatedAt = LocalDateTime.now(); - } -} + * 사용자 ID 존재 여부 확인 + * + * @param userId 사용자 ID + * @return 존재 여부 + +Member 인증 서비스 구현체 및 Controllers +코드 \ No newline at end of file diff --git a/member/src/main/java/com/won/smarketing/member/repository/MemberRepository.java b/member/src/main/java/com/won/smarketing/member/repository/MemberRepository.java index c7b6d44..eec42ea 100644 --- a/member/src/main/java/com/won/smarketing/member/repository/MemberRepository.java +++ b/member/src/main/java/com/won/smarketing/member/repository/MemberRepository.java @@ -17,7 +17,7 @@ public interface MemberRepository extends JpaRepository { * 사용자 ID로 회원 조회 * * @param userId 사용자 ID - * @return 회원 정보 + * @return 회원 정보 (Optional) */ Optional findByUserId(String userId); @@ -38,9 +38,9 @@ public interface MemberRepository extends JpaRepository { boolean existsByEmail(String email); /** - * 사업자 번호 존재 여부 확인 + * 사업자번호 존재 여부 확인 * - * @param businessNumber 사업자 번호 + * @param businessNumber 사업자번호 * @return 존재 여부 */ boolean existsByBusinessNumber(String businessNumber); diff --git a/member/src/main/java/com/won/smarketing/member/service/AuthService.java b/member/src/main/java/com/won/smarketing/member/service/AuthService.java index f93b0b7..c73bc1f 100644 --- a/member/src/main/java/com/won/smarketing/member/service/AuthService.java +++ b/member/src/main/java/com/won/smarketing/member/service/AuthService.java @@ -5,31 +5,31 @@ import com.won.smarketing.member.dto.LoginResponse; import com.won.smarketing.member.dto.TokenResponse; /** - * 인증/인가 서비스 인터페이스 - * 로그인, 로그아웃, 토큰 갱신 기능 정의 + * 인증 서비스 인터페이스 + * 로그인, 로그아웃, 토큰 갱신 관련 비즈니스 로직 정의 */ public interface AuthService { /** - * 로그인 인증 처리 + * 로그인 * * @param request 로그인 요청 정보 - * @return JWT 토큰 정보 + * @return 로그인 응답 정보 (토큰 포함) */ LoginResponse login(LoginRequest request); /** - * 로그아웃 처리 + * 로그아웃 * - * @param refreshToken 무효화할 Refresh Token + * @param refreshToken 리프레시 토큰 */ void logout(String refreshToken); /** - * 토큰 갱신 처리 + * 토큰 갱신 * - * @param refreshToken 갱신에 사용할 Refresh Token - * @return 새로운 JWT 토큰 정보 + * @param refreshToken 리프레시 토큰 + * @return 새로운 토큰 정보 */ TokenResponse refresh(String refreshToken); } diff --git a/member/src/main/java/com/won/smarketing/member/service/AuthServiceImpl.java b/member/src/main/java/com/won/smarketing/member/service/AuthServiceImpl.java index 8413aed..d75a828 100644 --- a/member/src/main/java/com/won/smarketing/member/service/AuthServiceImpl.java +++ b/member/src/main/java/com/won/smarketing/member/service/AuthServiceImpl.java @@ -2,130 +2,21 @@ package com.won.smarketing.member.service; import com.won.smarketing.common.exception.BusinessException; import com.won.smarketing.common.exception.ErrorCode; -import com.won.smarketing.common.security.JwtTokenProvider; -import com.won.smarketing.member.dto.LoginRequest; -import com.won.smarketing.member.dto.LoginResponse; -import com.won.smarketing.member.dto.TokenResponse; -import com.won.smarketing.member.entity.Member; -import com.won.smarketing.member.repository.MemberRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.concurrent.TimeUnit; - -/** - * 인증/인가 서비스 구현체 - * 로그인, 로그아웃, 토큰 갱신 기능 구현 - */ -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class AuthServiceImpl implements AuthService { - - private final MemberRepository memberRepository; - private final PasswordEncoder passwordEncoder; - private final JwtTokenProvider jwtTokenProvider; - private final RedisTemplate redisTemplate; - - private static final String REFRESH_TOKEN_PREFIX = "refresh_token:"; - - /** - * 로그인 인증 처리 - * - * @param request 로그인 요청 정보 - * @return JWT 토큰 정보 - */ - @Override - @Transactional - public LoginResponse login(LoginRequest request) { - // 사용자 조회 - Member member = memberRepository.findByUserId(request.getUserId()) - .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); - - // 패스워드 검증 - if (!passwordEncoder.matches(request.getPassword(), member.getPassword())) { - throw new BusinessException(ErrorCode.INVALID_PASSWORD); - } - - // JWT 토큰 생성 - String accessToken = jwtTokenProvider.generateAccessToken(member.getUserId()); - String refreshToken = jwtTokenProvider.generateRefreshToken(member.getUserId()); - long expiresIn = jwtTokenProvider.getAccessTokenValidityTime(); - - // Refresh Token을 Redis에 저장 - String refreshTokenKey = REFRESH_TOKEN_PREFIX + member.getUserId(); - redisTemplate.opsForValue().set(refreshTokenKey, refreshToken, - jwtTokenProvider.getRefreshTokenValidityTime(), TimeUnit.MILLISECONDS); - - return LoginResponse.builder() - .accessToken(accessToken) - .refreshToken(refreshToken) - .expiresIn(expiresIn) - .build(); - } - - /** - * 로그아웃 처리 - * - * @param refreshToken 무효화할 Refresh Token - */ - @Override - @Transactional - public void logout(String refreshToken) { - // 토큰에서 사용자 ID 추출 - String userId = jwtTokenProvider.getUserIdFromToken(refreshToken); +import com. +재시도 +Y +계속 +편집 +Member 인증 서비스 구현체 및 Controllers +코드 ∙ 버전 2 + // 새로운 리프레시 토큰을 Redis에 저장 + redisTemplate.opsForValue().set( + REFRESH_TOKEN_PREFIX + userId, + newRefreshToken, + 7, + TimeUnit.DAYS + ); - // Redis에서 Refresh Token 삭제 - String refreshTokenKey = REFRESH_TOKEN_PREFIX + userId; - redisTemplate.delete(refreshTokenKey); - } - - /** - * 토큰 갱신 처리 - * - * @param refreshToken 갱신에 사용할 Refresh Token - * @return 새로운 JWT 토큰 정보 - */ - @Override - @Transactional - public TokenResponse refresh(String refreshToken) { - // Refresh Token 유효성 검증 - if (!jwtTokenProvider.validateToken(refreshToken)) { - throw new BusinessException(ErrorCode.INVALID_TOKEN); - } - - // 토큰에서 사용자 ID 추출 - String userId = jwtTokenProvider.getUserIdFromToken(refreshToken); - - // Redis에서 저장된 Refresh Token 확인 - String refreshTokenKey = REFRESH_TOKEN_PREFIX + userId; - String storedRefreshToken = redisTemplate.opsForValue().get(refreshTokenKey); - - if (storedRefreshToken == null || !storedRefreshToken.equals(refreshToken)) { - throw new BusinessException(ErrorCode.INVALID_TOKEN); - } - - // 사용자 존재 여부 확인 - Member member = memberRepository.findByUserId(userId) - .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); - - // 새로운 토큰 생성 - String newAccessToken = jwtTokenProvider.generateAccessToken(member.getUserId()); - String newRefreshToken = jwtTokenProvider.generateRefreshToken(member.getUserId()); - long expiresIn = jwtTokenProvider.getAccessTokenValidityTime(); - - // 기존 Refresh Token 삭제 후 새로운 토큰 저장 - redisTemplate.delete(refreshTokenKey); - redisTemplate.opsForValue().set(refreshTokenKey, newRefreshToken, - jwtTokenProvider.getRefreshTokenValidityTime(), TimeUnit.MILLISECONDS); - - return TokenResponse.builder() - .accessToken(newAccessToken) - .refreshToken(newRefreshToken) - .expiresIn(expiresIn) - .build(); - } -} + // 기존 리프레시 토큰을 +Store 서비스 Entity 및 DTO 클래스들 +코드 \ No newline at end of file diff --git a/member/src/main/java/com/won/smarketing/member/service/MemberService.java b/member/src/main/java/com/won/smarketing/member/service/MemberService.java index a2dc6c8..c1e456f 100644 --- a/member/src/main/java/com/won/smarketing/member/service/MemberService.java +++ b/member/src/main/java/com/won/smarketing/member/service/MemberService.java @@ -4,13 +4,13 @@ import com.won.smarketing.member.dto.RegisterRequest; import com.won.smarketing.member.dto.ValidationResponse; /** - * 회원 관리 서비스 인터페이스 - * 회원가입, 중복 확인, 패스워드 유효성 검증 기능 정의 + * 회원 서비스 인터페이스 + * 회원 관리 관련 비즈니스 로직 정의 */ public interface MemberService { /** - * 회원가입 처리 + * 회원 등록 * * @param request 회원가입 요청 정보 */ @@ -20,15 +20,31 @@ public interface MemberService { * 사용자 ID 중복 확인 * * @param userId 확인할 사용자 ID - * @return 중복 여부 (true: 중복, false: 사용 가능) + * @return 중복 여부 */ boolean checkDuplicate(String userId); + /** + * 이메일 중복 확인 + * + * @param email 확인할 이메일 + * @return 중복 여부 + */ + boolean checkEmailDuplicate(String email); + + /** + * 사업자번호 중복 확인 + * + * @param businessNumber 확인할 사업자번호 + * @return 중복 여부 + */ + boolean checkBusinessNumberDuplicate(String businessNumber); + /** * 패스워드 유효성 검증 * * @param password 검증할 패스워드 - * @return 유효성 검증 결과 + * @return 검증 결과 */ ValidationResponse validatePassword(String password); } diff --git a/member/src/main/java/com/won/smarketing/member/service/MemberServiceImpl.java b/member/src/main/java/com/won/smarketing/member/service/MemberServiceImpl.java index c837763..8c730d2 100644 --- a/member/src/main/java/com/won/smarketing/member/service/MemberServiceImpl.java +++ b/member/src/main/java/com/won/smarketing/member/service/MemberServiceImpl.java @@ -7,6 +7,7 @@ import com.won.smarketing.member.dto.ValidationResponse; import com.won.smarketing.member.entity.Member; import com.won.smarketing.member.repository.MemberRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -16,9 +17,10 @@ import java.util.List; import java.util.regex.Pattern; /** - * 회원 관리 서비스 구현체 - * 회원가입, 중복 확인, 패스워드 유효성 검증 기능 구현 + * 회원 서비스 구현체 + * 회원 등록, 중복 확인, 패스워드 검증 기능 구현 */ +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -26,90 +28,119 @@ public class MemberServiceImpl implements MemberService { private final MemberRepository memberRepository; private final PasswordEncoder passwordEncoder; - - // 패스워드 정규식: 영문, 숫자, 특수문자 각각 최소 1개 포함, 8자 이상 - private static final Pattern PASSWORD_PATTERN = Pattern.compile( - "^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,}$" - ); + + // 패스워드 검증 패턴 + private static final Pattern LETTER_PATTERN = Pattern.compile(".*[a-zA-Z].*"); + private static final Pattern DIGIT_PATTERN = Pattern.compile(".*\\d.*"); + private static final Pattern SPECIAL_CHAR_PATTERN = Pattern.compile(".*[@$!%*?&].*"); /** - * 회원가입 처리 + * 회원 등록 * * @param request 회원가입 요청 정보 */ @Override @Transactional public void register(RegisterRequest request) { - // 중복 ID 확인 + log.info("회원 등록 시작: {}", request.getUserId()); + + // 중복 확인 if (memberRepository.existsByUserId(request.getUserId())) { throw new BusinessException(ErrorCode.DUPLICATE_MEMBER_ID); } - - // 이메일 중복 확인 + if (memberRepository.existsByEmail(request.getEmail())) { throw new BusinessException(ErrorCode.DUPLICATE_EMAIL); } - - // 사업자 번호 중복 확인 - if (memberRepository.existsByBusinessNumber(request.getBusinessNumber())) { + + if (request.getBusinessNumber() != null && + memberRepository.existsByBusinessNumber(request.getBusinessNumber())) { throw new BusinessException(ErrorCode.DUPLICATE_BUSINESS_NUMBER); } - - // 패스워드 암호화 - String encodedPassword = passwordEncoder.encode(request.getPassword()); - + // 회원 엔티티 생성 및 저장 Member member = Member.builder() .userId(request.getUserId()) - .password(encodedPassword) + .password(passwordEncoder.encode(request.getPassword())) .name(request.getName()) .businessNumber(request.getBusinessNumber()) .email(request.getEmail()) .build(); - + memberRepository.save(member); + log.info("회원 등록 완료: {}", request.getUserId()); } /** * 사용자 ID 중복 확인 * * @param userId 확인할 사용자 ID - * @return 중복 여부 (true: 중복, false: 사용 가능) + * @return 중복 여부 */ @Override public boolean checkDuplicate(String userId) { return memberRepository.existsByUserId(userId); } + /** + * 이메일 중복 확인 + * + * @param email 확인할 이메일 + * @return 중복 여부 + */ + @Override + public boolean checkEmailDuplicate(String email) { + return memberRepository.existsByEmail(email); + } + + /** + * 사업자번호 중복 확인 + * + * @param businessNumber 확인할 사업자번호 + * @return 중복 여부 + */ + @Override + public boolean checkBusinessNumberDuplicate(String businessNumber) { + if (businessNumber == null || businessNumber.trim().isEmpty()) { + return false; + } + return memberRepository.existsByBusinessNumber(businessNumber); + } + /** * 패스워드 유효성 검증 * * @param password 검증할 패스워드 - * @return 유효성 검증 결과 + * @return 검증 결과 */ @Override public ValidationResponse validatePassword(String password) { List errors = new ArrayList<>(); - boolean isValid = true; - - // 길이 검증 (8자 이상) - if (password.length() < 8) { - errors.add("패스워드는 8자 이상이어야 합니다."); - isValid = false; + + // 길이 검증 + if (password.length() < 8 || password.length() > 20) { + errors.add("패스워드는 8-20자 사이여야 합니다"); } - - // 패턴 검증 (영문, 숫자, 특수문자 포함) - if (!PASSWORD_PATTERN.matcher(password).matches()) { - errors.add("패스워드는 영문, 숫자, 특수문자를 각각 최소 1개씩 포함해야 합니다."); - isValid = false; + + // 영문 포함 여부 + if (!LETTER_PATTERN.matcher(password).matches()) { + errors.add("영문이 포함되어야 합니다"); + } + + // 숫자 포함 여부 + if (!DIGIT_PATTERN.matcher(password).matches()) { + errors.add("숫자가 포함되어야 합니다"); + } + + // 특수문자 포함 여부 + if (!SPECIAL_CHAR_PATTERN.matcher(password).matches()) { + errors.add("특수문자(@$!%*?&)가 포함되어야 합니다"); + } + + if (errors.isEmpty()) { + return ValidationResponse.valid("사용 가능한 패스워드입니다."); + } else { + return ValidationResponse.invalid("패스워드 규칙을 확인해 주세요.", errors); } - - String message = isValid ? "유효한 패스워드입니다." : "패스워드가 보안 규칙을 만족하지 않습니다."; - - return ValidationResponse.builder() - .isValid(isValid) - .message(message) - .errors(errors) - .build(); } } diff --git a/member/src/main/resources/application.yml b/member/src/main/resources/application.yml index 7fb0c9a..83ad594 100644 --- a/member/src/main/resources/application.yml +++ b/member/src/main/resources/application.yml @@ -1,42 +1,33 @@ server: - port: ${SERVER_PORT:8081} - servlet: - context-path: / + port: ${MEMBER_PORT:8081} spring: application: name: member-service datasource: - url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:memberdb} - username: ${POSTGRES_USER:postgres} - password: ${POSTGRES_PASSWORD:postgres} + url: ${DB_URL:jdbc:mysql://localhost:3306/smarketing_member} + username: ${DB_USERNAME:root} + password: ${DB_PASSWORD:password} + driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: - ddl-auto: ${JPA_DDL_AUTO:update} - show-sql: ${JPA_SHOW_SQL:true} + ddl-auto: ${DDL_AUTO:update} + show-sql: ${SHOW_SQL:true} properties: hibernate: - dialect: org.hibernate.dialect.PostgreSQLDialect + dialect: org.hibernate.dialect.MySQLDialect format_sql: true data: redis: host: ${REDIS_HOST:localhost} port: ${REDIS_PORT:6379} password: ${REDIS_PASSWORD:} - timeout: 2000ms jwt: - secret-key: ${JWT_SECRET_KEY:mySecretKeyForJWTTokenGenerationThatShouldBeVeryLongAndSecure} - access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:900000} - refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:86400000} - -springdoc: - swagger-ui: - path: /swagger-ui.html - operations-sorter: method - api-docs: - path: /api-docs + secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789} + access-token-validity: ${JWT_ACCESS_VALIDITY:3600000} + refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000} logging: level: - com.won.smarketing.member: ${LOG_LEVEL:DEBUG} + com.won.smarketing: ${LOG_LEVEL:DEBUG} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/DetailedMarketingTipResponse.java b/recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/DetailedMarketingTipResponse.java similarity index 100% rename from ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/DetailedMarketingTipResponse.java rename to recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/DetailedMarketingTipResponse.java diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/ErrorResponseDto.java b/recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/ErrorResponseDto.java similarity index 99% rename from ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/ErrorResponseDto.java rename to recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/ErrorResponseDto.java index 335d5ab..43c77da 100644 --- a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/ErrorResponseDto.java +++ b/recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/ErrorResponseDto.java @@ -28,4 +28,5 @@ public class ErrorResponseDto { @Schema(description = "요청 경로", example = "/api/recommendation/marketing-tips") private String path; -} \ No newline at end of file +} + diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipGenerationRequest.java b/recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipGenerationRequest.java similarity index 100% rename from ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipGenerationRequest.java rename to recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipGenerationRequest.java diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipRequest.java b/recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipRequest.java similarity index 100% rename from ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipRequest.java rename to recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipRequest.java diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java b/recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java similarity index 100% rename from ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java rename to recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/StoreInfoDto.java b/recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/StoreInfoDto.java similarity index 100% rename from ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/StoreInfoDto.java rename to recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/StoreInfoDto.java diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/WeatherInfoDto.java b/recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/WeatherInfoDto.java similarity index 100% rename from ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/WeatherInfoDto.java rename to recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/WeatherInfoDto.java diff --git a/settings.gradle b/settings.gradle index f31190a..a398afa 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,5 +1,4 @@ rootProject.name = 'smarketing' - include 'common' include 'member' include 'store' diff --git a/store/build.gradle b/store/build.gradle index b96273e..909bd30 100644 --- a/store/build.gradle +++ b/store/build.gradle @@ -1,5 +1,6 @@ dependencies { implementation project(':common') + implementation 'com.mysql:mysql-connector-j' } bootJar { diff --git a/store/src/main/java/com/won/smarketing/store/config/JpaConfig.java b/store/src/main/java/com/won/smarketing/store/config/JpaConfig.java new file mode 100644 index 0000000..4efd00c --- /dev/null +++ b/store/src/main/java/com/won/smarketing/store/config/JpaConfig.java @@ -0,0 +1,28 @@ +package com.won.smarketing.store.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +/** + * JPA 설정 클래스 + * JPA Auditing 기능 활성화 + */ +@Configuration +@EnableJpaAuditing +public class JpaConfig { +}리는 50자 이하여야 합니다") + private String category; + + @Schema(description = "가격", example = "4500", required = true) + @NotNull(message = "가격은 필수입니다") + @Min(value = 0, message = "가격은 0원 이상이어야 합니다") + private Integer price; + + @Schema(description = "메뉴 설명", example = "진한 맛의 아메리카노") + @Size(max = 500, message = "메뉴 설명은 500자 이하여야 합니다") + private String description; + + @Schema(description = "이미지 URL", example = "https://example.com/americano.jpg") + @Size(max = 500, message = "이미지 URL은 500자 이하여야 합니다") + private String image; +} diff --git a/store/src/main/java/com/won/smarketing/store/dto/MenuCreateRequest.java b/store/src/main/java/com/won/smarketing/store/dto/MenuCreateRequest.java index d800e7d..7cb3804 100644 --- a/store/src/main/java/com/won/smarketing/store/dto/MenuCreateRequest.java +++ b/store/src/main/java/com/won/smarketing/store/dto/MenuCreateRequest.java @@ -1,49 +1,49 @@ package com.won.smarketing.store.dto; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; -import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import javax.validation.constraints.Min; -import javax.validation.constraints.NotBlank; -import javax.validation.constraints.NotNull; -import javax.validation.constraints.Size; - /** * 메뉴 등록 요청 DTO - * 메뉴 등록 시 필요한 정보를 담는 데이터 전송 객체 + * 메뉴 등록 시 필요한 정보를 전달합니다. */ @Data @NoArgsConstructor @AllArgsConstructor -@Builder -@Schema(description = "메뉴 등록 요청 정보") +@Schema(description = "메뉴 등록 요청") public class MenuCreateRequest { - + @Schema(description = "매장 ID", example = "1", required = true) - @NotNull(message = "매장 ID는 필수입니다.") + @NotNull(message = "매장 ID는 필수입니다") private Long storeId; - + @Schema(description = "메뉴명", example = "아메리카노", required = true) - @NotBlank(message = "메뉴명은 필수입니다.") - @Size(max = 200, message = "메뉴명은 200자 이하여야 합니다.") + @NotBlank(message = "메뉴명은 필수입니다") + @Size(max = 100, message = "메뉴명은 100자 이하여야 합니다") private String menuName; - - @Schema(description = "메뉴 카테고리", example = "커피", required = true) - @NotBlank(message = "카테고리는 필수입니다.") - @Size(max = 100, message = "카테고리는 100자 이하여야 합니다.") + + @Schema(description = "카테고리", example = "커피") + @Size(max = 50, message = "카테고리는 50자 이하여야 합니다") private String category; - - @Schema(description = "가격", example = "4500", required = true) - @NotNull(message = "가격은 필수입니다.") - @Min(value = 0, message = "가격은 0 이상이어야 합니다.") + + @Schema(description = "가격", example = "4500") + @Min(value = 0, message = "가격은 0원 이상이어야 합니다") private Integer price; - - @Schema(description = "메뉴 설명", example = "진한 원두의 깊은 맛") + + @Schema(description = "메뉴 설명", example = "진한 맛의 아메리카노") + @Size(max = 500, message = "메뉴 설명은 500자 이하여야 합니다") private String description; - - @Schema(description = "메뉴 이미지 URL", example = "https://example.com/americano.jpg") + + @Schema(description = "이미지 URL", example = "https://example.com/americano.jpg") + @Size(max = 500, message = "이미지 URL은 500자 이하여야 합니다") private String image; } + + + diff --git a/store/src/main/java/com/won/smarketing/store/dto/MenuResponse.java b/store/src/main/java/com/won/smarketing/store/dto/MenuResponse.java index 232556a..aa9f642 100644 --- a/store/src/main/java/com/won/smarketing/store/dto/MenuResponse.java +++ b/store/src/main/java/com/won/smarketing/store/dto/MenuResponse.java @@ -9,37 +9,41 @@ import lombok.NoArgsConstructor; import java.time.LocalDateTime; /** - * 메뉴 정보 응답 DTO - * 메뉴 정보 조회/등록/수정 시 반환되는 데이터 + * 메뉴 응답 DTO + * 메뉴 정보를 클라이언트에게 전달합니다. */ @Data @NoArgsConstructor @AllArgsConstructor @Builder -@Schema(description = "메뉴 정보 응답") +@Schema(description = "메뉴 응답") public class MenuResponse { - + @Schema(description = "메뉴 ID", example = "1") private Long menuId; - + + @Schema(description = "매장 ID", example = "1") + private Long storeId; + @Schema(description = "메뉴명", example = "아메리카노") private String menuName; - - @Schema(description = "메뉴 카테고리", example = "커피") + + @Schema(description = "카테고리", example = "커피") private String category; - + @Schema(description = "가격", example = "4500") private Integer price; - - @Schema(description = "메뉴 설명", example = "진한 원두의 깊은 맛") + + @Schema(description = "메뉴 설명", example = "진한 맛의 아메리카노") private String description; - - @Schema(description = "메뉴 이미지 URL", example = "https://example.com/americano.jpg") + + @Schema(description = "이미지 URL", example = "https://example.com/americano.jpg") private String image; - - @Schema(description = "등록 시각") + + @Schema(description = "등록일시", example = "2024-01-15T10:30:00") private LocalDateTime createdAt; - - @Schema(description = "수정 시각") + + @Schema(description = "수정일시", example = "2024-01-15T10:30:00") private LocalDateTime updatedAt; } + diff --git a/store/src/main/java/com/won/smarketing/store/dto/SalesResponse.java b/store/src/main/java/com/won/smarketing/store/dto/SalesResponse.java index 4fcfa9b..3152582 100644 --- a/store/src/main/java/com/won/smarketing/store/dto/SalesResponse.java +++ b/store/src/main/java/com/won/smarketing/store/dto/SalesResponse.java @@ -9,22 +9,28 @@ import lombok.NoArgsConstructor; import java.math.BigDecimal; /** - * 매출 정보 응답 DTO - * 오늘 매출, 월간 매출, 전일 대비 매출 정보 + * 매출 응답 DTO + * 매출 정보를 클라이언트에게 전달합니다. */ @Data @NoArgsConstructor @AllArgsConstructor @Builder -@Schema(description = "매출 정보 응답") +@Schema(description = "매출 응답") public class SalesResponse { - + @Schema(description = "오늘 매출", example = "150000") private BigDecimal todaySales; - - @Schema(description = "이번 달 매출", example = "3200000") + + @Schema(description = "월간 매출", example = "4500000") private BigDecimal monthSales; - - @Schema(description = "전일 대비 매출 변화량", example = "25000") + + @Schema(description = "전일 대비 매출 변화", example = "25000") private BigDecimal previousDayComparison; + + @Schema(description = "전일 대비 매출 변화율 (%)", example = "15.5") + private BigDecimal previousDayChangeRate; + + @Schema(description = "목표 매출 대비 달성율 (%)", example = "85.2") + private BigDecimal goalAchievementRate; } diff --git a/store/src/main/java/com/won/smarketing/store/dto/StoreCreateRequest.java b/store/src/main/java/com/won/smarketing/store/dto/StoreCreateRequest.java index 9b56620..71dd250 100644 --- a/store/src/main/java/com/won/smarketing/store/dto/StoreCreateRequest.java +++ b/store/src/main/java/com/won/smarketing/store/dto/StoreCreateRequest.java @@ -1,79 +1,58 @@ package com.won.smarketing.store.dto; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; -import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import javax.validation.constraints.NotBlank; -import javax.validation.constraints.NotNull; -import javax.validation.constraints.Pattern; -import javax.validation.constraints.Size; - /** * 매장 등록 요청 DTO - * 매장 등록 시 필요한 정보를 담는 데이터 전송 객체 + * 매장 등록 시 필요한 정보를 전달합니다. */ @Data @NoArgsConstructor @AllArgsConstructor -@Builder -@Schema(description = "매장 등록 요청 정보") +@Schema(description = "매장 등록 요청") public class StoreCreateRequest { - - @Schema(description = "매장 소유자 사용자 ID", example = "testuser", required = true) - @NotBlank(message = "사용자 ID는 필수입니다.") - private String userId; - + @Schema(description = "매장명", example = "맛있는 카페", required = true) - @NotBlank(message = "매장명은 필수입니다.") - @Size(max = 200, message = "매장명은 200자 이하여야 합니다.") + @NotBlank(message = "매장명은 필수입니다") + @Size(max = 100, message = "매장명은 100자 이하여야 합니다") private String storeName; - - @Schema(description = "매장 이미지 URL", example = "https://example.com/store.jpg") - private String storeImage; - - @Schema(description = "업종", example = "카페", required = true) - @NotBlank(message = "업종은 필수입니다.") - @Size(max = 100, message = "업종은 100자 이하여야 합니다.") + + @Schema(description = "업종", example = "카페") + @Size(max = 50, message = "업종은 50자 이하여야 합니다") private String businessType; - - @Schema(description = "매장 주소", example = "서울시 강남구 테헤란로 123", required = true) - @NotBlank(message = "주소는 필수입니다.") - @Size(max = 500, message = "주소는 500자 이하여야 합니다.") + + @Schema(description = "주소", example = "서울시 강남구 테헤란로 123", required = true) + @NotBlank(message = "주소는 필수입니다") + @Size(max = 200, message = "주소는 200자 이하여야 합니다") private String address; - - @Schema(description = "매장 전화번호", example = "02-1234-5678", required = true) - @NotBlank(message = "전화번호는 필수입니다.") - @Pattern(regexp = "^\\d{2,3}-\\d{3,4}-\\d{4}$", message = "올바른 전화번호 형식이 아닙니다.") + + @Schema(description = "전화번호", example = "02-1234-5678") + @Size(max = 20, message = "전화번호는 20자 이하여야 합니다") private String phoneNumber; - - @Schema(description = "사업자 번호", example = "123-45-67890", required = true) - @NotBlank(message = "사업자 번호는 필수입니다.") - @Pattern(regexp = "^\\d{3}-\\d{2}-\\d{5}$", message = "사업자 번호 형식이 올바르지 않습니다.") - private String businessNumber; - - @Schema(description = "인스타그램 계정", example = "@mycafe") - @Size(max = 100, message = "인스타그램 계정은 100자 이하여야 합니다.") - private String instaAccount; - - @Schema(description = "네이버 블로그 계정", example = "mycafe_blog") - @Size(max = 100, message = "네이버 블로그 계정은 100자 이하여야 합니다.") - private String naverBlogAccount; - - @Schema(description = "오픈 시간", example = "09:00") - @Pattern(regexp = "^\\d{2}:\\d{2}$", message = "올바른 시간 형식이 아닙니다. (HH:MM)") - private String openTime; - - @Schema(description = "마감 시간", example = "22:00") - @Pattern(regexp = "^\\d{2}:\\d{2}$", message = "올바른 시간 형식이 아닙니다. (HH:MM)") - private String closeTime; - - @Schema(description = "휴무일", example = "매주 월요일") - @Size(max = 100, message = "휴무일은 100자 이하여야 합니다.") + + @Schema(description = "영업시간", example = "09:00 - 22:00") + @Size(max = 100, message = "영업시간은 100자 이하여야 합니다") + private String businessHours; + + @Schema(description = "휴무일", example = "매주 일요일") + @Size(max = 100, message = "휴무일은 100자 이하여야 합니다") private String closedDays; - + @Schema(description = "좌석 수", example = "20") private Integer seatCount; + + @Schema(description = "SNS 계정 정보", example = "인스타그램: @mystore") + @Size(max = 500, message = "SNS 계정 정보는 500자 이하여야 합니다") + private String snsAccounts; + + @Schema(description = "매장 설명", example = "따뜻한 분위기의 동네 카페입니다.") + @Size(max = 1000, message = "매장 설명은 1000자 이하여야 합니다") + private String description; } + + diff --git a/store/src/main/java/com/won/smarketing/store/dto/StoreResponse.java b/store/src/main/java/com/won/smarketing/store/dto/StoreResponse.java index 72898f3..f0b583a 100644 --- a/store/src/main/java/com/won/smarketing/store/dto/StoreResponse.java +++ b/store/src/main/java/com/won/smarketing/store/dto/StoreResponse.java @@ -9,58 +9,50 @@ import lombok.NoArgsConstructor; import java.time.LocalDateTime; /** - * 매장 정보 응답 DTO - * 매장 정보 조회/등록/수정 시 반환되는 데이터 + * 매장 응답 DTO + * 매장 정보를 클라이언트에게 전달합니다. */ @Data @NoArgsConstructor @AllArgsConstructor @Builder -@Schema(description = "매장 정보 응답") +@Schema(description = "매장 응답") public class StoreResponse { - + @Schema(description = "매장 ID", example = "1") private Long storeId; - + @Schema(description = "매장명", example = "맛있는 카페") private String storeName; - - @Schema(description = "매장 이미지 URL", example = "https://example.com/store.jpg") - private String storeImage; - + @Schema(description = "업종", example = "카페") private String businessType; - - @Schema(description = "매장 주소", example = "서울시 강남구 테헤란로 123") + + @Schema(description = "주소", example = "서울시 강남구 테헤란로 123") private String address; - - @Schema(description = "매장 전화번호", example = "02-1234-5678") + + @Schema(description = "전화번호", example = "02-1234-5678") private String phoneNumber; - - @Schema(description = "사업자 번호", example = "123-45-67890") - private String businessNumber; - - @Schema(description = "인스타그램 계정", example = "@mycafe") - private String instaAccount; - - @Schema(description = "네이버 블로그 계정", example = "mycafe_blog") - private String naverBlogAccount; - - @Schema(description = "오픈 시간", example = "09:00") - private String openTime; - - @Schema(description = "마감 시간", example = "22:00") - private String closeTime; - - @Schema(description = "휴무일", example = "매주 월요일") + + @Schema(description = "영업시간", example = "09:00 - 22:00") + private String businessHours; + + @Schema(description = "휴무일", example = "매주 일요일") private String closedDays; - + @Schema(description = "좌석 수", example = "20") private Integer seatCount; - - @Schema(description = "등록 시각") + + @Schema(description = "SNS 계정 정보", example = "인스타그램: @mystore") + private String snsAccounts; + + @Schema(description = "매장 설명", example = "따뜻한 분위기의 동네 카페입니다.") + private String description; + + @Schema(description = "등록일시", example = "2024-01-15T10:30:00") private LocalDateTime createdAt; - - @Schema(description = "수정 시각") + + @Schema(description = "수정일시", example = "2024-01-15T10:30:00") private LocalDateTime updatedAt; } + diff --git a/store/src/main/java/com/won/smarketing/store/dto/StoreUpdateRequest.java b/store/src/main/java/com/won/smarketing/store/dto/StoreUpdateRequest.java index 7bb9306..4592a6f 100644 --- a/store/src/main/java/com/won/smarketing/store/dto/StoreUpdateRequest.java +++ b/store/src/main/java/com/won/smarketing/store/dto/StoreUpdateRequest.java @@ -1,60 +1,55 @@ package com.won.smarketing.store.dto; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; -import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import javax.validation.constraints.Pattern; -import javax.validation.constraints.Size; - /** * 매장 수정 요청 DTO - * 매장 정보 수정 시 필요한 정보를 담는 데이터 전송 객체 + * 매장 정보 수정 시 필요한 정보를 전달합니다. */ @Data @NoArgsConstructor @AllArgsConstructor -@Builder -@Schema(description = "매장 수정 요청 정보") +@Schema(description = "매장 수정 요청") public class StoreUpdateRequest { - + @Schema(description = "매장명", example = "맛있는 카페") - @Size(max = 200, message = "매장명은 200자 이하여야 합니다.") + @Size(max = 100, message = "매장명은 100자 이하여야 합니다") private String storeName; - - @Schema(description = "매장 이미지 URL", example = "https://example.com/store.jpg") - private String storeImage; - - @Schema(description = "매장 주소", example = "서울시 강남구 테헤란로 123") - @Size(max = 500, message = "주소는 500자 이하여야 합니다.") + + @Schema(description = "업종", example = "카페") + @Size(max = 50, message = "업종은 50자 이하여야 합니다") + private String businessType; + + @Schema(description = "주소", example = "서울시 강남구 테헤란로 123") + @Size(max = 200, message = "주소는 200자 이하여야 합니다") private String address; - - @Schema(description = "매장 전화번호", example = "02-1234-5678") - @Pattern(regexp = "^\\d{2,3}-\\d{3,4}-\\d{4}$", message = "올바른 전화번호 형식이 아닙니다.") + + @Schema(description = "전화번호", example = "02-1234-5678") + @Size(max = 20, message = "전화번호는 20자 이하여야 합니다") private String phoneNumber; - - @Schema(description = "인스타그램 계정", example = "@mycafe") - @Size(max = 100, message = "인스타그램 계정은 100자 이하여야 합니다.") - private String instaAccount; - - @Schema(description = "네이버 블로그 계정", example = "mycafe_blog") - @Size(max = 100, message = "네이버 블로그 계정은 100자 이하여야 합니다.") - private String naverBlogAccount; - - @Schema(description = "오픈 시간", example = "09:00") - @Pattern(regexp = "^\\d{2}:\\d{2}$", message = "올바른 시간 형식이 아닙니다. (HH:MM)") - private String openTime; - - @Schema(description = "마감 시간", example = "22:00") - @Pattern(regexp = "^\\d{2}:\\d{2}$", message = "올바른 시간 형식이 아닙니다. (HH:MM)") - private String closeTime; - - @Schema(description = "휴무일", example = "매주 월요일") - @Size(max = 100, message = "휴무일은 100자 이하여야 합니다.") + + @Schema(description = "영업시간", example = "09:00 - 22:00") + @Size(max = 100, message = "영업시간은 100자 이하여야 합니다") + private String businessHours; + + @Schema(description = "휴무일", example = "매주 일요일") + @Size(max = 100, message = "휴무일은 100자 이하여야 합니다") private String closedDays; - + @Schema(description = "좌석 수", example = "20") private Integer seatCount; + + @Schema(description = "SNS 계정 정보", example = "인스타그램: @mystore") + @Size(max = 500, message = "SNS 계정 정보는 500자 이하여야 합니다") + private String snsAccounts; + + @Schema(description = "매장 설명", example = "따뜻한 분위기의 동네 카페입니다.") + @Size(max = 1000, message = "매장 설명은 1000자 이하여야 합니다") + private String description; } + + diff --git a/store/src/main/java/com/won/smarketing/store/entity/Menu.java b/store/src/main/java/com/won/smarketing/store/entity/Menu.java index 7461445..83bb830 100644 --- a/store/src/main/java/com/won/smarketing/store/entity/Menu.java +++ b/store/src/main/java/com/won/smarketing/store/entity/Menu.java @@ -1,98 +1,62 @@ package com.won.smarketing.store.entity; import jakarta.persistence.*; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; import java.time.LocalDateTime; /** - * 메뉴 정보를 나타내는 엔티티 - * 메뉴명, 카테고리, 가격, 설명, 이미지 정보 저장 + * 메뉴 엔티티 + * 매장의 메뉴 정보를 관리 */ @Entity @Table(name = "menus") @Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor @AllArgsConstructor @Builder +@EntityListeners(AuditingEntityListener.class) public class Menu { - /** - * 메뉴 고유 식별자 - */ @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "menu_id") private Long id; - /** - * 매장 ID - */ @Column(name = "store_id", nullable = false) private Long storeId; - /** - * 메뉴명 - */ - @Column(name = "menu_name", nullable = false, length = 200) + @Column(name = "menu_name", nullable = false, length = 100) private String menuName; - /** - * 메뉴 카테고리 - */ - @Column(name = "category", nullable = false, length = 100) + @Column(name = "category", length = 50) private String category; - /** - * 가격 - */ @Column(name = "price", nullable = false) private Integer price; - /** - * 메뉴 설명 - */ - @Column(name = "description", columnDefinition = "TEXT") + @Column(name = "description", length = 500) private String description; - /** - * 메뉴 이미지 URL - */ - @Column(name = "image", length = 500) + @Column(name = "image_url", length = 500) private String image; - /** - * 메뉴 등록 시각 - */ + @CreatedDate @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; - /** - * 메뉴 정보 수정 시각 - */ - @Column(name = "updated_at", nullable = false) + @LastModifiedDate + @Column(name = "updated_at") private LocalDateTime updatedAt; /** - * 엔티티 저장 전 실행되는 메서드 - * 생성 시각과 수정 시각을 현재 시각으로 설정 - */ - @PrePersist - protected void onCreate() { - createdAt = LocalDateTime.now(); - updatedAt = LocalDateTime.now(); - } - - /** - * 엔티티 업데이트 전 실행되는 메서드 - * 수정 시각을 현재 시각으로 갱신 - */ - @PreUpdate - protected void onUpdate() { - updatedAt = LocalDateTime.now(); - } - - /** - * 메뉴 정보 업데이트 메서드 + * 메뉴 정보 업데이트 * * @param menuName 메뉴명 * @param category 카테고리 @@ -100,10 +64,17 @@ public class Menu { * @param description 설명 * @param image 이미지 URL */ - public void updateMenuInfo(String menuName, String category, Integer price, String description, String image) { - this.menuName = menuName; - this.category = category; - this.price = price; + public void updateMenu(String menuName, String category, Integer price, + String description, String image) { + if (menuName != null && !menuName.trim().isEmpty()) { + this.menuName = menuName; + } + if (category != null && !category.trim().isEmpty()) { + this.category = category; + } + if (price != null && price > 0) { + this.price = price; + } this.description = description; this.image = image; } diff --git a/store/src/main/java/com/won/smarketing/store/entity/Sales.java b/store/src/main/java/com/won/smarketing/store/entity/Sales.java index 5398ae2..91e74c7 100644 --- a/store/src/main/java/com/won/smarketing/store/entity/Sales.java +++ b/store/src/main/java/com/won/smarketing/store/entity/Sales.java @@ -59,3 +59,4 @@ public class Sales { createdAt = LocalDateTime.now(); } } + diff --git a/store/src/main/java/com/won/smarketing/store/entity/Store.java b/store/src/main/java/com/won/smarketing/store/entity/Store.java index e2688d8..c7df5f3 100644 --- a/store/src/main/java/com/won/smarketing/store/entity/Store.java +++ b/store/src/main/java/com/won/smarketing/store/entity/Store.java @@ -1,164 +1,103 @@ package com.won.smarketing.store.entity; import jakarta.persistence.*; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; import java.time.LocalDateTime; +import java.time.LocalTime; /** - * 매장 정보를 나타내는 엔티티 - * 매장의 기본 정보, 운영 정보, SNS 계정 정보 저장 + * 매장 엔티티 + * 매장의 기본 정보와 운영 정보를 관리 */ @Entity @Table(name = "stores") @Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor @AllArgsConstructor @Builder +@EntityListeners(AuditingEntityListener.class) public class Store { - /** - * 매장 고유 식별자 - */ @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "store_id") private Long id; - /** - * 매장 소유자 사용자 ID - */ - @Column(name = "user_id", unique = true, nullable = false, length = 50) - private String userId; + @Column(name = "member_id", nullable = false) + private Long memberId; - /** - * 매장명 - */ - @Column(name = "store_name", nullable = false, length = 200) + @Column(name = "store_name", nullable = false, length = 100) private String storeName; - /** - * 매장 이미지 URL - */ - @Column(name = "store_image", length = 500) - private String storeImage; - - /** - * 업종 - */ - @Column(name = "business_type", nullable = false, length = 100) + @Column(name = "business_type", length = 50) private String businessType; - /** - * 매장 주소 - */ - @Column(name = "address", nullable = false, length = 500) + @Column(name = "address", nullable = false, length = 200) private String address; - /** - * 매장 전화번호 - */ - @Column(name = "phone_number", nullable = false, length = 20) + @Column(name = "phone_number", length = 20) private String phoneNumber; - /** - * 사업자 번호 - */ - @Column(name = "business_number", nullable = false, length = 20) - private String businessNumber; + @Column(name = "business_hours", length = 100) + private String businessHours; - /** - * 인스타그램 계정 - */ - @Column(name = "insta_account", length = 100) - private String instaAccount; - - /** - * 네이버 블로그 계정 - */ - @Column(name = "naver_blog_account", length = 100) - private String naverBlogAccount; - - /** - * 오픈 시간 - */ - @Column(name = "open_time", length = 10) - private String openTime; - - /** - * 마감 시간 - */ - @Column(name = "close_time", length = 10) - private String closeTime; - - /** - * 휴무일 - */ @Column(name = "closed_days", length = 100) private String closedDays; - /** - * 좌석 수 - */ @Column(name = "seat_count") private Integer seatCount; - /** - * 매장 등록 시각 - */ + @Column(name = "sns_accounts", length = 500) + private String snsAccounts; + + @Column(name = "description", length = 1000) + private String description; + + @CreatedDate @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; - /** - * 매장 정보 수정 시각 - */ - @Column(name = "updated_at", nullable = false) + @LastModifiedDate + @Column(name = "updated_at") private LocalDateTime updatedAt; /** - * 엔티티 저장 전 실행되는 메서드 - * 생성 시각과 수정 시각을 현재 시각으로 설정 - */ - @PrePersist - protected void onCreate() { - createdAt = LocalDateTime.now(); - updatedAt = LocalDateTime.now(); - } - - /** - * 엔티티 업데이트 전 실행되는 메서드 - * 수정 시각을 현재 시각으로 갱신 - */ - @PreUpdate - protected void onUpdate() { - updatedAt = LocalDateTime.now(); - } - - /** - * 매장 정보 업데이트 메서드 + * 매장 정보 업데이트 * * @param storeName 매장명 - * @param storeImage 매장 이미지 + * @param businessType 업종 * @param address 주소 * @param phoneNumber 전화번호 - * @param instaAccount 인스타그램 계정 - * @param naverBlogAccount 네이버 블로그 계정 - * @param openTime 오픈 시간 - * @param closeTime 마감 시간 + * @param businessHours 영업시간 * @param closedDays 휴무일 * @param seatCount 좌석 수 + * @param snsAccounts SNS 계정 정보 + * @param description 설명 */ - public void updateStoreInfo(String storeName, String storeImage, String address, String phoneNumber, - String instaAccount, String naverBlogAccount, String openTime, String closeTime, - String closedDays, Integer seatCount) { - this.storeName = storeName; - this.storeImage = storeImage; - this.address = address; + public void updateStore(String storeName, String businessType, String address, + String phoneNumber, String businessHours, String closedDays, + Integer seatCount, String snsAccounts, String description) { + if (storeName != null && !storeName.trim().isEmpty()) { + this.storeName = storeName; + } + if (businessType != null && !businessType.trim().isEmpty()) { + this.businessType = businessType; + } + if (address != null && !address.trim().isEmpty()) { + this.address = address; + } this.phoneNumber = phoneNumber; - this.instaAccount = instaAccount; - this.naverBlogAccount = naverBlogAccount; - this.openTime = openTime; - this.closeTime = closeTime; + this.businessHours = businessHours; this.closedDays = closedDays; this.seatCount = seatCount; + this.snsAccounts = snsAccounts; + this.description = description; } } diff --git a/store/src/main/java/com/won/smarketing/store/repository/StoreRepository.java b/store/src/main/java/com/won/smarketing/store/repository/StoreRepository.java index 97a26db..9ef911e 100644 --- a/store/src/main/java/com/won/smarketing/store/repository/StoreRepository.java +++ b/store/src/main/java/com/won/smarketing/store/repository/StoreRepository.java @@ -14,10 +14,29 @@ import java.util.Optional; public interface StoreRepository extends JpaRepository { /** - * 사용자 ID로 매장 조회 + * 회원 ID로 매장 조회 * - * @param userId 사용자 ID - * @return 매장 정보 + * @param memberId 회원 ID + * @return 매장 정보 (Optional) */ - Optional findByUserId(String userId); + Optional findByMemberId(Long memberId); + + /** + * 회원의 매장 존재 여부 확인 + * + * @param memberId 회원 ID + * @return 존재 여부 + */ + boolean existsByMemberId(Long memberId); + + /** + * 매장명으로 매장 조회 + * + * @param storeName 매장명 + * @return 매장 목록 + */ + Optional findByStoreName(String storeName); } + + + diff --git a/store/src/main/java/com/won/smarketing/store/service/MenuService.java b/store/src/main/java/com/won/smarketing/store/service/MenuService.java index e15141b..764ad24 100644 --- a/store/src/main/java/com/won/smarketing/store/service/MenuService.java +++ b/store/src/main/java/com/won/smarketing/store/service/MenuService.java @@ -7,13 +7,13 @@ import com.won.smarketing.store.dto.MenuUpdateRequest; import java.util.List; /** - * 메뉴 관리 서비스 인터페이스 - * 메뉴 등록, 조회, 수정, 삭제 기능 정의 + * 메뉴 서비스 인터페이스 + * 메뉴 관리 관련 비즈니스 로직 정의 */ public interface MenuService { /** - * 메뉴 정보 등록 + * 메뉴 등록 * * @param request 메뉴 등록 요청 정보 * @return 등록된 메뉴 정보 @@ -31,7 +31,7 @@ public interface MenuService { /** * 메뉴 정보 수정 * - * @param menuId 수정할 메뉴 ID + * @param menuId 메뉴 ID * @param request 메뉴 수정 요청 정보 * @return 수정된 메뉴 정보 */ @@ -40,7 +40,7 @@ public interface MenuService { /** * 메뉴 삭제 * - * @param menuId 삭제할 메뉴 ID + * @param menuId 메뉴 ID */ void deleteMenu(Long menuId); } diff --git a/store/src/main/java/com/won/smarketing/store/service/SalesService.java b/store/src/main/java/com/won/smarketing/store/service/SalesService.java index 5d92c5e..c077a9d 100644 --- a/store/src/main/java/com/won/smarketing/store/service/SalesService.java +++ b/store/src/main/java/com/won/smarketing/store/service/SalesService.java @@ -3,15 +3,15 @@ package com.won.smarketing.store.service; import com.won.smarketing.store.dto.SalesResponse; /** - * 매출 관리 서비스 인터페이스 - * 매출 조회 기능 정의 + * 매출 서비스 인터페이스 + * 매출 조회 관련 비즈니스 로직 정의 */ public interface SalesService { /** * 매출 정보 조회 * - * @return 매출 정보 (오늘, 월간, 전일 대비) + * @return 매출 정보 */ SalesResponse getSales(); } diff --git a/store/src/main/java/com/won/smarketing/store/service/SalesServiceImpl.java b/store/src/main/java/com/won/smarketing/store/service/SalesServiceImpl.java index f4109ab..847b3f9 100644 --- a/store/src/main/java/com/won/smarketing/store/service/SalesServiceImpl.java +++ b/store/src/main/java/com/won/smarketing/store/service/SalesServiceImpl.java @@ -1,42 +1,60 @@ package com.won.smarketing.store.service; import com.won.smarketing.store.dto.SalesResponse; -import com.won.smarketing.store.repository.SalesRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; +import java.math.RoundingMode; /** - * 매출 관리 서비스 구현체 - * 매출 조회 기능 구현 + * 매출 서비스 구현체 + * 매출 조회 기능 구현 (현재는 Mock 데이터) */ +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class SalesServiceImpl implements SalesService { - private final SalesRepository salesRepository; - /** * 매출 정보 조회 + * 현재는 Mock 데이터를 반환 (실제로는 매출 데이터 조회 로직 필요) * - * @return 매출 정보 (오늘, 월간, 전일 대비) + * @return 매출 정보 */ @Override public SalesResponse getSales() { - // TODO: 현재는 더미 데이터 반환, 실제로는 현재 로그인한 사용자의 매장 ID를 사용해야 함 - Long storeId = 1L; // 임시로 설정 + log.info("매출 정보 조회"); + + // Mock 데이터 (실제로는 데이터베이스에서 조회) + BigDecimal todaySales = new BigDecimal("150000"); + BigDecimal monthSales = new BigDecimal("4500000"); + BigDecimal yesterdaySales = new BigDecimal("125000"); + BigDecimal targetSales = new BigDecimal("176000"); + + // 전일 대비 변화 계산 + BigDecimal previousDayComparison = todaySales.subtract(yesterdaySales); + BigDecimal previousDayChangeRate = yesterdaySales.compareTo(BigDecimal.ZERO) > 0 + ? previousDayComparison.divide(yesterdaySales, 4, RoundingMode.HALF_UP) + .multiply(new BigDecimal("100")) + : BigDecimal.ZERO; + + // 목표 대비 달성률 계산 + BigDecimal goalAchievementRate = targetSales.compareTo(BigDecimal.ZERO) > 0 + ? todaySales.divide(targetSales, 4, RoundingMode.HALF_UP) + .multiply(new BigDecimal("100")) + : BigDecimal.ZERO; - BigDecimal todaySales = salesRepository.findTodaySalesByStoreId(storeId); - BigDecimal monthSales = salesRepository.findMonthSalesByStoreId(storeId); - BigDecimal previousDayComparison = salesRepository.findPreviousDayComparisonByStoreId(storeId); - return SalesResponse.builder() - .todaySales(todaySales != null ? todaySales : BigDecimal.ZERO) - .monthSales(monthSales != null ? monthSales : BigDecimal.ZERO) - .previousDayComparison(previousDayComparison != null ? previousDayComparison : BigDecimal.ZERO) + .todaySales(todaySales) + .monthSales(monthSales) + .previousDayComparison(previousDayComparison) + .previousDayChangeRate(previousDayChangeRate) + .goalAchievementRate(goalAchievementRate) .build(); } } + diff --git a/store/src/main/java/com/won/smarketing/store/service/StoreService.java b/store/src/main/java/com/won/smarketing/store/service/StoreService.java index c00b924..bc342a8 100644 --- a/store/src/main/java/com/won/smarketing/store/service/StoreService.java +++ b/store/src/main/java/com/won/smarketing/store/service/StoreService.java @@ -5,13 +5,13 @@ import com.won.smarketing.store.dto.StoreResponse; import com.won.smarketing.store.dto.StoreUpdateRequest; /** - * 매장 관리 서비스 인터페이스 - * 매장 등록, 조회, 수정 기능 정의 + * 매장 서비스 인터페이스 + * 매장 관리 관련 비즈니스 로직 정의 */ public interface StoreService { /** - * 매장 정보 등록 + * 매장 등록 * * @param request 매장 등록 요청 정보 * @return 등록된 매장 정보 @@ -19,9 +19,16 @@ public interface StoreService { StoreResponse register(StoreCreateRequest request); /** - * 매장 정보 조회 + * 매장 정보 조회 (현재 로그인 사용자) * - * @param storeId 조회할 매장 ID + * @return 매장 정보 + */ + StoreResponse getMyStore(); + + /** + * 매장 정보 조회 (매장 ID) + * + * @param storeId 매장 ID * @return 매장 정보 */ StoreResponse getStore(String storeId); @@ -29,7 +36,7 @@ public interface StoreService { /** * 매장 정보 수정 * - * @param storeId 수정할 매장 ID + * @param storeId 매장 ID * @param request 매장 수정 요청 정보 * @return 수정된 매장 정보 */ diff --git a/store/src/main/java/com/won/smarketing/store/service/StoreServiceImpl.java b/store/src/main/java/com/won/smarketing/store/service/StoreServiceImpl.java index fe0d505..4ab16cc 100644 --- a/store/src/main/java/com/won/smarketing/store/service/StoreServiceImpl.java +++ b/store/src/main/java/com/won/smarketing/store/service/StoreServiceImpl.java @@ -8,13 +8,16 @@ import com.won.smarketing.store.dto.StoreUpdateRequest; import com.won.smarketing.store.entity.Store; import com.won.smarketing.store.repository.StoreRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; /** - * 매장 관리 서비스 구현체 + * 매장 서비스 구현체 * 매장 등록, 조회, 수정 기능 구현 */ +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -23,7 +26,7 @@ public class StoreServiceImpl implements StoreService { private final StoreRepository storeRepository; /** - * 매장 정보 등록 + * 매장 등록 * * @param request 매장 등록 요청 정보 * @return 등록된 매장 정보 @@ -31,50 +34,75 @@ public class StoreServiceImpl implements StoreService { @Override @Transactional public StoreResponse register(StoreCreateRequest request) { - // 사용자별 매장 중복 등록 확인 - if (storeRepository.findByUserId(request.getUserId()).isPresent()) { + String currentUserId = getCurrentUserId(); + Long memberId = Long.valueOf(currentUserId); // 실제로는 Member ID 조회 필요 + + log.info("매장 등록 시작: {} (회원: {})", request.getStoreName(), memberId); + + // 회원당 하나의 매장만 등록 가능 + if (storeRepository.existsByMemberId(memberId)) { throw new BusinessException(ErrorCode.STORE_ALREADY_EXISTS); } - + // 매장 엔티티 생성 및 저장 Store store = Store.builder() - .userId(request.getUserId()) + .memberId(memberId) .storeName(request.getStoreName()) - .storeImage(request.getStoreImage()) .businessType(request.getBusinessType()) .address(request.getAddress()) .phoneNumber(request.getPhoneNumber()) - .businessNumber(request.getBusinessNumber()) - .instaAccount(request.getInstaAccount()) - .naverBlogAccount(request.getNaverBlogAccount()) - .openTime(request.getOpenTime()) - .closeTime(request.getCloseTime()) + .businessHours(request.getBusinessHours()) .closedDays(request.getClosedDays()) .seatCount(request.getSeatCount()) + .snsAccounts(request.getSnsAccounts()) + .description(request.getDescription()) .build(); - + Store savedStore = storeRepository.save(store); + log.info("매장 등록 완료: {} (ID: {})", savedStore.getStoreName(), savedStore.getId()); + return toStoreResponse(savedStore); } /** - * 매장 정보 조회 + * 매장 정보 조회 (현재 로그인 사용자) * - * @param storeId 조회할 매장 ID * @return 매장 정보 */ @Override - public StoreResponse getStore(String storeId) { - Store store = storeRepository.findByUserId(storeId) + public StoreResponse getMyStore() { + String currentUserId = getCurrentUserId(); + Long memberId = Long.valueOf(currentUserId); + + Store store = storeRepository.findByMemberId(memberId) .orElseThrow(() -> new BusinessException(ErrorCode.STORE_NOT_FOUND)); return toStoreResponse(store); } + /** + * 매장 정보 조회 (매장 ID) + * + * @param storeId 매장 ID + * @return 매장 정보 + */ + @Override + public StoreResponse getStore(String storeId) { + try { + Long id = Long.valueOf(storeId); + Store store = storeRepository.findById(id) + .orElseThrow(() -> new BusinessException(ErrorCode.STORE_NOT_FOUND)); + + return toStoreResponse(store); + } catch (NumberFormatException e) { + throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE); + } + } + /** * 매장 정보 수정 * - * @param storeId 수정할 매장 ID + * @param storeId 매장 ID * @param request 매장 수정 요청 정보 * @return 수정된 매장 정보 */ @@ -83,22 +111,23 @@ public class StoreServiceImpl implements StoreService { public StoreResponse updateStore(Long storeId, StoreUpdateRequest request) { Store store = storeRepository.findById(storeId) .orElseThrow(() -> new BusinessException(ErrorCode.STORE_NOT_FOUND)); - + // 매장 정보 업데이트 - store.updateStoreInfo( + store.updateStore( request.getStoreName(), - request.getStoreImage(), + request.getBusinessType(), request.getAddress(), request.getPhoneNumber(), - request.getInstaAccount(), - request.getNaverBlogAccount(), - request.getOpenTime(), - request.getCloseTime(), + request.getBusinessHours(), request.getClosedDays(), - request.getSeatCount() + request.getSeatCount(), + request.getSnsAccounts(), + request.getDescription() ); - + Store updatedStore = storeRepository.save(store); + log.info("매장 정보 수정 완료: {} (ID: {})", updatedStore.getStoreName(), updatedStore.getId()); + return toStoreResponse(updatedStore); } @@ -112,19 +141,25 @@ public class StoreServiceImpl implements StoreService { return StoreResponse.builder() .storeId(store.getId()) .storeName(store.getStoreName()) - .storeImage(store.getStoreImage()) .businessType(store.getBusinessType()) .address(store.getAddress()) .phoneNumber(store.getPhoneNumber()) - .businessNumber(store.getBusinessNumber()) - .instaAccount(store.getInstaAccount()) - .naverBlogAccount(store.getNaverBlogAccount()) - .openTime(store.getOpenTime()) - .closeTime(store.getCloseTime()) + .businessHours(store.getBusinessHours()) .closedDays(store.getClosedDays()) .seatCount(store.getSeatCount()) + .snsAccounts(store.getSnsAccounts()) + .description(store.getDescription()) .createdAt(store.getCreatedAt()) .updatedAt(store.getUpdatedAt()) .build(); } + + /** + * 현재 로그인된 사용자 ID 조회 + * + * @return 사용자 ID + */ + private String getCurrentUserId() { + return SecurityContextHolder.getContext().getAuthentication().getName(); + } }