백오피스 추가 및 이미지태그 예외처리

feature-credit
김성경 2026-05-04 13:32:09 +09:00
parent 1e3da98c6a
commit 37bf9f54ee
34 changed files with 1530 additions and 73 deletions

View File

@ -1,13 +1,11 @@
from fastapi import FastAPI
from sqladmin import Admin
from app.database.session import engine
from app.home.api.home_admin import ImageAdmin, ProjectAdmin
from app.lyric.api.lyrics_admin import LyricAdmin
from app.song.api.song_admin import SongAdmin
from app.sns.api.sns_admin import SNSUploadTaskAdmin
from app.backoffice.admin.admin_view import AdminAdmin
from app.backoffice.admin.auth import AdminAuthBackend
from app.backoffice.credit_view import CreditChargeRequestAdmin, CreditTransactionAdmin, CreditTransactionAdmin
from sqlalchemy.ext.asyncio import AsyncEngine
from app.user.api.user_admin import RefreshTokenAdmin, SocialAccountAdmin, UserAdmin
from app.video.api.video_admin import VideoAdmin
from config import prj_settings
# https://github.com/aminalaee/sqladmin
@ -15,34 +13,28 @@ from config import prj_settings
def init_admin(
app: FastAPI,
db_engine: engine,
db_engine: AsyncEngine,
base_url: str = prj_settings.ADMIN_BASE_URL,
) -> Admin:
auth_backend = AdminAuthBackend(secret_key=prj_settings.ADMIN_SESSION_SECRET)
admin = Admin(
app,
db_engine,
base_url=base_url,
authentication_backend=auth_backend,
title="ADO2 관리자",
)
# 프로젝트 관리
admin.add_view(ProjectAdmin)
admin.add_view(ImageAdmin)
# 가사 관리
admin.add_view(LyricAdmin)
# 노래 관리
admin.add_view(SongAdmin)
# 영상 관리
admin.add_view(VideoAdmin)
# 사용자 관리
admin.add_view(UserAdmin)
admin.add_view(RefreshTokenAdmin)
admin.add_view(SocialAccountAdmin)
# SNS 관리
admin.add_view(SNSUploadTaskAdmin)
# 크레딧 관리
admin.add_view(CreditChargeRequestAdmin)
admin.add_view(CreditTransactionAdmin)
# 백오피스 설정
admin.add_view(AdminAdmin)
return admin

View File

View File

View File

@ -0,0 +1,56 @@
from sqladmin import ModelView
from app.backoffice.admin.models import Admin
class AdminAdmin(ModelView, model=Admin):
name = "관리자 계정"
name_plural = "관리자 계정 목록"
icon = "fa-solid fa-user-shield"
category = "백오피스 설정"
page_size = 20
column_list = [
"id",
"username",
"name",
"is_active",
"last_login_at",
"created_at",
]
column_details_list = [
"id",
"username",
"name",
"is_active",
"last_login_at",
"created_at",
"updated_at",
]
form_columns = ["username", "name", "is_active"]
column_searchable_list = [Admin.username, Admin.name]
column_default_sort = (Admin.created_at, True)
column_sortable_list = [
Admin.id,
Admin.username,
Admin.is_active,
Admin.last_login_at,
Admin.created_at,
]
column_labels = {
"id": "ID",
"username": "아이디",
"name": "이름",
"is_active": "활성화",
"last_login_at": "마지막 로그인",
"created_at": "생성일시",
"updated_at": "수정일시",
}
can_delete = False

View File

@ -0,0 +1,72 @@
import logging
from datetime import datetime
from sqladmin.authentication import AuthenticationBackend
from sqlalchemy import select
from starlette.requests import Request
from starlette.responses import RedirectResponse
from app.backoffice.admin.models import Admin
from app.database.session import AsyncSessionLocal
logger = logging.getLogger(__name__)
class AdminAuthBackend(AuthenticationBackend):
async def login(self, request: Request) -> bool:
form = await request.form()
username = form.get("username", "")
password = form.get("password", "")
if not username or not password:
return False
async with AsyncSessionLocal() as session:
result = await session.execute(
select(Admin).where(
Admin.username == username,
Admin.is_active == True, # noqa: E712
)
)
admin = result.scalar_one_or_none()
if admin is None or admin.password != password:
logger.warning(f"[ADMIN-AUTH] login failed username={username}")
return False
request.session["admin_id"] = admin.id
logger.info(f"[ADMIN-AUTH] login success admin_id={admin.id} username={username}")
# 마지막 로그인 시간 갱신
async with AsyncSessionLocal() as session:
result = await session.execute(select(Admin).where(Admin.id == admin.id))
a = result.scalar_one()
a.last_login_at = datetime.now()
await session.commit()
return True
async def logout(self, request: Request) -> bool:
request.session.clear()
return True
async def authenticate(self, request: Request) -> bool:
admin_id = request.session.get("admin_id")
if not admin_id:
return False
async with AsyncSessionLocal() as session:
result = await session.execute(
select(Admin).where(
Admin.id == admin_id,
Admin.is_active == True, # noqa: E712
)
)
admin = result.scalar_one_or_none()
if admin is None:
logger.warning(f"[ADMIN-AUTH] authenticate failed admin_id={admin_id}")
request.session.clear()
return False
return True

View File

@ -0,0 +1,78 @@
from datetime import datetime
from typing import Optional
from sqlalchemy import BigInteger, Boolean, DateTime, Index, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database.session import Base
class Admin(Base):
__tablename__ = "admin"
__table_args__ = (
Index("idx_admin_username", "username", unique=True),
Index("idx_admin_is_active", "is_active"),
{
"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="고유 식별자",
)
username: Mapped[str] = mapped_column(
String(50),
nullable=False,
unique=True,
comment="로그인 ID",
)
password: Mapped[str] = mapped_column(
String(255),
nullable=False,
comment="비밀번호",
)
name: Mapped[Optional[str]] = mapped_column(
String(50),
nullable=True,
comment="표시 이름",
)
is_active: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=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="수정 일시",
)
def __repr__(self) -> str:
return f"<Admin(id={self.id}, username='{self.username}', is_active={self.is_active})>"

