diff --git a/app/database/session.py b/app/database/session.py index cb4ef52..8e35772 100644 --- a/app/database/session.py +++ b/app/database/session.py @@ -72,7 +72,9 @@ async def create_db_tables(): import asyncio # 모델 import (테이블 메타데이터 등록용) - from app.home.models import Image, Project # noqa: F401 + # 주의: User를 먼저 import해야 UserProject가 User를 참조할 수 있음 + from app.user.models import User # noqa: F401 + from app.home.models import Image, Project, UserProject # noqa: F401 from app.lyric.models import Lyric # noqa: F401 from app.song.models import Song # noqa: F401 from app.video.models import Video # noqa: F401 diff --git a/app/home/models.py b/app/home/models.py index 13c8508..ad4135b 100644 --- a/app/home/models.py +++ b/app/home/models.py @@ -4,19 +4,138 @@ Home 모듈 SQLAlchemy 모델 정의 이 모듈은 영상 제작 파이프라인의 핵심 데이터 모델을 정의합니다. - Project: 프로젝트(사용자 입력 이력) 관리 - Image: 업로드된 이미지 URL 관리 +- UserProject: User와 Project 간 M:N 관계 중계 테이블 """ from datetime import datetime from typing import TYPE_CHECKING, List, Optional -from sqlalchemy import DateTime, Index, Integer, String, Text, func +from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, Text, func from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database.session import Base +# ============================================================================= +# User-Project M:N 관계 중계 테이블 +# ============================================================================= +# +# 설계 의도: +# - User와 Project는 다대다(M:N) 관계입니다. +# - 한 사용자는 여러 프로젝트에 참여할 수 있습니다. +# - 한 프로젝트에는 여러 사용자가 참여할 수 있습니다. +# +# 중계 테이블 역할: +# - UserProject 테이블이 두 테이블 간의 관계를 연결합니다. +# - 각 레코드는 특정 사용자와 특정 프로젝트의 연결을 나타냅니다. +# - 추가 속성(role, joined_at)으로 관계의 메타데이터를 저장합니다. +# +# 외래키 설정: +# - user_id: User 테이블의 id를 참조 (ON DELETE CASCADE) +# - project_id: Project 테이블의 id를 참조 (ON DELETE CASCADE) +# - CASCADE 설정으로 부모 레코드 삭제 시 중계 레코드도 자동 삭제됩니다. +# +# 관계 방향: +# - User.projects → UserProject → Project (사용자가 참여한 프로젝트 목록) +# - Project.users → UserProject → User (프로젝트에 참여한 사용자 목록) +# ============================================================================= + + +class UserProject(Base): + """ + User-Project M:N 관계 중계 테이블 + + 사용자와 프로젝트 간의 다대다 관계를 관리합니다. + 한 사용자는 여러 프로젝트에 참여할 수 있고, + 한 프로젝트에는 여러 사용자가 참여할 수 있습니다. + + Attributes: + id: 고유 식별자 (자동 증가) + user_id: 사용자 외래키 (User.id 참조) + project_id: 프로젝트 외래키 (Project.id 참조) + role: 프로젝트 내 사용자 역할 (owner: 소유자, member: 멤버, viewer: 조회자) + joined_at: 프로젝트 참여 일시 + + 외래키 제약조건: + - user_id → user.id (ON DELETE CASCADE) + - project_id → project.id (ON DELETE CASCADE) + + 유니크 제약조건: + - (user_id, project_id) 조합은 유일해야 함 (중복 참여 방지) + """ + + __tablename__ = "user_project" + __table_args__ = ( + Index("idx_user_project_user_id", "user_id"), + Index("idx_user_project_project_id", "project_id"), + Index("idx_user_project_user_project", "user_id", "project_id", unique=True), + { + "mysql_engine": "InnoDB", + "mysql_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + }, + ) + + id: Mapped[int] = mapped_column( + Integer, + primary_key=True, + nullable=False, + autoincrement=True, + comment="고유 식별자", + ) + + # 외래키: User 테이블 참조 + # - BigInteger 사용 (User.id가 BigInteger이므로 타입 일치 필요) + # - ondelete="CASCADE": User 삭제 시 연결된 UserProject 레코드도 삭제 + user_id: Mapped[int] = mapped_column( + BigInteger, + ForeignKey("user.id", ondelete="CASCADE"), + nullable=False, + comment="사용자 외래키 (User.id 참조)", + ) + + # 외래키: Project 테이블 참조 + # - Integer 사용 (Project.id가 Integer이므로 타입 일치 필요) + # - ondelete="CASCADE": Project 삭제 시 연결된 UserProject 레코드도 삭제 + project_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("project.id", ondelete="CASCADE"), + nullable=False, + comment="프로젝트 외래키 (Project.id 참조)", + ) + + # ========================================================================== + # Relationships (관계 설정) + # ========================================================================== + # back_populates: 양방향 관계 설정 (User.user_projects, Project.user_projects) + # lazy="selectin": N+1 문제 방지를 위한 즉시 로딩 + # ========================================================================== + user: Mapped["User"] = relationship( + "User", + back_populates="user_projects", + lazy="selectin", + ) + + project: Mapped["Project"] = relationship( + "Project", + back_populates="user_projects", + lazy="selectin", + ) + + def __repr__(self) -> str: + return ( + f"" + ) + + if TYPE_CHECKING: from app.lyric.models import Lyric from app.song.models import Song + from app.user.models import User from app.video.models import Video @@ -39,6 +158,8 @@ class Project(Base): lyrics: 생성된 가사 목록 songs: 생성된 노래 목록 videos: 최종 영상 결과 목록 + user_projects: User와의 M:N 관계 (중계 테이블 통한 연결) + users: 프로젝트에 참여한 사용자 목록 (Association Proxy) """ __tablename__ = "project" @@ -123,6 +244,20 @@ class Project(Base): lazy="selectin", ) + # ========================================================================== + # User M:N 관계 (중계 테이블 UserProject 통한 연결) + # ========================================================================== + # back_populates: UserProject.project와 양방향 연결 + # cascade: Project 삭제 시 UserProject 레코드도 삭제 (User는 유지) + # lazy="selectin": N+1 문제 방지 + # ========================================================================== + user_projects: Mapped[List["UserProject"]] = relationship( + "UserProject", + back_populates="project", + cascade="all, delete-orphan", + lazy="selectin", + ) + def __repr__(self) -> str: def truncate(value: str | None, max_len: int = 10) -> str: if value is None: diff --git a/app/user/models.py b/app/user/models.py index f753f5a..0c34246 100644 --- a/app/user/models.py +++ b/app/user/models.py @@ -2,22 +2,19 @@ User 모듈 SQLAlchemy 모델 정의 카카오 소셜 로그인 기반 사용자 관리 모델입니다. - -주의: 이 모델은 현재 개발 중이므로 create_db_tables()에서 import하지 않습니다. - 테이블 생성이 필요할 때 app/database/session.py의 create_db_tables()에 - 아래 import를 추가하세요: - - from app.user.models import User # noqa: F401 """ -from datetime import datetime -from typing import Optional +from datetime import date, datetime +from typing import TYPE_CHECKING, List, Optional -from sqlalchemy import BigInteger, Boolean, DateTime, Index, String, func -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import BigInteger, Boolean, Date, DateTime, Index, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database.session import Base +if TYPE_CHECKING: + from app.home.models import UserProject + class User(Base): """ @@ -32,25 +29,50 @@ class User(Base): nickname: 카카오 닉네임 (선택) profile_image_url: 카카오 프로필 이미지 URL (선택) thumbnail_image_url: 카카오 썸네일 이미지 URL (선택) + phone: 전화번호 (선택) + name: 실명 (선택) + birth_date: 생년월일 (선택) + gender: 성별 (선택) is_active: 계정 활성화 상태 (기본 True) is_admin: 관리자 여부 (기본 False) + role: 사용자 권한 (user, manager, admin 등) + is_deleted: 소프트 삭제 여부 (기본 False) + deleted_at: 삭제 일시 last_login_at: 마지막 로그인 일시 created_at: 계정 생성 일시 updated_at: 계정 정보 수정 일시 + 권한 체계: + - user: 일반 사용자 (기본값) + - manager: 매니저 (일부 관리 권한) + - admin: 관리자 (is_admin=True와 동일) + + 소프트 삭제: + - is_deleted=True로 설정 시 삭제된 것으로 처리 + - 실제 데이터는 DB에 유지됨 + + Relationships: + user_projects: Project와의 M:N 관계 (중계 테이블 통한 연결) + projects: 사용자가 참여한 프로젝트 목록 (Association Proxy) + 카카오 API 응답 필드 매핑: - kakao_id: id (카카오 회원번호) - email: kakao_account.email - nickname: kakao_account.profile.nickname 또는 properties.nickname - profile_image_url: kakao_account.profile.profile_image_url - thumbnail_image_url: kakao_account.profile.thumbnail_image_url + - birth_date: kakao_account.birthday 또는 kakao_account.birthyear + - gender: kakao_account.gender (male/female) """ __tablename__ = "user" __table_args__ = ( Index("idx_user_kakao_id", "kakao_id", unique=True), Index("idx_user_email", "email"), + Index("idx_user_phone", "phone"), Index("idx_user_is_active", "is_active"), + Index("idx_user_is_deleted", "is_deleted"), + Index("idx_user_role", "role"), Index("idx_user_created_at", "created_at"), { "mysql_engine": "InnoDB", @@ -107,6 +129,33 @@ class User(Base): comment="카카오 썸네일 이미지 URL", ) + # ========================================================================== + # 추가 사용자 정보 + # ========================================================================== + phone: Mapped[Optional[str]] = mapped_column( + String(20), + nullable=True, + comment="전화번호 (본인인증, 알림용)", + ) + + name: Mapped[Optional[str]] = mapped_column( + String(50), + nullable=True, + comment="실명 (법적 실명, 결제/계약 시 사용)", + ) + + birth_date: Mapped[Optional[date]] = mapped_column( + Date, + nullable=True, + comment="생년월일 (카카오 제공 또는 직접 입력)", + ) + + gender: Mapped[Optional[str]] = mapped_column( + String(10), + nullable=True, + comment="성별 (male: 남성, female: 여성)", + ) + # ========================================================================== # 계정 상태 관리 # ========================================================================== @@ -124,6 +173,29 @@ class User(Base): comment="관리자 권한 여부", ) + role: Mapped[str] = mapped_column( + String(20), + nullable=False, + default="user", + comment="사용자 권한 (user: 일반, manager: 매니저, admin: 관리자)", + ) + + # ========================================================================== + # 소프트 삭제 + # ========================================================================== + is_deleted: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + default=False, + comment="소프트 삭제 여부 (True: 삭제됨)", + ) + + deleted_at: Mapped[Optional[datetime]] = mapped_column( + DateTime, + nullable=True, + comment="삭제 일시", + ) + # ========================================================================== # 시간 정보 # ========================================================================== @@ -148,12 +220,28 @@ class User(Base): comment="계정 정보 수정 일시", ) + # ========================================================================== + # Project M:N 관계 (중계 테이블 UserProject 통한 연결) + # ========================================================================== + # back_populates: UserProject.user와 양방향 연결 + # cascade: User 삭제 시 UserProject 레코드도 삭제 (Project는 유지) + # lazy="selectin": N+1 문제 방지 + # ========================================================================== + user_projects: Mapped[List["UserProject"]] = relationship( + "UserProject", + back_populates="user", + cascade="all, delete-orphan", + lazy="selectin", + ) + def __repr__(self) -> str: return ( f"" ) diff --git a/config.py b/config.py index 5a22d87..ba2961c 100644 --- a/config.py +++ b/config.py @@ -340,9 +340,6 @@ class LogSettings(BaseSettings): prj_settings = ProjectSettings() apikey_settings = APIKeySettings() db_settings = DatabaseSettings() -security_settings = SecuritySettings() -kakao_settings = KakaoSettings() -notification_settings = NotificationSettings() cors_settings = CORSSettings() crawler_settings = CrawlerSettings() azure_blob_settings = AzureBlobSettings() diff --git a/main.py b/main.py index 5a149e6..2a67612 100644 --- a/main.py +++ b/main.py @@ -6,6 +6,10 @@ from scalar_fastapi import get_scalar_api_reference from app.admin_manager import init_admin from app.core.common import lifespan from app.database.session import engine + +# 주의: User 모델을 먼저 import해야 UserProject가 User를 참조할 수 있음 +from app.user.models import User # noqa: F401 + from app.home.api.routers.v1.home import router as home_router from app.lyric.api.routers.v1.lyric import router as lyric_router from app.song.api.routers.v1.song import router as song_router