content-service 핵심 비즈니스 로직 및 영속성 계층 구현 완료

- Domain 모델 구현 (ImageStyle, Platform, GeneratedImage, Content, Job)
- JPA Entity 및 Repository 구현 (3개 엔티티, 3개 리포지토리)
- UseCase 인터페이스 정의 (Inbound 6개, Outbound 8개)
- Service 구현 (JobManagement, GetEventContent, GetImageList, GetImageDetail)
- DTO 구현 (ContentCommand, ContentInfo, ImageInfo, JobInfo)
- Application 설정 (ContentApplication, application.yml)
- 컴파일 오류 수정 및 검증 완료

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
cherry2250 2025-10-23 20:48:56 +09:00
parent ea82ff4748
commit 3d1dbda74b
43 changed files with 1603 additions and 0 deletions

Binary file not shown.

View File

@ -0,0 +1,99 @@
package com.kt.event.content.biz.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 콘텐츠 도메인 모델
* 이벤트에 대한 전체 콘텐츠 정보 (이미지 목록 포함)
*/
@Getter
@Builder
@AllArgsConstructor
public class Content {
/**
* 콘텐츠 ID
*/
private final Long id;
/**
* 이벤트 ID (이벤트 초안 ID)
*/
private final Long eventDraftId;
/**
* 이벤트 제목
*/
private final String eventTitle;
/**
* 이벤트 설명
*/
private final String eventDescription;
/**
* 생성된 이미지 목록
*/
@Builder.Default
private final List<GeneratedImage> images = new ArrayList<>();
/**
* 생성일시
*/
private final LocalDateTime createdAt;
/**
* 수정일시
*/
private final LocalDateTime updatedAt;
/**
* 이미지 추가
*
* @param image 생성된 이미지
*/
public void addImage(GeneratedImage image) {
this.images.add(image);
}
/**
* 선택된 이미지 조회
*
* @return 선택된 이미지 목록
*/
public List<GeneratedImage> getSelectedImages() {
return images.stream()
.filter(GeneratedImage::isSelected)
.toList();
}
/**
* 특정 스타일의 이미지 조회
*
* @param style 이미지 스타일
* @return 해당 스타일의 이미지 목록
*/
public List<GeneratedImage> getImagesByStyle(ImageStyle style) {
return images.stream()
.filter(image -> image.getStyle() == style)
.toList();
}
/**
* 특정 플랫폼의 이미지 조회
*
* @param platform 플랫폼
* @return 해당 플랫폼의 이미지 목록
*/
public List<GeneratedImage> getImagesByPlatform(Platform platform) {
return images.stream()
.filter(image -> image.getPlatform() == platform)
.toList();
}
}

View File

@ -0,0 +1,76 @@
package com.kt.event.content.biz.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import java.time.LocalDateTime;
/**
* 생성된 이미지 도메인 모델
* AI가 생성한 이미지의 비즈니스 정보
*/
@Getter
@Builder
@AllArgsConstructor
public class GeneratedImage {
/**
* 이미지 ID
*/
private final Long id;
/**
* 이벤트 ID (이벤트 초안 ID)
*/
private final Long eventDraftId;
/**
* 이미지 스타일
*/
private final ImageStyle style;
/**
* 플랫폼
*/
private final Platform platform;
/**
* CDN URL (Azure Blob Storage)
*/
private final String cdnUrl;
/**
* 프롬프트
*/
private final String prompt;
/**
* 선택 여부
*/
private boolean selected;
/**
* 생성일시
*/
private LocalDateTime createdAt;
/**
* 수정일시
*/
private LocalDateTime updatedAt;
/**
* 이미지 선택
*/
public void select() {
this.selected = true;
}
/**
* 이미지 선택 해제
*/
public void deselect() {
this.selected = false;
}
}

View File

@ -0,0 +1,32 @@
package com.kt.event.content.biz.domain;
/**
* 이미지 스타일 enum
* AI가 생성하는 이미지의 스타일 유형
*/
public enum ImageStyle {
/**
* 심플 스타일 - 깔끔하고 미니멀한 디자인
*/
SIMPLE("심플"),
/**
* 화려한 스타일 - 화려하고 풍부한 디자인
*/
FANCY("화려한"),
/**
* 트렌디 스타일 - 최신 트렌드를 반영한 디자인
*/
TRENDY("트렌디");
private final String displayName;
ImageStyle(String displayName) {
this.displayName = displayName;
}
public String getDisplayName() {
return displayName;
}
}

View File