View File

@ -0,0 +1,43 @@
import logging
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.backoffice.admin.models import Admin
logger = logging.getLogger(__name__)
async def create_admin(
*,
session: AsyncSession,
username: str,
password: str,
name: Optional[str] = None,
) -> Admin:
admin = Admin(
username=username,
password=password,
name=name,
)
session.add(admin)
await session.commit()
await session.refresh(admin)
logger.info(f"[ADMIN] created admin username={username}")
return admin
async def change_password(
*,
session: AsyncSession,
admin_id: int,
new_password: str,
) -> None:
result = await session.execute(select(Admin).where(Admin.id == admin_id))
admin = result.scalar_one_or_none()
if admin is None:
raise ValueError(f"Admin id={admin_id} not found")
admin.password = new_password
await session.commit()
logger.info(f"[ADMIN] password changed admin_id={admin_id}")

View File

@ -0,0 +1,202 @@
import logging
from sqlalchemy import select
from sqlalchemy.orm import outerjoin
from sqladmin import ModelView, action
from starlette.requests import Request
from starlette.responses import RedirectResponse
from app.backoffice.admin.models import Admin
from app.credit.models import CreditChargeRequest, CreditTransaction
from app.credit.services.credit_service import approve_charge_request, reject_charge_request
from app.database.session import AsyncSessionLocal
logger = logging.getLogger(__name__)
class CreditChargeRequestAdmin(ModelView, model=CreditChargeRequest):
name = "충전 요청"
name_plural = "충전 요청 목록"
icon = "fa-solid fa-coins"
category = "크레딧 관리"
page_size = 20
column_list = [
"id",
"user_uuid",
"requested_amount",
"status",
"admin.name",
"processed_at",
"created_at",
]
column_details_list = [
"id",
"user_uuid",
"requested_amount",
"message",
"status",
"admin.name",
"admin_note",
"processed_at",
"created_at",
"updated_at",
]
form_columns = ["admin_note"]
can_create = False
column_searchable_list = [
CreditChargeRequest.user_uuid,
CreditChargeRequest.status,
]
column_default_sort = (CreditChargeRequest.created_at, True)
column_sortable_list = [
CreditChargeRequest.id,
CreditChargeRequest.user_uuid,
CreditChargeRequest.requested_amount,
CreditChargeRequest.status,
CreditChargeRequest.processed_at,
CreditChargeRequest.created_at,
]
column_labels = {
"id": "ID",
"user_uuid": "사용자 UUID",
"requested_amount": "요청 크레딧",
"message": "사용자 메시지",
"status": "상태",
"admin.name": "처리 관리자",
"admin_note": "관리자 메모",
"processed_at": "처리일시",
"created_at": "요청일시",
"updated_at": "수정일시",
}
@action(
name="approve_request",
label="승인",
confirmation_message="선택한 충전 요청을 승인하시겠습니까?",
add_in_detail=True,
add_in_list=True,
)
async def approve_action(self, request: Request) -> RedirectResponse:
admin_id = request.session.get("admin_id")
pks = request.query_params.get("pks", "")
async with AsyncSessionLocal() as session:
for pk in pks.split(","):
if not pk.strip():
continue
try:
await approve_charge_request(
session=session,
request_id=int(pk),
admin_id=admin_id,
)
await session.commit()
except Exception as e:
await session.rollback()
logger.warning(f"[CREDIT-ADMIN] approve failed request_id={pk} error={e}")
return RedirectResponse(request.url_for("admin:list", identity=self.identity), status_code=302)
@action(
name="reject_request",
label="반려",
confirmation_message="선택한 충전 요청을 반려하시겠습니까?",
add_in_detail=True,
add_in_list=True,
)
async def reject_action(self, request: Request) -> RedirectResponse:
admin_id = request.session.get("admin_id")
pks = request.query_params.get("pks", "")
async with AsyncSessionLocal() as session:
for pk in pks.split(","):
if not pk.strip():
continue
try:
await reject_charge_request(
session=session,
request_id=int(pk),
admin_id=admin_id,
)
await session.commit()
except Exception as e:
await session.rollback()
logger.warning(f"[CREDIT-ADMIN] reject failed request_id={pk} error={e}")
return RedirectResponse(request.url_for("admin:list", identity=self.identity), status_code=302)
class CreditTransactionAdmin(ModelView, model=CreditTransaction):
name = "크레딧 변경"
name_plural = "크레딧 변경 목록"
icon = "fa-solid fa-clock-rotate-left"
category = "크레딧 관리"
page_size = 50
can_create = False
can_edit = False
can_delete = False
column_list = [
"id",
"user_uuid",
"amount",
"balance_after",
"type",
"admin.name",
"related_request_id",
"created_at",
]
column_details_list = [
"id",
"user_uuid",
"amount",
"balance_after",
"type",
"reason",
"admin.name",
"related_request_id",
"created_at",
]
column_searchable_list = [
CreditTransaction.user_uuid,
CreditTransaction.type,
]
column_default_sort = (CreditTransaction.created_at, True)
column_sortable_list = [
CreditTransaction.id,
CreditTransaction.user_uuid,
CreditTransaction.amount,
CreditTransaction.type,
CreditTransaction.created_at,
]
column_labels = {
"id": "ID",
"user_uuid": "사용자 UUID",
"amount": "변경 크레딧",
"balance_after": "변경 후 잔액",
"type": "변경 유형",
"reason": "사유",
"admin.name": "처리 관리자",
"related_request_id": "충전 요청 ID",
"created_at": "변경 일시",
}
def list_query(self, _request: Request):
return (
select(CreditTransaction)
.select_from(outerjoin(CreditTransaction, Admin, CreditTransaction.admin_id == Admin.id))
)

