marketing-contents file add

This commit is contained in:
박서은 2025-06-12 10:20:22 +09:00
parent 2004d7c736
commit 1e4dd2c7b0
30 changed files with 825 additions and 284 deletions

5
.idea/.gitignore generated vendored
View File

@ -1,5 +0,0 @@
# 디폴트 무시된 파일
/shelf/
/workspace.xml
# 환경에 따라 달라지는 Maven 홈 디렉터리
/mavenHomeManager.xml

11
.idea/gradle.xml generated
View File

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="ms-21" />
</GradleProjectSettings>
</option>
</component>
</project>

4
.idea/misc.xml generated
View File

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
</project>

6
.idea/vcs.xml generated
View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

109
.idea/workspace.xml generated Normal file
View File

@ -0,0 +1,109 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="7d9c48b3-e5c8-4a1c-af9a-469e24fa5715" name="변경" comment="">
<change beforePath="$PROJECT_DIR$/.idea/.gitignore" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/gradle.xml" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/misc.xml" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/vcs.xml" beforeDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="ExternalProjectsData">
<projectState path="$PROJECT_DIR$">
<ProjectState />
</projectState>
</component>
<component name="ExternalProjectsManager">
<system id="GRADLE">
<state>
<task path="$PROJECT_DIR$">
<activation />
</task>
<projects_view />
</state>
</system>
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="ProjectColorInfo">{
&quot;customColor&quot;: &quot;&quot;,
&quot;associatedIndex&quot;: 4
}</component>
<component name="ProjectId" id="2yLeuaqHXgKgtNCa4XzAZzifagS" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"Gradle.member.executor": "Run",
"Gradle.소스 다운로드.executor": "Run",
"ModuleVcsDetector.initialDetectionPerformed": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.git.unshallow": "true",
"git-widget-placeholder": "main",
"last_opened_file_path": "C:/home/workspace/smarketing/smarketing-backend",
"project.structure.last.edited": "SDK",
"project.structure.proportion": "0.15",
"project.structure.side.proportion": "0.2",
"settings.editor.selected.configurable": "reference.settingsdialog.project.gradle"
}
}]]></component>
<component name="RunDashboard">
<option name="configurationTypes">
<set>
<option value="GradleRunConfiguration" />
</set>
</option>
</component>
<component name="RunManager">
<configuration name="member" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="env">
<map>
<entry key="POSTGRES_HOST" value="psql-digitalgarage-02.postgres.database.azure.com" />
<entry key="POSTGRES_PASSWORD" value="DG_Won!" />
<entry key="POSTGRES_USER" value="pgadmin" />
</map>
</option>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="passParentEnvs" value="false" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value=":member:bootRun" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<method v="2" />
</configuration>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="디폴트 작업">
<changelist id="7d9c48b3-e5c8-4a1c-af9a-469e24fa5715" name="변경" comment="" />
<created>1749618504890</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1749618504890</updated>
</task>
<servers />
</component>
</project>

View File

