add python call

This commit is contained in:
박서은 2025-06-17 09:54:49 +09:00
parent 3e09e77707
commit faaf690db2
14 changed files with 196 additions and 535 deletions

View File

@ -1,4 +1,7 @@
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

@ -99,24 +99,24 @@ public class ContentQueryService implements ContentQueryUseCase {
* @param contentId 콘텐츠 ID * @param contentId 콘텐츠 ID
* @return 콘텐츠 상세 정보 * @return 콘텐츠 상세 정보
*/ */
@Override // @Override
public ContentDetailResponse getContentDetail(Long contentId) { // public ContentDetailResponse getContentDetail(Long contentId) {
Content content = contentRepository.findById(ContentId.of(contentId)) // Content content = contentRepository.findById(ContentId.of(contentId))
.orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); // .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND));
//
return ContentDetailResponse.builder() // return ContentDetailResponse.builder()
.contentId(content.getId()) // .contentId(content.getId())
.contentType(content.getContentType().name()) // .contentType(content.getContentType().name())
.platform(content.getPlatform().name()) // .platform(content.getPlatform().name())
.title(content.getTitle()) // .title(content.getTitle())
.content(content.getContent()) // .content(content.getContent())
.hashtags(content.getHashtags()) // .hashtags(content.getHashtags())
.images(content.getImages()) // .images(content.getImages())
.status(content.getStatus().name()) // .status(content.getStatus().name())
.creationConditions(toCreationConditionsDto(content.getCreationConditions())) // .creationConditions(toCreationConditionsDto(content.getCreationConditions()))
.createdAt(content.getCreatedAt()) // .createdAt(content.getCreatedAt())
.build(); // .build();
} // }
/** /**
* 콘텐츠 삭제 * 콘텐츠 삭제
@ -177,15 +177,15 @@ public class ContentQueryService implements ContentQueryUseCase {
* @param conditions CreationConditions 도메인 객체 * @param conditions CreationConditions 도메인 객체
* @return CreationConditionsDto * @return CreationConditionsDto
*/ */
private ContentDetailResponse.CreationConditionsDto toCreationConditionsDto(CreationConditions conditions) { // private ContentDetailResponse.CreationConditionsDto toCreationConditionsDto(CreationConditions conditions) {
if (conditions == null) { // if (conditions == null) {
return null; // return null;
} // }
//
return ContentDetailResponse.CreationConditionsDto.builder() // return ContentDetailResponse.CreationConditionsDto.builder()
.toneAndManner(conditions.getToneAndManner()) // .toneAndManner(conditions.getToneAndManner())
.emotionIntensity(conditions.getEmotionIntensity()) // .emotionIntensity(conditions.getEmotionIntensity())
.eventName(conditions.getEventName()) // .eventName(conditions.getEventName())
.build(); // .build();
} // }
} }

View File

