Compare commits
6 Commits
955fc4ee1e
...
4d6296bce3
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d6296bce3 | |||
| c6366ad238 | |||
| b671d275eb | |||
| bb97aa3ec8 | |||
| 335ea012cc | |||
| c168656fe1 |
39
CLAUDE.md
39
CLAUDE.md
@@ -7,7 +7,7 @@
|
||||
## 1. 프로젝트 개요
|
||||
|
||||
Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
||||
- **서비스**: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, deployer (8개)
|
||||
- **서비스**: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, portfolio, deployer (9개)
|
||||
- **프론트엔드**: 별도 레포 (React + Vite SPA), 빌드 산출물만 NAS에 배포
|
||||
- **인프라**: Docker Compose (9컨테이너) + Nginx(리버스 프록시) + Gitea Webhook 자동 배포
|
||||
|
||||
@@ -59,6 +59,7 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
||||
| `blog-lab` | 18700 | 블로그 마케팅 수익화 API |
|
||||
| `realestate-lab` | 18800 | 부동산 청약 자동 수집·매칭 API |
|
||||
| `agent-office` | 18900 | AI 에이전트 오피스 (실시간 WebSocket + 텔레그램 연동) |
|
||||
| `portfolio` | 18850 | 개인 포트폴리오 (프로필·경력·프로젝트·자기소개 관리) |
|
||||
| `travel-proxy` | 19000 | 여행 사진 API + 썸네일 생성 |
|
||||
| `lotto-frontend` (nginx) | 8080 | 정적 SPA 서빙 + API 리버스 프록시 |
|
||||
| `webpage-deployer` | 19010 | Gitea Webhook 수신 → 자동 배포 |
|
||||
@@ -77,6 +78,7 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
||||
| `/api/music/` | `music-lab:8000` | AI 음악 생성·라이브러리 API |
|
||||
| `/api/blog-marketing/` | `blog-lab:8000` | 블로그 마케팅 수익화 API |
|
||||
| `/api/realestate/` | `realestate-lab:8000` | 부동산 청약 API |
|
||||
| `/api/profile/` | `portfolio:8000` | 포트폴리오 API |
|
||||
| `/api/agent-office/` | `agent-office:8000` | AI 에이전트 오피스 API + WebSocket |
|
||||
| `/webhook`, `/webhook/` | `deployer:9000` | Gitea Webhook |
|
||||
| `/media/music/` | `/data/music/` (파일 직접 서빙) | 생성된 오디오 파일 |
|
||||
@@ -492,6 +494,41 @@ docker compose up -d
|
||||
| GET | `/api/agent-office/states` | 전체 에이전트 상태 조회 |
|
||||
| GET | `/api/agent-office/conversation/stats` | 텔레그램 자연어 대화 토큰·캐시 통계 (`days` 필터) |
|
||||
|
||||
### portfolio (portfolio/)
|
||||
- 개인 포트폴리오 서비스 (프로필, 경력, 프로젝트, 기술스택, 자기소개 관리)
|
||||
- DB: `/app/data/portfolio.db` (profile, careers, projects, skills, introductions 테이블)
|
||||
- 편집 인증: `PORTFOLIO_EDIT_PASSWORD` 환경변수, Bearer 토큰 (24시간 TTL)
|
||||
- 파일 구조: `main.py`, `db.py`, `models.py`, `auth.py`
|
||||
|
||||
**환경변수**
|
||||
- `PORTFOLIO_EDIT_PASSWORD`: 편집 모드 비밀번호 (미설정 시 편집 불가)
|
||||
|
||||
**portfolio API 목록**
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/profile/public` | 공개 데이터 일괄 조회 |
|
||||
| POST | `/api/profile/auth` | 비밀번호 인증 → 토큰 |
|
||||
| GET | `/api/profile/profile` | 프로필 조회 (인증) |
|
||||
| PUT | `/api/profile/profile` | 프로필 수정 (인증) |
|
||||
| GET | `/api/profile/careers` | 경력 목록 (인증) |
|
||||
| POST | `/api/profile/careers` | 경력 추가 (인증) |
|
||||
| PUT | `/api/profile/careers/{id}` | 경력 수정 (인증) |
|
||||
| DELETE | `/api/profile/careers/{id}` | 경력 삭제 (인증) |
|
||||
| GET | `/api/profile/projects` | 프로젝트 목록 (인증) |
|
||||
| POST | `/api/profile/projects` | 프로젝트 추가 (인증) |
|
||||
| PUT | `/api/profile/projects/{id}` | 프로젝트 수정 (인증) |
|
||||
| DELETE | `/api/profile/projects/{id}` | 프로젝트 삭제 (인증) |
|
||||
| GET | `/api/profile/skills` | 기술 목록 (인증) |
|
||||
| POST | `/api/profile/skills` | 기술 추가 (인증) |
|
||||
| PUT | `/api/profile/skills/{id}` | 기술 수정 (인증) |
|
||||
| DELETE | `/api/profile/skills/{id}` | 기술 삭제 (인증) |
|
||||
| GET | `/api/profile/introductions` | 자기소개 목록 (인증) |
|
||||
| POST | `/api/profile/introductions` | 자기소개 추가 (인증) |
|
||||
| PUT | `/api/profile/introductions/{id}` | 자기소개 수정 (인증) |
|
||||
| DELETE | `/api/profile/introductions/{id}` | 자기소개 삭제 (인증) |
|
||||
| PATCH | `/api/profile/introductions/{id}/main` | 메인 자기소개 지정 (인증) |
|
||||
|
||||
### deployer (deployer/)
|
||||
- Webhook 검증: `X-Gitea-Signature` (HMAC SHA256, `compare_digest` 사용)
|
||||
- `WEBHOOK_SECRET` 환경변수로 시크릿 관리
|
||||
|
||||
@@ -149,6 +149,25 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
portfolio:
|
||||
build:
|
||||
context: ./portfolio
|
||||
container_name: portfolio
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18850:8000"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Seoul}
|
||||
- PORTFOLIO_EDIT_PASSWORD=${PORTFOLIO_EDIT_PASSWORD:-}
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||
volumes:
|
||||
- ${RUNTIME_PATH:-.}/data/portfolio:/app/data
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
travel-proxy:
|
||||
build: ./travel-proxy
|
||||
container_name: travel-proxy
|
||||
|
||||
3163
docs/superpowers/plans/2026-04-27-agent-office-v2.md
Normal file
3163
docs/superpowers/plans/2026-04-27-agent-office-v2.md
Normal file
File diff suppressed because it is too large
Load Diff
2129
docs/superpowers/plans/2026-04-27-portfolio.md
Normal file
2129
docs/superpowers/plans/2026-04-27-portfolio.md
Normal file
File diff suppressed because it is too large
Load Diff
497
docs/superpowers/specs/2026-04-27-agent-office-v2-design.md
Normal file
497
docs/superpowers/specs/2026-04-27-agent-office-v2-design.md
Normal file
@@ -0,0 +1,497 @@
|
||||
# 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% 호환
|
||||
355
docs/superpowers/specs/2026-04-27-portfolio-design.md
Normal file
355
docs/superpowers/specs/2026-04-27-portfolio-design.md
Normal file
@@ -0,0 +1,355 @@
|
||||
# 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 내보내기" 대신 "공유" 또는 브라우저 인쇄 기능 활용
|
||||
@@ -140,6 +140,16 @@ server {
|
||||
}
|
||||
|
||||
|
||||
# profile API (Portfolio Service)
|
||||
location /api/profile/ {
|
||||
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://portfolio:8000/api/profile/;
|
||||
}
|
||||
|
||||
# agent-office API + WebSocket
|
||||
location /api/agent-office/ {
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
|
||||
10
portfolio/Dockerfile
Normal file
10
portfolio/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM python:3.12-alpine
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
0
portfolio/app/__init__.py
Normal file
0
portfolio/app/__init__.py
Normal file
39
portfolio/app/auth.py
Normal file
39
portfolio/app/auth.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import os
|
||||
import uuid
|
||||
import time
|
||||
import logging
|
||||
from fastapi import Header, HTTPException
|
||||
|
||||
logger = logging.getLogger("portfolio")
|
||||
|
||||
EDIT_PASSWORD = os.getenv("PORTFOLIO_EDIT_PASSWORD", "")
|
||||
TOKEN_TTL = 86400 # 24시간
|
||||
|
||||
_tokens: dict[str, float] = {} # token -> expiry timestamp
|
||||
|
||||
|
||||
def authenticate(password: str) -> dict:
|
||||
if not EDIT_PASSWORD:
|
||||
raise HTTPException(status_code=503, detail="Edit password not configured")
|
||||
if password != EDIT_PASSWORD:
|
||||
raise HTTPException(status_code=401, detail="Invalid password")
|
||||
token = uuid.uuid4().hex
|
||||
_tokens[token] = time.time() + TOKEN_TTL
|
||||
_cleanup()
|
||||
return {"token": token, "expires_in": TOKEN_TTL}
|
||||
|
||||
|
||||
def require_auth(authorization: str = Header("")):
|
||||
token = authorization.replace("Bearer ", "").strip()
|
||||
if not token or token not in _tokens:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
if time.time() > _tokens[token]:
|
||||
del _tokens[token]
|
||||
raise HTTPException(status_code=401, detail="Token expired")
|
||||
|
||||
|
||||
def _cleanup():
|
||||
now = time.time()
|
||||
expired = [t for t, exp in _tokens.items() if now > exp]
|
||||
for t in expired:
|
||||
del _tokens[t]
|
||||
350
portfolio/app/db.py
Normal file
350
portfolio/app/db.py
Normal file
@@ -0,0 +1,350 @@
|
||||
import sqlite3
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
logger = logging.getLogger("portfolio")
|
||||
|
||||
DB_PATH = "/app/data/portfolio.db"
|
||||
|
||||
|
||||
def _conn():
|
||||
c = sqlite3.connect(DB_PATH, timeout=10)
|
||||
c.row_factory = sqlite3.Row
|
||||
c.execute("PRAGMA journal_mode=WAL;")
|
||||
c.execute("PRAGMA foreign_keys=ON;")
|
||||
return c
|
||||
|
||||
|
||||
def _row_to_dict(r) -> Dict[str, Any]:
|
||||
if r is None:
|
||||
return None
|
||||
d = {c: r[c] for c in r.keys()}
|
||||
if "tech_stack" in d and isinstance(d["tech_stack"], str):
|
||||
try:
|
||||
d["tech_stack"] = json.loads(d["tech_stack"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
d["tech_stack"] = []
|
||||
return d
|
||||
|
||||
|
||||
def init_db():
|
||||
with _conn() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS profile (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
name_en TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT '',
|
||||
role_en TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
phone TEXT NOT NULL DEFAULT '',
|
||||
github_url TEXT NOT NULL DEFAULT '',
|
||||
blog_url TEXT NOT NULL DEFAULT '',
|
||||
photo_url TEXT NOT NULL DEFAULT '',
|
||||
bio TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
INSERT OR IGNORE INTO profile (id) VALUES (1)
|
||||
""")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS careers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
category TEXT NOT NULL DEFAULT 'company',
|
||||
organization TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
start_date TEXT NOT NULL DEFAULT '',
|
||||
end_date TEXT NOT NULL DEFAULT '',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
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'))
|
||||
)
|
||||
""")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
category TEXT NOT NULL DEFAULT 'personal',
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
tech_stack TEXT NOT NULL DEFAULT '[]',
|
||||
role TEXT NOT NULL DEFAULT '',
|
||||
start_date TEXT NOT NULL DEFAULT '',
|
||||
end_date TEXT NOT NULL DEFAULT '',
|
||||
url TEXT NOT NULL DEFAULT '',
|
||||
image_url TEXT NOT NULL DEFAULT '',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
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'))
|
||||
)
|
||||
""")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS skills (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
category TEXT NOT NULL DEFAULT 'language',
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
level INTEGER NOT NULL DEFAULT 3,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
""")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS introductions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
is_main INTEGER NOT NULL DEFAULT 0,
|
||||
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'))
|
||||
)
|
||||
""")
|
||||
logger.info("portfolio DB initialized")
|
||||
|
||||
|
||||
# ── Profile ──
|
||||
|
||||
def get_profile() -> Dict[str, Any]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT * FROM profile WHERE id = 1").fetchone()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
def update_profile(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
fields = {k: v for k, v in data.items() if k != "id" and v is not None}
|
||||
if not fields:
|
||||
return get_profile()
|
||||
set_clauses = ", ".join(f"{k} = ?" for k in fields)
|
||||
set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
f"UPDATE profile SET {set_clauses} WHERE id = 1",
|
||||
list(fields.values()),
|
||||
)
|
||||
return get_profile()
|
||||
|
||||
|
||||
# ── Careers ──
|
||||
|
||||
def get_careers() -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM careers ORDER BY sort_order, start_date DESC").fetchall()
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def create_career(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO careers (category, organization, role, description, start_date, end_date, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(data.get("category", "company"), data.get("organization", ""),
|
||||
data.get("role", ""), data.get("description", ""),
|
||||
data.get("start_date", ""), data.get("end_date", ""),
|
||||
data.get("sort_order", 0)),
|
||||
)
|
||||
row = conn.execute("SELECT * FROM careers ORDER BY id DESC LIMIT 1").fetchone()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
def update_career(career_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
fields = {k: v for k, v in data.items() if k not in ("id", "created_at") and v is not None}
|
||||
if not fields:
|
||||
return get_career(career_id)
|
||||
set_clauses = ", ".join(f"{k} = ?" for k in fields)
|
||||
set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"
|
||||
with _conn() as conn:
|
||||
existing = conn.execute("SELECT id FROM careers WHERE id = ?", (career_id,)).fetchone()
|
||||
if not existing:
|
||||
return None
|
||||
conn.execute(f"UPDATE careers SET {set_clauses} WHERE id = ?", list(fields.values()) + [career_id])
|
||||
row = conn.execute("SELECT * FROM careers WHERE id = ?", (career_id,)).fetchone()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
def delete_career(career_id: int) -> bool:
|
||||
with _conn() as conn:
|
||||
cur = conn.execute("DELETE FROM careers WHERE id = ?", (career_id,))
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def get_career(career_id: int) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT * FROM careers WHERE id = ?", (career_id,)).fetchone()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
# ── Projects ──
|
||||
|
||||
def get_projects() -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM projects ORDER BY sort_order, start_date DESC").fetchall()
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def create_project(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
tech = json.dumps(data.get("tech_stack", []), ensure_ascii=False)
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO projects (category, title, description, tech_stack, role, start_date, end_date, url, image_url, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(data.get("category", "personal"), data.get("title", ""),
|
||||
data.get("description", ""), tech,
|
||||
data.get("role", ""), data.get("start_date", ""),
|
||||
data.get("end_date", ""), data.get("url", ""),
|
||||
data.get("image_url", ""), data.get("sort_order", 0)),
|
||||
)
|
||||
row = conn.execute("SELECT * FROM projects ORDER BY id DESC LIMIT 1").fetchone()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
def update_project(project_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
fields = {k: v for k, v in data.items() if k not in ("id", "created_at") and v is not None}
|
||||
if "tech_stack" in fields and isinstance(fields["tech_stack"], list):
|
||||
fields["tech_stack"] = json.dumps(fields["tech_stack"], ensure_ascii=False)
|
||||
if not fields:
|
||||
return get_project(project_id)
|
||||
set_clauses = ", ".join(f"{k} = ?" for k in fields)
|
||||
set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"
|
||||
with _conn() as conn:
|
||||
existing = conn.execute("SELECT id FROM projects WHERE id = ?", (project_id,)).fetchone()
|
||||
if not existing:
|
||||
return None
|
||||
conn.execute(f"UPDATE projects SET {set_clauses} WHERE id = ?", list(fields.values()) + [project_id])
|
||||
row = conn.execute("SELECT * FROM projects WHERE id = ?", (project_id,)).fetchone()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
def delete_project(project_id: int) -> bool:
|
||||
with _conn() as conn:
|
||||
cur = conn.execute("DELETE FROM projects WHERE id = ?", (project_id,))
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def get_project(project_id: int) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT * FROM projects WHERE id = ?", (project_id,)).fetchone()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
# ── Skills ──
|
||||
|
||||
def get_skills() -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM skills ORDER BY sort_order, category, name").fetchall()
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def create_skill(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO skills (category, name, level, sort_order) VALUES (?, ?, ?, ?)",
|
||||
(data.get("category", "language"), data.get("name", ""),
|
||||
data.get("level", 3), data.get("sort_order", 0)),
|
||||
)
|
||||
row = conn.execute("SELECT * FROM skills ORDER BY id DESC LIMIT 1").fetchone()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
def update_skill(skill_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
fields = {k: v for k, v in data.items() if k != "id" and v is not None}
|
||||
if not fields:
|
||||
return get_skill(skill_id)
|
||||
set_clauses = ", ".join(f"{k} = ?" for k in fields)
|
||||
with _conn() as conn:
|
||||
existing = conn.execute("SELECT id FROM skills WHERE id = ?", (skill_id,)).fetchone()
|
||||
if not existing:
|
||||
return None
|
||||
conn.execute(f"UPDATE skills SET {set_clauses} WHERE id = ?", list(fields.values()) + [skill_id])
|
||||
row = conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
def delete_skill(skill_id: int) -> bool:
|
||||
with _conn() as conn:
|
||||
cur = conn.execute("DELETE FROM skills WHERE id = ?", (skill_id,))
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def get_skill(skill_id: int) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
# ── Introductions ──
|
||||
|
||||
def get_introductions() -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM introductions ORDER BY is_main DESC, updated_at DESC").fetchall()
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def create_introduction(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO introductions (title, content, is_main) VALUES (?, ?, ?)",
|
||||
(data.get("title", ""), data.get("content", ""), data.get("is_main", 0)),
|
||||
)
|
||||
row = conn.execute("SELECT * FROM introductions ORDER BY id DESC LIMIT 1").fetchone()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
def update_introduction(intro_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
fields = {k: v for k, v in data.items() if k not in ("id", "created_at") and v is not None}
|
||||
if not fields:
|
||||
return get_introduction(intro_id)
|
||||
set_clauses = ", ".join(f"{k} = ?" for k in fields)
|
||||
set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"
|
||||
with _conn() as conn:
|
||||
existing = conn.execute("SELECT id FROM introductions WHERE id = ?", (intro_id,)).fetchone()
|
||||
if not existing:
|
||||
return None
|
||||
conn.execute(f"UPDATE introductions SET {set_clauses} WHERE id = ?", list(fields.values()) + [intro_id])
|
||||
row = conn.execute("SELECT * FROM introductions WHERE id = ?", (intro_id,)).fetchone()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
def delete_introduction(intro_id: int) -> bool:
|
||||
with _conn() as conn:
|
||||
cur = conn.execute("DELETE FROM introductions WHERE id = ?", (intro_id,))
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def get_introduction(intro_id: int) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT * FROM introductions WHERE id = ?", (intro_id,)).fetchone()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
def set_main_introduction(intro_id: int) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
existing = conn.execute("SELECT id FROM introductions WHERE id = ?", (intro_id,)).fetchone()
|
||||
if not existing:
|
||||
return None
|
||||
conn.execute("UPDATE introductions SET is_main = 0 WHERE is_main = 1")
|
||||
conn.execute("UPDATE introductions SET is_main = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", (intro_id,))
|
||||
row = conn.execute("SELECT * FROM introductions WHERE id = ?", (intro_id,)).fetchone()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
# ── Public (일괄 조회) ──
|
||||
|
||||
def get_public_data() -> Dict[str, Any]:
|
||||
with _conn() as conn:
|
||||
profile = _row_to_dict(conn.execute("SELECT * FROM profile WHERE id = 1").fetchone())
|
||||
careers = [_row_to_dict(r) for r in conn.execute("SELECT * FROM careers ORDER BY sort_order, start_date DESC").fetchall()]
|
||||
projects = [_row_to_dict(r) for r in conn.execute("SELECT * FROM projects ORDER BY sort_order, start_date DESC").fetchall()]
|
||||
skills = [_row_to_dict(r) for r in conn.execute("SELECT * FROM skills ORDER BY sort_order, category, name").fetchall()]
|
||||
main_intro_row = conn.execute("SELECT * FROM introductions WHERE is_main = 1 LIMIT 1").fetchone()
|
||||
main_introduction = _row_to_dict(main_intro_row) if main_intro_row else None
|
||||
return {
|
||||
"profile": profile,
|
||||
"careers": careers,
|
||||
"projects": projects,
|
||||
"skills": skills,
|
||||
"main_introduction": main_introduction,
|
||||
}
|
||||
190
portfolio/app/main.py
Normal file
190
portfolio/app/main.py
Normal file
@@ -0,0 +1,190 @@
|
||||
import os
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI, Depends, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .db import (
|
||||
init_db, get_public_data,
|
||||
get_profile, update_profile,
|
||||
get_careers, create_career, update_career, delete_career,
|
||||
get_projects, create_project, update_project, delete_project,
|
||||
get_skills, create_skill, update_skill, delete_skill,
|
||||
get_introductions, create_introduction, update_introduction,
|
||||
delete_introduction, set_main_introduction,
|
||||
)
|
||||
from .models import (
|
||||
ProfileUpdate, CareerCreate, CareerUpdate,
|
||||
ProjectCreate, ProjectUpdate, SkillCreate, SkillUpdate,
|
||||
IntroCreate, IntroUpdate, AuthRequest,
|
||||
)
|
||||
from .auth import authenticate, require_auth
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
|
||||
logger = logging.getLogger("portfolio")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
logger.info("portfolio service 시작")
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
_cors_origins = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080").split(",")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[o.strip() for o in _cors_origins],
|
||||
allow_credentials=False,
|
||||
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Content-Type", "Authorization"],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ── Public ──
|
||||
|
||||
@app.get("/api/profile/public")
|
||||
def api_public():
|
||||
return get_public_data()
|
||||
|
||||
|
||||
# ── Auth ──
|
||||
|
||||
@app.post("/api/profile/auth")
|
||||
def api_auth(body: AuthRequest):
|
||||
return authenticate(body.password)
|
||||
|
||||
|
||||
# ── Profile (편집) ──
|
||||
|
||||
@app.get("/api/profile/profile", dependencies=[Depends(require_auth)])
|
||||
def api_profile_get():
|
||||
return get_profile()
|
||||
|
||||
|
||||
@app.put("/api/profile/profile", dependencies=[Depends(require_auth)])
|
||||
def api_profile_update(body: ProfileUpdate):
|
||||
return update_profile(body.model_dump(exclude_none=True))
|
||||
|
||||
|
||||
# ── Careers (편집) ──
|
||||
|
||||
@app.get("/api/profile/careers", dependencies=[Depends(require_auth)])
|
||||
def api_careers_list():
|
||||
return get_careers()
|
||||
|
||||
|
||||
@app.post("/api/profile/careers", status_code=201, dependencies=[Depends(require_auth)])
|
||||
def api_career_create(body: CareerCreate):
|
||||
return create_career(body.model_dump())
|
||||
|
||||
|
||||
@app.put("/api/profile/careers/{career_id}", dependencies=[Depends(require_auth)])
|
||||
def api_career_update(career_id: int, body: CareerUpdate):
|
||||
result = update_career(career_id, body.model_dump(exclude_none=True))
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Career not found")
|
||||
return result
|
||||
|
||||
|
||||
@app.delete("/api/profile/careers/{career_id}", dependencies=[Depends(require_auth)])
|
||||
def api_career_delete(career_id: int):
|
||||
if not delete_career(career_id):
|
||||
raise HTTPException(status_code=404, detail="Career not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Projects (편집) ──
|
||||
|
||||
@app.get("/api/profile/projects", dependencies=[Depends(require_auth)])
|
||||
def api_projects_list():
|
||||
return get_projects()
|
||||
|
||||
|
||||
@app.post("/api/profile/projects", status_code=201, dependencies=[Depends(require_auth)])
|
||||
def api_project_create(body: ProjectCreate):
|
||||
return create_project(body.model_dump())
|
||||
|
||||
|
||||
@app.put("/api/profile/projects/{project_id}", dependencies=[Depends(require_auth)])
|
||||
def api_project_update(project_id: int, body: ProjectUpdate):
|
||||
result = update_project(project_id, body.model_dump(exclude_none=True))
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
return result
|
||||
|
||||
|
||||
@app.delete("/api/profile/projects/{project_id}", dependencies=[Depends(require_auth)])
|
||||
def api_project_delete(project_id: int):
|
||||
if not delete_project(project_id):
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Skills (편집) ──
|
||||
|
||||
@app.get("/api/profile/skills", dependencies=[Depends(require_auth)])
|
||||
def api_skills_list():
|
||||
return get_skills()
|
||||
|
||||
|
||||
@app.post("/api/profile/skills", status_code=201, dependencies=[Depends(require_auth)])
|
||||
def api_skill_create(body: SkillCreate):
|
||||
return create_skill(body.model_dump())
|
||||
|
||||
|
||||
@app.put("/api/profile/skills/{skill_id}", dependencies=[Depends(require_auth)])
|
||||
def api_skill_update(skill_id: int, body: SkillUpdate):
|
||||
result = update_skill(skill_id, body.model_dump(exclude_none=True))
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Skill not found")
|
||||
return result
|
||||
|
||||
|
||||
@app.delete("/api/profile/skills/{skill_id}", dependencies=[Depends(require_auth)])
|
||||
def api_skill_delete(skill_id: int):
|
||||
if not delete_skill(skill_id):
|
||||
raise HTTPException(status_code=404, detail="Skill not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Introductions (편집) ──
|
||||
|
||||
@app.get("/api/profile/introductions", dependencies=[Depends(require_auth)])
|
||||
def api_intros_list():
|
||||
return get_introductions()
|
||||
|
||||
|
||||
@app.post("/api/profile/introductions", status_code=201, dependencies=[Depends(require_auth)])
|
||||
def api_intro_create(body: IntroCreate):
|
||||
return create_introduction(body.model_dump())
|
||||
|
||||
|
||||
@app.put("/api/profile/introductions/{intro_id}", dependencies=[Depends(require_auth)])
|
||||
def api_intro_update(intro_id: int, body: IntroUpdate):
|
||||
result = update_introduction(intro_id, body.model_dump(exclude_none=True))
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Introduction not found")
|
||||
return result
|
||||
|
||||
|
||||
@app.delete("/api/profile/introductions/{intro_id}", dependencies=[Depends(require_auth)])
|
||||
def api_intro_delete(intro_id: int):
|
||||
if not delete_introduction(intro_id):
|
||||
raise HTTPException(status_code=404, detail="Introduction not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.patch("/api/profile/introductions/{intro_id}/main", dependencies=[Depends(require_auth)])
|
||||
def api_intro_set_main(intro_id: int):
|
||||
result = set_main_introduction(intro_id)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Introduction not found")
|
||||
return result
|
||||
90
portfolio/app/models.py
Normal file
90
portfolio/app/models.py
Normal file
@@ -0,0 +1,90 @@
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ProfileUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
name_en: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
role_en: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
github_url: Optional[str] = None
|
||||
blog_url: Optional[str] = None
|
||||
photo_url: Optional[str] = None
|
||||
bio: Optional[str] = None
|
||||
|
||||
|
||||
class CareerCreate(BaseModel):
|
||||
category: str = "company"
|
||||
organization: str = ""
|
||||
role: str = ""
|
||||
description: str = ""
|
||||
start_date: str = ""
|
||||
end_date: str = ""
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class CareerUpdate(BaseModel):
|
||||
category: Optional[str] = None
|
||||
organization: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
start_date: Optional[str] = None
|
||||
end_date: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
|
||||
class ProjectCreate(BaseModel):
|
||||
category: str = "personal"
|
||||
title: str = ""
|
||||
description: str = ""
|
||||
tech_stack: List[str] = []
|
||||
role: str = ""
|
||||
start_date: str = ""
|
||||
end_date: str = ""
|
||||
url: str = ""
|
||||
image_url: str = ""
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class ProjectUpdate(BaseModel):
|
||||
category: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
tech_stack: Optional[List[str]] = None
|
||||
role: Optional[str] = None
|
||||
start_date: Optional[str] = None
|
||||
end_date: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
image_url: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
|
||||
class SkillCreate(BaseModel):
|
||||
category: str = "language"
|
||||
name: str = ""
|
||||
level: int = 3
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class SkillUpdate(BaseModel):
|
||||
category: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
level: Optional[int] = None
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
|
||||
class IntroCreate(BaseModel):
|
||||
title: str = ""
|
||||
content: str = ""
|
||||
is_main: int = 0
|
||||
|
||||
|
||||
class IntroUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
|
||||
|
||||
class AuthRequest(BaseModel):
|
||||
password: str
|
||||
3
portfolio/requirements.txt
Normal file
3
portfolio/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.30.6
|
||||
pydantic>=2.0
|
||||
@@ -2,7 +2,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
# ── 서비스 목록 (한 곳에서만 관리) ──
|
||||
SERVICES="backend travel-proxy deployer stock-lab music-lab blog-lab realestate-lab agent-office nginx scripts"
|
||||
SERVICES="backend travel-proxy deployer stock-lab music-lab blog-lab realestate-lab agent-office portfolio nginx scripts"
|
||||
|
||||
# 1. 자동 감지: Docker 컨테이너 내부인가?
|
||||
if [ -d "/repo" ] && [ -d "/runtime" ]; then
|
||||
|
||||
@@ -7,13 +7,13 @@ flock -n 200 || { echo "Deploy already running, skipping"; exit 0; }
|
||||
|
||||
# ── 서비스 목록 (한 곳에서만 관리) ──
|
||||
# docker compose 서비스명 (deployer 제외 — 자기 자신을 재빌드하면 스크립트 중단)
|
||||
BUILD_TARGETS="backend travel-proxy stock-lab music-lab blog-lab realestate-lab agent-office frontend"
|
||||
BUILD_TARGETS="backend travel-proxy stock-lab music-lab blog-lab realestate-lab agent-office portfolio frontend"
|
||||
# 컨테이너 이름 (고아 정리용)
|
||||
CONTAINER_NAMES="lotto-backend stock-lab music-lab blog-lab realestate-lab agent-office travel-proxy lotto-frontend"
|
||||
CONTAINER_NAMES="lotto-backend stock-lab music-lab blog-lab realestate-lab agent-office portfolio travel-proxy lotto-frontend"
|
||||
# 헬스체크 대상
|
||||
HEALTH_ENDPOINTS="backend stock-lab travel-proxy music-lab blog-lab realestate-lab agent-office"
|
||||
HEALTH_ENDPOINTS="backend stock-lab travel-proxy music-lab blog-lab realestate-lab agent-office portfolio"
|
||||
# data 디렉토리
|
||||
DATA_DIRS="music stock blog realestate agent-office"
|
||||
DATA_DIRS="music stock blog realestate agent-office portfolio"
|
||||
|
||||
# 1. 자동 감지: Docker 컨테이너 내부인가?
|
||||
if [ -d "/repo" ] && [ -d "/runtime" ]; then
|
||||
|
||||
Reference in New Issue
Block a user