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

588 lines
19 KiB
Python

"""
User 모듈 SQLAlchemy 모델 정의
카카오 소셜 로그인 기반 사용자 관리 모델입니다.
"""
from datetime import date, datetime
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 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[str] = mapped_column(
String(20),
nullable=False,
comment="플랫폼 구분 (youtube, instagram, facebook)",
)
# ==========================================================================
# 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=False,
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=False,
server_default=func.now(),
comment="연동 일시",
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
onupdate=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")>"
)