Compare commits

...

7 Commits

Author SHA1 Message Date
73ebdf9712 Merge pull request 'dev' (#6) from dev into main
Reviewed-on: #6
2025-11-17 11:02:14 +00:00
0d4cc83a97 Merge pull request '[FEAT] (카레고리 로직): 카테고리 서비스 구현 완료' (#5) from dev_stage into dev
Reviewed-on: #5
2025-11-17 11:02:00 +00:00
c9bfa81305 [FEAT] (카레고리 로직): 카테고리 서비스 구현 완료
v0.1.4 (2025-11-27)
- 카테고리 관련 API 구현 완료.
2025-11-17 20:01:08 +09:00
6daf842642 Merge pull request 'dev' (#4) from dev into main
Reviewed-on: #4
2025-11-16 09:04:41 +00:00
fc67076e80 Merge pull request '[FEAT] (사용자 로직): 프로필 서비스 구현 완료' (#3) from dev_stage into dev
Reviewed-on: #3
2025-11-16 09:03:15 +00:00
abf405f8ae [FEAT] (사용자 로직): 프로필 서비스 구현 완료
v0.1.3 (2025-11-16)
- 프로필 조회, 프로필 업데이트, 탈퇴 구현 완료.
2025-11-16 18:02:27 +09:00
782d858acb Merge pull request 'dev' (#2) from dev into main
Reviewed-on: #2
2025-11-16 07:24:50 +00:00
28 changed files with 896 additions and 115 deletions

View File

@@ -9,9 +9,9 @@ def register_blueprints(app: Flask) -> None:
# Swagger API Blueprint 등록 # Swagger API Blueprint 등록
from utils.swagger_config import api_blueprint from utils.swagger_config import api_blueprint
app.register_blueprint(api_blueprint, url_prefix='/api/v1') app.register_blueprint(api_blueprint, url_prefix="/api/v1")
# Flask-RESTX namespace에 리소스 등록 # Flask-RESTX namespace에 리소스 등록
from . import bp_auth from . import bp_auth, bp_users, bp_category
__all__ = ['register_blueprints'] __all__ = ["register_blueprints"]

View File

@@ -61,9 +61,9 @@ class Login(Resource):
tags=["Auth"] tags=["Auth"]
) )
@auth_ns.expect(auth_swagger.login_model) @auth_ns.expect(auth_swagger.login_model)
@auth_ns.response(200, 'Success - 로그인 성공') @auth_ns.response(200, "Success - 로그인 성공")
@auth_ns.response(400, '요청 변수가 올바르지 않습니다.') @auth_ns.response(400, "요청 변수가 올바르지 않습니다.")
@auth_ns.response(409, '로그인 할 수 없는 사용자입니다.') @auth_ns.response(409, "로그인 할 수 없는 사용자입니다.")
@auth_ns.response(500, "서버 내부 오류") @auth_ns.response(500, "서버 내부 오류")
def post(self): def post(self):
"""로그인""" """로그인"""

View File

@@ -0,0 +1,142 @@
"""
카테고리 관련 API 엔드포인트
"""
from flask import request, g
from flask_restx import Resource
from flask_jwt_extended import jwt_required
from utils.response import *
from utils.swagger_config import category_ns
from utils.decorators import load_current_user
from models.category_model import category_swagger
from services.category.category_service import CategoryService
@category_ns.route("/")
class Categories(Resource):
@category_ns.doc(
id="get_categories",
summary="카테고리 목록 조회",
description="카테고리 목록을 조회합니다.",
tags=["Category"],
security=[{"Bearer": []}]
)
@category_ns.response(200, "Success - 카테고리 목록 조회 완료")
@category_ns.response(401, "인증에 실패하였습니다.")
@category_ns.response(500, "서버 내부 오류")
@jwt_required()
def get(self):
"""카테고리 목록 조회"""
limit = request.args.get("limit", type=int)
offset = request.args.get("offset", type=int, default=0)
categories = CategoryService.get_all_categories(limit=limit, offset=offset)
return success_response(
data=[category.to_dict() for category in categories],
message="카테고리 목록 조회 성공"
)
@category_ns.doc(
id="create_category",
summary="카테고리 생성",
description="카테고리를 생성합니다.",
tags=["Category"],
security=[{"Bearer": []}]
)
@category_ns.expect(category_swagger.create_category)
@category_ns.response(201, "Success - 카테고리 생성 완료")
@category_ns.response(401, "인증에 실패하였습니다.")
@category_ns.response(409, "이미 존재하는 카테고리명")
@category_ns.response(500, "서버 내부 오류")
@jwt_required()
@load_current_user
def post(self):
"""카테고리 생성"""
request_data = request.get_json()
# 토큰에서 가져온 user_uuid를 request_data에 추가
user_uuid = g.current_user.user_uuid
category = CategoryService.create_category(request_data, user_uuid)
return success_response(
data=category.to_dict(),
message="카테고리 생성이 완료되었습니다",
status_code=201
)
@category_ns.route("/<string:category_uuid>")
class CategoryDetail(Resource):
@category_ns.doc(
id="get_category",
summary="카테고리 조회",
description="UUID로 특정 카테고리를 조회합니다.",
tags=["Category"],
security=[{"Bearer": []}],
params={"category_uuid": {"description": "카테고리 UUID", "type": "string"}}
)
@category_ns.response(201, "Success - 카테고리 조회 성공")
@category_ns.response(401, "인증에 실패하였습니다.")
@category_ns.response(409, "조회에 실패하였습니다.")
@category_ns.response(500, "서버 내부 오류")
@jwt_required()
def get(self, category_uuid):
"""카테고리 조회"""
category = CategoryService.get_category_by_uuid(category_uuid)
if not category:
return not_found_response("Category")
return success_response(
data=category.to_dict(),
message="카테고리 조회 성공"
)
@category_ns.doc(
id="update_category",
summary="카테고리 업데이트",
description="UUID로 특정 카테고리를 업데이트합니다.",
tags=["Category"],
security=[{"Bearer": []}],
params={"category_uuid": {"description": "카테고리 UUID", "type": "string"}}
)
@category_ns.expect(category_swagger.update_category)
@category_ns.response(200, "Success - 카테고리 업데이트 성공")
@category_ns.response(401, "인증에 실패하였습니다.")
@category_ns.response(409, "카테고리 업데이트에 실패하였습니다.")
@category_ns.response(500, "서버 내부 오류")
@jwt_required()
def put(self, category_uuid):
"""카테고리 업데이트"""
request_data = request.get_json()
category = CategoryService.update_category(category_uuid, request_data)
return success_response(
data=category.to_dict(),
message="카테고리 업데이트가 완료되었습니다",
status_code=200
)
@category_ns.doc(
id="delete_category",
summary="카테고리 삭제",
description="UUID로 특정 카테고리를 삭제합니다.",
tags=["Category"],
security=[{"Bearer": []}],
params={"category_uuid": {"description": "카테고리 UUID", "type": "string"}}
)
@category_ns.response(200, "Success - 카테고리 삭제 성공")
@category_ns.response(401, "인증에 실패하였습니다.")
@category_ns.response(409, "카테고리 삭제에 실패하였습니다.")
@category_ns.response(500, "서버 내부 오류")
@jwt_required()
def delete(self, category_uuid):
"""카테고리 삭제"""
CategoryService.delete_category(category_uuid)
return success_response(
message="카테고리 삭제가 완료되었습니다",
status_code=200
)

View File

@@ -1,3 +1,95 @@
""" """
유저 관련 API 엔드포인트 유저 관련 API 엔드포인트
""" """
from flask import request, g
from flask_restx import Resource
from flask_jwt_extended import jwt_required, get_jwt
from utils.response import *
from utils.swagger_config import user_ns
from utils.decorators import load_current_user
from models.user_model import user_swagger
from services.user.user_service import UserService
@user_ns.route("/me")
class MyProfile(Resource):
@user_ns.doc(
id="get_my_profile",
summary="내 프로필 조회",
description="현재 로그인한 사용의 프로필을 조회합니다. (토큰 기반)",
tags=["Users"],
security=[{"Bearer": []}]
)
@user_ns.response(200, "Success - 프로필 조회 완료")
@user_ns.response(401, "인증 실패에 실패하였습니다.")
@user_ns.response(500, "서버 내부 오류")
@jwt_required()
@load_current_user
def get(self):
"""내 프로필 조회"""
return success_response(
data=g.current_user.to_dict(),
message="프로필 조회 성공"
)
@user_ns.doc(
id="update_my_profile",
summary="내 프로필 업데이트",
description="현재 로그인한 사용자의 프로필을 업데이트 합니다. (토큰 기반)",
tags=["Users"],
security=[{"Bearer": []}]
)
@user_ns.expect(user_swagger.update_profile_model)
@user_ns.response(200, "Success - 프로필 업데이트 완료")
@user_ns.response(400, "잘못된 요청 데이터")
@user_ns.response(401, "인증 실패에 실패하였습니다.")
@user_ns.response(500, "서버 내부 오류")
@jwt_required()
@load_current_user
def put(self):
"""내 프로필 업데이트"""
user = g.current_user
data = request.get_json()
updated_user = UserService.update_user_profile(user.user_uuid, data)
return success_response(
data=updated_user.to_dict(),
message="프로필 업데이트 성공"
)
@user_ns.doc(
id="delete_my_account",
summary="회원 탈퇴",
description="현재 로그인한 사용자의 계정을 삭제합니다. (토큰 기반). 탈퇴 후 모든 데이터가 삭제되며 복구할 수 없습니다.",
tags=["Users"],
security=[{"Bearer": []}]
)
@user_ns.response(200, "Success - 회원 탈퇴 완료")
@user_ns.response(401, "인증에 실패하였습니다.")
@user_ns.response(404, "사용자를 찾을 수 없습니다.")
@user_ns.response(500, "서버 내부 오류")
@jwt_required()
@load_current_user
def delete(self):
"""회원 탈퇴"""
user = g.current_user
# JWT ID 추출 (로그아웃 처리용)
jwt_data = get_jwt()
jti = jwt_data.get("jti")
# 사용자 삭제 (캐시 무효화 + JWT 블랙리스트 추가 포함)
success = UserService.delete_user(user.user_uuid, jti=jti)
if not success:
return error_response(
message="회원 탈퇴에 실패했습니다.",
status_code=404
)
return success_response(
message="회원 탈퇴가 완료되었습니다."
)

View File

@@ -1,6 +1,6 @@
# API INFO # API INFO
API_VERSION = "v0.1.2" API_VERSION = "v0.1.4"
API_DATE = "2025-11-16" API_DATE = "2025-11-17"
# BASE DATE FORMAT # BASE DATE FORMAT
DATE_FORMAT = "%Y-%m-%d %H:%M:%S %z" DATE_FORMAT = "%Y-%m-%d %H:%M:%S %z"

View File

@@ -5,10 +5,12 @@ Models 레이어
# User # User
from .user_model import users, user_ips, user_dto, auth_swagger from .user_model import users, user_ips, user_dto, auth_swagger
# Category
from .category_model import category, category_dto, category_swagger
__all__ = [ __all__ = [
# User # User
users, users, user_ips, user_dto, auth_swagger,
user_ips, # Category
user_dto, category, category_dto, category_swagger,
auth_swagger
] ]

View File

@@ -0,0 +1,63 @@
"""
카테고리 모델
"""
import uuid
from typing import Dict, Any
from extensions import db
from utils.func import get_timestamp_ms
import constants
class Category(db.Model):
"""카테고리 모델"""
__tablename__ = "category"
# 기본 필드
category_uuid = db.Column(db.String(255), primary_key=True, nullable=False, comment="카타게로 UUID")
category_name = db.Column(db.String(255), unique=True, nullable=False, index=True, comment="카테고리명")
description = db.Column(db.String(500), nullable=True, comment="카테고리 설명")
order_no = db.Column(db.Integer, nullable=True, comment="표시 순서")
is_used = db.Column(db.SmallInteger, nullable=False, default=constants.IS_Y, comment="사용 여부 (0:미사용, 1:사용)")
# 편집자
created_by = db.Column(db.String(255), nullable=True, comment="생성자 UUID")
updated_by = db.Column(db.String(255), nullable=True, comment="수정자 UUID")
deleted_by = db.Column(db.String(255), nullable=True, comment="삭제자 UUID")
# 타임스탬프 (BIGINT - Unix Timestamp in milliseconds)
created_at = db.Column(db.BigInteger, nullable=True, comment="생성일")
updated_at = db.Column(db.BigInteger, nullable=True, comment="수정일")
deleted_at = db.Column(db.BigInteger, nullable=True, comment="삭제일")
def __init__(self, category_name: str, description: str = None, created_by: str = None):
"""카테고리 초기화"""
self.category_uuid = str(uuid.uuid4())
self.category_name = category_name
self.description = description
self.is_used = constants.IS_Y
self.created_by = created_by
self.created_at = int(get_timestamp_ms())
def update_timestamp(self) -> None:
"""타임스탬프 업데이트"""
self.updated_at = int(get_timestamp_ms())
def to_dict(self) -> Dict[str, Any]:
"""딕셔너리로 변환"""
return {
"category_uuid": self.category_uuid,
"category_name": self.category_name,
"description": self.description,
"order_no": self.order_no,
"is_used": self.is_used,
"created_by": self.created_by,
"updated_by": self.updated_by,
"deleted_by": self.deleted_by,
"created_at": self.created_at,
"updated_at": self.updated_at,
"deleted_at": self.deleted_at,
}
def __repr__(self) -> str:
return f'<Category uuid={self.category_uuid}, category_name={self.category_name}, is_used={self.is_used}'

View File

@@ -0,0 +1,23 @@
from pydantic import BaseModel, field_validator
from typing import Optional
class CreateCategoryDTO(BaseModel):
"""카테고리 생성 DTO"""
# 필수
category_name: str
description: Optional[str] = None
class Config:
strict = True
class UpdateCategoryDTO(BaseModel):
"""카테고리 업데이트 DTO"""
category_name: Optional[str] = None
description: Optional[str] = None
class Config:
strict = True

View File

@@ -0,0 +1,16 @@
"""
카테고리 Swagger 모델 정의
"""
from utils.swagger_config import category_ns
from flask_restx import fields
create_category = category_ns.model("CreateCategory", {
"category_name": fields.String(required=True, description="카테고리명", example="AI"),
"description": fields.String(required=False, description="카테고리 설명", example="AI 관련 주제입니다.")
})
update_category = category_ns.model("UpdateCategory", {
"category_name": fields.String(required=False, description="카테고리명", example="AI"),
"description": fields.String(required=False, description="카테고리 설명", example="AI 관련 주제입니다.")
})

View File

@@ -6,15 +6,15 @@ from utils.swagger_config import auth_ns
from flask_restx import fields from flask_restx import fields
# Swagger 모델 정의 # Swagger 모델 정의
register_model = auth_ns.model('Register', { register_model = auth_ns.model("Register", {
'id': fields.String(required=True, description='사용자 ID', example='user123'), "id": fields.String(required=True, description="사용자 ID", example="user123"),
'password': fields.String(required=True, description='비밀번호 (최소 6자)', example='password123'), "password": fields.String(required=True, description="비밀번호 (최소 6자)", example="password123"),
'name': fields.String(required=False, description='사용자 이름', example='홍길동') "user_name": fields.String(required=False, description="사용자 이름", example="홍길동")
}) })
login_model = auth_ns.model('Login', { login_model = auth_ns.model("Login", {
'id': fields.String(required=True, description='사용자 ID', example='user123'), "id": fields.String(required=True, description="사용자 ID", example="user123"),
'password': fields.String(required=True, description='비밀번호', example='password123') "password": fields.String(required=True, description="비밀번호", example="password123")
}) })
__all__ = ['register_model', 'login_model'] __all__ = ["register_model", "login_model"]

View File

@@ -11,20 +11,20 @@ class CreateUserDTO(BaseModel):
# 선택 # 선택
user_name: Optional[str] = None user_name: Optional[str] = None
@field_validator('id') @field_validator("id")
@classmethod @classmethod
def validate_id(cls, v): def validate_id(cls, v):
if not v or len(v.strip()) == 0: if not v or len(v.strip()) == 0:
raise ValueError('ID는 필수입니다') raise ValueError("ID는 필수입니다")
if len(v) < 3 or len(v) > 50: if len(v) < 3 or len(v) > 50:
raise ValueError('ID는 3-50자 사이여야 합니다') raise ValueError("ID는 3-50자 사이여야 합니다")
return v.strip() return v.strip()
@field_validator('password') @field_validator("password")
@classmethod @classmethod
def validate_password(cls, v): def validate_password(cls, v):
if not v or len(v) < 6: if not v or len(v) < 6:
raise ValueError('비밀번호는 최소 6자 이상이어야 합니다') raise ValueError("비밀번호는 최소 6자 이상이어야 합니다")
return v return v
# 자동 형변환 방지 # 자동 형변환 방지
@@ -39,11 +39,11 @@ class CheckUserDTO(BaseModel):
id: str id: str
password: str password: str
@field_validator('id', 'password') @field_validator("id", "password")
@classmethod @classmethod
def validate_required(cls, v): def validate_required(cls, v):
if not v or len(v.strip()) == 0: if not v or len(v.strip()) == 0:
raise ValueError('필수 항목입니다') raise ValueError("필수 항목입니다")
return v.strip() return v.strip()
# 자동 형변환 방지 # 자동 형변환 방지

View File

@@ -38,11 +38,11 @@ class UserIps(db.Model):
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
"""딕셔너리 변환""" """딕셔너리 변환"""
return { return {
'user_uuid': self.user_uuid, "user_uuid": self.user_uuid,
'user_ip': self.user_ip, "user_ip": self.user_ip,
'user_agent': self.user_agent, "user_agent": self.user_agent,
'created_at': self.created_at, "created_at": self.created_at,
'lasted_at': self.lasted_at "lasted_at": self.lasted_at
} }
def __repr__(self) -> str: def __repr__(self) -> str:

View File

@@ -0,0 +1,11 @@
"""
유저 관련 Swagger 모델
"""
from utils.swagger_config import user_ns
from flask_restx import fields
# Swagger 모델 정의
update_profile_model = user_ns.model("UpdateProfile", {
"user_name": fields.String(required=False, description="사용자 이름", example="홍길동")
})

View File

@@ -32,7 +32,7 @@ class Users(db.Model):
self.password = hash_password self.password = hash_password
# 선택적 필드 # 선택적 필드
self.user_name = kwargs.get('user_name') self.user_name = kwargs.get("user_name")
# 타임스탬프 # 타임스탬프
current_time = int(get_timestamp_ms()) current_time = int(get_timestamp_ms())
@@ -56,13 +56,13 @@ class Users(db.Model):
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
"""딕셔너리로 변환""" """딕셔너리로 변환"""
return { return {
'user_uuid': self.user_uuid, "user_uuid": self.user_uuid,
'id': self.id, "id": self.id,
'user_name': self.user_name, "user_name": self.user_name,
'auth_level': self.auth_level, "auth_level": self.auth_level,
'count': self.count, "count": self.count,
'created_at': self.created_at, "created_at": self.created_at,
'updated_at': self.updated_at "updated_at": self.updated_at
} }
def __repr__(self) -> str: def __repr__(self) -> str:

View File

@@ -13,11 +13,11 @@ class RedisFacadeOpr():
def __init__(self) -> None: def __init__(self) -> None:
"""Redis 설정 초기화""" """Redis 설정 초기화"""
self.redis_config = { self.redis_config = {
'host': settings.REDIS_HOST, "host": settings.REDIS_HOST,
'port': settings.REDIS_PORT, "port": settings.REDIS_PORT,
'password': settings.REDIS_PASSWORD, "password": settings.REDIS_PASSWORD,
'decode_responses': True, # auto-decode response "decode_responses": True, # auto-decode response
'db': settings.REDIS_DB "db": settings.REDIS_DB
} }
self.redis_client: Optional[redis.StrictRedis] = None self.redis_client: Optional[redis.StrictRedis] = None

View File

@@ -9,9 +9,14 @@ from .base_repository import BaseRepository
from .user.user_repository import UserRepository from .user.user_repository import UserRepository
from .user.user_ips_repository import UserIpsRepository from .user.user_ips_repository import UserIpsRepository
# Category
from .category.category_repository import CategoryRepository
__all__ = [ __all__ = [
# Base # Base
BaseRepository, BaseRepository,
# User # User
UserRepository, UserIpsRepository UserRepository, UserIpsRepository,
# Category
CategoryRepository,
] ]

View File

@@ -10,7 +10,7 @@ from extensions import db, custom_logger
import settings import settings
# Generic type for model entitles # Generic type for model entitles
T = TypeVar('T') T = TypeVar("T")
logger = custom_logger(f"{settings.LOG_PREFIX}_repository") logger = custom_logger(f"{settings.LOG_PREFIX}_repository")
@@ -52,12 +52,13 @@ class BaseRepository(ABC, Generic[T]):
logger.error(f"{self.model.__name__} 수정 실패: {str(e)}") logger.error(f"{self.model.__name__} 수정 실패: {str(e)}")
raise raise
def delete(self, entity: T) -> None: def delete(self, entity: T) -> bool:
"""엔티티 삭제""" """엔티티 삭제"""
try: try:
self.session.delete(entity) self.session.delete(entity)
self.session.flush() self.session.flush()
logger.debug(f"{self.model.__name__} 삭제완료: {getattr(entity, 'id', 'unknown')}") logger.debug(f"{self.model.__name__} 삭제완료: {getattr(entity, 'id', 'unknown')}")
return True
except SQLAlchemyError as e: except SQLAlchemyError as e:
logger.error(f"{self.model.__name__} 삭제 실패: {str(e)}") logger.error(f"{self.model.__name__} 삭제 실패: {str(e)}")
raise raise
@@ -68,4 +69,20 @@ class BaseRepository(ABC, Generic[T]):
return self.session.query(self.model).count() return self.session.query(self.model).count()
except SQLAlchemyError as e: except SQLAlchemyError as e:
logger.error(f"{self.model.__name__} 개수 조회 실패: {str(e)}") logger.error(f"{self.model.__name__} 개수 조회 실패: {str(e)}")
raise
def find_all(self, limit: Optional[int] = None, offset: int = 0) -> List[T]:
"""Find all entities with optional pagination."""
try:
query = self.session.query(self.model)
if offset > 0:
query = query.offset(offset)
if limit:
query = query.limit(limit)
return query.all()
except SQLAlchemyError as e:
logger.error(f"Error finding all {self.model.__name__}: {e}")
raise raise

View File

@@ -0,0 +1,36 @@
"""
카테고리 Repository
"""
from typing import Optional, Type
from repositories.base_repository import BaseRepository
from models.category_model.category import Category
class CategoryRepository(BaseRepository[Category]):
"""카테고리 Repository"""
@property
def model(self) -> Type[Category]:
"""모델 반환"""
return Category
def find_by_uuid(self, category_uuid: str) -> Optional[Category]:
"""UUID로 조회"""
return self.session.query(Category).filter_by(category_uuid=category_uuid).one_or_none()
def find_by_name(self, category_name: str) -> Optional[Category]:
"""카테고리명으로 조회"""
return self.session.query(Category).filter_by(category_name=category_name).one_or_none()
def exists_by_name(self, category_name: str) -> bool:
"""카테고리명으로 카테고리 존재 여부 확인"""
return self.session.query(
self.session.query(Category).filter_by(category_name=category_name).exists()
).scalar()
def exists_by_uuid(self, category_uuid: str) -> bool:
"""UUID로 카테고리 존재 여부 확인"""
return self.session.query(
self.session.query(Category).filter_by(category_uuid=category_uuid).exists()
).scalar()

View File

@@ -0,0 +1,20 @@
"""
서비스 레이어 패키지
"""
from services.auth.auth_service import AuthService
from services.user.user_service import UserService
from services.auth.cache_service import (
CacheService,
UserCacheService,
JWTBlacklistService
)
__all__ = [
"AuthService",
"UserService",
"CacheService",
"UserCacheService",
"JWTBlacklistService",
"AccountLockPolicy"
]

View File

@@ -130,8 +130,8 @@ class AuthService:
user_ips_repo = UserIpsRepository() user_ips_repo = UserIpsRepository()
# IP 및 User-Agent 추출 # IP 및 User-Agent 추출
ip_address = request.headers.get('X-Real-IP', '') if request.headers else '' ip_address = request.headers.get("X-Real-IP", "") if request.headers else ""
user_agent = request.headers.get('User-Agent', '') if request.headers else '' user_agent = request.headers.get("User-Agent", "") if request.headers else ""
# 기존 IP 기록 조회 로직 # 기존 IP 기록 조회 로직
existing_ip = user_ips_repo.find_by_user_and_ip(user, ip_address) existing_ip = user_ips_repo.find_by_user_and_ip(user, ip_address)

View File

@@ -165,4 +165,46 @@ class JWTBlacklistService:
def is_blacklisted(cls, jti: str) -> bool: def is_blacklisted(cls, jti: str) -> bool:
"""토큰이 블랙리스트에 있는지 확인""" """토큰이 블랙리스트에 있는지 확인"""
key = cls._get_key(jti) key = cls._get_key(jti)
return CacheService.exists(key) return CacheService.exists(key)
class AccountLockService:
"""계정 잠금 관리 서비스 (Redis 기반)"""
PREFIX = "account:lock:"
TTL = settings.ACCOUNT_LOCKOUT_DURATION_MINUTES * 60 # 분을 초로 변환
@classmethod
def _get_key(cls, user_uuid: str) -> str:
"""잠금 키 생성"""
return f"{cls.PREFIX}{user_uuid}"
@classmethod
def lock(cls, user_uuid: str, ttl: Optional[int] = None) -> bool:
"""계정 잠금 (Redis에 TTL과 함께 저장)"""
key = cls._get_key(user_uuid)
ttl = ttl or cls.TTL
if not CacheService._is_redis_available():
logger.warning("레디스가 연결되어 있지 않습니다. 계정 잠금이 불가능합니다.")
return False
success = CacheService.set(key, "1", ttl)
if success:
logger.info(f"계정 잠금 완료: {user_uuid} (TTL: {ttl}s)")
return success
@classmethod
def is_locked(cls, user_uuid: str) -> bool:
"""계정이 잠겨있는지 확인"""
key = cls._get_key(user_uuid)
return CacheService.exists(key)
@classmethod
def unlock(cls, user_uuid: str) -> bool:
"""계정 잠금 해제"""
key = cls._get_key(user_uuid)
success = CacheService.delete(key)
if success:
logger.info(f"계정 잠금 해제 완료: {user_uuid}")
return success

View File

@@ -0,0 +1,122 @@
"""
카테고리 서비스
"""
from typing import Dict, Any, Optional, List
from models.category_model.category import Category
from models.category_model.category_dto import CreateCategoryDTO
from repositories import CategoryRepository
from utils.db_decorators import transactional
from extensions import custom_logger
import settings
import constants
logger = custom_logger(f"{settings.LOG_PREFIX}_category_service")
class CategoryService:
"""
카테고리 서비스
"""
@transactional
def create_category(category_data: Dict[str, Any], user_uuid: str) -> Optional[Category]:
"""카테고리 생성"""
create_category_dto = CreateCategoryDTO(**category_data)
category_repo = CategoryRepository()
# 중복 검사
if category_repo.exists_by_name(create_category_dto.category_name):
return None
# 카테고리 생성
category = Category(
category_name=create_category_dto.category_name,
description=create_category_dto.description,
created_by=user_uuid
)
category = category_repo.create(category)
logger.info(f"카테고리 생성 완료: {category.category_uuid}")
return category
def get_category_by_uuid(category_uuid: str) -> Optional[Category]:
"""UUID로 카테고리 조회"""
category_repo = CategoryRepository()
return category_repo.find_by_uuid(category_uuid)
def get_category_by_name(category_name: str) -> Optional[Category]:
"""카테고리명으로 카테고리 조회"""
category_repo = CategoryRepository()
return category_repo.find_by_name(category_name)
def get_all_categories(limit: Optional[int] = None, offset: int = 0) -> List[Category]:
"""모든 카테고리 조회"""
category_repo = CategoryRepository()
return category_repo.find_all(limit=limit, offset=offset)
@transactional
def update_category(category_uuid: str, update_data: Dict[str, Any]) -> Category:
"""카테고리 업데이트"""
category_repo = CategoryRepository()
# 카테고리 조회
category = category_repo.find_by_uuid(category_uuid)
if not category:
return None
# 이름 변경 시 중복 검사
if 'category_name' in update_data and update_data['category_name'] != category.category_name:
if category_repo.exists_by_name(update_data['category_name']):
return None
category.category_name = update_data['category_name']
# 설명 업데이트
if 'description' in update_data:
category.description = update_data['description']
# 타임스탬프 업데이트
if hasattr(category, 'update_timestamp'):
category.update_timestamp()
category = category_repo.update(category)
logger.info(f"Category updated: {category.category_name} ({category_uuid})")
return category
@transactional
def delete_category(category_uuid: str) -> bool:
"""카테고리 삭제"""
category_repo = CategoryRepository()
# 카테고리 조회
category = category_repo.find_by_uuid(category_uuid)
if not category:
return None
category.is_used = constants.IS_N
# 삭제
success = category_repo.update(category)
if success:
logger.info(f"Category deleted: {category.category_name} ({category_uuid})")
return success
def count_categories() -> int:
"""카테고리 총 개수 조회"""
category_repo = CategoryRepository()
return category_repo.count()
def exists_by_name(category_name: str) -> bool:
"""카테고리명 존재 여부 확인"""
category_repo = CategoryRepository()
return category_repo.exists_by_name(category_name)

View File

@@ -1,4 +1,111 @@
""" """
사용자 서비스 사용자 서비스
사용자 관리 관련 비즈니스 로직을 관리하는 서비스 레이어 사용자 관리 관련 비즈니스 로직을 관리하는 서비스 레이어
""" """
from typing import Dict, Any, Optional
from types import SimpleNamespace
from models.user_model.users import Users
from repositories import UserRepository
from services.auth.cache_service import UserCacheService, JWTBlacklistService
from utils.db_decorators import transactional
from extensions import custom_logger
import settings
logger = custom_logger(f"{settings.LOG_PREFIX}_user_service")
class UserService:
"""
사용자 서비스
"""
@staticmethod
def get_user_by_uuid(user_uuid: str, use_cache: bool = True) -> Optional[Users]:
"""UUID로 사용자 조회 (캐시 우선)"""
if use_cache:
# 캐시 조회
cached_user_data = UserCacheService.get(user_uuid)
if cached_user_data:
user = SimpleNamespace(**cached_user_data)
user.to_dict = lambda: cached_user_data
logger.debug(f"캐시에서 사용자 조회 성공: {user_uuid}")
return user
# DB 조회
user_repo = UserRepository()
user = user_repo.find_by_uuid(user_uuid)
if user and use_cache:
# 캐시 저장
UserCacheService.set(user_uuid, user.to_dict())
logger.debug(f"DB에서 사용자 조회 후 캐시에 저장: {user_uuid}")
return user
@staticmethod
def get_user_by_id(user_ud: str) -> Optional[Users]:
"""ID로 사용자 조회"""
user_repo = UserRepository()
return user_repo.get_by_id(user_ud)
@staticmethod
@transactional
def update_user_profile(user_uuid: str, update_data: Dict[str, Any]) -> Users:
"""사용자 프로필 업데이트"""
user_repo = UserRepository()
# 사용자 조회
user = user_repo.find_by_uuid(user_uuid)
if not user:
return None
# 업데이트 가능한 필드만 처리
if "user_name" in update_data:
user.user_name = update_data["user_name"]
user = user_repo.update(user)
# 캐시 갱신
UserCacheService.refresh(user_uuid, user.to_dict())
logger.info(f"사용자 프로필이 업데이트되었습니다: {user_uuid}")
return user
@staticmethod
def invalidate_user_cache(user_uuid: str) -> bool:
"""사용자 캐시 무효화"""
success = UserCacheService.delete(user_uuid)
if success:
logger.debug(f"사용자 캐시가 무효화되었습니다: {user_uuid}")
return success
@staticmethod
@transactional
def delete_user(user_uuid: str, jti: Optional[str] = None) -> bool:
"""사용자 삭제 (회원 탈퇴)"""
user_repo = UserRepository()
user = user_repo.find_by_uuid(user_uuid)
if not user:
logger.warning(f"삭제할 사용자를 DB에서 찾을 수 없음: {user_uuid}")
UserCacheService.delete(user_uuid)
return False
# 삭제
success = user_repo.delete(user)
if success:
# 캐시 무효화
UserCacheService.delete(user_uuid)
# JWT 블랙리스트 추가 (현재 토큰 무효화)
if jti:
JWTBlacklistService.add(jti)
logger.debug(f"JWT 블랙리스트 추가: {jti}")
logger.info(f"사용자가 삭제되었습니다: {user_uuid}")
return success

View File

@@ -13,76 +13,76 @@ import multiprocessing
load_dotenv() load_dotenv()
# 환경설정 # 환경설정
DEBUG = os.getenv('DEBUG', False) DEBUG = os.getenv("DEBUG", False)
# 서버 # 서버
HOST = os.getenv('HOST', '0.0.0.0') HOST = os.getenv("HOST", "0.0.0.0")
PORT = int(os.getenv('PORT', '5000')) PORT = int(os.getenv("PORT", "5000"))
# 데이터베이스 설정 # 데이터베이스 설정
DB_HOST = os.getenv('DB_HOST', 'localhost') DB_HOST = os.getenv("DB_HOST", "localhost")
DB_PORT = int(os.getenv('DB_PORT', 5000)) DB_PORT = int(os.getenv("DB_PORT", 5000))
DB_USER = os.getenv('DB_USER', 'root') DB_USER = os.getenv("DB_USER", "root")
DB_PASSWORD = os.getenv('DB_PASSWORD', '') DB_PASSWORD = os.getenv("DB_PASSWORD", "")
DB_NAME = os.getenv('DB_NAME', 'test') DB_NAME = os.getenv("DB_NAME", "test")
SQLALCHEMY_DATABASE_URI = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}?charset=utf8mb4" SQLALCHEMY_DATABASE_URI = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}?charset=utf8mb4"
SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_ECHO = False SQLALCHEMY_ECHO = False
SQLALCHEMY_POOL_SIZE = int(os.getenv('DB_POOL_SIZE', 10)) SQLALCHEMY_POOL_SIZE = int(os.getenv("DB_POOL_SIZE", 10))
SQLALCHEMY_MAX_OVERFLOW = int(os.getenv('DB_MAX_OVERFLOW', 20)) SQLALCHEMY_MAX_OVERFLOW = int(os.getenv("DB_MAX_OVERFLOW", 20))
SQLALCHEMY_POOL_RECYCLE = int(os.getenv('DB_POOL_RECYCLE', 3600)) SQLALCHEMY_POOL_RECYCLE = int(os.getenv("DB_POOL_RECYCLE", 3600))
JWT_SECRET_KEY = os.getenv('JWT_SECRET_KEY', 'your-secret-key-change-this-in-production') JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your-secret-key-change-this-in-production")
JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=int(os.getenv('JWT_ACCESS_TOKEN_HOURS', 24))) JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=int(os.getenv("JWT_ACCESS_TOKEN_HOURS", 24)))
JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=int(os.getenv('JWT_REFRESH_TOKEN_DAYS', 30))) JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=int(os.getenv("JWT_REFRESH_TOKEN_DAYS", 30)))
JWT_TOKEN_LOCATION = ['headers'] JWT_TOKEN_LOCATION = ["headers"]
JWT_HEADER_NAME = 'Authorization' JWT_HEADER_NAME = "Authorization"
JWT_HEADER_TYPE = 'Bearer' JWT_HEADER_TYPE = "Bearer"
JWT_ENCODE_JTI = True # JWT ID 포함 (블랙리스트 추적용) JWT_ENCODE_JTI = True # JWT ID 포함 (블랙리스트 추적용)
# CORS 설정 # CORS 설정
CORS_ORIGINS = os.getenv('CORS_ORIGINS', '*').split(',') CORS_ORIGINS = os.getenv("CORS_ORIGINS", "*").split(",")
CORS_ALLOW_HEADERS = ['Content-Type', 'Authorization'] CORS_ALLOW_HEADERS = ["Content-Type", "Authorization"]
CORS_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'] CORS_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]
# Redis 설정 # Redis 설정
REDIS_HOST = os.getenv('REDIS_HOST', 'localhost') REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
REDIS_PORT = int(os.getenv('REDIS_PORT', 6379)) REDIS_PORT = int(os.getenv("REDIS_PORT", 6379))
REDIS_DB = int(os.getenv('REDIS_DB', 0)) REDIS_DB = int(os.getenv("REDIS_DB", 0))
REDIS_PASSWORD = os.getenv('REDIS_PASSWORD', None) REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", None)
# 로깅 설정 # 로깅 설정
LOG_DIR = os.getenv('LOG_DIR', 'logs') LOG_DIR = os.getenv("LOG_DIR", "logs")
LOG_PREFIX = os.getenv('LOG_PREFIX', 'default.') LOG_PREFIX = os.getenv("LOG_PREFIX", "default.")
LOG_LEVEL_TEXT = os.getenv('LOG_LEVEL_TEXT', 'INFO' if not DEBUG else 'DEBUG') LOG_LEVEL_TEXT = os.getenv("LOG_LEVEL_TEXT", "INFO" if not DEBUG else "DEBUG")
MAX_LOG_SIZE = int(os.getenv('MAX_LOG_SIZE', 10 * 1024 * 1024)) # 10MB MAX_LOG_SIZE = int(os.getenv("MAX_LOG_SIZE", 10 * 1024 * 1024)) # 10MB
LOG_BACKUP_COUNT = int(os.getenv('LOG_BACKUP_COUNT', 5)) LOG_BACKUP_COUNT = int(os.getenv("LOG_BACKUP_COUNT", 5))
# Worker 설정 # Worker 설정
GUNICORN_WORKERS = int(os.getenv('GUNICORN_WORKERS', multiprocessing.cpu_count() * 2 + 1)) GUNICORN_WORKERS = int(os.getenv("GUNICORN_WORKERS", multiprocessing.cpu_count() * 2 + 1))
GUNICORN_WORKER_CLASS = os.getenv('GUNICORN_WORKER_CLASS', 'gevent') # gevent for async I/O GUNICORN_WORKER_CLASS = os.getenv("GUNICORN_WORKER_CLASS", "gevent") # gevent for async I/O
GUNICORN_WORKER_CONNECTIONS = int(os.getenv('GUNICORN_WORKER_CONNECTIONS', 1000)) GUNICORN_WORKER_CONNECTIONS = int(os.getenv("GUNICORN_WORKER_CONNECTIONS", 1000))
GUNICORN_THREADS = int(os.getenv('GUNICORN_THREADS', 1)) # gevent 사용 시 무시됨 GUNICORN_THREADS = int(os.getenv("GUNICORN_THREADS", 1)) # gevent 사용 시 무시됨
# 타임아웃 설정 # 타임아웃 설정
GUNICORN_TIMEOUT = int(os.getenv('GUNICORN_TIMEOUT', 30)) # 요청 타임아웃 (초) GUNICORN_TIMEOUT = int(os.getenv("GUNICORN_TIMEOUT", 30)) # 요청 타임아웃 (초)
GUNICORN_KEEPALIVE = int(os.getenv('GUNICORN_KEEPALIVE', 5)) # Keep-Alive 연결 유지 시간 GUNICORN_KEEPALIVE = int(os.getenv("GUNICORN_KEEPALIVE", 5)) # Keep-Alive 연결 유지 시간
GUNICORN_GRACEFUL_TIMEOUT = int(os.getenv('GUNICORN_GRACEFUL_TIMEOUT', 30)) # Graceful shutdown 대기 시간 GUNICORN_GRACEFUL_TIMEOUT = int(os.getenv("GUNICORN_GRACEFUL_TIMEOUT", 30)) # Graceful shutdown 대기 시간
# 재시작 설정 (메모리 누수 방지) # 재시작 설정 (메모리 누수 방지)
GUNICORN_MAX_REQUESTS = int(os.getenv('GUNICORN_MAX_REQUESTS', 1000)) GUNICORN_MAX_REQUESTS = int(os.getenv("GUNICORN_MAX_REQUESTS", 1000))
GUNICORN_MAX_REQUESTS_JITTER = int(os.getenv('GUNICORN_MAX_REQUESTS_JITTER', 50)) GUNICORN_MAX_REQUESTS_JITTER = int(os.getenv("GUNICORN_MAX_REQUESTS_JITTER", 50))
# 로깅 설정 # 로깅 설정
GUNICORN_ACCESSLOG = os.getenv('GUNICORN_ACCESSLOG', '-') # '-' = stdout GUNICORN_ACCESSLOG = os.getenv("GUNICORN_ACCESSLOG", "-") # "-" = stdout
GUNICORN_ERRORLOG = os.getenv('GUNICORN_ERRORLOG', '-') GUNICORN_ERRORLOG = os.getenv("GUNICORN_ERRORLOG", "-")
GUNICORN_LOGLEVEL = os.getenv('GUNICORN_LOGLEVEL', 'info') GUNICORN_LOGLEVEL = os.getenv("GUNICORN_LOGLEVEL", "info")
# 보안 설정 - 로그인 실패 # 보안 설정 - 로그인 실패
MAX_LOGIN_ATTEMPTS = int(os.getenv('MAX_LOGIN_ATTEMPTS', 5)) MAX_LOGIN_ATTEMPTS = int(os.getenv("MAX_LOGIN_ATTEMPTS", 5))
ACCOUNT_LOCKOUT_DURATION_MINUTES = int(os.getenv('ACCOUNT_LOCKOUT_DURATION_MINUTES', 30)) ACCOUNT_LOCKOUT_DURATION_MINUTES = int(os.getenv("ACCOUNT_LOCKOUT_DURATION_MINUTES", 30))
# Redis 캐시 TTL 설정 (초 단위) # Redis 캐시 TTL 설정 (초 단위)
REDIS_USER_CACHE_TTL = int(os.getenv('REDIS_USER_CACHE_TTL', 300)) # 5분 REDIS_USER_CACHE_TTL = int(os.getenv("REDIS_USER_CACHE_TTL", 300)) # 5분
REDIS_JWT_BLACKLIST_TTL = int(os.getenv('REDIS_JWT_BLACKLIST_TTL', 86400)) # 24시간 REDIS_JWT_BLACKLIST_TTL = int(os.getenv("REDIS_JWT_BLACKLIST_TTL", 86400)) # 24시간

