From b789b2cecac7a3b11bb7cae03fc3a2746585c1c4 Mon Sep 17 00:00:00 2001 From: KIMB0B Date: Fri, 17 Apr 2026 22:17:03 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(member):=20=EC=83=9D=EC=84=B1,=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EC=8B=9C=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=EB=A5=BC=20=EC=9E=85=EB=A0=A5=EB=B0=9B=EB=8F=84=EB=A1=9D=20for?= =?UTF-8?q?m=20=ED=98=95=ED=83=9C=EB=A1=9C=20=EA=B5=AC=EC=84=B1=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/modules/member/dependencies.py | 5 +-- app/modules/member/router.py | 16 +++++---- app/modules/member/schemas.py | 54 ++++++++++++++++-------------- app/modules/member/service.py | 50 ++++++++++++++++++++++++--- app/shared/utils/form.py | 43 +++++++++++++++++++----- 5 files changed, 120 insertions(+), 48 deletions(-) diff --git a/app/modules/member/dependencies.py b/app/modules/member/dependencies.py index fc82349..f8fd9fc 100644 --- a/app/modules/member/dependencies.py +++ b/app/modules/member/dependencies.py @@ -11,15 +11,16 @@ from app.modules.member.repository import MemberRepository from app.modules.member.service import MemberService from app.shared.enums import MemberRole +from app.shared.storage.oci_object_storage import OCIObjectStorageClientDep #전체 흐름 #Client -> HTTP 요청 -> Router -> Service -> Repository -> DB #MemberService를 모든 Member api 함수들에게 의존성 주입을 하기 위한 과정 -def get_member_service(session: DbSessionDep) -> MemberService: +def get_member_service(session: DbSessionDep, storage: OCIObjectStorageClientDep) -> MemberService: repository = MemberRepository(session) - return MemberService(session, repository) + return MemberService(session, repository, storage) #get_member_service() 실행하고 MemberService 만들어서 service에 넣어줘 MemberServiceDep = Annotated[MemberService, Depends(get_member_service)] diff --git a/app/modules/member/router.py b/app/modules/member/router.py index e10512b..0546386 100644 --- a/app/modules/member/router.py +++ b/app/modules/member/router.py @@ -2,7 +2,8 @@ from typing import Annotated from uuid import UUID -from fastapi import APIRouter, Path, Query, Depends, status +from fastapi import APIRouter, Path, Query, Depends, status, UploadFile +from fastapi.params import File from fastapi.security import OAuth2PasswordRequestForm from app.modules.member.dependencies import CurrentMemberDep, MemberServiceDep, AdminMemberDep @@ -12,7 +13,7 @@ SignupVerifyRequestIn, SignupVerifyConfirmIn, MemberRoleUpdateIn ) from app.shared.schemas import ApiResponse, PageOut - +from app.shared.utils.form import as_form #prefix: 앞에 공통적으로 들어갈 경로명 #tags: Swagger에서 API를 분류할 때 사용하는 것 @@ -107,10 +108,11 @@ async def get_member( description="회원가입 기능입니다.", ) async def create_member( - data: MemberCreateIn, service: MemberServiceDep, + data: Annotated[MemberCreateIn, Depends(as_form(MemberCreateIn))], + profile_file: Annotated[UploadFile | None, File(description="업로드할 프로필 이미지 파일")] = None, ): - created = await service.create(data) + created = await service.create(data, profile_file=profile_file) return ApiResponse.success( code="MEMBER_CREATED", message="회원가입 성공", @@ -133,13 +135,15 @@ async def update_member( member_id: Annotated[UUID, Path(description="수정할 member의 ID")], #요청 JSON → (검증 + 파싱) → MemberUpdateIn 객체 → data로 들어옴 #data는 검증 완료 된 Pydantic 객체 - data: MemberUpdateIn, + data: Annotated[MemberUpdateIn, Depends(as_form(MemberUpdateIn))], + profile_file: Annotated[UploadFile | None, File(description="업로드할 프로필 이미지 파일")] = None, ): #DB 수정 → 수정된 객체 받음 updated = await service.update( target_member_id=member_id, actor=current_member, - data=data + data=data, + profile_file=profile_file, ) return ApiResponse.success( code="MEMBER_UPDATED", diff --git a/app/modules/member/schemas.py b/app/modules/member/schemas.py index 48cb94c..73c47fa 100644 --- a/app/modules/member/schemas.py +++ b/app/modules/member/schemas.py @@ -32,24 +32,24 @@ class MemberCreateIn(SQLModel): nickname: str | None = Field(default=None, description="닉네임") organization: str | None = Field(default=None, description="소속") dept: str | None = Field(default=None, description="부서") - profile_url: HttpUrl | None = Field(default=None, description="프로필 이미지 URL") detail: str | None = Field(default=None, description="상세 소개") model_config = { "json_schema_extra": { - "example": { - "email": "test@naver.com", - "password": "test1234!", - "name": "송시월", - "birth": "2001-05-21", - "gender": True, - "phone_num": "01012345678", - "nickname": "쏴리쏭", - "organization": "한성대학교", - "dept": "컴퓨터공학과", - "profile_url": "https://example.com/profile.jpg", - "detail": "안녕하세요!" - } + "examples": [ + { + "email": "test@naver.com", + "password": "test1234!", + "name": "송시월", + "birth": "2001-05-21", + "gender": True, + "phone_num": "01012345678", + "nickname": "쏴리쏭", + "organization": "한성대학교", + "dept": "컴퓨터공학과", + "detail": "안녕하세요!" + } + ] } } @@ -67,18 +67,20 @@ class MemberUpdateIn(SQLModel): model_config = { "json_schema_extra": { - "example": { - "password": "test1234!", - "name": "송시월", - "birth": "2001-05-21", - "gender": True, - "phone_num": "01012345678", - "nickname": "쏴리쏭", - "organization": "한성대학교", - "dept": "컴퓨터공학과", - "profile_url": "https://example.com/profile.jpg", - "detail": "안녕하세요!" - } + "examples": [ + { + "password": "test1234!", + "name": "송시월", + "birth": "2001-05-21", + "gender": True, + "phone_num": "01012345678", + "nickname": "쏴리쏭", + "organization": "한성대학교", + "dept": "컴퓨터공학과", + "profile_url": "https://example.com/profile.jpg", + "detail": "안녕하세요!" + } + ] } } diff --git a/app/modules/member/service.py b/app/modules/member/service.py index 80864e0..d610cba 100644 --- a/app/modules/member/service.py +++ b/app/modules/member/service.py @@ -1,12 +1,15 @@ +import logging from base64 import decode from typing import Any from uuid import UUID +from fastapi import UploadFile from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession 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.storage.oci_object_storage import OCIObjectStorageClient 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 @@ -14,13 +17,20 @@ from app.modules.member.schemas import MemberCreateIn, MemberUpdateIn from app.shared.enums import ProviderType, MemberRole +logger = logging.getLogger(__name__) #pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") #비밀번호 해쉬화 class MemberService: - def __init__(self, session: AsyncSession, repository: MemberRepository): + def __init__( + self, + session: AsyncSession, + repository: MemberRepository, + storage: OCIObjectStorageClient, + ): self.session = session self.repository = repository + self.storage = storage #get_by_id의 서비스 async def get(self, member_id: UUID, *, include_deleted: bool = False) -> Member: @@ -110,7 +120,7 @@ async def confirm_signup_verification(self, email: str, code: str) -> None: await redis_client.setex(f"verify:signup_passed:{email}", 600, "true") #멤버 생성 서비스(save) - async def create(self, data: MemberCreateIn) -> Member: + async def create(self, data: MemberCreateIn, profile_file: UploadFile | None = None) -> Member: # [3단계] 최종 회원가입 시 증표 확인 passed = await redis_client.get(f"verify:signup_passed:{data.email}") if not passed: @@ -123,9 +133,8 @@ async def create(self, data: MemberCreateIn) -> Member: #dto 타입으로 들어온 데이터를 DB에 넣기 위해 SqlModel에 쓰는 데이터 타입으로 변환 #member = Member(**data.model_dump(mode="json")) #이대로 저장하면 데이터 안에 있는 password가 그대로 들어가서 보안 문제 생김 member = Member( - **data.model_dump(exclude={"password", "profile_url"}), + **data.model_dump(exclude={"password"}), #enum 타입으로 shared.enums.py 안에 적어놓음. 오타 방지를 위해서 & 수정도 쉽게 하기 위해서 - profile_url=str(data.profile_url) if data.profile_url is not None else None, provider=ProviderType.LOCAL, provider_id=None, #소셜 로그인이 아니어서 hashed_password=password_hash(data.password) @@ -139,6 +148,12 @@ async def create(self, data: MemberCreateIn) -> Member: #이런 형태의 dictionary를 SqlModel에서 쓰기 위해서 아래처럼 바꿔줌 #Skill(name="python", level=3) + profile_object_name = None + if profile_file is not None: + profile_object_name = await self.storage.upload_object(file=profile_file, object_prefix="member_profile") + profile_object_utl = self.storage.build_object_url(profile_object_name) + member.profile_url = profile_object_utl + try: saved = await self.repository.save(member) #커밋은 서비스 단계에서 진행. @@ -153,12 +168,17 @@ async def create(self, data: MemberCreateIn) -> Member: #위의 AppError.bad_request 에러 부분과 다른 점은 #DB에서 unique를 어기는 데이터를 에러 처리할 때 생기는 상황을 위한 이중 처리 except IntegrityError: + if profile_object_name: + try: + await self.storage.delete_object(profile_object_name) + except Exception: + logger.error(f"업로드 실패 후 이미지 롤백 삭제 실패: {profile_object_name}",exc_info=True) await self.session.rollback() #무결성을 위반한 데이터는 저장하지 않고 롤백. raise AppError.bad_request(f"[{data.email}]은(는) 이미 존재하는 회원 이메일입니다.") #멤버 수정 서비스(save) #여기서의 member_id는 수정을 원하는 회원 id인데 수정할 회원 id가 없으면 안되므로 이렇게 구현. - async def update(self, target_member_id: UUID, actor: Member, data: MemberUpdateIn) -> Member: + async def update(self, target_member_id: UUID, actor: Member, data: MemberUpdateIn, profile_file: UploadFile | None = None) -> Member: if actor.role != MemberRole.ADMIN and actor.id != target_member_id: raise AppError.forbidden("본인 정보만 수정할 수 있거나 관리자 권한이 필요합니다.") @@ -176,6 +196,14 @@ async def update(self, target_member_id: UUID, actor: Member, data: MemberUpdate if "profile_url" in patch and patch["profile_url"] is not None: patch["profile_url"] = str(patch["profile_url"]) #profile_url 수정값을 문자열로 강제 형변환 + old_profile_url = member.profile_url + profile_object_name = None + + if profile_file is not None: + profile_object_name = await self.storage.upload_object(file=profile_file, object_prefix="member_profile") + profile_object_url = self.storage.build_object_url(profile_object_name) + patch["profile_url"] = profile_object_url + if data.password is not None: patch["hashed_password"] = password_hash(data.password) @@ -207,11 +235,23 @@ async def update(self, target_member_id: UUID, actor: Member, data: MemberUpdate #DB에서 자동으로 바뀌는 값들이 있음: updated_at, default 값, trigger, DB에서 가공된 값 #즉, refresh 안 하면 Python 객체는 옛날 값일 수 있음 await self.session.refresh(updated) #DB 기준 최신 상태 다시 가져오기 + + if old_profile_url: + try: + old_object_name = self.storage.extract_object_name(old_profile_url) + await self.storage.delete_object(old_object_name) + except Exception: + logger.error(f"기존 이미지 삭제 실패: {old_profile_url}",exc_info=True) return updated #이메일 unique인데 중복 넣었을 때, PK 충돌, FK 깨졌을 때 터짐. except IntegrityError: #flush 했던 것도 다 되돌림 #DB: 원래 상태로 복구됨 + if profile_object_name: + try: + await self.storage.delete_object(profile_object_name) + except Exception: + logger.error(f"업로드 실패 후 이미지 롤백 삭제 실패: {profile_object_name}",exc_info=True) await self.session.rollback() #아까 했던 DB 작업 전부 취소 raise AppError.bad_request(f"[{data.email}]은(는) 이미 존재하는 회원 이메일입니다.") diff --git a/app/shared/utils/form.py b/app/shared/utils/form.py index e2e98d2..d244893 100644 --- a/app/shared/utils/form.py +++ b/app/shared/utils/form.py @@ -6,22 +6,48 @@ def as_form(cls): new_params = [] + # 모델 전체 example 읽기 + model_json_schema_extra = getattr(cls, "model_config", {}).get("json_schema_extra", {}) or {} + model_examples = model_json_schema_extra.get("examples", []) + + first_example = model_examples[0] if model_examples else {} + for field_name, model_field in cls.model_fields.items(): field_info = model_field - json_schema_extra = field_info.json_schema_extra or {} + field_json_schema_extra = getattr(field_info, "json_schema_extra", None) or {} form_kwargs = {} if field_info.description: form_kwargs["description"] = field_info.description - # examples 우선 - if field_info.examples: + # 1순위: 필드 자체 examples / example + if getattr(field_info, "examples", None): form_kwargs["examples"] = field_info.examples - elif "examples" in json_schema_extra: - form_kwargs["examples"] = json_schema_extra["examples"] - elif "example" in json_schema_extra: - form_kwargs["example"] = json_schema_extra["example"] + + elif "openapi_examples" in field_json_schema_extra: + form_kwargs["openapi_examples"] = field_json_schema_extra["openapi_examples"] + + elif "examples" in field_json_schema_extra: + form_kwargs["examples"] = field_json_schema_extra["examples"] + + elif "example" in field_json_schema_extra: + form_kwargs["example"] = field_json_schema_extra["example"] + + # 2순위: 모델 전체 example에서 현재 필드 값 꺼내기 + elif field_name in first_example: + value = first_example[field_name] + + # Swagger UI는 openapi_examples 지원이 더 나음 + form_kwargs["openapi_examples"] = { + "default": { + "summary": f"{field_name} example", + "value": value, + } + } + + # 필요하면 같이 넣어도 됨 + form_kwargs["example"] = value default = ( Form(..., **form_kwargs) @@ -41,6 +67,5 @@ def as_form(cls): async def _as_form(**data): return cls(**data) - sig = signature(_as_form).replace(parameters=new_params) - _as_form.__signature__ = sig + _as_form.__signature__ = signature(_as_form).replace(parameters=new_params) return _as_form \ No newline at end of file From f0e9c91638801dbf9b41f4559af63f5b71f6eedc Mon Sep 17 00:00:00 2001 From: KIMB0B Date: Fri, 17 Apr 2026 22:39:25 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(member):=20member=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=EC=97=90=20=EC=A0=9C=EC=99=B8=EB=90=98=EB=8A=94=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/modules/member/models.py | 32 ++---------------------- app/modules/member/repository.py | 10 ++++---- app/modules/member/router.py | 4 +-- app/modules/member/schemas.py | 42 +++++--------------------------- app/modules/member/service.py | 22 +++++++---------- 5 files changed, 24 insertions(+), 86 deletions(-) diff --git a/app/modules/member/models.py b/app/modules/member/models.py index ff6a3cd..f5bac8c 100644 --- a/app/modules/member/models.py +++ b/app/modules/member/models.py @@ -62,32 +62,10 @@ class Member(BaseModel, table=True): description="회원 비밀번호" ) - name: str = Field( - nullable=False, - description="회원 이름" - ) - - birth: date = Field( - nullable=False, - description="회원 생년월일" - ) - - gender: bool | None = Field( + username: str | None = Field( default=None, nullable=True, - description="회원 성별(0: 남, 1: 여, null: 성별을 밝히고 싶지 않음)" - ) - - phone_num: str = Field( - max_length=20, - nullable=False, - description="회원 전화번호" - ) - - nickname: str | None = Field( - default=None, - nullable=True, - description="회원 닉네임" + description="회원 사용자 이름" ) organization: str | None = Field( @@ -96,12 +74,6 @@ class Member(BaseModel, table=True): description="회원 소속" ) - dept: str | None = Field( - default=None, - nullable=True, - description="회원 부서" - ) - profile_url: str | None = Field( default=None, nullable=True, diff --git a/app/modules/member/repository.py b/app/modules/member/repository.py index 1e3f962..0348e2a 100644 --- a/app/modules/member/repository.py +++ b/app/modules/member/repository.py @@ -59,13 +59,13 @@ async def list( stmt = select(Member) if keyword: - #Member.name(이름)에 keyword가 포함된 회원만 찾으라는 뜻 + #Member.username에 keyword가 포함된 회원만 찾으라는 뜻 #ilike: 대소문자 구분 없이 검색 #f"": 변수 값을 문자열에 넣는 문법 #ex) keyword = "python" f"{keyword}" => 결과: "python" #keyword%: keyword로 시작, %keyword: keyword로 끝, %keyword%: keyword 포함 - stmt = stmt.where(Member.name.ilike(f"%{keyword}%")) #"keyword" 포함(대소문자 구분X) + stmt = stmt.where(Member.username.ilike(f"%{keyword}%")) #"keyword" 포함(대소문자 구분X) #삭제된 회원을 숨길지 말지 정하는 코드 #include_deleted는 기본값이 false인데 not이 붙었으므로 true가 됨. #false일 때 조건문이 실행X, true일 때 조건문 실행(파이썬 문법) @@ -73,12 +73,12 @@ async def list( stmt = stmt.where(Member.is_deleted == False) #삭제 안 된 애들만 가져와 #정렬 & 페이지 - #order_by, asc: 이름 오름차순으로 정렬 + #order_by, asc: 사용자 이름 오름차순으로 정렬 #offset: 앞에서부터 몇 개 건너뜀(스킵) #limit: 몇 개 가져올지 제한 #ex) offset = 0, limit = 10 => 1~10번째 회원 #ex) offset = 10, limit = 10 => 11~20번째 회원 - stmt = stmt.order_by(Member.name.asc()).offset(offset).limit(limit) + stmt = stmt.order_by(Member.username.asc()).offset(offset).limit(limit) #실행!! #scalars(): Member 객체만 꺼냄 #.all(): 리스트로 반환 @@ -97,7 +97,7 @@ async def count( #list랑 count는 조건이 완전히 같아야 페이지가 맞아서 조건 부분이 동일. if keyword: - stmt = stmt.where(Member.name.ilike(f"%{keyword}%")) + stmt = stmt.where(Member.username.ilike(f"%{keyword}%")) if not include_deleted: stmt = stmt.where(Member.is_deleted == False) diff --git a/app/modules/member/router.py b/app/modules/member/router.py index 0546386..2c0eb20 100644 --- a/app/modules/member/router.py +++ b/app/modules/member/router.py @@ -30,7 +30,7 @@ async def list_members( service: MemberServiceDep, admin: AdminMemberDep, - keyword: Annotated[str | None, Query(description="검색 키워드", example="수진")] = None, + keyword: Annotated[str | None, Query(description="검색 키워드", example="쏴리쏭")] = None, #ge: 이상, le: 이하 page: Annotated[int,Query(ge=1, description="페이지 번호")] = 1, size: Annotated[int, Query(ge=1, le=100, description="페이지 크기")] = 50, @@ -86,7 +86,7 @@ async def get_my_info( #DbSessionDep: DB 접속 정보 #Path: 경로 변수에게 설명을 추가할 수 있도록 하는 것 #Query: 쿼리 파라미터 변수에게 설명을 추가할 수 있도록 하는 것 -#쿼리 파라미터: ex) /items?name=apple&page=2라고 한다면 name과 page가 쿼리 파라미터 +#쿼리 파라미터: ex) /items?username=sujin&page=2라고 한다면 username과 page가 쿼리 파라미터 async def get_member( service: MemberServiceDep, member_id: Annotated[UUID, Path(..., description="조회할 member의 id")], diff --git a/app/modules/member/schemas.py b/app/modules/member/schemas.py index 73c47fa..700cc7c 100644 --- a/app/modules/member/schemas.py +++ b/app/modules/member/schemas.py @@ -25,13 +25,8 @@ class MemberCreateIn(SQLModel): email: EmailStr = Field(description="회원 이메일") password: str = Field(description="비밀번호") - name: str = Field(description="이름") - birth: date = Field(description="생년월일") - gender: bool | None = Field(default=None, description="성별") - phone_num: str = Field(description="전화번호") - nickname: str | None = Field(default=None, description="닉네임") + username: str | None = Field(default=None, description="사용자 이름") organization: str | None = Field(default=None, description="소속") - dept: str | None = Field(default=None, description="부서") detail: str | None = Field(default=None, description="상세 소개") model_config = { @@ -40,13 +35,8 @@ class MemberCreateIn(SQLModel): { "email": "test@naver.com", "password": "test1234!", - "name": "송시월", - "birth": "2001-05-21", - "gender": True, - "phone_num": "01012345678", - "nickname": "쏴리쏭", + "username": "쏴리쏭", "organization": "한성대학교", - "dept": "컴퓨터공학과", "detail": "안녕하세요!" } ] @@ -55,14 +45,8 @@ class MemberCreateIn(SQLModel): class MemberUpdateIn(SQLModel): password: str | None = Field(default=None, description="비밀번호") - name: str | None = Field(default=None, description="이름") - birth: date | None = Field(default=None, description="생년월일") - gender: bool | None = Field(default=None, description="성별") - phone_num: str | None = Field(default=None, description="전화번호") - nickname: str | None = Field(default=None, description="닉네임") + username: str | None = Field(default=None, description="사용자 이름") organization: str | None = Field(default=None, description="소속") - dept: str | None = Field(default=None, description="부서") - profile_url: HttpUrl | None = Field(default=None, description="프로필 이미지 URL") detail: str | None = Field(default=None, description="상세 소개") model_config = { @@ -70,14 +54,8 @@ class MemberUpdateIn(SQLModel): "examples": [ { "password": "test1234!", - "name": "송시월", - "birth": "2001-05-21", - "gender": True, - "phone_num": "01012345678", - "nickname": "쏴리쏭", + "username": "쏴리쏭", "organization": "한성대학교", - "dept": "컴퓨터공학과", - "profile_url": "https://example.com/profile.jpg", "detail": "안녕하세요!" } ] @@ -89,12 +67,8 @@ 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="성별") - nickname: str | None = Field(default=None, description="닉네임") + username: str | None = Field(default=None, description="사용자 이름") organization: str | None = Field(default=None, description="소속") - dept: str | None = Field(default=None, description="부서") profile_url: HttpUrl | None = Field(default=None, description="프로필 이미지 URL") detail: str | None = Field(default=None, description="상세 소개") @@ -106,12 +80,8 @@ class MemberOut(SQLModel): "example": { "id": "3e1672cf-8d99-4b1c-9b5e-9c3ece11b089", "email": "test@example.com", - "name": "송시월", - "birth": "2001-05-21", - "gender": True, - "nickname": "쏴리쏭", + "username": "쏴리쏭", "organization": "한성대학교", - "dept": "컴퓨터공학과", "profile_url": "https://example.com/profile.jpg", "detail": "안녕하세요!" } diff --git a/app/modules/member/service.py b/app/modules/member/service.py index d610cba..6ef0f41 100644 --- a/app/modules/member/service.py +++ b/app/modules/member/service.py @@ -142,11 +142,11 @@ async def create(self, data: MemberCreateIn, profile_file: UploadFile | None = N #pydantic의 dictionary 타입-> SqlModel 타입으로 변경 #예시!!! #data = { - # "name": "python", - # "level": 3 + # "username": "쏴리쏭", + # "organization": "한성대학교" #} #이런 형태의 dictionary를 SqlModel에서 쓰기 위해서 아래처럼 바꿔줌 - #Skill(name="python", level=3) + #Member(username="쏴리쏭", organization="한성대학교") profile_object_name = None if profile_file is not None: @@ -192,10 +192,6 @@ async def update(self, target_member_id: UUID, actor: Member, data: MemberUpdate exclude_unset=True, ) - #이 둘은 DB에 넣기 전 특수한 처리를 해줘야 해서 따로 빼서 처리. - if "profile_url" in patch and patch["profile_url"] is not None: - patch["profile_url"] = str(patch["profile_url"]) #profile_url 수정값을 문자열로 강제 형변환 - old_profile_url = member.profile_url profile_object_name = None @@ -210,14 +206,14 @@ async def update(self, target_member_id: UUID, actor: Member, data: MemberUpdate #수정할 값인 patch(dictionary 타입임)의 items()를 사용하여 key와 value를 하나하나 가져옴 #ex) #patch = { - # "name": "sujin", - # "age": 26 + # "username": "sujin", + # "organization": "한성대학교" #} - #for문 첫번째 -> k = "name", v = "sujin" - #for문 두번째 -> k = "age", v = 26 + #for문 첫번째 -> k = "username", v = "sujin" + #for문 두번째 -> k = "organization", v = "한성대학교" #수정할 대상으로 가져온 member의 값을 하나 하나 수정함 - #for문 첫번째 -> member의 k(name)컬럼의 값을 v(sujin)로 변경 - #for문 두번째 -> member의 k(age)컬럼의 값으 v(26)로 변경 + #for문 첫번째 -> member의 k(username)컬럼의 값을 v(sujin)로 변경 + #for문 두번째 -> member의 k(organization)컬럼의 값으 v(한성대학교)로 변경 for k, v in patch.items(): setattr(member, k, v) From 3573368d5dd54db40d2ba9f68ea0aba28713dc57 Mon Sep 17 00:00:00 2001 From: KIMB0B Date: Sun, 19 Apr 2026 16:45:47 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(member):=20=EC=88=98=EC=A0=95=EB=90=9C?= =?UTF-8?q?=20member=20=ED=95=84=EB=93=9C=EC=97=90=20=EB=A7=9E=EC=B6=B0=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../a97f8719f6f8_update_member_field.py | 45 +++++ app/static/index.html | 5 +- app/static/login.html | 3 +- app/static/signup.html | 191 ++++++++++-------- 4 files changed, 151 insertions(+), 93 deletions(-) create mode 100644 alembic/versions/a97f8719f6f8_update_member_field.py diff --git a/alembic/versions/a97f8719f6f8_update_member_field.py b/alembic/versions/a97f8719f6f8_update_member_field.py new file mode 100644 index 0000000..86826a2 --- /dev/null +++ b/alembic/versions/a97f8719f6f8_update_member_field.py @@ -0,0 +1,45 @@ +"""update member field + +Revision ID: a97f8719f6f8 +Revises: 19316f07bc9b +Create Date: 2026-04-19 16:37:03.279205 + +""" +from typing import Sequence, Union + +import sqlmodel +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'a97f8719f6f8' +down_revision: Union[str, Sequence[str], None] = '19316f07bc9b' +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('username', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + op.drop_column('members', 'gender') + op.drop_column('members', 'nickname') + op.drop_column('members', 'phone_num') + op.drop_column('members', 'dept') + op.drop_column('members', 'birth') + op.drop_column('members', 'name') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('members', sa.Column('name', sa.VARCHAR(), autoincrement=False, nullable=False)) + op.add_column('members', sa.Column('birth', sa.DATE(), autoincrement=False, nullable=False)) + op.add_column('members', sa.Column('dept', sa.VARCHAR(), autoincrement=False, nullable=True)) + op.add_column('members', sa.Column('phone_num', sa.VARCHAR(length=20), autoincrement=False, nullable=False)) + op.add_column('members', sa.Column('nickname', sa.VARCHAR(), autoincrement=False, nullable=True)) + op.add_column('members', sa.Column('gender', sa.BOOLEAN(), autoincrement=False, nullable=True)) + op.drop_column('members', 'username') + # ### end Alembic commands ### diff --git a/app/static/index.html b/app/static/index.html index ea09a3f..6f1d12c 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -26,12 +26,11 @@

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

