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