diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java index 08115e2..50d69a1 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java @@ -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) { diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/JpaConfig.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/JpaConfig.java new file mode 100644 index 0000000..e95312d --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/JpaConfig.java @@ -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 { +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ObjectMapperConfig.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ObjectMapperConfig.java new file mode 100644 index 0000000..f9a77b8 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ObjectMapperConfig.java @@ -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; + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java index c83f178..4e95d02 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java @@ -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 strings, List strings1, ContentStatus contentStatus, CreationConditions conditions, Long storeId, LocalDateTime createdAt, LocalDateTime updatedAt) { + } + // ==================== 비즈니스 로직 메서드 ==================== /** diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java index b2a77bb..13bb3b0 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java @@ -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 diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java index 194c7aa..818506f 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java @@ -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 { /** diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java new file mode 100644 index 0000000..17f49f8 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java @@ -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; +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java new file mode 100644 index 0000000..7f87560 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java @@ -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; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java new file mode 100644 index 0000000..49cc6b4 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java @@ -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 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 convertJsonToList(String json) { + if (json == null || json.trim().isEmpty()) { + return Collections.emptyList(); + } + try { + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (Exception e) { + log.warn("Failed to convert JSON to list: {}", e.getMessage()); + return Collections.emptyList(); + } + } +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java new file mode 100644 index 0000000..9396d4d --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java @@ -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 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 findByFilters(ContentType contentType, Platform platform, String period, String sortBy) { + log.debug("Finding contents by filters - type: {}, platform: {}, period: {}, sortBy: {}", + contentType, platform, period, sortBy); + + List 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 findOngoingContents(String period) { + log.debug("Finding ongoing contents for period: {}", period); + List 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()); + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/SpringDataContentRepository.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/SpringDataContentRepository.java new file mode 100644 index 0000000..feba6b4 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/SpringDataContentRepository.java @@ -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 { + + /** + * 필터 조건으로 콘텐츠를 조회합니다. + * + * @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 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 findOngoingContents(@Param("period") String period); +} \ No newline at end of file