백오피스 추가 및 이미지태그 예외처리
parent
1e3da98c6a
commit
37bf9f54ee
|
|
@ -1,13 +1,11 @@
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from sqladmin import Admin
|
from sqladmin import Admin
|
||||||
|
|
||||||
from app.database.session import engine
|
from app.backoffice.admin.admin_view import AdminAdmin
|
||||||
from app.home.api.home_admin import ImageAdmin, ProjectAdmin
|
from app.backoffice.admin.auth import AdminAuthBackend
|
||||||
from app.lyric.api.lyrics_admin import LyricAdmin
|
from app.backoffice.credit_view import CreditChargeRequestAdmin, CreditTransactionAdmin, CreditTransactionAdmin
|
||||||
from app.song.api.song_admin import SongAdmin
|
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||||
from app.sns.api.sns_admin import SNSUploadTaskAdmin
|
|
||||||
from app.user.api.user_admin import RefreshTokenAdmin, SocialAccountAdmin, UserAdmin
|
from app.user.api.user_admin import RefreshTokenAdmin, SocialAccountAdmin, UserAdmin
|
||||||
from app.video.api.video_admin import VideoAdmin
|
|
||||||
from config import prj_settings
|
from config import prj_settings
|
||||||
|
|
||||||
# https://github.com/aminalaee/sqladmin
|
# https://github.com/aminalaee/sqladmin
|
||||||
|
|
@ -15,34 +13,28 @@ from config import prj_settings
|
||||||
|
|
||||||
def init_admin(
|
def init_admin(
|
||||||
app: FastAPI,
|
app: FastAPI,
|
||||||
db_engine: engine,
|
db_engine: AsyncEngine,
|
||||||
base_url: str = prj_settings.ADMIN_BASE_URL,
|
base_url: str = prj_settings.ADMIN_BASE_URL,
|
||||||
) -> Admin:
|
) -> Admin:
|
||||||
|
auth_backend = AdminAuthBackend(secret_key=prj_settings.ADMIN_SESSION_SECRET)
|
||||||
|
|
||||||
admin = Admin(
|
admin = Admin(
|
||||||
app,
|
app,
|
||||||
db_engine,
|
db_engine,
|
||||||
base_url=base_url,
|
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(UserAdmin)
|
||||||
admin.add_view(RefreshTokenAdmin)
|
|
||||||
admin.add_view(SocialAccountAdmin)
|
admin.add_view(SocialAccountAdmin)
|
||||||
|
|
||||||
# SNS 관리
|
# 크레딧 관리
|
||||||
admin.add_view(SNSUploadTaskAdmin)
|
admin.add_view(CreditChargeRequestAdmin)
|
||||||
|
admin.add_view(CreditTransactionAdmin)
|
||||||
|
|
||||||
|
# 백오피스 설정
|
||||||
|
admin.add_view(AdminAdmin)
|
||||||
|
|
||||||
return admin
|
return admin
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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})>"
|
||||||
|
|
@ -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}")
|
||||||
|
|
@ -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))
|
||||||
|
)
|
||||||
|
|
@ -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,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,
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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")>"
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -81,8 +81,10 @@ async def create_db_tables():
|
||||||
from app.sns.models import SNSUploadTask # noqa: F401
|
from app.sns.models import SNSUploadTask # noqa: F401
|
||||||
from app.social.models import SocialUpload # noqa: F401
|
from app.social.models import SocialUpload # noqa: F401
|
||||||
from app.dashboard.models import Dashboard # 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 = [
|
tables_to_create = [
|
||||||
User.__table__,
|
User.__table__,
|
||||||
RefreshToken.__table__,
|
RefreshToken.__table__,
|
||||||
|
|
@ -98,6 +100,9 @@ async def create_db_tables():
|
||||||
MarketingIntel.__table__,
|
MarketingIntel.__table__,
|
||||||
Dashboard.__table__,
|
Dashboard.__table__,
|
||||||
ImageTag.__table__,
|
ImageTag.__table__,
|
||||||
|
Admin.__table__,
|
||||||
|
CreditChargeRequest.__table__,
|
||||||
|
CreditTransaction.__table__,
|
||||||
]
|
]
|
||||||
|
|
||||||
logger.info("Creating database tables...")
|
logger.info("Creating database tables...")
|
||||||
|
|
|
||||||
|
|
@ -803,6 +803,8 @@ async def tagging_images(
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
for tag, tag_data in zip(null_imts, tag_datas):
|
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")
|
tag.img_tag = tag_data.model_dump(mode="json")
|
||||||
session.add(tag)
|
session.add(tag)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,7 @@ class SocialUploadHistoryItem(BaseModel):
|
||||||
platform: str = Field(..., description="플랫폼명")
|
platform: str = Field(..., description="플랫폼명")
|
||||||
status: str = Field(..., description="업로드 상태")
|
status: str = Field(..., description="업로드 상태")
|
||||||
title: str = Field(..., description="영상 제목")
|
title: str = Field(..., description="영상 제목")
|
||||||
|
platform_username: Optional[str] = Field(None, description="플랫폼 채널명")
|
||||||
platform_url: Optional[str] = Field(None, description="플랫폼 영상 URL")
|
platform_url: Optional[str] = Field(None, description="플랫폼 영상 URL")
|
||||||
error_message: Optional[str] = Field(None, description="에러 메시지")
|
error_message: Optional[str] = Field(None, description="에러 메시지")
|
||||||
scheduled_at: Optional[datetime] = Field(None, description="예약 게시 시간")
|
scheduled_at: Optional[datetime] = Field(None, description="예약 게시 시간")
|
||||||
|
|
|
||||||
|
|
@ -291,6 +291,7 @@ class SocialUploadService:
|
||||||
platform=upload.platform,
|
platform=upload.platform,
|
||||||
status=upload.status,
|
status=upload.status,
|
||||||
title=upload.title,
|
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,
|
platform_url=upload.platform_url,
|
||||||
error_message=upload.error_message,
|
error_message=upload.error_message,
|
||||||
scheduled_at=upload.scheduled_at,
|
scheduled_at=upload.scheduled_at,
|
||||||
|
|
|
||||||
|
|
@ -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
|
from app.user.models import RefreshToken, SocialAccount, User
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class UserAdmin(ModelView, model=User):
|
class UserAdmin(ModelView, model=User):
|
||||||
name = "사용자"
|
name = "사용자"
|
||||||
|
|
@ -15,6 +27,7 @@ class UserAdmin(ModelView, model=User):
|
||||||
"kakao_id",
|
"kakao_id",
|
||||||
"email",
|
"email",
|
||||||
"nickname",
|
"nickname",
|
||||||
|
"credits",
|
||||||
"role",
|
"role",
|
||||||
"is_active",
|
"is_active",
|
||||||
"is_deleted",
|
"is_deleted",
|
||||||
|
|
@ -32,6 +45,7 @@ class UserAdmin(ModelView, model=User):
|
||||||
"name",
|
"name",
|
||||||
"birth_date",
|
"birth_date",
|
||||||
"gender",
|
"gender",
|
||||||
|
"credits",
|
||||||
"is_active",
|
"is_active",
|
||||||
"is_admin",
|
"is_admin",
|
||||||
"role",
|
"role",
|
||||||
|
|
@ -40,14 +54,22 @@ class UserAdmin(ModelView, model=User):
|
||||||
"last_login_at",
|
"last_login_at",
|
||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
|
"credit_requests",
|
||||||
|
"credit_transactions",
|
||||||
]
|
]
|
||||||
|
|
||||||
form_excluded_columns = [
|
form_columns = [
|
||||||
"created_at",
|
"nickname",
|
||||||
"updated_at",
|
"email",
|
||||||
"projects",
|
"phone",
|
||||||
"refresh_tokens",
|
"name",
|
||||||
"social_accounts",
|
"birth_date",
|
||||||
|
"gender",
|
||||||
|
"credits",
|
||||||
|
"is_active",
|
||||||
|
"is_admin",
|
||||||
|
"role",
|
||||||
|
"is_deleted",
|
||||||
]
|
]
|
||||||
|
|
||||||
column_searchable_list = [
|
column_searchable_list = [
|
||||||
|
|
@ -65,6 +87,7 @@ class UserAdmin(ModelView, model=User):
|
||||||
User.kakao_id,
|
User.kakao_id,
|
||||||
User.email,
|
User.email,
|
||||||
User.nickname,
|
User.nickname,
|
||||||
|
User.credits,
|
||||||
User.role,
|
User.role,
|
||||||
User.is_active,
|
User.is_active,
|
||||||
User.is_deleted,
|
User.is_deleted,
|
||||||
|
|
@ -82,6 +105,7 @@ class UserAdmin(ModelView, model=User):
|
||||||
"name": "실명",
|
"name": "실명",
|
||||||
"birth_date": "생년월일",
|
"birth_date": "생년월일",
|
||||||
"gender": "성별",
|
"gender": "성별",
|
||||||
|
"credits": "크레딧",
|
||||||
"is_active": "활성화",
|
"is_active": "활성화",
|
||||||
"is_admin": "관리자",
|
"is_admin": "관리자",
|
||||||
"role": "권한",
|
"role": "권한",
|
||||||
|
|
@ -92,6 +116,82 @@ class UserAdmin(ModelView, model=User):
|
||||||
"updated_at": "수정일시",
|
"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):
|
class RefreshTokenAdmin(ModelView, model=RefreshToken):
|
||||||
name = "리프레시 토큰"
|
name = "리프레시 토큰"
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ from app.database.session import Base
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from app.credit.models import CreditChargeRequest, CreditTransaction
|
||||||
from app.home.models import Project
|
from app.home.models import Project
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -276,6 +277,24 @@ class User(Base):
|
||||||
lazy="selectin",
|
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:
|
def __repr__(self) -> str:
|
||||||
return (
|
return (
|
||||||
f"<User("
|
f"<User("
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,24 @@
|
||||||
from sqlalchemy import update
|
import logging
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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:
|
async def consume_credit(user_uuid: str, session: AsyncSession, *, reason: str = "video generation") -> bool:
|
||||||
"""atomic UPDATE로 1크레딧 차감.
|
"""크레딧 1 차감. 기존 호출처와 시그니처 호환 유지."""
|
||||||
|
try:
|
||||||
WHERE credits > 0 조건으로 음수 차감 방지 + PostgreSQL 행 락.
|
await deduct_credit(
|
||||||
차감 성공 여부 반환.
|
session=session,
|
||||||
"""
|
user_uuid=user_uuid,
|
||||||
result = await session.execute(
|
amount=1,
|
||||||
update(User)
|
type=CreditTransactionType.CONSUME,
|
||||||
.where(User.user_uuid == user_uuid, User.credits > 0)
|
reason=reason,
|
||||||
.values(credits=User.credits - 1)
|
|
||||||
)
|
)
|
||||||
return result.rowcount > 0
|
return True
|
||||||
|
except InsufficientCreditError:
|
||||||
|
return False
|
||||||
|
|
|
||||||
|
|
@ -463,6 +463,7 @@ class CreatomateService:
|
||||||
source_elements = template["source"]["elements"]
|
source_elements = template["source"]["elements"]
|
||||||
template_component_data = self.parse_template_component_name(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 = {}
|
modifications = {}
|
||||||
|
|
||||||
for slot_idx, (template_component_name, template_type) in enumerate(template_component_data.items()):
|
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:
|
if "animations" not in elem:
|
||||||
continue
|
continue
|
||||||
for animation in elem["animations"]:
|
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"]:
|
if "transition" in animation and animation["transition"]:
|
||||||
track_maximum_duration[elem["track"]] -= animation["duration"]
|
track_maximum_duration[elem["track"]] -= animation["duration"]
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -851,6 +851,7 @@ async def get_image_tags_by_task_id(task_id: str) -> list[dict]:
|
||||||
.where(
|
.where(
|
||||||
Image.task_id == task_id,
|
Image.task_id == task_id,
|
||||||
Image.is_deleted == False,
|
Image.is_deleted == False,
|
||||||
|
ImageTag.img_tag.is_not(None),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
print(stmt.compile(dialect=mysql.dialect(), compile_kwargs={"literal_binds": True}))
|
print(stmt.compile(dialect=mysql.dialect(), compile_kwargs={"literal_binds": True}))
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ class ProjectSettings(BaseSettings):
|
||||||
VERSION: str = Field(default="0.1.0")
|
VERSION: str = Field(default="0.1.0")
|
||||||
DESCRIPTION: str = Field(default="FastAPI 기반 CastAD 프로젝트")
|
DESCRIPTION: str = Field(default="FastAPI 기반 CastAD 프로젝트")
|
||||||
ADMIN_BASE_URL: str = Field(default="/admin")
|
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)
|
DEBUG: bool = Field(default=True)
|
||||||
TIMEZONE: str = Field(
|
TIMEZONE: str = Field(
|
||||||
default="Asia/Seoul",
|
default="Asia/Seoul",
|
||||||
|
|
|
||||||
|
|
@ -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`
|
ALTER TABLE `user`
|
||||||
ADD COLUMN credits INT NOT NULL DEFAULT 3 COMMENT '영상 생성 크레딧';
|
ADD COLUMN credits INT NOT NULL DEFAULT 3 COMMENT '영상 생성 크레딧';
|
||||||
|
|
|
||||||
|
|
@ -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='백오피스 관리자 계정';
|
||||||
|
|
@ -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='크레딧 변경 이력';
|
||||||
2
main.py
2
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.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.seo import router as social_seo_router
|
||||||
from app.social.api.routers.v1.internal import router as social_internal_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 app.utils.cors import CustomCORSMiddleware
|
||||||
from config import prj_settings
|
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(social_internal_router) # 내부 스케줄러 전용 라우터
|
||||||
app.include_router(sns_router) # SNS API 라우터 추가
|
app.include_router(sns_router) # SNS API 라우터 추가
|
||||||
app.include_router(dashboard_router) # Dashboard API 라우터 추가
|
app.include_router(dashboard_router) # Dashboard API 라우터 추가
|
||||||
|
app.include_router(credit_router, prefix="/user") # Credit API 라우터 추가
|
||||||
|
|
||||||
# DEBUG 모드에서만 테스트 라우터 등록
|
# DEBUG 모드에서만 테스트 라우터 등록
|
||||||
if prj_settings.DEBUG:
|
if prj_settings.DEBUG:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue