Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions alembic/versions/19316f07bc9b_add_role_to_memeber.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Add role to memeber

Revision ID: 19316f07bc9b
Revises: a2d5453e6a58
Create Date: 2026-04-13 02:13:51.281738

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = '19316f07bc9b'
down_revision: Union[str, Sequence[str], None] = 'a2d5453e6a58'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('members', sa.Column('role', sa.Enum('user', 'admin', name='memberrole'), nullable=False))
# ### end Alembic commands ###


def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('members', 'role')
# ### end Alembic commands ###
13 changes: 13 additions & 0 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,19 @@ def LOCAL_DATABASE_URL(self) -> str:
# LOGGING
LOG_LEVEL: str = "INFO"

# SMTP Settings
SMTP_HOST: str = "smtp.gmail.com"
SMTP_PORT: int = 587
SMTP_USER: str = ""
SMTP_PASSWORD: str = ""
EMAIL_FROM: str = ""

# REDIS Settings
REDIS_HOST: str = "localhost"
REDIS_PORT: int = 6379
REDIS_PASSWORD: str | None = None
REDIS_DB: int = 0

# Oracle Cloud Storage
OCI_USER_OCID: str = ""
OCI_API_KEY_PATH: str = ""
Expand Down
6 changes: 5 additions & 1 deletion app/core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,8 @@ def forbidden(message="Forbidden"):

@staticmethod
def not_found(entity="resourse"):
return AppError(status.HTTP_404_NOT_FOUND, "NOT_FOUND", f"{entity}을(를) 찾을 수 없습니다.")
return AppError(status.HTTP_404_NOT_FOUND, "NOT_FOUND", f"{entity}을(를) 찾을 수 없습니다.")

