본문 바로가기

FastAPI

User CURD(fastAPI+Javascript+Jinja)

# Project_folder/main.py

from app.core.inits import initialize_app

app = initialize_app()

 

# Project_folder/app/apis/accounts.py

import os
import uuid
from typing import Optional

from fastapi import APIRouter, status, Depends, Response, Request, HTTPException, Form, UploadFile, File
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from fastapi_mail import MessageSchema, MessageType
from pydantic import EmailStr, TypeAdapter, ValidationError
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.database import get_db, PROFILE_IMAGE_UPLOAD_URL, ARTICLE_THUMBNAIL_UPLOAD_DIR, ARTICLE_EDITOR_USER_IMG_UPLOAD_DIR, ARTICLE_EDITOR_USER_VIDEO_UPLOAD_DIR
from app.core.redis import ACCESS_COOKIE_MAX_AGE, redis_client, CODE_TTL_SECONDS
from app.core.settings import CONFIG
from app.dependencies.auth import get_optional_current_user, get_current_user
from app.models.user import User
from app.schemas.accounts import EmailRequest, VerifyRequest, UserOut, UserLostPasswordIn, UserPasswordUpdate, UserIn, UserUpdate, UserResetPasswordIn
from app.schemas.auth import TokenResponse, LoginRequest
from app.services.account_service import UserService, get_user_service
from app.services.auth_service import AuthService, get_auth_service
from app.services.token_service import AsyncTokenService, REFRESH_TOKEN_PREFIX
from app.utils.accounts import verify_password
from app.utils.auth import get_token_expiry
from app.utils.commons import refresh_expire, random_string, upload_single_image, remove_dir_with_files, old_image_remove
from app.utils.cookies import compute_cookie_attrs
from app.utils.email import AUTHCODE_EMAIL_HTML_TEMPLATE, fastapi_email
from app.utils.exc_handler import CustomErrorException

router = APIRouter()


@router.post("/authcode/request",
             summary="인증 코드", description="인증 코드 생성",
             responses={401: {
                 "description": "유효하지 않은 인증 코드 확인 시도",
                 "content": {"application/json": {"example": {"detail": "유효하지 않은 인증 코드입니다."}}}
             }})
async def authcode_request_email(payload: EmailRequest,
                                 current_user: Optional[User] = Depends(get_optional_current_user),
                                 _user_service: UserService = Depends(get_user_service),
                                 ):
    email = str(payload.email).lower().strip()
    _type = payload.type
    print("_type: ", _type)
    existed_email_user = await _user_service.get_user_by_email(payload.email)
    if _type == "register":
        if existed_email_user:
            print("신규 가입: 존재하는 이메일")
            raise CustomErrorException(status_code=499, detail="존재하는 이메일입니다.")
        else:
            pass
    elif _type == "lost":
        if not existed_email_user:
            print("비밀번호 분실/설정 요청중 본인 확인: 가입되어 있지 않은 이메일")
            raise CustomErrorException(status_code=499, detail="가입되어 있지 않은 이메일입니다.")
        else:
            pass
    elif _type == "email":
        if existed_email_user and existed_email_user.email == current_user.email:
            print("이메일 변경 요청중 본인 확인: 로그인한 사용자의 동일한 이메일")
            raise CustomErrorException(status_code=499, detail="동일한 이메일입니다.")
        if existed_email_user and existed_email_user.email != current_user.email:  # 이런 경우는 없는 것 같은데...
            print("이메일 변경 요청중 본인 확인: 다른 사용자 이메일")
            raise CustomErrorException(status_code=499, detail="다른 사용자의 이메일입니다.")
        else:
            pass
    else:
        print("요청 type 없슴: Bad request")
        raise CustomErrorException(status_code=410, detail="잘못된 요청입니다.")

    # 비번 분실 그리고 통과한 신규가입과 이메일 변경
    recent_key = f"verify_recent:{email}"
    if await redis_client.exists(recent_key):
        print("CustomErrorException STATUS_CODE: ", 439, "과도한 요청")
        raise CustomErrorException(status_code=439, detail="과도한 요청: 잠시 후에 다시 진행해 주세요")

    session_key = f"user:{email}"  # Redis 해시 키 (세션 역할)
    await redis_client.hset(session_key, mapping={"email": email})  # Redis에 이메일 저장 (hset)
    await redis_client.expire(session_key, CODE_TTL_SECONDS)
    """ Redis의 hset() 명령 자체는 **만료시간(TTL)**을 직접 지정할 수 없어요.
        대신에 **키 전체(session_key)**에 대해 만료시간을 따로 expire() 또는 expireat()으로 설정해야 합니다.        
        즉, hset()으로 저장한 뒤에 expire()를 호출하는 방식으로 처리합니다. """

    authcode = str(await random_string(7, "number"))
    code_key = f"verify:{email}"
    await redis_client.set(code_key, authcode, ex=CODE_TTL_SECONDS)  # Redis에 인증코드 저장하고 TTL 설정 (10분)
    await redis_client.set(recent_key, "1", ex=30)  # 최근 요청 키(예: 30초 내 재요청 방지) - 선택

    print("await redis_client.get(code_key): ", await redis_client.get(code_key))

    title = None
    if _type == "register":
        title = "[서비스] 회원가입 인증번호"
    elif _type == "lost":
        title = "[서비스] 비밀번호 설정 인증번호"
    elif _type == "email":
        title = "[서비스] 이메일 변경 인증번호"

    from jinja2 import Template
    html_body = Template(AUTHCODE_EMAIL_HTML_TEMPLATE).render(code=authcode, title=title)  # , verify_link=verify_link) # 직접 입력으로 교체

    """fastapi-mail 1.5.0(1.5.8버전은 recipients=[NameEmail(email=email)]을 쓰라는데
    작동을 하지 않는다."""
    message = MessageSchema(
        subject=title,
        recipients=[payload.email],
        body=html_body,
        subtype=MessageType.html,
    )

    try:
        await fastapi_email.send_message(message)
    except Exception as e:
        await redis_client.delete(code_key)  # 실패 시 Redis에 저장된 코드 제거
        print("이메일 전송 실패: ", e)
        raise CustomErrorException(status_code=600, detail="이메일 전송이 실패했습니다.")

    return JSONResponse({"message": "인증번호를 이메일로 발송했습니다. (10분간 유효)"})


@router.post("/authcode/verify",
             summary="인증 코드", description="인증 코드 확인",
             responses={401: {
                 "description": "유효하지 않은 인증 코드 확인 시도",
                 "content": {"application/json": {"example": {"detail": "유효하지 않은 인증 코드입니다."}}}
             }})
async def authcode_verify(payload: VerifyRequest,
                          current_user: Optional[User] = Depends(get_optional_current_user),
                          db: AsyncSession = Depends(get_db)):
    """ 회원 가입시 인증 코드로 본인 확인(단순 인증하기)
    ### 이메일 변경 로직도 여기에 넣었다.(인증과 동시에 이메일 변경)
    javascript 코드도 authVerifyForm.addEventListener('submit' 에서 같은 흐름의 로직을 유지했다."""
    email = str(payload.email).lower().strip()
    authcode = payload.authcode.strip()
    _type = payload.type
    password = payload.password
    old_email = payload.old_email

    code_key = f"verify:{email}"
    session_key = f"user:{email}"
    stored_code = await redis_client.get(code_key)  # Redis에서 코드 확인
    session_data = await redis_client.hgetall(session_key)  # 세션에 저장된 이메일 확인
    if not stored_code:
        print("CustomErrorException STATUS_CODE: ", 410, "유효하지 않은 인증코드")
        raise CustomErrorException(status_code=410, detail="유효하지 않은 인증코드입니다.")  # 만료되었거나 존재하지 않습니다.
    if stored_code != authcode:
        print("CustomErrorException STATUS_CODE: ", 410, "인증코드 불일치")
        raise CustomErrorException(status_code=410, detail="인증코드가 일치하지 않습니다.")
    if not session_data or session_data.get("email") != email:
        print("CustomErrorException STATUS_CODE: ", 410, "세션 이메일 불일치")
        raise CustomErrorException(status_code=410, detail="세션 이메일이 일치하지 않습니다.")

    if _type == "email":
        """ email_update 과정 """
        if not old_email:
            raise HTTPException(status_code=400, detail="과거 이메일이 반드시 필요합니다.")
        _user_service = UserService(db=db)
        old_user = await _user_service.get_user_by_email(old_email)
        if old_user and old_user.id == current_user.id:
            password_ok = await verify_password(password, str(old_user.password))
            if password_ok:
                await _user_service.update_email(old_email, payload.email)
                await redis_client.delete(code_key)  # 검증 성공 -> 코드 삭제(한번만 사용)
                return JSONResponse({"message": "이메일 변경 성공: 확인을 클릭하면, 새로운 이메일로 로그인됩니다."})
            else:
                raise CustomErrorException(status_code=411,
                                           detail="비밀번호가 일치하지 않습니다.",
                                           headers={"WWW-Authenticate": "Bearer"})
        else:
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail="이메일 변경 권한이 없습니다."
            )
    await redis_client.delete(code_key)  # 검증 성공 -> 코드 삭제(한번만 사용)

    verified_token = str(uuid.uuid4())
    verified_key = f"verified:{email}"
    await redis_client.set(verified_key, verified_token, ex=CODE_TTL_SECONDS)  # 10분 동안 유지

    if _type == "register":
        message = "이메일 인증 성공: 회원가입을 진행하세요."
    else:  # _type == "lost" # 비번 분실/설정
        message = "이메일 인증 성공: 비밀번호 설정을 진행하세요."
    return JSONResponse({"message": message,
                         "verified_token": verified_token})


@router.post("/register",
             response_model=UserOut, )
async def register_user(username: str = Form(...),
                        email: str = Form(...),
                        token: str = Form(...),
                        password: str = Form(...),
                        password2: str = Form(...),
                        imagefile: UploadFile | None = File(None),
                        user_service: UserService = Depends(get_user_service), ):
    """이메일과 토큰은 입력값이 없어도 진입된다.
        username(닉네임), 비밀번호는 js단에서 빈값 및 validation을 처리한다. 이미지는 없어도 들어온다.
        이미지등의 파일을 받는 경우는 pydantic schema로 검증이 안된다. formData로 받아서 검증해야 한다.
        하지만, 이미지등의 파일이 없는 경우는 json으로 받아서 pydantic schema로 검증할 수 있고, 그렇게 하는 것을 권장한다."""
    print("In register_user: ", username, email, token, password, imagefile)
    verified_key = f"verified:{email}"
    session_key = f"user:{email}"
    verified_token = await redis_client.get(verified_key)
    session_data = await redis_client.hgetall(session_key)

    if not verified_token:  # email이 빈칸이어도 여기로 오지만, CustomError 발생시킨다.
        print("CustomErrorException STATUS_CODE: ", 410, "유효하지 않은 인증토큰")
        raise CustomErrorException(status_code=410, detail="유효하지 않은 인증토큰입니다.")
    if verified_token != token:  # token이 빈칸이어도 여기로 오지만, CustomError 발생시킨다.
        print("CustomErrorException STATUS_CODE: ", 410, "인증토큰 불일치")
        raise CustomErrorException(status_code=410, detail="인증토큰이 일치하지 않습니다.")
    if not session_data or session_data.get("email") != email:  # 들어온 이메일 값이 세션에 저장된 이메일과 다르면, CustomError 발생시킨다.
        print("CustomErrorException STATUS_CODE: ", 410, "세션 이메일 불일치")
        raise CustomErrorException(status_code=410, detail="세션 이메일이 일치하지 않습니다.")

    try:
        validated_email: EmailStr = TypeAdapter(EmailStr).validate_python(email)
        user_in = UserIn(username=username, email=validated_email, password=password, confirmPassword=password2)  # 템플릿 단에서 넘어온 UserIn validation
    except ValidationError as e:
        """UserIn pydantic schema 검증 에러 결과의 첫번째 실제 메시지만 출력하기 위한 로직"""
        first = next(iter(e.errors()), None)
        if not first:
            raise CustomErrorException(status_code=422, detail={"non_field": ["잘못된 입력입니다."]})

        field = str((first.get("loc") or ["non_field"])[-1])
        msg = first.get("msg", "잘못된 입력입니다.")
        # 기존 포맷(dict of list)을 유지
        raise CustomErrorException(status_code=422, detail={field: [msg]})

    existed_username = await user_service.get_user_by_username(user_in.username)
    if existed_username:
        print("CustomErrorException STATUS_CODE: ", 499, "존재하는 닉네임")
        raise CustomErrorException(status_code=499, detail="이미 사용하는 닉네임입니다.")

    existed_user_email = await user_service.get_user_by_email(user_in.email)
    if existed_user_email:
        print("CustomErrorException STATUS_CODE: ", 499, "존재하는 이메일")
        raise CustomErrorException(status_code=499, detail="이미 사용하고 있는 이메일입니다.")
    created_user = await user_service.create_user(user_in)

    img_path = None
    if imagefile:
        img_path = await upload_single_image(PROFILE_IMAGE_UPLOAD_URL, created_user, imagefile)
    created_user = await user_service.user_image_update(created_user.id, img_path)

    await redis_client.delete(verified_key)
    await redis_client.delete(session_key)

    return JSONResponse(status_code=201, content=jsonable_encoder(created_user))


@router.patch("/lost/password/resetting", response_model=UserOut,
              summary="회원 비밀번호 분실/설정", description="특정 회원의 비밀번호를 분실하여 재설정합니다.",
              responses={404: {
                  "description": "회원 비밀번호 설정 실패",
                  "content": {"application/json": {"example": {"detail": "회원을 찾을 수 없습니다."}}},
                  403: {
                      "description": "회원 비밀번호 수정 권한 없슴",
                      "content": {"application/json": {"example": {"detail": "접근 권한이 없습니다."}}}
                  }
              }})
async def lost_password_resetting(lost_password_in: UserLostPasswordIn,
                                  _user_service: UserService = Depends(get_user_service)):
    email = str(lost_password_in.email).lower().strip()
    token = lost_password_in.token
    print("token: ", token)
    newpassword = lost_password_in.newpassword

    verified_key = f"verified:{email}"
    session_key = f"user:{email}"
    verified_token = await redis_client.get(verified_key)
    print("verified_token: ", verified_token)
    session_data = await redis_client.hgetall(session_key)

    if not verified_token:  # email이 빈칸이어도 여기로 오지만, CustomError 발생시킨다.
        raise CustomErrorException(status_code=410, detail="유효하지 않은 인증토큰입니다.")
    if verified_token != token:  # token이 빈칸이어도 여기로 오지만, CustomError 발생시킨다.
        raise CustomErrorException(status_code=410, detail="인증토큰이 일치하지 않습니다.")
    if not session_data or session_data.get("email") != email:  # 들어온 이메일 값이 세션에 저장된 이메일과 다르면, CustomError 발생시킨다.
        raise CustomErrorException(status_code=410, detail="세션 이메일이 일치하지 않습니다.")

    user = await _user_service.get_user_by_email(lost_password_in.email)
    if user:
        _password_update = UserPasswordUpdate(password=newpassword)
        await _user_service.update_password(user.id, _password_update)

    else:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="회원을 찾을 수 없습니다."
        )

    await redis_client.delete(verified_key)
    await redis_client.delete(session_key)

    return user
    # return JSONResponse({"message": "비밀번호 설정 성공: 재설정된 비밀번호로 로그인됩니다."})


@router.post(
    "/login",
    response_model=TokenResponse,
    summary="사용자 로그인",
    description="사용자 로그인 후 JWT 토큰을 발급합니다.",
    responses={
        401: {
            "description": "인증 실패",
            "content": {"application/json": {"example": {"detail": "인증 실패"}}}
        }})
async def login(response: Response, request: Request,
                login_data: LoginRequest,
                auth_service: AuthService = Depends(get_auth_service)):
    user = await auth_service.authenticate_user(login_data)
    if not user:
        from app.utils.exc_handler import CustomErrorException
        raise CustomErrorException(status_code=411,
                                   detail="인증에 실패했습니다.",
                                   headers={"WWW-Authenticate": "Bearer"})

    token_data = await auth_service.create_user_token(user)
    _access_token = token_data.get(CONFIG.ACCESS_COOKIE_NAME)
    _refresh_token = token_data.get(CONFIG.REFRESH_COOKIE_NAME)

    # 요청 정보로 HTTPS 여부 판별 (프록시가 있다면 x-forwarded-proto 우선)
    attrs = compute_cookie_attrs(request, cross_site=False)
    print("1. attrs['secure']: ", attrs["secure"])
    print("2. attrs['samesite']: ", attrs["samesite"])
    _REFRESH_COOKIE_EXPIRE = refresh_expire()

    response.set_cookie(
        key=CONFIG.ACCESS_COOKIE_NAME,
        value=_access_token,
        httponly=True,
        secure=attrs["secure"],  # HTTPS라면 True
        samesite=attrs["samesite"],
        max_age=ACCESS_COOKIE_MAX_AGE,

    )

    response.set_cookie(
        key=CONFIG.REFRESH_COOKIE_NAME,
        value=_refresh_token,
        httponly=True,  # JavaScript에서 쿠키에 접근 불가능하도록 설정
        secure=attrs["secure"],  # HTTPS 환경에서만 쿠키 전송
        samesite=attrs["samesite"],  # CSRF 공격 방지
        expires=_REFRESH_COOKIE_EXPIRE
    )

    return token_data


@router.patch("/account/update/{user_id}", response_model=UserOut,
              summary="회원 정보 수정", description="특정 회원의 정보를 수정합니다.",
              responses={404: {
                  "description": "회원 정보 수정 실패",
                  "content": {"application/json": {"example": {"detail": "회원을 찾을 수 없습니다."}}},
                  403: {
                      "description": "회원 정보 수정 권한 없슴",
                      "content": {"application/json": {"example": {"detail": "접근 권한이 없습니다."}}}
                  }
              }})
async def update_user(user_id: int,
                      username: str | None = Form(None),
                      email: EmailStr | None = Form(None),  # 별도로 변경 라우터 만듦(여기서는 None으로 들어옴)
                      password: str | None = Form(None),
                      imagefile: UploadFile | None = File(None),
                      current_user: User = Depends(get_current_user),
                      _user_service: UserService = Depends(get_user_service)):
    ''' username, 프로필 이미지 변경용
    email 변경은 별도로 이메일 인증코드 방식으로 구성
    password은 변경 권한으로 사용함 '''
    from app.utils.exc_handler import CustomErrorException
    user = await _user_service.get_user_by_id(user_id)
    if user != current_user:
        raise CustomErrorException(status_code=411, detail="접근 권한이 없습니다.")
    if username:
        existed_username_user = await _user_service.get_user_by_username(username)
        if username == user.username:
            raise CustomErrorException(status_code=499, detail="기존 닉네임과 동일합니다.")
        if existed_username_user and existed_username_user.username != user.username:
            raise CustomErrorException(status_code=499, detail="이미 사용하는 닉네임입니다.")
    if email:  # 여기서는 None으로 들어옴(우선 코드는 남겨두었다.)
        existed_email_user = await _user_service.get_user_by_email(email)
        if existed_email_user and existed_email_user.email != user.email:
            raise CustomErrorException(status_code=499, detail="이미 가입되어 있는 이메일입니다.")
    try:
        user_update = UserUpdate(username=username, email=email)
        password_ok = await verify_password(password, str(user.password))
        if password_ok:
            updated_user = await _user_service.update_user(user_id, user_update)
            if imagefile is not None:
                filename_only, ext = os.path.splitext(imagefile.filename)
                if len(imagefile.filename.strip()) > 0 and len(filename_only) > 0:
                    if user.img_path:
                        await old_image_remove(imagefile.filename, user.img_path)
                    img_path = await upload_single_image(PROFILE_IMAGE_UPLOAD_URL, updated_user, imagefile)
                    updated_user = await _user_service.user_image_update(updated_user.id, img_path)
            else:
                img_path = user.img_path  # user.img_path인 경우도 적용된다.
                updated_user = await _user_service.user_image_update(updated_user.id, img_path)
        else:
            raise CustomErrorException(status_code=411, detail="비밀번호가 일치하지 않습니다.")

        if not updated_user:
            raise HTTPException(
                status_code=status.HTTP_404_NOT_FOUND,
                detail="회원을 찾을 수 없습니다."
            )
        return updated_user

    except ValidationError as e:
        print("except ValidationError as e:", e.errors())
        print("except ValidationError as e.errors():", e.errors()[0]["loc"][0])
        # 요청 본문에 대한 자동 422 변환이 아닌, 수동으로 422로 변환해 주는 것이 좋습니다.
        if email is not None and e.errors()[0]["loc"][0] == "email":
            raise CustomErrorException(status_code=432, detail="이메일 형식 부적합")
        elif username is not None and e.errors()[0]["loc"][0] == "username":
            raise CustomErrorException(status_code=432, detail="닉네임 정책 위반")


@router.patch("/account/password/update/{user_id}", response_model=UserOut,
              summary="회원 비밀번호 수정", description="특정 회원의 비밀번호를 수정합니다.",
              responses={404: {
                  "description": "회원 비밀번호 수정 실패",
                  "content": {"application/json": {"example": {"detail": "회원을 찾을 수 없습니다."}}},
                  403: {
                      "description": "회원 비밀번호 수정 권한 없슴",
                      "content": {"application/json": {"example": {"detail": "접근 권한이 없습니다."}}}
                  }
              }})
async def password_update(password_in: UserResetPasswordIn,
                          current_user: User = Depends(get_current_user),
                          _user_service: UserService = Depends(get_user_service)):
    user = await _user_service.get_user_by_id(password_in.user_id)
    if user != current_user:
        raise CustomErrorException(status_code=411, detail="접근 권한이 없습니다.")
    if user:
        _password_update = UserPasswordUpdate(password=password_in.newpassword)
        password_ok = await verify_password(password_in.password, str(user.password))
        if password_ok:
            await _user_service.update_password(password_in.user_id, _password_update)
        else:
            raise CustomErrorException(status_code=411, detail="기존의 비밀번호와 일치하지 않습니다.")

    else:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="회원을 찾을 수 없습니다."
        )

    return user


@router.patch("/account/email/update/{user_id}")
async def email_update(user_id: int):
    """email_update 과정은
    @router.post("/authcode/verify")
    async def authcode_verify
        authcode_verify 에 담았다."""
    pass


@router.post(
    "/logout",
    summary="사용자 로그아웃",
    description="로그아웃 후 JWT 토큰을 삭제합니다.",
    responses={
        200: {
            "description": "로그아웃 성공",
            "content": {"application/json": {"example": {"message": "로그아웃 되었습니다."}}}
        }})
async def logout(response: Response,
                 request: Request):
    access_token = request.cookies.get(CONFIG.ACCESS_COOKIE_NAME)
    refresh_token = request.cookies.get(CONFIG.REFRESH_COOKIE_NAME)
    # 쿠키 삭제
    response.delete_cookie(key=CONFIG.ACCESS_COOKIE_NAME, path="/")
    response.delete_cookie(key=CONFIG.REFRESH_COOKIE_NAME, path="/")

    # 블랙리스트 처리
    if access_token:
        expiry = get_token_expiry(access_token)
        await AsyncTokenService.blacklist_token(access_token, expiry)
    if refresh_token:
        expiry = get_token_expiry(refresh_token)
        await AsyncTokenService.blacklist_token(refresh_token, expiry)
    print("로그아웃")

    return {"message": "로그아웃되었습니다."}