document.addEventListener('DOMContentLoaded', () => { const navLinks = document.getElementById('nav-links'); const token = localStorage.getItem('access_token'); - const userName = localStorage.getItem('user_name'); - const userNickname = localStorage.getItem('user_nickname'); + const userUsername = localStorage.getItem('user_username'); const userProfile = localStorage.getItem('user_profile'); if (token) { - const displayName = userNickname; + const displayName = userUsername || '사용자'; // 프로필 이미지가 있으면 해당 이미지, 없으면 기본 아이콘 사용 const profileImg = userProfile && userProfile !== 'null' diff --git a/app/static/login.html b/app/static/login.html index e05d52e..e9d0450 100644 --- a/app/static/login.html +++ b/app/static/login.html @@ -70,8 +70,7 @@

로그인

} }).then(handleResponse); - localStorage.setItem('user_name', userResult.data.name); - localStorage.setItem('user_nickname', userResult.data.nickname || ''); + localStorage.setItem('user_username', userResult.data.username); localStorage.setItem('user_profile', userResult.data.profile_url || ''); window.location.href = '/'; } catch (err) { diff --git a/app/static/signup.html b/app/static/signup.html index 6381d67..a9f8e7c 100644 --- a/app/static/signup.html +++ b/app/static/signup.html @@ -7,65 +7,101 @@ @@ -82,12 +118,14 @@

회원가입

+
+
-
+
계정 정보
+
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
- - -
-
-
- - -
-
- - -
-
- - -
+
+ + +
+ +
개인 정보
+ +
+ + +
+ +
+ + +
+ +
+ +
- +
@@ -175,9 +193,6 @@

회원가입

reader.onload = function(e) { profilePreview.style.backgroundImage = `url(${e.target.result})`; profilePreview.innerHTML = ''; - // 실제 구현에서는 여기서 서버에 이미지를 업로드하고 URL을 받아와야 함 - // 현재는 임시로 base64 또는 placeholder URL 사용 - document.getElementById('profile_url').value = "https://example.com/profile.jpg"; } reader.readAsDataURL(file); } @@ -249,25 +264,25 @@

회원가입

e.preventDefault(); if (!isEmailVerified) return alert('이메일 인증을 먼저 완료해주세요.'); - const data = { - email: verifiedEmail, - password: document.getElementById('password').value, - name: document.getElementById('name').value, - birth: document.getElementById('birth').value, - phone_num: document.getElementById('phone_num').value, - nickname: document.getElementById('nickname').value || null, - organization: document.getElementById('organization').value || null, - dept: document.getElementById('dept').value || null, - profile_url: document.getElementById('profile_url').value || null, - detail: document.getElementById('detail').value || null, - gender: document.querySelector('input[name="gender"]:checked').value === 'true' - }; + const formData = new FormData(); + formData.append('email', verifiedEmail); + formData.append('password', document.getElementById('password').value); + formData.append('username', document.getElementById('username').value); + + const organization = document.getElementById('organization').value; + if (organization) formData.append('organization', organization); + + const detail = document.getElementById('detail').value; + if (detail) formData.append('detail', detail); + + if (profileInput.files[0]) { + formData.append('profile_file', profileInput.files[0]); + } try { const result = await fetch('/members/signup', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) + body: formData }).then(handleResponse); alert(result.message || '회원가입이 완료되었습니다!');