@staticmethod
def internal_server_error(message="Internal Server Error"):
return AppError(status.HTTP_500_INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", message)
17 changes: 17 additions & 0 deletions app/core/redis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import redis.asyncio as redis
from app.core.config import settings

# Redis 클라이언트 설정
redis_client = redis.Redis(
host=settings.REDIS_HOST,
port=settings.REDIS_PORT,
password=settings.REDIS_PASSWORD,
db=settings.REDIS_DB,
decode_responses=True # 결과를 문자열로 받기 위해
)

async def get_redis():
"""
FastAPI 의존성 주입을 위한 redis 클라이언트 반환 함수
"""
return redis_client
14 changes: 13 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field, ConfigDict
from starlette.middleware.cors import CORSMiddleware
from starlette.responses import JSONResponse
from starlette.responses import JSONResponse, FileResponse

from app.core.config import settings
from app.core.exception_handler import register_exception_handlers
Expand Down Expand Up @@ -43,6 +44,17 @@ def create_app() -> FastAPI:
app.include_router(skill_router)
app.include_router(member_router)

# Static Files
app.mount("/static", StaticFiles(directory="app/static"), name="static")

@app.get("/")
async def read_index():
return FileResponse("app/static/index.html")

@app.get("/{page}.html")
async def read_html(page: str):
return FileResponse(f"app/static/{page}.html")

# Middleware
app.add_middleware(RequestIdMiddleware)

Expand Down
15 changes: 14 additions & 1 deletion app/modules/member/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from app.modules.member.models import Member
from app.modules.member.repository import MemberRepository
from app.modules.member.service import MemberService
from app.shared.enums import MemberRole


#전체 흐름
Expand Down Expand Up @@ -53,4 +54,16 @@ async def get_current_member(
except Exception as e:
raise AppError.unauthorized(f"인증 과정에서 오류가 발생했습니다.: {str(e)}")

CurrentMemberDep = Annotated[Member, Depends(get_current_member)]
CurrentMemberDep = Annotated[Member, Depends(get_current_member)]

async def get_current_admin(
current_member: CurrentMemberDep,
) -> Member:
"""
현재 로그인한 사용자가 관리자인지 확인하는 의존성 주입 함수
"""
if current_member.role != MemberRole.ADMIN:
raise AppError.forbidden("관리자 권한이 필요합니다.")
return current_member

AdminMemberDep = Annotated[Member, Depends(get_current_admin)]
14 changes: 14 additions & 0 deletions app/modules/member/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
from typing import TYPE_CHECKING
from uuid import UUID, uuid4

from sqlalchemy import Enum, Column
from sqlmodel import Field, Relationship

from app.shared.models.base import BaseModel
from app.shared.enums import MemberRole

if TYPE_CHECKING:
from app.modules.resource.models import Resource
Expand All @@ -24,6 +26,18 @@ class Member(BaseModel, table=True):
description="회원 고유키"
)

role: MemberRole = Field(
default=MemberRole.USER,
sa_column=Column(
Enum(
MemberRole,
name="memberrole",
values_callable=lambda x: [e.value for e in x],
),
nullable=False,
)
)

provider: str = Field(
nullable=False,
description="OAuth2 제공자"
Expand Down
3 changes: 0 additions & 3 deletions app/modules/member/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,3 @@ async def hard_delete(self, member: Member) -> None:
#지금까지 변경사항 DB에 반영해줘->진짜 삭제
await self.session.flush()
#refresh 대상 없음, None이기에 return도 X



131 changes: 125 additions & 6 deletions app/modules/member/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@
from fastapi import APIRouter, Path, Query, Depends, status
from fastapi.security import OAuth2PasswordRequestForm

from app.modules.member.dependencies import CurrentMemberDep, MemberServiceDep
from app.modules.member.schemas import MemberCreateIn, MemberOut, MemberUpdateIn, TokenOut, RefreshTokenIn
from app.modules.member.dependencies import CurrentMemberDep, MemberServiceDep, AdminMemberDep
from app.modules.member.schemas import (
MemberCreateIn, MemberOut, MemberUpdateIn, TokenOut, RefreshTokenIn,
PasswordResetRequestIn, PasswordResetConfirmIn,
SignupVerifyRequestIn, SignupVerifyConfirmIn, MemberRoleUpdateIn
)
from app.shared.schemas import ApiResponse, PageOut


Expand All @@ -24,6 +28,7 @@
)
async def list_members(
service: MemberServiceDep,
admin: AdminMemberDep,
keyword: Annotated[str | None, Query(description="검색 키워드", example="수진")] = None,
#ge: 이상, le: 이하
page: Annotated[int,Query(ge=1, description="페이지 번호")] = 1,
Expand Down Expand Up @@ -53,6 +58,21 @@ async def list_members(
),
)

@router.get(
path="/me",
response_model=ApiResponse[MemberOut],
summary="내 정보 조회",
description="현재 로그인한 회원의 정보를 조회합니다."
)
async def get_my_info(
current_member: CurrentMemberDep,
):
return ApiResponse.success(
code="MEMBER_FETCHED",
message="내 정보 조회 성공",
data=MemberOut.model_validate(current_member)
)

#path: 경로, response_model: Swagger에서 보여줄 응답 예시 형태
#summary: Swagger에서 보여줄 간단한 API 설명
#description: Swagger에서 보여줄 상세한 API 설명
Expand Down Expand Up @@ -80,7 +100,7 @@ async def get_member(
)

@router.post(
path="",
path="/signup",
response_model=ApiResponse[MemberOut],
status_code=status.HTTP_201_CREATED,
summary="회원 생성",
Expand Down Expand Up @@ -118,7 +138,7 @@ async def update_member(
#DB 수정 → 수정된 객체 받음
updated = await service.update(
target_member_id=member_id,
actor_member_id=current_member.id,
actor=current_member,
data=data
)
return ApiResponse.success(
Expand All @@ -130,6 +150,26 @@ async def update_member(
data=MemberOut.model_validate(updated),
#응답용 데이터로 변환 (필터링 + 검증)
)

@router.patch(
path="/{member_id}/role",
response_model=ApiResponse[MemberOut],
summary="회원 권한 수정",
description="관리자가 특정 회원의 권한을 수정합니다."
)
async def update_member_role(
service: MemberServiceDep,
admin: AdminMemberDep,
member_id: Annotated[UUID, Path(description="권한을 수정할 member ID")],
data: MemberRoleUpdateIn,
):
updated = await service.update_role(member_id=member_id, role=data.role)
return ApiResponse.success(
code="MEMBER_ROLE_UPDATED",
message="회원 권한 수정 성공",
data=MemberOut.model_validate(updated)
)

#model_dump()와 model_validated() 차이점
#model_dump(): Pydantic → dict(Json 형태)
#언제 할까?: DB에 넣을 때, PATCH 할 때 (exclude_unset=True), JSON 응답 만들 때
Expand All @@ -152,7 +192,7 @@ async def delete_member(
#router는 요청 받기만 하고, Service가 진짜 일함
await service.delete(
target_member_id=member_id,
actor_member_id=current_member.id,
actor=current_member,
hard=hard
)
return ApiResponse.success(
Expand Down Expand Up @@ -210,4 +250,83 @@ async def reissue_refresh_token(
data: RefreshTokenIn,
):
tokens = await service.reissue(data.refresh_token)
return TokenOut(**tokens)
return TokenOut(**tokens)

# 회원가입 이메일 인증 요청 (인증 코드 발송)
@router.post(
path="/signup/verify/request",
response_model=ApiResponse[None],
summary="회원가입 이메일 인증 요청",
description="회원가입 전 이메일 중복 확인 및 인증 코드를 발송합니다."
)
async def request_signup_verification(
service: MemberServiceDep,
data: SignupVerifyRequestIn,
):
await service.request_signup_verification(data.email)
return ApiResponse.success(
code="SIGNUP_VERIFY_CODE_SENT",
message="인증 코드가 이메일로 발송되었습니다.",
data=None
)

# 회원가입 이메일 인증 확인 (코드 검증)
@router.post(
path="/signup/verify/confirm",
response_model=ApiResponse[None],
summary="회원가입 이메일 인증 확인",
description="인증 코드를 검증하고 회원가입 가능한 상태로 표시합니다."
)
async def confirm_signup_verification(
service: MemberServiceDep,
data: SignupVerifyConfirmIn,
):
await service.confirm_signup_verification(
email=data.email,
code=data.code
)
return ApiResponse.success(
code="SIGNUP_VERIFY_SUCCESS",
message="이메일 인증이 완료되었습니다.",
data=None
)

# 비밀번호 재설정 요청 (인증 코드 이메일 발송)
@router.post(
path="/password/reset/request",
response_model=ApiResponse[None],
summary="비밀번호 재설정 요청",
description="이메일로 6자리 인증 코드를 발송합니다."
)
async def request_password_reset(
service: MemberServiceDep,
data: PasswordResetRequestIn,
):
await service.request_password_reset(data.email)
return ApiResponse.success(
code="PASSWORD_RESET_CODE_SENT",
message="인증 코드가 이메일로 발송되었습니다.",
data=None
)

# 비밀번호 재설정 확정 (코드 검증 + 새 비밀번호 설정)
@router.post(
path="/password/reset/confirm",
response_model=ApiResponse[None],
summary="비밀번호 재설정 확정",
description="이메일과 인증 코드, 새 비밀번호를 받아 비밀번호를 변경합니다."
)
async def confirm_password_reset(
service: MemberServiceDep,
data: PasswordResetConfirmIn,
):
await service.confirm_password_reset(
email=data.email,
code=data.code,
new_password=data.new_password
)
return ApiResponse.success(
code="PASSWORD_RESET_SUCCESS",
message="비밀번호가 성공적으로 변경되었습니다.",
data=None
)
Loading
Loading