@ -49,8 +49,8 @@ public class PosterContentService implements PosterContentUseCase {
CreationConditions conditions = CreationConditions.builder() CreationConditions conditions = CreationConditions.builder()
.category(request.getCategory()) .category(request.getCategory())
.requirement(request.getRequirement()) .requirement(request.getRequirement())
.toneAndManner(request.getToneAndManner()) // .toneAndManner(request.getToneAndManner())
.emotionIntensity(request.getEmotionIntensity()) // .emotionIntensity(request.getEmotionIntensity())
.eventName(request.getEventName()) .eventName(request.getEventName())
.startDate(request.getStartDate()) .startDate(request.getStartDate())
.endDate(request.getEndDate()) .endDate(request.getEndDate())
@ -80,8 +80,8 @@ public class PosterContentService implements PosterContentUseCase {
CreationConditions conditions = CreationConditions.builder() CreationConditions conditions = CreationConditions.builder()
.category(request.getCategory()) .category(request.getCategory())
.requirement(request.getRequirement()) .requirement(request.getRequirement())
.toneAndManner(request.getToneAndManner()) // .toneAndManner(request.getToneAndManner())
.emotionIntensity(request.getEmotionIntensity()) // .emotionIntensity(request.getEmotionIntensity())
.eventName(request.getEventName()) .eventName(request.getEventName())
.startDate(request.getStartDate()) .startDate(request.getStartDate())
.endDate(request.getEndDate()) .endDate(request.getEndDate())

View File

@ -41,49 +41,12 @@ public class SnsContentService implements SnsContentUseCase {
@Transactional @Transactional
public SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request) { public SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request) {
// AI를 사용하여 SNS 콘텐츠 생성 // AI를 사용하여 SNS 콘텐츠 생성
String generatedContent = aiContentGenerator.generateSnsContent(request); String content = aiContentGenerator.generateSnsContent(request);
// 플랫폼에 맞는 해시태그 생성
Platform platform = Platform.fromString(request.getPlatform());
List<String> hashtags = aiContentGenerator.generateHashtags(generatedContent, platform);
// 생성 조건 정보 구성
CreationConditions conditions = CreationConditions.builder()
.category(request.getCategory())
.requirement(request.getRequirement())
.toneAndManner(request.getToneAndManner())
.emotionIntensity(request.getEmotionIntensity())
.eventName(request.getEventName())
.startDate(request.getStartDate())
.endDate(request.getEndDate())
.build();
// 임시 콘텐츠 생성 (저장하지 않음)
Content content = Content.builder()
// .contentType(ContentType.SNS_POST)
.platform(platform)
.title(request.getTitle())
.content(generatedContent)
.hashtags(hashtags)
.images(request.getImages())
.status(ContentStatus.DRAFT)
.creationConditions(conditions)
.storeId(request.getStoreId())
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.build();
return SnsContentCreateResponse.builder() return SnsContentCreateResponse.builder()
.contentId(null) // 임시 생성이므로 ID 없음 .content(content)
.contentType(content.getContentType().name())
.platform(content.getPlatform().name())
.title(content.getTitle())
.content(content.getContent())
.hashtags(content.getHashtags())
.fixedImages(content.getImages())
.status(content.getStatus().name())
.createdAt(content.getCreatedAt())
.build(); .build();
} }
/** /**
@ -98,8 +61,8 @@ public class SnsContentService implements SnsContentUseCase {
CreationConditions conditions = CreationConditions.builder() CreationConditions conditions = CreationConditions.builder()
.category(request.getCategory()) .category(request.getCategory())
.requirement(request.getRequirement()) .requirement(request.getRequirement())
.toneAndManner(request.getToneAndManner()) //.toneAndManner(request.getToneAndManner())
.emotionIntensity(request.getEmotionIntensity()) //.emotionIntensity(request.getEmotionIntensity())
.eventName(request.getEventName()) .eventName(request.getEventName())
.startDate(request.getStartDate()) .startDate(request.getStartDate())
.endDate(request.getEndDate()) .endDate(request.getEndDate())

View File

@ -44,7 +44,7 @@ public interface ContentQueryUseCase {
* @param contentId 콘텐츠 ID * @param contentId 콘텐츠 ID
* @return 콘텐츠 상세 정보 * @return 콘텐츠 상세 정보
*/ */
ContentDetailResponse getContentDetail(Long contentId); //ContentDetailResponse getContentDetail(Long contentId);
/** /**
* 콘텐츠 삭제 * 콘텐츠 삭제

View File

@ -0,0 +1,31 @@
// marketing-content/src/main/java/com/won/smarketing/content/config/WebClientConfig.java
package com.won.smarketing.content.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;
import io.netty.channel.ChannelOption;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import reactor.netty.http.client.HttpClient;
import java.time.Duration;
/**
* WebClient 설정
* Python AI 서비스 호출을 위한 WebClient 구성
*/
@Configuration
public class WebClientConfig {
@Bean
public WebClient webClient() {
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000)
.responseTimeout(Duration.ofMillis(30000));
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(1024 * 1024))
.build();
}
}

View File

@ -24,8 +24,11 @@ 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 toneAndManner;
private String emotionIntensity; // private String emotionIntensity;
private String storeName;
private String storeType;
private String target;
private String eventName; private String eventName;
private LocalDate startDate; private LocalDate startDate;
private LocalDate endDate; private LocalDate endDate;

