import time from contextlib import asynccontextmanager from fastapi import FastAPI, Request, Response from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from sqlalchemy import text from common.database.db_session_manager import DB_SESSION_MNG from common.enums import DBType, DBWRType from common.logger import LOG from common.utils.gtime import GTime from config.server_configs import web_server_config from scheduler import shutdown_scheduler, start_scheduler import router.v1.auth.account import router.v1.place.place import router.v1.fact.fact import router.v1.faq.faq import router.v1.media.media import router.v1.media.relay import router.v1.job.job import router.v1.site.site import router.v1.site.showcase import router.v1.site.booking_request import router.v1.site.post import router.v1.site.review import router.v1.local.local import router.v1.social.social import router.v1.social.oauth import router.v1.agent.kakao API_SERVER_START_TIME = GTime.UTCStr() @asynccontextmanager async def lifespan(app: FastAPI): # startup: 배치 스케줄러 기동(SCHEDULER_ENABLED=1 인 프로세스에서만) start_scheduler() yield # shutdown: 스케줄러 정지 + DB 엔진 커넥션 풀 정리 shutdown_scheduler() await DB_SESSION_MNG.dispose_all() app = FastAPI(title="Web4Ai API", lifespan=lifespan) # CORS — 관리자 프론트(client_url) + 랜딩(landing_url, 미설정이면 제외). # # ★ client_url 은 쉼표로 여러 오리진을 받는다. 로컬 개발에서 vite 는 3000 이 막혀 있으면 # 3001, 3002… 로 옮겨 뜨는데(--port 는 희망값이지 고정이 아니다), 그때마다 서버 설정을 # 고치게 하면 원인이 CORS 라는 걸 알아내는 데만 반나절이 든다. 개발 포트 몇 개를 한 줄에 적어 둔다. # 운영은 실제 도메인 하나만 적으면 된다. def _origins(*values: str) -> list[str]: seen: list[str] = [] for value in values: for origin in value.split(","): origin = origin.strip().rstrip("/") if origin and origin not in seen: seen.append(origin) return seen ALLOWED_ORIGINS = _origins(web_server_config.client_url, web_server_config.landing_url) app.add_middleware( CORSMiddleware, allow_origins=ALLOWED_ORIGINS, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Accept-Encoding: gzip 요청에 대해 1000 bytes 이상 응답을 압축. app.add_middleware(GZipMiddleware, minimum_size=1000) @app.middleware("http") async def log_time(request: Request, call_next): start_time = time.time() response = await call_next(request) if request.url.path.startswith('/v1/social/'): response.headers['Cache-Control'] = 'no-store' response.headers['Referrer-Policy'] = 'no-referrer' response.headers['X-Robots-Tag'] = 'noindex, nofollow' elapsed = time.time() - start_time # status_code 를 함께 남긴다(403/4xx 등을 로그만으로 식별 가능하게). LOG.d(f"{response.status_code} {request.method} {request.url.path} - {elapsed:.4f}s") return response @app.get(path="/healthz", responses={404: {"description": "Not found"}}) async def healthz(): return API_SERVER_START_TIME @app.get(path="/readyz", responses={404: {"description": "Not found"}, 503: {"description": "Not ready"}}) async def readyz(response: Response): """★ healthz 와 다른 걸 본다 — healthz 는 "프로세스가 살아 있나"(항상 200), 이건 "요청을 실제로 처리할 수 있나"(DB 에 붙는지 실제로 한 번 물어본다). ★ 왜 필요한가: 이 서버·DB 가 통째로 죽으면 우리 알림(alert_service, Teams webhook)도 같이 죽는다 — 자기 장애를 자기가 알릴 수 없다. 외부 감시(uptime 모니터 등)가 이 경로를 주기적으로 찔러야 전체 다운을 잡는다. DEPLOY.md·SERVERS.md 에 붙일 절차: 이 경로가 2xx 가 아니면(또는 응답이 없으면) 그 감시 서비스 **자신의** 채널로 알린다 — Teams webhook 이 죽은 원인 그 자체일 수 있으므로 같은 경로로 알리면 안 된다.""" try: async def _ping(s): await s.execute(text("SELECT 1")) return True await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, _ping) return {"ok": True, "db": "up"} except Exception as ex: # noqa: BLE001 — 준비 안 됐다는 것 자체가 이 엔드포인트의 응답이다 LOG.w(f"[readyz] DB 연결 확인 실패: {type(ex).__name__}: {ex}") response.status_code = 503 return {"ok": False, "db": "down"} # 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.. 를 import 후 include. app.include_router(router.v1.auth.account.router) app.include_router(router.v1.place.place.router) app.include_router(router.v1.fact.fact.router) app.include_router(router.v1.faq.faq.router) app.include_router(router.v1.media.media.router) # ★ 인증 없는 공개 중계. 발행본(정적 페이지)이 캔버스에 사진을 그릴 때 부른다 — # 남의 CDN 이 CORS 를 안 줘서 캔버스가 오염되는 것을 피하는 유일한 길이다(relay.py). app.include_router(router.v1.media.relay.router) app.include_router(router.v1.job.job.router) app.include_router(router.v1.site.site.router) app.include_router(router.v1.site.site.my_router) # ★ 인증 없는 공개 목록. 랜딩이 부른다 — 어드민 진입점(:9801)에는 붙이지 않는다. app.include_router(router.v1.site.showcase.router) app.include_router(router.v1.site.booking_request.router) app.include_router(router.v1.site.post.router) app.include_router(router.v1.site.post.owner_router) app.include_router(router.v1.site.review.router) app.include_router(router.v1.local.local.router) app.include_router(router.v1.local.local.weather_router) app.include_router(router.v1.social.social.router) app.include_router(router.v1.social.oauth.router) app.include_router(router.v1.agent.kakao.router)