o2o-site-AEO/solution/backend/router/v1/validator/dependencies.py
Mina Choi 872d00f3c4 [feat] solution: 미니 블로그·이용후기·예약요청 추가, /s/stay 목업·발행 사이트 UI 다수 수정
인앱 미니 블로그(AI 자동 포스트, 이메일 승인)·이용후기(즉시 게시)·예약 요청(메일 발송)을
새로 붙였고, 병행해서 /s/stay 목업과 발행 사이트 공통 렌더러(UnitsSection·FestivalSection·
LocalGuideSection·WeatherSection 등)의 UI 버그를 다수 고쳤다. 범위가 넓지만 한 주 분량
작업을 한 커밋으로 묶어 달라는 요청에 따라 하나로 묶는다.

- solution/backend: post/review/booking_request 라우터·서비스·CRUD 추가, 스케줄러에
  블로그 초안 생성(새벽 4:10)·발송(아침 9:00) cron 등록, 마이그레이션 4건 추가
- solution/frontend, admin/frontend: 생성된 API 클라이언트 갱신, 리뷰 모더레이션·
  블로그 글 관리 페이지 추가
- solution/site/src: 객실 상세+실시간예약(날짜선택·연락처 폼)을 모달로 통합, 축제·
  주변안내 카드 클릭 시 모달 전환, 후기 목록 카드 UI, 공용 Modal 컴포넌트 신설,
  날씨 문구 동기화 버그 수정(하늘줄·기온줄 한 타이머로), 시설·편의 가능/불가 아이콘
  색상 하이라이트, 헤더 메뉴 순서를 실제 섹션 순서에 맞춤, 하단 탭바 아이콘 정렬 버그
  (line-height) 수정, 추천일정 점선 연결+데스크톱 자동펼침/모바일 축소, 채널 라벨에
  크롤링 원문("NOL")이 새던 것을 bookingLabel() 로 교체
- solution/site/scripts/mockup: /s/stay 패치 스크립트·주입 CSS·JS 다수 수정, stay4~6
  빌드 스크립트 추가(다른 세션 작업)

테스트: solution/site `npx tsc --noEmit` 통과, `npx vitest run` 93 passed,
solution/backend `pytest tests/test_booking_request.py` 6 passed(로컬 DB 대상).
예약 요청 메일은 실제 발송까지 확인(place 66894a1b 소유자 이메일 누락을 DB에서 보정).
2026-09-18 09:03:18 +09:00

147 lines
6.2 KiB
Python

import asyncio
import json
from datetime import datetime, timedelta, timezone
from typing import Any, Union
from fastapi import Depends
from fastapi.responses import JSONResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
import bcrypt
from jose import jwt, JWTError, ExpiredSignatureError
from common.enums import (
EXCEPTION_ACCESS_TOKEN_EXPIRED,
EXCEPTION_FORBIDDEN,
EXCEPTION_INVALID_CLIENT_ACCESS,
EXCEPTION_REFRESH_TOKEN_EXPIRED,
UserRole,
)
from common.logger import LOG
from common.models.gmodel import UserInfo
from common.utils.gtime import GTime
from config.server_configs import jwt_token_config
security = HTTPBearer()
# ---- 비밀번호 해시 (bcrypt) ------------------------------------------------
# bcrypt 는 CPU 바운드 동기 작업이라 그대로 호출하면 asyncio 이벤트 루프를 막아
# 같은 워커의 다른 요청(healthz 등)까지 멈춘다. 스레드풀(asyncio.to_thread)로 보낸다.
# bcrypt 는 해싱 중 GIL 을 해제하므로 스레드들이 여러 코어에서 실제 병렬로 돈다.
# 입력은 최대 72 bytes 까지만 사용하므로 사전에 잘라준다.
def _hash_pw(pw: str) -> str:
return bcrypt.hashpw(pw.encode("utf-8")[:72], bcrypt.gensalt()).decode("utf-8")
def _verify_pw(pw: str, hashed_pw: str) -> bool:
try:
return bcrypt.checkpw(pw.encode("utf-8")[:72], hashed_pw.encode("utf-8"))
except (ValueError, TypeError):
return False
async def GetHashedPW(pw: str) -> str:
return await asyncio.to_thread(_hash_pw, pw)
async def VerifyPW(pw: str, hashed_pw: str) -> bool:
return await asyncio.to_thread(_verify_pw, pw, hashed_pw)
# ---- JWT 토큰 발급/검증 ----------------------------------------------------
JWT_ALGORITHM = "HS256"
JWT_ACCESS_SECRET = jwt_token_config.access_key
JWT_REFRESH_SECRET = jwt_token_config.refresh_key
ACCESS_TOKEN_EXPIRE_MIN = jwt_token_config.access_expire_min
REFRESH_TOKEN_EXPIRE_MIN = 60 * 24 * jwt_token_config.refresh_expire_day
def __create_token(subject: Union[str, Any], secret_key: str, expire_min: int) -> str:
to_encode = {
"sub": str(subject),
"exp": GTime.AddMinutes(expire_min), # jose 가 exp 클레임을 자동 검증
}
return jwt.encode(to_encode, secret_key, JWT_ALGORITHM)
def CreateAccessToken(subject: UserInfo) -> str:
return __create_token(subject.to_json(), JWT_ACCESS_SECRET, ACCESS_TOKEN_EXPIRE_MIN)
def CreateRefreshToken(subject: UserInfo) -> str:
return __create_token(subject.to_json(), JWT_REFRESH_SECRET, REFRESH_TOKEN_EXPIRE_MIN)
def CreateDayPassToken(subject: UserInfo) -> str:
"""그날 자정(KST)까지만 사는 접근 토큰 — 미니 블로그 메일의 "수정하기" 링크 전용.
★ 일반 로그인 세션과 다르다 — 사장님이 메일에서 그 글 하나를 고치러 들어오는 맥락에서만
쓰이고, 유효기간도 그만큼 짧다(2026-09-17, 사장님 지시: "로그인도 크레덴셜로 자동으로
되게 (그날까지만)"). refresh 토큰은 안 준다 — 그날이 지나면 다시 메일을 받아야 한다."""
now_kst = datetime.now(timezone(timedelta(hours=9)))
midnight_kst = (now_kst + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
expire_min = max(1, int((midnight_kst - now_kst).total_seconds() // 60))
return __create_token(subject.to_json(), JWT_ACCESS_SECRET, expire_min)
def __decode_token(jwt_token: str, secret_key: str, expired_exception) -> UserInfo:
try:
decoded = jwt.decode(jwt_token, secret_key, algorithms=[JWT_ALGORITHM])
return UserInfo(**json.loads(decoded.get("sub")))
except ExpiredSignatureError:
raise expired_exception
except JWTError as ex:
LOG.e_no_callstack(ex)
raise EXCEPTION_INVALID_CLIENT_ACCESS
def DecodeAccessToken(jwt_token: str) -> UserInfo:
return __decode_token(jwt_token, JWT_ACCESS_SECRET, EXCEPTION_ACCESS_TOKEN_EXPIRED)
def DecodeRefreshToken(jwt_token: str) -> UserInfo:
return __decode_token(jwt_token, JWT_REFRESH_SECRET, EXCEPTION_REFRESH_TOKEN_EXPIRED)
# ---- Depends 용 토큰 검증기 ------------------------------------------------
# 보호된 엔드포인트에서 dependencies=[Depends(IsValidAccessToken)] 로 사용.
async def IsValidAccessToken(credentials: HTTPAuthorizationCredentials = Depends(security)) -> UserInfo:
return DecodeAccessToken(credentials.credentials)
async def IsValidRefreshToken(credentials: HTTPAuthorizationCredentials = Depends(security)) -> UserInfo:
return DecodeRefreshToken(credentials.credentials)
# 최고관리자 이상(OWNER/DEVELOPER) 게이트. 회원관리·회사설정에 건다.
async def RequireOwner(user_info: UserInfo = Depends(IsValidAccessToken)) -> UserInfo:
if (user_info.role or 0) < UserRole.OWNER.value:
raise EXCEPTION_FORBIDDEN
return user_info
# 개발자(내부 운영) 전용 게이트. 회사 스코프를 넘어 전 고객사 데이터를 보는 /v1/admin 에만 건다.
# OWNER 는 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니라서 여기선 막힌다.
async def RequireDeveloper(user_info: UserInfo = Depends(IsValidAccessToken)) -> UserInfo:
if (user_info.role or 0) < UserRole.DEVELOPER.value:
raise EXCEPTION_FORBIDDEN
return user_info
# ---- ResponseNone 처리 -----------------------------------------------------
# 응답 객체에서 값이 None 인 필드를 재귀적으로 제거하여 페이로드를 줄인다.
# 모든 라우터는 return RemoveNoneResponse(await service....) 형태로 반환한다.
def RemoveNoneValues(obj: Any) -> Any:
if isinstance(obj, dict):
return {k: RemoveNoneValues(v) for k, v in obj.items() if v is not None}
if isinstance(obj, list):
return [RemoveNoneValues(v) for v in obj]
return obj
def RemoveNoneResponse(obj) -> JSONResponse:
# mode="json": uuid/datetime 등 DB 네이티브 타입(asyncpg.UUID 포함)을 pydantic 단에서
# JSON 안전한 문자열로 변환한다. content 가 이미 JSON-safe dict 이므로 표준 JSONResponse 사용
# (ORJSONResponse 는 최신 FastAPI 에서 deprecated).
return JSONResponse(content=RemoveNoneValues(obj.model_dump(mode="json")))