docs: 완료된 spec/plan 제거 + lotto 프리미엄 로드맵 보존
운영 중인 기능에 대한 design/plan 문서 일괄 삭제(20개 spec + 14개 plan). 미구현 pet-lab만 보존. lotto-premium-roadmap.md 신규 추가 (Phase 3 구독 모델 미구현 — STATUS.md에서 참조). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,402 +0,0 @@
|
||||
# Lotto 구매 연동 + 전략 진화 시스템 설계
|
||||
|
||||
> 작성일: 2026-04-05
|
||||
> 상태: 승인 대기
|
||||
|
||||
---
|
||||
|
||||
## 1. 목표
|
||||
|
||||
로또 번호 추천 기능을 고도화하여:
|
||||
1. **동행복권 실 구매 연동** — 추천 번호를 클립보드 복사 + 동행복권 바로가기로 실제 구매 지원
|
||||
2. **가상 구매 모드** — 돈을 쓰지 않고 "이 번호로 구매한다"를 등록, 결과 발표 후 자동 가상 수익률 계산
|
||||
3. **전략 진화 시스템** — 구매 이력 기반으로 각 추천 전략(combined, simulation, heatmap, manual, custom)의 성과를 추적하고, EMA + Softmax로 가중치를 자동 조정하는 메타 전략
|
||||
4. **통합 구매 이력** — 실제/가상 구매를 하나의 리스트에서 관리하되, 실 구매는 시각적으로 강조
|
||||
|
||||
---
|
||||
|
||||
## 2. 접근 방식
|
||||
|
||||
**방식 1 (단일 확장) 채택**: 기존 `lotto-backend`(backend/) 서비스 내부에 모듈 추가.
|
||||
- NAS Celeron J4025 환경에서 새 컨테이너 추가는 리소스 부담
|
||||
- 기존 checker/recommender/DB와 자연스러운 연동 가능
|
||||
- 파일 수준 모듈 분리로 유지보수성 확보
|
||||
|
||||
---
|
||||
|
||||
## 3. 데이터 모델
|
||||
|
||||
### 3.1 기존 `purchase_history` 테이블 마이그레이션
|
||||
|
||||
현재 스키마:
|
||||
```sql
|
||||
CREATE TABLE purchase_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
draw_no INTEGER NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
sets INTEGER NOT NULL DEFAULT 1,
|
||||
prize INTEGER NOT NULL DEFAULT 0,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
```
|
||||
|
||||
마이그레이션 전략: **ALTER TABLE로 컬럼 추가** (기존 데이터 보존)
|
||||
|
||||
```sql
|
||||
ALTER TABLE purchase_history ADD COLUMN numbers TEXT NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE purchase_history ADD COLUMN is_real INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE purchase_history ADD COLUMN source_strategy TEXT NOT NULL DEFAULT 'manual';
|
||||
ALTER TABLE purchase_history ADD COLUMN source_detail TEXT NOT NULL DEFAULT '{}';
|
||||
ALTER TABLE purchase_history ADD COLUMN checked INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE purchase_history ADD COLUMN results TEXT NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE purchase_history ADD COLUMN total_prize INTEGER NOT NULL DEFAULT 0;
|
||||
```
|
||||
|
||||
- 기존 레코드: `is_real=1`, `source_strategy='manual'`, `checked=0` (기본값)
|
||||
- 기존 `prize` 컬럼은 하위호환용으로 유지. 신규 로직은 `total_prize` + `results` 사용
|
||||
- 기존 `sets` 컬럼은 하위호환용으로 유지. 신규 로직은 `numbers` JSON 배열 길이로 세트 수 산출
|
||||
|
||||
### 3.2 신규 `strategy_performance` 테이블
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS strategy_performance (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
strategy TEXT NOT NULL,
|
||||
draw_no INTEGER NOT NULL,
|
||||
sets_count INTEGER NOT NULL DEFAULT 0,
|
||||
total_correct INTEGER NOT NULL DEFAULT 0,
|
||||
max_correct INTEGER NOT NULL DEFAULT 0,
|
||||
prize_total INTEGER NOT NULL DEFAULT 0,
|
||||
avg_score REAL NOT NULL DEFAULT 0.0,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
UNIQUE(strategy, draw_no)
|
||||
);
|
||||
```
|
||||
|
||||
### 3.3 신규 `strategy_weights` 테이블
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS strategy_weights (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
strategy TEXT NOT NULL UNIQUE,
|
||||
weight REAL NOT NULL DEFAULT 0.2,
|
||||
ema_score REAL NOT NULL DEFAULT 0.15,
|
||||
total_sets INTEGER NOT NULL DEFAULT 0,
|
||||
total_hits_3plus INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
```
|
||||
|
||||
초기 가중치 (첫 실행 시 seed):
|
||||
|
||||
| strategy | weight | ema_score |
|
||||
|-----------|--------|-----------|
|
||||
| combined | 0.30 | 0.15 |
|
||||
| simulation | 0.25 | 0.15 |
|
||||
| heatmap | 0.20 | 0.15 |
|
||||
| manual | 0.15 | 0.15 |
|
||||
| custom | 0.10 | 0.15 |
|
||||
|
||||
---
|
||||
|
||||
## 4. API 설계
|
||||
|
||||
### 4.1 구매 API (기존 경로 확장)
|
||||
|
||||
| 메서드 | 경로 | 변경 사항 |
|
||||
|--------|------|----------|
|
||||
| `POST` | `/api/lotto/purchase` | 요청 바디 확장 (numbers, is_real, source_strategy, source_detail 추가) |
|
||||
| `GET` | `/api/lotto/purchase` | 필터 추가: `is_real`, `strategy`, `checked` |
|
||||
| `GET` | `/api/lotto/purchase/stats` | 응답 확장: total/real/virtual + by_strategy 섹션 |
|
||||
| `PUT` | `/api/lotto/purchase/{id}` | 기존 그대로 (allowed 필드 확장) |
|
||||
| `DELETE` | `/api/lotto/purchase/{id}` | 기존 그대로 |
|
||||
|
||||
**POST 요청 바디:**
|
||||
```json
|
||||
{
|
||||
"draw_no": 1125,
|
||||
"numbers": [[3, 12, 23, 34, 38, 45], [7, 14, 21, 29, 36, 42]],
|
||||
"is_real": true,
|
||||
"amount": 2000,
|
||||
"source_strategy": "combined",
|
||||
"source_detail": {"recommendation_ids": [451, 452]},
|
||||
"note": ""
|
||||
}
|
||||
```
|
||||
|
||||
하위호환: `numbers`가 빈 배열이면 기존 방식(sets + amount만)으로 동작. `is_real` 미지정 시 기본값 `true`.
|
||||
|
||||
**GET /purchase/stats 응답:**
|
||||
```json
|
||||
{
|
||||
"total": {"sets": 48, "invested": 48000, "prize": 15000, "roi": -68.75, "win_rate": 12.5},
|
||||
"real": {"sets": 20, "invested": 20000, "prize": 10000, "roi": -50.0, "win_rate": 15.0},
|
||||
"virtual": {"sets": 28, "invested": 28000, "prize": 5000, "roi": -82.14, "win_rate": 10.7},
|
||||
"by_strategy": {
|
||||
"combined": {"sets": 15, "avg_correct": 1.8, "hits_3plus": 3, "roi": -45.0},
|
||||
"simulation": {"sets": 12, "avg_correct": 2.1, "hits_3plus": 4, "roi": -30.0}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 전략 진화 API (신규)
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| `GET` | `/api/lotto/strategy/weights` | 현재 전략별 가중치 + 성과 요약 + trend |
|
||||
| `GET` | `/api/lotto/strategy/performance` | 전략별 회차 성과 이력 (차트용, `days` 파라미터) |
|
||||
| `POST` | `/api/lotto/strategy/evolve` | 수동 가중치 재계산 트리거 |
|
||||
|
||||
**GET /strategy/weights 응답:**
|
||||
```json
|
||||
{
|
||||
"weights": [
|
||||
{"strategy": "combined", "weight": 0.32, "ema_score": 0.285, "total_sets": 15, "hits_3plus": 3, "trend": "up"},
|
||||
{"strategy": "simulation", "weight": 0.28, "ema_score": 0.312, "total_sets": 12, "hits_3plus": 4, "trend": "up"},
|
||||
{"strategy": "heatmap", "weight": 0.18, "ema_score": 0.195, "total_sets": 10, "hits_3plus": 1, "trend": "down"},
|
||||
{"strategy": "manual", "weight": 0.14, "ema_score": 0.160, "total_sets": 8, "hits_3plus": 1, "trend": "stable"},
|
||||
{"strategy": "custom", "weight": 0.08, "ema_score": 0.105, "total_sets": 3, "hits_3plus": 0, "trend": "stable"}
|
||||
],
|
||||
"last_evolved": "2026-04-05T09:10:00",
|
||||
"min_data_draws": 10,
|
||||
"current_data_draws": 32,
|
||||
"status": "active"
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 스마트 추천 API (신규)
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| `GET` | `/api/lotto/recommend/smart` | 전략 가중치 기반 메타 전략 추천. `sets` 파라미터 (기본 5) |
|
||||
|
||||
**응답:**
|
||||
```json
|
||||
{
|
||||
"sets": [
|
||||
{
|
||||
"numbers": [3, 12, 23, 34, 38, 45],
|
||||
"meta_score": 0.847,
|
||||
"source_strategy": "simulation",
|
||||
"contribution": {"simulation": 0.42, "combined": 0.31, "heatmap": 0.27},
|
||||
"individual_scores": {"frequency": 0.82, "fingerprint": 0.91, "gap": 0.78, "cooccur": 0.85, "diversity": 0.73}
|
||||
}
|
||||
],
|
||||
"strategy_weights_used": {"combined": 0.32, "simulation": 0.28, "heatmap": 0.18, "manual": 0.14, "custom": 0.08},
|
||||
"learning_status": {"draws_learned": 32, "status": "active", "message": ""}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 전략 진화 알고리즘
|
||||
|
||||
### 5.1 성과 점수 산출 (회차별, 세트별)
|
||||
|
||||
```python
|
||||
set_score = correct_count / 6.0
|
||||
|
||||
# 당첨 등수별 보너스
|
||||
RANK_BONUS = {5: 0.1, 4: 0.3, 3: 0.6, 2: 0.8, 1: 1.0}
|
||||
set_score += RANK_BONUS.get(rank, 0)
|
||||
|
||||
# 한 구매 건의 draw_score = avg(set_scores)
|
||||
```
|
||||
|
||||
### 5.2 EMA 갱신
|
||||
|
||||
```python
|
||||
ALPHA = 0.3 # 최근 3~4회차가 EMA의 ~65% 차지
|
||||
new_ema = ALPHA * draw_score + (1 - ALPHA) * old_ema
|
||||
```
|
||||
|
||||
### 5.3 가중치 변환 (Softmax)
|
||||
|
||||
```python
|
||||
TEMPERATURE = 2.0
|
||||
MIN_WEIGHT = 0.05
|
||||
|
||||
raw = {s: exp(ema / TEMPERATURE) for s, ema in ema_scores.items()}
|
||||
total = sum(raw.values())
|
||||
weights = {s: max(v / total, MIN_WEIGHT) for s, v in raw.items()}
|
||||
# 재정규화하여 합 = 1.0
|
||||
remainder = 1.0 - sum(weights.values())
|
||||
# ... 비례 배분으로 조정
|
||||
```
|
||||
|
||||
### 5.4 재계산 타이밍
|
||||
|
||||
- **자동**: `check_results_for_draw()` → purchases 체크 → strategy_performance 갱신 → weights 재계산
|
||||
- **수동**: `POST /api/lotto/strategy/evolve`
|
||||
|
||||
### 5.5 스마트 추천 흐름
|
||||
|
||||
1. `strategy_weights` 로드
|
||||
2. 각 전략에서 후보 10세트 생성:
|
||||
- `combined`: `generate_combined_recommendation()` x 10
|
||||
- `simulation`: `get_best_picks()` 상위 10개
|
||||
- `heatmap`: `recommend_with_heatmap()` x 10
|
||||
- `manual`: `recommend_numbers()` x 10
|
||||
- `custom`: 데이터 없으면 skip
|
||||
3. `meta_score = original_score x strategy_weight`
|
||||
4. 전체 풀에서 중복 제거 후 상위 N세트 선출
|
||||
5. 각 세트에 출처 전략 + 기여도 breakdown 첨부
|
||||
|
||||
### 5.6 콜드 스타트
|
||||
|
||||
- 구매 이력 0건: 초기 가중치 그대로 사용
|
||||
- 특정 전략 구매 0건: 해당 전략 EMA 초기값(0.15) 유지
|
||||
- 10회차 미만: 스마트 추천 응답에 `status: "learning"` + 기존 combined 추천 병행
|
||||
|
||||
### 5.7 Trend 판정
|
||||
|
||||
```python
|
||||
recent_delta = current_ema - ema_5_draws_ago
|
||||
if recent_delta > 0.02: trend = "up"
|
||||
elif recent_delta < -0.02: trend = "down"
|
||||
else: trend = "stable"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 체커 연동 (자동 파이프라인)
|
||||
|
||||
기존 흐름에 purchase 체크를 연결:
|
||||
|
||||
```
|
||||
Scheduler (09:10 / 21:10)
|
||||
→ sync_latest()
|
||||
→ 새 회차 감지 시:
|
||||
→ check_results_for_draw() # 기존: recommendations 체크
|
||||
→ check_purchases_for_draw() # 신규: purchases 체크
|
||||
→ 각 세트별 rank/correct/bonus 계산 (checker._calc_rank 재사용)
|
||||
→ purchases.results, total_prize, checked=1 갱신
|
||||
→ strategy_performance upsert
|
||||
→ strategy_evolver.recalculate_weights()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 백엔드 모듈 구조
|
||||
|
||||
### 7.1 신규 파일
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `purchase_manager.py` | 구매 이력 관리 + 결과 체크 |
|
||||
| `strategy_evolver.py` | EMA 계산 + 가중치 진화 + 스마트 추천 |
|
||||
|
||||
### 7.2 수정 파일
|
||||
|
||||
| 파일 | 변경 내용 |
|
||||
|------|----------|
|
||||
| `db.py` | purchase_history ALTER + 신규 테이블 2개 + CRUD 함수 추가 |
|
||||
| `main.py` | 신규 엔드포인트 9개 + Pydantic 모델 + import |
|
||||
| `checker.py` | `check_results_for_draw()` 끝에 purchase 체크 호출 추가 |
|
||||
|
||||
### 7.3 기존 유지 파일 (변경 없음)
|
||||
|
||||
`recommender.py`, `generator.py`, `analyzer.py`, `collector.py`, `utils.py`
|
||||
|
||||
---
|
||||
|
||||
## 8. 프론트엔드 변경
|
||||
|
||||
### 8.1 신규 컴포넌트
|
||||
|
||||
| 컴포넌트 | 역할 |
|
||||
|----------|------|
|
||||
| `SmartRecommendPanel.jsx` | 전략 진화 기반 메타 추천 + 구매 버튼 |
|
||||
| `PurchaseHub.jsx` | 통합 구매 이력 (기존 PurchasePanel 대체) |
|
||||
| `StrategyDashboard.jsx` | 전략 가중치 시각화 + 성과 추이 차트 |
|
||||
| `PurchaseButton.jsx` | 공통 구매 버튼 (실구매/가상구매) |
|
||||
|
||||
### 8.2 수정 컴포넌트
|
||||
|
||||
| 컴포넌트 | 변경 내용 |
|
||||
|----------|----------|
|
||||
| `CombinedRecommendPanel.jsx` | 구매 버튼(PurchaseButton) 추가 |
|
||||
| `Functions.jsx` | 신규 패널 3개 추가 + import |
|
||||
|
||||
### 8.3 신규 훅
|
||||
|
||||
| 훅 | 역할 |
|
||||
|----|------|
|
||||
| `useStrategyWeights.js` | 전략 가중치/성과 데이터 fetch |
|
||||
|
||||
### 8.4 수정 훅
|
||||
|
||||
| 훅 | 변경 내용 |
|
||||
|----|----------|
|
||||
| `usePurchases.js` | 새 API 스키마 연동 (numbers, is_real, source_strategy 등) |
|
||||
|
||||
### 8.5 API 헬퍼 추가 (`api.js`)
|
||||
|
||||
```javascript
|
||||
// 전략
|
||||
getStrategyWeights() // GET /api/lotto/strategy/weights
|
||||
getStrategyPerformance(days) // GET /api/lotto/strategy/performance
|
||||
triggerStrategyEvolve() // POST /api/lotto/strategy/evolve
|
||||
|
||||
// 스마트 추천
|
||||
getSmartRecommend(sets) // GET /api/lotto/recommend/smart
|
||||
```
|
||||
|
||||
### 8.6 동행복권 바로가기
|
||||
|
||||
별도 API 없음. 프론트엔드 PurchaseButton에서:
|
||||
1. 번호를 클립보드에 복사
|
||||
2. `window.open('https://dhlottery.co.kr/gameResult.do?method=byWin')` — 새 탭
|
||||
3. 확인 다이얼로그 "구매 완료했나요?" → 예 → `POST /api/lotto/purchase (is_real=1)`
|
||||
|
||||
### 8.7 UI 시각 구분
|
||||
|
||||
- 실 구매: 금색/강조 배경 + 지갑 아이콘
|
||||
- 가상 구매: 기본 배경 + 게임패드 아이콘
|
||||
- 미확인: 시계 아이콘
|
||||
- 당첨: 초록 하이라이트 + 체크 아이콘
|
||||
|
||||
---
|
||||
|
||||
## 9. 전체 데이터 흐름
|
||||
|
||||
```
|
||||
추천(기존) ──[구매 버튼]──→ POST /purchase
|
||||
│
|
||||
스마트 추천(신규) ──[구매 버튼]──┘
|
||||
↓
|
||||
purchase_history 테이블
|
||||
│
|
||||
매주 토요일 추첨 결과 ──→ sync_latest()
|
||||
↓
|
||||
check_results_for_draw()
|
||||
├── recommendations 체크 (기존)
|
||||
└── check_purchases_for_draw() (신규)
|
||||
↓
|
||||
strategy_performance 갱신
|
||||
↓
|
||||
recalculate_weights()
|
||||
↓
|
||||
strategy_weights 갱신
|
||||
↓
|
||||
다음 스마트 추천에 반영 ──→ 순환
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 비기능 요구사항
|
||||
|
||||
- **하위호환**: 기존 purchase API 사용자(프론트 PurchasePanel)는 마이그레이션 중에도 동작해야 함
|
||||
- **성능**: 스마트 추천은 각 전략 10세트 생성 → 총 50세트 중 상위 N개 선출. 1-2초 내 응답 목표
|
||||
- **데이터 안전**: ALTER TABLE은 SQLite 트랜잭션으로 안전하게 실행. 기존 데이터 유실 없음
|
||||
- **콜드 스타트**: 구매 데이터 없어도 스마트 추천 동작 (초기 가중치 사용)
|
||||
|
||||
---
|
||||
|
||||
## 11. 범위 외 (추후 고려)
|
||||
|
||||
- 동행복권 자동 로그인/자동 구매 (CAPTCHA + 보안 정책으로 불가)
|
||||
- 번호 자동 입력 브라우저 확장 프로그램
|
||||
- 푸시 알림 (당첨 결과 통보)
|
||||
- 다중 사용자 지원
|
||||
@@ -1,342 +0,0 @@
|
||||
# realestate-lab 설계 스펙
|
||||
|
||||
> 부동산 청약 공고 자동 수집 + 프로필 기반 자격 매칭 서비스
|
||||
|
||||
---
|
||||
|
||||
## 1. 개요
|
||||
|
||||
공공데이터포털(한국부동산원 청약홈 분양정보 API)에서 청약 공고를 자동 수집하고, 사용자 프로필 기반으로 지원 가능 여부를 자동 판별하는 독립 서비스.
|
||||
|
||||
**핵심 목표:**
|
||||
- 수동 공고 등록 없이 자동 수집 → DB 저장
|
||||
- 프로필 기반 자격 매칭 → 지원 가능한 청약만 필터링
|
||||
- 프론트에서 "새 공고 N건" 확인 → 향후 텔레그램 알림 확장
|
||||
|
||||
---
|
||||
|
||||
## 2. 서비스 아키텍처
|
||||
|
||||
### 독립 서비스 구조
|
||||
|
||||
```
|
||||
realestate-lab/ # 포트 18800
|
||||
├── app/
|
||||
│ ├── main.py # FastAPI 앱 + APScheduler
|
||||
│ ├── db.py # SQLite CRUD (realestate.db)
|
||||
│ ├── collector.py # 공공데이터포털 API 수집기
|
||||
│ ├── matcher.py # 프로필 기반 자격 매칭 엔진
|
||||
│ └── models.py # Pydantic 요청/응답 모델
|
||||
├── Dockerfile
|
||||
└── requirements.txt
|
||||
```
|
||||
|
||||
### 수집 흐름
|
||||
|
||||
```
|
||||
APScheduler (매일 09:00)
|
||||
→ collector.py: 청약홈 API 5개 엔드포인트 호출
|
||||
→ DB에 신규 공고 upsert (HOUSE_MANAGE_NO + PBLANC_NO 기준)
|
||||
→ matcher.py: 프로필 매칭 → 적격 공고에 match_status 부여
|
||||
→ 신규 매칭 공고 카운트 → (향후) 텔레그램 알림
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 데이터 소스
|
||||
|
||||
### 공공데이터포털 — 한국부동산원_청약홈 분양정보 조회 서비스
|
||||
|
||||
- **Base URL**: `https://api.odcloud.kr/api`
|
||||
- **서비스 키**: `DATA_GO_KR_API_KEY` 환경변수
|
||||
- **일 호출 제한**: 40,000건
|
||||
- **데이터 포맷**: JSON
|
||||
|
||||
### 수집 대상 API 엔드포인트
|
||||
|
||||
| 엔드포인트 | 설명 |
|
||||
|-----------|------|
|
||||
| `/ApplyhomeInfoDetailSvc/v1/getAPTLttotPblancDetail` | APT 분양정보 상세 |
|
||||
| `/ApplyhomeInfoDetailSvc/v1/getUrbtyOfctlLttotPblancDetail` | 오피스텔/도시형/민간임대 상세 |
|
||||
| `/ApplyhomeInfoDetailSvc/v1/getRemndrLttotPblancDetail` | 잔여세대 상세 |
|
||||
| `/ApplyhomeInfoDetailSvc/v1/getPblPvtRentLttotPblancDetail` | 공공지원 민간임대 상세 |
|
||||
| `/ApplyhomeInfoDetailSvc/v1/getOPTLttotPblancDetail` | 임의공급 상세 |
|
||||
|
||||
### 주택형별 상세 API (모델별 세대수·분양가)
|
||||
|
||||
| 엔드포인트 | 설명 |
|
||||
|-----------|------|
|
||||
| `/ApplyhomeInfoDetailSvc/v1/getAPTLttotPblancMdl` | APT 주택형별 |
|
||||
| `/ApplyhomeInfoDetailSvc/v1/getUrbtyOfctlLttotPblancMdl` | 오피스텔 주택형별 |
|
||||
| `/ApplyhomeInfoDetailSvc/v1/getRemndrLttotPblancMdl` | 잔여세대 주택형별 |
|
||||
| `/ApplyhomeInfoDetailSvc/v1/getPblPvtRentLttotPblancMdl` | 공공지원 민간임대 주택형별 |
|
||||
| `/ApplyhomeInfoDetailSvc/v1/getOPTLttotPblancMdl` | 임의공급 주택형별 |
|
||||
|
||||
### 공통 쿼리 파라미터
|
||||
|
||||
- `page` (기본: 1), `perPage` (기본: 100)
|
||||
- `serviceKey` — 인코딩된 API 키
|
||||
- `cond[RCRIT_PBLANC_DE::GTE]` / `cond[RCRIT_PBLANC_DE::LTE]` — 모집공고일 범위 필터
|
||||
|
||||
---
|
||||
|
||||
## 4. DB 스키마 (realestate.db)
|
||||
|
||||
### announcements (청약 공고)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | INTEGER PK | 자동 증가 |
|
||||
| house_manage_no | TEXT NOT NULL | 주택관리번호 |
|
||||
| pblanc_no | TEXT NOT NULL | 공고번호 |
|
||||
| house_nm | TEXT | 주택명 |
|
||||
| house_secd | TEXT | 주택구분코드 (01:APT, 02:오피스텔, 04:무순위 등) |
|
||||
| house_dtl_secd | TEXT | 주택상세구분코드 (01:민영, 03:국민 등) |
|
||||
| rent_secd | TEXT | 분양구분 (0:분양, 1:임대) |
|
||||
| region_code | TEXT | 공급지역코드 |
|
||||
| region_name | TEXT | 공급지역명 |
|
||||
| address | TEXT | 공급위치 |
|
||||
| total_units | INTEGER | 공급규모 |
|
||||
| rcrit_date | TEXT | 모집공고일 |
|
||||
| receipt_start | TEXT | 청약접수시작일 |
|
||||
| receipt_end | TEXT | 청약접수종료일 |
|
||||
| spsply_start | TEXT | 특별공급 접수시작일 |
|
||||
| spsply_end | TEXT | 특별공급 접수종료일 |
|
||||
| gnrl_rank1_start | TEXT | 1순위 접수시작일 |
|
||||
| gnrl_rank1_end | TEXT | 1순위 접수종료일 |
|
||||
| winner_date | TEXT | 당첨자발표일 |
|
||||
| contract_start | TEXT | 계약시작일 |
|
||||
| contract_end | TEXT | 계약종료일 |
|
||||
| homepage_url | TEXT | 홈페이지 |
|
||||
| pblanc_url | TEXT | 공고 URL |
|
||||
| constructor | TEXT | 시공사 |
|
||||
| developer | TEXT | 시행사 |
|
||||
| move_in_month | TEXT | 입주예정월 |
|
||||
| is_speculative_area | TEXT | 투기과열지구 |
|
||||
| is_price_cap | TEXT | 분양가상한제 |
|
||||
| contact | TEXT | 문의처 |
|
||||
| status | TEXT | 청약예정/청약중/결과발표/완료 (자동 계산) |
|
||||
| source | TEXT | auto/manual |
|
||||
| created_at | TEXT | |
|
||||
| updated_at | TEXT | |
|
||||
|
||||
- UNIQUE 제약: `(house_manage_no, pblanc_no)`
|
||||
- INDEX: `idx_realestate_status` on `status`
|
||||
- INDEX: `idx_realestate_region` on `region_name`
|
||||
|
||||
### announcement_models (주택형별 상세)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | INTEGER PK | |
|
||||
| house_manage_no | TEXT | FK → announcements |
|
||||
| pblanc_no | TEXT | FK → announcements |
|
||||
| model_no | TEXT | 모델번호 |
|
||||
| house_ty | TEXT | 주택형 (84A 등) |
|
||||
| supply_area | REAL | 공급면적(㎡) |
|
||||
| general_units | INTEGER | 일반공급 세대수 |
|
||||
| special_units | INTEGER | 특별공급 세대수 |
|
||||
| multi_child_units | INTEGER | 다자녀 |
|
||||
| newlywed_units | INTEGER | 신혼부부 |
|
||||
| first_life_units | INTEGER | 생애최초 |
|
||||
| old_parent_units | INTEGER | 노부모부양 |
|
||||
| institution_units | INTEGER | 기관추천 |
|
||||
| youth_units | INTEGER | 청년 |
|
||||
| newborn_units | INTEGER | 신생아 |
|
||||
| top_amount | INTEGER | 분양최고금액(만원) |
|
||||
|
||||
- UNIQUE: `(house_manage_no, pblanc_no, model_no)`
|
||||
|
||||
### user_profile (사용자 청약 프로필)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | INTEGER PK | 항상 1 (단일 사용자) |
|
||||
| name | TEXT | 이름 |
|
||||
| age | INTEGER | 나이 |
|
||||
| is_homeless | BOOLEAN | 무주택 여부 |
|
||||
| is_householder | BOOLEAN | 세대주 여부 |
|
||||
| subscription_months | INTEGER | 청약통장 가입개월수 |
|
||||
| subscription_amount | INTEGER | 청약통장 납입총액(만원) |
|
||||
| family_members | INTEGER | 세대원 수 |
|
||||
| has_dependents | BOOLEAN | 부양가족 유무 |
|
||||
| children_count | INTEGER | 미성년 자녀수 |
|
||||
| is_newlywed | BOOLEAN | 신혼부부 여부 |
|
||||
| marriage_months | INTEGER | 혼인기간(개월) |
|
||||
| has_newborn | BOOLEAN | 2세 이하 자녀 유무 |
|
||||
| is_first_home | BOOLEAN | 생애최초 해당 여부 |
|
||||
| income_level | TEXT | 소득수준 (100%이하/100~130%/130~160%) |
|
||||
| preferred_regions | TEXT | 관심지역 JSON 배열 |
|
||||
| preferred_types | TEXT | 관심주택유형 JSON 배열 |
|
||||
| min_area | REAL | 최소 희망면적(㎡) |
|
||||
| max_area | REAL | 최대 희망면적(㎡) |
|
||||
| max_price | INTEGER | 최대 분양가(만원) |
|
||||
| updated_at | TEXT | |
|
||||
|
||||
### match_results (매칭 결과)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | INTEGER PK | |
|
||||
| announcement_id | INTEGER | FK → announcements |
|
||||
| model_id | INTEGER | FK → announcement_models (nullable) |
|
||||
| match_score | INTEGER | 매칭 점수 (0~100) |
|
||||
| match_reasons | TEXT | 매칭 사유 JSON 배열 |
|
||||
| eligible_types | TEXT | 지원 가능 유형 JSON 배열 |
|
||||
| is_new | BOOLEAN | 신규 매칭 여부 (알림용) |
|
||||
| created_at | TEXT | |
|
||||
|
||||
- UNIQUE: `(announcement_id, model_id)`
|
||||
|
||||
---
|
||||
|
||||
## 5. API 엔드포인트
|
||||
|
||||
### 청약 공고
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/realestate/announcements` | 공고 목록 (필터: region, status, house_type, matched_only, sort, page, size) |
|
||||
| GET | `/api/realestate/announcements/{id}` | 공고 상세 (주택형별 포함) |
|
||||
| POST | `/api/realestate/announcements` | 수동 공고 등록 |
|
||||
| PUT | `/api/realestate/announcements/{id}` | 공고 수정 |
|
||||
| DELETE | `/api/realestate/announcements/{id}` | 공고 삭제 |
|
||||
|
||||
### 수집 관리
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| POST | `/api/realestate/collect` | 수동 수집 트리거 |
|
||||
| GET | `/api/realestate/collect/status` | 마지막 수집 결과 (수집일시, 신규건수, 에러) |
|
||||
|
||||
### 프로필
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/realestate/profile` | 내 프로필 조회 |
|
||||
| PUT | `/api/realestate/profile` | 프로필 수정 (upsert) |
|
||||
|
||||
### 매칭
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/realestate/matches` | 매칭 결과 목록 (점수순, 신규 우선) |
|
||||
| POST | `/api/realestate/matches/refresh` | 매칭 재계산 |
|
||||
| PATCH | `/api/realestate/matches/{id}/read` | 신규 알림 읽음 처리 |
|
||||
|
||||
### 대시보드
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/realestate/dashboard` | 요약 (진행중 공고수, 신규 매칭수, 다가오는 일정) |
|
||||
|
||||
---
|
||||
|
||||
## 6. 매칭 엔진
|
||||
|
||||
### 점수 산출 (0~100)
|
||||
|
||||
| 기준 | 가중치 | 로직 |
|
||||
|------|--------|------|
|
||||
| 지역 매칭 | 30 | preferred_regions에 포함 → 30점 |
|
||||
| 주택유형 매칭 | 10 | preferred_types에 포함 → 10점 |
|
||||
| 면적 매칭 | 15 | min_area~max_area 범위 내 주택형 존재 → 15점 |
|
||||
| 가격 매칭 | 15 | max_price 이하 주택형 존재 → 15점 |
|
||||
| 자격 매칭 | 30 | 지원 가능 공급유형 수에 비례 |
|
||||
|
||||
### 자격 매칭 세부
|
||||
|
||||
| 공급유형 | 판별 조건 |
|
||||
|----------|----------|
|
||||
| 일반 1순위 | 무주택 + 세대주 + 청약통장 가입기간 충족 (투기과열 24개월, 그 외 12개월) |
|
||||
| 일반 2순위 | 1순위 미충족 시 |
|
||||
| 특별-신혼부부 | is_newlywed + 무주택 + 소득기준 |
|
||||
| 특별-생애최초 | is_first_home + 무주택 + 소득기준 |
|
||||
| 특별-다자녀 | children_count >= 2 + 무주택 |
|
||||
| 특별-노부모부양 | has_dependents + 무주택 |
|
||||
| 특별-청년 | age 19~39 + 무주택 |
|
||||
| 특별-신생아 | has_newborn + 무주택 |
|
||||
|
||||
- 1개 유형 → 10점, 2개 → 20점, 3개 이상 → 30점
|
||||
- `eligible_types`: 지원 가능 유형 목록 저장
|
||||
- `match_reasons`: 각 판별 사유 저장
|
||||
|
||||
### 상태 자동 계산
|
||||
|
||||
```
|
||||
오늘 < receipt_start → 청약예정
|
||||
receipt_start ≤ 오늘 ≤ receipt_end → 청약중
|
||||
receipt_end < 오늘 ≤ winner_date → 결과발표
|
||||
오늘 > winner_date → 완료
|
||||
```
|
||||
|
||||
### 매칭 실행 시점
|
||||
|
||||
- 신규 공고 수집 후 자동 실행
|
||||
- 프로필 변경 시 `POST /matches/refresh`로 재계산
|
||||
- 매일 00:00 상태 갱신 시 재매칭
|
||||
|
||||
---
|
||||
|
||||
## 7. 인프라 통합
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```yaml
|
||||
realestate-lab:
|
||||
build: ./realestate-lab
|
||||
container_name: realestate-lab
|
||||
ports:
|
||||
- "18800:8000"
|
||||
volumes:
|
||||
- ${RUNTIME_PATH:-.}/data:/app/data
|
||||
environment:
|
||||
- DATA_GO_KR_API_KEY=${DATA_GO_KR_API_KEY}
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
### Nginx
|
||||
|
||||
```nginx
|
||||
location /api/realestate/ {
|
||||
proxy_pass http://realestate-lab:8000;
|
||||
}
|
||||
```
|
||||
|
||||
### APScheduler
|
||||
|
||||
| 시간 | Job | 설명 |
|
||||
|------|-----|------|
|
||||
| 매일 09:00 | `run_collection` | 5개 API 수집 → 매칭 |
|
||||
| 매일 00:00 | `update_statuses` | 날짜 기반 상태 갱신 |
|
||||
|
||||
### 배포
|
||||
|
||||
- `scripts/deploy-nas.sh`에 `realestate-lab/` rsync 대상 추가
|
||||
|
||||
---
|
||||
|
||||
## 8. lotto-backend 제거 대상
|
||||
|
||||
| 파일 | 제거 항목 |
|
||||
|------|----------|
|
||||
| `backend/app/db.py` | `realestate_complexes` 테이블 생성, CRUD 함수 5개 |
|
||||
| `backend/app/main.py` | `ComplexCreate`/`ComplexUpdate` 모델, `/api/realestate/complexes` 라우트 4개 |
|
||||
|
||||
기존 `realestate_complexes` 테이블 데이터는 마이그레이션 불필요 (스키마 완전 상이).
|
||||
|
||||
---
|
||||
|
||||
## 9. 환경변수
|
||||
|
||||
| 변수 | 설명 | 필수 |
|
||||
|------|------|------|
|
||||
| `DATA_GO_KR_API_KEY` | 공공데이터포털 API 키 | 선택 (미설정 시 수동 등록만 가능) |
|
||||
|
||||
---
|
||||
|
||||
## 10. 향후 확장
|
||||
|
||||
- **텔레그램 알림**: 신규 매칭 공고 발생 시 텔레그램 봇으로 push (알림 모듈 분리 구조 대비)
|
||||
- **경쟁률 조회**: 청약 접수 기간 중 경쟁률 실시간 수집
|
||||
- **실거래가 비교**: 주변 시세와 분양가 비교 분석
|
||||
@@ -1,398 +0,0 @@
|
||||
# Music Lab Suno API 전체 기능 확장 설계
|
||||
|
||||
> 작성일: 2026-04-08
|
||||
> 범위: 백엔드 (web-backend/music-lab) + 프론트엔드 (web-ui/src/pages/music)
|
||||
|
||||
---
|
||||
|
||||
## 1. 목표
|
||||
|
||||
Suno API의 미사용 기능을 전부 활용하여 music-lab을 완전한 AI 음악 프로덕션 스튜디오로 업그레이드한다. 실용도 기준 3단계로 점진 확장.
|
||||
|
||||
## 2. 단계별 기능 목록
|
||||
|
||||
### Phase 1: 핵심 생성 강화
|
||||
|
||||
| # | 기능 | 설명 |
|
||||
|---|------|------|
|
||||
| 1-1 | 크레딧 잔액 상시 표시 | 헤더에 실시간 크레딧 배지, 10 이하 경고 |
|
||||
| 1-2 | 보컬 성별 선택 | Male / Female / Auto 3버튼 |
|
||||
| 1-3 | negativeTags | 제외 스타일 텍스트 + 프리셋 칩 |
|
||||
| 1-4 | V5_5 모델 추가 | SUNO_MODELS 딕셔너리에 추가 |
|
||||
| 1-5 | styleWeight / audioWeight | 0~1.0 슬라이더 2개 |
|
||||
| 1-6 | 커버 이미지 생성 | 라이브러리 카드에서 앨범아트 2장 생성 |
|
||||
|
||||
### Phase 2: 후처리 파워업
|
||||
|
||||
| # | 기능 | 설명 |
|
||||
|---|------|------|
|
||||
| 2-1 | WAV 고음질 변환 | MP3→WAV 변환 다운로드 |
|
||||
| 2-2 | 12스템 분리 | 드럼/베이스/기타 등 12개 개별 추출 |
|
||||
| 2-3 | 타임스탬프 가사 | 가라오케 스타일 싱크 재생 |
|
||||
| 2-4 | 스타일 부스트 | AI로 최적 스타일 프롬프트 자동 생성 |
|
||||
|
||||
### Phase 3: 고급 크리에이티브
|
||||
|
||||
| # | 기능 | 설명 |
|
||||
|---|------|------|
|
||||
| 3-1 | 오디오 업로드 + 커버 | 외부 음원을 Suno 스타일로 리메이크 |
|
||||
| 3-2 | 오디오 업로드 + 확장 | 외부 음원 이어서 만들기 |
|
||||
| 3-3 | 보컬 추가 | 인스트루멘탈에 AI 보컬 입히기 |
|
||||
| 3-4 | 인스트루멘탈 추가 | 보컬에 AI 반주 입히기 |
|
||||
| 3-5 | 뮤직비디오 생성 | MP4 자동 생성 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 백엔드 API 설계
|
||||
|
||||
### 3.1 기존 엔드포인트 수정
|
||||
|
||||
#### GenerateRequest 스키마 확장 (main.py)
|
||||
|
||||
```python
|
||||
class GenerateRequest(BaseModel):
|
||||
# 기존 필드 유지
|
||||
provider: str = "suno"
|
||||
model: str = "V4"
|
||||
title: str = ""
|
||||
genre: str = ""
|
||||
moods: list[str] = []
|
||||
instruments: list[str] = []
|
||||
duration_sec: int | None = None
|
||||
bpm: int | None = None
|
||||
key: str = ""
|
||||
scale: str = ""
|
||||
prompt: str = ""
|
||||
lyrics: str = ""
|
||||
instrumental: bool = False
|
||||
|
||||
# Phase 1 추가
|
||||
vocal_gender: str | None = None # "m" | "f" | None(auto)
|
||||
negative_tags: str | None = None # 제외 스타일
|
||||
style_weight: float | None = None # 0.0~1.0
|
||||
audio_weight: float | None = None # 0.0~1.0
|
||||
```
|
||||
|
||||
#### SUNO_MODELS 확장 (suno_provider.py)
|
||||
|
||||
```python
|
||||
SUNO_MODELS = {
|
||||
"V4": {"name": "V4", "max_duration": 240},
|
||||
"V4_5": {"name": "V4.5", "max_duration": 480},
|
||||
"V4_5PLUS": {"name": "V4.5+", "max_duration": 480},
|
||||
"V4_5ALL": {"name": "V4.5 All","max_duration": 480},
|
||||
"V5": {"name": "V5", "max_duration": 480},
|
||||
"V5_5": {"name": "V5.5", "max_duration": 480}, # 추가
|
||||
}
|
||||
```
|
||||
|
||||
#### _build_suno_payload 확장
|
||||
|
||||
새 파라미터를 Suno API 페이로드에 매핑:
|
||||
- `vocal_gender` → `vocalGender`
|
||||
- `negative_tags` → `negativeTags`
|
||||
- `style_weight` → `styleWeight`
|
||||
- `audio_weight` → `audioWeight`
|
||||
|
||||
None이 아닌 경우에만 페이로드에 포함.
|
||||
|
||||
### 3.2 신규 엔드포인트
|
||||
|
||||
#### Phase 1
|
||||
|
||||
```
|
||||
POST /api/music/cover-image
|
||||
Request: { "task_id": str, "suno_id": str }
|
||||
Response: { "task_id": str } → 폴링 → { "images": [url1, url2] }
|
||||
```
|
||||
|
||||
#### Phase 2
|
||||
|
||||
```
|
||||
POST /api/music/wav
|
||||
Request: { "task_id": str, "suno_id": str }
|
||||
Response: { "task_id": str } → 폴링 → { "wav_url": str }
|
||||
|
||||
POST /api/music/stem-split
|
||||
Request: { "task_id": str, "suno_id": str }
|
||||
Response: { "task_id": str } → 폴링 → { "stems": { vocal: url, drums: url, ... } }
|
||||
|
||||
GET /api/music/timestamped-lyrics?task_id=...&suno_id=...
|
||||
Response: { "aligned_words": [...], "waveform_data": [...] }
|
||||
|
||||
POST /api/music/style-boost
|
||||
Request: { "content": str }
|
||||
Response: { "result": str, "credits_consumed": float }
|
||||
```
|
||||
|
||||
#### Phase 3
|
||||
|
||||
```
|
||||
POST /api/music/upload-cover
|
||||
Request: { "upload_url": str, "model": str, "custom_mode": bool,
|
||||
"instrumental": bool, "prompt"?: str, "style"?: str, "title"?: str,
|
||||
"vocal_gender"?: str, "negative_tags"?: str,
|
||||
"style_weight"?: float, "audio_weight"?: float }
|
||||
Response: { "task_id": str }
|
||||
|
||||
POST /api/music/upload-extend
|
||||
Request: { "upload_url": str, "model": str, "continue_at"?: float,
|
||||
"default_param_flag": bool, "prompt"?: str, "style"?: str, "title"?: str,
|
||||
"vocal_gender"?: str, "negative_tags"?: str }
|
||||
Response: { "task_id": str }
|
||||
|
||||
POST /api/music/add-vocals
|
||||
Request: { "upload_url": str, "prompt": str, "title": str, "style": str,
|
||||
"negative_tags": str, "vocal_gender"?: str, "model"?: str,
|
||||
"style_weight"?: float, "audio_weight"?: float }
|
||||
Response: { "task_id": str }
|
||||
|
||||
POST /api/music/add-instrumental
|
||||
Request: { "upload_url": str, "title": str, "tags": str, "negative_tags": str,
|
||||
"vocal_gender"?: str, "model"?: str,
|
||||
"style_weight"?: float, "audio_weight"?: float }
|
||||
Response: { "task_id": str }
|
||||
|
||||
POST /api/music/video
|
||||
Request: { "task_id": str, "suno_id": str, "author"?: str, "domain_name"?: str }
|
||||
Response: { "task_id": str } → 폴링 → { "video_url": str }
|
||||
```
|
||||
|
||||
### 3.3 suno_provider.py 리팩토링
|
||||
|
||||
**공통 폴링 헬퍼 추출:**
|
||||
|
||||
```python
|
||||
def _poll_suno_task(
|
||||
record_info_url: str,
|
||||
task_id: str,
|
||||
max_attempts: int = 40,
|
||||
interval: int = 8,
|
||||
success_extractor: Callable[[dict], Any] = None
|
||||
) -> dict:
|
||||
"""
|
||||
범용 Suno 작업 폴링.
|
||||
record_info_url: 예) "/api/v1/generate/record-info"
|
||||
success_extractor: SUCCESS 상태일 때 결과 추출 함수
|
||||
"""
|
||||
```
|
||||
|
||||
기존 `run_suno_generation`, `run_suno_extend`, `run_vocal_removal`도 이 헬퍼를 사용하도록 리팩토링.
|
||||
|
||||
**신규 함수 목록:**
|
||||
|
||||
| 함수 | Phase | Suno 엔드포인트 | 비동기 |
|
||||
|------|-------|----------------|--------|
|
||||
| `run_cover_image` | 1 | `POST /api/v1/suno/cover/generate` | 폴링 |
|
||||
| `run_wav_convert` | 2 | `POST /api/v1/wav/generate` | 폴링 |
|
||||
| `run_stem_split` | 2 | `POST /api/v1/vocal-removal/generate` (type=split_stem) | 폴링 |
|
||||
| `get_timestamped_lyrics` | 2 | `POST /api/v1/generate/get-timestamped-lyrics` | 동기 |
|
||||
| `generate_style_boost` | 2 | `POST /api/v1/style/generate` | 동기 |
|
||||
| `run_upload_cover` | 3 | `POST /api/v1/generate/upload-cover` | 폴링 |
|
||||
| `run_upload_extend` | 3 | `POST /api/v1/generate/upload-extend` | 폴링 |
|
||||
| `run_add_vocals` | 3 | `POST /api/v1/generate/add-vocals` | 폴링 |
|
||||
| `run_add_instrumental` | 3 | `POST /api/v1/generate/add-instrumental` | 폴링 |
|
||||
| `run_video_generate` | 3 | `POST /api/v1/mp4/generate` | 폴링 |
|
||||
|
||||
### 3.4 DB 스키마 변경
|
||||
|
||||
**music_library 테이블 컬럼 추가 (ALTER TABLE 마이그레이션):**
|
||||
|
||||
```sql
|
||||
ALTER TABLE music_library ADD COLUMN cover_images TEXT NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE music_library ADD COLUMN wav_url TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE music_library ADD COLUMN video_url TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE music_library ADD COLUMN stem_urls TEXT NOT NULL DEFAULT '{}';
|
||||
```
|
||||
|
||||
**db.py 함수 추가:**
|
||||
|
||||
```python
|
||||
def update_track_cover_images(track_id: int, images: list[str])
|
||||
def update_track_wav_url(track_id: int, wav_url: str)
|
||||
def update_track_video_url(track_id: int, video_url: str)
|
||||
def update_track_stem_urls(track_id: int, stems: dict)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 프론트엔드 UI/UX 설계
|
||||
|
||||
### 4.1 파일 구조 (컴포넌트 분할)
|
||||
|
||||
```
|
||||
web-ui/src/pages/music/
|
||||
├── MusicStudio.jsx -- 메인 (탭 라우팅 + 공유 상태)
|
||||
├── MusicStudio.css -- 전체 스타일
|
||||
├── components/
|
||||
│ ├── CreateTab.jsx -- 생성 폼 (6단계 + Phase 1 확장)
|
||||
│ ├── LyricsTab.jsx -- 가사 관리
|
||||
│ ├── LibraryTab.jsx -- 라이브러리 + 카드
|
||||
│ ├── RemixTab.jsx -- Phase 3: 업로드/리믹스
|
||||
│ ├── AudioPlayer.jsx -- 오디오 플레이어
|
||||
│ ├── LibraryCard.jsx -- 트랙 카드 + 액션 메뉴
|
||||
│ ├── StemModal.jsx -- 12스템 결과 모달
|
||||
│ ├── CoverArtModal.jsx -- 커버 이미지 선택 모달
|
||||
│ ├── SyncedLyricsPlayer.jsx -- 타임스탬프 가사 오버레이
|
||||
│ └── CreditsBadge.jsx -- 크레딧 잔액 배지
|
||||
```
|
||||
|
||||
### 4.2 Phase 1 UI 변경
|
||||
|
||||
#### 크레딧 배지 (CreditsBadge)
|
||||
- 위치: 헤더 우측 상단, 탭 옆
|
||||
- 표시: `⚡ 127 credits`
|
||||
- 10 이하: 빨간색 + pulse 애니메이션
|
||||
- 갱신 시점: 페이지 로드, 생성 완료 후, 30초 자동 갱신
|
||||
|
||||
#### Create 탭 Step 4 확장
|
||||
|
||||
**Vocal Gender (Suno 전용):**
|
||||
- 3버튼 토글: `♂ Male` | `♀ Female` | `Auto`
|
||||
- 기본값: Auto
|
||||
- 스타일: `.ms-gender-toggle` (기존 duration-rail과 유사)
|
||||
|
||||
**Negative Tags:**
|
||||
- 텍스트 입력 필드 + 프리셋 칩
|
||||
- 프리셋: screaming, autotune, distortion, whisper, falsetto, rap
|
||||
- 칩 클릭 시 텍스트에 추가/제거
|
||||
- 스타일: `.ms-negative-tags` (기존 mood-rack과 유사)
|
||||
|
||||
**Style Weight / Audio Weight:**
|
||||
- range 슬라이더 2개 (기존 BPM 슬라이더와 동일 스타일)
|
||||
- 레이블: "Prompt ↔ Style Balance" / "Original ↔ AI Balance"
|
||||
- 0~100 표시, API 전송 시 0.0~1.0 변환
|
||||
- 기본값: 미설정 (슬라이더 중앙, 값 전송 안 함)
|
||||
|
||||
#### Library 카드 액션 메뉴 확장
|
||||
|
||||
기존 5개 버튼 → 6개 (Cover Art 추가)
|
||||
4개 초과 시 `•••` 더보기 드롭다운 메뉴로 분기:
|
||||
- 기본 노출: Play, Download, Delete
|
||||
- 더보기: Extend, Vocal Split, Cover Art (+ Phase 2/3 추가분)
|
||||
|
||||
#### CoverArtModal
|
||||
- 2장 이미지 좌우 비교 표시
|
||||
- 각 이미지 아래 "이 이미지 사용" 버튼
|
||||
- 선택 시 라이브러리 카드 썸네일 업데이트
|
||||
|
||||
### 4.3 Phase 2 UI 변경
|
||||
|
||||
#### Library 카드 더보기 메뉴 추가
|
||||
- WAV 다운로드
|
||||
- Stem Split (12스템)
|
||||
- Synced Lyrics
|
||||
- Style Boost (Create 탭 프롬프트로 전달)
|
||||
|
||||
#### StemModal
|
||||
- 3×4 그리드 카드 레이아웃
|
||||
- 각 스템: 이름 아이콘 + 미니 재생 버튼 + 다운로드 버튼
|
||||
- 12개 스템: vocal, backing_vocals, drums, bass, guitar, keyboard, strings, brass, woodwinds, percussion, synth, fx
|
||||
- 스타일: 기존 라이브러리 카드의 축소 버전
|
||||
|
||||
#### SyncedLyricsPlayer
|
||||
- AudioPlayer 교체/오버레이 모드
|
||||
- 재생 중 현재 단어를 accent 컬러로 하이라이트
|
||||
- 하단에 waveformData 기반 파형 바
|
||||
- 닫기 버튼으로 일반 플레이어 복귀
|
||||
|
||||
#### Style Boost 버튼
|
||||
- Create 탭 장르 선택 영역에 `✨ Style Boost` 버튼
|
||||
- 클릭 시: 현재 genre + moods 조합 → API 호출 → 결과를 프롬프트에 삽입
|
||||
- 로딩 중 버튼 스피너
|
||||
|
||||
### 4.4 Phase 3 UI 변경
|
||||
|
||||
#### Remix 탭 (신규 4번째 탭)
|
||||
- 탭 레이블: `REMIX`
|
||||
- 상단: 오디오 URL 입력 필드 (또는 라이브러리에서 선택)
|
||||
- 4개 액션 카드 그리드 (2×2):
|
||||
- **AI Cover**: 아이콘 + 설명 + 파라미터 폼 (펼침)
|
||||
- **Extend**: 아이콘 + 설명 + continue_at 입력
|
||||
- **Add Vocals**: 아이콘 + 설명 + prompt/style 입력
|
||||
- **Add Instrumental**: 아이콘 + 설명 + tags 입력
|
||||
- 선택한 카드만 펼쳐서 세부 옵션 표시
|
||||
- 하단: 뮤직비디오 생성 버튼 (라이브러리에서 선택한 곡 대상)
|
||||
|
||||
### 4.5 디자인 토큰 추가
|
||||
|
||||
```css
|
||||
/* Phase 1 추가 토큰 */
|
||||
--ms-danger: #e74c3c; /* 크레딧 경고 빨간색 */
|
||||
--ms-male: #4a9eff; /* 남성 보컬 파란색 */
|
||||
--ms-female: #ff6b9d; /* 여성 보컬 분홍색 */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. api.js 추가 함수
|
||||
|
||||
```javascript
|
||||
// Phase 1
|
||||
export const generateCoverImage = (payload) => apiPost('/api/music/cover-image', payload);
|
||||
|
||||
// Phase 2
|
||||
export const convertToWav = (payload) => apiPost('/api/music/wav', payload);
|
||||
export const splitStems = (payload) => apiPost('/api/music/stem-split', payload);
|
||||
export const getTimestampedLyrics = (taskId, sunoId) =>
|
||||
apiGet(`/api/music/timestamped-lyrics?task_id=${taskId}&suno_id=${sunoId}`);
|
||||
export const generateStyleBoost = (content) => apiPost('/api/music/style-boost', { content });
|
||||
|
||||
// Phase 3
|
||||
export const uploadAndCover = (payload) => apiPost('/api/music/upload-cover', payload);
|
||||
export const uploadAndExtend = (payload) => apiPost('/api/music/upload-extend', payload);
|
||||
export const addVocals = (payload) => apiPost('/api/music/add-vocals', payload);
|
||||
export const addInstrumental = (payload) => apiPost('/api/music/add-instrumental', payload);
|
||||
export const generateVideo = (payload) => apiPost('/api/music/video', payload);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 폴링 패턴
|
||||
|
||||
모든 비동기 작업은 기존 음악 생성과 동일한 폴링 패턴:
|
||||
|
||||
1. POST 요청 → `{ task_id }` 반환
|
||||
2. 프론트: 3초 간격 `GET /api/music/status/{task_id}` 폴링
|
||||
3. 백엔드: BackgroundTask에서 Suno 폴링 → music_tasks 상태 업데이트
|
||||
4. `status: succeeded` → 결과 반환 + 라이브러리 자동 갱신
|
||||
|
||||
동기 API (타임스탬프 가사, 스타일 부스트)는 즉시 응답.
|
||||
|
||||
---
|
||||
|
||||
## 7. 구현 순서
|
||||
|
||||
### Phase 1 (핵심 생성 강화)
|
||||
1. 백엔드: SUNO_MODELS에 V5_5 추가 + 공통 폴링 헬퍼 추출
|
||||
2. 백엔드: GenerateRequest 스키마 확장 + _build_suno_payload 매핑
|
||||
3. 백엔드: 커버 이미지 생성 엔드포인트 + suno_provider 함수
|
||||
4. 백엔드: DB 마이그레이션 (cover_images, wav_url, video_url, stem_urls)
|
||||
5. 프론트: 컴포넌트 분할 (MusicStudio → CreateTab, LyricsTab, LibraryTab, etc.)
|
||||
6. 프론트: CreditsBadge 구현
|
||||
7. 프론트: Create 탭 Step 4 확장 (vocal gender, negative tags, weight 슬라이더)
|
||||
8. 프론트: LibraryCard 더보기 메뉴 + CoverArtModal
|
||||
9. 프론트: api.js 함수 추가
|
||||
|
||||
### Phase 2 (후처리 파워업)
|
||||
10. 백엔드: WAV/스템/타임스탬프/스타일부스트 엔드포인트
|
||||
11. 프론트: StemModal + SyncedLyricsPlayer + Style Boost 버튼
|
||||
12. 프론트: Library 카드 Phase 2 액션 추가
|
||||
|
||||
### Phase 3 (고급 크리에이티브)
|
||||
13. 백엔드: upload-cover/upload-extend/add-vocals/add-instrumental/video 엔드포인트
|
||||
14. 프론트: RemixTab 구현
|
||||
15. 프론트: Library 카드 Phase 3 액션 (Video)
|
||||
|
||||
---
|
||||
|
||||
## 8. 제약사항 및 주의점
|
||||
|
||||
- **Suno 파일 보관**: 14~15일 후 자동 삭제 → 로컬 다운로드 필수 (기존 패턴 유지)
|
||||
- **동시 요청 제한**: 10초당 20건 → 배치 작업 시 rate limiting 고려
|
||||
- **12스템 분리 비용**: 50크레딧 → UI에 비용 경고 표시
|
||||
- **WAV 중복 변환**: 409 에러 → 이미 변환된 경우 기존 URL 반환
|
||||
- **뮤직비디오**: taskId + audioId 필요 → 라이브러리에 task_id 저장 필수
|
||||
- **V5_5 모델**: 커스텀 모델 → 문서상 제한사항 추가 확인 필요
|
||||
- **크레딧 조회 엔드포인트**: 기존 `/api/v1/get-credits` vs 문서 `/api/v1/generate/credit` → 두 엔드포인트 폴백 시도
|
||||
- **upload 계열 API**: upload_url은 외부 접근 가능한 URL이어야 함 → 로컬 파일은 NAS nginx URL로 변환
|
||||
@@ -1,444 +0,0 @@
|
||||
# Agent Office - AI 에이전트 사무실 시각화 설계
|
||||
|
||||
## 개요
|
||||
|
||||
Lab 하위에 2D 픽셀아트 스타일의 가상 사무실을 구현하여, AI 에이전트들이 실시간으로 작업하는 모습을 게임처럼 시각화하고 상호작용하는 페이지.
|
||||
|
||||
### 핵심 컨셉
|
||||
- **게임 같은 사무실**: 2D 픽셀아트 오픈 오피스에 에이전트 캐릭터들이 배치
|
||||
- **실제 작업 수행**: 에이전트들이 기존 백엔드 서비스 API를 호출하여 실제 결과물 생성
|
||||
- **직접 지시**: 에이전트 클릭 → 채팅/명령 패널로 지시, 승인 요청 시 알림 표시
|
||||
- **텔레그램 양방향**: 알림 발송 + 인라인 버튼으로 승인/거절/수정
|
||||
- **아이들 행동**: 장시간 명령 없으면 휴게실에서 커피, 졸기, 동료 잡담 등
|
||||
|
||||
### MVP 범위
|
||||
- **에이전트 2개**: StockAgent (주식 뉴스/주가 알람), MusicAgent (작곡 파이프라인)
|
||||
- **사무실**: 단일 오픈 오피스 (향후 방/층 확장 가능)
|
||||
- **텔레그램**: 양방향 (알림 + 인라인 버튼 승인)
|
||||
|
||||
---
|
||||
|
||||
## 1. 아키텍처
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Frontend (React) │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌─────────────────────────┐ │
|
||||
│ │ OfficeCanvas │ │ React Overlay │ │
|
||||
│ │ (Canvas 2D) │ │ - ChatPanel │ │
|
||||
│ │ - 타일맵 렌더 │ │ - AgentStatus │ │
|
||||
│ │ - 스프라이트 │ │ - TaskHistory │ │
|
||||
│ │ - 클릭 히트맵 │ │ - ApprovalDialog │ │
|
||||
│ └──────────────┘ └─────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────┐ │
|
||||
│ │ useAgentManager (상태 + WebSocket) │ │
|
||||
│ └──────────────────────────────────────────┘ │
|
||||
└──────────────────┬──────────────────────────────┘
|
||||
│ WebSocket + REST
|
||||
┌──────────────────▼──────────────────────────────┐
|
||||
│ Backend: agent-office (새 서비스, 포트 18900) │
|
||||
│ │
|
||||
│ ┌────────────┐ ┌────────────┐ ┌──────────────┐ │
|
||||
│ │ Scheduler │ │ Agent FSM │ │ Telegram Bot │ │
|
||||
│ │(APScheduler)│ │ (상태머신) │ │ (양방향) │ │
|
||||
│ └────────────┘ └────────────┘ └──────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────┐ │
|
||||
│ │ Service Proxy (기존 서비스 API 호출) │ │
|
||||
│ │ stock-lab / music-lab 등 │ │
|
||||
│ └──────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 핵심 결정
|
||||
- **agent-office**: 새 백엔드 서비스 (포트 18900). 기존 서비스는 수정하지 않음
|
||||
- **Service Proxy 패턴**: agent-office가 기존 서비스 API를 HTTP 호출
|
||||
- **WebSocket**: 에이전트 상태 변화를 실시간 전달
|
||||
- **Canvas + React 오버레이 하이브리드**: 게임 렌더링은 Canvas, UI 패널은 React DOM
|
||||
|
||||
---
|
||||
|
||||
## 2. 에이전트 상태 머신 (FSM)
|
||||
|
||||
### 상태 전이
|
||||
|
||||
```
|
||||
┌──────┐ 스케줄/지시 ┌──────────┐ 완료 ┌──────────┐
|
||||
│ idle │ ──────────────→ │ working │ ───────→ │ reporting│
|
||||
└──┬───┘ └────┬─────┘ └────┬─────┘
|
||||
│ │ 승인 필요 │
|
||||
│ 장시간 idle ▼ │ 결과 전달 후
|
||||
│ ┌───────────┐ │
|
||||
▼ │ waiting │ │
|
||||
┌──────┐ │ (승인대기) │ │
|
||||
│ break│ └───────────┘ │
|
||||
│ (휴식)│ │
|
||||
└──┬───┘◄───────────────────────────────────────────┘
|
||||
│ 새 작업 발생
|
||||
└──────────→ idle
|
||||
```
|
||||
|
||||
### 상태별 시각화
|
||||
|
||||
| 상태 | 캐릭터 행동 | 위치 | 오버레이 |
|
||||
|------|------------|------|---------|
|
||||
| `idle` | 모니터 보며 대기 애니메이션 | 자기 데스크 | 없음 |
|
||||
| `working` | 타이핑 애니메이션, 모니터에 진행 표시 | 자기 데스크 | 작업명 말풍선 |
|
||||
| `waiting` | 살짝 좌우 흔들림 | 자기 데스크 | `❗` 아이콘 (클릭 유도) |
|
||||
| `reporting` | 결과물 들고 걸어감 | 데스크 → 회의 테이블 | 결과 요약 말풍선 |
|
||||
| `break` | 커피 마시기/졸기/산책/잡담 | 휴게실/복도 | `☕`/`💤` 아이콘 |
|
||||
|
||||
### 아이들 행동 규칙
|
||||
- idle 상태 5분 경과 → 50% 확률로 break 전환
|
||||
- break 지속: 1~3분 랜덤 → idle 복귀
|
||||
- break 중 에이전트끼리 근처에 있으면 잡담 애니메이션
|
||||
- 새 작업 발생 시 즉시 break 종료 → idle → working
|
||||
|
||||
### 승인 흐름별 분류
|
||||
|
||||
| 에이전트 | 자동 실행 | 승인 필요 |
|
||||
|---------|----------|----------|
|
||||
| Stock | 뉴스 요약, 주가 알람 | - |
|
||||
| Music | - | 작곡 (프롬프트 확인 후) |
|
||||
| Lotto (향후) | 통계 분석, 추천번호 | 구매 관련 |
|
||||
| Blog (향후) | - | 키워드 제시 후 글 생성 |
|
||||
| Realestate (향후) | 공고 수집, 매칭 | - |
|
||||
| Claude AI (향후) | - | 직접 지시 + 승인 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 사무실 맵 & 렌더링
|
||||
|
||||
### 타일맵 구조 (MVP: 단일 오픈 오피스)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
|
||||
│ │Stock│ │Music│ │Claude│ │ (빈) │ │
|
||||
│ │Desk │ │Desk │ │Desk │ │향후용│ │
|
||||
│ └─────┘ └─────┘ └─────┘ └─────┘ │
|
||||
│ │
|
||||
│ ┌───────────┐ │
|
||||
│ │ 회의 테이블 │ │
|
||||
│ │ (보고구역) │ │
|
||||
│ └───────────┘ │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌─────────────────┐ │
|
||||
│ │ 휴게실 │ │ CEO 데스크 (나) │ │
|
||||
│ │ coffee │ │ │ │
|
||||
│ └──────────┘ └─────────────────┘ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 렌더링 계층 (아래→위)
|
||||
1. **바닥 타일**: 카펫, 나무 바닥
|
||||
2. **가구**: 데스크, 의자, 소파, 화분, 커피머신
|
||||
3. **캐릭터**: 에이전트 스프라이트 (상태별 애니메이션)
|
||||
4. **오버레이**: 말풍선, 상태 아이콘, 이름표
|
||||
|
||||
### 스프라이트 에셋
|
||||
- 무료 픽셀아트 에셋팩 활용 (타일셋, 가구)
|
||||
- 에이전트 캐릭터: 기본 인물 스프라이트 + 액세서리로 구분
|
||||
- Stock: 넥타이 + 차트 모니터
|
||||
- Music: 헤드폰 + 음표 이펙트
|
||||
- Claude: 보라색 톤 + AI 아이콘
|
||||
- 스프라이트시트: 4방향 × 4프레임 (idle, walk, work, break)
|
||||
|
||||
### Canvas 렌더링 엔진
|
||||
- **게임 루프**: `requestAnimationFrame` 기반, 60fps 타겟
|
||||
- **카메라**: 고정 뷰 (MVP), 향후 줌/팬 추가 가능
|
||||
- **클릭 히트맵**: 캐릭터 바운딩 박스 체크 → 클릭 시 React 이벤트 발생
|
||||
- **이동**: 웨이포인트 기반 lerp (데스크↔회의실↔휴게실)
|
||||
|
||||
---
|
||||
|
||||
## 4. 백엔드: agent-office 서비스
|
||||
|
||||
### 파일 구조
|
||||
|
||||
```
|
||||
agent-office/
|
||||
├── app/
|
||||
│ ├── main.py # FastAPI + WebSocket + lifespan
|
||||
│ ├── db.py # SQLite (agent_tasks, agent_logs, agent_config)
|
||||
│ ├── config.py # 환경변수, 서비스 URL 설정
|
||||
│ ├── scheduler.py # APScheduler 스케줄 관리
|
||||
│ ├── telegram_bot.py # Telegram Bot API 양방향
|
||||
│ ├── websocket_manager.py # WebSocket 연결 관리 + 브로드캐스트
|
||||
│ ├── service_proxy.py # 기존 서비스 API 호출 래퍼
|
||||
│ ├── agents/
|
||||
│ │ ├── base.py # BaseAgent (FSM, 공통 로직)
|
||||
│ │ ├── stock.py # StockAgent
|
||||
│ │ └── music.py # MusicAgent
|
||||
│ └── models.py # Pydantic 모델
|
||||
├── Dockerfile
|
||||
└── requirements.txt
|
||||
```
|
||||
|
||||
### DB 테이블 (agent_office.db)
|
||||
|
||||
**agent_config**
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| agent_id | TEXT PK | 에이전트 식별자 (stock, music, ...) |
|
||||
| display_name | TEXT | 표시명 ("주식 트레이더") |
|
||||
| enabled | BOOLEAN | 활성 상태 |
|
||||
| schedule_config | TEXT (JSON) | 스케줄 설정 |
|
||||
| custom_config | TEXT (JSON) | 에이전트별 커스텀 설정 (감시 종목 등) |
|
||||
| created_at | TEXT | 생성 시각 |
|
||||
| updated_at | TEXT | 수정 시각 |
|
||||
|
||||
**agent_tasks**
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | TEXT PK (UUID) | 작업 ID |
|
||||
| agent_id | TEXT FK | 에이전트 |
|
||||
| task_type | TEXT | 작업 유형 (news_summary, price_alert, compose, ...) |
|
||||
| status | TEXT | pending / approved / working / succeeded / failed |
|
||||
| input_data | TEXT (JSON) | 입력 파라미터 |
|
||||
| result_data | TEXT (JSON) | 결과 데이터 |
|
||||
| requires_approval | BOOLEAN | 승인 필요 여부 |
|
||||
| approved_at | TEXT | 승인 시각 |
|
||||
| approved_via | TEXT | 승인 경로 (web / telegram) |
|
||||
| created_at | TEXT | 생성 시각 |
|
||||
| completed_at | TEXT | 완료 시각 |
|
||||
|
||||
**agent_logs**
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | INTEGER PK | 자동 증가 |
|
||||
| agent_id | TEXT FK | 에이전트 |
|
||||
| task_id | TEXT FK | 관련 작업 (nullable) |
|
||||
| level | TEXT | info / warn / error |
|
||||
| message | TEXT | 로그 메시지 |
|
||||
| created_at | TEXT | 시각 |
|
||||
|
||||
**telegram_state**
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| callback_id | TEXT PK | 텔레그램 콜백 ID |
|
||||
| task_id | TEXT FK | 매핑된 작업 |
|
||||
| agent_id | TEXT FK | 매핑된 에이전트 |
|
||||
| action | TEXT | approve / reject / modify |
|
||||
| responded | BOOLEAN | 응답 완료 여부 |
|
||||
| created_at | TEXT | 생성 시각 |
|
||||
|
||||
### BaseAgent 인터페이스
|
||||
|
||||
```python
|
||||
class BaseAgent:
|
||||
agent_id: str
|
||||
state: str # idle, working, waiting, reporting, break
|
||||
|
||||
async def on_schedule(self) -> None:
|
||||
"""스케줄러에 의해 호출. 자동 작업 실행."""
|
||||
|
||||
async def on_command(self, command: str, params: dict) -> dict:
|
||||
"""사용자 직접 지시 처리."""
|
||||
|
||||
async def on_approval(self, task_id: str, approved: bool, feedback: str) -> None:
|
||||
"""승인/거절 콜백."""
|
||||
|
||||
async def get_status(self) -> dict:
|
||||
"""현재 상태 + 최근 작업 요약."""
|
||||
```
|
||||
|
||||
### MVP 에이전트 상세
|
||||
|
||||
**StockAgent:**
|
||||
- 스케줄: 매일 08:00 `on_schedule()` → `stock-lab GET /api/stock/news` 호출
|
||||
- AI 요약: 뉴스 데이터를 Ollama(192.168.45.59)로 요약 생성
|
||||
- 텔레그램 전송: 요약 결과를 포맷팅하여 발송 (자동, 승인 불필요)
|
||||
- 주가 알람: `agent_config.custom_config`에 감시 종목/조건 저장, 주기적 체크
|
||||
- 상태 전이: idle → working(뉴스 수집) → reporting(텔레그램 전송) → idle
|
||||
|
||||
**MusicAgent:**
|
||||
- 트리거: 사용자 웹/텔레그램 지시 → `on_command()`
|
||||
- 프롬프트 확인: 사용자 입력 프롬프트를 텔레그램으로 전송 + 인라인 버튼
|
||||
- 승인 시: `music-lab POST /api/music/generate` 호출
|
||||
- 상태 폴링: `music-lab GET /api/music/status/{task_id}` → 완료까지 반복
|
||||
- 결과 알림: 생성된 음악 URL을 텔레그램 + 웹에 전달
|
||||
- 상태 전이: idle → waiting(프롬프트 승인 대기) → working(생성 중) → reporting(결과 전달) → idle
|
||||
|
||||
---
|
||||
|
||||
## 5. 텔레그램 봇
|
||||
|
||||
### 구성
|
||||
- **Telegram Bot API** + **Webhook 수신** (NAS에서)
|
||||
- agent-office 서비스 내부에 통합 (별도 프로세스 아님)
|
||||
- Nginx: `/api/agent-office/telegram/webhook` → `agent-office:8000`
|
||||
|
||||
### 환경변수
|
||||
- `TELEGRAM_BOT_TOKEN`: Bot Father에서 발급
|
||||
- `TELEGRAM_CHAT_ID`: 사용자 채팅 ID (1:1 봇)
|
||||
- `TELEGRAM_WEBHOOK_URL`: Webhook 수신 URL (NAS 외부 접근 가능 URL)
|
||||
|
||||
### 메시지 포맷
|
||||
|
||||
**자동 알림 (뉴스 요약):**
|
||||
```
|
||||
📈 [주식 에이전트] 아침 뉴스 요약
|
||||
━━━━━━━━━━━━━━━━
|
||||
• 삼성전자: 반도체 수출 호조...
|
||||
• 코스피: 외인 순매수 전환...
|
||||
• 미국 CPI 발표 예정...
|
||||
|
||||
📊 관심종목 현황
|
||||
삼성전자 82,500원 (+2.1%)
|
||||
AAPL $185.20 (+1.2%)
|
||||
```
|
||||
|
||||
**승인 요청 (작곡):**
|
||||
```
|
||||
🎵 [음악 에이전트] 작곡 요청
|
||||
━━━━━━━━━━━━━━━━
|
||||
프롬프트: "Lo-fi hip hop, rainy day, piano"
|
||||
스타일: Chill, Ambient
|
||||
모델: V5.5
|
||||
|
||||
[✅ 승인] [❌ 거절] [✏️ 수정]
|
||||
```
|
||||
|
||||
**주가 알람:**
|
||||
```
|
||||
🚨 [주식 에이전트] 주가 알림
|
||||
━━━━━━━━━━━━━━━━
|
||||
삼성전자 82,500원
|
||||
조건: 82,000원 이상 → 도달!
|
||||
현재 등락: +2.1%
|
||||
```
|
||||
|
||||
### 양방향 흐름
|
||||
1. 에이전트 → `telegram_bot.send_message()` → 텔레그램
|
||||
2. 사용자 → 인라인 버튼 클릭 or 텍스트 입력
|
||||
3. 텔레그램 → Webhook POST → `telegram_bot.handle_webhook()`
|
||||
4. `handle_webhook()` → `telegram_state` 조회 → 에이전트 `on_approval()` 호출
|
||||
5. 에이전트 FSM 상태 전이 → WebSocket 브로드캐스트 → 프론트엔드 반영
|
||||
|
||||
---
|
||||
|
||||
## 6. 프론트엔드 구조
|
||||
|
||||
### 파일 구조
|
||||
|
||||
```
|
||||
src/pages/agent-office/
|
||||
├── AgentOffice.jsx # 메인 페이지 (Canvas + Overlay 컨테이너)
|
||||
├── AgentOffice.css # 스타일
|
||||
├── canvas/
|
||||
│ ├── OfficeRenderer.js # Canvas 렌더링 엔진 (게임루프)
|
||||
│ ├── SpriteSheet.js # 스프라이트시트 로더 + 프레임 애니메이션
|
||||
│ ├── TileMap.js # 타일맵 데이터 + 렌더링
|
||||
│ └── AgentSprite.js # 에이전트 캐릭터 (위치, 상태, 이동, 애니메이션)
|
||||
├── components/
|
||||
│ ├── ChatPanel.jsx # 에이전트 채팅/명령 패널
|
||||
│ ├── AgentBubble.jsx # 말풍선/상태 아이콘 오버레이
|
||||
│ ├── TaskHistory.jsx # 작업 이력 사이드패널
|
||||
│ └── ApprovalDialog.jsx # 승인 요청 다이얼로그
|
||||
├── hooks/
|
||||
│ ├── useAgentManager.js # WebSocket + 에이전트 상태 관리
|
||||
│ └── useOfficeCanvas.js # Canvas 초기화 + 이벤트 바인딩
|
||||
└── assets/
|
||||
├── tileset.png # 사무실 타일셋 (16x16 or 32x32)
|
||||
├── agents.png # 에이전트 스프라이트시트
|
||||
└── office-map.json # 타일맵 데이터
|
||||
```
|
||||
|
||||
### WebSocket 프로토콜
|
||||
|
||||
**서버 → 클라이언트:**
|
||||
```json
|
||||
{"type": "agent_state", "agent": "stock", "state": "working", "detail": "뉴스 수집 중..."}
|
||||
{"type": "agent_state", "agent": "music", "state": "waiting", "detail": "프롬프트 승인 대기", "task_id": "abc-123"}
|
||||
{"type": "task_complete", "agent": "stock", "task_id": "...", "result": {"summary": "..."}}
|
||||
{"type": "agent_move", "agent": "stock", "target": "break_room"}
|
||||
```
|
||||
|
||||
**클라이언트 → 서버:**
|
||||
```json
|
||||
{"type": "command", "agent": "music", "action": "compose", "params": {"prompt": "...", "style": "..."}}
|
||||
{"type": "approval", "agent": "music", "task_id": "abc-123", "approved": true}
|
||||
{"type": "query", "agent": "stock", "action": "status"}
|
||||
```
|
||||
|
||||
### ChatPanel 기능
|
||||
- 에이전트별 채팅 히스토리 표시
|
||||
- 텍스트 입력 + 빠른 액션 버튼
|
||||
- 승인 대기 중인 작업 강조 표시
|
||||
- 최근 작업 결과 인라인 표시
|
||||
|
||||
---
|
||||
|
||||
## 7. 인프라 변경
|
||||
|
||||
### Docker Compose 추가
|
||||
|
||||
```yaml
|
||||
agent-office:
|
||||
build: ./agent-office
|
||||
container_name: agent-office
|
||||
ports:
|
||||
- "18900:8000"
|
||||
volumes:
|
||||
- ${RUNTIME_PATH}/data:/app/data
|
||||
environment:
|
||||
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
|
||||
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID}
|
||||
- TELEGRAM_WEBHOOK_URL=${TELEGRAM_WEBHOOK_URL}
|
||||
- STOCK_LAB_URL=http://stock-lab:8000
|
||||
- MUSIC_LAB_URL=http://music-lab:8000
|
||||
depends_on:
|
||||
- stock-lab
|
||||
- music-lab
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
### Nginx 라우팅 추가
|
||||
|
||||
```nginx
|
||||
location /api/agent-office/ {
|
||||
proxy_pass http://agent-office:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade"; # WebSocket 지원
|
||||
}
|
||||
```
|
||||
|
||||
### 라우팅 (React Router)
|
||||
|
||||
```javascript
|
||||
// routes.jsx
|
||||
{ path: 'agent-office', lazy: () => import('./pages/agent-office/AgentOffice') }
|
||||
```
|
||||
|
||||
Lab 페이지(EffectLab.jsx)의 LAB_ITEMS에 Agent Office 항목 추가.
|
||||
|
||||
---
|
||||
|
||||
## 8. 향후 확장 (Phase 2+)
|
||||
|
||||
| 단계 | 내용 |
|
||||
|------|------|
|
||||
| Phase 2 | LottoAgent, BlogAgent, RealestateAgent 추가 |
|
||||
| Phase 3 | Claude AI Agent (자연어 복합 지시) |
|
||||
| Phase 4 | 방/층 확장 (부서별 공간 분리) |
|
||||
| Phase 5 | 에이전트 간 협업 시각화 (회의 테이블에서 토론) |
|
||||
| Phase 6 | 에이전트 커스텀 (이름, 외형, 성격 설정) |
|
||||
|
||||
---
|
||||
|
||||
## 9. 기술 스택 요약
|
||||
|
||||
| 레이어 | 기술 |
|
||||
|--------|------|
|
||||
| 사무실 렌더링 | HTML5 Canvas 2D (커스텀 엔진) |
|
||||
| 프론트엔드 | React 18 + Vite |
|
||||
| 실시간 통신 | WebSocket (FastAPI) |
|
||||
| 백엔드 | FastAPI (Python 3.12) |
|
||||
| DB | SQLite (agent_office.db) |
|
||||
| 스케줄러 | APScheduler |
|
||||
| 메시징 | Telegram Bot API (Webhook) |
|
||||
| 서비스 연동 | HTTP Proxy (기존 서비스 API 호출) |
|
||||
@@ -1,350 +0,0 @@
|
||||
# Lotto AI 큐레이터 — 설계 문서
|
||||
|
||||
> 작성일: 2026-04-15
|
||||
> 목표: 난잡한 lotto 랩을 **주간 AI 브리핑**을 축으로 재정리. 매주 월요일 아침 자동으로 "이번 주 5세트 + 내러티브 리포트"를 생성해 구매 의사결정 참고.
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경
|
||||
|
||||
- 현재 lotto 랩은 분석(5가지)·추천(통계/히트맵/메타)·시뮬레이션·전략진화 등 기능이 풍부하지만 출력이 분산되어 "결국 뭘 사야 하지"가 한눈에 들어오지 않음.
|
||||
- `docs/lotto-premium-roadmap.md` Phase 1 방향(신뢰 기반 + 주간 리포트)을 AI 활용으로 압축 실행.
|
||||
|
||||
## 2. 핵심 결정사항
|
||||
|
||||
| 항목 | 결정 |
|
||||
|------|------|
|
||||
| AI 역할 | **큐레이터(Curator)** — 숫자 생성 X, 기존 엔진 후보 중 5세트 선별 + 내러티브 작성 |
|
||||
| 브리핑 형식 | **A+B 조합** — 리포트형 내러티브 + 최종 5세트 카드 |
|
||||
| 트리거 | **매주 월요일 07:00 자동 생성** (웹 UI 전용, 텔레그램 미전송) |
|
||||
| 로직 위치 | **agent-office `lotto` 에이전트** (lotto-backend는 엔진·저장소 역할만) |
|
||||
| 모델 | `claude-sonnet-4-5` (주 1회 호출, 품질 우선) — 환경변수 `LOTTO_CURATOR_MODEL` |
|
||||
| 사용량 노출 | 브리핑 카드 + 큐레이터 사용량 API(월간 집계) |
|
||||
|
||||
---
|
||||
|
||||
## 3. 아키텍처
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ 월요일 07:00 APScheduler (agent-office) │
|
||||
│ → lotto 에이전트 curate_weekly 태스크 │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ 1. GET /api/lotto/curator/candidates?n=20 │ │
|
||||
│ │ 2. GET /api/lotto/curator/context │ │
|
||||
│ │ 3. Claude Sonnet 4.5 호출 (strict JSON out) │ │
|
||||
│ │ 4. 스키마·번호 검증 + 1회 재시도 │ │
|
||||
│ │ 5. POST /api/lotto/briefing (저장) │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 사용자는 웹에서: │
|
||||
│ GET /api/lotto/briefing/latest (최신 표시) │
|
||||
│ POST /api/agent-office/command {agent:"lotto", …} (수동) │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
서비스 경계: **lotto-backend = 데이터·엔진 / agent-office = AI 판단**.
|
||||
|
||||
---
|
||||
|
||||
## 4. Backend (lotto-backend)
|
||||
|
||||
### 4.1 신규 API
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/lotto/curator/candidates` | 큐레이터용 후보 N세트 + 세트별 피처 |
|
||||
| GET | `/api/lotto/curator/context` | 주간 맥락(핫/콜드·직전 회차 분석·내 최근 성과) |
|
||||
| POST | `/api/lotto/briefing` | 큐레이터 결과 저장 |
|
||||
| GET | `/api/lotto/briefing/latest` | 최신 브리핑 |
|
||||
| GET | `/api/lotto/briefing/{draw_no}` | 특정 회차 브리핑 |
|
||||
| GET | `/api/lotto/briefing?limit=10` | 브리핑 이력 |
|
||||
| GET | `/api/lotto/curator/usage?days=30` | 큐레이터 토큰·비용 집계 |
|
||||
|
||||
### 4.2 `GET /curator/candidates` 응답 구조
|
||||
|
||||
```json
|
||||
{
|
||||
"draw_no": 1180,
|
||||
"generated_at": "2026-04-13T07:00:00Z",
|
||||
"candidates": [
|
||||
{
|
||||
"numbers": [3, 14, 22, 29, 35, 41],
|
||||
"source": "simulation" | "meta" | "heatmap" | "statistics",
|
||||
"features": {
|
||||
"odd_count": 3,
|
||||
"even_count": 3,
|
||||
"low_count": 3, // 1~22
|
||||
"high_count": 3, // 23~45
|
||||
"range_distribution": [1,1,1,1,1,1], // 1-10,11-20,...,41-45
|
||||
"has_consecutive": true,
|
||||
"hot_number_count": 1, // context.hot_numbers 교집합
|
||||
"cold_number_count": 2, // context.cold_numbers 교집합
|
||||
"sum": 144,
|
||||
"historical_match_avg": 2.3 // 이 세트가 과거 실제 회차와 평균 몇 개 일치
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
중복 제거: 6숫자 정렬 튜플 기준 set 해시. 각 세트의 `source`는 가장 먼저 포함시킨 엔진.
|
||||
|
||||
### 4.3 `GET /curator/context` 응답 구조
|
||||
|
||||
```json
|
||||
{
|
||||
"draw_no": 1180,
|
||||
"hot_numbers": [3, 17, 28], // 최근 10회 과출현 top
|
||||
"cold_numbers": [7, 22, 41], // 최근 30회 미출현 top
|
||||
"last_draw_summary": "1179회: 7, 12, 18, 24, 31, 40 (홀4짝2, 저4고2)",
|
||||
"recent_analysis": {
|
||||
"avg_sum": 138,
|
||||
"avg_odd_count": 2.8
|
||||
},
|
||||
"my_recent_performance": [
|
||||
{ "draw_no": 1177, "purchased_sets": 5, "best_match": 3 },
|
||||
{ "draw_no": 1178, "purchased_sets": 5, "best_match": 2 },
|
||||
{ "draw_no": 1179, "purchased_sets": 5, "best_match": 4 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 신규 테이블 `lotto_briefings`
|
||||
|
||||
```sql
|
||||
CREATE TABLE lotto_briefings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
draw_no INTEGER UNIQUE NOT NULL,
|
||||
picks TEXT NOT NULL, -- JSON: 5세트 + reason + risk_tag
|
||||
narrative TEXT NOT NULL, -- JSON: headline/summary_3lines/hot_cold/warnings
|
||||
confidence INTEGER NOT NULL, -- 0~100
|
||||
model TEXT NOT NULL,
|
||||
tokens_input INTEGER DEFAULT 0,
|
||||
tokens_output INTEGER DEFAULT 0,
|
||||
cache_read INTEGER DEFAULT 0,
|
||||
cache_write INTEGER DEFAULT 0,
|
||||
latency_ms INTEGER DEFAULT 0,
|
||||
source TEXT NOT NULL DEFAULT 'auto', -- 'auto' | 'manual'
|
||||
generated_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
|
||||
);
|
||||
CREATE INDEX idx_briefings_draw ON lotto_briefings(draw_no DESC);
|
||||
```
|
||||
|
||||
### 4.5 파일 구조 정리
|
||||
|
||||
`backend/app/main.py` 933줄 → 라우터 분리:
|
||||
- `backend/app/routers/briefing.py` — briefing CRUD + curator usage
|
||||
- `backend/app/routers/curator.py` — candidates / context
|
||||
- `backend/app/curator_helpers.py` — 후보 중복 제거, 피처 계산, 맥락 추출
|
||||
|
||||
기존 `main.py`는 라우터 등록과 앱 조립만 담당(목표 ~300줄).
|
||||
|
||||
---
|
||||
|
||||
## 5. agent-office `lotto` 에이전트
|
||||
|
||||
### 5.1 파일 구조
|
||||
|
||||
```
|
||||
agent-office/app/
|
||||
agents/lotto.py # LottoAgent (BaseAgent 상속)
|
||||
curator/
|
||||
__init__.py
|
||||
pipeline.py # curate_weekly() 메인 플로우
|
||||
prompt.py # system prompt + 출력 스키마 정의
|
||||
schema.py # pydantic 응답 모델 + 검증
|
||||
service.py # lotto-backend 호출 래퍼 (httpx)
|
||||
```
|
||||
|
||||
`service_proxy.py`에 `lotto_candidates()`, `lotto_context()`, `lotto_save_briefing()` 메서드 추가.
|
||||
|
||||
### 5.2 태스크 타입
|
||||
|
||||
- `curate_weekly` — 자동/수동 공통. 파라미터 없음(draw_no 자동 계산).
|
||||
|
||||
### 5.3 큐레이터 규칙 (system prompt 요지)
|
||||
|
||||
```
|
||||
당신은 로또 번호 큐레이터입니다. 후보 20세트 중 5세트를 다음 규칙으로 선별합니다.
|
||||
|
||||
선별 규칙:
|
||||
- 5세트의 리스크 분포: 안정 2 · 균형 2 · 공격 1 (유연 ±1)
|
||||
- 홀짝 비율, 저/고 구간, 연속번호 포함 여부가 세트끼리 겹치지 않도록 다양성 확보
|
||||
- hot_number_count와 cold_number_count 모두 0인 세트는 최소 1개
|
||||
- 후보 외 번호 사용 절대 금지
|
||||
- 각 세트 reason은 40자 이내 한 줄 (해당 세트 피처와 context 값만 근거)
|
||||
|
||||
출력은 반드시 아래 JSON 스키마로만:
|
||||
{
|
||||
"picks": [
|
||||
{"numbers":[...], "risk_tag":"안정"|"균형"|"공격", "reason":"..."}
|
||||
],
|
||||
"narrative": {
|
||||
"headline": "...",
|
||||
"summary_3lines": ["...","...","..."],
|
||||
"hot_cold_comment": "...",
|
||||
"warnings": "..." // 없으면 빈 문자열
|
||||
},
|
||||
"confidence": 0-100
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 파이프라인 의사코드
|
||||
|
||||
```python
|
||||
async def curate_weekly(draw_no: int) -> dict:
|
||||
candidates = await service.lotto_candidates(n=20)
|
||||
context = await service.lotto_context()
|
||||
prompt = build_prompt(candidates, context, draw_no)
|
||||
|
||||
result, usage = await call_claude(prompt, model=LOTTO_CURATOR_MODEL)
|
||||
parsed = validate(result) # 실패 시 1회 재시도
|
||||
if parsed is None:
|
||||
raise CuratorError("schema validation failed after retry")
|
||||
|
||||
await service.lotto_save_briefing({
|
||||
"draw_no": draw_no,
|
||||
"picks": parsed.picks,
|
||||
"narrative": parsed.narrative,
|
||||
"confidence": parsed.confidence,
|
||||
"model": LOTTO_CURATOR_MODEL,
|
||||
"tokens_input": usage.input,
|
||||
"tokens_output": usage.output,
|
||||
"cache_read": usage.cache_read,
|
||||
"cache_write": usage.cache_write,
|
||||
"latency_ms": usage.latency_ms,
|
||||
"source": "auto" | "manual",
|
||||
})
|
||||
return {"ok": True, "draw_no": draw_no, ...}
|
||||
```
|
||||
|
||||
### 5.5 검증 로직 (`schema.py`)
|
||||
|
||||
- pydantic 모델로 형식 검증
|
||||
- 번호 제약: 각 세트 정확히 6개 · 중복 없음 · 1~45 범위
|
||||
- 세트 수: 정확히 5
|
||||
- 번호가 **candidates 내에 존재하는 조합인지** 대조 (환각 차단)
|
||||
- risk_tag 분포가 규칙에서 ±1 이상 벗어나면 경고 로그(차단은 안 함)
|
||||
- 실패 시 errors 리스트 담아 1회 재시도(프롬프트에 에러 피드백 포함)
|
||||
|
||||
### 5.6 스케줄러
|
||||
|
||||
`scheduler.py`에 추가:
|
||||
```python
|
||||
scheduler.add_job(_run_lotto_curate, "cron", day_of_week="mon", hour=7, minute=0, id="lotto_curate")
|
||||
```
|
||||
|
||||
### 5.7 상태 표시
|
||||
|
||||
agent-office 메인 UI에 lotto 에이전트 카드가 추가되어 `idle` / `working` / `error` 상태 실시간 표시(기존 BaseAgent 패턴).
|
||||
|
||||
---
|
||||
|
||||
## 6. Frontend (web-ui)
|
||||
|
||||
### 6.1 새 탭 구조
|
||||
|
||||
```
|
||||
Lotto
|
||||
├─ 🗓 이번 주 브리핑 (기본)
|
||||
├─ 📊 분석·통계
|
||||
└─ 💰 구매·성과
|
||||
```
|
||||
|
||||
`Functions.jsx` 460줄 → 탭 라우터 ~80줄로 축소. 각 탭은 `pages/lotto/tabs/BriefingTab.jsx`, `AnalysisTab.jsx`, `PurchaseTab.jsx`.
|
||||
|
||||
### 6.2 신규 컴포넌트 (`components/briefing/`)
|
||||
|
||||
- **BriefingHeader.jsx** — 회차 번호, 생성 시각, 신뢰도 바, 재생성 버튼, **사용 토큰 칩**(`42K in · 1.2K out · $0.18`)
|
||||
- **BriefingSummary.jsx** — 3줄 요약 + 핫/콜드 블록 + 주의사항
|
||||
- **PickSetCard.jsx** — 6볼 + risk 뱃지(🟢안정/🟡균형/🔴공격) + reason + "구매 기록" CTA
|
||||
- **BriefingEmpty.jsx** — 브리핑 없을 때 placeholder + "지금 생성" 버튼
|
||||
- **CuratorUsageFooter.jsx** — 페이지 하단 mini 카드. 최근 30일 호출 수·토큰·추정 비용·캐시 히트율
|
||||
|
||||
### 6.3 훅
|
||||
|
||||
- **useBriefing.js**
|
||||
- `GET /api/lotto/briefing/latest`
|
||||
- `regenerate()`: `POST /api/agent-office/command {agent:"lotto", action:"curate_now"}` → 3초 간격 최대 40회(=2분) 폴링으로 신규 briefing 확인
|
||||
- 로딩/에러 상태 분리, 월요일 07:00 이후인데 브리핑 없으면 빈 상태 CTA
|
||||
- **useCuratorUsage.js** — `GET /api/lotto/curator/usage?days=30`
|
||||
|
||||
### 6.4 기존 컴포넌트 처리
|
||||
|
||||
| 컴포넌트 | 조치 |
|
||||
|---------|------|
|
||||
| `FrequencyChart`, `MetricBlock`, `PersonalAnalysisPanel`, `ReportPanel` | 분석 탭으로 이동 |
|
||||
| `PurchasePanel`, `PerformanceBanner` | 구매 탭으로 이동 |
|
||||
| `CombinedRecommendPanel`, `ConfidenceRing` | 제거 후보 — 정리 패스에서 실제 참조 없으면 삭제 |
|
||||
|
||||
### 6.5 토큰·비용 노출 정책
|
||||
|
||||
- **브리핑 카드 헤더**: 이번 브리핑 1건의 in/out 토큰 + 추정 비용 (Sonnet 4.5 단가 기준 계산 — 상수로 프론트에 보유, `$3/$15 per 1M tokens`)
|
||||
- **페이지 하단 푸터**: 최근 30일 누적 — 호출 수, 총 토큰, 추정 비용, 캐시 히트율
|
||||
- **Agent Office 사이드**: 기존 `GET /api/agent-office/agents/lotto/token-usage` 자동 상속
|
||||
|
||||
### 6.6 모바일
|
||||
|
||||
브리핑 탭 세로 스택 기본. PickSetCard는 한 행 1카드 + 6볼 flex-wrap. 헤더 토큰 칩은 768px 이하에서 축약 표시(`$0.18`만).
|
||||
|
||||
---
|
||||
|
||||
## 7. 환경변수
|
||||
|
||||
| 변수 | 기본값 | 위치 |
|
||||
|------|--------|------|
|
||||
| `ANTHROPIC_API_KEY` | (없음) | agent-office (이미 존재) |
|
||||
| `LOTTO_CURATOR_MODEL` | `claude-sonnet-4-5` | agent-office |
|
||||
| `LOTTO_BACKEND_URL` | `http://lotto-backend:8000` | agent-office (service_proxy) |
|
||||
|
||||
---
|
||||
|
||||
## 8. 에러·폴백
|
||||
|
||||
| 상황 | 처리 |
|
||||
|------|------|
|
||||
| lotto-backend 후보 API 실패 | 에이전트 상태 `error` + 로그 + 슬랙/알림 없음(주 1회라 로그 충분) |
|
||||
| Claude 호출 실패 | 1회 재시도 후 실패 시 error 저장, 기존 최신 브리핑 유지 |
|
||||
| JSON 스키마 검증 실패 | 피드백 포함 1회 재시도 → 실패 시 error |
|
||||
| 월요일 생성 자체가 누락 | 사용자가 웹에서 수동 재생성 버튼으로 보완 가능 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 구현 순서
|
||||
|
||||
1. **Backend**: curator 엔드포인트 + briefing CRUD + 라우터 분리
|
||||
2. **Agent-office**: lotto 에이전트 + curator pipeline + 월요일 스케줄러
|
||||
3. **Frontend**: BriefingTab + 컴포넌트 + 훅 + 탭 재배치
|
||||
4. **미사용 정리 패스**: 아래 "10. 정리 대상" 후보를 실제 참조 grep → 제거
|
||||
|
||||
---
|
||||
|
||||
## 10. 정리 대상 (최종 패스에서 검증 후 제거)
|
||||
|
||||
### Frontend
|
||||
- `components/CombinedRecommendPanel.jsx`
|
||||
- `components/ConfidenceRing.jsx`
|
||||
- `Functions.jsx` 내 인라인 레이아웃 로직 (탭 분리 후 잔재)
|
||||
|
||||
### Backend
|
||||
- `strategy_evolver.py` 중 실제 사용되지 않는 EMA 서브 함수
|
||||
- 주간 리포트 관련 `weekly_reports` 테이블 — 브리핑이 대체하므로 드롭 후보
|
||||
- `best_picks` 교체 로직 중 큐레이터 전환 후 사용 안 되는 경로
|
||||
|
||||
### DB 드롭 후보
|
||||
- `weekly_reports` (브리핑이 대체)
|
||||
- `simulation_candidates` (best_picks만 있으면 충분한지 사용처 grep 후 결정)
|
||||
|
||||
정리 패스는 **실제 import/참조 grep → 없으면 제거 → 테스트 → 커밋** 순서로 별도 커밋 분리.
|
||||
|
||||
---
|
||||
|
||||
## 11. 성공 기준
|
||||
|
||||
- 월요일 07:00 브리핑이 자동 생성되고, 웹 페이지 진입 1초 안에 5세트 + 3줄 요약이 보인다.
|
||||
- 큐레이터는 candidates 내 세트만 선택한다(환각 0건).
|
||||
- 브리핑 카드에 이번 건 토큰/비용, 페이지 하단에 30일 누적 사용량이 표시된다.
|
||||
- 기존 난잡한 패널이 분석/구매 탭으로 정돈되고 브리핑 탭이 기본 진입점이다.
|
||||
- 미사용 테이블·컴포넌트가 최종 정리 패스에서 제거된다.
|
||||
@@ -1,360 +0,0 @@
|
||||
# 반응형 웹 UI/UX 전면 개선 설계
|
||||
|
||||
> 모바일에서 UI 짤림 현상 해결 + 풀 모바일 경험 적용
|
||||
> 작성일: 2026-04-23
|
||||
> 리뷰 반영: 2026-04-23 (라우트 경로 수정, breakpoint 예외 명시, 구현 복잡도 보완)
|
||||
|
||||
---
|
||||
|
||||
## 1. 목표
|
||||
|
||||
- 전체 15개 뷰(12개 라우트 + 3개 서브라우트)에서 모바일 UI 짤림 현상 해결
|
||||
- 현재 다크 네온 사이버펑크 디자인 톤 유지
|
||||
- 모바일 전용 UX 패턴 추가 (바텀 네비게이션, 스와이프, 풀다운 리프레시, FAB, 바텀시트)
|
||||
- 기능적 손실 없이 반응형 적용
|
||||
|
||||
**대상 뷰 목록 (routes.jsx 기준):**
|
||||
|
||||
| # | 라우트 | 컴포넌트 | 비고 |
|
||||
|---|--------|---------|------|
|
||||
| 1 | `/` | Home | |
|
||||
| 2 | `/lotto` | Lotto | 3탭 (Briefing/Analysis/Purchase) |
|
||||
| 3 | `/stock` | Stock | |
|
||||
| 4 | `/stock/trade` | StockTrade | 서브라우트 |
|
||||
| 5 | `/travel` | Travel | |
|
||||
| 6 | `/blog` | Blog | |
|
||||
| 7 | `/blog-lab` | BlogMarketing | |
|
||||
| 8 | `/realestate` | Subscription | |
|
||||
| 9 | `/music` | MusicStudio | |
|
||||
| 10 | `/todo` | Todo | |
|
||||
| 11 | `/agent-office` | AgentOffice | |
|
||||
| 12 | `/lab` | EffectLab | |
|
||||
| 13 | `/lab/sword-stream` | SwordStream | 서브라우트 |
|
||||
| 14 | `/lab/day-calc` | DayCalc | 서브라우트 |
|
||||
|
||||
> Note: `RealEstate.jsx` (`/realestate/property`)는 routes.jsx에 미등록 상태. 반응형 스코프에서 제외.
|
||||
|
||||
---
|
||||
|
||||
## 2. 접근 방식
|
||||
|
||||
**글로벌 모바일 시스템 구축 → 주요 페이지 적용 → 전체 페이지 확장**
|
||||
|
||||
1. 공통 모바일 인프라(컴포넌트, breakpoint, 앱 셸) 구축
|
||||
2. 주요 4개 페이지 (홈, 로또, 주식, 여행) 우선 적용
|
||||
3. 나머지 페이지 확장 적용
|
||||
|
||||
---
|
||||
|
||||
## 3. 글로벌 모바일 인프라
|
||||
|
||||
### 3-1. Breakpoint 시스템 통일
|
||||
|
||||
현재 53개 미디어 쿼리에서 다양한 값이 혼재. 4단계로 통일:
|
||||
|
||||
| 이름 | 값 | 용도 |
|
||||
|------|-----|------|
|
||||
| sm | 480px | 소형 폰 |
|
||||
| md | 768px | 태블릿/대형 폰 (주요 분기점) |
|
||||
| lg | 1024px | 소형 데스크톱 |
|
||||
| xl | 1280px | 대형 데스크톱 |
|
||||
|
||||
기존 미디어 쿼리의 비표준 값(640px, 900px, 960px, 1100px 등)은 기능 손실 없이 가장 가까운 표준 breakpoint로 정리한다.
|
||||
|
||||
**허용 예외 (이동 시 시각적 회귀 발생):**
|
||||
|
||||
| 기존 값 | 파일 | 사유 |
|
||||
|---------|------|------|
|
||||
| 420px | Stock.css (4곳) | 소형 폰 전용 패딩/라벨 축소, 480px로 이동 시 중간 기기에서 불필요한 축소 |
|
||||
| 520px | Stock.css (1곳) | 지표 카드 특수 레이아웃 |
|
||||
| 700px | Stock.css (1곳) | AI 코치 설정 그리드, 768px로 이동 시 태블릿에서 조기 축소 |
|
||||
|
||||
위 값들은 해당 페이지 CSS에서 기존 값을 유지한다.
|
||||
|
||||
### 3-2. 바텀 네비게이션 바 (`BottomNav`)
|
||||
|
||||
- 768px 이하에서 사이드바 대신 표시
|
||||
- 주요 5개 메뉴 아이콘 + "더보기" 메뉴 (나머지 페이지)
|
||||
- 현재 페이지 활성 표시 — 네온 시안 글로우 유지
|
||||
- 사이드바는 모바일에서 완전히 숨김 (기존 햄버거→슬라이드 방식 제거)
|
||||
- 높이: 56~64px
|
||||
- `env(safe-area-inset-bottom)` 대응 (노치/홈 인디케이터 기기)
|
||||
- `index.html`에 `viewport-fit=cover` 추가 필요: `<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">`
|
||||
- 더보기 메뉴: 탭 시 위로 펼쳐지는 오버레이 패널
|
||||
|
||||
**사이드바→바텀네비 마이그레이션 상세:**
|
||||
- `Navbar.jsx`: 768px 이하에서 사이드바 렌더링 제거, `sidebar-toggle` 버튼 제거
|
||||
- `Navbar.css`: `.sidebar` transform/transition 미디어 쿼리 제거, `.sidebar__overlay` 제거
|
||||
- `Navbar.jsx` useEffect: `body.overflow = 'hidden'` 토글 로직 정리
|
||||
- `App.jsx`에서 `BottomNav` 컴포넌트 조건부 렌더링 (`useIsMobile()` 기반)
|
||||
|
||||
**더보기 메뉴 내용 (나머지 네비게이션 항목):**
|
||||
|
||||
| 순서 | 아이콘 | 라벨 | 경로 |
|
||||
|------|--------|------|------|
|
||||
| 1 | 음악 | 뮤직 | `/music` |
|
||||
| 2 | 로봇 | 에이전트 | `/agent-office` |
|
||||
| 3 | 블로그 | 블로그 | `/blog` |
|
||||
| 4 | 마케팅 | 블로그랩 | `/blog-lab` |
|
||||
| 5 | 건물 | 청약 | `/realestate` |
|
||||
| 6 | 체크 | TODO | `/todo` |
|
||||
| 7 | 실험 | 이펙트랩 | `/lab` |
|
||||
|
||||
**기본 5개 메뉴 구성:**
|
||||
|
||||
| 순서 | 아이콘 | 라벨 | 경로 |
|
||||
|------|--------|------|------|
|
||||
| 1 | 홈 | 홈 | `/` |
|
||||
| 2 | 클로버 | 로또 | `/lotto` |
|
||||
| 3 | 차트 | 주식 | `/stock` |
|
||||
| 4 | 카메라 | 여행 | `/travel` |
|
||||
| 5 | 더보기 | 메뉴 | 오버레이 |
|
||||
|
||||
### 3-3. 공통 모바일 컴포넌트
|
||||
|
||||
| 컴포넌트 | 파일 | 역할 |
|
||||
|---------|------|------|
|
||||
| `BottomNav` | `src/components/BottomNav.jsx` | 하단 고정 네비게이션 |
|
||||
| `PullToRefresh` | `src/components/PullToRefresh.jsx` | 터치 풀다운 새로고침 래퍼 |
|
||||
| `SwipeableView` | `src/components/SwipeableView.jsx` | 좌우 스와이프 탭/뷰 전환 |
|
||||
| `FAB` | `src/components/FAB.jsx` | 플로팅 액션 버튼 (바텀 네비 위 배치) |
|
||||
| `MobileSheet` | `src/components/MobileSheet.jsx` | 바텀시트 모달 (드래그 핸들, 스냅 포인트) |
|
||||
|
||||
**공통 훅 (신규 `src/hooks/` 디렉토리 생성):**
|
||||
|
||||
> 기존 훅은 페이지별 디렉토리에 colocate (`src/pages/lotto/hooks/` 등).
|
||||
> 모바일 인프라 훅은 여러 페이지에서 공유하므로 `src/hooks/`에 배치한다.
|
||||
|
||||
| 훅 | 파일 | 역할 |
|
||||
|----|------|------|
|
||||
| `useIsMobile` | `src/hooks/useIsMobile.js` | 768px 이하 감지 (matchMedia) |
|
||||
| `useSwipe` | `src/hooks/useSwipe.js` | 터치 스와이프 방향·거리 감지 |
|
||||
|
||||
**경량 라이브러리 활용:**
|
||||
- `react-swipeable` (~3KB gzipped): SwipeableView/useSwipe 기반으로 활용 — 터치 velocity, threshold snap, 방향 판별을 직접 구현하지 않음
|
||||
- PullToRefresh: 터치 이벤트 직접 구현하되, iOS Safari rubber-banding 및 `overscroll-behavior: contain` 대응 필수
|
||||
- MobileSheet: CSS `transform` + `touch-action: none`으로 구현, 스냅 포인트 2단계 (50%, 90%)
|
||||
|
||||
### 3-4. 앱 셸 레이아웃 변경
|
||||
|
||||
```
|
||||
데스크톱: [사이드바 240px] [콘텐츠]
|
||||
모바일: [탑바 56px]
|
||||
[콘텐츠 (padding-bottom: 바텀네비 높이)]
|
||||
[바텀 네비 56-64px]
|
||||
```
|
||||
|
||||
- 콘텐츠 영역에 `padding-bottom` 추가 (바텀 네비 겹침 방지)
|
||||
- 탑바: 현재 구조 유지, 페이지 타이틀 + 액션 버튼 영역
|
||||
- `body` overflow: 모바일에서 auto (현재와 동일)
|
||||
|
||||
---
|
||||
|
||||
## 4. 주요 페이지별 모바일 설계
|
||||
|
||||
### 4-1. 홈 (Home) — `/`
|
||||
|
||||
| 영역 | 데스크톱 | 모바일 (≤768px) |
|
||||
|------|---------|-----------------|
|
||||
| 히어로 | 2컬럼 그리드 | 1컬럼 스택, 타이틀 축소 |
|
||||
| 네비 카드 그리드 | auto-fill minmax(180px) | 2컬럼 고정, 카드 높이 축소 |
|
||||
| TODO 보드 | 3컬럼 칸반 | 스와이프 탭 (Todo/진행중/완료) |
|
||||
| 블로그 포스트 | 카드 그리드 | 1컬럼 리스트 |
|
||||
| 프로필 섹션 | 사이드 카드 | 하단 접이식 패널 |
|
||||
|
||||
- 풀다운 리프레시: 블로그 포스트 갱신
|
||||
- FAB: 없음 (네비게이션 허브)
|
||||
|
||||
### 4-2. 로또 (Lotto) — `/lotto`
|
||||
|
||||
| 영역 | 데스크톱 | 모바일 |
|
||||
|------|---------|--------|
|
||||
| 3탭 구조 | 상단 탭바 | 스와이프 탭 전환 |
|
||||
| 브리핑 탭 | 카드 레이아웃 | 1컬럼, 볼 크기 36→32px |
|
||||
| 분석 탭 | 그리드 카드 | 1컬럼 스택 |
|
||||
| 구매 이력 테이블 | 6컬럼 그리드 | 가로 스크롤 테이블 + 행 터치 바텀시트 |
|
||||
| 번호 추천 카드 | 다중 그리드 | 1컬럼, 볼 간격 조정 |
|
||||
| 전략 차트 | 넓은 차트 | 가로스크롤 또는 축소 |
|
||||
|
||||
- FAB: "추천받기" (빠른 번호 추천)
|
||||
- 풀다운 리프레시: 브리핑/분석 데이터 갱신
|
||||
|
||||
### 4-3. 주식 (Stock / StockTrade) — `/stock`, `/stock/trade`
|
||||
|
||||
**Stock (뉴스/지표)**
|
||||
|
||||
| 영역 | 데스크톱 | 모바일 |
|
||||
|------|---------|--------|
|
||||
| 헤더 | 2컬럼 | 1컬럼 스택 |
|
||||
| 뉴스 그리드 | auto-fit minmax(260px) | 1컬럼 카드 리스트 |
|
||||
| 필터 | 가로 나열 | 가로 스크롤 칩 바 |
|
||||
| 지표 카드 | 그리드 | 가로 스크롤 카드 캐러셀 |
|
||||
|
||||
**StockTrade (매매)**
|
||||
|
||||
| 영역 | 데스크톱 | 모바일 |
|
||||
|------|---------|--------|
|
||||
| 포트폴리오 테이블 | 넓은 테이블 | 카드형 리스트 (종목별 카드) |
|
||||
| 매도 이력 | 테이블 | 가로 스크롤 + 행 터치 바텀시트 |
|
||||
| 자산 차트 | 넓은 recharts | 풀 너비, 축 라벨 축소 |
|
||||
| 예수금 섹션 | 인라인 | 접이식 카드 |
|
||||
|
||||
- FAB: "종목 추가" (Stock), "매도 기록" (StockTrade)
|
||||
- 풀다운 리프레시: 뉴스/포트폴리오 갱신
|
||||
|
||||
### 4-4. 여행 (Travel) — `/travel`
|
||||
|
||||
| 영역 | 데스크톱 | 모바일 |
|
||||
|------|---------|--------|
|
||||
| 지역 선택 | Leaflet 지도 | 높이 50vh→35vh, 핀치 줌 |
|
||||
| 사진 그리드 | 다중 컬럼 | 2컬럼 → 1컬럼 (≤480px) |
|
||||
| 사진 상세 | 모달 | 풀스크린 뷰어 + 스와이프 넘기기 |
|
||||
| 지역 필터 | 드롭다운 | 바텀시트 지역 선택 |
|
||||
|
||||
- 풀다운 리프레시: 사진 목록 갱신
|
||||
- FAB: 없음
|
||||
|
||||
---
|
||||
|
||||
## 5. 나머지 페이지 모바일 설계
|
||||
|
||||
### 5-1. 블로그 (Blog) — `/blog`
|
||||
|
||||
| 영역 | 모바일 변경 |
|
||||
|------|------------|
|
||||
| 글 목록 | 1컬럼 리스트형 |
|
||||
| 글 상세 | 풀 너비, 폰트 크기 조정 |
|
||||
| 태그 필터 | 가로 스크롤 칩 바 |
|
||||
| 작성/수정 폼 | 풀 너비, 툴바 축소 |
|
||||
|
||||
- FAB: "글 쓰기"
|
||||
- 풀다운 리프레시: 글 목록 갱신
|
||||
|
||||
### 5-2. 블로그 마케팅 (BlogMarketing) — `/blog-lab`
|
||||
|
||||
| 영역 | 모바일 변경 |
|
||||
|------|------------|
|
||||
| 대시보드 지표 | 2컬럼 → 1컬럼 (≤480px) |
|
||||
| 파이프라인 테이블 | 카드형 리스트 (상태 배지) |
|
||||
| 키워드 분석 | 접이식 아코디언 |
|
||||
| 수익 내역 | 가로 스크롤 테이블 |
|
||||
|
||||
- FAB: "키워드 분석"
|
||||
- 풀다운 리프레시: 대시보드 갱신
|
||||
|
||||
### 5-3. 부동산 청약 (Subscription) — `/realestate`
|
||||
|
||||
| 영역 | 모바일 변경 |
|
||||
|------|------------|
|
||||
| 공고 목록 | 1컬럼 카드 리스트 |
|
||||
| 필터 | 바텀시트 필터 패널 |
|
||||
| 공고 상세 | 바텀시트 상세보기 |
|
||||
| 매칭 결과 | 1컬럼, 점수 강조 |
|
||||
| 대시보드 | 2컬럼 그리드 |
|
||||
|
||||
- FAB: "공고 등록"
|
||||
- 풀다운 리프레시: 공고/매칭 갱신
|
||||
|
||||
### 5-4. 뮤직 스튜디오 (MusicStudio) — `/music`
|
||||
|
||||
| 영역 | 모바일 변경 |
|
||||
|------|------------|
|
||||
| 헤더 | 1컬럼, 타이틀 클램프 축소 |
|
||||
| 생성 폼 | 풀 너비 스택 |
|
||||
| 라이브러리 | 1컬럼 리스트 (앨범아트 + 제목) |
|
||||
| 플레이어 | 미니 플레이어 바텀 고정 (높이 56px, 바텀 네비 위 = bottom: 64px) |
|
||||
| 가사 에디터 | 풀 너비 |
|
||||
| 레이더 위젯 | 중앙 정렬 |
|
||||
|
||||
- FAB: "음악 생성"
|
||||
- 풀다운 리프레시: 라이브러리 갱신
|
||||
- 미니 플레이어 표시 시 콘텐츠 padding-bottom: 바텀네비(64px) + 미니플레이어(56px) = 120px
|
||||
|
||||
### 5-5. TODO — `/todo`
|
||||
|
||||
| 영역 | 모바일 변경 |
|
||||
|------|------------|
|
||||
| 칸반 보드 | 스와이프 탭 (Todo/진행중/완료) |
|
||||
| 할일 카드 | 스와이프로 상태 변경 |
|
||||
| 입력 폼 | FAB → 바텀시트 입력 폼 |
|
||||
|
||||
- FAB: "할일 추가"
|
||||
|
||||
### 5-6. 에이전트 오피스 (AgentOffice) — `/agent-office`
|
||||
|
||||
| 영역 | 모바일 변경 |
|
||||
|------|------------|
|
||||
| 캔버스 오피스 | 풀스크린 캔버스, 핀치 줌/패닝 |
|
||||
| 에이전트 패널 | 바텀시트 에이전트 상세 |
|
||||
| 작업 로그 | 바텀시트 로그 뷰 |
|
||||
| 명령 입력 | 하단 입력 바 (채팅 UX) |
|
||||
| WebSocket 상태 | 탑바에 연결 상태 아이콘 |
|
||||
|
||||
### 5-7. 이펙트 랩 — `/lab`, `/lab/day-calc`, `/lab/sword-stream`
|
||||
|
||||
| 페이지 | 모바일 변경 |
|
||||
|--------|------------|
|
||||
| EffectLab 허브 | 카드 그리드 → 1컬럼 리스트 |
|
||||
| DayCalc | 풀 너비 스택, 네이티브 날짜 피커 |
|
||||
| SwordStream | 풀스크린 캔버스, 터치 인터랙션 유지, 오버레이 축소 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 터치 타겟 가이드라인
|
||||
|
||||
- 모든 터치 타겟: 최소 44×44px (Apple HIG 기준)
|
||||
- 버튼 간 간격: 최소 8px
|
||||
- FAB 크기: 56×56px
|
||||
- 바텀 네비 아이템: 최소 48×48px 터치 영역
|
||||
|
||||
---
|
||||
|
||||
## 7. 성능 고려사항
|
||||
|
||||
- 모바일에서 글로우/그라디언트 효과: box-shadow 개수 줄이기 (3중→1중)
|
||||
- `background-attachment: fixed` → 모바일에서 `scroll` (현재 적용됨, 유지)
|
||||
- 이미지: `loading="lazy"` 속성 확인
|
||||
- 스와이프/터치 이벤트: passive listener 사용
|
||||
- 바텀시트 애니메이션: `transform` + `will-change` 사용 (layout thrashing 방지)
|
||||
- 신규 애니메이션(스와이프, 바텀시트, 풀다운)은 `prefers-reduced-motion: reduce` 쿼리 존중 — Travel.css, MusicStudio.css 기존 패턴과 통일
|
||||
|
||||
### 주의: Stock.css / StockTrade.jsx 커플링
|
||||
|
||||
`StockTrade.jsx`는 `Stock.css`의 스타일을 공유한다. Stock.css의 반응형 수정은 StockTrade에도 영향을 미치므로, 반드시 두 페이지를 함께 검증해야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 8. 구현 순서
|
||||
|
||||
### Phase 1: 글로벌 인프라
|
||||
|
||||
**Phase 1a: Breakpoint 정리 (기존 CSS만 수정, 신규 코드 없음)**
|
||||
1. Breakpoint 시스템 통일 — 각 CSS 파일의 비표준 미디어 쿼리를 표준 값으로 정리
|
||||
2. `index.html`에 `viewport-fit=cover` 추가
|
||||
3. 회귀 테스트: 정리 후 각 페이지 데스크톱/모바일 확인
|
||||
|
||||
**Phase 1b: 공통 컴포넌트 & 앱 셸**
|
||||
4. `react-swipeable` 패키지 설치
|
||||
5. `src/hooks/` 디렉토리 생성 + `useIsMobile`, `useSwipe` 훅 구현
|
||||
6. `BottomNav` 컴포넌트 구현 + 사이드바 모바일 제거 마이그레이션 (Navbar.jsx/css 수정)
|
||||
7. `PullToRefresh`, `SwipeableView`, `FAB`, `MobileSheet` 컴포넌트 구현
|
||||
8. 앱 셸 레이아웃 수정 (App.jsx, App.css)
|
||||
|
||||
### Phase 2: 주요 페이지 적용
|
||||
9. 홈 페이지 반응형 개선
|
||||
10. 로또 페이지 반응형 개선
|
||||
11. 주식 페이지 (Stock + StockTrade 함께 검증) 반응형 개선
|
||||
12. 여행 페이지 반응형 개선
|
||||
|
||||
### Phase 3: 나머지 페이지 확장
|
||||
13. 블로그 (`/blog`) + 블로그 마케팅 (`/blog-lab`)
|
||||
14. 부동산 청약 (`/realestate`)
|
||||
15. 뮤직 스튜디오 (`/music`)
|
||||
16. TODO (`/todo`)
|
||||
17. 에이전트 오피스 (`/agent-office`)
|
||||
18. 이펙트 랩 (`/lab` + `/lab/day-calc` + `/lab/sword-stream`)
|
||||
|
||||
### Phase 4: 검증
|
||||
19. 전체 뷰 모바일 UI 검증 — 대상 뷰포트: 360px (Galaxy S), 390px (iPhone 14), 768px (iPad), 1024px (데스크톱)
|
||||
20. `prefers-reduced-motion` 동작 확인
|
||||
21. 터치 타겟 크기 검증 (44×44px 최소)
|
||||
@@ -1,313 +0,0 @@
|
||||
# Travel Gallery Redesign — Design Spec
|
||||
|
||||
## Goal
|
||||
|
||||
Travel 여행 기록 갤러리를 앨범 기반 진입 + Masonry 그리드 + HERO 확대 라이트박스로 리디자인한다. 모놀리식 1,024줄 컴포넌트를 7-8개 집중된 파일로 분리하고, 시네마틱 여행 감성을 강화한다.
|
||||
|
||||
## Scope
|
||||
|
||||
- **포함**: 프론트엔드 리디자인 (컴포넌트 분리 + 새 UX/UI)
|
||||
- **포함**: 동영상 탭 UI 셸 (플레이스홀더)
|
||||
- **제외**: 백엔드 동영상 API (별도 후속 스펙)
|
||||
- **제외**: 핀치 줌 (복잡도 대비 효과 낮음)
|
||||
|
||||
## Architecture
|
||||
|
||||
점진적 리팩토링 — 기존 API 호출/캐싱/페이지네이션 로직을 `useTravelData` 훅으로 추출하여 재활용하고, UI 레이어를 새로 구성한다. 라우팅 변경 없이 React 상태 기반으로 앨범 진입/이탈을 관리한다.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- React 18 (기존)
|
||||
- Leaflet + react-leaflet (기존, 미니맵으로 축소)
|
||||
- react-swipeable (기존, 라이트박스 스와이프)
|
||||
- SwipeableView 컴포넌트 (기존, 사진/영상 탭)
|
||||
- CSS columns (Masonry 레이아웃)
|
||||
- IntersectionObserver (무한스크롤 + 스크롤 리빌)
|
||||
- Web Animations API / CSS transitions (shared element transition)
|
||||
|
||||
---
|
||||
|
||||
## 1. Component Structure & File Layout
|
||||
|
||||
```
|
||||
src/pages/travel/
|
||||
├── Travel.jsx # 메인 컨테이너 (미니맵 + 앨범 카드 리스트)
|
||||
├── Travel.css # 전체 레이아웃 + CSS 변수
|
||||
├── AlbumCard.jsx # 여행지 앨범 카드
|
||||
├── AlbumCard.css
|
||||
├── AlbumDetail.jsx # 앨범 상세 (탭 + Masonry)
|
||||
├── AlbumDetail.css
|
||||
├── MasonryGrid.jsx # Masonry 레이아웃 + 무한스크롤
|
||||
├── MasonryGrid.css
|
||||
├── HeroLightbox.jsx # HERO 확대 전환 라이트박스
|
||||
├── HeroLightbox.css
|
||||
├── MiniMap.jsx # Leaflet 미니맵
|
||||
├── MiniMap.css
|
||||
├── VideoTab.jsx # 영상 탭 UI 셸
|
||||
├── VideoTab.css
|
||||
└── useTravelData.js # API 호출 + 캐싱 + 페이지네이션 훅
|
||||
```
|
||||
|
||||
### Responsibilities
|
||||
|
||||
| 파일 | 책임 |
|
||||
|------|------|
|
||||
| `Travel.jsx` | 페이지 레이아웃, 지역 필터 상태, 앨범 선택 상태 관리 |
|
||||
| `useTravelData.js` | API fetch, 10분 TTL 캐시, 앨범별 그룹핑, 페이지네이션 |
|
||||
| `MiniMap.jsx` | Leaflet 지도 렌더링, GeoJSON 폴리곤, 지역 클릭 이벤트 발행 |
|
||||
| `AlbumCard.jsx` | 대표 사진 + 앨범명 + 사진 수 뱃지, 호버 효과 |
|
||||
| `AlbumDetail.jsx` | 앨범 오버레이, 진입/이탈 애니메이션, 사진/영상 탭 전환 |
|
||||
| `MasonryGrid.jsx` | CSS columns Masonry, IntersectionObserver 무한스크롤 + 스크롤 리빌 |
|
||||
| `HeroLightbox.jsx` | shared element transition, 좌우 스와이프, 썸네일 스트립 |
|
||||
| `VideoTab.jsx` | "영상 기능 준비 중" 플레이스홀더 |
|
||||
|
||||
### Page Flow
|
||||
|
||||
```
|
||||
Travel.jsx (메인)
|
||||
├── MiniMap (상단, 접기/펼치기 가능)
|
||||
│ └── 지역 클릭 → selectedRegion 상태 변경 → 앨범 필터
|
||||
├── AlbumCard[] (여행지 카드 리스트)
|
||||
│ └── 클릭 → AlbumDetail (오버레이)
|
||||
│ ├── [사진 탭] MasonryGrid
|
||||
│ │ └── 사진 클릭 → HeroLightbox
|
||||
│ └── [영상 탭] VideoTab
|
||||
└── useTravelData (데이터 레이어)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Main View — MiniMap + Album Card List
|
||||
|
||||
### MiniMap
|
||||
|
||||
- 높이: 데스크톱 200px, 모바일 150px
|
||||
- GeoJSON 지역 폴리곤 유지 (기존 MapLayer 로직 추출)
|
||||
- 클릭 시 해당 지역 앨범만 필터링
|
||||
- 선택된 지역: 지역별 악센트 컬러로 하이라이트
|
||||
- "전체 보기" 버튼으로 필터 해제
|
||||
- 접기/펼치기 토글 (기본: 펼침)
|
||||
- 접힌 상태: 높이 0 + overflow hidden, 토글 버튼만 표시
|
||||
|
||||
### Album Card List
|
||||
|
||||
- **카드 구성**: 대표 사진 배경 (object-fit: cover) + 앨범 이름 + 사진 수 뱃지
|
||||
- **대표 사진**: 앨범 첫 번째 사진의 썸네일 URL
|
||||
- **카드 레이아웃**: `display: grid`
|
||||
- 데스크톱 (>1024px): 3열
|
||||
- 태블릿 (769px-1024px): 2열
|
||||
- 모바일 (<=768px): 1열
|
||||
- **카드 높이**: 데스크톱 240px, 모바일 200px
|
||||
- **호버**: scale(1.03) + 지역 악센트 글로우
|
||||
- **지역 필터 전환**: fade 애니메이션 (opacity 300ms)
|
||||
|
||||
### Album Data Grouping
|
||||
|
||||
백엔드 API 변경 없이 프론트에서 처리:
|
||||
|
||||
1. 각 region에 대해 `GET /api/travel/photos?region={id}&page=1&size=1` 호출
|
||||
2. 응답의 `total` 필드로 사진 수 확보, `items[0]`으로 대표 사진 확보
|
||||
3. region_map.json의 albums 목록에서 앨범명 추출
|
||||
4. 기존 10분 TTL 캐시 로직 재활용
|
||||
|
||||
---
|
||||
|
||||
## 3. Album Detail — Masonry Grid + Tabs + Transitions
|
||||
|
||||
### Entry Animation (Shared Element Transition)
|
||||
|
||||
1. 앨범 카드 클릭 시 `getBoundingClientRect()`로 카드 시작 위치 캡처
|
||||
2. 카드 clone을 `position: fixed`로 생성
|
||||
3. clone을 `inset: 0` (풀스크린)으로 animate (400ms, cubic-bezier(0.4, 0, 0.2, 1))
|
||||
4. 애니메이션 완료 → clone 제거, AlbumDetail 오버레이 표시
|
||||
|
||||
### Exit Animation
|
||||
|
||||
1. 뒤로가기/닫기 클릭
|
||||
2. AlbumDetail을 숨기고, 원래 카드 위치로 역재생 (400ms)
|
||||
3. 애니메이션 완료 → 앨범 카드 리스트로 복귀
|
||||
|
||||
### Photo/Video Tabs
|
||||
|
||||
- 앨범 상세 상단에 "사진 | 영상" 탭 바
|
||||
- 기존 `SwipeableView` 컴포넌트 재활용 (모바일 스와이프 전환)
|
||||
- 영상 탭: VideoTab 컴포넌트 (플레이스홀더)
|
||||
|
||||
### Masonry Grid (Photo Tab)
|
||||
|
||||
- **레이아웃**: CSS `column-count` 기반
|
||||
- 데스크톱 (>1024px): 4열
|
||||
- 태블릿 (769px-1024px): 3열
|
||||
- 모바일 (<=768px): 2열
|
||||
- **사진 비율**: 원본 유지 (`width: 100%`, `height: auto`)
|
||||
- **갭**: `column-gap: 8px`, 각 사진 `margin-bottom: 8px`
|
||||
- **break-inside**: `avoid` (사진이 컬럼 경계에 걸리지 않도록)
|
||||
- **무한 스크롤**: IntersectionObserver 센티널, rootMargin 300px, page size 20
|
||||
- **스크롤 리빌**: 뷰포트 진입 시 아래에서 20px 올라오며 fade-in, 사진마다 50ms 지연
|
||||
- **lazy loading**: `loading="lazy"` 속성, 첫 8장은 `loading="eager"`
|
||||
|
||||
### Video Tab (Shell)
|
||||
|
||||
- 중앙 정렬된 비디오 아이콘 + "영상 기능 준비 중" 텍스트
|
||||
- 앰버 톤 텍스트, 세리프 폰트
|
||||
- 백엔드 동영상 API 완성 시 이 컴포넌트 내부만 교체
|
||||
|
||||
---
|
||||
|
||||
## 4. HERO Lightbox
|
||||
|
||||
### Shared Element Transition (Photo → Fullscreen)
|
||||
|
||||
1. Masonry에서 사진 클릭 → `getBoundingClientRect()`로 시작 위치 캡처
|
||||
2. 사진 clone을 `position: fixed`로 생성
|
||||
3. clone을 화면 중앙 + 최대 크기로 animate (350ms, cubic-bezier(0.4, 0, 0.2, 1))
|
||||
4. 애니메이션 완료 → clone 제거, 라이트박스 UI 표시
|
||||
5. 배경은 `#000` opacity 0→1 동시 전환
|
||||
|
||||
### Fullscreen Viewer
|
||||
|
||||
- **배경**: 순수 블랙 `#000`, z-index 3000
|
||||
- **사진**: `max-width: 100%`, `max-height: calc(100vh - 140px)`, `object-fit: contain`
|
||||
- **좌우 탐색**:
|
||||
- 데스크톱: 좌우 화살표 버튼 (hover 시 표시)
|
||||
- 모바일: react-swipeable로 좌우 스와이프
|
||||
- 키보드: ArrowLeft/ArrowRight
|
||||
- **하단 썸네일 스트립**:
|
||||
- 높이 68px, 썸네일 52x52px
|
||||
- 활성 썸네일: 앰버 테두리 (2px solid)
|
||||
- 활성 썸네일 자동 센터링 (smooth scroll)
|
||||
- 필름 퍼포레이션 장식 제거 (간소화)
|
||||
- **메타 정보**: 사진 위 또는 아래에 앨범명 + 파일명 (앰버 텍스트, 14px)
|
||||
- **닫기**:
|
||||
- X 버튼 (우상단)
|
||||
- 아래로 스와이프 (모바일, threshold 100px)
|
||||
- ESC 키
|
||||
- 닫기 시 역재생 transition → 원래 그리드 위치로 복귀
|
||||
|
||||
### Slide Animation (이전/다음)
|
||||
|
||||
- 좌우 전환 시 현재 사진이 나가고 새 사진이 들어오는 slide 애니메이션
|
||||
- 280ms, cubic-bezier(0.25, 0.46, 0.45, 0.94)
|
||||
- 방향에 따라 왼쪽/오른쪽에서 진입
|
||||
|
||||
---
|
||||
|
||||
## 5. Visual Design — Cinematic Travel Aesthetic
|
||||
|
||||
### Color System
|
||||
|
||||
- **베이스 배경**: `#0f0c09` (깊은 다크)
|
||||
- **베이스 텍스트**: `#f5e6c8` (따뜻한 앰버)
|
||||
- **뮤트 텍스트**: `rgba(245,230,200,0.5)`
|
||||
- **라인/테두리**: `rgba(245,230,200,0.08)`
|
||||
- **지역별 악센트**:
|
||||
- 일본: `#c73e1d` (주홍)
|
||||
- 유럽: `#2563eb` (코발트)
|
||||
- 동남아: `#059669` (에메랄드)
|
||||
- 국내: `#d97706` (호박)
|
||||
- 기타: 기본 앰버 `#d4a574`
|
||||
- 악센트 적용: 앨범 카드 호버 글로우, 미니맵 지역 하이라이트, 탭 활성 상태
|
||||
|
||||
### Typography
|
||||
|
||||
- **제목/앨범명**: `Cormorant Garamond`, serif (기존 유지)
|
||||
- **메타 정보/뱃지**: `Space Mono`, monospace (기존 유지)
|
||||
- **앨범 카드 제목**: 데스크톱 24px, 모바일 18px
|
||||
- **사진 수 뱃지**: 11px 모노, `rgba(15,12,9,0.7)` 배경 위 앰버 텍스트
|
||||
|
||||
### Album Card Visual
|
||||
|
||||
- 대표 사진 위 하단 30% 그라디언트: `linear-gradient(transparent, rgba(15,12,9,0.85))`
|
||||
- 그라디언트 위에 앨범명 + 사진 수
|
||||
- `border-radius: 12px`
|
||||
- `border: 1px solid rgba(245,230,200,0.08)`
|
||||
- 호버: `box-shadow: 0 0 20px rgba({accent}, 0.15)` + `transform: scale(1.03)`
|
||||
|
||||
### Masonry Photo Style
|
||||
|
||||
- `border-radius: 4px`
|
||||
- 호버: `filter: brightness(1.08)` + `cursor: zoom-in`
|
||||
- 스크롤 리빌: translateY(20px) + opacity(0) → translateY(0) + opacity(1), 사진마다 50ms 지연
|
||||
|
||||
### Lightbox Visual
|
||||
|
||||
- 배경: `#000`
|
||||
- 메타 텍스트: 앰버 `#f5e6c8`, 세리프 폰트, 14px
|
||||
- 썸네일 스트립: 활성 아이템에 앰버 2px 테두리
|
||||
- 카운터: "3 / 156" 형태, 우상단, 모노스페이스
|
||||
|
||||
---
|
||||
|
||||
## 6. Responsive Design
|
||||
|
||||
### Breakpoints
|
||||
|
||||
| 구간 | 앨범 카드 | Masonry 열 | 미니맵 높이 |
|
||||
|------|----------|-----------|-----------|
|
||||
| >1024px | 3열 | 4열 | 200px |
|
||||
| 769-1024px | 2열 | 3열 | 200px |
|
||||
| <=768px | 1열 | 2열 | 150px |
|
||||
|
||||
### Mobile Specifics
|
||||
|
||||
- 앨범 상세: `position: fixed; inset: 0` (풀스크린 오버레이)
|
||||
- 라이트박스: 100dvh, 화살표 버튼 숨김 (스와이프로 대체)
|
||||
- 미니맵: 기본 접힘 (모바일에서 공간 절약)
|
||||
- 하단 네비게이션 고려: `padding-bottom: calc(var(--bottom-nav-h) + var(--safe-area-bottom))`
|
||||
|
||||
---
|
||||
|
||||
## 7. Reduced Motion
|
||||
|
||||
`prefers-reduced-motion: reduce` 적용 시:
|
||||
|
||||
- shared element transition (앨범 진입/이탈, 라이트박스 열기/닫기) → 즉시 fade (opacity 0→1, 150ms)
|
||||
- 스크롤 리빌 애니메이션 → 즉시 표시 (opacity 1, transform none)
|
||||
- 카드 호버 scale → 없음 (색상 변화만 유지)
|
||||
- 슬라이드 전환 → 즉시 교체 (fade)
|
||||
- 미니맵 접기/펼치기 → 즉시 전환
|
||||
|
||||
---
|
||||
|
||||
## 8. Data Flow
|
||||
|
||||
```
|
||||
useTravelData hook
|
||||
├── fetchRegions() → GET /api/travel/regions
|
||||
├── fetchAlbums(region?) → GET /api/travel/photos?region={id}&page=1&size=1 (per region)
|
||||
├── fetchPhotos(region, page) → GET /api/travel/photos?region={id}&page={n}&size=20
|
||||
└── cache (Map, 10min TTL) → 기존 캐시 로직 재활용
|
||||
|
||||
State:
|
||||
- regions: GeoJSON[]
|
||||
- albums: { id, name, region, coverThumb, totalPhotos }[]
|
||||
- selectedRegion: string | null
|
||||
- selectedAlbum: string | null
|
||||
- photos: Photo[]
|
||||
- page, hasNext, loading, loadingMore
|
||||
```
|
||||
|
||||
### API Contract (기존 유지, 변경 없음)
|
||||
|
||||
```
|
||||
GET /api/travel/regions
|
||||
→ GeoJSON FeatureCollection
|
||||
|
||||
GET /api/travel/photos?region=japan&page=1&size=20
|
||||
→ { region, page, size, total, has_next, items: [{ album, file, url, thumb, mtime }] }
|
||||
|
||||
POST /api/travel/reload
|
||||
→ { status: "ok" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Performance Considerations
|
||||
|
||||
- **앨범 카드 대표 사진**: page=1&size=1로 최소 데이터만 요청
|
||||
- **Masonry 이미지**: 썸네일(480x480) 사용, 라이트박스에서만 원본 로드
|
||||
- **무한 스크롤**: 20개씩 점진적 로드, rootMargin 300px 선제 로드
|
||||
- **lazy loading**: 브라우저 네이티브 `loading="lazy"`
|
||||
- **캐시**: 10분 TTL, 리전 단위
|
||||
- **스크롤 리빌**: IntersectionObserver 단일 인스턴스로 배치 감시
|
||||
- **shared element transition**: `will-change: transform` 적용, 합성 레이어로 GPU 가속
|
||||
@@ -1,203 +0,0 @@
|
||||
# Travel-Proxy 성능 개선 설계
|
||||
|
||||
## 목표
|
||||
|
||||
travel-proxy의 파일 스캔 기반 아키텍처를 SQLite 인덱스 DB로 전환하여 수천 장의 사진을 무난하게 처리하고, 앨범 커버 지정 + 썸네일 사전 생성을 지원한다.
|
||||
|
||||
## 배경
|
||||
|
||||
현재 travel-proxy는 `os.scandir`으로 NAS 폴더를 매번 스캔하고, 메모리 캐시(TTL 300초)로 결과를 보관한다. 사진 수백 장에서는 문제없지만, 수천 장이면:
|
||||
- 캐시 만료 시 1~2초 스캔 지연
|
||||
- 콜드 스타트(컨테이너 재시작) 시 첫 요청 느림
|
||||
- 전체 리스트를 메모리에 상주
|
||||
- 썸네일이 첫 요청 시 동기 생성되어 초기 로딩 지연
|
||||
|
||||
## 아키텍처
|
||||
|
||||
### 변경 전
|
||||
|
||||
```
|
||||
API 요청 → os.scandir(폴더) → 메모리 캐시 → 슬라이싱 페이지네이션
|
||||
↓
|
||||
썸네일 온디맨드 생성 (Pillow)
|
||||
```
|
||||
|
||||
### 변경 후
|
||||
|
||||
```
|
||||
수동 sync 버튼 → 폴더 스캔 → travel.db 동기화 + 썸네일 사전 생성
|
||||
↓
|
||||
API 요청 → SQLite 쿼리 (인덱스) → 페이지네이션
|
||||
```
|
||||
|
||||
### 파일 구조
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `main.py` | FastAPI 라우트 (기존 + 신규) |
|
||||
| `db.py` (신규) | SQLite 스키마 정의, 쿼리 헬퍼 |
|
||||
| `indexer.py` (신규) | 폴더 스캔 → DB 동기화 + 썸네일 생성 |
|
||||
|
||||
기존 `main.py`의 `scan_album`, `ensure_thumb`, 메모리 캐시 로직이 `indexer.py`와 `db.py`로 이동하고, `main.py`는 라우트만 남는다.
|
||||
|
||||
## DB 스키마
|
||||
|
||||
```sql
|
||||
CREATE TABLE photos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
album TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
mtime REAL NOT NULL,
|
||||
has_thumb INTEGER DEFAULT 0,
|
||||
indexed_at TEXT NOT NULL,
|
||||
UNIQUE(album, filename)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_photos_album ON photos(album);
|
||||
|
||||
CREATE TABLE album_covers (
|
||||
album TEXT PRIMARY KEY,
|
||||
filename TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
### 설계 포인트
|
||||
|
||||
- `photos` 테이블에 URL/thumb 경로를 저장하지 않음 — 런타임에 `MEDIA_BASE` + album + filename으로 조합 (환경변수 변경에 유연)
|
||||
- `mtime`으로 변경 감지 — 동기화 시 파일이 삭제됐거나 mtime이 바뀌면 갱신
|
||||
- `album_covers`가 비어있으면 해당 앨범의 첫 번째 사진이 자동 커버
|
||||
|
||||
## API 설계
|
||||
|
||||
### 기존 API 변경
|
||||
|
||||
| 엔드포인트 | 변경 내용 |
|
||||
|-----------|----------|
|
||||
| `GET /api/travel/photos` | 내부 로직만 변경 (os.scandir → DB 쿼리). 응답 형식 동일 |
|
||||
| `GET /api/travel/regions` | 변경 없음 |
|
||||
| `POST /api/travel/reload` | 제거 (sync로 대체) |
|
||||
| `GET /media/travel/.thumb/{album}/{filename}` | 유지 — 동기화 시 이미 썸네일 생성되므로 Pillow 호출 빈도 대폭 감소. 미생성 분 폴백으로 온디맨드 생성 유지 |
|
||||
|
||||
### 신규 API
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| `POST` | `/api/travel/sync` | 폴더 스캔 → DB 동기화 + 썸네일 생성 |
|
||||
| `GET` | `/api/travel/albums` | 앨범 목록 + 사진 수 + 커버 정보 |
|
||||
| `PUT` | `/api/travel/albums/{album}/cover` | 앨범 커버 지정 |
|
||||
|
||||
### POST /api/travel/sync
|
||||
|
||||
폴더를 스캔하여 DB와 동기화하고, 미생성 썸네일을 일괄 생성한다.
|
||||
|
||||
**요청**: 바디 없음
|
||||
|
||||
**응답**:
|
||||
```json
|
||||
{
|
||||
"added": 42,
|
||||
"removed": 3,
|
||||
"thumbs_generated": 42,
|
||||
"duration_sec": 12.5
|
||||
}
|
||||
```
|
||||
|
||||
**동기 실행** — 수동 트리거이므로 BackgroundTask 불필요, 응답에 결과 포함.
|
||||
|
||||
### GET /api/travel/albums
|
||||
|
||||
앨범 목록과 각 앨범의 사진 수, 커버 정보를 반환한다.
|
||||
|
||||
**응답**:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"album": "오사카",
|
||||
"count": 342,
|
||||
"cover_url": "/media/travel/오사카/IMG_3281.jpg",
|
||||
"cover_thumb": "/media/travel/.thumb/오사카/IMG_3281.jpg"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
커버가 지정되지 않은 앨범은 첫 번째 사진(album + filename 정렬 기준)이 자동 커버.
|
||||
|
||||
### PUT /api/travel/albums/{album}/cover
|
||||
|
||||
특정 사진을 앨범 커버로 지정한다.
|
||||
|
||||
**요청**:
|
||||
```json
|
||||
{
|
||||
"filename": "IMG_3281.jpg"
|
||||
}
|
||||
```
|
||||
|
||||
**응답**:
|
||||
```json
|
||||
{
|
||||
"album": "오사카",
|
||||
"filename": "IMG_3281.jpg",
|
||||
"cover_url": "/media/travel/오사카/IMG_3281.jpg",
|
||||
"cover_thumb": "/media/travel/.thumb/오사카/IMG_3281.jpg"
|
||||
}
|
||||
```
|
||||
|
||||
**검증**: 해당 album + filename 조합이 photos 테이블에 존재하는지 확인. 없으면 404.
|
||||
|
||||
## 동기화 로직 (indexer.py)
|
||||
|
||||
### sync 프로세스
|
||||
|
||||
1. `region_map.json`에서 전체 앨범 폴더 목록 수집
|
||||
2. 각 폴더 `os.scandir` → `{album, filename, mtime}` 세트 수집
|
||||
3. DB와 비교:
|
||||
- DB에 없는 파일 → INSERT (`added`)
|
||||
- DB에 있지만 폴더에 없는 파일 → DELETE (`removed`)
|
||||
- mtime이 다른 파일 → UPDATE + `has_thumb=0` (변경됨)
|
||||
4. `has_thumb=0`인 파일 → 썸네일 생성 → `has_thumb=1`로 갱신
|
||||
5. 결과 반환: `{added, removed, thumbs_generated, duration_sec}`
|
||||
|
||||
### 삭제된 커버 처리
|
||||
|
||||
커버로 지정된 사진이 폴더에서 삭제되면 `album_covers`에서도 제거 → 자동으로 첫 번째 사진 폴백.
|
||||
|
||||
### 성능
|
||||
|
||||
- NAS Celeron J4025 기준, 2,000장 최초 동기화 + 썸네일 생성 예상: 3~5분
|
||||
- 이후 동기화는 변경분만 처리 → 수초 이내
|
||||
|
||||
## 앨범 커버 지정 UX
|
||||
|
||||
프론트엔드 앨범 상세 페이지에서 사진을 길게 누르거나 우클릭 → "커버로 설정" 메뉴. `PUT /api/travel/albums/{album}/cover` 호출.
|
||||
|
||||
프론트엔드 변경은 이 스펙 범위 밖 — 백엔드 API만 제공하고, 프론트 연동은 별도 작업.
|
||||
|
||||
## 기존 API 호환성
|
||||
|
||||
- `GET /api/travel/photos` 응답 형식 (`items`, `total`, `has_next`, `matched_albums`) 완전히 유지
|
||||
- 프론트엔드 `useTravelData` 훅은 수정 없이 동작
|
||||
- `GET /api/travel/albums`는 선택적 개선용 — 프론트가 앨범 카드 커버를 표시할 때 활용
|
||||
|
||||
## Docker 변경
|
||||
|
||||
- `travel.db` 저장 위치: 썸네일 볼륨 내 `/data/thumbs/travel.db` (추가 볼륨 불필요)
|
||||
- `requirements.txt`에 `aiosqlite` 추가 불필요 — 동기 sqlite3 표준 라이브러리 사용
|
||||
- Dockerfile 변경 없음
|
||||
|
||||
### docker-compose.yml 변경
|
||||
|
||||
기존 볼륨에 DB를 함께 저장하므로 추가 볼륨 불필요:
|
||||
```yaml
|
||||
volumes:
|
||||
- ${PHOTO_PATH}:/data/travel:ro
|
||||
- ${RUNTIME_PATH}\travel-thumbs:/data/thumbs:rw # travel.db도 여기에 저장
|
||||
```
|
||||
|
||||
## 제거되는 코드
|
||||
|
||||
- `main.py`의 `CACHE`, `CACHE_TTL`, `META_MTIME_CACHE` 딕셔너리 및 관련 로직
|
||||
- `main.py`의 `scan_album()` 함수 (indexer.py로 이동)
|
||||
- `main.py`의 `ensure_thumb()` 함수 (indexer.py로 이동, 온디맨드 폴백은 유지)
|
||||
- `POST /api/travel/reload` 엔드포인트 (sync로 대체)
|
||||
@@ -1,497 +0,0 @@
|
||||
# Agent Office v2 — Pixel Office UX 대규모 업데이트 설계
|
||||
|
||||
> 참고 프로젝트: `pixel-agents` (VS Code 확장, React 19 + Canvas 2D)
|
||||
> 대상: `web-ui/src/pages/agent-office/` (프론트엔드) + `web-backend/agent-office/` (백엔드)
|
||||
|
||||
---
|
||||
|
||||
## 1. 목표
|
||||
|
||||
기존 대시보드 칼럼 중심 UI를 **전체 화면 픽셀 오피스** 중심으로 전환하여, "가상 오피스를 사용한다"는 몰입감을 제공한다.
|
||||
|
||||
### 핵심 변경
|
||||
|
||||
- 캔버스가 메인 화면을 차지하고, 에이전트 클릭 시 사이드 패널로 상세 정보 표시
|
||||
- BFS 경로 탐색 + 풀 배회 시스템으로 에이전트에 생동감 부여
|
||||
- 3가지 오피스 테마 프리셋 (Modern / Retro / Minimal)
|
||||
- 캐릭터 프로시저럴 고도화 + 스프라이트 로더 설계 (점진적 전환)
|
||||
|
||||
### 변경하지 않는 것
|
||||
|
||||
- 백엔드 FSM 5상태 (`idle`, `working`, `waiting`, `reporting`, `break`)
|
||||
- WebSocket 프로토콜 메시지 타입 (init, agent_state, task_complete, agent_move, notification, command_result)
|
||||
- REST API 엔드포인트
|
||||
- 텔레그램 봇 연동
|
||||
|
||||
---
|
||||
|
||||
## 2. 화면 구성
|
||||
|
||||
### 2.1 데스크톱 레이아웃
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┬──────────────┐
|
||||
│ [Agent Office] ● Connected [Theme ▾] [Zoom] │ │
|
||||
├──────────────────────────────────────────────────┤ Side Panel │
|
||||
│ │ 320px │
|
||||
│ │ │
|
||||
│ Pixel Office Canvas │ [Agent hdr] │
|
||||
│ (flex: 1, 전체 높이) │ [Tabs····] │
|
||||
│ │ [Content ] │
|
||||
│ - 에이전트 클릭 → 패널 열림 │ [·········] │
|
||||
│ - 빈 공간 클릭 → 패널 닫힘 │ │
|
||||
│ │ │
|
||||
└──────────────────────────────────────────────────┴──────────────┘
|
||||
```
|
||||
|
||||
- **상단 바**: 타이틀, WebSocket 연결 상태(●), 테마 드롭다운, 줌 컨트롤 (1x~4x)
|
||||
- **캔버스**: `flex: 1`로 남은 공간 전체 차지, `imageSmoothingEnabled = false`
|
||||
- **사이드 패널**: 320px 고정폭, 에이전트 클릭 시 슬라이드 인, X 버튼 또는 빈 공간 클릭으로 닫힘
|
||||
- **패널 닫힘 시**: 캔버스가 전체 너비로 확장
|
||||
|
||||
### 2.2 모바일 레이아웃 (< 768px)
|
||||
|
||||
```
|
||||
┌──────────────────────────┐
|
||||
│ [≡] Agent Office ● Conn │
|
||||
├──────────────────────────┤
|
||||
│ │
|
||||
│ Pixel Office Canvas │
|
||||
│ (전체 화면) │
|
||||
│ 핀치 줌 + 패닝 │
|
||||
│ │
|
||||
│ │
|
||||
├──────────────────────────┤ ← 바텀 시트 (드래그)
|
||||
│ [Agent Header] │
|
||||
│ [Tabs: Cmd|Task|Tok|Log]│
|
||||
│ [Content area] │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
- 캔버스: 전체 화면, 터치 핀치 줌/패닝
|
||||
- 사이드 패널 → 바텀 시트 (에이전트 탭 시 올라옴, 아래로 드래그 시 닫힘)
|
||||
- 상단 바: 햄버거 메뉴로 테마/줌 접기
|
||||
|
||||
---
|
||||
|
||||
## 3. 사이드 패널 구조
|
||||
|
||||
### 3.1 헤더
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ [🎵 32x32] 음악 프로듀서 │
|
||||
│ ● working - ... │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
- 에이전트 아이콘 (emoji 기반, 32x32 색상 배경)
|
||||
- display_name + 현재 상태 + state_detail
|
||||
|
||||
### 3.2 탭 구성
|
||||
|
||||
| 탭 | 내용 |
|
||||
|----|------|
|
||||
| **Commands** (기본) | Quick Action 버튼 (에이전트별 고유), Custom Command 입력, Approval UI (waiting 상태 시) |
|
||||
| **Tasks** | 최근 작업 이력 (상태 배지, 타임스탬프, 결과 펼치기) |
|
||||
| **Tokens** | 일간/주간 토큰 사용량 차트, 캐시 히트율 |
|
||||
| **Logs** | 에이전트 로그 스트림 (level별 색상, 자동 스크롤) |
|
||||
|
||||
### 3.3 에이전트별 Quick Actions
|
||||
|
||||
| 에이전트 | 버튼 |
|
||||
|---------|------|
|
||||
| Stock | Fetch News, Add Alert, Test Telegram |
|
||||
| Music | Compose, Check Credits |
|
||||
| Blog | Research, Add Keyword, List Keywords |
|
||||
| Realestate | Fetch Matches, Dashboard |
|
||||
| Lotto | Curate Now, Status |
|
||||
|
||||
---
|
||||
|
||||
## 4. 캔버스 엔진
|
||||
|
||||
### 4.1 타일맵
|
||||
|
||||
- **그리드**: 32 × 20 타일 (기존 20×14에서 확장)
|
||||
- **타일 크기**: 32px × 32px (기본), 줌에 따라 스케일
|
||||
- **타일 타입**: VOID(0), FLOOR(1), WALL(2), FURNITURE(3)
|
||||
- **렌더링 순서**: 바닥 → 벽 → 가구 → 에이전트 (Y좌표 Z-sorting) → 오버레이
|
||||
|
||||
### 4.2 오피스 레이아웃 (고정)
|
||||
|
||||
```
|
||||
WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW (W=Wall)
|
||||
W..............................W
|
||||
W...[Stock]...[Music]..........W
|
||||
W...desk+mon..desk+inst........W
|
||||
W..............................W
|
||||
W...[Blog]....[RE]....[Lotto]..W
|
||||
W...desk+mon..desk+mon.desk+monW
|
||||
W..............................W
|
||||
W..............................W
|
||||
W..........[Meeting]...........W
|
||||
W..........table 4x2...........W
|
||||
W..............................W
|
||||
W..............................W
|
||||
W....[Coffee]...[Sofa]........W
|
||||
W....machine....couch.........W
|
||||
W..............................W
|
||||
W...[Plants]......[Bookshelf]..W
|
||||
W..............................W
|
||||
W..............................W
|
||||
WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW
|
||||
```
|
||||
|
||||
- 각 에이전트 구역에 테마별 소품 (Stock: 모니터 3대, Music: 악기, Blog: 서류 등)
|
||||
- 중앙: 회의 테이블 (4x2 타일)
|
||||
- 하단: 휴게실 구역 (커피 머신 + 소파)
|
||||
- waypoint 정의: `desk_stock`, `desk_music`, `desk_blog`, `desk_realestate`, `desk_lotto`, `meeting`, `break_room`, `coffee`
|
||||
|
||||
### 4.3 줌 & 패닝
|
||||
|
||||
- 줌 레벨: 1x, 2x, 3x, 4x (정수 배율만, 픽셀 선명도 유지)
|
||||
- 데스크톱: 마우스 휠 줌, 드래그 패닝
|
||||
- 모바일: 핀치 줌, 터치 패닝
|
||||
- 기본값: 캔버스 크기에 맞춰 자동 fit
|
||||
|
||||
### 4.4 게임 루프
|
||||
|
||||
```javascript
|
||||
function gameLoop(timestamp) {
|
||||
const dt = (timestamp - lastTime) / 1000;
|
||||
lastTime = timestamp;
|
||||
|
||||
update(dt); // 에이전트 이동, 애니메이션 프레임 업데이트
|
||||
render(); // 타일맵 → 가구 → 에이전트(Y-sort) → 오버레이
|
||||
|
||||
requestAnimationFrame(gameLoop);
|
||||
}
|
||||
```
|
||||
|
||||
- 60fps requestAnimationFrame
|
||||
- `imageSmoothingEnabled = false` (픽셀 선명도)
|
||||
- devicePixelRatio 반영
|
||||
|
||||
---
|
||||
|
||||
## 5. 에이전트 캐릭터 시스템
|
||||
|
||||
### 5.1 프로시저럴 렌더링 (Phase 1)
|
||||
|
||||
- 해상도: 16 × 32px (기존 8×16에서 2배 확대)
|
||||
- 에이전트별 고유 색상 (기존 유지)
|
||||
- 애니메이션 프레임:
|
||||
|
||||
| 상태 | 프레임 수 | 속도 | 설명 |
|
||||
|------|----------|------|------|
|
||||
| idle | 2 | 0.8s/frame | 미세 움직임 (숨쉬기) |
|
||||
| walk | 4 | 0.15s/frame | 걷기 사이클 [0,1,2,1] |
|
||||
| type | 2 | 0.3s/frame | 타이핑 (팔 움직임) |
|
||||
| wait | 2 | 0.5s/frame | 좌우 흔들림 (wobble) |
|
||||
| break | 2 | 1.0s/frame | 커피 마시기 / 졸기 |
|
||||
|
||||
- 4방향 스프라이트: DOWN, UP, RIGHT, LEFT (LEFT = RIGHT 좌우반전)
|
||||
|
||||
### 5.2 스프라이트 로더 (Phase 2 준비)
|
||||
|
||||
```javascript
|
||||
class SpriteLoader {
|
||||
constructor() {
|
||||
this.sprites = new Map(); // agent_id → spritesheet Image
|
||||
this.fallback = 'procedural';
|
||||
}
|
||||
|
||||
async load(agentId, sheetUrl) { /* PNG 로드 */ }
|
||||
|
||||
draw(ctx, agentId, state, direction, frame, x, y) {
|
||||
if (this.sprites.has(agentId)) {
|
||||
// 스프라이트시트에서 프레임 추출하여 그리기
|
||||
} else {
|
||||
// 프로시저럴 폴백
|
||||
ProceduralSprite.draw(ctx, agentId, state, direction, frame, x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 스프라이트시트 규격: 각 프레임 16×32px, 가로로 프레임 나열
|
||||
- 행: 방향 (DOWN/UP/RIGHT), 열: 상태별 프레임
|
||||
- PNG 없으면 프로시저럴 폴백 → 에셋 제작 전에도 완전 동작
|
||||
|
||||
---
|
||||
|
||||
## 6. 이동 시스템
|
||||
|
||||
### 6.1 BFS 경로 탐색
|
||||
|
||||
```javascript
|
||||
function findPath(grid, start, goal) {
|
||||
// 4방향 BFS (상하좌우, 대각선 없음)
|
||||
// blocked 타일(가구, 벽) 회피
|
||||
// 반환: [{col, row}, ...] 경로 배열
|
||||
}
|
||||
```
|
||||
|
||||
- 가구 footprint → `blocked[]` 배열로 타일 마킹
|
||||
- 의자/책상 뒤 타일은 walkable (backgroundTiles 개념)
|
||||
- 경로 없으면 제자리 유지
|
||||
|
||||
### 6.2 이동 파라미터
|
||||
|
||||
| 파라미터 | 값 | 설명 |
|
||||
|---------|-----|------|
|
||||
| WALK_SPEED | 48 px/sec | pixel-agents 참고 |
|
||||
| moveProgress | 0~1 | 현재 타일 → 다음 타일 선형 보간 |
|
||||
| direction | DOWN/UP/RIGHT/LEFT | 이동 방향 → 스프라이트 방향 결정 |
|
||||
|
||||
### 6.3 배회 로직 (idle 상태)
|
||||
|
||||
```
|
||||
idle 진입
|
||||
→ 3~8초 대기 (seatTimer)
|
||||
→ 자리에서 일어남
|
||||
→ 인접 floor 타일로 랜덤 이동
|
||||
→ 3~6회 반복 (wanderCount)
|
||||
→ 자리로 BFS 복귀
|
||||
→ 2~20초 자리에서 휴식 (restTimer)
|
||||
→ 반복
|
||||
```
|
||||
|
||||
### 6.4 상태 전환 시 이동 시퀀스
|
||||
|
||||
| 전환 | 동작 |
|
||||
|------|------|
|
||||
| `* → working` | 배회 중단, 자기 책상으로 BFS 이동 → 도착 후 type 애니메이션 |
|
||||
| `* → waiting` | 자기 책상에서 wobble 애니메이션 + 말풍선 |
|
||||
| `* → reporting` | 자기 책상에서 빠른 type 애니메이션 |
|
||||
| `idle (배회 중)` | 랜덤 floor 타일로 이동, wanderCount 소진 시 복귀 |
|
||||
| `* → break` | 휴게실(break_room/coffee) waypoint로 BFS 이동 → break 애니메이션 |
|
||||
| `break → idle` | 자기 책상으로 BFS 이동 → idle 루프 시작 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 오버레이 시스템
|
||||
|
||||
캔버스 위에 HTML이 아닌 Canvas 2D로 직접 렌더링.
|
||||
|
||||
### 7.1 항상 표시
|
||||
|
||||
- **이름 라벨**: 에이전트 아래, 에이전트 색상 텍스트, 12px
|
||||
- **상태 배지**: 이름 아래, 배경색 + 텍스트 ("working", "idle", "break")
|
||||
|
||||
### 7.2 조건부 표시
|
||||
|
||||
- **말풍선**: `waiting` 상태에서만, 에이전트 위에 "승인 대기!" 텍스트
|
||||
- 둥근 사각형 배경 (#fbbf24), 아래 삼각형 꼬리
|
||||
- 2초 페이드인, 상태 변경 시 즉시 사라짐
|
||||
- **알림 배지**: 미확인 notification 있을 때, 에이전트 우상단에 빨간 원 + 숫자
|
||||
|
||||
### 7.3 렌더링 순서
|
||||
|
||||
```
|
||||
1. 타일맵 (바닥 + 벽)
|
||||
2. 가구 (Y-sort)
|
||||
3. 에이전트 (Y-sort, 가구와 혼합)
|
||||
4. 오버레이 (말풍선, 이름, 배지) — 항상 최상위
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 테마 시스템
|
||||
|
||||
### 8.1 테마 데이터 구조
|
||||
|
||||
```javascript
|
||||
const THEMES = {
|
||||
modern: {
|
||||
name: 'Modern',
|
||||
wall: { color: '#1a1a2e', border: '#333', accent: '#8b5cf6' },
|
||||
floor: { color1: '#2a2a3e', color2: '#323248' },
|
||||
furniture: { desk: '#3a3a5a', chair: '#4c1d95', monitor: '#5555aa', shelf: '#2a2a4e' },
|
||||
decor: { plant: '#22c55e', pot: '#4a3a2a', lamp: '#fbbf24', ledStrip: '#8b5cf6' },
|
||||
lighting: { ambient: 'rgba(139,92,246,0.05)', glow: 'rgba(139,92,246,0.15)' }
|
||||
},
|
||||
retro: {
|
||||
name: 'Retro',
|
||||
wall: { color: '#2a1a0a', border: '#6a4a2a', accent: '#cc8844' },
|
||||
floor: { color1: '#4a3a1a', color2: '#3a2a10' },
|
||||
furniture: { desk: '#6a4a1a', chair: '#8a5a2a', monitor: '#555555', shelf: '#5a3a1a' },
|
||||
decor: { plant: '#44aa44', pot: '#6a4a2a', lamp: '#ffcc66', brick: '#8a5a2a' },
|
||||
lighting: { ambient: 'rgba(255,200,100,0.05)', glow: 'rgba(255,200,100,0.2)' }
|
||||
},
|
||||
minimal: {
|
||||
name: 'Minimal',
|
||||
wall: { color: '#fafafa', border: '#ddd', accent: '#3b82f6' },
|
||||
floor: { color1: '#e8e8e8', color2: '#f0f0f0' },
|
||||
furniture: { desk: '#ffffff', chair: '#e0e0e0', monitor: '#333333', shelf: '#f5f5f5' },
|
||||
decor: { plant: '#86efac', pot: '#ffffff', lamp: '#fbbf24', window: '#e0f0ff' },
|
||||
lighting: { ambient: 'rgba(59,130,246,0.03)', glow: 'rgba(255,255,255,0.1)' }
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 8.2 테마 적용 방식
|
||||
|
||||
- `TileMap.render(theme)` — 바닥/벽 색상을 theme에서 읽어 렌더링
|
||||
- `FurnitureRenderer.draw(type, theme)` — 가구별 프로시저럴 렌더링에 theme 팔레트 적용
|
||||
- 테마 전환 시 전체 캔버스 리렌더 (레이아웃 변경 없음)
|
||||
- 사용자 선택은 `localStorage`에 저장, 기본값: `modern`
|
||||
|
||||
### 8.3 테마별 고유 데코
|
||||
|
||||
| 테마 | 고유 요소 |
|
||||
|------|----------|
|
||||
| Modern | LED 스트립 (벽 하단), 네온 글로우, 미니멀 화분 |
|
||||
| Retro | 벽돌 텍스처, CRT 모니터, 책장(컬러풀 책), 탁상 램프 |
|
||||
| Minimal | 창문(자연광), 다육이, 깔끔한 화이트 선반 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 히트 테스팅 & 인터랙션
|
||||
|
||||
### 9.1 클릭 처리
|
||||
|
||||
```javascript
|
||||
canvas.onclick = (e) => {
|
||||
const {col, row} = screenToTile(e.offsetX, e.offsetY, zoom, pan);
|
||||
|
||||
// 1. 에이전트 히트 테스트 (역순, 최상위 우선)
|
||||
const agent = agents.findLast(a =>
|
||||
Math.abs(a.x - col) < 1 && Math.abs(a.y - row) < 1.5
|
||||
);
|
||||
|
||||
if (agent) {
|
||||
openSidePanel(agent.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 빈 공간 → 패널 닫기
|
||||
closeSidePanel();
|
||||
};
|
||||
```
|
||||
|
||||
### 9.2 호버 (데스크톱만)
|
||||
|
||||
- 에이전트 위 호버 시 커서 `pointer`로 변경
|
||||
- 툴팁 불필요 (이름+배지가 항상 표시되므로)
|
||||
|
||||
---
|
||||
|
||||
## 10. WebSocket 연동
|
||||
|
||||
기존 프로토콜 100% 유지. 프론트엔드에서 메시지 수신 시 캔버스 상태만 추가 업데이트.
|
||||
|
||||
| 메시지 타입 | 캔버스 반응 |
|
||||
|------------|-----------|
|
||||
| `agent_state` | 해당 에이전트 FSM 상태 전환 → 애니메이션/위치 변경 트리거 |
|
||||
| `agent_move` | target에 따라 BFS 경로 계산 → 이동 시작 |
|
||||
| `task_complete` | 에이전트 상태를 idle로 전환 |
|
||||
| `notification` | 에이전트 위 알림 배지 카운트 증가 |
|
||||
| `init` | 모든 에이전트 초기 위치/상태 설정 |
|
||||
|
||||
### agent_state 수신 시 이동 로직
|
||||
|
||||
```javascript
|
||||
function onAgentState(agentId, newState) {
|
||||
const agent = agents.get(agentId);
|
||||
|
||||
switch (newState) {
|
||||
case 'working':
|
||||
case 'waiting':
|
||||
case 'reporting':
|
||||
// 자리에 있지 않으면 자리로 이동
|
||||
if (!agent.isAtDesk()) agent.moveTo(agent.deskWaypoint);
|
||||
break;
|
||||
case 'break':
|
||||
agent.moveTo('break_room');
|
||||
break;
|
||||
case 'idle':
|
||||
// 배회 루프 시작
|
||||
agent.startWandering();
|
||||
break;
|
||||
}
|
||||
|
||||
agent.setState(newState);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 파일 구조 (프론트엔드)
|
||||
|
||||
```
|
||||
src/pages/agent-office/
|
||||
├── AgentOffice.jsx # 루트 컴포넌트 (재작성)
|
||||
├── AgentOffice.css # 스타일 (재작성)
|
||||
├── hooks/
|
||||
│ ├── useAgentManager.js # WebSocket + 상태 (기존 확장)
|
||||
│ └── useOfficeCanvas.js # 캔버스 셋업 (재작성)
|
||||
├── components/
|
||||
│ ├── TopBar.jsx # 상단 바 (신규)
|
||||
│ ├── SidePanel.jsx # 사이드 패널 컨테이너 (신규)
|
||||
│ ├── CommandTab.jsx # Commands 탭 (AgentColumn 리팩토링)
|
||||
│ ├── TaskTab.jsx # Tasks 탭 (AgentColumn에서 분리)
|
||||
│ ├── TokenTab.jsx # Tokens 탭 (신규)
|
||||
│ ├── LogTab.jsx # Logs 탭 (신규)
|
||||
│ ├── ApprovalCard.jsx # 승인 UI 카드 (신규)
|
||||
│ └── MobileBottomSheet.jsx # 모바일 바텀 시트 (신규)
|
||||
├── canvas/
|
||||
│ ├── OfficeRenderer.js # 게임 루프 + 렌더 파이프라인 (재작성)
|
||||
│ ├── TileMap.js # 타일맵 렌더링 + 테마 적용 (재작성)
|
||||
│ ├── FurnitureRenderer.js # 가구 프로시저럴 렌더링 (신규)
|
||||
│ ├── AgentSprite.js # 에이전트 이동 + 애니메이션 (재작성)
|
||||
│ ├── ProceduralSprite.js # 프로시저럴 캐릭터 렌더링 (SpriteSheet 리팩토링)
|
||||
│ ├── SpriteLoader.js # 스프라이트시트 로더 + 폴백 (신규)
|
||||
│ ├── Pathfinder.js # BFS 경로 탐색 (신규)
|
||||
│ ├── OverlayRenderer.js # 이름, 배지, 말풍선 (신규)
|
||||
│ └── themes.js # 테마 데이터 (신규)
|
||||
├── assets/
|
||||
│ ├── office-map.json # 32x20 맵 데이터 (재작성)
|
||||
│ └── sprites/ # Phase 2 스프라이트시트 PNG (빈 디렉토리)
|
||||
```
|
||||
|
||||
### 삭제 대상
|
||||
|
||||
- `components/AgentColumn.jsx` → CommandTab + TaskTab으로 분리
|
||||
- `components/CommandColumn.jsx` → SidePanel 내 CommandTab으로 통합
|
||||
- `components/ChatPanel.jsx` → 미사용, 삭제
|
||||
- `components/DocumentPanel.jsx` → LogTab으로 대체
|
||||
- `canvas/SpriteSheet.js` → ProceduralSprite.js로 리팩토링
|
||||
|
||||
---
|
||||
|
||||
## 12. 백엔드 변경사항
|
||||
|
||||
**없음.** 기존 WebSocket 프로토콜과 REST API를 그대로 사용한다.
|
||||
|
||||
단, `agent_move` 메시지가 break 전환 시에도 정확히 발송되는지 확인 필요:
|
||||
- `base.py`의 `check_idle_break()` → `transition('break')` → WebSocket broadcast에 `agent_move` 포함 여부 확인
|
||||
- 필요 시 `transition()` 메서드에서 break 상태 전환 시 `agent_move` 메시지 추가
|
||||
|
||||
---
|
||||
|
||||
## 13. 구현 순서 (Phase 개요)
|
||||
|
||||
| Phase | 내용 | 의존성 |
|
||||
|-------|------|--------|
|
||||
| **1. 캔버스 엔진** | 게임 루프, 타일맵, 줌/팬, 테마 시스템 | 없음 |
|
||||
| **2. 에이전트 시스템** | 프로시저럴 캐릭터, BFS 경로 탐색, 상태별 애니메이션, 배회 로직 | Phase 1 |
|
||||
| **3. 오버레이** | 이름 라벨, 상태 배지, 말풍선, 알림 배지 | Phase 2 |
|
||||
| **4. 사이드 패널** | 4탭 구성, Quick Actions, Approval UI | Phase 1 |
|
||||
| **5. 페이지 통합** | AgentOffice.jsx 재작성, WebSocket 연동, 히트 테스팅 | Phase 1-4 |
|
||||
| **6. 모바일 대응** | 바텀 시트, 핀치 줌, 터치 이벤트, 반응형 | Phase 5 |
|
||||
| **7. 스프라이트 로더** | SpriteLoader 구현, 폴백 연결 | Phase 2 |
|
||||
|
||||
---
|
||||
|
||||
## 14. 성공 기준
|
||||
|
||||
- [ ] 전체 화면 캔버스에서 5명의 에이전트가 상태에 맞게 애니메이션
|
||||
- [ ] idle 에이전트가 사무실을 배회하다 자리로 복귀
|
||||
- [ ] break 에이전트가 휴게실로 이동하여 휴식
|
||||
- [ ] 에이전트 클릭 시 사이드 패널 열림, 4탭 모두 동작
|
||||
- [ ] Commands 탭에서 명령 전송 + 승인/거부 동작
|
||||
- [ ] 3가지 테마 전환 동작, localStorage에 저장
|
||||
- [ ] 모바일에서 바텀 시트 + 핀치 줌 동작
|
||||
- [ ] 기존 WebSocket 프로토콜과 100% 호환
|
||||
@@ -1,220 +0,0 @@
|
||||
# Personal 서비스 마이그레이션 설계
|
||||
|
||||
## 개요
|
||||
|
||||
기존 `portfolio` 서비스를 `personal`로 리네이밍하고, lotto-backend에 있던 Blog/Todo 기능을 personal 서비스로 통합한다.
|
||||
|
||||
**목표**: 신규 컨테이너 없이, 개인 콘텐츠(포트폴리오 + 블로그 + 투두)를 하나의 서비스로 통합
|
||||
|
||||
**제약**: 기존 데이터 무손실 이전 필수
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처
|
||||
|
||||
### 변경 전
|
||||
|
||||
```
|
||||
lotto-backend (lotto.db)
|
||||
├── 로또 API (/api/lotto/*)
|
||||
├── 블로그 API (/api/blog/posts) ← 이전 대상
|
||||
└── 투두 API (/api/todos) ← 이전 대상
|
||||
|
||||
portfolio (portfolio.db)
|
||||
└── 포트폴리오 API (/api/profile/*)
|
||||
```
|
||||
|
||||
### 변경 후
|
||||
|
||||
```
|
||||
lotto-backend (lotto.db)
|
||||
└── 로또 API (/api/lotto/*) ← Blog/Todo 라우트 제거
|
||||
|
||||
personal (personal.db)
|
||||
├── 포트폴리오 API (/api/profile/*)
|
||||
├── 블로그 API (/api/blog/posts) ← 통합
|
||||
└── 투두 API (/api/todos) ← 통합
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 서비스 속성
|
||||
|
||||
| 항목 | 현재 (portfolio) | 변경 후 (personal) |
|
||||
|------|-----------------|-------------------|
|
||||
| 디렉토리 | `portfolio/` | `personal/` |
|
||||
| 컨테이너명 | `portfolio` | `personal` |
|
||||
| 포트 | 18850 | 18850 (유지) |
|
||||
| DB 파일 | `data/portfolio/portfolio.db` | `data/personal/personal.db` |
|
||||
| API prefix | `/api/profile/` | `/api/profile/` + `/api/todos` + `/api/blog/` |
|
||||
|
||||
---
|
||||
|
||||
## DB 스키마
|
||||
|
||||
personal.db에 기존 5테이블 + 신규 2테이블:
|
||||
|
||||
### 기존 테이블 (portfolio에서 이관)
|
||||
- `profile` — 프로필 (id=1 싱글턴)
|
||||
- `careers` — 경력
|
||||
- `projects` — 프로젝트
|
||||
- `skills` — 기술스택
|
||||
- `introductions` — 자기소개
|
||||
|
||||
### 신규 추가 테이블 (lotto-backend에서 이관)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS todos (
|
||||
id TEXT PRIMARY KEY
|
||||
DEFAULT (lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2)))),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'todo'
|
||||
CHECK(status IN ('todo','in_progress','done')),
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_todos_created ON todos(created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS blog_posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
excerpt TEXT NOT NULL DEFAULT '',
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
date TEXT NOT NULL DEFAULT (date('now','localtime')),
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_blog_date ON blog_posts(date DESC);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 엔드포인트 (personal 서비스 전체)
|
||||
|
||||
### 포트폴리오 (기존 유지)
|
||||
| 메서드 | 경로 | 인증 | 설명 |
|
||||
|--------|------|------|------|
|
||||
| GET | `/api/profile/public` | - | 공개 데이터 일괄 조회 |
|
||||
| POST | `/api/profile/auth` | - | 비밀번호 인증 → 토큰 |
|
||||
| GET/PUT | `/api/profile/profile` | Bearer | 프로필 조회/수정 |
|
||||
| GET/POST | `/api/profile/careers` | Bearer | 경력 목록/추가 |
|
||||
| PUT/DELETE | `/api/profile/careers/{id}` | Bearer | 경력 수정/삭제 |
|
||||
| GET/POST | `/api/profile/projects` | Bearer | 프로젝트 목록/추가 |
|
||||
| PUT/DELETE | `/api/profile/projects/{id}` | Bearer | 프로젝트 수정/삭제 |
|
||||
| GET/POST | `/api/profile/skills` | Bearer | 기술 목록/추가 |
|
||||
| PUT/DELETE | `/api/profile/skills/{id}` | Bearer | 기술 수정/삭제 |
|
||||
| GET/POST | `/api/profile/introductions` | Bearer | 자기소개 목록/추가 |
|
||||
| PUT/DELETE | `/api/profile/introductions/{id}` | Bearer | 자기소개 수정/삭제 |
|
||||
| PATCH | `/api/profile/introductions/{id}/main` | Bearer | 메인 자기소개 지정 |
|
||||
|
||||
### 투두 (lotto-backend에서 이전, 인증 없음)
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/todos` | 전체 목록 |
|
||||
| POST | `/api/todos` | 생성 |
|
||||
| DELETE | `/api/todos/done` | 완료 일괄 삭제 |
|
||||
| PUT | `/api/todos/{id}` | 수정 |
|
||||
| DELETE | `/api/todos/{id}` | 삭제 |
|
||||
|
||||
### 블로그 (lotto-backend에서 이전, 인증 없음)
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/blog/posts` | 목록 (`{"posts": [...]}`) |
|
||||
| POST | `/api/blog/posts` | 생성 |
|
||||
| PUT | `/api/blog/posts/{id}` | 수정 |
|
||||
| DELETE | `/api/blog/posts/{id}` | 삭제 |
|
||||
|
||||
---
|
||||
|
||||
## Nginx 라우팅 변경
|
||||
|
||||
```nginx
|
||||
# 추가: /api/todos → personal
|
||||
location /api/todos {
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
set $personal_backend personal:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://$personal_backend$request_uri;
|
||||
}
|
||||
|
||||
# 추가: /api/blog/ → personal
|
||||
location /api/blog/ {
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
set $personal_backend personal:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://$personal_backend$request_uri;
|
||||
}
|
||||
|
||||
# 변경: portfolio → personal
|
||||
location /api/profile/ {
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
set $personal_backend personal:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://$personal_backend$request_uri;
|
||||
}
|
||||
```
|
||||
|
||||
기존 `/api/` catch-all은 lotto-backend로 유지 (todos/blog 요청은 위의 더 구체적인 location에서 먼저 매칭).
|
||||
|
||||
---
|
||||
|
||||
## 인프라 변경
|
||||
|
||||
### docker-compose.yml
|
||||
- `portfolio` 서비스 → `personal`로 리네이밍
|
||||
- 볼륨: `${RUNTIME_PATH}/data/personal:/app/data`
|
||||
- 환경변수 동일 (PORTFOLIO_EDIT_PASSWORD 등)
|
||||
|
||||
### deploy.sh / deploy-nas.sh
|
||||
- SERVICES, BUILD_TARGETS, CONTAINER_NAMES 등에서 `portfolio` → `personal` 변경
|
||||
- DATA_DIRS에서 `portfolio` → `personal` 변경
|
||||
|
||||
### lotto-backend 정리
|
||||
- `main.py`에서 Blog/Todo 라우트 + Pydantic 모델 제거 (약 100줄)
|
||||
- `db.py`에서 Blog/Todo CRUD 함수 제거 (약 130줄)
|
||||
- `db.py`의 `init_db()`에서 todos/blog_posts 테이블 생성 코드는 유지 (기존 DB 호환)
|
||||
|
||||
---
|
||||
|
||||
## 배포 순서 (안전 우선)
|
||||
|
||||
1. **코드 개발** — personal 서비스 + lotto-backend 정리 + 인프라 변경
|
||||
2. **git push** — 자동 배포 트리거
|
||||
3. **NAS에서 데이터 디렉토리 준비** — `mkdir -p data/personal`
|
||||
4. **기존 portfolio.db 이동** — `cp data/portfolio/portfolio.db data/personal/personal.db`
|
||||
5. **lotto.db에서 Blog/Todo 데이터 복사**:
|
||||
```bash
|
||||
sqlite3 data/lotto.db ".dump todos" | sqlite3 data/personal/personal.db
|
||||
sqlite3 data/lotto.db ".dump blog_posts" | sqlite3 data/personal/personal.db
|
||||
```
|
||||
6. **컨테이너 재시작** — `docker compose restart personal`
|
||||
7. **검증** — API 호출로 데이터 건수 대조
|
||||
8. **lotto.db 원본 테이블** — 삭제하지 않고 당분간 유지
|
||||
|
||||
---
|
||||
|
||||
## 프론트엔드
|
||||
|
||||
변경 없음. 모든 API 호출이 상대경로(`/api/todos`, `/api/blog/posts`, `/api/profile/`)이므로 nginx 라우팅 변경만으로 자동 적용.
|
||||
|
||||
---
|
||||
|
||||
## 리스크
|
||||
|
||||
- **낮음**: Blog/Todo는 lotto 테이블과 FK/공유 쿼리 없음
|
||||
- **롤백**: lotto.db 원본 테이블 유지 + nginx 라우팅 원복으로 즉시 롤백 가능
|
||||
- **다운타임**: nginx reload 순간 (~1초)
|
||||
@@ -1,355 +0,0 @@
|
||||
# Portfolio Service Design Spec
|
||||
|
||||
> 개인 포트폴리오 정식 서비<EC849C><EBB984>. 취업/이직용 이력서 + 개인 브랜딩 쇼케이스 겸용.
|
||||
|
||||
---
|
||||
|
||||
## 1. 서비스 개요
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|-----|
|
||||
| 서비스명 | portfolio |
|
||||
| 경로 | `web-backend/portfolio/` |
|
||||
| 컨테이너 | `portfolio` |
|
||||
| 내부 포트 | 8000 |
|
||||
| 외부 포트 | 18850 |
|
||||
| DB | `/app/data/portfolio.db` (SQLite) |
|
||||
| Nginx 프록시 | `/api/portfolio/` → `portfolio:8000` |
|
||||
| 프레임워크 | FastAPI (Python 3.12) |
|
||||
| 프론트 경로 | `/portfolio` |
|
||||
|
||||
### 목적
|
||||
|
||||
- 프로필, 경력, 프로젝트, 기술스택을 웹에서 관리하고 공개 전시
|
||||
- 자기소개 글을 다중 버전으로 관리 (메인 1개 지정, 클립보드 복사)
|
||||
- 이력서 PDF 내보내기
|
||||
- 홈 페이지에 요약 카드로 연동
|
||||
|
||||
---
|
||||
|
||||
## 2. DB 스키마
|
||||
|
||||
### `profile` (1행, upsert)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | INTEGER PK | 항상 1 |
|
||||
| name | TEXT | 이름 (한글) |
|
||||
| name_en | TEXT | 이름 (영문) |
|
||||
| role | TEXT | 직함 (한글) |
|
||||
| role_en | TEXT | 직함 (영문) |
|
||||
| email | TEXT | 이메일 |
|
||||
| phone | TEXT | 전화번호 |
|
||||
| github_url | TEXT | GitHub URL |
|
||||
| blog_url | TEXT | 블로그 URL |
|
||||
| photo_url | TEXT | 프로필 사진 URL |
|
||||
| bio | TEXT | 간단 소개 (3줄 정도) |
|
||||
| updated_at | TEXT | ISO8601 |
|
||||
|
||||
### `careers` (경력 이력)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | INTEGER PK AUTOINCREMENT | |
|
||||
| category | TEXT | `company` \| `education` \| `etc` |
|
||||
| organization | TEXT | 회사/기관명 |
|
||||
| role | TEXT | 직함/전공 |
|
||||
| description | TEXT | 설명 |
|
||||
| start_date | TEXT | YYYY-MM |
|
||||
| end_date | TEXT | YYYY-MM 또는 빈 문자열(현재) |
|
||||
| sort_order | INTEGER | 정렬 순서 (낮을수록 위) |
|
||||
| created_at | TEXT | ISO8601 |
|
||||
| updated_at | TEXT | ISO8601 |
|
||||
|
||||
### `projects` (프로젝트)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | INTEGER PK AUTOINCREMENT | |
|
||||
| category | TEXT | `company` \| `personal` \| `academy` |
|
||||
| title | TEXT | 프로젝트명 |
|
||||
| description | TEXT | 설명 |
|
||||
| tech_stack | TEXT | JSON 배열 `["Python", "FastAPI", ...]` |
|
||||
| role | TEXT | 담당 역할 |
|
||||
| start_date | TEXT | YYYY-MM |
|
||||
| end_date | TEXT | YYYY-MM 또는 빈 문자열 |
|
||||
| url | TEXT | 프로젝트 URL (선택) |
|
||||
| image_url | TEXT | 대표 이미지 URL (선택) |
|
||||
| sort_order | INTEGER | 정렬 순서 |
|
||||
| created_at | TEXT | ISO8601 |
|
||||
| updated_at | TEXT | ISO8601 |
|
||||
|
||||
### `skills` (기술 스택)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | INTEGER PK AUTOINCREMENT | |
|
||||
| category | TEXT | `language` \| `framework` \| `infra` \| `tool` |
|
||||
| name | TEXT | 기술명 |
|
||||
| level | INTEGER | 숙련도 1~5 |
|
||||
| sort_order | INTEGER | 정렬 순서 |
|
||||
|
||||
### `introductions` (자기소개 글)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | INTEGER PK AUTOINCREMENT | |
|
||||
| title | TEXT | 버전명 (예: "이직용 짧은 버전") |
|
||||
| content | TEXT | 본문 |
|
||||
| is_main | INTEGER | 0 \| 1 (메인 자기소개 지정, 항상 1개만 1) |
|
||||
| created_at | TEXT | ISO8601 |
|
||||
| updated_at | TEXT | ISO8601 |
|
||||
|
||||
---
|
||||
|
||||
## 3. API 설계
|
||||
|
||||
### 공개 API (인증 불필요)
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/portfolio/public` | 전체 공개 데이터 일괄 조회 (profile + careers + projects + skills + 메인 자기소개) |
|
||||
|
||||
응답 형태:
|
||||
```json
|
||||
{
|
||||
"profile": { ... },
|
||||
"careers": [ ... ],
|
||||
"projects": [ ... ],
|
||||
"skills": [ ... ],
|
||||
"main_introduction": { "id": 1, "title": "...", "content": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
### 인증 API
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| POST | `/api/portfolio/auth` | 비밀번호 검증 → 세션 토큰 반환 |
|
||||
|
||||
- 요청: `{ "password": "..." }`
|
||||
- 응답: `{ "token": "uuid-string", "expires_in": 86400 }`
|
||||
- 환경변수: `PORTFOLIO_EDIT_PASSWORD`
|
||||
- 토큰: UUID, 서버 메모리 딕셔너리 저장, 24시간 TTL
|
||||
- 실패: 401
|
||||
|
||||
### 편집 API (Authorization: Bearer {token} 필요)
|
||||
|
||||
**Profile:**
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/portfolio/profile` | 프로필 조회 |
|
||||
| PUT | `/api/portfolio/profile` | 프로필 수정 (upsert) |
|
||||
|
||||
**Careers:**
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/portfolio/careers` | 경력 목록 |
|
||||
| POST | `/api/portfolio/careers` | 경력 추가 |
|
||||
| PUT | `/api/portfolio/careers/{id}` | 경력 수정 |
|
||||
| DELETE | `/api/portfolio/careers/{id}` | 경력 삭제 |
|
||||
|
||||
**Projects:**
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/portfolio/projects` | 프로젝트 목록 |
|
||||
| POST | `/api/portfolio/projects` | 프로젝트 추가 |
|
||||
| PUT | `/api/portfolio/projects/{id}` | 프로젝트 수정 |
|
||||
| DELETE | `/api/portfolio/projects/{id}` | 프로젝트 삭제 |
|
||||
|
||||
**Skills:**
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/portfolio/skills` | 기술 목록 |
|
||||
| POST | `/api/portfolio/skills` | 기술 추가 |
|
||||
| PUT | `/api/portfolio/skills/{id}` | 기술 수정 |
|
||||
| DELETE | `/api/portfolio/skills/{id}` | 기술 삭제 |
|
||||
|
||||
**Introductions:**
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/portfolio/introductions` | 자기소개 전체 목록 |
|
||||
| POST | `/api/portfolio/introductions` | 자기소개 추가 |
|
||||
| PUT | `/api/portfolio/introductions/{id}` | 자기소개 수정 |
|
||||
| DELETE | `/api/portfolio/introductions/{id}` | 자기소개 삭제 |
|
||||
| PATCH | `/api/portfolio/introductions/{id}/main` | 메인 자기소개 지정 (기존 is_main=1 → 0 리셋) |
|
||||
|
||||
---
|
||||
|
||||
## 4. 인증 흐름
|
||||
|
||||
```
|
||||
편집 버튼 클릭
|
||||
→ 토큰 없음 → 비밀번호 모달 표시
|
||||
→ POST /api/portfolio/auth { password }
|
||||
→ 성공: 토큰을 React state에 저장 (새로고침 시 재인증)
|
||||
→ 이후 편집 API 호출에 Authorization: Bearer {token} 포함
|
||||
→ 토큰 만료/불일치 시 401 → 재인증 모달
|
||||
```
|
||||
|
||||
서버 측:
|
||||
- `_auth_tokens: dict[str, float]` 메모리 딕셔너리 (token → expiry timestamp)
|
||||
- FastAPI Depends로 토큰 검증 미들웨어
|
||||
- 서버 재시작 시 토큰 소멸 (재인증 필요, 보안상 적절)
|
||||
|
||||
---
|
||||
|
||||
## 5. 프론트엔드 구조
|
||||
|
||||
### 라우팅
|
||||
|
||||
`routes.jsx`에 추가:
|
||||
- navLink: `{ id: 'portfolio', label: 'Portfolio', path: '/portfolio', subtitle: 'RESUME', accent: '#06b6d4' }`
|
||||
- appRoute: `{ path: 'portfolio', element: <Portfolio /> }`
|
||||
|
||||
### 파일 구조
|
||||
|
||||
```
|
||||
src/pages/portfolio/
|
||||
Portfolio.jsx — 메인 페이지 (3탭 컨테이너)
|
||||
Portfolio.css — 스타일
|
||||
ProfileTab.jsx — 탭 1: 프로필 & 이력 & 기술스택
|
||||
ProjectTab.jsx — 탭 2: 프로젝트
|
||||
IntroTab.jsx — 탭 3: 자기소개 관리
|
||||
usePortfolio.js — API 호출 + 인증 상태 관리 훅
|
||||
PasswordModal.jsx — 비밀번호 입력 모달
|
||||
ResumeView.jsx — PDF 출력 전용 레이아웃 (print CSS)
|
||||
```
|
||||
|
||||
### 탭 1: 프로필 & 이력
|
||||
|
||||
**보기 모드:**
|
||||
- 프로필 카드 (사진, 이름, 역할, 바이오, 연락처 아이콘 링크)
|
||||
- 경력 타임라인 (category별 그룹: 회사 → 교육 → 기타, sort_order 순)
|
||||
- 기술 스택 (category별 그룹, level 바 표시)
|
||||
- "이력서 PDF 내보내기" 버튼
|
||||
|
||||
**편집 모드:**
|
||||
- 프로필: 인라인 편집 (input/textarea)
|
||||
- 경력: 추가/편집/삭제/순서 변경
|
||||
- 기술: 추가/편집/삭제/순서 변경
|
||||
|
||||
### 탭 2: 프로젝트
|
||||
|
||||
**보기 모드:**
|
||||
- 카테고리 필터 버튼 (전체 / 회사 / 개인 / 아카데미)
|
||||
- 프로젝트 카드 그리드: 제목, 설명(2줄 clamp), 기술스택 태그, 기간, 링크 아이콘
|
||||
|
||||
**편집 모드:**
|
||||
- 프로젝트 추가/편집/삭제 폼
|
||||
- tech_stack: 태그 입력 UI (쉼표 또는 엔터로 추가)
|
||||
|
||||
### 탭 3: 자기소개 관리
|
||||
|
||||
- 자기소개 글 리스트 (메인 표시: 별 배지)
|
||||
- 각 항목: 제목, 미리보기(3줄), 수정일
|
||||
- 액션 버튼: 복사(클립보드) / 편집 / 메인 지정 / 삭제
|
||||
- 상단: "새 글 작성" 버튼 → 인라인 폼 또는 MobileSheet
|
||||
- 복사 버튼: `navigator.clipboard.writeText()` → "복사됨!" 피드백 1.5초
|
||||
|
||||
### 편집 모드 진입
|
||||
|
||||
- 각 탭 우상단 "편집" 토글 버튼
|
||||
- 첫 클릭 시 PasswordModal 표시 → 인증 성공 → 편집 UI 노출
|
||||
- 인증 토큰은 usePortfolio 훅에서 관리 (React state, 새로고침 시 소멸)
|
||||
|
||||
---
|
||||
|
||||
## 6. 홈 페이지 연동
|
||||
|
||||
### 변경 내용
|
||||
|
||||
현재 Home.jsx Profile 섹션(하드코딩)을 요약 카드로 교체:
|
||||
|
||||
- `GET /api/portfolio/public` fetch
|
||||
- 성공 시: 이름, 역할, 바이오, 기술태그 상위 8개, 대표 프로젝트 3개 카드
|
||||
- "포트폴리오 보기 →" 링크 버튼
|
||||
- 실패 시: 기존 하드코딩 프로필 폴백 (서비스 미가동 대응)
|
||||
|
||||
---
|
||||
|
||||
## 7. PDF 내보내기
|
||||
|
||||
### 방식
|
||||
|
||||
`window.print()` + `@media print` 전용 CSS
|
||||
|
||||
- ResumeView.jsx: 이력서 레이아웃 전용 컴포넌트
|
||||
- "PDF 내보내기" 버튼 → ResumeView를 화면에 렌더링 → `window.print()` → 숨김
|
||||
- 프린트 CSS: 네비/탭/편집버튼 숨기고, A4 1~2페이지 레이아웃 렌더링
|
||||
|
||||
### 이력서 레이아웃 (A4)
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ [사진] 박재오 │
|
||||
│ Server Developer │
|
||||
│ email | github │
|
||||
├──────────────────────────────┤
|
||||
│ ABOUT │
|
||||
│ (메인 자기소개 또는 bio) │
|
||||
├──────────────────────────────┤
|
||||
│ EXPERIENCE │
|
||||
│ - 현대오토에버 (2023~현재) │
|
||||
│ - 롯데정보통신 (2020~2023) │
|
||||
│ - SSAFY 1기 (2019) │
|
||||
├──────────────────────────────┤
|
||||
│ PROJECTS │
|
||||
│ - 프로젝트 카드 목록 │
|
||||
├──────────────────────────────┤
|
||||
│ SKILLS │
|
||||
│ [태그 나열] │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Docker / Nginx 변경
|
||||
|
||||
### docker-compose.yml 추가
|
||||
|
||||
```yaml
|
||||
portfolio:
|
||||
build: ./portfolio
|
||||
container_name: portfolio
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ${RUNTIME_PATH:-.}/data:/app/data
|
||||
environment:
|
||||
- PORTFOLIO_EDIT_PASSWORD=${PORTFOLIO_EDIT_PASSWORD}
|
||||
ports:
|
||||
- "18850:8000"
|
||||
```
|
||||
|
||||
### Nginx 추가
|
||||
|
||||
```nginx
|
||||
location /api/portfolio/ {
|
||||
proxy_pass http://portfolio:8000/api/portfolio/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Backlog (향후)
|
||||
|
||||
- Blog CRUD (`/api/blog/posts`) → portfolio 서비스로 이전
|
||||
- Todo CRUD (`/api/todos`) → portfolio 서비스로 이전
|
||||
- 이전 완료 후 lotto-backend에서 해당 테이블/라우트 제거
|
||||
- Nginx 라우팅 변경 (`/api/blog/`, `/api/todos` → portfolio)
|
||||
|
||||
---
|
||||
|
||||
## 10. 모바일 대응
|
||||
|
||||
- 기존 프로젝트 패턴 그대로: `useIsMobile()` + SwipeableView 3탭
|
||||
- 편집 모드: MobileSheet 활용
|
||||
- 자기소개 복사: 모바일에서도 `navigator.clipboard` 동작
|
||||
- PDF: 모바일에서는 "PDF 내보내기" 대신 "공유" 또는 브라우저 인쇄 기능 활용
|
||||
@@ -1,397 +0,0 @@
|
||||
# 청약 타겟팅 프론트엔드 설계 — 자치구 5티어 + 알림 설정
|
||||
|
||||
> 대상: `web-ui/src/pages/subscription/`
|
||||
> 백엔드 의존: 2026-04-28-realestate-targeting-enhancement-design.md (이미 배포됨)
|
||||
> 후속 별도 스펙: Subscription.jsx 분할 리팩토링, 5축 progress bar, 추가 알림 채널
|
||||
|
||||
---
|
||||
|
||||
## 1. 목표
|
||||
|
||||
백엔드 청약 타겟팅 고도화로 추가된 3 프로필 필드(`preferred_districts`, `min_match_score`, `notify_enabled`)를 프론트 UI에 노출한다. 매칭 결과·공고 카드에는 자치구 + 5티어 뱃지를, 상세 모달에는 매칭 사유 텍스트를 추가해 사용자가 점수의 근거를 즉시 이해할 수 있게 한다.
|
||||
|
||||
### 핵심 변경
|
||||
|
||||
- **ProfileTab**: 자치구 5티어 분류(드래그&드롭, PC 전용) + 임계값 슬라이더 + 알림 토글
|
||||
- **모바일**: 자치구 분류는 read-only — "PC에서 편집해주세요" 안내
|
||||
- **카드 표시**: AnnouncementCard / 매칭 카드에 district 뱃지 + 5티어 뱃지(reasons에서 derive)
|
||||
- **상세 모달**: AnnouncementDetail에 "매칭 분석" 섹션 (점수 + reasons 텍스트 + 자격)
|
||||
|
||||
### 변경하지 않는 것
|
||||
|
||||
- Subscription.jsx 자체 분할 — 본 스코프 외(별도 리팩토링)
|
||||
- 백엔드 응답 형태 — 모든 필요 데이터는 이미 응답에 포함됨
|
||||
- 5축 점수 분해 시각화 — 백엔드 응답 변경 필요(별도)
|
||||
- 알림 채널 추가 — 텔레그램 외 이메일/Slack은 별도
|
||||
|
||||
---
|
||||
|
||||
## 2. 컴포넌트 분할
|
||||
|
||||
### 2.1 신규 컴포넌트 2개
|
||||
|
||||
| 파일 | 책임 | 추정 크기 |
|
||||
|------|------|----------|
|
||||
| `web-ui/src/pages/subscription/components/DistrictTierEditor.jsx` | 자치구 5티어 드래그&드롭 + 모바일 read-only | ~180줄 |
|
||||
| `web-ui/src/pages/subscription/components/NotificationSettings.jsx` | 임계값 슬라이더 + 알림 토글 + 미리보기 | ~80줄 |
|
||||
|
||||
ProfileTab(현재 343줄)에 그대로 추가하면 단일 함수가 거대화되어 가독성·유지보수가 떨어진다. 의미 단위로 분할.
|
||||
|
||||
### 2.2 변경 받는 기존 컴포넌트
|
||||
|
||||
| 컴포넌트 (파일: Subscription.jsx) | 변경 |
|
||||
|----|------|
|
||||
| ProfileTab (956~1299줄) | 신규 컴포넌트 2개 import + 자치구 섹션 / 알림 설정 섹션 렌더 + handleSave에서 신규 3필드 송신 |
|
||||
| AnnouncementCard (315~389줄) | district 뱃지 + 5티어 뱃지(`extractTier(reasons)`) |
|
||||
| AnnouncementDetail (390~595줄) | "매칭 분석" 섹션 추가 (점수 + reasons + eligible_types) |
|
||||
| MatchesTab (763~955줄) | 매치 카드에 district + 5티어 뱃지 + reasons 표시 |
|
||||
| 모듈 상단 | `DEFAULT_PROFILE`에 신규 3필드 기본값 추가, `extractTier` 헬퍼 함수 |
|
||||
|
||||
### 2.3 스타일
|
||||
|
||||
- `Subscription.css`: 5티어 뱃지 5 클래스(`.sub-chip--tier-S`~`D`), 드래그&드롭 hover/dragover, 슬라이더, 토글, district 뱃지
|
||||
|
||||
### 2.4 기각된 대안
|
||||
|
||||
| 대안 | 기각 사유 |
|
||||
|------|-----------|
|
||||
| 단일 파일에 모든 신규 UI | ProfileTab이 500줄+ 거대화, 디버깅 어려움 |
|
||||
| Subscription.jsx 자체 분할 | 본 작업 스코프 외, 별도 리팩토링이 적절 |
|
||||
| `react-dnd` 도입 | 의존성 +50KB, 모바일 어차피 사용 안 함. YAGNI |
|
||||
| 5칼럼 체크박스 그리드 | 모바일/데스크톱 둘 다 무난하지만 드래그&드롭이 더 직관적이라 채택 안 함 |
|
||||
|
||||
---
|
||||
|
||||
## 3. DistrictTierEditor 컴포넌트
|
||||
|
||||
### 3.1 인터페이스
|
||||
|
||||
```jsx
|
||||
<DistrictTierEditor
|
||||
value={preferredDistricts} // {"S":["강남구",...], "A":[...], "B":[...], "C":[...], "D":[...]}
|
||||
onChange={(next) => setProfile({...profile, preferred_districts: next})}
|
||||
/>
|
||||
```
|
||||
|
||||
`value`가 비어있거나 누락되면 빈 객체 fallback. `onChange`는 새 객체를 항상 한 번 호출(부모는 setState만 처리).
|
||||
|
||||
### 3.2 상수
|
||||
|
||||
```jsx
|
||||
const SEOUL_DISTRICTS = [
|
||||
"강남구","강동구","강북구","강서구","관악구",
|
||||
"광진구","구로구","금천구","노원구","도봉구",
|
||||
"동대문구","동작구","마포구","서대문구","서초구",
|
||||
"성동구","성북구","송파구","양천구","영등포구",
|
||||
"용산구","은평구","종로구","중구","중랑구",
|
||||
];
|
||||
|
||||
const TIERS = [
|
||||
{ key: "S", label: "S", weight: "100%" },
|
||||
{ key: "A", label: "A", weight: "80%" },
|
||||
{ key: "B", label: "B", weight: "60%" },
|
||||
{ key: "C", label: "C", weight: "40%" },
|
||||
{ key: "D", label: "D", weight: "20%" },
|
||||
];
|
||||
|
||||
const EMPTY_TIERS = { S:[], A:[], B:[], C:[], D:[] };
|
||||
```
|
||||
|
||||
### 3.3 데스크톱 레이아웃 (≥768px)
|
||||
|
||||
```
|
||||
┌─ 자치구 우선순위 ─────────────────────────────────────────┐
|
||||
│ 미할당 (드래그해서 분류) │
|
||||
│ [강서구] [노원구] [도봉구] [중랑구] [관악구] ... │
|
||||
│ │
|
||||
│ ┌─ S 100% ─┐ ┌─ A 80% ─┐ ┌─ B 60% ─┐ ┌─ C 40% ─┐ ┌─ D 20% ─┐│
|
||||
│ │[강남구]× │ │[송파구]× │ │ │ │ │ │ ││
|
||||
│ │[서초구]× │ │[마포구]× │ │ │ │ │ │ ││
|
||||
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘│
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- 5티어는 가로 5칼럼 그리드(`grid-template-columns: repeat(5, 1fr)`)
|
||||
- 미할당 풀은 그리드 위, 가로 wrap
|
||||
- 자치구 칩은 `<span draggable="true">` + `<button>×</button>` (`×` 클릭 시 미할당으로 복귀)
|
||||
- 각 티어 슬롯은 dropzone(`onDragOver` + `onDrop`)
|
||||
- 미할당 풀도 dropzone(드래그해서 떨어뜨리면 해당 티어에서 제거)
|
||||
|
||||
### 3.4 모바일 레이아웃 (<768px) — read-only
|
||||
|
||||
```
|
||||
┌─ 자치구 우선순위 ──────────────┐
|
||||
│ S 100% 강남구, 서초구 │
|
||||
│ A 80% 송파구, 마포구 │
|
||||
│ B 60% (없음) │
|
||||
│ C 40% (없음) │
|
||||
│ D 20% (없음) │
|
||||
│ │
|
||||
│ ✏️ 자치구 분류는 PC에서 편집 │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
분기 로직:
|
||||
|
||||
```jsx
|
||||
const [isDesktop, setIsDesktop] = useState(
|
||||
typeof window !== "undefined" && window.matchMedia("(min-width: 768px)").matches
|
||||
);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(min-width: 768px)");
|
||||
const handler = (e) => setIsDesktop(e.matches);
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, []);
|
||||
```
|
||||
|
||||
`isDesktop=false`면 read-only 뷰만 렌더, 드래그 핸들러는 등록하지 않음.
|
||||
|
||||
### 3.5 핵심 로직
|
||||
|
||||
```jsx
|
||||
const handleDrop = (district, targetTier /* null = 미할당 */) => {
|
||||
const current = value || EMPTY_TIERS;
|
||||
const next = { ...EMPTY_TIERS };
|
||||
for (const t of Object.keys(EMPTY_TIERS)) {
|
||||
next[t] = (current[t] || []).filter(d => d !== district);
|
||||
}
|
||||
if (targetTier) {
|
||||
next[targetTier] = [...next[targetTier], district];
|
||||
}
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const unassigned = SEOUL_DISTRICTS.filter(d =>
|
||||
!TIERS.some(t => (value?.[t.key] || []).includes(d))
|
||||
);
|
||||
```
|
||||
|
||||
`onChange`는 새 객체를 통째로 전달(immutable update).
|
||||
|
||||
### 3.6 드래그&드롭 이벤트 (HTML5 native)
|
||||
|
||||
| 이벤트 | 핸들러 |
|
||||
|--------|--------|
|
||||
| `onDragStart` (chip) | `e.dataTransfer.setData("district", districtName)` |
|
||||
| `onDragOver` (zone) | `e.preventDefault()` (drop 허용) |
|
||||
| `onDrop` (zone) | `e.preventDefault()` + `handleDrop(e.dataTransfer.getData("district"), tierKey)` |
|
||||
|
||||
외부 라이브러리 없음.
|
||||
|
||||
---
|
||||
|
||||
## 4. NotificationSettings 컴포넌트
|
||||
|
||||
### 4.1 인터페이스
|
||||
|
||||
```jsx
|
||||
<NotificationSettings
|
||||
minScore={profile.min_match_score} // number 0~100
|
||||
notifyEnabled={profile.notify_enabled} // bool
|
||||
onChange={(patch) => setProfile({...profile, ...patch})}
|
||||
/>
|
||||
```
|
||||
|
||||
`onChange` 호출 예시: 슬라이더 변경 시 `onChange({ min_match_score: 75 })`, 토글 시 `onChange({ notify_enabled: false })`.
|
||||
|
||||
### 4.2 레이아웃
|
||||
|
||||
```
|
||||
┌─ 🔔 알림 설정 ────────────────────────────────┐
|
||||
│ 텔레그램 알림 ●━━━○ ON │
|
||||
│ 매칭 임계값 ▬▬▬▬▬▬●▬▬▬ 70점 │
|
||||
│ 0 50 100 │
|
||||
│ │
|
||||
│ 💡 70점 이상 매치 시 텔레그램에 자동 알림합니다│
|
||||
└────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.3 컨트롤
|
||||
|
||||
- 토글: `<input type="checkbox" className="sub-toggle">` + 사용자 정의 CSS (Subscription.css에 `.sub-toggle` 신설)
|
||||
- 슬라이더: `<input type="range" min="0" max="100" step="5">` + 우측 숫자 라벨
|
||||
- 미리보기: `notify_enabled === false` 일 때 경고 톤 메시지("알림 OFF — 메시지가 발송되지 않습니다")
|
||||
|
||||
### 4.4 저장 동작
|
||||
|
||||
각 컨트롤 변경 시 `onChange`로 부모 state만 업데이트. 실제 PUT 요청은 ProfileTab 기존 "저장" 버튼이 일괄 처리(다른 모든 필드와 동일 패턴).
|
||||
|
||||
### 4.5 카운트 미리보기 (스코프 외)
|
||||
|
||||
"현재 임계값 통과 매치 N건" 같은 카운트 미리보기는 본 스펙에서 다루지 않는다. `dashboard.new_match_count`는 "미확인 매칭"이라 임계값 통과와 의미가 다르고, 정확한 카운트를 위해서는 백엔드에 `dashboard.pass_count` 필드 신설이 필요하다. 후속 스펙으로 분리.
|
||||
|
||||
---
|
||||
|
||||
## 5. 카드 표시 변경
|
||||
|
||||
### 5.1 헬퍼 함수 (Subscription.jsx 모듈 상단)
|
||||
|
||||
```jsx
|
||||
function extractTier(reasons) {
|
||||
for (const r of reasons || []) {
|
||||
const m = r.match(/자치구 ([SABCD])티어/);
|
||||
if (m) return m[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
- 백엔드 응답 변경 없이 reasons 배열에서 티어 도출
|
||||
- reasons 형식 예시: `"자치구 S티어: 강남구 (+25)"` (백엔드 matcher.py의 fmt와 일치)
|
||||
- 광역만 매칭(legacy 모드)이면 티어 없음 → `null`
|
||||
|
||||
### 5.2 AnnouncementCard
|
||||
|
||||
기존 카드 제목/지역 라인 옆 또는 메타 라인에 추가:
|
||||
|
||||
```jsx
|
||||
{item.district && (
|
||||
<span className="sub-chip sub-chip--district">{item.district}</span>
|
||||
)}
|
||||
{(() => {
|
||||
const tier = extractTier(item.match_reasons);
|
||||
return tier ? (
|
||||
<span className={`sub-chip sub-chip--tier sub-chip--tier-${tier}`}>
|
||||
{tier}티어
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
```
|
||||
|
||||
`item.match_reasons`는 매칭 결과가 있는 경우만 존재. 없으면 뱃지 미표시(공고 목록 탭에서 매칭 결과 없는 카드).
|
||||
|
||||
### 5.3 AnnouncementDetail
|
||||
|
||||
상세 모달 하단에 새 섹션:
|
||||
|
||||
```
|
||||
┌─ 매칭 분석 ─────────────────────────────────┐
|
||||
│ ⭐ 점수: 90점 / 100점 │
|
||||
│ │
|
||||
│ 💡 매칭 사유 │
|
||||
│ • 광역 일치: 서울특별시 │
|
||||
│ • 자치구 S티어: 강남구 (+25) │
|
||||
│ • 예산 범위 내 모델 존재 (최고가 7.2억원) │
|
||||
│ • 자격 유형 2개: 일반1순위, 특별-신혼부부 │
|
||||
│ │
|
||||
│ ✓ 신청 자격 │
|
||||
│ [일반1순위] [특별-신혼부부] │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
`item.match_score`, `item.match_reasons`, `item.eligible_types`는 이미 응답에 포함됨(get_unnotified_matches는 물론 get_matches/get_announcement도 enrich_items 거침). 매칭 결과가 없는 공고에는 이 섹션 자체를 렌더하지 않음(`item.match_score` 존재 여부로 분기).
|
||||
|
||||
### 5.4 MatchesTab
|
||||
|
||||
매치 카드는 이미 매칭 데이터를 받지만 district + 5티어 뱃지 표시가 부족할 가능성 높음. AnnouncementCard와 동일한 helper(`extractTier`)로 일관 표시. 카드 클릭 시 AnnouncementDetail 모달이 reasons 노출.
|
||||
|
||||
### 5.5 5티어 뱃지 색상 (Subscription.css 신설)
|
||||
|
||||
```css
|
||||
.sub-chip--tier-S { background:#fee2e2; color:#dc2626; border-color:#fca5a5; }
|
||||
.sub-chip--tier-A { background:#fef3c7; color:#d97706; border-color:#fcd34d; }
|
||||
.sub-chip--tier-B { background:#d1fae5; color:#059669; border-color:#6ee7b7; }
|
||||
.sub-chip--tier-C { background:#dbeafe; color:#2563eb; border-color:#93c5fd; }
|
||||
.sub-chip--tier-D { background:#ede9fe; color:#7c3aed; border-color:#c4b5fd; }
|
||||
.sub-chip--district { background:#f3f4f6; color:#374151; border-color:#d1d5db; }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. ProfileTab 통합
|
||||
|
||||
### 6.1 DEFAULT_PROFILE 갱신
|
||||
|
||||
Subscription.jsx 모듈 상단의 `DEFAULT_PROFILE` 상수에 3 필드 default 추가:
|
||||
|
||||
```jsx
|
||||
const DEFAULT_PROFILE = {
|
||||
// ... 기존 필드
|
||||
preferred_regions: '',
|
||||
preferred_types: '',
|
||||
min_area: '',
|
||||
max_area: '',
|
||||
max_price: '',
|
||||
// 신규
|
||||
preferred_districts: {},
|
||||
min_match_score: 70,
|
||||
notify_enabled: true,
|
||||
};
|
||||
```
|
||||
|
||||
### 6.2 ProfileTab 렌더 추가 위치
|
||||
|
||||
자치구 섹션은 기존 "선호 조건" 섹션 다음, 알림 설정 섹션은 그 다음에 배치(저장 버튼 위):
|
||||
|
||||
```jsx
|
||||
<DistrictTierEditor
|
||||
value={profile.preferred_districts}
|
||||
onChange={(next) => handleChange("preferred_districts", next)}
|
||||
/>
|
||||
|
||||
<NotificationSettings
|
||||
minScore={profile.min_match_score ?? 70}
|
||||
notifyEnabled={profile.notify_enabled ?? true}
|
||||
onChange={(patch) => setProfile(prev => ({ ...prev, ...patch }))}
|
||||
/>
|
||||
```
|
||||
|
||||
### 6.3 handleSave 변경
|
||||
|
||||
신규 3 필드는 변환 없이 그대로 PUT body에 포함:
|
||||
|
||||
```jsx
|
||||
// 기존 변환 로직 다음에
|
||||
payload.preferred_districts = profile.preferred_districts || {};
|
||||
payload.min_match_score = profile.min_match_score ?? null;
|
||||
payload.notify_enabled = profile.notify_enabled ?? null;
|
||||
```
|
||||
|
||||
JSON 형태(객체)는 백엔드 ProfileUpdate 모델에서 `Dict[str, List[str]]`로 받음(이미 구현됨).
|
||||
|
||||
---
|
||||
|
||||
## 7. 테스트 전략
|
||||
|
||||
`web-ui` 레포는 단위 테스트 인프라가 빈약(컨벤션 확인 필요). 본 작업의 검증:
|
||||
|
||||
| 영역 | 검증 방식 |
|
||||
|------|-----------|
|
||||
| 빌드 | `npm run build` warning/error 없음 |
|
||||
| 데스크톱 자치구 편집 | 미할당 풀 → S 슬롯 드래그 → 저장 → 새로고침 → 유지 확인 |
|
||||
| 자치구 티어 이동 | S → A로 드래그 → S에서 사라지고 A에 등장 |
|
||||
| 자치구 해제 | × 버튼 또는 미할당 풀로 드래그 → 미할당 풀에 복귀 |
|
||||
| 모바일 read-only | 개발자 도구 < 768px → 편집 영역 숨김 + 안내 메시지 표시 |
|
||||
| 임계값 슬라이더 | 0→100 조절, 즉시 미리보기 텍스트 갱신, 저장·새로고침 후 유지 |
|
||||
| 알림 토글 | OFF 시 경고 톤 안내 표시 |
|
||||
| 매칭 카드 | district 뱃지 + 5티어 뱃지 표시 (해당 데이터 있는 경우) |
|
||||
| 상세 모달 | 매칭 분석 섹션의 점수 + reasons + 자격 표시 |
|
||||
| 회귀 | 기존 프로필 필드(나이/청약통장/특공 등) 입력·저장 정상 |
|
||||
|
||||
`scripts/dev.bat` 또는 `cd web-ui && npm run dev`로 dev server 실행 후 브라우저에서 수동 검증.
|
||||
|
||||
배포는 frontend 별도 절차: `cd web-ui && npm run release:nas` (NAS Z 드라이브에 robocopy).
|
||||
|
||||
---
|
||||
|
||||
## 8. 스코프
|
||||
|
||||
### 본 스펙 범위
|
||||
|
||||
- ✅ DistrictTierEditor 신규 컴포넌트
|
||||
- ✅ NotificationSettings 신규 컴포넌트
|
||||
- ✅ ProfileTab 신규 3 필드 통합 + 저장
|
||||
- ✅ AnnouncementCard / MatchesTab district + 5티어 뱃지
|
||||
- ✅ AnnouncementDetail 매칭 분석 섹션
|
||||
- ✅ Subscription.css 5티어 뱃지 + 드래그 영역 + 토글 + 슬라이더 스타일
|
||||
- ✅ 모바일 read-only fallback
|
||||
|
||||
### 후속 별도 스펙
|
||||
|
||||
- ❌ Subscription.jsx 자체 분할 (1354줄 → 별도 리팩토링)
|
||||
- ❌ 5축 점수 분해 progress bar (백엔드 응답에 5축 점수 추가 필요)
|
||||
- ❌ 임계값 통과 매치 카운트 미리보기 (`dashboard.pass_count` 백엔드 신설 필요)
|
||||
- ❌ 자치구 5티어 자동 추천 (사용자 가점·예산 기반)
|
||||
- ❌ 알림 채널 추가 (이메일/Slack)
|
||||
- ❌ 모바일 자치구 편집 지원 (touch backend 필요 시)
|
||||
@@ -1,479 +0,0 @@
|
||||
# 청약 서비스 타겟팅 고도화 설계
|
||||
|
||||
> 대상: `web-backend/realestate-lab/` + `web-backend/agent-office/`
|
||||
> 후속 별도 스펙: 프론트 자치구 입력 UI(`web-ui`), 청약 가점 vs 커트라인 비교, 서울 외 광역 자치구 파싱
|
||||
|
||||
---
|
||||
|
||||
## 1. 목표
|
||||
|
||||
현재 청약 서비스가 1) 완료된 공고까지 무차별 수집하고, 2) 매칭이 binary라 단지별 의미 있는 점수 차이가 없으며, 3) 데일리 리포트라 "발견 즉시"의 가치를 못 살리는 문제를 해결한다.
|
||||
|
||||
### 핵심 변경
|
||||
|
||||
- **수집**: 모집공고 30일 이전 + 이미 `완료` 상태인 공고는 저장하지 않음. 90일 경과 완료 공고 자동 정리.
|
||||
- **단일 SoT**: `user_profile.preferred_regions`를 수집·조회·매칭의 단일 기준점으로 사용 (서울 default).
|
||||
- **매칭**: 자치구 5티어 가중치(S=100% / A=80% / B=60% / C=40% / D=20%) 도입. 자격 점수 미세 조정.
|
||||
- **알림**: 데일리 리포트 폐기. "신규 매칭 + 임계값 통과" 즉시 텔레그램 푸시. realestate-lab → agent-office HTTP push.
|
||||
|
||||
### 변경하지 않는 것
|
||||
|
||||
- 공공데이터 API 엔드포인트 5종 구성
|
||||
- 매칭 총점 100점 체계
|
||||
- 텔레그램 봇 토큰·formatter는 agent-office에 단일 보관
|
||||
- realestate-lab의 09:00 / 00:00 cron 스케줄(기존 그대로 유지, 트리거 로직만 변경)
|
||||
|
||||
---
|
||||
|
||||
## 2. 아키텍처 변경 개요
|
||||
|
||||
### 2.1 변경 포인트
|
||||
|
||||
| # | 위치 | 변경 |
|
||||
|---|------|------|
|
||||
| 1 | `realestate-lab/collector.py` | API 호출 시 모집공고일 윈도우 사전 적용. 응답 시 `완료` 상태 skip. 자치구 파싱. 90일 경과 완료 공고 정리. |
|
||||
| 2 | `realestate-lab/db.py` | `user_profile`에 3컬럼, `announcements`에 `district`, `match_results`에 `notified_at` 추가. `delete_old_completed_announcements()` 신규. |
|
||||
| 3 | `realestate-lab/matcher.py` | 자치구 5티어 가중치 + 자격 점수 재배분. binary → 자치구 그라디언트. |
|
||||
| 4 | `realestate-lab` 신규 모듈 | `notifier.py`: 임계값 통과 신규 매칭 추출 + agent-office push. `notified_at` 멱등 마킹. |
|
||||
| 5 | `agent-office/agents/realestate.py` | 데일리 cron 폐기. `on_new_matches(matches)` 신규. 메시지 fmt + 인라인 키보드. |
|
||||
| 6 | `agent-office/main.py` | `POST /api/agent-office/realestate/notify` 신규 엔드포인트. |
|
||||
|
||||
### 2.2 데이터 흐름
|
||||
|
||||
```
|
||||
[09:00 cron] realestate-lab.scheduled_collect()
|
||||
├─ collect_all()
|
||||
│ ├─ API 호출 (RCRIT_PBLANC_DE_FROM = today − 30일)
|
||||
│ ├─ 응답 파싱 + district 추출
|
||||
│ ├─ status='완료' skip → upsert
|
||||
│ └─ delete_old_completed_announcements(grace_days=90)
|
||||
├─ run_matching() // 5티어 가중치 적용
|
||||
└─ notify_new_matches()
|
||||
├─ SELECT match_results WHERE notified_at IS NULL
|
||||
│ AND match_score >= profile.min_match_score
|
||||
│ AND profile.notify_enabled = 1
|
||||
├─ POST agent-office /api/agent-office/realestate/notify
|
||||
└─ 성공 → UPDATE notified_at = now()
|
||||
|
||||
[agent-office] POST /api/agent-office/realestate/notify
|
||||
└─ RealestateAgent.on_new_matches(matches)
|
||||
├─ formatter로 텔레그램 텍스트 + 인라인 키보드 빌드
|
||||
└─ telegram_bot.send_message()
|
||||
```
|
||||
|
||||
### 2.3 기각된 대안
|
||||
|
||||
| 대안 | 기각 사유 |
|
||||
|------|-----------|
|
||||
| 매칭 로직을 agent-office에 이식 | 두 서비스에 매칭 코드 복제 → 동기화 부담 |
|
||||
| 완료 공고 즉시 삭제 | 사용자가 회고 못 함. 90일 grace 채택 |
|
||||
| agent-office가 realestate-lab을 폴링 | 트래픽 + 지연 |
|
||||
| realestate-lab이 직접 텔레그램 호출 | 토큰·formatter 분산. 봇 단일 책임 위반 |
|
||||
| 가격·면적 그라디언트 곡선 | 점수 해석 어려움. binary 유지 (자치구 1축에만 곡선 적용) |
|
||||
|
||||
---
|
||||
|
||||
## 3. DB 스키마 변경
|
||||
|
||||
### 3.1 `user_profile` — 3컬럼 추가
|
||||
|
||||
```sql
|
||||
ALTER TABLE user_profile ADD COLUMN preferred_districts TEXT NOT NULL DEFAULT '{}';
|
||||
ALTER TABLE user_profile ADD COLUMN min_match_score INTEGER NOT NULL DEFAULT 70;
|
||||
ALTER TABLE user_profile ADD COLUMN notify_enabled INTEGER NOT NULL DEFAULT 1;
|
||||
```
|
||||
|
||||
- **`preferred_districts`**: JSON. 5티어 분류.
|
||||
```json
|
||||
{"S": ["강남구", "서초구"], "A": ["송파구", "마포구"], "B": [], "C": [], "D": []}
|
||||
```
|
||||
모든 티어 비어있으면 자치구 기준 미설정으로 간주 (기존 호환 동작).
|
||||
- **`min_match_score`**: 알림 트리거 임계값(0~100). 기본 70.
|
||||
- **`notify_enabled`**: 텔레그램 푸시 ON/OFF. 0이면 알림 전체 차단.
|
||||
|
||||
### 3.2 `announcements` — `district` 컬럼 추가
|
||||
|
||||
```sql
|
||||
ALTER TABLE announcements ADD COLUMN district TEXT;
|
||||
CREATE INDEX IF NOT EXISTS idx_ann_district ON announcements(district);
|
||||
```
|
||||
|
||||
- collector가 응답의 `HSSPLY_ADRES` / `region_name`을 정규식 파싱하여 채움.
|
||||
- 서울 외 지역, 파싱 실패 → NULL.
|
||||
|
||||
### 3.3 `match_results` — `notified_at` 컬럼 추가
|
||||
|
||||
```sql
|
||||
ALTER TABLE match_results ADD COLUMN notified_at TEXT;
|
||||
```
|
||||
|
||||
- NULL이면 미알림. 알림 송신 후 `strftime('%Y-%m-%dT%H:%M:%fZ','now')` 기록.
|
||||
- 기존 `is_new`(사용자가 UI에서 봤는지)와 의미 분리.
|
||||
|
||||
### 3.4 신규 함수
|
||||
|
||||
```python
|
||||
def delete_old_completed_announcements(grace_days: int = 90) -> int:
|
||||
"""winner_date + grace_days 경과한 status='완료' 공고를 삭제.
|
||||
winner_date가 NULL인 행은 안전하게 보존(수동 검토 대상).
|
||||
match_results는 FK CASCADE로 자동 삭제. 삭제된 건수 반환.
|
||||
"""
|
||||
```
|
||||
|
||||
```python
|
||||
def get_unnotified_matches(min_score: int) -> list[dict]:
|
||||
"""notified_at IS NULL AND match_score >= min_score 인 매칭 + 공고 정보 조인 반환."""
|
||||
```
|
||||
|
||||
```python
|
||||
def mark_matches_notified(match_ids: list[int]) -> None:
|
||||
"""notified_at = now() 일괄 업데이트."""
|
||||
```
|
||||
|
||||
### 3.5 마이그레이션 패턴
|
||||
|
||||
기존 db.py의 `init_db()` 안에서 try/except로 컬럼 존재 여부 검사 후 ALTER (운영 DB 무중단).
|
||||
|
||||
---
|
||||
|
||||
## 4. collector 변경
|
||||
|
||||
### 4.1 모집공고일 윈도우 사전 좁힘
|
||||
|
||||
```python
|
||||
def collect_all() -> dict:
|
||||
today = date.today()
|
||||
date_from = (today - timedelta(days=30)).strftime("%Y%m%d")
|
||||
|
||||
for detail_ep, model_ep in DETAIL_ENDPOINTS:
|
||||
rows = _api_call(detail_ep, params={
|
||||
# 공공데이터 API 파라미터명은 엔드포인트별로 다를 수 있음.
|
||||
# 구현 시 한국부동산원 API 스펙 확인 후 정확한 키 적용.
|
||||
"RCRIT_PBLANC_DE_FROM": date_from,
|
||||
})
|
||||
# ...
|
||||
```
|
||||
|
||||
> ⚠️ **구현 시 검증 필요**: `ApplyhomeInfoDetailSvc`의 5개 엔드포인트가 모두 모집공고일 필터 파라미터를 지원하지 않을 수 있음. 미지원 시 응답 수신 후 클라이언트 측에서 `parsed["rcrit_date"] < date_from` skip하는 fallback을 적용.
|
||||
|
||||
### 4.2 `완료` 상태 skip
|
||||
|
||||
```python
|
||||
parsed = _parse_apt_detail(raw)
|
||||
parsed["district"] = _extract_district(parsed)
|
||||
|
||||
status = compute_status(
|
||||
parsed.get("receipt_start", ""),
|
||||
parsed.get("receipt_end", ""),
|
||||
parsed.get("winner_date", ""),
|
||||
)
|
||||
if status == "완료":
|
||||
continue # DB 자원 절감
|
||||
|
||||
# 일정 정보 없는 공고 skip (기존 로직 유지)
|
||||
has_dates = any(parsed.get(f) for f in (...))
|
||||
if not has_dates:
|
||||
continue
|
||||
|
||||
upsert_announcement(parsed)
|
||||
```
|
||||
|
||||
### 4.3 자치구 추출
|
||||
|
||||
```python
|
||||
DISTRICT_PATTERN = re.compile(r"(?:서울특별시|서울시|서울)\s+(\S+?(?:구|군))")
|
||||
|
||||
def _extract_district(parsed: dict) -> str | None:
|
||||
for src in (parsed.get("address"), parsed.get("region_name")):
|
||||
if not src:
|
||||
continue
|
||||
m = DISTRICT_PATTERN.search(src)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
```
|
||||
|
||||
### 4.4 정리 + 매칭 + 알림 트리거
|
||||
|
||||
```python
|
||||
def collect_all() -> dict:
|
||||
# ... 위 수집 로직
|
||||
save_collect_log(new_count, total_count)
|
||||
return {"new_count": new_count, "total_count": total_count}
|
||||
|
||||
|
||||
def scheduled_collect():
|
||||
"""09:00 cron — 수집 + 정리 + 매칭 + 알림"""
|
||||
collect_all()
|
||||
deleted = delete_old_completed_announcements(grace_days=90)
|
||||
logger.info("정리: %d건 삭제", deleted)
|
||||
run_matching()
|
||||
notify_new_matches() # NEW
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. matcher 변경
|
||||
|
||||
### 5.1 가중치 재배분 (총 100점 유지)
|
||||
|
||||
| 축 | 기존 | 신규 |
|
||||
|----|------|------|
|
||||
| 지역 | 30 | **35** (광역 10 + 자치구 가중 0~25) |
|
||||
| 주택유형 | 10 | 10 |
|
||||
| 면적 | 15 | 15 |
|
||||
| 가격 | 15 | 15 |
|
||||
| 자격 | 30 | **25** |
|
||||
|
||||
### 5.2 지역 점수 (35점)
|
||||
|
||||
```python
|
||||
TIER_WEIGHTS = {"S": 1.00, "A": 0.80, "B": 0.60, "C": 0.40, "D": 0.20}
|
||||
|
||||
def _region_score(profile: dict, ann: dict) -> tuple[int, list[str]]:
|
||||
region_name = ann.get("region_name") or ""
|
||||
district = ann.get("district") or ""
|
||||
preferred_regions = profile.get("preferred_regions") or []
|
||||
preferred_districts = profile.get("preferred_districts") or {}
|
||||
|
||||
region_match = bool(region_name and any(r in region_name for r in preferred_regions))
|
||||
if not region_match:
|
||||
return 0, []
|
||||
|
||||
# 자치구 기준 미설정 → 광역만으로 풀 점수 (기존 호환)
|
||||
has_districts = any(preferred_districts.get(t) for t in TIER_WEIGHTS)
|
||||
if not has_districts:
|
||||
return 35, [f"선호 지역 일치: {region_name}"]
|
||||
|
||||
score = 10
|
||||
reasons = [f"광역 일치: {region_name}"]
|
||||
|
||||
for tier, weight in TIER_WEIGHTS.items():
|
||||
if district in (preferred_districts.get(tier) or []):
|
||||
tier_score = round(25 * weight)
|
||||
score += tier_score
|
||||
reasons.append(f"자치구 {tier}티어: {district} (+{tier_score})")
|
||||
break
|
||||
|
||||
return score, reasons
|
||||
```
|
||||
|
||||
### 5.3 자격 점수 (25점)
|
||||
|
||||
```python
|
||||
def _eligibility_score(eligible_types: list[str]) -> int:
|
||||
if not eligible_types:
|
||||
return 0
|
||||
score = 15 # 첫 자격
|
||||
score += min((len(eligible_types) - 1) * 5, 10) # 추가 자격당 +5, 최대 +10
|
||||
return score
|
||||
```
|
||||
|
||||
다른 축(주택유형 10, 면적 15, 가격 15)은 기존 binary 로직 유지.
|
||||
|
||||
### 5.4 매칭 결과 저장
|
||||
|
||||
`run_matching()`은 기존 흐름 유지. `match_results.notified_at`은 손대지 않음 (notifier가 관리).
|
||||
|
||||
---
|
||||
|
||||
## 6. 알림 흐름
|
||||
|
||||
### 6.1 realestate-lab 측 — `notifier.py`
|
||||
|
||||
```python
|
||||
import os
|
||||
import requests
|
||||
from .db import get_unnotified_matches, mark_matches_notified, get_profile
|
||||
|
||||
AGENT_OFFICE_URL = os.getenv("AGENT_OFFICE_URL", "http://agent-office:8000")
|
||||
|
||||
|
||||
def notify_new_matches() -> dict:
|
||||
profile = get_profile()
|
||||
if not profile or not profile.get("notify_enabled"):
|
||||
return {"sent": 0, "skipped": "notify_disabled"}
|
||||
|
||||
threshold = profile.get("min_match_score", 70)
|
||||
matches = get_unnotified_matches(threshold)
|
||||
if not matches:
|
||||
return {"sent": 0}
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{AGENT_OFFICE_URL}/api/agent-office/realestate/notify",
|
||||
json={"matches": matches},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
body = resp.json()
|
||||
sent_ids = body.get("sent_ids", [])
|
||||
if sent_ids:
|
||||
mark_matches_notified(sent_ids)
|
||||
return body
|
||||
except requests.RequestException as e:
|
||||
logger.error("알림 push 실패: %s", e)
|
||||
return {"sent": 0, "error": str(e)}
|
||||
```
|
||||
|
||||
알림 push 실패 시 `notified_at`을 채우지 않아 다음 사이클에서 재시도된다.
|
||||
|
||||
### 6.2 agent-office 측 — 신규 엔드포인트
|
||||
|
||||
```python
|
||||
# agent-office/main.py
|
||||
@app.post("/api/agent-office/realestate/notify")
|
||||
async def realestate_notify(body: dict):
|
||||
matches = body.get("matches", [])
|
||||
agent = registry.get("realestate")
|
||||
result = await agent.on_new_matches(matches)
|
||||
return result
|
||||
```
|
||||
|
||||
```python
|
||||
# agents/realestate.py
|
||||
async def on_new_matches(self, matches: list[dict]) -> dict:
|
||||
if not matches:
|
||||
return {"sent": 0, "sent_ids": []}
|
||||
|
||||
text = telegram_formatter.format_realestate_matches(matches)
|
||||
keyboard = telegram_formatter.build_match_keyboard(matches)
|
||||
tg = await telegram_bot.send_message(text, reply_markup=keyboard)
|
||||
|
||||
if not tg.get("ok"):
|
||||
return {"sent": 0, "sent_ids": [], "error": tg.get("error")}
|
||||
|
||||
sent_ids = [m["id"] for m in matches]
|
||||
return {"sent": len(matches), "sent_ids": sent_ids, "message_id": tg.get("message_id")}
|
||||
```
|
||||
|
||||
### 6.3 텔레그램 메시지 포맷
|
||||
|
||||
**3건 이상 — 묶음 카드**
|
||||
|
||||
```
|
||||
🏢 새 청약 매칭 3건
|
||||
|
||||
⭐ 92점 — 디에이치 강남 [S]
|
||||
📍 서울 강남구 (분양가상한제) · 32~45㎡ · 6.2~9.8억
|
||||
📅 청약 05/15(수) ~ 05/19(일)
|
||||
|
||||
⭐ 78점 — 마포 푸르지오 [A]
|
||||
📍 서울 마포구 · 59~84㎡ · 8.0~11.5억
|
||||
📅 청약 05/22(수) ~ 05/26(일)
|
||||
|
||||
⭐ 72점 — 송파 데시앙 [A]
|
||||
📍 서울 송파구 · 39~59㎡ · 5.8~7.9억
|
||||
📅 청약 05/27(월) ~ 05/30(목)
|
||||
|
||||
[전체 보기]
|
||||
```
|
||||
|
||||
**1~2건 — 풀 카드**
|
||||
|
||||
```
|
||||
⭐ 90점 — 디에이치 강남 [S]
|
||||
📍 서울 강남구 (분양가상한제)
|
||||
🏠 32~45㎡ · 6.2~9.8억
|
||||
📅 청약 05/15(수) ~ 05/19(일)
|
||||
✓ 자격: 일반1순위, 특별-신혼부부
|
||||
💡 광역 일치 / 자치구 S티어 / 예산 범위 / 자격 2개
|
||||
|
||||
[🔖 북마크] [📄 공고 보기]
|
||||
```
|
||||
|
||||
### 6.4 인라인 키보드 콜백
|
||||
|
||||
| 버튼 | 콜백 동작 |
|
||||
|------|-----------|
|
||||
| `[🔖 북마크]` | `PATCH /api/realestate/announcements/{id}/bookmark` (기존 endpoint) |
|
||||
| `[📄 공고 보기]` | `pblanc_url` (텔레그램 URL 버튼) |
|
||||
| `[전체 보기]` | 대시보드 deep link (`/realestate?tab=matches`) |
|
||||
|
||||
agent-office의 텔레그램 webhook(`/api/agent-office/telegram/webhook`)이 callback_query를 받아 service_proxy로 realestate-lab API 호출.
|
||||
|
||||
### 6.5 기존 RealestateAgent 동작 정리
|
||||
|
||||
```python
|
||||
# agent-office/scheduler.py — 09:15 데일리 cron 제거
|
||||
# scheduler.add_job(realestate_agent.on_schedule, ...) ← REMOVE
|
||||
```
|
||||
|
||||
`RealestateAgent.on_schedule()`은 호출 지점이 사라지므로 제거. `on_command("fetch_matches")`는 수동 트리거(텔레그램 슬래시 명령)용으로 보존하되 `on_new_matches()`를 직접 호출하도록 단순화.
|
||||
|
||||
### 6.6 환경변수
|
||||
|
||||
| 변수 | 위치 | 기본값 |
|
||||
|------|------|--------|
|
||||
| `AGENT_OFFICE_URL` | realestate-lab `.env` | `http://agent-office:8000` |
|
||||
| `TELEGRAM_BOT_TOKEN` / `TELEGRAM_CHAT_ID` | agent-office (기존) | (기존) |
|
||||
|
||||
docker-compose의 사내 네트워크로 호출되므로 외부 노출 없음.
|
||||
|
||||
---
|
||||
|
||||
## 7. API 변경 요약
|
||||
|
||||
### 7.1 realestate-lab
|
||||
|
||||
| 메서드 | 경로 | 변경 |
|
||||
|--------|------|------|
|
||||
| PUT | `/api/realestate/profile` | body에 `preferred_districts`, `min_match_score`, `notify_enabled` 수용 |
|
||||
| GET | `/api/realestate/profile` | 응답에 위 3필드 포함 |
|
||||
| GET | `/api/realestate/announcements` | 응답 item에 `district` 포함 |
|
||||
| GET | `/api/realestate/announcements/{id}` | 응답에 `district` 포함 |
|
||||
| GET | `/api/realestate/matches` | 응답 item에 `notified_at` 포함 (디버깅용) |
|
||||
|
||||
### 7.2 agent-office
|
||||
|
||||
| 메서드 | 경로 | 변경 |
|
||||
|--------|------|------|
|
||||
| POST | `/api/agent-office/realestate/notify` | **신규** — realestate-lab 전용 push 수신 |
|
||||
|
||||
### 7.3 Pydantic 모델 확장
|
||||
|
||||
```python
|
||||
# realestate-lab/app/models.py
|
||||
class ProfileUpdate(BaseModel):
|
||||
# ... 기존 필드
|
||||
preferred_districts: Optional[Dict[str, List[str]]] = None
|
||||
min_match_score: Optional[int] = Field(default=None, ge=0, le=100)
|
||||
notify_enabled: Optional[bool] = None
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 테스트 전략
|
||||
|
||||
| 영역 | 테스트 항목 |
|
||||
|------|-------------|
|
||||
| `_extract_district` | "서울특별시 강남구 도곡동" → `"강남구"`, "서울 송파구" → `"송파구"`, "부산 해운대구" → NULL, "" → NULL |
|
||||
| `compute_status` | 변경 없음. 기존 테스트 유지 |
|
||||
| `_region_score` | 광역 미매칭 / 광역만 매칭 + 자치구 미설정 / S~D 티어별 / 광역 매칭 + 비선호 자치구 — 5케이스 |
|
||||
| `_eligibility_score` | 자격 0개 / 1개 / 3개 / 5개 — 점수 단조 증가 + 25 상한 |
|
||||
| `delete_old_completed_announcements` | winner_date 91일 전 → 삭제, 89일 전 → 보존, status≠'완료' → 보존 |
|
||||
| collector 사전 좁힘 | mock API 응답으로 30일 윈도우 외 데이터 skip 확인. `완료` skip 확인 |
|
||||
| `notify_new_matches` 멱등성 | `notified_at` 채워진 매치는 push 후보 제외, push 실패 시 `notified_at` 미기록 → 다음 사이클 재시도 |
|
||||
| agent-office push endpoint | mock telegram client로 `format_realestate_matches` 호출 + send 검증 |
|
||||
| 알림 임계값 필터 | min_match_score=70, score=69 → push 대상 외 / score=70 → 포함 |
|
||||
| `notify_enabled=0` | push 자체 skip |
|
||||
|
||||
NAS Docker는 git push 자동 배포이므로 별도 절차 없음. ALTER TABLE은 init_db에서 try/except 패턴으로 운영 DB 무중단 적용.
|
||||
|
||||
---
|
||||
|
||||
## 9. 스코프
|
||||
|
||||
### 본 스펙 범위
|
||||
|
||||
- ✅ realestate-lab: collector, matcher, db 변경, notifier 신규
|
||||
- ✅ agent-office: `/realestate/notify` 엔드포인트, `on_new_matches` 메소드, 메시지 formatter
|
||||
- ✅ 기존 데일리 RealestateAgent cron 폐기
|
||||
|
||||
### 후속 별도 스펙
|
||||
|
||||
- ❌ 프론트(`web-ui`) 자치구 5티어 입력 UI (별도 frontend 스펙)
|
||||
- ❌ 청약 가점 vs 공고별 예상 커트라인 비교 (외부 데이터 의존성, 별도 연구)
|
||||
- ❌ 서울 외 광역(부산 해운대구 등) 자치구 파싱 확장
|
||||
- ❌ 매칭 임계값 변경 후 재발송 트리거 (`POST /notifications/resend`)
|
||||
- ❌ 자치구별 매칭 분포 대시보드 위젯
|
||||
@@ -1,359 +0,0 @@
|
||||
# music-lab YouTube 수익화 고도화 설계
|
||||
|
||||
> 작성일: 2026-05-01
|
||||
> 범위: music-lab + agent-office 확장
|
||||
|
||||
---
|
||||
|
||||
## 1. 개요
|
||||
|
||||
Suno API로 생성한 음악을 YouTube 업로드 가능한 완성 영상으로 만들고, 시장 수요 분석을 통해 수익이 나는 콘텐츠를 정기적으로 생산하는 파이프라인 구축.
|
||||
|
||||
**핵심 목표:**
|
||||
- 시장 조사 자동화 → 만들 만한 장르/스타일 추천
|
||||
- 음악 + 영상 합성 → YouTube 업로드 패키지(MP4 + 메타데이터) 자동 생성
|
||||
- 수익 추적 → 채널별·장르별·국가별 RPM 분석
|
||||
- **Phase 1**: 파일 내보내기(수동 업로드) → **Phase 3**: YouTube API 자동 업로드
|
||||
|
||||
---
|
||||
|
||||
## 2. 결정 사항 요약
|
||||
|
||||
| 항목 | 결정 |
|
||||
|------|------|
|
||||
| 자동화 수준 | 반자동 — 수집·추천 자동, 생성·업로드 수동 트리거 |
|
||||
| 업로드 방식 | Phase 1: 파일 내보내기, Phase 3: YouTube API |
|
||||
| 영상 포맷 | 오디오 비주얼라이저 + AI 이미지 슬라이드쇼 |
|
||||
| 시장 조사 데이터 | YouTube 트렌드 + Google Trends + Billboard (해외 시장 포함) |
|
||||
| 음악 언어 전략 | 인스트루멘탈 + 영어 가사 혼합 |
|
||||
| 이미지 소스 | Suno 커버이미지 + Pexels/Unsplash (추후 Stable Diffusion) |
|
||||
| 주력 해외 시장 | 브라질, 인도네시아, 멕시코, 글로벌 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 아키텍처
|
||||
|
||||
```
|
||||
[외부 데이터 소스]
|
||||
YouTube Data API v3 · Google Trends · Billboard · Pexels/Unsplash
|
||||
↓ 매일 09:00 스케줄
|
||||
[agent-office :18900]
|
||||
YouTubeResearchAgent (신규)
|
||||
- 국가별 트렌딩 수집·분석
|
||||
- POST /api/music/market/ingest → music-lab push
|
||||
- 매주 월요일 08:00 텔레그램 인사이트 리포트
|
||||
↓
|
||||
[music-lab :18600]
|
||||
기존: 음악 생성 · 라이브러리
|
||||
신규: 시장 데이터 저장 · 영상 제작 파이프라인 · 수익화 추적
|
||||
↓
|
||||
[내보내기 패키지]
|
||||
output.mp4 + thumbnail.jpg + metadata.json
|
||||
(Phase 3: YouTube API 자동 업로드)
|
||||
```
|
||||
|
||||
**변경 없는 것:** 컨테이너 수, 포트 배정, Nginx 라우팅 (경로 1개 추가 제외)
|
||||
|
||||
---
|
||||
|
||||
## 4. DB 스키마 (신규)
|
||||
|
||||
### 4-1. music.db 신규 테이블
|
||||
|
||||
#### `market_trends`
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | INTEGER PK | |
|
||||
| source | TEXT | `'youtube'` \| `'google_trends'` \| `'billboard'` |
|
||||
| country | TEXT | `'BR'` \| `'ID'` \| `'MX'` \| `'US'` \| `'KR'` … |
|
||||
| genre | TEXT | 장르 문자열 |
|
||||
| keyword | TEXT | 검색 키워드 |
|
||||
| score | REAL | 정규화 인기도 (0.0~1.0) |
|
||||
| rank | INTEGER | 차트 순위 (nullable) |
|
||||
| metadata | TEXT | JSON — 추가 원본 데이터 |
|
||||
| collected_at | TEXT | ISO8601 |
|
||||
|
||||
인덱스: `(country, source, collected_at DESC)`
|
||||
|
||||
#### `trend_reports`
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | INTEGER PK | |
|
||||
| report_date | TEXT UNIQUE | YYYY-MM-DD |
|
||||
| top_genres | TEXT | JSON 배열 `[{genre, score, countries}]` |
|
||||
| top_keywords | TEXT | JSON 배열 |
|
||||
| recommended_styles | TEXT | JSON `[{genre, prompt, countries, reason}]` |
|
||||
| insights | TEXT | AI 분석 텍스트 |
|
||||
| created_at | TEXT | ISO8601 |
|
||||
|
||||
#### `video_projects`
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | INTEGER PK | |
|
||||
| track_id | INTEGER FK | → music_library.id |
|
||||
| format | TEXT | `'visualizer'` \| `'slideshow'` |
|
||||
| status | TEXT | `'pending'` \| `'rendering'` \| `'done'` \| `'failed'` |
|
||||
| output_path | TEXT | MP4 로컬 경로 |
|
||||
| output_url | TEXT | `/media/videos/…` 서빙 URL |
|
||||
| thumbnail_path | TEXT | JPG 로컬 경로 |
|
||||
| target_countries | TEXT | JSON 배열 `['BR', 'ID']` |
|
||||
| yt_title | TEXT | Claude API 생성 제목 (최대 100자) |
|
||||
| yt_description | TEXT | Claude API 생성 설명 (해시태그 포함) |
|
||||
| yt_tags | TEXT | JSON 배열 (10-15개, 국가별 현지화) |
|
||||
| render_params | TEXT | JSON — 렌더링 파라미터 (색상, 전환 효과 등) |
|
||||
| error | TEXT | 실패 시 에러 메시지 |
|
||||
| created_at | TEXT | ISO8601 |
|
||||
| completed_at | TEXT | ISO8601 (nullable) |
|
||||
|
||||
#### `revenue_records`
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | INTEGER PK | |
|
||||
| video_project_id | INTEGER FK | → video_projects.id (nullable) |
|
||||
| yt_video_id | TEXT | YouTube 영상 ID |
|
||||
| record_month | TEXT | YYYY-MM |
|
||||
| views | INTEGER | 조회수 |
|
||||
| watch_hours | REAL | 시청 시간 (시간 단위) |
|
||||
| revenue_usd | REAL | 수익 (USD) |
|
||||
| rpm_usd | REAL | revenue / views * 1000 |
|
||||
| country | TEXT | 국가별 분석용 (nullable) |
|
||||
| source | TEXT | `'manual'` \| `'youtube_api'` |
|
||||
| created_at | TEXT | ISO8601 |
|
||||
|
||||
### 4-2. agent_office.db 신규 테이블
|
||||
|
||||
#### `youtube_research_jobs`
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | INTEGER PK | |
|
||||
| status | TEXT | `'running'` \| `'completed'` \| `'failed'` |
|
||||
| countries | TEXT | JSON 배열 — 수집 대상 국가 |
|
||||
| trends_collected | INTEGER | 수집된 트렌드 건수 |
|
||||
| report_id | INTEGER | 생성된 trend_reports.id (nullable) |
|
||||
| error | TEXT | 실패 시 에러 |
|
||||
| started_at | TEXT | ISO8601 |
|
||||
| completed_at | TEXT | ISO8601 (nullable) |
|
||||
|
||||
---
|
||||
|
||||
## 5. 신규 API 엔드포인트
|
||||
|
||||
### 5-1. music-lab — 시장 조사
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/music/market/trends` | 트렌드 목록 (`country`, `genre`, `source`, `days` 필터) |
|
||||
| GET | `/api/music/market/report/latest` | 최신 분석 리포트 + 추천 스타일 |
|
||||
| GET | `/api/music/market/report` | 리포트 이력 |
|
||||
| POST | `/api/music/market/ingest` | agent-office → 트렌드 데이터 수신 |
|
||||
| GET | `/api/music/market/suggest` | 트렌드 기반 제작 아이디어 추천 |
|
||||
|
||||
### 5-2. music-lab — 영상 제작
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| POST | `/api/music/video-project` | 프로젝트 생성 (`track_id`, `format`, `target_countries`) |
|
||||
| GET | `/api/music/video-projects` | 프로젝트 목록 |
|
||||
| GET | `/api/music/video-project/{id}` | 프로젝트 상세 + 렌더링 상태 |
|
||||
| POST | `/api/music/video-project/{id}/render` | 렌더링 시작 (BackgroundTask) |
|
||||
| GET | `/api/music/video-project/{id}/export` | 내보내기 패키지 (MP4 URL + metadata JSON) |
|
||||
| DELETE | `/api/music/video-project/{id}` | 프로젝트 삭제 |
|
||||
|
||||
### 5-3. music-lab — 수익화 추적
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/music/revenue` | 수익 기록 (`yt_video_id`, `year_month` 필터) |
|
||||
| POST | `/api/music/revenue` | 수익 기록 추가 |
|
||||
| PUT | `/api/music/revenue/{id}` | 수익 기록 수정 |
|
||||
| DELETE | `/api/music/revenue/{id}` | 수익 기록 삭제 |
|
||||
| GET | `/api/music/revenue/dashboard` | 총수익·RPM·장르별·국가별 집계 |
|
||||
|
||||
### 5-4. agent-office — YouTube 리서치
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| POST | `/api/agent-office/youtube/research` | 수동 리서치 트리거 (`countries` 지정 가능) |
|
||||
| GET | `/api/agent-office/youtube/research/status` | 마지막 실행 상태 + 수집 건수 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 영상 제작 파이프라인
|
||||
|
||||
### 6-1. 오디오 비주얼라이저 (`format: 'visualizer'`)
|
||||
|
||||
```
|
||||
MP3 (file_path) + 배경 이미지 (cover_images[0] 우선, 없으면 장르별 그라디언트 기본 배경)
|
||||
→ FFmpeg showwaves 필터 (1920×1080, 음파 오버레이)
|
||||
→ H.264 + AAC MP4
|
||||
→ 썸네일 추출 (5초 지점 프레임)
|
||||
→ Claude API 메타데이터 생성
|
||||
```
|
||||
|
||||
핵심 FFmpeg 명령:
|
||||
```bash
|
||||
ffmpeg -loop 1 -i cover.jpg -i audio.mp3 \
|
||||
-filter_complex \
|
||||
"[1:a]showwaves=s=1920x200:mode=cline:colors=0xFF4444[wave]; \
|
||||
[0:v][wave]overlay=0:880[out]" \
|
||||
-map "[out]" -map 1:a \
|
||||
-c:v libx264 -c:a aac -shortest output.mp4
|
||||
```
|
||||
|
||||
적합 장르: Lo-fi, Ambient, Study Music, Phonk
|
||||
|
||||
### 6-2. AI 이미지 슬라이드쇼 (`format: 'slideshow'`)
|
||||
|
||||
```
|
||||
① 키워드 추출 (genre + moods + prompt → 검색어)
|
||||
② 이미지 수집
|
||||
- Pexels API: 키워드 검색 4-6장 (무료 200req/시간)
|
||||
- Suno 커버이미지: cover_images 필드에서 1-2장
|
||||
③ 이미지당 표시 시간 = track.duration_sec / 이미지 수
|
||||
④ FFmpeg xfade 전환 (fade, 1초)
|
||||
⑤ H.264 + AAC MP4 출력
|
||||
⑥ 썸네일 추출 + Claude API 메타데이터 생성
|
||||
```
|
||||
|
||||
### 6-3. 공통 후처리
|
||||
|
||||
**Claude API 메타데이터 생성:**
|
||||
- 입력: `genre`, `moods`, `lyrics`, `target_countries`
|
||||
- 출력:
|
||||
- `yt_title`: 최대 100자, SEO 최적화, 국가 감안
|
||||
- `yt_description`: 해시태그 + 타임스탬프 + 링크 플레이스홀더
|
||||
- `yt_tags`: 10-15개, 현지어 포함 (예: 브라질 타겟 → `"música relaxante"`, `"estudo música"`)
|
||||
|
||||
**내보내기 패키지:**
|
||||
```
|
||||
/data/videos/{project_id}/
|
||||
output.mp4 ← 최종 영상
|
||||
thumbnail.jpg ← 썸네일
|
||||
metadata.json ← {title, description, tags, target_countries, category}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. YouTubeResearchAgent (agent-office)
|
||||
|
||||
**파일:** `agents/youtube.py`
|
||||
|
||||
**데이터 수집 (매일 09:00):**
|
||||
1. YouTube Data API v3 — 국가별 (`BR`, `ID`, `MX`, `US`, `KR`) 트렌딩 음악 카테고리 50개
|
||||
2. pytrends — 장르별 Google Trends 점수 (최근 7일)
|
||||
3. Billboard Hot 100 스크래핑 — 글로벌 차트 상위 20
|
||||
|
||||
**분석 → trend_reports 생성:**
|
||||
- 소스별 score 정규화 후 장르 클러스터링
|
||||
- `recommended_styles` 생성: `{genre, suno_prompt, target_countries, reason}`
|
||||
- Claude API로 `insights` 텍스트 생성
|
||||
|
||||
**push → music-lab:**
|
||||
```
|
||||
POST http://music-lab:8000/api/music/market/ingest
|
||||
body: {trends: [...], report: {...}}
|
||||
```
|
||||
|
||||
**스케줄러:**
|
||||
- 매일 09:00 — `youtube_research_job`
|
||||
- 매주 월요일 08:00 — 주간 인사이트 텔레그램 발송
|
||||
|
||||
---
|
||||
|
||||
## 8. 인프라 변경사항
|
||||
|
||||
| 대상 | 변경 내용 |
|
||||
|------|-----------|
|
||||
| `music-lab/Dockerfile` | `RUN apt-get install -y ffmpeg` 추가 |
|
||||
| `nginx/default.conf` | `/media/videos/` → `/data/videos/` 경로 추가 |
|
||||
| `music-lab/requirements.txt` | `anthropic`, `Pillow` 추가 |
|
||||
| `agent-office/requirements.txt` | `google-api-python-client`, `pytrends` 추가 |
|
||||
| `.env` | `PEXELS_API_KEY`, `YOUTUBE_DATA_API_KEY` 추가 |
|
||||
| `docker-compose.yml` | music-lab volume에 `/data/videos` 마운트 추가 |
|
||||
|
||||
**CLAUDE.md 업데이트 필요:**
|
||||
- Nginx: `/media/videos/` 경로 추가
|
||||
- music-lab API 목록에 신규 16개 추가 (시장조사 5 + 영상제작 6 + 수익화 5), agent-office 2개 추가
|
||||
- agent-office 스케줄러에 youtube_research_job 추가
|
||||
|
||||
---
|
||||
|
||||
## 9. 수익화 전략
|
||||
|
||||
### 9-1. YouTube 광고 수익 (CPM 기준)
|
||||
|
||||
| 국가 | CPM 범위 |
|
||||
|------|---------|
|
||||
| 브라질 | $1.5 ~ $4 |
|
||||
| 인도네시아 | $1.0 ~ $2.5 |
|
||||
| 미국 | $3.0 ~ $8.0 |
|
||||
| 한국 | $2.0 ~ $5.0 |
|
||||
|
||||
Lo-fi / Ambient은 긴 시청 시간 유도 → RPM 유리. 인스트루멘탈은 언어 장벽 없음.
|
||||
|
||||
### 9-2. 국가별 장르 전략
|
||||
|
||||
| 국가 | 주력 장르 |
|
||||
|------|-----------|
|
||||
| 브라질 | Funk, Phonk, Lo-fi |
|
||||
| 인도네시아 | Pop, Study Music, Lo-fi |
|
||||
| 멕시코 | Latin Pop, Reggaeton |
|
||||
| 글로벌 | Ambient, Cinematic |
|
||||
|
||||
### 9-3. 업로드 목표
|
||||
|
||||
- **주 3-5개** 영상 업로드 (시스템 안정화 후 일 1개 목표)
|
||||
- 영상 **50개** 누적 → 수익 활성화 (구독자 1,000 + 시청 4,000시간)
|
||||
- 영상 **200개** 누적 → 월 $100+ 수동 수익 목표
|
||||
|
||||
---
|
||||
|
||||
## 10. 구현 로드맵
|
||||
|
||||
### Phase 1 — 영상 제작 파이프라인 (약 2-3주)
|
||||
|
||||
**music-lab 백엔드:**
|
||||
- `video_producer.py` — FFmpeg 래퍼 (비주얼라이저 + 슬라이드쇼)
|
||||
- `market.py` — 트렌드 데이터 수신·저장·조회·추천
|
||||
- `monetization.py` — 수익화 추적 CRUD
|
||||
- DB 마이그레이션: `video_projects`, `revenue_records`
|
||||
- 신규 API 12개 (영상 제작 6 + 수익화 5 + market ingest 1)
|
||||
- Dockerfile `ffmpeg` 추가
|
||||
- Nginx `/media/videos/` 경로 추가
|
||||
|
||||
### Phase 2 — 시장 조사 자동화 (약 1-2주)
|
||||
|
||||
**agent-office:**
|
||||
- `agents/youtube.py` (YouTubeResearchAgent)
|
||||
- YouTube Data API v3 연동
|
||||
- pytrends 연동
|
||||
- Billboard 스크래핑
|
||||
- 스케줄러 등록 (매일 09:00, 매주 월요일 08:00)
|
||||
- `youtube_research_jobs` DB 테이블
|
||||
- 신규 API 2개 + agent-office API 2개
|
||||
|
||||
**music-lab:**
|
||||
- DB 마이그레이션: `market_trends`, `trend_reports`
|
||||
- 신규 API 4개 (트렌드 조회 3 + 추천 1)
|
||||
|
||||
### Phase 3 — YouTube API 자동 업로드 (채널 안정화 후)
|
||||
|
||||
- YouTube Data API OAuth 2.0 인증
|
||||
- 동영상 업로드·썸네일 설정 자동화
|
||||
- YouTube Studio 수익 데이터 자동 수집 (`source: 'youtube_api'`)
|
||||
- 텔레그램 업로드 완료 알림
|
||||
|
||||
---
|
||||
|
||||
## 11. 신규 파일 목록
|
||||
|
||||
### music-lab/app/
|
||||
- `video_producer.py` — FFmpeg 비주얼라이저·슬라이드쇼 렌더링
|
||||
- `market.py` — 시장 트렌드 수신·저장·조회·추천
|
||||
- `monetization.py` — 수익 기록 CRUD·대시보드
|
||||
|
||||
### agent-office/app/agents/
|
||||
- `youtube.py` — YouTubeResearchAgent
|
||||
|
||||
### agent-office/app/
|
||||
- `youtube_researcher.py` — YouTube/Trends/Billboard 데이터 수집 로직
|
||||
@@ -1,208 +0,0 @@
|
||||
# Music YouTube Tab Frontend — Design Spec
|
||||
|
||||
**Date:** 2026-05-01
|
||||
**Repo:** `web-page` (React + Vite SPA at `/Users/jaeohpark/development/web-page/`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
MusicStudio 페이지에 **🎯 YouTube 탭**을 추가한다. 기존 4개 탭(Create / Lyrics / Library / Remix) 옆에 하나 더 붙이며, 탭 내부에 3개의 서브탭을 둔다.
|
||||
|
||||
- **🎬 영상 제작** — 트랙 선택 → 포맷·국가 설정 → 렌더링 → 내보내기
|
||||
- **💰 수익 추적** — 수동 수익 기록 입력 + 장르별 RPM 차트 + 기록 테이블
|
||||
- **📊 시장 트렌드** — agent-office가 매일 수집한 YouTube/Trends/Billboard 데이터 표시 + AI 프롬프트 추천
|
||||
|
||||
---
|
||||
|
||||
## 2. 영향 파일
|
||||
|
||||
### 수정
|
||||
| 파일 | 변경 내용 |
|
||||
|------|-----------|
|
||||
| `src/pages/music/MusicStudio.jsx` | tab 상태에 `'youtube'` 추가, 탭 버튼 추가, YoutubeTab 렌더링 |
|
||||
| `src/api.js` | 비디오 프로젝트 / 수익 / 시장 트렌드 API 함수 추가 |
|
||||
|
||||
### 신규 생성
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `src/pages/music/components/YoutubeTab.jsx` | YouTube 탭 루트 컴포넌트 (서브탭 상태 관리) |
|
||||
| `src/pages/music/components/VideoProjectsTab.jsx` | 🎬 영상 제작 서브탭 |
|
||||
| `src/pages/music/components/RevenueTab.jsx` | 💰 수익 추적 서브탭 |
|
||||
| `src/pages/music/components/TrendsTab.jsx` | 📊 시장 트렌드 서브탭 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 컴포넌트 계층
|
||||
|
||||
```
|
||||
MusicStudio
|
||||
└── [tab === 'youtube']
|
||||
└── YoutubeTab
|
||||
├── subtab 상태: 'video' | 'revenue' | 'trends'
|
||||
├── [subtab === 'video'] → VideoProjectsTab
|
||||
├── [subtab === 'revenue'] → RevenueTab
|
||||
└── [subtab === 'trends'] → TrendsTab
|
||||
```
|
||||
|
||||
**YoutubeTab props:**
|
||||
- `library: Array` — 라이브러리 트랙 목록 (MusicStudio에서 내려줌, 트랙 선택 드롭다운용)
|
||||
- `initialTrackId?: string` — Library 탭의 "영상 만들기" 버튼 클릭 시 pre-select용
|
||||
|
||||
---
|
||||
|
||||
## 4. 서브탭 상세
|
||||
|
||||
### 4-1. VideoProjectsTab (`subtab === 'video'`)
|
||||
|
||||
**① 새 영상 만들기 패널**
|
||||
- 트랙 선택 드롭다운 (`library` prop에서 목록, `title` 표시)
|
||||
- 형식 선택: `비주얼라이저` | `슬라이드쇼` (toggle)
|
||||
- 타겟 국가 칩: BR / US / ID / MX / KR (복수 선택 가능)
|
||||
- "프로젝트 생성" 버튼 → `POST /api/music/video-project`
|
||||
|
||||
**② 영상 프로젝트 목록**
|
||||
- `GET /api/music/video-projects` 폴링 (렌더링 중인 프로젝트 있을 때 5초 간격)
|
||||
- 상태별 표시:
|
||||
- `pending` — "대기" 배지 + "▶ 렌더" 버튼 → `POST /api/music/video-project/:id/render`
|
||||
- `rendering` — "처리중" 배지 + 진행 바 (시작 시각 기준 경과 시간 표시)
|
||||
- `done` — "✓ 완료" 배지 + "↓ 내보내기" 버튼
|
||||
- `failed` — "실패" 배지 (빨간색)
|
||||
|
||||
**③ 내보내기 패키지 (done 상태 프로젝트 선택 시)**
|
||||
- `GET /api/music/video-project/:id/export` → `{mp4_url, thumbnail_url, metadata}`
|
||||
- mp4 다운로드 링크, thumbnail 다운로드 링크, metadata.json 미리보기 (title / tags / target)
|
||||
|
||||
**상태 관리:**
|
||||
```js
|
||||
const [projects, setProjects] = useState([])
|
||||
const [selectedTrackId, setSelectedTrackId] = useState(initialTrackId ?? '')
|
||||
const [format, setFormat] = useState('visualizer')
|
||||
const [countries, setCountries] = useState(['BR'])
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [exportData, setExportData] = useState(null) // 선택된 done 프로젝트의 export
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4-2. RevenueTab (`subtab === 'revenue'`)
|
||||
|
||||
**대시보드 카드 (3개)**
|
||||
- `GET /api/music/revenue/dashboard` → `{total_revenue_usd, total_views, avg_rpm}`
|
||||
- 총 수익 / 총 조회수 / 가중평균 RPM
|
||||
|
||||
**장르별 RPM 바 차트**
|
||||
- `GET /api/music/revenue` → 레코드 목록에서 장르별로 RPM 집계
|
||||
- 바 차트 (CSS 기반, 라이브러리 없음) — genre / rpm / color 매핑
|
||||
|
||||
**수익 기록 추가 폼**
|
||||
- 필드: `yt_video_id`, `record_month` (YYYY-MM), `revenue_usd`, `views`, `country`
|
||||
- "저장" → `POST /api/music/revenue`
|
||||
- 성공 시 목록 + 대시보드 리프레시
|
||||
|
||||
**수익 기록 테이블**
|
||||
- `GET /api/music/revenue` — 영상 제목 / 월 / 수익 / 조회수 / RPM
|
||||
- 행 클릭 → 수정 폼 인라인 펼침
|
||||
- 삭제 버튼 → `DELETE /api/music/revenue/:id`
|
||||
|
||||
**장르 추론:** `yt_video_id`는 자유 입력이고 장르 컬럼이 DB에 없으므로, `genre` 필드를 수익 기록 폼에 optional 셀렉트로 추가한다. DB 스키마에 이미 없으면 프론트에서만 관리하지 않고, API 명세 확인 후 처리.
|
||||
|
||||
> **참고:** `revenue_records` 테이블에 `genre` 컬럼이 없다. 차트는 `yt_video_id`별 집계만 가능. 장르별 RPM 차트는 "영상별 RPM 비교"로 레이블을 바꿔서 구현한다.
|
||||
|
||||
**상태 관리:**
|
||||
```js
|
||||
const [dashboard, setDashboard] = useState(null)
|
||||
const [records, setRecords] = useState([])
|
||||
const [form, setForm] = useState({ yt_video_id:'', record_month:'', revenue_usd:'', views:'', country:'BR' })
|
||||
const [editingId, setEditingId] = useState(null)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4-3. TrendsTab (`subtab === 'trends'`)
|
||||
|
||||
**수집 상태 바**
|
||||
- `GET /api/music/market/report/latest` → `{report_date, created_at, top_genres, recommended_styles}`
|
||||
- 마지막 수집 일시 + 트렌드 수 표시
|
||||
- "↻ 수동 수집" 버튼 → `POST /api/agent-office/youtube/research` (body: `{}`)
|
||||
|
||||
**오늘의 인기 장르 Top 5**
|
||||
- `top_genres` 배열에서 상위 5개 렌더링
|
||||
- 각 항목: 장르명 / 대상 국가 플래그 / 점수 바
|
||||
|
||||
**AI 추천 Suno 프롬프트**
|
||||
- `GET /api/music/market/suggest` → `[{genre, suno_prompt, target_countries, reason}]`
|
||||
- 카드 형태, 프롬프트 클릭 시 클립보드 복사
|
||||
|
||||
**트렌드 리포트 이력**
|
||||
- `GET /api/music/market/report` → 날짜 목록
|
||||
- 날짜 클릭 → 해당 날짜 리포트 상세 표시 (top_genres + recommended_styles)
|
||||
|
||||
**상태 관리:**
|
||||
```js
|
||||
const [latestReport, setLatestReport] = useState(null)
|
||||
const [reports, setReports] = useState([])
|
||||
const [suggestions, setSuggestions] = useState([])
|
||||
const [selectedReport, setSelectedReport] = useState(null)
|
||||
const [researching, setResearching] = useState(false)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. API 추가 목록 (`src/api.js`)
|
||||
|
||||
```js
|
||||
// 기존 api.js 헬퍼: apiGet / apiPost / apiPut / apiDelete (plain fetch 래퍼)
|
||||
|
||||
// Video Projects
|
||||
export const createVideoProject = (data) => apiPost('/api/music/video-project', data)
|
||||
export const getVideoProjects = () => apiGet('/api/music/video-projects')
|
||||
export const renderVideoProject = (id) => apiPost(`/api/music/video-project/${id}/render`)
|
||||
export const exportVideoProject = (id) => apiGet(`/api/music/video-project/${id}/export`)
|
||||
export const deleteVideoProject = (id) => apiDelete(`/api/music/video-project/${id}`)
|
||||
|
||||
// Revenue
|
||||
export const getRevenueDashboard = () => apiGet('/api/music/revenue/dashboard')
|
||||
export const getRevenueRecords = () => apiGet('/api/music/revenue')
|
||||
export const addRevenueRecord = (data) => apiPost('/api/music/revenue', data)
|
||||
export const updateRevenueRecord = (id, data) => apiPut(`/api/music/revenue/${id}`, data)
|
||||
export const deleteRevenueRecord = (id) => apiDelete(`/api/music/revenue/${id}`)
|
||||
|
||||
// Market Trends
|
||||
export const getLatestTrendReport = () => apiGet('/api/music/market/report/latest')
|
||||
export const getTrendReports = () => apiGet('/api/music/market/report')
|
||||
export const getMarketSuggestions = () => apiGet('/api/music/market/suggest')
|
||||
export const triggerYoutubeResearch = () => apiPost('/api/agent-office/youtube/research', {})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Library 탭 연동
|
||||
|
||||
`MusicStudio.jsx`의 `LibraryCard` 컴포넌트에 **"🎬 영상 만들기"** 버튼 추가:
|
||||
|
||||
```jsx
|
||||
<button onClick={() => {
|
||||
setTab('youtube')
|
||||
setInitialTrackId(track.id)
|
||||
}}>🎬 영상 만들기</button>
|
||||
```
|
||||
|
||||
`initialTrackId` 상태를 MusicStudio 루트에 두고 YoutubeTab에 prop으로 내려준다. VideoProjectsTab이 마운트되면 해당 트랙을 드롭다운에 pre-select.
|
||||
|
||||
---
|
||||
|
||||
## 7. 스타일 가이드
|
||||
|
||||
기존 MusicStudio.css의 다크 테마 변수 재사용:
|
||||
- 배경: `#111827` / `#0d1117` / `#1f2937`
|
||||
- 강조색: `#22c55e` (초록, 완료·생성), `#f59e0b` (노랑, 처리중), `#3b82f6` (파랑, 수익), `#a855f7` (보라, 트렌드)
|
||||
- 새 CSS 클래스는 `MusicStudio.css`에 추가 (별도 파일 없음)
|
||||
|
||||
---
|
||||
|
||||
## 8. 범위 외 (Out of scope)
|
||||
|
||||
- YouTube Analytics OAuth 자동 동기화 (나중에 확장)
|
||||
- 영상 업로드 자동화 (YouTube Data API write scope)
|
||||
- 차트 라이브러리 도입 (CSS 바로 구현)
|
||||
@@ -1,446 +0,0 @@
|
||||
# packs-lab 인프라 통합 + admin mint-token 설계
|
||||
|
||||
> 대상: `web-backend/packs-lab/`
|
||||
> 외부 의존: Supabase(`pack_files` 테이블) + Vercel SaaS(HMAC 호출자)
|
||||
> 후속 별도 스펙: Vercel-side admin UI / 사용자 다운로드 / cleanup cron / multi-admin
|
||||
|
||||
---
|
||||
|
||||
## 1. 목표
|
||||
|
||||
`packs-lab`은 NAS 자료 다운로드 자동화 백엔드. Synology DSM 공유 링크 발급 + 5GB 멀티파트 업로드 수신을 담당하고, Vercel SaaS와 HMAC으로 통신한다. 사용자 인증은 Vercel이 Supabase로 처리하고 본 서비스는 외부 인증을 다루지 않는다.
|
||||
|
||||
이미 코드(HMAC 미들웨어 / DSM client / 4 라우트)는 작성되어 있으나 인프라 통합 + Supabase 스키마 + admin upload 토큰 발급 흐름이 빠져 있어 운영 가능 상태가 아니다. 본 스펙은 그 갭을 메운다.
|
||||
|
||||
### 핵심 변경
|
||||
|
||||
- **신규 라우트**: `POST /api/packs/admin/mint-token` (Vercel HMAC → 일회성 업로드 토큰)
|
||||
- **Supabase DDL**: `pack_files` 테이블 + 활성·삭제 인덱스
|
||||
- **인프라**: docker-compose `packs-lab` 서비스 등록(18950) + nginx `/api/packs/` 5GB 통과 + `.env.example` 6+1 환경변수
|
||||
- **테스트**: routes 통합 + DSM client mock
|
||||
- **문서**: web-backend / workspace CLAUDE.md 5곳 갱신
|
||||
- **DELETE 라우트 docstring**: "DSM 공유 정리" 표현을 "DSM 공유 자동 만료"로 수정 (실제 동작과 일치)
|
||||
|
||||
### 변경하지 않는 것
|
||||
|
||||
- 기존 `auth.py` (`mint_upload_token` 그대로 활용)
|
||||
- 기존 `dsm_client.py`
|
||||
- 기존 `routes.py`의 sign-link / upload / list / delete 본문
|
||||
- DSM 공유 추적 테이블 — 4시간 자동 만료로 충분(브레인스토밍 결정)
|
||||
|
||||
---
|
||||
|
||||
## 2. 컴포넌트 + 통신 흐름
|
||||
|
||||
### 2.1 변경 받는 파일
|
||||
|
||||
| 영역 | 파일 | 변경 |
|
||||
|------|------|------|
|
||||
| 백엔드 | `packs-lab/app/routes.py` | DELETE docstring 수정 + admin mint-token 라우트 추가 |
|
||||
| 백엔드 | `packs-lab/app/models.py` | `MintTokenRequest`, `MintTokenResponse` 스키마 추가 |
|
||||
| 백엔드 | `packs-lab/app/auth.py` | 변경 없음 (기존 `mint_upload_token` 활용) |
|
||||
| 테스트 | `packs-lab/tests/conftest.py` (신규) | autouse `BACKEND_HMAC_SECRET` 셋팅 |
|
||||
| 테스트 | `packs-lab/tests/test_routes.py` (신규) | 5 라우트 통합 테스트 |
|
||||
| 테스트 | `packs-lab/tests/test_dsm_client.py` (신규) | DSM 7.x API mock 테스트 |
|
||||
| DB | `packs-lab/supabase/pack_files.sql` (신규) | DDL + 인덱스 |
|
||||
| 인프라 | `docker-compose.yml` | `packs-lab` 서비스 추가 |
|
||||
| 인프라 | `nginx/default.conf` | `/api/packs/` 라우팅 (`client_max_body_size 5G` + streaming) |
|
||||
| 인프라 | `.env.example` | 6+1 신규 환경변수 |
|
||||
| 문서 | `web-backend/CLAUDE.md` | 1·4·5·8·9 섹션 갱신 |
|
||||
| 문서 | `workspace/CLAUDE.md` | 컨테이너 표 한 줄 추가 |
|
||||
|
||||
### 2.2 통신 흐름
|
||||
|
||||
**ADMIN 업로드**
|
||||
|
||||
```
|
||||
Vercel admin UI ─────→ Vercel API (HMAC 헤더 추가)
|
||||
│
|
||||
▼
|
||||
POST /api/packs/admin/mint-token
|
||||
│
|
||||
backend: verify_request_hmac
|
||||
│
|
||||
mint_upload_token({tier, label, filename, size_bytes, jti, expires_at})
|
||||
│
|
||||
Vercel ←─────────────── token ──────┘
|
||||
│
|
||||
▼
|
||||
admin browser → POST /api/packs/upload
|
||||
Authorization: Bearer <token>
|
||||
multipart body (≤5GB)
|
||||
│
|
||||
backend: verify_upload_token + JTI mark
|
||||
│
|
||||
파일 저장 (PACK_BASE_DIR/{tier}/{filename})
|
||||
│
|
||||
Supabase INSERT pack_files
|
||||
```
|
||||
|
||||
**사용자 다운로드**
|
||||
|
||||
```
|
||||
사용자 → Vercel SaaS (Supabase auth + tier·결제 검증)
|
||||
│
|
||||
▼
|
||||
POST /api/packs/sign-link (HMAC + file_path)
|
||||
│
|
||||
backend: verify_request_hmac
|
||||
│
|
||||
DSM Sharing.create (4시간 만료)
|
||||
│
|
||||
사용자 ← Vercel ← 다운로드 URL (4시간 유효)
|
||||
```
|
||||
|
||||
### 2.3 기각된 대안
|
||||
|
||||
| 대안 | 기각 사유 |
|
||||
|------|-----------|
|
||||
| Vercel-side 토큰 발급 | 토큰 포맷 양쪽 분산, 변경 시 동기화 부담 |
|
||||
| admin browser → backend 직접 HMAC | admin browser에 secret 노출, 보안 약화 |
|
||||
| DSM 공유 추적 테이블 | 4시간 자동 만료로 충분, YAGNI |
|
||||
| Resumable multipart upload | 5GB는 단일 stream으로 충분, 복잡도 증가 |
|
||||
| `pack_files.min_tier`를 PostgreSQL ENUM | tier 추가 시 ALTER TYPE 번거로움. text+CHECK 채택 |
|
||||
|
||||
---
|
||||
|
||||
## 3. `POST /api/packs/admin/mint-token`
|
||||
|
||||
### 3.1 Pydantic 스키마 (`models.py` 추가)
|
||||
|
||||
```python
|
||||
class MintTokenRequest(BaseModel):
|
||||
"""Vercel → backend: admin upload 토큰 발급 요청."""
|
||||
tier: PackTier
|
||||
label: str = Field(..., max_length=200)
|
||||
filename: str = Field(..., max_length=255)
|
||||
size_bytes: int = Field(..., gt=0, le=5 * 1024 * 1024 * 1024)
|
||||
|
||||
|
||||
class MintTokenResponse(BaseModel):
|
||||
token: str
|
||||
expires_at: datetime
|
||||
jti: str
|
||||
```
|
||||
|
||||
### 3.2 라우트 본문 (`routes.py` 추가)
|
||||
|
||||
```python
|
||||
import time, uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .auth import mint_upload_token, verify_request_hmac
|
||||
from .models import MintTokenRequest, MintTokenResponse
|
||||
|
||||
UPLOAD_TOKEN_TTL_SEC = int(os.getenv("UPLOAD_TOKEN_TTL_SEC", "1800")) # 30분 default
|
||||
|
||||
@router.post("/admin/mint-token", response_model=MintTokenResponse)
|
||||
async def mint_token(
|
||||
request: Request,
|
||||
x_timestamp: str = Header(""),
|
||||
x_signature: str = Header(""),
|
||||
):
|
||||
body = await request.body()
|
||||
verify_request_hmac(body, x_timestamp, x_signature)
|
||||
payload = MintTokenRequest.model_validate_json(body)
|
||||
_check_filename(payload.filename) # upload 라우트와 동일 검증
|
||||
|
||||
jti = str(uuid.uuid4())
|
||||
expires_ts = int(time.time()) + UPLOAD_TOKEN_TTL_SEC
|
||||
token = mint_upload_token({
|
||||
"tier": payload.tier,
|
||||
"label": payload.label,
|
||||
"filename": payload.filename,
|
||||
"size_bytes": payload.size_bytes,
|
||||
"jti": jti,
|
||||
"expires_at": expires_ts,
|
||||
})
|
||||
return MintTokenResponse(
|
||||
token=token,
|
||||
expires_at=datetime.fromtimestamp(expires_ts, tz=timezone.utc),
|
||||
jti=jti,
|
||||
)
|
||||
```
|
||||
|
||||
### 3.3 결정 근거
|
||||
|
||||
| 항목 | 값 | 근거 |
|
||||
|------|-----|------|
|
||||
| TTL default | 1800s (30분) | 5GB 업로드 시작 + 진행 시간 여유. 1Gbps에서 약 40s, 50Mbps에서 약 14분 |
|
||||
| TTL env override | `UPLOAD_TOKEN_TTL_SEC` | 운영 중 조정 가능 |
|
||||
| filename 검증 | upload와 동일 (`_check_filename`) | 토큰 발급 시점에 미리 거부 → admin UI 즉시 피드백 |
|
||||
| jti 응답 포함 | yes | admin이 업로드 결과 추적용 |
|
||||
| Vercel ↔ backend | HMAC (`X-Timestamp` + `X-Signature`) | 다른 admin 라우트와 동일 패턴 |
|
||||
| admin browser ↔ backend | Bearer token (단발성 jti) | 기존 upload 라우트 그대로 |
|
||||
|
||||
### 3.4 DELETE 라우트 docstring 수정
|
||||
|
||||
`routes.py` 모듈 docstring에서:
|
||||
|
||||
```diff
|
||||
- DELETE /api/packs/{file_id} — Vercel HMAC 인증 → soft delete + DSM 공유 정리
|
||||
+ DELETE /api/packs/{file_id} — Vercel HMAC 인증 → soft delete (DSM 공유는 자동 만료)
|
||||
```
|
||||
|
||||
`delete_file` 함수에는 변경 없음.
|
||||
|
||||
---
|
||||
|
||||
## 4. Supabase `pack_files` DDL
|
||||
|
||||
**파일**: `packs-lab/supabase/pack_files.sql` (신규, 운영 배포 시 Supabase SQL editor에서 실행)
|
||||
|
||||
```sql
|
||||
-- pack_files: NAS에 저장된 다운로드 가능한 패키지 파일 메타
|
||||
create table if not exists public.pack_files (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
min_tier text not null check (min_tier in ('starter','pro','master')),
|
||||
label text not null,
|
||||
file_path text not null unique, -- NAS 절대경로, 동일 경로 중복 방지
|
||||
filename text not null,
|
||||
size_bytes bigint not null check (size_bytes > 0),
|
||||
sort_order integer not null default 0,
|
||||
uploaded_at timestamptz not null default now(),
|
||||
deleted_at timestamptz
|
||||
);
|
||||
|
||||
-- list 라우트의 hot path: deleted_at IS NULL + tier/order 정렬
|
||||
create index if not exists pack_files_active_idx
|
||||
on public.pack_files (min_tier, sort_order)
|
||||
where deleted_at is null;
|
||||
|
||||
-- soft-deleted 통계 / cleanup 잡 대비
|
||||
create index if not exists pack_files_deleted_at_idx
|
||||
on public.pack_files (deleted_at)
|
||||
where deleted_at is not null;
|
||||
```
|
||||
|
||||
### 4.1 필드 결정 근거
|
||||
|
||||
| 필드 | 타입 / 제약 | 근거 |
|
||||
|------|------------|------|
|
||||
| `id` | uuid PK + `gen_random_uuid()` default | routes.py가 client-side `uuid.uuid4()` 생성하지만 default도 둬 fallback |
|
||||
| `min_tier` | text + CHECK | enum 대신 text+CHECK가 PostgreSQL에서 더 유연 |
|
||||
| `file_path` | text NOT NULL UNIQUE | 같은 tier/filename 충돌은 파일시스템에서 잡지만 DB 레벨도 보강 |
|
||||
| `size_bytes` | bigint + CHECK > 0 | 5GB는 int 범위 안이지만 미래 대비 bigint |
|
||||
| `sort_order` | int NOT NULL default 0 | routes INSERT가 sort_order 미지정 → 0 기본 |
|
||||
| `uploaded_at` | timestamptz default now() | routes 코드가 `res.data[0]["uploaded_at"]` 그대로 응답에 사용 — DB가 채워줌 |
|
||||
| `deleted_at` | nullable | soft delete |
|
||||
|
||||
### 4.2 RLS
|
||||
|
||||
비활성. backend가 `service_role` key 사용하므로 RLS 우회. Vercel/사용자 직접 접근 없음 → unsafe 아님.
|
||||
|
||||
---
|
||||
|
||||
## 5. 인프라 통합
|
||||
|
||||
### 5.1 `docker-compose.yml` — `packs-lab` 서비스
|
||||
|
||||
```yaml
|
||||
packs-lab:
|
||||
build:
|
||||
context: ./packs-lab
|
||||
dockerfile: Dockerfile
|
||||
container_name: packs-lab
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18950:8000"
|
||||
environment:
|
||||
TZ: Asia/Seoul
|
||||
DSM_HOST: ${DSM_HOST}
|
||||
DSM_USER: ${DSM_USER}
|
||||
DSM_PASS: ${DSM_PASS}
|
||||
BACKEND_HMAC_SECRET: ${BACKEND_HMAC_SECRET}
|
||||
SUPABASE_URL: ${SUPABASE_URL}
|
||||
SUPABASE_SERVICE_KEY: ${SUPABASE_SERVICE_KEY}
|
||||
UPLOAD_TOKEN_TTL_SEC: ${UPLOAD_TOKEN_TTL_SEC:-1800}
|
||||
volumes:
|
||||
- ${PACK_DATA_PATH:-./data/packs}:/volume1/docker/webpage/media/packs
|
||||
```
|
||||
|
||||
| 결정 | 값 | 근거 |
|
||||
|------|-----|------|
|
||||
| 포트 | 18950 | 18800(realestate) → 18900(agent-office) → 18950(packs) 순차 |
|
||||
| `PACK_BASE_DIR` 마운트 | 컨테이너 경로 `/volume1/docker/webpage/media/packs` 고정 | routes.py 하드코딩 경로 |
|
||||
| `PACK_DATA_PATH` env | default `./data/packs` (로컬), NAS `.env`에 `/volume1/docker/webpage/media/packs` 명시 | 운영/로컬 분리 |
|
||||
|
||||
### 5.2 `nginx/default.conf` — `/api/packs/` 라우팅
|
||||
|
||||
```nginx
|
||||
location /api/packs/ {
|
||||
proxy_pass http://packs-lab:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 5GB 멀티파트 업로드 대응
|
||||
client_max_body_size 5G;
|
||||
proxy_request_buffering off; # 스트리밍 통과 (메모리/디스크 buffer 회피)
|
||||
proxy_read_timeout 1800s;
|
||||
proxy_send_timeout 1800s;
|
||||
}
|
||||
```
|
||||
|
||||
| 결정 | 근거 |
|
||||
|------|------|
|
||||
| `client_max_body_size 5G` | 라우트 단위 — 다른 location은 default 유지 |
|
||||
| `proxy_request_buffering off` | 5GB 파일을 nginx가 모두 받고 backend에 forward하면 ~5GB 디스크 buffer 발생 |
|
||||
| `proxy_read/send_timeout 1800s` | 30분 — 업로드 토큰 TTL과 일치, 느린 업링크에서 5GB 전송 여유 |
|
||||
|
||||
### 5.3 `.env.example` — 6+1 신규 환경변수
|
||||
|
||||
```bash
|
||||
# ─── packs-lab — NAS 자료 다운로드 자동화 ────────────────────────────
|
||||
# Synology DSM 7.x 인증 (공유 링크 발급용)
|
||||
DSM_HOST=https://gahusb.synology.me:5001
|
||||
DSM_USER=
|
||||
DSM_PASS=
|
||||
|
||||
# Vercel SaaS ↔ backend HMAC 시크릿 (양쪽 동일 값)
|
||||
BACKEND_HMAC_SECRET=
|
||||
|
||||
# Supabase pack_files 테이블 접근 (service_role 키, RLS 우회)
|
||||
SUPABASE_URL=https://<project>.supabase.co
|
||||
SUPABASE_SERVICE_KEY=
|
||||
|
||||
# admin upload 토큰 TTL (초). default 1800 = 30분
|
||||
UPLOAD_TOKEN_TTL_SEC=1800
|
||||
|
||||
# 로컬 개발: ./data/packs / NAS 운영: /volume1/docker/webpage/media/packs
|
||||
PACK_DATA_PATH=./data/packs
|
||||
```
|
||||
|
||||
### 5.4 NAS 디렉토리 준비
|
||||
|
||||
운영 첫 배포 시 SSH로 1회:
|
||||
|
||||
```bash
|
||||
mkdir -p /volume1/docker/webpage/media/packs/{starter,pro,master}
|
||||
chown -R PUID:PGID /volume1/docker/webpage/media/packs
|
||||
```
|
||||
|
||||
PUID/PGID는 `.env`의 기존 값 사용.
|
||||
|
||||
---
|
||||
|
||||
## 6. 테스트 전략
|
||||
|
||||
기존 `tests/test_auth.py` 유지. 신규 3 파일.
|
||||
|
||||
### 6.1 `tests/conftest.py` (신규)
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _hmac_secret(monkeypatch):
|
||||
"""모든 테스트에서 동일한 HMAC secret 사용."""
|
||||
monkeypatch.setenv("BACKEND_HMAC_SECRET", "test-secret-do-not-use-in-prod")
|
||||
```
|
||||
|
||||
### 6.2 `tests/test_routes.py` (신규) — 통합 테스트
|
||||
|
||||
DSM·Supabase 모두 mock. `pytest`, `monkeypatch`, `unittest.mock`, `fastapi.testclient.TestClient` 사용.
|
||||
|
||||
| 테스트 | 검증 |
|
||||
|--------|------|
|
||||
| `test_sign_link_hmac_required` | timestamp/signature 헤더 누락 → 401 |
|
||||
| `test_sign_link_outside_base_dir` | file_path가 `PACK_BASE_DIR` 외부 → 400 |
|
||||
| `test_sign_link_calls_dsm` | mock된 `create_share_link` 호출 검증, URL 응답 |
|
||||
| `test_mint_token_hmac_required` | HMAC 누락 → 401 |
|
||||
| `test_mint_token_returns_valid_token` | 발급된 token이 `verify_upload_token`으로 통과 |
|
||||
| `test_mint_token_invalid_filename` | 확장자 미허용 → 400 |
|
||||
| `test_upload_token_required` | Authorization Bearer 누락 → 401 |
|
||||
| `test_upload_size_mismatch` | 토큰 size_bytes ≠ 실제 → 400 |
|
||||
| `test_upload_jti_replay` | 같은 토큰 두 번 → 두 번째 409 |
|
||||
| `test_list_returns_active_only` | mock supabase 응답에서 deleted_at NULL만 반환 |
|
||||
| `test_delete_soft_deletes` | mock supabase update에 deleted_at ISO timestamp 들어감 |
|
||||
|
||||
### 6.3 `tests/test_dsm_client.py` (신규)
|
||||
|
||||
httpx mock(`respx` 또는 `MockTransport`) 또는 `monkeypatch.setattr` 패치.
|
||||
|
||||
| 테스트 | 검증 |
|
||||
|--------|------|
|
||||
| `test_create_share_link_login_logout` | login → Sharing.create → logout 순서 |
|
||||
| `test_create_share_link_returns_url_and_expiry` | 응답 파싱 |
|
||||
| `test_dsm_login_failure_raises` | login API success=false → DSMError |
|
||||
| `test_dsm_share_failure_logs_out` | Sharing.create 실패해도 logout 호출 (try/finally) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 문서 갱신
|
||||
|
||||
### 7.1 `web-backend/CLAUDE.md` — 5곳
|
||||
|
||||
**1. 1.프로젝트 개요**
|
||||
|
||||
```diff
|
||||
- 서비스: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, personal, deployer (9개)
|
||||
+ 서비스: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, personal, packs-lab, deployer (10개)
|
||||
```
|
||||
|
||||
**2. 4.Docker 서비스 표** — 신규 행
|
||||
|
||||
```
|
||||
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (DSM 공유 링크 + 5GB 업로드, Vercel SaaS와 HMAC 통신) |
|
||||
```
|
||||
|
||||
**3. 5.Nginx 라우팅 표** — 신규 행
|
||||
|
||||
```
|
||||
| `/api/packs/` | `packs-lab:8000` | 5GB 업로드 (`client_max_body_size 5G` + `proxy_request_buffering off`) |
|
||||
```
|
||||
|
||||
**4. 8.로컬 개발 표** — 신규 행
|
||||
|
||||
```
|
||||
| Packs Lab | http://localhost:18950 |
|
||||
```
|
||||
|
||||
**5. 9.서비스별** — `### packs-lab (packs-lab/)` 신규 섹션
|
||||
|
||||
내용:
|
||||
- 용도 (NAS DSM 공유링크 + 5GB 업로드 + Vercel HMAC, 사용자 인증은 Vercel이 Supabase로 처리)
|
||||
- 환경변수 6+1개
|
||||
- DB는 외부 Supabase `pack_files` (DDL은 `packs-lab/supabase/pack_files.sql`)
|
||||
- 파일 구조: `main.py`, `auth.py`, `dsm_client.py`, `routes.py`, `models.py`
|
||||
- API 표 5개:
|
||||
- `POST /api/packs/sign-link` (Vercel HMAC → DSM Sharing.create)
|
||||
- `POST /api/packs/admin/mint-token` (Vercel HMAC → upload 토큰)
|
||||
- `POST /api/packs/upload` (Bearer token → multipart 5GB)
|
||||
- `GET /api/packs/list` (Vercel HMAC → 활성 파일 목록)
|
||||
- `DELETE /api/packs/{file_id}` (Vercel HMAC → soft delete)
|
||||
|
||||
### 7.2 `workspace/CLAUDE.md`
|
||||
|
||||
컨테이너 표에 한 줄 추가:
|
||||
|
||||
```
|
||||
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (Vercel SaaS와 HMAC 통신) |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 스코프
|
||||
|
||||
### 본 spec 범위
|
||||
|
||||
- ✅ admin mint-token 라우트 신설
|
||||
- ✅ Supabase `pack_files` DDL
|
||||
- ✅ docker-compose / nginx / .env.example / NAS 디렉토리 마운트
|
||||
- ✅ tests (auth 유지 + routes 통합 + dsm_client mock)
|
||||
- ✅ CLAUDE.md 2곳 갱신
|
||||
- ✅ DELETE 라우트 docstring 수정
|
||||
|
||||
### 후속 별도 spec
|
||||
|
||||
- ❌ Vercel SaaS-side admin UI / 사용자 다운로드 UI / Supabase pricing & user 테이블
|
||||
- ❌ DSM 공유 추적 (즉시 차단 필요시)
|
||||
- ❌ deleted_at + N일 후 실제 파일 삭제 cron
|
||||
- ❌ multi-admin 토큰 발급 권한 분리
|
||||
- ❌ resumable multipart 업로드 (5GB tus 등)
|
||||
- ❌ pack_files sort_order 편집 endpoint (admin UI 단계)
|
||||
- ❌ monitoring (업로드 실패율, DSM API latency)
|
||||
Reference in New Issue
Block a user