@ -42,7 +42,7 @@ public class SnsContentService implements SnsContentUseCase {
public SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request) {
// AI를 사용하여 SNS 콘텐츠 생성
String generatedContent = aiContentGenerator.generateSnsContent(request);
// 플랫폼에 맞는 해시태그 생성
Platform platform = Platform.fromString(request.getPlatform());
List<String> hashtags = aiContentGenerator.generateHashtags(generatedContent, platform);
@ -60,7 +60,7 @@ public class SnsContentService implements SnsContentUseCase {
// 임시 콘텐츠 생성 (저장하지 않음)
Content content = Content.builder()
.contentType(ContentType.SNS_POST)
// .contentType(ContentType.SNS_POST)
.platform(platform)
.title(request.getTitle())
.content(generatedContent)
@ -88,7 +88,7 @@ public class SnsContentService implements SnsContentUseCase {
/**
* SNS 콘텐츠 저장
*
*
* @param request SNS 콘텐츠 저장 요청
*/
@Override
@ -107,7 +107,7 @@ public class SnsContentService implements SnsContentUseCase {
// 콘텐츠 엔티티 생성 저장
Content content = Content.builder()
.contentType(ContentType.SNS_POST)
// .contentType(ContentType.SNS_POST)
.platform(Platform.fromString(request.getPlatform()))
.title(request.getTitle())
.content(request.getContent())

View File

@ -1,3 +1,4 @@
// 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.presentation.dto.PosterContentCreateRequest;
@ -5,23 +6,21 @@ import com.won.smarketing.content.presentation.dto.PosterContentCreateResponse;
import com.won.smarketing.content.presentation.dto.PosterContentSaveRequest;
/**
* 포스터 콘텐츠 관련 Use Case 인터페이스
* 홍보 포스터 생성 저장 기능 정의
* 포스터 콘텐츠 관련 UseCase 인터페이스
* Clean Architecture의 Application Layer에서 비즈니스 로직 정의
*/
public interface PosterContentUseCase {
/**
* 포스터 콘텐츠 생성
*
* @param request 포스터 콘텐츠 생성 요청
* @return 생성된 포스터 콘텐츠 정보
* @return 포스터 콘텐츠 생성 응답
*/
PosterContentCreateResponse generatePosterContent(PosterContentCreateRequest request);
/**
* 포스터 콘텐츠 저장
*
* @param request 포스터 콘텐츠 저장 요청
*/
void savePosterContent(PosterContentSaveRequest request);
}
}

View File

@ -1,3 +1,4 @@
// marketing-content/src/main/java/com/won/smarketing/content/application/usecase/SnsContentUseCase.java
package com.won.smarketing.content.application.usecase;
import com.won.smarketing.content.presentation.dto.SnsContentCreateRequest;
@ -5,23 +6,21 @@ import com.won.smarketing.content.presentation.dto.SnsContentCreateResponse;
import com.won.smarketing.content.presentation.dto.SnsContentSaveRequest;
/**
* SNS 콘텐츠 관련 Use Case 인터페이스
* SNS 게시물 생성 저장 기능 정의
* SNS 콘텐츠 관련 UseCase 인터페이스
* Clean Architecture의 Application Layer에서 비즈니스 로직 정의
*/
public interface SnsContentUseCase {
/**
* SNS 콘텐츠 생성
*
* @param request SNS 콘텐츠 생성 요청
* @return 생성된 SNS 콘텐츠 정보
* @return SNS 콘텐츠 생성 응답
*/
SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request);
/**
* SNS 콘텐츠 저장
*
* @param request SNS 콘텐츠 저장 요청
*/
void saveSnsContent(SnsContentSaveRequest request);
}
}

View File

@ -1,18 +0,0 @@
// 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 {
}

View File