82
src/utils/decorators.py Normal file
View File

@@ -0,0 +1,82 @@
"""
데코레이터 유틸리티 모듈
"""
from functools import wraps
from typing import Callable
from flask import request, g
from flask_jwt_extended import get_jwt_identity, get_jwt
from utils.response import *
import settings
from extensions import custom_logger
logger = custom_logger(f"{settings.LOG_PREFIX}_decorators")
# Lazy 로딩
def _get_user_service():
from services.user.user_service import UserService
return UserService()
def _get_account_lock_service():
from services.auth.cache_service import AccountLockService
return AccountLockService
def _get_jwt_blacklist_service():
from services.auth.cache_service import JWTBlacklistService
return JWTBlacklistService
def load_current_user(f: Callable) -> Callable:
"""
JWT identity(user_uuid)를 기반으로 User 객체를 로드함.
g.current_user에 저장
Redis 캐시를 활용
JWT 블랙리스트 체크 (로그아웃된 토큰 방지)
"""
@wraps(f)
def decorated_function(*args, **kwargs):
try:
# JWT 페이로드 및 user_uuid 추출
jwt_payload = get_jwt()
user_uuid = get_jwt_identity()
jti = jwt_payload.get("jti")
if not user_uuid:
logger.warning("JWT에서 user_uuid를 찾을 수 없습니다.")
return unauthorized_response(message="토큰에서 사용자 정보를 찾을 수 없습니다.")
# JWT 블랙리스트 체크 (사용이 불가능한 토큰)
JWTBlacklistService = _get_jwt_blacklist_service()
if jti and JWTBlacklistService.is_blacklisted(jti):
logger.warning(f"블랙리스트된 토큰 접근 시도: jti={jti}, user_uuid={user_uuid}")
return unauthorized_response(message="사용이 불가능한 토큰입니다. 다시 로그인해주세요.")
# UserService를 통한 조회
UserService = _get_user_service()
user = UserService.get_user_by_uuid(user_uuid, use_cache=True)
if not user:
logger.warning(f"user_uuid에 해당하는 사용자를 찾을 수 없습니다: {user_uuid}")
return unauthorized_response(message="사용자를 찾을 수 없습니다.")
# 계정 잠금 체크 (Redis 기반)
AccountLockService = _get_account_lock_service()
if AccountLockService.is_locked(user_uuid):
logger.warning(f"잠긴 계정 접근 시도: {user_uuid}")
return unauthorized_response(message="계정이 잠겼습니다. 관리자에게 문의하세요.")
# DB의 로그인 실패 횟수가 최대 시도 횟수를 초과하면 Redis에 잠금 설정
if (user.count or 0) >= settings.MAX_LOGIN_ATTEMPTS:
AccountLockService.lock(user_uuid)
logger.warning(f"로그인 실패 횟수 초과로 계정 잠금: {user_uuid}")
return unauthorized_response(message="계정이 잠겼습니다. 관리자에게 문의하세요.")
# g에 현재 사용자 저장
g.current_user = user
return f(*args, **kwargs)
except Exception as e:
logger.exception(f"현재 사용자 로드에 실패하였습니다. error={str(e)}")
return error_response(message="현재 사용자 정보를 불러오는 데 실패하였습니다.")
return decorated_function

