Compare commits
1 Commits
main
...
feature-da
| Author | SHA1 | Date |
|---|---|---|
|
|
6301186288 |
|
|
@ -53,6 +53,3 @@ Dockerfile
|
||||||
|
|
||||||
zzz/
|
zzz/
|
||||||
credentials/service_account.json
|
credentials/service_account.json
|
||||||
|
|
||||||
# Scheduler (separate repo)
|
|
||||||
o2o-castad-scheduler/
|
|
||||||
|
|
@ -276,5 +276,3 @@ fastapi run main.py
|
||||||
│◀───────────────│ │ │
|
│◀───────────────│ │ │
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
```
|
```
|
||||||
|
|
||||||
testAc
|
|
||||||
|
|
@ -1,78 +1,48 @@
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from sqladmin import Admin
|
from sqladmin import Admin
|
||||||
from sqladmin.authentication import login_required
|
|
||||||
from starlette.exceptions import HTTPException
|
|
||||||
from starlette.requests import Request
|
|
||||||
from starlette.responses import Response
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
||||||
|
|
||||||
from app.backoffice.admin.admin_view import AdminAdmin
|
from app.database.session import engine
|
||||||
from app.backoffice.admin.auth import AdminAuthBackend
|
from app.home.api.home_admin import ImageAdmin, ProjectAdmin
|
||||||
from app.backoffice.credit_view import CreditChargeRequestAdmin, CreditTransactionAdmin
|
from app.lyric.api.lyrics_admin import LyricAdmin
|
||||||
from app.backoffice.dashboard import get_dashboard_context
|
from app.song.api.song_admin import SongAdmin
|
||||||
from app.user.api.user_admin import SocialAccountAdmin, UserAdmin
|
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
|
from config import prj_settings
|
||||||
|
|
||||||
TEMPLATES_DIR = Path(__file__).parent / "backoffice" / "frontend" / "templates"
|
# https://github.com/aminalaee/sqladmin
|
||||||
|
|
||||||
|
|
||||||
class DashboardAdmin(Admin):
|
|
||||||
@login_required
|
|
||||||
async def index(self, request: Request) -> Response:
|
|
||||||
ctx = await get_dashboard_context()
|
|
||||||
admin_role = request.session.get("admin_role", "viewer")
|
|
||||||
return await self.templates.TemplateResponse(
|
|
||||||
request,
|
|
||||||
"sqladmin/index.html",
|
|
||||||
{"title": "대시보드", "subtitle": "", "admin_role": admin_role, **ctx},
|
|
||||||
)
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
async def edit(self, request: Request) -> Response:
|
|
||||||
if request.session.get("admin_role") == "viewer":
|
|
||||||
raise HTTPException(status_code=403)
|
|
||||||
return await super().edit(request)
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
async def create(self, request: Request) -> Response:
|
|
||||||
if request.session.get("admin_role") == "viewer":
|
|
||||||
raise HTTPException(status_code=403)
|
|
||||||
return await super().create(request)
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
async def delete(self, request: Request) -> Response:
|
|
||||||
if request.session.get("admin_role") == "viewer":
|
|
||||||
raise HTTPException(status_code=403)
|
|
||||||
return await super().delete(request)
|
|
||||||
|
|
||||||
|
|
||||||
def init_admin(
|
def init_admin(
|
||||||
app: FastAPI,
|
app: FastAPI,
|
||||||
db_engine: AsyncEngine,
|
db_engine: engine,
|
||||||
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 = DashboardAdmin(
|
|
||||||
app,
|
app,
|
||||||
db_engine,
|
db_engine,
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
authentication_backend=auth_backend,
|
|
||||||
title="ADO2 관리자",
|
|
||||||
templates_dir=str(TEMPLATES_DIR),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 프로젝트 관리
|
||||||
|
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)
|
||||||
|
|
||||||
# 크레딧 관리 (superadmin: 전체, viewer: 읽기 전용)
|
# SNS 관리
|
||||||
admin.add_view(CreditChargeRequestAdmin)
|
admin.add_view(SNSUploadTaskAdmin)
|
||||||
admin.add_view(CreditTransactionAdmin)
|
|
||||||
|
|
||||||
# 백오피스 설정
|
|
||||||
admin.add_view(AdminAdmin)
|
|
||||||
|
|
||||||
return admin
|
return admin
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,7 @@ from app.user.dependencies.auth import get_current_user
|
||||||
from app.user.models import User
|
from app.user.models import User
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from app.utils.pagination import PaginatedResponse
|
from app.utils.pagination import PaginatedResponse
|
||||||
from app.comment.models import Comment
|
from app.video.models import Video
|
||||||
from app.database.like_cache import get_like_counts, mset_like_counts
|
|
||||||
from app.video.models import Video, VideoReaction
|
|
||||||
from app.video.schemas.video_schema import VideoListItem
|
from app.video.schemas.video_schema import VideoListItem
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
@ -101,22 +99,9 @@ async def get_videos(
|
||||||
total_result = await session.execute(count_query)
|
total_result = await session.execute(count_query)
|
||||||
total = total_result.scalar() or 0
|
total = total_result.scalar() or 0
|
||||||
|
|
||||||
# 쿼리 2: Video + Project + comment_count 조회 (like_count는 Redis에서)
|
# 쿼리 2: Video + Project 데이터 조회 (task_id별 최신 영상만)
|
||||||
comment_count_subq = (
|
|
||||||
select(func.count(Comment.id))
|
|
||||||
.where(
|
|
||||||
Comment.video_id == Video.id,
|
|
||||||
Comment.is_deleted == False, # noqa: E712
|
|
||||||
)
|
|
||||||
.correlate(Video)
|
|
||||||
.scalar_subquery()
|
|
||||||
)
|
|
||||||
data_query = (
|
data_query = (
|
||||||
select(
|
select(Video, Project)
|
||||||
Video,
|
|
||||||
Project,
|
|
||||||
comment_count_subq.label("comment_count"),
|
|
||||||
)
|
|
||||||
.join(Project, Video.project_id == Project.id)
|
.join(Project, Video.project_id == Project.id)
|
||||||
.where(Video.id.in_(select(latest_video_ids.c.latest_id)))
|
.where(Video.id.in_(select(latest_video_ids.c.latest_id)))
|
||||||
.order_by(Video.created_at.desc())
|
.order_by(Video.created_at.desc())
|
||||||
|
|
@ -126,29 +111,6 @@ async def get_videos(
|
||||||
result = await session.execute(data_query)
|
result = await session.execute(data_query)
|
||||||
rows = result.all()
|
rows = result.all()
|
||||||
|
|
||||||
# Redis mget으로 like_count 일괄 조회
|
|
||||||
video_ids = [video.id for video, project, _ in rows]
|
|
||||||
like_count_map = await get_like_counts(video_ids)
|
|
||||||
|
|
||||||
# 캐시 미스(None)인 video_id만 DB에서 보정
|
|
||||||
missing_ids = [vid for vid, cnt in like_count_map.items() if cnt is None]
|
|
||||||
if missing_ids:
|
|
||||||
db_counts = (await session.execute(
|
|
||||||
select(VideoReaction.video_id, func.count(VideoReaction.id))
|
|
||||||
.where(VideoReaction.video_id.in_(missing_ids))
|
|
||||||
.group_by(VideoReaction.video_id)
|
|
||||||
)).all()
|
|
||||||
db_found_ids = set()
|
|
||||||
batch = {}
|
|
||||||
for vid, cnt in db_counts:
|
|
||||||
batch[vid] = cnt
|
|
||||||
like_count_map[vid] = cnt
|
|
||||||
db_found_ids.add(vid)
|
|
||||||
await mset_like_counts(batch)
|
|
||||||
for vid in missing_ids:
|
|
||||||
if vid not in db_found_ids:
|
|
||||||
like_count_map[vid] = 0
|
|
||||||
|
|
||||||
# VideoListItem으로 변환
|
# VideoListItem으로 변환
|
||||||
items = [
|
items = [
|
||||||
VideoListItem(
|
VideoListItem(
|
||||||
|
|
@ -158,10 +120,8 @@ async def get_videos(
|
||||||
task_id=video.task_id,
|
task_id=video.task_id,
|
||||||
result_movie_url=video.result_movie_url,
|
result_movie_url=video.result_movie_url,
|
||||||
created_at=video.created_at,
|
created_at=video.created_at,
|
||||||
like_count=like_count_map.get(video.id) or 0,
|
|
||||||
comment_count=comment_count or 0,
|
|
||||||
)
|
)
|
||||||
for video, project, comment_count in rows
|
for video, project in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
response = PaginatedResponse.create(
|
response = PaginatedResponse.create(
|
||||||
|
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
from sqladmin import ModelView
|
|
||||||
from wtforms import PasswordField, SelectField
|
|
||||||
|
|
||||||
from app.backoffice.admin.models import Admin
|
|
||||||
from app.backoffice.mixins import SuperAdminOnly
|
|
||||||
|
|
||||||
|
|
||||||
class AdminAdmin(SuperAdminOnly, ModelView, model=Admin):
|
|
||||||
name = "관리자 계정"
|
|
||||||
name_plural = "관리자 계정 목록"
|
|
||||||
icon = "fa-solid fa-user-shield"
|
|
||||||
category = "백오피스 설정"
|
|
||||||
page_size = 30
|
|
||||||
|
|
||||||
column_list = [
|
|
||||||
"id",
|
|
||||||
"username",
|
|
||||||
"name",
|
|
||||||
"role",
|
|
||||||
"is_active",
|
|
||||||
"last_login_at",
|
|
||||||
"created_at",
|
|
||||||
]
|
|
||||||
|
|
||||||
column_details_list = [
|
|
||||||
"id",
|
|
||||||
"username",
|
|
||||||
"name",
|
|
||||||
"role",
|
|
||||||
"is_active",
|
|
||||||
"last_login_at",
|
|
||||||
"created_at",
|
|
||||||
"updated_at",
|
|
||||||
]
|
|
||||||
|
|
||||||
form_columns = ["username", "password", "name", "role", "is_active"]
|
|
||||||
|
|
||||||
form_overrides = {
|
|
||||||
"password": PasswordField,
|
|
||||||
"role": SelectField,
|
|
||||||
}
|
|
||||||
|
|
||||||
form_args = {
|
|
||||||
"role": {
|
|
||||||
"label": "권한",
|
|
||||||
"choices": [("superadmin", "전체 관리자"), ("viewer", "일반 관리자")],
|
|
||||||
"default": "viewer",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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": "이름",
|
|
||||||
"role": "권한",
|
|
||||||
"is_active": "활성화",
|
|
||||||
"last_login_at": "마지막 로그인",
|
|
||||||
"created_at": "생성일시",
|
|
||||||
"updated_at": "수정일시",
|
|
||||||
}
|
|
||||||
|
|
||||||
can_delete = False
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
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
|
|
||||||
request.session["admin_role"] = admin.role
|
|
||||||
request.session["admin_name"] = admin.name or admin.username
|
|
||||||
logger.info(f"[ADMIN-AUTH] login success admin_id={admin.id} username={username} role={admin.role}")
|
|
||||||
|
|
||||||
# 마지막 로그인 시간 갱신
|
|
||||||
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
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
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="표시 이름",
|
|
||||||
)
|
|
||||||
|
|
||||||
role: Mapped[str] = mapped_column(
|
|
||||||
String(20),
|
|
||||||
nullable=False,
|
|
||||||
default="viewer",
|
|
||||||
server_default="viewer",
|
|
||||||
comment="권한 (superadmin: 전체, viewer: 조회만)",
|
|
||||||
)
|
|
||||||
|
|
||||||
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})>"
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
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}")
|
|
||||||
|
|
@ -1,205 +0,0 @@
|
||||||
import logging
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.orm import outerjoin
|
|
||||||
from sqladmin import ModelView, action
|
|
||||||
from app.backoffice.mixins import SuperAdminEditable
|
|
||||||
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(SuperAdminEditable, ModelView, model=CreditChargeRequest):
|
|
||||||
name = "충전 요청"
|
|
||||||
name_plural = "충전 요청 목록"
|
|
||||||
icon = "fa-solid fa-coins"
|
|
||||||
category = "크레딧 관리"
|
|
||||||
page_size = 30
|
|
||||||
can_edit = True
|
|
||||||
can_delete = False
|
|
||||||
|
|
||||||
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(SuperAdminEditable, ModelView, model=CreditTransaction):
|
|
||||||
name = "크레딧 변경"
|
|
||||||
name_plural = "크레딧 변경 목록"
|
|
||||||
icon = "fa-solid fa-clock-rotate-left"
|
|
||||||
category = "크레딧 관리"
|
|
||||||
page_size = 30
|
|
||||||
|
|
||||||
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))
|
|
||||||
)
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from starlette.requests import Request
|
|
||||||
from starlette.responses import Response
|
|
||||||
|
|
||||||
from app.credit.models import ChargeRequestStatus, CreditChargeRequest, CreditTransaction, CreditTransactionType
|
|
||||||
from app.database.session import AsyncSessionLocal
|
|
||||||
from app.user.models import User
|
|
||||||
from config import TIMEZONE
|
|
||||||
|
|
||||||
|
|
||||||
async def get_dashboard_context() -> dict:
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
today_start = datetime.now(TIMEZONE).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
||||||
|
|
||||||
pending_charge_requests_count = (await session.execute(
|
|
||||||
select(func.count()).select_from(CreditChargeRequest)
|
|
||||||
.where(CreditChargeRequest.status == ChargeRequestStatus.PENDING)
|
|
||||||
)).scalar()
|
|
||||||
|
|
||||||
today_charge = (await session.execute(
|
|
||||||
select(func.count()).select_from(CreditTransaction)
|
|
||||||
.where(
|
|
||||||
CreditTransaction.type == CreditTransactionType.CHARGE,
|
|
||||||
CreditTransaction.created_at >= today_start,
|
|
||||||
)
|
|
||||||
)).scalar()
|
|
||||||
|
|
||||||
today_consume = (await session.execute(
|
|
||||||
select(func.count()).select_from(CreditTransaction)
|
|
||||||
.where(
|
|
||||||
CreditTransaction.type == CreditTransactionType.CONSUME,
|
|
||||||
CreditTransaction.created_at >= today_start,
|
|
||||||
)
|
|
||||||
)).scalar()
|
|
||||||
|
|
||||||
month_start = datetime.now(TIMEZONE).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
||||||
month_consume = (await session.execute(
|
|
||||||
select(func.coalesce(func.sum(func.abs(CreditTransaction.amount)), 0))
|
|
||||||
.select_from(CreditTransaction)
|
|
||||||
.where(
|
|
||||||
CreditTransaction.type == CreditTransactionType.CONSUME,
|
|
||||||
CreditTransaction.created_at >= month_start,
|
|
||||||
)
|
|
||||||
)).scalar()
|
|
||||||
|
|
||||||
pending_requests = (await session.execute(
|
|
||||||
select(CreditChargeRequest)
|
|
||||||
.where(CreditChargeRequest.status == ChargeRequestStatus.PENDING)
|
|
||||||
.order_by(CreditChargeRequest.created_at.desc())
|
|
||||||
.limit(10)
|
|
||||||
)).scalars().all()
|
|
||||||
|
|
||||||
recent_transactions = (await session.execute(
|
|
||||||
select(CreditTransaction)
|
|
||||||
.order_by(CreditTransaction.created_at.desc())
|
|
||||||
.limit(10)
|
|
||||||
)).scalars().all()
|
|
||||||
|
|
||||||
recent_users = (await session.execute(
|
|
||||||
select(User)
|
|
||||||
.where(User.is_deleted == False)
|
|
||||||
.order_by(User.created_at.desc())
|
|
||||||
.limit(10)
|
|
||||||
)).scalars().all()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"stats": {
|
|
||||||
"pending_charge_requests": pending_charge_requests_count,
|
|
||||||
"today_charge": today_charge,
|
|
||||||
"today_consume": today_consume,
|
|
||||||
"month_consume": month_consume,
|
|
||||||
},
|
|
||||||
"pending_requests": pending_requests,
|
|
||||||
"recent_transactions": recent_transactions,
|
|
||||||
"recent_users": recent_users,
|
|
||||||
}
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
{% extends "sqladmin/layout.html" %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h3 class="card-title">
|
|
||||||
{% for pk in model_view.pk_columns -%}
|
|
||||||
{{ pk.name }}
|
|
||||||
{%- if not loop.last %};{% endif -%}
|
|
||||||
{% endfor %}: {{ get_object_identifier(model) }}</h3>
|
|
||||||
</div>
|
|
||||||
<div class="card-body border-bottom py-3">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table card-table table-vcenter text-nowrap table-hover table-bordered">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="w-1">Column</th>
|
|
||||||
<th class="w-1">Value</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for name in model_view._details_prop_names %}
|
|
||||||
{% set label = model_view._column_labels.get(name, name) %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ label }}</td>
|
|
||||||
{% set value, formatted_value = model_view.get_detail_value(model, name) %}
|
|
||||||
{% if name in model_view._relation_names %}
|
|
||||||
{% if is_list( value ) %}
|
|
||||||
<td>
|
|
||||||
{% for elem, formatted_elem in zip(value, formatted_value) %}
|
|
||||||
{% if model_view.show_compact_lists %}
|
|
||||||
<a href="{{ model_view._build_url_for('admin:details', request, elem) }}">({{ formatted_elem }})</a>
|
|
||||||
{% else %}
|
|
||||||
<a href="{{ model_view._build_url_for('admin:details', request, elem) }}">{{ formatted_elem }}</a><br/>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</td>
|
|
||||||
{% else %}
|
|
||||||
<td><a href="{{ model_view._url_for_details_with_prop(request, model, name) }}">{{ formatted_value }}</a>
|
|
||||||
</td>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
<td>{{ formatted_value }}</td>
|
|
||||||
{% endif %}
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer container">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-1">
|
|
||||||
<a href="{{ url_for('admin:list', identity=model_view.identity) }}" class="btn">
|
|
||||||
Go Back
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% if model_view.can_delete and request.session.get('admin_role') == 'superadmin' %}
|
|
||||||
<div class="col-md-1">
|
|
||||||
<a href="#" data-name="{{ model_view.name }}" data-pk="{{ get_object_identifier(model) }}"
|
|
||||||
data-url="{{ model_view._url_for_delete(request, model) }}" data-bs-toggle="modal"
|
|
||||||
data-bs-target="#modal-delete" class="btn btn-danger">
|
|
||||||
Delete
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if model_view.can_edit and request.session.get('admin_role') == 'superadmin' %}
|
|
||||||
<div class="col-md-1">
|
|
||||||
<a href="{{ model_view._build_url_for('admin:edit', request, model) }}" class="btn btn-primary">
|
|
||||||
Edit
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% for custom_action,label in model_view._custom_actions_in_detail.items() %}
|
|
||||||
<div class="col-md-1">
|
|
||||||
{% if custom_action in model_view._custom_actions_confirmation %}
|
|
||||||
<a href="#" class="btn btn-secondary" data-bs-toggle="modal"
|
|
||||||
data-bs-target="#modal-confirmation-{{ custom_action }}">
|
|
||||||
{{ label }}
|
|
||||||
</a>
|
|
||||||
{% else %}
|
|
||||||
<a href="{{ model_view._url_for_action(request, custom_action) }}?pks={{ get_object_identifier(model) }}"
|
|
||||||
class="btn btn-secondary">
|
|
||||||
{{ label }}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% if model_view.can_delete %}
|
|
||||||
{% include 'sqladmin/modals/delete.html' %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% for custom_action in model_view._custom_actions_in_detail %}
|
|
||||||
{% if custom_action in model_view._custom_actions_confirmation %}
|
|
||||||
{% with confirmation_message = model_view._custom_actions_confirmation[custom_action], custom_action=custom_action,
|
|
||||||
url=model_view._url_for_action(request, custom_action) + '?pks=' + (get_object_identifier(model) | string) %}
|
|
||||||
{% include 'sqladmin/modals/details_action_confirmation.html' %}
|
|
||||||
{% endwith %}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,149 +0,0 @@
|
||||||
{% extends "sqladmin/layout.html" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<!-- 요약 카드 -->
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="row row-deck row-cards mb-4">
|
|
||||||
<div class="col-sm-6 col-lg-3">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="subheader">대기 중인 요청</div>
|
|
||||||
<div class="h1 mb-3 text-warning">{{ stats.pending_charge_requests }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-6 col-lg-3">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="subheader">오늘 승인한 요청</div>
|
|
||||||
<div class="h1 mb-3 text-success">{{ stats.today_charge }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-6 col-lg-3">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="subheader">오늘 소모 크레딧</div>
|
|
||||||
<div class="h1 mb-3 text-danger">{{ stats.today_consume }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-6 col-lg-3">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="subheader">이번 달 소모 크레딧</div>
|
|
||||||
<div class="h1 mb-3 text-danger">{{ stats.month_consume }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 대기 중인 요청 -->
|
|
||||||
<div class="col-12 col-lg-6">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h3 class="card-title">대기 중인 요청</h3>
|
|
||||||
<div class="card-options">
|
|
||||||
<a href="{{ request.url_for('admin:list', identity='credit-charge-request') }}" class="btn btn-sm btn-primary">전체 보기</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-vcenter card-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>사용자 UUID</th>
|
|
||||||
<th>요청 크레딧</th>
|
|
||||||
<th>요청일시</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for req in pending_requests %}
|
|
||||||
<tr>
|
|
||||||
<td class="text-truncate" style="max-width:150px;">{{ req.user_uuid }}</td>
|
|
||||||
<td>{{ req.requested_amount }}</td>
|
|
||||||
<td>{{ req.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
|
||||||
</tr>
|
|
||||||
{% else %}
|
|
||||||
<tr><td colspan="3" class="text-center text-muted">대기 중인 요청 없음</td></tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 최근 크레딧 변화 -->
|
|
||||||
<div class="col-12 col-lg-6">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h3 class="card-title">최근 크레딧 변화</h3>
|
|
||||||
<div class="card-options">
|
|
||||||
<a href="{{ request.url_for('admin:list', identity='credit-transaction') }}" class="btn btn-sm btn-primary">전체 보기</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-vcenter card-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>사용자 UUID</th>
|
|
||||||
<th>유형</th>
|
|
||||||
<th>변경</th>
|
|
||||||
<th>잔액</th>
|
|
||||||
<th>일시</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for tx in recent_transactions %}
|
|
||||||
<tr>
|
|
||||||
<td class="text-truncate" style="max-width:120px;">{{ tx.user_uuid }}</td>
|
|
||||||
<td>{{ tx.type }}</td>
|
|
||||||
<td class="{{ 'text-success' if tx.amount > 0 else 'text-danger' }}">{{ '%+d' % tx.amount }}</td>
|
|
||||||
<td>{{ tx.balance_after }}</td>
|
|
||||||
<td>{{ tx.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
|
||||||
</tr>
|
|
||||||
{% else %}
|
|
||||||
<tr><td colspan="5" class="text-center text-muted">이력 없음</td></tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 최근 가입 사용자 -->
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h3 class="card-title">최근 가입 사용자</h3>
|
|
||||||
<div class="card-options">
|
|
||||||
<a href="{{ request.url_for('admin:list', identity='user') }}" class="btn btn-sm btn-primary">전체 보기</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-vcenter card-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>닉네임</th>
|
|
||||||
<th>이메일</th>
|
|
||||||
<th>크레딧</th>
|
|
||||||
<th>권한</th>
|
|
||||||
<th>가입일시</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for user in recent_users %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ user.nickname or '-' }}</td>
|
|
||||||
<td>{{ user.email or '-' }}</td>
|
|
||||||
<td>{{ user.credits }}</td>
|
|
||||||
<td>{{ user.role }}</td>
|
|
||||||
<td>{{ user.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
{% extends "sqladmin/base.html" %}
|
|
||||||
{% from 'sqladmin/_macros.html' import display_menu %}
|
|
||||||
{% block body %}
|
|
||||||
<div class="wrapper">
|
|
||||||
<aside class="navbar navbar-expand-lg navbar-vertical navbar-expand-md navbar-dark">
|
|
||||||
<div class="container-fluid">
|
|
||||||
<h1 class="navbar-brand navbar-brand-autodark">
|
|
||||||
<a href="{{ url_for('admin:index') }}">
|
|
||||||
{% if admin.logo_url %}
|
|
||||||
<img src="{{ admin.logo_url }}" width="64" height="64" alt="Admin" class="navbar-brand-image" />
|
|
||||||
{% else %}
|
|
||||||
<h3>{{ admin.title }}</h3>
|
|
||||||
{% endif %}
|
|
||||||
</a>
|
|
||||||
</h1>
|
|
||||||
<nav class="navbar navbar-expand-sm" id="navbar-menu">
|
|
||||||
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent"
|
|
||||||
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
|
||||||
{{ display_menu(admin._menu, request) }}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
{% if admin.authentication_backend %}
|
|
||||||
<div class="mb-2 text-center text-white">
|
|
||||||
<div class="fw-bold">{{ request.session.get('admin_name', '') }}</div>
|
|
||||||
<small>
|
|
||||||
{% if request.session.get('admin_role') == 'superadmin' %}
|
|
||||||
<span class="badge bg-danger">전체 관리자</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge bg-warning text-dark">일반 관리자</span>
|
|
||||||
{% endif %}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
<a href="{{ request.url_for('admin:logout') }}" class="btn btn-secondary btn-icon">
|
|
||||||
<i class="fa fa-sign-out"></i>
|
|
||||||
<span>Logout</span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
<div class="page-wrapper">
|
|
||||||
<div class="container-fluid">
|
|
||||||
<div class="page-header d-print-none">
|
|
||||||
{% block content_header %}
|
|
||||||
<div class="row align-items-center">
|
|
||||||
<div class="col">
|
|
||||||
<h2 class="page-title">{{ title }}</h2>
|
|
||||||
<div class="page-pretitle">{{ subtitle }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="page-body flex-grow-1">
|
|
||||||
<div class="container-fluid">
|
|
||||||
<div class="row row-deck row-cards">
|
|
||||||
{% block content %} {% endblock %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,299 +0,0 @@
|
||||||
{% extends "sqladmin/layout.html" %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="container-fluid">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="d-flex">
|
|
||||||
<div class="flex-grow-1 me-2">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h3 class="card-title">{{ model_view.name_plural }}</h3>
|
|
||||||
<div class="ms-auto">
|
|
||||||
{% if model_view.can_export %}
|
|
||||||
{% if model_view.export_types | length > 1 %}
|
|
||||||
<div class="ms-3 d-inline-block dropdown">
|
|
||||||
<a href="#" class="btn btn-secondary dropdown-toggle" id="dropdownMenuButton1" data-bs-toggle="dropdown"
|
|
||||||
aria-expanded="false">
|
|
||||||
Export
|
|
||||||
</a>
|
|
||||||
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton1">
|
|
||||||
{% for export_type in model_view.export_types %}
|
|
||||||
<li><a class="dropdown-item"
|
|
||||||
href="{{ url_for('admin:export', identity=model_view.identity, export_type=export_type) }}">{{
|
|
||||||
export_type | upper }}</a></li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% elif model_view.export_types | length == 1 %}
|
|
||||||
<div class="ms-3 d-inline-block">
|
|
||||||
<a href="{{ url_for('admin:export', identity=model_view.identity, export_type=model_view.export_types[0]) }}"
|
|
||||||
class="btn btn-secondary">
|
|
||||||
Export
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
{% if model_view.can_create %}
|
|
||||||
<div class="ms-3 d-inline-block">
|
|
||||||
<a href="{{ url_for('admin:create', identity=model_view.identity) }}" class="btn btn-primary">
|
|
||||||
+ New {{ model_view.name }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-body border-bottom py-3">
|
|
||||||
<div class="d-flex justify-content-between">
|
|
||||||
<div class="dropdown col-4">
|
|
||||||
<button {% if not model_view.can_delete and not model_view._custom_actions_in_list %} disabled {% endif %}
|
|
||||||
class="btn btn-light dropdown-toggle" type="button" id="dropdownMenuButton" data-toggle="dropdown"
|
|
||||||
aria-haspopup="true" aria-expanded="false">
|
|
||||||
Actions
|
|
||||||
</button>
|
|
||||||
{% if model_view.can_delete or model_view._custom_actions_in_list %}
|
|
||||||
<div class="dropdown-menu" aria-labelledby="dropdownMenuButton">
|
|
||||||
{% if model_view.can_delete and request.session.get('admin_role') == 'superadmin' %}
|
|
||||||
<a class="dropdown-item" id="action-delete" href="#" data-name="{{ model_view.name }}"
|
|
||||||
data-url="{{ url_for('admin:delete', identity=model_view.identity) }}" data-bs-toggle="modal"
|
|
||||||
data-bs-target="#modal-delete">Delete selected items</a>
|
|
||||||
{% endif %}
|
|
||||||
{% for custom_action, label in model_view._custom_actions_in_list.items() %}
|
|
||||||
{% if custom_action in model_view._custom_actions_confirmation %}
|
|
||||||
<a class="dropdown-item" id="action-customconfirm-{{ custom_action }}" href="#" data-bs-toggle="modal"
|
|
||||||
data-bs-target="#modal-confirmation-{{ custom_action }}">
|
|
||||||
{{ label }}
|
|
||||||
</a>
|
|
||||||
{% else %}
|
|
||||||
<a class="dropdown-item" id="action-custom-{{ custom_action }}" href="#"
|
|
||||||
data-url="{{ model_view._url_for_action(request, custom_action) }}">
|
|
||||||
{{ label }}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% if model_view.column_searchable_list %}
|
|
||||||
<div class="col-md-4 text-muted">
|
|
||||||
<div class="input-group">
|
|
||||||
<input id="search-input" type="text" class="form-control"
|
|
||||||
placeholder="Search: {{ model_view.search_placeholder() }}"
|
|
||||||
value="{{ request.query_params.get('search', '') }}">
|
|
||||||
<button id="search-button" class="btn" type="button">Search</button>
|
|
||||||
<button id="search-reset" class="btn" type="button" {% if not request.query_params.get('search')
|
|
||||||
%}disabled{% endif %}><i class="fa-solid fa-times"></i></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table card-table table-vcenter text-nowrap">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="w-1"><input class="form-check-input m-0 align-middle" type="checkbox" aria-label="Select all"
|
|
||||||
id="select-all"></th>
|
|
||||||
<th class="w-1"></th>
|
|
||||||
{% for name in model_view._list_prop_names %}
|
|
||||||
{% set label = model_view._column_labels.get(name, name) %}
|
|
||||||
<th>
|
|
||||||
{% if name in model_view._sort_fields %}
|
|
||||||
{% if request.query_params.get("sortBy") == name and request.query_params.get("sort") == "asc" %}
|
|
||||||
<a href="{{ request.url.include_query_params(sort='desc') }}"><i class="fa-solid fa-arrow-up"></i> {{
|
|
||||||
label }}</a>
|
|
||||||
{% elif request.query_params.get("sortBy") == name and request.query_params.get("sort") == "desc" %}
|
|
||||||
<a href="{{ request.url.include_query_params(sort='asc') }}"><i class="fa-solid fa-arrow-down"></i> {{ label
|
|
||||||
}}</a>
|
|
||||||
{% else %}
|
|
||||||
<a href="{{ request.url.include_query_params(sortBy=name, sort='asc') }}">{{ label }}</a>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
{{ label }}
|
|
||||||
{% endif %}
|
|
||||||
</th>
|
|
||||||
{% endfor %}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for row in pagination.rows %}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<input type="hidden" value="{{ get_object_identifier(row) }}">
|
|
||||||
<input class="form-check-input m-0 align-middle select-box" type="checkbox" aria-label="Select item">
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
{% if model_view.can_view_details %}
|
|
||||||
<a href="{{ model_view._build_url_for('admin:details', request, row) }}" data-bs-toggle="tooltip"
|
|
||||||
data-bs-placement="top" title="View">
|
|
||||||
<span class="me-1"><i class="fa-solid fa-eye"></i></span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if model_view.can_edit and request.session.get('admin_role') == 'superadmin' %}
|
|
||||||
<a href="{{ model_view._build_url_for('admin:edit', request, row) }}" data-bs-toggle="tooltip"
|
|
||||||
data-bs-placement="top" title="Edit">
|
|
||||||
<span class="me-1"><i class="fa-solid fa-pen-to-square"></i></span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if model_view.can_delete and request.session.get('admin_role') == 'superadmin' %}
|
|
||||||
<a href="#" data-name="{{ model_view.name }}" data-pk="{{ get_object_identifier(row) }}"
|
|
||||||
data-url="{{ model_view._url_for_delete(request, row) }}" data-bs-toggle="modal"
|
|
||||||
data-bs-target="#modal-delete" title="Delete">
|
|
||||||
<span class="me-1"><i class="fa-solid fa-trash"></i></span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
{% for name in model_view._list_prop_names %}
|
|
||||||
{% set value, formatted_value = model_view.get_list_value(row, name) %}
|
|
||||||
{% if name in model_view._relation_names %}
|
|
||||||
{% if is_list( value ) %}
|
|
||||||
<td>
|
|
||||||
{% for elem, formatted_elem in zip(value, formatted_value) %}
|
|
||||||
{% if model_view.show_compact_lists %}
|
|
||||||
<a href="{{ model_view._build_url_for('admin:details', request, elem) }}">({{ formatted_elem }})</a>
|
|
||||||
{% else %}
|
|
||||||
<a href="{{ model_view._build_url_for('admin:details', request, elem) }}">{{ formatted_elem }}</a><br/>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</td>
|
|
||||||
{% else %}
|
|
||||||
<td><a href="{{ model_view._url_for_details_with_prop(request, row, name) }}">{{ formatted_value }}</a></td>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
<td>{{ formatted_value }}</td>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer d-flex justify-content-between align-items-center gap-2">
|
|
||||||
<p class="m-0 text-muted">Showing <span>{{ ((pagination.page - 1) * pagination.page_size) + 1 }}</span> to
|
|
||||||
<span>{{ min(pagination.page * pagination.page_size, pagination.count) }}</span> of <span>{{ pagination.count
|
|
||||||
}}</span> items
|
|
||||||
</p>
|
|
||||||
<ul class="pagination m-0 ms-auto">
|
|
||||||
<li class="page-item {% if not pagination.has_previous %}disabled{% endif %}">
|
|
||||||
{% if pagination.has_previous %}
|
|
||||||
<a class="page-link" href="{{ pagination.previous_page.url }}">
|
|
||||||
{% else %}
|
|
||||||
<a class="page-link" href="#">
|
|
||||||
{% endif %}
|
|
||||||
<i class="fa-solid fa-chevron-left"></i>
|
|
||||||
prev
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% for page_control in pagination.page_controls %}
|
|
||||||
<li class="page-item {% if page_control.number == pagination.page %}active{% endif %}"><a class="page-link"
|
|
||||||
href="{{ page_control.url }}">{{ page_control.number }}</a></li>
|
|
||||||
{% endfor %}
|
|
||||||
<li class="page-item {% if not pagination.has_next %}disabled{% endif %}">
|
|
||||||
{% if pagination.has_next %}
|
|
||||||
<a class="page-link" href="{{ pagination.next_page.url }}">
|
|
||||||
{% else %}
|
|
||||||
<a class="page-link" href="#">
|
|
||||||
{% endif %}
|
|
||||||
next
|
|
||||||
<i class="fa-solid fa-chevron-right"></i>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div class="dropdown text-muted">
|
|
||||||
Show
|
|
||||||
<a href="#" class="btn btn-sm btn-light dropdown-toggle" data-toggle="dropdown" aria-haspopup="true"
|
|
||||||
aria-expanded="false">
|
|
||||||
{{ request.query_params.get("pageSize") or model_view.page_size }} / Page
|
|
||||||
</a>
|
|
||||||
<div class="dropdown-menu">
|
|
||||||
{% for page_size_option in model_view.page_size_options %}
|
|
||||||
<a class="dropdown-item" href="{{ request.url.include_query_params(pageSize=page_size_option, page=pagination.resize(page_size_option).page) }}">
|
|
||||||
{{ page_size_option }} / Page
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% if model_view.get_filters() %}
|
|
||||||
<div class="col-md-3" style="width: 300px; flex-shrink: 0;">
|
|
||||||
<div id="filter-sidebar" class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h3 class="card-title">Filters</h3>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
{% for filter in model_view.get_filters() %}
|
|
||||||
{% if filter.has_operator %}
|
|
||||||
<div class="mb-3">
|
|
||||||
<div class="fw-bold text-truncate">{{ filter.title }}</div>
|
|
||||||
<div>
|
|
||||||
<!-- Show current filter if active -->
|
|
||||||
{% set current_filter = request.query_params.get(filter.parameter_name, '') %}
|
|
||||||
{% set current_op = request.query_params.get(filter.parameter_name + '_op', '') %}
|
|
||||||
{% if current_filter %}
|
|
||||||
<div class="mb-2 text-muted small">
|
|
||||||
Current: {{ current_op }} {{ current_filter }}
|
|
||||||
<a href="{{ request.url.remove_query_params(filter.parameter_name).remove_query_params(filter.parameter_name + '_op') }}" class="text-decoration-none">[Clear]</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<!-- Single form with dropdown for operations -->
|
|
||||||
<form method="get" class="d-flex flex-column" style="gap: 8px;">
|
|
||||||
<!-- Preserve existing query parameters -->
|
|
||||||
{% for key, value in request.query_params.items() %}
|
|
||||||
{% if key != filter.parameter_name and key != filter.parameter_name + '_op' %}
|
|
||||||
<input type="hidden" name="{{ key }}" value="{{ value }}">
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
<!-- Operation dropdown -->
|
|
||||||
<select name="{{ filter.parameter_name }}_op" class="form-select form-select-sm" required>
|
|
||||||
<option value="">Select operation...</option>
|
|
||||||
{% for op_value, op_label in filter.get_operation_options_for_model(model_view.model) %}
|
|
||||||
<option value="{{ op_value }}" {% if current_op == op_value %}selected{% endif %}>{{ op_label }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<!-- Value input -->
|
|
||||||
<input type="text"
|
|
||||||
name="{{ filter.parameter_name }}"
|
|
||||||
placeholder="Enter value"
|
|
||||||
class="form-control form-control-sm"
|
|
||||||
value="{{ current_filter }}"
|
|
||||||
required>
|
|
||||||
<button type="submit" class="btn btn-sm btn-outline-primary">Apply Filter</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<!-- Fallback for other filter types -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<div class="fw-bold text-truncate">{{ filter.title }}</div>
|
|
||||||
<div>
|
|
||||||
{% for lookup in filter.lookups(request, model_view.model, model_view._run_arbitrary_query) %}
|
|
||||||
<a href="{{ request.url.include_query_params(**{filter.parameter_name: lookup[0]}) }}" class="d-block text-decoration-none text-truncate">
|
|
||||||
{{ lookup[1] }}
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% if model_view.can_delete %}
|
|
||||||
{% include 'sqladmin/modals/delete.html' %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% for custom_action in model_view._custom_actions_in_list %}
|
|
||||||
{% if custom_action in model_view._custom_actions_confirmation %}
|
|
||||||
{% with confirmation_message = model_view._custom_actions_confirmation[custom_action], custom_action=custom_action,
|
|
||||||
url=model_view._url_for_action(request, custom_action) %}
|
|
||||||
{% include 'sqladmin/modals/list_action_confirmation.html' %}
|
|
||||||
{% endwith %}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
from starlette.requests import Request
|
|
||||||
|
|
||||||
|
|
||||||
class SuperAdminOnly:
|
|
||||||
"""superadmin만 접근 가능 (편집/삭제/액션 모두 허용)"""
|
|
||||||
|
|
||||||
def is_accessible(self, request: Request) -> bool:
|
|
||||||
return request.session.get("admin_role") == "superadmin"
|
|
||||||
|
|
||||||
|
|
||||||
class ViewerReadOnly:
|
|
||||||
"""viewer만 접근 가능한 읽기 전용 뷰"""
|
|
||||||
|
|
||||||
can_create = False
|
|
||||||
can_edit = False
|
|
||||||
can_delete = False
|
|
||||||
|
|
||||||
def is_accessible(self, request: Request) -> bool:
|
|
||||||
return request.session.get("admin_role") == "viewer"
|
|
||||||
|
|
||||||
|
|
||||||
class ViewerAccessible:
|
|
||||||
"""superadmin + viewer 접근 가능, 읽기 전용"""
|
|
||||||
|
|
||||||
can_create = False
|
|
||||||
can_edit = False
|
|
||||||
can_delete = False
|
|
||||||
|
|
||||||
def is_accessible(self, request: Request) -> bool:
|
|
||||||
return request.session.get("admin_role") in ("superadmin", "viewer")
|
|
||||||
|
|
||||||
|
|
||||||
class SuperAdminEditable:
|
|
||||||
"""superadmin + viewer 접근 가능, superadmin만 편집"""
|
|
||||||
|
|
||||||
can_create = False
|
|
||||||
can_edit = False
|
|
||||||
can_delete = False
|
|
||||||
|
|
||||||
def is_accessible(self, request: Request) -> bool:
|
|
||||||
return request.session.get("admin_role") in ("superadmin", "viewer")
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -1,176 +0,0 @@
|
||||||
"""
|
|
||||||
Comment API Router
|
|
||||||
|
|
||||||
영상 댓글 관련 엔드포인트를 제공합니다.
|
|
||||||
|
|
||||||
엔드포인트 목록:
|
|
||||||
- POST /comment/video/{video_id}: 댓글/대댓글 작성 (로그인 필수)
|
|
||||||
- GET /comment/video/{video_id}: 댓글 목록 조회 (비로그인 허용)
|
|
||||||
- DELETE /comment/{comment_id}: 본인 댓글 소프트 삭제 (로그인 필수)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.comment.schemas.comment_schema import (
|
|
||||||
CommentCreateRequest,
|
|
||||||
CommentCreateResponse,
|
|
||||||
CommentItem,
|
|
||||||
DeleteCommentResponse,
|
|
||||||
)
|
|
||||||
from app.comment.services.comment import create_comment, delete_comment, list_comments
|
|
||||||
from app.database.session import get_session
|
|
||||||
from app.dependencies.pagination import PaginationParams, get_pagination_params
|
|
||||||
from app.user.dependencies.auth import get_current_user, get_current_user_optional
|
|
||||||
from app.user.models import User
|
|
||||||
from app.utils.logger import get_logger
|
|
||||||
from app.utils.pagination import PaginatedResponse
|
|
||||||
|
|
||||||
logger = get_logger("comment")
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/comment", tags=["Comment"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/video/{video_id}",
|
|
||||||
summary="댓글/대댓글 작성",
|
|
||||||
description="""
|
|
||||||
## 개요
|
|
||||||
영상에 댓글 또는 대댓글을 작성합니다. 로그인 필수.
|
|
||||||
|
|
||||||
## 경로 파라미터
|
|
||||||
- **video_id**: 댓글을 달 영상의 ID
|
|
||||||
|
|
||||||
## 요청 본문
|
|
||||||
- **content**: 댓글 본문 (1~100자)
|
|
||||||
- **parent_id**: 대댓글일 때만 부모 댓글 id (생략 시 최상위 댓글)
|
|
||||||
|
|
||||||
## 참고
|
|
||||||
- 작성자 정보는 응답에 포함되지 않습니다 (익명 정책).
|
|
||||||
- 대댓글에 또 대댓글을 다는 것은 불가합니다 (최대 2-depth).
|
|
||||||
""",
|
|
||||||
response_model=CommentCreateResponse,
|
|
||||||
responses={
|
|
||||||
200: {"description": "댓글 작성 성공"},
|
|
||||||
400: {"description": "잘못된 parent_id (2-depth 초과, 다른 영상의 댓글 등)"},
|
|
||||||
401: {"description": "인증 실패"},
|
|
||||||
404: {"description": "영상을 찾을 수 없음"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def post_comment(
|
|
||||||
video_id: int,
|
|
||||||
body: CommentCreateRequest,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> CommentCreateResponse:
|
|
||||||
logger.info(
|
|
||||||
f"[post_comment] START - video_id: {video_id}, user: {current_user.user_uuid}, "
|
|
||||||
f"parent_id: {body.parent_id}"
|
|
||||||
)
|
|
||||||
comment = await create_comment(
|
|
||||||
session=session,
|
|
||||||
video_id=video_id,
|
|
||||||
user_uuid=current_user.user_uuid,
|
|
||||||
nickname=body.nickname,
|
|
||||||
content=body.content,
|
|
||||||
parent_id=body.parent_id,
|
|
||||||
)
|
|
||||||
logger.info(f"[post_comment] SUCCESS - comment_id: {comment.id}")
|
|
||||||
return CommentCreateResponse(
|
|
||||||
id=comment.id,
|
|
||||||
nickname=comment.nickname or "익명",
|
|
||||||
parent_id=comment.parent_id,
|
|
||||||
content=comment.content,
|
|
||||||
created_at=comment.created_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/video/{video_id}",
|
|
||||||
summary="댓글 목록 조회",
|
|
||||||
description="""
|
|
||||||
## 개요
|
|
||||||
영상의 댓글 목록을 페이지네이션하여 반환합니다. 비로그인도 접근 가능.
|
|
||||||
|
|
||||||
## 경로 파라미터
|
|
||||||
- **video_id**: 댓글을 조회할 영상의 ID
|
|
||||||
|
|
||||||
## 쿼리 파라미터
|
|
||||||
- **page**: 페이지 번호 (기본값: 1)
|
|
||||||
- **page_size**: 페이지당 댓글 수 (기본값: 10, 최대: 100)
|
|
||||||
|
|
||||||
## 참고
|
|
||||||
- 최상위 댓글만 페이지네이션됩니다. 각 댓글의 대댓글은 전부 포함됩니다.
|
|
||||||
- 작성자 정보는 노출되지 않으며, is_mine으로 본인 댓글 여부만 확인 가능합니다.
|
|
||||||
- 삭제된 댓글은 content=null로 노출됩니다 (대댓글이 있는 경우).
|
|
||||||
""",
|
|
||||||
response_model=PaginatedResponse[CommentItem],
|
|
||||||
responses={
|
|
||||||
200: {"description": "댓글 목록 조회 성공"},
|
|
||||||
500: {"description": "조회 실패"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def get_comments(
|
|
||||||
video_id: int,
|
|
||||||
current_user: User | None = Depends(get_current_user_optional),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
pagination: PaginationParams = Depends(get_pagination_params),
|
|
||||||
) -> PaginatedResponse[CommentItem]:
|
|
||||||
logger.info(
|
|
||||||
f"[get_comments] START - video_id: {video_id}, "
|
|
||||||
f"page: {pagination.page}, page_size: {pagination.page_size}"
|
|
||||||
)
|
|
||||||
current_user_uuid = current_user.user_uuid if current_user else None
|
|
||||||
result = await list_comments(
|
|
||||||
session=session,
|
|
||||||
video_id=video_id,
|
|
||||||
page=pagination.page,
|
|
||||||
page_size=pagination.page_size,
|
|
||||||
current_user_uuid=current_user_uuid,
|
|
||||||
)
|
|
||||||
logger.info(f"[get_comments] SUCCESS - total: {result.total}, items: {len(result.items)}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
|
||||||
"/{comment_id}",
|
|
||||||
summary="댓글 소프트 삭제",
|
|
||||||
description="""
|
|
||||||
## 개요
|
|
||||||
본인이 작성한 댓글을 소프트 삭제합니다. 로그인 필수.
|
|
||||||
|
|
||||||
## 경로 파라미터
|
|
||||||
- **comment_id**: 삭제할 댓글의 ID
|
|
||||||
|
|
||||||
## 참고
|
|
||||||
- 본인 댓글만 삭제 가능합니다.
|
|
||||||
- 소프트 삭제 방식으로 DB에 데이터는 유지됩니다.
|
|
||||||
- 부모 댓글 삭제 시 대댓글은 유지되며, 목록 조회 시 content=null로 표시됩니다.
|
|
||||||
""",
|
|
||||||
response_model=DeleteCommentResponse,
|
|
||||||
responses={
|
|
||||||
200: {"description": "삭제 성공"},
|
|
||||||
401: {"description": "인증 실패"},
|
|
||||||
403: {"description": "삭제 권한 없음"},
|
|
||||||
404: {"description": "댓글을 찾을 수 없음"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def remove_comment(
|
|
||||||
comment_id: int,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> DeleteCommentResponse:
|
|
||||||
logger.info(
|
|
||||||
f"[remove_comment] START - comment_id: {comment_id}, user: {current_user.user_uuid}"
|
|
||||||
)
|
|
||||||
await delete_comment(
|
|
||||||
session=session,
|
|
||||||
comment_id=comment_id,
|
|
||||||
current_user_uuid=current_user.user_uuid,
|
|
||||||
)
|
|
||||||
logger.info(f"[remove_comment] SUCCESS - comment_id: {comment_id}")
|
|
||||||
return DeleteCommentResponse(
|
|
||||||
success=True,
|
|
||||||
comment_id=comment_id,
|
|
||||||
message="댓글이 삭제되었습니다.",
|
|
||||||
)
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
from datetime import datetime
|
|
||||||
from typing import TYPE_CHECKING, List, Optional
|
|
||||||
|
|
||||||
from sqlalchemy import Boolean, 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.user.models import User
|
|
||||||
from app.video.models import Video
|
|
||||||
|
|
||||||
|
|
||||||
class Comment(Base):
|
|
||||||
"""
|
|
||||||
영상 댓글 테이블
|
|
||||||
|
|
||||||
2-depth 구조 (최상위 댓글 + 대댓글 1단계).
|
|
||||||
parent_id가 NULL이면 최상위 댓글, 값이 있으면 대댓글.
|
|
||||||
작성자(user_uuid)는 DB에 저장하지만 API 응답에는 미노출 (익명 정책).
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "comment"
|
|
||||||
__table_args__ = (
|
|
||||||
Index("idx_comment_video_id", "video_id"),
|
|
||||||
Index("idx_comment_user_uuid", "user_uuid"),
|
|
||||||
Index("idx_comment_parent_id", "parent_id"),
|
|
||||||
Index("idx_comment_is_deleted", "is_deleted"),
|
|
||||||
{
|
|
||||||
"mysql_engine": "InnoDB",
|
|
||||||
"mysql_charset": "utf8mb4",
|
|
||||||
"mysql_collate": "utf8mb4_unicode_ci",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(
|
|
||||||
Integer, primary_key=True, autoincrement=True, comment="고유 식별자"
|
|
||||||
)
|
|
||||||
video_id: Mapped[int] = mapped_column(
|
|
||||||
Integer,
|
|
||||||
ForeignKey("video.id", ondelete="CASCADE"),
|
|
||||||
nullable=False,
|
|
||||||
comment="연결된 Video의 id",
|
|
||||||
)
|
|
||||||
user_uuid: Mapped[str] = mapped_column(
|
|
||||||
ForeignKey("user.user_uuid", ondelete="CASCADE"),
|
|
||||||
nullable=False,
|
|
||||||
comment="작성자 UUID (응답 미노출, 권한 검증용)",
|
|
||||||
)
|
|
||||||
parent_id: Mapped[Optional[int]] = mapped_column(
|
|
||||||
Integer,
|
|
||||||
ForeignKey("comment.id", ondelete="CASCADE"),
|
|
||||||
nullable=True,
|
|
||||||
comment="NULL=최상위 댓글, 값=대댓글의 부모 id",
|
|
||||||
)
|
|
||||||
nickname: Mapped[Optional[str]] = mapped_column(
|
|
||||||
String(50), nullable=True, comment="댓글 작성자 닉네임 (null이면 익명)"
|
|
||||||
)
|
|
||||||
content: Mapped[str] = mapped_column(
|
|
||||||
String(100), nullable=False, comment="댓글 본문 (한글 기준 100자 이내)"
|
|
||||||
)
|
|
||||||
is_deleted: Mapped[bool] = mapped_column(
|
|
||||||
Boolean, nullable=False, default=False, comment="소프트 삭제 여부"
|
|
||||||
)
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime,
|
|
||||||
nullable=False,
|
|
||||||
server_default=func.now(),
|
|
||||||
comment="작성 일시",
|
|
||||||
)
|
|
||||||
|
|
||||||
video: Mapped["Video"] = relationship("Video", back_populates="comments")
|
|
||||||
user: Mapped["User"] = relationship("User", back_populates="comments")
|
|
||||||
parent: Mapped[Optional["Comment"]] = relationship(
|
|
||||||
"Comment", remote_side=[id], back_populates="replies"
|
|
||||||
)
|
|
||||||
replies: Mapped[List["Comment"]] = relationship(
|
|
||||||
"Comment",
|
|
||||||
back_populates="parent",
|
|
||||||
cascade="all, delete-orphan",
|
|
||||||
)
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
from datetime import datetime
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class CommentCreateRequest(BaseModel):
|
|
||||||
nickname: Optional[str] = Field(None, min_length=1, max_length=50, description="작성자 닉네임 (미입력 시 익명)")
|
|
||||||
content: str = Field(..., min_length=1, max_length=100, description="댓글 본문 (한글 기준 100자 이내)")
|
|
||||||
parent_id: Optional[int] = Field(None, description="대댓글일 때만 부모 댓글 id")
|
|
||||||
|
|
||||||
|
|
||||||
class ReplyItem(BaseModel):
|
|
||||||
"""대댓글 응답"""
|
|
||||||
|
|
||||||
id: int = Field(..., description="댓글 고유 ID")
|
|
||||||
nickname: str = Field(..., description="작성자 닉네임 (미입력 시 '익명')")
|
|
||||||
content: Optional[str] = Field(None, description="본문 (소프트 삭제된 경우 null)")
|
|
||||||
is_deleted: bool = Field(..., description="삭제 여부")
|
|
||||||
is_mine: bool = Field(..., description="현재 로그인 사용자의 댓글 여부")
|
|
||||||
created_at: datetime = Field(..., description="작성 일시")
|
|
||||||
|
|
||||||
|
|
||||||
class CommentItem(BaseModel):
|
|
||||||
"""최상위 댓글 응답 — replies 포함"""
|
|
||||||
|
|
||||||
id: int = Field(..., description="댓글 고유 ID")
|
|
||||||
nickname: str = Field(..., description="작성자 닉네임 (미입력 시 '익명')")
|
|
||||||
content: Optional[str] = Field(None, description="본문 (소프트 삭제된 경우 null)")
|
|
||||||
is_deleted: bool = Field(..., description="삭제 여부")
|
|
||||||
is_mine: bool = Field(..., description="현재 로그인 사용자의 댓글 여부")
|
|
||||||
created_at: datetime = Field(..., description="작성 일시")
|
|
||||||
replies: List[ReplyItem] = Field(default_factory=list, description="대댓글 목록")
|
|
||||||
|
|
||||||
|
|
||||||
class CommentCreateResponse(BaseModel):
|
|
||||||
id: int = Field(..., description="생성된 댓글 고유 ID")
|
|
||||||
nickname: str = Field(..., description="작성자 닉네임 (미입력 시 '익명')")
|
|
||||||
parent_id: Optional[int] = Field(None, description="부모 댓글 id (대댓글인 경우)")
|
|
||||||
content: str = Field(..., description="댓글 본문")
|
|
||||||
created_at: datetime = Field(..., description="작성 일시")
|
|
||||||
|
|
||||||
|
|
||||||
class DeleteCommentResponse(BaseModel):
|
|
||||||
success: bool = Field(..., description="삭제 성공 여부")
|
|
||||||
comment_id: int = Field(..., description="삭제된 댓글 ID")
|
|
||||||
message: str = Field(..., description="결과 메시지")
|
|
||||||
|
|
@ -1,189 +0,0 @@
|
||||||
from collections import defaultdict
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from fastapi import HTTPException
|
|
||||||
from sqlalchemy import exists, select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.comment.models import Comment
|
|
||||||
from app.comment.schemas.comment_schema import CommentItem, ReplyItem
|
|
||||||
from app.utils.pagination import PaginatedResponse
|
|
||||||
from app.video.models import Video
|
|
||||||
|
|
||||||
|
|
||||||
async def _validate_parent(
|
|
||||||
session: AsyncSession,
|
|
||||||
parent_id: int,
|
|
||||||
video_id: int,
|
|
||||||
) -> None:
|
|
||||||
"""2-depth 제한 + 동일 video 검증."""
|
|
||||||
result = await session.execute(
|
|
||||||
select(Comment).where(
|
|
||||||
Comment.id == parent_id,
|
|
||||||
Comment.is_deleted == False, # noqa: E712
|
|
||||||
)
|
|
||||||
)
|
|
||||||
parent = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if parent is None:
|
|
||||||
raise HTTPException(status_code=400, detail="부모 댓글을 찾을 수 없습니다.")
|
|
||||||
if parent.video_id != video_id:
|
|
||||||
raise HTTPException(status_code=400, detail="다른 영상의 댓글에는 대댓글을 달 수 없습니다.")
|
|
||||||
if parent.parent_id is not None:
|
|
||||||
raise HTTPException(status_code=400, detail="대댓글에는 대댓글을 달 수 없습니다. (최대 2-depth)")
|
|
||||||
|
|
||||||
|
|
||||||
def _build_comment_items(
|
|
||||||
parents: list,
|
|
||||||
replies_map: dict,
|
|
||||||
current_user_uuid: Optional[str],
|
|
||||||
) -> List[CommentItem]:
|
|
||||||
items = []
|
|
||||||
for c in parents:
|
|
||||||
raw_replies = replies_map.get(c.id, [])
|
|
||||||
replies = [
|
|
||||||
ReplyItem(
|
|
||||||
id=r.id,
|
|
||||||
nickname=r.nickname or "익명",
|
|
||||||
content=None if r.is_deleted else r.content,
|
|
||||||
is_deleted=r.is_deleted,
|
|
||||||
is_mine=(current_user_uuid == r.user_uuid) if current_user_uuid else False,
|
|
||||||
created_at=r.created_at,
|
|
||||||
)
|
|
||||||
for r in raw_replies
|
|
||||||
]
|
|
||||||
items.append(
|
|
||||||
CommentItem(
|
|
||||||
id=c.id,
|
|
||||||
nickname=c.nickname or "익명",
|
|
||||||
content=None if c.is_deleted else c.content,
|
|
||||||
is_deleted=c.is_deleted,
|
|
||||||
is_mine=(current_user_uuid == c.user_uuid) if current_user_uuid else False,
|
|
||||||
created_at=c.created_at,
|
|
||||||
replies=replies,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
async def create_comment(
|
|
||||||
session: AsyncSession,
|
|
||||||
video_id: int,
|
|
||||||
user_uuid: str,
|
|
||||||
nickname: str,
|
|
||||||
content: str,
|
|
||||||
parent_id: Optional[int],
|
|
||||||
) -> Comment:
|
|
||||||
# Video 존재 확인
|
|
||||||
video_result = await session.execute(
|
|
||||||
select(Video).where(
|
|
||||||
Video.id == video_id,
|
|
||||||
Video.status == "completed",
|
|
||||||
Video.is_deleted == False, # noqa: E712
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if video_result.scalar_one_or_none() is None:
|
|
||||||
raise HTTPException(status_code=404, detail="영상을 찾을 수 없습니다.")
|
|
||||||
|
|
||||||
# parent_id 검증
|
|
||||||
if parent_id is not None:
|
|
||||||
await _validate_parent(session, parent_id, video_id)
|
|
||||||
|
|
||||||
comment = Comment(
|
|
||||||
video_id=video_id,
|
|
||||||
user_uuid=user_uuid,
|
|
||||||
nickname=nickname,
|
|
||||||
parent_id=parent_id,
|
|
||||||
content=content,
|
|
||||||
)
|
|
||||||
session.add(comment)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(comment)
|
|
||||||
return comment
|
|
||||||
|
|
||||||
|
|
||||||
async def list_comments(
|
|
||||||
session: AsyncSession,
|
|
||||||
video_id: int,
|
|
||||||
page: int,
|
|
||||||
page_size: int,
|
|
||||||
current_user_uuid: Optional[str],
|
|
||||||
) -> PaginatedResponse[CommentItem]:
|
|
||||||
offset = (page - 1) * page_size
|
|
||||||
|
|
||||||
# 살아있는 자식이 있는지 확인하는 서브쿼리
|
|
||||||
has_live_reply = (
|
|
||||||
exists()
|
|
||||||
.where(
|
|
||||||
Comment.parent_id == Comment.id,
|
|
||||||
Comment.is_deleted == False, # noqa: E712
|
|
||||||
)
|
|
||||||
.correlate(Comment)
|
|
||||||
)
|
|
||||||
|
|
||||||
# 최상위 댓글 필터: 삭제 안 됐거나 살아있는 대댓글이 있는 것
|
|
||||||
parent_where = [
|
|
||||||
Comment.video_id == video_id,
|
|
||||||
Comment.parent_id.is_(None),
|
|
||||||
(Comment.is_deleted == False) | has_live_reply, # noqa: E712
|
|
||||||
]
|
|
||||||
|
|
||||||
from sqlalchemy import func
|
|
||||||
|
|
||||||
count_q = select(func.count(Comment.id)).where(*parent_where)
|
|
||||||
total = (await session.execute(count_q)).scalar() or 0
|
|
||||||
|
|
||||||
parents_q = (
|
|
||||||
select(Comment)
|
|
||||||
.where(*parent_where)
|
|
||||||
.order_by(Comment.created_at.desc())
|
|
||||||
.offset(offset)
|
|
||||||
.limit(page_size)
|
|
||||||
)
|
|
||||||
parents = (await session.execute(parents_q)).scalars().all()
|
|
||||||
|
|
||||||
replies_map: dict = defaultdict(list)
|
|
||||||
if parents:
|
|
||||||
parent_ids = [c.id for c in parents]
|
|
||||||
replies_q = (
|
|
||||||
select(Comment)
|
|
||||||
.where(
|
|
||||||
Comment.parent_id.in_(parent_ids),
|
|
||||||
Comment.is_deleted == False, # noqa: E712
|
|
||||||
)
|
|
||||||
.order_by(Comment.created_at.asc())
|
|
||||||
)
|
|
||||||
replies = (await session.execute(replies_q)).scalars().all()
|
|
||||||
for r in replies:
|
|
||||||
replies_map[r.parent_id].append(r)
|
|
||||||
|
|
||||||
items = _build_comment_items(list(parents), replies_map, current_user_uuid)
|
|
||||||
|
|
||||||
return PaginatedResponse.create(
|
|
||||||
items=items,
|
|
||||||
total=total,
|
|
||||||
page=page,
|
|
||||||
page_size=page_size,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_comment(
|
|
||||||
session: AsyncSession,
|
|
||||||
comment_id: int,
|
|
||||||
current_user_uuid: str,
|
|
||||||
) -> None:
|
|
||||||
result = await session.execute(
|
|
||||||
select(Comment).where(
|
|
||||||
Comment.id == comment_id,
|
|
||||||
Comment.is_deleted == False, # noqa: E712
|
|
||||||
)
|
|
||||||
)
|
|
||||||
comment = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if comment is None:
|
|
||||||
raise HTTPException(status_code=404, detail="댓글을 찾을 수 없습니다.")
|
|
||||||
if comment.user_uuid != current_user_uuid:
|
|
||||||
raise HTTPException(status_code=403, detail="삭제 권한이 없습니다.")
|
|
||||||
|
|
||||||
comment.is_deleted = True
|
|
||||||
await session.commit()
|
|
||||||
|
|
@ -51,9 +51,6 @@ async def lifespan(app: FastAPI):
|
||||||
await close_shared_client()
|
await close_shared_client()
|
||||||
await close_shared_blob_client()
|
await close_shared_blob_client()
|
||||||
|
|
||||||
from app.database.like_cache import close_like_cache
|
|
||||||
await close_like_cache()
|
|
||||||
|
|
||||||
# 데이터베이스 엔진 종료
|
# 데이터베이스 엔진 종료
|
||||||
from app.database.session import dispose_engine
|
from app.database.session import dispose_engine
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,162 +0,0 @@
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,243 +0,0 @@
|
||||||
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")>"
|
|
||||||
)
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,195 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,235 +0,0 @@
|
||||||
"""
|
|
||||||
좋아요 Redis 캐시 클라이언트
|
|
||||||
|
|
||||||
Write-Behind 패턴 적용:
|
|
||||||
- 토글 시 Redis를 즉시 업데이트하고 dirty SET에 표시
|
|
||||||
- 스케줄러가 1분마다 dirty 항목을 MySQL에 bulk write
|
|
||||||
|
|
||||||
Key 패턴:
|
|
||||||
- video:like:count:{video_id} INT — 좋아요 카운트
|
|
||||||
- video:like:users:{video_id} SET — 좋아요 누른 user_uuid 목록
|
|
||||||
- video:reaction:dirty SET — DB 동기화 대기 "{video_id}:{user_uuid}"
|
|
||||||
- video:reaction:dirty:processing SET — 플러시 중 임시 (크래시 복구용)
|
|
||||||
|
|
||||||
캐시 미스(Redis 재시작 등) 시 호출부에서 DB 조회 후 backfill_user_set() / set_like_count()로 복구합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import redis.asyncio as aioredis
|
|
||||||
|
|
||||||
from config import db_settings
|
|
||||||
|
|
||||||
_client: aioredis.Redis | None = None
|
|
||||||
|
|
||||||
# 원자적 토글 Lua 스크립트 — 동시 더블클릭 race condition 방지
|
|
||||||
_TOGGLE_LIKE_SCRIPT = """
|
|
||||||
local user_key = KEYS[1]
|
|
||||||
local count_key = KEYS[2]
|
|
||||||
local user_uuid = ARGV[1]
|
|
||||||
|
|
||||||
if redis.call('SISMEMBER', user_key, user_uuid) == 1 then
|
|
||||||
redis.call('SREM', user_key, user_uuid)
|
|
||||||
local c = tonumber(redis.call('DECR', count_key))
|
|
||||||
if c < 0 then
|
|
||||||
redis.call('SET', count_key, 0)
|
|
||||||
c = 0
|
|
||||||
end
|
|
||||||
return {0, c}
|
|
||||||
else
|
|
||||||
redis.call('SADD', user_key, user_uuid)
|
|
||||||
local c = tonumber(redis.call('INCR', count_key))
|
|
||||||
return {1, c}
|
|
||||||
end
|
|
||||||
"""
|
|
||||||
|
|
||||||
_DIRTY_KEY = "video:reaction:dirty"
|
|
||||||
_DIRTY_PROCESSING_KEY = "video:reaction:dirty:processing"
|
|
||||||
|
|
||||||
|
|
||||||
def get_like_cache() -> aioredis.Redis:
|
|
||||||
global _client
|
|
||||||
if _client is None:
|
|
||||||
_client = aioredis.Redis(
|
|
||||||
host=db_settings.REDIS_HOST,
|
|
||||||
port=db_settings.REDIS_PORT,
|
|
||||||
db=2,
|
|
||||||
decode_responses=True,
|
|
||||||
)
|
|
||||||
return _client
|
|
||||||
|
|
||||||
|
|
||||||
async def close_like_cache() -> None:
|
|
||||||
global _client
|
|
||||||
if _client:
|
|
||||||
await _client.aclose()
|
|
||||||
_client = None
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
# Key 헬퍼
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _key(video_id: int) -> str:
|
|
||||||
return f"video:like:count:{video_id}"
|
|
||||||
|
|
||||||
|
|
||||||
def _user_key(video_id: int) -> str:
|
|
||||||
return f"video:like:users:{video_id}"
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
# 카운트 (기존 API 유지)
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def get_like_count(video_id: int) -> int | None:
|
|
||||||
"""Redis에서 like_count 조회. 캐시 미스 시 None 반환."""
|
|
||||||
val = await get_like_cache().get(_key(video_id))
|
|
||||||
if val is None:
|
|
||||||
return None
|
|
||||||
return max(int(val), 0)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_like_counts(video_ids: list[int]) -> dict[int, int | None]:
|
|
||||||
"""여러 영상의 like_count를 한 번에 조회 (mget).
|
|
||||||
캐시 미스인 video_id는 None으로 반환."""
|
|
||||||
if not video_ids:
|
|
||||||
return {}
|
|
||||||
keys = [_key(vid) for vid in video_ids]
|
|
||||||
values = await get_like_cache().mget(*keys)
|
|
||||||
return {
|
|
||||||
vid: max(int(v), 0) if v is not None else None
|
|
||||||
for vid, v in zip(video_ids, values)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def set_like_count(video_id: int, count: int) -> None:
|
|
||||||
"""like_count를 Redis에 저장 (음수 방지)."""
|
|
||||||
await get_like_cache().set(_key(video_id), max(count, 0))
|
|
||||||
|
|
||||||
|
|
||||||
async def mset_like_counts(counts: dict[int, int]) -> None:
|
|
||||||
"""여러 영상의 like_count를 한 번에 저장 (mset)."""
|
|
||||||
if not counts:
|
|
||||||
return
|
|
||||||
await get_like_cache().mset({_key(vid): max(cnt, 0) for vid, cnt in counts.items()})
|
|
||||||
|
|
||||||
|
|
||||||
async def incr_like_count(video_id: int) -> int:
|
|
||||||
"""like_count를 1 증가 후 반환."""
|
|
||||||
return max(int(await get_like_cache().incr(_key(video_id))), 0)
|
|
||||||
|
|
||||||
|
|
||||||
async def decr_like_count(video_id: int) -> int:
|
|
||||||
"""like_count를 1 감소 후 반환 (음수 방지)."""
|
|
||||||
count = int(await get_like_cache().decr(_key(video_id)))
|
|
||||||
if count < 0:
|
|
||||||
await get_like_cache().set(_key(video_id), 0)
|
|
||||||
return 0
|
|
||||||
return count
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
# 유저 SET (is_liked_by_me source of truth)
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def toggle_like_atomic(video_id: int, user_uuid: str) -> tuple[bool, int]:
|
|
||||||
"""Lua 스크립트로 원자적 좋아요 토글.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(is_liked, new_count) 튜플
|
|
||||||
"""
|
|
||||||
result = await get_like_cache().eval(
|
|
||||||
_TOGGLE_LIKE_SCRIPT,
|
|
||||||
2,
|
|
||||||
_user_key(video_id),
|
|
||||||
_key(video_id),
|
|
||||||
user_uuid,
|
|
||||||
)
|
|
||||||
return bool(result[0]), int(result[1])
|
|
||||||
|
|
||||||
|
|
||||||
async def is_user_liked(video_id: int, user_uuid: str) -> bool | None:
|
|
||||||
"""Redis user-set에서 좋아요 여부 조회.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True/False: 조회 성공
|
|
||||||
None: user-set 키가 없음 (cold-start backfill 필요 신호)
|
|
||||||
"""
|
|
||||||
client = get_like_cache()
|
|
||||||
key = _user_key(video_id)
|
|
||||||
if not await client.exists(key):
|
|
||||||
return None
|
|
||||||
return bool(await client.sismember(key, user_uuid))
|
|
||||||
|
|
||||||
|
|
||||||
async def is_user_set_exists(video_id: int) -> bool:
|
|
||||||
"""Redis user-set 키 존재 여부 확인."""
|
|
||||||
return bool(await get_like_cache().exists(_user_key(video_id)))
|
|
||||||
|
|
||||||
|
|
||||||
async def bulk_is_user_liked(
|
|
||||||
video_ids: list[int], user_uuid: str
|
|
||||||
) -> dict[int, bool | None]:
|
|
||||||
"""여러 영상의 is_liked 여부를 한 번에 조회 (pipeline).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{video_id: True/False} — user-set 키가 없는 영상은 None
|
|
||||||
"""
|
|
||||||
if not video_ids:
|
|
||||||
return {}
|
|
||||||
client = get_like_cache()
|
|
||||||
async with client.pipeline(transaction=False) as pipe:
|
|
||||||
for vid in video_ids:
|
|
||||||
pipe.exists(_user_key(vid))
|
|
||||||
pipe.sismember(_user_key(vid), user_uuid)
|
|
||||||
responses = await pipe.execute()
|
|
||||||
|
|
||||||
return {
|
|
||||||
vid: (bool(responses[i * 2 + 1]) if responses[i * 2] else None)
|
|
||||||
for i, vid in enumerate(video_ids)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def backfill_user_set(video_id: int, user_uuids: list[str]) -> None:
|
|
||||||
"""DB에서 가져온 유저 목록을 Redis SET에 일괄 적재."""
|
|
||||||
if user_uuids:
|
|
||||||
await get_like_cache().sadd(_user_key(video_id), *user_uuids)
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
# Dirty SET (Write-Behind 큐)
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def mark_dirty(video_id: int, user_uuid: str) -> None:
|
|
||||||
"""DB 동기화 대기 목록에 추가."""
|
|
||||||
await get_like_cache().sadd(_DIRTY_KEY, f"{video_id}:{user_uuid}")
|
|
||||||
|
|
||||||
|
|
||||||
async def drain_dirty() -> list[tuple[int, str]]:
|
|
||||||
"""dirty SET을 processing으로 RENAME 후 전체 반환.
|
|
||||||
|
|
||||||
이전 실행 중 크래시로 남은 processing 항목은 먼저 병합하여 유실 방지.
|
|
||||||
"""
|
|
||||||
client = get_like_cache()
|
|
||||||
|
|
||||||
# 이전 크래시 잔여 항목 병합
|
|
||||||
if await client.exists(_DIRTY_PROCESSING_KEY):
|
|
||||||
await client.sunionstore(_DIRTY_KEY, _DIRTY_KEY, _DIRTY_PROCESSING_KEY)
|
|
||||||
await client.delete(_DIRTY_PROCESSING_KEY)
|
|
||||||
|
|
||||||
if not await client.exists(_DIRTY_KEY):
|
|
||||||
return []
|
|
||||||
|
|
||||||
# RENAME으로 플러시 중 새로 들어오는 토글과 분리
|
|
||||||
await client.rename(_DIRTY_KEY, _DIRTY_PROCESSING_KEY)
|
|
||||||
members = await client.smembers(_DIRTY_PROCESSING_KEY)
|
|
||||||
|
|
||||||
result = []
|
|
||||||
for member in members:
|
|
||||||
vid_str, user_uuid = member.split(":", 1)
|
|
||||||
result.append((int(vid_str), user_uuid))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def commit_dirty_processing() -> None:
|
|
||||||
"""DB 반영 완료 후 processing SET 삭제."""
|
|
||||||
await get_like_cache().delete(_DIRTY_PROCESSING_KEY)
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
import time
|
import time
|
||||||
import traceback
|
|
||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
from fastapi import HTTPException
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from config import db_settings
|
from config import db_settings
|
||||||
|
import traceback
|
||||||
|
|
||||||
logger = get_logger("database")
|
logger = get_logger("database")
|
||||||
|
|
||||||
|
|
@ -82,10 +81,8 @@ 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__,
|
||||||
|
|
@ -101,9 +98,6 @@ 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...")
|
||||||
|
|
@ -135,16 +129,18 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
# )
|
# )
|
||||||
try:
|
try:
|
||||||
yield session
|
yield session
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
from app.dashboard.exceptions import DashboardException
|
||||||
|
if isinstance(e, DashboardException):
|
||||||
|
raise e
|
||||||
|
import traceback
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
logger.error(
|
logger.error(
|
||||||
f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, "
|
f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, "
|
||||||
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
|
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
|
||||||
)
|
)
|
||||||
raise
|
raise e
|
||||||
finally:
|
finally:
|
||||||
total_time = time.perf_counter() - start_time
|
total_time = time.perf_counter() - start_time
|
||||||
# logger.debug(
|
# logger.debug(
|
||||||
|
|
@ -172,8 +168,6 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
# )
|
# )
|
||||||
try:
|
try:
|
||||||
yield session
|
yield session
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|
@ -182,7 +176,7 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
|
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
|
||||||
)
|
)
|
||||||
logger.debug(traceback.format_exc())
|
logger.debug(traceback.format_exc())
|
||||||
raise
|
raise e
|
||||||
finally:
|
finally:
|
||||||
total_time = time.perf_counter() - start_time
|
total_time = time.perf_counter() - start_time
|
||||||
# logger.debug(
|
# logger.debug(
|
||||||
|
|
|
||||||
|
|
@ -42,41 +42,31 @@ from config import MEDIA_ROOT
|
||||||
# 로거 설정
|
# 로거 설정
|
||||||
logger = get_logger("home")
|
logger = get_logger("home")
|
||||||
|
|
||||||
# 전국 시/군 이름 목록 (roadAddress에서 region 추출용)
|
# 전국 시 이름 목록 (roadAddress에서 region 추출용)
|
||||||
# fmt: off
|
# fmt: off
|
||||||
KOREAN_CITIES = [
|
KOREAN_CITIES = [
|
||||||
# 특별시/광역시
|
# 특별시/광역시
|
||||||
"서울시", "부산시", "대구시", "인천시", "광주시", "대전시", "울산시", "세종시",
|
"서울시", "부산시", "대구시", "인천시", "광주시", "대전시", "울산시", "세종시",
|
||||||
# 경기도
|
# 경기도
|
||||||
"수원시", "성남시", "고양시", "용인시", "부천시", "안산시", "안양시", "남양주시",
|
"수원시", "성남시", "고양시", "용인시", "부천시", "안산시", "안양시", "남양주시",
|
||||||
"화성시", "평택시", "의정부시", "시흥시", "파주시", "김포시", "광주시", "광명시",
|
"화성시", "평택시", "의정부시", "시흥시", "파주시", "광명시", "김포시", "군포시",
|
||||||
"군포시", "하남시", "오산시", "이천시", "안성시", "구리시", "양주시", "포천시",
|
"광주시", "이천시", "양주시", "오산시", "구리시", "안성시", "포천시", "의왕시",
|
||||||
"여주시", "동두천시", "과천시", "가평군", "양평군", "연천군",
|
"하남시", "여주시", "동두천시", "과천시",
|
||||||
# 강원특별자치도
|
# 강원도
|
||||||
"춘천시", "원주시", "강릉시", "동해시", "태백시", "속초시", "삼척시",
|
"춘천시", "원주시", "강릉시", "동해시", "태백시", "속초시", "삼척시",
|
||||||
"홍천군", "횡성군", "영월군", "평창군", "정선군", "철원군", "화천군",
|
|
||||||
"양구군", "인제군", "고성군", "양양군",
|
|
||||||
# 충청북도
|
# 충청북도
|
||||||
"청주시", "충주시", "제천시",
|
"청주시", "충주시", "제천시",
|
||||||
"보은군", "옥천군", "영동군", "증평군", "진천군", "괴산군", "음성군", "단양군",
|
|
||||||
# 충청남도
|
# 충청남도
|
||||||
"천안시", "공주시", "보령시", "아산시", "서산시", "논산시", "계룡시", "당진시",
|
"천안시", "공주시", "보령시", "아산시", "서산시", "논산시", "계룡시", "당진시",
|
||||||
"금산군", "부여군", "서천군", "청양군", "홍성군", "예산군", "태안군",
|
# 전라북도
|
||||||
# 전북특별자치도
|
|
||||||
"전주시", "군산시", "익산시", "정읍시", "남원시", "김제시",
|
"전주시", "군산시", "익산시", "정읍시", "남원시", "김제시",
|
||||||
"완주군", "진안군", "무주군", "장수군", "임실군", "순창군", "고창군", "부안군",
|
|
||||||
# 전라남도
|
# 전라남도
|
||||||
"목포시", "여수시", "순천시", "나주시", "광양시",
|
"목포시", "여수시", "순천시", "나주시", "광양시",
|
||||||
"담양군", "곡성군", "구례군", "고흥군", "보성군", "화순군", "장흥군", "강진군",
|
|
||||||
"해남군", "영암군", "무안군", "함평군", "영광군", "장성군", "완도군", "진도군", "신안군",
|
|
||||||
# 경상북도
|
# 경상북도
|
||||||
"포항시", "경주시", "김천시", "안동시", "구미시", "영주시", "영천시", "상주시", "문경시", "경산시",
|
"포항시", "경주시", "김천시", "안동시", "구미시", "영주시", "영천시", "상주시", "문경시", "경산시",
|
||||||
"의성군", "청송군", "영양군", "영덕군", "청도군", "고령군", "성주군", "칠곡군",
|
|
||||||
"예천군", "봉화군", "울진군", "울릉군",
|
|
||||||
# 경상남도
|
# 경상남도
|
||||||
"창원시", "진주시", "통영시", "사천시", "김해시", "밀양시", "거제시", "양산시",
|
"창원시", "진주시", "통영시", "사천시", "김해시", "밀양시", "거제시", "양산시",
|
||||||
"의령군", "함안군", "창녕군", "고성군", "남해군", "하동군", "산청군", "함양군", "거창군", "합천군",
|
# 제주도
|
||||||
# 제주특별자치도
|
|
||||||
"제주시", "서귀포시",
|
"제주시", "서귀포시",
|
||||||
]
|
]
|
||||||
# fmt: on
|
# fmt: on
|
||||||
|
|
@ -126,39 +116,13 @@ async def search_accommodation(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
METRO_CITY_MAP = {
|
|
||||||
"서울": "서울시", "부산": "부산시", "대구": "대구시",
|
|
||||||
"인천": "인천시", "광주": "광주시", "대전": "대전시",
|
|
||||||
"울산": "울산시", "세종": "세종시",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_region_from_address(road_address: str | None) -> str:
|
def _extract_region_from_address(road_address: str | None) -> str:
|
||||||
"""roadAddress에서 시/군 이름 추출
|
"""roadAddress에서 시 이름 추출"""
|
||||||
|
|
||||||
매칭 우선순위:
|
|
||||||
1. KOREAN_CITIES 직접 매칭 (시/군 접미사 포함)
|
|
||||||
2. KOREAN_CITIES 접미사 생략 매칭
|
|
||||||
3. 주소 두 번째 토큰이 시/군으로 끝나는 경우 (예: "전북 군산시 ...")
|
|
||||||
4. 주소 두 번째 토큰이 구/동인 경우 → 첫 번째 토큰으로 광역시 매핑 (예: "서울 강남구 ...")
|
|
||||||
"""
|
|
||||||
if not road_address:
|
if not road_address:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
for city in KOREAN_CITIES:
|
for city in KOREAN_CITIES:
|
||||||
if city in road_address:
|
if city in road_address:
|
||||||
return city
|
return city
|
||||||
if city[:-1] in road_address:
|
|
||||||
return city
|
|
||||||
|
|
||||||
tokens = road_address.split()
|
|
||||||
if len(tokens) >= 2:
|
|
||||||
second = tokens[1]
|
|
||||||
if second.endswith("시") or second.endswith("군"):
|
|
||||||
return second
|
|
||||||
if second.endswith("구") or second.endswith("동"):
|
|
||||||
return METRO_CITY_MAP.get(tokens[0], "")
|
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -290,7 +254,7 @@ async def _crawling_logic(
|
||||||
marketing_analysis = None
|
marketing_analysis = None
|
||||||
|
|
||||||
if scraper.base_info:
|
if scraper.base_info:
|
||||||
road_address = scraper.base_info.get("roadAddress", "") or scraper.base_info.get("address", "")
|
road_address = scraper.base_info.get("roadAddress", "")
|
||||||
customer_name = scraper.base_info.get("name", "")
|
customer_name = scraper.base_info.get("name", "")
|
||||||
region = _extract_region_from_address(road_address)
|
region = _extract_region_from_address(road_address)
|
||||||
|
|
||||||
|
|
@ -787,7 +751,7 @@ async def upload_images_blob(
|
||||||
image_urls = [img.img_url for img in result_images]
|
image_urls = [img.img_url for img in result_images]
|
||||||
|
|
||||||
logger.info(f"[image_tagging] START - task_id: {task_id}")
|
logger.info(f"[image_tagging] START - task_id: {task_id}")
|
||||||
await tagging_images(image_urls, clear_old_tags=True)
|
await tag_images_if_not_exist(image_urls)
|
||||||
logger.info(f"[image_tagging] Done - task_id: {task_id}")
|
logger.info(f"[image_tagging] Done - task_id: {task_id}")
|
||||||
|
|
||||||
total_time = time.perf_counter() - request_start
|
total_time = time.perf_counter() - request_start
|
||||||
|
|
@ -807,9 +771,8 @@ async def upload_images_blob(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def tagging_images(
|
async def tag_images_if_not_exist(
|
||||||
image_urls : list[str],
|
image_urls : list[str]
|
||||||
clear_old_tags : bool = False
|
|
||||||
) -> None:
|
) -> None:
|
||||||
# 1. 조회
|
# 1. 조회
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
|
|
@ -821,26 +784,21 @@ async def tagging_images(
|
||||||
image_tags_query_results = await session.execute(stmt)
|
image_tags_query_results = await session.execute(stmt)
|
||||||
image_tags = image_tags_query_results.scalars().all()
|
image_tags = image_tags_query_results.scalars().all()
|
||||||
existing_urls = {tag.img_url for tag in image_tags}
|
existing_urls = {tag.img_url for tag in image_tags}
|
||||||
new_imt = [
|
new_tags = [
|
||||||
ImageTag(img_url=url, img_tag=None)
|
ImageTag(img_url=url, img_tag=None)
|
||||||
for url in image_urls
|
for url in image_urls
|
||||||
if url not in existing_urls
|
if url not in existing_urls
|
||||||
]
|
]
|
||||||
if clear_old_tags:
|
session.add_all(new_tags)
|
||||||
for tag in image_tags:
|
|
||||||
tag.img_tag = None
|
null_tags = [tag for tag in image_tags if tag.img_tag is None] + new_tags
|
||||||
session.add_all(new_imt)
|
|
||||||
null_imts = [imt for imt in image_tags if imt.img_tag is None] + new_imt
|
if null_tags:
|
||||||
await session.commit()
|
tag_datas = await autotag_images([img.img_url for img in null_tags])
|
||||||
|
|
||||||
if null_imts:
|
|
||||||
tag_datas = await autotag_images([img.img_url for img in null_imts])
|
|
||||||
print(tag_datas)
|
print(tag_datas)
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
for tag, tag_data in zip(null_tags, 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)
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,6 @@ from app.lyric.schemas.lyric import (
|
||||||
LyricDetailResponse,
|
LyricDetailResponse,
|
||||||
LyricListItem,
|
LyricListItem,
|
||||||
LyricStatusResponse,
|
LyricStatusResponse,
|
||||||
SubtitleStatusResponse,
|
|
||||||
)
|
)
|
||||||
from app.lyric.worker.lyric_task import generate_lyric_background, generate_subtitle_background
|
from app.lyric.worker.lyric_task import generate_lyric_background, generate_subtitle_background
|
||||||
from app.utils.prompts.chatgpt_prompt import ChatgptService
|
from app.utils.prompts.chatgpt_prompt import ChatgptService
|
||||||
|
|
@ -241,21 +240,6 @@ async def generate_lyric(
|
||||||
request_start = time.perf_counter()
|
request_start = time.perf_counter()
|
||||||
task_id = request_body.task_id
|
task_id = request_body.task_id
|
||||||
|
|
||||||
user = (await session.execute(
|
|
||||||
select(User).where(User.user_uuid == current_user.user_uuid)
|
|
||||||
)).scalar_one()
|
|
||||||
|
|
||||||
if user.credits <= 0:
|
|
||||||
logger.info(
|
|
||||||
f"크레딧 부족, user_uuid: {current_user.user_uuid}, credits: {current_user.credits}"
|
|
||||||
)
|
|
||||||
return GenerateLyricResponse(
|
|
||||||
success=False,
|
|
||||||
task_id=task_id,
|
|
||||||
lyric=None,
|
|
||||||
language=request_body.language,
|
|
||||||
error_message="No credits remaining.",
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"[generate_lyric] ========== START ==========")
|
logger.info(f"[generate_lyric] ========== START ==========")
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -357,14 +341,6 @@ async def generate_lyric(
|
||||||
step4_start = time.perf_counter()
|
step4_start = time.perf_counter()
|
||||||
logger.debug(f"[generate_lyric] Step 4: 백그라운드 태스크 스케줄링...")
|
logger.debug(f"[generate_lyric] Step 4: 백그라운드 태스크 스케줄링...")
|
||||||
orientation = request_body.orientation
|
orientation = request_body.orientation
|
||||||
|
|
||||||
if request_body.instrumental:
|
|
||||||
# BGM 모드: ChatGPT 가사 생성 없이 Lyric을 즉시 completed로 마무리
|
|
||||||
lyric.status = "completed"
|
|
||||||
lyric.lyric_result = ""
|
|
||||||
await session.commit()
|
|
||||||
logger.info(f"[generate_lyric] BGM 모드 - 가사 생성 스킵, lyric_id: {lyric.id}")
|
|
||||||
else:
|
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
generate_lyric_background,
|
generate_lyric_background,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
|
|
@ -376,7 +352,7 @@ async def generate_lyric(
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
generate_subtitle_background,
|
generate_subtitle_background,
|
||||||
orientation = orientation,
|
orientation = orientation,
|
||||||
task_id=task_id,
|
task_id=task_id
|
||||||
)
|
)
|
||||||
|
|
||||||
step4_elapsed = (time.perf_counter() - step4_start) * 1000
|
step4_elapsed = (time.perf_counter() - step4_start) * 1000
|
||||||
|
|
@ -516,86 +492,6 @@ async def list_lyrics(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/subtitle/status/{task_id}",
|
|
||||||
summary="자막 생성 상태 조회",
|
|
||||||
description="""
|
|
||||||
자막(subtitle) 생성 완료 여부를 조회합니다.
|
|
||||||
|
|
||||||
## 인증
|
|
||||||
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
|
|
||||||
|
|
||||||
## 경로 파라미터
|
|
||||||
- **task_id**: 프로젝트 task_id (필수)
|
|
||||||
|
|
||||||
## 상태 값
|
|
||||||
- **pending**: 자막 생성 진행 중 — 잠시 후 재요청
|
|
||||||
- **completed**: 자막 생성 완료 — `/video/generate/{task_id}` 호출 가능
|
|
||||||
|
|
||||||
## 사용 예시 (cURL)
|
|
||||||
```bash
|
|
||||||
curl -X GET "http://localhost:8000/lyric/subtitle/status/019123ab-cdef-7890-abcd-ef1234567890" \\
|
|
||||||
-H "Authorization: Bearer {access_token}"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 참고
|
|
||||||
- 자막은 `/lyric/generate` 호출 시 백그라운드에서 자동 생성됩니다.
|
|
||||||
- 클라이언트는 `completed` 상태 확인 후 `/video/generate`를 호출해야 합니다.
|
|
||||||
""",
|
|
||||||
response_model=SubtitleStatusResponse,
|
|
||||||
responses={
|
|
||||||
200: {"description": "상태 조회 성공"},
|
|
||||||
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
|
||||||
404: {"description": "해당 task_id에 해당하는 프로젝트를 찾을 수 없음"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def get_subtitle_status(
|
|
||||||
task_id: str,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> SubtitleStatusResponse:
|
|
||||||
"""task_id로 자막 생성 상태를 조회합니다."""
|
|
||||||
logger.info(f"[get_subtitle_status] START - task_id: {task_id}")
|
|
||||||
|
|
||||||
project_result = await session.execute(
|
|
||||||
select(Project)
|
|
||||||
.where(Project.task_id == task_id)
|
|
||||||
.order_by(Project.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
project = project_result.scalar_one_or_none()
|
|
||||||
if not project:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail=f"task_id '{task_id}'에 해당하는 프로젝트를 찾을 수 없습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
marketing_result = await session.execute(
|
|
||||||
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
|
|
||||||
)
|
|
||||||
intel = marketing_result.scalar_one_or_none()
|
|
||||||
if not intel:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail=f"task_id '{task_id}'에 해당하는 마케팅 인텔리전스를 찾을 수 없습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
if intel.subtitle:
|
|
||||||
logger.info(f"[get_subtitle_status] completed - task_id: {task_id}")
|
|
||||||
return SubtitleStatusResponse(
|
|
||||||
task_id=task_id,
|
|
||||||
status="completed",
|
|
||||||
message="자막 생성이 완료되었습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"[get_subtitle_status] pending - task_id: {task_id}")
|
|
||||||
return SubtitleStatusResponse(
|
|
||||||
task_id=task_id,
|
|
||||||
status="pending",
|
|
||||||
message="자막 생성이 진행 중입니다. 잠시 후 다시 확인해주세요.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{task_id}",
|
"/{task_id}",
|
||||||
summary="가사 상세 조회",
|
summary="가사 상세 조회",
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,6 @@ class GenerateLyricRequest(BaseModel):
|
||||||
description="영상 방향 (horizontal: 가로형, vertical: 세로형)",
|
description="영상 방향 (horizontal: 가로형, vertical: 세로형)",
|
||||||
),
|
),
|
||||||
m_id : Optional[int] = Field(None, description="마케팅 인텔리전스 ID 값")
|
m_id : Optional[int] = Field(None, description="마케팅 인텔리전스 ID 값")
|
||||||
instrumental: bool = Field(default=False, description="BGM 전용 모드 (가사 생성 안 함)")
|
|
||||||
|
|
||||||
|
|
||||||
class GenerateLyricResponse(BaseModel):
|
class GenerateLyricResponse(BaseModel):
|
||||||
|
|
@ -202,55 +201,6 @@ class LyricDetailResponse(BaseModel):
|
||||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||||
|
|
||||||
|
|
||||||
class SubtitleStatusResponse(BaseModel):
|
|
||||||
"""자막 생성 상태 조회 응답 스키마
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
GET /subtitle/status/{task_id}
|
|
||||||
클라이언트가 subtitle 완료 여부를 polling할 때 사용합니다.
|
|
||||||
|
|
||||||
Status Values:
|
|
||||||
- pending: 자막 생성 진행 중 (재시도 필요)
|
|
||||||
- completed: 자막 생성 완료 (/video/generate 호출 가능)
|
|
||||||
- failed: 자막 생성 실패 (/lyric/generate 재호출 필요)
|
|
||||||
"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"examples": [
|
|
||||||
{
|
|
||||||
"summary": "생성 중",
|
|
||||||
"value": {
|
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
|
||||||
"status": "pending",
|
|
||||||
"message": "자막 생성이 진행 중입니다. 잠시 후 다시 확인해주세요.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"summary": "완료",
|
|
||||||
"value": {
|
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
|
||||||
"status": "completed",
|
|
||||||
"message": "자막 생성이 완료되었습니다.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"summary": "실패",
|
|
||||||
"value": {
|
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
|
||||||
"status": "failed",
|
|
||||||
"message": "자막 생성에 실패했습니다. 다시 시도해주세요.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
task_id: str = Field(..., description="작업 고유 식별자")
|
|
||||||
status: Literal["pending", "completed", "failed"] = Field(..., description="자막 생성 상태")
|
|
||||||
message: str = Field(..., description="상태 메시지")
|
|
||||||
|
|
||||||
|
|
||||||
class LyricListItem(BaseModel):
|
class LyricListItem(BaseModel):
|
||||||
"""가사 목록 아이템 스키마
|
"""가사 목록 아이템 스키마
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -158,13 +158,9 @@ async def generate_lyric_background(
|
||||||
|
|
||||||
async def generate_subtitle_background(
|
async def generate_subtitle_background(
|
||||||
orientation: str,
|
orientation: str,
|
||||||
task_id: str,
|
task_id: str
|
||||||
max_retries: int = 3,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
logger.info(f"[generate_subtitle_background] START - task_id: {task_id}, orientation: {orientation}")
|
logger.info(f"[generate_subtitle_background] task_id: {task_id}, {orientation}")
|
||||||
|
|
||||||
for attempt in range(1, max_retries + 1):
|
|
||||||
try:
|
|
||||||
creatomate_service = CreatomateService(orientation=orientation)
|
creatomate_service = CreatomateService(orientation=orientation)
|
||||||
template = await creatomate_service.get_one_template_data(creatomate_service.template_id)
|
template = await creatomate_service.get_one_template_data(creatomate_service.template_id)
|
||||||
pitchings = creatomate_service.extract_text_format_from_template(template)
|
pitchings = creatomate_service.extract_text_format_from_template(template)
|
||||||
|
|
@ -186,7 +182,7 @@ async def generate_subtitle_background(
|
||||||
|
|
||||||
store_address = project.detail_region_info
|
store_address = project.detail_region_info
|
||||||
customer_name = project.store_name
|
customer_name = project.store_name
|
||||||
logger.info(f"[generate_subtitle_background] customer_name: {customer_name}, store_address: {store_address}")
|
logger.info(f"[generate_subtitle_background] customer_name: {customer_name}, {store_address}")
|
||||||
|
|
||||||
generated_subtitles = await subtitle_generator.generate_subtitle_contents(
|
generated_subtitles = await subtitle_generator.generate_subtitle_contents(
|
||||||
marketing_intelligence = marketing_intelligence.intel_result,
|
marketing_intelligence = marketing_intelligence.intel_result,
|
||||||
|
|
@ -196,10 +192,7 @@ async def generate_subtitle_background(
|
||||||
)
|
)
|
||||||
pitching_output_list = generated_subtitles.pitching_results
|
pitching_output_list = generated_subtitles.pitching_results
|
||||||
|
|
||||||
subtitle_modifications = {
|
subtitle_modifications = {pitching_output.pitching_tag : pitching_output.pitching_data for pitching_output in pitching_output_list}
|
||||||
pitching_output.pitching_tag: pitching_output.pitching_data
|
|
||||||
for pitching_output in pitching_output_list
|
|
||||||
}
|
|
||||||
logger.info(f"[generate_subtitle_background] subtitle_modifications: {subtitle_modifications}")
|
logger.info(f"[generate_subtitle_background] subtitle_modifications: {subtitle_modifications}")
|
||||||
|
|
||||||
async with BackgroundSessionLocal() as session:
|
async with BackgroundSessionLocal() as session:
|
||||||
|
|
@ -209,16 +202,8 @@ async def generate_subtitle_background(
|
||||||
marketing_intelligence = marketing_result.scalar_one_or_none()
|
marketing_intelligence = marketing_result.scalar_one_or_none()
|
||||||
marketing_intelligence.subtitle = subtitle_modifications
|
marketing_intelligence.subtitle = subtitle_modifications
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
logger.info(f"[generate_subtitle_background] task_id: {task_id} DONE")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
logger.info(f"[generate_subtitle_background] DONE - task_id: {task_id} (attempt {attempt}/{max_retries})")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"[generate_subtitle_background] FAILED (attempt {attempt}/{max_retries}) - task_id: {task_id}, error: {e}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
if attempt < max_retries:
|
|
||||||
logger.info(f"[generate_subtitle_background] 재시도 중... ({attempt + 1}/{max_retries}) - task_id: {task_id}")
|
|
||||||
|
|
||||||
logger.error(f"[generate_subtitle_background] 모든 재시도 실패 - task_id: {task_id}")
|
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ class YouTubeOAuthClient(BaseOAuthClient):
|
||||||
"response_type": "code",
|
"response_type": "code",
|
||||||
"scope": " ".join(YOUTUBE_SCOPES),
|
"scope": " ".join(YOUTUBE_SCOPES),
|
||||||
"access_type": "offline", # refresh_token 받기 위해 필요
|
"access_type": "offline", # refresh_token 받기 위해 필요
|
||||||
"prompt": "consent", # 항상 동의 화면 표시하여 refresh_token 발급 보장
|
"prompt": "select_account", # 계정 선택만 표시 (동의 화면은 최초 1회만)
|
||||||
"state": state,
|
"state": state,
|
||||||
}
|
}
|
||||||
url = f"{self.AUTHORIZATION_URL}?{urlencode(params)}"
|
url = f"{self.AUTHORIZATION_URL}?{urlencode(params)}"
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,6 @@ 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="예약 게시 시간")
|
||||||
|
|
|
||||||
|
|
@ -306,7 +306,7 @@ class SocialAccountService:
|
||||||
else:
|
else:
|
||||||
# DB datetime은 naive, now()는 aware이므로 naive로 통일하여 비교
|
# DB datetime은 naive, now()는 aware이므로 naive로 통일하여 비교
|
||||||
current_time = now().replace(tzinfo=None)
|
current_time = now().replace(tzinfo=None)
|
||||||
buffer_time = current_time + timedelta(minutes=20)
|
buffer_time = current_time + timedelta(hours=1)
|
||||||
if account.token_expires_at <= buffer_time:
|
if account.token_expires_at <= buffer_time:
|
||||||
should_refresh = True
|
should_refresh = True
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -291,7 +291,6 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -103,22 +103,6 @@ async def generate_song(
|
||||||
from app.database.session import AsyncSessionLocal
|
from app.database.session import AsyncSessionLocal
|
||||||
|
|
||||||
request_start = time.perf_counter()
|
request_start = time.perf_counter()
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
user = (await session.execute(
|
|
||||||
select(User).where(User.user_uuid == current_user.user_uuid)
|
|
||||||
)).scalar_one()
|
|
||||||
|
|
||||||
if user.credits <= 0:
|
|
||||||
logger.info(f"크레딧 부족, user_uuid: {current_user.user_uuid}, credits: {user.credits}")
|
|
||||||
return GenerateSongResponse(
|
|
||||||
success=False,
|
|
||||||
task_id=task_id,
|
|
||||||
song_id=None,
|
|
||||||
message="No credits remaining.",
|
|
||||||
error_message="No credits remaining.",
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[generate_song] START - task_id: {task_id}, "
|
f"[generate_song] START - task_id: {task_id}, "
|
||||||
f"genre: {request_body.genre}, language: {request_body.language}"
|
f"genre: {request_body.genre}, language: {request_body.language}"
|
||||||
|
|
@ -185,10 +169,9 @@ async def generate_song(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Song 테이블에 초기 데이터 저장
|
# Song 테이블에 초기 데이터 저장
|
||||||
if request_body.instrumental:
|
song_prompt = (
|
||||||
song_prompt = f"[Instrumental]\n[Genre]\n{request_body.genre}"
|
f"[Lyrics]\n{request_body.lyrics}\n\n[Genre]\n{request_body.genre}"
|
||||||
else:
|
)
|
||||||
song_prompt = f"[Lyrics]\n{request_body.lyrics}\n\n[Genre]\n{request_body.genre}"
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"[generate_song] Lyrics comparison - task_id: {task_id}\n"
|
f"[generate_song] Lyrics comparison - task_id: {task_id}\n"
|
||||||
f"{'=' * 60}\n"
|
f"{'=' * 60}\n"
|
||||||
|
|
@ -250,7 +233,6 @@ async def generate_song(
|
||||||
suno_task_id = await suno_service.generate(
|
suno_task_id = await suno_service.generate(
|
||||||
prompt=request_body.lyrics,
|
prompt=request_body.lyrics,
|
||||||
genre=request_body.genre,
|
genre=request_body.genre,
|
||||||
instrumental=request_body.instrumental,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
stage2_time = time.perf_counter()
|
stage2_time = time.perf_counter()
|
||||||
|
|
@ -454,18 +436,6 @@ async def get_song_status(
|
||||||
)
|
)
|
||||||
|
|
||||||
suno_audio_id = first_clip.get("id")
|
suno_audio_id = first_clip.get("id")
|
||||||
|
|
||||||
# BGM 모드(lyric_result가 비어 있음)에서는 타임스탬프 생성 스킵
|
|
||||||
lyric_result = await session.execute(
|
|
||||||
select(Lyric)
|
|
||||||
.where(Lyric.task_id == song.task_id)
|
|
||||||
.order_by(Lyric.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
lyric = lyric_result.scalar_one_or_none()
|
|
||||||
gt_lyric = lyric.lyric_result if lyric else None
|
|
||||||
|
|
||||||
if gt_lyric:
|
|
||||||
word_data = await suno_service.get_lyric_timestamp(
|
word_data = await suno_service.get_lyric_timestamp(
|
||||||
suno_task_id, suno_audio_id
|
suno_task_id, suno_audio_id
|
||||||
)
|
)
|
||||||
|
|
@ -474,6 +444,14 @@ async def get_song_status(
|
||||||
f"suno_task_id: {suno_task_id}, suno_audio_id: {suno_audio_id}, "
|
f"suno_task_id: {suno_task_id}, suno_audio_id: {suno_audio_id}, "
|
||||||
f"word_data: {word_data}"
|
f"word_data: {word_data}"
|
||||||
)
|
)
|
||||||
|
lyric_result = await session.execute(
|
||||||
|
select(Lyric)
|
||||||
|
.where(Lyric.task_id == song.task_id)
|
||||||
|
.order_by(Lyric.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
lyric = lyric_result.scalar_one_or_none()
|
||||||
|
gt_lyric = lyric.lyric_result
|
||||||
lyric_line_list = gt_lyric.split("\n")
|
lyric_line_list = gt_lyric.split("\n")
|
||||||
sentences = [
|
sentences = [
|
||||||
lyric_line.strip(",. ")
|
lyric_line.strip(",. ")
|
||||||
|
|
@ -488,10 +466,16 @@ async def get_song_status(
|
||||||
timestamped_lyrics = suno_service.align_lyrics(
|
timestamped_lyrics = suno_service.align_lyrics(
|
||||||
word_data, sentences
|
word_data, sentences
|
||||||
)
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"[get_song_status] sentences from lyric - "
|
||||||
|
f"sentences: {sentences}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO : DB upload timestamped_lyrics
|
||||||
for order_idx, timestamped_lyric in enumerate(
|
for order_idx, timestamped_lyric in enumerate(
|
||||||
timestamped_lyrics
|
timestamped_lyrics
|
||||||
):
|
):
|
||||||
|
# start_sec 또는 end_sec가 None인 경우 건너뛰기
|
||||||
if (
|
if (
|
||||||
timestamped_lyric["start_sec"] is None
|
timestamped_lyric["start_sec"] is None
|
||||||
or timestamped_lyric["end_sec"] is None
|
or timestamped_lyric["end_sec"] is None
|
||||||
|
|
@ -512,11 +496,6 @@ async def get_song_status(
|
||||||
end_time=timestamped_lyric["end_sec"],
|
end_time=timestamped_lyric["end_sec"],
|
||||||
)
|
)
|
||||||
session.add(song_timestamp)
|
session.add(song_timestamp)
|
||||||
else:
|
|
||||||
logger.info(
|
|
||||||
f"[get_song_status] BGM 모드 - 타임스탬프 생성 스킵, "
|
|
||||||
f"suno_task_id: {suno_task_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
parsed_response.status = "processing"
|
parsed_response.status = "processing"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, model_validator
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -33,7 +33,7 @@ class GenerateSongRequest(BaseModel):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lyrics: Optional[str] = Field(None, description="노래에 사용할 가사 (instrumental=True이면 생략 가능)")
|
lyrics: str = Field(..., description="노래에 사용할 가사")
|
||||||
genre: str = Field(
|
genre: str = Field(
|
||||||
...,
|
...,
|
||||||
description="음악 장르 (K-Pop, Pop, R&B, Hip-Hop, Ballad, EDM, Rock, Jazz 등)",
|
description="음악 장르 (K-Pop, Pop, R&B, Hip-Hop, Ballad, EDM, Rock, Jazz 등)",
|
||||||
|
|
@ -42,15 +42,6 @@ class GenerateSongRequest(BaseModel):
|
||||||
default="Korean",
|
default="Korean",
|
||||||
description="노래 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
description="노래 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||||
)
|
)
|
||||||
instrumental: bool = Field(default=False, description="BGM 전용 모드 (가사 없이 음악만 생성)")
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
|
||||||
def validate_lyrics_required(self) -> "GenerateSongRequest":
|
|
||||||
if not self.instrumental and not self.lyrics:
|
|
||||||
raise ValueError("instrumental=False일 때 lyrics는 필수입니다.")
|
|
||||||
if self.instrumental:
|
|
||||||
self.lyrics = None
|
|
||||||
return self
|
|
||||||
|
|
||||||
|
|
||||||
class GenerateSongResponse(BaseModel):
|
class GenerateSongResponse(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ logger = logging.getLogger(__name__)
|
||||||
from app.user.dependencies import get_current_user
|
from app.user.dependencies import get_current_user
|
||||||
from app.user.models import RefreshToken, User
|
from app.user.models import RefreshToken, User
|
||||||
from app.user.schemas.user_schema import (
|
from app.user.schemas.user_schema import (
|
||||||
CreditResponse,
|
|
||||||
KakaoCodeRequest,
|
KakaoCodeRequest,
|
||||||
KakaoLoginResponse,
|
KakaoLoginResponse,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
|
|
@ -354,22 +353,6 @@ async def get_me(
|
||||||
return UserResponse.model_validate(current_user)
|
return UserResponse.model_validate(current_user)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/me/credits",
|
|
||||||
response_model=CreditResponse,
|
|
||||||
summary="잔여 크레딧 조회",
|
|
||||||
description="현재 로그인한 사용자의 잔여 영상 생성 크레딧을 반환합니다.",
|
|
||||||
responses={
|
|
||||||
200: {"description": "조회 성공"},
|
|
||||||
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def get_my_credits(
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
) -> CreditResponse:
|
|
||||||
return CreditResponse(credits=current_user.credits)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 테스트용 엔드포인트 (DEBUG 모드에서만 main.py에서 라우터가 등록됨)
|
# 테스트용 엔드포인트 (DEBUG 모드에서만 main.py에서 라우터가 등록됨)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,20 @@
|
||||||
import logging
|
from sqladmin import ModelView
|
||||||
|
|
||||||
from sqladmin import ModelView, action
|
from app.user.models import RefreshToken, SocialAccount, User
|
||||||
from starlette.requests import Request
|
|
||||||
from starlette.responses import RedirectResponse
|
|
||||||
|
|
||||||
from app.backoffice.mixins import SuperAdminEditable, ViewerAccessible
|
|
||||||
from app.backoffice.user_view_actions import (
|
|
||||||
handle_block_users,
|
|
||||||
handle_deduct_credits,
|
|
||||||
handle_grant_credits,
|
|
||||||
)
|
|
||||||
from app.user.models import SocialAccount, User
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class UserAdmin(SuperAdminEditable, ModelView, model=User):
|
class UserAdmin(ModelView, model=User):
|
||||||
name = "사용자"
|
name = "사용자"
|
||||||
name_plural = "사용자 목록"
|
name_plural = "사용자 목록"
|
||||||
icon = "fa-solid fa-user"
|
icon = "fa-solid fa-user"
|
||||||
category = "사용자 관리"
|
category = "사용자 관리"
|
||||||
page_size = 30
|
page_size = 20
|
||||||
can_edit = True
|
|
||||||
can_delete = True
|
|
||||||
|
|
||||||
column_list = [
|
column_list = [
|
||||||
"id",
|
"id",
|
||||||
"user_uuid",
|
"kakao_id",
|
||||||
"email",
|
"email",
|
||||||
"nickname",
|
"nickname",
|
||||||
"credits",
|
|
||||||
"role",
|
"role",
|
||||||
"is_active",
|
"is_active",
|
||||||
"is_deleted",
|
"is_deleted",
|
||||||
|
|
@ -38,7 +23,7 @@ class UserAdmin(SuperAdminEditable, ModelView, model=User):
|
||||||
|
|
||||||
column_details_list = [
|
column_details_list = [
|
||||||
"id",
|
"id",
|
||||||
"user_uuid",
|
"kakao_id",
|
||||||
"email",
|
"email",
|
||||||
"nickname",
|
"nickname",
|
||||||
"profile_image_url",
|
"profile_image_url",
|
||||||
|
|
@ -47,7 +32,6 @@ class UserAdmin(SuperAdminEditable, ModelView, model=User):
|
||||||
"name",
|
"name",
|
||||||
"birth_date",
|
"birth_date",
|
||||||
"gender",
|
"gender",
|
||||||
"credits",
|
|
||||||
"is_active",
|
"is_active",
|
||||||
"is_admin",
|
"is_admin",
|
||||||
"role",
|
"role",
|
||||||
|
|
@ -58,22 +42,16 @@ class UserAdmin(SuperAdminEditable, ModelView, model=User):
|
||||||
"updated_at",
|
"updated_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
form_columns = [
|
form_excluded_columns = [
|
||||||
"nickname",
|
"created_at",
|
||||||
"email",
|
"updated_at",
|
||||||
"phone",
|
"projects",
|
||||||
"name",
|
"refresh_tokens",
|
||||||
"birth_date",
|
"social_accounts",
|
||||||
"gender",
|
|
||||||
"credits",
|
|
||||||
"is_active",
|
|
||||||
"is_admin",
|
|
||||||
"role",
|
|
||||||
"is_deleted",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
column_searchable_list = [
|
column_searchable_list = [
|
||||||
User.user_uuid,
|
User.kakao_id,
|
||||||
User.email,
|
User.email,
|
||||||
User.nickname,
|
User.nickname,
|
||||||
User.phone,
|
User.phone,
|
||||||
|
|
@ -84,10 +62,9 @@ class UserAdmin(SuperAdminEditable, ModelView, model=User):
|
||||||
|
|
||||||
column_sortable_list = [
|
column_sortable_list = [
|
||||||
User.id,
|
User.id,
|
||||||
User.user_uuid,
|
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,
|
||||||
|
|
@ -96,16 +73,15 @@ class UserAdmin(SuperAdminEditable, ModelView, model=User):
|
||||||
|
|
||||||
column_labels = {
|
column_labels = {
|
||||||
"id": "ID",
|
"id": "ID",
|
||||||
"user_uuid": "UUID",
|
"kakao_id": "카카오 ID",
|
||||||
"email": "이메일",
|
"email": "이메일",
|
||||||
"nickname": "닉네임",
|
"nickname": "닉네임",
|
||||||
"profile_image_url": "프로필 이미지",
|
"profile_image_url": "프로필 이미지",
|
||||||
"thumbnail_image_url": "썸네일 이미지",
|
"thumbnail_image_url": "썸네일 이미지",
|
||||||
"phone": "전화번호",
|
"phone": "전화번호",
|
||||||
"name": "이름",
|
"name": "실명",
|
||||||
"birth_date": "생년월일",
|
"birth_date": "생년월일",
|
||||||
"gender": "성별",
|
"gender": "성별",
|
||||||
"credits": "크레딧",
|
|
||||||
"is_active": "활성화",
|
"is_active": "활성화",
|
||||||
"is_admin": "관리자",
|
"is_admin": "관리자",
|
||||||
"role": "권한",
|
"role": "권한",
|
||||||
|
|
@ -116,71 +92,71 @@ class UserAdmin(SuperAdminEditable, ModelView, model=User):
|
||||||
"updated_at": "수정일시",
|
"updated_at": "수정일시",
|
||||||
}
|
}
|
||||||
|
|
||||||
@action(
|
|
||||||
name="01_block_user",
|
|
||||||
label="계정 차단",
|
|
||||||
confirmation_message="선택한 사용자를 차단하시겠습니까?",
|
|
||||||
add_in_list=True,
|
|
||||||
)
|
|
||||||
async def seq_f_block_user_action(self, request: Request) -> RedirectResponse:
|
|
||||||
return await handle_block_users(request, self.identity, block=True)
|
|
||||||
|
|
||||||
@action(
|
class RefreshTokenAdmin(ModelView, model=RefreshToken):
|
||||||
name="02_unblock_user",
|
name = "리프레시 토큰"
|
||||||
label="차단 해제",
|
name_plural = "리프레시 토큰 목록"
|
||||||
confirmation_message="선택한 사용자의 차단을 해제하시겠습니까?",
|
icon = "fa-solid fa-key"
|
||||||
add_in_list=True,
|
category = "사용자 관리"
|
||||||
)
|
page_size = 20
|
||||||
async def seq_e_unblock_user_action(self, request: Request) -> RedirectResponse:
|
|
||||||
return await handle_block_users(request, self.identity, block=False)
|
|
||||||
|
|
||||||
@action(
|
column_list = [
|
||||||
name="03_grant_credits_1",
|
"id",
|
||||||
label="크레딧 +1",
|
"user_id",
|
||||||
confirmation_message="선택한 사용자에게 크레딧 1개를 충전하시겠습니까?",
|
"is_revoked",
|
||||||
add_in_list=True,
|
"expires_at",
|
||||||
)
|
"created_at",
|
||||||
async def seq_d_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(
|
column_details_list = [
|
||||||
name="04_grant_credits_5",
|
"id",
|
||||||
label="크레딧 +5",
|
"user_id",
|
||||||
confirmation_message="선택한 사용자에게 크레딧 5개를 충전하시겠습니까?",
|
"token_hash",
|
||||||
add_in_list=True,
|
"expires_at",
|
||||||
)
|
"is_revoked",
|
||||||
async def seq_c_grant_credits_5_action(self, request: Request) -> RedirectResponse:
|
"created_at",
|
||||||
admin_id = request.session.get("admin_id")
|
"revoked_at",
|
||||||
return await handle_grant_credits(request, self.identity, amount=5, admin_id=admin_id)
|
"user_agent",
|
||||||
|
"ip_address",
|
||||||
|
]
|
||||||
|
|
||||||
@action(
|
form_excluded_columns = ["created_at", "user"]
|
||||||
name="05_grant_credits_10",
|
|
||||||
label="크레딧 +10",
|
|
||||||
confirmation_message="선택한 사용자에게 크레딧 10개를 충전하시겠습니까?",
|
|
||||||
add_in_list=True,
|
|
||||||
)
|
|
||||||
async def seq_b_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(
|
column_searchable_list = [
|
||||||
name="06_deduct_credits_1",
|
RefreshToken.user_id,
|
||||||
label="크레딧 -1",
|
RefreshToken.token_hash,
|
||||||
confirmation_message="선택한 사용자의 크레딧 1개를 차감하시겠습니까?",
|
RefreshToken.ip_address,
|
||||||
add_in_list=True,
|
]
|
||||||
)
|
|
||||||
async def seq_a_deduct_credits_1_action(self, request: Request) -> RedirectResponse:
|
column_default_sort = (RefreshToken.created_at, True)
|
||||||
admin_id = request.session.get("admin_id")
|
|
||||||
return await handle_deduct_credits(request, self.identity, amount=1, admin_id=admin_id)
|
column_sortable_list = [
|
||||||
|
RefreshToken.id,
|
||||||
|
RefreshToken.user_id,
|
||||||
|
RefreshToken.is_revoked,
|
||||||
|
RefreshToken.expires_at,
|
||||||
|
RefreshToken.created_at,
|
||||||
|
]
|
||||||
|
|
||||||
|
column_labels = {
|
||||||
|
"id": "ID",
|
||||||
|
"user_id": "사용자 ID",
|
||||||
|
"token_hash": "토큰 해시",
|
||||||
|
"expires_at": "만료일시",
|
||||||
|
"is_revoked": "폐기됨",
|
||||||
|
"created_at": "생성일시",
|
||||||
|
"revoked_at": "폐기일시",
|
||||||
|
"user_agent": "User Agent",
|
||||||
|
"ip_address": "IP 주소",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class SocialAccountAdmin(ViewerAccessible, ModelView, model=SocialAccount):
|
class SocialAccountAdmin(ModelView, model=SocialAccount):
|
||||||
name = "소셜 계정"
|
name = "소셜 계정"
|
||||||
name_plural = "소셜 계정 목록"
|
name_plural = "소셜 계정 목록"
|
||||||
icon = "fa-solid fa-share-nodes"
|
icon = "fa-solid fa-share-nodes"
|
||||||
category = "사용자 관리"
|
category = "사용자 관리"
|
||||||
page_size = 30
|
page_size = 20
|
||||||
|
|
||||||
column_list = [
|
column_list = [
|
||||||
"id",
|
"id",
|
||||||
|
|
@ -198,6 +174,8 @@ class SocialAccountAdmin(ViewerAccessible, ModelView, model=SocialAccount):
|
||||||
"platform",
|
"platform",
|
||||||
"platform_user_id",
|
"platform_user_id",
|
||||||
"platform_username",
|
"platform_username",
|
||||||
|
"platform_data",
|
||||||
|
"scope",
|
||||||
"token_expires_at",
|
"token_expires_at",
|
||||||
"is_active",
|
"is_active",
|
||||||
"is_deleted",
|
"is_deleted",
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,10 @@ from app.user.services.auth import (
|
||||||
AdminRequiredError,
|
AdminRequiredError,
|
||||||
InvalidTokenError,
|
InvalidTokenError,
|
||||||
MissingTokenError,
|
MissingTokenError,
|
||||||
TokenExpiredError,
|
|
||||||
UserInactiveError,
|
UserInactiveError,
|
||||||
UserNotFoundError,
|
UserNotFoundError,
|
||||||
)
|
)
|
||||||
from app.user.services.jwt import decode_token, is_token_expired
|
from app.user.services.jwt import decode_token
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -59,9 +58,6 @@ async def get_current_user(
|
||||||
|
|
||||||
payload = decode_token(token)
|
payload = decode_token(token)
|
||||||
if payload is None:
|
if payload is None:
|
||||||
if is_token_expired(token):
|
|
||||||
logger.info(f"[AUTH-DEP] Access Token 만료 - token: ...{token[-20:]}")
|
|
||||||
raise TokenExpiredError()
|
|
||||||
logger.warning(f"[AUTH-DEP] Access Token 디코딩 실패 - token: ...{token[-20:]}")
|
logger.warning(f"[AUTH-DEP] Access Token 디코딩 실패 - token: ...{token[-20:]}")
|
||||||
raise InvalidTokenError()
|
raise InvalidTokenError()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,7 @@ from app.database.session import Base
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.comment.models import Comment
|
|
||||||
from app.credit.models import CreditChargeRequest, CreditTransaction
|
|
||||||
from app.home.models import Project
|
from app.home.models import Project
|
||||||
from app.video.models import VideoReaction
|
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
|
|
@ -219,14 +216,6 @@ class User(Base):
|
||||||
comment="마지막 로그인 일시",
|
comment="마지막 로그인 일시",
|
||||||
)
|
)
|
||||||
|
|
||||||
credits: Mapped[int] = mapped_column(
|
|
||||||
Integer,
|
|
||||||
nullable=False,
|
|
||||||
default=3,
|
|
||||||
server_default="3",
|
|
||||||
comment="잔여 영상 생성 크레딧",
|
|
||||||
)
|
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime,
|
DateTime,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
|
|
@ -279,38 +268,6 @@ 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",
|
|
||||||
)
|
|
||||||
|
|
||||||
comments: Mapped[List["Comment"]] = relationship(
|
|
||||||
"Comment",
|
|
||||||
back_populates="user",
|
|
||||||
cascade="all, delete-orphan",
|
|
||||||
lazy="noload",
|
|
||||||
)
|
|
||||||
|
|
||||||
video_reactions: Mapped[List["VideoReaction"]] = relationship(
|
|
||||||
"VideoReaction",
|
|
||||||
back_populates="user",
|
|
||||||
cascade="all, delete-orphan",
|
|
||||||
lazy="noload",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return (
|
return (
|
||||||
f"<User("
|
f"<User("
|
||||||
|
|
|
||||||
|
|
@ -160,22 +160,6 @@ class LoginResponse(BaseModel):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 크레딧 스키마
|
|
||||||
# =============================================================================
|
|
||||||
class CreditResponse(BaseModel):
|
|
||||||
"""잔여 크레딧 응답"""
|
|
||||||
|
|
||||||
credits: int = Field(..., description="영상 생성 크레딧")
|
|
||||||
|
|
||||||
model_config = {
|
|
||||||
"json_schema_extra": {
|
|
||||||
"example": {
|
|
||||||
"credits": 3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 내부 사용 스키마 (카카오 API 응답 파싱)
|
# 내부 사용 스키마 (카카오 API 응답 파싱)
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,6 @@ from app.user.services.jwt import (
|
||||||
get_access_token_expire_seconds,
|
get_access_token_expire_seconds,
|
||||||
get_refresh_token_expires_at,
|
get_refresh_token_expires_at,
|
||||||
get_token_hash,
|
get_token_hash,
|
||||||
is_token_expired,
|
|
||||||
)
|
)
|
||||||
from app.user.services.kakao import kakao_client
|
from app.user.services.kakao import kakao_client
|
||||||
|
|
||||||
|
|
@ -213,9 +212,6 @@ class AuthService:
|
||||||
# 1. 토큰 디코딩 및 검증
|
# 1. 토큰 디코딩 및 검증
|
||||||
payload = decode_token(refresh_token)
|
payload = decode_token(refresh_token)
|
||||||
if payload is None:
|
if payload is None:
|
||||||
if is_token_expired(refresh_token):
|
|
||||||
logger.info(f"[AUTH] 토큰 갱신 실패 [1/8 만료] - token: ...{refresh_token[-20:]}")
|
|
||||||
raise TokenExpiredError()
|
|
||||||
logger.warning(f"[AUTH] 토큰 갱신 실패 [1/8 디코딩] - token: ...{refresh_token[-20:]}")
|
logger.warning(f"[AUTH] 토큰 갱신 실패 [1/8 디코딩] - token: ...{refresh_token[-20:]}")
|
||||||
raise InvalidTokenError()
|
raise InvalidTokenError()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import logging
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
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: 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
|
|
||||||
|
|
@ -116,28 +116,6 @@ def decode_token(token: str) -> Optional[dict]:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def is_token_expired(token: str) -> bool:
|
|
||||||
"""
|
|
||||||
토큰이 만료됐는지 확인 (서명/형식은 유효하지만 exp 초과인 경우)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True: 서명은 유효하나 만료된 토큰, False: 형식/서명 자체가 잘못된 토큰
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(
|
|
||||||
token,
|
|
||||||
jwt_settings.JWT_SECRET,
|
|
||||||
algorithms=[jwt_settings.JWT_ALGORITHM],
|
|
||||||
options={"verify_exp": False},
|
|
||||||
)
|
|
||||||
exp = payload.get("exp")
|
|
||||||
if exp is None:
|
|
||||||
return False
|
|
||||||
return datetime.fromtimestamp(exp) < datetime.now()
|
|
||||||
except JWTError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def get_token_hash(token: str) -> str:
|
def get_token_hash(token: str) -> str:
|
||||||
"""
|
"""
|
||||||
토큰의 SHA-256 해시값 생성
|
토큰의 SHA-256 해시값 생성
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ async def autotag_images(image_url_list : list[str]) -> list[dict]: #tag_list
|
||||||
|
|
||||||
image_result_tasks = [chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_input_data['img_url'], False, silent = True) for image_input_data in image_input_data_list]
|
image_result_tasks = [chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_input_data['img_url'], False, silent = True) for image_input_data in image_input_data_list]
|
||||||
image_result_list: list[BaseModel | BaseException] = await asyncio.gather(*image_result_tasks, return_exceptions=True)
|
image_result_list: list[BaseModel | BaseException] = await asyncio.gather(*image_result_tasks, return_exceptions=True)
|
||||||
MAX_RETRY = 2 # 하드코딩, 어떻게 처리할지는 나중에
|
MAX_RETRY = 3 # 하드코딩, 어떻게 처리할지는 나중에
|
||||||
for _ in range(MAX_RETRY):
|
for _ in range(MAX_RETRY):
|
||||||
failed_idx = [i for i, r in enumerate(image_result_list) if isinstance(r, Exception)]
|
failed_idx = [i for i, r in enumerate(image_result_list) if isinstance(r, Exception)]
|
||||||
print("Failed", failed_idx)
|
print("Failed", failed_idx)
|
||||||
|
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
"""
|
|
||||||
BGM 모드용 더미 가사 템플릿
|
|
||||||
|
|
||||||
instrumental=True 호출 시 Suno가 가사 길이/구조를 참고해 60초짜리 BGM을 생성하도록
|
|
||||||
placeholder 가사를 제공합니다. 실제 보컬은 생성되지 않습니다.
|
|
||||||
|
|
||||||
3가지 버전 모두 섹션 태그 없이 한국어 9줄로 통일.
|
|
||||||
분위기(밝음/감성/에너지)만 가사 텍스트로 차별화합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import random
|
|
||||||
|
|
||||||
_BGM_DUMMY_LYRICS_LIST = [
|
|
||||||
# 버전 1 — 밝고 경쾌한 분위기
|
|
||||||
(
|
|
||||||
"햇살 가득한 아침이 시작되고\n"
|
|
||||||
"따스한 바람이 살며시 불어와\n"
|
|
||||||
"거리마다 웃음꽃이 피어나고\n"
|
|
||||||
"오늘도 설레는 하루가 열려\n"
|
|
||||||
"가볍게 발걸음을 내딛으며\n"
|
|
||||||
"환한 빛 속으로 걸어가는 길\n"
|
|
||||||
"두근두근 설레는 이 순간을\n"
|
|
||||||
"온 마음 가득 담아 느껴봐\n"
|
|
||||||
"오늘 하루도 빛나는 하루야\n"
|
|
||||||
"환한 미소로 하루를 마무리해\n"
|
|
||||||
),
|
|
||||||
# 버전 2 — 잔잔하고 감성적인 분위기
|
|
||||||
(
|
|
||||||
"저녁 노을이 물드는 창가에서\n"
|
|
||||||
"조용히 흘러가는 시간 속에\n"
|
|
||||||
"잔잔한 바람이 마음을 적시고\n"
|
|
||||||
"기억 속 풍경이 스쳐 지나가\n"
|
|
||||||
"부드럽게 감기는 이 느낌처럼\n"
|
|
||||||
"천천히 숨을 고르며 머물러\n"
|
|
||||||
"마음 깊은 곳에 스며드는 온기\n"
|
|
||||||
"조용히 눈을 감고 느껴봐\n"
|
|
||||||
"이 순간 여기 머무는 것만으로도 충분해\n"
|
|
||||||
"고요한 밤이 나를 감싸 안아줘\n"
|
|
||||||
),
|
|
||||||
# 버전 3 — 강렬하고 에너지 넘치는 분위기
|
|
||||||
(
|
|
||||||
"밤거리에 불빛이 타오르고\n"
|
|
||||||
"심장이 두근두근 뛰기 시작해\n"
|
|
||||||
"온몸에 퍼지는 뜨거운 열기\n"
|
|
||||||
"멈출 수 없는 이 흐름 속으로\n"
|
|
||||||
"있는 힘껏 달려가는 이 순간\n"
|
|
||||||
"모든 걸 내려놓고 느껴봐\n"
|
|
||||||
"짜릿하게 타오르는 지금 이 밤\n"
|
|
||||||
"온 세상이 하나로 움직여\n"
|
|
||||||
"끝까지 불태워 이 에너지를\n"
|
|
||||||
"새벽빛이 밝아올 때까지 달려\n"
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def get_random_bgm_lyrics() -> tuple[str, int]:
|
|
||||||
"""BGM 더미 가사 3종 중 하나를 랜덤으로 반환합니다.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(lyrics, version): 선택된 가사 텍스트와 버전 번호 (1~3)
|
|
||||||
"""
|
|
||||||
index = random.randrange(len(_BGM_DUMMY_LYRICS_LIST))
|
|
||||||
return _BGM_DUMMY_LYRICS_LIST[index], index + 1
|
|
||||||
|
|
@ -19,16 +19,12 @@ Note:
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import re
|
|
||||||
from typing import Any, Optional, Type
|
from typing import Any, Optional, Type
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
||||||
def normalize_location(name: str) -> str:
|
|
||||||
return re.sub(r'(특별시|광역시|특별자치시|특별자치도|시|군|구|도)$', '', name)
|
|
||||||
|
|
||||||
def _generate_uuid7_string() -> str:
|
def _generate_uuid7_string() -> str:
|
||||||
"""UUID7 문자열을 생성합니다.
|
"""UUID7 문자열을 생성합니다.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,6 @@ import httpx
|
||||||
|
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from app.utils.prompts.schemas.image import SpaceType,Subject,Camera,MotionRecommended,NarrativePhase
|
from app.utils.prompts.schemas.image import SpaceType,Subject,Camera,MotionRecommended,NarrativePhase
|
||||||
from app.utils.common import normalize_location
|
|
||||||
|
|
||||||
from config import apikey_settings, creatomate_settings, recovery_settings
|
from config import apikey_settings, creatomate_settings, recovery_settings
|
||||||
|
|
||||||
# 로거 설정
|
# 로거 설정
|
||||||
|
|
@ -463,7 +461,6 @@ 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()):
|
||||||
|
|
@ -773,9 +770,7 @@ class CreatomateService:
|
||||||
if "animations" not in elem:
|
if "animations" not in elem:
|
||||||
continue
|
continue
|
||||||
for animation in elem["animations"]:
|
for animation in elem["animations"]:
|
||||||
if "reversed" in animation:
|
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
|
||||||
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:
|
||||||
|
|
@ -871,19 +866,3 @@ class CreatomateService:
|
||||||
logger.error("this template does not have same amount of keyword and subtitle.")
|
logger.error("this template does not have same amount of keyword and subtitle.")
|
||||||
pitching_list = keyword_list + subtitle_list
|
pitching_list = keyword_list + subtitle_list
|
||||||
return pitching_list
|
return pitching_list
|
||||||
|
|
||||||
|
|
||||||
def make_thumbnail_modification(self, brand_name : str, region : str, brand_concept : str, category_definition : str, target_keywords : list[str]):
|
|
||||||
|
|
||||||
len_keywords = len(target_keywords) if len(target_keywords) < 3 else 3
|
|
||||||
|
|
||||||
hashtaged_target_keywords = [f"#{tk}" for tk in target_keywords[len_keywords]]
|
|
||||||
|
|
||||||
mod_dict = {
|
|
||||||
"thumb-hashtag-primary" : ' '.join(hashtaged_target_keywords),
|
|
||||||
"thumb-brand-wordmark" : brand_name,
|
|
||||||
"thumb-subheadline-selling_point" : f"{brand_name} · {normalize_location(region)}",
|
|
||||||
"thumb-headline-hook_claim-aspirational" : brand_concept,
|
|
||||||
"thumb-badge-category" : category_definition,
|
|
||||||
}
|
|
||||||
return mod_dict
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import re
|
|
||||||
from html import unescape
|
|
||||||
from difflib import SequenceMatcher
|
|
||||||
from playwright.async_api import async_playwright
|
from playwright.async_api import async_playwright
|
||||||
from urllib import parse
|
from urllib import parse
|
||||||
import time
|
import time
|
||||||
|
|
@ -98,156 +95,57 @@ patchedGetter.toString();''')
|
||||||
page = self.page
|
page = self.page
|
||||||
await page.goto(url, wait_until=wait_until, timeout=timeout)
|
await page.goto(url, wait_until=wait_until, timeout=timeout)
|
||||||
|
|
||||||
@staticmethod
|
async def get_place_id_url(self, selected):
|
||||||
def _clean_title(text: str) -> str:
|
count = 0
|
||||||
text = unescape(text) # HTML 엔티티 디코딩 (& → &)
|
get_place_id_url_start = time.perf_counter()
|
||||||
text = re.sub(r"<.*?>", "", text) # HTML 태그 제거
|
while (count <= self._max_retry):
|
||||||
return text.strip()
|
title = selected['title'].replace("<b>", "").replace("</b>", "")
|
||||||
|
address = selected.get('roadAddress', selected['address']).replace("<b>", "").replace("</b>", "")
|
||||||
@staticmethod
|
encoded_query = parse.quote(f"{address} {title}")
|
||||||
def _similarity(a: str, b: str) -> float:
|
|
||||||
return SequenceMatcher(None, a, b).ratio()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _refine_address(address: str) -> str:
|
|
||||||
"""한국 주소 패턴에서 첫 번째 유효한 주소만 추출한다."""
|
|
||||||
patterns = [
|
|
||||||
# 도로명 (정식): 경기도 가평군 운악로 278
|
|
||||||
re.compile(
|
|
||||||
r'[가-힣]+(?:특별시|광역시|특별자치시|도|특별자치도|시)\s+'
|
|
||||||
r'[가-힣\s]+?(?:로|길|대로)\s+\d+(?:-\d+)?'
|
|
||||||
),
|
|
||||||
# 지번 (정식): 경기도 가평군 조종면 운악리 278
|
|
||||||
re.compile(
|
|
||||||
r'[가-힣]+(?:특별시|광역시|특별자치시|도|특별자치도|시)\s+'
|
|
||||||
r'[가-힣\s]+?(?:읍|면|동|리|가)\s+\d+(?:-\d+)?'
|
|
||||||
),
|
|
||||||
# 도로명 (축약): 경기 가평 운악로 278
|
|
||||||
re.compile(
|
|
||||||
r'[가-힣]{1,4}\s+[가-힣]{1,6}\s+'
|
|
||||||
r'[가-힣\s]+?(?:로|길|대로)\s+\d+(?:-\d+)?'
|
|
||||||
),
|
|
||||||
# 지번 (축약): 경기 가평 조종면 운악리 278
|
|
||||||
re.compile(
|
|
||||||
r'[가-힣]{1,4}\s+[가-힣]{1,6}\s+'
|
|
||||||
r'[가-힣\s]+?(?:읍|면|동|리|가)\s+\d+(?:-\d+)?'
|
|
||||||
),
|
|
||||||
]
|
|
||||||
for pattern in patterns:
|
|
||||||
m = pattern.search(address)
|
|
||||||
if m:
|
|
||||||
return m.group().strip()
|
|
||||||
return address
|
|
||||||
|
|
||||||
async def _extract_candidates_from_list_page(self) -> list[dict]:
|
|
||||||
"""pcmap.place.naver.com iframe HTML에서 place ID와 업체명을 추출한다."""
|
|
||||||
pcmap_frame = None
|
|
||||||
for frame in self.page.frames:
|
|
||||||
if "pcmap.place.naver.com" in frame.url:
|
|
||||||
pcmap_frame = frame
|
|
||||||
logger.debug(f"[DEBUG] pcmap frame 발견: {frame.url[:80]}")
|
|
||||||
break
|
|
||||||
|
|
||||||
if not pcmap_frame:
|
|
||||||
logger.debug("[DEBUG] pcmap frame 없음")
|
|
||||||
return []
|
|
||||||
|
|
||||||
try:
|
|
||||||
html = await pcmap_frame.content()
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"[DEBUG] pcmap frame content 추출 실패: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
# {"id":"11659052","name":"프레지던트 호텔",...} 형태의 JSON 쌍 추출
|
|
||||||
pair_pattern = re.compile(
|
|
||||||
r'"id"\s*:\s*"(\d{5,})"[^}]{0,200}?"name"\s*:\s*"([^"]{1,60})"'
|
|
||||||
r'|"name"\s*:\s*"([^"]{1,60})"[^}]{0,200}?"id"\s*:\s*"(\d{5,})"'
|
|
||||||
)
|
|
||||||
|
|
||||||
seen = {} # place_id → title (순서 보존)
|
|
||||||
for m in pair_pattern.finditer(html):
|
|
||||||
if m.group(1): # id 먼저
|
|
||||||
pid, title = m.group(1), m.group(2)
|
|
||||||
else: # name 먼저
|
|
||||||
pid, title = m.group(4), m.group(3)
|
|
||||||
if pid not in seen:
|
|
||||||
seen[pid] = title
|
|
||||||
|
|
||||||
candidates = [
|
|
||||||
{"title": title, "place_url": f"https://map.naver.com/p/entry/place/{pid}"}
|
|
||||||
for pid, title in list(seen.items())[:10]
|
|
||||||
]
|
|
||||||
|
|
||||||
for i, c in enumerate(candidates):
|
|
||||||
logger.debug(f"[DEBUG] 후보 {i+1}: {c['title']} / {c['place_url']}")
|
|
||||||
|
|
||||||
logger.debug(f"[DEBUG] 목록 후보 {len(candidates)}개 추출")
|
|
||||||
return candidates
|
|
||||||
|
|
||||||
async def _try_search(self, address: str, title: str) -> str | None:
|
|
||||||
"""주어진 주소+업체명으로 검색해서 place URL을 반환한다. 실패 시 None."""
|
|
||||||
encoded_query = parse.quote(f"{address} {title}".strip())
|
|
||||||
url = f"https://map.naver.com/p/search/{encoded_query}"
|
url = f"https://map.naver.com/p/search/{encoded_query}"
|
||||||
|
|
||||||
|
wait_first_start = time.perf_counter()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self.goto_url(url, wait_until="networkidle",timeout = self._timeout*1000)
|
await self.goto_url(url, wait_until="networkidle",timeout = self._timeout*1000)
|
||||||
except:
|
except:
|
||||||
if "/place/" in self.page.url:
|
if "/place/" in self.page.url:
|
||||||
return self.page.url
|
return self.page.url
|
||||||
logger.error("[ERROR] Can't Finish networkidle")
|
logger.error(f"[ERROR] Can't Finish networkidle")
|
||||||
|
|
||||||
|
|
||||||
|
wait_first_time = (time.perf_counter() - wait_first_start) * 1000
|
||||||
|
|
||||||
|
logger.debug(f"[DEBUG] Try {count+1} : Wait for perfect matching : {wait_first_time}ms")
|
||||||
|
|
||||||
if "/place/" in self.page.url:
|
if "/place/" in self.page.url:
|
||||||
return self.page.url
|
return self.page.url
|
||||||
|
|
||||||
candidates = await self._extract_candidates_from_list_page()
|
|
||||||
if candidates:
|
|
||||||
best = max(
|
|
||||||
candidates,
|
|
||||||
key=lambda c: self._similarity(title, self._clean_title(c['title']))
|
|
||||||
)
|
|
||||||
best_score = self._similarity(title, self._clean_title(best['title']))
|
|
||||||
logger.info(
|
|
||||||
f"[AUTO-SELECT] '{title}' → '{best['title']}' (score={best_score:.2f}) {best['place_url']}"
|
|
||||||
)
|
|
||||||
return best['place_url']
|
|
||||||
|
|
||||||
# isCorrectAnswer=true 로 강제 단일결과 재시도 (원본 로직 유지)
|
logger.debug(f"[DEBUG] Try {count+1} : url place id not found, retry for forced collect answer")
|
||||||
correct_url = self.page.url.replace("?", "?isCorrectAnswer=true&")
|
wait_forced_correct_start = time.perf_counter()
|
||||||
|
|
||||||
|
url = self.page.url.replace("?","?isCorrectAnswer=true&")
|
||||||
try:
|
try:
|
||||||
await self.goto_url(correct_url, wait_until="networkidle", timeout=self._timeout * 1000)
|
await self.goto_url(url, wait_until="networkidle",timeout = self._timeout*1000)
|
||||||
except:
|
except:
|
||||||
if "/place/" in self.page.url:
|
if "/place/" in self.page.url:
|
||||||
return self.page.url
|
return self.page.url
|
||||||
logger.error("[ERROR] Can't Finish networkidle (isCorrectAnswer)")
|
logger.error(f"[ERROR] Can't Finish networkidle")
|
||||||
|
|
||||||
|
wait_forced_correct_time = (time.perf_counter() - wait_forced_correct_start) * 1000
|
||||||
|
logger.debug(f"[DEBUG] Try {count+1} : Wait for forced isCorrectAnswer flag : {wait_forced_correct_time}ms")
|
||||||
|
|
||||||
if "/place/" in self.page.url:
|
if "/place/" in self.page.url:
|
||||||
return self.page.url
|
return self.page.url
|
||||||
|
count += 1
|
||||||
|
|
||||||
return None
|
logger.error("[ERROR] Not found url for {selected}")
|
||||||
|
|
||||||
async def get_place_id_url(self, selected):
|
return None # 404
|
||||||
title = self._clean_title(selected['title'])
|
|
||||||
address = self._clean_title(selected.get('roadAddress', selected['address']))
|
|
||||||
|
|
||||||
# 1차 시도: 원본 주소 + 업체명
|
|
||||||
logger.debug(f"[DEBUG] 1차 시도 - address: {address}")
|
|
||||||
result = await self._try_search(address, title)
|
|
||||||
if result:
|
|
||||||
return result
|
|
||||||
|
|
||||||
# 2차 시도: 정제 주소 + 업체명
|
# if (count == self._max_retry / 2):
|
||||||
refined = self._refine_address(address)
|
# raise Exception("Failed to identify place id. loading timeout")
|
||||||
if refined != address:
|
# else:
|
||||||
logger.info(f"[REFINE] 주소 정제: '{address}' → '{refined}'")
|
# raise Exception("Failed to identify place id. item is ambiguous")
|
||||||
result = await self._try_search(refined, title)
|
|
||||||
if result:
|
|
||||||
return result
|
|
||||||
|
|
||||||
# 3차 시도: 업체명만으로 검색
|
|
||||||
logger.info(f"[RETRY] 업체명만으로 재시도: '{title}'")
|
|
||||||
result = await self._try_search("", title)
|
|
||||||
if result:
|
|
||||||
return result
|
|
||||||
|
|
||||||
logger.error(f"[ERROR] Not found url for {selected}")
|
|
||||||
return None
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@ from functools import lru_cache
|
||||||
logger = get_logger("prompt")
|
logger = get_logger("prompt")
|
||||||
|
|
||||||
_SCOPES = [
|
_SCOPES = [
|
||||||
"https://www.googleapis.com/auth/spreadsheets.readonly"
|
"https://www.googleapis.com/auth/spreadsheets.readonly",
|
||||||
|
"https://www.googleapis.com/auth/drive.readonly"
|
||||||
]
|
]
|
||||||
|
|
||||||
class Prompt():
|
class Prompt():
|
||||||
|
|
|
||||||
|
|
@ -10,20 +10,11 @@ from app.utils.prompts.chatgpt_prompt import ChatgptService
|
||||||
from app.utils.prompts.schemas import *
|
from app.utils.prompts.schemas import *
|
||||||
from app.utils.prompts.prompts import *
|
from app.utils.prompts.prompts import *
|
||||||
|
|
||||||
logger = get_logger("subtitle")
|
|
||||||
|
|
||||||
class SubtitleContentsGenerator():
|
class SubtitleContentsGenerator():
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.chatgpt_service = ChatgptService(timeout=60.0)
|
self.chatgpt_service = ChatgptService()
|
||||||
|
|
||||||
async def generate_subtitle_contents(self, marketing_intelligence : dict[str, Any], pitching_label_list : list[Any], customer_name : str, detail_region_info : str) -> SubtitlePromptOutput:
|
async def generate_subtitle_contents(self, marketing_intelligence : dict[str, Any], pitching_label_list : list[Any], customer_name : str, detail_region_info : str) -> SubtitlePromptOutput:
|
||||||
start = time.perf_counter()
|
|
||||||
logger.info(
|
|
||||||
f"[SubtitleContentsGenerator] START - customer: {customer_name}, "
|
|
||||||
f"pitching_count: {len(pitching_label_list)}, "
|
|
||||||
f"labels: {pitching_label_list}"
|
|
||||||
)
|
|
||||||
|
|
||||||
dynamic_subtitle_prompt = create_dynamic_subtitle_prompt(len(pitching_label_list))
|
dynamic_subtitle_prompt = create_dynamic_subtitle_prompt(len(pitching_label_list))
|
||||||
pitching_label_string = "\n".join(pitching_label_list)
|
pitching_label_string = "\n".join(pitching_label_list)
|
||||||
marketing_intel_string = json.dumps(marketing_intelligence, ensure_ascii=False)
|
marketing_intel_string = json.dumps(marketing_intelligence, ensure_ascii=False)
|
||||||
|
|
@ -33,17 +24,7 @@ class SubtitleContentsGenerator():
|
||||||
"customer_name" : customer_name,
|
"customer_name" : customer_name,
|
||||||
"detail_region_info" : detail_region_info,
|
"detail_region_info" : detail_region_info,
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[SubtitleContentsGenerator] GPT 호출 시작 - model: {dynamic_subtitle_prompt.prompt_model}"
|
|
||||||
)
|
|
||||||
output_data = await self.chatgpt_service.generate_structured_output(dynamic_subtitle_prompt, input_data)
|
output_data = await self.chatgpt_service.generate_structured_output(dynamic_subtitle_prompt, input_data)
|
||||||
|
|
||||||
elapsed = (time.perf_counter() - start) * 1000
|
|
||||||
logger.info(
|
|
||||||
f"[SubtitleContentsGenerator] DONE - 소요시간: {elapsed:.0f}ms, "
|
|
||||||
f"결과: {[r.pitching_tag for r in output_data.pitching_results]}"
|
|
||||||
)
|
|
||||||
return output_data
|
return output_data
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,12 +55,11 @@ generate() 호출 시 callback_url 파라미터를 전달하면 생성 완료
|
||||||
- 동일 task_id에 대해 여러 콜백 수신 가능 (멱등성 처리 필요)
|
- 동일 task_id에 대해 여러 콜백 수신 가능 (멱등성 처리 필요)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.song.schemas.song_schema import PollingSongResponse, SongClipData
|
from app.song.schemas.song_schema import PollingSongResponse, SongClipData
|
||||||
from app.utils.bgm_lyrics import get_random_bgm_lyrics
|
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from config import apikey_settings, recovery_settings
|
from config import apikey_settings, recovery_settings
|
||||||
|
|
||||||
|
|
@ -103,10 +102,9 @@ class SunoService:
|
||||||
|
|
||||||
async def generate(
|
async def generate(
|
||||||
self,
|
self,
|
||||||
prompt: str | None = None,
|
prompt: str,
|
||||||
genre: str | None = None,
|
genre: str | None = None,
|
||||||
callback_url: str | None = None,
|
callback_url: str | None = None,
|
||||||
instrumental: bool = False,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
음악 생성 요청
|
음악 생성 요청
|
||||||
|
|
@ -117,7 +115,6 @@ class SunoService:
|
||||||
genre: 음악 장르 (예: "K-Pop", "Pop", "R&B", "Hip-Hop", "Ballad", "EDM")
|
genre: 음악 장르 (예: "K-Pop", "Pop", "R&B", "Hip-Hop", "Ballad", "EDM")
|
||||||
None일 경우 style 파라미터를 전송하지 않음
|
None일 경우 style 파라미터를 전송하지 않음
|
||||||
callback_url: 생성 완료 시 알림 받을 URL (None일 경우 config에서 기본값 사용)
|
callback_url: 생성 완료 시 알림 받을 URL (None일 경우 config에서 기본값 사용)
|
||||||
instrumental: True이면 BGM 전용 — 더미 가사로 60초 길이를 유도하고 보컬 없이 생성
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
task_id: 작업 추적용 ID
|
task_id: 작업 추적용 ID
|
||||||
|
|
@ -127,26 +124,23 @@ class SunoService:
|
||||||
- 다운로드 URL: 2-3분 내 생성
|
- 다운로드 URL: 2-3분 내 생성
|
||||||
- 생성되는 노래는 약 1분 이내의 길이
|
- 생성되는 노래는 약 1분 이내의 길이
|
||||||
"""
|
"""
|
||||||
actual_callback_url = callback_url or apikey_settings.SUNO_CALLBACK_URL
|
# 정확히 1분 길이의 노래 생성을 위한 프롬프트 조건 추가
|
||||||
|
formatted_prompt = f"[Song Duration: Exactly 1 minute - Must be precisely 60 seconds]\n{prompt}"
|
||||||
|
|
||||||
if instrumental:
|
# callback_url이 없으면 config에서 기본값 사용 (Suno API 필수 파라미터)
|
||||||
bgm_lyrics, bgm_version = get_random_bgm_lyrics()
|
actual_callback_url = callback_url or apikey_settings.SUNO_CALLBACK_URL
|
||||||
formatted_prompt = f"[Song Duration: Around 1 minute - Must be around 60 seconds]\n{bgm_lyrics}"
|
|
||||||
logger.info(f"[Suno] BGM 더미 가사 버전 {bgm_version} 선택됨")
|
|
||||||
else:
|
|
||||||
formatted_prompt = (
|
|
||||||
f"[Song Duration: Exactly 1 minute - Must be precisely 60 seconds]\n{prompt}"
|
|
||||||
)
|
|
||||||
|
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"model": "V5",
|
"model": "V5",
|
||||||
"customMode": True,
|
"customMode": True,
|
||||||
"instrumental": instrumental,
|
"instrumental": False,
|
||||||
"prompt": formatted_prompt,
|
"prompt": formatted_prompt,
|
||||||
"callBackUrl": actual_callback_url,
|
"callBackUrl": actual_callback_url,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# genre가 있을 때만 style 추가
|
||||||
if genre:
|
if genre:
|
||||||
payload["style"] = f"{genre}, around 60 seconds" if instrumental else genre
|
payload["style"] = genre
|
||||||
|
|
||||||
last_error: Exception | None = None
|
last_error: Exception | None = None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
"""
|
|
||||||
내부 전용 좋아요 반응 플러시 API
|
|
||||||
|
|
||||||
스케줄러가 1분마다 호출하여 Redis dirty SET의 좋아요 토글을 MySQL에 bulk write합니다.
|
|
||||||
X-Internal-Secret 헤더로 인증합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
|
||||||
from sqlalchemy import delete, insert, tuple_
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.database.like_cache import (
|
|
||||||
commit_dirty_processing,
|
|
||||||
drain_dirty,
|
|
||||||
is_user_liked,
|
|
||||||
)
|
|
||||||
from app.database.session import get_session
|
|
||||||
from app.video.models import VideoReaction
|
|
||||||
from config import internal_settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/internal/video", tags=["Internal"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/reactions/flush",
|
|
||||||
summary="[내부] 좋아요 반응 DB 플러시",
|
|
||||||
description="스케줄러 서버에서 1분마다 호출하는 내부 전용 엔드포인트입니다. "
|
|
||||||
"Redis dirty SET의 항목을 MySQL video_reaction 테이블에 bulk write합니다.",
|
|
||||||
)
|
|
||||||
async def flush_reactions(
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
x_internal_secret: str = Header(...),
|
|
||||||
) -> dict:
|
|
||||||
"""Redis dirty SET → MySQL bulk write.
|
|
||||||
|
|
||||||
1. drain_dirty(): dirty SET을 processing으로 RENAME 후 항목 조회
|
|
||||||
2. 각 항목의 현재 Redis 상태(is_liked) 확인
|
|
||||||
3. is_liked=True → INSERT IGNORE, is_liked=False → DELETE
|
|
||||||
4. commit_dirty_processing(): processing SET 삭제
|
|
||||||
"""
|
|
||||||
if x_internal_secret != internal_settings.INTERNAL_SECRET_KEY:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Invalid internal secret",
|
|
||||||
)
|
|
||||||
|
|
||||||
pairs = await drain_dirty()
|
|
||||||
if not pairs:
|
|
||||||
logger.info("[REACTION_FLUSH] dirty 항목 없음, 종료")
|
|
||||||
return {"flushed": 0, "adds": 0, "dels": 0}
|
|
||||||
|
|
||||||
logger.info(f"[REACTION_FLUSH] START - dirty 항목 {len(pairs)}건")
|
|
||||||
|
|
||||||
adds: list[dict] = []
|
|
||||||
dels: list[tuple[int, str]] = []
|
|
||||||
|
|
||||||
# Redis 현재 상태 기준으로 add / delete 분류
|
|
||||||
for video_id, user_uuid in pairs:
|
|
||||||
liked = await is_user_liked(video_id, user_uuid)
|
|
||||||
if liked:
|
|
||||||
adds.append({"video_id": video_id, "user_uuid": user_uuid})
|
|
||||||
else:
|
|
||||||
dels.append((video_id, user_uuid))
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Bulk INSERT IGNORE — UniqueConstraint 보장으로 멱등 처리
|
|
||||||
if adds:
|
|
||||||
await session.execute(
|
|
||||||
insert(VideoReaction).prefix_with("IGNORE").values(adds)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Bulk DELETE
|
|
||||||
if dels:
|
|
||||||
await session.execute(
|
|
||||||
delete(VideoReaction).where(
|
|
||||||
tuple_(
|
|
||||||
VideoReaction.video_id,
|
|
||||||
VideoReaction.user_uuid,
|
|
||||||
).in_(dels)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
await commit_dirty_processing()
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[REACTION_FLUSH] SUCCESS - adds: {len(adds)}, dels: {len(dels)}"
|
|
||||||
)
|
|
||||||
return {"flushed": len(pairs), "adds": len(adds), "dels": len(dels)}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
await session.rollback()
|
|
||||||
logger.error(f"[REACTION_FLUSH] EXCEPTION - error: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=f"플러시 실패: {str(e)}")
|
|
||||||
|
|
@ -14,48 +14,29 @@ Video API Router
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from collections import defaultdict
|
import asyncio
|
||||||
|
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database.session import get_session
|
from app.database.session import get_session
|
||||||
from app.dependencies.pagination import PaginationParams, get_pagination_params
|
from app.user.dependencies.auth import get_current_user
|
||||||
from app.user.dependencies.auth import get_current_user, get_current_user_optional
|
|
||||||
from app.user.models import User
|
from app.user.models import User
|
||||||
from app.utils.pagination import PaginatedResponse
|
|
||||||
from app.home.models import Image, Project, MarketingIntel, ImageTag
|
from app.home.models import Image, Project, MarketingIntel, ImageTag
|
||||||
from app.home.api.routers.v1.home import _extract_region_from_address
|
|
||||||
from app.lyric.models import Lyric
|
from app.lyric.models import Lyric
|
||||||
from app.song.models import Song, SongTimestamp
|
from app.song.models import Song, SongTimestamp
|
||||||
from app.utils.creatomate import CreatomateService
|
from app.utils.creatomate import CreatomateService
|
||||||
from app.utils.subtitles import SubtitleContentsGenerator
|
from app.utils.subtitles import SubtitleContentsGenerator
|
||||||
|
|
||||||
from app.comment.models import Comment
|
|
||||||
from app.database.like_cache import (
|
|
||||||
backfill_user_set,
|
|
||||||
bulk_is_user_liked,
|
|
||||||
get_like_count,
|
|
||||||
get_like_counts,
|
|
||||||
is_user_liked,
|
|
||||||
is_user_set_exists,
|
|
||||||
mark_dirty,
|
|
||||||
mset_like_counts,
|
|
||||||
set_like_count,
|
|
||||||
toggle_like_atomic,
|
|
||||||
)
|
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from app.video.models import Video, VideoReaction
|
from app.video.models import Video
|
||||||
from app.video.schemas.video_schema import (
|
from app.video.schemas.video_schema import (
|
||||||
DownloadVideoResponse,
|
DownloadVideoResponse,
|
||||||
GenerateVideoResponse,
|
GenerateVideoResponse,
|
||||||
LikeToggleResponse,
|
|
||||||
PollingVideoResponse,
|
PollingVideoResponse,
|
||||||
VideoDetailResponse,
|
|
||||||
VideoRenderData,
|
VideoRenderData,
|
||||||
VideoThumbnailItem,
|
|
||||||
)
|
)
|
||||||
from app.video.worker.video_task import download_and_upload_video_to_blob
|
from app.video.worker.video_task import download_and_upload_video_to_blob
|
||||||
from app.video.services.video import get_image_tags_by_task_id
|
from app.video.services.video import get_image_tags_by_task_id
|
||||||
|
|
@ -167,9 +148,37 @@ async def generate_video(
|
||||||
image_urls: list[str] = []
|
image_urls: list[str] = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
subtitle_done = False
|
||||||
|
count = 0
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
project_result = await session.execute(
|
||||||
|
select(Project)
|
||||||
|
.where(Project.task_id == task_id)
|
||||||
|
.order_by(Project.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
project = project_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
while not subtitle_done:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
logger.info(f"[generate_video] Checking subtitle- task_id: {task_id}, count : {count}")
|
||||||
|
marketing_result = await session.execute(
|
||||||
|
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
|
||||||
|
)
|
||||||
|
marketing_intelligence = marketing_result.scalar_one_or_none()
|
||||||
|
subtitle_done = bool(marketing_intelligence.subtitle)
|
||||||
|
if subtitle_done:
|
||||||
|
logger.info(f"[generate_video] Check subtitle done task_id: {task_id}")
|
||||||
|
break
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
if count > 60 :
|
||||||
|
raise Exception("subtitle 결과 생성 실패")
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
|
||||||
# 세션을 명시적으로 열고 DB 작업 후 바로 닫음
|
# 세션을 명시적으로 열고 DB 작업 후 바로 닫음
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
# ===== 순차 쿼리 실행: Project, MarketingIntel, Lyric, Song, Image =====
|
# ===== 순차 쿼리 실행: Project, Lyric, Song, Image =====
|
||||||
# Note: AsyncSession은 동일 세션에서 병렬 쿼리를 지원하지 않음
|
# Note: AsyncSession은 동일 세션에서 병렬 쿼리를 지원하지 않음
|
||||||
|
|
||||||
# Project 조회
|
# Project 조회
|
||||||
|
|
@ -179,44 +188,6 @@ async def generate_video(
|
||||||
.order_by(Project.created_at.desc())
|
.order_by(Project.created_at.desc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
project = project_result.scalar_one_or_none()
|
|
||||||
if not project:
|
|
||||||
logger.warning(f"[generate_video] Project NOT FOUND - task_id: {task_id}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.",
|
|
||||||
)
|
|
||||||
project_id = project.id
|
|
||||||
store_address = project.detail_region_info
|
|
||||||
brand_name = project.store_name
|
|
||||||
region = project.region
|
|
||||||
|
|
||||||
# MarketingIntel 조회
|
|
||||||
marketing_result = await session.execute(
|
|
||||||
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
|
|
||||||
)
|
|
||||||
marketing_intelligence: MarketingIntel = marketing_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
# subtitle 미완료 시 즉시 반환 — Lyric/Song/Image 쿼리 전에 체크하여 불필요한 조회 방지
|
|
||||||
# 클라이언트가 /lyric/subtitle/status/{task_id} 폴링 후 재시도
|
|
||||||
if not marketing_intelligence.subtitle:
|
|
||||||
logger.info(f"[generate_video] subtitle pending - task_id: {task_id}")
|
|
||||||
return GenerateVideoResponse(
|
|
||||||
success=False,
|
|
||||||
status="subtitle_pending",
|
|
||||||
task_id=task_id,
|
|
||||||
creatomate_render_id=None,
|
|
||||||
message="자막 생성이 아직 완료되지 않았습니다. /lyric/subtitle/status/{task_id}로 완료 확인 후 재요청하세요.",
|
|
||||||
error_message=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
category_definition = marketing_intelligence.intel_result["market_positioning"]["category_definition"]
|
|
||||||
target_keywords = marketing_intelligence.intel_result["target_keywords"]
|
|
||||||
|
|
||||||
brand_concept = ""
|
|
||||||
for sp in marketing_intelligence.intel_result["selling_points"]:
|
|
||||||
if "concept" in sp["english_category"].lower():
|
|
||||||
brand_concept = sp["description"]
|
|
||||||
|
|
||||||
# Lyric 조회
|
# Lyric 조회
|
||||||
lyric_result = await session.execute(
|
lyric_result = await session.execute(
|
||||||
|
|
@ -247,6 +218,25 @@ async def generate_video(
|
||||||
f"elapsed: {(query_time - request_start) * 1000:.1f}ms"
|
f"elapsed: {(query_time - request_start) * 1000:.1f}ms"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ===== 결과 처리: Project =====
|
||||||
|
project = project_result.scalar_one_or_none()
|
||||||
|
if not project:
|
||||||
|
logger.warning(
|
||||||
|
f"[generate_video] Project NOT FOUND - task_id: {task_id}"
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.",
|
||||||
|
)
|
||||||
|
project_id = project.id
|
||||||
|
store_address = project.detail_region_info
|
||||||
|
# customer_name = project.store_name
|
||||||
|
|
||||||
|
marketing_result = await session.execute(
|
||||||
|
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
|
||||||
|
)
|
||||||
|
marketing_intelligence = marketing_result.scalar_one_or_none()
|
||||||
|
|
||||||
# ===== 결과 처리: Lyric =====
|
# ===== 결과 처리: Lyric =====
|
||||||
lyric = lyric_result.scalar_one_or_none()
|
lyric = lyric_result.scalar_one_or_none()
|
||||||
if not lyric:
|
if not lyric:
|
||||||
|
|
@ -294,19 +284,10 @@ async def generate_video(
|
||||||
)
|
)
|
||||||
image_urls = [img.img_url for img in images]
|
image_urls = [img.img_url for img in images]
|
||||||
|
|
||||||
# SongTimestamp 조회 (외부 API 호출 전 필요한 데이터이므로 1단계에서 수집)
|
|
||||||
song_timestamp_result = await session.execute(
|
|
||||||
select(SongTimestamp).where(
|
|
||||||
SongTimestamp.suno_audio_id == song.suno_audio_id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
song_timestamp_list = song_timestamp_result.scalars().all()
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[generate_video] Data loaded - task_id: {task_id}, "
|
f"[generate_video] Data loaded - task_id: {task_id}, "
|
||||||
f"project_id: {project_id}, lyric_id: {lyric_id}, "
|
f"project_id: {project_id}, lyric_id: {lyric_id}, "
|
||||||
f"song_id: {song_id}, images: {len(image_urls)}, "
|
f"song_id: {song_id}, images: {len(image_urls)}"
|
||||||
f"timestamps: {len(song_timestamp_list)}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# ===== Video 테이블에 초기 데이터 저장 및 커밋 =====
|
# ===== Video 테이블에 초기 데이터 저장 및 커밋 =====
|
||||||
|
|
@ -387,17 +368,6 @@ async def generate_video(
|
||||||
subtitle_modifications = marketing_intelligence.subtitle
|
subtitle_modifications = marketing_intelligence.subtitle
|
||||||
|
|
||||||
modifications.update(subtitle_modifications)
|
modifications.update(subtitle_modifications)
|
||||||
|
|
||||||
# revert thumbnail scene
|
|
||||||
# thumbnail_modifications = creatomate_service.make_thumbnail_modification(
|
|
||||||
# brand_name =brand_name,
|
|
||||||
# region = region,
|
|
||||||
# brand_concept = brand_concept,
|
|
||||||
# category_definition= category_definition,
|
|
||||||
# target_keywords=target_keywords)
|
|
||||||
|
|
||||||
# modifications.update(thumbnail_modifications)
|
|
||||||
|
|
||||||
# 6-3. elements 수정
|
# 6-3. elements 수정
|
||||||
new_elements = creatomate_service.modify_element(
|
new_elements = creatomate_service.modify_element(
|
||||||
template["source"]["elements"],
|
template["source"]["elements"],
|
||||||
|
|
@ -416,6 +386,13 @@ async def generate_video(
|
||||||
|
|
||||||
logger.debug(f"[generate_video] Duration extended - task_id: {task_id}")
|
logger.debug(f"[generate_video] Duration extended - task_id: {task_id}")
|
||||||
|
|
||||||
|
song_timestamp_result = await session.execute(
|
||||||
|
select(SongTimestamp).where(
|
||||||
|
SongTimestamp.suno_audio_id == song.suno_audio_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
song_timestamp_list = song_timestamp_result.scalars().all()
|
||||||
|
|
||||||
logger.debug(f"[generate_video] song_timestamp_list count: {len(song_timestamp_list)}")
|
logger.debug(f"[generate_video] song_timestamp_list count: {len(song_timestamp_list)}")
|
||||||
|
|
||||||
for i, ts in enumerate(song_timestamp_list):
|
for i, ts in enumerate(song_timestamp_list):
|
||||||
|
|
@ -677,7 +654,7 @@ async def get_video_status(
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
logger.error(
|
logger.error(
|
||||||
f"[get_video_status] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}\n{traceback.format_exc()}"
|
f"[get_video_status] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}"
|
||||||
)
|
)
|
||||||
return PollingVideoResponse(
|
return PollingVideoResponse(
|
||||||
success=False,
|
success=False,
|
||||||
|
|
@ -685,7 +662,7 @@ async def get_video_status(
|
||||||
message="상태 조회에 실패했습니다.",
|
message="상태 조회에 실패했습니다.",
|
||||||
render_data=None,
|
render_data=None,
|
||||||
raw_response=None,
|
raw_response=None,
|
||||||
error_message=f"{type(e).__name__}: {e}",
|
error_message=f"{type(e).__name__}: {e}\n{traceback.format_exc()}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -795,7 +772,7 @@ async def download_video(
|
||||||
status="completed",
|
status="completed",
|
||||||
message="영상 다운로드가 완료되었습니다.",
|
message="영상 다운로드가 완료되었습니다.",
|
||||||
store_name=project.store_name if project else None,
|
store_name=project.store_name if project else None,
|
||||||
region=project.region or _extract_region_from_address(project.detail_region_info) if project else None,
|
region=project.region if project else None,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
result_movie_url=video.result_movie_url,
|
result_movie_url=video.result_movie_url,
|
||||||
created_at=video.created_at,
|
created_at=video.created_at,
|
||||||
|
|
@ -809,353 +786,3 @@ async def download_video(
|
||||||
message="영상 다운로드 조회에 실패했습니다.",
|
message="영상 다운로드 조회에 실패했습니다.",
|
||||||
error_message=str(e),
|
error_message=str(e),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/all",
|
|
||||||
summary="ADO2 콘텐츠 - 전체 사용자 영상 갤러리",
|
|
||||||
description="""
|
|
||||||
## 개요
|
|
||||||
모든 사용자가 생성 완료한 영상을 페이지네이션하여 반환합니다.
|
|
||||||
|
|
||||||
## 쿼리 파라미터
|
|
||||||
- **page**: 페이지 번호 (1부터 시작, 기본값: 1)
|
|
||||||
- **page_size**: 페이지당 데이터 수 (기본값: 10, 최대: 100)
|
|
||||||
- **sort_by**: 정렬 기준 (created_at: 최신순, like_count: 좋아요순, comment_count: 댓글순, 기본값: created_at)
|
|
||||||
- **order**: 정렬 방향 (desc: 내림차순, asc: 오름차순, 기본값: desc)
|
|
||||||
- **store_name**: 업체명 검색 (부분 일치, 값이 있을 때만 전송)
|
|
||||||
- **region**: 지역명 검색 (부분 일치, 값이 있을 때만 전송)
|
|
||||||
""",
|
|
||||||
response_model=PaginatedResponse[VideoThumbnailItem],
|
|
||||||
responses={
|
|
||||||
200: {"description": "갤러리 조회 성공"},
|
|
||||||
500: {"description": "조회 실패"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def get_all_videos(
|
|
||||||
current_user: User | None = Depends(get_current_user_optional),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
pagination: PaginationParams = Depends(get_pagination_params),
|
|
||||||
sort_by: str = Query(default="created_at", description="정렬 기준 (created_at, like_count, comment_count)"),
|
|
||||||
order: str = Query(default="desc", description="정렬 방향 (desc, asc)"),
|
|
||||||
store_name: str | None = Query(default=None, description="업체명 검색 (부분 일치)"),
|
|
||||||
region: str | None = Query(default=None, description="지역명 검색 (부분 일치)"),
|
|
||||||
) -> PaginatedResponse[VideoThumbnailItem]:
|
|
||||||
"""전체 사용자의 완료된 영상 갤러리를 반환합니다."""
|
|
||||||
logger.info(
|
|
||||||
f"[get_all_videos] START - page: {pagination.page}, page_size: {pagination.page_size}, "
|
|
||||||
f"sort_by: {sort_by}, order: {order}, store_name: {store_name}, region: {region}"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
offset = (pagination.page - 1) * pagination.page_size
|
|
||||||
|
|
||||||
where_clauses = [
|
|
||||||
Video.status == "completed",
|
|
||||||
Video.is_deleted == False, # noqa: E712
|
|
||||||
Project.is_deleted == False, # noqa: E712
|
|
||||||
Video.result_movie_url.is_not(None),
|
|
||||||
]
|
|
||||||
if store_name:
|
|
||||||
where_clauses.append(Project.store_name.ilike(f"%{store_name}%"))
|
|
||||||
if region:
|
|
||||||
where_clauses.append(
|
|
||||||
or_(
|
|
||||||
Project.region.ilike(f"%{region}%"),
|
|
||||||
Project.detail_region_info.ilike(f"%{region}%"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
count_q = (
|
|
||||||
select(func.count(Video.id))
|
|
||||||
.join(Project, Video.project_id == Project.id)
|
|
||||||
.where(*where_clauses)
|
|
||||||
)
|
|
||||||
total = (await session.execute(count_q)).scalar() or 0
|
|
||||||
|
|
||||||
comment_count_subq = (
|
|
||||||
select(func.count(Comment.id))
|
|
||||||
.where(
|
|
||||||
Comment.video_id == Video.id,
|
|
||||||
Comment.is_deleted == False, # noqa: E712
|
|
||||||
)
|
|
||||||
.correlate(Video)
|
|
||||||
.scalar_subquery()
|
|
||||||
)
|
|
||||||
|
|
||||||
# like_count 정렬은 Redis 대신 서브쿼리로 처리 (ORDER BY에만 사용)
|
|
||||||
like_count_subq_for_sort = (
|
|
||||||
select(func.count(VideoReaction.id))
|
|
||||||
.where(VideoReaction.video_id == Video.id)
|
|
||||||
.correlate(Video)
|
|
||||||
.scalar_subquery()
|
|
||||||
)
|
|
||||||
sort_col_map = {
|
|
||||||
"like_count": like_count_subq_for_sort,
|
|
||||||
"comment_count": comment_count_subq,
|
|
||||||
"created_at": Video.created_at,
|
|
||||||
}
|
|
||||||
sort_col = sort_col_map.get(sort_by, Video.created_at)
|
|
||||||
order_clause = sort_col.asc() if order == "asc" else sort_col.desc()
|
|
||||||
|
|
||||||
list_q = (
|
|
||||||
select(
|
|
||||||
Video,
|
|
||||||
Project,
|
|
||||||
comment_count_subq.label("comment_count"),
|
|
||||||
)
|
|
||||||
.join(Project, Video.project_id == Project.id)
|
|
||||||
.where(*where_clauses)
|
|
||||||
.order_by(order_clause)
|
|
||||||
.offset(offset)
|
|
||||||
.limit(pagination.page_size)
|
|
||||||
)
|
|
||||||
rows = (await session.execute(list_q)).all()
|
|
||||||
|
|
||||||
video_ids = [v.id for v, p, _ in rows]
|
|
||||||
|
|
||||||
# Redis mget으로 like_count 일괄 조회
|
|
||||||
like_count_map = await get_like_counts(video_ids)
|
|
||||||
|
|
||||||
# 카운트 캐시 미스 보정
|
|
||||||
missing_ids = [vid for vid, cnt in like_count_map.items() if cnt is None]
|
|
||||||
if missing_ids:
|
|
||||||
db_counts = (await session.execute(
|
|
||||||
select(VideoReaction.video_id, func.count(VideoReaction.id))
|
|
||||||
.where(VideoReaction.video_id.in_(missing_ids))
|
|
||||||
.group_by(VideoReaction.video_id)
|
|
||||||
)).all()
|
|
||||||
db_found_ids = set()
|
|
||||||
batch = {}
|
|
||||||
for vid, cnt in db_counts:
|
|
||||||
batch[vid] = cnt
|
|
||||||
like_count_map[vid] = cnt
|
|
||||||
db_found_ids.add(vid)
|
|
||||||
await mset_like_counts(batch)
|
|
||||||
for vid in missing_ids:
|
|
||||||
if vid not in db_found_ids:
|
|
||||||
like_count_map[vid] = 0
|
|
||||||
|
|
||||||
# is_liked_by_me: Redis user-set 기준, cold-start 시 DB backfill
|
|
||||||
liked_map: dict[int, bool] = {}
|
|
||||||
if current_user:
|
|
||||||
raw_liked = await bulk_is_user_liked(video_ids, current_user.user_uuid)
|
|
||||||
|
|
||||||
# user-set이 없는(None) 영상 중 count > 0인 것만 backfill 필요
|
|
||||||
needs_backfill = [
|
|
||||||
vid for vid, liked in raw_liked.items()
|
|
||||||
if liked is None and like_count_map.get(vid, 0) > 0
|
|
||||||
]
|
|
||||||
if needs_backfill:
|
|
||||||
reaction_rows = (await session.execute(
|
|
||||||
select(VideoReaction.video_id, VideoReaction.user_uuid)
|
|
||||||
.where(VideoReaction.video_id.in_(needs_backfill))
|
|
||||||
)).all()
|
|
||||||
user_map: dict[int, list[str]] = defaultdict(list)
|
|
||||||
for vid, uuid in reaction_rows:
|
|
||||||
user_map[vid].append(uuid)
|
|
||||||
for vid in needs_backfill:
|
|
||||||
await backfill_user_set(vid, user_map.get(vid, []))
|
|
||||||
|
|
||||||
# backfill 후 재조회
|
|
||||||
updated = await bulk_is_user_liked(needs_backfill, current_user.user_uuid)
|
|
||||||
raw_liked.update(updated)
|
|
||||||
|
|
||||||
liked_map = {vid: bool(liked) for vid, liked in raw_liked.items()}
|
|
||||||
|
|
||||||
items = [
|
|
||||||
VideoThumbnailItem(
|
|
||||||
video_id=v.id,
|
|
||||||
store_name=p.store_name,
|
|
||||||
result_movie_url=v.result_movie_url,
|
|
||||||
created_at=v.created_at,
|
|
||||||
like_count=like_count_map.get(v.id) or 0,
|
|
||||||
is_liked_by_me=liked_map.get(v.id, False),
|
|
||||||
comment_count=comment_count or 0,
|
|
||||||
)
|
|
||||||
for v, p, comment_count in rows
|
|
||||||
]
|
|
||||||
|
|
||||||
response = PaginatedResponse.create(
|
|
||||||
items=items,
|
|
||||||
total=total,
|
|
||||||
page=pagination.page,
|
|
||||||
page_size=pagination.page_size,
|
|
||||||
)
|
|
||||||
logger.info(f"[get_all_videos] SUCCESS - total: {total}, items: {len(items)}")
|
|
||||||
return response
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[get_all_videos] EXCEPTION - error: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=f"갤러리 조회에 실패했습니다: {str(e)}")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/{video_id}/like",
|
|
||||||
summary="영상 좋아요 토글",
|
|
||||||
description="""
|
|
||||||
## 개요
|
|
||||||
영상에 좋아요를 토글합니다. 로그인 필수.
|
|
||||||
|
|
||||||
- 처음 호출: 좋아요 추가 (is_liked=true)
|
|
||||||
- 다시 호출: 좋아요 취소 (is_liked=false)
|
|
||||||
""",
|
|
||||||
response_model=LikeToggleResponse,
|
|
||||||
responses={
|
|
||||||
200: {"description": "토글 성공"},
|
|
||||||
401: {"description": "인증 실패"},
|
|
||||||
404: {"description": "영상을 찾을 수 없음"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def toggle_like(
|
|
||||||
video_id: int,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> LikeToggleResponse:
|
|
||||||
"""영상 좋아요를 토글합니다.
|
|
||||||
|
|
||||||
Write-Behind 패턴:
|
|
||||||
1. Redis user-set / count를 즉시 원자적으로 업데이트 (Lua script)
|
|
||||||
2. dirty SET에 표시 → 스케줄러가 1분마다 MySQL에 반영
|
|
||||||
DB write가 없으므로 고트래픽에서도 응답 지연 없음.
|
|
||||||
"""
|
|
||||||
logger.info(f"[toggle_like] START - video_id: {video_id}, user: {current_user.user_uuid}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 영상 존재 확인 (DB read는 유지 — 404 처리 필수)
|
|
||||||
video_result = await session.execute(
|
|
||||||
select(Video).where(
|
|
||||||
Video.id == video_id,
|
|
||||||
Video.status == "completed",
|
|
||||||
Video.is_deleted == False, # noqa: E712
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if video_result.scalar_one_or_none() is None:
|
|
||||||
raise HTTPException(status_code=404, detail="영상을 찾을 수 없습니다.")
|
|
||||||
|
|
||||||
# Cold-start 보정: Redis에 데이터가 없으면 DB에서 backfill
|
|
||||||
count = await get_like_count(video_id)
|
|
||||||
if count is None:
|
|
||||||
# 카운트와 user-set 모두 없음 → DB에서 전체 복구
|
|
||||||
user_uuids = (await session.execute(
|
|
||||||
select(VideoReaction.user_uuid)
|
|
||||||
.where(VideoReaction.video_id == video_id)
|
|
||||||
)).scalars().all()
|
|
||||||
await backfill_user_set(video_id, list(user_uuids))
|
|
||||||
await set_like_count(video_id, len(user_uuids))
|
|
||||||
elif count > 0:
|
|
||||||
if not await is_user_set_exists(video_id):
|
|
||||||
# 카운트는 있지만 user-set이 증발한 경우 (부분 캐시 미스)
|
|
||||||
user_uuids = (await session.execute(
|
|
||||||
select(VideoReaction.user_uuid)
|
|
||||||
.where(VideoReaction.video_id == video_id)
|
|
||||||
)).scalars().all()
|
|
||||||
await backfill_user_set(video_id, list(user_uuids))
|
|
||||||
|
|
||||||
# Lua 스크립트로 원자적 토글 (race condition 방지)
|
|
||||||
is_liked, like_count = await toggle_like_atomic(video_id, current_user.user_uuid)
|
|
||||||
|
|
||||||
# dirty SET에 표시 → 스케줄러가 DB에 반영
|
|
||||||
await mark_dirty(video_id, current_user.user_uuid)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[toggle_like] SUCCESS - video_id: {video_id}, "
|
|
||||||
f"is_liked: {is_liked}, count: {like_count}"
|
|
||||||
)
|
|
||||||
return LikeToggleResponse(video_id=video_id, is_liked=is_liked, like_count=like_count)
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[toggle_like] EXCEPTION - video_id: {video_id}, error: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=f"좋아요 처리에 실패했습니다: {str(e)}")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/{video_id}",
|
|
||||||
summary="단일 영상 상세 조회",
|
|
||||||
description="""
|
|
||||||
## 개요
|
|
||||||
video_id에 해당하는 완료된 영상의 상세 정보를 반환합니다.
|
|
||||||
|
|
||||||
## 경로 파라미터
|
|
||||||
- **video_id**: 조회할 영상의 ID (Video.id)
|
|
||||||
""",
|
|
||||||
response_model=VideoDetailResponse,
|
|
||||||
responses={
|
|
||||||
200: {"description": "상세 조회 성공"},
|
|
||||||
404: {"description": "영상을 찾을 수 없음"},
|
|
||||||
500: {"description": "조회 실패"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def get_video_detail(
|
|
||||||
video_id: int,
|
|
||||||
current_user: User | None = Depends(get_current_user_optional),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> VideoDetailResponse:
|
|
||||||
"""video_id에 해당하는 완료된 영상 상세 정보를 반환합니다."""
|
|
||||||
logger.info(f"[get_video_detail] START - video_id: {video_id}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await session.execute(
|
|
||||||
select(Video, Project)
|
|
||||||
.join(Project, Video.project_id == Project.id)
|
|
||||||
.where(
|
|
||||||
Video.id == video_id,
|
|
||||||
Video.status == "completed",
|
|
||||||
Video.is_deleted == False, # noqa: E712
|
|
||||||
Project.is_deleted == False, # noqa: E712
|
|
||||||
)
|
|
||||||
)
|
|
||||||
row = result.one_or_none()
|
|
||||||
|
|
||||||
if row is None:
|
|
||||||
logger.warning(f"[get_video_detail] NOT FOUND - video_id: {video_id}")
|
|
||||||
raise HTTPException(status_code=404, detail="영상을 찾을 수 없습니다.")
|
|
||||||
|
|
||||||
video, project = row
|
|
||||||
|
|
||||||
# like_count: Redis 조회, 캐시 미스 시 DB backfill
|
|
||||||
like_count = await get_like_count(video_id)
|
|
||||||
if like_count is None:
|
|
||||||
user_uuids = (await session.execute(
|
|
||||||
select(VideoReaction.user_uuid)
|
|
||||||
.where(VideoReaction.video_id == video_id)
|
|
||||||
)).scalars().all()
|
|
||||||
like_count = len(user_uuids)
|
|
||||||
await backfill_user_set(video_id, list(user_uuids))
|
|
||||||
await set_like_count(video_id, like_count)
|
|
||||||
|
|
||||||
# is_liked_by_me: Redis user-set 기준, cold-start 시 DB backfill
|
|
||||||
is_liked_by_me = False
|
|
||||||
if current_user:
|
|
||||||
liked = await is_user_liked(video_id, current_user.user_uuid)
|
|
||||||
if liked is None:
|
|
||||||
# user-set 없음 → count key로 cold-start 여부 판별
|
|
||||||
if like_count > 0:
|
|
||||||
user_uuids = (await session.execute(
|
|
||||||
select(VideoReaction.user_uuid)
|
|
||||||
.where(VideoReaction.video_id == video_id)
|
|
||||||
)).scalars().all()
|
|
||||||
await backfill_user_set(video_id, list(user_uuids))
|
|
||||||
liked = current_user.user_uuid in set(user_uuids)
|
|
||||||
else:
|
|
||||||
liked = False
|
|
||||||
is_liked_by_me = liked
|
|
||||||
|
|
||||||
logger.info(f"[get_video_detail] SUCCESS - video_id: {video_id}")
|
|
||||||
return VideoDetailResponse(
|
|
||||||
video_id=video.id,
|
|
||||||
result_movie_url=video.result_movie_url,
|
|
||||||
store_name=project.store_name,
|
|
||||||
region=project.region or _extract_region_from_address(project.detail_region_info),
|
|
||||||
created_at=video.created_at,
|
|
||||||
like_count=like_count,
|
|
||||||
is_liked_by_me=is_liked_by_me,
|
|
||||||
)
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[get_video_detail] EXCEPTION - video_id: {video_id}, error: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=f"영상 조회에 실패했습니다: {str(e)}")
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,15 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, List, Optional
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, UniqueConstraint, func
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.database.session import Base
|
from app.database.session import Base
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.comment.models import Comment
|
|
||||||
from app.home.models import Project
|
from app.home.models import Project
|
||||||
from app.lyric.models import Lyric
|
from app.lyric.models import Lyric
|
||||||
from app.song.models import Song
|
from app.song.models import Song
|
||||||
from app.user.models import User
|
|
||||||
|
|
||||||
|
|
||||||
class Video(Base):
|
class Video(Base):
|
||||||
|
|
@ -35,8 +33,6 @@ class Video(Base):
|
||||||
project: 연결된 Project
|
project: 연결된 Project
|
||||||
lyric: 연결된 Lyric
|
lyric: 연결된 Lyric
|
||||||
song: 연결된 Song
|
song: 연결된 Song
|
||||||
comments: 영상 댓글 목록
|
|
||||||
likes: 영상 좋아요 목록
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "video"
|
__tablename__ = "video"
|
||||||
|
|
@ -136,20 +132,6 @@ class Video(Base):
|
||||||
back_populates="videos",
|
back_populates="videos",
|
||||||
)
|
)
|
||||||
|
|
||||||
comments: Mapped[List["Comment"]] = relationship(
|
|
||||||
"Comment",
|
|
||||||
back_populates="video",
|
|
||||||
cascade="all, delete-orphan",
|
|
||||||
lazy="noload",
|
|
||||||
)
|
|
||||||
|
|
||||||
reactions: Mapped[List["VideoReaction"]] = relationship(
|
|
||||||
"VideoReaction",
|
|
||||||
back_populates="video",
|
|
||||||
cascade="all, delete-orphan",
|
|
||||||
lazy="noload",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
def truncate(value: str | None, max_len: int = 10) -> str:
|
def truncate(value: str | None, max_len: int = 10) -> str:
|
||||||
if value is None:
|
if value is None:
|
||||||
|
|
@ -163,50 +145,3 @@ class Video(Base):
|
||||||
f"status='{self.status}'"
|
f"status='{self.status}'"
|
||||||
f")>"
|
f")>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class VideoReaction(Base):
|
|
||||||
"""
|
|
||||||
영상 반응 테이블
|
|
||||||
|
|
||||||
사용자가 영상에 반응(현재는 좋아요)을 남기면 생성, 다시 누르면 삭제(토글).
|
|
||||||
(user_uuid, video_id) 유니크 제약으로 1인 1회 보장.
|
|
||||||
향후 reaction_type 컬럼 추가로 다양한 반응 종류 확장 가능.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "video_reaction"
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint("user_uuid", "video_id", name="uq_video_reaction_user_video"),
|
|
||||||
Index("idx_video_reaction_video_id", "video_id"),
|
|
||||||
Index("idx_video_reaction_user_uuid", "user_uuid"),
|
|
||||||
{
|
|
||||||
"mysql_engine": "InnoDB",
|
|
||||||
"mysql_charset": "utf8mb4",
|
|
||||||
"mysql_collate": "utf8mb4_unicode_ci",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(
|
|
||||||
Integer, primary_key=True, autoincrement=True, comment="고유 식별자"
|
|
||||||
)
|
|
||||||
video_id: Mapped[int] = mapped_column(
|
|
||||||
Integer,
|
|
||||||
ForeignKey("video.id", ondelete="CASCADE"),
|
|
||||||
nullable=False,
|
|
||||||
comment="연결된 Video의 id",
|
|
||||||
)
|
|
||||||
user_uuid: Mapped[str] = mapped_column(
|
|
||||||
String(36),
|
|
||||||
ForeignKey("user.user_uuid", ondelete="CASCADE"),
|
|
||||||
nullable=False,
|
|
||||||
comment="반응한 사용자 UUID",
|
|
||||||
)
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime,
|
|
||||||
nullable=False,
|
|
||||||
server_default=func.now(),
|
|
||||||
comment="반응 일시",
|
|
||||||
)
|
|
||||||
|
|
||||||
video: Mapped["Video"] = relationship("Video", back_populates="reactions")
|
|
||||||
user: Mapped["User"] = relationship("User", back_populates="video_reactions")
|
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@ class GenerateVideoResponse(BaseModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
success: bool = Field(..., description="요청 성공 여부")
|
success: bool = Field(..., description="요청 성공 여부")
|
||||||
status: Optional[str] = Field(None, description="처리 상태 (subtitle_pending: 자막 미완료, completed: 정상 접수)")
|
|
||||||
task_id: Optional[str] = Field(None, description="내부 작업 ID (Project task_id)")
|
task_id: Optional[str] = Field(None, description="내부 작업 ID (Project task_id)")
|
||||||
creatomate_render_id: Optional[str] = Field(None, description="Creatomate 렌더 ID")
|
creatomate_render_id: Optional[str] = Field(None, description="Creatomate 렌더 ID")
|
||||||
message: str = Field(..., description="응답 메시지")
|
message: str = Field(..., description="응답 메시지")
|
||||||
|
|
@ -158,51 +157,5 @@ class VideoListItem(BaseModel):
|
||||||
task_id: str = Field(..., description="작업 고유 식별자")
|
task_id: str = Field(..., description="작업 고유 식별자")
|
||||||
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
|
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
|
||||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||||
like_count: int = Field(0, description="좋아요 수")
|
|
||||||
comment_count: int = Field(0, description="댓글 수 (대댓글 포함)")
|
|
||||||
|
|
||||||
|
|
||||||
class VideoThumbnailItem(BaseModel):
|
|
||||||
"""ADO2 콘텐츠 갤러리용 최소 영상 정보 (썸네일 표시 + 상세 페이지 이동용)
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
GET /video/all 응답의 개별 영상 정보
|
|
||||||
"""
|
|
||||||
|
|
||||||
video_id: int = Field(..., description="영상 고유 ID (상세 페이지 라우팅 키)")
|
|
||||||
store_name: str = Field(..., description="업체명")
|
|
||||||
result_movie_url: str = Field(..., description="영상 URL — 프론트에서 <video> 태그 첫 프레임을 썸네일로 사용")
|
|
||||||
created_at: datetime = Field(..., description="생성 일시")
|
|
||||||
like_count: int = Field(..., description="좋아요 수")
|
|
||||||
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)")
|
|
||||||
comment_count: int = Field(..., description="댓글 수 (대댓글 포함)")
|
|
||||||
|
|
||||||
|
|
||||||
class VideoDetailResponse(BaseModel):
|
|
||||||
"""단일 영상 상세 응답
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
GET /video/{video_id}
|
|
||||||
"""
|
|
||||||
|
|
||||||
video_id: int = Field(..., description="영상 고유 ID")
|
|
||||||
result_movie_url: str = Field(..., description="영상 URL")
|
|
||||||
store_name: Optional[str] = Field(None, description="업체명")
|
|
||||||
region: Optional[str] = Field(None, description="지역명")
|
|
||||||
created_at: datetime = Field(..., description="생성 일시")
|
|
||||||
like_count: int = Field(..., description="좋아요 수")
|
|
||||||
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)")
|
|
||||||
|
|
||||||
|
|
||||||
class LikeToggleResponse(BaseModel):
|
|
||||||
"""좋아요 토글 응답
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
POST /video/{video_id}/like
|
|
||||||
"""
|
|
||||||
|
|
||||||
video_id: int = Field(..., description="영상 고유 ID")
|
|
||||||
is_liked: bool = Field(..., description="토글 후 상태 (true=좋아요 누름, false=취소됨)")
|
|
||||||
like_count: int = Field(..., description="토글 후 전체 좋아요 수")
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -851,7 +851,6 @@ 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}))
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ from sqlalchemy import select
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
from app.database.session import BackgroundSessionLocal
|
from app.database.session import BackgroundSessionLocal
|
||||||
from app.user.services.credit import consume_credit
|
|
||||||
from app.video.models import Video
|
from app.video.models import Video
|
||||||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
from app.utils.upload_blob_as_request import AzureBlobUploader
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
|
|
@ -155,12 +154,6 @@ async def download_and_upload_video_to_blob(
|
||||||
|
|
||||||
# Video 테이블 업데이트 (creatomate_render_id로 특정 Video 식별)
|
# Video 테이블 업데이트 (creatomate_render_id로 특정 Video 식별)
|
||||||
await _update_video_status(task_id, "completed", blob_url, creatomate_render_id)
|
await _update_video_status(task_id, "completed", blob_url, creatomate_render_id)
|
||||||
|
|
||||||
# 영상 생성 완료 시 크레딧 1 차감 (credits > 0 조건으로 음수 방지)
|
|
||||||
async with BackgroundSessionLocal() as session:
|
|
||||||
await consume_credit(user_uuid, session)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
logger.info(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}, creatomate_render_id: {creatomate_render_id}")
|
logger.info(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}, creatomate_render_id: {creatomate_render_id}")
|
||||||
|
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,6 @@ 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,8 +0,0 @@
|
||||||
-- ============================================================
|
|
||||||
-- Migration: user 테이블에 credits 컬럼 추가
|
|
||||||
-- Date: 2026-04-28
|
|
||||||
-- Description: 사용자 크레딧 시스템 도입
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
ALTER TABLE `user`
|
|
||||||
ADD COLUMN credits INT NOT NULL DEFAULT 3 COMMENT '영상 생성 크레딧';
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
-- ============================================================
|
|
||||||
-- 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 '표시 이름',
|
|
||||||
role VARCHAR(20) NOT NULL DEFAULT 'superadmin' COMMENT '권한 (superadmin: 전체, viewer: 조회만)',
|
|
||||||
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='백오피스 관리자 계정';
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
-- ============================================================
|
|
||||||
-- 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='크레딧 변경 이력';
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
-- ============================================================
|
|
||||||
-- Migration: 영상 댓글 테이블 추가
|
|
||||||
-- Date: 2026-05-21
|
|
||||||
-- Description: 영상 상세 페이지 댓글/대댓글 기능 (2-depth, 소프트 삭제)
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS comment (
|
|
||||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '고유 식별자',
|
|
||||||
video_id INT NOT NULL COMMENT '연결된 Video의 id',
|
|
||||||
user_uuid VARCHAR(36) NOT NULL COMMENT '작성자 UUID (user.user_uuid 참조, 응답 미노출)',
|
|
||||||
nickname VARCHAR(20) NULL COMMENT '작성자 닉네임 (null이면 익명으로 표시)',
|
|
||||||
parent_id BIGINT NULL COMMENT 'NULL=최상위 댓글, 값=대댓글의 부모 id',
|
|
||||||
content VARCHAR(100) NOT NULL COMMENT '댓글 본문 (한글 기준 100자 이내)',
|
|
||||||
is_deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '소프트 삭제 여부',
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '작성 일시',
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
CONSTRAINT fk_comment_video FOREIGN KEY (video_id) REFERENCES video(id) ON DELETE CASCADE,
|
|
||||||
CONSTRAINT fk_comment_user FOREIGN KEY (user_uuid) REFERENCES `user`(user_uuid) ON DELETE CASCADE,
|
|
||||||
CONSTRAINT fk_comment_parent FOREIGN KEY (parent_id) REFERENCES comment(id) ON DELETE CASCADE,
|
|
||||||
INDEX idx_comment_video_id (video_id),
|
|
||||||
INDEX idx_comment_user_uuid (user_uuid),
|
|
||||||
INDEX idx_comment_parent_id (parent_id),
|
|
||||||
INDEX idx_comment_is_deleted (is_deleted)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
||||||
COMMENT='영상 댓글/대댓글';
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
-- ============================================================
|
|
||||||
-- Migration: 영상 반응(좋아요) 테이블 추가
|
|
||||||
-- Date: 2026-05-21
|
|
||||||
-- Description: 사용자 영상별 좋아요 토글 (1인 1회, 확장 가능한 구조)
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS video_reaction (
|
|
||||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '고유 식별자',
|
|
||||||
video_id INT NOT NULL COMMENT '연결된 Video의 id',
|
|
||||||
user_uuid VARCHAR(36) NOT NULL COMMENT '반응한 사용자 UUID',
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '반응 일시',
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
CONSTRAINT uq_video_reaction_user_video UNIQUE (user_uuid, video_id),
|
|
||||||
CONSTRAINT fk_video_reaction_video FOREIGN KEY (video_id) REFERENCES video(id) ON DELETE CASCADE,
|
|
||||||
CONSTRAINT fk_video_reaction_user FOREIGN KEY (user_uuid) REFERENCES `user`(user_uuid) ON DELETE CASCADE,
|
|
||||||
INDEX idx_video_reaction_video_id (video_id),
|
|
||||||
INDEX idx_video_reaction_user_uuid (user_uuid)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
||||||
COMMENT='영상 반응 (user_uuid + video_id 유니크)';
|
|
||||||
25
main.py
25
main.py
|
|
@ -24,9 +24,6 @@ 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.comment.api.routers.v1.comment import router as comment_router
|
|
||||||
from app.video.api.routers.internal.reactions import router as video_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
|
||||||
|
|
||||||
|
|
@ -176,25 +173,6 @@ tags_metadata = [
|
||||||
- created_at 기준 내림차순 정렬됩니다.
|
- created_at 기준 내림차순 정렬됩니다.
|
||||||
- 삭제는 소프트 삭제(is_deleted=True) 방식으로 처리되며, 데이터 복구가 가능합니다.
|
- 삭제는 소프트 삭제(is_deleted=True) 방식으로 처리되며, 데이터 복구가 가능합니다.
|
||||||
- 삭제 대상: Video, SongTimestamp, Song, Lyric, Image, Project
|
- 삭제 대상: Video, SongTimestamp, Song, Lyric, Image, Project
|
||||||
""",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Comment",
|
|
||||||
"description": """영상 댓글 API - 댓글/대댓글 작성·조회·삭제
|
|
||||||
|
|
||||||
**인증: 조회는 불필요, 작성/삭제는 필요** - `Authorization: Bearer {access_token}` 헤더
|
|
||||||
|
|
||||||
## 주요 기능
|
|
||||||
|
|
||||||
- `POST /comment/video/{video_id}` - 댓글/대댓글 작성 (로그인 필수)
|
|
||||||
- `GET /comment/video/{video_id}` - 댓글 목록 조회 (비로그인 허용)
|
|
||||||
- `DELETE /comment/{comment_id}` - 본인 댓글 소프트 삭제 (로그인 필수)
|
|
||||||
|
|
||||||
## 참고
|
|
||||||
|
|
||||||
- 최대 2-depth (댓글 + 대댓글). 대댓글에 대댓글은 불가합니다.
|
|
||||||
- 작성자 정보는 응답에 포함되지 않습니다 (익명 정책).
|
|
||||||
- is_mine 필드로 본인 댓글 여부를 확인할 수 있습니다.
|
|
||||||
""",
|
""",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -411,15 +389,12 @@ app.include_router(lyric_router)
|
||||||
app.include_router(song_router)
|
app.include_router(song_router)
|
||||||
app.include_router(video_router)
|
app.include_router(video_router)
|
||||||
app.include_router(archive_router) # Archive API 라우터 추가
|
app.include_router(archive_router) # Archive API 라우터 추가
|
||||||
app.include_router(comment_router) # Comment API 라우터 추가
|
|
||||||
app.include_router(social_oauth_router, prefix="/social") # Social OAuth 라우터 추가
|
app.include_router(social_oauth_router, prefix="/social") # Social OAuth 라우터 추가
|
||||||
app.include_router(social_upload_router, prefix="/social") # Social Upload 라우터 추가
|
app.include_router(social_upload_router, prefix="/social") # Social Upload 라우터 추가
|
||||||
app.include_router(social_seo_router, prefix="/social") # Social Upload 라우터 추가
|
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(video_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:
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ dependencies = [
|
||||||
"aiofiles>=25.1.0",
|
"aiofiles>=25.1.0",
|
||||||
"aiohttp>=3.13.2",
|
"aiohttp>=3.13.2",
|
||||||
"aiomysql>=0.3.2",
|
"aiomysql>=0.3.2",
|
||||||
"asyncmy>=0.2.11",
|
"asyncmy>=0.2.10",
|
||||||
"beautifulsoup4>=4.14.3",
|
"beautifulsoup4>=4.14.3",
|
||||||
"fastapi-cli>=0.0.16",
|
"fastapi-cli>=0.0.16",
|
||||||
"fastapi[standard]>=0.125.0",
|
"fastapi[standard]>=0.125.0",
|
||||||
|
|
@ -23,7 +23,7 @@ dependencies = [
|
||||||
"ruff>=0.14.9",
|
"ruff>=0.14.9",
|
||||||
"scalar-fastapi>=1.6.1",
|
"scalar-fastapi>=1.6.1",
|
||||||
"sqladmin[full]>=0.22.0",
|
"sqladmin[full]>=0.22.0",
|
||||||
"sqlalchemy[asyncio]>=2.0.50",
|
"sqlalchemy[asyncio]>=2.0.45",
|
||||||
"uuid7>=0.1.0",
|
"uuid7>=0.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
28
uv.lock
28
uv.lock
|
|
@ -744,7 +744,7 @@ requires-dist = [
|
||||||
{ name = "aiofiles", specifier = ">=25.1.0" },
|
{ name = "aiofiles", specifier = ">=25.1.0" },
|
||||||
{ name = "aiohttp", specifier = ">=3.13.2" },
|
{ name = "aiohttp", specifier = ">=3.13.2" },
|
||||||
{ name = "aiomysql", specifier = ">=0.3.2" },
|
{ name = "aiomysql", specifier = ">=0.3.2" },
|
||||||
{ name = "asyncmy", specifier = ">=0.2.11" },
|
{ name = "asyncmy", specifier = ">=0.2.10" },
|
||||||
{ name = "beautifulsoup4", specifier = ">=4.14.3" },
|
{ name = "beautifulsoup4", specifier = ">=4.14.3" },
|
||||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.125.0" },
|
{ name = "fastapi", extras = ["standard"], specifier = ">=0.125.0" },
|
||||||
{ name = "fastapi-cli", specifier = ">=0.0.16" },
|
{ name = "fastapi-cli", specifier = ">=0.0.16" },
|
||||||
|
|
@ -759,7 +759,7 @@ requires-dist = [
|
||||||
{ name = "ruff", specifier = ">=0.14.9" },
|
{ name = "ruff", specifier = ">=0.14.9" },
|
||||||
{ name = "scalar-fastapi", specifier = ">=1.6.1" },
|
{ name = "scalar-fastapi", specifier = ">=1.6.1" },
|
||||||
{ name = "sqladmin", extras = ["full"], specifier = ">=0.22.0" },
|
{ name = "sqladmin", extras = ["full"], specifier = ">=0.22.0" },
|
||||||
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.50" },
|
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.45" },
|
||||||
{ name = "uuid7", specifier = ">=0.1.0" },
|
{ name = "uuid7", specifier = ">=0.1.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -1294,22 +1294,26 @@ full = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlalchemy"
|
name = "sqlalchemy"
|
||||||
version = "2.0.50"
|
version = "2.0.46"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
|
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/57/da/6fbf010c8ebb347679d0d100b22fe9ba5e13fd04046c5df7280d2f0bf706/sqlalchemy-2.0.50.tar.gz", hash = "sha256:af5607d11ef90fd6a5c0549fe0045dce1663d427426bcfb506dcb5346a85a3b9", size = 9907424, upload-time = "2026-05-24T19:20:04.018Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/06/aa/9ce0f3e7a9829ead5c8ce549392f33a12c4555a6c0609bb27d882e9c7ddf/sqlalchemy-2.0.46.tar.gz", hash = "sha256:cf36851ee7219c170bb0793dbc3da3e80c582e04a5437bc601bfe8c85c9216d7", size = 9865393, upload-time = "2026-01-21T18:03:45.119Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/0b/c4/c42356b527296e9862f67990efce31ef78b4cf69cd3f80873a528a060320/sqlalchemy-2.0.50-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:06a9210bdc5f4298cff0781087e2ff45683922252dacc452846373a58761f093", size = 2156697, upload-time = "2026-05-24T19:27:54.764Z" },
|
{ url = "https://files.pythonhosted.org/packages/b3/4b/fa7838fe20bb752810feed60e45625a9a8b0102c0c09971e2d1d95362992/sqlalchemy-2.0.46-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:93a12da97cca70cea10d4b4fc602589c4511f96c1f8f6c11817620c021d21d00", size = 2150268, upload-time = "2026-01-21T19:05:56.621Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/60/a1/b1a70e3c4365ac7fe9e347f3710f19b562c866fb96d45e3c891588789a7b/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b53784972ade4f8174b9aa661f31a06f8a936d2cfdd602913ff3c6dd40ae873", size = 3284260, upload-time = "2026-05-24T20:09:34.195Z" },
|
{ url = "https://files.pythonhosted.org/packages/46/c1/b34dccd712e8ea846edf396e00973dda82d598cb93762e55e43e6835eba9/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af865c18752d416798dae13f83f38927c52f085c52e2f32b8ab0fef46fdd02c2", size = 3276511, upload-time = "2026-01-21T18:46:49.022Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3f/4a/f3ac3caa19f263d57b0a47f8c91bbf56583dc2d3fc63acfbf644abb24fe0/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31648fa14460537e768a7303b078e4344d208e0d23e06867c1f376a227ed82db", size = 3302280, upload-time = "2026-05-24T20:17:17.825Z" },
|
{ url = "https://files.pythonhosted.org/packages/96/48/a04d9c94753e5d5d096c628c82a98c4793b9c08ca0e7155c3eb7d7db9f24/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d679b5f318423eacb61f933a9a0f75535bfca7056daeadbf6bd5bcee6183aee", size = 3292881, upload-time = "2026-01-21T18:40:13.089Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/66/55/ccada3e3d62254587819749a0bc69f41173eb48a6e385d10e66d32a9c88e/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03f4323c980ad0e918cc9e5369b015f759f4e534db5bbaf4dc36832c10d05064", size = 3231580, upload-time = "2026-05-24T20:09:36.406Z" },
|
{ url = "https://files.pythonhosted.org/packages/be/f4/06eda6e91476f90a7d8058f74311cb65a2fb68d988171aced81707189131/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64901e08c33462acc9ec3bad27fc7a5c2b6491665f2aa57564e57a4f5d7c52ad", size = 3224559, upload-time = "2026-01-21T18:46:50.974Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/05/f6/6809349130a2de0e109e7f00fd7d431da9565b9b2868b32ee684754f672b/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2b9dcc43afef8ac157cd92fce96985d6b8b0cfbd3df4d666f66b4d55a75d202f", size = 3269375, upload-time = "2026-05-24T20:17:20.34Z" },
|
{ url = "https://files.pythonhosted.org/packages/ab/a2/d2af04095412ca6345ac22b33b89fe8d6f32a481e613ffcb2377d931d8d0/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8ac45e8f4eaac0f9f8043ea0e224158855c6a4329fd4ee37c45c61e3beb518e", size = 3262728, upload-time = "2026-01-21T18:40:14.883Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/84/278a811ef4e07be9c89dc5cdd7be833268509a66a68c4897cf585e67428f/sqlalchemy-2.0.50-cp313-cp313-win32.whl", hash = "sha256:60922d6599065ddca2c6f376b9aa2f41a6b85a271725e0909490bbc50b1998a5", size = 2117229, upload-time = "2026-05-24T19:50:08.215Z" },
|
{ url = "https://files.pythonhosted.org/packages/31/48/1980c7caa5978a3b8225b4d230e69a2a6538a3562b8b31cea679b6933c83/sqlalchemy-2.0.46-cp313-cp313-win32.whl", hash = "sha256:8d3b44b3d0ab2f1319d71d9863d76eeb46766f8cf9e921ac293511804d39813f", size = 2111295, upload-time = "2026-01-21T18:42:52.366Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f6/1c/067cc6187ed32d2ec222fe6d2643acc1659a6d0659f8a7cbc5ad3ae83280/sqlalchemy-2.0.50-cp313-cp313-win_amd64.whl", hash = "sha256:287086e67275a212c4582d166a6fb03a65ccc5551d80866270ce0dd9f34eccd3", size = 2143126, upload-time = "2026-05-24T19:50:09.691Z" },
|
{ url = "https://files.pythonhosted.org/packages/2d/54/f8d65bbde3d877617c4720f3c9f60e99bb7266df0d5d78b6e25e7c149f35/sqlalchemy-2.0.46-cp313-cp313-win_amd64.whl", hash = "sha256:77f8071d8fbcbb2dd11b7fd40dedd04e8ebe2eb80497916efedba844298065ef", size = 2137076, upload-time = "2026-01-21T18:42:53.924Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d0/10/f7220e9b784d295d241c86ed99aeb537f92afcd469a64861f2717e9bb077/sqlalchemy-2.0.50-py3-none-any.whl", hash = "sha256:92064363517a3ff8212b5a93b8c62876579d8dfd1ca5b561335f30152d884fa9", size = 1943861, upload-time = "2026-05-24T19:59:01.119Z" },
|
{ url = "https://files.pythonhosted.org/packages/56/ba/9be4f97c7eb2b9d5544f2624adfc2853e796ed51d2bb8aec90bc94b7137e/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1e8cc6cc01da346dc92d9509a63033b9b1bda4fed7a7a7807ed385c7dccdc10", size = 3556533, upload-time = "2026-01-21T18:33:06.636Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/a6/b1fc6634564dbb4415b7ed6419cdfeaadefd2c39cdab1e3aa07a5f2474c2/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96c7cca1a4babaaf3bfff3e4e606e38578856917e52f0384635a95b226c87764", size = 3523208, upload-time = "2026-01-21T18:45:08.436Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/d8/41e0bdfc0f930ff236f86fccd12962d8fa03713f17ed57332d38af6a3782/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2a9f9aee38039cf4755891a1e50e1effcc42ea6ba053743f452c372c3152b1b", size = 3464292, upload-time = "2026-01-21T18:33:08.208Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/8b/9dcbec62d95bea85f5ecad9b8d65b78cc30fb0ffceeb3597961f3712549b/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db23b1bf8cfe1f7fda19018e7207b20cdb5168f83c437ff7e95d19e39289c447", size = 3473497, upload-time = "2026-01-21T18:45:10.552Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/a1/9c4efa03300926601c19c18582531b45aededfb961ab3c3585f1e24f120b/sqlalchemy-2.0.46-py3-none-any.whl", hash = "sha256:f9c11766e7e7c0a2767dda5acb006a118640c9fc0a4104214b96269bfb78399e", size = 1937882, upload-time = "2026-01-21T18:22:10.456Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue