""" User 모듈 SQLAlchemy 모델 정의 카카오 소셜 로그인 기반 사용자 관리 모델입니다. """ from datetime import date, datetime from enum import Enum from typing import TYPE_CHECKING, List, Optional from sqlalchemy import BigInteger, Boolean, Date, DateTime, ForeignKey, Index, Integer, String, Text, func from sqlalchemy.dialects.mysql import JSON from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database.session import Base if TYPE_CHECKING: from app.home.models import Project class User(Base): """ 사용자 테이블 (카카오 소셜 로그인) 카카오 로그인을 통해 인증된 사용자 정보를 저장합니다. Attributes: id: 고유 식별자 (자동 증가) kakao_id: 카카오 고유 ID (필수, 유니크) user_uuid: 사용자 식별을 위한 UUID7 (필수, 유니크) email: 이메일 주소 (선택, 카카오에서 제공 시) 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: projects: 사용자가 소유한 프로젝트 목록 (1:N 관계) 카카오 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"), Index("idx_user_uuid", "user_uuid"), 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", "mysql_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci", }, ) # ========================================================================== # 기본 식별자 # ========================================================================== id: Mapped[int] = mapped_column( BigInteger, primary_key=True, nullable=False, autoincrement=True, comment="고유 식별자", ) # ========================================================================== # 카카오 소셜 로그인 필수 정보 # ========================================================================== kakao_id: Mapped[int] = mapped_column( BigInteger, nullable=False, unique=True, comment="카카오 고유 ID (회원번호)", ) user_uuid: Mapped[str] = mapped_column( String(36), nullable=False, unique=True, comment="사용자 식별을 위한 UUID7 (시간순 정렬 가능한 UUID)", ) # ========================================================================== # 카카오에서 제공하는 사용자 정보 (선택적) # ========================================================================== email: Mapped[Optional[str]] = mapped_column( String(255), nullable=True, comment="이메일 주소 (카카오 계정 이메일, 동의 시 제공)", ) nickname: Mapped[Optional[str]] = mapped_column( String(100), nullable=True, comment="카카오 닉네임", ) profile_image_url: Mapped[Optional[str]] = mapped_column( String(2048), nullable=True, comment="카카오 프로필 이미지 URL", ) thumbnail_image_url: Mapped[Optional[str]] = mapped_column( String(2048), nullable=True, 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: 여성)", ) # ========================================================================== # 계정 상태 관리 # ========================================================================== is_active: Mapped[bool] = mapped_column( Boolean, nullable=False, default=True, comment="계정 활성화 상태 (비활성화 시 로그인 차단)", ) is_admin: Mapped[bool] = mapped_column( Boolean, nullable=False, default=False, 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="삭제 일시", ) # ========================================================================== # 시간 정보 # ========================================================================== last_login_at: Mapped[Optional[datetime]] = mapped_column( DateTime, nullable=True, comment="마지막 로그인 일시", ) created_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, server_default=func.now(), comment="계정 생성 일시", ) updated_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, server_default=func.now(), onupdate=func.now(), comment="계정 정보 수정 일시", ) # ========================================================================== # Project 1:N 관계 (한 사용자가 여러 프로젝트를 소유) # ========================================================================== # back_populates: Project.owner와 양방향 연결 # cascade: 사용자 삭제 시 프로젝트는 유지 (owner가 NULL로 설정됨) # lazy="selectin": N+1 문제 방지 # ========================================================================== projects: Mapped[List["Project"]] = relationship( "Project", back_populates="owner", lazy="selectin", ) # ========================================================================== # RefreshToken 1:N 관계 # ========================================================================== # 한 사용자는 여러 리프레시 토큰을 가질 수 있음 (다중 기기 로그인) # ========================================================================== refresh_tokens: Mapped[List["RefreshToken"]] = relationship( "RefreshToken", back_populates="user", cascade="all, delete-orphan", lazy="selectin", ) # ========================================================================== # SocialAccount 1:N 관계 # ========================================================================== # 한 사용자는 여러 소셜 계정을 연동할 수 있음 (YouTube, Instagram, Facebook) # ========================================================================== social_accounts: Mapped[List["SocialAccount"]] = relationship( "SocialAccount", back_populates="user", cascade="all, delete-orphan", lazy="selectin", ) def __repr__(self) -> str: return ( f"" ) class RefreshToken(Base): """ 리프레시 토큰 테이블 JWT 리프레시 토큰을 DB에 저장하여 관리합니다. 토큰 폐기(로그아웃), 다중 기기 로그인 관리 등에 활용됩니다. Attributes: id: 고유 식별자 (자동 증가) user_id: 사용자 외래키 (User.id 참조) user_uuid: 사용자 UUID (User.user_uuid 참조) token_hash: 리프레시 토큰의 SHA-256 해시값 (원본 저장 X) expires_at: 토큰 만료 일시 is_revoked: 토큰 폐기 여부 (로그아웃 시 True) created_at: 토큰 생성 일시 revoked_at: 토큰 폐기 일시 user_agent: 접속 기기 정보 (브라우저, OS 등) ip_address: 접속 IP 주소 보안 고려사항: - 토큰 원본은 저장하지 않고 해시값만 저장 - 토큰 검증 시 해시값 비교로 유효성 확인 - 로그아웃 시 is_revoked=True로 설정하여 재사용 방지 """ __tablename__ = "refresh_token" __table_args__ = ( Index("idx_refresh_token_user_id", "user_id"), Index("idx_refresh_token_user_uuid", "user_uuid"), Index("idx_refresh_token_token_hash", "token_hash", unique=True), Index("idx_refresh_token_expires_at", "expires_at"), Index("idx_refresh_token_is_revoked", "is_revoked"), { "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_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("user.id", ondelete="CASCADE"), nullable=False, comment="사용자 외래키 (User.id 참조)", ) user_uuid: Mapped[str] = mapped_column( String(36), nullable=False, comment="사용자 UUID (User.user_uuid 참조)", ) token_hash: Mapped[str] = mapped_column( String(64), nullable=False, comment="리프레시 토큰 SHA-256 해시값", ) expires_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, comment="토큰 만료 일시", ) is_revoked: Mapped[bool] = mapped_column( Boolean, nullable=False, default=False, comment="토큰 폐기 여부 (True: 폐기됨)", ) created_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, server_default=func.now(), comment="토큰 생성 일시", ) revoked_at: Mapped[Optional[datetime]] = mapped_column( DateTime, nullable=True, comment="토큰 폐기 일시", ) user_agent: Mapped[Optional[str]] = mapped_column( String(500), nullable=True, comment="접속 기기 정보 (브라우저, OS 등)", ) ip_address: Mapped[Optional[str]] = mapped_column( String(45), nullable=True, comment="접속 IP 주소 (IPv4/IPv6)", ) # ========================================================================== # User 관계 # ========================================================================== user: Mapped["User"] = relationship( "User", back_populates="refresh_tokens", lazy="selectin", # lazy loading 방지 ) def __repr__(self) -> str: return ( f"" ) class Platform(str, Enum): """소셜 플랫폼 구분""" YOUTUBE = "youtube" INSTAGRAM = "instagram" FACEBOOK = "facebook" TIKTOK = "tiktok" class SocialAccount(Base): """ 소셜 계정 연동 테이블 사용자가 연동한 외부 소셜 플랫폼 계정 정보를 저장합니다. YouTube, Instagram, Facebook 계정 연동을 지원합니다. Attributes: id: 고유 식별자 (자동 증가) user_id: 사용자 외래키 (User.id 참조) platform: 플랫폼 구분 (youtube, instagram, facebook) access_token: OAuth 액세스 토큰 refresh_token: OAuth 리프레시 토큰 (선택) token_expires_at: 토큰 만료 일시 scope: 허용된 권한 범위 platform_user_id: 플랫폼 내 사용자 고유 ID platform_username: 플랫폼 내 사용자명/핸들 platform_data: 플랫폼별 추가 정보 (JSON) is_active: 연동 활성화 상태 connected_at: 연동 일시 updated_at: 정보 수정 일시 플랫폼별 platform_data 예시: - YouTube: {"channel_id": "UC...", "channel_title": "채널명"} - Instagram: {"business_account_id": "...", "facebook_page_id": "..."} - Facebook: {"page_id": "...", "page_access_token": "..."} Relationships: user: 연동된 사용자 (User 테이블 참조) """ __tablename__ = "social_account" __table_args__ = ( Index("idx_social_account_user_uuid", "user_uuid"), Index("idx_social_account_platform", "platform"), Index("idx_social_account_is_active", "is_active"), Index("idx_social_account_is_deleted", "is_deleted"), Index( "uq_user_platform_account", "user_uuid", "platform", "platform_user_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_uuid: Mapped[str] = mapped_column( String(36), ForeignKey("user.user_uuid", ondelete="CASCADE"), nullable=False, comment="사용자 외래키 (User.user_uuid 참조)", ) # ========================================================================== # 플랫폼 구분 # ========================================================================== platform: Mapped[Platform] = mapped_column( String(20), nullable=False, comment="플랫폼 구분 (youtube, instagram, facebook, tiktok)", ) # ========================================================================== # OAuth 토큰 정보 # ========================================================================== access_token: Mapped[str] = mapped_column( Text, nullable=False, comment="OAuth 액세스 토큰", ) refresh_token: Mapped[Optional[str]] = mapped_column( Text, nullable=True, comment="OAuth 리프레시 토큰 (플랫폼에 따라 선택적)", ) token_expires_at: Mapped[Optional[datetime]] = mapped_column( DateTime, nullable=True, comment="토큰 만료 일시", ) scope: Mapped[Optional[str]] = mapped_column( Text, nullable=True, comment="허용된 권한 범위 (OAuth scope)", ) # ========================================================================== # 플랫폼 계정 식별 정보 # ========================================================================== platform_user_id: Mapped[Optional[str]] = mapped_column( String(100), nullable=True, comment="플랫폼 내 사용자 고유 ID", ) platform_username: Mapped[Optional[str]] = mapped_column( String(100), nullable=True, comment="플랫폼 내 사용자명/핸들 (@username)", ) # ========================================================================== # 플랫폼별 추가 정보 (JSON) # ========================================================================== platform_data: Mapped[Optional[dict]] = mapped_column( JSON, nullable=True, comment="플랫폼별 추가 정보 (채널ID, 페이지ID 등)", ) # ========================================================================== # 연동 상태 # ========================================================================== is_active: Mapped[bool] = mapped_column( Boolean, nullable=False, default=True, comment="활성화 상태 (비활성화 시 사용 중지)", ) is_deleted: Mapped[bool] = mapped_column( Boolean, nullable=False, default=False, comment="소프트 삭제 여부 (True: 삭제됨)", ) # ========================================================================== # 시간 정보 # ========================================================================== connected_at: Mapped[datetime] = mapped_column( DateTime, nullable=True, server_default=func.now(), onupdate=func.now(), comment="연결 일시", ) updated_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, server_default=func.now(), onupdate=func.now(), comment="정보 수정 일시", ) created_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, server_default=func.now(), comment="생성 일시", ) # ========================================================================== # User 관계 # ========================================================================== user: Mapped["User"] = relationship( "User", back_populates="social_accounts", lazy="selectin", # lazy loading 방지 ) def __repr__(self) -> str: return ( f"" )