Content Service 기능 개발 완료

- Redis 연동 구현 (패스워드 인증 지원)
  - RedisConfig에 password 설정 추가
  - RedisGateway에 ContentReader/Writer 인터페이스 구현
  - application-dev.yml 프로파일 추가

- 이미지 삭제 기능 구현
  - DeleteImageUseCase 인터페이스 추가
  - DeleteImageService 구현
  - ContentWriter.deleteImageById() 메서드 추가
  - RedisGateway 및 MockRedisGateway 삭제 로직 구현

- 이미지 필터링 기능 추가
  - GetImageListUseCase에 style, platform 파라미터 추가
  - GetImageListService에 Stream filter 로직 구현
  - ContentController에서 String → Enum 변환 처리

- Mock 서비스 dev 프로파일 지원
  - MockGenerateImagesService dev 프로파일 추가
  - MockRegenerateImageService dev 프로파일 추가

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
cherry2250
2025-10-27 09:45:21 +09:00
parent ff83dca1a1
commit f838c689ed
14 changed files with 563 additions and 11 deletions
@@ -0,0 +1,38 @@
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.usecase.in.DeleteImageUseCase;
import com.kt.event.content.biz.usecase.out.ContentReader;
import com.kt.event.content.biz.usecase.out.ContentWriter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 이미지 삭제 서비스
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
public class DeleteImageService implements DeleteImageUseCase {
private final ContentReader contentReader;
private final ContentWriter contentWriter;
@Override
public void execute(Long imageId) {
log.info("[DeleteImageService] 이미지 삭제 요청: imageId={}", imageId);
// 이미지 존재 확인
contentReader.findImageById(imageId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMMON_001, "이미지를 찾을 수 없습니다"));
// 이미지 삭제
contentWriter.deleteImageById(imageId);
log.info("[DeleteImageService] 이미지 삭제 완료: imageId={}", imageId);
}
}
@@ -1,6 +1,8 @@
package com.kt.event.content.biz.service;
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 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;
@@ -24,9 +26,15 @@ public class GetImageListService implements GetImageListUseCase {
private final ContentReader contentReader;
@Override
public List<ImageInfo> execute(Long eventDraftId) {
public List<ImageInfo> execute(Long eventDraftId, ImageStyle style, Platform platform) {
log.info("이미지 목록 조회: eventDraftId={}, style={}, platform={}", eventDraftId, style, platform);
List<GeneratedImage> images = contentReader.findImagesByEventDraftId(eventDraftId);
// 필터링 적용
return images.stream()
.filter(image -> style == null || image.getStyle() == style)
.filter(image -> platform == null || image.getPlatform() == platform)
.map(ImageInfo::from)
.collect(Collectors.toList());
}
@@ -29,7 +29,7 @@ import java.util.UUID;
*/
@Slf4j
@Service
@Profile({"local", "test"})
@Profile({"local", "test", "dev"})
@RequiredArgsConstructor
public class MockGenerateImagesService implements GenerateImagesUseCase {
@@ -19,7 +19,7 @@ import java.util.UUID;
*/
@Slf4j
@Service
@Profile({"local", "test"})
@Profile({"local", "test", "dev"})
@RequiredArgsConstructor
public class MockRegenerateImageService implements RegenerateImageUseCase {
@@ -0,0 +1,14 @@
package com.kt.event.content.biz.usecase.in;
/**
* 이미지 삭제 UseCase
*/
public interface DeleteImageUseCase {
/**
* 이미지 삭제
*
* @param imageId 삭제할 이미지 ID
*/
void execute(Long imageId);
}
@@ -1,5 +1,7 @@
package com.kt.event.content.biz.usecase.in;
import com.kt.event.content.biz.domain.ImageStyle;
import com.kt.event.content.biz.domain.Platform;
import com.kt.event.content.biz.dto.ImageInfo;
import java.util.List;
@@ -10,10 +12,12 @@ import java.util.List;
public interface GetImageListUseCase {
/**
* 이벤트의 이미지 목록 조회
* 이벤트의 이미지 목록 조회 (필터링 지원)
*
* @param eventDraftId 이벤트 초안 ID
* @param style 이미지 스타일 필터 (null이면 전체)
* @param platform 플랫폼 필터 (null이면 전체)
* @return 이미지 정보 목록
*/
List<ImageInfo> execute(Long eventDraftId);
List<ImageInfo> execute(Long eventDraftId, ImageStyle style, Platform platform);
}
@@ -23,4 +23,11 @@ public interface ContentWriter {
* @return 저장된 이미지
*/
GeneratedImage saveImage(GeneratedImage image);
/**
* 이미지 ID로 이미지 삭제
*
* @param imageId 이미지 ID
*/
void deleteImageById(Long imageId);
}
@@ -25,9 +25,18 @@ public class RedisConfig {
@Value("${spring.data.redis.port}")
private int port;
@Value("${spring.data.redis.password:}")
private String password;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, port);
// 패스워드가 있는 경우에만 설정
if (password != null && !password.isEmpty()) {
config.setPassword(password);
}
return new LettuceConnectionFactory(config);
}
@@ -1,12 +1,15 @@
package com.kt.event.content.infra.gateway;
import com.fasterxml.jackson.databind.ObjectMapper;
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.RedisImageData;
import com.kt.event.content.biz.dto.RedisJobData;
import com.kt.event.content.biz.usecase.out.ContentReader;
import com.kt.event.content.biz.usecase.out.ContentWriter;
import com.kt.event.content.biz.usecase.out.ImageReader;
import com.kt.event.content.biz.usecase.out.ImageWriter;
import com.kt.event.content.biz.usecase.out.JobReader;
@@ -36,7 +39,7 @@ import java.util.stream.Collectors;
@Component
@Profile({"!local", "!test"})
@RequiredArgsConstructor
public class RedisGateway implements RedisAIDataReader, RedisImageWriter, ImageWriter, ImageReader, JobWriter, JobReader {
public class RedisGateway implements RedisAIDataReader, RedisImageWriter, ImageWriter, ImageReader, JobWriter, JobReader, ContentReader, ContentWriter {
private final RedisTemplate<String, Object> redisTemplate;
private final ObjectMapper objectMapper;
@@ -338,4 +341,190 @@ public class RedisGateway implements RedisAIDataReader, RedisImageWriter, ImageW
String value = getString(map, key);
return value != null && !value.isEmpty() ? LocalDateTime.parse(value) : null;
}
// ==================== ContentReader 구현 ====================
private static final String CONTENT_META_KEY_PREFIX = "content:meta:";
private static final String IMAGE_BY_ID_KEY_PREFIX = "content:image:id:";
private static final String IMAGE_IDS_SET_KEY_PREFIX = "content:images:";
@Override
public Optional<Content> findByEventDraftIdWithImages(Long eventDraftId) {
try {
String contentKey = CONTENT_META_KEY_PREFIX + eventDraftId;
Map<Object, Object> contentFields = redisTemplate.opsForHash().entries(contentKey);
if (contentFields.isEmpty()) {
log.warn("Content를 찾을 수 없음: eventDraftId={}", eventDraftId);
return Optional.empty();
}
// 이미지 목록 조회
List<GeneratedImage> images = findImagesByEventDraftId(eventDraftId);
// Content 재구성
Content content = Content.builder()
.id(getLong(contentFields, "id"))
.eventDraftId(getLong(contentFields, "eventDraftId"))
.eventTitle(getString(contentFields, "eventTitle"))
.eventDescription(getString(contentFields, "eventDescription"))
.images(images)
.createdAt(getLocalDateTime(contentFields, "createdAt"))
.updatedAt(getLocalDateTime(contentFields, "updatedAt"))
.build();
return Optional.of(content);
} catch (Exception e) {
log.error("Content 조회 실패: eventDraftId={}", eventDraftId, e);
return Optional.empty();
}
}
@Override
public Optional<GeneratedImage> findImageById(Long imageId) {
try {
String key = IMAGE_BY_ID_KEY_PREFIX + imageId;
Object data = redisTemplate.opsForValue().get(key);
if (data == null) {
log.warn("이미지를 찾을 수 없음: imageId={}", imageId);
return Optional.empty();
}
GeneratedImage image = objectMapper.readValue(data.toString(), GeneratedImage.class);
return Optional.of(image);
} catch (Exception e) {
log.error("이미지 조회 실패: imageId={}", imageId, e);
return Optional.empty();
}
}
@Override
public List<GeneratedImage> findImagesByEventDraftId(Long eventDraftId) {
try {
String setKey = IMAGE_IDS_SET_KEY_PREFIX + eventDraftId;
var imageIdSet = redisTemplate.opsForSet().members(setKey);
if (imageIdSet == null || imageIdSet.isEmpty()) {
log.info("이미지 목록이 비어있음: eventDraftId={}", eventDraftId);
return new ArrayList<>();
}
List<GeneratedImage> images = new ArrayList<>();
for (Object imageIdObj : imageIdSet) {
Long imageId = Long.valueOf(imageIdObj.toString());
findImageById(imageId).ifPresent(images::add);
}
log.info("이미지 목록 조회 완료: eventDraftId={}, count={}", eventDraftId, images.size());
return images;
} catch (Exception e) {
log.error("이미지 목록 조회 실패: eventDraftId={}", eventDraftId, e);
return new ArrayList<>();
}
}
// ==================== ContentWriter 구현 ====================
private static Long nextContentId = 1L;
private static Long nextImageId = 1L;
@Override
public Content save(Content content) {
try {
Long id = content.getId() != null ? content.getId() : nextContentId++;
String contentKey = CONTENT_META_KEY_PREFIX + content.getEventDraftId();
// Content 메타 정보 저장
Map<String, String> contentFields = new java.util.HashMap<>();
contentFields.put("id", String.valueOf(id));
contentFields.put("eventDraftId", String.valueOf(content.getEventDraftId()));
contentFields.put("eventTitle", content.getEventTitle() != null ? content.getEventTitle() : "");
contentFields.put("eventDescription", content.getEventDescription() != null ? content.getEventDescription() : "");
contentFields.put("createdAt", content.getCreatedAt() != null ? content.getCreatedAt().toString() : LocalDateTime.now().toString());
contentFields.put("updatedAt", content.getUpdatedAt() != null ? content.getUpdatedAt().toString() : LocalDateTime.now().toString());
redisTemplate.opsForHash().putAll(contentKey, contentFields);
redisTemplate.expire(contentKey, DEFAULT_TTL);
// Content 재구성하여 반환
Content savedContent = Content.builder()
.id(id)
.eventDraftId(content.getEventDraftId())
.eventTitle(content.getEventTitle())
.eventDescription(content.getEventDescription())
.images(content.getImages())
.createdAt(content.getCreatedAt())
.updatedAt(content.getUpdatedAt())
.build();
log.info("Content 저장 완료: contentId={}, eventDraftId={}", id, content.getEventDraftId());
return savedContent;
} catch (Exception e) {
log.error("Content 저장 실패: eventDraftId={}", content.getEventDraftId(), e);
throw new RuntimeException("Content 저장 실패", e);
}
}
@Override
public GeneratedImage saveImage(GeneratedImage image) {
try {
Long imageId = image.getId() != null ? image.getId() : nextImageId++;
// GeneratedImage 저장
String imageKey = IMAGE_BY_ID_KEY_PREFIX + imageId;
GeneratedImage savedImage = GeneratedImage.builder()
.id(imageId)
.eventDraftId(image.getEventDraftId())
.style(image.getStyle())
.platform(image.getPlatform())
.cdnUrl(image.getCdnUrl())
.prompt(image.getPrompt())
.selected(image.isSelected())
.createdAt(image.getCreatedAt() != null ? image.getCreatedAt() : LocalDateTime.now())
.updatedAt(image.getUpdatedAt() != null ? image.getUpdatedAt() : LocalDateTime.now())
.build();
String json = objectMapper.writeValueAsString(savedImage);
redisTemplate.opsForValue().set(imageKey, json, DEFAULT_TTL);
// Image ID를 Set에 추가
String setKey = IMAGE_IDS_SET_KEY_PREFIX + image.getEventDraftId();
redisTemplate.opsForSet().add(setKey, imageId);
redisTemplate.expire(setKey, DEFAULT_TTL);
log.info("이미지 저장 완료: imageId={}, eventDraftId={}", imageId, image.getEventDraftId());
return savedImage;
} catch (Exception e) {
log.error("이미지 저장 실패: eventDraftId={}", image.getEventDraftId(), e);
throw new RuntimeException("이미지 저장 실패", e);
}
}
@Override
public void deleteImageById(Long imageId) {
try {
// 이미지 조회
Optional<GeneratedImage> imageOpt = findImageById(imageId);
if (imageOpt.isEmpty()) {
log.warn("삭제할 이미지를 찾을 수 없음: imageId={}", imageId);
return;
}
GeneratedImage image = imageOpt.get();
// Image 삭제
String imageKey = IMAGE_BY_ID_KEY_PREFIX + imageId;
redisTemplate.delete(imageKey);
// Set에서 Image ID 제거
String setKey = IMAGE_IDS_SET_KEY_PREFIX + image.getEventDraftId();
redisTemplate.opsForSet().remove(setKey, imageId);
log.info("이미지 삭제 완료: imageId={}, eventDraftId={}", imageId, image.getEventDraftId());
} catch (Exception e) {
log.error("이미지 삭제 실패: imageId={}", imageId, e);
throw new RuntimeException("이미지 삭제 실패", e);
}
}
}
@@ -398,4 +398,33 @@ public class MockRedisGateway implements RedisAIDataReader, RedisImageWriter, Im
throw e;
}
}
/**
* 이미지 ID로 이미지 삭제
*/
@Override
public void deleteImageById(Long imageId) {
try {
// imageByIdStorage에서 이미지 조회
GeneratedImage image = imageByIdStorage.get(imageId);
if (image == null) {
log.warn("[MOCK] 삭제할 이미지를 찾을 수 없음: imageId={}", imageId);
return;
}
// imageByIdStorage에서 삭제
imageByIdStorage.remove(imageId);
// imageStorage에서도 삭제 (Redis 캐시 스토리지)
String key = buildImageKey(image.getEventDraftId(), image.getStyle(), image.getPlatform());
imageStorage.remove(key);
log.info("[MOCK] 이미지 삭제 완료: imageId={}, eventDraftId={}, style={}, platform={}",
imageId, image.getEventDraftId(), image.getStyle(), image.getPlatform());
} catch (Exception e) {
log.error("[MOCK] 이미지 삭제 실패: imageId={}", imageId, e);
throw e;
}
}
}
@@ -1,9 +1,12 @@
package com.kt.event.content.infra.web.controller;
import com.kt.event.content.biz.domain.ImageStyle;
import com.kt.event.content.biz.domain.Platform;
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.DeleteImageUseCase;
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;
@@ -38,6 +41,7 @@ public class ContentController {
private final GetImageListUseCase getImageListUseCase;
private final GetImageDetailUseCase getImageDetailUseCase;
private final RegenerateImageUseCase regenerateImageUseCase;
private final DeleteImageUseCase deleteImageUseCase;
/**
* POST /content/images/generate
@@ -104,8 +108,11 @@ public class ContentController {
@RequestParam(required = false) String platform) {
log.info("이미지 목록 조회: eventDraftId={}, style={}, platform={}", eventDraftId, style, platform);
// TODO: 필터링 기능 추가 (현재는 전체 목록 반환)
List<ImageInfo> images = getImageListUseCase.execute(eventDraftId);
// String -> Enum 변환
ImageStyle imageStyle = style != null ? ImageStyle.valueOf(style.toUpperCase()) : null;
Platform imagePlatform = platform != null ? Platform.valueOf(platform.toUpperCase()) : null;
List<ImageInfo> images = getImageListUseCase.execute(eventDraftId, imageStyle, imagePlatform);
return ResponseEntity.ok(images);
}
@@ -137,8 +144,7 @@ public class ContentController {
public ResponseEntity<Void> deleteImage(@PathVariable Long imageId) {
log.info("이미지 삭제 요청: imageId={}", imageId);
// TODO: DeleteImageUseCase 구현 필요
// deleteImageUseCase.execute(imageId);
deleteImageUseCase.execute(imageId);
return ResponseEntity.noContent().build();
}
@@ -0,0 +1,34 @@
spring:
application:
name: content-service
data:
redis:
host: 20.214.210.71
port: 6379
password: Hi5Jessica!
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
@@ -4,8 +4,9 @@ spring:
data:
redis:
host: 4.217.131.139
host: 20.214.210.71
port: 6379
password: Hi5Jessica!
kafka:
bootstrap-servers: 20.249.125.115:9092