사용자의 uuid -> ksuid로 변경, 테이블 구조 변경

insta
Dohyun Lim 2026-01-28 14:05:15 +09:00
parent dc7351d0f9
commit aa8d9d7c14
13 changed files with 170 additions and 75 deletions

View File

@ -149,7 +149,7 @@ class Project(Base):
id: 고유 식별자 (자동 증가) id: 고유 식별자 (자동 증가)
store_name: 고객명 (필수) store_name: 고객명 (필수)
region: 지역명 (필수, : 서울, 부산, 대구 ) region: 지역명 (필수, : 서울, 부산, 대구 )
task_id: 작업 고유 식별자 (UUID 형식, 36) task_id: 작업 고유 식별자 (KSUID 형식, 27)
detail_region_info: 상세 지역 정보 (선택, JSON 또는 텍스트 형식) detail_region_info: 상세 지역 정보 (선택, JSON 또는 텍스트 형식)
created_at: 생성 일시 (자동 설정) created_at: 생성 일시 (자동 설정)
@ -184,21 +184,20 @@ class Project(Base):
store_name: Mapped[str] = mapped_column( store_name: Mapped[str] = mapped_column(
String(255), String(255),
nullable=False, nullable=False,
index=True,
comment="가게명", comment="가게명",
) )
region: Mapped[str] = mapped_column( region: Mapped[str] = mapped_column(
String(100), String(100),
nullable=False, nullable=False,
index=True,
comment="지역명 (예: 군산)", comment="지역명 (예: 군산)",
) )
task_id: Mapped[str] = mapped_column( task_id: Mapped[str] = mapped_column(
String(36), String(27),
nullable=False, nullable=False,
comment="프로젝트 작업 고유 식별자 (UUID)", unique=True,
comment="프로젝트 작업 고유 식별자 (KSUID)",
) )
detail_region_info: Mapped[Optional[str]] = mapped_column( detail_region_info: Mapped[Optional[str]] = mapped_column(
@ -281,7 +280,7 @@ class Image(Base):
Attributes: Attributes:
id: 고유 식별자 (자동 증가) id: 고유 식별자 (자동 증가)
task_id: 이미지 업로드 작업 고유 식별자 (UUID) task_id: 이미지 업로드 작업 고유 식별자 (KSUID)
img_name: 이미지명 img_name: 이미지명
img_url: 이미지 URL (S3, CDN 등의 경로) img_url: 이미지 URL (S3, CDN 등의 경로)
created_at: 생성 일시 (자동 설정) created_at: 생성 일시 (자동 설정)
@ -289,6 +288,7 @@ class Image(Base):
__tablename__ = "image" __tablename__ = "image"
__table_args__ = ( __table_args__ = (
Index("idx_image_task_id", "task_id"),
{ {
"mysql_engine": "InnoDB", "mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4", "mysql_charset": "utf8mb4",
@ -305,9 +305,10 @@ class Image(Base):
) )
task_id: Mapped[str] = mapped_column( task_id: Mapped[str] = mapped_column(
String(36), String(27),
nullable=False, nullable=False,
comment="이미지 업로드 작업 고유 식별자 (UUID)", unique=True,
comment="이미지 업로드 작업 고유 식별자 (KSUID)",
) )
img_name: Mapped[str] = mapped_column( img_name: Mapped[str] = mapped_column(

View File

@ -91,7 +91,7 @@ class GenerateUrlsRequest(GenerateRequestInfo):
class GenerateUploadResponse(BaseModel): class GenerateUploadResponse(BaseModel):
"""파일 업로드 기반 생성 응답 스키마""" """파일 업로드 기반 생성 응답 스키마"""
task_id: str = Field(..., description="작업 고유 식별자 (UUID7)") task_id: str = Field(..., description="작업 고유 식별자 (KSUID)")
status: Literal["processing", "completed", "failed"] = Field( status: Literal["processing", "completed", "failed"] = Field(
..., description="작업 상태" ..., description="작업 상태"
) )
@ -102,7 +102,7 @@ class GenerateUploadResponse(BaseModel):
class GenerateResponse(BaseModel): class GenerateResponse(BaseModel):
"""생성 응답 스키마""" """생성 응답 스키마"""
task_id: str = Field(..., description="작업 고유 식별자 (UUID7)") task_id: str = Field(..., description="작업 고유 식별자 (KSUID)")
status: Literal["processing", "completed", "failed"] = Field( status: Literal["processing", "completed", "failed"] = Field(
..., description="작업 상태" ..., description="작업 상태"
) )
@ -291,7 +291,7 @@ class ImageUploadResponse(BaseModel):
} }
) )
task_id: str = Field(..., description="작업 고유 식별자 (새로 생성된 UUID7)") task_id: str = Field(..., description="작업 고유 식별자 (새로 생성된 KSUID)")
total_count: int = Field(..., description="총 업로드된 이미지 개수") total_count: int = Field(..., description="총 업로드된 이미지 개수")
url_count: int = Field(..., description="URL로 등록된 이미지 개수") url_count: int = Field(..., description="URL로 등록된 이미지 개수")
file_count: int = Field(..., description="파일로 업로드된 이미지 개수") file_count: int = Field(..., description="파일로 업로드된 이미지 개수")

View File

@ -1,7 +1,7 @@
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, List from typing import TYPE_CHECKING, List
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy.dialects.mysql import LONGTEXT from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@ -23,7 +23,7 @@ class Lyric(Base):
Attributes: Attributes:
id: 고유 식별자 (자동 증가) id: 고유 식별자 (자동 증가)
project_id: 연결된 Project의 id (외래키) project_id: 연결된 Project의 id (외래키)
task_id: 가사 생성 작업의 고유 식별자 (UUID 형식) task_id: 가사 생성 작업의 고유 식별자 (KSUID 형식)
status: 처리 상태 (pending, processing, completed, failed ) status: 처리 상태 (pending, processing, completed, failed )
lyric_prompt: 가사 생성에 사용된 프롬프트 lyric_prompt: 가사 생성에 사용된 프롬프트
lyric_result: 생성된 가사 결과 (LONGTEXT로 가사 지원) lyric_result: 생성된 가사 결과 (LONGTEXT로 가사 지원)
@ -37,6 +37,8 @@ class Lyric(Base):
__tablename__ = "lyric" __tablename__ = "lyric"
__table_args__ = ( __table_args__ = (
Index("idx_lyric_task_id", "task_id"),
Index("idx_lyric_project_id", "project_id"),
{ {
"mysql_engine": "InnoDB", "mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4", "mysql_charset": "utf8mb4",
@ -56,14 +58,14 @@ class Lyric(Base):
Integer, Integer,
ForeignKey("project.id", ondelete="CASCADE"), ForeignKey("project.id", ondelete="CASCADE"),
nullable=False, nullable=False,
index=True,
comment="연결된 Project의 id", comment="연결된 Project의 id",
) )
task_id: Mapped[str] = mapped_column( task_id: Mapped[str] = mapped_column(
String(36), String(27),
nullable=False, nullable=False,
comment="가사 생성 작업 고유 식별자 (UUID)", unique=True,
comment="가사 생성 작업 고유 식별자 (KSUID)",
) )
status: Mapped[str] = mapped_column( status: Mapped[str] = mapped_column(

View File

@ -1,7 +1,7 @@
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, List, Optional from typing import TYPE_CHECKING, List, Optional
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text, func from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base from app.database.session import Base
@ -23,7 +23,7 @@ class Song(Base):
id: 고유 식별자 (자동 증가) id: 고유 식별자 (자동 증가)
project_id: 연결된 Project의 id (외래키) project_id: 연결된 Project의 id (외래키)
lyric_id: 연결된 Lyric의 id (외래키) lyric_id: 연결된 Lyric의 id (외래키)
task_id: 노래 생성 작업의 고유 식별자 (UUID 형식) task_id: 노래 생성 작업의 고유 식별자 (KSUID 형식)
suno_task_id: Suno API 작업 고유 식별자 (선택) suno_task_id: Suno API 작업 고유 식별자 (선택)
status: 처리 상태 (processing, uploading, completed, failed) status: 처리 상태 (processing, uploading, completed, failed)
song_prompt: 노래 생성에 사용된 프롬프트 song_prompt: 노래 생성에 사용된 프롬프트
@ -39,6 +39,9 @@ class Song(Base):
__tablename__ = "song" __tablename__ = "song"
__table_args__ = ( __table_args__ = (
Index("idx_song_task_id", "task_id"),
Index("idx_song_project_id", "project_id"),
Index("idx_song_lyric_id", "lyric_id"),
{ {
"mysql_engine": "InnoDB", "mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4", "mysql_charset": "utf8mb4",
@ -58,7 +61,6 @@ class Song(Base):
Integer, Integer,
ForeignKey("project.id", ondelete="CASCADE"), ForeignKey("project.id", ondelete="CASCADE"),
nullable=False, nullable=False,
index=True,
comment="연결된 Project의 id", comment="연결된 Project의 id",
) )
@ -66,14 +68,14 @@ class Song(Base):
Integer, Integer,
ForeignKey("lyric.id", ondelete="CASCADE"), ForeignKey("lyric.id", ondelete="CASCADE"),
nullable=False, nullable=False,
index=True,
comment="연결된 Lyric의 id", comment="연결된 Lyric의 id",
) )
task_id: Mapped[str] = mapped_column( task_id: Mapped[str] = mapped_column(
String(36), String(27),
nullable=False, nullable=False,
comment="노래 생성 작업 고유 식별자 (UUID)", unique=True,
comment="노래 생성 작업 고유 식별자 (KSUID)",
) )
suno_task_id: Mapped[Optional[str]] = mapped_column( suno_task_id: Mapped[Optional[str]] = mapped_column(
@ -177,6 +179,7 @@ class SongTimestamp(Base):
__tablename__ = "song_timestamp" __tablename__ = "song_timestamp"
__table_args__ = ( __table_args__ = (
Index("idx_song_timestamp_suno_audio_id", "suno_audio_id"),
{ {
"mysql_engine": "InnoDB", "mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4", "mysql_charset": "utf8mb4",
@ -195,7 +198,6 @@ class SongTimestamp(Base):
suno_audio_id: Mapped[str] = mapped_column( suno_audio_id: Mapped[str] = mapped_column(
String(64), String(64),
nullable=False, nullable=False,
index=True,
comment="가사의 원본 오디오 ID", comment="가사의 원본 오디오 ID",
) )

View File

@ -58,14 +58,14 @@ async def get_current_user(
if payload.get("type") != "access": if payload.get("type") != "access":
raise InvalidTokenError("액세스 토큰이 아닙니다.") raise InvalidTokenError("액세스 토큰이 아닙니다.")
user_id = payload.get("sub") user_ksuid = payload.get("sub")
if user_id is None: if user_ksuid is None:
raise InvalidTokenError() raise InvalidTokenError()
# 사용자 조회 # 사용자 조회
result = await session.execute( result = await session.execute(
select(User).where( select(User).where(
User.id == int(user_id), User.user_ksuid == user_ksuid,
User.is_deleted == False, # noqa: E712 User.is_deleted == False, # noqa: E712
) )
) )
@ -106,13 +106,13 @@ async def get_current_user_optional(
if payload.get("type") != "access": if payload.get("type") != "access":
return None return None
user_id = payload.get("sub") user_ksuid = payload.get("sub")
if user_id is None: if user_ksuid is None:
return None return None
result = await session.execute( result = await session.execute(
select(User).where( select(User).where(
User.id == int(user_id), User.user_ksuid == user_ksuid,
User.is_deleted == False, # noqa: E712 User.is_deleted == False, # noqa: E712
) )
) )

View File

@ -26,6 +26,7 @@ class User(Base):
Attributes: Attributes:
id: 고유 식별자 (자동 증가) id: 고유 식별자 (자동 증가)
kakao_id: 카카오 고유 ID (필수, 유니크) kakao_id: 카카오 고유 ID (필수, 유니크)
user_ksuid: 사용자 식별을 위한 KSUID (필수, 유니크)
email: 이메일 주소 (선택, 카카오에서 제공 ) email: 이메일 주소 (선택, 카카오에서 제공 )
nickname: 카카오 닉네임 (선택) nickname: 카카오 닉네임 (선택)
profile_image_url: 카카오 프로필 이미지 URL (선택) profile_image_url: 카카오 프로필 이미지 URL (선택)
@ -68,7 +69,8 @@ class User(Base):
__tablename__ = "user" __tablename__ = "user"
__table_args__ = ( __table_args__ = (
Index("idx_user_kakao_id", "kakao_id", unique=True), Index("idx_user_kakao_id", "kakao_id"),
Index("idx_user_ksuid", "user_ksuid"),
Index("idx_user_email", "email"), Index("idx_user_email", "email"),
Index("idx_user_phone", "phone"), Index("idx_user_phone", "phone"),
Index("idx_user_is_active", "is_active"), Index("idx_user_is_active", "is_active"),
@ -103,6 +105,13 @@ class User(Base):
comment="카카오 고유 ID (회원번호)", comment="카카오 고유 ID (회원번호)",
) )
user_ksuid: Mapped[str] = mapped_column(
String(27),
nullable=False,
unique=True,
comment="사용자 식별을 위한 KSUID (K-Sortable Unique Identifier)",
)
# ========================================================================== # ==========================================================================
# 카카오에서 제공하는 사용자 정보 (선택적) # 카카오에서 제공하는 사용자 정보 (선택적)
# ========================================================================== # ==========================================================================
@ -282,6 +291,7 @@ class RefreshToken(Base):
Attributes: Attributes:
id: 고유 식별자 (자동 증가) id: 고유 식별자 (자동 증가)
user_id: 사용자 외래키 (User.id 참조) user_id: 사용자 외래키 (User.id 참조)
user_ksuid: 사용자 KSUID (User.user_ksuid 참조)
token_hash: 리프레시 토큰의 SHA-256 해시값 (원본 저장 X) token_hash: 리프레시 토큰의 SHA-256 해시값 (원본 저장 X)
expires_at: 토큰 만료 일시 expires_at: 토큰 만료 일시
is_revoked: 토큰 폐기 여부 (로그아웃 True) is_revoked: 토큰 폐기 여부 (로그아웃 True)
@ -299,6 +309,7 @@ class RefreshToken(Base):
__tablename__ = "refresh_token" __tablename__ = "refresh_token"
__table_args__ = ( __table_args__ = (
Index("idx_refresh_token_user_id", "user_id"), Index("idx_refresh_token_user_id", "user_id"),
Index("idx_refresh_token_user_ksuid", "user_ksuid"),
Index("idx_refresh_token_token_hash", "token_hash", unique=True), Index("idx_refresh_token_token_hash", "token_hash", unique=True),
Index("idx_refresh_token_expires_at", "expires_at"), Index("idx_refresh_token_expires_at", "expires_at"),
Index("idx_refresh_token_is_revoked", "is_revoked"), Index("idx_refresh_token_is_revoked", "is_revoked"),
@ -324,6 +335,12 @@ class RefreshToken(Base):
comment="사용자 외래키 (User.id 참조)", comment="사용자 외래키 (User.id 참조)",
) )
user_ksuid: Mapped[str] = mapped_column(
String(27),
nullable=False,
comment="사용자 KSUID (User.user_ksuid 참조)",
)
token_hash: Mapped[str] = mapped_column( token_hash: Mapped[str] = mapped_column(
String(64), String(64),
nullable=False, nullable=False,

View File

@ -156,13 +156,13 @@ class UserBriefResponse(BaseModel):
class LoginResponse(BaseModel): class LoginResponse(BaseModel):
"""로그인 응답 (토큰 + 사용자 정보)""" """로그인 응답 (토큰 정보)"""
access_token: str = Field(..., description="액세스 토큰") access_token: str = Field(..., description="액세스 토큰")
refresh_token: str = Field(..., description="리프레시 토큰") refresh_token: str = Field(..., description="리프레시 토큰")
token_type: str = Field(default="Bearer", description="토큰 타입") token_type: str = Field(default="Bearer", description="토큰 타입")
expires_in: int = Field(..., description="액세스 토큰 만료 시간 (초)") expires_in: int = Field(..., description="액세스 토큰 만료 시간 (초)")
user: UserBriefResponse = Field(..., description="사용자 정보") is_new_user: bool = Field(..., description="신규 가입 여부")
redirect_url: str = Field(..., description="로그인 후 리다이렉트할 프론트엔드 URL") redirect_url: str = Field(..., description="로그인 후 리다이렉트할 프론트엔드 URL")
model_config = { model_config = {
@ -172,13 +172,7 @@ class LoginResponse(BaseModel):
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidHlwZSI6InJlZnJlc2giLCJleHAiOjE3MDU4MzM2MDB9.yyy", "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidHlwZSI6InJlZnJlc2giLCJleHAiOjE3MDU4MzM2MDB9.yyy",
"token_type": "Bearer", "token_type": "Bearer",
"expires_in": 3600, "expires_in": 3600,
"user": { "is_new_user": False,
"id": 1,
"nickname": "홍길동",
"email": "user@kakao.com",
"profile_image_url": "https://k.kakaocdn.net/dn/.../profile.jpg",
"is_new_user": False
},
"redirect_url": "http://localhost:3000" "redirect_url": "http://localhost:3000"
} }
} }

View File

@ -24,11 +24,11 @@ from app.user.exceptions import (
UserNotFoundError, UserNotFoundError,
) )
from app.user.models import RefreshToken, User from app.user.models import RefreshToken, User
from app.utils.common import generate_ksuid
from app.user.schemas.user_schema import ( from app.user.schemas.user_schema import (
AccessTokenResponse, AccessTokenResponse,
KakaoUserInfo, KakaoUserInfo,
LoginResponse, LoginResponse,
UserBriefResponse,
) )
from app.user.services.jwt import ( from app.user.services.jwt import (
create_access_token, create_access_token,
@ -96,27 +96,28 @@ class AuthService:
# 5. JWT 토큰 생성 # 5. JWT 토큰 생성
logger.info("[AUTH] 5단계: JWT 토큰 생성 시작") logger.info("[AUTH] 5단계: JWT 토큰 생성 시작")
access_token = create_access_token(user.id) access_token = create_access_token(user.user_ksuid)
refresh_token = create_refresh_token(user.id) refresh_token = create_refresh_token(user.user_ksuid)
logger.debug(f"[AUTH] JWT 토큰 생성 완료 - user_id: {user.id}") logger.debug(f"[AUTH] JWT 토큰 생성 완료 - user_ksuid: {user.user_ksuid}")
# 6. 리프레시 토큰 DB 저장 # 6. 리프레시 토큰 DB 저장
logger.info("[AUTH] 6단계: 리프레시 토큰 저장 시작") logger.info("[AUTH] 6단계: 리프레시 토큰 저장 시작")
await self._save_refresh_token( await self._save_refresh_token(
user_id=user.id, user_id=user.id,
user_ksuid=user.user_ksuid,
token=refresh_token, token=refresh_token,
session=session, session=session,
user_agent=user_agent, user_agent=user_agent,
ip_address=ip_address, ip_address=ip_address,
) )
logger.debug(f"[AUTH] 리프레시 토큰 저장 완료 - user_id: {user.id}") logger.debug(f"[AUTH] 리프레시 토큰 저장 완료 - user_id: {user.id}, user_ksuid: {user.user_ksuid}")
# 7. 마지막 로그인 시간 업데이트 # 7. 마지막 로그인 시간 업데이트
user.last_login_at = datetime.now(timezone.utc) user.last_login_at = datetime.now(timezone.utc)
await session.commit() await session.commit()
redirect_url = f"{prj_settings.PROJECT_DOMAIN}" redirect_url = f"{prj_settings.PROJECT_DOMAIN}"
logger.info(f"[AUTH] 카카오 로그인 완료 - user_id: {user.id}, redirect_url: {redirect_url}") logger.info(f"[AUTH] 카카오 로그인 완료 - user_id: {user.id}, user_ksuid: {user.user_ksuid}, redirect_url: {redirect_url}")
logger.debug(f"[AUTH] 응답 토큰 정보 - access_token: {access_token[:30]}..., refresh_token: {refresh_token[:30]}...") logger.debug(f"[AUTH] 응답 토큰 정보 - access_token: {access_token[:30]}..., refresh_token: {refresh_token[:30]}...")
return LoginResponse( return LoginResponse(
@ -124,13 +125,7 @@ class AuthService:
refresh_token=refresh_token, refresh_token=refresh_token,
token_type="Bearer", token_type="Bearer",
expires_in=get_access_token_expire_seconds(), expires_in=get_access_token_expire_seconds(),
user=UserBriefResponse( is_new_user=is_new_user,
id=user.id,
nickname=user.nickname,
email=user.email,
profile_image_url=user.profile_image_url,
is_new_user=is_new_user,
),
redirect_url=redirect_url, redirect_url=redirect_url,
) )
@ -177,8 +172,8 @@ class AuthService:
raise TokenExpiredError() raise TokenExpiredError()
# 4. 사용자 확인 # 4. 사용자 확인
user_id = int(payload.get("sub")) user_ksuid = payload.get("sub")
user = await self._get_user_by_id(user_id, session) user = await self._get_user_by_ksuid(user_ksuid, session)
if user is None: if user is None:
raise UserNotFoundError() raise UserNotFoundError()
@ -187,7 +182,7 @@ class AuthService:
raise UserInactiveError() raise UserInactiveError()
# 5. 새 액세스 토큰 발급 # 5. 새 액세스 토큰 발급
new_access_token = create_access_token(user.id) new_access_token = create_access_token(user.user_ksuid)
return AccessTokenResponse( return AccessTokenResponse(
access_token=new_access_token, access_token=new_access_token,
@ -269,8 +264,10 @@ class AuthService:
# 신규 사용자 생성 # 신규 사용자 생성
logger.info(f"[AUTH] 신규 사용자 생성 시작 - kakao_id: {kakao_id}") logger.info(f"[AUTH] 신규 사용자 생성 시작 - kakao_id: {kakao_id}")
ksuid = await generate_ksuid(session=session, table_name=User)
new_user = User( new_user = User(
kakao_id=kakao_id, kakao_id=kakao_id,
user_ksuid=ksuid,
email=kakao_account.email if kakao_account else None, email=kakao_account.email if kakao_account else None,
nickname=profile.nickname if profile else None, nickname=profile.nickname if profile else None,
profile_image_url=profile.profile_image_url if profile else None, profile_image_url=profile.profile_image_url if profile else None,
@ -319,6 +316,7 @@ class AuthService:
async def _save_refresh_token( async def _save_refresh_token(
self, self,
user_id: int, user_id: int,
user_ksuid: str,
token: str, token: str,
session: AsyncSession, session: AsyncSession,
user_agent: Optional[str] = None, user_agent: Optional[str] = None,
@ -329,6 +327,7 @@ class AuthService:
Args: Args:
user_id: 사용자 ID user_id: 사용자 ID
user_ksuid: 사용자 KSUID
token: 리프레시 토큰 token: 리프레시 토큰
session: DB 세션 session: DB 세션
user_agent: User-Agent user_agent: User-Agent
@ -342,6 +341,7 @@ class AuthService:
refresh_token = RefreshToken( refresh_token = RefreshToken(
user_id=user_id, user_id=user_id,
user_ksuid=user_ksuid,
token_hash=token_hash, token_hash=token_hash,
expires_at=expires_at, expires_at=expires_at,
user_agent=user_agent, user_agent=user_agent,
@ -391,6 +391,26 @@ class AuthService:
) )
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def _get_user_by_ksuid(
self,
user_ksuid: str,
session: AsyncSession,
) -> Optional[User]:
"""
KSUID로 사용자 조회
Args:
user_ksuid: 사용자 KSUID
session: DB 세션
Returns:
User 객체 또는 None
"""
result = await session.execute(
select(User).where(User.user_ksuid == user_ksuid, User.is_deleted == False) # noqa: E712
)
return result.scalar_one_or_none()
async def _revoke_refresh_token_by_hash( async def _revoke_refresh_token_by_hash(
self, self,
token_hash: str, token_hash: str,

View File

@ -13,12 +13,12 @@ from jose import JWTError, jwt
from config import jwt_settings from config import jwt_settings
def create_access_token(user_id: int) -> str: def create_access_token(user_ksuid: str) -> str:
""" """
JWT 액세스 토큰 생성 JWT 액세스 토큰 생성
Args: Args:
user_id: 사용자 ID user_ksuid: 사용자 KSUID
Returns: Returns:
JWT 액세스 토큰 문자열 JWT 액세스 토큰 문자열
@ -27,7 +27,7 @@ def create_access_token(user_id: int) -> str:
minutes=jwt_settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES minutes=jwt_settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
) )
to_encode = { to_encode = {
"sub": str(user_id), "sub": user_ksuid,
"exp": expire, "exp": expire,
"type": "access", "type": "access",
} }
@ -38,12 +38,12 @@ def create_access_token(user_id: int) -> str:
) )
def create_refresh_token(user_id: int) -> str: def create_refresh_token(user_ksuid: str) -> str:
""" """
JWT 리프레시 토큰 생성 JWT 리프레시 토큰 생성
Args: Args:
user_id: 사용자 ID user_ksuid: 사용자 KSUID
Returns: Returns:
JWT 리프레시 토큰 문자열 JWT 리프레시 토큰 문자열
@ -52,7 +52,7 @@ def create_refresh_token(user_id: int) -> str:
days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
) )
to_encode = { to_encode = {
"sub": str(user_id), "sub": user_ksuid,
"exp": expire, "exp": expire,
"type": "refresh", "type": "refresh",
} }

View File

@ -4,11 +4,14 @@ Common Utility Functions
공통으로 사용되는 유틸리티 함수들을 정의합니다. 공통으로 사용되는 유틸리티 함수들을 정의합니다.
사용 예시: 사용 예시:
from app.utils.common import generate_task_id from app.utils.common import generate_task_id, generate_ksuid
# task_id 생성 # task_id 생성
task_id = await generate_task_id(session=session, table_name=Project) task_id = await generate_task_id(session=session, table_name=Project)
# ksuid 생성
ksuid = await generate_ksuid(session=session, table_name=User)
Note: Note:
페이지네이션 기능은 app.utils.pagination 모듈을 사용하세요: 페이지네이션 기능은 app.utils.pagination 모듈을 사용하세요:
from app.utils.pagination import PaginatedResponse, get_paginated from app.utils.pagination import PaginatedResponse, get_paginated
@ -18,7 +21,7 @@ from typing import Any, Optional, Type
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from uuid_extensions import uuid7 from svix_ksuid import Ksuid
async def generate_task_id( async def generate_task_id(
@ -32,16 +35,16 @@ async def generate_task_id(
table_name: task_id 컬럼이 있는 SQLAlchemy 테이블 클래스 (optional) table_name: task_id 컬럼이 있는 SQLAlchemy 테이블 클래스 (optional)
Returns: Returns:
str: 생성된 uuid7 문자열 str: 생성된 ksuid 문자열
Usage: Usage:
# 단순 uuid7 생성 # 단순 ksuid 생성
task_id = await generate_task_id() task_id = await generate_task_id()
# 테이블에서 중복 검사 후 생성 # 테이블에서 중복 검사 후 생성
task_id = await generate_task_id(session=session, table_name=Project) task_id = await generate_task_id(session=session, table_name=Project)
""" """
task_id = str(uuid7()) task_id = str(Ksuid())
if session is None or table_name is None: if session is None or table_name is None:
return task_id return task_id
@ -55,4 +58,41 @@ async def generate_task_id(
if existing is None: if existing is None:
return task_id return task_id
task_id = str(uuid7()) task_id = str(Ksuid())
async def generate_ksuid(
session: Optional[AsyncSession] = None,
table_name: Optional[Type[Any]] = None,
) -> str:
"""고유한 ksuid를 생성합니다.
Args:
session: SQLAlchemy AsyncSession (optional)
table_name: user_ksuid 컬럼이 있는 SQLAlchemy 테이블 클래스 (optional)
Returns:
str: 생성된 ksuid 문자열
Usage:
# 단순 ksuid 생성
ksuid = await generate_ksuid()
# 테이블에서 중복 검사 후 생성
ksuid = await generate_ksuid(session=session, table_name=User)
"""
ksuid = str(Ksuid())
if session is None or table_name is None:
return ksuid
while True:
result = await session.execute(
select(table_name).where(table_name.user_ksuid == ksuid)
)
existing = result.scalar_one_or_none()
if existing is None:
return ksuid
ksuid = str(Ksuid())

View File

@ -1,7 +1,7 @@
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
from sqlalchemy import DateTime, ForeignKey, Integer, String, func from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base from app.database.session import Base
@ -24,7 +24,7 @@ class Video(Base):
project_id: 연결된 Project의 id (외래키) project_id: 연결된 Project의 id (외래키)
lyric_id: 연결된 Lyric의 id (외래키) lyric_id: 연결된 Lyric의 id (외래키)
song_id: 연결된 Song의 id (외래키) song_id: 연결된 Song의 id (외래키)
task_id: 영상 생성 작업의 고유 식별자 (UUID 형식) task_id: 영상 생성 작업의 고유 식별자 (KSUID 형식)
status: 처리 상태 (pending, processing, completed, failed ) status: 처리 상태 (pending, processing, completed, failed )
result_movie_url: 생성된 영상 URL (S3, CDN 경로) result_movie_url: 생성된 영상 URL (S3, CDN 경로)
created_at: 생성 일시 (자동 설정) created_at: 생성 일시 (자동 설정)
@ -37,6 +37,10 @@ class Video(Base):
__tablename__ = "video" __tablename__ = "video"
__table_args__ = ( __table_args__ = (
Index("idx_video_task_id", "task_id"),
Index("idx_video_project_id", "project_id"),
Index("idx_video_lyric_id", "lyric_id"),
Index("idx_video_song_id", "song_id"),
{ {
"mysql_engine": "InnoDB", "mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4", "mysql_charset": "utf8mb4",
@ -56,7 +60,6 @@ class Video(Base):
Integer, Integer,
ForeignKey("project.id", ondelete="CASCADE"), ForeignKey("project.id", ondelete="CASCADE"),
nullable=False, nullable=False,
index=True,
comment="연결된 Project의 id", comment="연결된 Project의 id",
) )
@ -64,7 +67,6 @@ class Video(Base):
Integer, Integer,
ForeignKey("lyric.id", ondelete="CASCADE"), ForeignKey("lyric.id", ondelete="CASCADE"),
nullable=False, nullable=False,
index=True,
comment="연결된 Lyric의 id", comment="연결된 Lyric의 id",
) )
@ -72,15 +74,14 @@ class Video(Base):
Integer, Integer,
ForeignKey("song.id", ondelete="CASCADE"), ForeignKey("song.id", ondelete="CASCADE"),
nullable=False, nullable=False,
index=True,
comment="연결된 Song의 id", comment="연결된 Song의 id",
) )
task_id: Mapped[str] = mapped_column( task_id: Mapped[str] = mapped_column(
String(36), String(27),
nullable=False, nullable=False,
index=True, unique=True,
comment="영상 생성 작업 고유 식별자 (UUID)", comment="영상 생성 작업 고유 식별자 (KSUID)",
) )
creatomate_render_id: Mapped[Optional[str]] = mapped_column( creatomate_render_id: Mapped[Optional[str]] = mapped_column(

View File

@ -22,6 +22,7 @@ dependencies = [
"scalar-fastapi>=1.6.1", "scalar-fastapi>=1.6.1",
"sqladmin[full]>=0.22.0", "sqladmin[full]>=0.22.0",
"sqlalchemy[asyncio]>=2.0.45", "sqlalchemy[asyncio]>=2.0.45",
"svix-ksuid>=0.6.2",
"uuid7>=0.1.0", "uuid7>=0.1.0",
] ]

17
uv.lock
View File

@ -725,6 +725,7 @@ dependencies = [
{ name = "scalar-fastapi" }, { name = "scalar-fastapi" },
{ name = "sqladmin", extra = ["full"] }, { name = "sqladmin", extra = ["full"] },
{ name = "sqlalchemy", extra = ["asyncio"] }, { name = "sqlalchemy", extra = ["asyncio"] },
{ name = "svix-ksuid" },
{ name = "uuid7" }, { name = "uuid7" },
] ]
@ -753,6 +754,7 @@ requires-dist = [
{ name = "scalar-fastapi", specifier = ">=1.6.1" }, { name = "scalar-fastapi", specifier = ">=1.6.1" },
{ name = "sqladmin", extras = ["full"], specifier = ">=0.22.0" }, { name = "sqladmin", extras = ["full"], specifier = ">=0.22.0" },
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.45" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.45" },
{ name = "svix-ksuid", specifier = ">=0.6.2" },
{ name = "uuid7", specifier = ">=0.1.0" }, { name = "uuid7", specifier = ">=0.1.0" },
] ]
@ -1019,6 +1021,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
] ]
[[package]]
name = "python-baseconv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/33/d0/9297d7d8dd74767b4d5560d834b30b2fff17d39987c23ed8656f476e0d9b/python-baseconv-1.2.2.tar.gz", hash = "sha256:0539f8bd0464013b05ad62e0a1673f0ac9086c76b43ebf9f833053527cd9931b", size = 4929, upload-time = "2019-04-04T19:28:57.17Z" }
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.2.1" version = "1.2.1"
@ -1314,6 +1322,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" },
] ]
[[package]]
name = "svix-ksuid"
version = "0.6.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-baseconv" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a1/7a/0c98b77ca01d64f13143607b88273a13110d659780b93cb1333abebf8039/svix-ksuid-0.6.2.tar.gz", hash = "sha256:beb95bd6284bdbd526834e233846653d2bd26eb162b3233513d8f2c853c78964", size = 6957, upload-time = "2023-07-07T09:18:24.717Z" }
[[package]] [[package]]
name = "tqdm" name = "tqdm"
version = "4.67.1" version = "4.67.1"