distribution-service API 명세서를 실제 구현에 맞게 수정

- 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 <noreply@anthropic.com>
This commit is contained in:
sunmingLee 2025-10-24 13:45:45 +09:00
parent 9f50c7feaa
commit b0d8a6d10e
14 changed files with 1018 additions and 225 deletions

View File

@ -0,0 +1,31 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="AiServiceApplication" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot" nameIsGenerated="true">
<option name="ACTIVE_PROFILES" />
<module name="kt-event-marketing.ai-service.main" />
<option name="SPRING_BOOT_MAIN_CLASS" value="com.kt.ai.AiApplication" />
<extension name="coverage">
<pattern>
<option name="PATTERN" value="com.kt.ai.*" />
<option name="ENABLED" value="true" />
</pattern>
</extension>
<envs>
<env name="SERVER_PORT" value="8081" />
<env name="DB_HOST" value="4.230.112.141" />
<env name="DB_PORT" value="5432" />
<env name="DB_NAME" value="aidb" />
<env name="DB_USERNAME" value="eventuser" />
<env name="DB_PASSWORD" value="Hi5Jessica!" />
<env name="REDIS_HOST" value="20.214.210.71" />
<env name="REDIS_PORT" value="6379" />
<env name="REDIS_PASSWORD" value="Hi5Jessica!" />
<env name="KAFKA_BOOTSTRAP_SERVERS" value="4.230.50.63:9092" />
<env name="KAFKA_CONSUMER_GROUP" value="ai" />
<env name="JPA_DDL_AUTO" value="update" />
<env name="JPA_SHOW_SQL" value="false" />
</envs>
<method v="2">
<option name="Make" enabled="true" />
</method>
</configuration>
</component>

View File

@ -0,0 +1,31 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="AnalyticsServiceApplication" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot" nameIsGenerated="true">
<option name="ACTIVE_PROFILES" />
<module name="kt-event-marketing.analytics-service.main" />
<option name="SPRING_BOOT_MAIN_CLASS" value="com.kt.analytics.AnalyticsApplication" />
<extension name="coverage">
<pattern>
<option name="PATTERN" value="com.kt.analytics.*" />
<option name="ENABLED" value="true" />
</pattern>
</extension>
<envs>
<env name="SERVER_PORT" value="8087" />
<env name="DB_HOST" value="4.230.49.9" />
<env name="DB_PORT" value="5432" />
<env name="DB_NAME" value="analyticdb" />
<env name="DB_USERNAME" value="eventuser" />
<env name="DB_PASSWORD" value="Hi5Jessica!" />
<env name="REDIS_HOST" value="20.214.210.71" />
<env name="REDIS_PORT" value="6379" />
<env name="REDIS_PASSWORD" value="Hi5Jessica!" />
<env name="KAFKA_BOOTSTRAP_SERVERS" value="4.230.50.63:9092" />
<env name="KAFKA_CONSUMER_GROUP" value="analytic" />
<env name="JPA_DDL_AUTO" value="update" />
<env name="JPA_SHOW_SQL" value="false" />
</envs>
<method v="2">
<option name="Make" enabled="true" />
</method>
</configuration>
</component>

View File

@ -0,0 +1,29 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="ContentServiceApplication" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot" nameIsGenerated="true">
<option name="ACTIVE_PROFILES" />
<module name="kt-event-marketing.content-service.main" />
<option name="SPRING_BOOT_MAIN_CLASS" value="com.kt.content.ContentApplication" />
<extension name="coverage">
<pattern>
<option name="PATTERN" value="com.kt.content.*" />
<option name="ENABLED" value="true" />
</pattern>
</extension>
<envs>
<env name="SERVER_PORT" value="8084" />
<env name="DB_HOST" value="4.217.131.139" />
<env name="DB_PORT" value="5432" />
<env name="DB_NAME" value="contentdb" />
<env name="DB_USERNAME" value="eventuser" />
<env name="DB_PASSWORD" value="Hi5Jessica!" />
<env name="REDIS_HOST" value="20.214.210.71" />
<env name="REDIS_PORT" value="6379" />
<env name="REDIS_PASSWORD" value="Hi5Jessica!" />
<env name="JPA_DDL_AUTO" value="update" />
<env name="JPA_SHOW_SQL" value="false" />
</envs>
<method v="2">
<option name="Make" enabled="true" />
</method>
</configuration>
</component>