View File

@@ -87,7 +87,7 @@ logging.basicConfig(
# create own logger class to prevent init custom loggers by other libs # create own logger class to prevent init custom loggers by other libs
class GatekeeperLogger(logging.Logger): class GatekeeperLogger(logging.Logger):
def addHandler(self, h): def addHandler(self, h):
# only 'root', '__main__' and own loggers will be accepted # only "root", "__main__" and own loggers will be accepted
if self.name == "root" or self.name.startswith((settings.LOG_PREFIX, "__main__")): if self.name == "root" or self.name.startswith((settings.LOG_PREFIX, "__main__")):
return super().addHandler(h) return super().addHandler(h)
@@ -96,7 +96,7 @@ logging.setLoggerClass(GatekeeperLogger)
# Werkzeug 로거 설정 (Flask 개발 서버용) # Werkzeug 로거 설정 (Flask 개발 서버용)
# 기존 werkzeug 로거의 핸들러를 모두 제거하고 커스텀 포맷 적용 # 기존 werkzeug 로거의 핸들러를 모두 제거하고 커스텀 포맷 적용
import logging as _logging import logging as _logging
werkzeug_logger = _logging.getLogger('werkzeug') werkzeug_logger = _logging.getLogger("werkzeug")
werkzeug_logger.handlers = [] # 기존 핸들러 제거 werkzeug_logger.handlers = [] # 기존 핸들러 제거
werkzeug_logger.setLevel(settings.LOG_LEVEL_TEXT) werkzeug_logger.setLevel(settings.LOG_LEVEL_TEXT)
handler = _logging.StreamHandler(sys.stdout) handler = _logging.StreamHandler(sys.stdout)
@@ -105,7 +105,7 @@ werkzeug_logger.addHandler(handler)
werkzeug_logger.propagate = False werkzeug_logger.propagate = False
# Flask의 기본 로거도 동일하게 설정 # Flask의 기본 로거도 동일하게 설정
flask_logger = _logging.getLogger('flask.app') flask_logger = _logging.getLogger("flask.app")
flask_logger.handlers = [] flask_logger.handlers = []
flask_logger.setLevel(settings.LOG_LEVEL_TEXT) flask_logger.setLevel(settings.LOG_LEVEL_TEXT)
flask_handler = _logging.StreamHandler(sys.stdout) flask_handler = _logging.StreamHandler(sys.stdout)

View File

@@ -66,7 +66,7 @@ def error_response(message: str = "An error occurred", status_code: int = 400,
message, message,
message, message,
errors=errors, errors=errors,
error_code=error_code or HTTP_STATUS_CODES.get(status_code, 'UNKNOWN_ERROR') error_code=error_code or HTTP_STATUS_CODES.get(status_code, "UNKNOWN_ERROR")
) )
return (jsonify(body), status_code) if not _is_restx_context() else (body, status_code) return (jsonify(body), status_code) if not _is_restx_context() else (body, status_code)

View File

@@ -9,32 +9,33 @@ from flask_restx import Api
import constants import constants
# Swagger API Blueprint 및 설정 # Swagger API Blueprint 및 설정
api_blueprint = Blueprint('api', __name__) api_blueprint = Blueprint("api", __name__)
api = Api( api = Api(
api_blueprint, api_blueprint,
title='NuriQ API', title="NuriQ API",
version=constants.API_VERSION, version=constants.API_VERSION,
description='NuriQ REST API 문서', description="NuriQ REST API 문서",
doc='/docs', doc="/docs",
authorizations={ authorizations={
'Bearer': { "Bearer": {
'type': 'apiKey', "type": "apiKey",
'in': 'header', "in": "header",
'name': 'Authorization', "name": "Authorization",
'description': 'JWT 토큰 입력 (예: Bearer <token>)' "description": "JWT 토큰 입력 (예: Bearer <token>)"
} }
}, },
security='Bearer', security="Bearer",
validate=True validate=True
) )
# 네임 스페이스 정의 및 API 추가 # 네임 스페이스 정의 및 API 추가
auth_ns = api.namespace('auth', description='인증 API', path='/auth') auth_ns = api.namespace("auth", description="인증 API", path="/auth")
user_ns = api.namespace("users", description="유저 API", path="/users")
category_ns = api.namespace("categories", description="카테고리 API", path="/categories")
# 네임 스페이스 정의 및 API에 추가 # 네임 스페이스 정의 및 API에 추가
__all__ = [ __all__ = [
'api_blueprint', 'api', "api_blueprint", "api",
'auth_ns' "auth_ns", "user_ns"
] ]