@router.delete("/account/delete/{user_id}",
               summary="회원 탈퇴",
               description="특정 회원을 탈퇴 시킵니다.",
               responses={200: {
                   "description": "회원 탈퇴 성공",
                   "content": {"application/json": {"example": {"detail": "회원의 탈퇴가 성공적으로 이루어 졌습니다."}}},
                   404: {
                       "description": "회원 탈퇴 실패",
                       "content": {"application/json": {"example": {"detail": "회원을 찾을 수 없습니다."}}}
                   }
               }})
async def delete_user(user_id: int,
                      request: Request,
                      current_user: User = Depends(get_current_user),
                      _user_service: UserService = Depends(get_user_service)):
    _user = await _user_service.get_user_by_id(user_id)
    if _user != current_user:
        raise CustomErrorException(status_code=411, detail="접근 권한이 없습니다.")
    if _user is False:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="회원을 찾을 수 없습니다."
        )

    # article(게시글)의 썸네일, quill 내용의 이미지/동영상 삭제
    # article(게시글)의 내용 자체는 모델에서 삭제되도록 ORM정의해놓음 """cascade="all, delete-orphan","""
    article_thumb_dir = f'{ARTICLE_THUMBNAIL_UPLOAD_DIR}' + '/' + f'{user_id}'
    content_img_dir = f'{ARTICLE_EDITOR_USER_IMG_UPLOAD_DIR}' + '/' + f'{user_id}'
    content_video_dir = f'{ARTICLE_EDITOR_USER_VIDEO_UPLOAD_DIR}' + '/' + f'{user_id}'
    await remove_dir_with_files(article_thumb_dir)
    await remove_dir_with_files(content_img_dir)
    await remove_dir_with_files(content_video_dir)

    user_thumb_dir = f'{PROFILE_IMAGE_UPLOAD_URL}' + '/' + f'{user_id}'
    await remove_dir_with_files(user_thumb_dir)
    """프로필 이미지는 삭제한다. 하지만, 
    게시글의 author_id는 남겨두고, 해당 회원이 작성했던 게시글은 비활성화 하는 것으로 처리하자."""
    print("delete_user user_id:", user_id)
    await _user_service.delete_user(user_id)

    #############################
    access_token = request.cookies.get(CONFIG.ACCESS_COOKIE_NAME)
    refresh_token = request.cookies.get(CONFIG.REFRESH_COOKIE_NAME)

    resp = JSONResponse(
        status_code=200,
        content={"detail": "회원의 탈퇴가 성공적으로 이루어졌습니다."}
    )
    domain = request.url.hostname

    resp.delete_cookie(
        key=CONFIG.ACCESS_COOKIE_NAME,
        path="/",
        domain=domain,  # 필요 시 명시적으로 지정
        httponly=True
    )
    resp.delete_cookie(
        key=CONFIG.REFRESH_COOKIE_NAME,
        path="/",
        domain=domain,
        httponly=True
    )

    if access_token:
        access_exp = get_token_expiry(access_token)
        await AsyncTokenService.blacklist_token(access_token, access_exp)
    if refresh_token:
        refresh_exp = get_token_expiry(refresh_token)
        await AsyncTokenService.blacklist_token(refresh_token, refresh_exp)
        # 만약 Redis에 별도 키로 저장했다면 삭제:
        await redis_client.delete(f"{REFRESH_TOKEN_PREFIX}{user_id}")

    request.state.skip_set_cookie = True
    print("Final headers:", resp.headers.getlist("set-cookie"))

    print("쿠키 삭제 확인 request.cookies.get(ACCESS_COOKIE_NAME): ", request.cookies.get(CONFIG.ACCESS_COOKIE_NAME))
    print("쿠키 삭제 확인 request.cookies.get(REFRESH_COOKIE_NAME): ", request.cookies.get(CONFIG.REFRESH_COOKIE_NAME))
    print("response.raw_headers: ", resp.raw_headers)

    return resp

 

# Project_folder/app/apis/auth.py

from fastapi import APIRouter, Request

router = APIRouter()


@router.get("/csrf_token", summary="CSRF_TOKEN End Point",)
def csrf_token_endpoint(request: Request):
    # middleware sets cookie 'csrf_token' and possibly request.state.csrf_token
    token = request.cookies.get("csrf_token")
    if not token:
        token = getattr(request.state, "csrf_token", "")
    return {"csrf_token": token}

 

# Project_folder/app/core/database.py

import os
from datetime import datetime, timezone
from typing import AsyncGenerator

from sqlalchemy import Integer, DateTime
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.orm import declarative_base, Mapped, mapped_column

from app.core.settings import CONFIG, MEDIA_DIR

PROFILE_IMAGE_UPLOAD_URL = os.path.join(MEDIA_DIR, CONFIG.PROFILE_IMAGE_URL)
ARTICLE_THUMBNAIL_UPLOAD_DIR = os.path.join(MEDIA_DIR, CONFIG.ARTICLE_THUMBNAIL_DIR)
ARTICLE_EDITOR_USER_IMG_UPLOAD_DIR = os.path.join(MEDIA_DIR, CONFIG.ARTICLE_EDITOR_USER_IMG_DIR)
ARTICLE_EDITOR_USER_VIDEO_UPLOAD_DIR = os.path.join(MEDIA_DIR, CONFIG.ARTICLE_EDITOR_USER_VIDEO_DIR)

DATABASE_URL = f"{CONFIG.DB_TYPE}+{CONFIG.DB_DRIVER}://{CONFIG.DB_USER}:{CONFIG.DB_PASSWORD}@{CONFIG.DB_HOST}:{CONFIG.DB_PORT}/{CONFIG.DB_NAME}?charset=utf8"
print("2. DATABASE_URL:", DATABASE_URL.split("//")[0])
ASYNC_ENGINE = create_async_engine(DATABASE_URL,
                                   echo=CONFIG.DEBUG,
                                   future=True,
                                   pool_size=10, max_overflow=0, pool_recycle=300,  # 5분마다 연결 재활용
                                   # encoding="utf-8"
                                   )

# 세션 로컬 클래스 생성
AsyncSessionLocal = async_sessionmaker(
    ASYNC_ENGINE,
    class_=AsyncSession,  # add
    expire_on_commit=False,
    autocommit=False,
    autoflush=False,
    # poolclass=NullPool,  # SQLite에서는 NullPool 권장
)
"""
 autocommit=False 부분은 주의하자. 
 autocommit=False로 설정하면 데이터를 변경했을때 commit 이라는 사인을 주어야만 실제 저장이 된다. 

 만약 autocommit=True로 설정할 경우에는 commit이라는 사인이 없어도 즉시 데이터베이스에 변경사항이 적용된다. 

 그리고 autocommit=False인 경우에는 데이터를 잘못 저장했을 경우 rollback 사인으로 되돌리는 것이 가능하지만 
 autocommit=True인 경우에는 commit이 필요없는 것처럼 rollback도 동작하지 않는다는 점에 주의해야 한다.
"""

Base = declarative_base()  # Base 클래스 (모든 모델이 상속)

class BaseModel(Base):
    __abstract__ = True
    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True),
                                                 nullable=False,
                                                 default=lambda: datetime.now(timezone.utc))
    updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True),
                                                 nullable=False,
                                                 default=lambda: datetime.now(timezone.utc),
                                                 onupdate=lambda: datetime.now(timezone.utc))


async def get_db() -> AsyncGenerator[AsyncSession, None]:
    session: AsyncSession = AsyncSessionLocal()
    print(f"[get_session] new session: {id(session)}")
    try:
        yield session
    except Exception as e:
        print(f"Session rollback triggered due to exception: {e}")
        await session.rollback()
        raise
    finally:
        print(f"[get_session] close session: {id(session)}")
        await session.close()

 

# Project_folder/app/core/inits.py

from contextlib import asynccontextmanager

import redis
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from fastapi_csrf_jinja.middleware import FastAPICSRFJinjaMiddleware

from app.apis import accounts as apis_accounts
from app.apis import auth as apis_auth
from app.core.database import ASYNC_ENGINE
from app.core.redis import redis_client
from app.core.settings import STATIC_DIR, MEDIA_DIR, CONFIG, templates
from app.utils.commons import to_kst
from app.utils.middleware import AccessTokenSetCookieMiddleware
from app.views import index
from app.views import accounts as views_accounts


@asynccontextmanager
async def lifespan(app: FastAPI):
    print("Initializing database......")
    # FastAPI 인스턴스 기동시 필요한 작업 수행.
    try:
        await redis_client.ping() # Redis 연결 테스트
        print("Redis connection established......")
    except redis.exceptions.ConnectionError:
        print("Failed to connect to Redis......")
    print("Starting up...")
    yield
    # FastAPI 인스턴스 종료시 필요한 작업 수행
    await redis_client.aclose()
    print("Redis connection closed......")
    print("Shutting down...")
    await ASYNC_ENGINE.dispose()


def including_middleware(app):
    app.add_middleware(CORSMiddleware,
                       allow_origins=[
                           "http://localhost:5173",  # 프론트엔드 origin (예시)
                           "http://127.0.0.1:5500",  # 필요시 추가
                           "http://localhost:8000",  # 백엔드가 템플릿 제공하는 경우
                       ], # 실제 프론트 주소
                       allow_methods=["*"],
                       allow_headers=["*"],
                       allow_credentials=True,
                       max_age=-1)
    app.add_middleware(FastAPICSRFJinjaMiddleware,
                       secret=CONFIG.SECRET_KEY,
                       cookie_name="csrf_token",
                       header_name="X-CSRF-Token",)
                       # swagger를 CSRF_TOKEN적용에서 제외시키는 방법: 라이브러리에서 제공하는 예외 옵션 이름에 맞춰 사용하세요.
                       # (예: exclude_paths, exempt_paths, exempt_urls 등)
                       # 이 프로젝트는 swagger로 커스터마이징해서 csrf_token을 적용시켰다.
                       # exempt_urls=["/swagger/custom/docs", "/swagger/custom/redoc", "/swagger/custom/openapi.json"])
    """ AccessTokenSetCookieMiddleware: access_token이 만료되면, 
    get_current_user 리프레시로 폴백하면서 액세스토큰을 만들때 가로채서 쿠키에 심는다."""
    app.add_middleware(AccessTokenSetCookieMiddleware)

def including_router(app):
    app.include_router(index.router, prefix="", tags=["IndexViews"])
    app.include_router(apis_accounts.router, prefix="/apis/accounts", tags=["AccountsAPI"])
    app.include_router(apis_auth.router, prefix="/apis/auth", tags=["Auth"])
    app.include_router(views_accounts.router, prefix="/views/accounts", tags=["AccountsViews"])


def initialize_app():
    app = FastAPI(title=CONFIG.APP_NAME,
                  version=CONFIG.APP_VERSION,
                  description=CONFIG.APP_DESCRIPTION,
                  lifespan=lifespan,
                  docs_url=None, redoc_url=None, openapi_url="/swagger/custom/openapi.json",
                  )
    app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
    app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")

    templates.env.globals["STATIC_URL"] = "/static"
    templates.env.globals["MEDIA_URL"] = "/media"
    templates.env.filters["to_kst"] = to_kst

    including_middleware(app)
    including_router(app)
    return app

 

# Project_folder/app/core/redis.py

from redis.asyncio import Redis, ConnectionPool

from app.core.settings import CONFIG

# # Connection Pool 기반 access ##############################
""" # AI Chat # 비동기적으로 ConnectionPool 적용
비동기 Redis 클라이언트와 함께 쓰려면 동기용 ConnectionPool이 아니라 asyncio 전용 풀을 써야 합니다. 
즉, redis.ConnectionPool이 아니라 redis.asyncio.ConnectionPool을 사용해야 합니다.
from redis.asyncio import Redis, ConnectionPool
예시 1) asyncio용 ConnectionPool을 직접 생성해서 사용
"""

'''마땅히 설정할 곳이 없어서... 서버 구동시작시 여기를 지나가므로 전역변수처럼 사용'''
ACCESS_COOKIE_MAX_AGE = CONFIG.ACCESS_TOKEN_EXPIRE * 60 # 초 1800 : 30분
CODE_TTL_SECONDS = 10 * 60  # 10분


host = CONFIG.REDIS_HOST if CONFIG.APP_ENV == "production" else "localhost"
port = CONFIG.REDIS_PORT if CONFIG.APP_ENV == "production" else 6379
password = CONFIG.REDIS_PASSWORD if CONFIG.APP_ENV == "production" else None
db = CONFIG.REDIS_DB if CONFIG.APP_ENV == "production" else 0


redis_pool = ConnectionPool(
    host=host,
    port=port,
    db=db,
    password=password,
    decode_responses=True,  # 문자열 응답을 자동으로 디코딩
    max_connections=10)

redis_client = Redis(connection_pool=redis_pool)

 

# Project_folder/app/core/settings.py

import os
from functools import lru_cache
from pathlib import Path
from typing import Optional, List

from fastapi.templating import Jinja2Templates
from fastapi_csrf_jinja.jinja_processor import csrf_token_processor
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

PRESENT_DIR = os.path.dirname(os.path.abspath(__file__))
APP_DIR = Path(__file__).resolve().parent.parent
ROOT_DIR = Path(__file__).resolve().parent.parent.parent  ## root폴더
TEMPLATE_DIR = os.path.join(APP_DIR, 'templates')
STATIC_DIR = os.path.join(APP_DIR, 'static')
MEDIA_DIR = os.path.join(APP_DIR, 'media')

templates = Jinja2Templates(
    directory=TEMPLATE_DIR,
    context_processors=[csrf_token_processor("csrf_token", "X-CSRF-Token")]
)

class Settings(BaseSettings):
    """여기에서 값이 비어있는 것은 .env 파일에서 채워진다.
    그 값은 override 되지 않는다. 흐름상 그럴것 같기는 한데...???
    최종적으로 .env에 설정된 값으로 채워져 버리는 것인것 같다."""
    APP_ENV: str = "development"
    APP_NAME: str = "FastAPI_Jinja"
    APP_VERSION: str = "0.0.0"
    APP_DESCRIPTION: str = ("FastAPI_Jinja/Javascript을 이용한 프로젝트 개발: \n"
                            "Jinja Template를 이용하여 Server Side Rendering을 적용하고, \n"
                            "데이터 입력과정을 pydantic schema적용하고 POST 메서드에서 vanilla javascript를 이용하여 개발 진행")

    DEBUG: bool = False

    # 기본값은 두지 않고 .env에서 읽히도록 둡니다.
    SECRET_KEY: Optional[str] = None
    ALGORITHM: str

    DB_TYPE: str
    DB_DRIVER: str

    # 이메일/인증코드
    SMTP_FROM: str  # = os.getenv("SMTP_FROM", SMTP_USERNAME)
    SMTP_USERNAME: str
    SMTP_PASSWORD: str
    SMTP_PORT: int  # = int(os.getenv("SMTP_PORT", 587))
    SMTP_HOST: str  # = os.getenv("SMTP_HOST", "smtp.gmail.com")

    # 액세스/리프페시 토큰
    ACCESS_COOKIE_NAME: str
    REFRESH_COOKIE_NAME: str
    NEW_ACCESS_COOKIE_NAME: str
    NEW_REFRESH_COOKIE_NAME: str

    ACCESS_TOKEN_EXPIRE: int
    REFRESH_TOKEN_EXPIRE: int

    # Media Store Path
    PROFILE_IMAGE_URL: str
    ARTICLE_THUMBNAIL_DIR: str
    ARTICLE_EDITOR_USER_IMG_DIR: str
    ARTICLE_EDITOR_USER_VIDEO_DIR: str

    ORIGINS: List[str] = Field(default_factory=list)

    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        extra="ignore",
        case_sensitive=False,
    )


class DevSettings(Settings):
    DEBUG: bool = Field(..., validation_alias="DEBUG_TRUE")

    ORIGINS: str = Field(..., validation_alias="DEV_ORIGINS")

    DB_NAME: str = Field(..., validation_alias="DEV_DB_NAME")
    DB_HOST: str = Field(..., validation_alias="DEV_DB_HOST")
    DB_PORT: str = Field(..., validation_alias="DEV_DB_PORT")
    DB_USER: str = Field(..., validation_alias="DEV_DB_USER")
    DB_PASSWORD: str = Field(..., validation_alias="DEV_DB_PASSWORD")


class ProdSettings(Settings):
    APP_NAME: str = Field(..., validation_alias="PROD_APP_NAME")
    APP_VERSION: str = Field(..., validation_alias="PROD_APP_VERSION")
    APP_DESCRIPTION: str = Field(..., validation_alias="PROD_APP_DESCRIPTION")

    ORIGINS: str = Field(..., validation_alias="PROD_ORIGINS")

    DB_NAME: str = Field(..., validation_alias="PROD_DB_NAME")
    DB_HOST: str = Field(..., validation_alias="PROD_DB_HOST")
    DB_PORT: str = Field(..., validation_alias="PROD_DB_PORT")
    DB_USER: str = Field(..., validation_alias="PROD_DB_USER")
    DB_PASSWORD: str = Field(..., validation_alias="PROD_DB_PASSWORD")

    REDIS_HOST: str = Field(..., validation_alias="REDIS_HOST")
    REDIS_PORT: str = Field(..., validation_alias="REDIS_PORT")
    REDIS_DB: str = Field(..., validation_alias="REDIS_DB")
    REDIS_PASSWORD: str = Field(..., validation_alias="REDIS_PASSWORD")

    # 운영에서는 SECRET_KEY 필수
    @model_validator(mode="after")
    def ensure_secret_key(self):
        if not self.SECRET_KEY or len(self.SECRET_KEY) < 16:
            raise ValueError("In production, SECRET_KEY must be set and sufficiently long.")
        return self


@lru_cache(maxsize=1)
def get_settings() -> Settings:
    print("1. setting start... Only One")
    # model_config의 env_file 설정만으로도 충분하지만, 아래 호출이 있어도 문제는 없습니다.
    try:
        from dotenv import load_dotenv
        load_dotenv(".env", override=False, encoding="utf-8")
    except Exception as e:
        print(f"load_dotenv error: {e}")
        # python-dotenv 미설치 등인 경우에도 설정 로딩은 pydantic-settings가 처리하므로 무시 가능
        pass

    app_env = os.getenv("APP_ENV", "development").strip().lower()
    if app_env == "production":
        return ProdSettings()
    print("2. settings...APP_ENV: ", app_env)
    return DevSettings()

CONFIG = get_settings()

 

# Project_folder/app/dependencies/auth.py

from typing import Optional, Set

from fastapi import Depends, HTTPException, Request, status, Response, Security

from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose.exceptions import ExpiredSignatureError
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.database import get_db
from app.core.redis import ACCESS_COOKIE_MAX_AGE
from app.core.settings import CONFIG
from app.models.user import User
from app.services.auth_service import AuthService, get_auth_service
from app.services.token_service import AsyncTokenService
from app.utils.auth import payload_to_user, get_token_expiry, verify_token
from app.utils.cookies import compute_cookie_attrs

# 헤더는 선택적으로만 받도록 설정 (없어도 에러 발생 X)
bearer_scheme = HTTPBearer(auto_error=False)

"""
토큰에서 현재 사용자 정보를 가져오는 의존성 함수
"""

async def get_current_user(
        request: Request, response: Response,
        credentials: Optional[HTTPAuthorizationCredentials] = Security(bearer_scheme),
        db: AsyncSession = Depends(get_db),
):
    # 1) 헤더
    auth = request.headers.get("authorization")
    print("1. get_current_user 시작  ::: auth::::::: ", auth)
    access_token = auth.split(" ", 1)[1].strip() if auth and auth.lower().startswith("bearer ") else None
    print("2. get_current_user 시작 ::: access_token::::::: ", access_token)

    # 2) 쿠키 폴백
    if not access_token:
        access_token = request.cookies.get(CONFIG.ACCESS_COOKIE_NAME)
    print("3. get_current_user 시작 ::: request.cookies.get access_token::::::: ", access_token)
    if access_token:
        try:
            user = await payload_to_user(access_token, db)
            print("get_current_user 끝 if access_token: user::::::: ", user)
            return user
        except ExpiredSignatureError:
            pass  # 3) 리프레시 시도

    # 3) 리프레시 폴백 (공통 함수 호출)
    refresh_token = request.cookies.get(CONFIG.REFRESH_COOKIE_NAME)
    print("4. refresh_access_token_from_cookie: refresh_token: ", refresh_token)
    if not refresh_token:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")

    auth_service = AuthService(db=db)
    token_payload = await auth_service.refresh_access_token(refresh_token)
    if not token_payload:
        # refresh 토큰이 유효하지 않거나 만료된 경우
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Refresh Token is invalid or expired",
        )

    new_access = token_payload.get(CONFIG.ACCESS_COOKIE_NAME)
    if not new_access:
        raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to issue access token")

    # request.state에 보관 (뷰에서 꺼내서 실제 TemplateResponse에 심을 수 있게)
    request.state.new_access = new_access
    attrs = compute_cookie_attrs(request, cross_site=False)
    print("1. attrs['secure']: ", attrs["secure"])
    print("2. attrs['samesite']: ", attrs["samesite"])
    response.set_cookie(
        key=CONFIG.ACCESS_COOKIE_NAME,
        value=new_access,
        httponly=True,
        secure=attrs["secure"],  # HTTPS라면 True
        samesite=attrs["samesite"],
        path="/",
        max_age=ACCESS_COOKIE_MAX_AGE,
    )

    user = await payload_to_user(new_access, db)

    print("get_current_user 끝 refresh_access_token) 리프레시 폴백: user::::::: ", user)
    return user


async def get_optional_current_user(request: Request, response: Response,
                                    db: AsyncSession = Depends(get_db)) -> Optional[User]:
    """
    인증 토큰이 없거나 유효하지 않은 경우 None을 반환하고, 다른 예외는 그대로 전달합니다.
    AI Chat:
        - 익명 접근을 허용하는 엔드포인트에서는 Depends(get_optional_current_user)를 사용하세요.
            이것을 적용하고 if current_user is None or current_user != user 로 분기하여 "Not authorized: 접근권한이 없습니다."로 raise 날려도 된다.
            그러면, Depends(get_current_user) 주입해서, "Not authenticated: 로그인하지 않았습니다."를 raise 날리는 것과 효과가 같다.
            효과는 같지만, 엄밀한 의미에서는 다르다.
        - 인증이 반드시 필요한 엔드포인트는 기존처럼 Depends(get_current_user)를 유지하면 됩니다.
    """
    try:
        # 핵심: get_current_user를 직접 호출하되, db를 명시적으로 전달
        return await get_current_user(request=request, response=response, db=db)
    except HTTPException as e:
        if e.status_code in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN):
            return None
        raise


# def allow_usernames(*allowed_names: str):
def allow_usernames(allowed_names: list):
    """ AI Chat이 알려준 방식임
    특정 username만 접근을 허용하는 FastAPI 의존성 팩토리.
    ADMINS = ["admin", "owner"]
    사용 예) Depends(allow_usernames(ADMINS))
    """
    allowed: Set[str] = {n.strip() for n in allowed_names if n and n.strip()}
    if not allowed:
        raise ValueError("allow_usernames requires at least one non-empty username")

    async def dependency(user = Depends(get_current_user)):
        if user is None:
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail="Not authenticated",
            )
        username = getattr(user, "username", None)
        if not username or username not in allowed:
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail="You Don't Have Permission to Access This Resource.",
            )
        return user  # 필요 시 엔드포인트에서 user를 그대로 사용 가능

    return dependency

 