View File

@ -0,0 +1,31 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="EventServiceApplication" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot" nameIsGenerated="true">
<option name="ACTIVE_PROFILES" />
<module name="kt-event-marketing.event-service.main" />
<option name="SPRING_BOOT_MAIN_CLASS" value="com.kt.event.EventApplication" />
<extension name="coverage">
<pattern>
<option name="PATTERN" value="com.kt.event.*" />
<option name="ENABLED" value="true" />
</pattern>
</extension>
<envs>
<env name="SERVER_PORT" value="8082" />
<env name="DB_HOST" value="20.249.177.232" />
<env name="DB_PORT" value="5432" />
<env name="DB_NAME" value="eventdb" />
<env name="DB_USERNAME" value="eventuser" />
<env name="DB_PASSWORD" value="Hi5Jessica!" />
<env name="REDIS_HOST" value="20.214.210.71" />
<env name="REDIS_PORT" value="6379" />
<env name="REDIS_PASSWORD" value="Hi5Jessica!" />
<env name="KAFKA_BOOTSTRAP_SERVERS" value="4.230.50.63:9092" />
<env name="DISTRIBUTION_SERVICE_URL" value="http://localhost:8085" />
<env name="JPA_DDL_AUTO" value="update" />
<env name="JPA_SHOW_SQL" value="false" />
</envs>
<method v="2">
<option name="Make" enabled="true" />
</method>
</configuration>
</component>

View File

@ -0,0 +1,29 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="ParticipationServiceApplication" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot" nameIsGenerated="true">
<option name="ACTIVE_PROFILES" />
<module name="kt-event-marketing.participation-service.main" />
<option name="SPRING_BOOT_MAIN_CLASS" value="com.kt.participation.ParticipationApplication" />
<extension name="coverage">
<pattern>
<option name="PATTERN" value="com.kt.participation.*" />
<option name="ENABLED" value="true" />
</pattern>
</extension>
<envs>
<env name="SERVER_PORT" value="8086" />
<env name="DB_HOST" value="4.230.72.147" />
<env name="DB_PORT" value="5432" />
<env name="DB_NAME" value="participationdb" />
<env name="DB_USERNAME" value="eventuser" />
<env name="DB_PASSWORD" value="Hi5Jessica!" />
<env name="REDIS_HOST" value="20.214.210.71" />
<env name="REDIS_PORT" value="6379" />
<env name="REDIS_PASSWORD" value="Hi5Jessica!" />
<env name="JPA_DDL_AUTO" value="update" />
<env name="JPA_SHOW_SQL" value="false" />
</envs>
<method v="2">
<option name="Make" enabled="true" />
</method>
</configuration>
</component>

View File

@ -0,0 +1,29 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="UserServiceApplication" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot" nameIsGenerated="true">
<option name="ACTIVE_PROFILES" />
<module name="kt-event-marketing.user-service.main" />
<option name="SPRING_BOOT_MAIN_CLASS" value="com.kt.user.UserApplication" />
<extension name="coverage">
<pattern>
<option name="PATTERN" value="com.kt.user.*" />
<option name="ENABLED" value="true" />
</pattern>
</extension>
<envs>
<env name="SERVER_PORT" value="8083" />
<env name="DB_HOST" value="20.249.125.115" />
<env name="DB_PORT" value="5432" />
<env name="DB_NAME" value="userdb" />
<env name="DB_USERNAME" value="eventuser" />
<env name="DB_PASSWORD" value="Hi5Jessica!" />
<env name="REDIS_HOST" value="20.214.210.71" />
<env name="REDIS_PORT" value="6379" />
<env name="REDIS_PASSWORD" value="Hi5Jessica!" />
<env name="JPA_DDL_AUTO" value="update" />
<env name="JPA_SHOW_SQL" value="false" />
</envs>
<method v="2">
<option name="Make" enabled="true" />
</method>
</configuration>
</component>

