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.ops.ops import router.v1.social.social import router.v1.social.oauth import router.v1.agent.kakao import router.v1.agent.chat import router.v1.agent.kakao_bot 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, 미설정이면 제외). 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 에 붙는지 실제로 한 번 물어본다).""" 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"} # 각 도메인 라우터를 등록한다. 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) # 인증 없는 공개 중계. 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) # 인증 없는 공개 목록. 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.ops.ops.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) app.include_router(router.v1.agent.chat.router) app.include_router(router.v1.agent.kakao_bot.router)