# Project_folder/app/models/user.py

from typing import Optional

from sqlalchemy import String, Boolean
from sqlalchemy.orm import Mapped, mapped_column

from app.core.database import BaseModel


class User(BaseModel):
    __tablename__ = "users"

    # String은 제한 글자수를 지정해야 한다.
    username: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
    email: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
    password: Mapped[str] = mapped_column(String(255), nullable=False)
    img_path: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
    is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)

"""
- unique 와 index 중복
    - unique=True만으로도 고유 인덱스가 생성됩니다. index=True는 중복이므로 제거해도 됩니다(권장).
- 비밀번호 길이
    - password는 해시를 저장하므로 길이를 넉넉히 두는 것이 안전합니다(예: String(255)). 해시 종류에 따라 100자를 넘길 수 있습니다.
- 기본 키/타입
    - id는 Integer(primary_key=True)면 충분합니다. 아주 큰 규모를 예상하면 BigInteger도 고려할 수 있습니다.
"""

 

# Project_folder/app/schemas/accounts.py

from datetime import datetime
from pydantic import BaseModel, field_validator, EmailStr, ConfigDict, Field
from pydantic_core import PydanticCustomError
from pydantic_core.core_schema import FieldValidationInfo

from app.utils.accounts import optimal_password
from app.utils.commons import strict_email


class UserBase(BaseModel):
    username: str
    email: EmailStr

    @field_validator('username')
    def validate_username_min_length(cls, v: str):
        if len(v) < 3:
            raise PydanticCustomError('value_error', '닉네임은 최소 3글자이상 이어야 합니다.')
        return v

    @field_validator('email', mode='before')
    def validate_email_strict(cls, v):
        email = strict_email(v)
        return email


class UserIn(UserBase):
    password: str
    confirmPassword: str

    @field_validator('username', 'password', 'confirmPassword', 'email')
    def not_empty(cls, v):
        if not v or not v.strip():
            # raise ValueError('빈 값은 허용되지 않습니다.')
            # 접두사 없이 메시지 그대로 내려감
            raise PydanticCustomError('empty_value', '빈 값은 허용되지 않습니다.')
        return v

    @field_validator("password")
    def password_validator(cls, password: str | None) -> str | None:
        if not password:
            return password
        optimal_password(password)
        return password

    @field_validator('confirmPassword')
    def passwords_match(cls, v, info: FieldValidationInfo):
        if 'password' in info.data and v != info.data['password']:
            # raise ValueError('비밀번호가 일치하지 않습니다')
            # 접두사 없이 메시지 그대로 내려감
            raise PydanticCustomError('empty_value', '비밀번호가 일치하지 않습니다.')
        return v


class UserOut(UserBase):
    id: int
    created_at: datetime
    model_config = ConfigDict(from_attributes=True)


class UserUpdate(BaseModel):
    username: str | None = Field(None)
    email: EmailStr | None = None

    @field_validator('username')
    def validate_username_min_length(cls, v: str):
        if v is None:
            print("username is None")
            return None
        if isinstance(v, str) and v.strip() == '':
            return None
        if len(v) < 3:
            raise PydanticCustomError('value_error', '닉네임은 최소 3글자이상 이어야 합니다.')
        return v

    @field_validator('email', mode='before')
    def validate_email_none(cls, v):
        if v is None:
            print("email is None")
            return None
        if isinstance(v, str) and v.strip() == '':
            return None
        email = strict_email(v)
        return email

class UserResetPasswordIn(BaseModel):
    user_id: int
    password: str
    newpassword: str
    confirmPassword: str

    @field_validator('password', 'newpassword', 'confirmPassword')
    def not_empty(cls, v):
        if not v or not v.strip():
            # raise ValueError('빈 값은 허용되지 않습니다.')
            # 접두사 없이 메시지 그대로 내려감
            raise PydanticCustomError('empty_value', '빈 값은 허용되지 않습니다.')
        return v

    @field_validator("newpassword")
    def password_validator(cls, v: str | None) -> str | None:
        if not v:
            return v
        optimal_password(v)
        return v

    @field_validator('confirmPassword')
    def passwords_match(cls, v, info: FieldValidationInfo):
        if 'newpassword' in info.data and v != info.data['newpassword']:
            # raise ValueError('비밀번호가 일치하지 않습니다')
            # 접두사 없이 메시지 그대로 내려감
            raise PydanticCustomError('empty_value', '비밀번호가 일치하지 않습니다.')
        return v

class UserLostPasswordIn(BaseModel):
    email: EmailStr
    token: str
    newpassword: str
    confirmPassword: str

    @field_validator('email', mode='before')
    def validate_email_strict(cls, v):
        email = strict_email(v)
        return email

    @field_validator('token', 'newpassword', 'confirmPassword')
    def not_empty(cls, v):
        if not v or not v.strip():
            raise PydanticCustomError('empty_value', '빈 값은 허용되지 않습니다.')
        return v

    @field_validator("newpassword")
    def password_validator(cls, v: str | None) -> str | None:
        if not v:
            return v
        optimal_password(v)
        return v

    @field_validator('confirmPassword')
    def passwords_match(cls, v, info: FieldValidationInfo):
        if 'newpassword' in info.data and v != info.data['newpassword']:
            # raise ValueError('비밀번호가 일치하지 않습니다')
            # 접두사 없이 메시지 그대로 내려감
            raise PydanticCustomError('empty_value', '비밀번호가 일치하지 않습니다.')
        return v


class UserPasswordUpdate(BaseModel):
    password: str | None = Field(None)

    @field_validator("password")
    def password_validator(cls, password: str | None) -> str | None:
        if not password:
            return password
        optimal_password(password)
        return password


class EmailRequest(BaseModel):
    email: EmailStr
    type:str

    @field_validator('email', mode='before')
    def validate_email_strict(cls, v):
        email = strict_email(v)
        return email


class VerifyRequest(BaseModel):
    type: str | None = None
    old_email: EmailStr | None = None
    email: EmailStr | None = None  # ← 이메일도 상황에 따라 비울 수 있게 None 허용
    authcode: str | None = None  # ← type에 따라 필수가 다르므로 None 허용
    password: str | None = None

    @field_validator('old_email', 'email', mode='before')
    def validate_email_strict(cls, v):
        """인증코드만 검증할 때는 old_email/email이 들어오지 않는다.
        이메일 변경할 때는 인증코드와 함께 old_email과 email이 들어온다.
        old_email과 email이 들어오지 않는 경우는 인증코드만 검증할 수 있도록 None을 반환"""
        if v is None or v == "":
            return None
        """# 그 외는 strict_email 통과"""
        email = strict_email(v)
        return email

 

# Project_folder/app/schemas/auth.py

from pydantic import BaseModel, EmailStr, field_validator
from pydantic_core import PydanticCustomError

from app.utils.commons import strict_email


class TokenResponse(BaseModel):
    access_token: str
    refresh_token: str
    token_type: str


class LoginRequest(BaseModel):
    email: EmailStr
    password: str

    @field_validator('email', mode='before')
    def validate_email_strict(cls, v):
        email = strict_email(v)
        return email

    @field_validator('password')
    def not_empty(cls, v):
        print(f"Login Request: ", v)
        if not v or not v.strip():
            # raise ValueError('빈 값은 허용되지 않습니다.')
            # 접두사 없이 메시지 그대로 내려감
            raise PydanticCustomError('empty_value', '빈 값은 허용되지 않습니다.')
        return v

 

# Project_folder/app/services/account_service.py

from pydantic import EmailStr
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import Depends

from app.core.database import get_db
from app.models.user import User
from app.schemas.accounts import UserIn, UserPasswordUpdate, UserUpdate
from app.utils.accounts import get_password_hash


class UserService:
    def __init__(self, db: AsyncSession):
        self.db = db

    async def create_user(self, user_in: UserIn):
        hashed_password = await get_password_hash(user_in.confirmPassword)
        db_user = User(
            email=str(user_in.email),
            username=user_in.username,
            password=hashed_password,
        )

        self.db.add(db_user)
        await self.db.commit()
        await self.db.refresh(db_user)

        return db_user

    async def get_user_by_email(self, email: EmailStr):
        query = (select(User).where(User.email == email))
        result = await self.db.execute(query)  # await 추가
        return result.scalar_one_or_none()

    async def get_user_by_username(self, username: str):
        query = (select(User).where(User.username == username))
        result = await self.db.execute(query)  # await 추가
        return result.scalar_one_or_none()
    async def get_users(self):
        query = (select(User).order_by(User.created_at.desc()))
        result = await self.db.execute(query)
        users = result.scalars().all()
        return users

    async def get_user_by_id(self, user_id: int):
        query = (select(User).where(User.id == user_id))
        result = await self.db.execute(query)
        user = result.scalar_one_or_none()
        return user

    async def update_user(self, user_id: int, user_update: UserUpdate):
        user = await self.get_user_by_id(user_id)
        if user is None:
            return None
        if user_update.username is not None:
            user.username = user_update.username
        if user_update.email is not None:
            user.email = str(user_update.email)
        await self.db.commit()
        await self.db.refresh(user)
        return user

    async def update_email(self, old_email: EmailStr, email: EmailStr):
        user = await self.get_user_by_email(old_email)
        print("user", user)
        if user is None:
            return None
        user.email = str(email)
        await self.db.commit()
        await self.db.refresh(user)
        return user

    async def update_password(self, user_id: int, password_update: UserPasswordUpdate):
        user = await self.get_user_by_id(user_id)
        if user is None:
            return None
        hashed_password = await get_password_hash(password_update.password)
        user.password = hashed_password
        await self.db.commit()
        await self.db.refresh(user)
        return user

    async def user_image_update(self, user_id: int, img_path: str):
        user = await self.get_user_by_id(user_id)
        if user is None:
            return None
        user.img_path = img_path
        await self.db.commit()
        await self.db.refresh(user)
        return user

    async def delete_user(self, user_id: int):
        user = await self.get_user_by_id(user_id)
        if user is None:
            return False
        await self.db.delete(user)
        await self.db.commit()
        return True

def get_user_service(db: AsyncSession = Depends(get_db)) -> 'UserService':
    return UserService(db)

 

# Project_folder/app/services/auth_service.py

from datetime import timedelta, datetime, timezone

from fastapi import Depends
from jose import jwt
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.database import get_db
from app.core.settings import CONFIG
from app.models.user import User
from app.schemas.auth import LoginRequest
from app.services.token_service import AsyncTokenService
from app.utils.accounts import verify_password
from app.utils.auth import create_access_token, create_refresh_token, verify_token
from app.utils.exc_handler import CustomErrorException


class AuthService:
    def __init__(self, db: AsyncSession):
        self.db = db

    """
    사용자 인증을 수행합니다.
    """

    async def authenticate_user(self, login_data: LoginRequest):
        # 사용자 조회
        print("login_data: ", login_data)
        print("login_data.email: ", login_data.email)
        if not login_data.email or not login_data.password:
            raise CustomErrorException(status_code=410,
                                   detail="인증 실패: 빈칸을 채워주세요.",
                                   headers={"WWW-Authenticate": "Bearer"})

        query = (
            select(User).
            where(User.email == login_data.email)
        )
        result = await self.db.execute(query)
        user = result.scalar_one_or_none()
        print("user: ", user)
        print("user.email: ", user.email)
        print("login_data.email: ", login_data.email)
        print("login_data.password: ", login_data.password)
        print("user.password: ", user.password)

        if not user:
            # return None
            # 사용자 없음
            raise CustomErrorException(status_code=411,
                                   detail="인증 실패: 가입된 이메일이 존재하지 않습니다.",
                                   headers={"WWW-Authenticate": "Bearer"})

        password_ok = await verify_password(login_data.password, str(user.password))
        if not password_ok:
            # return None
            # raise PydanticCustomError를 던지면 Internal Server Error가 터져버린다.
            # raise PydanticCustomError('empty_value', '비밀번호 불일치')
            # 비밀번호 불일치
            raise CustomErrorException(status_code=411,
                                   detail="인증 실패: 비밀번호가 일치하지 않습니다.",
                                   headers={"WWW-Authenticate": "Bearer"})


        print("user: ", user)
        return user

    """
    사용자 정보를 기반으로 액세스 토큰을 생성합니다.
    """

    # AI Chat 권장: 정말 self/cls를 쓰지 않는다면 정적 메서드로 전환
    # - 메서드 선언에 @staticmethod를 붙이고 self 인자를 제거하세요.
    @staticmethod
    async def create_user_token(user: User):
    # async def create_user_token(self, user: User):
        # 토큰에 포함될 데이터
        token_data = {
            "username": user.username,
            "email": user.email,
            "user_id": user.id,
        }

        # 만료 시간 설정
        access_token_expires = timedelta(minutes=CONFIG.ACCESS_TOKEN_EXPIRE)

        # 액세스 토큰 생성
        access_token = await create_access_token(
            data=token_data,
            expires_delta=access_token_expires
        )
        try:
            unverified = jwt.get_unverified_claims(access_token)
            exp_ts = unverified.get("exp")
            if exp_ts:
                print("============== access_token의 만료기간 관련 정보 ==============")
                print("exp:", datetime.fromtimestamp(exp_ts, tz=timezone.utc))
                print("now:", datetime.now(timezone.utc))
                print("seconds_left:", int(exp_ts - datetime.now(timezone.utc).timestamp()))
        except Exception as e:
            print("get_unverified_claims 실패는 단순 디버깅 용도이므로 그대로 진행", e)
            pass

        # 리프레시 토큰 생성
        refresh_token = await create_refresh_token(
            data=token_data
        )

        try:
            unverified = jwt.get_unverified_claims(refresh_token)
            exp_ts = unverified.get("exp")
            if exp_ts:
                print("============== refresh_token의 만료기간 관련 정보 ==============")
                print("exp:", datetime.fromtimestamp(exp_ts, tz=timezone.utc))
                print("now:", datetime.now(timezone.utc))
                print("seconds_left:", int(exp_ts - datetime.now(timezone.utc).timestamp()))
        except Exception as e:
            print("get_unverified_claims 실패는 단순 디버깅 용도이므로 그대로 진행", e)
            pass


        # refresh_token을 Redis에 저장
        await AsyncTokenService.store_refresh_token(user.id, refresh_token)

        return {
            CONFIG.ACCESS_COOKIE_NAME: access_token,
            CONFIG.REFRESH_COOKIE_NAME: refresh_token,
            "token_type": "bearer"
        }

    """
    리프레시 토큰을 사용하여 새 액세스 토큰을 발급합니다.
    """

    async def refresh_access_token(self, refresh_token: str):
        print("refresh_access_token 리프레시 토큰으로 액세스 토큰 생성 시작: refresh_token: ", refresh_token)
        # 리프레시 토큰 검증
        payload = verify_token(refresh_token)
        if not payload:
            print("refresh_access_token payload 없다.: ", payload)
            return None

        user_id = payload.get("user_id")

        if not user_id:
            print("refresh_access_token user_id 없다.: ", user_id)
            return None

        """ # refresh_token이 만료기간이 남아 있는 경우 redis에 저장하는 로직을 한번 더 실행
        # 뭔가 redis 관련 문제로 인해 is_valid가 None으로 반환되어 버리는 오류?를 없애기 위해 인위적으로 redis에 저장하는 로직을 한번더 실행한다. """

        print("refresh_token 인위적 다시 redis 저장 시작")
        await AsyncTokenService.store_refresh_token(user_id, refresh_token)
        print("refresh_token 인위적 다시 저장 끝 ===> Redis에서 리프레시 토큰 유효성 확인")

        # Redis에서 리프레시 토큰 유효성 확인
        is_valid = await AsyncTokenService.validate_refresh_token(user_id, refresh_token)
        if not is_valid:
            print("refresh_access_token is_valid 안됐다.: ", is_valid)
            return None

        # 사용자 조회
        query = (select(User).where(User.id == user_id))
        result = await self.db.execute(query)
        user = result.scalar_one_or_none()
        if not user:
            print("refresh_access_token user 없다.: ", user)
            return None

        # 토큰에 포함될 데이터
        token_data = {
            "username": user.username,
            "email": user.email,
            "user_id": user.id,
        }

        # 새 액세스 토큰 생성
        access_token = await create_access_token(token_data)
        print("refresh_access_token access_token 만들었다.: ", access_token)

        return {
            CONFIG.ACCESS_COOKIE_NAME: access_token,
            "token_type": "bearer"
        }

def get_auth_service(db: AsyncSession = Depends(get_db)):
    return AuthService(db)

 

# Project_folder/app/services/token_service.py

from datetime import timedelta
from typing import Optional

from app.core.redis import redis_client
from app.core.settings import CONFIG

TOKEN_BLACKLIST_PREFIX = "blacklist:"  # 토큰 블랙리스트 키 접두사
REFRESH_TOKEN_PREFIX = "refresh:"  # Refresh 토큰 저장 접두사
DEFAULT_TOKEN_EXPIRY = 60 * 30  # 토큰 유효 기간 (초)


class AsyncTokenService:
    """
    Redis asyncio 클라이언트를 사용하는 비동기 토큰 서비스
    """

    @classmethod
    async def blacklist_token(cls, token: str, expires_in: int = DEFAULT_TOKEN_EXPIRY) -> bool:
        key = f"{TOKEN_BLACKLIST_PREFIX}{token}"
        await redis_client.set(key, "1", ex=expires_in)
        return True

    @classmethod
    async def is_token_blacklisted(cls, token: str) -> bool:
        key = f"{TOKEN_BLACKLIST_PREFIX}{token}"
        return bool(await redis_client.exists(key))

    @classmethod
    async def clear_blacklist(cls) -> None:
        # 대량 삭제 최적화: 일괄 수집 후 delete(*keys)
        keys = []
        async for key in redis_client.scan_iter(match=f"{TOKEN_BLACKLIST_PREFIX}*"):
            keys.append(key)
        if keys:
            await redis_client.delete(*keys)

    @classmethod
    async def store_refresh_token(cls, user_id: int, refresh_token: str) -> bool:
        user_key = f"{REFRESH_TOKEN_PREFIX}{user_id}"
        print("store_refresh_token user_key: ", user_key)

        expire_seconds = int(timedelta(days=CONFIG.REFRESH_TOKEN_EXPIRE + 1).total_seconds())
        print("store_refresh_token expire_seconds: ", expire_seconds)

        # asyncio 파이프라인
        async with redis_client.pipeline(transaction=True) as pipe:
            await pipe.sadd(user_key, refresh_token)
            await pipe.expire(user_key, expire_seconds)
            await pipe.execute()

        return True

    @classmethod
    async def validate_refresh_token(cls, user_id: int, refresh_token: str) -> bool:
        user_key = f"{REFRESH_TOKEN_PREFIX}{user_id}"
        return bool(await redis_client.sismember(user_key, refresh_token))

    @classmethod
    async def revoke_refresh_token(cls, user_id: int, refresh_token: Optional[str] = None) -> bool:
        user_key = f"{REFRESH_TOKEN_PREFIX}{user_id}"
        if refresh_token:
            await redis_client.srem(user_key, refresh_token)
        else:
            await redis_client.delete(user_key)
        return True

 

# Project_folder/app/utils/accounts.py

import asyncio
import re

from passlib.context import CryptContext

from app.utils.exc_handler import CustomErrorException

""" 아래의 순서대로, 
pip install "bcrypt==4.0.1"  # 반드시 bcrypt==4.0.1로 설치해야 한다.
pip install "passlib[bcrypt]"
"""

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

# 미리 컴파일된 정규식 (알파벳, 숫자, 특수문자 포함 9~50자)
# PASSWORD_REGEX = re.compile(r"^(?=.*[A-Za-z])(?=.*\d)(?=.*[!@#$%^&*?_=+-])[A-Za-z\d!@#$%^&*?_=+-]{9,50}$")


async def get_password_hash(password: str) -> str:
    # CPU 바운드 작업: 스레드 풀로 오프로드
    return await asyncio.to_thread(pwd_context.hash, password)


async def verify_password(plain_password: str, hashed_password: str) -> bool:
    # CPU 바운드 작업: 스레드 풀로 오프로드
    return await asyncio.to_thread(pwd_context.verify, plain_password, hashed_password)


def optimal_password(password: str):
    # password_optimal = PASSWORD_REGEX.search(str(password))
    # 가벼운 연산이라 굳이 비동기 함수가 필요하지는 않는다. 또한 field_validator는 비동기 함수를 지원하지 않는다.
    password_reg = r"^(?=.*[A-Za-z])(?=.*\d)(?=.*[!@#$%^&*?_=+-])[A-Za-z\d!@#$%^&*?_=+-]{9,50}$"
    regex = re.compile(password_reg)
    password_optimal = re.search(regex, str(password))
    print("password_optimal: password : ", password)

    if not password_optimal:
        from app.utils.exc_handler import CustomErrorException
        raise CustomErrorException(status_code=432, detail="비밀번호는 알파벳, 특수문자와 숫자를 모두 포함한 9자리 이상이어야 합니다.")


async def validate_self_user(user_id: int, current_user, user_service):
    user = await user_service.get_user_by_id(user_id)

    if current_user is None:
        raise CustomErrorException(status_code=403, detail="로그인하지 않았습니다.")

    if user is None:
        raise CustomErrorException(status_code=404, detail="존재하지 않는 사용자입니다.")

    if current_user.id != user_id:
        raise CustomErrorException(status_code=403, detail="접근권한이 없습니다.")

    return user

 

# Project_folder/app/utils/auth.py

import asyncio
from datetime import datetime, timedelta, timezone
import time
from typing import Optional, Any

from jose import jwt, JWTError, ExpiredSignatureError
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from fastapi import Depends, HTTPException

from app.core.database import get_db
from app.core.settings import CONFIG
from app.models.user import User
from app.utils.commons import refresh_expire

"""
JWT 액세스 토큰을 생성합니다.
"""

async def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
    to_encode = data.copy()

    # 만료 시간 설정(30분)
    if expires_delta:
        expire = datetime.now(timezone.utc) + expires_delta # 넘어온 값: timedelta(minutes=ACCESS_TOKEN_EXPIRE)
    else:
        expire = datetime.now(timezone.utc) + timedelta(minutes=CONFIG.ACCESS_TOKEN_EXPIRE)

    # JWT 페이로드에 만료 시간 추가
    to_encode.update({
        "exp": expire,
    })

    # JWT 토큰 생성 (스레드로 오프로드)
    encoded_jwt = await asyncio.to_thread(jwt.encode,
                                          to_encode,
                                          CONFIG.SECRET_KEY,
                                          algorithm=CONFIG.ALGORITHM)
    print("create_access_token ", encoded_jwt)
    return encoded_jwt


"""
JWT refresh 토큰을 생성합니다.
"""

async def create_refresh_token(data: dict) -> str:
    user_id = data["user_id"]
    # 만료 시간 설정(7일)
    _REFRESH_COOKIE_EXPIRE = refresh_expire()
    expire = _REFRESH_COOKIE_EXPIRE

    # JWT 페이로드에 만료 시간과 고유 ID 추가
    refresh_payload = {
        "user_id": user_id,
        "exp": expire,
        "type": "refresh"  # 토큰 타입 명시
    }

    # JWT 토큰 생성 (스레드로 오프로드)
    encoded_jwt = await asyncio.to_thread(jwt.encode,
                                          refresh_payload,
                                          CONFIG.SECRET_KEY,
                                          algorithm=CONFIG.ALGORITHM)
    print("create_refresh_token ", encoded_jwt)
    return encoded_jwt