View File

@ -11,10 +11,10 @@ info:
- Retry 패턴 및 Fallback 처리 - Retry 패턴 및 Fallback 처리
## 배포 채널 ## 배포 채널
- **우리동네TV**: 영상 콘텐츠 업로드 - **우리동네TV** (URIDONGNETV): 영상 콘텐츠 업로드
- **링고비즈**: 연결음 업데이트 - **링고비즈** (RINGOBIZ): 연결음 업데이트
- **지니TV**: 광고 등록 - **지니TV** (GINITV): 광고 등록
- **SNS**: Instagram, Naver Blog, Kakao Channel - **SNS**: Instagram (INSTAGRAM), Naver Blog (NAVER), Kakao Channel (KAKAO)
## Resilience 패턴 ## Resilience 패턴
- Circuit Breaker: 채널별 독립적 장애 격리 - Circuit Breaker: 채널별 독립적 장애 격리
@ -79,23 +79,21 @@ paths:
summary: 다중 채널 배포 예시 summary: 다중 채널 배포 예시
value: value:
eventId: "evt-12345" eventId: "evt-12345"
title: "신규 고객 환영 이벤트"
description: "신규 고객님을 위한 특별 할인 이벤트"
imageUrl: "https://cdn.example.com/images/event-main.jpg"
channels: channels:
- type: "WOORIDONGNE_TV" - "URIDONGNETV"
config: - "INSTAGRAM"
radius: "1km" - "NAVER"
timeSlots: channelSettings:
- "weekday_evening" URIDONGNETV:
- "weekend_lunch" radius: "1km"
- type: "INSTAGRAM" timeSlot: "evening"
config: INSTAGRAM:
scheduledTime: "2025-11-01T10:00:00Z" scheduledTime: "2025-11-01T10:00:00"
- type: "NAVER_BLOG" NAVER:
config: scheduledTime: "2025-11-01T10:30:00"
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"
responses: responses:
'200': '200':
description: 배포 완료 description: 배포 완료
@ -107,25 +105,29 @@ paths:
allSuccess: allSuccess:
summary: 모든 채널 배포 성공 summary: 모든 채널 배포 성공
value: value:
distributionId: "dist-12345"
eventId: "evt-12345" eventId: "evt-12345"
status: "COMPLETED" success: true
completedAt: "2025-11-01T09:00:00Z" channelResults:
results: - channel: "URIDONGNETV"
- channel: "WOORIDONGNE_TV" success: true
status: "SUCCESS"
distributionId: "wtv-uuid-12345" distributionId: "wtv-uuid-12345"
estimatedViews: 1000 estimatedReach: 1000
message: "배포 완료" executionTimeMs: 234
- channel: "INSTAGRAM" - channel: "INSTAGRAM"
status: "SUCCESS" success: true
postUrl: "https://instagram.com/p/generated-post-id" distributionId: "ig-uuid-12345"
postId: "ig-post-12345" estimatedReach: 500
message: "게시 완료" executionTimeMs: 456
- channel: "NAVER_BLOG" - channel: "NAVER"
status: "SUCCESS" success: true
postUrl: "https://blog.naver.com/store123/generated-post" distributionId: "naver-uuid-12345"
message: "게시 완료" estimatedReach: 300
executionTimeMs: 123
successCount: 3
failureCount: 0
completedAt: "2025-11-01T09:00:00"
totalExecutionTimeMs: 1234
message: "배포가 성공적으로 완료되었습니다"
'400': '400':
description: 잘못된 요청 description: 잘못된 요청
content: content:
@ -217,67 +219,77 @@ paths:
value: value:
eventId: "evt-12345" eventId: "evt-12345"
overallStatus: "COMPLETED" overallStatus: "COMPLETED"
completedAt: "2025-11-01T09:00:00Z" startedAt: "2025-11-01T08:58:00"
completedAt: "2025-11-01T09:00:00"
channels: channels:
- channel: "WOORIDONGNE_TV" - channel: "URIDONGNETV"
status: "COMPLETED" status: "COMPLETED"
distributionId: "wtv-uuid-12345" distributionId: "wtv-uuid-12345"
estimatedViews: 1500 estimatedViews: 1500
completedAt: "2025-11-01T09:00:00Z" completedAt: "2025-11-01T09:00:00"
- channel: "RINGO_BIZ" - channel: "RINGOBIZ"
status: "COMPLETED" status: "COMPLETED"
updateTimestamp: "2025-11-01T09:00:00Z" updateTimestamp: "2025-11-01T09:00:00"
- channel: "GENIE_TV" completedAt: "2025-11-01T09:00:00"
- channel: "GINITV"
status: "COMPLETED" status: "COMPLETED"
adId: "gtv-uuid-12345" adId: "gtv-uuid-12345"
impressionSchedule: impressionSchedule:
- "2025-11-01 18:00-20:00" - "2025-11-01 18:00-20:00"
- "2025-11-02 12:00-14:00" - "2025-11-02 12:00-14:00"
completedAt: "2025-11-01T09:00:00"
- channel: "INSTAGRAM" - channel: "INSTAGRAM"
status: "COMPLETED" status: "COMPLETED"
postUrl: "https://instagram.com/p/generated-post-id" postUrl: "https://instagram.com/p/generated-post-id"
postId: "ig-post-12345" postId: "ig-post-12345"
- channel: "NAVER_BLOG" completedAt: "2025-11-01T09:00:00"
- channel: "NAVER"
status: "COMPLETED" status: "COMPLETED"
postUrl: "https://blog.naver.com/store123/generated-post" postUrl: "https://blog.naver.com/store123/generated-post"
- channel: "KAKAO_CHANNEL" completedAt: "2025-11-01T09:00:00"
- channel: "KAKAO"
status: "COMPLETED" status: "COMPLETED"
messageId: "kakao-msg-12345" messageId: "kakao-msg-12345"
completedAt: "2025-11-01T09:00:00"
inProgress: inProgress:
summary: 배포 진행중 상태 summary: 배포 진행중 상태
value: value:
eventId: "evt-12345" eventId: "evt-12345"
overallStatus: "IN_PROGRESS" overallStatus: "IN_PROGRESS"
startedAt: "2025-11-01T08:58:00Z" startedAt: "2025-11-01T08:58:00"
channels: channels:
- channel: "WOORIDONGNE_TV" - channel: "URIDONGNETV"
status: "COMPLETED" status: "COMPLETED"
distributionId: "wtv-uuid-12345" distributionId: "wtv-uuid-12345"
estimatedViews: 1500 estimatedViews: 1500
completedAt: "2025-11-01T08:59:00"
- channel: "INSTAGRAM" - channel: "INSTAGRAM"
status: "IN_PROGRESS" status: "IN_PROGRESS"
progress: 50 progress: 50
- channel: "NAVER_BLOG" - channel: "NAVER"
status: "PENDING" status: "PENDING"
partialFailure: partialFailure:
summary: 일부 채널 실패 상태 summary: 일부 채널 실패 상태
value: value:
eventId: "evt-12345" eventId: "evt-12345"
overallStatus: "PARTIAL_FAILURE" overallStatus: "PARTIAL_FAILURE"
completedAt: "2025-11-01T09:00:00Z" startedAt: "2025-11-01T08:58:00"
completedAt: "2025-11-01T09:00:00"
channels: channels:
- channel: "WOORIDONGNE_TV" - channel: "URIDONGNETV"
status: "COMPLETED" status: "COMPLETED"
distributionId: "wtv-uuid-12345" distributionId: "wtv-uuid-12345"
estimatedViews: 1500 estimatedViews: 1500
completedAt: "2025-11-01T08:59:00"
- channel: "INSTAGRAM" - channel: "INSTAGRAM"
status: "FAILED" status: "FAILED"
errorMessage: "Instagram API 타임아웃" errorMessage: "Instagram API 타임아웃"
retries: 3 retries: 3
lastRetryAt: "2025-11-01T08:59:30Z" lastRetryAt: "2025-11-01T08:59:30"
- channel: "NAVER_BLOG" - channel: "NAVER"
status: "COMPLETED" status: "COMPLETED"
postUrl: "https://blog.naver.com/store123/generated-post" postUrl: "https://blog.naver.com/store123/generated-post"
completedAt: "2025-11-01T09:00:00"
'404': '404':
description: 배포 이력을 찾을 수 없음 description: 배포 이력을 찾을 수 없음
content: content:
@ -305,196 +317,133 @@ components:
required: required:
- eventId - eventId
- channels - channels
- contentUrls
properties: properties:
eventId: eventId:
type: string type: string
description: 이벤트 ID description: 이벤트 ID
example: "evt-12345" 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: channels:
type: array type: array
description: 배포할 채널 목록 description: 배포할 채널 목록
minItems: 1 minItems: 1
items: items:
$ref: '#/components/schemas/ChannelConfig' type: string
contentUrls: enum:
- URIDONGNETV
- RINGOBIZ
- GINITV
- INSTAGRAM
- NAVER
- KAKAO
example: ["URIDONGNETV", "INSTAGRAM", "NAVER"]
channelSettings:
type: object type: object
description: 플랫폼별 콘텐츠 URL description: 채널별 추가 설정 (Optional)
properties: additionalProperties:
wooridongneTV: type: object
type: string additionalProperties: true
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
example: example:
scheduledTime: "2025-11-01T10:00:00Z" URIDONGNETV:
caption: "이벤트 안내" radius: "1km"
hashtags: timeSlot: "evening"
- "이벤트" INSTAGRAM:
- "할인" scheduledTime: "2025-11-01T10:00:00"
DistributionResponse: DistributionResponse:
type: object type: object
required: required:
- distributionId
- eventId - eventId
- status - success
- results - channelResults
- successCount
- failureCount
properties: properties:
distributionId:
type: string
description: 배포 ID
example: "dist-12345"
eventId: eventId:
type: string type: string
description: 이벤트 ID description: 이벤트 ID
example: "evt-12345" example: "evt-12345"
status: success:
type: string type: boolean
description: 전체 배포 상태 description: 배포 성공 여부 (모든 채널 또는 일부 채널 성공)
enum: example: true
- PENDING channelResults:
- IN_PROGRESS type: array
- COMPLETED description: 채널별 배포 결과
- PARTIAL_FAILURE items:
- FAILED $ref: '#/components/schemas/ChannelDistributionResult'
example: "COMPLETED" successCount:
startedAt: type: integer
type: string description: 성공한 채널 수
format: date-time example: 3
description: 배포 시작 시각 failureCount:
example: "2025-11-01T08:59:00Z" type: integer
description: 실패한 채널 수
example: 0
completedAt: completedAt:
type: string type: string
format: date-time format: date-time
description: 배포 완료 시각 description: 배포 완료 시각
example: "2025-11-01T09:00:00Z" example: "2025-11-01T09:00:00"
results: totalExecutionTimeMs:
type: array type: integer
description: 채널별 배포 결과 format: int64
items: description: 전체 배포 소요 시간 (ms)
$ref: '#/components/schemas/ChannelResult' example: 1234
message:
type: string
description: 메시지
example: "배포가 성공적으로 완료되었습니다"
ChannelResult: ChannelDistributionResult:
type: object type: object
required: required:
- channel - channel
- status - success
properties: properties:
channel: channel:
type: string type: string
description: 채널 타입 description: 채널 타입
enum: enum:
- WOORIDONGNE_TV - URIDONGNETV
- RINGO_BIZ - RINGOBIZ
- GENIE_TV - GINITV
- INSTAGRAM - INSTAGRAM
- NAVER_BLOG - NAVER
- KAKAO_CHANNEL - KAKAO
example: "INSTAGRAM" example: "INSTAGRAM"
status: success:
type: string type: boolean
description: 채널별 배포 상태 description: 배포 성공 여부
enum: example: true
- PENDING
- IN_PROGRESS
- SUCCESS
- FAILED
example: "SUCCESS"
distributionId: distributionId:
type: string type: string
description: 채널별 배포 ID (우리동네TV, 지니TV) description: 배포 ID (성공 시)
example: "wtv-uuid-12345" example: "dist-uuid-12345"
estimatedViews: estimatedReach:
type: integer type: integer
description: 예상 노출 수 (우리동네TV, 지니TV) description: 예상 노출 수 (성공 시)
example: 1500 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: errorMessage:
type: string type: string
description: 오류 메시지 (실패 시) description: 에러 메시지 (실패 시)
example: "Instagram API 타임아웃" example: "Instagram API 타임아웃"
retries: executionTimeMs:
type: integer type: integer
description: 재시도 횟수 format: int64
example: 0 description: 배포 소요 시간 (ms)
lastRetryAt: example: 234
type: string
format: date-time
description: 마지막 재시도 시각
example: "2025-11-01T08:59:30Z"
DistributionStatusResponse: DistributionStatusResponse:
type: object type: object
@ -544,12 +493,12 @@ components:
type: string type: string
description: 채널 타입 description: 채널 타입
enum: enum:
- WOORIDONGNE_TV - URIDONGNETV
- RINGO_BIZ - RINGOBIZ
- GENIE_TV - GINITV
- INSTAGRAM - INSTAGRAM
- NAVER_BLOG - NAVER
- KAKAO_CHANNEL - KAKAO
example: "INSTAGRAM" example: "INSTAGRAM"
status: status:
type: string type: string
@ -569,7 +518,7 @@ components:
distributionId: distributionId:
type: string type: string
description: 채널별 배포 ID description: 채널별 배포 ID
example: "wtv-uuid-12345" example: "dist-uuid-12345"
estimatedViews: estimatedViews:
type: integer type: integer
description: 예상 노출 수 description: 예상 노출 수
@ -578,35 +527,35 @@ components:
type: string type: string
format: date-time format: date-time
description: 업데이트 완료 시각 description: 업데이트 완료 시각
example: "2025-11-01T09:00:00Z" example: "2025-11-01T09:00:00"
adId: adId:
type: string type: string
description: 광고 ID description: 광고 ID (지니TV)
example: "gtv-uuid-12345" example: "gtv-uuid-12345"
impressionSchedule: impressionSchedule:
type: array type: array
description: 노출 스케줄 description: 노출 스케줄 (지니TV)
items: items:
type: string type: string
example: example:
- "2025-11-01 18:00-20:00" - "2025-11-01 18:00-20:00"
postUrl: postUrl:
type: string type: string
description: 게시물 URL description: 게시물 URL (Instagram, Naver Blog)
example: "https://instagram.com/p/generated-post-id" example: "https://instagram.com/p/generated-post-id"
postId: postId:
type: string type: string
description: 게시물 ID description: 게시물 ID (Instagram)
example: "ig-post-12345" example: "ig-post-12345"
messageId: messageId:
type: string type: string
description: 메시지 ID description: 메시지 ID (Kakao Channel)
example: "kakao-msg-12345" example: "kakao-msg-12345"
completedAt: completedAt:
type: string type: string
format: date-time format: date-time
description: 완료 시각 description: 완료 시각
example: "2025-11-01T09:00:00Z" example: "2025-11-01T09:00:00"
errorMessage: errorMessage:
type: string type: string
description: 오류 메시지 description: 오류 메시지
@ -619,7 +568,7 @@ components:
type: string type: string
format: date-time format: date-time
description: 마지막 재시도 시각 description: 마지막 재시도 시각
example: "2025-11-01T08:59:30Z" example: "2025-11-01T08:59:30"
ErrorResponse: ErrorResponse:
type: object type: object