@ -0,0 +1,140 @@
package com.kt.event.content.biz.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import java.time.LocalDateTime;
/**
* Job 도메인 모델
* 이미지 생성 작업의 비즈니스 정보
*/
@Getter
@Builder
@AllArgsConstructor
public class Job {
/**
* Job 상태 enum
*/
public enum Status {
PENDING, // 대기
PROCESSING, // 처리
COMPLETED, // 완료
FAILED // 실패
}
/**
* Job ID
*/
private final String id;
/**
* 이벤트 ID (이벤트 초안 ID)
*/
private final Long eventDraftId;
/**
* Job 타입 (image-generation)
*/
private final String jobType;
/**
* Job 상태
*/
private Status status;
/**
* 진행률 (0-100)
*/
private int progress;
/**
* 결과 메시지
*/
private String resultMessage;
/**
* 에러 메시지
*/
private String errorMessage;
/**
* 생성일시
*/
private final LocalDateTime createdAt;
/**
* 수정일시
*/
private final LocalDateTime updatedAt;
/**
* Job 시작
*/
public void start() {
this.status = Status.PROCESSING;
this.progress = 0;
}
/**
* 진행률 업데이트
*
* @param progress 진행률 (0-100)
*/
public void updateProgress(int progress) {
if (progress < 0 || progress > 100) {
throw new IllegalArgumentException("진행률은 0-100 사이여야 합니다");
}
this.progress = progress;
}
/**
* Job 완료 처리
*
* @param resultMessage 결과 메시지
*/
public void complete(String resultMessage) {
this.status = Status.COMPLETED;
this.progress = 100;
this.resultMessage = resultMessage;
}
/**
* Job 실패 처리
*
* @param errorMessage 에러 메시지
*/
public void fail(String errorMessage) {
this.status = Status.FAILED;
this.errorMessage = errorMessage;
}
/**
* Job 진행 여부
*
* @return 진행 중이면 true
*/
public boolean isProcessing() {
return status == Status.PROCESSING;
}
/**
* Job 완료 여부
*
* @return 완료되었으면 true
*/
public boolean isCompleted() {
return status == Status.COMPLETED;
}
/**
* Job 실패 여부
*
* @return 실패했으면 true
*/
public boolean isFailed() {
return status == Status.FAILED;
}
}

View File

@ -0,0 +1,53 @@
package com.kt.event.content.biz.domain;
/**
* 플랫폼 enum
* 이미지가 배포될 SNS 플랫폼 유형
*/
public enum Platform {
/**
* Instagram - 1080x1080 정사각형
*/
INSTAGRAM("Instagram", 1080, 1080),
/**
* 네이버 블로그 - 800x600
*/
NAVER("네이버 블로그", 800, 600),
/**
* 카카오 채널 - 800x800 정사각형
*/
KAKAO("카카오 채널", 800, 800);
private final String displayName;
private final int width;
private final int height;
Platform(String displayName, int width, int height) {
this.displayName = displayName;
this.width = width;
this.height = height;
}
public String getDisplayName() {
return displayName;
}
public int getWidth() {
return width;
}
public int getHeight() {
return height;
}
/**
* 이미지 크기 문자열 반환
*
* @return 가로x세로 형식 (: 1080x1080)
*/
public String getSizeString() {
return width + "x" + height;
}
}

View File

@ -0,0 +1,40 @@
package com.kt.event.content.biz.dto;
import com.kt.event.content.biz.domain.ImageStyle;
import com.kt.event.content.biz.domain.Platform;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import java.util.List;
/**
* 콘텐츠 관련 커맨드 DTO
*/
public class ContentCommand {
/**
* 이미지 생성 요청 커맨드
*/
@Getter
@Builder
@AllArgsConstructor
public static class GenerateImages {
private Long eventDraftId;
private String eventTitle;
private String eventDescription;
private List<ImageStyle> styles;
private List<Platform> platforms;
}
/**
* 이미지 재생성 요청 커맨드
*/
@Getter
@Builder
@AllArgsConstructor
public static class RegenerateImage {
private Long imageId;
private String newPrompt;
}
}

View File

@ -0,0 +1,47 @@
package com.kt.event.content.biz.dto;
import com.kt.event.content.biz.domain.Content;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
/**
* 콘텐츠 정보 DTO
*/
@Getter
@Builder
@AllArgsConstructor
public class ContentInfo {
private Long id;
private Long eventDraftId;
private String eventTitle;
private String eventDescription;
private List<ImageInfo> images;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/**
* 도메인 모델로부터 생성
*
* @param content 콘텐츠 도메인 모델
* @return ContentInfo
*/
public static ContentInfo from(Content content) {
return ContentInfo.builder()
.id(content.getId())
.eventDraftId(content.getEventDraftId())
.eventTitle(content.getEventTitle())
.eventDescription(content.getEventDescription())
.images(content.getImages().stream()
.map(ImageInfo::from)
.collect(Collectors.toList()))
.createdAt(content.getCreatedAt())
.updatedAt(content.getUpdatedAt())
.build();
}
}

View File

@ -0,0 +1,49 @@
package com.kt.event.content.biz.dto;
import com.kt.event.content.biz.domain.GeneratedImage;
import com.kt.event.content.biz.domain.ImageStyle;
import com.kt.event.content.biz.domain.Platform;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import java.time.LocalDateTime;
/**
* 이미지 정보 DTO
*/
@Getter
@Builder
@AllArgsConstructor
public class ImageInfo {
private Long id;
private Long eventDraftId;
private ImageStyle style;
private Platform platform;
private String cdnUrl;
private String prompt;
private boolean selected;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/**
* 도메인 모델로부터 생성
*
* @param image 이미지 도메인 모델
* @return ImageInfo
*/
public static ImageInfo from(GeneratedImage image) {
return ImageInfo.builder()
.id(image.getId())
.eventDraftId(image.getEventDraftId())
.style(image.getStyle())
.platform(image.getPlatform())
.cdnUrl(image.getCdnUrl())
.prompt(image.getPrompt())
.selected(image.isSelected())
.createdAt(image.getCreatedAt())
.updatedAt(image.getUpdatedAt())
.build();
}
}

View File

@ -0,0 +1,47 @@
package com.kt.event.content.biz.dto;
import com.kt.event.content.biz.domain.Job;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import java.time.LocalDateTime;
/**
* Job 정보 DTO
*/
@Getter
@Builder
@AllArgsConstructor
public class JobInfo {
private String id;
private Long eventDraftId;
private String jobType;
private Job.Status status;
private int progress;
private String resultMessage;
private String errorMessage;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/**
* 도메인 모델로부터 생성
*
* @param job Job 도메인 모델
* @return JobInfo
*/
public static JobInfo from(Job job) {
return JobInfo.builder()
.id(job.getId())
.eventDraftId(job.getEventDraftId())
.jobType(job.getJobType())
.status(job.getStatus())
.progress(job.getProgress())
.resultMessage(job.getResultMessage())
.errorMessage(job.getErrorMessage())
.createdAt(job.getCreatedAt())
.updatedAt(job.getUpdatedAt())
.build();
}
}

View File

@ -0,0 +1,32 @@
package com.kt.event.content.biz.service;
import com.kt.event.common.exception.BusinessException;
import com.kt.event.common.exception.ErrorCode;
import com.kt.event.content.biz.domain.Content;
import com.kt.event.content.biz.dto.ContentInfo;
import com.kt.event.content.biz.usecase.in.GetEventContentUseCase;
import com.kt.event.content.biz.usecase.out.ContentReader;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 이벤트 콘텐츠 조회 서비스
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class GetEventContentService implements GetEventContentUseCase {
private final ContentReader contentReader;
@Override
public ContentInfo execute(Long eventDraftId) {
Content content = contentReader.findByEventDraftIdWithImages(eventDraftId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMMON_001, "콘텐츠를 찾을 수 없습니다"));
return ContentInfo.from(content);
}
}

View File

@ -0,0 +1,32 @@
package com.kt.event.content.biz.service;
import com.kt.event.common.exception.BusinessException;
import com.kt.event.common.exception.ErrorCode;
import com.kt.event.content.biz.domain.GeneratedImage;
import com.kt.event.content.biz.dto.ImageInfo;
import com.kt.event.content.biz.usecase.in.GetImageDetailUseCase;
import com.kt.event.content.biz.usecase.out.ContentReader;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 이미지 상세 조회 서비스
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class GetImageDetailService implements GetImageDetailUseCase {
private final ContentReader contentReader;
@Override
public ImageInfo execute(Long imageId) {
GeneratedImage image = contentReader.findImageById(imageId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMMON_001, "이미지를 찾을 수 없습니다"));
return ImageInfo.from(image);
}
}

View File

@ -0,0 +1,33 @@
package com.kt.event.content.biz.service;
import com.kt.event.content.biz.domain.GeneratedImage;
import com.kt.event.content.biz.dto.ImageInfo;
import com.kt.event.content.biz.usecase.in.GetImageListUseCase;
import com.kt.event.content.biz.usecase.out.ContentReader;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;
/**
* 이미지 목록 조회 서비스
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class GetImageListService implements GetImageListUseCase {
private final ContentReader contentReader;
@Override
public List<ImageInfo> execute(Long eventDraftId) {
List<GeneratedImage> images = contentReader.findImagesByEventDraftId(eventDraftId);
return images.stream()
.map(ImageInfo::from)
.collect(Collectors.toList());
}
}

View File

@ -0,0 +1,33 @@
package com.kt.event.content.biz.service;
import com.kt.event.common.exception.BusinessException;
import com.kt.event.common.exception.ErrorCode;
import com.kt.event.content.biz.domain.Job;
import com.kt.event.content.biz.dto.JobInfo;
import com.kt.event.content.biz.usecase.in.GetJobStatusUseCase;
import com.kt.event.content.biz.usecase.out.JobReader;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Job 관리 서비스
* Job 상태 조회 기능 제공
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class JobManagementService implements GetJobStatusUseCase {
private final JobReader jobReader;
@Override
public JobInfo execute(String jobId) {
Job job = jobReader.findById(jobId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMMON_001, "Job을 찾을 수 없습니다"));
return JobInfo.from(job);
}
}

View File

@ -0,0 +1,19 @@
package com.kt.event.content.biz.usecase.in;
import com.kt.event.content.biz.dto.ContentCommand;
import com.kt.event.content.biz.dto.JobInfo;
/**
* 이미지 생성 UseCase
* 비동기로 이미지 생성 작업을 시작
*/
public interface GenerateImagesUseCase {
/**
* 이미지 생성 요청
*
* @param command 이미지 생성 커맨드
* @return Job 정보
*/
JobInfo execute(ContentCommand.GenerateImages command);
}

View File

@ -0,0 +1,17 @@
package com.kt.event.content.biz.usecase.in;
import com.kt.event.content.biz.dto.ContentInfo;
/**
* 이벤트 콘텐츠 조회 UseCase
*/
public interface GetEventContentUseCase {
/**
* 이벤트 전체 콘텐츠 조회 (이미지 목록 포함)
*
* @param eventDraftId 이벤트 초안 ID
* @return 콘텐츠 정보
*/
ContentInfo execute(Long eventDraftId);
}

View File

@ -0,0 +1,17 @@
package com.kt.event.content.biz.usecase.in;
import com.kt.event.content.biz.dto.ImageInfo;
/**
* 이미지 상세 조회 UseCase
*/
public interface GetImageDetailUseCase {
/**
* 이미지 상세 정보 조회
*
* @param imageId 이미지 ID
* @return 이미지 정보
*/
ImageInfo execute(Long imageId);
}

View File

@ -0,0 +1,19 @@
package com.kt.event.content.biz.usecase.in;
import com.kt.event.content.biz.dto.ImageInfo;
import java.util.List;
/**
* 이미지 목록 조회 UseCase
*/
public interface GetImageListUseCase {
/**
* 이벤트의 이미지 목록 조회
*
* @param eventDraftId 이벤트 초안 ID
* @return 이미지 정보 목록
*/
List<ImageInfo> execute(Long eventDraftId);
}

View File

@ -0,0 +1,17 @@
package com.kt.event.content.biz.usecase.in;
import com.kt.event.content.biz.dto.JobInfo;
/**
* Job 상태 조회 UseCase
*/
public interface GetJobStatusUseCase {
/**
* Job 상태 조회
*
* @param jobId Job ID
* @return Job 정보
*/
JobInfo execute(String jobId);
}

View File

@ -0,0 +1,18 @@
package com.kt.event.content.biz.usecase.in;
import com.kt.event.content.biz.dto.ContentCommand;
import com.kt.event.content.biz.dto.JobInfo;
/**
* 이미지 재생성 UseCase
*/
public interface RegenerateImageUseCase {
/**
* 이미지 재생성 요청
*
* @param command 이미지 재생성 커맨드
* @return Job 정보
*/
JobInfo execute(ContentCommand.RegenerateImage command);
}

View File

@ -0,0 +1,17 @@
package com.kt.event.content.biz.usecase.out;
/**
* CDN 업로드 포트
* Azure Blob Storage에 이미지 업로드
*/
public interface CDNUploader {
/**
* 이미지 업로드
*
* @param imageData 이미지 바이트 데이터
* @param fileName 파일명
* @return CDN URL
*/
String upload(byte[] imageData, String fileName);
}

View File

@ -0,0 +1,37 @@
package com.kt.event.content.biz.usecase.out;
import com.kt.event.content.biz.domain.Content;
import com.kt.event.content.biz.domain.GeneratedImage;
import java.util.List;
import java.util.Optional;
/**
* 콘텐츠 조회 포트
*/
public interface ContentReader {
/**
* 이벤트 초안 ID로 콘텐츠 조회 (이미지 목록 포함)
*
* @param eventDraftId 이벤트 초안 ID
* @return 콘텐츠 도메인 모델
*/
Optional<Content> findByEventDraftIdWithImages(Long eventDraftId);
/**
* 이미지 ID로 이미지 조회
*
* @param imageId 이미지 ID
* @return 이미지 도메인 모델
*/
Optional<GeneratedImage> findImageById(Long imageId);
/**
* 이벤트 초안 ID로 이미지 목록 조회
*
* @param eventDraftId 이벤트 초안 ID
* @return 이미지 도메인 모델 목록
*/
List<GeneratedImage> findImagesByEventDraftId(Long eventDraftId);
}

View File

@ -0,0 +1,26 @@
package com.kt.event.content.biz.usecase.out;
import com.kt.event.content.biz.domain.Content;
import com.kt.event.content.biz.domain.GeneratedImage;
/**
* 콘텐츠 저장 포트
*/
public interface ContentWriter {
/**
* 콘텐츠 저장
*
* @param content 콘텐츠 도메인 모델
* @return 저장된 콘텐츠
*/
Content save(Content content);
/**
* 이미지 저장
*
* @param image 이미지 도메인 모델
* @return 저장된 이미지
*/
GeneratedImage saveImage(GeneratedImage image);
}

View File

@ -0,0 +1,21 @@
package com.kt.event.content.biz.usecase.out;
import com.kt.event.content.biz.domain.ImageStyle;
import com.kt.event.content.biz.domain.Platform;
/**
* 이미지 생성 API 호출 포트
* Stable Diffusion, DALL-E 외부 이미지 생성 API 호출
*/
public interface ImageGeneratorCaller {
/**
* 이미지 생성
*
* @param prompt 프롬프트
* @param style 이미지 스타일
* @param platform 플랫폼 (이미지 크기 결정)
* @return 생성된 이미지 바이트 데이터
*/
byte[] generateImage(String prompt, ImageStyle style, Platform platform);
}

View File

@ -0,0 +1,19 @@
package com.kt.event.content.biz.usecase.out;
import com.kt.event.content.biz.domain.Job;
import java.util.Optional;
/**
* Job 조회 포트
*/
public interface JobReader {
/**
* Job ID로 조회
*
* @param jobId Job ID
* @return Job 도메인 모델
*/
Optional<Job> findById(String jobId);
}

View File

@ -0,0 +1,17 @@
package com.kt.event.content.biz.usecase.out;
import com.kt.event.content.biz.domain.Job;
/**
* Job 저장 포트
*/
public interface JobWriter {
/**
* Job 저장
*
* @param job Job 도메인 모델
* @return 저장된 Job
*/
Job save(Job job);
}

View File

@ -0,0 +1,19 @@
package com.kt.event.content.biz.usecase.out;
import java.util.Map;
import java.util.Optional;
/**
* Redis AI 데이터 조회 포트
* Event Service가 저장한 AI 추천 데이터를 읽음
*/
public interface RedisAIDataReader {
/**
* AI 추천 데이터 조회
*
* @param eventDraftId 이벤트 초안 ID
* @return AI 추천 데이터 (JSON 형태의 Map)
*/
Optional<Map<String, Object>> getAIRecommendation(Long eventDraftId);
}

View File

@ -0,0 +1,21 @@
package com.kt.event.content.biz.usecase.out;
import com.kt.event.content.biz.domain.GeneratedImage;
import java.util.List;
/**
* Redis 이미지 데이터 저장 포트
* 생성된 이미지 정보를 Redis에 캐싱
*/
public interface RedisImageWriter {
/**
* 이미지 목록 캐싱
*
* @param eventDraftId 이벤트 초안 ID
* @param images 이미지 목록
* @param ttlSeconds TTL ()
*/
void cacheImages(Long eventDraftId, List<GeneratedImage> images, long ttlSeconds);
}

View File

@ -0,0 +1,27 @@
package com.kt.event.content.infra;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
/**
* Content Service Application
*/
@SpringBootApplication(scanBasePackages = {
"com.kt.event.content",
"com.kt.event.common"
})
@EntityScan(basePackages = {
"com.kt.event.content.infra.gateway.entity",
"com.kt.event.common.entity"
})
@EnableJpaRepositories(basePackages = "com.kt.event.content.infra.gateway.repository")
@EnableJpaAuditing
public class ContentApplication {
public static void main(String[] args) {
SpringApplication.run(ContentApplication.class, args);
}
}

View File

@ -0,0 +1,98 @@
package com.kt.event.content.infra.gateway.entity;
import com.kt.event.common.entity.BaseTimeEntity;
import com.kt.event.content.biz.domain.Content;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/**
* 콘텐츠 엔티티
* 이벤트에 대한 전체 콘텐츠 정보를 저장
*/
@Entity
@Table(name = "contents")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class ContentEntity extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 이벤트 ID (이벤트 초안 ID)
*/
@Column(name = "event_draft_id", nullable = false)
private Long eventDraftId;
/**
* 이벤트 제목
*/
@Column(name = "event_title", nullable = false, length = 200)
private String eventTitle;
/**
* 이벤트 설명
*/
@Column(name = "event_description", columnDefinition = "TEXT")
private String eventDescription;
/**
* 생성된 이미지 목록
*/
@OneToMany(mappedBy = "content", cascade = CascadeType.ALL, orphanRemoval = true)
private List<GeneratedImageEntity> images = new ArrayList<>();
/**
* 정적 팩토리 메서드: 콘텐츠 생성
*
* @param eventDraftId 이벤트 초안 ID
* @param eventTitle 이벤트 제목
* @param eventDescription 이벤트 설명
* @return ContentEntity
*/
public static ContentEntity create(Long eventDraftId, String eventTitle, String eventDescription) {
ContentEntity entity = new ContentEntity();
entity.eventDraftId = eventDraftId;
entity.eventTitle = eventTitle;
entity.eventDescription = eventDescription;
return entity;
}
/**
* 도메인 모델로 변환
*
* @return Content 도메인 모델
*/
public Content toDomain() {
return Content.builder()
.id(id)
.eventDraftId(eventDraftId)
.eventTitle(eventTitle)
.eventDescription(eventDescription)
.images(images.stream()
.map(GeneratedImageEntity::toDomain)
.collect(Collectors.toList()))
.createdAt(getCreatedAt())
.updatedAt(getUpdatedAt())
.build();
}
/**
* 이미지 추가
*
* @param image 생성된 이미지 엔티티
*/
public void addImage(GeneratedImageEntity image) {
images.add(image);
image.assignContent(this);
}
}

View File

@ -0,0 +1,139 @@
package com.kt.event.content.infra.gateway.entity;
import com.kt.event.common.entity.BaseTimeEntity;
import com.kt.event.content.biz.domain.GeneratedImage;
import com.kt.event.content.biz.domain.ImageStyle;
import com.kt.event.content.biz.domain.Platform;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 생성된 이미지 엔티티
* AI가 생성한 이미지 정보를 저장
*/
@Entity
@Table(name = "generated_images", indexes = {
@Index(name = "idx_event_draft_id", columnList = "event_draft_id"),
@Index(name = "idx_style_platform", columnList = "style,platform")
})
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class GeneratedImageEntity extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 콘텐츠 (양방향 관계)
*/
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "content_id")
private ContentEntity content;
/**
* 이벤트 ID (이벤트 초안 ID)
*/
@Column(name = "event_draft_id", nullable = false)
private Long eventDraftId;
/**
* 이미지 스타일
*/
@Enumerated(EnumType.STRING)
@Column(name = "style", nullable = false, length = 20)
private ImageStyle style;
/**
* 플랫폼
*/
@Enumerated(EnumType.STRING)
@Column(name = "platform", nullable = false, length = 20)
private Platform platform;
/**
* CDN URL (Azure Blob Storage)
*/
@Column(name = "cdn_url", nullable = false, length = 500)
private String cdnUrl;
/**
* 프롬프트
*/
@Column(name = "prompt", columnDefinition = "TEXT")
private String prompt;
/**
* 선택 여부
*/
@Column(name = "selected", nullable = false)
private boolean selected;
/**
* 정적 팩토리 메서드: 이미지 생성
*
* @param eventDraftId 이벤트 초안 ID
* @param style 이미지 스타일
* @param platform 플랫폼
* @param cdnUrl CDN URL
* @param prompt 프롬프트
* @return GeneratedImageEntity
*/
public static GeneratedImageEntity create(Long eventDraftId, ImageStyle style, Platform platform,
String cdnUrl, String prompt) {
GeneratedImageEntity entity = new GeneratedImageEntity();
entity.eventDraftId = eventDraftId;
entity.style = style;
entity.platform = platform;
entity.cdnUrl = cdnUrl;
entity.prompt = prompt;
entity.selected = false;
return entity;
}
/**
* 도메인 모델로 변환
*
* @return GeneratedImage 도메인 모델
*/
public GeneratedImage toDomain() {
return GeneratedImage.builder()
.id(id)
.eventDraftId(eventDraftId)
.style(style)
.platform(platform)
.cdnUrl(cdnUrl)
.prompt(prompt)
.selected(selected)
.createdAt(getCreatedAt())
.updatedAt(getUpdatedAt())
.build();
}
/**
* 콘텐츠 할당 (양방향 관계 설정용)
*
* @param content 콘텐츠 엔티티
*/
protected void assignContent(ContentEntity content) {
this.content = content;
}
/**
* 이미지 선택
*/
public void select() {
this.selected = true;
}
/**
* 이미지 선택 해제
*/
public void deselect() {
this.selected = false;
}
}

View File

@ -0,0 +1,143 @@
package com.kt.event.content.infra.gateway.entity;
import com.kt.event.common.entity.BaseTimeEntity;
import com.kt.event.content.biz.domain.Job;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* Job 엔티티
* 이미지 생성 작업 정보를 저장
*/
@Entity
@Table(name = "jobs", indexes = {
@Index(name = "idx_event_draft_id", columnList = "event_draft_id"),
@Index(name = "idx_status", columnList = "status")
})
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class JobEntity extends BaseTimeEntity {
@Id
@Column(name = "id", length = 36)
private String id;
/**
* 이벤트 ID (이벤트 초안 ID)
*/
@Column(name = "event_draft_id", nullable = false)
private Long eventDraftId;
/**
* Job 타입
*/
@Column(name = "job_type", nullable = false, length = 50)
private String jobType;
/**
* Job 상태
*/
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
private Job.Status status;
/**
* 진행률 (0-100)
*/
@Column(name = "progress", nullable = false)
private int progress;
/**
* 결과 메시지
*/
@Column(name = "result_message", columnDefinition = "TEXT")
private String resultMessage;
/**
* 에러 메시지
*/
@Column(name = "error_message", columnDefinition = "TEXT")
private String errorMessage;
/**
* 정적 팩토리 메서드: Job 생성
*
* @param id Job ID (UUID)
* @param eventDraftId 이벤트 초안 ID
* @param jobType Job 타입
* @return JobEntity
*/
public static JobEntity create(String id, Long eventDraftId, String jobType) {
JobEntity entity = new JobEntity();
entity.id = id;
entity.eventDraftId = eventDraftId;
entity.jobType = jobType;
entity.status = Job.Status.PENDING;
entity.progress = 0;
return entity;
}
/**
* 도메인 모델로 변환
*
* @return Job 도메인 모델
*/
public Job toDomain() {
return Job.builder()
.id(id)
.eventDraftId(eventDraftId)
.jobType(jobType)
.status(status)
.progress(progress)
.resultMessage(resultMessage)
.errorMessage(errorMessage)
.createdAt(getCreatedAt())
.updatedAt(getUpdatedAt())
.build();
}
/**
* Job 시작
*/
public void start() {
this.status = Job.Status.PROCESSING;
this.progress = 0;
}
/**
* 진행률 업데이트
*
* @param progress 진행률 (0-100)
*/
public void updateProgress(int progress) {
if (progress < 0 || progress > 100) {
throw new IllegalArgumentException("진행률은 0-100 사이여야 합니다");
}
this.progress = progress;
}
/**
* Job 완료 처리
*
* @param resultMessage 결과 메시지
*/
public void complete(String resultMessage) {
this.status = Job.Status.COMPLETED;
this.progress = 100;
this.resultMessage = resultMessage;
}
/**
* Job 실패 처리
*
* @param errorMessage 에러 메시지
*/
public void fail(String errorMessage) {
this.status = Job.Status.FAILED;
this.errorMessage = errorMessage;
}
}

View File

@ -0,0 +1,41 @@
package com.kt.event.content.infra.gateway.repository;
import com.kt.event.content.infra.gateway.entity.ContentEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Optional;
/**
* 콘텐츠 JPA 리포지토리
*/
public interface ContentJpaRepository extends JpaRepository<ContentEntity, Long> {
/**
* 이벤트 초안 ID로 콘텐츠 조회 (이미지 목록 포함)
*
* @param eventDraftId 이벤트 초안 ID
* @return 콘텐츠 엔티티
*/
@Query("SELECT DISTINCT c FROM ContentEntity c " +
"LEFT JOIN FETCH c.images " +
"WHERE c.eventDraftId = :eventDraftId")
Optional<ContentEntity> findByEventDraftIdWithImages(@Param("eventDraftId") Long eventDraftId);
/**
* 이벤트 초안 ID로 콘텐츠 조회
*
* @param eventDraftId 이벤트 초안 ID
* @return 콘텐츠 엔티티
*/
Optional<ContentEntity> findByEventDraftId(Long eventDraftId);
/**
* 이벤트 초안 ID로 콘텐츠 존재 여부 확인
*
* @param eventDraftId 이벤트 초안 ID
* @return 존재 여부
*/
boolean existsByEventDraftId(Long eventDraftId);
}

View File

@ -0,0 +1,68 @@
package com.kt.event.content.infra.gateway.repository;
import com.kt.event.content.biz.domain.ImageStyle;
import com.kt.event.content.biz.domain.Platform;
import com.kt.event.content.infra.gateway.entity.GeneratedImageEntity;
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;
/**
* 생성된 이미지 JPA 리포지토리
*/
public interface GeneratedImageJpaRepository extends JpaRepository<GeneratedImageEntity, Long> {
/**
* 이벤트 초안 ID로 이미지 목록 조회
*
* @param eventDraftId 이벤트 초안 ID
* @return 이미지 엔티티 목록
*/
List<GeneratedImageEntity> findByEventDraftId(Long eventDraftId);
/**
* 이벤트 초안 ID와 스타일로 이미지 목록 조회
*
* @param eventDraftId 이벤트 초안 ID
* @param style 이미지 스타일
* @return 이미지 엔티티 목록
*/
List<GeneratedImageEntity> findByEventDraftIdAndStyle(Long eventDraftId, ImageStyle style);
/**
* 이벤트 초안 ID와 플랫폼으로 이미지 목록 조회
*
* @param eventDraftId 이벤트 초안 ID
* @param platform 플랫폼
* @return 이미지 엔티티 목록
*/
List<GeneratedImageEntity> findByEventDraftIdAndPlatform(Long eventDraftId, Platform platform);
/**
* 이벤트 초안 ID와 선택 여부로 이미지 목록 조회
*
* @param eventDraftId 이벤트 초안 ID
* @param selected 선택 여부
* @return 이미지 엔티티 목록
*/
List<GeneratedImageEntity> findByEventDraftIdAndSelected(Long eventDraftId, boolean selected);
/**
* 이벤트 초안 ID로 선택된 이미지 목록 조회
*
* @param eventDraftId 이벤트 초안 ID
* @return 선택된 이미지 엔티티 목록
*/
@Query("SELECT i FROM GeneratedImageEntity i WHERE i.eventDraftId = :eventDraftId AND i.selected = true")
List<GeneratedImageEntity> findSelectedImages(@Param("eventDraftId") Long eventDraftId);
/**
* 이벤트 초안 ID로 모든 이미지 선택 해제
*
* @param eventDraftId 이벤트 초안 ID
*/
@Query("UPDATE GeneratedImageEntity i SET i.selected = false WHERE i.eventDraftId = :eventDraftId")
void deselectAllByEventDraftId(@Param("eventDraftId") Long eventDraftId);
}

View File

@ -0,0 +1,40 @@
package com.kt.event.content.infra.gateway.repository;
import com.kt.event.content.biz.domain.Job;
import com.kt.event.content.infra.gateway.entity.JobEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
/**
* Job JPA 리포지토리
*/
public interface JobJpaRepository extends JpaRepository<JobEntity, String> {
/**
* 이벤트 초안 ID로 Job 목록 조회
*
* @param eventDraftId 이벤트 초안 ID
* @return Job 엔티티 목록
*/
List<JobEntity> findByEventDraftId(Long eventDraftId);
/**
* 이벤트 초안 ID와 상태로 Job 목록 조회
*
* @param eventDraftId 이벤트 초안 ID
* @param status Job 상태
* @return Job 엔티티 목록
*/
List<JobEntity> findByEventDraftIdAndStatus(Long eventDraftId, Job.Status status);
/**
* 이벤트 초안 ID와 Job 타입으로 최신 Job 조회
*
* @param eventDraftId 이벤트 초안 ID
* @param jobType Job 타입
* @return Job 엔티티
*/
Optional<JobEntity> findFirstByEventDraftIdAndJobTypeOrderByCreatedAtDesc(Long eventDraftId, String jobType);
}

View File

@ -0,0 +1,50 @@
spring:
application:
name: content-service
datasource:
url: jdbc:postgresql://4.217.131.139:5432/contentdb
username: eventuser
password: Hi5Jessica!
driver-class-name: org.postgresql.Driver
jpa:
database-platform: org.hibernate.dialect.PostgreSQLDialect
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
format_sql: true
use_sql_comments: true
data:
redis:
host: 4.217.131.139
port: 6379
kafka:
bootstrap-servers: 20.249.125.115:9092
consumer:
group-id: content-service-consumers
auto-offset-reset: earliest
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
server:
port: 8084
jwt:
secret: kt-event-marketing-jwt-secret-key-for-authentication-and-authorization-2025
access-token-validity: 3600000
refresh-token-validity: 604800000
azure:
storage:
connection-string: ${AZURE_STORAGE_CONNECTION_STRING:}
container-name: event-images
logging:
level:
com.kt.event: DEBUG
org.hibernate.SQL: DEBUG