로그인한 사장님이 자기 사이트를 볼 화면이 없었다. 사이트는 place_id 로 한 건씩만 읽혀서 (site_crud.get_site_by_place) 사업장 목록으로 그리면 줄마다 사이트를 다시 물어 N+1 이 된다. - site_crud.list_company_sites: places LEFT JOIN sites LEFT JOIN site_versions 한 번. 사이트가 아직 없는 사업장(위저드만 걸어온 것)도 내려간다 — 빠지면 만들다 만 것을 찾을 길이 없다 - protocol.MySiteData: 한 줄 = 사업장 + 사이트. render(정적 파일 존재)는 넣지 않았다 — 보고서 파일을 읽는 값이라 줄 수만큼 파일 IO 가 된다. 단건(Res_Site)이 계속 소유한다 - site_service.list_my_sites: 회사 스코프. needs_rebuild 는 단건과 같은 규칙으로 판정한다 - GET /v1/site/list 는 라우터 객체를 따로 둔다 — 기존 라우터는 접두어에 place_id 가 박혀 있다 테스트 5건 추가(비어 있는 사업장·조인·회사 격리·재빌드 일치·비로그인), 539 passed (기존 실패 4건은 이 변경 전에도 같다 — build_publish 3 · snapshot 1)
93 lines
3.3 KiB
Python
93 lines
3.3 KiB
Python
import time
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.middleware.gzip import GZipMiddleware
|
|
|
|
from common.database.db_session_manager import DB_SESSION_MNG
|
|
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.job.job
|
|
import router.v1.site.site
|
|
import router.v1.local.local
|
|
|
|
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)
|
|
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
|
|
|
|
|
|
# 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.<domain>.<file> 를 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)
|
|
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.local.local.router)
|
|
app.include_router(router.v1.local.local.weather_router)
|