o2o-castad-backend/app/user/models.py

607 lines
20 KiB
Python

"""
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"<User("
f"id={self.id}, "
f"kakao_id={self.kakao_id}, "
f"nickname='{self.nickname}', "
f"role='{self.role}', "
f"is_active={self.is_active}, "
f"is_deleted={self.is_deleted}"
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,
unique=True,
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",
)
def __repr__(self) -> str:
return (
f"<RefreshToken("
f"id={self.id}, "
f"user_id={self.user_id}, "
f"is_revoked={self.is_revoked}, "
f"expires_at={self.expires_at}"
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[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",
)
def __repr__(self) -> str:
return (
f"<SocialAccount("
f"id={self.id}, "
f"user_uuid='{self.user_uuid}', "
f"platform='{self.platform}', "
f"platform_username='{self.platform_username}', "
f"is_active={self.is_active}"
f")>"
)