Content Service API 테스트 구현 추가

- REST API Controller 구현 (이미지 생성, Job 조회, 콘텐츠 조회 등)
- Gateway 어댑터 구현 (ContentGateway, JobGateway)
- Mock Gateway 구현 (Redis, CDN, AI 이미지 생성기)
- Mock UseCase 구현 (실제 이미지 생성 시뮬레이션)
- Security 및 Swagger 설정 추가
- 로컬 테스트를 위한 H2 데이터베이스 설정 (application-local.yml)
- 비동기 처리를 위한 @EnableAsync 설정

🤖 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 21:30:21 +09:00
parent 3d1dbda74b
commit 06995864b9
15 changed files with 897 additions and 0 deletions

View File

@ -17,4 +17,7 @@ dependencies {
// Jackson for JSON // Jackson for JSON
implementation 'com.fasterxml.jackson.core:jackson-databind' implementation 'com.fasterxml.jackson.core:jackson-databind'
// H2 Database for local testing
runtimeOnly 'com.h2database:h2'
} }

View File

@ -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<ImageStyle> styles = command.getStyles() != null && !command.getStyles().isEmpty()
? command.getStyles()
: List.of(ImageStyle.FANCY, ImageStyle.SIMPLE);
List<Platform> platforms = command.getPlatforms() != null && !command.getPlatforms().isEmpty()
? command.getPlatforms()
: List.of(Platform.INSTAGRAM, Platform.KAKAO);
List<GeneratedImage> 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);
}
}
}

View File

@ -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);
}
}

View File

@ -5,6 +5,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.scheduling.annotation.EnableAsync;
/** /**
* Content Service Application * Content Service Application
@ -19,6 +20,7 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
}) })
@EnableJpaRepositories(basePackages = "com.kt.event.content.infra.gateway.repository") @EnableJpaRepositories(basePackages = "com.kt.event.content.infra.gateway.repository")
@EnableJpaAuditing @EnableJpaAuditing
@EnableAsync
public class ContentApplication { public class ContentApplication {
public static void main(String[] args) { public static void main(String[] args) {

View File

@ -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();
}
}

View File

@ -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")
));
}
}

View File

@ -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<Content> findByEventDraftIdWithImages(Long eventDraftId) {
log.debug("이벤트 콘텐츠 조회 (with images): eventDraftId={}", eventDraftId);
return contentRepository.findByEventDraftIdWithImages(eventDraftId)
.map(ContentEntity::toDomain);
}
@Override
@Transactional(readOnly = true)
public Optional<GeneratedImage> findImageById(Long imageId) {
log.debug("이미지 조회: imageId={}", imageId);
return imageRepository.findById(imageId)
.map(GeneratedImageEntity::toDomain);
}
@Override
@Transactional(readOnly = true)
public List<GeneratedImage> 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();
}
}

View File

@ -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<Job> findById(String jobId) {
log.debug("Job 조회: jobId={}", jobId);
return jobRepository.findById(jobId)
.map(JobEntity::toDomain);
}
/**
* 이벤트별 Job 조회 (추가 메서드)
*/
@Transactional(readOnly = true)
public List<Job> 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<Job> 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);
}
}

View File

@ -86,6 +86,17 @@ public class ContentEntity extends BaseTimeEntity {
.build(); .build();
} }
/**
* 콘텐츠 정보 업데이트
*
* @param eventTitle 이벤트 제목
* @param eventDescription 이벤트 설명
*/
public void update(String eventTitle, String eventDescription) {
this.eventTitle = eventTitle;
this.eventDescription = eventDescription;
}
/** /**
* 이미지 추가 * 이미지 추가
* *

View File

@ -140,4 +140,33 @@ public class JobEntity extends BaseTimeEntity {
this.status = Job.Status.FAILED; this.status = Job.Status.FAILED;
this.errorMessage = errorMessage; 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;
}
} }

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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<Long, Map<String, Object>> aiDataCache = new HashMap<>();
// ========================================
// RedisAIDataReader 구현
// ========================================
@Override
public Optional<Map<String, Object>> getAIRecommendation(Long eventDraftId) {
log.info("[MOCK] Redis에서 AI 추천 데이터 조회: eventDraftId={}", eventDraftId);
// Mock 데이터 반환
Map<String, Object> 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<GeneratedImage> images, long ttlSeconds) {
log.info("[MOCK] Redis에 이미지 캐싱: eventDraftId={}, count={}, ttl={}초",
eventDraftId, images.size(), ttlSeconds);
}
}

View File

@ -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<JobInfo> 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<JobInfo> 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<ContentInfo> 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<List<ImageInfo>> getImages(
@PathVariable Long eventDraftId,
@RequestParam(required = false) String style,
@RequestParam(required = false) String platform) {
log.info("이미지 목록 조회: eventDraftId={}, style={}, platform={}", eventDraftId, style, platform);
// TODO: 필터링 기능 추가 (현재는 전체 목록 반환)
List<ImageInfo> images = getImageListUseCase.execute(eventDraftId);
return ResponseEntity.ok(images);
}
/**
* GET /content/images/{imageId}
* 특정 이미지 상세 조회
*
* @param imageId 이미지 ID
* @return 200 OK - 이미지 상세 정보
*/
@GetMapping("/images/{imageId}")
public ResponseEntity<ImageInfo> 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<Void> 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<JobInfo> 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);
}
}

View File

@ -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