Skip to content
Open
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -165,5 +165,7 @@ vite.config.ts.timestamp-*
calm-scarab-478705-c7-b25fdd5670c3.json
**/secret.yaml

CLAUDE.md

**/values.yaml
AGENTS.md
58 changes: 57 additions & 1 deletion main_server/app/repositories/user_repository.py
Original file line number Diff line number Diff line change
@@ -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()
75 changes: 67 additions & 8 deletions main_server/app/routers/user_router.py
Original file line number Diff line number Diff line change
@@ -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),
Expand All @@ -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),
Expand All @@ -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)
50 changes: 47 additions & 3 deletions main_server/app/schemas/user_schemas.py
Original file line number Diff line number Diff line change
@@ -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
111 changes: 110 additions & 1 deletion main_server/app/services/user_service.py
Original file line number Diff line number Diff line change
@@ -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="계정 삭제 중 오류가 발생했습니다.")