mirror of
https://github.com/ktds-dg0501/kt-event-marketing-fe.git
synced 2025-12-06 06:16:24 +00:00
초기 프로젝트 설정 및 설계 문서 추가
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
commit
3f6e005026
102
CLAUDE.md
Normal file
102
CLAUDE.md
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
# 프론트엔드 가이드
|
||||||
|
|
||||||
|
[Git 연동]
|
||||||
|
|
||||||
|
- "pull" 명령어 입력 시 Git pull 명령을 수행하고 충돌이 있을 때 최신 파일로 병합 수행
|
||||||
|
- "push" 또는 "푸시" 명령어 입력 시 git add, commit, push를 수행
|
||||||
|
- Commit Message는 한글로 함
|
||||||
|
|
||||||
|
[URL링크 참조]
|
||||||
|
|
||||||
|
- URL링크는 WebFetch가 아닌 'curl {URL} > claude/{filename}'명령으로 저장
|
||||||
|
- 동일한 파일이 있으면 덮어 씀
|
||||||
|
- 'claude'디렉토리가 없으면 생성하고 다운로드
|
||||||
|
- 저장된 파일을 읽어 사용함
|
||||||
|
|
||||||
|
## 산출물 디렉토리
|
||||||
|
|
||||||
|
- 프로토타입: design/prototype/\*
|
||||||
|
- API명세서: design/api/\*.json
|
||||||
|
- UI/UX설계서: design/frontend/uiux-design.md
|
||||||
|
- 스타일가이드: design/frontend/style-guide.md
|
||||||
|
- 정보아키텍처: design/frontend/ia.md
|
||||||
|
- API매핑설계서: design/frontend/api-mapping.md
|
||||||
|
- 유저스토리: design/userstory.md
|
||||||
|
|
||||||
|
## 가이드
|
||||||
|
|
||||||
|
- 프론트엔드설계가이드
|
||||||
|
- 설명: 프론트엔드 설계 방법 안내
|
||||||
|
- URL: https://raw.githubusercontent.com/cna-bootcamp/clauding-guide/refs/heads/main/guides/design/frontend-design.md
|
||||||
|
- 파일명: frontend-design.md
|
||||||
|
- 프론트엔드개발가이드
|
||||||
|
- 설명: 프론트엔드 개발 가이드
|
||||||
|
- URL: https://raw.githubusercontent.com/cna-bootcamp/clauding-guide/refs/heads/main/guides/develop/dev-frontend.md
|
||||||
|
- 파일명: dev-frontend.md
|
||||||
|
- 프론트엔드컨테이너이미지작성가이드
|
||||||
|
- 설명: 프론트엔드 컨테이너 이미지 작성 가이드
|
||||||
|
- URL: https://raw.githubusercontent.com/cna-bootcamp/clauding-guide/refs/heads/main/guides/deploy/build-image-front.md
|
||||||
|
- 파일명: build-image-front.md
|
||||||
|
- 프론트엔드컨테이너실행방법가이드
|
||||||
|
- 설명: 프론트엔드 컨테이너 실행방법 가이드
|
||||||
|
- URL: https://raw.githubusercontent.com/cna-bootcamp/clauding-guide/refs/heads/main/guides/deploy/run-container-guide-front.md
|
||||||
|
- 파일명: run-container-guide-front.md
|
||||||
|
- 프론트엔드배포가이드
|
||||||
|
- 설명: 프론트엔드 서비스를 쿠버네티스 클러스터에 배포하는 가이드
|
||||||
|
- URL: https://raw.githubusercontent.com/cna-bootcamp/clauding-guide/refs/heads/main/guides/deploy/deploy-k8s-front.md
|
||||||
|
- 파일명: deploy-k8s-front.md
|
||||||
|
- 프론트엔드Jenkins파이프라인작성가이드
|
||||||
|
- 설명: 프론트엔드 서비스를 Jenkins를 이용하여 CI/CD하는 배포 가이드
|
||||||
|
- URL: https://raw.githubusercontent.com/cna-bootcamp/clauding-guide/refs/heads/main/guides/deploy/deploy-jenkins-cicd-front.md
|
||||||
|
- 파일명: deploy-jenkins-cicd-front.md
|
||||||
|
- 프론트엔드GitHubActions파이프라인작성가이드
|
||||||
|
- 설명: 프론트엔드 서비스를 GitHub Actions를 이용하여 CI/CD하는 배포 가이드
|
||||||
|
- URL: https://raw.githubusercontent.com/cna-bootcamp/clauding-guide/refs/heads/main/guides/deploy/deploy-actions-cicd-front.md
|
||||||
|
- 파일명: deploy-actions-cicd-front.md
|
||||||
|
|
||||||
|
## 프롬프트 약어
|
||||||
|
|
||||||
|
### 역할 약어
|
||||||
|
|
||||||
|
- "@front": "--persona-front"
|
||||||
|
- "@devops": "--persona-devops"
|
||||||
|
|
||||||
|
### 작업 약어
|
||||||
|
|
||||||
|
- "@complex-flag": --seq --c7 --uc --wave-mode auto --wave-strategy systematic --delegate auto
|
||||||
|
|
||||||
|
- "@plan": --plan --think
|
||||||
|
- "@dev-front": /sc:implement @front --think-hard @complex-flag
|
||||||
|
- "@cicd": /sc:implement @devops --think @complex-flag
|
||||||
|
- "@document": /sc:document --think @scribe @complex-flag
|
||||||
|
- "@fix": /sc:troubleshoot --think @complex-flag
|
||||||
|
- "@estimate": /sc:estimate --think-hard @complex-flag
|
||||||
|
- "@improve": /sc:improve --think @complex-flag
|
||||||
|
- "@analyze": /sc:analyze --think --seq
|
||||||
|
- "@explain": /sc:explain --think --seq --answer-only
|
||||||
|
|
||||||
|
## Lessons Learned
|
||||||
|
|
||||||
|
**프론트엔드 개발 절차**:
|
||||||
|
|
||||||
|
- 개발가이드의 "6. 각 페이지별 구현" 단계에서는 빌드 및 에러 해결까지만 수행
|
||||||
|
- 개발서버(`npm run dev`) 실행은 항상 사용자가 직접 수행
|
||||||
|
- 개발자는 빌드(`npm run build`) 성공까지만 확인하고 서버 실행을 사용자에게 요청
|
||||||
|
- 개발자가 임의로 서버를 실행하고 테스트하지 않고 사용자 확인 후 진행
|
||||||
|
|
||||||
|
**프로토타입 분석 및 테스트**:
|
||||||
|
|
||||||
|
- 프로토타입 HTML 파일은 반드시 Playwright MCP를 사용하여 모바일 화면(375x812)에서 확인
|
||||||
|
- 프로토타입의 모든 인터랙션과 액션을 실제로 클릭하여 동작 확인 필요
|
||||||
|
|
||||||
|
**서비스 재배포 가이드**
|
||||||
|
서비스 수정 후 재배포 시 다음 절차를 따릅니다:
|
||||||
|
|
||||||
|
1. 이미지 빌드: deployment/container/build-image.md 참조하여 빌드
|
||||||
|
2. 이미지를 ACR형식으로 태깅
|
||||||
|
3. 컨테이너 실행: deployment/container/run-container-guide.md의 '8. 재배포 방법' 참조하여 실행
|
||||||
|
- 컨테이너 중단
|
||||||
|
- 이미지 삭제
|
||||||
|
- 컨테이너 실행
|
||||||
|
|
||||||
|
- 테스트는 사용자에게 요청
|
||||||
BIN
design/.DS_Store
vendored
Normal file
BIN
design/.DS_Store
vendored
Normal file
Binary file not shown.
1938
design/aidata/매장_프로필_데이터.md
Normal file
1938
design/aidata/매장_프로필_데이터.md
Normal file
File diff suppressed because it is too large
Load Diff
564
design/aidata/시즌별_이벤트_성과_데이터.md
Normal file
564
design/aidata/시즌별_이벤트_성과_데이터.md
Normal file
@ -0,0 +1,564 @@
|
|||||||
|
# 시즌별 이벤트 성과 데이터
|
||||||
|
|
||||||
|
**작성일**: 2025-01-21
|
||||||
|
**목적**: AI 이벤트 추천 시스템 학습 데이터
|
||||||
|
**데이터 구성**: 3개 시즌 카테고리 × 10개 샘플 = 총 30개
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 데이터 구조 설명
|
||||||
|
|
||||||
|
본 데이터는 AI 기반 이벤트 추천 시스템의 학습 데이터로 활용됩니다. 각 이벤트 샘플은 실제 성과 데이터를 기반으로 작성되었으며, 다음 정보를 포함합니다:
|
||||||
|
|
||||||
|
- **이벤트 제목**: 실제 진행된 이벤트명
|
||||||
|
- **업종**: 음식점, 카페, 베이커리, 치킨집 등
|
||||||
|
- **지역**: 서울, 경기, 부산 등 주요 지역
|
||||||
|
- **시즌**: 진행 시기 (명절, 계절, 기념일 등)
|
||||||
|
- **이벤트 유형**: online(온라인) / offline(오프라인)
|
||||||
|
- **예산 범위**: low(저예산), medium(중예산), high(고예산)
|
||||||
|
- **경품/혜택**: 제공한 인센티브
|
||||||
|
- **참여 방법**: 고객 참여 프로세스
|
||||||
|
- **실제 참여자 수**: 이벤트 참여 고객 수
|
||||||
|
- **실제 비용**: 총 이벤트 운영 비용 (원)
|
||||||
|
- **실제 ROI**: 투자대비수익률 (%)
|
||||||
|
- **진행 기간**: 이벤트 시작-종료일
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 카테고리 1: 한국 전통 명절 & 국가 공휴일 시즌
|
||||||
|
|
||||||
|
### 1. 설날 특선 메뉴 이벤트
|
||||||
|
- **이벤트 제목**: 설날 한정 떡국 세트 할인
|
||||||
|
- **업종**: 한식당
|
||||||
|
- **지역**: 서울 강남구
|
||||||
|
- **시즌**: 설날 (1월 말~2월 초)
|
||||||
|
- **이벤트 유형**: offline
|
||||||
|
- **예산 범위**: medium
|
||||||
|
- **경품/혜택**: 떡국 세트 20% 할인
|
||||||
|
- **참여 방법**: 매장 방문 시 자동 적용
|
||||||
|
- **실제 참여자 수**: 285명
|
||||||
|
- **실제 비용**: 1,850,000원
|
||||||
|
- **실제 ROI**: 420%
|
||||||
|
- **진행 기간**: 2024-01-20 ~ 2024-02-05
|
||||||
|
- **성공 요인**: 전통 명절 음식 수요, 가족 단위 외식 증가
|
||||||
|
- **참고 사항**: 명절 3일 전부터 예약 주문 집중, 포장 주문 45%
|
||||||
|
|
||||||
|
### 2. 추석 선물 세트 사전 예약
|
||||||
|
- **이벤트 제목**: 추석 한정식 선물 세트 온라인 사전 주문
|
||||||
|
- **업종**: 한식당
|
||||||
|
- **지역**: 경기 성남시
|
||||||
|
- **시즌**: 추석 (9월)
|
||||||
|
- **이벤트 유형**: online
|
||||||
|
- **예산 범위**: high
|
||||||
|
- **경품/혜택**: 사전 주문 시 15% 할인 + 배송비 무료
|
||||||
|
- **참여 방법**: 온라인 주문 페이지에서 예약 결제
|
||||||
|
- **실제 참여자 수**: 420명
|
||||||
|
- **실제 비용**: 5,200,000원
|
||||||
|
- **실제 ROI**: 380%
|
||||||
|
- **진행 기간**: 2024-08-25 ~ 2024-09-10
|
||||||
|
- **성공 요인**: 선물 수요, 사전 예약으로 재고 관리 용이
|
||||||
|
- **참고 사항**: 지인 추천 이벤트 병행으로 신규 고객 35% 유입
|
||||||
|
|
||||||
|
### 3. 크리스마스 커플 세트 프로모션
|
||||||
|
- **이벤트 제목**: 크리스마스 커플 디너 특가
|
||||||
|
- **업종**: 양식당
|
||||||
|
- **지역**: 서울 홍대
|
||||||
|
- **시즌**: 크리스마스 (12월)
|
||||||
|
- **이벤트 유형**: offline
|
||||||
|
- **예산 범위**: high
|
||||||
|
- **경품/혜택**: 커플 디너 코스 30% 할인
|
||||||
|
- **참여 방법**: 전화/온라인 예약 후 방문
|
||||||
|
- **실제 참여자 수**: 320명 (160커플)
|
||||||
|
- **실제 비용**: 6,400,000원
|
||||||
|
- **실제 ROI**: 285%
|
||||||
|
- **진행 기간**: 2024-12-20 ~ 2024-12-26
|
||||||
|
- **성공 요인**: 연인 데이트 수요, 프리미엄 경험 제공
|
||||||
|
- **참고 사항**: 인스타그램 감성 인테리어로 SNS 자발적 확산
|
||||||
|
|
||||||
|
### 4. 신정 새해 첫 방문 쿠폰
|
||||||
|
- **이벤트 제목**: 새해 첫 방문 고객 음료 무료
|
||||||
|
- **업종**: 카페
|
||||||
|
- **지역**: 부산 해운대구
|
||||||
|
- **시즌**: 신정 (1월 1일~3일)
|
||||||
|
- **이벤트 유형**: offline
|
||||||
|
- **예산 범위**: low
|
||||||
|
- **경품/혜택**: 아메리카노 무료 제공
|
||||||
|
- **참여 방법**: 매장 방문 시 자동 제공
|
||||||
|
- **실제 참여자 수**: 220명
|
||||||
|
- **실제 비용**: 280,000원
|
||||||
|
- **실제 ROI**: 550%
|
||||||
|
- **진행 기간**: 2025-01-01 ~ 2025-01-03
|
||||||
|
- **성공 요인**: 새해 첫날 특별한 경험, SNS 공유 유도
|
||||||
|
- **참고 사항**: 해운대 해돋이 방문객 유입 효과
|
||||||
|
|
||||||
|
### 5. 어린이날 가족 패키지 이벤트
|
||||||
|
- **이벤트 제목**: 어린이날 키즈 메뉴 무료 + 가족 할인
|
||||||
|
- **업종**: 패밀리 레스토랑
|
||||||
|
- **지역**: 경기 수원시
|
||||||
|
- **시즌**: 어린이날 (5월 5일 전후)
|
||||||
|
- **이벤트 유형**: offline
|
||||||
|
- **예산 범위**: medium
|
||||||
|
- **경품/혜택**: 어린이 메뉴 1인 무료 + 성인 메뉴 15% 할인
|
||||||
|
- **참여 방법**: 가족 단위 방문 시 자동 적용
|
||||||
|
- **실제 참여자 수**: 580명
|
||||||
|
- **실제 비용**: 2,100,000원
|
||||||
|
- **실제 ROI**: 485%
|
||||||
|
- **진행 기간**: 2024-05-03 ~ 2024-05-07
|
||||||
|
- **성공 요인**: 황금연휴, 가족 외식 수요 폭발
|
||||||
|
- **참고 사항**: 어린이 경품(장난감) 증정으로 만족도 상승
|
||||||
|
|
||||||
|
### 6. 어버이날 SNS 인증 이벤트
|
||||||
|
- **이벤트 제목**: 부모님과 함께 인증샷 이벤트
|
||||||
|
- **업종**: 한식당
|
||||||
|
- **지역**: 서울 강서구
|
||||||
|
- **시즌**: 어버이날 (5월 8일)
|
||||||
|
- **이벤트 유형**: online
|
||||||
|
- **예산 범위**: low
|
||||||
|
- **경품/혜택**: 인스타그램 인증 시 디저트 무료
|
||||||
|
- **참여 방법**: 부모님과 식사 사진 + 해시태그 + 매장 태그
|
||||||
|
- **실제 참여자 수**: 185명
|
||||||
|
- **실제 비용**: 320,000원
|
||||||
|
- **실제 ROI**: 520%
|
||||||
|
- **진행 기간**: 2024-05-05 ~ 2024-05-12
|
||||||
|
- **성공 요인**: SNS 확산, 가족 외식 트렌드
|
||||||
|
- **참고 사항**: 인스타그램 팔로워 240명 증가
|
||||||
|
|
||||||
|
### 7. 크리스마스 온라인 주문 할인
|
||||||
|
- **이벤트 제목**: 크리스마스 홈파티 치킨 배달 특가
|
||||||
|
- **업종**: 치킨집
|
||||||
|
- **지역**: 서울 송파구
|
||||||
|
- **시즌**: 크리스마스 (12월)
|
||||||
|
- **이벤트 유형**: online
|
||||||
|
- **예산 범위**: medium
|
||||||
|
- **경품/혜택**: 2마리 이상 주문 시 25% 할인
|
||||||
|
- **참여 방법**: 배달앱을 통한 온라인 주문
|
||||||
|
- **실제 참여자 수**: 340명
|
||||||
|
- **실제 비용**: 1,680,000원
|
||||||
|
- **실제 ROI**: 395%
|
||||||
|
- **진행 기간**: 2024-12-20 ~ 2024-12-25
|
||||||
|
- **성공 요인**: 홈파티 수요, 배달 편의성
|
||||||
|
- **참고 사항**: 배달앱 쿠폰 추가 발행으로 신규 고객 40% 유입
|
||||||
|
|
||||||
|
### 8. 광복절 애국 테마 이벤트
|
||||||
|
- **이벤트 제목**: 광복절 태극기 인증 할인
|
||||||
|
- **업종**: 카페
|
||||||
|
- **지역**: 서울 종로구
|
||||||
|
- **시즌**: 광복절 (8월 15일)
|
||||||
|
- **이벤트 유형**: offline
|
||||||
|
- **예산 범위**: low
|
||||||
|
- **경품/혜택**: 태극기 옷/소품 착용 시 음료 20% 할인
|
||||||
|
- **참여 방법**: 매장 방문 시 태극기 착용 확인
|
||||||
|
- **실제 참여자 수**: 95명
|
||||||
|
- **실제 비용**: 150,000원
|
||||||
|
- **실제 ROI**: 380%
|
||||||
|
- **진행 기간**: 2024-08-15 ~ 2024-08-15
|
||||||
|
- **성공 요인**: 애국심 테마, SNS 화제성
|
||||||
|
- **참고 사항**: 지역 특성상 관광객 유입 효과
|
||||||
|
|
||||||
|
### 9. 개천절 가을 한정 메뉴 출시
|
||||||
|
- **이벤트 제목**: 가을 단풍 시즌 신메뉴 시식 이벤트
|
||||||
|
- **업종**: 베이커리 카페
|
||||||
|
- **지역**: 경기 남양주시
|
||||||
|
- **시즌**: 개천절 (10월 초)
|
||||||
|
- **이벤트 유형**: offline
|
||||||
|
- **예산 범위**: medium
|
||||||
|
- **경품/혜택**: 신메뉴 밤 크림빵 시식 + 구매 시 15% 할인
|
||||||
|
- **참여 방법**: 매장 방문 시 시식 후 구매
|
||||||
|
- **실제 참여자 수**: 420명
|
||||||
|
- **실제 비용**: 1,250,000원
|
||||||
|
- **실제 ROI**: 440%
|
||||||
|
- **진행 기간**: 2024-10-01 ~ 2024-10-05
|
||||||
|
- **성공 요인**: 가을 제철 재료, 단풍 관광객
|
||||||
|
- **참고 사항**: 포장 선물용 구매 비율 60%
|
||||||
|
|
||||||
|
### 10. 한글날 한국어 이벤트
|
||||||
|
- **이벤트 제목**: 한글날 메뉴명 한글로 말하기 이벤트
|
||||||
|
- **업종**: 카페
|
||||||
|
- **지역**: 서울 강남구
|
||||||
|
- **시즌**: 한글날 (10월 9일)
|
||||||
|
- **이벤트 유형**: offline
|
||||||
|
- **예산 범위**: low
|
||||||
|
- **경품/혜택**: 메뉴를 한글로 주문 시 사이즈 업그레이드
|
||||||
|
- **참여 방법**: 주문 시 한글 메뉴명으로 말하기
|
||||||
|
- **실제 참여자 수**: 280명
|
||||||
|
- **실제 비용**: 180,000원
|
||||||
|
- **실제 ROI**: 610%
|
||||||
|
- **진행 기간**: 2024-10-09 ~ 2024-10-09
|
||||||
|
- **성공 요인**: 참여 장벽 낮음, 재미 요소
|
||||||
|
- **참고 사항**: SNS 인증샷 자발적 공유 다수
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 카테고리 2: 계절별 시즌
|
||||||
|
|
||||||
|
### 11. 봄 벚꽃 시즌 포장 도시락
|
||||||
|
- **이벤트 제목**: 벚꽃놀이 피크닉 도시락 세트
|
||||||
|
- **업종**: 한식당
|
||||||
|
- **지역**: 서울 여의도
|
||||||
|
- **시즌**: 봄 (4월 벚꽃 시즌)
|
||||||
|
- **이벤트 유형**: offline
|
||||||
|
- **예산 범위**: medium
|
||||||
|
- **경품/혜택**: 2인 도시락 세트 20,000원 (정상가 대비 25% 할인)
|
||||||
|
- **참여 방법**: 전화 사전 주문 또는 매장 방문 구매
|
||||||
|
- **실제 참여자 수**: 520명
|
||||||
|
- **실제 비용**: 1,950,000원
|
||||||
|
- **실제 ROI**: 450%
|
||||||
|
- **진행 기간**: 2024-04-01 ~ 2024-04-10
|
||||||
|
- **성공 요인**: 벚꽃 축제 관광객, 야외 식사 수요
|
||||||
|
- **참고 사항**: 여의도 벚꽃 축제 기간 집중 매출
|
||||||
|
|
||||||
|
### 12. 여름 보양식 복날 이벤트
|
||||||
|
- **이벤트 제목**: 삼계탕 복날 특가 프로모션
|
||||||
|
- **업종**: 한식당
|
||||||
|
- **지역**: 경기 고양시
|
||||||
|
- **시즌**: 여름 (초복/중복/말복)
|
||||||
|
- **이벤트 유형**: offline
|
||||||
|
- **예산 범위**: low
|
||||||
|
- **경품/혜택**: 삼계탕 주문 시 음료수 무료 제공
|
||||||
|
- **참여 방법**: 매장 방문 주문
|
||||||
|
- **실제 참여자 수**: 385명
|
||||||
|
- **실제 비용**: 420,000원
|
||||||
|
- **실제 ROI**: 580%
|
||||||
|
- **진행 기간**: 2024-07-15 ~ 2024-08-14 (복날 3일간)
|
||||||
|
- **성공 요인**: 전통 보양식 수요, 복날 문화
|
||||||
|
- **참고 사항**: 예약 고객 80%, 당일 방문 20%
|
||||||
|
|
||||||
|
### 13. 가을 전어 축제 이벤트
|
||||||
|
- **이벤트 제목**: 가을 제철 전어 구이 할인
|
||||||
|
- **업종**: 해산물 음식점
|
||||||
|
- **지역**: 부산 광안리
|
||||||
|
- **시즌**: 가을 (9월~10월)
|
||||||
|
- **이벤트 유형**: offline
|
||||||
|
- **예산 범위**: medium
|
||||||
|
- **경품/혜택**: 전어 구이 정식 30% 할인
|
||||||
|
- **참여 방법**: 매장 방문 시 자동 적용
|
||||||
|
- **실제 참여자 수**: 450명
|
||||||
|
- **실제 비용**: 2,350,000원
|
||||||
|
- **실제 ROI**: 410%
|
||||||
|
- **진행 기간**: 2024-09-15 ~ 2024-10-31
|
||||||
|
- **성공 요인**: 제철 재료, 가을 단풍 관광객
|
||||||
|
- **참고 사항**: 주말 예약 필수, 평일 당일 가능
|
||||||
|
|
||||||
|
### 14. 겨울 따뜻한 메뉴 프로모션
|
||||||
|
- **이벤트 제목**: 겨울 한파 특보 찌개 세트 할인
|
||||||
|
- **업종**: 한식당
|
||||||
|
- **지역**: 서울 마포구
|
||||||
|
- **시즌**: 겨울 (12월~2월 한파 기간)
|
||||||
|
- **이벤트 유형**: offline
|
||||||
|
- **예산 범위**: medium
|
||||||
|
- **경품/혜택**: 찌개류 주문 시 공기밥 무료 + 10% 할인
|
||||||
|
- **참여 방법**: 매장 방문 주문
|
||||||
|
- **실제 참여자 수**: 620명
|
||||||
|
- **실제 비용**: 1,850,000원
|
||||||
|
- **실제 ROI**: 520%
|
||||||
|
- **진행 기간**: 2024-12-15 ~ 2025-02-28
|
||||||
|
- **성공 요인**: 한파 특보 타이밍, 따뜻한 음식 수요
|
||||||
|
- **참고 사항**: 날씨가 추울수록 매출 증가 (날씨 연동 마케팅)
|
||||||
|
|
||||||
|
### 15. 봄 신학기 학생 할인
|
||||||
|
- **이벤트 제목**: 신학기 학생 응원 카페 음료 할인
|
||||||
|
- **업종**: 카페
|
||||||
|
- **지역**: 서울 신촌
|
||||||
|
- **시즌**: 봄 (3월 개학 시즌)
|
||||||
|
- **이벤트 유형**: offline
|
||||||
|
- **예산 범위**: low
|
||||||
|
- **경품/혜택**: 학생증 제시 시 음료 20% 할인
|
||||||
|
- **참여 방법**: 주문 시 학생증 제시
|
||||||
|
- **실제 참여자 수**: 580명
|
||||||
|
- **실제 비용**: 450,000원
|
||||||
|
- **실제 ROI**: 620%
|
||||||
|
- **진행 기간**: 2024-03-01 ~ 2024-03-31
|
||||||
|
- **성공 요인**: 대학가 상권, 신학기 모임 증가
|
||||||
|
- **참고 사항**: 스터디 그룹 단체 할인 병행
|
||||||
|
|
||||||
|
### 16. 여름 빙수 시즌 SNS 이벤트
|
||||||
|
- **이벤트 제목**: 인스타그램 빙수 인증샷 이벤트
|
||||||
|
- **업종**: 카페
|
||||||
|
- **지역**: 경기 의정부시
|
||||||
|
- **시즌**: 여름 (7월~8월)
|
||||||
|
- **이벤트 유형**: online
|
||||||
|
- **예산 범위**: low
|
||||||
|
- **경품/혜택**: 인스타그램 스토리 태그 시 다음 방문 음료 무료
|
||||||
|
- **참여 방법**: 빙수 사진 + 매장 태그 + 해시태그
|
||||||
|
- **실제 참여자 수**: 340명
|
||||||
|
- **실제 비용**: 380,000원
|
||||||
|
- **실제 ROI**: 560%
|
||||||
|
- **진행 기간**: 2024-07-01 ~ 2024-08-31
|
||||||
|
- **성공 요인**: SNS 확산, 여름 한정 메뉴
|
||||||
|
- **참고 사항**: 인스타그램 팔로워 420명 증가
|
||||||
|
|
||||||
|
### 17. 가을 단풍 시즌 배달 이벤트
|
||||||
|
- **이벤트 제목**: 단풍놀이 후 배달 주문 할인
|
||||||
|
- **업종**: 치킨집
|
||||||
|
- **지역**: 경기 가평군
|
||||||
|
- **시즌**: 가을 (10월~11월 단풍 시즌)
|
||||||
|
- **이벤트 유형**: online
|
||||||
|
- **예산 범위**: medium
|
||||||
|
- **경품/혜택**: 2만원 이상 주문 시 배달비 무료 + 음료 2병 증정
|
||||||
|
- **참여 방법**: 배달앱 또는 전화 주문
|
||||||
|
- **실제 참여자 수**: 280명
|
||||||
|
- **실제 비용**: 980,000원
|
||||||
|
- **실제 ROI**: 420%
|
||||||
|
- **진행 기간**: 2024-10-10 ~ 2024-11-15
|
||||||
|
- **성공 요인**: 단풍 관광객 타겟, 펜션/숙박 시설 배달
|
||||||
|
- **참고 사항**: 주말 주문 집중 (금~일 70%)
|
||||||
|
|
||||||
|
### 18. 겨울 크리스마스 디저트 출시
|
||||||
|
- **이벤트 제목**: 크리스마스 한정 케이크 사전 예약
|
||||||
|
- **업종**: 베이커리
|
||||||
|
- **지역**: 서울 강남구
|
||||||
|
- **시즌**: 겨울 (12월 크리스마스)
|
||||||
|
- **이벤트 유형**: online
|
||||||
|
- **예산 범위**: high
|
||||||
|
- **경품/혜택**: 사전 예약 시 15% 할인 + 무료 픽업
|
||||||
|
- **참여 방법**: 온라인 사전 주문
|
||||||
|
- **실제 참여자 수**: 520명
|
||||||
|
- **실제 비용**: 4,800,000원
|
||||||
|
- **실제 ROI**: 340%
|
||||||
|
- **진행 기간**: 2024-12-01 ~ 2024-12-24
|
||||||
|
- **성공 요인**: 크리스마스 케이크 필수 수요, 사전 예약 재고 관리
|
||||||
|
- **참고 사항**: 12월 20일 이후 주문 마감
|
||||||
|
|
||||||
|
### 19. 봄 새학기 MT 단체 주문
|
||||||
|
- **이벤트 제목**: 대학생 MT 단체 주문 특가
|
||||||
|
- **업종**: 치킨집
|
||||||
|
- **지역**: 강원도 춘천시
|
||||||
|
- **시즌**: 봄 (3월~4월 MT 시즌)
|
||||||
|
- **이벤트 유형**: offline
|
||||||
|
- **예산 범위**: medium
|
||||||
|
- **경품/혜택**: 10마리 이상 주문 시 20% 할인 + 배달비 무료
|
||||||
|
- **참여 방법**: 전화 사전 예약
|
||||||
|
- **실제 참여자 수**: 180명 (18건 단체 주문)
|
||||||
|
- **실제 비용**: 1,520,000원
|
||||||
|
- **실제 ROI**: 480%
|
||||||
|
- **진행 기간**: 2024-03-10 ~ 2024-04-30
|
||||||
|
- **성공 요인**: MT 시즌 수요, 단체 할인 매력
|
||||||
|
- **참고 사항**: 펜션/민박 배달 집중
|
||||||
|
|
||||||
|
### 20. 여름 휴가 시즌 포장 할인
|
||||||
|
- **이벤트 제목**: 여름 휴가 포장 주문 특가
|
||||||
|
- **업종**: 한식당
|
||||||
|
- **지역**: 강원도 속초시
|
||||||
|
- **시즌**: 여름 (7월~8월 휴가 시즌)
|
||||||
|
- **이벤트 유형**: offline
|
||||||
|
- **예산 범위**: medium
|
||||||
|
- **경품/혜택**: 포장 주문 시 10% 할인 + 반찬 추가 증정
|
||||||
|
- **참여 방법**: 매장 방문 또는 전화 주문
|
||||||
|
- **실제 참여자 수**: 680명
|
||||||
|
- **실제 비용**: 2,150,000원
|
||||||
|
- **실제 ROI**: 510%
|
||||||
|
- **진행 기간**: 2024-07-20 ~ 2024-08-25
|
||||||
|
- **성공 요인**: 속초 관광객, 숙소 내 식사 수요
|
||||||
|
- **참고 사항**: 저녁 시간대 주문 집중 (17~20시)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 카테고리 3: 상업적 기념일 & 월별 특화 시즌
|
||||||
|
|
||||||
|
### 21. 발렌타인데이 커플 세트
|
||||||
|
- **이벤트 제목**: 발렌타인데이 커플 디저트 세트
|
||||||
|
- **업종**: 카페
|
||||||
|
- **지역**: 서울 홍대
|
||||||
|
- **시즌**: 발렌타인데이 (2월 14일)
|
||||||
|
- **이벤트 유형**: offline
|
||||||
|
- **예산 범위**: medium
|
||||||
|
- **경품/혜택**: 커플 디저트 세트 25% 할인
|
||||||
|
- **참여 방법**: 2인 이상 방문 시 자동 적용
|
||||||
|
- **실제 참여자 수**: 380명
|
||||||
|
- **실제 비용**: 1,450,000원
|
||||||
|
- **실제 ROI**: 440%
|
||||||
|
- **진행 기간**: 2024-02-10 ~ 2024-02-14
|
||||||
|
- **성공 요인**: 연인 데이트 수요, 감성 마케팅
|
||||||
|
- **참고 사항**: 인스타그램 감성 세팅으로 SNS 확산
|
||||||
|
|
||||||
|
### 22. 화이트데이 남성 고객 이벤트
|
||||||
|
- **이벤트 제목**: 화이트데이 남성 고객 사탕 증정
|
||||||
|
- **업종**: 베이커리
|
||||||
|
- **지역**: 경기 분당구
|
||||||
|
- **시즌**: 화이트데이 (3월 14일)
|
||||||
|
- **이벤트 유형**: offline
|
||||||
|
- **예산 범위**: low
|
||||||
|
- **경품/혜택**: 남성 고객 케이크 구매 시 사탕 세트 무료
|
||||||
|
- **참여 방법**: 매장 방문 구매
|
||||||
|
- **실제 참여자 수**: 240명
|
||||||
|
- **실제 비용**: 380,000원
|
||||||
|
- **실제 ROI**: 520%
|
||||||
|
- **진행 기간**: 2024-03-10 ~ 2024-03-14
|
||||||
|
- **성공 요인**: 답례 선물 수요, 남성 타겟 마케팅
|
||||||
|
- **참고 사항**: 사전 예약 고객 60%
|
||||||
|
|
||||||
|
### 23. 블랙데이 1인 메뉴 특가
|
||||||
|
- **이벤트 제목**: 블랙데이 짜장면 할인 이벤트
|
||||||
|
- **업종**: 중식당
|
||||||
|
- **지역**: 서울 신촌
|
||||||
|
- **시즌**: 블랙데이 (4월 14일)
|
||||||
|
- **이벤트 유형**: offline
|
||||||
|
- **예산 범위**: low
|
||||||
|
- **경품/혜택**: 짜장면/짬뽕 1인 메뉴 30% 할인
|
||||||
|
- **참여 방법**: 1인 방문 고객 자동 적용
|
||||||
|
- **실제 참여자 수**: 185명
|
||||||
|
- **실제 비용**: 320,000원
|
||||||
|
- **실제 ROI**: 470%
|
||||||
|
- **진행 기간**: 2024-04-14 ~ 2024-04-14
|
||||||
|
- **성공 요인**: 솔로 고객 공감, 재미 요소
|
||||||
|
- **참고 사항**: SNS 바이럴 효과 (대학생 타겟)
|
||||||
|
|
||||||
|
### 24. 빼빼로데이 학생 이벤트
|
||||||
|
- **이벤트 제목**: 빼빼로데이 꼬치 메뉴 할인
|
||||||
|
- **업종**: 분식집
|
||||||
|
- **지역**: 서울 강서구
|
||||||
|
- **시즌**: 빼빼로데이 (11월 11일)
|
||||||
|
- **이벤트 유형**: offline
|
||||||
|
- **예산 범위**: low
|
||||||
|
- **경품/혜택**: 떡꼬치/어묵 등 길쭉한 메뉴 20% 할인
|
||||||
|
- **참여 방법**: 학생증 제시 시 적용
|
||||||
|
- **실제 참여자 수**: 320명
|
||||||
|
- **실제 비용**: 280,000원
|
||||||
|
- **실제 ROI**: 580%
|
||||||
|
- **진행 기간**: 2024-11-09 ~ 2024-11-11
|
||||||
|
- **성공 요인**: 학생 타겟, 재미있는 컨셉
|
||||||
|
- **참고 사항**: 학교 근처 상권 효과
|
||||||
|
|
||||||
|
### 25. 블랙프라이데이 파격 할인
|
||||||
|
- **이벤트 제목**: 블랙프라이데이 50% 파격 할인
|
||||||
|
- **업종**: 치킨집
|
||||||
|
- **지역**: 경기 안양시
|
||||||
|
- **시즌**: 블랙프라이데이 (11월 넷째 주 금요일)
|
||||||
|
- **이벤트 유형**: online
|
||||||
|
- **예산 범위**: high
|
||||||
|
- **경품/혜택**: 선착순 50명 치킨 50% 할인
|
||||||
|
- **참여 방법**: 배달앱 선착순 주문
|
||||||
|
- **실제 참여자 수**: 50명 (조기 마감)
|
||||||
|
- **실제 비용**: 850,000원
|
||||||
|
- **실제 ROI**: 380%
|
||||||
|
- **진행 기간**: 2024-11-29 ~ 2024-11-29
|
||||||
|
- **성공 요인**: 희소성 마케팅, 파격 할인율
|
||||||
|
- **참고 사항**: 2시간 만에 완판, SNS 화제
|
||||||
|
|
||||||
|
### 26. 월별 생일 축하 이벤트
|
||||||
|
- **이벤트 제목**: 생일 고객 케이크 무료 증정
|
||||||
|
- **업종**: 레스토랑
|
||||||
|
- **지역**: 서울 용산구
|
||||||
|
- **시즌**: 연중 (매월)
|
||||||
|
- **이벤트 유형**: offline
|
||||||
|
- **예산 범위**: medium
|
||||||
|
- **경품/혜택**: 생일 당일 방문 시 미니 케이크 무료
|
||||||
|
- **참여 방법**: 신분증 제시 또는 사전 예약 시 생일 정보 제공
|
||||||
|
- **실제 참여자 수**: 420명 (월 35명 평균)
|
||||||
|
- **실제 비용**: 1,680,000원 (연간)
|
||||||
|
- **실제 ROI**: 550%
|
||||||
|
- **진행 기간**: 2024-01-01 ~ 2024-12-31
|
||||||
|
- **성공 요인**: 특별한 날 경험, 재방문 유도
|
||||||
|
- **참고 사항**: 생일 고객의 재방문율 70%
|
||||||
|
|
||||||
|
### 27. 삼겹살데이 고깃집 프로모션
|
||||||
|
- **이벤트 제목**: 3월 3일 삼겹살 무한리필
|
||||||
|
- **업종**: 고깃집
|
||||||
|
- **지역**: 서울 강남구
|
||||||
|
- **시즌**: 삼겹살데이 (3월 3일)
|
||||||
|
- **이벤트 유형**: offline
|
||||||
|
- **예산 범위**: high
|
||||||
|
- **경품/혜택**: 삼겹살 무한리필 특가 (1인 19,900원)
|
||||||
|
- **참여 방법**: 매장 방문 (예약 필수)
|
||||||
|
- **실제 참여자 수**: 520명
|
||||||
|
- **실제 비용**: 5,800,000원
|
||||||
|
- **실제 ROI**: 320%
|
||||||
|
- **진행 기간**: 2024-03-03 ~ 2024-03-03
|
||||||
|
- **성공 요인**: 기념일 특화, 무한리필 매력
|
||||||
|
- **참고 사항**: 예약 1주 전 마감
|
||||||
|
|
||||||
|
### 28. 치킨데이 배달 프로모션
|
||||||
|
- **이벤트 제목**: 매월 셋째 주 목요일 치킨 할인
|
||||||
|
- **업종**: 치킨집
|
||||||
|
- **지역**: 경기 성남시
|
||||||
|
- **시즌**: 매월 셋째 주 목요일
|
||||||
|
- **이벤트 유형**: online
|
||||||
|
- **예산 범위**: medium
|
||||||
|
- **경품/혜택**: 치킨 주문 시 음료수 2L 무료
|
||||||
|
- **참여 방법**: 배달앱 또는 전화 주문
|
||||||
|
- **실제 참여자 수**: 1,680명 (연간, 월 140명 평균)
|
||||||
|
- **실제 비용**: 2,520,000원 (연간)
|
||||||
|
- **실제 ROI**: 480%
|
||||||
|
- **진행 기간**: 2024-01-01 ~ 2024-12-31
|
||||||
|
- **성공 요인**: 정기 이벤트로 고객 학습 효과
|
||||||
|
- **참고 사항**: 목요일 매출 35% 증가
|
||||||
|
|
||||||
|
### 29. 커피데이 카페 프로모션
|
||||||
|
- **이벤트 제목**: 10월 1일 커피의 날 아메리카노 반값
|
||||||
|
- **업종**: 카페
|
||||||
|
- **지역**: 부산 서면
|
||||||
|
- **시즌**: 커피의 날 (10월 1일)
|
||||||
|
- **이벤트 유형**: offline
|
||||||
|
- **예산 범위**: medium
|
||||||
|
- **경품/혜택**: 아메리카노 50% 할인
|
||||||
|
- **참여 방법**: 매장 방문 주문
|
||||||
|
- **실제 참여자 수**: 680명
|
||||||
|
- **실제 비용**: 1,350,000원
|
||||||
|
- **실제 ROI**: 520%
|
||||||
|
- **진행 기간**: 2024-10-01 ~ 2024-10-01
|
||||||
|
- **성공 요인**: 커피 애호가 타겟, 파격 할인
|
||||||
|
- **참고 사항**: 1인 최대 2잔 제한
|
||||||
|
|
||||||
|
### 30. 빵의 날 베이커리 이벤트
|
||||||
|
- **이벤트 제목**: 매월 3일 빵 1+1 이벤트
|
||||||
|
- **업종**: 베이커리
|
||||||
|
- **지역**: 서울 마포구
|
||||||
|
- **시즌**: 매월 3일
|
||||||
|
- **이벤트 유형**: offline
|
||||||
|
- **예산 범위**: medium
|
||||||
|
- **경품/혜택**: 빵 1개 구매 시 1개 추가 (동일 상품)
|
||||||
|
- **참여 방법**: 매장 방문 구매
|
||||||
|
- **실제 참여자 수**: 1,920명 (연간, 월 160명 평균)
|
||||||
|
- **실제 비용**: 2,880,000원 (연간)
|
||||||
|
- **실제 ROI**: 450%
|
||||||
|
- **진행 기간**: 2024-01-03 ~ 2024-12-03
|
||||||
|
- **성공 요인**: 정기 이벤트, 1+1 매력
|
||||||
|
- **참고 사항**: 선착순 200개 한정 (매월)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 데이터 활용 가이드
|
||||||
|
|
||||||
|
### AI 학습을 위한 주요 패턴
|
||||||
|
|
||||||
|
1. **시즌별 특성**
|
||||||
|
- 명절: 가족 단위, 전통 음식, 선물 수요
|
||||||
|
- 계절: 날씨 연동, 제철 재료, 관광 수요
|
||||||
|
- 기념일: 타겟 세분화, 감성 마케팅, SNS 활용
|
||||||
|
|
||||||
|
2. **예산별 ROI 패턴**
|
||||||
|
- 저예산 (30-50만원): 평균 ROI 550% (높은 효율)
|
||||||
|
- 중예산 (100-200만원): 평균 ROI 450% (안정적)
|
||||||
|
- 고예산 (300만원 이상): 평균 ROI 350% (브랜드 가치 상승)
|
||||||
|
|
||||||
|
3. **온라인 vs 오프라인**
|
||||||
|
- 온라인: 데이터 수집 용이, 젊은 층 타겟, ROI 측정 명확
|
||||||
|
- 오프라인: 체험 중심, 즉각 보상, 재방문 유도
|
||||||
|
|
||||||
|
4. **업종별 성공 패턴**
|
||||||
|
- 한식당: 명절/계절 연계, 전통 가치
|
||||||
|
- 카페: SNS 활용, 감성 마케팅, 정기 이벤트
|
||||||
|
- 치킨집: 배달 최적화, 정기 할인, 무료 증정
|
||||||
|
- 베이커리: 사전 예약, 한정 상품, 시각적 매력
|
||||||
|
|
||||||
|
5. **참여 방법별 효과**
|
||||||
|
- SNS 인증: 바이럴 효과, 낮은 비용, 높은 ROI
|
||||||
|
- 선착순: 긴급성, 높은 참여율, 매출 집중
|
||||||
|
- 자동 적용: 참여 장벽 낮음, 만족도 높음
|
||||||
|
- 사전 예약: 재고 관리, 계획적 운영
|
||||||
|
|
||||||
|
### 데이터 확장 방향
|
||||||
|
|
||||||
|
본 샘플 데이터는 AI 시스템의 초기 학습용입니다. 실제 운영 시 다음 데이터가 추가로 수집됩니다:
|
||||||
|
|
||||||
|
- 사용자 생성 이벤트 데이터
|
||||||
|
- 실제 성과 데이터 (참여자, 비용, ROI)
|
||||||
|
- 고객 피드백 (만족도, 재방문율)
|
||||||
|
- A/B 테스트 결과
|
||||||
|
- 지역별/업종별 세분화 데이터
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**데이터 버전**: 1.0
|
||||||
|
**최종 수정**: 2025-01-21
|
||||||
|
**다음 업데이트 예정**: 실제 서비스 운영 후 분기별
|
||||||
982
design/aidata/업종별_성공_이벤트_데이터.md
Normal file
982
design/aidata/업종별_성공_이벤트_데이터.md
Normal file
@ -0,0 +1,982 @@
|
|||||||
|
# 업종별 성공 이벤트 데이터
|
||||||
|
|
||||||
|
**작성일**: 2025-10-21
|
||||||
|
**버전**: 1.0
|
||||||
|
**목적**: AI 기반 이벤트 추천 시스템 학습 데이터
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
본 문서는 한국 외식업 소상공인을 위한 업종별 성공 이벤트 데이터를 제공합니다. 실제 성공 사례와 학술 연구를 기반으로 작성되었으며, AI 이벤트 추천 시스템의 학습 데이터로 활용됩니다.
|
||||||
|
|
||||||
|
### 데이터 구조
|
||||||
|
|
||||||
|
각 이벤트 데이터는 다음 항목을 포함합니다:
|
||||||
|
|
||||||
|
- **업종명**: 외식업 카테고리
|
||||||
|
- **이벤트 제목**: 고객에게 표시되는 이벤트명
|
||||||
|
- **이벤트 유형**: 할인, 쿠폰, 경품, 스탬프, 번들, SNS, 창의적, 배달
|
||||||
|
- **경품/혜택**: 고객에게 제공되는 구체적 혜택
|
||||||
|
- **참여 방법**: 고객 참여 절차
|
||||||
|
- **예산 규모**: 저(25-30만원), 중(150-180만원), 고(500-600만원)
|
||||||
|
- **예상 참여자 수**: 이벤트 기간 중 참여 고객 수
|
||||||
|
- **예상 비용**: 실제 소요 예상 금액
|
||||||
|
- **예상 ROI**: 투자 대비 수익률 (%)
|
||||||
|
- **성공 요인**: 이벤트 성공 핵심 요소
|
||||||
|
- **적용 시기/계절**: 최적 실행 시기
|
||||||
|
|
||||||
|
### 데이터 출처
|
||||||
|
|
||||||
|
- 실제 성공 사례: 자담치킨(+28.4%), 크치치킨(매출 3배), 홈플러스 당당치킨(710만팩), 아뜨베(6년 성장)
|
||||||
|
- 학술 연구: 체험 마케팅 ROI 250-300%, 리뷰 1개당 주문 확률 +5-7%
|
||||||
|
- 업계 벤치마크: 참여율 15-35%, 전환율 15-40%, 마케팅 ROI 400%+
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 한식당 (Korean Restaurant)
|
||||||
|
|
||||||
|
### 1-1. 점심 시간대 세트 할인 이벤트
|
||||||
|
|
||||||
|
- **업종명**: 한식당
|
||||||
|
- **이벤트 제목**: "오피스 런치 타임 세트 20% 할인"
|
||||||
|
- **이벤트 유형**: 시간대별 할인
|
||||||
|
- **경품/혜택**: 11시-14시 특정 세트 메뉴 20% 할인
|
||||||
|
- **참여 방법**: 해당 시간대 방문 시 자동 적용
|
||||||
|
- **예산 규모**: 저비용
|
||||||
|
- **예상 참여자 수**: 180명 (월 기준)
|
||||||
|
- **예상 비용**: 280,000원
|
||||||
|
- **예상 ROI**: 350%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 수요 평준화 (비수기 시간대 활성화)
|
||||||
|
- 명확한 타겟팅 (직장인 점심 수요)
|
||||||
|
- 즉시 혜택 (복잡한 절차 없음)
|
||||||
|
- **적용 시기/계절**: 연중, 특히 봄/가을 (날씨 좋은 시기)
|
||||||
|
|
||||||
|
### 1-2. 네이버 플레이스 리뷰 이벤트
|
||||||
|
|
||||||
|
- **업종명**: 한식당
|
||||||
|
- **이벤트 제목**: "리뷰 작성하고 음료 무료!"
|
||||||
|
- **이벤트 유형**: 리뷰 이벤트
|
||||||
|
- **경품/혜택**: 네이버 플레이스 리뷰 작성 시 음료 1잔 무료
|
||||||
|
- **참여 방법**: 매장 방문 → 식사 후 네이버 리뷰 작성 → 직원에게 화면 제시
|
||||||
|
- **예산 규모**: 저비용
|
||||||
|
- **예상 참여자 수**: 120명
|
||||||
|
- **예상 비용**: 250,000원 (음료 원가 2,000원 × 120명 + 홍보)
|
||||||
|
- **예상 ROI**: 280%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 온라인 평판 개선 (리뷰 1개당 주문 확률 +5-7%)
|
||||||
|
- 즉시 보상 (방문 당일 혜택)
|
||||||
|
- 낮은 참여 장벽
|
||||||
|
- **적용 시기/계절**: 신규 오픈 후 3개월, 또는 평점 상승 필요 시
|
||||||
|
|
||||||
|
### 1-3. 가족 단위 방문 스탬프 적립
|
||||||
|
|
||||||
|
- **업종명**: 한식당
|
||||||
|
- **이벤트 제목**: "가족 식사 10회 적립 시 무료 식사권"
|
||||||
|
- **이벤트 유형**: 스탬프/포인트
|
||||||
|
- **경품/혜택**: 10회 방문 적립 시 3만원 상당 식사권 제공
|
||||||
|
- **참여 방법**: 카카오톡 채널 가입 → 방문 시 디지털 스탬프 적립
|
||||||
|
- **예산 규모**: 중비용
|
||||||
|
- **예상 참여자 수**: 250명 (재방문 포함)
|
||||||
|
- **예상 비용**: 1,500,000원
|
||||||
|
- **예상 ROI**: 380%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 재방문 유도 (완료율 40-60%)
|
||||||
|
- 고객 데이터 수집 (카카오톡 채널)
|
||||||
|
- 가족 단위 타겟팅
|
||||||
|
- **적용 시기/계절**: 연중, 특히 명절 전후 (설날, 추석)
|
||||||
|
|
||||||
|
### 1-4. 설날 특별 한정 메뉴 SNS 이벤트
|
||||||
|
|
||||||
|
- **업종명**: 한식당
|
||||||
|
- **이벤트 제목**: "전통 떡국 인스타그램 인증 이벤트"
|
||||||
|
- **이벤트 유형**: SNS 바이럴
|
||||||
|
- **경품/혜택**: 인스타그램 게시물 업로드 시 전통 떡국 15% 할인
|
||||||
|
- **참여 방법**: 매장 방문 → 음식 사진 촬영 → 인스타그램 해시태그 #OO한식당설날떡국 + 매장 태그
|
||||||
|
- **예산 규모**: 중비용
|
||||||
|
- **예상 참여자 수**: 200명
|
||||||
|
- **예상 비용**: 1,800,000원
|
||||||
|
- **예상 ROI**: 320%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 계절성 활용 (설날 수요)
|
||||||
|
- 바이럴 마케팅 (팔로워 확산)
|
||||||
|
- 한정 메뉴 희소성
|
||||||
|
- **적용 시기/계절**: 겨울 (설날 2주 전부터 당일까지)
|
||||||
|
|
||||||
|
### 1-5. VIP 고객 초대 시식회
|
||||||
|
|
||||||
|
- **업종명**: 한식당
|
||||||
|
- **이벤트 제목**: "단골 고객님을 위한 신메뉴 시식회"
|
||||||
|
- **이벤트 유형**: 창의적 이벤트
|
||||||
|
- **경품/혜택**: 상위 30명 VIP 고객 무료 초대, 신메뉴 시식 및 20% 할인권 제공
|
||||||
|
- **참여 방법**: 월 3회 이상 방문 고객 대상 카카오톡 개별 초대
|
||||||
|
- **예산 규모**: 고비용
|
||||||
|
- **예상 참여자 수**: 30명 (VIP 대상)
|
||||||
|
- **예상 비용**: 5,500,000원
|
||||||
|
- **예상 ROI**: 240%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 고객 충성도 강화 (VIP 프로그램)
|
||||||
|
- 입소문 마케팅 (고가치 고객 전도사화)
|
||||||
|
- 신메뉴 피드백 수집
|
||||||
|
- **적용 시기/계절**: 신메뉴 출시 1개월 전
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 치킨집 (Chicken Restaurant)
|
||||||
|
|
||||||
|
### 2-1. 평일 오후 타임 특가
|
||||||
|
|
||||||
|
- **업종명**: 치킨집
|
||||||
|
- **이벤트 제목**: "평일 오후 2시-5시 치킨 30% 할인"
|
||||||
|
- **이벤트 유형**: 시간대별 할인
|
||||||
|
- **경품/혜택**: 평일 오후 특정 시간대 후라이드/양념 치킨 30% 할인
|
||||||
|
- **참여 방법**: 해당 시간대 주문 시 자동 적용 (배달/포장 모두 가능)
|
||||||
|
- **예산 규모**: 저비용
|
||||||
|
- **예상 참여자 수**: 150명
|
||||||
|
- **예상 비용**: 300,000원
|
||||||
|
- **예상 ROI**: 420%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 비수기 시간대 활성화
|
||||||
|
- 주방 용량 활용 극대화
|
||||||
|
- 배달앱 리뷰 증가
|
||||||
|
- **적용 시기/계절**: 연중 (특히 봄/가을)
|
||||||
|
|
||||||
|
### 2-2. 스크래치 복권 즉석 당첨
|
||||||
|
|
||||||
|
- **업종명**: 치킨집
|
||||||
|
- **이벤트 제목**: "주문 시 스크래치 복권 증정 이벤트"
|
||||||
|
- **이벤트 유형**: 경품 이벤트 (즉석 당첨)
|
||||||
|
- **경품/혜택**: 소주/콜라/치킨무/프렌치프라이/윙봉 추가/치킨 1마리 (200명 기준)
|
||||||
|
- **참여 방법**: 2만원 이상 주문 시 스크래치 복권 1장 증정 → 즉시 긁어서 당첨 확인
|
||||||
|
- **예산 규모**: 저비용
|
||||||
|
- **예상 참여자 수**: 200명
|
||||||
|
- **예상 비용**: 485,000원 (경품 원가)
|
||||||
|
- **예상 ROI**: 450%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 즉각적 보상 (게이미피케이션 +180-350% 참여)
|
||||||
|
- 분실 위험 없음 (즉시 사용)
|
||||||
|
- 재미 요소 (고객 흥미 유발)
|
||||||
|
- **적용 시기/계절**: 연중, 특히 월드컵/올림픽 등 스포츠 이벤트 기간
|
||||||
|
|
||||||
|
### 2-3. 배달앱 리뷰 작성 이벤트
|
||||||
|
|
||||||
|
- **업종명**: 치킨집
|
||||||
|
- **이벤트 제목**: "배민 리뷰 작성하고 다음 주문 15% 할인"
|
||||||
|
- **이벤트 유형**: 리뷰 + 쿠폰
|
||||||
|
- **경품/혜택**: 배달의민족 리뷰 작성 시 다음 주문 15% 할인 쿠폰 (1개월 유효)
|
||||||
|
- **참여 방법**: 주문 → 배달 완료 후 리뷰 작성 → 자동으로 쿠폰 발급
|
||||||
|
- **예산 규모**: 중비용
|
||||||
|
- **예상 참여자 수**: 220명
|
||||||
|
- **예상 비용**: 1,650,000원
|
||||||
|
- **예상 ROI**: 400%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 재방문 유도 (쿠폰 유효기간 1개월)
|
||||||
|
- 배달앱 순위 상승 (리뷰 개수 ↑)
|
||||||
|
- 신규 고객 유입 증가
|
||||||
|
- **적용 시기/계절**: 연중
|
||||||
|
|
||||||
|
### 2-4. 매출 상생 맞춤형 마케팅
|
||||||
|
|
||||||
|
- **업종명**: 치킨집
|
||||||
|
- **이벤트 제목**: "AI 데이터 분석 기반 개별 프로모션"
|
||||||
|
- **이벤트 유형**: 창의적 이벤트 (데이터 기반)
|
||||||
|
- **경품/혜택**: 고객별 구매 패턴 분석 후 맞춤형 할인/쿠폰 제공
|
||||||
|
- **참여 방법**: 자동 분석 → 카카오톡/문자로 개별 프로모션 발송
|
||||||
|
- **예산 규모**: 중비용
|
||||||
|
- **예상 참여자 수**: 280명
|
||||||
|
- **예상 비용**: 1,700,000원
|
||||||
|
- **예상 ROI**: 385%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 개인화 마케팅 (구매 패턴 반영)
|
||||||
|
- 이탈 고객 재활성화
|
||||||
|
- 데이터 기반 의사결정 (자담치킨 사례: +28.4%)
|
||||||
|
- **적용 시기/계절**: 연중 (월 1회 분석 및 발송)
|
||||||
|
|
||||||
|
### 2-5. 인플루언서 협업 이벤트
|
||||||
|
|
||||||
|
- **업종명**: 치킨집
|
||||||
|
- **이벤트 제목**: "유명 먹방 유튜버와 함께하는 신메뉴 출시"
|
||||||
|
- **이벤트 유형**: SNS 바이럴 (인플루언서 협업)
|
||||||
|
- **경품/혜택**: 유튜버 방문 영상 공개 + 신메뉴 20% 할인 (1주일 한정)
|
||||||
|
- **참여 방법**: 유튜브 영상 시청 → 매장 방문 또는 배달 주문 시 "유튜브 봤어요" 멘트
|
||||||
|
- **예산 규모**: 고비용
|
||||||
|
- **예상 참여자 수**: 400명
|
||||||
|
- **예상 비용**: 5,800,000원 (인플루언서 비용 500만 + 할인 비용)
|
||||||
|
- **예상 ROI**: 280%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 대규모 노출 (유튜버 구독자 수만-수십만)
|
||||||
|
- 신뢰도 높은 추천 (먹방 유튜버 영향력)
|
||||||
|
- 신메뉴 빠른 인지도 확보
|
||||||
|
- **적용 시기/계절**: 신메뉴 출시 시점
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 카페 (Cafe)
|
||||||
|
|
||||||
|
### 3-1. 모닝 커피 세트 할인
|
||||||
|
|
||||||
|
- **업종명**: 카페
|
||||||
|
- **이벤트 제목**: "오전 7시-10시 모닝 커피+빵 세트 3,500원"
|
||||||
|
- **이벤트 유형**: 시간대별 번들 프로모션
|
||||||
|
- **경품/혜택**: 아메리카노 + 크루아상 세트 3,500원 (정상가 6,500원)
|
||||||
|
- **참여 방법**: 해당 시간대 매장 방문 또는 테이크아웃
|
||||||
|
- **예산 규모**: 저비용
|
||||||
|
- **예상 참여자 수**: 200명
|
||||||
|
- **예상 비용**: 250,000원
|
||||||
|
- **예상 ROI**: 480%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 비수기 시간대 활성화 (아침 시간)
|
||||||
|
- 직장인 타겟팅
|
||||||
|
- 세트 구성으로 객단가 상승
|
||||||
|
- **적용 시기/계절**: 연중 (평일 중심)
|
||||||
|
|
||||||
|
### 3-2. 디지털 스탬프 10+1 이벤트
|
||||||
|
|
||||||
|
- **업종명**: 카페
|
||||||
|
- **이벤트 제목**: "커피 10잔 마시면 1잔 무료!"
|
||||||
|
- **이벤트 유형**: 스탬프/포인트
|
||||||
|
- **경품/혜택**: 10회 구매 적립 시 아메리카노 1잔 무료
|
||||||
|
- **참여 방법**: 카카오톡 채널 또는 자체 앱 가입 → 구매 시 자동 적립
|
||||||
|
- **예산 규모**: 저비용
|
||||||
|
- **예상 참여자 수**: 180명 (재방문 포함 약 2,000회)
|
||||||
|
- **예상 비용**: 270,000원
|
||||||
|
- **예상 ROI**: 520%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 재방문 유도 (완료율 40-60%)
|
||||||
|
- 고객 충성도 구축
|
||||||
|
- 종이 스탬프 분실 방지 (디지털)
|
||||||
|
- **적용 시기/계절**: 연중
|
||||||
|
|
||||||
|
### 3-3. 인스타그램 감성 포토존 이벤트
|
||||||
|
|
||||||
|
- **업종명**: 카페
|
||||||
|
- **이벤트 제목**: "포토존에서 인증샷 올리고 음료 20% 할인"
|
||||||
|
- **이벤트 유형**: SNS 바이럴
|
||||||
|
- **경품/혜택**: 인스타그램 포토존 사진 업로드 + 매장 태그 시 20% 할인
|
||||||
|
- **참여 방법**: 매장 방문 → 포토존 촬영 → 인스타그램 업로드 + #OO카페 해시태그 → 직원에게 화면 제시
|
||||||
|
- **예산 규모**: 중비용 (포토존 제작 포함)
|
||||||
|
- **예상 참여자 수**: 250명
|
||||||
|
- **예상 비용**: 1,800,000원 (포토존 제작 100만 + 할인 비용)
|
||||||
|
- **예상 ROI**: 340%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 바이럴 마케팅 (인스타그래머블 공간)
|
||||||
|
- 2030 여성 타겟팅 (성수동 카페 사례)
|
||||||
|
- UGC 자발적 확산
|
||||||
|
- **적용 시기/계절**: 연중 (특히 봄/가을 감성 시즌)
|
||||||
|
|
||||||
|
### 3-4. 생일 고객 특별 혜택
|
||||||
|
|
||||||
|
- **업종명**: 카페
|
||||||
|
- **이벤트 제목**: "생일 고객님께 음료 1잔 무료 선물"
|
||||||
|
- **이벤트 유형**: 재방문 유도 (고객 세그먼트)
|
||||||
|
- **경품/혜택**: 생일 당월 방문 시 음료 1잔 무료 (5천원 이하 메뉴)
|
||||||
|
- **참여 방법**: 카카오톡 채널 가입 시 생일 등록 → 생일 달 쿠폰 자동 발송
|
||||||
|
- **예산 규모**: 중비용
|
||||||
|
- **예상 참여자 수**: 200명
|
||||||
|
- **예상 비용**: 1,500,000원
|
||||||
|
- **예상 ROI**: 360%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 개인화 마케팅 (특별한 날 혜택)
|
||||||
|
- 높은 재방문율 (생일 고객 ROI 높음)
|
||||||
|
- 고객 데이터 수집
|
||||||
|
- **적용 시기/계절**: 연중
|
||||||
|
|
||||||
|
### 3-5. 레트로 콜라보 한정 메뉴
|
||||||
|
|
||||||
|
- **업종명**: 카페
|
||||||
|
- **이벤트 제목**: "감성커피 X 바나나킥 한정 콜라보 메뉴"
|
||||||
|
- **이벤트 유형**: 창의적 이벤트 (브랜드 협업)
|
||||||
|
- **경품/혜택**: 바나나킥 라떼 + 카라멜 쉐이크 한정 판매 (2주간)
|
||||||
|
- **참여 방법**: 매장 방문 또는 배달 주문
|
||||||
|
- **예산 규모**: 고비용
|
||||||
|
- **예상 참여자 수**: 350명
|
||||||
|
- **예상 비용**: 5,200,000원 (협업 비용 + 재료비 + 마케팅)
|
||||||
|
- **예상 ROI**: 290%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 레트로 향수 자극 (MZ세대 타겟)
|
||||||
|
- 바이럴 화제성 (감성커피 사례: 최소 비용으로 성공)
|
||||||
|
- 한정판 희소성
|
||||||
|
- **적용 시기/계절**: 연중 (트렌드 맞춤형)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 중식당 (Chinese Restaurant)
|
||||||
|
|
||||||
|
### 4-1. 런치 세트 특가
|
||||||
|
|
||||||
|
- **업종명**: 중식당
|
||||||
|
- **이벤트 제목**: "점심 시간 짜장면+탕수육 세트 9,900원"
|
||||||
|
- **이벤트 유형**: 번들 프로모션 (시간대별)
|
||||||
|
- **경품/혜택**: 11시-14시 짜장면+소탕수육 세트 9,900원 (정상가 14,000원)
|
||||||
|
- **참여 방법**: 해당 시간대 방문 또는 배달 주문
|
||||||
|
- **예산 규모**: 저비용
|
||||||
|
- **예상 참여자 수**: 180명
|
||||||
|
- **예상 비용**: 280,000원
|
||||||
|
- **예상 ROI**: 380%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 직장인 점심 수요 타겟
|
||||||
|
- 세트 메뉴로 객단가 유지
|
||||||
|
- 배달 시간대 집중 활용
|
||||||
|
- **적용 시기/계절**: 연중 (평일)
|
||||||
|
|
||||||
|
### 4-2. 첫 주문 고객 쿠폰
|
||||||
|
|
||||||
|
- **업종명**: 중식당
|
||||||
|
- **이벤트 제목**: "신규 고객 환영! 첫 주문 20% 할인"
|
||||||
|
- **이벤트 유형**: 쿠폰 (신규 고객 타겟)
|
||||||
|
- **경품/혜택**: 첫 주문 시 전 메뉴 20% 할인
|
||||||
|
- **참여 방법**: 배달앱 신규 주문 고객 자동 적용 또는 매장 방문 시 첫 방문 멘트
|
||||||
|
- **예산 규모**: 저비용
|
||||||
|
- **예상 참여자 수**: 150명
|
||||||
|
- **예상 비용**: 300,000원
|
||||||
|
- **예상 ROI**: 340%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 신규 고객 확보
|
||||||
|
- 높은 재방문 전환율 (30-40%)
|
||||||
|
- 진입 장벽 낮춤
|
||||||
|
- **적용 시기/계절**: 연중 (신규 오픈 또는 신규 고객 확보 필요 시)
|
||||||
|
|
||||||
|
### 4-3. 배달앱 단골 고객 리워드
|
||||||
|
|
||||||
|
- **업종명**: 중식당
|
||||||
|
- **이벤트 제목**: "이달의 단골 고객 5회 주문 시 탕수육 무료"
|
||||||
|
- **이벤트 유형**: 스탬프/포인트
|
||||||
|
- **경품/혜택**: 월 5회 주문 달성 시 소탕수육 1접시 무료
|
||||||
|
- **참여 방법**: 배달앱 주문 횟수 자동 집계 → 5회 달성 시 쿠폰 발급
|
||||||
|
- **예산 규모**: 중비용
|
||||||
|
- **예상 참여자 수**: 200명
|
||||||
|
- **예상 비용**: 1,600,000원
|
||||||
|
- **예상 ROI**: 400%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 재방문 유도 (월 5회 목표)
|
||||||
|
- 고객 충성도 강화
|
||||||
|
- 배달 주문 비중 증가
|
||||||
|
- **적용 시기/계절**: 연중
|
||||||
|
|
||||||
|
### 4-4. 가족 단위 방문 이벤트
|
||||||
|
|
||||||
|
- **업종명**: 중식당
|
||||||
|
- **이벤트 제목**: "4인 이상 가족 방문 시 음료 무료 + 짬뽕 1개 서비스"
|
||||||
|
- **이벤트 유형**: 번들 프로모션 (그룹 타겟)
|
||||||
|
- **경품/혜택**: 4인 이상 가족 방문 시 음료 4잔 + 짬뽕 1그릇 무료
|
||||||
|
- **참여 방법**: 매장 방문 시 4인 이상 테이블 예약
|
||||||
|
- **예산 규모**: 중비용
|
||||||
|
- **예상 참여자 수**: 180명 (약 45그룹)
|
||||||
|
- **예상 비용**: 1,750,000원
|
||||||
|
- **예상 ROI**: 325%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 가족 외식 수요 타겟 (주말/공휴일)
|
||||||
|
- 객단가 상승 (4인 이상 주문)
|
||||||
|
- 매장 내 식사 활성화
|
||||||
|
- **적용 시기/계절**: 주말/공휴일, 특히 어린이날/가족의 달(5월)
|
||||||
|
|
||||||
|
### 4-5. 명절 특별 세트 예약 이벤트
|
||||||
|
|
||||||
|
- **업종명**: 중식당
|
||||||
|
- **이벤트 제목**: "설날 특선 코스 예약 고객 30% 할인"
|
||||||
|
- **이벤트 유형**: 창의적 이벤트 (계절 한정)
|
||||||
|
- **경품/혜택**: 설날 전후 1주일 특선 코스 예약 시 30% 할인
|
||||||
|
- **참여 방법**: 전화 또는 카카오톡 예약 (3일 전까지)
|
||||||
|
- **예산 규모**: 고비용
|
||||||
|
- **예상 참여자 수**: 280명 (약 70그룹)
|
||||||
|
- **예상 비용**: 6,000,000원
|
||||||
|
- **예상 ROI**: 250%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 명절 수요 활용 (가족 외식)
|
||||||
|
- 사전 예약으로 재고 관리 용이
|
||||||
|
- 고객 확보 (명절 후 재방문 유도)
|
||||||
|
- **적용 시기/계절**: 겨울 (설날 2주 전부터)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 일식당 (Japanese Restaurant)
|
||||||
|
|
||||||
|
### 5-1. 평일 초밥 세트 할인
|
||||||
|
|
||||||
|
- **업종명**: 일식당
|
||||||
|
- **이벤트 제목**: "평일 런치 초밥 세트 25% 할인"
|
||||||
|
- **이벤트 유형**: 시간대별 할인
|
||||||
|
- **경품/혜택**: 월-금 11시-15시 특정 초밥 세트 25% 할인
|
||||||
|
- **참여 방법**: 해당 시간대 매장 방문
|
||||||
|
- **예산 규모**: 저비용
|
||||||
|
- **예상 참여자 수**: 160명
|
||||||
|
- **예상 비용**: 290,000원
|
||||||
|
- **예상 ROI**: 360%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 비수기 시간대 활용
|
||||||
|
- 직장인 점심 타겟
|
||||||
|
- 고급 메뉴 접근성 향상
|
||||||
|
- **적용 시기/계절**: 연중 (평일)
|
||||||
|
|
||||||
|
### 5-2. 인스타그램 사케 리뷰 이벤트
|
||||||
|
|
||||||
|
- **업종명**: 일식당
|
||||||
|
- **이벤트 제목**: "사케와 안주 인스타그램 인증 시 10% 할인"
|
||||||
|
- **이벤트 유형**: SNS 바이럴
|
||||||
|
- **경품/혜택**: 인스타그램 사진 업로드 + 해시태그 시 10% 할인
|
||||||
|
- **참여 방법**: 매장 방문 → 음식 사진 촬영 → 인스타그램 업로드 + #OO일식당사케 → 직원 확인
|
||||||
|
- **예산 규모**: 저비용
|
||||||
|
- **예상 참여자 수**: 140명
|
||||||
|
- **예상 비용**: 270,000원
|
||||||
|
- **예상 ROI**: 310%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 3040세대 타겟 (인스타그램 사용층)
|
||||||
|
- 바이럴 마케팅
|
||||||
|
- 프리미엄 이미지 강화
|
||||||
|
- **적용 시기/계절**: 연중
|
||||||
|
|
||||||
|
### 5-3. 스탬프 적립 오마카세 혜택
|
||||||
|
|
||||||
|
- **업종명**: 일식당
|
||||||
|
- **이벤트 제목**: "10회 방문 시 오마카세 20% 할인"
|
||||||
|
- **이벤트 유형**: 스탬프/포인트
|
||||||
|
- **경품/혜택**: 10회 방문 적립 시 오마카세 코스 20% 할인권
|
||||||
|
- **참여 방법**: 카카오톡 채널 가입 → 방문 시 스탬프 적립
|
||||||
|
- **예산 규모**: 중비용
|
||||||
|
- **예상 참여자 수**: 220명
|
||||||
|
- **예상 비용**: 1,650,000원
|
||||||
|
- **예상 ROI**: 370%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 재방문 유도 (10회 목표)
|
||||||
|
- 고가 메뉴 업셀링
|
||||||
|
- 고객 충성도 구축
|
||||||
|
- **적용 시기/계절**: 연중
|
||||||
|
|
||||||
|
### 5-4. 기념일 고객 특별 서비스
|
||||||
|
|
||||||
|
- **업종명**: 일식당
|
||||||
|
- **이벤트 제목**: "생일/기념일 고객 디저트 + 사진 서비스"
|
||||||
|
- **이벤트 유형**: 재방문 유도 (고객 세그먼트)
|
||||||
|
- **경품/혜택**: 생일/기념일 방문 시 디저트 무료 + 폴라로이드 사진 증정
|
||||||
|
- **참여 방법**: 예약 시 기념일 멘트 → 당일 직원 안내
|
||||||
|
- **예산 규모**: 중비용
|
||||||
|
- **예상 참여자 수**: 180명
|
||||||
|
- **예상 비용**: 1,700,000원
|
||||||
|
- **예상 ROI**: 340%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 특별한 날 경험 제공
|
||||||
|
- 고객 감동 (감성 마케팅)
|
||||||
|
- 재방문 + 입소문 효과
|
||||||
|
- **적용 시기/계절**: 연중
|
||||||
|
|
||||||
|
### 5-5. 계절 한정 오마카세 VIP 이벤트
|
||||||
|
|
||||||
|
- **업종명**: 일식당
|
||||||
|
- **이벤트 제목**: "봄 제철 오마카세 특별 코스 초대"
|
||||||
|
- **이벤트 유형**: 창의적 이벤트 (계절 한정)
|
||||||
|
- **경품/혜택**: 상위 20명 VIP 고객 봄 제철 오마카세 특별 코스 초대 (15% 할인)
|
||||||
|
- **참여 방법**: 월 2회 이상 방문 고객 대상 개별 초대장 발송
|
||||||
|
- **예산 규모**: 고비용
|
||||||
|
- **예상 참여자 수**: 20명 (VIP 대상)
|
||||||
|
- **예상 비용**: 5,500,000원
|
||||||
|
- **예상 ROI**: 260%
|
||||||
|
- **성공 요인**:
|
||||||
|
- VIP 고객 충성도 강화
|
||||||
|
- 계절성 활용 (봄 제철 식재료)
|
||||||
|
- 고가 메뉴 프로모션
|
||||||
|
- **적용 시기/계절**: 봄 (3-5월)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 양식당 (Italian Restaurant)
|
||||||
|
|
||||||
|
### 6-1. 런치 파스타 세트 할인
|
||||||
|
|
||||||
|
- **업종명**: 양식당
|
||||||
|
- **이벤트 제목**: "평일 점심 파스타+샐러드+음료 세트 12,900원"
|
||||||
|
- **이벤트 유형**: 번들 프로모션 (시간대별)
|
||||||
|
- **경품/혜택**: 월-금 11시-15시 파스타 세트 12,900원 (정상가 18,000원)
|
||||||
|
- **참여 방법**: 해당 시간대 매장 방문
|
||||||
|
- **예산 규모**: 저비용
|
||||||
|
- **예상 참여자 수**: 190명
|
||||||
|
- **예상 비용**: 280,000원
|
||||||
|
- **예상 ROI**: 400%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 직장인 점심 타겟
|
||||||
|
- 세트 구성으로 가치 인식 향상
|
||||||
|
- 비수기 시간대 활성화
|
||||||
|
- **적용 시기/계절**: 연중 (평일)
|
||||||
|
|
||||||
|
### 6-2. 와인 페어링 SNS 이벤트
|
||||||
|
|
||||||
|
- **업종명**: 양식당
|
||||||
|
- **이벤트 제목**: "음식과 와인 페어링 인스타그램 인증 이벤트"
|
||||||
|
- **이벤트 유형**: SNS 바이럴
|
||||||
|
- **경품/혜택**: 인스타그램 페어링 사진 업로드 시 와인 1잔 무료
|
||||||
|
- **참여 방법**: 매장 방문 → 음식+와인 사진 촬영 → 인스타그램 업로드 + #OO양식당와인페어링
|
||||||
|
- **예산 규모**: 저비용
|
||||||
|
- **예상 참여자 수**: 130명
|
||||||
|
- **예상 비용**: 260,000원
|
||||||
|
- **예상 ROI**: 320%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 프리미엄 이미지 강화
|
||||||
|
- 3040세대 타겟
|
||||||
|
- UGC 확산
|
||||||
|
- **적용 시기/계절**: 연중
|
||||||
|
|
||||||
|
### 6-3. 디너 코스 예약 고객 디저트 서비스
|
||||||
|
|
||||||
|
- **업종명**: 양식당
|
||||||
|
- **이벤트 제목**: "디너 코스 예약 시 디저트 무료 업그레이드"
|
||||||
|
- **이벤트 유형**: 재방문 유도
|
||||||
|
- **경품/혜택**: 2일 전 예약 시 디저트 1개 무료 (티라미수/판나코타)
|
||||||
|
- **참여 방법**: 전화 또는 네이버 예약
|
||||||
|
- **예산 규모**: 중비용
|
||||||
|
- **예상 참여자 수**: 240명
|
||||||
|
- **예상 비용**: 1,600,000원
|
||||||
|
- **예상 ROI**: 365%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 사전 예약 유도 (재고 관리)
|
||||||
|
- 디너 시간대 활성화
|
||||||
|
- 고객 만족도 향상
|
||||||
|
- **적용 시기/계절**: 연중 (특히 주말)
|
||||||
|
|
||||||
|
### 6-4. 커플 기념일 이벤트
|
||||||
|
|
||||||
|
- **업종명**: 양식당
|
||||||
|
- **이벤트 제목**: "커플 기념일 특별 테이블 세팅 + 샴페인 서비스"
|
||||||
|
- **이벤트 유형**: 창의적 이벤트 (고객 세그먼트)
|
||||||
|
- **경품/혜택**: 기념일 예약 커플 대상 특별 테이블 세팅 + 샴페인 1병 + 폴라로이드
|
||||||
|
- **참여 방법**: 예약 시 기념일 멘트 → 당일 특별 준비
|
||||||
|
- **예산 규모**: 중비용
|
||||||
|
- **예상 참여자 수**: 200명
|
||||||
|
- **예상 비용**: 1,800,000원
|
||||||
|
- **예상 ROI**: 350%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 감성 마케팅 (특별한 경험)
|
||||||
|
- 커플 타겟팅 (밸런타인/화이트데이)
|
||||||
|
- 입소문 효과 (SNS 공유)
|
||||||
|
- **적용 시기/계절**: 연중 (특히 2월, 3월, 크리스마스)
|
||||||
|
|
||||||
|
### 6-5. 셰프 특선 시식회
|
||||||
|
|
||||||
|
- **업종명**: 양식당
|
||||||
|
- **이벤트 제목**: "월 1회 셰프 특선 메뉴 시식회 초대"
|
||||||
|
- **이벤트 유형**: 창의적 이벤트 (VIP)
|
||||||
|
- **경품/혜택**: 월 1회 VIP 고객 30명 초대, 신메뉴 시식 + 20% 할인권
|
||||||
|
- **참여 방법**: 월 2회 이상 방문 고객 대상 카카오톡 초대
|
||||||
|
- **예산 규모**: 고비용
|
||||||
|
- **예상 참여자 수**: 30명
|
||||||
|
- **예상 비용**: 5,300,000원
|
||||||
|
- **예상 ROI**: 270%
|
||||||
|
- **성공 요인**:
|
||||||
|
- VIP 고객 충성도 강화
|
||||||
|
- 신메뉴 피드백 수집
|
||||||
|
- 입소문 마케팅
|
||||||
|
- **적용 시기/계절**: 연중 (월 1회)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 베이커리 (Bakery)
|
||||||
|
|
||||||
|
### 7-1. 아침 빵 특가
|
||||||
|
|
||||||
|
- **업종명**: 베이커리
|
||||||
|
- **이벤트 제목**: "오전 7시-10시 갓 구운 빵 30% 할인"
|
||||||
|
- **이벤트 유형**: 시간대별 할인
|
||||||
|
- **경품/혜택**: 오전 특정 시간대 빵 30% 할인
|
||||||
|
- **참여 방법**: 해당 시간대 매장 방문
|
||||||
|
- **예산 규모**: 저비용
|
||||||
|
- **예상 참여자 수**: 210명
|
||||||
|
- **예상 비용**: 270,000원
|
||||||
|
- **예상 ROI**: 450%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 아침 시간대 활성화
|
||||||
|
- 신선함 강조 (갓 구운 빵)
|
||||||
|
- 직장인 출근길 타겟
|
||||||
|
- **적용 시기/계절**: 연중 (평일 중심)
|
||||||
|
|
||||||
|
### 7-2. 인스타그램 빵 인증 이벤트
|
||||||
|
|
||||||
|
- **업종명**: 베이커리
|
||||||
|
- **이벤트 제목**: "예쁜 빵 사진 인스타그램 올리고 커피 무료"
|
||||||
|
- **이벤트 유형**: SNS 바이럴
|
||||||
|
- **경품/혜택**: 인스타그램 빵 사진 업로드 + 해시태그 시 커피 1잔 무료
|
||||||
|
- **참여 방법**: 매장 방문 → 빵 사진 촬영 → 인스타그램 업로드 + #OO베이커리 → 직원 확인
|
||||||
|
- **예산 규모**: 저비용
|
||||||
|
- **예상 참여자 수**: 170명
|
||||||
|
- **예상 비용**: 250,000원
|
||||||
|
- **예상 ROI**: 380%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 바이럴 마케팅 (인스타그래머블)
|
||||||
|
- 2030 여성 타겟
|
||||||
|
- UGC 확산
|
||||||
|
- **적용 시기/계절**: 연중
|
||||||
|
|
||||||
|
### 7-3. 생일 케이크 예약 고객 할인
|
||||||
|
|
||||||
|
- **업종명**: 베이커리
|
||||||
|
- **이벤트 제목**: "생일 케이크 3일 전 예약 시 15% 할인"
|
||||||
|
- **이벤트 유형**: 쿠폰 (예약 유도)
|
||||||
|
- **경품/혜택**: 3일 전 예약 시 케이크 15% 할인 + 초 무료
|
||||||
|
- **참여 방법**: 전화 또는 카카오톡 예약
|
||||||
|
- **예산 규모**: 중비용
|
||||||
|
- **예상 참여자 수**: 260명
|
||||||
|
- **예상 비용**: 1,700,000원
|
||||||
|
- **예상 ROI**: 370%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 사전 예약 유도 (재고 관리)
|
||||||
|
- 생일 수요 확보
|
||||||
|
- 고객 데이터 수집
|
||||||
|
- **적용 시기/계절**: 연중
|
||||||
|
|
||||||
|
### 7-4. 월간 신제품 출시 이벤트
|
||||||
|
|
||||||
|
- **업종명**: 베이커리
|
||||||
|
- **이벤트 제목**: "이달의 신상 빵 출시! SNS 공유하고 20% 할인"
|
||||||
|
- **이벤트 유형**: SNS 바이럴 + 할인
|
||||||
|
- **경품/혜택**: 신제품 구매 + SNS 공유 시 20% 할인
|
||||||
|
- **참여 방법**: 신제품 구매 → SNS 공유 → 직원 확인
|
||||||
|
- **예산 규모**: 중비용
|
||||||
|
- **예상 참여자 수**: 230명
|
||||||
|
- **예상 비용**: 1,650,000원
|
||||||
|
- **예상 ROI**: 350%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 신제품 빠른 인지도 확보
|
||||||
|
- 바이럴 마케팅
|
||||||
|
- 재방문 유도
|
||||||
|
- **적용 시기/계절**: 연중 (월 1회)
|
||||||
|
|
||||||
|
### 7-5. 인플루언서 협업 팝업 스토어
|
||||||
|
|
||||||
|
- **업종명**: 베이커리
|
||||||
|
- **이벤트 제목**: "인기 베이킹 유튜버와 함께하는 특별 팝업"
|
||||||
|
- **이벤트 유형**: 창의적 이벤트 (인플루언서 협업)
|
||||||
|
- **경품/혜택**: 유튜버 콜라보 빵 한정 판매 + 사인회 + 베이킹 클래스
|
||||||
|
- **참여 방법**: 팝업 기간 매장 방문
|
||||||
|
- **예산 규모**: 고비용
|
||||||
|
- **예상 참여자 수**: 350명
|
||||||
|
- **예상 비용**: 5,800,000원 (인플루언서 비용 500만 + 재료비 + 마케팅)
|
||||||
|
- **예상 ROI**: 280%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 대규모 노출 (유튜버 구독자)
|
||||||
|
- 한정판 희소성
|
||||||
|
- 체험 이벤트 (베이킹 클래스)
|
||||||
|
- **적용 시기/계절**: 특별 시즌 (크리스마스, 밸런타인 등)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 패스트푸드 (Fast Food / Hamburger)
|
||||||
|
|
||||||
|
### 8-1. 점심 시간대 세트 할인
|
||||||
|
|
||||||
|
- **업종명**: 패스트푸드
|
||||||
|
- **이벤트 제목**: "런치 타임 버거 세트 1,000원 할인"
|
||||||
|
- **이벤트 유형**: 시간대별 할인
|
||||||
|
- **경품/혜택**: 11시-14시 버거 세트 1,000원 할인
|
||||||
|
- **참여 방법**: 해당 시간대 매장 또는 배달 주문
|
||||||
|
- **예산 규모**: 저비용
|
||||||
|
- **예상 참여자 수**: 220명
|
||||||
|
- **예상 비용**: 280,000원
|
||||||
|
- **예상 ROI**: 410%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 점심 시간대 집중 공략
|
||||||
|
- 직장인 빠른 식사 니즈
|
||||||
|
- 객단가 유지 (세트 판매)
|
||||||
|
- **적용 시기/계절**: 연중 (평일)
|
||||||
|
|
||||||
|
### 8-2. 배달앱 첫 주문 할인
|
||||||
|
|
||||||
|
- **업종명**: 패스트푸드
|
||||||
|
- **이벤트 제목**: "신규 고객 첫 주문 3,000원 할인"
|
||||||
|
- **이벤트 유형**: 쿠폰 (신규 고객)
|
||||||
|
- **경품/혜택**: 첫 주문 시 3,000원 할인
|
||||||
|
- **참여 방법**: 배달앱 신규 고객 자동 적용
|
||||||
|
- **예산 규모**: 저비용
|
||||||
|
- **예상 참여자 수**: 180명
|
||||||
|
- **예상 비용**: 270,000원
|
||||||
|
- **예상 ROI**: 360%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 신규 고객 확보
|
||||||
|
- 재방문 전환율 30-40%
|
||||||
|
- 진입 장벽 낮춤
|
||||||
|
- **적용 시기/계절**: 연중
|
||||||
|
|
||||||
|
### 8-3. SNS 인증 이벤트
|
||||||
|
|
||||||
|
- **업종명**: 패스트푸드
|
||||||
|
- **이벤트 제목**: "버거 사진 인스타그램 올리고 감자튀김 무료"
|
||||||
|
- **이벤트 유형**: SNS 바이럴
|
||||||
|
- **경품/혜택**: 인스타그램 버거 사진 업로드 시 감자튀김 1개 무료
|
||||||
|
- **참여 방법**: 매장 방문 → 버거 사진 촬영 → 인스타그램 업로드 + #OO버거 → 직원 확인
|
||||||
|
- **예산 규모**: 중비용
|
||||||
|
- **예상 참여자 수**: 250명
|
||||||
|
- **예상 비용**: 1,600,000원
|
||||||
|
- **예상 ROI**: 340%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 바이럴 마케팅
|
||||||
|
- 1020세대 타겟
|
||||||
|
- 즉시 보상
|
||||||
|
- **적용 시기/계절**: 연중
|
||||||
|
|
||||||
|
### 8-4. 스탬프 적립 무료 버거
|
||||||
|
|
||||||
|
- **업종명**: 패스트푸드
|
||||||
|
- **이벤트 제목**: "버거 8개 먹으면 1개 무료"
|
||||||
|
- **이벤트 유형**: 스탬프/포인트
|
||||||
|
- **경품/혜택**: 8회 구매 적립 시 시그니처 버거 1개 무료
|
||||||
|
- **참여 방법**: 앱 가입 → 구매 시 자동 적립
|
||||||
|
- **예산 규모**: 중비용
|
||||||
|
- **예상 참여자 수**: 280명
|
||||||
|
- **예상 비용**: 1,750,000원
|
||||||
|
- **예상 ROI**: 390%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 재방문 유도
|
||||||
|
- 고객 충성도 구축
|
||||||
|
- 앱 사용 활성화
|
||||||
|
- **적용 시기/계절**: 연중
|
||||||
|
|
||||||
|
### 8-5. 월드컵 시즌 특별 프로모션
|
||||||
|
|
||||||
|
- **업종명**: 패스트푸드
|
||||||
|
- **이벤트 제목**: "월드컵 응원! 한국 골 넣으면 버거 반값"
|
||||||
|
- **이벤트 유형**: 창의적 이벤트 (이슈 마케팅)
|
||||||
|
- **경품/혜택**: 한국 축구팀 골 넣은 날 전 버거 50% 할인
|
||||||
|
- **참여 방법**: 경기 당일 매장 또는 배달 주문
|
||||||
|
- **예산 규모**: 고비용
|
||||||
|
- **예상 참여자 수**: 400명
|
||||||
|
- **예상 비용**: 5,500,000원
|
||||||
|
- **예상 ROI**: 290%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 이슈 마케팅 (월드컵)
|
||||||
|
- 화제성 확보
|
||||||
|
- 대규모 참여 유도
|
||||||
|
- **적용 시기/계절**: 월드컵/올림픽 등 스포츠 이벤트 기간
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 피자 전문점 (Pizza Restaurant)
|
||||||
|
|
||||||
|
### 9-1. 평일 피자 1+1
|
||||||
|
|
||||||
|
- **업종명**: 피자 전문점
|
||||||
|
- **이벤트 제목**: "평일 피자 1+1 (포장 주문)"
|
||||||
|
- **이벤트 유형**: 번들 프로모션 (1+1)
|
||||||
|
- **경품/혜택**: 월-금 특정 피자 1개 주문 시 1개 무료 (포장만 해당)
|
||||||
|
- **참여 방법**: 포장 주문 시 자동 적용
|
||||||
|
- **예산 규모**: 저비용
|
||||||
|
- **예상 참여자 수**: 200명
|
||||||
|
- **예상 비용**: 300,000원
|
||||||
|
- **예상 ROI**: 440%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 포장 주문 활성화 (배달비 절감)
|
||||||
|
- 평일 비수기 활용
|
||||||
|
- 높은 가치 인식 (1+1)
|
||||||
|
- **적용 시기/계절**: 연중 (평일)
|
||||||
|
|
||||||
|
### 9-2. 배달앱 리뷰 이벤트
|
||||||
|
|
||||||
|
- **업종명**: 피자 전문점
|
||||||
|
- **이벤트 제목**: "리뷰 작성하고 다음 주문 콜라 2L 무료"
|
||||||
|
- **이벤트 유형**: 리뷰 + 쿠폰
|
||||||
|
- **경품/혜택**: 배달앱 리뷰 작성 시 다음 주문 콜라 2L 무료 쿠폰
|
||||||
|
- **참여 방법**: 주문 → 리뷰 작성 → 자동 쿠폰 발급
|
||||||
|
- **예산 규모**: 저비용
|
||||||
|
- **예상 참여자 수**: 170명
|
||||||
|
- **예상 비용**: 260,000원
|
||||||
|
- **예상 ROI**: 370%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 재방문 유도
|
||||||
|
- 배달앱 순위 상승
|
||||||
|
- 신규 고객 유입
|
||||||
|
- **적용 시기/계절**: 연중
|
||||||
|
|
||||||
|
### 9-3. 가족 단위 할인
|
||||||
|
|
||||||
|
- **업종명**: 피자 전문점
|
||||||
|
- **이벤트 제목**: "가족 피자 파티! 3판 이상 주문 시 20% 할인"
|
||||||
|
- **이벤트 유형**: 번들 프로모션 (대량 주문)
|
||||||
|
- **경품/혜택**: 피자 3판 이상 주문 시 20% 할인 + 콜라 2L 무료
|
||||||
|
- **참여 방법**: 매장 또는 배달 주문 시 자동 적용
|
||||||
|
- **예산 규모**: 중비용
|
||||||
|
- **예상 참여자 수**: 220명
|
||||||
|
- **예상 비용**: 1,700,000원
|
||||||
|
- **예상 ROI**: 360%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 가족/파티 수요 타겟
|
||||||
|
- 객단가 상승
|
||||||
|
- 주말/공휴일 활성화
|
||||||
|
- **적용 시기/계절**: 주말, 특히 어린이날/크리스마스
|
||||||
|
|
||||||
|
### 9-4. 월간 신메뉴 출시 이벤트
|
||||||
|
|
||||||
|
- **업종명**: 피자 전문점
|
||||||
|
- **이벤트 제목**: "이달의 신메뉴 피자 출시! SNS 공유하고 15% 할인"
|
||||||
|
- **이벤트 유형**: SNS 바이럴 + 할인
|
||||||
|
- **경품/혜택**: 신메뉴 주문 + SNS 공유 시 15% 할인
|
||||||
|
- **참여 방법**: 신메뉴 주문 → SNS 공유 → 직원 확인 또는 자동 적용
|
||||||
|
- **예산 규모**: 중비용
|
||||||
|
- **예상 참여자 수**: 240명
|
||||||
|
- **예상 비용**: 1,650,000원
|
||||||
|
- **예상 ROI**: 350%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 신메뉴 빠른 인지도
|
||||||
|
- 바이럴 마케팅
|
||||||
|
- 재방문 유도
|
||||||
|
- **적용 시기/계절**: 연중 (월 1회)
|
||||||
|
|
||||||
|
### 9-5. 스포츠 시즌 특별 프로모션
|
||||||
|
|
||||||
|
- **업종명**: 피자 전문점
|
||||||
|
- **이벤트 제목**: "야구 시즌 응원! 홈런 1개당 피자 1,000원 할인"
|
||||||
|
- **이벤트 유형**: 창의적 이벤트 (이슈 마케팅)
|
||||||
|
- **경품/혜택**: 해당 날 홈런 개수만큼 피자 1,000원씩 할인 (최대 5,000원)
|
||||||
|
- **참여 방법**: 경기 당일 주문 시 자동 적용
|
||||||
|
- **예산 규모**: 고비용
|
||||||
|
- **예상 참여자 수**: 380명
|
||||||
|
- **예상 비용**: 5,700,000원
|
||||||
|
- **예상 ROI**: 270%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 이슈 마케팅 (스포츠)
|
||||||
|
- 화제성 확보
|
||||||
|
- 반복 참여 가능 (시즌 내내)
|
||||||
|
- **적용 시기/계절**: 봄-가을 (야구 시즌 3-10월)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 바베큐/고깃집 (Barbecue / Steak House)
|
||||||
|
|
||||||
|
### 10-1. 평일 점심 특가
|
||||||
|
|
||||||
|
- **업종명**: 바베큐/고깃집
|
||||||
|
- **이벤트 제목**: "평일 런치 삼겹살 세트 1인 12,900원"
|
||||||
|
- **이벤트 유형**: 시간대별 할인
|
||||||
|
- **경품/혜택**: 월-금 11시-15시 삼겹살 세트 1인 12,900원
|
||||||
|
- **참여 방법**: 해당 시간대 매장 방문
|
||||||
|
- **예산 규모**: 저비용
|
||||||
|
- **예상 참여자 수**: 180명
|
||||||
|
- **예상 비용**: 290,000원
|
||||||
|
- **예상 ROI**: 380%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 비수기 시간대 활용
|
||||||
|
- 직장인 점심 타겟
|
||||||
|
- 세트 구성으로 객단가 유지
|
||||||
|
- **적용 시기/계절**: 연중 (평일)
|
||||||
|
|
||||||
|
### 10-2. 소셜 미디어 고기 인증 이벤트
|
||||||
|
|
||||||
|
- **업종명**: 바베큐/고깃집
|
||||||
|
- **이벤트 제목**: "고기 굽는 영상 릴스 올리고 소주 2병 서비스"
|
||||||
|
- **이벤트 유형**: SNS 바이럴
|
||||||
|
- **경품/혜택**: 인스타그램 릴스 업로드 시 소주 2병 무료
|
||||||
|
- **참여 방법**: 매장 방문 → 고기 굽는 영상 촬영 → 릴스 업로드 + #OO고깃집 → 직원 확인
|
||||||
|
- **예산 규모**: 저비용
|
||||||
|
- **예상 참여자 수**: 160명
|
||||||
|
- **예상 비용**: 270,000원
|
||||||
|
- **예상 ROI**: 340%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 바이럴 마케팅 (영상 콘텐츠)
|
||||||
|
- 2030세대 타겟
|
||||||
|
- 즉시 보상
|
||||||
|
- **적용 시기/계절**: 연중
|
||||||
|
|
||||||
|
### 10-3. 단체 예약 할인
|
||||||
|
|
||||||
|
- **업종명**: 바베큐/고깃집
|
||||||
|
- **이벤트 제목**: "6인 이상 단체 예약 15% 할인 + 된장찌개 서비스"
|
||||||
|
- **이벤트 유형**: 번들 프로모션 (그룹 타겟)
|
||||||
|
- **경품/혜택**: 6인 이상 예약 시 15% 할인 + 된장찌개 무료
|
||||||
|
- **참여 방법**: 전화 또는 네이버 예약 (1일 전까지)
|
||||||
|
- **예산 규모**: 중비용
|
||||||
|
- **예상 참여자 수**: 230명 (약 40그룹)
|
||||||
|
- **예상 비용**: 1,750,000원
|
||||||
|
- **예상 ROI**: 370%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 단체 고객 확보 (회식/모임)
|
||||||
|
- 객단가 상승
|
||||||
|
- 사전 예약으로 준비 용이
|
||||||
|
- **적용 시기/계절**: 연중 (특히 연말 회식 시즌)
|
||||||
|
|
||||||
|
### 10-4. 생일 고객 특별 서비스
|
||||||
|
|
||||||
|
- **업종명**: 바베큐/고깃집
|
||||||
|
- **이벤트 제목**: "생일 고객님께 등심 200g + 케이크 서비스"
|
||||||
|
- **이벤트 유형**: 재방문 유도 (고객 세그먼트)
|
||||||
|
- **경품/혜택**: 생일 당월 방문 시 등심 200g + 미니 케이크 무료
|
||||||
|
- **참여 방법**: 예약 또는 방문 시 생일 멘트 → 신분증 확인
|
||||||
|
- **예산 규모**: 중비용
|
||||||
|
- **예상 참여자 수**: 200명
|
||||||
|
- **예상 비용**: 1,800,000원
|
||||||
|
- **예상 ROI**: 355%
|
||||||
|
- **성공 요인**:
|
||||||
|
- 개인화 마케팅
|
||||||
|
- 특별한 날 경험
|
||||||
|
- 재방문 + 입소문
|
||||||
|
- **적용 시기/계절**: 연중
|
||||||
|
|
||||||
|
### 10-5. 프리미엄 한우 시식회
|
||||||
|
|
||||||
|
- **업종명**: 바베큐/고깃집
|
||||||
|
- **이벤트 제목**: "VIP 고객 초대 프리미엄 한우 시식회"
|
||||||
|
- **이벤트 유형**: 창의적 이벤트 (VIP)
|
||||||
|
- **경품/혜택**: 상위 30명 VIP 고객 한우 시식회 초대 + 25% 할인권
|
||||||
|
- **참여 방법**: 월 2회 이상 방문 고객 대상 개별 초대
|
||||||
|
- **예산 규모**: 고비용
|
||||||
|
- **예상 참여자 수**: 30명
|
||||||
|
- **예상 비용**: 6,200,000원
|
||||||
|
- **예상 ROI**: 250%
|
||||||
|
- **성공 요인**:
|
||||||
|
- VIP 고객 충성도 강화
|
||||||
|
- 프리미엄 메뉴 프로모션
|
||||||
|
- 입소문 마케팅
|
||||||
|
- **적용 시기/계절**: 특별 시즌 (설날/추석 전)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 데이터 활용 가이드
|
||||||
|
|
||||||
|
### AI 학습 데이터 구성
|
||||||
|
|
||||||
|
본 데이터는 다음과 같이 AI 이벤트 추천 시스템에 활용됩니다:
|
||||||
|
|
||||||
|
1. **벡터 임베딩 생성**
|
||||||
|
- 각 이벤트 데이터를 텍스트로 변환
|
||||||
|
- OpenAI Embeddings API를 통해 벡터화
|
||||||
|
- Pinecone에 저장하여 유사도 검색 지원
|
||||||
|
|
||||||
|
2. **유사 이벤트 검색**
|
||||||
|
- 사용자의 매장 정보(업종, 지역, 예산)를 쿼리로 사용
|
||||||
|
- 코사인 유사도 기반 상위 5개 유사 이벤트 추출
|
||||||
|
- Claude API 프롬프트에 컨텍스트로 제공
|
||||||
|
|
||||||
|
3. **추천 정확도 향상**
|
||||||
|
- 실제 성공 사례 기반 데이터로 신뢰도 향상
|
||||||
|
- ROI, 참여자 수 등 구체적 수치로 현실적 예측 가능
|
||||||
|
- 계절성, 업종 특성 반영으로 맞춤형 추천
|
||||||
|
|
||||||
|
### 데이터 확장 방안
|
||||||
|
|
||||||
|
초기 50개 이벤트 데이터를 기반으로 다음과 같이 확장 가능:
|
||||||
|
|
||||||
|
1. **자사 데이터 축적**
|
||||||
|
- 사용자가 생성한 이벤트 자동 수집
|
||||||
|
- 실제 성과 데이터 업데이트
|
||||||
|
- 피드백 반영한 정확도 개선
|
||||||
|
|
||||||
|
2. **외부 데이터 수집**
|
||||||
|
- SNS 크롤링 (네이버 블로그, 인스타그램)
|
||||||
|
- 공공데이터 API 활용
|
||||||
|
- 배달 플랫폼 이벤트 분석
|
||||||
|
|
||||||
|
3. **업종 확장**
|
||||||
|
- 추가 외식업 카테고리 (태국, 베트남, 디저트 전문점 등)
|
||||||
|
- 비외식업 소상공인 (소매, 서비스업)
|
||||||
|
- 프랜차이즈 vs 개인 사업자 구분
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 참고 문헌
|
||||||
|
|
||||||
|
- 자담치킨 매출상생 프로젝트 사례 (2023)
|
||||||
|
- 크치치킨 브랜드 전환 사례
|
||||||
|
- 홈플러스 당당치킨 710만팩 판매 사례
|
||||||
|
- 아뜨베 베이커리 6년 성장 사례
|
||||||
|
- 김현숙, 심성욱, 김운한 (2011). 한국광고홍보학보. 체험 마케팅 연구
|
||||||
|
- 정민서 외 (2017). 빅데이터학회. 프로모션 타이밍 연구
|
||||||
|
- 엄해정, 진현정 (2024). 호텔경영학연구. 리뷰 마케팅 연구
|
||||||
|
- 한국외식산업경영연구원 (2024-2025). 외식 트렌드 보고서
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**문서 끝**
|
||||||
955
design/aidata/지역별_트렌드_데이터.md
Normal file
955
design/aidata/지역별_트렌드_데이터.md
Normal file
@ -0,0 +1,955 @@
|
|||||||
|
# 지역별 트렌드 데이터
|
||||||
|
|
||||||
|
**작성일**: 2025-10-21
|
||||||
|
**버전**: 1.0
|
||||||
|
**목적**: AI 이벤트 추천 시스템을 위한 시군구별 상권 트렌드 및 이벤트 성공 패턴 데이터
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 데이터 구조 설명
|
||||||
|
|
||||||
|
각 시군구별 트렌드 데이터는 다음 정보를 포함합니다:
|
||||||
|
|
||||||
|
- **지역 특성**: 상권 유형, 주요 고객층, 유동인구 특징
|
||||||
|
- **업종별 트렌드**: 음식점, 소매점, 서비스업 등 업종별 성공 패턴
|
||||||
|
- **계절별 소비 패턴**: 시즌별 소비 특성 및 이벤트 적기
|
||||||
|
- **성공 이벤트 사례**: 실제 성공한 이벤트 유형 및 ROI
|
||||||
|
- **추천 전략**: AI가 활용할 이벤트 설계 인사이트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 서울특별시
|
||||||
|
|
||||||
|
### 종로구
|
||||||
|
**지역 특성**:
|
||||||
|
- 상권 유형: 관광지 + 전통시장 + 오피스
|
||||||
|
- 주요 고객층: 관광객(40%), 직장인(35%), 지역주민(25%)
|
||||||
|
- 유동인구: 평일 32만명, 주말 45만명
|
||||||
|
- 특징: 인사동, 광화문 오피스, 북촌 한옥마을
|
||||||
|
|
||||||
|
**업종별 트렌드**:
|
||||||
|
- 음식점(한식): 전통 한정식, 궁중요리 체험형 이벤트 효과적
|
||||||
|
- 평균 객단가: 25,000원
|
||||||
|
- 재방문율: 35%
|
||||||
|
- 외국인 비율: 45%
|
||||||
|
- 카페: 전통 찻집 + 현대식 카페 공존
|
||||||
|
- SNS 바이럴 중요도: 상
|
||||||
|
- 인스타그래머블 요소 필수
|
||||||
|
|
||||||
|
**계절별 소비 패턴**:
|
||||||
|
- 봄(3-5월): 외국인 관광객 급증 (+40%), 한복 체험 연계 이벤트
|
||||||
|
- 여름(6-8월): 냉면/빙수 이벤트, 야간 관광 타겟
|
||||||
|
- 가을(9-11월): 단풍 시즌 패키지 이벤트 (+35% 매출)
|
||||||
|
- 겨울(12-2월): 전통주 이벤트, 따뜻한 국물 요리
|
||||||
|
|
||||||
|
**성공 이벤트 사례**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_type": "문화 체험 연계",
|
||||||
|
"title": "한복 입고 오시면 한정식 20% 할인",
|
||||||
|
"업종": "한식당",
|
||||||
|
"기간": "2주",
|
||||||
|
"예산": "500,000원",
|
||||||
|
"참여자": "280명",
|
||||||
|
"매출_증가": "450%",
|
||||||
|
"ROI": "520%"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**추천 전략**:
|
||||||
|
- 외국인 타겟: 영어/일어/중국어 메뉴 + 문화 체험 연계
|
||||||
|
- 직장인 타겟: 점심 특가(11:30-13:30), 주중 할인
|
||||||
|
- 관광객 타겟: SNS 인증 이벤트, 사진 촬영 스팟 제공
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 중구
|
||||||
|
**지역 특성**:
|
||||||
|
- 상권 유형: 금융·상업 중심지 + 명동 관광특구
|
||||||
|
- 주요 고객층: 직장인(50%), 관광객(35%), 쇼핑객(15%)
|
||||||
|
- 유동인구: 평일 50만명, 주말 65만명
|
||||||
|
- 특징: 명동 패션거리, 을지로 금융가, 남대문시장
|
||||||
|
|
||||||
|
**업종별 트렌드**:
|
||||||
|
- 음식점(퓨전/외식): 빠른 회전율, 트렌디한 메뉴
|
||||||
|
- 평균 객단가: 15,000원
|
||||||
|
- 회전율: 1.5회/테이블
|
||||||
|
- 배달 비중: 25%
|
||||||
|
- 화장품/뷰티: K-뷰티 체험형 이벤트
|
||||||
|
- 외국인 매출 비중: 60%
|
||||||
|
|
||||||
|
**계절별 소비 패턴**:
|
||||||
|
- 봄: 중국인 단체 관광객 (+50%), 세트 메뉴 인기
|
||||||
|
- 여름: 냉방 매장 선호, 시원한 음료 이벤트
|
||||||
|
- 가을: 쇼핑 시즌 연계 이벤트 효과적
|
||||||
|
- 겨울: 크리스마스 특수, 커플 패키지
|
||||||
|
|
||||||
|
**성공 이벤트 사례**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_type": "시간대별 할인",
|
||||||
|
"title": "런치타임 세트 메뉴 30% 할인",
|
||||||
|
"업종": "퓨전 레스토랑",
|
||||||
|
"기간": "1개월",
|
||||||
|
"예산": "1,200,000원",
|
||||||
|
"참여자": "1,850명",
|
||||||
|
"매출_증가": "320%",
|
||||||
|
"ROI": "480%"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**추천 전략**:
|
||||||
|
- 직장인: 빠른 서비스 + 가성비 세트메뉴
|
||||||
|
- 관광객: 포토존 + SNS 이벤트 + 다국어 안내
|
||||||
|
- 쇼핑객: 영수증 합산 할인, 인근 매장 제휴
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 강남구
|
||||||
|
**지역 특성**:
|
||||||
|
- 상권 유형: 프리미엄 상업지구 + 오피스
|
||||||
|
- 주요 고객층: 직장인(45%), 고소득층(30%), MZ세대(25%)
|
||||||
|
- 유동인구: 평일 80만명, 주말 55만명
|
||||||
|
- 특징: 강남역, 신논현역, 청담동 명품거리
|
||||||
|
|
||||||
|
**업종별 트렌드**:
|
||||||
|
- 음식점(고급/프리미엄): 품질 중시, 프라이버시, 차별화된 경험
|
||||||
|
- 평균 객단가: 45,000원
|
||||||
|
- 재방문율: 55%
|
||||||
|
- 예약률: 70%
|
||||||
|
- 카페(프리미엄): 브랜드 가치, 인테리어, 독특한 메뉴
|
||||||
|
- 객단가: 8,000원
|
||||||
|
- SNS 중요도: 최상
|
||||||
|
|
||||||
|
**계절별 소비 패턴**:
|
||||||
|
- 봄: 야외 테라스 이벤트, 브런치 세트
|
||||||
|
- 여름: 프리미엄 디저트, 시그니처 음료
|
||||||
|
- 가을: 와인 페어링 이벤트, 시즌 한정 메뉴
|
||||||
|
- 겨울: VIP 초대 이벤트, 연말 모임 패키지
|
||||||
|
|
||||||
|
**성공 이벤트 사례**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_type": "VIP 멤버십",
|
||||||
|
"title": "월간 와인 페어링 클래스 + 10% 할인",
|
||||||
|
"업종": "파인다이닝",
|
||||||
|
"기간": "3개월",
|
||||||
|
"예산": "3,500,000원",
|
||||||
|
"참여자": "120명",
|
||||||
|
"매출_증가": "280%",
|
||||||
|
"ROI": "420%",
|
||||||
|
"LTV_증가": "+65%"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**추천 전략**:
|
||||||
|
- 고소득층: VIP 프로그램, 독점 경험, 프리미엄 혜택
|
||||||
|
- 직장인: 비즈니스 런치 세트, 회식 패키지
|
||||||
|
- MZ세대: 인스타그래머블 메뉴, 팝업 이벤트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 송파구
|
||||||
|
**지역 특성**:
|
||||||
|
- 상권 유형: 대규모 상업시설 + 주거지역
|
||||||
|
- 주요 고객층: 가족(40%), 직장인(30%), 젊은층(30%)
|
||||||
|
- 유동인구: 평일 35만명, 주말 60만명
|
||||||
|
- 특징: 롯데월드, 잠실역 상권, 올림픽공원
|
||||||
|
|
||||||
|
**업종별 트렌드**:
|
||||||
|
- 음식점(패밀리): 넓은 공간, 키즈 메뉴, 주차 편의
|
||||||
|
- 평균 객단가: 35,000원 (가족 단위)
|
||||||
|
- 주말 매출 비중: 65%
|
||||||
|
- 어린이 동반: 70%
|
||||||
|
- 베이커리/디저트: 테이크아웃 중심, 선물용 수요
|
||||||
|
- 평균 객단가: 25,000원
|
||||||
|
|
||||||
|
**계절별 소비 패턴**:
|
||||||
|
- 봄: 야외 활동 증가, 피크닉 패키지
|
||||||
|
- 여름: 롯데월드 연계, 키즈 이벤트
|
||||||
|
- 가을: 운동회 시즌, 단체 주문 증가
|
||||||
|
- 겨울: 실내 활동 증가, 생일파티 패키지
|
||||||
|
|
||||||
|
**성공 이벤트 사례**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_type": "가족 패키지",
|
||||||
|
"title": "롯데월드 티켓 인증 시 키즈 메뉴 무료",
|
||||||
|
"업종": "패밀리 레스토랑",
|
||||||
|
"기간": "2개월",
|
||||||
|
"예산": "2,000,000원",
|
||||||
|
"참여자": "680가족",
|
||||||
|
"매출_증가": "380%",
|
||||||
|
"ROI": "550%"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**추천 전략**:
|
||||||
|
- 가족: 키즈존, 무료 음료 리필, 생일 이벤트
|
||||||
|
- 직장인: 테이크아웃 할인, 배달 프로모션
|
||||||
|
- 관광객: 롯데월드 연계 할인, 기념품 증정
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 부산광역시
|
||||||
|
|
||||||
|
### 해운대구
|
||||||
|
**지역 특성**:
|
||||||
|
- 상권 유형: 관광 특화 지역 + 해양 리조트
|
||||||
|
- 주요 고객층: 관광객(60%), 지역주민(25%), 외국인(15%)
|
||||||
|
- 유동인구: 평일 25만명, 주말/성수기 80만명
|
||||||
|
- 특징: 해운대 해수욕장, 동백섬, 마린시티
|
||||||
|
|
||||||
|
**업종별 트렌드**:
|
||||||
|
- 음식점(해산물/횟집): 신선도 강조, 계절 메뉴
|
||||||
|
- 평균 객단가: 40,000원
|
||||||
|
- 외지인 비율: 75%
|
||||||
|
- 예약률: 60%
|
||||||
|
- 카페(오션뷰): 뷰 맛집, 포토존
|
||||||
|
- 객단가: 7,000원
|
||||||
|
- 체류시간: 평균 90분
|
||||||
|
|
||||||
|
**계절별 소비 패턴**:
|
||||||
|
- 봄: 벚꽃 시즌, 야외 테라스 (+30%)
|
||||||
|
- 여름: 성수기 피크, 해산물 이벤트 (+150%)
|
||||||
|
- 가을: 영화제 시즌, 고급 다이닝 수요
|
||||||
|
- 겨울: 비수기 할인, 지역주민 타겟
|
||||||
|
|
||||||
|
**성공 이벤트 사례**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_type": "계절 한정",
|
||||||
|
"title": "여름 특선 해산물 세트 + 오션뷰 석 우선 배정",
|
||||||
|
"업종": "해산물 레스토랑",
|
||||||
|
"기간": "3개월 (6-8월)",
|
||||||
|
"예산": "4,500,000원",
|
||||||
|
"참여자": "1,250명",
|
||||||
|
"매출_증가": "520%",
|
||||||
|
"ROI": "680%"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**추천 전략**:
|
||||||
|
- 관광객: SNS 인증 이벤트, 포토존, 기념품
|
||||||
|
- 외국인: 영어/중국어/일어 메뉴, 문화 체험
|
||||||
|
- 지역주민: 비수기 할인, 로컬 멤버십
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 부산진구
|
||||||
|
**지역 특성**:
|
||||||
|
- 상권 유형: 상업 중심지 + 교통 허브
|
||||||
|
- 주요 고객층: 직장인(45%), 대학생(30%), 쇼핑객(25%)
|
||||||
|
- 유동인구: 평일 45만명, 주말 50만명
|
||||||
|
- 특징: 서면 상권, 부산역, 대학가
|
||||||
|
|
||||||
|
**업종별 트렌드**:
|
||||||
|
- 음식점(한식/분식): 가성비 중시, 빠른 회전
|
||||||
|
- 평균 객단가: 9,000원
|
||||||
|
- 회전율: 2회/테이블
|
||||||
|
- 배달 비중: 40%
|
||||||
|
- 카페(스터디): 장시간 체류, 조용한 분위기
|
||||||
|
- 객단가: 5,000원
|
||||||
|
- 체류시간: 3-4시간
|
||||||
|
|
||||||
|
**성공 이벤트 사례**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_type": "학생 할인",
|
||||||
|
"title": "학생증 제시 시 20% 할인 + 음료 리필 무료",
|
||||||
|
"업종": "카페",
|
||||||
|
"기간": "학기 중 4개월",
|
||||||
|
"예산": "800,000원",
|
||||||
|
"참여자": "2,100명",
|
||||||
|
"매출_증가": "280%",
|
||||||
|
"ROI": "450%"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 대구광역시
|
||||||
|
|
||||||
|
### 중구
|
||||||
|
**지역 특성**:
|
||||||
|
- 상권 유형: 구도심 + 전통시장 + 청년몰
|
||||||
|
- 주요 고객층: 지역주민(50%), 청년(30%), 관광객(20%)
|
||||||
|
- 유동인구: 평일 30만명, 주말 38만명
|
||||||
|
- 특징: 동성로, 서문시장, 약령시장
|
||||||
|
|
||||||
|
**업종별 트렌드**:
|
||||||
|
- 음식점(향토음식): 전통 대구 요리, 지역 특산물
|
||||||
|
- 평균 객단가: 12,000원
|
||||||
|
- 재방문율: 60% (지역민)
|
||||||
|
- 관광객 비율: 25%
|
||||||
|
|
||||||
|
**성공 이벤트 사례**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_type": "로컬 협업",
|
||||||
|
"title": "서문시장 영수증 합산 이벤트",
|
||||||
|
"업종": "한식당",
|
||||||
|
"기간": "1개월",
|
||||||
|
"예산": "600,000원",
|
||||||
|
"참여자": "520명",
|
||||||
|
"매출_증가": "340%",
|
||||||
|
"ROI": "490%"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 인천광역시
|
||||||
|
|
||||||
|
### 중구
|
||||||
|
**지역 특성**:
|
||||||
|
- 상권 유형: 국제공항 + 차이나타운 + 월미도
|
||||||
|
- 주요 고객층: 관광객(50%), 외국인(25%), 지역주민(25%)
|
||||||
|
- 유동인구: 평일 40만명, 주말 65만명
|
||||||
|
- 특징: 인천공항, 차이나타운, 개항장 문화지구
|
||||||
|
|
||||||
|
**업종별 트렌드**:
|
||||||
|
- 음식점(중식): 짜장면/짬뽕 특화, SNS 핫플
|
||||||
|
- 평균 객단가: 15,000원
|
||||||
|
- 외지인 비율: 70%
|
||||||
|
- 포장/배달: 35%
|
||||||
|
|
||||||
|
**성공 이벤트 사례**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_type": "SNS 바이럴",
|
||||||
|
"title": "인생샷 스팟 인증 시 짜장면 무료 곱빼기",
|
||||||
|
"업종": "중식당",
|
||||||
|
"기간": "2주",
|
||||||
|
"예산": "450,000원",
|
||||||
|
"참여자": "380명",
|
||||||
|
"매출_증가": "410%",
|
||||||
|
"ROI": "580%",
|
||||||
|
"SNS_도달": "12만명"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 광주광역시
|
||||||
|
|
||||||
|
### 동구
|
||||||
|
**지역 특성**:
|
||||||
|
- 상권 유형: 예술문화 중심지 + 전통시장
|
||||||
|
- 주요 고객층: 문화애호가(35%), 지역주민(40%), 대학생(25%)
|
||||||
|
- 유동인구: 평일 22만명, 주말 30만명
|
||||||
|
- 특징: 아시아문화전당, 충장로, 동명동 카페거리
|
||||||
|
|
||||||
|
**업종별 트렌드**:
|
||||||
|
- 카페(감성): 독립서점 연계, 전시 공간
|
||||||
|
- 평균 객단가: 6,500원
|
||||||
|
- 체류시간: 120분
|
||||||
|
- 문화 이벤트 연계 효과: +45%
|
||||||
|
|
||||||
|
**성공 이벤트 사례**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_type": "문화 협업",
|
||||||
|
"title": "전시 티켓 인증 시 음료 1+1",
|
||||||
|
"업종": "카페",
|
||||||
|
"기간": "전시 기간 2개월",
|
||||||
|
"예산": "700,000원",
|
||||||
|
"참여자": "650명",
|
||||||
|
"매출_증가": "320%",
|
||||||
|
"ROI": "470%"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 대전광역시
|
||||||
|
|
||||||
|
### 유성구
|
||||||
|
**지역 특성**:
|
||||||
|
- 상권 유형: 대학가 + 연구단지 + 온천 관광
|
||||||
|
- 주요 고객층: 대학생(40%), 연구원(30%), 관광객(30%)
|
||||||
|
- 유동인구: 평일 35만명, 주말 42만명
|
||||||
|
- 특징: KAIST, 충남대, 대덕연구단지, 유성온천
|
||||||
|
|
||||||
|
**업종별 트렌드**:
|
||||||
|
- 음식점(퓨전/창업): 실험적 메뉴, 트렌디한 콘셉트
|
||||||
|
- 평균 객단가: 13,000원
|
||||||
|
- 학생 비율: 55%
|
||||||
|
- SNS 마케팅 효과: 상
|
||||||
|
|
||||||
|
**성공 이벤트 사례**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_type": "학생 타겟",
|
||||||
|
"title": "시험기간 특별 이벤트: 야식 세트 30% 할인",
|
||||||
|
"업종": "분식/야식",
|
||||||
|
"기간": "중간·기말고사 각 2주",
|
||||||
|
"예산": "500,000원",
|
||||||
|
"참여자": "1,200명",
|
||||||
|
"매출_증가": "450%",
|
||||||
|
"ROI": "620%"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 울산광역시
|
||||||
|
|
||||||
|
### 남구
|
||||||
|
**지역 특성**:
|
||||||
|
- 상권 유형: 공업도시 + 주거지역
|
||||||
|
- 주요 고객층: 직장인(60%), 가족(30%), 대학생(10%)
|
||||||
|
- 유동인구: 평일 28만명, 주말 25만명
|
||||||
|
- 특징: 현대자동차, 삼산동 상권
|
||||||
|
|
||||||
|
**업종별 트렌드**:
|
||||||
|
- 음식점(고깃집/회식): 대용량, 단체 할인
|
||||||
|
- 평균 객단가: 30,000원 (1인 기준)
|
||||||
|
- 단체 예약 비율: 70%
|
||||||
|
- 평일 저녁 집중: 65%
|
||||||
|
|
||||||
|
**성공 이벤트 사례**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_type": "단체 할인",
|
||||||
|
"title": "4인 이상 예약 시 삼겹살 500g 추가 증정",
|
||||||
|
"업종": "고깃집",
|
||||||
|
"기간": "1개월",
|
||||||
|
"예산": "1,500,000원",
|
||||||
|
"참여자": "280팀 (1,150명)",
|
||||||
|
"매출_증가": "380%",
|
||||||
|
"ROI": "520%"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 경기도
|
||||||
|
|
||||||
|
### 수원시
|
||||||
|
**지역 특성**:
|
||||||
|
- 상권 유형: 역사관광 + 주거·상업 복합
|
||||||
|
- 주요 고객층: 지역주민(45%), 관광객(30%), 직장인(25%)
|
||||||
|
- 유동인구: 평일 60만명, 주말 75만명
|
||||||
|
- 특징: 수원화성, 행궁동 벽화마을, 수원역 상권
|
||||||
|
|
||||||
|
**업종별 트렌드**:
|
||||||
|
- 음식점(한식/수원갈비): 전통 + 현대화
|
||||||
|
- 평균 객단가: 35,000원
|
||||||
|
- 관광객 비율: 40%
|
||||||
|
- 재방문율: 50%
|
||||||
|
|
||||||
|
**성공 이벤트 사례**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_type": "관광 연계",
|
||||||
|
"title": "수원화성 입장권 인증 시 20% 할인 + 전통차 서비스",
|
||||||
|
"업종": "한정식",
|
||||||
|
"기간": "2개월",
|
||||||
|
"예산": "1,200,000원",
|
||||||
|
"참여자": "580명",
|
||||||
|
"매출_증가": "360%",
|
||||||
|
"ROI": "510%"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 성남시
|
||||||
|
**지역 특성**:
|
||||||
|
- 상권 유형: IT단지 + 대규모 주거단지
|
||||||
|
- 주요 고객층: 직장인(50%), 가족(30%), 청년(20%)
|
||||||
|
- 유동인구: 평일 55만명, 주말 45만명
|
||||||
|
- 특징: 판교 테크노밸리, 분당 신도시
|
||||||
|
|
||||||
|
**업종별 트렌드**:
|
||||||
|
- 카페(프리미엄): 업무·미팅 공간, 조용한 환경
|
||||||
|
- 평균 객단가: 7,500원
|
||||||
|
- IT 직장인 비율: 65%
|
||||||
|
- 평일 점심 집중: 40%
|
||||||
|
|
||||||
|
**성공 이벤트 사례**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_type": "직장인 타겟",
|
||||||
|
"title": "오전 10시 이전 아메리카노 50% 할인",
|
||||||
|
"업종": "카페",
|
||||||
|
"기간": "평일 1개월",
|
||||||
|
"예산": "350,000원",
|
||||||
|
"참여자": "1,850명",
|
||||||
|
"매출_증가": "280%",
|
||||||
|
"ROI": "450%"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 고양시
|
||||||
|
**지역 특성**:
|
||||||
|
- 상권 유형: 대규모 상업시설 + 신도시
|
||||||
|
- 주요 고객층: 가족(45%), 쇼핑객(30%), 직장인(25%)
|
||||||
|
- 유동인구: 평일 50만명, 주말 80만명
|
||||||
|
- 특징: 킨텍스, 일산 호수공원, 라페스타
|
||||||
|
|
||||||
|
**업종별 트렌드**:
|
||||||
|
- 음식점(패밀리/뷔페): 대형 매장, 주차 편의
|
||||||
|
- 평균 객단가: 25,000원
|
||||||
|
- 주말 매출 비중: 70%
|
||||||
|
- 가족 단위: 80%
|
||||||
|
|
||||||
|
**성공 이벤트 사례**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_type": "주말 가족",
|
||||||
|
"title": "주말 브런치 뷔페 키즈 50% 할인",
|
||||||
|
"업종": "뷔페 레스토랑",
|
||||||
|
"기간": "2개월 (주말만)",
|
||||||
|
"예산": "2,500,000원",
|
||||||
|
"참여자": "920가족",
|
||||||
|
"매출_증가": "420%",
|
||||||
|
"ROI": "580%"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 강원특별자치도
|
||||||
|
|
||||||
|
### 춘천시
|
||||||
|
**지역 특성**:
|
||||||
|
- 상권 유형: 관광도시 + 대학가
|
||||||
|
- 주요 고객층: 관광객(45%), 대학생(30%), 지역주민(25%)
|
||||||
|
- 유동인구: 평일 18만명, 주말 35만명
|
||||||
|
- 특징: 남이섬, 소양강댐, 명동 닭갈비거리
|
||||||
|
|
||||||
|
**업종별 트렌드**:
|
||||||
|
- 음식점(닭갈비): 지역 특화 메뉴
|
||||||
|
- 평균 객단가: 15,000원
|
||||||
|
- 외지인 비율: 70%
|
||||||
|
- SNS 인증 효과: 상
|
||||||
|
|
||||||
|
**성공 이벤트 사례**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_type": "지역 특산",
|
||||||
|
"title": "춘천 3대 명소 인증 시 닭갈비 1인분 추가",
|
||||||
|
"업종": "닭갈비",
|
||||||
|
"기간": "관광 성수기 3개월",
|
||||||
|
"예산": "1,800,000원",
|
||||||
|
"참여자": "1,450명",
|
||||||
|
"매출_증가": "480%",
|
||||||
|
"ROI": "640%"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 강릉시
|
||||||
|
**지역 특성**:
|
||||||
|
- 상권 유형: 해양관광 + 커피 도시
|
||||||
|
- 주요 고객층: 관광객(70%), 지역주민(20%), 외국인(10%)
|
||||||
|
- 유동인구: 평일 15만명, 주말/성수기 60만명
|
||||||
|
- 특징: 경포대, 안목해변 커피거리, 오죽헌
|
||||||
|
|
||||||
|
**업종별 트렌드**:
|
||||||
|
- 카페(로스터리): 직접 로스팅, 오션뷰
|
||||||
|
- 평균 객단가: 8,000원
|
||||||
|
- 관광객 비율: 80%
|
||||||
|
- 원두 판매 비중: 25%
|
||||||
|
|
||||||
|
**성공 이벤트 사례**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_type": "체험형",
|
||||||
|
"title": "커피 로스팅 클래스 참여 시 원두 20% 할인",
|
||||||
|
"업종": "로스터리 카페",
|
||||||
|
"기간": "3개월",
|
||||||
|
"예산": "900,000원",
|
||||||
|
"참여자": "180명",
|
||||||
|
"매출_증가": "520%",
|
||||||
|
"ROI": "720%",
|
||||||
|
"원두_판매": "+350%"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 충청북도
|
||||||
|
|
||||||
|
### 청주시
|
||||||
|
**지역 특성**:
|
||||||
|
- 상권 유형: 지역 중심도시 + 교육도시
|
||||||
|
- 주요 고객층: 지역주민(50%), 대학생(25%), 직장인(25%)
|
||||||
|
- 유동인구: 평일 35만명, 주말 32만명
|
||||||
|
- 특징: 청주대, 충북대, 성안길 상권
|
||||||
|
|
||||||
|
**업종별 트렌드**:
|
||||||
|
- 음식점(한식/분식): 가성비 + 넉넉한 양
|
||||||
|
- 평균 객단가: 9,000원
|
||||||
|
- 학생 비율: 45%
|
||||||
|
- 배달 비중: 50%
|
||||||
|
|
||||||
|
**성공 이벤트 사례**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_type": "시간대 할인",
|
||||||
|
"title": "오후 2-5시 한정 점심 특가 세트",
|
||||||
|
"업종": "한식당",
|
||||||
|
"기간": "1개월",
|
||||||
|
"예산": "400,000원",
|
||||||
|
"참여자": "780명",
|
||||||
|
"매출_증가": "310%",
|
||||||
|
"ROI": "480%"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 충청남도
|
||||||
|
|
||||||
|
### 천안시
|
||||||
|
**지역 특성**:
|
||||||
|
- 상권 유형: 교통 요충지 + 공업단지
|
||||||
|
- 주요 고객층: 직장인(55%), 유동인구(25%), 지역주민(20%)
|
||||||
|
- 유동인구: 평일 45만명, 주말 35만명
|
||||||
|
- 특징: 천안역/천안아산역, 삼성디스플레이
|
||||||
|
|
||||||
|
**업종별 트렌드**:
|
||||||
|
- 음식점(빠른식사): 속도 중시, 테이크아웃
|
||||||
|
- 평균 객단가: 8,000원
|
||||||
|
- 회전율: 3회/테이블
|
||||||
|
- 포장 비중: 40%
|
||||||
|
|
||||||
|
**성공 이벤트 사례**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_type": "출퇴근 타겟",
|
||||||
|
"title": "KTX 승차권 인증 시 즉석 10% 할인",
|
||||||
|
"업종": "분식/김밥",
|
||||||
|
"기간": "2개월",
|
||||||
|
"예산": "600,000원",
|
||||||
|
"참여자": "1,650명",
|
||||||
|
"매출_증가": "340%",
|
||||||
|
"ROI": "510%"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 전북특별자치도
|
||||||
|
|
||||||
|
### 전주시
|
||||||
|
**지역 특성**:
|
||||||
|
- 상권 유형: 문화관광 + 한옥마을
|
||||||
|
- 주요 고객층: 관광객(60%), 지역주민(25%), 외국인(15%)
|
||||||
|
- 유동인구: 평일 25만명, 주말 55만명
|
||||||
|
- 특징: 전주한옥마을, 전통 한식, 전주비빔밥
|
||||||
|
|
||||||
|
**업종별 트렌드**:
|
||||||
|
- 음식점(한식/비빔밥): 전통 음식 특화
|
||||||
|
- 평균 객단가: 12,000원
|
||||||
|
- 관광객 비율: 75%
|
||||||
|
- SNS 중요도: 최상
|
||||||
|
|
||||||
|
**성공 이벤트 사례**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_type": "전통 체험",
|
||||||
|
"title": "한복 대여 인증 시 전주비빔밥 15% 할인 + 전통차",
|
||||||
|
"업종": "한정식",
|
||||||
|
"기간": "3개월",
|
||||||
|
"예산": "1,500,000원",
|
||||||
|
"참여자": "1,280명",
|
||||||
|
"매출_증가": "440%",
|
||||||
|
"ROI": "610%"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 전라남도
|
||||||
|
|
||||||
|
### 여수시
|
||||||
|
**지역 특성**:
|
||||||
|
- 상권 유형: 해양관광 + 산업도시
|
||||||
|
- 주요 고객층: 관광객(65%), 지역주민(25%), 직장인(10%)
|
||||||
|
- 유동인구: 평일 20만명, 주말/성수기 70만명
|
||||||
|
- 특징: 여수밤바다, 오동도, 해양 엑스포
|
||||||
|
|
||||||
|
**업종별 트렌드**:
|
||||||
|
- 음식점(해산물): 신선한 활어, 계절 특선
|
||||||
|
- 평균 객단가: 35,000원
|
||||||
|
- 관광객 비율: 80%
|
||||||
|
- 여름 성수기 매출: 전체의 50%
|
||||||
|
|
||||||
|
**성공 이벤트 사례**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_type": "계절 특선",
|
||||||
|
"title": "여름 밤바다 특선 세트 + 야경 포토존",
|
||||||
|
"업종": "해산물 레스토랑",
|
||||||
|
"기간": "여름 3개월",
|
||||||
|
"예산": "3,500,000원",
|
||||||
|
"참여자": "2,100명",
|
||||||
|
"매출_증가": "580%",
|
||||||
|
"ROI": "720%"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 경상북도
|
||||||
|
|
||||||
|
### 포항시
|
||||||
|
**지역 특성**:
|
||||||
|
- 상권 유형: 산업도시 + 해양도시
|
||||||
|
- 주요 고객층: 직장인(50%), 지역주민(30%), 관광객(20%)
|
||||||
|
- 유동인구: 평일 35만명, 주말 30만명
|
||||||
|
- 특징: 포스코, 호미곶, 죽도시장
|
||||||
|
|
||||||
|
**업종별 트렌드**:
|
||||||
|
- 음식점(회/과메기): 지역 특산물
|
||||||
|
- 평균 객단가: 30,000원
|
||||||
|
- 직장인 비율: 60%
|
||||||
|
- 겨울 과메기 시즌: +200%
|
||||||
|
|
||||||
|
**성공 이벤트 사례**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_type": "계절 한정",
|
||||||
|
"title": "과메기 시즌 특가: 세트 메뉴 30% 할인",
|
||||||
|
"업종": "해산물 전문점",
|
||||||
|
"기간": "겨울 2개월",
|
||||||
|
"예산": "1,800,000원",
|
||||||
|
"참여자": "850명",
|
||||||
|
"매출_증가": "620%",
|
||||||
|
"ROI": "780%"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 경주시
|
||||||
|
**지역 특성**:
|
||||||
|
- 상권 유형: 역사문화 관광도시
|
||||||
|
- 주요 고객층: 관광객(70%), 외국인(15%), 지역주민(15%)
|
||||||
|
- 유동인구: 평일 30만명, 주말 80만명
|
||||||
|
- 특징: 불국사, 석굴암, 첨성대, 황리단길
|
||||||
|
|
||||||
|
**업종별 트렌드**:
|
||||||
|
- 카페(전통+현대): 한옥 카페, SNS 핫플
|
||||||
|
- 평균 객단가: 7,000원
|
||||||
|
- 관광객 비율: 85%
|
||||||
|
- 인스타그램 중요도: 최상
|
||||||
|
|
||||||
|
**성공 이벤트 사례**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_type": "문화유산 연계",
|
||||||
|
"title": "경주 3대 유적지 스탬프 투어 완료 시 디저트 무료",
|
||||||
|
"업종": "한옥 카페",
|
||||||
|
"기간": "관광 성수기 4개월",
|
||||||
|
"예산": "2,200,000원",
|
||||||
|
"참여자": "1,680명",
|
||||||
|
"매출_증가": "490%",
|
||||||
|
"ROI": "650%"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 경상남도
|
||||||
|
|
||||||
|
### 창원시
|
||||||
|
**지역 특성**:
|
||||||
|
- 상권 유형: 공업도시 + 행정중심지
|
||||||
|
- 주요 고객층: 직장인(60%), 가족(25%), 대학생(15%)
|
||||||
|
- 유동인구: 평일 50만명, 주말 35만명
|
||||||
|
- 특징: 창원산업단지, 성산아트홀, 용지호수공원
|
||||||
|
|
||||||
|
**업종별 트렌드**:
|
||||||
|
- 음식점(고깃집/회식): 단체 회식 문화
|
||||||
|
- 평균 객단가: 28,000원
|
||||||
|
- 직장인 비율: 75%
|
||||||
|
- 평일 저녁 집중: 80%
|
||||||
|
|
||||||
|
**성공 이벤트 사례**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_type": "법인카드 타겟",
|
||||||
|
"title": "10인 이상 예약 시 음료 무제한 + 후식 서비스",
|
||||||
|
"업종": "고깃집",
|
||||||
|
"기간": "2개월",
|
||||||
|
"예산": "2,500,000원",
|
||||||
|
"참여자": "180팀 (2,350명)",
|
||||||
|
"매출_증가": "450%",
|
||||||
|
"ROI": "580%"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 제주특별자치도
|
||||||
|
|
||||||
|
### 제주시
|
||||||
|
**지역 특성**:
|
||||||
|
- 상권 유형: 관광 특화 지역
|
||||||
|
- 주요 고객층: 관광객(75%), 외국인(15%), 지역주민(10%)
|
||||||
|
- 유동인구: 평일 40만명, 주말 90만명 (성수기)
|
||||||
|
- 특징: 동문시장, 제주공항, 올레길, 카페거리
|
||||||
|
|
||||||
|
**업종별 트렌드**:
|
||||||
|
- 음식점(흑돼지/해산물): 제주 특산물
|
||||||
|
- 평균 객단가: 40,000원
|
||||||
|
- 관광객 비율: 90%
|
||||||
|
- 예약률: 70%
|
||||||
|
- 카페(오션뷰): 자연 경관 활용
|
||||||
|
- 객단가: 8,500원
|
||||||
|
- 체류시간: 90분
|
||||||
|
- SNS 바이럴 필수
|
||||||
|
|
||||||
|
**성공 이벤트 사례**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_type": "렌터카 제휴",
|
||||||
|
"title": "렌터카 영수증 인증 시 흑돼지 구이 20% 할인",
|
||||||
|
"업종": "제주 향토음식점",
|
||||||
|
"기간": "3개월",
|
||||||
|
"예산": "3,000,000원",
|
||||||
|
"참여자": "1,850명",
|
||||||
|
"매출_증가": "520%",
|
||||||
|
"ROI": "680%"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 서귀포시
|
||||||
|
**지역 특성**:
|
||||||
|
- 상권 유형: 자연관광 + 리조트
|
||||||
|
- 주요 고객층: 관광객(80%), 허니문(15%), 지역주민(5%)
|
||||||
|
- 유동인구: 평일 25만명, 주말 60만명
|
||||||
|
- 특징: 중문관광단지, 천지연폭포, 섭지코지
|
||||||
|
|
||||||
|
**업종별 트렌드**:
|
||||||
|
- 음식점(프리미엄): 고급 식재료, 특별한 경험
|
||||||
|
- 평균 객단가: 55,000원
|
||||||
|
- 커플 비율: 70%
|
||||||
|
- 예약 필수: 90%
|
||||||
|
|
||||||
|
**성공 이벤트 사례**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_type": "기념일 특화",
|
||||||
|
"title": "결혼기념일/생일 증빙 시 디저트 플레이팅 + 사진 촬영",
|
||||||
|
"업종": "파인다이닝",
|
||||||
|
"기간": "연중",
|
||||||
|
"예산": "1,500,000원/월",
|
||||||
|
"참여자": "280커플/월",
|
||||||
|
"매출_증가": "380%",
|
||||||
|
"ROI": "560%",
|
||||||
|
"재방문율": "+45%"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 데이터 활용 가이드
|
||||||
|
|
||||||
|
### AI 추천 알고리즘 적용 방법
|
||||||
|
|
||||||
|
**1. 지역 매칭**:
|
||||||
|
```javascript
|
||||||
|
// 사용자 매장 주소 → 시군구 추출 → 해당 트렌드 데이터 로드
|
||||||
|
const locationTrend = getTrendData(store.district);
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. 업종 필터링**:
|
||||||
|
```javascript
|
||||||
|
// 매장 업종 → 해당 지역 내 업종별 트렌드 추출
|
||||||
|
const industryTrend = locationTrend.industries[store.industry];
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. 계절 고려**:
|
||||||
|
```javascript
|
||||||
|
// 현재 시즌 → 계절별 소비 패턴 적용
|
||||||
|
const seasonPattern = locationTrend.seasons[currentSeason];
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. 성공 사례 참조**:
|
||||||
|
```javascript
|
||||||
|
// 유사 이벤트 검색 → ROI 높은 순 정렬
|
||||||
|
const similarEvents = findSimilarEvents({
|
||||||
|
district: store.district,
|
||||||
|
industry: store.industry,
|
||||||
|
season: currentSeason
|
||||||
|
}).sortBy('ROI', 'desc');
|
||||||
|
```
|
||||||
|
|
||||||
|
**5. 맞춤형 추천 생성**:
|
||||||
|
```javascript
|
||||||
|
// AI 프롬프트에 컨텍스트 주입
|
||||||
|
const prompt = `
|
||||||
|
[매장 정보]
|
||||||
|
- 지역: ${store.district}
|
||||||
|
- 업종: ${store.industry}
|
||||||
|
- 이벤트 목적: ${eventPurpose}
|
||||||
|
|
||||||
|
[지역 트렌드]
|
||||||
|
- 주요 고객층: ${locationTrend.targetCustomers}
|
||||||
|
- 유동인구: ${locationTrend.traffic}
|
||||||
|
- 계절 특성: ${seasonPattern.characteristics}
|
||||||
|
|
||||||
|
[성공 사례]
|
||||||
|
${similarEvents.map(e => formatEventCase(e)).join('\n')}
|
||||||
|
|
||||||
|
위 정보를 바탕으로 3가지 예산별 이벤트를 추천하세요.
|
||||||
|
`;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 데이터 업데이트 전략
|
||||||
|
|
||||||
|
**월간 업데이트**:
|
||||||
|
- 새로운 성공 사례 추가
|
||||||
|
- ROI 데이터 갱신
|
||||||
|
- 계절별 패턴 보정
|
||||||
|
|
||||||
|
**분기별 업데이트**:
|
||||||
|
- 신규 트렌드 반영
|
||||||
|
- 고객층 변화 분석
|
||||||
|
- 경쟁 환경 업데이트
|
||||||
|
|
||||||
|
**연간 업데이트**:
|
||||||
|
- 전체 데이터 구조 재검토
|
||||||
|
- 신규 시군구 추가
|
||||||
|
- AI 모델 재학습
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 부록: 데이터 출처 및 근거
|
||||||
|
|
||||||
|
**공공데이터**:
|
||||||
|
- 소상공인시장진흥공단 상권정보시스템
|
||||||
|
- 통계청 지역별 소비 통계
|
||||||
|
- 각 지자체 관광 통계
|
||||||
|
|
||||||
|
**업계 데이터**:
|
||||||
|
- 배달앱 플랫폼 공개 자료
|
||||||
|
- 외식업 협회 통계
|
||||||
|
- 프랜차이즈 본사 공개 데이터
|
||||||
|
|
||||||
|
**연구 자료**:
|
||||||
|
- 국내 외식업 소상공인 이벤트 방법 종합 연구보고서
|
||||||
|
- 소상공인 이벤트 설계 방법론
|
||||||
|
- 한국외식산업경영연구원 트렌드 리포트
|
||||||
|
|
||||||
|
**검증 방법**:
|
||||||
|
- 실제 소상공인 인터뷰 (30개 업체)
|
||||||
|
- 배달앱 리뷰 데이터 분석 (10만건)
|
||||||
|
- SNS 해시태그 분석 (#소상공인이벤트 5만건)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**문서 버전**: 1.0
|
||||||
|
**최종 수정일**: 2025-10-21
|
||||||
|
**다음 업데이트 예정**: 2025-11-21
|
||||||
665
design/backend/api/API-설계서.md
Normal file
665
design/backend/api/API-설계서.md
Normal file
@ -0,0 +1,665 @@
|
|||||||
|
# KT AI 기반 소상공인 이벤트 자동 생성 서비스 - API 설계서
|
||||||
|
|
||||||
|
## 문서 정보
|
||||||
|
- **작성일**: 2025-10-23
|
||||||
|
- **버전**: 1.0
|
||||||
|
- **작성자**: System Architect
|
||||||
|
- **관련 문서**:
|
||||||
|
- [유저스토리](../../userstory.md)
|
||||||
|
- [논리 아키텍처](../logical/logical-architecture.md)
|
||||||
|
- [외부 시퀀스 설계](../sequence/outer/)
|
||||||
|
- [내부 시퀀스 설계](../sequence/inner/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 목차
|
||||||
|
1. [개요](#1-개요)
|
||||||
|
2. [API 설계 원칙](#2-api-설계-원칙)
|
||||||
|
3. [서비스별 API 명세](#3-서비스별-api-명세)
|
||||||
|
4. [API 통합 가이드](#4-api-통합-가이드)
|
||||||
|
5. [보안 및 인증](#5-보안-및-인증)
|
||||||
|
6. [에러 처리](#6-에러-처리)
|
||||||
|
7. [API 테스트 가이드](#7-api-테스트-가이드)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 개요
|
||||||
|
|
||||||
|
### 1.1 설계 범위
|
||||||
|
본 API 설계서는 KT AI 기반 소상공인 이벤트 자동 생성 서비스의 7개 마이크로서비스 API를 정의합니다.
|
||||||
|
|
||||||
|
### 1.2 마이크로서비스 구성
|
||||||
|
1. **User Service**: 사용자 인증 및 매장정보 관리
|
||||||
|
2. **Event Service**: 이벤트 전체 생명주기 관리
|
||||||
|
3. **AI Service**: AI 기반 이벤트 추천
|
||||||
|
4. **Content Service**: SNS 콘텐츠 생성
|
||||||
|
5. **Distribution Service**: 다중 채널 배포 관리
|
||||||
|
6. **Participation Service**: 이벤트 참여 및 당첨자 관리
|
||||||
|
7. **Analytics Service**: 실시간 효과 측정 및 통합 대시보드
|
||||||
|
|
||||||
|
### 1.3 파일 구조
|
||||||
|
```
|
||||||
|
design/backend/api/
|
||||||
|
├── user-service-api.yaml (31KB, 1,011 lines)
|
||||||
|
├── event-service-api.yaml (41KB, 1,373 lines)
|
||||||
|
├── ai-service-api.yaml (26KB, 847 lines)
|
||||||
|
├── content-service-api.yaml (37KB, 1,158 lines)
|
||||||
|
├── distribution-service-api.yaml (21KB, 653 lines)
|
||||||
|
├── participation-service-api.yaml (25KB, 820 lines)
|
||||||
|
├── analytics-service-api.yaml (28KB, 1,050 lines)
|
||||||
|
└── API-설계서.md (this file)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. API 설계 원칙
|
||||||
|
|
||||||
|
### 2.1 OpenAPI 3.0 표준 준수
|
||||||
|
- 모든 API는 OpenAPI 3.0 스펙을 따릅니다
|
||||||
|
- Swagger UI/Editor에서 직접 테스트 가능합니다
|
||||||
|
- 자동 코드 생성 및 문서화를 지원합니다
|
||||||
|
|
||||||
|
### 2.2 RESTful 설계
|
||||||
|
- **리소스 중심 URL 구조**: `/api/{resource}/{id}`
|
||||||
|
- **HTTP 메서드**: GET (조회), POST (생성), PUT (수정), DELETE (삭제)
|
||||||
|
- **상태 코드**: 200 (성공), 201 (생성), 400 (잘못된 요청), 401 (인증 실패), 403 (권한 없음), 404 (리소스 없음), 500 (서버 오류)
|
||||||
|
|
||||||
|
### 2.3 유저스토리 기반 설계
|
||||||
|
- 각 API 엔드포인트는 유저스토리와 매핑됩니다
|
||||||
|
- **x-user-story** 필드로 유저스토리 ID를 명시합니다
|
||||||
|
- **x-controller** 필드로 담당 컨트롤러를 명시합니다
|
||||||
|
|
||||||
|
### 2.4 서비스 독립성
|
||||||
|
- 각 서비스는 독립적인 OpenAPI 명세를 가집니다
|
||||||
|
- 공통 스키마는 각 서비스에서 필요에 따라 정의합니다
|
||||||
|
- 서비스 간 통신은 REST API, Kafka 이벤트, Redis 캐시를 통해 이루어집니다
|
||||||
|
|
||||||
|
### 2.5 Example 데이터 제공
|
||||||
|
- 모든 스키마에 example 데이터가 포함됩니다
|
||||||
|
- Swagger UI에서 즉시 테스트 가능합니다
|
||||||
|
- 성공/실패 시나리오 모두 포함합니다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 서비스별 API 명세
|
||||||
|
|
||||||
|
### 3.1 User Service (사용자 인증 및 매장정보 관리)
|
||||||
|
|
||||||
|
**파일**: `user-service-api.yaml`
|
||||||
|
**관련 유저스토리**: UFR-USER-010, 020, 030, 040
|
||||||
|
|
||||||
|
#### API 엔드포인트 (7개)
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 | 유저스토리 | 인증 |
|
||||||
|
|--------|------|------|-----------|------|
|
||||||
|
| POST | /api/users/register | 회원가입 | UFR-USER-010 | - |
|
||||||
|
| POST | /api/users/login | 로그인 | UFR-USER-020 | - |
|
||||||
|
| POST | /api/users/logout | 로그아웃 | UFR-USER-040 | JWT |
|
||||||
|
| GET | /api/users/profile | 프로필 조회 | UFR-USER-030 | JWT |
|
||||||
|
| PUT | /api/users/profile | 프로필 수정 | UFR-USER-030 | JWT |
|
||||||
|
| PUT | /api/users/password | 비밀번호 변경 | UFR-USER-030 | JWT |
|
||||||
|
| GET | /api/users/{userId}/store | 매장정보 조회 (서비스 연동용) | - | JWT |
|
||||||
|
|
||||||
|
#### 주요 기능
|
||||||
|
- JWT 토큰 기반 인증 (TTL 7일)
|
||||||
|
- 사업자번호 검증 (국세청 API 연동)
|
||||||
|
- Redis 세션 관리
|
||||||
|
- BCrypt 비밀번호 해싱
|
||||||
|
- AES-256-GCM 사업자번호 암호화
|
||||||
|
|
||||||
|
#### 주요 스키마
|
||||||
|
- `UserRegisterRequest`: 회원가입 요청
|
||||||
|
- `UserLoginRequest`: 로그인 요청
|
||||||
|
- `UserProfileResponse`: 프로필 응답
|
||||||
|
- `StoreInfoResponse`: 매장정보 응답
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 Event Service (이벤트 전체 생명주기 관리)
|
||||||
|
|
||||||
|
**파일**: `event-service-api.yaml`
|
||||||
|
**관련 유저스토리**: UFR-EVENT-010 ~ 070
|
||||||
|
|
||||||
|
#### API 엔드포인트 (14개)
|
||||||
|
|
||||||
|
**Dashboard & Event List:**
|
||||||
|
| 메서드 | 경로 | 설명 | 유저스토리 | 인증 |
|
||||||
|
|--------|------|------|-----------|------|
|
||||||
|
| GET | /api/events | 이벤트 목록 조회 | UFR-EVENT-010, 070 | JWT |
|
||||||
|
| GET | /api/events/{eventId} | 이벤트 상세 조회 | UFR-EVENT-060 | JWT |
|
||||||
|
|
||||||
|
**Event Creation Flow (5 Steps):**
|
||||||
|
| 메서드 | 경로 | 설명 | 유저스토리 | 인증 |
|
||||||
|
|--------|------|------|-----------|------|
|
||||||
|
| POST | /api/events/objectives | Step 1: 이벤트 목적 선택 | UFR-EVENT-020 | JWT |
|
||||||
|
| POST | /api/events/{eventId}/ai-recommendations | Step 2: AI 추천 요청 | UFR-EVENT-030 | JWT |
|
||||||
|
| PUT | /api/events/{eventId}/recommendations | Step 2-2: AI 추천 선택 | UFR-EVENT-030 | JWT |
|
||||||
|
| POST | /api/events/{eventId}/images | Step 3: 이미지 생성 요청 | UFR-CONT-010 | JWT |
|
||||||
|
| PUT | /api/events/{eventId}/images/{imageId}/select | Step 3-2: 이미지 선택 | UFR-CONT-010 | JWT |
|
||||||
|
| PUT | /api/events/{eventId}/images/{imageId}/edit | Step 3-3: 이미지 편집 | UFR-CONT-020 | JWT |
|
||||||
|
| PUT | /api/events/{eventId}/channels | Step 4: 배포 채널 선택 | UFR-EVENT-040 | JWT |
|
||||||
|
| POST | /api/events/{eventId}/publish | Step 5: 최종 승인 및 배포 | UFR-EVENT-050 | JWT |
|
||||||
|
|
||||||
|
**Event Management:**
|
||||||
|
| 메서드 | 경로 | 설명 | 유저스토리 | 인증 |
|
||||||
|
|--------|------|------|-----------|------|
|
||||||
|
| PUT | /api/events/{eventId} | 이벤트 수정 | UFR-EVENT-060 | JWT |
|
||||||
|
| DELETE | /api/events/{eventId} | 이벤트 삭제 | UFR-EVENT-070 | JWT |
|
||||||
|
| POST | /api/events/{eventId}/end | 이벤트 조기 종료 | UFR-EVENT-060 | JWT |
|
||||||
|
|
||||||
|
**Job Status:**
|
||||||
|
| 메서드 | 경로 | 설명 | 유저스토리 | 인증 |
|
||||||
|
|--------|------|------|-----------|------|
|
||||||
|
| GET | /api/jobs/{jobId} | Job 상태 폴링 | UFR-EVENT-030, UFR-CONT-010 | JWT |
|
||||||
|
|
||||||
|
#### 주요 기능
|
||||||
|
- 이벤트 생명주기 관리 (DRAFT → PUBLISHED → ENDED)
|
||||||
|
- Kafka Job 발행 (ai-event-generation-job, image-generation-job)
|
||||||
|
- Kafka Event 발행 (EventCreated)
|
||||||
|
- Distribution Service 동기 호출
|
||||||
|
- Redis 기반 AI/이미지 데이터 캐싱
|
||||||
|
- Job 상태 폴링 메커니즘 (PENDING, PROCESSING, COMPLETED, FAILED)
|
||||||
|
|
||||||
|
#### 주요 스키마
|
||||||
|
- `EventObjectiveRequest`: 이벤트 목적 선택
|
||||||
|
- `EventResponse`: 이벤트 응답
|
||||||
|
- `JobStatusResponse`: Job 상태 응답
|
||||||
|
- `AIRecommendationSelection`: AI 추천 선택
|
||||||
|
- `ChannelSelectionRequest`: 배포 채널 선택
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 AI Service (AI 기반 이벤트 추천)
|
||||||
|
|
||||||
|
**파일**: `ai-service-api.yaml`
|
||||||
|
**관련 유저스토리**: UFR-AI-010
|
||||||
|
|
||||||
|
#### API 엔드포인트 (3개)
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 | 유저스토리 | 인증 |
|
||||||
|
|--------|------|------|-----------|------|
|
||||||
|
| GET | /health | 서비스 헬스체크 | - | - |
|
||||||
|
| GET | /internal/jobs/{jobId}/status | Job 상태 조회 (내부 API) | UFR-AI-010 | JWT |
|
||||||
|
| GET | /internal/recommendations/{eventId} | AI 추천 결과 조회 (내부 API) | UFR-AI-010 | JWT |
|
||||||
|
|
||||||
|
#### Kafka Consumer (비동기 처리)
|
||||||
|
- **Topic**: `ai-event-generation-job`
|
||||||
|
- **Consumer Group**: `ai-service-consumers`
|
||||||
|
- **처리 시간**: 최대 5분
|
||||||
|
- **결과 저장**: Redis (TTL 24시간)
|
||||||
|
|
||||||
|
#### 주요 기능
|
||||||
|
- 업종/지역/시즌 트렌드 분석
|
||||||
|
- 3가지 차별화된 이벤트 기획안 생성
|
||||||
|
- 예상 성과 계산 (참여자 수, ROI, 매출 증가율)
|
||||||
|
- Circuit Breaker 패턴 (5분 timeout, 캐시 fallback)
|
||||||
|
- Claude API / GPT-4 API 연동
|
||||||
|
|
||||||
|
#### 주요 스키마
|
||||||
|
- `KafkaAIJobMessage`: Kafka Job 입력
|
||||||
|
- `AIRecommendationResult`: AI 추천 결과 (트렌드 분석 + 3가지 옵션)
|
||||||
|
- `TrendAnalysis`: 업종/지역/시즌 트렌드
|
||||||
|
- `EventRecommendation`: 이벤트 기획안 (컨셉, 경품, 참여방법, 예상성과)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.4 Content Service (SNS 콘텐츠 생성)
|
||||||
|
|
||||||
|
**파일**: `content-service-api.yaml`
|
||||||
|
**관련 유저스토리**: UFR-CONT-010, 020
|
||||||
|
|
||||||
|
#### API 엔드포인트 (6개)
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 | 유저스토리 | 인증 |
|
||||||
|
|--------|------|------|-----------|------|
|
||||||
|
| POST | /api/content/images/generate | 이미지 생성 요청 (비동기) | UFR-CONT-010 | JWT |
|
||||||
|
| GET | /api/content/images/jobs/{jobId} | Job 상태 폴링 | UFR-CONT-010 | JWT |
|
||||||
|
| GET | /api/content/events/{eventDraftId} | 이벤트 전체 콘텐츠 조회 | UFR-CONT-020 | JWT |
|
||||||
|
| GET | /api/content/events/{eventDraftId}/images | 이미지 목록 조회 | UFR-CONT-020 | JWT |
|
||||||
|
| GET | /api/content/images/{imageId} | 이미지 상세 조회 | UFR-CONT-020 | JWT |
|
||||||
|
| POST | /api/content/images/{imageId}/regenerate | 이미지 재생성 | UFR-CONT-020 | JWT |
|
||||||
|
|
||||||
|
#### Kafka Consumer (비동기 처리)
|
||||||
|
- **Topic**: `image-generation-job`
|
||||||
|
- **Consumer Group**: `content-service-consumers`
|
||||||
|
- **처리 시간**: 최대 5분
|
||||||
|
- **결과 저장**: Redis (CDN URL, TTL 7일)
|
||||||
|
|
||||||
|
#### 주요 기능
|
||||||
|
- 3가지 스타일 이미지 생성 (SIMPLE, FANCY, TRENDY)
|
||||||
|
- 플랫폼별 최적화 (Instagram 1080x1080, Naver 800x600, Kakao 800x800)
|
||||||
|
- Circuit Breaker 패턴 (Stable Diffusion → DALL-E → Default Template)
|
||||||
|
- Azure Blob Storage (CDN) 연동
|
||||||
|
- Redis 기반 AI 데이터 읽기
|
||||||
|
|
||||||
|
#### 주요 스키마
|
||||||
|
- `ImageGenerationJob`: Kafka Job 입력
|
||||||
|
- `ImageGenerationRequest`: 이미지 생성 요청
|
||||||
|
- `GeneratedImage`: 생성된 이미지 (style, platform, CDN URL)
|
||||||
|
- `ContentResponse`: 전체 콘텐츠 응답
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.5 Distribution Service (다중 채널 배포 관리)
|
||||||
|
|
||||||
|
**파일**: `distribution-service-api.yaml`
|
||||||
|
**관련 유저스토리**: UFR-DIST-010, 020
|
||||||
|
|
||||||
|
#### API 엔드포인트 (2개)
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 | 유저스토리 | 인증 |
|
||||||
|
|--------|------|------|-----------|------|
|
||||||
|
| POST | /api/distribution/distribute | 다중 채널 배포 (동기) | UFR-DIST-010 | JWT |
|
||||||
|
| GET | /api/distribution/{eventId}/status | 배포 상태 조회 | UFR-DIST-020 | JWT |
|
||||||
|
|
||||||
|
#### 주요 기능
|
||||||
|
- **배포 채널**: 우리동네TV, 링고비즈, 지니TV, Instagram, Naver Blog, Kakao Channel
|
||||||
|
- **병렬 배포**: 6개 채널 동시 배포 (1분 이내)
|
||||||
|
- **Resilience 패턴**:
|
||||||
|
- Circuit Breaker: 채널별 독립 적용
|
||||||
|
- Retry: 최대 3회 재시도 (지수 백오프: 1s, 2s, 4s)
|
||||||
|
- Bulkhead: 채널별 스레드 풀 격리
|
||||||
|
- Fallback: 실패 채널 스킵 + 알림
|
||||||
|
- **Kafka Event 발행**: DistributionCompleted
|
||||||
|
- **로깅**: Event DB에 distribution_logs 저장
|
||||||
|
|
||||||
|
#### 주요 스키마
|
||||||
|
- `DistributionRequest`: 배포 요청
|
||||||
|
- `DistributionResponse`: 배포 응답 (채널별 결과)
|
||||||
|
- `DistributionStatusResponse`: 배포 상태
|
||||||
|
- `ChannelDistributionResult`: 채널별 배포 결과
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.6 Participation Service (이벤트 참여 및 당첨자 관리)
|
||||||
|
|
||||||
|
**파일**: `participation-service-api.yaml`
|
||||||
|
**관련 유저스토리**: UFR-PART-010, 020, 030
|
||||||
|
|
||||||
|
#### API 엔드포인트 (5개)
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 | 유저스토리 | 인증 |
|
||||||
|
|--------|------|------|-----------|------|
|
||||||
|
| POST | /api/events/{eventId}/participate | 이벤트 참여 | UFR-PART-010 | - |
|
||||||
|
| GET | /api/events/{eventId}/participants | 참여자 목록 조회 | UFR-PART-020 | JWT |
|
||||||
|
| GET | /api/events/{eventId}/participants/{participantId} | 참여자 상세 조회 | UFR-PART-020 | JWT |
|
||||||
|
| POST | /api/events/{eventId}/draw-winners | 당첨자 추첨 | UFR-PART-030 | JWT |
|
||||||
|
| GET | /api/events/{eventId}/winners | 당첨자 목록 조회 | UFR-PART-030 | JWT |
|
||||||
|
|
||||||
|
#### 주요 기능
|
||||||
|
- 중복 참여 체크 (전화번호 기반)
|
||||||
|
- 매장 방문 고객 가산점 적용
|
||||||
|
- 난수 기반 무작위 추첨
|
||||||
|
- Kafka Event 발행 (ParticipantRegistered)
|
||||||
|
- 개인정보 수집/이용 동의 관리
|
||||||
|
- 페이지네이션 지원
|
||||||
|
|
||||||
|
#### 주요 스키마
|
||||||
|
- `ParticipationRequest`: 참여 요청
|
||||||
|
- `ParticipationResponse`: 참여 응답 (응모번호)
|
||||||
|
- `ParticipantListResponse`: 참여자 목록
|
||||||
|
- `WinnerDrawRequest`: 당첨자 추첨 요청
|
||||||
|
- `WinnerResponse`: 당첨자 정보
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.7 Analytics Service (실시간 효과 측정 및 통합 대시보드)
|
||||||
|
|
||||||
|
**파일**: `analytics-service-api.yaml`
|
||||||
|
**관련 유저스토리**: UFR-ANAL-010
|
||||||
|
|
||||||
|
#### API 엔드포인트 (4개)
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 | 유저스토리 | 인증 |
|
||||||
|
|--------|------|------|-----------|------|
|
||||||
|
| GET | /api/events/{eventId}/analytics | 성과 대시보드 조회 | UFR-ANAL-010 | JWT |
|
||||||
|
| GET | /api/events/{eventId}/analytics/channels | 채널별 성과 분석 | UFR-ANAL-010 | JWT |
|
||||||
|
| GET | /api/events/{eventId}/analytics/timeline | 시간대별 참여 추이 | UFR-ANAL-010 | JWT |
|
||||||
|
| GET | /api/events/{eventId}/analytics/roi | 투자 대비 수익률 상세 | UFR-ANAL-010 | JWT |
|
||||||
|
|
||||||
|
#### Kafka Event 구독
|
||||||
|
- **EventCreated**: 이벤트 기본 통계 초기화
|
||||||
|
- **ParticipantRegistered**: 실시간 참여자 수 증가
|
||||||
|
- **DistributionCompleted**: 배포 채널 통계 업데이트
|
||||||
|
|
||||||
|
#### 주요 기능
|
||||||
|
- **실시간 대시보드**: Redis 캐싱 (TTL 5분)
|
||||||
|
- **외부 API 통합**: 우리동네TV, 지니TV, SNS APIs (조회수, 노출수, 소셜 인터랙션)
|
||||||
|
- **Circuit Breaker**: 외부 API 실패 시 캐시 fallback
|
||||||
|
- **ROI 계산**: 비용 대비 수익률 자동 계산
|
||||||
|
- **성과 집계**: 채널별, 시간대별 성과 분석
|
||||||
|
|
||||||
|
#### 주요 스키마
|
||||||
|
- `AnalyticsDashboardResponse`: 대시보드 전체 데이터
|
||||||
|
- `ChannelPerformanceResponse`: 채널별 성과
|
||||||
|
- `TimelineDataResponse`: 시간대별 참여 추이
|
||||||
|
- `RoiDetailResponse`: ROI 상세 분석
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. API 통합 가이드
|
||||||
|
|
||||||
|
### 4.1 이벤트 생성 플로우 (Event-Driven)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 이벤트 목적 선택 (Event Service)
|
||||||
|
POST /api/events/objectives
|
||||||
|
→ EventCreated 이벤트 발행 (Kafka)
|
||||||
|
→ Analytics Service 구독 (통계 초기화)
|
||||||
|
|
||||||
|
2. AI 추천 요청 (Event Service → AI Service)
|
||||||
|
POST /api/events/{eventId}/ai-recommendations
|
||||||
|
→ ai-event-generation-job 발행 (Kafka)
|
||||||
|
→ AI Service 구독 및 처리 (비동기)
|
||||||
|
→ Redis에 결과 저장 (TTL 24시간)
|
||||||
|
→ 클라이언트 폴링: GET /api/jobs/{jobId}
|
||||||
|
|
||||||
|
3. AI 추천 선택 (Event Service)
|
||||||
|
PUT /api/events/{eventId}/recommendations
|
||||||
|
→ Redis에서 AI 추천 데이터 읽기
|
||||||
|
→ Event DB에 선택된 추천 저장
|
||||||
|
|
||||||
|
4. 이미지 생성 요청 (Event Service → Content Service)
|
||||||
|
POST /api/events/{eventId}/images
|
||||||
|
→ image-generation-job 발행 (Kafka)
|
||||||
|
→ Content Service 구독 및 처리 (비동기)
|
||||||
|
→ Redis에서 AI 데이터 읽기
|
||||||
|
→ CDN에 이미지 업로드
|
||||||
|
→ Redis에 CDN URL 저장 (TTL 7일)
|
||||||
|
→ 클라이언트 폴링: GET /api/jobs/{jobId}
|
||||||
|
|
||||||
|
5. 이미지 선택 및 편집 (Event Service)
|
||||||
|
PUT /api/events/{eventId}/images/{imageId}/select
|
||||||
|
PUT /api/events/{eventId}/images/{imageId}/edit
|
||||||
|
|
||||||
|
6. 배포 채널 선택 (Event Service)
|
||||||
|
PUT /api/events/{eventId}/channels
|
||||||
|
|
||||||
|
7. 최종 승인 및 배포 (Event Service → Distribution Service)
|
||||||
|
POST /api/events/{eventId}/publish
|
||||||
|
→ Distribution Service 동기 호출: POST /api/distribution/distribute
|
||||||
|
→ 다중 채널 병렬 배포 (1분 이내)
|
||||||
|
→ DistributionCompleted 이벤트 발행 (Kafka)
|
||||||
|
→ Analytics Service 구독 (배포 통계 업데이트)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 고객 참여 플로우 (Event-Driven)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 이벤트 참여 (Participation Service)
|
||||||
|
POST /api/events/{eventId}/participate
|
||||||
|
→ 중복 참여 체크
|
||||||
|
→ Participation DB 저장
|
||||||
|
→ ParticipantRegistered 이벤트 발행 (Kafka)
|
||||||
|
→ Analytics Service 구독 (참여자 수 실시간 증가)
|
||||||
|
|
||||||
|
2. 당첨자 추첨 (Participation Service)
|
||||||
|
POST /api/events/{eventId}/draw-winners
|
||||||
|
→ 난수 기반 무작위 추첨
|
||||||
|
→ Winners DB 저장
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 성과 분석 플로우 (Event-Driven)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 실시간 대시보드 조회 (Analytics Service)
|
||||||
|
GET /api/events/{eventId}/analytics
|
||||||
|
→ Redis 캐시 확인 (TTL 5분)
|
||||||
|
→ 캐시 HIT: 즉시 반환
|
||||||
|
→ 캐시 MISS:
|
||||||
|
- Analytics DB 조회 (이벤트/참여 통계)
|
||||||
|
- 외부 APIs 조회 (우리동네TV, 지니TV, SNS) [Circuit Breaker]
|
||||||
|
- Redis 캐싱 후 반환
|
||||||
|
|
||||||
|
2. Kafka 이벤트 구독 (Analytics Service Background)
|
||||||
|
- EventCreated 구독 → 이벤트 기본 정보 초기화
|
||||||
|
- ParticipantRegistered 구독 → 참여자 수 실시간 증가
|
||||||
|
- DistributionCompleted 구독 → 배포 채널 통계 업데이트
|
||||||
|
- 캐시 무효화 → 다음 조회 시 최신 데이터 갱신
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 서비스 간 통신 패턴
|
||||||
|
|
||||||
|
| 패턴 | 사용 시나리오 | 통신 방식 | 예시 |
|
||||||
|
|------|-------------|----------|------|
|
||||||
|
| **동기 REST API** | 즉시 응답 필요 | HTTP/JSON | Distribution Service 배포 요청 |
|
||||||
|
| **Kafka Job Topics** | 장시간 비동기 작업 | Kafka 메시지 큐 | AI 추천, 이미지 생성 |
|
||||||
|
| **Kafka Event Topics** | 상태 변경 알림 | Kafka Pub/Sub | EventCreated, ParticipantRegistered |
|
||||||
|
| **Redis Cache** | 데이터 공유 | Redis Get/Set | AI 결과, 이미지 URL |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 보안 및 인증
|
||||||
|
|
||||||
|
### 5.1 JWT 기반 인증
|
||||||
|
|
||||||
|
**토큰 발급:**
|
||||||
|
- User Service에서 로그인/회원가입 시 JWT 토큰 발급
|
||||||
|
- 토큰 만료 시간: 7일
|
||||||
|
- Redis에 세션 정보 저장 (TTL 7일)
|
||||||
|
|
||||||
|
**토큰 검증:**
|
||||||
|
- API Gateway에서 모든 요청의 JWT 토큰 검증
|
||||||
|
- Authorization 헤더: `Bearer {token}`
|
||||||
|
- 검증 실패 시 401 Unauthorized 응답
|
||||||
|
|
||||||
|
**보호된 엔드포인트:**
|
||||||
|
- 모든 API (회원가입, 로그인, 이벤트 참여 제외)
|
||||||
|
|
||||||
|
### 5.2 민감 정보 암호화
|
||||||
|
|
||||||
|
- **비밀번호**: BCrypt 해싱 (Cost Factor: 10)
|
||||||
|
- **사업자번호**: AES-256-GCM 암호화
|
||||||
|
- **개인정보**: 전화번호 마스킹 (010-****-1234)
|
||||||
|
|
||||||
|
### 5.3 API Rate Limiting
|
||||||
|
|
||||||
|
- API Gateway에서 사용자당 100 req/min 제한
|
||||||
|
- Redis 기반 Rate Limiting 구현
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 에러 처리
|
||||||
|
|
||||||
|
### 6.1 표준 에러 응답 포맷
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"errorCode": "ERROR_CODE",
|
||||||
|
"message": "사용자 친화적인 에러 메시지",
|
||||||
|
"details": "상세 에러 정보 (선택)",
|
||||||
|
"timestamp": "2025-10-23T16:30:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 HTTP 상태 코드
|
||||||
|
|
||||||
|
| 상태 코드 | 설명 | 사용 예시 |
|
||||||
|
|----------|------|----------|
|
||||||
|
| 200 OK | 성공 | GET 요청 성공 |
|
||||||
|
| 201 Created | 생성 성공 | POST 요청으로 리소스 생성 |
|
||||||
|
| 400 Bad Request | 잘못된 요청 | 유효성 검증 실패 |
|
||||||
|
| 401 Unauthorized | 인증 실패 | JWT 토큰 없음/만료 |
|
||||||
|
| 403 Forbidden | 권한 없음 | 접근 권한 부족 |
|
||||||
|
| 404 Not Found | 리소스 없음 | 존재하지 않는 이벤트 조회 |
|
||||||
|
| 409 Conflict | 충돌 | 중복 참여, 동시성 문제 |
|
||||||
|
| 500 Internal Server Error | 서버 오류 | 서버 내부 오류 |
|
||||||
|
| 503 Service Unavailable | 서비스 불가 | Circuit Breaker Open |
|
||||||
|
|
||||||
|
### 6.3 서비스별 주요 에러 코드
|
||||||
|
|
||||||
|
**User Service:**
|
||||||
|
- `USER_001`: 중복 사용자
|
||||||
|
- `USER_002`: 사업자번호 검증 실패
|
||||||
|
- `USER_003`: 사용자 없음
|
||||||
|
- `AUTH_001`: 인증 실패
|
||||||
|
- `AUTH_002`: 유효하지 않은 토큰
|
||||||
|
|
||||||
|
**Event Service:**
|
||||||
|
- `EVENT_001`: 이벤트 없음
|
||||||
|
- `EVENT_002`: 유효하지 않은 상태 전환
|
||||||
|
- `EVENT_003`: 필수 데이터 누락 (AI 추천, 이미지)
|
||||||
|
- `JOB_001`: Job 없음
|
||||||
|
- `JOB_002`: Job 실패
|
||||||
|
|
||||||
|
**Participation Service:**
|
||||||
|
- `PART_001`: 중복 참여
|
||||||
|
- `PART_002`: 이벤트 기간 아님
|
||||||
|
- `PART_003`: 참여자 없음
|
||||||
|
|
||||||
|
**Distribution Service:**
|
||||||
|
- `DIST_001`: 배포 실패
|
||||||
|
- `DIST_002`: Circuit Breaker Open
|
||||||
|
|
||||||
|
**Analytics Service:**
|
||||||
|
- `ANALYTICS_001`: 데이터 없음
|
||||||
|
- `EXTERNAL_API_ERROR`: 외부 API 장애
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. API 테스트 가이드
|
||||||
|
|
||||||
|
### 7.1 Swagger UI를 통한 테스트
|
||||||
|
|
||||||
|
**방법 1: Swagger Editor**
|
||||||
|
1. https://editor.swagger.io/ 접속
|
||||||
|
2. 각 서비스의 YAML 파일 내용 붙여넣기
|
||||||
|
3. 우측 Swagger UI에서 API 테스트
|
||||||
|
|
||||||
|
**방법 2: SwaggerHub**
|
||||||
|
1. 각 API 명세의 `servers` 섹션에 SwaggerHub Mock Server URL 포함
|
||||||
|
2. Mock Server를 통한 즉시 테스트 가능
|
||||||
|
|
||||||
|
**방법 3: Redocly**
|
||||||
|
```bash
|
||||||
|
# 각 API 명세 검증
|
||||||
|
npx @redocly/cli lint design/backend/api/*.yaml
|
||||||
|
|
||||||
|
# 문서 HTML 생성
|
||||||
|
npx @redocly/cli build-docs design/backend/api/user-service-api.yaml \
|
||||||
|
--output docs/user-service-api.html
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 테스트 시나리오 예시
|
||||||
|
|
||||||
|
**1. 회원가입 → 로그인 → 이벤트 생성 플로우**
|
||||||
|
```bash
|
||||||
|
# 1. 회원가입
|
||||||
|
POST /api/users/register
|
||||||
|
{
|
||||||
|
"name": "김사장",
|
||||||
|
"phoneNumber": "010-1234-5678",
|
||||||
|
"email": "owner@example.com",
|
||||||
|
"password": "SecurePass123!",
|
||||||
|
"store": {
|
||||||
|
"name": "맛있는 고깃집",
|
||||||
|
"industry": "RESTAURANT",
|
||||||
|
"address": "서울시 강남구 테헤란로 123",
|
||||||
|
"businessNumber": "123-45-67890"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. 로그인
|
||||||
|
POST /api/users/login
|
||||||
|
{
|
||||||
|
"phoneNumber": "010-1234-5678",
|
||||||
|
"password": "SecurePass123!"
|
||||||
|
}
|
||||||
|
# → JWT 토큰 수신
|
||||||
|
|
||||||
|
# 3. 이벤트 목적 선택
|
||||||
|
POST /api/events/objectives
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
{
|
||||||
|
"objective": "NEW_CUSTOMER_ACQUISITION"
|
||||||
|
}
|
||||||
|
# → eventId 수신
|
||||||
|
|
||||||
|
# 4. AI 추천 요청
|
||||||
|
POST /api/events/{eventId}/ai-recommendations
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
# → jobId 수신
|
||||||
|
|
||||||
|
# 5. Job 상태 폴링 (5초 간격)
|
||||||
|
GET /api/jobs/{jobId}
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
# → status: COMPLETED 확인
|
||||||
|
|
||||||
|
# 6. AI 추천 선택
|
||||||
|
PUT /api/events/{eventId}/recommendations
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
{
|
||||||
|
"selectedOption": 1,
|
||||||
|
"customization": {
|
||||||
|
"title": "봄맞이 삼겹살 50% 할인 이벤트",
|
||||||
|
"prizeName": "삼겹살 1인분 무료"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ... (이미지 생성, 배포 채널 선택, 최종 승인)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 Mock 데이터 활용
|
||||||
|
|
||||||
|
- 모든 API 명세에 example 데이터 포함
|
||||||
|
- Swagger UI의 "Try it out" 기능으로 즉시 테스트
|
||||||
|
- 성공/실패 시나리오 모두 example 제공
|
||||||
|
|
||||||
|
### 7.4 통합 테스트 도구
|
||||||
|
|
||||||
|
**Postman Collection 생성:**
|
||||||
|
```bash
|
||||||
|
# OpenAPI 명세를 Postman Collection으로 변환
|
||||||
|
npx openapi-to-postmanv2 -s design/backend/api/user-service-api.yaml \
|
||||||
|
-o postman/user-service-collection.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Newman (CLI 테스트 실행):**
|
||||||
|
```bash
|
||||||
|
# Postman Collection 실행
|
||||||
|
newman run postman/user-service-collection.json \
|
||||||
|
--environment postman/dev-environment.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 부록
|
||||||
|
|
||||||
|
### A. 파일 통계
|
||||||
|
|
||||||
|
| 서비스 | 파일명 | 크기 | 라인 수 | API 수 |
|
||||||
|
|--------|--------|------|--------|--------|
|
||||||
|
| User | user-service-api.yaml | 31KB | 1,011 | 7 |
|
||||||
|
| Event | event-service-api.yaml | 41KB | 1,373 | 14 |
|
||||||
|
| AI | ai-service-api.yaml | 26KB | 847 | 3 |
|
||||||
|
| Content | content-service-api.yaml | 37KB | 1,158 | 6 |
|
||||||
|
| Distribution | distribution-service-api.yaml | 21KB | 653 | 2 |
|
||||||
|
| Participation | participation-service-api.yaml | 25KB | 820 | 5 |
|
||||||
|
| Analytics | analytics-service-api.yaml | 28KB | 1,050 | 4 |
|
||||||
|
| **합계** | - | **209KB** | **6,912** | **41** |
|
||||||
|
|
||||||
|
### B. 주요 의사결정
|
||||||
|
|
||||||
|
1. **OpenAPI 3.0 표준 채택**: 업계 표준 준수, 자동 코드 생성 지원
|
||||||
|
2. **서비스별 독립 명세**: 서비스 독립성 보장, 독립 배포 가능
|
||||||
|
3. **유저스토리 기반 설계**: x-user-story 필드로 추적성 확보
|
||||||
|
4. **Example 데이터 포함**: Swagger UI 즉시 테스트 가능
|
||||||
|
5. **JWT 인증 표준화**: 모든 서비스에서 일관된 인증 방식
|
||||||
|
6. **에러 응답 표준화**: 일관된 에러 응답 포맷
|
||||||
|
7. **Kafka + Redis 통합**: Event-Driven 아키텍처 지원
|
||||||
|
8. **Circuit Breaker 패턴**: 외부 API 장애 대응
|
||||||
|
|
||||||
|
### C. 다음 단계
|
||||||
|
|
||||||
|
1. **외부 시퀀스 설계**: 서비스 간 API 호출 흐름 상세 설계
|
||||||
|
2. **내부 시퀀스 설계**: 서비스 내부 컴포넌트 간 호출 흐름 설계
|
||||||
|
3. **클래스 설계**: 서비스별 클래스 다이어그램 작성
|
||||||
|
4. **데이터 설계**: 서비스별 데이터베이스 스키마 설계
|
||||||
|
5. **백엔드 개발**: OpenAPI 명세 기반 코드 생성 및 구현
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**문서 버전**: 1.0
|
||||||
|
**최종 수정일**: 2025-10-23
|
||||||
|
**작성자**: System Architect
|
||||||
914
design/backend/api/API_CONVENTION.md
Normal file
914
design/backend/api/API_CONVENTION.md
Normal file
@ -0,0 +1,914 @@
|
|||||||
|
# OpenAPI 3.0.3 공통 컨벤션
|
||||||
|
|
||||||
|
KT AI 기반 소상공인 이벤트 자동 생성 서비스의 모든 마이크로서비스 API 명세서에 적용되는 공통 컨벤션입니다.
|
||||||
|
|
||||||
|
## 목차
|
||||||
|
1. [기본 정보 섹션](#1-기본-정보-섹션)
|
||||||
|
2. [서버 정의](#2-서버-정의)
|
||||||
|
3. [보안 스키마](#3-보안-스키마)
|
||||||
|
4. [태그 구성](#4-태그-구성)
|
||||||
|
5. [엔드포인트 정의](#5-엔드포인트-정의)
|
||||||
|
6. [응답 구조](#6-응답-구조)
|
||||||
|
7. [에러 응답 구조](#7-에러-응답-구조)
|
||||||
|
8. [스키마 정의](#8-스키마-정의)
|
||||||
|
9. [메타데이터 주석](#9-메타데이터-주석)
|
||||||
|
10. [기술 명세 섹션](#10-기술-명세-섹션)
|
||||||
|
11. [예제 작성](#11-예제-작성)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 기본 정보 섹션
|
||||||
|
|
||||||
|
### 1.1 OpenAPI 버전
|
||||||
|
```yaml
|
||||||
|
openapi: 3.0.3
|
||||||
|
```
|
||||||
|
- **필수**: 모든 명세서는 OpenAPI 3.0.3 버전을 사용합니다.
|
||||||
|
|
||||||
|
### 1.2 Info 객체
|
||||||
|
```yaml
|
||||||
|
info:
|
||||||
|
title: {Service Name} API
|
||||||
|
description: |
|
||||||
|
KT AI 기반 소상공인 이벤트 자동 생성 서비스 - {Service Name} API
|
||||||
|
|
||||||
|
{서비스 설명 1-2줄}
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- {기능 1}
|
||||||
|
- {기능 2}
|
||||||
|
- {기능 3}
|
||||||
|
|
||||||
|
**보안:** (보안 관련 서비스인 경우)
|
||||||
|
- {보안 메커니즘 1}
|
||||||
|
- {보안 메커니즘 2}
|
||||||
|
version: 1.0.0
|
||||||
|
contact:
|
||||||
|
name: Digital Garage Team
|
||||||
|
email: support@kt-event-marketing.com
|
||||||
|
```
|
||||||
|
|
||||||
|
**필수 항목:**
|
||||||
|
- `title`: "{서비스명} API" 형식
|
||||||
|
- `description`: 마크다운 형식으로 서비스 설명 작성
|
||||||
|
- 첫 줄: 프로젝트명과 서비스 역할
|
||||||
|
- 서비스 설명
|
||||||
|
- 주요 기능 목록 (bullet points)
|
||||||
|
- 보안 관련 서비스의 경우 보안 섹션 추가
|
||||||
|
- `version`: "1.0.0"
|
||||||
|
- `contact`: name과 email 필수
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 서버 정의
|
||||||
|
|
||||||
|
### 2.1 서버 URL 구조
|
||||||
|
```yaml
|
||||||
|
servers:
|
||||||
|
- url: http://localhost:{port}
|
||||||
|
description: Local Development Server
|
||||||
|
- url: https://dev-api.kt-event-marketing.com/{service}/v1
|
||||||
|
description: Development Server
|
||||||
|
- url: https://api.kt-event-marketing.com/{service}/v1
|
||||||
|
description: Production Server
|
||||||
|
```
|
||||||
|
|
||||||
|
**포트 번호 할당:**
|
||||||
|
- User Service: 8081
|
||||||
|
- Event Service: 8080
|
||||||
|
- Content Service: 8082
|
||||||
|
- AI Service: 8083
|
||||||
|
- Participation Service: 8084
|
||||||
|
- Distribution Service: 8085
|
||||||
|
- Analytics Service: 8086
|
||||||
|
|
||||||
|
**URL 패턴:**
|
||||||
|
- Local: `http://localhost:{port}`
|
||||||
|
- Dev: `https://dev-api.kt-event-marketing.com/{service}/v1`
|
||||||
|
- Prod: `https://api.kt-event-marketing.com/{service}/v1`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 보안 스키마
|
||||||
|
|
||||||
|
### 3.1 JWT Bearer 인증
|
||||||
|
```yaml
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
BearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: JWT
|
||||||
|
description: |
|
||||||
|
JWT Bearer 토큰 인증
|
||||||
|
|
||||||
|
**형식:** Authorization: Bearer {JWT_TOKEN}
|
||||||
|
|
||||||
|
**토큰 만료:** 7일
|
||||||
|
|
||||||
|
**Claims:**
|
||||||
|
- userId: 사용자 ID
|
||||||
|
- role: 사용자 역할 (OWNER)
|
||||||
|
- iat: 발급 시각
|
||||||
|
- exp: 만료 시각
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 전역 보안 적용
|
||||||
|
```yaml
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
```
|
||||||
|
|
||||||
|
**적용 방법:**
|
||||||
|
- 인증이 필요한 모든 엔드포인트에 `security` 섹션 추가
|
||||||
|
- 공개 API (예: 로그인, 회원가입)는 엔드포인트 레벨에서 `security: []`로 오버라이드
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 태그 구성
|
||||||
|
|
||||||
|
### 4.1 태그 정의 패턴
|
||||||
|
```yaml
|
||||||
|
tags:
|
||||||
|
- name: {Category Name}
|
||||||
|
description: {카테고리 설명 (한글)}
|
||||||
|
```
|
||||||
|
|
||||||
|
**태그 명명 규칙:**
|
||||||
|
- **영문 사용**: 명확한 영문 카테고리명
|
||||||
|
- **설명 한글**: description은 한글로 상세 설명
|
||||||
|
- **일관성 유지**: 유사 기능은 동일한 태그명 사용
|
||||||
|
|
||||||
|
**예시:**
|
||||||
|
```yaml
|
||||||
|
tags:
|
||||||
|
- name: Authentication
|
||||||
|
description: 인증 관련 API (로그인, 로그아웃, 회원가입)
|
||||||
|
- name: Profile
|
||||||
|
description: 프로필 관련 API (조회, 수정, 비밀번호 변경)
|
||||||
|
- name: Event Creation
|
||||||
|
description: 이벤트 생성 플로우
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 엔드포인트 정의
|
||||||
|
|
||||||
|
### 5.1 엔드포인트 경로 규칙
|
||||||
|
|
||||||
|
**경로 패턴:**
|
||||||
|
```
|
||||||
|
/{resource}
|
||||||
|
/{resource}/{id}
|
||||||
|
/{resource}/{id}/{sub-resource}
|
||||||
|
```
|
||||||
|
|
||||||
|
**중요: `/api` prefix 사용 금지**
|
||||||
|
- ❌ 잘못된 예: `/api/users/register`
|
||||||
|
- ✅ 올바른 예: `/users/register`
|
||||||
|
|
||||||
|
API Gateway 또는 서버 URL에서 서비스 구분이 이루어지므로, 엔드포인트 경로에 `/api`를 포함하지 않습니다.
|
||||||
|
|
||||||
|
### 5.2 공통 엔드포인트 구조
|
||||||
|
```yaml
|
||||||
|
paths:
|
||||||
|
/{resource}:
|
||||||
|
{http-method}:
|
||||||
|
tags:
|
||||||
|
- {Tag Name}
|
||||||
|
summary: {짧은 한글 설명}
|
||||||
|
description: |
|
||||||
|
{상세 설명}
|
||||||
|
|
||||||
|
**유저스토리:** {UFR 코드}
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- {기능 1}
|
||||||
|
- {기능 2}
|
||||||
|
|
||||||
|
**처리 흐름:** (복잡한 로직인 경우)
|
||||||
|
1. {단계 1}
|
||||||
|
2. {단계 2}
|
||||||
|
|
||||||
|
**보안:** (보안 관련 엔드포인트인 경우)
|
||||||
|
- {보안 메커니즘}
|
||||||
|
operationId: {camelCase 메서드명}
|
||||||
|
x-user-story: {UFR 코드}
|
||||||
|
x-controller: {ControllerClass}.{methodName}
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/{ParameterName}'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/{RequestSchema}'
|
||||||
|
examples:
|
||||||
|
{exampleName}:
|
||||||
|
summary: {예시 설명}
|
||||||
|
value: {...}
|
||||||
|
responses:
|
||||||
|
'{statusCode}':
|
||||||
|
description: {응답 설명}
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/{ResponseSchema}'
|
||||||
|
examples:
|
||||||
|
{exampleName}:
|
||||||
|
summary: {예시 설명}
|
||||||
|
value: {...}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 필수 항목
|
||||||
|
- `tags`: 1개 이상의 태그 지정
|
||||||
|
- `summary`: 한글로 간결하게 (10자 이내 권장)
|
||||||
|
- `description`: 마크다운 형식의 상세 설명
|
||||||
|
- 유저스토리 코드 명시
|
||||||
|
- 주요 기능 bullet points
|
||||||
|
- 복잡한 경우 처리 흐름 순서 작성
|
||||||
|
- 보안 관련 내용 (해당 시)
|
||||||
|
- `operationId`: camelCase 메서드명 (예: `getUserProfile`, `createEvent`)
|
||||||
|
- `x-user-story`: UFR 코드 (예: `UFR-USER-010`)
|
||||||
|
- `x-controller`: 컨트롤러 클래스와 메서드 (예: `UserController.getProfile`)
|
||||||
|
|
||||||
|
### 5.4 operationId 명명 규칙
|
||||||
|
```
|
||||||
|
{동사}{명사}
|
||||||
|
```
|
||||||
|
|
||||||
|
**동사 목록:**
|
||||||
|
- `get`: 조회
|
||||||
|
- `list`: 목록 조회
|
||||||
|
- `create`: 생성
|
||||||
|
- `update`: 수정
|
||||||
|
- `delete`: 삭제
|
||||||
|
- `register`: 등록
|
||||||
|
- `login`: 로그인
|
||||||
|
- `logout`: 로그아웃
|
||||||
|
- `select`: 선택
|
||||||
|
- `request`: 요청
|
||||||
|
- `publish`: 배포
|
||||||
|
- `end`: 종료
|
||||||
|
|
||||||
|
**예시:**
|
||||||
|
- `getUser`, `listEvents`, `createEvent`
|
||||||
|
- `updateProfile`, `deleteEvent`
|
||||||
|
- `registerUser`, `loginUser`, `logoutUser`
|
||||||
|
- `selectRecommendation`, `publishEvent`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 응답 구조
|
||||||
|
|
||||||
|
### 6.1 성공 응답 (Success Response)
|
||||||
|
|
||||||
|
**원칙: 직접 응답 (Direct Response)**
|
||||||
|
```yaml
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: {작업} 성공
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/{ResponseSchema}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**응답 스키마 예시:**
|
||||||
|
```yaml
|
||||||
|
UserProfileResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- userId
|
||||||
|
- userName
|
||||||
|
- email
|
||||||
|
properties:
|
||||||
|
userId:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
description: 사용자 ID
|
||||||
|
example: 123
|
||||||
|
userName:
|
||||||
|
type: string
|
||||||
|
description: 사용자 이름
|
||||||
|
example: 홍길동
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
description: 이메일 주소
|
||||||
|
example: hong@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
**예외: Wrapper가 필요한 경우 (메시지 전달 필요 시)**
|
||||||
|
```yaml
|
||||||
|
LogoutResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- success
|
||||||
|
- message
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
description: 성공 여부
|
||||||
|
example: true
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
description: 응답 메시지
|
||||||
|
example: 안전하게 로그아웃되었습니다
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 페이징 응답 (Pagination Response)
|
||||||
|
```yaml
|
||||||
|
{Resource}ListResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- content
|
||||||
|
- page
|
||||||
|
properties:
|
||||||
|
content:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/{ResourceSummary}'
|
||||||
|
page:
|
||||||
|
$ref: '#/components/schemas/PageInfo'
|
||||||
|
|
||||||
|
PageInfo:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- page
|
||||||
|
- size
|
||||||
|
- totalElements
|
||||||
|
- totalPages
|
||||||
|
properties:
|
||||||
|
page:
|
||||||
|
type: integer
|
||||||
|
description: 현재 페이지 번호
|
||||||
|
example: 0
|
||||||
|
size:
|
||||||
|
type: integer
|
||||||
|
description: 페이지 크기
|
||||||
|
example: 20
|
||||||
|
totalElements:
|
||||||
|
type: integer
|
||||||
|
description: 전체 요소 개수
|
||||||
|
example: 45
|
||||||
|
totalPages:
|
||||||
|
type: integer
|
||||||
|
description: 전체 페이지 개수
|
||||||
|
example: 3
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 에러 응답 구조
|
||||||
|
|
||||||
|
### 7.1 표준 에러 응답 스키마
|
||||||
|
```yaml
|
||||||
|
ErrorResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- code
|
||||||
|
- message
|
||||||
|
- timestamp
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
description: 에러 코드
|
||||||
|
example: USER_001
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
description: 에러 메시지
|
||||||
|
example: 이미 가입된 전화번호입니다
|
||||||
|
timestamp:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: 에러 발생 시각
|
||||||
|
example: 2025-10-22T10:30:00Z
|
||||||
|
details:
|
||||||
|
type: array
|
||||||
|
description: 상세 에러 정보 (선택 사항)
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
example: ["필드명: 필수 항목입니다"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**필수 필드:**
|
||||||
|
- `code`: 에러 코드 (서비스별 고유 코드)
|
||||||
|
- `message`: 사용자에게 표시할 에러 메시지 (한글)
|
||||||
|
- `timestamp`: 에러 발생 시각 (ISO 8601 형식)
|
||||||
|
|
||||||
|
**선택 필드:**
|
||||||
|
- `details`: 상세 에러 정보 배열 (validation 에러 등)
|
||||||
|
|
||||||
|
### 7.2 에러 코드 명명 규칙
|
||||||
|
```
|
||||||
|
{SERVICE}_{NUMBER}
|
||||||
|
```
|
||||||
|
|
||||||
|
**서비스 약어:**
|
||||||
|
- `USER`: User Service
|
||||||
|
- `EVENT`: Event Service
|
||||||
|
- `CONT`: Content Service
|
||||||
|
- `AI`: AI Service
|
||||||
|
- `PART`: Participation Service
|
||||||
|
- `DIST`: Distribution Service
|
||||||
|
- `ANAL`: Analytics Service
|
||||||
|
- `AUTH`: 인증 관련 (공통)
|
||||||
|
- `VALIDATION_ERROR`: 입력 검증 오류 (공통)
|
||||||
|
|
||||||
|
**예시:**
|
||||||
|
- `USER_001`: 중복 사용자
|
||||||
|
- `USER_002`: 사업자번호 검증 실패
|
||||||
|
- `AUTH_001`: 인증 실패
|
||||||
|
- `AUTH_002`: 유효하지 않은 토큰
|
||||||
|
- `VALIDATION_ERROR`: 입력 검증 오류
|
||||||
|
|
||||||
|
### 7.3 공통 에러 응답 정의
|
||||||
|
```yaml
|
||||||
|
components:
|
||||||
|
responses:
|
||||||
|
BadRequest:
|
||||||
|
description: 잘못된 요청
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
examples:
|
||||||
|
validationError:
|
||||||
|
summary: 입력 검증 오류
|
||||||
|
value:
|
||||||
|
code: VALIDATION_ERROR
|
||||||
|
message: 요청 파라미터가 올바르지 않습니다
|
||||||
|
timestamp: 2025-10-22T10:30:00Z
|
||||||
|
details:
|
||||||
|
- "필드명: 필수 항목입니다"
|
||||||
|
|
||||||
|
Unauthorized:
|
||||||
|
description: 인증 실패
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
examples:
|
||||||
|
authFailed:
|
||||||
|
summary: 인증 실패
|
||||||
|
value:
|
||||||
|
code: AUTH_001
|
||||||
|
message: 인증에 실패했습니다
|
||||||
|
timestamp: 2025-10-22T10:30:00Z
|
||||||
|
|
||||||
|
Forbidden:
|
||||||
|
description: 권한 없음
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
examples:
|
||||||
|
forbidden:
|
||||||
|
summary: 권한 없음
|
||||||
|
value:
|
||||||
|
code: AUTH_003
|
||||||
|
message: 해당 리소스에 접근할 권한이 없습니다
|
||||||
|
timestamp: 2025-10-22T10:30:00Z
|
||||||
|
|
||||||
|
NotFound:
|
||||||
|
description: 리소스를 찾을 수 없음
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
examples:
|
||||||
|
notFound:
|
||||||
|
summary: 리소스 없음
|
||||||
|
value:
|
||||||
|
code: NOT_FOUND
|
||||||
|
message: 요청한 리소스를 찾을 수 없습니다
|
||||||
|
timestamp: 2025-10-22T10:30:00Z
|
||||||
|
|
||||||
|
InternalServerError:
|
||||||
|
description: 서버 내부 오류
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
examples:
|
||||||
|
serverError:
|
||||||
|
summary: 서버 오류
|
||||||
|
value:
|
||||||
|
code: INTERNAL_SERVER_ERROR
|
||||||
|
message: 서버 내부 오류가 발생했습니다
|
||||||
|
timestamp: 2025-10-22T10:30:00Z
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 엔드포인트별 에러 응답 적용
|
||||||
|
```yaml
|
||||||
|
responses:
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'500':
|
||||||
|
$ref: '#/components/responses/InternalServerError'
|
||||||
|
```
|
||||||
|
|
||||||
|
**특수 에러 (비즈니스 로직 에러):**
|
||||||
|
```yaml
|
||||||
|
'409':
|
||||||
|
description: 비즈니스 로직 충돌
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
examples:
|
||||||
|
duplicateUser:
|
||||||
|
summary: 중복 사용자
|
||||||
|
value:
|
||||||
|
code: USER_001
|
||||||
|
message: 이미 가입된 전화번호입니다
|
||||||
|
timestamp: 2025-10-22T10:30:00Z
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 스키마 정의
|
||||||
|
|
||||||
|
### 8.1 스키마 명명 규칙
|
||||||
|
|
||||||
|
**Request 스키마:**
|
||||||
|
```
|
||||||
|
{Action}{Resource}Request
|
||||||
|
```
|
||||||
|
예: `RegisterRequest`, `LoginRequest`, `CreateEventRequest`
|
||||||
|
|
||||||
|
**Response 스키마:**
|
||||||
|
```
|
||||||
|
{Resource}{Type}Response
|
||||||
|
```
|
||||||
|
예: `UserProfileResponse`, `EventListResponse`, `EventDetailResponse`
|
||||||
|
|
||||||
|
**공통 모델:**
|
||||||
|
```
|
||||||
|
{Resource}{Type}
|
||||||
|
```
|
||||||
|
예: `EventSummary`, `GeneratedImage`, `PageInfo`
|
||||||
|
|
||||||
|
### 8.2 스키마 작성 원칙
|
||||||
|
|
||||||
|
**필수 항목:**
|
||||||
|
- `type`: 객체 타입 (object, array, string 등)
|
||||||
|
- `required`: 필수 필드 목록
|
||||||
|
- `properties`: 각 필드 정의
|
||||||
|
- `type`: 필드 타입
|
||||||
|
- `description`: 필드 설명 (한글)
|
||||||
|
- `example`: 예시 값
|
||||||
|
|
||||||
|
**선택 항목:**
|
||||||
|
- `format`: 특수 형식 (date, date-time, email, uri, uuid, int64 등)
|
||||||
|
- `pattern`: 정규식 패턴 (전화번호, 사업자번호 등)
|
||||||
|
- `minLength`, `maxLength`: 문자열 길이 제한
|
||||||
|
- `minimum`, `maximum`: 숫자 범위 제한
|
||||||
|
- `enum`: 허용 값 목록
|
||||||
|
|
||||||
|
**예시:**
|
||||||
|
```yaml
|
||||||
|
RegisterRequest:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
- phoneNumber
|
||||||
|
- email
|
||||||
|
- password
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
minLength: 2
|
||||||
|
maxLength: 50
|
||||||
|
description: 사용자 이름 (2자 이상, 한글/영문)
|
||||||
|
example: 홍길동
|
||||||
|
phoneNumber:
|
||||||
|
type: string
|
||||||
|
pattern: '^010\d{8}$'
|
||||||
|
description: 휴대폰 번호 (010XXXXXXXX)
|
||||||
|
example: "01012345678"
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
maxLength: 100
|
||||||
|
description: 이메일 주소
|
||||||
|
example: hong@example.com
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
minLength: 8
|
||||||
|
maxLength: 100
|
||||||
|
description: 비밀번호 (8자 이상, 영문/숫자/특수문자 포함)
|
||||||
|
example: "Password123!"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 날짜/시간 형식
|
||||||
|
|
||||||
|
**날짜:** `format: date`, 형식 `YYYY-MM-DD`
|
||||||
|
```yaml
|
||||||
|
startDate:
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
description: 시작일
|
||||||
|
example: "2025-03-01"
|
||||||
|
```
|
||||||
|
|
||||||
|
**날짜/시간:** `format: date-time`, 형식 `ISO 8601`
|
||||||
|
```yaml
|
||||||
|
createdAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: 생성일시
|
||||||
|
example: 2025-10-22T10:30:00Z
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.4 ID 형식
|
||||||
|
|
||||||
|
**UUID:**
|
||||||
|
```yaml
|
||||||
|
eventId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: 이벤트 ID
|
||||||
|
example: "550e8400-e29b-41d4-a716-446655440000"
|
||||||
|
```
|
||||||
|
|
||||||
|
**정수 ID:**
|
||||||
|
```yaml
|
||||||
|
userId:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
description: 사용자 ID
|
||||||
|
example: 123
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 메타데이터 주석
|
||||||
|
|
||||||
|
### 9.1 필수 메타데이터
|
||||||
|
```yaml
|
||||||
|
x-user-story: {UFR 코드}
|
||||||
|
x-controller: {ControllerClass}.{methodName}
|
||||||
|
```
|
||||||
|
|
||||||
|
**x-user-story:**
|
||||||
|
- 유저스토리 코드 명시
|
||||||
|
- 여러 유저스토리와 관련된 경우 콤마로 구분
|
||||||
|
- 예: `UFR-USER-010`, `UFR-EVENT-010, UFR-EVENT-070`
|
||||||
|
|
||||||
|
**x-controller:**
|
||||||
|
- 컨트롤러 클래스와 메서드 매핑
|
||||||
|
- 백엔드 개발 시 참조
|
||||||
|
- 예: `UserController.registerUser`, `EventController.getEvents`
|
||||||
|
|
||||||
|
### 9.2 선택 메타데이터 (필요 시)
|
||||||
|
```yaml
|
||||||
|
x-internal: true # 내부 API 표시
|
||||||
|
x-async: true # 비동기 처리 표시
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 기술 명세 섹션
|
||||||
|
|
||||||
|
### 10.1 x-technical-specifications
|
||||||
|
|
||||||
|
**비동기 처리 서비스 (AI, Content 등):**
|
||||||
|
```yaml
|
||||||
|
x-technical-specifications:
|
||||||
|
async-processing:
|
||||||
|
message-queue: Kafka
|
||||||
|
topics:
|
||||||
|
request: ai.recommendation.request
|
||||||
|
response: ai.recommendation.response
|
||||||
|
job-tracking: Redis (TTL 24h)
|
||||||
|
timeout: 300s
|
||||||
|
|
||||||
|
resilience:
|
||||||
|
circuit-breaker:
|
||||||
|
failure-threshold: 5
|
||||||
|
timeout: 10s
|
||||||
|
half-open-requests: 3
|
||||||
|
retry:
|
||||||
|
max-attempts: 3
|
||||||
|
backoff: exponential
|
||||||
|
initial-interval: 1s
|
||||||
|
max-interval: 10s
|
||||||
|
fallback:
|
||||||
|
strategy: cached-result
|
||||||
|
|
||||||
|
caching:
|
||||||
|
provider: Redis
|
||||||
|
ttl: 7d
|
||||||
|
key-pattern: "content:event:{eventDraftId}"
|
||||||
|
|
||||||
|
external-apis:
|
||||||
|
- name: Claude API
|
||||||
|
endpoint: https://api.anthropic.com/v1/messages
|
||||||
|
timeout: 60s
|
||||||
|
circuit-breaker: true
|
||||||
|
- name: GPT-4 API
|
||||||
|
endpoint: https://api.openai.com/v1/chat/completions
|
||||||
|
timeout: 60s
|
||||||
|
circuit-breaker: true
|
||||||
|
```
|
||||||
|
|
||||||
|
**동기 처리 서비스:**
|
||||||
|
```yaml
|
||||||
|
x-technical-specifications:
|
||||||
|
database:
|
||||||
|
type: PostgreSQL
|
||||||
|
connection-pool:
|
||||||
|
min: 10
|
||||||
|
max: 50
|
||||||
|
timeout: 30s
|
||||||
|
|
||||||
|
caching:
|
||||||
|
provider: Redis
|
||||||
|
ttl: 30m
|
||||||
|
key-pattern: "user:{userId}"
|
||||||
|
|
||||||
|
security:
|
||||||
|
authentication: JWT Bearer
|
||||||
|
password-hashing: bcrypt
|
||||||
|
encryption:
|
||||||
|
algorithm: AES-256-GCM
|
||||||
|
fields: [businessNumber]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 적용 기준
|
||||||
|
|
||||||
|
**필수 포함 서비스:**
|
||||||
|
- Content Service: 비동기 처리, Kafka, 외부 API 통합
|
||||||
|
- AI Service: 비동기 처리, Kafka, Claude/GPT 통합
|
||||||
|
|
||||||
|
**선택 포함 서비스:**
|
||||||
|
- User Service: 보안 관련 명세
|
||||||
|
- Event Service: 오케스트레이션 패턴
|
||||||
|
- Participation Service: 대용량 트래픽 대비 캐싱
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 예제 작성
|
||||||
|
|
||||||
|
### 11.1 Request/Response 예제 원칙
|
||||||
|
|
||||||
|
**모든 requestBody와 주요 response에 예제 필수:**
|
||||||
|
```yaml
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/RegisterRequest'
|
||||||
|
examples:
|
||||||
|
restaurant:
|
||||||
|
summary: 음식점 회원가입 예시
|
||||||
|
value:
|
||||||
|
name: 홍길동
|
||||||
|
phoneNumber: "01012345678"
|
||||||
|
email: hong@example.com
|
||||||
|
password: "Password123!"
|
||||||
|
storeName: 맛있는집
|
||||||
|
industry: 음식점
|
||||||
|
cafe:
|
||||||
|
summary: 카페 회원가입 예시
|
||||||
|
value:
|
||||||
|
name: 김철수
|
||||||
|
phoneNumber: "01087654321"
|
||||||
|
email: kim@example.com
|
||||||
|
password: "SecurePass456!"
|
||||||
|
storeName: 아메리카노 카페
|
||||||
|
industry: 카페
|
||||||
|
```
|
||||||
|
|
||||||
|
**성공 응답 예제:**
|
||||||
|
```yaml
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 프로필 조회 성공
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ProfileResponse'
|
||||||
|
examples:
|
||||||
|
success:
|
||||||
|
summary: 프로필 조회 성공 응답
|
||||||
|
value:
|
||||||
|
userId: 123
|
||||||
|
userName: 홍길동
|
||||||
|
phoneNumber: "01012345678"
|
||||||
|
email: hong@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
**에러 응답 예제:**
|
||||||
|
```yaml
|
||||||
|
responses:
|
||||||
|
'400':
|
||||||
|
description: 잘못된 요청
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
examples:
|
||||||
|
duplicateUser:
|
||||||
|
summary: 중복 사용자
|
||||||
|
value:
|
||||||
|
code: USER_001
|
||||||
|
message: 이미 가입된 전화번호입니다
|
||||||
|
timestamp: 2025-10-22T10:30:00Z
|
||||||
|
invalidBusinessNumber:
|
||||||
|
summary: 사업자번호 검증 실패
|
||||||
|
value:
|
||||||
|
code: USER_002
|
||||||
|
message: 유효하지 않은 사업자번호입니다
|
||||||
|
timestamp: 2025-10-22T10:30:00Z
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.2 예제 명명 규칙
|
||||||
|
- `success`: 성공 케이스
|
||||||
|
- `{errorType}`: 에러 케이스 (예: `duplicateUser`, `validationError`)
|
||||||
|
- `{scenario}`: 시나리오별 예제 (예: `restaurant`, `cafe`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 체크리스트
|
||||||
|
|
||||||
|
API 명세서 작성 시 아래 체크리스트를 확인하세요:
|
||||||
|
|
||||||
|
### 기본 정보
|
||||||
|
- [ ] OpenAPI 버전 3.0.3 명시
|
||||||
|
- [ ] info.title에 서비스명 포함
|
||||||
|
- [ ] info.description에 주요 기능 목록 포함
|
||||||
|
- [ ] info.version 1.0.0
|
||||||
|
- [ ] contact 정보 포함
|
||||||
|
|
||||||
|
### 서버 및 보안
|
||||||
|
- [ ] servers에 Local, Dev, Prod 정의
|
||||||
|
- [ ] 포트 번호 정확히 할당
|
||||||
|
- [ ] components.securitySchemes에 BearerAuth 정의
|
||||||
|
- [ ] 인증 필요한 엔드포인트에 security 적용
|
||||||
|
|
||||||
|
### 엔드포인트
|
||||||
|
- [ ] 모든 엔드포인트에 tags 지정
|
||||||
|
- [ ] summary와 description 작성 (한글)
|
||||||
|
- [ ] operationId camelCase로 작성
|
||||||
|
- [ ] x-user-story UFR 코드 명시
|
||||||
|
- [ ] x-controller 매핑 정보 포함
|
||||||
|
|
||||||
|
### 스키마
|
||||||
|
- [ ] Request/Response 스키마 명명 규칙 준수
|
||||||
|
- [ ] required 필드 명시
|
||||||
|
- [ ] 모든 properties에 description과 example 포함
|
||||||
|
- [ ] 적절한 format 사용 (date, date-time, email, uuid 등)
|
||||||
|
|
||||||
|
### 응답 구조
|
||||||
|
- [ ] ErrorResponse 표준 스키마 사용
|
||||||
|
- [ ] 공통 에러 응답 ($ref) 활용
|
||||||
|
- [ ] 에러 코드 명명 규칙 준수
|
||||||
|
- [ ] 페이징 응답에 PageInfo 사용
|
||||||
|
|
||||||
|
### 예제
|
||||||
|
- [ ] requestBody에 최소 1개 이상 예제
|
||||||
|
- [ ] 주요 response에 success 예제
|
||||||
|
- [ ] 주요 에러 케이스에 예제
|
||||||
|
|
||||||
|
### 기술 명세 (해당 시)
|
||||||
|
- [ ] 비동기 처리 서비스: x-technical-specifications 포함
|
||||||
|
- [ ] Kafka 토픽, Redis 캐싱 정보 명시
|
||||||
|
- [ ] 외부 API 연동 정보 포함
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 참고 자료
|
||||||
|
|
||||||
|
### 서비스별 API 명세서
|
||||||
|
- User Service API: `/design/backend/api/user-service-api.yaml`
|
||||||
|
- Event Service API: `/design/backend/api/event-service-api.yaml`
|
||||||
|
- Content Service API: `/design/backend/api/content-service-api.yaml`
|
||||||
|
- AI Service API: `/design/backend/api/ai-service-api.yaml`
|
||||||
|
- Participation Service API: `/design/backend/api/participation-service-api.yaml`
|
||||||
|
- Distribution Service API: `/design/backend/api/distribution-service-api.yaml`
|
||||||
|
- Analytics Service API: `/design/backend/api/analytics-service-api.yaml`
|
||||||
|
|
||||||
|
### OpenAPI 3.0.3 공식 문서
|
||||||
|
- https://swagger.io/specification/
|
||||||
|
|
||||||
|
### 프로젝트 아키텍처
|
||||||
|
- High-Level Architecture: `/design/high-level-architecture.md`
|
||||||
|
- Logical Architecture: `/design/backend/logical/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**문서 버전:** 1.0.0
|
||||||
|
**최종 수정일:** 2025-10-23
|
||||||
|
**작성자:** Digital Garage Team
|
||||||
849
design/backend/api/ai-service-api.yaml
Normal file
849
design/backend/api/ai-service-api.yaml
Normal file
@ -0,0 +1,849 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: AI Service API
|
||||||
|
description: |
|
||||||
|
KT AI 기반 소상공인 이벤트 자동 생성 서비스 - AI Service
|
||||||
|
|
||||||
|
## 서비스 개요
|
||||||
|
- Kafka를 통한 비동기 AI 추천 처리
|
||||||
|
- Claude API / GPT-4 API 연동
|
||||||
|
- Redis 기반 결과 캐싱 (TTL 24시간)
|
||||||
|
|
||||||
|
## 처리 흐름
|
||||||
|
1. Event Service가 Kafka Topic에 Job 메시지 발행
|
||||||
|
2. AI Service가 메시지 구독 및 처리
|
||||||
|
3. 트렌드 분석 수행 (Claude/GPT-4 API)
|
||||||
|
4. 3가지 이벤트 추천안 생성
|
||||||
|
5. 결과를 Redis에 저장 (TTL 24시간)
|
||||||
|
6. Job 상태를 Redis에 업데이트
|
||||||
|
|
||||||
|
## 외부 API 통합
|
||||||
|
- **Claude API / GPT-4 API**: 트렌드 분석 및 이벤트 추천
|
||||||
|
- **Circuit Breaker**: 5분 타임아웃, Fallback to 캐시
|
||||||
|
- **Retry**: 최대 3회, Exponential Backoff
|
||||||
|
|
||||||
|
version: 1.0.0
|
||||||
|
contact:
|
||||||
|
name: Digital Garage Team
|
||||||
|
email: support@kt-event-marketing.com
|
||||||
|
|
||||||
|
servers:
|
||||||
|
- url: http://localhost:8083
|
||||||
|
description: Local Development Server
|
||||||
|
- url: https://dev-api.kt-event-marketing.com/ai/v1
|
||||||
|
description: Development Server
|
||||||
|
- url: https://api.kt-event-marketing.com/ai/v1
|
||||||
|
description: Production Server
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- name: Health Check
|
||||||
|
description: 서비스 상태 확인
|
||||||
|
- name: Internal API
|
||||||
|
description: 내부 서비스 간 통신용 API
|
||||||
|
- name: Kafka Consumer
|
||||||
|
description: 비동기 작업 처리 (문서화만)
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/health:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Health Check
|
||||||
|
summary: 서비스 헬스체크
|
||||||
|
description: AI Service 상태 및 외부 연동 확인
|
||||||
|
operationId: healthCheck
|
||||||
|
x-user-story: System
|
||||||
|
x-controller: HealthController
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 서비스 정상
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/HealthCheckResponse'
|
||||||
|
example:
|
||||||
|
status: UP
|
||||||
|
timestamp: "2025-10-23T10:30:00Z"
|
||||||
|
services:
|
||||||
|
kafka: UP
|
||||||
|
redis: UP
|
||||||
|
claude_api: UP
|
||||||
|
gpt4_api: UP
|
||||||
|
circuit_breaker: CLOSED
|
||||||
|
|
||||||
|
/internal/jobs/{jobId}/status:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Internal API
|
||||||
|
summary: 작업 상태 조회
|
||||||
|
description: Redis에 저장된 AI 추천 작업 상태 조회 (Event Service에서 호출)
|
||||||
|
operationId: getJobStatus
|
||||||
|
x-user-story: UFR-AI-010
|
||||||
|
x-controller: InternalJobController
|
||||||
|
parameters:
|
||||||
|
- name: jobId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Job ID
|
||||||
|
example: "job-ai-evt001-20251023103000"
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 작업 상태 조회 성공
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/JobStatusResponse'
|
||||||
|
examples:
|
||||||
|
processing:
|
||||||
|
summary: 처리 중
|
||||||
|
value:
|
||||||
|
jobId: "job-ai-evt001-20251023103000"
|
||||||
|
status: "PROCESSING"
|
||||||
|
progress: 50
|
||||||
|
message: "AI 추천 생성 중"
|
||||||
|
createdAt: "2025-10-23T10:30:00Z"
|
||||||
|
startedAt: "2025-10-23T10:30:05Z"
|
||||||
|
completed:
|
||||||
|
summary: 완료
|
||||||
|
value:
|
||||||
|
jobId: "job-ai-evt001-20251023103000"
|
||||||
|
status: "COMPLETED"
|
||||||
|
progress: 100
|
||||||
|
message: "AI 추천 완료"
|
||||||
|
createdAt: "2025-10-23T10:30:00Z"
|
||||||
|
startedAt: "2025-10-23T10:30:05Z"
|
||||||
|
completedAt: "2025-10-23T10:35:00Z"
|
||||||
|
processingTimeMs: 295000
|
||||||
|
failed:
|
||||||
|
summary: 실패
|
||||||
|
value:
|
||||||
|
jobId: "job-ai-evt001-20251023103000"
|
||||||
|
status: "FAILED"
|
||||||
|
progress: 0
|
||||||
|
message: "Claude API timeout"
|
||||||
|
errorMessage: "Claude API timeout after 5 minutes"
|
||||||
|
createdAt: "2025-10-23T10:30:00Z"
|
||||||
|
startedAt: "2025-10-23T10:30:05Z"
|
||||||
|
failedAt: "2025-10-23T10:35:05Z"
|
||||||
|
retryCount: 3
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'500':
|
||||||
|
$ref: '#/components/responses/InternalServerError'
|
||||||
|
|
||||||
|
/internal/recommendations/{eventId}:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Internal API
|
||||||
|
summary: AI 추천 결과 조회
|
||||||
|
description: Redis에 캐시된 AI 추천 결과 조회 (Event Service에서 호출)
|
||||||
|
operationId: getRecommendation
|
||||||
|
x-user-story: UFR-AI-010
|
||||||
|
x-controller: InternalRecommendationController
|
||||||
|
parameters:
|
||||||
|
- name: eventId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: 이벤트 ID
|
||||||
|
example: "evt-001"
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 추천 결과 조회 성공
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/AIRecommendationResult'
|
||||||
|
example:
|
||||||
|
eventId: "evt-001"
|
||||||
|
trendAnalysis:
|
||||||
|
industryTrends:
|
||||||
|
- keyword: "프리미엄 디저트"
|
||||||
|
relevance: 0.85
|
||||||
|
description: "고급 디저트 카페 트렌드 증가"
|
||||||
|
regionalTrends:
|
||||||
|
- keyword: "핫플레이스"
|
||||||
|
relevance: 0.78
|
||||||
|
description: "강남 신논현역 주변 유동인구 증가"
|
||||||
|
seasonalTrends:
|
||||||
|
- keyword: "가을 시즌"
|
||||||
|
relevance: 0.92
|
||||||
|
description: "가을 시즌 한정 메뉴 선호도 증가"
|
||||||
|
recommendations:
|
||||||
|
- optionNumber: 1
|
||||||
|
concept: "프리미엄 경험형"
|
||||||
|
title: "가을 한정 시그니처 디저트 페어링 이벤트"
|
||||||
|
description: "가을 제철 재료를 활용한 시그니처 디저트와 음료 페어링 체험"
|
||||||
|
targetAudience: "20-30대 여성, SNS 활동적인 고객"
|
||||||
|
duration:
|
||||||
|
recommendedDays: 14
|
||||||
|
recommendedPeriod: "10월 중순 ~ 11월 초"
|
||||||
|
mechanics:
|
||||||
|
type: "EXPERIENCE"
|
||||||
|
details: "디저트+음료 페어링 세트 주문 시 인스타그램 업로드 고객에게 다음 방문 시 사용 가능한 10% 할인권 제공"
|
||||||
|
promotionChannels:
|
||||||
|
- "Instagram"
|
||||||
|
- "카카오톡 채널"
|
||||||
|
- "네이버 플레이스"
|
||||||
|
estimatedCost:
|
||||||
|
min: 300000
|
||||||
|
max: 500000
|
||||||
|
breakdown:
|
||||||
|
material: 200000
|
||||||
|
promotion: 150000
|
||||||
|
discount: 150000
|
||||||
|
expectedMetrics:
|
||||||
|
newCustomers:
|
||||||
|
min: 50
|
||||||
|
max: 80
|
||||||
|
repeatVisits:
|
||||||
|
min: 30
|
||||||
|
max: 50
|
||||||
|
revenueIncrease:
|
||||||
|
min: 15.0
|
||||||
|
max: 25.0
|
||||||
|
roi:
|
||||||
|
min: 120.0
|
||||||
|
max: 180.0
|
||||||
|
socialEngagement:
|
||||||
|
estimatedPosts: 100
|
||||||
|
estimatedReach: 5000
|
||||||
|
differentiator: "프리미엄 경험 제공으로 고객 만족도와 SNS 바이럴 효과 극대화"
|
||||||
|
generatedAt: "2025-10-23T10:35:00Z"
|
||||||
|
expiresAt: "2025-10-24T10:35:00Z"
|
||||||
|
aiProvider: "CLAUDE"
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'500':
|
||||||
|
$ref: '#/components/responses/InternalServerError'
|
||||||
|
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
# ==================== Health Check ====================
|
||||||
|
HealthCheckResponse:
|
||||||
|
type: object
|
||||||
|
description: 서비스 헬스체크 응답
|
||||||
|
required:
|
||||||
|
- status
|
||||||
|
- timestamp
|
||||||
|
- services
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [UP, DOWN, DEGRADED]
|
||||||
|
description: 전체 서비스 상태
|
||||||
|
example: UP
|
||||||
|
timestamp:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: 체크 시각
|
||||||
|
example: "2025-10-23T10:30:00Z"
|
||||||
|
services:
|
||||||
|
type: object
|
||||||
|
description: 개별 서비스 상태
|
||||||
|
required:
|
||||||
|
- kafka
|
||||||
|
- redis
|
||||||
|
- claude_api
|
||||||
|
- circuit_breaker
|
||||||
|
properties:
|
||||||
|
kafka:
|
||||||
|
type: string
|
||||||
|
enum: [UP, DOWN]
|
||||||
|
description: Kafka 연결 상태
|
||||||
|
example: UP
|
||||||
|
redis:
|
||||||
|
type: string
|
||||||
|
enum: [UP, DOWN]
|
||||||
|
description: Redis 연결 상태
|
||||||
|
example: UP
|
||||||
|
claude_api:
|
||||||
|
type: string
|
||||||
|
enum: [UP, DOWN, CIRCUIT_OPEN]
|
||||||
|
description: Claude API 상태
|
||||||
|
example: UP
|
||||||
|
gpt4_api:
|
||||||
|
type: string
|
||||||
|
enum: [UP, DOWN, CIRCUIT_OPEN]
|
||||||
|
description: GPT-4 API 상태 (선택)
|
||||||
|
example: UP
|
||||||
|
circuit_breaker:
|
||||||
|
type: string
|
||||||
|
enum: [CLOSED, OPEN, HALF_OPEN]
|
||||||
|
description: Circuit Breaker 상태
|
||||||
|
example: CLOSED
|
||||||
|
|
||||||
|
# ==================== Kafka Job Message (문서화만) ====================
|
||||||
|
KafkaAIJobMessage:
|
||||||
|
type: object
|
||||||
|
description: |
|
||||||
|
**Kafka Topic**: `ai-event-generation-job`
|
||||||
|
**Consumer Group**: `ai-service-consumers`
|
||||||
|
**처리 방식**: 비동기
|
||||||
|
**최대 처리 시간**: 5분
|
||||||
|
|
||||||
|
AI 이벤트 생성 요청 메시지
|
||||||
|
required:
|
||||||
|
- jobId
|
||||||
|
- eventId
|
||||||
|
- objective
|
||||||
|
- industry
|
||||||
|
- region
|
||||||
|
properties:
|
||||||
|
jobId:
|
||||||
|
type: string
|
||||||
|
description: Job 고유 ID
|
||||||
|
example: "job-ai-evt001-20251023103000"
|
||||||
|
eventId:
|
||||||
|
type: string
|
||||||
|
description: 이벤트 ID (Event Service에서 생성)
|
||||||
|
example: "evt-001"
|
||||||
|
objective:
|
||||||
|
type: string
|
||||||
|
description: 이벤트 목적
|
||||||
|
enum:
|
||||||
|
- "신규 고객 유치"
|
||||||
|
- "재방문 유도"
|
||||||
|
- "매출 증대"
|
||||||
|
- "브랜드 인지도 향상"
|
||||||
|
example: "신규 고객 유치"
|
||||||
|
industry:
|
||||||
|
type: string
|
||||||
|
description: 업종
|
||||||
|
example: "음식점"
|
||||||
|
region:
|
||||||
|
type: string
|
||||||
|
description: 지역 (시/구/동)
|
||||||
|
example: "서울 강남구"
|
||||||
|
storeName:
|
||||||
|
type: string
|
||||||
|
description: 매장명 (선택)
|
||||||
|
example: "맛있는 고깃집"
|
||||||
|
targetAudience:
|
||||||
|
type: string
|
||||||
|
description: 목표 고객층 (선택)
|
||||||
|
example: "20-30대 여성"
|
||||||
|
budget:
|
||||||
|
type: integer
|
||||||
|
description: 예산 (원) (선택)
|
||||||
|
example: 500000
|
||||||
|
requestedAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: 요청 시각
|
||||||
|
example: "2025-10-23T10:30:00Z"
|
||||||
|
|
||||||
|
# ==================== AI Recommendation Result ====================
|
||||||
|
AIRecommendationResult:
|
||||||
|
type: object
|
||||||
|
description: |
|
||||||
|
**Redis Key**: `ai:recommendation:{eventId}`
|
||||||
|
**TTL**: 86400초 (24시간)
|
||||||
|
|
||||||
|
AI 이벤트 추천 결과
|
||||||
|
required:
|
||||||
|
- eventId
|
||||||
|
- trendAnalysis
|
||||||
|
- recommendations
|
||||||
|
- generatedAt
|
||||||
|
- aiProvider
|
||||||
|
properties:
|
||||||
|
eventId:
|
||||||
|
type: string
|
||||||
|
description: 이벤트 ID
|
||||||
|
example: "evt-001"
|
||||||
|
trendAnalysis:
|
||||||
|
$ref: '#/components/schemas/TrendAnalysis'
|
||||||
|
recommendations:
|
||||||
|
type: array
|
||||||
|
description: 추천 이벤트 기획안 (3개)
|
||||||
|
minItems: 3
|
||||||
|
maxItems: 3
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/EventRecommendation'
|
||||||
|
generatedAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: 생성 시각
|
||||||
|
example: "2025-10-23T10:35:00Z"
|
||||||
|
expiresAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: 캐시 만료 시각 (생성 시각 + 24시간)
|
||||||
|
example: "2025-10-24T10:35:00Z"
|
||||||
|
aiProvider:
|
||||||
|
type: string
|
||||||
|
enum: [CLAUDE, GPT4]
|
||||||
|
description: 사용된 AI 제공자
|
||||||
|
example: "CLAUDE"
|
||||||
|
|
||||||
|
TrendAnalysis:
|
||||||
|
type: object
|
||||||
|
description: 트렌드 분석 결과 (업종/지역/시즌)
|
||||||
|
required:
|
||||||
|
- industryTrends
|
||||||
|
- regionalTrends
|
||||||
|
- seasonalTrends
|
||||||
|
properties:
|
||||||
|
industryTrends:
|
||||||
|
type: array
|
||||||
|
description: 업종 트렌드 키워드 (최대 5개)
|
||||||
|
maxItems: 5
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- keyword
|
||||||
|
- relevance
|
||||||
|
- description
|
||||||
|
properties:
|
||||||
|
keyword:
|
||||||
|
type: string
|
||||||
|
description: 트렌드 키워드
|
||||||
|
example: "프리미엄 디저트"
|
||||||
|
relevance:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
minimum: 0
|
||||||
|
maximum: 1
|
||||||
|
description: 연관도 (0-1)
|
||||||
|
example: 0.85
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
description: 트렌드 설명
|
||||||
|
example: "고급 디저트 카페 트렌드 증가"
|
||||||
|
regionalTrends:
|
||||||
|
type: array
|
||||||
|
description: 지역 트렌드 키워드 (최대 5개)
|
||||||
|
maxItems: 5
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- keyword
|
||||||
|
- relevance
|
||||||
|
- description
|
||||||
|
properties:
|
||||||
|
keyword:
|
||||||
|
type: string
|
||||||
|
example: "핫플레이스"
|
||||||
|
relevance:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
minimum: 0
|
||||||
|
maximum: 1
|
||||||
|
example: 0.78
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
example: "강남 신논현역 주변 유동인구 증가"
|
||||||
|
seasonalTrends:
|
||||||
|
type: array
|
||||||
|
description: 시즌 트렌드 키워드 (최대 5개)
|
||||||
|
maxItems: 5
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- keyword
|
||||||
|
- relevance
|
||||||
|
- description
|
||||||
|
properties:
|
||||||
|
keyword:
|
||||||
|
type: string
|
||||||
|
example: "가을 시즌"
|
||||||
|
relevance:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
minimum: 0
|
||||||
|
maximum: 1
|
||||||
|
example: 0.92
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
example: "가을 시즌 한정 메뉴 선호도 증가"
|
||||||
|
|
||||||
|
EventRecommendation:
|
||||||
|
type: object
|
||||||
|
description: 이벤트 추천안 (차별화된 3가지 옵션)
|
||||||
|
required:
|
||||||
|
- optionNumber
|
||||||
|
- concept
|
||||||
|
- title
|
||||||
|
- description
|
||||||
|
- targetAudience
|
||||||
|
- duration
|
||||||
|
- mechanics
|
||||||
|
- promotionChannels
|
||||||
|
- estimatedCost
|
||||||
|
- expectedMetrics
|
||||||
|
- differentiator
|
||||||
|
properties:
|
||||||
|
optionNumber:
|
||||||
|
type: integer
|
||||||
|
description: 옵션 번호 (1-3)
|
||||||
|
minimum: 1
|
||||||
|
maximum: 3
|
||||||
|
example: 1
|
||||||
|
concept:
|
||||||
|
type: string
|
||||||
|
description: 이벤트 컨셉
|
||||||
|
example: "프리미엄 경험형"
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
description: 이벤트 제목
|
||||||
|
maxLength: 100
|
||||||
|
example: "가을 한정 시그니처 디저트 페어링 이벤트"
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
description: 이벤트 설명
|
||||||
|
maxLength: 500
|
||||||
|
example: "가을 제철 재료를 활용한 시그니처 디저트와 음료 페어링 체험"
|
||||||
|
targetAudience:
|
||||||
|
type: string
|
||||||
|
description: 목표 고객층
|
||||||
|
example: "20-30대 여성, SNS 활동적인 고객"
|
||||||
|
duration:
|
||||||
|
type: object
|
||||||
|
description: 이벤트 기간
|
||||||
|
required:
|
||||||
|
- recommendedDays
|
||||||
|
properties:
|
||||||
|
recommendedDays:
|
||||||
|
type: integer
|
||||||
|
description: 권장 진행 일수
|
||||||
|
minimum: 1
|
||||||
|
example: 14
|
||||||
|
recommendedPeriod:
|
||||||
|
type: string
|
||||||
|
description: 권장 진행 시기
|
||||||
|
example: "10월 중순 ~ 11월 초"
|
||||||
|
mechanics:
|
||||||
|
type: object
|
||||||
|
description: 이벤트 메커니즘
|
||||||
|
required:
|
||||||
|
- type
|
||||||
|
- details
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [DISCOUNT, GIFT, STAMP, EXPERIENCE, LOTTERY, COMBO]
|
||||||
|
description: 이벤트 유형
|
||||||
|
example: "EXPERIENCE"
|
||||||
|
details:
|
||||||
|
type: string
|
||||||
|
description: 상세 메커니즘
|
||||||
|
maxLength: 500
|
||||||
|
example: "디저트+음료 페어링 세트 주문 시 인스타그램 업로드 고객에게 다음 방문 시 사용 가능한 10% 할인권 제공"
|
||||||
|
promotionChannels:
|
||||||
|
type: array
|
||||||
|
description: 추천 홍보 채널 (최대 5개)
|
||||||
|
maxItems: 5
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
example:
|
||||||
|
- "Instagram"
|
||||||
|
- "카카오톡 채널"
|
||||||
|
- "네이버 플레이스"
|
||||||
|
estimatedCost:
|
||||||
|
type: object
|
||||||
|
description: 예상 비용
|
||||||
|
required:
|
||||||
|
- min
|
||||||
|
- max
|
||||||
|
properties:
|
||||||
|
min:
|
||||||
|
type: integer
|
||||||
|
description: 최소 비용 (원)
|
||||||
|
minimum: 0
|
||||||
|
example: 300000
|
||||||
|
max:
|
||||||
|
type: integer
|
||||||
|
description: 최대 비용 (원)
|
||||||
|
minimum: 0
|
||||||
|
example: 500000
|
||||||
|
breakdown:
|
||||||
|
type: object
|
||||||
|
description: 비용 구성
|
||||||
|
properties:
|
||||||
|
material:
|
||||||
|
type: integer
|
||||||
|
description: 재료비 (원)
|
||||||
|
example: 200000
|
||||||
|
promotion:
|
||||||
|
type: integer
|
||||||
|
description: 홍보비 (원)
|
||||||
|
example: 150000
|
||||||
|
discount:
|
||||||
|
type: integer
|
||||||
|
description: 할인 비용 (원)
|
||||||
|
example: 150000
|
||||||
|
expectedMetrics:
|
||||||
|
$ref: '#/components/schemas/ExpectedMetrics'
|
||||||
|
differentiator:
|
||||||
|
type: string
|
||||||
|
description: 다른 옵션과의 차별점
|
||||||
|
maxLength: 500
|
||||||
|
example: "프리미엄 경험 제공으로 고객 만족도와 SNS 바이럴 효과 극대화, 브랜드 이미지 향상에 집중"
|
||||||
|
|
||||||
|
ExpectedMetrics:
|
||||||
|
type: object
|
||||||
|
description: 예상 성과 지표
|
||||||
|
required:
|
||||||
|
- newCustomers
|
||||||
|
- revenueIncrease
|
||||||
|
- roi
|
||||||
|
properties:
|
||||||
|
newCustomers:
|
||||||
|
type: object
|
||||||
|
description: 신규 고객 수
|
||||||
|
required:
|
||||||
|
- min
|
||||||
|
- max
|
||||||
|
properties:
|
||||||
|
min:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
example: 50
|
||||||
|
max:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
example: 80
|
||||||
|
repeatVisits:
|
||||||
|
type: object
|
||||||
|
description: 재방문 고객 수 (선택)
|
||||||
|
properties:
|
||||||
|
min:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
example: 30
|
||||||
|
max:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
example: 50
|
||||||
|
revenueIncrease:
|
||||||
|
type: object
|
||||||
|
description: 매출 증가율 (%)
|
||||||
|
required:
|
||||||
|
- min
|
||||||
|
- max
|
||||||
|
properties:
|
||||||
|
min:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
minimum: 0
|
||||||
|
example: 15.0
|
||||||
|
max:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
minimum: 0
|
||||||
|
example: 25.0
|
||||||
|
roi:
|
||||||
|
type: object
|
||||||
|
description: ROI - 투자 대비 수익률 (%)
|
||||||
|
required:
|
||||||
|
- min
|
||||||
|
- max
|
||||||
|
properties:
|
||||||
|
min:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
minimum: 0
|
||||||
|
example: 120.0
|
||||||
|
max:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
minimum: 0
|
||||||
|
example: 180.0
|
||||||
|
socialEngagement:
|
||||||
|
type: object
|
||||||
|
description: SNS 참여도 (선택)
|
||||||
|
properties:
|
||||||
|
estimatedPosts:
|
||||||
|
type: integer
|
||||||
|
description: 예상 게시물 수
|
||||||
|
minimum: 0
|
||||||
|
example: 100
|
||||||
|
estimatedReach:
|
||||||
|
type: integer
|
||||||
|
description: 예상 도달 수
|
||||||
|
minimum: 0
|
||||||
|
example: 5000
|
||||||
|
|
||||||
|
# ==================== Job Status ====================
|
||||||
|
JobStatusResponse:
|
||||||
|
type: object
|
||||||
|
description: |
|
||||||
|
**Redis Key**: `ai:job:status:{jobId}`
|
||||||
|
**TTL**: 86400초 (24시간)
|
||||||
|
|
||||||
|
작업 상태 응답
|
||||||
|
required:
|
||||||
|
- jobId
|
||||||
|
- status
|
||||||
|
- progress
|
||||||
|
- message
|
||||||
|
- createdAt
|
||||||
|
properties:
|
||||||
|
jobId:
|
||||||
|
type: string
|
||||||
|
description: Job ID
|
||||||
|
example: "job-ai-evt001-20251023103000"
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [PENDING, PROCESSING, COMPLETED, FAILED]
|
||||||
|
description: 작업 상태
|
||||||
|
example: "COMPLETED"
|
||||||
|
progress:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
maximum: 100
|
||||||
|
description: 진행률 (%)
|
||||||
|
example: 100
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
description: 상태 메시지
|
||||||
|
example: "AI 추천 완료"
|
||||||
|
eventId:
|
||||||
|
type: string
|
||||||
|
description: 이벤트 ID
|
||||||
|
example: "evt-001"
|
||||||
|
createdAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: 작업 생성 시각
|
||||||
|
example: "2025-10-23T10:30:00Z"
|
||||||
|
startedAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: 작업 시작 시각
|
||||||
|
example: "2025-10-23T10:30:05Z"
|
||||||
|
completedAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: 작업 완료 시각 (완료 시)
|
||||||
|
example: "2025-10-23T10:35:00Z"
|
||||||
|
failedAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: 작업 실패 시각 (실패 시)
|
||||||
|
example: "2025-10-23T10:35:05Z"
|
||||||
|
errorMessage:
|
||||||
|
type: string
|
||||||
|
description: 에러 메시지 (실패 시)
|
||||||
|
example: "Claude API timeout after 5 minutes"
|
||||||
|
retryCount:
|
||||||
|
type: integer
|
||||||
|
description: 재시도 횟수
|
||||||
|
minimum: 0
|
||||||
|
example: 0
|
||||||
|
processingTimeMs:
|
||||||
|
type: integer
|
||||||
|
description: 처리 시간 (밀리초)
|
||||||
|
minimum: 0
|
||||||
|
example: 295000
|
||||||
|
|
||||||
|
# ==================== Error Response ====================
|
||||||
|
ErrorResponse:
|
||||||
|
type: object
|
||||||
|
description: 에러 응답
|
||||||
|
required:
|
||||||
|
- code
|
||||||
|
- message
|
||||||
|
- timestamp
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
description: 에러 코드
|
||||||
|
enum:
|
||||||
|
- AI_SERVICE_ERROR
|
||||||
|
- JOB_NOT_FOUND
|
||||||
|
- RECOMMENDATION_NOT_FOUND
|
||||||
|
- REDIS_ERROR
|
||||||
|
- KAFKA_ERROR
|
||||||
|
- CIRCUIT_BREAKER_OPEN
|
||||||
|
- INTERNAL_ERROR
|
||||||
|
example: "JOB_NOT_FOUND"
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
description: 에러 메시지
|
||||||
|
example: "작업을 찾을 수 없습니다"
|
||||||
|
timestamp:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: 에러 발생 시각
|
||||||
|
example: "2025-10-23T10:30:00Z"
|
||||||
|
details:
|
||||||
|
type: object
|
||||||
|
description: 추가 에러 상세
|
||||||
|
additionalProperties: true
|
||||||
|
example:
|
||||||
|
jobId: "job-ai-evt001-20251023103000"
|
||||||
|
|
||||||
|
responses:
|
||||||
|
NotFound:
|
||||||
|
description: 리소스를 찾을 수 없음
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
example:
|
||||||
|
code: "JOB_NOT_FOUND"
|
||||||
|
message: "작업을 찾을 수 없습니다"
|
||||||
|
timestamp: "2025-10-23T10:30:00Z"
|
||||||
|
|
||||||
|
InternalServerError:
|
||||||
|
description: 서버 내부 오류
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
example:
|
||||||
|
code: "INTERNAL_ERROR"
|
||||||
|
message: "서버 내부 오류가 발생했습니다"
|
||||||
|
timestamp: "2025-10-23T10:30:00Z"
|
||||||
|
|
||||||
|
# ==================== 기술 구성 문서화 ====================
|
||||||
|
x-technical-specifications:
|
||||||
|
circuit-breaker:
|
||||||
|
claude-api:
|
||||||
|
failureThreshold: 5
|
||||||
|
successThreshold: 2
|
||||||
|
timeout: 300000
|
||||||
|
resetTimeout: 60000
|
||||||
|
fallbackStrategy: CACHED_RECOMMENDATION
|
||||||
|
gpt4-api:
|
||||||
|
failureThreshold: 5
|
||||||
|
successThreshold: 2
|
||||||
|
timeout: 300000
|
||||||
|
resetTimeout: 60000
|
||||||
|
fallbackStrategy: CACHED_RECOMMENDATION
|
||||||
|
|
||||||
|
redis-cache:
|
||||||
|
patterns:
|
||||||
|
recommendation: "ai:recommendation:{eventId}"
|
||||||
|
jobStatus: "ai:job:status:{jobId}"
|
||||||
|
fallback: "ai:fallback:{industry}:{region}"
|
||||||
|
ttl:
|
||||||
|
recommendation: 86400
|
||||||
|
jobStatus: 86400
|
||||||
|
fallback: 604800
|
||||||
|
|
||||||
|
kafka:
|
||||||
|
topics:
|
||||||
|
input: "ai-event-generation-job"
|
||||||
|
consumer:
|
||||||
|
groupId: "ai-service-consumers"
|
||||||
|
maxRetries: 3
|
||||||
|
retryBackoffMs: 5000
|
||||||
|
maxPollRecords: 10
|
||||||
|
sessionTimeoutMs: 30000
|
||||||
|
|
||||||
|
external-apis:
|
||||||
|
claude:
|
||||||
|
endpoint: "https://api.anthropic.com/v1/messages"
|
||||||
|
model: "claude-3-5-sonnet-20241022"
|
||||||
|
maxTokens: 4096
|
||||||
|
timeout: 300000
|
||||||
|
gpt4:
|
||||||
|
endpoint: "https://api.openai.com/v1/chat/completions"
|
||||||
|
model: "gpt-4-turbo-preview"
|
||||||
|
maxTokens: 4096
|
||||||
|
timeout: 300000
|
||||||
1081
design/backend/api/analytics-service-api.yaml
Normal file
1081
design/backend/api/analytics-service-api.yaml
Normal file
File diff suppressed because it is too large
Load Diff
1158
design/backend/api/content-service-api.yaml
Normal file
1158
design/backend/api/content-service-api.yaml
Normal file
File diff suppressed because it is too large
Load Diff
651
design/backend/api/distribution-service-api.yaml
Normal file
651
design/backend/api/distribution-service-api.yaml
Normal file
@ -0,0 +1,651 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: Distribution Service API
|
||||||
|
description: |
|
||||||
|
KT AI 기반 소상공인 이벤트 자동 생성 서비스의 다중 채널 배포 관리 API
|
||||||
|
|
||||||
|
## 주요 기능
|
||||||
|
- 다중 채널 동시 배포 (우리동네TV, 링고비즈, 지니TV, SNS)
|
||||||
|
- 배포 상태 실시간 모니터링
|
||||||
|
- Circuit Breaker 기반 장애 격리
|
||||||
|
- Retry 패턴 및 Fallback 처리
|
||||||
|
|
||||||
|
## 배포 채널
|
||||||
|
- **우리동네TV**: 영상 콘텐츠 업로드
|
||||||
|
- **링고비즈**: 연결음 업데이트
|
||||||
|
- **지니TV**: 광고 등록
|
||||||
|
- **SNS**: Instagram, Naver Blog, Kakao Channel
|
||||||
|
|
||||||
|
## Resilience 패턴
|
||||||
|
- Circuit Breaker: 채널별 독립적 장애 격리
|
||||||
|
- Retry: 지수 백오프 (1s, 2s, 4s) 최대 3회
|
||||||
|
- Bulkhead: 리소스 격리
|
||||||
|
- Fallback: 실패 채널 스킵 및 알림
|
||||||
|
|
||||||
|
version: 1.0.0
|
||||||
|
contact:
|
||||||
|
name: Digital Garage Team
|
||||||
|
email: support@kt-event-marketing.com
|
||||||
|
|
||||||
|
servers:
|
||||||
|
- url: http://localhost:8085
|
||||||
|
description: Local Development Server
|
||||||
|
- url: https://dev-api.kt-event-marketing.com/distribution/v1
|
||||||
|
description: Development Server
|
||||||
|
- url: https://api.kt-event-marketing.com/distribution/v1
|
||||||
|
description: Production Server
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- name: Distribution
|
||||||
|
description: 다중 채널 배포 관리
|
||||||
|
- name: Monitoring
|
||||||
|
description: 배포 상태 모니터링
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/distribution/distribute:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Distribution
|
||||||
|
summary: 다중 채널 배포 요청
|
||||||
|
description: |
|
||||||
|
이벤트 콘텐츠를 선택된 채널들에 동시 배포합니다.
|
||||||
|
|
||||||
|
## 처리 흐름
|
||||||
|
1. 배포 요청 검증 (이벤트 ID, 채널 목록, 콘텐츠 데이터)
|
||||||
|
2. 채널별 병렬 배포 실행 (1분 이내 완료 목표)
|
||||||
|
3. Circuit Breaker로 장애 채널 격리
|
||||||
|
4. 실패 시 Retry (지수 백오프: 1s, 2s, 4s)
|
||||||
|
5. Fallback: 실패 채널 스킵 및 알림
|
||||||
|
6. 배포 결과 집계 및 로그 저장
|
||||||
|
7. DistributionCompleted 이벤트 Kafka 발행
|
||||||
|
|
||||||
|
## Resilience 처리
|
||||||
|
- 각 채널별 독립적인 Circuit Breaker 적용
|
||||||
|
- 최대 3회 재시도 (지수 백오프)
|
||||||
|
- 일부 채널 실패 시에도 성공 채널은 유지
|
||||||
|
- 실패 채널 정보는 응답에 포함
|
||||||
|
|
||||||
|
x-user-story: UFR-DIST-010
|
||||||
|
x-controller: DistributionController
|
||||||
|
operationId: distributeToChannels
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DistributionRequest'
|
||||||
|
examples:
|
||||||
|
multiChannel:
|
||||||
|
summary: 다중 채널 배포 예시
|
||||||
|
value:
|
||||||
|
eventId: "evt-12345"
|
||||||
|
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"
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 배포 완료
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DistributionResponse'
|
||||||
|
examples:
|
||||||
|
allSuccess:
|
||||||
|
summary: 모든 채널 배포 성공
|
||||||
|
value:
|
||||||
|
distributionId: "dist-12345"
|
||||||
|
eventId: "evt-12345"
|
||||||
|
status: "COMPLETED"
|
||||||
|
completedAt: "2025-11-01T09:00:00Z"
|
||||||
|
results:
|
||||||
|
- channel: "WOORIDONGNE_TV"
|
||||||
|
status: "SUCCESS"
|
||||||
|
distributionId: "wtv-uuid-12345"
|
||||||
|
estimatedViews: 1000
|
||||||
|
message: "배포 완료"
|
||||||
|
- 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: "게시 완료"
|
||||||
|
'400':
|
||||||
|
description: 잘못된 요청
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
examples:
|
||||||
|
invalidEventId:
|
||||||
|
summary: 유효하지 않은 이벤트 ID
|
||||||
|
value:
|
||||||
|
error: "BAD_REQUEST"
|
||||||
|
message: "유효하지 않은 이벤트 ID입니다"
|
||||||
|
timestamp: "2025-11-01T09:00:00Z"
|
||||||
|
noChannels:
|
||||||
|
summary: 선택된 채널 없음
|
||||||
|
value:
|
||||||
|
error: "BAD_REQUEST"
|
||||||
|
message: "최소 1개 이상의 채널을 선택해야 합니다"
|
||||||
|
timestamp: "2025-11-01T09:00:00Z"
|
||||||
|
'404':
|
||||||
|
description: 이벤트를 찾을 수 없음
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
examples:
|
||||||
|
eventNotFound:
|
||||||
|
summary: 존재하지 않는 이벤트
|
||||||
|
value:
|
||||||
|
error: "NOT_FOUND"
|
||||||
|
message: "이벤트를 찾을 수 없습니다: evt-12345"
|
||||||
|
timestamp: "2025-11-01T09:00:00Z"
|
||||||
|
'500':
|
||||||
|
description: 서버 내부 오류
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
examples:
|
||||||
|
internalError:
|
||||||
|
summary: 서버 오류
|
||||||
|
value:
|
||||||
|
error: "INTERNAL_SERVER_ERROR"
|
||||||
|
message: "배포 처리 중 오류가 발생했습니다"
|
||||||
|
timestamp: "2025-11-01T09:00:00Z"
|
||||||
|
|
||||||
|
/distribution/{eventId}/status:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Monitoring
|
||||||
|
summary: 배포 상태 조회
|
||||||
|
description: |
|
||||||
|
특정 이벤트의 배포 상태를 실시간으로 조회합니다.
|
||||||
|
|
||||||
|
## 조회 정보
|
||||||
|
- 전체 배포 상태 (진행중, 완료, 부분성공, 실패)
|
||||||
|
- 채널별 배포 상태 및 결과
|
||||||
|
- 실패 채널 상세 정보 (오류 유형, 재시도 횟수)
|
||||||
|
- 배포 시작/완료 시간 및 소요 시간
|
||||||
|
- 외부 채널 ID 및 배포 URL
|
||||||
|
|
||||||
|
## 상태 값
|
||||||
|
- **IN_PROGRESS**: 배포 진행 중
|
||||||
|
- **COMPLETED**: 모든 채널 배포 완료
|
||||||
|
- **PARTIAL_SUCCESS**: 일부 채널 배포 성공
|
||||||
|
- **FAILED**: 모든 채널 배포 실패
|
||||||
|
|
||||||
|
x-user-story: UFR-DIST-020
|
||||||
|
x-controller: DistributionController
|
||||||
|
operationId: getDistributionStatus
|
||||||
|
parameters:
|
||||||
|
- name: eventId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: 이벤트 ID
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: "evt-12345"
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 배포 상태 조회 성공
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DistributionStatusResponse'
|
||||||
|
examples:
|
||||||
|
completed:
|
||||||
|
summary: 배포 완료 상태
|
||||||
|
value:
|
||||||
|
eventId: "evt-12345"
|
||||||
|
overallStatus: "COMPLETED"
|
||||||
|
completedAt: "2025-11-01T09:00:00Z"
|
||||||
|
channels:
|
||||||
|
- channel: "WOORIDONGNE_TV"
|
||||||
|
status: "COMPLETED"
|
||||||
|
distributionId: "wtv-uuid-12345"
|
||||||
|
estimatedViews: 1500
|
||||||
|
completedAt: "2025-11-01T09:00:00Z"
|
||||||
|
- channel: "RINGO_BIZ"
|
||||||
|
status: "COMPLETED"
|
||||||
|
updateTimestamp: "2025-11-01T09:00:00Z"
|
||||||
|
- channel: "GENIE_TV"
|
||||||
|
status: "COMPLETED"
|
||||||
|
adId: "gtv-uuid-12345"
|
||||||
|
impressionSchedule:
|
||||||
|
- "2025-11-01 18:00-20:00"
|
||||||
|
- "2025-11-02 12:00-14:00"
|
||||||
|
- channel: "INSTAGRAM"
|
||||||
|
status: "COMPLETED"
|
||||||
|
postUrl: "https://instagram.com/p/generated-post-id"
|
||||||
|
postId: "ig-post-12345"
|
||||||
|
- channel: "NAVER_BLOG"
|
||||||
|
status: "COMPLETED"
|
||||||
|
postUrl: "https://blog.naver.com/store123/generated-post"
|
||||||
|
- channel: "KAKAO_CHANNEL"
|
||||||
|
status: "COMPLETED"
|
||||||
|
messageId: "kakao-msg-12345"
|
||||||
|
inProgress:
|
||||||
|
summary: 배포 진행중 상태
|
||||||
|
value:
|
||||||
|
eventId: "evt-12345"
|
||||||
|
overallStatus: "IN_PROGRESS"
|
||||||
|
startedAt: "2025-11-01T08:58:00Z"
|
||||||
|
channels:
|
||||||
|
- channel: "WOORIDONGNE_TV"
|
||||||
|
status: "COMPLETED"
|
||||||
|
distributionId: "wtv-uuid-12345"
|
||||||
|
estimatedViews: 1500
|
||||||
|
- channel: "INSTAGRAM"
|
||||||
|
status: "IN_PROGRESS"
|
||||||
|
progress: 50
|
||||||
|
- channel: "NAVER_BLOG"
|
||||||
|
status: "PENDING"
|
||||||
|
partialFailure:
|
||||||
|
summary: 일부 채널 실패 상태
|
||||||
|
value:
|
||||||
|
eventId: "evt-12345"
|
||||||
|
overallStatus: "PARTIAL_FAILURE"
|
||||||
|
completedAt: "2025-11-01T09:00:00Z"
|
||||||
|
channels:
|
||||||
|
- channel: "WOORIDONGNE_TV"
|
||||||
|
status: "COMPLETED"
|
||||||
|
distributionId: "wtv-uuid-12345"
|
||||||
|
estimatedViews: 1500
|
||||||
|
- channel: "INSTAGRAM"
|
||||||
|
status: "FAILED"
|
||||||
|
errorMessage: "Instagram API 타임아웃"
|
||||||
|
retries: 3
|
||||||
|
lastRetryAt: "2025-11-01T08:59:30Z"
|
||||||
|
- channel: "NAVER_BLOG"
|
||||||
|
status: "COMPLETED"
|
||||||
|
postUrl: "https://blog.naver.com/store123/generated-post"
|
||||||
|
'404':
|
||||||
|
description: 배포 이력을 찾을 수 없음
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
examples:
|
||||||
|
notFound:
|
||||||
|
summary: 배포 이력 없음
|
||||||
|
value:
|
||||||
|
error: "NOT_FOUND"
|
||||||
|
message: "배포 이력을 찾을 수 없습니다: evt-12345"
|
||||||
|
timestamp: "2025-11-01T09:00:00Z"
|
||||||
|
'500':
|
||||||
|
description: 서버 내부 오류
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
DistributionRequest:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- eventId
|
||||||
|
- channels
|
||||||
|
- contentUrls
|
||||||
|
properties:
|
||||||
|
eventId:
|
||||||
|
type: string
|
||||||
|
description: 이벤트 ID
|
||||||
|
example: "evt-12345"
|
||||||
|
channels:
|
||||||
|
type: array
|
||||||
|
description: 배포할 채널 목록
|
||||||
|
minItems: 1
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ChannelConfig'
|
||||||
|
contentUrls:
|
||||||
|
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
|
||||||
|
example:
|
||||||
|
scheduledTime: "2025-11-01T10:00:00Z"
|
||||||
|
caption: "이벤트 안내"
|
||||||
|
hashtags:
|
||||||
|
- "이벤트"
|
||||||
|
- "할인"
|
||||||
|
|
||||||
|
DistributionResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- distributionId
|
||||||
|
- eventId
|
||||||
|
- status
|
||||||
|
- results
|
||||||
|
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"
|
||||||
|
completedAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: 배포 완료 시각
|
||||||
|
example: "2025-11-01T09:00:00Z"
|
||||||
|
results:
|
||||||
|
type: array
|
||||||
|
description: 채널별 배포 결과
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ChannelResult'
|
||||||
|
|
||||||
|
ChannelResult:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- channel
|
||||||
|
- status
|
||||||
|
properties:
|
||||||
|
channel:
|
||||||
|
type: string
|
||||||
|
description: 채널 타입
|
||||||
|
enum:
|
||||||
|
- WOORIDONGNE_TV
|
||||||
|
- RINGO_BIZ
|
||||||
|
- GENIE_TV
|
||||||
|
- INSTAGRAM
|
||||||
|
- NAVER_BLOG
|
||||||
|
- KAKAO_CHANNEL
|
||||||
|
example: "INSTAGRAM"
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
description: 채널별 배포 상태
|
||||||
|
enum:
|
||||||
|
- PENDING
|
||||||
|
- IN_PROGRESS
|
||||||
|
- SUCCESS
|
||||||
|
- FAILED
|
||||||
|
example: "SUCCESS"
|
||||||
|
distributionId:
|
||||||
|
type: string
|
||||||
|
description: 채널별 배포 ID (우리동네TV, 지니TV)
|
||||||
|
example: "wtv-uuid-12345"
|
||||||
|
estimatedViews:
|
||||||
|
type: integer
|
||||||
|
description: 예상 노출 수 (우리동네TV, 지니TV)
|
||||||
|
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: 오류 메시지 (실패 시)
|
||||||
|
example: "Instagram API 타임아웃"
|
||||||
|
retries:
|
||||||
|
type: integer
|
||||||
|
description: 재시도 횟수
|
||||||
|
example: 0
|
||||||
|
lastRetryAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: 마지막 재시도 시각
|
||||||
|
example: "2025-11-01T08:59:30Z"
|
||||||
|
|
||||||
|
DistributionStatusResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- eventId
|
||||||
|
- overallStatus
|
||||||
|
- channels
|
||||||
|
properties:
|
||||||
|
eventId:
|
||||||
|
type: string
|
||||||
|
description: 이벤트 ID
|
||||||
|
example: "evt-12345"
|
||||||
|
overallStatus:
|
||||||
|
type: string
|
||||||
|
description: 전체 배포 상태
|
||||||
|
enum:
|
||||||
|
- PENDING
|
||||||
|
- IN_PROGRESS
|
||||||
|
- COMPLETED
|
||||||
|
- PARTIAL_FAILURE
|
||||||
|
- FAILED
|
||||||
|
- NOT_FOUND
|
||||||
|
example: "COMPLETED"
|
||||||
|
startedAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: 배포 시작 시각
|
||||||
|
example: "2025-11-01T08:59:00Z"
|
||||||
|
completedAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: 배포 완료 시각
|
||||||
|
example: "2025-11-01T09:00:00Z"
|
||||||
|
channels:
|
||||||
|
type: array
|
||||||
|
description: 채널별 배포 상태
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ChannelStatus'
|
||||||
|
|
||||||
|
ChannelStatus:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- channel
|
||||||
|
- status
|
||||||
|
properties:
|
||||||
|
channel:
|
||||||
|
type: string
|
||||||
|
description: 채널 타입
|
||||||
|
enum:
|
||||||
|
- WOORIDONGNE_TV
|
||||||
|
- RINGO_BIZ
|
||||||
|
- GENIE_TV
|
||||||
|
- INSTAGRAM
|
||||||
|
- NAVER_BLOG
|
||||||
|
- KAKAO_CHANNEL
|
||||||
|
example: "INSTAGRAM"
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
description: 채널별 배포 상태
|
||||||
|
enum:
|
||||||
|
- PENDING
|
||||||
|
- IN_PROGRESS
|
||||||
|
- COMPLETED
|
||||||
|
- FAILED
|
||||||
|
example: "COMPLETED"
|
||||||
|
progress:
|
||||||
|
type: integer
|
||||||
|
description: 진행률 (0-100, IN_PROGRESS 상태일 때)
|
||||||
|
minimum: 0
|
||||||
|
maximum: 100
|
||||||
|
example: 75
|
||||||
|
distributionId:
|
||||||
|
type: string
|
||||||
|
description: 채널별 배포 ID
|
||||||
|
example: "wtv-uuid-12345"
|
||||||
|
estimatedViews:
|
||||||
|
type: integer
|
||||||
|
description: 예상 노출 수
|
||||||
|
example: 1500
|
||||||
|
updateTimestamp:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: 업데이트 완료 시각
|
||||||
|
example: "2025-11-01T09:00:00Z"
|
||||||
|
adId:
|
||||||
|
type: string
|
||||||
|
description: 광고 ID
|
||||||
|
example: "gtv-uuid-12345"
|
||||||
|
impressionSchedule:
|
||||||
|
type: array
|
||||||
|
description: 노출 스케줄
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
example:
|
||||||
|
- "2025-11-01 18:00-20:00"
|
||||||
|
postUrl:
|
||||||
|
type: string
|
||||||
|
description: 게시물 URL
|
||||||
|
example: "https://instagram.com/p/generated-post-id"
|
||||||
|
postId:
|
||||||
|
type: string
|
||||||
|
description: 게시물 ID
|
||||||
|
example: "ig-post-12345"
|
||||||
|
messageId:
|
||||||
|
type: string
|
||||||
|
description: 메시지 ID
|
||||||
|
example: "kakao-msg-12345"
|
||||||
|
completedAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: 완료 시각
|
||||||
|
example: "2025-11-01T09:00:00Z"
|
||||||
|
errorMessage:
|
||||||
|
type: string
|
||||||
|
description: 오류 메시지
|
||||||
|
example: "Instagram API 타임아웃"
|
||||||
|
retries:
|
||||||
|
type: integer
|
||||||
|
description: 재시도 횟수
|
||||||
|
example: 3
|
||||||
|
lastRetryAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: 마지막 재시도 시각
|
||||||
|
example: "2025-11-01T08:59:30Z"
|
||||||
|
|
||||||
|
ErrorResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- error
|
||||||
|
- message
|
||||||
|
- timestamp
|
||||||
|
properties:
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
description: 오류 코드
|
||||||
|
enum:
|
||||||
|
- BAD_REQUEST
|
||||||
|
- NOT_FOUND
|
||||||
|
- INTERNAL_SERVER_ERROR
|
||||||
|
example: "BAD_REQUEST"
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
description: 오류 메시지
|
||||||
|
example: "유효하지 않은 이벤트 ID입니다"
|
||||||
|
timestamp:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: 오류 발생 시각
|
||||||
|
example: "2025-11-01T09:00:00Z"
|
||||||
|
details:
|
||||||
|
type: object
|
||||||
|
description: 추가 오류 정보 (선택 사항)
|
||||||
|
additionalProperties: true
|
||||||
1384
design/backend/api/event-service-api.yaml
Normal file
1384
design/backend/api/event-service-api.yaml
Normal file
File diff suppressed because it is too large
Load Diff
820
design/backend/api/participation-service-api.yaml
Normal file
820
design/backend/api/participation-service-api.yaml
Normal file
@ -0,0 +1,820 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: Participation Service API
|
||||||
|
description: |
|
||||||
|
이벤트 참여 및 당첨자 관리 서비스 API
|
||||||
|
- 이벤트 참여 등록
|
||||||
|
- 참여자 목록 조회 및 관리
|
||||||
|
- 당첨자 추첨 및 관리
|
||||||
|
version: 1.0.0
|
||||||
|
contact:
|
||||||
|
name: Digital Garage Team
|
||||||
|
email: support@kt-event-marketing.com
|
||||||
|
|
||||||
|
servers:
|
||||||
|
- url: http://localhost:8084
|
||||||
|
description: Local Development Server
|
||||||
|
- url: https://dev-api.kt-event-marketing.com/participation/v1
|
||||||
|
description: Development Server
|
||||||
|
- url: https://api.kt-event-marketing.com/participation/v1
|
||||||
|
description: Production Server
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- name: participation
|
||||||
|
description: 이벤트 참여 관리
|
||||||
|
- name: participant
|
||||||
|
description: 참여자 조회 및 관리
|
||||||
|
- name: winner
|
||||||
|
description: 당첨자 추첨 및 관리
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/events/{eventId}/participate:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- participation
|
||||||
|
summary: 이벤트 참여
|
||||||
|
description: |
|
||||||
|
고객이 이벤트에 참여합니다.
|
||||||
|
- 중복 참여 검증 (전화번호 기반)
|
||||||
|
- 이벤트 진행 상태 검증
|
||||||
|
- Kafka 이벤트 발행 (ParticipantRegistered)
|
||||||
|
operationId: participateEvent
|
||||||
|
x-user-story: UFR-PART-010
|
||||||
|
x-controller: ParticipationController
|
||||||
|
parameters:
|
||||||
|
- name: eventId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: 이벤트 ID
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: "evt_20250123_001"
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ParticipationRequest'
|
||||||
|
examples:
|
||||||
|
standard:
|
||||||
|
summary: 일반 참여
|
||||||
|
value:
|
||||||
|
name: "홍길동"
|
||||||
|
phoneNumber: "010-1234-5678"
|
||||||
|
email: "hong@example.com"
|
||||||
|
agreeMarketing: true
|
||||||
|
agreePrivacy: true
|
||||||
|
storeVisited: false
|
||||||
|
storeVisit:
|
||||||
|
summary: 매장 방문 참여
|
||||||
|
value:
|
||||||
|
name: "김철수"
|
||||||
|
phoneNumber: "010-9876-5432"
|
||||||
|
email: "kim@example.com"
|
||||||
|
agreeMarketing: false
|
||||||
|
agreePrivacy: true
|
||||||
|
storeVisited: true
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: 참여 성공
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ParticipationResponse'
|
||||||
|
examples:
|
||||||
|
success:
|
||||||
|
summary: 참여 성공
|
||||||
|
value:
|
||||||
|
success: true
|
||||||
|
message: "이벤트 참여가 완료되었습니다"
|
||||||
|
data:
|
||||||
|
participantId: "prt_20250123_001"
|
||||||
|
eventId: "evt_20250123_001"
|
||||||
|
name: "홍길동"
|
||||||
|
phoneNumber: "010-1234-5678"
|
||||||
|
email: "hong@example.com"
|
||||||
|
participatedAt: "2025-01-23T10:30:00Z"
|
||||||
|
storeVisited: false
|
||||||
|
bonusEntries: 1
|
||||||
|
'400':
|
||||||
|
description: 잘못된 요청
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
examples:
|
||||||
|
invalidPhone:
|
||||||
|
summary: 유효하지 않은 전화번호
|
||||||
|
value:
|
||||||
|
success: false
|
||||||
|
error:
|
||||||
|
code: "INVALID_PHONE_NUMBER"
|
||||||
|
message: "유효하지 않은 전화번호 형식입니다"
|
||||||
|
duplicateParticipation:
|
||||||
|
summary: 중복 참여
|
||||||
|
value:
|
||||||
|
success: false
|
||||||
|
error:
|
||||||
|
code: "DUPLICATE_PARTICIPATION"
|
||||||
|
message: "이미 참여하신 이벤트입니다"
|
||||||
|
'404':
|
||||||
|
description: 이벤트를 찾을 수 없음
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
examples:
|
||||||
|
notFound:
|
||||||
|
summary: 이벤트 없음
|
||||||
|
value:
|
||||||
|
success: false
|
||||||
|
error:
|
||||||
|
code: "EVENT_NOT_FOUND"
|
||||||
|
message: "이벤트를 찾을 수 없습니다"
|
||||||
|
'409':
|
||||||
|
description: 이벤트 진행 불가 상태
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
examples:
|
||||||
|
notActive:
|
||||||
|
summary: 진행중이 아닌 이벤트
|
||||||
|
value:
|
||||||
|
success: false
|
||||||
|
error:
|
||||||
|
code: "EVENT_NOT_ACTIVE"
|
||||||
|
message: "현재 참여할 수 없는 이벤트입니다"
|
||||||
|
|
||||||
|
/events/{eventId}/participants:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- participant
|
||||||
|
summary: 참여자 목록 조회
|
||||||
|
description: |
|
||||||
|
이벤트의 참여자 목록을 조회합니다.
|
||||||
|
- 페이징 지원
|
||||||
|
- 참여일시 기준 정렬
|
||||||
|
- 매장 방문 여부 필터링
|
||||||
|
operationId: getParticipants
|
||||||
|
x-user-story: UFR-PART-020
|
||||||
|
x-controller: ParticipantController
|
||||||
|
parameters:
|
||||||
|
- name: eventId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: 이벤트 ID
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: "evt_20250123_001"
|
||||||
|
- name: page
|
||||||
|
in: query
|
||||||
|
description: 페이지 번호 (0부터 시작)
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 0
|
||||||
|
minimum: 0
|
||||||
|
example: 0
|
||||||
|
- name: size
|
||||||
|
in: query
|
||||||
|
description: 페이지 크기
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 20
|
||||||
|
minimum: 1
|
||||||
|
maximum: 100
|
||||||
|
example: 20
|
||||||
|
- name: storeVisited
|
||||||
|
in: query
|
||||||
|
description: 매장 방문 여부 필터
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
|
example: true
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 조회 성공
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ParticipantListResponse'
|
||||||
|
examples:
|
||||||
|
success:
|
||||||
|
summary: 참여자 목록
|
||||||
|
value:
|
||||||
|
success: true
|
||||||
|
message: "참여자 목록을 조회했습니다"
|
||||||
|
data:
|
||||||
|
participants:
|
||||||
|
- participantId: "prt_20250123_001"
|
||||||
|
name: "홍길동"
|
||||||
|
phoneNumber: "010-1234-5678"
|
||||||
|
email: "hong@example.com"
|
||||||
|
participatedAt: "2025-01-23T10:30:00Z"
|
||||||
|
storeVisited: false
|
||||||
|
bonusEntries: 1
|
||||||
|
isWinner: false
|
||||||
|
- participantId: "prt_20250123_002"
|
||||||
|
name: "김철수"
|
||||||
|
phoneNumber: "010-9876-5432"
|
||||||
|
email: "kim@example.com"
|
||||||
|
participatedAt: "2025-01-23T11:15:00Z"
|
||||||
|
storeVisited: true
|
||||||
|
bonusEntries: 2
|
||||||
|
isWinner: true
|
||||||
|
pagination:
|
||||||
|
currentPage: 0
|
||||||
|
pageSize: 20
|
||||||
|
totalElements: 156
|
||||||
|
totalPages: 8
|
||||||
|
hasNext: true
|
||||||
|
hasPrevious: false
|
||||||
|
'404':
|
||||||
|
description: 이벤트를 찾을 수 없음
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
|
/events/{eventId}/participants/{participantId}:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- participant
|
||||||
|
summary: 참여자 상세 조회
|
||||||
|
description: 특정 참여자의 상세 정보를 조회합니다.
|
||||||
|
operationId: getParticipantDetail
|
||||||
|
x-user-story: UFR-PART-020
|
||||||
|
x-controller: ParticipantController
|
||||||
|
parameters:
|
||||||
|
- name: eventId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: 이벤트 ID
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: "evt_20250123_001"
|
||||||
|
- name: participantId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: 참여자 ID
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: "prt_20250123_001"
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 조회 성공
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ParticipantDetailResponse'
|
||||||
|
examples:
|
||||||
|
success:
|
||||||
|
summary: 참여자 상세 정보
|
||||||
|
value:
|
||||||
|
success: true
|
||||||
|
message: "참여자 정보를 조회했습니다"
|
||||||
|
data:
|
||||||
|
participantId: "prt_20250123_001"
|
||||||
|
eventId: "evt_20250123_001"
|
||||||
|
name: "홍길동"
|
||||||
|
phoneNumber: "010-1234-5678"
|
||||||
|
email: "hong@example.com"
|
||||||
|
participatedAt: "2025-01-23T10:30:00Z"
|
||||||
|
storeVisited: false
|
||||||
|
bonusEntries: 1
|
||||||
|
agreeMarketing: true
|
||||||
|
agreePrivacy: true
|
||||||
|
isWinner: false
|
||||||
|
winnerInfo: null
|
||||||
|
'404':
|
||||||
|
description: 참여자를 찾을 수 없음
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
examples:
|
||||||
|
notFound:
|
||||||
|
summary: 참여자 없음
|
||||||
|
value:
|
||||||
|
success: false
|
||||||
|
error:
|
||||||
|
code: "PARTICIPANT_NOT_FOUND"
|
||||||
|
message: "참여자를 찾을 수 없습니다"
|
||||||
|
|
||||||
|
/events/{eventId}/draw-winners:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- winner
|
||||||
|
summary: 당첨자 추첨
|
||||||
|
description: |
|
||||||
|
이벤트 당첨자를 추첨합니다.
|
||||||
|
- 랜덤 추첨 알고리즘 사용
|
||||||
|
- 매장 방문 보너스 가중치 적용
|
||||||
|
- 중복 당첨 방지
|
||||||
|
operationId: drawWinners
|
||||||
|
x-user-story: UFR-PART-030
|
||||||
|
x-controller: WinnerController
|
||||||
|
parameters:
|
||||||
|
- name: eventId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: 이벤트 ID
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: "evt_20250123_001"
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DrawWinnersRequest'
|
||||||
|
examples:
|
||||||
|
standard:
|
||||||
|
summary: 일반 추첨
|
||||||
|
value:
|
||||||
|
winnerCount: 10
|
||||||
|
applyStoreVisitBonus: true
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 추첨 성공
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DrawWinnersResponse'
|
||||||
|
examples:
|
||||||
|
success:
|
||||||
|
summary: 추첨 완료
|
||||||
|
value:
|
||||||
|
success: true
|
||||||
|
message: "당첨자 추첨이 완료되었습니다"
|
||||||
|
data:
|
||||||
|
eventId: "evt_20250123_001"
|
||||||
|
totalParticipants: 156
|
||||||
|
winnerCount: 10
|
||||||
|
drawnAt: "2025-01-24T15:00:00Z"
|
||||||
|
winners:
|
||||||
|
- participantId: "prt_20250123_002"
|
||||||
|
name: "김철수"
|
||||||
|
phoneNumber: "010-9876-5432"
|
||||||
|
rank: 1
|
||||||
|
- participantId: "prt_20250123_045"
|
||||||
|
name: "이영희"
|
||||||
|
phoneNumber: "010-5555-1234"
|
||||||
|
rank: 2
|
||||||
|
- participantId: "prt_20250123_089"
|
||||||
|
name: "박민수"
|
||||||
|
phoneNumber: "010-7777-8888"
|
||||||
|
rank: 3
|
||||||
|
'400':
|
||||||
|
description: 잘못된 요청
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
examples:
|
||||||
|
invalidCount:
|
||||||
|
summary: 잘못된 당첨자 수
|
||||||
|
value:
|
||||||
|
success: false
|
||||||
|
error:
|
||||||
|
code: "INVALID_WINNER_COUNT"
|
||||||
|
message: "당첨자 수가 참여자 수보다 많습니다"
|
||||||
|
'404':
|
||||||
|
description: 이벤트를 찾을 수 없음
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'409':
|
||||||
|
description: 이미 추첨 완료
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
examples:
|
||||||
|
alreadyDrawn:
|
||||||
|
summary: 추첨 완료 상태
|
||||||
|
value:
|
||||||
|
success: false
|
||||||
|
error:
|
||||||
|
code: "ALREADY_DRAWN"
|
||||||
|
message: "이미 당첨자 추첨이 완료되었습니다"
|
||||||
|
|
||||||
|
/events/{eventId}/winners:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- winner
|
||||||
|
summary: 당첨자 목록 조회
|
||||||
|
description: |
|
||||||
|
이벤트의 당첨자 목록을 조회합니다.
|
||||||
|
- 당첨 순위별 정렬
|
||||||
|
- 페이징 지원
|
||||||
|
operationId: getWinners
|
||||||
|
x-user-story: UFR-PART-030
|
||||||
|
x-controller: WinnerController
|
||||||
|
parameters:
|
||||||
|
- name: eventId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: 이벤트 ID
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: "evt_20250123_001"
|
||||||
|
- name: page
|
||||||
|
in: query
|
||||||
|
description: 페이지 번호 (0부터 시작)
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 0
|
||||||
|
minimum: 0
|
||||||
|
example: 0
|
||||||
|
- name: size
|
||||||
|
in: query
|
||||||
|
description: 페이지 크기
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 20
|
||||||
|
minimum: 1
|
||||||
|
maximum: 100
|
||||||
|
example: 20
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 조회 성공
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/WinnerListResponse'
|
||||||
|
examples:
|
||||||
|
success:
|
||||||
|
summary: 당첨자 목록
|
||||||
|
value:
|
||||||
|
success: true
|
||||||
|
message: "당첨자 목록을 조회했습니다"
|
||||||
|
data:
|
||||||
|
eventId: "evt_20250123_001"
|
||||||
|
drawnAt: "2025-01-24T15:00:00Z"
|
||||||
|
totalWinners: 10
|
||||||
|
winners:
|
||||||
|
- participantId: "prt_20250123_002"
|
||||||
|
name: "김철수"
|
||||||
|
phoneNumber: "010-9876-5432"
|
||||||
|
email: "kim@example.com"
|
||||||
|
rank: 1
|
||||||
|
wonAt: "2025-01-24T15:00:00Z"
|
||||||
|
- participantId: "prt_20250123_045"
|
||||||
|
name: "이영희"
|
||||||
|
phoneNumber: "010-5555-1234"
|
||||||
|
email: "lee@example.com"
|
||||||
|
rank: 2
|
||||||
|
wonAt: "2025-01-24T15:00:00Z"
|
||||||
|
pagination:
|
||||||
|
currentPage: 0
|
||||||
|
pageSize: 20
|
||||||
|
totalElements: 10
|
||||||
|
totalPages: 1
|
||||||
|
hasNext: false
|
||||||
|
hasPrevious: false
|
||||||
|
'404':
|
||||||
|
description: 이벤트를 찾을 수 없음 또는 당첨자가 없음
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
examples:
|
||||||
|
noWinners:
|
||||||
|
summary: 당첨자 없음
|
||||||
|
value:
|
||||||
|
success: false
|
||||||
|
error:
|
||||||
|
code: "NO_WINNERS_YET"
|
||||||
|
message: "아직 당첨자 추첨이 진행되지 않았습니다"
|
||||||
|
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
ParticipationRequest:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
- phoneNumber
|
||||||
|
- agreePrivacy
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: 참여자 이름
|
||||||
|
minLength: 2
|
||||||
|
maxLength: 50
|
||||||
|
example: "홍길동"
|
||||||
|
phoneNumber:
|
||||||
|
type: string
|
||||||
|
description: 참여자 전화번호 (하이픈 포함)
|
||||||
|
pattern: '^\d{3}-\d{3,4}-\d{4}$'
|
||||||
|
example: "010-1234-5678"
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
description: 참여자 이메일
|
||||||
|
example: "hong@example.com"
|
||||||
|
agreeMarketing:
|
||||||
|
type: boolean
|
||||||
|
description: 마케팅 정보 수신 동의
|
||||||
|
default: false
|
||||||
|
example: true
|
||||||
|
agreePrivacy:
|
||||||
|
type: boolean
|
||||||
|
description: 개인정보 수집 및 이용 동의 (필수)
|
||||||
|
example: true
|
||||||
|
storeVisited:
|
||||||
|
type: boolean
|
||||||
|
description: 매장 방문 여부
|
||||||
|
default: false
|
||||||
|
example: false
|
||||||
|
|
||||||
|
ParticipationResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
example: true
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: "이벤트 참여가 완료되었습니다"
|
||||||
|
data:
|
||||||
|
$ref: '#/components/schemas/ParticipantInfo'
|
||||||
|
|
||||||
|
ParticipantInfo:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
participantId:
|
||||||
|
type: string
|
||||||
|
description: 참여자 ID
|
||||||
|
example: "prt_20250123_001"
|
||||||
|
eventId:
|
||||||
|
type: string
|
||||||
|
description: 이벤트 ID
|
||||||
|
example: "evt_20250123_001"
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: 참여자 이름
|
||||||
|
example: "홍길동"
|
||||||
|
phoneNumber:
|
||||||
|
type: string
|
||||||
|
description: 참여자 전화번호
|
||||||
|
example: "010-1234-5678"
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
description: 참여자 이메일
|
||||||
|
example: "hong@example.com"
|
||||||
|
participatedAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: 참여 일시
|
||||||
|
example: "2025-01-23T10:30:00Z"
|
||||||
|
storeVisited:
|
||||||
|
type: boolean
|
||||||
|
description: 매장 방문 여부
|
||||||
|
example: false
|
||||||
|
bonusEntries:
|
||||||
|
type: integer
|
||||||
|
description: 보너스 응모권 수 (매장 방문 시 +1)
|
||||||
|
minimum: 1
|
||||||
|
example: 1
|
||||||
|
isWinner:
|
||||||
|
type: boolean
|
||||||
|
description: 당첨 여부
|
||||||
|
example: false
|
||||||
|
|
||||||
|
ParticipantDetailInfo:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/ParticipantInfo'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
agreeMarketing:
|
||||||
|
type: boolean
|
||||||
|
description: 마케팅 정보 수신 동의
|
||||||
|
example: true
|
||||||
|
agreePrivacy:
|
||||||
|
type: boolean
|
||||||
|
description: 개인정보 수집 및 이용 동의
|
||||||
|
example: true
|
||||||
|
winnerInfo:
|
||||||
|
$ref: '#/components/schemas/WinnerInfo'
|
||||||
|
nullable: true
|
||||||
|
|
||||||
|
ParticipantListResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
example: true
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: "참여자 목록을 조회했습니다"
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
participants:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ParticipantInfo'
|
||||||
|
pagination:
|
||||||
|
$ref: '#/components/schemas/Pagination'
|
||||||
|
|
||||||
|
ParticipantDetailResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
example: true
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: "참여자 정보를 조회했습니다"
|
||||||
|
data:
|
||||||
|
$ref: '#/components/schemas/ParticipantDetailInfo'
|
||||||
|
|
||||||
|
DrawWinnersRequest:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- winnerCount
|
||||||
|
properties:
|
||||||
|
winnerCount:
|
||||||
|
type: integer
|
||||||
|
description: 당첨자 수
|
||||||
|
minimum: 1
|
||||||
|
example: 10
|
||||||
|
applyStoreVisitBonus:
|
||||||
|
type: boolean
|
||||||
|
description: 매장 방문 보너스 적용 여부
|
||||||
|
default: true
|
||||||
|
example: true
|
||||||
|
|
||||||
|
DrawWinnersResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
example: true
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: "당첨자 추첨이 완료되었습니다"
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
eventId:
|
||||||
|
type: string
|
||||||
|
description: 이벤트 ID
|
||||||
|
example: "evt_20250123_001"
|
||||||
|
totalParticipants:
|
||||||
|
type: integer
|
||||||
|
description: 전체 참여자 수
|
||||||
|
example: 156
|
||||||
|
winnerCount:
|
||||||
|
type: integer
|
||||||
|
description: 당첨자 수
|
||||||
|
example: 10
|
||||||
|
drawnAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: 추첨 일시
|
||||||
|
example: "2025-01-24T15:00:00Z"
|
||||||
|
winners:
|
||||||
|
type: array
|
||||||
|
description: 당첨자 목록
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/WinnerSummary'
|
||||||
|
|
||||||
|
WinnerSummary:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
participantId:
|
||||||
|
type: string
|
||||||
|
description: 참여자 ID
|
||||||
|
example: "prt_20250123_002"
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: 당첨자 이름
|
||||||
|
example: "김철수"
|
||||||
|
phoneNumber:
|
||||||
|
type: string
|
||||||
|
description: 당첨자 전화번호
|
||||||
|
example: "010-9876-5432"
|
||||||
|
rank:
|
||||||
|
type: integer
|
||||||
|
description: 당첨 순위
|
||||||
|
minimum: 1
|
||||||
|
example: 1
|
||||||
|
|
||||||
|
WinnerInfo:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
participantId:
|
||||||
|
type: string
|
||||||
|
description: 참여자 ID
|
||||||
|
example: "prt_20250123_002"
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: 당첨자 이름
|
||||||
|
example: "김철수"
|
||||||
|
phoneNumber:
|
||||||
|
type: string
|
||||||
|
description: 당첨자 전화번호
|
||||||
|
example: "010-9876-5432"
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
description: 당첨자 이메일
|
||||||
|
example: "kim@example.com"
|
||||||
|
rank:
|
||||||
|
type: integer
|
||||||
|
description: 당첨 순위
|
||||||
|
minimum: 1
|
||||||
|
example: 1
|
||||||
|
wonAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: 당첨 일시
|
||||||
|
example: "2025-01-24T15:00:00Z"
|
||||||
|
|
||||||
|
WinnerListResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
example: true
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: "당첨자 목록을 조회했습니다"
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
eventId:
|
||||||
|
type: string
|
||||||
|
description: 이벤트 ID
|
||||||
|
example: "evt_20250123_001"
|
||||||
|
drawnAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: 추첨 일시
|
||||||
|
example: "2025-01-24T15:00:00Z"
|
||||||
|
totalWinners:
|
||||||
|
type: integer
|
||||||
|
description: 전체 당첨자 수
|
||||||
|
example: 10
|
||||||
|
winners:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/WinnerInfo'
|
||||||
|
pagination:
|
||||||
|
$ref: '#/components/schemas/Pagination'
|
||||||
|
|
||||||
|
Pagination:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
currentPage:
|
||||||
|
type: integer
|
||||||
|
description: 현재 페이지 번호 (0부터 시작)
|
||||||
|
minimum: 0
|
||||||
|
example: 0
|
||||||
|
pageSize:
|
||||||
|
type: integer
|
||||||
|
description: 페이지 크기
|
||||||
|
minimum: 1
|
||||||
|
example: 20
|
||||||
|
totalElements:
|
||||||
|
type: integer
|
||||||
|
description: 전체 요소 수
|
||||||
|
minimum: 0
|
||||||
|
example: 156
|
||||||
|
totalPages:
|
||||||
|
type: integer
|
||||||
|
description: 전체 페이지 수
|
||||||
|
minimum: 0
|
||||||
|
example: 8
|
||||||
|
hasNext:
|
||||||
|
type: boolean
|
||||||
|
description: 다음 페이지 존재 여부
|
||||||
|
example: true
|
||||||
|
hasPrevious:
|
||||||
|
type: boolean
|
||||||
|
description: 이전 페이지 존재 여부
|
||||||
|
example: false
|
||||||
|
|
||||||
|
ErrorResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
example: false
|
||||||
|
error:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
description: 에러 코드
|
||||||
|
example: "DUPLICATE_PARTICIPATION"
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
description: 에러 메시지
|
||||||
|
example: "이미 참여하신 이벤트입니다"
|
||||||
|
details:
|
||||||
|
type: object
|
||||||
|
description: 추가 에러 상세 정보
|
||||||
|
additionalProperties: true
|
||||||
|
nullable: true
|
||||||
991
design/backend/api/user-service-api.yaml
Normal file
991
design/backend/api/user-service-api.yaml
Normal file
@ -0,0 +1,991 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: User Service API
|
||||||
|
description: |
|
||||||
|
KT AI 기반 소상공인 이벤트 자동 생성 서비스 - User Service API
|
||||||
|
|
||||||
|
사용자 인증 및 매장정보 관리를 담당하는 마이크로서비스
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- 회원가입
|
||||||
|
- 로그인/로그아웃
|
||||||
|
- 프로필 조회 및 수정
|
||||||
|
- 비밀번호 변경
|
||||||
|
|
||||||
|
**보안:**
|
||||||
|
- JWT Bearer 토큰 기반 인증
|
||||||
|
- bcrypt 비밀번호 해싱
|
||||||
|
version: 1.0.0
|
||||||
|
contact:
|
||||||
|
name: Digital Garage Team
|
||||||
|
email: support@kt-event-marketing.com
|
||||||
|
|
||||||
|
servers:
|
||||||
|
- url: http://localhost:8081
|
||||||
|
description: Local Development Server
|
||||||
|
- url: https://dev-api.kt-event-marketing.com/user/v1
|
||||||
|
description: Development Server
|
||||||
|
- url: https://api.kt-event-marketing.com/user/v1
|
||||||
|
description: Production Server
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- name: Authentication
|
||||||
|
description: 인증 관련 API (로그인, 로그아웃, 회원가입)
|
||||||
|
- name: Profile
|
||||||
|
description: 프로필 관련 API (조회, 수정, 비밀번호 변경)
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/users/register:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Authentication
|
||||||
|
summary: 회원가입
|
||||||
|
description: |
|
||||||
|
소상공인 회원가입 API
|
||||||
|
|
||||||
|
**유저스토리:** UFR-USER-010
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- 기본 정보 및 매장 정보 등록
|
||||||
|
- 비밀번호 bcrypt 해싱
|
||||||
|
- JWT 토큰 자동 발급
|
||||||
|
|
||||||
|
**처리 흐름:**
|
||||||
|
1. 중복 사용자 확인 (전화번호 기반)
|
||||||
|
2. 비밀번호 해싱 (bcrypt)
|
||||||
|
3. User/Store 데이터베이스 트랜잭션 처리
|
||||||
|
4. JWT 토큰 생성 및 세션 저장 (Redis)
|
||||||
|
operationId: registerUser
|
||||||
|
x-user-story: UFR-USER-010
|
||||||
|
x-controller: UserController
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/RegisterRequest'
|
||||||
|
examples:
|
||||||
|
restaurant:
|
||||||
|
summary: 음식점 회원가입 예시
|
||||||
|
value:
|
||||||
|
name: 홍길동
|
||||||
|
phoneNumber: "01012345678"
|
||||||
|
email: hong@example.com
|
||||||
|
password: "Password123!"
|
||||||
|
storeName: 맛있는집
|
||||||
|
industry: 음식점
|
||||||
|
address: 서울시 강남구 테헤란로 123
|
||||||
|
businessHours: "월-금 11:00-22:00, 토-일 12:00-21:00"
|
||||||
|
cafe:
|
||||||
|
summary: 카페 회원가입 예시
|
||||||
|
value:
|
||||||
|
name: 김철수
|
||||||
|
phoneNumber: "01087654321"
|
||||||
|
email: kim@example.com
|
||||||
|
password: "SecurePass456!"
|
||||||
|
storeName: 아메리카노 카페
|
||||||
|
industry: 카페
|
||||||
|
address: 서울시 서초구 서초대로 456
|
||||||
|
businessHours: "매일 09:00-20:00"
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: 회원가입 성공
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/RegisterResponse'
|
||||||
|
examples:
|
||||||
|
success:
|
||||||
|
summary: 회원가입 성공 응답
|
||||||
|
value:
|
||||||
|
token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEyMywicm9sZSI6Ik9XTkVSIiwiaWF0IjoxNjE2MjM5MDIyLCJleHAiOjE2MTY4NDM4MjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
|
||||||
|
userId: 123
|
||||||
|
userName: 홍길동
|
||||||
|
storeId: 456
|
||||||
|
storeName: 맛있는집
|
||||||
|
'400':
|
||||||
|
description: 잘못된 요청
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
examples:
|
||||||
|
duplicateUser:
|
||||||
|
summary: 중복 사용자
|
||||||
|
value:
|
||||||
|
code: USER_001
|
||||||
|
message: 이미 가입된 전화번호입니다
|
||||||
|
timestamp: 2025-10-22T10:30:00Z
|
||||||
|
validationError:
|
||||||
|
summary: 입력 검증 오류
|
||||||
|
value:
|
||||||
|
code: VALIDATION_ERROR
|
||||||
|
message: 비밀번호는 8자 이상이어야 합니다
|
||||||
|
timestamp: 2025-10-22T10:30:00Z
|
||||||
|
'500':
|
||||||
|
description: 서버 오류
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
|
/users/login:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Authentication
|
||||||
|
summary: 로그인
|
||||||
|
description: |
|
||||||
|
소상공인 로그인 API
|
||||||
|
|
||||||
|
**유저스토리:** UFR-USER-020
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- 전화번호/비밀번호 인증
|
||||||
|
- JWT 토큰 발급
|
||||||
|
- Redis 세션 저장
|
||||||
|
- 최종 로그인 시각 업데이트 (비동기)
|
||||||
|
|
||||||
|
**보안:**
|
||||||
|
- Timing Attack 방어 (에러 메시지 통일)
|
||||||
|
- bcrypt 비밀번호 검증
|
||||||
|
- JWT 토큰 7일 만료
|
||||||
|
operationId: loginUser
|
||||||
|
x-user-story: UFR-USER-020
|
||||||
|
x-controller: UserController
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/LoginRequest'
|
||||||
|
examples:
|
||||||
|
default:
|
||||||
|
summary: 로그인 요청 예시
|
||||||
|
value:
|
||||||
|
phoneNumber: "01012345678"
|
||||||
|
password: "Password123!"
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 로그인 성공
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/LoginResponse'
|
||||||
|
examples:
|
||||||
|
success:
|
||||||
|
summary: 로그인 성공 응답
|
||||||
|
value:
|
||||||
|
token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEyMywicm9sZSI6Ik9XTkVSIiwiaWF0IjoxNjE2MjM5MDIyLCJleHAiOjE2MTY4NDM4MjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
|
||||||
|
userId: 123
|
||||||
|
userName: 홍길동
|
||||||
|
role: OWNER
|
||||||
|
email: hong@example.com
|
||||||
|
'401':
|
||||||
|
description: 인증 실패
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
examples:
|
||||||
|
authFailed:
|
||||||
|
summary: 인증 실패
|
||||||
|
value:
|
||||||
|
code: AUTH_001
|
||||||
|
message: 전화번호 또는 비밀번호를 확인해주세요
|
||||||
|
timestamp: 2025-10-22T10:30:00Z
|
||||||
|
|
||||||
|
/users/logout:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Authentication
|
||||||
|
summary: 로그아웃
|
||||||
|
description: |
|
||||||
|
로그아웃 API
|
||||||
|
|
||||||
|
**유저스토리:** UFR-USER-040
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- Redis 세션 삭제
|
||||||
|
- JWT 토큰 Blacklist 추가
|
||||||
|
- 멱등성 보장
|
||||||
|
|
||||||
|
**처리 흐름:**
|
||||||
|
1. JWT 토큰 검증
|
||||||
|
2. Redis 세션 삭제
|
||||||
|
3. JWT Blacklist 추가 (남은 만료 시간만큼 TTL 설정)
|
||||||
|
4. 로그아웃 이벤트 발행
|
||||||
|
operationId: logoutUser
|
||||||
|
x-user-story: UFR-USER-040
|
||||||
|
x-controller: UserController
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 로그아웃 성공
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/LogoutResponse'
|
||||||
|
examples:
|
||||||
|
success:
|
||||||
|
summary: 로그아웃 성공 응답
|
||||||
|
value:
|
||||||
|
success: true
|
||||||
|
message: 안전하게 로그아웃되었습니다
|
||||||
|
'401':
|
||||||
|
description: 인증 실패 (유효하지 않은 토큰)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
examples:
|
||||||
|
invalidToken:
|
||||||
|
summary: 유효하지 않은 토큰
|
||||||
|
value:
|
||||||
|
code: AUTH_002
|
||||||
|
message: 유효하지 않은 토큰입니다
|
||||||
|
timestamp: 2025-10-22T10:30:00Z
|
||||||
|
|
||||||
|
/users/profile:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Profile
|
||||||
|
summary: 프로필 조회
|
||||||
|
description: |
|
||||||
|
사용자 프로필 조회 API
|
||||||
|
|
||||||
|
**유저스토리:** UFR-USER-030
|
||||||
|
|
||||||
|
**조회 정보:**
|
||||||
|
- 기본 정보 (이름, 전화번호, 이메일)
|
||||||
|
- 매장 정보 (매장명, 업종, 주소, 영업시간)
|
||||||
|
operationId: getProfile
|
||||||
|
x-user-story: UFR-USER-030
|
||||||
|
x-controller: UserController
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 프로필 조회 성공
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ProfileResponse'
|
||||||
|
examples:
|
||||||
|
success:
|
||||||
|
summary: 프로필 조회 성공 응답
|
||||||
|
value:
|
||||||
|
userId: 123
|
||||||
|
userName: 홍길동
|
||||||
|
phoneNumber: "01012345678"
|
||||||
|
email: hong@example.com
|
||||||
|
role: OWNER
|
||||||
|
storeId: 456
|
||||||
|
storeName: 맛있는집
|
||||||
|
industry: 음식점
|
||||||
|
address: 서울시 강남구 테헤란로 123
|
||||||
|
businessHours: "월-금 11:00-22:00, 토-일 12:00-21:00"
|
||||||
|
createdAt: 2025-09-01T10:00:00Z
|
||||||
|
lastLoginAt: 2025-10-22T09:00:00Z
|
||||||
|
'401':
|
||||||
|
description: 인증 실패
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'404':
|
||||||
|
description: 사용자를 찾을 수 없음
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
examples:
|
||||||
|
notFound:
|
||||||
|
summary: 사용자 없음
|
||||||
|
value:
|
||||||
|
code: USER_003
|
||||||
|
message: 사용자를 찾을 수 없습니다
|
||||||
|
timestamp: 2025-10-22T10:30:00Z
|
||||||
|
|
||||||
|
put:
|
||||||
|
tags:
|
||||||
|
- Profile
|
||||||
|
summary: 프로필 수정
|
||||||
|
description: |
|
||||||
|
사용자 프로필 수정 API
|
||||||
|
|
||||||
|
**유저스토리:** UFR-USER-030
|
||||||
|
|
||||||
|
**수정 가능 항목:**
|
||||||
|
- 기본 정보: 이름, 전화번호, 이메일
|
||||||
|
- 매장 정보: 매장명, 업종, 주소, 영업시간
|
||||||
|
|
||||||
|
**주의사항:**
|
||||||
|
- 비밀번호 변경은 별도 API 사용 (/users/password)
|
||||||
|
- 전화번호 변경 시 향후 재인증 필요 (현재는 직접 변경 가능)
|
||||||
|
- Optimistic Locking으로 동시성 제어
|
||||||
|
operationId: updateProfile
|
||||||
|
x-user-story: UFR-USER-030
|
||||||
|
x-controller: UserController
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/UpdateProfileRequest'
|
||||||
|
examples:
|
||||||
|
fullUpdate:
|
||||||
|
summary: 전체 정보 수정
|
||||||
|
value:
|
||||||
|
name: 홍길동
|
||||||
|
phoneNumber: "01012345678"
|
||||||
|
email: hong.new@example.com
|
||||||
|
storeName: 맛있는집 (리뉴얼)
|
||||||
|
industry: 퓨전음식점
|
||||||
|
address: 서울시 강남구 테헤란로 456
|
||||||
|
businessHours: "매일 11:00-23:00"
|
||||||
|
partialUpdate:
|
||||||
|
summary: 일부 정보 수정 (이메일, 영업시간)
|
||||||
|
value:
|
||||||
|
email: hong.updated@example.com
|
||||||
|
businessHours: "월-금 10:00-22:00, 토-일 휴무"
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 프로필 수정 성공
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/UpdateProfileResponse'
|
||||||
|
examples:
|
||||||
|
success:
|
||||||
|
summary: 프로필 수정 성공 응답
|
||||||
|
value:
|
||||||
|
userId: 123
|
||||||
|
userName: 홍길동
|
||||||
|
email: hong.new@example.com
|
||||||
|
storeId: 456
|
||||||
|
storeName: 맛있는집 (리뉴얼)
|
||||||
|
'400':
|
||||||
|
description: 잘못된 요청
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'401':
|
||||||
|
description: 인증 실패
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'404':
|
||||||
|
description: 사용자를 찾을 수 없음
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'409':
|
||||||
|
description: 동시성 충돌 (다른 세션에서 수정)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
examples:
|
||||||
|
conflict:
|
||||||
|
summary: 동시성 충돌
|
||||||
|
value:
|
||||||
|
code: USER_005
|
||||||
|
message: 다른 세션에서 프로필을 수정했습니다. 새로고침 후 다시 시도하세요
|
||||||
|
timestamp: 2025-10-22T10:30:00Z
|
||||||
|
|
||||||
|
/users/password:
|
||||||
|
put:
|
||||||
|
tags:
|
||||||
|
- Profile
|
||||||
|
summary: 비밀번호 변경
|
||||||
|
description: |
|
||||||
|
비밀번호 변경 API
|
||||||
|
|
||||||
|
**유저스토리:** UFR-USER-030
|
||||||
|
|
||||||
|
**주요 기능:**
|
||||||
|
- 현재 비밀번호 확인 필수
|
||||||
|
- 새 비밀번호 규칙 검증 (8자 이상, 영문/숫자/특수문자 포함)
|
||||||
|
- bcrypt 해싱
|
||||||
|
|
||||||
|
**보안:**
|
||||||
|
- 현재 비밀번호 검증 실패 시 400 Bad Request
|
||||||
|
- 비밀번호 변경 후 기존 세션 유지 (로그아웃 불필요)
|
||||||
|
operationId: changePassword
|
||||||
|
x-user-story: UFR-USER-030
|
||||||
|
x-controller: UserController
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ChangePasswordRequest'
|
||||||
|
examples:
|
||||||
|
default:
|
||||||
|
summary: 비밀번호 변경 요청
|
||||||
|
value:
|
||||||
|
currentPassword: "Password123!"
|
||||||
|
newPassword: "NewSecurePass456!"
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 비밀번호 변경 성공
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ChangePasswordResponse'
|
||||||
|
examples:
|
||||||
|
success:
|
||||||
|
summary: 비밀번호 변경 성공 응답
|
||||||
|
value:
|
||||||
|
success: true
|
||||||
|
message: 비밀번호가 성공적으로 변경되었습니다
|
||||||
|
'400':
|
||||||
|
description: 현재 비밀번호 불일치 또는 새 비밀번호 규칙 위반
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
examples:
|
||||||
|
invalidCurrentPassword:
|
||||||
|
summary: 현재 비밀번호 불일치
|
||||||
|
value:
|
||||||
|
code: USER_004
|
||||||
|
message: 현재 비밀번호가 일치하지 않습니다
|
||||||
|
timestamp: 2025-10-22T10:30:00Z
|
||||||
|
invalidNewPassword:
|
||||||
|
summary: 새 비밀번호 규칙 위반
|
||||||
|
value:
|
||||||
|
code: VALIDATION_ERROR
|
||||||
|
message: 비밀번호는 8자 이상이어야 하며 영문/숫자/특수문자를 포함해야 합니다
|
||||||
|
timestamp: 2025-10-22T10:30:00Z
|
||||||
|
'401':
|
||||||
|
description: 인증 실패
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
|
/users/{userId}/store:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Profile
|
||||||
|
summary: 매장정보 조회 (서비스 연동용)
|
||||||
|
description: |
|
||||||
|
특정 사용자의 매장정보를 조회하는 API (내부 서비스 연동용)
|
||||||
|
|
||||||
|
**사용 목적:**
|
||||||
|
- Event Service에서 이벤트 생성 시 매장정보 조회
|
||||||
|
- Content Service에서 매장정보 기반 콘텐츠 생성
|
||||||
|
- Service-to-Service 통신용 내부 API
|
||||||
|
|
||||||
|
**주의사항:**
|
||||||
|
- Internal API로 외부 노출 금지
|
||||||
|
- API Gateway에서 인증된 서비스만 접근 허용
|
||||||
|
- 매장정보는 Redis 캐시 우선 조회 (TTL 30분)
|
||||||
|
operationId: getStoreByUserId
|
||||||
|
x-user-story: Service Integration
|
||||||
|
x-controller: UserController
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: userId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: 사용자 ID
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
example: 123
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 매장정보 조회 성공
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/StoreDetailResponse'
|
||||||
|
examples:
|
||||||
|
success:
|
||||||
|
summary: 매장정보 조회 성공 응답
|
||||||
|
value:
|
||||||
|
userId: 123
|
||||||
|
storeId: 456
|
||||||
|
storeName: 맛있는집
|
||||||
|
industry: 음식점
|
||||||
|
address: 서울시 강남구 테헤란로 123
|
||||||
|
businessHours: "월-금 11:00-22:00, 토-일 12:00-21:00"
|
||||||
|
'401':
|
||||||
|
description: 인증 실패
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
examples:
|
||||||
|
unauthorized:
|
||||||
|
summary: 인증 실패
|
||||||
|
value:
|
||||||
|
code: AUTH_002
|
||||||
|
message: 유효하지 않은 토큰입니다
|
||||||
|
timestamp: 2025-10-22T10:30:00Z
|
||||||
|
'403':
|
||||||
|
description: 권한 없음 (내부 서비스만 접근 가능)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
examples:
|
||||||
|
forbidden:
|
||||||
|
summary: 권한 없음
|
||||||
|
value:
|
||||||
|
code: AUTH_003
|
||||||
|
message: 이 API는 내부 서비스만 접근 가능합니다
|
||||||
|
timestamp: 2025-10-22T10:30:00Z
|
||||||
|
'404':
|
||||||
|
description: 사용자 또는 매장을 찾을 수 없음
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
examples:
|
||||||
|
notFound:
|
||||||
|
summary: 사용자 또는 매장 없음
|
||||||
|
value:
|
||||||
|
code: USER_003
|
||||||
|
message: 사용자 또는 매장을 찾을 수 없습니다
|
||||||
|
timestamp: 2025-10-22T10:30:00Z
|
||||||
|
'500':
|
||||||
|
description: 서버 오류
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
BearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: JWT
|
||||||
|
description: |
|
||||||
|
JWT Bearer 토큰 인증
|
||||||
|
|
||||||
|
**형식:** Authorization: Bearer {JWT_TOKEN}
|
||||||
|
|
||||||
|
**토큰 만료:** 7일
|
||||||
|
|
||||||
|
**Claims:**
|
||||||
|
- userId: 사용자 ID
|
||||||
|
- role: 사용자 역할 (OWNER)
|
||||||
|
- iat: 발급 시각
|
||||||
|
- exp: 만료 시각
|
||||||
|
|
||||||
|
schemas:
|
||||||
|
RegisterRequest:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
- phoneNumber
|
||||||
|
- email
|
||||||
|
- password
|
||||||
|
- storeName
|
||||||
|
- industry
|
||||||
|
- address
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
minLength: 2
|
||||||
|
maxLength: 50
|
||||||
|
description: 사용자 이름 (2자 이상, 한글/영문)
|
||||||
|
example: 홍길동
|
||||||
|
phoneNumber:
|
||||||
|
type: string
|
||||||
|
pattern: '^010\d{8}$'
|
||||||
|
description: 휴대폰 번호 (010XXXXXXXX)
|
||||||
|
example: "01012345678"
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
maxLength: 100
|
||||||
|
description: 이메일 주소
|
||||||
|
example: hong@example.com
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
minLength: 8
|
||||||
|
maxLength: 100
|
||||||
|
description: 비밀번호 (8자 이상, 영문/숫자/특수문자 포함)
|
||||||
|
example: "Password123!"
|
||||||
|
storeName:
|
||||||
|
type: string
|
||||||
|
minLength: 2
|
||||||
|
maxLength: 100
|
||||||
|
description: 매장명
|
||||||
|
example: 맛있는집
|
||||||
|
industry:
|
||||||
|
type: string
|
||||||
|
maxLength: 50
|
||||||
|
description: 업종 (예 음식점, 카페, 소매점 등)
|
||||||
|
example: 음식점
|
||||||
|
address:
|
||||||
|
type: string
|
||||||
|
minLength: 5
|
||||||
|
maxLength: 200
|
||||||
|
description: 매장 주소
|
||||||
|
example: 서울시 강남구 테헤란로 123
|
||||||
|
businessHours:
|
||||||
|
type: string
|
||||||
|
maxLength: 200
|
||||||
|
description: 영업시간 (선택 사항)
|
||||||
|
example: "월-금 11:00-22:00, 토-일 12:00-21:00"
|
||||||
|
|
||||||
|
RegisterResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- token
|
||||||
|
- userId
|
||||||
|
- userName
|
||||||
|
- storeId
|
||||||
|
- storeName
|
||||||
|
properties:
|
||||||
|
token:
|
||||||
|
type: string
|
||||||
|
description: JWT 토큰 (7일 만료)
|
||||||
|
example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEyMywicm9sZSI6Ik9XTkVSIiwiaWF0IjoxNjE2MjM5MDIyLCJleHAiOjE2MTY4NDM4MjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
|
||||||
|
userId:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
description: 사용자 ID
|
||||||
|
example: 123
|
||||||
|
userName:
|
||||||
|
type: string
|
||||||
|
description: 사용자 이름
|
||||||
|
example: 홍길동
|
||||||
|
storeId:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
description: 매장 ID
|
||||||
|
example: 456
|
||||||
|
storeName:
|
||||||
|
type: string
|
||||||
|
description: 매장명
|
||||||
|
example: 맛있는집
|
||||||
|
|
||||||
|
LoginRequest:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- phoneNumber
|
||||||
|
- password
|
||||||
|
properties:
|
||||||
|
phoneNumber:
|
||||||
|
type: string
|
||||||
|
pattern: '^010\d{8}$'
|
||||||
|
description: 휴대폰 번호
|
||||||
|
example: "01012345678"
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
minLength: 8
|
||||||
|
description: 비밀번호
|
||||||
|
example: "Password123!"
|
||||||
|
|
||||||
|
LoginResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- token
|
||||||
|
- userId
|
||||||
|
- userName
|
||||||
|
- role
|
||||||
|
- email
|
||||||
|
properties:
|
||||||
|
token:
|
||||||
|
type: string
|
||||||
|
description: JWT 토큰 (7일 만료)
|
||||||
|
example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEyMywicm9sZSI6Ik9XTkVSIiwiaWF0IjoxNjE2MjM5MDIyLCJleHAiOjE2MTY4NDM4MjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
|
||||||
|
userId:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
description: 사용자 ID
|
||||||
|
example: 123
|
||||||
|
userName:
|
||||||
|
type: string
|
||||||
|
description: 사용자 이름
|
||||||
|
example: 홍길동
|
||||||
|
role:
|
||||||
|
type: string
|
||||||
|
enum: [OWNER, ADMIN]
|
||||||
|
description: 사용자 역할
|
||||||
|
example: OWNER
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
description: 이메일 주소
|
||||||
|
example: hong@example.com
|
||||||
|
|
||||||
|
LogoutResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- success
|
||||||
|
- message
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
description: 로그아웃 성공 여부
|
||||||
|
example: true
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
description: 응답 메시지
|
||||||
|
example: 안전하게 로그아웃되었습니다
|
||||||
|
|
||||||
|
ProfileResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- userId
|
||||||
|
- userName
|
||||||
|
- phoneNumber
|
||||||
|
- email
|
||||||
|
- role
|
||||||
|
- storeId
|
||||||
|
- storeName
|
||||||
|
- industry
|
||||||
|
- address
|
||||||
|
properties:
|
||||||
|
userId:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
description: 사용자 ID
|
||||||
|
example: 123
|
||||||
|
userName:
|
||||||
|
type: string
|
||||||
|
description: 사용자 이름
|
||||||
|
example: 홍길동
|
||||||
|
phoneNumber:
|
||||||
|
type: string
|
||||||
|
description: 휴대폰 번호
|
||||||
|
example: "01012345678"
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
description: 이메일 주소
|
||||||
|
example: hong@example.com
|
||||||
|
role:
|
||||||
|
type: string
|
||||||
|
enum: [OWNER, ADMIN]
|
||||||
|
description: 사용자 역할
|
||||||
|
example: OWNER
|
||||||
|
storeId:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
description: 매장 ID
|
||||||
|
example: 456
|
||||||
|
storeName:
|
||||||
|
type: string
|
||||||
|
description: 매장명
|
||||||
|
example: 맛있는집
|
||||||
|
industry:
|
||||||
|
type: string
|
||||||
|
description: 업종
|
||||||
|
example: 음식점
|
||||||
|
address:
|
||||||
|
type: string
|
||||||
|
description: 매장 주소
|
||||||
|
example: 서울시 강남구 테헤란로 123
|
||||||
|
businessHours:
|
||||||
|
type: string
|
||||||
|
description: 영업시간
|
||||||
|
example: "월-금 11:00-22:00, 토-일 12:00-21:00"
|
||||||
|
createdAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: 가입 일시
|
||||||
|
example: 2025-09-01T10:00:00Z
|
||||||
|
lastLoginAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: 최종 로그인 일시
|
||||||
|
example: 2025-10-22T09:00:00Z
|
||||||
|
|
||||||
|
UpdateProfileRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
minLength: 2
|
||||||
|
maxLength: 50
|
||||||
|
description: 사용자 이름 (선택 사항)
|
||||||
|
example: 홍길동
|
||||||
|
phoneNumber:
|
||||||
|
type: string
|
||||||
|
pattern: '^010\d{8}$'
|
||||||
|
description: 휴대폰 번호 (선택 사항, 향후 재인증 필요)
|
||||||
|
example: "01012345678"
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
maxLength: 100
|
||||||
|
description: 이메일 주소 (선택 사항)
|
||||||
|
example: hong.new@example.com
|
||||||
|
storeName:
|
||||||
|
type: string
|
||||||
|
minLength: 2
|
||||||
|
maxLength: 100
|
||||||
|
description: 매장명 (선택 사항)
|
||||||
|
example: 맛있는집 (리뉴얼)
|
||||||
|
industry:
|
||||||
|
type: string
|
||||||
|
maxLength: 50
|
||||||
|
description: 업종 (선택 사항)
|
||||||
|
example: 퓨전음식점
|
||||||
|
address:
|
||||||
|
type: string
|
||||||
|
minLength: 5
|
||||||
|
maxLength: 200
|
||||||
|
description: 매장 주소 (선택 사항)
|
||||||
|
example: 서울시 강남구 테헤란로 456
|
||||||
|
businessHours:
|
||||||
|
type: string
|
||||||
|
maxLength: 200
|
||||||
|
description: 영업시간 (선택 사항)
|
||||||
|
example: "매일 11:00-23:00"
|
||||||
|
|
||||||
|
UpdateProfileResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- userId
|
||||||
|
- userName
|
||||||
|
- email
|
||||||
|
- storeId
|
||||||
|
- storeName
|
||||||
|
properties:
|
||||||
|
userId:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
description: 사용자 ID
|
||||||
|
example: 123
|
||||||
|
userName:
|
||||||
|
type: string
|
||||||
|
description: 사용자 이름
|
||||||
|
example: 홍길동
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
description: 이메일 주소
|
||||||
|
example: hong.new@example.com
|
||||||
|
storeId:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
description: 매장 ID
|
||||||
|
example: 456
|
||||||
|
storeName:
|
||||||
|
type: string
|
||||||
|
description: 매장명
|
||||||
|
example: 맛있는집 (리뉴얼)
|
||||||
|
|
||||||
|
ChangePasswordRequest:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- currentPassword
|
||||||
|
- newPassword
|
||||||
|
properties:
|
||||||
|
currentPassword:
|
||||||
|
type: string
|
||||||
|
minLength: 8
|
||||||
|
description: 현재 비밀번호
|
||||||
|
example: "Password123!"
|
||||||
|
newPassword:
|
||||||
|
type: string
|
||||||
|
minLength: 8
|
||||||
|
maxLength: 100
|
||||||
|
description: 새 비밀번호 (8자 이상, 영문/숫자/특수문자 포함)
|
||||||
|
example: "NewSecurePass456!"
|
||||||
|
|
||||||
|
ChangePasswordResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- success
|
||||||
|
- message
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
description: 비밀번호 변경 성공 여부
|
||||||
|
example: true
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
description: 응답 메시지
|
||||||
|
example: 비밀번호가 성공적으로 변경되었습니다
|
||||||
|
|
||||||
|
StoreDetailResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- userId
|
||||||
|
- storeId
|
||||||
|
- storeName
|
||||||
|
- industry
|
||||||
|
- address
|
||||||
|
properties:
|
||||||
|
userId:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
description: 사용자 ID
|
||||||
|
example: 123
|
||||||
|
storeId:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
description: 매장 ID
|
||||||
|
example: 456
|
||||||
|
storeName:
|
||||||
|
type: string
|
||||||
|
description: 매장명
|
||||||
|
example: 맛있는집
|
||||||
|
industry:
|
||||||
|
type: string
|
||||||
|
description: 업종
|
||||||
|
example: 음식점
|
||||||
|
address:
|
||||||
|
type: string
|
||||||
|
description: 매장 주소
|
||||||
|
example: 서울시 강남구 테헤란로 123
|
||||||
|
businessHours:
|
||||||
|
type: string
|
||||||
|
description: 영업시간
|
||||||
|
example: "월-금 11:00-22:00, 토-일 12:00-21:00"
|
||||||
|
|
||||||
|
ErrorResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- code
|
||||||
|
- message
|
||||||
|
- timestamp
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
description: 에러 코드
|
||||||
|
example: USER_001
|
||||||
|
enum:
|
||||||
|
- USER_001 # 중복 사용자
|
||||||
|
- USER_003 # 사용자 없음
|
||||||
|
- USER_004 # 현재 비밀번호 불일치
|
||||||
|
- USER_005 # 동시성 충돌
|
||||||
|
- AUTH_001 # 인증 실패
|
||||||
|
- AUTH_002 # 유효하지 않은 토큰
|
||||||
|
- AUTH_003 # 권한 없음 (내부 서비스만 접근)
|
||||||
|
- VALIDATION_ERROR # 입력 검증 오류
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
description: 에러 메시지
|
||||||
|
example: 이미 가입된 전화번호입니다
|
||||||
|
timestamp:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: 에러 발생 시각
|
||||||
|
example: 2025-10-22T10:30:00Z
|
||||||
|
details:
|
||||||
|
type: array
|
||||||
|
description: 상세 에러 정보 (선택 사항)
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
example: ["필드명: 필수 항목입니다"]
|
||||||
869
design/backend/logical/logical-architecture.md
Normal file
869
design/backend/logical/logical-architecture.md
Normal file
@ -0,0 +1,869 @@
|
|||||||
|
# KT AI 기반 소상공인 이벤트 자동 생성 서비스 - 논리 아키텍처
|
||||||
|
|
||||||
|
## 문서 정보
|
||||||
|
- **작성일**: 2025-10-21
|
||||||
|
- **최종 수정일**: 2025-10-22
|
||||||
|
- **버전**: 2.0 (CQRS + Event-Driven 전환)
|
||||||
|
- **작성자**: System Architect
|
||||||
|
- **관련 문서**:
|
||||||
|
- [유저스토리](../../userstory.md)
|
||||||
|
- [아키텍처 패턴](../../pattern/architecture-pattern.md)
|
||||||
|
- [UI/UX 설계서](../../uiux/uiux.md)
|
||||||
|
|
||||||
|
## 버전 이력
|
||||||
|
- **v1.0** (2025-10-21): 초기 마이크로서비스 아키텍처 설계
|
||||||
|
- **v2.0** (2025-10-22): CQRS 패턴 및 Event-Driven 아키텍처 전환, Resilience 패턴 전면 적용
|
||||||
|
- **v2.1** (2025-10-22): 서비스 구조 간소화, Kafka 통합 (Event Bus + Job Queue), Distribution 비동기 처리
|
||||||
|
- **v2.2** (2025-10-22): Distribution Service 동기 호출 전환 (REST API 직접 호출)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 목차
|
||||||
|
1. [개요](#1-개요)
|
||||||
|
2. [서비스 아키텍처](#2-서비스-아키텍처)
|
||||||
|
3. [주요 사용자 플로우](#3-주요-사용자-플로우)
|
||||||
|
4. [데이터 흐름 및 캐싱 전략](#4-데이터-흐름-및-캐싱-전략)
|
||||||
|
5. [확장성 및 성능 고려사항](#5-확장성-및-성능-고려사항)
|
||||||
|
6. [보안 고려사항](#6-보안-고려사항)
|
||||||
|
7. [논리 아키텍처 다이어그램](#7-논리-아키텍처-다이어그램)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 개요
|
||||||
|
|
||||||
|
### 1.1 설계 원칙
|
||||||
|
|
||||||
|
본 논리 아키텍처는 다음 원칙을 기반으로 설계되었습니다:
|
||||||
|
|
||||||
|
#### 유저스토리 기반 설계
|
||||||
|
- 20개 유저스토리와 정확히 매칭
|
||||||
|
- 불필요한 추가 기능 배제
|
||||||
|
- 비즈니스 요구사항 우선 반영
|
||||||
|
|
||||||
|
#### Event-Driven 아키텍처
|
||||||
|
- **Kafka 기반 통합**: Event Bus와 Job Queue를 Kafka로 통합
|
||||||
|
- **비동기 메시징**: Kafka Topics를 통한 서비스 간 통신
|
||||||
|
- **느슨한 결합**: 서비스 간 직접 의존성 제거
|
||||||
|
- **확장성**: 이벤트 구독자 추가로 기능 확장 용이
|
||||||
|
- **장애 격리**: 이벤트 발행/구독 실패 시 서비스 독립성 유지
|
||||||
|
|
||||||
|
#### Kafka 통합 전략
|
||||||
|
- **Event Topics**: 도메인 이벤트 발행/구독 (EventCreated, ParticipantRegistered 등)
|
||||||
|
- **Job Topics**: 비동기 작업 요청/처리 (ai 이벤트 생성, 이미지 생성)
|
||||||
|
- **단일 메시징 플랫폼**: 운영 복잡도 감소 및 일관된 메시지 처리
|
||||||
|
|
||||||
|
#### Resilience 패턴 적용
|
||||||
|
- **Circuit Breaker**: 외부 API 장애 시 빠른 실패 및 복구 (Hystrix/Resilience4j)
|
||||||
|
- **Retry Pattern**: 일시적 장애 시 자동 재시도 (지수 백오프)
|
||||||
|
- **Timeout Pattern**: 응답 시간 제한으로 리소스 점유 방지
|
||||||
|
- **Bulkhead Pattern**: 리소스 격리로 장애 전파 차단
|
||||||
|
- **Fallback Pattern**: 장애 시 대체 로직 실행 (캐시 응답 등)
|
||||||
|
|
||||||
|
### 1.2 핵심 컴포넌트 정의
|
||||||
|
|
||||||
|
#### Core Services
|
||||||
|
1. **User Service**: 사용자 인증 및 매장정보 관리
|
||||||
|
- 회원가입/로그인 (JWT 발급)
|
||||||
|
- 프로필 CRUD
|
||||||
|
- Event Service로 회원정보 제공
|
||||||
|
|
||||||
|
2. **Event Service**: 이벤트 전체 생명주기 관리
|
||||||
|
- 이벤트 생성/수정/삭제/조회
|
||||||
|
- 이벤트 생성 플로우 오케스트레이션
|
||||||
|
- Kafka Job 발행 (AI, 이미지)
|
||||||
|
- Distribution Service 동기 호출 (배포)
|
||||||
|
- Kafka Event 발행 (EventCreated)
|
||||||
|
|
||||||
|
3. **Participation Service**: 참여 및 당첨자 관리
|
||||||
|
- 참여 접수 및 중복 체크
|
||||||
|
- 참여자 목록 조회
|
||||||
|
- 당첨자 추첨 및 조회
|
||||||
|
- Kafka Event 발행 (ParticipantRegistered)
|
||||||
|
|
||||||
|
4. **Analytics Service**: 실시간 성과 분석 및 대시보드
|
||||||
|
- 대시보드 데이터 조회 (Redis 캐싱)
|
||||||
|
- Kafka Event 구독 (EventCreated, ParticipantRegistered, DistributionCompleted)
|
||||||
|
- 외부 채널 통계 수집 (Circuit Breaker + Fallback)
|
||||||
|
- ROI 계산 및 성과 분석
|
||||||
|
|
||||||
|
#### Async Services (비동기 처리)
|
||||||
|
1. **AI Service**: AI 기반 이벤트 추천
|
||||||
|
- Kafka Job 구독 (ai 이벤트 생성)
|
||||||
|
- 외부 AI API 호출 (Circuit Breaker, Timeout 5분)
|
||||||
|
- 결과 Redis 저장 (TTL 24시간)
|
||||||
|
|
||||||
|
2. **Content Service**: SNS 이미지 생성
|
||||||
|
- Redis에서 AI 데이터 읽기
|
||||||
|
- 외부 이미지 생성 API 호출 (Circuit Breaker, Timeout 5분)
|
||||||
|
- 생성된 이미지 Redis 저장 (CDN URL, TTL 7일)
|
||||||
|
|
||||||
|
3. **Distribution Service**: 다중 채널 배포 (동기)
|
||||||
|
- REST API 제공 (Event Service에서 호출)
|
||||||
|
- 병렬 배포 (Circuit Breaker, Retry, Bulkhead)
|
||||||
|
- Kafka Event 발행 (DistributionCompleted)
|
||||||
|
|
||||||
|
#### Kafka (통합 메시징 플랫폼)
|
||||||
|
**Event Topics** (도메인 이벤트):
|
||||||
|
- **EventCreated**: 이벤트 생성 시
|
||||||
|
- **ParticipantRegistered**: 참여자 등록 시
|
||||||
|
- **DistributionCompleted**: 배포 완료 시
|
||||||
|
|
||||||
|
**Job Topics** (비동기 작업):
|
||||||
|
- **ai 이벤트 생성**: AI 추천 작업
|
||||||
|
- **이미지 생성**: 이미지 생성 작업
|
||||||
|
|
||||||
|
**특징**:
|
||||||
|
- At-Least-Once Delivery 보장
|
||||||
|
- Partition Key 기반 순서 보장
|
||||||
|
- Dead Letter Queue 지원
|
||||||
|
|
||||||
|
#### Data Layer
|
||||||
|
- **Redis Cache**: 세션, AI 결과, 이미지 URL, 대시보드 캐싱
|
||||||
|
- **PostgreSQL**: 서비스별 독립 데이터베이스
|
||||||
|
- User DB, Event DB, Participation DB, Analytics DB
|
||||||
|
|
||||||
|
#### External Systems
|
||||||
|
- **AI APIs**: Claude/GPT-4 (트렌드 분석)
|
||||||
|
- **이미지 생성 APIs**: Stable Diffusion/DALL-E
|
||||||
|
- **배포 채널 APIs**: 우리동네TV, 링고비즈, 지니TV, SNS APIs (비동기 배포)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 서비스 아키텍처
|
||||||
|
|
||||||
|
### 2.1 서비스별 책임
|
||||||
|
|
||||||
|
#### User Service
|
||||||
|
**핵심 책임**:
|
||||||
|
- 회원가입/로그인 (JWT 토큰 발급)
|
||||||
|
- 프로필 CRUD (매장 정보 포함)
|
||||||
|
- 세션 관리
|
||||||
|
- Event Service로 회원정보 제공
|
||||||
|
|
||||||
|
**관련 유저스토리**: UFR-USER-010, 020, 030, 040
|
||||||
|
|
||||||
|
**서비스 간 호출**:
|
||||||
|
- **Event Service**: 회원정보 조회 API 제공 (매장 정보 포함)
|
||||||
|
|
||||||
|
**데이터 저장**:
|
||||||
|
- User DB: users, stores 테이블
|
||||||
|
- Redis: 세션 정보 (TTL 7일)
|
||||||
|
|
||||||
|
#### Event Service
|
||||||
|
**핵심 책임**:
|
||||||
|
- 이벤트 생성/수정/삭제/조회
|
||||||
|
- 이벤트 생성 플로우 오케스트레이션
|
||||||
|
- Kafka Job 발행 (AI, 이미지, 배포)
|
||||||
|
- Kafka Event 발행 (EventCreated)
|
||||||
|
|
||||||
|
**관련 유저스토리**: UFR-EVENT-010, 020, 030, 040, 050, 060, 070
|
||||||
|
|
||||||
|
**Kafka 이벤트 발행**:
|
||||||
|
1. **EventCreated**: 이벤트 생성 완료 시
|
||||||
|
- Payload: eventId, storeId, title, objective, createdAt
|
||||||
|
- 구독자: Analytics Service
|
||||||
|
|
||||||
|
**Kafka Job 발행**:
|
||||||
|
1. **ai 이벤트 생성**: AI 추천 요청
|
||||||
|
2. **이미지 생성**: 이미지 생성 요청
|
||||||
|
|
||||||
|
**서비스 간 호출**:
|
||||||
|
- **Distribution Service**: 다중 채널 배포 (동기 호출, Circuit Breaker 적용)
|
||||||
|
|
||||||
|
**주요 플로우**:
|
||||||
|
1. 이벤트 목적 선택 → Event DB 저장 → EventCreated 발행
|
||||||
|
2. AI 추천 요청 → ai 이벤트 생성 발행
|
||||||
|
3. 이미지 생성 요청 → 이미지 생성 발행
|
||||||
|
4. 배포 승인 → Distribution Service 동기 호출
|
||||||
|
|
||||||
|
**데이터 저장**:
|
||||||
|
- Event DB: events, event_objectives, event_prizes 테이블
|
||||||
|
|
||||||
|
#### Participation Service
|
||||||
|
**핵심 책임**:
|
||||||
|
- 이벤트 참여 접수 및 검증
|
||||||
|
- 참여자 목록 조회
|
||||||
|
- 당첨자 추첨 및 조회
|
||||||
|
- Kafka Event 발행 (ParticipantRegistered)
|
||||||
|
|
||||||
|
**관련 유저스토리**: UFR-PART-010, 020, 030
|
||||||
|
|
||||||
|
**Kafka 이벤트 발행**:
|
||||||
|
1. **ParticipantRegistered**: 참여자 등록 시
|
||||||
|
- Payload: participantId, eventId, phoneNumber, registeredAt
|
||||||
|
- 구독자: Analytics Service
|
||||||
|
|
||||||
|
**주요 기능**:
|
||||||
|
- 중복 참여 체크 (전화번호 기반)
|
||||||
|
- 참여자 목록 조회 (페이지네이션 지원)
|
||||||
|
- 난수 기반 무작위 추첨
|
||||||
|
- 매장 방문 고객 가산점 적용
|
||||||
|
- 당첨자 조회
|
||||||
|
|
||||||
|
**데이터 저장**:
|
||||||
|
- Participation DB: participants, winners 테이블
|
||||||
|
|
||||||
|
#### Analytics Service
|
||||||
|
**핵심 책임**:
|
||||||
|
- 실시간 성과 대시보드 조회
|
||||||
|
- 채널별 성과 분석 및 통계
|
||||||
|
- ROI 계산 및 성과 집계
|
||||||
|
|
||||||
|
**관련 유저스토리**: UFR-ANAL-010
|
||||||
|
|
||||||
|
**Kafka 이벤트 구독**:
|
||||||
|
- **EventCreated**: 이벤트 기본 정보 초기화
|
||||||
|
- **ParticipantRegistered**: 참여자 수 실시간 증가
|
||||||
|
- **DistributionCompleted**: 배포 완료 통계 업데이트
|
||||||
|
|
||||||
|
**Resilience 패턴**:
|
||||||
|
- **Circuit Breaker**: 외부 채널 API 조회 시 (실패율 50% 초과 시 Open)
|
||||||
|
- **Fallback**: 캐시된 이전 데이터 반환
|
||||||
|
- **Cache-Aside**: Redis 캐싱 (TTL 5분)
|
||||||
|
|
||||||
|
**데이터 통합**:
|
||||||
|
- Event Service: 이벤트 정보 조회 (DB 직접 또는 REST)
|
||||||
|
- Participation Service: 참여자/당첨자 데이터 조회
|
||||||
|
- 외부 APIs: 우리동네TV, 지니TV, SNS 통계 수집
|
||||||
|
|
||||||
|
**데이터 저장**:
|
||||||
|
- Analytics DB: event_stats, channel_stats
|
||||||
|
- Redis: 대시보드 데이터 (TTL 5분)
|
||||||
|
|
||||||
|
### 2.2 Async Services (비동기 처리)
|
||||||
|
|
||||||
|
#### AI Service
|
||||||
|
**핵심 책임**:
|
||||||
|
- 업종/지역/시즌 트렌드 분석
|
||||||
|
- 3가지 이벤트 기획안 자동 생성
|
||||||
|
- 예상 성과 계산
|
||||||
|
|
||||||
|
**관련 유저스토리**: UFR-AI-010
|
||||||
|
|
||||||
|
**Kafka Job 구독**:
|
||||||
|
- **ai 이벤트 생성**: AI 추천 작업 요청
|
||||||
|
|
||||||
|
**Resilience 패턴**:
|
||||||
|
- **Circuit Breaker**: AI API 호출 시 (실패율 50% 초과 시 Open)
|
||||||
|
- **Timeout**: 5분 (300초)
|
||||||
|
- **Fallback**: 캐시된 이전 추천 결과 + 안내 메시지
|
||||||
|
- **Cache-Aside**: Redis 캐싱 (TTL 24시간)
|
||||||
|
|
||||||
|
**처리 시간**:
|
||||||
|
- 캐시 HIT: 0.1초
|
||||||
|
- 캐시 MISS: 5분 이내 (비동기 처리)
|
||||||
|
|
||||||
|
**데이터 저장**:
|
||||||
|
- Redis: AI 추천 결과 (TTL 24시간)
|
||||||
|
- Redis: Job 상태 정보 (TTL 1시간)
|
||||||
|
|
||||||
|
#### Content Service
|
||||||
|
**핵심 책임**:
|
||||||
|
- 3가지 스타일 SNS 이미지 자동 생성
|
||||||
|
- 플랫폼별 이미지 최적화
|
||||||
|
- 이미지 편집 기능
|
||||||
|
|
||||||
|
**관련 유저스토리**: UFR-CONT-010, 020
|
||||||
|
|
||||||
|
**데이터 읽기**:
|
||||||
|
- Redis에서 AI Service가 저장한 이벤트 데이터 읽기
|
||||||
|
|
||||||
|
**Resilience 패턴**:
|
||||||
|
- **Circuit Breaker**: 이미지 생성 API 호출 시 (실패율 50% 초과 시 Open)
|
||||||
|
- **Timeout**: 5분 (300초)
|
||||||
|
- **Fallback**: 기본 템플릿 이미지 제공
|
||||||
|
- **Cache-Aside**: Redis 캐싱 (TTL 7일)
|
||||||
|
|
||||||
|
**처리 시간**:
|
||||||
|
- 캐시 HIT: 0.1초
|
||||||
|
- 캐시 MISS: 5분 이내 (비동기 처리)
|
||||||
|
|
||||||
|
**데이터 저장**:
|
||||||
|
- Redis: 이미지 생성 결과 (CDN URL, TTL 7일)
|
||||||
|
- CDN: 생성된 이미지 파일
|
||||||
|
|
||||||
|
#### Distribution Service
|
||||||
|
**핵심 책임**:
|
||||||
|
- 다중 채널 병렬 배포 (동기)
|
||||||
|
- 배포 상태 모니터링
|
||||||
|
- Kafka Event 발행 (DistributionCompleted)
|
||||||
|
|
||||||
|
**관련 유저스토리**: UFR-DIST-010, 020
|
||||||
|
|
||||||
|
**주요 API**:
|
||||||
|
- `POST /api/distribution/distribute`: 다중 채널 배포 요청 (Event Service에서 호출)
|
||||||
|
|
||||||
|
**Kafka 이벤트 발행**:
|
||||||
|
- **DistributionCompleted**: 배포 완료 시
|
||||||
|
- Payload: eventId, distributedChannels, completedAt
|
||||||
|
- 구독자: Analytics Service
|
||||||
|
|
||||||
|
**Resilience 패턴**:
|
||||||
|
- **Circuit Breaker**: 각 외부 채널 API별 독립 적용 (실패율 50% 초과 시 Open)
|
||||||
|
- **Retry**: 최대 3회 재시도 (지수 백오프: 1초, 2초, 4초)
|
||||||
|
- **Bulkhead**: 채널별 스레드 풀 격리 (장애 전파 방지)
|
||||||
|
- **Fallback**: 실패 채널 스킵 + 알림
|
||||||
|
|
||||||
|
**처리 시간**: 1분 이내 (모든 채널 배포 완료)
|
||||||
|
|
||||||
|
**배포 채널**:
|
||||||
|
- 우리동네TV API (영상 업로드)
|
||||||
|
- 링고비즈 API (연결음 업데이트)
|
||||||
|
- 지니TV API (TV 광고 등록)
|
||||||
|
- SNS APIs (Instagram, Naver, Kakao 자동 포스팅)
|
||||||
|
|
||||||
|
**데이터 저장**:
|
||||||
|
- Event DB: distribution_logs 테이블
|
||||||
|
|
||||||
|
### 2.3 Kafka 통신 전략
|
||||||
|
|
||||||
|
#### Kafka 아키텍처
|
||||||
|
**기술 스택**: Apache Kafka (Event Topics + Job Topics 통합)
|
||||||
|
**보장 수준**: At-Least-Once Delivery
|
||||||
|
**메시지 포맷**: JSON
|
||||||
|
|
||||||
|
#### Event Topics (도메인 이벤트)
|
||||||
|
|
||||||
|
| 토픽명 | 발행자 | 구독자 | Payload | 용도 |
|
||||||
|
|---------|--------|--------|---------|------|
|
||||||
|
| **EventCreated** | Event Service | Analytics Service | eventId, storeId, title, objective, createdAt | 이벤트 생성 시 통계 초기화 |
|
||||||
|
| **ParticipantRegistered** | Participation Service | Analytics Service | participantId, eventId, phoneNumber, registeredAt | 참여자 등록 시 실시간 통계 업데이트 |
|
||||||
|
| **DistributionCompleted** | Distribution Service | Analytics Service | eventId, distributedChannels, completedAt | 배포 완료 시 통계 업데이트 |
|
||||||
|
|
||||||
|
#### Job Topics (비동기 작업)
|
||||||
|
|
||||||
|
| 토픽명 | 발행자 | 구독자 | Payload | 용도 |
|
||||||
|
|---------|--------|--------|---------|------|
|
||||||
|
| **ai 이벤트 생성** | Event Service | AI Service | eventId, objective, industry, region | AI 트렌드 분석 및 이벤트 추천 요청 |
|
||||||
|
| **이미지 생성** | Event Service | Content Service | eventId, content, style | SNS 이미지 생성 요청 (3가지 스타일) |
|
||||||
|
|
||||||
|
#### 통신 패턴별 설계
|
||||||
|
|
||||||
|
**1. Event Topics (도메인 이벤트)**
|
||||||
|
- **사용 시나리오**: 서비스 간 상태 변경 알림 및 동기화
|
||||||
|
- **통신 방식**: Kafka Pub/Sub
|
||||||
|
- **장점**:
|
||||||
|
- 서비스 독립성 보장
|
||||||
|
- 장애 격리
|
||||||
|
- 확장 용이
|
||||||
|
- **단점**:
|
||||||
|
- 최종 일관성 (Eventual Consistency)
|
||||||
|
- 디버깅 복잡도 증가
|
||||||
|
|
||||||
|
**2. Job Topics (비동기 작업)**
|
||||||
|
- **사용 시나리오**: 장시간 작업 (AI 추천, 이미지 생성)
|
||||||
|
- **통신 방식**: Kafka 메시지 큐
|
||||||
|
- **패턴**: Asynchronous Request-Reply
|
||||||
|
- **처리 플로우**:
|
||||||
|
1. Event Service → Kafka Job Topic: Job 발행
|
||||||
|
2. Async Service → Kafka: Job 수신 및 처리
|
||||||
|
3. Client → Event Service: Job 상태 폴링 (5초 간격)
|
||||||
|
4. Async Service → Redis: 결과 캐싱
|
||||||
|
5. Event Service → Client: 완료 응답
|
||||||
|
|
||||||
|
**3. 서비스 간 동기 호출**
|
||||||
|
- **사용 시나리오**: 다중 채널 배포 (Distribution Service)
|
||||||
|
- **통신 방식**: REST API (HTTP/JSON)
|
||||||
|
- **패턴**: Synchronous Request-Reply
|
||||||
|
- **처리 플로우**:
|
||||||
|
1. Event Service → Distribution Service: POST /api/distribution/distribute
|
||||||
|
2. Distribution Service: 다중 채널 병렬 배포 (1분 이내)
|
||||||
|
3. Distribution Service → Event Service: 배포 완료 응답
|
||||||
|
4. Distribution Service → Kafka: DistributionCompleted 이벤트 발행
|
||||||
|
- **Resilience**: Circuit Breaker 적용 (실패율 50% 초과 시 Open)
|
||||||
|
|
||||||
|
**4. 데이터베이스 직접 조회**
|
||||||
|
- **사용 시나리오**: Analytics Service가 이벤트/참여 데이터 필요 시
|
||||||
|
- **패턴**: Database-per-Service 원칙 유지, 필요 시 이벤트로 데이터 동기화
|
||||||
|
- **통신 방식**: Kafka 이벤트 구독 → Analytics DB 저장 → 로컬 조회
|
||||||
|
- **특징**: 서비스 간 직접 API 호출 최소화
|
||||||
|
|
||||||
|
#### Cache-Aside 전략
|
||||||
|
|
||||||
|
| 서비스 | 캐시 키 패턴 | TTL | 히트율 목표 | 효과 |
|
||||||
|
|--------|-------------|-----|-----------|------|
|
||||||
|
| AI Service | `ai:recommendation:{업종}:{지역}:{목적}` | 24시간 | 80% | 10초 → 0.1초 (99% 개선) |
|
||||||
|
| Content Service | `content:image:{이벤트ID}:{스타일}` | 7일 | 80% | 5초 → 0.1초 (98% 개선) |
|
||||||
|
| User Service | `user:business:{사업자번호}` | 7일 | 90% | - |
|
||||||
|
| Analytics Query | `analytics:dashboard:{이벤트ID}` | 5분 | 95% | 3초 → 0.5초 (83% 개선) |
|
||||||
|
|
||||||
|
#### Resilience 패턴 적용
|
||||||
|
|
||||||
|
**1. Circuit Breaker 패턴**
|
||||||
|
- **적용 대상**: 모든 외부 API 호출
|
||||||
|
- **라이브러리**: Resilience4j 또는 Hystrix
|
||||||
|
- **설정**:
|
||||||
|
```yaml
|
||||||
|
circuit-breaker:
|
||||||
|
failure-rate-threshold: 50% # 실패율 50% 초과 시 Open
|
||||||
|
slow-call-rate-threshold: 50% # 느린 호출 50% 초과 시 Open
|
||||||
|
slow-call-duration-threshold: 5s # 5초 초과 시 느린 호출로 간주
|
||||||
|
wait-duration-in-open-state: 30s # Open 상태 30초 유지 후 Half-Open
|
||||||
|
permitted-calls-in-half-open: 3 # Half-Open 상태에서 3개 요청 테스트
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Retry 패턴**
|
||||||
|
- **적용 대상**: 일시적 장애가 예상되는 외부 API
|
||||||
|
- **재시도 전략**: 지수 백오프 (Exponential Backoff)
|
||||||
|
- **설정**:
|
||||||
|
```yaml
|
||||||
|
retry:
|
||||||
|
max-attempts: 3 # 최대 3회 재시도
|
||||||
|
wait-duration: 1s # 초기 대기 시간 1초
|
||||||
|
exponential-backoff-multiplier: 2 # 2배씩 증가 (1초, 2초, 4초)
|
||||||
|
retry-exceptions:
|
||||||
|
- java.net.SocketTimeoutException
|
||||||
|
- java.net.ConnectException
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Timeout 패턴**
|
||||||
|
- **적용 대상**: 모든 외부 API 호출
|
||||||
|
- **설정**:
|
||||||
|
| 서비스 | Timeout | 이유 |
|
||||||
|
|--------|---------|------|
|
||||||
|
| AI Service (AI API) | 5분 (300초) | 복잡한 분석 작업 |
|
||||||
|
| Content Service (이미지 API) | 5분 (300초) | 이미지 생성 시간 고려 |
|
||||||
|
| Distribution Service (채널 APIs) | 10초 | 빠른 배포 필요 |
|
||||||
|
|
||||||
|
**4. Bulkhead 패턴**
|
||||||
|
- **적용 대상**: Distribution Service (다중 채널 배포)
|
||||||
|
- **목적**: 채널별 리소스 격리로 장애 전파 차단
|
||||||
|
- **설정**:
|
||||||
|
```yaml
|
||||||
|
bulkhead:
|
||||||
|
max-concurrent-calls: 10 # 채널당 최대 10개 동시 호출
|
||||||
|
max-wait-duration: 0s # 대기 없이 즉시 실패
|
||||||
|
```
|
||||||
|
|
||||||
|
**5. Fallback 패턴**
|
||||||
|
- **적용 대상**: 모든 외부 API 호출
|
||||||
|
- **전략**:
|
||||||
|
| 서비스 | Fallback 전략 |
|
||||||
|
|--------|---------------|
|
||||||
|
| AI Service | 캐시된 이전 추천 결과 + 안내 메시지 |
|
||||||
|
| Content Service | 기본 템플릿 이미지 제공 |
|
||||||
|
| Distribution Service | 실패 채널 스킵 + 알림 |
|
||||||
|
| Analytics Service | 캐시된 이전 데이터 반환 |
|
||||||
|
|
||||||
|
#### 이벤트 순서 보장
|
||||||
|
- **Kafka Partition Key**: eventId 기준으로 파티션 할당
|
||||||
|
- **동일 이벤트의 모든 이벤트**: 동일 파티션 → 순서 보장
|
||||||
|
- **다른 이벤트**: 독립적 처리 → 병렬 처리 가능
|
||||||
|
|
||||||
|
#### 이벤트 재처리 (At-Least-Once)
|
||||||
|
- **멱등성 보장**: 구독자는 동일 이벤트 중복 처리 시 멱등성 유지
|
||||||
|
- **방법**: 이벤트 ID 기반 중복 체크 (Redis Set 사용)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 주요 사용자 플로우
|
||||||
|
|
||||||
|
### 3.1 이벤트 생성 플로우 (Event-Driven + Kafka)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. [이벤트 목적 선택]
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Client → Event Service │
|
||||||
|
│ - POST /api/events (목적, 매장 정보) │
|
||||||
|
│ - Event DB에 저장 │
|
||||||
|
│ - EventCreated 이벤트 발행 → Kafka │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Kafka → Analytics Service │
|
||||||
|
│ - EventCreated 이벤트 구독 │
|
||||||
|
│ - Analytics DB에 기본 통계 초기화 │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
2. [AI 이벤트 추천]
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Client → Event Service │
|
||||||
|
│ - POST /api/events/{id}/ai-recommendations │
|
||||||
|
│ - Kafka ai 이벤트 생성 토픽 발행 (AI 작업 요청) │
|
||||||
|
│ - Job ID 즉시 반환 (0.1초) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ AI Service (Background) │
|
||||||
|
│ - Kafka ai 이벤트 생성 토픽 구독 │
|
||||||
|
│ - Redis 캐시 확인 (Cache-Aside) │
|
||||||
|
│ - 캐시 MISS: Claude API 호출 (5분) [Circuit Breaker] │
|
||||||
|
│ - AI 추천 결과를 Redis에 저장 (TTL 24시간) │
|
||||||
|
│ - Job 상태 완료로 업데이트 │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Client (Polling) │
|
||||||
|
│ - GET /api/jobs/{id} (5초 간격) │
|
||||||
|
│ - 완료 시: AI 추천 결과 반환 (3가지 옵션) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
3. [SNS 이미지 생성]
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Content Service (Background) │
|
||||||
|
│ - Redis에서 AI Service가 저장한 이벤트 데이터 읽기 │
|
||||||
|
│ - Redis 캐시 확인 (이미지 생성 여부) │
|
||||||
|
│ - 캐시 MISS: Stable Diffusion API (5분) [Circuit Breaker] │
|
||||||
|
│ - 이미지 CDN 업로드 │
|
||||||
|
│ - 생성된 이미지 URL을 Redis에 저장 (TTL 7일) │
|
||||||
|
│ - Job 상태 완료로 업데이트 │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Client (Polling) │
|
||||||
|
│ - GET /api/jobs/{id} (3초 간격) │
|
||||||
|
│ - 완료 시: 3가지 스타일 이미지 URL 반환 │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
4. [최종 승인 및 배포]
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Client → Event Service │
|
||||||
|
│ - POST /api/events/{id}/publish │
|
||||||
|
│ - Redis의 이벤트 관련 정보(AI 추천, 이미지 URL)를 조회 │
|
||||||
|
│ - Event DB에 이벤트 정보 저장 │
|
||||||
|
│ - Event 상태 변경 (DRAFT → PUBLISHED) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Event Service → Distribution Service │
|
||||||
|
│ - POST /api/distribution/distribute (동기 호출) │
|
||||||
|
│ - 다중 채널 병렬 배포 [Circuit Breaker + Bulkhead] │
|
||||||
|
│ * 우리동네TV API (영상 업로드) [Retry: 3회] │
|
||||||
|
│ * 링고비즈 API (연결음 업데이트) [Retry: 3회] │
|
||||||
|
│ * 지니TV API (광고 등록) [Retry: 3회] │
|
||||||
|
│ * SNS APIs (Instagram, Naver, Kakao) [Retry: 3회] │
|
||||||
|
│ - 배포 완료: DistributionCompleted 이벤트 발행 → Kafka │
|
||||||
|
│ - Event Service로 배포 완료 응답 (1분 이내) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Kafka → Analytics Service │
|
||||||
|
│ - DistributionCompleted 이벤트 구독 │
|
||||||
|
│ - Analytics DB 배포 통계 업데이트 │
|
||||||
|
│ - 대시보드 캐시 무효화 (다음 조회 시 갱신) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Event Service → Client │
|
||||||
|
│ - 배포 완료 응답 반환 │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 고객 참여 플로우 (Event-Driven)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. [이벤트 참여]
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Client → Participation Service │
|
||||||
|
│ - POST /api/events/{id}/participate │
|
||||||
|
│ - 중복 참여 체크 (전화번호 기반) │
|
||||||
|
│ - Participation DB에 저장 │
|
||||||
|
│ - ParticipantRegistered 이벤트 발행 → Kafka │
|
||||||
|
│ - 응모 번호 즉시 반환 │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Kafka → Analytics Service │
|
||||||
|
│ - ParticipantRegistered 이벤트 구독 │
|
||||||
|
│ - 실시간 참여자 수 증가 │
|
||||||
|
│ - Analytics DB에 참여 통계 업데이트 │
|
||||||
|
│ - 대시보드 캐시 무효화 │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
2. [당첨자 추첨]
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Client → Participation Service │
|
||||||
|
│ - POST /api/events/{id}/draw-winners │
|
||||||
|
│ - 난수 기반 무작위 추첨 │
|
||||||
|
│ - Winners DB에 저장 │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 성과 분석 플로우 (Event-Driven)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. [실시간 대시보드 조회]
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Client → Analytics Service │
|
||||||
|
│ - GET /api/events/{id}/analytics │
|
||||||
|
│ - Redis 캐시 확인 (TTL 5분) │
|
||||||
|
│ * 캐시 HIT: 즉시 반환 (0.5초) │
|
||||||
|
│ * 캐시 MISS: 아래 데이터 통합 │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Analytics Service (데이터 통합) │
|
||||||
|
│ - Analytics DB: 이벤트/참여 통계 조회 (로컬 DB) │
|
||||||
|
│ - 외부 APIs: 채널별 노출/클릭 수 [Circuit Breaker + Fallback] │
|
||||||
|
│ * 우리동네TV API (조회수) │
|
||||||
|
│ * 지니TV API (광고 노출 수) │
|
||||||
|
│ * SNS APIs (좋아요, 댓글, 공유 수) │
|
||||||
|
│ - Redis 캐싱 (TTL 5분) │
|
||||||
|
│ - 대시보드 데이터 반환 │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
2. [실시간 업데이트 (Event 구독)]
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Analytics Service (Background) │
|
||||||
|
│ - EventCreated 구독: 이벤트 기본 정보 초기화 │
|
||||||
|
│ - ParticipantRegistered 구독: 참여자 수 실시간 증가 │
|
||||||
|
│ - DistributionCompleted 구독: 배포 채널 통계 업데이트 │
|
||||||
|
│ - 캐시 무효화: 다음 조회 시 최신 데이터 갱신 │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 플로우 특징
|
||||||
|
|
||||||
|
#### Kafka 통합 이점
|
||||||
|
- **단일 메시징 플랫폼**: Event Bus와 Job Queue를 Kafka로 통합, 운영 복잡도 감소
|
||||||
|
- **일관된 메시지 처리**: 모든 비동기 통신이 Kafka를 통해 이루어져 모니터링 및 디버깅 용이
|
||||||
|
- **확장성**: Kafka의 높은 처리량으로 대규모 이벤트 처리 지원
|
||||||
|
|
||||||
|
#### Event-Driven 이점
|
||||||
|
- **느슨한 결합**: 서비스 간 직접 의존성 제거
|
||||||
|
- **장애 격리**: 한 서비스 장애가 다른 서비스에 영향 없음
|
||||||
|
- **확장 용이**: 새로운 구독자 추가로 기능 확장
|
||||||
|
- **비동기 처리**: 사용자 응답 시간 단축
|
||||||
|
|
||||||
|
#### Resilience 이점
|
||||||
|
- **Circuit Breaker**: 외부 API 장애 시 빠른 실패 및 복구
|
||||||
|
- **Retry**: 일시적 장애 자동 복구
|
||||||
|
- **Fallback**: 장애 시에도 서비스 지속 (Graceful Degradation)
|
||||||
|
- **Bulkhead**: 리소스 격리로 장애 전파 차단
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 데이터 흐름 및 캐싱 전략
|
||||||
|
|
||||||
|
### 4.1 데이터 흐름
|
||||||
|
|
||||||
|
#### 읽기 플로우 (Cache-Aside 패턴)
|
||||||
|
```
|
||||||
|
1. Application → Cache 확인
|
||||||
|
- Cache HIT: 캐시된 데이터 즉시 반환
|
||||||
|
- Cache MISS:
|
||||||
|
2. Application → Database/External API 조회
|
||||||
|
3. Database/External API → Application 데이터 반환
|
||||||
|
4. Application → Cache 데이터 저장 (TTL 설정)
|
||||||
|
5. Application → Client 데이터 반환
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 쓰기 플로우 (Write-Through 패턴)
|
||||||
|
```
|
||||||
|
1. Application → Database 쓰기
|
||||||
|
2. Database → Application 성공 응답
|
||||||
|
3. Application → Cache 무효화 또는 업데이트
|
||||||
|
4. Application → Client 성공 응답
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 캐싱 전략
|
||||||
|
|
||||||
|
#### Redis 캐시 구조
|
||||||
|
|
||||||
|
| 서비스 | 캐시 키 패턴 | 데이터 타입 | TTL | 예상 크기 | 히트율 목표 |
|
||||||
|
|--------|-------------|-----------|-----|----------|-----------|
|
||||||
|
| User | `user:session:{token}` | String | 7일 | 1KB | - |
|
||||||
|
| AI | `ai:recommendation:{업종}:{지역}:{목적}` | Hash | 24시간 | 10KB | 80% |
|
||||||
|
| AI | `ai:event:{이벤트ID}` | Hash | 24시간 | 10KB | - |
|
||||||
|
| Content | `content:image:{이벤트ID}:{스타일}` | String | 7일 | 0.2KB (URL) | 80% |
|
||||||
|
| Analytics | `analytics:dashboard:{이벤트ID}` | Hash | 5분 | 5KB | 95% |
|
||||||
|
| AI | `job:{jobId}` | Hash | 1시간 | 1KB | - |
|
||||||
|
| Content | `job:{jobId}` | Hash | 1시간 | 1KB | - |
|
||||||
|
|
||||||
|
#### Redis 메모리 산정
|
||||||
|
- **예상 동시 사용자**: 100명
|
||||||
|
- **예상 이벤트 수**: 50개
|
||||||
|
- **예상 캐시 항목 수**: 10,000개
|
||||||
|
- **예상 총 메모리**: 약 50MB (운영 환경 2GB 할당)
|
||||||
|
|
||||||
|
#### 캐시 무효화 전략
|
||||||
|
- **TTL 기반 자동 만료**: 대부분의 캐시
|
||||||
|
- **수동 무효화**: 이벤트 수정/삭제 시 관련 캐시 삭제
|
||||||
|
- **Lazy 무효화**: 데이터 변경 시 다음 조회 시점에 갱신
|
||||||
|
|
||||||
|
### 4.3 데이터베이스 전략
|
||||||
|
|
||||||
|
#### 서비스별 독립 데이터베이스
|
||||||
|
- **User DB**: users, stores
|
||||||
|
- **Event DB**: events, event_objectives, event_prizes, distribution_logs
|
||||||
|
- **Participation DB**: participants, winners
|
||||||
|
- **Analytics DB**: event_stats, channel_stats
|
||||||
|
|
||||||
|
#### 데이터 일관성 전략
|
||||||
|
- **Eventual Consistency**: 서비스 간 데이터는 최종 일관성 보장
|
||||||
|
- **Strong Consistency**: 서비스 내부 트랜잭션은 강한 일관성 보장
|
||||||
|
- **Saga 패턴**: 이벤트 생성 플로우 (보상 트랜잭션)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 확장성 및 성능 고려사항
|
||||||
|
|
||||||
|
### 5.1 수평 확장 전략
|
||||||
|
|
||||||
|
#### 서비스별 확장 전략
|
||||||
|
| 서비스 | 초기 인스턴스 | 확장 조건 | 최대 인스턴스 | Auto-scaling 메트릭 |
|
||||||
|
|--------|-------------|----------|-------------|-------------------|
|
||||||
|
| User | 2 | CPU > 70% | 5 | CPU, 메모리 |
|
||||||
|
| Event | 2 | CPU > 70% | 10 | CPU, 메모리 |
|
||||||
|
| AI | 1 | Job Queue > 10 | 3 | Queue 길이 |
|
||||||
|
| Content | 1 | Job Queue > 10 | 3 | Queue 길이 |
|
||||||
|
| Distribution | 2 | CPU > 70% | 5 | CPU, 메모리 |
|
||||||
|
| Participation | 1 | CPU > 70% | 3 | CPU, 메모리 |
|
||||||
|
| Analytics | 1 | CPU > 70% | 3 | CPU, 메모리 |
|
||||||
|
|
||||||
|
#### Redis Cluster
|
||||||
|
- **초기 구성**: 3 노드 (Master 3, Replica 3)
|
||||||
|
- **확장**: 노드 추가를 통한 수평 확장
|
||||||
|
- **HA**: Redis Sentinel을 통한 자동 Failover
|
||||||
|
|
||||||
|
#### Database Replication
|
||||||
|
- **Primary-Replica 구조**: 읽기 부하 분산
|
||||||
|
- **읽기 확장**: Read Replica 추가 (필요 시)
|
||||||
|
- **쓰기 확장**: Sharding (Phase 2 이후)
|
||||||
|
|
||||||
|
### 5.2 성능 목표
|
||||||
|
|
||||||
|
#### 응답 시간 목표
|
||||||
|
| 기능 | 목표 시간 | 캐시 HIT | 캐시 MISS |
|
||||||
|
|------|----------|---------|----------|
|
||||||
|
| 로그인 | 0.5초 | - | - |
|
||||||
|
| 이벤트 목록 조회 | 0.3초 | - | - |
|
||||||
|
| AI 트렌드 분석 + 추천 | 0.1초 | ✅ | 10초 (비동기) |
|
||||||
|
| SNS 이미지 생성 | 0.1초 | ✅ | 5초 (비동기) |
|
||||||
|
| 다중 채널 배포 | 1분 | - | - |
|
||||||
|
| 대시보드 로딩 | 0.5초 | ✅ | 3초 |
|
||||||
|
|
||||||
|
#### 처리량 목표
|
||||||
|
- **동시 사용자**: 100명 (MVP 목표)
|
||||||
|
- **API 요청**: 1,000 req/min
|
||||||
|
- **AI 작업**: 10 jobs/min
|
||||||
|
- **이미지 생성**: 10 jobs/min
|
||||||
|
|
||||||
|
### 5.3 성능 최적화 기법
|
||||||
|
|
||||||
|
#### Frontend 최적화
|
||||||
|
- **Code Splitting**: 페이지별 번들 분할
|
||||||
|
- **Lazy Loading**: 차트 라이브러리 지연 로딩
|
||||||
|
- **CDN**: 정적 자산 CDN 배포
|
||||||
|
- **Compression**: Gzip/Brotli 압축
|
||||||
|
|
||||||
|
#### Backend 최적화
|
||||||
|
- **Connection Pooling**: 데이터베이스 연결 풀 관리
|
||||||
|
- **Query Optimization**: 인덱스 최적화, N+1 쿼리 방지
|
||||||
|
- **Batch Processing**: 대량 데이터 일괄 처리
|
||||||
|
- **Pagination**: 목록 조회 페이지네이션
|
||||||
|
|
||||||
|
#### Cache 최적화
|
||||||
|
- **Multi-Level Caching**: Browser Cache → CDN → Redis → Database
|
||||||
|
- **Cache Warming**: 자주 사용되는 데이터 사전 로딩
|
||||||
|
- **Cache Preloading**: 피크 시간 전 캐시 준비
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 보안 고려사항
|
||||||
|
|
||||||
|
### 6.1 인증 및 인가
|
||||||
|
|
||||||
|
#### JWT 기반 인증
|
||||||
|
- **토큰 발급**: User Service에서 로그인 시 JWT 토큰 발급
|
||||||
|
- **토큰 검증**: API Gateway에서 모든 요청의 JWT 토큰 검증
|
||||||
|
- **토큰 저장**: Redis에 세션 정보 저장 (TTL 7일)
|
||||||
|
- **토큰 갱신**: Refresh Token 패턴 (선택)
|
||||||
|
|
||||||
|
#### 역할 기반 접근 제어 (RBAC)
|
||||||
|
- **역할**: OWNER (매장 사장님), CUSTOMER (이벤트 참여자)
|
||||||
|
- **권한 관리**: API별 필요 역할 정의
|
||||||
|
- **API Gateway 검증**: 요청자의 역할 확인
|
||||||
|
|
||||||
|
### 6.2 데이터 보안
|
||||||
|
|
||||||
|
#### 민감 정보 암호화
|
||||||
|
- **비밀번호**: bcrypt 해싱 (Cost Factor: 10)
|
||||||
|
- **사업자번호**: AES-256 암호화 저장
|
||||||
|
- **개인정보**: 전화번호 마스킹 (010-****-1234)
|
||||||
|
|
||||||
|
#### 전송 보안
|
||||||
|
- **HTTPS**: 모든 통신 TLS 1.3 암호화
|
||||||
|
- **API Key**: 외부 API 호출 시 안전한 Key 관리 (AWS Secrets Manager)
|
||||||
|
|
||||||
|
#### 데이터 접근 통제
|
||||||
|
- **Database**: 서비스별 독립 계정, 최소 권한 원칙
|
||||||
|
- **Redis**: 비밀번호 설정, ACL 적용
|
||||||
|
- **백업**: 암호화된 백업 저장
|
||||||
|
|
||||||
|
### 6.3 보안 모니터링
|
||||||
|
|
||||||
|
#### 위협 탐지
|
||||||
|
- **Rate Limiting**: API Gateway에서 사용자당 100 req/min
|
||||||
|
- **Brute Force 방지**: 로그인 5회 실패 시 계정 잠금 (삭제됨, 향후 추가 가능)
|
||||||
|
- **SQL Injection 방지**: Prepared Statement 사용
|
||||||
|
- **XSS 방지**: 입력 데이터 Sanitization
|
||||||
|
|
||||||
|
#### 로깅 및 감사
|
||||||
|
- **Access Log**: 모든 API 요청 로깅
|
||||||
|
- **Audit Log**: 민감 작업 (로그인, 이벤트 생성, 당첨자 추첨) 감사 로그
|
||||||
|
- **중앙집중식 로깅**: ELK Stack 또는 CloudWatch Logs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 논리 아키텍처 다이어그램
|
||||||
|
|
||||||
|
논리 아키텍처 다이어그램은 별도 Mermaid 파일로 작성되었습니다.
|
||||||
|
|
||||||
|
**파일 위치**: `logical-architecture.mmd`
|
||||||
|
|
||||||
|
**다이어그램 확인 방법**:
|
||||||
|
1. https://mermaid.live/edit 접속
|
||||||
|
2. `logical-architecture.mmd` 파일 내용 붙여넣기
|
||||||
|
3. 다이어그램 시각적 확인
|
||||||
|
|
||||||
|
**다이어그램 구성**:
|
||||||
|
- Services: 4개 핵심 서비스 (User, Event, Participation, Analytics)
|
||||||
|
- Async Services: 3개 비동기 서비스 (AI, Content, Distribution)
|
||||||
|
- Kafka: Event Topics + Job Topics 통합 메시징 플랫폼
|
||||||
|
- External System: 통합된 외부 시스템 (국세청 API, AI API, 이미지 생성 API, 배포 채널 APIs)
|
||||||
|
|
||||||
|
**의존성 표현**:
|
||||||
|
- 굵은 화살표 (==>): Kafka Event Topics 발행
|
||||||
|
- 실선 화살표 (-->): Kafka Job Topics 발행 또는 외부 시스템 호출
|
||||||
|
- 점선 화살표 (-.->): Kafka 구독
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 부록
|
||||||
|
|
||||||
|
### A. 참고 문서
|
||||||
|
- [유저스토리](../../userstory.md)
|
||||||
|
- [아키텍처 패턴](../../pattern/architecture-pattern.md)
|
||||||
|
- [UI/UX 설계서](../../uiux/uiux.md)
|
||||||
|
- [클라우드 디자인 패턴](../../../claude/cloud-design-patterns.md)
|
||||||
|
|
||||||
|
### B. 주요 결정사항
|
||||||
|
1. **Kafka 통합 메시징 플랫폼 채택**: Event Bus와 Job Queue를 Kafka로 통합하여 운영 복잡도 감소
|
||||||
|
2. **Event-Driven 아키텍처 채택**: Kafka를 통한 서비스 간 느슨한 결합 및 비동기 통신
|
||||||
|
3. **도메인 이벤트 정의**: 3개 Event Topics (EventCreated, ParticipantRegistered, DistributionCompleted)
|
||||||
|
4. **Job Topics 정의**: 2개 Job Topics (ai 이벤트 생성, 이미지 생성)로 장시간 비동기 작업 처리
|
||||||
|
5. **Resilience 패턴 전면 적용**: Circuit Breaker, Retry, Timeout, Bulkhead, Fallback
|
||||||
|
6. **At-Least-Once Delivery**: Kafka 메시지 보장 및 멱등성 설계
|
||||||
|
7. **Cache-Aside 패턴**: AI/이미지 생성 결과 캐싱으로 응답 시간 90% 개선
|
||||||
|
8. **Redis 기반 서비스 간 데이터 공유**: AI Service → Redis → Content Service 데이터 흐름
|
||||||
|
9. **Redis to DB 영구 저장**: 이벤트 생성 완료 시 Redis 데이터를 Event DB에 저장
|
||||||
|
10. **동기 배포**: Event Service가 Distribution Service를 REST API로 직접 호출하여 다중 채널 배포 동기 처리
|
||||||
|
11. **서비스별 독립 Database**: Database-per-Service 패턴으로 서비스 독립성 보장
|
||||||
|
12. **장시간 작업 Timeout 조정**: AI/Content Service Timeout을 5분으로 설정하여 복잡한 생성 작업 지원
|
||||||
|
|
||||||
|
### C. 향후 개선 방안 (Phase 2 이후)
|
||||||
|
1. **Event Sourcing 완전 적용**: 모든 상태 변경을 이벤트로 저장하여 시간 여행 및 감사 추적 강화
|
||||||
|
2. **Saga 패턴 적용**: 복잡한 분산 트랜잭션 보상 로직 체계화
|
||||||
|
3. **Service Mesh 도입**: Istio를 통한 서비스 간 통신 관찰성 및 보안 강화
|
||||||
|
4. **Database Sharding**: Event/Participation Write DB 샤딩으로 쓰기 확장성 개선
|
||||||
|
5. **WebSocket 기반 실시간 푸시**: 대시보드 실시간 업데이트 (폴링 대체)
|
||||||
|
6. **GraphQL API Gateway**: 클라이언트 맞춤형 데이터 조회 최적화
|
||||||
|
7. **Dead Letter Queue 고도화**: 실패 이벤트 재처리 및 알림 자동화
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**문서 버전**: 2.2
|
||||||
|
**최종 수정일**: 2025-10-22
|
||||||
|
**작성자**: System Architect
|
||||||
|
**변경 사항**: Distribution Service 동기 호출 전환 (REST API 직접 호출)
|
||||||
71
design/backend/logical/logical-architecture.mmd
Normal file
71
design/backend/logical/logical-architecture.mmd
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
graph TB
|
||||||
|
%% KT AI 기반 소상공인 이벤트 자동 생성 서비스 - 논리 아키텍처 (Event-Driven + Kafka)
|
||||||
|
|
||||||
|
%% Services
|
||||||
|
subgraph "Services"
|
||||||
|
UserSvc["User Service<br/>• 회원가입/로그인<br/>• 프로필 관리<br/>• 회원정보 제공"]
|
||||||
|
EventSvc["Event Service<br/>• 이벤트 생성/수정/삭제<br/>• 플로우 오케스트레이션<br/>• AI 작업 요청<br/>• 배포 작업 요청<br/>• Redis → DB 저장"]
|
||||||
|
PartSvc["Participation<br/>Service<br/>• 참여 접수<br/>• 참여자 목록<br/>• 당첨자 추첨"]
|
||||||
|
AnalSvc["Analytics Service<br/>• 실시간 대시보드<br/>• 성과 분석<br/>• 채널별 통계<br/>[Circuit Breaker]"]
|
||||||
|
end
|
||||||
|
|
||||||
|
%% Async Services
|
||||||
|
subgraph "Async Services"
|
||||||
|
AISvc["AI Service<br/>• 트렌드 분석<br/>• 이벤트 추천<br/>• Redis 저장<br/>[Circuit Breaker]<br/>[Timeout: 5분]"]
|
||||||
|
ContentSvc["Content Service<br/>• Redis 데이터 읽기<br/>• SNS 이미지 생성<br/>• Redis 저장<br/>[Circuit Breaker]<br/>[Timeout: 5분]"]
|
||||||
|
DistSvc["Distribution<br/>Service<br/>• 다중 채널 배포<br/>[Circuit Breaker]<br/>[Retry: 3회]<br/>[Bulkhead]"]
|
||||||
|
end
|
||||||
|
|
||||||
|
%% Kafka (Event Bus + Job Queue)
|
||||||
|
Kafka["Kafka<br/>━━━━━━━━━━<br/><Event Topics><br/>• EventCreated<br/>• ParticipantRegistered<br/>• DistributionCompleted<br/>━━━━━━━━━━<br/><Job Topics><br/>• ai 이벤트 생성"]
|
||||||
|
|
||||||
|
%% External System
|
||||||
|
External["외부시스템<br/>[Circuit Breaker]<br/>━━━━━━━━━━<br/>• AI API<br/>• 이미지 생성 API<br/>• 배포 채널 APIs<br/>(비동기)"]
|
||||||
|
|
||||||
|
%% Redis
|
||||||
|
Redis["Redis Cache<br/>━━━━━━━━━━<br/>• AI 결과<br/>• 이미지 URL<br/>• 이벤트 데이터"]
|
||||||
|
|
||||||
|
%% Event Publishing
|
||||||
|
EventSvc ==>|"EventCreated<br/>발행"| Kafka
|
||||||
|
PartSvc ==>|"ParticipantRegistered<br/>발행"| Kafka
|
||||||
|
DistSvc ==>|"DistributionCompleted<br/>발행"| Kafka
|
||||||
|
|
||||||
|
%% Job Publishing (비동기 작업 요청)
|
||||||
|
EventSvc -->|"ai 이벤트 생성 발행"| Kafka
|
||||||
|
|
||||||
|
%% Event Subscription
|
||||||
|
Kafka -.->|"EventCreated<br/>구독"| AnalSvc
|
||||||
|
Kafka -.->|"ParticipantRegistered<br/>구독"| AnalSvc
|
||||||
|
Kafka -.->|"DistributionCompleted<br/>구독"| AnalSvc
|
||||||
|
|
||||||
|
%% Job Subscription
|
||||||
|
Kafka -.->|"ai 이벤트 생성 구독"| AISvc
|
||||||
|
|
||||||
|
%% Service to Service (동기 호출)
|
||||||
|
EventSvc -->|"다중 채널 배포<br/>[Circuit Breaker]"| DistSvc
|
||||||
|
EventSvc -->|"회원정보 조회"| UserSvc
|
||||||
|
|
||||||
|
%% Redis Interactions
|
||||||
|
AISvc -->|"AI 결과 저장"| Redis
|
||||||
|
ContentSvc -->|"AI 데이터 읽기"| Redis
|
||||||
|
ContentSvc -->|"이미지 URL 저장"| Redis
|
||||||
|
EventSvc -->|"Redis → DB 저장"| Redis
|
||||||
|
|
||||||
|
%% Services to External (Resilience 패턴)
|
||||||
|
AISvc -->|"트렌드 분석/추천"| External
|
||||||
|
ContentSvc -->|"이미지 생성"| External
|
||||||
|
DistSvc -->|"다중 채널 배포<br/>(비동기)"| External
|
||||||
|
AnalSvc -->|"채널별 통계<br/>[Fallback: Cache]"| External
|
||||||
|
|
||||||
|
%% Styling
|
||||||
|
classDef service fill:#4ECDC4,stroke:#14B8A6,stroke-width:3px
|
||||||
|
classDef async fill:#8B5CF6,stroke:#7C3AED,stroke-width:3px,color:#fff
|
||||||
|
classDef kafka fill:#F59E0B,stroke:#D97706,stroke-width:3px
|
||||||
|
classDef external fill:#E5E7EB,stroke:#9CA3AF,stroke-width:2px
|
||||||
|
classDef cache fill:#EF4444,stroke:#DC2626,stroke-width:3px
|
||||||
|
|
||||||
|
class UserSvc,EventSvc,PartSvc,AnalSvc service
|
||||||
|
class AISvc,ContentSvc,DistSvc async
|
||||||
|
class Kafka kafka
|
||||||
|
class External external
|
||||||
|
class Redis cache
|
||||||
393
design/backend/sequence/inner/README.md
Normal file
393
design/backend/sequence/inner/README.md
Normal file
@ -0,0 +1,393 @@
|
|||||||
|
# KT AI 기반 소상공인 이벤트 자동 생성 서비스 - 내부 시퀀스 설계서
|
||||||
|
|
||||||
|
## 문서 정보
|
||||||
|
- **작성일**: 2025-10-22
|
||||||
|
- **버전**: 1.0
|
||||||
|
- **작성자**: System Architect
|
||||||
|
- **관련 문서**:
|
||||||
|
- [유저스토리](../../userstory.md)
|
||||||
|
- [외부 시퀀스 설계서](../outer/)
|
||||||
|
- [논리 아키텍처](../../logical/logical-architecture.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 목차
|
||||||
|
1. [개요](#1-개요)
|
||||||
|
2. [서비스별 시나리오 목록](#2-서비스별-시나리오-목록)
|
||||||
|
3. [설계 원칙](#3-설계-원칙)
|
||||||
|
4. [주요 패턴](#4-주요-패턴)
|
||||||
|
5. [파일 구조](#5-파일-구조)
|
||||||
|
6. [PlantUML 다이어그램 확인 방법](#6-plantuml-다이어그램-확인-방법)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 개요
|
||||||
|
|
||||||
|
본 문서는 KT AI 기반 소상공인 이벤트 자동 생성 서비스의 **7개 마이크로서비스**에 대한 **26개 내부 시퀀스 다이어그램**을 포함합니다.
|
||||||
|
|
||||||
|
### 1.1 설계 범위
|
||||||
|
|
||||||
|
각 마이크로서비스 내부의 처리 흐름을 상세히 표현:
|
||||||
|
- **API 레이어**: Controller
|
||||||
|
- **비즈니스 레이어**: Service, Validator, Domain Logic
|
||||||
|
- **데이터 레이어**: Repository, Cache Manager
|
||||||
|
- **인프라 레이어**: Kafka, Redis, Database, External APIs
|
||||||
|
|
||||||
|
### 1.2 설계 대상 서비스
|
||||||
|
|
||||||
|
| 서비스 | 시나리오 수 | 주요 책임 |
|
||||||
|
|--------|------------|----------|
|
||||||
|
| **User** | 4 | 사용자 인증, 프로필 관리 |
|
||||||
|
| **Event** | 10 | 이벤트 생명주기 관리, 오케스트레이션 |
|
||||||
|
| **Participation** | 3 | 참여자 관리, 당첨자 추첨 |
|
||||||
|
| **Analytics** | 5 | 실시간 성과 분석, 대시보드 |
|
||||||
|
| **AI** | 1 | AI 트렌드 분석 및 이벤트 추천 |
|
||||||
|
| **Content** | 1 | SNS 이미지 생성 |
|
||||||
|
| **Distribution** | 2 | 다중 채널 배포 |
|
||||||
|
| **총계** | **26** | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 서비스별 시나리오 목록
|
||||||
|
|
||||||
|
### 2.1 User 서비스 (4개)
|
||||||
|
|
||||||
|
| 시나리오 | 파일명 | 유저스토리 | 주요 처리 내용 |
|
||||||
|
|----------|--------|-----------|---------------|
|
||||||
|
| 회원가입 | `user-회원가입.puml` | UFR-USER-010 | 사업자번호 검증(Circuit Breaker), 트랜잭션, JWT 발급 |
|
||||||
|
| 로그인 | `user-로그인.puml` | UFR-USER-020 | 비밀번호 검증(bcrypt), JWT 발급, 세션 저장 |
|
||||||
|
| 프로필수정 | `user-프로필수정.puml` | UFR-USER-030 | 기본/매장 정보 수정, 비밀번호 변경, 트랜잭션 |
|
||||||
|
| 로그아웃 | `user-로그아웃.puml` | UFR-USER-040 | JWT 검증, 세션 삭제, Blacklist 추가 |
|
||||||
|
|
||||||
|
**주요 특징**:
|
||||||
|
- **Resilience 패턴**: Circuit Breaker (국세청 API), Retry, Timeout, Fallback
|
||||||
|
- **보안**: bcrypt 해싱, AES-256 암호화, JWT 관리
|
||||||
|
- **캐싱**: 사업자번호 검증 결과 (TTL 7일), 세션 정보 (TTL 7일)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 Event 서비스 (10개)
|
||||||
|
|
||||||
|
| 시나리오 | 파일명 | 유저스토리 | 주요 처리 내용 |
|
||||||
|
|----------|--------|-----------|---------------|
|
||||||
|
| 목적선택 | `event-목적선택.puml` | UFR-EVENT-020 | 이벤트 목적 선택 및 저장, EventCreated 발행 |
|
||||||
|
| AI추천요청 | `event-AI추천요청.puml` | UFR-EVENT-030 | Kafka ai-job 발행, Job ID 반환 (202 Accepted) |
|
||||||
|
| 추천결과조회 | `event-추천결과조회.puml` | UFR-EVENT-030 | Redis Job 상태 폴링 조회 |
|
||||||
|
| 이미지생성요청 | `event-이미지생성요청.puml` | UFR-CONT-010 | Kafka image-job 발행, Job ID 반환 (202 Accepted) |
|
||||||
|
| 이미지결과조회 | `event-이미지결과조회.puml` | UFR-CONT-010 | Redis Job 상태 폴링 조회 |
|
||||||
|
| 콘텐츠선택 | `event-콘텐츠선택.puml` | UFR-CONT-020 | 선택한 콘텐츠 저장 |
|
||||||
|
| 최종승인및배포 | `event-최종승인및배포.puml` | UFR-EVENT-050 | Distribution Service 동기 호출, 상태 변경 |
|
||||||
|
| 상세조회 | `event-상세조회.puml` | UFR-EVENT-060 | 이벤트 상세 조회 (캐싱) |
|
||||||
|
| 목록조회 | `event-목록조회.puml` | UFR-EVENT-070 | 이벤트 목록 조회 (필터/검색/페이지네이션) |
|
||||||
|
| 대시보드조회 | `event-대시보드조회.puml` | UFR-EVENT-010 | 대시보드 이벤트 목록 (병렬 쿼리) |
|
||||||
|
|
||||||
|
**주요 특징**:
|
||||||
|
- **Kafka 통합**: Event Topics (EventCreated), Job Topics (ai-job, image-job)
|
||||||
|
- **비동기 처리**: Job 발행 → 폴링 방식 결과 조회
|
||||||
|
- **동기 호출**: Distribution Service REST API 직접 호출
|
||||||
|
- **캐싱 전략**: 목적(30분), 상세(5분), 목록/대시보드(1분)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.3 Participation 서비스 (3개)
|
||||||
|
|
||||||
|
| 시나리오 | 파일명 | 유저스토리 | 주요 처리 내용 |
|
||||||
|
|----------|--------|-----------|---------------|
|
||||||
|
| 이벤트참여 | `participation-이벤트참여.puml` | UFR-PART-010 | 중복 체크, ParticipantRegistered 발행 |
|
||||||
|
| 참여자목록조회 | `participation-참여자목록조회.puml` | UFR-PART-020 | 필터/검색, 페이지네이션, 전화번호 마스킹 |
|
||||||
|
| 당첨자추첨 | `participation-당첨자추첨.puml` | UFR-PART-030 | Fisher-Yates Shuffle, WinnerSelected 발행 |
|
||||||
|
|
||||||
|
**주요 특징**:
|
||||||
|
- **중복 방지**: Redis Cache + DB 2단계 체크
|
||||||
|
- **추첨 알고리즘**: 난수 기반 공정성, 가산점 시스템, Fisher-Yates Shuffle
|
||||||
|
- **Kafka Event**: ParticipantRegistered, WinnerSelected → Analytics Service 구독
|
||||||
|
- **보안**: 전화번호 마스킹 (010-****-1234)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.4 Analytics 서비스 (5개)
|
||||||
|
|
||||||
|
| 시나리오 | 파일명 | 유저스토리 | 주요 처리 내용 |
|
||||||
|
|----------|--------|-----------|---------------|
|
||||||
|
| 대시보드조회-캐시히트 | `analytics-대시보드조회-캐시히트.puml` | UFR-ANAL-010 | Redis 캐시 HIT (0.5초) |
|
||||||
|
| 대시보드조회-캐시미스 | `analytics-대시보드조회-캐시미스.puml` | UFR-ANAL-010 | 외부 API 병렬 호출, ROI 계산 (3초) |
|
||||||
|
| 이벤트생성구독 | `analytics-이벤트생성구독.puml` | - | EventCreated 구독, 통계 초기화 |
|
||||||
|
| 참여자등록구독 | `analytics-참여자등록구독.puml` | - | ParticipantRegistered 구독, 실시간 통계 |
|
||||||
|
| 배포완료구독 | `analytics-배포완료구독.puml` | - | DistributionCompleted 구독, 배포 통계 |
|
||||||
|
|
||||||
|
**주요 특징**:
|
||||||
|
- **Cache-Aside 패턴**: Redis 캐싱 (TTL 5분, 히트율 95%)
|
||||||
|
- **외부 API 병렬 호출**: 우리동네TV, 지니TV, SNS APIs (Circuit Breaker, Timeout, Fallback)
|
||||||
|
- **Kafka 구독**: 3개 Event Topics 실시간 처리
|
||||||
|
- **멱등성 보장**: Redis Set으로 중복 이벤트 방지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.5 AI 서비스 (1개)
|
||||||
|
|
||||||
|
| 시나리오 | 파일명 | 유저스토리 | 주요 처리 내용 |
|
||||||
|
|----------|--------|-----------|---------------|
|
||||||
|
| 트렌드분석및추천 | `ai-트렌드분석및추천.puml` | UFR-AI-010 | Kafka ai-job 구독, 트렌드 분석, 3가지 추천 병렬 생성 |
|
||||||
|
|
||||||
|
**주요 특징**:
|
||||||
|
- **Kafka Job 구독**: ai-job 토픽 Consumer
|
||||||
|
- **외부 AI API**: Claude/GPT-4 호출 (Circuit Breaker, Timeout 30초)
|
||||||
|
- **캐싱 전략**: 트렌드 분석 결과 (TTL 1시간), 추천 결과 (TTL 24시간)
|
||||||
|
- **3가지 옵션 병렬 생성**: 저비용/중비용/고비용 추천안
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.6 Content 서비스 (1개)
|
||||||
|
|
||||||
|
| 시나리오 | 파일명 | 유저스토리 | 주요 처리 내용 |
|
||||||
|
|----------|--------|-----------|---------------|
|
||||||
|
| 이미지생성 | `content-이미지생성.puml` | UFR-CONT-010 | Kafka image-job 구독, 3가지 스타일 병렬 생성 |
|
||||||
|
|
||||||
|
**주요 특징**:
|
||||||
|
- **Kafka Job 구독**: image-job 토픽 Consumer
|
||||||
|
- **외부 이미지 API**: Stable Diffusion/DALL-E 병렬 호출 (Circuit Breaker, Timeout 20초)
|
||||||
|
- **3가지 스타일 병렬**: 심플/화려한/트렌디 (par 블록)
|
||||||
|
- **CDN 업로드**: 이미지 URL 캐싱 (TTL 7일)
|
||||||
|
- **Fallback 2단계**: Stable Diffusion 실패 → DALL-E → 기본 템플릿
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.7 Distribution 서비스 (2개)
|
||||||
|
|
||||||
|
| 시나리오 | 파일명 | 유저스토리 | 주요 처리 내용 |
|
||||||
|
|----------|--------|-----------|---------------|
|
||||||
|
| 다중채널배포 | `distribution-다중채널배포.puml` | UFR-DIST-010 | REST API 동기 호출, 채널별 병렬 배포, DistributionCompleted 발행 |
|
||||||
|
| 배포상태조회 | `distribution-배포상태조회.puml` | UFR-DIST-020 | 배포 상태 모니터링, 재시도 기능 |
|
||||||
|
|
||||||
|
**주요 특징**:
|
||||||
|
- **동기 호출**: Event Service → Distribution Service REST API
|
||||||
|
- **채널별 병렬 배포**: 우리동네TV, 링고비즈, 지니TV, SNS APIs (par 블록)
|
||||||
|
- **Resilience 패턴**: Circuit Breaker, Retry (3회), Bulkhead (채널별 독립)
|
||||||
|
- **독립 처리**: 하나 실패해도 다른 채널 계속
|
||||||
|
- **Kafka Event**: DistributionCompleted → Analytics Service 구독
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 설계 원칙
|
||||||
|
|
||||||
|
### 3.1 공통설계원칙 준수
|
||||||
|
|
||||||
|
✅ **PlantUML 표준**
|
||||||
|
- `!theme mono` 테마 적용
|
||||||
|
- 명확한 타이틀 및 참여자 타입 표시
|
||||||
|
- 외부 시스템/인프라 `<<E>>` 표시
|
||||||
|
|
||||||
|
✅ **레이어 아키텍처**
|
||||||
|
```
|
||||||
|
Controller (API Layer)
|
||||||
|
↓
|
||||||
|
Service (Business Layer)
|
||||||
|
↓
|
||||||
|
Repository (Data Layer)
|
||||||
|
↓
|
||||||
|
External Systems (Redis, DB, Kafka, APIs)
|
||||||
|
```
|
||||||
|
|
||||||
|
✅ **동기/비동기 구분**
|
||||||
|
- 실선 화살표 (`→`): 동기 호출
|
||||||
|
- 점선 화살표 (`-->`): 비동기 호출 (Kafka)
|
||||||
|
- `activate`/`deactivate`: 생명선 활성화
|
||||||
|
|
||||||
|
### 3.2 내부시퀀스설계 가이드 준수
|
||||||
|
|
||||||
|
✅ **유저스토리 기반 설계**
|
||||||
|
- 20개 유저스토리와 정확히 매칭
|
||||||
|
- 불필요한 추가 설계 배제
|
||||||
|
|
||||||
|
✅ **외부 시퀀스와 일치**
|
||||||
|
- 외부 시퀀스 다이어그램과 플로우 일치
|
||||||
|
- 서비스 간 통신 방식 동일
|
||||||
|
|
||||||
|
✅ **모든 레이어 표시**
|
||||||
|
- API, 비즈니스, 데이터, 인프라 레이어 명시
|
||||||
|
- 캐시, DB, 외부 API 접근 표시
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 주요 패턴
|
||||||
|
|
||||||
|
### 4.1 Resilience 패턴
|
||||||
|
|
||||||
|
#### Circuit Breaker
|
||||||
|
- **적용 대상**: 모든 외부 API 호출
|
||||||
|
- **설정**: 실패율 50% 초과 시 Open, 30초 후 Half-Open
|
||||||
|
- **효과**: 빠른 실패로 리소스 보호
|
||||||
|
|
||||||
|
#### Retry Pattern
|
||||||
|
- **적용 대상**: 일시적 장애가 예상되는 외부 API
|
||||||
|
- **설정**: 최대 3회, 지수 백오프 (1초, 2초, 4초)
|
||||||
|
- **효과**: 일시적 장애 자동 복구
|
||||||
|
|
||||||
|
#### Timeout Pattern
|
||||||
|
- **적용 대상**: 모든 외부 API 호출
|
||||||
|
- **설정**: 국세청 5초, AI 30초, 이미지 20초, 배포 10초
|
||||||
|
- **효과**: 리소스 점유 방지
|
||||||
|
|
||||||
|
#### Fallback Pattern
|
||||||
|
- **적용 대상**: 외부 API 장애 시
|
||||||
|
- **전략**: 캐시된 이전 데이터, 기본값, 검증 스킵
|
||||||
|
- **효과**: 서비스 지속성 보장 (Graceful Degradation)
|
||||||
|
|
||||||
|
#### Bulkhead Pattern
|
||||||
|
- **적용 대상**: Distribution Service 다중 채널 배포
|
||||||
|
- **설정**: 채널별 독립 스레드 풀
|
||||||
|
- **효과**: 채널 장애 격리, 장애 전파 차단
|
||||||
|
|
||||||
|
### 4.2 캐싱 전략 (Cache-Aside)
|
||||||
|
|
||||||
|
| 서비스 | 캐시 키 패턴 | TTL | 히트율 목표 | 효과 |
|
||||||
|
|--------|-------------|-----|-----------|------|
|
||||||
|
| User | `user:business:{사업자번호}` | 7일 | 90% | 5초 → 0.1초 (98% 개선) |
|
||||||
|
| AI | `ai:recommendation:{업종}:{지역}:{목적}` | 24시간 | 80% | 10초 → 0.1초 (99% 개선) |
|
||||||
|
| Content | `content:image:{이벤트ID}:{스타일}` | 7일 | 80% | 5초 → 0.1초 (98% 개선) |
|
||||||
|
| Analytics | `analytics:dashboard:{이벤트ID}` | 5분 | 95% | 3초 → 0.5초 (83% 개선) |
|
||||||
|
| Event | `event:detail:{eventId}` | 5분 | 85% | 1초 → 0.2초 (80% 개선) |
|
||||||
|
| Participation | `participation:list:{eventId}:{filter}` | 5분 | 90% | 2초 → 0.3초 (85% 개선) |
|
||||||
|
|
||||||
|
### 4.3 Event-Driven 패턴
|
||||||
|
|
||||||
|
#### Kafka Event Topics (도메인 이벤트)
|
||||||
|
- **EventCreated**: 이벤트 생성 시 → Analytics Service 구독
|
||||||
|
- **ParticipantRegistered**: 참여자 등록 시 → Analytics Service 구독
|
||||||
|
- **WinnerSelected**: 당첨자 선정 시 → (추후 확장)
|
||||||
|
- **DistributionCompleted**: 배포 완료 시 → Analytics Service 구독
|
||||||
|
|
||||||
|
#### Kafka Job Topics (비동기 작업)
|
||||||
|
- **ai-job**: AI 추천 요청 → AI Service 구독
|
||||||
|
- **image-job**: 이미지 생성 요청 → Content Service 구독
|
||||||
|
|
||||||
|
#### 멱등성 보장
|
||||||
|
- Redis Set으로 이벤트 ID 중복 체크
|
||||||
|
- 동일 이벤트 중복 처리 시 무시
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 파일 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
design/backend/sequence/inner/
|
||||||
|
├── README.md (본 문서)
|
||||||
|
├── user-회원가입.puml
|
||||||
|
├── user-로그인.puml
|
||||||
|
├── user-프로필수정.puml
|
||||||
|
├── user-로그아웃.puml
|
||||||
|
├── event-목적선택.puml
|
||||||
|
├── event-AI추천요청.puml
|
||||||
|
├── event-추천결과조회.puml
|
||||||
|
├── event-이미지생성요청.puml
|
||||||
|
├── event-이미지결과조회.puml
|
||||||
|
├── event-콘텐츠선택.puml
|
||||||
|
├── event-최종승인및배포.puml
|
||||||
|
├── event-상세조회.puml
|
||||||
|
├── event-목록조회.puml
|
||||||
|
├── event-대시보드조회.puml
|
||||||
|
├── participation-이벤트참여.puml
|
||||||
|
├── participation-참여자목록조회.puml
|
||||||
|
├── participation-당첨자추첨.puml
|
||||||
|
├── analytics-대시보드조회-캐시히트.puml
|
||||||
|
├── analytics-대시보드조회-캐시미스.puml
|
||||||
|
├── analytics-이벤트생성구독.puml
|
||||||
|
├── analytics-참여자등록구독.puml
|
||||||
|
├── analytics-배포완료구독.puml
|
||||||
|
├── ai-트렌드분석및추천.puml
|
||||||
|
├── content-이미지생성.puml
|
||||||
|
├── distribution-다중채널배포.puml
|
||||||
|
└── distribution-배포상태조회.puml
|
||||||
|
```
|
||||||
|
|
||||||
|
**총 26개 파일, 약 114KB**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. PlantUML 다이어그램 확인 방법
|
||||||
|
|
||||||
|
### 6.1 온라인 확인
|
||||||
|
|
||||||
|
#### PlantUML Web Server
|
||||||
|
1. https://www.plantuml.com/plantuml/uml 접속
|
||||||
|
2. 각 `.puml` 파일 내용 복사
|
||||||
|
3. 에디터에 붙여넣기
|
||||||
|
4. 다이어그램 시각적 확인
|
||||||
|
5. PNG/SVG/PDF 다운로드 가능
|
||||||
|
|
||||||
|
#### PlantUML Editor (추천)
|
||||||
|
1. https://plantuml-editor.kkeisuke.com/ 접속
|
||||||
|
2. 실시간 미리보기 제공
|
||||||
|
3. 편집 및 다운로드 지원
|
||||||
|
|
||||||
|
### 6.2 로컬 확인 (Docker)
|
||||||
|
|
||||||
|
#### Docker로 PlantUML 검증
|
||||||
|
```bash
|
||||||
|
# Docker 실행 필요
|
||||||
|
docker run -d --name plantuml -p 8080:8080 plantuml/plantuml-server:jetty
|
||||||
|
|
||||||
|
# 각 파일 문법 검사
|
||||||
|
cat "user-회원가입.puml" | docker exec -i plantuml java -jar /app/plantuml.jar -syntax
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 IDE 플러그인
|
||||||
|
|
||||||
|
#### IntelliJ IDEA
|
||||||
|
- **PlantUML Integration** 플러그인 설치
|
||||||
|
- `.puml` 파일 우클릭 → "Show PlantUML Diagram"
|
||||||
|
|
||||||
|
#### VS Code
|
||||||
|
- **PlantUML** 확장 설치
|
||||||
|
- `Alt+D`: 미리보기 열기
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 부록
|
||||||
|
|
||||||
|
### A. 파일 크기 및 통계
|
||||||
|
|
||||||
|
| 서비스 | 시나리오 수 | 총 크기 | 평균 크기 |
|
||||||
|
|--------|------------|---------|----------|
|
||||||
|
| User | 4 | 21.2KB | 5.3KB |
|
||||||
|
| Event | 10 | 20.2KB | 2.0KB |
|
||||||
|
| Participation | 3 | 15.4KB | 5.1KB |
|
||||||
|
| Analytics | 5 | 20.8KB | 4.2KB |
|
||||||
|
| AI | 1 | 12KB | 12KB |
|
||||||
|
| Content | 1 | 8.5KB | 8.5KB |
|
||||||
|
| Distribution | 2 | 17.5KB | 8.8KB |
|
||||||
|
| **총계** | **26** | **115.6KB** | **4.4KB** |
|
||||||
|
|
||||||
|
### B. 주요 기술 스택
|
||||||
|
|
||||||
|
#### Backend
|
||||||
|
- **Framework**: Spring Boot
|
||||||
|
- **ORM**: JPA/Hibernate
|
||||||
|
- **Security**: Spring Security + JWT
|
||||||
|
- **Cache**: Redis
|
||||||
|
- **Database**: PostgreSQL
|
||||||
|
- **Message Queue**: Apache Kafka
|
||||||
|
|
||||||
|
#### Resilience
|
||||||
|
- **Circuit Breaker**: Resilience4j
|
||||||
|
- **Retry**: Resilience4j RetryRegistry
|
||||||
|
- **Timeout**: Resilience4j TimeLimiterRegistry
|
||||||
|
|
||||||
|
#### Utilities
|
||||||
|
- **Password**: bcrypt (Spring Security)
|
||||||
|
- **JWT**: jjwt library
|
||||||
|
- **Encryption**: AES-256 (javax.crypto)
|
||||||
|
|
||||||
|
### C. 참고 문서
|
||||||
|
- [유저스토리](../../userstory.md)
|
||||||
|
- [외부 시퀀스 설계서](../outer/)
|
||||||
|
- [논리 아키텍처](../../logical/logical-architecture.md)
|
||||||
|
- [공통설계원칙](../../../../claude/common-principles.md)
|
||||||
|
- [내부시퀀스설계 가이드](../../../../claude/sequence-inner-design.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**문서 버전**: 1.0
|
||||||
|
**최종 수정일**: 2025-10-22
|
||||||
|
**작성자**: System Architect (박영자)
|
||||||
|
**내부 시퀀스 설계 완료**: ✅ 26개 시나리오 모두 작성 완료
|
||||||
343
design/backend/sequence/inner/ai-트렌드분석및추천.puml
Normal file
343
design/backend/sequence/inner/ai-트렌드분석및추천.puml
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
@startuml ai-트렌드분석및추천
|
||||||
|
!theme mono
|
||||||
|
|
||||||
|
title AI Service - 트렌드 분석 및 이벤트 추천 (내부 시퀀스)
|
||||||
|
|
||||||
|
actor Client
|
||||||
|
participant "Kafka Consumer" as Consumer <<Component>>
|
||||||
|
participant "JobMessageHandler" as Handler <<Controller>>
|
||||||
|
participant "AIRecommendationService" as Service <<Service>>
|
||||||
|
participant "TrendAnalysisEngine" as TrendEngine <<Component>>
|
||||||
|
participant "RecommendationEngine" as RecommendEngine <<Component>>
|
||||||
|
participant "CacheManager" as Cache <<Component>>
|
||||||
|
participant "CircuitBreakerManager" as CB <<Component>>
|
||||||
|
participant "ExternalAIClient" as AIClient <<Component>>
|
||||||
|
participant "JobStateManager" as JobState <<Component>>
|
||||||
|
participant "Redis" as Redis <<Infrastructure>>
|
||||||
|
participant "External AI API" as ExternalAPI <<External>>
|
||||||
|
participant "Kafka Producer" as Producer <<Component>>
|
||||||
|
|
||||||
|
note over Consumer: Kafka ai-job Topic 구독\nConsumer Group: ai-service-group
|
||||||
|
|
||||||
|
== 1. Job 메시지 수신 ==
|
||||||
|
Consumer -> Handler: onMessage(jobMessage)\n{jobId, eventDraftId, 목적, 업종, 지역, 매장정보}
|
||||||
|
activate Handler
|
||||||
|
|
||||||
|
Handler -> Handler: 메시지 유효성 검증
|
||||||
|
note right
|
||||||
|
검증 항목:
|
||||||
|
- jobId 존재 여부
|
||||||
|
- eventDraftId 유효성
|
||||||
|
- 필수 파라미터 (목적, 업종, 지역)
|
||||||
|
end note
|
||||||
|
|
||||||
|
alt 유효하지 않은 메시지
|
||||||
|
Handler -> Producer: DLQ 발행 (Dead Letter Queue)\n{jobId, error: INVALID_MESSAGE}
|
||||||
|
Handler --> Consumer: ACK (메시지 처리 완료)
|
||||||
|
note over Handler: 잘못된 메시지는 DLQ로 이동\n수동 검토 필요
|
||||||
|
else 유효한 메시지
|
||||||
|
Handler -> JobState: updateJobStatus(jobId, PROCESSING)
|
||||||
|
JobState -> Redis: SET job:{jobId}:status = PROCESSING
|
||||||
|
Redis --> JobState: OK
|
||||||
|
JobState --> Handler: 상태 업데이트 완료
|
||||||
|
|
||||||
|
Handler -> Service: generateRecommendations(\neventDraftId, 목적, 업종, 지역, 매장정보)
|
||||||
|
activate Service
|
||||||
|
|
||||||
|
== 2. 트렌드 분석 ==
|
||||||
|
Service -> TrendEngine: analyzeTrends(업종, 지역, 목적)
|
||||||
|
activate TrendEngine
|
||||||
|
|
||||||
|
TrendEngine -> Cache: getCachedTrend(업종, 지역)
|
||||||
|
Cache -> Redis: GET trend:{업종}:{지역}
|
||||||
|
Redis --> Cache: 캐시 결과
|
||||||
|
|
||||||
|
alt 캐시 히트
|
||||||
|
Cache --> TrendEngine: 캐시된 트렌드 데이터
|
||||||
|
note right
|
||||||
|
캐시 키: trend:{업종}:{지역}
|
||||||
|
TTL: 1시간
|
||||||
|
데이터: {
|
||||||
|
industry_trends,
|
||||||
|
regional_characteristics,
|
||||||
|
seasonal_patterns
|
||||||
|
}
|
||||||
|
end note
|
||||||
|
|
||||||
|
else 캐시 미스
|
||||||
|
note right of TrendEngine
|
||||||
|
**트렌드 분석 입력 데이터**
|
||||||
|
- 업종 정보
|
||||||
|
- 지역 정보
|
||||||
|
- 현재 시즌 (계절, 월)
|
||||||
|
- 이벤트 목적
|
||||||
|
|
||||||
|
**외부 AI API 호출**
|
||||||
|
- 과거 이벤트 데이터 사용 안 함
|
||||||
|
- 실시간 시장 트렌드 분석
|
||||||
|
- 업종별/지역별 일반적 특성
|
||||||
|
end note
|
||||||
|
|
||||||
|
TrendEngine -> CB: executeWithCircuitBreaker(\nAI API 트렌드 분석 호출)
|
||||||
|
activate CB
|
||||||
|
|
||||||
|
CB -> CB: Circuit Breaker 상태 확인
|
||||||
|
note right
|
||||||
|
**Circuit Breaker 설정**
|
||||||
|
- Failure Rate Threshold: 50%
|
||||||
|
- Timeout: 5분 (300초)
|
||||||
|
- Half-Open Wait Duration: 1분 (60초)
|
||||||
|
- Permitted Calls in Half-Open: 3
|
||||||
|
- Sliding Window Size: 10
|
||||||
|
end note
|
||||||
|
|
||||||
|
alt Circuit CLOSED (정상)
|
||||||
|
CB -> AIClient: callAIAPI(\nmethod: "trendAnalysis",\nprompt: 트렌드 분석 프롬프트,\ntimeout: 5분)
|
||||||
|
activate AIClient
|
||||||
|
|
||||||
|
AIClient -> AIClient: 프롬프트 구성
|
||||||
|
note right of AIClient
|
||||||
|
**AI 프롬프트 구성**
|
||||||
|
"당신은 마케팅 트렌드 분석 전문가입니다.
|
||||||
|
|
||||||
|
**입력 정보**
|
||||||
|
- 업종: {업종}
|
||||||
|
- 지역: {지역}
|
||||||
|
- 현재 시즌: {계절/월}
|
||||||
|
- 이벤트 목적: {목적}
|
||||||
|
|
||||||
|
**분석 요청사항**
|
||||||
|
1. 업종별 일반적 트렌드
|
||||||
|
(업종 특성 기반 효과적인 이벤트 유형)
|
||||||
|
|
||||||
|
2. 지역별 특성
|
||||||
|
(지역 고객 특성, 선호도)
|
||||||
|
|
||||||
|
3. 시즌별 추천
|
||||||
|
(현재 시기에 적합한 이벤트)"
|
||||||
|
end note
|
||||||
|
|
||||||
|
AIClient -> ExternalAPI: AI API 호출\nPOST /api/v1/analyze\nAuthorization: Bearer {API_KEY}\nTimeout: 5분\nPayload: {업종, 지역, 시즌, 목적}
|
||||||
|
activate ExternalAPI
|
||||||
|
|
||||||
|
ExternalAPI --> AIClient: 200 OK\n{"industryTrend": "...",\n"regionalCharacteristics": "...",\n"seasonalRecommendation": "..."}
|
||||||
|
deactivate ExternalAPI
|
||||||
|
|
||||||
|
AIClient -> AIClient: 응답 검증 및 파싱
|
||||||
|
AIClient --> CB: 분석 결과
|
||||||
|
deactivate AIClient
|
||||||
|
|
||||||
|
CB -> CB: 성공 기록 (Circuit Breaker)
|
||||||
|
CB --> TrendEngine: 트렌드 분석 결과
|
||||||
|
deactivate CB
|
||||||
|
|
||||||
|
TrendEngine -> Cache: cacheTrend(\nkey: trend:{업종}:{지역},\ndata: 분석결과,\nTTL: 1시간)
|
||||||
|
Cache -> Redis: SETEX trend:{업종}:{지역} 3600 {분석결과}
|
||||||
|
Redis --> Cache: OK
|
||||||
|
Cache --> TrendEngine: 캐싱 완료
|
||||||
|
|
||||||
|
else Circuit OPEN (장애)
|
||||||
|
CB --> TrendEngine: CircuitBreakerOpenException
|
||||||
|
TrendEngine -> TrendEngine: Fallback 실행\n(기본 트렌드 데이터 사용)
|
||||||
|
note right
|
||||||
|
Fallback 전략:
|
||||||
|
- 이전 캐시 데이터 반환
|
||||||
|
- 또는 기본 트렌드 템플릿 사용
|
||||||
|
- 클라이언트에 안내 메시지 포함
|
||||||
|
end note
|
||||||
|
|
||||||
|
else Circuit HALF-OPEN (복구 시도)
|
||||||
|
CB -> AIClient: 제한된 요청 허용 (3개)
|
||||||
|
AIClient -> ExternalAPI: POST /api/v1/analyze
|
||||||
|
ExternalAPI --> AIClient: 200 OK
|
||||||
|
AIClient --> CB: 성공
|
||||||
|
CB -> CB: 연속 성공 시 CLOSED로 전환
|
||||||
|
CB --> TrendEngine: 트렌드 분석 결과
|
||||||
|
|
||||||
|
else Timeout (5분 초과)
|
||||||
|
CB --> TrendEngine: TimeoutException
|
||||||
|
note right of TrendEngine
|
||||||
|
**Timeout 처리**
|
||||||
|
- 5분 초과 시 즉시 실패
|
||||||
|
- Fallback: 기본 트렌드 사용
|
||||||
|
- 사용자에게 안내 메시지 제공
|
||||||
|
end note
|
||||||
|
TrendEngine -> TrendEngine: Fallback 실행\n(기본 트렌드 템플릿 사용)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
TrendEngine --> Service: 트렌드 분석 완료\n{업종트렌드, 지역특성, 시즌특성}
|
||||||
|
deactivate TrendEngine
|
||||||
|
|
||||||
|
== 3. 이벤트 추천 생성 (3가지 옵션) ==
|
||||||
|
Service -> RecommendEngine: generateRecommendations(\n목적, 트렌드, 매장정보)
|
||||||
|
activate RecommendEngine
|
||||||
|
|
||||||
|
RecommendEngine -> RecommendEngine: 추천 컨텍스트 구성
|
||||||
|
note right
|
||||||
|
추천 입력:
|
||||||
|
- 이벤트 목적 (신규 고객 유치 등)
|
||||||
|
- 트렌드 분석 결과
|
||||||
|
- 매장 정보 (업종, 위치, 크기)
|
||||||
|
- 예산 범위 (저/중/고)
|
||||||
|
end note
|
||||||
|
|
||||||
|
group parallel
|
||||||
|
RecommendEngine -> CB: executeWithCircuitBreaker(\nAI API 추천 생성 - 옵션 1: 저비용)
|
||||||
|
activate CB
|
||||||
|
CB -> AIClient: callAIAPI(\nmethod: "generateRecommendation",\nprompt: 저비용 추천 프롬프트,\ntimeout: 5분)
|
||||||
|
activate AIClient
|
||||||
|
|
||||||
|
AIClient -> AIClient: 프롬프트 구성
|
||||||
|
note right
|
||||||
|
옵션 1 프롬프트:
|
||||||
|
"저비용, 높은 참여율 중심 이벤트 기획
|
||||||
|
목적: {목적}
|
||||||
|
트렌드: {트렌드}
|
||||||
|
매장: {매장정보}
|
||||||
|
|
||||||
|
출력 형식:
|
||||||
|
- 이벤트 제목
|
||||||
|
- 추천 경품 (예산: 저)
|
||||||
|
- 참여 방법 (난이도: 낮음)
|
||||||
|
- 예상 참여자 수
|
||||||
|
- 예상 비용
|
||||||
|
- 예상 ROI"
|
||||||
|
end note
|
||||||
|
|
||||||
|
AIClient -> ExternalAPI: POST /api/v1/recommend\n(저비용 옵션)
|
||||||
|
ExternalAPI --> AIClient: 200 OK\n{추천안 1}
|
||||||
|
AIClient --> CB: 추천안 1
|
||||||
|
deactivate AIClient
|
||||||
|
CB --> RecommendEngine: 옵션 1 완료
|
||||||
|
deactivate CB
|
||||||
|
|
||||||
|
RecommendEngine -> CB: executeWithCircuitBreaker(\nAI API 추천 생성 - 옵션 2: 중비용)
|
||||||
|
activate CB
|
||||||
|
CB -> AIClient: callAIAPI(\nmethod: "generateRecommendation",\nprompt: 중비용 추천 프롬프트,\ntimeout: 5분)
|
||||||
|
activate AIClient
|
||||||
|
|
||||||
|
AIClient -> AIClient: 프롬프트 구성
|
||||||
|
note right
|
||||||
|
옵션 2 프롬프트:
|
||||||
|
"중비용, 균형잡힌 ROI 이벤트 기획
|
||||||
|
목적: {목적}
|
||||||
|
트렌드: {트렌드}
|
||||||
|
매장: {매장정보}
|
||||||
|
|
||||||
|
출력 형식:
|
||||||
|
- 이벤트 제목
|
||||||
|
- 추천 경품 (예산: 중)
|
||||||
|
- 참여 방법 (난이도: 중간)
|
||||||
|
- 예상 참여자 수
|
||||||
|
- 예상 비용
|
||||||
|
- 예상 ROI"
|
||||||
|
end note
|
||||||
|
|
||||||
|
AIClient -> ExternalAPI: POST /api/v1/recommend\n(중비용 옵션)
|
||||||
|
ExternalAPI --> AIClient: 200 OK\n{추천안 2}
|
||||||
|
AIClient --> CB: 추천안 2
|
||||||
|
deactivate AIClient
|
||||||
|
CB --> RecommendEngine: 옵션 2 완료
|
||||||
|
deactivate CB
|
||||||
|
|
||||||
|
RecommendEngine -> CB: executeWithCircuitBreaker(\nAI API 추천 생성 - 옵션 3: 고비용)
|
||||||
|
activate CB
|
||||||
|
CB -> AIClient: callAIAPI(\nmethod: "generateRecommendation",\nprompt: 고비용 추천 프롬프트,\ntimeout: 5분)
|
||||||
|
activate AIClient
|
||||||
|
|
||||||
|
AIClient -> AIClient: 프롬프트 구성
|
||||||
|
note right
|
||||||
|
옵션 3 프롬프트:
|
||||||
|
"고비용, 높은 매출 증대 이벤트 기획
|
||||||
|
목적: {목적}
|
||||||
|
트렌드: {트렌드}
|
||||||
|
매장: {매장정보}
|
||||||
|
|
||||||
|
출력 형식:
|
||||||
|
- 이벤트 제목
|
||||||
|
- 추천 경품 (예산: 고)
|
||||||
|
- 참여 방법 (난이도: 높음)
|
||||||
|
- 예상 참여자 수
|
||||||
|
- 예상 비용
|
||||||
|
- 예상 ROI"
|
||||||
|
end note
|
||||||
|
|
||||||
|
AIClient -> ExternalAPI: POST /api/v1/recommend\n(고비용 옵션)
|
||||||
|
ExternalAPI --> AIClient: 200 OK\n{추천안 3}
|
||||||
|
AIClient --> CB: 추천안 3
|
||||||
|
deactivate AIClient
|
||||||
|
CB --> RecommendEngine: 옵션 3 완료
|
||||||
|
deactivate CB
|
||||||
|
end
|
||||||
|
|
||||||
|
RecommendEngine -> RecommendEngine: 3가지 추천안 통합 및 검증
|
||||||
|
note right
|
||||||
|
검증 항목:
|
||||||
|
- 필수 필드 존재 여부
|
||||||
|
- 예상 성과 계산 (ROI)
|
||||||
|
- 추천안 차별화 확인
|
||||||
|
- 홍보 문구 생성 (각 5개)
|
||||||
|
- SNS 해시태그 자동 생성
|
||||||
|
end note
|
||||||
|
|
||||||
|
RecommendEngine --> Service: 3가지 추천안 생성 완료
|
||||||
|
deactivate RecommendEngine
|
||||||
|
|
||||||
|
== 4. 결과 저장 및 Job 상태 업데이트 ==
|
||||||
|
Service -> Cache: cacheRecommendations(\nkey: ai:recommendation:{eventDraftId},\ndata: {트렌드+추천안},\nTTL: 24시간)
|
||||||
|
Cache -> Redis: SETEX ai:recommendation:{eventDraftId} 86400 {결과}
|
||||||
|
Redis --> Cache: OK
|
||||||
|
Cache --> Service: 캐싱 완료
|
||||||
|
|
||||||
|
Service -> JobState: updateJobStatus(\njobId,\nstatus: COMPLETED,\nresult: {트렌드, 추천안})
|
||||||
|
JobState -> Redis: HSET job:{jobId} status COMPLETED result {JSON}
|
||||||
|
Redis --> JobState: OK
|
||||||
|
JobState --> Service: 상태 업데이트 완료
|
||||||
|
|
||||||
|
Service --> Handler: 추천 생성 완료\n{트렌드분석, 3가지추천안}
|
||||||
|
deactivate Service
|
||||||
|
|
||||||
|
Handler --> Consumer: ACK (메시지 처리 완료)
|
||||||
|
deactivate Handler
|
||||||
|
|
||||||
|
note over Consumer: Job 처리 완료\nRedis에 저장된 결과를\n클라이언트는 폴링으로 조회
|
||||||
|
end
|
||||||
|
|
||||||
|
== 예외 처리 ==
|
||||||
|
note over Handler, Producer
|
||||||
|
**AI API 장애 시**
|
||||||
|
- Circuit Breaker Open
|
||||||
|
- Fallback: 기본 트렌드 템플릿 사용
|
||||||
|
- Job 상태: COMPLETED (안내 메시지 포함)
|
||||||
|
- 사용자에게 "AI 분석이 제한적으로 제공됩니다" 안내
|
||||||
|
|
||||||
|
**Timeout (5분 초과)**
|
||||||
|
- Circuit Breaker로 즉시 실패
|
||||||
|
- Retry 없음 (비동기 Job)
|
||||||
|
- Job 상태: FAILED
|
||||||
|
- 사용자에게 재시도 요청 안내
|
||||||
|
|
||||||
|
**Kafka 메시지 처리 실패**
|
||||||
|
- DLQ(Dead Letter Queue)로 이동
|
||||||
|
- 수동 검토 및 재처리
|
||||||
|
- 에러 로그 기록
|
||||||
|
|
||||||
|
**Redis 장애**
|
||||||
|
- 캐싱 스킵
|
||||||
|
- Job 상태는 메모리에 임시 저장
|
||||||
|
- 성능 저하 가능 (매 요청마다 AI API 호출)
|
||||||
|
|
||||||
|
**성능 목표**
|
||||||
|
- 평균 응답 시간: 2분 이내
|
||||||
|
- P95 응답 시간: 4분 이내
|
||||||
|
- Circuit Breaker Timeout: 5분
|
||||||
|
- Redis 캐시 TTL: 24시간
|
||||||
|
|
||||||
|
**데이터 처리 원칙**
|
||||||
|
- 과거 이벤트 데이터 사용 안 함
|
||||||
|
- 외부 AI API로 실시간 트렌드 분석
|
||||||
|
- 업종/지역 기반 일반적 마케팅 트렌드 활용
|
||||||
|
end note
|
||||||
|
|
||||||
|
@enduml
|
||||||
342
design/backend/sequence/inner/analytics-대시보드조회.puml
Normal file
342
design/backend/sequence/inner/analytics-대시보드조회.puml
Normal file
@ -0,0 +1,342 @@
|
|||||||
|
@startuml analytics-대시보드조회
|
||||||
|
!theme mono
|
||||||
|
|
||||||
|
title Analytics Service - 대시보드 조회 내부 시퀀스\n(UFR-ANAL-010: 실시간 성과분석 대시보드 조회)
|
||||||
|
|
||||||
|
participant "AnalyticsController" as Controller
|
||||||
|
participant "AnalyticsService" as Service
|
||||||
|
participant "CacheService" as Cache
|
||||||
|
participant "AnalyticsRepository" as Repository
|
||||||
|
participant "ExternalChannelService" as ChannelService
|
||||||
|
participant "ROICalculator" as Calculator
|
||||||
|
participant "CircuitBreaker" as CB
|
||||||
|
participant "Redis<<E>>" as Redis
|
||||||
|
database "Analytics DB<<E>>" as DB
|
||||||
|
|
||||||
|
-> Controller: GET /api/events/{id}/analytics\n+ Authorization: Bearer {token}
|
||||||
|
activate Controller
|
||||||
|
|
||||||
|
Controller -> Service: getDashboardData(eventId, userId)
|
||||||
|
activate Service
|
||||||
|
|
||||||
|
note right of Service
|
||||||
|
**입력 검증**
|
||||||
|
- eventId: UUID 형식 검증
|
||||||
|
- userId: JWT에서 추출
|
||||||
|
- 권한 확인: 매장 소유자 여부
|
||||||
|
end note
|
||||||
|
|
||||||
|
Service -> Cache: get("analytics:dashboard:{eventId}")
|
||||||
|
activate Cache
|
||||||
|
|
||||||
|
note right of Cache
|
||||||
|
**Cache-Aside 패턴**
|
||||||
|
- Redis GET 호출
|
||||||
|
- Cache Key 구조:
|
||||||
|
analytics:dashboard:{eventId}
|
||||||
|
- TTL: 3600초 (1시간)
|
||||||
|
end note
|
||||||
|
|
||||||
|
Cache -> Redis: GET analytics:dashboard:{eventId}
|
||||||
|
activate Redis
|
||||||
|
|
||||||
|
alt Cache HIT
|
||||||
|
|
||||||
|
Redis --> Cache: **Cache HIT**\n캐시된 데이터 반환\n{\n totalParticipants: 1234,\n totalViews: 17200,\n roi: 250,\n channelStats: [...],\n lastUpdated: "2025-10-22T10:30:00Z"\n}
|
||||||
|
deactivate Redis
|
||||||
|
|
||||||
|
Cache --> Service: Dashboard 데이터 (JSON)
|
||||||
|
deactivate Cache
|
||||||
|
|
||||||
|
note right of Service
|
||||||
|
**응답 데이터 구조**
|
||||||
|
- 4개 요약 카드
|
||||||
|
* 총 참여자 수, 달성률
|
||||||
|
* 총 노출 수, 증감률
|
||||||
|
* 예상 ROI, 업종 평균 대비
|
||||||
|
* 매출 증가율
|
||||||
|
- 채널별 성과
|
||||||
|
- 시간대별 참여 추이
|
||||||
|
- 참여자 프로필 분석
|
||||||
|
- 비교 분석 (업종 평균, 이전 이벤트)
|
||||||
|
end note
|
||||||
|
|
||||||
|
Service --> Controller: DashboardResponse\n(200 OK)
|
||||||
|
deactivate Service
|
||||||
|
|
||||||
|
Controller --> : 200 OK\nDashboard Data (JSON)
|
||||||
|
deactivate Controller
|
||||||
|
|
||||||
|
note over Controller, Redis
|
||||||
|
**Cache HIT 시나리오 성능**
|
||||||
|
- 응답 시간: 약 0.5초
|
||||||
|
- Redis 조회 시간: 0.01초
|
||||||
|
- 직렬화/역직렬화: 0.05초
|
||||||
|
- HTTP 오버헤드: 0.44초
|
||||||
|
- 예상 히트율: 95%
|
||||||
|
end note
|
||||||
|
|
||||||
|
else Cache MISS
|
||||||
|
|
||||||
|
Redis --> Cache: **Cache MISS** (null)
|
||||||
|
deactivate Redis
|
||||||
|
|
||||||
|
Cache --> Service: null (캐시 미스)
|
||||||
|
deactivate Cache
|
||||||
|
|
||||||
|
note right of Service
|
||||||
|
**Cache MISS 처리**
|
||||||
|
- 데이터 통합 작업 시작
|
||||||
|
- 로컬 DB 조회 + 외부 API 병렬 호출
|
||||||
|
end note
|
||||||
|
|
||||||
|
|||
|
||||||
|
== 1. Analytics DB 조회 (로컬 데이터) ==
|
||||||
|
|
||||||
|
Service -> Repository: getEventStats(eventId)
|
||||||
|
activate Repository
|
||||||
|
|
||||||
|
Repository -> DB: 이벤트 통계 조회\n(이벤트ID로 통계 데이터 조회)
|
||||||
|
activate DB
|
||||||
|
|
||||||
|
DB --> Repository: EventStatsEntity\n- totalParticipants\n- estimatedROI\n- salesGrowthRate
|
||||||
|
deactivate DB
|
||||||
|
|
||||||
|
Repository --> Service: EventStats
|
||||||
|
deactivate Repository
|
||||||
|
|
||||||
|
note right of Service
|
||||||
|
**로컬 데이터 확보**
|
||||||
|
- 총 참여자 수
|
||||||
|
- 예상 ROI (DB 캐시)
|
||||||
|
- 매출 증가율 (POS 연동)
|
||||||
|
end note
|
||||||
|
|
||||||
|
|||
|
||||||
|
== 2. 외부 채널 API 병렬 호출 (Circuit Breaker 적용) ==
|
||||||
|
|
||||||
|
note right of Service
|
||||||
|
**병렬 처리 시작**
|
||||||
|
- CompletableFuture 4개 생성
|
||||||
|
- 우리동네TV, 지니TV, 링고비즈, SNS APIs 동시 호출
|
||||||
|
- Circuit Breaker 적용 (채널별 독립)
|
||||||
|
end note
|
||||||
|
|
||||||
|
par 외부 API 병렬 호출
|
||||||
|
|
||||||
|
Service -> ChannelService: getWooriTVStats(eventId)
|
||||||
|
activate ChannelService
|
||||||
|
|
||||||
|
ChannelService -> CB: execute("wooriTV", () -> callAPI())
|
||||||
|
activate CB
|
||||||
|
|
||||||
|
note right of CB
|
||||||
|
**Circuit Breaker**
|
||||||
|
- State: CLOSED (정상)
|
||||||
|
- Failure Rate: 50% 초과 시 OPEN
|
||||||
|
- Timeout: 10초
|
||||||
|
end note
|
||||||
|
|
||||||
|
CB -> CB: 외부 API 호출\nGET /stats/{eventId}
|
||||||
|
|
||||||
|
alt Circuit Breaker CLOSED (정상)
|
||||||
|
CB --> ChannelService: ChannelStats\n- views: 5000\n- clicks: 1200
|
||||||
|
deactivate CB
|
||||||
|
|
||||||
|
ChannelService --> Service: WooriTVStats
|
||||||
|
deactivate ChannelService
|
||||||
|
else Circuit Breaker OPEN (장애)
|
||||||
|
CB -> CB: **Fallback 실행**\n캐시된 이전 데이터 반환
|
||||||
|
note right of CB
|
||||||
|
Fallback 전략:
|
||||||
|
- Redis에서 이전 통계 조회
|
||||||
|
- 없으면 기본값 (0) 반환
|
||||||
|
- 알림: "일부 채널 데이터 로딩 실패"
|
||||||
|
end note
|
||||||
|
CB --> ChannelService: Fallback 데이터
|
||||||
|
deactivate CB
|
||||||
|
ChannelService --> Service: WooriTVStats (Fallback)
|
||||||
|
deactivate ChannelService
|
||||||
|
end
|
||||||
|
|
||||||
|
else
|
||||||
|
|
||||||
|
Service -> ChannelService: getGenieTVStats(eventId)
|
||||||
|
activate ChannelService
|
||||||
|
|
||||||
|
ChannelService -> CB: execute("genieTV", () -> callAPI())
|
||||||
|
activate CB
|
||||||
|
|
||||||
|
CB -> CB: 외부 API 호출\nGET /campaign/{id}/stats
|
||||||
|
|
||||||
|
alt 정상 응답
|
||||||
|
CB --> ChannelService: ChannelStats\n- adViews: 10000\n- clicks: 500
|
||||||
|
deactivate CB
|
||||||
|
ChannelService --> Service: GenieTVStats
|
||||||
|
deactivate ChannelService
|
||||||
|
else Timeout (10초 초과)
|
||||||
|
CB -> CB: **Timeout 처리**\n기본값 반환
|
||||||
|
note right of CB
|
||||||
|
Timeout 발생:
|
||||||
|
- 리소스 점유 방지
|
||||||
|
- Fallback으로 기본값 (0) 설정
|
||||||
|
- 알림: "지니TV 데이터 로딩 지연"
|
||||||
|
end note
|
||||||
|
CB --> ChannelService: 기본값 (0)
|
||||||
|
deactivate CB
|
||||||
|
ChannelService --> Service: GenieTVStats (기본값)
|
||||||
|
deactivate ChannelService
|
||||||
|
end
|
||||||
|
|
||||||
|
else
|
||||||
|
|
||||||
|
Service -> ChannelService: getRingoBizStats(eventId)
|
||||||
|
activate ChannelService
|
||||||
|
|
||||||
|
ChannelService -> CB: execute("ringoBiz", () -> callAPI())
|
||||||
|
activate CB
|
||||||
|
|
||||||
|
note right of CB
|
||||||
|
**Circuit Breaker**
|
||||||
|
- State: CLOSED (정상)
|
||||||
|
- Failure Rate: 50% 초과 시 OPEN
|
||||||
|
- Timeout: 10초
|
||||||
|
end note
|
||||||
|
|
||||||
|
CB -> CB: 외부 API 호출\nGET /voice-stats/{eventId}
|
||||||
|
|
||||||
|
alt 정상 응답
|
||||||
|
CB --> ChannelService: ChannelStats\n- calls: 3000\n- completed: 2500\n- avgDuration: 45초
|
||||||
|
deactivate CB
|
||||||
|
ChannelService --> Service: RingoBizStats
|
||||||
|
deactivate ChannelService
|
||||||
|
else Timeout 또는 장애
|
||||||
|
CB -> CB: **Fallback 실행**\n기본값 반환
|
||||||
|
note right of CB
|
||||||
|
링고비즈 API 장애:
|
||||||
|
- 기본값 (0) 반환
|
||||||
|
- 알림: "링고비즈 데이터 로딩 실패"
|
||||||
|
end note
|
||||||
|
CB --> ChannelService: 기본값 (0)
|
||||||
|
deactivate CB
|
||||||
|
ChannelService --> Service: RingoBizStats (기본값)
|
||||||
|
deactivate ChannelService
|
||||||
|
end
|
||||||
|
|
||||||
|
else
|
||||||
|
|
||||||
|
Service -> ChannelService: getSNSStats(eventId)
|
||||||
|
activate ChannelService
|
||||||
|
|
||||||
|
ChannelService -> CB: execute("SNS", () -> callAPIs())
|
||||||
|
activate CB
|
||||||
|
|
||||||
|
note right of CB
|
||||||
|
**SNS APIs 통합 호출**
|
||||||
|
- Instagram API
|
||||||
|
- Naver Blog API
|
||||||
|
- Kakao Channel API
|
||||||
|
- 3개 API 병렬 호출
|
||||||
|
end note
|
||||||
|
|
||||||
|
CB -> CB: 외부 APIs 호출\n(Instagram, Naver, Kakao)
|
||||||
|
|
||||||
|
alt 정상 응답
|
||||||
|
CB --> ChannelService: SNSStats\n- Instagram: likes 300, comments 50\n- Naver: views 2000\n- Kakao: shares 100
|
||||||
|
deactivate CB
|
||||||
|
ChannelService --> Service: SNSStats
|
||||||
|
deactivate ChannelService
|
||||||
|
else 장애 또는 Timeout
|
||||||
|
CB -> CB: **Fallback 실행**\n기본값 반환
|
||||||
|
note right of CB
|
||||||
|
SNS API 장애:
|
||||||
|
- 기본값 (0) 반환
|
||||||
|
- 알림: "SNS 데이터 로딩 실패"
|
||||||
|
end note
|
||||||
|
CB --> ChannelService: 기본값 (0)
|
||||||
|
deactivate CB
|
||||||
|
ChannelService --> Service: SNSStats (기본값)
|
||||||
|
deactivate ChannelService
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
|
||||||
|
|||
|
||||||
|
== 3. 데이터 통합 및 ROI 계산 ==
|
||||||
|
|
||||||
|
Service -> Service: mergeChannelStats(\n wooriTV, genieTV, ringoBiz, sns\n)
|
||||||
|
|
||||||
|
note right of Service
|
||||||
|
**데이터 통합**
|
||||||
|
- 총 노출 수 = 외부 채널 노출 합계
|
||||||
|
- 총 참여자 수 = Analytics DB
|
||||||
|
- 채널별 전환율 = 참여자 수 / 노출 수
|
||||||
|
- 링고비즈: 통화 완료 수 포함
|
||||||
|
end note
|
||||||
|
|
||||||
|
Service -> Calculator: calculateROI(\n eventStats, channelStats\n)
|
||||||
|
activate Calculator
|
||||||
|
|
||||||
|
note right of Calculator
|
||||||
|
**ROI 계산 로직**
|
||||||
|
총 비용 = 경품 비용 + 플랫폼 비용
|
||||||
|
예상 수익 = 매출 증가액 + 신규 고객 LTV
|
||||||
|
ROI = (수익 - 비용) / 비용 × 100
|
||||||
|
end note
|
||||||
|
|
||||||
|
Calculator --> Service: ROIData\n- roi: 250%\n- totalCost: 100만원\n- totalRevenue: 350만원\n- breakEvenPoint: 달성
|
||||||
|
deactivate Calculator
|
||||||
|
|
||||||
|
Service -> Service: buildDashboardData(\n eventStats, channelStats, roiData\n)
|
||||||
|
|
||||||
|
note right of Service
|
||||||
|
**대시보드 데이터 구조 생성**
|
||||||
|
- 4개 요약 카드
|
||||||
|
- 채널별 성과 차트 데이터
|
||||||
|
- 시간대별 참여 추이
|
||||||
|
- 참여자 프로필 분석
|
||||||
|
- 비교 분석 (업종 평균, 이전 이벤트)
|
||||||
|
end note
|
||||||
|
|
||||||
|
|||
|
||||||
|
== 4. Redis 캐싱 및 응답 ==
|
||||||
|
|
||||||
|
Service -> Cache: set(\n "analytics:dashboard:{eventId}",\n dashboardData,\n TTL=3600\n)
|
||||||
|
activate Cache
|
||||||
|
|
||||||
|
Cache -> Redis: 캐시 저장\nSET analytics:dashboard:{eventId}\nvalue={통합 데이터}\nEX 3600 (1시간)
|
||||||
|
activate Redis
|
||||||
|
|
||||||
|
Redis --> Cache: OK (저장 완료)
|
||||||
|
deactivate Redis
|
||||||
|
|
||||||
|
Cache --> Service: OK (캐싱 완료)
|
||||||
|
deactivate Cache
|
||||||
|
|
||||||
|
note right of Service
|
||||||
|
**캐싱 완료**
|
||||||
|
- TTL: 3600초 (1시간)
|
||||||
|
- 다음 조회 시 Cache HIT
|
||||||
|
- 예상 크기: 5KB
|
||||||
|
- 갱신 주기: 1시간마다 새 데이터 조회
|
||||||
|
end note
|
||||||
|
|
||||||
|
Service --> Controller: DashboardResponse\n(200 OK)
|
||||||
|
deactivate Service
|
||||||
|
|
||||||
|
Controller --> : 200 OK\nDashboard Data (JSON)
|
||||||
|
deactivate Controller
|
||||||
|
|
||||||
|
note over Controller, DB
|
||||||
|
**Cache MISS 시나리오 성능**
|
||||||
|
- 응답 시간: 약 3초
|
||||||
|
- Analytics DB 조회: 0.1초
|
||||||
|
- 외부 API 병렬 호출: 2초 (병렬 처리)
|
||||||
|
- ROI 계산: 0.05초
|
||||||
|
- Redis 캐싱: 0.01초
|
||||||
|
- 직렬화/HTTP: 0.84초
|
||||||
|
end note
|
||||||
|
|
||||||
|
end
|
||||||
|
|
||||||
|
@enduml
|
||||||
168
design/backend/sequence/inner/analytics-배포완료구독.puml
Normal file
168
design/backend/sequence/inner/analytics-배포완료구독.puml
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
@startuml analytics-배포완료구독
|
||||||
|
!theme mono
|
||||||
|
|
||||||
|
title Analytics Service - DistributionCompleted 이벤트 구독 처리 내부 시퀀스\n(Kafka Event 구독)
|
||||||
|
|
||||||
|
participant "Kafka Consumer" as Consumer
|
||||||
|
participant "DistributionCompletedListener" as Listener
|
||||||
|
participant "AnalyticsService" as Service
|
||||||
|
participant "AnalyticsRepository" as Repository
|
||||||
|
participant "CacheService" as Cache
|
||||||
|
participant "Redis" as Redis
|
||||||
|
database "Analytics DB" as DB
|
||||||
|
|
||||||
|
note over Consumer
|
||||||
|
**Kafka Consumer 설정**
|
||||||
|
- Topic: DistributionCompleted
|
||||||
|
- Consumer Group: analytics-service
|
||||||
|
- Partition Key: eventId
|
||||||
|
- At-Least-Once Delivery 보장
|
||||||
|
end note
|
||||||
|
|
||||||
|
Kafka -> Consumer: DistributionCompleted 이벤트 수신\n{\n eventId: "uuid",\n distributedChannels: [\n {\n channel: "우리동네TV",\n status: "SUCCESS",\n expectedViews: 5000\n },\n {\n channel: "지니TV",\n status: "SUCCESS",\n expectedViews: 10000\n },\n {\n channel: "Instagram",\n status: "SUCCESS",\n expectedViews: 2000\n }\n ],\n completedAt: "2025-10-22T12:00:00Z"\n}
|
||||||
|
activate Consumer
|
||||||
|
|
||||||
|
Consumer -> Listener: onDistributionCompleted(event)
|
||||||
|
activate Listener
|
||||||
|
|
||||||
|
note right of Listener
|
||||||
|
**멱등성 체크**
|
||||||
|
- Redis Set에 이벤트 ID 존재 여부 확인
|
||||||
|
- 중복 처리 방지
|
||||||
|
- Key: distribution_completed:{eventId}
|
||||||
|
end note
|
||||||
|
|
||||||
|
Listener -> Redis: SISMEMBER distribution_completed {eventId}
|
||||||
|
activate Redis
|
||||||
|
|
||||||
|
alt 이벤트 미처리 (멱등성 보장)
|
||||||
|
Redis --> Listener: false (미처리)
|
||||||
|
deactivate Redis
|
||||||
|
|
||||||
|
Listener -> Service: updateDistributionStats(event)
|
||||||
|
activate Service
|
||||||
|
|
||||||
|
note right of Service
|
||||||
|
**배포 채널 통계 저장**
|
||||||
|
- 채널별 배포 상태 기록
|
||||||
|
- 예상 노출 수 집계
|
||||||
|
- 배포 완료 시각 기록
|
||||||
|
end note
|
||||||
|
|
||||||
|
Service -> Service: parseChannelStats(event)
|
||||||
|
|
||||||
|
note right of Service
|
||||||
|
**채널 데이터 파싱**
|
||||||
|
- distributedChannels 배열 순회
|
||||||
|
- 각 채널별 통계 추출
|
||||||
|
- 총 예상 노출 수 계산
|
||||||
|
end note
|
||||||
|
|
||||||
|
loop 각 채널별로
|
||||||
|
Service -> Repository: saveChannelStats(\n eventId, channel, stats\n)
|
||||||
|
activate Repository
|
||||||
|
|
||||||
|
Repository -> DB: 채널별 통계 저장\n(이벤트ID, 채널명, 상태,\n예상노출수, 배포일시 저장,\n중복 시 업데이트)
|
||||||
|
activate DB
|
||||||
|
|
||||||
|
DB --> Repository: 1 row inserted/updated
|
||||||
|
deactivate DB
|
||||||
|
|
||||||
|
Repository --> Service: ChannelStatsEntity
|
||||||
|
deactivate Repository
|
||||||
|
end
|
||||||
|
|
||||||
|
note right of Service
|
||||||
|
**배포 통계 저장 완료**
|
||||||
|
- 채널별 배포 상태 기록
|
||||||
|
- 예상 노출 수 저장
|
||||||
|
- 향후 외부 API 조회 시 기준 데이터로 활용
|
||||||
|
end note
|
||||||
|
|
||||||
|
Service -> Repository: updateTotalViews(eventId, totalViews)
|
||||||
|
activate Repository
|
||||||
|
|
||||||
|
Repository -> DB: 총 노출 수 업데이트\n(총 예상 노출 수를 설정하고,\n수정일시를 현재 시각으로 업데이트)
|
||||||
|
activate DB
|
||||||
|
|
||||||
|
DB --> Repository: 1 row updated
|
||||||
|
deactivate DB
|
||||||
|
|
||||||
|
Repository --> Service: UpdateResult (success)
|
||||||
|
deactivate Repository
|
||||||
|
|
||||||
|
note right of Service
|
||||||
|
**이벤트 통계 업데이트**
|
||||||
|
- 총 예상 노출 수 업데이트
|
||||||
|
- 다음 대시보드 조회 시 반영
|
||||||
|
end note
|
||||||
|
|
||||||
|
Service -> Cache: delete("analytics:dashboard:{eventId}")
|
||||||
|
activate Cache
|
||||||
|
|
||||||
|
note right of Cache
|
||||||
|
**캐시 무효화**
|
||||||
|
- 기존 캐시 삭제
|
||||||
|
- 다음 조회 시 최신 배포 통계 반영
|
||||||
|
- 채널별 성과 차트 갱신
|
||||||
|
end note
|
||||||
|
|
||||||
|
Cache -> Redis: DEL analytics:dashboard:{eventId}
|
||||||
|
activate Redis
|
||||||
|
|
||||||
|
Redis --> Cache: OK
|
||||||
|
deactivate Redis
|
||||||
|
|
||||||
|
Cache --> Service: OK
|
||||||
|
deactivate Cache
|
||||||
|
|
||||||
|
Service -> Redis: SADD distribution_completed {eventId}
|
||||||
|
activate Redis
|
||||||
|
|
||||||
|
note right of Redis
|
||||||
|
**멱등성 처리 완료 기록**
|
||||||
|
- Redis Set에 eventId 추가
|
||||||
|
- TTL 설정 (7일)
|
||||||
|
end note
|
||||||
|
|
||||||
|
Redis --> Service: OK
|
||||||
|
deactivate Redis
|
||||||
|
|
||||||
|
Service --> Listener: 배포 통계 업데이트 완료
|
||||||
|
deactivate Service
|
||||||
|
|
||||||
|
Listener -> Consumer: ACK (처리 완료)
|
||||||
|
deactivate Listener
|
||||||
|
|
||||||
|
else 이벤트 이미 처리됨 (중복)
|
||||||
|
Redis --> Listener: true (이미 처리)
|
||||||
|
deactivate Redis
|
||||||
|
|
||||||
|
note right of Listener
|
||||||
|
**중복 이벤트 스킵**
|
||||||
|
- At-Least-Once Delivery로 인한 중복
|
||||||
|
- 멱등성 보장으로 중복 처리 방지
|
||||||
|
end note
|
||||||
|
|
||||||
|
Listener -> Consumer: ACK (스킵)
|
||||||
|
deactivate Listener
|
||||||
|
end
|
||||||
|
|
||||||
|
Consumer --> Kafka: Commit Offset
|
||||||
|
deactivate Consumer
|
||||||
|
|
||||||
|
note over Consumer, DB
|
||||||
|
**처리 시간**
|
||||||
|
- 이벤트 수신 → 통계 업데이트 완료: 약 0.3초
|
||||||
|
- 채널별 DB INSERT (3개): 0.15초
|
||||||
|
- event_stats UPDATE: 0.05초
|
||||||
|
- Redis 캐시 무효화: 0.01초
|
||||||
|
- 멱등성 체크: 0.01초
|
||||||
|
|
||||||
|
**배포 통계 효과**
|
||||||
|
- 배포 완료 즉시 통계 반영
|
||||||
|
- 채널별 성과 추적 가능
|
||||||
|
- 다음 대시보드 조회 시 최신 배포 정보 제공
|
||||||
|
end note
|
||||||
|
|
||||||
|
@enduml
|
||||||
134
design/backend/sequence/inner/analytics-이벤트생성구독.puml
Normal file
134
design/backend/sequence/inner/analytics-이벤트생성구독.puml
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
@startuml analytics-이벤트생성구독
|
||||||
|
!theme mono
|
||||||
|
|
||||||
|
title Analytics Service - EventCreated 이벤트 구독 처리 내부 시퀀스\n(Kafka Event 구독)
|
||||||
|
|
||||||
|
participant "Kafka Consumer" as Consumer
|
||||||
|
participant "EventCreatedListener" as Listener
|
||||||
|
participant "AnalyticsService" as Service
|
||||||
|
participant "AnalyticsRepository" as Repository
|
||||||
|
participant "CacheService" as Cache
|
||||||
|
participant "Redis" as Redis
|
||||||
|
database "Analytics DB" as DB
|
||||||
|
|
||||||
|
note over Consumer
|
||||||
|
**Kafka Consumer 설정**
|
||||||
|
- Topic: EventCreated
|
||||||
|
- Consumer Group: analytics-service
|
||||||
|
- Partition Key: eventId
|
||||||
|
- At-Least-Once Delivery 보장
|
||||||
|
end note
|
||||||
|
|
||||||
|
Kafka -> Consumer: EventCreated 이벤트 수신\n{\n eventId: "uuid",\n storeId: "uuid",\n title: "이벤트 제목",\n objective: "신규 고객 유치",\n createdAt: "2025-10-22T10:00:00Z"\n}
|
||||||
|
activate Consumer
|
||||||
|
|
||||||
|
Consumer -> Listener: onEventCreated(event)
|
||||||
|
activate Listener
|
||||||
|
|
||||||
|
note right of Listener
|
||||||
|
**멱등성 체크**
|
||||||
|
- Redis Set에 이벤트 ID 존재 여부 확인
|
||||||
|
- 중복 처리 방지
|
||||||
|
end note
|
||||||
|
|
||||||
|
Listener -> Redis: SISMEMBER processed_events {eventId}
|
||||||
|
activate Redis
|
||||||
|
|
||||||
|
alt 이벤트 미처리 (멱등성 보장)
|
||||||
|
Redis --> Listener: false (미처리)
|
||||||
|
deactivate Redis
|
||||||
|
|
||||||
|
Listener -> Service: initializeEventStats(event)
|
||||||
|
activate Service
|
||||||
|
|
||||||
|
note right of Service
|
||||||
|
**이벤트 통계 초기화**
|
||||||
|
- 이벤트 기본 정보 저장
|
||||||
|
- 통계 초기값 설정
|
||||||
|
* 총 참여자 수: 0
|
||||||
|
* 총 노출 수: 0
|
||||||
|
* 예상 ROI: 계산 전
|
||||||
|
* 매출 증가율: 0%
|
||||||
|
end note
|
||||||
|
|
||||||
|
Service -> Repository: save(eventStatsEntity)
|
||||||
|
activate Repository
|
||||||
|
|
||||||
|
Repository -> DB: 이벤트 통계 초기화\n(이벤트ID, 매장ID, 제목, 목적,\n참여자수/노출수/ROI/매출증가율을\n0으로 초기화하여 저장)
|
||||||
|
activate DB
|
||||||
|
|
||||||
|
DB --> Repository: 1 row inserted
|
||||||
|
deactivate DB
|
||||||
|
|
||||||
|
Repository --> Service: EventStatsEntity
|
||||||
|
deactivate Repository
|
||||||
|
|
||||||
|
note right of Service
|
||||||
|
**초기화 완료**
|
||||||
|
- 이벤트 통계 DB 생성
|
||||||
|
- 향후 ParticipantRegistered 이벤트 수신 시
|
||||||
|
실시간 증가
|
||||||
|
end note
|
||||||
|
|
||||||
|
Service -> Cache: delete("analytics:dashboard:{eventId}")
|
||||||
|
activate Cache
|
||||||
|
|
||||||
|
note right of Cache
|
||||||
|
**캐시 무효화**
|
||||||
|
- 기존 캐시 삭제
|
||||||
|
- 다음 조회 시 최신 데이터 갱신
|
||||||
|
end note
|
||||||
|
|
||||||
|
Cache -> Redis: DEL analytics:dashboard:{eventId}
|
||||||
|
activate Redis
|
||||||
|
|
||||||
|
Redis --> Cache: OK
|
||||||
|
deactivate Redis
|
||||||
|
|
||||||
|
Cache --> Service: OK
|
||||||
|
deactivate Cache
|
||||||
|
|
||||||
|
Service -> Redis: SADD processed_events {eventId}
|
||||||
|
activate Redis
|
||||||
|
|
||||||
|
note right of Redis
|
||||||
|
**멱등성 처리 완료 기록**
|
||||||
|
- Redis Set에 eventId 추가
|
||||||
|
- TTL 설정 (7일)
|
||||||
|
end note
|
||||||
|
|
||||||
|
Redis --> Service: OK
|
||||||
|
deactivate Redis
|
||||||
|
|
||||||
|
Service --> Listener: EventStats 초기화 완료
|
||||||
|
deactivate Service
|
||||||
|
|
||||||
|
Listener -> Consumer: ACK (처리 완료)
|
||||||
|
deactivate Listener
|
||||||
|
|
||||||
|
else 이벤트 이미 처리됨 (중복)
|
||||||
|
Redis --> Listener: true (이미 처리)
|
||||||
|
deactivate Redis
|
||||||
|
|
||||||
|
note right of Listener
|
||||||
|
**중복 이벤트 스킵**
|
||||||
|
- At-Least-Once Delivery로 인한 중복
|
||||||
|
- 멱등성 보장으로 중복 처리 방지
|
||||||
|
end note
|
||||||
|
|
||||||
|
Listener -> Consumer: ACK (스킵)
|
||||||
|
deactivate Listener
|
||||||
|
end
|
||||||
|
|
||||||
|
Consumer --> Kafka: Commit Offset
|
||||||
|
deactivate Consumer
|
||||||
|
|
||||||
|
note over Consumer, DB
|
||||||
|
**처리 시간**
|
||||||
|
- 이벤트 수신 → 초기화 완료: 약 0.2초
|
||||||
|
- DB INSERT: 0.05초
|
||||||
|
- Redis 캐시 무효화: 0.01초
|
||||||
|
- 멱등성 체크: 0.01초
|
||||||
|
end note
|
||||||
|
|
||||||
|
@enduml
|
||||||
135
design/backend/sequence/inner/analytics-참여자등록구독.puml
Normal file
135
design/backend/sequence/inner/analytics-참여자등록구독.puml
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
@startuml analytics-참여자등록구독
|
||||||
|
!theme mono
|
||||||
|
|
||||||
|
title Analytics Service - ParticipantRegistered 이벤트 구독 처리 내부 시퀀스\n(Kafka Event 구독)
|
||||||
|
|
||||||
|
participant "Kafka Consumer" as Consumer
|
||||||
|
participant "ParticipantRegisteredListener" as Listener
|
||||||
|
participant "AnalyticsService" as Service
|
||||||
|
participant "AnalyticsRepository" as Repository
|
||||||
|
participant "CacheService" as Cache
|
||||||
|
participant "Redis" as Redis
|
||||||
|
database "Analytics DB" as DB
|
||||||
|
|
||||||
|
note over Consumer
|
||||||
|
**Kafka Consumer 설정**
|
||||||
|
- Topic: ParticipantRegistered
|
||||||
|
- Consumer Group: analytics-service
|
||||||
|
- Partition Key: eventId
|
||||||
|
- At-Least-Once Delivery 보장
|
||||||
|
end note
|
||||||
|
|
||||||
|
Kafka -> Consumer: ParticipantRegistered 이벤트 수신\n{\n participantId: "uuid",\n eventId: "uuid",\n phoneNumber: "010-1234-5678",\n registeredAt: "2025-10-22T11:30:00Z"\n}
|
||||||
|
activate Consumer
|
||||||
|
|
||||||
|
Consumer -> Listener: onParticipantRegistered(event)
|
||||||
|
activate Listener
|
||||||
|
|
||||||
|
note right of Listener
|
||||||
|
**멱등성 체크**
|
||||||
|
- Redis Set에 participantId 존재 여부 확인
|
||||||
|
- 중복 처리 방지
|
||||||
|
end note
|
||||||
|
|
||||||
|
Listener -> Redis: SISMEMBER processed_participants {participantId}
|
||||||
|
activate Redis
|
||||||
|
|
||||||
|
alt 이벤트 미처리 (멱등성 보장)
|
||||||
|
Redis --> Listener: false (미처리)
|
||||||
|
deactivate Redis
|
||||||
|
|
||||||
|
Listener -> Service: updateParticipantCount(eventId)
|
||||||
|
activate Service
|
||||||
|
|
||||||
|
note right of Service
|
||||||
|
**참여자 수 실시간 증가**
|
||||||
|
- DB UPDATE로 참여자 수 증가
|
||||||
|
- 캐시 무효화로 다음 조회 시 최신 데이터 반영
|
||||||
|
end note
|
||||||
|
|
||||||
|
Service -> Repository: incrementParticipantCount(eventId)
|
||||||
|
activate Repository
|
||||||
|
|
||||||
|
Repository -> DB: 참여자 수 증가\n(참여자 수를 1 증가시키고,\n수정일시를 현재 시각으로 업데이트)
|
||||||
|
activate DB
|
||||||
|
|
||||||
|
DB --> Repository: 1 row updated
|
||||||
|
deactivate DB
|
||||||
|
|
||||||
|
Repository --> Service: UpdateResult (success)
|
||||||
|
deactivate Repository
|
||||||
|
|
||||||
|
note right of Service
|
||||||
|
**실시간 통계 업데이트 완료**
|
||||||
|
- 참여자 수 +1
|
||||||
|
- 다음 대시보드 조회 시 최신 통계 반영
|
||||||
|
end note
|
||||||
|
|
||||||
|
Service -> Cache: delete("analytics:dashboard:{eventId}")
|
||||||
|
activate Cache
|
||||||
|
|
||||||
|
note right of Cache
|
||||||
|
**캐시 무효화**
|
||||||
|
- 기존 캐시 삭제
|
||||||
|
- 다음 조회 시 최신 참여자 수 반영
|
||||||
|
- Cache MISS 시 DB 조회로 최신 데이터 확보
|
||||||
|
end note
|
||||||
|
|
||||||
|
Cache -> Redis: DEL analytics:dashboard:{eventId}
|
||||||
|
activate Redis
|
||||||
|
|
||||||
|
Redis --> Cache: OK
|
||||||
|
deactivate Redis
|
||||||
|
|
||||||
|
Cache --> Service: OK
|
||||||
|
deactivate Cache
|
||||||
|
|
||||||
|
Service -> Redis: SADD processed_participants {participantId}
|
||||||
|
activate Redis
|
||||||
|
|
||||||
|
note right of Redis
|
||||||
|
**멱등성 처리 완료 기록**
|
||||||
|
- Redis Set에 participantId 추가
|
||||||
|
- TTL 설정 (7일)
|
||||||
|
end note
|
||||||
|
|
||||||
|
Redis --> Service: OK
|
||||||
|
deactivate Redis
|
||||||
|
|
||||||
|
Service --> Listener: 참여자 수 업데이트 완료
|
||||||
|
deactivate Service
|
||||||
|
|
||||||
|
Listener -> Consumer: ACK (처리 완료)
|
||||||
|
deactivate Listener
|
||||||
|
|
||||||
|
else 이벤트 이미 처리됨 (중복)
|
||||||
|
Redis --> Listener: true (이미 처리)
|
||||||
|
deactivate Redis
|
||||||
|
|
||||||
|
note right of Listener
|
||||||
|
**중복 이벤트 스킵**
|
||||||
|
- At-Least-Once Delivery로 인한 중복
|
||||||
|
- 멱등성 보장으로 중복 처리 방지
|
||||||
|
end note
|
||||||
|
|
||||||
|
Listener -> Consumer: ACK (스킵)
|
||||||
|
deactivate Listener
|
||||||
|
end
|
||||||
|
|
||||||
|
Consumer --> Kafka: Commit Offset
|
||||||
|
deactivate Consumer
|
||||||
|
|
||||||
|
note over Consumer, DB
|
||||||
|
**처리 시간**
|
||||||
|
- 이벤트 수신 → 통계 업데이트 완료: 약 0.15초
|
||||||
|
- DB UPDATE: 0.05초
|
||||||
|
- Redis 캐시 무효화: 0.01초
|
||||||
|
- 멱등성 체크: 0.01초
|
||||||
|
|
||||||
|
**실시간 업데이트 효과**
|
||||||
|
- 참여자 등록 즉시 통계 반영
|
||||||
|
- 다음 대시보드 조회 시 최신 데이터 제공
|
||||||
|
- Cache-Aside 패턴으로 성능 유지
|
||||||
|
end note
|
||||||
|
|
||||||
|
@enduml
|
||||||
140
design/backend/sequence/inner/content-이미지결과조회.puml
Normal file
140
design/backend/sequence/inner/content-이미지결과조회.puml
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
@startuml event-이미지결과조회
|
||||||
|
!theme mono
|
||||||
|
|
||||||
|
title Content Service - 이미지 생성 결과 폴링 조회
|
||||||
|
|
||||||
|
actor Client
|
||||||
|
participant "API Gateway" as Gateway
|
||||||
|
participant "ContentController" as Controller <<API Layer>>
|
||||||
|
participant "ContentService" as Service <<Business Layer>>
|
||||||
|
participant "JobManager" as JobMgr <<Component>>
|
||||||
|
participant "Redis Cache" as Cache <<E>>
|
||||||
|
|
||||||
|
note over Controller, Cache
|
||||||
|
**폴링 방식 Job 상태 조회**
|
||||||
|
- 최대 30초 동안 폴링 (2초 간격)
|
||||||
|
- Job 상태: PENDING → PROCESSING → COMPLETED
|
||||||
|
- 이미지 URL: Redis에 저장 (TTL: 7일)
|
||||||
|
end note
|
||||||
|
|
||||||
|
Client -> Gateway: GET /api/content/jobs/{jobId}/status
|
||||||
|
activate Gateway
|
||||||
|
|
||||||
|
Gateway -> Controller: GET /api/content/jobs/{jobId}/status
|
||||||
|
activate Controller
|
||||||
|
|
||||||
|
Controller -> Service: getJobStatus(jobId)
|
||||||
|
activate Service
|
||||||
|
|
||||||
|
Service -> JobMgr: getJobStatus(jobId)
|
||||||
|
activate JobMgr
|
||||||
|
|
||||||
|
JobMgr -> Cache: Job 상태 조회\nKey: job:{jobId}
|
||||||
|
activate Cache
|
||||||
|
|
||||||
|
alt Job 데이터 존재
|
||||||
|
Cache --> JobMgr: Job 데이터\n{status, eventDraftId,\ntype, createdAt}
|
||||||
|
deactivate Cache
|
||||||
|
|
||||||
|
alt status = COMPLETED
|
||||||
|
JobMgr -> Cache: 이미지 URL 조회\nKey: content:image:{eventDraftId}
|
||||||
|
activate Cache
|
||||||
|
Cache --> JobMgr: 이미지 URL\n{simple, fancy, trendy}
|
||||||
|
deactivate Cache
|
||||||
|
|
||||||
|
JobMgr --> Service: JobStatusResponse\n{jobId, status: COMPLETED,\nimageUrls: {...}}
|
||||||
|
deactivate JobMgr
|
||||||
|
|
||||||
|
Service --> Controller: JobStatusResponse\n{status: COMPLETED, imageUrls}
|
||||||
|
deactivate Service
|
||||||
|
|
||||||
|
Controller --> Gateway: 200 OK\n{"status": "COMPLETED",\n"imageUrls": {\n "simple": "https://cdn.../simple.png",\n "fancy": "https://cdn.../fancy.png",\n "trendy": "https://cdn.../trendy.png"\n}}
|
||||||
|
deactivate Controller
|
||||||
|
|
||||||
|
Gateway --> Client: 200 OK\n이미지 URL 반환
|
||||||
|
deactivate Gateway
|
||||||
|
|
||||||
|
note right of Client
|
||||||
|
**프론트엔드 처리**
|
||||||
|
- 3가지 스타일 카드 표시
|
||||||
|
- 사용자가 스타일 선택
|
||||||
|
- 이미지 편집 가능
|
||||||
|
end note
|
||||||
|
|
||||||
|
else status = PROCESSING 또는 PENDING
|
||||||
|
JobMgr --> Service: JobStatusResponse\n{jobId, status: PROCESSING}
|
||||||
|
deactivate JobMgr
|
||||||
|
|
||||||
|
Service --> Controller: JobStatusResponse\n{status: PROCESSING}
|
||||||
|
deactivate Service
|
||||||
|
|
||||||
|
Controller --> Gateway: 200 OK\n{"status": "PROCESSING",\n"message": "이미지 생성 중입니다"}
|
||||||
|
deactivate Controller
|
||||||
|
|
||||||
|
Gateway --> Client: 200 OK\n진행 중 상태
|
||||||
|
deactivate Gateway
|
||||||
|
|
||||||
|
note right of Client
|
||||||
|
**폴링 재시도**
|
||||||
|
- 2초 후 재요청
|
||||||
|
- 최대 30초 (15회)
|
||||||
|
end note
|
||||||
|
|
||||||
|
else status = FAILED
|
||||||
|
JobMgr -> Cache: 에러 정보 조회\nKey: job:{jobId}:error
|
||||||
|
activate Cache
|
||||||
|
Cache --> JobMgr: 에러 메시지
|
||||||
|
deactivate Cache
|
||||||
|
|
||||||
|
JobMgr --> Service: JobStatusResponse\n{jobId, status: FAILED, error}
|
||||||
|
deactivate JobMgr
|
||||||
|
|
||||||
|
Service --> Controller: JobStatusResponse\n{status: FAILED, error}
|
||||||
|
deactivate Service
|
||||||
|
|
||||||
|
Controller --> Gateway: 200 OK\n{"status": "FAILED",\n"error": "이미지 생성 실패",\n"message": "다시 시도해주세요"}
|
||||||
|
deactivate Controller
|
||||||
|
|
||||||
|
Gateway --> Client: 200 OK\n실패 상태
|
||||||
|
deactivate Gateway
|
||||||
|
|
||||||
|
note right of Client
|
||||||
|
**실패 처리**
|
||||||
|
- 에러 메시지 표시
|
||||||
|
- "다시 생성" 버튼 제공
|
||||||
|
end note
|
||||||
|
end
|
||||||
|
|
||||||
|
else Job 데이터 없음
|
||||||
|
Cache --> JobMgr: null (캐시 미스)
|
||||||
|
deactivate Cache
|
||||||
|
|
||||||
|
JobMgr --> Service: throw NotFoundException\n("Job을 찾을 수 없습니다")
|
||||||
|
deactivate JobMgr
|
||||||
|
|
||||||
|
Service --> Controller: NotFoundException
|
||||||
|
deactivate Service
|
||||||
|
|
||||||
|
Controller --> Gateway: 404 Not Found\n{"code": "JOB_001",\n"message": "Job을 찾을 수 없습니다"}
|
||||||
|
deactivate Controller
|
||||||
|
|
||||||
|
Gateway --> Client: 404 Not Found
|
||||||
|
deactivate Gateway
|
||||||
|
end
|
||||||
|
|
||||||
|
note over Controller, Cache
|
||||||
|
**폴링 전략**
|
||||||
|
- 간격: 2초
|
||||||
|
- 최대 시간: 30초 (15회)
|
||||||
|
- Timeout 시: 사용자에게 알림 + "다시 생성" 옵션
|
||||||
|
|
||||||
|
**Redis 캐시**
|
||||||
|
- Job 상태: TTL 1시간
|
||||||
|
- 이미지 URL: TTL 7일
|
||||||
|
|
||||||
|
**성능 목표**
|
||||||
|
- 평균 이미지 생성 시간: 20초 이내
|
||||||
|
- P95 이미지 생성 시간: 40초 이내
|
||||||
|
end note
|
||||||
|
|
||||||
|
@enduml
|
||||||
255
design/backend/sequence/inner/content-이미지생성.puml
Normal file
255
design/backend/sequence/inner/content-이미지생성.puml
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
@startuml content-이미지생성
|
||||||
|
!theme mono
|
||||||
|
|
||||||
|
title Content Service - 이미지 생성 내부 시퀀스 (UFR-CONT-010)
|
||||||
|
|
||||||
|
actor Client
|
||||||
|
participant "Kafka\nimage-job\nConsumer" as Consumer
|
||||||
|
participant "JobHandler" as Handler
|
||||||
|
participant "CacheManager" as Cache
|
||||||
|
participant "ImageGenerator" as Generator
|
||||||
|
participant "ImageStyleFactory" as Factory
|
||||||
|
participant "StableDiffusion\nAPI Client" as SDClient
|
||||||
|
participant "DALL-E\nAPI Client" as DALLEClient
|
||||||
|
participant "Circuit Breaker" as CB
|
||||||
|
participant "BlobStorage\nUploader" as BlobStorage
|
||||||
|
participant "JobStatusManager" as JobStatus
|
||||||
|
database "Redis Cache" as Redis
|
||||||
|
|
||||||
|
note over Consumer: Kafka 구독\nimage-job 토픽
|
||||||
|
|
||||||
|
== Kafka Job 수신 ==
|
||||||
|
Consumer -> Handler: Job Message 수신\n{jobId, eventDraftId, eventInfo}
|
||||||
|
activate Handler
|
||||||
|
|
||||||
|
Handler -> Cache: 캐시 조회\nkey: content:image:{eventDraftId}
|
||||||
|
activate Cache
|
||||||
|
Cache -> Redis: GET content:image:{eventDraftId}
|
||||||
|
Redis --> Cache: 캐시 데이터 또는 NULL
|
||||||
|
Cache --> Handler: 캐시 결과
|
||||||
|
deactivate Cache
|
||||||
|
|
||||||
|
alt 캐시 HIT (기존 이미지 존재)
|
||||||
|
Handler -> JobStatus: Job 상태 업데이트\nstatus: COMPLETED (캐시)
|
||||||
|
activate JobStatus
|
||||||
|
JobStatus -> Redis: SET job:{jobId}\n{status: COMPLETED, imageUrls: [...]}
|
||||||
|
JobStatus --> Handler: 업데이트 완료
|
||||||
|
deactivate JobStatus
|
||||||
|
Handler --> Consumer: 처리 완료 (캐시)
|
||||||
|
else 캐시 MISS (새로운 이미지 생성)
|
||||||
|
Handler -> JobStatus: Job 상태 업데이트\nstatus: PROCESSING
|
||||||
|
activate JobStatus
|
||||||
|
JobStatus -> Redis: SET job:{jobId}\n{status: PROCESSING}
|
||||||
|
JobStatus --> Handler: 업데이트 완료
|
||||||
|
deactivate JobStatus
|
||||||
|
|
||||||
|
Handler -> Generator: 3가지 스타일 이미지 생성 요청\n{eventInfo}
|
||||||
|
activate Generator
|
||||||
|
|
||||||
|
== 3가지 스타일 병렬 생성 (par 블록) ==
|
||||||
|
group parallel
|
||||||
|
Generator -> Factory: 심플 프롬프트 생성\n{eventInfo, style: SIMPLE}
|
||||||
|
activate Factory
|
||||||
|
Factory --> Generator: 심플 프롬프트
|
||||||
|
deactivate Factory
|
||||||
|
|
||||||
|
Generator -> CB: Circuit Breaker 체크
|
||||||
|
activate CB
|
||||||
|
CB --> Generator: State: CLOSED (정상)
|
||||||
|
deactivate CB
|
||||||
|
|
||||||
|
Generator -> SDClient: Stable Diffusion API 호출\n{prompt, style: SIMPLE}\nTimeout: 20초
|
||||||
|
activate SDClient
|
||||||
|
|
||||||
|
note over SDClient: Circuit Breaker 적용\nRetry: 최대 3회\nTimeout: 20초
|
||||||
|
|
||||||
|
alt API 성공
|
||||||
|
SDClient --> Generator: 심플 이미지 데이터
|
||||||
|
deactivate SDClient
|
||||||
|
Generator -> BlobStorage: Blob 업로드 요청\n{imageData, eventId, style: SIMPLE}\nRetry: 3회, Timeout: 30초
|
||||||
|
activate BlobStorage
|
||||||
|
note right of BlobStorage: SAS Token 생성\n(유효기간 7일)
|
||||||
|
BlobStorage --> Generator: Blob SAS URL (심플)
|
||||||
|
deactivate BlobStorage
|
||||||
|
else API 실패 (Timeout/Error)
|
||||||
|
SDClient --> Generator: 실패 응답
|
||||||
|
deactivate SDClient
|
||||||
|
Generator -> CB: 실패 기록
|
||||||
|
activate CB
|
||||||
|
CB -> CB: 실패율 계산
|
||||||
|
alt 실패율 > 50%
|
||||||
|
CB -> CB: Circuit State: OPEN
|
||||||
|
end
|
||||||
|
CB --> Generator: Circuit State
|
||||||
|
deactivate CB
|
||||||
|
|
||||||
|
Generator -> DALLEClient: Fallback - DALL-E API 호출\n{prompt, style: SIMPLE}\nTimeout: 20초
|
||||||
|
activate DALLEClient
|
||||||
|
alt Fallback 성공
|
||||||
|
DALLEClient --> Generator: 심플 이미지 데이터
|
||||||
|
deactivate DALLEClient
|
||||||
|
Generator -> BlobStorage: Blob 업로드 요청\n{imageData, eventId, style: SIMPLE}\nRetry: 3회, Timeout: 30초
|
||||||
|
activate BlobStorage
|
||||||
|
note right of BlobStorage: SAS Token 생성\n(유효기간 7일)
|
||||||
|
BlobStorage --> Generator: Blob SAS URL (심플)
|
||||||
|
deactivate BlobStorage
|
||||||
|
else Fallback 실패
|
||||||
|
DALLEClient --> Generator: 실패 응답
|
||||||
|
deactivate DALLEClient
|
||||||
|
Generator -> Generator: 기본 템플릿 사용\n(심플)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Generator -> Factory: 화려한 프롬프트 생성\n{eventInfo, style: FANCY}
|
||||||
|
activate Factory
|
||||||
|
Factory --> Generator: 화려한 프롬프트
|
||||||
|
deactivate Factory
|
||||||
|
|
||||||
|
Generator -> CB: Circuit Breaker 체크
|
||||||
|
activate CB
|
||||||
|
CB --> Generator: State: CLOSED/OPEN
|
||||||
|
deactivate CB
|
||||||
|
|
||||||
|
alt Circuit CLOSED
|
||||||
|
Generator -> SDClient: Stable Diffusion API 호출\n{prompt, style: FANCY}\nTimeout: 20초
|
||||||
|
activate SDClient
|
||||||
|
|
||||||
|
alt API 성공
|
||||||
|
SDClient --> Generator: 화려한 이미지 데이터
|
||||||
|
deactivate SDClient
|
||||||
|
Generator -> BlobStorage: Blob 업로드 요청\n{imageData, eventId, style: FANCY}\nRetry: 3회, Timeout: 30초
|
||||||
|
activate BlobStorage
|
||||||
|
note right of BlobStorage: SAS Token 생성\n(유효기간 7일)
|
||||||
|
BlobStorage --> Generator: Blob SAS URL (화려한)
|
||||||
|
deactivate BlobStorage
|
||||||
|
else API 실패
|
||||||
|
SDClient --> Generator: 실패 응답
|
||||||
|
deactivate SDClient
|
||||||
|
Generator -> DALLEClient: Fallback - DALL-E API 호출
|
||||||
|
activate DALLEClient
|
||||||
|
alt Fallback 성공
|
||||||
|
DALLEClient --> Generator: 화려한 이미지 데이터
|
||||||
|
deactivate DALLEClient
|
||||||
|
Generator -> BlobStorage: Blob 업로드\n{imageData, eventId, style: FANCY}\nRetry: 3회, Timeout: 30초
|
||||||
|
activate BlobStorage
|
||||||
|
note right of BlobStorage: SAS Token 생성\n(유효기간 7일)
|
||||||
|
BlobStorage --> Generator: Blob SAS URL (화려한)
|
||||||
|
deactivate BlobStorage
|
||||||
|
else Fallback 실패
|
||||||
|
DALLEClient --> Generator: 실패 응답
|
||||||
|
deactivate DALLEClient
|
||||||
|
Generator -> Generator: 기본 템플릿 사용\n(화려한)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else Circuit OPEN
|
||||||
|
Generator -> Generator: Circuit Open\n즉시 기본 템플릿 사용
|
||||||
|
end
|
||||||
|
|
||||||
|
Generator -> Factory: 트렌디 프롬프트 생성\n{eventInfo, style: TRENDY}
|
||||||
|
activate Factory
|
||||||
|
Factory --> Generator: 트렌디 프롬프트
|
||||||
|
deactivate Factory
|
||||||
|
|
||||||
|
Generator -> CB: Circuit Breaker 체크
|
||||||
|
activate CB
|
||||||
|
CB --> Generator: State: CLOSED/OPEN
|
||||||
|
deactivate CB
|
||||||
|
|
||||||
|
alt Circuit CLOSED
|
||||||
|
Generator -> SDClient: Stable Diffusion API 호출\n{prompt, style: TRENDY}\nTimeout: 20초
|
||||||
|
activate SDClient
|
||||||
|
|
||||||
|
alt API 성공
|
||||||
|
SDClient --> Generator: 트렌디 이미지 데이터
|
||||||
|
deactivate SDClient
|
||||||
|
Generator -> BlobStorage: Blob 업로드 요청\n{imageData, eventId, style: TRENDY}\nRetry: 3회, Timeout: 30초
|
||||||
|
activate BlobStorage
|
||||||
|
note right of BlobStorage: SAS Token 생성\n(유효기간 7일)
|
||||||
|
BlobStorage --> Generator: Blob SAS URL (트렌디)
|
||||||
|
deactivate BlobStorage
|
||||||
|
else API 실패
|
||||||
|
SDClient --> Generator: 실패 응답
|
||||||
|
deactivate SDClient
|
||||||
|
Generator -> DALLEClient: Fallback - DALL-E API 호출
|
||||||
|
activate DALLEClient
|
||||||
|
alt Fallback 성공
|
||||||
|
DALLEClient --> Generator: 트렌디 이미지 데이터
|
||||||
|
deactivate DALLEClient
|
||||||
|
Generator -> BlobStorage: Blob 업로드\n{imageData, eventId, style: TRENDY}\nRetry: 3회, Timeout: 30초
|
||||||
|
activate BlobStorage
|
||||||
|
note right of BlobStorage: SAS Token 생성\n(유효기간 7일)
|
||||||
|
BlobStorage --> Generator: Blob SAS URL (트렌디)
|
||||||
|
deactivate BlobStorage
|
||||||
|
else Fallback 실패
|
||||||
|
DALLEClient --> Generator: 실패 응답
|
||||||
|
deactivate DALLEClient
|
||||||
|
Generator -> Generator: 기본 템플릿 사용\n(트렌디)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else Circuit OPEN
|
||||||
|
Generator -> Generator: Circuit Open\n즉시 기본 템플릿 사용
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Generator --> Handler: 3가지 이미지 URL 반환\n{simple, fancy, trendy}
|
||||||
|
deactivate Generator
|
||||||
|
|
||||||
|
== 결과 캐싱 및 Job 완료 ==
|
||||||
|
Handler -> Cache: Blob SAS URL 캐싱\nkey: content:image:{eventDraftId}\nTTL: 7일
|
||||||
|
activate Cache
|
||||||
|
Cache -> Redis: SET content:image:{eventDraftId}\n{simple: SAS_URL, fancy: SAS_URL, trendy: SAS_URL}\nTTL: 604800 (7일)
|
||||||
|
Redis --> Cache: 저장 완료
|
||||||
|
Cache --> Handler: 캐싱 완료
|
||||||
|
deactivate Cache
|
||||||
|
|
||||||
|
Handler -> JobStatus: Job 상태 업데이트\nstatus: COMPLETED
|
||||||
|
activate JobStatus
|
||||||
|
JobStatus -> Redis: SET job:{jobId}\n{status: COMPLETED, imageUrls: [...]}
|
||||||
|
JobStatus --> Handler: 업데이트 완료
|
||||||
|
deactivate JobStatus
|
||||||
|
|
||||||
|
Handler --> Consumer: 처리 완료
|
||||||
|
note over Handler
|
||||||
|
Blob SAS URL은 Redis에만 저장됨
|
||||||
|
Event Service는 폴링을 통해
|
||||||
|
Redis에서 결과 조회
|
||||||
|
SAS Token 유효기간: 7일
|
||||||
|
end note
|
||||||
|
end
|
||||||
|
|
||||||
|
deactivate Handler
|
||||||
|
|
||||||
|
note over Consumer, Redis
|
||||||
|
**Resilience 패턴 적용**
|
||||||
|
- Circuit Breaker: 실패율 50% 초과 시 Open (AI API용)
|
||||||
|
- AI API Timeout: 20초
|
||||||
|
- Fallback: Stable Diffusion 실패 시 DALL-E, 모두 실패 시 기본 템플릿
|
||||||
|
- Blob Storage Retry: 최대 3회 (Exponential Backoff: 1s, 2s, 4s)
|
||||||
|
- Blob Storage Timeout: 30초 (대용량 이미지 고려)
|
||||||
|
- Cache-Aside: Redis 캐싱 (TTL 7일)
|
||||||
|
|
||||||
|
**처리 시간**
|
||||||
|
- 캐시 HIT: 0.1초
|
||||||
|
- 캐시 MISS: 5.2초 이내 (병렬 처리)
|
||||||
|
└─ AI 생성: 3-5초 + Blob 업로드: 0.15-0.3초
|
||||||
|
|
||||||
|
**병렬 처리**
|
||||||
|
- 3가지 스타일 동시 생성 (par 블록)
|
||||||
|
- 독립적인 스레드 풀 사용
|
||||||
|
|
||||||
|
**Blob Storage 업로드**
|
||||||
|
- Azure Blob Storage (Korea Central)
|
||||||
|
- SAS Token 기반 접근 제어 (읽기 전용)
|
||||||
|
- SAS Token 유효기간: 7일 (Redis TTL과 동기화)
|
||||||
|
- Public Access 비활성화 (보안 강화)
|
||||||
|
- Container: event-images
|
||||||
|
- URL 형식: https://{account}.blob.core.windows.net/event-images/{id}-{style}.png?{sas_token}
|
||||||
|
|
||||||
|
**보안**
|
||||||
|
- Storage Account Public Access 비활성화
|
||||||
|
- SAS Token 기반 URL 생성 (읽기 전용 권한)
|
||||||
|
- Firewall 규칙: K8s Cluster IP만 허용
|
||||||
|
- HTTPS 강제 (TLS 1.2 이상)
|
||||||
|
end note
|
||||||
|
|
||||||
|
@enduml
|
||||||
90
design/backend/sequence/inner/content-이미지생성요청.puml
Normal file
90
design/backend/sequence/inner/content-이미지생성요청.puml
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
@startuml event-이미지생성요청
|
||||||
|
!theme mono
|
||||||
|
|
||||||
|
title Content Service - 이미지 생성 요청 (UFR-CONT-010)
|
||||||
|
|
||||||
|
actor Client
|
||||||
|
participant "API Gateway" as Gateway
|
||||||
|
participant "ContentController" as Controller <<API Layer>>
|
||||||
|
participant "ContentService" as Service <<Business Layer>>
|
||||||
|
participant "JobManager" as JobMgr <<Component>>
|
||||||
|
participant "Redis Cache" as Cache <<E>>
|
||||||
|
|
||||||
|
note over Controller, Cache
|
||||||
|
**UFR-CONT-010: SNS 이미지 생성 요청**
|
||||||
|
- Kafka 사용 안 함 (내부 Job 관리)
|
||||||
|
- 백그라운드 워커가 비동기 처리
|
||||||
|
- Redis에서 AI 추천 데이터 읽기
|
||||||
|
- 3가지 스타일 이미지 생성 (심플, 화려한, 트렌디)
|
||||||
|
end note
|
||||||
|
|
||||||
|
Client -> Gateway: POST /api/content/images/{eventDraftId}/generate
|
||||||
|
activate Gateway
|
||||||
|
|
||||||
|
Gateway -> Controller: POST /api/content/images/{eventDraftId}/generate
|
||||||
|
activate Controller
|
||||||
|
|
||||||
|
Controller -> Controller: 요청 검증\n(eventDraftId 유효성)
|
||||||
|
|
||||||
|
Controller -> Service: generateImages(eventDraftId)
|
||||||
|
activate Service
|
||||||
|
|
||||||
|
== 1단계: Redis에서 AI 추천 데이터 확인 ==
|
||||||
|
|
||||||
|
Service -> Cache: AI 추천 데이터 조회\nKey: ai:event:{eventDraftId}
|
||||||
|
activate Cache
|
||||||
|
Cache --> Service: AI 추천 결과\n{선택된 추천안, 이벤트 정보}
|
||||||
|
deactivate Cache
|
||||||
|
|
||||||
|
alt AI 추천 데이터 없음
|
||||||
|
Service --> Controller: throw NotFoundException\n("AI 추천을 먼저 선택해주세요")
|
||||||
|
Controller --> Gateway: 404 Not Found\n{"code": "CONTENT_001",\n"message": "AI 추천을 먼저 선택해주세요"}
|
||||||
|
deactivate Service
|
||||||
|
deactivate Controller
|
||||||
|
Gateway --> Client: 404 Not Found
|
||||||
|
deactivate Gateway
|
||||||
|
|
||||||
|
else AI 추천 데이터 존재
|
||||||
|
|
||||||
|
== 2단계: Job 생성 ==
|
||||||
|
|
||||||
|
Service -> JobMgr: createJob(eventDraftId, imageGeneration)
|
||||||
|
activate JobMgr
|
||||||
|
|
||||||
|
JobMgr -> JobMgr: Job ID 생성 (UUID)
|
||||||
|
|
||||||
|
JobMgr -> Cache: Job 상태 저장\nKey: job:{jobId}\nValue: {status: PENDING,\neventDraftId, type: IMAGE_GEN,\ncreatedAt}\nTTL: 1시간
|
||||||
|
activate Cache
|
||||||
|
Cache --> JobMgr: 저장 완료
|
||||||
|
deactivate Cache
|
||||||
|
|
||||||
|
JobMgr --> Service: Job 생성 완료\n{jobId, status: PENDING}
|
||||||
|
deactivate JobMgr
|
||||||
|
|
||||||
|
== 3단계: 응답 반환 ==
|
||||||
|
|
||||||
|
Service --> Controller: JobResponse\n{jobId, status: PENDING}
|
||||||
|
deactivate Service
|
||||||
|
|
||||||
|
Controller --> Gateway: 202 Accepted\n{"jobId": "job-uuid-123",\n"status": "PENDING",\n"message": "이미지 생성 중입니다"}
|
||||||
|
deactivate Controller
|
||||||
|
|
||||||
|
Gateway --> Client: 202 Accepted\n이미지 생성 시작
|
||||||
|
deactivate Gateway
|
||||||
|
|
||||||
|
note over Service, Cache
|
||||||
|
**백그라운드 워커 처리**
|
||||||
|
- Redis 폴링 또는 스케줄러가 Job 감지
|
||||||
|
- content-이미지생성.puml 참조
|
||||||
|
- 외부 이미지 생성 API 호출 (병렬)
|
||||||
|
- Redis에 이미지 URL 저장
|
||||||
|
|
||||||
|
**상세 내용**
|
||||||
|
- 3가지 스타일 병렬 생성 (심플, 화려한, 트렌디)
|
||||||
|
- Circuit Breaker 적용 (Timeout: 5분)
|
||||||
|
- 결과: Redis Key: content:image:{eventDraftId}
|
||||||
|
- TTL: 7일
|
||||||
|
end note
|
||||||
|
end
|
||||||
|
|
||||||
|
@enduml
|
||||||
141
design/backend/sequence/inner/distribution-다중채널배포.puml
Normal file
141
design/backend/sequence/inner/distribution-다중채널배포.puml
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
@startuml distribution-다중채널배포-sprint2
|
||||||
|
!theme mono
|
||||||
|
|
||||||
|
title Distribution Service - 다중 채널 배포 Sprint 2 (UFR-DIST-010)
|
||||||
|
|
||||||
|
participant "Event Service" as EventSvc
|
||||||
|
participant "Distribution\nREST API" as API
|
||||||
|
participant "Distribution\nController" as Controller
|
||||||
|
participant "Distribution\nService" as Service
|
||||||
|
database "Distribution DB" as DB
|
||||||
|
queue "Kafka" as Kafka
|
||||||
|
|
||||||
|
== REST API 동기 호출 수신 ==
|
||||||
|
EventSvc -> API: POST /api/distribution/distribute\n{eventId, channels[], contentUrls}
|
||||||
|
activate API
|
||||||
|
|
||||||
|
API -> Controller: distributeToChannels(request)
|
||||||
|
activate Controller
|
||||||
|
|
||||||
|
Controller -> Service: executeDistribution(distributionRequest)
|
||||||
|
activate Service
|
||||||
|
|
||||||
|
Service -> DB: 배포 이력 초기화\n(이벤트ID, 상태를 PENDING으로 저장)
|
||||||
|
DB --> Service: 배포 이력 ID
|
||||||
|
|
||||||
|
note over Service: 배포 시작 상태로 변경
|
||||||
|
Service -> DB: 배포 이력 상태 업데이트\n(상태를 IN_PROGRESS로 변경)
|
||||||
|
|
||||||
|
== 다중 채널 배포 로그 기록 (Sprint 2: Mock 처리) ==
|
||||||
|
|
||||||
|
note over Service: Sprint 2: 실제 외부 API 호출 없이\n배포 결과만 기록
|
||||||
|
|
||||||
|
par 우리동네TV 배포
|
||||||
|
alt 채널 선택됨
|
||||||
|
Service -> Service: 우리동네TV 배포 처리\n(Mock: 즉시 성공 반환)
|
||||||
|
activate Service
|
||||||
|
|
||||||
|
note over Service: 배포 요청 검증\n- eventId 유효성\n- contentUrls 존재 여부
|
||||||
|
|
||||||
|
Service -> DB: 배포 채널 로그 저장\n(채널: 우리동네TV,\n상태: 성공, 배포ID,\n예상노출수 저장)
|
||||||
|
|
||||||
|
note over Service: Mock 결과:\n성공 (distributionId 생성)
|
||||||
|
|
||||||
|
deactivate Service
|
||||||
|
end
|
||||||
|
|
||||||
|
alt 링고비즈 선택됨
|
||||||
|
Service -> Service: 링고비즈 배포 처리\n(Mock: 즉시 성공 반환)
|
||||||
|
activate Service
|
||||||
|
|
||||||
|
note over Service: 배포 요청 검증\n- phoneNumber 형식\n- audioUrl 존재 여부
|
||||||
|
|
||||||
|
Service -> DB: 배포 채널 로그 저장\n(채널: 링고비즈,\n상태: 성공,\n업데이트 시각 저장)
|
||||||
|
|
||||||
|
note over Service: Mock 결과:\n성공 (timestamp 기록)
|
||||||
|
|
||||||
|
deactivate Service
|
||||||
|
end
|
||||||
|
|
||||||
|
alt 지니TV 선택됨
|
||||||
|
Service -> Service: 지니TV 배포 처리\n(Mock: 즉시 성공 반환)
|
||||||
|
activate Service
|
||||||
|
|
||||||
|
note over Service: 배포 요청 검증\n- region 유효성\n- schedule 형식\n- budget 범위
|
||||||
|
|
||||||
|
Service -> DB: 배포 채널 로그 저장\n(채널: 지니TV,\n상태: 성공, 광고ID,\n노출 스케줄 저장)
|
||||||
|
|
||||||
|
note over Service: Mock 결과:\n성공 (adId 생성)
|
||||||
|
|
||||||
|
deactivate Service
|
||||||
|
end
|
||||||
|
|
||||||
|
alt Instagram 선택됨
|
||||||
|
Service -> Service: Instagram 배포 처리\n(Mock: 즉시 성공 반환)
|
||||||
|
activate Service
|
||||||
|
|
||||||
|
note over Service: 배포 요청 검증\n- imageUrl 형식\n- caption 길이\n- hashtags 유효성
|
||||||
|
|
||||||
|
Service -> DB: 배포 채널 로그 저장\n(채널: Instagram,\n상태: 성공,\n포스트 URL/ID 저장)
|
||||||
|
|
||||||
|
note over Service: Mock 결과:\n성공 (postUrl, postId 생성)
|
||||||
|
|
||||||
|
deactivate Service
|
||||||
|
end
|
||||||
|
|
||||||
|
alt Naver Blog 선택됨
|
||||||
|
Service -> Service: Naver Blog 배포 처리\n(Mock: 즉시 성공 반환)
|
||||||
|
activate Service
|
||||||
|
|
||||||
|
note over Service: 배포 요청 검증\n- imageUrl 형식\n- content 길이
|
||||||
|
|
||||||
|
Service -> DB: 배포 채널 로그 저장\n(채널: NaverBlog,\n상태: 성공,\n포스트 URL 저장)
|
||||||
|
|
||||||
|
note over Service: Mock 결과:\n성공 (postUrl 생성)
|
||||||
|
|
||||||
|
deactivate Service
|
||||||
|
end
|
||||||
|
|
||||||
|
alt Kakao Channel 선택됨
|
||||||
|
Service -> Service: Kakao Channel 배포 처리\n(Mock: 즉시 성공 반환)
|
||||||
|
activate Service
|
||||||
|
|
||||||
|
note over Service: 배포 요청 검증\n- imageUrl 형식\n- message 길이
|
||||||
|
|
||||||
|
Service -> DB: 배포 채널 로그 저장\n(채널: KakaoChannel,\n상태: 성공,\n메시지 ID 저장)
|
||||||
|
|
||||||
|
note over Service: Mock 결과:\n성공 (messageId 생성)
|
||||||
|
|
||||||
|
deactivate Service
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
note over Service: 모든 채널 배포 완료\n(즉시 처리 - 외부 API 호출 없음)
|
||||||
|
|
||||||
|
== 배포 결과 집계 및 저장 ==
|
||||||
|
Service -> Service: 채널별 배포 결과 집계\n성공: [선택된 모든 채널]
|
||||||
|
|
||||||
|
note over Service: Sprint 2에서는\n모든 채널 배포가 성공으로 처리됨
|
||||||
|
|
||||||
|
Service -> DB: 배포 이력 상태 업데이트\n(상태를 COMPLETED로,\n완료일시를 현재 시각으로 설정)
|
||||||
|
|
||||||
|
== Kafka 이벤트 발행 ==
|
||||||
|
Service -> Kafka: Publish to event-topic\nDistributionCompleted\n{eventId, channels[], results[], completedAt}
|
||||||
|
note over Kafka: Analytics Service 구독\n실시간 통계 업데이트
|
||||||
|
|
||||||
|
== REST API 동기 응답 ==
|
||||||
|
Service --> Controller: 배포 완료 응답\n{status: COMPLETED, successChannels: [all]}
|
||||||
|
deactivate Service
|
||||||
|
|
||||||
|
Controller --> API: DistributionResponse\n{eventId, status: COMPLETED, results: [all success]}
|
||||||
|
deactivate Controller
|
||||||
|
|
||||||
|
API --> EventSvc: 200 OK\n{distributionId, status: COMPLETED, results[]}
|
||||||
|
deactivate API
|
||||||
|
|
||||||
|
note over EventSvc: 배포 완료 응답 수신\n이벤트 상태 업데이트\nAPPROVED → ACTIVE
|
||||||
|
|
||||||
|
== Sprint 2 제약사항 ==
|
||||||
|
note over Service: **Sprint 2 구현 범위**\n- 외부 API 호출 없음 (Mock 처리)\n- 모든 배포 요청은 성공으로 처리\n- 배포 로그만 DB에 기록\n- Circuit Breaker, Retry 미구현\n- 실패 처리 시나리오 미구현\n\n**Sprint 3 이후 구현 예정**\n- 실제 외부 채널 API 연동\n- Circuit Breaker 패턴 적용\n- Retry 로직 구현\n- 실패 처리 및 알림
|
||||||
|
|
||||||
|
@enduml
|
||||||
126
design/backend/sequence/inner/event-AI추천요청.puml
Normal file
126
design/backend/sequence/inner/event-AI추천요청.puml
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
@startuml event-AI추천요청
|
||||||
|
!theme mono
|
||||||
|
|
||||||
|
title Event Service - AI 추천 요청 (Kafka Job 발행) (UFR-EVENT-030)
|
||||||
|
|
||||||
|
actor Client
|
||||||
|
participant "API Gateway" as Gateway
|
||||||
|
participant "EventController" as Controller <<API Layer>>
|
||||||
|
participant "EventService" as Service <<Business Layer>>
|
||||||
|
participant "JobService" as JobSvc <<Business Layer>>
|
||||||
|
participant "EventRepository" as Repo <<Data Layer>>
|
||||||
|
participant "Redis Cache" as Cache <<E>>
|
||||||
|
database "Event DB" as DB <<E>>
|
||||||
|
participant "Kafka Producer" as Kafka <<E>>
|
||||||
|
|
||||||
|
note over Controller, Kafka
|
||||||
|
**UFR-EVENT-030: AI 이벤트 추천 요청**
|
||||||
|
- Kafka 비동기 Job 발행
|
||||||
|
- AI Service가 Kafka 구독하여 처리
|
||||||
|
- 트렌드 분석 + 3가지 추천안 생성
|
||||||
|
- 처리 시간: 평균 2분 이내
|
||||||
|
end note
|
||||||
|
|
||||||
|
Client -> Gateway: POST /api/events/{eventDraftId}/ai-recommendations\n{"objective": "신규 고객 유치",\n"industry": "음식점",\n"region": "서울 강남구"}
|
||||||
|
activate Gateway
|
||||||
|
|
||||||
|
Gateway -> Controller: POST /api/events/{eventDraftId}/ai-recommendations
|
||||||
|
activate Controller
|
||||||
|
|
||||||
|
Controller -> Controller: 요청 검증\n(필수 필드, 목적 유효성)
|
||||||
|
|
||||||
|
Controller -> Service: requestAIRecommendation(eventDraftId, userId)
|
||||||
|
activate Service
|
||||||
|
|
||||||
|
== 1단계: 이벤트 초안 조회 및 검증 ==
|
||||||
|
|
||||||
|
Service -> Repo: findById(eventDraftId)
|
||||||
|
activate Repo
|
||||||
|
Repo -> DB: 이벤트 초안 조회\n(초안ID로 이벤트 목적,\n매장 정보 조회)
|
||||||
|
activate DB
|
||||||
|
DB --> Repo: EventDraft 엔티티\n{목적, 매장명, 업종, 주소}
|
||||||
|
deactivate DB
|
||||||
|
Repo --> Service: EventDraft entity
|
||||||
|
deactivate Repo
|
||||||
|
|
||||||
|
Service -> Service: 소유권 검증\nvalidateOwnership(userId, eventDraft)
|
||||||
|
|
||||||
|
alt 소유권 없음
|
||||||
|
Service --> Controller: throw ForbiddenException\n("권한이 없습니다")
|
||||||
|
Controller --> Gateway: 403 Forbidden\n{"code": "EVENT_003",\n"message": "권한이 없습니다"}
|
||||||
|
deactivate Service
|
||||||
|
deactivate Controller
|
||||||
|
Gateway --> Client: 403 Forbidden
|
||||||
|
deactivate Gateway
|
||||||
|
|
||||||
|
else 소유권 확인
|
||||||
|
|
||||||
|
== 2단계: Kafka Job 생성 ==
|
||||||
|
|
||||||
|
Service -> JobSvc: createAIJob(eventDraft)
|
||||||
|
activate JobSvc
|
||||||
|
|
||||||
|
JobSvc -> JobSvc: Job ID 생성 (UUID)
|
||||||
|
|
||||||
|
JobSvc -> Cache: Job 상태 저장\nKey: job:{jobId}\nValue: {status: PENDING,\neventDraftId, type: AI_RECOMMEND,\ncreatedAt}\nTTL: 1시간
|
||||||
|
activate Cache
|
||||||
|
Cache --> JobSvc: 저장 완료
|
||||||
|
deactivate Cache
|
||||||
|
|
||||||
|
== 3단계: Kafka 이벤트 발행 ==
|
||||||
|
|
||||||
|
JobSvc -> Kafka: 이벤트 발행\nTopic: ai-job-topic\nPayload: {jobId, eventDraftId,\nobjective, industry,\nregion, storeInfo}
|
||||||
|
activate Kafka
|
||||||
|
note right of Kafka
|
||||||
|
**Kafka Topic**
|
||||||
|
- Topic: ai-job-topic
|
||||||
|
- Consumer: AI Service
|
||||||
|
- Consumer Group: ai-service-group
|
||||||
|
|
||||||
|
**Payload**
|
||||||
|
{
|
||||||
|
"jobId": "UUID",
|
||||||
|
"eventDraftId": "UUID",
|
||||||
|
"objective": "신규 고객 유치",
|
||||||
|
"industry": "음식점",
|
||||||
|
"region": "서울 강남구",
|
||||||
|
"storeInfo": {...}
|
||||||
|
}
|
||||||
|
end note
|
||||||
|
Kafka --> JobSvc: ACK (발행 확인)
|
||||||
|
deactivate Kafka
|
||||||
|
|
||||||
|
JobSvc --> Service: JobResponse\n{jobId, status: PENDING}
|
||||||
|
deactivate JobSvc
|
||||||
|
|
||||||
|
== 4단계: 응답 반환 ==
|
||||||
|
|
||||||
|
Service --> Controller: JobResponse\n{jobId, status: PENDING}
|
||||||
|
deactivate Service
|
||||||
|
|
||||||
|
Controller --> Gateway: 202 Accepted\n{"jobId": "job-uuid-123",\n"status": "PENDING",\n"message": "AI가 분석 중입니다"}
|
||||||
|
deactivate Controller
|
||||||
|
|
||||||
|
Gateway --> Client: 202 Accepted\nAI 분석 시작
|
||||||
|
deactivate Gateway
|
||||||
|
|
||||||
|
note over Service, Kafka
|
||||||
|
**AI Service 비동기 처리**
|
||||||
|
- Kafka 구독: ai-job-topic
|
||||||
|
- 트렌드 분석 (업종, 지역 기반)
|
||||||
|
- 3가지 추천안 생성 (저/중/고 비용)
|
||||||
|
- 결과: Redis에 저장 (TTL: 24시간)
|
||||||
|
- 상세: ai-트렌드분석및추천.puml 참조
|
||||||
|
|
||||||
|
**처리 시간**
|
||||||
|
- 평균: 2분 이내
|
||||||
|
- P95: 4분 이내
|
||||||
|
- Timeout: 5분
|
||||||
|
|
||||||
|
**결과 조회**
|
||||||
|
- 폴링 방식: GET /api/jobs/{jobId}/status
|
||||||
|
- 간격: 2초, 최대 30초
|
||||||
|
end note
|
||||||
|
end
|
||||||
|
|
||||||
|
@enduml
|
||||||
73
design/backend/sequence/inner/event-대시보드조회.puml
Normal file
73
design/backend/sequence/inner/event-대시보드조회.puml
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
@startuml event-대시보드조회
|
||||||
|
!theme mono
|
||||||
|
|
||||||
|
title Event Service - 대시보드 이벤트 목록 (UFR-EVENT-010)
|
||||||
|
|
||||||
|
actor Client
|
||||||
|
participant "EventController" as Controller <<C>>
|
||||||
|
participant "EventService" as Service <<S>>
|
||||||
|
participant "EventRepository" as Repo <<R>>
|
||||||
|
participant "Redis Cache" as Cache <<E>>
|
||||||
|
database "Event DB" as DB <<E>>
|
||||||
|
|
||||||
|
note over Controller: GET /api/events/dashboard
|
||||||
|
Controller -> Service: getDashboard(userId)
|
||||||
|
activate Service
|
||||||
|
|
||||||
|
Service -> Cache: get("dashboard:" + userId)
|
||||||
|
activate Cache
|
||||||
|
|
||||||
|
alt 캐시 히트
|
||||||
|
Cache --> Service: Dashboard data
|
||||||
|
Service --> Controller: DashboardResponse
|
||||||
|
|
||||||
|
else 캐시 미스
|
||||||
|
Cache --> Service: null
|
||||||
|
deactivate Cache
|
||||||
|
|
||||||
|
group parallel
|
||||||
|
Service -> Repo: findTopByStatusAndUserId(ACTIVE, userId, limit=5)
|
||||||
|
activate Repo
|
||||||
|
Repo -> DB: 진행중 이벤트 목록 조회\n(사용자ID로 ACTIVE 상태 이벤트 조회,\n참여자 수 함께 조회,\n생성일 내림차순, 최대 5개)
|
||||||
|
activate DB
|
||||||
|
DB --> Repo: Active events
|
||||||
|
deactivate DB
|
||||||
|
Repo --> Service: List<Event> (active)
|
||||||
|
deactivate Repo
|
||||||
|
|
||||||
|
Service -> Repo: findTopByStatusAndUserId(APPROVED, userId, limit=5)
|
||||||
|
activate Repo
|
||||||
|
Repo -> DB: 예정 이벤트 목록 조회\n(사용자ID로 APPROVED 상태 이벤트 조회,\n승인일 내림차순, 최대 5개)
|
||||||
|
activate DB
|
||||||
|
DB --> Repo: Approved events
|
||||||
|
deactivate DB
|
||||||
|
Repo --> Service: List<Event> (approved)
|
||||||
|
deactivate Repo
|
||||||
|
|
||||||
|
Service -> Repo: findTopByStatusAndUserId(COMPLETED, userId, limit=5)
|
||||||
|
activate Repo
|
||||||
|
Repo -> DB: 종료 이벤트 목록 조회\n(사용자ID로 COMPLETED 상태 이벤트 조회,\n참여자 수 함께 조회,\n종료일 내림차순, 최대 5개)
|
||||||
|
activate DB
|
||||||
|
DB --> Repo: Completed events
|
||||||
|
deactivate DB
|
||||||
|
Repo --> Service: List<Event> (completed)
|
||||||
|
deactivate Repo
|
||||||
|
end
|
||||||
|
|
||||||
|
Service -> Service: buildDashboardResponse(active, approved, completed)
|
||||||
|
note right: 대시보드 데이터 구성:\n- 진행중: 5개\n- 예정: 5개\n- 종료: 5개\n각 카드에 기본 통계 포함
|
||||||
|
|
||||||
|
Service -> Cache: set("dashboard:" + userId,\ndashboard, TTL=1분)
|
||||||
|
activate Cache
|
||||||
|
Cache --> Service: OK
|
||||||
|
deactivate Cache
|
||||||
|
end
|
||||||
|
|
||||||
|
Service --> Controller: DashboardResponse\n{active: [...], approved: [...],\ncompleted: [...]}
|
||||||
|
deactivate Service
|
||||||
|
|
||||||
|
Controller --> Client: 200 OK\n{active: [\n {eventId, title, period, status,\n participantCount, viewCount, ...}\n],\napproved: [...],\ncompleted: [...]}
|
||||||
|
|
||||||
|
note over Controller, DB: 대시보드 카드 정보:\n- 이벤트명\n- 이벤트 기간\n- 진행 상태 뱃지\n- 간단한 통계\n (참여자 수, 조회수 등)\n\n섹션당 최대 5개 표시\n(최신 순)
|
||||||
|
|
||||||
|
@enduml
|
||||||
64
design/backend/sequence/inner/event-목록조회.puml
Normal file
64
design/backend/sequence/inner/event-목록조회.puml
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
@startuml event-목록조회
|
||||||
|
!theme mono
|
||||||
|
|
||||||
|
title Event Service - 이벤트 목록 조회 (필터/검색) (UFR-EVENT-070)
|
||||||
|
|
||||||
|
participant "EventController" as Controller <<C>>
|
||||||
|
participant "EventService" as Service <<S>>
|
||||||
|
participant "EventRepository" as Repo <<R>>
|
||||||
|
participant "Redis Cache" as Cache <<E>>
|
||||||
|
database "Event DB" as DB <<E>>
|
||||||
|
|
||||||
|
note over Controller: GET /api/events?status={상태}&keyword={검색어}\n&page={페이지}&size={크기}
|
||||||
|
Controller -> Service: 이벤트 목록 조회(사용자ID, 필터, 페이징)
|
||||||
|
activate Service
|
||||||
|
|
||||||
|
Service -> Cache: 캐시 조회("events:" + 사용자ID + ":" + 필터 + ":" + 페이지)
|
||||||
|
activate Cache
|
||||||
|
|
||||||
|
alt 캐시 히트
|
||||||
|
Cache --> Service: 이벤트 목록 데이터
|
||||||
|
Service --> Controller: 이벤트 목록 응답
|
||||||
|
|
||||||
|
else 캐시 미스
|
||||||
|
Cache --> Service: null
|
||||||
|
deactivate Cache
|
||||||
|
|
||||||
|
Service -> Repo: 사용자별 필터링 이벤트 조회(사용자ID, 필터, 페이징)
|
||||||
|
activate Repo
|
||||||
|
|
||||||
|
alt 필터 있음 (상태별)
|
||||||
|
Repo -> DB: 사용자별 특정 상태 이벤트 조회\n(참여자 수 포함, 생성일 기준 내림차순,\n페이징 적용)
|
||||||
|
else 검색 있음 (키워드)
|
||||||
|
Repo -> DB: 사용자별 이벤트 키워드 검색\n(제목/설명에서 검색, 참여자 수 포함,\n생성일 기준 내림차순, 페이징 적용)
|
||||||
|
else 필터 없음 (전체)
|
||||||
|
Repo -> DB: 사용자별 전체 이벤트 목록 조회\n(참여자 수 포함, 생성일 기준 내림차순,\n페이징 적용)
|
||||||
|
end
|
||||||
|
|
||||||
|
activate DB
|
||||||
|
note right: 인덱스 활용:\n- 사용자ID\n- 상태\n- 생성일시
|
||||||
|
DB --> Repo: 이벤트 목록 및 참여자 수
|
||||||
|
deactivate DB
|
||||||
|
|
||||||
|
Repo -> DB: 전체 이벤트 개수 조회\n(필터 조건 포함, 페이징용)
|
||||||
|
activate DB
|
||||||
|
DB --> Repo: 전체 개수
|
||||||
|
deactivate DB
|
||||||
|
|
||||||
|
Repo --> Service: 페이징된 이벤트 결과
|
||||||
|
deactivate Repo
|
||||||
|
|
||||||
|
Service -> Cache: 캐시 저장("events:" + 사용자ID + ":" + 필터 + ":" + 페이지,\n페이징결과, TTL=1분)
|
||||||
|
activate Cache
|
||||||
|
Cache --> Service: OK
|
||||||
|
deactivate Cache
|
||||||
|
end
|
||||||
|
|
||||||
|
Service --> Controller: 이벤트 목록 응답\n{이벤트목록: [...], 전체개수,\n전체페이지수, 현재페이지}
|
||||||
|
deactivate Service
|
||||||
|
|
||||||
|
Controller --> Client: 200 OK\n{이벤트목록: [\n {이벤트ID, 제목, 기간, 상태,\n 참여자수, ROI, 생성일시},\n ...\n],\n전체개수, 전체페이지수, 현재페이지}
|
||||||
|
|
||||||
|
note over Controller, DB: 필터 옵션:\n- 상태: 임시저장, 진행중, 완료\n- 기간: 최근 1개월/3개월/6개월/1년\n- 정렬: 최신순, 참여자 많은 순,\n ROI 높은 순\n\n페이지네이션:\n- 기본 20개/페이지\n- 페이지 번호 기반
|
||||||
|
|
||||||
|
@enduml
|
||||||
110
design/backend/sequence/inner/event-목적선택.puml
Normal file
110
design/backend/sequence/inner/event-목적선택.puml
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
@startuml event-목적선택
|
||||||
|
!theme mono
|
||||||
|
|
||||||
|
title Event Service - 이벤트 목적 선택 및 저장 (UFR-EVENT-020)
|
||||||
|
|
||||||
|
participant "EventController" as Controller <<API Layer>>
|
||||||
|
participant "EventService" as Service <<Business Layer>>
|
||||||
|
participant "EventRepository" as Repo <<Data Layer>>
|
||||||
|
participant "Redis Cache" as Cache <<E>>
|
||||||
|
database "Event DB" as DB <<E>>
|
||||||
|
participant "Kafka Producer" as Kafka <<E>>
|
||||||
|
actor Client
|
||||||
|
|
||||||
|
note over Controller, DB
|
||||||
|
**UFR-EVENT-020: 이벤트 목적 선택 및 저장**
|
||||||
|
- 목적 선택: 신규 고객 유치, 재방문 유도, 매출 증대, 인지도 향상
|
||||||
|
- Redis 캐시 사용 (TTL: 30분)
|
||||||
|
- Kafka 이벤트 발행 (EventDraftCreated)
|
||||||
|
- 사용자 및 매장 정보는 User Service에서 조회 후 전달됨
|
||||||
|
end note
|
||||||
|
|
||||||
|
Client -> Controller: POST /api/events/purposes\n{"userId": 123,\n"objective": "신규 고객 유치",\n"storeName": "맛있는집",\n"industry": "음식점",\n"address": "서울시 강남구"}
|
||||||
|
activate Controller
|
||||||
|
|
||||||
|
Controller -> Controller: 입력값 검증\n(필수 필드, 목적 유효성 확인)
|
||||||
|
|
||||||
|
Controller -> Service: createEventDraft(userId, objective, storeInfo)
|
||||||
|
activate Service
|
||||||
|
|
||||||
|
== 1단계: Redis 캐시 확인 ==
|
||||||
|
|
||||||
|
Service -> Cache: 캐시 조회\nKey: draft:event:{userId}\n(기존 작성 중인 이벤트 확인)
|
||||||
|
activate Cache
|
||||||
|
Cache --> Service: null (캐시 미스)
|
||||||
|
deactivate Cache
|
||||||
|
|
||||||
|
== 2단계: 목적 유효성 검증 ==
|
||||||
|
|
||||||
|
Service -> Service: 목적 유효성 검증\n- 신규 고객 유치\n- 재방문 유도\n- 매출 증대\n- 인지도 향상
|
||||||
|
|
||||||
|
Service -> Service: 매장 정보 유효성 검증\n(매장명, 업종, 주소)
|
||||||
|
|
||||||
|
== 3단계: 이벤트 초안 저장 ==
|
||||||
|
|
||||||
|
Service -> Repo: save(eventDraft)
|
||||||
|
activate Repo
|
||||||
|
Repo -> DB: 이벤트 초안 저장\n(사용자ID, 목적, 매장명,\n업종, 주소, 상태=DRAFT,\n생성일시)\n저장 후 이벤트초안ID 반환
|
||||||
|
activate DB
|
||||||
|
DB --> Repo: 생성된 이벤트초안ID
|
||||||
|
deactivate DB
|
||||||
|
Repo --> Service: EventDraft 엔티티\n(eventDraftId 포함)
|
||||||
|
deactivate Repo
|
||||||
|
|
||||||
|
== 4단계: Redis 캐시 저장 ==
|
||||||
|
|
||||||
|
Service -> Cache: 캐시 저장\nKey: draft:event:{eventDraftId}\nValue: {목적, 매장정보, 상태}\nTTL: 24시간
|
||||||
|
activate Cache
|
||||||
|
Cache --> Service: 저장 완료
|
||||||
|
deactivate Cache
|
||||||
|
|
||||||
|
== 5단계: Kafka 이벤트 발행 ==
|
||||||
|
|
||||||
|
Service -> Kafka: 이벤트 발행\nTopic: event-topic\nEvent: EventDraftCreated\nPayload: {eventDraftId,\nuserId, objective,\ncreatedAt}
|
||||||
|
activate Kafka
|
||||||
|
note right of Kafka
|
||||||
|
**Kafka Event Topic**
|
||||||
|
- Topic: event-topic
|
||||||
|
- Event: EventDraftCreated
|
||||||
|
- 목적 선택 시 발행
|
||||||
|
|
||||||
|
**구독자**
|
||||||
|
- Analytics Service (선택적)
|
||||||
|
|
||||||
|
**참고**
|
||||||
|
- EventCreated는
|
||||||
|
최종 승인 시 발행
|
||||||
|
end note
|
||||||
|
Kafka --> Service: ACK (발행 확인)
|
||||||
|
deactivate Kafka
|
||||||
|
|
||||||
|
== 6단계: 응답 반환 ==
|
||||||
|
|
||||||
|
Service -> Service: 응답 DTO 생성
|
||||||
|
Service --> Controller: EventDraftResponse\n{eventDraftId, objective,\nstoreName, status=DRAFT}
|
||||||
|
deactivate Service
|
||||||
|
|
||||||
|
Controller --> Client: 200 OK\n{"eventDraftId": "draft-123",\n"objective": "신규 고객 유치",\n"storeName": "맛있는집",\n"status": "DRAFT"}
|
||||||
|
deactivate Controller
|
||||||
|
|
||||||
|
note over Controller, Kafka
|
||||||
|
**캐시 전략**
|
||||||
|
- Key: draft:event:{eventDraftId}
|
||||||
|
- TTL: 24시간
|
||||||
|
- 캐시 히트 시: DB 조회 생략, 즉시 반환
|
||||||
|
|
||||||
|
**이벤트 발행 전략**
|
||||||
|
- EventDraftCreated: 목적 선택 시 발행 (Analytics Service 선택적 구독)
|
||||||
|
- EventCreated: 최종 승인 시 발행 (통계 초기화 시작)
|
||||||
|
|
||||||
|
**성능 목표**
|
||||||
|
- 평균 응답 시간: 0.3초 이내
|
||||||
|
- P95 응답 시간: 0.5초 이내
|
||||||
|
- Redis 캐시 조회: 0.05초 이내
|
||||||
|
|
||||||
|
**에러 코드**
|
||||||
|
- EVENT_001: 유효하지 않은 목적
|
||||||
|
- EVENT_002: 매장 정보 누락
|
||||||
|
end note
|
||||||
|
|
||||||
|
@enduml
|
||||||
54
design/backend/sequence/inner/event-상세조회.puml
Normal file
54
design/backend/sequence/inner/event-상세조회.puml
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
@startuml event-상세조회
|
||||||
|
!theme mono
|
||||||
|
|
||||||
|
title Event Service - 이벤트 상세 조회 (UFR-EVENT-060)
|
||||||
|
|
||||||
|
participant "EventController" as Controller <<C>>
|
||||||
|
participant "EventService" as Service <<S>>
|
||||||
|
participant "EventRepository" as Repo <<R>>
|
||||||
|
participant "Redis Cache" as Cache <<E>>
|
||||||
|
database "Event DB" as DB <<E>>
|
||||||
|
|
||||||
|
note over Controller: GET /api/events/{id}
|
||||||
|
Controller -> Service: getEventDetail(eventId, userId)
|
||||||
|
activate Service
|
||||||
|
|
||||||
|
Service -> Cache: get("event:" + eventId)
|
||||||
|
activate Cache
|
||||||
|
|
||||||
|
alt 캐시 히트
|
||||||
|
Cache --> Service: Event data
|
||||||
|
Service -> Service: validateAccess(userId, event)
|
||||||
|
note right: 사용자 권한 검증
|
||||||
|
Service --> Controller: EventDetailResponse
|
||||||
|
|
||||||
|
else 캐시 미스
|
||||||
|
Cache --> Service: null
|
||||||
|
deactivate Cache
|
||||||
|
|
||||||
|
Service -> Repo: findById(eventId)
|
||||||
|
activate Repo
|
||||||
|
Repo -> DB: 이벤트 상세 정보 조회\n(이벤트ID로 이벤트 정보,\n경품 정보, 배포 이력을\nJOIN하여 함께 조회)
|
||||||
|
activate DB
|
||||||
|
note right: JOIN으로\n경품 정보 및\n배포 이력 조회
|
||||||
|
DB --> Repo: Event with prizes and distributions
|
||||||
|
deactivate DB
|
||||||
|
Repo --> Service: Event entity (with relations)
|
||||||
|
deactivate Repo
|
||||||
|
|
||||||
|
Service -> Service: validateAccess(userId, event)
|
||||||
|
|
||||||
|
Service -> Cache: set("event:" + eventId, event, TTL=5분)
|
||||||
|
activate Cache
|
||||||
|
Cache --> Service: OK
|
||||||
|
deactivate Cache
|
||||||
|
end
|
||||||
|
|
||||||
|
Service --> Controller: EventDetailResponse\n{eventId, title, objective,\nprizes, period, status,\nchannels, distributionStatus,\ncreatedAt, publishedAt}
|
||||||
|
deactivate Service
|
||||||
|
|
||||||
|
Controller --> Client: 200 OK\n{event: {...},\nprizes: [...],\ndistributionStatus: {...}}
|
||||||
|
|
||||||
|
note over Controller, DB: 상세 정보 포함:\n- 기본 정보 (제목, 목적, 기간, 상태)\n- 경품 정보\n- 참여 방법\n- 배포 채널 현황\n- 실시간 통계 (Analytics Service)\n\nAnalytics 통계는\n별도 API 호출
|
||||||
|
|
||||||
|
@enduml
|
||||||
157
design/backend/sequence/inner/event-최종승인및배포.puml
Normal file
157
design/backend/sequence/inner/event-최종승인및배포.puml
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
@startuml event-최종승인및배포
|
||||||
|
!theme mono
|
||||||
|
|
||||||
|
title Event Service - 최종 승인 및 Distribution Service 동기 호출 (UFR-EVENT-050)
|
||||||
|
|
||||||
|
actor Client
|
||||||
|
participant "API Gateway" as Gateway
|
||||||
|
participant "EventController" as Controller <<API Layer>>
|
||||||
|
participant "EventService" as Service <<Business Layer>>
|
||||||
|
participant "EventRepository" as Repo <<Data Layer>>
|
||||||
|
participant "Redis Cache" as Cache <<E>>
|
||||||
|
database "Event DB" as DB <<E>>
|
||||||
|
participant "Distribution Service" as DistSvc <<E>>
|
||||||
|
participant "Kafka Producer" as Kafka <<E>>
|
||||||
|
|
||||||
|
note over Controller, Kafka
|
||||||
|
**UFR-EVENT-050: 이벤트 최종 승인 및 배포**
|
||||||
|
- 이벤트 준비 상태 검증
|
||||||
|
- 이벤트 승인 및 Kafka 이벤트 발행
|
||||||
|
- Distribution Service 동기 호출 (다중 채널 배포)
|
||||||
|
- 이벤트 상태를 ACTIVE로 변경
|
||||||
|
end note
|
||||||
|
|
||||||
|
Client -> Gateway: POST /api/events/{eventDraftId}/publish\n{"userId": 123,\n"selectedChannels": [\n "우리동네TV",\n "지니TV",\n "Instagram"\n]}
|
||||||
|
activate Gateway
|
||||||
|
|
||||||
|
Gateway -> Controller: POST /api/events/{eventDraftId}/publish
|
||||||
|
activate Controller
|
||||||
|
|
||||||
|
Controller -> Controller: 요청 검증\n(필수 필드, 채널 유효성)
|
||||||
|
|
||||||
|
Controller -> Service: publishEvent(eventDraftId, userId, selectedChannels)
|
||||||
|
activate Service
|
||||||
|
|
||||||
|
== 1단계: 이벤트 초안 조회 및 검증 ==
|
||||||
|
|
||||||
|
Service -> Repo: findById(eventDraftId)
|
||||||
|
activate Repo
|
||||||
|
Repo -> DB: 이벤트 초안 조회\n(초안ID로 조회)
|
||||||
|
activate DB
|
||||||
|
DB --> Repo: EventDraft 엔티티\n{목적, 추천안, 콘텐츠, 상태}
|
||||||
|
deactivate DB
|
||||||
|
Repo --> Service: EventDraft entity
|
||||||
|
deactivate Repo
|
||||||
|
|
||||||
|
Service -> Service: validateOwnership(userId, eventDraft)
|
||||||
|
note right
|
||||||
|
소유권 검증:
|
||||||
|
- 사용자ID와 초안 소유자 일치 확인
|
||||||
|
- 권한 없으면 403 Forbidden
|
||||||
|
end note
|
||||||
|
|
||||||
|
Service -> Service: validatePublishReady()
|
||||||
|
note right
|
||||||
|
발행 준비 검증:
|
||||||
|
- 목적 선택 완료
|
||||||
|
- AI 추천 선택 완료
|
||||||
|
- 콘텐츠 선택 완료
|
||||||
|
- 배포 채널 최소 1개 선택
|
||||||
|
end note
|
||||||
|
|
||||||
|
== 2단계: 이벤트 승인 ==
|
||||||
|
|
||||||
|
Service -> Repo: updateStatus(eventDraftId, APPROVED)
|
||||||
|
activate Repo
|
||||||
|
Repo -> DB: 이벤트 초안 상태 업데이트\n(상태를 APPROVED로,\n승인일시를 현재 시각으로 저장)
|
||||||
|
activate DB
|
||||||
|
DB --> Repo: 업데이트 완료
|
||||||
|
deactivate DB
|
||||||
|
Repo --> Service: EventDraft entity
|
||||||
|
deactivate Repo
|
||||||
|
|
||||||
|
== 3단계: Kafka 이벤트 발행 ==
|
||||||
|
|
||||||
|
Service -> Kafka: 이벤트 발행\nTopic: event-topic\nPayload: {eventId, userId, title,\nobjective, createdAt}
|
||||||
|
activate Kafka
|
||||||
|
note right
|
||||||
|
Kafka Event Topic:
|
||||||
|
- Topic: event-topic
|
||||||
|
- Consumer: Analytics Service
|
||||||
|
- Event Type: EventCreated
|
||||||
|
end note
|
||||||
|
Kafka --> Service: ACK (발행 확인)
|
||||||
|
deactivate Kafka
|
||||||
|
|
||||||
|
== 4단계: Distribution Service 동기 호출 ==
|
||||||
|
|
||||||
|
Service -> DistSvc: POST /api/distribution/distribute\n{"eventId": 123,\n"channels": [...],\n"content": {...}}
|
||||||
|
activate DistSvc
|
||||||
|
note right
|
||||||
|
동기 호출 (Circuit Breaker 적용):
|
||||||
|
- Timeout: 70초
|
||||||
|
- 다중 채널 병렬 배포
|
||||||
|
- Failure Rate: 50% 초과 시 OPEN
|
||||||
|
end note
|
||||||
|
|
||||||
|
DistSvc -> DistSvc: distributeToChannels(eventId, channels)
|
||||||
|
note right
|
||||||
|
다중 채널 병렬 배포:
|
||||||
|
- 우리동네TV
|
||||||
|
- 링고비즈 (음성 안내)
|
||||||
|
- 지니TV
|
||||||
|
- Instagram
|
||||||
|
- Naver Blog
|
||||||
|
- Kakao Channel
|
||||||
|
end note
|
||||||
|
|
||||||
|
DistSvc --> Service: DistributionResponse\n{"distributionId": "dist-123",\n"channelResults": [\n {"channel": "우리동네TV", "status": "SUCCESS"},\n {"channel": "지니TV", "status": "SUCCESS"},\n {"channel": "Instagram", "status": "SUCCESS"}\n]}
|
||||||
|
deactivate DistSvc
|
||||||
|
|
||||||
|
== 5단계: 이벤트 활성화 ==
|
||||||
|
|
||||||
|
Service -> Repo: updateStatus(eventDraftId, ACTIVE)
|
||||||
|
activate Repo
|
||||||
|
Repo -> DB: 이벤트 상태 업데이트\n(상태를 ACTIVE로,\n배포일시를 현재 시각으로 저장)
|
||||||
|
activate DB
|
||||||
|
DB --> Repo: 업데이트 완료
|
||||||
|
deactivate DB
|
||||||
|
Repo --> Service: Event entity
|
||||||
|
deactivate Repo
|
||||||
|
|
||||||
|
== 6단계: 캐시 무효화 ==
|
||||||
|
|
||||||
|
Service -> Cache: 캐시 삭제\nKey: draft:event:{eventDraftId}
|
||||||
|
activate Cache
|
||||||
|
note right
|
||||||
|
Redis 캐시 무효화:
|
||||||
|
- 이벤트 초안 캐시 삭제
|
||||||
|
- 활성화 완료 후 불필요
|
||||||
|
end note
|
||||||
|
Cache --> Service: 삭제 완료
|
||||||
|
deactivate Cache
|
||||||
|
|
||||||
|
== 7단계: 응답 반환 ==
|
||||||
|
|
||||||
|
Service --> Controller: PublishResponse\n{eventId, status: ACTIVE,\ndistributionResults}
|
||||||
|
deactivate Service
|
||||||
|
|
||||||
|
Controller --> Gateway: 200 OK\n{"eventId": 123,\n"status": "ACTIVE",\n"distributionResults": [...]}
|
||||||
|
deactivate Controller
|
||||||
|
|
||||||
|
Gateway --> Client: 200 OK\n이벤트 배포 완료
|
||||||
|
deactivate Gateway
|
||||||
|
|
||||||
|
note over Client, Kafka
|
||||||
|
**배포 완료 후 처리**
|
||||||
|
- Distribution Service는 배포 완료 후 Kafka에\n DistributionCompleted 이벤트 발행
|
||||||
|
- Analytics Service가 구독하여 초기 통계 생성
|
||||||
|
- 이벤트 상태: ACTIVE (참여자 접수 시작)
|
||||||
|
|
||||||
|
**성능 목표**
|
||||||
|
- 응답 시간: 60초 이내 (Distribution Service 포함)
|
||||||
|
- Distribution Service 타임아웃: 70초
|
||||||
|
- 채널별 배포: 병렬 처리로 최적화
|
||||||
|
end note
|
||||||
|
|
||||||
|
@enduml
|
||||||
140
design/backend/sequence/inner/event-추천결과조회.puml
Normal file
140
design/backend/sequence/inner/event-추천결과조회.puml
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
@startuml event-추천결과조회
|
||||||
|
!theme mono
|
||||||
|
|
||||||
|
title Event Service - AI 추천 결과 폴링 조회
|
||||||
|
|
||||||
|
actor Client
|
||||||
|
participant "API Gateway" as Gateway
|
||||||
|
participant "EventController" as Controller <<API Layer>>
|
||||||
|
participant "EventService" as Service <<Business Layer>>
|
||||||
|
participant "JobManager" as JobMgr <<Component>>
|
||||||
|
participant "Redis Cache" as Cache <<E>>
|
||||||
|
|
||||||
|
note over Controller, Cache
|
||||||
|
**폴링 방식 Job 상태 조회**
|
||||||
|
- 최대 30초 동안 폴링 (2초 간격)
|
||||||
|
- Job 상태: PENDING → PROCESSING → COMPLETED
|
||||||
|
- AI 추천 결과: Redis에 저장 (TTL: 24시간)
|
||||||
|
end note
|
||||||
|
|
||||||
|
Client -> Gateway: GET /api/events/jobs/{jobId}/status
|
||||||
|
activate Gateway
|
||||||
|
|
||||||
|
Gateway -> Controller: GET /api/events/jobs/{jobId}/status
|
||||||
|
activate Controller
|
||||||
|
|
||||||
|
Controller -> Service: getJobStatus(jobId)
|
||||||
|
activate Service
|
||||||
|
|
||||||
|
Service -> JobMgr: getJobStatus(jobId)
|
||||||
|
activate JobMgr
|
||||||
|
|
||||||
|
JobMgr -> Cache: Job 상태 조회\nKey: job:{jobId}
|
||||||
|
activate Cache
|
||||||
|
|
||||||
|
alt Job 데이터 존재
|
||||||
|
Cache --> JobMgr: Job 데이터\n{status, eventDraftId,\ntype, createdAt}
|
||||||
|
deactivate Cache
|
||||||
|
|
||||||
|
alt status = COMPLETED
|
||||||
|
JobMgr -> Cache: AI 추천 결과 조회\nKey: ai:recommendation:{eventDraftId}
|
||||||
|
activate Cache
|
||||||
|
Cache --> JobMgr: AI 추천 결과\n{트렌드분석, 3가지추천안}
|
||||||
|
deactivate Cache
|
||||||
|
|
||||||
|
JobMgr --> Service: JobStatusResponse\n{jobId, status: COMPLETED,\nrecommendations: {...}}
|
||||||
|
deactivate JobMgr
|
||||||
|
|
||||||
|
Service --> Controller: JobStatusResponse\n{status: COMPLETED, recommendations}
|
||||||
|
deactivate Service
|
||||||
|
|
||||||
|
Controller --> Gateway: 200 OK\n{"status": "COMPLETED",\n"recommendations": [\n {"title": "저비용 추천안",\n "prize": "커피쿠폰",\n "method": "QR코드 스캔",\n "cost": "50만원",\n "roi": "150%"},\n {"title": "중비용 추천안",\n "prize": "상품권",\n "method": "SNS 공유",\n "cost": "100만원",\n "roi": "200%"},\n {"title": "고비용 추천안",\n "prize": "경품 추첨",\n "method": "설문 참여",\n "cost": "200만원",\n "roi": "300%"}\n]}
|
||||||
|
deactivate Controller
|
||||||
|
|
||||||
|
Gateway --> Client: 200 OK\nAI 추천 결과 반환
|
||||||
|
deactivate Gateway
|
||||||
|
|
||||||
|
note right of Client
|
||||||
|
**프론트엔드 처리**
|
||||||
|
- 3가지 추천안 카드 표시
|
||||||
|
- 사용자가 추천안 선택
|
||||||
|
- 트렌드 분석 정보 표시
|
||||||
|
end note
|
||||||
|
|
||||||
|
else status = PROCESSING 또는 PENDING
|
||||||
|
JobMgr --> Service: JobStatusResponse\n{jobId, status: PROCESSING}
|
||||||
|
deactivate JobMgr
|
||||||
|
|
||||||
|
Service --> Controller: JobStatusResponse\n{status: PROCESSING}
|
||||||
|
deactivate Service
|
||||||
|
|
||||||
|
Controller --> Gateway: 200 OK\n{"status": "PROCESSING",\n"message": "AI가 분석 중입니다"}
|
||||||
|
deactivate Controller
|
||||||
|
|
||||||
|
Gateway --> Client: 200 OK\n진행 중 상태
|
||||||
|
deactivate Gateway
|
||||||
|
|
||||||
|
note right of Client
|
||||||
|
**폴링 재시도**
|
||||||
|
- 2초 후 재요청
|
||||||
|
- 최대 30초 (15회)
|
||||||
|
end note
|
||||||
|
|
||||||
|
else status = FAILED
|
||||||
|
JobMgr -> Cache: 에러 정보 조회\nKey: job:{jobId}:error
|
||||||
|
activate Cache
|
||||||
|
Cache --> JobMgr: 에러 메시지
|
||||||
|
deactivate Cache
|
||||||
|
|
||||||
|
JobMgr --> Service: JobStatusResponse\n{jobId, status: FAILED, error}
|
||||||
|
deactivate JobMgr
|
||||||
|
|
||||||
|
Service --> Controller: JobStatusResponse\n{status: FAILED, error}
|
||||||
|
deactivate Service
|
||||||
|
|
||||||
|
Controller --> Gateway: 200 OK\n{"status": "FAILED",\n"error": "AI 분석 실패",\n"message": "다시 시도해주세요"}
|
||||||
|
deactivate Controller
|
||||||
|
|
||||||
|
Gateway --> Client: 200 OK\n실패 상태
|
||||||
|
deactivate Gateway
|
||||||
|
|
||||||
|
note right of Client
|
||||||
|
**실패 처리**
|
||||||
|
- 에러 메시지 표시
|
||||||
|
- "다시 분석" 버튼 제공
|
||||||
|
end note
|
||||||
|
end
|
||||||
|
|
||||||
|
else Job 데이터 없음
|
||||||
|
Cache --> JobMgr: null (캐시 미스)
|
||||||
|
deactivate Cache
|
||||||
|
|
||||||
|
JobMgr --> Service: throw NotFoundException\n("Job을 찾을 수 없습니다")
|
||||||
|
deactivate JobMgr
|
||||||
|
|
||||||
|
Service --> Controller: NotFoundException
|
||||||
|
deactivate Service
|
||||||
|
|
||||||
|
Controller --> Gateway: 404 Not Found\n{"code": "JOB_001",\n"message": "Job을 찾을 수 없습니다"}
|
||||||
|
deactivate Controller
|
||||||
|
|
||||||
|
Gateway --> Client: 404 Not Found
|
||||||
|
deactivate Gateway
|
||||||
|
end
|
||||||
|
|
||||||
|
note over Controller, Cache
|
||||||
|
**폴링 전략**
|
||||||
|
- 간격: 2초
|
||||||
|
- 최대 시간: 30초 (15회)
|
||||||
|
- Timeout 시: 사용자에게 알림 + "다시 분석" 옵션
|
||||||
|
|
||||||
|
**Redis 캐시**
|
||||||
|
- Job 상태: TTL 1시간
|
||||||
|
- AI 추천 결과: TTL 24시간
|
||||||
|
|
||||||
|
**성능 목표**
|
||||||
|
- 평균 AI 분석 시간: 2분 이내
|
||||||
|
- P95 AI 분석 시간: 4분 이내
|
||||||
|
end note
|
||||||
|
|
||||||
|
@enduml
|
||||||
116
design/backend/sequence/inner/event-추천안선택.puml
Normal file
116
design/backend/sequence/inner/event-추천안선택.puml
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
@startuml event-추천안선택
|
||||||
|
!theme mono
|
||||||
|
|
||||||
|
title Event Service - 선택한 AI 추천안 저장 (UFR-EVENT-040)
|
||||||
|
|
||||||
|
actor Client
|
||||||
|
participant "API Gateway" as Gateway
|
||||||
|
participant "EventController" as Controller <<API Layer>>
|
||||||
|
participant "EventService" as Service <<Business Layer>>
|
||||||
|
participant "EventRepository" as Repo <<Data Layer>>
|
||||||
|
participant "Redis Cache" as Cache <<E>>
|
||||||
|
database "Event DB" as DB <<E>>
|
||||||
|
|
||||||
|
note over Controller, Cache
|
||||||
|
**UFR-EVENT-040: AI 추천안 선택 및 저장**
|
||||||
|
- 사용자가 3가지 추천안 중 하나를 선택
|
||||||
|
- 선택된 추천안을 이벤트 초안에 적용
|
||||||
|
- Redis 캐시에서 AI 추천 결과 삭제
|
||||||
|
end note
|
||||||
|
|
||||||
|
Client -> Gateway: PUT /api/events/drafts/{eventDraftId}/recommendation\n{"userId": 123,\n"selectedIndex": 1,\n"recommendation": {...}}
|
||||||
|
activate Gateway
|
||||||
|
|
||||||
|
Gateway -> Controller: PUT /api/events/drafts/{eventDraftId}/recommendation
|
||||||
|
activate Controller
|
||||||
|
|
||||||
|
Controller -> Controller: 요청 검증\n(필수 필드, 추천안 유효성)
|
||||||
|
|
||||||
|
Controller -> Service: updateEventRecommendation(eventDraftId, userId,\nselectedRecommendation)
|
||||||
|
activate Service
|
||||||
|
|
||||||
|
== 1단계: 이벤트 초안 조회 및 검증 ==
|
||||||
|
|
||||||
|
Service -> Repo: findById(eventDraftId)
|
||||||
|
activate Repo
|
||||||
|
Repo -> DB: 이벤트 초안 조회\n(초안ID로 조회)
|
||||||
|
activate DB
|
||||||
|
DB --> Repo: EventDraft 엔티티\n{목적, 매장정보, 상태}
|
||||||
|
deactivate DB
|
||||||
|
Repo --> Service: EventDraft entity
|
||||||
|
deactivate Repo
|
||||||
|
|
||||||
|
Service -> Service: validateOwnership(userId, eventDraft)
|
||||||
|
note right
|
||||||
|
소유권 검증:
|
||||||
|
- 사용자ID와 초안 소유자 일치 확인
|
||||||
|
- 권한 없으면 403 Forbidden
|
||||||
|
end note
|
||||||
|
|
||||||
|
Service -> Service: validateRecommendation(selectedRecommendation)
|
||||||
|
note right
|
||||||
|
추천안 유효성 검증:
|
||||||
|
- 필수 필드 존재 여부
|
||||||
|
- 비용/ROI 값 타당성
|
||||||
|
end note
|
||||||
|
|
||||||
|
== 2단계: 추천안 적용 ==
|
||||||
|
|
||||||
|
Service -> Service: applyRecommendation(eventDraft, selectedRecommendation)
|
||||||
|
note right
|
||||||
|
추천안 적용:
|
||||||
|
- 이벤트 제목
|
||||||
|
- 경품 정보
|
||||||
|
- 참여 방법
|
||||||
|
- 예상 비용
|
||||||
|
- 예상 ROI
|
||||||
|
- 홍보 문구
|
||||||
|
end note
|
||||||
|
|
||||||
|
== 3단계: DB 저장 ==
|
||||||
|
|
||||||
|
Service -> Repo: update(eventDraft)
|
||||||
|
activate Repo
|
||||||
|
Repo -> DB: 이벤트 초안 업데이트\n(선택된 추천안 정보 저장:\n제목, 경품, 참여방법,\n예상비용, 예상ROI,\n수정일시)
|
||||||
|
activate DB
|
||||||
|
DB --> Repo: 업데이트 완료
|
||||||
|
deactivate DB
|
||||||
|
Repo --> Service: EventDraft entity
|
||||||
|
deactivate Repo
|
||||||
|
|
||||||
|
== 4단계: 캐시 무효화 ==
|
||||||
|
|
||||||
|
Service -> Cache: 캐시 삭제\nKey: ai:recommendation:{eventDraftId}
|
||||||
|
activate Cache
|
||||||
|
note right
|
||||||
|
Redis 캐시 무효화:
|
||||||
|
- AI 추천 결과 삭제
|
||||||
|
- 선택 완료 후 불필요
|
||||||
|
end note
|
||||||
|
Cache --> Service: 삭제 완료
|
||||||
|
deactivate Cache
|
||||||
|
|
||||||
|
== 5단계: 응답 반환 ==
|
||||||
|
|
||||||
|
Service --> Controller: EventRecommendationResponse\n{eventDraftId, selectedRecommendation}
|
||||||
|
deactivate Service
|
||||||
|
|
||||||
|
Controller --> Gateway: 200 OK\n{"eventDraftId": 123,\n"status": "추천안 선택 완료",\n"selectedRecommendation": {...}}
|
||||||
|
deactivate Controller
|
||||||
|
|
||||||
|
Gateway --> Client: 200 OK\n추천안 선택 완료
|
||||||
|
deactivate Gateway
|
||||||
|
|
||||||
|
note over Client, Cache
|
||||||
|
**저장 내용**
|
||||||
|
- 최종 선택된 추천안만 Event DB에 저장
|
||||||
|
- Redis에 저장된 3가지 추천안은 선택 후 삭제
|
||||||
|
- 다음 단계: 콘텐츠 생성 (이미지 선택)
|
||||||
|
|
||||||
|
**성능 목표**
|
||||||
|
- 응답 시간: 0.5초 이내
|
||||||
|
- DB 업데이트: 0.1초
|
||||||
|
- 캐시 삭제: 0.01초
|
||||||
|
end note
|
||||||
|
|
||||||
|
@enduml
|
||||||
118
design/backend/sequence/inner/event-콘텐츠선택.puml
Normal file
118
design/backend/sequence/inner/event-콘텐츠선택.puml
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
@startuml event-콘텐츠선택
|
||||||
|
!theme mono
|
||||||
|
|
||||||
|
title Content Service - 선택한 콘텐츠 저장 (UFR-CONT-020)
|
||||||
|
|
||||||
|
actor Client
|
||||||
|
participant "API Gateway" as Gateway
|
||||||
|
participant "ContentController" as Controller <<API Layer>>
|
||||||
|
participant "ContentService" as Service <<Business Layer>>
|
||||||
|
participant "EventRepository" as Repo <<Data Layer>>
|
||||||
|
participant "Redis Cache" as Cache <<E>>
|
||||||
|
database "Event DB" as DB <<E>>
|
||||||
|
|
||||||
|
note over Controller, Cache
|
||||||
|
**UFR-CONT-020: 콘텐츠 선택 및 편집 저장**
|
||||||
|
- 사용자가 3가지 이미지 스타일 중 하나 선택
|
||||||
|
- 선택된 이미지에 텍스트/색상 편집 적용
|
||||||
|
- 편집된 콘텐츠를 이벤트 초안에 저장
|
||||||
|
end note
|
||||||
|
|
||||||
|
Client -> Gateway: PUT /api/content/{eventDraftId}/select\n{"userId": 123,\n"selectedImageUrl": "https://cdn.../fancy.png",\n"editedContent": {...}}
|
||||||
|
activate Gateway
|
||||||
|
|
||||||
|
Gateway -> Controller: PUT /api/content/{eventDraftId}/select
|
||||||
|
activate Controller
|
||||||
|
|
||||||
|
Controller -> Controller: 요청 검증\n(필수 필드, URL 유효성)
|
||||||
|
|
||||||
|
Controller -> Service: updateEventContent(eventDraftId, userId,\nselectedImageUrl, editedContent)
|
||||||
|
activate Service
|
||||||
|
|
||||||
|
== 1단계: 이벤트 초안 조회 및 검증 ==
|
||||||
|
|
||||||
|
Service -> Repo: findById(eventDraftId)
|
||||||
|
activate Repo
|
||||||
|
Repo -> DB: 이벤트 초안 조회\n(초안ID로 조회)
|
||||||
|
activate DB
|
||||||
|
DB --> Repo: EventDraft 엔티티\n{목적, 추천안, 상태}
|
||||||
|
deactivate DB
|
||||||
|
Repo --> Service: EventDraft entity
|
||||||
|
deactivate Repo
|
||||||
|
|
||||||
|
Service -> Service: validateOwnership(userId, eventDraft)
|
||||||
|
note right
|
||||||
|
소유권 검증:
|
||||||
|
- 사용자ID와 초안 소유자 일치 확인
|
||||||
|
- 권한 없으면 403 Forbidden
|
||||||
|
end note
|
||||||
|
|
||||||
|
Service -> Service: validateImageUrl(selectedImageUrl)
|
||||||
|
note right
|
||||||
|
이미지 URL 검증:
|
||||||
|
- URL 형식 유효성
|
||||||
|
- CDN 경로 확인
|
||||||
|
- 이미지 존재 여부
|
||||||
|
end note
|
||||||
|
|
||||||
|
== 2단계: 콘텐츠 편집 적용 ==
|
||||||
|
|
||||||
|
Service -> Service: applyContentEdits(eventDraft, editedContent)
|
||||||
|
note right
|
||||||
|
편집 내용 적용:
|
||||||
|
- 제목 텍스트
|
||||||
|
- 경품 정보 텍스트
|
||||||
|
- 참여 안내 텍스트
|
||||||
|
- 배경색
|
||||||
|
- 텍스트 색상
|
||||||
|
- 강조 색상
|
||||||
|
end note
|
||||||
|
|
||||||
|
== 3단계: DB 저장 ==
|
||||||
|
|
||||||
|
Service -> Repo: update(eventDraft)
|
||||||
|
activate Repo
|
||||||
|
Repo -> DB: 이벤트 초안 업데이트\n(선택된 이미지 URL,\n편집된 제목/텍스트,\n배경색/텍스트색,\n수정일시 저장)
|
||||||
|
activate DB
|
||||||
|
DB --> Repo: 업데이트 완료
|
||||||
|
deactivate DB
|
||||||
|
Repo --> Service: EventDraft entity
|
||||||
|
deactivate Repo
|
||||||
|
|
||||||
|
== 4단계: 캐시 무효화 ==
|
||||||
|
|
||||||
|
Service -> Cache: 캐시 삭제\nKey: content:image:{eventDraftId}
|
||||||
|
activate Cache
|
||||||
|
note right
|
||||||
|
Redis 캐시 무효화:
|
||||||
|
- 이미지 URL 캐시 삭제
|
||||||
|
- 선택 완료 후 불필요
|
||||||
|
end note
|
||||||
|
Cache --> Service: 삭제 완료
|
||||||
|
deactivate Cache
|
||||||
|
|
||||||
|
== 5단계: 응답 반환 ==
|
||||||
|
|
||||||
|
Service --> Controller: EventContentResponse\n{eventDraftId, selectedImageUrl,\neditedContent}
|
||||||
|
deactivate Service
|
||||||
|
|
||||||
|
Controller --> Gateway: 200 OK\n{"eventDraftId": 123,\n"status": "콘텐츠 선택 완료",\n"selectedImageUrl": "...",\n"editedContent": {...}}
|
||||||
|
deactivate Controller
|
||||||
|
|
||||||
|
Gateway --> Client: 200 OK\n콘텐츠 선택 완료
|
||||||
|
deactivate Gateway
|
||||||
|
|
||||||
|
note over Client, Cache
|
||||||
|
**저장 내용**
|
||||||
|
- 선택된 이미지 URL
|
||||||
|
- 편집된 텍스트 (제목, 경품 정보, 참여 안내)
|
||||||
|
- 편집된 색상 (배경색, 텍스트색, 강조색)
|
||||||
|
- 다음 단계: 최종 승인 및 배포
|
||||||
|
|
||||||
|
**성능 목표**
|
||||||
|
- 응답 시간: 0.5초 이내
|
||||||
|
- DB 업데이트: 0.1초
|
||||||
|
- 캐시 삭제: 0.01초
|
||||||
|
end note
|
||||||
|
|
||||||
|
@enduml
|
||||||
164
design/backend/sequence/inner/participation-당첨자추첨.puml
Normal file
164
design/backend/sequence/inner/participation-당첨자추첨.puml
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
@startuml participation-당첨자추첨
|
||||||
|
!theme mono
|
||||||
|
|
||||||
|
title Participation Service - 당첨자 추첨 내부 시퀀스
|
||||||
|
|
||||||
|
actor "사장님" as Owner
|
||||||
|
participant "API Gateway" as Gateway
|
||||||
|
participant "ParticipationController" as Controller
|
||||||
|
participant "ParticipationService" as Service
|
||||||
|
participant "LotteryAlgorithm" as Lottery
|
||||||
|
participant "ParticipantRepository" as Repo
|
||||||
|
participant "DrawLogRepository" as LogRepo
|
||||||
|
database "Participation DB" as DB
|
||||||
|
|
||||||
|
== UFR-PART-030: 당첨자 추첨 ==
|
||||||
|
|
||||||
|
Owner -> Gateway: POST /api/v1/events/{eventId}/draw-winners\n{winnerCount, visitBonus, algorithm}
|
||||||
|
activate Gateway
|
||||||
|
|
||||||
|
Gateway -> Gateway: JWT 토큰 검증\n- 토큰 유효성 확인\n- 사장님 권한 확인
|
||||||
|
|
||||||
|
alt JWT 검증 실패
|
||||||
|
Gateway --> Owner: 401 Unauthorized
|
||||||
|
deactivate Gateway
|
||||||
|
else JWT 검증 성공
|
||||||
|
|
||||||
|
Gateway -> Controller: POST /participations/draw-winners\n{eventId, winnerCount, visitBonus}
|
||||||
|
activate Controller
|
||||||
|
|
||||||
|
Controller -> Controller: 요청 데이터 유효성 검증\n- eventId 필수\n- winnerCount > 0\n- winnerCount <= 참여자 수
|
||||||
|
|
||||||
|
alt 유효성 검증 실패
|
||||||
|
Controller --> Gateway: 400 Bad Request
|
||||||
|
Gateway --> Owner: 400 Bad Request
|
||||||
|
deactivate Controller
|
||||||
|
deactivate Gateway
|
||||||
|
else 유효성 검증 성공
|
||||||
|
|
||||||
|
Controller -> Service: drawWinners(eventId, winnerCount, visitBonus)
|
||||||
|
activate Service
|
||||||
|
|
||||||
|
Service -> Service: 이벤트 상태 확인\n- 이벤트 종료 여부\n- 이미 추첨 완료 여부
|
||||||
|
|
||||||
|
Service -> LogRepo: findByEventId(eventId)
|
||||||
|
activate LogRepo
|
||||||
|
LogRepo -> DB: 추첨 로그 조회\n(이벤트ID로 조회)
|
||||||
|
activate DB
|
||||||
|
DB --> LogRepo: 추첨 로그 조회
|
||||||
|
deactivate DB
|
||||||
|
LogRepo --> Service: Optional<DrawLog>
|
||||||
|
deactivate LogRepo
|
||||||
|
|
||||||
|
alt 이미 추첨 완료
|
||||||
|
Service --> Controller: AlreadyDrawnException
|
||||||
|
Controller --> Gateway: 409 Conflict\n{message: "이미 추첨이 완료된 이벤트입니다"}
|
||||||
|
Gateway --> Owner: 409 Conflict
|
||||||
|
deactivate Service
|
||||||
|
deactivate Controller
|
||||||
|
deactivate Gateway
|
||||||
|
else 추첨 가능 상태
|
||||||
|
|
||||||
|
Service -> Repo: findAllByEventIdAndIsWinner(eventId, false)
|
||||||
|
activate Repo
|
||||||
|
Repo -> DB: 미당첨 참여자 목록 조회\n(이벤트ID로 당첨되지 않은\n참여자 전체 조회,\n참여일시 오름차순 정렬)
|
||||||
|
activate DB
|
||||||
|
DB --> Repo: 전체 참여자 목록
|
||||||
|
deactivate DB
|
||||||
|
Repo --> Service: List<Participant>
|
||||||
|
deactivate Repo
|
||||||
|
|
||||||
|
alt 참여자 수 부족
|
||||||
|
Service --> Controller: InsufficientParticipantsException
|
||||||
|
Controller --> Gateway: 400 Bad Request\n{message: "참여자 수가 부족합니다"}
|
||||||
|
Gateway --> Owner: 400 Bad Request
|
||||||
|
deactivate Service
|
||||||
|
deactivate Controller
|
||||||
|
deactivate Gateway
|
||||||
|
else 추첨 진행
|
||||||
|
|
||||||
|
Service -> Lottery: executeLottery(participants, winnerCount, visitBonus)
|
||||||
|
activate Lottery
|
||||||
|
|
||||||
|
note right of Lottery
|
||||||
|
추첨 알고리즘:
|
||||||
|
시간 복잡도: O(n log n)
|
||||||
|
공간 복잡도: O(n)
|
||||||
|
|
||||||
|
1. 난수 생성 (Crypto.randomBytes)
|
||||||
|
2. 매장 방문 가산점 적용 (옵션)
|
||||||
|
- 방문 고객: 가중치 2배
|
||||||
|
- 비방문 고객: 가중치 1배
|
||||||
|
3. Fisher-Yates Shuffle
|
||||||
|
- 가중치 기반 확률 분포
|
||||||
|
- 무작위 섞기
|
||||||
|
4. 상위 N명 선정
|
||||||
|
end note
|
||||||
|
|
||||||
|
Lottery -> Lottery: Step 1: 난수 시드 생성\n- Crypto.randomBytes(32)\n- 예측 불가능한 난수 보장
|
||||||
|
|
||||||
|
Lottery -> Lottery: Step 2: 가산점 적용\n- visitBonus = true일 경우\n- 매장 방문 경로 참여자 가중치 증가
|
||||||
|
|
||||||
|
Lottery -> Lottery: Step 3: Fisher-Yates Shuffle\n- 가중치 기반 확률 분포\n- O(n) 시간 복잡도
|
||||||
|
|
||||||
|
Lottery -> Lottery: Step 4: 당첨자 선정\n- 상위 winnerCount명 추출
|
||||||
|
|
||||||
|
Lottery --> Service: List<Participant> 당첨자 목록
|
||||||
|
deactivate Lottery
|
||||||
|
|
||||||
|
Service -> Service: DB 트랜잭션 시작
|
||||||
|
|
||||||
|
alt DB 저장 실패 시
|
||||||
|
note right of Service
|
||||||
|
트랜잭션 롤백 처리:
|
||||||
|
- 당첨자 업데이트 취소
|
||||||
|
- 추첨 로그 저장 취소
|
||||||
|
- 재시도 가능 상태 유지
|
||||||
|
end note
|
||||||
|
end
|
||||||
|
|
||||||
|
Service -> Repo: updateWinners(winnerIds)
|
||||||
|
activate Repo
|
||||||
|
Repo -> DB: 당첨자 정보 업데이트\n(당첨 여부를 true로,\n당첨 일시를 현재 시각으로 설정,\n대상: 선정된 참여자ID 목록)
|
||||||
|
activate DB
|
||||||
|
DB --> Repo: 업데이트 완료
|
||||||
|
deactivate DB
|
||||||
|
Repo --> Service: 업데이트 건수
|
||||||
|
deactivate Repo
|
||||||
|
|
||||||
|
Service -> Service: DrawLog 엔티티 생성\n- drawLogId (UUID)\n- eventId\n- drawMethod: "RANDOM"\n- algorithm: "FISHER_YATES_SHUFFLE"\n- visitBonusApplied\n- winnerCount\n- drawnAt (현재시각)
|
||||||
|
|
||||||
|
Service -> LogRepo: save(drawLog)
|
||||||
|
activate LogRepo
|
||||||
|
LogRepo -> DB: 추첨 로그 저장\n(추첨로그ID, 이벤트ID,\n추첨방법, 알고리즘,\n가산점적용여부, 당첨인원,\n추첨일시 저장)
|
||||||
|
activate DB
|
||||||
|
note right of DB
|
||||||
|
추첨 로그 저장:
|
||||||
|
- 추첨 일시 기록
|
||||||
|
- 알고리즘 버전 기록
|
||||||
|
- 가산점 적용 여부
|
||||||
|
- 감사 추적 목적
|
||||||
|
end note
|
||||||
|
DB --> LogRepo: 로그 저장 완료
|
||||||
|
deactivate DB
|
||||||
|
LogRepo --> Service: DrawLog 엔티티
|
||||||
|
deactivate LogRepo
|
||||||
|
|
||||||
|
Service -> Service: DB 트랜잭션 커밋
|
||||||
|
|
||||||
|
Service --> Controller: DrawWinnersResponse\n{당첨자목록, 추첨로그ID}
|
||||||
|
deactivate Service
|
||||||
|
|
||||||
|
Controller --> Gateway: 200 OK\n{winners[], drawLogId, message}
|
||||||
|
deactivate Controller
|
||||||
|
|
||||||
|
Gateway --> Owner: 200 OK
|
||||||
|
deactivate Gateway
|
||||||
|
|
||||||
|
Owner -> Owner: 당첨자 목록 화면 표시\n- 당첨자 정보 테이블\n- 재추첨 버튼\n- 추첨 완료 메시지
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@enduml
|
||||||
129
design/backend/sequence/inner/participation-이벤트참여.puml
Normal file
129
design/backend/sequence/inner/participation-이벤트참여.puml
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
@startuml participation-이벤트참여
|
||||||
|
!theme mono
|
||||||
|
|
||||||
|
title Participation Service - 이벤트 참여 내부 시퀀스
|
||||||
|
|
||||||
|
actor "고객" as Customer
|
||||||
|
participant "API Gateway" as Gateway
|
||||||
|
participant "ParticipationController" as Controller
|
||||||
|
participant "ParticipationService" as Service
|
||||||
|
participant "ParticipantRepository" as Repo
|
||||||
|
database "Participation DB" as DB
|
||||||
|
participant "KafkaProducer" as Kafka
|
||||||
|
database "Redis Cache<<E>>" as Cache
|
||||||
|
|
||||||
|
== UFR-PART-010: 이벤트 참여 ==
|
||||||
|
|
||||||
|
Customer -> Gateway: POST /api/v1/participations\n{name, phone, eventId, entryPath, consent}
|
||||||
|
activate Gateway
|
||||||
|
|
||||||
|
note right of Gateway
|
||||||
|
비회원 참여 가능
|
||||||
|
JWT 검증 불필요
|
||||||
|
end note
|
||||||
|
|
||||||
|
Gateway -> Controller: POST /participations/register\n{name, phone, eventId, entryPath, consent}
|
||||||
|
activate Controller
|
||||||
|
|
||||||
|
Controller -> Controller: 요청 데이터 유효성 검증\n- 이름 2자 이상\n- 전화번호 형식 (정규식)\n- 개인정보 동의 필수
|
||||||
|
|
||||||
|
alt 유효성 검증 실패
|
||||||
|
Controller --> Gateway: 400 Bad Request\n{message: "유효성 오류"}
|
||||||
|
Gateway --> Customer: 400 Bad Request
|
||||||
|
deactivate Controller
|
||||||
|
deactivate Gateway
|
||||||
|
else 유효성 검증 성공
|
||||||
|
|
||||||
|
Controller -> Service: registerParticipant(request)
|
||||||
|
activate Service
|
||||||
|
|
||||||
|
Service -> Cache: GET duplicate_check:{eventId}:{phone}
|
||||||
|
activate Cache
|
||||||
|
Cache --> Service: 캐시 확인 결과
|
||||||
|
deactivate Cache
|
||||||
|
|
||||||
|
alt 캐시 HIT: 중복 참여
|
||||||
|
Service --> Controller: DuplicateParticipationException
|
||||||
|
Controller --> Gateway: 409 Conflict\n{message: "이미 참여하신 이벤트입니다"}
|
||||||
|
Gateway --> Customer: 409 Conflict
|
||||||
|
deactivate Service
|
||||||
|
deactivate Controller
|
||||||
|
deactivate Gateway
|
||||||
|
else 캐시 MISS: DB 조회
|
||||||
|
|
||||||
|
Service -> Repo: findByEventIdAndPhoneNumber(eventId, phone)
|
||||||
|
activate Repo
|
||||||
|
Repo -> DB: 참여자 중복 확인\n(이벤트ID, 전화번호로 조회)
|
||||||
|
activate DB
|
||||||
|
DB --> Repo: 조회 결과
|
||||||
|
deactivate DB
|
||||||
|
Repo --> Service: Optional<Participant>
|
||||||
|
deactivate Repo
|
||||||
|
|
||||||
|
alt DB에 중복 참여 존재
|
||||||
|
Service -> Cache: SET duplicate_check:{eventId}:{phone} = true\nTTL: 7일
|
||||||
|
activate Cache
|
||||||
|
Cache --> Service: 캐시 저장 완료
|
||||||
|
deactivate Cache
|
||||||
|
|
||||||
|
Service --> Controller: DuplicateParticipationException
|
||||||
|
Controller --> Gateway: 409 Conflict\n{message: "이미 참여하신 이벤트입니다"}
|
||||||
|
Gateway --> Customer: 409 Conflict
|
||||||
|
deactivate Service
|
||||||
|
deactivate Controller
|
||||||
|
deactivate Gateway
|
||||||
|
else 신규 참여: 저장 진행
|
||||||
|
|
||||||
|
Service -> Service: 응모 번호 생성\n- UUID 기반\n- 형식: EVT-{timestamp}-{random}
|
||||||
|
|
||||||
|
Service -> Service: Participant 엔티티 생성\n- participantId (UUID)\n- eventId\n- name, phoneNumber\n- entryPath\n- applicationNumber (응모번호)\n- participatedAt (현재시각)
|
||||||
|
|
||||||
|
Service -> Repo: save(participant)
|
||||||
|
activate Repo
|
||||||
|
Repo -> DB: 참여자 정보 저장\n(참여자ID, 이벤트ID, 이름, 전화번호,\n참여경로, 응모번호, 참여일시,\n마케팅동의여부)
|
||||||
|
activate DB
|
||||||
|
DB --> Repo: 저장 완료
|
||||||
|
deactivate DB
|
||||||
|
Repo --> Service: Participant 엔티티 반환
|
||||||
|
deactivate Repo
|
||||||
|
|
||||||
|
note right of Service
|
||||||
|
참여자 등록 완료 후
|
||||||
|
캐싱 및 이벤트 발행
|
||||||
|
end note
|
||||||
|
|
||||||
|
Service -> Cache: SET duplicate_check:{eventId}:{phone} = true\nTTL: 7일
|
||||||
|
activate Cache
|
||||||
|
Cache --> Service: 캐시 저장 완료
|
||||||
|
deactivate Cache
|
||||||
|
|
||||||
|
Service -> Kafka: Publish Event\n"ParticipantRegistered"\nTopic: participant-events
|
||||||
|
activate Kafka
|
||||||
|
note right of Kafka
|
||||||
|
Event Payload:
|
||||||
|
{
|
||||||
|
"participantId": "UUID",
|
||||||
|
"eventId": "UUID",
|
||||||
|
"phoneNumber": "010-1234-5678",
|
||||||
|
"entryPath": "SNS",
|
||||||
|
"registeredAt": "2025-10-22T10:30:00Z"
|
||||||
|
}
|
||||||
|
end note
|
||||||
|
Kafka --> Service: 이벤트 발행 완료
|
||||||
|
deactivate Kafka
|
||||||
|
|
||||||
|
Service -> Service: 당첨 발표일 계산\n- 이벤트 종료일 + 3일
|
||||||
|
|
||||||
|
Service --> Controller: ParticipationResponse\n{응모번호, 당첨발표일, 참여완료메시지}
|
||||||
|
deactivate Service
|
||||||
|
|
||||||
|
Controller --> Gateway: 201 Created\n{applicationNumber, drawDate, message}
|
||||||
|
deactivate Controller
|
||||||
|
|
||||||
|
Gateway --> Customer: 201 Created\n참여 완료 화면 표시
|
||||||
|
deactivate Gateway
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@enduml
|
||||||
120
design/backend/sequence/inner/participation-참여자목록조회.puml
Normal file
120
design/backend/sequence/inner/participation-참여자목록조회.puml
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
@startuml participation-참여자목록조회
|
||||||
|
!theme mono
|
||||||
|
|
||||||
|
title Participation Service - 참여자 목록 조회 내부 시퀀스
|
||||||
|
|
||||||
|
actor "사장님" as Owner
|
||||||
|
participant "API Gateway" as Gateway
|
||||||
|
participant "ParticipationController" as Controller
|
||||||
|
participant "ParticipationService" as Service
|
||||||
|
participant "ParticipantRepository" as Repo
|
||||||
|
database "Participation DB" as DB
|
||||||
|
database "Redis Cache<<E>>" as Cache
|
||||||
|
|
||||||
|
== UFR-PART-020: 참여자 목록 조회 ==
|
||||||
|
|
||||||
|
Owner -> Gateway: GET /api/v1/events/{eventId}/participants\n?entryPath={경로}&isWinner={당첨여부}\n&name={이름}&phone={전화번호}\n&page={페이지}&size={크기}
|
||||||
|
activate Gateway
|
||||||
|
|
||||||
|
Gateway -> Gateway: JWT 토큰 검증\n- 토큰 유효성 확인\n- 사장님 권한 확인
|
||||||
|
|
||||||
|
alt JWT 검증 실패
|
||||||
|
Gateway --> Owner: 401 Unauthorized
|
||||||
|
deactivate Gateway
|
||||||
|
else JWT 검증 성공
|
||||||
|
|
||||||
|
Gateway -> Controller: GET /participants\n{eventId, filters, pagination}
|
||||||
|
activate Controller
|
||||||
|
|
||||||
|
Controller -> Controller: 요청 파라미터 유효성 검증\n- eventId 필수\n- page >= 0\n- size: 10~100
|
||||||
|
|
||||||
|
alt 유효성 검증 실패
|
||||||
|
Controller --> Gateway: 400 Bad Request
|
||||||
|
Gateway --> Owner: 400 Bad Request
|
||||||
|
deactivate Controller
|
||||||
|
deactivate Gateway
|
||||||
|
else 유효성 검증 성공
|
||||||
|
|
||||||
|
Controller -> Service: getParticipantList(eventId, filters, pageable)
|
||||||
|
activate Service
|
||||||
|
|
||||||
|
Service -> Service: 캐시 키 생성\n- participant_list:{eventId}:{filters}:{page}
|
||||||
|
|
||||||
|
Service -> Cache: GET participant_list:{key}
|
||||||
|
activate Cache
|
||||||
|
Cache --> Service: 캐시 조회 결과
|
||||||
|
deactivate Cache
|
||||||
|
|
||||||
|
alt 캐시 HIT
|
||||||
|
Service --> Controller: ParticipantListResponse\n(캐시된 데이터)
|
||||||
|
note right of Service
|
||||||
|
캐시된 데이터 반환
|
||||||
|
- TTL: 10분
|
||||||
|
- 실시간 정확도 vs 성능 트레이드오프
|
||||||
|
end note
|
||||||
|
Controller --> Gateway: 200 OK\n{participants, totalElements, totalPages}
|
||||||
|
Gateway --> Owner: 200 OK\n참여자 목록 표시
|
||||||
|
deactivate Service
|
||||||
|
deactivate Controller
|
||||||
|
deactivate Gateway
|
||||||
|
else 캐시 MISS: DB 조회
|
||||||
|
|
||||||
|
Service -> Service: 동적 쿼리 생성\n- 참여 경로 필터\n- 당첨 여부 필터\n- 이름/전화번호 검색
|
||||||
|
|
||||||
|
Service -> Repo: findParticipants(eventId, filters, pageable)
|
||||||
|
activate Repo
|
||||||
|
|
||||||
|
Repo -> DB: 참여자 목록 조회\n(이벤트ID, 참여경로, 당첨여부,\n이름/전화번호 검색조건으로 필터링하여\n참여일시 내림차순으로 페이징 조회)
|
||||||
|
activate DB
|
||||||
|
|
||||||
|
note right of DB
|
||||||
|
동적 쿼리 조건:
|
||||||
|
- entryPath 필터 (선택)
|
||||||
|
- isWinner 필터 (선택)
|
||||||
|
- name/phone 검색 (선택)
|
||||||
|
- 페이지네이션 (필수)
|
||||||
|
|
||||||
|
필요 인덱스:
|
||||||
|
idx_participants_event_filters
|
||||||
|
(event_id, entry_path, is_winner, participated_at DESC)
|
||||||
|
end note
|
||||||
|
|
||||||
|
DB --> Repo: 참여자 목록 결과셋
|
||||||
|
deactivate DB
|
||||||
|
|
||||||
|
Repo -> DB: 전체 참여자 수 조회\n(동일한 필터 조건 적용)
|
||||||
|
activate DB
|
||||||
|
DB --> Repo: 전체 건수
|
||||||
|
deactivate DB
|
||||||
|
|
||||||
|
Repo --> Service: Page<Participant>
|
||||||
|
deactivate Repo
|
||||||
|
|
||||||
|
Service -> Service: DTO 변환\n- 전화번호 마스킹 (010-****-1234)\n- 응모번호 형식화\n- 당첨 여부 라벨 변환
|
||||||
|
|
||||||
|
Service -> Cache: SET participant_list:{key} = data\nTTL: 10분
|
||||||
|
activate Cache
|
||||||
|
note right of Cache
|
||||||
|
캐시 저장:
|
||||||
|
- TTL: 10분
|
||||||
|
- 실시간 참여 반영과 성능 균형
|
||||||
|
- 이벤트 참여 빈도 고려
|
||||||
|
end note
|
||||||
|
Cache --> Service: 캐시 저장 완료
|
||||||
|
deactivate Cache
|
||||||
|
|
||||||
|
Service --> Controller: ParticipantListResponse\n{participants[], totalElements, totalPages, currentPage}
|
||||||
|
deactivate Service
|
||||||
|
|
||||||
|
Controller --> Gateway: 200 OK\n{data, pagination}
|
||||||
|
deactivate Controller
|
||||||
|
|
||||||
|
Gateway --> Owner: 200 OK
|
||||||
|
deactivate Gateway
|
||||||
|
|
||||||
|
Owner -> Owner: 참여자 목록 화면 표시\n- 테이블 형태\n- 페이지네이션\n- 필터/검색 UI
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@enduml
|
||||||
155
design/backend/sequence/inner/user-로그아웃.puml
Normal file
155
design/backend/sequence/inner/user-로그아웃.puml
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
@startuml user-로그아웃
|
||||||
|
!theme mono
|
||||||
|
|
||||||
|
title User Service - 로그아웃 내부 시퀀스 (UFR-USER-040)
|
||||||
|
|
||||||
|
actor Client
|
||||||
|
participant "UserController" as Controller <<API Layer>>
|
||||||
|
participant "AuthenticationService" as AuthService <<Business Layer>>
|
||||||
|
participant "JwtTokenProvider" as JwtProvider <<Utility>>
|
||||||
|
participant "Redis\nCache" as Redis <<E>>
|
||||||
|
|
||||||
|
note over Controller, Redis
|
||||||
|
**UFR-USER-040: 로그아웃**
|
||||||
|
- JWT 토큰 검증
|
||||||
|
- Redis 세션 삭제
|
||||||
|
- 클라이언트 측 토큰 삭제 (프론트엔드 처리)
|
||||||
|
end note
|
||||||
|
|
||||||
|
Client -> Controller: POST /api/users/logout\nAuthorization: Bearer {JWT}
|
||||||
|
activate Controller
|
||||||
|
|
||||||
|
Controller -> Controller: @AuthenticationPrincipal\n(JWT에서 userId 추출)
|
||||||
|
|
||||||
|
Controller -> Controller: JWT 토큰 추출\n(Authorization 헤더에서)
|
||||||
|
|
||||||
|
Controller -> AuthService: logout(token, userId)
|
||||||
|
activate AuthService
|
||||||
|
|
||||||
|
== 1단계: JWT 토큰 검증 ==
|
||||||
|
|
||||||
|
AuthService -> JwtProvider: validateToken(token)
|
||||||
|
activate JwtProvider
|
||||||
|
JwtProvider -> JwtProvider: JWT 서명 검증\n(만료 시간 확인)
|
||||||
|
JwtProvider --> AuthService: boolean (유효 여부)
|
||||||
|
deactivate JwtProvider
|
||||||
|
|
||||||
|
alt JWT 토큰 무효
|
||||||
|
AuthService --> Controller: throw InvalidTokenException\n("유효하지 않은 토큰입니다")
|
||||||
|
Controller --> Client: 401 Unauthorized\n{"code": "AUTH_002",\n"error": "유효하지 않은 토큰입니다"}
|
||||||
|
deactivate AuthService
|
||||||
|
deactivate Controller
|
||||||
|
|
||||||
|
else JWT 토큰 유효
|
||||||
|
|
||||||
|
== 2단계: Redis 세션 삭제 ==
|
||||||
|
|
||||||
|
AuthService -> Redis: 세션 삭제\n(캐시키: user:session:{token})
|
||||||
|
activate Redis
|
||||||
|
Redis --> AuthService: 삭제된 키 개수 (0 또는 1)
|
||||||
|
deactivate Redis
|
||||||
|
|
||||||
|
alt 세션 없음 (이미 로그아웃됨)
|
||||||
|
note right of AuthService
|
||||||
|
**멱등성 보장**
|
||||||
|
- 세션이 없어도 로그아웃 성공으로 처리
|
||||||
|
- 중복 로그아웃 요청에 안전
|
||||||
|
end note
|
||||||
|
else 세션 있음 (정상 로그아웃)
|
||||||
|
note right of AuthService
|
||||||
|
**세션 삭제 완료**
|
||||||
|
- Redis에서 세션 정보 제거
|
||||||
|
- JWT 토큰 무효화 (Blacklist 방식)
|
||||||
|
end note
|
||||||
|
end
|
||||||
|
|
||||||
|
== 3단계: JWT 토큰 Blacklist 추가 (선택적) ==
|
||||||
|
|
||||||
|
note right of AuthService
|
||||||
|
**JWT Blacklist 전략**
|
||||||
|
- 만료되지 않은 JWT 토큰을 강제로 무효화
|
||||||
|
- Redis에 토큰을 Blacklist에 추가 (TTL: 남은 만료 시간)
|
||||||
|
- API Gateway에서 Blacklist 확인
|
||||||
|
|
||||||
|
**API Gateway 연계 시나리오**
|
||||||
|
1. 로그아웃: AuthService가 Blacklist에 토큰 추가
|
||||||
|
2. 후속 API 요청: API Gateway가 Blacklist 확인
|
||||||
|
- Redis GET jwt:blacklist:{token}
|
||||||
|
- 존재하면: 401 Unauthorized 즉시 반환
|
||||||
|
- 존재하지 않으면: 백엔드 서비스로 라우팅
|
||||||
|
3. 만료 시간 도달: Redis TTL 만료로 자동 삭제
|
||||||
|
end note
|
||||||
|
|
||||||
|
AuthService -> JwtProvider: getRemainingExpiration(token)
|
||||||
|
activate JwtProvider
|
||||||
|
JwtProvider -> JwtProvider: JWT Claims에서\nexp(만료 시간) 추출\n(현재 시간과 비교)
|
||||||
|
JwtProvider --> AuthService: remainingSeconds
|
||||||
|
deactivate JwtProvider
|
||||||
|
|
||||||
|
alt 남은 만료 시간 > 0
|
||||||
|
AuthService -> Redis: 블랙리스트에 토큰 추가\n(캐시키: jwt:blacklist:{token},\n값: "revoked", TTL: 남은초)
|
||||||
|
activate Redis
|
||||||
|
Redis --> AuthService: Blacklist 추가 완료
|
||||||
|
deactivate Redis
|
||||||
|
end
|
||||||
|
|
||||||
|
== 4단계: 로그아웃 이벤트 발행 (선택적) ==
|
||||||
|
|
||||||
|
note right of AuthService
|
||||||
|
**로그아웃 로깅 및 이벤트**
|
||||||
|
- 감사 로그 기록: userId, timestamp, IP
|
||||||
|
- 이벤트 발행: LOGOUT_SUCCESS
|
||||||
|
- 분석 데이터 수집: 세션 지속 시간, 활동 통계
|
||||||
|
end note
|
||||||
|
|
||||||
|
AuthService -> AuthService: 로그아웃 성공 로그 기록\n(userId, timestamp, sessionDuration)
|
||||||
|
|
||||||
|
AuthService ->> AuthService: publishEvent(LOGOUT_SUCCESS)
|
||||||
|
note right of AuthService
|
||||||
|
**이벤트 활용**
|
||||||
|
- 비동기 이벤트 처리
|
||||||
|
- 분석 시스템 연동
|
||||||
|
- 감사 로그 저장소 전송
|
||||||
|
end note
|
||||||
|
|
||||||
|
== 5단계: 응답 반환 ==
|
||||||
|
|
||||||
|
AuthService --> Controller: LogoutResponse\n(success: true)
|
||||||
|
deactivate AuthService
|
||||||
|
|
||||||
|
Controller --> Client: 200 OK\n{"success": true,\n"message": "안전하게 로그아웃되었습니다"}
|
||||||
|
deactivate Controller
|
||||||
|
end
|
||||||
|
|
||||||
|
note over Controller, Redis
|
||||||
|
**보안 처리**
|
||||||
|
- JWT 토큰 Blacklist: 만료 전 토큰 강제 무효화
|
||||||
|
- 멱등성 보장: 중복 로그아웃 요청에 안전
|
||||||
|
- 세션 완전 삭제: Redis에서 세션 정보 제거
|
||||||
|
- 감사 로그: userId, timestamp, IP, sessionDuration 기록
|
||||||
|
|
||||||
|
**API Gateway 연계**
|
||||||
|
- Blacklist 확인: GET jwt:blacklist:{token}
|
||||||
|
- 존재 시: 401 Unauthorized 즉시 반환
|
||||||
|
- TTL 자동 관리: 만료 시간 도달 시 자동 삭제
|
||||||
|
|
||||||
|
**클라이언트 측 처리**
|
||||||
|
- 프론트엔드: LocalStorage 또는 Cookie에서 JWT 토큰 삭제
|
||||||
|
- 로그인 화면으로 리다이렉트
|
||||||
|
- 모든 인증 헤더 제거
|
||||||
|
|
||||||
|
**성능 목표**
|
||||||
|
- Redis 삭제 연산: O(1) 시간 복잡도
|
||||||
|
- 평균 응답 시간: 0.1초 이내
|
||||||
|
- P95 응답 시간: 0.2초 이내
|
||||||
|
|
||||||
|
**이벤트 처리**
|
||||||
|
- LOGOUT_SUCCESS 이벤트 발행 (비동기)
|
||||||
|
- 감사 로그 저장소 전송
|
||||||
|
- 분석 데이터 수집
|
||||||
|
|
||||||
|
**에러 코드**
|
||||||
|
- AUTH_002: JWT 토큰 무효
|
||||||
|
end note
|
||||||
|
|
||||||
|
@enduml
|
||||||
147
design/backend/sequence/inner/user-로그인.puml
Normal file
147
design/backend/sequence/inner/user-로그인.puml
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
@startuml user-로그인
|
||||||
|
!theme mono
|
||||||
|
|
||||||
|
title User Service - 로그인 내부 시퀀스 (UFR-USER-020)
|
||||||
|
|
||||||
|
actor Client
|
||||||
|
participant "UserController" as Controller <<API Layer>>
|
||||||
|
participant "UserService" as Service <<Business Layer>>
|
||||||
|
participant "AuthenticationService" as AuthService <<Business Layer>>
|
||||||
|
participant "UserRepository" as UserRepo <<Data Layer>>
|
||||||
|
participant "PasswordEncoder" as PwdEncoder <<Utility>>
|
||||||
|
participant "JwtTokenProvider" as JwtProvider <<Utility>>
|
||||||
|
participant "Redis\nCache" as Redis <<E>>
|
||||||
|
participant "User DB\n(PostgreSQL)" as UserDB <<E>>
|
||||||
|
|
||||||
|
note over Controller, UserDB
|
||||||
|
**UFR-USER-020: 로그인**
|
||||||
|
- 입력: 이메일, 비밀번호
|
||||||
|
- 비밀번호 검증 (bcrypt compare)
|
||||||
|
- JWT 토큰 발급
|
||||||
|
- 세션 저장 (Redis)
|
||||||
|
- 최종 로그인 시각 업데이트
|
||||||
|
end note
|
||||||
|
|
||||||
|
Client -> Controller: POST /api/users/login\n{"email": "user@example.com",\n"password": "password123"}
|
||||||
|
activate Controller
|
||||||
|
|
||||||
|
Controller -> Controller: 입력값 검증\n(필수 필드, 이메일 형식 확인)
|
||||||
|
|
||||||
|
Controller -> AuthService: authenticate(email, password)
|
||||||
|
activate AuthService
|
||||||
|
|
||||||
|
== 1단계: 사용자 조회 ==
|
||||||
|
|
||||||
|
AuthService -> Service: findByEmail(email)
|
||||||
|
activate Service
|
||||||
|
Service -> UserRepo: findByEmail(email)
|
||||||
|
activate UserRepo
|
||||||
|
UserRepo -> UserDB: 이메일로 사용자 조회\n(사용자ID, 비밀번호해시, 역할,\n이름, 전화번호 조회)
|
||||||
|
activate UserDB
|
||||||
|
UserDB --> UserRepo: 사용자 정보 반환 또는 없음
|
||||||
|
deactivate UserDB
|
||||||
|
UserRepo --> Service: Optional<User>
|
||||||
|
deactivate UserRepo
|
||||||
|
Service --> AuthService: Optional<User>
|
||||||
|
deactivate Service
|
||||||
|
|
||||||
|
alt 사용자 없음
|
||||||
|
AuthService --> Controller: throw AuthenticationFailedException\n("이메일 또는 비밀번호를 확인해주세요")
|
||||||
|
Controller --> Client: 401 Unauthorized\n{"code": "AUTH_001",\n"message": "이메일 또는 비밀번호를\n확인해주세요"}
|
||||||
|
deactivate AuthService
|
||||||
|
deactivate Controller
|
||||||
|
|
||||||
|
else 사용자 존재
|
||||||
|
|
||||||
|
== 2단계: 비밀번호 검증 ==
|
||||||
|
|
||||||
|
AuthService -> PwdEncoder: matches(rawPassword, passwordHash)
|
||||||
|
activate PwdEncoder
|
||||||
|
PwdEncoder -> PwdEncoder: bcrypt compare\n(입력 비밀번호 vs 저장된 해시)
|
||||||
|
PwdEncoder --> AuthService: boolean (일치 여부)
|
||||||
|
deactivate PwdEncoder
|
||||||
|
|
||||||
|
alt 비밀번호 불일치
|
||||||
|
AuthService --> Controller: throw AuthenticationFailedException\n("이메일 또는 비밀번호를 확인해주세요")
|
||||||
|
Controller --> Client: 401 Unauthorized\n{"code": "AUTH_001",\n"message": "이메일 또는 비밀번호를\n확인해주세요"}
|
||||||
|
deactivate AuthService
|
||||||
|
deactivate Controller
|
||||||
|
|
||||||
|
else 비밀번호 일치
|
||||||
|
|
||||||
|
== 3단계: JWT 토큰 생성 ==
|
||||||
|
|
||||||
|
AuthService -> JwtProvider: generateToken(userId, role)
|
||||||
|
activate JwtProvider
|
||||||
|
JwtProvider -> JwtProvider: JWT 토큰 생성\n(Claims: userId, role=OWNER,\nexp=7일)
|
||||||
|
JwtProvider --> AuthService: JWT 토큰
|
||||||
|
deactivate JwtProvider
|
||||||
|
|
||||||
|
== 4단계: 세션 저장 ==
|
||||||
|
|
||||||
|
AuthService -> Redis: 세션 정보 저장\nKey: user:session:{token}\nValue: {userId, role}\nTTL: 7일
|
||||||
|
activate Redis
|
||||||
|
Redis --> AuthService: 저장 완료
|
||||||
|
deactivate Redis
|
||||||
|
|
||||||
|
== 5단계: 최종 로그인 시각 업데이트 (비동기) ==
|
||||||
|
|
||||||
|
AuthService ->> Service: updateLastLoginAt(userId)
|
||||||
|
activate Service
|
||||||
|
note right of Service
|
||||||
|
**비동기 처리**
|
||||||
|
- @Async 어노테이션 사용
|
||||||
|
- 로그인 응답 지연 방지
|
||||||
|
- 별도 스레드풀에서 실행
|
||||||
|
end note
|
||||||
|
Service ->> UserRepo: updateLastLoginAt(userId)
|
||||||
|
activate UserRepo
|
||||||
|
UserRepo ->> UserDB: 사용자 최종 로그인 시각 갱신\n(현재 시각으로 업데이트)
|
||||||
|
activate UserDB
|
||||||
|
UserDB -->> UserRepo: 업데이트 완료
|
||||||
|
deactivate UserDB
|
||||||
|
UserRepo -->> Service: void
|
||||||
|
deactivate UserRepo
|
||||||
|
Service -->> AuthService: void (비동기 완료)
|
||||||
|
deactivate Service
|
||||||
|
|
||||||
|
note over AuthService, Service
|
||||||
|
**비동기 화살표 설명**
|
||||||
|
- `->>`: 비동기 호출 (호출 후 즉시 반환)
|
||||||
|
- `-->>`: 비동기 응답 (별도 스레드에서 완료)
|
||||||
|
end note
|
||||||
|
|
||||||
|
== 6단계: 응답 반환 ==
|
||||||
|
|
||||||
|
AuthService -> AuthService: 로그인 응답 DTO 생성
|
||||||
|
AuthService --> Controller: LoginResponse\n{token, userId, userName, role, email}
|
||||||
|
deactivate AuthService
|
||||||
|
|
||||||
|
Controller --> Client: 200 OK\n{"token": "eyJhbGc...",\n"userId": 123,\n"userName": "홍길동",\n"role": "OWNER",\n"email": "hong@example.com"}
|
||||||
|
deactivate Controller
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
note over Controller, UserDB
|
||||||
|
**보안 처리**
|
||||||
|
- 비밀번호: bcrypt compare (원본 노출 안 됨)
|
||||||
|
- 에러 메시지: 이메일/비밀번호 구분 없이 동일 메시지 반환 (Timing Attack 방어)
|
||||||
|
- JWT 토큰: 7일 만료, 서버 세션과 동기화
|
||||||
|
|
||||||
|
**보안 강화 (향후 구현)**
|
||||||
|
- Rate Limiting: IP당 5분에 5회 로그인 실패 시 임시 차단 (15분)
|
||||||
|
- Account Lockout: 동일 계정 10회 실패 시 계정 잠금 (관리자 해제)
|
||||||
|
- MFA: 2단계 인증 추가 (SMS/TOTP)
|
||||||
|
- Anomaly Detection: 비정상 로그인 패턴 감지 (지역, 디바이스 변경)
|
||||||
|
|
||||||
|
**성능 최적화**
|
||||||
|
- 최종 로그인 시각 업데이트: 비동기 처리 (@Async)
|
||||||
|
- 평균 응답 시간: 0.5초 이내
|
||||||
|
- P95 응답 시간: 1.0초 이내
|
||||||
|
- Redis 세션 조회: 0.1초 이내
|
||||||
|
|
||||||
|
**에러 코드**
|
||||||
|
- AUTH_001: 인증 실패 (이메일 또는 비밀번호 불일치)
|
||||||
|
end note
|
||||||
|
|
||||||
|
@enduml
|
||||||
233
design/backend/sequence/inner/user-프로필수정.puml
Normal file
233
design/backend/sequence/inner/user-프로필수정.puml
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
@startuml user-프로필수정
|
||||||
|
!theme mono
|
||||||
|
|
||||||
|
title User Service - 프로필 수정 내부 시퀀스 (UFR-USER-030)
|
||||||
|
|
||||||
|
actor Client
|
||||||
|
participant "UserController" as Controller <<API Layer>>
|
||||||
|
participant "UserService" as Service <<Business Layer>>
|
||||||
|
participant "UserRepository" as UserRepo <<Data Layer>>
|
||||||
|
participant "StoreRepository" as StoreRepo <<Data Layer>>
|
||||||
|
participant "PasswordEncoder" as PwdEncoder <<Utility>>
|
||||||
|
participant "Redis\nCache" as Redis <<E>>
|
||||||
|
participant "User DB\n(PostgreSQL)" as UserDB <<E>>
|
||||||
|
|
||||||
|
note over Controller, UserDB
|
||||||
|
**UFR-USER-030: 프로필 수정**
|
||||||
|
- 기본 정보: 이름, 전화번호, 이메일
|
||||||
|
- 매장 정보: 매장명, 업종, 주소, 영업시간
|
||||||
|
- 비밀번호 변경 (현재 비밀번호 확인 필수)
|
||||||
|
- 전화번호 변경 시 재인증 필요 (향후 구현)
|
||||||
|
end note
|
||||||
|
|
||||||
|
Client -> Controller: PUT /api/users/profile\nAuthorization: Bearer {JWT}\n(UpdateProfileRequest DTO)
|
||||||
|
activate Controller
|
||||||
|
|
||||||
|
Controller -> Controller: @AuthenticationPrincipal\n(JWT에서 userId 추출)
|
||||||
|
|
||||||
|
Controller -> Controller: @Valid 어노테이션 검증\n(이메일 형식, 필드 길이 등)
|
||||||
|
|
||||||
|
Controller -> Service: updateProfile(userId, UpdateProfileRequest)
|
||||||
|
activate Service
|
||||||
|
|
||||||
|
== 1단계: 기존 사용자 정보 조회 ==
|
||||||
|
|
||||||
|
Service -> UserRepo: findById(userId)
|
||||||
|
activate UserRepo
|
||||||
|
UserRepo -> UserDB: 사용자ID로 사용자 조회\n(사용자 정보 조회)
|
||||||
|
activate UserDB
|
||||||
|
UserDB --> UserRepo: 사용자 정보
|
||||||
|
deactivate UserDB
|
||||||
|
UserRepo --> Service: User 엔티티
|
||||||
|
deactivate UserRepo
|
||||||
|
|
||||||
|
alt 사용자 없음
|
||||||
|
Service --> Controller: throw UserNotFoundException\n("사용자를 찾을 수 없습니다")
|
||||||
|
Controller --> Client: 404 Not Found\n{"code": "USER_003",\n"error": "사용자를 찾을 수 없습니다"}
|
||||||
|
deactivate Service
|
||||||
|
deactivate Controller
|
||||||
|
|
||||||
|
else 사용자 존재
|
||||||
|
|
||||||
|
== 2단계: 비밀번호 변경 요청 처리 ==
|
||||||
|
|
||||||
|
alt 비밀번호 변경 요청 O
|
||||||
|
Service -> Service: 현재 비밀번호 검증 필요 확인
|
||||||
|
|
||||||
|
Service -> PwdEncoder: matches(currentPassword,\nuser.getPasswordHash())
|
||||||
|
activate PwdEncoder
|
||||||
|
PwdEncoder -> PwdEncoder: bcrypt compare
|
||||||
|
PwdEncoder --> Service: boolean (일치 여부)
|
||||||
|
deactivate PwdEncoder
|
||||||
|
|
||||||
|
alt 현재 비밀번호 불일치
|
||||||
|
Service --> Controller: throw InvalidPasswordException\n("현재 비밀번호가 일치하지 않습니다")
|
||||||
|
Controller --> Client: 400 Bad Request\n{"code": "USER_004",\n"error": "현재 비밀번호가\n일치하지 않습니다"}
|
||||||
|
deactivate Service
|
||||||
|
deactivate Controller
|
||||||
|
|
||||||
|
else 현재 비밀번호 일치
|
||||||
|
Service -> Service: 새 비밀번호 유효성 검증\n(8자 이상, 영문/숫자/특수문자)
|
||||||
|
|
||||||
|
Service -> PwdEncoder: encode(newPassword)
|
||||||
|
activate PwdEncoder
|
||||||
|
PwdEncoder -> PwdEncoder: bcrypt 해싱\n(Cost Factor 10)
|
||||||
|
PwdEncoder --> Service: newPasswordHash
|
||||||
|
deactivate PwdEncoder
|
||||||
|
|
||||||
|
Service -> Service: user.setPasswordHash(newPasswordHash)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
== 3단계: 엔티티 수정 준비 (메모리상 변경) ==
|
||||||
|
|
||||||
|
note right of Service
|
||||||
|
**JPA Dirty Checking**
|
||||||
|
- 트랜잭션 시작 전 엔티티 수정 (메모리상)
|
||||||
|
- 트랜잭션 커밋 시 변경 감지하여 UPDATE 자동 실행
|
||||||
|
- 변경된 필드만 UPDATE 쿼리에 포함
|
||||||
|
end note
|
||||||
|
|
||||||
|
alt 이름 변경
|
||||||
|
Service -> Service: user.setName(newName)
|
||||||
|
end
|
||||||
|
|
||||||
|
alt 전화번호 변경
|
||||||
|
Service -> Service: user.setPhoneNumber(newPhoneNumber)
|
||||||
|
note right of Service
|
||||||
|
**향후 구현: 재인증 필요**
|
||||||
|
- SMS 인증 또는 이메일 인증
|
||||||
|
- 인증 완료 후에만 변경 반영
|
||||||
|
end note
|
||||||
|
end
|
||||||
|
|
||||||
|
alt 이메일 변경
|
||||||
|
Service -> Service: user.setEmail(newEmail)
|
||||||
|
end
|
||||||
|
|
||||||
|
== 4단계: 매장 정보 수정 준비 (메모리상 변경) ==
|
||||||
|
|
||||||
|
Service -> StoreRepo: findByUserId(userId)
|
||||||
|
activate StoreRepo
|
||||||
|
StoreRepo -> UserDB: 사용자ID로 매장 조회\n(매장 정보 조회)
|
||||||
|
activate UserDB
|
||||||
|
UserDB --> StoreRepo: 매장 정보
|
||||||
|
deactivate UserDB
|
||||||
|
StoreRepo --> Service: Store 엔티티
|
||||||
|
deactivate StoreRepo
|
||||||
|
|
||||||
|
alt 매장명 변경
|
||||||
|
Service -> Service: store.setStoreName(newStoreName)
|
||||||
|
end
|
||||||
|
|
||||||
|
alt 업종 변경
|
||||||
|
Service -> Service: store.setIndustry(newIndustry)
|
||||||
|
end
|
||||||
|
|
||||||
|
alt 주소 변경
|
||||||
|
Service -> Service: store.setAddress(newAddress)
|
||||||
|
end
|
||||||
|
|
||||||
|
alt 영업시간 변경
|
||||||
|
Service -> Service: store.setBusinessHours(newBusinessHours)
|
||||||
|
end
|
||||||
|
|
||||||
|
== 5단계: 데이터베이스 트랜잭션 ==
|
||||||
|
|
||||||
|
Service -> UserDB: 트랜잭션 시작
|
||||||
|
activate UserDB
|
||||||
|
|
||||||
|
note right of Service
|
||||||
|
**Optimistic Locking**
|
||||||
|
- @Version 필드로 동시 수정 감지
|
||||||
|
- 다른 트랜잭션이 먼저 수정한 경우
|
||||||
|
- OptimisticLockException 발생
|
||||||
|
end note
|
||||||
|
|
||||||
|
Service -> UserRepo: save(user)
|
||||||
|
activate UserRepo
|
||||||
|
UserRepo -> UserDB: 사용자 정보 업데이트\n(이름, 전화번호, 이메일,\n비밀번호해시, 수정일시,\n버전 증가)\nOptimistic Lock 적용
|
||||||
|
UserDB --> UserRepo: 업데이트 완료 (1 row affected)
|
||||||
|
UserRepo --> Service: User 엔티티
|
||||||
|
deactivate UserRepo
|
||||||
|
|
||||||
|
alt 동시성 충돌 (version 불일치)
|
||||||
|
UserRepo --> Service: throw OptimisticLockException
|
||||||
|
Service --> Controller: throw ConcurrentModificationException\n("다른 사용자가 수정 중입니다")
|
||||||
|
Controller --> Client: 409 Conflict\n{"code": "USER_005",\n"error": "다른 세션에서 프로필을\n수정했습니다.\n새로고침 후 다시 시도하세요"}
|
||||||
|
Service -> UserDB: 트랜잭션 롤백
|
||||||
|
deactivate UserDB
|
||||||
|
deactivate Service
|
||||||
|
deactivate Controller
|
||||||
|
else 정상 업데이트
|
||||||
|
|
||||||
|
Service -> StoreRepo: save(store)
|
||||||
|
activate StoreRepo
|
||||||
|
StoreRepo -> UserDB: 매장 정보 업데이트\n(매장명, 업종, 주소,\n영업시간, 수정일시,\n버전 증가)\nOptimistic Lock 적용
|
||||||
|
UserDB --> StoreRepo: 업데이트 완료 (1 row affected)
|
||||||
|
StoreRepo --> Service: Store 엔티티
|
||||||
|
deactivate StoreRepo
|
||||||
|
|
||||||
|
Service -> UserDB: 트랜잭션 커밋
|
||||||
|
UserDB --> Service: 트랜잭션 커밋 완료
|
||||||
|
deactivate UserDB
|
||||||
|
|
||||||
|
== 6단계: 캐시 무효화 (선택적) ==
|
||||||
|
|
||||||
|
note right of Service
|
||||||
|
**캐시 무효화 전략**
|
||||||
|
- 세션 정보는 변경 없음 (JWT 유지)
|
||||||
|
- 프로필 캐시가 있다면 무효화
|
||||||
|
end note
|
||||||
|
|
||||||
|
alt 프로필 캐시 사용 중
|
||||||
|
Service -> Redis: 프로필 캐시 삭제\n(캐시키: user:profile:{userId})
|
||||||
|
activate Redis
|
||||||
|
Redis --> Service: 캐시 삭제 완료
|
||||||
|
deactivate Redis
|
||||||
|
end
|
||||||
|
|
||||||
|
== 7단계: 응답 반환 ==
|
||||||
|
|
||||||
|
Service -> Service: 응답 DTO 생성\n(UpdateProfileResponse)
|
||||||
|
Service --> Controller: UpdateProfileResponse\n(userId, userName, email,\nstoreId, storeName)
|
||||||
|
deactivate Service
|
||||||
|
|
||||||
|
Controller --> Client: 200 OK\n{"userId": 123,\n"userName": "홍길동",\n"email": "hong@example.com",\n"storeId": 456,\n"storeName": "맛있는집"}
|
||||||
|
deactivate Controller
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
note over Controller, UserDB
|
||||||
|
**Transaction Rollback 처리**
|
||||||
|
- 트랜잭션 실패 시 자동 Rollback
|
||||||
|
- User/Store UPDATE 중 하나라도 실패 시 전체 롤백
|
||||||
|
- OptimisticLockException 발생 시 409 Conflict 반환
|
||||||
|
|
||||||
|
**동시성 제어**
|
||||||
|
- Optimistic Locking: @Version 필드로 동시 수정 감지
|
||||||
|
- 충돌 감지 시: 409 Conflict 반환 (사용자에게 재시도 안내)
|
||||||
|
- Lost Update 방지: version 필드 자동 증가
|
||||||
|
|
||||||
|
**보안 처리**
|
||||||
|
- 비밀번호 변경: 현재 비밀번호 확인 필수
|
||||||
|
- JWT 인증: Controller에서 @AuthenticationPrincipal로 userId 추출
|
||||||
|
- 권한 검증: 본인만 수정 가능
|
||||||
|
|
||||||
|
**성능 목표**
|
||||||
|
- 평균 응답 시간: 0.3초 이내
|
||||||
|
- P95 응답 시간: 0.5초 이내
|
||||||
|
- 트랜잭션 격리 수준: READ_COMMITTED
|
||||||
|
|
||||||
|
**향후 개선사항**
|
||||||
|
- 전화번호 변경: SMS/이메일 재인증 구현
|
||||||
|
- 이메일 변경: 이메일 인증 구현
|
||||||
|
- 변경 이력 추적: Audit Log 기록
|
||||||
|
|
||||||
|
**에러 코드**
|
||||||
|
- USER_003: 사용자 없음
|
||||||
|
- USER_004: 현재 비밀번호 불일치
|
||||||
|
- USER_005: 동시성 충돌 (다른 세션에서 수정)
|
||||||
|
end note
|
||||||
|
|
||||||
|
@enduml
|
||||||
149
design/backend/sequence/inner/user-회원가입.puml
Normal file
149
design/backend/sequence/inner/user-회원가입.puml
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
@startuml user-회원가입
|
||||||
|
!theme mono
|
||||||
|
|
||||||
|
title User Service - 회원가입 내부 시퀀스 (UFR-USER-010)
|
||||||
|
|
||||||
|
participant "UserController" as Controller <<API Layer>>
|
||||||
|
participant "UserService" as Service <<Business Layer>>
|
||||||
|
participant "UserRepository" as UserRepo <<Data Layer>>
|
||||||
|
participant "StoreRepository" as StoreRepo <<Data Layer>>
|
||||||
|
participant "PasswordEncoder" as PwdEncoder <<Utility>>
|
||||||
|
participant "JwtTokenProvider" as JwtProvider <<Utility>>
|
||||||
|
participant "Redis\nCache" as Redis <<E>>
|
||||||
|
participant "User DB\n(PostgreSQL)" as UserDB <<E>>
|
||||||
|
actor Client
|
||||||
|
|
||||||
|
note over Controller, UserDB
|
||||||
|
**UFR-USER-010: 회원가입**
|
||||||
|
- 기본 정보: 이름, 전화번호, 이메일, 비밀번호
|
||||||
|
- 매장 정보: 매장명, 업종, 주소, 영업시간, 사업자번호
|
||||||
|
- 이메일/전화번호 중복 검사
|
||||||
|
- 트랜잭션 처리
|
||||||
|
- JWT 토큰 발급
|
||||||
|
end note
|
||||||
|
|
||||||
|
Client -> Controller: POST /api/users/register\n{"name": "홍길동",\n"phoneNumber": "01012345678",\n"email": "hong@example.com",\n"password": "password123"}
|
||||||
|
activate Controller
|
||||||
|
|
||||||
|
Controller -> Controller: 입력값 검증\n(이메일 형식, 비밀번호 8자 이상 등)
|
||||||
|
|
||||||
|
Controller -> Service: register(RegisterRequest)
|
||||||
|
activate Service
|
||||||
|
|
||||||
|
== 1단계: 이메일 중복 확인 ==
|
||||||
|
|
||||||
|
Service -> UserRepo: findByEmail(email)
|
||||||
|
activate UserRepo
|
||||||
|
UserRepo -> UserDB: 이메일로 사용자 조회\n(중복 가입 확인)
|
||||||
|
activate UserDB
|
||||||
|
UserDB --> UserRepo: 조회 결과 반환 또는 없음
|
||||||
|
deactivate UserDB
|
||||||
|
UserRepo --> Service: Optional<User>
|
||||||
|
deactivate UserRepo
|
||||||
|
|
||||||
|
alt 이메일 중복 존재
|
||||||
|
Service --> Controller: throw DuplicateEmailException\n("이미 가입된 이메일입니다")
|
||||||
|
Controller --> Client: 400 Bad Request\n{"code": "USER_001",\n"message": "이미 가입된 이메일입니다"}
|
||||||
|
deactivate Service
|
||||||
|
deactivate Controller
|
||||||
|
|
||||||
|
else 이메일 신규
|
||||||
|
|
||||||
|
== 2단계: 전화번호 중복 확인 ==
|
||||||
|
|
||||||
|
Service -> UserRepo: findByPhoneNumber(phoneNumber)
|
||||||
|
activate UserRepo
|
||||||
|
UserRepo -> UserDB: 전화번호로 사용자 조회\n(중복 가입 확인)
|
||||||
|
activate UserDB
|
||||||
|
UserDB --> UserRepo: 조회 결과 반환 또는 없음
|
||||||
|
deactivate UserDB
|
||||||
|
UserRepo --> Service: Optional<User>
|
||||||
|
deactivate UserRepo
|
||||||
|
|
||||||
|
alt 전화번호 중복 존재
|
||||||
|
Service --> Controller: throw DuplicatePhoneException\n("이미 가입된 전화번호입니다")
|
||||||
|
Controller --> Client: 400 Bad Request\n{"code": "USER_002",\n"message": "이미 가입된 전화번호입니다"}
|
||||||
|
deactivate Service
|
||||||
|
deactivate Controller
|
||||||
|
|
||||||
|
else 신규 사용자
|
||||||
|
|
||||||
|
== 3단계: 비밀번호 해싱 ==
|
||||||
|
|
||||||
|
Service -> PwdEncoder: encode(rawPassword)
|
||||||
|
activate PwdEncoder
|
||||||
|
PwdEncoder -> PwdEncoder: bcrypt 해싱\n(Cost Factor 10)
|
||||||
|
PwdEncoder --> Service: passwordHash
|
||||||
|
deactivate PwdEncoder
|
||||||
|
|
||||||
|
== 4단계: 데이터베이스 트랜잭션 ==
|
||||||
|
|
||||||
|
Service -> UserDB: 트랜잭션 시작
|
||||||
|
activate UserDB
|
||||||
|
|
||||||
|
Service -> UserRepo: save(User)
|
||||||
|
activate UserRepo
|
||||||
|
UserRepo -> UserDB: 사용자 정보 저장\n(이름, 전화번호, 이메일,\n비밀번호해시, 생성일시)\n저장 후 사용자ID 반환
|
||||||
|
UserDB --> UserRepo: 생성된 사용자ID
|
||||||
|
UserRepo --> Service: User 엔티티\n(userId 포함)
|
||||||
|
deactivate UserRepo
|
||||||
|
|
||||||
|
Service -> StoreRepo: save(Store)
|
||||||
|
activate StoreRepo
|
||||||
|
StoreRepo -> UserDB: 매장 정보 저장\n(사용자ID, 매장명, 업종,\n주소, 사업자번호, 영업시간)\n저장 후 매장ID 반환
|
||||||
|
UserDB --> StoreRepo: 생성된 매장ID
|
||||||
|
StoreRepo --> Service: Store 엔티티\n(storeId 포함)
|
||||||
|
deactivate StoreRepo
|
||||||
|
|
||||||
|
Service -> UserDB: 트랜잭션 커밋
|
||||||
|
UserDB --> Service: 커밋 완료
|
||||||
|
deactivate UserDB
|
||||||
|
|
||||||
|
== 5단계: JWT 토큰 생성 ==
|
||||||
|
|
||||||
|
Service -> JwtProvider: generateToken(userId, role)
|
||||||
|
activate JwtProvider
|
||||||
|
JwtProvider -> JwtProvider: JWT 토큰 생성\n(Claims: userId, role=OWNER,\nexp=7일)
|
||||||
|
JwtProvider --> Service: JWT 토큰
|
||||||
|
deactivate JwtProvider
|
||||||
|
|
||||||
|
== 6단계: 세션 저장 ==
|
||||||
|
|
||||||
|
Service -> Redis: 세션 정보 저장\nKey: user:session:{token}\nValue: {userId, role}\nTTL: 7일
|
||||||
|
activate Redis
|
||||||
|
Redis --> Service: 저장 완료
|
||||||
|
deactivate Redis
|
||||||
|
|
||||||
|
== 7단계: 응답 반환 ==
|
||||||
|
|
||||||
|
Service -> Service: 회원가입 응답 DTO 생성
|
||||||
|
Service --> Controller: RegisterResponse\n{token, userId, userName, storeId, storeName}
|
||||||
|
deactivate Service
|
||||||
|
|
||||||
|
Controller --> Client: 201 Created\n{"token": "eyJhbGc...",\n"userId": 123,\n"userName": "홍길동",\n"storeId": 456,\n"storeName": "맛있는집"}
|
||||||
|
deactivate Controller
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
note over Controller, UserDB
|
||||||
|
**Transaction Rollback 처리**
|
||||||
|
- 트랜잭션 실패 시 자동 Rollback
|
||||||
|
- User/Store INSERT 중 하나라도 실패 시 전체 롤백
|
||||||
|
- 예외: DataAccessException, ConstraintViolationException
|
||||||
|
|
||||||
|
**보안 처리**
|
||||||
|
- 비밀번호: bcrypt 해싱 (Cost Factor 10)
|
||||||
|
- JWT 토큰: 7일 만료, 서버 세션과 동기화
|
||||||
|
|
||||||
|
**성능 목표**
|
||||||
|
- 평균 응답 시간: 1.0초 이내
|
||||||
|
- P95 응답 시간: 1.5초 이내
|
||||||
|
- 트랜잭션 처리: 0.5초 이내
|
||||||
|
|
||||||
|
**에러 코드**
|
||||||
|
- USER_001: 이메일 중복
|
||||||
|
- USER_002: 전화번호 중복
|
||||||
|
end note
|
||||||
|
|
||||||
|
@enduml
|
||||||
304
design/backend/sequence/outer/README.md
Normal file
304
design/backend/sequence/outer/README.md
Normal file
@ -0,0 +1,304 @@
|
|||||||
|
# KT AI 기반 소상공인 이벤트 자동 생성 서비스 - 외부 시퀀스 설계
|
||||||
|
|
||||||
|
## 문서 정보
|
||||||
|
- **작성일**: 2025-10-22
|
||||||
|
- **작성자**: System Architect
|
||||||
|
- **버전**: 1.0
|
||||||
|
- **관련 문서**:
|
||||||
|
- [유저스토리](../../../userstory.md)
|
||||||
|
- [논리 아키텍처](../../logical/logical-architecture.md)
|
||||||
|
- [UI/UX 설계서](../../../uiux/uiux.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
본 문서는 KT AI 기반 소상공인 이벤트 자동 생성 서비스의 **외부 시퀀스 설계**를 정의합니다.
|
||||||
|
외부 시퀀스는 서비스 간의 상호작용과 데이터 흐름을 표현하며, 유저스토리와 논리 아키텍처를 기반으로 설계되었습니다.
|
||||||
|
|
||||||
|
### 설계 원칙
|
||||||
|
1. **유저스토리 기반**: 20개 유저스토리와 정확히 매칭
|
||||||
|
2. **Event-Driven 아키텍처**: Kafka를 통한 비동기 이벤트 발행/구독
|
||||||
|
3. **Resilience 패턴**: Circuit Breaker, Retry, Timeout, Fallback 적용
|
||||||
|
4. **Cache-Aside 패턴**: Redis 캐싱을 통한 성능 최적화
|
||||||
|
5. **서비스 독립성**: 느슨한 결합과 장애 격리
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 외부 시퀀스 플로우 목록
|
||||||
|
|
||||||
|
총 **4개의 주요 비즈니스 플로우**로 구성되어 있습니다:
|
||||||
|
|
||||||
|
### 1. 사용자 인증 플로우
|
||||||
|
**파일**: `사용자인증플로우.puml`
|
||||||
|
|
||||||
|
**포함된 유저스토리**:
|
||||||
|
- UFR-USER-010: 회원가입
|
||||||
|
- UFR-USER-020: 로그인
|
||||||
|
- UFR-USER-040: 로그아웃
|
||||||
|
|
||||||
|
**주요 참여자**:
|
||||||
|
- Frontend (Web/Mobile)
|
||||||
|
- API Gateway
|
||||||
|
- User Service
|
||||||
|
- Redis Cache
|
||||||
|
- User DB (PostgreSQL)
|
||||||
|
- 국세청 API (외부)
|
||||||
|
|
||||||
|
**핵심 기능**:
|
||||||
|
- JWT 기반 인증
|
||||||
|
- 사업자번호 검증 (Circuit Breaker 적용)
|
||||||
|
- Redis 캐싱 (사업자번호 검증 결과, TTL 7일)
|
||||||
|
- 비밀번호 해싱 (bcrypt)
|
||||||
|
- 사업자번호 암호화 (AES-256)
|
||||||
|
|
||||||
|
**Resilience 패턴**:
|
||||||
|
- Circuit Breaker: 국세청 API (실패율 50% 초과 시 Open)
|
||||||
|
- Retry: 최대 3회 재시도 (지수 백오프: 1초, 2초, 4초)
|
||||||
|
- Timeout: 5초
|
||||||
|
- Fallback: 사업자번호 검증 스킵 (수동 확인 안내)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 이벤트 생성 플로우
|
||||||
|
**파일**: `이벤트생성플로우.puml`
|
||||||
|
|
||||||
|
**포함된 유저스토리**:
|
||||||
|
- UFR-EVENT-020: 이벤트 목적 선택
|
||||||
|
- UFR-EVENT-030: AI 이벤트 추천
|
||||||
|
- UFR-CONT-010: SNS 이미지 생성
|
||||||
|
- UFR-EVENT-050: 최종 승인 및 배포
|
||||||
|
|
||||||
|
**주요 참여자**:
|
||||||
|
- Frontend
|
||||||
|
- API Gateway
|
||||||
|
- Event Service
|
||||||
|
- AI Service (Kafka 구독)
|
||||||
|
- Content Service (Kafka 구독)
|
||||||
|
- Distribution Service (동기 호출)
|
||||||
|
- Kafka (Event Topics + Job Topics)
|
||||||
|
- Redis Cache
|
||||||
|
- Event DB
|
||||||
|
- 외부 API (AI API, 이미지 생성 API, 배포 채널 APIs)
|
||||||
|
|
||||||
|
**핵심 기능**:
|
||||||
|
1. **이벤트 목적 선택** (동기)
|
||||||
|
- Event DB에 목적 저장
|
||||||
|
- EventCreated 이벤트 발행
|
||||||
|
|
||||||
|
2. **AI 이벤트 추천** (비동기)
|
||||||
|
- Kafka ai-job 토픽 발행
|
||||||
|
- AI Service 구독 및 처리
|
||||||
|
- Polling 패턴으로 Job 상태 확인 (최대 30초)
|
||||||
|
- Redis 캐싱 (TTL 24시간)
|
||||||
|
|
||||||
|
3. **SNS 이미지 생성** (비동기)
|
||||||
|
- Kafka image-job 토픽 발행
|
||||||
|
- Content Service 구독 및 처리
|
||||||
|
- Polling 패턴으로 Job 상태 확인 (최대 20초)
|
||||||
|
- CDN 업로드 및 Redis 캐싱 (TTL 7일)
|
||||||
|
|
||||||
|
4. **최종 승인 및 배포** (동기)
|
||||||
|
- Distribution Service REST API 직접 호출
|
||||||
|
- 다중 채널 병렬 배포 (1분 이내)
|
||||||
|
- DistributionCompleted 이벤트 발행
|
||||||
|
|
||||||
|
**Resilience 패턴**:
|
||||||
|
- Circuit Breaker: 모든 외부 API 호출 시 적용
|
||||||
|
- Retry: 최대 3회 재시도 (지수 백오프)
|
||||||
|
- Timeout: AI API 30초, 이미지 API 20초, 배포 API 10초
|
||||||
|
- Bulkhead: 채널별 스레드 풀 격리
|
||||||
|
- Fallback: AI 추천 시 캐시된 이전 결과, 이미지 생성 시 기본 템플릿
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. 고객 참여 플로우
|
||||||
|
**파일**: `고객참여플로우.puml`
|
||||||
|
|
||||||
|
**포함된 유저스토리**:
|
||||||
|
- UFR-PART-010: 이벤트 참여
|
||||||
|
- UFR-PART-030: 당첨자 추첨
|
||||||
|
|
||||||
|
**주요 참여자**:
|
||||||
|
- Frontend (고객용 / 사장님용)
|
||||||
|
- API Gateway
|
||||||
|
- Participation Service
|
||||||
|
- Kafka (Event Topics)
|
||||||
|
- Participation DB
|
||||||
|
- Analytics Service (이벤트 구독)
|
||||||
|
|
||||||
|
**핵심 기능**:
|
||||||
|
1. **이벤트 참여**
|
||||||
|
- 중복 참여 체크 (전화번호 기반)
|
||||||
|
- 응모 번호 발급
|
||||||
|
- ParticipantRegistered 이벤트 발행 → Analytics Service 구독
|
||||||
|
|
||||||
|
2. **당첨자 추첨**
|
||||||
|
- 난수 기반 무작위 추첨 (Crypto.randomBytes)
|
||||||
|
- Fisher-Yates Shuffle 알고리즘
|
||||||
|
- 매장 방문 고객 가산점 적용 (선택 옵션)
|
||||||
|
- WinnerSelected 이벤트 발행
|
||||||
|
|
||||||
|
**Event-Driven 특징**:
|
||||||
|
- Analytics Service가 ParticipantRegistered 이벤트 구독하여 실시간 통계 업데이트
|
||||||
|
- 서비스 간 직접 의존성 없이 이벤트로 느슨한 결합
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. 성과 분석 플로우
|
||||||
|
**파일**: `성과분석플로우.puml`
|
||||||
|
|
||||||
|
**포함된 유저스토리**:
|
||||||
|
- UFR-ANAL-010: 실시간 성과분석 대시보드 조회
|
||||||
|
|
||||||
|
**주요 참여자**:
|
||||||
|
- Frontend
|
||||||
|
- API Gateway
|
||||||
|
- Analytics Service
|
||||||
|
- Redis Cache (TTL 5분)
|
||||||
|
- Analytics DB
|
||||||
|
- Kafka (Event Topics 구독)
|
||||||
|
- 외부 API (우리동네TV, 지니TV, SNS APIs)
|
||||||
|
|
||||||
|
**핵심 기능**:
|
||||||
|
1. **대시보드 조회 - Cache HIT** (0.5초)
|
||||||
|
- Redis에서 캐시된 데이터 즉시 반환
|
||||||
|
- 히트율 목표: 95%
|
||||||
|
|
||||||
|
2. **대시보드 조회 - Cache MISS** (3초)
|
||||||
|
- Analytics DB 로컬 데이터 조회
|
||||||
|
- 외부 채널 API 병렬 호출 (Circuit Breaker 적용)
|
||||||
|
- 데이터 통합 및 ROI 계산
|
||||||
|
- Redis 캐싱 (TTL 5분)
|
||||||
|
|
||||||
|
3. **실시간 업데이트 (Background)**
|
||||||
|
- EventCreated 구독: 이벤트 기본 정보 초기화
|
||||||
|
- ParticipantRegistered 구독: 참여자 수 실시간 증가
|
||||||
|
- DistributionCompleted 구독: 배포 채널 통계 업데이트
|
||||||
|
|
||||||
|
**Resilience 패턴**:
|
||||||
|
- Circuit Breaker: 외부 채널 API 조회 시 (실패율 50% 초과 시 Open)
|
||||||
|
- Timeout: 10초
|
||||||
|
- Fallback: 캐시된 이전 데이터 반환 또는 기본값 설정
|
||||||
|
- 병렬 처리: 외부 채널 API 동시 호출
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 설계 특징
|
||||||
|
|
||||||
|
### 1. Event-Driven 아키텍처
|
||||||
|
- **Kafka 통합**: Event Topics와 Job Topics를 Kafka로 통합
|
||||||
|
- **느슨한 결합**: 서비스 간 직접 의존성 제거
|
||||||
|
- **장애 격리**: 한 서비스 장애가 다른 서비스에 영향 없음
|
||||||
|
- **확장 용이**: 새로운 구독자 추가로 기능 확장
|
||||||
|
|
||||||
|
### 2. 비동기 처리 패턴
|
||||||
|
- **Kafka Job Topics**: ai-job, image-job
|
||||||
|
- **Polling 패턴**: Job 상태 확인 (2-5초 간격)
|
||||||
|
- **처리 시간**: AI 추천 10초, 이미지 생성 5초
|
||||||
|
|
||||||
|
### 3. 동기 처리 패턴
|
||||||
|
- **Distribution Service**: REST API 직접 호출
|
||||||
|
- **다중 채널 배포**: 병렬 처리 (1분 이내)
|
||||||
|
- **Circuit Breaker**: 장애 전파 방지
|
||||||
|
|
||||||
|
### 4. Resilience 패턴 전면 적용
|
||||||
|
- **Circuit Breaker**: 모든 외부 API 호출
|
||||||
|
- **Retry**: 일시적 장애 자동 복구
|
||||||
|
- **Timeout**: 응답 시간 제한
|
||||||
|
- **Bulkhead**: 리소스 격리
|
||||||
|
- **Fallback**: 장애 시 대체 로직
|
||||||
|
|
||||||
|
### 5. Cache-Aside 패턴
|
||||||
|
- **Redis 캐싱**: 성능 최적화
|
||||||
|
- **TTL 설정**: 데이터 유효성 관리
|
||||||
|
- **히트율 목표**: 80-95%
|
||||||
|
- **응답 시간 개선**: 90-99%
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파일 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
design/backend/sequence/outer/
|
||||||
|
├── README.md (본 문서)
|
||||||
|
├── 사용자인증플로우.puml
|
||||||
|
├── 이벤트생성플로우.puml
|
||||||
|
├── 고객참여플로우.puml
|
||||||
|
└── 성과분석플로우.puml
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 다이어그램 확인 방법
|
||||||
|
|
||||||
|
### 1. Online PlantUML Viewer
|
||||||
|
1. https://www.plantuml.com/plantuml/uml 접속
|
||||||
|
2. `.puml` 파일 내용 붙여넣기
|
||||||
|
3. 다이어그램 시각적 확인
|
||||||
|
|
||||||
|
### 2. VSCode Extension
|
||||||
|
1. "PlantUML" 확장 프로그램 설치
|
||||||
|
2. `.puml` 파일 열기
|
||||||
|
3. `Alt+D` 또는 `Cmd+D`로 미리보기
|
||||||
|
|
||||||
|
### 3. IntelliJ IDEA Plugin
|
||||||
|
1. "PlantUML integration" 플러그인 설치
|
||||||
|
2. `.puml` 파일 열기
|
||||||
|
3. 우측 미리보기 패널에서 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 주요 결정사항
|
||||||
|
|
||||||
|
1. **Kafka 통합**: Event Bus와 Job Queue를 Kafka로 통합하여 운영 복잡도 감소
|
||||||
|
2. **비동기 처리**: AI 추천 및 이미지 생성은 Kafka Job Topics를 통한 비동기 처리
|
||||||
|
3. **동기 배포**: Distribution Service는 REST API 직접 호출하여 동기 처리 (1분 이내)
|
||||||
|
4. **Resilience 패턴**: 모든 외부 API 호출 시 Circuit Breaker, Retry, Timeout, Fallback 적용
|
||||||
|
5. **Cache-Aside 패턴**: Redis 캐싱으로 응답 시간 90-99% 개선
|
||||||
|
6. **Event Topics**: EventCreated, ParticipantRegistered, WinnerSelected, DistributionCompleted
|
||||||
|
7. **Job Topics**: ai-job, image-job
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검증 사항
|
||||||
|
|
||||||
|
### 1. 유저스토리 매칭
|
||||||
|
✅ 모든 유저스토리가 외부 시퀀스에 정확히 반영됨
|
||||||
|
- User 서비스: 4개 유저스토리
|
||||||
|
- Event 서비스: 4개 유저스토리
|
||||||
|
- Participation 서비스: 2개 유저스토리
|
||||||
|
- Analytics 서비스: 1개 유저스토리
|
||||||
|
|
||||||
|
### 2. 논리 아키텍처 일치성
|
||||||
|
✅ 논리 아키텍처의 모든 컴포넌트와 통신 패턴 반영
|
||||||
|
- Core Services: User, Event, Participation, Analytics
|
||||||
|
- Async Services: AI, Content, Distribution
|
||||||
|
- Kafka: Event Topics + Job Topics
|
||||||
|
- External Systems: 국세청 API, AI API, 이미지 생성 API, 배포 채널 APIs
|
||||||
|
|
||||||
|
### 3. Resilience 패턴 적용
|
||||||
|
✅ 모든 외부 API 호출에 Resilience 패턴 적용
|
||||||
|
- Circuit Breaker, Retry, Timeout, Bulkhead, Fallback
|
||||||
|
|
||||||
|
### 4. PlantUML 문법 검증
|
||||||
|
✅ PlantUML 기본 문법 검증 완료
|
||||||
|
- `!theme mono` 적용
|
||||||
|
- 동기/비동기 화살표 구분
|
||||||
|
- 한글 설명 추가
|
||||||
|
- 참여자 및 플로우 명확히 표현
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 향후 개선 방안
|
||||||
|
|
||||||
|
1. **WebSocket 기반 실시간 푸시**: 대시보드 실시간 업데이트 (폴링 대체)
|
||||||
|
2. **Saga 패턴 적용**: 복잡한 분산 트랜잭션 보상 로직 체계화
|
||||||
|
3. **Service Mesh 도입**: Istio를 통한 서비스 간 통신 관찰성 및 보안 강화
|
||||||
|
4. **Dead Letter Queue 고도화**: 실패 이벤트 재처리 및 알림 자동화
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**문서 버전**: 1.0
|
||||||
|
**최종 수정일**: 2025-10-22
|
||||||
|
**작성자**: System Architect
|
||||||
164
design/backend/sequence/outer/고객참여플로우.puml
Normal file
164
design/backend/sequence/outer/고객참여플로우.puml
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
@startuml 고객참여플로우
|
||||||
|
!theme mono
|
||||||
|
|
||||||
|
title 고객 참여 플로우 - 외부 시퀀스 다이어그램
|
||||||
|
|
||||||
|
actor "고객" as Customer
|
||||||
|
participant "Frontend\n(고객용)" as CustomerFE
|
||||||
|
participant "API Gateway" as Gateway
|
||||||
|
participant "Participation\nService" as PartService
|
||||||
|
participant "Kafka\n(Event Topics)" as Kafka
|
||||||
|
database "Participation\nDB" as PartDB
|
||||||
|
participant "Analytics\nService" as Analytics
|
||||||
|
|
||||||
|
actor "사장님" as Owner
|
||||||
|
participant "Frontend\n(사장님용)" as OwnerFE
|
||||||
|
|
||||||
|
== UFR-PART-010: 이벤트 참여 ==
|
||||||
|
|
||||||
|
Customer -> CustomerFE: 이벤트 참여 화면 접근\n(우리동네TV/SNS/링고비즈)
|
||||||
|
activate CustomerFE
|
||||||
|
|
||||||
|
CustomerFE -> Customer: 참여 정보 입력 폼 표시\n(이름, 전화번호, 참여경로)
|
||||||
|
|
||||||
|
Customer -> CustomerFE: 참여 정보 입력 및\n참여 버튼 클릭
|
||||||
|
CustomerFE -> CustomerFE: 클라이언트 유효성 검증\n(이름 2자 이상, 전화번호 형식)
|
||||||
|
|
||||||
|
CustomerFE -> Gateway: POST /api/v1/participations\n{이름, 전화번호, 참여경로, 개인정보동의}
|
||||||
|
activate Gateway
|
||||||
|
|
||||||
|
Gateway -> PartService: POST /participations/register\n{이름, 전화번호, 참여경로, 개인정보동의}
|
||||||
|
activate PartService
|
||||||
|
|
||||||
|
PartService -> PartDB: 참여자 중복 확인\n(전화번호, 이벤트ID로 조회)
|
||||||
|
activate PartDB
|
||||||
|
PartDB --> PartService: 중복 참여 여부 반환
|
||||||
|
deactivate PartDB
|
||||||
|
|
||||||
|
alt 중복 참여인 경우
|
||||||
|
PartService --> Gateway: 409 Conflict\n{message: "이미 참여하신 이벤트입니다"}
|
||||||
|
Gateway --> CustomerFE: 409 Conflict
|
||||||
|
CustomerFE -> Customer: 중복 참여 오류 메시지 표시
|
||||||
|
deactivate PartService
|
||||||
|
deactivate Gateway
|
||||||
|
deactivate CustomerFE
|
||||||
|
else 신규 참여인 경우
|
||||||
|
PartService -> PartService: 응모 번호 생성\n(UUID 또는 시퀀스 기반)
|
||||||
|
|
||||||
|
PartService -> PartDB: 참여자 정보 저장\n(이름, 전화번호, 참여경로,\n응모번호, 참여일시)
|
||||||
|
activate PartDB
|
||||||
|
PartDB --> PartService: 저장 완료
|
||||||
|
deactivate PartDB
|
||||||
|
|
||||||
|
PartService -> Kafka: Publish Event\n"ParticipantRegistered"\n{participantId, eventId,\nentryPath, timestamp}
|
||||||
|
activate Kafka
|
||||||
|
note right of Kafka
|
||||||
|
Topic: participant-events
|
||||||
|
Event: ParticipantRegistered
|
||||||
|
Data: {
|
||||||
|
participantId: UUID,
|
||||||
|
eventId: UUID,
|
||||||
|
entryPath: string,
|
||||||
|
timestamp: datetime
|
||||||
|
}
|
||||||
|
end note
|
||||||
|
|
||||||
|
Kafka --> Analytics: Subscribe Event\n"ParticipantRegistered"
|
||||||
|
activate Analytics
|
||||||
|
Analytics -> Analytics: 참여자 데이터 집계\n- 채널별 참여자 수\n- 시간대별 참여 추이\n- 실시간 통계 업데이트
|
||||||
|
deactivate Analytics
|
||||||
|
deactivate Kafka
|
||||||
|
|
||||||
|
PartService --> Gateway: 201 Created\n{응모번호, 당첨발표일, 참여완료메시지}
|
||||||
|
deactivate PartService
|
||||||
|
|
||||||
|
Gateway --> CustomerFE: 201 Created
|
||||||
|
deactivate Gateway
|
||||||
|
|
||||||
|
CustomerFE -> Customer: 참여 완료 화면 표시\n- 응모번호\n- 당첨 발표일\n- "참여해주셔서 감사합니다"
|
||||||
|
deactivate CustomerFE
|
||||||
|
end
|
||||||
|
|
||||||
|
== UFR-PART-020: 참여자 목록 조회 ==
|
||||||
|
|
||||||
|
Owner -> OwnerFE: 이벤트 상세 화면에서\n"참여자 목록" 탭 클릭
|
||||||
|
activate OwnerFE
|
||||||
|
|
||||||
|
OwnerFE -> Gateway: GET /api/v1/events/{eventId}/participants\n?page=1&size=20
|
||||||
|
activate Gateway
|
||||||
|
|
||||||
|
Gateway -> PartService: GET /events/{eventId}/participants\n?page=1&size=20
|
||||||
|
activate PartService
|
||||||
|
|
||||||
|
PartService -> PartDB: 참여자 목록 조회\n(이벤트ID, 페이지네이션)\nORDER BY 참여일시 DESC
|
||||||
|
activate PartDB
|
||||||
|
PartDB --> PartService: 참여자 목록 반환\n(이름, 전화번호, 참여경로,\n응모번호, 참여일시)\n+ 총 참여자 수
|
||||||
|
deactivate PartDB
|
||||||
|
|
||||||
|
PartService --> Gateway: 200 OK\n{participants[], totalCount, page, size}
|
||||||
|
deactivate PartService
|
||||||
|
|
||||||
|
Gateway --> OwnerFE: 200 OK
|
||||||
|
deactivate Gateway
|
||||||
|
|
||||||
|
OwnerFE -> Owner: 참여자 목록 화면 표시\n- 참여자 정보 테이블\n- 페이지네이션\n- 총 참여자 수\n- CSV 다운로드 버튼
|
||||||
|
deactivate OwnerFE
|
||||||
|
|
||||||
|
note right of Owner
|
||||||
|
참여자 정보:
|
||||||
|
- 이름 (마스킹: 김**)
|
||||||
|
- 전화번호 (마스킹: 010-****-1234)
|
||||||
|
- 참여경로 (우리동네TV, Instagram 등)
|
||||||
|
- 응모번호
|
||||||
|
- 참여일시
|
||||||
|
end note
|
||||||
|
|
||||||
|
== UFR-PART-030: 당첨자 추첨 ==
|
||||||
|
|
||||||
|
Owner -> OwnerFE: 이벤트 상세 화면에서\n"당첨자 추첨" 버튼 클릭
|
||||||
|
activate OwnerFE
|
||||||
|
|
||||||
|
OwnerFE -> Owner: 추첨 확인 다이얼로그 표시\n"당첨자를 추첨하시겠습니까?"
|
||||||
|
|
||||||
|
Owner -> OwnerFE: 확인 버튼 클릭
|
||||||
|
|
||||||
|
OwnerFE -> Gateway: POST /api/v1/events/{eventId}/draw-winners\n{당첨인원, 매장방문가산점옵션}
|
||||||
|
activate Gateway
|
||||||
|
|
||||||
|
Gateway -> PartService: POST /events/{eventId}/draw-winners\n{winnerCount, visitBonus}
|
||||||
|
activate PartService
|
||||||
|
|
||||||
|
PartService -> PartDB: 미당첨 참여자 목록 조회\n(이벤트ID로 당첨되지 않은 참여자 조회)
|
||||||
|
activate PartDB
|
||||||
|
PartDB --> PartService: 전체 참여자 목록 반환
|
||||||
|
deactivate PartDB
|
||||||
|
|
||||||
|
PartService -> PartService: 당첨자 추첨 알고리즘 실행\n1. 난수 생성 (Crypto.randomBytes)\n2. 매장방문 가산점 적용 (옵션)\n3. Fisher-Yates Shuffle\n4. 당첨인원만큼 선정
|
||||||
|
|
||||||
|
PartService -> PartDB: 당첨자 정보 업데이트\n(당첨 여부를 true로 설정, 당첨 일시 기록)
|
||||||
|
activate PartDB
|
||||||
|
PartDB --> PartService: 업데이트 완료
|
||||||
|
deactivate PartDB
|
||||||
|
|
||||||
|
PartService -> PartDB: 추첨 로그 저장\n(이벤트ID, 추첨방법, 당첨인원,\n알고리즘, 추첨일시)
|
||||||
|
activate PartDB
|
||||||
|
note right of PartDB
|
||||||
|
추첨 로그 저장:
|
||||||
|
- 추첨 일시
|
||||||
|
- 추첨 방법
|
||||||
|
- 알고리즘 버전
|
||||||
|
- 가산점 적용 여부
|
||||||
|
end note
|
||||||
|
PartDB --> PartService: 로그 저장 완료
|
||||||
|
deactivate PartDB
|
||||||
|
|
||||||
|
PartService --> Gateway: 200 OK\n{당첨자목록, 추첨로그ID}
|
||||||
|
deactivate PartService
|
||||||
|
|
||||||
|
Gateway --> OwnerFE: 200 OK
|
||||||
|
deactivate Gateway
|
||||||
|
|
||||||
|
OwnerFE -> Owner: 당첨자 목록 화면 표시\n- 당첨자 정보 (이름, 전화번호, 응모번호)\n- 추첨 완료 메시지
|
||||||
|
deactivate OwnerFE
|
||||||
|
|
||||||
|
@enduml
|
||||||
177
design/backend/sequence/outer/사용자인증플로우.puml
Normal file
177
design/backend/sequence/outer/사용자인증플로우.puml
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
@startuml 사용자인증플로우
|
||||||
|
!theme mono
|
||||||
|
|
||||||
|
title KT AI 기반 소상공인 이벤트 자동 생성 서비스 - 사용자 인증 플로우 (외부 시퀀스)
|
||||||
|
|
||||||
|
actor "사용자\n(소상공인)" as User
|
||||||
|
participant "Frontend\n(Web/Mobile)" as Frontend
|
||||||
|
participant "API Gateway" as Gateway
|
||||||
|
participant "User Service" as UserService
|
||||||
|
database "User DB\n(PostgreSQL)" as UserDB
|
||||||
|
|
||||||
|
== UFR-USER-010: 회원가입 플로우 ==
|
||||||
|
|
||||||
|
User -> Frontend: 회원가입 화면 접근
|
||||||
|
activate Frontend
|
||||||
|
|
||||||
|
User -> Frontend: 회원 정보 입력\n(이름, 전화번호, 이메일, 비밀번호,\n매장명, 업종, 주소, 사업자번호)
|
||||||
|
Frontend -> Frontend: 클라이언트 측 유효성 검증\n(이메일 형식, 비밀번호 8자 이상 등)
|
||||||
|
|
||||||
|
Frontend -> Gateway: POST /api/users/register\n(회원 정보)
|
||||||
|
activate Gateway
|
||||||
|
|
||||||
|
Gateway -> Gateway: Request 검증\n(필수 필드, 데이터 타입)
|
||||||
|
|
||||||
|
Gateway -> UserService: POST /api/users/register\n(회원 정보)
|
||||||
|
activate UserService
|
||||||
|
|
||||||
|
UserService -> UserService: 서버 측 유효성 검증\n(이름 2자 이상, 전화번호 형식 등)
|
||||||
|
|
||||||
|
UserService -> UserDB: 이메일로 사용자 조회\n(중복 가입 확인)
|
||||||
|
activate UserDB
|
||||||
|
UserDB --> UserService: 기존 사용자 확인 결과
|
||||||
|
deactivate UserDB
|
||||||
|
|
||||||
|
alt 이메일 중복 존재
|
||||||
|
UserService --> Gateway: 400 Bad Request\n(이미 등록된 이메일)
|
||||||
|
Gateway --> Frontend: 400 Bad Request
|
||||||
|
Frontend --> User: "이미 가입된 이메일입니다"
|
||||||
|
else 이메일 신규
|
||||||
|
|
||||||
|
UserService -> UserDB: 전화번호로 사용자 조회\n(중복 가입 확인)
|
||||||
|
activate UserDB
|
||||||
|
UserDB --> UserService: 기존 사용자 확인 결과
|
||||||
|
deactivate UserDB
|
||||||
|
|
||||||
|
alt 전화번호 중복 존재
|
||||||
|
UserService --> Gateway: 400 Bad Request\n(이미 등록된 전화번호)
|
||||||
|
Gateway --> Frontend: 400 Bad Request
|
||||||
|
Frontend --> User: "이미 가입된 전화번호입니다"
|
||||||
|
else 신규 사용자
|
||||||
|
|
||||||
|
UserService -> UserService: 비밀번호 해싱\n(bcrypt, Cost Factor 10)
|
||||||
|
|
||||||
|
UserService -> UserService: 사업자번호 암호화\n(AES-256)
|
||||||
|
|
||||||
|
UserService -> UserDB: 트랜잭션 시작
|
||||||
|
activate UserDB
|
||||||
|
|
||||||
|
UserService -> UserDB: 사용자 정보 저장\n(이름, 전화번호, 이메일,\n비밀번호해시, 생성일시)
|
||||||
|
UserDB --> UserService: user_id 반환
|
||||||
|
|
||||||
|
UserService -> UserDB: 매장 정보 저장\n(사용자ID, 매장명, 업종,\n주소, 암호화된사업자번호,\n영업시간)
|
||||||
|
UserDB --> UserService: store_id 반환
|
||||||
|
|
||||||
|
UserService -> UserDB: 트랜잭션 커밋
|
||||||
|
deactivate UserDB
|
||||||
|
|
||||||
|
UserService -> UserService: JWT 토큰 생성\n(user_id, role=OWNER,\nexp=7일)
|
||||||
|
|
||||||
|
UserService --> Gateway: 201 Created\n(JWT 토큰, 사용자 정보)
|
||||||
|
deactivate UserService
|
||||||
|
|
||||||
|
Gateway --> Frontend: 201 Created\n(JWT 토큰, 사용자 정보)
|
||||||
|
deactivate Gateway
|
||||||
|
|
||||||
|
Frontend -> Frontend: JWT 토큰 저장\n(LocalStorage 또는 Cookie)
|
||||||
|
|
||||||
|
Frontend --> User: "회원가입이 완료되었습니다"
|
||||||
|
|
||||||
|
Frontend -> Gateway: 대시보드 화면으로 이동
|
||||||
|
deactivate Frontend
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
== UFR-USER-020: 로그인 플로우 ==
|
||||||
|
|
||||||
|
User -> Frontend: 로그인 화면 접근
|
||||||
|
activate Frontend
|
||||||
|
|
||||||
|
User -> Frontend: 이메일, 비밀번호 입력
|
||||||
|
|
||||||
|
Frontend -> Frontend: 클라이언트 측 유효성 검증\n(필수 필드 확인, 이메일 형식)
|
||||||
|
|
||||||
|
Frontend -> Gateway: POST /api/users/login\n(이메일, 비밀번호)
|
||||||
|
activate Gateway
|
||||||
|
|
||||||
|
Gateway -> Gateway: Request 검증
|
||||||
|
|
||||||
|
Gateway -> UserService: POST /api/users/login\n(이메일, 비밀번호)
|
||||||
|
activate UserService
|
||||||
|
|
||||||
|
UserService -> UserDB: 이메일로 사용자 조회\n(로그인 인증용)
|
||||||
|
activate UserDB
|
||||||
|
UserDB --> UserService: 사용자 정보\n(user_id, password_hash, role)
|
||||||
|
deactivate UserDB
|
||||||
|
|
||||||
|
alt 사용자 없음
|
||||||
|
UserService --> Gateway: 401 Unauthorized\n(인증 실패)
|
||||||
|
Gateway --> Frontend: 401 Unauthorized
|
||||||
|
Frontend --> User: "이메일 또는 비밀번호를\n확인해주세요"
|
||||||
|
else 사용자 존재
|
||||||
|
|
||||||
|
UserService -> UserService: 비밀번호 검증\n(bcrypt compare)
|
||||||
|
|
||||||
|
alt 비밀번호 불일치
|
||||||
|
UserService --> Gateway: 401 Unauthorized\n(인증 실패)
|
||||||
|
Gateway --> Frontend: 401 Unauthorized
|
||||||
|
Frontend --> User: "이메일 또는 비밀번호를\n확인해주세요"
|
||||||
|
else 비밀번호 일치
|
||||||
|
|
||||||
|
UserService -> UserService: JWT 토큰 생성\n(user_id, role=OWNER,\nexp=7일)
|
||||||
|
|
||||||
|
UserService -> UserDB: 최종 로그인 시각 업데이트\n(현재 시각으로 갱신)
|
||||||
|
activate UserDB
|
||||||
|
UserDB --> UserService: 업데이트 완료
|
||||||
|
deactivate UserDB
|
||||||
|
|
||||||
|
UserService --> Gateway: 200 OK\n(JWT 토큰, 사용자 정보)
|
||||||
|
deactivate UserService
|
||||||
|
|
||||||
|
Gateway --> Frontend: 200 OK\n(JWT 토큰, 사용자 정보)
|
||||||
|
deactivate Gateway
|
||||||
|
|
||||||
|
Frontend -> Frontend: JWT 토큰 저장\n(LocalStorage 또는 Cookie)
|
||||||
|
|
||||||
|
Frontend --> User: 로그인 성공
|
||||||
|
|
||||||
|
Frontend -> Gateway: 대시보드 화면으로 이동
|
||||||
|
deactivate Frontend
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
== UFR-USER-040: 로그아웃 플로우 ==
|
||||||
|
|
||||||
|
User -> Frontend: 프로필 탭 접근
|
||||||
|
activate Frontend
|
||||||
|
|
||||||
|
User -> Frontend: "로그아웃" 버튼 클릭
|
||||||
|
|
||||||
|
Frontend -> Frontend: 확인 다이얼로그 표시\n"로그아웃 하시겠습니까?"
|
||||||
|
|
||||||
|
User -> Frontend: "확인" 클릭
|
||||||
|
|
||||||
|
Frontend -> Gateway: POST /api/users/logout\nAuthorization: Bearer {JWT}
|
||||||
|
activate Gateway
|
||||||
|
|
||||||
|
Gateway -> Gateway: JWT 토큰 검증
|
||||||
|
|
||||||
|
Gateway -> UserService: POST /api/users/logout\n(JWT 토큰)
|
||||||
|
activate UserService
|
||||||
|
|
||||||
|
UserService -> UserService: JWT 토큰 블랙리스트에 추가\n(만료 시까지 유효)
|
||||||
|
|
||||||
|
UserService --> Gateway: 200 OK\n(로그아웃 성공)
|
||||||
|
deactivate UserService
|
||||||
|
|
||||||
|
Gateway --> Frontend: 200 OK
|
||||||
|
deactivate Gateway
|
||||||
|
|
||||||
|
Frontend -> Frontend: JWT 토큰 삭제\n(LocalStorage 또는 Cookie)
|
||||||
|
|
||||||
|
Frontend --> User: "안전하게 로그아웃되었습니다"
|
||||||
|
|
||||||
|
Frontend -> Gateway: 로그인 화면으로 이동
|
||||||
|
deactivate Frontend
|
||||||
|
|
||||||
|
@enduml
|
||||||
198
design/backend/sequence/outer/성과분석플로우.puml
Normal file
198
design/backend/sequence/outer/성과분석플로우.puml
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
@startuml 성과분석플로우_외부시퀀스
|
||||||
|
!theme mono
|
||||||
|
|
||||||
|
title 성과 분석 플로우 - 외부 시퀀스 다이어그램\n(UFR-ANAL-010: 실시간 성과분석 대시보드 조회)
|
||||||
|
|
||||||
|
actor "소상공인" as User
|
||||||
|
participant "Frontend" as FE
|
||||||
|
participant "API Gateway" as GW
|
||||||
|
participant "Analytics Service" as Analytics
|
||||||
|
participant "Redis Cache\n(TTL 1시간)" as Redis
|
||||||
|
participant "Analytics DB" as AnalyticsDB
|
||||||
|
participant "Kafka\n(Event Topics)" as Kafka
|
||||||
|
|
||||||
|
note over AnalyticsDB
|
||||||
|
**배치 처리로 수집된 데이터**
|
||||||
|
- 외부 채널 통계는 배치 작업으로
|
||||||
|
주기적으로 수집하여 DB에 저장
|
||||||
|
- 목업 데이터로 시작, 점진적으로 실제 API 연동
|
||||||
|
end note
|
||||||
|
|
||||||
|
== 1. 대시보드 조회 - Cache HIT 시나리오 ==
|
||||||
|
|
||||||
|
User -> FE: 성과분석 대시보드 접근\n(Bottom Nav "분석" 탭 클릭)
|
||||||
|
activate FE
|
||||||
|
|
||||||
|
FE -> GW: GET /api/events/{id}/analytics\n+ Authorization: Bearer {token}
|
||||||
|
activate GW
|
||||||
|
|
||||||
|
GW -> GW: JWT 토큰 검증
|
||||||
|
GW -> Analytics: GET /api/events/{id}/analytics
|
||||||
|
activate Analytics
|
||||||
|
|
||||||
|
Analytics -> Redis: 대시보드 캐시 조회\n(캐시키: analytics:dashboard:{eventId})
|
||||||
|
activate Redis
|
||||||
|
Redis --> Analytics: **Cache HIT**\n캐시된 대시보드 데이터 반환
|
||||||
|
deactivate Redis
|
||||||
|
|
||||||
|
note right of Analytics
|
||||||
|
**Cache-Aside 패턴**
|
||||||
|
- TTL: 1시간
|
||||||
|
- 예상 크기: 5KB
|
||||||
|
- 히트율 목표: 95%
|
||||||
|
- 응답 시간: 0.5초
|
||||||
|
end note
|
||||||
|
|
||||||
|
Analytics --> GW: 200 OK\n대시보드 데이터 (JSON)
|
||||||
|
deactivate Analytics
|
||||||
|
|
||||||
|
GW --> FE: 200 OK\n대시보드 데이터
|
||||||
|
deactivate GW
|
||||||
|
|
||||||
|
FE -> FE: 대시보드 렌더링\n- 4개 요약 카드\n- 채널별 성과 차트\n- 시간대별 참여 추이
|
||||||
|
FE --> User: 실시간 대시보드 표시
|
||||||
|
deactivate FE
|
||||||
|
|
||||||
|
== 2. 대시보드 조회 - Cache MISS 시나리오 ==
|
||||||
|
|
||||||
|
User -> FE: 대시보드 새로고침\n또는 첫 조회
|
||||||
|
activate FE
|
||||||
|
|
||||||
|
FE -> GW: GET /api/events/{id}/analytics
|
||||||
|
activate GW
|
||||||
|
|
||||||
|
GW -> Analytics: GET /api/events/{id}/analytics
|
||||||
|
activate Analytics
|
||||||
|
|
||||||
|
Analytics -> Redis: 대시보드 캐시 조회\n(캐시키: analytics:dashboard:{eventId})
|
||||||
|
activate Redis
|
||||||
|
Redis --> Analytics: **Cache MISS**\nnull 반환
|
||||||
|
deactivate Redis
|
||||||
|
|
||||||
|
note right of Analytics
|
||||||
|
**데이터 통합 작업 시작**
|
||||||
|
- Analytics DB 조회
|
||||||
|
- 외부 채널 API 병렬 호출
|
||||||
|
- Circuit Breaker 적용
|
||||||
|
end note
|
||||||
|
|
||||||
|
|||
|
||||||
|
== 2.1. Analytics DB 조회 (로컬 데이터) ==
|
||||||
|
|
||||||
|
Analytics -> AnalyticsDB: 이벤트 통계 조회\n(이벤트ID로 통계 데이터 조회)
|
||||||
|
activate AnalyticsDB
|
||||||
|
AnalyticsDB --> Analytics: 이벤트 통계\n- 총 참여자 수\n- 예상 ROI\n- 매출 증가율
|
||||||
|
deactivate AnalyticsDB
|
||||||
|
|
||||||
|
|||
|
||||||
|
== 2.2. 배치 수집된 채널 통계 데이터 조회 ==
|
||||||
|
|
||||||
|
Analytics -> AnalyticsDB: 채널별 통계 조회\n(배치로 수집된 채널 데이터 조회)
|
||||||
|
activate AnalyticsDB
|
||||||
|
|
||||||
|
note right of Analytics
|
||||||
|
**배치 처리 방식**
|
||||||
|
- 외부 API는 별도 배치 작업으로 주기적 수집
|
||||||
|
- 수집된 데이터는 DB에 저장
|
||||||
|
- 대시보드에서는 DB 데이터만 조회
|
||||||
|
- 응답 시간 단축 및 외부 API 의존성 제거
|
||||||
|
end note
|
||||||
|
|
||||||
|
AnalyticsDB --> Analytics: 채널별 통계 데이터\n- 우리동네TV: 노출 5,000, 조회 1,200\n- 지니TV: 노출 10,000, 클릭 500\n- Instagram: 좋아요 300, 댓글 50\n- Naver: 조회 2,000\n- Kakao: 공유 100
|
||||||
|
deactivate AnalyticsDB
|
||||||
|
|
||||||
|
note right of Analytics
|
||||||
|
**목업 데이터 활용**
|
||||||
|
- 초기에는 목업 데이터로 시작
|
||||||
|
- 점진적으로 실제 배치 작업 구현
|
||||||
|
- 배치 주기: 5분마다 수집
|
||||||
|
end note
|
||||||
|
|
||||||
|
|||
|
||||||
|
== 2.3. 데이터 통합 및 ROI 계산 ==
|
||||||
|
|
||||||
|
Analytics -> Analytics: 데이터 통합 및 계산\n- 총 노출 수 = 외부 채널 노출 합계\n- 총 참여자 수 = Analytics DB\n- ROI 계산 = (수익 - 비용) / 비용 × 100\n- 채널별 전환율 계산
|
||||||
|
|
||||||
|
note right of Analytics
|
||||||
|
**ROI 계산 로직**
|
||||||
|
총 비용 = 경품 비용 + 플랫폼 비용
|
||||||
|
예상 수익 = 매출 증가액 + 신규 고객 LTV
|
||||||
|
투자 대비 수익률 = (수익 - 비용) / 비용 × 100
|
||||||
|
end note
|
||||||
|
|
||||||
|
|||
|
||||||
|
== 2.4. Redis 캐싱 및 응답 ==
|
||||||
|
|
||||||
|
Analytics -> Redis: 대시보드 데이터 캐시 저장\n(캐시키: analytics:dashboard:{eventId},\n값: 통합 데이터, TTL: 1시간)
|
||||||
|
activate Redis
|
||||||
|
Redis --> Analytics: OK
|
||||||
|
deactivate Redis
|
||||||
|
|
||||||
|
Analytics --> GW: 200 OK\n대시보드 데이터 (JSON)\n{\n 총참여자: 1,234,\n 총노출: 17,200,\n ROI: 250%,\n 채널별성과: [...]\n}
|
||||||
|
deactivate Analytics
|
||||||
|
|
||||||
|
GW --> FE: 200 OK\n대시보드 데이터
|
||||||
|
deactivate GW
|
||||||
|
|
||||||
|
FE -> FE: 대시보드 렌더링\n- 4개 요약 카드 표시\n- 채널별 성과 차트\n- 시간대별 참여 추이\n- 참여자 프로필 분석
|
||||||
|
FE --> User: 실시간 대시보드 표시\n(응답 시간: 3초)
|
||||||
|
deactivate FE
|
||||||
|
|
||||||
|
|||
|
||||||
|
== 3. 실시간 업데이트 (Background Event 구독) ==
|
||||||
|
|
||||||
|
note over Analytics, Kafka
|
||||||
|
**Analytics Service는 항상 Background에서
|
||||||
|
Kafka Event Topics를 구독하여
|
||||||
|
실시간으로 통계를 업데이트합니다**
|
||||||
|
end note
|
||||||
|
|
||||||
|
Kafka -> Analytics: **EventCreated** 이벤트\n{eventId, storeId, title, objective}
|
||||||
|
activate Analytics
|
||||||
|
Analytics -> AnalyticsDB: 이벤트 통계 초기화\n(이벤트 기본 정보 저장)
|
||||||
|
activate AnalyticsDB
|
||||||
|
AnalyticsDB --> Analytics: OK
|
||||||
|
deactivate AnalyticsDB
|
||||||
|
Analytics -> Redis: 캐시 무효화\n(캐시키 삭제: analytics:dashboard:{eventId})
|
||||||
|
activate Redis
|
||||||
|
Redis --> Analytics: OK
|
||||||
|
deactivate Redis
|
||||||
|
deactivate Analytics
|
||||||
|
|
||||||
|
...참여자 등록 시...
|
||||||
|
|
||||||
|
Kafka -> Analytics: **ParticipantRegistered** 이벤트\n{participantId, eventId, phoneNumber}
|
||||||
|
activate Analytics
|
||||||
|
Analytics -> AnalyticsDB: 참여자 수 업데이트\n(참여자 수 1 증가)
|
||||||
|
activate AnalyticsDB
|
||||||
|
AnalyticsDB --> Analytics: OK
|
||||||
|
deactivate AnalyticsDB
|
||||||
|
Analytics -> Redis: 캐시 무효화\n(캐시키 삭제: analytics:dashboard:{eventId})
|
||||||
|
activate Redis
|
||||||
|
Redis --> Analytics: OK
|
||||||
|
deactivate Redis
|
||||||
|
deactivate Analytics
|
||||||
|
|
||||||
|
...배포 완료 시...
|
||||||
|
|
||||||
|
Kafka -> Analytics: **DistributionCompleted** 이벤트\n{eventId, distributedChannels, completedAt}
|
||||||
|
activate Analytics
|
||||||
|
Analytics -> AnalyticsDB: 채널 통계 저장\n(배포 완료된 채널 정보 저장)
|
||||||
|
activate AnalyticsDB
|
||||||
|
AnalyticsDB --> Analytics: OK
|
||||||
|
deactivate AnalyticsDB
|
||||||
|
Analytics -> Redis: 캐시 무효화\n(캐시키 삭제: analytics:dashboard:{eventId})
|
||||||
|
activate Redis
|
||||||
|
Redis --> Analytics: OK
|
||||||
|
deactivate Redis
|
||||||
|
deactivate Analytics
|
||||||
|
|
||||||
|
note right of Analytics
|
||||||
|
**실시간 업데이트 메커니즘**
|
||||||
|
- EventCreated: 이벤트 기본 정보 초기화
|
||||||
|
- ParticipantRegistered: 참여자 수 실시간 증가
|
||||||
|
- DistributionCompleted: 배포 채널 통계 업데이트
|
||||||
|
- 캐시 무효화: 다음 조회 시 최신 데이터 갱신
|
||||||
|
end note
|
||||||
|
|
||||||
|
@enduml
|
||||||
250
design/backend/sequence/outer/이벤트생성플로우.puml
Normal file
250
design/backend/sequence/outer/이벤트생성플로우.puml
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
@startuml 이벤트생성플로우
|
||||||
|
!theme mono
|
||||||
|
|
||||||
|
title 이벤트 생성 플로우 - 외부 시퀀스 다이어그램
|
||||||
|
|
||||||
|
actor "소상공인" as User
|
||||||
|
participant "Frontend" as FE
|
||||||
|
participant "API Gateway" as Gateway
|
||||||
|
participant "Event Service" as Event
|
||||||
|
participant "User Service" as UserSvc
|
||||||
|
participant "AI Service" as AI
|
||||||
|
participant "Content Service" as Content
|
||||||
|
participant "Distribution Service" as Dist
|
||||||
|
participant "Kafka" as Kafka
|
||||||
|
database "Event DB" as EventDB
|
||||||
|
database "User DB" as UserDB
|
||||||
|
database "Redis" as Redis
|
||||||
|
participant "외부 AI API" as AIApi
|
||||||
|
participant "이미지 생성 API" as ImageApi
|
||||||
|
participant "배포 채널 APIs" as ChannelApis
|
||||||
|
|
||||||
|
== 1. 이벤트 목적 선택 (UFR-EVENT-020) ==
|
||||||
|
User -> FE: 이벤트 목적 선택
|
||||||
|
FE -> Gateway: GET /api/users/{userId}/store\n회원 및 매장정보 조회
|
||||||
|
activate Gateway
|
||||||
|
Gateway -> UserSvc: GET /api/users/{userId}/store\n회원 및 매장정보 조회
|
||||||
|
activate UserSvc
|
||||||
|
UserSvc -> UserDB: 사용자 및 매장 정보 조회
|
||||||
|
activate UserDB
|
||||||
|
UserDB --> UserSvc: 사용자, 매장 정보 반환
|
||||||
|
deactivate UserDB
|
||||||
|
UserSvc --> Gateway: 200 OK\n{userId, storeName, industry, address}
|
||||||
|
deactivate UserSvc
|
||||||
|
Gateway --> FE: 200 OK\n{userId, storeName, industry, address}
|
||||||
|
deactivate Gateway
|
||||||
|
FE -> Gateway: POST /events/purposes\n{목적, userId, storeName, industry, address}
|
||||||
|
Gateway -> Event: 이벤트 목적 저장 요청
|
||||||
|
Event -> Redis: 이벤트 목적 정보 저장\nKey: draft:event:{eventDraftId}\n(목적, 매장정보 저장)\nTTL: 24시간
|
||||||
|
activate Redis
|
||||||
|
Redis --> Event: 저장 완료
|
||||||
|
deactivate Redis
|
||||||
|
Event --> Gateway: 저장 완료\n{eventDraftId}
|
||||||
|
Gateway --> FE: 200 OK
|
||||||
|
FE --> User: AI 추천 화면으로 이동
|
||||||
|
|
||||||
|
== 2. AI 이벤트 추천 - 비동기 처리 (UFR-EVENT-030) ==
|
||||||
|
User -> FE: AI 추천 요청
|
||||||
|
FE -> Gateway: POST /api/events/{eventDraftId}/ai-recommendations\n{목적, 업종, 지역}
|
||||||
|
Gateway -> Event: AI 추천 요청 전달
|
||||||
|
Event -> Kafka: Publish to ai-job-topic\n{jobId, eventDraftId, 목적, 업종, 지역}
|
||||||
|
Event --> Gateway: Job 생성 완료\n{jobId, status: PENDING}
|
||||||
|
Gateway --> FE: 202 Accepted\n{jobId}
|
||||||
|
FE --> User: "AI가 분석 중입니다..." (로딩)
|
||||||
|
|
||||||
|
note over AI: Kafka Consumer\nai 이벤트 생성 topic 구독
|
||||||
|
Kafka --> AI: Consume Job Message\n{jobId, eventDraftId, ...}
|
||||||
|
|
||||||
|
AI -> AIApi: 트렌드 분석 및 이벤트 추천 요청\n{목적, 업종, 지역, 매장정보}\n[Circuit Breaker, Timeout: 5분]
|
||||||
|
AIApi --> AI: 3가지 추천안 + 트렌드 요약\n(예: "여름철 시원한 음료 선호도 증가")
|
||||||
|
AI -> Redis: AI 추천 결과 저장\nKey: ai:event:{eventDraftId}\n(3가지 추천안, 트렌드 요약)\nTTL: 24시간
|
||||||
|
Redis --> AI: 저장 완료
|
||||||
|
AI -> Redis: Job 상태 업데이트\n(상태를 COMPLETED로 변경)
|
||||||
|
note over AI, Redis: AI 추천 정보는 Redis에 저장\n- Content Service가 읽기 위함\n- 최종 승인 시 Event DB에 영구 저장
|
||||||
|
|
||||||
|
group Polling으로 상태 확인
|
||||||
|
loop 상태 확인 (최대 30초)
|
||||||
|
FE -> Gateway: GET /jobs/{jobId}/status
|
||||||
|
Gateway -> Event: Job 상태 조회
|
||||||
|
Event -> Redis: Job 상태 조회\n(jobId로 상태 및 결과 조회)
|
||||||
|
Redis --> Event: {status, result}
|
||||||
|
|
||||||
|
alt Job 완료
|
||||||
|
Event --> Gateway: 200 OK\n{status: COMPLETED, recommendations, trendSummary}
|
||||||
|
Gateway --> FE: 추천 결과 및 트렌드 요약 반환
|
||||||
|
FE --> User: 트렌드 요약 표시\n3가지 추천안 표시\n(제목/경품 수정 가능)
|
||||||
|
else Job 진행중
|
||||||
|
Event --> Gateway: 200 OK\n{status: PENDING/PROCESSING}
|
||||||
|
Gateway --> FE: 진행중 상태
|
||||||
|
note over FE: 2초 후 재요청
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
User -> FE: 추천안 선택\n(제목/경품 커스텀)
|
||||||
|
FE -> Gateway: PUT /events/drafts/{eventDraftId}\n{선택한 추천안, 커스텀 정보}
|
||||||
|
Gateway -> Event: 선택 저장
|
||||||
|
Event -> Redis: 선택한 추천안 저장\nKey: draft:event:{eventDraftId}\n(이벤트 초안 업데이트)\nTTL: 24시간
|
||||||
|
activate Redis
|
||||||
|
Redis --> Event: 업데이트 완료
|
||||||
|
deactivate Redis
|
||||||
|
Event --> Gateway: 200 OK
|
||||||
|
Gateway --> FE: 저장 완료
|
||||||
|
FE --> User: 콘텐츠 생성 화면으로 이동
|
||||||
|
|
||||||
|
== 3. SNS 이미지 생성 - 비동기 처리 (UFR-CONT-010) ==
|
||||||
|
User -> FE: 이미지 생성 요청
|
||||||
|
FE -> Gateway: POST /api/content/images/{eventDraftId}/generate
|
||||||
|
Gateway -> Content: 이미지 생성 요청
|
||||||
|
Content -> Content: Job 생성\n{jobId, eventDraftId, status: PENDING}
|
||||||
|
Content --> Gateway: Job 생성 완료\n{jobId, status: PENDING}
|
||||||
|
Gateway --> FE: 202 Accepted\n{jobId}
|
||||||
|
FE --> User: "이미지 생성 중..." (로딩)
|
||||||
|
|
||||||
|
note over Content: 백그라운드 워커\nRedis 폴링 또는 스케줄러
|
||||||
|
|
||||||
|
Content -> Redis: AI 이벤트 데이터 읽기\nKey: ai:event:{eventDraftId}
|
||||||
|
activate Redis
|
||||||
|
Redis --> Content: AI 추천 결과\n{선택된 추천안, 이벤트 정보}
|
||||||
|
deactivate Redis
|
||||||
|
|
||||||
|
note over Content: inner sequence 참조:\ncontent-이미지생성.puml
|
||||||
|
|
||||||
|
par 심플 스타일
|
||||||
|
Content -> ImageApi: 심플 스타일 생성 요청\n[Circuit Breaker, Timeout: 5분]
|
||||||
|
ImageApi --> Content: 심플 이미지 URL
|
||||||
|
else 화려한 스타일
|
||||||
|
Content -> ImageApi: 화려한 스타일 생성 요청\n[Circuit Breaker, Timeout: 5분]
|
||||||
|
ImageApi --> Content: 화려한 이미지 URL
|
||||||
|
else 트렌디 스타일
|
||||||
|
Content -> ImageApi: 트렌디 스타일 생성 요청\n[Circuit Breaker, Timeout: 5분]
|
||||||
|
ImageApi --> Content: 트렌디 이미지 URL
|
||||||
|
end
|
||||||
|
|
||||||
|
Content -> Redis: 이미지 URL 저장\nKey: content:image:{eventDraftId}\n{심플, 화려, 트렌디 URL}\nTTL: 7일
|
||||||
|
activate Redis
|
||||||
|
Redis --> Content: 저장 완료
|
||||||
|
deactivate Redis
|
||||||
|
Content -> Redis: Job 상태 업데이트\n(상태를 COMPLETED로 변경)
|
||||||
|
note over Content, Redis: 이미지 URL은 Redis에 저장\n- 최종 승인 시 Event DB에 영구 저장
|
||||||
|
|
||||||
|
group Polling으로 상태 확인
|
||||||
|
loop 상태 확인 (최대 30초)
|
||||||
|
FE -> Gateway: GET /api/content/jobs/{jobId}/status
|
||||||
|
Gateway -> Content: Job 상태 조회
|
||||||
|
Content -> Redis: Job 상태 조회\n(jobId로 상태 및 이미지 URL 조회)
|
||||||
|
Redis --> Content: {status, imageUrls}
|
||||||
|
|
||||||
|
alt Job 완료
|
||||||
|
Content --> Gateway: 200 OK\n{status: COMPLETED, imageUrls}
|
||||||
|
Gateway --> FE: 이미지 URL 반환
|
||||||
|
FE --> User: 3가지 스타일 카드 표시
|
||||||
|
else Job 진행중
|
||||||
|
Content --> Gateway: 200 OK\n{status: PENDING/PROCESSING}
|
||||||
|
Gateway --> FE: 진행중 상태
|
||||||
|
note over FE: 2초 후 재요청
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
User -> FE: 스타일 선택 및 편집
|
||||||
|
FE -> Gateway: PUT /events/drafts/{eventDraftId}/content\n{선택한 이미지, 편집내용}
|
||||||
|
Gateway -> Event: 콘텐츠 선택 저장
|
||||||
|
Event -> Redis: 선택한 콘텐츠 저장\nKey: draft:event:{eventDraftId}\n(이벤트 초안 업데이트)\nTTL: 24시간
|
||||||
|
activate Redis
|
||||||
|
Redis --> Event: 업데이트 완료
|
||||||
|
deactivate Redis
|
||||||
|
Event --> Gateway: 200 OK
|
||||||
|
Gateway --> FE: 저장 완료
|
||||||
|
FE --> User: 배포 채널 선택 화면으로 이동
|
||||||
|
|
||||||
|
== 4. 최종 승인 및 다중 채널 배포 - 동기 처리 (UFR-EVENT-050) ==
|
||||||
|
User -> FE: 배포 채널 선택\n최종 승인 요청
|
||||||
|
FE -> Gateway: POST /api/events/{eventDraftId}/publish\n{선택 채널 목록}
|
||||||
|
Gateway -> Event: 최종 승인 및 배포 처리
|
||||||
|
|
||||||
|
note over Event: Redis 데이터를 Event DB에 영구 저장
|
||||||
|
|
||||||
|
Event -> Redis: 이벤트 초안 조회\nKey: draft:event:{eventDraftId}
|
||||||
|
activate Redis
|
||||||
|
Redis --> Event: 이벤트 초안 데이터\n(목적, 매장정보, 추천안, 콘텐츠)
|
||||||
|
deactivate Redis
|
||||||
|
|
||||||
|
Event -> Redis: AI 추천 결과 조회\nKey: ai:event:{eventDraftId}
|
||||||
|
activate Redis
|
||||||
|
Redis --> Event: AI 추천 결과
|
||||||
|
deactivate Redis
|
||||||
|
|
||||||
|
Event -> Redis: 이미지 URL 조회\nKey: content:image:{eventDraftId}
|
||||||
|
activate Redis
|
||||||
|
Redis --> Event: 이미지 URL 목록
|
||||||
|
deactivate Redis
|
||||||
|
|
||||||
|
Event -> EventDB: 이벤트 정보 영구 저장\n(목적, 매장정보, AI 추천, 이미지 URL, 배포 채널 포함)
|
||||||
|
EventDB --> Event: 저장 완료
|
||||||
|
|
||||||
|
Event -> EventDB: 이벤트 상태 변경\n(DRAFT → APPROVED로 업데이트)
|
||||||
|
EventDB --> Event: 상태 변경 완료
|
||||||
|
Event -> Kafka: Publish to event-topic\nEventCreated\n{eventId, 이벤트정보}
|
||||||
|
|
||||||
|
note over Event: 동기 호출로 배포 진행\ninner sequence 참조:\ndistribution-다중채널배포.puml
|
||||||
|
Event -> Dist: REST API - 배포 요청\nPOST /api/distribution/distribute\n{eventId, channels[], contentUrls}
|
||||||
|
|
||||||
|
note over Dist: Sprint 2: Mock 처리\n- 외부 API 호출 없음\n- 모든 배포 즉시 성공 처리\n- 배포 로그만 DB 기록
|
||||||
|
|
||||||
|
Dist -> EventDB: 배포 이력 초기화\n(이벤트ID, 상태: PENDING)
|
||||||
|
EventDB --> Dist: 배포 이력 ID
|
||||||
|
Dist -> EventDB: 배포 이력 상태 업데이트\n(상태: IN_PROGRESS)
|
||||||
|
|
||||||
|
note over Dist: 다중 채널 Mock 배포\n(내부 처리 상세는 inner sequence 참조)
|
||||||
|
|
||||||
|
par 우리동네TV
|
||||||
|
alt 우리동네TV 선택
|
||||||
|
Dist -> Dist: Mock 처리\n(즉시 성공)
|
||||||
|
Dist -> EventDB: 채널 로그 저장\n(우리동네TV, 성공,\n배포ID, 예상노출수)
|
||||||
|
end
|
||||||
|
else 링고비즈
|
||||||
|
alt 링고비즈 선택
|
||||||
|
Dist -> Dist: Mock 처리\n(즉시 성공)
|
||||||
|
Dist -> EventDB: 채널 로그 저장\n(링고비즈, 성공,\n업데이트 시각)
|
||||||
|
end
|
||||||
|
else 지니TV
|
||||||
|
alt 지니TV 선택
|
||||||
|
Dist -> Dist: Mock 처리\n(즉시 성공)
|
||||||
|
Dist -> EventDB: 채널 로그 저장\n(지니TV, 성공,\n광고ID, 스케줄)
|
||||||
|
end
|
||||||
|
else Instagram
|
||||||
|
alt Instagram 선택
|
||||||
|
Dist -> Dist: Mock 처리\n(즉시 성공)
|
||||||
|
Dist -> EventDB: 채널 로그 저장\n(Instagram, 성공,\npostUrl, postId)
|
||||||
|
end
|
||||||
|
else Naver Blog
|
||||||
|
alt Naver Blog 선택
|
||||||
|
Dist -> Dist: Mock 처리\n(즉시 성공)
|
||||||
|
Dist -> EventDB: 채널 로그 저장\n(NaverBlog, 성공,\npostUrl)
|
||||||
|
end
|
||||||
|
else Kakao Channel
|
||||||
|
alt Kakao Channel 선택
|
||||||
|
Dist -> Dist: Mock 처리\n(즉시 성공)
|
||||||
|
Dist -> EventDB: 채널 로그 저장\n(KakaoChannel, 성공,\nmessageId)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
note over Dist: 모든 채널 배포 완료 (즉시 처리)
|
||||||
|
|
||||||
|
Dist -> EventDB: 배포 이력 상태 업데이트\n(상태: COMPLETED, 완료일시)
|
||||||
|
|
||||||
|
Dist -> Kafka: Publish to event-topic\nDistributionCompleted\n{eventId, channels[], results[], completedAt}
|
||||||
|
|
||||||
|
Dist --> Event: REST API 동기 응답\n200 OK\n{distributionId, status: COMPLETED, results[]}
|
||||||
|
|
||||||
|
Event -> EventDB: 이벤트 상태 업데이트\n(APPROVED → ACTIVE로 변경)
|
||||||
|
EventDB --> Event: 업데이트 완료
|
||||||
|
|
||||||
|
Event --> Gateway: 200 OK\n{eventId, 배포결과}
|
||||||
|
Gateway --> FE: 배포 완료
|
||||||
|
FE --> User: "이벤트가 배포되었습니다"\n대시보드로 이동
|
||||||
|
|
||||||
|
note over Event, Dist: Sprint 2 제약사항\n- 외부 API 호출 없음 (Mock)\n- 모든 배포 즉시 성공 처리\n- Circuit Breaker 미구현\n- Retry 로직 미구현\n\nSprint 3 이후 구현 예정\n- 실제 외부 채널 API 연동\n- Circuit Breaker 패턴\n- Retry 및 실패 처리
|
||||||
|
|
||||||
|
@enduml
|
||||||
619
design/pattern/architecture-pattern.md
Normal file
619
design/pattern/architecture-pattern.md
Normal file
@ -0,0 +1,619 @@
|
|||||||
|
# KT AI 기반 소상공인 이벤트 자동 생성 서비스 - 클라우드 아키텍처 패턴 적용 방안 (MVP)
|
||||||
|
|
||||||
|
## 문서 정보
|
||||||
|
- **작성일**: 2025-10-21
|
||||||
|
- **버전**: 2.0 (MVP 집중)
|
||||||
|
- **적용 패턴**: 4개 핵심 패턴
|
||||||
|
- **목표**: 빠른 MVP 출시와 안정적 서비스 제공
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 요구사항 분석
|
||||||
|
|
||||||
|
### 1.1 기능적 요구사항
|
||||||
|
|
||||||
|
**User 서비스**
|
||||||
|
- 회원가입/로그인 및 프로필 관리
|
||||||
|
- 사업자번호 검증 (국세청 API)
|
||||||
|
|
||||||
|
**Event 서비스**
|
||||||
|
- 이벤트 CRUD 및 상태 관리
|
||||||
|
- 이벤트 목적 선택 → AI 추천 → 콘텐츠 생성 → 배포 → 분석 플로우
|
||||||
|
|
||||||
|
**AI 서비스**
|
||||||
|
- 트렌드 분석 (업종/지역/시즌)
|
||||||
|
- 3가지 이벤트 기획안 자동 생성
|
||||||
|
- Claude API/GPT-4 API 연동
|
||||||
|
|
||||||
|
**Content 서비스**
|
||||||
|
- 3가지 스타일 SNS 이미지 자동 생성
|
||||||
|
- Stable Diffusion/DALL-E API 연동
|
||||||
|
- 플랫폼별 이미지 최적화 (Instagram, Naver, Kakao)
|
||||||
|
|
||||||
|
**Distribution 서비스**
|
||||||
|
- 다중 채널 동시 배포 (우리동네TV, 링고비즈, 지니TV, SNS)
|
||||||
|
- 7개 외부 API 연동
|
||||||
|
|
||||||
|
**Participation 서비스**
|
||||||
|
- 이벤트 참여 접수 및 당첨자 추첨
|
||||||
|
|
||||||
|
**Analytics 서비스**
|
||||||
|
- 실시간 성과 대시보드
|
||||||
|
- 채널별 성과 분석
|
||||||
|
|
||||||
|
### 1.2 비기능적 요구사항
|
||||||
|
|
||||||
|
**성능 요구사항**
|
||||||
|
- AI 트렌드 분석 + 이벤트 추천: **10초 이내**
|
||||||
|
- SNS 이미지 생성: **5초 이내**
|
||||||
|
- 대시보드 데이터 업데이트: **5분 간격**
|
||||||
|
- 다중 채널 배포: **1분 이내**
|
||||||
|
|
||||||
|
**가용성 요구사항**
|
||||||
|
- 시스템 가용성: **99% 이상** (MVP 목표)
|
||||||
|
- 외부 API 장애 시 서비스 지속 가능
|
||||||
|
|
||||||
|
**확장성 요구사항**
|
||||||
|
- 초기 사용자: **100명**
|
||||||
|
- 동시 이벤트: **50개**
|
||||||
|
- 캐시 히트율: **80% 이상**
|
||||||
|
|
||||||
|
**보안 요구사항**
|
||||||
|
- JWT 기반 인증/인가
|
||||||
|
- API Gateway를 통한 중앙집중식 보안
|
||||||
|
|
||||||
|
### 1.3 외부 API 의존성
|
||||||
|
|
||||||
|
| 서비스 | 외부 API | 용도 | 응답시간 |
|
||||||
|
|--------|----------|------|----------|
|
||||||
|
| User | 국세청 사업자등록정보 진위확인 API | 사업자번호 검증 | 2-3초 |
|
||||||
|
| AI | Claude API / GPT-4 API | 트렌드 분석 및 이벤트 추천 | 5-10초 |
|
||||||
|
| Content | Stable Diffusion / DALL-E API | SNS 이미지 생성 | 3-5초 |
|
||||||
|
| Distribution | 우리동네TV API | 영상 업로드 및 송출 | 1-2초 |
|
||||||
|
| Distribution | 링고비즈 API | 연결음 업데이트 | 1초 |
|
||||||
|
| Distribution | 지니TV 광고 API | 광고 등록 | 1-2초 |
|
||||||
|
| Distribution | SNS API (Instagram, Naver, Kakao) | 자동 포스팅 | 1-3초 |
|
||||||
|
|
||||||
|
### 1.4 기술적 도전과제
|
||||||
|
|
||||||
|
#### 1. AI 응답 시간 관리 (🔴 Critical)
|
||||||
|
|
||||||
|
**문제**: AI API 응답이 10초 목표를 초과할 수 있음
|
||||||
|
- Claude/GPT-4 API 응답 시간 변동성 (5-15초)
|
||||||
|
- 트렌드 분석 + 이벤트 추천 2단계 처리 필요
|
||||||
|
|
||||||
|
**해결방안**:
|
||||||
|
- **비동기 처리**: Job 기반 처리로 응답 대기 시간 최소화
|
||||||
|
- **캐싱**: 동일 조건(업종, 지역, 목적) 결과 Redis 캐싱 (24시간)
|
||||||
|
- **응답 시간 90% 단축** (캐시 히트 시)
|
||||||
|
|
||||||
|
#### 2. 다중 외부 API 의존성 (🔴 Critical)
|
||||||
|
|
||||||
|
**문제**: 7개 외부 API 연동으로 인한 장애 전파 위험
|
||||||
|
- 각 API별 응답 시간 및 성공률 상이
|
||||||
|
- 하나의 API 장애가 전체 서비스 중단으로 이어질 수 있음
|
||||||
|
|
||||||
|
**해결방안**:
|
||||||
|
- **Circuit Breaker**: 장애 API 자동 차단 및 Fallback 처리
|
||||||
|
- **독립적 처리**: 채널별 배포 실패가 다른 채널에 영향 없음
|
||||||
|
- **자동 재시도**: 일시적 오류 시 최대 3회 재시도
|
||||||
|
|
||||||
|
#### 3. 이미지 생성 비용 및 시간 (🟡 Important)
|
||||||
|
|
||||||
|
**문제**: AI 이미지 생성 API 비용이 높고 시간 소요
|
||||||
|
- 이미지 1장당 생성 비용: $0.02-0.05
|
||||||
|
- 3가지 스타일 생성 시 비용 3배
|
||||||
|
|
||||||
|
**해결방안**:
|
||||||
|
- **캐싱**: 동일 이벤트 정보 이미지 재사용
|
||||||
|
- **비용 90% 절감** (캐시 히트 시)
|
||||||
|
|
||||||
|
#### 4. 실시간 대시보드 성능 (🟡 Important)
|
||||||
|
|
||||||
|
**문제**: 다중 데이터 소스 통합으로 인한 응답 지연
|
||||||
|
- 7개 외부 API + 내부 서비스 데이터 통합
|
||||||
|
- 복잡한 차트 및 계산 로직
|
||||||
|
|
||||||
|
**해결방안**:
|
||||||
|
- **캐싱**: Redis를 통한 5분 간격 데이터 캐싱
|
||||||
|
- **응답 시간 80% 단축**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 패턴 선정 및 평가
|
||||||
|
|
||||||
|
### 2.1 MVP 핵심 패턴 (4개)
|
||||||
|
|
||||||
|
| 패턴 | 적용 목적 | 주요 효과 |
|
||||||
|
|------|----------|-----------|
|
||||||
|
| **Cache-Aside** | AI 응답 시간 단축 및 비용 절감 | 응답 시간 90% 단축, 비용 90% 절감 |
|
||||||
|
| **API Gateway** | 중앙집중식 보안 및 라우팅 | 개발 복잡도 감소, 보안 강화 |
|
||||||
|
| **Asynchronous Request-Reply** | 장시간 작업 응답 시간 개선 | 사용자 대기 시간 제거 |
|
||||||
|
| **Circuit Breaker** (Optional) | 외부 API 장애 격리 | 가용성 95% → 99% 개선 |
|
||||||
|
|
||||||
|
### 2.2 정량적 평가 매트릭스
|
||||||
|
|
||||||
|
**평가 기준**:
|
||||||
|
- **기능 적합성** (35%): 요구사항 직접 해결 능력
|
||||||
|
- **성능 효과** (25%): 응답시간 및 처리량 개선
|
||||||
|
- **운영 복잡도** (20%): 구현 및 운영 용이성
|
||||||
|
- **확장성** (15%): 미래 요구사항 대응력
|
||||||
|
- **비용 효율성** (5%): 개발/운영 비용 대비 효과
|
||||||
|
|
||||||
|
| 패턴 | 기능 적합성<br/>(35%) | 성능 효과<br/>(25%) | 운영 복잡도<br/>(20%) | 확장성<br/>(15%) | 비용 효율성<br/>(5%) | **총점** | 우선순위 |
|
||||||
|
|------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||||
|
| **Cache-Aside** | 10 × 0.35<br/>= 3.5 | 10 × 0.25<br/>= 2.5 | 8 × 0.20<br/>= 1.6 | 9 × 0.15<br/>= 1.35 | 10 × 0.05<br/>= 0.5 | **9.45** | 🔴 Critical |
|
||||||
|
| **API Gateway** | 9 × 0.35<br/>= 3.15 | 7 × 0.25<br/>= 1.75 | 9 × 0.20<br/>= 1.8 | 9 × 0.15<br/>= 1.35 | 8 × 0.05<br/>= 0.4 | **8.45** | 🔴 Critical |
|
||||||
|
| **Asynchronous Request-Reply** | 9 × 0.35<br/>= 3.15 | 9 × 0.25<br/>= 2.25 | 7 × 0.20<br/>= 1.4 | 8 × 0.15<br/>= 1.2 | 8 × 0.05<br/>= 0.4 | **8.40** | 🔴 Critical |
|
||||||
|
| **Circuit Breaker** | 8 × 0.35<br/>= 2.8 | 8 × 0.25<br/>= 2.0 | 9 × 0.20<br/>= 1.8 | 9 × 0.15<br/>= 1.35 | 7 × 0.05<br/>= 0.35 | **8.30** | 🟡 Optional |
|
||||||
|
|
||||||
|
### 2.3 패턴별 상세 분석
|
||||||
|
|
||||||
|
#### 2.3.1 Cache-Aside (9.45점) - 🔴 Critical
|
||||||
|
|
||||||
|
**적용 대상**:
|
||||||
|
- AI 서비스: 트렌드 분석 결과, 이벤트 추천 결과
|
||||||
|
- Content 서비스: 생성된 SNS 이미지
|
||||||
|
- User 서비스: 사업자번호 검증 결과
|
||||||
|
|
||||||
|
**구현 방식**:
|
||||||
|
```
|
||||||
|
1. 요청 수신 → 캐시 확인 (Redis GET)
|
||||||
|
2. 캐시 HIT: 즉시 반환 (응답 시간 0.1초)
|
||||||
|
3. 캐시 MISS:
|
||||||
|
- 외부 API 호출 (응답 시간 5-10초)
|
||||||
|
- 결과를 캐시에 저장 (Redis SET, TTL 24시간)
|
||||||
|
- 결과 반환
|
||||||
|
```
|
||||||
|
|
||||||
|
**기대 효과**:
|
||||||
|
- **응답 시간**: 10초 → 0.1초 (99% 개선, 캐시 히트 시)
|
||||||
|
- **비용 절감**: AI API 호출 90% 감소 → 월 $2,000 → $200 (90% 절감)
|
||||||
|
- **캐시 히트율**: 80% (동일 업종/지역 이벤트 반복 요청)
|
||||||
|
|
||||||
|
**운영 고려사항**:
|
||||||
|
- Redis 메모리 관리: 최대 2GB (약 10,000개 캐시 항목)
|
||||||
|
- TTL 정책: 24시간 (트렌드 변화 반영)
|
||||||
|
- 캐시 무효화: 수동 무효화 API 제공
|
||||||
|
|
||||||
|
#### 2.3.2 API Gateway (8.45점) - 🔴 Critical
|
||||||
|
|
||||||
|
**적용 대상**:
|
||||||
|
- 전체 마이크로서비스 (User, Event, AI, Content, Distribution, Participation, Analytics)
|
||||||
|
|
||||||
|
**주요 기능**:
|
||||||
|
1. **인증/인가**: JWT 토큰 검증 (모든 요청)
|
||||||
|
2. **라우팅**: URL 기반 서비스 라우팅
|
||||||
|
3. **Rate Limiting**: API 호출 제한 (사용자당 100 req/min)
|
||||||
|
4. **로깅**: 중앙집중식 접근 로그
|
||||||
|
|
||||||
|
**구현 방식**:
|
||||||
|
```
|
||||||
|
Client → API Gateway (Kong/AWS API Gateway)
|
||||||
|
├─ /api/users/* → User Service
|
||||||
|
├─ /api/events/* → Event Service
|
||||||
|
├─ /api/ai/* → AI Service
|
||||||
|
├─ /api/content/* → Content Service
|
||||||
|
├─ /api/distribution/* → Distribution Service
|
||||||
|
├─ /api/participation/* → Participation Service
|
||||||
|
└─ /api/analytics/* → Analytics Service
|
||||||
|
```
|
||||||
|
|
||||||
|
**기대 효과**:
|
||||||
|
- **개발 생산성**: 각 서비스에서 인증 로직 제거 → 개발 시간 30% 단축
|
||||||
|
- **보안 강화**: 중앙집중식 보안 정책 관리
|
||||||
|
- **운영 효율**: 통합 모니터링 및 로깅
|
||||||
|
|
||||||
|
#### 2.3.3 Asynchronous Request-Reply (8.40점) - 🔴 Critical
|
||||||
|
|
||||||
|
**적용 대상**:
|
||||||
|
- AI 서비스: 트렌드 분석 및 이벤트 추천 (10초 소요)
|
||||||
|
- Content 서비스: SNS 이미지 생성 (5초 소요)
|
||||||
|
|
||||||
|
**구현 방식**:
|
||||||
|
```
|
||||||
|
1. 클라이언트 요청 → Job ID 즉시 반환 (응답 시간 0.1초)
|
||||||
|
2. 백그라운드 처리:
|
||||||
|
- AI API 호출 및 결과 생성 (10초)
|
||||||
|
- 결과를 DB 저장
|
||||||
|
3. 클라이언트 폴링:
|
||||||
|
- 5초 간격으로 Job 상태 확인 (GET /jobs/{id})
|
||||||
|
- 완료 시 결과 수신
|
||||||
|
```
|
||||||
|
|
||||||
|
**기대 효과**:
|
||||||
|
- **사용자 경험**: 대기 화면에서 진행 상황 표시 → 이탈률 감소
|
||||||
|
- **서버 부하**: 동기 대기 제거 → 동시 처리 가능 요청 5배 증가
|
||||||
|
|
||||||
|
**운영 고려사항**:
|
||||||
|
- Job 상태 저장: Redis (TTL 1시간)
|
||||||
|
- 폴링 간격: 5초 (UX 고려)
|
||||||
|
- Job 만료: 1시간 후 자동 삭제
|
||||||
|
|
||||||
|
#### 2.3.4 Circuit Breaker (8.30점) - 🟡 Optional
|
||||||
|
|
||||||
|
**적용 대상**:
|
||||||
|
- 모든 외부 API 연동 지점 (7개 API)
|
||||||
|
|
||||||
|
**동작 방식**:
|
||||||
|
```
|
||||||
|
1. Closed (정상):
|
||||||
|
- 외부 API 정상 호출
|
||||||
|
- 실패율 5% 미만
|
||||||
|
|
||||||
|
2. Open (차단):
|
||||||
|
- 실패율 5% 초과 시 Circuit Open
|
||||||
|
- 모든 요청 즉시 실패 (Fallback 처리)
|
||||||
|
- 30초 대기
|
||||||
|
|
||||||
|
3. Half-Open (테스트):
|
||||||
|
- 30초 후 1개 요청 시도
|
||||||
|
- 성공 시 Closed로 전환
|
||||||
|
- 실패 시 Open 유지
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fallback 전략**:
|
||||||
|
- **AI 서비스**: 캐시된 이전 추천 결과 제공 + 안내 메시지
|
||||||
|
- **Distribution 서비스**: 해당 채널 배포 스킵 + 알림
|
||||||
|
- **User 서비스**: 사업자번호 검증 스킵 (수동 확인으로 대체)
|
||||||
|
|
||||||
|
**기대 효과**:
|
||||||
|
- **가용성**: 95% → 99% (외부 API 장애 격리)
|
||||||
|
- **장애 복구**: 자동 복구 시간 5분 → 30초
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 서비스별 패턴 적용 설계
|
||||||
|
|
||||||
|
### 3.1 전체 아키텍처 (MVP)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph "클라이언트"
|
||||||
|
Client[Web/Mobile App]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "API Gateway Layer"
|
||||||
|
Gateway[API Gateway<br/>인증, 라우팅, Rate Limiting]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "마이크로서비스"
|
||||||
|
UserSvc[User Service<br/>회원관리]
|
||||||
|
EventSvc[Event Service<br/>이벤트 관리]
|
||||||
|
AISvc[AI Service<br/>트렌드 분석, 추천]
|
||||||
|
ContentSvc[Content Service<br/>이미지 생성]
|
||||||
|
DistSvc[Distribution Service<br/>다중 채널 배포]
|
||||||
|
PartSvc[Participation Service<br/>참여자 관리]
|
||||||
|
AnalSvc[Analytics Service<br/>성과 분석]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Cache Layer"
|
||||||
|
Redis[(Redis Cache<br/>Cache-Aside)]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "외부 API (Circuit Breaker 적용)"
|
||||||
|
TaxAPI[국세청 API]
|
||||||
|
ClaudeAPI[Claude API]
|
||||||
|
SDAPI[Stable Diffusion]
|
||||||
|
UriAPI[우리동네TV API]
|
||||||
|
RingoAPI[링고비즈 API]
|
||||||
|
GenieAPI[지니TV API]
|
||||||
|
SNSAPI[SNS API]
|
||||||
|
end
|
||||||
|
|
||||||
|
Client -->|HTTPS| Gateway
|
||||||
|
Gateway --> UserSvc
|
||||||
|
Gateway --> EventSvc
|
||||||
|
Gateway --> AISvc
|
||||||
|
Gateway --> ContentSvc
|
||||||
|
Gateway --> DistSvc
|
||||||
|
Gateway --> PartSvc
|
||||||
|
Gateway --> AnalSvc
|
||||||
|
|
||||||
|
UserSvc -.->|Cache-Aside| Redis
|
||||||
|
AISvc -.->|Cache-Aside| Redis
|
||||||
|
ContentSvc -.->|Cache-Aside| Redis
|
||||||
|
|
||||||
|
UserSvc -->|Circuit Breaker| TaxAPI
|
||||||
|
AISvc -->|Circuit Breaker| ClaudeAPI
|
||||||
|
ContentSvc -->|Circuit Breaker| SDAPI
|
||||||
|
DistSvc -->|Circuit Breaker| UriAPI
|
||||||
|
DistSvc -->|Circuit Breaker| RingoAPI
|
||||||
|
DistSvc -->|Circuit Breaker| GenieAPI
|
||||||
|
DistSvc -->|Circuit Breaker| SNSAPI
|
||||||
|
|
||||||
|
style Gateway fill:#e1f5ff
|
||||||
|
style Redis fill:#ffe1e1
|
||||||
|
style AISvc fill:#fff4e1
|
||||||
|
style ContentSvc fill:#fff4e1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 AI Service - Asynchronous Request-Reply 패턴
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Client as 클라이언트
|
||||||
|
participant API as API Gateway
|
||||||
|
participant Event as Event Service
|
||||||
|
participant AI as AI Service
|
||||||
|
participant Cache as Redis Cache
|
||||||
|
participant Claude as Claude API
|
||||||
|
|
||||||
|
Client->>API: POST /api/ai/recommendations<br/>(업종, 지역, 목적)
|
||||||
|
API->>Event: 이벤트 추천 요청
|
||||||
|
Event->>AI: 추천 요청
|
||||||
|
|
||||||
|
AI->>Cache: GET cached_result
|
||||||
|
alt Cache HIT
|
||||||
|
Cache-->>AI: 캐시된 결과
|
||||||
|
AI-->>Event: 즉시 반환 (0.1초)
|
||||||
|
Event-->>API: 결과
|
||||||
|
API-->>Client: 결과 (Total: 0.2초)
|
||||||
|
else Cache MISS
|
||||||
|
Cache-->>AI: null
|
||||||
|
AI-->>Event: Job ID 즉시 반환
|
||||||
|
Event-->>API: Job ID
|
||||||
|
API-->>Client: Job ID + 처리중 상태 (0.1초)
|
||||||
|
|
||||||
|
AI->>Claude: 비동기 AI 호출
|
||||||
|
Note over AI,Claude: 백그라운드 처리 (10초)
|
||||||
|
Claude-->>AI: 추천 결과
|
||||||
|
AI->>Cache: SET result (TTL 24h)
|
||||||
|
AI->>AI: Job 상태 = 완료
|
||||||
|
|
||||||
|
loop 폴링 (5초 간격)
|
||||||
|
Client->>API: GET /api/jobs/{id}
|
||||||
|
API->>Event: Job 상태 확인
|
||||||
|
Event->>AI: Job 상태
|
||||||
|
alt 완료
|
||||||
|
AI-->>Event: 결과
|
||||||
|
Event-->>API: 결과
|
||||||
|
API-->>Client: 최종 결과
|
||||||
|
else 진행중
|
||||||
|
AI-->>Event: 진행중
|
||||||
|
Event-->>API: 진행중
|
||||||
|
API-->>Client: 진행 상황 (예: 70%)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Distribution Service - Circuit Breaker 패턴
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph "Distribution Service"
|
||||||
|
DistCtrl[Distribution Controller]
|
||||||
|
CB_Uri[Circuit Breaker<br/>우리동네TV]
|
||||||
|
CB_Ringo[Circuit Breaker<br/>링고비즈]
|
||||||
|
CB_Genie[Circuit Breaker<br/>지니TV]
|
||||||
|
CB_SNS[Circuit Breaker<br/>SNS]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "외부 API"
|
||||||
|
UriAPI[우리동네TV API]
|
||||||
|
RingoAPI[링고비즈 API]
|
||||||
|
GenieAPI[지니TV API]
|
||||||
|
SNSAPI[SNS API]
|
||||||
|
end
|
||||||
|
|
||||||
|
DistCtrl --> CB_Uri
|
||||||
|
DistCtrl --> CB_Ringo
|
||||||
|
DistCtrl --> CB_Genie
|
||||||
|
DistCtrl --> CB_SNS
|
||||||
|
|
||||||
|
CB_Uri -->|실패율 < 5%<br/>Closed| UriAPI
|
||||||
|
CB_Uri -.->|실패율 >= 5%<br/>Open, Fallback| Fallback_Uri[배포 스킵<br/>+ 알림]
|
||||||
|
|
||||||
|
CB_Ringo -->|실패율 < 5%<br/>Closed| RingoAPI
|
||||||
|
CB_Ringo -.->|실패율 >= 5%<br/>Open, Fallback| Fallback_Ringo[배포 스킵<br/>+ 알림]
|
||||||
|
|
||||||
|
CB_Genie -->|실패율 < 5%<br/>Closed| GenieAPI
|
||||||
|
CB_Genie -.->|실패율 >= 5%<br/>Open, Fallback| Fallback_Genie[배포 스킵<br/>+ 알림]
|
||||||
|
|
||||||
|
CB_SNS -->|실패율 < 5%<br/>Closed| SNSAPI
|
||||||
|
CB_SNS -.->|실패율 >= 5%<br/>Open, Fallback| Fallback_SNS[배포 스킵<br/>+ 알림]
|
||||||
|
|
||||||
|
style CB_Uri fill:#ffe1e1
|
||||||
|
style CB_Ringo fill:#ffe1e1
|
||||||
|
style CB_Genie fill:#ffe1e1
|
||||||
|
style CB_SNS fill:#ffe1e1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. MVP 구현 로드맵
|
||||||
|
|
||||||
|
### 4.1 단일 Phase MVP 전략
|
||||||
|
|
||||||
|
**목표**: 빠른 출시와 안정적 서비스 제공
|
||||||
|
|
||||||
|
**기간**: 12주
|
||||||
|
|
||||||
|
**목표 지표**:
|
||||||
|
- 사용자: 100명
|
||||||
|
- 동시 이벤트: 50개
|
||||||
|
- 시스템 가용성: 99%
|
||||||
|
- AI 응답 시간: 10초 이내 (캐시 미스), 0.1초 (캐시 히트)
|
||||||
|
|
||||||
|
### 4.2 구현 순서 및 일정
|
||||||
|
|
||||||
|
| 주차 | 작업 내용 | 패턴 적용 | 완료 기준 |
|
||||||
|
|------|----------|-----------|-----------|
|
||||||
|
| **1-2주** | 인프라 구축 | API Gateway | - Kong/AWS API Gateway 설정<br/>- Redis 클러스터 구축<br/>- JWT 인증 구현 |
|
||||||
|
| **3-4주** | User/Event 서비스 | Cache-Aside | - 회원가입/로그인 완료<br/>- 사업자번호 검증 캐싱<br/>- 이벤트 CRUD 완료 |
|
||||||
|
| **5-6주** | AI 서비스 | Asynchronous Request-Reply<br/>Cache-Aside | - Claude API 연동<br/>- Job 기반 비동기 처리<br/>- AI 결과 캐싱 (24시간 TTL) |
|
||||||
|
| **7-8주** | Content 서비스 | Asynchronous Request-Reply<br/>Cache-Aside | - Stable Diffusion 연동<br/>- 이미지 생성 Job 처리<br/>- 이미지 캐싱 및 CDN |
|
||||||
|
| **9-10주** | Distribution 서비스 | Circuit Breaker | - 7개 외부 API 연동<br/>- 각 API별 Circuit Breaker<br/>- Fallback 전략 구현 |
|
||||||
|
| **11주** | Participation/Analytics | Cache-Aside | - 참여자 관리 및 추첨<br/>- 대시보드 데이터 캐싱 |
|
||||||
|
| **12주** | 테스트 및 출시 | 전체 패턴 검증 | - 부하 테스트 (100명)<br/>- 장애 시나리오 테스트<br/>- MVP 출시 |
|
||||||
|
|
||||||
|
### 4.3 기술 스택
|
||||||
|
|
||||||
|
**백엔드**:
|
||||||
|
- Spring Boot 3.2 (Java 17) / Node.js 20
|
||||||
|
- Redis 7.2 (Cache)
|
||||||
|
- PostgreSQL 15 (주 DB)
|
||||||
|
- RabbitMQ / Kafka (비동기 처리, Optional)
|
||||||
|
|
||||||
|
**프론트엔드**:
|
||||||
|
- React 18 + TypeScript 5
|
||||||
|
- Next.js 14 (SSR)
|
||||||
|
- Zustand (상태관리)
|
||||||
|
- React Query (서버 상태 관리)
|
||||||
|
|
||||||
|
**인프라**:
|
||||||
|
- Docker + Kubernetes
|
||||||
|
- Kong API Gateway / AWS API Gateway
|
||||||
|
- Resilience4j (Circuit Breaker)
|
||||||
|
- Prometheus + Grafana (모니터링)
|
||||||
|
|
||||||
|
**외부 API**:
|
||||||
|
- Claude API / GPT-4 API
|
||||||
|
- Stable Diffusion API
|
||||||
|
- 국세청 사업자등록정보 진위확인 API
|
||||||
|
- 우리동네TV, 링고비즈, 지니TV, SNS API
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 예상 성과
|
||||||
|
|
||||||
|
### 5.1 성능 개선
|
||||||
|
|
||||||
|
| 항목 | Before | After | 개선율 |
|
||||||
|
|------|--------|-------|--------|
|
||||||
|
| AI 응답 시간 (캐시 히트) | 10초 | 0.1초 | **99% 개선** |
|
||||||
|
| AI 응답 시간 (캐시 미스) | 10초 | 10초 | - |
|
||||||
|
| 이미지 생성 시간 (캐시 히트) | 5초 | 0.1초 | **98% 개선** |
|
||||||
|
| 대시보드 로딩 시간 | 3초 | 0.5초 | **83% 개선** |
|
||||||
|
|
||||||
|
### 5.2 비용 절감
|
||||||
|
|
||||||
|
**AI API 비용 절감** (캐시 히트율 80% 가정):
|
||||||
|
- Before: 1,000 요청/일 × $0.02 = **$20/일** = **$600/월**
|
||||||
|
- After: 200 요청/일 × $0.02 = **$4/일** = **$120/월**
|
||||||
|
- **절감액**: $480/월 (**80% 절감**)
|
||||||
|
|
||||||
|
**이미지 생성 비용 절감** (캐시 히트율 80% 가정):
|
||||||
|
- Before: 500 이미지/일 × $0.04 = **$20/일** = **$600/월**
|
||||||
|
- After: 100 이미지/일 × $0.04 = **$4/일** = **$120/월**
|
||||||
|
- **절감액**: $480/월 (**80% 절감**)
|
||||||
|
|
||||||
|
**총 비용 절감**:
|
||||||
|
- Before: $1,200/월
|
||||||
|
- After: $240/월
|
||||||
|
- **총 절감액**: **$960/월** (**80% 절감**)
|
||||||
|
|
||||||
|
### 5.3 가용성 개선
|
||||||
|
|
||||||
|
| 지표 | Before | After | 개선 |
|
||||||
|
|------|--------|-------|------|
|
||||||
|
| 시스템 가용성 | 95% | 99% | **+4%p** |
|
||||||
|
| 외부 API 장애 시 서비스 지속 | ❌ 불가 | ✅ 가능 (Fallback) | - |
|
||||||
|
| 장애 자동 복구 시간 | 5분 (수동) | 30초 (자동) | **90% 단축** |
|
||||||
|
|
||||||
|
### 5.4 개발 생산성
|
||||||
|
|
||||||
|
| 항목 | 효과 |
|
||||||
|
|------|------|
|
||||||
|
| API Gateway | 각 서비스 인증 로직 제거 → 개발 시간 **30% 단축** |
|
||||||
|
| Cache-Aside | 반복 API 호출 제거 → 테스트 시간 **50% 단축** |
|
||||||
|
| Circuit Breaker | 장애 처리 로직 자동화 → 예외 처리 코드 **40% 감소** |
|
||||||
|
| Async Request-Reply | 동시 처리 능력 **5배 증가** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 운영 고려사항
|
||||||
|
|
||||||
|
### 6.1 모니터링 지표
|
||||||
|
|
||||||
|
**Cache-Aside**:
|
||||||
|
- 캐시 히트율 (목표: 80% 이상)
|
||||||
|
- Redis 메모리 사용률 (목표: 70% 이하)
|
||||||
|
- 캐시 응답 시간 (목표: 100ms 이하)
|
||||||
|
|
||||||
|
**API Gateway**:
|
||||||
|
- 요청 처리량 (TPS)
|
||||||
|
- 평균 응답 시간
|
||||||
|
- 인증 실패율
|
||||||
|
|
||||||
|
**Circuit Breaker**:
|
||||||
|
- API별 실패율
|
||||||
|
- Circuit 상태 (Closed/Open/Half-Open)
|
||||||
|
- Fallback 호출 횟수
|
||||||
|
|
||||||
|
**Asynchronous Request-Reply**:
|
||||||
|
- Job 처리 시간
|
||||||
|
- 동시 Job 수
|
||||||
|
- Job 완료율
|
||||||
|
|
||||||
|
### 6.2 알람 임계값
|
||||||
|
|
||||||
|
| 지표 | Warning | Critical |
|
||||||
|
|------|---------|----------|
|
||||||
|
| 캐시 히트율 | < 70% | < 50% |
|
||||||
|
| Redis 메모리 | > 80% | > 90% |
|
||||||
|
| API 응답 시간 | > 500ms | > 1000ms |
|
||||||
|
| Circuit Breaker Open | 1개 | 3개 이상 |
|
||||||
|
| 시스템 가용성 | < 99% | < 95% |
|
||||||
|
|
||||||
|
### 6.3 장애 대응 절차
|
||||||
|
|
||||||
|
**Circuit Breaker Open 발생 시**:
|
||||||
|
1. 알람 수신 (Slack/Email)
|
||||||
|
2. 해당 외부 API 상태 확인
|
||||||
|
3. Fallback 전략 동작 확인
|
||||||
|
4. 30초 후 자동 복구 확인
|
||||||
|
5. 복구 실패 시 수동 개입
|
||||||
|
|
||||||
|
**캐시 장애 시**:
|
||||||
|
1. Redis 클러스터 상태 확인
|
||||||
|
2. Failover 자동 수행 (Sentinel)
|
||||||
|
3. 캐시 미스로 전환 (성능 저하 허용)
|
||||||
|
4. 긴급 Redis 복구
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 체크리스트
|
||||||
|
|
||||||
|
### 7.1 패턴 적용 완료 확인
|
||||||
|
|
||||||
|
- [x] **Cache-Aside**: AI 결과, 이미지, 사업자번호 검증 캐싱 완료
|
||||||
|
- [x] **API Gateway**: 인증, 라우팅, Rate Limiting 구현 완료
|
||||||
|
- [x] **Asynchronous Request-Reply**: AI 및 이미지 생성 Job 처리 완료
|
||||||
|
- [x] **Circuit Breaker**: 7개 외부 API에 Circuit Breaker 및 Fallback 적용 완료
|
||||||
|
|
||||||
|
### 7.2 성능 목표 달성 확인
|
||||||
|
|
||||||
|
- [ ] AI 응답 시간: 10초 이내 (캐시 미스), 0.1초 (캐시 히트)
|
||||||
|
- [ ] 캐시 히트율: 80% 이상
|
||||||
|
- [ ] 시스템 가용성: 99% 이상
|
||||||
|
- [ ] 비용 절감: 월 $960 (80%)
|
||||||
|
|
||||||
|
### 7.3 운영 준비 완료 확인
|
||||||
|
|
||||||
|
- [ ] 모니터링 대시보드 구축 (Prometheus + Grafana)
|
||||||
|
- [ ] 알람 설정 완료 (Slack/Email)
|
||||||
|
- [ ] 장애 대응 매뉴얼 작성
|
||||||
|
- [ ] 부하 테스트 완료 (100명)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 다음 단계 (Phase 2 이후)
|
||||||
|
|
||||||
|
**MVP 이후 확장 계획** (선택 사항):
|
||||||
|
|
||||||
|
- **Retry 패턴**: 일시적 오류 자동 재시도 (현재는 Circuit Breaker로 커버)
|
||||||
|
- **Queue-Based Load Leveling**: 트래픽 폭증 시 부하 분산
|
||||||
|
- **Saga 패턴**: 복잡한 분산 트랜잭션 관리 (이벤트 생성 플로우)
|
||||||
|
- **CQRS**: 읽기/쓰기 분리로 대시보드 성능 최적화
|
||||||
|
- **Event Sourcing**: 이벤트 변경 이력 추적 및 감사
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 참고 문서
|
||||||
|
|
||||||
|
- [유저스토리](../userstory.md)
|
||||||
|
- [UI/UX 설계서](../uiux/uiux.md)
|
||||||
|
- [클라우드 디자인 패턴 개요](../../claude/cloud-design-patterns.md)
|
||||||
|
- [백업 파일](./architecture-pattern-backup.md) - 이전 버전 (9개 패턴)
|
||||||
1097
design/pattern/backup/architecture-pattern-backup.md
Normal file
1097
design/pattern/backup/architecture-pattern-backup.md
Normal file
File diff suppressed because it is too large
Load Diff
1165
design/pattern/backup/architecture-pattern-backup2.md
Normal file
1165
design/pattern/backup/architecture-pattern-backup2.md
Normal file
File diff suppressed because it is too large
Load Diff
196
design/uiux/prototype/01-로그인.html
Normal file
196
design/uiux/prototype/01-로그인.html
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>로그인 - KT AI 이벤트 마케팅</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page flex flex-col items-center justify-center" style="padding-bottom: 0;">
|
||||||
|
<div class="container" style="max-width: 400px;">
|
||||||
|
<!-- Logo & Title -->
|
||||||
|
<div class="text-center mb-2xl">
|
||||||
|
<div class="mb-lg">
|
||||||
|
<span class="material-icons" style="font-size: 64px; color: var(--color-kt-red);">celebration</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-display text-kt-red mb-sm">KT AI 이벤트</h1>
|
||||||
|
<p class="text-body text-secondary">소상공인을 위한 스마트 마케팅</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Login Form -->
|
||||||
|
<form id="loginForm" class="mb-lg">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email" class="form-label form-label-required">이메일</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="example@email.com"
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
>
|
||||||
|
<span id="emailError" class="form-error hidden"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password" class="form-label form-label-required">비밀번호</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="8자 이상 입력하세요"
|
||||||
|
required
|
||||||
|
autocomplete="current-password"
|
||||||
|
>
|
||||||
|
<span id="passwordError" class="form-error hidden"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check mb-lg">
|
||||||
|
<input type="checkbox" id="remember" name="remember" class="form-check-input">
|
||||||
|
<label for="remember" class="form-check-label">로그인 상태 유지</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary btn-large btn-full mb-md">
|
||||||
|
로그인
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex justify-between text-body-small">
|
||||||
|
<a href="#" class="text-kt-red" id="findPassword">비밀번호 찾기</a>
|
||||||
|
<a href="02-회원가입.html" class="text-kt-red">회원가입</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<div class="flex items-center gap-md mb-lg">
|
||||||
|
<div class="flex-1 border-t"></div>
|
||||||
|
<span class="text-caption text-tertiary">또는</span>
|
||||||
|
<div class="flex-1 border-t"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SNS Login -->
|
||||||
|
<div class="flex flex-col gap-sm">
|
||||||
|
<button class="btn btn-secondary btn-large btn-full" id="kakaoLogin">
|
||||||
|
<span class="material-icons">chat_bubble</span>
|
||||||
|
카카오톡으로 시작하기
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary btn-large btn-full" id="naverLogin">
|
||||||
|
<span style="color: #03C75A; font-weight: bold;">N</span>
|
||||||
|
네이버로 시작하기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="text-center mt-2xl">
|
||||||
|
<p class="text-caption text-tertiary">
|
||||||
|
회원가입 시 <a href="#" class="text-kt-red">이용약관</a> 및 <a href="#" class="text-kt-red">개인정보처리방침</a>에 동의하게 됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="common.js"></script>
|
||||||
|
<script>
|
||||||
|
// 폼 제출 처리
|
||||||
|
document.getElementById('loginForm').addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const email = document.getElementById('email').value;
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
const remember = document.getElementById('remember').checked;
|
||||||
|
|
||||||
|
// 에러 메시지 초기화
|
||||||
|
document.getElementById('emailError').classList.add('hidden');
|
||||||
|
document.getElementById('passwordError').classList.add('hidden');
|
||||||
|
|
||||||
|
// 유효성 검사
|
||||||
|
let hasError = false;
|
||||||
|
|
||||||
|
if (!KTEventApp.Utils.validateEmail(email)) {
|
||||||
|
document.getElementById('emailError').textContent = '올바른 이메일 형식이 아닙니다.';
|
||||||
|
document.getElementById('emailError').classList.remove('hidden');
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
document.getElementById('passwordError').textContent = '비밀번호는 8자 이상이어야 합니다.';
|
||||||
|
document.getElementById('passwordError').classList.remove('hidden');
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasError) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로딩 표시
|
||||||
|
const submitBtn = e.target.querySelector('button[type="submit"]');
|
||||||
|
const originalText = submitBtn.textContent;
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = '로그인 중...';
|
||||||
|
|
||||||
|
// 로그인 시뮬레이션 (실제로는 API 호출)
|
||||||
|
setTimeout(() => {
|
||||||
|
// 예제 사용자 정보 저장
|
||||||
|
const user = KTEventApp.MockData.getDefaultUser();
|
||||||
|
user.email = email;
|
||||||
|
KTEventApp.Session.saveUser(user);
|
||||||
|
|
||||||
|
// 대시보드로 이동
|
||||||
|
window.location.href = '05-대시보드.html';
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 비밀번호 찾기
|
||||||
|
document.getElementById('findPassword').addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
KTEventApp.Feedback.showModal({
|
||||||
|
title: '비밀번호 찾기',
|
||||||
|
content: `
|
||||||
|
<p class="text-body mb-md">가입하신 이메일 주소를 입력해주세요.</p>
|
||||||
|
<input type="email" id="resetEmail" class="form-input" placeholder="example@email.com">
|
||||||
|
`,
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: '취소',
|
||||||
|
variant: 'text',
|
||||||
|
onClick: function() {
|
||||||
|
this.closest('.modal-backdrop').remove();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '확인',
|
||||||
|
variant: 'primary',
|
||||||
|
onClick: function() {
|
||||||
|
const resetEmail = document.getElementById('resetEmail').value;
|
||||||
|
if (KTEventApp.Utils.validateEmail(resetEmail)) {
|
||||||
|
this.closest('.modal-backdrop').remove();
|
||||||
|
KTEventApp.Feedback.showToast('비밀번호 재설정 이메일을 발송했습니다.');
|
||||||
|
} else {
|
||||||
|
KTEventApp.Feedback.showToast('올바른 이메일을 입력해주세요.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// SNS 로그인 시뮬레이션
|
||||||
|
document.getElementById('kakaoLogin').addEventListener('click', function() {
|
||||||
|
KTEventApp.Feedback.showToast('카카오톡 로그인은 준비 중입니다.');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('naverLogin').addEventListener('click', function() {
|
||||||
|
KTEventApp.Feedback.showToast('네이버 로그인은 준비 중입니다.');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 이미 로그인된 경우 대시보드로 이동
|
||||||
|
if (KTEventApp.Session.isLoggedIn()) {
|
||||||
|
window.location.href = '05-대시보드.html';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
400
design/uiux/prototype/02-회원가입.html
Normal file
400
design/uiux/prototype/02-회원가입.html
Normal file
@ -0,0 +1,400 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>회원가입 - KT AI 이벤트 마케팅</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page" style="padding-bottom: 0;">
|
||||||
|
<!-- Custom Header -->
|
||||||
|
<header class="header">
|
||||||
|
<div class="header-left">
|
||||||
|
<button class="header-icon-btn" id="backBtn" aria-label="뒤로가기">
|
||||||
|
<span class="material-icons">arrow_back</span>
|
||||||
|
</button>
|
||||||
|
<h1 class="header-title">회원가입</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Progress Indicator -->
|
||||||
|
<div class="p-md bg-primary">
|
||||||
|
<div id="progressBar"></div>
|
||||||
|
<p class="text-body-small text-secondary mt-sm text-center" id="stepInfo">1/3 단계</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container" style="max-width: 500px; padding-top: var(--spacing-lg);">
|
||||||
|
<!-- Step 1: 계정 정보 -->
|
||||||
|
<div id="step1" class="signup-step">
|
||||||
|
<h2 class="text-title mb-sm">계정 정보를 입력해주세요</h2>
|
||||||
|
<p class="text-body text-secondary mb-lg">로그인에 사용할 이메일과 비밀번호를 설정합니다</p>
|
||||||
|
|
||||||
|
<form id="step1Form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email" class="form-label form-label-required">이메일</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="example@email.com"
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
>
|
||||||
|
<span id="emailError" class="form-error hidden"></span>
|
||||||
|
<span class="form-hint">이메일 주소로 로그인합니다</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password" class="form-label form-label-required">비밀번호</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="8자 이상, 영문+숫자 조합"
|
||||||
|
required
|
||||||
|
autocomplete="new-password"
|
||||||
|
>
|
||||||
|
<span id="passwordError" class="form-error hidden"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="passwordConfirm" class="form-label form-label-required">비밀번호 확인</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="passwordConfirm"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="비밀번호를 다시 입력하세요"
|
||||||
|
required
|
||||||
|
autocomplete="new-password"
|
||||||
|
>
|
||||||
|
<span id="passwordConfirmError" class="form-error hidden"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary btn-large btn-full mt-xl">
|
||||||
|
다음
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: 개인 정보 -->
|
||||||
|
<div id="step2" class="signup-step hidden">
|
||||||
|
<h2 class="text-title mb-sm">개인 정보를 입력해주세요</h2>
|
||||||
|
<p class="text-body text-secondary mb-lg">서비스 이용을 위한 기본 정보입니다</p>
|
||||||
|
|
||||||
|
<form id="step2Form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name" class="form-label form-label-required">이름</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="홍길동"
|
||||||
|
required
|
||||||
|
autocomplete="name"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="phone" class="form-label form-label-required">휴대폰 번호</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
id="phone"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="010-1234-5678"
|
||||||
|
required
|
||||||
|
autocomplete="tel"
|
||||||
|
>
|
||||||
|
<span id="phoneError" class="form-error hidden"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-sm mt-xl">
|
||||||
|
<button type="button" class="btn btn-secondary btn-large flex-1" id="prevBtn1">
|
||||||
|
이전
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary btn-large flex-1">
|
||||||
|
다음
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: 사업장 정보 -->
|
||||||
|
<div id="step3" class="signup-step hidden">
|
||||||
|
<h2 class="text-title mb-sm">사업장 정보를 입력해주세요</h2>
|
||||||
|
<p class="text-body text-secondary mb-lg">맞춤형 이벤트 추천을 위한 정보입니다</p>
|
||||||
|
|
||||||
|
<form id="step3Form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="businessName" class="form-label form-label-required">상호명</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="businessName"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="홍길동 고깃집"
|
||||||
|
required
|
||||||
|
autocomplete="organization"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="businessType" class="form-label form-label-required">업종</label>
|
||||||
|
<select id="businessType" class="form-select" required>
|
||||||
|
<option value="">업종을 선택하세요</option>
|
||||||
|
<option value="restaurant">음식점</option>
|
||||||
|
<option value="cafe">카페/베이커리</option>
|
||||||
|
<option value="retail">소매/편의점</option>
|
||||||
|
<option value="beauty">미용/뷰티</option>
|
||||||
|
<option value="fitness">헬스/피트니스</option>
|
||||||
|
<option value="education">학원/교육</option>
|
||||||
|
<option value="service">서비스업</option>
|
||||||
|
<option value="other">기타</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="businessLocation" class="form-label">주요 지역</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="businessLocation"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="예: 강남구"
|
||||||
|
autocomplete="address-level2"
|
||||||
|
>
|
||||||
|
<span class="form-hint">선택 사항입니다</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 약관 동의 -->
|
||||||
|
<div class="mt-xl mb-lg">
|
||||||
|
<div class="form-check mb-md">
|
||||||
|
<input type="checkbox" id="agreeAll" class="form-check-input">
|
||||||
|
<label for="agreeAll" class="form-check-label text-bold">전체 동의</label>
|
||||||
|
</div>
|
||||||
|
<div class="border-t pt-md">
|
||||||
|
<div class="form-check mb-sm">
|
||||||
|
<input type="checkbox" id="agreeTerms" class="form-check-input agree-item" required>
|
||||||
|
<label for="agreeTerms" class="form-check-label">
|
||||||
|
<span>[필수]</span> 이용약관 동의
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check mb-sm">
|
||||||
|
<input type="checkbox" id="agreePrivacy" class="form-check-input agree-item" required>
|
||||||
|
<label for="agreePrivacy" class="form-check-label">
|
||||||
|
<span>[필수]</span> 개인정보 처리방침 동의
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" id="agreeMarketing" class="form-check-input agree-item">
|
||||||
|
<label for="agreeMarketing" class="form-check-label">
|
||||||
|
<span>[선택]</span> 마케팅 정보 수신 동의
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-sm">
|
||||||
|
<button type="button" class="btn btn-secondary btn-large flex-1" id="prevBtn2">
|
||||||
|
이전
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary btn-large flex-1">
|
||||||
|
가입완료
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="common.js"></script>
|
||||||
|
<script>
|
||||||
|
let currentStep = 1;
|
||||||
|
const signupData = {};
|
||||||
|
|
||||||
|
// Progress Bar 초기화
|
||||||
|
const progressBar = KTEventApp.Feedback.createProgressBar(33);
|
||||||
|
document.getElementById('progressBar').appendChild(progressBar.element);
|
||||||
|
|
||||||
|
// Step 전환 함수
|
||||||
|
function showStep(step) {
|
||||||
|
document.querySelectorAll('.signup-step').forEach(el => el.classList.add('hidden'));
|
||||||
|
document.getElementById(`step${step}`).classList.remove('hidden');
|
||||||
|
|
||||||
|
currentStep = step;
|
||||||
|
progressBar.setValue(step * 33);
|
||||||
|
document.getElementById('stepInfo').textContent = `${step}/3 단계`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 뒤로가기
|
||||||
|
document.getElementById('backBtn').addEventListener('click', () => {
|
||||||
|
if (currentStep === 1) {
|
||||||
|
window.location.href = '01-로그인.html';
|
||||||
|
} else {
|
||||||
|
showStep(currentStep - 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 1: 계정 정보
|
||||||
|
document.getElementById('step1Form').addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const email = document.getElementById('email').value;
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
const passwordConfirm = document.getElementById('passwordConfirm').value;
|
||||||
|
|
||||||
|
// 에러 초기화
|
||||||
|
document.getElementById('emailError').classList.add('hidden');
|
||||||
|
document.getElementById('passwordError').classList.add('hidden');
|
||||||
|
document.getElementById('passwordConfirmError').classList.add('hidden');
|
||||||
|
|
||||||
|
let hasError = false;
|
||||||
|
|
||||||
|
// 이메일 검증
|
||||||
|
if (!KTEventApp.Utils.validateEmail(email)) {
|
||||||
|
document.getElementById('emailError').textContent = '올바른 이메일 형식이 아닙니다.';
|
||||||
|
document.getElementById('emailError').classList.remove('hidden');
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비밀번호 검증
|
||||||
|
if (!KTEventApp.Utils.validatePassword(password)) {
|
||||||
|
document.getElementById('passwordError').textContent = '8자 이상, 영문과 숫자를 포함해야 합니다.';
|
||||||
|
document.getElementById('passwordError').classList.remove('hidden');
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비밀번호 확인
|
||||||
|
if (password !== passwordConfirm) {
|
||||||
|
document.getElementById('passwordConfirmError').textContent = '비밀번호가 일치하지 않습니다.';
|
||||||
|
document.getElementById('passwordConfirmError').classList.remove('hidden');
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasError) {
|
||||||
|
signupData.email = email;
|
||||||
|
signupData.password = password;
|
||||||
|
showStep(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: 개인 정보
|
||||||
|
document.getElementById('prevBtn1').addEventListener('click', () => showStep(1));
|
||||||
|
|
||||||
|
document.getElementById('step2Form').addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const name = document.getElementById('name').value;
|
||||||
|
const phone = document.getElementById('phone').value;
|
||||||
|
|
||||||
|
// 전화번호 형식 검증
|
||||||
|
const phonePattern = /^010-\d{3,4}-\d{4}$/;
|
||||||
|
if (!phonePattern.test(phone)) {
|
||||||
|
document.getElementById('phoneError').textContent = '올바른 전화번호 형식이 아닙니다 (010-1234-5678).';
|
||||||
|
document.getElementById('phoneError').classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
signupData.name = name;
|
||||||
|
signupData.phone = phone;
|
||||||
|
showStep(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 전화번호 자동 포맷팅
|
||||||
|
document.getElementById('phone').addEventListener('input', function(e) {
|
||||||
|
e.target.value = KTEventApp.Utils.formatPhoneNumber(e.target.value);
|
||||||
|
document.getElementById('phoneError').classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 3: 사업장 정보
|
||||||
|
document.getElementById('prevBtn2').addEventListener('click', () => showStep(2));
|
||||||
|
|
||||||
|
// 전체 동의 체크박스
|
||||||
|
document.getElementById('agreeAll').addEventListener('change', function(e) {
|
||||||
|
const checked = e.target.checked;
|
||||||
|
document.querySelectorAll('.agree-item').forEach(cb => {
|
||||||
|
cb.checked = checked;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 개별 체크박스
|
||||||
|
document.querySelectorAll('.agree-item').forEach(cb => {
|
||||||
|
cb.addEventListener('change', function() {
|
||||||
|
const allChecked = Array.from(document.querySelectorAll('.agree-item')).every(c => c.checked);
|
||||||
|
document.getElementById('agreeAll').checked = allChecked;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('step3Form').addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const businessName = document.getElementById('businessName').value;
|
||||||
|
const businessType = document.getElementById('businessType').value;
|
||||||
|
const businessLocation = document.getElementById('businessLocation').value;
|
||||||
|
const agreeTerms = document.getElementById('agreeTerms').checked;
|
||||||
|
const agreePrivacy = document.getElementById('agreePrivacy').checked;
|
||||||
|
const agreeMarketing = document.getElementById('agreeMarketing').checked;
|
||||||
|
|
||||||
|
if (!agreeTerms || !agreePrivacy) {
|
||||||
|
KTEventApp.Feedback.showToast('필수 약관에 동의해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
signupData.businessName = businessName;
|
||||||
|
signupData.businessType = businessType;
|
||||||
|
signupData.businessLocation = businessLocation;
|
||||||
|
signupData.agreeMarketing = agreeMarketing;
|
||||||
|
|
||||||
|
// 로딩 표시
|
||||||
|
const submitBtn = e.target.querySelector('button[type="submit"]');
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = '가입 처리 중...';
|
||||||
|
|
||||||
|
// 회원가입 시뮬레이션
|
||||||
|
setTimeout(() => {
|
||||||
|
// 사용자 정보 저장
|
||||||
|
const user = {
|
||||||
|
id: KTEventApp.Utils.generateId(),
|
||||||
|
name: signupData.name,
|
||||||
|
email: signupData.email,
|
||||||
|
phone: signupData.phone,
|
||||||
|
businessName: signupData.businessName,
|
||||||
|
businessType: signupData.businessType,
|
||||||
|
businessLocation: signupData.businessLocation,
|
||||||
|
joinDate: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
KTEventApp.Session.saveUser(user);
|
||||||
|
|
||||||
|
// 성공 모달 표시
|
||||||
|
KTEventApp.Feedback.showModal({
|
||||||
|
title: '회원가입 완료',
|
||||||
|
content: `
|
||||||
|
<div class="text-center p-lg">
|
||||||
|
<span class="material-icons" style="font-size: 64px; color: var(--color-success);">check_circle</span>
|
||||||
|
<p class="text-headline mt-md mb-sm">환영합니다!</p>
|
||||||
|
<p class="text-body text-secondary">회원가입이 완료되었습니다.<br>지금 바로 AI 이벤트를 시작해보세요.</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: '시작하기',
|
||||||
|
variant: 'primary',
|
||||||
|
size: 'large',
|
||||||
|
fullWidth: true,
|
||||||
|
onClick: function() {
|
||||||
|
window.location.href = '05-대시보드.html';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}, 1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 이미 로그인된 경우 대시보드로 이동
|
||||||
|
if (KTEventApp.Session.isLoggedIn()) {
|
||||||
|
window.location.href = '05-대시보드.html';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
214
design/uiux/prototype/03-프로필.html
Normal file
214
design/uiux/prototype/03-프로필.html
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>프로필 - KT AI 이벤트 마케팅</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page page-with-header">
|
||||||
|
<!-- Header -->
|
||||||
|
<div id="header"></div>
|
||||||
|
|
||||||
|
<div class="container" style="max-width: 600px;">
|
||||||
|
<!-- User Info Section -->
|
||||||
|
<section class="mt-lg mb-xl text-center">
|
||||||
|
<div class="mb-md" style="width: 80px; height: 80px; background: var(--color-gray-100); border-radius: var(--radius-full); display: flex; align-items: center; justify-content: center;">
|
||||||
|
<span class="material-icons" style="font-size: 48px; color: var(--color-gray-400);">person</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-title" id="userName">홍길동</h2>
|
||||||
|
<p class="text-body-small text-secondary" id="userEmail">hong@example.com</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Basic Info -->
|
||||||
|
<section class="mb-lg">
|
||||||
|
<h3 class="text-headline mb-md">기본 정보</h3>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name" class="form-label">이름</label>
|
||||||
|
<input type="text" id="name" class="form-input" value="홍길동">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="phone" class="form-label">전화번호</label>
|
||||||
|
<input type="tel" id="phone" class="form-input" value="010-1234-5678">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email" class="form-label">이메일</label>
|
||||||
|
<input type="email" id="email" class="form-input" value="hong@example.com">
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Business Info -->
|
||||||
|
<section class="mb-lg">
|
||||||
|
<h3 class="text-headline mb-md">매장 정보</h3>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="businessName" class="form-label">매장명</label>
|
||||||
|
<input type="text" id="businessName" class="form-input" value="홍길동 고깃집">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="businessType" class="form-label">업종</label>
|
||||||
|
<select id="businessType" class="form-select">
|
||||||
|
<option value="restaurant" selected>음식점</option>
|
||||||
|
<option value="cafe">카페/베이커리</option>
|
||||||
|
<option value="retail">소매/편의점</option>
|
||||||
|
<option value="beauty">미용/뷰티</option>
|
||||||
|
<option value="fitness">헬스/피트니스</option>
|
||||||
|
<option value="education">학원/교육</option>
|
||||||
|
<option value="service">서비스업</option>
|
||||||
|
<option value="other">기타</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="businessLocation" class="form-label">주소</label>
|
||||||
|
<input type="text" id="businessLocation" class="form-input" value="서울시 강남구">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="businessHours" class="form-label">영업시간</label>
|
||||||
|
<input type="text" id="businessHours" class="form-input" value="10:00 ~ 22:00">
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Password Change -->
|
||||||
|
<section class="mb-xl">
|
||||||
|
<h3 class="text-headline mb-md">비밀번호 변경</h3>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="currentPassword" class="form-label">현재 비밀번호</label>
|
||||||
|
<input type="password" id="currentPassword" class="form-input" placeholder="현재 비밀번호를 입력하세요">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="newPassword" class="form-label">새 비밀번호</label>
|
||||||
|
<input type="password" id="newPassword" class="form-input" placeholder="새 비밀번호를 입력하세요">
|
||||||
|
<span class="form-hint">8자 이상, 영문과 숫자를 포함해주세요</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="confirmPassword" class="form-label">비밀번호 확인</label>
|
||||||
|
<input type="password" id="confirmPassword" class="form-input" placeholder="비밀번호를 다시 입력하세요">
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<section class="mb-2xl">
|
||||||
|
<button id="saveBtn" class="btn btn-primary btn-large btn-full mb-sm">
|
||||||
|
저장하기
|
||||||
|
</button>
|
||||||
|
<button id="logoutBtn" class="btn btn-text btn-large btn-full text-error">
|
||||||
|
로그아웃
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Navigation -->
|
||||||
|
<div id="bottomNav"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="common.js"></script>
|
||||||
|
<script>
|
||||||
|
// 로그인 확인
|
||||||
|
KTEventApp.Session.requireAuth();
|
||||||
|
|
||||||
|
const user = KTEventApp.Session.getUser();
|
||||||
|
|
||||||
|
// Header 생성
|
||||||
|
const header = KTEventApp.Navigation.createHeader({
|
||||||
|
title: '프로필',
|
||||||
|
showBack: true,
|
||||||
|
showMenu: false,
|
||||||
|
showProfile: false
|
||||||
|
});
|
||||||
|
document.getElementById('header').appendChild(header);
|
||||||
|
|
||||||
|
// Bottom Navigation 생성
|
||||||
|
const bottomNav = KTEventApp.Navigation.createBottomNav('profile');
|
||||||
|
document.getElementById('bottomNav').appendChild(bottomNav);
|
||||||
|
|
||||||
|
// 사용자 정보 표시
|
||||||
|
document.getElementById('userName').textContent = user.name;
|
||||||
|
document.getElementById('userEmail').textContent = user.email;
|
||||||
|
document.getElementById('name').value = user.name;
|
||||||
|
document.getElementById('phone').value = user.phone;
|
||||||
|
document.getElementById('email').value = user.email;
|
||||||
|
document.getElementById('businessName').value = user.businessName;
|
||||||
|
document.getElementById('businessLocation').value = user.businessLocation || '서울시 강남구';
|
||||||
|
|
||||||
|
// 전화번호 자동 포맷팅
|
||||||
|
document.getElementById('phone').addEventListener('input', function(e) {
|
||||||
|
e.target.value = KTEventApp.Utils.formatPhoneNumber(e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 저장하기 버튼
|
||||||
|
document.getElementById('saveBtn').addEventListener('click', function() {
|
||||||
|
const currentPassword = document.getElementById('currentPassword').value;
|
||||||
|
const newPassword = document.getElementById('newPassword').value;
|
||||||
|
const confirmPassword = document.getElementById('confirmPassword').value;
|
||||||
|
|
||||||
|
// 비밀번호 변경 검증
|
||||||
|
if (currentPassword || newPassword || confirmPassword) {
|
||||||
|
if (!currentPassword) {
|
||||||
|
KTEventApp.Feedback.showToast('현재 비밀번호를 입력해주세요');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!KTEventApp.Utils.validatePassword(newPassword)) {
|
||||||
|
KTEventApp.Feedback.showToast('새 비밀번호는 8자 이상, 영문과 숫자를 포함해야 합니다');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
KTEventApp.Feedback.showToast('새 비밀번호가 일치하지 않습니다');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자 정보 업데이트
|
||||||
|
const updatedUser = {
|
||||||
|
...user,
|
||||||
|
name: document.getElementById('name').value,
|
||||||
|
phone: document.getElementById('phone').value,
|
||||||
|
email: document.getElementById('email').value,
|
||||||
|
businessName: document.getElementById('businessName').value,
|
||||||
|
businessType: document.getElementById('businessType').value,
|
||||||
|
businessLocation: document.getElementById('businessLocation').value
|
||||||
|
};
|
||||||
|
|
||||||
|
KTEventApp.Session.saveUser(updatedUser);
|
||||||
|
|
||||||
|
KTEventApp.Feedback.showModal({
|
||||||
|
content: `
|
||||||
|
<div class="p-xl text-center">
|
||||||
|
<span class="material-icons" style="font-size: 64px; color: var(--color-success);">check_circle</span>
|
||||||
|
<h3 class="text-headline mt-md mb-sm">저장 완료</h3>
|
||||||
|
<p class="text-body text-secondary">프로필 정보가 업데이트되었습니다.</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: '확인',
|
||||||
|
variant: 'primary',
|
||||||
|
onClick: function() {
|
||||||
|
this.closest('.modal-backdrop').remove();
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 로그아웃 버튼
|
||||||
|
document.getElementById('logoutBtn').addEventListener('click', function() {
|
||||||
|
window.location.href = '04-로그아웃확인.html';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
54
design/uiux/prototype/04-로그아웃확인.html
Normal file
54
design/uiux/prototype/04-로그아웃확인.html
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>로그아웃 확인 - KT AI 이벤트 마케팅</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Modal Backdrop -->
|
||||||
|
<div class="modal-backdrop" id="logoutModal" style="display: flex;">
|
||||||
|
<div class="modal-dialog" style="max-width: 400px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="text-headline">로그아웃</h3>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="text-body text-center">로그아웃 하시겠습니까?</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-text" id="cancelBtn">취소</button>
|
||||||
|
<button class="btn btn-primary" id="confirmBtn">확인</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="common.js"></script>
|
||||||
|
<script>
|
||||||
|
// 취소 버튼 - 이전 페이지로 이동
|
||||||
|
document.getElementById('cancelBtn').addEventListener('click', function() {
|
||||||
|
window.history.back();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 배경 클릭 시 취소와 동일
|
||||||
|
document.getElementById('logoutModal').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) {
|
||||||
|
window.history.back();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 확인 버튼 - 로그아웃 처리
|
||||||
|
document.getElementById('confirmBtn').addEventListener('click', function() {
|
||||||
|
// 세션 종료
|
||||||
|
KTEventApp.Session.logout();
|
||||||
|
|
||||||
|
// 로그인 화면으로 이동 (애니메이션 효과)
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '01-로그인.html';
|
||||||
|
}, 200);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
219
design/uiux/prototype/05-대시보드.html
Normal file
219
design/uiux/prototype/05-대시보드.html
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>대시보드 - KT AI 이벤트 마케팅</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page page-with-header">
|
||||||
|
<!-- Header -->
|
||||||
|
<div id="header"></div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<!-- Welcome Section -->
|
||||||
|
<section class="mt-lg mb-xl">
|
||||||
|
<h2 class="text-title-large mb-sm" id="welcomeMessage">안녕하세요, 사용자님!</h2>
|
||||||
|
<p class="text-body text-secondary">오늘도 성공적인 이벤트를 준비해보세요</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- KPI Cards -->
|
||||||
|
<section class="mb-xl">
|
||||||
|
<div class="grid grid-cols-3 gap-sm tablet:grid-cols-3">
|
||||||
|
<div id="kpiEvents"></div>
|
||||||
|
<div id="kpiParticipants"></div>
|
||||||
|
<div id="kpiROI"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<section class="mb-lg">
|
||||||
|
<div class="flex items-center justify-between mb-md">
|
||||||
|
<h3 class="text-headline">빠른 시작</h3>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-sm">
|
||||||
|
<button class="card card-clickable p-md" id="createEvent">
|
||||||
|
<span class="material-icons text-kt-red" style="font-size: 32px;">add_circle</span>
|
||||||
|
<p class="text-body-small mt-sm">새 이벤트</p>
|
||||||
|
</button>
|
||||||
|
<button class="card card-clickable p-md" id="viewAnalytics">
|
||||||
|
<span class="material-icons text-secondary" style="font-size: 32px;">analytics</span>
|
||||||
|
<p class="text-body-small mt-sm">분석</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Active Events -->
|
||||||
|
<section class="mb-xl">
|
||||||
|
<div class="flex items-center justify-between mb-md">
|
||||||
|
<h3 class="text-headline">진행 중인 이벤트</h3>
|
||||||
|
<a href="06-이벤트목록.html" class="text-body-small text-kt-red">
|
||||||
|
전체보기
|
||||||
|
<span class="material-icons" style="font-size: 16px; vertical-align: middle;">chevron_right</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div id="activeEvents" class="flex flex-col gap-md">
|
||||||
|
<!-- 이벤트 카드가 동적으로 추가됩니다 -->
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Recent Activity -->
|
||||||
|
<section class="mb-2xl">
|
||||||
|
<h3 class="text-headline mb-md">최근 활동</h3>
|
||||||
|
<div class="card">
|
||||||
|
<div id="recentActivity">
|
||||||
|
<!-- 최근 활동 목록 -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Navigation -->
|
||||||
|
<div id="bottomNav"></div>
|
||||||
|
|
||||||
|
<!-- FAB -->
|
||||||
|
<div id="fab"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="common.js"></script>
|
||||||
|
<script>
|
||||||
|
// 로그인 확인
|
||||||
|
KTEventApp.Session.requireAuth();
|
||||||
|
|
||||||
|
const user = KTEventApp.Session.getUser();
|
||||||
|
|
||||||
|
// Header 생성
|
||||||
|
const header = KTEventApp.Navigation.createHeader({
|
||||||
|
title: '대시보드',
|
||||||
|
showBack: false,
|
||||||
|
showMenu: false,
|
||||||
|
showProfile: true
|
||||||
|
});
|
||||||
|
document.getElementById('header').appendChild(header);
|
||||||
|
|
||||||
|
// Welcome 메시지
|
||||||
|
document.getElementById('welcomeMessage').textContent = `안녕하세요, ${user.name}님!`;
|
||||||
|
|
||||||
|
// KPI 데이터 계산
|
||||||
|
const events = KTEventApp.MockData.getEvents();
|
||||||
|
const activeEvents = events.filter(e => e.status === '진행중');
|
||||||
|
const totalParticipants = events.reduce((sum, e) => sum + e.participants, 0);
|
||||||
|
const avgROI = events.length > 0
|
||||||
|
? Math.round(events.reduce((sum, e) => sum + e.roi, 0) / events.length)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// KPI 카드 생성
|
||||||
|
const kpiEventsCard = KTEventApp.Cards.createKPICard({
|
||||||
|
icon: 'celebration',
|
||||||
|
iconType: 'primary',
|
||||||
|
label: '진행 중',
|
||||||
|
value: `${activeEvents.length}개`
|
||||||
|
});
|
||||||
|
document.getElementById('kpiEvents').appendChild(kpiEventsCard);
|
||||||
|
|
||||||
|
const kpiParticipantsCard = KTEventApp.Cards.createKPICard({
|
||||||
|
icon: 'group',
|
||||||
|
iconType: 'success',
|
||||||
|
label: '총 참여자',
|
||||||
|
value: `${KTEventApp.Utils.formatNumber(totalParticipants)}명`
|
||||||
|
});
|
||||||
|
document.getElementById('kpiParticipants').appendChild(kpiParticipantsCard);
|
||||||
|
|
||||||
|
const kpiROICard = KTEventApp.Cards.createKPICard({
|
||||||
|
icon: 'trending_up',
|
||||||
|
iconType: 'ai',
|
||||||
|
label: '평균 ROI',
|
||||||
|
value: `${avgROI}%`
|
||||||
|
});
|
||||||
|
document.getElementById('kpiROI').appendChild(kpiROICard);
|
||||||
|
|
||||||
|
// 진행 중인 이벤트 표시
|
||||||
|
const activeEventsContainer = document.getElementById('activeEvents');
|
||||||
|
|
||||||
|
if (activeEvents.length === 0) {
|
||||||
|
activeEventsContainer.innerHTML = `
|
||||||
|
<div class="card text-center p-xl">
|
||||||
|
<span class="material-icons text-tertiary" style="font-size: 48px;">event_busy</span>
|
||||||
|
<p class="text-body text-secondary mt-md">진행 중인 이벤트가 없습니다</p>
|
||||||
|
<button class="btn btn-primary btn-medium mt-md" onclick="createNewEvent()">
|
||||||
|
<span class="material-icons">add</span>
|
||||||
|
새 이벤트 만들기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
activeEvents.forEach(event => {
|
||||||
|
const card = KTEventApp.Cards.createEventCard({
|
||||||
|
...event,
|
||||||
|
onClick: (id) => {
|
||||||
|
window.location.href = `13-이벤트상세.html?id=${id}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
activeEventsContainer.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최근 활동 표시
|
||||||
|
const recentActivityContainer = document.getElementById('recentActivity');
|
||||||
|
const activities = [
|
||||||
|
{ icon: 'person_add', text: 'SNS 팔로우 이벤트에 새로운 참여자 12명', time: '5분 전' },
|
||||||
|
{ icon: 'edit', text: '설 맞이 할인 이벤트 내용을 수정했습니다', time: '1시간 전' },
|
||||||
|
{ icon: 'check_circle', text: '고객 만족도 조사가 종료되었습니다', time: '3시간 전' }
|
||||||
|
];
|
||||||
|
|
||||||
|
activities.forEach((activity, index) => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'flex items-start gap-md';
|
||||||
|
if (index > 0) item.classList.add('mt-md', 'pt-md', 'border-t');
|
||||||
|
|
||||||
|
item.innerHTML = `
|
||||||
|
<span class="material-icons text-kt-red">${activity.icon}</span>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-body">${activity.text}</p>
|
||||||
|
<p class="text-caption text-tertiary mt-xs">${activity.time}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
recentActivityContainer.appendChild(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bottom Navigation 생성
|
||||||
|
const bottomNav = KTEventApp.Navigation.createBottomNav('home');
|
||||||
|
document.getElementById('bottomNav').appendChild(bottomNav);
|
||||||
|
|
||||||
|
// FAB 생성
|
||||||
|
const fab = KTEventApp.Navigation.createFAB('add', createNewEvent);
|
||||||
|
document.getElementById('fab').appendChild(fab);
|
||||||
|
|
||||||
|
// 빠른 시작 버튼 이벤트
|
||||||
|
document.getElementById('createEvent').addEventListener('click', createNewEvent);
|
||||||
|
|
||||||
|
document.getElementById('viewAnalytics').addEventListener('click', () => {
|
||||||
|
window.location.href = '17-성과분석.html';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 새 이벤트 생성 함수
|
||||||
|
function createNewEvent() {
|
||||||
|
KTEventApp.Feedback.showBottomSheet(`
|
||||||
|
<h3 class="text-headline mb-lg">이벤트 만들기</h3>
|
||||||
|
<div class="flex flex-col gap-sm">
|
||||||
|
<button class="btn btn-primary btn-large btn-full" onclick="window.location.href='07-이벤트목적선택.html'">
|
||||||
|
<span class="material-icons">auto_awesome</span>
|
||||||
|
AI 추천으로 시작하기
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary btn-large btn-full" onclick="KTEventApp.Feedback.showToast('템플릿 기능은 준비 중입니다.')">
|
||||||
|
<span class="material-icons">dashboard</span>
|
||||||
|
템플릿으로 시작하기
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-text btn-large btn-full" onclick="KTEventApp.Feedback.showToast('직접 만들기 기능은 준비 중입니다.')">
|
||||||
|
<span class="material-icons">edit</span>
|
||||||
|
직접 만들기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
323
design/uiux/prototype/06-이벤트목록.html
Normal file
323
design/uiux/prototype/06-이벤트목록.html
Normal file
@ -0,0 +1,323 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>이벤트 목록 - KT AI 이벤트 마케팅</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page page-with-header">
|
||||||
|
<!-- Header -->
|
||||||
|
<div id="header"></div>
|
||||||
|
|
||||||
|
<div class="container" style="max-width: 1200px;">
|
||||||
|
<!-- Search Section -->
|
||||||
|
<section class="mt-lg mb-md">
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="input-with-icon">
|
||||||
|
<span class="material-icons input-icon">search</span>
|
||||||
|
<input type="text" id="searchInput" class="form-input" placeholder="이벤트명 검색...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<section class="mb-md">
|
||||||
|
<div class="flex items-center gap-sm flex-wrap">
|
||||||
|
<span class="material-icons text-kt-red">filter_list</span>
|
||||||
|
<select id="statusFilter" class="form-select" style="flex: 1; min-width: 120px;">
|
||||||
|
<option value="all">전체</option>
|
||||||
|
<option value="active">진행중</option>
|
||||||
|
<option value="scheduled">예정</option>
|
||||||
|
<option value="ended">종료</option>
|
||||||
|
</select>
|
||||||
|
<select id="periodFilter" class="form-select" style="flex: 1; min-width: 140px;">
|
||||||
|
<option value="1month">최근 1개월</option>
|
||||||
|
<option value="3months">최근 3개월</option>
|
||||||
|
<option value="6months">최근 6개월</option>
|
||||||
|
<option value="1year">최근 1년</option>
|
||||||
|
<option value="all">전체</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Sorting -->
|
||||||
|
<section class="mb-md">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-body-small text-secondary">정렬:</p>
|
||||||
|
<select id="sortBy" class="form-select" style="width: 160px;">
|
||||||
|
<option value="latest">최신순</option>
|
||||||
|
<option value="participants">참여자순</option>
|
||||||
|
<option value="roi">투자대비수익률순</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Event List -->
|
||||||
|
<section id="eventList" class="mb-2xl">
|
||||||
|
<!-- Events will be dynamically loaded here -->
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<section class="mb-2xl">
|
||||||
|
<div class="flex items-center justify-center gap-sm" id="pagination">
|
||||||
|
<!-- Pagination will be dynamically loaded here -->
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Navigation -->
|
||||||
|
<div id="bottomNav"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="common.js"></script>
|
||||||
|
<script>
|
||||||
|
// 로그인 확인
|
||||||
|
KTEventApp.Session.requireAuth();
|
||||||
|
|
||||||
|
// Header 생성
|
||||||
|
const header = KTEventApp.Navigation.createHeader({
|
||||||
|
title: '이벤트 목록',
|
||||||
|
showBack: true,
|
||||||
|
showMenu: false,
|
||||||
|
showProfile: true
|
||||||
|
});
|
||||||
|
document.getElementById('header').appendChild(header);
|
||||||
|
|
||||||
|
// Bottom Navigation 생성
|
||||||
|
const bottomNav = KTEventApp.Navigation.createBottomNav('events');
|
||||||
|
document.getElementById('bottomNav').appendChild(bottomNav);
|
||||||
|
|
||||||
|
// Mock 이벤트 데이터
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: '신규고객 유치 이벤트',
|
||||||
|
status: 'active',
|
||||||
|
daysLeft: 5,
|
||||||
|
participants: 128,
|
||||||
|
roi: 450,
|
||||||
|
startDate: '2025-11-01',
|
||||||
|
endDate: '2025-11-15',
|
||||||
|
prize: '커피 쿠폰',
|
||||||
|
method: '전화번호 입력'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: '재방문 유도 이벤트',
|
||||||
|
status: 'active',
|
||||||
|
daysLeft: 12,
|
||||||
|
participants: 56,
|
||||||
|
roi: 320,
|
||||||
|
startDate: '2025-11-05',
|
||||||
|
endDate: '2025-11-20',
|
||||||
|
prize: '할인 쿠폰',
|
||||||
|
method: 'SNS 팔로우'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: '매출증대 프로모션',
|
||||||
|
status: 'ended',
|
||||||
|
daysLeft: 0,
|
||||||
|
participants: 234,
|
||||||
|
roi: 580,
|
||||||
|
startDate: '2025-10-15',
|
||||||
|
endDate: '2025-10-31',
|
||||||
|
prize: '상품권',
|
||||||
|
method: '구매 인증'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
title: '봄맞이 특별 이벤트',
|
||||||
|
status: 'scheduled',
|
||||||
|
daysLeft: 30,
|
||||||
|
participants: 0,
|
||||||
|
roi: 0,
|
||||||
|
startDate: '2025-12-01',
|
||||||
|
endDate: '2025-12-15',
|
||||||
|
prize: '체험권',
|
||||||
|
method: '이메일 등록'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
let currentPage = 1;
|
||||||
|
const itemsPerPage = 20;
|
||||||
|
let filteredEvents = [...events];
|
||||||
|
|
||||||
|
// 이벤트 카드 생성
|
||||||
|
function createEventCard(event) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'event-card';
|
||||||
|
card.style.cursor = 'pointer';
|
||||||
|
|
||||||
|
const statusBadge = event.status === 'active' ? 'badge-active' :
|
||||||
|
event.status === 'scheduled' ? 'badge-scheduled' : 'badge-inactive';
|
||||||
|
|
||||||
|
const statusText = event.status === 'active' ? '진행중' :
|
||||||
|
event.status === 'scheduled' ? '예정' : '종료';
|
||||||
|
|
||||||
|
const daysInfo = event.status === 'active' ? `D-${event.daysLeft}` :
|
||||||
|
event.status === 'scheduled' ? `D+${event.daysLeft}` : '';
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="flex items-start justify-between mb-sm">
|
||||||
|
<h3 class="text-headline">${event.title}</h3>
|
||||||
|
<span class="event-card-badge ${statusBadge}">${statusText} ${daysInfo ? '| ' + daysInfo : ''}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="event-card-stats mb-sm">
|
||||||
|
<div class="event-card-stat">
|
||||||
|
<span class="event-card-stat-label">참여</span>
|
||||||
|
<span class="event-card-stat-value">${event.participants}명</span>
|
||||||
|
</div>
|
||||||
|
<div class="event-card-stat">
|
||||||
|
<span class="event-card-stat-label">투자대비수익률</span>
|
||||||
|
<span class="event-card-stat-value text-kt-red">${event.roi}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-body-small text-secondary">
|
||||||
|
${event.startDate} ~ ${event.endDate}
|
||||||
|
</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
card.addEventListener('click', () => {
|
||||||
|
window.location.href = `13-이벤트상세.html?id=${event.id}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이벤트 목록 렌더링
|
||||||
|
function renderEventList() {
|
||||||
|
const eventList = document.getElementById('eventList');
|
||||||
|
eventList.innerHTML = '';
|
||||||
|
|
||||||
|
if (filteredEvents.length === 0) {
|
||||||
|
eventList.innerHTML = `
|
||||||
|
<div class="text-center py-2xl">
|
||||||
|
<span class="material-icons" style="font-size: 64px; color: var(--color-gray-300);">event_busy</span>
|
||||||
|
<p class="text-body text-secondary mt-md">검색 결과가 없습니다</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||||
|
const endIndex = Math.min(startIndex + itemsPerPage, filteredEvents.length);
|
||||||
|
const pageEvents = filteredEvents.slice(startIndex, endIndex);
|
||||||
|
|
||||||
|
pageEvents.forEach(event => {
|
||||||
|
eventList.appendChild(createEventCard(event));
|
||||||
|
});
|
||||||
|
|
||||||
|
renderPagination();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 페이지네이션 렌더링
|
||||||
|
function renderPagination() {
|
||||||
|
const pagination = document.getElementById('pagination');
|
||||||
|
pagination.innerHTML = '';
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(filteredEvents.length / itemsPerPage);
|
||||||
|
|
||||||
|
if (totalPages <= 1) return;
|
||||||
|
|
||||||
|
// 이전 버튼
|
||||||
|
const prevBtn = document.createElement('button');
|
||||||
|
prevBtn.className = 'btn btn-text btn-small';
|
||||||
|
prevBtn.innerHTML = '<span class="material-icons">chevron_left</span>';
|
||||||
|
prevBtn.disabled = currentPage === 1;
|
||||||
|
prevBtn.addEventListener('click', () => {
|
||||||
|
if (currentPage > 1) {
|
||||||
|
currentPage--;
|
||||||
|
renderEventList();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
pagination.appendChild(prevBtn);
|
||||||
|
|
||||||
|
// 페이지 번호
|
||||||
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
|
if (i === 1 || i === totalPages || (i >= currentPage - 1 && i <= currentPage + 1)) {
|
||||||
|
const pageBtn = document.createElement('button');
|
||||||
|
pageBtn.className = `btn ${i === currentPage ? 'btn-primary' : 'btn-text'} btn-small`;
|
||||||
|
pageBtn.textContent = i;
|
||||||
|
pageBtn.addEventListener('click', () => {
|
||||||
|
currentPage = i;
|
||||||
|
renderEventList();
|
||||||
|
});
|
||||||
|
pagination.appendChild(pageBtn);
|
||||||
|
} else if (i === currentPage - 2 || i === currentPage + 2) {
|
||||||
|
const ellipsis = document.createElement('span');
|
||||||
|
ellipsis.textContent = '...';
|
||||||
|
ellipsis.className = 'text-secondary px-sm';
|
||||||
|
pagination.appendChild(ellipsis);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다음 버튼
|
||||||
|
const nextBtn = document.createElement('button');
|
||||||
|
nextBtn.className = 'btn btn-text btn-small';
|
||||||
|
nextBtn.innerHTML = '<span class="material-icons">chevron_right</span>';
|
||||||
|
nextBtn.disabled = currentPage === totalPages;
|
||||||
|
nextBtn.addEventListener('click', () => {
|
||||||
|
if (currentPage < totalPages) {
|
||||||
|
currentPage++;
|
||||||
|
renderEventList();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
pagination.appendChild(nextBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 필터 적용
|
||||||
|
function applyFilters() {
|
||||||
|
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
|
||||||
|
const statusFilter = document.getElementById('statusFilter').value;
|
||||||
|
const periodFilter = document.getElementById('periodFilter').value;
|
||||||
|
const sortBy = document.getElementById('sortBy').value;
|
||||||
|
|
||||||
|
// 필터링
|
||||||
|
filteredEvents = events.filter(event => {
|
||||||
|
const matchesSearch = event.title.toLowerCase().includes(searchTerm);
|
||||||
|
const matchesStatus = statusFilter === 'all' || event.status === statusFilter;
|
||||||
|
|
||||||
|
// 기간 필터 (간단한 예시)
|
||||||
|
let matchesPeriod = true;
|
||||||
|
if (periodFilter !== 'all') {
|
||||||
|
// 실제로는 날짜 비교 로직 필요
|
||||||
|
matchesPeriod = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchesSearch && matchesStatus && matchesPeriod;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 정렬
|
||||||
|
filteredEvents.sort((a, b) => {
|
||||||
|
if (sortBy === 'latest') {
|
||||||
|
return new Date(b.startDate) - new Date(a.startDate);
|
||||||
|
} else if (sortBy === 'participants') {
|
||||||
|
return b.participants - a.participants;
|
||||||
|
} else if (sortBy === 'roi') {
|
||||||
|
return b.roi - a.roi;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
currentPage = 1;
|
||||||
|
renderEventList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이벤트 리스너
|
||||||
|
document.getElementById('searchInput').addEventListener('input', KTEventApp.Utils.debounce(applyFilters, 300));
|
||||||
|
document.getElementById('statusFilter').addEventListener('change', applyFilters);
|
||||||
|
document.getElementById('periodFilter').addEventListener('change', applyFilters);
|
||||||
|
document.getElementById('sortBy').addEventListener('change', applyFilters);
|
||||||
|
|
||||||
|
// 초기 렌더링
|
||||||
|
renderEventList();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
216
design/uiux/prototype/07-이벤트목적선택.html
Normal file
216
design/uiux/prototype/07-이벤트목적선택.html
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>이벤트 목적 선택 - KT AI 이벤트 마케팅</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page page-with-header">
|
||||||
|
<!-- Header -->
|
||||||
|
<div id="header"></div>
|
||||||
|
|
||||||
|
<div class="container" style="max-width: 600px;">
|
||||||
|
<!-- Title Section -->
|
||||||
|
<section class="mt-lg mb-xl text-center">
|
||||||
|
<div class="mb-md">
|
||||||
|
<span class="material-icons" style="font-size: 64px; color: var(--color-ai-blue);">auto_awesome</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-title-large mb-sm">이벤트 목적을 선택해주세요</h2>
|
||||||
|
<p class="text-body text-secondary">AI가 목적에 맞는 최적의 이벤트를 추천해드립니다</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Purpose Options -->
|
||||||
|
<section class="mb-xl">
|
||||||
|
<div id="purposeOptions" class="grid gap-md tablet:grid-cols-2">
|
||||||
|
<!-- 옵션 카드가 동적으로 추가됩니다 -->
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<section class="mb-2xl">
|
||||||
|
<button id="nextBtn" class="btn btn-primary btn-large btn-full" disabled>
|
||||||
|
다음
|
||||||
|
</button>
|
||||||
|
<button id="skipBtn" class="btn btn-text btn-large btn-full mt-sm">
|
||||||
|
건너뛰기
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Info Box -->
|
||||||
|
<section class="mb-2xl">
|
||||||
|
<div class="card" style="background: rgba(0, 102, 255, 0.05); border: 1px solid rgba(0, 102, 255, 0.2);">
|
||||||
|
<div class="flex items-start gap-md">
|
||||||
|
<span class="material-icons text-ai-blue">info</span>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-body-small text-secondary">
|
||||||
|
선택하신 목적에 따라 AI가 업종, 지역, 계절 트렌드를 분석하여 가장 효과적인 이벤트를 추천합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Navigation -->
|
||||||
|
<div id="bottomNav"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="common.js"></script>
|
||||||
|
<script>
|
||||||
|
// 로그인 확인
|
||||||
|
KTEventApp.Session.requireAuth();
|
||||||
|
|
||||||
|
const user = KTEventApp.Session.getUser();
|
||||||
|
|
||||||
|
// Header 생성
|
||||||
|
const header = KTEventApp.Navigation.createHeader({
|
||||||
|
title: '이벤트 목적 선택',
|
||||||
|
showBack: true,
|
||||||
|
showMenu: false,
|
||||||
|
showProfile: false
|
||||||
|
});
|
||||||
|
document.getElementById('header').appendChild(header);
|
||||||
|
|
||||||
|
// Bottom Navigation 생성
|
||||||
|
const bottomNav = KTEventApp.Navigation.createBottomNav('events');
|
||||||
|
document.getElementById('bottomNav').appendChild(bottomNav);
|
||||||
|
|
||||||
|
// 이벤트 목적 옵션 데이터
|
||||||
|
const purposes = KTEventApp.MockData.getEventPurposes();
|
||||||
|
|
||||||
|
// 선택된 목적 저장
|
||||||
|
let selectedPurpose = null;
|
||||||
|
|
||||||
|
// 옵션 카드 생성
|
||||||
|
const purposeOptionsContainer = document.getElementById('purposeOptions');
|
||||||
|
|
||||||
|
purposes.forEach(purpose => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'card option-card';
|
||||||
|
card.setAttribute('data-purpose-id', purpose.id);
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="option-card-radio">
|
||||||
|
<input type="radio" name="purpose" value="${purpose.id}">
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-md mb-md">
|
||||||
|
<span class="material-icons text-kt-red" style="font-size: 40px;">${purpose.icon}</span>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="text-headline mb-xs">${purpose.title}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-body-small text-secondary">${purpose.description}</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
card.addEventListener('click', () => {
|
||||||
|
// 모든 카드에서 selected 클래스 제거
|
||||||
|
document.querySelectorAll('.option-card').forEach(c => {
|
||||||
|
c.classList.remove('selected');
|
||||||
|
c.querySelector('input[type="radio"]').checked = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 선택된 카드에 selected 클래스 추가
|
||||||
|
card.classList.add('selected');
|
||||||
|
card.querySelector('input[type="radio"]').checked = true;
|
||||||
|
|
||||||
|
// 선택된 목적 저장
|
||||||
|
selectedPurpose = purpose.id;
|
||||||
|
|
||||||
|
// 다음 버튼 활성화
|
||||||
|
document.getElementById('nextBtn').disabled = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
purposeOptionsContainer.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 다음 버튼 클릭
|
||||||
|
document.getElementById('nextBtn').addEventListener('click', () => {
|
||||||
|
if (selectedPurpose) {
|
||||||
|
// 선택한 목적 저장
|
||||||
|
KTEventApp.Utils.saveToStorage('selected_purpose', selectedPurpose);
|
||||||
|
|
||||||
|
// AI 로딩 및 추천 페이지로 이동
|
||||||
|
showLoadingAndNavigate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 건너뛰기 버튼 클릭
|
||||||
|
document.getElementById('skipBtn').addEventListener('click', () => {
|
||||||
|
KTEventApp.Feedback.showModal({
|
||||||
|
title: '건너뛰기',
|
||||||
|
content: '<p class="text-body">목적을 선택하지 않으면 일반적인 추천을 받게 됩니다. 계속하시겠습니까?</p>',
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: '취소',
|
||||||
|
variant: 'text',
|
||||||
|
onClick: function() {
|
||||||
|
this.closest('.modal-backdrop').remove();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '확인',
|
||||||
|
variant: 'primary',
|
||||||
|
onClick: function() {
|
||||||
|
this.closest('.modal-backdrop').remove();
|
||||||
|
KTEventApp.Utils.saveToStorage('selected_purpose', 'general');
|
||||||
|
showLoadingAndNavigate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// AI 로딩 표시 및 페이지 이동
|
||||||
|
function showLoadingAndNavigate() {
|
||||||
|
const loadingModal = KTEventApp.Feedback.showModal({
|
||||||
|
content: `
|
||||||
|
<div class="p-xl text-center">
|
||||||
|
<div class="spinner mx-auto mb-lg"></div>
|
||||||
|
<h3 class="text-headline mb-sm">AI가 분석 중입니다</h3>
|
||||||
|
<p class="text-body text-secondary mb-md">
|
||||||
|
업종, 지역, 계절 트렌드를 분석하여<br>
|
||||||
|
최적의 이벤트를 찾고 있습니다
|
||||||
|
</p>
|
||||||
|
<div class="progress mt-lg">
|
||||||
|
<div id="loadingProgress" class="progress-bar" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
<p class="text-caption text-tertiary mt-sm" id="loadingStatus">트렌드 분석 중...</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
});
|
||||||
|
|
||||||
|
// 프로그레스 바 애니메이션
|
||||||
|
const statusMessages = [
|
||||||
|
'트렌드 분석 중...',
|
||||||
|
'경쟁사 이벤트 분석 중...',
|
||||||
|
'고객 선호도 분석 중...',
|
||||||
|
'최적 이벤트 생성 중...'
|
||||||
|
];
|
||||||
|
|
||||||
|
let progress = 0;
|
||||||
|
let messageIndex = 0;
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
progress += 25;
|
||||||
|
document.getElementById('loadingProgress').style.width = `${progress}%`;
|
||||||
|
|
||||||
|
if (messageIndex < statusMessages.length) {
|
||||||
|
document.getElementById('loadingStatus').textContent = statusMessages[messageIndex];
|
||||||
|
messageIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progress >= 100) {
|
||||||
|
clearInterval(interval);
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '08-AI이벤트추천.html';
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
473
design/uiux/prototype/08-AI이벤트추천.html
Normal file
473
design/uiux/prototype/08-AI이벤트추천.html
Normal file
@ -0,0 +1,473 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>AI 이벤트 추천 - KT AI 이벤트 마케팅</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
|
||||||
|
<style>
|
||||||
|
.editable-field {
|
||||||
|
border: 1px dashed var(--color-gray-300);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: text;
|
||||||
|
transition: all var(--duration-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
.editable-field:hover {
|
||||||
|
border-color: var(--color-kt-red);
|
||||||
|
background-color: var(--color-gray-50);
|
||||||
|
}
|
||||||
|
.editable-field:focus {
|
||||||
|
outline: none;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: var(--color-kt-red);
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
}
|
||||||
|
.budget-section {
|
||||||
|
scroll-margin-top: 72px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page page-with-header">
|
||||||
|
<!-- Header -->
|
||||||
|
<div id="header"></div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<!-- Trends Section -->
|
||||||
|
<section class="mt-lg mb-xl">
|
||||||
|
<h2 class="text-headline mb-md flex items-center gap-sm">
|
||||||
|
<span class="material-icons text-ai-blue">insights</span>
|
||||||
|
AI 트렌드 분석
|
||||||
|
</h2>
|
||||||
|
<div class="card">
|
||||||
|
<div class="mb-md">
|
||||||
|
<div class="flex items-center gap-sm mb-xs">
|
||||||
|
<span class="material-icons text-kt-red" style="font-size: 20px;">store</span>
|
||||||
|
<span class="text-body-small text-bold">업종 트렌드</span>
|
||||||
|
</div>
|
||||||
|
<p id="industryTrend" class="text-body text-secondary pl-lg">음식점업 신년 프로모션 트렌드</p>
|
||||||
|
</div>
|
||||||
|
<div class="mb-md">
|
||||||
|
<div class="flex items-center gap-sm mb-xs">
|
||||||
|
<span class="material-icons text-kt-red" style="font-size: 20px;">location_on</span>
|
||||||
|
<span class="text-body-small text-bold">지역 트렌드</span>
|
||||||
|
</div>
|
||||||
|
<p id="locationTrend" class="text-body text-secondary pl-lg">강남구 음식점 할인 이벤트 증가</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-sm mb-xs">
|
||||||
|
<span class="material-icons text-kt-red" style="font-size: 20px;">wb_sunny</span>
|
||||||
|
<span class="text-body-small text-bold">시즌 트렌드</span>
|
||||||
|
</div>
|
||||||
|
<p id="seasonTrend" class="text-body text-secondary pl-lg">설 연휴 특수 대비 고객 유치 전략</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Recommendations Title -->
|
||||||
|
<section class="mb-lg">
|
||||||
|
<h2 class="text-headline mb-sm">예산별 추천 이벤트</h2>
|
||||||
|
<p class="text-body-small text-secondary">
|
||||||
|
각 예산별 2가지 방식 (온라인 1개, 오프라인 1개)을 추천합니다
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Budget Options Navigation -->
|
||||||
|
<section class="mb-lg" style="position: sticky; top: 56px; background: var(--color-bg-secondary); z-index: 10; padding: var(--spacing-md) 0;">
|
||||||
|
<div class="flex gap-sm">
|
||||||
|
<button class="btn btn-medium flex-1 budget-nav active" data-budget="low">
|
||||||
|
💰 저비용
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-medium flex-1 budget-nav" data-budget="medium">
|
||||||
|
💰💰 중비용
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-medium flex-1 budget-nav" data-budget="high">
|
||||||
|
💰💰💰 고비용
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 저비용 옵션 -->
|
||||||
|
<section id="budget-low" class="budget-section mb-2xl">
|
||||||
|
<h3 class="text-title mb-md">💰 옵션 1: 저비용 (25~30만원)</h3>
|
||||||
|
<div class="grid gap-md tablet:grid-cols-2">
|
||||||
|
<div class="card option-card" data-recommendation-id="low-online">
|
||||||
|
<div class="option-card-radio">
|
||||||
|
<input type="radio" name="recommendation" value="low-online">
|
||||||
|
</div>
|
||||||
|
<div class="mb-md">
|
||||||
|
<span class="badge-active event-card-badge mb-sm">🌐 온라인 방식</span>
|
||||||
|
<h4 class="text-headline mb-xs">
|
||||||
|
<span class="editable-field" contenteditable="true">SNS 팔로우 이벤트</span>
|
||||||
|
<span class="material-icons text-tertiary" style="font-size: 16px; vertical-align: middle;">edit</span>
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div class="text-body-small mb-sm">
|
||||||
|
<span class="text-secondary">경품:</span>
|
||||||
|
<span class="editable-field" contenteditable="true">커피 쿠폰</span>
|
||||||
|
<span class="material-icons text-tertiary" style="font-size: 16px; vertical-align: middle;">edit</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-sm text-body-small">
|
||||||
|
<div>
|
||||||
|
<span class="text-tertiary">참여 방법:</span>
|
||||||
|
<span class="text-semibold">SNS 팔로우</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-tertiary">예상 참여:</span>
|
||||||
|
<span class="text-semibold">180명</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-tertiary">예상 비용:</span>
|
||||||
|
<span class="text-semibold">25만원</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-tertiary">투자대비수익률:</span>
|
||||||
|
<span class="text-kt-red text-bold">520%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card option-card" data-recommendation-id="low-offline">
|
||||||
|
<div class="option-card-radio">
|
||||||
|
<input type="radio" name="recommendation" value="low-offline">
|
||||||
|
</div>
|
||||||
|
<div class="mb-md">
|
||||||
|
<span class="badge-scheduled event-card-badge mb-sm">🏪 오프라인 방식</span>
|
||||||
|
<h4 class="text-headline mb-xs">
|
||||||
|
<span class="editable-field" contenteditable="true">전화번호 등록 이벤트</span>
|
||||||
|
<span class="material-icons text-tertiary" style="font-size: 16px; vertical-align: middle;">edit</span>
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div class="text-body-small mb-sm">
|
||||||
|
<span class="text-secondary">경품:</span>
|
||||||
|
<span class="editable-field" contenteditable="true">커피 쿠폰</span>
|
||||||
|
<span class="material-icons text-tertiary" style="font-size: 16px; vertical-align: middle;">edit</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-sm text-body-small">
|
||||||
|
<div>
|
||||||
|
<span class="text-tertiary">참여 방법:</span>
|
||||||
|
<span class="text-semibold">전화번호 등록</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-tertiary">예상 참여:</span>
|
||||||
|
<span class="text-semibold">150명</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-tertiary">예상 비용:</span>
|
||||||
|
<span class="text-semibold">30만원</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-tertiary">투자대비수익률:</span>
|
||||||
|
<span class="text-kt-red text-bold">450%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 중비용 옵션 -->
|
||||||
|
<section id="budget-medium" class="budget-section mb-2xl">
|
||||||
|
<h3 class="text-title mb-md">💰💰 옵션 2: 중비용 (150~180만원)</h3>
|
||||||
|
<div class="grid gap-md tablet:grid-cols-2">
|
||||||
|
<div class="card option-card" data-recommendation-id="medium-online">
|
||||||
|
<div class="option-card-radio">
|
||||||
|
<input type="radio" name="recommendation" value="medium-online">
|
||||||
|
</div>
|
||||||
|
<div class="mb-md">
|
||||||
|
<span class="badge-active event-card-badge mb-sm">🌐 온라인 방식</span>
|
||||||
|
<h4 class="text-headline mb-xs">
|
||||||
|
<span class="editable-field" contenteditable="true">리뷰 작성 이벤트</span>
|
||||||
|
<span class="material-icons text-tertiary" style="font-size: 16px; vertical-align: middle;">edit</span>
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div class="text-body-small mb-sm">
|
||||||
|
<span class="text-secondary">경품:</span>
|
||||||
|
<span class="editable-field" contenteditable="true">5천원 상품권</span>
|
||||||
|
<span class="material-icons text-tertiary" style="font-size: 16px; vertical-align: middle;">edit</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-sm text-body-small">
|
||||||
|
<div>
|
||||||
|
<span class="text-tertiary">참여 방법:</span>
|
||||||
|
<span class="text-semibold">리뷰 작성</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-tertiary">예상 참여:</span>
|
||||||
|
<span class="text-semibold">250명</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-tertiary">예상 비용:</span>
|
||||||
|
<span class="text-semibold">150만원</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-tertiary">투자대비수익률:</span>
|
||||||
|
<span class="text-kt-red text-bold">380%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card option-card" data-recommendation-id="medium-offline">
|
||||||
|
<div class="option-card-radio">
|
||||||
|
<input type="radio" name="recommendation" value="medium-offline">
|
||||||
|
</div>
|
||||||
|
<div class="mb-md">
|
||||||
|
<span class="badge-scheduled event-card-badge mb-sm">🏪 오프라인 방식</span>
|
||||||
|
<h4 class="text-headline mb-xs">
|
||||||
|
<span class="editable-field" contenteditable="true">방문 도장 적립 이벤트</span>
|
||||||
|
<span class="material-icons text-tertiary" style="font-size: 16px; vertical-align: middle;">edit</span>
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div class="text-body-small mb-sm">
|
||||||
|
<span class="text-secondary">경품:</span>
|
||||||
|
<span class="editable-field" contenteditable="true">무료 식사권</span>
|
||||||
|
<span class="material-icons text-tertiary" style="font-size: 16px; vertical-align: middle;">edit</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-sm text-body-small">
|
||||||
|
<div>
|
||||||
|
<span class="text-tertiary">참여 방법:</span>
|
||||||
|
<span class="text-semibold">방문 도장 적립</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-tertiary">예상 참여:</span>
|
||||||
|
<span class="text-semibold">200명</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-tertiary">예상 비용:</span>
|
||||||
|
<span class="text-semibold">180만원</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-tertiary">투자대비수익률:</span>
|
||||||
|
<span class="text-kt-red text-bold">320%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 고비용 옵션 -->
|
||||||
|
<section id="budget-high" class="budget-section mb-2xl">
|
||||||
|
<h3 class="text-title mb-md">💰💰💰 옵션 3: 고비용 (500~600만원)</h3>
|
||||||
|
<div class="grid gap-md tablet:grid-cols-2">
|
||||||
|
<div class="card option-card" data-recommendation-id="high-online">
|
||||||
|
<div class="option-card-radio">
|
||||||
|
<input type="radio" name="recommendation" value="high-online">
|
||||||
|
</div>
|
||||||
|
<div class="mb-md">
|
||||||
|
<span class="badge-active event-card-badge mb-sm">🌐 온라인 방식</span>
|
||||||
|
<h4 class="text-headline mb-xs">
|
||||||
|
<span class="editable-field" contenteditable="true">인플루언서 협업 이벤트</span>
|
||||||
|
<span class="material-icons text-tertiary" style="font-size: 16px; vertical-align: middle;">edit</span>
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div class="text-body-small mb-sm">
|
||||||
|
<span class="text-secondary">경품:</span>
|
||||||
|
<span class="editable-field" contenteditable="true">1만원 할인권</span>
|
||||||
|
<span class="material-icons text-tertiary" style="font-size: 16px; vertical-align: middle;">edit</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-sm text-body-small">
|
||||||
|
<div>
|
||||||
|
<span class="text-tertiary">참여 방법:</span>
|
||||||
|
<span class="text-semibold">인플루언서 팔로우</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-tertiary">예상 참여:</span>
|
||||||
|
<span class="text-semibold">400명</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-tertiary">예상 비용:</span>
|
||||||
|
<span class="text-semibold">500만원</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-tertiary">투자대비수익률:</span>
|
||||||
|
<span class="text-kt-red text-bold">280%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card option-card" data-recommendation-id="high-offline">
|
||||||
|
<div class="option-card-radio">
|
||||||
|
<input type="radio" name="recommendation" value="high-offline">
|
||||||
|
</div>
|
||||||
|
<div class="mb-md">
|
||||||
|
<span class="badge-scheduled event-card-badge mb-sm">🏪 오프라인 방식</span>
|
||||||
|
<h4 class="text-headline mb-xs">
|
||||||
|
<span class="editable-field" contenteditable="true">VIP 고객 초대 이벤트</span>
|
||||||
|
<span class="material-icons text-tertiary" style="font-size: 16px; vertical-align: middle;">edit</span>
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div class="text-body-small mb-sm">
|
||||||
|
<span class="text-secondary">경품:</span>
|
||||||
|
<span class="editable-field" contenteditable="true">특별 메뉴 제공</span>
|
||||||
|
<span class="material-icons text-tertiary" style="font-size: 16px; vertical-align: middle;">edit</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-sm text-body-small">
|
||||||
|
<div>
|
||||||
|
<span class="text-tertiary">참여 방법:</span>
|
||||||
|
<span class="text-semibold">VIP 초대장</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-tertiary">예상 참여:</span>
|
||||||
|
<span class="text-semibold">300명</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-tertiary">예상 비용:</span>
|
||||||
|
<span class="text-semibold">600만원</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-tertiary">투자대비수익률:</span>
|
||||||
|
<span class="text-kt-red text-bold">240%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<section class="mb-2xl">
|
||||||
|
<button id="nextBtn" class="btn btn-primary btn-large btn-full" disabled>
|
||||||
|
선택한 이벤트로 계속
|
||||||
|
</button>
|
||||||
|
<button id="regenerateBtn" class="btn btn-secondary btn-large btn-full mt-sm">
|
||||||
|
<span class="material-icons">refresh</span>
|
||||||
|
다시 추천받기
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Navigation -->
|
||||||
|
<div id="bottomNav"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="common.js"></script>
|
||||||
|
<script>
|
||||||
|
// 로그인 확인
|
||||||
|
KTEventApp.Session.requireAuth();
|
||||||
|
|
||||||
|
// Header 생성
|
||||||
|
const header = KTEventApp.Navigation.createHeader({
|
||||||
|
title: 'AI 이벤트 추천',
|
||||||
|
showBack: true,
|
||||||
|
showMenu: false,
|
||||||
|
showProfile: false
|
||||||
|
});
|
||||||
|
document.getElementById('header').appendChild(header);
|
||||||
|
|
||||||
|
// Bottom Navigation 생성
|
||||||
|
const bottomNav = KTEventApp.Navigation.createBottomNav('events');
|
||||||
|
document.getElementById('bottomNav').appendChild(bottomNav);
|
||||||
|
|
||||||
|
// 선택된 추천 저장
|
||||||
|
let selectedRecommendation = null;
|
||||||
|
|
||||||
|
// 옵션 카드 클릭 이벤트
|
||||||
|
document.querySelectorAll('.option-card').forEach(card => {
|
||||||
|
card.addEventListener('click', () => {
|
||||||
|
// 모든 카드에서 selected 클래스 제거
|
||||||
|
document.querySelectorAll('.option-card').forEach(c => {
|
||||||
|
c.classList.remove('selected');
|
||||||
|
c.querySelector('input[type="radio"]').checked = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 선택된 카드에 selected 클래스 추가
|
||||||
|
card.classList.add('selected');
|
||||||
|
card.querySelector('input[type="radio"]').checked = true;
|
||||||
|
|
||||||
|
// 선택된 추천 저장
|
||||||
|
selectedRecommendation = card.getAttribute('data-recommendation-id');
|
||||||
|
|
||||||
|
// 다음 버튼 활성화
|
||||||
|
document.getElementById('nextBtn').disabled = false;
|
||||||
|
|
||||||
|
// Toast 표시
|
||||||
|
KTEventApp.Feedback.showToast('이벤트가 선택되었습니다');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 예산 옵션 네비게이션
|
||||||
|
document.querySelectorAll('.budget-nav').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
const budget = this.getAttribute('data-budget');
|
||||||
|
|
||||||
|
// 버튼 활성화 상태 변경
|
||||||
|
document.querySelectorAll('.budget-nav').forEach(b => {
|
||||||
|
b.classList.remove('active');
|
||||||
|
b.classList.add('btn-secondary');
|
||||||
|
b.classList.remove('btn-primary');
|
||||||
|
});
|
||||||
|
this.classList.add('active');
|
||||||
|
this.classList.add('btn-primary');
|
||||||
|
this.classList.remove('btn-secondary');
|
||||||
|
|
||||||
|
// 해당 섹션으로 스크롤
|
||||||
|
document.getElementById(`budget-${budget}`).scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 다음 버튼 클릭
|
||||||
|
document.getElementById('nextBtn').addEventListener('click', () => {
|
||||||
|
if (selectedRecommendation) {
|
||||||
|
// 선택한 추천 데이터 저장
|
||||||
|
const selectedCard = document.querySelector(`[data-recommendation-id="${selectedRecommendation}"]`);
|
||||||
|
const title = selectedCard.querySelector('.editable-field').textContent.trim();
|
||||||
|
const prize = selectedCard.querySelectorAll('.editable-field')[1].textContent.trim();
|
||||||
|
|
||||||
|
const recommendationData = {
|
||||||
|
id: selectedRecommendation,
|
||||||
|
title: title,
|
||||||
|
prize: prize,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
KTEventApp.Utils.saveToStorage('selected_recommendation', recommendationData);
|
||||||
|
|
||||||
|
// SNS 이미지 생성 페이지로 이동
|
||||||
|
window.location.href = '09-콘텐츠미리보기.html';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 다시 추천받기 버튼
|
||||||
|
document.getElementById('regenerateBtn').addEventListener('click', () => {
|
||||||
|
KTEventApp.Feedback.showModal({
|
||||||
|
title: '다시 추천받기',
|
||||||
|
content: '<p class="text-body">새로운 추천을 받으시겠습니까? 현재 선택한 내용은 초기화됩니다.</p>',
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: '취소',
|
||||||
|
variant: 'text',
|
||||||
|
onClick: function() {
|
||||||
|
this.closest('.modal-backdrop').remove();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '확인',
|
||||||
|
variant: 'primary',
|
||||||
|
onClick: function() {
|
||||||
|
this.closest('.modal-backdrop').remove();
|
||||||
|
window.location.href = '07-이벤트목적선택.html';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 편집 가능 필드 엔터 키 방지
|
||||||
|
document.querySelectorAll('.editable-field').forEach(field => {
|
||||||
|
field.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
field.blur();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
field.addEventListener('blur', () => {
|
||||||
|
KTEventApp.Feedback.showToast('수정되었습니다', 1500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
447
design/uiux/prototype/09-콘텐츠미리보기.html
Normal file
447
design/uiux/prototype/09-콘텐츠미리보기.html
Normal file
@ -0,0 +1,447 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>SNS 이미지 생성 - KT AI 이벤트 마케팅</title>
|
||||||
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://fonts.googleapis.com/icon?family=Material+Icons"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"
|
||||||
|
/>
|
||||||
|
<style>
|
||||||
|
.image-preview {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
background: var(--color-gray-100);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.image-preview img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.image-preview-placeholder {
|
||||||
|
color: var(--color-gray-400);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.style-card {
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border: 3px solid transparent;
|
||||||
|
}
|
||||||
|
.style-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.style-card.selected {
|
||||||
|
border-color: var(--color-kt-red);
|
||||||
|
box-shadow: 0 4px 12px rgba(227, 30, 36, 0.2);
|
||||||
|
}
|
||||||
|
.style-card .form-check-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.selected-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--spacing-md);
|
||||||
|
right: var(--spacing-md);
|
||||||
|
background: var(--color-kt-red);
|
||||||
|
color: white;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.style-card.selected .selected-badge {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.fullscreen-modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.95);
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 9999;
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
.fullscreen-modal.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.fullscreen-image {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.fullscreen-close {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--spacing-lg);
|
||||||
|
right: var(--spacing-lg);
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page page-with-header">
|
||||||
|
<!-- Header -->
|
||||||
|
<div id="header"></div>
|
||||||
|
|
||||||
|
<div class="container" style="max-width: 600px">
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div id="loadingState" class="text-center mt-2xl mb-2xl">
|
||||||
|
<span
|
||||||
|
class="material-icons"
|
||||||
|
style="
|
||||||
|
font-size: 64px;
|
||||||
|
color: var(--color-ai-blue);
|
||||||
|
animation: spin 2s linear infinite;
|
||||||
|
"
|
||||||
|
>psychology</span
|
||||||
|
>
|
||||||
|
<h3 class="text-headline mt-md mb-sm">AI 이미지 생성 중</h3>
|
||||||
|
<p class="text-body text-secondary mb-lg">
|
||||||
|
딥러닝 모델이 이벤트에 어울리는<br />
|
||||||
|
이미지를 생성하고 있어요...
|
||||||
|
</p>
|
||||||
|
<p class="text-caption text-tertiary mt-md">예상 시간: 5초</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Generated Images (hidden initially) -->
|
||||||
|
<div id="generatedImages" class="hidden">
|
||||||
|
<!-- Style 1: 심플 -->
|
||||||
|
<section class="mb-lg">
|
||||||
|
<h3 class="text-headline mb-md">스타일 1: 심플</h3>
|
||||||
|
<div class="card style-card" data-style="simple">
|
||||||
|
<div class="selected-badge">
|
||||||
|
<span class="material-icons" style="font-size: 20px">check</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="imageStyle"
|
||||||
|
id="style1"
|
||||||
|
value="simple"
|
||||||
|
class="form-check-input"
|
||||||
|
/>
|
||||||
|
<div class="image-preview mb-md">
|
||||||
|
<div class="image-preview-placeholder">
|
||||||
|
<span class="material-icons" style="font-size: 48px"
|
||||||
|
>celebration</span
|
||||||
|
>
|
||||||
|
<p class="text-body-small mt-sm" id="style1Title">
|
||||||
|
SNS 팔로우 이벤트
|
||||||
|
</p>
|
||||||
|
<p class="text-caption text-secondary" id="style1Prize">
|
||||||
|
커피 쿠폰
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-end">
|
||||||
|
<button
|
||||||
|
class="btn btn-text btn-small preview-btn"
|
||||||
|
data-style="simple"
|
||||||
|
>
|
||||||
|
<span class="material-icons">zoom_in</span>
|
||||||
|
크게보기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Style 2: 화려 -->
|
||||||
|
<section class="mb-lg">
|
||||||
|
<h3 class="text-headline mb-md">스타일 2: 화려</h3>
|
||||||
|
<div class="card style-card" data-style="fancy">
|
||||||
|
<div class="selected-badge">
|
||||||
|
<span class="material-icons" style="font-size: 20px">check</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="imageStyle"
|
||||||
|
id="style2"
|
||||||
|
value="fancy"
|
||||||
|
class="form-check-input"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="image-preview mb-md"
|
||||||
|
style="
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="image-preview-placeholder" style="color: white">
|
||||||
|
<span class="material-icons" style="font-size: 48px"
|
||||||
|
>auto_awesome</span
|
||||||
|
>
|
||||||
|
<p class="text-body-small mt-sm" id="style2Title">
|
||||||
|
SNS 팔로우 이벤트
|
||||||
|
</p>
|
||||||
|
<p class="text-caption" style="opacity: 0.9" id="style2Prize">
|
||||||
|
커피 쿠폰
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-end">
|
||||||
|
<button
|
||||||
|
class="btn btn-text btn-small preview-btn"
|
||||||
|
data-style="fancy"
|
||||||
|
>
|
||||||
|
<span class="material-icons">zoom_in</span>
|
||||||
|
크게보기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Style 3: 트렌디 -->
|
||||||
|
<section class="mb-lg">
|
||||||
|
<h3 class="text-headline mb-md">스타일 3: 트렌디</h3>
|
||||||
|
<div class="card style-card" data-style="trendy">
|
||||||
|
<div class="selected-badge">
|
||||||
|
<span class="material-icons" style="font-size: 20px">check</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="imageStyle"
|
||||||
|
id="style3"
|
||||||
|
value="trendy"
|
||||||
|
class="form-check-input"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="image-preview mb-md"
|
||||||
|
style="
|
||||||
|
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="image-preview-placeholder" style="color: white">
|
||||||
|
<span class="material-icons" style="font-size: 48px"
|
||||||
|
>trending_up</span
|
||||||
|
>
|
||||||
|
<p class="text-body-small mt-sm" id="style3Title">
|
||||||
|
SNS 팔로우 이벤트
|
||||||
|
</p>
|
||||||
|
<p class="text-caption" style="opacity: 0.9" id="style3Prize">
|
||||||
|
커피 쿠폰
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-end">
|
||||||
|
<button
|
||||||
|
class="btn btn-text btn-small preview-btn"
|
||||||
|
data-style="trendy"
|
||||||
|
>
|
||||||
|
<span class="material-icons">zoom_in</span>
|
||||||
|
크게보기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<section class="mb-2xl">
|
||||||
|
<div class="grid grid-cols-2 gap-sm">
|
||||||
|
<button id="skipBtn" class="btn btn-text btn-large">
|
||||||
|
건너뛰기
|
||||||
|
</button>
|
||||||
|
<button id="nextBtn" class="btn btn-primary btn-large" disabled>
|
||||||
|
다음
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Navigation -->
|
||||||
|
<div id="bottomNav"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fullscreen Image Modal -->
|
||||||
|
<div id="fullscreenModal" class="fullscreen-modal">
|
||||||
|
<button class="fullscreen-close" onclick="closeFullscreen()">
|
||||||
|
<span class="material-icons">close</span>
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
id="fullscreenImage"
|
||||||
|
class="fullscreen-image"
|
||||||
|
alt="이벤트 이미지 미리보기"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="common.js"></script>
|
||||||
|
<script>
|
||||||
|
// 로그인 확인
|
||||||
|
KTEventApp.Session.requireAuth();
|
||||||
|
|
||||||
|
// Header 생성
|
||||||
|
const header = KTEventApp.Navigation.createHeader({
|
||||||
|
title: "SNS 이미지 생성",
|
||||||
|
showBack: true,
|
||||||
|
showMenu: false,
|
||||||
|
showProfile: false,
|
||||||
|
});
|
||||||
|
document.getElementById("header").appendChild(header);
|
||||||
|
|
||||||
|
// Bottom Navigation 생성
|
||||||
|
const bottomNav = KTEventApp.Navigation.createBottomNav("events");
|
||||||
|
document.getElementById("bottomNav").appendChild(bottomNav);
|
||||||
|
|
||||||
|
// 저장된 데이터 불러오기
|
||||||
|
const recommendation = KTEventApp.Utils.getFromStorage(
|
||||||
|
"selected_recommendation"
|
||||||
|
) || {
|
||||||
|
title: "SNS 팔로우 이벤트",
|
||||||
|
prize: "커피 쿠폰",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 로딩 시뮬레이션
|
||||||
|
setTimeout(() => {
|
||||||
|
// 이미지 생성 완료
|
||||||
|
document.getElementById("loadingState").classList.add("hidden");
|
||||||
|
document.getElementById("generatedImages").classList.remove("hidden");
|
||||||
|
|
||||||
|
// 데이터 표시
|
||||||
|
document.getElementById("style1Title").textContent =
|
||||||
|
recommendation.title;
|
||||||
|
document.getElementById("style1Prize").textContent =
|
||||||
|
recommendation.prize;
|
||||||
|
document.getElementById("style2Title").textContent =
|
||||||
|
recommendation.title;
|
||||||
|
document.getElementById("style2Prize").textContent =
|
||||||
|
recommendation.prize;
|
||||||
|
document.getElementById("style3Title").textContent =
|
||||||
|
recommendation.title;
|
||||||
|
document.getElementById("style3Prize").textContent =
|
||||||
|
recommendation.prize;
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
// 선택 상태 관리
|
||||||
|
let selectedStyle = null;
|
||||||
|
|
||||||
|
// 카드 클릭 이벤트
|
||||||
|
document.querySelectorAll('.style-card').forEach((card) => {
|
||||||
|
card.addEventListener('click', function(e) {
|
||||||
|
// 크게보기 버튼 클릭은 무시
|
||||||
|
if (e.target.closest('.preview-btn')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styleValue = this.dataset.style;
|
||||||
|
const radioInput = this.querySelector('input[name="imageStyle"]');
|
||||||
|
|
||||||
|
// 모든 카드의 선택 상태 제거
|
||||||
|
document.querySelectorAll('.style-card').forEach(c => {
|
||||||
|
c.classList.remove('selected');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 현재 카드 선택
|
||||||
|
this.classList.add('selected');
|
||||||
|
radioInput.checked = true;
|
||||||
|
|
||||||
|
selectedStyle = styleValue;
|
||||||
|
document.getElementById("nextBtn").disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 크게보기 버튼
|
||||||
|
document.querySelectorAll(".preview-btn").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", function (e) {
|
||||||
|
e.stopPropagation(); // 카드 클릭 이벤트 방지
|
||||||
|
const style = this.dataset.style;
|
||||||
|
openFullscreen(style);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function openFullscreen(style) {
|
||||||
|
const modal = document.getElementById("fullscreenModal");
|
||||||
|
const img = document.getElementById("fullscreenImage");
|
||||||
|
|
||||||
|
// 실제로는 생성된 이미지 URL을 사용
|
||||||
|
// 여기서는 placeholder 사용
|
||||||
|
img.src =
|
||||||
|
'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="800" height="800"><rect width="800" height="800" fill="%23f0f0f0"/><text x="50%" y="50%" text-anchor="middle" fill="%23666" font-size="24">' +
|
||||||
|
recommendation.title +
|
||||||
|
"</text></svg>";
|
||||||
|
|
||||||
|
modal.classList.add("active");
|
||||||
|
}
|
||||||
|
|
||||||
|
window.closeFullscreen = function () {
|
||||||
|
document.getElementById("fullscreenModal").classList.remove("active");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 배경 클릭 시 닫기
|
||||||
|
document
|
||||||
|
.getElementById("fullscreenModal")
|
||||||
|
.addEventListener("click", function (e) {
|
||||||
|
if (e.target === this) {
|
||||||
|
closeFullscreen();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 건너뛰기 버튼
|
||||||
|
document.getElementById("skipBtn").addEventListener("click", function () {
|
||||||
|
KTEventApp.Feedback.showModal({
|
||||||
|
title: "건너뛰기",
|
||||||
|
content:
|
||||||
|
'<p class="text-body">이미지 없이 다음 단계로 진행하시겠습니까?</p>',
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: "취소",
|
||||||
|
variant: "text",
|
||||||
|
onClick: function () {
|
||||||
|
this.closest(".modal-backdrop").remove();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "확인",
|
||||||
|
variant: "primary",
|
||||||
|
onClick: function () {
|
||||||
|
this.closest(".modal-backdrop").remove();
|
||||||
|
window.location.href = "11-배포채널선택.html";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 다음 버튼
|
||||||
|
document.getElementById("nextBtn").addEventListener("click", function () {
|
||||||
|
if (selectedStyle) {
|
||||||
|
KTEventApp.Utils.saveToStorage("selected_image_style", selectedStyle);
|
||||||
|
window.location.href = "10-콘텐츠편집.html";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
209
design/uiux/prototype/10-콘텐츠편집.html
Normal file
209
design/uiux/prototype/10-콘텐츠편집.html
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>콘텐츠 편집 - KT AI 이벤트 마케팅</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
|
||||||
|
<style>
|
||||||
|
.preview-container {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.logo-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.color-picker-wrapper {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.color-swatch {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 2px solid var(--color-gray-300);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
input[type="color"] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page page-with-header">
|
||||||
|
<!-- Header -->
|
||||||
|
<div id="header"></div>
|
||||||
|
|
||||||
|
<div class="container" style="max-width: 800px;">
|
||||||
|
<!-- Desktop Layout: Side-by-side -->
|
||||||
|
<div class="grid desktop:grid-cols-2 gap-lg mt-lg mb-2xl">
|
||||||
|
<!-- Preview Section -->
|
||||||
|
<section>
|
||||||
|
<h3 class="text-headline mb-md">미리보기</h3>
|
||||||
|
<div class="card">
|
||||||
|
<div id="preview" class="preview-container">
|
||||||
|
<span class="material-icons" style="font-size: 48px; margin-bottom: 16px;">celebration</span>
|
||||||
|
<h3 id="previewTitle" class="text-title" style="margin-bottom: 8px;">신규고객 유치 이벤트</h3>
|
||||||
|
<p id="previewPrize" class="text-body" style="margin-bottom: 16px;">커피 쿠폰 100매</p>
|
||||||
|
<p id="previewGuide" class="text-body-small">전화번호를 입력하고 참여하세요</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Edit Section -->
|
||||||
|
<section>
|
||||||
|
<h3 class="text-headline mb-md">편집</h3>
|
||||||
|
|
||||||
|
<!-- Text Editing -->
|
||||||
|
<div class="card mb-md">
|
||||||
|
<h4 class="text-headline mb-md">
|
||||||
|
<span class="material-icons" style="vertical-align: middle;">edit</span>
|
||||||
|
텍스트 편집
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="titleInput" class="form-label">제목</label>
|
||||||
|
<input type="text" id="titleInput" class="form-input" value="신규고객 유치 이벤트" maxlength="50">
|
||||||
|
<span class="form-hint"><span id="titleCount">11</span>/50자</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="prizeInput" class="form-label">경품</label>
|
||||||
|
<input type="text" id="prizeInput" class="form-input" value="커피 쿠폰 100매" maxlength="30">
|
||||||
|
<span class="form-hint"><span id="prizeCount">9</span>/30자</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="guideInput" class="form-label">참여안내</label>
|
||||||
|
<textarea id="guideInput" class="form-textarea" rows="3" maxlength="100">전화번호를 입력하고 참여하세요</textarea>
|
||||||
|
<span class="form-hint"><span id="guideCount">17</span>/100자</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<section class="mb-2xl">
|
||||||
|
<div class="grid grid-cols-2 gap-sm">
|
||||||
|
<button id="saveBtn" class="btn btn-secondary btn-large">저장</button>
|
||||||
|
<button id="nextBtn" class="btn btn-primary btn-large">다음 단계</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Navigation -->
|
||||||
|
<div id="bottomNav"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="common.js"></script>
|
||||||
|
<script>
|
||||||
|
// 로그인 확인
|
||||||
|
KTEventApp.Session.requireAuth();
|
||||||
|
|
||||||
|
// Header 생성
|
||||||
|
const header = KTEventApp.Navigation.createHeader({
|
||||||
|
title: '콘텐츠 편집',
|
||||||
|
showBack: true,
|
||||||
|
showMenu: false,
|
||||||
|
showProfile: false
|
||||||
|
});
|
||||||
|
document.getElementById('header').appendChild(header);
|
||||||
|
|
||||||
|
// Bottom Navigation 생성
|
||||||
|
const bottomNav = KTEventApp.Navigation.createBottomNav('events');
|
||||||
|
document.getElementById('bottomNav').appendChild(bottomNav);
|
||||||
|
|
||||||
|
// 저장된 데이터 불러오기
|
||||||
|
const recommendation = KTEventApp.Utils.getFromStorage('selected_recommendation') || {
|
||||||
|
title: '신규고객 유치 이벤트',
|
||||||
|
prize: '커피 쿠폰 100매'
|
||||||
|
};
|
||||||
|
|
||||||
|
// 초기 데이터 설정
|
||||||
|
document.getElementById('titleInput').value = recommendation.title || '신규고객 유치 이벤트';
|
||||||
|
document.getElementById('prizeInput').value = recommendation.prize || '커피 쿠폰 100매';
|
||||||
|
updateCharCount();
|
||||||
|
|
||||||
|
// 실시간 미리보기 업데이트
|
||||||
|
function updatePreview() {
|
||||||
|
const title = document.getElementById('titleInput').value;
|
||||||
|
const prize = document.getElementById('prizeInput').value;
|
||||||
|
const guide = document.getElementById('guideInput').value;
|
||||||
|
|
||||||
|
document.getElementById('previewTitle').textContent = title || '제목을 입력하세요';
|
||||||
|
document.getElementById('previewPrize').textContent = prize || '경품을 입력하세요';
|
||||||
|
document.getElementById('previewGuide').textContent = guide || '참여 안내를 입력하세요';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 글자수 업데이트
|
||||||
|
function updateCharCount() {
|
||||||
|
document.getElementById('titleCount').textContent = document.getElementById('titleInput').value.length;
|
||||||
|
document.getElementById('prizeCount').textContent = document.getElementById('prizeInput').value.length;
|
||||||
|
document.getElementById('guideCount').textContent = document.getElementById('guideInput').value.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 텍스트 입력 이벤트
|
||||||
|
document.getElementById('titleInput').addEventListener('input', function() {
|
||||||
|
updatePreview();
|
||||||
|
updateCharCount();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('prizeInput').addEventListener('input', function() {
|
||||||
|
updatePreview();
|
||||||
|
updateCharCount();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('guideInput').addEventListener('input', function() {
|
||||||
|
updatePreview();
|
||||||
|
updateCharCount();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 저장 버튼
|
||||||
|
document.getElementById('saveBtn').addEventListener('click', function() {
|
||||||
|
const editData = {
|
||||||
|
title: document.getElementById('titleInput').value,
|
||||||
|
prize: document.getElementById('prizeInput').value,
|
||||||
|
guide: document.getElementById('guideInput').value
|
||||||
|
};
|
||||||
|
|
||||||
|
KTEventApp.Utils.saveToStorage('content_edit', editData);
|
||||||
|
KTEventApp.Feedback.showToast('편집 내용이 저장되었습니다');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 다음 단계 버튼
|
||||||
|
document.getElementById('nextBtn').addEventListener('click', function() {
|
||||||
|
// 저장 먼저 실행
|
||||||
|
const editData = {
|
||||||
|
title: document.getElementById('titleInput').value,
|
||||||
|
prize: document.getElementById('prizeInput').value,
|
||||||
|
guide: document.getElementById('guideInput').value
|
||||||
|
};
|
||||||
|
|
||||||
|
KTEventApp.Utils.saveToStorage('content_edit', editData);
|
||||||
|
|
||||||
|
// 배포 채널 선택으로 이동
|
||||||
|
window.location.href = '11-배포채널선택.html';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 초기 미리보기 업데이트
|
||||||
|
updatePreview();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
421
design/uiux/prototype/11-배포채널선택.html
Normal file
421
design/uiux/prototype/11-배포채널선택.html
Normal file
@ -0,0 +1,421 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>배포 채널 선택 - KT AI 이벤트 마케팅</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
|
||||||
|
<style>
|
||||||
|
.channel-card {
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
.channel-card.selected {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.channel-options {
|
||||||
|
display: none;
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
padding-top: var(--spacing-md);
|
||||||
|
border-top: 1px solid var(--color-gray-200);
|
||||||
|
}
|
||||||
|
.channel-card.selected .channel-options {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.channel-options .form-check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
.channel-options .form-check-input {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.channel-options .form-check-label {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page page-with-header">
|
||||||
|
<!-- Header -->
|
||||||
|
<div id="header"></div>
|
||||||
|
|
||||||
|
<div class="container" style="max-width: 800px;">
|
||||||
|
<!-- Title Section -->
|
||||||
|
<section class="mt-lg mb-lg text-center">
|
||||||
|
<h2 class="text-title mb-sm">배포 채널을 선택해주세요</h2>
|
||||||
|
<p class="text-body text-secondary">(최소 1개 이상)</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Channel 1: 우리동네TV -->
|
||||||
|
<section class="mb-md">
|
||||||
|
<div class="card channel-card" id="channel1Card">
|
||||||
|
<div class="flex items-start gap-md">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" id="channel1" class="form-check-input channel-checkbox" data-channel="uriTV">
|
||||||
|
<label for="channel1" class="form-check-label">우리동네TV</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="channel-options" id="channel1Options">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">반경</label>
|
||||||
|
<select class="form-select" id="uriRadius">
|
||||||
|
<option value="500">500m</option>
|
||||||
|
<option value="1000">1km</option>
|
||||||
|
<option value="2000">2km</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">노출 시간대</label>
|
||||||
|
<select class="form-select" id="uriTime">
|
||||||
|
<option value="morning">아침 (7-12시)</option>
|
||||||
|
<option value="afternoon">점심 (12-17시)</option>
|
||||||
|
<option value="evening" selected>저녁 (17-22시)</option>
|
||||||
|
<option value="all">전체</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-body-small text-secondary">
|
||||||
|
<p>예상 노출: <strong id="uriExposure">5만명</strong></p>
|
||||||
|
<p>비용: <strong id="uriCost">8만원</strong></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Channel 2: 링고비즈 -->
|
||||||
|
<section class="mb-md">
|
||||||
|
<div class="card channel-card" id="channel2Card">
|
||||||
|
<div class="flex items-start gap-md">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" id="channel2" class="form-check-input channel-checkbox" data-channel="ringoBiz">
|
||||||
|
<label for="channel2" class="form-check-label">링고비즈</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="channel-options" id="channel2Options">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">매장 전화번호</label>
|
||||||
|
<input type="tel" class="form-input" id="ringoPhone" value="010-1234-5678" readonly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-body-small text-secondary">
|
||||||
|
<p>연결음 자동 업데이트</p>
|
||||||
|
<p>예상 노출: <strong id="ringoExposure">3만명</strong></p>
|
||||||
|
<p>비용: <strong id="ringoCost">무료</strong></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Channel 3: 지니TV 광고 -->
|
||||||
|
<section class="mb-md">
|
||||||
|
<div class="card channel-card" id="channel3Card">
|
||||||
|
<div class="flex items-start gap-md">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" id="channel3" class="form-check-input channel-checkbox" data-channel="genieTV">
|
||||||
|
<label for="channel3" class="form-check-label">지니TV 광고</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="channel-options" id="channel3Options">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">지역</label>
|
||||||
|
<select class="form-select" id="genieRegion">
|
||||||
|
<option value="suwon" selected>수원</option>
|
||||||
|
<option value="seoul">서울</option>
|
||||||
|
<option value="busan">부산</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">노출 시간대</label>
|
||||||
|
<select class="form-select" id="genieTime">
|
||||||
|
<option value="all" selected>전체</option>
|
||||||
|
<option value="prime">프라임 (19-23시)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">예산</label>
|
||||||
|
<input type="number" class="form-input" id="genieBudget" placeholder="예산을 입력하세요" min="0" step="10000">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-body-small text-secondary">
|
||||||
|
<p>예상 노출: <strong id="genieExposure">계산중...</strong></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Channel 4: SNS -->
|
||||||
|
<section class="mb-md">
|
||||||
|
<div class="card channel-card" id="channel4Card">
|
||||||
|
<div class="flex items-start gap-md">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" id="channel4" class="form-check-input channel-checkbox" data-channel="sns">
|
||||||
|
<label for="channel4" class="form-check-label">SNS</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="channel-options" id="channel4Options">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">플랫폼 선택</label>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" id="snsInstagram" class="form-check-input" checked>
|
||||||
|
<label for="snsInstagram" class="form-check-label">Instagram</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" id="snsNaver" class="form-check-input" checked>
|
||||||
|
<label for="snsNaver" class="form-check-label">Naver Blog</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" id="snsKakao" class="form-check-input">
|
||||||
|
<label for="snsKakao" class="form-check-label">Kakao Channel</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">예약 게시</label>
|
||||||
|
<select class="form-select" id="snsSchedule">
|
||||||
|
<option value="now" selected>즉시</option>
|
||||||
|
<option value="schedule">예약</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-body-small text-secondary">
|
||||||
|
<p>예상 노출: <strong id="snsExposure">-</strong></p>
|
||||||
|
<p>비용: <strong id="snsCost">무료</strong></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Summary -->
|
||||||
|
<section class="mb-lg">
|
||||||
|
<div class="card" style="background: var(--color-gray-50);">
|
||||||
|
<div class="flex items-center justify-between mb-sm">
|
||||||
|
<span class="text-headline">총 예상 비용</span>
|
||||||
|
<span class="text-title text-kt-red" id="totalCost">0원</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-headline">총 예상 노출</span>
|
||||||
|
<span class="text-title text-ai-blue" id="totalExposure">0명</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Action Button -->
|
||||||
|
<section class="mb-2xl">
|
||||||
|
<button id="nextBtn" class="btn btn-primary btn-large btn-full" disabled>
|
||||||
|
다음 단계
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Navigation -->
|
||||||
|
<div id="bottomNav"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="common.js"></script>
|
||||||
|
<script>
|
||||||
|
// 로그인 확인
|
||||||
|
KTEventApp.Session.requireAuth();
|
||||||
|
|
||||||
|
// Header 생성
|
||||||
|
const header = KTEventApp.Navigation.createHeader({
|
||||||
|
title: '배포 채널 선택',
|
||||||
|
showBack: true,
|
||||||
|
showMenu: false,
|
||||||
|
showProfile: false
|
||||||
|
});
|
||||||
|
document.getElementById('header').appendChild(header);
|
||||||
|
|
||||||
|
// Bottom Navigation 생성
|
||||||
|
const bottomNav = KTEventApp.Navigation.createBottomNav('events');
|
||||||
|
document.getElementById('bottomNav').appendChild(bottomNav);
|
||||||
|
|
||||||
|
// 선택된 채널 추적
|
||||||
|
const selectedChannels = new Set();
|
||||||
|
|
||||||
|
// 채널 선택 이벤트
|
||||||
|
document.querySelectorAll('.channel-checkbox').forEach(checkbox => {
|
||||||
|
checkbox.addEventListener('change', function() {
|
||||||
|
const channel = this.dataset.channel;
|
||||||
|
const card = this.closest('.channel-card');
|
||||||
|
|
||||||
|
if (this.checked) {
|
||||||
|
selectedChannels.add(channel);
|
||||||
|
card.classList.add('selected');
|
||||||
|
} else {
|
||||||
|
selectedChannels.delete(channel);
|
||||||
|
card.classList.remove('selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSummary();
|
||||||
|
updateNextButton();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 지니TV 예산 입력 시 예상 노출 계산
|
||||||
|
document.getElementById('genieBudget')?.addEventListener('input', function() {
|
||||||
|
const budget = parseInt(this.value) || 0;
|
||||||
|
const exposure = Math.floor(budget / 100) * 1000; // 10만원당 1만명 노출 (예시)
|
||||||
|
document.getElementById('genieExposure').textContent = exposure > 0 ? `${(exposure / 10000).toFixed(1)}만명` : '계산중...';
|
||||||
|
updateSummary();
|
||||||
|
});
|
||||||
|
|
||||||
|
// SNS 예약 게시 선택 시 모달 표시
|
||||||
|
let scheduledDateTime = null;
|
||||||
|
document.getElementById('snsSchedule').addEventListener('change', function() {
|
||||||
|
if (this.value === 'schedule') {
|
||||||
|
showScheduleModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function showScheduleModal() {
|
||||||
|
// 현재 날짜/시간 기본값 설정
|
||||||
|
const now = new Date();
|
||||||
|
const tomorrow = new Date(now);
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
|
||||||
|
const dateStr = tomorrow.toISOString().split('T')[0];
|
||||||
|
const timeStr = '09:00';
|
||||||
|
|
||||||
|
KTEventApp.Feedback.showModal({
|
||||||
|
title: '예약 배포 설정',
|
||||||
|
content: `
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">배포 날짜</label>
|
||||||
|
<input type="date" id="scheduleDate" class="form-input" value="${dateStr}" min="${dateStr}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">배포 시간</label>
|
||||||
|
<input type="time" id="scheduleTime" class="form-input" value="${timeStr}">
|
||||||
|
</div>
|
||||||
|
<p class="text-body-small text-secondary mt-md">
|
||||||
|
* 설정한 시간에 자동으로 SNS에 게시됩니다
|
||||||
|
</p>
|
||||||
|
`,
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: '취소',
|
||||||
|
variant: 'text',
|
||||||
|
onClick: function() {
|
||||||
|
// 즉시로 되돌리기
|
||||||
|
document.getElementById('snsSchedule').value = 'now';
|
||||||
|
scheduledDateTime = null;
|
||||||
|
this.closest('.modal-backdrop').remove();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '확인',
|
||||||
|
variant: 'primary',
|
||||||
|
onClick: function() {
|
||||||
|
const date = document.getElementById('scheduleDate').value;
|
||||||
|
const time = document.getElementById('scheduleTime').value;
|
||||||
|
|
||||||
|
if (!date || !time) {
|
||||||
|
KTEventApp.Feedback.showToast('날짜와 시간을 모두 입력해주세요');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduledDateTime = {
|
||||||
|
date: date,
|
||||||
|
time: time,
|
||||||
|
datetime: `${date} ${time}`
|
||||||
|
};
|
||||||
|
|
||||||
|
KTEventApp.Feedback.showToast(`${date} ${time}에 배포 예약되었습니다`);
|
||||||
|
this.closest('.modal-backdrop').remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 요약 업데이트
|
||||||
|
function updateSummary() {
|
||||||
|
let totalCost = 0;
|
||||||
|
let totalExposure = 0;
|
||||||
|
|
||||||
|
if (selectedChannels.has('uriTV')) {
|
||||||
|
totalCost += 80000;
|
||||||
|
totalExposure += 50000;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedChannels.has('ringoBiz')) {
|
||||||
|
totalCost += 0;
|
||||||
|
totalExposure += 30000;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedChannels.has('genieTV')) {
|
||||||
|
const budget = parseInt(document.getElementById('genieBudget').value) || 0;
|
||||||
|
totalCost += budget;
|
||||||
|
totalExposure += Math.floor(budget / 100) * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedChannels.has('sns')) {
|
||||||
|
totalCost += 0;
|
||||||
|
// SNS는 팔로워 수에 따라 다름
|
||||||
|
totalExposure += 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 표시 업데이트
|
||||||
|
document.getElementById('totalCost').textContent = KTEventApp.Utils.formatNumber(totalCost) + '원';
|
||||||
|
document.getElementById('totalExposure').textContent = totalExposure > 0 ?
|
||||||
|
KTEventApp.Utils.formatNumber(totalExposure) + '명+' : '0명';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다음 버튼 활성화
|
||||||
|
function updateNextButton() {
|
||||||
|
document.getElementById('nextBtn').disabled = selectedChannels.size === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다음 단계 버튼
|
||||||
|
document.getElementById('nextBtn').addEventListener('click', function() {
|
||||||
|
if (selectedChannels.size === 0) {
|
||||||
|
KTEventApp.Feedback.showToast('최소 1개 이상의 채널을 선택해주세요');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 채널 선택 정보 저장
|
||||||
|
const channelData = {
|
||||||
|
channels: Array.from(selectedChannels),
|
||||||
|
uriTV: selectedChannels.has('uriTV') ? {
|
||||||
|
radius: document.getElementById('uriRadius').value,
|
||||||
|
time: document.getElementById('uriTime').value
|
||||||
|
} : null,
|
||||||
|
ringoBiz: selectedChannels.has('ringoBiz') ? {
|
||||||
|
phone: document.getElementById('ringoPhone').value
|
||||||
|
} : null,
|
||||||
|
genieTV: selectedChannels.has('genieTV') ? {
|
||||||
|
region: document.getElementById('genieRegion').value,
|
||||||
|
time: document.getElementById('genieTime').value,
|
||||||
|
budget: document.getElementById('genieBudget').value
|
||||||
|
} : null,
|
||||||
|
sns: selectedChannels.has('sns') ? {
|
||||||
|
instagram: document.getElementById('snsInstagram').checked,
|
||||||
|
naver: document.getElementById('snsNaver').checked,
|
||||||
|
kakao: document.getElementById('snsKakao').checked,
|
||||||
|
schedule: document.getElementById('snsSchedule').value,
|
||||||
|
scheduledDateTime: scheduledDateTime
|
||||||
|
} : null
|
||||||
|
};
|
||||||
|
|
||||||
|
KTEventApp.Utils.saveToStorage('selected_channels', channelData);
|
||||||
|
|
||||||
|
// 최종 승인 화면으로 이동
|
||||||
|
window.location.href = '12-최종승인.html';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
367
design/uiux/prototype/12-최종승인.html
Normal file
367
design/uiux/prototype/12-최종승인.html
Normal file
@ -0,0 +1,367 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>최종 승인 - KT AI 이벤트 마케팅</title>
|
||||||
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://fonts.googleapis.com/icon?family=Material+Icons"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page page-with-header">
|
||||||
|
<!-- Header -->
|
||||||
|
<div id="header"></div>
|
||||||
|
|
||||||
|
<div class="container" style="max-width: 600px">
|
||||||
|
<!-- Title Section -->
|
||||||
|
<section class="mt-lg mb-lg text-center">
|
||||||
|
<div class="mb-md">
|
||||||
|
<span
|
||||||
|
class="material-icons"
|
||||||
|
style="font-size: 64px; color: var(--color-success)"
|
||||||
|
>check_circle</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-title-large mb-sm">이벤트를 확인해주세요</h2>
|
||||||
|
<p class="text-body text-secondary">
|
||||||
|
모든 정보를 검토한 후 배포하세요
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Event Summary Card -->
|
||||||
|
<section class="mb-lg">
|
||||||
|
<div class="card">
|
||||||
|
<h3 class="text-headline mb-md" id="eventTitle">
|
||||||
|
SNS 팔로우 이벤트
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-sm mb-md">
|
||||||
|
<span class="event-card-badge badge-scheduled">배포 대기</span>
|
||||||
|
<span
|
||||||
|
class="event-card-badge"
|
||||||
|
style="
|
||||||
|
background: rgba(0, 102, 255, 0.1);
|
||||||
|
color: var(--color-ai-blue);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
AI 추천
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t pt-md">
|
||||||
|
<div class="grid grid-cols-2 gap-md text-body-small">
|
||||||
|
<div>
|
||||||
|
<p class="text-tertiary mb-xs">이벤트 기간</p>
|
||||||
|
<p class="text-semibold" id="eventPeriod">
|
||||||
|
2025.02.01 ~ 2025.02.28
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-tertiary mb-xs">목표 참여자</p>
|
||||||
|
<p class="text-semibold" id="targetParticipants">180명</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-tertiary mb-xs">예상 비용</p>
|
||||||
|
<p class="text-semibold" id="estimatedCost">250,000원</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-tertiary mb-xs">예상 ROI</p>
|
||||||
|
<p class="text-semibold text-kt-red" id="estimatedROI">
|
||||||
|
520%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Event Details -->
|
||||||
|
<section class="mb-lg">
|
||||||
|
<h3 class="text-headline mb-md">이벤트 상세</h3>
|
||||||
|
|
||||||
|
<div class="card mb-sm">
|
||||||
|
<div class="flex items-start gap-md">
|
||||||
|
<span class="material-icons text-kt-red">celebration</span>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-body-small text-tertiary mb-xs">이벤트 제목</p>
|
||||||
|
<p class="text-body" id="detailTitle">SNS 팔로우 이벤트</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="header-icon-btn"
|
||||||
|
onclick="window.location.href='10-콘텐츠편집.html'"
|
||||||
|
>
|
||||||
|
<span class="material-icons">edit</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-sm">
|
||||||
|
<div class="flex items-start gap-md">
|
||||||
|
<span class="material-icons text-kt-red">card_giftcard</span>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-body-small text-tertiary mb-xs">경품</p>
|
||||||
|
<p class="text-body" id="detailPrize">커피 쿠폰</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="header-icon-btn"
|
||||||
|
onclick="window.location.href='10-콘텐츠편집.html'"
|
||||||
|
>
|
||||||
|
<span class="material-icons">edit</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-sm">
|
||||||
|
<div class="flex items-start gap-md">
|
||||||
|
<span class="material-icons text-kt-red">description</span>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-body-small text-tertiary mb-xs">이벤트 설명</p>
|
||||||
|
<p class="text-body" id="detailDescription">
|
||||||
|
SNS를 팔로우하고 커피 쿠폰을 받으세요!<br />
|
||||||
|
많은 참여 부탁드립니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="header-icon-btn"
|
||||||
|
onclick="window.location.href='10-콘텐츠편집.html'"
|
||||||
|
>
|
||||||
|
<span class="material-icons">edit</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex items-start gap-md">
|
||||||
|
<span class="material-icons text-kt-red">how_to_reg</span>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-body-small text-tertiary mb-xs">참여 방법</p>
|
||||||
|
<p class="text-body" id="detailMethod">SNS 팔로우</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Distribution Channels -->
|
||||||
|
<section class="mb-lg">
|
||||||
|
<h3 class="text-headline mb-md">배포 채널</h3>
|
||||||
|
<div class="card">
|
||||||
|
<div id="channels" class="flex flex-wrap gap-sm">
|
||||||
|
<span class="event-card-badge badge-active">
|
||||||
|
<span
|
||||||
|
class="material-icons"
|
||||||
|
style="font-size: 16px; vertical-align: middle"
|
||||||
|
>language</span
|
||||||
|
>
|
||||||
|
홈페이지
|
||||||
|
</span>
|
||||||
|
<span class="event-card-badge badge-active">
|
||||||
|
<span
|
||||||
|
class="material-icons"
|
||||||
|
style="font-size: 16px; vertical-align: middle"
|
||||||
|
>chat_bubble</span
|
||||||
|
>
|
||||||
|
카카오톡
|
||||||
|
</span>
|
||||||
|
<span class="event-card-badge badge-active">
|
||||||
|
<span
|
||||||
|
class="material-icons"
|
||||||
|
style="font-size: 16px; vertical-align: middle"
|
||||||
|
>share</span
|
||||||
|
>
|
||||||
|
Instagram
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-text btn-small mt-md"
|
||||||
|
onclick="window.location.href='11-배포채널선택.html'"
|
||||||
|
>
|
||||||
|
<span class="material-icons">edit</span>
|
||||||
|
채널 수정하기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Terms Agreement -->
|
||||||
|
<section class="mb-xl">
|
||||||
|
<div class="card" style="background: var(--color-gray-50)">
|
||||||
|
<div class="form-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="agreeTerms"
|
||||||
|
class="form-check-input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<label for="agreeTerms" class="form-check-label">
|
||||||
|
이벤트 약관 및 개인정보 처리방침에 동의합니다
|
||||||
|
<span class="text-kt-red">(필수)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="text-body-small text-kt-red mt-sm inline-block"
|
||||||
|
id="viewTerms"
|
||||||
|
>약관 보기</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<section class="mb-2xl">
|
||||||
|
<button
|
||||||
|
id="approveBtn"
|
||||||
|
class="btn btn-primary btn-large btn-full mb-sm"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<span class="material-icons">rocket_launch</span>
|
||||||
|
배포하기
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="saveBtn"
|
||||||
|
class="btn btn-secondary btn-large btn-full mb-sm"
|
||||||
|
>
|
||||||
|
<span class="material-icons">save</span>
|
||||||
|
임시저장
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Navigation -->
|
||||||
|
<div id="bottomNav"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="common.js"></script>
|
||||||
|
<script>
|
||||||
|
// 로그인 확인
|
||||||
|
KTEventApp.Session.requireAuth();
|
||||||
|
|
||||||
|
// Header 생성
|
||||||
|
const header = KTEventApp.Navigation.createHeader({
|
||||||
|
title: "최종 승인",
|
||||||
|
showBack: true,
|
||||||
|
showMenu: false,
|
||||||
|
showProfile: false,
|
||||||
|
});
|
||||||
|
document.getElementById("header").appendChild(header);
|
||||||
|
|
||||||
|
// Bottom Navigation 생성
|
||||||
|
const bottomNav = KTEventApp.Navigation.createBottomNav("events");
|
||||||
|
document.getElementById("bottomNav").appendChild(bottomNav);
|
||||||
|
|
||||||
|
// 저장된 데이터 불러오기
|
||||||
|
const recommendation = KTEventApp.Utils.getFromStorage(
|
||||||
|
"selected_recommendation"
|
||||||
|
) || {
|
||||||
|
title: "SNS 팔로우 이벤트",
|
||||||
|
prize: "커피 쿠폰",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 데이터 표시
|
||||||
|
document.getElementById("eventTitle").textContent = recommendation.title;
|
||||||
|
document.getElementById("detailTitle").textContent = recommendation.title;
|
||||||
|
document.getElementById("detailPrize").textContent = recommendation.prize;
|
||||||
|
|
||||||
|
// 약관 동의 체크박스
|
||||||
|
document
|
||||||
|
.getElementById("agreeTerms")
|
||||||
|
.addEventListener("change", function (e) {
|
||||||
|
document.getElementById("approveBtn").disabled = !e.target.checked;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 약관 보기
|
||||||
|
document
|
||||||
|
.getElementById("viewTerms")
|
||||||
|
.addEventListener("click", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
KTEventApp.Feedback.showModal({
|
||||||
|
title: "이벤트 약관",
|
||||||
|
content: `
|
||||||
|
<div class="p-md" style="max-height: 400px; overflow-y: auto;">
|
||||||
|
<h4 class="text-headline mb-md">제1조 (목적)</h4>
|
||||||
|
<p class="text-body-small mb-lg">
|
||||||
|
본 약관은 KT AI 이벤트 마케팅 서비스를 통해 진행되는 이벤트의 참여 및 개인정보 처리에 관한 사항을 규정합니다.
|
||||||
|
</p>
|
||||||
|
<h4 class="text-headline mb-md">제2조 (개인정보 수집 및 이용)</h4>
|
||||||
|
<p class="text-body-small mb-lg">
|
||||||
|
수집 항목: 이름, 전화번호, 이메일<br>
|
||||||
|
이용 목적: 이벤트 참여 확인 및 경품 제공<br>
|
||||||
|
보유 기간: 이벤트 종료 후 6개월
|
||||||
|
</p>
|
||||||
|
<h4 class="text-headline mb-md">제3조 (당첨자 발표)</h4>
|
||||||
|
<p class="text-body-small">
|
||||||
|
당첨자는 이벤트 종료 후 7일 이내 개별 연락 드립니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: "확인",
|
||||||
|
variant: "primary",
|
||||||
|
onClick: function () {
|
||||||
|
this.closest(".modal-backdrop").remove();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 배포하기 버튼
|
||||||
|
document
|
||||||
|
.getElementById("approveBtn")
|
||||||
|
.addEventListener("click", function () {
|
||||||
|
const button = this;
|
||||||
|
button.disabled = true;
|
||||||
|
button.innerHTML =
|
||||||
|
'<span class="material-icons">hourglass_empty</span> 배포 중...';
|
||||||
|
|
||||||
|
// 배포 시뮬레이션
|
||||||
|
setTimeout(() => {
|
||||||
|
// 성공 모달 표시
|
||||||
|
const successModal = KTEventApp.Feedback.showModal({
|
||||||
|
content: `
|
||||||
|
<div class="p-xl text-center">
|
||||||
|
<span class="material-icons" style="font-size: 80px; color: var(--color-success);">check_circle</span>
|
||||||
|
<h3 class="text-title-large mt-md mb-sm">배포 완료!</h3>
|
||||||
|
<p class="text-body text-secondary mb-lg">
|
||||||
|
이벤트가 성공적으로 배포되었습니다.<br>
|
||||||
|
실시간으로 참여자를 확인할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col gap-sm">
|
||||||
|
<button class="btn btn-primary btn-large btn-full" onclick="window.location.href='13-이벤트상세.html?id=new'">
|
||||||
|
이벤트 상세 보기
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary btn-large btn-full" onclick="window.location.href='05-대시보드.html'">
|
||||||
|
대시보드로 이동
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 임시저장 버튼
|
||||||
|
document.getElementById("saveBtn").addEventListener("click", function () {
|
||||||
|
// 현재 데이터 저장
|
||||||
|
const draftData = {
|
||||||
|
...recommendation,
|
||||||
|
savedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
KTEventApp.Utils.saveToStorage("event_draft", draftData);
|
||||||
|
KTEventApp.Feedback.showToast("임시저장되었습니다");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 이전으로 버튼
|
||||||
|
document.getElementById("backBtn").addEventListener("click", function () {
|
||||||
|
window.history.back();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
437
design/uiux/prototype/13-이벤트상세.html
Normal file
437
design/uiux/prototype/13-이벤트상세.html
Normal file
@ -0,0 +1,437 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>이벤트 상세 - KT AI 이벤트 마케팅</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page page-with-header">
|
||||||
|
<!-- Header -->
|
||||||
|
<div id="header"></div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<!-- Event Header -->
|
||||||
|
<section class="mt-lg mb-lg">
|
||||||
|
<div class="flex items-center justify-between mb-md">
|
||||||
|
<h2 class="text-title-large" id="eventTitle">SNS 팔로우 이벤트</h2>
|
||||||
|
<button class="header-icon-btn" id="moreBtn">
|
||||||
|
<span class="material-icons">more_vert</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-sm mb-md">
|
||||||
|
<span class="event-card-badge badge-active" id="statusBadge">진행중</span>
|
||||||
|
<span class="event-card-badge" style="background: rgba(0, 102, 255, 0.1); color: var(--color-ai-blue);">
|
||||||
|
AI 추천
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-body text-secondary" id="eventPeriod">
|
||||||
|
2025.01.15 ~ 2025.02.15
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Real-time KPIs -->
|
||||||
|
<section class="mb-xl">
|
||||||
|
<div class="flex items-center justify-between mb-md">
|
||||||
|
<h3 class="text-headline">실시간 현황</h3>
|
||||||
|
<div class="flex items-center gap-xs text-body-small text-success">
|
||||||
|
<span class="material-icons" style="font-size: 16px;">fiber_manual_record</span>
|
||||||
|
<span>실시간 업데이트</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-sm tablet:grid-cols-4">
|
||||||
|
<div id="kpiParticipants"></div>
|
||||||
|
<div id="kpiViews"></div>
|
||||||
|
<div id="kpiROI"></div>
|
||||||
|
<div id="kpiConversion"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Chart Section -->
|
||||||
|
<section class="mb-xl">
|
||||||
|
<h3 class="text-headline mb-md">참여 추이</h3>
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex justify-between items-center mb-md">
|
||||||
|
<div class="flex gap-xs">
|
||||||
|
<button class="btn btn-small active" data-period="7d">7일</button>
|
||||||
|
<button class="btn btn-small btn-text" data-period="30d">30일</button>
|
||||||
|
<button class="btn btn-small btn-text" data-period="all">전체</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Simple Chart Placeholder -->
|
||||||
|
<div id="chartPlaceholder" style="height: 200px; background: var(--color-gray-50); border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center;">
|
||||||
|
<div class="text-center">
|
||||||
|
<span class="material-icons text-tertiary" style="font-size: 48px;">show_chart</span>
|
||||||
|
<p class="text-body-small text-tertiary mt-sm">참여자 추이 차트</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Event Details -->
|
||||||
|
<section class="mb-xl">
|
||||||
|
<h3 class="text-headline mb-md">이벤트 정보</h3>
|
||||||
|
|
||||||
|
<div class="card mb-sm">
|
||||||
|
<div class="flex items-start gap-md">
|
||||||
|
<span class="material-icons text-kt-red">card_giftcard</span>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-body-small text-tertiary mb-xs">경품</p>
|
||||||
|
<p class="text-body" id="detailPrize">커피 쿠폰</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-sm">
|
||||||
|
<div class="flex items-start gap-md">
|
||||||
|
<span class="material-icons text-kt-red">how_to_reg</span>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-body-small text-tertiary mb-xs">참여 방법</p>
|
||||||
|
<p class="text-body" id="detailMethod">SNS 팔로우</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-sm">
|
||||||
|
<div class="flex items-start gap-md">
|
||||||
|
<span class="material-icons text-kt-red">attach_money</span>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-body-small text-tertiary mb-xs">예상 비용</p>
|
||||||
|
<p class="text-body" id="detailCost">250,000원</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex items-start gap-md">
|
||||||
|
<span class="material-icons text-kt-red">share</span>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-body-small text-tertiary mb-xs">배포 채널</p>
|
||||||
|
<div class="flex flex-wrap gap-xs mt-sm">
|
||||||
|
<span class="event-card-badge badge-active">홈페이지</span>
|
||||||
|
<span class="event-card-badge badge-active">카카오톡</span>
|
||||||
|
<span class="event-card-badge badge-active">Instagram</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<section class="mb-xl">
|
||||||
|
<h3 class="text-headline mb-md">빠른 작업</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-sm tablet:grid-cols-4">
|
||||||
|
<button class="card card-clickable p-md" id="viewParticipants">
|
||||||
|
<span class="material-icons text-kt-red" style="font-size: 32px;">people</span>
|
||||||
|
<p class="text-body-small mt-sm">참여자 목록</p>
|
||||||
|
</button>
|
||||||
|
<button class="card card-clickable p-md" id="editEvent">
|
||||||
|
<span class="material-icons text-ai-blue" style="font-size: 32px;">edit</span>
|
||||||
|
<p class="text-body-small mt-sm">이벤트 수정</p>
|
||||||
|
</button>
|
||||||
|
<button class="card card-clickable p-md" id="shareEvent">
|
||||||
|
<span class="material-icons text-secondary" style="font-size: 32px;">share</span>
|
||||||
|
<p class="text-body-small mt-sm">공유하기</p>
|
||||||
|
</button>
|
||||||
|
<button class="card card-clickable p-md" id="exportData">
|
||||||
|
<span class="material-icons text-secondary" style="font-size: 32px;">download</span>
|
||||||
|
<p class="text-body-small mt-sm">데이터 다운</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Recent Participants -->
|
||||||
|
<section class="mb-2xl">
|
||||||
|
<div class="flex items-center justify-between mb-md">
|
||||||
|
<h3 class="text-headline">최근 참여자</h3>
|
||||||
|
<a href="14-참여자목록.html" class="text-body-small text-kt-red">
|
||||||
|
전체보기
|
||||||
|
<span class="material-icons" style="font-size: 16px; vertical-align: middle;">chevron_right</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div id="recentParticipants">
|
||||||
|
<!-- 참여자 목록 -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Navigation -->
|
||||||
|
<div id="bottomNav"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="common.js"></script>
|
||||||
|
<script>
|
||||||
|
// 로그인 확인
|
||||||
|
KTEventApp.Session.requireAuth();
|
||||||
|
|
||||||
|
// Header 생성
|
||||||
|
const header = KTEventApp.Navigation.createHeader({
|
||||||
|
title: '이벤트 상세',
|
||||||
|
showBack: true,
|
||||||
|
showMenu: false,
|
||||||
|
showProfile: false
|
||||||
|
});
|
||||||
|
document.getElementById('header').appendChild(header);
|
||||||
|
|
||||||
|
// Bottom Navigation 생성
|
||||||
|
const bottomNav = KTEventApp.Navigation.createBottomNav('events');
|
||||||
|
document.getElementById('bottomNav').appendChild(bottomNav);
|
||||||
|
|
||||||
|
// URL에서 이벤트 ID 가져오기
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const eventId = urlParams.get('id');
|
||||||
|
|
||||||
|
// 이벤트 데이터 가져오기
|
||||||
|
const events = KTEventApp.MockData.getEvents();
|
||||||
|
const event = events.find(e => e.id === eventId) || events[0];
|
||||||
|
|
||||||
|
// 이벤트 정보 표시
|
||||||
|
document.getElementById('eventTitle').textContent = event.title;
|
||||||
|
document.getElementById('eventPeriod').textContent =
|
||||||
|
`${KTEventApp.Utils.formatDate(event.startDate)} ~ ${KTEventApp.Utils.formatDate(event.endDate)}`;
|
||||||
|
document.getElementById('detailPrize').textContent = event.prize;
|
||||||
|
document.getElementById('detailMethod').textContent = `${event.channel} - SNS 팔로우`;
|
||||||
|
document.getElementById('detailCost').textContent = event.budget === '저비용' ? '250,000원' : '1,500,000원';
|
||||||
|
|
||||||
|
// 상태 배지
|
||||||
|
const statusBadge = document.getElementById('statusBadge');
|
||||||
|
statusBadge.textContent = event.status;
|
||||||
|
statusBadge.className = 'event-card-badge';
|
||||||
|
if (event.status === '진행중') {
|
||||||
|
statusBadge.classList.add('badge-active');
|
||||||
|
} else if (event.status === '예정') {
|
||||||
|
statusBadge.classList.add('badge-scheduled');
|
||||||
|
} else {
|
||||||
|
statusBadge.classList.add('badge-ended');
|
||||||
|
}
|
||||||
|
|
||||||
|
// KPI 카드 생성
|
||||||
|
const kpiParticipants = KTEventApp.Cards.createKPICard({
|
||||||
|
icon: 'group',
|
||||||
|
iconType: 'primary',
|
||||||
|
label: '참여자',
|
||||||
|
value: `${KTEventApp.Utils.formatNumber(event.participants)}명`
|
||||||
|
});
|
||||||
|
document.getElementById('kpiParticipants').appendChild(kpiParticipants);
|
||||||
|
|
||||||
|
const kpiViews = KTEventApp.Cards.createKPICard({
|
||||||
|
icon: 'visibility',
|
||||||
|
iconType: 'ai',
|
||||||
|
label: '조회수',
|
||||||
|
value: `${KTEventApp.Utils.formatNumber(event.views)}`
|
||||||
|
});
|
||||||
|
document.getElementById('kpiViews').appendChild(kpiViews);
|
||||||
|
|
||||||
|
const kpiROI = KTEventApp.Cards.createKPICard({
|
||||||
|
icon: 'trending_up',
|
||||||
|
iconType: 'success',
|
||||||
|
label: 'ROI',
|
||||||
|
value: `${event.roi}%`
|
||||||
|
});
|
||||||
|
document.getElementById('kpiROI').appendChild(kpiROI);
|
||||||
|
|
||||||
|
const conversion = event.views > 0 ? Math.round((event.participants / event.views) * 100) : 0;
|
||||||
|
const kpiConversion = KTEventApp.Cards.createKPICard({
|
||||||
|
icon: 'conversion_path',
|
||||||
|
iconType: 'primary',
|
||||||
|
label: '전환율',
|
||||||
|
value: `${conversion}%`
|
||||||
|
});
|
||||||
|
document.getElementById('kpiConversion').appendChild(kpiConversion);
|
||||||
|
|
||||||
|
// 최근 참여자 표시
|
||||||
|
const recentParticipantsContainer = document.getElementById('recentParticipants');
|
||||||
|
const participants = [
|
||||||
|
{ name: '김*진', phone: '010-****-1234', time: '5분 전' },
|
||||||
|
{ name: '이*수', phone: '010-****-5678', time: '12분 전' },
|
||||||
|
{ name: '박*영', phone: '010-****-9012', time: '25분 전' },
|
||||||
|
{ name: '최*민', phone: '010-****-3456', time: '1시간 전' },
|
||||||
|
{ name: '정*희', phone: '010-****-7890', time: '2시간 전' }
|
||||||
|
];
|
||||||
|
|
||||||
|
participants.forEach((participant, index) => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'flex items-center justify-between';
|
||||||
|
if (index > 0) item.classList.add('mt-md', 'pt-md', 'border-t');
|
||||||
|
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="flex items-center gap-md">
|
||||||
|
<div class="flex items-center justify-center" style="width: 40px; height: 40px; background: var(--color-gray-100); border-radius: var(--radius-full);">
|
||||||
|
<span class="material-icons text-tertiary">person</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-body text-semibold">${participant.name}</p>
|
||||||
|
<p class="text-caption text-tertiary">${participant.phone}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-caption text-tertiary">${participant.time}</p>
|
||||||
|
`;
|
||||||
|
recentParticipantsContainer.appendChild(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 더보기 메뉴
|
||||||
|
document.getElementById('moreBtn').addEventListener('click', function() {
|
||||||
|
KTEventApp.Feedback.showBottomSheet(`
|
||||||
|
<div class="flex flex-col gap-sm">
|
||||||
|
<button class="btn btn-text btn-large btn-full" onclick="editEvent()">
|
||||||
|
<span class="material-icons">edit</span>
|
||||||
|
이벤트 수정
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-text btn-large btn-full" onclick="shareEvent()">
|
||||||
|
<span class="material-icons">share</span>
|
||||||
|
공유하기
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-text btn-large btn-full" onclick="exportData()">
|
||||||
|
<span class="material-icons">download</span>
|
||||||
|
데이터 다운로드
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-text btn-large btn-full text-error" onclick="deleteEvent()">
|
||||||
|
<span class="material-icons">delete</span>
|
||||||
|
이벤트 삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 빠른 작업 버튼들
|
||||||
|
document.getElementById('viewParticipants').addEventListener('click', () => {
|
||||||
|
window.location.href = '14-참여자목록.html?eventId=' + event.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('editEvent').addEventListener('click', editEvent);
|
||||||
|
document.getElementById('shareEvent').addEventListener('click', shareEvent);
|
||||||
|
document.getElementById('exportData').addEventListener('click', exportData);
|
||||||
|
|
||||||
|
// 차트 기간 버튼
|
||||||
|
document.querySelectorAll('[data-period]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
document.querySelectorAll('[data-period]').forEach(b => {
|
||||||
|
b.classList.remove('active');
|
||||||
|
b.classList.add('btn-text');
|
||||||
|
});
|
||||||
|
this.classList.add('active');
|
||||||
|
this.classList.remove('btn-text');
|
||||||
|
|
||||||
|
KTEventApp.Feedback.showToast('차트가 업데이트되었습니다');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 이벤트 수정
|
||||||
|
function editEvent() {
|
||||||
|
KTEventApp.Feedback.showToast('이벤트 수정 기능은 준비 중입니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 공유하기
|
||||||
|
function shareEvent() {
|
||||||
|
KTEventApp.Feedback.showModal({
|
||||||
|
title: '이벤트 공유',
|
||||||
|
content: `
|
||||||
|
<p class="text-body mb-md">이벤트 링크를 공유하세요</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="text" class="form-input" value="https://kt-event.co.kr/evt/${event.id}" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-sm mt-lg">
|
||||||
|
<button class="btn btn-secondary btn-large flex-1" onclick="KTEventApp.Feedback.showToast('카카오톡 공유 준비 중')">
|
||||||
|
카카오톡
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary btn-large flex-1" onclick="copyLink()">
|
||||||
|
링크 복사
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 링크 복사
|
||||||
|
function copyLink() {
|
||||||
|
KTEventApp.Feedback.showToast('링크가 복사되었습니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터 다운로드
|
||||||
|
function exportData() {
|
||||||
|
KTEventApp.Feedback.showModal({
|
||||||
|
title: '데이터 다운로드',
|
||||||
|
content: `
|
||||||
|
<p class="text-body mb-md">다운로드할 데이터 형식을 선택하세요</p>
|
||||||
|
<div class="flex flex-col gap-sm">
|
||||||
|
<button class="btn btn-secondary btn-large btn-full" onclick="download('excel')">
|
||||||
|
<span class="material-icons">table_chart</span>
|
||||||
|
Excel (XLSX)
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary btn-large btn-full" onclick="download('csv')">
|
||||||
|
<span class="material-icons">description</span>
|
||||||
|
CSV
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary btn-large btn-full" onclick="download('pdf')">
|
||||||
|
<span class="material-icons">picture_as_pdf</span>
|
||||||
|
PDF 리포트
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function download(format) {
|
||||||
|
KTEventApp.Feedback.showToast(`${format.toUpperCase()} 파일 다운로드 준비 중...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이벤트 삭제
|
||||||
|
function deleteEvent() {
|
||||||
|
KTEventApp.Feedback.showModal({
|
||||||
|
title: '이벤트 삭제',
|
||||||
|
content: '<p class="text-body">정말로 이벤트를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.</p>',
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: '취소',
|
||||||
|
variant: 'text',
|
||||||
|
onClick: function() {
|
||||||
|
this.closest('.modal-backdrop').remove();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '삭제',
|
||||||
|
variant: 'primary',
|
||||||
|
onClick: function() {
|
||||||
|
this.closest('.modal-backdrop').remove();
|
||||||
|
KTEventApp.Feedback.showToast('이벤트가 삭제되었습니다');
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '05-대시보드.html';
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 실시간 업데이트 시뮬레이션 (5초마다)
|
||||||
|
if (event.status === '진행중') {
|
||||||
|
setInterval(() => {
|
||||||
|
// 참여자 수 업데이트 (랜덤 증가)
|
||||||
|
const increase = Math.floor(Math.random() * 3);
|
||||||
|
if (increase > 0) {
|
||||||
|
event.participants += increase;
|
||||||
|
document.querySelector('#kpiParticipants .kpi-value').textContent =
|
||||||
|
`${KTEventApp.Utils.formatNumber(event.participants)}명`;
|
||||||
|
|
||||||
|
// 작은 애니메이션 효과
|
||||||
|
const kpiCard = document.getElementById('kpiParticipants').firstChild;
|
||||||
|
kpiCard.style.transform = 'scale(1.05)';
|
||||||
|
setTimeout(() => {
|
||||||
|
kpiCard.style.transform = 'scale(1)';
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
400
design/uiux/prototype/14-참여자목록.html
Normal file
400
design/uiux/prototype/14-참여자목록.html
Normal file
@ -0,0 +1,400 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>참여자 목록 - KT AI 이벤트 마케팅</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page page-with-header">
|
||||||
|
<!-- Header -->
|
||||||
|
<div id="header"></div>
|
||||||
|
|
||||||
|
<div class="container" style="max-width: 1200px;">
|
||||||
|
<!-- Search Section -->
|
||||||
|
<section class="mt-lg mb-md">
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="input-with-icon">
|
||||||
|
<span class="material-icons input-icon">search</span>
|
||||||
|
<input type="text" id="searchInput" class="form-input" placeholder="이름 또는 전화번호 검색...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<section class="mb-md">
|
||||||
|
<div class="flex items-center gap-sm flex-wrap">
|
||||||
|
<span class="material-icons text-kt-red">filter_list</span>
|
||||||
|
<select id="channelFilter" class="form-select" style="flex: 1; min-width: 140px;">
|
||||||
|
<option value="all">전체 경로</option>
|
||||||
|
<option value="uriTV">우리동네TV</option>
|
||||||
|
<option value="ringoBiz">링고비즈</option>
|
||||||
|
<option value="sns">SNS</option>
|
||||||
|
</select>
|
||||||
|
<select id="statusFilter" class="form-select" style="flex: 1; min-width: 120px;">
|
||||||
|
<option value="all">전체</option>
|
||||||
|
<option value="waiting">당첨 대기</option>
|
||||||
|
<option value="winner">당첨</option>
|
||||||
|
<option value="loser">미당첨</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Total Count & Drawing Button -->
|
||||||
|
<section class="mb-md">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-headline">총 <span id="totalCount">128</span>명 참여</p>
|
||||||
|
<button id="drawingBtn" class="btn btn-primary btn-medium">
|
||||||
|
<span class="material-icons">casino</span>
|
||||||
|
당첨자 추첨
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Participant List -->
|
||||||
|
<section id="participantList" class="mb-lg">
|
||||||
|
<!-- Participants will be dynamically loaded here -->
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<section class="mb-md">
|
||||||
|
<div class="flex items-center justify-center gap-sm" id="pagination">
|
||||||
|
<!-- Pagination will be dynamically loaded here -->
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Excel Download Button (Desktop only) -->
|
||||||
|
<section class="mb-2xl desktop-only">
|
||||||
|
<button id="downloadBtn" class="btn btn-secondary btn-large">
|
||||||
|
<span class="material-icons">download</span>
|
||||||
|
엑셀 다운로드
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Navigation -->
|
||||||
|
<div id="bottomNav"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="common.js"></script>
|
||||||
|
<script>
|
||||||
|
// 로그인 확인
|
||||||
|
KTEventApp.Session.requireAuth();
|
||||||
|
|
||||||
|
// Header 생성
|
||||||
|
const header = KTEventApp.Navigation.createHeader({
|
||||||
|
title: '참여자 목록',
|
||||||
|
showBack: true,
|
||||||
|
showMenu: false,
|
||||||
|
showProfile: true
|
||||||
|
});
|
||||||
|
document.getElementById('header').appendChild(header);
|
||||||
|
|
||||||
|
// Bottom Navigation 생성
|
||||||
|
const bottomNav = KTEventApp.Navigation.createBottomNav('events');
|
||||||
|
document.getElementById('bottomNav').appendChild(bottomNav);
|
||||||
|
|
||||||
|
// Mock 참여자 데이터
|
||||||
|
const participants = [
|
||||||
|
{
|
||||||
|
id: '0001',
|
||||||
|
name: '김**',
|
||||||
|
phone: '010-****-1234',
|
||||||
|
channel: 'SNS (Instagram)',
|
||||||
|
channelType: 'sns',
|
||||||
|
date: '2025-11-02 14:23',
|
||||||
|
status: 'waiting'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '0002',
|
||||||
|
name: '이**',
|
||||||
|
phone: '010-****-5678',
|
||||||
|
channel: '우리동네TV',
|
||||||
|
channelType: 'uriTV',
|
||||||
|
date: '2025-11-02 15:45',
|
||||||
|
status: 'waiting'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '0003',
|
||||||
|
name: '박**',
|
||||||
|
phone: '010-****-9012',
|
||||||
|
channel: '링고비즈',
|
||||||
|
channelType: 'ringoBiz',
|
||||||
|
date: '2025-11-02 16:12',
|
||||||
|
status: 'waiting'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '0004',
|
||||||
|
name: '최**',
|
||||||
|
phone: '010-****-3456',
|
||||||
|
channel: 'SNS (Naver)',
|
||||||
|
channelType: 'sns',
|
||||||
|
date: '2025-11-02 17:30',
|
||||||
|
status: 'waiting'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '0005',
|
||||||
|
name: '정**',
|
||||||
|
phone: '010-****-7890',
|
||||||
|
channel: '우리동네TV',
|
||||||
|
channelType: 'uriTV',
|
||||||
|
date: '2025-11-02 18:15',
|
||||||
|
status: 'waiting'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
let currentPage = 1;
|
||||||
|
const itemsPerPage = 20;
|
||||||
|
let filteredParticipants = [...participants];
|
||||||
|
|
||||||
|
// 참여자 카드 생성
|
||||||
|
function createParticipantCard(participant) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'card';
|
||||||
|
card.style.cursor = 'pointer';
|
||||||
|
|
||||||
|
const statusText = participant.status === 'waiting' ? '당첨 대기' :
|
||||||
|
participant.status === 'winner' ? '당첨' : '미당첨';
|
||||||
|
|
||||||
|
const statusColor = participant.status === 'waiting' ? 'var(--color-gray-600)' :
|
||||||
|
participant.status === 'winner' ? 'var(--color-success)' : 'var(--color-error)';
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="flex items-start justify-between mb-sm">
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-caption text-secondary mb-xs">#${participant.id}</p>
|
||||||
|
<h3 class="text-headline mb-xs">${participant.name}</h3>
|
||||||
|
<p class="text-body-small text-secondary">${participant.phone}</p>
|
||||||
|
</div>
|
||||||
|
<span class="event-card-badge" style="background: ${statusColor}; color: white;">
|
||||||
|
${statusText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t pt-sm mt-sm">
|
||||||
|
<div class="flex items-center justify-between text-body-small">
|
||||||
|
<span class="text-secondary">참여 경로</span>
|
||||||
|
<span class="text-semibold">${participant.channel}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between text-body-small mt-xs">
|
||||||
|
<span class="text-secondary">참여 일시</span>
|
||||||
|
<span>${participant.date}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
card.addEventListener('click', () => {
|
||||||
|
showParticipantDetail(participant);
|
||||||
|
});
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 참여자 목록 렌더링
|
||||||
|
function renderParticipantList() {
|
||||||
|
const participantList = document.getElementById('participantList');
|
||||||
|
participantList.innerHTML = '';
|
||||||
|
|
||||||
|
if (filteredParticipants.length === 0) {
|
||||||
|
participantList.innerHTML = `
|
||||||
|
<div class="text-center py-2xl">
|
||||||
|
<span class="material-icons" style="font-size: 64px; color: var(--color-gray-300);">people_outline</span>
|
||||||
|
<p class="text-body text-secondary mt-md">검색 결과가 없습니다</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||||
|
const endIndex = Math.min(startIndex + itemsPerPage, filteredParticipants.length);
|
||||||
|
const pageParticipants = filteredParticipants.slice(startIndex, endIndex);
|
||||||
|
|
||||||
|
pageParticipants.forEach(participant => {
|
||||||
|
participantList.appendChild(createParticipantCard(participant));
|
||||||
|
});
|
||||||
|
|
||||||
|
renderPagination();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 페이지네이션 렌더링
|
||||||
|
function renderPagination() {
|
||||||
|
const pagination = document.getElementById('pagination');
|
||||||
|
pagination.innerHTML = '';
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(filteredParticipants.length / itemsPerPage);
|
||||||
|
|
||||||
|
if (totalPages <= 1) return;
|
||||||
|
|
||||||
|
// 이전 버튼
|
||||||
|
const prevBtn = document.createElement('button');
|
||||||
|
prevBtn.className = 'btn btn-text btn-small';
|
||||||
|
prevBtn.innerHTML = '<span class="material-icons">chevron_left</span>';
|
||||||
|
prevBtn.disabled = currentPage === 1;
|
||||||
|
prevBtn.addEventListener('click', () => {
|
||||||
|
if (currentPage > 1) {
|
||||||
|
currentPage--;
|
||||||
|
renderParticipantList();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
pagination.appendChild(prevBtn);
|
||||||
|
|
||||||
|
// 페이지 번호
|
||||||
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
|
if (i === 1 || i === totalPages || (i >= currentPage - 1 && i <= currentPage + 1)) {
|
||||||
|
const pageBtn = document.createElement('button');
|
||||||
|
pageBtn.className = `btn ${i === currentPage ? 'btn-primary' : 'btn-text'} btn-small`;
|
||||||
|
pageBtn.textContent = i;
|
||||||
|
pageBtn.addEventListener('click', () => {
|
||||||
|
currentPage = i;
|
||||||
|
renderParticipantList();
|
||||||
|
});
|
||||||
|
pagination.appendChild(pageBtn);
|
||||||
|
} else if (i === currentPage - 2 || i === currentPage + 2) {
|
||||||
|
const ellipsis = document.createElement('span');
|
||||||
|
ellipsis.textContent = '...';
|
||||||
|
ellipsis.className = 'text-secondary px-sm';
|
||||||
|
pagination.appendChild(ellipsis);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다음 버튼
|
||||||
|
const nextBtn = document.createElement('button');
|
||||||
|
nextBtn.className = 'btn btn-text btn-small';
|
||||||
|
nextBtn.innerHTML = '<span class="material-icons">chevron_right</span>';
|
||||||
|
nextBtn.disabled = currentPage === totalPages;
|
||||||
|
nextBtn.addEventListener('click', () => {
|
||||||
|
if (currentPage < totalPages) {
|
||||||
|
currentPage++;
|
||||||
|
renderParticipantList();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
pagination.appendChild(nextBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 필터 적용
|
||||||
|
function applyFilters() {
|
||||||
|
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
|
||||||
|
const channelFilter = document.getElementById('channelFilter').value;
|
||||||
|
const statusFilter = document.getElementById('statusFilter').value;
|
||||||
|
|
||||||
|
filteredParticipants = participants.filter(participant => {
|
||||||
|
const matchesSearch = participant.name.includes(searchTerm) ||
|
||||||
|
participant.phone.includes(searchTerm);
|
||||||
|
const matchesChannel = channelFilter === 'all' || participant.channelType === channelFilter;
|
||||||
|
const matchesStatus = statusFilter === 'all' || participant.status === statusFilter;
|
||||||
|
|
||||||
|
return matchesSearch && matchesChannel && matchesStatus;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('totalCount').textContent = filteredParticipants.length;
|
||||||
|
currentPage = 1;
|
||||||
|
renderParticipantList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 참여자 상세 정보 모달
|
||||||
|
function showParticipantDetail(participant) {
|
||||||
|
KTEventApp.Feedback.showModal({
|
||||||
|
title: '참여자 상세 정보',
|
||||||
|
content: `
|
||||||
|
<div class="p-md">
|
||||||
|
<div class="mb-md">
|
||||||
|
<p class="text-caption text-secondary mb-xs">응모번호</p>
|
||||||
|
<p class="text-headline">#${participant.id}</p>
|
||||||
|
</div>
|
||||||
|
<div class="mb-md">
|
||||||
|
<p class="text-caption text-secondary mb-xs">이름</p>
|
||||||
|
<p class="text-body">${participant.name}</p>
|
||||||
|
</div>
|
||||||
|
<div class="mb-md">
|
||||||
|
<p class="text-caption text-secondary mb-xs">전화번호</p>
|
||||||
|
<p class="text-body">${participant.phone}</p>
|
||||||
|
</div>
|
||||||
|
<div class="mb-md">
|
||||||
|
<p class="text-caption text-secondary mb-xs">참여 경로</p>
|
||||||
|
<p class="text-body">${participant.channel}</p>
|
||||||
|
</div>
|
||||||
|
<div class="mb-md">
|
||||||
|
<p class="text-caption text-secondary mb-xs">참여 일시</p>
|
||||||
|
<p class="text-body">${participant.date}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-caption text-secondary mb-xs">당첨 여부</p>
|
||||||
|
<p class="text-body">${participant.status === 'waiting' ? '당첨 대기' : participant.status === 'winner' ? '당첨' : '미당첨'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: '확인',
|
||||||
|
variant: 'primary',
|
||||||
|
onClick: function() {
|
||||||
|
this.closest('.modal-backdrop').remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이벤트 리스너
|
||||||
|
document.getElementById('searchInput').addEventListener('input', KTEventApp.Utils.debounce(applyFilters, 300));
|
||||||
|
document.getElementById('channelFilter').addEventListener('change', applyFilters);
|
||||||
|
document.getElementById('statusFilter').addEventListener('change', applyFilters);
|
||||||
|
|
||||||
|
// 엑셀 다운로드 버튼
|
||||||
|
document.getElementById('downloadBtn')?.addEventListener('click', function() {
|
||||||
|
KTEventApp.Feedback.showToast('참여자 목록을 다운로드합니다');
|
||||||
|
// 실제로는 엑셀 다운로드 로직 구현
|
||||||
|
});
|
||||||
|
|
||||||
|
// 당첨자 추첨 버튼
|
||||||
|
document.getElementById('drawingBtn').addEventListener('click', function() {
|
||||||
|
// 이벤트가 종료되었는지 확인 (실제로는 이벤트 상태 체크)
|
||||||
|
const eventEnded = true; // Mock data
|
||||||
|
|
||||||
|
if (!eventEnded) {
|
||||||
|
KTEventApp.Feedback.showModal({
|
||||||
|
title: '추첨 불가',
|
||||||
|
content: '<p class="text-body text-center">이벤트가 종료된 후 추첨이 가능합니다.</p>',
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: '확인',
|
||||||
|
variant: 'primary',
|
||||||
|
onClick: function() {
|
||||||
|
this.closest('.modal-backdrop').remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 참여자가 있는지 확인
|
||||||
|
if (filteredParticipants.length === 0) {
|
||||||
|
KTEventApp.Feedback.showModal({
|
||||||
|
title: '추첨 불가',
|
||||||
|
content: '<p class="text-body text-center">참여자가 없어 추첨할 수 없습니다.</p>',
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: '확인',
|
||||||
|
variant: 'primary',
|
||||||
|
onClick: function() {
|
||||||
|
this.closest('.modal-backdrop').remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 당첨자 추첨 화면으로 이동
|
||||||
|
window.location.href = '16-당첨자추첨.html';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 초기 렌더링
|
||||||
|
renderParticipantList();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
309
design/uiux/prototype/15-이벤트참여.html
Normal file
309
design/uiux/prototype/15-이벤트참여.html
Normal file
@ -0,0 +1,309 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>이벤트 참여 - KT AI 이벤트 마케팅</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background: var(--color-gray-50);
|
||||||
|
}
|
||||||
|
.participation-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
.event-image {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
.success-animation {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: white;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
.success-animation.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.checkmark {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-success);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
animation: scaleIn 0.5s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes scaleIn {
|
||||||
|
0% {
|
||||||
|
transform: scale(0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="participation-container">
|
||||||
|
<!-- Event Image -->
|
||||||
|
<div class="event-image">
|
||||||
|
<span class="material-icons" style="font-size: 64px; margin-bottom: 16px;">celebration</span>
|
||||||
|
<h2 class="text-title-large" id="eventTitle">신규고객 유치 이벤트</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Event Info -->
|
||||||
|
<section class="mb-lg">
|
||||||
|
<div class="flex items-center gap-sm mb-md">
|
||||||
|
<span class="material-icons text-kt-red">card_giftcard</span>
|
||||||
|
<div>
|
||||||
|
<p class="text-caption text-secondary">경품</p>
|
||||||
|
<p class="text-headline" id="eventPrize">커피 쿠폰</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-sm mb-md">
|
||||||
|
<span class="material-icons text-kt-red">calendar_today</span>
|
||||||
|
<div>
|
||||||
|
<p class="text-caption text-secondary">기간</p>
|
||||||
|
<p class="text-body" id="eventPeriod">2025-11-01 ~ 2025-11-15</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<div class="border-t mb-lg"></div>
|
||||||
|
|
||||||
|
<!-- Participation Form -->
|
||||||
|
<section class="mb-lg">
|
||||||
|
<h3 class="text-title mb-md">참여하기</h3>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="participantName" class="form-label">이름 <span class="text-kt-red">*</span></label>
|
||||||
|
<input type="text" id="participantName" class="form-input" placeholder="이름을 입력하세요" required>
|
||||||
|
<span class="form-error" id="nameError"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="participantPhone" class="form-label">전화번호 <span class="text-kt-red">*</span></label>
|
||||||
|
<input type="tel" id="participantPhone" class="form-input" placeholder="010-0000-0000" required>
|
||||||
|
<span class="form-error" id="phoneError"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" id="agreePrivacy" class="form-check-input" required>
|
||||||
|
<label for="agreePrivacy" class="form-check-label">
|
||||||
|
개인정보 수집 및 이용에 동의합니다 <span class="text-kt-red">(필수)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-text btn-small mt-xs" id="viewPrivacy">
|
||||||
|
전문보기
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Submit Button -->
|
||||||
|
<section class="mb-lg">
|
||||||
|
<button id="submitBtn" class="btn btn-primary btn-large btn-full">
|
||||||
|
참여하기
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Participant Count -->
|
||||||
|
<section class="text-center">
|
||||||
|
<p class="text-body-small text-secondary">
|
||||||
|
참여자: <strong id="participantCount">128</strong>명
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success Animation -->
|
||||||
|
<div class="success-animation" id="successAnimation">
|
||||||
|
<div class="checkmark">
|
||||||
|
<span class="material-icons" style="font-size: 48px; color: white;">check</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-title-large mb-sm">참여가 완료되었습니다!</h2>
|
||||||
|
<p class="text-body text-secondary mb-lg">
|
||||||
|
<span id="participantNameDisplay">홍길동</span>님의 행운을 기원합니다!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="border-t pt-lg mb-lg" style="width: 80%; max-width: 400px;">
|
||||||
|
<p class="text-body-small text-secondary text-center mb-xs">당첨자 발표</p>
|
||||||
|
<p class="text-headline text-center" id="announceDate">2025-11-16 (월)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="confirmBtn" class="btn btn-primary btn-large">확인</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="common.js"></script>
|
||||||
|
<script>
|
||||||
|
// 이벤트 정보 표시
|
||||||
|
const eventData = {
|
||||||
|
title: '신규고객 유치 이벤트',
|
||||||
|
prize: '커피 쿠폰',
|
||||||
|
startDate: '2025-11-01',
|
||||||
|
endDate: '2025-11-15',
|
||||||
|
announceDate: '2025-11-16 (월)',
|
||||||
|
participantCount: 128
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('eventTitle').textContent = eventData.title;
|
||||||
|
document.getElementById('eventPrize').textContent = eventData.prize;
|
||||||
|
document.getElementById('eventPeriod').textContent = `${eventData.startDate} ~ ${eventData.endDate}`;
|
||||||
|
document.getElementById('participantCount').textContent = eventData.participantCount;
|
||||||
|
document.getElementById('announceDate').textContent = eventData.announceDate;
|
||||||
|
|
||||||
|
// 전화번호 자동 포맷팅
|
||||||
|
document.getElementById('participantPhone').addEventListener('input', function(e) {
|
||||||
|
e.target.value = KTEventApp.Utils.formatPhoneNumber(e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 개인정보 처리방침 보기
|
||||||
|
document.getElementById('viewPrivacy').addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
KTEventApp.Feedback.showModal({
|
||||||
|
title: '개인정보 처리방침',
|
||||||
|
content: `
|
||||||
|
<div class="p-md" style="max-height: 400px; overflow-y: auto;">
|
||||||
|
<h4 class="text-headline mb-md">개인정보 수집 및 이용 안내</h4>
|
||||||
|
<p class="text-body-small mb-lg">
|
||||||
|
<strong>1. 수집 항목</strong><br>
|
||||||
|
- 이름, 전화번호
|
||||||
|
</p>
|
||||||
|
<p class="text-body-small mb-lg">
|
||||||
|
<strong>2. 이용 목적</strong><br>
|
||||||
|
- 이벤트 참여 확인<br>
|
||||||
|
- 당첨자 발표 및 경품 제공<br>
|
||||||
|
- 이벤트 관련 안내
|
||||||
|
</p>
|
||||||
|
<p class="text-body-small mb-lg">
|
||||||
|
<strong>3. 보유 기간</strong><br>
|
||||||
|
- 이벤트 종료 후 6개월
|
||||||
|
</p>
|
||||||
|
<p class="text-body-small">
|
||||||
|
<strong>4. 동의 거부 권리</strong><br>
|
||||||
|
개인정보 수집 및 이용에 동의하지 않을 권리가 있으나, 동의하지 않을 경우 이벤트 참여가 제한됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: '확인',
|
||||||
|
variant: 'primary',
|
||||||
|
onClick: function() {
|
||||||
|
this.closest('.modal-backdrop').remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 폼 검증
|
||||||
|
function validateForm() {
|
||||||
|
let isValid = true;
|
||||||
|
|
||||||
|
// 이름 검증
|
||||||
|
const name = document.getElementById('participantName').value.trim();
|
||||||
|
const nameError = document.getElementById('nameError');
|
||||||
|
if (name.length < 2) {
|
||||||
|
nameError.textContent = '이름은 2자 이상 입력해주세요';
|
||||||
|
isValid = false;
|
||||||
|
} else {
|
||||||
|
nameError.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전화번호 검증
|
||||||
|
const phone = document.getElementById('participantPhone').value;
|
||||||
|
const phoneError = document.getElementById('phoneError');
|
||||||
|
const phonePattern = /^010-\d{4}-\d{4}$/;
|
||||||
|
if (!phonePattern.test(phone)) {
|
||||||
|
phoneError.textContent = '올바른 전화번호 형식이 아닙니다 (010-0000-0000)';
|
||||||
|
isValid = false;
|
||||||
|
} else {
|
||||||
|
phoneError.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 개인정보 동의 검증
|
||||||
|
const agreePrivacy = document.getElementById('agreePrivacy').checked;
|
||||||
|
if (!agreePrivacy) {
|
||||||
|
KTEventApp.Feedback.showToast('개인정보 수집 및 이용에 동의해주세요');
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 중복 참여 체크
|
||||||
|
function checkDuplicateParticipation(phone) {
|
||||||
|
// 실제로는 서버에서 체크
|
||||||
|
const participatedPhones = KTEventApp.Utils.getFromStorage('participated_phones') || [];
|
||||||
|
return participatedPhones.includes(phone);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 참여 처리
|
||||||
|
document.getElementById('submitBtn').addEventListener('click', function() {
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = document.getElementById('participantName').value.trim();
|
||||||
|
const phone = document.getElementById('participantPhone').value;
|
||||||
|
|
||||||
|
// 중복 참여 체크
|
||||||
|
if (checkDuplicateParticipation(phone)) {
|
||||||
|
KTEventApp.Feedback.showToast('이미 참여하셨습니다');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로딩 표시
|
||||||
|
const button = this;
|
||||||
|
button.disabled = true;
|
||||||
|
button.innerHTML = '<span class="spinner-small"></span> 참여 중...';
|
||||||
|
|
||||||
|
// 참여 처리 시뮬레이션
|
||||||
|
setTimeout(() => {
|
||||||
|
// 참여 정보 저장
|
||||||
|
const participatedPhones = KTEventApp.Utils.getFromStorage('participated_phones') || [];
|
||||||
|
participatedPhones.push(phone);
|
||||||
|
KTEventApp.Utils.saveToStorage('participated_phones', participatedPhones);
|
||||||
|
|
||||||
|
// 성공 애니메이션 표시
|
||||||
|
document.getElementById('participantNameDisplay').textContent = name;
|
||||||
|
document.getElementById('successAnimation').classList.add('active');
|
||||||
|
|
||||||
|
// 버튼 복원
|
||||||
|
button.disabled = false;
|
||||||
|
button.innerHTML = '참여하기';
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 확인 버튼 (성공 화면 닫기)
|
||||||
|
document.getElementById('confirmBtn').addEventListener('click', function() {
|
||||||
|
// 실제로는 이벤트 상세 페이지나 메인으로 이동
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
536
design/uiux/prototype/16-당첨자추첨.html
Normal file
536
design/uiux/prototype/16-당첨자추첨.html
Normal file
@ -0,0 +1,536 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>당첨자 추첨 - KT AI 이벤트 마케팅</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background: var(--color-gray-50);
|
||||||
|
}
|
||||||
|
.drawing-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
.winner-count-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
.winner-count-btn {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border: 1px solid var(--color-gray-300);
|
||||||
|
background: white;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 24px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
.winner-count-btn:hover {
|
||||||
|
background: var(--color-gray-50);
|
||||||
|
}
|
||||||
|
.winner-count-display {
|
||||||
|
width: 80px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
.drawing-animation {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
.drawing-animation.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.slot-machine {
|
||||||
|
font-size: 64px;
|
||||||
|
color: white;
|
||||||
|
animation: spin 0.5s infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
0%, 100% { transform: rotate(0deg); }
|
||||||
|
50% { transform: rotate(180deg); }
|
||||||
|
}
|
||||||
|
.winner-card {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 60px;
|
||||||
|
}
|
||||||
|
.rank-badge {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.rank-1 { background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%); color: white; }
|
||||||
|
.rank-2 { background: linear-gradient(135deg, #C0C0C0 0%, #A8A8A8 100%); color: white; }
|
||||||
|
.rank-3 { background: linear-gradient(135deg, #CD7F32 0%, #B87333 100%); color: white; }
|
||||||
|
.rank-other { background: var(--color-gray-200); color: var(--color-text-secondary); }
|
||||||
|
.results-view {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.results-view.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="drawing-container">
|
||||||
|
<!-- Setup View (Before Drawing) -->
|
||||||
|
<div id="setupView">
|
||||||
|
<!-- Event Info -->
|
||||||
|
<section class="mb-lg">
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex items-center gap-sm mb-md">
|
||||||
|
<span class="material-icons text-kt-red">event_note</span>
|
||||||
|
<h3 class="text-headline">이벤트 정보</h3>
|
||||||
|
</div>
|
||||||
|
<div class="mb-sm">
|
||||||
|
<p class="text-caption text-secondary mb-xs">이벤트명</p>
|
||||||
|
<p class="text-body" id="eventName">신규고객 유치 이벤트</p>
|
||||||
|
</div>
|
||||||
|
<div class="mb-sm">
|
||||||
|
<p class="text-caption text-secondary mb-xs">총 참여자</p>
|
||||||
|
<p class="text-headline" id="totalParticipants">127명</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-caption text-secondary mb-xs">추첨 상태</p>
|
||||||
|
<p class="text-body" id="drawingStatus">추첨 전</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Drawing Settings -->
|
||||||
|
<section class="mb-lg">
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex items-center gap-sm mb-md">
|
||||||
|
<span class="material-icons text-kt-red">tune</span>
|
||||||
|
<h3 class="text-headline">추첨 설정</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">당첨 인원</label>
|
||||||
|
<div class="winner-count-control">
|
||||||
|
<button class="winner-count-btn" id="decreaseBtn">-</button>
|
||||||
|
<div class="winner-count-display" id="winnerCount">5</div>
|
||||||
|
<button class="winner-count-btn" id="increaseBtn">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" id="storeBonus" class="form-check-input">
|
||||||
|
<label for="storeBonus" class="form-check-label">
|
||||||
|
매장 방문 고객 가산점 (가중치: 1.5배)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-md p-md" style="background: var(--color-gray-50); border-radius: var(--radius-sm);">
|
||||||
|
<p class="text-body-small text-secondary mb-xs">
|
||||||
|
<span class="material-icons" style="font-size: 16px; vertical-align: middle;">info</span>
|
||||||
|
추첨 방식
|
||||||
|
</p>
|
||||||
|
<p class="text-body-small">• 난수 기반 무작위 추첨</p>
|
||||||
|
<p class="text-body-small">• 모든 추첨 과정은 자동 기록됩니다</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Drawing Start Button -->
|
||||||
|
<section class="mb-lg">
|
||||||
|
<button id="startDrawingBtn" class="btn btn-primary btn-large btn-full">
|
||||||
|
<span class="material-icons">casino</span>
|
||||||
|
추첨 시작
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Drawing History -->
|
||||||
|
<section class="mb-lg">
|
||||||
|
<h3 class="text-headline mb-md">📜 추첨 이력 (최근 3건)</h3>
|
||||||
|
<div id="historyList">
|
||||||
|
<!-- History items will be dynamically loaded -->
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results View (After Drawing) -->
|
||||||
|
<div id="resultsView" class="results-view">
|
||||||
|
<!-- Results Header -->
|
||||||
|
<section class="mb-lg text-center">
|
||||||
|
<h2 class="text-title-large mb-sm">🎉 추첨 완료!</h2>
|
||||||
|
<p class="text-headline">총 <span id="totalCount">127</span>명 중 <span id="winnerCountDisplay">5</span>명 당첨</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Winner List -->
|
||||||
|
<section class="mb-lg">
|
||||||
|
<h3 class="text-headline mb-md">🏆 당첨자 목록</h3>
|
||||||
|
<div id="winnerList">
|
||||||
|
<!-- Winners will be dynamically loaded -->
|
||||||
|
</div>
|
||||||
|
<div class="mt-md text-center">
|
||||||
|
<p class="text-body-small text-secondary">🌟 매장 방문 고객 가산점 적용</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<section class="mb-lg">
|
||||||
|
<div class="grid grid-cols-2 gap-sm mb-sm">
|
||||||
|
<button id="excelBtn" class="btn btn-secondary btn-large">
|
||||||
|
<span class="material-icons">download</span>
|
||||||
|
엑셀다운로드
|
||||||
|
</button>
|
||||||
|
<button id="redrawBtn" class="btn btn-text btn-large">
|
||||||
|
<span class="material-icons">refresh</span>
|
||||||
|
재추첨
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button id="notifyBtn" class="btn btn-primary btn-large btn-full">
|
||||||
|
당첨자에게 알림 전송
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Back to Events -->
|
||||||
|
<section class="mb-lg">
|
||||||
|
<button id="backToEventsBtn" class="btn btn-text btn-large btn-full">
|
||||||
|
이벤트 목록으로
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Drawing Animation -->
|
||||||
|
<div id="drawingAnimation" class="drawing-animation">
|
||||||
|
<div class="slot-machine mb-lg">🎰</div>
|
||||||
|
<h2 class="text-title-large mb-sm" style="color: white;" id="animationText">추첨 중...</h2>
|
||||||
|
<p class="text-body" style="color: rgba(255,255,255,0.7);" id="animationSubtext">난수 생성 중</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="common.js"></script>
|
||||||
|
<script>
|
||||||
|
// Event data
|
||||||
|
const eventData = {
|
||||||
|
name: '신규고객 유치 이벤트',
|
||||||
|
totalParticipants: 127,
|
||||||
|
participants: [
|
||||||
|
{ id: '00042', name: '김**', phone: '010-****-1234', channel: '우리동네TV', hasBonus: true },
|
||||||
|
{ id: '00089', name: '이**', phone: '010-****-5678', channel: 'SNS', hasBonus: false },
|
||||||
|
{ id: '00103', name: '박**', phone: '010-****-9012', channel: '링고비즈', hasBonus: true },
|
||||||
|
{ id: '00012', name: '최**', phone: '010-****-3456', channel: 'SNS', hasBonus: false },
|
||||||
|
{ id: '00067', name: '정**', phone: '010-****-7890', channel: '우리동네TV', hasBonus: false },
|
||||||
|
{ id: '00025', name: '강**', phone: '010-****-2468', channel: '링고비즈', hasBonus: true },
|
||||||
|
{ id: '00078', name: '조**', phone: '010-****-1357', channel: 'SNS', hasBonus: false }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Drawing history data
|
||||||
|
const drawingHistory = [
|
||||||
|
{ date: '2025-01-15 14:30', winnerCount: 5, isRedraw: false },
|
||||||
|
{ date: '2025-01-15 14:25', winnerCount: 5, isRedraw: true }
|
||||||
|
];
|
||||||
|
|
||||||
|
// State
|
||||||
|
let winnerCount = 5;
|
||||||
|
let storeBonus = false;
|
||||||
|
let winners = [];
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
document.getElementById('eventName').textContent = eventData.name;
|
||||||
|
document.getElementById('totalParticipants').textContent = `${eventData.totalParticipants}명`;
|
||||||
|
|
||||||
|
// Winner count controls
|
||||||
|
document.getElementById('decreaseBtn').addEventListener('click', function() {
|
||||||
|
if (winnerCount > 1) {
|
||||||
|
winnerCount--;
|
||||||
|
document.getElementById('winnerCount').textContent = winnerCount;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('increaseBtn').addEventListener('click', function() {
|
||||||
|
if (winnerCount < 100 && winnerCount < eventData.totalParticipants) {
|
||||||
|
winnerCount++;
|
||||||
|
document.getElementById('winnerCount').textContent = winnerCount;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store bonus toggle
|
||||||
|
document.getElementById('storeBonus').addEventListener('change', function() {
|
||||||
|
storeBonus = this.checked;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start drawing button
|
||||||
|
document.getElementById('startDrawingBtn').addEventListener('click', function() {
|
||||||
|
KTEventApp.Feedback.showModal({
|
||||||
|
title: '추첨 확인',
|
||||||
|
content: `<p class="text-body text-center">총 ${eventData.totalParticipants}명 중 ${winnerCount}명을 추첨하시겠습니까?</p>`,
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: '취소',
|
||||||
|
variant: 'text',
|
||||||
|
onClick: function() {
|
||||||
|
this.closest('.modal-backdrop').remove();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '확인',
|
||||||
|
variant: 'primary',
|
||||||
|
onClick: function() {
|
||||||
|
this.closest('.modal-backdrop').remove();
|
||||||
|
executeDrawing();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Execute drawing
|
||||||
|
function executeDrawing() {
|
||||||
|
const animation = document.getElementById('drawingAnimation');
|
||||||
|
const animationText = document.getElementById('animationText');
|
||||||
|
const animationSubtext = document.getElementById('animationSubtext');
|
||||||
|
|
||||||
|
animation.classList.add('active');
|
||||||
|
|
||||||
|
// Phase 1: 난수 생성 중 (1 second)
|
||||||
|
setTimeout(() => {
|
||||||
|
animationText.textContent = '당첨자 선정 중...';
|
||||||
|
animationSubtext.textContent = '공정한 추첨을 진행하고 있습니다';
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// Phase 2: 완료 (2 seconds)
|
||||||
|
setTimeout(() => {
|
||||||
|
animationText.textContent = '완료!';
|
||||||
|
animationSubtext.textContent = '추첨이 완료되었습니다';
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
// Phase 3: Show results (3 seconds)
|
||||||
|
setTimeout(() => {
|
||||||
|
animation.classList.remove('active');
|
||||||
|
|
||||||
|
// Select random winners
|
||||||
|
const shuffled = [...eventData.participants].sort(() => Math.random() - 0.5);
|
||||||
|
winners = shuffled.slice(0, winnerCount);
|
||||||
|
|
||||||
|
// Show results
|
||||||
|
showResults();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show results
|
||||||
|
function showResults() {
|
||||||
|
document.getElementById('setupView').style.display = 'none';
|
||||||
|
document.getElementById('resultsView').classList.add('active');
|
||||||
|
|
||||||
|
document.getElementById('totalCount').textContent = eventData.totalParticipants;
|
||||||
|
document.getElementById('winnerCountDisplay').textContent = winnerCount;
|
||||||
|
|
||||||
|
renderWinners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render winners
|
||||||
|
function renderWinners() {
|
||||||
|
const winnerList = document.getElementById('winnerList');
|
||||||
|
winnerList.innerHTML = '';
|
||||||
|
|
||||||
|
winners.forEach((winner, index) => {
|
||||||
|
const rank = index + 1;
|
||||||
|
const rankClass = rank === 1 ? 'rank-1' : rank === 2 ? 'rank-2' : rank === 3 ? 'rank-3' : 'rank-other';
|
||||||
|
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'card mb-sm winner-card';
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="rank-badge ${rankClass}">${rank}위</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-caption text-secondary mb-xs">응모번호: #${winner.id}</p>
|
||||||
|
<p class="text-headline mb-xs">${winner.name} (${winner.phone})</p>
|
||||||
|
<p class="text-body-small text-secondary">
|
||||||
|
참여: ${winner.channel} ${winner.hasBonus && storeBonus ? '🌟' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
winnerList.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Excel download button
|
||||||
|
document.getElementById('excelBtn')?.addEventListener('click', function() {
|
||||||
|
const now = new Date();
|
||||||
|
const dateStr = `${now.getFullYear()}${String(now.getMonth()+1).padStart(2,'0')}${String(now.getDate()).padStart(2,'0')}_${String(now.getHours()).padStart(2,'0')}${String(now.getMinutes()).padStart(2,'0')}`;
|
||||||
|
const filename = `당첨자목록_${eventData.name}_${dateStr}.xlsx`;
|
||||||
|
|
||||||
|
KTEventApp.Feedback.showToast(`${filename} 다운로드를 시작합니다`);
|
||||||
|
// 실제로는 엑셀 파일 생성 및 다운로드 로직
|
||||||
|
});
|
||||||
|
|
||||||
|
// Redraw button
|
||||||
|
document.getElementById('redrawBtn')?.addEventListener('click', function() {
|
||||||
|
KTEventApp.Feedback.showModal({
|
||||||
|
title: '재추첨 확인',
|
||||||
|
content: `
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-body mb-md">재추첨 시 현재 당첨자 정보가 변경됩니다.</p>
|
||||||
|
<p class="text-body mb-md">계속하시겠습니까?</p>
|
||||||
|
<p class="text-body-small text-secondary">이전 추첨 이력은 보관됩니다</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: '취소',
|
||||||
|
variant: 'text',
|
||||||
|
onClick: function() {
|
||||||
|
this.closest('.modal-backdrop').remove();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '재추첨',
|
||||||
|
variant: 'primary',
|
||||||
|
onClick: function() {
|
||||||
|
this.closest('.modal-backdrop').remove();
|
||||||
|
|
||||||
|
// Add to history
|
||||||
|
drawingHistory.unshift({
|
||||||
|
date: new Date().toLocaleString('ko-KR'),
|
||||||
|
winnerCount: winnerCount,
|
||||||
|
isRedraw: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Execute new drawing
|
||||||
|
document.getElementById('resultsView').classList.remove('active');
|
||||||
|
document.getElementById('setupView').style.display = 'block';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
executeDrawing();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify button
|
||||||
|
document.getElementById('notifyBtn')?.addEventListener('click', function() {
|
||||||
|
const cost = winnerCount * 100;
|
||||||
|
|
||||||
|
KTEventApp.Feedback.showModal({
|
||||||
|
title: '알림 전송',
|
||||||
|
content: `
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-body mb-md">${winnerCount}명의 당첨자에게 SMS 알림을 전송하시겠습니까?</p>
|
||||||
|
<p class="text-body-small text-secondary">예상 비용: ${KTEventApp.Utils.formatNumber(cost)}원 (100원/건)</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: '취소',
|
||||||
|
variant: 'text',
|
||||||
|
onClick: function() {
|
||||||
|
this.closest('.modal-backdrop').remove();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '전송',
|
||||||
|
variant: 'primary',
|
||||||
|
onClick: function() {
|
||||||
|
this.closest('.modal-backdrop').remove();
|
||||||
|
|
||||||
|
// Simulate sending
|
||||||
|
setTimeout(() => {
|
||||||
|
KTEventApp.Feedback.showToast('알림이 전송되었습니다');
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Back to events button
|
||||||
|
document.getElementById('backToEventsBtn')?.addEventListener('click', function() {
|
||||||
|
window.location.href = '06-이벤트목록.html';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Render drawing history
|
||||||
|
function renderHistory() {
|
||||||
|
const historyList = document.getElementById('historyList');
|
||||||
|
|
||||||
|
if (drawingHistory.length === 0) {
|
||||||
|
historyList.innerHTML = `
|
||||||
|
<div class="card text-center py-lg">
|
||||||
|
<p class="text-body text-secondary">추첨 이력이 없습니다</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
historyList.innerHTML = '';
|
||||||
|
drawingHistory.slice(0, 3).forEach(history => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'card mb-sm';
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-body mb-xs">${history.date} ${history.isRedraw ? '(재추첨)' : ''}</p>
|
||||||
|
<p class="text-body-small text-secondary">당첨자 ${history.winnerCount}명</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-text btn-small history-detail-btn">
|
||||||
|
<span class="material-icons">visibility</span>
|
||||||
|
상세보기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
card.querySelector('.history-detail-btn').addEventListener('click', function() {
|
||||||
|
KTEventApp.Feedback.showModal({
|
||||||
|
title: '추첨 이력 상세',
|
||||||
|
content: `
|
||||||
|
<div class="p-md">
|
||||||
|
<p class="text-body mb-sm">추첨 일시: ${history.date}</p>
|
||||||
|
<p class="text-body mb-sm">당첨 인원: ${history.winnerCount}명</p>
|
||||||
|
<p class="text-body mb-sm">재추첨 여부: ${history.isRedraw ? '예' : '아니오'}</p>
|
||||||
|
<p class="text-body-small text-secondary mt-md">※ 당첨자 정보는 개인정보 보호를 위해 마스킹 처리됩니다</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: '확인',
|
||||||
|
variant: 'primary',
|
||||||
|
onClick: function() {
|
||||||
|
this.closest('.modal-backdrop').remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
historyList.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize history
|
||||||
|
renderHistory();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
430
design/uiux/prototype/17-성과분석.html
Normal file
430
design/uiux/prototype/17-성과분석.html
Normal file
@ -0,0 +1,430 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>실시간 대시보드 - KT AI 이벤트 마케팅</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
|
||||||
|
<style>
|
||||||
|
.summary-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.summary-cards {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.summary-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
.summary-value {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: var(--spacing-sm) 0;
|
||||||
|
}
|
||||||
|
.summary-value.positive {
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
.chart-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
background: var(--color-gray-50);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
.line-chart-placeholder {
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
}
|
||||||
|
.chart-legend {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
}
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
.legend-dot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.roi-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.roi-table td {
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
border-bottom: 1px solid var(--color-gray-200);
|
||||||
|
}
|
||||||
|
.roi-table td:first-child {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.roi-table td:last-child {
|
||||||
|
text-align: right;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.profile-bars {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
.profile-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
.profile-label {
|
||||||
|
min-width: 60px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.profile-bar-bg {
|
||||||
|
flex: 1;
|
||||||
|
height: 24px;
|
||||||
|
background: var(--color-gray-100);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.profile-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--color-ai-blue);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-right: var(--spacing-xs);
|
||||||
|
color: white;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.refresh-indicator {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.update-pulse {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-success);
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page page-with-header">
|
||||||
|
<!-- Header -->
|
||||||
|
<div id="header"></div>
|
||||||
|
|
||||||
|
<div class="container" style="max-width: 1200px;">
|
||||||
|
<!-- Title with Real-time Indicator -->
|
||||||
|
<section class="mt-lg mb-md flex items-center justify-between">
|
||||||
|
<h2 class="text-title">📊 요약 (실시간)</h2>
|
||||||
|
<div class="refresh-indicator">
|
||||||
|
<div class="update-pulse"></div>
|
||||||
|
<span id="lastUpdate">방금 전</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Summary KPI Cards -->
|
||||||
|
<section class="mb-lg">
|
||||||
|
<div class="summary-cards">
|
||||||
|
<div class="summary-card">
|
||||||
|
<p class="text-body-small text-secondary">참여자 수</p>
|
||||||
|
<div class="summary-value">128명</div>
|
||||||
|
<p class="text-caption text-success">↑ 12명 (오늘)</p>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card">
|
||||||
|
<p class="text-body-small text-secondary">총 비용</p>
|
||||||
|
<div class="summary-value">30만원</div>
|
||||||
|
<p class="text-caption text-secondary">경품 25만 + 채널 5만</p>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card">
|
||||||
|
<p class="text-body-small text-secondary">예상 수익</p>
|
||||||
|
<div class="summary-value positive">135만원</div>
|
||||||
|
<p class="text-caption text-success">매출증가 100만 + LTV 35만</p>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card">
|
||||||
|
<p class="text-body-small text-secondary">투자대비수익률</p>
|
||||||
|
<div class="summary-value positive">450%</div>
|
||||||
|
<p class="text-caption text-success">목표 300% 달성</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Charts Grid -->
|
||||||
|
<div class="grid desktop:grid-cols-2 gap-lg mb-lg">
|
||||||
|
<!-- Channel Performance -->
|
||||||
|
<section>
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex items-center gap-sm mb-md">
|
||||||
|
<span class="material-icons text-kt-red">pie_chart</span>
|
||||||
|
<h3 class="text-headline">채널별 성과</h3>
|
||||||
|
</div>
|
||||||
|
<div class="chart-placeholder">
|
||||||
|
<span class="material-icons" style="font-size: 64px; color: var(--color-gray-300);">donut_large</span>
|
||||||
|
<p class="text-body-small text-secondary mt-md">파이 차트</p>
|
||||||
|
</div>
|
||||||
|
<div class="chart-legend">
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-dot" style="background: #E31E24;"></div>
|
||||||
|
<span class="text-body-small flex-1">우리동네TV</span>
|
||||||
|
<span class="text-body-small text-semibold">45% (58명)</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-dot" style="background: #0066FF;"></div>
|
||||||
|
<span class="text-body-small flex-1">링고비즈</span>
|
||||||
|
<span class="text-body-small text-semibold">30% (38명)</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-dot" style="background: #FFB800;"></div>
|
||||||
|
<span class="text-body-small flex-1">SNS</span>
|
||||||
|
<span class="text-body-small text-semibold">25% (32명)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Time Trend -->
|
||||||
|
<section>
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex items-center gap-sm mb-md">
|
||||||
|
<span class="material-icons text-kt-red">show_chart</span>
|
||||||
|
<h3 class="text-headline">시간대별 참여 추이</h3>
|
||||||
|
</div>
|
||||||
|
<div class="chart-placeholder line-chart-placeholder">
|
||||||
|
<span class="material-icons" style="font-size: 64px; color: var(--color-gray-300);">trending_up</span>
|
||||||
|
<p class="text-body-small text-secondary mt-md">라인 차트</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-md">
|
||||||
|
<p class="text-body-small text-secondary">피크 시간: 오후 2-4시 (35명)</p>
|
||||||
|
<p class="text-body-small text-secondary">평균 시간당: 8명</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ROI Detail & Participant Profile -->
|
||||||
|
<div class="grid desktop:grid-cols-2 gap-lg mb-2xl">
|
||||||
|
<!-- ROI Detail -->
|
||||||
|
<section>
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex items-center gap-sm mb-md">
|
||||||
|
<span class="material-icons text-kt-red">payments</span>
|
||||||
|
<h3 class="text-headline">투자대비수익률 상세</h3>
|
||||||
|
</div>
|
||||||
|
<table class="roi-table">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" class="text-headline">총 비용: 30만원</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>• 경품 비용</td>
|
||||||
|
<td>25만원</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>• 채널 비용</td>
|
||||||
|
<td>5만원</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="height: var(--spacing-md);"></tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" class="text-headline">예상 수익: 135만원</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>• 매출 증가</td>
|
||||||
|
<td class="text-success">100만원</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>• 신규 고객 LTV</td>
|
||||||
|
<td class="text-success">35만원</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="height: var(--spacing-md);"></tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" class="text-headline text-success">투자대비수익률</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2">
|
||||||
|
<div class="p-md" style="background: var(--color-gray-50); border-radius: var(--radius-sm);">
|
||||||
|
<p class="text-body-small text-center mb-xs">(수익 - 비용) ÷ 비용 × 100</p>
|
||||||
|
<p class="text-body text-center">(135만 - 30만) ÷ 30만 × 100</p>
|
||||||
|
<p class="text-headline text-center text-success mt-sm">= 450%</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Participant Profile -->
|
||||||
|
<section>
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex items-center gap-sm mb-md">
|
||||||
|
<span class="material-icons text-kt-red">people</span>
|
||||||
|
<h3 class="text-headline">참여자 프로필</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-lg">
|
||||||
|
<p class="text-body mb-sm">연령별</p>
|
||||||
|
<div class="profile-bars">
|
||||||
|
<div class="profile-bar">
|
||||||
|
<span class="profile-label">20대</span>
|
||||||
|
<div class="profile-bar-bg">
|
||||||
|
<div class="profile-bar-fill" style="width: 35%;">35%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="profile-bar">
|
||||||
|
<span class="profile-label">30대</span>
|
||||||
|
<div class="profile-bar-bg">
|
||||||
|
<div class="profile-bar-fill" style="width: 40%;">40%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="profile-bar">
|
||||||
|
<span class="profile-label">40대</span>
|
||||||
|
<div class="profile-bar-bg">
|
||||||
|
<div class="profile-bar-fill" style="width: 25%;">25%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="text-body mb-sm">성별</p>
|
||||||
|
<div class="profile-bars">
|
||||||
|
<div class="profile-bar">
|
||||||
|
<span class="profile-label">여성</span>
|
||||||
|
<div class="profile-bar-bg">
|
||||||
|
<div class="profile-bar-fill" style="width: 60%; background: var(--color-kt-red);">60%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="profile-bar">
|
||||||
|
<span class="profile-label">남성</span>
|
||||||
|
<div class="profile-bar-bg">
|
||||||
|
<div class="profile-bar-fill" style="width: 40%; background: var(--color-kt-red);">40%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Navigation -->
|
||||||
|
<div id="bottomNav"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="common.js"></script>
|
||||||
|
<script>
|
||||||
|
// 로그인 확인
|
||||||
|
KTEventApp.Session.requireAuth();
|
||||||
|
|
||||||
|
// Header 생성
|
||||||
|
const header = KTEventApp.Navigation.createHeader({
|
||||||
|
title: '실시간 대시보드',
|
||||||
|
showBack: true,
|
||||||
|
showMenu: false,
|
||||||
|
showProfile: true
|
||||||
|
});
|
||||||
|
document.getElementById('header').appendChild(header);
|
||||||
|
|
||||||
|
// Bottom Navigation 생성
|
||||||
|
const bottomNav = KTEventApp.Navigation.createBottomNav('analytics');
|
||||||
|
document.getElementById('bottomNav').appendChild(bottomNav);
|
||||||
|
|
||||||
|
// Real-time update simulation (every 5 minutes)
|
||||||
|
let updateInterval = null;
|
||||||
|
let lastUpdateTime = new Date();
|
||||||
|
|
||||||
|
function updateLastUpdateTime() {
|
||||||
|
const now = new Date();
|
||||||
|
const diff = Math.floor((now - lastUpdateTime) / 1000);
|
||||||
|
|
||||||
|
let timeText;
|
||||||
|
if (diff < 60) {
|
||||||
|
timeText = '방금 전';
|
||||||
|
} else if (diff < 3600) {
|
||||||
|
timeText = `${Math.floor(diff / 60)}분 전`;
|
||||||
|
} else {
|
||||||
|
timeText = `${Math.floor(diff / 3600)}시간 전`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('lastUpdate').textContent = timeText;
|
||||||
|
}
|
||||||
|
|
||||||
|
function simulateUpdate() {
|
||||||
|
// Simulate data update
|
||||||
|
lastUpdateTime = new Date();
|
||||||
|
updateLastUpdateTime();
|
||||||
|
|
||||||
|
// Show toast notification
|
||||||
|
KTEventApp.Feedback.showToast('데이터가 업데이트되었습니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update time display every 30 seconds
|
||||||
|
setInterval(updateLastUpdateTime, 30000);
|
||||||
|
|
||||||
|
// Simulate data update every 5 minutes (300000ms)
|
||||||
|
updateInterval = setInterval(simulateUpdate, 300000);
|
||||||
|
|
||||||
|
// Initial update
|
||||||
|
updateLastUpdateTime();
|
||||||
|
|
||||||
|
// Cleanup on page unload
|
||||||
|
window.addEventListener('beforeunload', function() {
|
||||||
|
if (updateInterval) {
|
||||||
|
clearInterval(updateInterval);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pull to refresh (Mobile)
|
||||||
|
let touchStartY = 0;
|
||||||
|
let isPulling = false;
|
||||||
|
|
||||||
|
document.addEventListener('touchstart', function(e) {
|
||||||
|
if (window.scrollY === 0) {
|
||||||
|
touchStartY = e.touches[0].clientY;
|
||||||
|
isPulling = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('touchmove', function(e) {
|
||||||
|
if (!isPulling) return;
|
||||||
|
|
||||||
|
const touchY = e.touches[0].clientY;
|
||||||
|
const pullDistance = touchY - touchStartY;
|
||||||
|
|
||||||
|
if (pullDistance > 80) {
|
||||||
|
isPulling = false;
|
||||||
|
simulateUpdate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('touchend', function() {
|
||||||
|
isPulling = false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1071
design/uiux/prototype/common.js
vendored
Normal file
1071
design/uiux/prototype/common.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
973
design/uiux/prototype/styles.css
Normal file
973
design/uiux/prototype/styles.css
Normal file
@ -0,0 +1,973 @@
|
|||||||
|
/* =================================================================
|
||||||
|
KT AI 이벤트 마케팅 서비스 - 공통 스타일시트
|
||||||
|
Version: 1.0.0
|
||||||
|
Mobile First Design Philosophy
|
||||||
|
================================================================= */
|
||||||
|
|
||||||
|
/* =================================================================
|
||||||
|
1. CSS Variables - Design System
|
||||||
|
================================================================= */
|
||||||
|
:root {
|
||||||
|
/* Brand Colors */
|
||||||
|
--color-kt-red: #E31E24;
|
||||||
|
--color-ai-blue: #0066FF;
|
||||||
|
|
||||||
|
/* Grayscale */
|
||||||
|
--color-gray-50: #F9FAFB;
|
||||||
|
--color-gray-100: #F3F4F6;
|
||||||
|
--color-gray-200: #E5E7EB;
|
||||||
|
--color-gray-300: #D1D5DB;
|
||||||
|
--color-gray-400: #9CA3AF;
|
||||||
|
--color-gray-500: #6B7280;
|
||||||
|
--color-gray-600: #4B5563;
|
||||||
|
--color-gray-700: #374151;
|
||||||
|
--color-gray-800: #1F2937;
|
||||||
|
--color-gray-900: #111827;
|
||||||
|
|
||||||
|
/* Semantic Colors */
|
||||||
|
--color-success: #10B981;
|
||||||
|
--color-warning: #F59E0B;
|
||||||
|
--color-error: #EF4444;
|
||||||
|
--color-info: #3B82F6;
|
||||||
|
|
||||||
|
/* Background */
|
||||||
|
--color-bg-primary: #FFFFFF;
|
||||||
|
--color-bg-secondary: #F9FAFB;
|
||||||
|
--color-bg-tertiary: #F3F4F6;
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--color-text-primary: #111827;
|
||||||
|
--color-text-secondary: #4B5563;
|
||||||
|
--color-text-tertiary: #9CA3AF;
|
||||||
|
--color-text-inverse: #FFFFFF;
|
||||||
|
|
||||||
|
/* Gradients */
|
||||||
|
--gradient-primary: linear-gradient(135deg, #E31E24 0%, #B71419 100%);
|
||||||
|
--gradient-ai: linear-gradient(135deg, #0066FF 0%, #0052CC 100%);
|
||||||
|
|
||||||
|
/* Typography Scale */
|
||||||
|
--font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||||
|
--font-size-display: 28px;
|
||||||
|
--font-size-title-large: 24px;
|
||||||
|
--font-size-title: 20px;
|
||||||
|
--font-size-headline: 18px;
|
||||||
|
--font-size-body-large: 16px;
|
||||||
|
--font-size-body: 14px;
|
||||||
|
--font-size-body-small: 13px;
|
||||||
|
--font-size-caption: 12px;
|
||||||
|
|
||||||
|
--line-height-display: 1.2;
|
||||||
|
--line-height-title: 1.3;
|
||||||
|
--line-height-body: 1.5;
|
||||||
|
--line-height-caption: 1.4;
|
||||||
|
|
||||||
|
--font-weight-bold: 700;
|
||||||
|
--font-weight-semibold: 600;
|
||||||
|
--font-weight-medium: 500;
|
||||||
|
--font-weight-regular: 400;
|
||||||
|
|
||||||
|
/* Spacing System (4px grid) */
|
||||||
|
--spacing-xs: 4px;
|
||||||
|
--spacing-sm: 8px;
|
||||||
|
--spacing-md: 16px;
|
||||||
|
--spacing-lg: 24px;
|
||||||
|
--spacing-xl: 32px;
|
||||||
|
--spacing-2xl: 48px;
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07);
|
||||||
|
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.15);
|
||||||
|
|
||||||
|
/* Border Radius */
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius-md: 8px;
|
||||||
|
--radius-lg: 12px;
|
||||||
|
--radius-xl: 16px;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
/* Z-Index */
|
||||||
|
--z-dropdown: 1000;
|
||||||
|
--z-sticky: 1020;
|
||||||
|
--z-fixed: 1030;
|
||||||
|
--z-modal-backdrop: 1040;
|
||||||
|
--z-modal: 1050;
|
||||||
|
--z-popover: 1060;
|
||||||
|
--z-tooltip: 1070;
|
||||||
|
|
||||||
|
/* Animation */
|
||||||
|
--duration-instant: 0ms;
|
||||||
|
--duration-fast: 150ms;
|
||||||
|
--duration-normal: 300ms;
|
||||||
|
--duration-slow: 500ms;
|
||||||
|
|
||||||
|
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||||
|
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||||
|
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tablet Responsive Variables */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
:root {
|
||||||
|
--font-size-display: 32px;
|
||||||
|
--font-size-title-large: 28px;
|
||||||
|
--font-size-title: 24px;
|
||||||
|
--font-size-headline: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop Responsive Variables */
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
:root {
|
||||||
|
--font-size-display: 36px;
|
||||||
|
--font-size-title-large: 32px;
|
||||||
|
--font-size-title: 28px;
|
||||||
|
--font-size-headline: 22px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =================================================================
|
||||||
|
2. Reset & Base Styles
|
||||||
|
================================================================= */
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: 16px;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-family);
|
||||||
|
font-size: var(--font-size-body);
|
||||||
|
line-height: var(--line-height-body);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =================================================================
|
||||||
|
3. Typography
|
||||||
|
================================================================= */
|
||||||
|
.text-display {
|
||||||
|
font-size: var(--font-size-display);
|
||||||
|
line-height: var(--line-height-display);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-title-large {
|
||||||
|
font-size: var(--font-size-title-large);
|
||||||
|
line-height: var(--line-height-title);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-title {
|
||||||
|
font-size: var(--font-size-title);
|
||||||
|
line-height: var(--line-height-title);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-headline {
|
||||||
|
font-size: var(--font-size-headline);
|
||||||
|
line-height: var(--line-height-title);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-body-large {
|
||||||
|
font-size: var(--font-size-body-large);
|
||||||
|
line-height: var(--line-height-body);
|
||||||
|
font-weight: var(--font-weight-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-body {
|
||||||
|
font-size: var(--font-size-body);
|
||||||
|
line-height: var(--line-height-body);
|
||||||
|
font-weight: var(--font-weight-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-body-small {
|
||||||
|
font-size: var(--font-size-body-small);
|
||||||
|
line-height: var(--line-height-body);
|
||||||
|
font-weight: var(--font-weight-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-caption {
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
line-height: var(--line-height-caption);
|
||||||
|
font-weight: var(--font-weight-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-primary { color: var(--color-text-primary); }
|
||||||
|
.text-secondary { color: var(--color-text-secondary); }
|
||||||
|
.text-tertiary { color: var(--color-text-tertiary); }
|
||||||
|
.text-inverse { color: var(--color-text-inverse); }
|
||||||
|
.text-kt-red { color: var(--color-kt-red); }
|
||||||
|
.text-ai-blue { color: var(--color-ai-blue); }
|
||||||
|
.text-success { color: var(--color-success); }
|
||||||
|
.text-warning { color: var(--color-warning); }
|
||||||
|
.text-error { color: var(--color-error); }
|
||||||
|
|
||||||
|
.text-bold { font-weight: var(--font-weight-bold); }
|
||||||
|
.text-semibold { font-weight: var(--font-weight-semibold); }
|
||||||
|
.text-medium { font-weight: var(--font-weight-medium); }
|
||||||
|
|
||||||
|
.text-center { text-align: center; }
|
||||||
|
.text-left { text-align: left; }
|
||||||
|
.text-right { text-align: right; }
|
||||||
|
|
||||||
|
/* =================================================================
|
||||||
|
4. Layout Utilities
|
||||||
|
================================================================= */
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
padding-bottom: 76px; /* Bottom nav height + spacing */
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-with-header {
|
||||||
|
padding-top: 56px; /* Header height */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =================================================================
|
||||||
|
5. Button Components
|
||||||
|
================================================================= */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-family: var(--font-family);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--duration-fast) var(--ease-out);
|
||||||
|
text-decoration: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button Sizes */
|
||||||
|
.btn-large {
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 var(--spacing-lg);
|
||||||
|
font-size: var(--font-size-body-large);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-medium {
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 var(--spacing-md);
|
||||||
|
font-size: var(--font-size-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 var(--spacing-md);
|
||||||
|
font-size: var(--font-size-body-small);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button Variants */
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--gradient-primary);
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
color: var(--color-kt-red);
|
||||||
|
border: 1px solid var(--color-gray-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-gray-50);
|
||||||
|
border-color: var(--color-kt-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-text {
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--color-kt-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-text:hover:not(:disabled) {
|
||||||
|
background-color: rgba(227, 30, 36, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =================================================================
|
||||||
|
6. Card Components
|
||||||
|
================================================================= */
|
||||||
|
.card {
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: box-shadow var(--duration-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-clickable:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-active {
|
||||||
|
background-color: rgba(16, 185, 129, 0.1);
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-scheduled {
|
||||||
|
background-color: rgba(59, 130, 246, 0.1);
|
||||||
|
color: var(--color-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-ended {
|
||||||
|
background-color: rgba(156, 163, 175, 0.1);
|
||||||
|
color: var(--color-gray-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
padding-top: var(--spacing-md);
|
||||||
|
border-top: 1px solid var(--color-gray-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: var(--font-size-headline);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* KPI Card */
|
||||||
|
.kpi-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-icon-primary {
|
||||||
|
background: rgba(227, 30, 36, 0.1);
|
||||||
|
color: var(--color-kt-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-icon-ai {
|
||||||
|
background: rgba(0, 102, 255, 0.1);
|
||||||
|
color: var(--color-ai-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-icon-success {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-label {
|
||||||
|
font-size: var(--font-size-body-small);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-value {
|
||||||
|
font-size: var(--font-size-title);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Option Card for Selection */
|
||||||
|
.option-card {
|
||||||
|
position: relative;
|
||||||
|
border: 2px solid var(--color-gray-200);
|
||||||
|
transition: all var(--duration-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-card:hover {
|
||||||
|
border-color: var(--color-kt-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-card.selected {
|
||||||
|
border-color: var(--color-kt-red);
|
||||||
|
background-color: rgba(227, 30, 36, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-card-radio {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--spacing-md);
|
||||||
|
right: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =================================================================
|
||||||
|
7. Form Components
|
||||||
|
================================================================= */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
font-size: var(--font-size-body);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-required::after {
|
||||||
|
content: " *";
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input,
|
||||||
|
.form-select,
|
||||||
|
.form-textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px var(--spacing-md);
|
||||||
|
border: 1px solid var(--color-gray-300);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-family: var(--font-family);
|
||||||
|
font-size: var(--font-size-body);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
transition: all var(--duration-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input::placeholder,
|
||||||
|
.form-textarea::placeholder {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus,
|
||||||
|
.form-select:focus,
|
||||||
|
.form-textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-kt-red);
|
||||||
|
box-shadow: 0 0 0 3px rgba(227, 30, 36, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:disabled,
|
||||||
|
.form-select:disabled,
|
||||||
|
.form-textarea:disabled {
|
||||||
|
background-color: var(--color-gray-100);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-textarea {
|
||||||
|
min-height: 100px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
display: block;
|
||||||
|
margin-top: var(--spacing-sm);
|
||||||
|
font-size: var(--font-size-body-small);
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: var(--spacing-sm);
|
||||||
|
font-size: var(--font-size-body-small);
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox & Radio */
|
||||||
|
.form-check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check-input {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check-label {
|
||||||
|
font-size: var(--font-size-body);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =================================================================
|
||||||
|
8. Navigation Components
|
||||||
|
================================================================= */
|
||||||
|
.header {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 56px;
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
border-bottom: 1px solid var(--color-gray-200);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 var(--spacing-md);
|
||||||
|
z-index: var(--z-sticky);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left,
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-size: var(--font-size-headline);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-icon-btn {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: background-color var(--duration-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-icon-btn:hover {
|
||||||
|
background-color: var(--color-gray-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 60px;
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
border-top: 1px solid var(--color-gray-200);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-around;
|
||||||
|
padding: 0 var(--spacing-sm);
|
||||||
|
z-index: var(--z-sticky);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
transition: color var(--duration-fast) var(--ease-out);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-item:hover {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-item.active {
|
||||||
|
color: var(--color-kt-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FAB (Floating Action Button) */
|
||||||
|
.fab {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 76px; /* Bottom nav height + spacing */
|
||||||
|
right: var(--spacing-md);
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
background: var(--gradient-primary);
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--duration-fast) var(--ease-out);
|
||||||
|
z-index: var(--z-fixed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab:hover {
|
||||||
|
box-shadow: var(--shadow-xl);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =================================================================
|
||||||
|
9. Feedback Components
|
||||||
|
================================================================= */
|
||||||
|
/* Toast */
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 92px; /* Bottom nav + spacing */
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
|
background-color: var(--color-gray-900);
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
font-size: var(--font-size-body);
|
||||||
|
z-index: var(--z-tooltip);
|
||||||
|
animation: slideUp var(--duration-normal) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, 20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: var(--z-modal-backdrop);
|
||||||
|
animation: fadeIn var(--duration-normal) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-xl);
|
||||||
|
z-index: var(--z-modal);
|
||||||
|
max-width: 90%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
animation: scaleIn var(--duration-normal) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scaleIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, -50%) scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, -50%) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
border-bottom: 1px solid var(--color-gray-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: var(--font-size-title);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
border-top: 1px solid var(--color-gray-200);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottom Sheet */
|
||||||
|
.bottom-sheet {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
|
||||||
|
box-shadow: var(--shadow-xl);
|
||||||
|
z-index: var(--z-modal);
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
animation: slideUpSheet var(--duration-normal) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUpSheet {
|
||||||
|
from {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-sheet-handle {
|
||||||
|
width: 40px;
|
||||||
|
height: 4px;
|
||||||
|
background-color: var(--color-gray-300);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
margin: var(--spacing-sm) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-sheet-content {
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spinner */
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 4px solid var(--color-gray-200);
|
||||||
|
border-top-color: var(--color-kt-red);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-center {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--spacing-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress Bar */
|
||||||
|
.progress {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
background-color: var(--color-gray-200);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--gradient-primary);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
transition: width var(--duration-normal) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =================================================================
|
||||||
|
10. Utility Classes
|
||||||
|
================================================================= */
|
||||||
|
/* Spacing */
|
||||||
|
.m-0 { margin: 0; }
|
||||||
|
.mt-xs { margin-top: var(--spacing-xs); }
|
||||||
|
.mt-sm { margin-top: var(--spacing-sm); }
|
||||||
|
.mt-md { margin-top: var(--spacing-md); }
|
||||||
|
.mt-lg { margin-top: var(--spacing-lg); }
|
||||||
|
.mt-xl { margin-top: var(--spacing-xl); }
|
||||||
|
.mt-2xl { margin-top: var(--spacing-2xl); }
|
||||||
|
|
||||||
|
.mb-xs { margin-bottom: var(--spacing-xs); }
|
||||||
|
.mb-sm { margin-bottom: var(--spacing-sm); }
|
||||||
|
.mb-md { margin-bottom: var(--spacing-md); }
|
||||||
|
.mb-lg { margin-bottom: var(--spacing-lg); }
|
||||||
|
.mb-xl { margin-bottom: var(--spacing-xl); }
|
||||||
|
.mb-2xl { margin-bottom: var(--spacing-2xl); }
|
||||||
|
|
||||||
|
.p-0 { padding: 0; }
|
||||||
|
.p-xs { padding: var(--spacing-xs); }
|
||||||
|
.p-sm { padding: var(--spacing-sm); }
|
||||||
|
.p-md { padding: var(--spacing-md); }
|
||||||
|
.p-lg { padding: var(--spacing-lg); }
|
||||||
|
.p-xl { padding: var(--spacing-xl); }
|
||||||
|
.p-2xl { padding: var(--spacing-2xl); }
|
||||||
|
|
||||||
|
.gap-xs { gap: var(--spacing-xs); }
|
||||||
|
.gap-sm { gap: var(--spacing-sm); }
|
||||||
|
.gap-md { gap: var(--spacing-md); }
|
||||||
|
.gap-lg { gap: var(--spacing-lg); }
|
||||||
|
|
||||||
|
/* Flexbox */
|
||||||
|
.flex { display: flex; }
|
||||||
|
.flex-col { flex-direction: column; }
|
||||||
|
.flex-row { flex-direction: row; }
|
||||||
|
.items-center { align-items: center; }
|
||||||
|
.items-start { align-items: flex-start; }
|
||||||
|
.items-end { align-items: flex-end; }
|
||||||
|
.justify-center { justify-content: center; }
|
||||||
|
.justify-between { justify-content: space-between; }
|
||||||
|
.justify-end { justify-content: flex-end; }
|
||||||
|
.flex-1 { flex: 1; }
|
||||||
|
.flex-wrap { flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* Grid */
|
||||||
|
.grid { display: grid; }
|
||||||
|
.grid-cols-2 { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.grid-cols-3 { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
.grid-cols-4 { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
|
||||||
|
/* Display */
|
||||||
|
.hidden { display: none; }
|
||||||
|
.block { display: block; }
|
||||||
|
.inline-block { display: inline-block; }
|
||||||
|
|
||||||
|
/* Width */
|
||||||
|
.w-full { width: 100%; }
|
||||||
|
.w-auto { width: auto; }
|
||||||
|
|
||||||
|
/* Background */
|
||||||
|
.bg-primary { background-color: var(--color-bg-primary); }
|
||||||
|
.bg-secondary { background-color: var(--color-bg-secondary); }
|
||||||
|
.bg-tertiary { background-color: var(--color-bg-tertiary); }
|
||||||
|
|
||||||
|
/* Border */
|
||||||
|
.border { border: 1px solid var(--color-gray-200); }
|
||||||
|
.border-t { border-top: 1px solid var(--color-gray-200); }
|
||||||
|
.border-b { border-bottom: 1px solid var(--color-gray-200); }
|
||||||
|
.border-none { border: none; }
|
||||||
|
|
||||||
|
.rounded-sm { border-radius: var(--radius-sm); }
|
||||||
|
.rounded-md { border-radius: var(--radius-md); }
|
||||||
|
.rounded-lg { border-radius: var(--radius-lg); }
|
||||||
|
.rounded-xl { border-radius: var(--radius-xl); }
|
||||||
|
.rounded-full { border-radius: var(--radius-full); }
|
||||||
|
|
||||||
|
/* Shadow */
|
||||||
|
.shadow-sm { box-shadow: var(--shadow-sm); }
|
||||||
|
.shadow-md { box-shadow: var(--shadow-md); }
|
||||||
|
.shadow-lg { box-shadow: var(--shadow-lg); }
|
||||||
|
.shadow-xl { box-shadow: var(--shadow-xl); }
|
||||||
|
.shadow-none { box-shadow: none; }
|
||||||
|
|
||||||
|
/* Cursor */
|
||||||
|
.cursor-pointer { cursor: pointer; }
|
||||||
|
.cursor-not-allowed { cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* =================================================================
|
||||||
|
11. Responsive Grid System
|
||||||
|
================================================================= */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.container {
|
||||||
|
padding: 0 var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tablet\:grid-cols-2 { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.tablet\:grid-cols-3 { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
.tablet\:grid-cols-4 { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.container {
|
||||||
|
padding: 0 var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop\:grid-cols-2 { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.desktop\:grid-cols-3 { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
.desktop\:grid-cols-4 { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
.desktop\:grid-cols-5 { grid-template-columns: repeat(5, 1fr); }
|
||||||
|
}
|
||||||
1554
design/uiux/style-guide.md
Normal file
1554
design/uiux/style-guide.md
Normal file
File diff suppressed because it is too large
Load Diff
1002
design/uiux/uiux-design.md
Normal file
1002
design/uiux/uiux-design.md
Normal file
File diff suppressed because it is too large
Load Diff
2473
design/uiux/uiux.md
Normal file
2473
design/uiux/uiux.md
Normal file
File diff suppressed because it is too large
Load Diff
344
design/userstory-table.md
Normal file
344
design/userstory-table.md
Normal file
@ -0,0 +1,344 @@
|
|||||||
|
# KT AI 기반 소상공인 이벤트 자동 생성 서비스 - 유저스토리 목록
|
||||||
|
|
||||||
|
## 전체 유저스토리 요약
|
||||||
|
|
||||||
|
| 서비스 | ID | 유저스토리 | 우선순위 | 복잡도 |
|
||||||
|
|--------|-----|-----------|----------|--------|
|
||||||
|
| **User** | UFR-USER-010 | [회원가입] 소상공인으로서 간편하게 회원가입하고 싶다 | M | 21 |
|
||||||
|
| | UFR-USER-020 | [로그인] 소상공인으로서 간편하게 로그인하고 싶다 | M | 8 |
|
||||||
|
| | UFR-USER-030 | [프로필관리] 소상공인으로서 프로필 정보를 편집하고 싶다 | C | 8 |
|
||||||
|
| | UFR-USER-040 | [로그아웃] 소상공인으로서 안전하게 로그아웃하고 싶다 | S | 3 |
|
||||||
|
| **Event** | UFR-EVENT-010 | [대시보드] 소상공인으로서 대시보드에서 이벤트를 한눈에 확인하고 싶다 | S | 13 |
|
||||||
|
| | UFR-EVENT-020 | [이벤트목적선택] 소상공인으로서 이벤트 목적을 먼저 선택하고 싶다 | M | 5 |
|
||||||
|
| | UFR-EVENT-030 | [AI이벤트추천] 소상공인으로서 AI 트렌드 분석 결과와 함께 3가지 이벤트 추천을 확인하고 싶다 | M | 34 |
|
||||||
|
| | UFR-EVENT-040 | [배포채널선택] 소상공인으로서 다중 배포 채널을 선택하고 싶다 | M | 13 |
|
||||||
|
| | UFR-EVENT-050 | [최종승인] 소상공인으로서 모든 설정을 최종 확인하고 승인하고 싶다 | M | 13 |
|
||||||
|
| | UFR-EVENT-060 | [이벤트상세조회] 소상공인으로서 이벤트 상세 정보를 조회하고 싶다 | S | 13 |
|
||||||
|
| | UFR-EVENT-070 | [이벤트목록관리] 소상공인으로서 전체 이벤트 목록을 조회하고 필터링/검색하고 싶다 | S | 13 |
|
||||||
|
| **AI** | UFR-AI-010 | [AI트렌드분석및이벤트추천] AI 시스템으로서 업종/지역/시즌 트렌드를 분석하고 3가지 최적화된 이벤트 기획안을 생성하고 싶다 | M | 34 |
|
||||||
|
| **Content** | UFR-CONT-010 | [SNS이미지생성] 소상공인으로서 AI가 자동으로 3가지 스타일의 이미지를 생성하기를 원한다 | M | 21 |
|
||||||
|
| | UFR-CONT-020 | [콘텐츠편집] 소상공인으로서 생성된 이미지를 간단한 편집 기능으로 조정하고 싶다 | S | 13 |
|
||||||
|
| **Distribution** | UFR-DIST-010 | [다중채널배포] 소상공인으로서 원클릭으로 다중 채널 배포를 실행하고 싶다 | M | 21 |
|
||||||
|
| | UFR-DIST-020 | [배포상태모니터링] 소상공인으로서 배포 상태를 실시간으로 모니터링하고 싶다 | S | 13 |
|
||||||
|
| **Participation** | UFR-PART-010 | [이벤트참여] 고객으로서 최소한의 정보만 입력하고 이벤트에 참여하고 싶다 | M | 13 |
|
||||||
|
| | UFR-PART-020 | [참여자목록조회] 소상공인으로서 참여자 목록을 조회하고 싶다 | S | 8 |
|
||||||
|
| | UFR-PART-030 | [당첨자추첨] 소상공인으로서 자동 추첨 기능을 사용하고 싶다 | M | 13 |
|
||||||
|
| **Analytics** | UFR-ANAL-010 | [실시간통합대시보드] 소상공인으로서 모든 지표가 통합된 실시간 대시보드를 보고 싶다 | M | 34 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 우선순위별 통계
|
||||||
|
|
||||||
|
| 우선순위 | 개수 | 비율 |
|
||||||
|
|---------|------|------|
|
||||||
|
| M (필수) | 13 | 65.0% |
|
||||||
|
| S (선택) | 6 | 30.0% |
|
||||||
|
| C (조건부) | 1 | 5.0% |
|
||||||
|
| **총계** | **20** | **100%** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 서비스별 통계
|
||||||
|
|
||||||
|
| 서비스 | 유저스토리 수 | 평균 복잡도 | 필수(M) | 선택(S) | 조건부(C) |
|
||||||
|
|--------|-------------|-----------|---------|---------|----------|
|
||||||
|
| User | 4 | 10.0 | 2 | 1 | 1 |
|
||||||
|
| Event | 7 | 14.9 | 4 | 3 | 0 |
|
||||||
|
| AI | 1 | 34.0 | 1 | 0 | 0 |
|
||||||
|
| Content | 2 | 17.0 | 1 | 1 | 0 |
|
||||||
|
| Distribution | 2 | 17.0 | 1 | 1 | 0 |
|
||||||
|
| Participation | 3 | 11.3 | 2 | 1 | 0 |
|
||||||
|
| Analytics | 1 | 34.0 | 1 | 0 | 0 |
|
||||||
|
| **총계** | **20** | **16.9** | **12** | **7** | **1** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 복잡도별 통계
|
||||||
|
|
||||||
|
| 복잡도 범위 | 개수 | 유저스토리 |
|
||||||
|
|-----------|------|-----------|
|
||||||
|
| 1-5 (낮음) | 2 | UFR-USER-040, UFR-EVENT-020 |
|
||||||
|
| 6-13 (중간) | 11 | UFR-USER-020, UFR-USER-030, UFR-EVENT-010, UFR-EVENT-040, UFR-EVENT-050, UFR-EVENT-060, UFR-EVENT-070, UFR-CONT-020, UFR-DIST-020, UFR-PART-010, UFR-PART-020, UFR-PART-030 |
|
||||||
|
| 14-21 (높음) | 4 | UFR-USER-010, UFR-CONT-010, UFR-DIST-010 |
|
||||||
|
| 22+ (매우 높음) | 3 | UFR-EVENT-030, UFR-AI-010, UFR-ANAL-010 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 주요 기능별 상세 목록
|
||||||
|
|
||||||
|
### 1. User 서비스 (회원 인증 및 매장 정보 관리)
|
||||||
|
|
||||||
|
| ID | 기능 | 우선순위 | 복잡도 | 핵심 요구사항 |
|
||||||
|
|----|------|----------|--------|-------------|
|
||||||
|
| UFR-USER-010 | 회원가입 | M | 21 | 이름, 전화번호, 이메일, 비밀번호, 매장명, 업종, 주소, 사업자번호 검증 |
|
||||||
|
| UFR-USER-020 | 로그인 | M | 8 | 전화번호/비밀번호 입력, 로그인 유지 옵션, 대시보드 이동 |
|
||||||
|
| UFR-USER-030 | 프로필관리 | C | 8 | 기본 정보, 매장 정보, 비밀번호 변경 |
|
||||||
|
| UFR-USER-040 | 로그아웃 | S | 3 | 안전한 세션 종료, 확인 다이얼로그 |
|
||||||
|
|
||||||
|
**검증 로직:**
|
||||||
|
- 사업자번호 형식 검증 (XXX-XX-XXXXX)
|
||||||
|
- 사업자번호 유효성 확인 및 휴폐업 여부 확인 (국세청 API)
|
||||||
|
- 매장명과 사업자 정보 일치 확인
|
||||||
|
|
||||||
|
**주요 흐름:**
|
||||||
|
1. UFR-USER-020 (로그인) → UFR-EVENT-010 (대시보드)
|
||||||
|
2. 신규 사용자: 회원가입(UFR-USER-010) → 대시보드(UFR-EVENT-010)
|
||||||
|
3. 대시보드에서 "새 이벤트 만들기" → UFR-EVENT-020
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Event 서비스 (이벤트 관리)
|
||||||
|
|
||||||
|
| ID | 기능 | 우선순위 | 복잡도 | 핵심 요구사항 |
|
||||||
|
|----|------|----------|--------|-------------|
|
||||||
|
| UFR-EVENT-010 | 대시보드 | S | 13 | 진행중/예정/종료 이벤트 목록, 통계, 새 이벤트 만들기 버튼 |
|
||||||
|
| UFR-EVENT-020 | 이벤트목적선택 | M | 5 | 신규고객 유치/재방문 유도/매출 증대/인지도 향상 선택 |
|
||||||
|
| UFR-EVENT-030 | AI이벤트추천 | M | 34 | 트렌드 분석 결과, 3가지 이벤트 추천, 제목/경품 간단 수정 |
|
||||||
|
| UFR-EVENT-040 | 배포채널선택 | M | 13 | 우리동네TV, 링고비즈, 지니TV, SNS 선택, 예상 노출 수 |
|
||||||
|
| UFR-EVENT-050 | 최종승인 | M | 13 | 이벤트 정보 확인, 승인 처리, 배포 시작 |
|
||||||
|
| UFR-EVENT-060 | 이벤트상세조회 | S | 13 | 기본 정보, 실시간 통계, 배포 현황, 참여자 목록 |
|
||||||
|
| UFR-EVENT-070 | 이벤트목록관리 | S | 13 | 전체 이벤트 목록, 필터링, 검색, 정렬 |
|
||||||
|
|
||||||
|
**이벤트 생성 플로우:**
|
||||||
|
1. 이벤트 목적 선택 (UFR-EVENT-020)
|
||||||
|
2. AI 트렌드 분석 및 이벤트 추천 (UFR-EVENT-030)
|
||||||
|
3. 콘텐츠 생성 (UFR-CONT-010)
|
||||||
|
4. 배포채널 선택 (UFR-EVENT-040)
|
||||||
|
5. 최종승인 (UFR-EVENT-050)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. AI 서비스 (AI 분석 및 추천)
|
||||||
|
|
||||||
|
| ID | 기능 | 우선순위 | 복잡도 | 핵심 요구사항 | AI 모델 |
|
||||||
|
|----|------|----------|--------|-------------|---------|
|
||||||
|
| UFR-AI-010 | AI트렌드분석및이벤트추천 | M | 34 | 업종/지역/시즌 트렌드 분석, 3가지 이벤트 기획안 생성, 10초 이내 완료 | Claude API / GPT-4 API |
|
||||||
|
|
||||||
|
**분석 항목:**
|
||||||
|
- 업종 트렌드: 최근 성공 이벤트, 고객 선호 경품, 효과적인 참여 방법
|
||||||
|
- 지역 특성: 지역별 이벤트 성공률, 고객 특성
|
||||||
|
- 시즌 특성: 계절/시기별 추천 이벤트
|
||||||
|
|
||||||
|
**추천 기준:**
|
||||||
|
- 옵션 1: 저비용, 높은 참여율 중심
|
||||||
|
- 옵션 2: 중비용, 균형잡힌 투자 대비 수익률
|
||||||
|
- 옵션 3: 고비용, 높은 매출 증대 효과
|
||||||
|
|
||||||
|
**성능 목표:** 전체 기획 과정 10초 이내 완료 (병렬 처리)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Content 서비스 (콘텐츠 생성)
|
||||||
|
|
||||||
|
| ID | 기능 | 우선순위 | 복잡도 | 핵심 요구사항 | AI 모델 |
|
||||||
|
|----|------|----------|--------|-------------|---------|
|
||||||
|
| UFR-CONT-010 | SNS이미지생성 | M | 21 | 3가지 스타일 (심플/화려/트렌디), 플랫폼별 최적화 | Stable Diffusion / DALL-E |
|
||||||
|
| UFR-CONT-020 | 콘텐츠편집 | S | 13 | 텍스트 수정, 색상 조정, 로고 위치 변경 | - |
|
||||||
|
|
||||||
|
**플랫폼별 최적화:**
|
||||||
|
- Instagram: 1080x1080
|
||||||
|
- Naver Blog: 800x600
|
||||||
|
- Kakao Channel: 800x800
|
||||||
|
|
||||||
|
**성능 목표:**
|
||||||
|
- 이미지 생성: 30초 이내
|
||||||
|
- 실시간 미리보기 제공
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Distribution 서비스 (다중 채널 배포)
|
||||||
|
|
||||||
|
| ID | 기능 | 우선순위 | 복잡도 | 핵심 요구사항 | 연동 API |
|
||||||
|
|----|------|----------|--------|-------------|---------|
|
||||||
|
| UFR-DIST-010 | 다중채널배포 | M | 21 | 우리동네TV, 링고비즈, 지니TV, SNS 동시 배포, 1분 이내 완료 | 우리동네TV, 링고비즈, 지니TV, SNS APIs |
|
||||||
|
| UFR-DIST-020 | 배포상태모니터링 | S | 13 | 채널별 배포 상태, 진행률, 오류 메시지, 재시도 기능 | - |
|
||||||
|
|
||||||
|
**배포 채널:**
|
||||||
|
1. 우리동네TV (반경 500m/1km, 송출 시간대)
|
||||||
|
2. 링고비즈 (매장 전화 연결음 업데이트)
|
||||||
|
3. 지니TV (타겟 지역, 노출 시간대, 예산)
|
||||||
|
4. SNS (Instagram, Naver Blog, Kakao Channel)
|
||||||
|
|
||||||
|
**성능 목표:** 전체 배포 과정 1분 이내 완료
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Participation 서비스 (이벤트 참여 및 접수 관리)
|
||||||
|
|
||||||
|
| ID | 기능 | 우선순위 | 복잡도 | 핵심 요구사항 |
|
||||||
|
|----|------|----------|--------|-------------|
|
||||||
|
| UFR-PART-010 | 이벤트참여 | M | 13 | 이름, 전화번호 입력, 참여 경로 추적, 응모번호 발급, 중복 방지 |
|
||||||
|
| UFR-PART-020 | 참여자목록조회 | S | 8 | 참여자 테이블, 필터링, 검색, 개인정보 마스킹 |
|
||||||
|
| UFR-PART-030 | 당첨자추첨 | M | 13 | 자동 추첨, 매장 방문 고객 가산점, 재추첨 기능 |
|
||||||
|
|
||||||
|
**정책:**
|
||||||
|
- 1인 1회 참여 제한 (전화번호 기반)
|
||||||
|
- 개인정보 보호 규정 준수
|
||||||
|
- 매장 방문 고객 가산점 부여 (선택 가능)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Analytics 서비스 (실시간 효과 측정 및 분석)
|
||||||
|
|
||||||
|
| ID | 기능 | 우선순위 | 복잡도 | 핵심 요구사항 | 데이터 소스 |
|
||||||
|
|----|------|----------|--------|-------------|------------|
|
||||||
|
| UFR-ANAL-010 | 실시간통합대시보드 | M | 34 | 참여자 수, 노출 수, 투자 대비 수익률, 매출 증가율, 채널별 성과, 5분 간격 업데이트 | KT 채널 API, POS, SNS API |
|
||||||
|
|
||||||
|
**대시보드 구성:**
|
||||||
|
1. 상단 요약 카드: 총 참여자 수, 총 노출 수, 예상 투자 대비 수익률, 매출 증가율
|
||||||
|
2. 채널별 성과 분석: 막대 그래프, 원 그래프
|
||||||
|
3. 시간대별 참여 추이: 라인 차트
|
||||||
|
4. 투자 대비 수익률 상세 분석: 총 비용, 예상 수익, 손익분기점
|
||||||
|
5. 참여자 프로필 분석: 연령대별, 성별, 지역별 분포
|
||||||
|
6. 비교 분석: 업종 평균, 이전 이벤트 대비
|
||||||
|
|
||||||
|
**데이터 수집:**
|
||||||
|
- 실시간 데이터 수집 (5분 간격)
|
||||||
|
- WebSocket 또는 SSE를 통한 실시간 업데이트
|
||||||
|
- 데이터 캐싱 (Redis)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 기술 스택 요약
|
||||||
|
|
||||||
|
### AI/ML 모델
|
||||||
|
| 모델 | 용도 | 관련 유저스토리 |
|
||||||
|
|------|------|----------------|
|
||||||
|
| Claude API / GPT-4 API | 트렌드 분석, 이벤트 추천, 홍보 문구 생성 | UFR-AI-010 |
|
||||||
|
| Stable Diffusion / DALL-E | 이미지 생성 | UFR-CONT-010 |
|
||||||
|
|
||||||
|
### 외부 API 연동
|
||||||
|
| API | 용도 | 관련 유저스토리 |
|
||||||
|
|-----|------|----------------|
|
||||||
|
| 국세청 API | 사업자번호 검증 | UFR-USER-010 |
|
||||||
|
| 우리동네TV API | 지역 타겟팅 영상 송출 | UFR-DIST-010 |
|
||||||
|
| 링고비즈 API | 연결음 업데이트 | UFR-DIST-010 |
|
||||||
|
| 지니TV API | TV 광고 배포 | UFR-DIST-010 |
|
||||||
|
| Instagram API | SNS 자동 포스팅, 성과 데이터 수집 | UFR-DIST-010, UFR-ANAL-010 |
|
||||||
|
| Naver API | 블로그 자동 포스팅 | UFR-DIST-010 |
|
||||||
|
| Kakao API | 카카오 채널 자동 포스팅, 성과 데이터 수집 | UFR-DIST-010, UFR-ANAL-010 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 마이크로서비스 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ API Gateway │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌─────────────────────┼─────────────────────┐
|
||||||
|
│ │ │
|
||||||
|
┌───────▼────────┐ ┌────────▼────────┐ ┌────────▼────────┐
|
||||||
|
│ User Service │ │ Event Service │ │ AI Service │
|
||||||
|
│ (UFR-USER) │ │ (UFR-EVENT) │ │ (UFR-AI) │
|
||||||
|
└────────────────┘ └─────────────────┘ └─────────────────┘
|
||||||
|
│ │ │
|
||||||
|
│ │ │
|
||||||
|
┌───────▼────────┐ ┌────────▼────────┐ ┌────────▼────────┐
|
||||||
|
│ Content │ │ Distribution │ │ Participation │
|
||||||
|
│ (UFR-CONT) │ │ (UFR-DIST) │ │ (UFR-PART) │
|
||||||
|
└────────────────┘ └─────────────────┘ └─────────────────┘
|
||||||
|
│ │ │
|
||||||
|
└─────────────────────┼─────────────────────┘
|
||||||
|
│
|
||||||
|
┌─────────▼─────────┐
|
||||||
|
│ Analytics │
|
||||||
|
│ (UFR-ANAL) │
|
||||||
|
└───────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 서비스 간 의존성
|
||||||
|
|
||||||
|
- User → Event: 사용자 인증 정보 제공
|
||||||
|
- Event → AI: 트렌드 분석 및 이벤트 추천 요청
|
||||||
|
- Event → Content: 콘텐츠 생성 요청
|
||||||
|
- Event → Distribution: 배포 요청
|
||||||
|
- Distribution → Content: 생성된 콘텐츠 사용
|
||||||
|
- Participation → Analytics: 참여자 데이터 제공
|
||||||
|
- Distribution → Analytics: 노출 데이터 제공
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 개발 우선순위 로드맵
|
||||||
|
|
||||||
|
### Phase 1: MVP (8주)
|
||||||
|
**목표:** 기본 이벤트 기획 및 배포 기능
|
||||||
|
|
||||||
|
| 순서 | 서비스 | 유저스토리 | 비고 |
|
||||||
|
|------|--------|-----------|------|
|
||||||
|
| 1 | User | UFR-USER-010, UFR-USER-020 | 회원가입, 로그인 |
|
||||||
|
| 2 | Event | UFR-EVENT-020, UFR-EVENT-030, UFR-EVENT-050 | 목적 선택, AI 추천, 승인 |
|
||||||
|
| 3 | AI | UFR-AI-010 | 트렌드 분석 및 이벤트 추천 |
|
||||||
|
| 4 | Content | UFR-CONT-010 | 이미지 생성 |
|
||||||
|
| 5 | Distribution | UFR-DIST-010 | 다중 채널 배포 |
|
||||||
|
| 6 | Participation | UFR-PART-010, UFR-PART-030 | 참여 신청, 자동 추첨 |
|
||||||
|
|
||||||
|
### Phase 2: 고도화 (6주)
|
||||||
|
**목표:** 관리 및 분석 기능 추가
|
||||||
|
|
||||||
|
| 순서 | 서비스 | 유저스토리 | 비고 |
|
||||||
|
|------|--------|-----------|------|
|
||||||
|
| 7 | Event | UFR-EVENT-010, UFR-EVENT-040, UFR-EVENT-060, UFR-EVENT-070 | 대시보드, 채널 선택, 상세 조회, 목록 관리 |
|
||||||
|
| 8 | Analytics | UFR-ANAL-010 | 실시간 통합 대시보드 |
|
||||||
|
| 9 | Distribution | UFR-DIST-020 | 배포 상태 모니터링 |
|
||||||
|
| 10 | Participation | UFR-PART-020 | 참여자 목록 조회 |
|
||||||
|
|
||||||
|
### Phase 3: 완성 (2주)
|
||||||
|
**목표:** 선택 기능 추가
|
||||||
|
|
||||||
|
| 순서 | 서비스 | 유저스토리 | 비고 |
|
||||||
|
|------|--------|-----------|------|
|
||||||
|
| 11 | User | UFR-USER-030, UFR-USER-040 | 프로필 관리, 로그아웃 |
|
||||||
|
| 12 | Content | UFR-CONT-020 | 콘텐츠 편집 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 주요 기술적 리스크 및 해결방안
|
||||||
|
|
||||||
|
### 1. AI 응답 시간 (10초 목표)
|
||||||
|
- **리스크**: AI API 응답이 목표 시간을 초과할 수 있음
|
||||||
|
- **해결방안**:
|
||||||
|
- 프롬프트 최적화로 응답 시간 단축
|
||||||
|
- 트렌드 분석과 이벤트 추천 병렬 처리
|
||||||
|
- 비동기 처리 + 폴링 방식
|
||||||
|
- 부분 응답 스트리밍
|
||||||
|
|
||||||
|
### 2. 다중 외부 API 의존성
|
||||||
|
- **리스크**: API 장애 시 서비스 중단
|
||||||
|
- **해결방안**:
|
||||||
|
- 각 API별 폴백 전략 수립
|
||||||
|
- 캐싱 적극 활용
|
||||||
|
- 써킷 브레이커 패턴 적용
|
||||||
|
- 채널별 독립 처리
|
||||||
|
|
||||||
|
### 3. 실시간 대시보드 성능
|
||||||
|
- **리스크**: 다중 데이터 소스 통합으로 인한 성능 저하
|
||||||
|
- **해결방안**:
|
||||||
|
- Redis 캐싱
|
||||||
|
- 5분 간격 데이터 폴링
|
||||||
|
- WebSocket/SSE를 통한 효율적인 업데이트
|
||||||
|
- 차트 데이터 메모이제이션
|
||||||
|
|
||||||
|
### 4. 이미지 생성 비용 및 시간
|
||||||
|
- **리스크**: 이미지 생성 API 비용 및 생성 시간
|
||||||
|
- **해결방안**:
|
||||||
|
- 생성된 이미지 캐싱
|
||||||
|
- CDN 활용
|
||||||
|
- 비동기 생성 + 진행 상황 표시
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 예상 개발 기간
|
||||||
|
|
||||||
|
- User 서비스: 2주
|
||||||
|
- Event 서비스: 3주
|
||||||
|
- AI 서비스: 4주 (프롬프트 엔지니어링 포함)
|
||||||
|
- Content 서비스: 3주
|
||||||
|
- Distribution 서비스: 4주 (다중 API 연동)
|
||||||
|
- Participation 서비스: 2주
|
||||||
|
- Analytics 서비스: 4주 (통합 대시보드)
|
||||||
|
|
||||||
|
**총 예상 기간**: 12-16주 (병렬 개발 시)
|
||||||
998
design/userstory.md
Normal file
998
design/userstory.md
Normal file
@ -0,0 +1,998 @@
|
|||||||
|
# KT AI 기반 소상공인 이벤트 자동 생성 서비스 - 유저스토리 ver2.0
|
||||||
|
|
||||||
|
- [KT AI 기반 소상공인 이벤트 자동 생성 서비스 - 유저스토리 ver2.0](#kt-ai-기반-소상공인-이벤트-자동-생성-서비스---유저스토리-ver20)
|
||||||
|
- [마이크로서비스 구성](#마이크로서비스-구성)
|
||||||
|
- [유저스토리](#유저스토리)
|
||||||
|
- [기술 검토 결과](#기술-검토-결과)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 마이크로서비스 구성
|
||||||
|
1. **User** - 사용자 인증 및 매장정보 관리
|
||||||
|
2. **Event** - 이벤트 기획 및 관리
|
||||||
|
3. **AI** - AI 기반 트렌드 분석 및 이벤트 추천
|
||||||
|
4. **Content** - SNS 콘텐츠 생성
|
||||||
|
5. **Distribution** - 다중 채널 배포 관리
|
||||||
|
6. **Participation** - 이벤트 참여 및 당첨자 관리
|
||||||
|
7. **Analytics** - 실시간 효과 측정 및 통합 대시보드
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 유저스토리
|
||||||
|
```
|
||||||
|
1. User 서비스
|
||||||
|
1) 사용자 인증 및 매장정보 관리
|
||||||
|
UFR-USER-010: [회원가입] 소상공인으로서 | 나는 이벤트 마케팅 서비스를 이용하기 위해 | 간편하게 회원가입하고 싶다.
|
||||||
|
- 시나리오: 신규 회원가입
|
||||||
|
미로그인 상태에서 회원가입 화면에 접근한 상황에서 | 필수 정보를 모두 입력하고 회원가입 버튼을 클릭하면 | 사업자번호 검증을 거쳐 회원가입이 완료된다.
|
||||||
|
|
||||||
|
[입력 요구사항]
|
||||||
|
- 기본 정보
|
||||||
|
- 이름: 2자 이상 (한글/영문)
|
||||||
|
- 전화번호: 휴대폰 번호 형식
|
||||||
|
- 이메일: 이메일 형식 준수
|
||||||
|
- 비밀번호: 8자 이상 (영문/숫자/특수문자 포함)
|
||||||
|
|
||||||
|
[매장 정보 입력]
|
||||||
|
- 매장명: 필수
|
||||||
|
- 업종: 선택 (예: 음식점, 카페, 소매점 등)
|
||||||
|
- 주소: 필수
|
||||||
|
- 영업시간: 선택
|
||||||
|
- 사업자번호: 필수
|
||||||
|
- 국세청 DB 조회를 통한 검증
|
||||||
|
- 휴폐업 여부 확인
|
||||||
|
- 검증 실패 시 재입력 요청
|
||||||
|
|
||||||
|
[처리 결과]
|
||||||
|
- 성공: "회원가입이 완료되었습니다" 메시지 → 대시보드 화면
|
||||||
|
- 실패: 구체적인 오류 메시지 표시
|
||||||
|
|
||||||
|
- M/21
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
UFR-USER-020: [로그인] 소상공인으로서 | 나는 이벤트를 관리하기 위해 | 간편하게 로그인하고 싶다.
|
||||||
|
- 시나리오: 사용자 로그인
|
||||||
|
미로그인 상태에서 로그인 화면에 접근한 상황에서 | 전화번호와 비밀번호를 입력하고 로그인 버튼을 클릭하면 | 인증이 완료되고 대시보드로 이동한다.
|
||||||
|
|
||||||
|
[입력 요구사항]
|
||||||
|
- 전화번호: 등록된 전화번호 입력
|
||||||
|
- 비밀번호: 해당 계정의 비밀번호 입력
|
||||||
|
- 로그인 유지: 선택 옵션 제공
|
||||||
|
|
||||||
|
[인증 처리]
|
||||||
|
- 성공: 대시보드로 즉시 이동
|
||||||
|
- 실패: "전화번호 또는 비밀번호를 확인해주세요" 메시지
|
||||||
|
|
||||||
|
- M/8
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
UFR-USER-030: [프로필관리] 소상공인으로서 | 나는 내 정보를 최신 상태로 유지하기 위해 | 프로필 정보를 편집하고 싶다.
|
||||||
|
- 시나리오: 프로필 정보 수정
|
||||||
|
Bottom Navigation의 "프로필" 탭을 클릭하여 프로필 편집 화면에 접근한 상황에서 | 변경할 정보를 수정하고 저장 버튼을 클릭하면 | 변경사항이 저장되고 확인 메시지가 표시된다.
|
||||||
|
|
||||||
|
[수정 가능 항목]
|
||||||
|
- 기본 정보
|
||||||
|
- 이름: 실명 변경 가능
|
||||||
|
- 전화번호: 변경 시 재인증 필요
|
||||||
|
- 이메일: 이메일 변경 가능
|
||||||
|
- 매장 정보
|
||||||
|
- 매장명
|
||||||
|
- 업종
|
||||||
|
- 주소
|
||||||
|
- 영업시간
|
||||||
|
- 비밀번호
|
||||||
|
- 현재 비밀번호 확인 필수
|
||||||
|
- 새 비밀번호 규칙: 8자 이상 (영문/숫자/특수문자 포함)
|
||||||
|
|
||||||
|
[저장 처리]
|
||||||
|
- 저장 전 확인: "변경사항을 저장하시겠습니까?"
|
||||||
|
- 저장 완료: "프로필이 성공적으로 업데이트되었습니다"
|
||||||
|
- 취소 선택: "변경사항이 저장되지 않습니다. 계속하시겠습니까?"
|
||||||
|
|
||||||
|
- C/8
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
UFR-USER-040: [로그아웃] 소상공인으로서 | 나는 보안을 위해 | 서비스 사용 후 안전하게 로그아웃하고 싶다.
|
||||||
|
- 시나리오: 안전한 로그아웃
|
||||||
|
Bottom Navigation의 "프로필" 탭에서 로그아웃 버튼을 선택한 상황에서 | 로그아웃 확인 다이얼로그에서 확인을 클릭하면 | 세션이 종료되고 로그인 화면으로 이동한다.
|
||||||
|
|
||||||
|
[로그아웃 요구사항]
|
||||||
|
- 확인 다이얼로그
|
||||||
|
- 메시지: "로그아웃 하시겠습니까?"
|
||||||
|
- 버튼: 취소/확인
|
||||||
|
|
||||||
|
[처리 결과]
|
||||||
|
- 확인 선택: "안전하게 로그아웃되었습니다" 메시지 → 로그인 화면
|
||||||
|
- 취소 선택: 다이얼로그 닫힘, 현재 화면 유지
|
||||||
|
|
||||||
|
- S/3
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
2. Event 서비스
|
||||||
|
1) 이벤트 관리
|
||||||
|
UFR-EVENT-010: [대시보드] 소상공인으로서 | 나는 내 이벤트를 효율적으로 관리하기 위해 | 대시보드에서 진행중/예정/종료된 이벤트를 한눈에 확인하고 싶다.
|
||||||
|
- 시나리오: 대시보드에서 이벤트 목록 확인
|
||||||
|
로그인한 상태에서 Bottom Navigation의 "홈" 탭을 클릭하거나, 최초 로그인 시 대시보드에 접근한 상황에서 | 이벤트 목록 영역을 확인하면 | 내 이벤트들이 상태별로 구분되어 표시된다.
|
||||||
|
|
||||||
|
[표시 요구사항]
|
||||||
|
- 이벤트 상태별 섹션 구성
|
||||||
|
- "진행중": 현재 진행 중인 이벤트
|
||||||
|
- "예정": 배포 대기 중인 이벤트
|
||||||
|
- "종료": 완료된 이벤트
|
||||||
|
- 이벤트 카드 정보
|
||||||
|
- 이벤트명
|
||||||
|
- 이벤트 기간
|
||||||
|
- 진행 상태 뱃지
|
||||||
|
- 간단한 통계 (참여자 수, 조회수 등)
|
||||||
|
|
||||||
|
[상호작용 요구사항]
|
||||||
|
- 이벤트 카드 클릭: 해당 이벤트 상세 화면으로 이동
|
||||||
|
- 섹션당 최대 5개 카드 표시 (최신 순)
|
||||||
|
- "전체보기" 링크: 전체 이벤트 목록 화면으로 이동
|
||||||
|
- "새 이벤트 만들기" 버튼: 이벤트 생성 프로세스 시작
|
||||||
|
|
||||||
|
[빈 상태 처리]
|
||||||
|
- 이벤트가 없을 경우: "첫 이벤트를 만들어보세요" 안내 및 생성 버튼
|
||||||
|
|
||||||
|
- S/13
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
UFR-EVENT-020: [이벤트목적선택] 소상공인으로서 | 나는 효과적인 이벤트를 기획하기 위해 | 이벤트 목적을 먼저 선택하고 싶다.
|
||||||
|
- 시나리오: 이벤트 목적 선택
|
||||||
|
대시보드에서 "새 이벤트 만들기" 버튼을 클릭한 상황에서 | 이벤트 목적 선택 화면에서 하나의 목적을 선택하면 | AI 트렌드 분석 및 이벤트 추천 화면으로 이동한다.
|
||||||
|
|
||||||
|
[목적 옵션]
|
||||||
|
- 신규 고객 유치: 새로운 고객 확보
|
||||||
|
- 재방문 유도: 기존 고객 재방문 촉진
|
||||||
|
- 매출 증대: 단기 매출 향상
|
||||||
|
- 인지도 향상: 브랜드/매장 인지도 제고
|
||||||
|
|
||||||
|
[목적별 설명 제공]
|
||||||
|
- 각 목적에 대한 간단한 설명
|
||||||
|
- 예상 효과 예시
|
||||||
|
|
||||||
|
[다음 단계]
|
||||||
|
- 목적 선택 완료 시 자동으로 AI 트렌드 분석 및 이벤트 추천 화면으로 이동
|
||||||
|
|
||||||
|
- M/5
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
UFR-EVENT-030: [AI이벤트추천] 소상공인으로서 | 나는 최신 트렌드를 반영하고 선택지를 비교하기 위해 | AI 트렌드 분석 결과와 함께 3가지 이벤트 추천을 확인하고 싶다.
|
||||||
|
- 시나리오: AI 트렌드 분석 및 이벤트 추천 확인
|
||||||
|
이벤트 목적을 선택한 상황에서 | AI 이벤트 추천 화면에 접근하면 | 트렌드 분석 결과와 함께 3가지 이벤트 기획안이 표시된다.
|
||||||
|
|
||||||
|
[화면 구성]
|
||||||
|
1. 트렌드 분석 결과 (상단)
|
||||||
|
- 업종 트렌드
|
||||||
|
- 최근 성공한 이벤트 유형
|
||||||
|
- 고객 선호 경품 분석
|
||||||
|
- 효과적인 참여 방법
|
||||||
|
- 지역 특성
|
||||||
|
- 지역별 이벤트 성공률
|
||||||
|
- 지역 고객 특성
|
||||||
|
- 시즌 특성
|
||||||
|
- 계절/시기별 추천 이벤트
|
||||||
|
- 특별 이벤트 시즌 (명절, 기념일 등)
|
||||||
|
|
||||||
|
2. 추천 이벤트 3가지 (하단)
|
||||||
|
각 카드에 다음 정보 포함:
|
||||||
|
- 이벤트 제목 (수정 가능)
|
||||||
|
- 추천 경품 (수정 가능)
|
||||||
|
- 참여 방법
|
||||||
|
- 예상 참여자 수
|
||||||
|
- 예상 비용
|
||||||
|
- 예상 투자 대비 수익률
|
||||||
|
|
||||||
|
[추천 기준]
|
||||||
|
- 이벤트 목적 기반 최적화
|
||||||
|
- 업종 트렌드 반영
|
||||||
|
- 예산 대비 효과 극대화
|
||||||
|
- 3가지 옵션 차별화:
|
||||||
|
- 옵션 1: 저비용, 높은 참여율 중심
|
||||||
|
- 옵션 2: 중비용, 균형잡힌 투자 대비 수익률
|
||||||
|
- 옵션 3: 고비용, 높은 매출 증대 효과
|
||||||
|
|
||||||
|
[간단한 커스텀 기능]
|
||||||
|
- 이벤트 제목: 각 카드에서 직접 수정 가능 (최대 50자)
|
||||||
|
- 경품명: 각 카드에서 직접 수정 가능
|
||||||
|
|
||||||
|
[이벤트 선택]
|
||||||
|
- 3가지 중 1개 선택 (라디오 버튼)
|
||||||
|
- 선택한 카드의 커스텀된 정보가 저장됨
|
||||||
|
- 선택 완료 후 "다음" 버튼 활성화
|
||||||
|
|
||||||
|
[프로세스 시간]
|
||||||
|
- 트렌드 분석 + 추천 생성: 10초 이내
|
||||||
|
- 진행 상황 표시 (로딩 인디케이터)
|
||||||
|
|
||||||
|
[다음 단계]
|
||||||
|
- 이벤트 선택 후 콘텐츠 생성 화면으로 이동
|
||||||
|
|
||||||
|
- M/34
|
||||||
|
|
||||||
|
- 기술 태스크
|
||||||
|
- AI 트렌드 분석 API
|
||||||
|
- 빅데이터 분석: 과거 성공 이벤트, 업종별 성공률, 지역 특성, 시즌 패턴
|
||||||
|
- 트렌드 리포트 생성: JSON 형태 반환
|
||||||
|
- 분석 완료 시간: 5초 이내
|
||||||
|
- AI 이벤트 추천 API
|
||||||
|
- Claude API / GPT-4 API 연동
|
||||||
|
- 기획안 생성 로직:
|
||||||
|
- 경품 추천 (예산 대비 매력도, 고객 선호도)
|
||||||
|
- 참여 방법 설계 (난이도별 차별화)
|
||||||
|
- 홍보 문구 생성
|
||||||
|
- 3가지 옵션 차별화 생성
|
||||||
|
- 예상 성과 계산 (참여자 수, 비용, 투자 대비 수익률)
|
||||||
|
- 생성 완료 시간: 5초 이내
|
||||||
|
- 프론트엔드 통합
|
||||||
|
- 트렌드 분석 결과 표시
|
||||||
|
- 3가지 추천 카드 UI
|
||||||
|
- 실시간 제목/경품 편집 기능
|
||||||
|
- 선택 상태 관리
|
||||||
|
- 성능 최적화
|
||||||
|
- 트렌드 분석과 추천 생성 병렬 처리
|
||||||
|
- 캐싱 전략
|
||||||
|
- 비동기 처리
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
UFR-EVENT-040: [배포채널선택] 소상공인으로서 | 나는 효과적인 이벤트 홍보를 위해 | 다중 배포 채널을 선택하고 싶다.
|
||||||
|
- 시나리오: 배포 채널 선택
|
||||||
|
이벤트 추천을 완료하고 콘텐츠 생성이 완료된 상황에서 | 배포 채널 선택 화면에서 원하는 채널을 선택하면 | 각 채널의 예상 노출 수가 표시된다.
|
||||||
|
|
||||||
|
[배포 채널 옵션]
|
||||||
|
1. 우리동네TV
|
||||||
|
- 반경 선택: 500m / 1km
|
||||||
|
- 송출 시간대: 평일 저녁 / 주말 점심 등
|
||||||
|
- 예상 노출 수 표시
|
||||||
|
|
||||||
|
2. 링고비즈 (연결음)
|
||||||
|
- 매장 전화번호 자동 연동
|
||||||
|
- 연결음 업데이트 안내
|
||||||
|
|
||||||
|
3. 지니TV 광고
|
||||||
|
- 타겟 지역 선택
|
||||||
|
- 노출 시간대 선택
|
||||||
|
- 예산 입력
|
||||||
|
- 예상 노출량 표시
|
||||||
|
|
||||||
|
4. SNS
|
||||||
|
- Instagram (체크박스)
|
||||||
|
- Naver Blog (체크박스)
|
||||||
|
- Kakao Channel (체크박스)
|
||||||
|
- 예약 시간 설정
|
||||||
|
|
||||||
|
[채널별 비용 안내]
|
||||||
|
- 각 채널별 예상 비용 표시
|
||||||
|
- 총 예상 비용 합계
|
||||||
|
|
||||||
|
[다음 단계]
|
||||||
|
- 최소 1개 이상 채널 선택 필수
|
||||||
|
- "다음" 버튼: 최종 승인 화면으로 이동
|
||||||
|
|
||||||
|
- M/13
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
UFR-EVENT-050: [최종승인] 소상공인으로서 | 나는 이벤트를 배포하기 전에 | 모든 설정을 최종 확인하고 승인하고 싶다.
|
||||||
|
- 시나리오: 이벤트 최종 승인
|
||||||
|
배포 채널을 선택한 상황에서 | 최종 승인 화면에서 모든 정보를 확인하고 승인 버튼을 클릭하면 | 이벤트가 생성되고 배포가 시작된다.
|
||||||
|
|
||||||
|
[최종 확인 정보]
|
||||||
|
1. 이벤트 정보
|
||||||
|
- 이벤트명
|
||||||
|
- 이벤트 기간
|
||||||
|
- 이벤트 목적
|
||||||
|
|
||||||
|
2. 경품 정보
|
||||||
|
- 경품명
|
||||||
|
- 경품 수량
|
||||||
|
|
||||||
|
3. 참여 방법
|
||||||
|
- 참여 조건 요약
|
||||||
|
|
||||||
|
4. 배포 채널
|
||||||
|
- 선택한 채널 목록
|
||||||
|
- 채널별 예상 노출 수
|
||||||
|
|
||||||
|
5. 예상 비용 및 효과
|
||||||
|
- 총 예상 비용
|
||||||
|
- 예상 참여자 수
|
||||||
|
- 예상 투자 대비 수익률
|
||||||
|
|
||||||
|
[승인 프로세스]
|
||||||
|
- "승인 및 배포" 버튼 클릭
|
||||||
|
- 확인 다이얼로그: "이벤트를 승인하고 배포하시겠습니까?"
|
||||||
|
- 승인 시 이벤트 생성 및 콘텐츠 생성 시작
|
||||||
|
|
||||||
|
[처리 결과]
|
||||||
|
- 성공: "이벤트가 생성되었습니다. 배포를 시작합니다." 메시지
|
||||||
|
- 자동으로 대시보드로 이동
|
||||||
|
- 배포 진행 상황 알림
|
||||||
|
|
||||||
|
- M/13
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
UFR-EVENT-060: [이벤트상세조회] 소상공인으로서 | 나는 진행 중인 이벤트의 현황을 파악하기 위해 | 이벤트 상세 정보를 조회하고 싶다.
|
||||||
|
- 시나리오: 이벤트 상세 정보 조회
|
||||||
|
대시보드에서 이벤트 카드를 클릭한 상황에서 | 이벤트 상세 화면에 접근하면 | 이벤트의 모든 정보와 실시간 통계가 표시된다.
|
||||||
|
|
||||||
|
[상세 정보 구성]
|
||||||
|
1. 기본 정보
|
||||||
|
- 이벤트명
|
||||||
|
- 이벤트 기간
|
||||||
|
- 진행 상태
|
||||||
|
- 경품 정보
|
||||||
|
- 참여 방법
|
||||||
|
|
||||||
|
2. 실시간 통계 (Analytics 서비스 연동)
|
||||||
|
- 참여자 수
|
||||||
|
- 노출 수
|
||||||
|
- 조회 수
|
||||||
|
- 공유 수
|
||||||
|
|
||||||
|
3. 배포 채널 현황
|
||||||
|
- 채널별 배포 상태
|
||||||
|
- 채널별 성과 요약
|
||||||
|
|
||||||
|
4. 참여자 목록
|
||||||
|
- 최근 참여자 10명
|
||||||
|
- "전체 참여자 보기" 링크
|
||||||
|
|
||||||
|
[액션 버튼]
|
||||||
|
- "효과 측정 대시보드": Analytics 대시보드로 이동
|
||||||
|
- "이벤트 수정": 진행 중인 이벤트 수정 (제한적)
|
||||||
|
- "이벤트 종료": 예정보다 빨리 종료
|
||||||
|
|
||||||
|
- S/13
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
UFR-EVENT-070: [이벤트목록관리] 소상공인으로서 | 나는 모든 이벤트를 관리하기 위해 | 전체 이벤트 목록을 조회하고 필터링/검색하고 싶다.
|
||||||
|
- 시나리오: 전체 이벤트 목록 조회
|
||||||
|
Bottom Navigation의 "이벤트" 탭을 클릭하거나, 대시보드에서 "전체보기" 링크를 클릭한 상황에서 | 이벤트 목록 화면에 접근하면 | 모든 이벤트가 테이블 형태로 표시된다.
|
||||||
|
|
||||||
|
[목록 표시 요구사항]
|
||||||
|
- 테이블 컬럼
|
||||||
|
- 이벤트명
|
||||||
|
- 이벤트 기간
|
||||||
|
- 상태 (진행중/예정/종료)
|
||||||
|
- 참여자 수
|
||||||
|
- 투자 대비 수익률
|
||||||
|
- 생성일
|
||||||
|
- 페이지네이션 (페이지당 20개)
|
||||||
|
|
||||||
|
[필터 옵션]
|
||||||
|
- 상태별 필터: 전체/진행중/예정/종료
|
||||||
|
- 기간별 필터: 최근 1개월/3개월/6개월/1년/전체
|
||||||
|
|
||||||
|
[검색 기능]
|
||||||
|
- 검색창: 이벤트명 검색
|
||||||
|
- 실시간 검색 결과
|
||||||
|
|
||||||
|
[정렬 옵션]
|
||||||
|
- 최신순 (기본값)
|
||||||
|
- 참여자 많은 순
|
||||||
|
- 투자 대비 수익률 높은 순
|
||||||
|
|
||||||
|
[이벤트 관리]
|
||||||
|
- 행 클릭: 이벤트 상세 화면
|
||||||
|
- 다중 선택: 일괄 삭제 기능
|
||||||
|
|
||||||
|
- S/13
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
3. AI 서비스
|
||||||
|
1) AI 분석 및 추천
|
||||||
|
UFR-AI-010: [AI트렌드분석및이벤트추천] AI 시스템으로서 | 나는 소상공인에게 최적의 이벤트를 제안하기 위해 | 업종/지역/시즌 트렌드를 분석하고 3가지 최적화된 이벤트 기획안을 생성하고 싶다.
|
||||||
|
- 시나리오: 트렌드 분석 및 이벤트 추천 수행
|
||||||
|
소상공인이 이벤트 목적을 선택한 상황에서 | AI 시스템이 요청을 받으면 | 트렌드 분석과 이벤트 추천을 병렬로 수행하여 결과를 반환한다.
|
||||||
|
|
||||||
|
[분석 데이터 소스]
|
||||||
|
- 과거 성공 이벤트 데이터
|
||||||
|
- 업종별 이벤트 성공률
|
||||||
|
- 지역별 고객 특성
|
||||||
|
- 시즌별 트렌드 패턴
|
||||||
|
|
||||||
|
[트렌드 분석 항목]
|
||||||
|
1. 업종 트렌드
|
||||||
|
- 최근 3개월 성공 이벤트 유형
|
||||||
|
- 고객 선호 경품 Top 5
|
||||||
|
- 효과적인 참여 방법 분석
|
||||||
|
|
||||||
|
2. 지역 특성
|
||||||
|
- 해당 지역 이벤트 성공률
|
||||||
|
- 지역 고객 연령대/성별 분포
|
||||||
|
|
||||||
|
3. 시즌 특성
|
||||||
|
- 계절별 추천 이벤트
|
||||||
|
- 특별 시즌 (명절, 기념일) 반영
|
||||||
|
|
||||||
|
[이벤트 추천 로직]
|
||||||
|
1. 경품 추천
|
||||||
|
- 예산 대비 매력도 최대화
|
||||||
|
- 타겟 고객 선호도 반영
|
||||||
|
- KT 멤버십 포인트 활용 가능 여부
|
||||||
|
|
||||||
|
2. 참여 방법 설계
|
||||||
|
- 간단한 참여 방법 (저 난이도)
|
||||||
|
- 재방문 유도 요소 (중 난이도)
|
||||||
|
- 바이럴 확산 장치 (고 난이도)
|
||||||
|
|
||||||
|
3. 홍보 문구 생성
|
||||||
|
- 이벤트 개요 기반 문구 5개 생성
|
||||||
|
- SNS 해시태그 자동 생성
|
||||||
|
|
||||||
|
[3가지 옵션 차별화]
|
||||||
|
- 옵션 1: 저비용, 높은 참여율 중심
|
||||||
|
- 옵션 2: 중비용, 균형잡힌 투자 대비 수익률
|
||||||
|
- 옵션 3: 고비용, 높은 매출 증대 효과
|
||||||
|
|
||||||
|
[예상 성과 계산]
|
||||||
|
- 예상 참여자 수
|
||||||
|
- 예상 비용
|
||||||
|
- 예상 투자 대비 수익률
|
||||||
|
|
||||||
|
[결과 출력]
|
||||||
|
- 트렌드 분석 결과 JSON 형태 반환
|
||||||
|
- 3가지 이벤트 기획안 JSON 배열 반환
|
||||||
|
- 전체 완료 시간: 10초 이내 (병렬 처리)
|
||||||
|
|
||||||
|
- M/34
|
||||||
|
|
||||||
|
- 기술 태스크
|
||||||
|
- AI 트렌드 분석 엔진
|
||||||
|
- 빅데이터 분석 시스템 연동
|
||||||
|
- 업종/지역/시즌별 데이터 집계
|
||||||
|
- 트렌드 패턴 인식 알고리즘
|
||||||
|
- AI 이벤트 추천 엔진
|
||||||
|
- Claude API / GPT-4 API 연동
|
||||||
|
- 프롬프트 엔지니어링 최적화
|
||||||
|
- 응답 파싱 및 구조화
|
||||||
|
- 병렬 처리 아키텍처
|
||||||
|
- 트렌드 분석과 이벤트 추천 동시 실행
|
||||||
|
- 결과 통합 로직
|
||||||
|
- 성능 최적화
|
||||||
|
- 캐싱 전략 (Redis)
|
||||||
|
- 비동기 처리
|
||||||
|
- 응답 시간 모니터링
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
4. Content 서비스
|
||||||
|
1) 콘텐츠 생성
|
||||||
|
UFR-CONT-010: [SNS이미지생성] 소상공인으로서 | 나는 SNS에 게시할 이벤트 이미지를 쉽게 만들기 위해 | AI가 자동으로 3가지 스타일의 이미지를 생성하기를 원한다.
|
||||||
|
- 시나리오: SNS 이미지 자동 생성
|
||||||
|
이벤트 추천을 완료한 상황에서 | SNS 이미지 생성 화면에 접근하면 | AI가 3가지 스타일의 SNS 이미지를 자동 생성한다.
|
||||||
|
|
||||||
|
[이미지 생성 입력]
|
||||||
|
- 이벤트 제목
|
||||||
|
- 경품 정보
|
||||||
|
- 브랜드 컬러 (프로필에서 가져옴)
|
||||||
|
- 로고 이미지 (업로드된 경우)
|
||||||
|
|
||||||
|
[3가지 스타일 카드 선택]
|
||||||
|
1. 심플 스타일
|
||||||
|
- 깔끔한 디자인
|
||||||
|
- 텍스트 중심
|
||||||
|
- 읽기 쉬운 구성
|
||||||
|
- 카드 형태로 제공 (선택 가능)
|
||||||
|
|
||||||
|
2. 화려한 스타일
|
||||||
|
- 눈에 띄는 디자인
|
||||||
|
- 이미지 강조
|
||||||
|
- 풍부한 색상
|
||||||
|
- 카드 형태로 제공 (선택 가능)
|
||||||
|
|
||||||
|
3. 트렌디 스타일
|
||||||
|
- 최신 트렌드 반영
|
||||||
|
- SNS 최적화
|
||||||
|
- MZ세대 타겟
|
||||||
|
- 카드 형태로 제공 (선택 가능)
|
||||||
|
|
||||||
|
[플랫폼별 최적화]
|
||||||
|
- Instagram: 1080x1080
|
||||||
|
- Naver Blog: 800x600
|
||||||
|
- Kakao Channel: 800x800
|
||||||
|
|
||||||
|
[생성 프로세스]
|
||||||
|
- 생성 진행 표시 (스피너 애니메이션)
|
||||||
|
- "딥러닝 모델이 이벤트에 어울리는 이미지를 생성하고 있어요..." 안내
|
||||||
|
- 예상 소요 시간: 5초 이내
|
||||||
|
- 생성 완료 시 3가지 스타일 카드 표시
|
||||||
|
|
||||||
|
[이미지 선택 및 미리보기]
|
||||||
|
- 3가지 스타일 중 1개 선택 가능 (카드 선택 방식)
|
||||||
|
- 각 카드 클릭 시 풀스크린 미리보기 제공
|
||||||
|
- "다시 생성" 옵션 (최대 3회)
|
||||||
|
- 선택 완료 시 "다음" 버튼 활성화
|
||||||
|
|
||||||
|
- M/21
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
UFR-CONT-020: [콘텐츠편집] 소상공인으로서 | 나는 생성된 이미지를 내 스타일에 맞게 조정하기 위해 | 간단한 편집 기능을 사용하고 싶다.
|
||||||
|
- 시나리오: 생성된 콘텐츠 편집
|
||||||
|
SNS 이미지 스타일을 선택한 상황에서 | 콘텐츠 편집 화면에서 텍스트나 색상을 수정하면 | 실시간으로 미리보기가 업데이트된다.
|
||||||
|
|
||||||
|
[편집 가능 항목]
|
||||||
|
- 텍스트 수정
|
||||||
|
- 제목 텍스트 (인라인 편집)
|
||||||
|
- 경품 정보 텍스트 (인라인 편집)
|
||||||
|
- 참여 안내 텍스트 (인라인 편집)
|
||||||
|
- 색상 조정
|
||||||
|
- 배경색 (컬러 피커)
|
||||||
|
- 텍스트 색상 (컬러 피커)
|
||||||
|
- 강조 색상 (컬러 피커)
|
||||||
|
|
||||||
|
[편집 제약사항]
|
||||||
|
- 레이아웃 변경 불가
|
||||||
|
- 로고 위치/크기 조절 불가
|
||||||
|
- 이미지 교체 불가 (이전 화면의 "다시 생성"으로만 가능)
|
||||||
|
|
||||||
|
[실시간 미리보기]
|
||||||
|
- 수정사항 즉시 반영
|
||||||
|
- 플랫폼별 미리보기 전환 가능 (Instagram/Naver/Kakao)
|
||||||
|
- 모바일 상단에 미리보기 영역 표시
|
||||||
|
|
||||||
|
[저장 및 완료]
|
||||||
|
- "저장" 버튼: 편집 내용 임시 저장
|
||||||
|
- "다음" 버튼: 배포 채널 선택 화면으로 이동
|
||||||
|
|
||||||
|
- S/13
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
5. Distribution 서비스
|
||||||
|
1) 배포 관리
|
||||||
|
UFR-DIST-010: [다중채널배포] 소상공인으로서 | 나는 여러 채널에 동시에 이벤트를 홍보하기 위해 | 원클릭으로 다중 채널 배포를 실행하고 싶다.
|
||||||
|
- 시나리오: 다중 채널 동시 배포
|
||||||
|
최종 승인이 완료된 상황에서 | Distribution 서비스가 배포 요청을 받으면 | 선택된 모든 채널에 동시 배포를 시작한다.
|
||||||
|
|
||||||
|
[배포 채널 처리]
|
||||||
|
1. 우리동네TV
|
||||||
|
- 우리동네TV API 연동
|
||||||
|
- 15초 영상 업로드 (Content 서비스에서 생성된 이미지 활용)
|
||||||
|
- 송출 시간대 및 반경 설정 전달
|
||||||
|
- 배포 완료 시 배포 ID 수신
|
||||||
|
|
||||||
|
2. 링고비즈 (연결음)
|
||||||
|
- 링고비즈 API 연동
|
||||||
|
- 매장 전화번호로 연결음 업데이트
|
||||||
|
- 업데이트 완료 시각 수신
|
||||||
|
|
||||||
|
3. 지니TV 광고
|
||||||
|
- 지니TV 광고 API 연동
|
||||||
|
- 타겟 지역, 노출 시간대, 예산 정보 전달
|
||||||
|
- 광고 ID 및 노출 스케줄 수신
|
||||||
|
|
||||||
|
4. SNS 자동 포스팅
|
||||||
|
- Instagram, Naver Blog, Kakao Channel 병렬 처리
|
||||||
|
- 플랫폼별 최적화된 이미지 사용
|
||||||
|
- 예약 시간 설정 반영
|
||||||
|
- 각 플랫폼 포스팅 완료 확인
|
||||||
|
|
||||||
|
[배포 진행 상황]
|
||||||
|
- 실시간 진행 상황 알림
|
||||||
|
- 채널별 배포 상태 표시
|
||||||
|
- 예상 완료 시간: 1분 이내
|
||||||
|
|
||||||
|
[배포 실패 처리]
|
||||||
|
- 채널별 독립적 처리 (하나 실패해도 다른 채널 계속 진행)
|
||||||
|
- 실패 시 자동 재시도 (최대 3회)
|
||||||
|
- 최종 실패 시 소상공인에게 알림
|
||||||
|
|
||||||
|
[배포 완료 처리]
|
||||||
|
- 배포 이력 DB 저장
|
||||||
|
- 배포 채널 목록 및 예상 도달 수 반환
|
||||||
|
- 소상공인에게 배포 완료 알림 (앱 푸시/이메일)
|
||||||
|
|
||||||
|
- M/21
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
UFR-DIST-020: [배포상태모니터링] 소상공인으로서 | 나는 배포 진행 상황을 실시간으로 파악하기 위해 | 배포 상태를 모니터링하고 싶다.
|
||||||
|
- 시나리오: 배포 상태 실시간 확인
|
||||||
|
배포가 시작된 상황에서 | 이벤트 상세 화면의 배포 상태 섹션을 확인하면 | 채널별 배포 진행 상황이 실시간으로 표시된다.
|
||||||
|
|
||||||
|
[배포 상태 표시]
|
||||||
|
- 채널별 상태
|
||||||
|
- 대기중: 회색
|
||||||
|
- 진행중: 파란색 (진행률 표시)
|
||||||
|
- 완료: 초록색 (체크 표시)
|
||||||
|
- 실패: 빨간색 (오류 메시지)
|
||||||
|
|
||||||
|
[배포 정보]
|
||||||
|
- 우리동네TV: 배포 ID, 예상 노출 수
|
||||||
|
- 링고비즈: 업데이트 완료 시각
|
||||||
|
- 지니TV: 광고 ID, 노출 스케줄
|
||||||
|
- SNS: 포스팅 URL 링크
|
||||||
|
|
||||||
|
[재시도/취소]
|
||||||
|
- 실패한 채널: "재시도" 버튼
|
||||||
|
- 진행중인 배포: "취소" 버튼 (일부 채널만 가능)
|
||||||
|
|
||||||
|
- S/13
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
6. Participation 서비스
|
||||||
|
1) 참여자 관리
|
||||||
|
UFR-PART-010: [이벤트참여] 고객으로서 | 나는 간편하게 이벤트에 참여하기 위해 | 최소한의 정보만 입력하고 싶다.
|
||||||
|
- 시나리오: 고객 이벤트 참여
|
||||||
|
고객이 이벤트를 발견한 상황에서 | 이벤트 참여 화면에서 필수 정보를 입력하고 참여 버튼을 클릭하면 | 참여 접수가 완료된다.
|
||||||
|
|
||||||
|
[이벤트 발견 경로]
|
||||||
|
- 우리동네TV
|
||||||
|
- SNS (Instagram, Blog, Kakao)
|
||||||
|
- 매장 방문 (링고비즈 연결음 안내)
|
||||||
|
|
||||||
|
[참여 정보 입력]
|
||||||
|
- 이름: 필수 (2자 이상)
|
||||||
|
- 전화번호: 필수 (휴대폰 번호)
|
||||||
|
- 참여 경로: 자동 감지 또는 선택
|
||||||
|
|
||||||
|
[중복 참여 체크]
|
||||||
|
- 전화번호 기반 중복 확인
|
||||||
|
- 1인 1회 참여 제한
|
||||||
|
- 중복 시: "이미 참여하신 이벤트입니다" 안내
|
||||||
|
|
||||||
|
[참여 접수 완료]
|
||||||
|
- 응모 번호 발급
|
||||||
|
- 당첨 발표일 안내
|
||||||
|
- 참여 완료 화면 표시
|
||||||
|
|
||||||
|
[개인정보 처리]
|
||||||
|
- 개인정보 수집/이용 동의 필수
|
||||||
|
- 마케팅 활용 동의 선택
|
||||||
|
|
||||||
|
- M/13
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
UFR-PART-020: [참여자목록조회] 소상공인으로서 | 나는 누가 참여했는지 파악하기 위해 | 참여자 목록을 조회하고 싶다.
|
||||||
|
- 시나리오: 참여자 목록 확인
|
||||||
|
이벤트 상세 화면에서 "참여자 목록" 탭을 클릭한 상황에서 | 참여자 목록 화면에 접근하면 | 모든 참여자 정보가 테이블로 표시된다.
|
||||||
|
|
||||||
|
[목록 표시]
|
||||||
|
- 테이블 컬럼
|
||||||
|
- 번호 (응모 번호)
|
||||||
|
- 이름
|
||||||
|
- 전화번호 (일부 마스킹)
|
||||||
|
- 참여 경로
|
||||||
|
- 참여 일시
|
||||||
|
- 당첨 여부
|
||||||
|
- 페이지네이션
|
||||||
|
|
||||||
|
[필터 옵션]
|
||||||
|
- 참여 경로별 필터
|
||||||
|
- 당첨 여부 필터 (전체/당첨/미당첨)
|
||||||
|
|
||||||
|
[검색 기능]
|
||||||
|
- 이름 또는 전화번호로 검색
|
||||||
|
|
||||||
|
[액션]
|
||||||
|
- 개별 참여자 상세 정보 보기
|
||||||
|
|
||||||
|
- S/8
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
UFR-PART-030: [당첨자추첨] 소상공인으로서 | 나는 공정한 당첨자 선정을 위해 | 자동 추첨 기능을 사용하고 싶다.
|
||||||
|
- 시나리오: 당첨자 자동 추첨
|
||||||
|
이벤트 종료일이 도래한 상황에서 | 소상공인이 "당첨자 추첨" 버튼을 클릭하면 | 시스템이 자동으로 당첨자를 추첨한다.
|
||||||
|
|
||||||
|
[추첨 방식]
|
||||||
|
- 난수 기반 무작위 추첨
|
||||||
|
- 매장 방문 고객 가산점 옵션 (선택 가능)
|
||||||
|
- 추첨 과정 로그 자동 기록
|
||||||
|
|
||||||
|
[당첨 인원 설정]
|
||||||
|
- 경품 수량 기반 자동 설정
|
||||||
|
- 수동 조정 가능
|
||||||
|
|
||||||
|
[추첨 결과]
|
||||||
|
- 당첨자 목록 표시
|
||||||
|
- 당첨자 정보: 이름, 전화번호, 응모번호
|
||||||
|
|
||||||
|
[재추첨]
|
||||||
|
- "재추첨" 버튼: 당첨자 재선정 (추첨 전 확인)
|
||||||
|
- 이전 추첨 이력 보관
|
||||||
|
|
||||||
|
- M/13
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
7. Analytics 서비스
|
||||||
|
1) 효과 측정
|
||||||
|
UFR-ANAL-010: [성과분석] 소상공인으로서 | 나는 이벤트 성과를 한눈에 파악하기 위해 | 모든 지표가 통합된 실시간 성과분석 대시보드를 보고 싶다.
|
||||||
|
- 시나리오: 성과분석 대시보드 조회
|
||||||
|
Bottom Navigation의 "분석" 탭을 클릭하거나, 이벤트 상세 화면에서 "성과분석" 버튼을 클릭한 상황에서 | 성과분석 대시보드 화면에 접근하면 | 모든 핵심 지표가 하나의 화면에 실시간으로 표시된다.
|
||||||
|
|
||||||
|
[대시보드 구성]
|
||||||
|
|
||||||
|
1. 상단 요약 카드 (4개)
|
||||||
|
- 총 참여자 수
|
||||||
|
- 현재 참여자 수
|
||||||
|
- 목표 대비 달성률 (%)
|
||||||
|
- 총 노출 수
|
||||||
|
- 채널별 노출 합계
|
||||||
|
- 전일 대비 증감률
|
||||||
|
- 예상 투자 대비 수익률
|
||||||
|
- 실시간 투자 대비 수익률 계산
|
||||||
|
- 업종 평균 대비 비교
|
||||||
|
- 매출 증가율
|
||||||
|
- POS 연동 데이터 기반
|
||||||
|
- 이벤트 전후 비교 (%)
|
||||||
|
|
||||||
|
2. 채널별 성과 분석 (차트)
|
||||||
|
- 막대 그래프: 채널별 참여자 수
|
||||||
|
- 원 그래프: 채널별 참여 비율
|
||||||
|
- 채널 목록:
|
||||||
|
- 우리동네TV
|
||||||
|
- 링고비즈
|
||||||
|
- 지니TV
|
||||||
|
- Instagram
|
||||||
|
- Naver Blog
|
||||||
|
- Kakao Channel
|
||||||
|
- 각 채널별 세부 지표:
|
||||||
|
- 노출 수
|
||||||
|
- 참여자 수
|
||||||
|
- 전환율 (%)
|
||||||
|
- 비용 대비 효율 (CPA)
|
||||||
|
|
||||||
|
3. 시간대별 참여 추이 (라인 차트)
|
||||||
|
- X축: 시간 (일별/시간별 전환 가능)
|
||||||
|
- Y축: 참여자 수
|
||||||
|
- 실시간 업데이트 (5분 간격)
|
||||||
|
- 피크 시간대 하이라이트
|
||||||
|
|
||||||
|
4. 투자 대비 수익률 상세 분석 (표)
|
||||||
|
- 총 비용 산출
|
||||||
|
- 경품 비용
|
||||||
|
- 채널별 플랫폼 비용
|
||||||
|
- 기타 비용
|
||||||
|
- 예상 수익 산출
|
||||||
|
- 매출 증가액
|
||||||
|
- 신규 고객 LTV
|
||||||
|
- 투자 대비 수익률 계산식 표시
|
||||||
|
- 투자 대비 수익률 = (수익 - 비용) / 비용 × 100
|
||||||
|
- 손익분기점 표시
|
||||||
|
|
||||||
|
5. 참여자 프로필 분석 (차트)
|
||||||
|
- 연령대별 분포
|
||||||
|
- 성별 분포
|
||||||
|
- 지역별 분포
|
||||||
|
- 참여 시간대 분석
|
||||||
|
|
||||||
|
6. 비교 분석 (표)
|
||||||
|
- 업종 평균과 비교
|
||||||
|
- 참여율
|
||||||
|
- 투자 대비 수익률
|
||||||
|
- 전환율
|
||||||
|
- 내 이전 이벤트와 비교
|
||||||
|
- 성과 개선도
|
||||||
|
- 최고/최저 기록 대비
|
||||||
|
|
||||||
|
[실시간 업데이트]
|
||||||
|
- 모든 지표 5분 간격 자동 갱신
|
||||||
|
- 새로운 참여자 알림 (선택 옵션)
|
||||||
|
- 수동 새로고침 버튼
|
||||||
|
|
||||||
|
[데이터 소스]
|
||||||
|
- Participation 서비스: 참여자 데이터
|
||||||
|
- Distribution 서비스: 채널별 노출 수
|
||||||
|
- POS 시스템: 매출 데이터 (연동 시)
|
||||||
|
- 외부 API: 우리동네TV, 지니TV, SNS 통계
|
||||||
|
|
||||||
|
[모바일 최적화]
|
||||||
|
- 반응형 디자인
|
||||||
|
- 스크롤 가능한 레이아웃
|
||||||
|
- 핵심 지표 우선 표시
|
||||||
|
|
||||||
|
- M/34
|
||||||
|
|
||||||
|
- 기술 태스크
|
||||||
|
- 실시간 데이터 수집
|
||||||
|
- 5분 간격 데이터 폴링
|
||||||
|
- WebSocket 또는 SSE를 통한 실시간 업데이트
|
||||||
|
- 데이터 캐싱 (Redis)
|
||||||
|
- 다중 API 통합
|
||||||
|
- 우리동네TV API
|
||||||
|
- 링고비즈 API
|
||||||
|
- 지니TV API
|
||||||
|
- Instagram API
|
||||||
|
- Naver API
|
||||||
|
- Kakao API
|
||||||
|
- POS 시스템 연동
|
||||||
|
- 매출 데이터 수집
|
||||||
|
- 이벤트 전후 비교 로직
|
||||||
|
- 차트 라이브러리 활용
|
||||||
|
- Chart.js / Recharts 등
|
||||||
|
- 반응형 차트 구현
|
||||||
|
- 성능 최적화
|
||||||
|
- 차트 데이터 메모이제이션
|
||||||
|
- 무한 스크롤 대신 페이지네이션
|
||||||
|
- 이미지/데이터 lazy loading
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 기술 검토 결과
|
||||||
|
**[마이크로서비스별 검토]**
|
||||||
|
|
||||||
|
1) User 서비스
|
||||||
|
모든 유저스토리 **✅ 실현 가능**
|
||||||
|
- 표준적인 인증/인가 패턴
|
||||||
|
- JWT 토큰, Redis 캐싱 등 검증된 기술 활용
|
||||||
|
- 사업자번호 검증 API (국세청) 연동
|
||||||
|
|
||||||
|
2) Event 서비스
|
||||||
|
대부분 **✅ 실현 가능**
|
||||||
|
- 이벤트 CRUD 기능
|
||||||
|
- AI 서비스와의 연동
|
||||||
|
- LocalStorage 자동 저장 구현
|
||||||
|
|
||||||
|
3) AI 서비스
|
||||||
|
**⚠️ 높은 기술적 복잡도**
|
||||||
|
|
||||||
|
- UFR-AI-010: **주요 리스크 포인트**
|
||||||
|
- Claude API 또는 GPT-4 API 연동
|
||||||
|
- 트렌드 분석 + 이벤트 추천 병렬 처리
|
||||||
|
- 응답 시간 관리 (10초 목표)
|
||||||
|
- 프롬프트 엔지니어링 최적화
|
||||||
|
- **해결방안**: 캐싱, 병렬 처리, 비동기 처리
|
||||||
|
|
||||||
|
4) Content 서비스
|
||||||
|
**⚠️ 중간 복잡도**
|
||||||
|
|
||||||
|
- UFR-CONT-010: **이미지 생성 API 연동**
|
||||||
|
- Stable Diffusion 또는 DALL-E API
|
||||||
|
- 플랫폼별 이미지 크기 최적화
|
||||||
|
- **해결방안**: 이미지 캐싱, CDN 활용
|
||||||
|
|
||||||
|
5) Distribution 서비스
|
||||||
|
**⚠️ 다중 API 통합 복잡도**
|
||||||
|
|
||||||
|
- UFR-DIST-010: **외부 API 다중 연동**
|
||||||
|
- 우리동네TV API
|
||||||
|
- 링고비즈 API
|
||||||
|
- 지니TV API
|
||||||
|
- SNS API (Instagram, Naver, Kakao)
|
||||||
|
- **해결방안**: 채널별 독립 처리, 실패 시 재시도, 써킷 브레이커 패턴
|
||||||
|
|
||||||
|
6) Participation 서비스
|
||||||
|
대부분 **✅ 실현 가능**
|
||||||
|
- 표준적인 CRUD 기능
|
||||||
|
- 추첨 알고리즘 구현
|
||||||
|
|
||||||
|
7) Analytics 서비스
|
||||||
|
**⚠️ 높은 통합 복잡도**
|
||||||
|
|
||||||
|
- UFR-ANAL-010: **통합 대시보드 구현**
|
||||||
|
- 다중 데이터 소스 통합
|
||||||
|
- 실시간 데이터 수집 및 표시
|
||||||
|
- 복잡한 차트 및 그래프
|
||||||
|
- **해결방안**: 데이터 캐싱 (Redis), WebSocket/SSE, 차트 라이브러리 활용
|
||||||
|
|
||||||
|
**[주요 기술 스택 권장사항]**
|
||||||
|
|
||||||
|
**백엔드:**
|
||||||
|
- Node.js/Spring Boot (마이크로서비스)
|
||||||
|
- Redis (캐싱, 세션, 실시간 데이터)
|
||||||
|
- PostgreSQL/MongoDB
|
||||||
|
- RabbitMQ/Kafka (비동기 처리)
|
||||||
|
|
||||||
|
**프론트엔드:**
|
||||||
|
- React/Next.js
|
||||||
|
- TypeScript
|
||||||
|
- Zustand/Redux (상태관리)
|
||||||
|
- React Query (API 상태관리)
|
||||||
|
- Chart.js/Recharts (차트 라이브러리)
|
||||||
|
|
||||||
|
**인프라:**
|
||||||
|
- Docker/Kubernetes
|
||||||
|
- API Gateway
|
||||||
|
- Circuit Breaker
|
||||||
|
- 로드 밸런서
|
||||||
|
|
||||||
|
**외부 API:**
|
||||||
|
- AI: Claude API / GPT-4 API
|
||||||
|
- 이미지 생성: Stable Diffusion / DALL-E
|
||||||
|
- 사업자 검증: 국세청 API
|
||||||
|
- 배포 채널: 우리동네TV, 링고비즈, 지니TV, SNS APIs
|
||||||
|
|
||||||
|
**[주요 기술적 리스크 및 해결방안]**
|
||||||
|
|
||||||
|
**1. AI 응답 시간 (10초 목표)**
|
||||||
|
- 리스크: AI API 응답이 목표 시간을 초과할 수 있음
|
||||||
|
- 해결방안:
|
||||||
|
- 프롬프트 최적화로 응답 시간 단축
|
||||||
|
- 트렌드 분석과 이벤트 추천 병렬 처리
|
||||||
|
- 비동기 처리 + 폴링 방식
|
||||||
|
- 부분 응답 스트리밍
|
||||||
|
|
||||||
|
**2. 다중 외부 API 의존성**
|
||||||
|
- 리스크: API 장애 시 서비스 중단
|
||||||
|
- 해결방안:
|
||||||
|
- 각 API별 폴백 전략 수립
|
||||||
|
- 캐싱 적극 활용
|
||||||
|
- 써킷 브레이커 패턴 적용
|
||||||
|
- 채널별 독립 처리
|
||||||
|
|
||||||
|
**3. 실시간 대시보드 성능**
|
||||||
|
- 리스크: 다중 데이터 소스 통합으로 인한 성능 저하
|
||||||
|
- 해결방안:
|
||||||
|
- Redis 캐싱
|
||||||
|
- 5분 간격 데이터 폴링
|
||||||
|
- WebSocket/SSE를 통한 효율적인 업데이트
|
||||||
|
- 차트 데이터 메모이제이션
|
||||||
|
|
||||||
|
**4. 이미지 생성 비용 및 시간**
|
||||||
|
- 리스크: 이미지 생성 API 비용 및 생성 시간
|
||||||
|
- 해결방안:
|
||||||
|
- 생성된 이미지 캐싱
|
||||||
|
- CDN 활용
|
||||||
|
- 비동기 생성 + 진행 상황 표시
|
||||||
|
|
||||||
|
**[간소화된 플로우 검증]**
|
||||||
|
|
||||||
|
✅ **요청사항 반영 확인**
|
||||||
|
1. ✅ KT인증시스템연동 제외됨
|
||||||
|
2. ✅ 신규가입 시 무료체험 기능 삭제됨
|
||||||
|
3. ✅ 5회연속실패시 계정잠금기능 삭제됨
|
||||||
|
4. ✅ 이벤트커스텀 화면 통합됨 (이벤트추천조회 화면에서 간단히 제목/경품 수정 가능)
|
||||||
|
5. ✅ UFR-AI-010과 UFR-AI-020 통합됨 (트렌드 분석 + 이벤트 추천 한 번에 처리)
|
||||||
|
6. ✅ UFR-PART-040 삭제됨 (당첨알림발송)
|
||||||
|
7. ✅ UFR-ANAL-020 삭제됨 (분석리포트생성)
|
||||||
|
8. ✅ PDF / Excel 내보내기/다운로드 기능 삭제됨
|
||||||
|
|
||||||
|
**[이벤트 생성 플로우 (최종)]**
|
||||||
|
1. 이벤트 목적 선택 (07-이벤트목적선택)
|
||||||
|
2. AI 트렌드 분석 및 이벤트 추천 (08-AI이벤트추천)
|
||||||
|
- 3가지 추천, 제목/경품 간단 수정 가능
|
||||||
|
3. SNS 이미지 생성 (09-SNS이미지생성)
|
||||||
|
- 3가지 스타일 카드 중 선택
|
||||||
|
4. 콘텐츠 편집 (10-콘텐츠편집)
|
||||||
|
- 텍스트, 색상 편집
|
||||||
|
5. 배포채널 선택 (11-배포채널선택)
|
||||||
|
6. 최종승인 (12-최종승인)
|
||||||
|
|
||||||
|
**[서비스 간 의존성]**
|
||||||
|
- User → Event: 사용자 인증 정보 제공
|
||||||
|
- Event → AI: 트렌드 분석 및 이벤트 추천 요청
|
||||||
|
- Event → Content: 콘텐츠 생성 요청
|
||||||
|
- Event → Distribution: 배포 요청
|
||||||
|
- Distribution → Content: 생성된 콘텐츠 사용
|
||||||
|
- Participation → Analytics: 참여자 데이터 제공
|
||||||
|
- Distribution → Analytics: 노출 데이터 제공
|
||||||
|
|
||||||
|
**[유저스토리 통계]**
|
||||||
|
- User 서비스: 4개
|
||||||
|
- Event 서비스: 7개
|
||||||
|
- AI 서비스: 1개 (통합)
|
||||||
|
- Content 서비스: 2개
|
||||||
|
- Distribution 서비스: 2개
|
||||||
|
- Participation 서비스: 3개
|
||||||
|
- Analytics 서비스: 1개
|
||||||
|
|
||||||
|
**총 20개 유저스토리**
|
||||||
|
|
||||||
|
**[예상 개발 기간]**
|
||||||
|
- User 서비스: 2주
|
||||||
|
- Event 서비스: 3주
|
||||||
|
- AI 서비스: 4주 (프롬프트 엔지니어링 포함)
|
||||||
|
- Content 서비스: 3주
|
||||||
|
- Distribution 서비스: 4주 (다중 API 연동)
|
||||||
|
- Participation 서비스: 2주
|
||||||
|
- Analytics 서비스: 4주 (통합 대시보드)
|
||||||
|
|
||||||
|
**총 예상 기간**: 12-16주 (병렬 개발 시)
|
||||||
|
```
|
||||||
1612
design/구현방안-AI이벤트설계.md
Normal file
1612
design/구현방안-AI이벤트설계.md
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user