View File

@ -19,7 +19,7 @@ import org.springframework.web.bind.annotation.*;
*/ */
@Slf4j @Slf4j
@RestController @RestController
@RequestMapping("/api/distribution") @RequestMapping("/distribution")
@RequiredArgsConstructor @RequiredArgsConstructor
public class DistributionController { public class DistributionController {

View File

@ -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();
}
}

View File

@ -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<ChannelStatusEntity> 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);
}
}

View File

@ -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<ChannelStatusEntity> 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<ChannelStatus> 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<String>으로 변환
List<String> impressionScheduleList = null;
if (entity.getImpressionSchedule() != null && !entity.getImpressionSchedule().isEmpty()) {
try {
impressionScheduleList = objectMapper.readValue(
entity.getImpressionSchedule(),
new TypeReference<List<String>>() {}
);
} 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();
}
}

View File

@ -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<DistributionStatus, Long> {
/**
* 이벤트 ID로 배포 상태 조회
*
* @param eventId 이벤트 ID
* @return 배포 상태 (없으면 Optional.empty())
*/
Optional<DistributionStatus> 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<DistributionStatus> findByEventIdWithChannels(@Param("eventId") String eventId);
/**
* 이벤트 ID로 배포 상태 존재 여부 확인
*
* @param eventId 이벤트 ID
* @return 존재 여부
*/
boolean existsByEventId(String eventId);
/**
* 이벤트 ID로 배포 상태 삭제
*
* @param eventId 이벤트 ID
*/
void deleteByEventId(String eventId);
}

