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:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,681 +0,0 @@
|
||||
# Travel-Proxy 성능 개선 구현 계획
|
||||
|
||||
> **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.
|
||||
|
||||
**Goal:** travel-proxy의 os.scandir 기반 아키텍처를 SQLite 인덱스 DB로 전환하고, 앨범 커버 지정 + 썸네일 사전 생성을 지원한다.
|
||||
|
||||
**Architecture:** 기존 main.py의 스캔/캐시/썸네일 로직을 db.py(스키마+쿼리)와 indexer.py(동기화+썸네일)로 분리. main.py는 라우트만 담당. DB 경로는 `/data/thumbs/travel.db`.
|
||||
|
||||
**Tech Stack:** Python 3.12, FastAPI, SQLite (표준 라이브러리 sqlite3), Pillow
|
||||
|
||||
---
|
||||
|
||||
### 파일 구조
|
||||
|
||||
| 파일 | 역할 | 상태 |
|
||||
|------|------|------|
|
||||
| `travel-proxy/app/db.py` | SQLite 스키마 정의, 커넥션 헬퍼, 쿼리 함수 | 신규 |
|
||||
| `travel-proxy/app/indexer.py` | 폴더 스캔 → DB 동기화 + 썸네일 일괄 생성 | 신규 |
|
||||
| `travel-proxy/app/main.py` | FastAPI 라우트 (기존 수정 + 신규 추가) | 수정 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: db.py — SQLite 스키마 및 쿼리 헬퍼
|
||||
|
||||
**Files:**
|
||||
- Create: `travel-proxy/app/db.py`
|
||||
|
||||
- [ ] **Step 1: db.py 파일 생성**
|
||||
|
||||
```python
|
||||
import os
|
||||
import sqlite3
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
DB_PATH = os.getenv("TRAVEL_DB_PATH", "/data/thumbs/travel.db")
|
||||
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS 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 DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
UNIQUE(album, filename)
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_photos_album ON photos(album)")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS album_covers (
|
||||
album TEXT PRIMARY KEY,
|
||||
filename TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
def get_photos_by_region(albums: List[str], page: int, size: int) -> Dict[str, Any]:
|
||||
"""region에 속한 앨범들의 사진을 페이지네이션하여 반환."""
|
||||
if not albums:
|
||||
return {"items": [], "total": 0, "has_next": False, "matched_albums": []}
|
||||
|
||||
placeholders = ",".join("?" for _ in albums)
|
||||
|
||||
with _conn() as conn:
|
||||
# 앨범별 사진 수
|
||||
rows = conn.execute(
|
||||
f"SELECT album, COUNT(*) as cnt FROM photos WHERE album IN ({placeholders}) GROUP BY album",
|
||||
albums,
|
||||
).fetchall()
|
||||
matched_albums = [{"album": r["album"], "count": r["cnt"]} for r in rows]
|
||||
|
||||
# 전체 수
|
||||
total_row = conn.execute(
|
||||
f"SELECT COUNT(*) as cnt FROM photos WHERE album IN ({placeholders})",
|
||||
albums,
|
||||
).fetchone()
|
||||
total = total_row["cnt"]
|
||||
|
||||
# 페이지네이션
|
||||
offset = (page - 1) * size
|
||||
items = conn.execute(
|
||||
f"""SELECT album, filename, mtime FROM photos
|
||||
WHERE album IN ({placeholders})
|
||||
ORDER BY album, filename
|
||||
LIMIT ? OFFSET ?""",
|
||||
[*albums, size, offset],
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"items": [dict(r) for r in items],
|
||||
"total": total,
|
||||
"has_next": (offset + size) < total,
|
||||
"matched_albums": matched_albums,
|
||||
}
|
||||
|
||||
|
||||
def get_all_albums() -> List[Dict[str, Any]]:
|
||||
"""전체 앨범 목록 + 사진 수 + 커버 정보."""
|
||||
with _conn() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT p.album, COUNT(*) as count,
|
||||
COALESCE(c.filename, MIN(p.filename)) as cover_filename
|
||||
FROM photos p
|
||||
LEFT JOIN album_covers c ON p.album = c.album
|
||||
GROUP BY p.album
|
||||
ORDER BY p.album
|
||||
""").fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def set_album_cover(album: str, filename: str) -> bool:
|
||||
"""앨범 커버 지정. 해당 photo가 존재하면 True, 없으면 False."""
|
||||
with _conn() as conn:
|
||||
exists = conn.execute(
|
||||
"SELECT 1 FROM photos WHERE album = ? AND filename = ?",
|
||||
(album, filename),
|
||||
).fetchone()
|
||||
if not exists:
|
||||
return False
|
||||
|
||||
conn.execute(
|
||||
"""INSERT INTO album_covers (album, filename, updated_at)
|
||||
VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
ON CONFLICT(album) DO UPDATE SET
|
||||
filename = excluded.filename,
|
||||
updated_at = excluded.updated_at""",
|
||||
(album, filename),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def get_album_cover(album: str) -> Optional[str]:
|
||||
"""앨범 커버 파일명 반환. 미지정 시 None."""
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT filename FROM album_covers WHERE album = ?",
|
||||
(album,),
|
||||
).fetchone()
|
||||
return row["filename"] if row else None
|
||||
|
||||
|
||||
def upsert_photo(album: str, filename: str, mtime: float) -> str:
|
||||
"""사진 upsert. 반환: 'added' | 'updated' | 'unchanged'."""
|
||||
with _conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT mtime, has_thumb FROM photos WHERE album = ? AND filename = ?",
|
||||
(album, filename),
|
||||
).fetchone()
|
||||
|
||||
if not existing:
|
||||
conn.execute(
|
||||
"INSERT INTO photos (album, filename, mtime, has_thumb) VALUES (?, ?, ?, 0)",
|
||||
(album, filename, mtime),
|
||||
)
|
||||
return "added"
|
||||
elif existing["mtime"] != mtime:
|
||||
conn.execute(
|
||||
"UPDATE photos SET mtime = ?, has_thumb = 0 WHERE album = ? AND filename = ?",
|
||||
(mtime, album, filename),
|
||||
)
|
||||
return "updated"
|
||||
return "unchanged"
|
||||
|
||||
|
||||
def remove_missing_photos(album: str, existing_filenames: set) -> int:
|
||||
"""폴더에 없는 사진을 DB에서 제거. 제거 수 반환."""
|
||||
with _conn() as conn:
|
||||
db_rows = conn.execute(
|
||||
"SELECT filename FROM photos WHERE album = ?", (album,)
|
||||
).fetchall()
|
||||
db_filenames = {r["filename"] for r in db_rows}
|
||||
to_remove = db_filenames - existing_filenames
|
||||
|
||||
if to_remove:
|
||||
placeholders = ",".join("?" for _ in to_remove)
|
||||
conn.execute(
|
||||
f"DELETE FROM photos WHERE album = ? AND filename IN ({placeholders})",
|
||||
[album, *to_remove],
|
||||
)
|
||||
# 삭제된 파일이 커버였으면 커버도 제거
|
||||
conn.execute(
|
||||
f"DELETE FROM album_covers WHERE album = ? AND filename IN ({placeholders})",
|
||||
[album, *to_remove],
|
||||
)
|
||||
return len(to_remove)
|
||||
|
||||
|
||||
def get_photos_without_thumb() -> List[Dict[str, str]]:
|
||||
"""썸네일 미생성 사진 목록."""
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT album, filename FROM photos WHERE has_thumb = 0"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def mark_thumb_done(album: str, filename: str) -> None:
|
||||
"""썸네일 생성 완료 표시."""
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE photos SET has_thumb = 1 WHERE album = ? AND filename = ?",
|
||||
(album, filename),
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 커밋**
|
||||
|
||||
```bash
|
||||
git add travel-proxy/app/db.py
|
||||
git commit -m "feat(travel-proxy): db.py — SQLite 스키마 + 쿼리 헬퍼"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: indexer.py — 폴더 동기화 + 썸네일 일괄 생성
|
||||
|
||||
**Files:**
|
||||
- Create: `travel-proxy/app/indexer.py`
|
||||
|
||||
- [ ] **Step 1: indexer.py 파일 생성**
|
||||
|
||||
기존 main.py의 `ensure_thumb` 로직(라인 105-144)과 `scan_album` 로직(라인 146-166)을 기반으로 작성. `IMAGE_EXT`, `THUMB_SIZE`, 경로 상수는 main.py에서 import.
|
||||
|
||||
```python
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Set
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from . import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
IMAGE_EXT = {".jpg", ".jpeg", ".png", ".webp"}
|
||||
THUMB_SIZE = (480, 480)
|
||||
|
||||
|
||||
def _scan_folder(folder: Path) -> List[Dict[str, Any]]:
|
||||
"""폴더 내 이미지 파일 목록 수집 (os.scandir)."""
|
||||
if not folder.exists():
|
||||
return []
|
||||
items = []
|
||||
with os.scandir(folder) as entries:
|
||||
for entry in entries:
|
||||
if entry.is_file() and Path(entry.name).suffix.lower() in IMAGE_EXT:
|
||||
items.append({
|
||||
"filename": entry.name,
|
||||
"mtime": entry.stat().st_mtime,
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
def _generate_thumb(src: Path, dest: Path) -> bool:
|
||||
"""원본에서 480x480 썸네일 생성. 성공 시 True."""
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = dest.with_name(dest.stem + ".tmp" + dest.suffix)
|
||||
try:
|
||||
with Image.open(src) as im:
|
||||
im.thumbnail(THUMB_SIZE)
|
||||
ext = dest.suffix.lower()
|
||||
if ext in (".jpg", ".jpeg"):
|
||||
fmt = "JPEG"
|
||||
elif ext == ".png":
|
||||
fmt = "PNG"
|
||||
elif ext == ".webp":
|
||||
fmt = "WEBP"
|
||||
else:
|
||||
fmt = (im.format or "").upper() or "JPEG"
|
||||
im.save(tmp, format=fmt, quality=85, optimize=True)
|
||||
tmp.replace(dest)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("Thumb generation failed: %s → %s", src, e)
|
||||
try:
|
||||
if tmp.exists():
|
||||
tmp.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def sync(
|
||||
travel_root: Path,
|
||||
thumb_root: Path,
|
||||
region_map_path: Path,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
폴더 스캔 → DB 동기화 + 썸네일 일괄 생성.
|
||||
|
||||
Returns:
|
||||
{"added": int, "removed": int, "thumbs_generated": int, "duration_sec": float}
|
||||
"""
|
||||
start = time.time()
|
||||
|
||||
# 1. region_map.json에서 전체 앨범 폴더 수집
|
||||
with open(region_map_path, "r", encoding="utf-8") as f:
|
||||
region_map = json.load(f)
|
||||
|
||||
all_albums: Set[str] = set()
|
||||
for v in region_map.values():
|
||||
if isinstance(v, list):
|
||||
all_albums.update(v)
|
||||
elif isinstance(v, dict) and isinstance(v.get("albums"), list):
|
||||
all_albums.update(v["albums"])
|
||||
|
||||
# 2. 각 앨범 폴더 스캔 → DB 동기화
|
||||
added = 0
|
||||
removed = 0
|
||||
|
||||
for album in sorted(all_albums):
|
||||
folder = travel_root / album
|
||||
items = _scan_folder(folder)
|
||||
existing_filenames = set()
|
||||
|
||||
for item in items:
|
||||
existing_filenames.add(item["filename"])
|
||||
result = db.upsert_photo(album, item["filename"], item["mtime"])
|
||||
if result == "added":
|
||||
added += 1
|
||||
|
||||
removed += db.remove_missing_photos(album, existing_filenames)
|
||||
|
||||
# 3. 썸네일 미생성 분 일괄 생성
|
||||
no_thumb = db.get_photos_without_thumb()
|
||||
thumbs_generated = 0
|
||||
|
||||
for photo in no_thumb:
|
||||
src = travel_root / photo["album"] / photo["filename"]
|
||||
dest = thumb_root / photo["album"] / photo["filename"]
|
||||
if _generate_thumb(src, dest):
|
||||
db.mark_thumb_done(photo["album"], photo["filename"])
|
||||
thumbs_generated += 1
|
||||
|
||||
duration = round(time.time() - start, 2)
|
||||
logger.info(
|
||||
"Sync complete: added=%d removed=%d thumbs=%d duration=%.2fs",
|
||||
added, removed, thumbs_generated, duration,
|
||||
)
|
||||
|
||||
return {
|
||||
"added": added,
|
||||
"removed": removed,
|
||||
"thumbs_generated": thumbs_generated,
|
||||
"duration_sec": duration,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 커밋**
|
||||
|
||||
```bash
|
||||
git add travel-proxy/app/indexer.py
|
||||
git commit -m "feat(travel-proxy): indexer.py — 폴더 동기화 + 썸네일 일괄 생성"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: main.py 리팩토링 — DB 기반 photos API + 캐시 제거
|
||||
|
||||
**Files:**
|
||||
- Modify: `travel-proxy/app/main.py`
|
||||
|
||||
이 Task에서 main.py의 메모리 캐시, `scan_album()`, 기존 `photos()` 라우트를 DB 기반으로 교체한다.
|
||||
|
||||
- [ ] **Step 1: main.py를 DB 기반으로 재작성**
|
||||
|
||||
main.py 전체를 아래로 교체:
|
||||
|
||||
```python
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, List
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from PIL import Image
|
||||
|
||||
from .db import init_db, get_photos_by_region, get_all_albums, set_album_cover, get_album_cover
|
||||
from .indexer import sync
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# -----------------------------
|
||||
# Env / Paths
|
||||
# -----------------------------
|
||||
ROOT = Path(os.getenv("TRAVEL_ROOT", "/data/travel")).resolve()
|
||||
MEDIA_BASE = os.getenv("TRAVEL_MEDIA_BASE", "/media/travel")
|
||||
|
||||
META_DIR = ROOT / "_meta"
|
||||
REGION_MAP_PATH = META_DIR / "region_map.json"
|
||||
REGIONS_GEOJSON_PATH = META_DIR / "regions.geojson"
|
||||
|
||||
THUMB_ROOT = Path(os.getenv("TRAVEL_THUMB_ROOT", "/data/thumbs")).resolve()
|
||||
THUMB_SIZE = (480, 480)
|
||||
|
||||
THUMB_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
IMAGE_EXT = {".jpg", ".jpeg", ".png", ".webp"}
|
||||
|
||||
# -----------------------------
|
||||
# DB init
|
||||
# -----------------------------
|
||||
init_db()
|
||||
|
||||
# -----------------------------
|
||||
# Helpers
|
||||
# -----------------------------
|
||||
def _read_json(path: Path) -> Any:
|
||||
if not path.exists():
|
||||
raise HTTPException(500, f"Missing required file: {path}")
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def load_region_map() -> dict:
|
||||
return _read_json(REGION_MAP_PATH)
|
||||
|
||||
|
||||
def load_regions_geojson() -> dict:
|
||||
return _read_json(REGIONS_GEOJSON_PATH)
|
||||
|
||||
|
||||
def _get_albums_for_region(region: str, region_map: dict) -> List[str]:
|
||||
if region not in region_map:
|
||||
raise HTTPException(400, "Unknown region")
|
||||
v = region_map[region]
|
||||
if isinstance(v, list):
|
||||
return v
|
||||
if isinstance(v, dict) and isinstance(v.get("albums"), list):
|
||||
return v["albums"]
|
||||
raise HTTPException(500, "Invalid region_map format")
|
||||
|
||||
|
||||
def _ensure_thumb_fallback(src: Path, album: str) -> Path:
|
||||
"""온디맨드 썸네일 폴백 (sync 누락 분 대응)."""
|
||||
out = THUMB_ROOT / album / src.name
|
||||
if out.exists():
|
||||
return out
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = out.with_name(out.stem + ".tmp" + out.suffix)
|
||||
try:
|
||||
with Image.open(src) as im:
|
||||
im.thumbnail(THUMB_SIZE)
|
||||
ext = out.suffix.lower()
|
||||
if ext in (".jpg", ".jpeg"):
|
||||
fmt = "JPEG"
|
||||
elif ext == ".png":
|
||||
fmt = "PNG"
|
||||
elif ext == ".webp":
|
||||
fmt = "WEBP"
|
||||
else:
|
||||
fmt = (im.format or "").upper() or "JPEG"
|
||||
im.save(tmp, format=fmt, quality=85, optimize=True)
|
||||
tmp.replace(out)
|
||||
return out
|
||||
finally:
|
||||
try:
|
||||
if tmp.exists():
|
||||
tmp.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# Models
|
||||
# -----------------------------
|
||||
class CoverRequest(BaseModel):
|
||||
filename: str
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# Routes
|
||||
# -----------------------------
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "healthy", "service": "travel-proxy"}
|
||||
|
||||
|
||||
@app.get("/api/travel/regions")
|
||||
def regions():
|
||||
return load_regions_geojson()
|
||||
|
||||
|
||||
@app.get("/api/travel/photos")
|
||||
def photos(
|
||||
region: str = Query(...),
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
):
|
||||
region_map = load_region_map()
|
||||
albums = _get_albums_for_region(region, region_map)
|
||||
result = get_photos_by_region(albums, page, size)
|
||||
|
||||
# URL 조합 (DB에는 경로를 저장하지 않음)
|
||||
items = []
|
||||
for row in result["items"]:
|
||||
items.append({
|
||||
"album": row["album"],
|
||||
"file": row["filename"],
|
||||
"url": f"{MEDIA_BASE}/{row['album']}/{row['filename']}",
|
||||
"thumb": f"{MEDIA_BASE}/.thumb/{row['album']}/{row['filename']}",
|
||||
"mtime": row["mtime"],
|
||||
})
|
||||
|
||||
return {
|
||||
"region": region,
|
||||
"page": page,
|
||||
"size": size,
|
||||
"total": result["total"],
|
||||
"has_next": result["has_next"],
|
||||
"items": items,
|
||||
"matched_albums": result["matched_albums"],
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/travel/sync")
|
||||
def sync_endpoint():
|
||||
result = sync(
|
||||
travel_root=ROOT,
|
||||
thumb_root=THUMB_ROOT,
|
||||
region_map_path=REGION_MAP_PATH,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/travel/albums")
|
||||
def albums_list():
|
||||
rows = get_all_albums()
|
||||
result = []
|
||||
for r in rows:
|
||||
cover = r["cover_filename"]
|
||||
result.append({
|
||||
"album": r["album"],
|
||||
"count": r["count"],
|
||||
"cover_url": f"{MEDIA_BASE}/{r['album']}/{cover}",
|
||||
"cover_thumb": f"{MEDIA_BASE}/.thumb/{r['album']}/{cover}",
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
@app.put("/api/travel/albums/{album}/cover")
|
||||
def set_cover(album: str, body: CoverRequest):
|
||||
ok = set_album_cover(album, body.filename)
|
||||
if not ok:
|
||||
raise HTTPException(404, f"Photo not found: {album}/{body.filename}")
|
||||
return {
|
||||
"album": album,
|
||||
"filename": body.filename,
|
||||
"cover_url": f"{MEDIA_BASE}/{album}/{body.filename}",
|
||||
"cover_thumb": f"{MEDIA_BASE}/.thumb/{album}/{body.filename}",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/media/travel/.thumb/{album}/{filename}")
|
||||
def get_thumb(album: str, filename: str):
|
||||
if ".." in album or ".." in filename:
|
||||
raise HTTPException(400, "Invalid path")
|
||||
src = (ROOT / album / filename).resolve()
|
||||
if not str(src).startswith(str(ROOT)):
|
||||
raise HTTPException(403, "Access denied")
|
||||
if not src.exists() or not src.is_file():
|
||||
raise HTTPException(404, "Source not found")
|
||||
p = _ensure_thumb_fallback(src, album)
|
||||
if not p.exists() or not p.is_file():
|
||||
raise HTTPException(404, "Thumbnail not found")
|
||||
return FileResponse(str(p))
|
||||
|
||||
|
||||
@app.get("/api/version")
|
||||
def version():
|
||||
return {"version": os.getenv("APP_VERSION", "dev")}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 커밋**
|
||||
|
||||
```bash
|
||||
git add travel-proxy/app/main.py
|
||||
git commit -m "refactor(travel-proxy): main.py DB 기반 전환 — 메모리 캐시 제거 + 신규 API"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 통합 검증
|
||||
|
||||
**Files:**
|
||||
- 없음 (기존 파일 검증만)
|
||||
|
||||
- [ ] **Step 1: import 구조 확인**
|
||||
|
||||
travel-proxy/app/ 디렉토리에 `__init__.py`가 필요한지 확인. FastAPI uvicorn 실행 명령이 `app.main:app`이므로 패키지 import가 동작하려면 `__init__.py`가 필요.
|
||||
|
||||
```bash
|
||||
ls travel-proxy/app/
|
||||
```
|
||||
|
||||
`__init__.py`가 없으면 생성:
|
||||
|
||||
```python
|
||||
# travel-proxy/app/__init__.py
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Dockerfile 확인**
|
||||
|
||||
현재 Dockerfile의 `COPY app /app/app` 라인이 db.py, indexer.py를 포함하는지 확인. 디렉토리 단위 복사이므로 추가 파일은 자동 포함됨. 변경 불필요.
|
||||
|
||||
- [ ] **Step 3: docker-compose.yml 환경변수 확인**
|
||||
|
||||
`TRAVEL_DB_PATH` 환경변수를 docker-compose.yml에 추가:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml의 travel-proxy 서비스 environment에 추가
|
||||
- TRAVEL_DB_PATH=${TRAVEL_DB_PATH:-/data/thumbs/travel.db}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: photos 응답 호환성 검증**
|
||||
|
||||
기존 응답 필드와 비교:
|
||||
- `region` ✓
|
||||
- `page`, `size` ✓
|
||||
- `total`, `has_next` ✓
|
||||
- `items[].album`, `items[].file`, `items[].url`, `items[].thumb`, `items[].mtime` ✓
|
||||
- `matched_albums` — 기존에는 `photos()` 응답에 없었으나 캐시 데이터에 포함. DB 버전은 항상 포함.
|
||||
|
||||
- [ ] **Step 5: 커밋 (변경 있을 시)**
|
||||
|
||||
```bash
|
||||
git add travel-proxy/app/__init__.py docker-compose.yml
|
||||
git commit -m "chore(travel-proxy): __init__.py + TRAVEL_DB_PATH 환경변수 추가"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: CLAUDE.md 업데이트
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md`
|
||||
|
||||
- [ ] **Step 1: travel-proxy 섹션에 DB 정보 추가**
|
||||
|
||||
CLAUDE.md의 travel-proxy 섹션에 아래 내용 추가:
|
||||
|
||||
- DB: `/data/thumbs/travel.db` (photos, album_covers 테이블)
|
||||
- 파일 구조에 `db.py`, `indexer.py` 추가
|
||||
|
||||
API 목록 테이블에 신규 API 3개 추가:
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| POST | `/api/travel/sync` | 폴더 스캔 → DB 동기화 + 썸네일 생성 |
|
||||
| GET | `/api/travel/albums` | 앨범 목록 + 사진 수 + 커버 정보 |
|
||||
| PUT | `/api/travel/albums/{album}/cover` | 앨범 커버 지정 |
|
||||
|
||||
`POST /api/travel/reload` 제거 표기.
|
||||
|
||||
- [ ] **Step 2: 커밋**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md
|
||||
git commit -m "docs: CLAUDE.md travel-proxy DB·API 업데이트"
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,971 +0,0 @@
|
||||
# 청약 타겟팅 프론트엔드 구현 계획
|
||||
|
||||
> **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.
|
||||
|
||||
**Goal:** 백엔드에서 추가된 자치구 5티어 매칭 기능을 `web-ui`의 청약(Subscription) 페이지에 노출 — 프로필 편집 UI(드래그&드롭 + 슬라이더 + 토글) + 카드/상세에 district·5티어·reasons 표시.
|
||||
|
||||
**Architecture:** `Subscription.jsx`(1354줄, 단일 파일)의 ProfileTab에 신규 컴포넌트 2개(`DistrictTierEditor`, `NotificationSettings`)를 추가하고, `AnnouncementCard`/`AnnouncementDetail`/`MatchesTab` 3 곳에 district + 5티어 뱃지 + reasons 표시를 추가한다. 백엔드 응답은 이미 모든 필요 데이터를 포함하므로 API 변경 없음.
|
||||
|
||||
**Tech Stack:** React 18 + Vite + JavaScript / Native HTML5 drag-and-drop / `window.matchMedia` 분기 / ESLint / 단위 테스트 인프라 없음(빌드 + lint + 수동 시각 검증)
|
||||
|
||||
**스펙 참조:** `web-backend/docs/superpowers/specs/2026-04-28-realestate-frontend-targeting-design.md`
|
||||
|
||||
**작업 디렉토리:** `C:\Users\jaeoh\Desktop\workspace\web-ui` (web-backend와 별도 git repo. commit/push도 web-ui repo에서 처리.)
|
||||
|
||||
**검증 방식:**
|
||||
- 단위 테스트 인프라 없음 → 각 task는 `npm run build` 통과 + `npm run lint` 통과로 1차 검증
|
||||
- 마지막 task에서 `npm run dev` + 브라우저로 수동 시각 검증 시나리오 일괄 실행
|
||||
- 백엔드는 NAS에 이미 배포됨(2a8635e..a508a56) → 실제 응답으로 동작 확인 가능
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 모듈 상단 변경 — DEFAULT_PROFILE 확장 + extractTier 헬퍼
|
||||
|
||||
`Subscription.jsx` 모듈 상단에 신규 3 필드 default와 reasons → tier 추출 헬퍼를 추가. 이후 task들이 이 두 가지를 의존.
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-ui/src/pages/subscription/Subscription.jsx` (모듈 상단 — `DEFAULT_PROFILE` 상수 + 새 헬퍼)
|
||||
|
||||
- [ ] **Step 1: DEFAULT_PROFILE에 신규 3 필드 default 추가**
|
||||
|
||||
`Subscription.jsx`에서 `DEFAULT_PROFILE` 상수 정의를 찾는다 (grep `DEFAULT_PROFILE =`). 끝부분에 3 필드 추가:
|
||||
|
||||
```javascript
|
||||
const DEFAULT_PROFILE = {
|
||||
// ... 기존 필드 그대로 유지
|
||||
preferred_regions: '',
|
||||
preferred_types: '',
|
||||
min_area: '',
|
||||
max_area: '',
|
||||
max_price: '',
|
||||
// 신규 (자치구 5티어 + 알림 설정)
|
||||
preferred_districts: {},
|
||||
min_match_score: 70,
|
||||
notify_enabled: true,
|
||||
};
|
||||
```
|
||||
|
||||
(주의: 기존 마지막 필드 뒤에 콤마가 있는지 확인 후 일관성 유지)
|
||||
|
||||
- [ ] **Step 2: `extractTier` 헬퍼 함수 추가**
|
||||
|
||||
`DEFAULT_PROFILE` 정의 위(또는 fmt 헬퍼들 근처, 모듈 최상단 영역) 어딘가에 추가:
|
||||
|
||||
```javascript
|
||||
// 매칭 reasons에서 자치구 티어를 추출 ("자치구 S티어: 강남구 (+25)" → "S")
|
||||
function extractTier(reasons) {
|
||||
for (const r of reasons || []) {
|
||||
const m = r.match(/자치구 ([SABCD])티어/);
|
||||
if (m) return m[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 빌드 + 린트 검증**
|
||||
|
||||
```bash
|
||||
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||
npm run build
|
||||
npm run lint
|
||||
```
|
||||
|
||||
Expected: build 성공, lint 0 errors / 0 warnings.
|
||||
|
||||
- [ ] **Step 4: 커밋**
|
||||
|
||||
```bash
|
||||
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||
git add src/pages/subscription/Subscription.jsx
|
||||
git commit -m "feat(subscription): DEFAULT_PROFILE 신규 3필드 + extractTier 헬퍼"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: DistrictTierEditor 컴포넌트 신규
|
||||
|
||||
자치구 5티어 분류 UI. 데스크톱 드래그&드롭 + 모바일 read-only.
|
||||
|
||||
**Files:**
|
||||
- Create: `web-ui/src/pages/subscription/components/DistrictTierEditor.jsx`
|
||||
|
||||
- [ ] **Step 1: 컴포넌트 파일 생성**
|
||||
|
||||
```jsx
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
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: [] };
|
||||
|
||||
function useIsDesktop() {
|
||||
const [isDesktop, setIsDesktop] = useState(
|
||||
typeof window !== "undefined" && window.matchMedia("(min-width: 768px)").matches
|
||||
);
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const mq = window.matchMedia("(min-width: 768px)");
|
||||
const handler = (e) => setIsDesktop(e.matches);
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, []);
|
||||
return isDesktop;
|
||||
}
|
||||
|
||||
export default function DistrictTierEditor({ value, onChange }) {
|
||||
const isDesktop = useIsDesktop();
|
||||
const [dragOver, setDragOver] = useState(null); // 현재 hover 중인 zone key
|
||||
|
||||
const current = value && Object.keys(value).length > 0 ? value : EMPTY_TIERS;
|
||||
|
||||
const unassigned = SEOUL_DISTRICTS.filter(
|
||||
d => !TIERS.some(t => (current[t.key] || []).includes(d))
|
||||
);
|
||||
|
||||
const moveDistrict = (district, targetTier /* null = 미할당 */) => {
|
||||
const next = { S: [], A: [], B: [], C: [], D: [] };
|
||||
for (const t of Object.keys(next)) {
|
||||
next[t] = (current[t] || []).filter(d => d !== district);
|
||||
}
|
||||
if (targetTier) {
|
||||
next[targetTier] = [...next[targetTier], district];
|
||||
}
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const onDragStart = (e, district) => {
|
||||
e.dataTransfer.setData("text/district", district);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
};
|
||||
const onDragOver = (e, key) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
if (dragOver !== key) setDragOver(key);
|
||||
};
|
||||
const onDragLeave = () => setDragOver(null);
|
||||
const onDrop = (e, targetTier /* null = 미할당 */) => {
|
||||
e.preventDefault();
|
||||
const district = e.dataTransfer.getData("text/district");
|
||||
setDragOver(null);
|
||||
if (district) moveDistrict(district, targetTier);
|
||||
};
|
||||
|
||||
if (!isDesktop) {
|
||||
return (
|
||||
<div className="sub-panel">
|
||||
<div className="sub-panel__head">
|
||||
<p className="sub-panel__eyebrow">자치구 우선순위</p>
|
||||
<h3>지역 5티어</h3>
|
||||
</div>
|
||||
<div className="sub-panel__body" style={{ display: "grid", gap: 10 }}>
|
||||
{TIERS.map(t => (
|
||||
<div key={t.key} className="dte-row dte-row--readonly">
|
||||
<span className={`sub-chip sub-chip--tier sub-chip--tier-${t.key}`}>
|
||||
{t.label} {t.weight}
|
||||
</span>
|
||||
<span className="dte-row__list">
|
||||
{(current[t.key] || []).length === 0
|
||||
? <span className="dte-empty">(없음)</span>
|
||||
: (current[t.key] || []).join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<p className="dte-mobile-hint">✏️ 자치구 분류는 PC에서 편집할 수 있어요</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sub-panel">
|
||||
<div className="sub-panel__head">
|
||||
<p className="sub-panel__eyebrow">자치구 우선순위</p>
|
||||
<h3>지역 5티어 (드래그해서 분류)</h3>
|
||||
</div>
|
||||
<div className="sub-panel__body" style={{ display: "grid", gap: 12 }}>
|
||||
{/* 미할당 풀 */}
|
||||
<div
|
||||
className={`dte-pool ${dragOver === "_unassigned" ? "dte-pool--over" : ""}`}
|
||||
onDragOver={(e) => onDragOver(e, "_unassigned")}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(e) => onDrop(e, null)}
|
||||
>
|
||||
<p className="dte-pool__title">미할당 ({unassigned.length})</p>
|
||||
<div className="dte-chips">
|
||||
{unassigned.map(d => (
|
||||
<span
|
||||
key={d}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, d)}
|
||||
className="sub-chip sub-chip--district dte-chip"
|
||||
>
|
||||
{d}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 5티어 그리드 */}
|
||||
<div className="dte-grid">
|
||||
{TIERS.map(t => (
|
||||
<div
|
||||
key={t.key}
|
||||
className={`dte-zone ${dragOver === t.key ? "dte-zone--over" : ""}`}
|
||||
onDragOver={(e) => onDragOver(e, t.key)}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(e) => onDrop(e, t.key)}
|
||||
>
|
||||
<div className={`dte-zone__head sub-chip--tier-${t.key}`}>
|
||||
{t.label} <span className="dte-zone__weight">{t.weight}</span>
|
||||
</div>
|
||||
<div className="dte-zone__chips">
|
||||
{(current[t.key] || []).map(d => (
|
||||
<span
|
||||
key={d}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, d)}
|
||||
className="sub-chip sub-chip--district dte-chip"
|
||||
>
|
||||
{d}
|
||||
<button
|
||||
type="button"
|
||||
className="dte-chip__remove"
|
||||
onClick={() => moveDistrict(d, null)}
|
||||
aria-label={`${d} 미할당으로`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 빌드 + 린트 검증**
|
||||
|
||||
```bash
|
||||
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||
npm run build
|
||||
npm run lint
|
||||
```
|
||||
|
||||
Expected: build 성공, lint 0 errors. 컴포넌트가 아직 사용되지 않으므로 dead code warning은 무시.
|
||||
|
||||
- [ ] **Step 3: 커밋**
|
||||
|
||||
```bash
|
||||
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||
git add src/pages/subscription/components/DistrictTierEditor.jsx
|
||||
git commit -m "feat(subscription): DistrictTierEditor — 자치구 5티어 드래그앤드롭"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: NotificationSettings 컴포넌트 신규
|
||||
|
||||
임계값 슬라이더 + 알림 토글 + 미리보기.
|
||||
|
||||
**Files:**
|
||||
- Create: `web-ui/src/pages/subscription/components/NotificationSettings.jsx`
|
||||
|
||||
- [ ] **Step 1: 컴포넌트 파일 생성**
|
||||
|
||||
```jsx
|
||||
export default function NotificationSettings({ minScore, notifyEnabled, onChange }) {
|
||||
const score = minScore ?? 70;
|
||||
const enabled = notifyEnabled ?? true;
|
||||
|
||||
return (
|
||||
<div className="sub-panel">
|
||||
<div className="sub-panel__head">
|
||||
<p className="sub-panel__eyebrow">알림 설정</p>
|
||||
<h3>🔔 텔레그램 알림</h3>
|
||||
</div>
|
||||
<div className="sub-panel__body" style={{ display: "grid", gap: 16 }}>
|
||||
<label className="ns-row">
|
||||
<span className="ns-row__label">텔레그램 알림</span>
|
||||
<span className="ns-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sub-toggle"
|
||||
checked={enabled}
|
||||
onChange={(e) => onChange({ notify_enabled: e.target.checked })}
|
||||
/>
|
||||
<span className="sub-toggle__label">{enabled ? "ON" : "OFF"}</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="ns-row ns-row--column">
|
||||
<span className="ns-row__label">매칭 임계값 — {score}점</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="5"
|
||||
value={score}
|
||||
onChange={(e) => onChange({ min_match_score: Number(e.target.value) })}
|
||||
className="ns-slider"
|
||||
disabled={!enabled}
|
||||
/>
|
||||
<div className="ns-scale">
|
||||
<span>0</span>
|
||||
<span>50</span>
|
||||
<span>100</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<p className="ns-hint">
|
||||
{enabled
|
||||
? `💡 ${score}점 이상 매치 시 텔레그램에 자동 알림합니다.`
|
||||
: "⚠️ 알림 OFF — 임계값을 통과한 매칭이 있어도 메시지가 발송되지 않습니다."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 빌드 + 린트 검증**
|
||||
|
||||
```bash
|
||||
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||
npm run build
|
||||
npm run lint
|
||||
```
|
||||
|
||||
Expected: build 성공, lint 0 errors.
|
||||
|
||||
- [ ] **Step 3: 커밋**
|
||||
|
||||
```bash
|
||||
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||
git add src/pages/subscription/components/NotificationSettings.jsx
|
||||
git commit -m "feat(subscription): NotificationSettings — 임계값 슬라이더 + 알림 토글"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: ProfileTab에 두 컴포넌트 통합 + handleSave 변경
|
||||
|
||||
신규 컴포넌트 2개를 ProfileTab에 import·렌더하고, handleSave가 신규 3 필드를 PUT body에 포함하도록 변경.
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-ui/src/pages/subscription/Subscription.jsx` ProfileTab 함수 (956~1299줄 부근)
|
||||
|
||||
- [ ] **Step 1: import 추가 (파일 상단의 다른 import들 근처)**
|
||||
|
||||
```javascript
|
||||
import DistrictTierEditor from "./components/DistrictTierEditor";
|
||||
import NotificationSettings from "./components/NotificationSettings";
|
||||
```
|
||||
|
||||
- [ ] **Step 2: handleSave 안 신규 3 필드 처리 추가**
|
||||
|
||||
`handleSave` 함수 안에서 payload 변환 부분을 찾아 다음을 추가. 기존 `preferred_regions` / `preferred_types` 변환 직후에:
|
||||
|
||||
```javascript
|
||||
// 신규: preferred_districts (객체), min_match_score, notify_enabled
|
||||
payload.preferred_districts = profile.preferred_districts && typeof profile.preferred_districts === "object"
|
||||
? profile.preferred_districts
|
||||
: {};
|
||||
payload.min_match_score = profile.min_match_score ?? null;
|
||||
payload.notify_enabled = profile.notify_enabled ?? null;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: ProfileTab의 GET 응답 처리에 신규 3 필드 매핑 보강**
|
||||
|
||||
`useEffect` 안의 `apiGet('/api/realestate/profile')` 응답 처리에서 `display = { ...DEFAULT_PROFILE, ...data }` 라인이 이미 있어 자동으로 spread 됨. 별도 수정 불필요. (백엔드가 항상 응답에 포함시키므로 fallback도 자연스러움.)
|
||||
|
||||
확인 차원에서 `min_match_score`/`notify_enabled`/`preferred_districts`가 응답에 없을 경우 DEFAULT 값이 사용되는지 검증.
|
||||
|
||||
- [ ] **Step 4: ProfileTab 렌더 — DistrictTierEditor / NotificationSettings 추가**
|
||||
|
||||
`return ()` 안에서 기존 "선호 조건" 패널과 저장 버튼 사이에 두 컴포넌트 삽입. 정확한 위치는 기존 코드의 마지막 `<div className="sub-panel">` (선호 조건 패널) 다음 + 저장 버튼 직전:
|
||||
|
||||
```jsx
|
||||
{/* 자치구 5티어 */}
|
||||
<DistrictTierEditor
|
||||
value={profile.preferred_districts}
|
||||
onChange={(next) => setProfile(prev => ({ ...prev, preferred_districts: next }))}
|
||||
/>
|
||||
|
||||
{/* 알림 설정 */}
|
||||
<NotificationSettings
|
||||
minScore={profile.min_match_score ?? 70}
|
||||
notifyEnabled={profile.notify_enabled ?? true}
|
||||
onChange={(patch) => setProfile(prev => ({ ...prev, ...patch }))}
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 빌드 + 린트 검증**
|
||||
|
||||
```bash
|
||||
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||
npm run build
|
||||
npm run lint
|
||||
```
|
||||
|
||||
Expected: build 성공, lint 0 errors.
|
||||
|
||||
- [ ] **Step 6: 커밋**
|
||||
|
||||
```bash
|
||||
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||
git add src/pages/subscription/Subscription.jsx
|
||||
git commit -m "feat(subscription): ProfileTab에 5티어/알림 설정 통합"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Subscription.css — 5티어 + 드래그영역 + 토글 + 슬라이더 + 매칭분석 스타일
|
||||
|
||||
신규 컴포넌트와 카드 표시 변경에 필요한 모든 CSS를 한 번에 추가.
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-ui/src/pages/subscription/Subscription.css` (파일 끝에 신규 섹션 추가)
|
||||
|
||||
- [ ] **Step 1: 5티어 + district 뱃지 색상**
|
||||
|
||||
`Subscription.css` 파일 끝에 추가:
|
||||
|
||||
```css
|
||||
/* === 신규: 자치구 5티어 + district 뱃지 ============================== */
|
||||
.sub-chip--district {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
.sub-chip--tier {
|
||||
font-weight: 700;
|
||||
}
|
||||
.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; }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: DistrictTierEditor 드래그&드롭 영역**
|
||||
|
||||
같은 파일에 이어서 추가:
|
||||
|
||||
```css
|
||||
/* === 신규: DistrictTierEditor ====================================== */
|
||||
.dte-pool {
|
||||
border: 1px dashed var(--border-soft, #e5e7eb);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.dte-pool--over {
|
||||
background: #f0f9ff;
|
||||
border-color: #38bdf8;
|
||||
}
|
||||
.dte-pool__title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted, #6b7280);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.dte-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.dte-chip {
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
.dte-chip:active { cursor: grabbing; }
|
||||
.dte-chip__remove {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
margin-left: 4px;
|
||||
padding: 0 2px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.dte-chip__remove:hover { opacity: 1; }
|
||||
|
||||
.dte-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.dte-zone {
|
||||
border: 1px solid var(--border-soft, #e5e7eb);
|
||||
border-radius: 12px;
|
||||
padding: 8px;
|
||||
min-height: 120px;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.dte-zone--over {
|
||||
background: #f0f9ff;
|
||||
border-color: #38bdf8;
|
||||
}
|
||||
.dte-zone__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.dte-zone__weight {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.dte-zone__chips {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* 모바일 read-only 뷰 */
|
||||
.dte-row {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 1fr;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border-soft, #e5e7eb);
|
||||
}
|
||||
.dte-row:last-of-type { border-bottom: 0; }
|
||||
.dte-row__list {
|
||||
color: var(--text, #1f2937);
|
||||
font-size: 14px;
|
||||
}
|
||||
.dte-empty {
|
||||
color: var(--text-muted, #6b7280);
|
||||
font-style: italic;
|
||||
}
|
||||
.dte-mobile-hint {
|
||||
margin: 4px 0 0;
|
||||
color: var(--text-muted, #6b7280);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: NotificationSettings — 토글 + 슬라이더**
|
||||
|
||||
같은 파일에 이어서 추가:
|
||||
|
||||
```css
|
||||
/* === 신규: NotificationSettings ==================================== */
|
||||
.ns-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.ns-row--column {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.ns-row__label {
|
||||
font-weight: 600;
|
||||
color: var(--text, #1f2937);
|
||||
}
|
||||
.ns-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.sub-toggle {
|
||||
appearance: none;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
background: #d1d5db;
|
||||
border-radius: 11px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
margin: 0;
|
||||
}
|
||||
.sub-toggle::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.sub-toggle:checked {
|
||||
background: #10b981;
|
||||
}
|
||||
.sub-toggle:checked::before {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
.sub-toggle__label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted, #6b7280);
|
||||
}
|
||||
.ns-slider {
|
||||
width: 100%;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.ns-slider:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.ns-scale {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted, #6b7280);
|
||||
}
|
||||
.ns-hint {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted, #6b7280);
|
||||
line-height: 1.5;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 매칭 분석 섹션 + 모바일 그리드 fallback**
|
||||
|
||||
같은 파일에 이어서 추가:
|
||||
|
||||
```css
|
||||
/* === 신규: 매칭 분석 섹션 ========================================== */
|
||||
.sub-match-analysis {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--surface-soft, #f9fafb);
|
||||
border-radius: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.sub-match-analysis__score {
|
||||
font-family: var(--font-display, system-ui);
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--accent, #3b82f6);
|
||||
}
|
||||
.sub-match-analysis__reasons {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: var(--text, #1f2937);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.sub-match-analysis__reasons li {
|
||||
margin: 2px 0;
|
||||
}
|
||||
.sub-match-analysis__elig {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* 모바일 dte-grid → 1칼럼 */
|
||||
@media (max-width: 767px) {
|
||||
.dte-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 빌드 검증**
|
||||
|
||||
```bash
|
||||
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: build 성공 (CSS 추가는 lint 영향 없음).
|
||||
|
||||
- [ ] **Step 6: 커밋**
|
||||
|
||||
```bash
|
||||
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||
git add src/pages/subscription/Subscription.css
|
||||
git commit -m "feat(subscription): 5티어 뱃지 + 드래그영역 + 토글 + 슬라이더 스타일"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: AnnouncementCard에 district + 5티어 뱃지
|
||||
|
||||
매칭 결과 데이터가 있는 경우만 뱃지 표시.
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-ui/src/pages/subscription/Subscription.jsx` AnnouncementCard 함수 (315~389줄 부근)
|
||||
|
||||
- [ ] **Step 1: AnnouncementCard 안 메타 라인에 뱃지 추가**
|
||||
|
||||
`AnnouncementCard` 컴포넌트의 JSX에서 단지명·지역 라인 직후 또는 메타 라인에 다음 뱃지를 추가:
|
||||
|
||||
```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;
|
||||
})()}
|
||||
```
|
||||
|
||||
(`extractTier`는 Task 1에서 모듈 상단에 정의됨. JSX 안에서 직접 호출 가능.)
|
||||
|
||||
정확한 삽입 위치: 카드 헤더의 region/area 라인 옆, 또는 status 뱃지 옆에 자연스럽게 배치. 기존 카드 구조에 맞춰 조정.
|
||||
|
||||
- [ ] **Step 2: 빌드 + 린트 검증**
|
||||
|
||||
```bash
|
||||
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||
npm run build
|
||||
npm run lint
|
||||
```
|
||||
|
||||
Expected: build 성공, lint 0 errors.
|
||||
|
||||
- [ ] **Step 3: 커밋**
|
||||
|
||||
```bash
|
||||
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||
git add src/pages/subscription/Subscription.jsx
|
||||
git commit -m "feat(subscription): AnnouncementCard에 district + 5티어 뱃지"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: AnnouncementDetail에 매칭 분석 섹션
|
||||
|
||||
매칭 결과가 있는 공고만 매칭 분석 섹션을 노출.
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-ui/src/pages/subscription/Subscription.jsx` AnnouncementDetail 함수 (390~595줄 부근)
|
||||
|
||||
- [ ] **Step 1: AnnouncementDetail 안 매칭 분석 섹션 추가**
|
||||
|
||||
`AnnouncementDetail` 컴포넌트의 JSX 마지막 부분(다른 모든 섹션 다음)에 추가:
|
||||
|
||||
```jsx
|
||||
{item.match_score !== undefined && item.match_score !== null && (
|
||||
<div className="sub-match-analysis">
|
||||
<div>
|
||||
<p className="sub-panel__eyebrow">매칭 분석</p>
|
||||
<span className="sub-match-analysis__score">
|
||||
⭐ {item.match_score}<span style={{ fontSize: 14, color: "var(--text-muted)" }}> / 100</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{item.match_reasons && item.match_reasons.length > 0 && (
|
||||
<div>
|
||||
<p className="sub-panel__eyebrow" style={{ marginTop: 8 }}>💡 매칭 사유</p>
|
||||
<ul className="sub-match-analysis__reasons">
|
||||
{item.match_reasons.map((r, idx) => (
|
||||
<li key={idx}>{r}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.eligible_types && item.eligible_types.length > 0 && (
|
||||
<div>
|
||||
<p className="sub-panel__eyebrow" style={{ marginTop: 8 }}>✓ 신청 자격</p>
|
||||
<div className="sub-match-analysis__elig">
|
||||
{item.eligible_types.map(t => (
|
||||
<span key={t} className="sub-chip">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 빌드 + 린트 검증**
|
||||
|
||||
```bash
|
||||
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||
npm run build
|
||||
npm run lint
|
||||
```
|
||||
|
||||
Expected: build 성공, lint 0 errors.
|
||||
|
||||
- [ ] **Step 3: 커밋**
|
||||
|
||||
```bash
|
||||
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||
git add src/pages/subscription/Subscription.jsx
|
||||
git commit -m "feat(subscription): AnnouncementDetail에 매칭 분석 섹션"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: MatchesTab 매치 카드에 district + 5티어 뱃지
|
||||
|
||||
`MatchesTab`은 별도의 매치 카드 마크업을 가지고 있을 가능성이 높다. `AnnouncementCard`와 동일한 helper(`extractTier`) + 뱃지 패턴을 적용.
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-ui/src/pages/subscription/Subscription.jsx` MatchesTab 함수 (763~955줄 부근)
|
||||
|
||||
- [ ] **Step 1: MatchesTab의 매치 카드 마크업을 찾아 district + 5티어 뱃지 삽입**
|
||||
|
||||
`MatchesTab` 함수 안에서 매치 한 건당 렌더하는 영역(보통 `match.house_nm` / `match.region_name` 등을 표시하는 곳)을 찾는다. 거기에 다음 뱃지를 추가:
|
||||
|
||||
```jsx
|
||||
{match.district && (
|
||||
<span className="sub-chip sub-chip--district">{match.district}</span>
|
||||
)}
|
||||
{(() => {
|
||||
const tier = extractTier(match.match_reasons);
|
||||
return tier ? (
|
||||
<span className={`sub-chip sub-chip--tier sub-chip--tier-${tier}`}>
|
||||
{tier}티어
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
```
|
||||
|
||||
(`match` 변수명은 실제 코드의 변수명에 맞춰 조정. 보통 `match`, `m`, 또는 `item`)
|
||||
|
||||
- [ ] **Step 2: 빌드 + 린트 검증**
|
||||
|
||||
```bash
|
||||
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||
npm run build
|
||||
npm run lint
|
||||
```
|
||||
|
||||
Expected: build 성공, lint 0 errors.
|
||||
|
||||
- [ ] **Step 3: 커밋**
|
||||
|
||||
```bash
|
||||
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||
git add src/pages/subscription/Subscription.jsx
|
||||
git commit -m "feat(subscription): MatchesTab 카드에 district + 5티어 뱃지"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: CLAUDE.md 업데이트 + 수동 시각 검증
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-ui/CLAUDE.md` (페이지/엔드포인트 표 업데이트)
|
||||
|
||||
- [ ] **Step 1: CLAUDE.md 업데이트**
|
||||
|
||||
`web-ui/CLAUDE.md`를 열고:
|
||||
1. Subscription 페이지 설명 섹션에 신규 기능 한 줄 추가:
|
||||
```
|
||||
- 프로필: 자치구 5티어 분류(드래그&드롭), 알림 임계값/토글 (백엔드 2026-04-28-realestate-targeting-enhancement-design 참조)
|
||||
- 카드/상세: district + 5티어 뱃지 + 매칭 사유 텍스트
|
||||
```
|
||||
2. API 엔드포인트 매핑 표에 `/api/realestate/profile` PUT body가 `preferred_districts` (object), `min_match_score` (int), `notify_enabled` (bool)을 받는다는 한 줄 추가.
|
||||
|
||||
- [ ] **Step 2: 수동 시각 검증 (dev server)**
|
||||
|
||||
```bash
|
||||
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||
npm run dev
|
||||
```
|
||||
|
||||
브라우저에서 `http://localhost:3007` 접속 후 청약 페이지(Subscription) 진입.
|
||||
|
||||
검증 시나리오 (모두 통과해야 함):
|
||||
|
||||
| # | 시나리오 | 기대 결과 |
|
||||
|---|---------|----------|
|
||||
| 1 | 데스크톱 뷰포트(>=768px) → 프로필 탭 → 자치구 영역 표시 | 미할당 풀 + 5티어 그리드(S/A/B/C/D) 노출, 25개 자치구가 미할당 풀에 보임 |
|
||||
| 2 | "강남구"를 S 슬롯으로 드래그 | S 슬롯에 들어가고 미할당에서 사라짐 |
|
||||
| 3 | "송파구"를 A 슬롯으로, "강남구"를 다시 S에서 A로 드래그 | A 슬롯에 둘 다, S는 비워짐 |
|
||||
| 4 | A 슬롯의 "송파구" 칩의 × 버튼 클릭 | 미할당 풀로 복귀 |
|
||||
| 5 | 알림 토글 OFF → 슬라이더 disabled, 안내 텍스트가 "알림 OFF" 톤 |
|
||||
| 6 | 슬라이더 80으로 변경 → "80점 이상 매치 시…" 텍스트 즉시 갱신 |
|
||||
| 7 | "저장" 버튼 클릭 → 새로고침 → 자치구/임계값/토글 값 유지 |
|
||||
| 8 | 모바일 뷰포트(<768px) | 자치구 영역이 read-only 리스트로 변경, 편집 영역 숨김, "PC에서 편집해주세요" 안내 표시 |
|
||||
| 9 | 공고 탭 → 매칭 결과 있는 공고 카드 | district 뱃지 + 5티어 뱃지 표시 (매칭 데이터 있는 경우만) |
|
||||
| 10 | 공고 카드 클릭 → 상세 모달 | 매칭 분석 섹션에 점수 + reasons + 자격 표시 |
|
||||
| 11 | 매칭 탭 → 카드들 | district + 5티어 뱃지 표시 |
|
||||
| 12 | 회귀 — 기존 프로필 필드(나이/청약통장/특공) 입력·저장 | 정상 동작 |
|
||||
|
||||
문제 발견 시 해당 task로 돌아가 수정.
|
||||
|
||||
- [ ] **Step 3: 빌드 최종 검증**
|
||||
|
||||
```bash
|
||||
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||
npm run build
|
||||
npm run lint
|
||||
```
|
||||
|
||||
Expected: 에러·경고 없음.
|
||||
|
||||
- [ ] **Step 4: 커밋**
|
||||
|
||||
```bash
|
||||
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||
git add CLAUDE.md
|
||||
git commit -m "docs(web-ui): 청약 5티어 + 알림 설정 문서 업데이트"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 완료 기준
|
||||
|
||||
- 9개 task 모두 commit 완료
|
||||
- `npm run build` warning/error 없이 통과
|
||||
- `npm run lint` 0 errors / 0 warnings
|
||||
- 12개 수동 시각 검증 시나리오 모두 통과
|
||||
- 매칭 점수 70점 이상 + notify_enabled=true + 자치구 S 티어에 강남구 설정 시, 백엔드(이미 NAS에 배포됨)가 신규 매칭 발견 시 텔레그램 알림 송신 (end-to-end)
|
||||
|
||||
---
|
||||
|
||||
## 배포
|
||||
|
||||
```bash
|
||||
cd C:\Users\jaeoh\Desktop\workspace\web-ui
|
||||
npm run release:nas
|
||||
```
|
||||
|
||||
NAS에 robocopy로 빌드 산출물 업로드. NAS Z 드라이브 연결이 전제(`\\gahusb.synology.me\docker`).
|
||||
|
||||
---
|
||||
|
||||
## 참고 — 후속 별도 plan
|
||||
|
||||
- Subscription.jsx 자체 분할 (1354줄 → 별도 리팩토링)
|
||||
- 5축 점수 분해 progress bar (백엔드 응답에 5축 점수 추가 필요)
|
||||
- 임계값 통과 매치 카운트 미리보기 (`dashboard.pass_count` 백엔드 신설 필요)
|
||||
- 자치구 5티어 자동 추천 (사용자 가점·예산 기반)
|
||||
- 알림 채널 추가 (이메일/Slack)
|
||||
- 모바일 자치구 편집 지원 (touch backend 도입 시)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,976 +0,0 @@
|
||||
# packs-lab 인프라 통합 + admin mint-token Implementation Plan
|
||||
|
||||
> **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.
|
||||
|
||||
**Goal:** packs-lab을 운영 가능 상태로 만든다 — admin upload 토큰 발급 endpoint + Supabase 스키마 + docker-compose/nginx/env 통합 + 통합 테스트 + 문서 갱신.
|
||||
|
||||
**Architecture:** 기존 코드(HMAC + DSM client + 4 라우트)는 그대로 유지하고, 신규 라우트 1개(`POST /api/packs/admin/mint-token`)를 routes.py에 추가한다. Supabase `pack_files` DDL 파일과 인프라(docker-compose 18950, nginx 5GB streaming, .env.example 6+1 환경변수)를 신설하고, 통합 테스트(routes + dsm_client mock)와 CLAUDE.md 5+1곳을 갱신한다.
|
||||
|
||||
**Tech Stack:** Python 3.12 / FastAPI / pytest + unittest.mock / Supabase(PostgreSQL) / Synology DSM 7.x API / nginx / Docker Compose
|
||||
|
||||
**스펙 참조:** `docs/superpowers/specs/2026-05-05-packs-lab-infra-integration-design.md`
|
||||
|
||||
**작업 디렉토리:** `C:\Users\jaeoh\Desktop\workspace\web-backend` (기존 web-backend repo)
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 테스트 인프라 — `tests/conftest.py`
|
||||
|
||||
기존 `tests/test_auth.py`는 `BACKEND_HMAC_SECRET=secret` 같은 fixture가 없어 환경변수 의존. 모든 테스트가 동일한 secret으로 동작하도록 autouse fixture를 conftest에 정리.
|
||||
|
||||
**Files:**
|
||||
- Create: `packs-lab/tests/conftest.py`
|
||||
|
||||
- [ ] **Step 1: conftest.py 생성**
|
||||
|
||||
`packs-lab/tests/conftest.py`:
|
||||
|
||||
```python
|
||||
"""packs-lab 테스트 공통 fixture."""
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _hmac_secret(monkeypatch):
|
||||
"""모든 테스트에서 동일한 HMAC secret 사용. auth._SECRET 모듈 캐시까지 갱신."""
|
||||
monkeypatch.setenv("BACKEND_HMAC_SECRET", "test-secret-do-not-use-in-prod")
|
||||
# auth.py 모듈은 import 시점에 _SECRET을 캐시하므로 monkeypatch로 함께 갱신
|
||||
from app import auth
|
||||
monkeypatch.setattr(auth, "_SECRET", "test-secret-do-not-use-in-prod")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 기존 test_auth.py 회귀 검증**
|
||||
|
||||
```bash
|
||||
cd C:\Users\jaeoh\Desktop\workspace\web-backend\packs-lab
|
||||
python -m pytest tests/test_auth.py -v
|
||||
```
|
||||
|
||||
Expected: 기존 테스트 모두 PASS (conftest 영향 없거나 PASS 그대로 유지). 만약 secret 인코딩 차이로 실패 시 해당 테스트의 secret 사용 부분을 conftest 값과 일치시킨다.
|
||||
|
||||
- [ ] **Step 3: 커밋**
|
||||
|
||||
```bash
|
||||
git add packs-lab/tests/conftest.py
|
||||
git commit -m "test(packs-lab): conftest로 HMAC secret 통일"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: admin mint-token 라우트 (스키마 + 구현 + 테스트)
|
||||
|
||||
`POST /api/packs/admin/mint-token` 신규. Pydantic 스키마 추가 + 라우트 구현 + 통합 테스트.
|
||||
|
||||
**Files:**
|
||||
- Modify: `packs-lab/app/models.py` (스키마 2개 추가)
|
||||
- Modify: `packs-lab/app/routes.py` (import 보강 + 라우트 추가)
|
||||
- Create: `packs-lab/tests/test_routes.py` (mint-token 관련 테스트만 우선)
|
||||
|
||||
- [ ] **Step 1: failing 테스트 작성**
|
||||
|
||||
`packs-lab/tests/test_routes.py`:
|
||||
|
||||
```python
|
||||
"""packs-lab 라우트 통합 테스트.
|
||||
|
||||
DSM·Supabase는 mock. HMAC 검증·토큰 발급·검증은 실제 코드 사용.
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
SECRET = "test-secret-do-not-use-in-prod"
|
||||
|
||||
|
||||
def _hmac_headers(body_bytes: bytes) -> dict:
|
||||
"""body에 대한 X-Timestamp + X-Signature 헤더 생성."""
|
||||
ts = str(int(time.time()))
|
||||
sig = hmac.new(SECRET.encode(), ts.encode() + b"." + body_bytes, hashlib.sha256).hexdigest()
|
||||
return {"X-Timestamp": ts, "X-Signature": sig}
|
||||
|
||||
|
||||
def test_mint_token_hmac_required():
|
||||
"""HMAC 헤더 누락 → 401."""
|
||||
client = TestClient(app)
|
||||
body = {"tier": "pro", "label": "샘플", "filename": "x.zip", "size_bytes": 1024}
|
||||
resp = client.post("/api/packs/admin/mint-token", json=body)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_mint_token_returns_valid_token():
|
||||
"""발급된 token이 verify_upload_token으로 통과해야 한다."""
|
||||
from app.auth import verify_upload_token
|
||||
|
||||
body = {"tier": "pro", "label": "샘플", "filename": "test.zip", "size_bytes": 2048}
|
||||
body_bytes = json.dumps(body).encode()
|
||||
headers = _hmac_headers(body_bytes)
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/packs/admin/mint-token", content=body_bytes, headers=headers)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "token" in data and "expires_at" in data and "jti" in data
|
||||
|
||||
payload = verify_upload_token(data["token"])
|
||||
assert payload["tier"] == "pro"
|
||||
assert payload["label"] == "샘플"
|
||||
assert payload["filename"] == "test.zip"
|
||||
assert payload["size_bytes"] == 2048
|
||||
assert payload["jti"] == data["jti"]
|
||||
|
||||
|
||||
def test_mint_token_invalid_filename():
|
||||
"""허용 외 확장자 → 400."""
|
||||
body = {"tier": "pro", "label": "샘플", "filename": "x.exe", "size_bytes": 1024}
|
||||
body_bytes = json.dumps(body).encode()
|
||||
headers = _hmac_headers(body_bytes)
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/packs/admin/mint-token", content=body_bytes, headers=headers)
|
||||
assert resp.status_code == 400
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 실패 확인**
|
||||
|
||||
```bash
|
||||
cd packs-lab
|
||||
python -m pytest tests/test_routes.py -v
|
||||
```
|
||||
|
||||
Expected: 모든 테스트 FAIL — `/api/packs/admin/mint-token` 라우트 없음 (404 또는 405).
|
||||
|
||||
- [ ] **Step 3: models.py에 스키마 추가**
|
||||
|
||||
`packs-lab/app/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
|
||||
```
|
||||
|
||||
- [ ] **Step 4: routes.py에 mint-token 라우트 추가**
|
||||
|
||||
`packs-lab/app/routes.py` 상단 import 블록에 다음을 추가:
|
||||
|
||||
```python
|
||||
import time
|
||||
from datetime import timezone
|
||||
```
|
||||
|
||||
(이미 `import uuid`, `from datetime import datetime`은 있음)
|
||||
|
||||
`from .auth import` 라인을 다음과 같이 확장:
|
||||
|
||||
```python
|
||||
from .auth import mint_upload_token, verify_request_hmac, verify_upload_token
|
||||
```
|
||||
|
||||
`from .models import` 라인을 다음과 같이 확장:
|
||||
|
||||
```python
|
||||
from .models import (
|
||||
MintTokenRequest,
|
||||
MintTokenResponse,
|
||||
PackFileItem,
|
||||
SignLinkRequest,
|
||||
SignLinkResponse,
|
||||
UploadResponse,
|
||||
)
|
||||
```
|
||||
|
||||
상수 추가 (`MAX_BYTES` 다음 줄에):
|
||||
|
||||
```python
|
||||
UPLOAD_TOKEN_TTL_SEC = int(os.getenv("UPLOAD_TOKEN_TTL_SEC", "1800")) # 30분 default
|
||||
```
|
||||
|
||||
라우트 추가 (`sign_link` 함수 다음, `upload` 함수 앞):
|
||||
|
||||
```python
|
||||
@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)
|
||||
|
||||
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,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 테스트 통과 확인**
|
||||
|
||||
```bash
|
||||
cd packs-lab
|
||||
python -m pytest tests/test_routes.py -v
|
||||
```
|
||||
|
||||
Expected: 3 passed.
|
||||
|
||||
- [ ] **Step 6: 커밋**
|
||||
|
||||
```bash
|
||||
git add packs-lab/app/models.py packs-lab/app/routes.py packs-lab/tests/test_routes.py
|
||||
git commit -m "feat(packs-lab): POST /api/packs/admin/mint-token 라우트 + 통합 테스트"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 기존 4 라우트 통합 테스트 (sign-link / upload / list / delete)
|
||||
|
||||
기존 라우트는 변경 없음. 테스트만 추가해 회귀 안전망 확보.
|
||||
|
||||
**Files:**
|
||||
- Modify: `packs-lab/tests/test_routes.py` (테스트 8개 추가)
|
||||
|
||||
- [ ] **Step 1: sign-link 테스트 추가**
|
||||
|
||||
`tests/test_routes.py` 끝에 추가:
|
||||
|
||||
```python
|
||||
def test_sign_link_hmac_required():
|
||||
"""HMAC 헤더 없으면 401."""
|
||||
client = TestClient(app)
|
||||
body = {"file_path": "/volume1/docker/webpage/media/packs/pro/x.zip"}
|
||||
resp = client.post("/api/packs/sign-link", json=body)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_sign_link_outside_base_dir():
|
||||
"""PACK_BASE_DIR 외부 경로 → 400."""
|
||||
body = {"file_path": "/etc/passwd"}
|
||||
body_bytes = json.dumps(body).encode()
|
||||
headers = _hmac_headers(body_bytes)
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/packs/sign-link", content=body_bytes, headers=headers)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_sign_link_calls_dsm():
|
||||
"""DSM client 호출되고 응답 URL 반환."""
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
body = {"file_path": "/volume1/docker/webpage/media/packs/pro/sample.zip"}
|
||||
body_bytes = json.dumps(body).encode()
|
||||
headers = _hmac_headers(body_bytes)
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
fake_url = "https://gahusb.synology.me:5001/sharing/abc123"
|
||||
fake_expires = datetime(2026, 5, 5, 13, 0, tzinfo=timezone.utc)
|
||||
|
||||
with patch("app.routes.create_share_link", new=AsyncMock(return_value=(fake_url, fake_expires))) as mock:
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/packs/sign-link", content=body_bytes, headers=headers)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["url"] == fake_url
|
||||
mock.assert_awaited_once()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: upload 테스트 추가**
|
||||
|
||||
```python
|
||||
def _make_upload_token(tier="pro", label="샘플", filename="test.zip", size_bytes=1024, jti=None, ttl=1800):
|
||||
"""테스트용 upload token 생성. mint_token endpoint 거치지 않고 직접."""
|
||||
import uuid
|
||||
from app.auth import mint_upload_token
|
||||
return mint_upload_token({
|
||||
"tier": tier,
|
||||
"label": label,
|
||||
"filename": filename,
|
||||
"size_bytes": size_bytes,
|
||||
"jti": jti or str(uuid.uuid4()),
|
||||
"expires_at": int(time.time()) + ttl,
|
||||
})
|
||||
|
||||
|
||||
def test_upload_token_required():
|
||||
"""Authorization Bearer 누락 → 401."""
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/packs/upload", files={"file": ("x.zip", b"hello")})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_upload_size_mismatch(tmp_path, monkeypatch):
|
||||
"""토큰 size_bytes ≠ 실제 → 400 + 파일 정리됨."""
|
||||
monkeypatch.setattr("app.routes.PACK_BASE_DIR", tmp_path)
|
||||
token = _make_upload_token(size_bytes=999) # 실제 5바이트지만 토큰엔 999
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/packs/upload",
|
||||
files={"file": ("test.zip", b"hello")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "크기" in resp.json()["detail"]
|
||||
|
||||
|
||||
def test_upload_jti_replay(tmp_path, monkeypatch):
|
||||
"""같은 jti 토큰 두 번 → 두 번째 409."""
|
||||
monkeypatch.setattr("app.routes.PACK_BASE_DIR", tmp_path)
|
||||
|
||||
fake_supabase = MagicMock()
|
||||
fake_supabase.table.return_value.insert.return_value.execute.return_value = MagicMock(
|
||||
data=[{"uploaded_at": "2026-05-05T12:00:00+00:00"}]
|
||||
)
|
||||
|
||||
token = _make_upload_token(filename="replay.zip", size_bytes=5, jti="replay-jti-1")
|
||||
|
||||
with patch("app.routes._supabase", return_value=fake_supabase):
|
||||
client = TestClient(app)
|
||||
|
||||
# 1차: 성공
|
||||
resp1 = client.post(
|
||||
"/api/packs/upload",
|
||||
files={"file": ("replay.zip", b"hello")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp1.status_code == 200
|
||||
|
||||
# 2차: 동일 토큰 재사용 — 두 번째 파일은 다른 이름으로 보내 파일명 충돌 회피
|
||||
resp2 = client.post(
|
||||
"/api/packs/upload",
|
||||
files={"file": ("replay.zip", b"world")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp2.status_code == 409
|
||||
```
|
||||
|
||||
- [ ] **Step 3: list / delete 테스트 추가**
|
||||
|
||||
```python
|
||||
def test_list_returns_active_only():
|
||||
"""mock supabase가 deleted_at IS NULL 행만 반환하는지 (쿼리 빌더 호출 검증)."""
|
||||
fake_rows = [
|
||||
{
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"min_tier": "pro",
|
||||
"label": "샘플",
|
||||
"file_path": "/volume1/docker/webpage/media/packs/pro/a.zip",
|
||||
"filename": "a.zip",
|
||||
"size_bytes": 1024,
|
||||
"sort_order": 0,
|
||||
"uploaded_at": "2026-05-05T12:00:00+00:00",
|
||||
}
|
||||
]
|
||||
|
||||
fake_supabase = MagicMock()
|
||||
chain = fake_supabase.table.return_value.select.return_value
|
||||
chain.is_.return_value.order.return_value.order.return_value.execute.return_value = MagicMock(data=fake_rows)
|
||||
|
||||
body_bytes = b""
|
||||
headers = _hmac_headers(body_bytes)
|
||||
|
||||
with patch("app.routes._supabase", return_value=fake_supabase):
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/packs/list", headers=headers)
|
||||
|
||||
assert resp.status_code == 200
|
||||
items = resp.json()
|
||||
assert len(items) == 1
|
||||
assert items[0]["filename"] == "a.zip"
|
||||
fake_supabase.table.return_value.select.return_value.is_.assert_called_with("deleted_at", "null")
|
||||
|
||||
|
||||
def test_delete_soft_deletes():
|
||||
"""DELETE 시 supabase update에 deleted_at ISO timestamp가 들어가야 한다."""
|
||||
fake_supabase = MagicMock()
|
||||
fake_supabase.table.return_value.update.return_value.eq.return_value.execute.return_value = MagicMock(
|
||||
data=[{"id": "abc"}]
|
||||
)
|
||||
|
||||
body_bytes = b""
|
||||
headers = _hmac_headers(body_bytes)
|
||||
|
||||
with patch("app.routes._supabase", return_value=fake_supabase):
|
||||
client = TestClient(app)
|
||||
resp = client.delete("/api/packs/abc", headers=headers)
|
||||
|
||||
assert resp.status_code == 200
|
||||
update_call = fake_supabase.table.return_value.update.call_args
|
||||
update_kwargs = update_call.args[0]
|
||||
assert "deleted_at" in update_kwargs
|
||||
# ISO 8601 timestamp 형식 검증 (예: 2026-05-05T12:00:00+00:00)
|
||||
assert "T" in update_kwargs["deleted_at"]
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 테스트 실행**
|
||||
|
||||
```bash
|
||||
cd packs-lab
|
||||
python -m pytest tests/test_routes.py -v
|
||||
```
|
||||
|
||||
Expected: 11 passed (3 from Task 2 + 3 sign-link + 3 upload + 2 list/delete).
|
||||
|
||||
- [ ] **Step 5: 커밋**
|
||||
|
||||
```bash
|
||||
git add packs-lab/tests/test_routes.py
|
||||
git commit -m "test(packs-lab): 기존 4 라우트 통합 테스트 (sign-link, upload, list, delete)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `tests/test_dsm_client.py` — DSM client mock 테스트
|
||||
|
||||
**Files:**
|
||||
- Create: `packs-lab/tests/test_dsm_client.py`
|
||||
|
||||
- [ ] **Step 1: DSM client 테스트 작성**
|
||||
|
||||
`packs-lab/tests/test_dsm_client.py`:
|
||||
|
||||
```python
|
||||
"""DSM 7.x API client 테스트 — httpx mock으로 외부 호출 차단."""
|
||||
import asyncio
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
import httpx
|
||||
|
||||
from app.dsm_client import create_share_link, DSMError, _login, _logout
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _dsm_env(monkeypatch):
|
||||
monkeypatch.setenv("DSM_HOST", "https://test-nas:5001")
|
||||
monkeypatch.setenv("DSM_USER", "test-user")
|
||||
monkeypatch.setenv("DSM_PASS", "test-pass")
|
||||
# 모듈 캐시도 갱신
|
||||
from app import dsm_client
|
||||
monkeypatch.setattr(dsm_client, "DSM_HOST", "https://test-nas:5001")
|
||||
monkeypatch.setattr(dsm_client, "DSM_USER", "test-user")
|
||||
monkeypatch.setattr(dsm_client, "DSM_PASS", "test-pass")
|
||||
|
||||
|
||||
def _make_response(json_data, status_code=200):
|
||||
"""httpx.Response mock."""
|
||||
mock = MagicMock(spec=httpx.Response)
|
||||
mock.json.return_value = json_data
|
||||
mock.status_code = status_code
|
||||
mock.raise_for_status = MagicMock()
|
||||
return mock
|
||||
|
||||
|
||||
def test_create_share_link_login_logout():
|
||||
"""login → Sharing.create → logout 순서가 보장되어야 한다."""
|
||||
call_order = []
|
||||
|
||||
async def fake_get(self, url, *, params=None, **kw):
|
||||
api = (params or {}).get("api", "")
|
||||
method = (params or {}).get("method", "")
|
||||
call_order.append(f"{api}.{method}")
|
||||
if api == "SYNO.API.Auth" and method == "login":
|
||||
return _make_response({"success": True, "data": {"sid": "fake-sid"}})
|
||||
if api == "SYNO.API.Auth" and method == "logout":
|
||||
return _make_response({"success": True})
|
||||
if api == "SYNO.FileStation.Sharing" and method == "create":
|
||||
return _make_response({
|
||||
"success": True,
|
||||
"data": {"links": [{"url": "https://test-nas:5001/sharing/abc"}]},
|
||||
})
|
||||
return _make_response({"success": False, "error": "unexpected"})
|
||||
|
||||
with patch.object(httpx.AsyncClient, "get", new=fake_get):
|
||||
url, expires_at = asyncio.run(create_share_link("/volume1/test/file.zip", expires_in_sec=3600))
|
||||
|
||||
assert url == "https://test-nas:5001/sharing/abc"
|
||||
assert call_order == [
|
||||
"SYNO.API.Auth.login",
|
||||
"SYNO.FileStation.Sharing.create",
|
||||
"SYNO.API.Auth.logout",
|
||||
]
|
||||
|
||||
|
||||
def test_create_share_link_returns_url_and_expiry():
|
||||
"""응답 파싱 — links[0].url 사용."""
|
||||
async def fake_get(self, url, *, params=None, **kw):
|
||||
method = (params or {}).get("method", "")
|
||||
if method == "login":
|
||||
return _make_response({"success": True, "data": {"sid": "sid"}})
|
||||
if method == "create":
|
||||
return _make_response({
|
||||
"success": True,
|
||||
"data": {"links": [{"url": "https://nas/sharing/xyz"}]},
|
||||
})
|
||||
return _make_response({"success": True})
|
||||
|
||||
with patch.object(httpx.AsyncClient, "get", new=fake_get):
|
||||
url, expires_at = asyncio.run(create_share_link("/volume1/test/file.zip", expires_in_sec=7200))
|
||||
|
||||
assert url == "https://nas/sharing/xyz"
|
||||
assert expires_at is not None
|
||||
|
||||
|
||||
def test_dsm_login_failure_raises():
|
||||
"""login API success=False → DSMError."""
|
||||
async def fake_get(self, url, *, params=None, **kw):
|
||||
return _make_response({"success": False, "error": {"code": 400}})
|
||||
|
||||
with patch.object(httpx.AsyncClient, "get", new=fake_get):
|
||||
with pytest.raises(DSMError, match="login 실패"):
|
||||
asyncio.run(create_share_link("/volume1/test/file.zip"))
|
||||
|
||||
|
||||
def test_dsm_share_failure_logs_out():
|
||||
"""Sharing.create 실패해도 logout 호출 (try/finally)."""
|
||||
call_order = []
|
||||
|
||||
async def fake_get(self, url, *, params=None, **kw):
|
||||
method = (params or {}).get("method", "")
|
||||
call_order.append(method)
|
||||
if method == "login":
|
||||
return _make_response({"success": True, "data": {"sid": "sid"}})
|
||||
if method == "create":
|
||||
return _make_response({"success": False, "error": {"code": 401}})
|
||||
if method == "logout":
|
||||
return _make_response({"success": True})
|
||||
return _make_response({"success": False})
|
||||
|
||||
with patch.object(httpx.AsyncClient, "get", new=fake_get):
|
||||
with pytest.raises(DSMError, match="Sharing.create 실패"):
|
||||
asyncio.run(create_share_link("/volume1/test/file.zip"))
|
||||
|
||||
assert "login" in call_order
|
||||
assert "logout" in call_order, "logout이 호출되지 않음 (finally 누락 의심)"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 테스트 실행**
|
||||
|
||||
```bash
|
||||
cd packs-lab
|
||||
python -m pytest tests/test_dsm_client.py -v
|
||||
```
|
||||
|
||||
Expected: 4 passed.
|
||||
|
||||
- [ ] **Step 3: 커밋**
|
||||
|
||||
```bash
|
||||
git add packs-lab/tests/test_dsm_client.py
|
||||
git commit -m "test(packs-lab): DSM client mock 테스트 (login/share/logout 순서)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: DELETE 라우트 docstring 수정
|
||||
|
||||
`routes.py` 모듈 docstring의 한 줄 변경.
|
||||
|
||||
**Files:**
|
||||
- Modify: `packs-lab/app/routes.py:1-7` (모듈 docstring)
|
||||
|
||||
- [ ] **Step 1: docstring 수정**
|
||||
|
||||
`packs-lab/app/routes.py` 첫 docstring을 다음으로 변경:
|
||||
|
||||
```python
|
||||
"""packs-lab API 엔드포인트.
|
||||
|
||||
- POST /api/packs/sign-link — Vercel HMAC 인증 → DSM 공유 링크
|
||||
- POST /api/packs/admin/mint-token — Vercel HMAC 인증 → 일회성 upload 토큰
|
||||
- POST /api/packs/upload — 일회성 토큰 인증 → multipart 저장 + supabase INSERT
|
||||
- GET /api/packs/list — Vercel HMAC 인증 → pack_files 전체 조회
|
||||
- DELETE /api/packs/{file_id} — Vercel HMAC 인증 → soft delete (DSM 공유는 자동 만료)
|
||||
"""
|
||||
```
|
||||
|
||||
(변경: `정리` → `자동 만료`, mint-token 줄 추가)
|
||||
|
||||
- [ ] **Step 2: 회귀 검증**
|
||||
|
||||
```bash
|
||||
cd packs-lab
|
||||
python -m pytest tests/ -v
|
||||
```
|
||||
|
||||
Expected: 모든 테스트 그대로 통과 (15 passed).
|
||||
|
||||
- [ ] **Step 3: 커밋**
|
||||
|
||||
```bash
|
||||
git add packs-lab/app/routes.py
|
||||
git commit -m "docs(packs-lab): routes 모듈 docstring 정리 (mint-token 추가, DSM 자동 만료 명시)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Supabase `pack_files` DDL
|
||||
|
||||
운영 적용 시 Supabase SQL editor에서 실행할 SQL 파일.
|
||||
|
||||
**Files:**
|
||||
- Create: `packs-lab/supabase/pack_files.sql`
|
||||
|
||||
- [ ] **Step 1: SQL 파일 생성**
|
||||
|
||||
`packs-lab/supabase/pack_files.sql`:
|
||||
|
||||
```sql
|
||||
-- pack_files: NAS에 저장된 다운로드 가능한 패키지 파일 메타
|
||||
-- 운영 적용: Supabase Dashboard → SQL editor에서 실행
|
||||
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,
|
||||
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;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 커밋**
|
||||
|
||||
```bash
|
||||
git add packs-lab/supabase/pack_files.sql
|
||||
git commit -m "feat(packs-lab): Supabase pack_files DDL + 활성/삭제 인덱스"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 인프라 통합 — docker-compose / nginx / .env.example
|
||||
|
||||
**Files:**
|
||||
- Modify: `docker-compose.yml` (packs-lab 서비스 추가)
|
||||
- Modify: `nginx/default.conf` (`/api/packs/` 라우팅)
|
||||
- Modify: `.env.example` (6+1 환경변수)
|
||||
|
||||
- [ ] **Step 1: docker-compose.yml — packs-lab 서비스 추가**
|
||||
|
||||
`docker-compose.yml`에서 다른 lab 서비스(예: `realestate-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
|
||||
```
|
||||
|
||||
- [ ] **Step 2: nginx/default.conf — /api/packs/ 라우팅**
|
||||
|
||||
기존 `location /api/agent-office/ { ... }` 다음(또는 다른 `/api/...` 라우트들 근처)에 추가:
|
||||
|
||||
```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;
|
||||
proxy_read_timeout 1800s;
|
||||
proxy_send_timeout 1800s;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: .env.example — 6+1 환경변수 추가**
|
||||
|
||||
`.env.example` 끝에 추가:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
- [ ] **Step 4: docker compose config 검증**
|
||||
|
||||
```bash
|
||||
cd C:\Users\jaeoh\Desktop\workspace\web-backend
|
||||
docker compose config 2>&1 | grep -A 10 "packs-lab:"
|
||||
```
|
||||
|
||||
Expected: packs-lab 서비스 정의가 정상 출력 (port mapping, environment 변수, volumes 모두 보임). 환경변수가 비어있어도 docker compose config는 통과.
|
||||
|
||||
> ⚠️ Docker가 로컬에 설치되어 있어야 검증 가능. 실제 실행은 NAS에서. 로컬 docker가 없으면 step skip하고 nginx config 문법만 별도 검증.
|
||||
|
||||
- [ ] **Step 5: 커밋**
|
||||
|
||||
```bash
|
||||
git add docker-compose.yml nginx/default.conf .env.example
|
||||
git commit -m "chore(infra): packs-lab 서비스 통합 (compose 18950 + nginx 5GB streaming + env 7개)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: NAS 디렉토리 준비 가이드 + 문서 갱신
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-backend/CLAUDE.md` (5곳 갱신)
|
||||
- Modify: `workspace/CLAUDE.md` (1줄 추가)
|
||||
|
||||
- [ ] **Step 1: web-backend/CLAUDE.md — 1.프로젝트 개요**
|
||||
|
||||
찾을 위치 (1.프로젝트 개요 섹션):
|
||||
|
||||
```
|
||||
- **서비스**: 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개)
|
||||
```
|
||||
|
||||
같은 섹션의 인프라 줄도:
|
||||
|
||||
```
|
||||
- **인프라**: Docker Compose (10컨테이너) + Nginx(리버스 프록시) + Gitea Webhook 자동 배포
|
||||
```
|
||||
|
||||
- [ ] **Step 2: web-backend/CLAUDE.md — 4.Docker 서비스 표**
|
||||
|
||||
표 마지막에 신규 행 추가 (deployer 행 직전 또는 personal 행 다음 — 알파벳 순):
|
||||
|
||||
```
|
||||
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (DSM 공유 링크 + 5GB 업로드, Vercel SaaS와 HMAC 통신) |
|
||||
```
|
||||
|
||||
- [ ] **Step 3: web-backend/CLAUDE.md — 5.Nginx 라우팅 표**
|
||||
|
||||
표 적절한 위치에 신규 행 추가:
|
||||
|
||||
```
|
||||
| `/api/packs/` | `packs-lab:8000` | 5GB 업로드 대응 (`client_max_body_size 5G`, `proxy_request_buffering off`, 1800s timeout) |
|
||||
```
|
||||
|
||||
- [ ] **Step 4: web-backend/CLAUDE.md — 8.로컬 개발 표**
|
||||
|
||||
표 끝에 신규 행 추가:
|
||||
|
||||
```
|
||||
| Packs Lab | http://localhost:18950 |
|
||||
```
|
||||
|
||||
- [ ] **Step 5: web-backend/CLAUDE.md — 9.서비스별 packs-lab 신규 섹션**
|
||||
|
||||
`### deployer (deployer/)` 섹션 직전에 추가 (또는 personal 다음):
|
||||
|
||||
```
|
||||
### packs-lab (packs-lab/)
|
||||
- NAS 자료 다운로드 자동화 — Synology DSM 공유링크 발급 + 5GB 멀티파트 업로드 수신
|
||||
- Vercel SaaS와 HMAC 인증으로 통신, 사용자 인증은 Vercel이 Supabase로 처리 (본 서비스는 외부 인증 없음)
|
||||
- DB: 외부 Supabase `pack_files` 테이블 (DDL: `packs-lab/supabase/pack_files.sql`)
|
||||
- 파일 구조: `app/main.py`, `app/auth.py`, `app/dsm_client.py`, `app/routes.py`, `app/models.py`
|
||||
- 운영 디렉토리: `/volume1/docker/webpage/media/packs/{starter,pro,master}/` (NAS PUID:PGID 권한 필요)
|
||||
|
||||
**환경변수**
|
||||
- `DSM_HOST` / `DSM_USER` / `DSM_PASS`: Synology DSM 7.x 인증 (공유 링크 발급용)
|
||||
- `BACKEND_HMAC_SECRET`: Vercel SaaS와 양쪽 공유 시크릿 (HMAC SHA256)
|
||||
- `SUPABASE_URL` / `SUPABASE_SERVICE_KEY`: Supabase pack_files 테이블 접근 (service_role, RLS 우회)
|
||||
- `UPLOAD_TOKEN_TTL_SEC`: admin upload 토큰 TTL (기본 1800초 = 30분)
|
||||
- `PACK_DATA_PATH`: 호스트 마운트 경로 (로컬 `./data/packs`, NAS `/volume1/docker/webpage/media/packs`)
|
||||
|
||||
**HMAC 인증 패턴**
|
||||
- Vercel → backend 요청: `X-Timestamp` (UNIX 초) + `X-Signature` (HMAC_SHA256(timestamp + "." + body, secret))
|
||||
- Replay 방어: 타임스탬프 ±5분 윈도우
|
||||
- admin browser → backend upload: `Authorization: Bearer <token>` (jti 단발성)
|
||||
|
||||
**packs-lab API 목록**
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| POST | `/api/packs/sign-link` | Vercel HMAC → DSM Sharing.create로 4시간 유효 다운로드 URL 발급 |
|
||||
| POST | `/api/packs/admin/mint-token` | Vercel HMAC → 일회성 upload 토큰 발급 (기본 30분 TTL) |
|
||||
| POST | `/api/packs/upload` | Bearer token → multipart 5GB 저장 + Supabase INSERT |
|
||||
| GET | `/api/packs/list` | Vercel HMAC → 활성 pack_files 목록 (deleted_at IS NULL) |
|
||||
| DELETE | `/api/packs/{file_id}` | Vercel HMAC → soft delete (DSM 공유는 자동 만료) |
|
||||
```
|
||||
|
||||
- [ ] **Step 6: workspace/CLAUDE.md — 컨테이너 표 한 줄 추가**
|
||||
|
||||
`workspace/CLAUDE.md`의 "Docker 서비스 & 포트" 표에 추가:
|
||||
|
||||
```
|
||||
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (Vercel SaaS와 HMAC 통신) |
|
||||
```
|
||||
|
||||
(personal 행 다음 또는 적절한 위치)
|
||||
|
||||
- [ ] **Step 7: 커밋 (web-backend repo의 CLAUDE.md만)**
|
||||
|
||||
작업 디렉토리는 `C:\Users\jaeoh\Desktop\workspace\web-backend`. 그 안의 `CLAUDE.md`만 git 추적 대상.
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md
|
||||
git commit -m "docs(claude): packs-lab 10번째 서비스로 등록 (포트/라우팅/API 표 + 신규 섹션)"
|
||||
```
|
||||
|
||||
> ℹ️ `workspace/CLAUDE.md`(상위 디렉토리의 워크스페이스 메모)는 git repo가 아님. 텍스트 편집만 하고 commit 대상에서 제외.
|
||||
|
||||
---
|
||||
|
||||
## Task 9: 회귀 검증 + NAS 디렉토리 가이드
|
||||
|
||||
전체 테스트 + docker compose config + NAS 배포 전 가이드.
|
||||
|
||||
**Files:**
|
||||
- (검증만)
|
||||
|
||||
- [ ] **Step 1: 전체 pytest**
|
||||
|
||||
```bash
|
||||
cd packs-lab
|
||||
python -m pytest tests/ -v
|
||||
```
|
||||
|
||||
Expected: 모든 테스트 통과 (test_auth + test_routes + test_dsm_client = 약 15+ tests).
|
||||
|
||||
- [ ] **Step 2: docker compose config 검증**
|
||||
|
||||
```bash
|
||||
cd C:\Users\jaeoh\Desktop\workspace\web-backend
|
||||
docker compose config 2>&1 | tail -30
|
||||
```
|
||||
|
||||
Expected: error 없이 packs-lab 포함된 전체 config 출력.
|
||||
|
||||
> ⚠️ Docker 미설치 시 skip. NAS에서 git push 후 webhook 배포 시점에 검증됨.
|
||||
|
||||
- [ ] **Step 3: NAS 배포 전 가이드 출력**
|
||||
|
||||
배포 전 NAS에서 SSH로 1회 실행할 명령들을 README 또는 NAS 배포 노트로 정리. 본 task에서는 명령만 제시 (실행은 사용자):
|
||||
|
||||
```bash
|
||||
# NAS SSH로 접속 후
|
||||
mkdir -p /volume1/docker/webpage/media/packs/{starter,pro,master}
|
||||
chown -R PUID:PGID /volume1/docker/webpage/media/packs # PUID/PGID는 .env 값 사용
|
||||
|
||||
# .env에 신규 환경변수 추가 (DSM_*, BACKEND_HMAC_SECRET, SUPABASE_*, UPLOAD_TOKEN_TTL_SEC, PACK_DATA_PATH=/volume1/docker/webpage/media/packs)
|
||||
|
||||
# Supabase에서 packs-lab/supabase/pack_files.sql 실행
|
||||
|
||||
# git push 후 webhook이 자동 배포
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 최종 commit (검증 결과 빈 commit으로 마일스톤 표시 — 선택)**
|
||||
|
||||
```bash
|
||||
# 만약 위 step에서 어떤 자동 수정이 있었으면 commit. 없으면 skip.
|
||||
git status
|
||||
```
|
||||
|
||||
회귀 검증으로 변경 사항 없으면 별도 commit 없이 종료.
|
||||
|
||||
---
|
||||
|
||||
## 완료 기준
|
||||
|
||||
- 모든 task의 step 통과 (체크박스 모두 체크)
|
||||
- `cd packs-lab && python -m pytest tests/ -v` — 통과 (test_auth + test_routes + test_dsm_client)
|
||||
- `docker compose config` — packs-lab 포함된 전체 config 정상
|
||||
- web-backend/CLAUDE.md 5곳 갱신 + workspace/CLAUDE.md 1줄
|
||||
- Supabase DDL 파일 존재 (운영 적용은 사용자가 NAS에서 SQL editor로)
|
||||
- NAS 디렉토리 준비 명령은 사용자가 SSH로 실행 (배포 전 1회)
|
||||
|
||||
---
|
||||
|
||||
## 배포
|
||||
|
||||
git push → Gitea webhook → deployer rsync → docker compose up -d --build (자동).
|
||||
|
||||
**배포 전 사용자 액션 (1회)**:
|
||||
1. Supabase에서 `pack_files` 테이블 생성 (DDL 실행)
|
||||
2. NAS SSH로 `/volume1/docker/webpage/media/packs/{starter,pro,master}` 디렉토리 생성 + 권한
|
||||
3. NAS `.env`에 신규 7개 환경변수 입력 (DSM 인증, HMAC secret, Supabase 키 등)
|
||||
|
||||
---
|
||||
|
||||
## 참고 — 후속 별도 plan (스코프 외)
|
||||
|
||||
- Vercel SaaS-side admin UI / 사용자 다운로드 UI / Supabase user 테이블
|
||||
- DSM 공유 추적 (즉시 차단 필요 시)
|
||||
- deleted_at + N일 후 실제 파일 삭제 cron
|
||||
- multi-admin 토큰 발급 권한 분리
|
||||
- resumable multipart 업로드 (5GB tus 등)
|
||||
- pack_files sort_order 편집 endpoint
|
||||
- 모니터링 (업로드 실패율, DSM API latency)
|
||||
@@ -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