"""
JWT 토큰을 검증하고 페이로드를 반환합니다.
"""

# AI Chat 권장: 동기 함수로 두는 것이 좋습니다.
def verify_token(token: str, *, type_: Optional[str] = None) -> Optional[dict[str, Any]]:
    # 디버깅용 로그 (원한다면 유지)
    try:
        unverified = jwt.get_unverified_claims(token)
        exp_ts = unverified.get("exp")
        if exp_ts:
            print("============== 해당 token의 만료기간 관련 정보 ==============")
            print("exp:", datetime.fromtimestamp(exp_ts, tz=timezone.utc))
            print("now:", datetime.now(timezone.utc))
            print("seconds_left:", int(exp_ts - datetime.now(timezone.utc).timestamp()))
    except Exception as e:
        print("get_unverified_claims 실패는 단순 디버깅 용도이므로 그대로 진행", e)
        pass

    try:
        payload = jwt.decode(token, CONFIG.SECRET_KEY, algorithms=[CONFIG.ALGORITHM])
        print("verify_token payload == :::::", payload)
        if type_ is not None and payload.get("type") != type_:
            # 타입 불일치 시 무효
            return None
        return payload
    except ExpiredSignatureError:
        print("verify_token: token expired")
        return None
    except JWTError as e:
        print("verify_token: jwt error:", repr(e))
        return None

async def payload_to_user(access_token: str, db: AsyncSession = Depends(get_db) ):
    payload = verify_token(access_token)
    print("payload_to_user: payload:::: ", payload)
    if payload is None:
        raise HTTPException(
            status_code=401,
            detail="존재하지 않는 사용자입니다.",
            headers={"WWW-Authenticate": "Bearer"},
        )

    # 토큰에서 사용자명 추출
    # username = payload.get("username") # username 변경시 변경된 username때문데 User를 찾을 수 없다.
    # if username is None:
    user_id = payload.get("user_id")
    if user_id is None:
        raise HTTPException(
            status_code=401,
            detail="인증되지 않은 사용자입니다.",
            headers={"WWW-Authenticate": "Bearer"},
        )

    # 사용자 조회
    # query = (select(User).where(User.username == username)) # username 변경시 변경된 username때문데 User를 찾을 수 없다.
    query = (select(User).where(User.id == user_id))
    result = await db.execute(query)
    user = result.scalar_one_or_none()
    if user is None:
        raise HTTPException(
            status_code=401,
            detail="사용자를 찾을 수 없습니다.",
            headers={"WWW-Authenticate": "Bearer"},
        )
    print("payload_to_user: user:::: : ", user)
    return user
"""
JWT 토큰의 남은 만료 시간을 초 단위로 계산
"""

# AI chat: 요약: 대부분의 경우 동기 함수로 두는 것이 더 낫습니다.
def get_token_expiry(token: str) -> int:
    try:
        payload = jwt.decode(token, CONFIG.SECRET_KEY, algorithms=[CONFIG.ALGORITHM])
        # payload = await asyncio.to_thread(
        #     jwt.decode,
        #     token,
        #     SECRET_KEY,
        #     algorithms=[ALGORITHM]
        # )

        exp = payload.get("exp")

        if exp is not None:
            # exp가 숫자 타임스탬프이거나 datetime일 수 있음
            if isinstance(exp, (int, float)):
                remaining = exp - time.time()
            elif isinstance(exp, datetime):
                remaining = (exp - datetime.now()).total_seconds()
            else:
                remaining = 0

            # 최소 1초 이상 설정
            return max(int(remaining), 1)

    except:
        pass

    # 기본값 (30분)
    return CONFIG.ACCESS_TOKEN_EXPIRE * 60

 

# Project_folder/app/utils/commons.py

import datetime
import random
import re
import shutil
import uuid
from typing import LiteralString

from email_validator import validate_email, EmailNotValidError
from fastapi import status, UploadFile, HTTPException, Request
import os
import aiofiles as aio
from pydantic_core import PydanticCustomError
from starlette.concurrency import run_in_threadpool

from app.core.settings import MEDIA_DIR, CONFIG, templates
from app.models.user import User


# 로컬파트/도메인 유효성 정규식
EMAIL_LOCAL_RE = re.compile(
    r"^(?![.])(?!.*[.]{2})[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*(?<!\.)$"
)
DOMAIN_LABEL_RE = re.compile(r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$")
TLD_ALPHA_RE = re.compile(r"^[A-Za-z]{2,24}$")
INVALID_EMAIL: LiteralString = "올바른 이메일 형식이 아닙니다."
#
def strict_email(v):
    if v is None:
        print(f"Email Strict Validation Error: ", '빈 값은 허용되지 않습니다.')
        raise PydanticCustomError('empty_value', "올바른 이메일 형식이 아닙니다.")

    s = str(v).strip()
    if not s:
        print(f"Email Strict Validation Error: ", '빈 값은 허용되지 않습니다.')
        raise PydanticCustomError('empty_value', INVALID_EMAIL)

    if s.count('@') != 1:
        print(f"Email Strict Validation Error: ", '올바른 이메일 형식이 아닙니다.')
        raise PydanticCustomError('invalid_email', INVALID_EMAIL)

    local, domain = s.split('@', 1)

    # 길이 제한
    if len(s) > 254:
        print(f"Email Strict Validation Error: ", '이메일 전체 길이가 너무 깁니다(최대 254).')
        raise PydanticCustomError('invalid_email', INVALID_EMAIL)
    if len(local) > 64:
        print(f"Email Strict Validation Error: ", '로컬파트 길이가 너무 깁니다(최대 64).')
        raise PydanticCustomError('invalid_email', INVALID_EMAIL)

    # 로컬파트(dot-atom) 검증
    if not EMAIL_LOCAL_RE.fullmatch(local):
        print(f"Email Strict Validation Error: ", '허용되지 않는 이메일 로컬파트입니다.')
        raise PydanticCustomError('invalid_email', INVALID_EMAIL)

    # 도메인 정규화 및 검증
    domain = domain.lower().rstrip('.')  # 끝의 점(FQDN 표기) 제거
    if not domain:
        print(f"Email Strict Validation Error: ", '올바른 이메일 형식이 아닙니다.')
        raise PydanticCustomError('invalid_email', INVALID_EMAIL)
    if len(domain) > 253:
        print(f"Email Strict Validation Error: ", '도메인 길이가 너무 깁니다(최대 253).')
        raise PydanticCustomError('invalid_email', INVALID_EMAIL)

    labels = domain.split('.')
    if len(labels) < 2:
        print(f"Email Strict Validation Error: ", '도메인에 점(.)이 최소 1개 포함되어야 합니다.')
        raise PydanticCustomError('invalid_email', INVALID_EMAIL)

    for label in labels:
        if not DOMAIN_LABEL_RE.fullmatch(label):
            print(f"Email Strict Validation Error: ", '허용되지 않는 도메인 라벨이 포함되어 있습니다.')
            raise PydanticCustomError('invalid_email', INVALID_EMAIL)

    tld = labels[-1]
    # TLD: 영문 2~24자 또는 punycode(xn--)
    if tld.startswith('xn--'):
        if not (5 <= len(tld) <= 63):
            print(f"Email Strict Validation Error: ", '유효하지 않은 최상위 도메인입니다.')
            raise PydanticCustomError('invalid_email', INVALID_EMAIL)
    else:
        if not TLD_ALPHA_RE.fullmatch(tld):
            print(f"Email Strict Validation Error: ", '유효하지 않은 최상위 도메인입니다.')
            raise PydanticCustomError('invalid_email', INVALID_EMAIL)
    if tld.isdigit():
        print(f"Email Strict Validation Error: ", '유효하지 않은 최상위 도메인입니다.')
        raise PydanticCustomError('invalid_email', INVALID_EMAIL)

    # 정상: 정규화(도메인은 소문자)
    return f"{local}@{domain}"

#
async def random_string(length:int, _type:str):
    if _type == "full":
        string_pool = "0za1qw2sc3de4rf5vb6gt7yh8nm9juiklop"
    elif _type == "string":
        string_pool = "abcdefghijklmnopqrstuvwxyz"
    else:
        string_pool = "0123456789"
    result = random.choices(string_pool, k=length)
    seperator = ""
    return seperator.join(result)
#
async def file_renaming(username:str, ext:str):
    date = datetime.datetime.now().strftime("%Y%m%d_%H%M_%S%f")
    random_str = await random_string(8, "full")
    new_filename = f"{username}_{date}{random_str}"
    return f"{new_filename}{ext}"
#
async def upload_single_image(path:str, user: User, imagefile: UploadFile = None):
    try:
        upload_dir = f"{path}"+"/"+f"{user.id}"+"/" # d/t Linux
        print("upload_dir: ", upload_dir)
        url = await file_write_return_url(upload_dir, user, imagefile, "media", _type="image")
        return url

    except Exception as e:
        print(e)
        raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
                            detail="이미지 파일이 제대로 Upload되지 않았습니다. ")

#
async def file_write_return_url(upload_dir: str, user: User, file: UploadFile, _dir: str, _type: str):
    if not os.path.exists(upload_dir):
        os.makedirs(upload_dir)

    filename_only, ext = os.path.splitext(file.filename)
    if len(file.filename.strip()) > 0 and len(filename_only) > 0:
        upload_filename = await file_renaming(user.username, ext)
        file_path = upload_dir + upload_filename

        async with aio.open(file_path, "wb") as outfile:
            if _type == "image":
                _CHUNK = 1024 * 1024
                while True:
                    chunk = await file.read(_CHUNK)
                    if not chunk:
                        break
                    await outfile.write(chunk)
            elif _type == "video":
                # await outfile.write(await file.read())
                # 대용량 업로드: 청크 단위로 읽어서 바로 쓰기 (메모리 사용 최소화)
                _CHUNK = 8 * 1024 * 1024  # 8MB
                while True:
                    chunk = await file.read(_CHUNK)
                    if not chunk:
                        break
                    await outfile.write(chunk)
                await outfile.flush()

        url = file_path.split(_dir)[1]
        return url
    else:
        return None

#
"""# 존재 체크-후-삭제 사이에 경쟁 상태가 생길 수 있슴, 
권장: 존재 여부 체크 없이 바로 삭제 시도 (경쟁 상태 방지)"""
async def remove_file_path(path:str): # 파일 삭제
    try:
        await run_in_threadpool(os.remove, path)
    except FileNotFoundError:
        pass  # 이미 없으면 무시

#
async def remove_empty_dir(_dir:str): # 빈 폴더 삭제
    try:
        await run_in_threadpool(os.rmdir, _dir)  # 비어 있을 때만 성공
    except FileNotFoundError:
        pass  # 이미 사라졌다면 무시
    except OSError as e:
        print("비어있지 않거나 잠겨 있는 경우: ", e)
        pass  # 비어있지 않거나 잠겨 있으면 무시(필요 시 로깅)


async def remove_dir_with_files(_dir:str): # 파일을 포함하여 폴더 삭제
    try:
        await run_in_threadpool(shutil.rmtree, _dir)
        # 이미지 저장 디렉토리 및 파일을 삭제, ignore_errors, onerror 파라미터 넣지 않아도 작동한다.
    except FileNotFoundError:
        pass

#
async def old_image_remove(filename:str, path:str):
    try:
        filename_only, ext = os.path.splitext(filename)
        if len(filename.strip()) > 0 and len(filename_only) > 0 and path is not None:
            # old_image_path = f'{APP_DIR}{path}' # \\없어도 된다. url 맨 앞에 \\ 있다.
            old_image_path = f'{MEDIA_DIR}'+'/'+f'{path}'
            await remove_file_path(old_image_path)

    except Exception as e:
        print(e)
        raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
                            detail="이미지 파일이 제대로 Upload되지 않았습니다. ")


# ----------------- 유틸: 이메일 유효성 체크 -----------------
def is_valid_email(email: str) -> bool:
    try:
        validate_email(email)
        return True
    except EmailNotValidError:
        return False


def refresh_expire():
    return datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=CONFIG.REFRESH_TOKEN_EXPIRE)

try:
    from zoneinfo import ZoneInfo  # Python 3.9+
    KST = ZoneInfo("Asia/Seoul")
except Exception as e:
    print("zoneInfo error: ", e)
    # tzdata가 없을 때를 위한 안전한 폴백(고정 +09:00)
    KST = datetime.timezone(datetime.timedelta(hours=9), name="KST")

#
def get_times(): # 시간 표시는 전역변수로 두지 말고, 매번 불러서 렌더링해야한다.
    # 1) 항상 UTC aware부터 시작
    _NOW_TIME_UTC= datetime.datetime.now(datetime.timezone.utc)

    # 2) 필요한 지역시간(KST)으로 변환
    _NOW_TIME = _NOW_TIME_UTC.astimezone(KST)
    return _NOW_TIME_UTC, _NOW_TIME

#
def to_kst(dt: datetime.datetime | None, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
    """
    UTC(또는 타임존 정보가 있는) datetime을 KST로 변환해 문자열로 반환합니다.
    - dt가 naive(타임존 없음)이면 UTC로 간주합니다.
    - dt가 None이면 빈 문자열을 반환합니다.
    - fmt로 출력 포맷을 지정할 수 있습니다.
    """
    if dt is None:
        return ""
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=datetime.timezone.utc)
    return dt.astimezone(KST).strftime(fmt)


async def render_with_times(request: Request, template: str, extra_context: dict | None = None):
    now_time_utc, now_time = get_times()
    context = {
        "request": request,
        "now_time_utc": now_time_utc.strftime('%Y-%m-%d %H:%M:%S.%f'),
        "now_time": now_time.strftime('%Y-%m-%d %H:%M:%S.%f'),
    }
    if extra_context:
        context.update(extra_context)
    return templates.TemplateResponse(template, context)


def create_orm_id(objs_all, user):
    """비동기 함수로 바꿔야 하나???"""
    try:
        unique_num = str(objs_all[0].id + 1)  # 고유해지지만, model.id와 일치하지는 않는다. 삭제된 놈들이 있으면...
        print("unique_num:::::::::::::: ", unique_num,)
    except Exception as e:
        print("c_orm_id Exception error::::::::: 임의로 1로... 할당  ", e)
        unique_num = str(1) # obj가 첫번째 것인 경우: 임의로 1로... 할당
    _random_string = str(uuid.uuid4())
    username = user.username
    orm_id = unique_num + ":" + username + "_" + _random_string
    return orm_id

 

# Project_folder/app/utils/cookies.py

# Python
from typing import Literal, TypedDict
from fastapi import Request

from app.core.settings import CONFIG


class CookieAttrs(TypedDict):
    secure: bool
    samesite: Literal["lax", "strict", "none"]

def _is_https(request: Request) -> bool:
    forwarded = request.headers.get("x-forwarded-proto")
    scheme = (forwarded or request.url.scheme or "").lower()
    return scheme == "https"

def compute_cookie_attrs(request: Request, *, cross_site: bool = False) -> CookieAttrs:
    """
    cross_site=True: 다른 사이트로 전송이 필요한 쿠키(예: 제3자 컨텍스트)일 때만 사용.
    개발 환경에서는 SameSite=None을 강제로 금지하고 Lax로 대체합니다.
    """
    is_https = _is_https(request)
    app_env = (CONFIG.APP_ENV or "").lower()

    # 기본값: same-site 시나리오
    if not cross_site:
        return CookieAttrs(secure=False if app_env == "development" else is_https, samesite="lax")

    # cross-site 시나리오
    if app_env == "development":
        # 개발에서는 None 금지 -> 경고 회피 및 로컬 디버깅 편의성
        return CookieAttrs(secure=False, samesite="lax")

    # 프로덕션: HTTPS일 때만 None + Secure
    if is_https:
        return CookieAttrs(secure=True, samesite="none")

    # 프로덕션이지만 http 인식인 경우(프록시 미설정 등): 안전하게 Lax로 폴백
    return CookieAttrs(secure=False, samesite="lax")

 

# Project_folder/app/utils/email.py

from fastapi_mail import FastMail, ConnectionConfig
from pydantic import EmailStr, TypeAdapter, SecretStr

from app.core.settings import CONFIG

# fastapi-mail==1.5.0 (1.5.8로 해본다.)
# -------------- FastMail 설정 --------------
if not CONFIG.SMTP_FROM:
    raise RuntimeError("SMTP_FROM environment variable is not set")
# 유효한 이메일인지 검증
MAIL_FROM: EmailStr = TypeAdapter(EmailStr).validate_python(CONFIG.SMTP_FROM)
mail_conf = ConnectionConfig(
    MAIL_USERNAME=CONFIG.SMTP_USERNAME,
    MAIL_PASSWORD=SecretStr(CONFIG.SMTP_PASSWORD or ""),
    MAIL_FROM=MAIL_FROM,
    MAIL_PORT=CONFIG.SMTP_PORT,
    MAIL_SERVER=CONFIG.SMTP_HOST,
    MAIL_STARTTLS=True,
    MAIL_SSL_TLS=False,
    USE_CREDENTIALS=True,
    VALIDATE_CERTS=True,
)

# ----------------- 인증코드 이메일 HTML 템플릿(스트링) -----------------
AUTHCODE_EMAIL_HTML_TEMPLATE = """
<!doctype html>
<html>
<head>
  <meta charset="utf-8" />
  <title>{{ title }}</title>
  <style>
    /* 이메일에서 간단히 적용되는 스타일 (대부분 이메일 클라이언트에서 지원) 직접 태그에 스타일을 먹여야 네이버에서도 적용된다.*/
    body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial; margin:0; padding:0; background:#f6f9fc; }
    .container { max-width:600px; margin:30px auto; background:#ffffff; border-radius:8px; padding:24px; box-shadow:0 2px 8px rgba(0,0,0,0.06); }
    .logo { text-align:center; margin-bottom:8px; }
    h1 { font-size:20px; margin:6px 0 14px; color:#111827; }
    p { color:#374151; font-size:15px; line-height:1.5; }
    .code { display:block; text-align:center; font-size:28px; font-weight:700; letter-spacing:4px; background:#f3f4f6; padding:12px 18px; margin:18px auto; border-radius:8px; width:fit-content; color:#111827; }
    .small { font-size:13px; color:#6b7280; margin-top:12px; }
    .footer { font-size:12px; color:#9ca3af; text-align:center; margin-top:18px; }
    .btn { display:inline-block; text-decoration:none; background:#2563eb; color:white; padding:10px 16px; border-radius:6px; }
  </style>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial; margin:0; padding:0; background:#f6f9fc;">
  <div class="container" style="max-width:600px; margin:30px auto; background:#ffffff; border-radius:8px; padding:24px; box-shadow:0 2px 8px rgba(0,0,0,0.06);">
    <div class="logo" style="text-align:center; margin-bottom:8px;">
      <!-- 로고를 원하면 img 태그 추가 -->
    </div>

    <h1 style="font-size:20px; margin:6px 0 14px; color:#111827;">회원가입을 위한 인증번호</h1>

    <p style="color:#374151; font-size:15px; line-height:1.5;">안녕하세요. 회원가입 절차를 위해 아래 인증번호를 입력해주세요. 인증번호는 <strong>10분</strong> 동안 유효합니다.</p>

    <div class="code" style="display:block; text-align:center; font-size:28px; font-weight:700; letter-spacing:4px; background:#f3f4f6; padding:12px 18px; margin:18px auto; border-radius:8px; width:fit-content; color:#111827;">{{ code }}</div>

    <p class="small" style="font-size:13px; color:#6b7280; margin-top:12px;">인증요청을 직접 하신 적이 없다면 이 메일을 무시하셔도 됩니다.</p>

    <div class="footer" style="font-size:12px; color:#9ca3af; text-align:center; margin-top:18px;">© 2025 Your Company — 안전한 서비스</div>
  </div>
</body>
</html>
"""

fastapi_email = FastMail(mail_conf)

 

# Project_folder/app/utils/exc_handler.py

import asyncio
import datetime
from typing import Any

from sqlalchemy.ext.asyncio import AsyncSession
from starlette.exceptions import HTTPException as StarletteHTTPException

from fastapi.responses import RedirectResponse, JSONResponse
from fastapi.requests import Request
from fastapi import status, HTTPException, Response, Depends

from app.core.database import get_db
from app.core.settings import templates, CONFIG


"""
400 == 410: Bad Request Error
401 == 411: Unauthorized Error
402 == 412: Payment Required Error
403 == 413: Forbidden Error
404 == 414: Not Found Error
405 == 415: Method Not Allowed Error
406 == 416: Not Acceptable Error
407 == 417: Proxy Authentication Required Error
408 == 418: Request Timeout Error
409 == 499: Existed Conflict Error
421 == 431: Misdirected Request Error
422 == 432: Unprocessable Entity Error
423 == 433: Locked Error 
429 == 439: Too Many Requests Error

500 == 600: Internal Server Error
"""

class CustomErrorException(HTTPException):
    def __init__(self, status_code: int, detail: Any, headers: dict = None):
        super().__init__(status_code=status_code, detail=detail, headers=headers)


async def custom_http_exception_handler(request: Request, exc: Exception):
    '''
    if exc.status_code == 401 and exc.detail == "refresh 실패":
        return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND)
    return JSONResponse(
        status_code=exc.status_code,
        content={"detail": exc.detail},
    )'''

    # 필요하다면 내부에서 Response 인스턴스 생성
    response = Response()

    if isinstance(exc, StarletteHTTPException):
        '''
        FastAPI에서 `isinstance()`는 특정 객체가 지정된 클래스 또는 타입의 인스턴스인지 확인하는 데 사용되는 파이썬 내장 함수입니다. 
        FastAPI에서는 주로 데이터 유효성 검증, 요청 데이터 처리, 또는 로직 분기 등을 위해 사용됩니다. 
        '''
        status_code = exc.status_code
        detail = exc.detail
    else:
        status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
        detail = "Internal Server Error"

    '''아래처럼 if 문으로 등록하면, 상태코드는 200으로 바뀌면서 json을 반환하게 된다. 
    js 단에서 잡아내서 errorTag.innerHTML하기 위해서...'''
    if status_code in (410, 411, 413, 415, 432, 439, 499, 600):
        return JSONResponse({"detail": f'{detail}'})

    if (status_code in (400, 401, 403)) and (getattr(exc, "detail", None) == "refresh 실패"):
        print("커스텀 exception handler refresh 실패", (getattr(exc, "detail", None)))
        current_user = None
        try:
            async for db in get_db():
                from app.dependencies.auth import get_optional_current_user
                maybe_user = get_optional_current_user(request, response, db)
                # 의존성 함수가 동기일 수도 있으므로 안전하게 처리
                if asyncio.iscoroutine(maybe_user):
                    current_user = await maybe_user
                else:
                    current_user = maybe_user
                break
        except Exception as e:
            print("Error: ", e)
            current_user = None

        now_time_utc = datetime.datetime.now(datetime.timezone.utc)
        now_time = datetime.datetime.now()
        access_token = request.cookies.get(CONFIG.ACCESS_COOKIE_NAME)
        refresh_token = request.cookies.get(CONFIG.REFRESH_COOKIE_NAME)

        template = "common/index.html"
        context = {
            "request": request,
            "title": "Hello World!!!",
            "now_time_utc": now_time_utc,
            "now_time": now_time,
            "access_token": access_token,
            "refresh_token": refresh_token,
            "current_user": current_user,
        }
        return templates.TemplateResponse(template, context, status_code=status.HTTP_200_OK)

    print("NOT REFRESH: 400 or 401 or 403: ", status_code)
    return templates.TemplateResponse(
            request = request,
            name="common/exceptions/http_error.html",
            context={
                "status_code": status_code,
                "title_message": "불편을 드려 죄송합니다.",
                "detail": detail
            },
            status_code = status_code
        )

 

# Project_folder/app/utils/middleware.py

from __future__ import annotations

from typing import Optional, List, Tuple
from urllib.parse import urlparse

from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from fastapi import Response, Request

from app.core.database import get_db
from app.core.redis import ACCESS_COOKIE_MAX_AGE
from app.core.settings import CONFIG
from app.services.auth_service import AuthService



def _is_cross_site(request: Request) -> bool:
    """
    Origin 헤더와 요청 URL을 비교하여 크로스 사이트 여부를 판단합니다.
    Origin이 없으면 동일 사이트로 간주합니다(첫 네비게이션 등).
    """
    origin = request.headers.get("origin")
    if not origin:
        return False
    try:
        o = urlparse(origin)
        req_host = request.url.hostname
        req_port = request.url.port or (443 if request.url.scheme == "https" else 80)
        o_port = o.port or (443 if o.scheme == "https" else 80)
        return (o.scheme != request.url.scheme) or (o.hostname != req_host) or (o_port != req_port)
    except Exception as e:
        print("Exception: _is_cross_site: ", e)
        return False


def _cookie_attrs_for(request: Request) -> dict:
    """
    요청 특성에 맞춘 쿠키 속성 결정:
    - 동일 사이트: SameSite=Lax, Secure=(https일 때만)
    - 크로스 사이트: SameSite=None, Secure=True(브라우저 정책상 필수)
    """
    cross = _is_cross_site(request)
    if cross:
        return dict(httponly=True,
                    samesite="none",
                    secure=True,
                    path="/",
                    max_age=ACCESS_COOKIE_MAX_AGE  # 초  # 필요 시 만료 설정
                    )
    else:
        print("secure: ", request.url.scheme == "https")
        return dict(httponly=True,
                    samesite="lax",
                    secure=(request.url.scheme == "https"),
                    # secure=False,  # https라면 True로 조정
                    path="/",
                    max_age=ACCESS_COOKIE_MAX_AGE # 초  # 필요 시 만료 설정
                    )


class AccessTokenSetCookieMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
        print("AccessTokenSetCookieMiddleware 시작 request.url: ", request.url)
        access_cookie: Optional[str] = request.cookies.get(CONFIG.ACCESS_COOKIE_NAME)
        refresh_cookie: Optional[str] = request.cookies.get(CONFIG.REFRESH_COOKIE_NAME)

        new_access: Optional[str] = None

        # 1) access_token이 없고 refresh_token만 있으면, 먼저 액세스 토큰을 발급
        if not access_cookie and refresh_cookie:
            async for db in get_db():
                try:
                    auth_service = AuthService(db=db)
                    refreshed = await auth_service.refresh_access_token(refresh_cookie)
                    if isinstance(refreshed, dict):
                        new_access = refreshed.get(CONFIG.ACCESS_COOKIE_NAME)
                        print("1. new_access: ", new_access)
                    elif isinstance(refreshed, str):
                        new_access = refreshed
                        print("2. new_access: ", new_access)
                except Exception as e:
                    # 개발 편의를 위해 로그만 남기고, refresh_token은 보존
                    print(f"[AccessTokenSetCookieMiddleware] refresh failed: {e}")
                    new_access = None
                finally: # get_db는 async generator이므로 한 번만 사용하고 빠져나옵니다.
                    print("AccessTokenSetCookieMiddleware 0.2.1 get_db finally: ", db)
                break

            print("AccessTokenSetCookieMiddleware new_access: ", new_access)
            # 2) 첫 요청부터 인증이 통과되도록 Authorization 헤더 주입
            if new_access:
                try:
                    raw_headers: List[Tuple[bytes, bytes]] = list(request.scope.get("headers", []))
                    raw_headers.append((b"authorization", f"Bearer {new_access}".encode("utf-8")))
                    request.scope["headers"] = raw_headers
                except Exception as e: # 헤더 주입 실패 시에도 응답 쿠키로는 설정됨
                    print(f"[AccessTokenSetCookieMiddleware] set auth header failed: {e}")
                    pass

        # 3) 애플리케이션 처리
        response = await call_next(request)

        # 4) 응답에 access_token 쿠키 설정(첫 응답부터 브라우저 저장)
        if new_access:
            attrs = _cookie_attrs_for(request)
            print("AccessTokenSetCookieMiddleware attrs: ", attrs)
            response.set_cookie(
                key=CONFIG.ACCESS_COOKIE_NAME,
                value=new_access,
                **attrs,
            )
        else:
            # 재발급이 없었다면 기존 동작 유지. 실패했다고 바로 refresh_token을 삭제하지는 않음.
            # 필요 시 아래 주석을 풀어 access_token만 정리할 수 있습니다.
            # response.delete_cookie(ACCESS_COOKIE_NAME, path="/")
            pass
        print("AccessTokenSetCookieMiddleware 끝 request.cookies.get(ACCESS_COOKIE_NAME): ", request.cookies.get(CONFIG.ACCESS_COOKIE_NAME))
        return response

 

# Project_folder/app/views/accounts.py

from fastapi import APIRouter, Request, Depends

from app.core.settings import templates
from app.dependencies.auth import get_current_user
from app.models.user import User
from app.schemas.accounts import UserOut
from app.services.account_service import UserService, get_user_service
from app.utils.accounts import validate_self_user
from app.utils.commons import get_times, render_with_times

router = APIRouter()


@router.get("/register")
async def register(request: Request):
    return await render_with_times(request, "accounts/register.html")


@router.get("/login")
async def login(request: Request):
    return await render_with_times(request, "accounts/login.html")


@router.get("/account/{user_id}", response_model=UserOut,
            summary="특정 회원 조회 HTML", description="회원의 ID 기반으로 특정 회원 조회 templates.TemplateResponse",
            responses={404: {
                "description": "회원 조회 실패",
                "content": {"application/json": {"example": {"detail": "회원을 찾을 수 없습니다."}}}
            }})
async def get_user_by_id(request: Request, user_id: int,
                         user_service: UserService = Depends(get_user_service),
                         current_user: User = Depends(get_current_user)):
    user = await validate_self_user(user_id, current_user, user_service)
    extra = {
        "current_user": user,
    }
    return await render_with_times(request, "accounts/detail.html", extra)


@router.get("/lost/password/reset")
async def lost_password_reset(request: Request):
    return await render_with_times(request, "accounts/lost.html")


@router.get("/username/update/{user_id}")
async def username_update(request: Request, user_id: int,
                          user_service: UserService = Depends(get_user_service),
                          current_user: User = Depends(get_current_user)):
    user = await validate_self_user(user_id, current_user, user_service)
    extra = {
        "current_user": user,
        "username": current_user.username,
    }
    return await render_with_times(request, "accounts/each.html", extra)


@router.get("/email/update/{user_id}")
async def email_update(request: Request, user_id: int,
                       user_service: UserService = Depends(get_user_service),
                       current_user: User = Depends(get_current_user)):
    user = await validate_self_user(user_id, current_user, user_service)
    extra = {
        "current_user": user,
        "email": current_user.email,
    }
    return await render_with_times(request, "accounts/each.html", extra)


@router.get("/password/update/{user_id}")
async def password_update(request: Request, user_id: int,
                          user_service: UserService = Depends(get_user_service),
                          current_user: User = Depends(get_current_user)):
    user = await validate_self_user(user_id, current_user, user_service)
    extra = {
        "current_user": user,
        "password": "password",
    }
    return await render_with_times(request, "accounts/each.html", extra)


@router.get("/profile/image/update/{user_id}")
async def profile_image_update(request: Request, user_id: int,
                               user_service: UserService = Depends(get_user_service),
                               current_user: User = Depends(get_current_user)):
    user = await validate_self_user(user_id, current_user, user_service)
    extra = {
        "current_user": user,
        "image": "image",
    }
    return await render_with_times(request, "accounts/each.html", extra)

 

# Project_folder/app/views/index.py

from typing import Optional

from fastapi import APIRouter, Request, Depends, Response
from fastapi.openapi.docs import get_redoc_html
from fastapi.responses import HTMLResponse, JSONResponse

from app.core.settings import templates
from app.dependencies.auth import get_optional_current_user
from app.models.user import User
from app.utils.commons import get_times

router = APIRouter()

@router.get("/")
def index(request: Request,
          current_user: Optional[User] = Depends(get_optional_current_user)):
    now_time_utc, now_time = get_times()
    _NOW_TIME_UTC = now_time_utc.strftime('%Y-%m-%d %H:%M:%S.%f')
    _NOW_TIME = now_time.strftime('%Y-%m-%d %H:%M:%S.%f')

    template = "common/index.html"
    context={"request": request, "message":"Hello World",
             "now_time_utc": _NOW_TIME_UTC,
             "now_time": _NOW_TIME,
             'current_user': current_user,
             }

    return templates.TemplateResponse(template,context)


SWAGGER_JS = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"
SWAGGER_CSS = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css"
SWAGGER_FAV = "https://fastapi.tiangolo.com/img/favicon.png"
COOKIE_NAME = "csrf_token"
HEADER_NAME = "X-CSRF-Token"
open_api_url = "/swagger/custom/openapi.json"

@router.get("/swagger/custom/docs", include_in_schema=False)
async def custom_docs():
    """여기에 html 문서자체를 커스텀 마이징하는 로직을 추가하면 된다.
    CSRF_TOKEN을 주입하는 경우 등...
    csrf_token을 header에 추가하는 커스텀 Swagger UI 페이지를 반환합니다.
    """
    from main import app
    openapi_url = app.openapi_url or open_api_url
    html = f"""<!doctype html>
                        <html>
                        <head>
                          <meta charset="utf-8" />
                          <meta name="viewport" content="width=device-width, initial-scale=1">
                          <title>Swagger Custom Docs</title>
                          <link rel="stylesheet" href="{SWAGGER_CSS}" />
                          <link rel="icon" type="image/png" href="{SWAGGER_FAV}" />
                          <style>
                            html {{ box-sizing: border-box; overflow: -moz-scrollbars-vertical; overflow-y: scroll; }}
                            *, *:before, *:after {{ box-sizing: inherit; }}
                            body {{ margin:0; background: #fafafa; }}
                          </style>
                        </head>
                        <body>
                          <div id="swagger-ui"></div>

                          <script src="{SWAGGER_JS}"></script>
                          <script>
                          window.onload = function() {{
                            // 쿠키에서 값 읽는 유틸 (안정적인 방법)
                            function getCookie(name) {{
                              const m = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)');
                              return m ? decodeURIComponent(m[2]) : null;
                            }}

                            // Swagger UI 초기화
                            const ui = SwaggerUIBundle({{
                              url: "{openapi_url}",
                              dom_id: '#swagger-ui',
                              deepLinking: true,
                              presets: [SwaggerUIBundle.presets.apis],
                              layout: "BaseLayout",

                              // requestInterceptor - 모든 요청이 전송되기 전에 호출됩니다.
                              requestInterceptor: function (req) {{
                                try {{
                                  // 안전한 메서드에는 CSRF를 추가하지 않습니다.
                                  const safeMethods = ['GET','HEAD','OPTIONS','TRACE'];
                                  if (!safeMethods.includes((req.method || '').toUpperCase())) {{
                                    const csrf = getCookie("{COOKIE_NAME}");
                                    if (csrf) {{
                                      // 헤더 추가 (대소문자 무관)
                                      req.headers["{HEADER_NAME}"] = csrf;
                                    }}
                                    // 쿠키 전송이 필요하면 credentials 설정 (CORS 허용 필요)
                                    // 'include' 는 cross-site에서도 쿠키를 전송합니다. same-origin이면 'same-origin' 사용 가능.
                                    req.credentials = 'include';
                                  }}
                                }} catch (e) {{
                                  console.warn('requestInterceptor error:', e);
                                }}
                                return req;
                              }},
                            }});

                            window.ui = ui;
                          }};
                          </script>
                        </body>
                        </html>"""

    # return get_swagger_ui_html(
    #     openapi_url=open_api_url,  # openapi 스펙 파일 URL
    #     title="Custom API Docs",
    # )
    return HTMLResponse(html)


@router.get("/swagger/custom/redoc", include_in_schema=False)
async def custom_redoc():
    """여기에 html 문서자체를 커스텀 마이징하는 로직을 추가하면 된다."""
    return get_redoc_html(
        openapi_url=open_api_url,   # 스펙 파일 경로
        title="Custom ReDoc",          # 페이지 제목
        redoc_favicon_url="https://fastapi.tiangolo.com/img/favicon.png",
    )

 

# Project_folder/app/static/statics/uikit

 

# Project_folder/app/static/statics/css/custom/footer.css

.footer {
    background-color: #5758BB;
    height: 60px;
}

.footer .main-container {
    font-size: 18px;
    width: 100%;
    height: 100%;
    color: white;
    display: flex;
    align-items: center;
    justify-content: center;
}

 

# Project_folder/app/static/statics/css/custom/form.css

.form-container.login-container, .form-container.register-container {
    max-width: 600px;
    margin: 40px auto;
    border: solid 1px #ced6e0;
    border-radius: 5px;
    padding: 20px 30px;}

