Skip to content
Merged

Dk #10

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.12
27 changes: 27 additions & 0 deletions .vercelignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
.env
.env.*
.venv
.git
.pytest_cache
.ruff_cache
__pycache__
graphify-out
gun
phases
tests
scripts
docs
.agents
.codex
.claude
.serena
.impeccable
*.pdf
*.pptx
*.docx
*.ipynb
/*.xlsx
data/route_cache.json
data/관광데이터랩
data/한국문화관광연구원
data/체류시간분석
50 changes: 50 additions & 0 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<!-- updated: 2026-09-19 | hash: f9c33432 | summary: Vercel 배포 설정과 검증 범위 및 운영 제약 -->
# Vercel 배포

프로젝트 루트를 배포한다. `pyproject.toml`의 `[tool.vercel]`에 ASGI 진입점
`src.api.main:app`을 명시한다. `vercel.json`의 함수 설정도 `src/api/main.py`에 적용한다.
프레임워크는 FastAPI로 선택하고 Docker나 프론트엔드용
빌드 명령, Output Directory를 별도로 지정하지 않는다. `.python-version`은
Python 3.12를 지정한다. `package.json`의 pptxgenjs는 발표자료용이며 웹 빌드가 아니다.

## 환경변수

Vercel 프로젝트 설정에 Production과 필요한 Preview 환경변수를 등록한다.
`.env`는 업로드하지 않는다.

- `ENVIRONMENT=production`
- `API_AUTH_TOKEN`: 운영자가 생성한 접근 키. 웹 화면에서 첫 인증 오류 시 입력하며 메모리에만 보관한다.
- `ANTHROPIC_API_KEY`, `CLAUDE_MODEL`: 문서 파싱과 설명 생성에 사용할 키와 계정에서 사용 가능한 모델.
- `KAKAO_REST_API_KEY`, `KAKAO_MOBILITY_KEY`: 장소 검색과 도로 이동시간 계산.
- `TOUR_API_KEY`, `SEOUL_DATA_API_KEY`: 관광 데이터와 서울 혼잡도.
- `CORS_ALLOWED_ORIGINS`: 별도 프론트엔드를 둘 경우 정확한 HTTPS origin. 같은 도메인의 웹 화면은 CORS 설정 없이 요청 가능.

Vercel 환경(`VERCEL=1`)에서는 환경 지정이 없으면 production이 기본값이다.
접근 키가 없으면 비용이 발생하는 API는 503으로 거부한다. 접근 키를
프론트엔드 소스에 넣지 않는다. 이 방식은 운영자/초대 사용자용이며, 공개 서비스의
회원 인증을 대체하지 않는다. 공개 운영에는 사용자 인증과 플랫폼 측 비용 제한이 필요하다.

## 서버리스 대응

- 정적 화면과 데이터 파일을 Python 함수 번들에 포함한다.
- 이동시간 캐시는 `/tmp/qtrip/route_cache.json`에 쓴다. 인스턴스 간 공유하거나 영구 저장하지 않는다.
- 업로드는 4MiB까지 허용한다. 큰 문서는 분할하거나 텍스트 입력을 사용한다.
- 함수 실행시간은 300초로 요청한다. 배포 계정에서 적용되는 실행시간을 확인한다.
- Rate Limit은 프로세스별이다. 여러 인스턴스 전체를 제한하려면 Vercel 방화벽 등의 별도 제한이 필요하다.
- SciPy, scikit-learn, OR-Tools를 포함하므로 실제 Linux 빌드의 함수 용량을 반드시 확인한다.
용량 초과 시 라이브러리를 무작정 제외하면 최적화 기능이 달라진다. 백엔드를 별도
컨테이너 서비스로 분리하는 방법을 검토한다.

## 공개 전 확인

로컬 테스트 통과는 Vercel 배포 성공을 보장하지 않는다. Preview 빌드에서
의존성 설치와 함수 크기, 콜드 스타트를 확인한 후 다음을 점검한다.

1. `/`, `/static/landing.css`, `/static/qtrip-map.png`가 200인지 확인한다.
2. `/health`가 200인지 확인한다. 이 엔드포인트는 외부 API 연결 성공을 보증하지 않는다.
3. 키 없이 `/api/validate` 요청 시 401, 올바른 키와 잘못된 요청 본문은 422인지 확인한다.
4. 웹에서 일정 텍스트와 4MiB 이하 PDF를 각각 입력하고 검증 결과까지 확인한다.
5. 실제 이동시간 API와 Claude 호출 성공 여부를 Vercel 로그에서 확인한다.
6. 모바일 화면과 재방문 시 서비스 워커의 새 버전 적용을 확인한다.

개발 확인: `python -m pytest tests/ -q`, `ruff check src/ tests/ app.py`.
5 changes: 5 additions & 0 deletions app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Vercel ASGI entry point; also usable with uvicorn app:app."""

from src.api.main import app

__all__ = ["app"]
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ dev = [
where = ["."]
include = ["src*"]

[tool.vercel]
entrypoint = "src.api.main:app"

[tool.ruff]
line-length = 100
target-version = "py311"
Expand Down
6 changes: 3 additions & 3 deletions src/api/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@

# 일정표 업로드(PDF·이미지·복사 텍스트) 파싱 — Claude Vision + 쿼리 리라이팅.
_EXTRACTOR = PlanExtractor.from_env()
_MAX_UPLOAD_BYTES = 15 * 1024 * 1024 # 15MB
_MAX_UPLOAD_BYTES = 4 * 1024 * 1024 # Leave room for multipart overhead on Vercel.
_ALLOWED_DOC_TYPES = {
"application/pdf": "application/pdf",
"image/png": "image/png",
Expand Down Expand Up @@ -1532,11 +1532,11 @@ async def parse_document(file: UploadFile) -> ParsedPlanResponse:
media_type = _ALLOWED_DOC_TYPES.get((file.content_type or "").lower())
if media_type is None:
raise HTTPException(status_code=415, detail="PDF, PNG, JPG 파일만 지원합니다.")
data = await file.read()
data = await file.read(_MAX_UPLOAD_BYTES + 1)
if not data:
raise HTTPException(status_code=422, detail="빈 파일입니다.")
if len(data) > _MAX_UPLOAD_BYTES:
raise HTTPException(status_code=413, detail="파일이 너무 큽니다 (최대 15MB).")
raise HTTPException(status_code=413, detail="파일이 너무 큽니다 (최대 4MB).")
try:
return await asyncio.to_thread(_EXTRACTOR.extract_from_document, data, media_type)
except PlanExtractionError as e:
Expand Down
34 changes: 34 additions & 0 deletions src/api/static/api-client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/* Deployment access key is entered by the operator, never embedded in assets.
Keep it in memory only; reload clears it. */
(() => {
const originalFetch = window.fetch.bind(window);
const protectedPaths = new Set(['/api/validate', '/api/parse/text', '/api/parse/document']);
let accessToken = '';
let askingForToken = false;
window.fetch = async (input, init = {}) => {
const url = new URL(input instanceof Request ? input.url : input, location.href);
if (url.origin !== location.origin || !protectedPaths.has(url.pathname)) {
return originalFetch(input, init);
}
const send = () => {
const headers = new Headers(init.headers || (input instanceof Request ? input.headers : undefined));
if (accessToken) headers.set('Authorization', 'Bearer ' + accessToken);
return originalFetch(input instanceof Request ? input.clone() : input, {...init, headers});
};
let response = await send();
if (response.status === 401 && !askingForToken) {
askingForToken = true;
try {
const entered = window.prompt('이 서비스는 접근 키가 필요합니다. 운영자에게 받은 접근 키를 입력해 주세요.');
if (entered && entered.trim()) {
accessToken = entered.trim();
response = await send();
if (response.status === 401) accessToken = '';
}
} finally {
askingForToken = false;
}
}
return response;
};
})();
6 changes: 6 additions & 0 deletions src/api/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1694,6 +1694,7 @@ <h3>데이터 라이선스</h3>
</div>
</div>