View File

@ -0,0 +1,114 @@
import logging
from typing import Optional
from sqlalchemy import select
from starlette.requests import Request
from starlette.responses import RedirectResponse
from app.credit.models import CreditTransactionType
from app.credit.services.credit_service import charge_credit, deduct_credit
from app.database.session import AsyncSessionLocal
from app.user.models import User
logger = logging.getLogger(__name__)
async def _get_users_by_pks(session, pks: str) -> list[User]:
ids = [int(pk) for pk in pks.split(",") if pk.strip()]
if not ids:
return []
result = await session.execute(select(User).where(User.id.in_(ids)))
return list(result.scalars().all())
async def handle_block_users(request: Request, identity: str, block: bool) -> RedirectResponse:
pks = request.query_params.get("pks", "")
action_str = "차단" if block else "차단 해제"
async with AsyncSessionLocal() as session:
users = await _get_users_by_pks(session, pks)
for user in users:
user.is_active = not block
await session.commit()
logger.info(f"[USER-ADMIN] {action_str} count={len(users)}")
return RedirectResponse(request.url_for("admin:list", identity=identity), status_code=302)
async def handle_set_role(request: Request, identity: str, role: str) -> RedirectResponse:
pks = request.query_params.get("pks", "")
is_admin = role == "admin"
async with AsyncSessionLocal() as session:
users = await _get_users_by_pks(session, pks)
for user in users:
user.role = role
user.is_admin = is_admin
await session.commit()
logger.info(f"[USER-ADMIN] set_role role={role} count={len(users)}")
return RedirectResponse(request.url_for("admin:list", identity=identity), status_code=302)
async def handle_grant_credits(
request: Request,
identity: str,
amount: int,
admin_id: Optional[int],
) -> RedirectResponse:
pks = request.query_params.get("pks", "")
ids = [int(pk) for pk in pks.split(",") if pk.strip()]
async with AsyncSessionLocal() as session:
for user_id in ids:
result = await session.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
continue
try:
await charge_credit(
session=session,
user_uuid=user.user_uuid,
amount=amount,
type=CreditTransactionType.ADMIN_ADJUST,
reason="관리자 수동 충전",
admin_id=admin_id,
)
await session.commit()
except Exception as e:
await session.rollback()
logger.warning(f"[USER-ADMIN] grant_credits failed user_id={user_id} error={e}")
return RedirectResponse(request.url_for("admin:list", identity=identity), status_code=302)
async def handle_deduct_credits(
request: Request,
identity: str,
amount: int,
admin_id: Optional[int],
) -> RedirectResponse:
pks = request.query_params.get("pks", "")
ids = [int(pk) for pk in pks.split(",") if pk.strip()]
async with AsyncSessionLocal() as session:
for user_id in ids:
result = await session.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
continue
try:
await deduct_credit(
session=session,
user_uuid=user.user_uuid,
amount=amount,
type=CreditTransactionType.ADMIN_ADJUST,
reason="관리자 수동 차감",
admin_id=admin_id,
)
await session.commit()
except Exception as e:
await session.rollback()
logger.warning(f"[USER-ADMIN] deduct_credits failed user_id={user_id} error={e}")
return RedirectResponse(request.url_for("admin:list", identity=identity), status_code=302)

0
app/credit/__init__.py Normal file
View File

View File

View File

View File

View File

