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:
2026-05-07 15:06:04 +09:00
parent 24a57f2b69
commit dc92c3d42d
31 changed files with 252 additions and 33124 deletions

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

View File

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

View File

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

View File

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