Compare commits
4 Commits
782d858acb
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d4cc83a97 | |||
| c9bfa81305 | |||
| fc67076e80 | |||
| abf405f8ae |
@@ -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"]
|
||||||
@@ -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):
|
||||||
"""로그인"""
|
"""로그인"""
|
||||||
|
|||||||
142
src/blueprints/bp_category.py
Normal file
142
src/blueprints/bp_category.py
Normal 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
|
||||||
|
)
|
||||||
@@ -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="회원 탈퇴가 완료되었습니다."
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
|
||||||
]
|
]
|
||||||
63
src/models/category_model/category.py
Normal file
63
src/models/category_model/category.py
Normal 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}'
|
||||||
23
src/models/category_model/category_dto.py
Normal file
23
src/models/category_model/category_dto.py
Normal 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
|
||||||
16
src/models/category_model/category_swagger.py
Normal file
16
src/models/category_model/category_swagger.py
Normal 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 관련 주제입니다.")
|
||||||
|
})
|
||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
# 자동 형변환 방지
|
# 자동 형변환 방지
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
11
src/models/user_model/user_swagger.py
Normal file
11
src/models/user_model/user_swagger.py
Normal 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="홍길동")
|
||||||
|
})
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
]
|
]
|
||||||
@@ -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
|
||||||
36
src/repositories/category/category_repository.py
Normal file
36
src/repositories/category/category_repository.py
Normal 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()
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
122
src/services/category/category_service.py
Normal file
122
src/services/category/category_service.py
Normal 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)
|
||||||
@@ -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
|
||||||
@@ -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
82
src/utils/decorators.py
Normal 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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
]
|
]
|
||||||
Reference in New Issue
Block a user