diff --git a/.gradle/8.10/checksums/checksums.lock b/.gradle/8.10/checksums/checksums.lock index 837e5b9..a1da254 100644 Binary files a/.gradle/8.10/checksums/checksums.lock and b/.gradle/8.10/checksums/checksums.lock differ diff --git a/.gradle/8.10/checksums/md5-checksums.bin b/.gradle/8.10/checksums/md5-checksums.bin index 04c6d00..589eb50 100644 Binary files a/.gradle/8.10/checksums/md5-checksums.bin and b/.gradle/8.10/checksums/md5-checksums.bin differ diff --git a/.gradle/8.10/checksums/sha1-checksums.bin b/.gradle/8.10/checksums/sha1-checksums.bin index 19a5410..73ed738 100644 Binary files a/.gradle/8.10/checksums/sha1-checksums.bin and b/.gradle/8.10/checksums/sha1-checksums.bin differ diff --git a/.gradle/8.10/executionHistory/executionHistory.bin b/.gradle/8.10/executionHistory/executionHistory.bin index 2177cdd..3778430 100644 Binary files a/.gradle/8.10/executionHistory/executionHistory.bin and b/.gradle/8.10/executionHistory/executionHistory.bin differ diff --git a/.gradle/8.10/executionHistory/executionHistory.lock b/.gradle/8.10/executionHistory/executionHistory.lock index 0ce4c96..778ae70 100644 Binary files a/.gradle/8.10/executionHistory/executionHistory.lock and b/.gradle/8.10/executionHistory/executionHistory.lock differ diff --git a/.gradle/8.10/fileHashes/fileHashes.bin b/.gradle/8.10/fileHashes/fileHashes.bin index 8088fbb..176d863 100644 Binary files a/.gradle/8.10/fileHashes/fileHashes.bin and b/.gradle/8.10/fileHashes/fileHashes.bin differ diff --git a/.gradle/8.10/fileHashes/fileHashes.lock b/.gradle/8.10/fileHashes/fileHashes.lock index 340e0dd..4577896 100644 Binary files a/.gradle/8.10/fileHashes/fileHashes.lock and b/.gradle/8.10/fileHashes/fileHashes.lock differ diff --git a/.gradle/8.10/fileHashes/resourceHashesCache.bin b/.gradle/8.10/fileHashes/resourceHashesCache.bin index 3d21896..4320628 100644 Binary files a/.gradle/8.10/fileHashes/resourceHashesCache.bin and b/.gradle/8.10/fileHashes/resourceHashesCache.bin differ diff --git a/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/.gradle/buildOutputCleanup/buildOutputCleanup.lock index 0350ff2..06bcff5 100644 Binary files a/.gradle/buildOutputCleanup/buildOutputCleanup.lock and b/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/.gradle/buildOutputCleanup/outputFiles.bin b/.gradle/buildOutputCleanup/outputFiles.bin index 4ed6f06..695f9c2 100644 Binary files a/.gradle/buildOutputCleanup/outputFiles.bin and b/.gradle/buildOutputCleanup/outputFiles.bin differ diff --git a/.gradle/file-system.probe b/.gradle/file-system.probe index ac4beb4..2037b03 100644 Binary files a/.gradle/file-system.probe and b/.gradle/file-system.probe differ diff --git a/.run/DistributionServiceApplication.run.xml b/.run/DistributionServiceApplication.run.xml new file mode 100644 index 0000000..664df2a --- /dev/null +++ b/.run/DistributionServiceApplication.run.xml @@ -0,0 +1,20 @@ + + + + diff --git a/claude/make-run-profile.md b/claude/make-run-profile.md new file mode 100644 index 0000000..f363a91 --- /dev/null +++ b/claude/make-run-profile.md @@ -0,0 +1,175 @@ +# 서비스실행파일작성가이드 + +[요청사항] +- <수행원칙>을 준용하여 수행 +- <수행순서>에 따라 수행 +- [결과파일] 안내에 따라 파일 작성 + +[가이드] +<수행원칙> +- 설정 Manifest(src/main/resources/application*.yml)의 각 항목의 값은 하드코딩하지 않고 환경변수 처리 +- Kubernetes에 배포된 데이터베이스는 LoadBalacer유형의 Service를 만들어 연결 +- MQ 이용 시 'MQ설치결과서'의 연결 정보를 실행 프로파일의 환경변수로 등록 +<수행순서> +- 준비: + - 데이터베이스설치결과서(develop/database/exec/db-exec-dev.md) 분석 + - 캐시설치결과서(develop/database/exec/cache-exec-dev.md) 분석 + - MQ설치결과서(develop/mq/mq-exec-dev.md) 분석 - 연결 정보 확인 + - kubectl get svc -n tripgen-dev | grep LoadBalancer 실행하여 External IP 목록 확인 +- 실행: + - 각 서비스별를 서브에이젼트로 병렬 수행 + - 설정 Manifest 수정 + - 하드코딩 되어 있는 값이 있으면 환경변수로 변환 + - 특히, 데이터베이스, MQ 등의 연결 정보는 반드시 환경변수로 변환해야 함 + - 민감한 정보의 디퐅트값은 생략하거나 간략한 값으로 지정 + - '<로그설정>'을 참조하여 Log 파일 설정 + - '<실행프로파일 작성 가이드>'에 따라 서비스 실행프로파일 작성 + - LoadBalancer External IP를 DB_HOST, REDIS_HOST로 설정 + - MQ 연결 정보를 application.yml의 환경변수명에 맞춰 설정 + - 서비스 실행 및 오류 수정 + - 'IntelliJ서비스실행기'를 'tools' 디렉토리에 다운로드 + - python 또는 python3 명령으로 백그라우드로 실행하고 결과 로그를 분석 + nohup python3 tools/run-intellij-service-profile.py {service-name} > logs/{service-name}.log 2>&1 & echo "Started {service-name} with PID: $!" + - 서비스 실행은 다른 방법 사용하지 말고 **반드시 python 프로그램 이용** + - 오류 수정 후 필요 시 실행파일의 환경변수를 올바르게 변경 + - 서비스 정상 시작 확인 후 서비스 중지 + - 결과: {service-name}/.run +<서비스 중지 방법> +- Window + - netstat -ano | findstr :{PORT} + - powershell "Stop-Process -Id {Process number} -Force" +- Linux/Mac + - netstat -ano | grep {PORT} + - kill -9 {Process number} +<로그설정> +- **application.yml 로그 파일 설정**: + ```yaml + logging: + file: + name: ${LOG_FILE:logs/trip-service.log} + logback: + rollingpolicy: + max-file-size: 10MB + max-history: 7 + total-size-cap: 100MB + ``` + +<실행프로파일 작성 가이드> +- {service-name}/.run/{service-name}.run.xml 파일로 작성 +- Spring Boot가 아니고 **Gradle 실행 프로파일**이어야 함: '[실행프로파일 예시]' 참조 +- Kubernetes에 배포된 데이터베이스의 LoadBalancer Service 확인: + - kubectl get svc -n {namespace} | grep LoadBalancer 명령으로 LoadBalancer IP 확인 + - 각 서비스별 데이터베이스의 LoadBalancer External IP를 DB_HOST로 사용 + - 캐시(Redis)의 LoadBalancer External IP를 REDIS_HOST로 사용 +- MQ 연결 설정: + - MQ설치결과서(develop/mq/mq-exec-dev.md)에서 연결 정보 확인 + - MQ 유형에 따른 연결 정보 설정 예시: + - RabbitMQ: RABBITMQ_HOST, RABBITMQ_PORT, RABBITMQ_USERNAME, RABBITMQ_PASSWORD + - Kafka: KAFKA_BOOTSTRAP_SERVERS, KAFKA_SECURITY_PROTOCOL + - Azure Service Bus: SERVICE_BUS_CONNECTION_STRING + - AWS SQS: AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY + - Redis (Pub/Sub): REDIS_HOST, REDIS_PORT, REDIS_PASSWORD + - ActiveMQ: ACTIVEMQ_BROKER_URL, ACTIVEMQ_USER, ACTIVEMQ_PASSWORD + - 기타 MQ: 해당 MQ의 연결에 필요한 호스트, 포트, 인증정보, 연결문자열 등을 환경변수로 설정 + - application.yml에 정의된 환경변수명 확인 후 매핑 +- 백킹서비스 연결 정보 매핑: + - 데이터베이스설치결과서에서 각 서비스별 DB 인증 정보 확인 + - 캐시설치결과서에서 각 서비스별 Redis 인증 정보 확인 + - LoadBalancer의 External IP를 호스트로 사용 (내부 DNS 아님) +- 개발모드의 DDL_AUTO값은 update로 함 +- JWT Secret Key는 모든 서비스가 동일해야 함 +- application.yaml의 환경변수와 일치하도록 환경변수 설정 +- application.yaml의 민감 정보는 기본값으로 지정하지 않고 실제 백킹서비스 정보로 지정 +- 백킹서비스 연결 확인 결과를 바탕으로 정확한 값을 지정 +- 기존에 파일이 있으면 내용을 분석하여 항목 추가/수정/삭제 + +[실행프로파일 예시] +``` + + + + + + + + true + true + + + + + false + false + + + +``` + +[참고자료] +- 데이터베이스설치결과서: develop/database/exec/db-exec-dev.md + - 각 서비스별 DB 연결 정보 (사용자명, 비밀번호, DB명) + - LoadBalancer Service External IP 목록 +- 캐시설치결과서: develop/database/exec/cache-exec-dev.md + - 각 서비스별 Redis 연결 정보 + - LoadBalancer Service External IP 목록 +- MQ설치결과서: develop/mq/mq-exec-dev.md + - MQ 유형 및 연결 정보 + - 연결에 필요한 호스트, 포트, 인증 정보 + - LoadBalancer Service External IP (해당하는 경우) diff --git a/distribution-service/.run/distribution-service.run.xml b/distribution-service/.run/distribution-service.run.xml new file mode 100644 index 0000000..b3879c4 --- /dev/null +++ b/distribution-service/.run/distribution-service.run.xml @@ -0,0 +1,51 @@ + + + + + + + + true + true + + + + + false + false + + + diff --git a/distribution-service/src/main/java/com/kt/distribution/DistributionApplication.java b/distribution-service/src/main/java/com/kt/distribution/DistributionApplication.java new file mode 100644 index 0000000..2534d29 --- /dev/null +++ b/distribution-service/src/main/java/com/kt/distribution/DistributionApplication.java @@ -0,0 +1,23 @@ +package com.kt.distribution; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.kafka.annotation.EnableKafka; + +/** + * Distribution Service Application + * 다중 채널 배포 관리 서비스 + * + * @author System Architect + * @since 2025-10-23 + */ +@SpringBootApplication +@EnableKafka +@EnableFeignClients +public class DistributionApplication { + + public static void main(String[] args) { + SpringApplication.run(DistributionApplication.class, args); + } +} diff --git a/distribution-service/src/main/java/com/kt/distribution/adapter/AbstractChannelAdapter.java b/distribution-service/src/main/java/com/kt/distribution/adapter/AbstractChannelAdapter.java new file mode 100644 index 0000000..c0bebce --- /dev/null +++ b/distribution-service/src/main/java/com/kt/distribution/adapter/AbstractChannelAdapter.java @@ -0,0 +1,86 @@ +package com.kt.distribution.adapter; + +import com.kt.distribution.dto.ChannelDistributionResult; +import com.kt.distribution.dto.DistributionRequest; +import io.github.resilience4j.bulkhead.annotation.Bulkhead; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import io.github.resilience4j.retry.annotation.Retry; +import lombok.extern.slf4j.Slf4j; + +/** + * Abstract Channel Adapter + * 공통 로직 및 Resilience4j 적용 + * + * @author System Architect + * @since 2025-10-23 + */ +@Slf4j +public abstract class AbstractChannelAdapter implements ChannelAdapter { + + /** + * 채널로 배포 실행 (Resilience4j 적용) + * + * @param request DistributionRequest + * @return ChannelDistributionResult + */ + @Override + @CircuitBreaker(name = "channelApi", fallbackMethod = "fallback") + @Retry(name = "channelApi") + @Bulkhead(name = "channelApi") + public ChannelDistributionResult distribute(DistributionRequest request) { + long startTime = System.currentTimeMillis(); + + try { + log.info("Starting distribution to channel: {}, eventId: {}", + getChannelType(), request.getEventId()); + + // 실제 외부 API 호출 (구현체에서 구현) + ChannelDistributionResult result = executeDistribution(request); + result.setExecutionTimeMs(System.currentTimeMillis() - startTime); + + log.info("Distribution completed successfully: channel={}, eventId={}, executionTime={}ms", + getChannelType(), request.getEventId(), result.getExecutionTimeMs()); + + return result; + + } catch (Exception e) { + long executionTime = System.currentTimeMillis() - startTime; + log.error("Distribution failed: channel={}, eventId={}, error={}", + getChannelType(), request.getEventId(), e.getMessage(), e); + + return ChannelDistributionResult.builder() + .channel(getChannelType()) + .success(false) + .errorMessage(e.getMessage()) + .executionTimeMs(executionTime) + .build(); + } + } + + /** + * 실제 외부 API 호출 로직 (구현체에서 구현) + * + * @param request DistributionRequest + * @return ChannelDistributionResult + */ + protected abstract ChannelDistributionResult executeDistribution(DistributionRequest request); + + /** + * Fallback 메서드 (Circuit Breaker Open 시) + * + * @param request DistributionRequest + * @param throwable Throwable + * @return ChannelDistributionResult + */ + protected ChannelDistributionResult fallback(DistributionRequest request, Throwable throwable) { + log.warn("Fallback triggered for channel: {}, eventId: {}, reason: {}", + getChannelType(), request.getEventId(), throwable.getMessage()); + + return ChannelDistributionResult.builder() + .channel(getChannelType()) + .success(false) + .errorMessage("Circuit Breaker Open: " + throwable.getMessage()) + .executionTimeMs(0) + .build(); + } +} diff --git a/distribution-service/src/main/java/com/kt/distribution/adapter/ChannelAdapter.java b/distribution-service/src/main/java/com/kt/distribution/adapter/ChannelAdapter.java new file mode 100644 index 0000000..bfedfc7 --- /dev/null +++ b/distribution-service/src/main/java/com/kt/distribution/adapter/ChannelAdapter.java @@ -0,0 +1,30 @@ +package com.kt.distribution.adapter; + +import com.kt.distribution.dto.ChannelDistributionResult; +import com.kt.distribution.dto.ChannelType; +import com.kt.distribution.dto.DistributionRequest; + +/** + * Channel Adapter Interface + * 각 채널별 배포 API를 호출하는 인터페이스 + * + * @author System Architect + * @since 2025-10-23 + */ +public interface ChannelAdapter { + + /** + * 지원하는 채널 타입 + * + * @return ChannelType + */ + ChannelType getChannelType(); + + /** + * 채널로 배포 실행 + * + * @param request DistributionRequest + * @return ChannelDistributionResult + */ + ChannelDistributionResult distribute(DistributionRequest request); +} diff --git a/distribution-service/src/main/java/com/kt/distribution/adapter/GiniTvAdapter.java b/distribution-service/src/main/java/com/kt/distribution/adapter/GiniTvAdapter.java new file mode 100644 index 0000000..655d9a6 --- /dev/null +++ b/distribution-service/src/main/java/com/kt/distribution/adapter/GiniTvAdapter.java @@ -0,0 +1,45 @@ +package com.kt.distribution.adapter; + +import com.kt.distribution.dto.ChannelDistributionResult; +import com.kt.distribution.dto.ChannelType; +import com.kt.distribution.dto.DistributionRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +/** + * 지니TV Adapter + * 지니TV 광고 등록 API 호출 + * + * @author System Architect + * @since 2025-10-23 + */ +@Slf4j +@Component +public class GiniTvAdapter extends AbstractChannelAdapter { + + @Value("${channel.apis.ginitv.url}") + private String apiUrl; + + @Override + public ChannelType getChannelType() { + return ChannelType.GINITV; + } + + @Override + protected ChannelDistributionResult executeDistribution(DistributionRequest request) { + log.debug("Calling GiniTV API: url={}, eventId={}", apiUrl, request.getEventId()); + + // TODO: 실제 API 호출 (현재는 Mock) + String distributionId = "GTIV-" + UUID.randomUUID().toString(); + + return ChannelDistributionResult.builder() + .channel(ChannelType.GINITV) + .success(true) + .distributionId(distributionId) + .estimatedReach(10000) // TV 광고 노출 수 + .build(); + } +} diff --git a/distribution-service/src/main/java/com/kt/distribution/adapter/InstagramAdapter.java b/distribution-service/src/main/java/com/kt/distribution/adapter/InstagramAdapter.java new file mode 100644 index 0000000..3b98443 --- /dev/null +++ b/distribution-service/src/main/java/com/kt/distribution/adapter/InstagramAdapter.java @@ -0,0 +1,45 @@ +package com.kt.distribution.adapter; + +import com.kt.distribution.dto.ChannelDistributionResult; +import com.kt.distribution.dto.ChannelType; +import com.kt.distribution.dto.DistributionRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +/** + * Instagram Adapter + * Instagram 포스팅 API 호출 + * + * @author System Architect + * @since 2025-10-23 + */ +@Slf4j +@Component +public class InstagramAdapter extends AbstractChannelAdapter { + + @Value("${channel.apis.instagram.url}") + private String apiUrl; + + @Override + public ChannelType getChannelType() { + return ChannelType.INSTAGRAM; + } + + @Override + protected ChannelDistributionResult executeDistribution(DistributionRequest request) { + log.debug("Calling Instagram API: url={}, eventId={}", apiUrl, request.getEventId()); + + // TODO: 실제 API 호출 (현재는 Mock) + String distributionId = "INSTA-" + UUID.randomUUID().toString(); + + return ChannelDistributionResult.builder() + .channel(ChannelType.INSTAGRAM) + .success(true) + .distributionId(distributionId) + .estimatedReach(3000) // 팔로워 수 기반 예상 노출 + .build(); + } +} diff --git a/distribution-service/src/main/java/com/kt/distribution/adapter/KakaoAdapter.java b/distribution-service/src/main/java/com/kt/distribution/adapter/KakaoAdapter.java new file mode 100644 index 0000000..68c7e06 --- /dev/null +++ b/distribution-service/src/main/java/com/kt/distribution/adapter/KakaoAdapter.java @@ -0,0 +1,45 @@ +package com.kt.distribution.adapter; + +import com.kt.distribution.dto.ChannelDistributionResult; +import com.kt.distribution.dto.ChannelType; +import com.kt.distribution.dto.DistributionRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +/** + * Kakao Channel Adapter + * Kakao Channel 포스팅 API 호출 + * + * @author System Architect + * @since 2025-10-23 + */ +@Slf4j +@Component +public class KakaoAdapter extends AbstractChannelAdapter { + + @Value("${channel.apis.kakao.url}") + private String apiUrl; + + @Override + public ChannelType getChannelType() { + return ChannelType.KAKAO; + } + + @Override + protected ChannelDistributionResult executeDistribution(DistributionRequest request) { + log.debug("Calling Kakao API: url={}, eventId={}", apiUrl, request.getEventId()); + + // TODO: 실제 API 호출 (현재는 Mock) + String distributionId = "KAKAO-" + UUID.randomUUID().toString(); + + return ChannelDistributionResult.builder() + .channel(ChannelType.KAKAO) + .success(true) + .distributionId(distributionId) + .estimatedReach(4000) // 채널 친구 수 기반 + .build(); + } +} diff --git a/distribution-service/src/main/java/com/kt/distribution/adapter/NaverAdapter.java b/distribution-service/src/main/java/com/kt/distribution/adapter/NaverAdapter.java new file mode 100644 index 0000000..0d7f44e --- /dev/null +++ b/distribution-service/src/main/java/com/kt/distribution/adapter/NaverAdapter.java @@ -0,0 +1,45 @@ +package com.kt.distribution.adapter; + +import com.kt.distribution.dto.ChannelDistributionResult; +import com.kt.distribution.dto.ChannelType; +import com.kt.distribution.dto.DistributionRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +/** + * Naver Blog Adapter + * Naver Blog 포스팅 API 호출 + * + * @author System Architect + * @since 2025-10-23 + */ +@Slf4j +@Component +public class NaverAdapter extends AbstractChannelAdapter { + + @Value("${channel.apis.naver.url}") + private String apiUrl; + + @Override + public ChannelType getChannelType() { + return ChannelType.NAVER; + } + + @Override + protected ChannelDistributionResult executeDistribution(DistributionRequest request) { + log.debug("Calling Naver API: url={}, eventId={}", apiUrl, request.getEventId()); + + // TODO: 실제 API 호출 (현재는 Mock) + String distributionId = "NAVER-" + UUID.randomUUID().toString(); + + return ChannelDistributionResult.builder() + .channel(ChannelType.NAVER) + .success(true) + .distributionId(distributionId) + .estimatedReach(2000) // 블로그 방문자 수 기반 + .build(); + } +} diff --git a/distribution-service/src/main/java/com/kt/distribution/adapter/RingoBizAdapter.java b/distribution-service/src/main/java/com/kt/distribution/adapter/RingoBizAdapter.java new file mode 100644 index 0000000..8ec0634 --- /dev/null +++ b/distribution-service/src/main/java/com/kt/distribution/adapter/RingoBizAdapter.java @@ -0,0 +1,45 @@ +package com.kt.distribution.adapter; + +import com.kt.distribution.dto.ChannelDistributionResult; +import com.kt.distribution.dto.ChannelType; +import com.kt.distribution.dto.DistributionRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +/** + * 링고비즈 Adapter + * 링고비즈 연결음 업데이트 API 호출 + * + * @author System Architect + * @since 2025-10-23 + */ +@Slf4j +@Component +public class RingoBizAdapter extends AbstractChannelAdapter { + + @Value("${channel.apis.ringobiz.url}") + private String apiUrl; + + @Override + public ChannelType getChannelType() { + return ChannelType.RINGOBIZ; + } + + @Override + protected ChannelDistributionResult executeDistribution(DistributionRequest request) { + log.debug("Calling RingoBiz API: url={}, eventId={}", apiUrl, request.getEventId()); + + // TODO: 실제 API 호출 (현재는 Mock) + String distributionId = "RBIZ-" + UUID.randomUUID().toString(); + + return ChannelDistributionResult.builder() + .channel(ChannelType.RINGOBIZ) + .success(true) + .distributionId(distributionId) + .estimatedReach(1000) // 연결음 사용자 수 + .build(); + } +} diff --git a/distribution-service/src/main/java/com/kt/distribution/adapter/UriDongNeTvAdapter.java b/distribution-service/src/main/java/com/kt/distribution/adapter/UriDongNeTvAdapter.java new file mode 100644 index 0000000..41fa264 --- /dev/null +++ b/distribution-service/src/main/java/com/kt/distribution/adapter/UriDongNeTvAdapter.java @@ -0,0 +1,72 @@ +package com.kt.distribution.adapter; + +import com.kt.distribution.dto.ChannelDistributionResult; +import com.kt.distribution.dto.ChannelType; +import com.kt.distribution.dto.DistributionRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * 우리동네TV Adapter + * 우리동네TV API 호출 + * + * @author System Architect + * @since 2025-10-23 + */ +@Slf4j +@Component +public class UriDongNeTvAdapter extends AbstractChannelAdapter { + + @Value("${channel.apis.uridongnetv.url}") + private String apiUrl; + + private final RestTemplate restTemplate = new RestTemplate(); + + @Override + public ChannelType getChannelType() { + return ChannelType.URIDONGNETV; + } + + @Override + protected ChannelDistributionResult executeDistribution(DistributionRequest request) { + log.debug("Calling UriDongNeTV API: url={}, eventId={}", apiUrl, request.getEventId()); + + // 외부 API 호출 준비 + Map payload = new HashMap<>(); + payload.put("eventId", request.getEventId()); + payload.put("title", request.getTitle()); + payload.put("videoUrl", request.getImageUrl()); // 이미지를 영상으로 변환한 URL + payload.put("radius", getChannelSetting(request, "radius", "500m")); + payload.put("timeSlot", getChannelSetting(request, "timeSlot", "evening")); + + // TODO: 실제 API 호출 (현재는 Mock) + // ResponseEntity response = restTemplate.postForEntity(apiUrl + "/distribute", payload, Map.class); + + // Mock 응답 + String distributionId = "UDTV-" + UUID.randomUUID().toString(); + int estimatedReach = 5000; + + return ChannelDistributionResult.builder() + .channel(ChannelType.URIDONGNETV) + .success(true) + .distributionId(distributionId) + .estimatedReach(estimatedReach) + .build(); + } + + private String getChannelSetting(DistributionRequest request, String key, String defaultValue) { + if (request.getChannelSettings() != null) { + Map settings = request.getChannelSettings().get(ChannelType.URIDONGNETV.name()); + if (settings != null && settings.containsKey(key)) { + return settings.get(key).toString(); + } + } + return defaultValue; + } +} diff --git a/distribution-service/src/main/java/com/kt/distribution/config/KafkaConfig.java b/distribution-service/src/main/java/com/kt/distribution/config/KafkaConfig.java new file mode 100644 index 0000000..92f2c90 --- /dev/null +++ b/distribution-service/src/main/java/com/kt/distribution/config/KafkaConfig.java @@ -0,0 +1,46 @@ +package com.kt.distribution.config; + +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.core.DefaultKafkaProducerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.core.ProducerFactory; +import org.springframework.kafka.support.serializer.JsonSerializer; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + +/** + * Kafka Configuration + * Kafka Producer 설정 + * + * @author System Architect + * @since 2025-10-23 + */ +@Configuration +@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = true) +public class KafkaConfig { + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServers; + + @Bean + public ProducerFactory producerFactory() { + Map configProps = new HashMap<>(); + configProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + configProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + configProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class); + configProps.put(JsonSerializer.ADD_TYPE_INFO_HEADERS, false); + return new DefaultKafkaProducerFactory<>(configProps); + } + + @Bean + public KafkaTemplate kafkaTemplate() { + return new KafkaTemplate<>(producerFactory()); + } +} diff --git a/distribution-service/src/main/java/com/kt/distribution/config/WebConfig.java b/distribution-service/src/main/java/com/kt/distribution/config/WebConfig.java new file mode 100644 index 0000000..1b7c1d0 --- /dev/null +++ b/distribution-service/src/main/java/com/kt/distribution/config/WebConfig.java @@ -0,0 +1,32 @@ +package com.kt.distribution.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Web Configuration + * CORS 설정 및 기타 웹 관련 설정 + * + * @author System Architect + * @since 2025-10-24 + */ +@Configuration +public class WebConfig implements WebMvcConfigurer { + + /** + * CORS 설정 + * - 모든 origin 허용 (개발 환경) + * - 모든 HTTP 메서드 허용 + * - Credentials 허용 + */ + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOriginPatterns("*") + .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(3600); + } +} diff --git a/distribution-service/src/main/java/com/kt/distribution/controller/DistributionController.java b/distribution-service/src/main/java/com/kt/distribution/controller/DistributionController.java new file mode 100644 index 0000000..c17503f --- /dev/null +++ b/distribution-service/src/main/java/com/kt/distribution/controller/DistributionController.java @@ -0,0 +1,71 @@ +package com.kt.distribution.controller; + +import com.kt.distribution.dto.DistributionRequest; +import com.kt.distribution.dto.DistributionResponse; +import com.kt.distribution.dto.DistributionStatusResponse; +import com.kt.distribution.service.DistributionService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * Distribution Controller + * POST /api/distribution/distribute - 다중 채널 배포 실행 + * GET /api/distribution/{eventId}/status - 배포 상태 조회 + * + * @author System Architect + * @since 2025-10-23 + */ +@Slf4j +@RestController +@RequestMapping("/api/distribution") +@RequiredArgsConstructor +public class DistributionController { + + private final DistributionService distributionService; + + /** + * 다중 채널 배포 실행 + * UFR-DIST-010: 다중채널배포 + * + * @param request DistributionRequest + * @return DistributionResponse + */ + @PostMapping("/distribute") + public ResponseEntity distribute(@RequestBody DistributionRequest request) { + log.info("Received distribution request: eventId={}, channels={}", + request.getEventId(), request.getChannels()); + + DistributionResponse response = distributionService.distribute(request); + + log.info("Distribution request processed: eventId={}, success={}, successCount={}, failureCount={}", + response.getEventId(), response.isSuccess(), + response.getSuccessCount(), response.getFailureCount()); + + return ResponseEntity.ok(response); + } + + /** + * 배포 상태 조회 + * UFR-DIST-020: 배포상태조회 + * + * @param eventId 이벤트 ID + * @return DistributionStatusResponse + */ + @GetMapping("/{eventId}/status") + public ResponseEntity getDistributionStatus(@PathVariable String eventId) { + log.info("Received distribution status request: eventId={}", eventId); + + DistributionStatusResponse response = distributionService.getDistributionStatus(eventId); + + log.info("Distribution status retrieved: eventId={}, overallStatus={}", + eventId, response.getOverallStatus()); + + if ("NOT_FOUND".equals(response.getOverallStatus())) { + return ResponseEntity.notFound().build(); + } + + return ResponseEntity.ok(response); + } +} diff --git a/distribution-service/src/main/java/com/kt/distribution/dto/ChannelDistributionResult.java b/distribution-service/src/main/java/com/kt/distribution/dto/ChannelDistributionResult.java new file mode 100644 index 0000000..915cfa1 --- /dev/null +++ b/distribution-service/src/main/java/com/kt/distribution/dto/ChannelDistributionResult.java @@ -0,0 +1,49 @@ +package com.kt.distribution.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 채널별 배포 결과 + * + * @author System Architect + * @since 2025-10-23 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ChannelDistributionResult { + + /** + * 채널 타입 + */ + private ChannelType channel; + + /** + * 배포 성공 여부 + */ + private boolean success; + + /** + * 배포 ID (성공 시) + */ + private String distributionId; + + /** + * 예상 노출 수 (성공 시) + */ + private Integer estimatedReach; + + /** + * 에러 메시지 (실패 시) + */ + private String errorMessage; + + /** + * 배포 소요 시간 (ms) + */ + private long executionTimeMs; +} diff --git a/distribution-service/src/main/java/com/kt/distribution/dto/ChannelStatus.java b/distribution-service/src/main/java/com/kt/distribution/dto/ChannelStatus.java new file mode 100644 index 0000000..a65567d --- /dev/null +++ b/distribution-service/src/main/java/com/kt/distribution/dto/ChannelStatus.java @@ -0,0 +1,100 @@ +package com.kt.distribution.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 채널별 배포 상태 DTO + * + * 각 채널의 배포 진행 상태 및 결과 정보를 담습니다. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ChannelStatus { + + /** + * 채널 타입 + */ + private ChannelType channel; + + /** + * 채널별 배포 상태 + * - PENDING: 대기 중 + * - IN_PROGRESS: 진행 중 + * - COMPLETED: 완료 + * - FAILED: 실패 + */ + private String status; + + /** + * 진행률 (0-100, IN_PROGRESS 상태일 때 사용) + */ + private Integer progress; + + /** + * 채널별 배포 ID (우리동네TV, 지니TV 등) + */ + private String distributionId; + + /** + * 예상 노출 수 (우리동네TV, 지니TV) + */ + private Integer estimatedViews; + + /** + * 업데이트 완료 시각 (링고비즈) + */ + private LocalDateTime updateTimestamp; + + /** + * 광고 ID (지니TV) + */ + private String adId; + + /** + * 노출 스케줄 (지니TV) + */ + private List impressionSchedule; + + /** + * 게시물 URL (Instagram, Naver Blog) + */ + private String postUrl; + + /** + * 게시물 ID (Instagram) + */ + private String postId; + + /** + * 메시지 ID (Kakao Channel) + */ + private String messageId; + + /** + * 완료 시각 + */ + private LocalDateTime completedAt; + + /** + * 오류 메시지 (실패 시) + */ + private String errorMessage; + + /** + * 재시도 횟수 + */ + private Integer retries; + + /** + * 마지막 재시도 시각 + */ + private LocalDateTime lastRetryAt; +} diff --git a/distribution-service/src/main/java/com/kt/distribution/dto/ChannelType.java b/distribution-service/src/main/java/com/kt/distribution/dto/ChannelType.java new file mode 100644 index 0000000..8a92b3b --- /dev/null +++ b/distribution-service/src/main/java/com/kt/distribution/dto/ChannelType.java @@ -0,0 +1,26 @@ +package com.kt.distribution.dto; + +/** + * 배포 채널 타입 + * + * @author System Architect + * @since 2025-10-23 + */ +public enum ChannelType { + URIDONGNETV("우리동네TV"), + RINGOBIZ("링고비즈"), + GINITV("지니TV"), + INSTAGRAM("Instagram"), + NAVER("Naver Blog"), + KAKAO("Kakao Channel"); + + private final String displayName; + + ChannelType(String displayName) { + this.displayName = displayName; + } + + public String getDisplayName() { + return displayName; + } +} diff --git a/distribution-service/src/main/java/com/kt/distribution/dto/DistributionRequest.java b/distribution-service/src/main/java/com/kt/distribution/dto/DistributionRequest.java new file mode 100644 index 0000000..fa1f68f --- /dev/null +++ b/distribution-service/src/main/java/com/kt/distribution/dto/DistributionRequest.java @@ -0,0 +1,54 @@ +package com.kt.distribution.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +/** + * 배포 요청 DTO + * POST /api/distribution/distribute + * + * @author System Architect + * @since 2025-10-23 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DistributionRequest { + + /** + * 이벤트 ID + */ + private String eventId; + + /** + * 이벤트 제목 + */ + private String title; + + /** + * 이벤트 설명 + */ + private String description; + + /** + * 이미지 URL (CDN) + */ + private String imageUrl; + + /** + * 배포할 채널 목록 + */ + private List channels; + + /** + * 채널별 추가 설정 (Optional) + * 예: { "URIDONGNETV": { "radius": "1km", "timeSlot": "evening" } } + */ + private Map> channelSettings; +} diff --git a/distribution-service/src/main/java/com/kt/distribution/dto/DistributionResponse.java b/distribution-service/src/main/java/com/kt/distribution/dto/DistributionResponse.java new file mode 100644 index 0000000..9945d80 --- /dev/null +++ b/distribution-service/src/main/java/com/kt/distribution/dto/DistributionResponse.java @@ -0,0 +1,63 @@ +package com.kt.distribution.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 배포 응답 DTO + * POST /api/distribution/distribute + * + * @author System Architect + * @since 2025-10-23 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DistributionResponse { + + /** + * 이벤트 ID + */ + private String eventId; + + /** + * 배포 성공 여부 (모든 채널 또는 일부 채널 성공) + */ + private boolean success; + + /** + * 채널별 배포 결과 + */ + private List channelResults; + + /** + * 성공한 채널 수 + */ + private int successCount; + + /** + * 실패한 채널 수 + */ + private int failureCount; + + /** + * 배포 완료 시각 + */ + private LocalDateTime completedAt; + + /** + * 전체 배포 소요 시간 (ms) + */ + private long totalExecutionTimeMs; + + /** + * 메시지 + */ + private String message; +} diff --git a/distribution-service/src/main/java/com/kt/distribution/dto/DistributionStatusResponse.java b/distribution-service/src/main/java/com/kt/distribution/dto/DistributionStatusResponse.java new file mode 100644 index 0000000..f65e964 --- /dev/null +++ b/distribution-service/src/main/java/com/kt/distribution/dto/DistributionStatusResponse.java @@ -0,0 +1,52 @@ +package com.kt.distribution.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 배포 상태 조회 응답 DTO + * + * 특정 이벤트의 전체 배포 상태 및 채널별 상세 상태 정보를 담습니다. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DistributionStatusResponse { + + /** + * 이벤트 ID + */ + private String eventId; + + /** + * 전체 배포 상태 + * - PENDING: 대기 중 + * - IN_PROGRESS: 진행 중 + * - COMPLETED: 완료 + * - PARTIAL_FAILURE: 부분 성공 + * - FAILED: 실패 + * - NOT_FOUND: 배포 이력 없음 + */ + private String overallStatus; + + /** + * 배포 시작 시각 + */ + private LocalDateTime startedAt; + + /** + * 배포 완료 시각 + */ + private LocalDateTime completedAt; + + /** + * 채널별 배포 상태 목록 + */ + private List channels; +} diff --git a/distribution-service/src/main/java/com/kt/distribution/event/DistributionCompletedEvent.java b/distribution-service/src/main/java/com/kt/distribution/event/DistributionCompletedEvent.java new file mode 100644 index 0000000..467b6a6 --- /dev/null +++ b/distribution-service/src/main/java/com/kt/distribution/event/DistributionCompletedEvent.java @@ -0,0 +1,48 @@ +package com.kt.distribution.event; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * Distribution Completed Event + * 배포 완료 시 Kafka로 발행하는 이벤트 + * + * @author System Architect + * @since 2025-10-23 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DistributionCompletedEvent { + + /** + * 이벤트 ID + */ + private String eventId; + + /** + * 배포 완료된 채널 목록 + */ + private List distributedChannels; + + /** + * 배포 완료 시각 + */ + private LocalDateTime completedAt; + + /** + * 성공한 채널 수 + */ + private int successCount; + + /** + * 실패한 채널 수 + */ + private int failureCount; +} diff --git a/distribution-service/src/main/java/com/kt/distribution/repository/DistributionStatusRepository.java b/distribution-service/src/main/java/com/kt/distribution/repository/DistributionStatusRepository.java new file mode 100644 index 0000000..aaa0b71 --- /dev/null +++ b/distribution-service/src/main/java/com/kt/distribution/repository/DistributionStatusRepository.java @@ -0,0 +1,65 @@ +package com.kt.distribution.repository; + +import com.kt.distribution.dto.DistributionStatusResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Repository; + +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 배포 상태 저장소 + * + * 메모리 기반으로 배포 상태를 관리합니다. + * 실제 운영 환경에서는 Redis 또는 데이터베이스를 사용하여 영구 저장하는 것을 권장합니다. + */ +@Slf4j +@Repository +public class DistributionStatusRepository { + + /** + * 이벤트 ID를 키로 배포 상태를 저장하는 메모리 저장소 + */ + private final Map distributionStatuses = new ConcurrentHashMap<>(); + + /** + * 배포 상태 저장 + * + * @param eventId 이벤트 ID + * @param status 배포 상태 + */ + public void save(String eventId, DistributionStatusResponse status) { + log.debug("Saving distribution status: eventId={}, overallStatus={}", eventId, status.getOverallStatus()); + distributionStatuses.put(eventId, status); + } + + /** + * 배포 상태 조회 + * + * @param eventId 이벤트 ID + * @return 배포 상태 (없으면 Optional.empty()) + */ + public Optional findByEventId(String eventId) { + log.debug("Finding distribution status: eventId={}", eventId); + return Optional.ofNullable(distributionStatuses.get(eventId)); + } + + /** + * 배포 상태 삭제 + * + * @param eventId 이벤트 ID + */ + public void delete(String eventId) { + log.debug("Deleting distribution status: eventId={}", eventId); + distributionStatuses.remove(eventId); + } + + /** + * 모든 배포 상태 삭제 (테스트용) + */ + public void deleteAll() { + log.debug("Deleting all distribution statuses"); + distributionStatuses.clear(); + } +} diff --git a/distribution-service/src/main/java/com/kt/distribution/service/DistributionService.java b/distribution-service/src/main/java/com/kt/distribution/service/DistributionService.java new file mode 100644 index 0000000..dac436f --- /dev/null +++ b/distribution-service/src/main/java/com/kt/distribution/service/DistributionService.java @@ -0,0 +1,261 @@ +package com.kt.distribution.service; + +import com.kt.distribution.adapter.ChannelAdapter; +import com.kt.distribution.dto.*; +import com.kt.distribution.event.DistributionCompletedEvent; +import com.kt.distribution.repository.DistributionStatusRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.Collectors; + +/** + * Distribution Service + * 다중 채널 병렬 배포 및 Kafka Event 발행 + * + * @author System Architect + * @since 2025-10-23 + */ +@Slf4j +@Service +public class DistributionService { + + private final List channelAdapters; + private final Optional kafkaEventPublisher; + private final DistributionStatusRepository statusRepository; + + @Autowired + public DistributionService(List channelAdapters, + Optional kafkaEventPublisher, + DistributionStatusRepository statusRepository) { + this.channelAdapters = channelAdapters; + this.kafkaEventPublisher = kafkaEventPublisher; + this.statusRepository = statusRepository; + } + + // 병렬 실행을 위한 ExecutorService (채널별 스레드 풀) + private final ExecutorService executorService = Executors.newFixedThreadPool(10); + + /** + * 다중 채널 병렬 배포 + * + * @param request DistributionRequest + * @return DistributionResponse + */ + public DistributionResponse distribute(DistributionRequest request) { + LocalDateTime startedAt = LocalDateTime.now(); + long startTime = System.currentTimeMillis(); + + log.info("Starting multi-channel distribution: eventId={}, channels={}", + request.getEventId(), request.getChannels()); + + // 배포 시작 상태 저장 (IN_PROGRESS) + saveInProgressStatus(request.getEventId(), request.getChannels(), startedAt); + + // 채널 어댑터 매핑 (타입별) + Map adapterMap = channelAdapters.stream() + .collect(Collectors.toMap( + adapter -> adapter.getChannelType().name(), + adapter -> adapter + )); + + // 병렬 배포 실행 + List> futures = request.getChannels().stream() + .map(channelType -> { + ChannelAdapter adapter = adapterMap.get(channelType.name()); + if (adapter == null) { + log.warn("No adapter found for channel: {}", channelType); + return CompletableFuture.completedFuture( + ChannelDistributionResult.builder() + .channel(channelType) + .success(false) + .errorMessage("Adapter not found") + .build() + ); + } + + // 비동기 실행 + return CompletableFuture.supplyAsync( + () -> adapter.distribute(request), + executorService + ); + }) + .collect(Collectors.toList()); + + // 모든 배포 완료 대기 + CompletableFuture allOf = CompletableFuture.allOf( + futures.toArray(new CompletableFuture[0]) + ); + + allOf.join(); // 블로킹 대기 (최대 1분 목표) + + // 결과 수집 + List results = futures.stream() + .map(CompletableFuture::join) + .collect(Collectors.toList()); + + long totalExecutionTime = System.currentTimeMillis() - startTime; + LocalDateTime completedAt = LocalDateTime.now(); + + // 성공/실패 카운트 + long successCount = results.stream().filter(ChannelDistributionResult::isSuccess).count(); + long failureCount = results.size() - successCount; + + log.info("Multi-channel distribution completed: eventId={}, successCount={}, failureCount={}, totalTime={}ms", + request.getEventId(), successCount, failureCount, totalExecutionTime); + + // 배포 완료 상태 저장 (COMPLETED/PARTIAL_FAILURE/FAILED) + saveCompletedStatus(request.getEventId(), results, startedAt, completedAt, successCount, failureCount); + + // Kafka Event 발행 + publishDistributionCompletedEvent(request.getEventId(), results); + + // 응답 생성 + return DistributionResponse.builder() + .eventId(request.getEventId()) + .success(successCount > 0) // 1개 이상 성공하면 성공으로 간주 + .channelResults(results) + .successCount((int) successCount) + .failureCount((int) failureCount) + .completedAt(completedAt) + .totalExecutionTimeMs(totalExecutionTime) + .message(String.format("Distribution completed: %d succeeded, %d failed", + successCount, failureCount)) + .build(); + } + + /** + * 배포 상태 조회 + * + * @param eventId 이벤트 ID + * @return 배포 상태 + */ + public DistributionStatusResponse getDistributionStatus(String eventId) { + return statusRepository.findByEventId(eventId) + .orElse(DistributionStatusResponse.builder() + .eventId(eventId) + .overallStatus("NOT_FOUND") + .channels(List.of()) + .build()); + } + + /** + * 배포 시작 상태 저장 (IN_PROGRESS) + * + * @param eventId 이벤트 ID + * @param channels 배포 채널 목록 + * @param startedAt 시작 시각 + */ + private void saveInProgressStatus(String eventId, List channels, LocalDateTime startedAt) { + List channelStatuses = channels.stream() + .map(channelType -> ChannelStatus.builder() + .channel(channelType) + .status("PENDING") + .build()) + .collect(Collectors.toList()); + + DistributionStatusResponse status = DistributionStatusResponse.builder() + .eventId(eventId) + .overallStatus("IN_PROGRESS") + .startedAt(startedAt) + .channels(channelStatuses) + .build(); + + statusRepository.save(eventId, status); + } + + /** + * 배포 완료 상태 저장 + * + * @param eventId 이벤트 ID + * @param results 배포 결과 + * @param startedAt 시작 시각 + * @param completedAt 완료 시각 + * @param successCount 성공 개수 + * @param failureCount 실패 개수 + */ + private void saveCompletedStatus(String eventId, List results, + LocalDateTime startedAt, LocalDateTime completedAt, + long successCount, long failureCount) { + // 전체 상태 결정 + String overallStatus; + if (successCount == 0) { + overallStatus = "FAILED"; + } else if (failureCount == 0) { + overallStatus = "COMPLETED"; + } else { + overallStatus = "PARTIAL_FAILURE"; + } + + // ChannelDistributionResult → ChannelStatus 변환 + List channelStatuses = results.stream() + .map(this::convertToChannelStatus) + .collect(Collectors.toList()); + + DistributionStatusResponse status = DistributionStatusResponse.builder() + .eventId(eventId) + .overallStatus(overallStatus) + .startedAt(startedAt) + .completedAt(completedAt) + .channels(channelStatuses) + .build(); + + statusRepository.save(eventId, status); + } + + /** + * ChannelDistributionResult를 ChannelStatus로 변환 + * + * @param result 배포 결과 + * @return 채널 상태 + */ + private ChannelStatus convertToChannelStatus(ChannelDistributionResult result) { + return ChannelStatus.builder() + .channel(result.getChannel()) + .status(result.isSuccess() ? "COMPLETED" : "FAILED") + .distributionId(result.getDistributionId()) + .estimatedViews(result.getEstimatedReach()) + .completedAt(LocalDateTime.now()) + .errorMessage(result.getErrorMessage()) + .build(); + } + + /** + * DistributionCompleted 이벤트 발행 + * + * @param eventId 이벤트 ID + * @param results 채널별 배포 결과 + */ + private void publishDistributionCompletedEvent(String eventId, List results) { + if (kafkaEventPublisher.isEmpty()) { + log.warn("KafkaEventPublisher not available - skipping event publishing"); + return; + } + + List distributedChannels = results.stream() + .filter(ChannelDistributionResult::isSuccess) + .map(result -> result.getChannel().name()) + .collect(Collectors.toList()); + + long successCount = results.stream().filter(ChannelDistributionResult::isSuccess).count(); + long failureCount = results.size() - successCount; + + DistributionCompletedEvent event = DistributionCompletedEvent.builder() + .eventId(eventId) + .distributedChannels(distributedChannels) + .completedAt(LocalDateTime.now()) + .successCount((int) successCount) + .failureCount((int) failureCount) + .build(); + + kafkaEventPublisher.get().publishDistributionCompleted(event); + } +} diff --git a/distribution-service/src/main/java/com/kt/distribution/service/KafkaEventPublisher.java b/distribution-service/src/main/java/com/kt/distribution/service/KafkaEventPublisher.java new file mode 100644 index 0000000..5e90b3c --- /dev/null +++ b/distribution-service/src/main/java/com/kt/distribution/service/KafkaEventPublisher.java @@ -0,0 +1,62 @@ +package com.kt.distribution.service; + +import com.kt.distribution.event.DistributionCompletedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.SendResult; +import org.springframework.stereotype.Service; + +import java.util.concurrent.CompletableFuture; + +/** + * Kafka Event Publisher + * DistributionCompleted 이벤트를 Kafka로 발행 + * + * @author System Architect + * @since 2025-10-23 + */ +@Slf4j +@Service +@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = true) +@RequiredArgsConstructor +public class KafkaEventPublisher { + + private final KafkaTemplate kafkaTemplate; + + @Value("${kafka.topics.distribution-completed}") + private String distributionCompletedTopic; + + /** + * 배포 완료 이벤트 발행 + * + * @param event DistributionCompletedEvent + */ + public void publishDistributionCompleted(DistributionCompletedEvent event) { + try { + log.info("Publishing DistributionCompletedEvent: eventId={}, successCount={}, failureCount={}", + event.getEventId(), event.getSuccessCount(), event.getFailureCount()); + + CompletableFuture> future = + kafkaTemplate.send(distributionCompletedTopic, event.getEventId(), event); + + future.whenComplete((result, ex) -> { + if (ex == null) { + log.info("DistributionCompletedEvent published successfully: topic={}, partition={}, offset={}", + distributionCompletedTopic, + result.getRecordMetadata().partition(), + result.getRecordMetadata().offset()); + } else { + log.error("Failed to publish DistributionCompletedEvent: eventId={}, error={}", + event.getEventId(), ex.getMessage(), ex); + } + }); + + } catch (Exception e) { + log.error("Error publishing DistributionCompletedEvent: eventId={}, error={}", + event.getEventId(), e.getMessage(), e); + } + } +} diff --git a/distribution-service/src/main/resources/application.yml b/distribution-service/src/main/resources/application.yml new file mode 100644 index 0000000..5013aa7 --- /dev/null +++ b/distribution-service/src/main/resources/application.yml @@ -0,0 +1,102 @@ +server: + port: 8085 + +spring: + application: + name: distribution-service + + # Disable auto-configuration (No database required) + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration + - org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration + - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration + - org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration + - org.springframework.boot.actuate.autoconfigure.security.servlet.ManagementWebSecurityAutoConfiguration + + kafka: + enabled: ${KAFKA_ENABLED:true} + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:4.230.50.63:9092} + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + properties: + spring.json.type.mapping: distributionCompleted:com.kt.distribution.event.DistributionCompletedEvent + consumer: + group-id: ${KAFKA_CONSUMER_GROUP:distribution-service} + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer + properties: + spring.json.trusted.packages: '*' + +# Kafka Topics +kafka: + topics: + distribution-completed: distribution-completed + +# Resilience4j Configuration +resilience4j: + circuitbreaker: + instances: + channelApi: + failure-rate-threshold: 50 + slow-call-rate-threshold: 50 + slow-call-duration-threshold: 5000ms + wait-duration-in-open-state: 30s + permitted-number-of-calls-in-half-open-state: 3 + sliding-window-type: COUNT_BASED + sliding-window-size: 10 + minimum-number-of-calls: 5 + + retry: + instances: + channelApi: + max-attempts: 3 + wait-duration: 1s + exponential-backoff-multiplier: 2 + retry-exceptions: + - java.net.SocketTimeoutException + - java.net.ConnectException + - org.springframework.web.client.ResourceAccessException + + bulkhead: + instances: + channelApi: + max-concurrent-calls: 10 + max-wait-duration: 0ms + +# External Channel APIs (Mock URLs) +channel: + apis: + uridongnetv: + url: ${URIDONGNETV_API_URL:http://localhost:9001/api/uridongnetv} + timeout: 10000 + ringobiz: + url: ${RINGOBIZ_API_URL:http://localhost:9002/api/ringobiz} + timeout: 10000 + ginitv: + url: ${GINITV_API_URL:http://localhost:9003/api/ginitv} + timeout: 10000 + instagram: + url: ${INSTAGRAM_API_URL:http://localhost:9004/api/instagram} + timeout: 10000 + naver: + url: ${NAVER_API_URL:http://localhost:9005/api/naver} + timeout: 10000 + kakao: + url: ${KAKAO_API_URL:http://localhost:9006/api/kakao} + timeout: 10000 + +# Logging +logging: + file: + name: ${LOG_FILE:logs/distribution-service.log} + logback: + rollingpolicy: + max-file-size: 10MB + max-history: 7 + total-size-cap: 100MB + level: + com.kt.distribution: DEBUG + org.springframework.kafka: INFO + io.github.resilience4j: DEBUG diff --git a/distribution-service/src/main/resources/mock-events.json b/distribution-service/src/main/resources/mock-events.json new file mode 100644 index 0000000..8e086a8 --- /dev/null +++ b/distribution-service/src/main/resources/mock-events.json @@ -0,0 +1,134 @@ +[ + { + "eventId": "evt-test-001", + "title": "봄맞이 삼겹살 50% 할인 이벤트", + "description": "3월 한정 특별 이벤트! 삼겹살 1인분 무료 증정", + "imageUrl": "https://cdn.example.com/event-image-001.jpg", + "channels": ["URIDONGNETV", "INSTAGRAM", "KAKAO", "NAVER"], + "channelSettings": { + "URIDONGNETV": { + "radius": "1km", + "timeSlot": "evening" + } + } + }, + { + "eventId": "evt-test-002", + "title": "신규 고객 환영! 치킨 3,000원 할인", + "description": "처음 방문하시는 고객님께 특별 할인 쿠폰 제공. 기간 내 사용 가능", + "imageUrl": "https://cdn.example.com/event-image-002.jpg", + "channels": ["INSTAGRAM", "KAKAO", "NAVER"], + "channelSettings": { + "INSTAGRAM": { + "hashtags": ["치킨", "할인", "신규고객"] + } + } + }, + { + "eventId": "evt-test-003", + "title": "주말 특가! 피자 1+1 이벤트", + "description": "토요일, 일요일 한정! 모든 피자 1+1 행사. 배달 주문 가능", + "imageUrl": "https://cdn.example.com/event-image-003.jpg", + "channels": ["URIDONGNETV", "KAKAO"], + "channelSettings": { + "URIDONGNETV": { + "radius": "2km", + "timeSlot": "lunch" + }, + "KAKAO": { + "targetAge": "20-40", + "sendTime": "11:00" + } + } + }, + { + "eventId": "evt-test-004", + "title": "여름 시즌 냉면 페스티벌", + "description": "시원한 냉면과 함께하는 여름! 전 메뉴 20% 할인", + "imageUrl": "https://cdn.example.com/event-image-004.jpg", + "channels": ["URIDONGNETV", "INSTAGRAM", "NAVER"], + "channelSettings": { + "URIDONGNETV": { + "radius": "500m", + "timeSlot": "afternoon" + } + } + }, + { + "eventId": "evt-test-005", + "title": "리뷰 작성 시 음료 무료!", + "description": "네이버 리뷰 작성하고 아메리카노 1잔 무료로 받아가세요", + "imageUrl": "https://cdn.example.com/event-image-005.jpg", + "channels": ["NAVER", "INSTAGRAM"], + "channelSettings": { + "NAVER": { + "reviewRequired": true + } + } + }, + { + "eventId": "evt-test-006", + "title": "생일 축하! 케이크 30% 할인", + "description": "생일 당일 방문 시 신분증 제시하면 케이크 30% 할인. 예약 필수", + "imageUrl": "https://cdn.example.com/event-image-006.jpg", + "channels": ["KAKAO", "INSTAGRAM"], + "channelSettings": { + "KAKAO": { + "reservationRequired": true, + "targetAge": "all" + } + } + }, + { + "eventId": "evt-test-007", + "title": "점심 시간 특가! 런치 세트 8,000원", + "description": "평일 11:30~14:00 런치 세트 메뉴 8,000원. 커피 포함", + "imageUrl": "https://cdn.example.com/event-image-007.jpg", + "channels": ["URIDONGNETV", "NAVER"], + "channelSettings": { + "URIDONGNETV": { + "radius": "1.5km", + "timeSlot": "lunch" + } + } + }, + { + "eventId": "evt-test-008", + "title": "가족 나들이 패키지 20% 할인", + "description": "4인 가족 세트 메뉴 20% 할인! 키즈 메뉴 포함", + "imageUrl": "https://cdn.example.com/event-image-008.jpg", + "channels": ["KAKAO", "INSTAGRAM", "NAVER"], + "channelSettings": { + "KAKAO": { + "targetAge": "30-50", + "sendTime": "10:00" + } + } + }, + { + "eventId": "evt-test-009", + "title": "야간 할인! 저녁 9시 이후 전 메뉴 15% OFF", + "description": "저녁 9시 이후 방문 시 모든 메뉴 15% 할인. 포장 가능", + "imageUrl": "https://cdn.example.com/event-image-009.jpg", + "channels": ["URIDONGNETV", "INSTAGRAM"], + "channelSettings": { + "URIDONGNETV": { + "radius": "1km", + "timeSlot": "evening" + } + } + }, + { + "eventId": "evt-test-010", + "title": "SNS 팔로우 이벤트! 디저트 무료", + "description": "인스타그램 팔로우 후 인증하면 디저트 1개 무료 제공", + "imageUrl": "https://cdn.example.com/event-image-010.jpg", + "channels": ["INSTAGRAM", "KAKAO"], + "channelSettings": { + "INSTAGRAM": { + "followRequired": true, + "hashtags": ["팔로우이벤트", "디저트무료", "맛집"] + } + } + } +] diff --git a/distribution-service/test-distribution.sh b/distribution-service/test-distribution.sh new file mode 100644 index 0000000..90d5f13 --- /dev/null +++ b/distribution-service/test-distribution.sh @@ -0,0 +1,34 @@ +#!/bin/bash + +# Distribution Service API 테스트 스크립트 + +echo "=== Distribution Service API Test ===" +echo "" + +# 1. Health Check (추후 추가 예정) +# echo "1. Health Check..." +# curl -X GET http://localhost:8085/actuator/health +# echo "" + +# 2. 다중 채널 배포 테스트 +echo "1. Testing Multi-Channel Distribution..." +echo "" + +curl -X POST http://localhost:8085/api/distribution/distribute \ + -H "Content-Type: application/json" \ + -d '{ + "eventId": "evt-test-001", + "title": "봄맞이 삼겹살 50% 할인 이벤트", + "description": "3월 한정 특별 이벤트! 삼겹살 1인분 무료 증정", + "imageUrl": "https://cdn.example.com/event-image.jpg", + "channels": ["URIDONGNETV", "INSTAGRAM", "KAKAO", "NAVER"], + "channelSettings": { + "URIDONGNETV": { + "radius": "1km", + "timeSlot": "evening" + } + } + }' | jq '.' + +echo "" +echo "=== Test Completed ===" diff --git a/tools/run-intellij-service-profile.py b/tools/run-intellij-service-profile.py new file mode 100644 index 0000000..2278686 --- /dev/null +++ b/tools/run-intellij-service-profile.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Tripgen Service Runner Script +Reads execution profiles from {service-name}/.run/{service-name}.run.xml and runs services accordingly. + +Usage: + python run-config.py + +Examples: + python run-config.py user-service + python run-config.py location-service + python run-config.py trip-service + python run-config.py ai-service +""" + +import os +import sys +import subprocess +import xml.etree.ElementTree as ET +from pathlib import Path +import argparse + + +def get_project_root(): + """Find project root directory""" + current_dir = Path(__file__).parent.absolute() + while current_dir.parent != current_dir: + if (current_dir / 'gradlew').exists() or (current_dir / 'gradlew.bat').exists(): + return current_dir + current_dir = current_dir.parent + + # If gradlew not found, assume parent directory of develop as project root + return Path(__file__).parent.parent.absolute() + + +def parse_run_configurations(project_root, service_name=None): + """Parse run configuration files from .run directories""" + configurations = {} + + if service_name: + # Parse specific service configuration + run_config_path = project_root / service_name / '.run' / f'{service_name}.run.xml' + if run_config_path.exists(): + config = parse_single_run_config(run_config_path, service_name) + if config: + configurations[service_name] = config + else: + print(f"[ERROR] Cannot find run configuration: {run_config_path}") + else: + # Find all service directories + service_dirs = ['user-service', 'location-service', 'trip-service', 'ai-service'] + for service in service_dirs: + run_config_path = project_root / service / '.run' / f'{service}.run.xml' + if run_config_path.exists(): + config = parse_single_run_config(run_config_path, service) + if config: + configurations[service] = config + + return configurations + + +def parse_single_run_config(config_path, service_name): + """Parse a single run configuration file""" + try: + tree = ET.parse(config_path) + root = tree.getroot() + + # Find configuration element + config = root.find('.//configuration[@type="GradleRunConfiguration"]') + if config is None: + print(f"[WARNING] No Gradle configuration found in {config_path}") + return None + + # Extract environment variables + env_vars = {} + env_option = config.find('.//option[@name="env"]') + if env_option is not None: + env_map = env_option.find('map') + if env_map is not None: + for entry in env_map.findall('entry'): + key = entry.get('key') + value = entry.get('value') + if key and value: + env_vars[key] = value + + # Extract task names + task_names = [] + task_names_option = config.find('.//option[@name="taskNames"]') + if task_names_option is not None: + task_list = task_names_option.find('list') + if task_list is not None: + for option in task_list.findall('option'): + value = option.get('value') + if value: + task_names.append(value) + + if env_vars or task_names: + return { + 'env_vars': env_vars, + 'task_names': task_names, + 'config_path': str(config_path) + } + + return None + + except ET.ParseError as e: + print(f"[ERROR] XML parsing error in {config_path}: {e}") + return None + except Exception as e: + print(f"[ERROR] Error reading {config_path}: {e}") + return None + + +def get_gradle_command(project_root): + """Return appropriate Gradle command for OS""" + if os.name == 'nt': # Windows + gradle_bat = project_root / 'gradlew.bat' + if gradle_bat.exists(): + return str(gradle_bat) + return 'gradle.bat' + else: # Unix-like (Linux, macOS) + gradle_sh = project_root / 'gradlew' + if gradle_sh.exists(): + return str(gradle_sh) + return 'gradle' + + +def run_service(service_name, config, project_root): + """Run service""" + print(f"[START] Starting {service_name} service...") + + # Set environment variables + env = os.environ.copy() + for key, value in config['env_vars'].items(): + env[key] = value + print(f" [ENV] {key}={value}") + + # Prepare Gradle command + gradle_cmd = get_gradle_command(project_root) + + # Execute tasks + for task_name in config['task_names']: + print(f"\n[RUN] Executing: {task_name}") + + cmd = [gradle_cmd, task_name] + + try: + # Execute from project root directory + process = subprocess.Popen( + cmd, + cwd=project_root, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + universal_newlines=True, + bufsize=1, + encoding='utf-8', + errors='replace' + ) + + print(f"[CMD] Command: {' '.join(cmd)}") + print(f"[DIR] Working directory: {project_root}") + print("=" * 50) + + # Real-time output + for line in process.stdout: + print(line.rstrip()) + + # Wait for process completion + process.wait() + + if process.returncode == 0: + print(f"\n[SUCCESS] {task_name} execution completed") + else: + print(f"\n[FAILED] {task_name} execution failed (exit code: {process.returncode})") + return False + + except KeyboardInterrupt: + print(f"\n[STOP] Interrupted by user") + process.terminate() + return False + except Exception as e: + print(f"\n[ERROR] Execution error: {e}") + return False + + return True + + +def list_available_services(configurations): + """List available services""" + print("[LIST] Available services:") + print("=" * 40) + + for service_name, config in configurations.items(): + if config['task_names']: + print(f" [SERVICE] {service_name}") + if 'config_path' in config: + print(f" +-- Config: {config['config_path']}") + for task in config['task_names']: + print(f" +-- Task: {task}") + print(f" +-- {len(config['env_vars'])} environment variables") + print() + + +def main(): + """Main function""" + parser = argparse.ArgumentParser( + description='Tripgen Service Runner Script', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python run-config.py user-service + python run-config.py location-service + python run-config.py trip-service + python run-config.py ai-service + python run-config.py --list + """ + ) + + parser.add_argument( + 'service_name', + nargs='?', + help='Service name to run' + ) + + parser.add_argument( + '--list', '-l', + action='store_true', + help='List available services' + ) + + args = parser.parse_args() + + # Find project root + project_root = get_project_root() + print(f"[INFO] Project root: {project_root}") + + # Parse run configurations + print("[INFO] Reading run configuration files...") + configurations = parse_run_configurations(project_root) + + if not configurations: + print("[ERROR] No execution configurations found") + return 1 + + print(f"[INFO] Found {len(configurations)} execution configurations") + + # List services request + if args.list: + list_available_services(configurations) + return 0 + + # If service name not provided + if not args.service_name: + print("\n[ERROR] Please provide service name") + list_available_services(configurations) + print("Usage: python run-config.py ") + return 1 + + # Find service + service_name = args.service_name + + # Try to parse specific service configuration if not found + if service_name not in configurations: + print(f"[INFO] Trying to find configuration for '{service_name}'...") + configurations = parse_run_configurations(project_root, service_name) + + if service_name not in configurations: + print(f"[ERROR] Cannot find '{service_name}' service") + list_available_services(configurations) + return 1 + + config = configurations[service_name] + + if not config['task_names']: + print(f"[ERROR] No executable tasks found for '{service_name}' service") + return 1 + + # Execute service + print(f"\n[TARGET] Starting '{service_name}' service execution") + print("=" * 50) + + success = run_service(service_name, config, project_root) + + if success: + print(f"\n[COMPLETE] '{service_name}' service started successfully!") + return 0 + else: + print(f"\n[FAILED] Failed to start '{service_name}' service") + return 1 + + +if __name__ == '__main__': + try: + exit_code = main() + sys.exit(exit_code) + except KeyboardInterrupt: + print("\n[STOP] Interrupted by user") + sys.exit(1) + except Exception as e: + print(f"\n[ERROR] Unexpected error occurred: {e}") + sys.exit(1) \ No newline at end of file