From b0d8a6d10e8481543629e665ed9e71f7bde7ed57 Mon Sep 17 00:00:00 2001 From: sunmingLee <25thbam@gmail.com> Date: Fri, 24 Oct 2025 13:45:45 +0900 Subject: [PATCH] =?UTF-8?q?distribution-service=20API=20=EB=AA=85=EC=84=B8?= =?UTF-8?q?=EC=84=9C=EB=A5=BC=20=EC=8B=A4=EC=A0=9C=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChannelType 열거형 값 수정 (URIDONGNETV, RINGOBIZ, GINITV 등) - DistributionRequest 스키마 변경 (title, description, imageUrl 추가) - DistributionResponse 스키마 변경 (success, successCount, failureCount 등) - ChannelDistributionResult 스키마 단순화 - 모든 예제 코드 실제 구현에 맞게 업데이트 - IntelliJ 서비스 실행 프로파일 추가 - Distribution 서비스 엔티티, 매퍼, 리포지토리 추가 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .run/AiServiceApplication.run.xml | 31 ++ .run/AnalyticsServiceApplication.run.xml | 31 ++ .run/ContentServiceApplication.run.xml | 29 ++ .run/EventServiceApplication.run.xml | 31 ++ .run/ParticipationServiceApplication.run.xml | 29 ++ .run/UserServiceApplication.run.xml | 29 ++ .../backend/api/distribution-service-api.yaml | 369 ++++++++---------- .../controller/DistributionController.java | 2 +- .../entity/ChannelStatusEntity.java | 168 ++++++++ .../entity/DistributionStatus.java | 118 ++++++ .../mapper/DistributionStatusMapper.java | 173 ++++++++ .../DistributionStatusJpaRepository.java | 53 +++ .../DistributionStatusRepository.java | 60 ++- tools/check_tables.py | 120 ++++++ 14 files changed, 1018 insertions(+), 225 deletions(-) create mode 100644 .run/AiServiceApplication.run.xml create mode 100644 .run/AnalyticsServiceApplication.run.xml create mode 100644 .run/ContentServiceApplication.run.xml create mode 100644 .run/EventServiceApplication.run.xml create mode 100644 .run/ParticipationServiceApplication.run.xml create mode 100644 .run/UserServiceApplication.run.xml create mode 100644 distribution-service/src/main/java/com/kt/distribution/entity/ChannelStatusEntity.java create mode 100644 distribution-service/src/main/java/com/kt/distribution/entity/DistributionStatus.java create mode 100644 distribution-service/src/main/java/com/kt/distribution/mapper/DistributionStatusMapper.java create mode 100644 distribution-service/src/main/java/com/kt/distribution/repository/DistributionStatusJpaRepository.java create mode 100644 tools/check_tables.py 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())