"""팀원용 로컬 개발 서버 — 유료 API 호출이 '구조적으로' 불가능한 상태로 띄운다. 왜 이 스크립트가 필요한가 ------------------------- 이 앱의 유료 경로(fal.ai 이미지·영상, Replicate, Gemini)는 전부 프로세스 환경변수로만 열린다. `app/services/fal_client.py` 의 def fal_key() -> str: return (os.getenv("FAL_KEY") or "").strip() 가 비면 모든 호출이 `FalUnavailable("... no network call made")` 로 끊긴다. 저장소 어디에도 `load_dotenv` 가 없으므로 `.env` 파일에 키를 적어도 이 경로에는 닿지 않는다. 즉 **띄우는 프로세스에서 키를 지우면 과금이 발생할 수 없다.** 이 스크립트는 그 상태를 만들고, 만들었다고 믿지 않고 **실제로 확인한 뒤** 서버를 띄운다. 확인에 실패하면 서버를 띄우지 않는다. 사용법 ------ python scripts/dev_local.py # 8223 번으로 서버 실행 python scripts/dev_local.py --check # 안전장치만 확인하고 종료 (서버 안 뜸) python scripts/dev_local.py --port 9000 실제 AI 결과가 필요하면 이 스크립트를 쓰지 말고, 키를 직접 넣어 별도로 띄운다. 그건 돈이 나가는 실행이라 일부러 여기서 막아 두었다. """ from __future__ import annotations import argparse import os import sys from pathlib import Path BACKEND = Path(__file__).resolve().parent.parent # 이 프로세스에서 지워 버릴 유료 크리덴셜. 전역 환경에 남아 있어도 서버는 못 본다. PAID_KEYS = ( "FAL_KEY", "REPLICATE_API_TOKEN", "GEMINI_API_KEY", "GOOGLE_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", ) # 무료 경로로 고정하는 설정. mock 프로바이더는 얼굴 박스만 그려서 돌려준다 # (app/services/comfyui_client.py 의 MockAIClient). SAFE_ENV = { "AI_PROVIDER": "mock", "GEMINI_ENABLE_REAL_CALL": "false", "GEMINI_REQUIRE_API_KEY": "false", "COMFYUI_REQUIRE_SERVER": "false", "VIDEO_ENABLED": "false", # Kling 영상도 유료다. 로컬에선 버튼 자체를 숨긴다. "ENV": "local", "ACCESS_CODE": "", # 로컬은 접속코드 게이트 없이 "ACCESS_CODES": "", "DATABASE_URL": "sqlite+pysqlite:///./dev_local.db", "STORAGE_BACKEND": "local", "LOCAL_STORAGE_DIR": "./dev_local_storage", "CELERY_TASK_ALWAYS_EAGER": "true", # Redis/워커 없이 요청 안에서 바로 처리 "CELERY_TASK_EAGER_PROPAGATES": "true", } def harden() -> list[str]: """유료 키를 지우고 무료 설정을 강제한다. 지워진 키 이름을 돌려준다.""" removed = [k for k in PAID_KEYS if (os.environ.get(k) or "").strip()] for k in PAID_KEYS: os.environ[k] = "" for k, v in SAFE_ENV.items(): os.environ[k] = v return removed def verify() -> list[str]: """설정했다고 믿지 않고 앱 코드에 직접 물어본다. 문제를 문자열 목록으로 돌려준다.""" problems: list[str] = [] from app.services.fal_client import fal_real_enabled if fal_real_enabled(): problems.append("fal 유료 호출이 아직 열려 있다 (FAL_KEY 가 살아 있음)") from app.core.config import get_settings s = get_settings() if s.ai_provider != "mock": problems.append(f"AI_PROVIDER 가 mock 이 아니다: {s.ai_provider!r}") if s.gemini_enable_real_call: problems.append("GEMINI_ENABLE_REAL_CALL 이 켜져 있다") if s.video_enabled: problems.append("VIDEO_ENABLED 가 켜져 있다 (Kling 영상은 유료)") from app.services.comfyui_client import get_ai_client, MockAIClient client = get_ai_client() if not isinstance(client, MockAIClient): problems.append(f"AI 클라이언트가 mock 이 아니다: {type(client).__name__}") return problems def main() -> int: ap = argparse.ArgumentParser(description="과금 불가 로컬 개발 서버") ap.add_argument("--port", type=int, default=8223) ap.add_argument("--host", default="127.0.0.1") ap.add_argument("--check", action="store_true", help="안전장치만 확인하고 종료") args = ap.parse_args() os.chdir(BACKEND) # .env 와 상대경로가 저장소 루트 기준이 되게 sys.path.insert(0, str(BACKEND)) removed = harden() problems = verify() print("=" * 62) print(" SalonView AI — 로컬 개발 서버 (과금 불가 모드)") print("=" * 62) if removed: print(f" 이 프로세스에서 제거한 유료 키 : {', '.join(removed)}") print(" (전역 환경의 값은 그대로다. 이 서버만 못 볼 뿐이다.)") else: print(" 이 환경에는 유료 키가 애초에 없다.") print(" AI 프로바이더 : mock (얼굴 박스만 그려서 반환)") print(" fal.ai 이미지/영상 : 차단 — no network call made") print(" Gemini 실호출 : off") print(" DB / 저장소 : ./dev_local.db, ./dev_local_storage") print(" Redis : 불필요 (Celery eager)") if problems: print("-" * 62) print(" 안전장치 확인 실패 — 서버를 띄우지 않는다:") for p in problems: print(f" - {p}") print("=" * 62) return 1 print(" 안전장치 확인 : 통과") print("=" * 62) if args.check: return 0 print(f" http://{args.host}:{args.port}/ 로 접속\n") import uvicorn uvicorn.run("app.main:app", host=args.host, port=args.port, reload=False) return 0 if __name__ == "__main__": raise SystemExit(main())