form button {border: #30336b; border-radius: 3px;
    width: 100%; height: 45px; background-color: #6A626B; color: white; font-weight: 500; font-size: 20px; cursor: pointer;}

form button:hover {background-color: #615463;}

.hr-bold {border: 0;
    margin-bottom: 25px;
    height: 2px;
    background: #dfe6e9;}

#errorTag {
    display: none;
    border: 1px solid mistyrose;
    background: #fff5f7;
    padding: 12px;
    border-radius: 8px;
    color: #a00;
    margin-bottom: 10px;
}

 

# Project_folder/app/static/statics/css/custom/header.css

header {
    width: 100%;
    background-color: #30336b;
}

div.menu > div.uk-navbar-right {gap: 20px;}
header .header nav.nav-container {
    height: 70px;
    max-width: 1180px;
    margin: 0 auto;
    padding: 0 20px;
    color: #dff9fb;
    display: flex;
    align-items: center;
    justify-content: space-between;
}

header .header nav.nav-container div.logo {
    font-size: 30px;
    font-weight: 700;
}

header .header nav.nav-container .menu .menu-item a {
    font-size: 18px;
}

header .header nav.nav-container div.logo a, .menu .menu-item a {
    text-decoration: none;
    color: #dff9fb;
}

header .header nav.nav-container div.logo a:hover, .menu .menu-item a:hover {
    color: #4bcffa;
}

 

# Project_folder/app/static/statics/css/custom/main.css

.main-with {width: 97%; margin: 0 auto;}
.width-100 {width: 100%;}
/************ 배경 색깔과 이미지 색깔을 동일하게 맞추는 효과 ************/
/*body {background: hsla(56, 80%, 98%, 1);}*/
/*.header-image.background {*/
/*    position: relative; !* 자식 요소의 위치 기준 설정 *!*/
/*    display: inline-block;*/
/*}*/
/*.header-image.background::after {*/
/*    content: ''; !* 의사 요소(pseudo-element) 생성 *!*/
/*    position: absolute; !* 부모 요소를 기준으로 절대 위치 지정 *!*/
/*    top: 0;*/
/*    left: 0;*/
/*    width: 100%;*/
/*    height: 100%;*/
/*    background: hsla(56, 80%, 98%, 1);*/
/*    mix-blend-mode: multiply; !* 이미지와 겹쳐진 색상을 혼합하는 효과 *!*/
/*}*/
/************ 배경 색깔과 이미지 색깔을 동일하게 맞추는 효과 ************/

header .header-container {display: flex; width:98.5%; margin-top:5px;}
.header-image img {width:70px; height: 70px}
.header-hr1 {margin-top:5px; margin-bottom:2px; height: 2px;  background: grey;}
.header-hr2 {margin-top:1px !important; margin-bottom: 0 !important; background: grey; height: 1px;}
.header-hr3 {height: 2px; background: grey;}
.header-hr4 {margin-top:3px !important; margin-bottom:10px !important; background: grey; height: 1px;}
.header-hr5 {margin-top:10px !important; margin-bottom:3px !important; background: grey; height: 1px;}
.header-hr6 {height: 2px; background: grey; margin-bottom: 10px !important;}

.logo-container {display: flex; justify-content: space-evenly; height: 67px;}
.logo-container .right {width: 5%;}
.logo {position: relative; left: -20px; text-align: center; margin-top:px; padding: 10px 0; font-size: 50px; font-weight: 900;}
.logo a {color: #485460;}
.logo-container .left {width: 5%;}

.menu-container {width: 100%;}
.menu {text-align: center; width: 80% !important;}
.menu .item {display: flex; justify-content: space-around; gap: 10px; font-size: 18px;}

footer .contents {text-align: center; font-size: 18px; color: #636e72;}
footer .footer {padding-bottom: 15px}
footer hr {margin-top: 10px; margin-bottom: 10px; height: 1px; background: #636e72;}

.section-container.flex {display: flex; flex-wrap: wrap; /* 반응형을 위해 추가 */}
.section-container.flex .rt-container,
.section-container.flex .lt-container{width: 15%;}
.section-container.flex .contents-container {width: 70%;}
.section-container.flex .rt-container div.side-item {margin: 10px 0 10px 10px; border: 1px solid #636e72;}
.section-container.flex .lt-container div.side-item {margin: 10px 10px 10px 0; border: 1px solid #636e72;}
.section-container.flex .rt-container div.side-item .inner {background-color: #f5f6fa; padding: 10px}
.section-container.flex .lt-container div.side-item .inner {background-color: #f5f6fa; padding: 10px}
@media (max-width: 1130px) {
    .main-with {width: 96%;}
    .section-container.flex {flex-direction: column; /* 블록들을 세로로 쌓음 */}
    .section-container.flex .contents-container {order: 1; /* 가운데 블록이 제일 위로 */ width: 100%;}
    .section-container.flex .lt-container {order: 2; /* 우측 블록이 두 번째로 */width: 100%;}
    .section-container.flex .rt-container {order: 3; /* 좌측 블록이 제일 아래로 */width: 100%;}
    .section-container.flex .rt-container div.side-item {margin: 10px 0;}
    .section-container.flex .lt-container div.side-item {margin: 10px 0;}
}
@media (max-width: 700px) {.main-with {width: 95%;}}
@media (max-width: 550px) {.main-with {width: 93%;}}

 

# Project_folder/app/static/statics/css/custom/reset.css

/**
 * Minified by jsDelivr using clean-css v5.3.3.
 * Original file: /npm/reset-css@4.0.1/reset.css
 *
 * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
 */
a, abbr, acronym, address, applet, article, aside, audio, b, big, blockquote, body,
canvas, caption, center, cite, code, dd, del, details, dfn, div, dl, dt, em, embed,
fieldset, figcaption, figure, footer, form, h1, h2, h3, h4, h5, h6, header, hgroup,
html, i, iframe, img, ins, kbd, label, legend, li, main, mark, menu, nav, object, ol,
output, p, pre, q, ruby, s, samp, section, small, span, strike, strong, sub, summary,
sup, table, tbody, td, tfoot, th, thead, time, tr, tt, u, ul, var, video {
    margin: 0;
    padding: 0;
    border: 0;
    font-size: 100%;
    font: inherit;
    vertical-align: baseline
}

body {
    color: #1e272e;
    line-height: 1.75rem;
    font-family: 'Noto Sans KR', sans-serif;
    font-size: 16px;
}

body a {
    text-decoration: none;
    color: #341f97;
}
body a:hover {
    color: #b33939;
    text-decoration: none;
}
/*# sourceMappingURL=/sm/5afe8c7b72cef7e558e6906b60d8730580a09d990f202e65e984835fa5d757cc.map */

article, aside, details, figcaption, figure, footer, header, hgroup, main, menu, nav, section {
    display: block
}

[hidden] {
    display: none
}


/*ol, ul {*/
/*    list-style: none*/
/*}*/

blockquote, q {
    quotes: none
}

blockquote:after, blockquote:before, q:after, q:before {
    content: '';
    content: none
}

table {
    border-collapse: collapse;
    border-spacing: 0
}
/* Custom Reset */
.fs-12 {font-size: 12px}
.fs-13 {font-size: 13px}
.fs-14 {font-size: 14px}
.fs-15 {font-size: 15px}
.fs-16 {font-size: 16px}
.fs-17 {font-size: 17px}
.fs-18 {font-size: 18px}

.pt-2 {padding-top: 2px}
.pt-3 {padding-top: 3px}
.pt-4 {padding-top: 4px}
.pt-5 {padding-top: 5px}
.pt-7 {padding-top: 7px}
.pt-8 {padding-top: 8px}
.pt-9 {padding-top: 9px}
.pt-10 {padding-top: 10px}
.pt-15 {padding-top: 15px}
.pt-20 {padding-top: 20px}
.pt-30 {padding-top: 30px}

.pb-2 {padding-bottom: 2px}
.pb-3 {padding-bottom: 3px}
.pb-4 {padding-bottom: 4px}
.pb-5 {padding-bottom: 5px}
.pb-7 {padding-bottom: 7px}
.pb-8 {padding-bottom: 8px}
.pb-9 {padding-bottom: 9px}
.pb-10 {padding-bottom: 10px}
.pb-15 {padding-bottom: 15px}
.pb-20 {padding-bottom: 20px}
.pb-30 {padding-bottom: 30px}

.pl-2 {padding-left: 2px}
.pl-3 {padding-left: 3px}
.pl-4 {padding-left: 4px}
.pl-5 {padding-left: 5px}
.pl-7 {padding-left: 7px}
.pl-8 {padding-left: 8px}
.pl-9 {padding-left: 9px}
.pl-10 {padding-left: 10px}
.pl-15 {padding-left: 15px}
.pl-20 {padding-left: 20px}
.pl-22 {padding-left: 22px}
.pl-23 {padding-left: 23px}
.pl-25 {padding-left: 25px}
.pl-30 {padding-left: 30px}
.pl-40 {padding-left: 40px}

.pr-5 {padding-right: 5px}
.pr-7 {padding-right: 7px}
.pr-8 {padding-right: 8px}
.pr-9 {padding-right: 9px}
.pr-10 {padding-right: 10px}
.pr-15 {padding-right: 15px}
.pr-20 {padding-right: 20px}
.pr-30 {padding-right: 30px}

.mt-0 {margin-top: 0}
.mt-2 {margin-top: 2px}
.mt-3 {margin-top: 3px}
.mt-4 {margin-top: 4px}
.mt-5 {margin-top: 5px}
.mt-6 {margin-top: 6px}
.mt-7 {margin-top: 7px}
.mt-8 {margin-top: 8px}
.mt-9 {margin-top: 9px}
.mt-10 {margin-top: 10px}
.mt-15 {margin-top: 15px}
.mt-20 {margin-top: 20px}
.mt-25 {margin-top: 25px}
.mt-30 {margin-top: 30px}
.mt-35 {margin-top: 35px}
.mt-40 {margin-top: 40px}

.mb-0 {margin-bottom: 0}
.mb-2 {margin-bottom: 2px}
.mb-3 {margin-bottom: 3px}
.mb-4 {margin-bottom: 4px}
.mb-5 {margin-bottom: 5px}
.mb-7 {margin-bottom: 7px}
.mb-8 {margin-bottom: 8px}
.mb-9 {margin-bottom: 9px}
.mb-10 {margin-bottom: 10px}
.mb-15 {margin-bottom: 15px}
.mb-20 {margin-bottom: 20px}
.mb-30 {margin-bottom: 30px}
.mb-40 {margin-bottom: 40px}

.mr-0 {margin-right: 0}
.mr-2 {margin-right: 2px}
.mr-3 {margin-right: 3px}
.mr-4 {margin-right: 4px}
.mr-5 {margin-right: 5px}
.mr-7 {margin-right: 7px}
.mr-8 {margin-right: 8px}
.mr-9 {margin-right: 9px}
.mr-10 {margin-right: 10px}
.mr-15 {margin-right: 15px}
.mr-20 {margin-right: 20px}
.mr-30 {margin-right: 30px}
.mr-40 {margin-right: 40px}

.ml-0 {margin-left: 0}
.ml-2 {margin-left: 2px}
.ml-3 {margin-left: 3px}
.ml-4 {margin-left: 4px}
.ml-5 {margin-left: 5px}
.ml-6 {margin-left: 6px}
.ml-7 {margin-left: 7px}
.ml-8 {margin-left: 8px}
.ml-9 {margin-left: 9px}
.ml-10 {margin-left: 10px}
.ml-15 {margin-left: 15px}
.ml-20 {margin-left: 20px}
.ml-30 {margin-left: 30px}
.ml-40 {margin-left: 40px}

 

# Project_folder/app/static/statics/js/fastapiClient.js

const STORAGE_KEY = 'access_token';
const ACCESS_TOKEN_COOKIE_NAME = 'access_token';
const ACCESS_TOKEN_EXPIRE = 30; // 백엔드와 맞춤
let SERVER_URL = (typeof window !== 'undefined' && window.SERVER_URL) ? window.SERVER_URL.replace(/\/+$/, '') : '';


export function getErrorTag() {
    return document.getElementById('errorTag');
}

export function gerUserIDTag() {
    return document.getElementById('userID');
}

export function gerUserEmailTag() {
    return document.getElementById('userEmail');
}

/**
 * FormData 또는 일반 객체에서 키에 대한 값을 안전하게 가져옵니다.
 * - FormData면 .get(key) 결과(첫 번째 값)를 반환
 * - 일반 객체면 obj[key]를 반환
 */
export function getParam(params, key) {
  const isFormData =
    typeof FormData !== 'undefined' && params instanceof FormData;
  return isFormData ? params.get(key) : params?.[key];
}


function getCookie(name) {
    if (typeof document === 'undefined') return '';
    const m = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)');
    return m ? decodeURIComponent(m.pop()) : '';
}

async function ensureCsrfToken() {
    let csrf_token = getCookie('csrf_token') || getCookie('csrftoken') || '';
    if (csrf_token) return csrf_token;
    // fetch server endpoint to ensure cookie and get token in JSON
    try {
        const url = (SERVER_URL || '') + '/apis/auth/csrf_token';
        const res = await fetch(url, {method: 'GET', credentials: 'include'});
        if (!res.ok) return '';
        const j = await res.json();
        csrf_token = j && (j.csrf_token || j.csrf || j.token) || '';
        return csrf_token;
    } catch (e) {
        console.warn('Failed to fetch csrf token', e);
        return '';
    }
}

// 공통 에러 메시지 정규화 헬퍼 (업데이트)
export function extractErrorMessage(err) {
    const toStr = (s) => (s ?? '').toString();
    const strip = (s) => toStr(s).replace(/^Error:\s*/, '');
    const tryParse = (s) => {
        if (typeof s !== 'string') return null;
        const t = s.trim();
        if (!(t.startsWith('{') || t.startsWith('['))) return null;
        try {
            return JSON.parse(t);
        } catch {
            return null;
        }
    };

    // 1) 문자열
    if (typeof err === 'string') {
        const parsed = tryParse(err);
        if (parsed) return extractErrorMessage(parsed);
        return strip(err);
    }

    // 2) Error 인스턴스
    if (err instanceof Error) {
        const msg = strip(err.message);
        const parsed = tryParse(msg);
        if (parsed) return extractErrorMessage(parsed);
        if (msg && msg !== '[object Object]') return msg;

        // Error에 실린 부가 정보 탐색
        if (err.cause) {
            const fromCause = extractErrorMessage(err.cause);
            if (fromCause && fromCause !== '[object Object]') return fromCause;
        }
        if (err.data) {
            const fromData = extractErrorMessage(err.data);
            if (fromData) return fromData;
        }
        if (err.response && err.response.data) {
            const fromResp = extractErrorMessage(err.response.data);
            if (fromResp) return fromResp;
        }
    }

    // 3) 서버 응답으로 추정되는 페이로드 탐색
    const data =
        err?.response?.data ??
        err?.data ??
        err?.body ??
        err?.payload ??
        err?.json ??
        err?.error ??
        err;

    // FastAPI: detail이 문자열
    if (typeof data?.detail === 'string' && data.detail) return data.detail;

    // FastAPI: detail이 배열(검증 오류) → msg 모아 출력
    if (Array.isArray(data?.detail)) {
        const msgs = data.detail
            .map((d) => d?.msg || d?.message || (typeof d === 'string' ? d : ''))
            .filter(Boolean);
        if (msgs.length) return msgs.join('\n');
    }

    // FastAPI: detail이 객체({필드: [메시지, ...]}) → 첫 메시지만 반환
    if (data && data.detail && typeof data.detail === 'object' && !Array.isArray(data.detail)) {
        const values = Object.values(data.detail);
        const firstValue = values.length ? values[0] : undefined;

        // 배열이면 첫 번째 요소, 아니면 값 자체를 메시지로 사용
        const rawMessage = Array.isArray(firstValue) ? firstValue[0] : firstValue;

        // 문자열화했을 때 [object Object]가 아니면 그대로 반환, 아니면 기본 메시지
        const message = toStr(rawMessage);
        return message && message !== '[object Object]'
            ? message
            : '알 수 없는 오류가 발생했습니다.';
    }


    // 일반적인 필드
    if (typeof data?.message === 'string' && data.message) return data.message;
    if (typeof data?.msg === 'string' && data.msg) return data.msg;
    if (typeof data?.error === 'string' && data.error) return data.error;

    // 상태 텍스트 보조
    const statusText = err?.response?.statusText || err?.statusText || err?.status?.text;
    if (typeof statusText === 'string' && statusText) return statusText;

    // 마지막 수단
    if (data && typeof data === 'object') {
        const nested = data?.error || data?.errors;
        if (nested) {
            const nm = extractErrorMessage(nested);
            if (nm) return nm;
        }
        try {
            return JSON.stringify(data);
        } catch {
        }
    }

    return '오류가 발생했습니다.';
}

// 실패 응답을 Error로 만들 때 원본 페이로드를 보존해 주는 유틸 (추가)
async function throwForBadResponse(res) {
    let payload = null;
    try {
        const ct = res.headers.get('content-type') || '';
        if (ct.includes('application/json')) payload = await res.json();
        else payload = await res.text();
    } catch (_) {
        payload = null;
    }

    const message =
        extractErrorMessage(payload) ||
        res.statusText ||
        `HTTP ${res.status}`;

    // cause/data에 원본을 담아두면 상위에서 extractErrorMessage가 복원 가능
    const error = new Error(message, {cause: payload ?? undefined});
    error.status = res.status;
    error.data = payload;
    throw error;
}


/**
 * fastapiClient(operation, url, params, success_callback, failure_callback)
 */
// helper: shallow build query string
function buildQuery(paramsObj = {}) {
  const usp = new URLSearchParams();
  Object.entries(paramsObj || {}).forEach(([k, v]) => {
    if (v === undefined || v === null || v === '') return;
    if (Array.isArray(v)) {
      v.forEach((item) => {
        if (item !== undefined && item !== null && item !== '') usp.append(k, String(item));
      });
    } else {
      usp.append(k, String(v));
    }
  });
  const s = usp.toString();
  return s ? `?${s}` : '';
}

function hasFileValue(obj) {
  if (!obj || typeof obj !== 'object') return false;
  for (const v of Object.values(obj)) {
    if (!v) continue;
    if (typeof File !== 'undefined' && v instanceof File) return true;
    if (typeof Blob !== 'undefined' && v instanceof Blob) return true;
    if (typeof FileList !== 'undefined' && v instanceof FileList && v.length > 0) return true;
    if (typeof v === 'object' && hasFileValue(v)) return true;
  }
  return false;
}

function objectToFormData(obj, form = new FormData(), prefix = '') {
  if (!obj || typeof obj !== 'object') return form;
  for (const [key, value] of Object.entries(obj)) {
    const formKey = prefix ? `${prefix}.${key}` : key;
    if (value === undefined || value === null) continue;

    if (typeof File !== 'undefined' && value instanceof File) {
      form.append(formKey, value);
    } else if (typeof Blob !== 'undefined' && value instanceof Blob) {
      const filename = (value && value.name) || 'blob';
      form.append(formKey, value, filename);
    } else if (typeof FileList !== 'undefined' && value instanceof FileList) {
      Array.from(value).forEach((f) => form.append(formKey, f));
    } else if (Array.isArray(value)) {
      value.forEach((item) => {
        if (item === undefined || item === null) return;
        if (typeof item === 'object' && !(item instanceof Date)) {
          objectToFormData(item, form, `${formKey}[]`);
        } else {
          form.append(`${formKey}[]`, String(item));
        }
      });
    } else if (value instanceof Date) {
      form.append(formKey, value.toISOString());
    } else if (typeof value === 'object') {
      objectToFormData(value, form, formKey);
    } else {
      form.append(formKey, String(value));
    }
  }
  return form;
}

export default async function fastapiClient(operation, url, params = {}, success_callback = () => {}, failure_callback = () => {}) {
  const method = (operation || 'GET').toUpperCase();
  const errorTag = getErrorTag();

  if (!SERVER_URL) {
    SERVER_URL = (typeof window !== 'undefined' && window.SERVER_URL) ? window.SERVER_URL.replace(/\/+$/, '') : '';
  }

  // Split query/body with backward compatibility:
  // - If params is FormData => body = params
  // - If params has shape { query, body, useFormData } => use that
  // - Else: GET/DELETE -> params as query, others -> params as body
  let queryObj = {};
  let bodyCandidate = null;
  let forceForm = false;

  if (typeof FormData !== 'undefined' && params instanceof FormData) {
    bodyCandidate = params;
  } else if (params && (Object.prototype.hasOwnProperty.call(params, 'body') || Object.prototype.hasOwnProperty.call(params, 'query'))) {
    queryObj = params.query || {};
    bodyCandidate = Object.prototype.hasOwnProperty.call(params, 'body') ? params.body : null;
    forceForm = !!params.useFormData;
  } else {
    if (method === 'GET' || method === 'DELETE') {
      queryObj = params || {};
    } else {
      bodyCandidate = params || {};
    }
  }

  // Build URL with query
  const base = (SERVER_URL || '') + url.replace(/\/+$/, '');
  const qs = buildQuery(queryObj);
  const fullUrl = base + qs;

  // Decide body + headers for JSON vs FormData
  let body = null;
  let isForm = false;

  if (bodyCandidate != null && method !== 'GET' && method !== 'DELETE') {
    if (typeof FormData !== 'undefined' && bodyCandidate instanceof FormData) {
      body = bodyCandidate;
      isForm = true;
    } else if (forceForm || hasFileValue(bodyCandidate)) {
      body = objectToFormData(bodyCandidate);
      isForm = true;
    } else {
      body = JSON.stringify(bodyCandidate);
      isForm = false;
    }
  }

  const headers = {};

  // Only set Content-Type for JSON; for FormData let browser set multipart boundary
  if (!isForm) {
    headers['Content-Type'] = 'application/json';
  }

  // Attach tokens if available
  // const accessToken = getAccessToken();
  // if (accessToken) {
  //   headers['Authorization'] = `Bearer ${accessToken}`;
  // }
  try {
    const csrf_token = await ensureCsrfToken();
    if (csrf_token) {
      headers['X-CSRF-Token'] = csrf_token;
    }
  } catch (e) {
    // non-fatal
  }

  const fetchOptions = {
    method,
    headers,
    credentials: 'include',
  };

  if (body != null) {
    fetchOptions.body = body;
  }

  try {
    const res = await fetch(fullUrl, fetchOptions);
    if (!res.ok) await throwForBadResponse(res);

    const contentType = res.headers.get('content-type') || '';
    let payload = null;
    if (contentType.includes('application/json')) {
      payload = await res.json();
    } else {
      const text = await res.text();
      try {
        payload = JSON.parse(text);
      } catch {
        payload = { detail: text };
      }
    }

    if (res.ok) {
      success_callback(payload, res);
      return payload;
    }

    if (errorTag) {
      errorTag.innerText = (payload && (payload.detail || payload.message)) || `Error ${res.status}: ${res.statusText}`;
    }
    failure_callback(payload, res);
    return Promise.reject(payload);
  } catch (err) {
    if (errorTag) {
      errorTag.innerText = err && err.message ? err.message : 'Network error';
    }
    failure_callback(err);
    throw err;
  }
}

// 공통 로그인 처리 함수
export async function loginAndRedirect(loginParams, {
    userIdValue,
    // redirectTo가 있으면 그대로 사용, 없으면 redirectBase + userIdValue
    redirectTo,
    redirectBase = '/views/accounts/account/',
    errorTag,
    fallbackMessage = '로그인에 실패했습니다.',
    onError, // 필요 시 커스텀 에러 핸들러 (msg, err) => void
} = {}) {
    // 내부 헬퍼: 에러 메시지 표시
    const showError = (msg, err) => {
        if (errorTag) {
            errorTag.style.display = 'block';
            errorTag.innerText = msg;
        }
        if (typeof onError === 'function') {
            try { onError(msg, err); } catch (_) {}
        }
    };

    try {
        const res = await fastapiClient('post', '/apis/accounts/login', loginParams);

        if (res && res.access_token) {
            const hasUserId =
                userIdValue !== undefined &&
                userIdValue !== null &&
                String(userIdValue).trim() !== '';

            window.location.href = redirectTo ?? (hasUserId ? (redirectBase + userIdValue) : '/');
             /* - redirectTo가 설정되어 있으면 그 값으로 이동합니다.
                - redirectTo가 없고 userIdValue가 비어 있으면 '/'로 이동합니다.
                - redirectTo가 없고 userIdValue가 값이 있으면 redirectBase + userIdValue로 이동합니다.
                */
            return {ok: true, data: res};
        }

        const msg = extractErrorMessage(res) || fallbackMessage;
        showError(msg, res);
        return { ok: false, error: res, message: msg };
    } catch (e) {
        const msg = extractErrorMessage(e) || fallbackMessage;
        showError(msg, e);
        return { ok: false, error: e, message: msg };
    }
}

 

# Project_folder/app/static/statics/js/main.js

import fastapiClient from './fastapiClient.js';

// set server url if template injected window.SERVER_URL else default
if (typeof window !== 'undefined') {
  if (!window.SERVER_URL || window.SERVER_URL === '') {
    // default to origin
    window.SERVER_URL = window.location.origin;
  }
}

// optional: pre-fetch CSRF token for pages (useful for forms)
(async function prefetchCsrf() {
  try {
    // call the client to make sure CSRF cookie/token exists
    await fastapiClient('get', '/apis/auth/csrf_token', {});
  } catch (e) {
    // ignore
    // console.warn('csrf prefetch failed', e);
  }
})();

 

# Project_folder/app/static/statics/js/custom/accounts/authCodeFastAPI.js

import fastapiClient, {extractErrorMessage, gerUserIDTag, getErrorTag, loginAndRedirect, getParam} from '../../fastapiClient.js';

// 값이 undefined/null/'' 인 키 제거
const compact = (obj) =>
    Object.fromEntries(
        Object.entries(obj).filter(([, v]) => v !== undefined && v !== null && v !== "")
    );

const errorTag = getErrorTag();
const userIDTag = gerUserIDTag();

export const authCodeRequest = async (event, {params, setError}) => {
    event.preventDefault();
    const requestFormElement = document.getElementById("authRequestForm");
    const verifyFormElement = document.getElementById("authVerifyForm");
    const oldEmailInput = document.getElementById("old-email");

    const url = '/apis/accounts/authcode/request';
    const requestEmailInput = document.getElementById("request-email");

    try {
        const data = await fastapiClient('post', url, params);
        alert(data.message);
        if (!oldEmailInput) {
            requestFormElement.style.display = "none";
            verifyFormElement.style.display = "block";
        } else {
            if (requestEmailInput) {
                requestEmailInput.readOnly = true;
                Object.assign(requestEmailInput.style, {
                    backgroundColor: "#f8f8f8",
                    color: "#999",
                    borderColor: "#e5e5e5",
                });
            }
        }
    } catch (err) {
        console.log("실패", err);
        if (setError) {
            const msg = extractErrorMessage(err);
            if (errorTag) {
                errorTag.style.display = 'block';
                errorTag.innerText = msg;
                console.error("1. catch auth request error", err);
            }
        }
        throw err; // 필요하면 에러 다시 던지기
        // **에러를 다시 던지더라도, 그 전에 에 원하는 내용을 넣어 두면
        // 화면에는 그대로 노출`errorTag.innerText`**됩니다.
    }
};

export const authCodeVerify = async (event, {params, setError, setToken}) => {
    event.preventDefault();

    const verifyFormElement = document.getElementById("authVerifyForm");
    const accountFormElement = document.getElementById("accountForm");

    const requestEmailInput = document.getElementById("request-email");
    const verifiedTokenInput = document.getElementById('verified-token');

    const url = '/apis/accounts/authcode/verify';
    // 선택 파라미터는 compact로 제거
    // const params = compact({email, authcode, type, old_email, password});

    try {
        const data = await fastapiClient('post', url, params);
        alert(data.message);
        if (typeof setToken === "function" && data && data.verified_token) {
            setToken(data["verified_token"]); // setToken(data.verified_token); 같은 의미
        }
        if (requestEmailInput) {
            requestEmailInput.readOnly = true;
            Object.assign(requestEmailInput.style, {
                backgroundColor: "#f8f8f8",
                color: "#999",
                borderColor: "#e5e5e5",
            });
        }

        if (["register", "lost"].includes(params?.type)) {
            if (verifiedTokenInput) verifiedTokenInput.value = data["verified_token"];
            if (verifyFormElement) verifyFormElement.style.display = "none";
            if (accountFormElement) accountFormElement.style.display = "block";
        } else {
            const loginParams = {email: params.email, password: params.password};
            await loginAndRedirect(loginParams, {
                userIdValue: userIDTag.value,
                errorTag, // 에러 메시지를 표시할 엘리먼트
                // redirectTo: '/원하면/완전한/경로',  // 필요 시 전체 경로 직접 지정
                // redirectBase: '/views/accounts/account/', // 기본값 유지 시 생략
                // onError: (msg, err) => { /* 필요 시 추가 로깅/처리 */ },
            });

            // try {
            //     const res = await fastapiClient('post', '/apis/accounts/login', loginParams);
            //     if (res && res.access_token) {
            //         window.location.href = '/views/accounts/account/' + userIDTag.value;
            //     } else {
            //         errorTag.style.display = 'block';
            //         errorTag.innerText = extractErrorMessage(res) || "로그인에 실패했습니다.";
            //     }
            // } catch (e) {
            //     const msg = extractErrorMessage(e) || "로그인에 실패했습니다.";
            //     errorTag.style.display = 'block';
            //     errorTag.innerText = msg;
            //     console.error("catch login error", e);
            // }
        }


    } catch (err) {
        console.log("실패", err);
        if (setError) {
            const msg = extractErrorMessage(err);
            if (errorTag) {
                errorTag.style.display = 'block';
                errorTag.innerText = msg;
                console.error("1. catch auth verify error", err);
            }
        }
        throw err; // 필요하면 에러 다시 던지기
    }

};

export const accountRegister = async (event, {params, setError}) => {
    event.preventDefault();
    const url = '/apis/accounts/register';

    try {
        const data = await fastapiClient('post', url, params);
        alert("회원가입 완료: 이메일과 비밀번호로 로그인됩니다.");
        const email = getParam(params, 'email');
        const password = getParam(params, 'password');

        const loginParams = {email: email, password: password};
        await loginAndRedirect(loginParams, {
            userIdValue: null, // 홈으로 ...
            // userIdValue: data.id, // 회원 상세페이지로 ...
            errorTag, // 에러 메시지를 표시할 엘리먼트
            // redirectTo: '/원하면/완전한/경로',  // 필요 시 전체 경로 직접 지정
            // redirectBase: '/views/accounts/account/', // 기본값 유지 시 생략
            // onError: (msg, err) => { /* 필요 시 추가 로깅/처리 */ },
        });
    } catch (err) {
        console.log("register 실패", err);           // 실패 시 error/response 로그
        if (setError) {
            const msg = extractErrorMessage(err) || '회원 등록에 실패했습니다.';
            if (errorTag) {
                errorTag.style.display = 'block';
                errorTag.innerText = msg;
                console.error("1. catch register error", err);
            }
        }
        throw err; // 필요하면 에러 다시 던지기
    }
};

export const lostPasswordReset = async (event, {params, setError}) => {
    event.preventDefault();
    const url = '/apis/accounts/lost/password/resetting';

    try {
        const data = await fastapiClient('patch', url, params);
        console.log("lost password reset 성공", data);          // 성공 시 response data 로그
        console.log("params: ", params)
        alert("비밀번호 설정 성공: 재설정된 비밀번호로 로그인됩니다.");

        const loginParams = {email: params.email, password: params.newpassword};
        await loginAndRedirect(loginParams, {
            // userIdValue: null, // 홈으로 ...
            userIdValue: data.id, // 회원 상세페이지로...
            errorTag, // 에러 메시지를 표시할 엘리먼트
            // redirectTo: '/원하면/완전한/경로',  // 필요 시 전체 경로 직접 지정
            // redirectBase: '/views/accounts/account/', // 기본값 유지 시 생략
            // onError: (msg, err) => { /* 필요 시 추가 로깅/처리 */ },
        });
    } catch (err) {
        console.log("lost password reset 실패", err);           // 실패 시 error/response 로그
        if (setError) {
            const msg = extractErrorMessage(err);
            if (errorTag) {
                errorTag.style.display = 'block';
                errorTag.innerText = msg;
                console.error("1. catch lost password reset error", err);
            }
        }
        throw err; // 필요하면 에러 다시 던지기
    }
};

 

# Project_folder/app/static/statics/js/custom/accounts/authCodeRequired.js

import {getErrorTag, extractErrorMessage} from '../../fastapiClient.js';
import {authCodeRequest, authCodeVerify, accountRegister, lostPasswordReset} from './authCodeFastAPI.js';

document.addEventListener('DOMContentLoaded', () => {
    const authRequestForm = document.getElementById('authRequestForm');
    if (!authRequestForm) return;
    const authVerifyForm = document.getElementById('authVerifyForm');
    if (!authVerifyForm) return;

    const requestEmailInput = document.getElementById('request-email');
    const verifyEmailInput = document.getElementById('verify-email');
    const registerEmailInput = document.getElementById('register-email');
    const errorTag = getErrorTag();
    let error = null;

    requestEmailInput.addEventListener('input', function () {
        // 한 문자씩 타이핑 할 때마다 hidden input 태그에 복사
        verifyEmailInput.value = this.value;
        if (registerEmailInput) registerEmailInput.value = this.value;
    });

    authRequestForm.addEventListener('submit', async (ev) => {
        ev.preventDefault();
        const params = {
            email: authRequestForm.elements['email'].value,
            type: authRequestForm.elements['type'].value
        };
        try {
            await authCodeRequest(ev, {
                params, setError: (v) => (error = v)
            });
        } catch (e) {
            const msg = extractErrorMessage(e) || '인증코드 요청 중 오류가 발생했습니다.';

            if (errorTag) {
                errorTag.style.display = 'block';
                errorTag.innerText = msg;
            }
            console.error("2. catch authcode request error", e);
        }
    });

    authVerifyForm.addEventListener('submit', async (ev) => {
        ev.preventDefault();
        const params = { // 단순 인증하기와 인증과 동시에 이메일 변경
            authcode: authVerifyForm.elements['authcode'].value,
            email: authVerifyForm.elements['email'].value,
            type: authVerifyForm.elements['type'].value,
            old_email: authVerifyForm.elements['old_email'].value,
            password: authVerifyForm.elements['password'].value
        };
        let token = null;
        try {
            await authCodeVerify(ev, {
                params, setError: (v) => (error = v),
                setToken: (v) => (token = v)
            });
        } catch (e) {
            const msg = extractErrorMessage(e) || '인증코드 검증 중에 오류가 발생했습니다.';
            if (errorTag) {
                errorTag.style.display = 'block';
                errorTag.innerText = msg;
            }
            console.error("catch authcode verify error", e);
        }
    });

    const accountForm = document.getElementById('accountForm');
    // if (!accountForm) return; 이메일 변경 html에는 이 accountForm이 없어 조기 종료되는 것을 막을 수 있다.
    if (accountForm) {
        accountForm.addEventListener('submit', async (ev) => {
            ev.preventDefault();
            const type = accountForm.elements['type'].value;
            try {
                if (type === "register") {
                    try {
                        // 파일 업로드 여부에 따라 FormData/JSON 분기
                        const fileInput = accountForm.elements['imagefile'];
                        const hasFile = fileInput && fileInput.files && fileInput.files.length > 0;
                        const fd = new FormData();
                        fd.append('username', accountForm.elements['username']?.value || '');
                        fd.append('email', accountForm.elements['email']?.value || '');
                        fd.append('token', accountForm.elements['token']?.value || '');
                        fd.append('password', accountForm.elements['password']?.value || '');
                        fd.append('password2', accountForm.elements['password2']?.value || '');
                        if (hasFile && fileInput.files[0] && fileInput.files[0].name) {
                            fd.append('imagefile', fileInput.files[0]);
                        }
                        let params = fd; // fastapiClient가 FormData면 body로 전송


                        await accountRegister(ev, {
                            params, setError: (v) => (error = v)
                        });

                    } catch (e) {
                        const msg = extractErrorMessage(e) || '인증코드 검증 중에 오류가 발생했습니다.';

                        if (errorTag) {
                            errorTag.style.display = 'block';
                            errorTag.innerText = msg;
                        }
                    }

                } else { // 비번 분실
                    try {
                        const params = {
                            email: accountForm.elements['email'].value,
                            token: accountForm.elements['token'].value,
                            newpassword: accountForm.elements['newpassword'].value,
                            confirmPassword: accountForm.elements['confirmPassword'].value
                        };
                        await lostPasswordReset(ev, {
                            params, setError: (v) => (error = v)
                        });

                    } catch (e) {
                        const msg = extractErrorMessage(e) || '인증코드 검증 중에 오류가 발생했습니다.';

                        if (errorTag) {
                            errorTag.style.display = 'block';
                            errorTag.innerText = msg;
                        }
                    }
                }

            } catch (e) {
                console.error("catch register error", e);
            }
        });
    }

});

 

# Project_folder/app/static/statics/js/custom/accounts/login.js

import fastapiClient, {getErrorTag, extractErrorMessage} from '../../fastapiClient.js';

document.addEventListener('DOMContentLoaded', () => {
  const form = document.getElementById('accountForm');
  if (!form) return;
  form.addEventListener('submit', async (ev) => {
    ev.preventDefault();
    const params = {
      email: form.elements['email'].value,
      password: form.elements['password'].value
    };
    const errorTag = getErrorTag();
    try {
      const res = await fastapiClient('post', '/apis/accounts/login', params);
      console.log(res);
      if (res && res.access_token) {
        window.location.href = '/';
      } else {
          errorTag.style.display = 'block';
          errorTag.innerText = extractErrorMessage(res) || "로그인에 실패했습니다.";
      }
    } catch (e) {
        const msg =extractErrorMessage(e);
        errorTag.style.display = 'block';
        errorTag.innerText = msg;
      console.error("catch login error", e);
    }
  });
});

 

# Project_folder/app/static/statics/js/custom/accounts/logout.js

import fastapiClient, {getErrorTag} from '../../fastapiClient.js';

document.addEventListener('DOMContentLoaded', () => {
  const logoutBtn = document.getElementById('logoutBtn');
  if (!logoutBtn) return;

  logoutBtn.addEventListener('click', async (ev) => {
    ev.preventDefault();
    const ok = confirm('정말 로그아웃 하시겠습니까?');
    if (!ok) return;

    try {
      await fastapiClient('post', '/apis/accounts/logout', {});
      window.location.href = '/';
    } catch (err) {
      console.error('logout failed', err);
      const errorTag = getErrorTag();
      errorTag.style.display = 'block';
      errorTag.innerText = '로그아웃 중 오류가 발생했습니다.';
    }
  });
});

 

# Project_folder/app/static/statics/js/custom/accounts/update.js

import {getErrorTag, extractErrorMessage} from '../../fastapiClient.js';
import {accountUpdate, passwordUpdate} from './updateFastAPI.js';

document.addEventListener('DOMContentLoaded', () => {
    const errorTag = getErrorTag();
    let error = null;

    const accountForm = document.getElementById('accountForm');

    accountForm.addEventListener('submit', async (ev) => {
        ev.preventDefault();
        const type = accountForm.elements['type'].value;
        const fd = new FormData(accountForm);
        try {
            if (type === "user") { //username or profile image
                try {
                    // 파일 업로드 여부에 따라 FormData/JSON 분기
                    const fileInput = accountForm.elements['imagefile'];
                    const hasFile = fileInput && fileInput.files && fileInput.files.length > 0;

                    if (hasFile && fileInput.files[0] && fileInput.files[0].name) {
                        fd.append('imagefile', fileInput.files[0]);
                    }

                    let params = fd; // fastapiClient가 FormData면 body로 전송
                    await accountUpdate(ev, {
                        params, setError: (v) => (error = v)
                    });

                } catch (e) {
                    const msg = extractErrorMessage(e) || '인증코드 검증 중에 오류가 발생했습니다.';

                    if (errorTag) {
                        errorTag.style.display = 'block';
                        errorTag.innerText = msg;
                    }
                }
            } else { // password
                try {
                    const params = {
                        user_id: accountForm.elements['user_id'].value,
                        password: accountForm.elements['password'].value,
                        newpassword: accountForm.elements['newpassword'].value,
                        confirmPassword: accountForm.elements['confirmPassword'].value
                    };
                    await passwordUpdate(ev, {
                        params, setError: (v) => (error = v)
                    });

                } catch (e) {
                    const msg = extractErrorMessage(e) || '인증코드 검증 중에 오류가 발생했습니다.';

                    if (errorTag) {
                        errorTag.style.display = 'block';
                        errorTag.innerText = msg;
                    }
                }
            }

        } catch (e) {
            console.error("catch register error", e);
        }
    });


});

 

# Project_folder/app/static/statics/js/custom/accounts/updateFastAPI.js

import fastapiClient, {extractErrorMessage, getErrorTag, gerUserIDTag, loginAndRedirect} from "../../fastapiClient.js";

const errorTag = getErrorTag();
const userIDTag = gerUserIDTag();

export const accountUpdate = async (event, {params, setError}) => {
    event.preventDefault();
    const url = '/apis/accounts/account/update/' + userIDTag.value;

    try {
        const data = await fastapiClient('patch', url, params);
        window.location.href = '/views/accounts/account/' + userIDTag.value;
    } catch (err) {
        if (setError) {
            const msg = extractErrorMessage(err) || '회원 등록에 실패했습니다.';
            if (errorTag) {
                errorTag.style.display = 'block';
                errorTag.innerText = msg;
                console.error("1. catch update error", err);
            }
        }
        throw err; // 필요하면 에러 다시 던지기
    }
};

export const passwordUpdate = async (event, {params, setError}) => {
    event.preventDefault();
    const url = '/apis/accounts/account/password/update/' + userIDTag.value;

    try {
        const data = await fastapiClient('patch', url, params);
        const loginParams = {email: data.email, password: params.newpassword};
        alert("비밀번호 변경 완료: 새로운 비밀번호로 로그인됩니다.");
        await loginAndRedirect(loginParams, {
                userIdValue: userIDTag.value,
                errorTag, // 에러 메시지를 표시할 엘리먼트
                // redirectTo: '/원하면/완전한/경로',  // 필요 시 전체 경로 직접 지정
                // redirectBase: '/views/accounts/account/', // 기본값 유지 시 생략
                // onError: (msg, err) => { /* 필요 시 추가 로깅/처리 */ },
            });
        // try {
        //     const res = await fastapiClient('post', '/apis/accounts/login', loginParams);
        //     if (res && res.access_token) {
        //          window.location.href = '/views/accounts/account/' + userIDTag.value;
        //     } else {
        //         errorTag.style.display = 'block';
        //         errorTag.innerText = extractErrorMessage(res) || "로그인에 실패했습니다.";
        //     }
        // } catch (e) {
        //     const msg = extractErrorMessage(e) || "로그인에 실패했습니다.";
        //     errorTag.style.display = 'block';
        //     errorTag.innerText = msg;
        //     console.error("catch login error", e);
        // }


    } catch (err) {
        if (setError) {
            const msg = extractErrorMessage(err) || '회원 등록에 실패했습니다.';
            if (errorTag) {
                errorTag.style.display = 'block';
                errorTag.innerText = msg;
                console.error("1. catch passwordUpdate error", err);
            }
        }
        throw err; // 필요하면 에러 다시 던지기
    }
};

 

# Project_folder/app/static/statics/js/custom/accounts/withdraw.js

import fastapiClient, {getErrorTag, extractErrorMessage} from '../../fastapiClient.js';

document.addEventListener('DOMContentLoaded', () => {
    const withDrawBtn = document.getElementById("withDrawBtn");
    if (!withDrawBtn) return;
    withDrawBtn.addEventListener('click', async (ev) => {
        ev.preventDefault();
        const confirmed = window.confirm("정말로 회원을 탈퇴하시겠습니까?\n관련 모든 데이터가 삭제되고, 작업 후 되돌릴 수 없습니다.");
        if (!confirmed) {
            return;
        }
        const userIdValue = document.getElementById("user_id")?.value;
        const errorTag = getErrorTag();
        try {
            const data = await fastapiClient('delete', '/apis/accounts/account/delete/'+userIdValue, {});
            console.log(data);
            alert(data?.detail || "회원 탈퇴가 완료되었습니다.");
            window.location.href = `/`;
        } catch (e) {
            const msg =extractErrorMessage(e) || "회원 탈퇴 요청이 실패했습니다.";
            errorTag.style.display = 'block';
            errorTag.innerText = msg;
            console.error("catch withdraw error", e);
        }
    });
});

 

# Project_folder/app/templates/layout.html

<!DOCTYPE html>
<html lang="kr">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, height=device-height, initial-scale=1, user-scalable=no, maximum-scale=1"/>
    <meta name="csrf-token" content="{{ csrf_token }}">

    {% block meta_keywords %}
    {% endblock %}

    {% block meta_description %}
    {% endblock %}

    {% block meta_robots %}
    {% endblock %}

    {% block meta_others %}
    {% endblock %}

    <title>AdvanceDOG | {% block title %}{% endblock %}</title>

    {% block main_css %}
    {% endblock %}

    {% block sub_css %}
    {% endblock %}

    {% block main_js %}
    {% endblock %}

    {% block sub_js %}
    {% endblock %}
</head>

<body>
    <header>
        {% block header %}
        {% endblock %}
    </header>

    <main class="main-with">
        {% block above_content %}
        {% endblock %}

        <section class="section-container flex">
            {% block main_right %}
            {% endblock %}

            {% block main %}
            {% endblock %}

            {% block main_left %}
            {% endblock %}
        </section>

        {% block below_content %}
        {% endblock %}
    </main>

    <footer class="main-with">
        {% block footer %}
        {% endblock %}
    </footer>

    {% block end_common_js %}
    {% endblock %}

    {% block end_js %}
    {% endblock %}

</body>

</html>
# Project_folder/app/templates/base.html

{% extends "layout.html" %}

{% block main_css %}
    <link rel="stylesheet" href="{{ STATIC_URL }}/statics/css/custom/reset.css">
    <link rel="stylesheet" href="{{ STATIC_URL }}/statics/css/custom/main.css">
    <link rel="stylesheet" href="{{ STATIC_URL }}/uikit/css/uikit.min.css">
{% endblock %}

{% block main_js %}
    <script src="{{ STATIC_URL }}/uikit/js/uikit.min.js"></script>
    <script src="{{ STATIC_URL }}/uikit/js/uikit-icons.min.js"></script>
{% endblock %}


{% block header %}
    {% include 'includes/header.html' %}
{% endblock %}

{% block main_right %}
    {% include 'includes/left.html' %}
{% endblock %}

{% block main_left %}
    {% include 'includes/right.html' %}
{% endblock %}

{% block footer %}
    {% include 'includes/footer.html' %}
{% endblock %}

{% block end_common_js %}
    <script type="module" src="/static/statics/js/main.js"></script>
    {% if current_user %}
    <script type="module" src="{{ STATIC_URL }}/statics/js/custom/accounts/logout.js"></script>
    {% endif %}
{% endblock %}

 

# Project_folder/app/templates/includes/footer.html


<div class="footer">
    <hr>
    <div class="contents">
        © FASTAPI DEV corp. 2025.8.24
    </div>
</div>

 

# Project_folder/app/templates/includes/header.html

<hr class="main-with header-hr1">
<hr class="main-with header-hr2">
<div class="header-container">
    <div class="header-image background">
        <a href="/"><img src="{{ MEDIA_URL }}/default/dog88.png" alt="Header"></a></div>
    <div class="menu-container">


        <div class="logo-container">
            <div class="right"></div>
{#            <div class="logo"><a href="/">DogCoding</a></div>#}
{#            <div class="logo"><a href="/">DogField</a></div>#}
{#            <div class="logo"><a href="/">DogBasics</a></div>#}
            <div class="logo"><a href="/">AdvanceDOG</a></div>
            <div class="left"></div>
        </div>
    </div>
</div>
<hr class="main-with header-hr3">
<hr class="main-with header-hr4">
<div class="main-with menu">
    <div class="item">
        <div class="menu-item"><a href="/articles">게시글</a></div>
        {% if current_user %}
            <div class="menu-item"><a href="/articles/article/create">글쓰기</a></div>
            <div class="menu-item"><a href="#" id="logoutBtn">로그아웃</a></div>
        {% else %}
            <div class="menu-item"><a href="/views/accounts/register">가입</a></div>
            <div class="menu-item"><a href="/views/accounts/login">로그인</a></div>

        {% endif %}
    </div>
</div>
<hr class="main-with header-hr5">
<hr class="main-with header-hr6">

 

# Project_folder/app/templates/includes/left.html

<article class="flex-item lt-container">
    <div class="side-item">
        <div class="inner">
            <div><a href="https://vclock.kr/time/" target="_blank">현재 시간</a></div>

            <div>한국 시간: {{ now_time }}</div>
            <div style="overflow-wrap: break-word">UTC 시간: {{ now_time_utc }}</div>
        </div>
    </div>
    <div class="side-item">
        <div class="inner">
            <div><strong>참고사이트</strong></div>
            <hr>

            <div class="menu-item"><a href="https://inf.run/zy8BF" target="_blank">FastAPI 완벽 가이드</a></div>
            <div class="menu-item"><a href="https://inf.run/PRgqv" target="_blank">
                FastAPI 실전편: JWT와 Redis로 완성하는 인증 시스템</a></div>
            <div><a href="https://inf.run/xZHr7" target="_blank">작정하고 장고! 배포(도커)까지</a></div>
            <div class="menu-item"><a href="https://inf.run/gk8qb" target="_blank">도커와 최신 서버 기술</a></div>
            <hr>

            <div class="menu-item"><a href="https://studiomeal.com/archives/197" target="_blank">CSS Flex를 익혀보자!</a></div>
            <div class="menu-item"><a href="https://www.freecodecamp.org/korean/news/javascripteseo-gajang-jal-alryeojin-http-yoceong-bangbeob-2/" target="_blank">
                JavaScript의 HTTP요청 방법</a></div>
            <div><a href="https://mangkyu.tistory.com/146" target="_blank">HTTP 상태 401 vs 403 차이</a></div>
            <hr>

            <div class="menu-item"><a href="https://icon-icons.com/ko/" target="_blank">무료 아이콘 추천</a></div>
            <div><a href="https://flatuicolors.com/" target="_blank">RGB COLOR</a></div>
            <div><a href="https://imagecolorpicker.com/" target="_blank">색상 뽑아내기</a></div>
            <div><a href="http://www.hipenpal.com/tool/html-color-charts-rgb-color-table-in-korean.php" target="_blank"> RGB색상코드표팔레트</a></div>
            <hr>

            <div><a href="https://jindevelopetravel0919.tistory.com/391" target="_blank">Docker 환경 Redis 설치</a></div>
            <div>
                <a href="https://engineeringcode.tistory.com/entry/%EC%9C%88%EB%8F%84%EC%9A%B0-11-%EB%A0%88%EB%94%94%EC%8A%A4Redis%EB%A5%BC-%EB%8F%84%EC%BB%A4%EB%A1%9C-%EC%84%A4%EC%B9%98%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95"
                   target="_blank">[윈도우 11] Redis를 도커로 설치</a></div>
            <hr>

            <div class="menu-item"><a href="https://developer.mozilla.org/ko/docs/Web/CSS/Layout_cookbook/Center_an_element" target="_blank">MDN</a></div>
            <div><a href="https://fastapi.tiangolo.com/advanced/templates/" target="_blank">FASTapi Jinja2Templates</a></div>
            <div><a href="https://www.starlette.io/templates/" target="_blank">Starlette Templates</a></div>
            <div><a href="https://www.fastapitutorial.com/blog/fastapi-hello-world/" target="_blank">FASTapi 공식 튜토리얼</a></div>
            <div><a href="https://coding-shop.tistory.com/403" target="_blank">Pydantic Field 데이터 검증</a></div>
            <div><a href="https://chaechae.life/blog/fastapi-permissions" target="_blank">FastAPI permissions 구현하기</a></div>
            <div><a href="https://dev.to/fastapitutorial/serving-html-with-fastapi-2b0p" target="_blank">Serving HTML with FastAPI</a></div>
            <div><a href="https://wikidocs.net/175875#svelte" target="_blank">점프 투 FastAPI: Svelte로 웹 개발</a></div>
            <div>
                <a href="https://mattpy.tistory.com/entry/FastAPIPython%EA%B3%BC-Svelte%EB%A1%9C-%EB%AC%B4%EC%9E%91%EC%A0%95-%EC%9B%B9-%EA%B0%9C%EB%B0%9C%ED%95%98%EA%B8%B0%EA%B8%B0%EC%B4%88-2?category=835392"
                   target="_blank">
                    FastAPI과 Svelte로 웹 개발
                </a></div>

        </div>
    </div>
</article>

 

# Project_folder/app/templates/includes/right.html

<article class="flex-item rt-container">
    <div class="side-item">
        <div class="inner">
            <div><a href="/server">서버 개발 관련</a></div>
            <div><a href="/docker">우분투 도커 셋팅</a></div>

            {% if current_user and admin %}
                <hr>
                <div><a href="/lotto/win/extract">로또 최다 당첨번호 추출하기</a></div>
                <div><a href="/lotto/top10">로또 Top10 예측</a></div>
            {% endif %}
        </div>
    </div>

    <div class="side-item">
        <div class="inner">
            {% if current_user %}
                <div><strong>{{ current_user.username }}님</strong></div>
                <div><a href="/views/accounts/account/{{ current_user.id }}"> 상세정보(탈퇴 포함)</a></div>
                <hr>

                <div><a href="/views/accounts/username/update/{{ current_user.id }}">닉네임 변경</a></div>
                <div><a href="/views/accounts/email/update/{{ current_user.id }}">이메일 변경</a></div>
                <div><a href="/views/accounts/profile/image/update/{{ current_user.id }}">프로필 이미지 변경</a></div>
                <div><a href="/views/accounts/password/update/{{ current_user.id }}">비밀번호 변경</a></div>

            {% else %}
                <div><a href="/views/accounts/lost/password/reset"> 비밀번호 분실</a></div>
            {% endif %}
        </div>
    </div>

    <div class="side-item">
        <div class="inner">
            <div><a href="/articles/article/1">1번 게시글 상세 페이지</a></div>
            <div><a href="/articles/article/update/1">1번 게시글 수정페이지</a></div>
            <hr>
            <div><a href="/accounts/account/1">1번 회원 상세정보</a></div>
            <div><a href="/accounts/account/update/1">1번 회원정보 수정</a></div>
        </div>
    </div>
</article>

 

# Project_folder/app/templates/common/index.html

{% extends "base.html" %}

{% block title %}
    Index
{% endblock %}

{% block main %}
    <article class="main flex-item contents-container">
        <div class="mt-10">
            <h2><strong>{{ message }}</strong></h2>
            <p>
                <strong>pycharm에서 .jshintrc 파일적용시키는 방법</strong> <br>
                ---. .jshintrc 파일의 위치는 프로젝트 폴더 아래(main.py와 동일한 위치) <br>
                ---. settings > Languages & Frameworks -> JavaScript -> Code Quality Tools -> JSHint ->Enable JSHint와 Use config files 체크박스를 활성화
            </p>
            <p>
                <a href="https://fastapi.tiangolo.com/ko/tutorial/" target="_blank">FastAPI 자습서 - 사용자 안내서</a> <br>

            </p>
        </div>
    </article>
{% endblock %}

 

# Project_folder/app/templates/accounts/detail.html

{% extends "base.html" %}

{% block title %}
    회원 상세정보
{% endblock %}

{% block main %}
    <article class="main flex-item contents-container">
        <div class="mt-10">
            회원 상세정보 : {{ current_user }}
            <hr class="hr-bold">
            <div class="uk-margin" id="errorTag"><!--js에서 넘어온 오류 메시지를 넣는다.--></div>
            <div>id: {{ current_user.id }}</div>
            <div>username: {{ current_user.username }}</div>
            <div>email: {{ current_user.email }}</div>
            <!--div class="menu-item"><a href="apis/accounts/{{ current_user.id }}">회원 탈퇴</a></div-->
            <input type="hidden" id="user_id" name="user_id" value="{{ current_user.id }}">
            <div class="menu-item"><a href="#" id="withDrawBtn">회원 탈퇴</a></div>
            {% if current_user.img_path %}
                <img src="{{ MEDIA_URL }}/{{ current_user.img_path }}" style="width: 100%" alt="Profile Image">
            {% else %}
                <img src="{{ MEDIA_URL }}/default/blog_default.png" style="width: 100%" alt="Profile Image">
            {% endif %}

            <script type="module" src="{{ STATIC_URL }}/statics/js/custom/accounts/withdraw.js"></script>
        </div>
    </article>
    </div>
{% endblock %}

 

# Project_folder/app/templates/accounts/each.html

{% extends "base.html" %}

{% block title %}
    {% if current_user %}
        회원정보 수정
    {% else %}
        회원등록
    {% endif %}
{% endblock %}

{% block sub_css %}
    <link rel="stylesheet" href="{{ STATIC_URL }}/statics/css/custom/form.css">
    <style>
        #authVerifyForm, #accountForm {
        {#display: none#}
        }

    </style>
{% endblock %}

{% block sub_js %}
    <script>
    </script>
{% endblock %}

{% block main %}
    <article class="main flex-item contents-container">
        <div class="mt-10">
            {% if email == current_user.email %}
                <div class="form-container register-container">
                    <!--이메일 변경은 변경하고자하는 이메일로 인증이 완료되면 변경된다.-->
                    <h2>이메일 변경하기</h2>
                    <hr class="hr-bold">
                    <div class="uk-margin">
                        <p>새로운 이메일로 인증코드가 발송됩니다. 인증코드를 확인하고 비밀번호와 함께 입력한 후 변경 완료하기를 클릭하면, 새로운 이메일로 변경이 완료됩니다.</p>
                    </div>
                    <div class="uk-margin">
                        <div>기존 이메일</div>
                        <input class="uk-input" id="old-email" type="text" value="{{ current_user.email }}" disabled>
                        <div><input id="userID" type="hidden" name="user_id" value="{{ current_user.id }}"></div>
                    </div>
                    <div class="uk-margin" id="errorTag"><!--js에서 넘어온 오류 메시지를 넣는다.--></div>

                    <form id="authRequestForm">
                        <div class="uk-margin request-container">
                            <div class="uk-margin request-email">
                                <div>새로운 이메일</div>
                                <input class="uk-input" id="request-type" type="hidden" name="type" value="email">
                                <input class="uk-input" id="request-email" type="email" name="email" required>
                            </div>
                            <div class="auth-create">
                                <button class="auth-create-btn" type="submit">인증코드 발송</button>
                            </div>
                        </div>
                    </form>
                    <form id="authVerifyForm">

                        <div class="uk-margin verify-container">
                            <div class="uk-margin">
                                <input class="uk-input" id="verify-email" type="hidden" name="email">
                            </div>
                            <div class="uk-margin input-container">
                                <div>인증코드</div>
                                <input class="uk-input" id="authcode" type="text" name="authcode" required>
                            </div>
                            <div class="uk-margin">
                                <div>비밀번호</div>
                                <input class="uk-input" type="hidden" name="type" value="email">
                                <input class="uk-input" type="hidden" name="old_email" value="{{ current_user.email }}">
                                <input class="uk-input" id="verify-password" type="password" name="password" required>
                            </div>
                            <div class="btn-container">
                                <button class="auth-btn" type="submit">변경 완료하기</button>
                            </div>
                        </div>
                    </form>
                    <script type="module" src="{{ STATIC_URL }}/statics/js/custom/accounts/authCodeRequired.js"></script>
                </div>
            {% else %}
                <div class="form-container register-container">
                    {% if username == current_user.username %}
                        <h2>닉네임 변경하기</h2>
                        <hr class="hr-bold">
                    {% elif image == "image" %}
                        <h2>프로필 이미지 변경하기</h2>
                        <hr class="hr-bold">
                        {% if current_user.img_path %}
                            <img src="{{ MEDIA_URL }}/{{ current_user.img_path }}" style="width: 100%" alt="Profile Image">
                        {% elif not current_user.img_path %}
                            <img src="{{ MEDIA_URL }}/default/blog_default.png" style="width: 100%" alt="Profile Image">
                        {% endif %}
                    {% elif password == "password" %}
                        <h2>비밀번호 변경하기</h2>
                        <hr class="hr-bold">
                    {% endif %}
                    <div class="uk-margin" id="errorTag"><!--js에서 넘어온 오류 메시지를 넣는다.--></div>

                    <form id="accountForm" enctype="multipart/form-data">
                        <div class="uk-margin">
                        <div><input id="userID" type="hidden" name="user_id" value="{{ current_user.id }}"></div>
                            {% if username == current_user.username %}
                                <input class="uk-input" type="hidden" name="type" value="user">
                                <div class="uk-margin">
                                    <input class="uk-input" id="username" type="text" name="username" value="{{ current_user.username }}">
                                </div>
                                <div class="uk-margin">
                                    <div>비밀번호</div>
                                    <input class="uk-input" id="password" type="password" name="password" required>
                                </div>
                            {% elif image == "image" %}
                                <input class="uk-input" type="hidden" name="type" value="user">
                                <div class="uk-margin">
                                    <label for="imagefile" class="form-label">Profile Image</label>
                                    <input type="file" accept="image/*" class="form-control" id="imagefile" name="imagefile" required/>
                                </div>
                                <div class="uk-margin">
                                    <div>비밀번호</div>
                                    <input class="uk-input" id="password" type="password" name="password" required>
                                </div>
                            {% elif password == "password" %}
                                <input class="uk-input" type="hidden" name="type" value="password">
                                <div class="uk-margin">
                                    <div>기존 비밀번호</div>
                                    <input class="uk-input" id="password" type="password" name="password" required>
                                </div>
                                <div class="uk-margin">
                                    <div>새 비밀번호</div>
                                    <input class="uk-input" id="newpassword" type="password" name="newpassword" required>
                                </div>
                                <div class="uk-margin">
                                    <div>새 비밀번호 확인</div>
                                    <input class="uk-input" id="confirmPassword" type="password" name="confirmPassword" required>
                                </div>

                            {% endif %}
                        </div>

                        <div class="uk-margin">
                            <button class="register-btn" type="submit">변경 완료하기</button>
                        </div>
                    </form>
                </div>
                <script type="module" src="{{ STATIC_URL }}/statics/js/custom/accounts/update.js"></script>
            {% endif %}
    </article>
    </div>
{% endblock %}

 

# Project_folder/app/templates/accounts/login.html

{% extends "base.html" %}

{% block title %}
    로그인
{% endblock %}

{% block sub_css %}
	<link rel="stylesheet" href="{{ STATIC_URL }}/statics/css/custom/form.css">
    <style>
    .add-container {display: flex; justify-content: space-between;}
    </style>
{% endblock %}

{% block main %}
    <article class="main flex-item contents-container">
        <div class="form-container login-container mt-10">
            <h2>로그인</h2>
            <hr class="hr-bold">
            <div class="uk-margin" id="errorTag"><!--js에서 넘어온 오류 메시지를 넣는다.--></div>
            <form id="accountForm">
                <div class="uk-margin">
                    <input class="uk-input"  id="email" type="text" placeholder="email" name="email" required>
                </div>
                <div class="uk-margin"><input class="uk-input" id="password" type="password" placeholder="Password" name="password" required></div>
                <div class="uk-margin"><button type="submit" class="login-btn">로 그 인</button></div>
            </form>
            <div class="add-container">
                <div>
                    <a href="/views/accounts/register">아직 가입을 하지 않으셨나요?</a>
                </div>
                <div>
                    <a href="/views/accounts/lost/password/reset"> 비밀번호를 잊으셨나요?</a>
                </div>
            </div>
            <script type="module" src="{{ STATIC_URL }}/statics/js/custom/accounts/login.js"></script>
        </div>
    </article>
{% endblock %}

 

# Project_folder/app/templates/accounts/lost.html

{% extends "base.html" %}

{% block title %}
    비밀번호 설정
{% endblock %}

{% block sub_css %}
    <link rel="stylesheet" href="{{ STATIC_URL }}/statics/css/custom/form.css">
    <style>
        #authVerifyForm, #accountForm {
            display: none
        }

        .verify-container {
            display: flex;
            justify-content: space-between;
        }

        .verify-container .input-container {
            width: 80%;
        }

        .verify-container .btn-container {
            width: 18%;
            margin-top: 48px;
        }

        .verify-container .btn-container .auth-btn {
            height: 40px;
            font-size: 18px;
        }
    </style>
{% endblock %}

{% block sub_js %}
    <script>
        <!--
        const UserID = {{ (current_user.id if current_user else none) | tojson }};
        const Username = {{ (current_user.username if current_user else '') | tojson }};
        const Email = {{ (current_user.email if current_user else '') | tojson }};

        const RegisterURL = "/apis/accounts/register";
        const UpdateURL = {{ ("/apis/accounts/" + (current_user.id | string) if current_user else none) | tojson }};
        const lostPasswordSetURL = "/apis/accounts/lost/password/setting"
        const LoginURL = "/apis/auth/login";
        -->
    </script>
{% endblock %}

{% block main %}
    <article class="main flex-item contents-container">
        <div class="form-container register-container mt-10">
            <h2>비밀번호 분실/설정</h2>
            <hr class="hr-bold">
            <div class="uk-margin" id="errorTag"><!--js에서 넘어온 오류 메시지를 넣는다.--></div>

            <form id="authRequestForm">
                <div class="uk-margin request-container">
                    <div class="uk-margin request-email">
                        <div>이메일</div>
                        <input class="uk-input" type="hidden" name="type" value="lost">
                        <input class="uk-input" id="request-email" type="email" name="email" required>
                    </div>
                    <div class="auth-create">
                        <button class="auth-create-btn" type="submit">인증코드 발송</button>
                    </div>
                </div>
            </form>
            <form id="authVerifyForm">
                <div class="uk-margin verify-container">
                    <div class="uk-margin">
                        <input class="uk-input" type="hidden" name="type" value="lost">
                        <input class="uk-input" type="hidden" name="old_email" value="">
                        <input class="uk-input" type="hidden" name="password" value="">
                        <input class="uk-input" id="verify-email" type="hidden" name="email">
                    </div>
                    <div class="uk-margin input-container">
                        <div>인증코드</div>
                        <input class="uk-input" id="authcode" type="text" name="authcode" required>
                    </div>
                    <div class="btn-container">
                        <button class="auth-btn" type="submit">인증하기</button>
                    </div>
                </div>
            </form>

            <form id="accountForm">
                <div class="uk-margin">
                    <div class="uk-margin">
                        <input class="uk-input" type="hidden" name="type" value="lost">
                        <input class="uk-input" id="register-email" type="hidden" name="email">
                        <input class="uk-input" id="verified-token" type="hidden" name="token">
                    </div>
                    <div class="uk-margin">
                        <div>새 비밀번호</div>
                        <input class="uk-input" id="newpassword" type="password" name="newpassword" required>
                    </div>
                    <div class="uk-margin">
                        <div>새 비밀번호 확인</div>
                        <input class="uk-input" id="confirmPassword" type="password" name="confirmPassword" required>
                    </div>

                </div>

                <div class="uk-margin">
                    <button class="register-btn" type="submit">정보 변경</button>
                </div>
            </form>
        </div>
        <script type="module" src="{{ STATIC_URL }}/statics/js/custom/accounts/authCodeRequired.js"></script>
    </article>
{% endblock %}

 

# Project_folder/app/templates/accounts/register.html

{% extends "base.html" %}

{% block title %}
    회원등록
{% endblock %}

{% block sub_css %}
    <link rel="stylesheet" href="{{ STATIC_URL }}/statics/css/custom/form.css">
    <style>
        #authVerifyForm, #accountForm {
            display: none
        }

        .verify-container {
            display: flex;
            justify-content: space-between;
        }

        .verify-container .input-container {
            width: 80%;
        }

        .verify-container .btn-container {
            width: 18%;
            margin-top: 48px;
        }

        .verify-container .btn-container .auth-btn {
            height: 40px;
            font-size: 18px;
        }
        #register-email {
            background-color: #f8f8f8;
            color: #999;
            border-color: #e5e5e5;
        }
    </style>
{% endblock %}

{% block sub_js %}
    <script>
    {#<!--#}
    {#    const UserID = {{ (current_user.id if current_user else none) | tojson }};#}
    {#    const Username = {{ (current_user.username if current_user else '') | tojson }};#}
    {#    const Email = {{ (current_user.email if current_user else '') | tojson }};#}
    {##}
    {#    const RegisterURL = "/apis/accounts/register";#}
    {#    const UpdateURL = {{ ("/apis/accounts/" + (current_user.id | string) if current_user else none) | tojson }};#}
    {#    const LoginURL = "/apis/auth/login"; -->#}
    </script>
{% endblock %}

{% block main %}
    <article class="main flex-item contents-container">
        <div class="form-container register-container mt-10">
            <h2>회원 등록</h2>
            <hr class="hr-bold">
            <div class="uk-margin" id="errorTag"><!--js에서 넘어온 오류 메시지를 넣는다.--></div>

            <form id="authRequestForm">
                <div class="uk-margin request-container">
                    <div class="uk-margin request-email">
                        <div>이메일</div>
                        <input class="uk-input" id="request-type" type="hidden" name="type" value="register">
                        <input class="uk-input" id="request-email" type="email" name="email" required>
                    </div>
                    <div class="auth-create">
                        <button class="auth-create-btn" type="submit">인증코드 발송</button>
                    </div>
                </div>
            </form>

            <form id="authVerifyForm">
                <div class="uk-margin verify-container">
                    <div class="uk-margin">
                        <input class="uk-input" type="hidden" name="type" value="register">
                        <input class="uk-input" type="hidden" name="old_email" value="">
                        <input class="uk-input" type="hidden" name="password" value="">
                        <input class="uk-input" id="verify-email" type="hidden" name="email">
                    </div>
                    <div class="uk-margin input-container">
                        <div>인증코드</div>
                        <input class="uk-input" id="authcode" type="text" name="authcode" required>
                    </div>
                    <div class="btn-container">
                        <button class="auth-btn" type="submit">인증하기</button>
                    </div>
                </div>
            </form>

            <form id="accountForm" enctype="multipart/form-data">
                <div class="uk-margin register-container">
                    <div class="uk-margin">
                        <div>가입 이메일</div>
                        <input class="uk-input" type="hidden" name="type" value="register">
                        <input class="uk-input" id="register-email" type="email" name="email" readonly>
                        <input class="uk-input" id="verified-token" type="hidden" name="token">
                    </div>
                    <div class="uk-margin">
                        <div>닉네임</div>
                        <input class="uk-input" id="username" type="text" name="username" required>
                    </div>
                    <div class="uk-margin">
                        <label for="imagefile" class="form-label">Profile Image</label>
                        <input type="file" accept="image/*" class="form-control" id="imagefile" name="imagefile"/>
                    </div>
                    <div class="uk-margin">
                        <div>비밀번호</div>
                        <input class="uk-input" id="password" type="password" name="password" required>
                    </div>
                    <div class="uk-margin">
                        <div>비밀번호 확인</div>
                        <input class="uk-input" id="confirmPassword" type="password" name="password2" required>
                    </div>
                    <div class="uk-margin">
                        <button id="register-btn" class="register-btn" type="submit">가입 신청</button>
                    </div>
                </div>
            </form>
        </div>
    <script type="module" src="{{ STATIC_URL }}/statics/js/custom/accounts/authCodeRequired.js"></script>
    </article>
{% endblock %}

 

# Project_folder/migrations/env.py

import asyncio
from logging.config import fileConfig
import os
import sys

from sqlalchemy import pool
from sqlalchemy.ext.asyncio import async_engine_from_config

from alembic import context

sys.path.insert(0, os.path.realpath(os.path.join(os.path.dirname(__file__), "..")))

from app.core.database import Base, DATABASE_URL

# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config

# Interpret the config file for Python logging.
# This line sets up loggers basically.
# if config.config_file_name is not None:
#     fileConfig(config.config_file_name)
if config.config_file_name is not None:
    try:
        fileConfig(config.config_file_name, encoding='utf-8')
    except TypeError:
        # older versions of fileConfig don't support the encoding parameter
        fileConfig(config.config_file_name)

# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
''' 반드시 모델들을 임포트 하라. '''
from app.models import user
# from app.test import exam

# target_metadata = mymodel.Base.metadata
# target_metadata = None
target_metadata = Base.metadata

# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.


def run_migrations_offline() -> None:
    """Run migrations in 'offline' mode.

    This configures the context with just a URL
    and not an Engine, though an Engine is acceptable
    here as well.  By skipping the Engine creation
    we don't even need a DBAPI to be available.

    Calls to context.execute() here emit the given string to the
    script output.

    """
    url = config.get_main_option("sqlalchemy.url")
    context.configure(
        url=url,
        target_metadata=target_metadata,
        literal_binds=True,
        dialect_opts={"paramstyle": "named"},
    )

    with context.begin_transaction():
        context.run_migrations()


def do_run_migrations(connection):
    context.configure(connection=connection, target_metadata=target_metadata)
    with context.begin_transaction():
        context.run_migrations()


async def run_migrations_online() -> None:
    """Run migrations in 'online' mode for an async application."""
    # 환경 변수가 설정되지 않았다면 alembic.ini의 기본값을 사용하도록 폴백 설정 (선택 사항)
    db_url = DATABASE_URL
    if not db_url:
        db_url = config.get_main_option("sqlalchemy.url")
        if not db_url:
            raise ValueError("데이터베이스 URL을 찾을 수 없습니다. .env 파일이나 환경 변수를 확인하세요.")

    connectable = async_engine_from_config(
        config.get_section(config.config_ini_section, {}),
        prefix="sqlalchemy.",
        poolclass=pool.NullPool,
        future=True,  # SQLAlchemy 2.0
        url=db_url  # 여기에 환경 변수에서 가져온 URL을 전달 (개발 및 배포모드 모두 커버)
    )

    async with connectable.connect() as connection:
        await connection.run_sync(do_run_migrations)

    await connectable.dispose()

if context.is_offline_mode():
    run_migrations_offline()
else:
    asyncio.run(run_migrations_online())

 

# Project_folder/alembic.ini

# A generic, single database configuration.

[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/migrations

# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s

# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.  for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .


# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =

# max length of characters to apply to the "slug" field
# truncate_slug_length = 40

# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false

# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false

# version location specification; This defaults
# to <script_location>/versions.  When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions

# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini.  If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
#    "version_path_separator" key, which if absent then falls back to the legacy
#    behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
#    behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os

# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false

# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8

# database URL.  This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
# sqlalchemy.url = mysql+aiomysql://root:981011@localhost:3306/advanced_db?charset=utf8mb4
sqlalchemy.url =


[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts.  See the documentation for further
# detail and examples

# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME

# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME

# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME

# Logging configuration.  This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = WARNING
handlers = console
qualname =

[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine

[logger_alembic]
level = INFO
handlers =
qualname = alembic

[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic

[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

 

# Project_folder/.env

# 배포시에 주석해제
#APP_ENV=production

DEBUG_TRUE=true
DEBUG_FALSE=false
# production시 DEBUG_FALSE = false

SECRET_KEY=7b6b057992ae6b79d3fecc6d3ebe40626ef4e2ed8221303202e60fdff204d2a7d334b0
ALGORITHM = HS256

DB_TYPE=mysql
DB_DRIVER=aiomysql

DEV_DB_NAME=advanced_db
DEV_DB_HOST=localhost
DEV_DB_PORT=3306
DEV_DB_USER=root
DEV_DB_PASSWORD=<비밀번호>


PROD_APP_NAME=Deployed APP
PROD_APP_VERSION=Deployed V_0
PROD_APP_DESCRIPTION=FastAPI_Svelte를 이용해 개발한 프로젝트에 대한 배포판

PROD_DB_NAME=advanced_db
PROD_DB_HOST=<배포IP>
PROD_DB_PORT=<포트번호>
PROD_DB_USER=root
PROD_DB_PASSWORD=<비밀번호>


# redis_config.py 연결 설정: REDIS_PASSWORD None은 에러 유발, 배포 과정에 비번 지정
REDIS_HOST=<배포IP>
REDIS_PORT=<포트번호>
REDIS_DB=0
REDIS_PASSWORD=<비밀번호>


# 이메일 보내기 sender 정보
SMTP_USERNAME=<email>
SMTP_PASSWORD=iccxyzmnkyfvszux
SMTP_FROM=<email>
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587

ACCESS_COOKIE_NAME = access_token
REFRESH_COOKIE_NAME = refresh_token
NEW_ACCESS_COOKIE_NAME = new_access_token
NEW_REFRESH_COOKIE_NAME = new_refresh_token

ACCESS_TOKEN_EXPIRE = 30
REFRESH_TOKEN_EXPIRE = 7


PROFILE_IMAGE_URL = user_images/accounts/profiles

ARTICLE_THUMBNAIL_DIR = user_images/articles/thumbnails
ARTICLE_EDITOR_USER_IMG_DIR = user_images/articles/quills
ARTICLE_EDITOR_USER_VIDEO_DIR = user_videos/articles/quills



# 프론트엔드 개발에 사용되는 포트: 아래 두개(127.0.0.1, localhost)는 별개로 인식한다. 둘다 필요하다.
# 리스트는 한 줄로..., 파싱 가능한 형태로 바꾸기
#DEV_ORIGINS="http://localhost:5173,http://localhost:5100,http://127.0.0.1:5173,http://127.0.0.1:5100"
DEV_ORIGINS=["http://localhost:5173","http://localhost:5174","http://localhost:7100","http://localhost:7101","http://127.0.0.1:5173","http://127.0.0.1:5174","http://127.0.0.1:7100","http://127.0.0.1:7101"]
PROD_ORIGINS=[]

 

# Project_folder/.jshintrc

{
  "esversion": 11,
  "module": true,
  "browser": true,
  "sub": true,
  "undef": true,

  "globals": {
    "alert": true,
    "fetch": true,
    "URLSearchParams": true,
    "AbortController": true

  }
}

 

# Project_folder/requirements.txt

aiofiles==25.1.0
aiomysql==0.3.2
aiosmtplib==3.0.2
alembic==1.17.1
annotated-doc==0.0.4
annotated-types==0.7.0
anyio==4.11.0
bcrypt==4.0.1
blinker==1.9.0
cffi==2.0.0
cryptography==46.0.3
dnspython==2.8.0
ecdsa==0.19.1
email-validator==2.3.0
fastapi==0.121.1
fastapi-csrf-jinja==0.1.3
fastapi-mail==1.5.0
greenlet==3.2.4
idna==3.11
itsdangerous==2.2.0
Jinja2==3.1.6
Mako==1.3.10
MarkupSafe==3.0.3
passlib==1.7.4
pyasn1==0.6.1
pycparser==2.23
pydantic==2.12.4
pydantic-settings==2.12.0
pydantic_core==2.41.5
PyMySQL==1.1.2
python-dotenv==1.2.1
python-jose==3.5.0
redis==7.0.1
rsa==4.9.1
six==1.17.0
sniffio==1.3.1
SQLAlchemy==2.0.44
starlette==0.49.3
typing-inspection==0.4.2
typing_extensions==4.15.0