Compare commits
6 Commits
feat/bugfi
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6674755800 | |||
| d919c75ea7 | |||
| 3a71c91eeb | |||
| 9d0e9aa8aa | |||
| d9c39a0206 | |||
| 0f73b6b07d |
@@ -124,5 +124,6 @@ PACK_DATA_PATH=./data/packs
|
|||||||
PACK_BASE_DIR=/app/data/packs
|
PACK_BASE_DIR=/app/data/packs
|
||||||
|
|
||||||
# DSM·Supabase에 노출되는 NAS 호스트 절대경로 (PACK_DATA_PATH와 같은 디렉토리를 호스트 시점에서 가리킴).
|
# DSM·Supabase에 노출되는 NAS 호스트 절대경로 (PACK_DATA_PATH와 같은 디렉토리를 호스트 시점에서 가리킴).
|
||||||
# 운영 NAS는 반드시 /volume1/docker/webpage/media/packs 같은 절대경로 설정. 미설정 시 PACK_DATA_PATH로 fallback (로컬 개발용).
|
# 운영 NAS는 반드시 /volume1/docker/webpage/media/packs 같은 절대경로 설정.
|
||||||
PACK_HOST_DIR=/volume1/docker/webpage/media/packs
|
# 미설정 시 PACK_DATA_PATH로 fallback (로컬 개발용).
|
||||||
|
PACK_HOST_DIR=/docker/webpage/media/packs
|
||||||
|
|||||||
8
.gitignore
vendored
@@ -66,3 +66,11 @@ temp/
|
|||||||
|
|
||||||
# Git worktrees
|
# Git worktrees
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
|
||||||
|
################################
|
||||||
|
# Local working files
|
||||||
|
################################
|
||||||
|
# Superpowers 스킬 캐시·세션 메타
|
||||||
|
.superpowers/
|
||||||
|
# 임시 코드 리뷰 노트 (작업 끝나면 폐기 또는 docs/로 이동)
|
||||||
|
CODE_REVIEW.md
|
||||||
|
|||||||
126
README.md
@@ -1,7 +1,7 @@
|
|||||||
# web-backend
|
# web-backend
|
||||||
|
|
||||||
Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
|
Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
|
||||||
로또 분석, 주식 포트폴리오, AI 음악 생성, 블로그 마케팅, 부동산 청약, AI 에이전트 오피스, 여행 앨범을 하나의 Docker Compose 스택으로 운영한다.
|
로또 분석, 주식 포트폴리오, AI 음악 생성, 인스타 카드 피드, 부동산 청약, AI 에이전트 오피스, 여행 앨범, 개인 서비스(포트폴리오·블로그·투두), NAS 자료 다운로드 자동화를 하나의 Docker Compose 스택으로 운영한다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -9,33 +9,37 @@ Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
|
|||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────────┐
|
||||||
│ lotto-frontend (Nginx:8080) │
|
│ frontend (Nginx:8080) │
|
||||||
│ ├── 정적 SPA 서빙 (React + Vite) │
|
│ ├── 정적 SPA 서빙 (React + Vite) │
|
||||||
│ └── API 리버스 프록시 │
|
│ └── API 리버스 프록시 │
|
||||||
│ ├── /api/ → lotto-backend:8000 (로또·블로그·투두)│
|
│ ├── /api/ → lotto:8000 (로또) │
|
||||||
│ ├── /api/stock/, /trade/ → stock:8000 │
|
│ ├── /api/stock/, /trade/ → stock:8000 │
|
||||||
│ ├── /api/portfolio → stock:8000 │
|
│ ├── /api/portfolio → stock:8000 │
|
||||||
│ ├── /api/music/ → music-lab:8000 │
|
│ ├── /api/music/ → music-lab:8000 │
|
||||||
│ ├── /api/blog-marketing/ → blog-lab:8000 │
|
│ ├── /api/insta/ → insta-lab:8000 │
|
||||||
│ ├── /api/realestate/ → realestate-lab:8000 │
|
│ ├── /api/realestate/ → realestate-lab:8000 │
|
||||||
│ ├── /api/agent-office/ → agent-office:8000 (+ WebSocket) │
|
│ ├── /api/agent-office/ → agent-office:8000 (+ WebSocket) │
|
||||||
│ ├── /api/travel/ → travel-proxy:8000 │
|
│ ├── /api/profile/, /todos, /blog/ → personal:8000 │
|
||||||
│ ├── /media/music/… (nginx 직접 서빙, 생성 오디오) │
|
│ ├── /api/packs/ → packs-lab:8000 (HMAC + 5GB upload) │
|
||||||
|
│ ├── /api/travel/ → travel-proxy:8000 │
|
||||||
|
│ ├── /media/music/, /media/videos/ (nginx 직접 서빙, 미디어) │
|
||||||
│ ├── /media/travel/… (nginx 직접 서빙, 사진/썸네일) │
|
│ ├── /media/travel/… (nginx 직접 서빙, 사진/썸네일) │
|
||||||
│ └── /webhook → deployer:9000 │
|
│ └── /webhook → deployer:9000 │
|
||||||
└──────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
| 컨테이너 | 포트 | 역할 |
|
| 컨테이너 | 포트 | 역할 |
|
||||||
|---------|------|------|
|
|---------|------|------|
|
||||||
| `lotto-backend` | 18000 | 로또 데이터 수집·분석·추천 + 블로그·투두 API |
|
| `lotto` | 18000 | 로또 데이터 수집·분석·추천 API |
|
||||||
| `stock` | 18500 | 주식 뉴스·AI 요약·KIS 실계좌·포트폴리오·자산 추적 |
|
| `stock` | 18500 | 주식 뉴스·AI 요약·KIS 실계좌·포트폴리오·자산 추적 |
|
||||||
| `music-lab` | 18600 | AI 음악 생성 (Suno + 로컬 MusicGen 듀얼 프로바이더) |
|
| `music-lab` | 18600 | AI 음악 생성 (Suno + 로컬 MusicGen 듀얼 프로바이더) + YouTube 수익화 |
|
||||||
| `blog-lab` | 18700 | 블로그 마케팅 수익화 (키워드→글 생성→리뷰→발행) |
|
| `insta-lab` | 18700 | 인스타 카드 피드 자동 생성 (뉴스→키워드→10페이지 카드, Playwright) |
|
||||||
| `realestate-lab` | 18800 | 청약 공고 자동 수집·프로필 매칭 |
|
| `realestate-lab` | 18800 | 청약 공고 자동 수집·5티어 매칭·신규 매칭 push |
|
||||||
| `agent-office` | 18900 | AI 에이전트 가상 오피스 (WebSocket + 텔레그램 봇) |
|
| `agent-office` | 18900 | AI 에이전트 가상 오피스 (WebSocket + 텔레그램 봇) |
|
||||||
|
| `personal` | 18850 | 개인 서비스 — 포트폴리오·블로그·투두 통합 |
|
||||||
|
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (DSM 공유 링크 + 5GB 청크 업로드) |
|
||||||
| `travel-proxy` | 19000 | 여행 사진 API + 온디맨드 썸네일 |
|
| `travel-proxy` | 19000 | 여행 사진 API + 온디맨드 썸네일 |
|
||||||
| `lotto-frontend` | 8080 | SPA 서빙 + 리버스 프록시 |
|
| `frontend` | 8080 | SPA 서빙 + 리버스 프록시 |
|
||||||
| `webpage-deployer` | 19010 | Gitea Webhook → 자동 배포 |
|
| `webpage-deployer` | 19010 | Gitea Webhook → 자동 배포 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -44,12 +48,14 @@ Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
|
|||||||
|
|
||||||
```
|
```
|
||||||
web-backend/
|
web-backend/
|
||||||
├── backend/ # lotto-backend (로또·블로그·투두)
|
├── lotto/ # 로또 추천·통계·시뮬레이션
|
||||||
├── stock/ # 주식·포트폴리오
|
├── stock/ # 주식·포트폴리오·KIS 연동
|
||||||
├── music-lab/ # AI 음악 생성
|
├── music-lab/ # AI 음악 생성 + YouTube 수익화
|
||||||
├── blog-lab/ # 블로그 마케팅 파이프라인
|
├── insta-lab/ # 인스타 카드 피드 자동 생성 (Playwright)
|
||||||
├── realestate-lab/ # 청약 자동 수집·매칭
|
├── realestate-lab/ # 청약 자동 수집·5티어 매칭
|
||||||
├── agent-office/ # AI 에이전트 오피스 (WS + 텔레그램)
|
├── agent-office/ # AI 에이전트 오피스 (WS + 텔레그램)
|
||||||
|
├── personal/ # 포트폴리오·블로그·투두 통합
|
||||||
|
├── packs-lab/ # NAS 자료 다운로드 자동화 (HMAC + Supabase)
|
||||||
├── travel-proxy/ # 여행 사진 + 썸네일
|
├── travel-proxy/ # 여행 사진 + 썸네일
|
||||||
├── deployer/ # Gitea Webhook 수신 → 자동 배포
|
├── deployer/ # Gitea Webhook 수신 → 자동 배포
|
||||||
├── nginx/default.conf # 리버스 프록시 + SPA + 캐시
|
├── nginx/default.conf # 리버스 프록시 + SPA + 캐시
|
||||||
@@ -74,12 +80,14 @@ curl http://localhost:18500/health
|
|||||||
| 서비스 | 로컬 URL |
|
| 서비스 | 로컬 URL |
|
||||||
|--------|----------|
|
|--------|----------|
|
||||||
| Frontend + API | http://localhost:8080 |
|
| Frontend + API | http://localhost:8080 |
|
||||||
| lotto-backend | http://localhost:18000 |
|
| lotto | http://localhost:18000 |
|
||||||
| stock | http://localhost:18500 |
|
| stock | http://localhost:18500 |
|
||||||
| music-lab | http://localhost:18600 |
|
| music-lab | http://localhost:18600 |
|
||||||
| blog-lab | http://localhost:18700 |
|
| insta-lab | http://localhost:18700 |
|
||||||
| realestate-lab | http://localhost:18800 |
|
| realestate-lab | http://localhost:18800 |
|
||||||
|
| personal | http://localhost:18850 |
|
||||||
| agent-office | http://localhost:18900 |
|
| agent-office | http://localhost:18900 |
|
||||||
|
| packs-lab | http://localhost:18950 |
|
||||||
| travel-proxy | http://localhost:19000 |
|
| travel-proxy | http://localhost:19000 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -123,20 +131,23 @@ curl http://localhost:18500/health
|
|||||||
- **라이브러리**: 생성 파일은 `/app/data/music/`에 저장되고 Nginx가 `/media/music/`으로 직접 서빙
|
- **라이브러리**: 생성 파일은 `/app/data/music/`에 저장되고 Nginx가 `/media/music/`으로 직접 서빙
|
||||||
- **가사 도구**: 저장·편집·타임스탬프 기반 가라오케 동기
|
- **가사 도구**: 저장·편집·타임스탬프 기반 가라오케 동기
|
||||||
|
|
||||||
### 4. blog-lab (`/api/blog-marketing/`)
|
### 4. insta-lab (`/api/insta/`)
|
||||||
|
|
||||||
블로그 마케팅 수익화 4단계 파이프라인 (`draft → marketed → reviewed → published`).
|
인스타그램 카드 피드 자동 생성 — 뉴스 모니터링 → 키워드 추출 → 10페이지 카드 카피·PNG 렌더 → 텔레그램 푸시 → 사용자 수동 업로드.
|
||||||
|
|
||||||
```
|
```
|
||||||
리서치(Naver Search + 상위 블로그 본문 크롤링)
|
NAVER 뉴스 + YouTube 인기 (외부 트렌드)
|
||||||
→ 작가(AI 초안 생성)
|
→ 카테고리별 빈도 + Claude Haiku 정제 → 트렌딩 키워드
|
||||||
→ 마케터(전환율 강화 + 브랜드 링크 삽입)
|
→ 사용자가 키워드 선택
|
||||||
→ 평가자(6기준×10점, 42/60 통과 시 published)
|
→ Claude Sonnet으로 10페이지 카피 추론 (커버 1 + 본문 8 + CTA 1)
|
||||||
|
→ Jinja2 + Playwright 1080×1350 PNG 10장 렌더
|
||||||
|
→ 텔레그램 미디어 그룹 + 추천 캡션·해시태그
|
||||||
```
|
```
|
||||||
|
|
||||||
- **AI 엔진**: Claude API (`claude-sonnet-4-20250514`)
|
- **AI 엔진**: Claude Sonnet (카피) + Claude Haiku (키워드 분류)
|
||||||
- **키워드 분석**: 네이버 검색(블로그+쇼핑) API + 경쟁도/기회 점수
|
- **데이터 소스**: NAVER 뉴스 검색 + YouTube Data API v3 mostPopular(KR)
|
||||||
- **수익 추적**: 포스트별 월간 클릭/구매/수익 기록
|
- **카테고리 가중치**: 사용자가 economy/psychology/celebrity 등 카테고리별 가중치 설정 → 자동 추출 비율에 반영
|
||||||
|
- **카드 디자인**: `insta-lab/app/templates/default/card.html.j2` — 사용자가 자유 수정 (Tailwind 등)
|
||||||
- **프롬프트 템플릿**: DB에 저장 → 코드 배포 없이 수정 가능
|
- **프롬프트 템플릿**: DB에 저장 → 코드 배포 없이 수정 가능
|
||||||
|
|
||||||
### 5. realestate-lab (`/api/realestate/`)
|
### 5. realestate-lab (`/api/realestate/`)
|
||||||
@@ -152,7 +163,7 @@ curl http://localhost:18500/health
|
|||||||
|
|
||||||
AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 4명의 에이전트가 실제 작업을 수행한다.
|
AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 4명의 에이전트가 실제 작업을 수행한다.
|
||||||
|
|
||||||
- **아키텍처**: stock / music-lab / blog-lab / realestate-lab 기존 API를 서비스 프록시로 호출 (직접 DB 접근 없음)
|
- **아키텍처**: stock / music-lab / insta-lab / realestate-lab 기존 API를 서비스 프록시로 호출 (직접 DB 접근 없음)
|
||||||
- **FSM 상태**: `idle → working → waiting(승인 대기) → reporting → break`
|
- **FSM 상태**: `idle → working → waiting(승인 대기) → reporting → break`
|
||||||
- **실시간 동기화**: WebSocket `/api/agent-office/ws` (init, agent_state, task_complete, command_result)
|
- **실시간 동기화**: WebSocket `/api/agent-office/ws` (init, agent_state, task_complete, command_result)
|
||||||
- **텔레그램 연동**: 양방향 알림 + 인라인 키보드 승인
|
- **텔레그램 연동**: 양방향 알림 + 인라인 키보드 승인
|
||||||
@@ -165,22 +176,28 @@ AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 4명의 에
|
|||||||
|---------|--------|-----|----------|
|
|---------|--------|-----|----------|
|
||||||
| 📈 **주식 트레이더** (`stock`) | 08:00 매일 | — | 뉴스 요약 (LLM) → 텔레그램 아침 브리핑, 종목 알람 등록 |
|
| 📈 **주식 트레이더** (`stock`) | 08:00 매일 | — | 뉴스 요약 (LLM) → 텔레그램 아침 브리핑, 종목 알람 등록 |
|
||||||
| 🎵 **음악 프로듀서** (`music`) | 수동 트리거 | ✅ 작곡 | 프롬프트 수신 → 승인 → Suno API 작곡 → 트랙 푸시 |
|
| 🎵 **음악 프로듀서** (`music`) | 수동 트리거 | ✅ 작곡 | 프롬프트 수신 → 승인 → Suno API 작곡 → 트랙 푸시 |
|
||||||
| ✍️ **블로그 마케터** (`blog`) | 10:00 매일 | ✅ 발행 | 트렌드 키워드 1개 선택 → 리서치→작가→마케터→평가 자동 실행 → 점수·본문을 텔레그램 승인 요청 → 승인 시 `published` 전환, 거절 시 재생성 |
|
| 🎴 **인스타 큐레이터** (`insta`) | 09:00 / 09:30 매일 | — | 09:00 외부 트렌드(NAVER + YouTube) 수집 → 09:30 가중치 기반 키워드 추출 → 텔레그램 후보 5개씩 카테고리당 인라인 버튼 푸시 → 사용자 선택 시 카드 10장 미디어 그룹 |
|
||||||
| 🏢 **청약 애널리스트** (`realestate`) | 09:15 매일 | — | realestate-lab 수집 트리거 → 신규 매칭 상위 5건 + 대시보드 요약을 텔레그램 리포트 (읽음 처리 자동) |
|
| 🏢 **청약 애널리스트** (`realestate`) | realestate-lab push trigger | — | realestate-lab이 신규 매칭 발견 시 push → 인라인 [북마크] 버튼 포함 텔레그램 알림 |
|
||||||
|
| 🎬 **YouTube 리서처** (`youtube`) | 09:00 매일 | — | 한국 YouTube 트렌딩 + Google Trends + Billboard → music-lab market_trends push |
|
||||||
|
|
||||||
#### 에이전트별 명령
|
#### 에이전트별 명령
|
||||||
|
|
||||||
**Stock** — `fetch_news`, `list_alerts`, `add_alert`, `test_telegram`
|
**Stock** — `fetch_news`, `list_alerts`, `add_alert`, `test_telegram`
|
||||||
**Music** — `compose` (승인 필요), `credits`
|
**Music** — `compose` (승인 필요), `credits`
|
||||||
**Blog** — `research {keyword}`, `add_trend_keyword`, `list_trend_keywords`
|
**Insta** — `extract`, `render <keyword_id>`, `collect_trends`
|
||||||
**Realestate** — `fetch_matches`, `dashboard`
|
**Realestate** — `fetch_matches`, `dashboard`
|
||||||
|
**YouTube** — `research {countries: [...]}`
|
||||||
|
|
||||||
#### 스케줄러 잡
|
#### 스케줄러 잡
|
||||||
|
|
||||||
- 07:00 월요일 — Lotto: AI 큐레이터 브리핑 (5세트 + 내러티브)
|
- 07:00 월요일 — Lotto: AI 큐레이터 브리핑 (5세트 + 내러티브)
|
||||||
- 07:30 — Stock: 뉴스 요약
|
- 07:30 — Stock: 뉴스 요약
|
||||||
- 09:15 — Realestate: 매칭 리포트
|
- 08:00 평일 — Stock: AI 뉴스 sentiment 분석
|
||||||
- 10:00 — Blog: 자동 파이프라인 (리서치→생성→리뷰→승인 대기)
|
- 09:00 — YouTube: 한국 트렌딩 수집
|
||||||
|
- 09:00 — Insta: 외부 트렌드 수집 (NAVER 인기 + YouTube mostPopular)
|
||||||
|
- 09:30 — Insta: 키워드 추출 (가중치 적용) + 텔레그램 후보 푸시
|
||||||
|
- 15:40 평일 — Stock: 총 자산 스냅샷
|
||||||
|
- 16:30 평일 — Stock: 스크리너 실행
|
||||||
- 60초 interval — 유휴 에이전트 휴식 체크
|
- 60초 interval — 유휴 에이전트 휴식 체크
|
||||||
|
|
||||||
### 7. travel-proxy (`/api/travel/`)
|
### 7. travel-proxy (`/api/travel/`)
|
||||||
@@ -265,13 +282,15 @@ git push → Gitea → X-Gitea-Signature (HMAC SHA256)
|
|||||||
|
|
||||||
| DB | 소유 서비스 | 주요 테이블 |
|
| DB | 소유 서비스 | 주요 테이블 |
|
||||||
|----|------------|-----------|
|
|----|------------|-----------|
|
||||||
| `lotto.db` | lotto-backend | draws, recommendations, simulation_runs/candidates, best_picks, purchase_history, strategy_performance/weights, weekly_reports, lotto_briefings, todos, blog_posts |
|
| `lotto.db` | lotto | draws, recommendations, simulation_runs/candidates, best_picks, purchase_history, strategy_performance/weights, weekly_reports, lotto_briefings |
|
||||||
| `stock.db` | stock | articles, portfolio, broker_cash, asset_snapshots, sell_history |
|
| `stock.db` | stock | articles, portfolio, broker_cash, asset_snapshots, sell_history |
|
||||||
| `music.db` | music-lab | music_tasks, music_library (provider, lyrics, image_url, suno_id, file_hash, cover_images, wav_url, video_url, stem_urls) |
|
| `music.db` | music-lab | music_tasks, music_library (provider, lyrics, image_url, suno_id, file_hash, cover_images, wav_url, video_url, stem_urls), video_projects, revenue_records, market_trends, trend_reports |
|
||||||
| `blog_marketing.db` | blog-lab | keyword_analyses, blog_posts, brand_links, commissions, generation_tasks, prompt_templates |
|
| `insta.db` | insta-lab | news_articles, trending_keywords (source 컬럼), card_slates, card_assets, generation_tasks, prompt_templates, account_preferences |
|
||||||
| `realestate.db` | realestate-lab | announcements, announcement_models, user_profile, match_results, collect_log |
|
| `realestate.db` | realestate-lab | announcements, announcement_models, user_profile, match_results, collect_log |
|
||||||
| `agent_office.db` | agent-office | agent_config, agent_tasks, agent_logs, telegram_state, conversation_messages |
|
| `agent_office.db` | agent-office | agent_config, agent_tasks, agent_logs, telegram_state, conversation_messages |
|
||||||
|
| `personal.db` | personal | profile, careers, projects, skills, introductions, todos, blog_posts |
|
||||||
| `travel.db` | travel-proxy | photos (album, filename, mtime, has_thumb), album_covers |
|
| `travel.db` | travel-proxy | photos (album, filename, mtime, has_thumb), album_covers |
|
||||||
|
| `pack_files` (외부 Supabase) | packs-lab | filename, host_path, mime, byte_size, sha256, deleted_at |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -292,33 +311,50 @@ PGID=1000
|
|||||||
WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
|
WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
|
||||||
WEBHOOK_SECRET=your_secret_here
|
WEBHOOK_SECRET=your_secret_here
|
||||||
|
|
||||||
# LLM (stock, blog-lab, agent-office 공통)
|
# LLM (stock, insta-lab, agent-office 공통)
|
||||||
ANTHROPIC_API_KEY=sk-ant-...
|
ANTHROPIC_API_KEY=sk-ant-...
|
||||||
ANTHROPIC_MODEL=claude-haiku-4-5-20251001
|
ANTHROPIC_MODEL=claude-haiku-4-5-20251001
|
||||||
LLM_PROVIDER=claude # claude | ollama
|
LLM_PROVIDER=claude # claude | ollama
|
||||||
OLLAMA_URL=http://192.168.45.59:11435
|
OLLAMA_URL=http://192.168.45.59:11435
|
||||||
OLLAMA_MODEL=qwen3:14b
|
OLLAMA_MODEL=qwen3:14b
|
||||||
|
|
||||||
|
# stock admin protection (CODE_REVIEW F2)
|
||||||
|
ADMIN_API_KEY=
|
||||||
|
ALLOW_UNAUTHENTICATED_ADMIN=false
|
||||||
|
|
||||||
# music-lab
|
# music-lab
|
||||||
SUNO_API_KEY=
|
SUNO_API_KEY=
|
||||||
MUSIC_AI_SERVER_URL=
|
MUSIC_AI_SERVER_URL=
|
||||||
MUSIC_MEDIA_BASE=/media/music
|
MUSIC_MEDIA_BASE=/media/music
|
||||||
|
|
||||||
# blog-lab
|
# insta-lab + agent-office (NAVER 검색 + YouTube Data API 공유)
|
||||||
NAVER_CLIENT_ID=
|
NAVER_CLIENT_ID=
|
||||||
NAVER_CLIENT_SECRET=
|
NAVER_CLIENT_SECRET=
|
||||||
|
YOUTUBE_DATA_API_KEY=
|
||||||
|
|
||||||
# realestate-lab
|
# realestate-lab
|
||||||
DATA_GO_KR_API_KEY=
|
DATA_GO_KR_API_KEY=
|
||||||
|
|
||||||
|
# packs-lab (DSM + Supabase)
|
||||||
|
DSM_HOST=
|
||||||
|
DSM_USER=
|
||||||
|
DSM_PASS=
|
||||||
|
BACKEND_HMAC_SECRET=
|
||||||
|
SUPABASE_URL=
|
||||||
|
SUPABASE_SERVICE_KEY=
|
||||||
|
PACK_HOST_DIR=/docker/webpage/media/packs # shared folder 시점 (CLAUDE.md F5)
|
||||||
|
|
||||||
# agent-office
|
# agent-office
|
||||||
TELEGRAM_BOT_TOKEN=
|
TELEGRAM_BOT_TOKEN=
|
||||||
TELEGRAM_CHAT_ID=
|
TELEGRAM_CHAT_ID=
|
||||||
TELEGRAM_WEBHOOK_URL=
|
TELEGRAM_WEBHOOK_URL=
|
||||||
STOCK_URL=http://stock:8000
|
STOCK_URL=http://stock:8000
|
||||||
MUSIC_LAB_URL=http://music-lab:8000
|
MUSIC_LAB_URL=http://music-lab:8000
|
||||||
BLOG_LAB_URL=http://blog-lab:8000
|
INSTA_LAB_URL=http://insta-lab:8000
|
||||||
REALESTATE_LAB_URL=http://realestate-lab:8000
|
REALESTATE_LAB_URL=http://realestate-lab:8000
|
||||||
|
|
||||||
|
# personal (포트폴리오 편집 인증)
|
||||||
|
PORTFOLIO_EDIT_PASSWORD=
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
34
STATUS.md
@@ -1,40 +1,42 @@
|
|||||||
# web-backend — 구현 현황 & 로드맵
|
# web-backend — 구현 현황 & 로드맵
|
||||||
|
|
||||||
> 최종 갱신: 2026-05-07
|
> 최종 갱신: 2026-05-17
|
||||||
> 자세한 서비스·환경변수·DB 표는 [CLAUDE.md](./CLAUDE.md), 설계는 `docs/superpowers/specs/`, 실행 계획은 `docs/superpowers/plans/` 참조.
|
> 자세한 서비스·환경변수·DB 표는 [CLAUDE.md](./CLAUDE.md), 설계는 `docs/superpowers/specs/`, 실행 계획은 `docs/superpowers/plans/` 참조.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 서비스 구현 현황
|
## 1. 서비스 구현 현황
|
||||||
|
|
||||||
### 1-1. 운영 중인 컨테이너 (10개)
|
### 1-1. 운영 중인 컨테이너 (11개)
|
||||||
|
|
||||||
| 서비스 | 포트 | 상태 | 핵심 기능 |
|
| 서비스 | 포트 | 상태 | 핵심 기능 |
|
||||||
|--------|------|------|-----------|
|
|--------|------|------|-----------|
|
||||||
| `lotto-backend` | 18000 | ✅ | 로또 추천·통계·리포트·구매내역 + 블로그·투두 |
|
| `lotto` | 18000 | ✅ | 로또 추천·통계·리포트·구매내역·AI 큐레이터 |
|
||||||
| `stock` | 18500 | ✅ | 주식 뉴스·지수·트레이딩·포트폴리오·자산 스냅샷 |
|
| `stock` | 18500 | ✅ | 주식 뉴스·지수·트레이딩·포트폴리오·자산 스냅샷·스크리너 |
|
||||||
| `music-lab` | 18600 | ✅ | Suno + MusicGen + YouTube 수익화 + 컴파일 |
|
| `music-lab` | 18600 | ✅ | Suno + MusicGen + YouTube 수익화 + 컴파일 |
|
||||||
| `blog-lab` | 18700 | ✅ | 블로그 마케팅 수익화 파이프라인 |
|
| `insta-lab` | 18700 | ✅ | 인스타 카드 피드 자동 생성 (NAVER + YouTube 트렌드 → 10페이지 카드, Playwright) |
|
||||||
| `realestate-lab` | 18800 | ✅ | 청약 수집·5티어 매칭·매칭 알림 |
|
| `realestate-lab` | 18800 | ✅ | 청약 수집·5티어 매칭·매칭 알림 push |
|
||||||
| `agent-office` | 18900 | ✅ | AI 에이전트 (WebSocket + 텔레그램 + YouTubeResearcher) |
|
| `personal` | 18850 | ✅ | 포트폴리오·블로그·투두 통합 (개인 서비스) |
|
||||||
| `packs-lab` | 18950 | ✅ | NAS 자료 다운로드 자동화 (HMAC + Supabase) — 2026-05-05 |
|
| `agent-office` | 18900 | ✅ | AI 에이전트 (WebSocket + 텔레그램 + InstaAgent + YouTubeResearcher) |
|
||||||
|
| `packs-lab` | 18950 | ✅ | NAS 자료 다운로드 자동화 (HMAC + Supabase + 5GB chunked upload) |
|
||||||
| `travel-proxy` | 19000 | ✅ | 여행 사진 API + 썸네일 + 지역 관리 |
|
| `travel-proxy` | 19000 | ✅ | 여행 사진 API + 썸네일 + 지역 관리 |
|
||||||
| `nginx` | 8080 | ✅ | SPA + 리버스 프록시 (5GB body limit) |
|
| `frontend` (nginx) | 8080 | ✅ | SPA + 리버스 프록시 (5GB body limit, 인스타 라우팅 포함) |
|
||||||
| `webpage-deployer` | 19010 | ✅ | Gitea Webhook 자동 배포 |
|
| `webpage-deployer` | 19010 | ✅ | Gitea Webhook 자동 배포 (BUILDKIT timeout 600s, healthcheck via docker inspect) |
|
||||||
|
|
||||||
### 1-2. 최근 큰 작업 (2026-04 ~ 05)
|
### 1-2. 최근 큰 작업 (2026-05)
|
||||||
|
|
||||||
| 시기 | 영역 | 핵심 |
|
| 시기 | 영역 | 핵심 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
|
| 2026-05-17 | 보안 / 정합성 | CODE_REVIEW F1 (packs-lab path traversal `startswith→relative_to`) + F2 (stock admin auth 503 거부) + F4 (portfolio total_buy 수량 곱산) |
|
||||||
|
| 2026-05-17 | insta-lab | Google Trends API 폐기 대응 → YouTube Data API v3로 source 교체. trend_collector 재작성 |
|
||||||
|
| 2026-05-16 | insta-lab | Trends 탭 추가 — 외부 트렌드 수집 (NAVER 인기 + YouTube) + 카테고리 가중치 (`account_preferences`) + 가중치 기반 키워드 추출 |
|
||||||
|
| 2026-05-15 | insta-lab | blog-lab 폐기 → insta-lab 신설. 뉴스 모니터링 → 키워드 추출 → 10페이지 카드 카피·PNG → 텔레그램 푸시 → 수동 인스타 업로드 파이프라인 |
|
||||||
| 2026-05-05 | packs-lab | sign-link / upload / list / delete + admin mint-token + 5GB nginx body limit + Supabase DDL |
|
| 2026-05-05 | packs-lab | sign-link / upload / list / delete + admin mint-token + 5GB nginx body limit + Supabase DDL |
|
||||||
| 2026-05-01~06 | music-lab | YouTube 수익화 백엔드 (market_trends·trend_reports DB + 5개 API) + 다중 트랙 FFmpeg concat MP4 |
|
| 2026-05-01~06 | music-lab | YouTube 수익화 백엔드 (market_trends·trend_reports DB + 5개 API) + 다중 트랙 FFmpeg concat MP4 |
|
||||||
| 2026-04-28 | realestate-lab | targeting enhancement (5티어 매칭·5축 점수·알림 대상 카운트) |
|
| 2026-04-28 | realestate-lab | targeting enhancement (5티어 매칭·5축 점수·알림 대상 카운트, realestate-lab push → agent-office RealestateAgent) |
|
||||||
| 2026-04-27 | personal | personal 서비스 분리 마이그레이션 (블로그·투두·포트폴리오 인증) |
|
| 2026-04-27 | personal | personal 서비스 분리 마이그레이션 (블로그·투두·포트폴리오 인증) |
|
||||||
| 2026-04-27 | agent-office | v2 — youtube_researcher (YouTube API + pytrends + Billboard) + 알림 |
|
| 2026-04-27 | agent-office | v2 — youtube_researcher (YouTube API + pytrends + Billboard) + 알림 |
|
||||||
| 2026-04-24 | travel-proxy | 갤러리 리디자인 + 성능 개선 (썸네일/페이지네이션) |
|
| 2026-04-15 | lotto | AI 큐레이터 (Claude 기반 주간 브리핑 자동 생성) |
|
||||||
| 2026-04-15 | lotto-backend | AI 큐레이터 (Claude 기반 주간 브리핑 자동 생성) |
|
|
||||||
| 2026-04-08 | music-lab | Suno enhancement + MusicGen 통합 |
|
|
||||||
| 2026-04-06 | blog-lab | 마케팅 파이프라인 (research → generate → market → review) |
|
|
||||||
|
|
||||||
### 1-3. 인프라 / DX
|
### 1-3. 인프라 / DX
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
## ⚠️ 변경 이력
|
||||||
|
|
||||||
|
- **2026-05-17**: 본문에 `google_trends` source로 기재된 모든 task와 코드 블록은 **실제 구현에서 `youtube_trending`으로 교체됨**. Google Trends 비공식 endpoint(RSS + dailytrends JSON 양쪽) 모두 404 폐기 확인. YouTube Data API v3 mostPopular로 source 대체 + pytrends 의존성 제거. 운영 코드는 현재 `youtube_trending` 사용 중. 이 plan을 다시 실행할 일이 있으면 본문의 `google_trends` 단어를 `youtube_trending`으로 읽어달라. 자세한 사유와 교체 체크리스트는 `feedback_external_data_sources.md`.
|
||||||
|
|
||||||
**Goal:** Add a "Trends" tab to the Insta page that pulls external trends from NAVER popular + Google Trends, lets the user set category weights, and feeds those weights back into the daily keyword extraction pipeline.
|
**Goal:** Add a "Trends" tab to the Insta page that pulls external trends from NAVER popular + Google Trends, lets the user set category weights, and feeds those weights back into the daily keyword extraction pipeline.
|
||||||
|
|
||||||
**Architecture:** New `trend_collector` module in insta-lab (NAVER `news.json` 인기순 + `pytrends` Google Trends + Claude Haiku 카테고리 분류 + in-memory 캐시). Existing `trending_keywords` table gets a `source` column; new `account_preferences` table stores category weights. `keyword_extractor` gains a `extract_with_weights()` variant. InstaAgent runs a new 09:00 cron for trend collection and applies weights at 09:30 extraction. web-ui splits InstaCards into Cards/Trends tabs and adds 3 panels (AccountFocus / ExternalTrends / PreferenceImpact).
|
**Architecture:** New `trend_collector` module in insta-lab (NAVER `news.json` 인기순 + `pytrends` Google Trends + Claude Haiku 카테고리 분류 + in-memory 캐시). Existing `trending_keywords` table gets a `source` column; new `account_preferences` table stores category weights. `keyword_extractor` gains a `extract_with_weights()` variant. InstaAgent runs a new 09:00 cron for trend collection and applies weights at 09:30 extraction. web-ui splits InstaCards into Cards/Trends tabs and adds 3 panels (AccountFocus / ExternalTrends / PreferenceImpact).
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
상태: 사용자 승인 대기 → writing-plans 진입 예정
|
상태: 사용자 승인 대기 → writing-plans 진입 예정
|
||||||
연관 문서: `2026-05-15-insta-agent-design.md` (insta-lab 기본 설계)
|
연관 문서: `2026-05-15-insta-agent-design.md` (insta-lab 기본 설계)
|
||||||
|
|
||||||
|
## ⚠️ 변경 이력
|
||||||
|
|
||||||
|
- **2026-05-17**: 본문에 `google_trends` source로 기재된 모든 항목은 **실제 구현에서 `youtube_trending`으로 교체됨**. Google Trends 비공식 endpoint 두 가지(`trendingsearches/daily/rss?geo=KR`, `/trends/api/dailytrends?...`)가 모두 404로 폐기되어 운영 호출이 빈 결과로 끝나는 문제 확인 → YouTube Data API v3 `videos.list?chart=mostPopular®ionCode=KR`로 source 대체. 이후 spec 본문을 읽을 때는 `google_trends` → `youtube_trending`, "Google Trends" → "YouTube 인기"로 치환 해석. 사유와 source 교체 시 동시 갱신 체크리스트: `feedback_external_data_sources.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 목적·배경
|
## 1. 목적·배경
|
||||||
|
|||||||
BIN
insta-lab/app/templates/minimal/pages/insta_card_checklist.png
Normal file
|
After Width: | Height: | Size: 1010 KiB |
BIN
insta-lab/app/templates/minimal/pages/insta_card_cta.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
insta-lab/app/templates/minimal/pages/insta_card_finish.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
insta-lab/app/templates/minimal/pages/insta_card_highlight.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
insta-lab/app/templates/minimal/pages/insta_card_keyword.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
insta-lab/app/templates/minimal/pages/insta_card_memo.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
insta-lab/app/templates/minimal/pages/insta_card_observation.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
insta-lab/app/templates/minimal/pages/insta_card_oneline.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
insta-lab/app/templates/minimal/pages/insta_card_start.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
insta-lab/app/templates/minimal/pages/insta_card_study.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
@@ -142,6 +142,7 @@ KB증권·삼성증권 등 Open API 미제공 증권사용.
|
|||||||
"name": "삼성전자",
|
"name": "삼성전자",
|
||||||
"quantity": 100,
|
"quantity": 100,
|
||||||
"avg_price": 72000,
|
"avg_price": 72000,
|
||||||
|
"purchase_price": 72000,
|
||||||
"current_price": 74500,
|
"current_price": 74500,
|
||||||
"price_session": "NXT_AFTER",
|
"price_session": "NXT_AFTER",
|
||||||
"price_as_of": "2026-05-11T19:21:40+09:00",
|
"price_as_of": "2026-05-11T19:21:40+09:00",
|
||||||
@@ -159,6 +160,10 @@ KB증권·삼성증권 등 Open API 미제공 증권사용.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **`purchase_price` 필드**: 종목별 매입 단가(1주당). 사용자가 수동 등록한 매입가가
|
||||||
|
> 평균단가(`avg_price`)와 다를 때 표시용으로 분리한다. 미설정 시 `avg_price`로 폴백.
|
||||||
|
> `summary.total_buy = SUM(purchase_price × quantity)` (CODE_REVIEW F4에서 명세 정합화).
|
||||||
|
|
||||||
> **주의**: 현재가 조회에 실패한 종목은 `current_price`, `eval_amount`, `profit_amount`, `profit_rate` 가 `null`로 반환됩니다.
|
> **주의**: 현재가 조회에 실패한 종목은 `current_price`, `eval_amount`, `profit_amount`, `profit_rate` 가 `null`로 반환됩니다.
|
||||||
> 프론트에서 `null` 체크 후 `"조회 실패"` 등으로 표시해 주세요.
|
> 프론트에서 `null` 체크 후 `"조회 실패"` 등으로 표시해 주세요.
|
||||||
|
|
||||||
|
|||||||
@@ -354,11 +354,11 @@ def get_portfolio():
|
|||||||
price_session = detail["session"] if detail else None
|
price_session = detail["session"] if detail else None
|
||||||
price_as_of = detail["as_of"] if detail else None
|
price_as_of = detail["as_of"] if detail else None
|
||||||
# avg_price: 평균단가 — 손익(평가금액 - 매입원가) 계산 기준
|
# avg_price: 평균단가 — 손익(평가금액 - 매입원가) 계산 기준
|
||||||
# purchase_price: 매입가 — 총 매입 금액 표시 기준 (없으면 avg_price로 폴백)
|
# purchase_price: 매입 단가(1주당) — 없으면 avg_price로 폴백 (CODE_REVIEW F4)
|
||||||
purchase_price = item.get("purchase_price") if item.get("purchase_price") is not None else item["avg_price"]
|
purchase_price = item.get("purchase_price") if item.get("purchase_price") is not None else item["avg_price"]
|
||||||
cost_basis = item["avg_price"] * item["quantity"]
|
cost_basis = item["avg_price"] * item["quantity"]
|
||||||
# 총 매입 금액 표시는 종목별 매입가의 단순 합계 (수량 미곱산)
|
# 총 매입 금액 = 단가 × 보유 수량. API_SPEC.md 예시(qty 100·avg 72000 → 7,200,000)와 일치
|
||||||
buy_amount = purchase_price
|
buy_amount = purchase_price * item["quantity"]
|
||||||
eval_amount = current_price * item["quantity"] if current_price is not None else None
|
eval_amount = current_price * item["quantity"] if current_price is not None else None
|
||||||
profit_amount = (eval_amount - cost_basis) if eval_amount is not None else None
|
profit_amount = (eval_amount - cost_basis) if eval_amount is not None else None
|
||||||
profit_rate = round((profit_amount / cost_basis) * 100, 2) if (profit_amount is not None and cost_basis) else None
|
profit_rate = round((profit_amount / cost_basis) * 100, 2) if (profit_amount is not None and cost_basis) else None
|
||||||
|
|||||||
77
stock/tests/test_portfolio_total_buy.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"""포트폴리오 /api/portfolio 응답의 total_buy 계산 회귀 테스트 (CODE_REVIEW F4).
|
||||||
|
|
||||||
|
purchase_price는 종목별 단가(1주당) 의미. total_buy = SUM(purchase_price × quantity).
|
||||||
|
purchase_price가 없으면 avg_price로 폴백 후 동일하게 수량 곱산.
|
||||||
|
"""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_db_setup(monkeypatch, items, cash=None):
|
||||||
|
from app import main as stock_main
|
||||||
|
monkeypatch.setattr(stock_main, "get_all_portfolio", lambda: items)
|
||||||
|
monkeypatch.setattr(stock_main, "get_all_broker_cash", lambda: cash or [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_portfolio_total_buy_uses_purchase_price_times_quantity(monkeypatch):
|
||||||
|
"""purchase_price 설정 시: total_buy = purchase_price × quantity 의 합."""
|
||||||
|
items = [
|
||||||
|
{"id": 1, "broker": "KB", "ticker": "005930", "name": "삼성전자",
|
||||||
|
"quantity": 100, "avg_price": 72000, "purchase_price": 70000},
|
||||||
|
]
|
||||||
|
fake_prices = {"005930": {"price": 74500, "session": "REGULAR", "as_of": "2026-05-17T10:00:00+09:00"}}
|
||||||
|
_fake_db_setup(monkeypatch, items)
|
||||||
|
from app import main as stock_main
|
||||||
|
monkeypatch.setattr(stock_main, "get_current_prices_detail", lambda t: fake_prices)
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.get("/api/portfolio")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
# purchase_price=70000 × quantity=100 = 7,000,000
|
||||||
|
assert data["summary"]["total_buy"] == 7_000_000
|
||||||
|
|
||||||
|
|
||||||
|
def test_portfolio_total_buy_falls_back_to_avg_price_with_quantity(monkeypatch):
|
||||||
|
"""purchase_price 미설정 시: avg_price 폴백 + 수량 곱산. API_SPEC 예시와 일치."""
|
||||||
|
items = [
|
||||||
|
{"id": 1, "broker": "KB", "ticker": "005930", "name": "삼성전자",
|
||||||
|
"quantity": 100, "avg_price": 72000, "purchase_price": None},
|
||||||
|
]
|
||||||
|
fake_prices = {"005930": {"price": 74500, "session": "REGULAR", "as_of": "2026-05-17T10:00:00+09:00"}}
|
||||||
|
_fake_db_setup(monkeypatch, items)
|
||||||
|
from app import main as stock_main
|
||||||
|
monkeypatch.setattr(stock_main, "get_current_prices_detail", lambda t: fake_prices)
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.get("/api/portfolio")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
# avg_price=72000 × quantity=100 = 7,200,000 (API_SPEC.md 예시와 일치)
|
||||||
|
assert data["summary"]["total_buy"] == 7_200_000
|
||||||
|
|
||||||
|
|
||||||
|
def test_portfolio_total_buy_sums_multiple_holdings(monkeypatch):
|
||||||
|
"""여러 종목 합산도 단가 × 수량 합."""
|
||||||
|
items = [
|
||||||
|
{"id": 1, "broker": "KB", "ticker": "005930", "name": "삼성전자",
|
||||||
|
"quantity": 100, "avg_price": 70000, "purchase_price": 70000},
|
||||||
|
{"id": 2, "broker": "NH", "ticker": "000660", "name": "SK하이닉스",
|
||||||
|
"quantity": 50, "avg_price": 130000, "purchase_price": 130000},
|
||||||
|
]
|
||||||
|
fake_prices = {
|
||||||
|
"005930": {"price": 74500, "session": "REGULAR", "as_of": "2026-05-17T10:00:00+09:00"},
|
||||||
|
"000660": {"price": 140000, "session": "REGULAR", "as_of": "2026-05-17T10:00:00+09:00"},
|
||||||
|
}
|
||||||
|
_fake_db_setup(monkeypatch, items)
|
||||||
|
from app import main as stock_main
|
||||||
|
monkeypatch.setattr(stock_main, "get_current_prices_detail", lambda t: fake_prices)
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.get("/api/portfolio")
|
||||||
|
data = resp.json()
|
||||||
|
# 70000*100 + 130000*50 = 7,000,000 + 6,500,000 = 13,500,000
|
||||||
|
assert data["summary"]["total_buy"] == 13_500_000
|
||||||