This commit is contained in:
unknown
2025-06-11 17:56:59 +09:00
36 changed files with 1544 additions and 586 deletions
@@ -1,4 +1,4 @@
dependencies {
implementation project(':common')
runtimeOnly 'com.mysql:mysql-connector-j'
runtimeOnly 'org.postgresql:postgresql'
}
@@ -9,9 +9,16 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
* 마케팅 콘텐츠 서비스 메인 애플리케이션 클래스
* Clean Architecture 패턴을 적용한 마케팅 콘텐츠 관리 서비스
*/
@SpringBootApplication(scanBasePackages = {"com.won.smarketing.content", "com.won.smarketing.common"})
@EntityScan(basePackages = {"com.won.smarketing.content.infrastructure.entity"})
@EnableJpaRepositories(basePackages = {"com.won.smarketing.content.infrastructure.repository"})
@SpringBootApplication(scanBasePackages = {
"com.won.smarketing.content",
"com.won.smarketing.common"
})
@EnableJpaRepositories(basePackages = {
"com.won.smarketing.content.infrastructure.repository"
})
@EntityScan(basePackages = {
"com.won.smarketing.content.domain.model"
})
public class MarketingContentServiceApplication {
public static void main(String[] args) {
@@ -40,18 +40,18 @@ public class ContentQueryService implements ContentQueryUseCase {
// 제목과 기간 업데이트
content.updateTitle(request.getTitle());
content.updatePeriod(request.getStartDate(), request.getEndDate());
content.updatePeriod(request.getPromotionStartDate(), request.getPromotionEndDate());
Content updatedContent = contentRepository.save(content);
return ContentUpdateResponse.builder()
.contentId(updatedContent.getId().getValue())
.contentType(updatedContent.getContentType().name())
.platform(updatedContent.getPlatform().name())
.contentId(updatedContent.getId())
//.contentType(updatedContent.getContentType().name())
//.platform(updatedContent.getPlatform().name())
.title(updatedContent.getTitle())
.content(updatedContent.getContent())
.hashtags(updatedContent.getHashtags())
.images(updatedContent.getImages())
//.hashtags(updatedContent.getHashtags())
//.images(updatedContent.getImages())
.status(updatedContent.getStatus().name())
.updatedAt(updatedContent.getUpdatedAt())
.build();
@@ -105,7 +105,7 @@ public class ContentQueryService implements ContentQueryUseCase {
.orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND));
return ContentDetailResponse.builder()
.contentId(content.getId().getValue())
.contentId(content.getId())
.contentType(content.getContentType().name())
.platform(content.getPlatform().name())
.title(content.getTitle())
@@ -140,7 +140,7 @@ public class ContentQueryService implements ContentQueryUseCase {
*/
private ContentResponse toContentResponse(Content content) {
return ContentResponse.builder()
.contentId(content.getId().getValue())
.contentId(content.getId())
.contentType(content.getContentType().name())
.platform(content.getPlatform().name())
.title(content.getTitle())
@@ -161,13 +161,13 @@ public class ContentQueryService implements ContentQueryUseCase {
*/
private OngoingContentResponse toOngoingContentResponse(Content content) {
return OngoingContentResponse.builder()
.contentId(content.getId().getValue())
.contentId(content.getId())
.contentType(content.getContentType().name())
.platform(content.getPlatform().name())
.title(content.getTitle())
.status(content.getStatus().name())
.createdAt(content.getCreatedAt())
.viewCount(0) // TODO: 실제 조회 수 구현 필요
.promotionStartDate(content.getPromotionStartDate())
//.viewCount(0) // TODO: 실제 조회 수 구현 필요
.build();
}
@@ -61,10 +61,10 @@ public class PosterContentService implements PosterContentUseCase {
.contentId(null) // 임시 생성이므로 ID 없음
.contentType(ContentType.POSTER.name())
.title(request.getTitle())
.image(generatedPoster)
.posterImage(generatedPoster)
.posterSizes(posterSizes)
.status(ContentStatus.DRAFT.name())
.createdAt(LocalDateTime.now())
//.createdAt(LocalDateTime.now())
.build();
}
@@ -39,7 +39,7 @@ public class SnsContentService implements SnsContentUseCase {
*/
@Override
@Transactional
/* public SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request) {
public SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request) {
// AI를 사용하여 SNS 콘텐츠 생성
String generatedContent = aiContentGenerator.generateSnsContent(request);
@@ -80,11 +80,11 @@ public class SnsContentService implements SnsContentUseCase {
.title(content.getTitle())
.content(content.getContent())
.hashtags(content.getHashtags())
.images(content.getImages())
.fixedImages(content.getImages())
.status(content.getStatus().name())
.createdAt(content.getCreatedAt())
.build();
}*/
}
/**
* SNS 콘텐츠 저장
@@ -0,0 +1,18 @@
// marketing-content/src/main/java/com/won/smarketing/content/config/JpaConfig.java
package com.won.smarketing.content.config;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
/**
* JPA 설정 클래스
*
* @author smarketing-team
* @version 1.0
*/
@Configuration
@EntityScan(basePackages = "com.won.smarketing.content.infrastructure.entity")
@EnableJpaRepositories(basePackages = "com.won.smarketing.content.infrastructure.repository")
public class JpaConfig {
}
@@ -0,0 +1,26 @@
// marketing-content/src/main/java/com/won/smarketing/content/config/ObjectMapperConfig.java
package com.won.smarketing.content.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* ObjectMapper 설정 클래스
*
* @author smarketing-team
* @version 1.0
*/
@Configuration
public class ObjectMapperConfig {
@Bean
public ObjectMapper objectMapper() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
return objectMapper;
}
}
@@ -131,6 +131,9 @@ public class Content {
@Column(name = "updated_at")
private LocalDateTime updatedAt;
public Content(ContentId of, ContentType contentType, Platform platform, String title, String content, List<String> strings, List<String> strings1, ContentStatus contentStatus, CreationConditions conditions, Long storeId, LocalDateTime createdAt, LocalDateTime updatedAt) {
}
// ==================== 비즈니스 로직 메서드 ====================
/**
@@ -216,6 +219,24 @@ public class Content {
this.promotionEndDate = endDate;
}
/**
* 홍보 기간 설정
*
* 비즈니스 규칙:
* - 시작일은 종료일보다 이전이어야 함
* - 과거 날짜로 설정 불가 (현재 시간 기준)
*
* @param startDate 홍보 시작일
* @param endDate 홍보 종료일
* @throws IllegalArgumentException 날짜가 유효하지 않은 경우
*/
public void updatePeriod(LocalDateTime startDate, LocalDateTime endDate) {
validatePromotionPeriod(startDate, endDate);
this.promotionStartDate = startDate;
this.promotionEndDate = endDate;
}
/**
* 해시태그 추가
*
@@ -1,16 +1,13 @@
package com.won.smarketing.content.domain.model;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.*;
/**
* 콘텐츠 식별자 값 객체
* 콘텐츠의 고유 식별자를 나타내는 도메인 객체
*/
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@EqualsAndHashCode
@@ -53,4 +53,14 @@ public class CreationConditions {
* 사진 스타일 (포스터용)
*/
private String photoStyle;
/**
* 타겟 고객
*/
private String targetAudience;
/**
* 프로모션 타입
*/
private String promotionType;
}
@@ -4,6 +4,7 @@ import com.won.smarketing.content.domain.model.Content;
import com.won.smarketing.content.domain.model.ContentId;
import com.won.smarketing.content.domain.model.ContentType;
import com.won.smarketing.content.domain.model.Platform;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@@ -12,6 +13,7 @@ import java.util.Optional;
* 콘텐츠 저장소 인터페이스
* 콘텐츠 도메인의 데이터 접근 추상화
*/
@Repository
public interface ContentRepository {
/**
@@ -0,0 +1,60 @@
package com.won.smarketing.content.infrastructure.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDate;
/**
* 콘텐츠 조건 JPA 엔티티
*
* @author smarketing-team
* @version 1.0
*/
@Entity
@Table(name = "contents_conditions")
@Getter
@Setter
@NoArgsConstructor
public class ContentConditionsJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToOne
@JoinColumn(name = "content_id")
private ContentJpaEntity content;
@Column(name = "category", length = 100)
private String category;
@Column(name = "requirement", columnDefinition = "TEXT")
private String requirement;
@Column(name = "tone_and_manner", length = 100)
private String toneAndManner;
@Column(name = "emotion_intensity", length = 100)
private String emotionIntensity;
@Column(name = "event_name", length = 200)
private String eventName;
@Column(name = "start_date")
private LocalDate startDate;
@Column(name = "end_date")
private LocalDate endDate;
@Column(name = "photo_style", length = 100)
private String photoStyle;
@Column(name = "TargetAudience", length = 100)
private String targetAudience;
@Column(name = "PromotionType", length = 100)
private String PromotionType;
}
@@ -0,0 +1,66 @@
// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java
package com.won.smarketing.content.infrastructure.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.LocalDateTime;
import java.util.List;
/**
* 콘텐츠 JPA 엔티티
*
* @author smarketing-team
* @version 1.0
*/
@Entity
@Table(name = "contents")
@Getter
@Setter
@NoArgsConstructor
public class ContentJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "store_id", nullable = false)
private Long storeId;
@Column(name = "content_type", nullable = false, length = 50)
private String contentType;
@Column(name = "platform", length = 50)
private String platform;
@Column(name = "title", length = 500)
private String title;
@Column(name = "content", columnDefinition = "TEXT")
private String content;
@Column(name = "hashtags", columnDefinition = "JSON")
private String hashtags;
@Column(name = "images", columnDefinition = "JSON")
private String images;
@Column(name = "status", length = 50)
private String status;
@CreationTimestamp
@Column(name = "created_at")
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
// 연관 엔티티
@OneToOne(mappedBy = "content", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private ContentConditionsJpaEntity conditions;
}
@@ -0,0 +1,144 @@
package com.won.smarketing.content.infrastructure.mapper;
import com.won.smarketing.content.domain.model.*;
import com.won.smarketing.content.infrastructure.entity.ContentConditionsJpaEntity;
import com.won.smarketing.content.infrastructure.entity.ContentJpaEntity;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.List;
/**
* 콘텐츠 도메인-엔티티 매퍼
*
* @author smarketing-team
* @version 1.0
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class ContentMapper {
private final ObjectMapper objectMapper;
/**
* 도메인 모델을 JPA 엔티티로 변환합니다.
*
* @param content 도메인 콘텐츠
* @return JPA 엔티티
*/
public ContentJpaEntity toEntity(Content content) {
if (content == null) {
return null;
}
ContentJpaEntity entity = new ContentJpaEntity();
if (content.getId() != null) {
entity.setId(content.getId());
}
entity.setStoreId(content.getStoreId());
entity.setContentType(content.getContentType().name());
entity.setPlatform(content.getPlatform() != null ? content.getPlatform().name() : null);
entity.setTitle(content.getTitle());
entity.setContent(content.getContent());
entity.setHashtags(convertListToJson(content.getHashtags()));
entity.setImages(convertListToJson(content.getImages()));
entity.setStatus(content.getStatus().name());
entity.setCreatedAt(content.getCreatedAt());
entity.setUpdatedAt(content.getUpdatedAt());
// 조건 정보 매핑
if (content.getCreationConditions() != null) {
ContentConditionsJpaEntity conditionsEntity = new ContentConditionsJpaEntity();
conditionsEntity.setContent(entity);
conditionsEntity.setCategory(content.getCreationConditions().getCategory());
conditionsEntity.setRequirement(content.getCreationConditions().getRequirement());
conditionsEntity.setToneAndManner(content.getCreationConditions().getToneAndManner());
conditionsEntity.setEmotionIntensity(content.getCreationConditions().getEmotionIntensity());
conditionsEntity.setEventName(content.getCreationConditions().getEventName());
conditionsEntity.setStartDate(content.getCreationConditions().getStartDate());
conditionsEntity.setEndDate(content.getCreationConditions().getEndDate());
conditionsEntity.setPhotoStyle(content.getCreationConditions().getPhotoStyle());
entity.setConditions(conditionsEntity);
}
return entity;
}
/**
* JPA 엔티티를 도메인 모델로 변환합니다.
*
* @param entity JPA 엔티티
* @return 도메인 콘텐츠
*/
public Content toDomain(ContentJpaEntity entity) {
if (entity == null) {
return null;
}
CreationConditions conditions = null;
if (entity.getConditions() != null) {
conditions = new CreationConditions(
entity.getConditions().getCategory(),
entity.getConditions().getRequirement(),
entity.getConditions().getToneAndManner(),
entity.getConditions().getEmotionIntensity(),
entity.getConditions().getEventName(),
entity.getConditions().getStartDate(),
entity.getConditions().getEndDate(),
entity.getConditions().getPhotoStyle(),
entity.getConditions().getTargetAudience(),
entity.getConditions().getPromotionType()
);
}
return new Content(
ContentId.of(entity.getId()),
ContentType.valueOf(entity.getContentType()),
entity.getPlatform() != null ? Platform.valueOf(entity.getPlatform()) : null,
entity.getTitle(),
entity.getContent(),
convertJsonToList(entity.getHashtags()),
convertJsonToList(entity.getImages()),
ContentStatus.valueOf(entity.getStatus()),
conditions,
entity.getStoreId(),
entity.getCreatedAt(),
entity.getUpdatedAt()
);
}
/**
* List를 JSON 문자열로 변환합니다.
*/
private String convertListToJson(List<String> list) {
if (list == null || list.isEmpty()) {
return null;
}
try {
return objectMapper.writeValueAsString(list);
} catch (Exception e) {
log.warn("Failed to convert list to JSON: {}", e.getMessage());
return null;
}
}
/**
* JSON 문자열을 List로 변환합니다.
*/
private List<String> convertJsonToList(String json) {
if (json == null || json.trim().isEmpty()) {
return Collections.emptyList();
}
try {
return objectMapper.readValue(json, new TypeReference<List<String>>() {});
} catch (Exception e) {
log.warn("Failed to convert JSON to list: {}", e.getMessage());
return Collections.emptyList();
}
}
}
@@ -0,0 +1,111 @@
package com.won.smarketing.content.infrastructure.repository;
import com.won.smarketing.content.domain.model.Content;
import com.won.smarketing.content.domain.model.ContentId;
import com.won.smarketing.content.domain.model.ContentType;
import com.won.smarketing.content.domain.model.Platform;
import com.won.smarketing.content.domain.repository.ContentRepository;
import com.won.smarketing.content.infrastructure.entity.ContentJpaEntity;
import com.won.smarketing.content.infrastructure.mapper.ContentMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* JPA 기반 콘텐츠 Repository 구현체
*
* @author smarketing-team
* @version 1.0
*/
@Repository
@RequiredArgsConstructor
@Slf4j
public class JpaContentRepository implements ContentRepository {
private final SpringDataContentRepository springDataContentRepository;
private final ContentMapper contentMapper;
/**
* 콘텐츠를 저장합니다.
*
* @param content 저장할 콘텐츠
* @return 저장된 콘텐츠
*/
@Override
public Content save(Content content) {
log.debug("Saving content: {}", content.getId());
ContentJpaEntity entity = contentMapper.toEntity(content);
ContentJpaEntity savedEntity = springDataContentRepository.save(entity);
return contentMapper.toDomain(savedEntity);
}
/**
* ID로 콘텐츠를 조회합니다.
*
* @param id 콘텐츠 ID
* @return 조회된 콘텐츠
*/
@Override
public Optional<Content> findById(ContentId id) {
log.debug("Finding content by id: {}", id.getValue());
return springDataContentRepository.findById(id.getValue())
.map(contentMapper::toDomain);
}
/**
* 필터 조건으로 콘텐츠 목록을 조회합니다.
*
* @param contentType 콘텐츠 타입
* @param platform 플랫폼
* @param period 기간
* @param sortBy 정렬 기준
* @return 콘텐츠 목록
*/
@Override
public List<Content> findByFilters(ContentType contentType, Platform platform, String period, String sortBy) {
log.debug("Finding contents by filters - type: {}, platform: {}, period: {}, sortBy: {}",
contentType, platform, period, sortBy);
List<ContentJpaEntity> entities = springDataContentRepository.findByFilters(
contentType != null ? contentType.name() : null,
platform != null ? platform.name() : null,
period,
sortBy
);
return entities.stream()
.map(contentMapper::toDomain)
.collect(Collectors.toList());
}
/**
* 진행 중인 콘텐츠 목록을 조회합니다.
*
* @param period 기간
* @return 진행 중인 콘텐츠 목록
*/
@Override
public List<Content> findOngoingContents(String period) {
log.debug("Finding ongoing contents for period: {}", period);
List<ContentJpaEntity> entities = springDataContentRepository.findOngoingContents(period);
return entities.stream()
.map(contentMapper::toDomain)
.collect(Collectors.toList());
}
/**
* ID로 콘텐츠를 삭제합니다.
*
* @param id 콘텐츠 ID
*/
@Override
public void deleteById(ContentId id) {
log.debug("Deleting content by id: {}", id.getValue());
springDataContentRepository.deleteById(id.getValue());
}
}
@@ -0,0 +1,51 @@
package com.won.smarketing.content.infrastructure.repository;
import com.won.smarketing.content.infrastructure.entity.ContentJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* Spring Data JPA 콘텐츠 Repository
*
* @author smarketing-team
* @version 1.0
*/
@Repository
public interface SpringDataContentRepository extends JpaRepository<ContentJpaEntity, Long> {
/**
* 필터 조건으로 콘텐츠를 조회합니다.
*
* @param contentType 콘텐츠 타입
* @param platform 플랫폼
* @param period 기간
* @param sortBy 정렬 기준
* @return 콘텐츠 목록
*/
@Query("SELECT c FROM ContentJpaEntity c WHERE " +
"(:contentType IS NULL OR c.contentType = :contentType) AND " +
"(:platform IS NULL OR c.platform = :platform) AND " +
"(:period IS NULL OR DATE(c.createdAt) >= CURRENT_DATE - INTERVAL :period DAY) " +
"ORDER BY " +
"CASE WHEN :sortBy = 'latest' THEN c.createdAt END DESC, " +
"CASE WHEN :sortBy = 'oldest' THEN c.createdAt END ASC")
List<ContentJpaEntity> findByFilters(@Param("contentType") String contentType,
@Param("platform") String platform,
@Param("period") String period,
@Param("sortBy") String sortBy);
/**
* 진행 중인 콘텐츠를 조회합니다.
*
* @param period 기간
* @return 진행 중인 콘텐츠 목록
*/
@Query("SELECT c FROM ContentJpaEntity c " +
"WHERE c.status = 'PUBLISHED' AND " +
"(:period IS NULL OR DATE(c.createdAt) >= CURRENT_DATE - INTERVAL :period DAY)")
List<ContentJpaEntity> findOngoingContents(@Param("period") String period);
}
@@ -12,7 +12,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import jakarta.validation.Valid;
import java.util.List;
/**
@@ -98,6 +98,9 @@ public class ContentResponse {
@Schema(description = "해시태그 개수", example = "8")
private Integer hashtagCount;
@Schema(description = "조회수", example = "8")
private Integer viewCount;
// ==================== 비즈니스 메서드 ====================
/**
@@ -227,7 +230,7 @@ public class ContentResponse {
*/
public static ContentResponse fromDomain(com.won.smarketing.content.domain.model.Content content) {
ContentResponseBuilder builder = ContentResponse.builder()
.contentId(content.getId().getValue())
.contentId(content.getId())
.contentType(content.getContentType().name())
.platform(content.getPlatform().name())
.title(content.getTitle())
@@ -1,3 +1,4 @@
// smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateRequest.java
package com.won.smarketing.content.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
@@ -8,6 +9,7 @@ import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
@@ -20,6 +22,13 @@ import java.util.List;
@Schema(description = "포스터 콘텐츠 생성 요청")
public class PosterContentCreateRequest {
@Schema(description = "매장 ID", example = "1", required = true)
@NotNull(message = "매장 ID는 필수입니다")
private Long storeId;
@Schema(description = "제목", example = "특별 이벤트 안내")
private String title;
@Schema(description = "홍보 대상", example = "메뉴", required = true)
@NotBlank(message = "홍보 대상은 필수입니다")
private String targetAudience;
@@ -48,4 +57,23 @@ public class PosterContentCreateRequest {
@NotNull(message = "이미지는 1개 이상 필수입니다")
@Size(min = 1, message = "이미지는 1개 이상 업로드해야 합니다")
private List<String> images;
// CreationConditions에 필요한 필드들
@Schema(description = "콘텐츠 카테고리", example = "이벤트")
private String category;
@Schema(description = "구체적인 요구사항", example = "신메뉴 출시 이벤트 포스터를 만들어주세요")
private String requirement;
@Schema(description = "톤앤매너", example = "전문적")
private String toneAndManner;
@Schema(description = "이벤트 시작일", example = "2024-01-15")
private LocalDate startDate;
@Schema(description = "이벤트 종료일", example = "2024-01-31")
private LocalDate endDate;
@Schema(description = "사진 스타일", example = "밝고 화사한")
private String photoStyle;
}
@@ -7,6 +7,7 @@ import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.Map;
/**
* 포스터 콘텐츠 생성 응답 DTO
@@ -27,8 +28,11 @@ public class PosterContentCreateResponse {
@Schema(description = "생성된 포스터 텍스트 내용")
private String content;
@Schema(description = "포스터 이미지 URL 목록")
private List<String> posterImages;
@Schema(description = "생성된 포스터 타입")
private String contentType;
@Schema(description = "포스터 이미지 URL")
private String posterImage;
@Schema(description = "원본 이미지 URL 목록")
private List<String> originalImages;
@@ -38,4 +42,8 @@ public class PosterContentCreateResponse {
@Schema(description = "생성 상태", example = "DRAFT")
private String status;
@Schema(description = "포스터사이즈", example = "800x600")
private Map<String, String> posterSizes;
}
@@ -1,3 +1,4 @@
// smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentSaveRequest.java
package com.won.smarketing.content.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
@@ -6,6 +7,9 @@ import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.util.List;
/**
* 포스터 콘텐츠 저장 요청 DTO
*/
@@ -19,15 +23,44 @@ public class PosterContentSaveRequest {
@NotNull(message = "콘텐츠 ID는 필수입니다")
private Long contentId;
@Schema(description = "최종 제목", example = "특별 이벤트 안내")
private String finalTitle;
@Schema(description = "매장 ID", example = "1", required = true)
@NotNull(message = "매장 ID는 필수입니다")
private Long storeId;
@Schema(description = "최종 콘텐츠 내용")
private String finalContent;
@Schema(description = "제목", example = "특별 이벤트 안내")
private String title;
@Schema(description = "콘텐츠 내용")
private String content;
@Schema(description = "선택된 포스터 이미지 URL")
private String selectedPosterImage;
private List<String> images;
@Schema(description = "발행 상태", example = "PUBLISHED")
private String status;
// CreationConditions에 필요한 필드들
@Schema(description = "콘텐츠 카테고리", example = "이벤트")
private String category;
@Schema(description = "구체적인 요구사항", example = "신메뉴 출시 이벤트 포스터를 만들어주세요")
private String requirement;
@Schema(description = "톤앤매너", example = "전문적")
private String toneAndManner;
@Schema(description = "감정 강도", example = "보통")
private String emotionIntensity;
@Schema(description = "이벤트명", example = "신메뉴 출시 이벤트")
private String eventName;
@Schema(description = "이벤트 시작일", example = "2024-01-15")
private LocalDate startDate;
@Schema(description = "이벤트 종료일", example = "2024-01-31")
private LocalDate endDate;
@Schema(description = "사진 스타일", example = "밝고 화사한")
private String photoStyle;
}
@@ -0,0 +1,160 @@
package com.won.smarketing.content.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.util.List;
/**
* SNS 콘텐츠 생성 요청 DTO
*
* AI 기반 SNS 콘텐츠 생성을 위한 요청 정보를 담고 있습니다.
* 사용자가 입력한 생성 조건을 바탕으로 AI가 적절한 SNS 콘텐츠를 생성합니다.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "SNS 콘텐츠 생성 요청")
public class SnsContentCreateRequest {
// ==================== 기본 정보 ====================
@Schema(description = "매장 ID", example = "1", required = true)
@NotNull(message = "매장 ID는 필수입니다")
private Long storeId;
@Schema(description = "대상 플랫폼",
example = "INSTAGRAM",
allowableValues = {"INSTAGRAM", "NAVER_BLOG", "FACEBOOK", "KAKAO_STORY"},
required = true)
@NotBlank(message = "플랫폼은 필수입니다")
private String platform;
@Schema(description = "콘텐츠 제목", example = "1", required = true)
@NotNull(message = "콘텐츠 제목은 필수입니다")
private String title;
// ==================== 콘텐츠 생성 조건 ====================
@Schema(description = "콘텐츠 카테고리",
example = "메뉴소개",
allowableValues = {"메뉴소개", "이벤트", "일상", "인테리어", "고객후기", "기타"})
private String category;
@Schema(description = "구체적인 요구사항 또는 홍보하고 싶은 내용",
example = "새로 출시된 시그니처 버거를 홍보하고 싶어요")
@Size(max = 500, message = "요구사항은 500자 이하로 입력해주세요")
private String requirement;
@Schema(description = "톤앤매너",
example = "친근함",
allowableValues = {"친근함", "전문적", "유머러스", "감성적", "트렌디"})
private String toneAndManner;
@Schema(description = "감정 강도",
example = "보통",
allowableValues = {"약함", "보통", "강함"})
private String emotionIntensity;
// ==================== 이벤트 정보 ====================
@Schema(description = "이벤트명 (이벤트 콘텐츠인 경우)",
example = "신메뉴 출시 이벤트")
@Size(max = 200, message = "이벤트명은 200자 이하로 입력해주세요")
private String eventName;
@Schema(description = "이벤트 시작일 (이벤트 콘텐츠인 경우)",
example = "2024-01-15")
private LocalDate startDate;
@Schema(description = "이벤트 종료일 (이벤트 콘텐츠인 경우)",
example = "2024-01-31")
private LocalDate endDate;
// ==================== 미디어 정보 ====================
@Schema(description = "업로드된 이미지 파일 경로 목록")
private List<String> images;
@Schema(description = "사진 스타일 선호도",
example = "밝고 화사한",
allowableValues = {"밝고 화사한", "차분하고 세련된", "빈티지한", "모던한", "자연스러운"})
private String photoStyle;
// ==================== 추가 옵션 ====================
@Schema(description = "해시태그 포함 여부", example = "true")
@Builder.Default
private Boolean includeHashtags = true;
@Schema(description = "이모지 포함 여부", example = "true")
@Builder.Default
private Boolean includeEmojis = true;
@Schema(description = "콜투액션 포함 여부 (좋아요, 팔로우 요청 등)", example = "true")
@Builder.Default
private Boolean includeCallToAction = true;
@Schema(description = "매장 위치 정보 포함 여부", example = "false")
@Builder.Default
private Boolean includeLocation = false;
// ==================== 플랫폼별 옵션 ====================
@Schema(description = "인스타그램 스토리용 여부 (Instagram인 경우)", example = "false")
@Builder.Default
private Boolean forInstagramStory = false;
@Schema(description = "네이버 블로그 포스팅용 여부 (Naver Blog인 경우)", example = "false")
@Builder.Default
private Boolean forNaverBlogPost = false;
// ==================== AI 생성 옵션 ====================
@Schema(description = "대안 제목 생성 개수", example = "3")
@Builder.Default
private Integer alternativeTitleCount = 3;
@Schema(description = "대안 해시태그 세트 생성 개수", example = "2")
@Builder.Default
private Integer alternativeHashtagSetCount = 2;
@Schema(description = "AI 모델 버전 지정 (없으면 기본값 사용)", example = "gpt-4-turbo")
private String preferredAiModel;
// ==================== 검증 메서드 ====================
/**
* 이벤트 날짜 유효성 검증
* 시작일이 종료일보다 이후인지 확인
*/
public boolean isValidEventDates() {
if (startDate != null && endDate != null) {
return !startDate.isAfter(endDate);
}
return true;
}
/**
* 플랫폼별 필수 조건 검증
*/
public boolean isValidForPlatform() {
if ("INSTAGRAM".equals(platform)) {
// 인스타그램은 이미지가 권장됨
return images != null && !images.isEmpty();
}
if ("NAVER_BLOG".equals(platform)) {
// 네이버 블로그는 상세한 내용이 필요
return requirement != null && requirement.length() >= 20;
}
return true;
}
}
@@ -93,6 +93,9 @@ public class SnsContentCreateResponse {
@Schema(description = "콘텐츠 카테고리", example = "음식/메뉴소개")
private String category;
@Schema(description = "보정된 이미지 URL 목록")
private List<String> fixedImages;
// ==================== 편집 가능 여부 ====================
@Schema(description = "제목 편집 가능 여부", example = "true")
@@ -1,3 +1,4 @@
// smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentSaveRequest.java
package com.won.smarketing.content.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
@@ -8,6 +9,7 @@ import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
@@ -24,6 +26,26 @@ public class SnsContentSaveRequest {
@NotNull(message = "콘텐츠 ID는 필수입니다")
private Long contentId;
@Schema(description = "매장 ID", example = "1", required = true)
@NotNull(message = "매장 ID는 필수입니다")
private Long storeId;
@Schema(description = "플랫폼", example = "INSTAGRAM", required = true)
@NotBlank(message = "플랫폼은 필수입니다")
private String platform;
@Schema(description = "제목", example = "맛있는 신메뉴를 소개합니다!")
private String title;
@Schema(description = "콘텐츠 내용")
private String content;
@Schema(description = "해시태그 목록")
private List<String> hashtags;
@Schema(description = "이미지 URL 목록")
private List<String> images;
@Schema(description = "최종 제목", example = "맛있는 신메뉴를 소개합니다!")
private String finalTitle;
@@ -32,4 +54,26 @@ public class SnsContentSaveRequest {
@Schema(description = "발행 상태", example = "PUBLISHED")
private String status;
// CreationConditions에 필요한 필드들
@Schema(description = "콘텐츠 카테고리", example = "메뉴소개")
private String category;
@Schema(description = "구체적인 요구사항", example = "새로 출시된 시그니처 버거를 홍보하고 싶어요")
private String requirement;
@Schema(description = "톤앤매너", example = "친근함")
private String toneAndManner;
@Schema(description = "감정 강도", example = "보통")
private String emotionIntensity;
@Schema(description = "이벤트명", example = "신메뉴 출시 이벤트")
private String eventName;
@Schema(description = "이벤트 시작일", example = "2024-01-15")
private LocalDate startDate;
@Schema(description = "이벤트 종료일", example = "2024-01-31")
private LocalDate endDate;
}
@@ -1,15 +1,14 @@
server:
port: ${SERVER_PORT:8083}
servlet:
context-path: /
spring:
application:
name: marketing-content-service
datasource:
url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:contentdb}
url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:MarketingContentDB}
username: ${POSTGRES_USER:postgres}
password: ${POSTGRES_PASSWORD:postgres}
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: ${JPA_DDL_AUTO:update}
@@ -29,14 +28,10 @@ external:
base-url: ${CLAUDE_AI_BASE_URL:https://api.anthropic.com}
model: ${CLAUDE_AI_MODEL:claude-3-sonnet-20240229}
max-tokens: ${CLAUDE_AI_MAX_TOKENS:4000}
springdoc:
swagger-ui:
path: /swagger-ui.html
operations-sorter: method
api-docs:
path: /api-docs
jwt:
secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789}
access-token-validity: ${JWT_ACCESS_VALIDITY:3600000}
refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000}
logging:
level:
com.won.smarketing.content: ${LOG_LEVEL:DEBUG}
+2 -1
View File
@@ -1,4 +1,5 @@
dependencies {
implementation project(':common')
runtimeOnly 'com.mysql:mysql-connector-j'
// 데이터베이스 의존성
runtimeOnly 'org.postgresql:postgresql'
}