From 37bf9f54eed2eb41c32ec47f64305ad2e9163e6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Mon, 4 May 2026 13:32:09 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B0=B1=EC=98=A4=ED=94=BC=EC=8A=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=ED=83=9C=EA=B7=B8=20=EC=98=88=EC=99=B8=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin_manager.py | 88 +++---- app/backoffice/__init__.py | 0 app/backoffice/admin/__init__.py | 0 app/backoffice/admin/admin_view.py | 56 ++++ app/backoffice/admin/auth.py | 72 ++++++ app/backoffice/admin/models.py | 78 ++++++ app/backoffice/admin/service.py | 43 ++++ app/backoffice/credit_view.py | 202 +++++++++++++++ app/backoffice/user_view_actions.py | 114 ++++++++ app/credit/__init__.py | 0 app/credit/api/__init__.py | 0 app/credit/api/routers/__init__.py | 0 app/credit/api/routers/v1/__init__.py | 0 app/credit/api/routers/v1/credit.py | 162 ++++++++++++ app/credit/exceptions.py | 27 ++ app/credit/models.py | 243 ++++++++++++++++++ app/credit/schemas/__init__.py | 0 app/credit/schemas/credit_schema.py | 59 +++++ app/credit/services/__init__.py | 0 app/credit/services/credit_service.py | 195 ++++++++++++++ app/database/session.py | 7 +- app/home/api/routers/v1/home.py | 2 + app/social/schemas/upload_schema.py | 1 + app/social/services/upload_service.py | 1 + app/user/api/user_admin.py | 114 +++++++- app/user/models.py | 19 ++ app/user/services/credit.py | 34 ++- app/utils/creatomate.py | 5 +- app/video/services/video.py | 1 + config.py | 2 + .../migration_2026_04_28_add_user_credits.sql | 7 +- .../migration_2026_04_29_add_admin_table.sql | 20 ++ ...migration_2026_04_29_add_credit_tables.sql | 49 ++++ main.py | 2 + 34 files changed, 1530 insertions(+), 73 deletions(-) create mode 100644 app/backoffice/__init__.py create mode 100644 app/backoffice/admin/__init__.py create mode 100644 app/backoffice/admin/admin_view.py create mode 100644 app/backoffice/admin/auth.py create mode 100644 app/backoffice/admin/models.py create mode 100644 app/backoffice/admin/service.py create mode 100644 app/backoffice/credit_view.py create mode 100644 app/backoffice/user_view_actions.py create mode 100644 app/credit/__init__.py create mode 100644 app/credit/api/__init__.py create mode 100644 app/credit/api/routers/__init__.py create mode 100644 app/credit/api/routers/v1/__init__.py create mode 100644 app/credit/api/routers/v1/credit.py create mode 100644 app/credit/exceptions.py create mode 100644 app/credit/models.py create mode 100644 app/credit/schemas/__init__.py create mode 100644 app/credit/schemas/credit_schema.py create mode 100644 app/credit/services/__init__.py create mode 100644 app/credit/services/credit_service.py create mode 100644 docs/database-schema/migration_2026_04_29_add_admin_table.sql create mode 100644 docs/database-schema/migration_2026_04_29_add_credit_tables.sql diff --git a/app/admin_manager.py b/app/admin_manager.py index 19c5628..4bf0bd0 100644 --- a/app/admin_manager.py +++ b/app/admin_manager.py @@ -1,48 +1,40 @@ -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.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 - - -def init_admin( - app: FastAPI, - db_engine: engine, - base_url: str = prj_settings.ADMIN_BASE_URL, -) -> Admin: - admin = Admin( - app, - db_engine, - base_url=base_url, - ) - - # 프로젝트 관리 - 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) - - return admin +from fastapi import FastAPI +from sqladmin import Admin + +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 config import prj_settings + +# https://github.com/aminalaee/sqladmin + + +def init_admin( + app: FastAPI, + 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(UserAdmin) + admin.add_view(SocialAccountAdmin) + + # 크레딧 관리 + admin.add_view(CreditChargeRequestAdmin) + admin.add_view(CreditTransactionAdmin) + + # 백오피스 설정 + admin.add_view(AdminAdmin) + + return admin diff --git a/app/backoffice/__init__.py b/app/backoffice/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/backoffice/admin/__init__.py b/app/backoffice/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/backoffice/admin/admin_view.py b/app/backoffice/admin/admin_view.py new file mode 100644 index 0000000..d50a321 --- /dev/null +++ b/app/backoffice/admin/admin_view.py @@ -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 diff --git a/app/backoffice/admin/auth.py b/app/backoffice/admin/auth.py new file mode 100644 index 0000000..d7e44be --- /dev/null +++ b/app/backoffice/admin/auth.py @@ -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 diff --git a/app/backoffice/admin/models.py b/app/backoffice/admin/models.py new file mode 100644 index 0000000..77cf02e --- /dev/null +++ b/app/backoffice/admin/models.py @@ -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"" diff --git a/app/backoffice/admin/service.py b/app/backoffice/admin/service.py new file mode 100644 index 0000000..d3fa196 --- /dev/null +++ b/app/backoffice/admin/service.py @@ -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}") diff --git a/app/backoffice/credit_view.py b/app/backoffice/credit_view.py new file mode 100644 index 0000000..0a737fa --- /dev/null +++ b/app/backoffice/credit_view.py @@ -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)) + ) diff --git a/app/backoffice/user_view_actions.py b/app/backoffice/user_view_actions.py new file mode 100644 index 0000000..f36ff4b --- /dev/null +++ b/app/backoffice/user_view_actions.py @@ -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) diff --git a/app/credit/__init__.py b/app/credit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/credit/api/__init__.py b/app/credit/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/credit/api/routers/__init__.py b/app/credit/api/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/credit/api/routers/v1/__init__.py b/app/credit/api/routers/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/credit/api/routers/v1/credit.py b/app/credit/api/routers/v1/credit.py new file mode 100644 index 0000000..f918f47 --- /dev/null +++ b/app/credit/api/routers/v1/credit.py @@ -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, + ) diff --git a/app/credit/exceptions.py b/app/credit/exceptions.py new file mode 100644 index 0000000..3cef871 --- /dev/null +++ b/app/credit/exceptions.py @@ -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 diff --git a/app/credit/models.py b/app/credit/models.py new file mode 100644 index 0000000..09834a1 --- /dev/null +++ b/app/credit/models.py @@ -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"" + ) + + +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"" + ) diff --git a/app/credit/schemas/__init__.py b/app/credit/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/credit/schemas/credit_schema.py b/app/credit/schemas/credit_schema.py new file mode 100644 index 0000000..1da16e9 --- /dev/null +++ b/app/credit/schemas/credit_schema.py @@ -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 diff --git a/app/credit/services/__init__.py b/app/credit/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/credit/services/credit_service.py b/app/credit/services/credit_service.py new file mode 100644 index 0000000..bfbd174 --- /dev/null +++ b/app/credit/services/credit_service.py @@ -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 diff --git a/app/database/session.py b/app/database/session.py index b6bf223..ff0e8e3 100644 --- a/app/database/session.py +++ b/app/database/session.py @@ -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...") diff --git a/app/home/api/routers/v1/home.py b/app/home/api/routers/v1/home.py index a107828..36fc98a 100644 --- a/app/home/api/routers/v1/home.py +++ b/app/home/api/routers/v1/home.py @@ -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() diff --git a/app/social/schemas/upload_schema.py b/app/social/schemas/upload_schema.py index 7d7bd02..d095262 100644 --- a/app/social/schemas/upload_schema.py +++ b/app/social/schemas/upload_schema.py @@ -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="예약 게시 시간") diff --git a/app/social/services/upload_service.py b/app/social/services/upload_service.py index 3472de3..dec2d88 100644 --- a/app/social/services/upload_service.py +++ b/app/social/services/upload_service.py @@ -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, diff --git a/app/user/api/user_admin.py b/app/user/api/user_admin.py index af39add..6e619e2 100644 --- a/app/user/api/user_admin.py +++ b/app/user/api/user_admin.py @@ -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 = "리프레시 토큰" diff --git a/app/user/models.py b/app/user/models.py index d3015aa..38d4df7 100644 --- a/app/user/models.py +++ b/app/user/models.py @@ -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" 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 diff --git a/app/utils/creatomate.py b/app/utils/creatomate.py index 2d7576b..e7b51df 100644 --- a/app/utils/creatomate.py +++ b/app/utils/creatomate.py @@ -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: diff --git a/app/video/services/video.py b/app/video/services/video.py index 8cd4a33..460d487 100644 --- a/app/video/services/video.py +++ b/app/video/services/video.py @@ -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})) diff --git a/config.py b/config.py index a3cc7bd..0a3458a 100644 --- a/config.py +++ b/config.py @@ -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", diff --git a/docs/database-schema/migration_2026_04_28_add_user_credits.sql b/docs/database-schema/migration_2026_04_28_add_user_credits.sql index 18ec720..abd0f29 100644 --- a/docs/database-schema/migration_2026_04_28_add_user_credits.sql +++ b/docs/database-schema/migration_2026_04_28_add_user_credits.sql @@ -1,5 +1,8 @@ --- 2026-04-28: 사용자 크레딧 시스템 도입 --- 실행 방법: psql -U -d -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 '영상 생성 크레딧'; diff --git a/docs/database-schema/migration_2026_04_29_add_admin_table.sql b/docs/database-schema/migration_2026_04_29_add_admin_table.sql new file mode 100644 index 0000000..f895b64 --- /dev/null +++ b/docs/database-schema/migration_2026_04_29_add_admin_table.sql @@ -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='백오피스 관리자 계정'; diff --git a/docs/database-schema/migration_2026_04_29_add_credit_tables.sql b/docs/database-schema/migration_2026_04_29_add_credit_tables.sql new file mode 100644 index 0000000..5a6e46c --- /dev/null +++ b/docs/database-schema/migration_2026_04_29_add_credit_tables.sql @@ -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='크레딧 변경 이력'; diff --git a/main.py b/main.py index 0b1c187..6c21d28 100644 --- a/main.py +++ b/main.py @@ -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: