diff --git a/content-service/build.gradle b/content-service/build.gradle index aa9be20..0120aef 100644 --- a/content-service/build.gradle +++ b/content-service/build.gradle @@ -17,4 +17,7 @@ dependencies { // Jackson for JSON implementation 'com.fasterxml.jackson.core:jackson-databind' + + // H2 Database for local testing + runtimeOnly 'com.h2database:h2' } diff --git a/content-service/src/main/java/com/kt/event/content/biz/service/mock/MockGenerateImagesService.java b/content-service/src/main/java/com/kt/event/content/biz/service/mock/MockGenerateImagesService.java new file mode 100644 index 0000000..db8aea0 --- /dev/null +++ b/content-service/src/main/java/com/kt/event/content/biz/service/mock/MockGenerateImagesService.java @@ -0,0 +1,163 @@ +package com.kt.event.content.biz.service.mock; + +import com.kt.event.content.biz.domain.Content; +import com.kt.event.content.biz.domain.GeneratedImage; +import com.kt.event.content.biz.domain.ImageStyle; +import com.kt.event.content.biz.domain.Job; +import com.kt.event.content.biz.domain.Platform; +import com.kt.event.content.biz.dto.ContentCommand; +import com.kt.event.content.biz.dto.JobInfo; +import com.kt.event.content.biz.usecase.in.GenerateImagesUseCase; +import com.kt.event.content.biz.usecase.out.ContentWriter; +import com.kt.event.content.biz.usecase.out.JobWriter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * Mock 이미지 생성 서비스 (테스트용) + * 실제 Kafka 연동 전까지 사용 + * + * 테스트를 위해 실제로 Content와 GeneratedImage를 생성합니다. + */ +@Slf4j +@Service +@Profile({"local", "test"}) +@RequiredArgsConstructor +public class MockGenerateImagesService implements GenerateImagesUseCase { + + private final JobWriter jobWriter; + private final ContentWriter contentWriter; + + @Override + public JobInfo execute(ContentCommand.GenerateImages command) { + log.info("[MOCK] 이미지 생성 요청: eventDraftId={}, styles={}, platforms={}", + command.getEventDraftId(), command.getStyles(), command.getPlatforms()); + + // Mock Job 생성 + String jobId = "job-mock-" + UUID.randomUUID().toString().substring(0, 8); + + Job job = Job.builder() + .id(jobId) + .eventDraftId(command.getEventDraftId()) + .jobType("image-generation") + .status(Job.Status.PENDING) + .progress(0) + .createdAt(java.time.LocalDateTime.now()) + .updatedAt(java.time.LocalDateTime.now()) + .build(); + + // Job 저장 + Job savedJob = jobWriter.save(job); + log.info("[MOCK] Job 생성 완료: jobId={}", jobId); + + // 비동기로 이미지 생성 시뮬레이션 + processImageGeneration(jobId, command); + + return JobInfo.from(savedJob); + } + + @Async + private void processImageGeneration(String jobId, ContentCommand.GenerateImages command) { + try { + log.info("[MOCK] 이미지 생성 시작: jobId={}", jobId); + + // 1초 대기 (이미지 생성 시뮬레이션) + Thread.sleep(1000); + + // Content 생성 또는 조회 + Content content = Content.builder() + .eventDraftId(command.getEventDraftId()) + .eventTitle("Mock 이벤트 제목 " + command.getEventDraftId()) + .eventDescription("Mock 이벤트 설명입니다. 테스트를 위한 Mock 데이터입니다.") + .createdAt(java.time.LocalDateTime.now()) + .updatedAt(java.time.LocalDateTime.now()) + .build(); + Content savedContent = contentWriter.save(content); + log.info("[MOCK] Content 생성 완료: contentId={}", savedContent.getId()); + + // 스타일 x 플랫폼 조합으로 이미지 생성 + List styles = command.getStyles() != null && !command.getStyles().isEmpty() + ? command.getStyles() + : List.of(ImageStyle.FANCY, ImageStyle.SIMPLE); + + List platforms = command.getPlatforms() != null && !command.getPlatforms().isEmpty() + ? command.getPlatforms() + : List.of(Platform.INSTAGRAM, Platform.KAKAO); + + List images = new ArrayList<>(); + int count = 0; + for (ImageStyle style : styles) { + for (Platform platform : platforms) { + count++; + String mockCdnUrl = String.format( + "https://mock-cdn.azure.com/images/%d/%s_%s_%s.png", + command.getEventDraftId(), + style.name().toLowerCase(), + platform.name().toLowerCase(), + UUID.randomUUID().toString().substring(0, 8) + ); + + GeneratedImage image = GeneratedImage.builder() + .eventDraftId(command.getEventDraftId()) + .style(style) + .platform(platform) + .cdnUrl(mockCdnUrl) + .prompt(String.format("Mock prompt for %s style on %s platform", style, platform)) + .selected(false) + .createdAt(java.time.LocalDateTime.now()) + .updatedAt(java.time.LocalDateTime.now()) + .build(); + + // 첫 번째 이미지를 선택된 이미지로 설정 + if (count == 1) { + image.select(); + } + + GeneratedImage savedImage = contentWriter.saveImage(image); + images.add(savedImage); + log.info("[MOCK] 이미지 생성: imageId={}, style={}, platform={}", + savedImage.getId(), style, platform); + } + } + + // Job 상태 업데이트: COMPLETED + Job completedJob = Job.builder() + .id(jobId) + .eventDraftId(command.getEventDraftId()) + .jobType("image-generation") + .status(Job.Status.COMPLETED) + .progress(100) + .resultMessage(String.format("%d개의 이미지가 성공적으로 생성되었습니다.", images.size())) + .createdAt(java.time.LocalDateTime.now()) + .updatedAt(java.time.LocalDateTime.now()) + .build(); + + jobWriter.save(completedJob); + log.info("[MOCK] Job 완료: jobId={}, 생성된 이미지 수={}", jobId, images.size()); + + } catch (Exception e) { + log.error("[MOCK] 이미지 생성 실패: jobId={}", jobId, e); + + // Job 상태 업데이트: FAILED + Job failedJob = Job.builder() + .id(jobId) + .eventDraftId(command.getEventDraftId()) + .jobType("image-generation") + .status(Job.Status.FAILED) + .progress(0) + .errorMessage(e.getMessage()) + .createdAt(java.time.LocalDateTime.now()) + .updatedAt(java.time.LocalDateTime.now()) + .build(); + + jobWriter.save(failedJob); + } + } +} diff --git a/content-service/src/main/java/com/kt/event/content/biz/service/mock/MockRegenerateImageService.java b/content-service/src/main/java/com/kt/event/content/biz/service/mock/MockRegenerateImageService.java new file mode 100644 index 0000000..e1aac30 --- /dev/null +++ b/content-service/src/main/java/com/kt/event/content/biz/service/mock/MockRegenerateImageService.java @@ -0,0 +1,51 @@ +package com.kt.event.content.biz.service.mock; + +import com.kt.event.content.biz.domain.Job; +import com.kt.event.content.biz.dto.ContentCommand; +import com.kt.event.content.biz.dto.JobInfo; +import com.kt.event.content.biz.usecase.in.RegenerateImageUseCase; +import com.kt.event.content.biz.usecase.out.JobWriter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +/** + * Mock 이미지 재생성 서비스 (테스트용) + * 실제 구현 전까지 사용 + */ +@Slf4j +@Service +@Profile({"local", "test"}) +@RequiredArgsConstructor +public class MockRegenerateImageService implements RegenerateImageUseCase { + + private final JobWriter jobWriter; + + @Override + public JobInfo execute(ContentCommand.RegenerateImage command) { + log.info("[MOCK] 이미지 재생성 요청: imageId={}", command.getImageId()); + + // Mock Job 생성 + String jobId = "job-regen-" + UUID.randomUUID().toString().substring(0, 8); + + Job job = Job.builder() + .id(jobId) + .eventDraftId(999L) // Mock event ID + .jobType("image-regeneration") + .status(Job.Status.PENDING) + .progress(0) + .createdAt(java.time.LocalDateTime.now()) + .updatedAt(java.time.LocalDateTime.now()) + .build(); + + // Job 저장 + Job savedJob = jobWriter.save(job); + + log.info("[MOCK] 재생성 Job 생성 완료: jobId={}", jobId); + + return JobInfo.from(savedJob); + } +} diff --git a/content-service/src/main/java/com/kt/event/content/infra/ContentApplication.java b/content-service/src/main/java/com/kt/event/content/infra/ContentApplication.java index 616f4aa..ebe6902 100644 --- a/content-service/src/main/java/com/kt/event/content/infra/ContentApplication.java +++ b/content-service/src/main/java/com/kt/event/content/infra/ContentApplication.java @@ -5,6 +5,7 @@ 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; +import org.springframework.scheduling.annotation.EnableAsync; /** * Content Service Application @@ -19,6 +20,7 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories; }) @EnableJpaRepositories(basePackages = "com.kt.event.content.infra.gateway.repository") @EnableJpaAuditing +@EnableAsync public class ContentApplication { public static void main(String[] args) { diff --git a/content-service/src/main/java/com/kt/event/content/infra/config/SecurityConfig.java b/content-service/src/main/java/com/kt/event/content/infra/config/SecurityConfig.java new file mode 100644 index 0000000..9b78a69 --- /dev/null +++ b/content-service/src/main/java/com/kt/event/content/infra/config/SecurityConfig.java @@ -0,0 +1,39 @@ +package com.kt.event.content.infra.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; + +/** + * Spring Security 설정 + * API 테스트를 위해 일단 모든 요청 허용 (추후 JWT 인증 추가) + */ +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + // CSRF 비활성화 (REST API는 CSRF 불필요) + .csrf(AbstractHttpConfigurer::disable) + + // 세션 사용 안 함 (JWT 기반 인증) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + + // 모든 요청 허용 (테스트용, 추후 JWT 필터 추가 필요) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() + .requestMatchers("/actuator/**").permitAll() + .anyRequest().permitAll() // TODO: 추후 authenticated()로 변경 + ); + + return http.build(); + } +} diff --git a/content-service/src/main/java/com/kt/event/content/infra/config/SwaggerConfig.java b/content-service/src/main/java/com/kt/event/content/infra/config/SwaggerConfig.java new file mode 100644 index 0000000..8a0f63a --- /dev/null +++ b/content-service/src/main/java/com/kt/event/content/infra/config/SwaggerConfig.java @@ -0,0 +1,50 @@ +package com.kt.event.content.infra.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +/** + * Swagger/OpenAPI 설정 + */ +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .info(new Info() + .title("Content Service API") + .version("1.0.0") + .description(""" + # KT AI 기반 소상공인 이벤트 자동 생성 서비스 - Content Service API + + ## 주요 기능 + - **SNS 이미지 생성**: AI 기반 이벤트 이미지 자동 생성 + - **콘텐츠 편집**: 생성된 이미지 조회, 재생성, 삭제 + - **3가지 스타일**: 심플(SIMPLE), 화려한(FANCY), 트렌디(TRENDY) + - **3개 플랫폼 최적화**: Instagram (1080x1080), Naver (800x600), Kakao (800x800) + """) + .contact(new Contact() + .name("Digital Garage Team") + .email("support@kt-event-marketing.com") + ) + ) + .servers(List.of( + new Server() + .url("http://localhost:8084") + .description("Local Development Server"), + new Server() + .url("https://dev-api.kt-event-marketing.com/content/v1") + .description("Development Server"), + new Server() + .url("https://api.kt-event-marketing.com/content/v1") + .description("Production Server") + )); + } +} diff --git a/content-service/src/main/java/com/kt/event/content/infra/gateway/ContentGateway.java b/content-service/src/main/java/com/kt/event/content/infra/gateway/ContentGateway.java new file mode 100644 index 0000000..305bc0e --- /dev/null +++ b/content-service/src/main/java/com/kt/event/content/infra/gateway/ContentGateway.java @@ -0,0 +1,119 @@ +package com.kt.event.content.infra.gateway; + +import com.kt.event.content.biz.domain.Content; +import com.kt.event.content.biz.domain.GeneratedImage; +import com.kt.event.content.biz.usecase.out.ContentReader; +import com.kt.event.content.biz.usecase.out.ContentWriter; +import com.kt.event.content.infra.gateway.entity.ContentEntity; +import com.kt.event.content.infra.gateway.entity.GeneratedImageEntity; +import com.kt.event.content.infra.gateway.repository.ContentJpaRepository; +import com.kt.event.content.infra.gateway.repository.GeneratedImageJpaRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * Content 영속성 Gateway + * ContentReader, ContentWriter outbound port 구현 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ContentGateway implements ContentReader, ContentWriter { + + private final ContentJpaRepository contentRepository; + private final GeneratedImageJpaRepository imageRepository; + + // ======================================== + // ContentReader 구현 + // ======================================== + + @Override + @Transactional(readOnly = true) + public Optional findByEventDraftIdWithImages(Long eventDraftId) { + log.debug("이벤트 콘텐츠 조회 (with images): eventDraftId={}", eventDraftId); + return contentRepository.findByEventDraftIdWithImages(eventDraftId) + .map(ContentEntity::toDomain); + } + + @Override + @Transactional(readOnly = true) + public Optional findImageById(Long imageId) { + log.debug("이미지 조회: imageId={}", imageId); + return imageRepository.findById(imageId) + .map(GeneratedImageEntity::toDomain); + } + + @Override + @Transactional(readOnly = true) + public List findImagesByEventDraftId(Long eventDraftId) { + log.debug("이미지 목록 조회: eventDraftId={}", eventDraftId); + return imageRepository.findByEventDraftId(eventDraftId).stream() + .map(GeneratedImageEntity::toDomain) + .collect(Collectors.toList()); + } + + // ======================================== + // ContentWriter 구현 + // ======================================== + + @Override + @Transactional + public Content save(Content content) { + log.debug("콘텐츠 저장: eventDraftId={}", content.getEventDraftId()); + + // Content Entity 조회 또는 생성 + ContentEntity contentEntity = contentRepository.findByEventDraftId(content.getEventDraftId()) + .orElseGet(() -> ContentEntity.create( + content.getEventDraftId(), + content.getEventTitle(), + content.getEventDescription() + )); + + // Content 업데이트 + contentEntity.update(content.getEventTitle(), content.getEventDescription()); + + // 저장 + ContentEntity saved = contentRepository.save(contentEntity); + + return saved.toDomain(); + } + + @Override + @Transactional + public GeneratedImage saveImage(GeneratedImage image) { + log.debug("이미지 저장: eventDraftId={}, style={}, platform={}", + image.getEventDraftId(), image.getStyle(), image.getPlatform()); + + // Content Entity 조회 + ContentEntity contentEntity = contentRepository.findByEventDraftId(image.getEventDraftId()) + .orElseThrow(() -> new IllegalStateException("Content를 먼저 저장해야 합니다")); + + // GeneratedImageEntity 생성 + GeneratedImageEntity imageEntity = GeneratedImageEntity.create( + image.getEventDraftId(), + image.getStyle(), + image.getPlatform(), + image.getCdnUrl(), + image.getPrompt() + ); + + // Content와 연결 + contentEntity.addImage(imageEntity); + + // 선택 상태 설정 + if (image.isSelected()) { + imageEntity.select(); + } + + // 저장 + GeneratedImageEntity saved = imageRepository.save(imageEntity); + + return saved.toDomain(); + } +} diff --git a/content-service/src/main/java/com/kt/event/content/infra/gateway/JobGateway.java b/content-service/src/main/java/com/kt/event/content/infra/gateway/JobGateway.java new file mode 100644 index 0000000..f176cc1 --- /dev/null +++ b/content-service/src/main/java/com/kt/event/content/infra/gateway/JobGateway.java @@ -0,0 +1,98 @@ +package com.kt.event.content.infra.gateway; + +import com.kt.event.content.biz.domain.Job; +import com.kt.event.content.biz.usecase.out.JobReader; +import com.kt.event.content.biz.usecase.out.JobWriter; +import com.kt.event.content.infra.gateway.entity.JobEntity; +import com.kt.event.content.infra.gateway.repository.JobJpaRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * Job 영속성 Gateway + * JobReader, JobWriter outbound port 구현 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class JobGateway implements JobReader, JobWriter { + + private final JobJpaRepository jobRepository; + + // ======================================== + // JobReader 구현 + // ======================================== + + @Override + @Transactional(readOnly = true) + public Optional findById(String jobId) { + log.debug("Job 조회: jobId={}", jobId); + return jobRepository.findById(jobId) + .map(JobEntity::toDomain); + } + + /** + * 이벤트별 Job 조회 (추가 메서드) + */ + @Transactional(readOnly = true) + public List findByEventDraftId(Long eventDraftId) { + log.debug("이벤트별 Job 조회: eventDraftId={}", eventDraftId); + return jobRepository.findByEventDraftId(eventDraftId).stream() + .map(JobEntity::toDomain) + .collect(Collectors.toList()); + } + + /** + * 최신 Job 조회 (추가 메서드) + */ + @Transactional(readOnly = true) + public Optional findLatestByEventDraftIdAndJobType(Long eventDraftId, String jobType) { + log.debug("최신 Job 조회: eventDraftId={}, jobType={}", eventDraftId, jobType); + return jobRepository.findFirstByEventDraftIdAndJobTypeOrderByCreatedAtDesc(eventDraftId, jobType) + .map(JobEntity::toDomain); + } + + // ======================================== + // JobWriter 구현 + // ======================================== + + @Override + @Transactional + public Job save(Job job) { + log.debug("Job 저장: jobId={}, status={}", job.getId(), job.getStatus()); + + JobEntity entity = jobRepository.findById(job.getId()) + .orElseGet(() -> JobEntity.create( + job.getId(), + job.getEventDraftId(), + job.getJobType() + )); + + // Job 상태 업데이트 + entity.updateStatus(job.getStatus(), job.getProgress()); + if (job.getResultMessage() != null) { + entity.setResultMessage(job.getResultMessage()); + } + if (job.getErrorMessage() != null) { + entity.setErrorMessage(job.getErrorMessage()); + } + + JobEntity saved = jobRepository.save(entity); + return saved.toDomain(); + } + + /** + * Job 삭제 (추가 메서드) + */ + @Transactional + public void delete(String jobId) { + log.debug("Job 삭제: jobId={}", jobId); + jobRepository.deleteById(jobId); + } +} diff --git a/content-service/src/main/java/com/kt/event/content/infra/gateway/entity/ContentEntity.java b/content-service/src/main/java/com/kt/event/content/infra/gateway/entity/ContentEntity.java index f877c86..5b57ce6 100644 --- a/content-service/src/main/java/com/kt/event/content/infra/gateway/entity/ContentEntity.java +++ b/content-service/src/main/java/com/kt/event/content/infra/gateway/entity/ContentEntity.java @@ -86,6 +86,17 @@ public class ContentEntity extends BaseTimeEntity { .build(); } + /** + * 콘텐츠 정보 업데이트 + * + * @param eventTitle 이벤트 제목 + * @param eventDescription 이벤트 설명 + */ + public void update(String eventTitle, String eventDescription) { + this.eventTitle = eventTitle; + this.eventDescription = eventDescription; + } + /** * 이미지 추가 * diff --git a/content-service/src/main/java/com/kt/event/content/infra/gateway/entity/JobEntity.java b/content-service/src/main/java/com/kt/event/content/infra/gateway/entity/JobEntity.java index 496f880..82839fc 100644 --- a/content-service/src/main/java/com/kt/event/content/infra/gateway/entity/JobEntity.java +++ b/content-service/src/main/java/com/kt/event/content/infra/gateway/entity/JobEntity.java @@ -140,4 +140,33 @@ public class JobEntity extends BaseTimeEntity { this.status = Job.Status.FAILED; this.errorMessage = errorMessage; } + + /** + * Job 상태 업데이트 + * + * @param status 새 상태 + * @param progress 진행률 + */ + public void updateStatus(Job.Status status, int progress) { + this.status = status; + this.progress = progress; + } + + /** + * 결과 메시지 설정 + * + * @param resultMessage 결과 메시지 + */ + public void setResultMessage(String resultMessage) { + this.resultMessage = resultMessage; + } + + /** + * 에러 메시지 설정 + * + * @param errorMessage 에러 메시지 + */ + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } } diff --git a/content-service/src/main/java/com/kt/event/content/infra/gateway/mock/MockCDNUploader.java b/content-service/src/main/java/com/kt/event/content/infra/gateway/mock/MockCDNUploader.java new file mode 100644 index 0000000..c11bc31 --- /dev/null +++ b/content-service/src/main/java/com/kt/event/content/infra/gateway/mock/MockCDNUploader.java @@ -0,0 +1,31 @@ +package com.kt.event.content.infra.gateway.mock; + +import com.kt.event.content.biz.usecase.out.CDNUploader; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +/** + * Mock CDN Uploader (테스트용) + * 실제 Azure Blob Storage 연동 전까지 사용 + */ +@Slf4j +@Component +@Profile({"local", "test"}) +public class MockCDNUploader implements CDNUploader { + + private static final String MOCK_CDN_BASE_URL = "https://cdn.kt-event.com/images/mock"; + + @Override + public String upload(byte[] imageData, String fileName) { + log.info("[MOCK] CDN에 이미지 업로드: fileName={}, size={} bytes", + fileName, imageData.length); + + // Mock CDN URL 생성 + String mockUrl = String.format("%s/%s", MOCK_CDN_BASE_URL, fileName); + + log.info("[MOCK] 업로드된 CDN URL: {}", mockUrl); + + return mockUrl; + } +} diff --git a/content-service/src/main/java/com/kt/event/content/infra/gateway/mock/MockImageGenerator.java b/content-service/src/main/java/com/kt/event/content/infra/gateway/mock/MockImageGenerator.java new file mode 100644 index 0000000..85d42bc --- /dev/null +++ b/content-service/src/main/java/com/kt/event/content/infra/gateway/mock/MockImageGenerator.java @@ -0,0 +1,41 @@ +package com.kt.event.content.infra.gateway.mock; + +import com.kt.event.content.biz.domain.ImageStyle; +import com.kt.event.content.biz.domain.Platform; +import com.kt.event.content.biz.usecase.out.ImageGeneratorCaller; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +/** + * Mock Image Generator (테스트용) + * 실제 AI 이미지 생성 API 연동 전까지 사용 + */ +@Slf4j +@Component +@Profile({"local", "test"}) +public class MockImageGenerator implements ImageGeneratorCaller { + + @Override + public byte[] generateImage(String prompt, ImageStyle style, Platform platform) { + log.info("[MOCK] AI 이미지 생성: prompt='{}', style={}, platform={}", + prompt, style, platform); + + // Mock: 빈 바이트 배열 반환 (실제로는 AI가 생성한 이미지 데이터) + byte[] mockImageData = createMockImageData(style, platform); + + log.info("[MOCK] 이미지 생성 완료: size={} bytes", mockImageData.length); + + return mockImageData; + } + + /** + * Mock 이미지 데이터 생성 + * 실제로는 PNG/JPEG 이미지 바이너리 데이터 + */ + private byte[] createMockImageData(ImageStyle style, Platform platform) { + // 간단한 Mock 데이터 생성 (실제로는 이미지 바이너리) + String mockContent = String.format("MOCK_IMAGE_DATA[style=%s,platform=%s]", style, platform); + return mockContent.getBytes(); + } +} diff --git a/content-service/src/main/java/com/kt/event/content/infra/gateway/mock/MockRedisGateway.java b/content-service/src/main/java/com/kt/event/content/infra/gateway/mock/MockRedisGateway.java new file mode 100644 index 0000000..f4ef24b --- /dev/null +++ b/content-service/src/main/java/com/kt/event/content/infra/gateway/mock/MockRedisGateway.java @@ -0,0 +1,52 @@ +package com.kt.event.content.infra.gateway.mock; + +import com.kt.event.content.biz.domain.GeneratedImage; +import com.kt.event.content.biz.usecase.out.RedisAIDataReader; +import com.kt.event.content.biz.usecase.out.RedisImageWriter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Mock Redis Gateway (테스트용) + * 실제 Redis 연동 전까지 사용 + */ +@Slf4j +@Component +@Profile({"local", "test"}) +public class MockRedisGateway implements RedisAIDataReader, RedisImageWriter { + + private final Map> aiDataCache = new HashMap<>(); + + // ======================================== + // RedisAIDataReader 구현 + // ======================================== + + @Override + public Optional> getAIRecommendation(Long eventDraftId) { + log.info("[MOCK] Redis에서 AI 추천 데이터 조회: eventDraftId={}", eventDraftId); + + // Mock 데이터 반환 + Map mockData = new HashMap<>(); + mockData.put("title", "테스트 이벤트 제목"); + mockData.put("description", "테스트 이벤트 설명"); + mockData.put("brandColor", "#FF5733"); + + return Optional.of(mockData); + } + + // ======================================== + // RedisImageWriter 구현 + // ======================================== + + @Override + public void cacheImages(Long eventDraftId, List images, long ttlSeconds) { + log.info("[MOCK] Redis에 이미지 캐싱: eventDraftId={}, count={}, ttl={}초", + eventDraftId, images.size(), ttlSeconds); + } +} diff --git a/content-service/src/main/java/com/kt/event/content/infra/web/controller/ContentController.java b/content-service/src/main/java/com/kt/event/content/infra/web/controller/ContentController.java new file mode 100644 index 0000000..a756d8e --- /dev/null +++ b/content-service/src/main/java/com/kt/event/content/infra/web/controller/ContentController.java @@ -0,0 +1,170 @@ +package com.kt.event.content.infra.web.controller; + +import com.kt.event.content.biz.dto.ContentCommand; +import com.kt.event.content.biz.dto.ContentInfo; +import com.kt.event.content.biz.dto.ImageInfo; +import com.kt.event.content.biz.dto.JobInfo; +import com.kt.event.content.biz.usecase.in.GenerateImagesUseCase; +import com.kt.event.content.biz.usecase.in.GetEventContentUseCase; +import com.kt.event.content.biz.usecase.in.GetImageDetailUseCase; +import com.kt.event.content.biz.usecase.in.GetImageListUseCase; +import com.kt.event.content.biz.usecase.in.GetJobStatusUseCase; +import com.kt.event.content.biz.usecase.in.RegenerateImageUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * Content Service REST API Controller + * + * API 명세: content-service-api.yaml + * - 이미지 생성 요청 및 Job 상태 조회 + * - 생성된 콘텐츠 조회 및 관리 + * - 이미지 재생성 및 삭제 + */ +@Slf4j +@RestController +@RequestMapping("/content") +@RequiredArgsConstructor +public class ContentController { + + private final GenerateImagesUseCase generateImagesUseCase; + private final GetJobStatusUseCase getJobStatusUseCase; + private final GetEventContentUseCase getEventContentUseCase; + private final GetImageListUseCase getImageListUseCase; + private final GetImageDetailUseCase getImageDetailUseCase; + private final RegenerateImageUseCase regenerateImageUseCase; + + /** + * POST /content/images/generate + * SNS 이미지 생성 요청 (비동기) + * + * @param command 이미지 생성 요청 정보 + * @return 202 ACCEPTED - Job ID 반환 + */ + @PostMapping("/images/generate") + public ResponseEntity generateImages(@RequestBody ContentCommand.GenerateImages command) { + log.info("이미지 생성 요청: eventDraftId={}, styles={}, platforms={}", + command.getEventDraftId(), command.getStyles(), command.getPlatforms()); + + JobInfo jobInfo = generateImagesUseCase.execute(command); + + return ResponseEntity.status(HttpStatus.ACCEPTED).body(jobInfo); + } + + /** + * GET /content/images/jobs/{jobId} + * 이미지 생성 작업 상태 조회 (폴링) + * + * @param jobId Job ID + * @return 200 OK - Job 상태 정보 + */ + @GetMapping("/images/jobs/{jobId}") + public ResponseEntity getJobStatus(@PathVariable String jobId) { + log.info("Job 상태 조회: jobId={}", jobId); + + JobInfo jobInfo = getJobStatusUseCase.execute(jobId); + + return ResponseEntity.ok(jobInfo); + } + + /** + * GET /content/events/{eventDraftId} + * 이벤트의 생성된 콘텐츠 조회 + * + * @param eventDraftId 이벤트 초안 ID + * @return 200 OK - 콘텐츠 정보 (이미지 목록 포함) + */ + @GetMapping("/events/{eventDraftId}") + public ResponseEntity getContentByEventId(@PathVariable Long eventDraftId) { + log.info("이벤트 콘텐츠 조회: eventDraftId={}", eventDraftId); + + ContentInfo contentInfo = getEventContentUseCase.execute(eventDraftId); + + return ResponseEntity.ok(contentInfo); + } + + /** + * GET /content/events/{eventDraftId}/images + * 이벤트의 이미지 목록 조회 (필터링) + * + * @param eventDraftId 이벤트 초안 ID + * @param style 이미지 스타일 필터 (선택) + * @param platform 플랫폼 필터 (선택) + * @return 200 OK - 이미지 목록 + */ + @GetMapping("/events/{eventDraftId}/images") + public ResponseEntity> getImages( + @PathVariable Long eventDraftId, + @RequestParam(required = false) String style, + @RequestParam(required = false) String platform) { + log.info("이미지 목록 조회: eventDraftId={}, style={}, platform={}", eventDraftId, style, platform); + + // TODO: 필터링 기능 추가 (현재는 전체 목록 반환) + List images = getImageListUseCase.execute(eventDraftId); + + return ResponseEntity.ok(images); + } + + /** + * GET /content/images/{imageId} + * 특정 이미지 상세 조회 + * + * @param imageId 이미지 ID + * @return 200 OK - 이미지 상세 정보 + */ + @GetMapping("/images/{imageId}") + public ResponseEntity getImageById(@PathVariable Long imageId) { + log.info("이미지 상세 조회: imageId={}", imageId); + + ImageInfo imageInfo = getImageDetailUseCase.execute(imageId); + + return ResponseEntity.ok(imageInfo); + } + + /** + * DELETE /content/images/{imageId} + * 생성된 이미지 삭제 + * + * @param imageId 이미지 ID + * @return 204 NO CONTENT + */ + @DeleteMapping("/images/{imageId}") + public ResponseEntity deleteImage(@PathVariable Long imageId) { + log.info("이미지 삭제 요청: imageId={}", imageId); + + // TODO: DeleteImageUseCase 구현 필요 + // deleteImageUseCase.execute(imageId); + + return ResponseEntity.noContent().build(); + } + + /** + * POST /content/images/{imageId}/regenerate + * 이미지 재생성 요청 + * + * @param imageId 이미지 ID + * @param requestBody 재생성 요청 정보 (선택) + * @return 202 ACCEPTED - Job ID 반환 + */ + @PostMapping("/images/{imageId}/regenerate") + public ResponseEntity regenerateImage( + @PathVariable Long imageId, + @RequestBody(required = false) ContentCommand.RegenerateImage requestBody) { + log.info("이미지 재생성 요청: imageId={}", imageId); + + // imageId를 포함한 command 생성 + ContentCommand.RegenerateImage command = ContentCommand.RegenerateImage.builder() + .imageId(imageId) + .newPrompt(requestBody != null ? requestBody.getNewPrompt() : null) + .build(); + + JobInfo jobInfo = regenerateImageUseCase.execute(command); + + return ResponseEntity.status(HttpStatus.ACCEPTED).body(jobInfo); + } +} diff --git a/content-service/src/main/resources/application-local.yml b/content-service/src/main/resources/application-local.yml new file mode 100644 index 0000000..08f697a --- /dev/null +++ b/content-service/src/main/resources/application-local.yml @@ -0,0 +1,38 @@ +spring: + datasource: + url: jdbc:h2:mem:contentdb + username: sa + password: + driver-class-name: org.h2.Driver + + h2: + console: + enabled: true + path: /h2-console + + jpa: + hibernate: + ddl-auto: create-drop + show-sql: true + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.H2Dialect + + data: + redis: + # Redis 연결 비활성화 (Mock 사용) + repositories: + enabled: false + + kafka: + # Kafka 연결 비활성화 (Mock 사용) + bootstrap-servers: localhost:9092 + +server: + port: 8084 + +logging: + level: + com.kt.event: DEBUG + org.hibernate.SQL: DEBUG