@ -46,7 +46,7 @@ public class Content {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "content_id")
@Column(name = "id")
private Long id;
// ==================== 콘텐츠 분류 ====================
@ -97,8 +97,7 @@ public class Content {
private ContentStatus status = ContentStatus.DRAFT;
// ==================== AI 생성 조건 (Embedded) ====================
@Embedded
//@Embedded
@AttributeOverrides({
@AttributeOverride(name = "toneAndManner", column = @Column(name = "tone_and_manner", length = 50)),
@AttributeOverride(name = "promotionType", column = @Column(name = "promotion_type", length = 50)),
@ -191,15 +190,15 @@ public class Content {
* @param status 새로운 상태
* @throws IllegalStateException 잘못된 상태 전환인 경우
*/
public void changeStatus(ContentStatus status) {
validateStatusTransition(this.status, status);
if (status == ContentStatus.PUBLISHED) {
validateForPublication();
}
this.status = status;
}
// public void changeStatus(ContentStatus status) {
// validateStatusTransition(this.status, status);
//
// if (status == ContentStatus.PUBLISHED) {
// validateForPublication();
// }
//
// this.status = status;
// }
/**
* 홍보 기간 설정
@ -352,9 +351,9 @@ public class Content {
*
* @return SNS 게시물이면 true
*/
public boolean isSnsContent() {
return this.contentType == ContentType.SNS_POST;
}
// public boolean isSnsContent() {
// return this.contentType == ContentType.SNS_POST;
// }
/**
* 포스터 콘텐츠 여부 확인
@ -424,11 +423,11 @@ public class Content {
/**
* 상태 전환 유효성 검증
*/
private void validateStatusTransition(ContentStatus from, ContentStatus to) {
if (from == ContentStatus.ARCHIVED && to != ContentStatus.PUBLISHED) {
throw new IllegalStateException("보관된 콘텐츠는 발행 상태로만 변경할 수 있습니다.");
}
}
// private void validateStatusTransition(ContentStatus from, ContentStatus to) {
// if (from == ContentStatus.ARCHIVED && to != ContentStatus.PUBLISHED) {
// throw new IllegalStateException("보관된 콘텐츠는 발행 상태로만 변경할 수 있습니다.");
// }
// }
/**
* 발행을 위한 유효성 검증
@ -502,7 +501,7 @@ public class Content {
public static Content createSnsContent(String title, String content, Platform platform,
Long storeId, CreationConditions conditions) {
Content snsContent = Content.builder()
.contentType(ContentType.SNS_POST)
// .contentType(ContentType.SNS_POST)
.platform(platform)
.title(title)
.content(content)

View File

@ -1,30 +1,53 @@
// marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java
package com.won.smarketing.content.domain.model;
import lombok.*;
import jakarta.persistence.Embeddable;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.Objects;
/**
* 콘텐츠 식별자 객체
* 콘텐츠의 고유 식별자를 나타내는 도메인 객체
* 콘텐츠 ID 객체
* Clean Architecture의 Domain Layer에 위치하는 식별자
*/
@Embeddable
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@EqualsAndHashCode
public class ContentId {
private Long value;
/**
* ContentId 생성 팩토리 메서드
*
* @param value 식별자
* @param value ID
* @return ContentId 인스턴스
*/
public static ContentId of(Long value) {
if (value == null || value <= 0) {
throw new IllegalArgumentException("ContentId 양수여야 합니다.");
throw new IllegalArgumentException("ContentId 값은 양수여야 합니다.");
}
return new ContentId(value);
}
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ContentId contentId = (ContentId) o;
return Objects.equals(value, contentId.value);
}
@Override
public int hashCode() {
return Objects.hash(value);
}
@Override
public String toString() {
return "ContentId{" + value + '}';
}
}

View File

@ -1,3 +1,4 @@
// marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java
package com.won.smarketing.content.domain.model;
import lombok.Getter;
@ -5,35 +6,35 @@ import lombok.RequiredArgsConstructor;
/**
* 콘텐츠 상태 열거형
* 콘텐츠의 생명주기 상태 정의
* Clean Architecture의 Domain Layer에 위치하는 비즈니스 규칙
*/
@Getter
@RequiredArgsConstructor
public enum ContentStatus {
DRAFT("임시저장"),
PUBLISHED("발행됨"),
ARCHIVED("보관됨");
PUBLISHED("게시됨"),
SCHEDULED("예약됨"),
DELETED("삭제됨"),
PROCESSING("처리중");
private final String displayName;
/**
* 문자열로부터 ContentStatus 변환
*
* @param status 상태 문자열
* @return ContentStatus
* @param value 문자열
* @return ContentStatus enum
* @throws IllegalArgumentException 유효하지 않은 값인 경우
*/
public static ContentStatus fromString(String status) {
if (status == null) {
return DRAFT;
public static ContentStatus fromString(String value) {
if (value == null) {
throw new IllegalArgumentException("ContentStatus 값은 null일 수 없습니다.");
}
for (ContentStatus s : ContentStatus.values()) {
if (s.name().equalsIgnoreCase(status)) {
return s;
}
try {
return ContentStatus.valueOf(value.toUpperCase());
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("유효하지 않은 ContentStatus 값입니다: " + value);
}
throw new IllegalArgumentException("알 수 없는 콘텐츠 상태: " + status);
}
}
}

View File

@ -1,3 +1,4 @@
// marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentType.java
package com.won.smarketing.content.domain.model;
import lombok.Getter;
@ -5,34 +6,34 @@ import lombok.RequiredArgsConstructor;
/**
* 콘텐츠 타입 열거형
* 지원되는 마케팅 콘텐츠 유형 정의
* Clean Architecture의 Domain Layer에 위치하는 비즈니스 규칙
*/
@Getter
@RequiredArgsConstructor
public enum ContentType {
SNS_POST("SNS 게시물"),
POSTER("홍보 포스터");
SNS("SNS 게시물"),
POSTER("홍보 포스터"),
VIDEO("동영상"),
BLOG("블로그 포스트");
private final String displayName;
/**
* 문자열로부터 ContentType 변환
*
* @param type 타입 문자열
* @return ContentType
* @param value 문자열
* @return ContentType enum
* @throws IllegalArgumentException 유효하지 않은 값인 경우
*/
public static ContentType fromString(String type) {
if (type == null) {
return null;
public static ContentType fromString(String value) {
if (value == null) {
throw new IllegalArgumentException("ContentType 값은 null일 수 없습니다.");
}
for (ContentType contentType : ContentType.values()) {
if (contentType.name().equalsIgnoreCase(type)) {
return contentType;
}
try {
return ContentType.valueOf(value.toUpperCase());
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("유효하지 않은 ContentType 값입니다: " + value);
}
throw new IllegalArgumentException("알 수 없는 콘텐츠 타입: " + type);
}
}
}

View File

@ -1,66 +1,68 @@
// marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java
package com.won.smarketing.content.domain.model;
import lombok.*;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
/**
* 콘텐츠 생성 조건 도메인 모델
* AI 콘텐츠 생성 사용되는 조건 정보
* Clean Architecture의 Domain Layer에 위치하는 객체
*/
@Entity
@Table(name = "contents_conditions")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor
@AllArgsConstructor
@Builder(toBuilder = true)
@Builder
public class CreationConditions {
/**
* 홍보 대상 카테고리
*/
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
//@OneToOne(mappedBy = "creationConditions")
@Column(name = "content", length = 100)
private Content 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;
/**
* 타겟 고객
*/
private String targetAudience;
/**
* 프로모션 타입
*/
@Column(name = "promotionType", length = 100)
private String promotionType;
}
public CreationConditions(String category, String requirement, String toneAndManner, String emotionIntensity, String eventName, LocalDate startDate, LocalDate endDate, String photoStyle, String promotionType) {
}
// /**
// * 콘텐츠와의 연관관계 설정
// * @param content 연관된 콘텐츠
// */
// public void setContent(Content content) {
// this.content = content;
// }
}

View File

@ -1,3 +1,4 @@
// marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java
package com.won.smarketing.content.domain.model;
import lombok.Getter;
@ -5,35 +6,36 @@ import lombok.RequiredArgsConstructor;
/**
* 플랫폼 열거형
* 콘텐츠가 게시될 플랫폼 정의
* Clean Architecture의 Domain Layer에 위치하는 비즈니스 규칙
*/
@Getter
@RequiredArgsConstructor
public enum Platform {
INSTAGRAM("인스타그램"),
NAVER_BLOG("네이버 블로그"),
GENERAL("범용");
FACEBOOK("페이스북"),
KAKAO_STORY("카카오스토리"),
YOUTUBE("유튜브"),
GENERAL("일반");
private final String displayName;
/**
* 문자열로부터 Platform 변환
*
* @param platform 플랫폼 문자열
* @return Platform
* @param value 문자열
* @return Platform enum
* @throws IllegalArgumentException 유효하지 않은 값인 경우
*/
public static Platform fromString(String platform) {
if (platform == null) {
return GENERAL;
public static Platform fromString(String value) {
if (value == null) {
throw new IllegalArgumentException("Platform 값은 null일 수 없습니다.");
}
for (Platform p : Platform.values()) {
if (p.name().equalsIgnoreCase(platform)) {
return p;
}
try {
return Platform.valueOf(value.toUpperCase());
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("유효하지 않은 Platform 값입니다: " + value);
}
throw new IllegalArgumentException("알 수 없는 플랫폼: " + platform);
}
}
}

View File

@ -1,40 +1,36 @@
// marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java
package com.won.smarketing.content.domain.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 org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* 콘텐츠 저장소 인터페이스
* 콘텐츠 도메인의 데이터 접근 추상화
* 콘텐츠 리포지토리 인터페이스
* Clean Architecture의 Domain Layer에서 데이터 접근 정의
*/
@Repository
public interface ContentRepository {
/**
* 콘텐츠 저장
*
* @param content 저장할 콘텐츠
* @return 저장된 콘텐츠
*/
Content save(Content content);
/**
* 콘텐츠 ID로 조회
*
* ID로 콘텐츠 조회
* @param id 콘텐츠 ID
* @return 콘텐츠 (Optional)
* @return 조회된 콘텐츠
*/
Optional<Content> findById(ContentId id);
/**
* 필터 조건으로 콘텐츠 목록 조회
*
* @param contentType 콘텐츠 타입
* @param platform 플랫폼
* @param period 기간
@ -42,19 +38,17 @@ public interface ContentRepository {
* @return 콘텐츠 목록
*/
List<Content> findByFilters(ContentType contentType, Platform platform, String period, String sortBy);
/**
* 진행 중인 콘텐츠 목록 조회
*
* @param period 기간
* @return 진행 중인 콘텐츠 목록
*/
List<Content> findOngoingContents(String period);
/**
* 콘텐츠 삭제
*
* ID로 콘텐츠 삭제
* @param id 삭제할 콘텐츠 ID
*/
void deleteById(ContentId id);
}
}

View File

@ -0,0 +1,38 @@
package com.won.smarketing.content.domain.repository;
import com.won.smarketing.content.infrastructure.entity.ContentEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* Spring Data JPA ContentRepository
* JPA 기반 콘텐츠 데이터 접근
*/
@Repository
public interface SpringDataContentRepository extends JpaRepository<ContentEntity, Long> {
/**
* 매장별 콘텐츠 조회
*
* @param storeId 매장 ID
* @return 콘텐츠 목록
*/
List<ContentEntity> findByStoreId(Long storeId);
/**
* 콘텐츠 타입별 조회
*
* @param contentType 콘텐츠 타입
* @return 콘텐츠 목록
*/
List<ContentEntity> findByContentType(String contentType);
/**
* 플랫폼별 조회
*
* @param platform 플랫폼
* @return 콘텐츠 목록
*/
List<ContentEntity> findByPlatform(String platform);
}

View File

@ -1,6 +1,8 @@
// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java
package com.won.smarketing.content.infrastructure.entity;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@ -8,24 +10,20 @@ import lombok.Setter;
import java.time.LocalDate;
/**
* 콘텐츠 조건 JPA 엔티티
*
* @author smarketing-team
* @version 1.0
* 콘텐츠 생성 조건 JPA 엔티티
*/
@Entity
@Table(name = "contents_conditions")
@Table(name = "content_conditions")
@Getter
@Setter
@NoArgsConstructor
public class ContentConditionsJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToOne
@JoinColumn(name = "content_id")
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "content_id", nullable = false)
private ContentJpaEntity content;
@Column(name = "category", length = 100)
@ -37,7 +35,7 @@ public class ContentConditionsJpaEntity {
@Column(name = "tone_and_manner", length = 100)
private String toneAndManner;
@Column(name = "emotion_intensity", length = 100)
@Column(name = "emotion_intensity", length = 50)
private String emotionIntensity;
@Column(name = "event_name", length = 200)
@ -52,9 +50,9 @@ public class ContentConditionsJpaEntity {
@Column(name = "photo_style", length = 100)
private String photoStyle;
@Column(name = "TargetAudience", length = 100)
@Column(name = "target_audience", length = 200)
private String targetAudience;
@Column(name = "PromotionType", length = 100)
private String PromotionType;
}
@Column(name = "promotion_type", length = 100)
private String promotionType;
}

View File

@ -0,0 +1,60 @@
package com.won.smarketing.content.infrastructure.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
/**
* 콘텐츠 엔티티
* 콘텐츠 정보를 데이터베이스에 저장하기 위한 JPA 엔티티
*/
@Entity
@Table(name = "contents")
@Data
@NoArgsConstructor
@AllArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public class ContentEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "content_type", nullable = false)
private String contentType;
@Column(name = "platform", nullable = false)
private String platform;
@Column(name = "title", nullable = false)
private String title;
@Column(name = "content", columnDefinition = "TEXT")
private String content;
@Column(name = "hashtags")
private String hashtags;
@Column(name = "images", columnDefinition = "TEXT")
private String images;
@Column(name = "status", nullable = false)
private String status;
@Column(name = "store_id", nullable = false)
private Long storeId;
@CreatedDate
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at")
private LocalDateTime updatedAt;
}

View File

@ -1,27 +1,24 @@
// 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.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
import java.util.List;
/**
* 콘텐츠 JPA 엔티티
*
* @author smarketing-team
* @version 1.0
*/
@Entity
@Table(name = "contents")
@Getter
@Setter
@NoArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public class ContentJpaEntity {
@Id
@ -43,24 +40,24 @@ public class ContentJpaEntity {
@Column(name = "content", columnDefinition = "TEXT")
private String content;
@Column(name = "hashtags", columnDefinition = "JSON")
@Column(name = "hashtags", columnDefinition = "TEXT")
private String hashtags;
@Column(name = "images", columnDefinition = "JSON")
@Column(name = "images", columnDefinition = "TEXT")
private String images;
@Column(name = "status", length = 50)
@Column(name = "status", nullable = false, length = 20)
private String status;
@CreationTimestamp
@Column(name = "created_at")
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@LastModifiedDate
@Column(name = "updated_at")
private LocalDateTime updatedAt;
// 연관 엔티티
// CreationConditions와의 관계 - OneToOne으로 별도 엔티티로 관리
@OneToOne(mappedBy = "content", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private ContentConditionsJpaEntity conditions;
}

View File

@ -0,0 +1,32 @@
// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiContentGenerator.java
package com.won.smarketing.content.infrastructure.external;
import com.won.smarketing.content.domain.model.Platform;
import com.won.smarketing.content.domain.model.CreationConditions;
import java.util.List;
/**
* AI 콘텐츠 생성 인터페이스
* Clean Architecture의 Infrastructure Layer에서 외부 AI 서비스와의 연동 정의
*/
public interface AiContentGenerator {
/**
* SNS 콘텐츠 생성
* @param title 제목
* @param category 카테고리
* @param platform 플랫폼
* @param conditions 생성 조건
* @return 생성된 콘텐츠 텍스트
*/
String generateSnsContent(String title, String category, Platform platform, CreationConditions conditions);
/**
* 해시태그 생성
* @param content 콘텐츠 내용
* @param platform 플랫폼
* @return 생성된 해시태그 목록
*/
List<String> generateHashtags(String content, Platform platform);
}

View File

@ -0,0 +1,29 @@
// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiPosterGenerator.java
package com.won.smarketing.content.infrastructure.external;
import com.won.smarketing.content.domain.model.CreationConditions;
import java.util.Map;
/**
* AI 포스터 생성 인터페이스
* Clean Architecture의 Infrastructure Layer에서 외부 AI 서비스와의 연동 정의
*/
public interface AiPosterGenerator {
/**
* 포스터 이미지 생성
* @param title 제목
* @param category 카테고리
* @param conditions 생성 조건
* @return 생성된 포스터 이미지 URL
*/
String generatePoster(String title, String category, CreationConditions conditions);
/**
* 포스터 다양한 사이즈 생성
* @param originalImage 원본 이미지 URL
* @return 사이즈별 이미지 URL
*/
Map<String, String> generatePosterSizes(String originalImage);
}

View File

@ -0,0 +1,125 @@
// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java
package com.won.smarketing.content.infrastructure.external;
import com.won.smarketing.content.domain.model.Platform;
import com.won.smarketing.content.domain.model.CreationConditions;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.List;
/**
* Claude AI를 활용한 콘텐츠 생성 구현체
* Clean Architecture의 Infrastructure Layer에 위치
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class ClaudeAiContentGenerator implements AiContentGenerator {
/**
* SNS 콘텐츠 생성
* Claude AI API를 호출하여 SNS 게시물을 생성합니다.
*
* @param title 제목
* @param category 카테고리
* @param platform 플랫폼
* @param conditions 생성 조건
* @return 생성된 콘텐츠 텍스트
*/
@Override
public String generateSnsContent(String title, String category, Platform platform, CreationConditions conditions) {
try {
// Claude AI API 호출 로직 (실제 구현에서는 HTTP 클라이언트를 사용)
String prompt = buildContentPrompt(title, category, platform, conditions);
// TODO: 실제 Claude AI API 호출
// 현재는 더미 데이터 반환
return generateDummySnsContent(title, platform);
} catch (Exception e) {
log.error("AI 콘텐츠 생성 실패: {}", e.getMessage(), e);
return generateFallbackContent(title, platform);
}
}
/**
* 해시태그 생성
* 콘텐츠 내용을 분석하여 관련 해시태그를 생성합니다.
*
* @param content 콘텐츠 내용
* @param platform 플랫폼
* @return 생성된 해시태그 목록
*/
@Override
public List<String> generateHashtags(String content, Platform platform) {
try {
// TODO: 실제 Claude AI API 호출하여 해시태그 생성
// 현재는 더미 데이터 반환
return generateDummyHashtags(platform);
} catch (Exception e) {
log.error("해시태그 생성 실패: {}", e.getMessage(), e);
return Arrays.asList("#맛집", "#신메뉴", "#추천");
}
}
/**
* AI 프롬프트 생성
*/
private String buildContentPrompt(String title, String category, Platform platform, CreationConditions conditions) {
StringBuilder prompt = new StringBuilder();
prompt.append("다음 조건에 맞는 ").append(platform.getDisplayName()).append(" 게시물을 작성해주세요:\n");
prompt.append("제목: ").append(title).append("\n");
prompt.append("카테고리: ").append(category).append("\n");
if (conditions.getRequirement() != null) {
prompt.append("요구사항: ").append(conditions.getRequirement()).append("\n");
}
if (conditions.getToneAndManner() != null) {
prompt.append("톤앤매너: ").append(conditions.getToneAndManner()).append("\n");
}
if (conditions.getEmotionIntensity() != null) {
prompt.append("감정 강도: ").append(conditions.getEmotionIntensity()).append("\n");
}
return prompt.toString();
}
/**
* 더미 SNS 콘텐츠 생성 (개발용)
*/
private String generateDummySnsContent(String title, Platform platform) {
switch (platform) {
case INSTAGRAM:
return String.format("🎉 %s\n\n맛있는 순간을 놓치지 마세요! 새로운 맛의 경험이 여러분을 기다리고 있어요. 따뜻한 분위기에서 즐기는 특별한 시간을 만들어보세요.\n\n📍 지금 바로 방문해보세요!", title);
case NAVER_BLOG:
return String.format("안녕하세요! 오늘은 %s에 대해 소개해드리려고 해요.\n\n정성스럽게 준비한 새로운 메뉴로 고객 여러분께 더 나은 경험을 선사하고 싶습니다. 많은 관심과 사랑 부탁드려요!", title);
default:
return String.format("%s - 새로운 경험을 만나보세요!", title);
}
}
/**
* 더미 해시태그 생성 (개발용)
*/
private List<String> generateDummyHashtags(Platform platform) {
switch (platform) {
case INSTAGRAM:
return Arrays.asList("#맛집", "#신메뉴", "#인스타그램", "#데일리", "#추천", "#음식스타그램");
case NAVER_BLOG:
return Arrays.asList("#맛집", "#리뷰", "#추천", "#신메뉴", "#블로그");
default:
return Arrays.asList("#맛집", "#신메뉴", "#추천");
}
}
/**
* 폴백 콘텐츠 생성 (AI 서비스 실패 )
*/
private String generateFallbackContent(String title, Platform platform) {
return String.format("🎉 %s\n\n새로운 소식을 전해드립니다. 많은 관심 부탁드려요!", title);
}
}

View File

@ -0,0 +1,118 @@
// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java
package com.won.smarketing.content.infrastructure.external;
import com.won.smarketing.content.domain.model.CreationConditions;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
/**
* Claude AI를 활용한 포스터 생성 구현체
* Clean Architecture의 Infrastructure Layer에 위치
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class ClaudeAiPosterGenerator implements AiPosterGenerator {
/**
* 포스터 이미지 생성
* Claude AI API를 호출하여 홍보 포스터를 생성합니다.
*
* @param title 제목
* @param category 카테고리
* @param conditions 생성 조건
* @return 생성된 포스터 이미지 URL
*/
@Override
public String generatePoster(String title, String category, CreationConditions conditions) {
try {
// Claude AI API 호출 로직 (실제 구현에서는 HTTP 클라이언트를 사용)
String prompt = buildPosterPrompt(title, category, conditions);
// TODO: 실제 Claude AI API 호출
// 현재는 더미 데이터 반환
return generateDummyPosterUrl(title);
} catch (Exception e) {
log.error("AI 포스터 생성 실패: {}", e.getMessage(), e);
return generateFallbackPosterUrl();
}
}
/**
* 포스터 다양한 사이즈 생성
* 원본 포스터를 기반으로 다양한 사이즈의 포스터를 생성합니다.
*
* @param originalImage 원본 이미지 URL
* @return 사이즈별 이미지 URL
*/
@Override
public Map<String, String> generatePosterSizes(String originalImage) {
try {
// TODO: 실제 이미지 리사이징 API 호출
// 현재는 더미 데이터 반환
return generateDummyPosterSizes(originalImage);
} catch (Exception e) {
log.error("포스터 사이즈 생성 실패: {}", e.getMessage(), e);
return new HashMap<>();
}
}
/**
* AI 포스터 프롬프트 생성
*/
private String buildPosterPrompt(String title, String category, CreationConditions conditions) {
StringBuilder prompt = new StringBuilder();
prompt.append("다음 조건에 맞는 홍보 포스터를 생성해주세요:\n");
prompt.append("제목: ").append(title).append("\n");
prompt.append("카테고리: ").append(category).append("\n");
if (conditions.getPhotoStyle() != null) {
prompt.append("사진 스타일: ").append(conditions.getPhotoStyle()).append("\n");
}
if (conditions.getRequirement() != null) {
prompt.append("요구사항: ").append(conditions.getRequirement()).append("\n");
}
if (conditions.getToneAndManner() != null) {
prompt.append("톤앤매너: ").append(conditions.getToneAndManner()).append("\n");
}
return prompt.toString();
}
/**
* 더미 포스터 URL 생성 (개발용)
*/
private String generateDummyPosterUrl(String title) {
return String.format("https://example.com/posters/%s-poster.jpg",
title.replaceAll("\\s+", "-").toLowerCase());
}
/**
* 더미 포스터 사이즈별 URL 생성 (개발용)
*/
private Map<String, String> generateDummyPosterSizes(String originalImage) {
Map<String, String> sizes = new HashMap<>();
String baseUrl = originalImage.substring(0, originalImage.lastIndexOf("."));
String extension = originalImage.substring(originalImage.lastIndexOf("."));
sizes.put("small", baseUrl + "-small" + extension);
sizes.put("medium", baseUrl + "-medium" + extension);
sizes.put("large", baseUrl + "-large" + extension);
sizes.put("xlarge", baseUrl + "-xlarge" + extension);
return sizes;
}
/**
* 폴백 포스터 URL 생성 (AI 서비스 실패 )
*/
private String generateFallbackPosterUrl() {
return "https://example.com/posters/default-poster.jpg";
}
}

View File

@ -91,7 +91,7 @@ public class ContentMapper {
entity.getConditions().getStartDate(),
entity.getConditions().getEndDate(),
entity.getConditions().getPhotoStyle(),
entity.getConditions().getTargetAudience(),
// entity.getConditions().getTargetAudience(),
entity.getConditions().getPromotionType()
);
}

View File

@ -1,3 +1,4 @@
// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java
package com.won.smarketing.content.infrastructure.repository;
import com.won.smarketing.content.domain.model.Content;
@ -5,60 +6,44 @@ 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
* JPA를 활용한 콘텐츠 리포지토리 구현체
* Clean Architecture의 Infrastructure Layer에 위치
*/
@Repository
@RequiredArgsConstructor
@Slf4j
public class JpaContentRepository implements ContentRepository {
private final SpringDataContentRepository springDataContentRepository;
private final ContentMapper contentMapper;
private final JpaContentRepositoryInterface jpaRepository;
/**
* 콘텐츠를 저장합니다.
*
* 콘텐츠 저장
* @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);
return jpaRepository.save(content);
}
/**
* ID로 콘텐츠를 조회합니다.
*
* 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);
return jpaRepository.findById(id.getValue());
}
/**
* 필터 조건으로 콘텐츠 목록을 조회합니다.
*
* 필터 조건으로 콘텐츠 목록 조회
* @param contentType 콘텐츠 타입
* @param platform 플랫폼
* @param period 기간
@ -67,45 +52,25 @@ public class JpaContentRepository implements ContentRepository {
*/
@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());
return jpaRepository.findByFilters(contentType, platform, period, sortBy);
}
/**
* 진행 중인 콘텐츠 목록을 조회합니다.
*
* 진행 중인 콘텐츠 목록 조회
* @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());
return jpaRepository.findOngoingContents(period);
}
/**
* ID로 콘텐츠를 삭제합니다.
*
* @param id 콘텐츠 ID
* ID로 콘텐츠 삭제
* @param id 삭제할 콘텐츠 ID
*/
@Override
public void deleteById(ContentId id) {
log.debug("Deleting content by id: {}", id.getValue());
springDataContentRepository.deleteById(id.getValue());
jpaRepository.deleteById(id.getValue());
}
}

View File

@ -0,0 +1,49 @@
// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepositoryInterface.java
package com.won.smarketing.content.infrastructure.repository;
import com.won.smarketing.content.domain.model.Content;
import com.won.smarketing.content.domain.model.ContentType;
import com.won.smarketing.content.domain.model.Platform;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
/**
* Spring Data JPA 콘텐츠 리포지토리 인터페이스
* Clean Architecture의 Infrastructure Layer에 위치
*/
public interface JpaContentRepositoryInterface extends JpaRepository<Content, Long> {
/**
* 필터 조건으로 콘텐츠 목록 조회
*/
@Query("SELECT c FROM Content c WHERE " +
"(:contentType IS NULL OR c.contentType = :contentType) AND " +
"(:platform IS NULL OR c.platform = :platform) AND " +
"(:period IS NULL OR " +
" (:period = 'week' AND c.createdAt >= CURRENT_DATE - 7) OR " +
" (:period = 'month' AND c.createdAt >= CURRENT_DATE - 30) OR " +
" (:period = 'year' AND c.createdAt >= CURRENT_DATE - 365)) " +
"ORDER BY " +
"CASE WHEN :sortBy = 'latest' THEN c.createdAt END DESC, " +
"CASE WHEN :sortBy = 'oldest' THEN c.createdAt END ASC, " +
"CASE WHEN :sortBy = 'title' THEN c.title END ASC")
List<Content> findByFilters(@Param("contentType") ContentType contentType,
@Param("platform") Platform platform,
@Param("period") String period,
@Param("sortBy") String sortBy);
/**
* 진행 중인 콘텐츠 목록 조회
*/
@Query("SELECT c FROM Content c WHERE " +
"c.status IN ('PUBLISHED', 'SCHEDULED') AND " +
"(:period IS NULL OR " +
" (:period = 'week' AND c.createdAt >= CURRENT_DATE - 7) OR " +
" (:period = 'month' AND c.createdAt >= CURRENT_DATE - 30) OR " +
" (:period = 'year' AND c.createdAt >= CURRENT_DATE - 365)) " +
"ORDER BY c.createdAt DESC")
List<Content> findOngoingContents(@Param("period") String period);
}

View File

@ -0,0 +1,45 @@
// marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/CreationConditionsDto.java
package com.won.smarketing.content.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
/**
* 콘텐츠 생성 조건 DTO
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "콘텐츠 생성 조건")
public class CreationConditionsDto {
@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 = "시작일")
private LocalDate startDate;
@Schema(description = "종료일")
private LocalDate endDate;
@Schema(description = "사진 스타일", example = "모던하고 깔끔한")
private String photoStyle;
}

View File

@ -306,7 +306,7 @@ public class SnsContentCreateResponse {
// 생성 조건 정보 설정
if (content.getCreationConditions() != null) {
builder.generationConditions(GenerationConditionsDto.builder()
.targetAudience(content.getCreationConditions().getTargetAudience())
//.targetAudience(content.getCreationConditions().getTargetAudience())
.eventName(content.getCreationConditions().getEventName())
.toneAndManner(content.getCreationConditions().getToneAndManner())
.promotionType(content.getCreationConditions().getPromotionType())

View File

@ -11,8 +11,8 @@ spring:
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: ${JPA_DDL_AUTO:update}
show-sql: ${JPA_SHOW_SQL:true}
ddl-auto: ${DDL_AUTO:update}
show-sql: ${SHOW_SQL:true}
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect