From e6ef3f0671018cc2716d4a19243edfd9fb81f5d7 Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 11 Jun 2025 11:01:53 +0900 Subject: [PATCH 01/34] 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 zcma&Obx`DOvL%eWOXJW;V64viP??$)@wHcsJ68)>bJS6*&iHnskXE8MjvIPVl|FrmV}Npeql07fCw6`pw`0s zGauF(<*@v{3t!qoUU*=j)6;|-(yg@jvDx&fV^trtZt27?4Tkn729qrItVh@PMwG5$ z+oXHSPM??iHZ!cVP~gYact-CwV`}~Q+R}PPNRy+T-geK+>fHrijpllon_F4N{@b-} z1M0=a!VbVmJM8Xk@NRv)m&aRYN}FSJ{LS;}2ArQ5baSjfy40l@T5)1r-^0fAU6f_} zzScst%$Nd-^ElV~H0TetQhMc%S{}Q4lssln=|;LG?Ulo}*mhg8YvBAUY7YFdXs~vv zv~{duzVw%C#GxkBwX=TYp1Dh*Uaum2?RmsvPaLlzO^fIJ`L?&OV?Y&kKj~^kWC`Ly zfL-}J^4a0Ojuz9O{jUbIS;^JatJ5+YNNHe}6nG9Yd6P-lJiK2ms)A^xq^H2fKrTF) zp!6=`Ece~57>^9(RA4OB9;f1FAhV%zVss%#rDq$9ZW3N2cXC7dMz;|UcRFecBm`DA z1pCO!#6zKp#@mx{2>Qcme8y$Qg_gnA%(`Vtg3ccwgb~D(&@y8#Jg8nNYW*-P{_M#E zZ|wCsQoO1(iIKd-2B9xzI}?l#Q@G5d$m1Lfh0q;iS5FDQ&9_2X-H)VDKA*fa{b(sV zL--krNCXibi1+*C2;4qVjb0KWUVGjjRT{A}Q*!cFmj0tRip2ra>WYJ>ZK4C|V~RYs z6;~+*)5F^x^aQqk9tjh)L;DOLlD8j+0<>kHc8MN|68PxQV`tJFbgxSfq-}b(_h`luA0&;Vk<@51i0 z_cu6{_*=vlvYbKjDawLw+t^H?OV00_73Cn3goU5?})UYFuoSX6Xqw;TKcrsc|r# z$sMWYl@cs#SVopO$hpHZ)cdU-+Ui%z&Sa#lMI~zWW@vE%QDh@bTe0&V9nL>4Et9`N zGT8(X{l@A~loDx}BDz`m6@tLv@$mTlVJ;4MGuj!;9Y=%;;_kj#o8n5tX%@M)2I@}u z_{I!^7N1BxW9`g&Z+K#lZ@7_dXdsqp{W9_`)zgZ=sD~%WS5s$`7z#XR!Lfy(4se(m zR@a3twgMs19!-c4jh`PfpJOSU;vShBKD|I0@rmv_x|+ogqslnLLOepJpPMOxhRb*i zGHkwf#?ylQ@k9QJL?!}MY4i7joSzMcEhrDKJH&?2v{-tgCqJe+Y0njl7HYff z{&~M;JUXVR$qM1FPucIEY(IBAuCHC@^~QG6O!dAjzQBxDOR~lJEr4KS9R*idQ^p{D zS#%NQADGbAH~6wAt}(1=Uff-1O#ITe)31zCL$e9~{w)gx)g>?zFE{Bc9nJT6xR!i8 z)l)~9&~zSZTHk{?iQL^MQo$wLi}`B*qnvUy+Y*jEraZMnEhuj`Fu+>b5xD1_Tp z)8|wedv42#3AZUL7x&G@p@&zcUvPkvg=YJS6?1B7ZEXr4b>M+9Gli$gK-Sgh{O@>q7TUg+H zNJj`6q#O@>4HpPJEHvNij`sYW&u%#=215HKNg;C!0#hH1vlO5+dFq9& zS)8{5_%hz?#D#wn&nm@aB?1_|@kpA@{%jYcs{K%$a4W{k@F zPyTav?jb;F(|GaZhm6&M#g|`ckO+|mCtAU)5_(hn&Ogd z9Ku}orOMu@K^Ac>eRh3+0-y^F`j^noa*OkS3p^tLV`TY$F$cPXZJ48!xz1d7%vfA( zUx2+sDPqHfiD-_wJDb38K^LtpN2B0w=$A10z%F9f_P2aDX63w7zDG5CekVQJGy18I zB!tI`6rZr7TK10L(8bpiaQ>S@b7r_u@lh^vakd0e6USWw7W%d_Ob%M!a`K>#I3r-w zo2^+9Y)Sb?P9)x0iA#^ns+Kp{JFF|$09jb6ZS2}_<-=$?^#IUo5;g`4ICZknr!_aJ zd73%QP^e-$%Xjt|28xM}ftD|V@76V_qvNu#?Mt*A-OV{E4_zC4Ymo|(cb+w^`Wv== z>)c%_U0w`d$^`lZQp@midD89ta_qTJW~5lRrIVwjRG_9aRiQGug%f3p@;*%Y@J5uQ|#dJ+P{Omc`d2VR)DXM*=ukjVqIpkb<9gn9{*+&#p)Ek zN=4zwNWHF~=GqcLkd!q0p(S2_K=Q`$whZ}r@ec_cb9hhg9a z6CE=1n8Q;hC?;ujo0numJBSYY6)GTq^=kB~`-qE*h%*V6-ip=c4+Yqs*7C@@b4YAi zuLjsmD!5M7r7d5ZPe>4$;iv|zq=9=;B$lI|xuAJwi~j~^Wuv!Qj2iEPWjh9Z&#+G>lZQpZ@(xfBrhc{rlLwOC;optJZDj4Xfu3$u6rt_=YY0~lxoy~fq=*L_&RmD7dZWBUmY&12S;(Ui^y zBpHR0?Gk|`U&CooNm_(kkO~pK+cC%uVh^cnNn)MZjF@l{_bvn4`Jc}8QwC5_)k$zs zM2qW1Zda%bIgY^3NcfL)9ug`05r5c%8ck)J6{fluBQhVE>h+IA&Kb}~$55m-^c1S3 zJMXGlOk+01qTQUFlh5Jc3xq|7McY$nCs$5=`8Y;|il#Ypb{O9}GJZD8!kYh{TKqs@ z-mQn1K4q$yGeyMcryHQgD6Ra<6^5V(>6_qg`3uxbl|T&cJVA*M_+OC#>w(xL`RoPQ zf1ZCI3G%;o-x>RzO!mc}K!XX{1rih0$~9XeczHgHdPfL}4IPi~5EV#ZcT9 zdgkB3+NPbybS-d;{8%bZW^U+x@Ak+uw;a5JrZH!WbNvl!b~r4*vs#he^bqz`W93PkZna2oYO9dBrKh2QCWt{dGOw)%Su%1bIjtp4dKjZ^ zWfhb$M0MQiDa4)9rkip9DaH0_tv=XxNm>6MKeWv>`KNk@QVkp$Lhq_~>M6S$oliq2 zU6i7bK;TY)m>-}X7hDTie>cc$J|`*}t=MAMfWIALRh2=O{L57{#fA_9LMnrV(HrN6 zG0K_P5^#$eKt{J|#l~U0WN_3)p^LLY(XEqes0OvI?3)GTNY&S13X+9`6PLVFRf8K) z9x@c|2T72+-KOm|kZ@j4EDDec>03FdgQlJ!&FbUQQH+nU^=U3Jyrgu97&#-W4C*;_ z(WacjhBDp@&Yon<9(BWPb;Q?Kc0gR5ZH~aRNkPAWbDY!FiYVSu!~Ss^9067|JCrZk z-{Rn2KEBR|Wti_iy) zXnh2wiU5Yz2L!W{{_#LwNWXeNPHkF=jjXmHC@n*oiz zIoM~Wvo^T@@t!QQW?Ujql-GBOlnB|HjN@x~K8z)c(X}%%5Zcux09vC8=@tvgY>czq z3D(U&FiETaN9aP}FDP3ZSIXIffq>M3{~eTB{uauL07oYiM=~K(XA{SN!rJLyXeC+Y zOdeebgHOc2aCIgC=8>-Q>zfuXV*=a&gp{l#E@K|{qft@YtO>xaF>O7sZz%8);e86? z+jJlFB{0fu6%8ew^_<+v>>%6eB8|t*_v7gb{x=vLLQYJKo;p7^o9!9A1)fZZ8i#ZU z<|E?bZakjkEV8xGi?n+{Xh3EgFKdM^;4D;5fHmc04PI>6oU>>WuLy6jgpPhf8$K4M zjJo*MbN0rZbZ!5DmoC^@hbqXiP^1l7I5;Wtp2i9Jkh+KtDJoXP0O8qmN;Sp(+%upX zAxXs*qlr(ck+-QG_mMx?hQNXVV~LT{$Q$ShX+&x?Q7v z@8t|UDylH6@RZ?WsMVd3B0z5zf50BP6U<&X_}+y3uJ0c5OD}+J&2T8}A%2Hu#Nt_4 zoOoTI$A!hQ<2pk5wfZDv+7Z{yo+Etqry=$!*pvYyS+kA4xnJ~3b~TBmA8Qd){w_bE zqDaLIjnU8m$wG#&T!}{e0qmHHipA{$j`%KN{&#_Kmjd&#X-hQN+ju$5Ms$iHj4r?) z&5m8tI}L$ih&95AjQ9EDfPKSmMj-@j?Q+h~C3<|Lg2zVtfKz=ft{YaQ1i6Om&EMll zzov%MsjSg=u^%EfnO+W}@)O6u0LwoX709h3Cxdc2Rwgjd%LLTChQvHZ+y<1q6kbJXj3_pq1&MBE{8 zd;aFotyW>4WHB{JSD8Z9M@jBitC1RF;!B8;Rf-B4nOiVbGlh9w51(8WjL&e{_iXN( zAvuMDIm_>L?rJPxc>S`bqC|W$njA0MKWa?V$u6mN@PLKYqak!bR!b%c^ze(M`ec(x zv500337YCT4gO3+9>oVIJLv$pkf`01S(DUM+4u!HQob|IFHJHm#>eb#eB1X5;bMc| z>QA4Zv}$S?fWg~31?Lr(C>MKhZg>gplRm`2WZ--iw%&&YlneQYY|PXl;_4*>vkp;I z$VYTZq|B*(3(y17#@ud@o)XUZPYN*rStQg5U1Sm2gM}7hf_G<>*T%6ebK*tF(kbJc zNPH4*xMnJNgw!ff{YXrhL&V$6`ylY={qT_xg9znQWw9>PlG~IbhnpsG_94Kk_(V-o&v7#F znra%uD-}KOX2dkak**hJnZZQyp#ERyyV^lNe!Qrg=VHiyr7*%j#PMvZMuYNE8o;JM zGrnDWmGGy)(UX{rLzJ*QEBd(VwMBXnJ@>*F8eOFy|FK*Vi0tYDw;#E zu#6eS;%Nm2KY+7dHGT3m{TM7sl=z8|V0e!DzEkY-RG8vTWDdSQFE|?+&FYA146@|y zV(JP>LWL;TSL6rao@W5fWqM1-xr$gRci#RQV2DX-x4@`w{uEUgoH4G|`J%H!N?*Qn zy~rjzuf(E7E!A9R2bSF|{{U(zO+;e29K_dGmC^p7MCP!=Bzq@}&AdF5=rtCwka zTT1A?5o}i*sXCsRXBt)`?nOL$zxuP3i*rm3Gmbmr6}9HCLvL*45d|(zP;q&(v%}S5yBmRVdYQQ24zh z6qL2<2>StU$_Ft29IyF!6=!@;tW=o8vNzVy*hh}XhZhUbxa&;9~woye<_YmkUZ)S?PW{7t; zmr%({tBlRLx=ffLd60`e{PQR3NUniWN2W^~7Sy~MPJ>A#!6PLnlw7O0(`=PgA}JLZ ztqhiNcKvobCcBel2 z-N82?4-()eGOisnWcQ9Wp23|ybG?*g!2j#>m3~0__IX1o%dG4b;VF@^B+mRgKx|ij zWr5G4jiRy}5n*(qu!W`y54Y*t8g`$YrjSunUmOsqykYB4-D(*(A~?QpuFWh;)A;5= zPl|=x+-w&H9B7EZGjUMqXT}MkcSfF}bHeRFLttu!vHD{Aq)3HVhvtZY^&-lxYb2%` zDXk7>V#WzPfJs6u{?ZhXpsMdm3kZscOc<^P&e&684Rc1-d=+=VOB)NR;{?0NjTl~D z1MXak$#X4{VNJyD$b;U~Q@;zlGoPc@ny!u7Pe;N2l4;i8Q=8>R3H{>HU(z z%hV2?rSinAg6&wuv1DmXok`5@a3@H0BrqsF~L$pRYHNEXXuRIWom0l zR9hrZpn1LoYc+G@q@VsFyMDNX;>_Vf%4>6$Y@j;KSK#g)TZRmjJxB!_NmUMTY(cAV zmewn7H{z`M3^Z& z2O$pWlDuZHAQJ{xjA}B;fuojAj8WxhO}_9>qd0|p0nBXS6IIRMX|8Qa!YDD{9NYYK z%JZrk2!Ss(Ra@NRW<7U#%8SZdWMFDU@;q<}%F{|6n#Y|?FaBgV$7!@|=NSVoxlJI4G-G(rn}bh|?mKkaBF$-Yr zA;t0r?^5Nz;u6gwxURapQ0$(-su(S+24Ffmx-aP(@8d>GhMtC5x*iEXIKthE*mk$` zOj!Uri|EAb4>03C1xaC#(q_I<;t}U7;1JqISVHz3tO{) zD(Yu@=>I9FDmDtUiWt81;BeaU{_=es^#QI7>uYl@e$$lGeZ~Q(f$?^3>$<<{n`Bn$ zn8bamZlL@6r^RZHV_c5WV7m2(G6X|OI!+04eAnNA5=0v1Z3lxml2#p~Zo57ri;4>;#16sSXXEK#QlH>=b$inEH0`G#<_ zvp;{+iY)BgX$R!`HmB{S&1TrS=V;*5SB$7*&%4rf_2wQS2ed2E%Wtz@y$4ecq4w<) z-?1vz_&u>s?BMrCQG6t9;t&gvYz;@K@$k!Zi=`tgpw*v-#U1Pxy%S9%52`uf$XMv~ zU}7FR5L4F<#9i%$P=t29nX9VBVv)-y7S$ZW;gmMVBvT$BT8d}B#XV^@;wXErJ-W2A zA=JftQRL>vNO(!n4mcd3O27bHYZD!a0kI)6b4hzzL9)l-OqWn)a~{VP;=Uo|D~?AY z#8grAAASNOkFMbRDdlqVUfB;GIS-B-_YXNlT_8~a|LvRMVXf!<^uy;)d$^OR(u)!) zHHH=FqJF-*BXif9uP~`SXlt0pYx|W&7jQnCbjy|8b-i>NWb@!6bx;1L&$v&+!%9BZ z0nN-l`&}xvv|wwxmC-ZmoFT_B#BzgQZxtm|4N+|;+(YW&Jtj^g!)iqPG++Z%x0LmqnF875%Ry&2QcCamx!T@FgE@H zN39P6e#I5y6Yl&K4eUP{^biV`u9{&CiCG#U6xgGRQr)zew;Z%x+ z-gC>y%gvx|dM=OrO`N@P+h2klPtbYvjS!mNnk4yE0+I&YrSRi?F^plh}hIp_+OKd#o7ID;b;%*c0ES z!J))9D&YufGIvNVwT|qsGWiZAwFODugFQ$VsNS%gMi8OJ#i${a4!E3<-4Jj<9SdSY z&xe|D0V1c`dZv+$8>(}RE|zL{E3 z-$5Anhp#7}oO(xm#}tF+W=KE*3(xxKxhBt-uuJP}`_K#0A< zE%rhMg?=b$ot^i@BhE3&)bNBpt1V*O`g?8hhcsV-n#=|9wGCOYt8`^#T&H7{U`yt2 z{l9Xl5CVsE=`)w4A^%PbIR6uG_5Ww9k`=q<@t9Bu662;o{8PTjDBzzbY#tL;$wrpjONqZ{^Ds4oanFm~uyPm#y1Ll3(H57YDWk9TlC zq;kebC!e=`FU&q2ojmz~GeLxaJHfs0#F%c(i+~gg$#$XOHIi@1mA72g2pFEdZSvp}m0zgQb5u2?tSRp#oo!bp`FP}< zaK4iuMpH+Jg{bb7n9N6eR*NZfgL7QiLxI zk6{uKr>xxJ42sR%bJ%m8QgrL|fzo9@?9eQiMW8O`j3teoO_R8cXPe_XiLnlYkE3U4 zN!^F)Z4ZWcA8gekEPLtFqX-Q~)te`LZnJK_pgdKs)Dp50 zdUq)JjlJeELskKg^6KY!sIou-HUnSFRsqG^lsHuRs`Z{f(Ti9eyd3cwu*Kxp?Ws7l z3cN>hGPXTnQK@qBgqz(n*qdJ2wbafELi?b90fK~+#XIkFGU4+HihnWq;{{)1J zv*Txl@GlnIMOjzjA1z%g?GsB2(6Zb-8fooT*8b0KF2CdsIw}~Hir$d3TdVHRx1m3c z4C3#h@1Xi@{t4zge-#B6jo*ChO%s-R%+9%-E|y<*4;L>$766RiygaLR?X%izyqMXA zb|N=Z-0PSFeH;W6aQ3(5VZWVC>5Ibgi&cj*c%_3=o#VyUJv* zM&bjyFOzlaFq;ZW(q?|yyi|_zS%oIuH^T*MZ6NNXBj;&yM3eQ7!CqXY?`7+*+GN47 zNR#%*ZH<^x{(0@hS8l{seisY~IE*)BD+R6^OJX}<2HRzo^fC$n>#yTOAZbk4%=Bei=JEe=o$jm`or0YDw*G?d> z=i$eEL7^}_?UI^9$;1Tn9b>$KOM@NAnvWrcru)r`?LodV%lz55O3y(%FqN;cKgj7t zlJ7BmLTQ*NDX#uelGbCY>k+&H*iSK?x-{w;f5G%%!^e4QT9z<_0vHbXW^MLR} zeC*jezrU|{*_F`I0mi)9=sUj^G03i@MjXx@ePv@(Udt2CCXVOJhRh4yp~fpn>ssHZ z?k(C>2uOMWKW5FVsBo#Nk!oqYbL`?#i~#!{3w^qmCto05uS|hKkT+iPrC-}hU_nbL zO622#mJupB21nChpime}&M1+whF2XM?prT-Vv)|EjWYK(yGYwJLRRMCkx;nMSpu?0 zNwa*{0n+Yg6=SR3-S&;vq=-lRqN`s9~#)OOaIcy3GZ&~l4g@2h| zThAN#=dh{3UN7Xil;nb8@%)wx5t!l z0RSe_yJQ+_y#qEYy$B)m2yDlul^|m9V2Ia$1CKi6Q19~GTbzqk*{y4;ew=_B4V8zw zScDH&QedBl&M*-S+bH}@IZUSkUfleyM45G>CnYY{hx8J9q}ME?Iv%XK`#DJRNmAYt zk2uY?A*uyBA=nlYjkcNPMGi*552=*Q>%l?gDK_XYh*Rya_c)ve{=ps`QYE0n!n!)_$TrGi_}J|>1v}(VE7I~aP-wns#?>Y zu+O7`5kq32zM4mAQpJ50vJsUDT_^s&^k-llQMy9!@wRnxw@~kXV6{;z_wLu3i=F3m z&eVsJmuauY)8(<=pNUM5!!fQ4uA6hBkJoElL1asWNkYE#qaP?a+biwWw~vB48PRS7 zY;DSHvgbIB$)!uJU)xA!yLE*kP0owzYo`v@wfdux#~f!dv#uNc_$SF@Qq9#3q5R zfuQnPPN_(z;#X#nRHTV>TWL_Q%}5N-a=PhkQ^GL+$=QYfoDr2JO-zo#j;mCsZVUQ) zJ96e^OqdLW6b-T@CW@eQg)EgIS9*k`xr$1yDa1NWqQ|gF^2pn#dP}3NjfRYx$pTrb zwGrf8=bQAjXx*8?du*?rlH2x~^pXjiEmj^XwQo{`NMonBN=Q@Y21!H)D( zA~%|VhiTjaRQ%|#Q9d*K4j~JDXOa4wmHb0L)hn*;Eq#*GI}@#ux4}bt+olS(M4$>c z=v8x74V_5~xH$sP+LZCTrMxi)VC%(Dg!2)KvW|Wwj@pwmH6%8zd*x0rUUe$e(Z%AW z@Q{4LL9#(A-9QaY2*+q8Yq2P`pbk3!V3mJkh3uH~uN)+p?67d(r|Vo0CebgR#u}i? zBxa^w%U|7QytN%L9bKaeYhwdg7(z=AoMeP0)M3XZA)NnyqL%D_x-(jXp&tp*`%Qsx z6}=lGr;^m1<{;e=QQZ!FNxvLcvJVGPkJ63at5%*`W?46!6|5FHYV0qhizSMT>Zoe8 zsJ48kb2@=*txGRe;?~KhZgr-ZZ&c0rNV7eK+h$I-UvQ=552@psVrvj#Ys@EU4p8`3 zsNqJu-o=#@9N!Pq`}<=|((u)>^r0k^*%r<{YTMm+mOPL>EoSREuQc-e2~C#ZQ&Xve zZ}OUzmE4{N-7cqhJiUoO_V#(nHX11fdfVZJT>|6CJGX5RQ+Ng$Nq9xs-C86-)~`>p zW--X53J`O~vS{WWjsAuGq{K#8f#2iz` zzSSNIf6;?5sXrHig%X(}0q^Y=eYwvh{TWK-fT>($8Ex>!vo_oGFw#ncr{vmERi^m7lRi%8Imph})ZopLoIWt*eFWSPuBK zu>;Pu2B#+e_W|IZ0_Q9E9(s@0>C*1ft`V{*UWz^K<0Ispxi@4umgGXW!j%7n+NC~* zBDhZ~k6sS44(G}*zg||X#9Weto;u*Ty;fP!+v*7be%cYG|yEOBomch#m8Np!Sw`L)q+T` zmrTMf2^}7j=RPwgpO9@eXfb{Q>GW#{X=+xt`AwTl!=TgYm)aS2x5*`FSUaaP_I{Xi zA#irF%G33Bw>t?^1YqX%czv|JF0+@Pzi%!KJ?z!u$A`Catug*tYPO`_Zho5iip0@! z;`rR0-|Ao!YUO3yaujlSQ+j-@*{m9dHLtve!sY1Xq_T2L3&=8N;n!!Eb8P0Z^p4PL zQDdZ?An2uzbIakOpC|d@=xEA}v-srucnX3Ym{~I#Ghl~JZU(a~Ppo9Gy1oZH&Wh%y zI=KH_s!Lm%lAY&`_KGm*Ht)j*C{-t}Nn71drvS!o|I|g>ZKjE3&Mq0TCs6}W;p>%M zQ(e!h*U~b;rsZ1OPigud>ej=&hRzs@b>>sq6@Yjhnw?M26YLnDH_Wt#*7S$-BtL08 zVyIKBm$}^vp?ILpIJetMkW1VtIc&7P3z0M|{y5gA!Yi5x4}UNz5C0Wdh02!h zNS>923}vrkzl07CX`hi)nj-B?#n?BJ2Vk0zOGsF<~{Fo7OMCN_85daxhk*pO}x_8;-h>}pcw26V6CqR-=x2vRL?GB#y%tYqi;J}kvxaz}*iFO6YO0ha6!fHU9#UI2Nv z_(`F#QU1B+P;E!t#Lb)^KaQYYSewj4L!_w$RH%@IL-M($?DV@lGj%3ZgVdHe^q>n(x zyd5PDpGbvR-&p*eU9$#e5#g3-W_Z@loCSz}f~{94>k6VRG`e5lI=SE0AJ7Z_+=nnE zTuHEW)W|a8{fJS>2TaX zuRoa=LCP~kP)kx4L+OqTjtJOtXiF=y;*eUFgCn^Y@`gtyp?n14PvWF=zhNGGsM{R- z^DsGxtoDtx+g^hZi@E2Y(msb-hm{dWiHdoQvdX88EdM>^DS#f}&kCGpPFDu*KjEpv$FZtLpeT>@)mf|z#ZWEsueeW~hF78Hu zfY9a+Gp?<)s{Poh_qdcSATV2oZJo$OH~K@QzE2kCADZ@xX(; z)0i=kcAi%nvlsYagvUp(z0>3`39iKG9WBDu3z)h38p|hLGdD+Khk394PF3qkX!02H z#rNE`T~P9vwNQ_pNe0toMCRCBHuJUmNUl)KFn6Gu2je+p>{<9^oZ4Gfb!)rLZ3CR3 z-o&b;Bh>51JOt=)$-9+Z!P}c@cKev_4F1ZZGs$I(A{*PoK!6j@ZJrAt zv2LxN#p1z2_0Ox|Q8PVblp9N${kXkpsNVa^tNWhof)8x8&VxywcJz#7&P&d8vvxn` zt75mu>yV=Dl#SuiV!^1BPh5R)`}k@Nr2+s8VGp?%Le>+fa{3&(XYi~{k{ z-u4#CgYIdhp~GxLC+_wT%I*)tm4=w;ErgmAt<5i6c~)7JD2olIaK8by{u-!tZWT#RQddptXRfEZxmfpt|@bs<*uh?Y_< zD>W09Iy4iM@@80&!e^~gj!N`3lZwosC!!ydvJtc0nH==K)v#ta_I}4Tar|;TLb|+) zSF(;=?$Z0?ZFdG6>Qz)6oPM}y1&zx_Mf`A&chb znSERvt9%wdPDBIU(07X+CY74u`J{@SSgesGy~)!Mqr#yV6$=w-dO;C`JDmv=YciTH zvcrN1kVvq|(3O)NNdth>X?ftc`W2X|FGnWV%s})+uV*bw>aoJ#0|$pIqK6K0Lw!@- z3pkPbzd`ljS=H2Bt0NYe)u+%kU%DWwWa>^vKo=lzDZHr>ruL5Ky&#q7davj-_$C6J z>V8D-XJ}0cL$8}Xud{T_{19#W5y}D9HT~$&YY-@=Th219U+#nT{tu=d|B)3K`pL53 zf7`I*|L@^dPEIDJkI3_oA9vsH7n7O}JaR{G~8 zfi$?kmKvu20(l`dV7=0S43VwVKvtF!7njv1Q{Ju#ysj=|dASq&iTE8ZTbd-iiu|2& zmll%Ee1|M?n9pf~?_tdQ<7%JA53!ulo1b^h#s|Su2S4r{TH7BRB3iIOiX5|vc^;5( zKfE1+ah18YA9o1EPT(AhBtve5(%GMbspXV)|1wf5VdvzeYt8GVGt0e*3|ELBhwRaO zE|yMhl;Bm?8Ju3-;DNnxM3Roelg`^!S%e({t)jvYtJCKPqN`LmMg^V&S z$9OIFLF$%Py~{l?#ReyMzpWixvm(n(Y^Am*#>atEZ8#YD&?>NUU=zLxOdSh0m6mL? z_twklB0SjM!3+7U^>-vV=KyQZI-6<(EZiwmNBzGy;Sjc#hQk%D;bay$v#zczt%mFCHL*817X4R;E$~N5(N$1Tv{VZh7d4mhu?HgkE>O+^-C*R@ zR0ima8PsEV*WFvz`NaB+lhX3&LUZcWWJJrG7ZjQrOWD%_jxv=)`cbCk zMgelcftZ%1-p9u!I-Zf_LLz{hcn5NRbxkWby@sj2XmYfAV?iw^0?hM<$&ZDctdC`; zsL|C-7d;w$z2Gt0@hsltNlytoPnK&$>ksr(=>!7}Vk#;)Hp)LuA7(2(Hh(y3LcxRY zim!`~j6`~B+sRBv4 z<#B{@38kH;sLB4eH2+8IPWklhd25r5j2VR}YK$lpZ%7eVF5CBr#~=kUp`i zlb+>Z%i%BJH}5dmfg1>h7U5Q(-F{1d=aHDbMv9TugohX5lq#szPAvPE|HaokMQIi_ zTcTNsO53(oX=hg2w!XA&+qP}nwr$(C)pgG8emS@Mf7m0&*kiA!wPLS`88c=aD$niJ zp?3j%NI^uy|5*MzF`k4hFbsyQZ@wu!*IY+U&&9PwumdmyfL(S0#!2RFfmtzD3m9V7 zsNOw9RQofl-XBfKBF^~~{oUVouka#r3EqRf=SnleD=r1Hm@~`y8U7R)w16fgHvK-6?-TFth)f3WlklbZh+}0 zx*}7oDF4U^1tX4^$qd%987I}g;+o0*$Gsd=J>~Uae~XY6UtbdF)J8TzJXoSrqHVC) zJ@pMgE#;zmuz?N2MIC+{&)tx=7A%$yq-{GAzyz zLzZLf=%2Jqy8wGHD;>^x57VG)sDZxU+EMfe0L{@1DtxrFOp)=zKY1i%HUf~Dro#8} zUw_Mj10K7iDsX}+fThqhb@&GI7PwONx!5z;`yLmB_92z0sBd#HiqTzDvAsTdx+%W{ z2YL#U=9r!@3pNXMp_nvximh+@HV3psUaVa-lOBekVuMf1RUd26~P*|MLouQrb}XM-bEw(UgQxMI6M&l3Nha z{MBcV=tl(b_4}oFdAo}WX$~$Mj-z70FowdoB{TN|h2BdYs?$imcj{IQpEf9q z)rzpttc0?iwopSmEoB&V!1aoZqEWEeO-MKMx(4iK7&Fhc(94c zdy}SOnSCOHX+A8q@i>gB@mQ~Anv|yiUsW!bO9hb&5JqTfDit9X6xDEz*mQEiNu$ay zwqkTV%WLat|Ar+xCOfYs0UQNM`sdsnn*zJr>5T=qOU4#Z(d90!IL76DaHIZeWKyE1 zqwN%9+~lPf2d7)vN2*Q?En?DEPcM+GQwvA<#;X3v=fqsxmjYtLJpc3)A8~*g(KqFx zZEnqqruFDnEagXUM>TC7ngwKMjc2Gx%#Ll#=N4qkOuK|;>4%=0Xl7k`E69@QJ-*Vq zk9p5!+Ek#bjuPa<@Xv7ku4uiWo|_wy)6tIr`aO!)h>m5zaMS-@{HGIXJ0UilA7*I} z?|NZ!Tp8@o-lnyde*H+@8IHME8VTQOGh96&XX3E+}OB zA>VLAGW+urF&J{H{9Gj3&u+Gyn?JAVW84_XBeGs1;mm?2SQm9^!3UE@(_FiMwgkJI zZ*caE={wMm`7>9R?z3Ewg!{PdFDrbzCmz=RF<@(yQJ_A6?PCd_MdUf5vv6G#9Mf)i#G z($OxDT~8RNZ>1R-vw|nN699a}MQN4gJE_9gA-0%>a?Q<9;f3ymgoi$OI!=aE6Elw z2I`l!qe-1J$T$X&x9Zz#;3!P$I);jdOgYY1nqny-k=4|Q4F!mkqACSN`blRji>z1` zc8M57`~1lgL+Ha%@V9_G($HFBXH%k;Swyr>EsQvg%6rNi){Tr&+NAMga2;@85531V z_h+h{jdB&-l+%aY{$oy2hQfx`d{&?#psJ78iXrhrO)McOFt-o80(W^LKM{Zw93O}m z;}G!51qE?hi=Gk2VRUL2kYOBRuAzktql%_KYF4>944&lJKfbr+uo@)hklCHkC=i)E zE*%WbWr@9zoNjumq|kT<9Hm*%&ahcQ)|TCjp@uymEU!&mqqgS;d|v)QlBsE0Jw|+^ zFi9xty2hOk?rlGYT3)Q7i4k65@$RJ-d<38o<`}3KsOR}t8sAShiVWevR8z^Si4>dS z)$&ILfZ9?H#H&lumngpj7`|rKQQ`|tmMmFR+y-9PP`;-425w+#PRKKnx7o-Rw8;}*Ctyw zKh~1oJ5+0hNZ79!1fb(t7IqD8*O1I_hM;o*V~vd_LKqu7c_thyLalEF8Y3oAV=ODv z$F_m(Z>ucO(@?+g_vZ`S9+=~Msu6W-V5I-V6h7->50nQ@+TELlpl{SIfYYNvS6T6D z`9cq=at#zEZUmTfTiM3*vUamr!OB~g$#?9$&QiwDMbSaEmciWf3O2E8?oE0ApScg38hb&iN%K+kvRt#d))-tr^ zD+%!d`i!OOE3in0Q_HzNXE!JcZ<0;cu6P_@;_TIyMZ@Wv!J z)HSXAYKE%-oBk`Ye@W3ShYu-bfCAZ}1|J16hFnLy z?Bmg2_kLhlZ*?`5R8(1%Y?{O?xT)IMv{-)VWa9#1pKH|oVRm4!lLmls=u}Lxs44@g^Zwa0Z_h>Rk<(_mHN47=Id4oba zQ-=qXGz^cNX(b*=NT0<^23+hpS&#OXzzVO@$Z2)D`@oS=#(s+eQ@+FSQcpXD@9npp zlxNC&q-PFU6|!;RiM`?o&Sj&)<4xG3#ozRyQxcW4=EE;E)wcZ&zUG*5elg;{9!j}I z9slay#_bb<)N!IKO16`n3^@w=Y%duKA-{8q``*!w9SW|SRbxcNl50{k&CsV@b`5Xg zWGZ1lX)zs_M65Yt&lO%mG0^IFxzE_CL_6$rDFc&#xX5EXEKbV8E2FOAt>Ka@e0aHQ zMBf>J$FLrCGL@$VgPKSbRkkqo>sOXmU!Yx+Dp7E3SRfT`v~!mjU3qj-*!!YjgI*^) z+*05x78FVnVwSGKr^A|FW*0B|HYgc{c;e3Ld}z4rMI7hVBKaiJRL_e$rxDW^8!nGLdJ<7ex9dFoyj|EkODflJ#Xl`j&bTO%=$v)c+gJsLK_%H3}A_} z6%rfG?a7+k7Bl(HW;wQ7BwY=YFMSR3J43?!;#~E&)-RV_L!|S%XEPYl&#`s!LcF>l zn&K8eemu&CJp2hOHJKaYU#hxEutr+O161ze&=j3w12)UKS%+LAwbjqR8sDoZHnD=m0(p62!zg zxt!Sj65S?6WPmm zL&U9c`6G}T`irf=NcOiZ!V)qhnvMNOPjVkyO2^CGJ+dKTnNAPa?!AxZEpO7yL_LkB zWpolpaDfSaO-&Uv=dj7`03^BT3_HJOAjn~X;wz-}03kNs@D^()_{*BD|0mII!J>5p z1h06PTyM#3BWzAz1FPewjtrQfvecWhkRR=^gKeFDe$rmaYAo!np6iuio3>$w?az$E zwGH|zy@OgvuXok}C)o1_&N6B3P7ZX&-yimXc1hAbXr!K&vclCL%hjVF$yHpK6i_Wa z*CMg1RAH1(EuuA01@lA$sMfe*s@9- z$jNWqM;a%d3?(>Hzp*MiOUM*?8eJ$=(0fYFis!YA;0m8s^Q=M0Hx4ai3eLn%CBm14 zOb8lfI!^UAu_RkuHmKA-8gx8Z;##oCpZV{{NlNSe<i;9!MfIN!&;JI-{|n{(A19|s z9oiGesENcLf@NN^9R0uIrgg(46r%kjR{0SbnjBqPq()wDJ@LC2{kUu_j$VR=l`#RdaRe zxx;b7bu+@IntWaV$si1_nrQpo*IWGLBhhMS13qH zTy4NpK<-3aVc;M)5v(8JeksSAGQJ%6(PXGnQ-g^GQPh|xCop?zVXlFz>42%rbP@jg z)n)% zM9anq5(R=uo4tq~W7wES$g|Ko z1iNIw@-{x@xKxSXAuTx@SEcw(%E49+JJCpT(y=d+n9PO0Gv1SmHkYbcxPgDHF}4iY zkXU4rkqkwVBz<{mcv~A0K|{zpX}aJcty9s(u-$je2&=1u(e#Q~UA{gA!f;0EAaDzdQ=}x7g(9gWrWYe~ zV98=VkHbI!5Rr;+SM;*#tOgYNlfr7;nLU~MD^jSdSpn@gYOa$TQPv+e8DyJ&>aInB zDk>JmjH=}<4H4N4z&QeFx>1VPY8GU&^1c&71T*@2#dINft%ibtY(bAm%<2YwPL?J0Mt{ z7l7BR718o5=v|jB!<7PDBafdL>?cCdVmKC;)MCOobo5edt%RTWiReAMaIU5X9h`@El0sR&Z z7Ed+FiyA+QAyWn zf7=%(8XpcS*C4^-L24TBUu%0;@s!Nzy{e95qjgkzElf0#ou`sYng<}wG1M|L? zKl6ITA1X9mt6o@S(#R3B{uwJI8O$&<3{+A?T~t>Kapx6#QJDol6%?i-{b1aRu?&9B z*W@$T*o&IQ&5Kc*4LK_)MK-f&Ys^OJ9FfE?0SDbAPd(RB)Oju#S(LK)?EVandS1qb#KR;OP|86J?;TqI%E8`vszd&-kS%&~;1Als=NaLzRNnj4q=+ zu5H#z)BDKHo1EJTC?Cd_oq0qEqNAF8PwU7fK!-WwVEp4~4g z3SEmE3-$ddli))xY9KN$lxEIfyLzup@utHn=Q{OCoz9?>u%L^JjClW$M8OB`txg4r6Q-6UlVx3tR%%Z!VMb6#|BKRL`I))#g zij8#9gk|p&Iwv+4s+=XRDW7VQrI(+9>DikEq!_6vIX8$>poDjSYIPcju%=qluSS&j zI-~+ztl1f71O-B+s7Hf>AZ#}DNSf`7C7*)%(Xzf|ps6Dr7IOGSR417xsU=Rxb z1pgk9vv${17h7mZ{)*R{mc%R=!i}8EFV9pl8V=nXCZruBff`$cqN3tpB&RK^$yH!A8RL zJ5KltH$&5%xC7pLZD}6wjD2-uq3&XL8CM$@V9jqalF{mvZ)c4Vn?xXbvkB(q%xbSdjoXJXanVN@I;8I`)XlBX@6BjuQKD28Jrg05} z^ImmK-Ux*QMn_A|1ionE#AurP8Vi?x)7jG?v#YyVe_9^up@6^t_Zy^T1yKW*t* z&Z0+0Eo(==98ig=^`he&G^K$I!F~1l~gq}%o5#pR6?T+ zLmZu&_ekx%^nys<^tC@)s$kD`^r8)1^tUazRkWEYPw0P)=%cqnyeFo3nW zyV$^0DXPKn5^QiOtOi4MIX^#3wBPJjenU#2OIAgCHPKXv$OY=e;yf7+_vI7KcjKq% z?RVzC24ekYp2lEhIE^J$l&wNX0<}1Poir8PjM`m#zwk-AL0w6WvltT}*JN8WFmtP_ z6#rK7$6S!nS!}PSFTG6AF7giGJw5%A%14ECde3x95(%>&W3zUF!8x5%*h-zk8b@Bz zh`7@ixoCVCZ&$$*YUJpur90Yg0X-P82>c~NMzDy7@Ed|6(#`;{)%t7#Yb>*DBiXC3 zUFq(UDFjrgOsc%0KJ_L;WQKF0q!MINpQzSsqwv?#Wg+-NO; z84#4nk$+3C{2f#}TrRhin=Erdfs77TqBSvmxm0P?01Tn@V(}gI_ltHRzQKPyvQ2=M zX#i1-a(>FPaESNx+wZ6J{^m_q3i})1n~JG80c<%-Ky!ZdTs8cn{qWY%x%X^27-Or_ z`KjiUE$OG9K4lWS16+?aak__C*)XA{ z6HmS*8#t_3dl}4;7ZZgn4|Tyy1lOEM1~6Qgl(|BgfQF{Mfjktch zB5kc~4NeehRYO%)3Z!FFHhUVVcV@uEX$eft5Qn&V3g;}hScW_d)K_h5i)vxjKCxcf zL>XlZ^*pQNuX*RJQn)b6;blT3<7@Ap)55)aK3n-H08GIx65W zO9B%gE%`!fyT`)hKjm-&=on)l&!i-QH+mXQ&lbXg0d|F{Ac#U;6b$pqQcpqWSgAPo zmr$gOoE*0r#7J=cu1$5YZE%uylM!i3L{;GW{ae9uy)+EaV>GqW6QJ)*B2)-W`|kLL z)EeeBtpgm;79U_1;Ni5!c^0RbG8yZ0W98JiG~TC8rjFRjGc6Zi8BtoC);q1@8h7UV zFa&LRzYsq%6d!o5-yrqyjXi>jg&c8bu}{Bz9F2D(B%nnuVAz74zmBGv)PAdFXS2(A z=Z?uupM2f-ar0!A)C6l2o8a|+uT*~huH)!h3i!&$ zr>76mt|lwexD(W_+5R{e@2SwR15lGxsnEy|gbS-s5?U}l*kcfQlfnQKo5=LZXizrL zM=0ty+$#f_qGGri-*t@LfGS?%7&LigUIU#JXvwEdJZvIgPCWFBTPT`@Re5z%%tRDO zkMlJCoqf2A=hkU7Ih=IxmPF~fEL90)u76nfFRQwe{m7b&Ww$pnk~$4Lx#s9|($Cvt ze|p{Xozhb^g1MNh-PqS_dLY|Fex4|rhM#lmzq&mhebD$5P>M$eqLoV|z=VQY{)7&sR#tW zl(S1i!!Rrg7kv+V@EL51PGpm511he%MbX2-Jl+DtyYA(0gZyZQjPZP@`SAH{n&25@ zd)emg(p2T3$A!Nmzo|%=z%AhLX)W4hsZNFhmd4<1l6?b3&Fg)G(Zh%J{Cf8Q;?_++ zgO7O<(-)H|Es@QqUgcXNJEfC-BCB~#dhi6ADVZtL!)Mx|u7>ukD052z!QZ5UC-+rd zYXWNRpCmdM{&?M9OMa;OiN{Y#0+F>lBQ=W@M;OXq;-7v3niC$pM8p!agNmq7F04;| z@s-_98JJB&s`Pr6o$KZ=8}qO*7m6SMp7kVmmh$jfnG{r@O(auI7Z^jj!x}NTLS9>k zdo}&Qc2m4Ws3)5qFw#<$h=g%+QUKiYog33bE)e4*H~6tfd42q+|FT5+vmr6Y$6HGC zV!!q>B`1Ho|6E|D<2tYE;4`8WRfm2#AVBBn%_W)mi(~x@g;uyQV3_)~!#A6kmFy0p zY~#!R1%h5E{5;rehP%-#kjMLt*{g((o@0-9*8lKVu+t~CtnOxuaMgo2ssI6@kX09{ zkn~q8Gx<6T)l}7tWYS#q0&~x|-3ho@l}qIr79qOJQcm&Kfr7H54=BQto0)vd1A_*V z)8b2{xa5O^u95~TS=HcJF5b9gMV%&M6uaj<>E zPNM~qGjJ~xbg%QTy#(hPtfc46^nN=Y_GmPYY_hTL{q`W3NedZyRL^kgU@Q$_KMAjEzz*eip`3u6AhPDcWXzR=Io5EtZRPme>#K9 z4lN&87i%YYjoCKN_z9YK+{fJu{yrriba#oGM|2l$ir017UH86Eoig3x+;bz32R*;n zt)Eyg#PhQbbGr^naCv0?H<=@+Poz)Xw*3Gn00qdSL|zGiyYKOA0CP%qk=rBAlt~hr zEvd3Z4nfW%g|c`_sfK$z8fWsXTQm@@eI-FpLGrW<^PIjYw)XC-xFk+M<6>MfG;WJr zuN}7b;p^`uc0j(73^=XJcw;|D4B(`)Flm|qEbB?>qBBv2V?`mWA?Q3yRdLkK7b}y& z+!3!JBI{+&`~;%Pj#n&&y+<;IQzw5SvqlbC+V=kLZLAHOQb zS{{8E&JXy1p|B&$K!T*GKtSV^{|Uk;`oE*F;?@q1dX|>|KWb@|Dy*lbGV0Gx;gpA$ z*N16`v*gQ?6Skw(f^|SL;;^ox6jf2AQ$Zl?gvEV&H|-ep*hIS@0TmGu1X1ZmEPY&f zKCrV{UgRAiNU*=+Uw%gjIQhTAC@67m)6(_D+N>)(^gK74F%M2NUpWpho}aq|Kxh$3 zz#DWOmQV4Lg&}`XTU41Z|P~5;wN2c?2L{a=)Xi~!m#*=22c~&AW zgG#yc!_p##fI&E{xQD9l#^x|9`wSyCMxXe<3^kDIkS0N>=oAz7b`@M>aT?e$IGZR; zS;I{gnr4cS^u$#>D(sjkh^T6_$s=*o%vNLC5+6J=HA$&0v6(Y1lm|RDn&v|^CTV{= zjVrg_S}WZ|k=zzp>DX08AtfT@LhW&}!rv^);ds7|mKc5^zge_Li>FTNFoA8dbk@K$ zuuzmDQRL1leikp%m}2_`A7*7=1p2!HBlj0KjPC|WT?5{_aa%}rQ+9MqcfXI0NtjvXz1U)|H>0{6^JpHspI4MfXjV%1Tc1O!tdvd{!IpO+@ z!nh()i-J3`AXow^MP!oVLVhVW&!CDaQxlD9b|Zsc%IzsZ@d~OfMvTFXoEQg9Nj|_L zI+^=(GK9!FGck+y8!KF!nzw8ZCX>?kQr=p@7EL_^;2Mlu1e7@ixfZQ#pqpyCJ```(m;la2NpJNoLQR};i4E;hd+|QBL@GdQy(Cc zTSgZ)4O~hXj86x<7&ho5ePzDrVD`XL7{7PjjNM1|6d5>*1hFPY!E(XDMA+AS;_%E~ z(dOs)vy29&I`5_yEw0x{8Adg%wvmoW&Q;x?5`HJFB@KtmS+o0ZFkE@f)v>YYh-z&m z#>ze?@JK4oE7kFRFD%MPC@x$^p{aW}*CH9Y_(oJ~St#(2)4e-b34D>VG6giMGFA83 zpZTHM2I*c8HE}5G;?Y7RXMA2k{Y?RxHb2 zZFQv?!*Kr_q;jt3`{?B5Wf}_a7`roT&m1BN9{;5Vqo6JPh*gnN(gj}#=A$-F(SRJj zUih_ce0f%K19VLXi5(VBGOFbc(YF zLvvOJl+W<}>_6_4O?LhD>MRGlrk;~J{S#Q;Q9F^;Cu@>EgZAH=-5fp02(VND(v#7n zK-`CfxEdonk!!65?3Ry(s$=|CvNV}u$5YpUf?9kZl8h@M!AMR7RG<9#=`_@qF@})d ztJDH>=F!5I+h!4#^DN6C$pd6^)_;0Bz7|#^edb9_qFg&eI}x{Roovml5^Yf5;=ehZ zGqz-x{I`J$ejkmGTFipKrUbv-+1S_Yga=)I2ZsO16_ye@!%&Op^6;#*Bm;=I^#F;? z27Sz-pXm4x-ykSW*3`)y4$89wy6dNOP$(@VYuPfb97XPDTY2FE{Z+{6=}LLA23mAc zskjZJ05>b)I7^SfVc)LnKW(&*(kP*jBnj>jtph`ZD@&30362cnQpZW8juUWcDnghc zy|tN1T6m?R7E8iyrL%)53`ymXX~_;#r${G`4Q(&7=m7b#jN%wdLlS0lb~r9RMdSuU zJ{~>>zGA5N`^QmrzaqDJ(=9y*?@HZyE!yLFONJO!8q5Up#2v>fR6CkquE$PEcvw5q zC8FZX!15JgSn{Gqft&>A9r0e#be^C<%)psE*nyW^e>tsc8s4Q}OIm})rOhuc{3o)g1r>Q^w5mas) zDlZQyjQefhl0PmH%cK05*&v{-M1QCiK=rAP%c#pdCq_StgDW}mmw$S&K6ASE=`u4+ z5wcmtrP27nAlQCc4qazffZoFV7*l2=Va}SVJD6CgRY^=5Ul=VYLGqR7H^LHA;H^1g}ekn=4K8SPRCT+pel*@jUXnLz+AIePjz@mUsslCN2 z({jl?BWf&DS+FlE5Xwp%5zXC7{!C=k9oQLP5B;sLQxd`pg+B@qPRqZ6FU(k~QkQu{ zF~5P=kLhs+D}8qqa|CQo2=cv$wkqAzBRmz_HL9(HRBj&73T@+B{(zZahlkkJ>EQmQ zenp59dy+L;sSWYde!z_W+I~-+2Xnm;c;wI_wH=RTgxpMlCW@;Us*0}L74J#E z8XbDWJGpBscw?W$&ZxZNxUq(*DKDwNzW7_}AIw$HF6Ix|;AJ3t6lN=v(c9=?n9;Y0 zK9A0uW4Ib9|Mp-itnzS#5in=Ny+XhGO8#(1_H4%Z6yEBciBiHfn*h;^r9gWb^$UB4 zJtN8^++GfT`1!WfQt#3sXGi-p<~gIVdMM<#ZZ0e_kdPG%Q5s20NNt3Jj^t$(?5cJ$ zGZ#FT(Lt>-0fP4b5V3az4_byF12k%}Spc$WsRydi&H|9H5u1RbfPC#lq=z#a9W(r1 z!*}KST!Yhsem0tO#r!z`znSL-=NnP~f(pw-sE+Z$e7i7t9nBP^5ts1~WFmW+j+<@7 zIh@^zKO{1%Lpx^$w8-S+T_59v;%N;EZtJzcfN%&@(Ux5 z@YzX^MwbbXESD*d(&qT7-eOHD6iaH-^N>p2sVdq&(`C$;?#mgBANIc5$r| z^A$r)@c{Z}N%sbfo?T`tTHz9-YpiMW?6>kr&W9t$Cuk{q^g1<$I~L zo++o2!!$;|U93cI#p4hyc!_Mv2QKXxv419}Ej#w#%N+YIBDdnn8;35!f2QZkUG?8O zpP47Wf9rnoI^^!9!dy~XsZ&!DU4bVTAi3Fc<9$_krGR&3TI=Az9uMgYU5dd~ksx+} zP+bs9y+NgEL>c@l>H1R%@>5SWg2k&@QZL(qNUI4XwDl6(=!Q^U%o984{|0e|mR$p+ z9BcwttR#7?As?@Q{+j?K6H7R71PuiA^Dl$=f47nUKL|koCwutc_P<-m{|Al3C~o7w z=4S=}s5LcJFT1zjS)+10X_r$74`K78pz!nGGH%JV%w75!YSIt#hT7}}K>+@{{a+Im z5p#6%^X*txY?}|T17xWW*sa^?G2QHt#@tlcw0GIcy;|NR2vaCBDvn=`h)1il7E5Rx z%)mA4$`$OZx)NF5vXZnaJ1)*cA6ryx6Ll~t!LzhxvcTedxT;>JS&e=?-&DXUPaQ2~ zH*69ezE`hgV{K-|0z|m~ld}=X^-Ob={wpex&}*+Rz{gx)G}gn!C_VN{UN=>^EV=Xc zr$-HO09cW&p4^M}V3yBjTP_xrVcc8iU_^Y-JD~(bgw*@GXGB1gYKz5DWO+O`>})|N zWrC)MR93yA)3{&27-M)TJB6Ml3~?zZg#mYsF=#OSTaw&K z@hBftpt+2l@)YK@|3DvTjl(8wZtpLp9Ik!6G$CSL_idZ$Ti?R)4toe8bb)l|)lNb}?K;O2K9vyn1QG zd=v#y-Ld49UVkmfRU>Egc+(Y$^-;6vW;3Lcu*6~etz}0|@+b|+!UCal)DEYGLbHWJ zll5Wi^$Y<6@S%^y%hdjRh6&{!z1Py|lZ|q&Wub3l41uN2zEF8E&5H5?PL*&V}?*a}Lp% zCYi{ghjpRNT^^B+_U59No50Ghih5qn(W5`RkrsDWr{~A1dgtv{sRkH4RU2^A{jb&0 zxVRnrm|u<;$iI;M6A>$POP)TWGU-gSjAERk*EGmVT(aw$!XUSe~7Ql-oRA54^4V(JWS6Q1mG?!vZ zx+pE!FEtvqr|Xrcb3oR`%LHFLmU_&{=p%mGy6MRe2Yz_5WJ8p@IgU2 zdVvvhhQtiQkChK%*&PsiPCBL9oDOoJX8!$S(V>R}+1M}wzK*U*A{KJ`r=lM;mPrKU zQDqqN(W*u-5-?$(SIk<6A0E}34y&@-IVC%S!a1F4kz<3bIKjlyD)ooO_7ftl%S_(6w`!vX&1PZ!K`@D@L6JR)6zO@Dl!YF{RY}d3HZ7?Q5E>w=$ ze)H_)48Ds*Ov4?zoGb2fe3}{!5Ooc|KCIni1o)(Gj+CO?`*7jsV`hIv@8J(22o4Q? zu?Bvi)zDG(me?7XKeL|iF9ZRgZdT*}Ffsl62Cu;{Gv9j6dO zPt*H2GqC)-C`V`ceuu=tM{7!2yTEj=*5+T~5DYiZ)Hy)*PARYI6R2lZXoOj;v8M4W z*O-NX(7_~Q&A3>Oaw&1lBH_H%SwmISX-i3)HfHvBOeVwTT{LUM3}ZuZmg<(>)KE;d zbs2!0v6>J;1nQ0UJkUxnkE@Ibi~Q}M=-=Rk;hcOnxO$luOKEVxZc|!XECgex(2`}T z3Y;Q_6rL)e+SrOZhQj5_e}Lv>w7n*Pep$yWZNQl>ubBgb_NIWWDn3kNpn+MPQXV;8 zV|_Ba5jsQ(w&Ey^IM|@|y!AqcJ#3m0#Q6_qvgCG~eoF#mnGmbO(;DP+bW%_aOs1R_ z@9p#7X2UA^--#Nwx_Hvk2l1`eO{P*#j@q2UELtH|Uh6hxR`h_847wIJo0=5CQQ`6it|%a-I$^&a@we1rc&*;QIu5Ck^?) zx*5eSd*mG#=6Hi(5!;5uUi&{HfnT1S8X-)?gE5CZ6KWoqM5|CyrULmuFBKOU8SOp* z{IB1$OCcq`S-k*xs;4fmhKsIGZ;GYAY*%(@875NxhMq|j*m4CNLI(Vho|N|F);!E0cS5y^$H^Izje?z}oTgyr`9x9G&rlJZw&uqIoBMtz zzhU0(9;w02?m#0!)cFi*r+8YvooQ;(s2lLVvyLqAE%Xqe!vtWbIs!l1Bpp(FIht-Z zPn#CN-2C|J*GhA2fuHqYQ2mJiXlGTzD}mkr2;ia8Wp}h^;OS7+N^Mw|en!1${vN6 z-x{8N*4UekA~`IV2&K-GzhAqau|}d*pEQ$1MH$cFi03OG^1NetZ_jW^STaEzr&Xho zB452St%v3ez2#TFm~`gZh$vi=in+y2d!z<{OZ~Kty-5bQ;0O=k_ESi8Nx9{*T`LJy6jqR>&|+>OZ;+=0hA04 zE25t^sE9HG)3^KKR_A5WDkqispweP9!I-@dCO&N!JrD@i{WBHnfQ z95o8;d$`AFnca3;N-0iX-CmbbAp5yQ!GoH;h7Cn?m{ammZJI8igP{U73lFnl2&gCs zqJ4(Vo~^j`{zOAzScL5B_Sm?Mjtek1d(A6X5ObcZi$;aOYy|g$}BY z$GEP3#i60Ju_&3SHzryH!gUFwC9-295u??cf+aYRQ1$+!rc#42YNattd6mZEFI@?C zqFM>6+zxEunIHDZ>{Z15u##>N(28Dw!>G(k*dB{NHvip@aP}f`@=Q;!o;zRMWo{Cx zo?kyzh8n7#f1g0&g>Cd>O-2g?uPwy8sy8hZbHSsXPmU;@l=HL=zm7mN(=@*|D$i+u zs~TllkCTvD$f&-#b9B?}#Lg*-ibK13R_a$RyoN3m5`10tdhAq{+VW)K#Bht-ra1*J z+n$N%V>u0rVtx`aKJDwXXrxaD7nS<>$=c82v7@KVx^S@vT;h=SZE37K>iahpx3;VDzEr9GY=2(%uaqM;^76eSP0QLzo4sI z>p_Eei*T$K;|qK`sq;?Hesp}(@VvX2Q4sAMYAJ}b&d$htDMC{FG-$o4k9ApECi1$a zXdamjiOGKHBh(4M<3(2x6n-CrmZMCknkQxdSS!qlis#I}btfX;J`JU3RlvtLdrymP zG0ZzrsGXVFiq+Wk1=BFay&9ZiCE#(`h~CL+c-Hs@iGTU@YxM%vlg;)`Tf~IknA^02 zXkN#Txo6aR{j$wP5T#|UH#5AP2{rSY8p?jKFv zG3kn3y`FaV!*Jq%m39_TQEhD>M@l*bhEPGe1{ft3q#K5AknT=F2_=T^l#ou5ln@D# z5Tzs(kRG@qNDa~HLNvfv7Z0g=bSlb?`QAx|Gfoni|iHJ%K0cy z;~Nsaa+{8HP_qrb{nj+xzkdYhSI@W4N_1`z(eSGIkbDP)!Ko|M%}Rqp(~KI2hl~eE zvJ!j4m6iwMgKy>fkCLC)`M$z9EV}B+sq1}}kVf$(ig0pWTY?rHz1Sm=4srTGNb^JG z=2$9wz-C@aZZZ2!HY#HNejqZRmE=pN(D$Kui$NpfhU`!y_s{@MIxiJdHb1|{6xb`> zE74_@QtgtG{4=3P1$^vn&m}7Aw8!1DnT$2thO#~44wl(N#ao8S0@t@m+Z!KD2CfK; z)n5DAPKV_etmH1aLDK$?`;sL91iVt$D z*SG}=-LIAg(*+JON!-5ivqOMQ1S!OQUgHglDsKik&Mwg;vva523`JwQH6SRz9eTY# zTIi23145~kc3r1mSWC_RzD%hs$S#!pkI9!BU80jJCJcwo*FZolQG$q`8C1d9pP@ND zG^&-ZraIvhg_FDVSfKGwkcI=avIan%2sK4coUs~Nr8jC*&!G0#?}_^s3r-c}-uAqi zM-Lw>Y}I``T;IS%Y|qH;s{F*ZefM!4{I5awr!K+T@uPd*Vu*iPWI}>(-D{zxsN>LG z=@747a_Rb2>q?y8xYf?dq2HM5tFO8Y5e4N;Y=xy8yAhI zsm>oy%R5;7)7T3V_b2%`aH^tNlsQpFxIFW#iV#8?{6{^cGr{A0@1bA)|K z>MMTuZD(pd2t|7vmHtywGXb%%=)S<`OG~}U+jm#xd%H8 z$v8-C%F?ah3$;hn?{G3(LT!SgvCVi$vwsZssAQvUwT`Q%qSw!LSd!(I!64w1=%Sc1Mck)q1@pZ@)=SY zoX}d+L3-RA|c?G3_BQNm&( z!i$AZ7cI(z7q|e9VM##6T3Xorj1JG(9os$;(I$y%mBy(#8{|3l4|x*oBAQL^XhZ0g zy1FR1teRrpKq{uLAibTLx#n({qwjlkOvR{OdSAeT5ah4-sNN)n4Clg1T9lzF)&yj; zyal1%+s4n1IG;^VPWJ;#olpk8Z42Gj-tjFeQ&PlxB)`oCNoUYKj4U$AeG8rYiD{pK zndDf&2;2;)D|KvOZP+e7fcPU9k4M2sfhr@vC~Ly0?S-4dz)ZGAYpCsAhChgbxLd4g zhTrbIPkO5SEp_kD>Ha0m12h5n3s;mE8kn515&nzSf+^D= zyE{JnJ;43l&BH55CL<=W%CF;6iUI)V5C*6!`**KqvzR2=Fj*3Y4`HYwx}TYD445(K z-QtXwtL?m*(F=LVH*H4oM>dXHBW=38q_dZ-_Vr&qpEPxd9Fs95P5W~@Z|Rt+WZP6l zPSQ}~Dh4V?Pp1g&Hk*Px?lm16C@X6M29Vrk%Rw@E||E-v~$ zb_E~{z<}#8i`Mx9mkqtd#Z1lZ-E_J8I+2oumc#x1)jdvh{W76NKm6x-RYpM~v!P8$ zw3e|YVf|}Hse9~oC@N7^j}Fi$hNpyaYnu1}bdXsD=^oI*%WKvbme|BI}$G3>smu#6y)ls|j? zF7Bhu9Z)j)C;3cZb+I>0stSK^WLOYV^U{pUYkgv>?+Nt^5j*CUB=eGw-CvU&40>y~ zGoHLXxY^7k5Xgv62{iQy|5jJQuq0|LU`}lE@flQ2Z*Zn*VWcQjm4FTb>LSVox^S4q zLn`LfS@mrjKCmg$nb^af?d?0&$aX6#2u(JyzIJvuJ*lwPrh|0~aEnSACCTezSdG%h zmSQg`17j@$Iq)r1&?+eR@1nlX|H`<}_!?BQSF&N+QQnvEAqZe+mIFui!0V49R?|9*$ zv!K1A01{8xq;L()Tv*Qk0-$Oj6+vCT*TUD{HvxO@3JjxBwM!4g3ydy&eaJw4CoQBF zJtULJ!YxgNR7_Ls%LmogyI7uIs=!B&?=MYY^yX+v;j@D_xGeZg>eZk0C;4e|HRNSi z6KlD9>q=3v-$4Zik&^ZDhNm1X)+7LCH1k!s+T3tn zUn@={1U&NJLq@K?~w|(=Y<4W{ucX}FdRr6pLw(l2$iK)At%t3gYBMlJz#(K0Nqm;=KAML!&MMSNz=%k=j*zh77r34Rs37iCY` z=_kva_41bdrj(b=4Wc5MO0~q^z#pIWJ>)vDSgIQF=3JVJe1iDy%h)8oNy{s_r&;m` zL{DYKSB_5xRb9xKNOS{qAY3qv5sSXVrrf%~*q5HO|CQ&lbKMePa$M5D{vlJcoGrCZ zD?fKbZN$6rWwz)w7`9h4DAmh1ij2}EO|bO#A9L0_RW6l*$sPPUJrUbhLC75L9%W5iO$Iw5~Yut-qBeu~hF|xD7-eQ%l z412vpq_;t%^F*pYDk%Q35c-erK|6Ve=FxQbAv~ikZ4c9$Y4;ee#ciOD9{yRqf55Qk zumv}#+JciT|Gj$uFOxBUze)=?l{B}qaC0_7m`t82<$K53!4Xvi9Tr)ADp3Off?O8o zVDG0Yx|tfn@r((m?Nxrh(b0DGjg)$;DfO&$6uY;4&F!4jnxkhP}Y3x zS?WFFt>=HWzqlQhffVfvM$Ta8Sg*r3j!Eo&rUOW7SCL2~lG7<+XZ;+{&8h5g8ElI+P>>yR2U%S93NN!Xhm|C682t6ysH-=o1=Bd*N*VlnG%l+KZFtjG`UkL;%65qn0UYQ`h zh0{9jDQx(`aBe7J0Aj3Z)4}`A|4OMM0a;?{j}qkYwi)~O8$9D}ITiMH2buiU>ixYp zhL${nwj6X($*OwmpVG`y5b6v45tX*J8?og}Qju6eJ9H}`X87iEd%BUo7<`2q(HJx+ zMR}d-J4oAf{V1W^a2~`M-YAdZ81dd4o6NPO{cmZaAS@RS4ir#Sr zfFZO-VIL|VN<%nEXr2` z$0FK2L#8O_f1w~c@G70JrB@N}r(gJ!Vmkk6{r68w!o$qO?HrFcjeU0_3F5;*!E2%( zTx>4?gP8w z1B?3UVZmz^%d_dIps>>0{cB~mp3{9UoPR6uQFecVq&} zY{ebB?AlPAD_}(ll{fK99;Wh1cgRbnw)maD^F>*J!R}eHM*W0VYN1TADWMy9H=$00 z5bHY${oDgwX7(W9LZw?}{!8(_{JB~Xkje6{0x4fgC4kUmpfJ+LT1DYD*TWu4#h{Y7 zFLronmc=hS=W=j1ar3r1JNjQoWo2hMWsqW*e?TF%#&{GpsaLp}iN~$)ar+7Ti}E&X z-nq~+Gkp(`qF0F_4A22>VZn-x>I$?PDZSeG8h_ifoWf^DxIb5%T7UytYo3}F|4#RC zUHpg$=)qVqD~=m(!~?XwocuxU1u}9qhhM7d^eqmJPi_e-!IO`*{u7A zbu*?L$Mbj-X9n3G2>+Kc#l`@d8}Xb9{l*IN{#M*d;s+3Pdr8FO$EBELR=8{ zd?LJbSv9fI`{OqTH)5{b?WulgMb)psp+W|@cSp=jtl-&5C}9lw@*0H+gEW(}mAWNz zf{~U;;N}|wdSaphgqnH{FWUy!{y3^=AC*c?RJ5Eb<^ zCgH_v7^axIUVmHSFL^zlj2R$zow$|y#7>%#U7d#Vp_ezcp3lefMyd5ES=q$>4pWyA zp_Zso^^NP~lu2=S6nD(3Z5u=Uy&B&F1i$J*3;3KhEkD_lgscHGR*;T;U!9vgQa(hI}oh9IzEf_PU_8F+i77t-~gDX z490Sb)LyVZmf18N6w{+37$aO<2!Av0 ztLaPOv^J<2@p{WnMiDudoghX_`luFZt_4eNU}*~cF5i%eEcNLs;D>QVIwr8mH;=dc z09`}JV;aaF;13@&iS(w>Jc=k~|d_1hcpM(l|O zu>!@}me%isTT$xT#hNUvh(ATd0wT4fbv=6htcHNEZIw9%E6wlYmwfu2{j0kh1y=$;Yf!|NldgB9ul zB{dbE&LfRnr8ITm@;-68wo#VV?8lG3ed&9k1}QBS3}WGV9%26?A1rBkkDR9Z3o+g+ z)eQg8BY3y(Dh5&z?VLLNdDV`C=muUvCPpGg!oYxIgOI3^%4>5d7jTh~ni!Fg2;fhx z(*c%H6Je84kmQh;5tC3*l~7khLxK-e|Cz?FLh!yYe7g|*LwqU?2wv^_ZyKT$fYVkGJo@AK0$+ml?}zJeB~deT2WL1vz}dxB z)y??t!}%M@)u$_IyW~)6u1SttJ!awd6N5lx|xBrmyrBh>tb&D*=C+Z3nPfq$1%WgY0bY*?PZ#Hk|=xn zGM#0*w4CaB^y0G(J4q=;5NeM@m-P}#mv7QZNF)M!dK^w{mk_!n0`+Y3PQutu-%NBt zzgPXug?JLEbUL{e_dk;Vd896&yPe(hliVK!lj%5+@BKdcrEZ2Nc_*i@ve*2lB>u~{ zFozd2FM|_0+nAGR4TLNHanQn_Oeb!JrUcvzJ?7p9TTNB}ocO3j$7ij!li8#k6 z@2tSd1>K03K9A#_-MIq)S;T#oE^;>U$)&}okIvDf3lm?kI{d80$>~xKUoS!%q1Pi?WpsUUt(tI ztjNjY*y&Rm9(S(DC2GuPHBJs@5M{RGm`c1z<6nwyN^)rMo-AS{M2$oM9|y%fM|}G~ 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(); + } } From 8a23cf689c9edc85c475b20d16a09dd899d9b870 Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 11 Jun 2025 11:03:38 +0900 Subject: [PATCH 02/34] update: ai-recommend dto --- .../recommend/presentation/dto/DetailedMarketingTipResponse.java | 0 .../smarketing/recommend/presentation/dto/ErrorResponseDto.java | 0 .../recommend/presentation/dto/MarketingTipGenerationRequest.java | 0 .../recommend/presentation/dto/MarketingTipRequest.java | 0 .../recommend/presentation/dto/MarketingTipResponse.java | 0 .../won/smarketing/recommend/presentation/dto/StoreInfoDto.java | 0 .../won/smarketing/recommend/presentation/dto/WeatherInfoDto.java | 0 7 files changed, 0 insertions(+), 0 deletions(-) rename {recommend => ai-recommend}/src/main/java/com/won/smarketing/recommend/presentation/dto/DetailedMarketingTipResponse.java (100%) rename {recommend => ai-recommend}/src/main/java/com/won/smarketing/recommend/presentation/dto/ErrorResponseDto.java (100%) rename {recommend => ai-recommend}/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipGenerationRequest.java (100%) rename {recommend => ai-recommend}/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipRequest.java (100%) rename {recommend => ai-recommend}/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java (100%) rename {recommend => ai-recommend}/src/main/java/com/won/smarketing/recommend/presentation/dto/StoreInfoDto.java (100%) rename {recommend => ai-recommend}/src/main/java/com/won/smarketing/recommend/presentation/dto/WeatherInfoDto.java (100%) diff --git a/recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/DetailedMarketingTipResponse.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/DetailedMarketingTipResponse.java similarity index 100% rename from recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/DetailedMarketingTipResponse.java rename to ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/DetailedMarketingTipResponse.java diff --git a/recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/ErrorResponseDto.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/ErrorResponseDto.java similarity index 100% rename from recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/ErrorResponseDto.java rename to ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/ErrorResponseDto.java diff --git a/recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipGenerationRequest.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipGenerationRequest.java similarity index 100% rename from recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipGenerationRequest.java rename to ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipGenerationRequest.java diff --git a/recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipRequest.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipRequest.java similarity index 100% rename from recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipRequest.java rename to ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipRequest.java diff --git a/recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java similarity index 100% rename from recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java rename to ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java diff --git a/recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/StoreInfoDto.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/StoreInfoDto.java similarity index 100% rename from recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/StoreInfoDto.java rename to ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/StoreInfoDto.java diff --git a/recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/WeatherInfoDto.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/WeatherInfoDto.java similarity index 100% rename from recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/WeatherInfoDto.java rename to ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/WeatherInfoDto.java From 1e809c4b5996e10cfe7bd98032774fe179421baf Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 11 Jun 2025 11:10:41 +0900 Subject: [PATCH 03/34] fix: build --- .../recommend/domain/model/MarketingTip.java | 58 ++++ .../external/ClaudeAiTipGenerator.java | 60 +---- build.gradle | 54 +++- common/build.gradle | 37 ++- gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43705 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 251 ++++++++++++++++++ gradlew.bat | 94 +++++++ settings.gradle | 2 +- 9 files changed, 487 insertions(+), 76 deletions(-) create mode 100644 ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MarketingTip.java create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MarketingTip.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MarketingTip.java new file mode 100644 index 0000000..8ff523d --- /dev/null +++ b/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MarketingTip.java @@ -0,0 +1,58 @@ +package com.won.smarketing.recommend.domain.model; + +import lombok.*; + +import java.time.LocalDateTime; + +/** + * 마케팅 팁 도메인 모델 + * AI가 생성한 마케팅 팁과 관련 정보를 관리 + */ +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class MarketingTip { + + /** + * 마케팅 팁 고유 식별자 + */ + private TipId id; + + /** + * 매장 ID + */ + private Long storeId; + + /** + * AI가 생성한 마케팅 팁 내용 + */ + private String tipContent; + + /** + * 팁 생성 시 참고한 날씨 데이터 + */ + private WeatherData weatherData; + + /** + * 팁 생성 시 참고한 매장 데이터 + */ + private StoreData storeData; + + /** + * 팁 생성 시각 + */ + private LocalDateTime createdAt; + + /** + * 팁 내용 업데이트 + * + * @param newContent 새로운 팁 내용 + */ + public void updateContent(String newContent) { + if (newContent == null || newContent.trim().isEmpty()) { + throw new IllegalArgumentException("팁 내용은 비어있을 수 없습니다."); + } + this.tipContent = newContent.trim(); + } +} \ No newline at end of file diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/ClaudeAiTipGenerator.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/ClaudeAiTipGenerator.java index bb93ce3..2a3f5ce 100644 --- a/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/ClaudeAiTipGenerator.java +++ b/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/ClaudeAiTipGenerator.java @@ -187,62 +187,4 @@ public class ClaudeAiTipGenerator implements AiTipGenerator { public void setType(String type) { this.type = type; } } } -}/model/MarketingTip.java -package com.won.smarketing.recommend.domain.model; - -import lombok.*; - -import java.time.LocalDateTime; - -/** - * 마케팅 팁 도메인 모델 - * AI가 생성한 마케팅 팁과 관련 정보를 관리 - */ -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@Builder -public class MarketingTip { - - /** - * 마케팅 팁 고유 식별자 - */ - private TipId id; - - /** - * 매장 ID - */ - private Long storeId; - - /** - * AI가 생성한 마케팅 팁 내용 - */ - private String tipContent; - - /** - * 팁 생성 시 참고한 날씨 데이터 - */ - private WeatherData weatherData; - - /** - * 팁 생성 시 참고한 매장 데이터 - */ - private StoreData storeData; - - /** - * 팁 생성 시각 - */ - private LocalDateTime createdAt; - - /** - * 팁 내용 업데이트 - * - * @param newContent 새로운 팁 내용 - */ - public void updateContent(String newContent) { - if (newContent == null || newContent.trim().isEmpty()) { - throw new IllegalArgumentException("팁 내용은 비어있을 수 없습니다."); - } - this.tipContent = newContent.trim(); - } -} +} \ No newline at end of file diff --git a/build.gradle b/build.gradle index ef2c19b..ebeb413 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,53 @@ plugins { id 'java' - id 'org.springframework.boot' version '3.4.0' - id 'io.spring.dependency-management' version '1.1.4' -} \ No newline at end of file + id 'org.springframework.boot' version '3.4.0' apply false + id 'io.spring.dependency-management' version '1.1.4' apply false +} + +allprojects { + group = 'com.won.smarketing' + version = '1.0.0' + + repositories { + mavenCentral() + } +} + +subprojects { + apply plugin: 'java' + apply plugin: 'org.springframework.boot' + apply plugin: 'io.spring.dependency-management' + + java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } + } + + configurations { + compileOnly { + extendsFrom annotationProcessor + } + } + + dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + implementation 'com.mysql:mysql-connector-j' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + } + + tasks.named('test') { + useJUnitPlatform() + } +} diff --git a/common/build.gradle b/common/build.gradle index b1e8282..0129569 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -1,16 +1,3 @@ -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 = '' @@ -19,3 +6,27 @@ jar { bootJar { enabled = false } + +// member/build.gradle +dependencies { + implementation project(':common') + runtimeOnly 'com.mysql:mysql-connector-j' +} + +// store/build.gradle +dependencies { + implementation project(':common') + runtimeOnly 'com.mysql:mysql-connector-j' +} + +// marketing-content/build.gradle +dependencies { + implementation project(':common') + runtimeOnly 'com.mysql:mysql-connector-j' +} + +// ai-recommend/build.gradle +dependencies { + implementation project(':common') + runtimeOnly 'com.mysql:mysql-connector-j' +} \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..9bbc975c742b298b441bfb90dbc124400a3751b9 GIT binary patch literal 43705 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 DHx0+F literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37f853b --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000..faf9300 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/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 new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@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/settings.gradle b/settings.gradle index a398afa..54fbe0d 100644 --- a/settings.gradle +++ b/settings.gradle @@ -3,4 +3,4 @@ include 'common' include 'member' include 'store' include 'marketing-content' -include 'ai-recommend' +include 'ai-recommend' \ No newline at end of file From f6d4380dc7a536790e8e5c0dbec9cddab6ddb78c Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 11 Jun 2025 11:30:59 +0900 Subject: [PATCH 04/34] fix: build --- ai-recommend/build.gradle | 16 ++----------- build.gradle | 4 ++-- common/build.gradle | 41 +++++++++++++--------------------- marketing-content/build.gradle | 10 ++------- member/build.gradle | 4 ++-- store/build.gradle | 8 ++----- 6 files changed, 26 insertions(+), 57 deletions(-) diff --git a/ai-recommend/build.gradle b/ai-recommend/build.gradle index 34e83d1..771a2fc 100644 --- a/ai-recommend/build.gradle +++ b/ai-recommend/build.gradle @@ -1,16 +1,4 @@ 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" -} + runtimeOnly 'com.mysql:mysql-connector-j' +} \ No newline at end of file diff --git a/build.gradle b/build.gradle index ebeb413..f60005e 100644 --- a/build.gradle +++ b/build.gradle @@ -42,7 +42,7 @@ subprojects { implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' - implementation 'com.mysql:mysql-connector-j' + runtimeOnly 'com.mysql:mysql-connector-j' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' } @@ -50,4 +50,4 @@ subprojects { tasks.named('test') { useJUnitPlatform() } -} +} \ No newline at end of file diff --git a/common/build.gradle b/common/build.gradle index 0129569..b46abbb 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -1,32 +1,23 @@ +bootJar { + enabled = false +} + jar { enabled = true archiveClassifier = '' } -bootJar { - enabled = false -} - -// member/build.gradle +// 공통 의존성 재정의 (API 노출용) dependencies { - implementation project(':common') - runtimeOnly 'com.mysql:mysql-connector-j' -} - -// store/build.gradle -dependencies { - implementation project(':common') - runtimeOnly 'com.mysql:mysql-connector-j' -} - -// marketing-content/build.gradle -dependencies { - implementation project(':common') - runtimeOnly 'com.mysql:mysql-connector-j' -} - -// ai-recommend/build.gradle -dependencies { - implementation project(':common') - runtimeOnly 'com.mysql:mysql-connector-j' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' + implementation 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' } \ No newline at end of file diff --git a/marketing-content/build.gradle b/marketing-content/build.gradle index 4fab010..771a2fc 100644 --- a/marketing-content/build.gradle +++ b/marketing-content/build.gradle @@ -1,10 +1,4 @@ dependencies { implementation project(':common') - implementation 'com.mysql:mysql-connector-j' - // HTTP Client for external AI API - implementation 'org.springframework.boot:spring-boot-starter-webflux' -} - -bootJar { - archiveFileName = "marketing-content-service.jar" -} + runtimeOnly 'com.mysql:mysql-connector-j' +} \ No newline at end of file diff --git a/member/build.gradle b/member/build.gradle index 043e34a..771a2fc 100644 --- a/member/build.gradle +++ b/member/build.gradle @@ -1,4 +1,4 @@ dependencies { implementation project(':common') - implementation 'com.mysql:mysql-connector-j' -} + runtimeOnly 'com.mysql:mysql-connector-j' +} \ No newline at end of file diff --git a/store/build.gradle b/store/build.gradle index 909bd30..771a2fc 100644 --- a/store/build.gradle +++ b/store/build.gradle @@ -1,8 +1,4 @@ dependencies { implementation project(':common') - implementation 'com.mysql:mysql-connector-j' -} - -bootJar { - archiveFileName = "store-service.jar" -} + runtimeOnly 'com.mysql:mysql-connector-j' +} \ No newline at end of file From 38af15a3fd3fb75fed262d29d845b8a1dc131421 Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 11 Jun 2025 13:17:30 +0900 Subject: [PATCH 05/34] fix: build --- .../external/WeatherApiDataProvider.java | 2 +- .../controller/RecommendationController.java | 2 +- .../dto/MarketingTipResponse.java | 2 + build.gradle | 7 +- .../common/security/JwtTokenProvider.java | 60 +++--- .../smarketing/member/dto/LogoutRequest.java | 2 +- .../won/smarketing/member/entity/Member.java | 97 +++++++-- .../member/service/AuthServiceImpl.java | 189 ++++++++++++++++-- 8 files changed, 282 insertions(+), 79 deletions(-) diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/WeatherApiDataProvider.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/WeatherApiDataProvider.java index 3fcf9dd..4896c5a 100644 --- a/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/WeatherApiDataProvider.java +++ b/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/WeatherApiDataProvider.java @@ -38,7 +38,7 @@ public class WeatherApiDataProvider implements WeatherDataProvider { * @return 날씨 데이터 */ @Override - public WeatherData getCurrentWeather(String location) { + public WeatherApiResponse getCurrentWeather(String location) { try { log.debug("날씨 정보 조회 시작: location={}", location); 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 fedc727..e929efb 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 @@ -10,7 +10,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import javax.validation.Valid; +import jakarta.validation.Valid; /** * AI 마케팅 추천을 위한 REST API 컨트롤러 diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java index 26e2331..ca1ffe0 100644 --- a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java +++ b/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java @@ -2,6 +2,7 @@ package com.won.smarketing.recommend.presentation.dto; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @@ -14,6 +15,7 @@ import java.time.LocalDateTime; @Data @NoArgsConstructor @AllArgsConstructor +@Builder @Schema(description = "AI 마케팅 팁 생성 응답") public class MarketingTipResponse { diff --git a/build.gradle b/build.gradle index f60005e..976019c 100644 --- a/build.gradle +++ b/build.gradle @@ -18,12 +18,6 @@ subprojects { apply plugin: 'org.springframework.boot' apply plugin: 'io.spring.dependency-management' - java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } - } - configurations { compileOnly { extendsFrom annotationProcessor @@ -32,6 +26,7 @@ subprojects { dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-webflux' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' 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 bd3966b..d88bc8e 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 @@ -2,6 +2,7 @@ package com.won.smarketing.common.security; import io.jsonwebtoken.*; import io.jsonwebtoken.security.Keys; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -18,19 +19,26 @@ import java.util.Date; public class JwtTokenProvider { private final SecretKey secretKey; + /** + * -- GETTER -- + * 액세스 토큰 유효시간 반환 + * + * @return 액세스 토큰 유효시간 (밀리초) + */ + @Getter private final long accessTokenValidityTime; private final long refreshTokenValidityTime; /** * JWT 토큰 프로바이더 생성자 - * + * * @param secret JWT 서명에 사용할 비밀키 * @param accessTokenValidityTime 액세스 토큰 유효시간 (밀리초) * @param refreshTokenValidityTime 리프레시 토큰 유효시간 (밀리초) */ public JwtTokenProvider(@Value("${jwt.secret}") String secret, - @Value("${jwt.access-token-validity}") long accessTokenValidityTime, - @Value("${jwt.refresh-token-validity}") long refreshTokenValidityTime) { + @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; @@ -38,7 +46,7 @@ public class JwtTokenProvider { /** * 액세스 토큰 생성 - * + * * @param userId 사용자 ID * @return 생성된 액세스 토큰 */ @@ -47,16 +55,16 @@ public class JwtTokenProvider { Date expiryDate = new Date(now.getTime() + accessTokenValidityTime); return Jwts.builder() - .setSubject(userId) - .setIssuedAt(now) - .setExpiration(expiryDate) + .subject(userId) + .issuedAt(now) + .expiration(expiryDate) .signWith(secretKey) .compact(); } /** * 리프레시 토큰 생성 - * + * * @param userId 사용자 ID * @return 생성된 리프레시 토큰 */ @@ -65,41 +73,41 @@ public class JwtTokenProvider { Date expiryDate = new Date(now.getTime() + refreshTokenValidityTime); return Jwts.builder() - .setSubject(userId) - .setIssuedAt(now) - .setExpiration(expiryDate) + .subject(userId) + .issuedAt(now) + .expiration(expiryDate) .signWith(secretKey) .compact(); } /** * 토큰에서 사용자 ID 추출 - * + * * @param token JWT 토큰 * @return 사용자 ID */ public String getUserIdFromToken(String token) { - Claims claims = Jwts.parserBuilder() - .setSigningKey(secretKey) + Claims claims = Jwts.parser() + .verifyWith(secretKey) .build() - .parseClaimsJws(token) - .getBody(); - + .parseSignedClaims(token) + .getPayload(); + return claims.getSubject(); } /** * 토큰 유효성 검증 - * + * * @param token 검증할 토큰 * @return 유효성 여부 */ public boolean validateToken(String token) { try { - Jwts.parserBuilder() - .setSigningKey(secretKey) + Jwts.parser() + .verifyWith(secretKey) .build() - .parseClaimsJws(token); + .parseSignedClaims(token); return true; } catch (SecurityException ex) { log.error("Invalid JWT signature: {}", ex.getMessage()); @@ -115,12 +123,4 @@ public class JwtTokenProvider { return false; } - /** - * 액세스 토큰 유효시간 반환 - * - * @return 액세스 토큰 유효시간 (밀리초) - */ - public long getAccessTokenValidityTime() { - return accessTokenValidityTime; - } -} +} \ No newline at end of file diff --git a/member/src/main/java/com/won/smarketing/member/dto/LogoutRequest.java b/member/src/main/java/com/won/smarketing/member/dto/LogoutRequest.java index d53f388..99008bf 100644 --- a/member/src/main/java/com/won/smarketing/member/dto/LogoutRequest.java +++ b/member/src/main/java/com/won/smarketing/member/dto/LogoutRequest.java @@ -6,7 +6,7 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import javax.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotBlank; /** * 로그아웃 요청 DTO 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 55c5c7c..cb76902 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 @@ -4,26 +4,79 @@ import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; -import lombok.NoArgsConstruct -재시도 -Y -계속 -편집 -Member 서비스 모든 클래스 구현 -코드 ∙ 버전 2 - /** - * 사용자 ID로 회원 조회 - * - * @param userId 사용자 ID - * @return 회원 정보 (Optional) - */ - Optional findByUserId(String userId); - +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 = "members") +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EntityListeners(AuditingEntityListener.class) +public class Member { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_id") + private Long id; + + @Column(name = "user_id", nullable = false, unique = true, length = 50) + private String userId; + + @Column(name = "password", nullable = false, length = 100) + private String password; + + @Column(name = "name", nullable = false, length = 50) + private String name; + + @Column(name = "business_number", length = 12) + private String businessNumber; + + @Column(name = "email", nullable = false, unique = true, length = 100) + private String email; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; + /** - * 사용자 ID 존재 여부 확인 - * - * @param userId 사용자 ID - * @return 존재 여부 - -Member 인증 서비스 구현체 및 Controllers -코드 \ No newline at end of file + * 회원 정보 업데이트 + * + * @param name 이름 + * @param email 이메일 + * @param businessNumber 사업자번호 + */ + public void updateProfile(String name, String email, String businessNumber) { + if (name != null && !name.trim().isEmpty()) { + this.name = name; + } + if (email != null && !email.trim().isEmpty()) { + this.email = email; + } + if (businessNumber != null && !businessNumber.trim().isEmpty()) { + this.businessNumber = businessNumber; + } + } + + /** + * 패스워드 변경 + * + * @param encodedPassword 암호화된 패스워드 + */ + public void changePassword(String encodedPassword) { + this.password = encodedPassword; + } +} 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 d75a828..c01646f 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,21 +2,174 @@ package com.won.smarketing.member.service; import com.won.smarketing.common.exception.BusinessException; import com.won.smarketing.common.exception.ErrorCode; -import com. -재시도 -Y -계속 -편집 -Member 인증 서비스 구현체 및 Controllers -코드 ∙ 버전 2 - // 새로운 리프레시 토큰을 Redis에 저장 - redisTemplate.opsForValue().set( - REFRESH_TOKEN_PREFIX + userId, - newRefreshToken, - 7, - TimeUnit.DAYS - ); - - // 기존 리프레시 토큰을 -Store 서비스 Entity 및 DTO 클래스들 -코드 \ No newline at end of file +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 lombok.extern.slf4j.Slf4j; +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; + +/** + * 인증 서비스 구현체 + * 로그인, 로그아웃, 토큰 갱신 기능 구현 + */ +@Slf4j +@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:"; + private static final String BLACKLIST_PREFIX = "blacklist:"; + + /** + * 로그인 + * + * @param request 로그인 요청 정보 + * @return 로그인 응답 정보 (토큰 포함) + */ + @Override + @Transactional + public LoginResponse login(LoginRequest request) { + log.info("로그인 시도: {}", request.getUserId()); + + // 회원 조회 + 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); + } + + // 토큰 생성 + String accessToken = jwtTokenProvider.generateAccessToken(member.getUserId()); + String refreshToken = jwtTokenProvider.generateRefreshToken(member.getUserId()); + + // 리프레시 토큰을 Redis에 저장 (7일) + redisTemplate.opsForValue().set( + REFRESH_TOKEN_PREFIX + member.getUserId(), + refreshToken, + 7, + TimeUnit.DAYS + ); + + log.info("로그인 성공: {}", request.getUserId()); + + return LoginResponse.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .expiresIn(jwtTokenProvider.getAccessTokenValidityTime() / 1000) + .userInfo(LoginResponse.UserInfo.builder() + .userId(member.getUserId()) + .name(member.getName()) + .email(member.getEmail()) + .build()) + .build(); + } + + /** + * 로그아웃 + * + * @param refreshToken 리프레시 토큰 + */ + @Override + @Transactional + public void logout(String refreshToken) { + try { + if (jwtTokenProvider.validateToken(refreshToken)) { + String userId = jwtTokenProvider.getUserIdFromToken(refreshToken); + + // Redis에서 리프레시 토큰 삭제 + redisTemplate.delete(REFRESH_TOKEN_PREFIX + userId); + + // 리프레시 토큰을 블랙리스트에 추가 + redisTemplate.opsForValue().set( + BLACKLIST_PREFIX + refreshToken, + "logout", + 7, + TimeUnit.DAYS + ); + + log.info("로그아웃 완료: {}", userId); + } + } catch (Exception ex) { + log.warn("로그아웃 처리 중 오류 발생: {}", ex.getMessage()); + // 로그아웃은 실패해도 클라이언트에게는 성공으로 응답 + } + } + + /** + * 토큰 갱신 + * + * @param refreshToken 리프레시 토큰 + * @return 새로운 토큰 정보 + */ + @Override + @Transactional + public TokenResponse refresh(String refreshToken) { + // 토큰 유효성 검증 + if (!jwtTokenProvider.validateToken(refreshToken)) { + throw new BusinessException(ErrorCode.INVALID_TOKEN); + } + + // 블랙리스트 확인 + if (redisTemplate.hasKey(BLACKLIST_PREFIX + refreshToken)) { + throw new BusinessException(ErrorCode.INVALID_TOKEN); + } + + String userId = jwtTokenProvider.getUserIdFromToken(refreshToken); + + // Redis에 저장된 리프레시 토큰과 비교 + String storedRefreshToken = redisTemplate.opsForValue().get(REFRESH_TOKEN_PREFIX + userId); + if (!refreshToken.equals(storedRefreshToken)) { + throw new BusinessException(ErrorCode.INVALID_TOKEN); + } + + // 회원 존재 확인 + if (!memberRepository.existsByUserId(userId)) { + throw new BusinessException(ErrorCode.MEMBER_NOT_FOUND); + } + + // 새로운 토큰 생성 + String newAccessToken = jwtTokenProvider.generateAccessToken(userId); + String newRefreshToken = jwtTokenProvider.generateRefreshToken(userId); + + // 새로운 리프레시 토큰을 Redis에 저장 + redisTemplate.opsForValue().set( + REFRESH_TOKEN_PREFIX + userId, + newRefreshToken, + 7, + TimeUnit.DAYS + ); + + // 기존 리프레시 토큰을 블랙리스트에 추가 + redisTemplate.opsForValue().set( + BLACKLIST_PREFIX + refreshToken, + "refreshed", + 7, + TimeUnit.DAYS + ); + + log.info("토큰 갱신 완료: {}", userId); + + return TokenResponse.builder() + .accessToken(newAccessToken) + .refreshToken(newRefreshToken) + .expiresIn(jwtTokenProvider.getAccessTokenValidityTime() / 1000) + .build(); + } +} \ No newline at end of file From 9182933fe6281fd6dc277e1b01f62d6b11ffd97f Mon Sep 17 00:00:00 2001 From: seoeun Date: Wed, 11 Jun 2025 13:46:58 +0900 Subject: [PATCH 06/34] error fix --- build.gradle | 5 +- .../smarketing/member/config/RedisConfig.java | 53 ------------------- member/src/main/resources/application.yml | 10 ++-- 3 files changed, 9 insertions(+), 59 deletions(-) delete mode 100644 member/src/main/java/com/won/smarketing/member/config/RedisConfig.java diff --git a/build.gradle b/build.gradle index 976019c..598ea7d 100644 --- a/build.gradle +++ b/build.gradle @@ -37,7 +37,10 @@ subprojects { implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' - runtimeOnly 'com.mysql:mysql-connector-j' + + // PostgreSQL (운영용) + runtimeOnly 'org.postgresql:postgresql:42.7.1' + testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' } 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 deleted file mode 100644 index 045285b..0000000 --- a/member/src/main/java/com/won/smarketing/member/config/RedisConfig.java +++ /dev/null @@ -1,53 +0,0 @@ -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/resources/application.yml b/member/src/main/resources/application.yml index 83ad594..c93fb60 100644 --- a/member/src/main/resources/application.yml +++ b/member/src/main/resources/application.yml @@ -5,17 +5,17 @@ spring: application: name: member-service datasource: - 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 + url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:MemberDB} + username: ${POSTGRES_USER:postgres} + password: ${POSTGRES_PASSWORD:postgres} + driver-class-name: org.postgresql.Driver jpa: hibernate: ddl-auto: ${DDL_AUTO:update} show-sql: ${SHOW_SQL:true} properties: hibernate: - dialect: org.hibernate.dialect.MySQLDialect + dialect: org.hibernate.dialect.PostgreSQLDialect format_sql: true data: redis: From 9b2f8f5331dd3cb318d36486fefa22705a0110e6 Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 11 Jun 2025 14:06:26 +0900 Subject: [PATCH 07/34] fix: springboot version --- build.gradle | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index 598ea7d..40498ed 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ plugins { id 'java' - id 'org.springframework.boot' version '3.4.0' apply false - id 'io.spring.dependency-management' version '1.1.4' apply false + id 'org.springframework.boot' version '3.2.0' + id 'io.spring.dependency-management' version '1.1.4' } allprojects { @@ -10,7 +10,7 @@ allprojects { repositories { mavenCentral() - } + }cd } subprojects { From a86d2e47ced0d1d5fde8a19ce22bd84cc68a2cd5 Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 11 Jun 2025 14:06:45 +0900 Subject: [PATCH 08/34] fix: springboot version --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 40498ed..35646cd 100644 --- a/build.gradle +++ b/build.gradle @@ -10,7 +10,7 @@ allprojects { repositories { mavenCentral() - }cd + } } subprojects { From 7813f934b9fb302f75686c6fc8db5c173275c287 Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 11 Jun 2025 14:24:26 +0900 Subject: [PATCH 09/34] modify: folder - java/python --- .idea/.gitignore | 5 + .idea/gradle.xml | 11 + .idea/misc.xml | 4 + .idea/vcs.xml | 6 + .../content/domain/model/Content.java | 114 ---- .gitignore => smarketing-java/.gitignore | 0 .../ai-recommend}/build.gradle | 0 .../AIRecommendServiceApplication.java | 0 .../service/MarketingTipService.java | 0 .../usecase/MarketingTipUseCase.java | 0 .../recommend/domain/model/MarketingTip.java | 0 .../recommend/domain/model/StoreData.java | 0 .../recommend/domain/model/TipId.java | 0 .../recommend/domain/model/WeatherData.java | 0 .../repository/MarketingTipRepository.java | 0 .../domain/service/AiTipGenerator.java | 0 .../domain/service/StoreDataProvider.java | 0 .../domain/service/WeatherDataProvider.java | 0 .../external/ClaudeAiTipGenerator.java | 0 .../external/StoreApiDataProvider.java | 0 .../external/WeatherApiDataProvider.java | 0 .../controller/RecommendationController.java | 0 .../dto/DetailedMarketingTipResponse.java | 0 .../presentation/dto/ErrorResponseDto.java | 0 .../dto/MarketingTipGenerationRequest.java | 0 .../presentation/dto/MarketingTipRequest.java | 0 .../dto/MarketingTipResponse.java | 0 .../presentation/dto/StoreInfoDto.java | 0 .../presentation/dto/WeatherInfoDto.java | 0 .../src/main/resources/application.yml | 0 build.gradle => smarketing-java/build.gradle | 0 .../common}/build.gradle | 0 .../smarketing/common/config/RedisConfig.java | 0 .../common/config/SecurityConfig.java | 0 .../common/config/SwaggerConfig.java | 0 .../smarketing/common/dto/ApiResponse.java | 0 .../smarketing/common/dto/PageResponse.java | 0 .../common/exception/BusinessException.java | 0 .../common/exception/ErrorCode.java | 0 .../exception/GlobalExceptionHandler.java | 0 .../security/JwtAuthenticationFilter.java | 0 .../common/security/JwtTokenProvider.java | 0 .../gradle}/wrapper/gradle-wrapper.jar | Bin .../gradle}/wrapper/gradle-wrapper.properties | 0 gradlew => smarketing-java/gradlew | 0 gradlew.bat => smarketing-java/gradlew.bat | 0 .../marketing-content}/build.gradle | 0 .../MarketingContentServiceApplication.java | 0 .../service/ContentQueryService.java | 15 +- .../service/PosterContentService.java | 0 .../service/SnsContentService.java | 6 +- .../usecase/ContentQueryUseCase.java | 0 .../usecase/PosterContentUseCase.java | 0 .../usecase/SnsContentUseCase.java | 0 .../content/domain/model/Content.java | 611 ++++++++++++++++++ .../content/domain/model/ContentId.java | 0 .../content/domain/model/ContentStatus.java | 0 .../content/domain/model/ContentType.java | 0 .../domain/model/CreationConditions.java | 0 .../content/domain/model/Platform.java | 0 .../domain/repository/ContentRepository.java | 0 .../domain/service/AiContentGenerator.java | 0 .../domain/service/AiPosterGenerator.java | 0 .../controller/ContentController.java | 0 .../dto/ContentDetailResponse.java | 86 +++ .../presentation/dto/ContentListRequest.java | 37 ++ .../dto/ContentRegenerateRequest.java | 33 + .../presentation/dto/ContentResponse.java | 361 +++++++++++ .../dto/ContentStatisticsResponse.java | 41 ++ .../dto/ContentUpdateRequest.java | 33 + .../dto/ContentUpdateResponse.java | 35 + .../dto/OngoingContentResponse.java | 47 ++ .../dto/PosterContentCreateRequest.java | 51 ++ .../dto/PosterContentCreateResponse.java | 41 ++ .../dto/PosterContentSaveRequest.java | 33 + .../dto/SnsContentCreateResponse.java | 380 +++++++++++ .../dto/SnsContentSaveRequest.java | 35 + .../src/main/resources/application.yml | 0 .../member}/build.gradle | 0 .../member/MemberServiceApplication.java | 0 .../smarketing/member/config/JpaConfig.java | 0 .../member/controller/AuthController.java | 0 .../member/controller/MemberController.java | 0 .../member/dto/DuplicateCheckResponse.java | 0 .../smarketing/member/dto/LoginRequest.java | 0 .../smarketing/member/dto/LoginResponse.java | 0 .../smarketing/member/dto/LogoutRequest.java | 0 .../member/dto/PasswordValidationRequest.java | 0 .../member/dto/RegisterRequest.java | 0 .../member/dto/TokenRefreshRequest.java | 0 .../smarketing/member/dto/TokenResponse.java | 0 .../member/dto/ValidationResponse.java | 0 .../won/smarketing/member/entity/Member.java | 0 .../member/repository/MemberRepository.java | 0 .../member/service/AuthService.java | 0 .../member/service/AuthServiceImpl.java | 0 .../member/service/MemberService.java | 0 .../member/service/MemberServiceImpl.java | 0 .../src/main/resources/application.yml | 0 .../settings.gradle | 0 {store => smarketing-java/store}/build.gradle | 0 .../store/StoreServiceApplication.java | 0 .../smarketing/store/config/JpaConfig.java | 0 .../store/controller/MenuController.java | 0 .../store/controller/SalesController.java | 0 .../store/controller/StoreController.java | 0 .../store/dto/MenuCreateRequest.java | 0 .../smarketing/store/dto/MenuResponse.java | 0 .../store/dto/MenuUpdateRequest.java | 0 .../smarketing/store/dto/SalesResponse.java | 0 .../store/dto/StoreCreateRequest.java | 0 .../smarketing/store/dto/StoreResponse.java | 0 .../store/dto/StoreUpdateRequest.java | 0 .../com/won/smarketing/store/entity/Menu.java | 0 .../won/smarketing/store/entity/Sales.java | 0 .../won/smarketing/store/entity/Store.java | 0 .../store/repository/MenuRepository.java | 0 .../store/repository/SalesRepository.java | 0 .../store/repository/StoreRepository.java | 0 .../smarketing/store/service/MenuService.java | 0 .../store/service/MenuServiceImpl.java | 0 .../store/service/SalesService.java | 0 .../store/service/SalesServiceImpl.java | 0 .../store/service/StoreService.java | 0 .../store/service/StoreServiceImpl.java | 0 .../store}/src/main/resources/application.yml | 0 126 files changed, 1856 insertions(+), 129 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/gradle.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/vcs.xml delete mode 100644 marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java rename .gitignore => smarketing-java/.gitignore (100%) rename {ai-recommend => smarketing-java/ai-recommend}/build.gradle (100%) rename {ai-recommend => smarketing-java/ai-recommend}/src/main/java/com/won/smarketing/recommend/AIRecommendServiceApplication.java (100%) rename {ai-recommend => smarketing-java/ai-recommend}/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java (100%) rename {ai-recommend => smarketing-java/ai-recommend}/src/main/java/com/won/smarketing/recommend/application/usecase/MarketingTipUseCase.java (100%) rename {ai-recommend => smarketing-java/ai-recommend}/src/main/java/com/won/smarketing/recommend/domain/model/MarketingTip.java (100%) rename {ai-recommend => smarketing-java/ai-recommend}/src/main/java/com/won/smarketing/recommend/domain/model/StoreData.java (100%) rename {ai-recommend => smarketing-java/ai-recommend}/src/main/java/com/won/smarketing/recommend/domain/model/TipId.java (100%) rename {ai-recommend => smarketing-java/ai-recommend}/src/main/java/com/won/smarketing/recommend/domain/model/WeatherData.java (100%) rename {ai-recommend => smarketing-java/ai-recommend}/src/main/java/com/won/smarketing/recommend/domain/repository/MarketingTipRepository.java (100%) rename {ai-recommend => smarketing-java/ai-recommend}/src/main/java/com/won/smarketing/recommend/domain/service/AiTipGenerator.java (100%) rename {ai-recommend => smarketing-java/ai-recommend}/src/main/java/com/won/smarketing/recommend/domain/service/StoreDataProvider.java (100%) rename {ai-recommend => smarketing-java/ai-recommend}/src/main/java/com/won/smarketing/recommend/domain/service/WeatherDataProvider.java (100%) rename {ai-recommend => smarketing-java/ai-recommend}/src/main/java/com/won/smarketing/recommend/infrastructure/external/ClaudeAiTipGenerator.java (100%) rename {ai-recommend => smarketing-java/ai-recommend}/src/main/java/com/won/smarketing/recommend/infrastructure/external/StoreApiDataProvider.java (100%) rename {ai-recommend => smarketing-java/ai-recommend}/src/main/java/com/won/smarketing/recommend/infrastructure/external/WeatherApiDataProvider.java (100%) rename {ai-recommend => smarketing-java/ai-recommend}/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java (100%) rename {ai-recommend => smarketing-java/ai-recommend}/src/main/java/com/won/smarketing/recommend/presentation/dto/DetailedMarketingTipResponse.java (100%) rename {ai-recommend => smarketing-java/ai-recommend}/src/main/java/com/won/smarketing/recommend/presentation/dto/ErrorResponseDto.java (100%) rename {ai-recommend => smarketing-java/ai-recommend}/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipGenerationRequest.java (100%) rename {ai-recommend => smarketing-java/ai-recommend}/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipRequest.java (100%) rename {ai-recommend => smarketing-java/ai-recommend}/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java (100%) rename {ai-recommend => smarketing-java/ai-recommend}/src/main/java/com/won/smarketing/recommend/presentation/dto/StoreInfoDto.java (100%) rename {ai-recommend => smarketing-java/ai-recommend}/src/main/java/com/won/smarketing/recommend/presentation/dto/WeatherInfoDto.java (100%) rename {ai-recommend => smarketing-java/ai-recommend}/src/main/resources/application.yml (100%) rename build.gradle => smarketing-java/build.gradle (100%) rename {common => smarketing-java/common}/build.gradle (100%) rename {common => smarketing-java/common}/src/main/java/com/won/smarketing/common/config/RedisConfig.java (100%) rename {common => smarketing-java/common}/src/main/java/com/won/smarketing/common/config/SecurityConfig.java (100%) rename {common => smarketing-java/common}/src/main/java/com/won/smarketing/common/config/SwaggerConfig.java (100%) rename {common => smarketing-java/common}/src/main/java/com/won/smarketing/common/dto/ApiResponse.java (100%) rename {common => smarketing-java/common}/src/main/java/com/won/smarketing/common/dto/PageResponse.java (100%) rename {common => smarketing-java/common}/src/main/java/com/won/smarketing/common/exception/BusinessException.java (100%) rename {common => smarketing-java/common}/src/main/java/com/won/smarketing/common/exception/ErrorCode.java (100%) rename {common => smarketing-java/common}/src/main/java/com/won/smarketing/common/exception/GlobalExceptionHandler.java (100%) rename {common => smarketing-java/common}/src/main/java/com/won/smarketing/common/security/JwtAuthenticationFilter.java (100%) rename {common => smarketing-java/common}/src/main/java/com/won/smarketing/common/security/JwtTokenProvider.java (100%) rename {gradle => smarketing-java/gradle}/wrapper/gradle-wrapper.jar (100%) rename {gradle => smarketing-java/gradle}/wrapper/gradle-wrapper.properties (100%) rename gradlew => smarketing-java/gradlew (100%) rename gradlew.bat => smarketing-java/gradlew.bat (100%) rename {marketing-content => smarketing-java/marketing-content}/build.gradle (100%) rename {marketing-content => smarketing-java/marketing-content}/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java (100%) rename {marketing-content => smarketing-java/marketing-content}/src/main/java/com/won/smarketing/content/application/service/ContentQueryService.java (90%) rename {marketing-content => smarketing-java/marketing-content}/src/main/java/com/won/smarketing/content/application/service/PosterContentService.java (100%) rename {marketing-content => smarketing-java/marketing-content}/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java (97%) rename {marketing-content => smarketing-java/marketing-content}/src/main/java/com/won/smarketing/content/application/usecase/ContentQueryUseCase.java (100%) rename {marketing-content => smarketing-java/marketing-content}/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java (100%) rename {marketing-content => smarketing-java/marketing-content}/src/main/java/com/won/smarketing/content/application/usecase/SnsContentUseCase.java (100%) create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java rename {marketing-content => smarketing-java/marketing-content}/src/main/java/com/won/smarketing/content/domain/model/ContentId.java (100%) rename {marketing-content => smarketing-java/marketing-content}/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java (100%) rename {marketing-content => smarketing-java/marketing-content}/src/main/java/com/won/smarketing/content/domain/model/ContentType.java (100%) rename {marketing-content => smarketing-java/marketing-content}/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java (100%) rename {marketing-content => smarketing-java/marketing-content}/src/main/java/com/won/smarketing/content/domain/model/Platform.java (100%) rename {marketing-content => smarketing-java/marketing-content}/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java (100%) rename {marketing-content => smarketing-java/marketing-content}/src/main/java/com/won/smarketing/content/domain/service/AiContentGenerator.java (100%) rename {marketing-content => smarketing-java/marketing-content}/src/main/java/com/won/smarketing/content/domain/service/AiPosterGenerator.java (100%) rename {marketing-content => smarketing-java/marketing-content}/src/main/java/com/won/smarketing/content/presentation/controller/ContentController.java (100%) create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentDetailResponse.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentListRequest.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentRegenerateRequest.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentResponse.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentStatisticsResponse.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentUpdateRequest.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentUpdateResponse.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/OngoingContentResponse.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateRequest.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateResponse.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentSaveRequest.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateResponse.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentSaveRequest.java rename {marketing-content => smarketing-java/marketing-content}/src/main/resources/application.yml (100%) rename {member => smarketing-java/member}/build.gradle (100%) rename {member => smarketing-java/member}/src/main/java/com/won/smarketing/member/MemberServiceApplication.java (100%) rename {member => smarketing-java/member}/src/main/java/com/won/smarketing/member/config/JpaConfig.java (100%) rename {member => smarketing-java/member}/src/main/java/com/won/smarketing/member/controller/AuthController.java (100%) rename {member => smarketing-java/member}/src/main/java/com/won/smarketing/member/controller/MemberController.java (100%) rename {member => smarketing-java/member}/src/main/java/com/won/smarketing/member/dto/DuplicateCheckResponse.java (100%) rename {member => smarketing-java/member}/src/main/java/com/won/smarketing/member/dto/LoginRequest.java (100%) rename {member => smarketing-java/member}/src/main/java/com/won/smarketing/member/dto/LoginResponse.java (100%) rename {member => smarketing-java/member}/src/main/java/com/won/smarketing/member/dto/LogoutRequest.java (100%) rename {member => smarketing-java/member}/src/main/java/com/won/smarketing/member/dto/PasswordValidationRequest.java (100%) rename {member => smarketing-java/member}/src/main/java/com/won/smarketing/member/dto/RegisterRequest.java (100%) rename {member => smarketing-java/member}/src/main/java/com/won/smarketing/member/dto/TokenRefreshRequest.java (100%) rename {member => smarketing-java/member}/src/main/java/com/won/smarketing/member/dto/TokenResponse.java (100%) rename {member => smarketing-java/member}/src/main/java/com/won/smarketing/member/dto/ValidationResponse.java (100%) rename {member => smarketing-java/member}/src/main/java/com/won/smarketing/member/entity/Member.java (100%) rename {member => smarketing-java/member}/src/main/java/com/won/smarketing/member/repository/MemberRepository.java (100%) rename {member => smarketing-java/member}/src/main/java/com/won/smarketing/member/service/AuthService.java (100%) rename {member => smarketing-java/member}/src/main/java/com/won/smarketing/member/service/AuthServiceImpl.java (100%) rename {member => smarketing-java/member}/src/main/java/com/won/smarketing/member/service/MemberService.java (100%) rename {member => smarketing-java/member}/src/main/java/com/won/smarketing/member/service/MemberServiceImpl.java (100%) rename {member => smarketing-java/member}/src/main/resources/application.yml (100%) rename settings.gradle => smarketing-java/settings.gradle (100%) rename {store => smarketing-java/store}/build.gradle (100%) rename {store => smarketing-java/store}/src/main/java/com/won/smarketing/store/StoreServiceApplication.java (100%) rename {store => smarketing-java/store}/src/main/java/com/won/smarketing/store/config/JpaConfig.java (100%) rename {store => smarketing-java/store}/src/main/java/com/won/smarketing/store/controller/MenuController.java (100%) rename {store => smarketing-java/store}/src/main/java/com/won/smarketing/store/controller/SalesController.java (100%) rename {store => smarketing-java/store}/src/main/java/com/won/smarketing/store/controller/StoreController.java (100%) rename {store => smarketing-java/store}/src/main/java/com/won/smarketing/store/dto/MenuCreateRequest.java (100%) rename {store => smarketing-java/store}/src/main/java/com/won/smarketing/store/dto/MenuResponse.java (100%) rename {store => smarketing-java/store}/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java (100%) rename {store => smarketing-java/store}/src/main/java/com/won/smarketing/store/dto/SalesResponse.java (100%) rename {store => smarketing-java/store}/src/main/java/com/won/smarketing/store/dto/StoreCreateRequest.java (100%) rename {store => smarketing-java/store}/src/main/java/com/won/smarketing/store/dto/StoreResponse.java (100%) rename {store => smarketing-java/store}/src/main/java/com/won/smarketing/store/dto/StoreUpdateRequest.java (100%) rename {store => smarketing-java/store}/src/main/java/com/won/smarketing/store/entity/Menu.java (100%) rename {store => smarketing-java/store}/src/main/java/com/won/smarketing/store/entity/Sales.java (100%) rename {store => smarketing-java/store}/src/main/java/com/won/smarketing/store/entity/Store.java (100%) rename {store => smarketing-java/store}/src/main/java/com/won/smarketing/store/repository/MenuRepository.java (100%) rename {store => smarketing-java/store}/src/main/java/com/won/smarketing/store/repository/SalesRepository.java (100%) rename {store => smarketing-java/store}/src/main/java/com/won/smarketing/store/repository/StoreRepository.java (100%) rename {store => smarketing-java/store}/src/main/java/com/won/smarketing/store/service/MenuService.java (100%) rename {store => smarketing-java/store}/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java (100%) rename {store => smarketing-java/store}/src/main/java/com/won/smarketing/store/service/SalesService.java (100%) rename {store => smarketing-java/store}/src/main/java/com/won/smarketing/store/service/SalesServiceImpl.java (100%) rename {store => smarketing-java/store}/src/main/java/com/won/smarketing/store/service/StoreService.java (100%) rename {store => smarketing-java/store}/src/main/java/com/won/smarketing/store/service/StoreServiceImpl.java (100%) rename {store => smarketing-java/store}/src/main/resources/application.yml (100%) diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..b7b3d1b --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# 디폴트 무시된 파일 +/shelf/ +/workspace.xml +# 환경에 따라 달라지는 Maven 홈 디렉터리 +/mavenHomeManager.xml diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..9018a0d --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..6ed36dd --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java b/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java deleted file mode 100644 index 6eee928..0000000 --- a/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.won.smarketing.content.domain.model; - -import lombok.*; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.List; - -/** - * 마케팅 콘텐츠 도메인 모델 - * 콘텐츠의 핵심 비즈니스 로직과 상태를 관리 - */ -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@Builder -public class Content { - - /** - * 콘텐츠 고유 식별자 - */ - private ContentId id; - - /** - * 콘텐츠 타입 (SNS 게시물, 포스터 등) - */ - private ContentType contentType; - - /** - * 플랫폼 (인스타그램, 네이버 블로그 등) - */ - private Platform platform; - - /** - * 콘텐츠 제목 - */ - private String title; - - /** - * 콘텐츠 내용 - */ - private String content; - - /** - * 해시태그 목록 - */ - private List hashtags; - - /** - * 이미지 URL 목록 - */ - private List images; - - /** - * 콘텐츠 상태 - */ - private ContentStatus status; - - /** - * 콘텐츠 생성 조건 - */ - private CreationConditions creationConditions; - - /** - * 매장 ID - */ - private Long storeId; - - /** - * 생성 시각 - */ - private LocalDateTime createdAt; - - /** - * 수정 시각 - */ - private LocalDateTime updatedAt; - - /** - * 콘텐츠 제목 업데이트 - * - * @param title 새로운 제목 - */ - public void updateTitle(String title) { - this.title = title; - this.updatedAt = LocalDateTime.now(); - } - - /** - * 콘텐츠 기간 업데이트 - * - * @param startDate 시작일 - * @param endDate 종료일 - */ - public void updatePeriod(LocalDate startDate, LocalDate endDate) { - if (this.creationConditions != null) { - this.creationConditions = this.creationConditions.toBuilder() - .startDate(startDate) - .endDate(endDate) - .build(); - } - this.updatedAt = LocalDateTime.now(); - } - - /** - * 콘텐츠 상태 변경 - * - * @param status 새로운 상태 - */ - public void changeStatus(ContentStatus status) { - this.status = status; - this.updatedAt = LocalDateTime.now(); - } -} diff --git a/.gitignore b/smarketing-java/.gitignore similarity index 100% rename from .gitignore rename to smarketing-java/.gitignore diff --git a/ai-recommend/build.gradle b/smarketing-java/ai-recommend/build.gradle similarity index 100% rename from ai-recommend/build.gradle rename to smarketing-java/ai-recommend/build.gradle diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/AIRecommendServiceApplication.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/AIRecommendServiceApplication.java similarity index 100% rename from ai-recommend/src/main/java/com/won/smarketing/recommend/AIRecommendServiceApplication.java rename to smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/AIRecommendServiceApplication.java diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java similarity index 100% rename from ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java rename to smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/application/usecase/MarketingTipUseCase.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/usecase/MarketingTipUseCase.java similarity index 100% rename from ai-recommend/src/main/java/com/won/smarketing/recommend/application/usecase/MarketingTipUseCase.java rename to smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/usecase/MarketingTipUseCase.java diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MarketingTip.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MarketingTip.java similarity index 100% rename from ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MarketingTip.java rename to smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MarketingTip.java diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreData.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreData.java similarity index 100% rename from ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreData.java rename to smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreData.java diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/TipId.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/TipId.java similarity index 100% rename from ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/TipId.java rename to smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/TipId.java diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/WeatherData.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/WeatherData.java similarity index 100% rename from ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/WeatherData.java rename to smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/WeatherData.java diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/MarketingTipRepository.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/MarketingTipRepository.java similarity index 100% rename from ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/MarketingTipRepository.java rename to smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/MarketingTipRepository.java diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/AiTipGenerator.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/AiTipGenerator.java similarity index 100% rename from ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/AiTipGenerator.java rename to smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/AiTipGenerator.java diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/StoreDataProvider.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/StoreDataProvider.java similarity index 100% rename from ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/StoreDataProvider.java rename to smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/StoreDataProvider.java diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/WeatherDataProvider.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/WeatherDataProvider.java similarity index 100% rename from ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/WeatherDataProvider.java rename to smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/WeatherDataProvider.java diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/ClaudeAiTipGenerator.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/ClaudeAiTipGenerator.java similarity index 100% rename from ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/ClaudeAiTipGenerator.java rename to smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/ClaudeAiTipGenerator.java diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/StoreApiDataProvider.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/StoreApiDataProvider.java similarity index 100% rename from ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/StoreApiDataProvider.java rename to smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/StoreApiDataProvider.java diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/WeatherApiDataProvider.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/WeatherApiDataProvider.java similarity index 100% rename from ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/WeatherApiDataProvider.java rename to smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/WeatherApiDataProvider.java diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java similarity index 100% rename from ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java rename to smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/DetailedMarketingTipResponse.java b/smarketing-java/ai-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 smarketing-java/ai-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/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/ErrorResponseDto.java similarity index 100% rename from ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/ErrorResponseDto.java rename to smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/ErrorResponseDto.java diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipGenerationRequest.java b/smarketing-java/ai-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 smarketing-java/ai-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/smarketing-java/ai-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 smarketing-java/ai-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/smarketing-java/ai-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 smarketing-java/ai-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/smarketing-java/ai-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 smarketing-java/ai-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/smarketing-java/ai-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 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/WeatherInfoDto.java diff --git a/ai-recommend/src/main/resources/application.yml b/smarketing-java/ai-recommend/src/main/resources/application.yml similarity index 100% rename from ai-recommend/src/main/resources/application.yml rename to smarketing-java/ai-recommend/src/main/resources/application.yml diff --git a/build.gradle b/smarketing-java/build.gradle similarity index 100% rename from build.gradle rename to smarketing-java/build.gradle diff --git a/common/build.gradle b/smarketing-java/common/build.gradle similarity index 100% rename from common/build.gradle rename to smarketing-java/common/build.gradle diff --git a/common/src/main/java/com/won/smarketing/common/config/RedisConfig.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/config/RedisConfig.java similarity index 100% rename from common/src/main/java/com/won/smarketing/common/config/RedisConfig.java rename to smarketing-java/common/src/main/java/com/won/smarketing/common/config/RedisConfig.java diff --git a/common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java similarity index 100% rename from common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java rename to smarketing-java/common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java diff --git a/common/src/main/java/com/won/smarketing/common/config/SwaggerConfig.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/config/SwaggerConfig.java similarity index 100% rename from common/src/main/java/com/won/smarketing/common/config/SwaggerConfig.java rename to smarketing-java/common/src/main/java/com/won/smarketing/common/config/SwaggerConfig.java diff --git a/common/src/main/java/com/won/smarketing/common/dto/ApiResponse.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/dto/ApiResponse.java similarity index 100% rename from common/src/main/java/com/won/smarketing/common/dto/ApiResponse.java rename to smarketing-java/common/src/main/java/com/won/smarketing/common/dto/ApiResponse.java diff --git a/common/src/main/java/com/won/smarketing/common/dto/PageResponse.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/dto/PageResponse.java similarity index 100% rename from common/src/main/java/com/won/smarketing/common/dto/PageResponse.java rename to smarketing-java/common/src/main/java/com/won/smarketing/common/dto/PageResponse.java diff --git a/common/src/main/java/com/won/smarketing/common/exception/BusinessException.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/exception/BusinessException.java similarity index 100% rename from common/src/main/java/com/won/smarketing/common/exception/BusinessException.java rename to smarketing-java/common/src/main/java/com/won/smarketing/common/exception/BusinessException.java diff --git a/common/src/main/java/com/won/smarketing/common/exception/ErrorCode.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/exception/ErrorCode.java similarity index 100% rename from common/src/main/java/com/won/smarketing/common/exception/ErrorCode.java rename to smarketing-java/common/src/main/java/com/won/smarketing/common/exception/ErrorCode.java diff --git a/common/src/main/java/com/won/smarketing/common/exception/GlobalExceptionHandler.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/exception/GlobalExceptionHandler.java similarity index 100% rename from common/src/main/java/com/won/smarketing/common/exception/GlobalExceptionHandler.java rename to smarketing-java/common/src/main/java/com/won/smarketing/common/exception/GlobalExceptionHandler.java diff --git a/common/src/main/java/com/won/smarketing/common/security/JwtAuthenticationFilter.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/security/JwtAuthenticationFilter.java similarity index 100% rename from common/src/main/java/com/won/smarketing/common/security/JwtAuthenticationFilter.java rename to smarketing-java/common/src/main/java/com/won/smarketing/common/security/JwtAuthenticationFilter.java diff --git a/common/src/main/java/com/won/smarketing/common/security/JwtTokenProvider.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/security/JwtTokenProvider.java similarity index 100% rename from common/src/main/java/com/won/smarketing/common/security/JwtTokenProvider.java rename to smarketing-java/common/src/main/java/com/won/smarketing/common/security/JwtTokenProvider.java diff --git a/gradle/wrapper/gradle-wrapper.jar b/smarketing-java/gradle/wrapper/gradle-wrapper.jar similarity index 100% rename from gradle/wrapper/gradle-wrapper.jar rename to smarketing-java/gradle/wrapper/gradle-wrapper.jar diff --git a/gradle/wrapper/gradle-wrapper.properties b/smarketing-java/gradle/wrapper/gradle-wrapper.properties similarity index 100% rename from gradle/wrapper/gradle-wrapper.properties rename to smarketing-java/gradle/wrapper/gradle-wrapper.properties diff --git a/gradlew b/smarketing-java/gradlew similarity index 100% rename from gradlew rename to smarketing-java/gradlew diff --git a/gradlew.bat b/smarketing-java/gradlew.bat similarity index 100% rename from gradlew.bat rename to smarketing-java/gradlew.bat diff --git a/marketing-content/build.gradle b/smarketing-java/marketing-content/build.gradle similarity index 100% rename from marketing-content/build.gradle rename to smarketing-java/marketing-content/build.gradle diff --git a/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java similarity index 100% rename from marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java rename to smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java diff --git a/marketing-content/src/main/java/com/won/smarketing/content/application/service/ContentQueryService.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/ContentQueryService.java similarity index 90% rename from marketing-content/src/main/java/com/won/smarketing/content/application/service/ContentQueryService.java rename to smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/ContentQueryService.java index eb868eb..a84e0a5 100644 --- a/marketing-content/src/main/java/com/won/smarketing/content/application/service/ContentQueryService.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/ContentQueryService.java @@ -3,11 +3,7 @@ package com.won.smarketing.content.application.service; import com.won.smarketing.common.exception.BusinessException; import com.won.smarketing.common.exception.ErrorCode; import com.won.smarketing.content.application.usecase.ContentQueryUseCase; -import com.won.smarketing.content.domain.model.Content; -import com.won.smarketing.content.domain.model.ContentId; -import com.won.smarketing.content.domain.model.ContentStatus; -import com.won.smarketing.content.domain.model.ContentType; -import com.won.smarketing.content.domain.model.Platform; +import com.won.smarketing.content.domain.model.*; import com.won.smarketing.content.domain.repository.ContentRepository; import com.won.smarketing.content.presentation.dto.*; import lombok.RequiredArgsConstructor; @@ -181,20 +177,15 @@ public class ContentQueryService implements ContentQueryUseCase { * @param conditions CreationConditions 도메인 객체 * @return CreationConditionsDto */ - private CreationConditionsDto toCreationConditionsDto(CreationConditions conditions) { + private ContentDetailResponse.CreationConditionsDto toCreationConditionsDto(CreationConditions conditions) { if (conditions == null) { return null; } - return CreationConditionsDto.builder() - .category(conditions.getCategory()) - .requirement(conditions.getRequirement()) + return ContentDetailResponse.CreationConditionsDto.builder() .toneAndManner(conditions.getToneAndManner()) .emotionIntensity(conditions.getEmotionIntensity()) .eventName(conditions.getEventName()) - .startDate(conditions.getStartDate()) - .endDate(conditions.getEndDate()) - .photoStyle(conditions.getPhotoStyle()) .build(); } } diff --git a/marketing-content/src/main/java/com/won/smarketing/content/application/service/PosterContentService.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/PosterContentService.java similarity index 100% rename from marketing-content/src/main/java/com/won/smarketing/content/application/service/PosterContentService.java rename to smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/PosterContentService.java diff --git a/marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java similarity index 97% rename from marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java rename to smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java index 508fbe5..444be06 100644 --- a/marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java @@ -33,13 +33,13 @@ public class SnsContentService implements SnsContentUseCase { /** * SNS 콘텐츠 생성 - * + * * @param request SNS 콘텐츠 생성 요청 * @return 생성된 SNS 콘텐츠 정보 */ @Override @Transactional - public SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request) { +/* public SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request) { // AI를 사용하여 SNS 콘텐츠 생성 String generatedContent = aiContentGenerator.generateSnsContent(request); @@ -84,7 +84,7 @@ public class SnsContentService implements SnsContentUseCase { .status(content.getStatus().name()) .createdAt(content.getCreatedAt()) .build(); - } + }*/ /** * SNS 콘텐츠 저장 diff --git a/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/ContentQueryUseCase.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/ContentQueryUseCase.java similarity index 100% rename from marketing-content/src/main/java/com/won/smarketing/content/application/usecase/ContentQueryUseCase.java rename to smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/ContentQueryUseCase.java diff --git a/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java similarity index 100% rename from marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java rename to smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java diff --git a/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/SnsContentUseCase.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/SnsContentUseCase.java similarity index 100% rename from marketing-content/src/main/java/com/won/smarketing/content/application/usecase/SnsContentUseCase.java rename to smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/SnsContentUseCase.java diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java new file mode 100644 index 0000000..440fd77 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java @@ -0,0 +1,611 @@ +// marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java +package com.won.smarketing.content.domain.model; + +import jakarta.persistence.*; +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.util.ArrayList; +import java.util.List; + +/** + * 콘텐츠 도메인 모델 + * + * 이 클래스는 마케팅 콘텐츠의 핵심 정보와 비즈니스 로직을 포함하는 + * DDD(Domain-Driven Design) 엔티티입니다. + * + * Clean Architecture의 Domain Layer에 위치하며, + * 비즈니스 규칙과 도메인 로직을 캡슐화합니다. + */ +@Entity +@Table( + name = "contents", + indexes = { + @Index(name = "idx_store_id", columnList = "store_id"), + @Index(name = "idx_content_type", columnList = "content_type"), + @Index(name = "idx_platform", columnList = "platform"), + @Index(name = "idx_status", columnList = "status"), + @Index(name = "idx_promotion_dates", columnList = "promotion_start_date, promotion_end_date"), + @Index(name = "idx_created_at", columnList = "created_at") + } +) +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EntityListeners(AuditingEntityListener.class) +public class Content { + + // ==================== 기본키 및 식별자 ==================== + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "content_id") + private Long id; + + // ==================== 콘텐츠 분류 ==================== + + @Enumerated(EnumType.STRING) + @Column(name = "content_type", nullable = false, length = 20) + private ContentType contentType; + + @Enumerated(EnumType.STRING) + @Column(name = "platform", nullable = false, length = 20) + private Platform platform; + + // ==================== 콘텐츠 내용 ==================== + + @Column(name = "title", nullable = false, length = 200) + private String title; + + @Column(name = "content", nullable = false, columnDefinition = "TEXT") + private String content; + + // ==================== 멀티미디어 및 메타데이터 ==================== + + @ElementCollection(fetch = FetchType.LAZY) + @CollectionTable( + name = "content_hashtags", + joinColumns = @JoinColumn(name = "content_id"), + indexes = @Index(name = "idx_content_hashtags", columnList = "content_id") + ) + @Column(name = "hashtag", length = 100) + @Builder.Default + private List hashtags = new ArrayList<>(); + + @ElementCollection(fetch = FetchType.LAZY) + @CollectionTable( + name = "content_images", + joinColumns = @JoinColumn(name = "content_id"), + indexes = @Index(name = "idx_content_images", columnList = "content_id") + ) + @Column(name = "image_url", length = 500) + @Builder.Default + private List images = new ArrayList<>(); + + // ==================== 상태 관리 ==================== + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + @Builder.Default + private ContentStatus status = ContentStatus.DRAFT; + + // ==================== AI 생성 조건 (Embedded) ==================== + + @Embedded + @AttributeOverrides({ + @AttributeOverride(name = "toneAndManner", column = @Column(name = "tone_and_manner", length = 50)), + @AttributeOverride(name = "promotionType", column = @Column(name = "promotion_type", length = 50)), + @AttributeOverride(name = "emotionIntensity", column = @Column(name = "emotion_intensity", length = 50)), + @AttributeOverride(name = "targetAudience", column = @Column(name = "target_audience", length = 50)), + @AttributeOverride(name = "eventName", column = @Column(name = "event_name", length = 100)) + }) + private CreationConditions creationConditions; + + // ==================== 비즈니스 관계 ==================== + + @Column(name = "store_id", nullable = false) + private Long storeId; + + // ==================== 홍보 기간 ==================== + + @Column(name = "promotion_start_date") + private LocalDateTime promotionStartDate; + + @Column(name = "promotion_end_date") + private LocalDateTime promotionEndDate; + + // ==================== 감사(Audit) 정보 ==================== + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + // ==================== 비즈니스 로직 메서드 ==================== + + /** + * 콘텐츠 제목 수정 + * + * 비즈니스 규칙: + * - 제목은 null이거나 빈 값일 수 없음 + * - 200자를 초과할 수 없음 + * - 발행된 콘텐츠는 제목 변경 시 상태가 DRAFT로 변경됨 + * + * @param title 새로운 제목 + * @throws IllegalArgumentException 제목이 유효하지 않은 경우 + */ + public void updateTitle(String title) { + validateTitle(title); + + boolean wasPublished = isPublished(); + this.title = title.trim(); + + // 발행된 콘텐츠의 제목이 변경되면 재검토 필요 + if (wasPublished) { + this.status = ContentStatus.DRAFT; + } + } + + /** + * 콘텐츠 내용 수정 + * + * 비즈니스 규칙: + * - 내용은 null이거나 빈 값일 수 없음 + * - 발행된 콘텐츠는 내용 변경 시 상태가 DRAFT로 변경됨 + * + * @param content 새로운 콘텐츠 내용 + * @throws IllegalArgumentException 내용이 유효하지 않은 경우 + */ + public void updateContent(String content) { + validateContent(content); + + boolean wasPublished = isPublished(); + this.content = content.trim(); + + // 발행된 콘텐츠의 내용이 변경되면 재검토 필요 + if (wasPublished) { + this.status = ContentStatus.DRAFT; + } + } + + /** + * 콘텐츠 상태 변경 + * + * 비즈니스 규칙: + * - PUBLISHED 상태로 변경시 유효성 검증 수행 + * - ARCHIVED 상태에서는 PUBLISHED로만 변경 가능 + * + * @param status 새로운 상태 + * @throws IllegalStateException 잘못된 상태 전환인 경우 + */ + public void changeStatus(ContentStatus status) { + validateStatusTransition(this.status, status); + + if (status == ContentStatus.PUBLISHED) { + validateForPublication(); + } + + this.status = status; + } + + /** + * 홍보 기간 설정 + * + * 비즈니스 규칙: + * - 시작일은 종료일보다 이전이어야 함 + * - 과거 날짜로 설정 불가 (현재 시간 기준) + * + * @param startDate 홍보 시작일 + * @param endDate 홍보 종료일 + * @throws IllegalArgumentException 날짜가 유효하지 않은 경우 + */ + public void setPromotionPeriod(LocalDateTime startDate, LocalDateTime endDate) { + validatePromotionPeriod(startDate, endDate); + + this.promotionStartDate = startDate; + this.promotionEndDate = endDate; + } + + /** + * 해시태그 추가 + * + * @param hashtag 추가할 해시태그 (# 없이) + */ + public void addHashtag(String hashtag) { + if (hashtag != null && !hashtag.trim().isEmpty()) { + String cleanHashtag = hashtag.trim().replace("#", ""); + if (!this.hashtags.contains(cleanHashtag)) { + this.hashtags.add(cleanHashtag); + } + } + } + + /** + * 해시태그 제거 + * + * @param hashtag 제거할 해시태그 + */ + public void removeHashtag(String hashtag) { + if (hashtag != null) { + String cleanHashtag = hashtag.trim().replace("#", ""); + this.hashtags.remove(cleanHashtag); + } + } + + /** + * 이미지 추가 + * + * @param imageUrl 이미지 URL + */ + public void addImage(String imageUrl) { + if (imageUrl != null && !imageUrl.trim().isEmpty()) { + if (!this.images.contains(imageUrl.trim())) { + this.images.add(imageUrl.trim()); + } + } + } + + /** + * 이미지 제거 + * + * @param imageUrl 제거할 이미지 URL + */ + public void removeImage(String imageUrl) { + if (imageUrl != null) { + this.images.remove(imageUrl.trim()); + } + } + + // ==================== 도메인 조회 메서드 ==================== + + /** + * 발행 상태 확인 + * + * @return 발행된 상태이면 true + */ + public boolean isPublished() { + return this.status == ContentStatus.PUBLISHED; + } + + /** + * 수정 가능 상태 확인 + * + * @return 임시저장 또는 예약발행 상태이면 true + */ + public boolean isEditable() { + return this.status == ContentStatus.DRAFT || this.status == ContentStatus.PUBLISHED; + } + + /** + * 현재 홍보 진행 중인지 확인 + * + * @return 홍보 기간 내이고 발행 상태이면 true + */ + public boolean isOngoingPromotion() { + if (!isPublished() || promotionStartDate == null || promotionEndDate == null) { + return false; + } + + LocalDateTime now = LocalDateTime.now(); + return now.isAfter(promotionStartDate) && now.isBefore(promotionEndDate); + } + + /** + * 홍보 예정 상태 확인 + * + * @return 홍보 시작 전이면 true + */ + public boolean isUpcomingPromotion() { + if (promotionStartDate == null) { + return false; + } + + return LocalDateTime.now().isBefore(promotionStartDate); + } + + /** + * 홍보 완료 상태 확인 + * + * @return 홍보 종료 후이면 true + */ + public boolean isCompletedPromotion() { + if (promotionEndDate == null) { + return false; + } + + return LocalDateTime.now().isAfter(promotionEndDate); + } + + /** + * SNS 콘텐츠 여부 확인 + * + * @return SNS 게시물이면 true + */ + public boolean isSnsContent() { + return this.contentType == ContentType.SNS_POST; + } + + /** + * 포스터 콘텐츠 여부 확인 + * + * @return 포스터이면 true + */ + public boolean isPosterContent() { + return this.contentType == ContentType.POSTER; + } + + /** + * 이미지가 있는 콘텐츠인지 확인 + * + * @return 이미지가 1개 이상 있으면 true + */ + public boolean hasImages() { + return this.images != null && !this.images.isEmpty(); + } + + /** + * 해시태그가 있는 콘텐츠인지 확인 + * + * @return 해시태그가 1개 이상 있으면 true + */ + public boolean hasHashtags() { + return this.hashtags != null && !this.hashtags.isEmpty(); + } + + // ==================== 유효성 검증 메서드 ==================== + + /** + * 제목 유효성 검증 + */ + private void validateTitle(String title) { + if (title == null || title.trim().isEmpty()) { + throw new IllegalArgumentException("제목은 필수 입력 사항입니다."); + } + if (title.trim().length() > 200) { + throw new IllegalArgumentException("제목은 200자를 초과할 수 없습니다."); + } + } + + /** + * 내용 유효성 검증 + */ + private void validateContent(String content) { + if (content == null || content.trim().isEmpty()) { + throw new IllegalArgumentException("콘텐츠 내용은 필수 입력 사항입니다."); + } + } + + /** + * 홍보 기간 유효성 검증 + */ + private void validatePromotionPeriod(LocalDateTime startDate, LocalDateTime endDate) { + if (startDate == null || endDate == null) { + throw new IllegalArgumentException("홍보 시작일과 종료일은 필수 입력 사항입니다."); + } + if (startDate.isAfter(endDate)) { + throw new IllegalArgumentException("홍보 시작일은 종료일보다 이전이어야 합니다."); + } + if (endDate.isBefore(LocalDateTime.now())) { + throw new IllegalArgumentException("홍보 종료일은 현재 시간 이후여야 합니다."); + } + } + + /** + * 상태 전환 유효성 검증 + */ + private void validateStatusTransition(ContentStatus from, ContentStatus to) { + if (from == ContentStatus.ARCHIVED && to != ContentStatus.PUBLISHED) { + throw new IllegalStateException("보관된 콘텐츠는 발행 상태로만 변경할 수 있습니다."); + } + } + + /** + * 발행을 위한 유효성 검증 + */ + private void validateForPublication() { + validateTitle(this.title); + validateContent(this.content); + + if (this.promotionStartDate == null || this.promotionEndDate == null) { + throw new IllegalStateException("발행하려면 홍보 기간을 설정해야 합니다."); + } + + if (this.contentType == ContentType.POSTER && !hasImages()) { + throw new IllegalStateException("포스터 콘텐츠는 이미지가 필수입니다."); + } + } + + // ==================== 비즈니스 계산 메서드 ==================== + + /** + * 홍보 진행률 계산 (0-100%) + * + * @return 진행률 + */ + public double calculateProgress() { + if (promotionStartDate == null || promotionEndDate == null) { + return 0.0; + } + + LocalDateTime now = LocalDateTime.now(); + + if (now.isBefore(promotionStartDate)) { + return 0.0; + } else if (now.isAfter(promotionEndDate)) { + return 100.0; + } + + long totalDuration = java.time.Duration.between(promotionStartDate, promotionEndDate).toHours(); + long elapsedDuration = java.time.Duration.between(promotionStartDate, now).toHours(); + + if (totalDuration == 0) { + return 100.0; + } + + return (double) elapsedDuration / totalDuration * 100.0; + } + + /** + * 남은 홍보 일수 계산 + * + * @return 남은 일수 (음수면 0) + */ + public long calculateRemainingDays() { + if (promotionEndDate == null) { + return 0L; + } + + LocalDateTime now = LocalDateTime.now(); + if (now.isAfter(promotionEndDate)) { + return 0L; + } + + return java.time.Duration.between(now, promotionEndDate).toDays(); + } + + // ==================== 팩토리 메서드 ==================== + + /** + * SNS 콘텐츠 생성 팩토리 메서드 + */ + public static Content createSnsContent(String title, String content, Platform platform, + Long storeId, CreationConditions conditions) { + Content snsContent = Content.builder() + .contentType(ContentType.SNS_POST) + .platform(platform) + .title(title) + .content(content) + .storeId(storeId) + .creationConditions(conditions) + .status(ContentStatus.DRAFT) + .hashtags(new ArrayList<>()) + .images(new ArrayList<>()) + .build(); + + // 유효성 검증 + snsContent.validateTitle(title); + snsContent.validateContent(content); + + return snsContent; + } + + /** + * 포스터 콘텐츠 생성 팩토리 메서드 + */ + public static Content createPosterContent(String title, String content, List images, + Long storeId, CreationConditions conditions) { + if (images == null || images.isEmpty()) { + throw new IllegalArgumentException("포스터 콘텐츠는 이미지가 필수입니다."); + } + + Content posterContent = Content.builder() + .contentType(ContentType.POSTER) + .platform(Platform.INSTAGRAM) // 기본값 + .title(title) + .content(content) + .storeId(storeId) + .creationConditions(conditions) + .status(ContentStatus.DRAFT) + .hashtags(new ArrayList<>()) + .images(new ArrayList<>(images)) + .build(); + + // 유효성 검증 + posterContent.validateTitle(title); + posterContent.validateContent(content); + + return posterContent; + } + + // ==================== Object 메서드 오버라이드 ==================== + + /** + * 비즈니스 키 기반 동등성 비교 + * JPA 엔티티에서는 ID가 아닌 비즈니스 키 사용 권장 + */ + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + + Content content = (Content) obj; + return id != null && id.equals(content.id); + } + + @Override + public int hashCode() { + return getClass().hashCode(); + } + + /** + * 디버깅용 toString (민감한 정보 제외) + */ + @Override + public String toString() { + return "Content{" + + "id=" + id + + ", contentType=" + contentType + + ", platform=" + platform + + ", title='" + title + '\'' + + ", status=" + status + + ", storeId=" + storeId + + ", promotionStartDate=" + promotionStartDate + + ", promotionEndDate=" + promotionEndDate + + ", createdAt=" + createdAt + + '}'; + } +} + +/* +==================== 데이터베이스 스키마 (참고용) ==================== + +CREATE TABLE contents ( + content_id BIGINT NOT NULL AUTO_INCREMENT, + content_type VARCHAR(20) NOT NULL, + platform VARCHAR(20) NOT NULL, + title VARCHAR(200) NOT NULL, + content TEXT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + tone_and_manner VARCHAR(50), + promotion_type VARCHAR(50), + emotion_intensity VARCHAR(50), + target_audience VARCHAR(50), + event_name VARCHAR(100), + store_id BIGINT NOT NULL, + promotion_start_date DATETIME, + promotion_end_date DATETIME, + created_at DATETIME NOT NULL, + updated_at DATETIME, + PRIMARY KEY (content_id), + INDEX idx_store_id (store_id), + INDEX idx_content_type (content_type), + INDEX idx_platform (platform), + INDEX idx_status (status), + INDEX idx_promotion_dates (promotion_start_date, promotion_end_date), + INDEX idx_created_at (created_at) +); + +CREATE TABLE content_hashtags ( + content_id BIGINT NOT NULL, + hashtag VARCHAR(100) NOT NULL, + INDEX idx_content_hashtags (content_id), + FOREIGN KEY (content_id) REFERENCES contents(content_id) ON DELETE CASCADE +); + +CREATE TABLE content_images ( + content_id BIGINT NOT NULL, + image_url VARCHAR(500) NOT NULL, + INDEX idx_content_images (content_id), + FOREIGN KEY (content_id) REFERENCES contents(content_id) ON DELETE CASCADE +); +*/ \ No newline at end of file diff --git a/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java similarity index 100% rename from marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java rename to smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java diff --git a/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java similarity index 100% rename from marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java rename to smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java diff --git a/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentType.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentType.java similarity index 100% rename from marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentType.java rename to smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentType.java diff --git a/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java similarity index 100% rename from marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java rename to smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java diff --git a/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java similarity index 100% rename from marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java rename to smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java diff --git a/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java similarity index 100% rename from marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java rename to smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java diff --git a/marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiContentGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiContentGenerator.java similarity index 100% rename from marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiContentGenerator.java rename to smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiContentGenerator.java diff --git a/marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiPosterGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiPosterGenerator.java similarity index 100% rename from marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiPosterGenerator.java rename to smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiPosterGenerator.java diff --git a/marketing-content/src/main/java/com/won/smarketing/content/presentation/controller/ContentController.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/controller/ContentController.java similarity index 100% rename from marketing-content/src/main/java/com/won/smarketing/content/presentation/controller/ContentController.java rename to smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/controller/ContentController.java diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentDetailResponse.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentDetailResponse.java new file mode 100644 index 0000000..7cc6a52 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentDetailResponse.java @@ -0,0 +1,86 @@ +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 콘텐츠 상세 응답 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "콘텐츠 상세 응답") +public class ContentDetailResponse { + + @Schema(description = "콘텐츠 ID", example = "1") + private Long contentId; + + @Schema(description = "콘텐츠 타입", example = "SNS_POST") + private String contentType; + + @Schema(description = "플랫폼", example = "INSTAGRAM") + private String platform; + + @Schema(description = "제목", example = "맛있는 신메뉴를 소개합니다!") + private String title; + + @Schema(description = "콘텐츠 내용") + private String content; + + @Schema(description = "해시태그 목록") + private List hashtags; + + @Schema(description = "이미지 URL 목록") + private List images; + + @Schema(description = "상태", example = "PUBLISHED") + private String status; + + @Schema(description = "홍보 시작일") + private LocalDateTime promotionStartDate; + + @Schema(description = "홍보 종료일") + private LocalDateTime promotionEndDate; + + @Schema(description = "생성 조건") + private CreationConditionsDto creationConditions; + + @Schema(description = "생성일시") + private LocalDateTime createdAt; + + @Schema(description = "수정일시") + private LocalDateTime updatedAt; + + /** + * 생성 조건 내부 DTO + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "콘텐츠 생성 조건") + public static class CreationConditionsDto { + + @Schema(description = "톤앤매너", example = "친근함") + private String toneAndManner; + + @Schema(description = "프로모션 유형", example = "할인 정보") + private String promotionType; + + @Schema(description = "감정 강도", example = "보통") + private String emotionIntensity; + + @Schema(description = "홍보 대상", example = "메뉴") + private String targetAudience; + + @Schema(description = "이벤트명", example = "신메뉴 출시 이벤트") + private String eventName; + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentListRequest.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentListRequest.java new file mode 100644 index 0000000..8a35e35 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentListRequest.java @@ -0,0 +1,37 @@ +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 콘텐츠 목록 조회 요청 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "콘텐츠 목록 조회 요청") +public class ContentListRequest { + + @Schema(description = "콘텐츠 타입", example = "SNS_POST") + private String contentType; + + @Schema(description = "플랫폼", example = "INSTAGRAM") + private String platform; + + @Schema(description = "조회 기간", example = "7days") + private String period; + + @Schema(description = "정렬 기준", example = "createdAt") + private String sortBy; + + @Schema(description = "정렬 방향", example = "DESC") + private String sortDirection; + + @Schema(description = "페이지 번호", example = "0") + private Integer page; + + @Schema(description = "페이지 크기", example = "20") + private Integer size; +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentRegenerateRequest.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentRegenerateRequest.java new file mode 100644 index 0000000..47060a0 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentRegenerateRequest.java @@ -0,0 +1,33 @@ +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 콘텐츠 재생성 요청 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "콘텐츠 재생성 요청") +public class ContentRegenerateRequest { + + @Schema(description = "원본 콘텐츠 ID", example = "1", required = true) + @NotNull(message = "원본 콘텐츠 ID는 필수입니다") + private Long originalContentId; + + @Schema(description = "수정된 톤앤매너", example = "전문적") + private String toneAndManner; + + @Schema(description = "수정된 프로모션 유형", example = "신메뉴 알림") + private String promotionType; + + @Schema(description = "수정된 감정 강도", example = "열정적") + private String emotionIntensity; + + @Schema(description = "추가 요구사항", example = "더 감성적으로 작성해주세요") + private String additionalRequirements; +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentResponse.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentResponse.java new file mode 100644 index 0000000..c3ff5c3 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentResponse.java @@ -0,0 +1,361 @@ +// marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentResponse.java +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 콘텐츠 응답 DTO + * 콘텐츠 목록 조회 시 사용되는 기본 응답 DTO + * + * 이 클래스는 콘텐츠의 핵심 정보만을 포함하여 목록 조회 시 성능을 최적화합니다. + * 상세 정보가 필요한 경우 ContentDetailResponse를 사용합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "콘텐츠 응답") +public class ContentResponse { + + // ==================== 기본 식별 정보 ==================== + + @Schema(description = "콘텐츠 ID", example = "1") + private Long contentId; + + @Schema(description = "콘텐츠 타입", example = "SNS_POST", + allowableValues = {"SNS_POST", "POSTER"}) + private String contentType; + + @Schema(description = "플랫폼", example = "INSTAGRAM", + allowableValues = {"INSTAGRAM", "NAVER_BLOG", "FACEBOOK", "KAKAO_STORY"}) + private String platform; + + // ==================== 콘텐츠 정보 ==================== + + @Schema(description = "제목", example = "맛있는 신메뉴를 소개합니다!") + private String title; + + @Schema(description = "콘텐츠 내용", example = "특별한 신메뉴가 출시되었습니다! 🍽️\n지금 바로 맛보세요!") + private String content; + + @Schema(description = "해시태그 목록", example = "[\"#맛집\", \"#신메뉴\", \"#추천\", \"#인스타그램\"]") + private List hashtags; + + @Schema(description = "이미지 URL 목록", + example = "[\"https://example.com/image1.jpg\", \"https://example.com/image2.jpg\"]") + private List images; + + // ==================== 상태 관리 ==================== + + @Schema(description = "상태", example = "PUBLISHED", + allowableValues = {"DRAFT", "PUBLISHED", "SCHEDULED", "ARCHIVED"}) + private String status; + + @Schema(description = "상태 표시명", example = "발행완료") + private String statusDisplay; + + // ==================== 홍보 기간 ==================== + + @Schema(description = "홍보 시작일", example = "2024-01-15T09:00:00") + private LocalDateTime promotionStartDate; + + @Schema(description = "홍보 종료일", example = "2024-01-22T23:59:59") + private LocalDateTime promotionEndDate; + + // ==================== 시간 정보 ==================== + + @Schema(description = "생성일시", example = "2024-01-15T10:30:00") + private LocalDateTime createdAt; + + @Schema(description = "수정일시", example = "2024-01-15T14:20:00") + private LocalDateTime updatedAt; + + // ==================== 계산된 필드들 ==================== + + @Schema(description = "홍보 진행 상태", example = "ONGOING", + allowableValues = {"UPCOMING", "ONGOING", "COMPLETED"}) + private String promotionStatus; + + @Schema(description = "남은 홍보 일수", example = "5") + private Long remainingDays; + + @Schema(description = "홍보 진행률 (%)", example = "60.5") + private Double progressPercentage; + + @Schema(description = "콘텐츠 요약 (첫 50자)", example = "특별한 신메뉴가 출시되었습니다! 지금 바로 맛보세요...") + private String contentSummary; + + @Schema(description = "이미지 개수", example = "3") + private Integer imageCount; + + @Schema(description = "해시태그 개수", example = "8") + private Integer hashtagCount; + + // ==================== 비즈니스 메서드 ==================== + + /** + * 콘텐츠 요약 생성 + * 콘텐츠가 길 경우 첫 50자만 표시하고 "..." 추가 + * + * @param content 원본 콘텐츠 + * @param maxLength 최대 길이 + * @return 요약된 콘텐츠 + */ + public static String createContentSummary(String content, int maxLength) { + if (content == null || content.length() <= maxLength) { + return content; + } + return content.substring(0, maxLength) + "..."; + } + + /** + * 홍보 상태 계산 + * 현재 시간과 홍보 기간을 비교하여 상태 결정 + * + * @param startDate 홍보 시작일 + * @param endDate 홍보 종료일 + * @return 홍보 상태 + */ + public static String calculatePromotionStatus(LocalDateTime startDate, LocalDateTime endDate) { + if (startDate == null || endDate == null) { + return "UNKNOWN"; + } + + LocalDateTime now = LocalDateTime.now(); + + if (now.isBefore(startDate)) { + return "UPCOMING"; // 홍보 예정 + } else if (now.isAfter(endDate)) { + return "COMPLETED"; // 홍보 완료 + } else { + return "ONGOING"; // 홍보 진행중 + } + } + + /** + * 남은 일수 계산 + * 홍보 종료일까지 남은 일수 계산 + * + * @param endDate 홍보 종료일 + * @return 남은 일수 (음수면 0 반환) + */ + public static Long calculateRemainingDays(LocalDateTime endDate) { + if (endDate == null) { + return 0L; + } + + LocalDateTime now = LocalDateTime.now(); + if (now.isAfter(endDate)) { + return 0L; + } + + return java.time.Duration.between(now, endDate).toDays(); + } + + /** + * 진행률 계산 + * 홍보 기간 대비 진행률 계산 (0-100%) + * + * @param startDate 홍보 시작일 + * @param endDate 홍보 종료일 + * @return 진행률 (0-100%) + */ + public static Double calculateProgressPercentage(LocalDateTime startDate, LocalDateTime endDate) { + if (startDate == null || endDate == null) { + return 0.0; + } + + LocalDateTime now = LocalDateTime.now(); + + if (now.isBefore(startDate)) { + return 0.0; // 아직 시작 안함 + } else if (now.isAfter(endDate)) { + return 100.0; // 완료 + } + + long totalDuration = java.time.Duration.between(startDate, endDate).toHours(); + long elapsedDuration = java.time.Duration.between(startDate, now).toHours(); + + if (totalDuration == 0) { + return 100.0; + } + + return (double) elapsedDuration / totalDuration * 100.0; + } + + /** + * 상태 표시명 변환 + * 영문 상태를 한글로 변환 + * + * @param status 영문 상태 + * @return 한글 상태명 + */ + public static String getStatusDisplay(String status) { + if (status == null) { + return "알 수 없음"; + } + + switch (status) { + case "DRAFT": + return "임시저장"; + case "PUBLISHED": + return "발행완료"; + case "SCHEDULED": + return "예약발행"; + case "ARCHIVED": + return "보관됨"; + default: + return status; + } + } + + // ==================== Builder 확장 메서드 ==================== + + /** + * 도메인 엔티티에서 ContentResponse 생성 + * 계산된 필드들을 자동으로 설정 + * + * @param content 콘텐츠 도메인 엔티티 + * @return ContentResponse + */ + public static ContentResponse fromDomain(com.won.smarketing.content.domain.model.Content content) { + ContentResponseBuilder builder = ContentResponse.builder() + .contentId(content.getId().getValue()) + .contentType(content.getContentType().name()) + .platform(content.getPlatform().name()) + .title(content.getTitle()) + .content(content.getContent()) + .hashtags(content.getHashtags()) + .images(content.getImages()) + .status(content.getStatus().name()) + .statusDisplay(getStatusDisplay(content.getStatus().name())) + .promotionStartDate(content.getPromotionStartDate()) + .promotionEndDate(content.getPromotionEndDate()) + .createdAt(content.getCreatedAt()) + .updatedAt(content.getUpdatedAt()); + + // 계산된 필드들 설정 + builder.contentSummary(createContentSummary(content.getContent(), 50)); + builder.imageCount(content.getImages() != null ? content.getImages().size() : 0); + builder.hashtagCount(content.getHashtags() != null ? content.getHashtags().size() : 0); + + // 홍보 관련 계산 필드들 + builder.promotionStatus(calculatePromotionStatus( + content.getPromotionStartDate(), + content.getPromotionEndDate())); + builder.remainingDays(calculateRemainingDays(content.getPromotionEndDate())); + builder.progressPercentage(calculateProgressPercentage( + content.getPromotionStartDate(), + content.getPromotionEndDate())); + + return builder.build(); + } + + // ==================== 유틸리티 메서드 ==================== + + /** + * 콘텐츠가 현재 활성 상태인지 확인 + * + * @return 홍보 기간 내이고 발행 상태면 true + */ + public boolean isActive() { + return "PUBLISHED".equals(status) && "ONGOING".equals(promotionStatus); + } + + /** + * 콘텐츠 수정 가능 여부 확인 + * + * @return 임시저장 상태이거나 예약발행 상태면 true + */ + public boolean isEditable() { + return "DRAFT".equals(status) || "SCHEDULED".equals(status); + } + + /** + * 이미지가 있는 콘텐츠인지 확인 + * + * @return 이미지가 1개 이상 있으면 true + */ + public boolean hasImages() { + return images != null && !images.isEmpty(); + } + + /** + * 해시태그가 있는 콘텐츠인지 확인 + * + * @return 해시태그가 1개 이상 있으면 true + */ + public boolean hasHashtags() { + return hashtags != null && !hashtags.isEmpty(); + } + + /** + * 디버깅용 toString (간소화된 정보만) + */ + @Override + public String toString() { + return "ContentResponse{" + + "contentId=" + contentId + + ", contentType='" + contentType + '\'' + + ", platform='" + platform + '\'' + + ", title='" + title + '\'' + + ", status='" + status + '\'' + + ", promotionStatus='" + promotionStatus + '\'' + + ", createdAt=" + createdAt + + '}'; + } +} + +/* +==================== 사용 예시 ==================== + +// 1. 도메인 엔티티에서 DTO 생성 +Content domainContent = contentRepository.findById(contentId); +ContentResponse response = ContentResponse.fromDomain(domainContent); + +// 2. 수동으로 빌더 사용 +ContentResponse response = ContentResponse.builder() + .contentId(1L) + .contentType("SNS_POST") + .platform("INSTAGRAM") + .title("맛있는 신메뉴") + .content("특별한 신메뉴가 출시되었습니다!") + .status("PUBLISHED") + .build(); + +// 3. 비즈니스 로직 활용 +boolean canEdit = response.isEditable(); +boolean isLive = response.isActive(); +String summary = response.getContentSummary(); + +==================== JSON 응답 예시 ==================== + +{ + "contentId": 1, + "contentType": "SNS_POST", + "platform": "INSTAGRAM", + "title": "맛있는 신메뉴를 소개합니다!", + "content": "특별한 신메뉴가 출시되었습니다! 🍽️\n지금 바로 맛보세요!", + "hashtags": ["#맛집", "#신메뉴", "#추천", "#인스타그램"], + "images": ["https://example.com/image1.jpg"], + "status": "PUBLISHED", + "statusDisplay": "발행완료", + "promotionStartDate": "2024-01-15T09:00:00", + "promotionEndDate": "2024-01-22T23:59:59", + "createdAt": "2024-01-15T10:30:00", + "updatedAt": "2024-01-15T14:20:00", + "promotionStatus": "ONGOING", + "remainingDays": 5, + "progressPercentage": 60.5, + "contentSummary": "특별한 신메뉴가 출시되었습니다! 지금 바로 맛보세요...", + "imageCount": 1, + "hashtagCount": 4 +} +*/ \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentStatisticsResponse.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentStatisticsResponse.java new file mode 100644 index 0000000..fed7dfa --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentStatisticsResponse.java @@ -0,0 +1,41 @@ +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +/** + * 콘텐츠 통계 응답 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "콘텐츠 통계 응답") +public class ContentStatisticsResponse { + + @Schema(description = "총 콘텐츠 수", example = "150") + private Long totalContents; + + @Schema(description = "이번 달 생성된 콘텐츠 수", example = "25") + private Long thisMonthContents; + + @Schema(description = "발행된 콘텐츠 수", example = "120") + private Long publishedContents; + + @Schema(description = "임시저장된 콘텐츠 수", example = "30") + private Long draftContents; + + @Schema(description = "콘텐츠 타입별 통계") + private Map contentTypeStats; + + @Schema(description = "플랫폼별 통계") + private Map platformStats; + + @Schema(description = "월별 생성 통계 (최근 6개월)") + private Map monthlyStats; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentUpdateRequest.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentUpdateRequest.java new file mode 100644 index 0000000..d550f0f --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentUpdateRequest.java @@ -0,0 +1,33 @@ +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 콘텐츠 수정 요청 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "콘텐츠 수정 요청") +public class ContentUpdateRequest { + + @Schema(description = "제목", example = "수정된 제목") + private String title; + + @Schema(description = "콘텐츠 내용") + private String content; + + @Schema(description = "홍보 시작일") + private LocalDateTime promotionStartDate; + + @Schema(description = "홍보 종료일") + private LocalDateTime promotionEndDate; + + @Schema(description = "상태", example = "PUBLISHED") + private String status; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentUpdateResponse.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentUpdateResponse.java new file mode 100644 index 0000000..3296ee2 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentUpdateResponse.java @@ -0,0 +1,35 @@ +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 콘텐츠 수정 응답 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "콘텐츠 수정 응답") +public class ContentUpdateResponse { + + @Schema(description = "콘텐츠 ID", example = "1") + private Long contentId; + + @Schema(description = "수정된 제목", example = "수정된 제목") + private String title; + + @Schema(description = "수정된 콘텐츠 내용") + private String content; + + @Schema(description = "상태", example = "PUBLISHED") + private String status; + + @Schema(description = "수정일시") + private LocalDateTime updatedAt; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/OngoingContentResponse.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/OngoingContentResponse.java new file mode 100644 index 0000000..047cb2d --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/OngoingContentResponse.java @@ -0,0 +1,47 @@ +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 진행 중인 콘텐츠 응답 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "진행 중인 콘텐츠 응답") +public class OngoingContentResponse { + + @Schema(description = "콘텐츠 ID", example = "1") + private Long contentId; + + @Schema(description = "콘텐츠 타입", example = "SNS_POST") + private String contentType; + + @Schema(description = "플랫폼", example = "INSTAGRAM") + private String platform; + + @Schema(description = "제목", example = "진행 중인 이벤트") + private String title; + + @Schema(description = "상태", example = "PUBLISHED") + private String status; + + @Schema(description = "홍보 시작일") + private LocalDateTime promotionStartDate; + + @Schema(description = "홍보 종료일") + private LocalDateTime promotionEndDate; + + @Schema(description = "남은 일수", example = "5") + private Long remainingDays; + + @Schema(description = "진행률 (%)", example = "60.5") + private Double progressPercentage; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateRequest.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateRequest.java new file mode 100644 index 0000000..1e3a406 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateRequest.java @@ -0,0 +1,51 @@ +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 포스터 콘텐츠 생성 요청 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "포스터 콘텐츠 생성 요청") +public class PosterContentCreateRequest { + + @Schema(description = "홍보 대상", example = "메뉴", required = true) + @NotBlank(message = "홍보 대상은 필수입니다") + private String targetAudience; + + @Schema(description = "홍보 시작일", required = true) + @NotNull(message = "홍보 시작일은 필수입니다") + private LocalDateTime promotionStartDate; + + @Schema(description = "홍보 종료일", required = true) + @NotNull(message = "홍보 종료일은 필수입니다") + private LocalDateTime promotionEndDate; + + @Schema(description = "이벤트명 (이벤트 홍보시)", example = "신메뉴 출시 이벤트") + private String eventName; + + @Schema(description = "이미지 스타일", example = "모던") + private String imageStyle; + + @Schema(description = "프로모션 유형", example = "할인 정보") + private String promotionType; + + @Schema(description = "감정 강도", example = "보통") + private String emotionIntensity; + + @Schema(description = "업로드된 이미지 URL 목록", required = true) + @NotNull(message = "이미지는 1개 이상 필수입니다") + @Size(min = 1, message = "이미지는 1개 이상 업로드해야 합니다") + private List images; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateResponse.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateResponse.java new file mode 100644 index 0000000..04bb601 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateResponse.java @@ -0,0 +1,41 @@ +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 포스터 콘텐츠 생성 응답 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "포스터 콘텐츠 생성 응답") +public class PosterContentCreateResponse { + + @Schema(description = "콘텐츠 ID", example = "1") + private Long contentId; + + @Schema(description = "생성된 포스터 제목", example = "특별 이벤트 안내") + private String title; + + @Schema(description = "생성된 포스터 텍스트 내용") + private String content; + + @Schema(description = "포스터 이미지 URL 목록") + private List posterImages; + + @Schema(description = "원본 이미지 URL 목록") + private List originalImages; + + @Schema(description = "이미지 스타일", example = "모던") + private String imageStyle; + + @Schema(description = "생성 상태", example = "DRAFT") + private String status; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentSaveRequest.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentSaveRequest.java new file mode 100644 index 0000000..a7bd715 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentSaveRequest.java @@ -0,0 +1,33 @@ +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 포스터 콘텐츠 저장 요청 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "포스터 콘텐츠 저장 요청") +public class PosterContentSaveRequest { + + @Schema(description = "콘텐츠 ID", example = "1", required = true) + @NotNull(message = "콘텐츠 ID는 필수입니다") + private Long contentId; + + @Schema(description = "최종 제목", example = "특별 이벤트 안내") + private String finalTitle; + + @Schema(description = "최종 콘텐츠 내용") + private String finalContent; + + @Schema(description = "선택된 포스터 이미지 URL") + private String selectedPosterImage; + + @Schema(description = "발행 상태", example = "PUBLISHED") + private String status; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateResponse.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateResponse.java new file mode 100644 index 0000000..31a8435 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateResponse.java @@ -0,0 +1,380 @@ +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * SNS 콘텐츠 생성 응답 DTO + * + * AI를 통해 SNS 콘텐츠를 생성한 후 클라이언트에게 반환되는 응답 정보입니다. + * 생성된 콘텐츠의 기본 정보와 함께 사용자가 추가 편집할 수 있는 정보를 포함합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "SNS 콘텐츠 생성 응답") +public class SnsContentCreateResponse { + + // ==================== 기본 식별 정보 ==================== + + @Schema(description = "생성된 콘텐츠 ID", example = "1") + private Long contentId; + + @Schema(description = "콘텐츠 타입", example = "SNS_POST") + private String contentType; + + @Schema(description = "대상 플랫폼", example = "INSTAGRAM", + allowableValues = {"INSTAGRAM", "NAVER_BLOG", "FACEBOOK", "KAKAO_STORY"}) + private String platform; + + // ==================== AI 생성 콘텐츠 ==================== + + @Schema(description = "AI가 생성한 콘텐츠 제목", + example = "맛있는 신메뉴를 소개합니다! ✨") + private String title; + + @Schema(description = "AI가 생성한 콘텐츠 내용", + example = "안녕하세요! 😊\n\n특별한 신메뉴가 출시되었습니다!\n진짜 맛있어서 꼭 한번 드셔보세요 🍽️\n\n매장에서 기다리고 있을게요! 💫") + private String content; + + @Schema(description = "AI가 생성한 해시태그 목록", + example = "[\"맛집\", \"신메뉴\", \"추천\", \"인스타그램\", \"일상\", \"좋아요\", \"팔로우\", \"맛있어요\"]") + private List hashtags; + + // ==================== 플랫폼별 최적화 정보 ==================== + + @Schema(description = "플랫폼별 최적화된 콘텐츠 길이", example = "280") + private Integer contentLength; + + @Schema(description = "플랫폼별 권장 해시태그 개수", example = "8") + private Integer recommendedHashtagCount; + + @Schema(description = "플랫폼별 최대 해시태그 개수", example = "15") + private Integer maxHashtagCount; + + // ==================== 생성 조건 정보 ==================== + + @Schema(description = "콘텐츠 생성에 사용된 조건들") + private GenerationConditionsDto generationConditions; + + // ==================== 상태 및 메타데이터 ==================== + + @Schema(description = "생성 상태", example = "DRAFT", + allowableValues = {"DRAFT", "PUBLISHED", "SCHEDULED"}) + private String status; + + @Schema(description = "생성 일시", example = "2024-01-15T10:30:00") + private LocalDateTime createdAt; + + @Schema(description = "AI 모델 버전", example = "gpt-4-turbo") + private String aiModelVersion; + + @Schema(description = "생성 시간 (초)", example = "3.5") + private Double generationTimeSeconds; + + // ==================== 추가 정보 ==================== + + @Schema(description = "업로드된 원본 이미지 URL 목록") + private List originalImages; + + @Schema(description = "콘텐츠 품질 점수 (1-100)", example = "85") + private Integer qualityScore; + + @Schema(description = "예상 참여율 (%)", example = "12.5") + private Double expectedEngagementRate; + + @Schema(description = "콘텐츠 카테고리", example = "음식/메뉴소개") + private String category; + + // ==================== 편집 가능 여부 ==================== + + @Schema(description = "제목 편집 가능 여부", example = "true") + @Builder.Default + private Boolean titleEditable = true; + + @Schema(description = "내용 편집 가능 여부", example = "true") + @Builder.Default + private Boolean contentEditable = true; + + @Schema(description = "해시태그 편집 가능 여부", example = "true") + @Builder.Default + private Boolean hashtagsEditable = true; + + // ==================== 대안 콘텐츠 ==================== + + @Schema(description = "대안 제목 목록 (사용자 선택용)") + private List alternativeTitles; + + @Schema(description = "대안 해시태그 세트 목록") + private List> alternativeHashtagSets; + + // ==================== 내부 DTO 클래스 ==================== + + /** + * 콘텐츠 생성 조건 DTO + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "콘텐츠 생성 조건") + public static class GenerationConditionsDto { + + @Schema(description = "홍보 대상", example = "메뉴") + private String targetAudience; + + @Schema(description = "이벤트명", example = "신메뉴 출시 이벤트") + private String eventName; + + @Schema(description = "톤앤매너", example = "친근함") + private String toneAndManner; + + @Schema(description = "프로모션 유형", example = "할인 정보") + private String promotionType; + + @Schema(description = "감정 강도", example = "보통") + private String emotionIntensity; + + @Schema(description = "홍보 시작일", example = "2024-01-15T09:00:00") + private LocalDateTime promotionStartDate; + + @Schema(description = "홍보 종료일", example = "2024-01-22T23:59:59") + private LocalDateTime promotionEndDate; + } + + // ==================== 비즈니스 메서드 ==================== + + /** + * 플랫폼별 콘텐츠 최적화 여부 확인 + * + * @return 콘텐츠가 플랫폼 권장 사항을 만족하면 true + */ + public boolean isOptimizedForPlatform() { + if (content == null || hashtags == null) { + return false; + } + + // 플랫폼별 최적화 기준 + switch (platform.toUpperCase()) { + case "INSTAGRAM": + return content.length() <= 2200 && + hashtags.size() <= 15 && + hashtags.size() >= 5; + case "NAVER_BLOG": + return content.length() >= 300 && + hashtags.size() <= 10 && + hashtags.size() >= 3; + case "FACEBOOK": + return content.length() <= 500 && + hashtags.size() <= 5; + default: + return true; + } + } + + /** + * 고품질 콘텐츠 여부 확인 + * + * @return 품질 점수가 80점 이상이면 true + */ + public boolean isHighQuality() { + return qualityScore != null && qualityScore >= 80; + } + + /** + * 참여율 예상 등급 반환 + * + * @return 예상 참여율 등급 (HIGH, MEDIUM, LOW) + */ + public String getExpectedEngagementLevel() { + if (expectedEngagementRate == null) { + return "UNKNOWN"; + } + + if (expectedEngagementRate >= 15.0) { + return "HIGH"; + } else if (expectedEngagementRate >= 8.0) { + return "MEDIUM"; + } else { + return "LOW"; + } + } + + /** + * 해시태그를 문자열로 변환 (# 포함) + * + * @return #으로 시작하는 해시태그 문자열 + */ + public String getHashtagsAsString() { + if (hashtags == null || hashtags.isEmpty()) { + return ""; + } + + return hashtags.stream() + .map(tag -> "#" + tag) + .reduce((a, b) -> a + " " + b) + .orElse(""); + } + + /** + * 콘텐츠 요약 생성 + * + * @param maxLength 최대 길이 + * @return 요약된 콘텐츠 + */ + public String getContentSummary(int maxLength) { + if (content == null || content.length() <= maxLength) { + return content; + } + return content.substring(0, maxLength) + "..."; + } + + /** + * 플랫폼별 최적화 제안사항 반환 + * + * @return 최적화 제안사항 목록 + */ + public List getOptimizationSuggestions() { + List suggestions = new java.util.ArrayList<>(); + + if (!isOptimizedForPlatform()) { + switch (platform.toUpperCase()) { + case "INSTAGRAM": + if (content != null && content.length() > 2200) { + suggestions.add("콘텐츠 길이를 2200자 이하로 줄여주세요."); + } + if (hashtags != null && hashtags.size() > 15) { + suggestions.add("해시태그를 15개 이하로 줄여주세요."); + } + if (hashtags != null && hashtags.size() < 5) { + suggestions.add("해시태그를 5개 이상 추가해주세요."); + } + break; + case "NAVER_BLOG": + if (content != null && content.length() < 300) { + suggestions.add("블로그 포스팅을 위해 내용을 300자 이상으로 늘려주세요."); + } + if (hashtags != null && hashtags.size() > 10) { + suggestions.add("네이버 블로그는 해시태그를 10개 이하로 사용하는 것이 좋습니다."); + } + break; + case "FACEBOOK": + if (content != null && content.length() > 500) { + suggestions.add("페이스북에서는 500자 이하의 짧은 글이 더 효과적입니다."); + } + break; + } + } + + return suggestions; + } + + // ==================== 팩토리 메서드 ==================== + + /** + * 도메인 엔티티에서 SnsContentCreateResponse 생성 + * + * @param content 콘텐츠 도메인 엔티티 + * @param aiMetadata AI 생성 메타데이터 + * @return SnsContentCreateResponse + */ + public static SnsContentCreateResponse fromDomain( + com.won.smarketing.content.domain.model.Content content, + AiGenerationMetadata aiMetadata) { + + SnsContentCreateResponseBuilder builder = SnsContentCreateResponse.builder() + .contentId(content.getId()) + .contentType(content.getContentType().name()) + .platform(content.getPlatform().name()) + .title(content.getTitle()) + .content(content.getContent()) + .hashtags(content.getHashtags()) + .status(content.getStatus().name()) + .createdAt(content.getCreatedAt()) + .originalImages(content.getImages()); + + // 생성 조건 정보 설정 + if (content.getCreationConditions() != null) { + builder.generationConditions(GenerationConditionsDto.builder() + .targetAudience(content.getCreationConditions().getTargetAudience()) + .eventName(content.getCreationConditions().getEventName()) + .toneAndManner(content.getCreationConditions().getToneAndManner()) + .promotionType(content.getCreationConditions().getPromotionType()) + .emotionIntensity(content.getCreationConditions().getEmotionIntensity()) + .promotionStartDate(content.getPromotionStartDate()) + .promotionEndDate(content.getPromotionEndDate()) + .build()); + } + + // AI 메타데이터 설정 + if (aiMetadata != null) { + builder.aiModelVersion(aiMetadata.getModelVersion()) + .generationTimeSeconds(aiMetadata.getGenerationTime()) + .qualityScore(aiMetadata.getQualityScore()) + .expectedEngagementRate(aiMetadata.getExpectedEngagementRate()) + .alternativeTitles(aiMetadata.getAlternativeTitles()) + .alternativeHashtagSets(aiMetadata.getAlternativeHashtagSets()); + } + + // 플랫폼별 최적화 정보 설정 + SnsContentCreateResponse response = builder.build(); + response.setContentLength(response.getContent() != null ? response.getContent().length() : 0); + response.setRecommendedHashtagCount(getRecommendedHashtagCount(content.getPlatform().name())); + response.setMaxHashtagCount(getMaxHashtagCount(content.getPlatform().name())); + + return response; + } + + /** + * 플랫폼별 권장 해시태그 개수 반환 + */ + private static Integer getRecommendedHashtagCount(String platform) { + switch (platform.toUpperCase()) { + case "INSTAGRAM": return 8; + case "NAVER_BLOG": return 5; + case "FACEBOOK": return 3; + case "KAKAO_STORY": return 5; + default: return 5; + } + } + + /** + * 플랫폼별 최대 해시태그 개수 반환 + */ + private static Integer getMaxHashtagCount(String platform) { + switch (platform.toUpperCase()) { + case "INSTAGRAM": return 15; + case "NAVER_BLOG": return 10; + case "FACEBOOK": return 5; + case "KAKAO_STORY": return 8; + default: return 10; + } + } + + // ==================== AI 생성 메타데이터 DTO ==================== + + /** + * AI 생성 메타데이터 + * AI 생성 과정에서 나온 부가 정보들 + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class AiGenerationMetadata { + private String modelVersion; + private Double generationTime; + private Integer qualityScore; + private Double expectedEngagementRate; + private List alternativeTitles; + private List> alternativeHashtagSets; + private String category; + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentSaveRequest.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentSaveRequest.java new file mode 100644 index 0000000..5b4a2c7 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentSaveRequest.java @@ -0,0 +1,35 @@ +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * SNS 콘텐츠 저장 요청 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "SNS 콘텐츠 저장 요청") +public class SnsContentSaveRequest { + + @Schema(description = "콘텐츠 ID", example = "1", required = true) + @NotNull(message = "콘텐츠 ID는 필수입니다") + private Long contentId; + + @Schema(description = "최종 제목", example = "맛있는 신메뉴를 소개합니다!") + private String finalTitle; + + @Schema(description = "최종 콘텐츠 내용") + private String finalContent; + + @Schema(description = "발행 상태", example = "PUBLISHED") + private String status; +} \ No newline at end of file diff --git a/marketing-content/src/main/resources/application.yml b/smarketing-java/marketing-content/src/main/resources/application.yml similarity index 100% rename from marketing-content/src/main/resources/application.yml rename to smarketing-java/marketing-content/src/main/resources/application.yml diff --git a/member/build.gradle b/smarketing-java/member/build.gradle similarity index 100% rename from member/build.gradle rename to smarketing-java/member/build.gradle diff --git a/member/src/main/java/com/won/smarketing/member/MemberServiceApplication.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/MemberServiceApplication.java similarity index 100% rename from member/src/main/java/com/won/smarketing/member/MemberServiceApplication.java rename to smarketing-java/member/src/main/java/com/won/smarketing/member/MemberServiceApplication.java diff --git a/member/src/main/java/com/won/smarketing/member/config/JpaConfig.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/config/JpaConfig.java similarity index 100% rename from member/src/main/java/com/won/smarketing/member/config/JpaConfig.java rename to smarketing-java/member/src/main/java/com/won/smarketing/member/config/JpaConfig.java diff --git a/member/src/main/java/com/won/smarketing/member/controller/AuthController.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/controller/AuthController.java similarity index 100% rename from member/src/main/java/com/won/smarketing/member/controller/AuthController.java rename to smarketing-java/member/src/main/java/com/won/smarketing/member/controller/AuthController.java diff --git a/member/src/main/java/com/won/smarketing/member/controller/MemberController.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/controller/MemberController.java similarity index 100% rename from member/src/main/java/com/won/smarketing/member/controller/MemberController.java rename to smarketing-java/member/src/main/java/com/won/smarketing/member/controller/MemberController.java diff --git a/member/src/main/java/com/won/smarketing/member/dto/DuplicateCheckResponse.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/DuplicateCheckResponse.java similarity index 100% rename from member/src/main/java/com/won/smarketing/member/dto/DuplicateCheckResponse.java rename to smarketing-java/member/src/main/java/com/won/smarketing/member/dto/DuplicateCheckResponse.java diff --git a/member/src/main/java/com/won/smarketing/member/dto/LoginRequest.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/LoginRequest.java similarity index 100% rename from member/src/main/java/com/won/smarketing/member/dto/LoginRequest.java rename to smarketing-java/member/src/main/java/com/won/smarketing/member/dto/LoginRequest.java diff --git a/member/src/main/java/com/won/smarketing/member/dto/LoginResponse.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/LoginResponse.java similarity index 100% rename from member/src/main/java/com/won/smarketing/member/dto/LoginResponse.java rename to smarketing-java/member/src/main/java/com/won/smarketing/member/dto/LoginResponse.java diff --git a/member/src/main/java/com/won/smarketing/member/dto/LogoutRequest.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/LogoutRequest.java similarity index 100% rename from member/src/main/java/com/won/smarketing/member/dto/LogoutRequest.java rename to smarketing-java/member/src/main/java/com/won/smarketing/member/dto/LogoutRequest.java diff --git a/member/src/main/java/com/won/smarketing/member/dto/PasswordValidationRequest.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/PasswordValidationRequest.java similarity index 100% rename from member/src/main/java/com/won/smarketing/member/dto/PasswordValidationRequest.java rename to smarketing-java/member/src/main/java/com/won/smarketing/member/dto/PasswordValidationRequest.java diff --git a/member/src/main/java/com/won/smarketing/member/dto/RegisterRequest.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/RegisterRequest.java similarity index 100% rename from member/src/main/java/com/won/smarketing/member/dto/RegisterRequest.java rename to smarketing-java/member/src/main/java/com/won/smarketing/member/dto/RegisterRequest.java diff --git a/member/src/main/java/com/won/smarketing/member/dto/TokenRefreshRequest.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/TokenRefreshRequest.java similarity index 100% rename from member/src/main/java/com/won/smarketing/member/dto/TokenRefreshRequest.java rename to smarketing-java/member/src/main/java/com/won/smarketing/member/dto/TokenRefreshRequest.java diff --git a/member/src/main/java/com/won/smarketing/member/dto/TokenResponse.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/TokenResponse.java similarity index 100% rename from member/src/main/java/com/won/smarketing/member/dto/TokenResponse.java rename to smarketing-java/member/src/main/java/com/won/smarketing/member/dto/TokenResponse.java diff --git a/member/src/main/java/com/won/smarketing/member/dto/ValidationResponse.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/ValidationResponse.java similarity index 100% rename from member/src/main/java/com/won/smarketing/member/dto/ValidationResponse.java rename to smarketing-java/member/src/main/java/com/won/smarketing/member/dto/ValidationResponse.java diff --git a/member/src/main/java/com/won/smarketing/member/entity/Member.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/entity/Member.java similarity index 100% rename from member/src/main/java/com/won/smarketing/member/entity/Member.java rename to smarketing-java/member/src/main/java/com/won/smarketing/member/entity/Member.java diff --git a/member/src/main/java/com/won/smarketing/member/repository/MemberRepository.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/repository/MemberRepository.java similarity index 100% rename from member/src/main/java/com/won/smarketing/member/repository/MemberRepository.java rename to smarketing-java/member/src/main/java/com/won/smarketing/member/repository/MemberRepository.java diff --git a/member/src/main/java/com/won/smarketing/member/service/AuthService.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/service/AuthService.java similarity index 100% rename from member/src/main/java/com/won/smarketing/member/service/AuthService.java rename to smarketing-java/member/src/main/java/com/won/smarketing/member/service/AuthService.java diff --git a/member/src/main/java/com/won/smarketing/member/service/AuthServiceImpl.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/service/AuthServiceImpl.java similarity index 100% rename from member/src/main/java/com/won/smarketing/member/service/AuthServiceImpl.java rename to smarketing-java/member/src/main/java/com/won/smarketing/member/service/AuthServiceImpl.java diff --git a/member/src/main/java/com/won/smarketing/member/service/MemberService.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/service/MemberService.java similarity index 100% rename from member/src/main/java/com/won/smarketing/member/service/MemberService.java rename to smarketing-java/member/src/main/java/com/won/smarketing/member/service/MemberService.java diff --git a/member/src/main/java/com/won/smarketing/member/service/MemberServiceImpl.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/service/MemberServiceImpl.java similarity index 100% rename from member/src/main/java/com/won/smarketing/member/service/MemberServiceImpl.java rename to smarketing-java/member/src/main/java/com/won/smarketing/member/service/MemberServiceImpl.java diff --git a/member/src/main/resources/application.yml b/smarketing-java/member/src/main/resources/application.yml similarity index 100% rename from member/src/main/resources/application.yml rename to smarketing-java/member/src/main/resources/application.yml diff --git a/settings.gradle b/smarketing-java/settings.gradle similarity index 100% rename from settings.gradle rename to smarketing-java/settings.gradle diff --git a/store/build.gradle b/smarketing-java/store/build.gradle similarity index 100% rename from store/build.gradle rename to smarketing-java/store/build.gradle diff --git a/store/src/main/java/com/won/smarketing/store/StoreServiceApplication.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/StoreServiceApplication.java similarity index 100% rename from store/src/main/java/com/won/smarketing/store/StoreServiceApplication.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/StoreServiceApplication.java diff --git a/store/src/main/java/com/won/smarketing/store/config/JpaConfig.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/config/JpaConfig.java similarity index 100% rename from store/src/main/java/com/won/smarketing/store/config/JpaConfig.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/config/JpaConfig.java diff --git a/store/src/main/java/com/won/smarketing/store/controller/MenuController.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/MenuController.java similarity index 100% rename from store/src/main/java/com/won/smarketing/store/controller/MenuController.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/controller/MenuController.java diff --git a/store/src/main/java/com/won/smarketing/store/controller/SalesController.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/SalesController.java similarity index 100% rename from store/src/main/java/com/won/smarketing/store/controller/SalesController.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/controller/SalesController.java diff --git a/store/src/main/java/com/won/smarketing/store/controller/StoreController.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/StoreController.java similarity index 100% rename from store/src/main/java/com/won/smarketing/store/controller/StoreController.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/controller/StoreController.java diff --git a/store/src/main/java/com/won/smarketing/store/dto/MenuCreateRequest.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuCreateRequest.java similarity index 100% rename from store/src/main/java/com/won/smarketing/store/dto/MenuCreateRequest.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuCreateRequest.java diff --git a/store/src/main/java/com/won/smarketing/store/dto/MenuResponse.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuResponse.java similarity index 100% rename from store/src/main/java/com/won/smarketing/store/dto/MenuResponse.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuResponse.java diff --git a/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java similarity index 100% rename from store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java diff --git a/store/src/main/java/com/won/smarketing/store/dto/SalesResponse.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/SalesResponse.java similarity index 100% rename from store/src/main/java/com/won/smarketing/store/dto/SalesResponse.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/dto/SalesResponse.java diff --git a/store/src/main/java/com/won/smarketing/store/dto/StoreCreateRequest.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreCreateRequest.java similarity index 100% rename from store/src/main/java/com/won/smarketing/store/dto/StoreCreateRequest.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreCreateRequest.java diff --git a/store/src/main/java/com/won/smarketing/store/dto/StoreResponse.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreResponse.java similarity index 100% rename from store/src/main/java/com/won/smarketing/store/dto/StoreResponse.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreResponse.java diff --git a/store/src/main/java/com/won/smarketing/store/dto/StoreUpdateRequest.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreUpdateRequest.java similarity index 100% rename from store/src/main/java/com/won/smarketing/store/dto/StoreUpdateRequest.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreUpdateRequest.java diff --git a/store/src/main/java/com/won/smarketing/store/entity/Menu.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Menu.java similarity index 100% rename from store/src/main/java/com/won/smarketing/store/entity/Menu.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Menu.java diff --git a/store/src/main/java/com/won/smarketing/store/entity/Sales.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Sales.java similarity index 100% rename from store/src/main/java/com/won/smarketing/store/entity/Sales.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Sales.java diff --git a/store/src/main/java/com/won/smarketing/store/entity/Store.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Store.java similarity index 100% rename from store/src/main/java/com/won/smarketing/store/entity/Store.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Store.java diff --git a/store/src/main/java/com/won/smarketing/store/repository/MenuRepository.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/repository/MenuRepository.java similarity index 100% rename from store/src/main/java/com/won/smarketing/store/repository/MenuRepository.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/repository/MenuRepository.java diff --git a/store/src/main/java/com/won/smarketing/store/repository/SalesRepository.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/repository/SalesRepository.java similarity index 100% rename from store/src/main/java/com/won/smarketing/store/repository/SalesRepository.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/repository/SalesRepository.java diff --git a/store/src/main/java/com/won/smarketing/store/repository/StoreRepository.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/repository/StoreRepository.java similarity index 100% rename from store/src/main/java/com/won/smarketing/store/repository/StoreRepository.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/repository/StoreRepository.java diff --git a/store/src/main/java/com/won/smarketing/store/service/MenuService.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuService.java similarity index 100% rename from store/src/main/java/com/won/smarketing/store/service/MenuService.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuService.java diff --git a/store/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java similarity index 100% rename from store/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java diff --git a/store/src/main/java/com/won/smarketing/store/service/SalesService.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/SalesService.java similarity index 100% rename from store/src/main/java/com/won/smarketing/store/service/SalesService.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/service/SalesService.java diff --git a/store/src/main/java/com/won/smarketing/store/service/SalesServiceImpl.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/SalesServiceImpl.java similarity index 100% rename from store/src/main/java/com/won/smarketing/store/service/SalesServiceImpl.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/service/SalesServiceImpl.java diff --git a/store/src/main/java/com/won/smarketing/store/service/StoreService.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/StoreService.java similarity index 100% rename from store/src/main/java/com/won/smarketing/store/service/StoreService.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/service/StoreService.java diff --git a/store/src/main/java/com/won/smarketing/store/service/StoreServiceImpl.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/StoreServiceImpl.java similarity index 100% rename from store/src/main/java/com/won/smarketing/store/service/StoreServiceImpl.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/service/StoreServiceImpl.java diff --git a/store/src/main/resources/application.yml b/smarketing-java/store/src/main/resources/application.yml similarity index 100% rename from store/src/main/resources/application.yml rename to smarketing-java/store/src/main/resources/application.yml From c94c75b4f268d408e590023ff771eff73cc46510 Mon Sep 17 00:00:00 2001 From: OhSeongRak Date: Wed, 11 Jun 2025 14:30:15 +0900 Subject: [PATCH 10/34] feat: ai service init --- smarketing-ai/.env | 6 + smarketing-ai/app.py | 150 +++++++++++ smarketing-ai/config/__init__.py | 1 + smarketing-ai/config/config.py | 26 ++ smarketing-ai/models/__init__.py | 1 + smarketing-ai/models/request_models.py | 27 ++ smarketing-ai/services/__init__.py | 1 + smarketing-ai/services/content_service.py | 200 ++++++++++++++ smarketing-ai/services/poster_service.py | 304 ++++++++++++++++++++++ smarketing-ai/utils/__init__.py | 1 + smarketing-ai/utils/ai_client.py | 176 +++++++++++++ smarketing-ai/utils/image_processor.py | 166 ++++++++++++ 12 files changed, 1059 insertions(+) create mode 100644 smarketing-ai/.env create mode 100644 smarketing-ai/app.py create mode 100644 smarketing-ai/config/__init__.py create mode 100644 smarketing-ai/config/config.py create mode 100644 smarketing-ai/models/__init__.py create mode 100644 smarketing-ai/models/request_models.py create mode 100644 smarketing-ai/services/__init__.py create mode 100644 smarketing-ai/services/content_service.py create mode 100644 smarketing-ai/services/poster_service.py create mode 100644 smarketing-ai/utils/__init__.py create mode 100644 smarketing-ai/utils/ai_client.py create mode 100644 smarketing-ai/utils/image_processor.py diff --git a/smarketing-ai/.env b/smarketing-ai/.env new file mode 100644 index 0000000..49a0633 --- /dev/null +++ b/smarketing-ai/.env @@ -0,0 +1,6 @@ +CLAUDE_API_KEY=your_claude_api_key_here +OPENAI_API_KEY=your_openai_api_key_here +FLASK_ENV=development +UPLOAD_FOLDER=uploads +MAX_CONTENT_LENGTH=16777216 +SECRET_KEY=your-secret-key-for-production \ No newline at end of file diff --git a/smarketing-ai/app.py b/smarketing-ai/app.py new file mode 100644 index 0000000..0592c23 --- /dev/null +++ b/smarketing-ai/app.py @@ -0,0 +1,150 @@ +""" +AI 마케팅 서비스 Flask 애플리케이션 +점주를 위한 마케팅 콘텐츠 및 포스터 자동 생성 서비스 +""" +from flask import Flask, request, jsonify +from flask_cors import CORS +from werkzeug.utils import secure_filename +import os +from datetime import datetime +import traceback +from config.config import Config +from services.content_service import ContentService +from services.poster_service import PosterService +from models.request_models import ContentRequest, PosterRequest +def create_app(): + """Flask 애플리케이션 팩토리""" + app = Flask(__name__) + app.config.from_object(Config) + # CORS 설정 + CORS(app) + # 업로드 폴더 생성 + os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) + os.makedirs(os.path.join(app.config['UPLOAD_FOLDER'], 'temp'), exist_ok=True) + os.makedirs('templates/poster_templates', exist_ok=True) + # 서비스 인스턴스 생성 + content_service = ContentService() + poster_service = PosterService() + @app.route('/health', methods=['GET']) + def health_check(): + """헬스 체크 API""" + return jsonify({ + 'status': 'healthy', + 'timestamp': datetime.now().isoformat(), + 'service': 'AI Marketing Service' + }) + @app.route('/api/content/generate', methods=['POST']) + def generate_content(): + """ + 마케팅 콘텐츠 생성 API + 점주가 입력한 정보를 바탕으로 플랫폼별 맞춤 게시글 생성 + """ + try: + # 요청 데이터 검증 + if not request.form: + return jsonify({'error': '요청 데이터가 없습니다.'}), 400 + # 파일 업로드 처리 + uploaded_files = [] + if 'images' in request.files: + files = request.files.getlist('images') + for file in files: + if file and file.filename: + filename = secure_filename(file.filename) + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + unique_filename = f"{timestamp}_{filename}" + file_path = os.path.join(app.config['UPLOAD_FOLDER'], 'temp', unique_filename) + file.save(file_path) + uploaded_files.append(file_path) + # 요청 모델 생성 + content_request = ContentRequest( + category=request.form.get('category', '음식'), + platform=request.form.get('platform', '인스타그램'), + image_paths=uploaded_files, + start_time=request.form.get('start_time'), + end_time=request.form.get('end_time'), + store_name=request.form.get('store_name', ''), + additional_info=request.form.get('additional_info', '') + ) + # 콘텐츠 생성 + result = content_service.generate_content(content_request) + # 임시 파일 정리 + for file_path in uploaded_files: + try: + os.remove(file_path) + except OSError: + pass + return jsonify(result) + except Exception as e: + # 에러 발생 시 임시 파일 정리 + for file_path in uploaded_files: + try: + os.remove(file_path) + except OSError: + pass + app.logger.error(f"콘텐츠 생성 중 오류 발생: {str(e)}") + app.logger.error(traceback.format_exc()) + return jsonify({'error': f'콘텐츠 생성 중 오류가 발생했습니다: {str(e)}'}), 500 + @app.route('/api/poster/generate', methods=['POST']) + def generate_poster(): + """ + 홍보 포스터 생성 API + 점주가 입력한 정보를 바탕으로 시각적 홍보 포스터 생성 + """ + try: + # 요청 데이터 검증 + if not request.form: + return jsonify({'error': '요청 데이터가 없습니다.'}), 400 + # 파일 업로드 처리 + uploaded_files = [] + if 'images' in request.files: + files = request.files.getlist('images') + for file in files: + if file and file.filename: + filename = secure_filename(file.filename) + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + unique_filename = f"{timestamp}_{filename}" + file_path = os.path.join(app.config['UPLOAD_FOLDER'], 'temp', unique_filename) + file.save(file_path) + uploaded_files.append(file_path) + # 요청 모델 생성 + poster_request = PosterRequest( + category=request.form.get('category', '음식'), + image_paths=uploaded_files, + start_time=request.form.get('start_time'), + end_time=request.form.get('end_time'), + store_name=request.form.get('store_name', ''), + event_title=request.form.get('event_title', ''), + discount_info=request.form.get('discount_info', ''), + additional_info=request.form.get('additional_info', '') + ) + # 포스터 생성 + result = poster_service.generate_poster(poster_request) + # 임시 파일 정리 + for file_path in uploaded_files: + try: + os.remove(file_path) + except OSError: + pass + return jsonify(result) + except Exception as e: + # 에러 발생 시 임시 파일 정리 + for file_path in uploaded_files: + try: + os.remove(file_path) + except OSError: + pass + app.logger.error(f"포스터 생성 중 오류 발생: {str(e)}") + app.logger.error(traceback.format_exc()) + return jsonify({'error': f'포스터 생성 중 오류가 발생했습니다: {str(e)}'}), 500 + @app.errorhandler(413) + def too_large(e): + """파일 크기 초과 에러 처리""" + return jsonify({'error': '업로드된 파일이 너무 큽니다. (최대 16MB)'}), 413 + @app.errorhandler(500) + def internal_error(error): + """내부 서버 에러 처리""" + return jsonify({'error': '내부 서버 오류가 발생했습니다.'}), 500 + return app +if __name__ == '__main__': + app = create_app() + app.run(host='0.0.0.0', port=5000, debug=True) \ No newline at end of file diff --git a/smarketing-ai/config/__init__.py b/smarketing-ai/config/__init__.py new file mode 100644 index 0000000..6ae5294 --- /dev/null +++ b/smarketing-ai/config/__init__.py @@ -0,0 +1 @@ +# Package initialization file diff --git a/smarketing-ai/config/config.py b/smarketing-ai/config/config.py new file mode 100644 index 0000000..de6f276 --- /dev/null +++ b/smarketing-ai/config/config.py @@ -0,0 +1,26 @@ +""" +Flask 애플리케이션 설정 +환경변수를 통한 설정 관리 +""" +import os +from dotenv import load_dotenv +load_dotenv() +class Config: + """애플리케이션 설정 클래스""" + # Flask 기본 설정 + SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production' + # 파일 업로드 설정 + UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER') or 'uploads' + MAX_CONTENT_LENGTH = int(os.environ.get('MAX_CONTENT_LENGTH') or 16 * 1024 * 1024) # 16MB + # AI API 설정 + CLAUDE_API_KEY = os.environ.get('CLAUDE_API_KEY') + OPENAI_API_KEY = os.environ.get('OPENAI_API_KEY') + # 지원되는 파일 확장자 + ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'} + # 템플릿 설정 + POSTER_TEMPLATE_PATH = 'templates/poster_templates' + @staticmethod + def allowed_file(filename): + """업로드 파일 확장자 검증""" + return '.' in filename and \ + filename.rsplit('.', 1)[1].lower() in Config.ALLOWED_EXTENSIONS \ No newline at end of file diff --git a/smarketing-ai/models/__init__.py b/smarketing-ai/models/__init__.py new file mode 100644 index 0000000..6ae5294 --- /dev/null +++ b/smarketing-ai/models/__init__.py @@ -0,0 +1 @@ +# Package initialization file diff --git a/smarketing-ai/models/request_models.py b/smarketing-ai/models/request_models.py new file mode 100644 index 0000000..5abacf6 --- /dev/null +++ b/smarketing-ai/models/request_models.py @@ -0,0 +1,27 @@ +""" +요청 모델 정의 +API 요청 데이터 구조를 정의 +""" +from dataclasses import dataclass +from typing import List, Optional +@dataclass +class ContentRequest: + """마케팅 콘텐츠 생성 요청 모델""" + category: str # 음식, 매장, 이벤트 + platform: str # 네이버 블로그, 인스타그램 + image_paths: List[str] # 업로드된 이미지 파일 경로들 + start_time: Optional[str] = None # 이벤트 시작 시간 + end_time: Optional[str] = None # 이벤트 종료 시간 + store_name: Optional[str] = None # 매장명 + additional_info: Optional[str] = None # 추가 정보 +@dataclass +class PosterRequest: + """홍보 포스터 생성 요청 모델""" + category: str # 음식, 매장, 이벤트 + image_paths: List[str] # 업로드된 이미지 파일 경로들 + start_time: Optional[str] = None # 이벤트 시작 시간 + end_time: Optional[str] = None # 이벤트 종료 시간 + store_name: Optional[str] = None # 매장명 + event_title: Optional[str] = None # 이벤트 제목 + discount_info: Optional[str] = None # 할인 정보 + additional_info: Optional[str] = None # 추가 정보 \ No newline at end of file diff --git a/smarketing-ai/services/__init__.py b/smarketing-ai/services/__init__.py new file mode 100644 index 0000000..6ae5294 --- /dev/null +++ b/smarketing-ai/services/__init__.py @@ -0,0 +1 @@ +# Package initialization file diff --git a/smarketing-ai/services/content_service.py b/smarketing-ai/services/content_service.py new file mode 100644 index 0000000..d856e05 --- /dev/null +++ b/smarketing-ai/services/content_service.py @@ -0,0 +1,200 @@ +""" +마케팅 콘텐츠 생성 서비스 +AI를 활용하여 플랫폼별 맞춤 게시글 생성 +""" +import os +from typing import Dict, Any +from datetime import datetime +from utils.ai_client import AIClient +from utils.image_processor import ImageProcessor +from models.request_models import ContentRequest +class ContentService: + """마케팅 콘텐츠 생성 서비스 클래스""" + def __init__(self): + """서비스 초기화""" + self.ai_client = AIClient() + self.image_processor = ImageProcessor() + # 플랫폼별 콘텐츠 특성 정의 + self.platform_specs = { + '인스타그램': { + 'max_length': 2200, + 'hashtag_count': 15, + 'style': '감성적이고 시각적', + 'format': '짧은 문장, 해시태그 활용' + }, + '네이버 블로그': { + 'max_length': 3000, + 'hashtag_count': 10, + 'style': '정보성과 친근함', + 'format': '구조화된 내용, 상세 설명' + } + } + # 카테고리별 키워드 정의 + self.category_keywords = { + '음식': ['맛집', '신메뉴', '추천', '맛있는', '특별한', '인기'], + '매장': ['분위기', '인테리어', '편안한', '아늑한', '특별한', '방문'], + '이벤트': ['할인', '이벤트', '특가', '한정', '기간한정', '혜택'] + } + def generate_content(self, request: ContentRequest) -> Dict[str, Any]: + """ + 마케팅 콘텐츠 생성 + Args: + request: 콘텐츠 생성 요청 데이터 + Returns: + 생성된 콘텐츠 정보 + """ + try: + # 이미지 분석 + image_analysis = self._analyze_images(request.image_paths) + # AI 프롬프트 생성 + prompt = self._create_content_prompt(request, image_analysis) + # AI로 콘텐츠 생성 + generated_content = self.ai_client.generate_text(prompt) + # 해시태그 생성 + hashtags = self._generate_hashtags(request) + # 최종 콘텐츠 포맷팅 + formatted_content = self._format_content( + generated_content, + hashtags, + request.platform + ) + return { + 'success': True, + 'content': formatted_content, + 'platform': request.platform, + 'category': request.category, + 'generated_at': datetime.now().isoformat(), + 'image_count': len(request.image_paths), + 'image_analysis': image_analysis + } + except Exception as e: + return { + 'success': False, + 'error': str(e), + 'generated_at': datetime.now().isoformat() + } + def _analyze_images(self, image_paths: list) -> Dict[str, Any]: + """ + 업로드된 이미지들 분석 + Args: + image_paths: 이미지 파일 경로 리스트 + Returns: + 이미지 분석 결과 + """ + analysis_results = [] + for image_path in image_paths: + try: + # 이미지 기본 정보 추출 + image_info = self.image_processor.get_image_info(image_path) + # AI를 통한 이미지 내용 분석 + image_description = self.ai_client.analyze_image(image_path) + analysis_results.append({ + 'path': image_path, + 'info': image_info, + 'description': image_description + }) + except Exception as e: + analysis_results.append({ + 'path': image_path, + 'error': str(e) + }) + return { + 'total_images': len(image_paths), + 'results': analysis_results + } + def _create_content_prompt(self, request: ContentRequest, image_analysis: Dict[str, Any]) -> str: + """ + AI 콘텐츠 생성을 위한 프롬프트 생성 + Args: + request: 콘텐츠 생성 요청 + image_analysis: 이미지 분석 결과 + Returns: + AI 프롬프트 문자열 + """ + platform_spec = self.platform_specs.get(request.platform, self.platform_specs['인스타그램']) + category_keywords = self.category_keywords.get(request.category, []) + # 이미지 설명 추출 + image_descriptions = [] + for result in image_analysis.get('results', []): + if 'description' in result: + image_descriptions.append(result['description']) + prompt = f""" +당신은 소상공인을 위한 마케팅 콘텐츠 전문가입니다. +다음 정보를 바탕으로 {request.platform}에 적합한 {request.category} 카테고리의 게시글을 작성해주세요. +**매장 정보:** +- 매장명: {request.store_name or '우리 가게'} +- 카테고리: {request.category} +- 추가 정보: {request.additional_info or '없음'} +**이벤트 정보:** +- 시작 시간: {request.start_time or '상시'} +- 종료 시간: {request.end_time or '상시'} +**이미지 분석 결과:** +{chr(10).join(image_descriptions) if image_descriptions else '이미지 없음'} +**플랫폼 특성:** +- 최대 길이: {platform_spec['max_length']}자 +- 스타일: {platform_spec['style']} +- 형식: {platform_spec['format']} +**요구사항:** +1. {request.platform}의 특성에 맞는 톤앤매너 사용 +2. {request.category} 카테고리에 적합한 내용 구성 +3. 고객의 관심을 끌 수 있는 매력적인 문구 사용 +4. 이미지와 연관된 내용으로 작성 +5. 자연스럽고 친근한 어조 사용 +해시태그는 별도로 생성하므로 본문에는 포함하지 마세요. +""" + return prompt + def _generate_hashtags(self, request: ContentRequest) -> list: + """ + 카테고리와 플랫폼에 맞는 해시태그 생성 + Args: + request: 콘텐츠 생성 요청 + Returns: + 해시태그 리스트 + """ + platform_spec = self.platform_specs.get(request.platform, self.platform_specs['인스타그램']) + category_keywords = self.category_keywords.get(request.category, []) + hashtags = [] + # 기본 해시태그 + if request.store_name: + hashtags.append(f"#{request.store_name.replace(' ', '')}") + # 카테고리별 해시태그 + hashtags.extend([f"#{keyword}" for keyword in category_keywords[:5]]) + # 공통 해시태그 + common_tags = ['#맛집', '#소상공인', '#로컬맛집', '#일상', '#소통'] + hashtags.extend(common_tags) + # 플랫폼별 인기 해시태그 + if request.platform == '인스타그램': + hashtags.extend(['#인스타푸드', '#데일리', '#오늘뭐먹지', '#맛스타그램']) + elif request.platform == '네이버 블로그': + hashtags.extend(['#블로그', '#후기', '#추천', '#정보']) + # 최대 개수 제한 + max_count = platform_spec['hashtag_count'] + return hashtags[:max_count] + def _format_content(self, content: str, hashtags: list, platform: str) -> str: + """ + 플랫폼에 맞게 콘텐츠 포맷팅 + Args: + content: 생성된 콘텐츠 + hashtags: 해시태그 리스트 + platform: 플랫폼명 + Returns: + 포맷팅된 최종 콘텐츠 + """ + platform_spec = self.platform_specs.get(platform, self.platform_specs['인스타그램']) + # 길이 제한 적용 + if len(content) > platform_spec['max_length'] - 100: # 해시태그 공간 확보 + content = content[:platform_spec['max_length'] - 100] + '...' + # 플랫폼별 포맷팅 + if platform == '인스타그램': + # 인스타그램: 본문 + 해시태그 + hashtag_string = ' '.join(hashtags) + formatted = f"{content}\n\n{hashtag_string}" + elif platform == '네이버 블로그': + # 네이버 블로그: 구조화된 형태 + hashtag_string = ' '.join(hashtags) + formatted = f"{content}\n\n---\n{hashtag_string}" + else: + # 기본 형태 + hashtag_string = ' '.join(hashtags) + formatted = f"{content}\n\n{hashtag_string}" + return formatted \ No newline at end of file diff --git a/smarketing-ai/services/poster_service.py b/smarketing-ai/services/poster_service.py new file mode 100644 index 0000000..c1e0245 --- /dev/null +++ b/smarketing-ai/services/poster_service.py @@ -0,0 +1,304 @@ +""" +홍보 포스터 생성 서비스 +AI와 이미지 처리를 활용한 시각적 마케팅 자료 생성 +""" +import os +import base64 +from typing import Dict, Any +from datetime import datetime +from PIL import Image, ImageDraw, ImageFont +from utils.ai_client import AIClient +from utils.image_processor import ImageProcessor +from models.request_models import PosterRequest +class PosterService: + """홍보 포스터 생성 서비스 클래스""" + def __init__(self): + """서비스 초기화""" + self.ai_client = AIClient() + self.image_processor = ImageProcessor() + # 포스터 기본 설정 + self.poster_config = { + 'width': 1080, + 'height': 1350, # 인스타그램 세로 비율 + 'background_color': (255, 255, 255), + 'text_color': (50, 50, 50), + 'accent_color': (255, 107, 107) + } + # 카테고리별 색상 테마 + self.category_themes = { + '음식': { + 'primary': (255, 107, 107), # 빨강 + 'secondary': (255, 206, 84), # 노랑 + 'background': (255, 248, 240) # 크림 + }, + '매장': { + 'primary': (74, 144, 226), # 파랑 + 'secondary': (120, 198, 121), # 초록 + 'background': (248, 251, 255) # 연한 파랑 + }, + '이벤트': { + 'primary': (156, 39, 176), # 보라 + 'secondary': (255, 193, 7), # 금색 + 'background': (252, 248, 255) # 연한 보라 + } + } + def generate_poster(self, request: PosterRequest) -> Dict[str, Any]: + """ + 홍보 포스터 생성 + Args: + request: 포스터 생성 요청 데이터 + Returns: + 생성된 포스터 정보 + """ + try: + # 포스터 텍스트 내용 생성 + poster_text = self._generate_poster_text(request) + # 이미지 전처리 + processed_images = self._process_images(request.image_paths) + # 포스터 이미지 생성 + poster_image = self._create_poster_image(request, poster_text, processed_images) + # 이미지를 base64로 인코딩 + poster_base64 = self._encode_image_to_base64(poster_image) + return { + 'success': True, + 'poster_data': poster_base64, + 'poster_text': poster_text, + 'category': request.category, + 'generated_at': datetime.now().isoformat(), + 'image_count': len(request.image_paths), + 'format': 'base64' + } + except Exception as e: + return { + 'success': False, + 'error': str(e), + 'generated_at': datetime.now().isoformat() + } + def _generate_poster_text(self, request: PosterRequest) -> Dict[str, str]: + """ + 포스터에 들어갈 텍스트 내용 생성 + Args: + request: 포스터 생성 요청 + Returns: + 포스터 텍스트 구성 요소들 + """ + # 이미지 분석 + image_descriptions = [] + for image_path in request.image_paths: + try: + description = self.ai_client.analyze_image(image_path) + image_descriptions.append(description) + except: + continue + # AI 프롬프트 생성 + prompt = f""" +당신은 소상공인을 위한 포스터 카피라이터입니다. +다음 정보를 바탕으로 매력적인 포스터 문구를 작성해주세요. +**매장 정보:** +- 매장명: {request.store_name or '우리 가게'} +- 카테고리: {request.category} +- 추가 정보: {request.additional_info or '없음'} +**이벤트 정보:** +- 이벤트 제목: {request.event_title or '특별 이벤트'} +- 할인 정보: {request.discount_info or '특가 진행'} +- 시작 시간: {request.start_time or '상시'} +- 종료 시간: {request.end_time or '상시'} +**이미지 설명:** +{chr(10).join(image_descriptions) if image_descriptions else '이미지 없음'} +다음 형식으로 응답해주세요: +1. 메인 헤드라인 (10글자 이내, 임팩트 있게) +2. 서브 헤드라인 (20글자 이내, 구체적 혜택) +3. 설명 문구 (30글자 이내, 친근하고 매력적으로) +4. 행동 유도 문구 (15글자 이내, 액션 유도) +각 항목은 줄바꿈으로 구분해서 작성해주세요. +""" + # AI로 텍스트 생성 + generated_text = self.ai_client.generate_text(prompt) + # 생성된 텍스트 파싱 + lines = generated_text.strip().split('\n') + return { + 'main_headline': lines[0] if len(lines) > 0 else request.event_title or '특별 이벤트', + 'sub_headline': lines[1] if len(lines) > 1 else request.discount_info or '지금 바로!', + 'description': lines[2] if len(lines) > 2 else '특별한 혜택을 놓치지 마세요', + 'call_to_action': lines[3] if len(lines) > 3 else '지금 방문하세요!' + } + def _process_images(self, image_paths: list) -> list: + """ + 포스터에 사용할 이미지들 전처리 + Args: + image_paths: 원본 이미지 경로 리스트 + Returns: + 전처리된 이미지 객체 리스트 + """ + processed_images = [] + for image_path in image_paths: + try: + # 이미지 로드 및 리사이즈 + image = Image.open(image_path) + # RGBA로 변환 (투명도 처리) + if image.mode != 'RGBA': + image = image.convert('RGBA') + # 포스터에 맞게 리사이즈 (최대 400x400) + image.thumbnail((400, 400), Image.Resampling.LANCZOS) + processed_images.append(image) + except Exception as e: + print(f"이미지 처리 오류 {image_path}: {e}") + continue + return processed_images + def _create_poster_image(self, request: PosterRequest, poster_text: Dict[str, str], images: list) -> Image.Image: + """ + 실제 포스터 이미지 생성 + Args: + request: 포스터 생성 요청 + poster_text: 포스터 텍스트 + images: 전처리된 이미지 리스트 + Returns: + 생성된 포스터 이미지 + """ + # 카테고리별 테마 적용 + theme = self.category_themes.get(request.category, self.category_themes['음식']) + # 캔버스 생성 + poster = Image.new('RGBA', + (self.poster_config['width'], self.poster_config['height']), + theme['background']) + draw = ImageDraw.Draw(poster) + # 폰트 설정 (시스템 기본 폰트 사용) + try: + # 다양한 폰트 시도 + title_font = ImageFont.truetype("arial.ttf", 60) + subtitle_font = ImageFont.truetype("arial.ttf", 40) + text_font = ImageFont.truetype("arial.ttf", 30) + small_font = ImageFont.truetype("arial.ttf", 24) + except: + # 기본 폰트 사용 + title_font = ImageFont.load_default() + subtitle_font = ImageFont.load_default() + text_font = ImageFont.load_default() + small_font = ImageFont.load_default() + # 레이아웃 계산 + y_pos = 80 + # 1. 메인 헤드라인 + main_headline = poster_text['main_headline'] + bbox = draw.textbbox((0, 0), main_headline, font=title_font) + text_width = bbox[2] - bbox[0] + x_pos = (self.poster_config['width'] - text_width) // 2 + draw.text((x_pos, y_pos), main_headline, + fill=theme['primary'], font=title_font) + y_pos += 100 + # 2. 서브 헤드라인 + sub_headline = poster_text['sub_headline'] + bbox = draw.textbbox((0, 0), sub_headline, font=subtitle_font) + text_width = bbox[2] - bbox[0] + x_pos = (self.poster_config['width'] - text_width) // 2 + draw.text((x_pos, y_pos), sub_headline, + fill=theme['secondary'], font=subtitle_font) + y_pos += 80 + # 3. 이미지 배치 (있는 경우) + if images: + image_y = y_pos + 30 + if len(images) == 1: + # 단일 이미지: 중앙 배치 + img = images[0] + img_x = (self.poster_config['width'] - img.width) // 2 + poster.paste(img, (img_x, image_y), img) + y_pos = image_y + img.height + 50 + elif len(images) == 2: + # 두 개 이미지: 나란히 배치 + total_width = sum(img.width for img in images) + 20 + start_x = (self.poster_config['width'] - total_width) // 2 + for i, img in enumerate(images): + img_x = start_x + (i * (img.width + 20)) + poster.paste(img, (img_x, image_y), img) + y_pos = image_y + max(img.height for img in images) + 50 + else: + # 여러 이미지: 그리드 형태 + cols = 2 + rows = (len(images) + cols - 1) // cols + img_spacing = 20 + for i, img in enumerate(images[:4]): # 최대 4개 + row = i // cols + col = i % cols + img_x = (self.poster_config['width'] // cols) * col + \ + (self.poster_config['width'] // cols - img.width) // 2 + img_y = image_y + row * (200 + img_spacing) + poster.paste(img, (img_x, img_y), img) + y_pos = image_y + rows * (200 + img_spacing) + 30 + # 4. 설명 문구 + description = poster_text['description'] + # 긴 텍스트는 줄바꿈 처리 + words = description.split() + lines = [] + current_line = [] + for word in words: + test_line = ' '.join(current_line + [word]) + bbox = draw.textbbox((0, 0), test_line, font=text_font) + if bbox[2] - bbox[0] < self.poster_config['width'] - 100: + current_line.append(word) + else: + if current_line: + lines.append(' '.join(current_line)) + current_line = [word] + if current_line: + lines.append(' '.join(current_line)) + for line in lines: + bbox = draw.textbbox((0, 0), line, font=text_font) + text_width = bbox[2] - bbox[0] + x_pos = (self.poster_config['width'] - text_width) // 2 + draw.text((x_pos, y_pos), line, fill=(80, 80, 80), font=text_font) + y_pos += 40 + y_pos += 30 + # 5. 기간 정보 (있는 경우) + if request.start_time and request.end_time: + period_text = f"기간: {request.start_time} ~ {request.end_time}" + bbox = draw.textbbox((0, 0), period_text, font=small_font) + text_width = bbox[2] - bbox[0] + x_pos = (self.poster_config['width'] - text_width) // 2 + draw.text((x_pos, y_pos), period_text, fill=(120, 120, 120), font=small_font) + y_pos += 50 + # 6. 행동 유도 문구 (버튼 스타일) + cta_text = poster_text['call_to_action'] + bbox = draw.textbbox((0, 0), cta_text, font=subtitle_font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + # 버튼 배경 + button_width = text_width + 60 + button_height = text_height + 30 + button_x = (self.poster_config['width'] - button_width) // 2 + button_y = self.poster_config['height'] - 150 + draw.rounded_rectangle([button_x, button_y, button_x + button_width, button_y + button_height], + radius=25, fill=theme['primary']) + # 버튼 텍스트 + text_x = button_x + (button_width - text_width) // 2 + text_y = button_y + (button_height - text_height) // 2 + draw.text((text_x, text_y), cta_text, fill=(255, 255, 255), font=subtitle_font) + # 7. 매장명 (하단) + if request.store_name: + store_text = request.store_name + bbox = draw.textbbox((0, 0), store_text, font=text_font) + text_width = bbox[2] - bbox[0] + x_pos = (self.poster_config['width'] - text_width) // 2 + y_pos = self.poster_config['height'] - 50 + draw.text((x_pos, y_pos), store_text, fill=(100, 100, 100), font=text_font) + return poster + def _encode_image_to_base64(self, image: Image.Image) -> str: + """ + PIL 이미지를 base64 문자열로 인코딩 + Args: + image: PIL 이미지 객체 + Returns: + base64 인코딩된 이미지 문자열 + """ + import io + # RGB로 변환 (JPEG 저장을 위해) + if image.mode == 'RGBA': + # 흰색 배경과 합성 + background = Image.new('RGB', image.size, (255, 255, 255)) + background.paste(image, mask=image.split()[-1]) + image = background + # 바이트 스트림으로 변환 + img_buffer = io.BytesIO() + image.save(img_buffer, format='JPEG', quality=90) + img_buffer.seek(0) + # base64 인코딩 + img_base64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8') + return f"data:image/jpeg;base64,{img_base64}" \ No newline at end of file diff --git a/smarketing-ai/utils/__init__.py b/smarketing-ai/utils/__init__.py new file mode 100644 index 0000000..6ae5294 --- /dev/null +++ b/smarketing-ai/utils/__init__.py @@ -0,0 +1 @@ +# Package initialization file diff --git a/smarketing-ai/utils/ai_client.py b/smarketing-ai/utils/ai_client.py new file mode 100644 index 0000000..2695f0c --- /dev/null +++ b/smarketing-ai/utils/ai_client.py @@ -0,0 +1,176 @@ +""" +AI 클라이언트 유틸리티 +Claude AI 및 OpenAI API 호출을 담당 +""" +import os +import base64 +from typing import Optional +import anthropic +import openai +from PIL import Image +import io +class AIClient: + """AI API 클라이언트 클래스""" + def __init__(self): + """AI 클라이언트 초기화""" + self.claude_api_key = os.getenv('CLAUDE_API_KEY') + self.openai_api_key = os.getenv('OPENAI_API_KEY') + # Claude 클라이언트 초기화 + if self.claude_api_key: + self.claude_client = anthropic.Anthropic(api_key=self.claude_api_key) + else: + self.claude_client = None + # OpenAI 클라이언트 초기화 + if self.openai_api_key: + openai.api_key = self.openai_api_key + self.openai_client = openai + else: + self.openai_client = None + def generate_text(self, prompt: str, max_tokens: int = 1000) -> str: + """ + 텍스트 생성 (Claude 우선, 실패시 OpenAI 사용) + Args: + prompt: 생성할 텍스트의 프롬프트 + max_tokens: 최대 토큰 수 + Returns: + 생성된 텍스트 + """ + # Claude AI 시도 + if self.claude_client: + try: + response = self.claude_client.messages.create( + model="claude-3-sonnet-20240229", + max_tokens=max_tokens, + messages=[ + {"role": "user", "content": prompt} + ] + ) + return response.content[0].text + except Exception as e: + print(f"Claude AI 호출 실패: {e}") + # OpenAI 시도 + if self.openai_client: + try: + response = self.openai_client.chat.completions.create( + model="gpt-3.5-turbo", + messages=[ + {"role": "user", "content": prompt} + ], + max_tokens=max_tokens + ) + return response.choices[0].message.content + except Exception as e: + print(f"OpenAI 호출 실패: {e}") + # 기본 응답 (AI 서비스 모두 실패시) + return self._generate_fallback_content(prompt) + def analyze_image(self, image_path: str) -> str: + """ + 이미지 분석 및 설명 생성 + Args: + image_path: 분석할 이미지 경로 + Returns: + 이미지 설명 텍스트 + """ + try: + # 이미지를 base64로 인코딩 + image_base64 = self._encode_image_to_base64(image_path) + # Claude Vision API 시도 + if self.claude_client: + try: + response = self.claude_client.messages.create( + model="claude-3-sonnet-20240229", + max_tokens=500, + messages=[ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "이 이미지를 보고 음식점 마케팅에 활용할 수 있도록 매력적으로 설명해주세요. 음식이라면 맛있어 보이는 특징을, 매장이라면 분위기를, 이벤트라면 특별함을 강조해서 한국어로 50자 이내로 설명해주세요." + }, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/jpeg", + "data": image_base64 + } + } + ] + } + ] + ) + return response.content[0].text + except Exception as e: + print(f"Claude 이미지 분석 실패: {e}") + # OpenAI Vision API 시도 + if self.openai_client: + try: + response = self.openai_client.chat.completions.create( + model="gpt-4-vision-preview", + messages=[ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "이 이미지를 보고 음식점 마케팅에 활용할 수 있도록 매력적으로 설명해주세요. 한국어로 50자 이내로 설명해주세요." + }, + { + "type": "image_url", + "image_url": { + "url": f"data:image/jpeg;base64,{image_base64}" + } + } + ] + } + ], + max_tokens=300 + ) + return response.choices[0].message.content + except Exception as e: + print(f"OpenAI 이미지 분석 실패: {e}") + except Exception as e: + print(f"이미지 분석 전체 실패: {e}") + # 기본 설명 반환 + return "맛있고 매력적인 음식점의 특별한 순간" + def _encode_image_to_base64(self, image_path: str) -> str: + """ + 이미지 파일을 base64로 인코딩 + Args: + image_path: 이미지 파일 경로 + Returns: + base64 인코딩된 이미지 문자열 + """ + with open(image_path, "rb") as image_file: + # 이미지 크기 조정 (API 제한 고려) + image = Image.open(image_file) + # 최대 크기 제한 (1024x1024) + if image.width > 1024 or image.height > 1024: + image.thumbnail((1024, 1024), Image.Resampling.LANCZOS) + # JPEG로 변환하여 파일 크기 줄이기 + if image.mode == 'RGBA': + background = Image.new('RGB', image.size, (255, 255, 255)) + background.paste(image, mask=image.split()[-1]) + image = background + img_buffer = io.BytesIO() + image.save(img_buffer, format='JPEG', quality=85) + img_buffer.seek(0) + return base64.b64encode(img_buffer.getvalue()).decode('utf-8') + def _generate_fallback_content(self, prompt: str) -> str: + """ + AI 서비스 실패시 기본 콘텐츠 생성 + Args: + prompt: 원본 프롬프트 + Returns: + 기본 콘텐츠 + """ + if "콘텐츠" in prompt or "게시글" in prompt: + return """안녕하세요! 오늘도 맛있는 하루 되세요 😊 +우리 가게의 특별한 메뉴를 소개합니다! +정성껏 준비한 음식으로 여러분을 맞이하겠습니다. +많은 관심과 사랑 부탁드려요!""" + elif "포스터" in prompt: + return "특별한 이벤트\n지금 바로 확인하세요\n우리 가게에서 만나요\n놓치지 마세요!" + else: + return "안녕하세요! 우리 가게를 찾아주셔서 감사합니다." \ No newline at end of file diff --git a/smarketing-ai/utils/image_processor.py b/smarketing-ai/utils/image_processor.py new file mode 100644 index 0000000..176c10a --- /dev/null +++ b/smarketing-ai/utils/image_processor.py @@ -0,0 +1,166 @@ +""" +이미지 처리 유틸리티 +이미지 분석, 변환, 최적화 기능 제공 +""" +import os +from typing import Dict, Any, Tuple +from PIL import Image, ImageOps +import io +class ImageProcessor: + """이미지 처리 클래스""" + def __init__(self): + """이미지 프로세서 초기화""" + self.supported_formats = {'JPEG', 'PNG', 'WEBP', 'GIF'} + self.max_size = (2048, 2048) # 최대 크기 + self.thumbnail_size = (400, 400) # 썸네일 크기 + def get_image_info(self, image_path: str) -> Dict[str, Any]: + """ + 이미지 기본 정보 추출 + Args: + image_path: 이미지 파일 경로 + Returns: + 이미지 정보 딕셔너리 + """ + try: + with Image.open(image_path) as image: + info = { + 'filename': os.path.basename(image_path), + 'format': image.format, + 'mode': image.mode, + 'size': image.size, + 'width': image.width, + 'height': image.height, + 'file_size': os.path.getsize(image_path), + 'aspect_ratio': round(image.width / image.height, 2) if image.height > 0 else 0 + } + # 이미지 특성 분석 + info['is_landscape'] = image.width > image.height + info['is_portrait'] = image.height > image.width + info['is_square'] = abs(image.width - image.height) < 50 + return info + except Exception as e: + return { + 'filename': os.path.basename(image_path), + 'error': str(e) + } + def resize_image(self, image_path: str, target_size: Tuple[int, int], + maintain_aspect: bool = True) -> Image.Image: + """ + 이미지 크기 조정 + Args: + image_path: 원본 이미지 경로 + target_size: 목표 크기 (width, height) + maintain_aspect: 종횡비 유지 여부 + Returns: + 리사이즈된 PIL 이미지 + """ + try: + with Image.open(image_path) as image: + if maintain_aspect: + # 종횡비 유지하며 리사이즈 + image.thumbnail(target_size, Image.Resampling.LANCZOS) + return image.copy() + else: + # 강제 리사이즈 + return image.resize(target_size, Image.Resampling.LANCZOS) + except Exception as e: + raise Exception(f"이미지 리사이즈 실패: {str(e)}") + def optimize_image(self, image_path: str, quality: int = 85) -> bytes: + """ + 이미지 최적화 (파일 크기 줄이기) + Args: + image_path: 원본 이미지 경로 + quality: JPEG 품질 (1-100) + Returns: + 최적화된 이미지 바이트 + """ + try: + with Image.open(image_path) as image: + # RGBA를 RGB로 변환 (JPEG 저장을 위해) + if image.mode == 'RGBA': + background = Image.new('RGB', image.size, (255, 255, 255)) + background.paste(image, mask=image.split()[-1]) + image = background + # 크기가 너무 크면 줄이기 + if image.width > self.max_size[0] or image.height > self.max_size[1]: + image.thumbnail(self.max_size, Image.Resampling.LANCZOS) + # 바이트 스트림으로 저장 + img_buffer = io.BytesIO() + image.save(img_buffer, format='JPEG', quality=quality, optimize=True) + return img_buffer.getvalue() + except Exception as e: + raise Exception(f"이미지 최적화 실패: {str(e)}") + def create_thumbnail(self, image_path: str, size: Tuple[int, int] = None) -> Image.Image: + """ + 썸네일 생성 + Args: + image_path: 원본 이미지 경로 + size: 썸네일 크기 (기본값: self.thumbnail_size) + Returns: + 썸네일 PIL 이미지 + """ + if size is None: + size = self.thumbnail_size + try: + with Image.open(image_path) as image: + # 정사각형 썸네일 생성 + thumbnail = ImageOps.fit(image, size, Image.Resampling.LANCZOS) + return thumbnail + except Exception as e: + raise Exception(f"썸네일 생성 실패: {str(e)}") + def analyze_colors(self, image_path: str, num_colors: int = 5) -> list: + """ + 이미지의 주요 색상 추출 + Args: + image_path: 이미지 파일 경로 + num_colors: 추출할 색상 개수 + Returns: + 주요 색상 리스트 [(R, G, B), ...] + """ + try: + with Image.open(image_path) as image: + # RGB로 변환 + if image.mode != 'RGB': + image = image.convert('RGB') + # 이미지 크기 줄여서 처리 속도 향상 + image.thumbnail((150, 150)) + # 색상 히스토그램 생성 + colors = image.getcolors(maxcolors=256*256*256) + if colors: + # 빈도순으로 정렬 + colors.sort(key=lambda x: x[0], reverse=True) + # 상위 색상들 반환 + dominant_colors = [] + for count, color in colors[:num_colors]: + dominant_colors.append(color) + return dominant_colors + return [(128, 128, 128)] # 기본 회색 + except Exception as e: + print(f"색상 분석 실패: {e}") + return [(128, 128, 128)] # 기본 회색 + def is_food_image(self, image_path: str) -> bool: + """ + 음식 이미지 여부 간단 판별 + (실제로는 AI 모델이 필요하지만, 여기서는 기본적인 휴리스틱 사용) + Args: + image_path: 이미지 파일 경로 + Returns: + 음식 이미지 여부 + """ + try: + # 파일명에서 키워드 확인 + filename = os.path.basename(image_path).lower() + food_keywords = ['food', 'meal', 'dish', 'menu', '음식', '메뉴', '요리'] + for keyword in food_keywords: + if keyword in filename: + return True + # 색상 분석으로 간단 판별 (음식은 따뜻한 색조가 많음) + colors = self.analyze_colors(image_path, 3) + warm_color_count = 0 + for r, g, b in colors: + # 따뜻한 색상 (빨강, 노랑, 주황 계열) 확인 + if r > 150 or (r > g and r > b): + warm_color_count += 1 + return warm_color_count >= 2 + except: + return False \ No newline at end of file From 3805de52827e6741768733f1c7c067d8d057ce03 Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 11 Jun 2025 14:49:45 +0900 Subject: [PATCH 11/34] fix: store build --- .../smarketing/store/config/JpaConfig.java | 5 +- .../store/controller/MenuController.java | 2 +- .../store/controller/StoreController.java | 2 +- .../store/dto/MenuUpdateRequest.java | 4 +- .../store/repository/SalesRepository.java | 63 +++++++++----- .../store/service/MenuServiceImpl.java | 2 +- .../store/service/SalesServiceImpl.java | 86 ++++++++++++------- .../store/src/main/resources/application.yml | 12 ++- 8 files changed, 118 insertions(+), 58 deletions(-) diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/config/JpaConfig.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/config/JpaConfig.java index 4efd00c..3c7e2f9 100644 --- a/smarketing-java/store/src/main/java/com/won/smarketing/store/config/JpaConfig.java +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/config/JpaConfig.java @@ -1,5 +1,9 @@ package com.won.smarketing.store.config; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @@ -10,7 +14,6 @@ import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @Configuration @EnableJpaAuditing public class JpaConfig { -}리는 50자 이하여야 합니다") private String category; @Schema(description = "가격", example = "4500", required = true) diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/MenuController.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/MenuController.java index 9f87a8b..e5e3e4f 100644 --- a/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/MenuController.java +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/MenuController.java @@ -12,7 +12,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import javax.validation.Valid; +import jakarta.validation.Valid; import java.util.List; /** diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/StoreController.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/StoreController.java index f4dec35..dbd699e 100644 --- a/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/StoreController.java +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/StoreController.java @@ -12,7 +12,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import javax.validation.Valid; +import jakarta.validation.Valid; /** * 매장 관리를 위한 REST API 컨트롤러 diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java index c10ac54..e597bc5 100644 --- a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java @@ -6,8 +6,8 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import javax.validation.constraints.Min; -import javax.validation.constraints.Size; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Size; /** * 메뉴 수정 요청 DTO diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/repository/SalesRepository.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/repository/SalesRepository.java index c36d1c5..f34b853 100644 --- a/smarketing-java/store/src/main/java/com/won/smarketing/store/repository/SalesRepository.java +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/repository/SalesRepository.java @@ -7,6 +7,8 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; /** * 매출 정보 데이터 접근을 위한 Repository @@ -14,31 +16,52 @@ import java.math.BigDecimal; */ @Repository public interface SalesRepository extends JpaRepository { - + /** - * 매장의 오늘 매출 조회 - * + * 매장의 특정 날짜 매출 조회 + * + * @param storeId 매장 ID + * @param salesDate 매출 날짜 + * @return 해당 날짜 매출 목록 + */ + List findByStoreIdAndSalesDate(Long storeId, LocalDate salesDate); + + /** + * 매장의 특정 기간 매출 조회 + * + * @param storeId 매장 ID + * @param startDate 시작 날짜 + * @param endDate 종료 날짜 + * @return 해당 기간 매출 목록 + */ + List findByStoreIdAndSalesDateBetween(Long storeId, LocalDate startDate, LocalDate endDate); + + /** + * 매장의 오늘 매출 조회 (네이티브 쿼리) + * * @param storeId 매장 ID * @return 오늘 매출 */ - @Query("SELECT COALESCE(SUM(s.salesAmount), 0) FROM Sales s WHERE s.storeId = :storeId AND s.salesDate = CURRENT_DATE") - BigDecimal findTodaySalesByStoreId(@Param("storeId") Long storeId); - + @Query(value = "SELECT COALESCE(SUM(sales_amount), 0) FROM sales WHERE store_id = :storeId AND sales_date = CURRENT_DATE", nativeQuery = true) + BigDecimal findTodaySalesByStoreIdNative(@Param("storeId") Long storeId); + /** - * 매장의 이번 달 매출 조회 - * + * 매장의 어제 매출 조회 (네이티브 쿼리) + * + * @param storeId 매장 ID + * @return 어제 매출 + */ + @Query(value = "SELECT COALESCE(SUM(sales_amount), 0) FROM sales WHERE store_id = :storeId AND sales_date = CURRENT_DATE - INTERVAL '1 day'", nativeQuery = true) + BigDecimal findYesterdaySalesByStoreIdNative(@Param("storeId") Long storeId); + + /** + * 매장의 이번 달 매출 조회 (네이티브 쿼리) + * * @param storeId 매장 ID * @return 이번 달 매출 */ - @Query("SELECT COALESCE(SUM(s.salesAmount), 0) FROM Sales s WHERE s.storeId = :storeId AND YEAR(s.salesDate) = YEAR(CURRENT_DATE) AND MONTH(s.salesDate) = MONTH(CURRENT_DATE)") - BigDecimal findMonthSalesByStoreId(@Param("storeId") Long storeId); - - /** - * 매장의 전일 대비 매출 변화량 조회 - * - * @param storeId 매장 ID - * @return 전일 대비 매출 변화량 - */ - @Query("SELECT COALESCE((SELECT SUM(s1.salesAmount) FROM Sales s1 WHERE s1.storeId = :storeId AND s1.salesDate = CURRENT_DATE) - (SELECT SUM(s2.salesAmount) FROM Sales s2 WHERE s2.storeId = :storeId AND s2.salesDate = CURRENT_DATE - 1), 0)") - BigDecimal findPreviousDayComparisonByStoreId(@Param("storeId") Long storeId); -} + @Query(value = "SELECT COALESCE(SUM(sales_amount), 0) FROM sales WHERE store_id = :storeId " + + "AND EXTRACT(YEAR FROM sales_date) = EXTRACT(YEAR FROM CURRENT_DATE) " + + "AND EXTRACT(MONTH FROM sales_date) = EXTRACT(MONTH FROM CURRENT_DATE)", nativeQuery = true) + BigDecimal findMonthSalesByStoreIdNative(@Param("storeId") Long storeId); +} \ No newline at end of file diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java index 3c66c15..7c31341 100644 --- a/smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java @@ -83,7 +83,7 @@ public class MenuServiceImpl implements MenuService { .orElseThrow(() -> new BusinessException(ErrorCode.MENU_NOT_FOUND)); // 메뉴 정보 업데이트 - menu.updateMenuInfo( + menu.updateMenu( request.getMenuName(), request.getCategory(), request.getPrice(), diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/service/SalesServiceImpl.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/SalesServiceImpl.java index 847b3f9..ded5564 100644 --- a/smarketing-java/store/src/main/java/com/won/smarketing/store/service/SalesServiceImpl.java +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/SalesServiceImpl.java @@ -1,60 +1,84 @@ package com.won.smarketing.store.service; import com.won.smarketing.store.dto.SalesResponse; +import com.won.smarketing.store.entity.Sales; +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; +import java.time.LocalDate; +import java.util.List; /** - * 매출 서비스 구현체 - * 매출 조회 기능 구현 (현재는 Mock 데이터) + * 매출 관리 서비스 구현체 + * 매출 조회 기능 구현 */ -@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class SalesServiceImpl implements SalesService { + private final SalesRepository salesRepository; + /** * 매출 정보 조회 - * 현재는 Mock 데이터를 반환 (실제로는 매출 데이터 조회 로직 필요) - * - * @return 매출 정보 + * + * @return 매출 정보 (오늘, 월간, 전일 대비) */ @Override public SalesResponse getSales() { - log.info("매출 정보 조회"); - - // Mock 데이터 (실제로는 데이터베이스에서 조회) - BigDecimal todaySales = new BigDecimal("150000"); - BigDecimal monthSales = new BigDecimal("4500000"); - BigDecimal yesterdaySales = new BigDecimal("125000"); - BigDecimal targetSales = new BigDecimal("176000"); - - // 전일 대비 변화 계산 + // TODO: 현재는 더미 데이터 반환, 실제로는 현재 로그인한 사용자의 매장 ID를 사용해야 함 + Long storeId = 1L; // 임시로 설정 + + // 오늘 매출 계산 + BigDecimal todaySales = calculateSalesByDate(storeId, LocalDate.now()); + + // 이번 달 매출 계산 + BigDecimal monthSales = calculateMonthSales(storeId); + + // 어제 매출 계산 + BigDecimal yesterdaySales = calculateSalesByDate(storeId, LocalDate.now().minusDays(1)); + + // 전일 대비 매출 변화량 계산 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; - + return SalesResponse.builder() .todaySales(todaySales) .monthSales(monthSales) .previousDayComparison(previousDayComparison) - .previousDayChangeRate(previousDayChangeRate) - .goalAchievementRate(goalAchievementRate) .build(); } -} + /** + * 특정 날짜의 매출 계산 + * + * @param storeId 매장 ID + * @param date 날짜 + * @return 해당 날짜 매출 + */ + private BigDecimal calculateSalesByDate(Long storeId, LocalDate date) { + List salesList = salesRepository.findByStoreIdAndSalesDate(storeId, date); + return salesList.stream() + .map(Sales::getSalesAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + /** + * 이번 달 매출 계산 + * + * @param storeId 매장 ID + * @return 이번 달 매출 + */ + private BigDecimal calculateMonthSales(Long storeId) { + LocalDate now = LocalDate.now(); + LocalDate startOfMonth = now.withDayOfMonth(1); + LocalDate endOfMonth = now.withDayOfMonth(now.lengthOfMonth()); + + List salesList = salesRepository.findByStoreIdAndSalesDateBetween(storeId, startOfMonth, endOfMonth); + return salesList.stream() + .map(Sales::getSalesAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } +} \ No newline at end of file diff --git a/smarketing-java/store/src/main/resources/application.yml b/smarketing-java/store/src/main/resources/application.yml index 800c27d..8140918 100644 --- a/smarketing-java/store/src/main/resources/application.yml +++ b/smarketing-java/store/src/main/resources/application.yml @@ -7,7 +7,7 @@ spring: application: name: store-service datasource: - url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:storedb} + url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:StoreDB} username: ${POSTGRES_USER:postgres} password: ${POSTGRES_PASSWORD:postgres} jpa: @@ -18,6 +18,11 @@ spring: hibernate: dialect: org.hibernate.dialect.PostgreSQLDialect format_sql: true + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} springdoc: swagger-ui: @@ -29,3 +34,8 @@ springdoc: logging: level: com.won.smarketing.store: ${LOG_LEVEL:DEBUG} + +jwt: + secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789} + access-token-validity: ${JWT_ACCESS_VALIDITY:3600000} + refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000} \ No newline at end of file From b854885d2eb648d6fc581c67275975006b40cce4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=9C=EC=9D=80?= <61147083+BangSun98@users.noreply.github.com> Date: Wed, 11 Jun 2025 15:15:20 +0900 Subject: [PATCH 12/34] Update application.yml update store yml --- .../store/src/main/resources/application.yml | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/smarketing-java/store/src/main/resources/application.yml b/smarketing-java/store/src/main/resources/application.yml index 8140918..8e20d9c 100644 --- a/smarketing-java/store/src/main/resources/application.yml +++ b/smarketing-java/store/src/main/resources/application.yml @@ -1,7 +1,5 @@ server: port: ${SERVER_PORT:8082} - servlet: - context-path: / spring: application: @@ -10,10 +8,11 @@ spring: url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:StoreDB} username: ${POSTGRES_USER:postgres} password: ${POSTGRES_PASSWORD:postgres} + driver-class-name: org.postgresql.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 @@ -24,13 +23,6 @@ spring: port: ${REDIS_PORT:6379} password: ${REDIS_PASSWORD:} -springdoc: - swagger-ui: - path: /swagger-ui.html - operations-sorter: method - api-docs: - path: /api-docs - logging: level: com.won.smarketing.store: ${LOG_LEVEL:DEBUG} From 885fcf0977d6b37be7d8ee711cad970ceafbdc06 Mon Sep 17 00:00:00 2001 From: OhSeongRak Date: Wed, 11 Jun 2025 16:06:52 +0900 Subject: [PATCH 13/34] =?UTF-8?q?style:=20=EC=BD=94=EB=93=9C=20=ED=8F=AC?= =?UTF-8?q?=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- smarketing-ai/app.py | 12 ++++++- smarketing-ai/services/content_service.py | 14 +++++++-- smarketing-ai/services/poster_service.py | 38 ++++++++++++++--------- 3 files changed, 45 insertions(+), 19 deletions(-) diff --git a/smarketing-ai/app.py b/smarketing-ai/app.py index 0592c23..abdb87b 100644 --- a/smarketing-ai/app.py +++ b/smarketing-ai/app.py @@ -12,6 +12,8 @@ from config.config import Config from services.content_service import ContentService from services.poster_service import PosterService from models.request_models import ContentRequest, PosterRequest + + def create_app(): """Flask 애플리케이션 팩토리""" app = Flask(__name__) @@ -25,6 +27,7 @@ def create_app(): # 서비스 인스턴스 생성 content_service = ContentService() poster_service = PosterService() + @app.route('/health', methods=['GET']) def health_check(): """헬스 체크 API""" @@ -33,6 +36,7 @@ def create_app(): 'timestamp': datetime.now().isoformat(), 'service': 'AI Marketing Service' }) + @app.route('/api/content/generate', methods=['POST']) def generate_content(): """ @@ -84,6 +88,7 @@ def create_app(): app.logger.error(f"콘텐츠 생성 중 오류 발생: {str(e)}") app.logger.error(traceback.format_exc()) return jsonify({'error': f'콘텐츠 생성 중 오류가 발생했습니다: {str(e)}'}), 500 + @app.route('/api/poster/generate', methods=['POST']) def generate_poster(): """ @@ -136,15 +141,20 @@ def create_app(): app.logger.error(f"포스터 생성 중 오류 발생: {str(e)}") app.logger.error(traceback.format_exc()) return jsonify({'error': f'포스터 생성 중 오류가 발생했습니다: {str(e)}'}), 500 + @app.errorhandler(413) def too_large(e): """파일 크기 초과 에러 처리""" return jsonify({'error': '업로드된 파일이 너무 큽니다. (최대 16MB)'}), 413 + @app.errorhandler(500) def internal_error(error): """내부 서버 에러 처리""" return jsonify({'error': '내부 서버 오류가 발생했습니다.'}), 500 + return app + + if __name__ == '__main__': app = create_app() - app.run(host='0.0.0.0', port=5000, debug=True) \ No newline at end of file + app.run(host='0.0.0.0', port=5000, debug=True) diff --git a/smarketing-ai/services/content_service.py b/smarketing-ai/services/content_service.py index d856e05..34dc36b 100644 --- a/smarketing-ai/services/content_service.py +++ b/smarketing-ai/services/content_service.py @@ -8,8 +8,11 @@ from datetime import datetime from utils.ai_client import AIClient from utils.image_processor import ImageProcessor from models.request_models import ContentRequest + + class ContentService: """마케팅 콘텐츠 생성 서비스 클래스""" + def __init__(self): """서비스 초기화""" self.ai_client = AIClient() @@ -35,6 +38,7 @@ class ContentService: '매장': ['분위기', '인테리어', '편안한', '아늑한', '특별한', '방문'], '이벤트': ['할인', '이벤트', '특가', '한정', '기간한정', '혜택'] } + def generate_content(self, request: ContentRequest) -> Dict[str, Any]: """ 마케팅 콘텐츠 생성 @@ -54,8 +58,8 @@ class ContentService: hashtags = self._generate_hashtags(request) # 최종 콘텐츠 포맷팅 formatted_content = self._format_content( - generated_content, - hashtags, + generated_content, + hashtags, request.platform ) return { @@ -73,6 +77,7 @@ class ContentService: 'error': str(e), 'generated_at': datetime.now().isoformat() } + def _analyze_images(self, image_paths: list) -> Dict[str, Any]: """ 업로드된 이미지들 분석 @@ -102,6 +107,7 @@ class ContentService: 'total_images': len(image_paths), 'results': analysis_results } + def _create_content_prompt(self, request: ContentRequest, image_analysis: Dict[str, Any]) -> str: """ AI 콘텐츠 생성을 위한 프롬프트 생성 @@ -143,6 +149,7 @@ class ContentService: 해시태그는 별도로 생성하므로 본문에는 포함하지 마세요. """ return prompt + def _generate_hashtags(self, request: ContentRequest) -> list: """ 카테고리와 플랫폼에 맞는 해시태그 생성 @@ -170,6 +177,7 @@ class ContentService: # 최대 개수 제한 max_count = platform_spec['hashtag_count'] return hashtags[:max_count] + def _format_content(self, content: str, hashtags: list, platform: str) -> str: """ 플랫폼에 맞게 콘텐츠 포맷팅 @@ -197,4 +205,4 @@ class ContentService: # 기본 형태 hashtag_string = ' '.join(hashtags) formatted = f"{content}\n\n{hashtag_string}" - return formatted \ No newline at end of file + return formatted diff --git a/smarketing-ai/services/poster_service.py b/smarketing-ai/services/poster_service.py index c1e0245..fc27adb 100644 --- a/smarketing-ai/services/poster_service.py +++ b/smarketing-ai/services/poster_service.py @@ -10,8 +10,11 @@ from PIL import Image, ImageDraw, ImageFont from utils.ai_client import AIClient from utils.image_processor import ImageProcessor from models.request_models import PosterRequest + + class PosterService: """홍보 포스터 생성 서비스 클래스""" + def __init__(self): """서비스 초기화""" self.ai_client = AIClient() @@ -27,21 +30,22 @@ class PosterService: # 카테고리별 색상 테마 self.category_themes = { '음식': { - 'primary': (255, 107, 107), # 빨강 - 'secondary': (255, 206, 84), # 노랑 + 'primary': (255, 107, 107), # 빨강 + 'secondary': (255, 206, 84), # 노랑 'background': (255, 248, 240) # 크림 }, '매장': { - 'primary': (74, 144, 226), # 파랑 + 'primary': (74, 144, 226), # 파랑 'secondary': (120, 198, 121), # 초록 'background': (248, 251, 255) # 연한 파랑 }, '이벤트': { - 'primary': (156, 39, 176), # 보라 - 'secondary': (255, 193, 7), # 금색 + 'primary': (156, 39, 176), # 보라 + 'secondary': (255, 193, 7), # 금색 'background': (252, 248, 255) # 연한 보라 } } + def generate_poster(self, request: PosterRequest) -> Dict[str, Any]: """ 홍보 포스터 생성 @@ -74,6 +78,7 @@ class PosterService: 'error': str(e), 'generated_at': datetime.now().isoformat() } + def _generate_poster_text(self, request: PosterRequest) -> Dict[str, str]: """ 포스터에 들어갈 텍스트 내용 생성 @@ -122,6 +127,7 @@ class PosterService: 'description': lines[2] if len(lines) > 2 else '특별한 혜택을 놓치지 마세요', 'call_to_action': lines[3] if len(lines) > 3 else '지금 방문하세요!' } + def _process_images(self, image_paths: list) -> list: """ 포스터에 사용할 이미지들 전처리 @@ -145,6 +151,7 @@ class PosterService: print(f"이미지 처리 오류 {image_path}: {e}") continue return processed_images + def _create_poster_image(self, request: PosterRequest, poster_text: Dict[str, str], images: list) -> Image.Image: """ 실제 포스터 이미지 생성 @@ -158,9 +165,9 @@ class PosterService: # 카테고리별 테마 적용 theme = self.category_themes.get(request.category, self.category_themes['음식']) # 캔버스 생성 - poster = Image.new('RGBA', - (self.poster_config['width'], self.poster_config['height']), - theme['background']) + poster = Image.new('RGBA', + (self.poster_config['width'], self.poster_config['height']), + theme['background']) draw = ImageDraw.Draw(poster) # 폰트 설정 (시스템 기본 폰트 사용) try: @@ -182,16 +189,16 @@ class PosterService: bbox = draw.textbbox((0, 0), main_headline, font=title_font) text_width = bbox[2] - bbox[0] x_pos = (self.poster_config['width'] - text_width) // 2 - draw.text((x_pos, y_pos), main_headline, - fill=theme['primary'], font=title_font) + draw.text((x_pos, y_pos), main_headline, + fill=theme['primary'], font=title_font) y_pos += 100 # 2. 서브 헤드라인 sub_headline = poster_text['sub_headline'] bbox = draw.textbbox((0, 0), sub_headline, font=subtitle_font) text_width = bbox[2] - bbox[0] x_pos = (self.poster_config['width'] - text_width) // 2 - draw.text((x_pos, y_pos), sub_headline, - fill=theme['secondary'], font=subtitle_font) + draw.text((x_pos, y_pos), sub_headline, + fill=theme['secondary'], font=subtitle_font) y_pos += 80 # 3. 이미지 배치 (있는 경우) if images: @@ -219,7 +226,7 @@ class PosterService: row = i // cols col = i % cols img_x = (self.poster_config['width'] // cols) * col + \ - (self.poster_config['width'] // cols - img.width) // 2 + (self.poster_config['width'] // cols - img.width) // 2 img_y = image_y + row * (200 + img_spacing) poster.paste(img, (img_x, img_y), img) y_pos = image_y + rows * (200 + img_spacing) + 30 @@ -266,7 +273,7 @@ class PosterService: button_x = (self.poster_config['width'] - button_width) // 2 button_y = self.poster_config['height'] - 150 draw.rounded_rectangle([button_x, button_y, button_x + button_width, button_y + button_height], - radius=25, fill=theme['primary']) + radius=25, fill=theme['primary']) # 버튼 텍스트 text_x = button_x + (button_width - text_width) // 2 text_y = button_y + (button_height - text_height) // 2 @@ -280,6 +287,7 @@ class PosterService: y_pos = self.poster_config['height'] - 50 draw.text((x_pos, y_pos), store_text, fill=(100, 100, 100), font=text_font) return poster + def _encode_image_to_base64(self, image: Image.Image) -> str: """ PIL 이미지를 base64 문자열로 인코딩 @@ -301,4 +309,4 @@ class PosterService: img_buffer.seek(0) # base64 인코딩 img_base64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8') - return f"data:image/jpeg;base64,{img_base64}" \ No newline at end of file + return f"data:image/jpeg;base64,{img_base64}" From b7c1f89269619ea7834eb1ef549a4340f2f6b8c2 Mon Sep 17 00:00:00 2001 From: OhSeongRak Date: Wed, 11 Jun 2025 16:07:05 +0900 Subject: [PATCH 14/34] feat: init --- smarketing-ai/.gitignore | 2 ++ smarketing-ai/Dockerfile | 25 +++++++++++++++++++++++++ smarketing-ai/requirements.txt | 8 ++++++++ 3 files changed, 35 insertions(+) create mode 100644 smarketing-ai/.gitignore create mode 100644 smarketing-ai/Dockerfile create mode 100644 smarketing-ai/requirements.txt diff --git a/smarketing-ai/.gitignore b/smarketing-ai/.gitignore new file mode 100644 index 0000000..3bf780b --- /dev/null +++ b/smarketing-ai/.gitignore @@ -0,0 +1,2 @@ +.idea +.env \ No newline at end of file diff --git a/smarketing-ai/Dockerfile b/smarketing-ai/Dockerfile new file mode 100644 index 0000000..897d2e0 --- /dev/null +++ b/smarketing-ai/Dockerfile @@ -0,0 +1,25 @@ +# Dockerfile +FROM python:3.11-slim + +WORKDIR /app + +# 시스템 패키지 설치 +RUN apt-get update && apt-get install -y \ + fonts-dejavu-core \ + && rm -rf /var/lib/apt/lists/* + +# Python 의존성 설치 +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# 애플리케이션 코드 복사 +COPY . . + +# 업로드 디렉토리 생성 +RUN mkdir -p uploads/temp templates/poster_templates + +# 포트 노출 +EXPOSE 5000 + +# 애플리케이션 실행 +CMD ["python", "app.py"] \ No newline at end of file diff --git a/smarketing-ai/requirements.txt b/smarketing-ai/requirements.txt new file mode 100644 index 0000000..f3f76cd --- /dev/null +++ b/smarketing-ai/requirements.txt @@ -0,0 +1,8 @@ +Flask==3.0.0 +Flask-CORS==4.0.0 +Pillow>=9.0.0 +requests==2.31.0 +anthropic==0.8.1 +openai==1.6.1 +python-dotenv==1.0.0 +Werkzeug==3.0.1 \ No newline at end of file From c5a7254ce51e645c37867c19ccaae8d68b02c27a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=9C=EC=9D=80?= <61147083+BangSun98@users.noreply.github.com> Date: Wed, 11 Jun 2025 16:30:30 +0900 Subject: [PATCH 15/34] update marketing-content update marketing-content --- .../marketing-content/build.gradle | 2 +- .../service/ContentQueryService.java | 22 +-- .../service/PosterContentService.java | 4 +- .../service/SnsContentService.java | 6 +- .../content/domain/model/Content.java | 18 ++ .../domain/model/CreationConditions.java | 10 ++ .../controller/ContentController.java | 2 +- .../presentation/dto/ContentResponse.java | 5 +- .../dto/PosterContentCreateRequest.java | 28 +++ .../dto/PosterContentCreateResponse.java | 12 +- .../dto/PosterContentSaveRequest.java | 43 ++++- .../dto/SnsContentCreateRequest.java | 160 ++++++++++++++++++ .../dto/SnsContentCreateResponse.java | 3 + .../dto/SnsContentSaveRequest.java | 44 +++++ .../src/main/resources/application.yml | 17 +- smarketing-java/member/build.gradle | 3 +- 16 files changed, 341 insertions(+), 38 deletions(-) create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateRequest.java diff --git a/smarketing-java/marketing-content/build.gradle b/smarketing-java/marketing-content/build.gradle index 771a2fc..188d7bd 100644 --- a/smarketing-java/marketing-content/build.gradle +++ b/smarketing-java/marketing-content/build.gradle @@ -1,4 +1,4 @@ dependencies { implementation project(':common') - runtimeOnly 'com.mysql:mysql-connector-j' + runtimeOnly 'org.postgresql:postgresql' } \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/ContentQueryService.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/ContentQueryService.java index a84e0a5..c196e58 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/ContentQueryService.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/ContentQueryService.java @@ -40,18 +40,18 @@ public class ContentQueryService implements ContentQueryUseCase { // 제목과 기간 업데이트 content.updateTitle(request.getTitle()); - content.updatePeriod(request.getStartDate(), request.getEndDate()); + content.updatePeriod(request.getPromotionStartDate(), request.getPromotionEndDate()); Content updatedContent = contentRepository.save(content); return ContentUpdateResponse.builder() - .contentId(updatedContent.getId().getValue()) - .contentType(updatedContent.getContentType().name()) - .platform(updatedContent.getPlatform().name()) + .contentId(updatedContent.getId()) + //.contentType(updatedContent.getContentType().name()) + //.platform(updatedContent.getPlatform().name()) .title(updatedContent.getTitle()) .content(updatedContent.getContent()) - .hashtags(updatedContent.getHashtags()) - .images(updatedContent.getImages()) + //.hashtags(updatedContent.getHashtags()) + //.images(updatedContent.getImages()) .status(updatedContent.getStatus().name()) .updatedAt(updatedContent.getUpdatedAt()) .build(); @@ -105,7 +105,7 @@ public class ContentQueryService implements ContentQueryUseCase { .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); return ContentDetailResponse.builder() - .contentId(content.getId().getValue()) + .contentId(content.getId()) .contentType(content.getContentType().name()) .platform(content.getPlatform().name()) .title(content.getTitle()) @@ -140,7 +140,7 @@ public class ContentQueryService implements ContentQueryUseCase { */ private ContentResponse toContentResponse(Content content) { return ContentResponse.builder() - .contentId(content.getId().getValue()) + .contentId(content.getId()) .contentType(content.getContentType().name()) .platform(content.getPlatform().name()) .title(content.getTitle()) @@ -161,13 +161,13 @@ public class ContentQueryService implements ContentQueryUseCase { */ private OngoingContentResponse toOngoingContentResponse(Content content) { return OngoingContentResponse.builder() - .contentId(content.getId().getValue()) + .contentId(content.getId()) .contentType(content.getContentType().name()) .platform(content.getPlatform().name()) .title(content.getTitle()) .status(content.getStatus().name()) - .createdAt(content.getCreatedAt()) - .viewCount(0) // TODO: 실제 조회 수 구현 필요 + .promotionStartDate(content.getPromotionStartDate()) + //.viewCount(0) // TODO: 실제 조회 수 구현 필요 .build(); } diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/PosterContentService.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/PosterContentService.java index c444d75..4db4d8a 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/PosterContentService.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/PosterContentService.java @@ -61,10 +61,10 @@ public class PosterContentService implements PosterContentUseCase { .contentId(null) // 임시 생성이므로 ID 없음 .contentType(ContentType.POSTER.name()) .title(request.getTitle()) - .image(generatedPoster) + .posterImage(generatedPoster) .posterSizes(posterSizes) .status(ContentStatus.DRAFT.name()) - .createdAt(LocalDateTime.now()) + //.createdAt(LocalDateTime.now()) .build(); } diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java index 444be06..fec5d4e 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java @@ -39,7 +39,7 @@ public class SnsContentService implements SnsContentUseCase { */ @Override @Transactional -/* public SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request) { + public SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request) { // AI를 사용하여 SNS 콘텐츠 생성 String generatedContent = aiContentGenerator.generateSnsContent(request); @@ -80,11 +80,11 @@ public class SnsContentService implements SnsContentUseCase { .title(content.getTitle()) .content(content.getContent()) .hashtags(content.getHashtags()) - .images(content.getImages()) + .fixedImages(content.getImages()) .status(content.getStatus().name()) .createdAt(content.getCreatedAt()) .build(); - }*/ + } /** * SNS 콘텐츠 저장 diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java index 440fd77..c83f178 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java @@ -216,6 +216,24 @@ public class Content { this.promotionEndDate = endDate; } + /** + * 홍보 기간 설정 + * + * 비즈니스 규칙: + * - 시작일은 종료일보다 이전이어야 함 + * - 과거 날짜로 설정 불가 (현재 시간 기준) + * + * @param startDate 홍보 시작일 + * @param endDate 홍보 종료일 + * @throws IllegalArgumentException 날짜가 유효하지 않은 경우 + */ + public void updatePeriod(LocalDateTime startDate, LocalDateTime endDate) { + validatePromotionPeriod(startDate, endDate); + + this.promotionStartDate = startDate; + this.promotionEndDate = endDate; + } + /** * 해시태그 추가 * diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java index b76a152..cf3c04e 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java @@ -53,4 +53,14 @@ public class CreationConditions { * 사진 스타일 (포스터용) */ private String photoStyle; + + /** + * 타겟 고객 + */ + private String targetAudience; + + /** + * 프로모션 타입 + */ + private String promotionType; } diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/controller/ContentController.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/controller/ContentController.java index e65842a..4feb6b7 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/controller/ContentController.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/controller/ContentController.java @@ -12,7 +12,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import javax.validation.Valid; +import jakarta.validation.Valid; import java.util.List; /** diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentResponse.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentResponse.java index c3ff5c3..964f4a2 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentResponse.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentResponse.java @@ -98,6 +98,9 @@ public class ContentResponse { @Schema(description = "해시태그 개수", example = "8") private Integer hashtagCount; + @Schema(description = "조회수", example = "8") + private Integer viewCount; + // ==================== 비즈니스 메서드 ==================== /** @@ -227,7 +230,7 @@ public class ContentResponse { */ public static ContentResponse fromDomain(com.won.smarketing.content.domain.model.Content content) { ContentResponseBuilder builder = ContentResponse.builder() - .contentId(content.getId().getValue()) + .contentId(content.getId()) .contentType(content.getContentType().name()) .platform(content.getPlatform().name()) .title(content.getTitle()) diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateRequest.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateRequest.java index 1e3a406..3ea3a15 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateRequest.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateRequest.java @@ -1,3 +1,4 @@ +// smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateRequest.java package com.won.smarketing.content.presentation.dto; import io.swagger.v3.oas.annotations.media.Schema; @@ -8,6 +9,7 @@ import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; @@ -20,6 +22,13 @@ import java.util.List; @Schema(description = "포스터 콘텐츠 생성 요청") public class PosterContentCreateRequest { + @Schema(description = "매장 ID", example = "1", required = true) + @NotNull(message = "매장 ID는 필수입니다") + private Long storeId; + + @Schema(description = "제목", example = "특별 이벤트 안내") + private String title; + @Schema(description = "홍보 대상", example = "메뉴", required = true) @NotBlank(message = "홍보 대상은 필수입니다") private String targetAudience; @@ -48,4 +57,23 @@ public class PosterContentCreateRequest { @NotNull(message = "이미지는 1개 이상 필수입니다") @Size(min = 1, message = "이미지는 1개 이상 업로드해야 합니다") private List images; + + // CreationConditions에 필요한 필드들 + @Schema(description = "콘텐츠 카테고리", example = "이벤트") + private String category; + + @Schema(description = "구체적인 요구사항", example = "신메뉴 출시 이벤트 포스터를 만들어주세요") + private String requirement; + + @Schema(description = "톤앤매너", example = "전문적") + private String toneAndManner; + + @Schema(description = "이벤트 시작일", example = "2024-01-15") + private LocalDate startDate; + + @Schema(description = "이벤트 종료일", example = "2024-01-31") + private LocalDate endDate; + + @Schema(description = "사진 스타일", example = "밝고 화사한") + private String photoStyle; } \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateResponse.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateResponse.java index 04bb601..0c02b68 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateResponse.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateResponse.java @@ -7,6 +7,7 @@ import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; +import java.util.Map; /** * 포스터 콘텐츠 생성 응답 DTO @@ -27,8 +28,11 @@ public class PosterContentCreateResponse { @Schema(description = "생성된 포스터 텍스트 내용") private String content; - @Schema(description = "포스터 이미지 URL 목록") - private List posterImages; + @Schema(description = "생성된 포스터 타입") + private String contentType; + + @Schema(description = "포스터 이미지 URL") + private String posterImage; @Schema(description = "원본 이미지 URL 목록") private List originalImages; @@ -38,4 +42,8 @@ public class PosterContentCreateResponse { @Schema(description = "생성 상태", example = "DRAFT") private String status; + + @Schema(description = "포스터사이즈", example = "800x600") + private Map posterSizes; + } \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentSaveRequest.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentSaveRequest.java index a7bd715..5335d11 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentSaveRequest.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentSaveRequest.java @@ -1,3 +1,4 @@ +// smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentSaveRequest.java package com.won.smarketing.content.presentation.dto; import io.swagger.v3.oas.annotations.media.Schema; @@ -6,6 +7,9 @@ import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +import java.time.LocalDate; +import java.util.List; + /** * 포스터 콘텐츠 저장 요청 DTO */ @@ -19,15 +23,44 @@ public class PosterContentSaveRequest { @NotNull(message = "콘텐츠 ID는 필수입니다") private Long contentId; - @Schema(description = "최종 제목", example = "특별 이벤트 안내") - private String finalTitle; + @Schema(description = "매장 ID", example = "1", required = true) + @NotNull(message = "매장 ID는 필수입니다") + private Long storeId; - @Schema(description = "최종 콘텐츠 내용") - private String finalContent; + @Schema(description = "제목", example = "특별 이벤트 안내") + private String title; + + @Schema(description = "콘텐츠 내용") + private String content; @Schema(description = "선택된 포스터 이미지 URL") - private String selectedPosterImage; + private List images; @Schema(description = "발행 상태", example = "PUBLISHED") private String status; + + // CreationConditions에 필요한 필드들 + @Schema(description = "콘텐츠 카테고리", example = "이벤트") + private String category; + + @Schema(description = "구체적인 요구사항", example = "신메뉴 출시 이벤트 포스터를 만들어주세요") + private String requirement; + + @Schema(description = "톤앤매너", example = "전문적") + private String toneAndManner; + + @Schema(description = "감정 강도", example = "보통") + private String emotionIntensity; + + @Schema(description = "이벤트명", example = "신메뉴 출시 이벤트") + private String eventName; + + @Schema(description = "이벤트 시작일", example = "2024-01-15") + private LocalDate startDate; + + @Schema(description = "이벤트 종료일", example = "2024-01-31") + private LocalDate endDate; + + @Schema(description = "사진 스타일", example = "밝고 화사한") + private String photoStyle; } \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateRequest.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateRequest.java new file mode 100644 index 0000000..70235b5 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateRequest.java @@ -0,0 +1,160 @@ +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +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 java.time.LocalDate; +import java.util.List; + +/** + * SNS 콘텐츠 생성 요청 DTO + * + * AI 기반 SNS 콘텐츠 생성을 위한 요청 정보를 담고 있습니다. + * 사용자가 입력한 생성 조건을 바탕으로 AI가 적절한 SNS 콘텐츠를 생성합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "SNS 콘텐츠 생성 요청") +public class SnsContentCreateRequest { + + // ==================== 기본 정보 ==================== + + @Schema(description = "매장 ID", example = "1", required = true) + @NotNull(message = "매장 ID는 필수입니다") + private Long storeId; + + @Schema(description = "대상 플랫폼", + example = "INSTAGRAM", + allowableValues = {"INSTAGRAM", "NAVER_BLOG", "FACEBOOK", "KAKAO_STORY"}, + required = true) + @NotBlank(message = "플랫폼은 필수입니다") + private String platform; + + @Schema(description = "콘텐츠 제목", example = "1", required = true) + @NotNull(message = "콘텐츠 제목은 필수입니다") + private String title; + + // ==================== 콘텐츠 생성 조건 ==================== + + @Schema(description = "콘텐츠 카테고리", + example = "메뉴소개", + allowableValues = {"메뉴소개", "이벤트", "일상", "인테리어", "고객후기", "기타"}) + private String category; + + @Schema(description = "구체적인 요구사항 또는 홍보하고 싶은 내용", + example = "새로 출시된 시그니처 버거를 홍보하고 싶어요") + @Size(max = 500, message = "요구사항은 500자 이하로 입력해주세요") + private String requirement; + + @Schema(description = "톤앤매너", + example = "친근함", + allowableValues = {"친근함", "전문적", "유머러스", "감성적", "트렌디"}) + private String toneAndManner; + + @Schema(description = "감정 강도", + example = "보통", + allowableValues = {"약함", "보통", "강함"}) + private String emotionIntensity; + + // ==================== 이벤트 정보 ==================== + + @Schema(description = "이벤트명 (이벤트 콘텐츠인 경우)", + example = "신메뉴 출시 이벤트") + @Size(max = 200, message = "이벤트명은 200자 이하로 입력해주세요") + private String eventName; + + @Schema(description = "이벤트 시작일 (이벤트 콘텐츠인 경우)", + example = "2024-01-15") + private LocalDate startDate; + + @Schema(description = "이벤트 종료일 (이벤트 콘텐츠인 경우)", + example = "2024-01-31") + private LocalDate endDate; + + // ==================== 미디어 정보 ==================== + + @Schema(description = "업로드된 이미지 파일 경로 목록") + private List images; + + @Schema(description = "사진 스타일 선호도", + example = "밝고 화사한", + allowableValues = {"밝고 화사한", "차분하고 세련된", "빈티지한", "모던한", "자연스러운"}) + private String photoStyle; + + // ==================== 추가 옵션 ==================== + + @Schema(description = "해시태그 포함 여부", example = "true") + @Builder.Default + private Boolean includeHashtags = true; + + @Schema(description = "이모지 포함 여부", example = "true") + @Builder.Default + private Boolean includeEmojis = true; + + @Schema(description = "콜투액션 포함 여부 (좋아요, 팔로우 요청 등)", example = "true") + @Builder.Default + private Boolean includeCallToAction = true; + + @Schema(description = "매장 위치 정보 포함 여부", example = "false") + @Builder.Default + private Boolean includeLocation = false; + + // ==================== 플랫폼별 옵션 ==================== + + @Schema(description = "인스타그램 스토리용 여부 (Instagram인 경우)", example = "false") + @Builder.Default + private Boolean forInstagramStory = false; + + @Schema(description = "네이버 블로그 포스팅용 여부 (Naver Blog인 경우)", example = "false") + @Builder.Default + private Boolean forNaverBlogPost = false; + + // ==================== AI 생성 옵션 ==================== + + @Schema(description = "대안 제목 생성 개수", example = "3") + @Builder.Default + private Integer alternativeTitleCount = 3; + + @Schema(description = "대안 해시태그 세트 생성 개수", example = "2") + @Builder.Default + private Integer alternativeHashtagSetCount = 2; + + @Schema(description = "AI 모델 버전 지정 (없으면 기본값 사용)", example = "gpt-4-turbo") + private String preferredAiModel; + + // ==================== 검증 메서드 ==================== + + /** + * 이벤트 날짜 유효성 검증 + * 시작일이 종료일보다 이후인지 확인 + */ + public boolean isValidEventDates() { + if (startDate != null && endDate != null) { + return !startDate.isAfter(endDate); + } + return true; + } + + /** + * 플랫폼별 필수 조건 검증 + */ + public boolean isValidForPlatform() { + if ("INSTAGRAM".equals(platform)) { + // 인스타그램은 이미지가 권장됨 + return images != null && !images.isEmpty(); + } + if ("NAVER_BLOG".equals(platform)) { + // 네이버 블로그는 상세한 내용이 필요 + return requirement != null && requirement.length() >= 20; + } + return true; + } +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateResponse.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateResponse.java index 31a8435..ce5ee97 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateResponse.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateResponse.java @@ -93,6 +93,9 @@ public class SnsContentCreateResponse { @Schema(description = "콘텐츠 카테고리", example = "음식/메뉴소개") private String category; + @Schema(description = "보정된 이미지 URL 목록") + private List fixedImages; + // ==================== 편집 가능 여부 ==================== @Schema(description = "제목 편집 가능 여부", example = "true") diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentSaveRequest.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentSaveRequest.java index 5b4a2c7..9adb6c8 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentSaveRequest.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentSaveRequest.java @@ -1,3 +1,4 @@ +// smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentSaveRequest.java package com.won.smarketing.content.presentation.dto; import io.swagger.v3.oas.annotations.media.Schema; @@ -8,6 +9,7 @@ import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; @@ -24,6 +26,26 @@ public class SnsContentSaveRequest { @NotNull(message = "콘텐츠 ID는 필수입니다") private Long contentId; + @Schema(description = "매장 ID", example = "1", required = true) + @NotNull(message = "매장 ID는 필수입니다") + private Long storeId; + + @Schema(description = "플랫폼", example = "INSTAGRAM", required = true) + @NotBlank(message = "플랫폼은 필수입니다") + private String platform; + + @Schema(description = "제목", example = "맛있는 신메뉴를 소개합니다!") + private String title; + + @Schema(description = "콘텐츠 내용") + private String content; + + @Schema(description = "해시태그 목록") + private List hashtags; + + @Schema(description = "이미지 URL 목록") + private List images; + @Schema(description = "최종 제목", example = "맛있는 신메뉴를 소개합니다!") private String finalTitle; @@ -32,4 +54,26 @@ public class SnsContentSaveRequest { @Schema(description = "발행 상태", example = "PUBLISHED") private String status; + + // CreationConditions에 필요한 필드들 + @Schema(description = "콘텐츠 카테고리", example = "메뉴소개") + private String category; + + @Schema(description = "구체적인 요구사항", example = "새로 출시된 시그니처 버거를 홍보하고 싶어요") + private String requirement; + + @Schema(description = "톤앤매너", example = "친근함") + private String toneAndManner; + + @Schema(description = "감정 강도", example = "보통") + private String emotionIntensity; + + @Schema(description = "이벤트명", example = "신메뉴 출시 이벤트") + private String eventName; + + @Schema(description = "이벤트 시작일", example = "2024-01-15") + private LocalDate startDate; + + @Schema(description = "이벤트 종료일", example = "2024-01-31") + private LocalDate endDate; } \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/resources/application.yml b/smarketing-java/marketing-content/src/main/resources/application.yml index 82f02a3..9f7259f 100644 --- a/smarketing-java/marketing-content/src/main/resources/application.yml +++ b/smarketing-java/marketing-content/src/main/resources/application.yml @@ -1,15 +1,14 @@ server: port: ${SERVER_PORT:8083} - servlet: - context-path: / spring: application: name: marketing-content-service datasource: - url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:contentdb} + url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:MarketingContentDB} username: ${POSTGRES_USER:postgres} password: ${POSTGRES_PASSWORD:postgres} + driver-class-name: org.postgresql.Driver jpa: hibernate: ddl-auto: ${JPA_DDL_AUTO:update} @@ -29,14 +28,10 @@ external: base-url: ${CLAUDE_AI_BASE_URL:https://api.anthropic.com} model: ${CLAUDE_AI_MODEL:claude-3-sonnet-20240229} max-tokens: ${CLAUDE_AI_MAX_TOKENS:4000} - -springdoc: - swagger-ui: - path: /swagger-ui.html - operations-sorter: method - api-docs: - path: /api-docs - +jwt: + secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789} + access-token-validity: ${JWT_ACCESS_VALIDITY:3600000} + refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000} logging: level: com.won.smarketing.content: ${LOG_LEVEL:DEBUG} diff --git a/smarketing-java/member/build.gradle b/smarketing-java/member/build.gradle index 771a2fc..c75e760 100644 --- a/smarketing-java/member/build.gradle +++ b/smarketing-java/member/build.gradle @@ -1,4 +1,5 @@ dependencies { implementation project(':common') - runtimeOnly 'com.mysql:mysql-connector-j' + // 데이터베이스 의존성 + runtimeOnly 'org.postgresql:postgresql' } \ No newline at end of file From c92c81d850abde186a826a749867a1fd8f4015d0 Mon Sep 17 00:00:00 2001 From: OhSeongRak Date: Wed, 11 Jun 2025 17:27:12 +0900 Subject: [PATCH 16/34] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- smarketing-ai/.gitignore | 25 +- smarketing-ai/Dockerfile | 1 - smarketing-ai/app.py | 131 +++++- smarketing-ai/models/request_models.py | 71 ++- smarketing-ai/services/content_service.py | 208 --------- smarketing-ai/services/poster_service.py | 430 +++++++----------- smarketing-ai/services/sns_content_service.py | 218 +++++++++ smarketing-ai/utils/ai_client.py | 121 +++-- 8 files changed, 660 insertions(+), 545 deletions(-) delete mode 100644 smarketing-ai/services/content_service.py create mode 100644 smarketing-ai/services/sns_content_service.py diff --git a/smarketing-ai/.gitignore b/smarketing-ai/.gitignore index 3bf780b..0ee64c1 100644 --- a/smarketing-ai/.gitignore +++ b/smarketing-ai/.gitignore @@ -1,2 +1,23 @@ -.idea -.env \ No newline at end of file +# Python 가상환경 +venv/ +env/ +ENV/ +.venv/ +.env/ + +# Python 캐시 +__pycache__/ +*.py[cod] +*$py.class +*.so + +# 환경 변수 파일 +.env +.env.local +.env.*.local + +# IDE 설정 +.vscode/ +.idea/ +*.swp +*.swo \ No newline at end of file diff --git a/smarketing-ai/Dockerfile b/smarketing-ai/Dockerfile index 897d2e0..8ad3c3f 100644 --- a/smarketing-ai/Dockerfile +++ b/smarketing-ai/Dockerfile @@ -1,4 +1,3 @@ -# Dockerfile FROM python:3.11-slim WORKDIR /app diff --git a/smarketing-ai/app.py b/smarketing-ai/app.py index abdb87b..a9e55d8 100644 --- a/smarketing-ai/app.py +++ b/smarketing-ai/app.py @@ -9,24 +9,29 @@ import os from datetime import datetime import traceback from config.config import Config -from services.content_service import ContentService +# from services.content_service import ContentService from services.poster_service import PosterService -from models.request_models import ContentRequest, PosterRequest +from services.sns_content_service import SnsContentService +# from services.poster_generation_service import PosterGenerationService +from models.request_models import ContentRequest, PosterRequest, SnsContentGetRequest, PosterContentGetRequest def create_app(): """Flask 애플리케이션 팩토리""" app = Flask(__name__) app.config.from_object(Config) + # CORS 설정 CORS(app) + # 업로드 폴더 생성 os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) os.makedirs(os.path.join(app.config['UPLOAD_FOLDER'], 'temp'), exist_ok=True) os.makedirs('templates/poster_templates', exist_ok=True) + # 서비스 인스턴스 생성 - content_service = ContentService() poster_service = PosterService() + sns_content_service = SnsContentService() @app.route('/health', methods=['GET']) def health_check(): @@ -37,16 +42,119 @@ def create_app(): 'service': 'AI Marketing Service' }) + # ===== 새로운 API 엔드포인트 ===== + + @app.route('/api/ai/sns', methods=['POST']) + def generate_sns_content(): + """ + SNS 게시물 생성 API (새로운 요구사항) + Java 서버에서 JSON 형태로 요청받아 HTML 형식의 게시물 반환 + """ + try: + # JSON 요청 데이터 검증 + if not request.is_json: + return jsonify({'error': 'Content-Type은 application/json이어야 합니다.'}), 400 + + data = request.get_json() + if not data: + return jsonify({'error': '요청 데이터가 없습니다.'}), 400 + + # 필수 필드 검증 + required_fields = ['title', 'category', 'contentType', 'platform', 'images'] + for field in required_fields: + if field not in data: + return jsonify({'error': f'필수 필드가 누락되었습니다: {field}'}), 400 + + # 요청 모델 생성 + sns_request = SnsContentGetRequest( + title=data.get('title'), + category=data.get('category'), + contentType=data.get('contentType'), + platform=data.get('platform'), + images=data.get('images', []), + requirement=data.get('requirement'), + toneAndManner=data.get('toneAndManner'), + emotionIntensity=data.get('emotionIntensity'), + eventName=data.get('eventName'), + startDate=data.get('startDate'), + endDate=data.get('endDate') + ) + + # SNS 콘텐츠 생성 + result = sns_content_service.generate_sns_content(sns_request) + + if result['success']: + return jsonify({'content': result['content']}) + else: + return jsonify({'error': result['error']}), 500 + + except Exception as e: + app.logger.error(f"SNS 콘텐츠 생성 중 오류 발생: {str(e)}") + app.logger.error(traceback.format_exc()) + return jsonify({'error': f'SNS 콘텐츠 생성 중 오류가 발생했습니다: {str(e)}'}), 500 + + @app.route('/api/ai/poster', methods=['POST']) + def generate_poster_content(): + """ + 홍보 포스터 생성 API (새로운 요구사항) + Java 서버에서 JSON 형태로 요청받아 OpenAI 이미지 URL 반환 + """ + try: + # JSON 요청 데이터 검증 + if not request.is_json: + return jsonify({'error': 'Content-Type은 application/json이어야 합니다.'}), 400 + + data = request.get_json() + if not data: + return jsonify({'error': '요청 데이터가 없습니다.'}), 400 + + # 필수 필드 검증 + required_fields = ['title', 'category', 'contentType', 'images'] + for field in required_fields: + if field not in data: + return jsonify({'error': f'필수 필드가 누락되었습니다: {field}'}), 400 + + # 요청 모델 생성 + poster_request = PosterContentGetRequest( + title=data.get('title'), + category=data.get('category'), + contentType=data.get('contentType'), + images=data.get('images', []), + photoStyle=data.get('photoStyle'), + requirement=data.get('requirement'), + toneAndManner=data.get('toneAndManner'), + emotionIntensity=data.get('emotionIntensity'), + eventName=data.get('eventName'), + startDate=data.get('startDate'), + endDate=data.get('endDate') + ) + + # 포스터 생성 + result = poster_service.generate_poster(poster_request) + + if result['success']: + return jsonify({'content': result['content']}) + else: + return jsonify({'error': result['error']}), 500 + + except Exception as e: + app.logger.error(f"포스터 생성 중 오류 발생: {str(e)}") + app.logger.error(traceback.format_exc()) + return jsonify({'error': f'포스터 생성 중 오류가 발생했습니다: {str(e)}'}), 500 + + # ===== 기존 API 엔드포인트 (하위 호환성) ===== + @app.route('/api/content/generate', methods=['POST']) def generate_content(): """ - 마케팅 콘텐츠 생성 API + 마케팅 콘텐츠 생성 API (기존) 점주가 입력한 정보를 바탕으로 플랫폼별 맞춤 게시글 생성 """ try: # 요청 데이터 검증 if not request.form: return jsonify({'error': '요청 데이터가 없습니다.'}), 400 + # 파일 업로드 처리 uploaded_files = [] if 'images' in request.files: @@ -59,6 +167,7 @@ def create_app(): file_path = os.path.join(app.config['UPLOAD_FOLDER'], 'temp', unique_filename) file.save(file_path) uploaded_files.append(file_path) + # 요청 모델 생성 content_request = ContentRequest( category=request.form.get('category', '음식'), @@ -69,15 +178,19 @@ def create_app(): store_name=request.form.get('store_name', ''), additional_info=request.form.get('additional_info', '') ) + # 콘텐츠 생성 - result = content_service.generate_content(content_request) + result = sns_content_service.generate_content(content_request) + # 임시 파일 정리 for file_path in uploaded_files: try: os.remove(file_path) except OSError: pass + return jsonify(result) + except Exception as e: # 에러 발생 시 임시 파일 정리 for file_path in uploaded_files: @@ -92,13 +205,14 @@ def create_app(): @app.route('/api/poster/generate', methods=['POST']) def generate_poster(): """ - 홍보 포스터 생성 API + 홍보 포스터 생성 API (기존) 점주가 입력한 정보를 바탕으로 시각적 홍보 포스터 생성 """ try: # 요청 데이터 검증 if not request.form: return jsonify({'error': '요청 데이터가 없습니다.'}), 400 + # 파일 업로드 처리 uploaded_files = [] if 'images' in request.files: @@ -111,6 +225,7 @@ def create_app(): file_path = os.path.join(app.config['UPLOAD_FOLDER'], 'temp', unique_filename) file.save(file_path) uploaded_files.append(file_path) + # 요청 모델 생성 poster_request = PosterRequest( category=request.form.get('category', '음식'), @@ -122,15 +237,19 @@ def create_app(): discount_info=request.form.get('discount_info', ''), additional_info=request.form.get('additional_info', '') ) + # 포스터 생성 result = poster_service.generate_poster(poster_request) + # 임시 파일 정리 for file_path in uploaded_files: try: os.remove(file_path) except OSError: pass + return jsonify(result) + except Exception as e: # 에러 발생 시 임시 파일 정리 for file_path in uploaded_files: diff --git a/smarketing-ai/models/request_models.py b/smarketing-ai/models/request_models.py index 5abacf6..8816533 100644 --- a/smarketing-ai/models/request_models.py +++ b/smarketing-ai/models/request_models.py @@ -4,24 +4,61 @@ API 요청 데이터 구조를 정의 """ from dataclasses import dataclass from typing import List, Optional + + +@dataclass +class SnsContentGetRequest: + """SNS 게시물 생성 요청 모델""" + title: str + category: str + contentType: str + platform: str + images: List[str] # 이미지 URL 리스트 + requirement: Optional[str] = None + toneAndManner: Optional[str] = None + emotionIntensity: Optional[str] = None + eventName: Optional[str] = None + startDate: Optional[str] = None + endDate: Optional[str] = None + + +@dataclass +class PosterContentGetRequest: + """홍보 포스터 생성 요청 모델""" + title: str + category: str + contentType: str + images: List[str] # 이미지 URL 리스트 + photoStyle: Optional[str] = None + requirement: Optional[str] = None + toneAndManner: Optional[str] = None + emotionIntensity: Optional[str] = None + eventName: Optional[str] = None + startDate: Optional[str] = None + endDate: Optional[str] = None + + +# 기존 모델들은 유지 @dataclass class ContentRequest: - """마케팅 콘텐츠 생성 요청 모델""" - category: str # 음식, 매장, 이벤트 - platform: str # 네이버 블로그, 인스타그램 - image_paths: List[str] # 업로드된 이미지 파일 경로들 - start_time: Optional[str] = None # 이벤트 시작 시간 - end_time: Optional[str] = None # 이벤트 종료 시간 - store_name: Optional[str] = None # 매장명 - additional_info: Optional[str] = None # 추가 정보 + """마케팅 콘텐츠 생성 요청 모델 (기존)""" + category: str + platform: str + image_paths: List[str] + start_time: Optional[str] = None + end_time: Optional[str] = None + store_name: Optional[str] = None + additional_info: Optional[str] = None + + @dataclass class PosterRequest: - """홍보 포스터 생성 요청 모델""" - category: str # 음식, 매장, 이벤트 - image_paths: List[str] # 업로드된 이미지 파일 경로들 - start_time: Optional[str] = None # 이벤트 시작 시간 - end_time: Optional[str] = None # 이벤트 종료 시간 - store_name: Optional[str] = None # 매장명 - event_title: Optional[str] = None # 이벤트 제목 - discount_info: Optional[str] = None # 할인 정보 - additional_info: Optional[str] = None # 추가 정보 \ No newline at end of file + """홍보 포스터 생성 요청 모델 (기존)""" + category: str + image_paths: List[str] + start_time: Optional[str] = None + end_time: Optional[str] = None + store_name: Optional[str] = None + event_title: Optional[str] = None + discount_info: Optional[str] = None + additional_info: Optional[str] = None diff --git a/smarketing-ai/services/content_service.py b/smarketing-ai/services/content_service.py deleted file mode 100644 index 34dc36b..0000000 --- a/smarketing-ai/services/content_service.py +++ /dev/null @@ -1,208 +0,0 @@ -""" -마케팅 콘텐츠 생성 서비스 -AI를 활용하여 플랫폼별 맞춤 게시글 생성 -""" -import os -from typing import Dict, Any -from datetime import datetime -from utils.ai_client import AIClient -from utils.image_processor import ImageProcessor -from models.request_models import ContentRequest - - -class ContentService: - """마케팅 콘텐츠 생성 서비스 클래스""" - - def __init__(self): - """서비스 초기화""" - self.ai_client = AIClient() - self.image_processor = ImageProcessor() - # 플랫폼별 콘텐츠 특성 정의 - self.platform_specs = { - '인스타그램': { - 'max_length': 2200, - 'hashtag_count': 15, - 'style': '감성적이고 시각적', - 'format': '짧은 문장, 해시태그 활용' - }, - '네이버 블로그': { - 'max_length': 3000, - 'hashtag_count': 10, - 'style': '정보성과 친근함', - 'format': '구조화된 내용, 상세 설명' - } - } - # 카테고리별 키워드 정의 - self.category_keywords = { - '음식': ['맛집', '신메뉴', '추천', '맛있는', '특별한', '인기'], - '매장': ['분위기', '인테리어', '편안한', '아늑한', '특별한', '방문'], - '이벤트': ['할인', '이벤트', '특가', '한정', '기간한정', '혜택'] - } - - def generate_content(self, request: ContentRequest) -> Dict[str, Any]: - """ - 마케팅 콘텐츠 생성 - Args: - request: 콘텐츠 생성 요청 데이터 - Returns: - 생성된 콘텐츠 정보 - """ - try: - # 이미지 분석 - image_analysis = self._analyze_images(request.image_paths) - # AI 프롬프트 생성 - prompt = self._create_content_prompt(request, image_analysis) - # AI로 콘텐츠 생성 - generated_content = self.ai_client.generate_text(prompt) - # 해시태그 생성 - hashtags = self._generate_hashtags(request) - # 최종 콘텐츠 포맷팅 - formatted_content = self._format_content( - generated_content, - hashtags, - request.platform - ) - return { - 'success': True, - 'content': formatted_content, - 'platform': request.platform, - 'category': request.category, - 'generated_at': datetime.now().isoformat(), - 'image_count': len(request.image_paths), - 'image_analysis': image_analysis - } - except Exception as e: - return { - 'success': False, - 'error': str(e), - 'generated_at': datetime.now().isoformat() - } - - def _analyze_images(self, image_paths: list) -> Dict[str, Any]: - """ - 업로드된 이미지들 분석 - Args: - image_paths: 이미지 파일 경로 리스트 - Returns: - 이미지 분석 결과 - """ - analysis_results = [] - for image_path in image_paths: - try: - # 이미지 기본 정보 추출 - image_info = self.image_processor.get_image_info(image_path) - # AI를 통한 이미지 내용 분석 - image_description = self.ai_client.analyze_image(image_path) - analysis_results.append({ - 'path': image_path, - 'info': image_info, - 'description': image_description - }) - except Exception as e: - analysis_results.append({ - 'path': image_path, - 'error': str(e) - }) - return { - 'total_images': len(image_paths), - 'results': analysis_results - } - - def _create_content_prompt(self, request: ContentRequest, image_analysis: Dict[str, Any]) -> str: - """ - AI 콘텐츠 생성을 위한 프롬프트 생성 - Args: - request: 콘텐츠 생성 요청 - image_analysis: 이미지 분석 결과 - Returns: - AI 프롬프트 문자열 - """ - platform_spec = self.platform_specs.get(request.platform, self.platform_specs['인스타그램']) - category_keywords = self.category_keywords.get(request.category, []) - # 이미지 설명 추출 - image_descriptions = [] - for result in image_analysis.get('results', []): - if 'description' in result: - image_descriptions.append(result['description']) - prompt = f""" -당신은 소상공인을 위한 마케팅 콘텐츠 전문가입니다. -다음 정보를 바탕으로 {request.platform}에 적합한 {request.category} 카테고리의 게시글을 작성해주세요. -**매장 정보:** -- 매장명: {request.store_name or '우리 가게'} -- 카테고리: {request.category} -- 추가 정보: {request.additional_info or '없음'} -**이벤트 정보:** -- 시작 시간: {request.start_time or '상시'} -- 종료 시간: {request.end_time or '상시'} -**이미지 분석 결과:** -{chr(10).join(image_descriptions) if image_descriptions else '이미지 없음'} -**플랫폼 특성:** -- 최대 길이: {platform_spec['max_length']}자 -- 스타일: {platform_spec['style']} -- 형식: {platform_spec['format']} -**요구사항:** -1. {request.platform}의 특성에 맞는 톤앤매너 사용 -2. {request.category} 카테고리에 적합한 내용 구성 -3. 고객의 관심을 끌 수 있는 매력적인 문구 사용 -4. 이미지와 연관된 내용으로 작성 -5. 자연스럽고 친근한 어조 사용 -해시태그는 별도로 생성하므로 본문에는 포함하지 마세요. -""" - return prompt - - def _generate_hashtags(self, request: ContentRequest) -> list: - """ - 카테고리와 플랫폼에 맞는 해시태그 생성 - Args: - request: 콘텐츠 생성 요청 - Returns: - 해시태그 리스트 - """ - platform_spec = self.platform_specs.get(request.platform, self.platform_specs['인스타그램']) - category_keywords = self.category_keywords.get(request.category, []) - hashtags = [] - # 기본 해시태그 - if request.store_name: - hashtags.append(f"#{request.store_name.replace(' ', '')}") - # 카테고리별 해시태그 - hashtags.extend([f"#{keyword}" for keyword in category_keywords[:5]]) - # 공통 해시태그 - common_tags = ['#맛집', '#소상공인', '#로컬맛집', '#일상', '#소통'] - hashtags.extend(common_tags) - # 플랫폼별 인기 해시태그 - if request.platform == '인스타그램': - hashtags.extend(['#인스타푸드', '#데일리', '#오늘뭐먹지', '#맛스타그램']) - elif request.platform == '네이버 블로그': - hashtags.extend(['#블로그', '#후기', '#추천', '#정보']) - # 최대 개수 제한 - max_count = platform_spec['hashtag_count'] - return hashtags[:max_count] - - def _format_content(self, content: str, hashtags: list, platform: str) -> str: - """ - 플랫폼에 맞게 콘텐츠 포맷팅 - Args: - content: 생성된 콘텐츠 - hashtags: 해시태그 리스트 - platform: 플랫폼명 - Returns: - 포맷팅된 최종 콘텐츠 - """ - platform_spec = self.platform_specs.get(platform, self.platform_specs['인스타그램']) - # 길이 제한 적용 - if len(content) > platform_spec['max_length'] - 100: # 해시태그 공간 확보 - content = content[:platform_spec['max_length'] - 100] + '...' - # 플랫폼별 포맷팅 - if platform == '인스타그램': - # 인스타그램: 본문 + 해시태그 - hashtag_string = ' '.join(hashtags) - formatted = f"{content}\n\n{hashtag_string}" - elif platform == '네이버 블로그': - # 네이버 블로그: 구조화된 형태 - hashtag_string = ' '.join(hashtags) - formatted = f"{content}\n\n---\n{hashtag_string}" - else: - # 기본 형태 - hashtag_string = ' '.join(hashtags) - formatted = f"{content}\n\n{hashtag_string}" - return formatted diff --git a/smarketing-ai/services/poster_service.py b/smarketing-ai/services/poster_service.py index fc27adb..9ebbcb2 100644 --- a/smarketing-ai/services/poster_service.py +++ b/smarketing-ai/services/poster_service.py @@ -1,312 +1,190 @@ """ -홍보 포스터 생성 서비스 -AI와 이미지 처리를 활용한 시각적 마케팅 자료 생성 +포스터 생성 서비스 +OpenAI를 사용한 이미지 생성 (한글 프롬프트) """ import os -import base64 from typing import Dict, Any from datetime import datetime -from PIL import Image, ImageDraw, ImageFont from utils.ai_client import AIClient from utils.image_processor import ImageProcessor -from models.request_models import PosterRequest +from models.request_models import PosterContentGetRequest class PosterService: - """홍보 포스터 생성 서비스 클래스""" + """포스터 생성 서비스 클래스""" def __init__(self): """서비스 초기화""" self.ai_client = AIClient() self.image_processor = ImageProcessor() - # 포스터 기본 설정 - self.poster_config = { - 'width': 1080, - 'height': 1350, # 인스타그램 세로 비율 - 'background_color': (255, 255, 255), - 'text_color': (50, 50, 50), - 'accent_color': (255, 107, 107) - } - # 카테고리별 색상 테마 - self.category_themes = { - '음식': { - 'primary': (255, 107, 107), # 빨강 - 'secondary': (255, 206, 84), # 노랑 - 'background': (255, 248, 240) # 크림 - }, - '매장': { - 'primary': (74, 144, 226), # 파랑 - 'secondary': (120, 198, 121), # 초록 - 'background': (248, 251, 255) # 연한 파랑 - }, - '이벤트': { - 'primary': (156, 39, 176), # 보라 - 'secondary': (255, 193, 7), # 금색 - 'background': (252, 248, 255) # 연한 보라 - } + + # 포토 스타일별 프롬프트 + self.photo_styles = { + '미니멀': '미니멀하고 깔끔한 디자인, 단순함, 여백 활용', + '모던': '현대적이고 세련된 디자인, 깔끔한 레이아웃', + '빈티지': '빈티지 느낌, 레트로 스타일, 클래식한 색감', + '컬러풀': '다채로운 색상, 밝고 생동감 있는 컬러', + '우아한': '우아하고 고급스러운 느낌, 세련된 분위기', + '캐주얼': '친근하고 편안한 느낌, 접근하기 쉬운 디자인' } - def generate_poster(self, request: PosterRequest) -> Dict[str, Any]: + # 카테고리별 이미지 스타일 + self.category_styles = { + '음식': '음식 사진, 먹음직스러운, 맛있어 보이는', + '매장': '레스토랑 인테리어, 아늑한 분위기', + '이벤트': '홍보용 디자인, 눈길을 끄는', + '메뉴': '메뉴 디자인, 정리된 레이아웃', + '할인': '세일 포스터, 할인 디자인' + } + + # 톤앤매너별 디자인 스타일 + self.tone_styles = { + '친근한': '따뜻하고 친근한 색감, 부드러운 느낌', + '정중한': '격식 있고 신뢰감 있는 디자인', + '재미있는': '밝고 유쾌한 분위기, 활기찬 색상', + '전문적인': '전문적이고 신뢰할 수 있는 디자인' + } + + # 감정 강도별 디자인 + self.emotion_designs = { + '약함': '은은하고 차분한 색감, 절제된 표현', + '보통': '적당히 활기찬 색상, 균형잡힌 디자인', + '강함': '강렬하고 임팩트 있는 색상, 역동적인 디자인' + } + + def generate_poster(self, request: PosterContentGetRequest) -> Dict[str, Any]: """ - 홍보 포스터 생성 - Args: - request: 포스터 생성 요청 데이터 - Returns: - 생성된 포스터 정보 + 포스터 생성 (OpenAI 이미지 URL 반환) """ try: - # 포스터 텍스트 내용 생성 - poster_text = self._generate_poster_text(request) - # 이미지 전처리 - processed_images = self._process_images(request.image_paths) - # 포스터 이미지 생성 - poster_image = self._create_poster_image(request, poster_text, processed_images) - # 이미지를 base64로 인코딩 - poster_base64 = self._encode_image_to_base64(poster_image) + # 참조 이미지 분석 (있는 경우) + image_analysis = self._analyze_reference_images(request.images) + + # 포스터 생성 프롬프트 생성 + prompt = self._create_poster_prompt(request, image_analysis) + + # OpenAI로 이미지 생성 + image_url = self.ai_client.generate_image_with_openai(prompt, "1024x1024") + return { 'success': True, - 'poster_data': poster_base64, - 'poster_text': poster_text, - 'category': request.category, - 'generated_at': datetime.now().isoformat(), - 'image_count': len(request.image_paths), - 'format': 'base64' + 'content': image_url } + except Exception as e: return { 'success': False, - 'error': str(e), - 'generated_at': datetime.now().isoformat() + 'error': str(e) } - def _generate_poster_text(self, request: PosterRequest) -> Dict[str, str]: + def _analyze_reference_images(self, image_urls: list) -> Dict[str, Any]: """ - 포스터에 들어갈 텍스트 내용 생성 - Args: - request: 포스터 생성 요청 - Returns: - 포스터 텍스트 구성 요소들 + 참조 이미지들 분석 """ - # 이미지 분석 - image_descriptions = [] - for image_path in request.image_paths: - try: - description = self.ai_client.analyze_image(image_path) - image_descriptions.append(description) - except: - continue - # AI 프롬프트 생성 - prompt = f""" -당신은 소상공인을 위한 포스터 카피라이터입니다. -다음 정보를 바탕으로 매력적인 포스터 문구를 작성해주세요. -**매장 정보:** -- 매장명: {request.store_name or '우리 가게'} -- 카테고리: {request.category} -- 추가 정보: {request.additional_info or '없음'} -**이벤트 정보:** -- 이벤트 제목: {request.event_title or '특별 이벤트'} -- 할인 정보: {request.discount_info or '특가 진행'} -- 시작 시간: {request.start_time or '상시'} -- 종료 시간: {request.end_time or '상시'} -**이미지 설명:** -{chr(10).join(image_descriptions) if image_descriptions else '이미지 없음'} -다음 형식으로 응답해주세요: -1. 메인 헤드라인 (10글자 이내, 임팩트 있게) -2. 서브 헤드라인 (20글자 이내, 구체적 혜택) -3. 설명 문구 (30글자 이내, 친근하고 매력적으로) -4. 행동 유도 문구 (15글자 이내, 액션 유도) -각 항목은 줄바꿈으로 구분해서 작성해주세요. -""" - # AI로 텍스트 생성 - generated_text = self.ai_client.generate_text(prompt) - # 생성된 텍스트 파싱 - lines = generated_text.strip().split('\n') - return { - 'main_headline': lines[0] if len(lines) > 0 else request.event_title or '특별 이벤트', - 'sub_headline': lines[1] if len(lines) > 1 else request.discount_info or '지금 바로!', - 'description': lines[2] if len(lines) > 2 else '특별한 혜택을 놓치지 마세요', - 'call_to_action': lines[3] if len(lines) > 3 else '지금 방문하세요!' - } + if not image_urls: + return {'total_images': 0, 'results': []} - def _process_images(self, image_paths: list) -> list: - """ - 포스터에 사용할 이미지들 전처리 - Args: - image_paths: 원본 이미지 경로 리스트 - Returns: - 전처리된 이미지 객체 리스트 - """ - processed_images = [] - for image_path in image_paths: - try: - # 이미지 로드 및 리사이즈 - image = Image.open(image_path) - # RGBA로 변환 (투명도 처리) - if image.mode != 'RGBA': - image = image.convert('RGBA') - # 포스터에 맞게 리사이즈 (최대 400x400) - image.thumbnail((400, 400), Image.Resampling.LANCZOS) - processed_images.append(image) - except Exception as e: - print(f"이미지 처리 오류 {image_path}: {e}") - continue - return processed_images + analysis_results = [] + temp_files = [] - def _create_poster_image(self, request: PosterRequest, poster_text: Dict[str, str], images: list) -> Image.Image: - """ - 실제 포스터 이미지 생성 - Args: - request: 포스터 생성 요청 - poster_text: 포스터 텍스트 - images: 전처리된 이미지 리스트 - Returns: - 생성된 포스터 이미지 - """ - # 카테고리별 테마 적용 - theme = self.category_themes.get(request.category, self.category_themes['음식']) - # 캔버스 생성 - poster = Image.new('RGBA', - (self.poster_config['width'], self.poster_config['height']), - theme['background']) - draw = ImageDraw.Draw(poster) - # 폰트 설정 (시스템 기본 폰트 사용) try: - # 다양한 폰트 시도 - title_font = ImageFont.truetype("arial.ttf", 60) - subtitle_font = ImageFont.truetype("arial.ttf", 40) - text_font = ImageFont.truetype("arial.ttf", 30) - small_font = ImageFont.truetype("arial.ttf", 24) - except: - # 기본 폰트 사용 - title_font = ImageFont.load_default() - subtitle_font = ImageFont.load_default() - text_font = ImageFont.load_default() - small_font = ImageFont.load_default() - # 레이아웃 계산 - y_pos = 80 - # 1. 메인 헤드라인 - main_headline = poster_text['main_headline'] - bbox = draw.textbbox((0, 0), main_headline, font=title_font) - text_width = bbox[2] - bbox[0] - x_pos = (self.poster_config['width'] - text_width) // 2 - draw.text((x_pos, y_pos), main_headline, - fill=theme['primary'], font=title_font) - y_pos += 100 - # 2. 서브 헤드라인 - sub_headline = poster_text['sub_headline'] - bbox = draw.textbbox((0, 0), sub_headline, font=subtitle_font) - text_width = bbox[2] - bbox[0] - x_pos = (self.poster_config['width'] - text_width) // 2 - draw.text((x_pos, y_pos), sub_headline, - fill=theme['secondary'], font=subtitle_font) - y_pos += 80 - # 3. 이미지 배치 (있는 경우) - if images: - image_y = y_pos + 30 - if len(images) == 1: - # 단일 이미지: 중앙 배치 - img = images[0] - img_x = (self.poster_config['width'] - img.width) // 2 - poster.paste(img, (img_x, image_y), img) - y_pos = image_y + img.height + 50 - elif len(images) == 2: - # 두 개 이미지: 나란히 배치 - total_width = sum(img.width for img in images) + 20 - start_x = (self.poster_config['width'] - total_width) // 2 - for i, img in enumerate(images): - img_x = start_x + (i * (img.width + 20)) - poster.paste(img, (img_x, image_y), img) - y_pos = image_y + max(img.height for img in images) + 50 - else: - # 여러 이미지: 그리드 형태 - cols = 2 - rows = (len(images) + cols - 1) // cols - img_spacing = 20 - for i, img in enumerate(images[:4]): # 최대 4개 - row = i // cols - col = i % cols - img_x = (self.poster_config['width'] // cols) * col + \ - (self.poster_config['width'] // cols - img.width) // 2 - img_y = image_y + row * (200 + img_spacing) - poster.paste(img, (img_x, img_y), img) - y_pos = image_y + rows * (200 + img_spacing) + 30 - # 4. 설명 문구 - description = poster_text['description'] - # 긴 텍스트는 줄바꿈 처리 - words = description.split() - lines = [] - current_line = [] - for word in words: - test_line = ' '.join(current_line + [word]) - bbox = draw.textbbox((0, 0), test_line, font=text_font) - if bbox[2] - bbox[0] < self.poster_config['width'] - 100: - current_line.append(word) - else: - if current_line: - lines.append(' '.join(current_line)) - current_line = [word] - if current_line: - lines.append(' '.join(current_line)) - for line in lines: - bbox = draw.textbbox((0, 0), line, font=text_font) - text_width = bbox[2] - bbox[0] - x_pos = (self.poster_config['width'] - text_width) // 2 - draw.text((x_pos, y_pos), line, fill=(80, 80, 80), font=text_font) - y_pos += 40 - y_pos += 30 - # 5. 기간 정보 (있는 경우) - if request.start_time and request.end_time: - period_text = f"기간: {request.start_time} ~ {request.end_time}" - bbox = draw.textbbox((0, 0), period_text, font=small_font) - text_width = bbox[2] - bbox[0] - x_pos = (self.poster_config['width'] - text_width) // 2 - draw.text((x_pos, y_pos), period_text, fill=(120, 120, 120), font=small_font) - y_pos += 50 - # 6. 행동 유도 문구 (버튼 스타일) - cta_text = poster_text['call_to_action'] - bbox = draw.textbbox((0, 0), cta_text, font=subtitle_font) - text_width = bbox[2] - bbox[0] - text_height = bbox[3] - bbox[1] - # 버튼 배경 - button_width = text_width + 60 - button_height = text_height + 30 - button_x = (self.poster_config['width'] - button_width) // 2 - button_y = self.poster_config['height'] - 150 - draw.rounded_rectangle([button_x, button_y, button_x + button_width, button_y + button_height], - radius=25, fill=theme['primary']) - # 버튼 텍스트 - text_x = button_x + (button_width - text_width) // 2 - text_y = button_y + (button_height - text_height) // 2 - draw.text((text_x, text_y), cta_text, fill=(255, 255, 255), font=subtitle_font) - # 7. 매장명 (하단) - if request.store_name: - store_text = request.store_name - bbox = draw.textbbox((0, 0), store_text, font=text_font) - text_width = bbox[2] - bbox[0] - x_pos = (self.poster_config['width'] - text_width) // 2 - y_pos = self.poster_config['height'] - 50 - draw.text((x_pos, y_pos), store_text, fill=(100, 100, 100), font=text_font) - return poster + for image_url in image_urls: + # 이미지 다운로드 + temp_path = self.ai_client.download_image_from_url(image_url) + if temp_path: + temp_files.append(temp_path) - def _encode_image_to_base64(self, image: Image.Image) -> str: + try: + # 이미지 분석 + image_description = self.ai_client.analyze_image(temp_path) + # 색상 분석 + colors = self.image_processor.analyze_colors(temp_path, 3) + + analysis_results.append({ + 'url': image_url, + 'description': image_description, + 'dominant_colors': colors + }) + except Exception as e: + analysis_results.append({ + 'url': image_url, + 'error': str(e) + }) + + return { + 'total_images': len(image_urls), + 'results': analysis_results + } + + finally: + # 임시 파일 정리 + for temp_file in temp_files: + try: + os.remove(temp_file) + except: + pass + + def _create_poster_prompt(self, request: PosterContentGetRequest, image_analysis: Dict[str, Any]) -> str: """ - PIL 이미지를 base64 문자열로 인코딩 - Args: - image: PIL 이미지 객체 - Returns: - base64 인코딩된 이미지 문자열 + 포스터 생성을 위한 AI 프롬프트 생성 (한글) """ - import io - # RGB로 변환 (JPEG 저장을 위해) - if image.mode == 'RGBA': - # 흰색 배경과 합성 - background = Image.new('RGB', image.size, (255, 255, 255)) - background.paste(image, mask=image.split()[-1]) - image = background - # 바이트 스트림으로 변환 - img_buffer = io.BytesIO() - image.save(img_buffer, format='JPEG', quality=90) - img_buffer.seek(0) - # base64 인코딩 - img_base64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8') - return f"data:image/jpeg;base64,{img_base64}" + # 기본 스타일 설정 + photo_style = self.photo_styles.get(request.photoStyle, '현대적이고 깔끔한 디자인') + category_style = self.category_styles.get(request.category, '홍보용 디자인') + tone_style = self.tone_styles.get(request.toneAndManner, '친근하고 따뜻한 느낌') + emotion_design = self.emotion_designs.get(request.emotionIntensity, '적당히 활기찬 디자인') + + # 참조 이미지 설명 + reference_descriptions = [] + for result in image_analysis.get('results', []): + if 'description' in result: + reference_descriptions.append(result['description']) + + # 색상 정보 + color_info = "" + if image_analysis.get('results'): + colors = image_analysis['results'][0].get('dominant_colors', []) + if colors: + color_info = f"참조 색상 팔레트: {colors[:3]}을 활용한 조화로운 색감" + + prompt = f""" +한국의 음식점/카페를 위한 전문적인 홍보 포스터를 디자인해주세요. + +**메인 콘텐츠:** +- 제목: "{request.title}" +- 카테고리: {request.category} +- 콘텐츠 타입: {request.contentType} + +**디자인 스타일 요구사항:** +- 포토 스타일: {photo_style} +- 카테고리 스타일: {category_style} +- 톤앤매너: {tone_style} +- 감정 강도: {emotion_design} + +**이벤트 정보:** +- 이벤트명: {request.eventName or '특별 프로모션'} +- 시작일: {request.startDate or '지금'} +- 종료일: {request.endDate or '한정 기간'} + +**특별 요구사항:** +{request.requirement or '눈길을 끄는 전문적인 디자인'} + +**참조 이미지 설명:** +{chr(10).join(reference_descriptions) if reference_descriptions else '참조 이미지 없음'} + +{color_info} + +**디자인 가이드라인:** +- 한국 음식점/카페에 적합한 깔끔하고 현대적인 레이아웃 +- 한글 텍스트 요소를 자연스럽게 포함 +- 가독성이 좋은 전문적인 타이포그래피 +- 명확한 대비로 읽기 쉽게 구성 +- 소셜미디어 공유에 적합한 크기 +- 저작권이 없는 오리지널 디자인 +- 음식점에 어울리는 맛있어 보이는 색상 조합 +- 고객의 시선을 끄는 매력적인 비주얼 + +고객들이 음식점을 방문하고 싶게 만드는 시각적으로 매력적인 포스터를 만들어주세요. +텍스트는 한글로, 전체적인 분위기는 한국적 감성에 맞게 디자인해주세요. +""" + return prompt diff --git a/smarketing-ai/services/sns_content_service.py b/smarketing-ai/services/sns_content_service.py new file mode 100644 index 0000000..e5090e0 --- /dev/null +++ b/smarketing-ai/services/sns_content_service.py @@ -0,0 +1,218 @@ +""" +SNS 콘텐츠 생성 서비스 +""" +import os +from typing import Dict, Any +from datetime import datetime +from utils.ai_client import AIClient +from utils.image_processor import ImageProcessor +from models.request_models import SnsContentGetRequest + + +class SnsContentService: + + def __init__(self): + """서비스 초기화""" + self.ai_client = AIClient() + self.image_processor = ImageProcessor() + + # 플랫폼별 콘텐츠 특성 정의 + self.platform_specs = { + '인스타그램': { + 'max_length': 2200, + 'hashtag_count': 15, + 'style': '감성적이고 시각적', + 'format': '짧은 문장, 해시태그 활용' + }, + '네이버 블로그': { + 'max_length': 3000, + 'hashtag_count': 10, + 'style': '정보성과 친근함', + 'format': '구조화된 내용, 상세 설명' + } + } + + # 톤앤매너별 스타일 + self.tone_styles = { + '친근한': '반말, 이모티콘 활용, 편안한 어조', + '정중한': '존댓말, 격식 있는 표현, 신뢰감 있는 어조', + '재미있는': '유머 섞인 표현, 트렌디한 말투, 참신한 비유', + '전문적인': '전문 용어 활용, 체계적 설명, 신뢰성 강조' + } + + # 감정 강도별 표현 + self.emotion_levels = { + '약함': '은은하고 차분한 표현', + '보통': '적당히 활기찬 표현', + '강함': '매우 열정적이고 강렬한 표현' + } + + def generate_sns_content(self, request: SnsContentGetRequest) -> Dict[str, Any]: + """ + SNS 콘텐츠 생성 (HTML 형식 반환) + """ + try: + # 이미지 다운로드 및 분석 + image_analysis = self._analyze_images_from_urls(request.images) + + # AI 프롬프트 생성 + prompt = self._create_sns_prompt(request, image_analysis) + + # AI로 콘텐츠 생성 + generated_content = self.ai_client.generate_text(prompt) + + # HTML 형식으로 포맷팅 + html_content = self._format_to_html(generated_content, request) + + return { + 'success': True, + 'content': html_content + } + + except Exception as e: + return { + 'success': False, + 'error': str(e) + } + + def _analyze_images_from_urls(self, image_urls: list) -> Dict[str, Any]: + """ + URL에서 이미지를 다운로드하고 분석 + """ + analysis_results = [] + temp_files = [] + + try: + for image_url in image_urls: + # 이미지 다운로드 + temp_path = self.ai_client.download_image_from_url(image_url) + if temp_path: + temp_files.append(temp_path) + + # 이미지 분석 + try: + image_info = self.image_processor.get_image_info(temp_path) + image_description = self.ai_client.analyze_image(temp_path) + + analysis_results.append({ + 'url': image_url, + 'info': image_info, + 'description': image_description + }) + except Exception as e: + analysis_results.append({ + 'url': image_url, + 'error': str(e) + }) + + return { + 'total_images': len(image_urls), + 'results': analysis_results + } + + finally: + # 임시 파일 정리 + for temp_file in temp_files: + try: + os.remove(temp_file) + except: + pass + + def _create_sns_prompt(self, request: SnsContentGetRequest, image_analysis: Dict[str, Any]) -> str: + """ + SNS 콘텐츠 생성을 위한 AI 프롬프트 생성 + """ + platform_spec = self.platform_specs.get(request.platform, self.platform_specs['인스타그램']) + tone_style = self.tone_styles.get(request.toneAndManner, '친근한 어조') + emotion_level = self.emotion_levels.get(request.emotionIntensity, '적당한 강도') + + # 이미지 설명 추출 + image_descriptions = [] + for result in image_analysis.get('results', []): + if 'description' in result: + image_descriptions.append(result['description']) + + prompt = f""" +당신은 소상공인을 위한 SNS 마케팅 콘텐츠 전문가입니다. +다음 정보를 바탕으로 {request.platform}에 적합한 게시글을 작성해주세요. + +**게시물 정보:** +- 제목: {request.title} +- 카테고리: {request.category} +- 콘텐츠 타입: {request.contentType} + +**스타일 요구사항:** +- 톤앤매너: {request.toneAndManner} ({tone_style}) +- 감정 강도: {request.emotionIntensity} ({emotion_level}) +- 특별 요구사항: {request.requirement or '없음'} + +**이벤트 정보:** +- 이벤트명: {request.eventName or '없음'} +- 시작일: {request.startDate or '없음'} +- 종료일: {request.endDate or '없음'} + +**이미지 분석 결과:** +{chr(10).join(image_descriptions) if image_descriptions else '이미지 없음'} + +**플랫폼 특성:** +- 최대 길이: {platform_spec['max_length']}자 +- 스타일: {platform_spec['style']} +- 형식: {platform_spec['format']} + +**요구사항:** +1. {request.platform}의 특성에 맞는 톤앤매너 사용 +2. {request.category} 카테고리에 적합한 내용 구성 +3. 고객의 관심을 끌 수 있는 매력적인 문구 사용 +4. 이미지와 연관된 내용으로 작성 +5. 지정된 톤앤매너와 감정 강도에 맞게 작성 + +본문과 해시태그를 모두 포함하여 완성된 게시글을 작성해주세요. +""" + return prompt + + def _format_to_html(self, content: str, request: SnsContentGetRequest) -> str: + """ + 생성된 콘텐츠를 HTML 형식으로 포맷팅 + """ + # 줄바꿈을
태그로 변환 + content = content.replace('\n', '
') + + # 해시태그를 파란색으로 스타일링 + import re + content = re.sub(r'(#[\w가-힣]+)', r'\1', content) + + # 이모티콘은 그대로 유지 + + # 전체 HTML 구조 + html_content = f""" +
+
+

{request.platform} 게시물

+
+
+
+ {content} +
+ {self._add_metadata_html(request)} +
+
+""" + return html_content + + def _add_metadata_html(self, request: SnsContentGetRequest) -> str: + """ + 메타데이터를 HTML에 추가 + """ + metadata_html = '
' + + if request.eventName: + metadata_html += f'
이벤트: {request.eventName}
' + + if request.startDate and request.endDate: + metadata_html += f'
기간: {request.startDate} ~ {request.endDate}
' + + metadata_html += f'
카테고리: {request.category}
' + metadata_html += f'
생성일: {datetime.now().strftime("%Y-%m-%d %H:%M")}
' + metadata_html += '
' + + return metadata_html diff --git a/smarketing-ai/utils/ai_client.py b/smarketing-ai/utils/ai_client.py index 2695f0c..d1ef889 100644 --- a/smarketing-ai/utils/ai_client.py +++ b/smarketing-ai/utils/ai_client.py @@ -4,36 +4,96 @@ Claude AI 및 OpenAI API 호출을 담당 """ import os import base64 -from typing import Optional +import requests +from typing import Optional, List import anthropic import openai from PIL import Image import io + + class AIClient: """AI API 클라이언트 클래스""" + def __init__(self): """AI 클라이언트 초기화""" self.claude_api_key = os.getenv('CLAUDE_API_KEY') self.openai_api_key = os.getenv('OPENAI_API_KEY') + # Claude 클라이언트 초기화 if self.claude_api_key: self.claude_client = anthropic.Anthropic(api_key=self.claude_api_key) else: self.claude_client = None + # OpenAI 클라이언트 초기화 if self.openai_api_key: - openai.api_key = self.openai_api_key - self.openai_client = openai + self.openai_client = openai.OpenAI(api_key=self.openai_api_key) else: self.openai_client = None + + def download_image_from_url(self, image_url: str) -> str: + """ + URL에서 이미지를 다운로드하여 임시 파일로 저장 + Args: + image_url: 다운로드할 이미지 URL + Returns: + 임시 저장된 파일 경로 + """ + try: + response = requests.get(image_url, timeout=30) + response.raise_for_status() + + # 임시 파일로 저장 + import tempfile + import uuid + + file_extension = image_url.split('.')[-1] if '.' in image_url else 'jpg' + temp_filename = f"temp_{uuid.uuid4()}.{file_extension}" + temp_path = os.path.join('uploads', 'temp', temp_filename) + + # 디렉토리 생성 + os.makedirs(os.path.dirname(temp_path), exist_ok=True) + + with open(temp_path, 'wb') as f: + f.write(response.content) + + return temp_path + + except Exception as e: + print(f"이미지 다운로드 실패 {image_url}: {e}") + return None + + def generate_image_with_openai(self, prompt: str, size: str = "1024x1024") -> str: + """ + OpenAI DALL-E를 사용하여 이미지 생성 + Args: + prompt: 이미지 생성 프롬프트 + size: 이미지 크기 (1024x1024, 1792x1024, 1024x1792) + Returns: + 생성된 이미지 URL + """ + try: + if not self.openai_client: + raise Exception("OpenAI API 키가 설정되지 않았습니다.") + + response = self.openai_client.images.generate( + model="dall-e-3", + prompt=prompt, + size=size, + quality="standard", + n=1, + ) + + return response.data[0].url + + except Exception as e: + print(f"OpenAI 이미지 생성 실패: {e}") + raise Exception(f"이미지 생성 중 오류가 발생했습니다: {str(e)}") + def generate_text(self, prompt: str, max_tokens: int = 1000) -> str: """ 텍스트 생성 (Claude 우선, 실패시 OpenAI 사용) - Args: - prompt: 생성할 텍스트의 프롬프트 - max_tokens: 최대 토큰 수 - Returns: - 생성된 텍스트 """ # Claude AI 시도 if self.claude_client: @@ -48,6 +108,7 @@ class AIClient: return response.content[0].text except Exception as e: print(f"Claude AI 호출 실패: {e}") + # OpenAI 시도 if self.openai_client: try: @@ -61,19 +122,18 @@ class AIClient: return response.choices[0].message.content except Exception as e: print(f"OpenAI 호출 실패: {e}") - # 기본 응답 (AI 서비스 모두 실패시) + + # 기본 응답 return self._generate_fallback_content(prompt) + def analyze_image(self, image_path: str) -> str: """ 이미지 분석 및 설명 생성 - Args: - image_path: 분석할 이미지 경로 - Returns: - 이미지 설명 텍스트 """ try: # 이미지를 base64로 인코딩 image_base64 = self._encode_image_to_base64(image_path) + # Claude Vision API 시도 if self.claude_client: try: @@ -103,6 +163,7 @@ class AIClient: return response.content[0].text except Exception as e: print(f"Claude 이미지 분석 실패: {e}") + # OpenAI Vision API 시도 if self.openai_client: try: @@ -130,47 +191,37 @@ class AIClient: return response.choices[0].message.content except Exception as e: print(f"OpenAI 이미지 분석 실패: {e}") + except Exception as e: print(f"이미지 분석 전체 실패: {e}") - # 기본 설명 반환 + return "맛있고 매력적인 음식점의 특별한 순간" + def _encode_image_to_base64(self, image_path: str) -> str: - """ - 이미지 파일을 base64로 인코딩 - Args: - image_path: 이미지 파일 경로 - Returns: - base64 인코딩된 이미지 문자열 - """ + """이미지 파일을 base64로 인코딩""" with open(image_path, "rb") as image_file: - # 이미지 크기 조정 (API 제한 고려) image = Image.open(image_file) - # 최대 크기 제한 (1024x1024) if image.width > 1024 or image.height > 1024: image.thumbnail((1024, 1024), Image.Resampling.LANCZOS) - # JPEG로 변환하여 파일 크기 줄이기 + if image.mode == 'RGBA': background = Image.new('RGB', image.size, (255, 255, 255)) background.paste(image, mask=image.split()[-1]) image = background + img_buffer = io.BytesIO() image.save(img_buffer, format='JPEG', quality=85) img_buffer.seek(0) return base64.b64encode(img_buffer.getvalue()).decode('utf-8') + def _generate_fallback_content(self, prompt: str) -> str: - """ - AI 서비스 실패시 기본 콘텐츠 생성 - Args: - prompt: 원본 프롬프트 - Returns: - 기본 콘텐츠 - """ + """AI 서비스 실패시 기본 콘텐츠 생성""" if "콘텐츠" in prompt or "게시글" in prompt: return """안녕하세요! 오늘도 맛있는 하루 되세요 😊 -우리 가게의 특별한 메뉴를 소개합니다! -정성껏 준비한 음식으로 여러분을 맞이하겠습니다. -많은 관심과 사랑 부탁드려요!""" + 우리 가게의 특별한 메뉴를 소개합니다! + 정성껏 준비한 음식으로 여러분을 맞이하겠습니다. + 많은 관심과 사랑 부탁드려요!""" elif "포스터" in prompt: return "특별한 이벤트\n지금 바로 확인하세요\n우리 가게에서 만나요\n놓치지 마세요!" else: - return "안녕하세요! 우리 가게를 찾아주셔서 감사합니다." \ No newline at end of file + return "안녕하세요! 우리 가게를 찾아주셔서 감사합니다." From 62646c29df5a2dc3fcf0090acf9d19a246c8f08b Mon Sep 17 00:00:00 2001 From: OhSeongRak Date: Wed, 11 Jun 2025 17:28:01 +0900 Subject: [PATCH 17/34] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- smarketing-ai/.env | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 smarketing-ai/.env diff --git a/smarketing-ai/.env b/smarketing-ai/.env deleted file mode 100644 index 49a0633..0000000 --- a/smarketing-ai/.env +++ /dev/null @@ -1,6 +0,0 @@ -CLAUDE_API_KEY=your_claude_api_key_here -OPENAI_API_KEY=your_openai_api_key_here -FLASK_ENV=development -UPLOAD_FOLDER=uploads -MAX_CONTENT_LENGTH=16777216 -SECRET_KEY=your-secret-key-for-production \ No newline at end of file From 032e8d0b0c977533ff7e21f50ddff16f90c63a71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=9C=EC=9D=80?= <61147083+BangSun98@users.noreply.github.com> Date: Wed, 11 Jun 2025 17:32:36 +0900 Subject: [PATCH 18/34] fix marketing-contents errors --- .../MarketingContentServiceApplication.java | 13 +- .../smarketing/content/config/JpaConfig.java | 18 +++ .../content/config/ObjectMapperConfig.java | 26 ++++ .../content/domain/model/Content.java | 3 + .../content/domain/model/ContentId.java | 7 +- .../domain/repository/ContentRepository.java | 2 + .../entity/ContentConditionsJpaEntity.java | 60 ++++++++ .../entity/ContentJpaEntity.java | 66 ++++++++ .../infrastructure/mapper/ContentMapper.java | 144 ++++++++++++++++++ .../repository/JpaContentRepository.java | 111 ++++++++++++++ .../SpringDataContentRepository.java | 51 +++++++ 11 files changed, 493 insertions(+), 8 deletions(-) create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/JpaConfig.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ObjectMapperConfig.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/SpringDataContentRepository.java diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java index 08115e2..50d69a1 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java @@ -9,9 +9,16 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories; * 마케팅 콘텐츠 서비스 메인 애플리케이션 클래스 * Clean Architecture 패턴을 적용한 마케팅 콘텐츠 관리 서비스 */ -@SpringBootApplication(scanBasePackages = {"com.won.smarketing.content", "com.won.smarketing.common"}) -@EntityScan(basePackages = {"com.won.smarketing.content.infrastructure.entity"}) -@EnableJpaRepositories(basePackages = {"com.won.smarketing.content.infrastructure.repository"}) +@SpringBootApplication(scanBasePackages = { + "com.won.smarketing.content", + "com.won.smarketing.common" +}) +@EnableJpaRepositories(basePackages = { + "com.won.smarketing.content.infrastructure.repository" +}) +@EntityScan(basePackages = { + "com.won.smarketing.content.domain.model" +}) public class MarketingContentServiceApplication { public static void main(String[] args) { diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/JpaConfig.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/JpaConfig.java new file mode 100644 index 0000000..e95312d --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/JpaConfig.java @@ -0,0 +1,18 @@ +// marketing-content/src/main/java/com/won/smarketing/content/config/JpaConfig.java +package com.won.smarketing.content.config; + +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +/** + * JPA 설정 클래스 + * + * @author smarketing-team + * @version 1.0 + */ +@Configuration +@EntityScan(basePackages = "com.won.smarketing.content.infrastructure.entity") +@EnableJpaRepositories(basePackages = "com.won.smarketing.content.infrastructure.repository") +public class JpaConfig { +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ObjectMapperConfig.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ObjectMapperConfig.java new file mode 100644 index 0000000..f9a77b8 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ObjectMapperConfig.java @@ -0,0 +1,26 @@ + + +// marketing-content/src/main/java/com/won/smarketing/content/config/ObjectMapperConfig.java +package com.won.smarketing.content.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * ObjectMapper 설정 클래스 + * + * @author smarketing-team + * @version 1.0 + */ +@Configuration +public class ObjectMapperConfig { + + @Bean + public ObjectMapper objectMapper() { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + return objectMapper; + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java index c83f178..4e95d02 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java @@ -131,6 +131,9 @@ public class Content { @Column(name = "updated_at") private LocalDateTime updatedAt; + public Content(ContentId of, ContentType contentType, Platform platform, String title, String content, List strings, List strings1, ContentStatus contentStatus, CreationConditions conditions, Long storeId, LocalDateTime createdAt, LocalDateTime updatedAt) { + } + // ==================== 비즈니스 로직 메서드 ==================== /** diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java index b2a77bb..13bb3b0 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java @@ -1,16 +1,13 @@ package com.won.smarketing.content.domain.model; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.EqualsAndHashCode; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; /** * 콘텐츠 식별자 값 객체 * 콘텐츠의 고유 식별자를 나타내는 도메인 객체 */ @Getter +@Setter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) @EqualsAndHashCode diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java index 194c7aa..818506f 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java @@ -4,6 +4,7 @@ import com.won.smarketing.content.domain.model.Content; import com.won.smarketing.content.domain.model.ContentId; import com.won.smarketing.content.domain.model.ContentType; import com.won.smarketing.content.domain.model.Platform; +import org.springframework.stereotype.Repository; import java.util.List; import java.util.Optional; @@ -12,6 +13,7 @@ import java.util.Optional; * 콘텐츠 저장소 인터페이스 * 콘텐츠 도메인의 데이터 접근 추상화 */ +@Repository public interface ContentRepository { /** diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java new file mode 100644 index 0000000..17f49f8 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java @@ -0,0 +1,60 @@ +package com.won.smarketing.content.infrastructure.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDate; + +/** + * 콘텐츠 조건 JPA 엔티티 + * + * @author smarketing-team + * @version 1.0 + */ +@Entity +@Table(name = "contents_conditions") +@Getter +@Setter +@NoArgsConstructor +public class ContentConditionsJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne + @JoinColumn(name = "content_id") + private ContentJpaEntity content; + + @Column(name = "category", length = 100) + private String category; + + @Column(name = "requirement", columnDefinition = "TEXT") + private String requirement; + + @Column(name = "tone_and_manner", length = 100) + private String toneAndManner; + + @Column(name = "emotion_intensity", length = 100) + private String emotionIntensity; + + @Column(name = "event_name", length = 200) + private String eventName; + + @Column(name = "start_date") + private LocalDate startDate; + + @Column(name = "end_date") + private LocalDate endDate; + + @Column(name = "photo_style", length = 100) + private String photoStyle; + + @Column(name = "TargetAudience", length = 100) + private String targetAudience; + + @Column(name = "PromotionType", length = 100) + private String PromotionType; +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java new file mode 100644 index 0000000..7f87560 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java @@ -0,0 +1,66 @@ +// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java +package com.won.smarketing.content.infrastructure.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 콘텐츠 JPA 엔티티 + * + * @author smarketing-team + * @version 1.0 + */ +@Entity +@Table(name = "contents") +@Getter +@Setter +@NoArgsConstructor +public class ContentJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "store_id", nullable = false) + private Long storeId; + + @Column(name = "content_type", nullable = false, length = 50) + private String contentType; + + @Column(name = "platform", length = 50) + private String platform; + + @Column(name = "title", length = 500) + private String title; + + @Column(name = "content", columnDefinition = "TEXT") + private String content; + + @Column(name = "hashtags", columnDefinition = "JSON") + private String hashtags; + + @Column(name = "images", columnDefinition = "JSON") + private String images; + + @Column(name = "status", length = 50) + private String status; + + @CreationTimestamp + @Column(name = "created_at") + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + // 연관 엔티티 + @OneToOne(mappedBy = "content", cascade = CascadeType.ALL, fetch = FetchType.LAZY) + private ContentConditionsJpaEntity conditions; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java new file mode 100644 index 0000000..49cc6b4 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java @@ -0,0 +1,144 @@ +package com.won.smarketing.content.infrastructure.mapper; + +import com.won.smarketing.content.domain.model.*; +import com.won.smarketing.content.infrastructure.entity.ContentConditionsJpaEntity; +import com.won.smarketing.content.infrastructure.entity.ContentJpaEntity; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.List; + +/** + * 콘텐츠 도메인-엔티티 매퍼 + * + * @author smarketing-team + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class ContentMapper { + + private final ObjectMapper objectMapper; + + /** + * 도메인 모델을 JPA 엔티티로 변환합니다. + * + * @param content 도메인 콘텐츠 + * @return JPA 엔티티 + */ + public ContentJpaEntity toEntity(Content content) { + if (content == null) { + return null; + } + + ContentJpaEntity entity = new ContentJpaEntity(); + if (content.getId() != null) { + entity.setId(content.getId()); + } + entity.setStoreId(content.getStoreId()); + entity.setContentType(content.getContentType().name()); + entity.setPlatform(content.getPlatform() != null ? content.getPlatform().name() : null); + entity.setTitle(content.getTitle()); + entity.setContent(content.getContent()); + entity.setHashtags(convertListToJson(content.getHashtags())); + entity.setImages(convertListToJson(content.getImages())); + entity.setStatus(content.getStatus().name()); + entity.setCreatedAt(content.getCreatedAt()); + entity.setUpdatedAt(content.getUpdatedAt()); + + // 조건 정보 매핑 + if (content.getCreationConditions() != null) { + ContentConditionsJpaEntity conditionsEntity = new ContentConditionsJpaEntity(); + conditionsEntity.setContent(entity); + conditionsEntity.setCategory(content.getCreationConditions().getCategory()); + conditionsEntity.setRequirement(content.getCreationConditions().getRequirement()); + conditionsEntity.setToneAndManner(content.getCreationConditions().getToneAndManner()); + conditionsEntity.setEmotionIntensity(content.getCreationConditions().getEmotionIntensity()); + conditionsEntity.setEventName(content.getCreationConditions().getEventName()); + conditionsEntity.setStartDate(content.getCreationConditions().getStartDate()); + conditionsEntity.setEndDate(content.getCreationConditions().getEndDate()); + conditionsEntity.setPhotoStyle(content.getCreationConditions().getPhotoStyle()); + entity.setConditions(conditionsEntity); + } + + return entity; + } + + /** + * JPA 엔티티를 도메인 모델로 변환합니다. + * + * @param entity JPA 엔티티 + * @return 도메인 콘텐츠 + */ + public Content toDomain(ContentJpaEntity entity) { + if (entity == null) { + return null; + } + + CreationConditions conditions = null; + if (entity.getConditions() != null) { + conditions = new CreationConditions( + entity.getConditions().getCategory(), + entity.getConditions().getRequirement(), + entity.getConditions().getToneAndManner(), + entity.getConditions().getEmotionIntensity(), + entity.getConditions().getEventName(), + entity.getConditions().getStartDate(), + entity.getConditions().getEndDate(), + entity.getConditions().getPhotoStyle(), + entity.getConditions().getTargetAudience(), + entity.getConditions().getPromotionType() + ); + } + + return new Content( + ContentId.of(entity.getId()), + ContentType.valueOf(entity.getContentType()), + entity.getPlatform() != null ? Platform.valueOf(entity.getPlatform()) : null, + entity.getTitle(), + entity.getContent(), + convertJsonToList(entity.getHashtags()), + convertJsonToList(entity.getImages()), + ContentStatus.valueOf(entity.getStatus()), + conditions, + entity.getStoreId(), + entity.getCreatedAt(), + entity.getUpdatedAt() + ); + } + + /** + * List를 JSON 문자열로 변환합니다. + */ + private String convertListToJson(List list) { + if (list == null || list.isEmpty()) { + return null; + } + try { + return objectMapper.writeValueAsString(list); + } catch (Exception e) { + log.warn("Failed to convert list to JSON: {}", e.getMessage()); + return null; + } + } + + /** + * JSON 문자열을 List로 변환합니다. + */ + private List convertJsonToList(String json) { + if (json == null || json.trim().isEmpty()) { + return Collections.emptyList(); + } + try { + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (Exception e) { + log.warn("Failed to convert JSON to list: {}", e.getMessage()); + return Collections.emptyList(); + } + } +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java new file mode 100644 index 0000000..9396d4d --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java @@ -0,0 +1,111 @@ +package com.won.smarketing.content.infrastructure.repository; + +import com.won.smarketing.content.domain.model.Content; +import com.won.smarketing.content.domain.model.ContentId; +import com.won.smarketing.content.domain.model.ContentType; +import com.won.smarketing.content.domain.model.Platform; +import com.won.smarketing.content.domain.repository.ContentRepository; +import com.won.smarketing.content.infrastructure.entity.ContentJpaEntity; +import com.won.smarketing.content.infrastructure.mapper.ContentMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * JPA 기반 콘텐츠 Repository 구현체 + * + * @author smarketing-team + * @version 1.0 + */ +@Repository +@RequiredArgsConstructor +@Slf4j +public class JpaContentRepository implements ContentRepository { + + private final SpringDataContentRepository springDataContentRepository; + private final ContentMapper contentMapper; + + /** + * 콘텐츠를 저장합니다. + * + * @param content 저장할 콘텐츠 + * @return 저장된 콘텐츠 + */ + @Override + public Content save(Content content) { + log.debug("Saving content: {}", content.getId()); + ContentJpaEntity entity = contentMapper.toEntity(content); + ContentJpaEntity savedEntity = springDataContentRepository.save(entity); + return contentMapper.toDomain(savedEntity); + } + + /** + * ID로 콘텐츠를 조회합니다. + * + * @param id 콘텐츠 ID + * @return 조회된 콘텐츠 + */ + @Override + public Optional findById(ContentId id) { + log.debug("Finding content by id: {}", id.getValue()); + return springDataContentRepository.findById(id.getValue()) + .map(contentMapper::toDomain); + } + + /** + * 필터 조건으로 콘텐츠 목록을 조회합니다. + * + * @param contentType 콘텐츠 타입 + * @param platform 플랫폼 + * @param period 기간 + * @param sortBy 정렬 기준 + * @return 콘텐츠 목록 + */ + @Override + public List findByFilters(ContentType contentType, Platform platform, String period, String sortBy) { + log.debug("Finding contents by filters - type: {}, platform: {}, period: {}, sortBy: {}", + contentType, platform, period, sortBy); + + List entities = springDataContentRepository.findByFilters( + contentType != null ? contentType.name() : null, + platform != null ? platform.name() : null, + period, + sortBy + ); + + return entities.stream() + .map(contentMapper::toDomain) + .collect(Collectors.toList()); + } + + /** + * 진행 중인 콘텐츠 목록을 조회합니다. + * + * @param period 기간 + * @return 진행 중인 콘텐츠 목록 + */ + @Override + public List findOngoingContents(String period) { + log.debug("Finding ongoing contents for period: {}", period); + List entities = springDataContentRepository.findOngoingContents(period); + + return entities.stream() + .map(contentMapper::toDomain) + .collect(Collectors.toList()); + } + + /** + * ID로 콘텐츠를 삭제합니다. + * + * @param id 콘텐츠 ID + */ + @Override + public void deleteById(ContentId id) { + log.debug("Deleting content by id: {}", id.getValue()); + springDataContentRepository.deleteById(id.getValue()); + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/SpringDataContentRepository.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/SpringDataContentRepository.java new file mode 100644 index 0000000..feba6b4 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/SpringDataContentRepository.java @@ -0,0 +1,51 @@ +package com.won.smarketing.content.infrastructure.repository; + +import com.won.smarketing.content.infrastructure.entity.ContentJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * Spring Data JPA 콘텐츠 Repository + * + * @author smarketing-team + * @version 1.0 + */ +@Repository +public interface SpringDataContentRepository extends JpaRepository { + + /** + * 필터 조건으로 콘텐츠를 조회합니다. + * + * @param contentType 콘텐츠 타입 + * @param platform 플랫폼 + * @param period 기간 + * @param sortBy 정렬 기준 + * @return 콘텐츠 목록 + */ + @Query("SELECT c FROM ContentJpaEntity c WHERE " + + "(:contentType IS NULL OR c.contentType = :contentType) AND " + + "(:platform IS NULL OR c.platform = :platform) AND " + + "(:period IS NULL OR DATE(c.createdAt) >= CURRENT_DATE - INTERVAL :period DAY) " + + "ORDER BY " + + "CASE WHEN :sortBy = 'latest' THEN c.createdAt END DESC, " + + "CASE WHEN :sortBy = 'oldest' THEN c.createdAt END ASC") + List findByFilters(@Param("contentType") String contentType, + @Param("platform") String platform, + @Param("period") String period, + @Param("sortBy") String sortBy); + + /** + * 진행 중인 콘텐츠를 조회합니다. + * + * @param period 기간 + * @return 진행 중인 콘텐츠 목록 + */ + @Query("SELECT c FROM ContentJpaEntity c " + + "WHERE c.status = 'PUBLISHED' AND " + + "(:period IS NULL OR DATE(c.createdAt) >= CURRENT_DATE - INTERVAL :period DAY)") + List findOngoingContents(@Param("period") String period); +} \ No newline at end of file From 1b5b6ebc6c145d4ba1ea87cefd55805d3e8d6aed Mon Sep 17 00:00:00 2001 From: OhSeongRak Date: Wed, 11 Jun 2025 17:35:20 +0900 Subject: [PATCH 19/34] =?UTF-8?q?refactor:=20=ED=8F=AC=ED=8A=B8=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- smarketing-ai/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/smarketing-ai/app.py b/smarketing-ai/app.py index a9e55d8..35cf56c 100644 --- a/smarketing-ai/app.py +++ b/smarketing-ai/app.py @@ -276,4 +276,4 @@ def create_app(): if __name__ == '__main__': app = create_app() - app.run(host='0.0.0.0', port=5000, debug=True) + app.run(host='0.0.0.0', port=5001, debug=True) From 26263cf2e85f321f0302dd216c5b9e70b88f5a03 Mon Sep 17 00:00:00 2001 From: OhSeongRak Date: Wed, 11 Jun 2025 17:35:27 +0900 Subject: [PATCH 20/34] =?UTF-8?q?refactor:=20=EB=B2=84=EC=A0=84=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- smarketing-ai/requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/smarketing-ai/requirements.txt b/smarketing-ai/requirements.txt index f3f76cd..4386f20 100644 --- a/smarketing-ai/requirements.txt +++ b/smarketing-ai/requirements.txt @@ -2,7 +2,7 @@ Flask==3.0.0 Flask-CORS==4.0.0 Pillow>=9.0.0 requests==2.31.0 -anthropic==0.8.1 -openai==1.6.1 +anthropic>=0.25.0 +openai>=1.12.0 python-dotenv==1.0.0 Werkzeug==3.0.1 \ No newline at end of file From c83ed0d033badd3ea9ce75e890830e20eba91045 Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 11 Jun 2025 17:56:39 +0900 Subject: [PATCH 21/34] fix: ai-recommend build --- .../AIRecommendServiceApplication.java | 14 +- .../application/service/AiApiService.java | 21 +++ .../service/MarketingTipService.java | 106 +++++++++---- .../service/WeatherDataService.java | 32 ++++ .../usecase/MarketingTipUseCase.java | 31 +++- .../recommend/config/CacheConfig.java | 13 ++ .../recommend/config/JpaConfig.java | 12 ++ .../recommend/config/WebClientConfig.java | 33 ++++ .../domain/model/BusinessInsight.java | 51 ++++++ .../recommend/domain/model/MarketingTip.java | 54 ++----- .../recommend/domain/model/StoreData.java | 61 +------- .../recommend/domain/model/TipId.java | 24 +-- .../recommend/domain/model/WeatherData.java | 61 +------- .../repository/BusinessInsightRepository.java | 15 ++ .../repository/MarketingTipRepository.java | 55 ++----- .../domain/service/StoreDataProvider.java | 8 +- .../domain/service/WeatherDataProvider.java | 6 +- .../external/AiApiServiceImpl.java | 137 +++++++++++++++++ .../external/ClaudeAiTipGenerator.java | 2 +- .../external/PythonAiTipGenerator.java | 137 +++++++++++++++++ .../external/StoreApiDataProvider.java | 136 ++++++++-------- .../external/WeatherApiDataProvider.java | 145 ++++-------------- .../JpaMarketingTipRepository.java | 38 +++++ .../persistence/MarketingTipEntity.java | 58 +++++++ .../MarketingTipJpaRepository.java | 14 ++ .../controller/RecommendationController.java | 66 ++++++-- .../presentation/dto/AIServiceRequest.java | 24 +++ .../presentation/dto/MarketingTipRequest.java | 22 +-- .../dto/MarketingTipResponse.java | 63 ++++++-- .../src/main/resources/application.yml | 13 +- 30 files changed, 977 insertions(+), 475 deletions(-) create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/AiApiService.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/WeatherDataService.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/CacheConfig.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/JpaConfig.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/WebClientConfig.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/BusinessInsight.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/BusinessInsightRepository.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/AiApiServiceImpl.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonAiTipGenerator.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/JpaMarketingTipRepository.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipEntity.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipJpaRepository.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/AIServiceRequest.java diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/AIRecommendServiceApplication.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/AIRecommendServiceApplication.java index 6ebb3f5..2c12c85 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/AIRecommendServiceApplication.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/AIRecommendServiceApplication.java @@ -2,18 +2,16 @@ package com.won.smarketing.recommend; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.autoconfigure.domain.EntityScan; -import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; /** - * AI 추천 서비스 메인 애플리케이션 클래스 - * Clean Architecture 패턴을 적용한 AI 마케팅 추천 서비스 + * AI 추천 서비스 메인 애플리케이션 */ -@SpringBootApplication(scanBasePackages = {"com.won.smarketing.recommend", "com.won.smarketing.common"}) -@EntityScan(basePackages = {"com.won.smarketing.recommend.infrastructure.entity"}) -@EnableJpaRepositories(basePackages = {"com.won.smarketing.recommend.infrastructure.repository"}) +@SpringBootApplication +@EnableJpaAuditing +@EnableCaching public class AIRecommendServiceApplication { - public static void main(String[] args) { SpringApplication.run(AIRecommendServiceApplication.class, args); } diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/AiApiService.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/AiApiService.java new file mode 100644 index 0000000..30338a0 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/AiApiService.java @@ -0,0 +1,21 @@ +package com.won.smarketing.recommend.domain.service; + +import com.won.smarketing.recommend.domain.model.StoreData; +import com.won.smarketing.recommend.domain.model.WeatherData; + +/** + * Python AI 서비스 인터페이스 + * AI 처리를 Python 서비스로 위임하는 도메인 서비스 + */ +public interface AiApiService { + + /** + * Python AI 서비스를 통한 마케팅 팁 생성 + * + * @param storeData 매장 정보 + * @param weatherData 날씨 정보 + * @param additionalRequirement 추가 요청사항 + * @return AI가 생성한 마케팅 팁 (한 줄) + */ + String generateMarketingTip(StoreData storeData, WeatherData weatherData, String additionalRequirement); +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java index 7d80205..67193b9 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java @@ -8,26 +8,26 @@ import com.won.smarketing.recommend.domain.model.StoreData; import com.won.smarketing.recommend.domain.model.TipId; import com.won.smarketing.recommend.domain.model.WeatherData; import com.won.smarketing.recommend.domain.repository.MarketingTipRepository; -import com.won.smarketing.recommend.domain.service.AiTipGenerator; import com.won.smarketing.recommend.domain.service.StoreDataProvider; import com.won.smarketing.recommend.domain.service.WeatherDataProvider; +import com.won.smarketing.recommend.domain.service.AiTipGenerator; import com.won.smarketing.recommend.presentation.dto.MarketingTipRequest; import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; - /** * 마케팅 팁 서비스 구현체 - * AI 기반 마케팅 팁 생성 및 저장 기능 구현 */ @Slf4j @Service @RequiredArgsConstructor -@Transactional(readOnly = true) +@Transactional public class MarketingTipService implements MarketingTipUseCase { private final MarketingTipRepository marketingTipRepository; @@ -35,49 +35,95 @@ public class MarketingTipService implements MarketingTipUseCase { private final WeatherDataProvider weatherDataProvider; private final AiTipGenerator aiTipGenerator; - /** - * AI 마케팅 팁 생성 - * - * @param request 마케팅 팁 생성 요청 - * @return 생성된 마케팅 팁 응답 - */ @Override - @Transactional public MarketingTipResponse generateMarketingTips(MarketingTipRequest request) { + log.info("마케팅 팁 생성 시작: storeId={}", request.getStoreId()); + try { - // 매장 정보 조회 + // 1. 매장 정보 조회 StoreData storeData = storeDataProvider.getStoreData(request.getStoreId()); log.debug("매장 정보 조회 완료: {}", storeData.getStoreName()); - // 날씨 정보 조회 + // 2. 날씨 정보 조회 WeatherData weatherData = weatherDataProvider.getCurrentWeather(storeData.getLocation()); - log.debug("날씨 정보 조회 완료: {} 도", weatherData.getTemperature()); + log.debug("날씨 정보 조회 완료: 온도={}, 상태={}", weatherData.getTemperature(), weatherData.getCondition()); - // AI를 사용하여 마케팅 팁 생성 - String tipContent = aiTipGenerator.generateTip(storeData, weatherData); - log.debug("AI 마케팅 팁 생성 완료"); + // 3. AI 팁 생성 + String aiGeneratedTip = aiTipGenerator.generateTip(storeData, weatherData, request.getAdditionalRequirement()); + log.debug("AI 팁 생성 완료: {}", aiGeneratedTip.substring(0, Math.min(50, aiGeneratedTip.length()))); - // 마케팅 팁 도메인 객체 생성 + // 4. 도메인 객체 생성 및 저장 MarketingTip marketingTip = MarketingTip.builder() .storeId(request.getStoreId()) - .tipContent(tipContent) + .tipContent(aiGeneratedTip) .weatherData(weatherData) .storeData(storeData) - .createdAt(LocalDateTime.now()) .build(); - // 마케팅 팁 저장 MarketingTip savedTip = marketingTipRepository.save(marketingTip); + log.info("마케팅 팁 저장 완료: tipId={}", savedTip.getId().getValue()); - return MarketingTipResponse.builder() - .tipId(savedTip.getId().getValue()) - .tipContent(savedTip.getTipContent()) - .createdAt(savedTip.getCreatedAt()) - .build(); + return convertToResponse(savedTip); } catch (Exception e) { - log.error("마케팅 팁 생성 중 오류 발생", e); - throw new BusinessException(ErrorCode.RECOMMENDATION_FAILED); + log.error("마케팅 팁 생성 중 오류: storeId={}", request.getStoreId(), e); + throw new BusinessException(ErrorCode.AI_TIP_GENERATION_FAILED); } } -} + + @Override + @Transactional(readOnly = true) + @Cacheable(value = "marketingTipHistory", key = "#storeId + '_' + #pageable.pageNumber + '_' + #pageable.pageSize") + public Page getMarketingTipHistory(Long storeId, Pageable pageable) { + log.info("마케팅 팁 이력 조회: storeId={}", storeId); + + Page tips = marketingTipRepository.findByStoreIdOrderByCreatedAtDesc(storeId, pageable); + + return tips.map(this::convertToResponse); + } + + @Override + @Transactional(readOnly = true) + public MarketingTipResponse getMarketingTip(Long tipId) { + log.info("마케팅 팁 상세 조회: tipId={}", tipId); + + MarketingTip marketingTip = marketingTipRepository.findById(tipId) + .orElseThrow(() -> new BusinessException(ErrorCode.MARKETING_TIP_NOT_FOUND)); + + return convertToResponse(marketingTip); + } + + private MarketingTipResponse convertToResponse(MarketingTip marketingTip) { + return MarketingTipResponse.builder() + .tipId(marketingTip.getId().getValue()) + .storeId(marketingTip.getStoreId()) + .storeName(marketingTip.getStoreData().getStoreName()) + .businessType(marketingTip.getStoreData().getBusinessType()) + .storeLocation(marketingTip.getStoreData().getLocation()) + .createdAt(marketingTip.getCreatedAt()) + .build(); + } + + public MarketingTip toDomain() { + WeatherData weatherData = WeatherData.builder() + .temperature(this.weatherTemperature) + .condition(this.weatherCondition) + .humidity(this.weatherHumidity) + .build(); + + StoreData storeData = StoreData.builder() + .storeName(this.storeName) + .businessType(this.businessType) + .location(this.storeLocation) + .build(); + + return MarketingTip.builder() + .id(this.id != null ? TipId.of(this.id) : null) + .storeId(this.storeId) + .tipContent(this.tipContent) + .weatherData(weatherData) + .storeData(storeData) + .createdAt(this.createdAt) + .build(); + } +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/WeatherDataService.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/WeatherDataService.java new file mode 100644 index 0000000..164a1a9 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/WeatherDataService.java @@ -0,0 +1,32 @@ +package com.won.smarketing.recommend.application.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + +/** + * 날씨 데이터 서비스 (Mock) + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class WeatherDataService { + + @Cacheable(value = "weatherData", key = "#location") + public com.won.smarketing.recommend.service.MarketingTipService.WeatherInfo getCurrentWeather(String location) { + log.debug("날씨 정보 조회: location={}", location); + + // Mock 데이터 반환 + double temperature = 20.0 + (Math.random() * 15); // 20-35도 + String[] conditions = {"맑음", "흐림", "비", "눈", "안개"}; + String condition = conditions[(int) (Math.random() * conditions.length)]; + double humidity = 50.0 + (Math.random() * 30); // 50-80% + + return com.won.smarketing.recommend.service.MarketingTipService.WeatherInfo.builder() + .temperature(Math.round(temperature * 10) / 10.0) + .condition(condition) + .humidity(Math.round(humidity * 10) / 10.0) + .build(); + } +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/usecase/MarketingTipUseCase.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/usecase/MarketingTipUseCase.java index b5e6598..b1a8329 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/usecase/MarketingTipUseCase.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/usecase/MarketingTipUseCase.java @@ -2,18 +2,37 @@ package com.won.smarketing.recommend.application.usecase; import com.won.smarketing.recommend.presentation.dto.MarketingTipRequest; import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; /** - * 마케팅 팁 관련 Use Case 인터페이스 - * AI 기반 마케팅 팁 생성 기능 정의 + * 마케팅 팁 생성 유즈케이스 인터페이스 + * 비즈니스 요구사항을 정의하는 애플리케이션 계층의 인터페이스 */ public interface MarketingTipUseCase { - + /** * AI 마케팅 팁 생성 - * + * * @param request 마케팅 팁 생성 요청 - * @return 생성된 마케팅 팁 응답 + * @return 생성된 마케팅 팁 정보 */ MarketingTipResponse generateMarketingTips(MarketingTipRequest request); -} + + /** + * 마케팅 팁 이력 조회 + * + * @param storeId 매장 ID + * @param pageable 페이징 정보 + * @return 마케팅 팁 이력 페이지 + */ + Page getMarketingTipHistory(Long storeId, Pageable pageable); + + /** + * 마케팅 팁 상세 조회 + * + * @param tipId 팁 ID + * @return 마케팅 팁 상세 정보 + */ + MarketingTipResponse getMarketingTip(Long tipId); +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/CacheConfig.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/CacheConfig.java new file mode 100644 index 0000000..9aec563 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/CacheConfig.java @@ -0,0 +1,13 @@ +package com.won.smarketing.recommend.config; + +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Configuration; + +/** + * 캐시 설정 + */ +@Configuration +@EnableCaching +public class CacheConfig { + // 기본 Simple 캐시 사용 +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/JpaConfig.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/JpaConfig.java new file mode 100644 index 0000000..de705f5 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/JpaConfig.java @@ -0,0 +1,12 @@ +package com.won.smarketing.recommend.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +/** + * JPA 설정 + */ +@Configuration +@EnableJpaRepositories +public class JpaConfig { +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/WebClientConfig.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/WebClientConfig.java new file mode 100644 index 0000000..47ed442 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/WebClientConfig.java @@ -0,0 +1,33 @@ +package com.won.smarketing.recommend.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; +import io.netty.channel.ChannelOption; +import io.netty.handler.timeout.ConnectTimeoutHandler; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import reactor.netty.http.client.HttpClient; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +/** + * WebClient 설정 + */ +@Configuration +public class WebClientConfig { + + @Bean + public WebClient webClient() { + HttpClient httpClient = HttpClient.create() + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000) + .responseTimeout(Duration.ofMillis(5000)) + .doOnConnected(conn -> conn + .addHandlerLast(new ConnectTimeoutHandler(5, TimeUnit.SECONDS))); + + return WebClient.builder() + .clientConnector(new ReactorClientHttpConnector(httpClient)) + .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(1024 * 1024)) + .build(); + } +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/BusinessInsight.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/BusinessInsight.java new file mode 100644 index 0000000..5022134 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/BusinessInsight.java @@ -0,0 +1,51 @@ +package com.won.smarketing.recommend.domain.model; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +/** + * 비즈니스 인사이트 엔티티 + */ +@Entity +@Table(name = "business_insights") +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EntityListeners(AuditingEntityListener.class) +public class BusinessInsight { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "insight_id") + private Long id; + + @Column(name = "store_id", nullable = false) + private Long storeId; + + @Column(name = "insight_type", nullable = false, length = 50) + private String insightType; + + @Column(name = "title", nullable = false, length = 200) + private String title; + + @Column(name = "description", columnDefinition = "TEXT") + private String description; + + @Column(name = "metric_value") + private Double metricValue; + + @Column(name = "recommendation", columnDefinition = "TEXT") + private String recommendation; + + @CreatedDate + @Column(name = "created_at") + private LocalDateTime createdAt; +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MarketingTip.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MarketingTip.java index 8ff523d..48bc27b 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MarketingTip.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MarketingTip.java @@ -1,58 +1,38 @@ package com.won.smarketing.recommend.domain.model; -import lombok.*; +import com.won.smarketing.recommend.domain.model.StoreData; +import com.won.smarketing.recommend.domain.model.TipId; +import com.won.smarketing.recommend.domain.model.WeatherData; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; import java.time.LocalDateTime; /** * 마케팅 팁 도메인 모델 - * AI가 생성한 마케팅 팁과 관련 정보를 관리 */ @Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor @Builder +@NoArgsConstructor +@AllArgsConstructor public class MarketingTip { - /** - * 마케팅 팁 고유 식별자 - */ private TipId id; - - /** - * 매장 ID - */ private Long storeId; - - /** - * AI가 생성한 마케팅 팁 내용 - */ private String tipContent; - - /** - * 팁 생성 시 참고한 날씨 데이터 - */ private WeatherData weatherData; - - /** - * 팁 생성 시 참고한 매장 데이터 - */ private StoreData storeData; - - /** - * 팁 생성 시각 - */ private LocalDateTime createdAt; - /** - * 팁 내용 업데이트 - * - * @param newContent 새로운 팁 내용 - */ - public void updateContent(String newContent) { - if (newContent == null || newContent.trim().isEmpty()) { - throw new IllegalArgumentException("팁 내용은 비어있을 수 없습니다."); - } - this.tipContent = newContent.trim(); + public static MarketingTip create(Long storeId, String tipContent, WeatherData weatherData, StoreData storeData) { + return MarketingTip.builder() + .storeId(storeId) + .tipContent(tipContent) + .weatherData(weatherData) + .storeData(storeData) + .createdAt(LocalDateTime.now()) + .build(); } } \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreData.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreData.java index 0f38f43..2afae1b 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreData.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreData.java @@ -1,66 +1,19 @@ package com.won.smarketing.recommend.domain.model; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; /** * 매장 데이터 값 객체 - * 마케팅 팁 생성에 사용되는 매장 정보 */ @Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor @Builder -@EqualsAndHashCode +@NoArgsConstructor +@AllArgsConstructor public class StoreData { - - /** - * 매장명 - */ private String storeName; - - /** - * 업종 - */ private String businessType; - - /** - * 매장 위치 (주소) - */ private String location; - - /** - * 매장 데이터 유효성 검증 - * - * @return 유효성 여부 - */ - public boolean isValid() { - return storeName != null && !storeName.trim().isEmpty() && - businessType != null && !businessType.trim().isEmpty() && - location != null && !location.trim().isEmpty(); - } - - /** - * 업종 카테고리 분류 - * - * @return 업종 카테고리 - */ - public String getBusinessCategory() { - if (businessType == null) { - return "기타"; - } - - String lowerCaseType = businessType.toLowerCase(); - - if (lowerCaseType.contains("카페") || lowerCaseType.contains("커피")) { - return "카페"; - } else if (lowerCaseType.contains("식당") || lowerCaseType.contains("레스토랑")) { - return "음식점"; - } else if (lowerCaseType.contains("베이커리") || lowerCaseType.contains("빵")) { - return "베이커리"; - } else if (lowerCaseType.contains("치킨") || lowerCaseType.contains("피자")) { - return "패스트푸드"; - } else { - return "기타"; - } - } -} +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/TipId.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/TipId.java index ae0b1df..105b3af 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/TipId.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/TipId.java @@ -1,29 +1,21 @@ package com.won.smarketing.recommend.domain.model; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; /** - * 마케팅 팁 식별자 값 객체 - * 마케팅 팁의 고유 식별자를 나타내는 도메인 객체 + * 팁 ID 값 객체 */ @Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) @EqualsAndHashCode +@NoArgsConstructor +@AllArgsConstructor public class TipId { - private Long value; - /** - * TipId 생성 팩토리 메서드 - * - * @param value 식별자 값 - * @return TipId 인스턴스 - */ public static TipId of(Long value) { - if (value == null || value <= 0) { - throw new IllegalArgumentException("TipId는 양수여야 합니다."); - } return new TipId(value); } -} +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/WeatherData.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/WeatherData.java index c1d4f54..90c6455 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/WeatherData.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/WeatherData.java @@ -1,66 +1,19 @@ package com.won.smarketing.recommend.domain.model; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; /** * 날씨 데이터 값 객체 - * 마케팅 팁 생성에 사용되는 날씨 정보 */ @Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor @Builder -@EqualsAndHashCode +@NoArgsConstructor +@AllArgsConstructor public class WeatherData { - - /** - * 온도 (섭씨) - */ private Double temperature; - - /** - * 날씨 상태 (맑음, 흐림, 비, 눈 등) - */ private String condition; - - /** - * 습도 (%) - */ private Double humidity; - - /** - * 날씨 데이터 유효성 검증 - * - * @return 유효성 여부 - */ - public boolean isValid() { - return temperature != null && - condition != null && !condition.trim().isEmpty() && - humidity != null && humidity >= 0 && humidity <= 100; - } - - /** - * 온도 기반 날씨 상태 설명 - * - * @return 날씨 상태 설명 - */ - public String getTemperatureDescription() { - if (temperature == null) { - return "알 수 없음"; - } - - if (temperature >= 30) { - return "매우 더움"; - } else if (temperature >= 25) { - return "더움"; - } else if (temperature >= 20) { - return "따뜻함"; - } else if (temperature >= 10) { - return "선선함"; - } else if (temperature >= 0) { - return "춥다"; - } else { - return "매우 춥다"; - } - } -} +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/BusinessInsightRepository.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/BusinessInsightRepository.java new file mode 100644 index 0000000..9925144 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/BusinessInsightRepository.java @@ -0,0 +1,15 @@ +package com.won.smarketing.recommend.domain.repository; + +import com.won.smarketing.recommend.domain.model.BusinessInsight; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface BusinessInsightRepository extends JpaRepository { + + List findByStoreIdOrderByCreatedAtDesc(Long storeId); + + List findByInsightTypeAndStoreId(String insightType, Long storeId); +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/MarketingTipRepository.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/MarketingTipRepository.java index fd5e537..140dff3 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/MarketingTipRepository.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/MarketingTipRepository.java @@ -1,56 +1,19 @@ package com.won.smarketing.recommend.domain.repository; import com.won.smarketing.recommend.domain.model.MarketingTip; -import com.won.smarketing.recommend.domain.model.TipId; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; -import java.time.LocalDateTime; -import java.util.List; import java.util.Optional; /** - * 마케팅 팁 저장소 인터페이스 - * 마케팅 팁 도메인의 데이터 접근 추상화 + * 마케팅 팁 레포지토리 인터페이스 */ public interface MarketingTipRepository { - - /** - * 마케팅 팁 저장 - * - * @param marketingTip 저장할 마케팅 팁 - * @return 저장된 마케팅 팁 - */ + MarketingTip save(MarketingTip marketingTip); - - /** - * 마케팅 팁 ID로 조회 - * - * @param id 마케팅 팁 ID - * @return 마케팅 팁 (Optional) - */ - Optional findById(TipId id); - - /** - * 매장별 마케팅 팁 목록 조회 - * - * @param storeId 매장 ID - * @return 마케팅 팁 목록 - */ - List findByStoreId(Long storeId); - - /** - * 특정 기간 내 생성된 마케팅 팁 조회 - * - * @param storeId 매장 ID - * @param startDate 시작 시각 - * @param endDate 종료 시각 - * @return 마케팅 팁 목록 - */ - List findByStoreIdAndCreatedAtBetween(Long storeId, LocalDateTime startDate, LocalDateTime endDate); - - /** - * 마케팅 팁 삭제 - * - * @param id 삭제할 마케팅 팁 ID - */ - void deleteById(TipId id); -} + + Optional findById(Long tipId); + + Page findByStoreIdOrderByCreatedAtDesc(Long storeId, Pageable pageable); +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/StoreDataProvider.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/StoreDataProvider.java index bb36bc3..aa526b1 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/StoreDataProvider.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/StoreDataProvider.java @@ -7,12 +7,12 @@ import com.won.smarketing.recommend.domain.model.StoreData; * 외부 매장 서비스로부터 매장 정보 조회 기능 정의 */ public interface StoreDataProvider { - + /** - * 매장 ID로 매장 데이터 조회 - * + * 매장 정보 조회 + * * @param storeId 매장 ID * @return 매장 데이터 */ StoreData getStoreData(Long storeId); -} +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/WeatherDataProvider.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/WeatherDataProvider.java index 5129f46..6f31ae0 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/WeatherDataProvider.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/WeatherDataProvider.java @@ -7,12 +7,12 @@ import com.won.smarketing.recommend.domain.model.WeatherData; * 외부 날씨 API로부터 날씨 정보 조회 기능 정의 */ public interface WeatherDataProvider { - + /** * 특정 위치의 현재 날씨 정보 조회 - * + * * @param location 위치 (주소) * @return 날씨 데이터 */ WeatherData getCurrentWeather(String location); -} +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/AiApiServiceImpl.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/AiApiServiceImpl.java new file mode 100644 index 0000000..b5fbed3 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/AiApiServiceImpl.java @@ -0,0 +1,137 @@ +//import com.won.smarketing.recommend.domain.model.StoreData; +//import com.won.smarketing.recommend.domain.model.WeatherData; +//import com.won.smarketing.recommend.domain.service.AiTipGenerator; +//import lombok.RequiredArgsConstructor; +//import lombok.extern.slf4j.Slf4j; +//import org.springframework.beans.factory.annotation.Value; +//import org.springframework.stereotype.Service; +//import org.springframework.web.reactive.function.client.WebClient; +// +//import java.time.Duration; +//import java.util.Map; +// +///** +// * Python AI 팁 생성 구현체 +// */ +//@Slf4j +//@Service +//@RequiredArgsConstructor +//public class PythonAiTipGenerator implements AiTipGenerator { +// +// private final WebClient webClient; +// +// @Value("${external.python-ai-service.base-url}") +// private String pythonAiServiceBaseUrl; +// +// @Value("${external.python-ai-service.api-key}") +// private String pythonAiServiceApiKey; +// +// @Value("${external.python-ai-service.timeout}") +// private int timeout; +// +// @Override +// public String generateTip(StoreData storeData, WeatherData weatherData, String additionalRequirement) { +// try { +// log.debug("Python AI 서비스 호출: store={}, weather={}도", +// storeData.getStoreName(), weatherData.getTemperature()); +// +// // Python AI 서비스 사용 가능 여부 확인 +// if (isPythonServiceAvailable()) { +// return callPythonAiService(storeData, weatherData, additionalRequirement); +// } else { +// log.warn("Python AI 서비스 사용 불가, Fallback 처리"); +// return createFallbackTip(storeData, weatherData, additionalRequirement); +// } +// +// } catch (Exception e) { +// log.error("Python AI 서비스 호출 실패, Fallback 처리: {}", e.getMessage()); +// return createFallbackTip(storeData, weatherData, additionalRequirement); +// } +// } +// +// private boolean isPythonServiceAvailable() { +// return !pythonAiServiceApiKey.equals("dummy-key"); +// } +// +// private String callPythonAiService(StoreData storeData, WeatherData weatherData, String additionalRequirement) { +// try { +// Map requestData = Map.of( +// "store_name", storeData.getStoreName(), +// "business_type", storeData.getBusinessType(), +// "location", storeData.getLocation(), +// "temperature", weatherData.getTemperature(), +// "weather_condition", weatherData.getCondition(), +// "humidity", weatherData.getHumidity(), +// "additional_requirement", additionalRequirement != null ? additionalRequirement : "" +// ); +// +// PythonAiResponse response = webClient +// .post() +// .uri(pythonAiServiceBaseUrl + "/api/v1/generate-marketing-tip") +// .header("Authorization", "Bearer " + pythonAiServiceApiKey) +// .header("Content-Type", "application/json") +// .bodyValue(requestData) +// .retrieve() +// .bodyToMono(PythonAiResponse.class) +// .timeout(Duration.ofMillis(timeout)) +// .block(); +// +// if (response != null && response.getTip() != null && !response.getTip().trim().isEmpty()) { +// return response.getTip(); +// } +// } catch (Exception e) { +// log.error("Python AI 서비스 실제 호출 실패: {}", e.getMessage()); +// } +// +// return createFallbackTip(storeData, weatherData, additionalRequirement); +// } +// +// private String createFallbackTip(StoreData storeData, WeatherData weatherData, String additionalRequirement) { +// String businessType = storeData.getBusinessType(); +// double temperature = weatherData.getTemperature(); +// String condition = weatherData.getCondition(); +// String storeName = storeData.getStoreName(); +// +// // 추가 요청사항이 있는 경우 우선 반영 +// if (additionalRequirement != null && !additionalRequirement.trim().isEmpty()) { +// return String.format("%s에서 %s를 고려한 특별한 서비스로 고객들을 맞이해보세요!", +// storeName, additionalRequirement); +// } +// +// // 날씨와 업종 기반 규칙 +// if (temperature > 25) { +// if (businessType.contains("카페")) { +// return String.format("더운 날씨(%.1f도)에는 시원한 아이스 음료와 디저트로 고객들을 시원하게 만족시켜보세요!", temperature); +// } else { +// return "더운 여름날, 시원한 음료나 냉면으로 고객들에게 청량감을 선사해보세요!"; +// } +// } else if (temperature < 10) { +// if (businessType.contains("카페")) { +// return String.format("추운 날씨(%.1f도)에는 따뜻한 음료와 베이커리로 고객들에게 따뜻함을 전해보세요!", temperature); +// } else { +// return "추운 겨울날, 따뜻한 국물 요리로 고객들의 몸과 마음을 따뜻하게 해보세요!"; +// } +// } +// +// if (condition.contains("비")) { +// return "비 오는 날에는 따뜻한 음료와 분위기로 고객들의 마음을 따뜻하게 해보세요!"; +// } +// +// // 기본 팁 +// return String.format("%s에서 오늘(%.1f도, %s) 같은 날씨에 어울리는 특별한 서비스로 고객들을 맞이해보세요!", +// storeName, temperature, condition); +// } +// +// private static class PythonAiResponse { +// private String tip; +// private String status; +// private String message; +// +// public String getTip() { return tip; } +// public void setTip(String tip) { this.tip = tip; } +// public String getStatus() { return status; } +// public void setStatus(String status) { this.status = status; } +// public String getMessage() { return message; } +// public void setMessage(String message) { this.message = message; } +// } +//} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/ClaudeAiTipGenerator.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/ClaudeAiTipGenerator.java index 2a3f5ce..827ef54 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/ClaudeAiTipGenerator.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/ClaudeAiTipGenerator.java @@ -151,7 +151,7 @@ public class ClaudeAiTipGenerator implements AiTipGenerator { } // 업종별 기본 팁 - String businessCategory = storeData.getBusinessCategory(); + String businessCategory = storeData.getBusinessType(); switch (businessCategory) { case "카페": tip.append("인스타그램용 예쁜 음료 사진을 올려보세요."); diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonAiTipGenerator.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonAiTipGenerator.java new file mode 100644 index 0000000..44a5f06 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonAiTipGenerator.java @@ -0,0 +1,137 @@ +import com.won.smarketing.recommend.domain.model.StoreData; +import com.won.smarketing.recommend.domain.model.WeatherData; +import com.won.smarketing.recommend.domain.service.AiTipGenerator; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +import java.time.Duration; +import java.util.Map; + +/** + * Python AI 팁 생성 구현체 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PythonAiTipGenerator implements AiTipGenerator { + + private final WebClient webClient; + + @Value("${external.python-ai-service.base-url}") + private String pythonAiServiceBaseUrl; + + @Value("${external.python-ai-service.api-key}") + private String pythonAiServiceApiKey; + + @Value("${external.python-ai-service.timeout}") + private int timeout; + + @Override + public String generateTip(StoreData storeData, WeatherData weatherData, String additionalRequirement) { + try { + log.debug("Python AI 서비스 호출: store={}, weather={}도", + storeData.getStoreName(), weatherData.getTemperature()); + + // Python AI 서비스 사용 가능 여부 확인 + if (isPythonServiceAvailable()) { + return callPythonAiService(storeData, weatherData, additionalRequirement); + } else { + log.warn("Python AI 서비스 사용 불가, Fallback 처리"); + return createFallbackTip(storeData, weatherData, additionalRequirement); + } + + } catch (Exception e) { + log.error("Python AI 서비스 호출 실패, Fallback 처리: {}", e.getMessage()); + return createFallbackTip(storeData, weatherData, additionalRequirement); + } + } + + private boolean isPythonServiceAvailable() { + return !pythonAiServiceApiKey.equals("dummy-key"); + } + + private String callPythonAiService(StoreData storeData, WeatherData weatherData, String additionalRequirement) { + try { + Map requestData = Map.of( + "store_name", storeData.getStoreName(), + "business_type", storeData.getBusinessType(), + "location", storeData.getLocation(), + "temperature", weatherData.getTemperature(), + "weather_condition", weatherData.getCondition(), + "humidity", weatherData.getHumidity(), + "additional_requirement", additionalRequirement != null ? additionalRequirement : "" + ); + + PythonAiResponse response = webClient + .post() + .uri(pythonAiServiceBaseUrl + "/api/v1/generate-marketing-tip") + .header("Authorization", "Bearer " + pythonAiServiceApiKey) + .header("Content-Type", "application/json") + .bodyValue(requestData) + .retrieve() + .bodyToMono(PythonAiResponse.class) + .timeout(Duration.ofMillis(timeout)) + .block(); + + if (response != null && response.getTip() != null && !response.getTip().trim().isEmpty()) { + return response.getTip(); + } + } catch (Exception e) { + log.error("Python AI 서비스 실제 호출 실패: {}", e.getMessage()); + } + + return createFallbackTip(storeData, weatherData, additionalRequirement); + } + + private String createFallbackTip(StoreData storeData, WeatherData weatherData, String additionalRequirement) { + String businessType = storeData.getBusinessType(); + double temperature = weatherData.getTemperature(); + String condition = weatherData.getCondition(); + String storeName = storeData.getStoreName(); + + // 추가 요청사항이 있는 경우 우선 반영 + if (additionalRequirement != null && !additionalRequirement.trim().isEmpty()) { + return String.format("%s에서 %s를 고려한 특별한 서비스로 고객들을 맞이해보세요!", + storeName, additionalRequirement); + } + + // 날씨와 업종 기반 규칙 + if (temperature > 25) { + if (businessType.contains("카페")) { + return String.format("더운 날씨(%.1f도)에는 시원한 아이스 음료와 디저트로 고객들을 시원하게 만족시켜보세요!", temperature); + } else { + return "더운 여름날, 시원한 음료나 냉면으로 고객들에게 청량감을 선사해보세요!"; + } + } else if (temperature < 10) { + if (businessType.contains("카페")) { + return String.format("추운 날씨(%.1f도)에는 따뜻한 음료와 베이커리로 고객들에게 따뜻함을 전해보세요!", temperature); + } else { + return "추운 겨울날, 따뜻한 국물 요리로 고객들의 몸과 마음을 따뜻하게 해보세요!"; + } + } + + if (condition.contains("비")) { + return "비 오는 날에는 따뜻한 음료와 분위기로 고객들의 마음을 따뜻하게 해보세요!"; + } + + // 기본 팁 + return String.format("%s에서 오늘(%.1f도, %s) 같은 날씨에 어울리는 특별한 서비스로 고객들을 맞이해보세요!", + storeName, temperature, condition); + } + + private static class PythonAiResponse { + private String tip; + private String status; + private String message; + + public String getTip() { return tip; } + public void setTip(String tip) { this.tip = tip; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public String getMessage() { return message; } + public void setMessage(String message) { this.message = message; } + } +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/StoreApiDataProvider.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/StoreApiDataProvider.java index 51efb70..ac84ee4 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/StoreApiDataProvider.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/StoreApiDataProvider.java @@ -7,16 +7,15 @@ import com.won.smarketing.recommend.domain.service.StoreDataProvider; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClientResponseException; -import reactor.core.publisher.Mono; import java.time.Duration; /** * 매장 API 데이터 제공자 구현체 - * 외부 매장 서비스 API를 통해 매장 정보 조회 */ @Slf4j @Service @@ -28,83 +27,98 @@ public class StoreApiDataProvider implements StoreDataProvider { @Value("${external.store-service.base-url}") private String storeServiceBaseUrl; - /** - * 매장 ID로 매장 데이터 조회 - * - * @param storeId 매장 ID - * @return 매장 데이터 - */ + @Value("${external.store-service.timeout}") + private int timeout; + @Override + @Cacheable(value = "storeData", key = "#storeId") public StoreData getStoreData(Long storeId) { try { - log.debug("매장 정보 조회 시작: storeId={}", storeId); - - StoreApiResponse response = webClient - .get() - .uri(storeServiceBaseUrl + "/api/store?storeId=" + storeId) - .retrieve() - .bodyToMono(StoreApiResponse.class) - .timeout(Duration.ofSeconds(10)) - .block(); + log.debug("매장 정보 조회 시도: storeId={}", storeId); - if (response == null || response.getData() == null) { - throw new BusinessException(ErrorCode.STORE_NOT_FOUND); + // 외부 서비스 연결 시도, 실패 시 Mock 데이터 반환 + if (isStoreServiceAvailable()) { + return callStoreService(storeId); + } else { + log.warn("매장 서비스 연결 불가, Mock 데이터 반환: storeId={}", storeId); + return createMockStoreData(storeId); } - StoreApiData storeApiData = response.getData(); - - StoreData storeData = StoreData.builder() - .storeName(storeApiData.getStoreName()) - .businessType(storeApiData.getBusinessType()) - .location(storeApiData.getAddress()) - .build(); - - log.debug("매장 정보 조회 완료: {}", storeData.getStoreName()); - return storeData; - - } catch (WebClientResponseException e) { - log.error("매장 서비스 API 호출 실패: storeId={}, status={}", storeId, e.getStatusCode(), e); - throw new BusinessException(ErrorCode.EXTERNAL_API_ERROR); } catch (Exception e) { - log.error("매장 정보 조회 중 오류 발생: storeId={}", storeId, e); - throw new BusinessException(ErrorCode.EXTERNAL_API_ERROR); + log.error("매장 정보 조회 실패, Mock 데이터 반환: storeId={}", storeId, e); + return createMockStoreData(storeId); } } - /** - * 매장 API 응답 DTO - */ + private boolean isStoreServiceAvailable() { + return !storeServiceBaseUrl.equals("http://localhost:8082"); + } + + private StoreData callStoreService(Long storeId) { + try { + StoreApiResponse response = webClient + .get() + .uri(storeServiceBaseUrl + "/api/store/" + storeId) + .retrieve() + .bodyToMono(StoreApiResponse.class) + .timeout(Duration.ofMillis(timeout)) + .block(); + + if (response != null && response.getData() != null) { + StoreApiResponse.StoreInfo storeInfo = response.getData(); + return StoreData.builder() + .storeName(storeInfo.getStoreName()) + .businessType(storeInfo.getBusinessType()) + .location(storeInfo.getAddress()) + .build(); + } + } catch (WebClientResponseException e) { + if (e.getStatusCode().value() == 404) { + throw new BusinessException(ErrorCode.STORE_NOT_FOUND); + } + log.error("매장 서비스 호출 실패: {}", e.getMessage()); + } + + return createMockStoreData(storeId); + } + + private StoreData createMockStoreData(Long storeId) { + return StoreData.builder() + .storeName("테스트 카페 " + storeId) + .businessType("카페") + .location("서울시 강남구") + .build(); + } + private static class StoreApiResponse { private int status; private String message; - private StoreApiData data; + private StoreInfo data; - // Getters and Setters public int getStatus() { return status; } public void setStatus(int status) { this.status = status; } public String getMessage() { return message; } public void setMessage(String message) { this.message = message; } - public StoreApiData getData() { return data; } - public void setData(StoreApiData data) { this.data = data; } - } + public StoreInfo getData() { return data; } + public void setData(StoreInfo data) { this.data = data; } - /** - * 매장 API 데이터 DTO - */ - private static class StoreApiData { - private Long storeId; - private String storeName; - private String businessType; - private String address; + static class StoreInfo { + private Long storeId; + private String storeName; + private String businessType; + private String address; + private String phoneNumber; - // Getters and Setters - public Long getStoreId() { return storeId; } - public void setStoreId(Long storeId) { this.storeId = storeId; } - public String getStoreName() { return storeName; } - public void setStoreName(String storeName) { this.storeName = storeName; } - public String getBusinessType() { return businessType; } - public void setBusinessType(String businessType) { this.businessType = businessType; } - public String getAddress() { return address; } - public void setAddress(String address) { this.address = address; } + public Long getStoreId() { return storeId; } + public void setStoreId(Long storeId) { this.storeId = storeId; } + public String getStoreName() { return storeName; } + public void setStoreName(String storeName) { this.storeName = storeName; } + public String getBusinessType() { return businessType; } + public void setBusinessType(String businessType) { this.businessType = businessType; } + public String getAddress() { return address; } + public void setAddress(String address) { this.address = address; } + public String getPhoneNumber() { return phoneNumber; } + public void setPhoneNumber(String phoneNumber) { this.phoneNumber = phoneNumber; } + } } -} +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/WeatherApiDataProvider.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/WeatherApiDataProvider.java index 4896c5a..8bf4d7c 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/WeatherApiDataProvider.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/WeatherApiDataProvider.java @@ -1,22 +1,18 @@ package com.won.smarketing.recommend.infrastructure.external; -import com.won.smarketing.common.exception.BusinessException; -import com.won.smarketing.common.exception.ErrorCode; import com.won.smarketing.recommend.domain.model.WeatherData; import com.won.smarketing.recommend.domain.service.WeatherDataProvider; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; -import org.springframework.web.reactive.function.client.WebClientResponseException; -import reactor.core.publisher.Mono; import java.time.Duration; /** * 날씨 API 데이터 제공자 구현체 - * 외부 날씨 API를 통해 날씨 정보 조회 */ @Slf4j @Service @@ -28,128 +24,45 @@ public class WeatherApiDataProvider implements WeatherDataProvider { @Value("${external.weather-api.api-key}") private String weatherApiKey; - @Value("${external.weather-api.base-url}") - private String weatherApiBaseUrl; + @Value("${external.weather-api.timeout}") + private int timeout; - /** - * 특정 위치의 현재 날씨 정보 조회 - * - * @param location 위치 (주소) - * @return 날씨 데이터 - */ @Override - public WeatherApiResponse getCurrentWeather(String location) { + @Cacheable(value = "weatherData", key = "#location") + public WeatherData getCurrentWeather(String location) { try { - log.debug("날씨 정보 조회 시작: location={}", location); - - // 한국 주요 도시로 단순화 - String city = extractCity(location); - - WeatherApiResponse response = webClient - .get() - .uri(uriBuilder -> uriBuilder - .scheme("https") - .host("api.openweathermap.org") - .path("/data/2.5/weather") - .queryParam("q", city + ",KR") - .queryParam("appid", weatherApiKey) - .queryParam("units", "metric") - .queryParam("lang", "kr") - .build()) - .retrieve() - .bodyToMono(WeatherApiResponse.class) - .timeout(Duration.ofSeconds(10)) - .onErrorReturn(createDefaultWeatherData()) // 오류 시 기본값 반환 - .block(); + log.debug("날씨 정보 조회: location={}", location); - if (response == null) { - return createDefaultWeatherData(); + // 개발 환경에서는 Mock 데이터 반환 + if (weatherApiKey.equals("dummy-key")) { + return createMockWeatherData(location); } - WeatherData weatherData = WeatherData.builder() - .temperature(response.getMain().getTemp()) - .condition(response.getWeather()[0].getDescription()) - .humidity(response.getMain().getHumidity()) - .build(); - - log.debug("날씨 정보 조회 완료: {}도, {}", weatherData.getTemperature(), weatherData.getCondition()); - return weatherData; + // 실제 날씨 API 호출 (향후 구현) + return callWeatherApi(location); } catch (Exception e) { - log.warn("날씨 정보 조회 실패, 기본값 사용: location={}", location, e); - return createDefaultWeatherData(); + log.warn("날씨 정보 조회 실패, Mock 데이터 사용: location={}", location, e); + return createMockWeatherData(location); } } - /** - * 주소에서 도시명 추출 - * - * @param location 전체 주소 - * @return 도시명 - */ - private String extractCity(String location) { - if (location == null || location.trim().isEmpty()) { - return "Seoul"; - } - - // 서울, 부산, 대구, 인천, 광주, 대전, 울산 등 주요 도시 추출 - String[] cities = {"서울", "부산", "대구", "인천", "광주", "대전", "울산", "세종", "수원", "창원"}; - - for (String city : cities) { - if (location.contains(city)) { - return city; - } - } - - return "Seoul"; // 기본값 + private WeatherData callWeatherApi(String location) { + // 실제 OpenWeatherMap API 호출 로직 (향후 구현) + log.info("실제 날씨 API 호출: {}", location); + return createMockWeatherData(location); } - /** - * 기본 날씨 데이터 생성 (API 호출 실패 시 사용) - * - * @return 기본 날씨 데이터 - */ - private WeatherApiResponse createDefaultWeatherData() { - WeatherApiResponse response = new WeatherApiResponse(); - response.setMain(new WeatherApiResponse.Main()); - response.getMain().setTemp(20.0); // 기본 온도 20도 - response.getMain().setHumidity(60.0); // 기본 습도 60% - - WeatherApiResponse.Weather[] weather = new WeatherApiResponse.Weather[1]; - weather[0] = new WeatherApiResponse.Weather(); - weather[0].setDescription("맑음"); - response.setWeather(weather); - - return response; + private WeatherData createMockWeatherData(String location) { + double temperature = 20.0 + (Math.random() * 15); // 20-35도 + String[] conditions = {"맑음", "흐림", "비", "눈", "안개"}; + String condition = conditions[(int) (Math.random() * conditions.length)]; + double humidity = 50.0 + (Math.random() * 30); // 50-80% + + return WeatherData.builder() + .temperature(Math.round(temperature * 10) / 10.0) + .condition(condition) + .humidity(Math.round(humidity * 10) / 10.0) + .build(); } - - /** - * 날씨 API 응답 DTO - */ - private static class WeatherApiResponse { - private Main main; - private Weather[] weather; - - public Main getMain() { return main; } - public void setMain(Main main) { this.main = main; } - public Weather[] getWeather() { return weather; } - public void setWeather(Weather[] weather) { this.weather = weather; } - - static class Main { - private Double temp; - private Double humidity; - - public Double getTemp() { return temp; } - public void setTemp(Double temp) { this.temp = temp; } - public Double getHumidity() { return humidity; } - public void setHumidity(Double humidity) { this.humidity = humidity; } - } - - static class Weather { - private String description; - - public String getDescription() { return description; } - public void setDescription(String description) { this.description = description; } - } - } -} +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/JpaMarketingTipRepository.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/JpaMarketingTipRepository.java new file mode 100644 index 0000000..45d7218 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/JpaMarketingTipRepository.java @@ -0,0 +1,38 @@ +import com.won.smarketing.recommend.domain.model.MarketingTip; +import com.won.smarketing.recommend.domain.repository.MarketingTipRepository; +import com.won.smarketing.recommend.infrastructure.persistence.MarketingTipJpaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * JPA 마케팅 팁 레포지토리 구현체 + */ +@Repository +@RequiredArgsConstructor +public class JpaMarketingTipRepository implements MarketingTipRepository { + + private final MarketingTipJpaRepository jpaRepository; + + @Override + public MarketingTip save(MarketingTip marketingTip) { + com.won.smarketing.recommend.entity.MarketingTipEntity entity = MarketingTipEntity.fromDomain(marketingTip); + com.won.smarketing.recommend.entity.MarketingTipEntity savedEntity = jpaRepository.save(entity); + return savedEntity.toDomain(); + } + + @Override + public Optional findById(Long tipId) { + return jpaRepository.findById(tipId) + .map(MarketingTipEntity::toDomain); + } + + @Override + public Page findByStoreIdOrderByCreatedAtDesc(Long storeId, Pageable pageable) { + return jpaRepository.findByStoreIdOrderByCreatedAtDesc(storeId, pageable) + .map(MarketingTipEntity::toDomain); + } +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipEntity.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipEntity.java new file mode 100644 index 0000000..7d47714 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipEntity.java @@ -0,0 +1,58 @@ +package com.won.smarketing.recommend.entity; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +/** + * 마케팅 팁 JPA 엔티티 + */ +@Entity +@Table(name = "marketing_tips") +@EntityListeners(AuditingEntityListener.class) +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MarketingTipEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "store_id", nullable = false) + private Long storeId; + + @Column(name = "tip_content", columnDefinition = "TEXT", nullable = false) + private String tipContent; + + // WeatherData 임베디드 + @Column(name = "weather_temperature") + private Double weatherTemperature; + + @Column(name = "weather_condition", length = 100) + private String weatherCondition; + + @Column(name = "weather_humidity") + private Double weatherHumidity; + + // StoreData 임베디드 + @Column(name = "store_name", length = 200) + private String storeName; + + @Column(name = "business_type", length = 100) + private String businessType; + + @Column(name = "store_location", length = 500) + private String storeLocation; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipJpaRepository.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipJpaRepository.java new file mode 100644 index 0000000..ca1ec19 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipJpaRepository.java @@ -0,0 +1,14 @@ +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +/** + * 마케팅 팁 JPA 레포지토리 + */ +public interface MarketingTipJpaRepository extends JpaRepository { + + @Query("SELECT m FROM MarketingTipEntity m WHERE m.storeId = :storeId ORDER BY m.createdAt DESC") + Page findByStoreIdOrderByCreatedAtDesc(@Param("storeId") Long storeId, Pageable pageable); +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java index e929efb..fbbab48 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java @@ -5,35 +5,73 @@ import com.won.smarketing.recommend.application.usecase.MarketingTipUseCase; 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.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import jakarta.validation.Valid; /** - * AI 마케팅 추천을 위한 REST API 컨트롤러 - * AI 기반 마케팅 팁 생성 기능 제공 + * AI 마케팅 추천 컨트롤러 */ -@Tag(name = "AI 마케팅 추천", description = "AI 기반 맞춤형 마케팅 추천 API") +@Tag(name = "AI 추천", description = "AI 기반 마케팅 팁 추천 API") +@Slf4j @RestController -@RequestMapping("/api/recommendation") +@RequestMapping("/api/recommendations") @RequiredArgsConstructor public class RecommendationController { private final MarketingTipUseCase marketingTipUseCase; - /** - * AI 마케팅 팁 생성 - * - * @param request 마케팅 팁 생성 요청 - * @return 생성된 마케팅 팁 - */ - @Operation(summary = "AI 마케팅 팁 생성", description = "매장 특성과 환경 정보를 바탕으로 AI 마케팅 팁을 생성합니다.") + @Operation( + summary = "AI 마케팅 팁 생성", + description = "매장 정보와 환경 데이터를 기반으로 AI 마케팅 팁을 생성합니다." + ) @PostMapping("/marketing-tips") - public ResponseEntity> generateMarketingTips(@Valid @RequestBody MarketingTipRequest request) { + public ResponseEntity> generateMarketingTips( + @Parameter(description = "마케팅 팁 생성 요청") @Valid @RequestBody MarketingTipRequest request) { + + log.info("AI 마케팅 팁 생성 요청: storeId={}", request.getStoreId()); + MarketingTipResponse response = marketingTipUseCase.generateMarketingTips(request); - return ResponseEntity.ok(ApiResponse.success(response, "AI 마케팅 팁이 성공적으로 생성되었습니다.")); + + log.info("AI 마케팅 팁 생성 완료: tipId={}", response.getTipId()); + return ResponseEntity.ok(ApiResponse.success(response)); } -} + + @Operation( + summary = "마케팅 팁 이력 조회", + description = "특정 매장의 마케팅 팁 생성 이력을 조회합니다." + ) + @GetMapping("/marketing-tips") + public ResponseEntity>> getMarketingTipHistory( + @Parameter(description = "매장 ID") @RequestParam Long storeId, + Pageable pageable) { + + log.info("마케팅 팁 이력 조회: storeId={}, page={}", storeId, pageable.getPageNumber()); + + Page response = marketingTipUseCase.getMarketingTipHistory(storeId, pageable); + + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @Operation( + summary = "마케팅 팁 상세 조회", + description = "특정 마케팅 팁의 상세 정보를 조회합니다." + ) + @GetMapping("/marketing-tips/{tipId}") + public ResponseEntity> getMarketingTip( + @Parameter(description = "팁 ID") @PathVariable Long tipId) { + + log.info("마케팅 팁 상세 조회: tipId={}", tipId); + + MarketingTipResponse response = marketingTipUseCase.getMarketingTip(tipId); + + return ResponseEntity.ok(ApiResponse.success(response)); + } +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/AIServiceRequest.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/AIServiceRequest.java new file mode 100644 index 0000000..396e20c --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/AIServiceRequest.java @@ -0,0 +1,24 @@ +package com.won.smarketing.recommend.presentation.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +/** + * Python AI 서비스 요청 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class AIServiceRequest { + + private String serviceType; // "marketing_tips", "business_insights", "trend_analysis" + private Long storeId; + private String category; + private Map parameters; + private Map context; // 매장 정보, 과거 데이터 등 +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipRequest.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipRequest.java index 0bf5ff8..c706619 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipRequest.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipRequest.java @@ -1,24 +1,26 @@ package com.won.smarketing.recommend.presentation.dto; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Positive; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -/** - * AI 마케팅 팁 생성 요청 DTO - * 매장 정보를 기반으로 개인화된 마케팅 팁을 요청할 때 사용됩니다. - */ +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +@Schema(description = "마케팅 팁 생성 요청") @Data +@Builder @NoArgsConstructor @AllArgsConstructor -@Schema(description = "AI 마케팅 팁 생성 요청") public class MarketingTipRequest { - + + @Schema(description = "매장 ID", example = "1", required = true) @NotNull(message = "매장 ID는 필수입니다") @Positive(message = "매장 ID는 양수여야 합니다") - @Schema(description = "매장 ID", example = "1", required = true) private Long storeId; -} + + @Schema(description = "추가 요청사항", example = "여름철 음료 프로모션에 집중해주세요") + private String additionalRequirement; +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java index ca1ffe0..047f34b 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java @@ -8,24 +8,61 @@ import lombok.NoArgsConstructor; import java.time.LocalDateTime; -/** - * AI 마케팅 팁 생성 응답 DTO - * AI가 생성한 개인화된 마케팅 팁 정보를 전달합니다. - */ +@Schema(description = "마케팅 팁 응답") @Data +@Builder @NoArgsConstructor @AllArgsConstructor -@Builder -@Schema(description = "AI 마케팅 팁 생성 응답") public class MarketingTipResponse { - + @Schema(description = "팁 ID", example = "1") private Long tipId; - - @Schema(description = "AI 생성 마케팅 팁 내용 (100자 이내)", - example = "오늘 같은 비 오는 날에는 따뜻한 음료와 함께 실내 분위기를 강조한 포스팅을 올려보세요. #비오는날카페 #따뜻한음료 해시태그로 감성을 어필해보세요!") + + @Schema(description = "매장 ID", example = "1") + private Long storeId; + + @Schema(description = "매장명", example = "카페 봄날") + private String storeName; + + @Schema(description = "AI 생성 마케팅 팁 내용") private String tipContent; - - @Schema(description = "팁 생성 시간", example = "2024-01-15T10:30:00") + + @Schema(description = "날씨 정보") + private WeatherInfo weatherInfo; + + @Schema(description = "매장 정보") + private StoreInfo storeInfo; + + @Schema(description = "생성 일시") private LocalDateTime createdAt; -} + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class WeatherInfo { + @Schema(description = "온도", example = "25.5") + private Double temperature; + + @Schema(description = "날씨 상태", example = "맑음") + private String condition; + + @Schema(description = "습도", example = "60.0") + private Double humidity; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class StoreInfo { + @Schema(description = "매장명", example = "카페 봄날") + private String storeName; + + @Schema(description = "업종", example = "카페") + private String businessType; + + @Schema(description = "위치", example = "서울시 강남구") + private String location; + } +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/resources/application.yml b/smarketing-java/ai-recommend/src/main/resources/application.yml index 8a6cb92..c3caad4 100644 --- a/smarketing-java/ai-recommend/src/main/resources/application.yml +++ b/smarketing-java/ai-recommend/src/main/resources/application.yml @@ -7,7 +7,7 @@ spring: application: name: ai-recommend-service datasource: - url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:recommenddb} + url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:AiRecommendationDB} username: ${POSTGRES_USER:postgres} password: ${POSTGRES_PASSWORD:postgres} jpa: @@ -18,6 +18,11 @@ spring: hibernate: dialect: org.hibernate.dialect.PostgreSQLDialect format_sql: true + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} ai: service: @@ -47,4 +52,8 @@ springdoc: logging: level: com.won.smarketing.recommend: ${LOG_LEVEL:DEBUG} - \ No newline at end of file + +jwt: + secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789} + access-token-validity: ${JWT_ACCESS_VALIDITY:3600000} + refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000} From 1e4dd2c7b00476d43311c33c0d3651ebd7c950be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=9C=EC=9D=80?= <61147083+BangSun98@users.noreply.github.com> Date: Thu, 12 Jun 2025 10:20:22 +0900 Subject: [PATCH 22/34] marketing-contents file add --- .idea/.gitignore | 5 - .idea/gradle.xml | 11 -- .idea/misc.xml | 4 - .idea/vcs.xml | 6 - .idea/workspace.xml | 109 +++++++++++++++ .../service/SnsContentService.java | 8 +- .../usecase/PosterContentUseCase.java | 15 +-- .../usecase/SnsContentUseCase.java | 15 +-- .../smarketing/content/config/JpaConfig.java | 18 --- .../content/domain/model/Content.java | 41 +++--- .../content/domain/model/ContentId.java | 41 ++++-- .../content/domain/model/ContentStatus.java | 37 +++--- .../content/domain/model/ContentType.java | 37 +++--- .../domain/model/CreationConditions.java | 76 +++++------ .../content/domain/model/Platform.java | 36 ++--- .../domain/repository/ContentRepository.java | 30 ++--- .../SpringDataContentRepository.java | 38 ++++++ .../entity/ContentConditionsJpaEntity.java | 24 ++-- .../infrastructure/entity/ContentEntity.java | 60 +++++++++ .../entity/ContentJpaEntity.java | 27 ++-- .../external/AiContentGenerator.java | 32 +++++ .../external/AiPosterGenerator.java | 29 ++++ .../external/ClaudeAiContentGenerator.java | 125 ++++++++++++++++++ .../external/ClaudeAiPosterGenerator.java | 118 +++++++++++++++++ .../infrastructure/mapper/ContentMapper.java | 2 +- .../repository/JpaContentRepository.java | 65 +++------ .../JpaContentRepositoryInterface.java | 49 +++++++ .../dto/CreationConditionsDto.java | 45 +++++++ .../dto/SnsContentCreateResponse.java | 2 +- .../src/main/resources/application.yml | 4 +- 30 files changed, 825 insertions(+), 284 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/gradle.xml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/vcs.xml create mode 100644 .idea/workspace.xml delete mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/JpaConfig.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/SpringDataContentRepository.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentEntity.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiContentGenerator.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiPosterGenerator.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepositoryInterface.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/CreationConditionsDto.java diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index b7b3d1b..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -# 디폴트 무시된 파일 -/shelf/ -/workspace.xml -# 환경에 따라 달라지는 Maven 홈 디렉터리 -/mavenHomeManager.xml diff --git a/.idea/gradle.xml b/.idea/gradle.xml deleted file mode 100644 index 9018a0d..0000000 --- a/.idea/gradle.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 6ed36dd..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..fc7acb6 --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + { + "customColor": "", + "associatedIndex": 4 +} + + + + + + + + + + + + + + + true + true + false + false + + + + + + + 1749618504890 + + + + \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java index fec5d4e..dd8e603 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java @@ -42,7 +42,7 @@ public class SnsContentService implements SnsContentUseCase { public SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request) { // AI를 사용하여 SNS 콘텐츠 생성 String generatedContent = aiContentGenerator.generateSnsContent(request); - + // 플랫폼에 맞는 해시태그 생성 Platform platform = Platform.fromString(request.getPlatform()); List hashtags = aiContentGenerator.generateHashtags(generatedContent, platform); @@ -60,7 +60,7 @@ public class SnsContentService implements SnsContentUseCase { // 임시 콘텐츠 생성 (저장하지 않음) Content content = Content.builder() - .contentType(ContentType.SNS_POST) +// .contentType(ContentType.SNS_POST) .platform(platform) .title(request.getTitle()) .content(generatedContent) @@ -88,7 +88,7 @@ public class SnsContentService implements SnsContentUseCase { /** * SNS 콘텐츠 저장 - * + * * @param request SNS 콘텐츠 저장 요청 */ @Override @@ -107,7 +107,7 @@ public class SnsContentService implements SnsContentUseCase { // 콘텐츠 엔티티 생성 및 저장 Content content = Content.builder() - .contentType(ContentType.SNS_POST) +// .contentType(ContentType.SNS_POST) .platform(Platform.fromString(request.getPlatform())) .title(request.getTitle()) .content(request.getContent()) diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java index 973b234..6bf2960 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java @@ -1,3 +1,4 @@ +// marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java package com.won.smarketing.content.application.usecase; import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest; @@ -5,23 +6,21 @@ import com.won.smarketing.content.presentation.dto.PosterContentCreateResponse; import com.won.smarketing.content.presentation.dto.PosterContentSaveRequest; /** - * 포스터 콘텐츠 관련 Use Case 인터페이스 - * 홍보 포스터 생성 및 저장 기능 정의 + * 포스터 콘텐츠 관련 UseCase 인터페이스 + * Clean Architecture의 Application Layer에서 비즈니스 로직 정의 */ public interface PosterContentUseCase { - + /** * 포스터 콘텐츠 생성 - * * @param request 포스터 콘텐츠 생성 요청 - * @return 생성된 포스터 콘텐츠 정보 + * @return 포스터 콘텐츠 생성 응답 */ PosterContentCreateResponse generatePosterContent(PosterContentCreateRequest request); - + /** * 포스터 콘텐츠 저장 - * * @param request 포스터 콘텐츠 저장 요청 */ void savePosterContent(PosterContentSaveRequest request); -} +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/SnsContentUseCase.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/SnsContentUseCase.java index e62902d..d2c6751 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/SnsContentUseCase.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/SnsContentUseCase.java @@ -1,3 +1,4 @@ +// marketing-content/src/main/java/com/won/smarketing/content/application/usecase/SnsContentUseCase.java package com.won.smarketing.content.application.usecase; import com.won.smarketing.content.presentation.dto.SnsContentCreateRequest; @@ -5,23 +6,21 @@ import com.won.smarketing.content.presentation.dto.SnsContentCreateResponse; import com.won.smarketing.content.presentation.dto.SnsContentSaveRequest; /** - * SNS 콘텐츠 관련 Use Case 인터페이스 - * SNS 게시물 생성 및 저장 기능 정의 + * SNS 콘텐츠 관련 UseCase 인터페이스 + * Clean Architecture의 Application Layer에서 비즈니스 로직 정의 */ public interface SnsContentUseCase { - + /** * SNS 콘텐츠 생성 - * * @param request SNS 콘텐츠 생성 요청 - * @return 생성된 SNS 콘텐츠 정보 + * @return SNS 콘텐츠 생성 응답 */ SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request); - + /** * SNS 콘텐츠 저장 - * * @param request SNS 콘텐츠 저장 요청 */ void saveSnsContent(SnsContentSaveRequest request); -} +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/JpaConfig.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/JpaConfig.java deleted file mode 100644 index e95312d..0000000 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/JpaConfig.java +++ /dev/null @@ -1,18 +0,0 @@ -// marketing-content/src/main/java/com/won/smarketing/content/config/JpaConfig.java -package com.won.smarketing.content.config; - -import org.springframework.boot.autoconfigure.domain.EntityScan; -import org.springframework.context.annotation.Configuration; -import org.springframework.data.jpa.repository.config.EnableJpaRepositories; - -/** - * JPA 설정 클래스 - * - * @author smarketing-team - * @version 1.0 - */ -@Configuration -@EntityScan(basePackages = "com.won.smarketing.content.infrastructure.entity") -@EnableJpaRepositories(basePackages = "com.won.smarketing.content.infrastructure.repository") -public class JpaConfig { -} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java index 4e95d02..549520c 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java @@ -46,7 +46,7 @@ public class Content { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "content_id") + @Column(name = "id") private Long id; // ==================== 콘텐츠 분류 ==================== @@ -97,8 +97,7 @@ public class Content { private ContentStatus status = ContentStatus.DRAFT; // ==================== AI 생성 조건 (Embedded) ==================== - - @Embedded + //@Embedded @AttributeOverrides({ @AttributeOverride(name = "toneAndManner", column = @Column(name = "tone_and_manner", length = 50)), @AttributeOverride(name = "promotionType", column = @Column(name = "promotion_type", length = 50)), @@ -191,15 +190,15 @@ public class Content { * @param status 새로운 상태 * @throws IllegalStateException 잘못된 상태 전환인 경우 */ - public void changeStatus(ContentStatus status) { - validateStatusTransition(this.status, status); - - if (status == ContentStatus.PUBLISHED) { - validateForPublication(); - } - - this.status = status; - } +// public void changeStatus(ContentStatus status) { +// validateStatusTransition(this.status, status); +// +// if (status == ContentStatus.PUBLISHED) { +// validateForPublication(); +// } +// +// this.status = status; +// } /** * 홍보 기간 설정 @@ -352,9 +351,9 @@ public class Content { * * @return SNS 게시물이면 true */ - public boolean isSnsContent() { - return this.contentType == ContentType.SNS_POST; - } +// public boolean isSnsContent() { +// return this.contentType == ContentType.SNS_POST; +// } /** * 포스터 콘텐츠 여부 확인 @@ -424,11 +423,11 @@ public class Content { /** * 상태 전환 유효성 검증 */ - private void validateStatusTransition(ContentStatus from, ContentStatus to) { - if (from == ContentStatus.ARCHIVED && to != ContentStatus.PUBLISHED) { - throw new IllegalStateException("보관된 콘텐츠는 발행 상태로만 변경할 수 있습니다."); - } - } +// private void validateStatusTransition(ContentStatus from, ContentStatus to) { +// if (from == ContentStatus.ARCHIVED && to != ContentStatus.PUBLISHED) { +// throw new IllegalStateException("보관된 콘텐츠는 발행 상태로만 변경할 수 있습니다."); +// } +// } /** * 발행을 위한 유효성 검증 @@ -502,7 +501,7 @@ public class Content { public static Content createSnsContent(String title, String content, Platform platform, Long storeId, CreationConditions conditions) { Content snsContent = Content.builder() - .contentType(ContentType.SNS_POST) +// .contentType(ContentType.SNS_POST) .platform(platform) .title(title) .content(content) diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java index 13bb3b0..25220a8 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java @@ -1,30 +1,53 @@ +// marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java package com.won.smarketing.content.domain.model; -import lombok.*; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; /** - * 콘텐츠 식별자 값 객체 - * 콘텐츠의 고유 식별자를 나타내는 도메인 객체 + * 콘텐츠 ID 값 객체 + * Clean Architecture의 Domain Layer에 위치하는 식별자 */ +@Embeddable @Getter -@Setter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) -@EqualsAndHashCode public class ContentId { private Long value; /** * ContentId 생성 팩토리 메서드 - * - * @param value 식별자 값 + * @param value ID 값 * @return ContentId 인스턴스 */ public static ContentId of(Long value) { if (value == null || value <= 0) { - throw new IllegalArgumentException("ContentId는 양수여야 합니다."); + throw new IllegalArgumentException("ContentId 값은 양수여야 합니다."); } return new ContentId(value); } -} + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ContentId contentId = (ContentId) o; + return Objects.equals(value, contentId.value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + + @Override + public String toString() { + return "ContentId{" + value + '}'; + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java index c40ec47..b235310 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java @@ -1,3 +1,4 @@ +// marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java package com.won.smarketing.content.domain.model; import lombok.Getter; @@ -5,35 +6,35 @@ import lombok.RequiredArgsConstructor; /** * 콘텐츠 상태 열거형 - * 콘텐츠의 생명주기 상태 정의 + * Clean Architecture의 Domain Layer에 위치하는 비즈니스 규칙 */ @Getter @RequiredArgsConstructor public enum ContentStatus { - + DRAFT("임시저장"), - PUBLISHED("발행됨"), - ARCHIVED("보관됨"); + PUBLISHED("게시됨"), + SCHEDULED("예약됨"), + DELETED("삭제됨"), + PROCESSING("처리중"); private final String displayName; /** * 문자열로부터 ContentStatus 변환 - * - * @param status 상태 문자열 - * @return ContentStatus + * @param value 문자열 값 + * @return ContentStatus enum + * @throws IllegalArgumentException 유효하지 않은 값인 경우 */ - public static ContentStatus fromString(String status) { - if (status == null) { - return DRAFT; + public static ContentStatus fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("ContentStatus 값은 null일 수 없습니다."); } - - for (ContentStatus s : ContentStatus.values()) { - if (s.name().equalsIgnoreCase(status)) { - return s; - } + + try { + return ContentStatus.valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("유효하지 않은 ContentStatus 값입니다: " + value); } - - throw new IllegalArgumentException("알 수 없는 콘텐츠 상태: " + status); } -} +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentType.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentType.java index dd91b91..f70228b 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentType.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentType.java @@ -1,3 +1,4 @@ +// marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentType.java package com.won.smarketing.content.domain.model; import lombok.Getter; @@ -5,34 +6,34 @@ import lombok.RequiredArgsConstructor; /** * 콘텐츠 타입 열거형 - * 지원되는 마케팅 콘텐츠 유형 정의 + * Clean Architecture의 Domain Layer에 위치하는 비즈니스 규칙 */ @Getter @RequiredArgsConstructor public enum ContentType { - - SNS_POST("SNS 게시물"), - POSTER("홍보 포스터"); + + SNS("SNS 게시물"), + POSTER("홍보 포스터"), + VIDEO("동영상"), + BLOG("블로그 포스트"); private final String displayName; /** * 문자열로부터 ContentType 변환 - * - * @param type 타입 문자열 - * @return ContentType + * @param value 문자열 값 + * @return ContentType enum + * @throws IllegalArgumentException 유효하지 않은 값인 경우 */ - public static ContentType fromString(String type) { - if (type == null) { - return null; + public static ContentType fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("ContentType 값은 null일 수 없습니다."); } - - for (ContentType contentType : ContentType.values()) { - if (contentType.name().equalsIgnoreCase(type)) { - return contentType; - } + + try { + return ContentType.valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("유효하지 않은 ContentType 값입니다: " + value); } - - throw new IllegalArgumentException("알 수 없는 콘텐츠 타입: " + type); } -} +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java index cf3c04e..cb1f914 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java @@ -1,66 +1,68 @@ +// marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java package com.won.smarketing.content.domain.model; -import lombok.*; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; import java.time.LocalDate; /** * 콘텐츠 생성 조건 도메인 모델 - * AI 콘텐츠 생성 시 사용되는 조건 정보 + * Clean Architecture의 Domain Layer에 위치하는 값 객체 */ +@Entity +@Table(name = "contents_conditions") @Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor @AllArgsConstructor -@Builder(toBuilder = true) +@Builder public class CreationConditions { - /** - * 홍보 대상 카테고리 - */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + //@OneToOne(mappedBy = "creationConditions") + @Column(name = "content", length = 100) + private Content content; + + @Column(name = "category", length = 100) private String category; - /** - * 특별 요구사항 - */ + @Column(name = "requirement", columnDefinition = "TEXT") private String requirement; - /** - * 톤앤매너 - */ + @Column(name = "tone_and_manner", length = 100) private String toneAndManner; - /** - * 감정 강도 - */ + @Column(name = "emotion_intensity", length = 100) private String emotionIntensity; - /** - * 이벤트명 - */ + @Column(name = "event_name", length = 200) private String eventName; - /** - * 홍보 시작일 - */ + @Column(name = "start_date") private LocalDate startDate; - /** - * 홍보 종료일 - */ + @Column(name = "end_date") private LocalDate endDate; - /** - * 사진 스타일 (포스터용) - */ + @Column(name = "photo_style", length = 100) private String photoStyle; - /** - * 타겟 고객 - */ - private String targetAudience; - - /** - * 프로모션 타입 - */ + @Column(name = "promotionType", length = 100) private String promotionType; -} + + public CreationConditions(String category, String requirement, String toneAndManner, String emotionIntensity, String eventName, LocalDate startDate, LocalDate endDate, String photoStyle, String promotionType) { + } +// /** +// * 콘텐츠와의 연관관계 설정 +// * @param content 연관된 콘텐츠 +// */ +// public void setContent(Content content) { +// this.content = content; +// } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java index acd6b33..66e266c 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java @@ -1,3 +1,4 @@ +// marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java package com.won.smarketing.content.domain.model; import lombok.Getter; @@ -5,35 +6,36 @@ import lombok.RequiredArgsConstructor; /** * 플랫폼 열거형 - * 콘텐츠가 게시될 플랫폼 정의 + * Clean Architecture의 Domain Layer에 위치하는 비즈니스 규칙 */ @Getter @RequiredArgsConstructor public enum Platform { - + INSTAGRAM("인스타그램"), NAVER_BLOG("네이버 블로그"), - GENERAL("범용"); + FACEBOOK("페이스북"), + KAKAO_STORY("카카오스토리"), + YOUTUBE("유튜브"), + GENERAL("일반"); private final String displayName; /** * 문자열로부터 Platform 변환 - * - * @param platform 플랫폼 문자열 - * @return Platform + * @param value 문자열 값 + * @return Platform enum + * @throws IllegalArgumentException 유효하지 않은 값인 경우 */ - public static Platform fromString(String platform) { - if (platform == null) { - return GENERAL; + public static Platform fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("Platform 값은 null일 수 없습니다."); } - - for (Platform p : Platform.values()) { - if (p.name().equalsIgnoreCase(platform)) { - return p; - } + + try { + return Platform.valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("유효하지 않은 Platform 값입니다: " + value); } - - throw new IllegalArgumentException("알 수 없는 플랫폼: " + platform); } -} +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java index 818506f..a2bfc43 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java @@ -1,40 +1,36 @@ +// marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java package com.won.smarketing.content.domain.repository; import com.won.smarketing.content.domain.model.Content; import com.won.smarketing.content.domain.model.ContentId; import com.won.smarketing.content.domain.model.ContentType; import com.won.smarketing.content.domain.model.Platform; -import org.springframework.stereotype.Repository; import java.util.List; import java.util.Optional; /** - * 콘텐츠 저장소 인터페이스 - * 콘텐츠 도메인의 데이터 접근 추상화 + * 콘텐츠 리포지토리 인터페이스 + * Clean Architecture의 Domain Layer에서 데이터 접근 정의 */ -@Repository public interface ContentRepository { - + /** * 콘텐츠 저장 - * * @param content 저장할 콘텐츠 * @return 저장된 콘텐츠 */ Content save(Content content); - + /** - * 콘텐츠 ID로 조회 - * + * ID로 콘텐츠 조회 * @param id 콘텐츠 ID - * @return 콘텐츠 (Optional) + * @return 조회된 콘텐츠 */ Optional findById(ContentId id); - + /** * 필터 조건으로 콘텐츠 목록 조회 - * * @param contentType 콘텐츠 타입 * @param platform 플랫폼 * @param period 기간 @@ -42,19 +38,17 @@ public interface ContentRepository { * @return 콘텐츠 목록 */ List findByFilters(ContentType contentType, Platform platform, String period, String sortBy); - + /** * 진행 중인 콘텐츠 목록 조회 - * * @param period 기간 * @return 진행 중인 콘텐츠 목록 */ List findOngoingContents(String period); - + /** - * 콘텐츠 삭제 - * + * ID로 콘텐츠 삭제 * @param id 삭제할 콘텐츠 ID */ void deleteById(ContentId id); -} +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/SpringDataContentRepository.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/SpringDataContentRepository.java new file mode 100644 index 0000000..d3a6e42 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/SpringDataContentRepository.java @@ -0,0 +1,38 @@ +package com.won.smarketing.content.domain.repository; +import com.won.smarketing.content.infrastructure.entity.ContentEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * Spring Data JPA ContentRepository + * JPA 기반 콘텐츠 데이터 접근 + */ +@Repository +public interface SpringDataContentRepository extends JpaRepository { + + /** + * 매장별 콘텐츠 조회 + * + * @param storeId 매장 ID + * @return 콘텐츠 목록 + */ + List findByStoreId(Long storeId); + + /** + * 콘텐츠 타입별 조회 + * + * @param contentType 콘텐츠 타입 + * @return 콘텐츠 목록 + */ + List findByContentType(String contentType); + + /** + * 플랫폼별 조회 + * + * @param platform 플랫폼 + * @return 콘텐츠 목록 + */ + List findByPlatform(String platform); +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java index 17f49f8..940bbba 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java @@ -1,6 +1,8 @@ +// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java package com.won.smarketing.content.infrastructure.entity; import jakarta.persistence.*; +import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @@ -8,24 +10,20 @@ import lombok.Setter; import java.time.LocalDate; /** - * 콘텐츠 조건 JPA 엔티티 - * - * @author smarketing-team - * @version 1.0 + * 콘텐츠 생성 조건 JPA 엔티티 */ @Entity -@Table(name = "contents_conditions") +@Table(name = "content_conditions") @Getter @Setter -@NoArgsConstructor public class ContentConditionsJpaEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @OneToOne - @JoinColumn(name = "content_id") + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id", nullable = false) private ContentJpaEntity content; @Column(name = "category", length = 100) @@ -37,7 +35,7 @@ public class ContentConditionsJpaEntity { @Column(name = "tone_and_manner", length = 100) private String toneAndManner; - @Column(name = "emotion_intensity", length = 100) + @Column(name = "emotion_intensity", length = 50) private String emotionIntensity; @Column(name = "event_name", length = 200) @@ -52,9 +50,9 @@ public class ContentConditionsJpaEntity { @Column(name = "photo_style", length = 100) private String photoStyle; - @Column(name = "TargetAudience", length = 100) + @Column(name = "target_audience", length = 200) private String targetAudience; - @Column(name = "PromotionType", length = 100) - private String PromotionType; -} + @Column(name = "promotion_type", length = 100) + private String promotionType; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentEntity.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentEntity.java new file mode 100644 index 0000000..ba941d4 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentEntity.java @@ -0,0 +1,60 @@ +package com.won.smarketing.content.infrastructure.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +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; + +/** + * 콘텐츠 엔티티 + * 콘텐츠 정보를 데이터베이스에 저장하기 위한 JPA 엔티티 + */ +@Entity +@Table(name = "contents") +@Data +@NoArgsConstructor +@AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) +public class ContentEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "content_type", nullable = false) + private String contentType; + + @Column(name = "platform", nullable = false) + private String platform; + + @Column(name = "title", nullable = false) + private String title; + + @Column(name = "content", columnDefinition = "TEXT") + private String content; + + @Column(name = "hashtags") + private String hashtags; + + @Column(name = "images", columnDefinition = "TEXT") + private String images; + + @Column(name = "status", nullable = false) + private String status; + + @Column(name = "store_id", nullable = false) + private Long storeId; + + @CreatedDate + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java index 7f87560..2bd786a 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java @@ -1,27 +1,24 @@ -// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java package com.won.smarketing.content.infrastructure.entity; import jakarta.persistence.*; +import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import org.hibernate.annotations.CreationTimestamp; -import org.hibernate.annotations.UpdateTimestamp; +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.util.List; /** * 콘텐츠 JPA 엔티티 - * - * @author smarketing-team - * @version 1.0 */ @Entity @Table(name = "contents") @Getter @Setter -@NoArgsConstructor +@EntityListeners(AuditingEntityListener.class) public class ContentJpaEntity { @Id @@ -43,24 +40,24 @@ public class ContentJpaEntity { @Column(name = "content", columnDefinition = "TEXT") private String content; - @Column(name = "hashtags", columnDefinition = "JSON") + @Column(name = "hashtags", columnDefinition = "TEXT") private String hashtags; - @Column(name = "images", columnDefinition = "JSON") + @Column(name = "images", columnDefinition = "TEXT") private String images; - @Column(name = "status", length = 50) + @Column(name = "status", nullable = false, length = 20) private String status; - @CreationTimestamp - @Column(name = "created_at") + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; - @UpdateTimestamp + @LastModifiedDate @Column(name = "updated_at") private LocalDateTime updatedAt; - // 연관 엔티티 + // CreationConditions와의 관계 - OneToOne으로 별도 엔티티로 관리 @OneToOne(mappedBy = "content", cascade = CascadeType.ALL, fetch = FetchType.LAZY) private ContentConditionsJpaEntity conditions; } \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiContentGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiContentGenerator.java new file mode 100644 index 0000000..b1d0e6d --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiContentGenerator.java @@ -0,0 +1,32 @@ +// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiContentGenerator.java +package com.won.smarketing.content.infrastructure.external; + +import com.won.smarketing.content.domain.model.Platform; +import com.won.smarketing.content.domain.model.CreationConditions; + +import java.util.List; + +/** + * AI 콘텐츠 생성 인터페이스 + * Clean Architecture의 Infrastructure Layer에서 외부 AI 서비스와의 연동 정의 + */ +public interface AiContentGenerator { + + /** + * SNS 콘텐츠 생성 + * @param title 제목 + * @param category 카테고리 + * @param platform 플랫폼 + * @param conditions 생성 조건 + * @return 생성된 콘텐츠 텍스트 + */ + String generateSnsContent(String title, String category, Platform platform, CreationConditions conditions); + + /** + * 해시태그 생성 + * @param content 콘텐츠 내용 + * @param platform 플랫폼 + * @return 생성된 해시태그 목록 + */ + List generateHashtags(String content, Platform platform); +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiPosterGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiPosterGenerator.java new file mode 100644 index 0000000..8bbe931 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiPosterGenerator.java @@ -0,0 +1,29 @@ +// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiPosterGenerator.java +package com.won.smarketing.content.infrastructure.external; + +import com.won.smarketing.content.domain.model.CreationConditions; + +import java.util.Map; + +/** + * AI 포스터 생성 인터페이스 + * Clean Architecture의 Infrastructure Layer에서 외부 AI 서비스와의 연동 정의 + */ +public interface AiPosterGenerator { + + /** + * 포스터 이미지 생성 + * @param title 제목 + * @param category 카테고리 + * @param conditions 생성 조건 + * @return 생성된 포스터 이미지 URL + */ + String generatePoster(String title, String category, CreationConditions conditions); + + /** + * 포스터 다양한 사이즈 생성 + * @param originalImage 원본 이미지 URL + * @return 사이즈별 이미지 URL 맵 + */ + Map generatePosterSizes(String originalImage); +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java new file mode 100644 index 0000000..5cf42a4 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java @@ -0,0 +1,125 @@ +// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java +package com.won.smarketing.content.infrastructure.external; + +import com.won.smarketing.content.domain.model.Platform; +import com.won.smarketing.content.domain.model.CreationConditions; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.List; + +/** + * Claude AI를 활용한 콘텐츠 생성 구현체 + * Clean Architecture의 Infrastructure Layer에 위치 + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class ClaudeAiContentGenerator implements AiContentGenerator { + + /** + * SNS 콘텐츠 생성 + * Claude AI API를 호출하여 SNS 게시물을 생성합니다. + * + * @param title 제목 + * @param category 카테고리 + * @param platform 플랫폼 + * @param conditions 생성 조건 + * @return 생성된 콘텐츠 텍스트 + */ + @Override + public String generateSnsContent(String title, String category, Platform platform, CreationConditions conditions) { + try { + // Claude AI API 호출 로직 (실제 구현에서는 HTTP 클라이언트를 사용) + String prompt = buildContentPrompt(title, category, platform, conditions); + + // TODO: 실제 Claude AI API 호출 + // 현재는 더미 데이터 반환 + return generateDummySnsContent(title, platform); + + } catch (Exception e) { + log.error("AI 콘텐츠 생성 실패: {}", e.getMessage(), e); + return generateFallbackContent(title, platform); + } + } + + /** + * 해시태그 생성 + * 콘텐츠 내용을 분석하여 관련 해시태그를 생성합니다. + * + * @param content 콘텐츠 내용 + * @param platform 플랫폼 + * @return 생성된 해시태그 목록 + */ + @Override + public List generateHashtags(String content, Platform platform) { + try { + // TODO: 실제 Claude AI API 호출하여 해시태그 생성 + // 현재는 더미 데이터 반환 + return generateDummyHashtags(platform); + + } catch (Exception e) { + log.error("해시태그 생성 실패: {}", e.getMessage(), e); + return Arrays.asList("#맛집", "#신메뉴", "#추천"); + } + } + + /** + * AI 프롬프트 생성 + */ + private String buildContentPrompt(String title, String category, Platform platform, CreationConditions conditions) { + StringBuilder prompt = new StringBuilder(); + prompt.append("다음 조건에 맞는 ").append(platform.getDisplayName()).append(" 게시물을 작성해주세요:\n"); + prompt.append("제목: ").append(title).append("\n"); + prompt.append("카테고리: ").append(category).append("\n"); + + if (conditions.getRequirement() != null) { + prompt.append("요구사항: ").append(conditions.getRequirement()).append("\n"); + } + if (conditions.getToneAndManner() != null) { + prompt.append("톤앤매너: ").append(conditions.getToneAndManner()).append("\n"); + } + if (conditions.getEmotionIntensity() != null) { + prompt.append("감정 강도: ").append(conditions.getEmotionIntensity()).append("\n"); + } + + return prompt.toString(); + } + + /** + * 더미 SNS 콘텐츠 생성 (개발용) + */ + private String generateDummySnsContent(String title, Platform platform) { + switch (platform) { + case INSTAGRAM: + return String.format("🎉 %s\n\n맛있는 순간을 놓치지 마세요! 새로운 맛의 경험이 여러분을 기다리고 있어요. 따뜻한 분위기에서 즐기는 특별한 시간을 만들어보세요.\n\n📍 지금 바로 방문해보세요!", title); + case NAVER_BLOG: + return String.format("안녕하세요! 오늘은 %s에 대해 소개해드리려고 해요.\n\n정성스럽게 준비한 새로운 메뉴로 고객 여러분께 더 나은 경험을 선사하고 싶습니다. 많은 관심과 사랑 부탁드려요!", title); + default: + return String.format("%s - 새로운 경험을 만나보세요!", title); + } + } + + /** + * 더미 해시태그 생성 (개발용) + */ + private List generateDummyHashtags(Platform platform) { + switch (platform) { + case INSTAGRAM: + return Arrays.asList("#맛집", "#신메뉴", "#인스타그램", "#데일리", "#추천", "#음식스타그램"); + case NAVER_BLOG: + return Arrays.asList("#맛집", "#리뷰", "#추천", "#신메뉴", "#블로그"); + default: + return Arrays.asList("#맛집", "#신메뉴", "#추천"); + } + } + + /** + * 폴백 콘텐츠 생성 (AI 서비스 실패 시) + */ + private String generateFallbackContent(String title, Platform platform) { + return String.format("🎉 %s\n\n새로운 소식을 전해드립니다. 많은 관심 부탁드려요!", title); + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java new file mode 100644 index 0000000..a667545 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java @@ -0,0 +1,118 @@ +// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java +package com.won.smarketing.content.infrastructure.external; + +import com.won.smarketing.content.domain.model.CreationConditions; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; + +/** + * Claude AI를 활용한 포스터 생성 구현체 + * Clean Architecture의 Infrastructure Layer에 위치 + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class ClaudeAiPosterGenerator implements AiPosterGenerator { + + /** + * 포스터 이미지 생성 + * Claude AI API를 호출하여 홍보 포스터를 생성합니다. + * + * @param title 제목 + * @param category 카테고리 + * @param conditions 생성 조건 + * @return 생성된 포스터 이미지 URL + */ + @Override + public String generatePoster(String title, String category, CreationConditions conditions) { + try { + // Claude AI API 호출 로직 (실제 구현에서는 HTTP 클라이언트를 사용) + String prompt = buildPosterPrompt(title, category, conditions); + + // TODO: 실제 Claude AI API 호출 + // 현재는 더미 데이터 반환 + return generateDummyPosterUrl(title); + + } catch (Exception e) { + log.error("AI 포스터 생성 실패: {}", e.getMessage(), e); + return generateFallbackPosterUrl(); + } + } + + /** + * 포스터 다양한 사이즈 생성 + * 원본 포스터를 기반으로 다양한 사이즈의 포스터를 생성합니다. + * + * @param originalImage 원본 이미지 URL + * @return 사이즈별 이미지 URL 맵 + */ + @Override + public Map generatePosterSizes(String originalImage) { + try { + // TODO: 실제 이미지 리사이징 API 호출 + // 현재는 더미 데이터 반환 + return generateDummyPosterSizes(originalImage); + + } catch (Exception e) { + log.error("포스터 사이즈 생성 실패: {}", e.getMessage(), e); + return new HashMap<>(); + } + } + + /** + * AI 포스터 프롬프트 생성 + */ + private String buildPosterPrompt(String title, String category, CreationConditions conditions) { + StringBuilder prompt = new StringBuilder(); + prompt.append("다음 조건에 맞는 홍보 포스터를 생성해주세요:\n"); + prompt.append("제목: ").append(title).append("\n"); + prompt.append("카테고리: ").append(category).append("\n"); + + if (conditions.getPhotoStyle() != null) { + prompt.append("사진 스타일: ").append(conditions.getPhotoStyle()).append("\n"); + } + if (conditions.getRequirement() != null) { + prompt.append("요구사항: ").append(conditions.getRequirement()).append("\n"); + } + if (conditions.getToneAndManner() != null) { + prompt.append("톤앤매너: ").append(conditions.getToneAndManner()).append("\n"); + } + + return prompt.toString(); + } + + /** + * 더미 포스터 URL 생성 (개발용) + */ + private String generateDummyPosterUrl(String title) { + return String.format("https://example.com/posters/%s-poster.jpg", + title.replaceAll("\\s+", "-").toLowerCase()); + } + + /** + * 더미 포스터 사이즈별 URL 생성 (개발용) + */ + private Map generateDummyPosterSizes(String originalImage) { + Map sizes = new HashMap<>(); + String baseUrl = originalImage.substring(0, originalImage.lastIndexOf(".")); + String extension = originalImage.substring(originalImage.lastIndexOf(".")); + + sizes.put("small", baseUrl + "-small" + extension); + sizes.put("medium", baseUrl + "-medium" + extension); + sizes.put("large", baseUrl + "-large" + extension); + sizes.put("xlarge", baseUrl + "-xlarge" + extension); + + return sizes; + } + + /** + * 폴백 포스터 URL 생성 (AI 서비스 실패 시) + */ + private String generateFallbackPosterUrl() { + return "https://example.com/posters/default-poster.jpg"; + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java index 49cc6b4..a03954f 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java @@ -91,7 +91,7 @@ public class ContentMapper { entity.getConditions().getStartDate(), entity.getConditions().getEndDate(), entity.getConditions().getPhotoStyle(), - entity.getConditions().getTargetAudience(), + // entity.getConditions().getTargetAudience(), entity.getConditions().getPromotionType() ); } diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java index 9396d4d..da461e5 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java @@ -1,3 +1,4 @@ +// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java package com.won.smarketing.content.infrastructure.repository; import com.won.smarketing.content.domain.model.Content; @@ -5,60 +6,44 @@ import com.won.smarketing.content.domain.model.ContentId; import com.won.smarketing.content.domain.model.ContentType; import com.won.smarketing.content.domain.model.Platform; import com.won.smarketing.content.domain.repository.ContentRepository; -import com.won.smarketing.content.infrastructure.entity.ContentJpaEntity; -import com.won.smarketing.content.infrastructure.mapper.ContentMapper; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Repository; import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; /** - * JPA 기반 콘텐츠 Repository 구현체 - * - * @author smarketing-team - * @version 1.0 + * JPA를 활용한 콘텐츠 리포지토리 구현체 + * Clean Architecture의 Infrastructure Layer에 위치 */ @Repository @RequiredArgsConstructor -@Slf4j public class JpaContentRepository implements ContentRepository { - private final SpringDataContentRepository springDataContentRepository; - private final ContentMapper contentMapper; + private final JpaContentRepositoryInterface jpaRepository; /** - * 콘텐츠를 저장합니다. - * + * 콘텐츠 저장 * @param content 저장할 콘텐츠 * @return 저장된 콘텐츠 */ @Override public Content save(Content content) { - log.debug("Saving content: {}", content.getId()); - ContentJpaEntity entity = contentMapper.toEntity(content); - ContentJpaEntity savedEntity = springDataContentRepository.save(entity); - return contentMapper.toDomain(savedEntity); + return jpaRepository.save(content); } /** - * ID로 콘텐츠를 조회합니다. - * + * ID로 콘텐츠 조회 * @param id 콘텐츠 ID * @return 조회된 콘텐츠 */ @Override public Optional findById(ContentId id) { - log.debug("Finding content by id: {}", id.getValue()); - return springDataContentRepository.findById(id.getValue()) - .map(contentMapper::toDomain); + return jpaRepository.findById(id.getValue()); } /** - * 필터 조건으로 콘텐츠 목록을 조회합니다. - * + * 필터 조건으로 콘텐츠 목록 조회 * @param contentType 콘텐츠 타입 * @param platform 플랫폼 * @param period 기간 @@ -67,45 +52,25 @@ public class JpaContentRepository implements ContentRepository { */ @Override public List findByFilters(ContentType contentType, Platform platform, String period, String sortBy) { - log.debug("Finding contents by filters - type: {}, platform: {}, period: {}, sortBy: {}", - contentType, platform, period, sortBy); - - List entities = springDataContentRepository.findByFilters( - contentType != null ? contentType.name() : null, - platform != null ? platform.name() : null, - period, - sortBy - ); - - return entities.stream() - .map(contentMapper::toDomain) - .collect(Collectors.toList()); + return jpaRepository.findByFilters(contentType, platform, period, sortBy); } /** - * 진행 중인 콘텐츠 목록을 조회합니다. - * + * 진행 중인 콘텐츠 목록 조회 * @param period 기간 * @return 진행 중인 콘텐츠 목록 */ @Override public List findOngoingContents(String period) { - log.debug("Finding ongoing contents for period: {}", period); - List entities = springDataContentRepository.findOngoingContents(period); - - return entities.stream() - .map(contentMapper::toDomain) - .collect(Collectors.toList()); + return jpaRepository.findOngoingContents(period); } /** - * ID로 콘텐츠를 삭제합니다. - * - * @param id 콘텐츠 ID + * ID로 콘텐츠 삭제 + * @param id 삭제할 콘텐츠 ID */ @Override public void deleteById(ContentId id) { - log.debug("Deleting content by id: {}", id.getValue()); - springDataContentRepository.deleteById(id.getValue()); + jpaRepository.deleteById(id.getValue()); } } \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepositoryInterface.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepositoryInterface.java new file mode 100644 index 0000000..380bba6 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepositoryInterface.java @@ -0,0 +1,49 @@ +// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepositoryInterface.java +package com.won.smarketing.content.infrastructure.repository; + +import com.won.smarketing.content.domain.model.Content; +import com.won.smarketing.content.domain.model.ContentType; +import com.won.smarketing.content.domain.model.Platform; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +/** + * Spring Data JPA 콘텐츠 리포지토리 인터페이스 + * Clean Architecture의 Infrastructure Layer에 위치 + */ +public interface JpaContentRepositoryInterface extends JpaRepository { + + /** + * 필터 조건으로 콘텐츠 목록 조회 + */ + @Query("SELECT c FROM Content c WHERE " + + "(:contentType IS NULL OR c.contentType = :contentType) AND " + + "(:platform IS NULL OR c.platform = :platform) AND " + + "(:period IS NULL OR " + + " (:period = 'week' AND c.createdAt >= CURRENT_DATE - 7) OR " + + " (:period = 'month' AND c.createdAt >= CURRENT_DATE - 30) OR " + + " (:period = 'year' AND c.createdAt >= CURRENT_DATE - 365)) " + + "ORDER BY " + + "CASE WHEN :sortBy = 'latest' THEN c.createdAt END DESC, " + + "CASE WHEN :sortBy = 'oldest' THEN c.createdAt END ASC, " + + "CASE WHEN :sortBy = 'title' THEN c.title END ASC") + List findByFilters(@Param("contentType") ContentType contentType, + @Param("platform") Platform platform, + @Param("period") String period, + @Param("sortBy") String sortBy); + + /** + * 진행 중인 콘텐츠 목록 조회 + */ + @Query("SELECT c FROM Content c WHERE " + + "c.status IN ('PUBLISHED', 'SCHEDULED') AND " + + "(:period IS NULL OR " + + " (:period = 'week' AND c.createdAt >= CURRENT_DATE - 7) OR " + + " (:period = 'month' AND c.createdAt >= CURRENT_DATE - 30) OR " + + " (:period = 'year' AND c.createdAt >= CURRENT_DATE - 365)) " + + "ORDER BY c.createdAt DESC") + List findOngoingContents(@Param("period") String period); +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/CreationConditionsDto.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/CreationConditionsDto.java new file mode 100644 index 0000000..403cdfa --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/CreationConditionsDto.java @@ -0,0 +1,45 @@ +// marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/CreationConditionsDto.java +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +/** + * 콘텐츠 생성 조건 DTO + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "콘텐츠 생성 조건") +public class CreationConditionsDto { + + @Schema(description = "카테고리", example = "음식") + private String category; + + @Schema(description = "생성 요구사항", example = "젊은 고객층을 타겟으로 한 재미있는 콘텐츠") + private String requirement; + + @Schema(description = "톤앤매너", example = "친근하고 활발한") + private String toneAndManner; + + @Schema(description = "감정 강도", example = "보통") + private String emotionIntensity; + + @Schema(description = "이벤트명", example = "신메뉴 출시 이벤트") + private String eventName; + + @Schema(description = "시작일") + private LocalDate startDate; + + @Schema(description = "종료일") + private LocalDate endDate; + + @Schema(description = "사진 스타일", example = "모던하고 깔끔한") + private String photoStyle; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateResponse.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateResponse.java index ce5ee97..0acf9ec 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateResponse.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateResponse.java @@ -306,7 +306,7 @@ public class SnsContentCreateResponse { // 생성 조건 정보 설정 if (content.getCreationConditions() != null) { builder.generationConditions(GenerationConditionsDto.builder() - .targetAudience(content.getCreationConditions().getTargetAudience()) + //.targetAudience(content.getCreationConditions().getTargetAudience()) .eventName(content.getCreationConditions().getEventName()) .toneAndManner(content.getCreationConditions().getToneAndManner()) .promotionType(content.getCreationConditions().getPromotionType()) diff --git a/smarketing-java/marketing-content/src/main/resources/application.yml b/smarketing-java/marketing-content/src/main/resources/application.yml index 9f7259f..0e9e68c 100644 --- a/smarketing-java/marketing-content/src/main/resources/application.yml +++ b/smarketing-java/marketing-content/src/main/resources/application.yml @@ -11,8 +11,8 @@ spring: driver-class-name: org.postgresql.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 From dce8dfa2d9548dddda8b5af8de236b76b3495ddc Mon Sep 17 00:00:00 2001 From: OhSeongRak Date: Thu, 12 Jun 2025 10:23:14 +0900 Subject: [PATCH 23/34] =?UTF-8?q?feat:=20menuName=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- smarketing-ai/app.py | 4 ++-- smarketing-ai/models/request_models.py | 2 ++ smarketing-ai/services/sns_content_service.py | 3 +++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/smarketing-ai/app.py b/smarketing-ai/app.py index 35cf56c..ebe2175 100644 --- a/smarketing-ai/app.py +++ b/smarketing-ai/app.py @@ -9,10 +9,8 @@ import os from datetime import datetime import traceback from config.config import Config -# from services.content_service import ContentService from services.poster_service import PosterService from services.sns_content_service import SnsContentService -# from services.poster_generation_service import PosterGenerationService from models.request_models import ContentRequest, PosterRequest, SnsContentGetRequest, PosterContentGetRequest @@ -75,6 +73,7 @@ def create_app(): requirement=data.get('requirement'), toneAndManner=data.get('toneAndManner'), emotionIntensity=data.get('emotionIntensity'), + menuName=data.get('menuName'), eventName=data.get('eventName'), startDate=data.get('startDate'), endDate=data.get('endDate') @@ -124,6 +123,7 @@ def create_app(): requirement=data.get('requirement'), toneAndManner=data.get('toneAndManner'), emotionIntensity=data.get('emotionIntensity'), + menuName=data.get('menuName'), eventName=data.get('eventName'), startDate=data.get('startDate'), endDate=data.get('endDate') diff --git a/smarketing-ai/models/request_models.py b/smarketing-ai/models/request_models.py index 8816533..b47b257 100644 --- a/smarketing-ai/models/request_models.py +++ b/smarketing-ai/models/request_models.py @@ -17,6 +17,7 @@ class SnsContentGetRequest: requirement: Optional[str] = None toneAndManner: Optional[str] = None emotionIntensity: Optional[str] = None + menuName: Optional[str] = None eventName: Optional[str] = None startDate: Optional[str] = None endDate: Optional[str] = None @@ -33,6 +34,7 @@ class PosterContentGetRequest: requirement: Optional[str] = None toneAndManner: Optional[str] = None emotionIntensity: Optional[str] = None + menuName: Optional[str] = None eventName: Optional[str] = None startDate: Optional[str] = None endDate: Optional[str] = None diff --git a/smarketing-ai/services/sns_content_service.py b/smarketing-ai/services/sns_content_service.py index e5090e0..fc80913 100644 --- a/smarketing-ai/services/sns_content_service.py +++ b/smarketing-ai/services/sns_content_service.py @@ -205,6 +205,9 @@ class SnsContentService: """ metadata_html = '
' + if request.menuName: + metadata_html += f'
메뉴: {request.menuName}
' + if request.eventName: metadata_html += f'
이벤트: {request.eventName}
' From 8949c22928c5817ee0249e93a8632834c8b7fb74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=9C=EC=9D=80?= <61147083+BangSun98@users.noreply.github.com> Date: Thu, 12 Jun 2025 10:40:55 +0900 Subject: [PATCH 24/34] fixed marketing-contents --- .../content/domain/model/Content.java | 590 ++---------------- .../content/domain/model/ContentId.java | 44 +- .../domain/model/CreationConditions.java | 60 +- .../entity/ContentConditionsJpaEntity.java | 32 +- .../entity/ContentJpaEntity.java | 7 + .../infrastructure/mapper/ContentMapper.java | 169 +++-- .../repository/JpaContentRepository.java | 95 ++- .../JpaContentRepositoryInterface.java | 88 ++- 8 files changed, 408 insertions(+), 677 deletions(-) diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java index 549520c..9a19b77 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java @@ -1,14 +1,10 @@ // marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java package com.won.smarketing.content.domain.model; -import jakarta.persistence.*; 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.util.ArrayList; @@ -17,615 +13,151 @@ import java.util.List; /** * 콘텐츠 도메인 모델 * - * 이 클래스는 마케팅 콘텐츠의 핵심 정보와 비즈니스 로직을 포함하는 - * DDD(Domain-Driven Design) 엔티티입니다. - * - * Clean Architecture의 Domain Layer에 위치하며, - * 비즈니스 규칙과 도메인 로직을 캡슐화합니다. + * Clean Architecture의 Domain Layer에 위치하는 핵심 엔티티 + * JPA 애노테이션을 제거하여 순수 도메인 모델로 유지 + * Infrastructure Layer에서 별도의 JPA 엔티티로 매핑 */ -@Entity -@Table( - name = "contents", - indexes = { - @Index(name = "idx_store_id", columnList = "store_id"), - @Index(name = "idx_content_type", columnList = "content_type"), - @Index(name = "idx_platform", columnList = "platform"), - @Index(name = "idx_status", columnList = "status"), - @Index(name = "idx_promotion_dates", columnList = "promotion_start_date, promotion_end_date"), - @Index(name = "idx_created_at", columnList = "created_at") - } -) @Getter @NoArgsConstructor @AllArgsConstructor @Builder -@EntityListeners(AuditingEntityListener.class) public class Content { // ==================== 기본키 및 식별자 ==================== - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "id") private Long id; // ==================== 콘텐츠 분류 ==================== - - @Enumerated(EnumType.STRING) - @Column(name = "content_type", nullable = false, length = 20) private ContentType contentType; - - @Enumerated(EnumType.STRING) - @Column(name = "platform", nullable = false, length = 20) private Platform platform; // ==================== 콘텐츠 내용 ==================== - - @Column(name = "title", nullable = false, length = 200) private String title; - - @Column(name = "content", nullable = false, columnDefinition = "TEXT") private String content; // ==================== 멀티미디어 및 메타데이터 ==================== - - @ElementCollection(fetch = FetchType.LAZY) - @CollectionTable( - name = "content_hashtags", - joinColumns = @JoinColumn(name = "content_id"), - indexes = @Index(name = "idx_content_hashtags", columnList = "content_id") - ) - @Column(name = "hashtag", length = 100) @Builder.Default private List hashtags = new ArrayList<>(); - @ElementCollection(fetch = FetchType.LAZY) - @CollectionTable( - name = "content_images", - joinColumns = @JoinColumn(name = "content_id"), - indexes = @Index(name = "idx_content_images", columnList = "content_id") - ) - @Column(name = "image_url", length = 500) @Builder.Default private List images = new ArrayList<>(); // ==================== 상태 관리 ==================== + private ContentStatus status; - @Enumerated(EnumType.STRING) - @Column(name = "status", nullable = false, length = 20) - @Builder.Default - private ContentStatus status = ContentStatus.DRAFT; - - // ==================== AI 생성 조건 (Embedded) ==================== - //@Embedded - @AttributeOverrides({ - @AttributeOverride(name = "toneAndManner", column = @Column(name = "tone_and_manner", length = 50)), - @AttributeOverride(name = "promotionType", column = @Column(name = "promotion_type", length = 50)), - @AttributeOverride(name = "emotionIntensity", column = @Column(name = "emotion_intensity", length = 50)), - @AttributeOverride(name = "targetAudience", column = @Column(name = "target_audience", length = 50)), - @AttributeOverride(name = "eventName", column = @Column(name = "event_name", length = 100)) - }) + // ==================== 생성 조건 ==================== private CreationConditions creationConditions; - // ==================== 비즈니스 관계 ==================== - - @Column(name = "store_id", nullable = false) + // ==================== 매장 정보 ==================== private Long storeId; - // ==================== 홍보 기간 ==================== - - @Column(name = "promotion_start_date") + // ==================== 프로모션 기간 ==================== private LocalDateTime promotionStartDate; - - @Column(name = "promotion_end_date") private LocalDateTime promotionEndDate; - // ==================== 감사(Audit) 정보 ==================== - - @CreatedDate - @Column(name = "created_at", nullable = false, updatable = false) + // ==================== 메타데이터 ==================== private LocalDateTime createdAt; - - @LastModifiedDate - @Column(name = "updated_at") private LocalDateTime updatedAt; public Content(ContentId of, ContentType contentType, Platform platform, String title, String content, List strings, List strings1, ContentStatus contentStatus, CreationConditions conditions, Long storeId, LocalDateTime createdAt, LocalDateTime updatedAt) { } - // ==================== 비즈니스 로직 메서드 ==================== + // ==================== 비즈니스 메서드 ==================== /** * 콘텐츠 제목 수정 - * - * 비즈니스 규칙: - * - 제목은 null이거나 빈 값일 수 없음 - * - 200자를 초과할 수 없음 - * - 발행된 콘텐츠는 제목 변경 시 상태가 DRAFT로 변경됨 - * - * @param title 새로운 제목 - * @throws IllegalArgumentException 제목이 유효하지 않은 경우 + * @param newTitle 새로운 제목 */ - public void updateTitle(String title) { - validateTitle(title); - - boolean wasPublished = isPublished(); - this.title = title.trim(); - - // 발행된 콘텐츠의 제목이 변경되면 재검토 필요 - if (wasPublished) { - this.status = ContentStatus.DRAFT; + public void updateTitle(String newTitle) { + if (newTitle == null || newTitle.trim().isEmpty()) { + throw new IllegalArgumentException("제목은 필수입니다."); } + this.title = newTitle.trim(); + this.updatedAt = LocalDateTime.now(); } /** * 콘텐츠 내용 수정 - * - * 비즈니스 규칙: - * - 내용은 null이거나 빈 값일 수 없음 - * - 발행된 콘텐츠는 내용 변경 시 상태가 DRAFT로 변경됨 - * - * @param content 새로운 콘텐츠 내용 - * @throws IllegalArgumentException 내용이 유효하지 않은 경우 + * @param newContent 새로운 내용 */ - public void updateContent(String content) { - validateContent(content); + public void updateContent(String newContent) { + this.content = newContent; + this.updatedAt = LocalDateTime.now(); + } - boolean wasPublished = isPublished(); - this.content = content.trim(); - - // 발행된 콘텐츠의 내용이 변경되면 재검토 필요 - if (wasPublished) { - this.status = ContentStatus.DRAFT; + /** + * 프로모션 기간 설정 + * @param startDate 시작일 + * @param endDate 종료일 + */ + public void updatePeriod(LocalDateTime startDate, LocalDateTime endDate) { + if (startDate != null && endDate != null && startDate.isAfter(endDate)) { + throw new IllegalArgumentException("시작일은 종료일보다 이후일 수 없습니다."); } + this.promotionStartDate = startDate; + this.promotionEndDate = endDate; + this.updatedAt = LocalDateTime.now(); } /** * 콘텐츠 상태 변경 - * - * 비즈니스 규칙: - * - PUBLISHED 상태로 변경시 유효성 검증 수행 - * - ARCHIVED 상태에서는 PUBLISHED로만 변경 가능 - * - * @param status 새로운 상태 - * @throws IllegalStateException 잘못된 상태 전환인 경우 + * @param newStatus 새로운 상태 */ -// public void changeStatus(ContentStatus status) { -// validateStatusTransition(this.status, status); -// -// if (status == ContentStatus.PUBLISHED) { -// validateForPublication(); -// } -// -// this.status = status; -// } - - /** - * 홍보 기간 설정 - * - * 비즈니스 규칙: - * - 시작일은 종료일보다 이전이어야 함 - * - 과거 날짜로 설정 불가 (현재 시간 기준) - * - * @param startDate 홍보 시작일 - * @param endDate 홍보 종료일 - * @throws IllegalArgumentException 날짜가 유효하지 않은 경우 - */ - public void setPromotionPeriod(LocalDateTime startDate, LocalDateTime endDate) { - validatePromotionPeriod(startDate, endDate); - - this.promotionStartDate = startDate; - this.promotionEndDate = endDate; - } - - /** - * 홍보 기간 설정 - * - * 비즈니스 규칙: - * - 시작일은 종료일보다 이전이어야 함 - * - 과거 날짜로 설정 불가 (현재 시간 기준) - * - * @param startDate 홍보 시작일 - * @param endDate 홍보 종료일 - * @throws IllegalArgumentException 날짜가 유효하지 않은 경우 - */ - public void updatePeriod(LocalDateTime startDate, LocalDateTime endDate) { - validatePromotionPeriod(startDate, endDate); - - this.promotionStartDate = startDate; - this.promotionEndDate = endDate; + public void updateStatus(ContentStatus newStatus) { + if (newStatus == null) { + throw new IllegalArgumentException("상태는 필수입니다."); + } + this.status = newStatus; + this.updatedAt = LocalDateTime.now(); } /** * 해시태그 추가 - * - * @param hashtag 추가할 해시태그 (# 없이) + * @param hashtag 추가할 해시태그 */ public void addHashtag(String hashtag) { if (hashtag != null && !hashtag.trim().isEmpty()) { - String cleanHashtag = hashtag.trim().replace("#", ""); - if (!this.hashtags.contains(cleanHashtag)) { - this.hashtags.add(cleanHashtag); + if (this.hashtags == null) { + this.hashtags = new ArrayList<>(); } - } - } - - /** - * 해시태그 제거 - * - * @param hashtag 제거할 해시태그 - */ - public void removeHashtag(String hashtag) { - if (hashtag != null) { - String cleanHashtag = hashtag.trim().replace("#", ""); - this.hashtags.remove(cleanHashtag); + this.hashtags.add(hashtag.trim()); + this.updatedAt = LocalDateTime.now(); } } /** * 이미지 추가 - * - * @param imageUrl 이미지 URL + * @param imageUrl 추가할 이미지 URL */ public void addImage(String imageUrl) { if (imageUrl != null && !imageUrl.trim().isEmpty()) { - if (!this.images.contains(imageUrl.trim())) { - this.images.add(imageUrl.trim()); + if (this.images == null) { + this.images = new ArrayList<>(); } + this.images.add(imageUrl.trim()); + this.updatedAt = LocalDateTime.now(); } } /** - * 이미지 제거 - * - * @param imageUrl 제거할 이미지 URL + * 프로모션 진행 중 여부 확인 + * @return 현재 시간이 프로모션 기간 내에 있으면 true */ - public void removeImage(String imageUrl) { - if (imageUrl != null) { - this.images.remove(imageUrl.trim()); - } - } - - // ==================== 도메인 조회 메서드 ==================== - - /** - * 발행 상태 확인 - * - * @return 발행된 상태이면 true - */ - public boolean isPublished() { - return this.status == ContentStatus.PUBLISHED; - } - - /** - * 수정 가능 상태 확인 - * - * @return 임시저장 또는 예약발행 상태이면 true - */ - public boolean isEditable() { - return this.status == ContentStatus.DRAFT || this.status == ContentStatus.PUBLISHED; - } - - /** - * 현재 홍보 진행 중인지 확인 - * - * @return 홍보 기간 내이고 발행 상태이면 true - */ - public boolean isOngoingPromotion() { - if (!isPublished() || promotionStartDate == null || promotionEndDate == null) { - return false; - } - - LocalDateTime now = LocalDateTime.now(); - return now.isAfter(promotionStartDate) && now.isBefore(promotionEndDate); - } - - /** - * 홍보 예정 상태 확인 - * - * @return 홍보 시작 전이면 true - */ - public boolean isUpcomingPromotion() { - if (promotionStartDate == null) { - return false; - } - - return LocalDateTime.now().isBefore(promotionStartDate); - } - - /** - * 홍보 완료 상태 확인 - * - * @return 홍보 종료 후이면 true - */ - public boolean isCompletedPromotion() { - if (promotionEndDate == null) { - return false; - } - - return LocalDateTime.now().isAfter(promotionEndDate); - } - - /** - * SNS 콘텐츠 여부 확인 - * - * @return SNS 게시물이면 true - */ -// public boolean isSnsContent() { -// return this.contentType == ContentType.SNS_POST; -// } - - /** - * 포스터 콘텐츠 여부 확인 - * - * @return 포스터이면 true - */ - public boolean isPosterContent() { - return this.contentType == ContentType.POSTER; - } - - /** - * 이미지가 있는 콘텐츠인지 확인 - * - * @return 이미지가 1개 이상 있으면 true - */ - public boolean hasImages() { - return this.images != null && !this.images.isEmpty(); - } - - /** - * 해시태그가 있는 콘텐츠인지 확인 - * - * @return 해시태그가 1개 이상 있으면 true - */ - public boolean hasHashtags() { - return this.hashtags != null && !this.hashtags.isEmpty(); - } - - // ==================== 유효성 검증 메서드 ==================== - - /** - * 제목 유효성 검증 - */ - private void validateTitle(String title) { - if (title == null || title.trim().isEmpty()) { - throw new IllegalArgumentException("제목은 필수 입력 사항입니다."); - } - if (title.trim().length() > 200) { - throw new IllegalArgumentException("제목은 200자를 초과할 수 없습니다."); - } - } - - /** - * 내용 유효성 검증 - */ - private void validateContent(String content) { - if (content == null || content.trim().isEmpty()) { - throw new IllegalArgumentException("콘텐츠 내용은 필수 입력 사항입니다."); - } - } - - /** - * 홍보 기간 유효성 검증 - */ - private void validatePromotionPeriod(LocalDateTime startDate, LocalDateTime endDate) { - if (startDate == null || endDate == null) { - throw new IllegalArgumentException("홍보 시작일과 종료일은 필수 입력 사항입니다."); - } - if (startDate.isAfter(endDate)) { - throw new IllegalArgumentException("홍보 시작일은 종료일보다 이전이어야 합니다."); - } - if (endDate.isBefore(LocalDateTime.now())) { - throw new IllegalArgumentException("홍보 종료일은 현재 시간 이후여야 합니다."); - } - } - - /** - * 상태 전환 유효성 검증 - */ -// private void validateStatusTransition(ContentStatus from, ContentStatus to) { -// if (from == ContentStatus.ARCHIVED && to != ContentStatus.PUBLISHED) { -// throw new IllegalStateException("보관된 콘텐츠는 발행 상태로만 변경할 수 있습니다."); -// } -// } - - /** - * 발행을 위한 유효성 검증 - */ - private void validateForPublication() { - validateTitle(this.title); - validateContent(this.content); - - if (this.promotionStartDate == null || this.promotionEndDate == null) { - throw new IllegalStateException("발행하려면 홍보 기간을 설정해야 합니다."); - } - - if (this.contentType == ContentType.POSTER && !hasImages()) { - throw new IllegalStateException("포스터 콘텐츠는 이미지가 필수입니다."); - } - } - - // ==================== 비즈니스 계산 메서드 ==================== - - /** - * 홍보 진행률 계산 (0-100%) - * - * @return 진행률 - */ - public double calculateProgress() { + public boolean isPromotionActive() { if (promotionStartDate == null || promotionEndDate == null) { - return 0.0; + return false; } - LocalDateTime now = LocalDateTime.now(); - - if (now.isBefore(promotionStartDate)) { - return 0.0; - } else if (now.isAfter(promotionEndDate)) { - return 100.0; - } - - long totalDuration = java.time.Duration.between(promotionStartDate, promotionEndDate).toHours(); - long elapsedDuration = java.time.Duration.between(promotionStartDate, now).toHours(); - - if (totalDuration == 0) { - return 100.0; - } - - return (double) elapsedDuration / totalDuration * 100.0; + return !now.isBefore(promotionStartDate) && !now.isAfter(promotionEndDate); } /** - * 남은 홍보 일수 계산 - * - * @return 남은 일수 (음수면 0) + * 콘텐츠 게시 가능 여부 확인 + * @return 필수 정보가 모두 입력되어 있으면 true */ - public long calculateRemainingDays() { - if (promotionEndDate == null) { - return 0L; - } - - LocalDateTime now = LocalDateTime.now(); - if (now.isAfter(promotionEndDate)) { - return 0L; - } - - return java.time.Duration.between(now, promotionEndDate).toDays(); + public boolean canBePublished() { + return title != null && !title.trim().isEmpty() + && contentType != null + && platform != null + && storeId != null; } - - // ==================== 팩토리 메서드 ==================== - - /** - * SNS 콘텐츠 생성 팩토리 메서드 - */ - public static Content createSnsContent(String title, String content, Platform platform, - Long storeId, CreationConditions conditions) { - Content snsContent = Content.builder() -// .contentType(ContentType.SNS_POST) - .platform(platform) - .title(title) - .content(content) - .storeId(storeId) - .creationConditions(conditions) - .status(ContentStatus.DRAFT) - .hashtags(new ArrayList<>()) - .images(new ArrayList<>()) - .build(); - - // 유효성 검증 - snsContent.validateTitle(title); - snsContent.validateContent(content); - - return snsContent; - } - - /** - * 포스터 콘텐츠 생성 팩토리 메서드 - */ - public static Content createPosterContent(String title, String content, List images, - Long storeId, CreationConditions conditions) { - if (images == null || images.isEmpty()) { - throw new IllegalArgumentException("포스터 콘텐츠는 이미지가 필수입니다."); - } - - Content posterContent = Content.builder() - .contentType(ContentType.POSTER) - .platform(Platform.INSTAGRAM) // 기본값 - .title(title) - .content(content) - .storeId(storeId) - .creationConditions(conditions) - .status(ContentStatus.DRAFT) - .hashtags(new ArrayList<>()) - .images(new ArrayList<>(images)) - .build(); - - // 유효성 검증 - posterContent.validateTitle(title); - posterContent.validateContent(content); - - return posterContent; - } - - // ==================== Object 메서드 오버라이드 ==================== - - /** - * 비즈니스 키 기반 동등성 비교 - * JPA 엔티티에서는 ID가 아닌 비즈니스 키 사용 권장 - */ - @Override - public boolean equals(Object obj) { - if (this == obj) return true; - if (obj == null || getClass() != obj.getClass()) return false; - - Content content = (Content) obj; - return id != null && id.equals(content.id); - } - - @Override - public int hashCode() { - return getClass().hashCode(); - } - - /** - * 디버깅용 toString (민감한 정보 제외) - */ - @Override - public String toString() { - return "Content{" + - "id=" + id + - ", contentType=" + contentType + - ", platform=" + platform + - ", title='" + title + '\'' + - ", status=" + status + - ", storeId=" + storeId + - ", promotionStartDate=" + promotionStartDate + - ", promotionEndDate=" + promotionEndDate + - ", createdAt=" + createdAt + - '}'; - } -} - -/* -==================== 데이터베이스 스키마 (참고용) ==================== - -CREATE TABLE contents ( - content_id BIGINT NOT NULL AUTO_INCREMENT, - content_type VARCHAR(20) NOT NULL, - platform VARCHAR(20) NOT NULL, - title VARCHAR(200) NOT NULL, - content TEXT NOT NULL, - status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', - tone_and_manner VARCHAR(50), - promotion_type VARCHAR(50), - emotion_intensity VARCHAR(50), - target_audience VARCHAR(50), - event_name VARCHAR(100), - store_id BIGINT NOT NULL, - promotion_start_date DATETIME, - promotion_end_date DATETIME, - created_at DATETIME NOT NULL, - updated_at DATETIME, - PRIMARY KEY (content_id), - INDEX idx_store_id (store_id), - INDEX idx_content_type (content_type), - INDEX idx_platform (platform), - INDEX idx_status (status), - INDEX idx_promotion_dates (promotion_start_date, promotion_end_date), - INDEX idx_created_at (created_at) -); - -CREATE TABLE content_hashtags ( - content_id BIGINT NOT NULL, - hashtag VARCHAR(100) NOT NULL, - INDEX idx_content_hashtags (content_id), - FOREIGN KEY (content_id) REFERENCES contents(content_id) ON DELETE CASCADE -); - -CREATE TABLE content_images ( - content_id BIGINT NOT NULL, - image_url VARCHAR(500) NOT NULL, - INDEX idx_content_images (content_id), - FOREIGN KEY (content_id) REFERENCES contents(content_id) ON DELETE CASCADE -); -*/ \ No newline at end of file +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java index 25220a8..2f07e2c 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java @@ -1,53 +1,51 @@ // marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java package com.won.smarketing.content.domain.model; -import jakarta.persistence.Embeddable; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.util.Objects; +import lombok.RequiredArgsConstructor; /** * 콘텐츠 ID 값 객체 - * Clean Architecture의 Domain Layer에 위치하는 식별자 + * Clean Architecture의 Domain Layer에서 식별자를 타입 안전하게 관리 */ -@Embeddable @Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) +@RequiredArgsConstructor +@EqualsAndHashCode public class ContentId { - private Long value; + private final Long value; /** - * ContentId 생성 팩토리 메서드 + * Long 값으로부터 ContentId 생성 * @param value ID 값 * @return ContentId 인스턴스 */ public static ContentId of(Long value) { if (value == null || value <= 0) { - throw new IllegalArgumentException("ContentId 값은 양수여야 합니다."); + throw new IllegalArgumentException("ContentId는 양수여야 합니다."); } return new ContentId(value); } - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - ContentId contentId = (ContentId) o; - return Objects.equals(value, contentId.value); + /** + * 새로운 ContentId 생성 (ID가 없는 경우) + * @return null 값을 가진 ContentId + */ + public static ContentId newId() { + return new ContentId(null); } - @Override - public int hashCode() { - return Objects.hash(value); + /** + * ID 값 존재 여부 확인 + * @return ID가 null이 아니면 true + */ + public boolean hasValue() { + return value != null; } @Override public String toString() { - return "ContentId{" + value + '}'; + return "ContentId{" + "value=" + value + '}'; } } \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java index cb1f914..d7a9543 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java @@ -1,7 +1,6 @@ // marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java package com.won.smarketing.content.domain.model; -import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -12,57 +11,48 @@ import java.time.LocalDate; /** * 콘텐츠 생성 조건 도메인 모델 * Clean Architecture의 Domain Layer에 위치하는 값 객체 + * + * JPA 애노테이션을 제거하여 순수 도메인 모델로 유지 + * Infrastructure Layer의 JPA 엔티티는 별도로 관리 */ -@Entity -@Table(name = "contents_conditions") @Getter @NoArgsConstructor @AllArgsConstructor @Builder public class CreationConditions { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - //@OneToOne(mappedBy = "creationConditions") - @Column(name = "content", length = 100) - private Content content; - - @Column(name = "category", length = 100) + private String id; private String category; - - @Column(name = "requirement", columnDefinition = "TEXT") private String requirement; - - @Column(name = "tone_and_manner", length = 100) private String toneAndManner; - - @Column(name = "emotion_intensity", length = 100) private String emotionIntensity; - - @Column(name = "event_name", length = 200) private String eventName; - - @Column(name = "start_date") private LocalDate startDate; - - @Column(name = "end_date") private LocalDate endDate; - - @Column(name = "photo_style", length = 100) private String photoStyle; - - @Column(name = "promotionType", length = 100) private String promotionType; public CreationConditions(String category, String requirement, String toneAndManner, String emotionIntensity, String eventName, LocalDate startDate, LocalDate endDate, String photoStyle, String promotionType) { } -// /** -// * 콘텐츠와의 연관관계 설정 -// * @param content 연관된 콘텐츠 -// */ -// public void setContent(Content content) { -// this.content = content; -// } + + /** + * 이벤트 기간 유효성 검증 + * @return 시작일이 종료일보다 이전이거나 같으면 true + */ + public boolean isValidEventPeriod() { + if (startDate == null || endDate == null) { + return true; + } + return !startDate.isAfter(endDate); + } + + /** + * 이벤트 조건 유무 확인 + * @return 이벤트명이나 날짜가 설정되어 있으면 true + */ + public boolean hasEventInfo() { + return eventName != null && !eventName.trim().isEmpty() + || startDate != null + || endDate != null; + } } \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java index 940bbba..b549b05 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java @@ -11,6 +11,7 @@ import java.time.LocalDate; /** * 콘텐츠 생성 조건 JPA 엔티티 + * Infrastructure Layer에서 데이터베이스 매핑을 담당 */ @Entity @Table(name = "content_conditions") @@ -50,9 +51,34 @@ public class ContentConditionsJpaEntity { @Column(name = "photo_style", length = 100) private String photoStyle; - @Column(name = "target_audience", length = 200) - private String targetAudience; - @Column(name = "promotion_type", length = 100) private String promotionType; + + // 생성자 + public ContentConditionsJpaEntity(ContentJpaEntity content, String category, String requirement, + String toneAndManner, String emotionIntensity, String eventName, + LocalDate startDate, LocalDate endDate, String photoStyle, String promotionType) { + this.content = content; + this.category = category; + this.requirement = requirement; + this.toneAndManner = toneAndManner; + this.emotionIntensity = emotionIntensity; + this.eventName = eventName; + this.startDate = startDate; + this.endDate = endDate; + this.photoStyle = photoStyle; + this.promotionType = promotionType; + } + + public ContentConditionsJpaEntity() { + + } + + // 팩토리 메서드 + public static ContentConditionsJpaEntity create(ContentJpaEntity content, String category, String requirement, + String toneAndManner, String emotionIntensity, String eventName, + LocalDate startDate, LocalDate endDate, String photoStyle, String promotionType) { + return new ContentConditionsJpaEntity(content, category, requirement, toneAndManner, emotionIntensity, + eventName, startDate, endDate, photoStyle, promotionType); + } } \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java index 2bd786a..bcc8499 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java @@ -10,6 +10,7 @@ import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; import java.time.LocalDateTime; +import java.util.Date; /** * 콘텐츠 JPA 엔티티 @@ -37,6 +38,12 @@ public class ContentJpaEntity { @Column(name = "title", length = 500) private String title; + @Column(name = "PromotionStartDate") + private LocalDateTime PromotionStartDate; + + @Column(name = "PromotionEndDate") + private LocalDateTime PromotionEndDate; + @Column(name = "content", columnDefinition = "TEXT") private String content; diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java index a03954f..44fdb68 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java @@ -1,3 +1,4 @@ +// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java package com.won.smarketing.content.infrastructure.mapper; import com.won.smarketing.content.domain.model.*; @@ -14,6 +15,7 @@ import java.util.List; /** * 콘텐츠 도메인-엔티티 매퍼 + * Clean Architecture에서 Infrastructure Layer와 Domain Layer 간 변환 담당 * * @author smarketing-team * @version 1.0 @@ -26,7 +28,7 @@ public class ContentMapper { private final ObjectMapper objectMapper; /** - * 도메인 모델을 JPA 엔티티로 변환합니다. + * 도메인 모델을 JPA 엔티티로 변환 * * @param content 도메인 콘텐츠 * @return JPA 엔티티 @@ -37,32 +39,30 @@ public class ContentMapper { } ContentJpaEntity entity = new ContentJpaEntity(); + + // 기본 필드 매핑 if (content.getId() != null) { entity.setId(content.getId()); } entity.setStoreId(content.getStoreId()); - entity.setContentType(content.getContentType().name()); + entity.setContentType(content.getContentType() != null ? content.getContentType().name() : null); entity.setPlatform(content.getPlatform() != null ? content.getPlatform().name() : null); entity.setTitle(content.getTitle()); entity.setContent(content.getContent()); - entity.setHashtags(convertListToJson(content.getHashtags())); - entity.setImages(convertListToJson(content.getImages())); - entity.setStatus(content.getStatus().name()); + entity.setStatus(content.getStatus() != null ? content.getStatus().name() : "DRAFT"); + entity.setPromotionStartDate(content.getPromotionStartDate()); + entity.setPromotionEndDate(content.getPromotionEndDate()); entity.setCreatedAt(content.getCreatedAt()); entity.setUpdatedAt(content.getUpdatedAt()); - // 조건 정보 매핑 + // 컬렉션 필드를 JSON으로 변환 + entity.setHashtags(convertListToJson(content.getHashtags())); + entity.setImages(convertListToJson(content.getImages())); + + // 생성 조건 정보 매핑 if (content.getCreationConditions() != null) { - ContentConditionsJpaEntity conditionsEntity = new ContentConditionsJpaEntity(); + ContentConditionsJpaEntity conditionsEntity = mapToConditionsEntity(content.getCreationConditions()); conditionsEntity.setContent(entity); - conditionsEntity.setCategory(content.getCreationConditions().getCategory()); - conditionsEntity.setRequirement(content.getCreationConditions().getRequirement()); - conditionsEntity.setToneAndManner(content.getCreationConditions().getToneAndManner()); - conditionsEntity.setEmotionIntensity(content.getCreationConditions().getEmotionIntensity()); - conditionsEntity.setEventName(content.getCreationConditions().getEventName()); - conditionsEntity.setStartDate(content.getCreationConditions().getStartDate()); - conditionsEntity.setEndDate(content.getCreationConditions().getEndDate()); - conditionsEntity.setPhotoStyle(content.getCreationConditions().getPhotoStyle()); entity.setConditions(conditionsEntity); } @@ -70,50 +70,74 @@ public class ContentMapper { } /** - * JPA 엔티티를 도메인 모델로 변환합니다. + * JPA 엔티티를 도메인 모델로 변환 * * @param entity JPA 엔티티 - * @return 도메인 콘텐츠 + * @return 도메인 모델 */ public Content toDomain(ContentJpaEntity entity) { if (entity == null) { return null; } - CreationConditions conditions = null; - if (entity.getConditions() != null) { - conditions = new CreationConditions( - entity.getConditions().getCategory(), - entity.getConditions().getRequirement(), - entity.getConditions().getToneAndManner(), - entity.getConditions().getEmotionIntensity(), - entity.getConditions().getEventName(), - entity.getConditions().getStartDate(), - entity.getConditions().getEndDate(), - entity.getConditions().getPhotoStyle(), - // entity.getConditions().getTargetAudience(), - entity.getConditions().getPromotionType() - ); - } - - return new Content( - ContentId.of(entity.getId()), - ContentType.valueOf(entity.getContentType()), - entity.getPlatform() != null ? Platform.valueOf(entity.getPlatform()) : null, - entity.getTitle(), - entity.getContent(), - convertJsonToList(entity.getHashtags()), - convertJsonToList(entity.getImages()), - ContentStatus.valueOf(entity.getStatus()), - conditions, - entity.getStoreId(), - entity.getCreatedAt(), - entity.getUpdatedAt() - ); + return Content.builder() + .id(entity.getId()) + .storeId(entity.getStoreId()) + .contentType(parseContentType(entity.getContentType())) + .platform(parsePlatform(entity.getPlatform())) + .title(entity.getTitle()) + .content(entity.getContent()) + .hashtags(convertJsonToList(entity.getHashtags())) + .images(convertJsonToList(entity.getImages())) + .status(parseContentStatus(entity.getStatus())) + .promotionStartDate(entity.getPromotionStartDate()) + .promotionEndDate(entity.getPromotionEndDate()) + .creationConditions(mapToConditionsDomain(entity.getConditions())) + .createdAt(entity.getCreatedAt()) + .updatedAt(entity.getUpdatedAt()) + .build(); } /** - * List를 JSON 문자열로 변환합니다. + * CreationConditions 도메인을 JPA 엔티티로 변환 + */ + private ContentConditionsJpaEntity mapToConditionsEntity(CreationConditions conditions) { + ContentConditionsJpaEntity entity = new ContentConditionsJpaEntity(); + entity.setCategory(conditions.getCategory()); + entity.setRequirement(conditions.getRequirement()); + entity.setToneAndManner(conditions.getToneAndManner()); + entity.setEmotionIntensity(conditions.getEmotionIntensity()); + entity.setEventName(conditions.getEventName()); + entity.setStartDate(conditions.getStartDate()); + entity.setEndDate(conditions.getEndDate()); + entity.setPhotoStyle(conditions.getPhotoStyle()); + entity.setPromotionType(conditions.getPromotionType()); + return entity; + } + + /** + * CreationConditions JPA 엔티티를 도메인으로 변환 + */ + private CreationConditions mapToConditionsDomain(ContentConditionsJpaEntity entity) { + if (entity == null) { + return null; + } + + return CreationConditions.builder() + .category(entity.getCategory()) + .requirement(entity.getRequirement()) + .toneAndManner(entity.getToneAndManner()) + .emotionIntensity(entity.getEmotionIntensity()) + .eventName(entity.getEventName()) + .startDate(entity.getStartDate()) + .endDate(entity.getEndDate()) + .photoStyle(entity.getPhotoStyle()) + .promotionType(entity.getPromotionType()) + .build(); + } + + /** + * List를 JSON 문자열로 변환 */ private String convertListToJson(List list) { if (list == null || list.isEmpty()) { @@ -128,7 +152,7 @@ public class ContentMapper { } /** - * JSON 문자열을 List로 변환합니다. + * JSON 문자열을 List로 변환 */ private List convertJsonToList(String json) { if (json == null || json.trim().isEmpty()) { @@ -141,4 +165,49 @@ public class ContentMapper { return Collections.emptyList(); } } -} + + /** + * 문자열을 ContentType 열거형으로 변환 + */ + private ContentType parseContentType(String contentType) { + if (contentType == null) { + return null; + } + try { + return ContentType.valueOf(contentType); + } catch (IllegalArgumentException e) { + log.warn("Unknown content type: {}", contentType); + return null; + } + } + + /** + * 문자열을 Platform 열거형으로 변환 + */ + private Platform parsePlatform(String platform) { + if (platform == null) { + return null; + } + try { + return Platform.valueOf(platform); + } catch (IllegalArgumentException e) { + log.warn("Unknown platform: {}", platform); + return null; + } + } + + /** + * 문자열을 ContentStatus 열거형으로 변환 + */ + private ContentStatus parseContentStatus(String status) { + if (status == null) { + return ContentStatus.DRAFT; + } + try { + return ContentStatus.valueOf(status); + } catch (IllegalArgumentException e) { + log.warn("Unknown content status: {}", status); + return ContentStatus.DRAFT; + } + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java index da461e5..f3f38ed 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java @@ -6,63 +6,100 @@ import com.won.smarketing.content.domain.model.ContentId; import com.won.smarketing.content.domain.model.ContentType; import com.won.smarketing.content.domain.model.Platform; import com.won.smarketing.content.domain.repository.ContentRepository; +import com.won.smarketing.content.infrastructure.entity.ContentJpaEntity; +import com.won.smarketing.content.infrastructure.mapper.ContentMapper; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Repository; import java.util.List; import java.util.Optional; +import java.util.stream.Collectors; /** * JPA를 활용한 콘텐츠 리포지토리 구현체 * Clean Architecture의 Infrastructure Layer에 위치 + * JPA 엔티티와 도메인 모델 간 변환을 위해 ContentMapper 사용 */ @Repository @RequiredArgsConstructor +@Slf4j public class JpaContentRepository implements ContentRepository { private final JpaContentRepositoryInterface jpaRepository; + private final ContentMapper contentMapper; /** * 콘텐츠 저장 - * @param content 저장할 콘텐츠 - * @return 저장된 콘텐츠 + * @param content 저장할 도메인 콘텐츠 + * @return 저장된 도메인 콘텐츠 */ @Override public Content save(Content content) { - return jpaRepository.save(content); + log.debug("Saving content: {}", content.getTitle()); + + // 도메인 모델을 JPA 엔티티로 변환 + ContentJpaEntity entity = contentMapper.toEntity(content); + + // JPA로 저장 + ContentJpaEntity savedEntity = jpaRepository.save(entity); + + // JPA 엔티티를 도메인 모델로 변환하여 반환 + Content savedContent = contentMapper.toDomain(savedEntity); + + log.debug("Content saved with ID: {}", savedContent.getId()); + return savedContent; } /** * ID로 콘텐츠 조회 * @param id 콘텐츠 ID - * @return 조회된 콘텐츠 + * @return 조회된 도메인 콘텐츠 */ @Override public Optional findById(ContentId id) { - return jpaRepository.findById(id.getValue()); + log.debug("Finding content by ID: {}", id.getValue()); + + return jpaRepository.findById(id.getValue()) + .map(contentMapper::toDomain); } /** * 필터 조건으로 콘텐츠 목록 조회 * @param contentType 콘텐츠 타입 * @param platform 플랫폼 - * @param period 기간 - * @param sortBy 정렬 기준 - * @return 콘텐츠 목록 + * @param period 기간 (현재는 사용하지 않음) + * @param sortBy 정렬 기준 (현재는 사용하지 않음) + * @return 도메인 콘텐츠 목록 */ @Override public List findByFilters(ContentType contentType, Platform platform, String period, String sortBy) { - return jpaRepository.findByFilters(contentType, platform, period, sortBy); + log.debug("Finding contents with filters - contentType: {}, platform: {}", contentType, platform); + + String contentTypeStr = contentType != null ? contentType.name() : null; + String platformStr = platform != null ? platform.name() : null; + + List entities = jpaRepository.findByFilters(contentTypeStr, platformStr, null); + + return entities.stream() + .map(contentMapper::toDomain) + .collect(Collectors.toList()); } /** * 진행 중인 콘텐츠 목록 조회 - * @param period 기간 - * @return 진행 중인 콘텐츠 목록 + * @param period 기간 (현재는 사용하지 않음) + * @return 진행 중인 도메인 콘텐츠 목록 */ @Override public List findOngoingContents(String period) { - return jpaRepository.findOngoingContents(period); + log.debug("Finding ongoing contents"); + + List entities = jpaRepository.findOngoingContents(); + + return entities.stream() + .map(contentMapper::toDomain) + .collect(Collectors.toList()); } /** @@ -71,6 +108,40 @@ public class JpaContentRepository implements ContentRepository { */ @Override public void deleteById(ContentId id) { + log.debug("Deleting content by ID: {}", id.getValue()); + jpaRepository.deleteById(id.getValue()); + + log.debug("Content deleted successfully"); + } + + /** + * 매장 ID로 콘텐츠 목록 조회 (추가 메서드) + * @param storeId 매장 ID + * @return 도메인 콘텐츠 목록 + */ + public List findByStoreId(Long storeId) { + log.debug("Finding contents by store ID: {}", storeId); + + List entities = jpaRepository.findByStoreId(storeId); + + return entities.stream() + .map(contentMapper::toDomain) + .collect(Collectors.toList()); + } + + /** + * 콘텐츠 타입으로 조회 (추가 메서드) + * @param contentType 콘텐츠 타입 + * @return 도메인 콘텐츠 목록 + */ + public List findByContentType(ContentType contentType) { + log.debug("Finding contents by type: {}", contentType); + + List entities = jpaRepository.findByContentType(contentType.name()); + + return entities.stream() + .map(contentMapper::toDomain) + .collect(Collectors.toList()); } } \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepositoryInterface.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepositoryInterface.java index 380bba6..37c4e74 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepositoryInterface.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepositoryInterface.java @@ -1,9 +1,7 @@ // marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepositoryInterface.java package com.won.smarketing.content.infrastructure.repository; -import com.won.smarketing.content.domain.model.Content; -import com.won.smarketing.content.domain.model.ContentType; -import com.won.smarketing.content.domain.model.Platform; +import com.won.smarketing.content.infrastructure.entity.ContentJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -13,37 +11,77 @@ import java.util.List; /** * Spring Data JPA 콘텐츠 리포지토리 인터페이스 * Clean Architecture의 Infrastructure Layer에 위치 + * JPA 엔티티(ContentJpaEntity)를 사용하여 데이터베이스 접근 */ -public interface JpaContentRepositoryInterface extends JpaRepository { +public interface JpaContentRepositoryInterface extends JpaRepository { + + /** + * 매장 ID로 콘텐츠 목록 조회 + * @param storeId 매장 ID + * @return 콘텐츠 엔티티 목록 + */ + List findByStoreId(Long storeId); + + /** + * 콘텐츠 타입으로 조회 + * @param contentType 콘텐츠 타입 + * @return 콘텐츠 엔티티 목록 + */ + List findByContentType(String contentType); + + /** + * 플랫폼으로 조회 + * @param platform 플랫폼 + * @return 콘텐츠 엔티티 목록 + */ + List findByPlatform(String platform); + + /** + * 상태로 조회 + * @param status 상태 + * @return 콘텐츠 엔티티 목록 + */ + List findByStatus(String status); /** * 필터 조건으로 콘텐츠 목록 조회 + * @param contentType 콘텐츠 타입 (null 가능) + * @param platform 플랫폼 (null 가능) + * @param status 상태 (null 가능) + * @return 콘텐츠 엔티티 목록 */ - @Query("SELECT c FROM Content c WHERE " + + @Query("SELECT c FROM ContentJpaEntity c WHERE " + "(:contentType IS NULL OR c.contentType = :contentType) AND " + "(:platform IS NULL OR c.platform = :platform) AND " + - "(:period IS NULL OR " + - " (:period = 'week' AND c.createdAt >= CURRENT_DATE - 7) OR " + - " (:period = 'month' AND c.createdAt >= CURRENT_DATE - 30) OR " + - " (:period = 'year' AND c.createdAt >= CURRENT_DATE - 365)) " + - "ORDER BY " + - "CASE WHEN :sortBy = 'latest' THEN c.createdAt END DESC, " + - "CASE WHEN :sortBy = 'oldest' THEN c.createdAt END ASC, " + - "CASE WHEN :sortBy = 'title' THEN c.title END ASC") - List findByFilters(@Param("contentType") ContentType contentType, - @Param("platform") Platform platform, - @Param("period") String period, - @Param("sortBy") String sortBy); + "(:status IS NULL OR c.status = :status) " + + "ORDER BY c.createdAt DESC") + List findByFilters(@Param("contentType") String contentType, + @Param("platform") String platform, + @Param("status") String status); /** - * 진행 중인 콘텐츠 목록 조회 + * 진행 중인 콘텐츠 목록 조회 (발행된 상태의 콘텐츠) + * @return 진행 중인 콘텐츠 엔티티 목록 */ - @Query("SELECT c FROM Content c WHERE " + - "c.status IN ('PUBLISHED', 'SCHEDULED') AND " + - "(:period IS NULL OR " + - " (:period = 'week' AND c.createdAt >= CURRENT_DATE - 7) OR " + - " (:period = 'month' AND c.createdAt >= CURRENT_DATE - 30) OR " + - " (:period = 'year' AND c.createdAt >= CURRENT_DATE - 365)) " + + @Query("SELECT c FROM ContentJpaEntity c WHERE " + + "c.status IN ('PUBLISHED', 'SCHEDULED') " + "ORDER BY c.createdAt DESC") - List findOngoingContents(@Param("period") String period); + List findOngoingContents(); + + /** + * 매장 ID와 콘텐츠 타입으로 조회 + * @param storeId 매장 ID + * @param contentType 콘텐츠 타입 + * @return 콘텐츠 엔티티 목록 + */ + List findByStoreIdAndContentType(Long storeId, String contentType); + + /** + * 최근 생성된 콘텐츠 조회 (limit 적용) + * @param storeId 매장 ID + * @return 최근 콘텐츠 엔티티 목록 + */ + @Query("SELECT c FROM ContentJpaEntity c WHERE c.storeId = :storeId " + + "ORDER BY c.createdAt DESC") + List findRecentContentsByStoreId(@Param("storeId") Long storeId); } \ No newline at end of file From de6b160acad14c9ecd6429c0ec4393df55b92fbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=9C=EC=9D=80?= <61147083+BangSun98@users.noreply.github.com> Date: Thu, 12 Jun 2025 11:02:07 +0900 Subject: [PATCH 25/34] fix marketing-content --- .../MarketingContentServiceApplication.java | 4 +- .../content/config/ContentConfig.java | 9 ++ .../external/ClaudeAiContentGenerator.java | 108 +++++++----------- .../external/ClaudeAiPosterGenerator.java | 92 +++++---------- .../SpringDataContentRepository.java | 51 --------- .../src/main/resources/application.yml | 18 ++- 6 files changed, 88 insertions(+), 194 deletions(-) create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ContentConfig.java delete mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/SpringDataContentRepository.java diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java index 50d69a1..537a189 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java @@ -3,6 +3,7 @@ package com.won.smarketing.content; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; /** @@ -17,8 +18,9 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories; "com.won.smarketing.content.infrastructure.repository" }) @EntityScan(basePackages = { - "com.won.smarketing.content.domain.model" + "com.won.smarketing.content.infrastructure.entity" }) +@EnableJpaAuditing public class MarketingContentServiceApplication { public static void main(String[] args) { diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ContentConfig.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ContentConfig.java new file mode 100644 index 0000000..3931d19 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ContentConfig.java @@ -0,0 +1,9 @@ +package com.won.smarketing.content.config; + +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ComponentScan(basePackages = "com.won.smarketing.content") +public class ContentConfig { +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java index 5cf42a4..9d72f1f 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java @@ -1,8 +1,10 @@ // marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java package com.won.smarketing.content.infrastructure.external; +// 수정: domain 패키지의 인터페이스를 import +import com.won.smarketing.content.domain.service.AiContentGenerator; import com.won.smarketing.content.domain.model.Platform; -import com.won.smarketing.content.domain.model.CreationConditions; +import com.won.smarketing.content.presentation.dto.SnsContentCreateRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -21,105 +23,73 @@ public class ClaudeAiContentGenerator implements AiContentGenerator { /** * SNS 콘텐츠 생성 - * Claude AI API를 호출하여 SNS 게시물을 생성합니다. - * - * @param title 제목 - * @param category 카테고리 - * @param platform 플랫폼 - * @param conditions 생성 조건 - * @return 생성된 콘텐츠 텍스트 */ @Override - public String generateSnsContent(String title, String category, Platform platform, CreationConditions conditions) { + public String generateSnsContent(SnsContentCreateRequest request) { try { - // Claude AI API 호출 로직 (실제 구현에서는 HTTP 클라이언트를 사용) - String prompt = buildContentPrompt(title, category, platform, conditions); - - // TODO: 실제 Claude AI API 호출 - // 현재는 더미 데이터 반환 - return generateDummySnsContent(title, platform); - + String prompt = buildContentPrompt(request); + return generateDummySnsContent(request.getTitle(), Platform.fromString(request.getPlatform())); } catch (Exception e) { log.error("AI 콘텐츠 생성 실패: {}", e.getMessage(), e); - return generateFallbackContent(title, platform); + return generateFallbackContent(request.getTitle(), Platform.fromString(request.getPlatform())); } } /** - * 해시태그 생성 - * 콘텐츠 내용을 분석하여 관련 해시태그를 생성합니다. - * - * @param content 콘텐츠 내용 - * @param platform 플랫폼 - * @return 생성된 해시태그 목록 + * 플랫폼별 해시태그 생성 */ @Override public List generateHashtags(String content, Platform platform) { try { - // TODO: 실제 Claude AI API 호출하여 해시태그 생성 - // 현재는 더미 데이터 반환 return generateDummyHashtags(platform); - } catch (Exception e) { log.error("해시태그 생성 실패: {}", e.getMessage(), e); - return Arrays.asList("#맛집", "#신메뉴", "#추천"); + return generateFallbackHashtags(); } } - /** - * AI 프롬프트 생성 - */ - private String buildContentPrompt(String title, String category, Platform platform, CreationConditions conditions) { + private String buildContentPrompt(SnsContentCreateRequest request) { StringBuilder prompt = new StringBuilder(); - prompt.append("다음 조건에 맞는 ").append(platform.getDisplayName()).append(" 게시물을 작성해주세요:\n"); - prompt.append("제목: ").append(title).append("\n"); - prompt.append("카테고리: ").append(category).append("\n"); + prompt.append("제목: ").append(request.getTitle()).append("\n"); + prompt.append("카테고리: ").append(request.getCategory()).append("\n"); + prompt.append("플랫폼: ").append(request.getPlatform()).append("\n"); - if (conditions.getRequirement() != null) { - prompt.append("요구사항: ").append(conditions.getRequirement()).append("\n"); + if (request.getRequirement() != null) { + prompt.append("요구사항: ").append(request.getRequirement()).append("\n"); } - if (conditions.getToneAndManner() != null) { - prompt.append("톤앤매너: ").append(conditions.getToneAndManner()).append("\n"); - } - if (conditions.getEmotionIntensity() != null) { - prompt.append("감정 강도: ").append(conditions.getEmotionIntensity()).append("\n"); + + if (request.getToneAndManner() != null) { + prompt.append("톤앤매너: ").append(request.getToneAndManner()).append("\n"); } return prompt.toString(); } - /** - * 더미 SNS 콘텐츠 생성 (개발용) - */ private String generateDummySnsContent(String title, Platform platform) { - switch (platform) { - case INSTAGRAM: - return String.format("🎉 %s\n\n맛있는 순간을 놓치지 마세요! 새로운 맛의 경험이 여러분을 기다리고 있어요. 따뜻한 분위기에서 즐기는 특별한 시간을 만들어보세요.\n\n📍 지금 바로 방문해보세요!", title); - case NAVER_BLOG: - return String.format("안녕하세요! 오늘은 %s에 대해 소개해드리려고 해요.\n\n정성스럽게 준비한 새로운 메뉴로 고객 여러분께 더 나은 경험을 선사하고 싶습니다. 많은 관심과 사랑 부탁드려요!", title); - default: - return String.format("%s - 새로운 경험을 만나보세요!", title); + String baseContent = "🌟 " + title + "를 소개합니다! 🌟\n\n" + + "저희 매장에서 특별한 경험을 만나보세요.\n" + + "고객 여러분의 소중한 시간을 더욱 특별하게 만들어드리겠습니다.\n\n"; + + if (platform == Platform.INSTAGRAM) { + return baseContent + "더 많은 정보는 프로필 링크에서 확인하세요! 📸"; + } else { + return baseContent + "자세한 내용은 저희 블로그를 방문해 주세요! ✨"; } } - /** - * 더미 해시태그 생성 (개발용) - */ - private List generateDummyHashtags(Platform platform) { - switch (platform) { - case INSTAGRAM: - return Arrays.asList("#맛집", "#신메뉴", "#인스타그램", "#데일리", "#추천", "#음식스타그램"); - case NAVER_BLOG: - return Arrays.asList("#맛집", "#리뷰", "#추천", "#신메뉴", "#블로그"); - default: - return Arrays.asList("#맛집", "#신메뉴", "#추천"); - } - } - - /** - * 폴백 콘텐츠 생성 (AI 서비스 실패 시) - */ private String generateFallbackContent(String title, Platform platform) { - return String.format("🎉 %s\n\n새로운 소식을 전해드립니다. 많은 관심 부탁드려요!", title); + return title + "에 대한 멋진 콘텐츠입니다. 많은 관심 부탁드립니다!"; + } + + private List generateDummyHashtags(Platform platform) { + if (platform == Platform.INSTAGRAM) { + return Arrays.asList("#맛집", "#데일리", "#소상공인", "#추천", "#인스타그램"); + } else { + return Arrays.asList("#맛집추천", "#블로그", "#리뷰", "#맛있는곳", "#소상공인응원"); + } + } + + private List generateFallbackHashtags() { + return Arrays.asList("#소상공인", "#마케팅", "#홍보"); } } \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java index a667545..7495966 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java @@ -1,7 +1,8 @@ // marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java package com.won.smarketing.content.infrastructure.external; -import com.won.smarketing.content.domain.model.CreationConditions; +import com.won.smarketing.content.domain.service.AiPosterGenerator; // 도메인 인터페이스 import +import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -19,23 +20,20 @@ import java.util.Map; public class ClaudeAiPosterGenerator implements AiPosterGenerator { /** - * 포스터 이미지 생성 - * Claude AI API를 호출하여 홍보 포스터를 생성합니다. + * 포스터 생성 * - * @param title 제목 - * @param category 카테고리 - * @param conditions 생성 조건 + * @param request 포스터 생성 요청 * @return 생성된 포스터 이미지 URL */ @Override - public String generatePoster(String title, String category, CreationConditions conditions) { + public String generatePoster(PosterContentCreateRequest request) { try { - // Claude AI API 호출 로직 (실제 구현에서는 HTTP 클라이언트를 사용) - String prompt = buildPosterPrompt(title, category, conditions); + // Claude AI API 호출 로직 + String prompt = buildPosterPrompt(request); // TODO: 실제 Claude AI API 호출 // 현재는 더미 데이터 반환 - return generateDummyPosterUrl(title); + return generateDummyPosterUrl(request.getTitle()); } catch (Exception e) { log.error("AI 포스터 생성 실패: {}", e.getMessage(), e); @@ -44,75 +42,45 @@ public class ClaudeAiPosterGenerator implements AiPosterGenerator { } /** - * 포스터 다양한 사이즈 생성 - * 원본 포스터를 기반으로 다양한 사이즈의 포스터를 생성합니다. + * 다양한 사이즈의 포스터 생성 * - * @param originalImage 원본 이미지 URL - * @return 사이즈별 이미지 URL 맵 + * @param baseImage 기본 이미지 + * @return 사이즈별 포스터 URL 맵 */ @Override - public Map generatePosterSizes(String originalImage) { - try { - // TODO: 실제 이미지 리사이징 API 호출 - // 현재는 더미 데이터 반환 - return generateDummyPosterSizes(originalImage); + public Map generatePosterSizes(String baseImage) { + Map sizes = new HashMap<>(); - } catch (Exception e) { - log.error("포스터 사이즈 생성 실패: {}", e.getMessage(), e); - return new HashMap<>(); - } + // 다양한 사이즈 생성 (더미 구현) + sizes.put("instagram_square", baseImage + "_1080x1080.jpg"); + sizes.put("instagram_story", baseImage + "_1080x1920.jpg"); + sizes.put("facebook_post", baseImage + "_1200x630.jpg"); + sizes.put("a4_poster", baseImage + "_2480x3508.jpg"); + + return sizes; } - /** - * AI 포스터 프롬프트 생성 - */ - private String buildPosterPrompt(String title, String category, CreationConditions conditions) { + private String buildPosterPrompt(PosterContentCreateRequest request) { StringBuilder prompt = new StringBuilder(); - prompt.append("다음 조건에 맞는 홍보 포스터를 생성해주세요:\n"); - prompt.append("제목: ").append(title).append("\n"); - prompt.append("카테고리: ").append(category).append("\n"); + prompt.append("포스터 제목: ").append(request.getTitle()).append("\n"); + prompt.append("카테고리: ").append(request.getCategory()).append("\n"); - if (conditions.getPhotoStyle() != null) { - prompt.append("사진 스타일: ").append(conditions.getPhotoStyle()).append("\n"); + if (request.getRequirement() != null) { + prompt.append("요구사항: ").append(request.getRequirement()).append("\n"); } - if (conditions.getRequirement() != null) { - prompt.append("요구사항: ").append(conditions.getRequirement()).append("\n"); - } - if (conditions.getToneAndManner() != null) { - prompt.append("톤앤매너: ").append(conditions.getToneAndManner()).append("\n"); + + if (request.getToneAndManner() != null) { + prompt.append("톤앤매너: ").append(request.getToneAndManner()).append("\n"); } return prompt.toString(); } - /** - * 더미 포스터 URL 생성 (개발용) - */ private String generateDummyPosterUrl(String title) { - return String.format("https://example.com/posters/%s-poster.jpg", - title.replaceAll("\\s+", "-").toLowerCase()); + return "https://dummy-ai-service.com/posters/" + title.hashCode() + ".jpg"; } - /** - * 더미 포스터 사이즈별 URL 생성 (개발용) - */ - private Map generateDummyPosterSizes(String originalImage) { - Map sizes = new HashMap<>(); - String baseUrl = originalImage.substring(0, originalImage.lastIndexOf(".")); - String extension = originalImage.substring(originalImage.lastIndexOf(".")); - - sizes.put("small", baseUrl + "-small" + extension); - sizes.put("medium", baseUrl + "-medium" + extension); - sizes.put("large", baseUrl + "-large" + extension); - sizes.put("xlarge", baseUrl + "-xlarge" + extension); - - return sizes; - } - - /** - * 폴백 포스터 URL 생성 (AI 서비스 실패 시) - */ private String generateFallbackPosterUrl() { - return "https://example.com/posters/default-poster.jpg"; + return "https://dummy-ai-service.com/posters/fallback.jpg"; } } \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/SpringDataContentRepository.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/SpringDataContentRepository.java deleted file mode 100644 index feba6b4..0000000 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/SpringDataContentRepository.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.won.smarketing.content.infrastructure.repository; - -import com.won.smarketing.content.infrastructure.entity.ContentJpaEntity; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; - -import java.util.List; - -/** - * Spring Data JPA 콘텐츠 Repository - * - * @author smarketing-team - * @version 1.0 - */ -@Repository -public interface SpringDataContentRepository extends JpaRepository { - - /** - * 필터 조건으로 콘텐츠를 조회합니다. - * - * @param contentType 콘텐츠 타입 - * @param platform 플랫폼 - * @param period 기간 - * @param sortBy 정렬 기준 - * @return 콘텐츠 목록 - */ - @Query("SELECT c FROM ContentJpaEntity c WHERE " + - "(:contentType IS NULL OR c.contentType = :contentType) AND " + - "(:platform IS NULL OR c.platform = :platform) AND " + - "(:period IS NULL OR DATE(c.createdAt) >= CURRENT_DATE - INTERVAL :period DAY) " + - "ORDER BY " + - "CASE WHEN :sortBy = 'latest' THEN c.createdAt END DESC, " + - "CASE WHEN :sortBy = 'oldest' THEN c.createdAt END ASC") - List findByFilters(@Param("contentType") String contentType, - @Param("platform") String platform, - @Param("period") String period, - @Param("sortBy") String sortBy); - - /** - * 진행 중인 콘텐츠를 조회합니다. - * - * @param period 기간 - * @return 진행 중인 콘텐츠 목록 - */ - @Query("SELECT c FROM ContentJpaEntity c " + - "WHERE c.status = 'PUBLISHED' AND " + - "(:period IS NULL OR DATE(c.createdAt) >= CURRENT_DATE - INTERVAL :period DAY)") - List findOngoingContents(@Param("period") String period); -} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/resources/application.yml b/smarketing-java/marketing-content/src/main/resources/application.yml index 0e9e68c..10dc73d 100644 --- a/smarketing-java/marketing-content/src/main/resources/application.yml +++ b/smarketing-java/marketing-content/src/main/resources/application.yml @@ -17,21 +17,17 @@ 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} + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} -external: - claude-ai: - api-key: ${CLAUDE_AI_API_KEY:your-claude-api-key} - base-url: ${CLAUDE_AI_BASE_URL:https://api.anthropic.com} - model: ${CLAUDE_AI_MODEL:claude-3-sonnet-20240229} - max-tokens: ${CLAUDE_AI_MAX_TOKENS:4000} jwt: secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789} access-token-validity: ${JWT_ACCESS_VALIDITY:3600000} refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000} + logging: level: - com.won.smarketing.content: ${LOG_LEVEL:DEBUG} + com.won.smarketing: ${LOG_LEVEL:DEBUG} From 66a4faf3ac4b2f3cb8d7e2fdbf724d8481b3df58 Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 12 Jun 2025 11:03:17 +0900 Subject: [PATCH 26/34] fix: ai-recommend fix --- .../AIRecommendServiceApplication.java | 10 +- .../application/service/AiApiService.java | 21 -- .../service/MarketingTipService.java | 72 ++----- .../service/WeatherDataService.java | 32 --- .../usecase/MarketingTipUseCase.java | 21 +- .../recommend/config/CacheConfig.java | 2 +- .../recommend/config/WebClientConfig.java | 8 +- .../domain/model/BusinessInsight.java | 51 ----- .../recommend/domain/model/MarketingTip.java | 15 +- .../recommend/domain/model/StoreData.java | 2 +- .../recommend/domain/model/TipId.java | 4 +- .../recommend/domain/model/WeatherData.java | 19 -- .../repository/BusinessInsightRepository.java | 15 -- .../repository/MarketingTipRepository.java | 2 +- .../domain/service/AiTipGenerator.java | 12 +- .../domain/service/StoreDataProvider.java | 11 +- .../domain/service/WeatherDataProvider.java | 18 -- .../external/AiApiServiceImpl.java | 137 ------------- .../external/ClaudeAiTipGenerator.java | 190 ------------------ .../external/PythonAiTipGenerator.java | 73 +++---- .../external/StoreApiDataProvider.java | 4 +- .../external/WeatherApiDataProvider.java | 68 ------- .../persistence/MarketingTipEntity.java | 51 +++-- .../MarketingTipJpaRepository.java | 12 +- ...y.java => MarketingTipRepositoryImpl.java} | 11 +- .../controller/HealthController.java | 34 ++++ .../controller/RecommendationController.java | 32 +-- .../presentation/dto/AIServiceRequest.java | 24 --- .../dto/DetailedMarketingTipResponse.java | 34 ---- .../presentation/dto/ErrorResponseDto.java | 32 --- .../dto/MarketingTipGenerationRequest.java | 29 --- .../presentation/dto/MarketingTipRequest.java | 2 +- .../dto/MarketingTipResponse.java | 20 +- .../presentation/dto/StoreInfoDto.java | 26 --- .../presentation/dto/WeatherInfoDto.java | 26 --- .../src/main/resources/application.yml | 30 +-- 36 files changed, 208 insertions(+), 942 deletions(-) delete mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/AiApiService.java delete mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/WeatherDataService.java delete mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/BusinessInsight.java delete mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/WeatherData.java delete mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/BusinessInsightRepository.java delete mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/WeatherDataProvider.java delete mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/AiApiServiceImpl.java delete mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/ClaudeAiTipGenerator.java delete mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/WeatherApiDataProvider.java rename smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/{JpaMarketingTipRepository.java => MarketingTipRepositoryImpl.java} (69%) create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/HealthController.java delete mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/AIServiceRequest.java delete mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/DetailedMarketingTipResponse.java delete mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/ErrorResponseDto.java delete mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipGenerationRequest.java delete mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/StoreInfoDto.java delete mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/WeatherInfoDto.java diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/AIRecommendServiceApplication.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/AIRecommendServiceApplication.java index 2c12c85..c331ea3 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/AIRecommendServiceApplication.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/AIRecommendServiceApplication.java @@ -4,12 +4,14 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cache.annotation.EnableCaching; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; -/** - * AI 추천 서비스 메인 애플리케이션 - */ -@SpringBootApplication +@SpringBootApplication(scanBasePackages = { + "com.won.smarketing.recommend", + "com.won.smarketing.common" +}) @EnableJpaAuditing +@EnableJpaRepositories(basePackages = "com.won.smarketing.recommend.infrastructure.persistence") @EnableCaching public class AIRecommendServiceApplication { public static void main(String[] args) { diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/AiApiService.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/AiApiService.java deleted file mode 100644 index 30338a0..0000000 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/AiApiService.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.won.smarketing.recommend.domain.service; - -import com.won.smarketing.recommend.domain.model.StoreData; -import com.won.smarketing.recommend.domain.model.WeatherData; - -/** - * Python AI 서비스 인터페이스 - * AI 처리를 Python 서비스로 위임하는 도메인 서비스 - */ -public interface AiApiService { - - /** - * Python AI 서비스를 통한 마케팅 팁 생성 - * - * @param storeData 매장 정보 - * @param weatherData 날씨 정보 - * @param additionalRequirement 추가 요청사항 - * @return AI가 생성한 마케팅 팁 (한 줄) - */ - String generateMarketingTip(StoreData storeData, WeatherData weatherData, String additionalRequirement); -} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java index 67193b9..f54dc92 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java @@ -5,11 +5,8 @@ import com.won.smarketing.common.exception.ErrorCode; import com.won.smarketing.recommend.application.usecase.MarketingTipUseCase; import com.won.smarketing.recommend.domain.model.MarketingTip; import com.won.smarketing.recommend.domain.model.StoreData; -import com.won.smarketing.recommend.domain.model.TipId; -import com.won.smarketing.recommend.domain.model.WeatherData; import com.won.smarketing.recommend.domain.repository.MarketingTipRepository; import com.won.smarketing.recommend.domain.service.StoreDataProvider; -import com.won.smarketing.recommend.domain.service.WeatherDataProvider; import com.won.smarketing.recommend.domain.service.AiTipGenerator; import com.won.smarketing.recommend.presentation.dto.MarketingTipRequest; import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse; @@ -32,42 +29,36 @@ public class MarketingTipService implements MarketingTipUseCase { private final MarketingTipRepository marketingTipRepository; private final StoreDataProvider storeDataProvider; - private final WeatherDataProvider weatherDataProvider; private final AiTipGenerator aiTipGenerator; @Override public MarketingTipResponse generateMarketingTips(MarketingTipRequest request) { log.info("마케팅 팁 생성 시작: storeId={}", request.getStoreId()); - + try { // 1. 매장 정보 조회 StoreData storeData = storeDataProvider.getStoreData(request.getStoreId()); log.debug("매장 정보 조회 완료: {}", storeData.getStoreName()); - - // 2. 날씨 정보 조회 - WeatherData weatherData = weatherDataProvider.getCurrentWeather(storeData.getLocation()); - log.debug("날씨 정보 조회 완료: 온도={}, 상태={}", weatherData.getTemperature(), weatherData.getCondition()); - - // 3. AI 팁 생성 - String aiGeneratedTip = aiTipGenerator.generateTip(storeData, weatherData, request.getAdditionalRequirement()); + + // 2. Python AI 서비스로 팁 생성 (매장 정보 + 추가 요청사항 전달) + String aiGeneratedTip = aiTipGenerator.generateTip(storeData, request.getAdditionalRequirement()); log.debug("AI 팁 생성 완료: {}", aiGeneratedTip.substring(0, Math.min(50, aiGeneratedTip.length()))); - - // 4. 도메인 객체 생성 및 저장 + + // 3. 도메인 객체 생성 및 저장 MarketingTip marketingTip = MarketingTip.builder() .storeId(request.getStoreId()) .tipContent(aiGeneratedTip) - .weatherData(weatherData) .storeData(storeData) .build(); - + MarketingTip savedTip = marketingTipRepository.save(marketingTip); log.info("마케팅 팁 저장 완료: tipId={}", savedTip.getId().getValue()); - + return convertToResponse(savedTip); - + } catch (Exception e) { log.error("마케팅 팁 생성 중 오류: storeId={}", request.getStoreId(), e); - throw new BusinessException(ErrorCode.AI_TIP_GENERATION_FAILED); + throw new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR); } } @@ -76,9 +67,9 @@ public class MarketingTipService implements MarketingTipUseCase { @Cacheable(value = "marketingTipHistory", key = "#storeId + '_' + #pageable.pageNumber + '_' + #pageable.pageSize") public Page getMarketingTipHistory(Long storeId, Pageable pageable) { log.info("마케팅 팁 이력 조회: storeId={}", storeId); - + Page tips = marketingTipRepository.findByStoreIdOrderByCreatedAtDesc(storeId, pageable); - + return tips.map(this::convertToResponse); } @@ -86,10 +77,10 @@ public class MarketingTipService implements MarketingTipUseCase { @Transactional(readOnly = true) public MarketingTipResponse getMarketingTip(Long tipId) { log.info("마케팅 팁 상세 조회: tipId={}", tipId); - + MarketingTip marketingTip = marketingTipRepository.findById(tipId) - .orElseThrow(() -> new BusinessException(ErrorCode.MARKETING_TIP_NOT_FOUND)); - + .orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR)); + return convertToResponse(marketingTip); } @@ -98,32 +89,13 @@ public class MarketingTipService implements MarketingTipUseCase { .tipId(marketingTip.getId().getValue()) .storeId(marketingTip.getStoreId()) .storeName(marketingTip.getStoreData().getStoreName()) - .businessType(marketingTip.getStoreData().getBusinessType()) - .storeLocation(marketingTip.getStoreData().getLocation()) + .tipContent(marketingTip.getTipContent()) + .storeInfo(MarketingTipResponse.StoreInfo.builder() + .storeName(marketingTip.getStoreData().getStoreName()) + .businessType(marketingTip.getStoreData().getBusinessType()) + .location(marketingTip.getStoreData().getLocation()) + .build()) .createdAt(marketingTip.getCreatedAt()) .build(); } - - public MarketingTip toDomain() { - WeatherData weatherData = WeatherData.builder() - .temperature(this.weatherTemperature) - .condition(this.weatherCondition) - .humidity(this.weatherHumidity) - .build(); - - StoreData storeData = StoreData.builder() - .storeName(this.storeName) - .businessType(this.businessType) - .location(this.storeLocation) - .build(); - - return MarketingTip.builder() - .id(this.id != null ? TipId.of(this.id) : null) - .storeId(this.storeId) - .tipContent(this.tipContent) - .weatherData(weatherData) - .storeData(storeData) - .createdAt(this.createdAt) - .build(); - } -} \ No newline at end of file +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/WeatherDataService.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/WeatherDataService.java deleted file mode 100644 index 164a1a9..0000000 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/WeatherDataService.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.won.smarketing.recommend.application.service; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.cache.annotation.Cacheable; -import org.springframework.stereotype.Service; - -/** - * 날씨 데이터 서비스 (Mock) - */ -@Slf4j -@Service -@RequiredArgsConstructor -public class WeatherDataService { - - @Cacheable(value = "weatherData", key = "#location") - public com.won.smarketing.recommend.service.MarketingTipService.WeatherInfo getCurrentWeather(String location) { - log.debug("날씨 정보 조회: location={}", location); - - // Mock 데이터 반환 - double temperature = 20.0 + (Math.random() * 15); // 20-35도 - String[] conditions = {"맑음", "흐림", "비", "눈", "안개"}; - String condition = conditions[(int) (Math.random() * conditions.length)]; - double humidity = 50.0 + (Math.random() * 30); // 50-80% - - return com.won.smarketing.recommend.service.MarketingTipService.WeatherInfo.builder() - .temperature(Math.round(temperature * 10) / 10.0) - .condition(condition) - .humidity(Math.round(humidity * 10) / 10.0) - .build(); - } -} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/usecase/MarketingTipUseCase.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/usecase/MarketingTipUseCase.java index b1a8329..48bd991 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/usecase/MarketingTipUseCase.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/usecase/MarketingTipUseCase.java @@ -6,33 +6,22 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; /** - * 마케팅 팁 생성 유즈케이스 인터페이스 - * 비즈니스 요구사항을 정의하는 애플리케이션 계층의 인터페이스 + * 마케팅 팁 유즈케이스 인터페이스 */ public interface MarketingTipUseCase { - + /** * AI 마케팅 팁 생성 - * - * @param request 마케팅 팁 생성 요청 - * @return 생성된 마케팅 팁 정보 */ MarketingTipResponse generateMarketingTips(MarketingTipRequest request); - + /** * 마케팅 팁 이력 조회 - * - * @param storeId 매장 ID - * @param pageable 페이징 정보 - * @return 마케팅 팁 이력 페이지 */ Page getMarketingTipHistory(Long storeId, Pageable pageable); - + /** * 마케팅 팁 상세 조회 - * - * @param tipId 팁 ID - * @return 마케팅 팁 상세 정보 */ MarketingTipResponse getMarketingTip(Long tipId); -} \ No newline at end of file +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/CacheConfig.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/CacheConfig.java index 9aec563..8dec201 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/CacheConfig.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/CacheConfig.java @@ -10,4 +10,4 @@ import org.springframework.context.annotation.Configuration; @EnableCaching public class CacheConfig { // 기본 Simple 캐시 사용 -} \ No newline at end of file +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/WebClientConfig.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/WebClientConfig.java index 47ed442..53578a1 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/WebClientConfig.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/WebClientConfig.java @@ -4,15 +4,13 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.reactive.function.client.WebClient; import io.netty.channel.ChannelOption; -import io.netty.handler.timeout.ConnectTimeoutHandler; import org.springframework.http.client.reactive.ReactorClientHttpConnector; import reactor.netty.http.client.HttpClient; import java.time.Duration; -import java.util.concurrent.TimeUnit; /** - * WebClient 설정 + * WebClient 설정 (간소화된 버전) */ @Configuration public class WebClientConfig { @@ -21,9 +19,7 @@ public class WebClientConfig { public WebClient webClient() { HttpClient httpClient = HttpClient.create() .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000) - .responseTimeout(Duration.ofMillis(5000)) - .doOnConnected(conn -> conn - .addHandlerLast(new ConnectTimeoutHandler(5, TimeUnit.SECONDS))); + .responseTimeout(Duration.ofMillis(5000)); return WebClient.builder() .clientConnector(new ReactorClientHttpConnector(httpClient)) diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/BusinessInsight.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/BusinessInsight.java deleted file mode 100644 index 5022134..0000000 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/BusinessInsight.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.won.smarketing.recommend.domain.model; - -import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import org.springframework.data.annotation.CreatedDate; -import org.springframework.data.jpa.domain.support.AuditingEntityListener; - -import java.time.LocalDateTime; - -/** - * 비즈니스 인사이트 엔티티 - */ -@Entity -@Table(name = "business_insights") -@Getter -@NoArgsConstructor -@AllArgsConstructor -@Builder -@EntityListeners(AuditingEntityListener.class) -public class BusinessInsight { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "insight_id") - private Long id; - - @Column(name = "store_id", nullable = false) - private Long storeId; - - @Column(name = "insight_type", nullable = false, length = 50) - private String insightType; - - @Column(name = "title", nullable = false, length = 200) - private String title; - - @Column(name = "description", columnDefinition = "TEXT") - private String description; - - @Column(name = "metric_value") - private Double metricValue; - - @Column(name = "recommendation", columnDefinition = "TEXT") - private String recommendation; - - @CreatedDate - @Column(name = "created_at") - private LocalDateTime createdAt; -} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MarketingTip.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MarketingTip.java index 48bc27b..302a79f 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MarketingTip.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MarketingTip.java @@ -1,8 +1,5 @@ package com.won.smarketing.recommend.domain.model; -import com.won.smarketing.recommend.domain.model.StoreData; -import com.won.smarketing.recommend.domain.model.TipId; -import com.won.smarketing.recommend.domain.model.WeatherData; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -11,28 +8,26 @@ import lombok.NoArgsConstructor; import java.time.LocalDateTime; /** - * 마케팅 팁 도메인 모델 + * 마케팅 팁 도메인 모델 (날씨 정보 제거) */ @Getter @Builder @NoArgsConstructor @AllArgsConstructor public class MarketingTip { - + private TipId id; private Long storeId; private String tipContent; - private WeatherData weatherData; private StoreData storeData; private LocalDateTime createdAt; - - public static MarketingTip create(Long storeId, String tipContent, WeatherData weatherData, StoreData storeData) { + + public static MarketingTip create(Long storeId, String tipContent, StoreData storeData) { return MarketingTip.builder() .storeId(storeId) .tipContent(tipContent) - .weatherData(weatherData) .storeData(storeData) .createdAt(LocalDateTime.now()) .build(); } -} \ No newline at end of file +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreData.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreData.java index 2afae1b..87c395d 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreData.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreData.java @@ -16,4 +16,4 @@ public class StoreData { private String storeName; private String businessType; private String location; -} \ No newline at end of file +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/TipId.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/TipId.java index 105b3af..47808cb 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/TipId.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/TipId.java @@ -14,8 +14,8 @@ import lombok.NoArgsConstructor; @AllArgsConstructor public class TipId { private Long value; - + public static TipId of(Long value) { return new TipId(value); } -} \ No newline at end of file +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/WeatherData.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/WeatherData.java deleted file mode 100644 index 90c6455..0000000 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/WeatherData.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.won.smarketing.recommend.domain.model; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -/** - * 날씨 데이터 값 객체 - */ -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class WeatherData { - private Double temperature; - private String condition; - private Double humidity; -} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/BusinessInsightRepository.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/BusinessInsightRepository.java deleted file mode 100644 index 9925144..0000000 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/BusinessInsightRepository.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.won.smarketing.recommend.domain.repository; - -import com.won.smarketing.recommend.domain.model.BusinessInsight; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import java.util.List; - -@Repository -public interface BusinessInsightRepository extends JpaRepository { - - List findByStoreIdOrderByCreatedAtDesc(Long storeId); - - List findByInsightTypeAndStoreId(String insightType, Long storeId); -} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/MarketingTipRepository.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/MarketingTipRepository.java index 140dff3..ce0be77 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/MarketingTipRepository.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/MarketingTipRepository.java @@ -7,7 +7,7 @@ import org.springframework.data.domain.Pageable; import java.util.Optional; /** - * 마케팅 팁 레포지토리 인터페이스 + * 마케팅 팁 레포지토리 인터페이스 (순수한 도메인 인터페이스) */ public interface MarketingTipRepository { diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/AiTipGenerator.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/AiTipGenerator.java index 8680caa..19547c0 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/AiTipGenerator.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/AiTipGenerator.java @@ -1,20 +1,18 @@ package com.won.smarketing.recommend.domain.service; import com.won.smarketing.recommend.domain.model.StoreData; -import com.won.smarketing.recommend.domain.model.WeatherData; /** - * AI 팁 생성 도메인 서비스 인터페이스 - * AI를 활용한 마케팅 팁 생성 기능 정의 + * AI 팁 생성 도메인 서비스 인터페이스 (단순화) */ public interface AiTipGenerator { /** - * 매장 정보와 날씨 정보를 바탕으로 마케팅 팁 생성 + * Python AI 서비스를 통한 마케팅 팁 생성 * - * @param storeData 매장 데이터 - * @param weatherData 날씨 데이터 + * @param storeData 매장 정보 + * @param additionalRequirement 추가 요청사항 * @return AI가 생성한 마케팅 팁 */ - String generateTip(StoreData storeData, WeatherData weatherData); + String generateTip(StoreData storeData, String additionalRequirement); } diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/StoreDataProvider.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/StoreDataProvider.java index aa526b1..1cea568 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/StoreDataProvider.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/StoreDataProvider.java @@ -4,15 +4,8 @@ import com.won.smarketing.recommend.domain.model.StoreData; /** * 매장 데이터 제공 도메인 서비스 인터페이스 - * 외부 매장 서비스로부터 매장 정보 조회 기능 정의 */ public interface StoreDataProvider { - - /** - * 매장 정보 조회 - * - * @param storeId 매장 ID - * @return 매장 데이터 - */ + StoreData getStoreData(Long storeId); -} \ No newline at end of file +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/WeatherDataProvider.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/WeatherDataProvider.java deleted file mode 100644 index 6f31ae0..0000000 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/WeatherDataProvider.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.won.smarketing.recommend.domain.service; - -import com.won.smarketing.recommend.domain.model.WeatherData; - -/** - * 날씨 데이터 제공 도메인 서비스 인터페이스 - * 외부 날씨 API로부터 날씨 정보 조회 기능 정의 - */ -public interface WeatherDataProvider { - - /** - * 특정 위치의 현재 날씨 정보 조회 - * - * @param location 위치 (주소) - * @return 날씨 데이터 - */ - WeatherData getCurrentWeather(String location); -} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/AiApiServiceImpl.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/AiApiServiceImpl.java deleted file mode 100644 index b5fbed3..0000000 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/AiApiServiceImpl.java +++ /dev/null @@ -1,137 +0,0 @@ -//import com.won.smarketing.recommend.domain.model.StoreData; -//import com.won.smarketing.recommend.domain.model.WeatherData; -//import com.won.smarketing.recommend.domain.service.AiTipGenerator; -//import lombok.RequiredArgsConstructor; -//import lombok.extern.slf4j.Slf4j; -//import org.springframework.beans.factory.annotation.Value; -//import org.springframework.stereotype.Service; -//import org.springframework.web.reactive.function.client.WebClient; -// -//import java.time.Duration; -//import java.util.Map; -// -///** -// * Python AI 팁 생성 구현체 -// */ -//@Slf4j -//@Service -//@RequiredArgsConstructor -//public class PythonAiTipGenerator implements AiTipGenerator { -// -// private final WebClient webClient; -// -// @Value("${external.python-ai-service.base-url}") -// private String pythonAiServiceBaseUrl; -// -// @Value("${external.python-ai-service.api-key}") -// private String pythonAiServiceApiKey; -// -// @Value("${external.python-ai-service.timeout}") -// private int timeout; -// -// @Override -// public String generateTip(StoreData storeData, WeatherData weatherData, String additionalRequirement) { -// try { -// log.debug("Python AI 서비스 호출: store={}, weather={}도", -// storeData.getStoreName(), weatherData.getTemperature()); -// -// // Python AI 서비스 사용 가능 여부 확인 -// if (isPythonServiceAvailable()) { -// return callPythonAiService(storeData, weatherData, additionalRequirement); -// } else { -// log.warn("Python AI 서비스 사용 불가, Fallback 처리"); -// return createFallbackTip(storeData, weatherData, additionalRequirement); -// } -// -// } catch (Exception e) { -// log.error("Python AI 서비스 호출 실패, Fallback 처리: {}", e.getMessage()); -// return createFallbackTip(storeData, weatherData, additionalRequirement); -// } -// } -// -// private boolean isPythonServiceAvailable() { -// return !pythonAiServiceApiKey.equals("dummy-key"); -// } -// -// private String callPythonAiService(StoreData storeData, WeatherData weatherData, String additionalRequirement) { -// try { -// Map requestData = Map.of( -// "store_name", storeData.getStoreName(), -// "business_type", storeData.getBusinessType(), -// "location", storeData.getLocation(), -// "temperature", weatherData.getTemperature(), -// "weather_condition", weatherData.getCondition(), -// "humidity", weatherData.getHumidity(), -// "additional_requirement", additionalRequirement != null ? additionalRequirement : "" -// ); -// -// PythonAiResponse response = webClient -// .post() -// .uri(pythonAiServiceBaseUrl + "/api/v1/generate-marketing-tip") -// .header("Authorization", "Bearer " + pythonAiServiceApiKey) -// .header("Content-Type", "application/json") -// .bodyValue(requestData) -// .retrieve() -// .bodyToMono(PythonAiResponse.class) -// .timeout(Duration.ofMillis(timeout)) -// .block(); -// -// if (response != null && response.getTip() != null && !response.getTip().trim().isEmpty()) { -// return response.getTip(); -// } -// } catch (Exception e) { -// log.error("Python AI 서비스 실제 호출 실패: {}", e.getMessage()); -// } -// -// return createFallbackTip(storeData, weatherData, additionalRequirement); -// } -// -// private String createFallbackTip(StoreData storeData, WeatherData weatherData, String additionalRequirement) { -// String businessType = storeData.getBusinessType(); -// double temperature = weatherData.getTemperature(); -// String condition = weatherData.getCondition(); -// String storeName = storeData.getStoreName(); -// -// // 추가 요청사항이 있는 경우 우선 반영 -// if (additionalRequirement != null && !additionalRequirement.trim().isEmpty()) { -// return String.format("%s에서 %s를 고려한 특별한 서비스로 고객들을 맞이해보세요!", -// storeName, additionalRequirement); -// } -// -// // 날씨와 업종 기반 규칙 -// if (temperature > 25) { -// if (businessType.contains("카페")) { -// return String.format("더운 날씨(%.1f도)에는 시원한 아이스 음료와 디저트로 고객들을 시원하게 만족시켜보세요!", temperature); -// } else { -// return "더운 여름날, 시원한 음료나 냉면으로 고객들에게 청량감을 선사해보세요!"; -// } -// } else if (temperature < 10) { -// if (businessType.contains("카페")) { -// return String.format("추운 날씨(%.1f도)에는 따뜻한 음료와 베이커리로 고객들에게 따뜻함을 전해보세요!", temperature); -// } else { -// return "추운 겨울날, 따뜻한 국물 요리로 고객들의 몸과 마음을 따뜻하게 해보세요!"; -// } -// } -// -// if (condition.contains("비")) { -// return "비 오는 날에는 따뜻한 음료와 분위기로 고객들의 마음을 따뜻하게 해보세요!"; -// } -// -// // 기본 팁 -// return String.format("%s에서 오늘(%.1f도, %s) 같은 날씨에 어울리는 특별한 서비스로 고객들을 맞이해보세요!", -// storeName, temperature, condition); -// } -// -// private static class PythonAiResponse { -// private String tip; -// private String status; -// private String message; -// -// public String getTip() { return tip; } -// public void setTip(String tip) { this.tip = tip; } -// public String getStatus() { return status; } -// public void setStatus(String status) { this.status = status; } -// public String getMessage() { return message; } -// public void setMessage(String message) { this.message = message; } -// } -//} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/ClaudeAiTipGenerator.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/ClaudeAiTipGenerator.java deleted file mode 100644 index 827ef54..0000000 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/ClaudeAiTipGenerator.java +++ /dev/null @@ -1,190 +0,0 @@ -package com.won.smarketing.recommend.infrastructure.external; - -import com.won.smarketing.common.exception.BusinessException; -import com.won.smarketing.common.exception.ErrorCode; -import com.won.smarketing.recommend.domain.model.StoreData; -import com.won.smarketing.recommend.domain.model.WeatherData; -import com.won.smarketing.recommend.domain.service.AiTipGenerator; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Service; -import org.springframework.web.reactive.function.client.WebClient; -import org.springframework.web.reactive.function.client.WebClientResponseException; -import reactor.core.publisher.Mono; - -import java.time.Duration; -import java.util.Map; - -/** - * Claude AI 팁 생성기 구현체 - * Claude AI API를 통해 마케팅 팁 생성 - */ -@Slf4j -@Service -@RequiredArgsConstructor -public class ClaudeAiTipGenerator implements AiTipGenerator { - - private final WebClient webClient; - - @Value("${external.claude-ai.api-key}") - private String claudeApiKey; - - @Value("${external.claude-ai.base-url}") - private String claudeApiBaseUrl; - - @Value("${external.claude-ai.model}") - private String claudeModel; - - @Value("${external.claude-ai.max-tokens}") - private Integer maxTokens; - - /** - * 매장 정보와 날씨 정보를 바탕으로 마케팅 팁 생성 - * - * @param storeData 매장 데이터 - * @param weatherData 날씨 데이터 - * @return AI가 생성한 마케팅 팁 - */ - @Override - public String generateTip(StoreData storeData, WeatherData weatherData) { - try { - log.debug("AI 마케팅 팁 생성 시작: store={}, weather={}도", - storeData.getStoreName(), weatherData.getTemperature()); - - String prompt = buildPrompt(storeData, weatherData); - - Map requestBody = Map.of( - "model", claudeModel, - "max_tokens", maxTokens, - "messages", new Object[]{ - Map.of( - "role", "user", - "content", prompt - ) - } - ); - - ClaudeApiResponse response = webClient - .post() - .uri(claudeApiBaseUrl + "/v1/messages") - .header(HttpHeaders.AUTHORIZATION, "Bearer " + claudeApiKey) - .header("anthropic-version", "2023-06-01") - .contentType(MediaType.APPLICATION_JSON) - .bodyValue(requestBody) - .retrieve() - .bodyToMono(ClaudeApiResponse.class) - .timeout(Duration.ofSeconds(30)) - .block(); - - if (response == null || response.getContent() == null || response.getContent().length == 0) { - throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE); - } - - String generatedTip = response.getContent()[0].getText(); - - // 100자 제한 적용 - if (generatedTip.length() > 100) { - generatedTip = generatedTip.substring(0, 97) + "..."; - } - - log.debug("AI 마케팅 팁 생성 완료: length={}", generatedTip.length()); - return generatedTip; - - } catch (WebClientResponseException e) { - log.error("Claude AI API 호출 실패: status={}", e.getStatusCode(), e); - return generateFallbackTip(storeData, weatherData); - } catch (Exception e) { - log.error("AI 마케팅 팁 생성 중 오류 발생", e); - return generateFallbackTip(storeData, weatherData); - } - } - - /** - * AI 프롬프트 구성 - * - * @param storeData 매장 데이터 - * @param weatherData 날씨 데이터 - * @return 프롬프트 문자열 - */ - private String buildPrompt(StoreData storeData, WeatherData weatherData) { - return String.format( - "다음 매장을 위한 오늘의 마케팅 팁을 100자 이내로 작성해주세요.\n\n" + - "매장 정보:\n" + - "- 매장명: %s\n" + - "- 업종: %s\n" + - "- 위치: %s\n\n" + - "오늘 날씨:\n" + - "- 온도: %.1f도\n" + - "- 날씨: %s\n" + - "- 습도: %.1f%%\n\n" + - "날씨와 매장 특성을 고려한 실용적이고 구체적인 마케팅 팁을 제안해주세요. " + - "반드시 100자 이내로 작성하고, 친근하고 실행 가능한 조언을 해주세요.", - storeData.getStoreName(), - storeData.getBusinessType(), - storeData.getLocation(), - weatherData.getTemperature(), - weatherData.getCondition(), - weatherData.getHumidity() - ); - } - - /** - * AI API 실패 시 대체 팁 생성 - * - * @param storeData 매장 데이터 - * @param weatherData 날씨 데이터 - * @return 대체 마케팅 팁 - */ - private String generateFallbackTip(StoreData storeData, WeatherData weatherData) { - StringBuilder tip = new StringBuilder(); - - // 날씨 기반 기본 팁 - if (weatherData.getTemperature() >= 25) { - tip.append("더운 날씨에는 시원한 음료나 디저트를 홍보해보세요! "); - } else if (weatherData.getTemperature() <= 10) { - tip.append("추운 날씨에는 따뜻한 메뉴를 강조해보세요! "); - } else { - tip.append("좋은 날씨를 활용한 야외석 이용을 추천해보세요! "); - } - - // 업종별 기본 팁 - String businessCategory = storeData.getBusinessType(); - switch (businessCategory) { - case "카페": - tip.append("인스타그램용 예쁜 음료 사진을 올려보세요."); - break; - case "음식점": - tip.append("시그니처 메뉴의 맛있는 사진을 SNS에 공유해보세요."); - break; - default: - tip.append("오늘의 특별 메뉴를 SNS에 홍보해보세요."); - break; - } - - String fallbackTip = tip.toString(); - return fallbackTip.length() > 100 ? fallbackTip.substring(0, 97) + "..." : fallbackTip; - } - - /** - * Claude API 응답 DTO - */ - private static class ClaudeApiResponse { - private Content[] content; - - public Content[] getContent() { return content; } - public void setContent(Content[] content) { this.content = content; } - - static class Content { - private String text; - private String type; - - public String getText() { return text; } - public void setText(String text) { this.text = text; } - public String getType() { return type; } - public void setType(String type) { this.type = type; } - } - } -} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonAiTipGenerator.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonAiTipGenerator.java index 44a5f06..4356fa9 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonAiTipGenerator.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonAiTipGenerator.java @@ -1,20 +1,21 @@ +package com.won.smarketing.recommend.infrastructure.external; + import com.won.smarketing.recommend.domain.model.StoreData; -import com.won.smarketing.recommend.domain.model.WeatherData; import com.won.smarketing.recommend.domain.service.AiTipGenerator; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; +import org.springframework.stereotype.Service; // 이 어노테이션이 누락되어 있었음 import org.springframework.web.reactive.function.client.WebClient; import java.time.Duration; import java.util.Map; /** - * Python AI 팁 생성 구현체 + * Python AI 팁 생성 구현체 (날씨 정보 제거) */ @Slf4j -@Service +@Service // 추가된 어노테이션 @RequiredArgsConstructor public class PythonAiTipGenerator implements AiTipGenerator { @@ -30,22 +31,21 @@ public class PythonAiTipGenerator implements AiTipGenerator { private int timeout; @Override - public String generateTip(StoreData storeData, WeatherData weatherData, String additionalRequirement) { + public String generateTip(StoreData storeData, String additionalRequirement) { try { - log.debug("Python AI 서비스 호출: store={}, weather={}도", - storeData.getStoreName(), weatherData.getTemperature()); + log.debug("Python AI 서비스 호출: store={}", storeData.getStoreName()); // Python AI 서비스 사용 가능 여부 확인 if (isPythonServiceAvailable()) { - return callPythonAiService(storeData, weatherData, additionalRequirement); + return callPythonAiService(storeData, additionalRequirement); } else { log.warn("Python AI 서비스 사용 불가, Fallback 처리"); - return createFallbackTip(storeData, weatherData, additionalRequirement); + return createFallbackTip(storeData, additionalRequirement); } } catch (Exception e) { log.error("Python AI 서비스 호출 실패, Fallback 처리: {}", e.getMessage()); - return createFallbackTip(storeData, weatherData, additionalRequirement); + return createFallbackTip(storeData, additionalRequirement); } } @@ -53,18 +53,18 @@ public class PythonAiTipGenerator implements AiTipGenerator { return !pythonAiServiceApiKey.equals("dummy-key"); } - private String callPythonAiService(StoreData storeData, WeatherData weatherData, String additionalRequirement) { + private String callPythonAiService(StoreData storeData, String additionalRequirement) { try { + // Python AI 서비스로 전송할 데이터 (날씨 정보 제거, 매장 정보만 전달) Map requestData = Map.of( "store_name", storeData.getStoreName(), "business_type", storeData.getBusinessType(), "location", storeData.getLocation(), - "temperature", weatherData.getTemperature(), - "weather_condition", weatherData.getCondition(), - "humidity", weatherData.getHumidity(), "additional_requirement", additionalRequirement != null ? additionalRequirement : "" ); + log.debug("Python AI 서비스 요청 데이터: {}", requestData); + PythonAiResponse response = webClient .post() .uri(pythonAiServiceBaseUrl + "/api/v1/generate-marketing-tip") @@ -77,49 +77,50 @@ public class PythonAiTipGenerator implements AiTipGenerator { .block(); if (response != null && response.getTip() != null && !response.getTip().trim().isEmpty()) { + log.debug("Python AI 서비스 응답 성공: tip length={}", response.getTip().length()); return response.getTip(); } } catch (Exception e) { log.error("Python AI 서비스 실제 호출 실패: {}", e.getMessage()); } - return createFallbackTip(storeData, weatherData, additionalRequirement); + return createFallbackTip(storeData, additionalRequirement); } - private String createFallbackTip(StoreData storeData, WeatherData weatherData, String additionalRequirement) { + /** + * 규칙 기반 Fallback 팁 생성 (날씨 정보 없이 매장 정보만 활용) + */ + private String createFallbackTip(StoreData storeData, String additionalRequirement) { String businessType = storeData.getBusinessType(); - double temperature = weatherData.getTemperature(); - String condition = weatherData.getCondition(); String storeName = storeData.getStoreName(); + String location = storeData.getLocation(); // 추가 요청사항이 있는 경우 우선 반영 if (additionalRequirement != null && !additionalRequirement.trim().isEmpty()) { - return String.format("%s에서 %s를 고려한 특별한 서비스로 고객들을 맞이해보세요!", + return String.format("%s에서 %s를 중심으로 한 특별한 서비스로 고객들을 맞이해보세요!", storeName, additionalRequirement); } - // 날씨와 업종 기반 규칙 - if (temperature > 25) { - if (businessType.contains("카페")) { - return String.format("더운 날씨(%.1f도)에는 시원한 아이스 음료와 디저트로 고객들을 시원하게 만족시켜보세요!", temperature); - } else { - return "더운 여름날, 시원한 음료나 냉면으로 고객들에게 청량감을 선사해보세요!"; - } - } else if (temperature < 10) { - if (businessType.contains("카페")) { - return String.format("추운 날씨(%.1f도)에는 따뜻한 음료와 베이커리로 고객들에게 따뜻함을 전해보세요!", temperature); - } else { - return "추운 겨울날, 따뜻한 국물 요리로 고객들의 몸과 마음을 따뜻하게 해보세요!"; - } + // 업종별 기본 팁 생성 + if (businessType.contains("카페")) { + return String.format("%s만의 시그니처 음료와 디저트로 고객들에게 특별한 경험을 선사해보세요!", storeName); + } else if (businessType.contains("음식점") || businessType.contains("식당")) { + return String.format("%s의 대표 메뉴를 활용한 특별한 이벤트로 고객들의 관심을 끌어보세요!", storeName); + } else if (businessType.contains("베이커리") || businessType.contains("빵집")) { + return String.format("%s의 갓 구운 빵과 함께하는 따뜻한 서비스로 고객들의 마음을 사로잡아보세요!", storeName); + } else if (businessType.contains("치킨") || businessType.contains("튀김")) { + return String.format("%s의 바삭하고 맛있는 메뉴로 고객들에게 만족스러운 식사를 제공해보세요!", storeName); } - if (condition.contains("비")) { - return "비 오는 날에는 따뜻한 음료와 분위기로 고객들의 마음을 따뜻하게 해보세요!"; + // 지역별 팁 + if (location.contains("강남") || location.contains("서초")) { + return String.format("%s에서 트렌디하고 세련된 서비스로 젊은 고객층을 공략해보세요!", storeName); + } else if (location.contains("홍대") || location.contains("신촌")) { + return String.format("%s에서 활기차고 개성 있는 이벤트로 대학생들의 관심을 끌어보세요!", storeName); } // 기본 팁 - return String.format("%s에서 오늘(%.1f도, %s) 같은 날씨에 어울리는 특별한 서비스로 고객들을 맞이해보세요!", - storeName, temperature, condition); + return String.format("%s만의 특별함을 살린 고객 맞춤 서비스로 단골 고객을 늘려보세요!", storeName); } private static class PythonAiResponse { diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/StoreApiDataProvider.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/StoreApiDataProvider.java index ac84ee4..c35a9e7 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/StoreApiDataProvider.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/StoreApiDataProvider.java @@ -8,7 +8,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.cache.annotation.Cacheable; -import org.springframework.stereotype.Service; +import org.springframework.stereotype.Service; // 이 어노테이션이 누락되어 있었음 import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClientResponseException; @@ -18,7 +18,7 @@ import java.time.Duration; * 매장 API 데이터 제공자 구현체 */ @Slf4j -@Service +@Service // 추가된 어노테이션 @RequiredArgsConstructor public class StoreApiDataProvider implements StoreDataProvider { diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/WeatherApiDataProvider.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/WeatherApiDataProvider.java deleted file mode 100644 index 8bf4d7c..0000000 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/WeatherApiDataProvider.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.won.smarketing.recommend.infrastructure.external; - -import com.won.smarketing.recommend.domain.model.WeatherData; -import com.won.smarketing.recommend.domain.service.WeatherDataProvider; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.cache.annotation.Cacheable; -import org.springframework.stereotype.Service; -import org.springframework.web.reactive.function.client.WebClient; - -import java.time.Duration; - -/** - * 날씨 API 데이터 제공자 구현체 - */ -@Slf4j -@Service -@RequiredArgsConstructor -public class WeatherApiDataProvider implements WeatherDataProvider { - - private final WebClient webClient; - - @Value("${external.weather-api.api-key}") - private String weatherApiKey; - - @Value("${external.weather-api.timeout}") - private int timeout; - - @Override - @Cacheable(value = "weatherData", key = "#location") - public WeatherData getCurrentWeather(String location) { - try { - log.debug("날씨 정보 조회: location={}", location); - - // 개발 환경에서는 Mock 데이터 반환 - if (weatherApiKey.equals("dummy-key")) { - return createMockWeatherData(location); - } - - // 실제 날씨 API 호출 (향후 구현) - return callWeatherApi(location); - - } catch (Exception e) { - log.warn("날씨 정보 조회 실패, Mock 데이터 사용: location={}", location, e); - return createMockWeatherData(location); - } - } - - private WeatherData callWeatherApi(String location) { - // 실제 OpenWeatherMap API 호출 로직 (향후 구현) - log.info("실제 날씨 API 호출: {}", location); - return createMockWeatherData(location); - } - - private WeatherData createMockWeatherData(String location) { - double temperature = 20.0 + (Math.random() * 15); // 20-35도 - String[] conditions = {"맑음", "흐림", "비", "눈", "안개"}; - String condition = conditions[(int) (Math.random() * conditions.length)]; - double humidity = 50.0 + (Math.random() * 30); // 50-80% - - return WeatherData.builder() - .temperature(Math.round(temperature * 10) / 10.0) - .condition(condition) - .humidity(Math.round(humidity * 10) / 10.0) - .build(); - } -} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipEntity.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipEntity.java index 7d47714..1bccd9f 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipEntity.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipEntity.java @@ -1,5 +1,8 @@ -package com.won.smarketing.recommend.entity; +package com.won.smarketing.recommend.infrastructure.persistence; +import com.won.smarketing.recommend.domain.model.MarketingTip; +import com.won.smarketing.recommend.domain.model.StoreData; +import com.won.smarketing.recommend.domain.model.TipId; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -11,7 +14,7 @@ import jakarta.persistence.*; import java.time.LocalDateTime; /** - * 마케팅 팁 JPA 엔티티 + * 마케팅 팁 JPA 엔티티 (날씨 정보 제거) */ @Entity @Table(name = "marketing_tips") @@ -29,20 +32,10 @@ public class MarketingTipEntity { @Column(name = "store_id", nullable = false) private Long storeId; - @Column(name = "tip_content", columnDefinition = "TEXT", nullable = false) + @Column(name = "tip_content", nullable = false, length = 2000) private String tipContent; - // WeatherData 임베디드 - @Column(name = "weather_temperature") - private Double weatherTemperature; - - @Column(name = "weather_condition", length = 100) - private String weatherCondition; - - @Column(name = "weather_humidity") - private Double weatherHumidity; - - // StoreData 임베디드 + // 매장 정보만 저장 @Column(name = "store_name", length = 200) private String storeName; @@ -55,4 +48,32 @@ public class MarketingTipEntity { @CreatedDate @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; -} \ No newline at end of file + + public static MarketingTipEntity fromDomain(MarketingTip marketingTip) { + return MarketingTipEntity.builder() + .id(marketingTip.getId() != null ? marketingTip.getId().getValue() : null) + .storeId(marketingTip.getStoreId()) + .tipContent(marketingTip.getTipContent()) + .storeName(marketingTip.getStoreData().getStoreName()) + .businessType(marketingTip.getStoreData().getBusinessType()) + .storeLocation(marketingTip.getStoreData().getLocation()) + .createdAt(marketingTip.getCreatedAt()) + .build(); + } + + public MarketingTip toDomain() { + StoreData storeData = StoreData.builder() + .storeName(this.storeName) + .businessType(this.businessType) + .location(this.storeLocation) + .build(); + + return MarketingTip.builder() + .id(this.id != null ? TipId.of(this.id) : null) + .storeId(this.storeId) + .tipContent(this.tipContent) + .storeData(storeData) + .createdAt(this.createdAt) + .build(); + } +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipJpaRepository.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipJpaRepository.java index ca1ec19..e2a9d0d 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipJpaRepository.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipJpaRepository.java @@ -1,14 +1,18 @@ +package com.won.smarketing.recommend.infrastructure.persistence; + import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; /** * 마케팅 팁 JPA 레포지토리 */ -public interface MarketingTipJpaRepository extends JpaRepository { - +@Repository +public interface MarketingTipJpaRepository extends JpaRepository { + @Query("SELECT m FROM MarketingTipEntity m WHERE m.storeId = :storeId ORDER BY m.createdAt DESC") - Page findByStoreIdOrderByCreatedAtDesc(@Param("storeId") Long storeId, Pageable pageable); -} \ No newline at end of file + Page findByStoreIdOrderByCreatedAtDesc(@Param("storeId") Long storeId, Pageable pageable); +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/JpaMarketingTipRepository.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipRepositoryImpl.java similarity index 69% rename from smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/JpaMarketingTipRepository.java rename to smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipRepositoryImpl.java index 45d7218..6b8198f 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/JpaMarketingTipRepository.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipRepositoryImpl.java @@ -1,6 +1,7 @@ +package com.won.smarketing.recommend.infrastructure.persistence; + import com.won.smarketing.recommend.domain.model.MarketingTip; import com.won.smarketing.recommend.domain.repository.MarketingTipRepository; -import com.won.smarketing.recommend.infrastructure.persistence.MarketingTipJpaRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -9,18 +10,18 @@ import org.springframework.stereotype.Repository; import java.util.Optional; /** - * JPA 마케팅 팁 레포지토리 구현체 + * 마케팅 팁 레포지토리 구현체 */ @Repository @RequiredArgsConstructor -public class JpaMarketingTipRepository implements MarketingTipRepository { +public class MarketingTipRepositoryImpl implements MarketingTipRepository { private final MarketingTipJpaRepository jpaRepository; @Override public MarketingTip save(MarketingTip marketingTip) { - com.won.smarketing.recommend.entity.MarketingTipEntity entity = MarketingTipEntity.fromDomain(marketingTip); - com.won.smarketing.recommend.entity.MarketingTipEntity savedEntity = jpaRepository.save(entity); + MarketingTipEntity entity = MarketingTipEntity.fromDomain(marketingTip); + MarketingTipEntity savedEntity = jpaRepository.save(entity); return savedEntity.toDomain(); } diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/HealthController.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/HealthController.java new file mode 100644 index 0000000..ad30482 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/HealthController.java @@ -0,0 +1,34 @@ +//package com.won.smarketing.recommend.presentation.controller; +// +//import org.springframework.web.bind.annotation.GetMapping; +//import org.springframework.web.bind.annotation.RestController; +// +//import java.time.LocalDateTime; +//import java.util.Map; +// +///** +// * 헬스체크 컨트롤러 +// */ +//@RestController +//public class HealthController { +// +// @GetMapping("/health") +// public Map health() { +// return Map.of( +// "status", "UP", +// "service", "ai-recommend-service", +// "timestamp", LocalDateTime.now(), +// "message", "AI 추천 서비스가 정상 동작 중입니다.", +// "features", Map.of( +// "store_integration", "매장 서비스 연동", +// "python_ai_integration", "Python AI 서비스 연동", +// "fallback_support", "Fallback 팁 생성 지원" +// ) +// ); +// } +//} +// } +// +// } catch (Exception e) { +// log.error("매장 정보 조회 실패, Mock 데이터 반환: storeId={}", storeId, e); +// return createMockStoreData(storeId); \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java index fbbab48..89912d3 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java @@ -29,49 +29,49 @@ public class RecommendationController { private final MarketingTipUseCase marketingTipUseCase; @Operation( - summary = "AI 마케팅 팁 생성", - description = "매장 정보와 환경 데이터를 기반으로 AI 마케팅 팁을 생성합니다." + summary = "AI 마케팅 팁 생성", + description = "매장 정보를 기반으로 Python AI 서비스에서 마케팅 팁을 생성합니다." ) @PostMapping("/marketing-tips") public ResponseEntity> generateMarketingTips( @Parameter(description = "마케팅 팁 생성 요청") @Valid @RequestBody MarketingTipRequest request) { - + log.info("AI 마케팅 팁 생성 요청: storeId={}", request.getStoreId()); - + MarketingTipResponse response = marketingTipUseCase.generateMarketingTips(request); - + log.info("AI 마케팅 팁 생성 완료: tipId={}", response.getTipId()); return ResponseEntity.ok(ApiResponse.success(response)); } @Operation( - summary = "마케팅 팁 이력 조회", - description = "특정 매장의 마케팅 팁 생성 이력을 조회합니다." + summary = "마케팅 팁 이력 조회", + description = "특정 매장의 마케팅 팁 생성 이력을 조회합니다." ) @GetMapping("/marketing-tips") public ResponseEntity>> getMarketingTipHistory( @Parameter(description = "매장 ID") @RequestParam Long storeId, Pageable pageable) { - + log.info("마케팅 팁 이력 조회: storeId={}, page={}", storeId, pageable.getPageNumber()); - + Page response = marketingTipUseCase.getMarketingTipHistory(storeId, pageable); - + return ResponseEntity.ok(ApiResponse.success(response)); } @Operation( - summary = "마케팅 팁 상세 조회", - description = "특정 마케팅 팁의 상세 정보를 조회합니다." + summary = "마케팅 팁 상세 조회", + description = "특정 마케팅 팁의 상세 정보를 조회합니다." ) @GetMapping("/marketing-tips/{tipId}") public ResponseEntity> getMarketingTip( @Parameter(description = "팁 ID") @PathVariable Long tipId) { - + log.info("마케팅 팁 상세 조회: tipId={}", tipId); - + MarketingTipResponse response = marketingTipUseCase.getMarketingTip(tipId); - + return ResponseEntity.ok(ApiResponse.success(response)); } -} \ No newline at end of file +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/AIServiceRequest.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/AIServiceRequest.java deleted file mode 100644 index 396e20c..0000000 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/AIServiceRequest.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.won.smarketing.recommend.presentation.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.util.Map; - -/** - * Python AI 서비스 요청 DTO - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class AIServiceRequest { - - private String serviceType; // "marketing_tips", "business_insights", "trend_analysis" - private Long storeId; - private String category; - private Map parameters; - private Map context; // 매장 정보, 과거 데이터 등 -} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/DetailedMarketingTipResponse.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/DetailedMarketingTipResponse.java deleted file mode 100644 index 9e90fc8..0000000 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/DetailedMarketingTipResponse.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.won.smarketing.recommend.presentation.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; - -/** - * 상세 AI 마케팅 팁 응답 DTO - * AI 마케팅 팁과 함께 생성 시 사용된 환경 데이터도 포함합니다. - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -@Schema(description = "상세 AI 마케팅 팁 응답") -public class DetailedMarketingTipResponse { - - @Schema(description = "팁 ID", example = "1") - private Long tipId; - - @Schema(description = "AI 생성 마케팅 팁 내용 (100자 이내)") - private String tipContent; - - @Schema(description = "팁 생성 시간", example = "2024-01-15T10:30:00") - private LocalDateTime createdAt; - - @Schema(description = "팁 생성 시 참고된 날씨 정보") - private WeatherInfoDto weatherInfo; - - @Schema(description = "팁 생성 시 참고된 매장 정보") - private StoreInfoDto storeInfo; -} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/ErrorResponseDto.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/ErrorResponseDto.java deleted file mode 100644 index 43c77da..0000000 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/ErrorResponseDto.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.won.smarketing.recommend.presentation.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; - -/** - * 에러 응답 DTO - * AI 추천 서비스에서 발생하는 에러 정보를 전달합니다. - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -@Schema(description = "에러 응답") -public class ErrorResponseDto { - - @Schema(description = "에러 코드", example = "AI_SERVICE_ERROR") - private String errorCode; - - @Schema(description = "에러 메시지", example = "AI 서비스 연결에 실패했습니다") - private String message; - - @Schema(description = "에러 발생 시간", example = "2024-01-15T10:30:00") - private LocalDateTime timestamp; - - @Schema(description = "요청 경로", example = "/api/recommendation/marketing-tips") - private String path; -} - diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipGenerationRequest.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipGenerationRequest.java deleted file mode 100644 index 70de05e..0000000 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipGenerationRequest.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.won.smarketing.recommend.presentation.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Positive; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -/** - * AI 마케팅 팁 생성을 위한 내부 요청 DTO - * 애플리케이션 계층에서 AI 서비스 호출 시 사용됩니다. - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -@Schema(description = "AI 마케팅 팁 생성 내부 요청") -public class MarketingTipGenerationRequest { - - @NotNull(message = "매장 정보는 필수입니다") - @Schema(description = "매장 정보", required = true) - private StoreInfoDto storeInfo; - - @Schema(description = "현재 날씨 정보") - private WeatherInfoDto weatherInfo; - - @Schema(description = "팁 생성 옵션", example = "일반") - private String tipType; -} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipRequest.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipRequest.java index c706619..5a0ceb5 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipRequest.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipRequest.java @@ -23,4 +23,4 @@ public class MarketingTipRequest { @Schema(description = "추가 요청사항", example = "여름철 음료 프로모션에 집중해주세요") private String additionalRequirement; -} \ No newline at end of file +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java index 047f34b..6c7ac7f 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java @@ -27,30 +27,12 @@ public class MarketingTipResponse { @Schema(description = "AI 생성 마케팅 팁 내용") private String tipContent; - @Schema(description = "날씨 정보") - private WeatherInfo weatherInfo; - @Schema(description = "매장 정보") private StoreInfo storeInfo; @Schema(description = "생성 일시") private LocalDateTime createdAt; - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class WeatherInfo { - @Schema(description = "온도", example = "25.5") - private Double temperature; - - @Schema(description = "날씨 상태", example = "맑음") - private String condition; - - @Schema(description = "습도", example = "60.0") - private Double humidity; - } - @Data @Builder @NoArgsConstructor @@ -65,4 +47,4 @@ public class MarketingTipResponse { @Schema(description = "위치", example = "서울시 강남구") private String location; } -} \ No newline at end of file +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/StoreInfoDto.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/StoreInfoDto.java deleted file mode 100644 index aae7983..0000000 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/StoreInfoDto.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.won.smarketing.recommend.presentation.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -/** - * 매장 정보 DTO - * AI 마케팅 팁 생성 시 매장 특성을 반영하기 위한 정보입니다. - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -@Schema(description = "매장 정보") -public class StoreInfoDto { - - @Schema(description = "매장명", example = "카페 원더풀") - private String storeName; - - @Schema(description = "업종", example = "카페") - private String businessType; - - @Schema(description = "매장 위치", example = "서울시 강남구") - private String location; -} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/WeatherInfoDto.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/WeatherInfoDto.java deleted file mode 100644 index 9757f11..0000000 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/WeatherInfoDto.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.won.smarketing.recommend.presentation.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -/** - * 날씨 정보 DTO - * AI 마케팅 팁 생성 시 참고되는 환경 데이터입니다. - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -@Schema(description = "날씨 정보") -public class WeatherInfoDto { - - @Schema(description = "기온 (섭씨)", example = "23.5") - private Double temperature; - - @Schema(description = "날씨 상태", example = "맑음") - private String condition; - - @Schema(description = "습도 (%)", example = "65.0") - private Double humidity; -} diff --git a/smarketing-java/ai-recommend/src/main/resources/application.yml b/smarketing-java/ai-recommend/src/main/resources/application.yml index c3caad4..018f81b 100644 --- a/smarketing-java/ai-recommend/src/main/resources/application.yml +++ b/smarketing-java/ai-recommend/src/main/resources/application.yml @@ -24,23 +24,23 @@ spring: port: ${REDIS_PORT:6379} password: ${REDIS_PASSWORD:} -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} - base-url: ${CLAUDE_AI_BASE_URL:https://api.anthropic.com} - model: ${CLAUDE_AI_MODEL:claude-3-sonnet-20240229} - max-tokens: ${CLAUDE_AI_MAX_TOKENS:2000} - weather-api: - api-key: ${WEATHER_API_KEY:your-weather-api-key} - base-url: ${WEATHER_API_BASE_URL:https://api.openweathermap.org/data/2.5} store-service: base-url: ${STORE_SERVICE_URL:http://localhost:8082} + timeout: ${STORE_SERVICE_TIMEOUT:5000} + python-ai-service: + base-url: ${PYTHON_AI_SERVICE_URL:http://localhost:8090} + api-key: ${PYTHON_AI_API_KEY:dummy-key} + timeout: ${PYTHON_AI_TIMEOUT:30000} + +management: + endpoints: + web: + exposure: + include: health,info,metrics + endpoint: + health: + show-details: always springdoc: swagger-ui: @@ -56,4 +56,4 @@ logging: jwt: secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789} access-token-validity: ${JWT_ACCESS_VALIDITY:3600000} - refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000} + refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000} \ No newline at end of file From 24e3f2a56c91301ca0656c7cfa179e5fd8eb13da Mon Sep 17 00:00:00 2001 From: OhSeongRak Date: Thu, 12 Jun 2025 13:32:42 +0900 Subject: [PATCH 27/34] =?UTF-8?q?feat:=20=EC=9E=84=EC=8B=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...mp_1e7658c3-43ba-4d61-9bfc-4c1d9c2a5098.jpg | Bin 0 -> 45047 bytes ...mp_44b7841c-56e8-4c94-b769-4580f00f7723.jpg | Bin 0 -> 137697 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 smarketing-ai/uploads/temp/temp_1e7658c3-43ba-4d61-9bfc-4c1d9c2a5098.jpg create mode 100644 smarketing-ai/uploads/temp/temp_44b7841c-56e8-4c94-b769-4580f00f7723.jpg diff --git a/smarketing-ai/uploads/temp/temp_1e7658c3-43ba-4d61-9bfc-4c1d9c2a5098.jpg b/smarketing-ai/uploads/temp/temp_1e7658c3-43ba-4d61-9bfc-4c1d9c2a5098.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3d8f70dbed03e797fa5b0cdec64c306eddc8be42 GIT binary patch literal 45047 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

aFIZ&~FPG?3bGBD>W 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 >q9%X+^M?`XhY*+yD<9?*Mzw!zU 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

(#D;WhY$735p7a+?&rrG{q{_TRP9|A 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 Date: Thu, 12 Jun 2025 13:33:02 +0900 Subject: [PATCH 28/34] =?UTF-8?q?refactor:=20SNS=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EA=B8=80=20=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- smarketing-ai/app.py | 2 +- smarketing-ai/services/poster_service.py | 3 ++ smarketing-ai/services/sns_content_service.py | 44 ++++++++++++------- smarketing-ai/utils/ai_client.py | 8 ++-- 4 files changed, 35 insertions(+), 22 deletions(-) diff --git a/smarketing-ai/app.py b/smarketing-ai/app.py index ebe2175..a88fe1e 100644 --- a/smarketing-ai/app.py +++ b/smarketing-ai/app.py @@ -42,7 +42,7 @@ def create_app(): # ===== 새로운 API 엔드포인트 ===== - @app.route('/api/ai/sns', methods=['POST']) + @app.route('/api/ai/sns', methods=['GET']) def generate_sns_content(): """ SNS 게시물 생성 API (새로운 요구사항) diff --git a/smarketing-ai/services/poster_service.py b/smarketing-ai/services/poster_service.py index 9ebbcb2..4894f8f 100644 --- a/smarketing-ai/services/poster_service.py +++ b/smarketing-ai/services/poster_service.py @@ -161,6 +161,9 @@ class PosterService: - 톤앤매너: {tone_style} - 감정 강도: {emotion_design} +**메뉴 정보:** +- 메뉴명: {request.menuName or '없음'} + **이벤트 정보:** - 이벤트명: {request.eventName or '특별 프로모션'} - 시작일: {request.startDate or '지금'} diff --git a/smarketing-ai/services/sns_content_service.py b/smarketing-ai/services/sns_content_service.py index fc80913..fb248e6 100644 --- a/smarketing-ai/services/sns_content_service.py +++ b/smarketing-ai/services/sns_content_service.py @@ -123,7 +123,7 @@ class SnsContentService: SNS 콘텐츠 생성을 위한 AI 프롬프트 생성 """ platform_spec = self.platform_specs.get(request.platform, self.platform_specs['인스타그램']) - tone_style = self.tone_styles.get(request.toneAndManner, '친근한 어조') + tone_style = self.tone_styles.get(request.toneAndManner, '정중하고 재밌는 어조') emotion_level = self.emotion_levels.get(request.emotionIntensity, '적당한 강도') # 이미지 설명 추출 @@ -146,6 +146,9 @@ class SnsContentService: - 감정 강도: {request.emotionIntensity} ({emotion_level}) - 특별 요구사항: {request.requirement or '없음'} +**메뉴 정보:** +- 메뉴명: {request.menuName or '없음'} + **이벤트 정보:** - 이벤트명: {request.eventName or '없음'} - 시작일: {request.startDate or '없음'} @@ -160,7 +163,7 @@ class SnsContentService: - 형식: {platform_spec['format']} **요구사항:** -1. {request.platform}의 특성에 맞는 톤앤매너 사용 +1. 중요 => {request.platform}의 특성에 맞는 내용 구성 2. {request.category} 카테고리에 적합한 내용 구성 3. 고객의 관심을 끌 수 있는 매력적인 문구 사용 4. 이미지와 연관된 내용으로 작성 @@ -174,29 +177,36 @@ class SnsContentService: """ 생성된 콘텐츠를 HTML 형식으로 포맷팅 """ - # 줄바꿈을
태그로 변환 + # 1. literal \n 문자열을 실제 줄바꿈으로 변환 + content = content.replace('\\n', '\n') + + # 2. 실제 줄바꿈을
태그로 변환 content = content.replace('\n', '
') - # 해시태그를 파란색으로 스타일링 - import re - content = re.sub(r'(#[\w가-힣]+)', r'\1', content) + # 3. 추가 정리: \r, 여러 공백 정리 + content = content.replace('\\r', '').replace('\r', '') - # 이모티콘은 그대로 유지 + # 4. 여러 개의
태그를 하나로 정리 + import re + content = re.sub(r'(
\s*){3,}', '

', content) + + # 5. 해시태그를 파란색으로 스타일링 + content = re.sub(r'(#[\w가-힣]+)', r'\1', content) # 전체 HTML 구조 html_content = f""" -

-
-

{request.platform} 게시물

-
-
-
- {content} +
+
+

{request.platform} 게시물

+
+
+
+ {content} +
+ {self._add_metadata_html(request)}
- {self._add_metadata_html(request)}
-
-""" + """ return html_content def _add_metadata_html(self, request: SnsContentGetRequest) -> str: diff --git a/smarketing-ai/utils/ai_client.py b/smarketing-ai/utils/ai_client.py index d1ef889..b4f032c 100644 --- a/smarketing-ai/utils/ai_client.py +++ b/smarketing-ai/utils/ai_client.py @@ -99,7 +99,7 @@ class AIClient: if self.claude_client: try: response = self.claude_client.messages.create( - model="claude-3-sonnet-20240229", + model="claude-3-5-sonnet-20240620", max_tokens=max_tokens, messages=[ {"role": "user", "content": prompt} @@ -113,7 +113,7 @@ class AIClient: if self.openai_client: try: response = self.openai_client.chat.completions.create( - model="gpt-3.5-turbo", + model="gpt-4o", messages=[ {"role": "user", "content": prompt} ], @@ -138,7 +138,7 @@ class AIClient: if self.claude_client: try: response = self.claude_client.messages.create( - model="claude-3-sonnet-20240229", + model="claude-3-5-sonnet-20240620", max_tokens=500, messages=[ { @@ -168,7 +168,7 @@ class AIClient: if self.openai_client: try: response = self.openai_client.chat.completions.create( - model="gpt-4-vision-preview", + model="gpt-4o", messages=[ { "role": "user", From 7cbf82db2e3345b81b115c3d015ed65fd90befa8 Mon Sep 17 00:00:00 2001 From: OhSeongRak Date: Thu, 12 Jun 2025 14:47:50 +0900 Subject: [PATCH 29/34] =?UTF-8?q?refactor:=20=ED=94=8C=EB=9E=AB=ED=8F=BC?= =?UTF-8?q?=EB=B3=84=20=ED=8A=B9=ED=99=94=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- smarketing-ai/services/sns_content_service.py | 559 +++++++++++++++--- 1 file changed, 487 insertions(+), 72 deletions(-) diff --git a/smarketing-ai/services/sns_content_service.py b/smarketing-ai/services/sns_content_service.py index fb248e6..24fa7a5 100644 --- a/smarketing-ai/services/sns_content_service.py +++ b/smarketing-ai/services/sns_content_service.py @@ -1,8 +1,8 @@ """ -SNS 콘텐츠 생성 서비스 +SNS 콘텐츠 생성 서비스 (플랫폼 특화 개선) """ import os -from typing import Dict, Any +from typing import Dict, Any, List, Tuple from datetime import datetime from utils.ai_client import AIClient from utils.image_processor import ImageProcessor @@ -16,28 +16,92 @@ class SnsContentService: self.ai_client = AIClient() self.image_processor = ImageProcessor() - # 플랫폼별 콘텐츠 특성 정의 + # 플랫폼별 콘텐츠 특성 정의 (대폭 개선) self.platform_specs = { '인스타그램': { 'max_length': 2200, 'hashtag_count': 15, 'style': '감성적이고 시각적', - 'format': '짧은 문장, 해시태그 활용' + 'format': '짧은 문장, 해시태그 활용', + 'content_structure': '후킹 문장 → 스토리텔링 → 행동 유도 → 해시태그', + 'writing_tips': [ + '첫 문장으로 관심 끌기', + '이모티콘을 적절히 활용', + '줄바꿈으로 가독성 높이기', + '개성 있는 말투 사용', + '팔로워와의 소통 유도' + ], + 'hashtag_strategy': [ + '브랜딩 해시태그 포함', + '지역 기반 해시태그', + '트렌딩 해시태그 활용', + '음식 관련 인기 해시태그', + '감정 표현 해시태그' + ], + 'call_to_action': ['팔로우', '댓글', '저장', '공유', '방문'] }, '네이버 블로그': { 'max_length': 3000, 'hashtag_count': 10, 'style': '정보성과 친근함', - 'format': '구조화된 내용, 상세 설명' + 'format': '구조화된 내용, 상세 설명', + 'content_structure': '제목 → 인트로 → 본문(구조화) → 마무리', + 'writing_tips': [ + '검색 키워드 자연스럽게 포함', + '단락별로 소제목 활용', + '구체적인 정보 제공', + '후기/리뷰 형식 활용', + '지역 정보 상세히 기술' + ], + 'seo_keywords': [ + '맛집', '리뷰', '추천', '후기', + '메뉴', '가격', '위치', '분위기', + '데이트', '모임', '가족', '혼밥' + ], + 'call_to_action': ['방문', '예약', '문의', '공감', '이웃추가'], + 'image_placement_strategy': [ + '매장 외관 → 인테리어 → 메뉴판 → 음식 → 분위기', + '텍스트 2-3문장마다 이미지 배치', + '이미지 설명은 간결하고 매력적으로', + '마지막에 대표 이미지로 마무리' + ] } } - # 톤앤매너별 스타일 + # 톤앤매너별 스타일 (플랫폼별 세분화) self.tone_styles = { - '친근한': '반말, 이모티콘 활용, 편안한 어조', - '정중한': '존댓말, 격식 있는 표현, 신뢰감 있는 어조', - '재미있는': '유머 섞인 표현, 트렌디한 말투, 참신한 비유', - '전문적인': '전문 용어 활용, 체계적 설명, 신뢰성 강조' + '친근한': { + '인스타그램': '반말, 친구같은 느낌, 이모티콘 많이 사용', + '네이버 블로그': '존댓말이지만 따뜻하고 친근한 어조' + }, + '정중한': { + '인스타그램': '정중하지만 접근하기 쉬운 어조', + '네이버 블로그': '격식 있고 신뢰감 있는 리뷰 스타일' + }, + '재미있는': { + '인스타그램': '유머러스하고 트렌디한 표현', + '네이버 블로그': '재미있는 에피소드가 포함된 후기' + }, + '전문적인': { + '인스타그램': '전문성을 어필하되 딱딱하지 않게', + '네이버 블로그': '전문가 관점의 상세한 분석과 평가' + } + } + + # 카테고리별 플랫폼 특화 키워드 + self.category_keywords = { + '음식': { + '인스타그램': ['#맛스타그램', '#음식스타그램', '#먹스타그램', '#맛집', '#foodstagram'], + '네이버 블로그': ['맛집 리뷰', '음식 후기', '메뉴 추천', '맛집 탐방', '식당 정보'] + }, + '매장': { + '인스타그램': ['#카페스타그램', '#인테리어', '#분위기맛집', '#데이트장소'], + '네이버 블로그': ['카페 추천', '분위기 좋은 곳', '인테리어 구경', '모임장소'] + }, + '이벤트': { + '인스타그램': ['#이벤트', '#프로모션', '#할인', '#특가'], + '네이버 블로그': ['이벤트 소식', '할인 정보', '프로모션 안내', '특별 혜택'] + } } # 감정 강도별 표현 @@ -47,28 +111,52 @@ class SnsContentService: '강함': '매우 열정적이고 강렬한 표현' } + # 이미지 타입 분류를 위한 키워드 + self.image_type_keywords = { + '매장외관': ['외관', '건물', '간판', '입구', '외부'], + '인테리어': ['내부', '인테리어', '좌석', '테이블', '분위기', '장식'], + '메뉴판': ['메뉴', '가격', '메뉴판', '메뉴보드', 'menu'], + '음식': ['음식', '요리', '메뉴', '디저트', '음료', '플레이팅'], + '사람': ['사람', '고객', '직원', '사장', '요리사'], + '기타': ['기타', '일반', '전체'] + } + def generate_sns_content(self, request: SnsContentGetRequest) -> Dict[str, Any]: """ - SNS 콘텐츠 생성 (HTML 형식 반환) + SNS 콘텐츠 생성 (플랫폼별 특화) """ try: # 이미지 다운로드 및 분석 image_analysis = self._analyze_images_from_urls(request.images) - # AI 프롬프트 생성 - prompt = self._create_sns_prompt(request, image_analysis) + # 네이버 블로그인 경우 이미지 배치 계획 생성 + image_placement_plan = None + if request.platform == '네이버 블로그': + image_placement_plan = self._create_image_placement_plan(image_analysis, request) + + # 플랫폼별 특화 프롬프트 생성 + prompt = self._create_platform_specific_prompt(request, image_analysis, image_placement_plan) # AI로 콘텐츠 생성 - generated_content = self.ai_client.generate_text(prompt) + generated_content = self.ai_client.generate_text(prompt, max_tokens=1500) + + # 플랫폼별 후처리 + processed_content = self._post_process_content(generated_content, request) # HTML 형식으로 포맷팅 - html_content = self._format_to_html(generated_content, request) + html_content = self._format_to_html(processed_content, request, image_placement_plan) - return { + result = { 'success': True, 'content': html_content } + # 네이버 블로그인 경우 이미지 배치 가이드라인 추가 + if request.platform == '네이버 블로그' and image_placement_plan: + result['image_placement_guide'] = image_placement_plan + + return result + except Exception as e: return { 'success': False, @@ -77,13 +165,13 @@ class SnsContentService: def _analyze_images_from_urls(self, image_urls: list) -> Dict[str, Any]: """ - URL에서 이미지를 다운로드하고 분석 + URL에서 이미지를 다운로드하고 분석 (이미지 타입 분류 추가) """ analysis_results = [] temp_files = [] try: - for image_url in image_urls: + for i, image_url in enumerate(image_urls): # 이미지 다운로드 temp_path = self.ai_client.download_image_from_url(image_url) if temp_path: @@ -94,15 +182,22 @@ class SnsContentService: image_info = self.image_processor.get_image_info(temp_path) image_description = self.ai_client.analyze_image(temp_path) + # 이미지 타입 분류 + image_type = self._classify_image_type(image_description) + analysis_results.append({ + 'index': i, 'url': image_url, 'info': image_info, - 'description': image_description + 'description': image_description, + 'type': image_type }) except Exception as e: analysis_results.append({ + 'index': i, 'url': image_url, - 'error': str(e) + 'error': str(e), + 'type': '기타' }) return { @@ -118,13 +213,119 @@ class SnsContentService: except: pass - def _create_sns_prompt(self, request: SnsContentGetRequest, image_analysis: Dict[str, Any]) -> str: + def _classify_image_type(self, description: str) -> str: """ - SNS 콘텐츠 생성을 위한 AI 프롬프트 생성 + 이미지 설명을 바탕으로 이미지 타입 분류 + """ + description_lower = description.lower() + + for image_type, keywords in self.image_type_keywords.items(): + for keyword in keywords: + if keyword in description_lower: + return image_type + + return '기타' + + def _create_image_placement_plan(self, image_analysis: Dict[str, Any], request: SnsContentGetRequest) -> Dict[ + str, Any]: + """ + 네이버 블로그용 이미지 배치 계획 생성 + """ + images = image_analysis.get('results', []) + if not images: + return None + + # 이미지 타입별 분류 + categorized_images = { + '매장외관': [], + '인테리어': [], + '메뉴판': [], + '음식': [], + '사람': [], + '기타': [] + } + + for img in images: + img_type = img.get('type', '기타') + categorized_images[img_type].append(img) + + # 블로그 구조에 따른 이미지 배치 계획 + placement_plan = { + 'structure': [ + { + 'section': '인트로', + 'description': '첫인상과 방문 동기', + 'recommended_images': [], + 'placement_guide': '매장 외관이나 대표적인 음식 사진으로 시작' + }, + { + 'section': '매장 정보', + 'description': '위치, 분위기, 인테리어 소개', + 'recommended_images': [], + 'placement_guide': '매장 외관 → 내부 인테리어 순서로 배치' + }, + { + 'section': '메뉴 소개', + 'description': '주문한 메뉴와 상세 후기', + 'recommended_images': [], + 'placement_guide': '메뉴판 → 실제 음식 사진 순서로 배치' + }, + { + 'section': '총평', + 'description': '재방문 의향과 추천 이유', + 'recommended_images': [], + 'placement_guide': '가장 매력적인 음식 사진이나 전체 분위기 사진' + } + ], + 'image_sequence': [], + 'usage_guide': [] + } + + # 각 섹션에 적절한 이미지 배정 + # 인트로: 매장외관 또는 대표 음식 + if categorized_images['매장외관']: + placement_plan['structure'][0]['recommended_images'].extend(categorized_images['매장외관'][:1]) + elif categorized_images['음식']: + placement_plan['structure'][0]['recommended_images'].extend(categorized_images['음식'][:1]) + + # 매장 정보: 외관 + 인테리어 + placement_plan['structure'][1]['recommended_images'].extend(categorized_images['매장외관']) + placement_plan['structure'][1]['recommended_images'].extend(categorized_images['인테리어']) + + # 메뉴 소개: 메뉴판 + 음식 + placement_plan['structure'][2]['recommended_images'].extend(categorized_images['메뉴판']) + placement_plan['structure'][2]['recommended_images'].extend(categorized_images['음식']) + + # 총평: 남은 음식 사진 또는 기타 + remaining_food = [img for img in categorized_images['음식'] + if img not in placement_plan['structure'][2]['recommended_images']] + placement_plan['structure'][3]['recommended_images'].extend(remaining_food[:1]) + placement_plan['structure'][3]['recommended_images'].extend(categorized_images['기타'][:1]) + + # 전체 이미지 순서 생성 + for section in placement_plan['structure']: + for img in section['recommended_images']: + if img not in placement_plan['image_sequence']: + placement_plan['image_sequence'].append(img) + + # 사용 가이드 생성 + placement_plan['usage_guide'] = [ + "📸 이미지 배치 가이드라인:", + "1. 각 섹션마다 2-3문장의 설명 후 이미지 삽입", + "2. 이미지마다 간단한 설명 텍스트 추가", + "3. 음식 사진은 가장 맛있어 보이는 각도로 배치", + "4. 마지막에 전체적인 분위기를 보여주는 사진으로 마무리" + ] + + return placement_plan + + def _create_platform_specific_prompt(self, request: SnsContentGetRequest, image_analysis: Dict[str, Any], + image_placement_plan: Dict[str, Any] = None) -> str: + """ + 플랫폼별 특화 프롬프트 생성 """ platform_spec = self.platform_specs.get(request.platform, self.platform_specs['인스타그램']) - tone_style = self.tone_styles.get(request.toneAndManner, '정중하고 재밌는 어조') - emotion_level = self.emotion_levels.get(request.emotionIntensity, '적당한 강도') + tone_style = self.tone_styles.get(request.toneAndManner, {}).get(request.platform, '친근하고 자연스러운 어조') # 이미지 설명 추출 image_descriptions = [] @@ -132,83 +333,296 @@ class SnsContentService: if 'description' in result: image_descriptions.append(result['description']) - prompt = f""" -당신은 소상공인을 위한 SNS 마케팅 콘텐츠 전문가입니다. -다음 정보를 바탕으로 {request.platform}에 적합한 게시글을 작성해주세요. + # 플랫폼별 특화 프롬프트 생성 + if request.platform == '인스타그램': + return self._create_instagram_prompt(request, platform_spec, tone_style, image_descriptions) + elif request.platform == '네이버 블로그': + return self._create_naver_blog_prompt(request, platform_spec, tone_style, image_descriptions, + image_placement_plan) + else: + return self._create_instagram_prompt(request, platform_spec, tone_style, image_descriptions) -**게시물 정보:** + def _create_instagram_prompt(self, request: SnsContentGetRequest, platform_spec: dict, tone_style: str, + image_descriptions: list) -> str: + """ + 인스타그램 특화 프롬프트 + """ + category_hashtags = self.category_keywords.get(request.category, {}).get('인스타그램', []) + + prompt = f""" +당신은 인스타그램 마케팅 전문가입니다. 소상공인 음식점을 위한 매력적인 인스타그램 게시물을 작성해주세요. + +**🎯 콘텐츠 정보:** - 제목: {request.title} - 카테고리: {request.category} - 콘텐츠 타입: {request.contentType} +- 메뉴명: {request.menuName or '특별 메뉴'} +- 이벤트: {request.eventName or '특별 이벤트'} -**스타일 요구사항:** -- 톤앤매너: {request.toneAndManner} ({tone_style}) -- 감정 강도: {request.emotionIntensity} ({emotion_level}) -- 특별 요구사항: {request.requirement or '없음'} - -**메뉴 정보:** -- 메뉴명: {request.menuName or '없음'} - -**이벤트 정보:** -- 이벤트명: {request.eventName or '없음'} -- 시작일: {request.startDate or '없음'} -- 종료일: {request.endDate or '없음'} - -**이미지 분석 결과:** -{chr(10).join(image_descriptions) if image_descriptions else '이미지 없음'} - -**플랫폼 특성:** +**📱 인스타그램 특화 요구사항:** +- 글 구조: {platform_spec['content_structure']} - 최대 길이: {platform_spec['max_length']}자 -- 스타일: {platform_spec['style']} -- 형식: {platform_spec['format']} +- 해시태그: {platform_spec['hashtag_count']}개 내외 +- 톤앤매너: {tone_style} -**요구사항:** -1. 중요 => {request.platform}의 특성에 맞는 내용 구성 -2. {request.category} 카테고리에 적합한 내용 구성 -3. 고객의 관심을 끌 수 있는 매력적인 문구 사용 -4. 이미지와 연관된 내용으로 작성 -5. 지정된 톤앤매너와 감정 강도에 맞게 작성 +**✨ 인스타그램 작성 가이드라인:** +{chr(10).join([f"- {tip}" for tip in platform_spec['writing_tips']])} -본문과 해시태그를 모두 포함하여 완성된 게시글을 작성해주세요. +**📸 이미지 분석 결과:** +{chr(10).join(image_descriptions) if image_descriptions else '시각적으로 매력적인 음식/매장 이미지'} + +**🏷️ 추천 해시태그 카테고리:** +- 기본 해시태그: {', '.join(category_hashtags[:5])} +- 브랜딩: #우리가게이름 (실제 가게명으로 대체) +- 지역: #강남맛집 #서울카페 (실제 위치로 대체) +- 감정: #행복한시간 #맛있다 #추천해요 + +**💡 콘텐츠 작성 지침:** +1. 첫 문장은 반드시 관심을 끄는 후킹 문장으로 시작 +2. 이모티콘을 적절히 활용하여 시각적 재미 추가 +3. 스토리텔링을 통해 감정적 연결 유도 +4. 명확한 행동 유도 문구 포함 (팔로우, 댓글, 저장, 방문 등) +5. 줄바꿈을 활용하여 가독성 향상 +6. 해시태그는 본문과 자연스럽게 연결되도록 배치 + +**특별 요구사항:** +{request.requirement or '고객의 관심을 끌고 방문을 유도하는 매력적인 게시물'} + +인스타그램 사용자들이 "저장하고 싶다", "친구에게 공유하고 싶다"라고 생각할 만한 매력적인 게시물을 작성해주세요. """ return prompt - def _format_to_html(self, content: str, request: SnsContentGetRequest) -> str: + def _create_naver_blog_prompt(self, request: SnsContentGetRequest, platform_spec: dict, tone_style: str, + image_descriptions: list, image_placement_plan: Dict[str, Any]) -> str: """ - 생성된 콘텐츠를 HTML 형식으로 포맷팅 + 네이버 블로그 특화 프롬프트 (이미지 배치 계획 포함) + """ + category_keywords = self.category_keywords.get(request.category, {}).get('네이버 블로그', []) + seo_keywords = platform_spec['seo_keywords'] + + # 이미지 배치 정보 추가 + image_placement_info = "" + if image_placement_plan: + image_placement_info = f""" + +**📸 이미지 배치 계획:** +{chr(10).join([f"- {section['section']}: {section['placement_guide']}" for section in image_placement_plan['structure']])} + +**이미지 사용 순서:** +{chr(10).join([f"{i + 1}. {img.get('description', 'Image')} (타입: {img.get('type', '기타')})" for i, img in enumerate(image_placement_plan.get('image_sequence', []))])} +""" + + prompt = f""" +당신은 네이버 블로그 맛집 리뷰 전문가입니다. 검색 최적화와 정보 제공을 중시하는 네이버 블로그 특성에 맞는 게시물을 작성해주세요. + +**📝 콘텐츠 정보:** +- 제목: {request.title} +- 카테고리: {request.category} +- 콘텐츠 타입: {request.contentType} +- 메뉴명: {request.menuName or '대표 메뉴'} +- 이벤트: {request.eventName or '특별 이벤트'} + +**🔍 네이버 블로그 특화 요구사항:** +- 글 구조: {platform_spec['content_structure']} +- 최대 길이: {platform_spec['max_length']}자 +- 톤앤매너: {tone_style} +- SEO 최적화 필수 + +**📚 블로그 작성 가이드라인:** +{chr(10).join([f"- {tip}" for tip in platform_spec['writing_tips']])} + +**🖼️ 이미지 분석 결과:** +{chr(10).join(image_descriptions) if image_descriptions else '상세한 음식/매장 정보'} + +{image_placement_info} + +**🔑 SEO 키워드 (자연스럽게 포함할 것):** +- 필수 키워드: {', '.join(seo_keywords[:8])} +- 카테고리 키워드: {', '.join(category_keywords[:5])} + +**📖 블로그 포스트 구조 (이미지 배치 포함):** +1. **인트로**: 방문 동기와 첫인상 + [IMAGE_1] 배치 +2. **매장 정보**: 위치, 운영시간, 분위기 + [IMAGE_2, IMAGE_3] 배치 +3. **메뉴 소개**: 주문한 메뉴와 상세 후기 + [IMAGE_4, IMAGE_5] 배치 +4. **총평**: 재방문 의향과 추천 이유 + [IMAGE_6] 배치 + +**💡 콘텐츠 작성 지침:** +1. 검색자의 궁금증을 해결하는 정보 중심 작성 +2. 구체적인 가격, 위치, 운영시간 등 실용 정보 포함 +3. 개인적인 경험과 솔직한 후기 작성 +4. 각 섹션마다 적절한 위치에 [IMAGE_X] 태그로 이미지 배치 위치 표시 +5. 이미지마다 간단한 설명 문구 추가 +6. 지역 정보와 접근성 정보 포함 + +**이미지 태그 사용법:** +- [IMAGE_1]: 첫 번째 이미지 배치 위치 +- [IMAGE_2]: 두 번째 이미지 배치 위치 +- 각 이미지 태그 다음 줄에 이미지 설명 문구 작성 + +**특별 요구사항:** +{request.requirement or '유용한 정보를 제공하여 방문을 유도하는 신뢰성 있는 후기'} + +네이버 검색에서 상위 노출되고, 실제로 도움이 되는 정보를 제공하는 블로그 포스트를 작성해주세요. +이미지 배치 위치를 [IMAGE_X] 태그로 명확히 표시해주세요. +""" + return prompt + + def _post_process_content(self, content: str, request: SnsContentGetRequest) -> str: + """ + 플랫폼별 후처리 + """ + if request.platform == '인스타그램': + return self._post_process_instagram(content, request) + elif request.platform == '네이버 블로그': + return self._post_process_naver_blog(content, request) + return content + + def _post_process_instagram(self, content: str, request: SnsContentGetRequest) -> str: + """ + 인스타그램 콘텐츠 후처리 + """ + import re + + # 해시태그 개수 조정 + hashtags = re.findall(r'#[\w가-힣]+', content) + if len(hashtags) > 15: + # 해시태그가 너무 많으면 중요도 순으로 15개만 유지 + all_hashtags = ' '.join(hashtags[:15]) + content = re.sub(r'#[\w가-힣]+', '', content) + content = content.strip() + '\n\n' + all_hashtags + + # 이모티콘이 부족하면 추가 + emoji_count = content.count('😊') + content.count('🍽️') + content.count('❤️') + content.count('✨') + if emoji_count < 3: + content = content.replace('!', '! 😊', 1) + + return content + + def _post_process_naver_blog(self, content: str, request: SnsContentGetRequest) -> str: + """ + 네이버 블로그 콘텐츠 후처리 + """ + # 구조화된 형태로 재구성 + if '📍' not in content and '🏷️' not in content: + # 이모티콘 기반 구조화가 없으면 추가 + lines = content.split('\n') + structured_content = [] + for line in lines: + if '위치' in line or '주소' in line: + line = f"📍 {line}" + elif '가격' in line or '메뉴' in line: + line = f"🏷️ {line}" + elif '분위기' in line or '인테리어' in line: + line = f"🏠 {line}" + structured_content.append(line) + content = '\n'.join(structured_content) + + return content + + def _format_to_html(self, content: str, request: SnsContentGetRequest, + image_placement_plan: Dict[str, Any] = None) -> str: + """ + 생성된 콘텐츠를 HTML 형식으로 포맷팅 (이미지 배치 포함) """ # 1. literal \n 문자열을 실제 줄바꿈으로 변환 content = content.replace('\\n', '\n') - # 2. 실제 줄바꿈을
태그로 변환 + # 2. 네이버 블로그인 경우 이미지 태그를 실제 이미지로 변환 + if request.platform == '네이버 블로그' and image_placement_plan: + content = self._replace_image_tags_with_html(content, image_placement_plan, request.images) + + # 3. 실제 줄바꿈을
태그로 변환 content = content.replace('\n', '
') - # 3. 추가 정리: \r, 여러 공백 정리 + # 4. 추가 정리: \r, 여러 공백 정리 content = content.replace('\\r', '').replace('\r', '') - # 4. 여러 개의
태그를 하나로 정리 + # 5. 여러 개의
태그를 하나로 정리 import re content = re.sub(r'(
\s*){3,}', '

', content) - # 5. 해시태그를 파란색으로 스타일링 + # 6. 해시태그를 파란색으로 스타일링 content = re.sub(r'(#[\w가-힣]+)', r'\1', content) + # 플랫폼별 헤더 스타일 + platform_style = "" + if request.platform == '인스타그램': + platform_style = "background: linear-gradient(45deg, #f09433 0%,#e6683c 25%,#dc2743 50%,#cc2366 75%,#bc1888 100%);" + elif request.platform == '네이버 블로그': + platform_style = "background: linear-gradient(135deg, #1EC800 0%, #00B33C 100%);" + else: + platform_style = "background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);" + # 전체 HTML 구조 html_content = f""" -
-
-

{request.platform} 게시물

-
-
-
- {content} -
- {self._add_metadata_html(request)} -
-
- """ +
+
+

{request.platform} 게시물

+
+
+
+ {content} +
+ {self._add_metadata_html(request)} +
+
+ """ return html_content + def _replace_image_tags_with_html(self, content: str, image_placement_plan: Dict[str, Any], + image_urls: List[str]) -> str: + """ + 네이버 블로그 콘텐츠의 [IMAGE_X] 태그를 실제 이미지 HTML로 변환 + """ + import re + + # [IMAGE_X] 패턴 찾기 + image_tags = re.findall(r'\[IMAGE_(\d+)\]', content) + + for tag in image_tags: + image_index = int(tag) - 1 # 1-based to 0-based + + if image_index < len(image_urls): + image_url = image_urls[image_index] + + # 이미지 배치 계획에서 해당 이미지 정보 찾기 + image_info = None + for img in image_placement_plan.get('image_sequence', []): + if img.get('index') == image_index: + image_info = img + break + + # 이미지 설명 생성 + image_description = "" + if image_info: + description = image_info.get('description', '') + img_type = image_info.get('type', '기타') + + if img_type == '음식': + image_description = f"😋 {description}" + elif img_type == '매장외관': + image_description = f"🏪 {description}" + elif img_type == '인테리어': + image_description = f"🏠 {description}" + elif img_type == '메뉴판': + image_description = f"📋 {description}" + else: + image_description = f"📸 {description}" + + # HTML 이미지 태그로 변환 + image_html = f""" +
+ 이미지 +
+ {image_description} +
+
""" + + # 콘텐츠에서 태그 교체 + content = content.replace(f'[IMAGE_{tag}]', image_html) + + return content + def _add_metadata_html(self, request: SnsContentGetRequest) -> str: """ 메타데이터를 HTML에 추가 @@ -225,6 +639,7 @@ class SnsContentService: metadata_html += f'
기간: {request.startDate} ~ {request.endDate}
' metadata_html += f'
카테고리: {request.category}
' + metadata_html += f'
플랫폼: {request.platform}
' metadata_html += f'
생성일: {datetime.now().strftime("%Y-%m-%d %H:%M")}
' metadata_html += '
' From 7cb0efa7697659876f1eda758883a4c5d8333830 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=9C=EC=9D=80?= <61147083+BangSun98@users.noreply.github.com> Date: Thu, 12 Jun 2025 15:17:15 +0900 Subject: [PATCH 30/34] fix redis config --- .../smarketing/common/config/RedisConfig.java | 24 ++++++++++++++++++- .../won/smarketing/member/entity/Member.java | 2 +- .../member/src/main/resources/application.yml | 2 +- .../store/controller/StoreController.java | 11 +++++---- .../won/smarketing/store/entity/Store.java | 4 ++-- .../store/repository/StoreRepository.java | 8 +++---- .../store/service/StoreService.java | 4 ++-- .../store/service/StoreServiceImpl.java | 24 ++++++++++--------- 8 files changed, 52 insertions(+), 27 deletions(-) diff --git a/smarketing-java/common/src/main/java/com/won/smarketing/common/config/RedisConfig.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/config/RedisConfig.java index b648673..a0bc038 100644 --- a/smarketing-java/common/src/main/java/com/won/smarketing/common/config/RedisConfig.java +++ b/smarketing-java/common/src/main/java/com/won/smarketing/common/config/RedisConfig.java @@ -4,6 +4,7 @@ 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.RedisStandaloneConfiguration; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.StringRedisSerializer; @@ -21,6 +22,12 @@ public class RedisConfig { @Value("${spring.data.redis.port}") private int redisPort; + @Value("${spring.data.redis.password:}") + private String redisPassword; + + @Value("${spring.data.redis.ssl:true}") + private boolean useSsl; + /** * Redis 연결 팩토리 설정 * @@ -28,7 +35,22 @@ public class RedisConfig { */ @Bean public RedisConnectionFactory redisConnectionFactory() { - return new LettuceConnectionFactory(redisHost, redisPort); + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(); + config.setHostName(redisHost); + config.setPort(redisPort); + + // Azure Redis는 패스워드 인증 필수 + if (redisPassword != null && !redisPassword.isEmpty()) { + config.setPassword(redisPassword); + } + + LettuceConnectionFactory factory = new LettuceConnectionFactory(config); + + // Azure Redis는 SSL 사용 (6380 포트) + factory.setUseSsl(useSsl); + factory.setValidateConnection(true); + + return factory; } /** diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/entity/Member.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/entity/Member.java index cb76902..0dd68e2 100644 --- a/smarketing-java/member/src/main/java/com/won/smarketing/member/entity/Member.java +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/entity/Member.java @@ -26,7 +26,7 @@ public class Member { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "member_id") + @Column(name = "id") private Long id; @Column(name = "user_id", nullable = false, unique = true, length = 50) diff --git a/smarketing-java/member/src/main/resources/application.yml b/smarketing-java/member/src/main/resources/application.yml index c93fb60..511b56f 100644 --- a/smarketing-java/member/src/main/resources/application.yml +++ b/smarketing-java/member/src/main/resources/application.yml @@ -20,7 +20,7 @@ spring: data: redis: host: ${REDIS_HOST:localhost} - port: ${REDIS_PORT:6379} + port: ${REDIS_PORT:6380} password: ${REDIS_PASSWORD:} jwt: diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/StoreController.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/StoreController.java index dbd699e..348aa57 100644 --- a/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/StoreController.java +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/StoreController.java @@ -42,15 +42,16 @@ public class StoreController { /** * 매장 정보 조회 * - * @param storeId 조회할 매장 ID + * //@param userId 조회할 매장 ID * @return 매장 정보 */ - @Operation(summary = "매장 조회", description = "매장 ID로 매장 정보를 조회합니다.") + @Operation(summary = "매장 조회", description = "유저 ID로 매장 정보를 조회합니다.") @GetMapping public ResponseEntity> getStore( - @Parameter(description = "매장 ID", required = true) - @RequestParam String storeId) { - StoreResponse response = storeService.getStore(storeId); +// @Parameter(description = "유저 ID", required = true) +// @RequestParam String userId + ) { + StoreResponse response = storeService.getStore(); return ResponseEntity.ok(ApiResponse.success(response)); } diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Store.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Store.java index c7df5f3..21efeec 100644 --- a/smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Store.java +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Store.java @@ -30,8 +30,8 @@ public class Store { @Column(name = "store_id") private Long id; - @Column(name = "member_id", nullable = false) - private Long memberId; + @Column(name = "user_id", nullable = false) + private String userId; @Column(name = "store_name", nullable = false, length = 100) private String storeName; diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/repository/StoreRepository.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/repository/StoreRepository.java index 9ef911e..4fbbcea 100644 --- a/smarketing-java/store/src/main/java/com/won/smarketing/store/repository/StoreRepository.java +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/repository/StoreRepository.java @@ -16,18 +16,18 @@ public interface StoreRepository extends JpaRepository { /** * 회원 ID로 매장 조회 * - * @param memberId 회원 ID + * @param userId 회원 ID * @return 매장 정보 (Optional) */ - Optional findByMemberId(Long memberId); + Optional findByUserId(String userId); /** * 회원의 매장 존재 여부 확인 * - * @param memberId 회원 ID + * @param userId 회원 ID * @return 존재 여부 */ - boolean existsByMemberId(Long memberId); + boolean existsByUserId(String userId); /** * 매장명으로 매장 조회 diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/service/StoreService.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/StoreService.java index bc342a8..ffd4a5f 100644 --- a/smarketing-java/store/src/main/java/com/won/smarketing/store/service/StoreService.java +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/StoreService.java @@ -28,10 +28,10 @@ public interface StoreService { /** * 매장 정보 조회 (매장 ID) * - * @param storeId 매장 ID + * //@param userId 매장 ID * @return 매장 정보 */ - StoreResponse getStore(String storeId); + StoreResponse getStore(); /** * 매장 정보 수정 diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/service/StoreServiceImpl.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/StoreServiceImpl.java index 4ab16cc..4ddd56a 100644 --- a/smarketing-java/store/src/main/java/com/won/smarketing/store/service/StoreServiceImpl.java +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/StoreServiceImpl.java @@ -7,6 +7,8 @@ import com.won.smarketing.store.dto.StoreResponse; import com.won.smarketing.store.dto.StoreUpdateRequest; import com.won.smarketing.store.entity.Store; import com.won.smarketing.store.repository.StoreRepository; +import jakarta.xml.bind.annotation.XmlType; +import lombok.Builder; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.context.SecurityContextHolder; @@ -34,19 +36,19 @@ public class StoreServiceImpl implements StoreService { @Override @Transactional public StoreResponse register(StoreCreateRequest request) { - String currentUserId = getCurrentUserId(); - Long memberId = Long.valueOf(currentUserId); // 실제로는 Member ID 조회 필요 + String memberId = getCurrentUserId(); + // Long memberId = Long.valueOf(currentUserId); // 실제로는 Member ID 조회 필요 log.info("매장 등록 시작: {} (회원: {})", request.getStoreName(), memberId); // 회원당 하나의 매장만 등록 가능 - if (storeRepository.existsByMemberId(memberId)) { + if (storeRepository.existsByUserId(memberId)) { throw new BusinessException(ErrorCode.STORE_ALREADY_EXISTS); } // 매장 엔티티 생성 및 저장 Store store = Store.builder() - .memberId(memberId) + .userId(memberId) .storeName(request.getStoreName()) .businessType(request.getBusinessType()) .address(request.getAddress()) @@ -71,10 +73,10 @@ public class StoreServiceImpl implements StoreService { */ @Override public StoreResponse getMyStore() { - String currentUserId = getCurrentUserId(); - Long memberId = Long.valueOf(currentUserId); + String memberId = getCurrentUserId(); + // Long memberId = Long.valueOf(currentUserId); - Store store = storeRepository.findByMemberId(memberId) + Store store = storeRepository.findByUserId(memberId) .orElseThrow(() -> new BusinessException(ErrorCode.STORE_NOT_FOUND)); return toStoreResponse(store); @@ -83,14 +85,14 @@ public class StoreServiceImpl implements StoreService { /** * 매장 정보 조회 (매장 ID) * - * @param storeId 매장 ID + * //@param storeId 매장 ID * @return 매장 정보 */ @Override - public StoreResponse getStore(String storeId) { + public StoreResponse getStore() { try { - Long id = Long.valueOf(storeId); - Store store = storeRepository.findById(id) + String userId = getCurrentUserId(); + Store store = storeRepository.findByUserId(userId) .orElseThrow(() -> new BusinessException(ErrorCode.STORE_NOT_FOUND)); return toStoreResponse(store); From 9ec87678d389d946d05eb17aab751ff7514d0e98 Mon Sep 17 00:00:00 2001 From: OhSeongRak Date: Thu, 12 Jun 2025 17:27:54 +0900 Subject: [PATCH 31/34] =?UTF-8?q?refactor:=20=ED=8F=AC=EC=8A=A4=ED=84=B0?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- smarketing-ai/Dockerfile | 15 +- smarketing-ai/app.py | 11 +- smarketing-ai/services/poster_service_v2.py | 382 ++++++++++++++++++++ smarketing-ai/utils/ai_client.py | 5 +- 4 files changed, 404 insertions(+), 9 deletions(-) create mode 100644 smarketing-ai/services/poster_service_v2.py diff --git a/smarketing-ai/Dockerfile b/smarketing-ai/Dockerfile index 8ad3c3f..68c4544 100644 --- a/smarketing-ai/Dockerfile +++ b/smarketing-ai/Dockerfile @@ -1,12 +1,21 @@ +# 1. Dockerfile에 한글 폰트 추가 FROM python:3.11-slim WORKDIR /app -# 시스템 패키지 설치 +# 시스템 패키지 및 한글 폰트 설치 RUN apt-get update && apt-get install -y \ fonts-dejavu-core \ + fonts-noto-cjk \ + fonts-nanum \ + wget \ && rm -rf /var/lib/apt/lists/* +# 추가 한글 폰트 다운로드 (선택사항) +RUN mkdir -p /app/fonts && \ + wget -O /app/fonts/NotoSansKR-Bold.ttf \ + "https://fonts.gstatic.com/s/notosanskr/v13/PbykFmXiEBPT4ITbgNA5Cgm20xz64px_1hVWr0wuPNGmlQNMEfD4.ttf" + # Python 의존성 설치 COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt @@ -14,8 +23,8 @@ RUN pip install --no-cache-dir -r requirements.txt # 애플리케이션 코드 복사 COPY . . -# 업로드 디렉토리 생성 -RUN mkdir -p uploads/temp templates/poster_templates +# 업로드 및 포스터 디렉토리 생성 +RUN mkdir -p uploads/temp uploads/posters templates/poster_templates # 포트 노출 EXPOSE 5000 diff --git a/smarketing-ai/app.py b/smarketing-ai/app.py index a88fe1e..05754aa 100644 --- a/smarketing-ai/app.py +++ b/smarketing-ai/app.py @@ -12,6 +12,7 @@ from config.config import Config from services.poster_service import PosterService from services.sns_content_service import SnsContentService from models.request_models import ContentRequest, PosterRequest, SnsContentGetRequest, PosterContentGetRequest +from services.poster_service_v2 import PosterServiceV2 def create_app(): @@ -29,6 +30,7 @@ def create_app(): # 서비스 인스턴스 생성 poster_service = PosterService() + poster_service_v2 = PosterServiceV2() sns_content_service = SnsContentService() @app.route('/health', methods=['GET']) @@ -92,11 +94,11 @@ def create_app(): app.logger.error(traceback.format_exc()) return jsonify({'error': f'SNS 콘텐츠 생성 중 오류가 발생했습니다: {str(e)}'}), 500 - @app.route('/api/ai/poster', methods=['POST']) + @app.route('/api/ai/poster', methods=['GET']) def generate_poster_content(): """ - 홍보 포스터 생성 API (새로운 요구사항) - Java 서버에서 JSON 형태로 요청받아 OpenAI 이미지 URL 반환 + 홍보 포스터 생성 API (개선된 버전) + 원본 이미지 보존 + 한글 텍스트 오버레이 """ try: # JSON 요청 데이터 검증 @@ -130,7 +132,8 @@ def create_app(): ) # 포스터 생성 - result = poster_service.generate_poster(poster_request) + # result = poster_service.generate_poster(poster_request) + result = poster_service_v2.generate_poster(poster_request) if result['success']: return jsonify({'content': result['content']}) diff --git a/smarketing-ai/services/poster_service_v2.py b/smarketing-ai/services/poster_service_v2.py new file mode 100644 index 0000000..f70a0d5 --- /dev/null +++ b/smarketing-ai/services/poster_service_v2.py @@ -0,0 +1,382 @@ +""" +하이브리드 포스터 생성 서비스 +DALL-E: 텍스트 없는 아름다운 배경 생성 +PIL: 완벽한 한글 텍스트 오버레이 +""" +import os +from typing import Dict, Any +from datetime import datetime +from PIL import Image, ImageDraw, ImageFont, ImageEnhance +import requests +import io +from utils.ai_client import AIClient +from utils.image_processor import ImageProcessor +from models.request_models import PosterContentGetRequest + + +class PosterServiceV2: + """하이브리드 포스터 생성 서비스""" + + def __init__(self): + """서비스 초기화""" + self.ai_client = AIClient() + self.image_processor = ImageProcessor() + + def generate_poster(self, request: PosterContentGetRequest) -> Dict[str, Any]: + """ + 하이브리드 포스터 생성 + 1. DALL-E로 텍스트 없는 배경 생성 + 2. PIL로 완벽한 한글 텍스트 오버레이 + """ + try: + # 1. 참조 이미지 분석 + image_analysis = self._analyze_reference_images(request.images) + + # 2. DALL-E로 텍스트 없는 배경 생성 + background_prompt = self._create_background_only_prompt(request, image_analysis) + background_url = self.ai_client.generate_image_with_openai(background_prompt, "1024x1024") + + # 3. 배경 이미지 다운로드 + background_image = self._download_and_load_image(background_url) + + # 4. AI로 텍스트 컨텐츠 생성 + text_content = self._generate_text_content(request) + + # 5. PIL로 한글 텍스트 오버레이 + final_poster = self._add_perfect_korean_text(background_image, text_content, request) + + # 6. 최종 이미지 저장 + poster_url = self._save_final_poster(final_poster) + + return { + 'success': True, + 'content': poster_url + } + + except Exception as e: + return { + 'success': False, + 'error': str(e) + } + + def _create_background_only_prompt(self, request: PosterContentGetRequest, image_analysis: Dict[str, Any]) -> str: + """텍스트 완전 제외 배경 전용 프롬프트""" + + # 참조 이미지 설명 + reference_descriptions = [] + for result in image_analysis.get('results', []): + if 'description' in result: + reference_descriptions.append(result['description']) + + prompt = f""" +Create a beautiful text-free background design for a Korean restaurant promotional poster. + +ABSOLUTE REQUIREMENTS: +- NO TEXT, NO LETTERS, NO WORDS, NO CHARACTERS of any kind +- Pure visual background design only +- Professional Korean food business aesthetic +- Leave clear areas for text overlay (top 20% and bottom 30%) + +DESIGN STYLE: +- Category: {request.category} themed design +- Photo Style: {request.photoStyle or 'modern'} aesthetic +- Mood: {request.toneAndManner or 'friendly'} atmosphere +- Intensity: {request.emotionIntensity or 'medium'} visual impact + +VISUAL ELEMENTS TO INCLUDE: +- Korean traditional patterns or modern geometric designs +- Food-related visual elements (ingredients, cooking utensils, abstract food shapes) +- Warm, appetizing color palette +- Professional restaurant branding feel +- Clean, modern layout structure + +REFERENCE CONTEXT: +{chr(10).join(reference_descriptions) if reference_descriptions else 'Clean, professional food business design'} + +COMPOSITION: +- Central visual focus area +- Clear top section for main title +- Clear bottom section for details +- Balanced negative space +- High-end restaurant poster aesthetic + +STRICTLY AVOID: +- Any form of text (Korean, English, numbers, symbols) +- Menu boards or signs with text +- Price displays +- Written content of any kind +- Typography elements + +Create a premium, appetizing background that will make customers want to visit the restaurant. +Focus on visual appeal, color harmony, and professional food business branding. +""" + return prompt + + def _download_and_load_image(self, image_url: str) -> Image.Image: + """이미지 URL에서 PIL 이미지로 로드""" + response = requests.get(image_url, timeout=30) + response.raise_for_status() + return Image.open(io.BytesIO(response.content)) + + def _generate_text_content(self, request: PosterContentGetRequest) -> Dict[str, str]: + """AI로 포스터 텍스트 컨텐츠 생성""" + prompt = f""" +한국 음식점 홍보 포스터용 텍스트를 생성해주세요. + +포스터 정보: +- 제목: {request.title} +- 카테고리: {request.category} +- 메뉴명: {request.menuName or ''} +- 이벤트명: {request.eventName or ''} +- 시작일: {request.startDate or ''} +- 종료일: {request.endDate or ''} + +다음 형식으로만 답변해주세요: +메인제목: [임팩트 있는 제목 8자 이내] +서브제목: [설명 문구 15자 이내] +기간정보: [기간 표시] +액션문구: [행동유도 8자 이내] +""" + + try: + ai_response = self.ai_client.generate_text(prompt, max_tokens=150) + return self._parse_text_content(ai_response, request) + except: + return self._create_fallback_content(request) + + def _parse_text_content(self, ai_response: str, request: PosterContentGetRequest) -> Dict[str, str]: + """AI 응답 파싱""" + content = { + 'main_title': request.title[:8], + 'sub_title': '', + 'period_info': '', + 'action_text': '지금 확인!' + } + + lines = ai_response.split('\n') + for line in lines: + line = line.strip() + if '메인제목:' in line: + content['main_title'] = line.split('메인제목:')[1].strip() + elif '서브제목:' in line: + content['sub_title'] = line.split('서브제목:')[1].strip() + elif '기간정보:' in line: + content['period_info'] = line.split('기간정보:')[1].strip() + elif '액션문구:' in line: + content['action_text'] = line.split('액션문구:')[1].strip() + + return content + + def _create_fallback_content(self, request: PosterContentGetRequest) -> Dict[str, str]: + """AI 실패시 기본 컨텐츠""" + return { + 'main_title': request.title[:8] if request.title else '특별 이벤트', + 'sub_title': request.eventName or request.menuName or '맛있는 음식', + 'period_info': f"{request.startDate} ~ {request.endDate}" if request.startDate and request.endDate else '', + 'action_text': '지금 방문!' + } + + def _add_perfect_korean_text(self, background: Image.Image, content: Dict[str, str], request: PosterContentGetRequest) -> Image.Image: + """완벽한 한글 텍스트 오버레이""" + + # 배경 이미지 복사 + poster = background.copy() + draw = ImageDraw.Draw(poster) + width, height = poster.size + + # 한글 폰트 로드 (여러 경로 시도) + fonts = self._load_korean_fonts() + + # 텍스트 색상 결정 (배경 분석 기반) + text_color = self._determine_text_color(background) + shadow_color = (0, 0, 0) if text_color == (255, 255, 255) else (255, 255, 255) + + # 1. 메인 제목 (상단) + if content['main_title']: + self._draw_text_with_effects( + draw, content['main_title'], + fonts['title'], text_color, shadow_color, + width // 2, height * 0.15, 'center' + ) + + # 2. 서브 제목 + if content['sub_title']: + self._draw_text_with_effects( + draw, content['sub_title'], + fonts['subtitle'], text_color, shadow_color, + width // 2, height * 0.75, 'center' + ) + + # 3. 기간 정보 + if content['period_info']: + self._draw_text_with_effects( + draw, content['period_info'], + fonts['small'], text_color, shadow_color, + width // 2, height * 0.82, 'center' + ) + + # 4. 액션 문구 (강조 배경) + if content['action_text']: + self._draw_call_to_action( + draw, content['action_text'], + fonts['subtitle'], width, height + ) + + return poster + + def _load_korean_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]: + """한글 폰트 로드 (여러 경로 시도)""" + font_paths = [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/System/Library/Fonts/Arial.ttf", # macOS + "C:/Windows/Fonts/arial.ttf", # Windows + "/usr/share/fonts/TTF/arial.ttf" # Linux + ] + + fonts = {} + + for font_path in font_paths: + try: + fonts['title'] = ImageFont.truetype(font_path, 60) + fonts['subtitle'] = ImageFont.truetype(font_path, 32) + fonts['small'] = ImageFont.truetype(font_path, 24) + break + except: + continue + + # 폰트 로드 실패시 기본 폰트 + if not fonts: + fonts = { + 'title': ImageFont.load_default(), + 'subtitle': ImageFont.load_default(), + 'small': ImageFont.load_default() + } + + return fonts + + def _determine_text_color(self, image: Image.Image) -> tuple: + """배경 이미지 분석하여 텍스트 색상 결정""" + # 이미지 상단과 하단의 평균 밝기 계산 + top_region = image.crop((0, 0, image.width, image.height // 4)) + bottom_region = image.crop((0, image.height * 3 // 4, image.width, image.height)) + + def get_brightness(img_region): + grayscale = img_region.convert('L') + pixels = list(grayscale.getdata()) + return sum(pixels) / len(pixels) + + top_brightness = get_brightness(top_region) + bottom_brightness = get_brightness(bottom_region) + avg_brightness = (top_brightness + bottom_brightness) / 2 + + # 밝으면 검은색, 어두우면 흰색 텍스트 + return (50, 50, 50) if avg_brightness > 128 else (255, 255, 255) + + def _draw_text_with_effects(self, draw, text, font, color, shadow_color, x, y, align='center'): + """그림자 효과가 있는 텍스트 그리기""" + if not text: + return + + # 텍스트 크기 계산 + bbox = draw.textbbox((0, 0), text, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + + # 위치 조정 + if align == 'center': + x = x - text_width // 2 + + # 배경 박스 (가독성 향상) + padding = 10 + box_coords = [ + x - padding, y - padding, + x + text_width + padding, y + text_height + padding + ] + draw.rectangle(box_coords, fill=(0, 0, 0, 180)) + + # 그림자 효과 + shadow_offset = 2 + draw.text((x + shadow_offset, y + shadow_offset), text, fill=shadow_color, font=font) + + # 메인 텍스트 + draw.text((x, y), text, fill=color, font=font) + + def _draw_call_to_action(self, draw, text, font, width, height): + """강조된 액션 버튼 스타일 텍스트""" + if not text: + return + + # 버튼 위치 (하단 중앙) + button_y = height * 0.88 + + # 텍스트 크기 + bbox = draw.textbbox((0, 0), text, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + + # 버튼 배경 + button_width = text_width + 40 + button_height = text_height + 20 + button_x = (width - button_width) // 2 + + # 버튼 그리기 + button_coords = [ + button_x, button_y - 10, + button_x + button_width, button_y + button_height + ] + draw.rounded_rectangle(button_coords, radius=25, fill=(255, 107, 107)) + + # 텍스트 그리기 + text_x = (width - text_width) // 2 + text_y = button_y + 5 + draw.text((text_x, text_y), text, fill=(255, 255, 255), font=font) + + def _save_final_poster(self, poster: Image.Image) -> str: + """최종 포스터 저장""" + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + filename = f"hybrid_poster_{timestamp}.png" + filepath = os.path.join('uploads', 'temp', filename) + + os.makedirs(os.path.dirname(filepath), exist_ok=True) + poster.save(filepath, 'PNG', quality=95) + + return f"http://localhost:5001/uploads/temp/{filename}" + + def _analyze_reference_images(self, image_urls: list) -> Dict[str, Any]: + """참조 이미지 분석 (기존 코드와 동일)""" + if not image_urls: + return {'total_images': 0, 'results': []} + + analysis_results = [] + temp_files = [] + + try: + for image_url in image_urls: + temp_path = self.ai_client.download_image_from_url(image_url) + if temp_path: + temp_files.append(temp_path) + try: + image_description = self.ai_client.analyze_image(temp_path) + colors = self.image_processor.analyze_colors(temp_path, 3) + analysis_results.append({ + 'url': image_url, + 'description': image_description, + 'dominant_colors': colors + }) + except Exception as e: + analysis_results.append({ + 'url': image_url, + 'error': str(e) + }) + + return { + 'total_images': len(image_urls), + 'results': analysis_results + } + + finally: + for temp_file in temp_files: + try: + os.remove(temp_file) + except: + pass \ No newline at end of file diff --git a/smarketing-ai/utils/ai_client.py b/smarketing-ai/utils/ai_client.py index b4f032c..7b1fe52 100644 --- a/smarketing-ai/utils/ai_client.py +++ b/smarketing-ai/utils/ai_client.py @@ -80,8 +80,9 @@ class AIClient: response = self.openai_client.images.generate( model="dall-e-3", prompt=prompt, - size=size, - quality="standard", + size="1024x1024", + quality="hd", # 고품질 설정 + style="vivid", # 또는 "natural" n=1, ) From 96add68b745373ced7b48d3a673b1ad55c5d3de5 Mon Sep 17 00:00:00 2001 From: OhSeongRak Date: Fri, 13 Jun 2025 16:56:54 +0900 Subject: [PATCH 32/34] =?UTF-8?q?fix:=20temp=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...mp_1e7658c3-43ba-4d61-9bfc-4c1d9c2a5098.jpg | Bin 45047 -> 0 bytes ...mp_44b7841c-56e8-4c94-b769-4580f00f7723.jpg | Bin 137697 -> 0 bytes 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 smarketing-ai/uploads/temp/temp_1e7658c3-43ba-4d61-9bfc-4c1d9c2a5098.jpg delete mode 100644 smarketing-ai/uploads/temp/temp_44b7841c-56e8-4c94-b769-4580f00f7723.jpg diff --git a/smarketing-ai/uploads/temp/temp_1e7658c3-43ba-4d61-9bfc-4c1d9c2a5098.jpg b/smarketing-ai/uploads/temp/temp_1e7658c3-43ba-4d61-9bfc-4c1d9c2a5098.jpg deleted file mode 100644 index 3d8f70dbed03e797fa5b0cdec64c306eddc8be42..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 45047 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

aFIZ&~FPG?3bGBD>W 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 >q9%X+^M?`XhY*+yD<9?*Mzw!zU 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

(#D;WhY$735p7a+?&rrG{q{_TRP9|A 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 Date: Fri, 13 Jun 2025 16:57:35 +0900 Subject: [PATCH 33/34] =?UTF-8?q?refactor:=20=ED=99=8D=EB=B3=B4=20?= =?UTF-8?q?=ED=8F=AC=EC=8A=A4=ED=84=B0=20=EC=83=9D=EC=84=B1=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EB=B2=84=EC=A0=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- smarketing-ai/app.py | 39 +++++++++++++++++++------- smarketing-ai/config/config.py | 6 +++- smarketing-ai/models/request_models.py | 9 +++--- 3 files changed, 39 insertions(+), 15 deletions(-) diff --git a/smarketing-ai/app.py b/smarketing-ai/app.py index 05754aa..6a4b9d4 100644 --- a/smarketing-ai/app.py +++ b/smarketing-ai/app.py @@ -12,7 +12,7 @@ from config.config import Config from services.poster_service import PosterService from services.sns_content_service import SnsContentService from models.request_models import ContentRequest, PosterRequest, SnsContentGetRequest, PosterContentGetRequest -from services.poster_service_v2 import PosterServiceV2 +from services.poster_service_v3 import PosterServiceV3 def create_app(): @@ -30,7 +30,7 @@ def create_app(): # 서비스 인스턴스 생성 poster_service = PosterService() - poster_service_v2 = PosterServiceV2() + poster_service_v3 = PosterServiceV3() sns_content_service = SnsContentService() @app.route('/health', methods=['GET']) @@ -97,8 +97,8 @@ def create_app(): @app.route('/api/ai/poster', methods=['GET']) def generate_poster_content(): """ - 홍보 포스터 생성 API (개선된 버전) - 원본 이미지 보존 + 한글 텍스트 오버레이 + 홍보 포스터 생성 API + 실제 제품 이미지를 포함한 분위기 배경 포스터 생성 """ try: # JSON 요청 데이터 검증 @@ -115,6 +115,23 @@ def create_app(): if field not in data: return jsonify({'error': f'필수 필드가 누락되었습니다: {field}'}), 400 + # 날짜 변환 처리 + start_date = None + end_date = None + if data.get('startDate'): + try: + from datetime import datetime + start_date = datetime.strptime(data['startDate'], '%Y-%m-%d').date() + except ValueError: + return jsonify({'error': 'startDate 형식이 올바르지 않습니다. YYYY-MM-DD 형식을 사용하세요.'}), 400 + + if data.get('endDate'): + try: + from datetime import datetime + end_date = datetime.strptime(data['endDate'], '%Y-%m-%d').date() + except ValueError: + return jsonify({'error': 'endDate 형식이 올바르지 않습니다. YYYY-MM-DD 형식을 사용하세요.'}), 400 + # 요청 모델 생성 poster_request = PosterContentGetRequest( title=data.get('title'), @@ -127,16 +144,18 @@ def create_app(): emotionIntensity=data.get('emotionIntensity'), menuName=data.get('menuName'), eventName=data.get('eventName'), - startDate=data.get('startDate'), - endDate=data.get('endDate') + startDate=start_date, + endDate=end_date ) - # 포스터 생성 - # result = poster_service.generate_poster(poster_request) - result = poster_service_v2.generate_poster(poster_request) + # 포스터 생성 (V3 사용) + result = poster_service_v3.generate_poster(poster_request) if result['success']: - return jsonify({'content': result['content']}) + return jsonify({ + 'content': result['content'], + 'analysis': result.get('analysis', {}) + }) else: return jsonify({'error': result['error']}), 500 diff --git a/smarketing-ai/config/config.py b/smarketing-ai/config/config.py index de6f276..6b63540 100644 --- a/smarketing-ai/config/config.py +++ b/smarketing-ai/config/config.py @@ -4,7 +4,10 @@ Flask 애플리케이션 설정 """ import os from dotenv import load_dotenv + load_dotenv() + + class Config: """애플리케이션 설정 클래스""" # Flask 기본 설정 @@ -19,8 +22,9 @@ class Config: ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'} # 템플릿 설정 POSTER_TEMPLATE_PATH = 'templates/poster_templates' + @staticmethod def allowed_file(filename): """업로드 파일 확장자 검증""" return '.' in filename and \ - filename.rsplit('.', 1)[1].lower() in Config.ALLOWED_EXTENSIONS \ No newline at end of file + filename.rsplit('.', 1)[1].lower() in Config.ALLOWED_EXTENSIONS diff --git a/smarketing-ai/models/request_models.py b/smarketing-ai/models/request_models.py index b47b257..653dfb8 100644 --- a/smarketing-ai/models/request_models.py +++ b/smarketing-ai/models/request_models.py @@ -4,6 +4,7 @@ API 요청 데이터 구조를 정의 """ from dataclasses import dataclass from typing import List, Optional +from datetime import date @dataclass @@ -19,8 +20,8 @@ class SnsContentGetRequest: emotionIntensity: Optional[str] = None menuName: Optional[str] = None eventName: Optional[str] = None - startDate: Optional[str] = None - endDate: Optional[str] = None + startDate: Optional[date] = None # LocalDate -> date + endDate: Optional[date] = None # LocalDate -> date @dataclass @@ -36,8 +37,8 @@ class PosterContentGetRequest: emotionIntensity: Optional[str] = None menuName: Optional[str] = None eventName: Optional[str] = None - startDate: Optional[str] = None - endDate: Optional[str] = None + startDate: Optional[date] = None # LocalDate -> date + endDate: Optional[date] = None # LocalDate -> date # 기존 모델들은 유지 From 5678ac3dea55d62ba2adb5143692755fb87dcc2e Mon Sep 17 00:00:00 2001 From: OhSeongRak Date: Fri, 13 Jun 2025 16:57:49 +0900 Subject: [PATCH 34/34] =?UTF-8?q?feat:=20=ED=99=8D=EB=B3=B4=20=ED=8F=AC?= =?UTF-8?q?=EC=8A=A4=ED=84=B0=20=EC=83=9D=EC=84=B1=20=EB=B2=84=EC=A0=843?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- smarketing-ai/services/poster_service_v3.py | 204 ++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 smarketing-ai/services/poster_service_v3.py diff --git a/smarketing-ai/services/poster_service_v3.py b/smarketing-ai/services/poster_service_v3.py new file mode 100644 index 0000000..d55662c --- /dev/null +++ b/smarketing-ai/services/poster_service_v3.py @@ -0,0 +1,204 @@ +""" +포스터 생성 서비스 V3 +OpenAI DALL-E를 사용한 이미지 생성 (메인 메뉴 이미지 1개 + 프롬프트 내 예시 링크 10개) +""" +import os +from typing import Dict, Any, List +from datetime import datetime +from utils.ai_client import AIClient +from utils.image_processor import ImageProcessor +from models.request_models import PosterContentGetRequest + + +class PosterServiceV3: + """포스터 생성 서비스 V3 클래스""" + + def __init__(self): + """서비스 초기화""" + self.ai_client = AIClient() + self.image_processor = ImageProcessor() + + # Azure Blob Storage 예시 이미지 링크 10개 (카페 음료 관련) + self.example_images = [ + "https://stdigitalgarage02.blob.core.windows.net/ai-content/example1.png", + "https://stdigitalgarage02.blob.core.windows.net/ai-content/example2.png", + "https://stdigitalgarage02.blob.core.windows.net/ai-content/example3.png", + "https://stdigitalgarage02.blob.core.windows.net/ai-content/example4.png", + "https://stdigitalgarage02.blob.core.windows.net/ai-content/example5.png", + "https://stdigitalgarage02.blob.core.windows.net/ai-content/example6.png", + "https://stdigitalgarage02.blob.core.windows.net/ai-content/example7.png" + ] + + # 포토 스타일별 프롬프트 + self.photo_styles = { + '미니멀': '미니멀하고 깔끔한 디자인, 단순함, 여백 활용', + '모던': '현대적이고 세련된 디자인, 깔끔한 레이아웃', + '빈티지': '빈티지 느낌, 레트로 스타일, 클래식한 색감', + '컬러풀': '다채로운 색상, 밝고 생동감 있는 컬러', + '우아한': '우아하고 고급스러운 느낌, 세련된 분위기', + '캐주얼': '친근하고 편안한 느낌, 접근하기 쉬운 디자인' + } + + # 카테고리별 이미지 스타일 + self.category_styles = { + '음식': '음식 사진, 먹음직스러운, 맛있어 보이는', + '매장': '레스토랑 인테리어, 아늑한 분위기', + '이벤트': '홍보용 디자인, 눈길을 끄는', + '메뉴': '메뉴 디자인, 정리된 레이아웃', + '할인': '세일 포스터, 할인 디자인', + '음료': '시원하고 상쾌한, 맛있어 보이는 음료' + } + + # 톤앤매너별 디자인 스타일 + self.tone_styles = { + '친근한': '따뜻하고 친근한 색감, 부드러운 느낌', + '정중한': '격식 있고 신뢰감 있는 디자인', + '재미있는': '밝고 유쾌한 분위기, 활기찬 색상', + '전문적인': '전문적이고 신뢰할 수 있는 디자인' + } + + # 감정 강도별 디자인 + self.emotion_designs = { + '약함': '은은하고 차분한 색감, 절제된 표현', + '보통': '적당히 활기찬 색상, 균형잡힌 디자인', + '강함': '강렬하고 임팩트 있는 색상, 역동적인 디자인' + } + + def generate_poster(self, request: PosterContentGetRequest) -> Dict[str, Any]: + """ + 포스터 생성 (메인 이미지 1개 분석 + 예시 링크 10개 프롬프트 제공) + """ + try: + # 메인 이미지 확인 + if not request.images: + return {'success': False, 'error': '메인 메뉴 이미지가 제공되지 않았습니다.'} + + main_image_url = request.images[0] # 첫 번째 이미지가 메인 메뉴 + + # 메인 이미지 분석 + main_image_analysis = self._analyze_main_image(main_image_url) + + # 포스터 생성 프롬프트 생성 (예시 링크 10개 포함) + prompt = self._create_poster_prompt_v3(request, main_image_analysis) + + # OpenAI로 이미지 생성 + image_url = self.ai_client.generate_image_with_openai(prompt, "1024x1024") + + return { + 'success': True, + 'content': image_url, + 'analysis': { + 'main_image': main_image_analysis, + 'example_images_used': len(self.example_images) + } + } + + except Exception as e: + return { + 'success': False, + 'error': str(e) + } + + def _analyze_main_image(self, image_url: str) -> Dict[str, Any]: + """ + 메인 메뉴 이미지 분석 + """ + temp_files = [] + try: + # 이미지 다운로드 + temp_path = self.ai_client.download_image_from_url(image_url) + if temp_path: + temp_files.append(temp_path) + + # 이미지 분석 + image_info = self.image_processor.get_image_info(temp_path) + image_description = self.ai_client.analyze_image(temp_path) + colors = self.image_processor.analyze_colors(temp_path, 5) + + return { + 'url': image_url, + 'info': image_info, + 'description': image_description, + 'dominant_colors': colors, + 'is_food': self.image_processor.is_food_image(temp_path) + } + else: + return { + 'url': image_url, + 'error': '이미지 다운로드 실패' + } + + except Exception as e: + return { + 'url': image_url, + 'error': str(e) + } + finally: + # 임시 파일 정리 + for temp_file in temp_files: + try: + os.remove(temp_file) + except: + pass + + def _create_poster_prompt_v3(self, request: PosterContentGetRequest, + main_analysis: Dict[str, Any]) -> str: + """ + V3 포스터 생성을 위한 AI 프롬프트 생성 (한글, 글자 완전 제외, 메인 이미지 기반 + 예시 링크 10개 포함) + """ + # 기본 스타일 설정 + photo_style = self.photo_styles.get(request.photoStyle, '현대적이고 깔끔한 디자인') + category_style = self.category_styles.get(request.category, '홍보용 디자인') + tone_style = self.tone_styles.get(request.toneAndManner, '친근하고 따뜻한 느낌') + emotion_design = self.emotion_designs.get(request.emotionIntensity, '적당히 활기찬 디자인') + + # 메인 이미지 정보 활용 + main_description = main_analysis.get('description', '맛있는 음식') + main_colors = main_analysis.get('dominant_colors', []) + main_image_url = main_analysis.get('url', '') + image_info = main_analysis.get('info', {}) + is_food = main_analysis.get('is_food', False) + + # 이미지 크기 및 비율 정보 + aspect_ratio = image_info.get('aspect_ratio', 1.0) if image_info else 1.0 + image_orientation = "가로형" if aspect_ratio > 1.2 else "세로형" if aspect_ratio < 0.8 else "정사각형" + + # 색상 정보를 텍스트로 변환 + color_description = "" + if main_colors: + color_rgb = main_colors[:3] # 상위 3개 색상 + color_description = f"주요 색상 RGB 값: {color_rgb}를 기반으로 한 조화로운 색감" + + # 예시 이미지 링크들을 문자열로 변환 + example_links = "\n".join([f"- {link}" for link in self.example_images]) + + prompt = f""" + 메인 이미지 URL을 참조하여, "글이 없는" 심플한 카페 포스터를 디자인해주세요. + + **핵심 기준 이미지:** + 메인 이미지 URL: {main_image_url} + 이 이미지 URL에 들어가 이미지를 다운로드 후, 이 이미지를 그대로 반영한 채 홍보 포스터를 디자인해주세요. + 심플한 배경이 중요합니다. + AI가 생성하지 않은 것처럼 현실적인 요소를 반영해주세요. + + **절대 필수 조건:** + - 어떤 형태의 텍스트, 글자, 문자, 숫자도 절대 포함하지 말 것!!!! - 가장 중요 + - 위의 메인 이미지를 임의 변경 없이, 포스터의 중심 요소로 포함할 것 + - 하나의 포스터만 생성해주세요 + - 메인 이미지의 색감과 분위기를 살려서 심플한 포스터 디자인 + - 메인 이미지가 돋보이도록 배경과 레이아웃 구성 + - 확실하지도 않은 문자 절대 생성 x + + **특별 요구사항:** + {request.requirement} + + + + **반드시 제외할 요소:** + - 모든 형태의 텍스트 (한글, 영어, 숫자, 기호) + - 메뉴판, 가격표, 간판 + - 글자가 적힌 모든 요소 + - 브랜드 로고나 문자 + + """ + return prompt