diff --git a/.run/AiServiceApplication.run.xml b/.run/AiServiceApplication.run.xml
new file mode 100644
index 0000000..d03ed94
--- /dev/null
+++ b/.run/AiServiceApplication.run.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.run/AnalyticsServiceApplication.run.xml b/.run/AnalyticsServiceApplication.run.xml
new file mode 100644
index 0000000..bf57744
--- /dev/null
+++ b/.run/AnalyticsServiceApplication.run.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.run/ContentServiceApplication.run.xml b/.run/ContentServiceApplication.run.xml
new file mode 100644
index 0000000..85d4235
--- /dev/null
+++ b/.run/ContentServiceApplication.run.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.run/EventServiceApplication.run.xml b/.run/EventServiceApplication.run.xml
new file mode 100644
index 0000000..46ef667
--- /dev/null
+++ b/.run/EventServiceApplication.run.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.run/ParticipationServiceApplication.run.xml b/.run/ParticipationServiceApplication.run.xml
new file mode 100644
index 0000000..20b0261
--- /dev/null
+++ b/.run/ParticipationServiceApplication.run.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.run/UserServiceApplication.run.xml b/.run/UserServiceApplication.run.xml
new file mode 100644
index 0000000..23d83db
--- /dev/null
+++ b/.run/UserServiceApplication.run.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/design/backend/api/distribution-service-api.yaml b/design/backend/api/distribution-service-api.yaml
index 938b3a8..d47ecb3 100644
--- a/design/backend/api/distribution-service-api.yaml
+++ b/design/backend/api/distribution-service-api.yaml
@@ -11,10 +11,10 @@ info:
- Retry 패턴 및 Fallback 처리
## 배포 채널
- - **우리동네TV**: 영상 콘텐츠 업로드
- - **링고비즈**: 연결음 업데이트
- - **지니TV**: 광고 등록
- - **SNS**: Instagram, Naver Blog, Kakao Channel
+ - **우리동네TV** (URIDONGNETV): 영상 콘텐츠 업로드
+ - **링고비즈** (RINGOBIZ): 연결음 업데이트
+ - **지니TV** (GINITV): 광고 등록
+ - **SNS**: Instagram (INSTAGRAM), Naver Blog (NAVER), Kakao Channel (KAKAO)
## Resilience 패턴
- Circuit Breaker: 채널별 독립적 장애 격리
@@ -79,23 +79,21 @@ paths:
summary: 다중 채널 배포 예시
value:
eventId: "evt-12345"
+ title: "신규 고객 환영 이벤트"
+ description: "신규 고객님을 위한 특별 할인 이벤트"
+ imageUrl: "https://cdn.example.com/images/event-main.jpg"
channels:
- - type: "WOORIDONGNE_TV"
- config:
- radius: "1km"
- timeSlots:
- - "weekday_evening"
- - "weekend_lunch"
- - type: "INSTAGRAM"
- config:
- scheduledTime: "2025-11-01T10:00:00Z"
- - type: "NAVER_BLOG"
- config:
- scheduledTime: "2025-11-01T10:30:00Z"
- contentUrls:
- instagram: "https://cdn.example.com/images/event-instagram.jpg"
- naverBlog: "https://cdn.example.com/images/event-naver.jpg"
- kakaoChannel: "https://cdn.example.com/images/event-kakao.jpg"
+ - "URIDONGNETV"
+ - "INSTAGRAM"
+ - "NAVER"
+ channelSettings:
+ URIDONGNETV:
+ radius: "1km"
+ timeSlot: "evening"
+ INSTAGRAM:
+ scheduledTime: "2025-11-01T10:00:00"
+ NAVER:
+ scheduledTime: "2025-11-01T10:30:00"
responses:
'200':
description: 배포 완료
@@ -107,25 +105,29 @@ paths:
allSuccess:
summary: 모든 채널 배포 성공
value:
- distributionId: "dist-12345"
eventId: "evt-12345"
- status: "COMPLETED"
- completedAt: "2025-11-01T09:00:00Z"
- results:
- - channel: "WOORIDONGNE_TV"
- status: "SUCCESS"
+ success: true
+ channelResults:
+ - channel: "URIDONGNETV"
+ success: true
distributionId: "wtv-uuid-12345"
- estimatedViews: 1000
- message: "배포 완료"
+ estimatedReach: 1000
+ executionTimeMs: 234
- channel: "INSTAGRAM"
- status: "SUCCESS"
- postUrl: "https://instagram.com/p/generated-post-id"
- postId: "ig-post-12345"
- message: "게시 완료"
- - channel: "NAVER_BLOG"
- status: "SUCCESS"
- postUrl: "https://blog.naver.com/store123/generated-post"
- message: "게시 완료"
+ success: true
+ distributionId: "ig-uuid-12345"
+ estimatedReach: 500
+ executionTimeMs: 456
+ - channel: "NAVER"
+ success: true
+ distributionId: "naver-uuid-12345"
+ estimatedReach: 300
+ executionTimeMs: 123
+ successCount: 3
+ failureCount: 0
+ completedAt: "2025-11-01T09:00:00"
+ totalExecutionTimeMs: 1234
+ message: "배포가 성공적으로 완료되었습니다"
'400':
description: 잘못된 요청
content:
@@ -217,67 +219,77 @@ paths:
value:
eventId: "evt-12345"
overallStatus: "COMPLETED"
- completedAt: "2025-11-01T09:00:00Z"
+ startedAt: "2025-11-01T08:58:00"
+ completedAt: "2025-11-01T09:00:00"
channels:
- - channel: "WOORIDONGNE_TV"
+ - channel: "URIDONGNETV"
status: "COMPLETED"
distributionId: "wtv-uuid-12345"
estimatedViews: 1500
- completedAt: "2025-11-01T09:00:00Z"
- - channel: "RINGO_BIZ"
+ completedAt: "2025-11-01T09:00:00"
+ - channel: "RINGOBIZ"
status: "COMPLETED"
- updateTimestamp: "2025-11-01T09:00:00Z"
- - channel: "GENIE_TV"
+ updateTimestamp: "2025-11-01T09:00:00"
+ completedAt: "2025-11-01T09:00:00"
+ - channel: "GINITV"
status: "COMPLETED"
adId: "gtv-uuid-12345"
impressionSchedule:
- "2025-11-01 18:00-20:00"
- "2025-11-02 12:00-14:00"
+ completedAt: "2025-11-01T09:00:00"
- channel: "INSTAGRAM"
status: "COMPLETED"
postUrl: "https://instagram.com/p/generated-post-id"
postId: "ig-post-12345"
- - channel: "NAVER_BLOG"
+ completedAt: "2025-11-01T09:00:00"
+ - channel: "NAVER"
status: "COMPLETED"
postUrl: "https://blog.naver.com/store123/generated-post"
- - channel: "KAKAO_CHANNEL"
+ completedAt: "2025-11-01T09:00:00"
+ - channel: "KAKAO"
status: "COMPLETED"
messageId: "kakao-msg-12345"
+ completedAt: "2025-11-01T09:00:00"
inProgress:
summary: 배포 진행중 상태
value:
eventId: "evt-12345"
overallStatus: "IN_PROGRESS"
- startedAt: "2025-11-01T08:58:00Z"
+ startedAt: "2025-11-01T08:58:00"
channels:
- - channel: "WOORIDONGNE_TV"
+ - channel: "URIDONGNETV"
status: "COMPLETED"
distributionId: "wtv-uuid-12345"
estimatedViews: 1500
+ completedAt: "2025-11-01T08:59:00"
- channel: "INSTAGRAM"
status: "IN_PROGRESS"
progress: 50
- - channel: "NAVER_BLOG"
+ - channel: "NAVER"
status: "PENDING"
partialFailure:
summary: 일부 채널 실패 상태
value:
eventId: "evt-12345"
overallStatus: "PARTIAL_FAILURE"
- completedAt: "2025-11-01T09:00:00Z"
+ startedAt: "2025-11-01T08:58:00"
+ completedAt: "2025-11-01T09:00:00"
channels:
- - channel: "WOORIDONGNE_TV"
+ - channel: "URIDONGNETV"
status: "COMPLETED"
distributionId: "wtv-uuid-12345"
estimatedViews: 1500
+ completedAt: "2025-11-01T08:59:00"
- channel: "INSTAGRAM"
status: "FAILED"
errorMessage: "Instagram API 타임아웃"
retries: 3
- lastRetryAt: "2025-11-01T08:59:30Z"
- - channel: "NAVER_BLOG"
+ lastRetryAt: "2025-11-01T08:59:30"
+ - channel: "NAVER"
status: "COMPLETED"
postUrl: "https://blog.naver.com/store123/generated-post"
+ completedAt: "2025-11-01T09:00:00"
'404':
description: 배포 이력을 찾을 수 없음
content:
@@ -305,196 +317,133 @@ components:
required:
- eventId
- channels
- - contentUrls
properties:
eventId:
type: string
description: 이벤트 ID
example: "evt-12345"
+ title:
+ type: string
+ description: 이벤트 제목
+ example: "신규 고객 환영 이벤트"
+ description:
+ type: string
+ description: 이벤트 설명
+ example: "신규 고객님을 위한 특별 할인 이벤트"
+ imageUrl:
+ type: string
+ description: 이미지 URL (CDN)
+ example: "https://cdn.example.com/images/event-main.jpg"
channels:
type: array
description: 배포할 채널 목록
minItems: 1
items:
- $ref: '#/components/schemas/ChannelConfig'
- contentUrls:
+ type: string
+ enum:
+ - URIDONGNETV
+ - RINGOBIZ
+ - GINITV
+ - INSTAGRAM
+ - NAVER
+ - KAKAO
+ example: ["URIDONGNETV", "INSTAGRAM", "NAVER"]
+ channelSettings:
type: object
- description: 플랫폼별 콘텐츠 URL
- properties:
- wooridongneTV:
- type: string
- description: 우리동네TV 영상 URL (15초)
- example: "https://cdn.example.com/videos/event-15s.mp4"
- ringoBiz:
- type: string
- description: 링고비즈 연결음 파일 URL
- example: "https://cdn.example.com/audio/ringtone.mp3"
- genieTV:
- type: string
- description: 지니TV 광고 영상 URL
- example: "https://cdn.example.com/videos/event-ad.mp4"
- instagram:
- type: string
- description: Instagram 이미지 URL (1080x1080)
- example: "https://cdn.example.com/images/event-instagram.jpg"
- naverBlog:
- type: string
- description: Naver Blog 이미지 URL (800x600)
- example: "https://cdn.example.com/images/event-naver.jpg"
- kakaoChannel:
- type: string
- description: Kakao Channel 이미지 URL (800x800)
- example: "https://cdn.example.com/images/event-kakao.jpg"
-
- ChannelConfig:
- type: object
- required:
- - type
- properties:
- type:
- type: string
- description: 채널 타입
- enum:
- - WOORIDONGNE_TV
- - RINGO_BIZ
- - GENIE_TV
- - INSTAGRAM
- - NAVER_BLOG
- - KAKAO_CHANNEL
- example: "INSTAGRAM"
- config:
- type: object
- description: 채널별 설정 (채널에 따라 다름)
- additionalProperties: true
+ description: 채널별 추가 설정 (Optional)
+ additionalProperties:
+ type: object
+ additionalProperties: true
example:
- scheduledTime: "2025-11-01T10:00:00Z"
- caption: "이벤트 안내"
- hashtags:
- - "이벤트"
- - "할인"
+ URIDONGNETV:
+ radius: "1km"
+ timeSlot: "evening"
+ INSTAGRAM:
+ scheduledTime: "2025-11-01T10:00:00"
DistributionResponse:
type: object
required:
- - distributionId
- eventId
- - status
- - results
+ - success
+ - channelResults
+ - successCount
+ - failureCount
properties:
- distributionId:
- type: string
- description: 배포 ID
- example: "dist-12345"
eventId:
type: string
description: 이벤트 ID
example: "evt-12345"
- status:
- type: string
- description: 전체 배포 상태
- enum:
- - PENDING
- - IN_PROGRESS
- - COMPLETED
- - PARTIAL_FAILURE
- - FAILED
- example: "COMPLETED"
- startedAt:
- type: string
- format: date-time
- description: 배포 시작 시각
- example: "2025-11-01T08:59:00Z"
+ success:
+ type: boolean
+ description: 배포 성공 여부 (모든 채널 또는 일부 채널 성공)
+ example: true
+ channelResults:
+ type: array
+ description: 채널별 배포 결과
+ items:
+ $ref: '#/components/schemas/ChannelDistributionResult'
+ successCount:
+ type: integer
+ description: 성공한 채널 수
+ example: 3
+ failureCount:
+ type: integer
+ description: 실패한 채널 수
+ example: 0
completedAt:
type: string
format: date-time
description: 배포 완료 시각
- example: "2025-11-01T09:00:00Z"
- results:
- type: array
- description: 채널별 배포 결과
- items:
- $ref: '#/components/schemas/ChannelResult'
+ example: "2025-11-01T09:00:00"
+ totalExecutionTimeMs:
+ type: integer
+ format: int64
+ description: 전체 배포 소요 시간 (ms)
+ example: 1234
+ message:
+ type: string
+ description: 메시지
+ example: "배포가 성공적으로 완료되었습니다"
- ChannelResult:
+ ChannelDistributionResult:
type: object
required:
- channel
- - status
+ - success
properties:
channel:
type: string
description: 채널 타입
enum:
- - WOORIDONGNE_TV
- - RINGO_BIZ
- - GENIE_TV
+ - URIDONGNETV
+ - RINGOBIZ
+ - GINITV
- INSTAGRAM
- - NAVER_BLOG
- - KAKAO_CHANNEL
+ - NAVER
+ - KAKAO
example: "INSTAGRAM"
- status:
- type: string
- description: 채널별 배포 상태
- enum:
- - PENDING
- - IN_PROGRESS
- - SUCCESS
- - FAILED
- example: "SUCCESS"
+ success:
+ type: boolean
+ description: 배포 성공 여부
+ example: true
distributionId:
type: string
- description: 채널별 배포 ID (우리동네TV, 지니TV)
- example: "wtv-uuid-12345"
- estimatedViews:
+ description: 배포 ID (성공 시)
+ example: "dist-uuid-12345"
+ estimatedReach:
type: integer
- description: 예상 노출 수 (우리동네TV, 지니TV)
+ description: 예상 노출 수 (성공 시)
example: 1500
- updateTimestamp:
- type: string
- format: date-time
- description: 업데이트 완료 시각 (링고비즈)
- example: "2025-11-01T09:00:00Z"
- adId:
- type: string
- description: 광고 ID (지니TV)
- example: "gtv-uuid-12345"
- impressionSchedule:
- type: array
- description: 노출 스케줄 (지니TV)
- items:
- type: string
- example:
- - "2025-11-01 18:00-20:00"
- - "2025-11-02 12:00-14:00"
- postUrl:
- type: string
- description: 게시물 URL (Instagram, Naver Blog)
- example: "https://instagram.com/p/generated-post-id"
- postId:
- type: string
- description: 게시물 ID (Instagram)
- example: "ig-post-12345"
- messageId:
- type: string
- description: 메시지 ID (Kakao Channel)
- example: "kakao-msg-12345"
- message:
- type: string
- description: 결과 메시지
- example: "배포 완료"
errorMessage:
type: string
- description: 오류 메시지 (실패 시)
+ description: 에러 메시지 (실패 시)
example: "Instagram API 타임아웃"
- retries:
+ executionTimeMs:
type: integer
- description: 재시도 횟수
- example: 0
- lastRetryAt:
- type: string
- format: date-time
- description: 마지막 재시도 시각
- example: "2025-11-01T08:59:30Z"
+ format: int64
+ description: 배포 소요 시간 (ms)
+ example: 234
DistributionStatusResponse:
type: object
@@ -544,12 +493,12 @@ components:
type: string
description: 채널 타입
enum:
- - WOORIDONGNE_TV
- - RINGO_BIZ
- - GENIE_TV
+ - URIDONGNETV
+ - RINGOBIZ
+ - GINITV
- INSTAGRAM
- - NAVER_BLOG
- - KAKAO_CHANNEL
+ - NAVER
+ - KAKAO
example: "INSTAGRAM"
status:
type: string
@@ -569,7 +518,7 @@ components:
distributionId:
type: string
description: 채널별 배포 ID
- example: "wtv-uuid-12345"
+ example: "dist-uuid-12345"
estimatedViews:
type: integer
description: 예상 노출 수
@@ -578,35 +527,35 @@ components:
type: string
format: date-time
description: 업데이트 완료 시각
- example: "2025-11-01T09:00:00Z"
+ example: "2025-11-01T09:00:00"
adId:
type: string
- description: 광고 ID
+ description: 광고 ID (지니TV)
example: "gtv-uuid-12345"
impressionSchedule:
type: array
- description: 노출 스케줄
+ description: 노출 스케줄 (지니TV)
items:
type: string
example:
- "2025-11-01 18:00-20:00"
postUrl:
type: string
- description: 게시물 URL
+ description: 게시물 URL (Instagram, Naver Blog)
example: "https://instagram.com/p/generated-post-id"
postId:
type: string
- description: 게시물 ID
+ description: 게시물 ID (Instagram)
example: "ig-post-12345"
messageId:
type: string
- description: 메시지 ID
+ description: 메시지 ID (Kakao Channel)
example: "kakao-msg-12345"
completedAt:
type: string
format: date-time
description: 완료 시각
- example: "2025-11-01T09:00:00Z"
+ example: "2025-11-01T09:00:00"
errorMessage:
type: string
description: 오류 메시지
@@ -619,7 +568,7 @@ components:
type: string
format: date-time
description: 마지막 재시도 시각
- example: "2025-11-01T08:59:30Z"
+ example: "2025-11-01T08:59:30"
ErrorResponse:
type: object
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
index c17503f..d0825fb 100644
--- a/distribution-service/src/main/java/com/kt/distribution/controller/DistributionController.java
+++ b/distribution-service/src/main/java/com/kt/distribution/controller/DistributionController.java
@@ -19,7 +19,7 @@ import org.springframework.web.bind.annotation.*;
*/
@Slf4j
@RestController
-@RequestMapping("/api/distribution")
+@RequestMapping("/distribution")
@RequiredArgsConstructor
public class DistributionController {
diff --git a/distribution-service/src/main/java/com/kt/distribution/entity/ChannelStatusEntity.java b/distribution-service/src/main/java/com/kt/distribution/entity/ChannelStatusEntity.java
new file mode 100644
index 0000000..05c40d8
--- /dev/null
+++ b/distribution-service/src/main/java/com/kt/distribution/entity/ChannelStatusEntity.java
@@ -0,0 +1,168 @@
+package com.kt.distribution.entity;
+
+import com.kt.distribution.dto.ChannelType;
+import jakarta.persistence.*;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+/**
+ * 채널별 배포 상태 엔티티
+ *
+ * 각 채널의 배포 진행 상태 및 결과 정보를 저장합니다.
+ *
+ * @author Backend Developer
+ * @since 2025-10-24
+ */
+@Entity
+@Table(name = "channel_status", indexes = {
+ @Index(name = "idx_distribution_channel", columnList = "distribution_status_id, channel")
+})
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ChannelStatusEntity {
+
+ /**
+ * 채널 상태 ID (Primary Key)
+ */
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ /**
+ * 배포 상태 (Foreign Key)
+ */
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "distribution_status_id", nullable = false)
+ private DistributionStatus distributionStatus;
+
+ /**
+ * 채널 타입
+ */
+ @Enumerated(EnumType.STRING)
+ @Column(name = "channel", nullable = false, length = 20)
+ private ChannelType channel;
+
+ /**
+ * 채널별 배포 상태
+ * - PENDING: 대기 중
+ * - IN_PROGRESS: 진행 중
+ * - COMPLETED: 완료
+ * - FAILED: 실패
+ */
+ @Column(name = "status", nullable = false, length = 20)
+ private String status;
+
+ /**
+ * 진행률 (0-100, IN_PROGRESS 상태일 때 사용)
+ */
+ @Column(name = "progress")
+ private Integer progress;
+
+ /**
+ * 채널별 배포 ID (우리동네TV, 지니TV 등)
+ */
+ @Column(name = "distribution_id", length = 100)
+ private String distributionId;
+
+ /**
+ * 예상 노출 수 (우리동네TV, 지니TV)
+ */
+ @Column(name = "estimated_views")
+ private Integer estimatedViews;
+
+ /**
+ * 업데이트 완료 시각 (링고비즈)
+ */
+ @Column(name = "update_timestamp")
+ private LocalDateTime updateTimestamp;
+
+ /**
+ * 광고 ID (지니TV)
+ */
+ @Column(name = "ad_id", length = 100)
+ private String adId;
+
+ /**
+ * 노출 스케줄 (지니TV) - JSON 형태로 저장
+ */
+ @Column(name = "impression_schedule", columnDefinition = "TEXT")
+ private String impressionSchedule;
+
+ /**
+ * 게시물 URL (Instagram, Naver Blog)
+ */
+ @Column(name = "post_url", columnDefinition = "TEXT")
+ private String postUrl;
+
+ /**
+ * 게시물 ID (Instagram)
+ */
+ @Column(name = "post_id", length = 100)
+ private String postId;
+
+ /**
+ * 메시지 ID (Kakao Channel)
+ */
+ @Column(name = "message_id", length = 100)
+ private String messageId;
+
+ /**
+ * 완료 시각
+ */
+ @Column(name = "completed_at")
+ private LocalDateTime completedAt;
+
+ /**
+ * 오류 메시지 (실패 시)
+ */
+ @Column(name = "error_message", columnDefinition = "TEXT")
+ private String errorMessage;
+
+ /**
+ * 재시도 횟수
+ */
+ @Column(name = "retries")
+ @Builder.Default
+ private Integer retries = 0;
+
+ /**
+ * 마지막 재시도 시각
+ */
+ @Column(name = "last_retry_at")
+ private LocalDateTime lastRetryAt;
+
+ /**
+ * 생성 시각
+ */
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+
+ /**
+ * 수정 시각
+ */
+ @Column(name = "updated_at")
+ private LocalDateTime updatedAt;
+
+ /**
+ * 생성 시 자동으로 생성 시각 설정
+ */
+ @PrePersist
+ protected void onCreate() {
+ createdAt = LocalDateTime.now();
+ updatedAt = LocalDateTime.now();
+ }
+
+ /**
+ * 수정 시 자동으로 수정 시각 설정
+ */
+ @PreUpdate
+ protected void onUpdate() {
+ updatedAt = LocalDateTime.now();
+ }
+}
diff --git a/distribution-service/src/main/java/com/kt/distribution/entity/DistributionStatus.java b/distribution-service/src/main/java/com/kt/distribution/entity/DistributionStatus.java
new file mode 100644
index 0000000..9b8eb13
--- /dev/null
+++ b/distribution-service/src/main/java/com/kt/distribution/entity/DistributionStatus.java
@@ -0,0 +1,118 @@
+package com.kt.distribution.entity;
+
+import jakarta.persistence.*;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 배포 상태 엔티티
+ *
+ * 이벤트의 전체 배포 상태 정보를 저장합니다.
+ *
+ * @author Backend Developer
+ * @since 2025-10-24
+ */
+@Entity
+@Table(name = "distribution_status", indexes = {
+ @Index(name = "idx_event_id", columnList = "event_id", unique = true)
+})
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class DistributionStatus {
+
+ /**
+ * 배포 상태 ID (Primary Key)
+ */
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ /**
+ * 이벤트 ID (Unique)
+ */
+ @Column(name = "event_id", nullable = false, unique = true, length = 100)
+ private String eventId;
+
+ /**
+ * 전체 배포 상태
+ * - PENDING: 대기 중
+ * - IN_PROGRESS: 진행 중
+ * - COMPLETED: 완료
+ * - PARTIAL_FAILURE: 부분 성공
+ * - FAILED: 실패
+ */
+ @Column(name = "overall_status", nullable = false, length = 20)
+ private String overallStatus;
+
+ /**
+ * 배포 시작 시각
+ */
+ @Column(name = "started_at")
+ private LocalDateTime startedAt;
+
+ /**
+ * 배포 완료 시각
+ */
+ @Column(name = "completed_at")
+ private LocalDateTime completedAt;
+
+ /**
+ * 채널별 배포 상태 목록 (1:N 관계)
+ */
+ @OneToMany(mappedBy = "distributionStatus", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
+ @Builder.Default
+ private List channels = new ArrayList<>();
+
+ /**
+ * 생성 시각
+ */
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+
+ /**
+ * 수정 시각
+ */
+ @Column(name = "updated_at")
+ private LocalDateTime updatedAt;
+
+ /**
+ * 생성 시 자동으로 생성 시각 설정
+ */
+ @PrePersist
+ protected void onCreate() {
+ createdAt = LocalDateTime.now();
+ updatedAt = LocalDateTime.now();
+ }
+
+ /**
+ * 수정 시 자동으로 수정 시각 설정
+ */
+ @PreUpdate
+ protected void onUpdate() {
+ updatedAt = LocalDateTime.now();
+ }
+
+ /**
+ * 채널 상태 추가 헬퍼 메서드
+ */
+ public void addChannelStatus(ChannelStatusEntity channelStatus) {
+ channels.add(channelStatus);
+ channelStatus.setDistributionStatus(this);
+ }
+
+ /**
+ * 채널 상태 제거 헬퍼 메서드
+ */
+ public void removeChannelStatus(ChannelStatusEntity channelStatus) {
+ channels.remove(channelStatus);
+ channelStatus.setDistributionStatus(null);
+ }
+}
diff --git a/distribution-service/src/main/java/com/kt/distribution/mapper/DistributionStatusMapper.java b/distribution-service/src/main/java/com/kt/distribution/mapper/DistributionStatusMapper.java
new file mode 100644
index 0000000..2bd1eff
--- /dev/null
+++ b/distribution-service/src/main/java/com/kt/distribution/mapper/DistributionStatusMapper.java
@@ -0,0 +1,173 @@
+package com.kt.distribution.mapper;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.kt.distribution.dto.ChannelStatus;
+import com.kt.distribution.dto.DistributionStatusResponse;
+import com.kt.distribution.entity.ChannelStatusEntity;
+import com.kt.distribution.entity.DistributionStatus;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * 배포 상태 Mapper
+ *
+ * Entity와 DTO 간의 변환을 담당합니다.
+ *
+ * @author Backend Developer
+ * @since 2025-10-24
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class DistributionStatusMapper {
+
+ private final ObjectMapper objectMapper;
+
+ /**
+ * DistributionStatusResponse DTO를 DistributionStatus Entity로 변환
+ *
+ * @param dto DistributionStatusResponse DTO
+ * @return DistributionStatus Entity
+ */
+ public DistributionStatus toEntity(DistributionStatusResponse dto) {
+ if (dto == null) {
+ return null;
+ }
+
+ DistributionStatus entity = DistributionStatus.builder()
+ .eventId(dto.getEventId())
+ .overallStatus(dto.getOverallStatus())
+ .startedAt(dto.getStartedAt())
+ .completedAt(dto.getCompletedAt())
+ .build();
+
+ // 채널 상태 변환 및 추가
+ if (dto.getChannels() != null) {
+ List channelEntities = dto.getChannels().stream()
+ .map(channelDto -> toChannelEntity(channelDto, entity))
+ .collect(Collectors.toList());
+
+ channelEntities.forEach(entity::addChannelStatus);
+ }
+
+ return entity;
+ }
+
+ /**
+ * DistributionStatus Entity를 DistributionStatusResponse DTO로 변환
+ *
+ * @param entity DistributionStatus Entity
+ * @return DistributionStatusResponse DTO
+ */
+ public DistributionStatusResponse toDto(DistributionStatus entity) {
+ if (entity == null) {
+ return null;
+ }
+
+ List channelDtos = entity.getChannels() != null
+ ? entity.getChannels().stream()
+ .map(this::toChannelDto)
+ .collect(Collectors.toList())
+ : Collections.emptyList();
+
+ return DistributionStatusResponse.builder()
+ .eventId(entity.getEventId())
+ .overallStatus(entity.getOverallStatus())
+ .startedAt(entity.getStartedAt())
+ .completedAt(entity.getCompletedAt())
+ .channels(channelDtos)
+ .build();
+ }
+
+ /**
+ * ChannelStatus DTO를 ChannelStatusEntity로 변환
+ *
+ * @param dto ChannelStatus DTO
+ * @param distributionStatus 부모 DistributionStatus Entity
+ * @return ChannelStatusEntity
+ */
+ private ChannelStatusEntity toChannelEntity(ChannelStatus dto, DistributionStatus distributionStatus) {
+ if (dto == null) {
+ return null;
+ }
+
+ // impressionSchedule를 JSON 문자열로 변환
+ String impressionScheduleJson = null;
+ if (dto.getImpressionSchedule() != null && !dto.getImpressionSchedule().isEmpty()) {
+ try {
+ impressionScheduleJson = objectMapper.writeValueAsString(dto.getImpressionSchedule());
+ } catch (JsonProcessingException e) {
+ log.error("Failed to serialize impressionSchedule", e);
+ }
+ }
+
+ return ChannelStatusEntity.builder()
+ .distributionStatus(distributionStatus)
+ .channel(dto.getChannel())
+ .status(dto.getStatus())
+ .progress(dto.getProgress())
+ .distributionId(dto.getDistributionId())
+ .estimatedViews(dto.getEstimatedViews())
+ .updateTimestamp(dto.getUpdateTimestamp())
+ .adId(dto.getAdId())
+ .impressionSchedule(impressionScheduleJson)
+ .postUrl(dto.getPostUrl())
+ .postId(dto.getPostId())
+ .messageId(dto.getMessageId())
+ .completedAt(dto.getCompletedAt())
+ .errorMessage(dto.getErrorMessage())
+ .retries(dto.getRetries())
+ .lastRetryAt(dto.getLastRetryAt())
+ .build();
+ }
+
+ /**
+ * ChannelStatusEntity를 ChannelStatus DTO로 변환
+ *
+ * @param entity ChannelStatusEntity
+ * @return ChannelStatus DTO
+ */
+ private ChannelStatus toChannelDto(ChannelStatusEntity entity) {
+ if (entity == null) {
+ return null;
+ }
+
+ // JSON 문자열을 List으로 변환
+ List impressionScheduleList = null;
+ if (entity.getImpressionSchedule() != null && !entity.getImpressionSchedule().isEmpty()) {
+ try {
+ impressionScheduleList = objectMapper.readValue(
+ entity.getImpressionSchedule(),
+ new TypeReference>() {}
+ );
+ } catch (JsonProcessingException e) {
+ log.error("Failed to deserialize impressionSchedule", e);
+ }
+ }
+
+ return ChannelStatus.builder()
+ .channel(entity.getChannel())
+ .status(entity.getStatus())
+ .progress(entity.getProgress())
+ .distributionId(entity.getDistributionId())
+ .estimatedViews(entity.getEstimatedViews())
+ .updateTimestamp(entity.getUpdateTimestamp())
+ .adId(entity.getAdId())
+ .impressionSchedule(impressionScheduleList)
+ .postUrl(entity.getPostUrl())
+ .postId(entity.getPostId())
+ .messageId(entity.getMessageId())
+ .completedAt(entity.getCompletedAt())
+ .errorMessage(entity.getErrorMessage())
+ .retries(entity.getRetries())
+ .lastRetryAt(entity.getLastRetryAt())
+ .build();
+ }
+}
diff --git a/distribution-service/src/main/java/com/kt/distribution/repository/DistributionStatusJpaRepository.java b/distribution-service/src/main/java/com/kt/distribution/repository/DistributionStatusJpaRepository.java
new file mode 100644
index 0000000..51889d3
--- /dev/null
+++ b/distribution-service/src/main/java/com/kt/distribution/repository/DistributionStatusJpaRepository.java
@@ -0,0 +1,53 @@
+package com.kt.distribution.repository;
+
+import com.kt.distribution.entity.DistributionStatus;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+
+/**
+ * 배포 상태 JPA Repository
+ *
+ * 배포 상태를 데이터베이스에 영구 저장하고 조회합니다.
+ *
+ * @author Backend Developer
+ * @since 2025-10-24
+ */
+@Repository
+public interface DistributionStatusJpaRepository extends JpaRepository {
+
+ /**
+ * 이벤트 ID로 배포 상태 조회
+ *
+ * @param eventId 이벤트 ID
+ * @return 배포 상태 (없으면 Optional.empty())
+ */
+ Optional findByEventId(String eventId);
+
+ /**
+ * 이벤트 ID로 배포 상태 조회 (채널 상태 Fetch Join)
+ *
+ * @param eventId 이벤트 ID
+ * @return 배포 상태 (채널 상태 포함, 없으면 Optional.empty())
+ */
+ @Query("SELECT d FROM DistributionStatus d LEFT JOIN FETCH d.channels WHERE d.eventId = :eventId")
+ Optional findByEventIdWithChannels(@Param("eventId") String eventId);
+
+ /**
+ * 이벤트 ID로 배포 상태 존재 여부 확인
+ *
+ * @param eventId 이벤트 ID
+ * @return 존재 여부
+ */
+ boolean existsByEventId(String eventId);
+
+ /**
+ * 이벤트 ID로 배포 상태 삭제
+ *
+ * @param eventId 이벤트 ID
+ */
+ void deleteByEventId(String eventId);
+}
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
index aaa0b71..e46b12b 100644
--- a/distribution-service/src/main/java/com/kt/distribution/repository/DistributionStatusRepository.java
+++ b/distribution-service/src/main/java/com/kt/distribution/repository/DistributionStatusRepository.java
@@ -1,48 +1,78 @@
package com.kt.distribution.repository;
import com.kt.distribution.dto.DistributionStatusResponse;
+import com.kt.distribution.entity.DistributionStatus;
+import com.kt.distribution.mapper.DistributionStatusMapper;
+import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
-import java.util.Map;
import java.util.Optional;
-import java.util.concurrent.ConcurrentHashMap;
/**
* 배포 상태 저장소
*
- * 메모리 기반으로 배포 상태를 관리합니다.
- * 실제 운영 환경에서는 Redis 또는 데이터베이스를 사용하여 영구 저장하는 것을 권장합니다.
+ * PostgreSQL 데이터베이스를 사용하여 배포 상태를 영구 저장합니다.
+ *
+ * @author Backend Developer
+ * @since 2025-10-24
*/
@Slf4j
@Repository
+@RequiredArgsConstructor
public class DistributionStatusRepository {
- /**
- * 이벤트 ID를 키로 배포 상태를 저장하는 메모리 저장소
- */
- private final Map distributionStatuses = new ConcurrentHashMap<>();
+ private final DistributionStatusJpaRepository jpaRepository;
+ private final DistributionStatusMapper mapper;
/**
* 배포 상태 저장
*
* @param eventId 이벤트 ID
- * @param status 배포 상태
+ * @param status 배포 상태 DTO
*/
+ @Transactional
public void save(String eventId, DistributionStatusResponse status) {
log.debug("Saving distribution status: eventId={}, overallStatus={}", eventId, status.getOverallStatus());
- distributionStatuses.put(eventId, status);
+
+ // 기존 데이터가 있으면 업데이트, 없으면 새로 생성
+ Optional existingStatus = jpaRepository.findByEventIdWithChannels(eventId);
+
+ if (existingStatus.isPresent()) {
+ // 기존 데이터 업데이트
+ DistributionStatus entity = existingStatus.get();
+ entity.setOverallStatus(status.getOverallStatus());
+ entity.setStartedAt(status.getStartedAt());
+ entity.setCompletedAt(status.getCompletedAt());
+
+ // 기존 채널 상태 모두 삭제 후 새로 추가
+ entity.getChannels().clear();
+
+ DistributionStatus newEntity = mapper.toEntity(status);
+ if (newEntity.getChannels() != null) {
+ newEntity.getChannels().forEach(entity::addChannelStatus);
+ }
+
+ jpaRepository.save(entity);
+ } else {
+ // 새로 생성
+ DistributionStatus entity = mapper.toEntity(status);
+ jpaRepository.save(entity);
+ }
}
/**
* 배포 상태 조회
*
* @param eventId 이벤트 ID
- * @return 배포 상태 (없으면 Optional.empty())
+ * @return 배포 상태 DTO (없으면 Optional.empty())
*/
+ @Transactional(readOnly = true)
public Optional findByEventId(String eventId) {
log.debug("Finding distribution status: eventId={}", eventId);
- return Optional.ofNullable(distributionStatuses.get(eventId));
+ return jpaRepository.findByEventIdWithChannels(eventId)
+ .map(mapper::toDto);
}
/**
@@ -50,16 +80,18 @@ public class DistributionStatusRepository {
*
* @param eventId 이벤트 ID
*/
+ @Transactional
public void delete(String eventId) {
log.debug("Deleting distribution status: eventId={}", eventId);
- distributionStatuses.remove(eventId);
+ jpaRepository.deleteByEventId(eventId);
}
/**
* 모든 배포 상태 삭제 (테스트용)
*/
+ @Transactional
public void deleteAll() {
log.debug("Deleting all distribution statuses");
- distributionStatuses.clear();
+ jpaRepository.deleteAll();
}
}
diff --git a/tools/check_tables.py b/tools/check_tables.py
new file mode 100644
index 0000000..7d794cc
--- /dev/null
+++ b/tools/check_tables.py
@@ -0,0 +1,120 @@
+#!/usr/bin/env python3
+"""
+PostgreSQL 테이블 확인 스크립트
+distribution-service의 테이블 생성 여부를 확인합니다.
+"""
+
+import psycopg2
+import sys
+
+# DB 연결 정보
+DB_CONFIG = {
+ 'host': '4.217.133.59',
+ 'port': 5432,
+ 'database': 'distributiondb',
+ 'user': 'eventuser',
+ 'password': 'Hi5Jessica!'
+}
+
+def main():
+ try:
+ # DB 연결
+ print(f"Connecting to database: {DB_CONFIG['host']}:{DB_CONFIG['port']}/{DB_CONFIG['database']}")
+ conn = psycopg2.connect(**DB_CONFIG)
+ cursor = conn.cursor()
+
+ # 테이블 목록 조회
+ print("\n=== Tables in distributiondb ===")
+ cursor.execute("""
+ SELECT table_name
+ FROM information_schema.tables
+ WHERE table_schema = 'public'
+ ORDER BY table_name
+ """)
+ tables = cursor.fetchall()
+
+ if not tables:
+ print("No tables found.")
+ else:
+ for table in tables:
+ print(f" - {table[0]}")
+
+ # distribution_status 테이블 구조 확인
+ if any('distribution_status' in table for table in tables):
+ print("\n=== distribution_status table structure ===")
+ cursor.execute("""
+ SELECT column_name, data_type, character_maximum_length, is_nullable
+ FROM information_schema.columns
+ WHERE table_name = 'distribution_status'
+ ORDER BY ordinal_position
+ """)
+ columns = cursor.fetchall()
+ for col in columns:
+ nullable = "NULL" if col[3] == 'YES' else "NOT NULL"
+ max_len = f"({col[2]})" if col[2] else ""
+ print(f" - {col[0]}: {col[1]}{max_len} {nullable}")
+
+ # channel_status 테이블 구조 확인
+ if any('channel_status' in table for table in tables):
+ print("\n=== channel_status table structure ===")
+ cursor.execute("""
+ SELECT column_name, data_type, character_maximum_length, is_nullable
+ FROM information_schema.columns
+ WHERE table_name = 'channel_status'
+ ORDER BY ordinal_position
+ """)
+ columns = cursor.fetchall()
+ for col in columns:
+ nullable = "NULL" if col[3] == 'YES' else "NOT NULL"
+ max_len = f"({col[2]})" if col[2] else ""
+ print(f" - {col[0]}: {col[1]}{max_len} {nullable}")
+
+ # 인덱스 확인
+ print("\n=== Indexes ===")
+ cursor.execute("""
+ SELECT tablename, indexname, indexdef
+ FROM pg_indexes
+ WHERE schemaname = 'public'
+ AND tablename IN ('distribution_status', 'channel_status')
+ ORDER BY tablename, indexname
+ """)
+ indexes = cursor.fetchall()
+ for idx in indexes:
+ print(f" - {idx[0]}.{idx[1]}")
+ print(f" {idx[2]}")
+
+ # 외래 키 확인
+ print("\n=== Foreign Keys ===")
+ cursor.execute("""
+ SELECT
+ tc.table_name,
+ kcu.column_name,
+ ccu.table_name AS foreign_table_name,
+ ccu.column_name AS foreign_column_name
+ FROM information_schema.table_constraints AS tc
+ JOIN information_schema.key_column_usage AS kcu
+ ON tc.constraint_name = kcu.constraint_name
+ AND tc.table_schema = kcu.table_schema
+ JOIN information_schema.constraint_column_usage AS ccu
+ ON ccu.constraint_name = tc.constraint_name
+ AND ccu.table_schema = tc.table_schema
+ WHERE tc.constraint_type = 'FOREIGN KEY'
+ AND tc.table_name IN ('distribution_status', 'channel_status')
+ """)
+ fks = cursor.fetchall()
+ for fk in fks:
+ print(f" - {fk[0]}.{fk[1]} -> {fk[2]}.{fk[3]}")
+
+ # 연결 종료
+ cursor.close()
+ conn.close()
+
+ print("\n✅ Database connection successful!")
+ return 0
+
+ except Exception as e:
+ print(f"\n❌ Error: {e}", file=sys.stderr)
+ return 1
+
+if __name__ == "__main__":
+ sys.exit(main())