<script src="/static/api-client.js?v=20260919"></script>
<script>
/* ─── State ─────────────────────────────────────────────────────────── */
var cfg = { region:"제주", date:"2026-06-01", startTime:"09:00", partyType:"친구", partySize:2, travelType:"", petFriendly:false };
Expand Down Expand Up @@ -1826,6 +1827,11 @@ <h3>데이터 라이선스</h3>
}

function setUploadFile(f) {
if (f.size > 4 * 1024 * 1024) {
document.getElementById("upload-status").textContent = "파일은 최대 4MB까지 업로드할 수 있습니다. 파일을 줄이거나 일정 텍스트를 붙여넣어 주세요.";
document.getElementById("upload-file-inp").value = "";
return;
}
_uploadFile = f;
document.getElementById("upload-drop-label").textContent = f.name+" 선택됨 (다른 파일을 올리려면 다시 클릭)";
document.getElementById("upload-text-inp").value = "";
Expand Down
2 changes: 1 addition & 1 deletion src/api/static/service-worker.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const CACHE = 'qtrip-v2-map-landing';
const CACHE = 'qtrip-v3-vercel';
const SHELL = ['/'];

self.addEventListener('install', function(e) {
Expand Down
5 changes: 4 additions & 1 deletion src/data/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from __future__ import annotations

import hashlib
import os
import re
from datetime import date as date_type
from pathlib import Path
Expand Down Expand Up @@ -304,7 +305,9 @@ def hard_fail_score_cap(self) -> ValidationResult:


class Settings(BaseSettings):
environment: str = "development"
environment: str = Field(
default_factory=lambda: "production" if os.getenv("VERCEL") == "1" else "development"
)
api_auth_token: str = ""
cors_allowed_origins: str = "http://localhost:8000,http://127.0.0.1:8000"
anthropic_api_key: str = ""
Expand Down
7 changes: 6 additions & 1 deletion src/matrix/route_matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import os
from pathlib import Path

from src.data.models import POI, Settings, VRPTWPlace
Expand Down Expand Up @@ -31,7 +32,11 @@ def from_settings(
return cls(HaversineMatrix())

if cache_path is None:
cache_path = Path(__file__).resolve().parents[2] / "data" / "route_cache.json"
cache_path = (
Path("/tmp/qtrip/route_cache.json")
if os.getenv("VERCEL") == "1"
else Path(__file__).resolve().parents[2] / "data" / "route_cache.json"
)
return cls(KakaoMobilityMatrix(api_key=api_key, cache_path=cache_path))

@property
Expand Down
36 changes: 36 additions & 0 deletions tests/test_vercel_runtime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from fastapi.testclient import TestClient

from src.api.main import app
from src.api.security import _LIMITER
from src.data.models import Settings
from src.matrix.route_matrix import RouteMatrixService


def test_vercel_cache_uses_writable_temp_directory(monkeypatch):
monkeypatch.setenv("VERCEL", "1")
service = RouteMatrixService.from_settings(Settings(kakao_mobility_key="test", _env_file=None))
assert str(service.provider._cache_path).startswith("/tmp/")


def test_vercel_defaults_to_production(monkeypatch):
monkeypatch.setenv("VERCEL", "1")
monkeypatch.delenv("ENVIRONMENT", raising=False)
assert Settings(_env_file=None).environment == "production"


def test_static_deployment_assets():
with TestClient(app) as client:
for path in ("/", "/static/landing.css", "/static/qtrip-map.png", "/static/api-client.js"):
assert client.get(path).status_code == 200


def test_oversize_document_is_rejected_before_extraction(monkeypatch):
monkeypatch.setenv("ENVIRONMENT", "development")
_LIMITER._hits.clear()
with TestClient(app) as client:
response = client.post(
"/api/parse/document",
files={"file": ("large.pdf", b"x" * (4 * 1024 * 1024 + 1), "application/pdf")},
)
assert response.status_code == 413
assert "4MB" in response.json()["detail"]
10 changes: 10 additions & 0 deletions vercel.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"functions": {
"src/api/main.py": {
"maxDuration": 300,
"includeFiles": "{src/**,data/**}",
"excludeFiles": "{tests/**,scripts/**,gun/**,graphify-out/**,phases/**,.venv/**}"
}
}
}
Loading