View File

@ -1,48 +1,78 @@
package com.kt.distribution.repository; package com.kt.distribution.repository;
import com.kt.distribution.dto.DistributionStatusResponse; 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 lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/** /**
* 배포 상태 저장소 * 배포 상태 저장소
* *
* 메모리 기반으로 배포 상태를 관리합니다. * PostgreSQL 데이터베이스를 사용하여 배포 상태를 영구 저장합니다.
* 실제 운영 환경에서는 Redis 또는 데이터베이스를 사용하여 영구 저장하는 것을 권장합니다. *
* @author Backend Developer
* @since 2025-10-24
*/ */
@Slf4j @Slf4j
@Repository @Repository
@RequiredArgsConstructor
public class DistributionStatusRepository { public class DistributionStatusRepository {
/** private final DistributionStatusJpaRepository jpaRepository;
* 이벤트 ID를 키로 배포 상태를 저장하는 메모리 저장소 private final DistributionStatusMapper mapper;
*/
private final Map<String, DistributionStatusResponse> distributionStatuses = new ConcurrentHashMap<>();
/** /**
* 배포 상태 저장 * 배포 상태 저장
* *
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
* @param status 배포 상태 * @param status 배포 상태 DTO
*/ */
@Transactional
public void save(String eventId, DistributionStatusResponse status) { public void save(String eventId, DistributionStatusResponse status) {
log.debug("Saving distribution status: eventId={}, overallStatus={}", eventId, status.getOverallStatus()); log.debug("Saving distribution status: eventId={}, overallStatus={}", eventId, status.getOverallStatus());
distributionStatuses.put(eventId, status);
// 기존 데이터가 있으면 업데이트, 없으면 새로 생성
Optional<DistributionStatus> 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 * @param eventId 이벤트 ID
* @return 배포 상태 (없으면 Optional.empty()) * @return 배포 상태 DTO (없으면 Optional.empty())
*/ */
@Transactional(readOnly = true)
public Optional<DistributionStatusResponse> findByEventId(String eventId) { public Optional<DistributionStatusResponse> findByEventId(String eventId) {
log.debug("Finding distribution status: eventId={}", 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 * @param eventId 이벤트 ID
*/ */
@Transactional
public void delete(String eventId) { public void delete(String eventId) {
log.debug("Deleting distribution status: eventId={}", eventId); log.debug("Deleting distribution status: eventId={}", eventId);
distributionStatuses.remove(eventId); jpaRepository.deleteByEventId(eventId);
} }
/** /**
* 모든 배포 상태 삭제 (테스트용) * 모든 배포 상태 삭제 (테스트용)
*/ */
@Transactional
public void deleteAll() { public void deleteAll() {
log.debug("Deleting all distribution statuses"); log.debug("Deleting all distribution statuses");
distributionStatuses.clear(); jpaRepository.deleteAll();
} }
} }

120
tools/check_tables.py Normal file
View File

@ -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())