From 27e34cdca2393e7bb124e7fbf2baaf61535edab6 Mon Sep 17 00:00:00 2001 From: ssarisong Date: Fri, 3 Apr 2026 01:31:40 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat(member):=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=9E=AC=EC=84=A4=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/modules/member/repository.py | 6 ++++ app/modules/member/router.py | 54 ++++++++++++++++++++++++++++++-- app/modules/member/schemas.py | 13 ++++++-- app/modules/member/service.py | 33 +++++++++++++++++++ 4 files changed, 102 insertions(+), 4 deletions(-) diff --git a/app/modules/member/repository.py b/app/modules/member/repository.py index 6da504a..39e9a6d 100644 --- a/app/modules/member/repository.py +++ b/app/modules/member/repository.py @@ -143,5 +143,11 @@ async def hard_delete(self, member: Member) -> None: await self.session.flush() #refresh 대상 없음, None이기에 return도 X + async def update_password(self, member: Member, hashed_password: str) -> Member: + member.password = hashed_password + await self.session.flush() + await self.session.refresh(member) + return member + diff --git a/app/modules/member/router.py b/app/modules/member/router.py index 7243397..6a6d34a 100644 --- a/app/modules/member/router.py +++ b/app/modules/member/router.py @@ -6,7 +6,8 @@ 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.schemas import MemberCreateIn, MemberOut, MemberUpdateIn, TokenOut, RefreshTokenIn, \ + PasswordRequestIn, PasswordTokenIn from app.shared.schemas import ApiResponse, PageOut @@ -210,4 +211,53 @@ async def reissue_refresh_token( data: RefreshTokenIn, ): tokens = await service.reissue(data.refresh_token) - return TokenOut(**tokens) \ No newline at end of file + return TokenOut(**tokens) + +#비밀번호 재설정 요청(이메일 받는 용) +#1. 클라이언트에 요청 +@router.post( + path="/password/reset/request", + response_model=ApiResponse[None], + summary="비밀번호 재설정 요청", +) +#2. DTO 검증 +#이메일 형식 검증, 잘못된 형식이면 에러 +async def request_password_reset( + service: MemberServiceDep, + data: PasswordRequestIn, +): + #3. 서비스 호출 + #이메일로 사용자 조회 -> 존재하면 JWT reset token 생성, 없으면 OK 반환 + token = await service.reset(data.email) + + return ApiResponse.success( + code="PASSWORD_RESET_REQUESTED", + message="비밀번호 재설정 요청 성공", + data={ "token": token } + ) + +#비밀번호 재설정 확정(token + new_password) +#1. 클라이언트 요청 +@router.post( + path="/password/reset", + response_model=ApiResponse[None], + summary="비밀번호 재설정 완료", +) +#2. DTO 검증 +#token 문자열, new_password 존재 확인 +async def reset_password( + service: MemberServiceDep, + data: PasswordTokenIn, +): + #3. service 호출 + #내부에서 token decode, type 검증(password_reset), 사용자 조회, 비밀번호 해싱, repository 호출 + await service.reset( + token=data.token, + new_password=data.new_password + ) + + return ApiResponse.success( + code="PASSWORD_RESET_SUCCESS", + message="비밀번호 재설정 성공", + data=None + ) \ No newline at end of file diff --git a/app/modules/member/schemas.py b/app/modules/member/schemas.py index 9d20d2d..1a7d427 100644 --- a/app/modules/member/schemas.py +++ b/app/modules/member/schemas.py @@ -121,7 +121,7 @@ class TokenOut(SQLModel): token_type: str = Field(default="Bearer", description="토큰 타입") model_config = ConfigDict( - json_schema_extra={ + json_schema_extra= { "example": { "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", @@ -140,4 +140,13 @@ class RefreshTokenIn(SQLModel): "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } } - } \ No newline at end of file + } + +#비밀번호 재설정 요청 +class PasswordRequestIn(SQLModel): + email: EmailStr = Field(description="비밀번호 재설정 요청 이메일") + +#비밀번호 재설정 토큰 +class PasswordTokenIn(SQLModel): + token: str = Field(description="비밀번호 재설정 토큰") + new_password: str = Field(description="새 비밀번호") \ No newline at end of file diff --git a/app/modules/member/service.py b/app/modules/member/service.py index 27a5e01..27b1d89 100644 --- a/app/modules/member/service.py +++ b/app/modules/member/service.py @@ -285,3 +285,36 @@ async def reissue(self, refresh_token: str) -> dict[str, str]: except Exception as e: raise AppError.unauthorized(f"토큰 재발급 과정에서 오류가 발생했습니다.: {str(e)}") + #비밀번호 재설정 함수 + #토큰 검증 + 사용자 확인 + 비밀번호 변경 전부 처리 + #전체 흐름: 토큰 검증 -> 사용자 확인 -> 비밀번호 해싱 -> DB 업데이트 + async def reset(self, token: str, new_password: str) -> None: + payload = decode_token(token) #JWT 복호화해서 payload 꺼냄 + #payload안에는 sub(사용자 ID), type(토큰 용도), exp(만료시간) 정보가 있음 + + #비밀번호 재설정용 토큰인지 검증 + #다른 토큰 들어오는 것 방지(access token, refresh token 등) + if payload.get("type") != "password_reset": + raise AppError.unauthorized("올바르지 않은 토큰입니다.") + + #사용자 ID 추출 + #JWT의 subject에서 사용자 식별값 가져옴 + #없으면 잘못된 토큰이므로 예외처리함. + member_id = payload.get("sub") + if not member_id: + raise AppError.unauthorized("토큰 정보가 올바르지 않습니다.") + + #실제 DB에 존재하는 사용자인지 확인 + #토큰이 유효해도 계정이 삭제되었을 수 있어서 한 번 더 검증 + member = await self.repository.get_by_id(UUID(member_id)) + + if not member: + raise AppError.not_found("사용자를 찾을 수 없습니다.") + + #새 비밀번호도 해싱하기 + hashed = password_hash(new_password) + + #실제 DB 반영 + #member 객체의 password를 변경 + #flush, refresh 포함해서 저장 + await self.repository.update_password(member, hashed) \ No newline at end of file From 8758b21feee019a1dc1ff68143f9838d2066a587 Mon Sep 17 00:00:00 2001 From: KIMB0B Date: Mon, 13 Apr 2026 00:05:28 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat(member):=20=EC=9D=B4=EB=A9=94=EC=9D=BC?= =?UTF-8?q?=20=EC=A0=84=EC=86=A1=20=EC=9C=A0=ED=8B=B8=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/config.py | 13 +++++ app/core/redis.py | 17 ++++++ app/modules/member/repository.py | 9 ---- app/modules/member/router.py | 4 +- app/modules/member/service.py | 10 +++- app/shared/utils/email.py | 88 ++++++++++++++++++++++++++++++++ poetry.lock | 37 +++++++++++++- pyproject.toml | 4 +- 8 files changed, 167 insertions(+), 15 deletions(-) create mode 100644 app/core/redis.py create mode 100644 app/shared/utils/email.py diff --git a/app/core/config.py b/app/core/config.py index 4b4d587..8865dd9 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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 = "" diff --git a/app/core/redis.py b/app/core/redis.py new file mode 100644 index 0000000..70cd1c2 --- /dev/null +++ b/app/core/redis.py @@ -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 diff --git a/app/modules/member/repository.py b/app/modules/member/repository.py index 39e9a6d..1e3f962 100644 --- a/app/modules/member/repository.py +++ b/app/modules/member/repository.py @@ -142,12 +142,3 @@ async def hard_delete(self, member: Member) -> None: #지금까지 변경사항 DB에 반영해줘->진짜 삭제 await self.session.flush() #refresh 대상 없음, None이기에 return도 X - - async def update_password(self, member: Member, hashed_password: str) -> Member: - member.password = hashed_password - await self.session.flush() - await self.session.refresh(member) - return member - - - diff --git a/app/modules/member/router.py b/app/modules/member/router.py index 6a6d34a..c4969b9 100644 --- a/app/modules/member/router.py +++ b/app/modules/member/router.py @@ -228,7 +228,7 @@ async def request_password_reset( ): #3. 서비스 호출 #이메일로 사용자 조회 -> 존재하면 JWT reset token 생성, 없으면 OK 반환 - token = await service.reset(data.email) + token = await service.reset_password(data.email) return ApiResponse.success( code="PASSWORD_RESET_REQUESTED", @@ -251,7 +251,7 @@ async def reset_password( ): #3. service 호출 #내부에서 token decode, type 검증(password_reset), 사용자 조회, 비밀번호 해싱, repository 호출 - await service.reset( + await service.reset_password( token=data.token, new_password=data.new_password ) diff --git a/app/modules/member/service.py b/app/modules/member/service.py index 27b1d89..536edba 100644 --- a/app/modules/member/service.py +++ b/app/modules/member/service.py @@ -288,7 +288,7 @@ async def reissue(self, refresh_token: str) -> dict[str, str]: #비밀번호 재설정 함수 #토큰 검증 + 사용자 확인 + 비밀번호 변경 전부 처리 #전체 흐름: 토큰 검증 -> 사용자 확인 -> 비밀번호 해싱 -> DB 업데이트 - async def reset(self, token: str, new_password: str) -> None: + async def reset_password(self, token: str, new_password: str) -> None: payload = decode_token(token) #JWT 복호화해서 payload 꺼냄 #payload안에는 sub(사용자 ID), type(토큰 용도), exp(만료시간) 정보가 있음 @@ -313,8 +313,14 @@ async def reset(self, token: str, new_password: str) -> None: #새 비밀번호도 해싱하기 hashed = password_hash(new_password) + member.hashed_password = hashed #실제 DB 반영 #member 객체의 password를 변경 #flush, refresh 포함해서 저장 - await self.repository.update_password(member, hashed) \ No newline at end of file + try: + await self.repository.save(member) + await self.session.commit() + except IntegrityError: + await self.session.rollback() + raise AppError.bad_request("비밀번호 변경 중 오류가 발생했습니다.") \ No newline at end of file diff --git a/app/shared/utils/email.py b/app/shared/utils/email.py new file mode 100644 index 0000000..8c9ae42 --- /dev/null +++ b/app/shared/utils/email.py @@ -0,0 +1,88 @@ +import random +import string +import aiosmtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from app.core.config import settings +from app.core.redis import redis_client + +async def send_email(subject: str, recipient: str, body: str): + """ + aiosmtplib를 사용하여 이메일을 비동기로 발송합니다. + """ + if not settings.SMTP_USER or not settings.SMTP_PASSWORD: + print(f"SMTP 설정이 없어 이메일을 발송하지 않았습니다. [To: {recipient}, Code/Body: {body}]") + return + + message = MIMEMultipart() + message["From"] = settings.EMAIL_FROM + message["To"] = recipient + message["Subject"] = subject + + # HTML 형식으로 보낼 수도 있으므로 plain 대신 html을 선택적으로 사용할 수 있게 확장 가능 + message.attach(MIMEText(body, "plain")) + + try: + await aiosmtplib.send( + message, + hostname=settings.SMTP_HOST, + port=settings.SMTP_PORT, + username=settings.SMTP_USER, + password=settings.SMTP_PASSWORD, + start_tls=True, + ) + except Exception as e: + print(f"이메일 발송 중 오류 발생: {str(e)}") + # 실제 운영 환경에서는 로거를 사용하세요. + +def generate_verification_code(length: int = 6) -> str: + """ + 지정된 길이의 랜덤 숫자 코드를 생성합니다. + """ + return "".join(random.choices(string.digits, k=length)) + +async def send_verification_email( + email: str, + code: str, + purpose: str = "signup", + custom_message: str | None = None +): + """ + 인증 코드를 포함한 이메일을 발송하고 Redis에 저장합니다. + + :param email: 수신자 이메일 + :param code: 6자리 인증 코드 + :param purpose: 인증 목적 (예: signup, reset_password) + :param custom_message: 이메일 본문에 포함할 커스텀 메시지 + """ + # Redis에 저장 (key format: "verify:{purpose}:{email}", TTL: 5분(300초)) + redis_key = f"verify:{purpose}:{email}" + await redis_client.setex(redis_key, 300, code) + + # 이메일 제목 및 본문 설정 + subjects = { + "signup": "[Teampling] 회원가입 인증 코드입니다.", + "reset_password": "[Teampling] 비밀번호 재설정 인증 코드입니다.", + "common": "[Teampling] 인증 코드입니다." + } + subject = subjects.get(purpose, subjects["common"]) + + if custom_message: + body = f"{custom_message}\n\n인증 코드: {code}\n\n이 코드는 5분 동안 유효합니다." + else: + body = f"요청하신 인증 코드는 다음과 같습니다.\n\n인증 코드: {code}\n\n이 코드는 5분 동안 유효합니다." + + await send_email(subject, email, body) + +async def verify_code(email: str, code: str, purpose: str = "signup") -> bool: + """ + Redis에 저장된 코드와 입력된 코드가 일치하는지 확인합니다. + """ + redis_key = f"verify:{purpose}:{email}" + stored_code = await redis_client.get(redis_key) + + if stored_code and stored_code == code: + # 인증 성공 시 코드 삭제 (1회용 인증) + await redis_client.delete(redis_key) + return True + return False diff --git a/poetry.lock b/poetry.lock index 46fa362..998a03a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,21 @@ # This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. +[[package]] +name = "aiosmtplib" +version = "3.0.2" +description = "asyncio SMTP client" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "aiosmtplib-3.0.2-py3-none-any.whl", hash = "sha256:8783059603a34834c7c90ca51103c3aa129d5922003b5ce98dbaa6d4440f10fc"}, + {file = "aiosmtplib-3.0.2.tar.gz", hash = "sha256:08fd840f9dbc23258025dca229e8a8f04d2ccf3ecb1319585615bfc7933f7f47"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "sphinx (>=7.0.0)", "sphinx-autodoc-typehints (>=1.24.0)", "sphinx-copybutton (>=0.5.0)"] +uvloop = ["uvloop (>=0.18)"] + [[package]] name = "alembic" version = "1.18.4" @@ -1305,6 +1321,25 @@ files = [ {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, ] +[[package]] +name = "redis" +version = "5.3.1" +description = "Python client for Redis database and key-value store" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "redis-5.3.1-py3-none-any.whl", hash = "sha256:dc1909bd24669cc31b5f67a039700b16ec30571096c5f1f0d9d2324bff31af97"}, + {file = "redis-5.3.1.tar.gz", hash = "sha256:ca49577a531ea64039b5a36db3d6cd1a0c7a60c34124d46924a45b956e8cf14c"}, +] + +[package.dependencies] +PyJWT = ">=2.9.0" + +[package.extras] +hiredis = ["hiredis (>=3.0.0)"] +ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==23.2.1)", "requests (>=2.31.0)"] + [[package]] name = "setuptools" version = "82.0.1" @@ -1824,4 +1859,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">=3.12" -content-hash = "604181dac3e1ff93666ddd714e85dbe4f82fdbdb79fdc58f0051221e8933e441" +content-hash = "e3690eac8a6395d48c305a9250795be343280d38e40b7b582ffc8d0a5c04c546" diff --git a/pyproject.toml b/pyproject.toml index b13f04c..b700a8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,9 @@ dependencies = [ "pyjwt (>=2.12.1,<3.0.0)", "passlib[bcrypt] (>=1.7.4,<2.0.0)", "bcrypt (<5)", - "oci (>=2.168.1,<3.0.0)" + "oci (>=2.168.1,<3.0.0)", + "aiosmtplib (>=3.0.0,<4.0.0)", + "redis (>=5.0.0,<6.0.0)" ] From b34becff692bd935f2eab8c75243916cd72214ae Mon Sep 17 00:00:00 2001 From: KIMB0B Date: Mon, 13 Apr 2026 00:46:20 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat(member):=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=9E=AC=EC=84=A4=EC=A0=95=20=EB=A9=94=EC=BB=A4?= =?UTF-8?q?=EB=8B=88=EC=A6=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/exceptions.py | 6 +++- app/modules/member/router.py | 49 +++++++++++++---------------- app/modules/member/schemas.py | 31 +++++++++++++++---- app/modules/member/service.py | 58 +++++++++++++++++------------------ docker-compose.prod.yaml | 17 +++++++++- docker-compose.yaml | 18 ++++++++++- 6 files changed, 112 insertions(+), 67 deletions(-) diff --git a/app/core/exceptions.py b/app/core/exceptions.py index 9370c49..aa96d7c 100644 --- a/app/core/exceptions.py +++ b/app/core/exceptions.py @@ -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}을(를) 찾을 수 없습니다.") \ No newline at end of file + 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) \ No newline at end of file diff --git a/app/modules/member/router.py b/app/modules/member/router.py index c4969b9..82d95a4 100644 --- a/app/modules/member/router.py +++ b/app/modules/member/router.py @@ -6,8 +6,10 @@ from fastapi.security import OAuth2PasswordRequestForm from app.modules.member.dependencies import CurrentMemberDep, MemberServiceDep -from app.modules.member.schemas import MemberCreateIn, MemberOut, MemberUpdateIn, TokenOut, RefreshTokenIn, \ - PasswordRequestIn, PasswordTokenIn +from app.modules.member.schemas import ( + MemberCreateIn, MemberOut, MemberUpdateIn, TokenOut, RefreshTokenIn, + PasswordResetRequestIn, PasswordResetConfirmIn +) from app.shared.schemas import ApiResponse, PageOut @@ -213,51 +215,42 @@ async def reissue_refresh_token( tokens = await service.reissue(data.refresh_token) return TokenOut(**tokens) -#비밀번호 재설정 요청(이메일 받는 용) -#1. 클라이언트에 요청 +# 비밀번호 재설정 요청 (인증 코드 이메일 발송) @router.post( path="/password/reset/request", response_model=ApiResponse[None], summary="비밀번호 재설정 요청", + description="이메일로 6자리 인증 코드를 발송합니다." ) -#2. DTO 검증 -#이메일 형식 검증, 잘못된 형식이면 에러 async def request_password_reset( service: MemberServiceDep, - data: PasswordRequestIn, + data: PasswordResetRequestIn, ): - #3. 서비스 호출 - #이메일로 사용자 조회 -> 존재하면 JWT reset token 생성, 없으면 OK 반환 - token = await service.reset_password(data.email) - + await service.request_password_reset(data.email) return ApiResponse.success( - code="PASSWORD_RESET_REQUESTED", - message="비밀번호 재설정 요청 성공", - data={ "token": token } + code="PASSWORD_RESET_CODE_SENT", + message="인증 코드가 이메일로 발송되었습니다.", + data=None ) -#비밀번호 재설정 확정(token + new_password) -#1. 클라이언트 요청 +# 비밀번호 재설정 확정 (코드 검증 + 새 비밀번호 설정) @router.post( - path="/password/reset", + path="/password/reset/confirm", response_model=ApiResponse[None], - summary="비밀번호 재설정 완료", + summary="비밀번호 재설정 확정", + description="이메일과 인증 코드, 새 비밀번호를 받아 비밀번호를 변경합니다." ) -#2. DTO 검증 -#token 문자열, new_password 존재 확인 -async def reset_password( +async def confirm_password_reset( service: MemberServiceDep, - data: PasswordTokenIn, + data: PasswordResetConfirmIn, ): - #3. service 호출 - #내부에서 token decode, type 검증(password_reset), 사용자 조회, 비밀번호 해싱, repository 호출 - await service.reset_password( - token=data.token, + await service.confirm_password_reset( + email=data.email, + code=data.code, new_password=data.new_password ) - return ApiResponse.success( code="PASSWORD_RESET_SUCCESS", - message="비밀번호 재설정 성공", + message="비밀번호가 성공적으로 변경되었습니다.", data=None ) \ No newline at end of file diff --git a/app/modules/member/schemas.py b/app/modules/member/schemas.py index 1a7d427..835c98d 100644 --- a/app/modules/member/schemas.py +++ b/app/modules/member/schemas.py @@ -142,11 +142,30 @@ class RefreshTokenIn(SQLModel): } } -#비밀번호 재설정 요청 -class PasswordRequestIn(SQLModel): +#비밀번호 재설정 요청 (이메일 입력) +class PasswordResetRequestIn(SQLModel): email: EmailStr = Field(description="비밀번호 재설정 요청 이메일") -#비밀번호 재설정 토큰 -class PasswordTokenIn(SQLModel): - token: str = Field(description="비밀번호 재설정 토큰") - new_password: str = Field(description="새 비밀번호") \ No newline at end of file + model_config = { + "json_schema_extra": { + "example": { + "email": "test@example.com" + } + } + } + +#비밀번호 재설정 확인 (이메일 + 인증코드 + 새 비밀번호) +class PasswordResetConfirmIn(SQLModel): + email: EmailStr = Field(description="이메일") + code: str = Field(..., min_length=6, max_length=6, description="6자리 인증 코드") + new_password: str = Field(..., min_length=8, description="새 비밀번호") + + model_config = { + "json_schema_extra": { + "example": { + "email": "test@example.com", + "code": "123456", + "new_password": "newpassword123!" + } + } + } \ No newline at end of file diff --git a/app/modules/member/service.py b/app/modules/member/service.py index 536edba..d284407 100644 --- a/app/modules/member/service.py +++ b/app/modules/member/service.py @@ -7,6 +7,7 @@ from app.core.exceptions import AppError from app.core.security import password_hash, verify_password, create_access_token, create_refresh_token, decode_token +from app.shared.utils.email import generate_verification_code, send_verification_email, verify_code from app.modules.member.models import Member from app.modules.member.repository import MemberRepository from app.modules.member.schemas import MemberCreateIn, MemberUpdateIn @@ -285,42 +286,39 @@ async def reissue(self, refresh_token: str) -> dict[str, str]: except Exception as e: raise AppError.unauthorized(f"토큰 재발급 과정에서 오류가 발생했습니다.: {str(e)}") - #비밀번호 재설정 함수 - #토큰 검증 + 사용자 확인 + 비밀번호 변경 전부 처리 - #전체 흐름: 토큰 검증 -> 사용자 확인 -> 비밀번호 해싱 -> DB 업데이트 - async def reset_password(self, token: str, new_password: str) -> None: - payload = decode_token(token) #JWT 복호화해서 payload 꺼냄 - #payload안에는 sub(사용자 ID), type(토큰 용도), exp(만료시간) 정보가 있음 - - #비밀번호 재설정용 토큰인지 검증 - #다른 토큰 들어오는 것 방지(access token, refresh token 등) - if payload.get("type") != "password_reset": - raise AppError.unauthorized("올바르지 않은 토큰입니다.") - - #사용자 ID 추출 - #JWT의 subject에서 사용자 식별값 가져옴 - #없으면 잘못된 토큰이므로 예외처리함. - member_id = payload.get("sub") - if not member_id: - raise AppError.unauthorized("토큰 정보가 올바르지 않습니다.") - - #실제 DB에 존재하는 사용자인지 확인 - #토큰이 유효해도 계정이 삭제되었을 수 있어서 한 번 더 검증 - member = await self.repository.get_by_id(UUID(member_id)) + # [1단계] 비밀번호 재설정 요청: 인증 코드 발송 + async def request_password_reset(self, email: str) -> None: + member = await self.repository.get_by_email(email) + # 보안상 이메일이 없어도 오류를 내지 않고 발송 완료 메시지만 띄우는 것이 일반적입니다. + if not member: + return + + code = generate_verification_code() + await send_verification_email( + email=email, + code=code, + purpose="reset_password", + custom_message="비밀번호 재설정 인증 코드입니다." + ) + + # [2단계] 코드 검증 및 실제 비밀번호 변경 + async def confirm_password_reset(self, email: str, code: str, new_password: str) -> None: + # 1. 코드 검증 (Redis 확인 및 성공 시 자동 삭제) + is_valid = await verify_code(email, code, purpose="reset_password") + if not is_valid: + raise AppError.bad_request("인증 코드가 틀렸거나 만료되었습니다.") + # 2. 사용자 확인 + member = await self.repository.get_by_email(email) if not member: raise AppError.not_found("사용자를 찾을 수 없습니다.") - #새 비밀번호도 해싱하기 - hashed = password_hash(new_password) - member.hashed_password = hashed + # 3. 비밀번호 업데이트 (해싱 후 저장) + member.hashed_password = password_hash(new_password) - #실제 DB 반영 - #member 객체의 password를 변경 - #flush, refresh 포함해서 저장 try: await self.repository.save(member) await self.session.commit() - except IntegrityError: + except Exception: await self.session.rollback() - raise AppError.bad_request("비밀번호 변경 중 오류가 발생했습니다.") \ No newline at end of file + raise AppError.internal_server_error("비밀번호 변경 중 오류가 발생했습니다.") \ No newline at end of file diff --git a/docker-compose.prod.yaml b/docker-compose.prod.yaml index ee5f05b..343e0a1 100644 --- a/docker-compose.prod.yaml +++ b/docker-compose.prod.yaml @@ -15,6 +15,18 @@ services: retries: 10 restart: always + redis: + image: redis:7-alpine + container_name: teampling-redis + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 10 + restart: always + app: image: ghcr.io/teampling/backend_main/backend:${IMAGE_TAG} container_name: teampling-app @@ -23,6 +35,8 @@ services: depends_on: db: condition: service_healthy + redis: + condition: service_healthy expose: - "8000" restart: always @@ -42,4 +56,5 @@ services: restart: always volumes: - postgres_data: \ No newline at end of file + postgres_data: + redis_data: \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index af26851..68bc052 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -17,6 +17,19 @@ services: timeout: 3s retries: 20 + redis: + image: redis:7-alpine + restart: unless-stopped + ports: + - "6379:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 20 + api: build: context: . @@ -26,6 +39,8 @@ services: depends_on: db: condition: service_healthy + redis: + condition: service_healthy ports: - "8000:8000" volumes: @@ -37,4 +52,5 @@ services: --reload volumes: - pgdata: \ No newline at end of file + pgdata: + redis_data: \ No newline at end of file From 88977f665857b24ddeff77077aa257c7da6ff8bd Mon Sep 17 00:00:00 2001 From: KIMB0B Date: Mon, 13 Apr 2026 00:58:34 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat(member):=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=8B=9C=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EB=B3=B8?= =?UTF-8?q?=EC=9D=B8=EC=9D=B8=EC=A6=9D=20=EA=B3=BC=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/modules/member/router.py | 42 ++++++++++++++++++++++++++++++++++- app/modules/member/schemas.py | 26 ++++++++++++++++++++++ app/modules/member/service.py | 33 +++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 1 deletion(-) diff --git a/app/modules/member/router.py b/app/modules/member/router.py index 82d95a4..d4c883b 100644 --- a/app/modules/member/router.py +++ b/app/modules/member/router.py @@ -8,7 +8,8 @@ from app.modules.member.dependencies import CurrentMemberDep, MemberServiceDep from app.modules.member.schemas import ( MemberCreateIn, MemberOut, MemberUpdateIn, TokenOut, RefreshTokenIn, - PasswordResetRequestIn, PasswordResetConfirmIn + PasswordResetRequestIn, PasswordResetConfirmIn, + SignupVerifyRequestIn, SignupVerifyConfirmIn ) from app.shared.schemas import ApiResponse, PageOut @@ -215,6 +216,45 @@ async def reissue_refresh_token( tokens = await service.reissue(data.refresh_token) 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", diff --git a/app/modules/member/schemas.py b/app/modules/member/schemas.py index 835c98d..2eca2f0 100644 --- a/app/modules/member/schemas.py +++ b/app/modules/member/schemas.py @@ -142,6 +142,32 @@ class RefreshTokenIn(SQLModel): } } +#회원가입 이메일 인증 요청 (이메일 입력) +class SignupVerifyRequestIn(SQLModel): + email: EmailStr = Field(description="인증할 이메일") + + model_config = { + "json_schema_extra": { + "example": { + "email": "newuser@example.com" + } + } + } + +#회원가입 이메일 인증 확인 (이메일 + 인증코드) +class SignupVerifyConfirmIn(SQLModel): + email: EmailStr = Field(description="이메일") + code: str = Field(..., min_length=6, max_length=6, description="6자리 인증 코드") + + model_config = { + "json_schema_extra": { + "example": { + "email": "newuser@example.com", + "code": "123456" + } + } + } + #비밀번호 재설정 요청 (이메일 입력) class PasswordResetRequestIn(SQLModel): email: EmailStr = Field(description="비밀번호 재설정 요청 이메일") diff --git a/app/modules/member/service.py b/app/modules/member/service.py index d284407..fbf409c 100644 --- a/app/modules/member/service.py +++ b/app/modules/member/service.py @@ -8,6 +8,7 @@ from app.core.exceptions import AppError from app.core.security import password_hash, verify_password, create_access_token, create_refresh_token, decode_token from app.shared.utils.email import generate_verification_code, send_verification_email, verify_code +from app.core.redis import redis_client from app.modules.member.models import Member from app.modules.member.repository import MemberRepository from app.modules.member.schemas import MemberCreateIn, MemberUpdateIn @@ -84,8 +85,37 @@ async def list( "total": total } + # [1단계] 회원가입용 인증 코드 발송 + async def request_signup_verification(self, email: str) -> None: + # 이미 가입된 이메일인지 먼저 체크 + existing = await self.repository.get_by_email(email) + if existing: + raise AppError.bad_request(f"[{email}]은(는) 이미 존재하는 회원 이메일입니다.") + + code = generate_verification_code() + await send_verification_email( + email=email, + code=code, + purpose="signup", + custom_message="팀플링 회원가입을 위한 인증 코드입니다." + ) + + # [2단계] 인증 코드 검증 및 '인증 완료' 증표 남기기 + async def confirm_signup_verification(self, email: str, code: str) -> None: + is_valid = await verify_code(email, code, purpose="signup") + if not is_valid: + raise AppError.bad_request("인증 코드가 틀렸거나 만료되었습니다.") + + # 인증 완료 증표를 Redis에 10분(600초)간 저장 (key: verify:signup_passed:{email}) + await redis_client.setex(f"verify:signup_passed:{email}", 600, "true") + #멤버 생성 서비스(save) async def create(self, data: MemberCreateIn) -> Member: + # [3단계] 최종 회원가입 시 증표 확인 + passed = await redis_client.get(f"verify:signup_passed:{data.email}") + if not passed: + raise AppError.bad_request("이메일 인증이 완료되지 않았거나 인증 시간이 만료되었습니다.") + existing = await self.repository.get_by_email(data.email) #회원이 이미 있는 경우 가입 불가능하게 하기 위한 작업 if existing: raise AppError.bad_request(f"[{data.email}]은(는) 이미 존재하는 회원 이메일입니다.") @@ -115,6 +145,9 @@ async def create(self, data: MemberCreateIn) -> Member: await self.session.commit() #서비스 단계에서 DB 데이터 변화가 있을 수 있기 때문에 또 refresh함. await self.session.refresh(saved) + + # 가입 성공 후 Redis 증표 삭제 + await redis_client.delete(f"verify:signup_passed:{data.email}") return saved #무결성 제약 조건 에러 #위의 AppError.bad_request 에러 부분과 다른 점은 From 5b28d36b13b794895e6419e8e9995c38dd88320a Mon Sep 17 00:00:00 2001 From: KIMB0B Date: Mon, 13 Apr 2026 02:26:04 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat(member):=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EA=B4=80=EB=A0=A8=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../19316f07bc9b_add_role_to_memeber.py | 32 +++++++++++++++++ app/modules/member/dependencies.py | 15 +++++++- app/modules/member/models.py | 14 ++++++++ app/modules/member/router.py | 31 ++++++++++++++--- app/modules/member/schemas.py | 14 ++++++++ app/modules/member/service.py | 34 +++++++++++++++---- app/shared/enums.py | 6 +++- 7 files changed, 132 insertions(+), 14 deletions(-) create mode 100644 alembic/versions/19316f07bc9b_add_role_to_memeber.py diff --git a/alembic/versions/19316f07bc9b_add_role_to_memeber.py b/alembic/versions/19316f07bc9b_add_role_to_memeber.py new file mode 100644 index 0000000..bde1387 --- /dev/null +++ b/alembic/versions/19316f07bc9b_add_role_to_memeber.py @@ -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 ### diff --git a/app/modules/member/dependencies.py b/app/modules/member/dependencies.py index 8910657..fc82349 100644 --- a/app/modules/member/dependencies.py +++ b/app/modules/member/dependencies.py @@ -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 #전체 흐름 @@ -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)] \ No newline at end of file +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)] \ No newline at end of file diff --git a/app/modules/member/models.py b/app/modules/member/models.py index 874f08d..ff6a3cd 100644 --- a/app/modules/member/models.py +++ b/app/modules/member/models.py @@ -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 @@ -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 제공자" diff --git a/app/modules/member/router.py b/app/modules/member/router.py index d4c883b..377df2b 100644 --- a/app/modules/member/router.py +++ b/app/modules/member/router.py @@ -5,11 +5,11 @@ 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.dependencies import CurrentMemberDep, MemberServiceDep, AdminMemberDep from app.modules.member.schemas import ( MemberCreateIn, MemberOut, MemberUpdateIn, TokenOut, RefreshTokenIn, PasswordResetRequestIn, PasswordResetConfirmIn, - SignupVerifyRequestIn, SignupVerifyConfirmIn + SignupVerifyRequestIn, SignupVerifyConfirmIn, MemberRoleUpdateIn ) from app.shared.schemas import ApiResponse, PageOut @@ -28,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, @@ -84,7 +85,7 @@ async def get_member( ) @router.post( - path="", + path="/signup", response_model=ApiResponse[MemberOut], status_code=status.HTTP_201_CREATED, summary="회원 생성", @@ -122,7 +123,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( @@ -134,6 +135,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 응답 만들 때 @@ -156,7 +177,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( diff --git a/app/modules/member/schemas.py b/app/modules/member/schemas.py index 2eca2f0..48cb94c 100644 --- a/app/modules/member/schemas.py +++ b/app/modules/member/schemas.py @@ -5,6 +5,8 @@ from pydantic import HttpUrl, ConfigDict, EmailStr from sqlmodel import SQLModel, Field +from app.shared.enums import MemberRole + #In: 서버 API로 들어오는 데이터(요청) #Out: 서버 API에서 나가는 데이터(응답) @@ -84,6 +86,7 @@ class MemberUpdateIn(SQLModel): class MemberOut(SQLModel): id: UUID = Field(description="회원 ID") email: EmailStr = Field(description="회원 이메일") + role: MemberRole = Field(description="회원 권한") name: str = Field(description="이름") birth: date = Field(description="생년월일") gender: bool | None = Field(default=None, description="성별") @@ -194,4 +197,15 @@ class PasswordResetConfirmIn(SQLModel): "new_password": "newpassword123!" } } + } + +class MemberRoleUpdateIn(SQLModel): + role: MemberRole = Field(description="변경할 회원 권한") + + model_config = { + "json_schema_extra": { + "example": { + "role": "admin" + } + } } \ No newline at end of file diff --git a/app/modules/member/service.py b/app/modules/member/service.py index fbf409c..80864e0 100644 --- a/app/modules/member/service.py +++ b/app/modules/member/service.py @@ -12,7 +12,7 @@ from app.modules.member.models import Member from app.modules.member.repository import MemberRepository from app.modules.member.schemas import MemberCreateIn, MemberUpdateIn -from app.shared.enums import ProviderType +from app.shared.enums import ProviderType, MemberRole #pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") #비밀번호 해쉬화 @@ -158,9 +158,9 @@ async def create(self, data: MemberCreateIn) -> Member: #멤버 수정 서비스(save) #여기서의 member_id는 수정을 원하는 회원 id인데 수정할 회원 id가 없으면 안되므로 이렇게 구현. - async def update(self, target_member_id: UUID, actor_member_id: UUID, data: MemberUpdateIn) -> Member: - if actor_member_id != target_member_id: - raise AppError.forbidden("본인 정보만 수정할 수 있습니다.") + async def update(self, target_member_id: UUID, actor: Member, data: MemberUpdateIn) -> Member: + if actor.role != MemberRole.ADMIN and actor.id != target_member_id: + raise AppError.forbidden("본인 정보만 수정할 수 있거나 관리자 권한이 필요합니다.") member = await self.repository.get_by_id(target_member_id, include_deleted=False) if not member: @@ -215,11 +215,31 @@ async def update(self, target_member_id: UUID, actor_member_id: UUID, data: Memb await self.session.rollback() #아까 했던 DB 작업 전부 취소 raise AppError.bad_request(f"[{data.email}]은(는) 이미 존재하는 회원 이메일입니다.") + async def update_role(self, member_id: UUID, role: MemberRole) -> Member: + """ + 관리자가 특정 회원의 권한을 수정합니다. + """ + member = await self.repository.get_by_id(member_id, include_deleted=False) + if not member: + raise AppError.not_found(f"Member[{member_id}]") + + member.role = role + + try: + updated = await self.repository.save(member) + await self.session.commit() + await self.session.refresh(updated) + return updated + except Exception: + await self.session.rollback() + raise + # 멤버 삭제 서비스(soft_delete, hard_delete) #member를 삭제하는데 hard=True면 진짜 삭제, 아니면 soft delete - async def delete(self, target_member_id: UUID, actor_member_id: UUID, *, hard: bool = False) -> None: - if actor_member_id != target_member_id: - raise AppError.forbidden("본인 정보만 삭제할 수 있습니다.") + async def delete(self, target_member_id: UUID, actor: Member, *, hard: bool = False) -> None: + # 관리자가 아니면서 본인이 아닌 경우에만 차단 + if actor.role != MemberRole.ADMIN and actor.id != target_member_id: + raise AppError.forbidden("본인 정보만 삭제할 수 있거나 관리자 권한이 필요합니다.") #삭제된 것이든 아니든 다 가져옴 #왜냐하면, hard delete 하려면 이미 삭제된 것도 찾아야 하기 때문에 diff --git a/app/shared/enums.py b/app/shared/enums.py index fc154ca..d8880f9 100644 --- a/app/shared/enums.py +++ b/app/shared/enums.py @@ -10,4 +10,8 @@ class ProviderType(str, Enum): LOCAL = "local" GOOGLE = "google" KAKAO = "kakao" - NAVER = "naver" \ No newline at end of file + NAVER = "naver" + +class MemberRole(str, Enum): + USER = "user" + ADMIN = "admin" \ No newline at end of file From e53e9427daa2189801fd847430a2e900933f9b2c Mon Sep 17 00:00:00 2001 From: KIMB0B Date: Mon, 13 Apr 2026 18:25:58 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat(member):=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EC=9E=91=EB=8F=99=20=ED=99=95=EC=9D=B8=EC=9A=A9=20HTML=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/main.py | 14 +- app/modules/member/router.py | 15 ++ app/static/index.html | 57 +++++++ app/static/login.html | 83 ++++++++++ app/static/password-reset.html | 107 +++++++++++++ app/static/signup.html | 281 +++++++++++++++++++++++++++++++++ app/static/style.css | 176 +++++++++++++++++++++ 7 files changed, 732 insertions(+), 1 deletion(-) create mode 100644 app/static/index.html create mode 100644 app/static/login.html create mode 100644 app/static/password-reset.html create mode 100644 app/static/signup.html create mode 100644 app/static/style.css diff --git a/app/main.py b/app/main.py index b962bdf..e581bcb 100644 --- a/app/main.py +++ b/app/main.py @@ -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 @@ -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) diff --git a/app/modules/member/router.py b/app/modules/member/router.py index 377df2b..e10512b 100644 --- a/app/modules/member/router.py +++ b/app/modules/member/router.py @@ -58,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 설명 diff --git a/app/static/index.html b/app/static/index.html new file mode 100644 index 0000000..ea09a3f --- /dev/null +++ b/app/static/index.html @@ -0,0 +1,57 @@ + + + + + + 팀플링 - Teampling + + + +
+ + +
+ +
+
+

팀플링에 오신 것을 환영합니다!

+

팀 프로젝트를 더 쉽고 효율적으로 관리하세요.

+
+
+ + + + diff --git a/app/static/login.html b/app/static/login.html new file mode 100644 index 0000000..e05d52e --- /dev/null +++ b/app/static/login.html @@ -0,0 +1,83 @@ + + + + + + 로그인 - 팀플링 + + + +
+ +
+ +
+
+

로그인

+
+
+ + +
+
+ + +
+ +
+ +
+
+ + + + diff --git a/app/static/password-reset.html b/app/static/password-reset.html new file mode 100644 index 0000000..fcd9339 --- /dev/null +++ b/app/static/password-reset.html @@ -0,0 +1,107 @@ + + + + + + 비밀번호 재설정 - 팀플링 + + + +
+ +
+ +
+
+

비밀번호 찾기

+ +
+
+ + +
+ +
+ + + + +
+
+ + + + diff --git a/app/static/signup.html b/app/static/signup.html new file mode 100644 index 0000000..6381d67 --- /dev/null +++ b/app/static/signup.html @@ -0,0 +1,281 @@ + + + + + + 회원가입 - 팀플링 + + + + +
+ +
+ +
+ +
+ + + + diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..fa2b4ff --- /dev/null +++ b/app/static/style.css @@ -0,0 +1,176 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; + font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, Roboto, 'Helvetica Neue', 'Segoe UI', 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif; +} + +body { + background-color: #f8f9fa; + color: #333; + line-height: 1.6; +} + +header { + background-color: #fff; + border-bottom: 1px solid #dee2e6; + padding: 1rem 2rem; + display: flex; + justify-content: space-between; + align-items: center; + position: sticky; + top: 0; + z-index: 1000; +} + +.logo { + font-size: 1.5rem; + font-weight: 800; + color: #6f42c1; + text-decoration: none; +} + +.nav-links { + display: flex; + gap: 1.5rem; + align-items: center; +} + +.nav-links a, .nav-links button { + text-decoration: none; + color: #495057; + font-weight: 500; + cursor: pointer; + background: transparent; + border: none; + font-size: 1rem; +} + +.nav-links a.login-btn { + background-color: #6f42c1; + color: #fff !important; + padding: 0.5rem 1.2rem; + border-radius: 6px; + transition: background-color 0.2s; + display: inline-block; +} + +.nav-links a.login-btn:hover { + background-color: #59359a; +} + +.user-profile-info { + display: flex; + align-items: center; + gap: 0.8rem; + font-weight: 600; +} + +.header-profile-img { + width: 35px; + height: 35px; + border-radius: 50%; + object-fit: cover; + background-color: #eee; + border: 1px solid #dee2e6; +} + +.user-nickname { + color: #333; +} + +main { + max-width: 1200px; + margin: 3rem auto; + padding: 0 1rem; + min-height: calc(100vh - 160px); +} + +.auth-container { + max-width: 400px; + margin: 0 auto; + background: #fff; + padding: 2.5rem; + border-radius: 12px; + box-shadow: 0 10px 25px rgba(0,0,0,0.05); +} + +.auth-container h2 { + margin-bottom: 2rem; + text-align: center; + font-weight: 700; +} + +.form-group { + margin-bottom: 1.5rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 600; + font-size: 0.9rem; +} + +.form-group input, .form-group select, .form-group textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid #ced4da; + border-radius: 6px; + font-size: 1rem; +} + +.form-group input:focus { + outline: none; + border-color: #6f42c1; + box-shadow: 0 0 0 3px rgba(111, 66, 193, 0.1); +} + +.btn-primary { + width: 100%; + padding: 0.75rem; + background-color: #6f42c1; + color: #fff; + border: none; + border-radius: 6px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + margin-top: 1rem; +} + +.btn-primary:hover { + background-color: #59359a; +} + +.auth-links { + margin-top: 1.5rem; + display: flex; + justify-content: center; + gap: 1rem; + font-size: 0.85rem; +} + +.auth-links a { + color: #6c757d; + text-decoration: none; +} + +.auth-links a:hover { + text-decoration: underline; + color: #6f42c1; +} + +.welcome-msg { + text-align: center; + margin-top: 5rem; +} + +.welcome-msg h1 { + font-size: 3rem; + margin-bottom: 1rem; +} + +.hidden { + display: none; +}