mirror of
https://github.com/won-ktds/smarketing-backend.git
synced 2025-12-06 07:06:24 +00:00
Merge branch 'main' of https://github.com/won-ktds/smarketing-backend
This commit is contained in:
commit
436e52027e
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -154,7 +154,6 @@ class PosterService:
|
||||
|
||||
### 📋 기본 정보
|
||||
카테고리: {request.category}
|
||||
콘텐츠 타입: {request.contentType}
|
||||
메뉴명: {request.menuName or '없음'}
|
||||
메뉴 정보: {main_description}
|
||||
|
||||
|
||||
@ -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') {
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -1,7 +1,4 @@
|
||||
dependencies {
|
||||
implementation project(':common')
|
||||
runtimeOnly 'org.postgresql:postgresql'
|
||||
|
||||
// WebClient를 위한 Spring WebFlux 의존성
|
||||
implementation 'org.springframework.boot:spring-boot-starter-webflux'
|
||||
}
|
||||
@ -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<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);
|
||||
|
||||
// 생성 조건 정보 구성
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -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<MultipartFile> files) {
|
||||
//파일들 주소 가져옴
|
||||
List<String> urls = blobStorageService.uploadImage(files, "containerName");
|
||||
request.setImages(urls);
|
||||
|
||||
// AI를 사용하여 SNS 콘텐츠 생성
|
||||
String content = aiContentGenerator.generateSnsContent(request);
|
||||
|
||||
return SnsContentCreateResponse.builder()
|
||||
.content(content)
|
||||
.build();
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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<MultipartFile> images, PosterContentCreateRequest request);
|
||||
|
||||
/**
|
||||
* 포스터 콘텐츠 저장
|
||||
* @param request 포스터 콘텐츠 저장 요청
|
||||
*/
|
||||
void savePosterContent(PosterContentSaveRequest request);
|
||||
Content savePosterContent(PosterContentSaveRequest request);
|
||||
}
|
||||
@ -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<MultipartFile> files);
|
||||
|
||||
/**
|
||||
* SNS 콘텐츠 저장
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
|
||||
@ -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<String> hashtags = new ArrayList<>();
|
||||
|
||||
@Builder.Default
|
||||
private List<String> 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<String> strings, List<String> strings1, ContentStatus contentStatus, CreationConditions conditions, Long storeId, LocalDateTime createdAt, LocalDateTime updatedAt) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()));
|
||||
// }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -46,12 +46,12 @@ public class PythonAiPosterGenerator implements AiPosterGenerator {
|
||||
// Python AI 서비스 호출
|
||||
Map<String, Object> 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) 추출
|
||||
|
||||
@ -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<ApiResponse<SnsContentCreateResponse>> generateSnsContent(@Valid @RequestBody SnsContentCreateRequest request) {
|
||||
SnsContentCreateResponse response = snsContentUseCase.generateSnsContent(request);
|
||||
@PostMapping(path = "/sns/generate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ResponseEntity<ApiResponse<SnsContentCreateResponse>> generateSnsContent(@Valid @RequestPart("request") String requestJson,
|
||||
@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 콘텐츠가 성공적으로 생성되었습니다."));
|
||||
}
|
||||
|
||||
@ -62,15 +72,22 @@ public class ContentController {
|
||||
* @return 생성된 포스터 콘텐츠 정보
|
||||
*/
|
||||
@Operation(summary = "홍보 포스터 생성", description = "AI를 활용하여 홍보 포스터를 생성합니다.")
|
||||
@PostMapping("/poster/generate")
|
||||
public ResponseEntity<ApiResponse<PosterContentCreateResponse>> generatePosterContent(@Valid @RequestBody PosterContentCreateRequest request) {
|
||||
PosterContentCreateResponse response = posterContentUseCase.generatePosterContent(request);
|
||||
@PostMapping(value = "/poster/generate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ResponseEntity<ApiResponse<PosterContentCreateResponse>> generatePosterContent(
|
||||
@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, "포스터 콘텐츠가 성공적으로 생성되었습니다."));
|
||||
}
|
||||
|
||||
/**
|
||||
* 홍보 포스터 저장
|
||||
*
|
||||
*
|
||||
* @param request 포스터 콘텐츠 저장 요청
|
||||
* @return 저장 성공 응답
|
||||
*/
|
||||
|
||||
@ -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<String> images;
|
||||
|
||||
@Schema(description = "콘텐츠 카테고리", example = "이벤트")
|
||||
|
||||
@ -31,19 +31,9 @@ public class PosterContentCreateResponse {
|
||||
@Schema(description = "생성된 포스터 타입")
|
||||
private String contentType;
|
||||
|
||||
@Schema(description = "포스터 이미지 URL")
|
||||
private String posterImage;
|
||||
|
||||
@Schema(description = "원본 이미지 URL 목록")
|
||||
private List<String> originalImages;
|
||||
|
||||
@Schema(description = "이미지 스타일", example = "모던")
|
||||
private String imageStyle;
|
||||
|
||||
@Schema(description = "생성 상태", example = "DRAFT")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "포스터사이즈", example = "800x600")
|
||||
private Map<String, String> posterSizes;
|
||||
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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자 이하로 입력해주세요")
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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'
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user