@ -0,0 +1,162 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.credit.exceptions import ChargeRequestForbiddenError, ChargeRequestNotFoundError
from app.credit.models import ChargeRequestStatus, CreditChargeRequest, CreditTransaction
from app.credit.schemas.credit_schema import (
ChargeRequestCreate,
ChargeRequestListResponse,
ChargeRequestResponse,
CreditTransactionListResponse,
CreditTransactionResponse,
)
from app.database.session import get_session
from app.user.dependencies.auth import get_current_user
from app.user.models import User
router = APIRouter(prefix="/credits", tags=["Credits"])
@router.post(
"/charge-requests",
response_model=ChargeRequestResponse,
status_code=201,
summary="크레딧 충전 요청 제출",
)
async def create_charge_request(
body: ChargeRequestCreate,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> ChargeRequestResponse:
charge_request = CreditChargeRequest(
user_uuid=current_user.user_uuid,
requested_amount=body.requested_amount,
message=body.message,
)
session.add(charge_request)
await session.commit()
await session.refresh(charge_request)
return ChargeRequestResponse.model_validate(charge_request)
@router.get(
"/charge-requests",
response_model=ChargeRequestListResponse,
summary="내 충전 요청 목록",
)
async def list_charge_requests(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> ChargeRequestListResponse:
offset = (page - 1) * page_size
total_result = await session.execute(
select(func.count()).where(CreditChargeRequest.user_uuid == current_user.user_uuid)
)
total = total_result.scalar_one()
items_result = await session.execute(
select(CreditChargeRequest)
.where(CreditChargeRequest.user_uuid == current_user.user_uuid)
.order_by(CreditChargeRequest.created_at.desc())
.offset(offset)
.limit(page_size)
)
items = items_result.scalars().all()
return ChargeRequestListResponse(
items=[ChargeRequestResponse.model_validate(i) for i in items],
total=total,
page=page,
page_size=page_size,
)
@router.get(
"/charge-requests/{request_id}",
response_model=ChargeRequestResponse,
summary="내 충전 요청 상세",
)
async def get_charge_request(
request_id: int,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> ChargeRequestResponse:
result = await session.execute(
select(CreditChargeRequest).where(
CreditChargeRequest.id == request_id,
CreditChargeRequest.user_uuid == current_user.user_uuid,
)
)
charge_request = result.scalar_one_or_none()
if charge_request is None:
raise ChargeRequestNotFoundError()
return ChargeRequestResponse.model_validate(charge_request)
@router.delete(
"/charge-requests/{request_id}",
status_code=204,
summary="충전 요청 취소 (pending 상태만)",
)
async def cancel_charge_request(
request_id: int,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> None:
result = await session.execute(
select(CreditChargeRequest).where(CreditChargeRequest.id == request_id)
)
charge_request = result.scalar_one_or_none()
if charge_request is None:
raise ChargeRequestNotFoundError()
if charge_request.user_uuid != current_user.user_uuid:
raise ChargeRequestForbiddenError()
from app.credit.exceptions import InvalidRequestStateError
if charge_request.status != ChargeRequestStatus.PENDING:
raise InvalidRequestStateError("대기 중인 요청만 취소할 수 있습니다.")
charge_request.status = ChargeRequestStatus.CANCELLED
await session.commit()
@router.get(
"/transactions",
response_model=CreditTransactionListResponse,
summary="내 크레딧 거래 이력",
)
async def list_transactions(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> CreditTransactionListResponse:
offset = (page - 1) * page_size
total_result = await session.execute(
select(func.count()).where(CreditTransaction.user_uuid == current_user.user_uuid)
)
total = total_result.scalar_one()
items_result = await session.execute(
select(CreditTransaction)
.where(CreditTransaction.user_uuid == current_user.user_uuid)
.order_by(CreditTransaction.created_at.desc())
.offset(offset)
.limit(page_size)
)
items = items_result.scalars().all()
return CreditTransactionListResponse(
items=[CreditTransactionResponse.model_validate(i) for i in items],
total=total,
page=page,
page_size=page_size,
)

27
app/credit/exceptions.py Normal file
View File

@ -0,0 +1,27 @@
from starlette import status
from app.core.exceptions import FastShipError
class InsufficientCreditError(FastShipError):
"""크레딧이 부족합니다."""
status = status.HTTP_400_BAD_REQUEST
class InvalidRequestStateError(FastShipError):
"""이미 처리된 요청입니다."""
status = status.HTTP_409_CONFLICT
class ChargeRequestNotFoundError(FastShipError):
"""충전 요청을 찾을 수 없습니다."""
status = status.HTTP_404_NOT_FOUND
class ChargeRequestForbiddenError(FastShipError):
"""본인의 충전 요청만 조회할 수 있습니다."""
status = status.HTTP_403_FORBIDDEN

243
app/credit/models.py Normal file
View File

@ -0,0 +1,243 @@
from datetime import datetime
from enum import Enum
from typing import TYPE_CHECKING, Optional
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base
if TYPE_CHECKING:
from app.backoffice.admin.models import Admin
from app.user.models import User
class ChargeRequestStatus(str, Enum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
CANCELLED = "cancelled"
class CreditTransactionType(str, Enum):
CHARGE = "charge"
CONSUME = "consume"
REFUND = "refund"
ADMIN_ADJUST = "admin_adjust"
class CreditChargeRequest(Base):
__tablename__ = "credit_charge_request"
__table_args__ = (
Index("idx_credit_request_user_uuid", "user_uuid"),
Index("idx_credit_request_status", "status"),
Index("idx_credit_request_created_at", "created_at"),
Index("idx_credit_request_status_created", "status", "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="고유 식별자",
)
user_uuid: Mapped[str] = mapped_column(
String(36),
ForeignKey("user.user_uuid", ondelete="CASCADE"),
nullable=False,
comment="사용자 UUID (user.user_uuid 참조)",
)
requested_amount: Mapped[int] = mapped_column(
Integer,
nullable=False,
comment="요청 크레딧 수량 (양수)",
)
message: Mapped[Optional[str]] = mapped_column(
String(500),
nullable=True,
comment="사용자 요청 메시지",
)
status: Mapped[str] = mapped_column(
String(20),
nullable=False,
default=ChargeRequestStatus.PENDING,
server_default="pending",
comment="처리 상태 (pending/approved/rejected/cancelled)",
)
admin_id: Mapped[Optional[int]] = mapped_column(
BigInteger,
ForeignKey("admin.id", ondelete="SET NULL"),
nullable=True,
comment="처리한 백오피스 관리자 ID",
)
admin_note: Mapped[Optional[str]] = mapped_column(
String(1000),
nullable=True,
comment="관리자 메모",
)
processed_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="수정 일시",
)
user: Mapped["User"] = relationship(
"User",
foreign_keys=[user_uuid],
primaryjoin="CreditChargeRequest.user_uuid == User.user_uuid",
back_populates="credit_requests",
lazy="noload",
)
transactions: Mapped[list["CreditTransaction"]] = relationship(
"CreditTransaction",
back_populates="charge_request",
lazy="noload",
)
admin: Mapped[Optional["Admin"]] = relationship(
"Admin",
foreign_keys=[admin_id],
primaryjoin="CreditChargeRequest.admin_id == Admin.id",
lazy="selectin",
)
def __repr__(self) -> str:
return (
f"<CreditChargeRequest("
f"id={self.id}, user_uuid='{self.user_uuid}', "
f"amount={self.requested_amount}, status='{self.status}'"
f")>"
)
class CreditTransaction(Base):
__tablename__ = "credit_transaction"
__table_args__ = (
Index("idx_credit_tx_user_uuid", "user_uuid"),
Index("idx_credit_tx_user_uuid_created", "user_uuid", "created_at"),
Index("idx_credit_tx_type", "type"),
Index("idx_credit_tx_related_request", "related_request_id"),
{
"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="고유 식별자",
)
user_uuid: Mapped[str] = mapped_column(
String(36),
ForeignKey("user.user_uuid", ondelete="CASCADE"),
nullable=False,
comment="사용자 UUID",
)
amount: Mapped[int] = mapped_column(
Integer,
nullable=False,
comment="변경 크레딧 수량 (충전 양수, 차감 음수)",
)
balance_after: Mapped[int] = mapped_column(
Integer,
nullable=False,
comment="변경 직후 잔액",
)
type: Mapped[str] = mapped_column(
String(20),
nullable=False,
comment="변경 유형 (charge/consume/refund/admin_adjust)",
)
reason: Mapped[Optional[str]] = mapped_column(
String(255),
nullable=True,
comment="변경 사유",
)
admin_id: Mapped[Optional[int]] = mapped_column(
BigInteger,
ForeignKey("admin.id", ondelete="SET NULL"),
nullable=True,
comment="처리 관리자 ID (관리자 충전/차감 시)",
)
related_request_id: Mapped[Optional[int]] = mapped_column(
BigInteger,
ForeignKey("credit_charge_request.id", ondelete="SET NULL"),
nullable=True,
comment="연관 충전 요청 ID",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
comment="변경 일시",
)
user: Mapped["User"] = relationship(
"User",
foreign_keys=[user_uuid],
primaryjoin="CreditTransaction.user_uuid == User.user_uuid",
back_populates="credit_transactions",
lazy="noload",
)
charge_request: Mapped[Optional[CreditChargeRequest]] = relationship(
"CreditChargeRequest",
back_populates="transactions",
lazy="noload",
)
admin: Mapped[Optional["Admin"]] = relationship(
"Admin",
foreign_keys=[admin_id],
primaryjoin="CreditTransaction.admin_id == Admin.id",
lazy="selectin",
)
def __repr__(self) -> str:
return (
f"<CreditTransaction("
f"id={self.id}, user_uuid='{self.user_uuid}', "
f"amount={self.amount}, type='{self.type}'"
f")>"
)

View File

View File

@ -0,0 +1,59 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
class ChargeRequestCreate(BaseModel):
requested_amount: int = Field(..., gt=0, le=10000, description="요청 크레딧 수량")
message: Optional[str] = Field(None, max_length=500, description="요청 메시지")
model_config = {
"json_schema_extra": {
"example": {
"requested_amount": 10,
"message": "크레딧 충전 요청합니다.",
}
}
}
class ChargeRequestResponse(BaseModel):
id: int
user_uuid: str
requested_amount: int
message: Optional[str]
status: str
admin_note: Optional[str]
processed_at: Optional[datetime]
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class ChargeRequestListResponse(BaseModel):
items: list[ChargeRequestResponse]
total: int
page: int
page_size: int
class CreditTransactionResponse(BaseModel):
id: int
user_uuid: str
amount: int
balance_after: int
type: str
reason: Optional[str]
related_request_id: Optional[int]
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class CreditTransactionListResponse(BaseModel):
items: list[CreditTransactionResponse]
total: int
page: int
page_size: int

View File

View File

@ -0,0 +1,195 @@
import logging
from datetime import datetime
from typing import Optional
from config import TIMEZONE
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.credit.exceptions import (
ChargeRequestNotFoundError,
InsufficientCreditError,
InvalidRequestStateError,
)
from app.credit.models import (
ChargeRequestStatus,
CreditChargeRequest,
CreditTransaction,
CreditTransactionType,
)
logger = logging.getLogger(__name__)
async def record_transaction(
*,
session: AsyncSession,
user_uuid: str,
amount: int,
balance_after: int,
type: CreditTransactionType,
reason: Optional[str] = None,
admin_id: Optional[int] = None,
related_request_id: Optional[int] = None,
) -> CreditTransaction:
tx = CreditTransaction(
user_uuid=user_uuid,
amount=amount,
balance_after=balance_after,
type=type,
reason=reason,
admin_id=admin_id,
related_request_id=related_request_id,
)
session.add(tx)
await session.flush()
return tx
async def charge_credit(
*,
session: AsyncSession,
user_uuid: str,
amount: int,
type: CreditTransactionType = CreditTransactionType.CHARGE,
reason: Optional[str] = None,
admin_id: Optional[int] = None,
related_request_id: Optional[int] = None,
) -> CreditTransaction:
from app.user.models import User
result = await session.execute(
select(User).where(User.user_uuid == user_uuid).with_for_update()
)
user = result.scalar_one_or_none()
if user is None:
from app.user.services.auth import UserNotFoundError
raise UserNotFoundError()
user.credits = user.credits + amount
await session.flush()
tx = await record_transaction(
session=session,
user_uuid=user_uuid,
amount=amount,
balance_after=user.credits,
type=type,
reason=reason,
admin_id=admin_id,
related_request_id=related_request_id,
)
logger.info(f"[CREDIT] charge user_uuid={user_uuid} amount=+{amount} balance_after={user.credits}")
return tx
async def deduct_credit(
*,
session: AsyncSession,
user_uuid: str,
amount: int,
type: CreditTransactionType = CreditTransactionType.CONSUME,
reason: Optional[str] = None,
admin_id: Optional[int] = None,
) -> CreditTransaction:
from app.user.models import User
result = await session.execute(
select(User).where(User.user_uuid == user_uuid).with_for_update()
)
user = result.scalar_one_or_none()
if user is None:
from app.user.services.auth import UserNotFoundError
raise UserNotFoundError()
if user.credits < amount:
logger.warning(f"[CREDIT] insufficient credits user_uuid={user_uuid} credits={user.credits} requested={amount}")
raise InsufficientCreditError()
user.credits = user.credits - amount
await session.flush()
tx = await record_transaction(
session=session,
user_uuid=user_uuid,
amount=-amount,
balance_after=user.credits,
type=type,
reason=reason,
admin_id=admin_id,
)
logger.info(f"[CREDIT] deduct user_uuid={user_uuid} amount=-{amount} balance_after={user.credits}")
return tx
async def approve_charge_request(
*,
session: AsyncSession,
request_id: int,
admin_id: int,
admin_note: Optional[str] = None,
) -> CreditChargeRequest:
result = await session.execute(
select(CreditChargeRequest)
.where(CreditChargeRequest.id == request_id)
.with_for_update()
)
charge_request = result.scalar_one_or_none()
if charge_request is None:
raise ChargeRequestNotFoundError()
if charge_request.status != ChargeRequestStatus.PENDING:
logger.warning(f"[CREDIT] approve blocked request_id={request_id} status={charge_request.status}")
raise InvalidRequestStateError()
await charge_credit(
session=session,
user_uuid=charge_request.user_uuid,
amount=charge_request.requested_amount,
type=CreditTransactionType.CHARGE,
reason="충전 요청 승인",
admin_id=admin_id,
related_request_id=request_id,
)
charge_request.status = ChargeRequestStatus.APPROVED
charge_request.admin_id = admin_id
charge_request.admin_note = admin_note
charge_request.processed_at = datetime.now(TIMEZONE)
await session.flush()
logger.info(f"[CREDIT] approved request_id={request_id} admin_id={admin_id} amount={charge_request.requested_amount}")
return charge_request
async def reject_charge_request(
*,
session: AsyncSession,
request_id: int,
admin_id: int,
admin_note: Optional[str] = None,
) -> CreditChargeRequest:
result = await session.execute(
select(CreditChargeRequest)
.where(CreditChargeRequest.id == request_id)
.with_for_update()
)
charge_request = result.scalar_one_or_none()
if charge_request is None:
raise ChargeRequestNotFoundError()
if charge_request.status != ChargeRequestStatus.PENDING:
logger.warning(f"[CREDIT] reject blocked request_id={request_id} status={charge_request.status}")
raise InvalidRequestStateError()
charge_request.status = ChargeRequestStatus.REJECTED
charge_request.admin_id = admin_id
charge_request.admin_note = admin_note
charge_request.processed_at = datetime.now(TIMEZONE)
await session.flush()
logger.info(f"[CREDIT] rejected request_id={request_id} admin_id={admin_id}")
return charge_request

View File

@ -81,8 +81,10 @@ async def create_db_tables():
from app.sns.models import SNSUploadTask # noqa: F401
from app.social.models import SocialUpload # noqa: F401
from app.dashboard.models import Dashboard # noqa: F401
from app.backoffice.admin.models import Admin # noqa: F401
from app.credit.models import CreditChargeRequest, CreditTransaction # noqa: F401
# 생성할 테이블 목록
# 생성할 테이블 목록 (FK 순서: 참조 대상 먼저)
tables_to_create = [
User.__table__,
RefreshToken.__table__,
@ -98,6 +100,9 @@ async def create_db_tables():
MarketingIntel.__table__,
Dashboard.__table__,
ImageTag.__table__,
Admin.__table__,
CreditChargeRequest.__table__,
CreditTransaction.__table__,
]
logger.info("Creating database tables...")

View File

@ -803,6 +803,8 @@ async def tagging_images(
async with AsyncSessionLocal() as session:
for tag, tag_data in zip(null_imts, tag_datas):
if isinstance(tag_data, Exception):
continue
tag.img_tag = tag_data.model_dump(mode="json")
session.add(tag)
await session.commit()

View File

@ -122,6 +122,7 @@ class SocialUploadHistoryItem(BaseModel):
platform: str = Field(..., description="플랫폼명")
status: str = Field(..., description="업로드 상태")
title: str = Field(..., description="영상 제목")
platform_username: Optional[str] = Field(None, description="플랫폼 채널명")
platform_url: Optional[str] = Field(None, description="플랫폼 영상 URL")
error_message: Optional[str] = Field(None, description="에러 메시지")
scheduled_at: Optional[datetime] = Field(None, description="예약 게시 시간")

View File

@ -291,6 +291,7 @@ class SocialUploadService:
platform=upload.platform,
status=upload.status,
title=upload.title,
platform_username=upload.social_account.platform_data.get("display_name") if upload.social_account and upload.social_account.platform_data else None,
platform_url=upload.platform_url,
error_message=upload.error_message,
scheduled_at=upload.scheduled_at,

View File

@ -1,7 +1,19 @@
from sqladmin import ModelView
import logging
from sqladmin import ModelView, action
from starlette.requests import Request
from starlette.responses import RedirectResponse
from app.backoffice.user_view_actions import (
handle_block_users,
handle_deduct_credits,
handle_grant_credits,
handle_set_role,
)
from app.user.models import RefreshToken, SocialAccount, User
logger = logging.getLogger(__name__)
class UserAdmin(ModelView, model=User):
name = "사용자"
@ -15,6 +27,7 @@ class UserAdmin(ModelView, model=User):
"kakao_id",
"email",
"nickname",
"credits",
"role",
"is_active",
"is_deleted",
@ -32,6 +45,7 @@ class UserAdmin(ModelView, model=User):
"name",
"birth_date",
"gender",
"credits",
"is_active",
"is_admin",
"role",
@ -40,14 +54,22 @@ class UserAdmin(ModelView, model=User):
"last_login_at",
"created_at",
"updated_at",
"credit_requests",
"credit_transactions",
]
form_excluded_columns = [
"created_at",
"updated_at",
"projects",
"refresh_tokens",
"social_accounts",
form_columns = [
"nickname",
"email",
"phone",
"name",
"birth_date",
"gender",
"credits",
"is_active",
"is_admin",
"role",
"is_deleted",
]
column_searchable_list = [
@ -65,6 +87,7 @@ class UserAdmin(ModelView, model=User):
User.kakao_id,
User.email,
User.nickname,
User.credits,
User.role,
User.is_active,
User.is_deleted,
@ -82,6 +105,7 @@ class UserAdmin(ModelView, model=User):
"name": "실명",
"birth_date": "생년월일",
"gender": "성별",
"credits": "크레딧",
"is_active": "활성화",
"is_admin": "관리자",
"role": "권한",
@ -92,6 +116,82 @@ class UserAdmin(ModelView, model=User):
"updated_at": "수정일시",
}
@action(
name="block_user",
label="계정 차단",
confirmation_message="선택한 사용자를 차단하시겠습니까? 로그인이 불가해집니다.",
add_in_list=True,
)
async def block_user_action(self, request: Request) -> RedirectResponse:
return await handle_block_users(request, self.identity, block=True)
@action(
name="unblock_user",
label="차단 해제",
confirmation_message="선택한 사용자의 차단을 해제하시겠습니까?",
add_in_list=True,
)
async def unblock_user_action(self, request: Request) -> RedirectResponse:
return await handle_block_users(request, self.identity, block=False)
@action(
name="set_role_admin",
label="권한: admin으로 변경",
confirmation_message="선택한 사용자를 admin으로 변경하시겠습니까?",
add_in_list=True,
)
async def set_role_admin_action(self, request: Request) -> RedirectResponse:
return await handle_set_role(request, self.identity, role="admin")
@action(
name="set_role_user",
label="권한: user로 변경",
confirmation_message="선택한 사용자를 일반 user로 변경하시겠습니까?",
add_in_list=True,
)
async def set_role_user_action(self, request: Request) -> RedirectResponse:
return await handle_set_role(request, self.identity, role="user")
@action(
name="grant_credits_1",
label="크레딧 +1 충전",
confirmation_message="선택한 사용자에게 크레딧 1개를 충전하시겠습니까?",
add_in_list=True,
)
async def grant_credits_1_action(self, request: Request) -> RedirectResponse:
admin_id = request.session.get("admin_id")
return await handle_grant_credits(request, self.identity, amount=1, admin_id=admin_id)
@action(
name="grant_credits_5",
label="크레딧 +5 충전",
confirmation_message="선택한 사용자에게 크레딧 5개를 충전하시겠습니까?",
add_in_list=True,
)
async def grant_credits_5_action(self, request: Request) -> RedirectResponse:
admin_id = request.session.get("admin_id")
return await handle_grant_credits(request, self.identity, amount=5, admin_id=admin_id)
@action(
name="grant_credits_10",
label="크레딧 +10 충전",
confirmation_message="선택한 사용자에게 크레딧 10개를 충전하시겠습니까?",
add_in_list=True,
)
async def grant_credits_10_action(self, request: Request) -> RedirectResponse:
admin_id = request.session.get("admin_id")
return await handle_grant_credits(request, self.identity, amount=10, admin_id=admin_id)
@action(
name="deduct_credits_1",
label="크레딧 -1 차감",
confirmation_message="선택한 사용자의 크레딧 1개를 차감하시겠습니까?",
add_in_list=True,
)
async def deduct_credits_1_action(self, request: Request) -> RedirectResponse:
admin_id = request.session.get("admin_id")
return await handle_deduct_credits(request, self.identity, amount=1, admin_id=admin_id)
class RefreshTokenAdmin(ModelView, model=RefreshToken):
name = "리프레시 토큰"

View File

@ -16,6 +16,7 @@ from app.database.session import Base
if TYPE_CHECKING:
from app.credit.models import CreditChargeRequest, CreditTransaction
from app.home.models import Project
@ -276,6 +277,24 @@ class User(Base):
lazy="selectin",
)
credit_requests: Mapped[List["CreditChargeRequest"]] = relationship(
"CreditChargeRequest",
foreign_keys="CreditChargeRequest.user_uuid",
primaryjoin="User.user_uuid == CreditChargeRequest.user_uuid",
back_populates="user",
cascade="all, delete-orphan",
lazy="noload",
)
credit_transactions: Mapped[List["CreditTransaction"]] = relationship(
"CreditTransaction",
foreign_keys="CreditTransaction.user_uuid",
primaryjoin="User.user_uuid == CreditTransaction.user_uuid",
back_populates="user",
cascade="all, delete-orphan",
lazy="noload",
)
def __repr__(self) -> str:
return (
f"<User("

View File

@ -1,18 +1,24 @@
from sqlalchemy import update
import logging
from sqlalchemy.ext.asyncio import AsyncSession
from app.user.models import User
from app.credit.exceptions import InsufficientCreditError
from app.credit.models import CreditTransactionType
from app.credit.services.credit_service import deduct_credit
logger = logging.getLogger(__name__)
async def consume_credit(user_uuid, session: AsyncSession) -> bool:
"""atomic UPDATE로 1크레딧 차감.
WHERE credits > 0 조건으로 음수 차감 방지 + PostgreSQL .
차감 성공 여부 반환.
"""
result = await session.execute(
update(User)
.where(User.user_uuid == user_uuid, User.credits > 0)
.values(credits=User.credits - 1)
)
return result.rowcount > 0
async def consume_credit(user_uuid: str, session: AsyncSession, *, reason: str = "video generation") -> bool:
"""크레딧 1 차감. 기존 호출처와 시그니처 호환 유지."""
try:
await deduct_credit(
session=session,
user_uuid=user_uuid,
amount=1,
type=CreditTransactionType.CONSUME,
reason=reason,
)
return True
except InsufficientCreditError:
return False

View File

@ -463,6 +463,7 @@ class CreatomateService:
source_elements = template["source"]["elements"]
template_component_data = self.parse_template_component_name(source_elements)
taged_image_list = [img for img in taged_image_list if img.get("image_tag") is not None]
modifications = {}
for slot_idx, (template_component_name, template_type) in enumerate(template_component_data.items()):
@ -772,7 +773,9 @@ class CreatomateService:
if "animations" not in elem:
continue
for animation in elem["animations"]:
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
if "reversed" in animation:
continue
assert animation.get("time",0) == 0 # 0이 아닌 경우 확인 필요
if "transition" in animation and animation["transition"]:
track_maximum_duration[elem["track"]] -= animation["duration"]
else:

View File

@ -851,6 +851,7 @@ async def get_image_tags_by_task_id(task_id: str) -> list[dict]:
.where(
Image.task_id == task_id,
Image.is_deleted == False,
ImageTag.img_tag.is_not(None),
)
)
print(stmt.compile(dialect=mysql.dialect(), compile_kwargs={"literal_binds": True}))

View File

@ -31,6 +31,8 @@ class ProjectSettings(BaseSettings):
VERSION: str = Field(default="0.1.0")
DESCRIPTION: str = Field(default="FastAPI 기반 CastAD 프로젝트")
ADMIN_BASE_URL: str = Field(default="/admin")
ADMIN_SESSION_SECRET: str = Field(default="dev-secret-change-me-in-production")
ADMIN_SESSION_MAX_AGE: int = Field(default=60 * 60 * 8)
DEBUG: bool = Field(default=True)
TIMEZONE: str = Field(
default="Asia/Seoul",

View File

@ -1,5 +1,8 @@
-- 2026-04-28: 사용자 크레딧 시스템 도입
-- 실행 방법: psql -U <user> -d <dbname> -f 2026_04_28_add_user_credits.sql
-- ============================================================
-- Migration: user 테이블에 credits 컬럼 추가
-- Date: 2026-04-28
-- Description: 사용자 크레딧 시스템 도입
-- ============================================================
ALTER TABLE `user`
ADD COLUMN credits INT NOT NULL DEFAULT 3 COMMENT '영상 생성 크레딧';

View File

@ -0,0 +1,20 @@
-- ============================================================
-- Migration: 백오피스 관리자 계정 테이블 추가
-- Date: 2026-04-29
-- Description: SQLAdmin 백오피스 전용 admin 계정 테이블 신설
-- ============================================================
CREATE TABLE IF NOT EXISTS `admin` (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '고유 식별자',
username VARCHAR(50) NOT NULL COMMENT '로그인 ID',
password VARCHAR(255) NOT NULL COMMENT '비밀번호',
name VARCHAR(50) NULL COMMENT '표시 이름',
is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '활성화 상태 (0: 비활성화)',
last_login_at DATETIME NULL COMMENT '마지막 로그인 일시',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 일시',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시',
PRIMARY KEY (id),
UNIQUE INDEX idx_admin_username (username),
INDEX idx_admin_is_active (is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='백오피스 관리자 계정';

View File

@ -0,0 +1,49 @@
-- ============================================================
-- Migration: 크레딧 충전 요청 / 거래 이력 테이블 추가
-- Date: 2026-04-29
-- Description: 백오피스 크레딧 워크플로우 도입
-- 선행 조건: migration_2026_04_29_add_admin_table.sql 먼저 실행
-- ============================================================
CREATE TABLE IF NOT EXISTS credit_charge_request (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '고유 식별자',
user_uuid VARCHAR(36) NOT NULL COMMENT '사용자 UUID (user.user_uuid 참조)',
requested_amount INT NOT NULL COMMENT '요청 크레딧 수량 (양수)',
message VARCHAR(500) NULL COMMENT '사용자 요청 메시지',
status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '처리 상태 (pending/approved/rejected/cancelled)',
admin_id BIGINT NULL COMMENT '처리한 백오피스 관리자 ID',
admin_note VARCHAR(1000) NULL COMMENT '관리자 메모',
processed_at DATETIME NULL COMMENT '처리 일시',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '요청 일시',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시',
PRIMARY KEY (id),
CONSTRAINT fk_charge_request_user FOREIGN KEY (user_uuid) REFERENCES `user`(user_uuid) ON DELETE CASCADE,
CONSTRAINT fk_charge_request_admin FOREIGN KEY (admin_id) REFERENCES `admin`(id) ON DELETE SET NULL,
INDEX idx_credit_request_user_uuid (user_uuid),
INDEX idx_credit_request_status (status),
INDEX idx_credit_request_created_at (created_at),
INDEX idx_credit_request_status_created (status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='사용자 크레딧 충전 요청';
CREATE TABLE IF NOT EXISTS credit_transaction (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '고유 식별자',
user_uuid VARCHAR(36) NOT NULL COMMENT '사용자 UUID',
amount INT NOT NULL COMMENT '변경 크레딧 수량 (충전 양수, 차감 음수)',
balance_after INT NOT NULL COMMENT '변경 후 잔액',
type VARCHAR(20) NOT NULL COMMENT '변경 유형 (charge/consume/refund/admin_adjust)',
reason VARCHAR(255) NULL COMMENT '변경 사유',
admin_id BIGINT NULL COMMENT '처리 관리자 ID',
related_request_id BIGINT NULL COMMENT '연관 충전 요청 ID',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '변경 일시',
PRIMARY KEY (id),
CONSTRAINT fk_credit_tx_user FOREIGN KEY (user_uuid) REFERENCES `user`(user_uuid) ON DELETE CASCADE,
CONSTRAINT fk_credit_tx_admin FOREIGN KEY (admin_id) REFERENCES `admin`(id) ON DELETE SET NULL,
CONSTRAINT fk_credit_tx_request FOREIGN KEY (related_request_id) REFERENCES credit_charge_request(id) ON DELETE SET NULL,
INDEX idx_credit_tx_user_uuid (user_uuid),
INDEX idx_credit_tx_user_uuid_created (user_uuid, created_at),
INDEX idx_credit_tx_type (type),
INDEX idx_credit_tx_related_request (related_request_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='크레딧 변경 이력';

View File

@ -24,6 +24,7 @@ from app.social.api.routers.v1.oauth import router as social_oauth_router
from app.social.api.routers.v1.upload import router as social_upload_router
from app.social.api.routers.v1.seo import router as social_seo_router
from app.social.api.routers.v1.internal import router as social_internal_router
from app.credit.api.routers.v1.credit import router as credit_router
from app.utils.cors import CustomCORSMiddleware
from config import prj_settings
@ -395,6 +396,7 @@ app.include_router(social_seo_router, prefix="/social") # Social Upload 라우
app.include_router(social_internal_router) # 내부 스케줄러 전용 라우터
app.include_router(sns_router) # SNS API 라우터 추가
app.include_router(dashboard_router) # Dashboard API 라우터 추가
app.include_router(credit_router, prefix="/user") # Credit API 라우터 추가
# DEBUG 모드에서만 테스트 라우터 등록
if prj_settings.DEBUG: