Merge pull request #10 from won-ktds/poster-content

Poster content
This commit is contained in:
yuhalog 2025-06-18 12:57:59 +09:00 committed by GitHub
commit 1579194e3e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 451 additions and 118 deletions

View File

@ -98,7 +98,7 @@ def create_app():
app.logger.error(traceback.format_exc()) app.logger.error(traceback.format_exc())
return jsonify({'error': f'SNS 콘텐츠 생성 중 오류가 발생했습니다: {str(e)}'}), 500 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(): def generate_poster_content():
""" """
홍보 포스터 생성 API 홍보 포스터 생성 API
@ -114,7 +114,7 @@ def create_app():
return jsonify({'error': '요청 데이터가 없습니다.'}), 400 return jsonify({'error': '요청 데이터가 없습니다.'}), 400
# 필수 필드 검증 # 필수 필드 검증
required_fields = ['title', 'category', 'contentType', 'images'] required_fields = ['title', 'category', 'images']
for field in required_fields: for field in required_fields:
if field not in data: if field not in data:
return jsonify({'error': f'필수 필드가 누락되었습니다: {field}'}), 400 return jsonify({'error': f'필수 필드가 누락되었습니다: {field}'}), 400
@ -140,12 +140,9 @@ def create_app():
poster_request = PosterContentGetRequest( poster_request = PosterContentGetRequest(
title=data.get('title'), title=data.get('title'),
category=data.get('category'), category=data.get('category'),
contentType=data.get('contentType'),
images=data.get('images', []), images=data.get('images', []),
photoStyle=data.get('photoStyle'), photoStyle=data.get('photoStyle'),
requirement=data.get('requirement'), requirement=data.get('requirement'),
toneAndManner=data.get('toneAndManner'),
emotionIntensity=data.get('emotionIntensity'),
menuName=data.get('menuName'), menuName=data.get('menuName'),
eventName=data.get('eventName'), eventName=data.get('eventName'),
startDate=start_date, startDate=start_date,

View File

@ -33,12 +33,9 @@ class PosterContentGetRequest:
"""홍보 포스터 생성 요청 모델""" """홍보 포스터 생성 요청 모델"""
title: str title: str
category: str category: str
contentType: str
images: List[str] # 이미지 URL 리스트 images: List[str] # 이미지 URL 리스트
photoStyle: Optional[str] = None photoStyle: Optional[str] = None
requirement: Optional[str] = None requirement: Optional[str] = None
toneAndManner: Optional[str] = None
emotionIntensity: Optional[str] = None
menuName: Optional[str] = None menuName: Optional[str] = None
eventName: Optional[str] = None eventName: Optional[str] = None
startDate: Optional[date] = None # LocalDate -> date startDate: Optional[date] = None # LocalDate -> date

View File

@ -154,7 +154,6 @@ class PosterService:
### 📋 기본 정보 ### 📋 기본 정보
카테고리: {request.category} 카테고리: {request.category}
콘텐츠 타입: {request.contentType}
메뉴명: {request.menuName or '없음'} 메뉴명: {request.menuName or '없음'}
메뉴 정보: {main_description} 메뉴 정보: {main_description}

View File

@ -53,6 +53,15 @@ subprojects {
implementation 'com.azure:azure-messaging-eventhubs-checkpointstore-blob:1.19.0' implementation 'com.azure:azure-messaging-eventhubs-checkpointstore-blob:1.19.0'
implementation 'com.azure:azure-identity:1.11.4' 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') { tasks.named('test') {

View File

@ -1,7 +1,4 @@
dependencies { dependencies {
implementation project(':common') implementation project(':common')
runtimeOnly 'org.postgresql:postgresql' runtimeOnly 'org.postgresql:postgresql'
// WebClient를 Spring WebFlux
implementation 'org.springframework.boot:spring-boot-starter-webflux'
} }

View File

@ -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.model.Platform;
import com.won.smarketing.content.domain.repository.ContentRepository; import com.won.smarketing.content.domain.repository.ContentRepository;
import com.won.smarketing.content.domain.service.AiPosterGenerator; 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.PosterContentCreateRequest;
import com.won.smarketing.content.presentation.dto.PosterContentCreateResponse; import com.won.smarketing.content.presentation.dto.PosterContentCreateResponse;
import com.won.smarketing.content.presentation.dto.PosterContentSaveRequest; import com.won.smarketing.content.presentation.dto.PosterContentSaveRequest;
import lombok.RequiredArgsConstructor; 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.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.List;
/** /**
* 포스터 콘텐츠 서비스 구현체 * 포스터 콘텐츠 서비스 구현체
* 홍보 포스터 생성 저장 기능 구현 * 홍보 포스터 생성 저장 기능 구현
*/ */
@Service @Service
@Slf4j
@RequiredArgsConstructor @RequiredArgsConstructor
@Transactional(readOnly = true) @Transactional(readOnly = true)
public class PosterContentService implements PosterContentUseCase { public class PosterContentService implements PosterContentUseCase {
@Value("${azure.storage.container.poster-images:poster-images}")
private String posterImageContainer;
private final ContentRepository contentRepository; private final ContentRepository contentRepository;
private final AiPosterGenerator aiPosterGenerator; private final AiPosterGenerator aiPosterGenerator;
private final BlobStorageService blobStorageService;
/** /**
* 포스터 콘텐츠 생성 * 포스터 콘텐츠 생성
@ -39,26 +47,20 @@ public class PosterContentService implements PosterContentUseCase {
*/ */
@Override @Override
@Transactional @Transactional
public PosterContentCreateResponse generatePosterContent(PosterContentCreateRequest request) { public PosterContentCreateResponse generatePosterContent(List<MultipartFile> images, PosterContentCreateRequest request) {
// 1. 이미지 blob storage에 저장하고 request 저장
List<String> imageUrls = blobStorageService.uploadImage(images, posterImageContainer);
request.setImages(imageUrls);
// 2. AI 요청
String generatedPoster = aiPosterGenerator.generatePoster(request); 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() return PosterContentCreateResponse.builder()
.contentId(null) // 임시 생성이므로 ID 없음 .contentId(null) // 임시 생성이므로 ID 없음
.contentType(ContentType.POSTER.name()) .contentType(ContentType.POSTER.name())
.title(request.getTitle()) .title(request.getTitle())
.posterImage(generatedPoster) .content(generatedPoster)
.posterSizes(new HashMap<>()) // 반환 (사이즈 변환 안함)
.status(ContentStatus.DRAFT.name()) .status(ContentStatus.DRAFT.name())
.build(); .build();
} }
@ -68,9 +70,8 @@ public class PosterContentService implements PosterContentUseCase {
* *
* @param request 포스터 콘텐츠 저장 요청 * @param request 포스터 콘텐츠 저장 요청
*/ */
@Override
@Transactional @Transactional
public void savePosterContent(PosterContentSaveRequest request) { public Content savePosterContent(PosterContentSaveRequest request) {
// 생성 조건 구성 // 생성 조건 구성
CreationConditions conditions = CreationConditions.builder() CreationConditions conditions = CreationConditions.builder()
.category(request.getCategory()) .category(request.getCategory())
@ -84,8 +85,9 @@ public class PosterContentService implements PosterContentUseCase {
// 콘텐츠 엔티티 생성 // 콘텐츠 엔티티 생성
Content content = Content.builder() Content content = Content.builder()
.contentType(ContentType.POSTER) .contentType(ContentType.POSTER)
.platform(Platform.GENERAL)
.title(request.getTitle()) .title(request.getTitle())
.content(request.getContent()) // .content(request.gen)
.images(request.getImages()) .images(request.getImages())
.status(ContentStatus.PUBLISHED) .status(ContentStatus.PUBLISHED)
.creationConditions(conditions) .creationConditions(conditions)
@ -93,6 +95,6 @@ public class PosterContentService implements PosterContentUseCase {
.build(); .build();
// 저장 // 저장
contentRepository.save(content); return contentRepository.save(content);
} }
} }

View File

@ -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.model.Platform;
import com.won.smarketing.content.domain.repository.ContentRepository; import com.won.smarketing.content.domain.repository.ContentRepository;
import com.won.smarketing.content.domain.service.AiContentGenerator; 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.SnsContentCreateRequest;
import com.won.smarketing.content.presentation.dto.SnsContentCreateResponse; import com.won.smarketing.content.presentation.dto.SnsContentCreateResponse;
import com.won.smarketing.content.presentation.dto.SnsContentSaveRequest; import com.won.smarketing.content.presentation.dto.SnsContentSaveRequest;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
@ -30,6 +32,7 @@ public class SnsContentService implements SnsContentUseCase {
private final ContentRepository contentRepository; private final ContentRepository contentRepository;
private final AiContentGenerator aiContentGenerator; private final AiContentGenerator aiContentGenerator;
private final BlobStorageService blobStorageService;
/** /**
* SNS 콘텐츠 생성 * SNS 콘텐츠 생성
@ -39,14 +42,17 @@ public class SnsContentService implements SnsContentUseCase {
*/ */
@Override @Override
@Transactional @Transactional
public SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request) { public SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request, List<MultipartFile> files) {
//파일들 주소 가져옴
List<String> urls = blobStorageService.uploadImage(files, "containerName");
request.setImages(urls);
// AI를 사용하여 SNS 콘텐츠 생성 // AI를 사용하여 SNS 콘텐츠 생성
String content = aiContentGenerator.generateSnsContent(request); String content = aiContentGenerator.generateSnsContent(request);
return SnsContentCreateResponse.builder() return SnsContentCreateResponse.builder()
.content(content) .content(content)
.build(); .build();
} }
/** /**

View File

@ -1,9 +1,13 @@
// marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java // marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java
package com.won.smarketing.content.application.usecase; 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.PosterContentCreateRequest;
import com.won.smarketing.content.presentation.dto.PosterContentCreateResponse; import com.won.smarketing.content.presentation.dto.PosterContentCreateResponse;
import com.won.smarketing.content.presentation.dto.PosterContentSaveRequest; import com.won.smarketing.content.presentation.dto.PosterContentSaveRequest;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
/** /**
* 포스터 콘텐츠 관련 UseCase 인터페이스 * 포스터 콘텐츠 관련 UseCase 인터페이스
@ -16,11 +20,11 @@ public interface PosterContentUseCase {
* @param request 포스터 콘텐츠 생성 요청 * @param request 포스터 콘텐츠 생성 요청
* @return 포스터 콘텐츠 생성 응답 * @return 포스터 콘텐츠 생성 응답
*/ */
PosterContentCreateResponse generatePosterContent(PosterContentCreateRequest request); PosterContentCreateResponse generatePosterContent(List<MultipartFile> images, PosterContentCreateRequest request);
/** /**
* 포스터 콘텐츠 저장 * 포스터 콘텐츠 저장
* @param request 포스터 콘텐츠 저장 요청 * @param request 포스터 콘텐츠 저장 요청
*/ */
void savePosterContent(PosterContentSaveRequest request); Content savePosterContent(PosterContentSaveRequest request);
} }

View File

@ -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.SnsContentCreateRequest;
import com.won.smarketing.content.presentation.dto.SnsContentCreateResponse; import com.won.smarketing.content.presentation.dto.SnsContentCreateResponse;
import com.won.smarketing.content.presentation.dto.SnsContentSaveRequest; import com.won.smarketing.content.presentation.dto.SnsContentSaveRequest;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
/** /**
* SNS 콘텐츠 관련 UseCase 인터페이스 * SNS 콘텐츠 관련 UseCase 인터페이스
@ -16,7 +19,7 @@ public interface SnsContentUseCase {
* @param request SNS 콘텐츠 생성 요청 * @param request SNS 콘텐츠 생성 요청
* @return SNS 콘텐츠 생성 응답 * @return SNS 콘텐츠 생성 응답
*/ */
SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request); SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request, List<MultipartFile> files);
/** /**
* SNS 콘텐츠 저장 * SNS 콘텐츠 저장

View File

@ -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);
}
}

View File

@ -1,4 +1,3 @@
// marketing-content/src/main/java/com/won/smarketing/content/config/WebClientConfig.java
package com.won.smarketing.content.config; package com.won.smarketing.content.config;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
@ -20,8 +19,8 @@ public class WebClientConfig {
@Bean @Bean
public WebClient webClient() { public WebClient webClient() {
HttpClient httpClient = HttpClient.create() HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000) .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 15000) // 연결 타임아웃: 15초
.responseTimeout(Duration.ofMillis(30000)); .responseTimeout(Duration.ofMinutes(5)); // 응답 타임아웃: 5분
return WebClient.builder() return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient)) .clientConnector(new ReactorClientHttpConnector(httpClient))

View File

@ -27,42 +27,37 @@ import java.util.List;
@Builder @Builder
public class Content { public class Content {
// ==================== 기본키 식별자 ====================
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "content_id") @Column(name = "content_id")
private Long id; private Long id;
// ==================== 콘텐츠 분류 ====================
private ContentType contentType; private ContentType contentType;
private Platform platform; private Platform platform;
// ==================== 콘텐츠 내용 ====================
private String title; private String title;
private String content; private String content;
// ==================== 멀티미디어 메타데이터 ====================
@Builder.Default @Builder.Default
private List<String> hashtags = new ArrayList<>(); private List<String> hashtags = new ArrayList<>();
@Builder.Default @Builder.Default
private List<String> images = new ArrayList<>(); private List<String> images = new ArrayList<>();
// ==================== 상태 관리 ====================
private ContentStatus status; private ContentStatus status;
// ==================== 생성 조건 ====================
private CreationConditions creationConditions; private CreationConditions creationConditions;
// ==================== 매장 정보 ====================
private Long storeId; private Long storeId;
// ==================== 프로모션 기간 ====================
private LocalDateTime promotionStartDate; private LocalDateTime promotionStartDate;
private LocalDateTime promotionEndDate; private LocalDateTime promotionEndDate;
// ==================== 메타데이터 ====================
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
public Content(ContentId of, ContentType contentType, Platform platform, String title, String content, List<String> strings, List<String> strings1, ContentStatus contentStatus, CreationConditions conditions, Long storeId, LocalDateTime createdAt, LocalDateTime updatedAt) { public Content(ContentId of, ContentType contentType, Platform platform, String title, String content, List<String> strings, List<String> strings1, ContentStatus contentStatus, CreationConditions conditions, Long storeId, LocalDateTime createdAt, LocalDateTime updatedAt) {

View File

@ -24,8 +24,6 @@ public class CreationConditions {
private String id; private String id;
private String category; private String category;
private String requirement; private String requirement;
// private String toneAndManner;
// private String emotionIntensity;
private String storeName; private String storeName;
private String storeType; private String storeType;
private String target; private String target;

View File

@ -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<String> uploadImage(List<MultipartFile> file, String containerName);
/**
* 파일 삭제
*
* @param fileUrl 삭제할 파일의 URL
* @return 삭제 성공 여부
*/
//boolean deleteFile(String fileUrl);
/**
* 컨테이너 존재 여부 확인 생성
*
* @param containerName 컨테이너 이름
*/
void ensureContainerExists(String containerName);
}

View File

@ -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<String> ALLOWED_EXTENSIONS = Arrays.asList(
"jpg", "jpeg", "png", "gif", "bmp", "webp"
);
// 허용되는 MIME 타입
private static final List<String> ALLOWED_MIME_TYPES = Arrays.asList(
"image/jpeg", "image/png", "image/gif", "image/bmp", "image/webp"
);
/**
* 이미지 파일 업로드
*
* @param files 업로드할 파일들
* @return 업로드된 파일의 URL
*/
@Override
public List<String> uploadImage(List<MultipartFile> files, String containerName) {
// 파일 유효성 검증
validateImageFile(files);
List<String> 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<MultipartFile> 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);
}
}
}

View File

@ -9,7 +9,6 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClient;
import java.time.Duration; import java.time.Duration;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
@ -44,15 +43,10 @@ public class ClaudeAiContentGenerator implements AiContentGenerator {
requestBody.put("category", request.getCategory()); requestBody.put("category", request.getCategory());
requestBody.put("contentType", request.getContentType()); requestBody.put("contentType", request.getContentType());
requestBody.put("requirement", request.getRequirement()); 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("target", request.getTarget());
requestBody.put("event_name", request.getEventName()); requestBody.put("event_name", request.getEventName());
requestBody.put("start_date", request.getStartDate()); requestBody.put("start_date", request.getStartDate());
requestBody.put("end_date", request.getEndDate()); requestBody.put("end_date", request.getEndDate());
requestBody.put("images", request.getImages()); requestBody.put("images", request.getImages());
// Python AI 서비스 호출 // Python AI 서비스 호출
@ -63,8 +57,8 @@ public class ClaudeAiContentGenerator implements AiContentGenerator {
.bodyValue(requestBody) .bodyValue(requestBody)
.retrieve() .retrieve()
.bodyToMono(Map.class) .bodyToMono(Map.class)
.timeout(Duration.ofSeconds(30)) .timeout(Duration.ofSeconds(300))
.block(); .block(Duration.ofMinutes(6));
String content = ""; String content = "";
@ -76,10 +70,6 @@ public class ClaudeAiContentGenerator implements AiContentGenerator {
return content; return content;
} }
return content; return content;
// } catch (Exception e) {
// log.error("AI 서비스 호출 실패: {}", e.getMessage(), e);
// return generateFallbackContent(request.getTitle(), Platform.fromString(request.getPlatform()));
// }
} }
/** /**

View File

@ -46,12 +46,12 @@ public class PythonAiPosterGenerator implements AiPosterGenerator {
// Python AI 서비스 호출 // Python AI 서비스 호출
Map<String, Object> response = webClient Map<String, Object> response = webClient
.post() .post()
.uri(aiServiceBaseUrl + "/api/ai/poster") .uri("http://localhost:5001" + "/api/ai/poster")
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.bodyValue(requestBody) .bodyValue(requestBody)
.retrieve() .retrieve()
.bodyToMono(Map.class) .bodyToMono(Map.class)
.timeout(Duration.ofSeconds(60)) // 포스터 생성은 시간이 오래 걸릴 있음 .timeout(Duration.ofSeconds(90)) // 포스터 생성은 시간이 오래 걸릴 있음
.block(); .block();
// 응답에서 content(이미지 URL) 추출 // 응답에서 content(이미지 URL) 추출

View File

@ -1,5 +1,7 @@
package com.won.smarketing.content.presentation.controller; 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.common.dto.ApiResponse;
import com.won.smarketing.content.application.usecase.ContentQueryUseCase; import com.won.smarketing.content.application.usecase.ContentQueryUseCase;
import com.won.smarketing.content.application.usecase.PosterContentUseCase; 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 com.won.smarketing.content.presentation.dto.*;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.web.multipart.MultipartFile;
import java.util.List; import java.util.List;
/** /**
@ -20,6 +27,7 @@ import java.util.List;
* SNS 콘텐츠 생성, 포스터 생성, 콘텐츠 관리 기능 제공 * SNS 콘텐츠 생성, 포스터 생성, 콘텐츠 관리 기능 제공
*/ */
@Tag(name = "마케팅 콘텐츠 관리", description = "AI 기반 마케팅 콘텐츠 생성 및 관리 API") @Tag(name = "마케팅 콘텐츠 관리", description = "AI 기반 마케팅 콘텐츠 생성 및 관리 API")
@Slf4j
@RestController @RestController
@RequestMapping("/api/content") @RequestMapping("/api/content")
@RequiredArgsConstructor @RequiredArgsConstructor
@ -28,17 +36,19 @@ public class ContentController {
private final SnsContentUseCase snsContentUseCase; private final SnsContentUseCase snsContentUseCase;
private final PosterContentUseCase posterContentUseCase; private final PosterContentUseCase posterContentUseCase;
private final ContentQueryUseCase contentQueryUseCase; private final ContentQueryUseCase contentQueryUseCase;
private final ObjectMapper objectMapper;
/** /**
* SNS 게시물 생성 * SNS 게시물 생성
*
* @param request SNS 콘텐츠 생성 요청
* @return 생성된 SNS 콘텐츠 정보 * @return 생성된 SNS 콘텐츠 정보
*/ */
@Operation(summary = "SNS 게시물 생성", description = "AI를 활용하여 SNS 게시물을 생성합니다.") @Operation(summary = "SNS 게시물 생성", description = "AI를 활용하여 SNS 게시물을 생성합니다.")
@PostMapping("/sns/generate") @PostMapping(path = "/sns/generate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ApiResponse<SnsContentCreateResponse>> generateSnsContent(@Valid @RequestBody SnsContentCreateRequest request) { public ResponseEntity<ApiResponse<SnsContentCreateResponse>> generateSnsContent(@Valid @RequestPart("request") String requestJson,
SnsContentCreateResponse response = snsContentUseCase.generateSnsContent(request); @Valid @RequestPart("files") List<MultipartFile> images) throws JsonProcessingException {
SnsContentCreateRequest request = objectMapper.readValue(requestJson, SnsContentCreateRequest.class);
SnsContentCreateResponse response = snsContentUseCase.generateSnsContent(request, images);
return ResponseEntity.ok(ApiResponse.success(response, "SNS 콘텐츠가 성공적으로 생성되었습니다.")); return ResponseEntity.ok(ApiResponse.success(response, "SNS 콘텐츠가 성공적으로 생성되었습니다."));
} }
@ -62,15 +72,22 @@ public class ContentController {
* @return 생성된 포스터 콘텐츠 정보 * @return 생성된 포스터 콘텐츠 정보
*/ */
@Operation(summary = "홍보 포스터 생성", description = "AI를 활용하여 홍보 포스터를 생성합니다.") @Operation(summary = "홍보 포스터 생성", description = "AI를 활용하여 홍보 포스터를 생성합니다.")
@PostMapping("/poster/generate") @PostMapping(value = "/poster/generate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ApiResponse<PosterContentCreateResponse>> generatePosterContent(@Valid @RequestBody PosterContentCreateRequest request) { public ResponseEntity<ApiResponse<PosterContentCreateResponse>> generatePosterContent(
PosterContentCreateResponse response = posterContentUseCase.generatePosterContent(request); @Parameter(content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE))
@RequestPart(value = "images", required = false) List<MultipartFile> 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, "포스터 콘텐츠가 성공적으로 생성되었습니다.")); return ResponseEntity.ok(ApiResponse.success(response, "포스터 콘텐츠가 성공적으로 생성되었습니다."));
} }
/** /**
* 홍보 포스터 저장 * 홍보 포스터 저장
* *
* @param request 포스터 콘텐츠 저장 요청 * @param request 포스터 콘텐츠 저장 요청
* @return 저장 성공 응답 * @return 저장 성공 응답
*/ */

View File

@ -50,9 +50,7 @@ public class PosterContentCreateRequest {
@Schema(description = "이미지 스타일", example = "모던") @Schema(description = "이미지 스타일", example = "모던")
private String imageStyle; private String imageStyle;
@Schema(description = "업로드된 이미지 URL 목록", required = true) @Schema(description = "업로드된 이미지 URL 목록")
@NotNull(message = "이미지는 1개 이상 필수입니다")
@Size(min = 1, message = "이미지는 1개 이상 업로드해야 합니다")
private List<String> images; private List<String> images;
@Schema(description = "콘텐츠 카테고리", example = "이벤트") @Schema(description = "콘텐츠 카테고리", example = "이벤트")

View File

@ -31,19 +31,9 @@ public class PosterContentCreateResponse {
@Schema(description = "생성된 포스터 타입") @Schema(description = "생성된 포스터 타입")
private String contentType; private String contentType;
@Schema(description = "포스터 이미지 URL")
private String posterImage;
@Schema(description = "원본 이미지 URL 목록")
private List<String> originalImages;
@Schema(description = "이미지 스타일", example = "모던") @Schema(description = "이미지 스타일", example = "모던")
private String imageStyle; private String imageStyle;
@Schema(description = "생성 상태", example = "DRAFT") @Schema(description = "생성 상태", example = "DRAFT")
private String status; private String status;
@Schema(description = "포스터사이즈", example = "800x600")
private Map<String, String> posterSizes;
} }

View File

@ -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; package com.won.smarketing.content.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
@ -19,12 +17,7 @@ import java.util.List;
@Schema(description = "포스터 콘텐츠 저장 요청") @Schema(description = "포스터 콘텐츠 저장 요청")
public class PosterContentSaveRequest { public class PosterContentSaveRequest {
@Schema(description = "콘텐츠 ID", example = "1", required = true) @Schema(description = "매장 ID", example = "1")
@NotNull(message = "콘텐츠 ID는 필수입니다")
private Long contentId;
@Schema(description = "매장 ID", example = "1", required = true)
@NotNull(message = "매장 ID는 필수입니다")
private Long storeId; private Long storeId;
@Schema(description = "제목", example = "특별 이벤트 안내") @Schema(description = "제목", example = "특별 이벤트 안내")
@ -39,19 +32,12 @@ public class PosterContentSaveRequest {
@Schema(description = "발행 상태", example = "PUBLISHED") @Schema(description = "발행 상태", example = "PUBLISHED")
private String status; private String status;
// CreationConditions에 필요한 필드들
@Schema(description = "콘텐츠 카테고리", example = "이벤트") @Schema(description = "콘텐츠 카테고리", example = "이벤트")
private String category; private String category;
@Schema(description = "구체적인 요구사항", example = "신메뉴 출시 이벤트 포스터를 만들어주세요") @Schema(description = "구체적인 요구사항", example = "신메뉴 출시 이벤트 포스터를 만들어주세요")
private String requirement; private String requirement;
@Schema(description = "톤앤매너", example = "전문적")
private String toneAndManner;
@Schema(description = "감정 강도", example = "보통")
private String emotionIntensity;
@Schema(description = "이벤트명", example = "신메뉴 출시 이벤트") @Schema(description = "이벤트명", example = "신메뉴 출시 이벤트")
private String eventName; private String eventName;

View File

@ -68,18 +68,6 @@ public class SnsContentCreateRequest {
@Schema(description = "콘텐츠 타입", example = "SNS 게시물") @Schema(description = "콘텐츠 타입", example = "SNS 게시물")
private String contentType; private String contentType;
// @Schema(description = "톤앤매너",
// example = "친근함",
// allowableValues = {"친근함", "전문적", "유머러스", "감성적", "트렌디"})
// private String toneAndManner;
// @Schema(description = "감정 강도",
// example = "보통",
// allowableValues = {"약함", "보통", "강함"})
// private String emotionIntensity;
// ==================== 이벤트 정보 ====================
@Schema(description = "이벤트명 (이벤트 콘텐츠인 경우)", @Schema(description = "이벤트명 (이벤트 콘텐츠인 경우)",
example = "신메뉴 출시 이벤트") example = "신메뉴 출시 이벤트")
@Size(max = 200, message = "이벤트명은 200자 이하로 입력해주세요") @Size(max = 200, message = "이벤트명은 200자 이하로 입력해주세요")

View File

@ -37,7 +37,15 @@ logging:
external: external:
ai-service: ai-service:
base-url: ${AI_SERVICE_BASE_URL:http://20.249.113.247:5001} 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: management:
endpoints: endpoints:
web: web:

View File

@ -1,8 +1,4 @@
dependencies { dependencies {
implementation project(':common') implementation project(':common')
runtimeOnly 'com.mysql:mysql-connector-j' 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'
} }