diff --git a/smarketing-ai/app.py b/smarketing-ai/app.py index d3c91da..e96f33a 100644 --- a/smarketing-ai/app.py +++ b/smarketing-ai/app.py @@ -98,7 +98,7 @@ def create_app(): app.logger.error(traceback.format_exc()) return jsonify({'error': f'SNS 콘텐츠 생성 중 오류가 발생했습니다: {str(e)}'}), 500 - @app.route('/api/ai/poster', methods=['GET']) + @app.route('/api/ai/poster', methods=['POST']) def generate_poster_content(): """ 홍보 포스터 생성 API @@ -114,7 +114,7 @@ def create_app(): return jsonify({'error': '요청 데이터가 없습니다.'}), 400 # 필수 필드 검증 - required_fields = ['title', 'category', 'contentType', 'images'] + required_fields = ['title', 'category', 'images'] for field in required_fields: if field not in data: return jsonify({'error': f'필수 필드가 누락되었습니다: {field}'}), 400 @@ -140,12 +140,9 @@ def create_app(): 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'), menuName=data.get('menuName'), eventName=data.get('eventName'), startDate=start_date, diff --git a/smarketing-ai/models/request_models.py b/smarketing-ai/models/request_models.py index 3f6952d..b21a4e1 100644 --- a/smarketing-ai/models/request_models.py +++ b/smarketing-ai/models/request_models.py @@ -33,12 +33,9 @@ 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 menuName: Optional[str] = None eventName: Optional[str] = None startDate: Optional[date] = None # LocalDate -> date diff --git a/smarketing-ai/services/poster_service.py b/smarketing-ai/services/poster_service.py index c90119c..3dec55e 100644 --- a/smarketing-ai/services/poster_service.py +++ b/smarketing-ai/services/poster_service.py @@ -154,7 +154,6 @@ class PosterService: ### 📋 기본 정보 카테고리: {request.category} - 콘텐츠 타입: {request.contentType} 메뉴명: {request.menuName or '없음'} 메뉴 정보: {main_description} diff --git a/smarketing-java/build.gradle b/smarketing-java/build.gradle index e917ca4..01426b7 100644 --- a/smarketing-java/build.gradle +++ b/smarketing-java/build.gradle @@ -53,6 +53,15 @@ subprojects { implementation 'com.azure:azure-messaging-eventhubs-checkpointstore-blob:1.19.0' implementation 'com.azure:azure-identity:1.11.4' + // Azure Blob Storage 의존성 추가 + implementation 'com.azure:azure-storage-blob:12.25.0' + implementation 'com.azure:azure-identity:1.11.1' + + implementation 'com.fasterxml.jackson.core:jackson-core' + implementation 'com.fasterxml.jackson.core:jackson-databind' + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' + implementation 'org.springframework.boot:spring-boot-starter-webflux' + } tasks.named('test') { diff --git a/smarketing-java/deployment/deploy.yaml.template b/smarketing-java/deployment/deploy.yaml.template index 9b8d945..2a2defd 100644 --- a/smarketing-java/deployment/deploy.yaml.template +++ b/smarketing-java/deployment/deploy.yaml.template @@ -462,6 +462,7 @@ spec: type: ClusterIP --- +# deploy.yaml.template의 Ingress 부분 - 완전한 설정 apiVersion: networking.k8s.io/v1 kind: Ingress metadata: @@ -478,6 +479,7 @@ spec: - host: smarketing.20.249.184.228.nip.io http: paths: + # Member 서비스 - 인증 관련 - path: /api/auth(/|$)(.*) pathType: ImplementationSpecific backend: @@ -485,6 +487,15 @@ spec: name: member port: number: 80 + # Member 서비스 - 회원 관리 (누락된 경로!) + - path: /api/member(/|$)(.*) + pathType: ImplementationSpecific + backend: + service: + name: member + port: + number: 80 + # Store 서비스 - path: /api/store(/|$)(.*) pathType: ImplementationSpecific backend: @@ -492,6 +503,31 @@ spec: name: store port: number: 80 + # Store 서비스 - 매출 관련 + - path: /api/sales(/|$)(.*) + pathType: ImplementationSpecific + backend: + service: + name: store + port: + number: 80 + # Store 서비스 - 메뉴 관련 + - path: /api/menu(/|$)(.*) + pathType: ImplementationSpecific + backend: + service: + name: store + port: + number: 80 + # Store 서비스 - 이미지 업로드 + - path: /api/images(/|$)(.*) + pathType: ImplementationSpecific + backend: + service: + name: store + port: + number: 80 + # Marketing Content 서비스 - path: /api/content(/|$)(.*) pathType: ImplementationSpecific backend: @@ -499,6 +535,7 @@ spec: name: marketing-content port: number: 80 + # AI Recommend 서비스 - path: /api/recommend(/|$)(.*) pathType: ImplementationSpecific backend: diff --git a/smarketing-java/marketing-content/build.gradle b/smarketing-java/marketing-content/build.gradle index 715bc47..188d7bd 100644 --- a/smarketing-java/marketing-content/build.gradle +++ b/smarketing-java/marketing-content/build.gradle @@ -1,7 +1,4 @@ dependencies { implementation project(':common') runtimeOnly 'org.postgresql:postgresql' - - // WebClient를 위한 Spring WebFlux 의존성 - implementation 'org.springframework.boot:spring-boot-starter-webflux' } \ No newline at end of file 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 55a1b19..94c5362 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 @@ -8,28 +8,36 @@ import com.won.smarketing.content.domain.model.CreationConditions; import com.won.smarketing.content.domain.model.Platform; import com.won.smarketing.content.domain.repository.ContentRepository; import com.won.smarketing.content.domain.service.AiPosterGenerator; +import com.won.smarketing.content.domain.service.BlobStorageService; import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest; import com.won.smarketing.content.presentation.dto.PosterContentCreateResponse; import com.won.smarketing.content.presentation.dto.PosterContentSaveRequest; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; -import java.time.LocalDateTime; import java.util.HashMap; -import java.util.Map; +import java.util.List; /** * 포스터 콘텐츠 서비스 구현체 * 홍보 포스터 생성 및 저장 기능 구현 */ @Service +@Slf4j @RequiredArgsConstructor @Transactional(readOnly = true) public class PosterContentService implements PosterContentUseCase { + @Value("${azure.storage.container.poster-images:poster-images}") + private String posterImageContainer; + private final ContentRepository contentRepository; private final AiPosterGenerator aiPosterGenerator; + private final BlobStorageService blobStorageService; /** * 포스터 콘텐츠 생성 @@ -39,26 +47,20 @@ public class PosterContentService implements PosterContentUseCase { */ @Override @Transactional - public PosterContentCreateResponse generatePosterContent(PosterContentCreateRequest request) { + public PosterContentCreateResponse generatePosterContent(List images, PosterContentCreateRequest request) { + // 1. 이미지 blob storage에 저장하고 request 저장 + List imageUrls = blobStorageService.uploadImage(images, posterImageContainer); + request.setImages(imageUrls); + + // 2. AI 요청 String generatedPoster = aiPosterGenerator.generatePoster(request); - // 생성 조건 정보 구성 - CreationConditions conditions = CreationConditions.builder() - .category(request.getCategory()) - .requirement(request.getRequirement()) - .eventName(request.getEventName()) - .startDate(request.getStartDate()) - .endDate(request.getEndDate()) - .photoStyle(request.getPhotoStyle()) - .build(); - return PosterContentCreateResponse.builder() .contentId(null) // 임시 생성이므로 ID 없음 .contentType(ContentType.POSTER.name()) .title(request.getTitle()) - .posterImage(generatedPoster) - .posterSizes(new HashMap<>()) // 빈 맵 반환 (사이즈 변환 안함) + .content(generatedPoster) .status(ContentStatus.DRAFT.name()) .build(); } @@ -68,9 +70,8 @@ public class PosterContentService implements PosterContentUseCase { * * @param request 포스터 콘텐츠 저장 요청 */ - @Override @Transactional - public void savePosterContent(PosterContentSaveRequest request) { + public Content savePosterContent(PosterContentSaveRequest request) { // 생성 조건 구성 CreationConditions conditions = CreationConditions.builder() .category(request.getCategory()) @@ -84,8 +85,9 @@ public class PosterContentService implements PosterContentUseCase { // 콘텐츠 엔티티 생성 Content content = Content.builder() .contentType(ContentType.POSTER) + .platform(Platform.GENERAL) .title(request.getTitle()) - .content(request.getContent()) +// .content(request.gen) .images(request.getImages()) .status(ContentStatus.PUBLISHED) .creationConditions(conditions) @@ -93,6 +95,6 @@ public class PosterContentService implements PosterContentUseCase { .build(); // 저장 - contentRepository.save(content); + return contentRepository.save(content); } } \ 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 074b39b..233bd5d 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 @@ -9,12 +9,14 @@ import com.won.smarketing.content.domain.model.CreationConditions; import com.won.smarketing.content.domain.model.Platform; import com.won.smarketing.content.domain.repository.ContentRepository; import com.won.smarketing.content.domain.service.AiContentGenerator; +import com.won.smarketing.content.domain.service.BlobStorageService; import com.won.smarketing.content.presentation.dto.SnsContentCreateRequest; import com.won.smarketing.content.presentation.dto.SnsContentCreateResponse; import com.won.smarketing.content.presentation.dto.SnsContentSaveRequest; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; import java.time.LocalDateTime; import java.util.List; @@ -30,6 +32,7 @@ public class SnsContentService implements SnsContentUseCase { private final ContentRepository contentRepository; private final AiContentGenerator aiContentGenerator; + private final BlobStorageService blobStorageService; /** * SNS 콘텐츠 생성 @@ -39,14 +42,17 @@ public class SnsContentService implements SnsContentUseCase { */ @Override @Transactional - public SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request) { + public SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request, List files) { + //파일들 주소 가져옴 + List urls = blobStorageService.uploadImage(files, "containerName"); + request.setImages(urls); + // AI를 사용하여 SNS 콘텐츠 생성 String content = aiContentGenerator.generateSnsContent(request); return SnsContentCreateResponse.builder() .content(content) .build(); - } /** 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 6bf2960..7f346e3 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,9 +1,13 @@ // 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.domain.model.Content; import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest; import com.won.smarketing.content.presentation.dto.PosterContentCreateResponse; import com.won.smarketing.content.presentation.dto.PosterContentSaveRequest; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; /** * 포스터 콘텐츠 관련 UseCase 인터페이스 @@ -16,11 +20,11 @@ public interface PosterContentUseCase { * @param request 포스터 콘텐츠 생성 요청 * @return 포스터 콘텐츠 생성 응답 */ - PosterContentCreateResponse generatePosterContent(PosterContentCreateRequest request); + PosterContentCreateResponse generatePosterContent(List images, PosterContentCreateRequest request); /** * 포스터 콘텐츠 저장 * @param request 포스터 콘텐츠 저장 요청 */ - void savePosterContent(PosterContentSaveRequest request); + Content 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 d2c6751..23c23f1 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 @@ -4,6 +4,9 @@ package com.won.smarketing.content.application.usecase; import com.won.smarketing.content.presentation.dto.SnsContentCreateRequest; import com.won.smarketing.content.presentation.dto.SnsContentCreateResponse; import com.won.smarketing.content.presentation.dto.SnsContentSaveRequest; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; /** * SNS 콘텐츠 관련 UseCase 인터페이스 @@ -16,7 +19,7 @@ public interface SnsContentUseCase { * @param request SNS 콘텐츠 생성 요청 * @return SNS 콘텐츠 생성 응답 */ - SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request); + SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request, List files); /** * SNS 콘텐츠 저장 diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/AzureBlobStorageConfig.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/AzureBlobStorageConfig.java new file mode 100644 index 0000000..09d27bd --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/AzureBlobStorageConfig.java @@ -0,0 +1,72 @@ +// store/src/main/java/com/won/smarketing/store/config/AzureBlobStorageConfig.java +package com.won.smarketing.content.config; + +import com.azure.identity.DefaultAzureCredentialBuilder; +import com.azure.storage.blob.BlobServiceClient; +import com.azure.storage.blob.BlobServiceClientBuilder; +import com.azure.storage.common.StorageSharedKeyCredential; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Azure Blob Storage 설정 클래스 + * Azure Blob Storage와의 연결을 위한 설정 + */ +@Configuration +@Slf4j +public class AzureBlobStorageConfig { + + @Value("${azure.storage.account-name}") + private String accountName; + + @Value("${azure.storage.account-key:}") + private String accountKey; + + @Value("${azure.storage.endpoint:}") + private String endpoint; + + /** + * Azure Blob Storage Service Client 생성 + * + * @return BlobServiceClient 인스턴스 + */ + @Bean + public BlobServiceClient blobServiceClient() { + try { + // Managed Identity 사용 시 (Azure 환경에서 권장) + if (accountKey == null || accountKey.isEmpty()) { + log.info("Azure Blob Storage 연결 - Managed Identity 사용"); + return new BlobServiceClientBuilder() + .endpoint(getEndpoint()) + .credential(new DefaultAzureCredentialBuilder().build()) + .buildClient(); + } + + // Account Key 사용 시 (개발 환경용) + log.info("Azure Blob Storage 연결 - Account Key 사용"); + StorageSharedKeyCredential credential = new StorageSharedKeyCredential(accountName, accountKey); + return new BlobServiceClientBuilder() + .endpoint(getEndpoint()) + .credential(credential) + .buildClient(); + + } catch (Exception e) { + log.error("Azure Blob Storage 클라이언트 생성 실패", e); + throw new RuntimeException("Azure Blob Storage 연결 실패", e); + } + } + + /** + * Storage Account 엔드포인트 URL 생성 + * + * @return 엔드포인트 URL + */ + private String getEndpoint() { + if (endpoint != null && !endpoint.isEmpty()) { + return endpoint; + } + return String.format("https://%s.blob.core.windows.net", accountName); + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/WebClientConfig.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/WebClientConfig.java index 7f7cf08..8fb41fc 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/WebClientConfig.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/WebClientConfig.java @@ -1,4 +1,3 @@ -// marketing-content/src/main/java/com/won/smarketing/content/config/WebClientConfig.java package com.won.smarketing.content.config; import org.springframework.context.annotation.Bean; @@ -20,8 +19,8 @@ public class WebClientConfig { @Bean public WebClient webClient() { HttpClient httpClient = HttpClient.create() - .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000) - .responseTimeout(Duration.ofMillis(30000)); + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 15000) // 연결 타임아웃: 15초 + .responseTimeout(Duration.ofMinutes(5)); // 응답 타임아웃: 5분 return WebClient.builder() .clientConnector(new ReactorClientHttpConnector(httpClient)) 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 32b4231..1a453ef 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 @@ -27,42 +27,37 @@ import java.util.List; @Builder public class Content { - // ==================== 기본키 및 식별자 ==================== @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "content_id") private Long id; - // ==================== 콘텐츠 분류 ==================== private ContentType contentType; + private Platform platform; - // ==================== 콘텐츠 내용 ==================== private String title; + private String content; - // ==================== 멀티미디어 및 메타데이터 ==================== @Builder.Default private List hashtags = new ArrayList<>(); @Builder.Default private List images = new ArrayList<>(); - // ==================== 상태 관리 ==================== private ContentStatus status; - // ==================== 생성 조건 ==================== private CreationConditions creationConditions; - // ==================== 매장 정보 ==================== private Long storeId; - // ==================== 프로모션 기간 ==================== private LocalDateTime promotionStartDate; + private LocalDateTime promotionEndDate; - // ==================== 메타데이터 ==================== private LocalDateTime createdAt; + 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/CreationConditions.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java index a284c2c..b90959e 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 @@ -24,8 +24,6 @@ public class CreationConditions { private String id; private String category; private String requirement; -// private String toneAndManner; -// private String emotionIntensity; private String storeName; private String storeType; private String target; diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/BlobStorageService.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/BlobStorageService.java new file mode 100644 index 0000000..92a6daf --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/BlobStorageService.java @@ -0,0 +1,37 @@ +// store/src/main/java/com/won/smarketing/store/service/BlobStorageService.java +package com.won.smarketing.content.domain.service; + +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +/** + * Azure Blob Storage 서비스 인터페이스 + * 파일 업로드, 다운로드, 삭제 기능 정의 + */ +public interface BlobStorageService { + + /** + * 이미지 파일 업로드 + * + * @param file 업로드할 파일 + * @return 업로드된 파일의 URL + */ + List uploadImage(List file, String containerName); + + + /** + * 파일 삭제 + * + * @param fileUrl 삭제할 파일의 URL + * @return 삭제 성공 여부 + */ + //boolean deleteFile(String fileUrl); + + /** + * 컨테이너 존재 여부 확인 및 생성 + * + * @param containerName 컨테이너 이름 + */ + void ensureContainerExists(String containerName); +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/BlobStorageServiceImpl.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/BlobStorageServiceImpl.java new file mode 100644 index 0000000..c9b9d40 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/BlobStorageServiceImpl.java @@ -0,0 +1,245 @@ +// store/src/main/java/com/won/smarketing/store/service/BlobStorageServiceImpl.java +package com.won.smarketing.content.domain.service; + +import com.azure.core.util.BinaryData; +import com.azure.storage.blob.BlobClient; +import com.azure.storage.blob.BlobContainerClient; +import com.azure.storage.blob.BlobServiceClient; +import com.azure.storage.blob.models.BlobHttpHeaders; +import com.azure.storage.blob.models.PublicAccessType; +import com.won.smarketing.common.exception.BusinessException; +import com.won.smarketing.common.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +/** + * Azure Blob Storage 서비스 구현체 + * 이미지 파일 업로드, 삭제 기능 구현 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class BlobStorageServiceImpl implements BlobStorageService { + + private final BlobServiceClient blobServiceClient; + + @Value("${azure.storage.max-file-size:10485760}") // 10MB + private long maxFileSize; + + // 허용되는 이미지 확장자 + private static final List ALLOWED_EXTENSIONS = Arrays.asList( + "jpg", "jpeg", "png", "gif", "bmp", "webp" + ); + + // 허용되는 MIME 타입 + private static final List ALLOWED_MIME_TYPES = Arrays.asList( + "image/jpeg", "image/png", "image/gif", "image/bmp", "image/webp" + ); + + /** + * 이미지 파일 업로드 + * + * @param files 업로드할 파일들 + * @return 업로드된 파일의 URL + */ + @Override + public List uploadImage(List files, String containerName) { + // 파일 유효성 검증 + validateImageFile(files); + List urls = new ArrayList<>(); + + try { + // 컨테이너 존재 확인 및 생성 + for(MultipartFile file : files) { + String fileName = generateMenuImageFileName(file.getOriginalFilename()); + + ensureContainerExists(containerName); + + // Blob 클라이언트 생성 + BlobContainerClient containerClient = blobServiceClient.getBlobContainerClient(containerName); + BlobClient blobClient = containerClient.getBlobClient(fileName); + + // 파일 업로드 (간단한 방식) + BinaryData binaryData = BinaryData.fromBytes(file.getBytes()); + + // 파일 업로드 실행 (덮어쓰기 허용) + blobClient.upload(binaryData, true); + + // Content-Type 설정 + BlobHttpHeaders headers = new BlobHttpHeaders().setContentType(file.getContentType()); + blobClient.setHttpHeaders(headers); + + String fileUrl = blobClient.getBlobUrl(); + log.info("이미지 업로드 성공: {}", fileUrl); + + urls.add(fileUrl); + } + return urls; + + } catch (IOException e) { + log.error("이미지 업로드 실패 - 파일 읽기 오류: {}", e.getMessage()); + throw new BusinessException(ErrorCode.FILE_UPLOAD_FAILED); + } catch (Exception e) { + log.error("이미지 업로드 실패: {}", e.getMessage()); + throw new BusinessException(ErrorCode.FILE_UPLOAD_FAILED); + } + } + + /** + * 파일 삭제 + * + * @param fileUrl 삭제할 파일의 URL + */ +// @Override + public void deleteFile(String fileUrl) { + try { + // URL에서 컨테이너명과 파일명 추출 + String[] urlParts = extractContainerAndFileName(fileUrl); + String containerName = urlParts[0]; + String fileName = urlParts[1]; + + BlobContainerClient containerClient = blobServiceClient.getBlobContainerClient(containerName); + BlobClient blobClient = containerClient.getBlobClient(fileName); + + boolean deleted = blobClient.deleteIfExists(); + + if (deleted) { + log.info("파일 삭제 성공: {}", fileUrl); + } else { + log.warn("파일이 존재하지 않음: {}", fileUrl); + } + + } catch (Exception e) { + log.error("파일 삭제 실패: {}", e.getMessage()); + } + } + + /** + * 컨테이너 존재 여부 확인 및 생성 + * + * @param containerName 컨테이너 이름 + */ + @Override + public void ensureContainerExists(String containerName) { + try { + BlobContainerClient containerClient = blobServiceClient.getBlobContainerClient(containerName); + + if (!containerClient.exists()) { + containerClient.createWithResponse(null, PublicAccessType.BLOB, null, null); + log.info("컨테이너 생성 완료: {}", containerName); + } + + } catch (Exception e) { + log.error("컨테이너 생성 실패: {}", e.getMessage()); + throw new BusinessException(ErrorCode.STORAGE_CONTAINER_ERROR); + } + } + + /** + * 이미지 파일 유효성 검증 + * + * @param files 검증할 파일 + */ + private void validateImageFile(List files) { + // 파일 존재 여부 확인 + if (files == null || files.isEmpty()) { + throw new BusinessException(ErrorCode.FILE_NOT_FOUND); + } + + for (MultipartFile file : files) { + // 파일 크기 확인 + if (file.getSize() > maxFileSize) { + throw new BusinessException(ErrorCode.FILE_SIZE_EXCEEDED); + } + + // 파일 확장자 확인 + String originalFilename = file.getOriginalFilename(); + if (originalFilename == null) { + throw new BusinessException(ErrorCode.INVALID_FILE_NAME); + } + + String extension = getFileExtension(originalFilename).toLowerCase(); + if (!ALLOWED_EXTENSIONS.contains(extension)) { + throw new BusinessException(ErrorCode.INVALID_FILE_EXTENSION); + } + + // MIME 타입 확인 + String contentType = file.getContentType(); + if (contentType == null || !ALLOWED_MIME_TYPES.contains(contentType)) { + throw new BusinessException(ErrorCode.INVALID_FILE_TYPE); + } + } + } + + /** + * 메뉴 이미지 파일명 생성 + * + * @param originalFilename 원본 파일명 + * @return 생성된 파일명 + */ + private String generateMenuImageFileName(String originalFilename) { + String extension = getFileExtension(originalFilename); + String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")); + String uuid = UUID.randomUUID().toString().substring(0, 8); + + return String.format("menu_%s_%s.%s", timestamp, uuid, extension); + } + + /** + * 매장 이미지 파일명 생성 + * + * @param storeId 매장 ID + * @param originalFilename 원본 파일명 + * @return 생성된 파일명 + */ + private String generateStoreImageFileName(Long storeId, String originalFilename) { + String extension = getFileExtension(originalFilename); + String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")); + String uuid = UUID.randomUUID().toString().substring(0, 8); + + return String.format("store_%d_%s_%s.%s", storeId, timestamp, uuid, extension); + } + + /** + * 파일 확장자 추출 + * + * @param filename 파일명 + * @return 확장자 + */ + private String getFileExtension(String filename) { + int lastDotIndex = filename.lastIndexOf('.'); + if (lastDotIndex == -1) { + return ""; + } + return filename.substring(lastDotIndex + 1); + } + + /** + * URL에서 컨테이너명과 파일명 추출 + * + * @param fileUrl 파일 URL + * @return [컨테이너명, 파일명] 배열 + */ + private String[] extractContainerAndFileName(String fileUrl) { + // URL 형식: https://accountname.blob.core.windows.net/container/filename + try { + String[] parts = fileUrl.split("/"); + String containerName = parts[parts.length - 2]; + String fileName = parts[parts.length - 1]; + return new String[]{containerName, fileName}; + } catch (Exception e) { + throw new BusinessException(ErrorCode.INVALID_FILE_URL); + } + } +} \ 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 07b0602..bb6abdf 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 @@ -9,7 +9,6 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpMethod; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; - import java.time.Duration; import java.util.HashMap; import java.util.Map; @@ -44,15 +43,10 @@ public class ClaudeAiContentGenerator implements AiContentGenerator { requestBody.put("category", request.getCategory()); requestBody.put("contentType", request.getContentType()); requestBody.put("requirement", request.getRequirement()); - - //requestBody.put("tone_and_manner", request.getToneAndManner()); - // requestBody.put("emotion_intensity", request.getEmotionIntensity()); requestBody.put("target", request.getTarget()); - requestBody.put("event_name", request.getEventName()); requestBody.put("start_date", request.getStartDate()); requestBody.put("end_date", request.getEndDate()); - requestBody.put("images", request.getImages()); // Python AI 서비스 호출 @@ -63,8 +57,8 @@ public class ClaudeAiContentGenerator implements AiContentGenerator { .bodyValue(requestBody) .retrieve() .bodyToMono(Map.class) - .timeout(Duration.ofSeconds(30)) - .block(); + .timeout(Duration.ofSeconds(300)) + .block(Duration.ofMinutes(6)); String content = ""; @@ -76,10 +70,6 @@ public class ClaudeAiContentGenerator implements AiContentGenerator { return content; } return content; -// } catch (Exception e) { -// log.error("AI 서비스 호출 실패: {}", e.getMessage(), e); -// return generateFallbackContent(request.getTitle(), Platform.fromString(request.getPlatform())); -// } } /** diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/PythonAiPosterGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/PythonAiPosterGenerator.java index 1fc2020..4ea396a 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/PythonAiPosterGenerator.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/PythonAiPosterGenerator.java @@ -46,12 +46,12 @@ public class PythonAiPosterGenerator implements AiPosterGenerator { // Python AI 서비스 호출 Map response = webClient .post() - .uri(aiServiceBaseUrl + "/api/ai/poster") + .uri("http://localhost:5001" + "/api/ai/poster") .header("Content-Type", "application/json") .bodyValue(requestBody) .retrieve() .bodyToMono(Map.class) - .timeout(Duration.ofSeconds(60)) // 포스터 생성은 시간이 오래 걸릴 수 있음 + .timeout(Duration.ofSeconds(90)) // 포스터 생성은 시간이 오래 걸릴 수 있음 .block(); // 응답에서 content(이미지 URL) 추출 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 b454fb1..ab7afa1 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 @@ -1,5 +1,7 @@ package com.won.smarketing.content.presentation.controller; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import com.won.smarketing.common.dto.ApiResponse; import com.won.smarketing.content.application.usecase.ContentQueryUseCase; import com.won.smarketing.content.application.usecase.PosterContentUseCase; @@ -7,12 +9,17 @@ import com.won.smarketing.content.application.usecase.SnsContentUseCase; import com.won.smarketing.content.presentation.dto.*; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import jakarta.validation.Valid; +import org.springframework.web.multipart.MultipartFile; + import java.util.List; /** @@ -20,6 +27,7 @@ import java.util.List; * SNS 콘텐츠 생성, 포스터 생성, 콘텐츠 관리 기능 제공 */ @Tag(name = "마케팅 콘텐츠 관리", description = "AI 기반 마케팅 콘텐츠 생성 및 관리 API") +@Slf4j @RestController @RequestMapping("/api/content") @RequiredArgsConstructor @@ -28,17 +36,19 @@ public class ContentController { private final SnsContentUseCase snsContentUseCase; private final PosterContentUseCase posterContentUseCase; private final ContentQueryUseCase contentQueryUseCase; + private final ObjectMapper objectMapper; /** * SNS 게시물 생성 - * - * @param request SNS 콘텐츠 생성 요청 + * @return 생성된 SNS 콘텐츠 정보 */ @Operation(summary = "SNS 게시물 생성", description = "AI를 활용하여 SNS 게시물을 생성합니다.") - @PostMapping("/sns/generate") - public ResponseEntity> generateSnsContent(@Valid @RequestBody SnsContentCreateRequest request) { - SnsContentCreateResponse response = snsContentUseCase.generateSnsContent(request); + @PostMapping(path = "/sns/generate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> generateSnsContent(@Valid @RequestPart("request") String requestJson, + @Valid @RequestPart("files") List images) throws JsonProcessingException { + SnsContentCreateRequest request = objectMapper.readValue(requestJson, SnsContentCreateRequest.class); + SnsContentCreateResponse response = snsContentUseCase.generateSnsContent(request, images); return ResponseEntity.ok(ApiResponse.success(response, "SNS 콘텐츠가 성공적으로 생성되었습니다.")); } @@ -62,15 +72,22 @@ public class ContentController { * @return 생성된 포스터 콘텐츠 정보 */ @Operation(summary = "홍보 포스터 생성", description = "AI를 활용하여 홍보 포스터를 생성합니다.") - @PostMapping("/poster/generate") - public ResponseEntity> generatePosterContent(@Valid @RequestBody PosterContentCreateRequest request) { - PosterContentCreateResponse response = posterContentUseCase.generatePosterContent(request); + @PostMapping(value = "/poster/generate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> generatePosterContent( + @Parameter(content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE)) + @RequestPart(value = "images", required = false) List images, + @RequestPart("request") String requestJson) throws JsonProcessingException { + + // JSON 파싱 + PosterContentCreateRequest request = objectMapper.readValue(requestJson, PosterContentCreateRequest.class); + + PosterContentCreateResponse response = posterContentUseCase.generatePosterContent(images, request); return ResponseEntity.ok(ApiResponse.success(response, "포스터 콘텐츠가 성공적으로 생성되었습니다.")); } /** * 홍보 포스터 저장 - * + * * @param request 포스터 콘텐츠 저장 요청 * @return 저장 성공 응답 */ 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 1cbf87d..65508ce 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 @@ -50,9 +50,7 @@ public class PosterContentCreateRequest { @Schema(description = "이미지 스타일", example = "모던") private String imageStyle; - @Schema(description = "업로드된 이미지 URL 목록", required = true) - @NotNull(message = "이미지는 1개 이상 필수입니다") - @Size(min = 1, message = "이미지는 1개 이상 업로드해야 합니다") + @Schema(description = "업로드된 이미지 URL 목록") private List images; @Schema(description = "콘텐츠 카테고리", example = "이벤트") 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 0c02b68..5fa5c53 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 @@ -31,19 +31,9 @@ public class PosterContentCreateResponse { @Schema(description = "생성된 포스터 타입") private String contentType; - @Schema(description = "포스터 이미지 URL") - private String posterImage; - - @Schema(description = "원본 이미지 URL 목록") - private List originalImages; - @Schema(description = "이미지 스타일", example = "모던") private String imageStyle; @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 5335d11..eb549f0 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,8 +1,6 @@ -// 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; -import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -19,12 +17,7 @@ import java.util.List; @Schema(description = "포스터 콘텐츠 저장 요청") public class PosterContentSaveRequest { - @Schema(description = "콘텐츠 ID", example = "1", required = true) - @NotNull(message = "콘텐츠 ID는 필수입니다") - private Long contentId; - - @Schema(description = "매장 ID", example = "1", required = true) - @NotNull(message = "매장 ID는 필수입니다") + @Schema(description = "매장 ID", example = "1") private Long storeId; @Schema(description = "제목", example = "특별 이벤트 안내") @@ -39,19 +32,12 @@ public class PosterContentSaveRequest { @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; 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 index f8bcdeb..271d604 100644 --- 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 @@ -68,18 +68,6 @@ public class SnsContentCreateRequest { @Schema(description = "콘텐츠 타입", example = "SNS 게시물") private String contentType; -// @Schema(description = "톤앤매너", -// example = "친근함", -// allowableValues = {"친근함", "전문적", "유머러스", "감성적", "트렌디"}) -// private String toneAndManner; - -// @Schema(description = "감정 강도", -// example = "보통", -// allowableValues = {"약함", "보통", "강함"}) -// private String emotionIntensity; - - // ==================== 이벤트 정보 ==================== - @Schema(description = "이벤트명 (이벤트 콘텐츠인 경우)", example = "신메뉴 출시 이벤트") @Size(max = 200, message = "이벤트명은 200자 이하로 입력해주세요") diff --git a/smarketing-java/marketing-content/src/main/resources/application.yml b/smarketing-java/marketing-content/src/main/resources/application.yml index 871f8d8..819d127 100644 --- a/smarketing-java/marketing-content/src/main/resources/application.yml +++ b/smarketing-java/marketing-content/src/main/resources/application.yml @@ -37,7 +37,15 @@ logging: external: ai-service: base-url: ${AI_SERVICE_BASE_URL:http://20.249.113.247:5001} - +azure: + storage: + account-name: ${AZURE_STORAGE_ACCOUNT_NAME:stdigitalgarage02} + account-key: ${AZURE_STORAGE_ACCOUNT_KEY:} + endpoint: ${AZURE_STORAGE_ENDPOINT:https://stdigitalgarage02.blob.core.windows.net} + container: + menu-images: ${AZURE_STORAGE_MENU_CONTAINER:smarketing-menu-images} + store-images: ${AZURE_STORAGE_STORE_CONTAINER:smarketing-store-images} + max-file-size: ${AZURE_STORAGE_MAX_FILE_SIZE:10485760} # 10MB management: endpoints: web: diff --git a/smarketing-java/store/build.gradle b/smarketing-java/store/build.gradle index dd9e26d..771a2fc 100644 --- a/smarketing-java/store/build.gradle +++ b/smarketing-java/store/build.gradle @@ -1,8 +1,4 @@ dependencies { implementation project(':common') runtimeOnly 'com.mysql:mysql-connector-j' - - // Azure Blob Storage 의존성 추가 - implementation 'com.azure:azure-storage-blob:12.25.0' - implementation 'com.azure:azure-identity:1.11.1' } \ No newline at end of file