From 76295c7fd786d7321023933e7294ecd3d0329ed6 Mon Sep 17 00:00:00 2001 From: yoona Date: Sun, 3 May 2026 22:27:19 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9C=A0=EC=A0=80=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + .../app/repositories/user_repository.py | 58 ++++++++- main_server/app/routers/user_router.py | 75 ++++++++++-- main_server/app/schemas/user_schemas.py | 50 +++++++- main_server/app/services/user_service.py | 111 +++++++++++++++++- 5 files changed, 284 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 4874749..f7bb715 100644 --- a/.gitignore +++ b/.gitignore @@ -164,3 +164,6 @@ vite.config.ts.timestamp-* calm-scarab-478705-c7-b25fdd5670c3.json **/secret.yaml + +CLAUDE.md + diff --git a/main_server/app/repositories/user_repository.py b/main_server/app/repositories/user_repository.py index 01255ab..ec413f4 100644 --- a/main_server/app/repositories/user_repository.py +++ b/main_server/app/repositories/user_repository.py @@ -1,3 +1,59 @@ +from sqlalchemy.orm import Session +from typing import List + +from app.models.user_models import User, AuthUser, Fields, UserField, UserStat +from app.models.platform_models import UserPlatform, UserPlatformVerification +from app.models.post_models import Posts, POST_AGG + + class UserRepository: - def __init__(self, db): + def __init__(self, db: Session): self.db = db + + def get_user_by_id(self, user_id: str) -> User | None: + return self.db.query(User).filter(User.user_id == user_id).first() + + def update_user_profile( + self, + user: User, + name: str, + notify_email: bool, + is_public: bool, + color_theme: str, + ) -> None: + user.name = name + user.notify_email = notify_email + user.is_public = is_public + user.color_theme = color_theme + + def get_user_interests(self, user_id: str) -> List[str]: + results = ( + self.db.query(Fields.field_name) + .join(UserField, UserField.field_id == Fields.field_id) + .filter(UserField.user_id == user_id) + .all() + ) + return [row.field_name for row in results] + + def get_fields_by_names(self, names: List[str]) -> List[Fields]: + return self.db.query(Fields).filter(Fields.field_name.in_(names)).all() + + def replace_user_interests(self, user_id: str, field_ids: List[int]) -> None: + self.db.query(UserField).filter(UserField.user_id == user_id).delete() + for fid in field_ids: + self.db.add(UserField(user_id=user_id, field_id=fid)) + + def get_auth_user_by_user_id(self, user_id: str) -> AuthUser | None: + return self.db.query(AuthUser).filter(AuthUser.user_id == user_id).first() + + def delete_user_cascade(self, user_id: str) -> None: + # FK cascade 미설정 테이블을 의존 순서로 직접 삭제 + self.db.query(POST_AGG).filter(POST_AGG.user_id == user_id).delete() + # POST_KEYWORDS는 POSTS에 DB CASCADE가 걸려있어 자동 삭제 + self.db.query(Posts).filter(Posts.user_id == user_id).delete() + self.db.query(UserStat).filter(UserStat.user_id == user_id).delete() + self.db.query(UserPlatformVerification).filter(UserPlatformVerification.user_id == user_id).delete() + self.db.query(UserPlatform).filter(UserPlatform.user_id == user_id).delete() + self.db.query(AuthUser).filter(AuthUser.user_id == user_id).delete() + # USER_FIELDS는 DB CASCADE로 자동 삭제 + self.db.query(User).filter(User.user_id == user_id).delete() diff --git a/main_server/app/routers/user_router.py b/main_server/app/routers/user_router.py index 186a50b..ea1ea5c 100644 --- a/main_server/app/routers/user_router.py +++ b/main_server/app/routers/user_router.py @@ -1,17 +1,31 @@ -from fastapi import APIRouter, Depends,HTTPException +from datetime import date from typing import List -from sqlalchemy.orm import Session -from app.dependencies.database import get_db + +from fastapi import APIRouter, Depends, HTTPException, Response +from sqlalchemy.orm import Session + from app.core.verify_jwt import get_current_user_id -from app.models.user_models import UserStat +from app.dependencies.database import get_db from app.models.platform_models import Platform -from app.schemas.analytics_schemas import UserStatResponse from app.models.post_models import Posts +from app.models.user_models import UserStat +from app.repositories.user_repository import UserRepository +from app.schemas.analytics_schemas import UserStatResponse from app.schemas.post_schemas import Post -from datetime import date +from app.schemas.user_schemas import ( + DeleteAccountRequest, + InterestsResponse, + InterestsUpdateRequest, + InterestsUpdateResponse, + ProfileResponse, + ProfileUpdateRequest, + ProfileUpdateResponse, +) +from app.services.user_service import UserService router = APIRouter(prefix='/api/user') + @router.get("/stats", response_model=UserStatResponse) def get_user_stats( db: Session = Depends(get_db), @@ -24,12 +38,13 @@ def get_user_stats( raise HTTPException(status_code=404, detail="User stats not found") else: return UserStatResponse( - duration= (date.today() - user_stats[0].created_at).days, - category= [{"category": stat.category, "count": stat.count} for stat in user_stats] if user_stats[0].category is not None else [], + duration=(date.today() - user_stats[0].created_at).days, + category=[{"category": stat.category, "count": stat.count} for stat in user_stats] if user_stats[0].category is not None else [], created_at=user_stats[0].created_at, count=sum(stat.count for stat in user_stats) ) + @router.get("/posts", response_model=list[Post]) def get_user_posts( db: Session = Depends(get_db), @@ -41,3 +56,47 @@ def get_user_posts( else: posts: list[Post] = db.query(Posts, Platform).filter(Posts.user_id == user_id, Posts.category == category, Posts.platform_id == Platform.platform_id).all() return map(lambda row: Post(url=row[0].url, category=row[0].category, date=row[0].date.strftime("%Y-%m-%d"), title=row[0].title, platform=row[1].name), posts) + + +@router.get("/profile", response_model=ProfileResponse) +def get_profile( + db: Session = Depends(get_db), + user_id: str = Depends(get_current_user_id), +) -> ProfileResponse: + return UserService(UserRepository(db)).get_profile(user_id) + + +@router.patch("/profile", response_model=ProfileUpdateResponse) +def update_profile( + data: ProfileUpdateRequest, + db: Session = Depends(get_db), + user_id: str = Depends(get_current_user_id), +) -> ProfileUpdateResponse: + return UserService(UserRepository(db)).update_profile(db, user_id, data) + + +@router.get("/interests", response_model=InterestsResponse) +def get_interests( + db: Session = Depends(get_db), + user_id: str = Depends(get_current_user_id), +) -> InterestsResponse: + return UserService(UserRepository(db)).get_interests(user_id) + + +@router.patch("/interests", response_model=InterestsUpdateResponse) +def update_interests( + data: InterestsUpdateRequest, + db: Session = Depends(get_db), + user_id: str = Depends(get_current_user_id), +) -> InterestsUpdateResponse: + return UserService(UserRepository(db)).update_interests(db, user_id, data) + + +@router.delete("", status_code=204) +def delete_account( + data: DeleteAccountRequest, + db: Session = Depends(get_db), + user_id: str = Depends(get_current_user_id), +) -> Response: + UserService(UserRepository(db)).delete_account(db, user_id, data.password) + return Response(status_code=204) diff --git a/main_server/app/schemas/user_schemas.py b/main_server/app/schemas/user_schemas.py index 72a2aad..681401e 100644 --- a/main_server/app/schemas/user_schemas.py +++ b/main_server/app/schemas/user_schemas.py @@ -1,5 +1,49 @@ -from pydantic import BaseModel +import re +from datetime import datetime +from pydantic import BaseModel, field_validator -class UserBaseSchema(BaseModel): - pass + +class ProfileResponse(BaseModel): + name: str + notifyEmail: bool + publicJandi: bool + + +class ProfileUpdateRequest(BaseModel): + name: str + notifyEmail: bool + publishJandi: bool + color_theme: str + + @field_validator("color_theme") + @classmethod + def validate_hex_color(cls, v: str) -> str: + if not re.fullmatch(r"#[0-9A-Fa-f]{6}", v): + raise ValueError("color_theme은 #RRGGBB 형식의 hex 코드여야 합니다.") + return v + + +class ProfileUpdateResponse(BaseModel): + name: str + notifyEmail: bool + publishJandi: bool + updated_at: datetime + color_theme: str + + +class InterestsResponse(BaseModel): + interests: list[str] + + +class InterestsUpdateRequest(BaseModel): + interests: list[str] + + +class InterestsUpdateResponse(BaseModel): + interests: list[str] + updated_at: datetime + + +class DeleteAccountRequest(BaseModel): + password: str diff --git a/main_server/app/services/user_service.py b/main_server/app/services/user_service.py index edc0809..d0e102b 100644 --- a/main_server/app/services/user_service.py +++ b/main_server/app/services/user_service.py @@ -1,3 +1,112 @@ +from datetime import datetime + +from fastapi import HTTPException +from passlib.context import CryptContext +from sqlalchemy.orm import Session + +from app.repositories.user_repository import UserRepository +from app.schemas.user_schemas import ( + InterestsResponse, + InterestsUpdateRequest, + InterestsUpdateResponse, + ProfileResponse, + ProfileUpdateRequest, + ProfileUpdateResponse, +) + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + class UserService: - def __init__(self, repository): + def __init__(self, repository: UserRepository): self.repository = repository + + def get_profile(self, user_id: str) -> ProfileResponse: + user = self.repository.get_user_by_id(user_id) + if not user: + raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.") + return ProfileResponse( + name=user.name, + notifyEmail=user.notify_email, + publicJandi=user.is_public, + ) + + def update_profile( + self, db: Session, user_id: str, data: ProfileUpdateRequest + ) -> ProfileUpdateResponse: + try: + user = self.repository.get_user_by_id(user_id) + if not user: + raise HTTPException(status_code=400, detail="사용자를 찾을 수 없습니다.") + self.repository.update_user_profile( + user, + name=data.name, + notify_email=data.notifyEmail, + is_public=data.publishJandi, + color_theme=data.color_theme, + ) + db.commit() + return ProfileUpdateResponse( + name=user.name, + notifyEmail=user.notify_email, + publishJandi=user.is_public, + updated_at=datetime.utcnow(), + color_theme=user.color_theme, + ) + except HTTPException: + db.rollback() + raise + except Exception: + db.rollback() + raise HTTPException(status_code=400, detail="프로필 업데이트 중 오류가 발생했습니다.") + + def get_interests(self, user_id: str) -> InterestsResponse: + return InterestsResponse(interests=self.repository.get_user_interests(user_id)) + + def update_interests( + self, db: Session, user_id: str, data: InterestsUpdateRequest + ) -> InterestsUpdateResponse: + try: + if not data.interests: + self.repository.replace_user_interests(user_id, []) + db.commit() + return InterestsUpdateResponse(interests=[], updated_at=datetime.utcnow()) + + found = self.repository.get_fields_by_names(data.interests) + found_names = {f.field_name for f in found} + invalid = [n for n in data.interests if n not in found_names] + if invalid: + raise HTTPException( + status_code=400, + detail=f"존재하지 않는 관심 분야: {', '.join(invalid)}", + ) + + name_to_id = {f.field_name: f.field_id for f in found} + self.repository.replace_user_interests(user_id, [name_to_id[n] for n in data.interests]) + db.commit() + return InterestsUpdateResponse( + interests=data.interests, + updated_at=datetime.utcnow(), + ) + except HTTPException: + db.rollback() + raise + except Exception: + db.rollback() + raise HTTPException(status_code=400, detail="관심 분야 업데이트 중 오류가 발생했습니다.") + + def delete_account(self, db: Session, user_id: str, password: str) -> None: + try: + auth_user = self.repository.get_auth_user_by_user_id(user_id) + if not auth_user: + raise HTTPException(status_code=400, detail="인증 정보를 찾을 수 없습니다.") + if not pwd_context.verify(password, auth_user.hashed_password): + raise HTTPException(status_code=400, detail="비밀번호가 일치하지 않습니다.") + self.repository.delete_user_cascade(user_id) + db.commit() + except HTTPException: + db.rollback() + raise + except Exception: + db.rollback() + raise HTTPException(status_code=400, detail="계정 삭제 중 오류가 발생했습니다.")