View File

@ -2,6 +2,7 @@ package com.won.smarketing.content.domain.service;
import com.won.smarketing.content.domain.model.Platform; import com.won.smarketing.content.domain.model.Platform;
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 java.util.List; import java.util.List;
@ -18,13 +19,4 @@ public interface AiContentGenerator {
* @return 생성된 콘텐츠 * @return 생성된 콘텐츠
*/ */
String generateSnsContent(SnsContentCreateRequest request); String generateSnsContent(SnsContentCreateRequest request);
/**
* 플랫폼별 해시태그 생성
*
* @param content 콘텐츠 내용
* @param platform 플랫폼
* @return 해시태그 목록
*/
List<String> generateHashtags(String content, Platform platform);
} }

View File

@ -1,95 +1,99 @@
// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java
package com.won.smarketing.content.infrastructure.external; package com.won.smarketing.content.infrastructure.external;
// 수정: domain 패키지의 인터페이스를 import
import com.won.smarketing.content.domain.service.AiContentGenerator; import com.won.smarketing.content.domain.service.AiContentGenerator;
import com.won.smarketing.content.domain.model.Platform;
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 lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import java.util.Arrays; import java.time.Duration;
import java.util.List; import java.util.HashMap;
import java.util.Map;
/** /**
* Claude AI를 활용한 콘텐츠 생성 구현체 * Python AI SNS Content Service를 활용한 콘텐츠 생성 구현체
* Clean Architecture의 Infrastructure Layer에 위치
*/ */
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j @Slf4j
public class ClaudeAiContentGenerator implements AiContentGenerator { public class ClaudeAiContentGenerator implements AiContentGenerator {
private final WebClient webClient;
@Value("${external.ai-service.base-url:http://20.249.139.88:5001}")
private String aiServiceBaseUrl;
/** /**
* SNS 콘텐츠 생성 * SNS 콘텐츠 생성 - Python AI 서비스 호출
*/ */
@Override @Override
public String generateSnsContent(SnsContentCreateRequest request) { public String generateSnsContent(SnsContentCreateRequest request) {
try { log.info("Python AI 서비스 호출: {}/api/ai/sns", aiServiceBaseUrl);
String prompt = buildContentPrompt(request);
return generateDummySnsContent(request.getTitle(), Platform.fromString(request.getPlatform())); // 요청 데이터 구성
} catch (Exception e) { Map<String, Object> requestBody = new HashMap<>();
log.error("AI 콘텐츠 생성 실패: {}", e.getMessage(), e); requestBody.put("storeId", request.getStoreId());
return generateFallbackContent(request.getTitle(), Platform.fromString(request.getPlatform())); requestBody.put("storeName", request.getStoreName());
requestBody.put("storeType", request.getStoreType());
requestBody.put("platform", request.getPlatform());
requestBody.put("title", request.getTitle());
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 서비스 호출
Map<String, Object> response = webClient
.method(HttpMethod.GET)
.uri(aiServiceBaseUrl + "/api/ai/sns")
.header("Content-Type", "application/json")
.bodyValue(requestBody)
.retrieve()
.bodyToMono(Map.class)
.timeout(Duration.ofSeconds(30))
.block();
String content = "";
// 응답에서 content 추출
if (response != null && response.containsKey("content")) {
content = (String) response.get("content");
log.info("AI 서비스 응답 성공: contentLength={}", content.length());
return content;
} }
return content;
// } catch (Exception e) {
// log.error("AI 서비스 호출 실패: {}", e.getMessage(), e);
// return generateFallbackContent(request.getTitle(), Platform.fromString(request.getPlatform()));
// }
} }
/** /**
* 플랫폼별 해시태그 생성 * 폴백 콘텐츠 생성
*/ */
@Override // private String generateFallbackContent(String title, Platform platform) {
public List<String> generateHashtags(String content, Platform platform) { // String baseContent = "🌟 " + title + "를 소개합니다! 🌟\n\n" +
try { // "저희 매장에서 특별한 경험을 만나보세요.\n" +
return generateDummyHashtags(platform); // "고객 여러분의 소중한 시간을 더욱 특별하게 만들어드리겠습니다.\n\n";
} catch (Exception e) { //
log.error("해시태그 생성 실패: {}", e.getMessage(), e); // if (platform == Platform.INSTAGRAM) {
return generateFallbackHashtags(); // return baseContent + "더 많은 정보는 프로필 링크에서 확인하세요! 📸";
} // } else {
} // return baseContent + "자세한 내용은 저희 블로그를 방문해 주세요! ✨";
// }
private String buildContentPrompt(SnsContentCreateRequest request) { // }
StringBuilder prompt = new StringBuilder();
prompt.append("제목: ").append(request.getTitle()).append("\n");
prompt.append("카테고리: ").append(request.getCategory()).append("\n");
prompt.append("플랫폼: ").append(request.getPlatform()).append("\n");
if (request.getRequirement() != null) {
prompt.append("요구사항: ").append(request.getRequirement()).append("\n");
}
if (request.getToneAndManner() != null) {
prompt.append("톤앤매너: ").append(request.getToneAndManner()).append("\n");
}
return prompt.toString();
}
private String generateDummySnsContent(String title, Platform platform) {
String baseContent = "🌟 " + title + "를 소개합니다! 🌟\n\n" +
"저희 매장에서 특별한 경험을 만나보세요.\n" +
"고객 여러분의 소중한 시간을 더욱 특별하게 만들어드리겠습니다.\n\n";
if (platform == Platform.INSTAGRAM) {
return baseContent + "더 많은 정보는 프로필 링크에서 확인하세요! 📸";
} else {
return baseContent + "자세한 내용은 저희 블로그를 방문해 주세요! ✨";
}
}
private String generateFallbackContent(String title, Platform platform) {
return title + "에 대한 멋진 콘텐츠입니다. 많은 관심 부탁드립니다!";
}
private List<String> generateDummyHashtags(Platform platform) {
if (platform == Platform.INSTAGRAM) {
return Arrays.asList("#맛집", "#데일리", "#소상공인", "#추천", "#인스타그램");
} else {
return Arrays.asList("#맛집추천", "#블로그", "#리뷰", "#맛있는곳", "#소상공인응원");
}
}
private List<String> generateFallbackHashtags() {
return Arrays.asList("#소상공인", "#마케팅", "#홍보");
}
} }

View File

@ -105,8 +105,8 @@ public class ContentMapper {
ContentConditionsJpaEntity entity = new ContentConditionsJpaEntity(); ContentConditionsJpaEntity entity = new ContentConditionsJpaEntity();
entity.setCategory(conditions.getCategory()); entity.setCategory(conditions.getCategory());
entity.setRequirement(conditions.getRequirement()); entity.setRequirement(conditions.getRequirement());
entity.setToneAndManner(conditions.getToneAndManner()); // entity.setToneAndManner(conditions.getToneAndManner());
entity.setEmotionIntensity(conditions.getEmotionIntensity()); // entity.setEmotionIntensity(conditions.getEmotionIntensity());
entity.setEventName(conditions.getEventName()); entity.setEventName(conditions.getEventName());
entity.setStartDate(conditions.getStartDate()); entity.setStartDate(conditions.getStartDate());
entity.setEndDate(conditions.getEndDate()); entity.setEndDate(conditions.getEndDate());
@ -126,8 +126,8 @@ public class ContentMapper {
return CreationConditions.builder() return CreationConditions.builder()
.category(entity.getCategory()) .category(entity.getCategory())
.requirement(entity.getRequirement()) .requirement(entity.getRequirement())
.toneAndManner(entity.getToneAndManner()) // .toneAndManner(entity.getToneAndManner())
.emotionIntensity(entity.getEmotionIntensity()) // .emotionIntensity(entity.getEmotionIntensity())
.eventName(entity.getEventName()) .eventName(entity.getEventName())
.startDate(entity.getStartDate()) .startDate(entity.getStartDate())
.endDate(entity.getEndDate()) .endDate(entity.getEndDate())

View File

@ -143,14 +143,14 @@ public class ContentController {
* @param contentId 조회할 콘텐츠 ID * @param contentId 조회할 콘텐츠 ID
* @return 콘텐츠 상세 정보 * @return 콘텐츠 상세 정보
*/ */
@Operation(summary = "콘텐츠 상세 조회", description = "특정 콘텐츠의 상세 정보를 조회합니다.") // @Operation(summary = "콘텐츠 상세 조회", description = "특정 콘텐츠의 상세 정보를 조회합니다.")
@GetMapping("/{contentId}") // @GetMapping("/{contentId}")
public ResponseEntity<ApiResponse<ContentDetailResponse>> getContentDetail( // public ResponseEntity<ApiResponse<ContentDetailResponse>> getContentDetail(
@Parameter(description = "콘텐츠 ID", required = true) // @Parameter(description = "콘텐츠 ID", required = true)
@PathVariable Long contentId) { // @PathVariable Long contentId) {
ContentDetailResponse response = contentQueryUseCase.getContentDetail(contentId); // ContentDetailResponse response = contentQueryUseCase.getContentDetail(contentId);
return ResponseEntity.ok(ApiResponse.success(response)); // return ResponseEntity.ok(ApiResponse.success(response));
} // }
/** /**
* 콘텐츠 삭제 * 콘텐츠 삭제

View File

@ -1,5 +1,6 @@
package com.won.smarketing.content.presentation.dto; package com.won.smarketing.content.presentation.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
@ -23,6 +24,7 @@ import java.util.List;
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@Schema(description = "SNS 콘텐츠 생성 요청") @Schema(description = "SNS 콘텐츠 생성 요청")
@JsonIgnoreProperties(ignoreUnknown = true)
public class SnsContentCreateRequest { public class SnsContentCreateRequest {
// ==================== 기본 정보 ==================== // ==================== 기본 정보 ====================
@ -31,6 +33,12 @@ public class SnsContentCreateRequest {
@NotNull(message = "매장 ID는 필수입니다") @NotNull(message = "매장 ID는 필수입니다")
private Long storeId; private Long storeId;
@Schema(description = "매장 이름", example = "명륜진사갈비")
private String storeName;
@Schema(description = "업종", example = "한식")
private String storeType;
@Schema(description = "대상 플랫폼", @Schema(description = "대상 플랫폼",
example = "INSTAGRAM", example = "INSTAGRAM",
allowableValues = {"INSTAGRAM", "NAVER_BLOG", "FACEBOOK", "KAKAO_STORY"}, allowableValues = {"INSTAGRAM", "NAVER_BLOG", "FACEBOOK", "KAKAO_STORY"},
@ -54,15 +62,21 @@ public class SnsContentCreateRequest {
@Size(max = 500, message = "요구사항은 500자 이하로 입력해주세요") @Size(max = 500, message = "요구사항은 500자 이하로 입력해주세요")
private String requirement; private String requirement;
@Schema(description = "톤앤매너", @Schema(description = "타겟층", example = "10대 청소년")
example = "친근함", private String target;
allowableValues = {"친근함", "전문적", "유머러스", "감성적", "트렌디"})
private String toneAndManner;
@Schema(description = "감정 강도", @Schema(description = "콘텐츠 타입", example = "SNS 게시물")
example = "보통", private String contentType;
allowableValues = {"약함", "보통", "강함"})
private String emotionIntensity; // @Schema(description = "톤앤매너",
// example = "친근함",
// allowableValues = {"친근함", "전문적", "유머러스", "감성적", "트렌디"})
// private String toneAndManner;
// @Schema(description = "감정 강도",
// example = "보통",
// allowableValues = {"약함", "보통", "강함"})
// private String emotionIntensity;
// ==================== 이벤트 정보 ==================== // ==================== 이벤트 정보 ====================

View File

@ -1,5 +1,6 @@
package com.won.smarketing.content.presentation.dto; package com.won.smarketing.content.presentation.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
@ -20,364 +21,8 @@ import java.util.List;
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@Schema(description = "SNS 콘텐츠 생성 응답") @Schema(description = "SNS 콘텐츠 생성 응답")
@JsonIgnoreProperties(ignoreUnknown = true)
public class SnsContentCreateResponse { public class SnsContentCreateResponse {
@Schema(description = "생성된 콘텐츠")
// ==================== 기본 식별 정보 ====================
@Schema(description = "생성된 콘텐츠 ID", example = "1")
private Long contentId;
@Schema(description = "콘텐츠 타입", example = "SNS_POST")
private String contentType;
@Schema(description = "대상 플랫폼", example = "INSTAGRAM",
allowableValues = {"INSTAGRAM", "NAVER_BLOG", "FACEBOOK", "KAKAO_STORY"})
private String platform;
// ==================== AI 생성 콘텐츠 ====================
@Schema(description = "AI가 생성한 콘텐츠 제목",
example = "맛있는 신메뉴를 소개합니다! ✨")
private String title;
@Schema(description = "AI가 생성한 콘텐츠 내용",
example = "안녕하세요! 😊\n\n특별한 신메뉴가 출시되었습니다!\n진짜 맛있어서 꼭 한번 드셔보세요 🍽️\n\n매장에서 기다리고 있을게요! 💫")
private String content; private String content;
@Schema(description = "AI가 생성한 해시태그 목록",
example = "[\"맛집\", \"신메뉴\", \"추천\", \"인스타그램\", \"일상\", \"좋아요\", \"팔로우\", \"맛있어요\"]")
private List<String> hashtags;
// ==================== 플랫폼별 최적화 정보 ====================
@Schema(description = "플랫폼별 최적화된 콘텐츠 길이", example = "280")
private Integer contentLength;
@Schema(description = "플랫폼별 권장 해시태그 개수", example = "8")
private Integer recommendedHashtagCount;
@Schema(description = "플랫폼별 최대 해시태그 개수", example = "15")
private Integer maxHashtagCount;
// ==================== 생성 조건 정보 ====================
@Schema(description = "콘텐츠 생성에 사용된 조건들")
private GenerationConditionsDto generationConditions;
// ==================== 상태 메타데이터 ====================
@Schema(description = "생성 상태", example = "DRAFT",
allowableValues = {"DRAFT", "PUBLISHED", "SCHEDULED"})
private String status;
@Schema(description = "생성 일시", example = "2024-01-15T10:30:00")
private LocalDateTime createdAt;
@Schema(description = "AI 모델 버전", example = "gpt-4-turbo")
private String aiModelVersion;
@Schema(description = "생성 시간 (초)", example = "3.5")
private Double generationTimeSeconds;
// ==================== 추가 정보 ====================
@Schema(description = "업로드된 원본 이미지 URL 목록")
private List<String> originalImages;
@Schema(description = "콘텐츠 품질 점수 (1-100)", example = "85")
private Integer qualityScore;
@Schema(description = "예상 참여율 (%)", example = "12.5")
private Double expectedEngagementRate;
@Schema(description = "콘텐츠 카테고리", example = "음식/메뉴소개")
private String category;
@Schema(description = "보정된 이미지 URL 목록")
private List<String> fixedImages;
// ==================== 편집 가능 여부 ====================
@Schema(description = "제목 편집 가능 여부", example = "true")
@Builder.Default
private Boolean titleEditable = true;
@Schema(description = "내용 편집 가능 여부", example = "true")
@Builder.Default
private Boolean contentEditable = true;
@Schema(description = "해시태그 편집 가능 여부", example = "true")
@Builder.Default
private Boolean hashtagsEditable = true;
// ==================== 대안 콘텐츠 ====================
@Schema(description = "대안 제목 목록 (사용자 선택용)")
private List<String> alternativeTitles;
@Schema(description = "대안 해시태그 세트 목록")
private List<List<String>> alternativeHashtagSets;
// ==================== 내부 DTO 클래스 ====================
/**
* 콘텐츠 생성 조건 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "콘텐츠 생성 조건")
public static class GenerationConditionsDto {
@Schema(description = "홍보 대상", example = "메뉴")
private String targetAudience;
@Schema(description = "이벤트명", example = "신메뉴 출시 이벤트")
private String eventName;
@Schema(description = "톤앤매너", example = "친근함")
private String toneAndManner;
@Schema(description = "프로모션 유형", example = "할인 정보")
private String promotionType;
@Schema(description = "감정 강도", example = "보통")
private String emotionIntensity;
@Schema(description = "홍보 시작일", example = "2024-01-15T09:00:00")
private LocalDateTime promotionStartDate;
@Schema(description = "홍보 종료일", example = "2024-01-22T23:59:59")
private LocalDateTime promotionEndDate;
}
// ==================== 비즈니스 메서드 ====================
/**
* 플랫폼별 콘텐츠 최적화 여부 확인
*
* @return 콘텐츠가 플랫폼 권장 사항을 만족하면 true
*/
public boolean isOptimizedForPlatform() {
if (content == null || hashtags == null) {
return false;
}
// 플랫폼별 최적화 기준
switch (platform.toUpperCase()) {
case "INSTAGRAM":
return content.length() <= 2200 &&
hashtags.size() <= 15 &&
hashtags.size() >= 5;
case "NAVER_BLOG":
return content.length() >= 300 &&
hashtags.size() <= 10 &&
hashtags.size() >= 3;
case "FACEBOOK":
return content.length() <= 500 &&
hashtags.size() <= 5;
default:
return true;
}
}
/**
* 고품질 콘텐츠 여부 확인
*
* @return 품질 점수가 80점 이상이면 true
*/
public boolean isHighQuality() {
return qualityScore != null && qualityScore >= 80;
}
/**
* 참여율 예상 등급 반환
*
* @return 예상 참여율 등급 (HIGH, MEDIUM, LOW)
*/
public String getExpectedEngagementLevel() {
if (expectedEngagementRate == null) {
return "UNKNOWN";
}
if (expectedEngagementRate >= 15.0) {
return "HIGH";
} else if (expectedEngagementRate >= 8.0) {
return "MEDIUM";
} else {
return "LOW";
}
}
/**
* 해시태그를 문자열로 변환 (# 포함)
*
* @return #으로 시작하는 해시태그 문자열
*/
public String getHashtagsAsString() {
if (hashtags == null || hashtags.isEmpty()) {
return "";
}
return hashtags.stream()
.map(tag -> "#" + tag)
.reduce((a, b) -> a + " " + b)
.orElse("");
}
/**
* 콘텐츠 요약 생성
*
* @param maxLength 최대 길이
* @return 요약된 콘텐츠
*/
public String getContentSummary(int maxLength) {
if (content == null || content.length() <= maxLength) {
return content;
}
return content.substring(0, maxLength) + "...";
}
/**
* 플랫폼별 최적화 제안사항 반환
*
* @return 최적화 제안사항 목록
*/
public List<String> getOptimizationSuggestions() {
List<String> suggestions = new java.util.ArrayList<>();
if (!isOptimizedForPlatform()) {
switch (platform.toUpperCase()) {
case "INSTAGRAM":
if (content != null && content.length() > 2200) {
suggestions.add("콘텐츠 길이를 2200자 이하로 줄여주세요.");
}
if (hashtags != null && hashtags.size() > 15) {
suggestions.add("해시태그를 15개 이하로 줄여주세요.");
}
if (hashtags != null && hashtags.size() < 5) {
suggestions.add("해시태그를 5개 이상 추가해주세요.");
}
break;
case "NAVER_BLOG":
if (content != null && content.length() < 300) {
suggestions.add("블로그 포스팅을 위해 내용을 300자 이상으로 늘려주세요.");
}
if (hashtags != null && hashtags.size() > 10) {
suggestions.add("네이버 블로그는 해시태그를 10개 이하로 사용하는 것이 좋습니다.");
}
break;
case "FACEBOOK":
if (content != null && content.length() > 500) {
suggestions.add("페이스북에서는 500자 이하의 짧은 글이 더 효과적입니다.");
}
break;
}
}
return suggestions;
}
// ==================== 팩토리 메서드 ====================
/**
* 도메인 엔티티에서 SnsContentCreateResponse 생성
*
* @param content 콘텐츠 도메인 엔티티
* @param aiMetadata AI 생성 메타데이터
* @return SnsContentCreateResponse
*/
public static SnsContentCreateResponse fromDomain(
com.won.smarketing.content.domain.model.Content content,
AiGenerationMetadata aiMetadata) {
SnsContentCreateResponseBuilder builder = SnsContentCreateResponse.builder()
.contentId(content.getId())
.contentType(content.getContentType().name())
.platform(content.getPlatform().name())
.title(content.getTitle())
.content(content.getContent())
.hashtags(content.getHashtags())
.status(content.getStatus().name())
.createdAt(content.getCreatedAt())
.originalImages(content.getImages());
// 생성 조건 정보 설정
if (content.getCreationConditions() != null) {
builder.generationConditions(GenerationConditionsDto.builder()
//.targetAudience(content.getCreationConditions().getTargetAudience())
.eventName(content.getCreationConditions().getEventName())
.toneAndManner(content.getCreationConditions().getToneAndManner())
.promotionType(content.getCreationConditions().getPromotionType())
.emotionIntensity(content.getCreationConditions().getEmotionIntensity())
.promotionStartDate(content.getPromotionStartDate())
.promotionEndDate(content.getPromotionEndDate())
.build());
}
// AI 메타데이터 설정
if (aiMetadata != null) {
builder.aiModelVersion(aiMetadata.getModelVersion())
.generationTimeSeconds(aiMetadata.getGenerationTime())
.qualityScore(aiMetadata.getQualityScore())
.expectedEngagementRate(aiMetadata.getExpectedEngagementRate())
.alternativeTitles(aiMetadata.getAlternativeTitles())
.alternativeHashtagSets(aiMetadata.getAlternativeHashtagSets());
}
// 플랫폼별 최적화 정보 설정
SnsContentCreateResponse response = builder.build();
response.setContentLength(response.getContent() != null ? response.getContent().length() : 0);
response.setRecommendedHashtagCount(getRecommendedHashtagCount(content.getPlatform().name()));
response.setMaxHashtagCount(getMaxHashtagCount(content.getPlatform().name()));
return response;
}
/**
* 플랫폼별 권장 해시태그 개수 반환
*/
private static Integer getRecommendedHashtagCount(String platform) {
switch (platform.toUpperCase()) {
case "INSTAGRAM": return 8;
case "NAVER_BLOG": return 5;
case "FACEBOOK": return 3;
case "KAKAO_STORY": return 5;
default: return 5;
}
}
/**
* 플랫폼별 최대 해시태그 개수 반환
*/
private static Integer getMaxHashtagCount(String platform) {
switch (platform.toUpperCase()) {
case "INSTAGRAM": return 15;
case "NAVER_BLOG": return 10;
case "FACEBOOK": return 5;
case "KAKAO_STORY": return 8;
default: return 10;
}
}
// ==================== AI 생성 메타데이터 DTO ====================
/**
* AI 생성 메타데이터
* AI 생성 과정에서 나온 부가 정보들
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class AiGenerationMetadata {
private String modelVersion;
private Double generationTime;
private Integer qualityScore;
private Double expectedEngagementRate;
private List<String> alternativeTitles;
private List<List<String>> alternativeHashtagSets;
private String category;
}
} }

View File

@ -2,6 +2,9 @@ server:
port: ${SERVER_PORT:8083} port: ${SERVER_PORT:8083}
spring: spring:
jackson:
deserialization:
fail-on-unknown-properties: false
application: application:
name: marketing-content-service name: marketing-content-service
datasource: datasource:
@ -31,3 +34,6 @@ jwt:
logging: logging:
level: level:
com.won.smarketing: ${LOG_LEVEL:DEBUG} com.won.smarketing: ${LOG_LEVEL:DEBUG}
external:
ai-service:
base-url: ${AI_SERVICE_BASE_URL:http://20.249.139.88:5001}