Compare commits
2 Commits
main
...
feature-sc
| Author | SHA1 | Date |
|---|---|---|
|
|
6e70c416b8 | |
|
|
7a887153ab |
|
|
@ -49,10 +49,4 @@ logs/
|
||||||
|
|
||||||
*.yml
|
*.yml
|
||||||
Dockerfile
|
Dockerfile
|
||||||
.dockerignore
|
.dockerignore
|
||||||
|
|
||||||
zzz/
|
|
||||||
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 sqladmin import Admin
|
||||||
from fastapi import FastAPI
|
|
||||||
from sqladmin import Admin
|
from app.database.session import engine
|
||||||
from sqladmin.authentication import login_required
|
from app.home.api.home_admin import ImageAdmin, ProjectAdmin
|
||||||
from starlette.exceptions import HTTPException
|
from app.lyric.api.lyrics_admin import LyricAdmin
|
||||||
from starlette.requests import Request
|
from app.song.api.song_admin import SongAdmin
|
||||||
from starlette.responses import Response
|
from app.sns.api.sns_admin import SNSUploadTaskAdmin
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
from app.user.api.user_admin import RefreshTokenAdmin, SocialAccountAdmin, UserAdmin
|
||||||
|
from app.video.api.video_admin import VideoAdmin
|
||||||
from app.backoffice.admin.admin_view import AdminAdmin
|
from config import prj_settings
|
||||||
from app.backoffice.admin.auth import AdminAuthBackend
|
|
||||||
from app.backoffice.credit_view import CreditChargeRequestAdmin, CreditTransactionAdmin
|
# https://github.com/aminalaee/sqladmin
|
||||||
from app.backoffice.dashboard import get_dashboard_context
|
|
||||||
from app.user.api.user_admin import SocialAccountAdmin, UserAdmin
|
|
||||||
from config import prj_settings
|
def init_admin(
|
||||||
|
app: FastAPI,
|
||||||
TEMPLATES_DIR = Path(__file__).parent / "backoffice" / "frontend" / "templates"
|
db_engine: engine,
|
||||||
|
base_url: str = prj_settings.ADMIN_BASE_URL,
|
||||||
|
) -> Admin:
|
||||||
class DashboardAdmin(Admin):
|
admin = Admin(
|
||||||
@login_required
|
app,
|
||||||
async def index(self, request: Request) -> Response:
|
db_engine,
|
||||||
ctx = await get_dashboard_context()
|
base_url=base_url,
|
||||||
admin_role = request.session.get("admin_role", "viewer")
|
)
|
||||||
return await self.templates.TemplateResponse(
|
|
||||||
request,
|
# 프로젝트 관리
|
||||||
"sqladmin/index.html",
|
admin.add_view(ProjectAdmin)
|
||||||
{"title": "대시보드", "subtitle": "", "admin_role": admin_role, **ctx},
|
admin.add_view(ImageAdmin)
|
||||||
)
|
|
||||||
|
# 가사 관리
|
||||||
@login_required
|
admin.add_view(LyricAdmin)
|
||||||
async def edit(self, request: Request) -> Response:
|
|
||||||
if request.session.get("admin_role") == "viewer":
|
# 노래 관리
|
||||||
raise HTTPException(status_code=403)
|
admin.add_view(SongAdmin)
|
||||||
return await super().edit(request)
|
|
||||||
|
# 영상 관리
|
||||||
@login_required
|
admin.add_view(VideoAdmin)
|
||||||
async def create(self, request: Request) -> Response:
|
|
||||||
if request.session.get("admin_role") == "viewer":
|
# 사용자 관리
|
||||||
raise HTTPException(status_code=403)
|
admin.add_view(UserAdmin)
|
||||||
return await super().create(request)
|
admin.add_view(RefreshTokenAdmin)
|
||||||
|
admin.add_view(SocialAccountAdmin)
|
||||||
@login_required
|
|
||||||
async def delete(self, request: Request) -> Response:
|
# SNS 관리
|
||||||
if request.session.get("admin_role") == "viewer":
|
admin.add_view(SNSUploadTaskAdmin)
|
||||||
raise HTTPException(status_code=403)
|
|
||||||
return await super().delete(request)
|
return admin
|
||||||
|
|
||||||
|
|
||||||
def init_admin(
|
|
||||||
app: FastAPI,
|
|
||||||
db_engine: AsyncEngine,
|
|
||||||
base_url: str = prj_settings.ADMIN_BASE_URL,
|
|
||||||
) -> Admin:
|
|
||||||
auth_backend = AdminAuthBackend(secret_key=prj_settings.ADMIN_SESSION_SECRET)
|
|
||||||
|
|
||||||
admin = DashboardAdmin(
|
|
||||||
app,
|
|
||||||
db_engine,
|
|
||||||
base_url=base_url,
|
|
||||||
authentication_backend=auth_backend,
|
|
||||||
title="ADO2 관리자",
|
|
||||||
templates_dir=str(TEMPLATES_DIR),
|
|
||||||
)
|
|
||||||
|
|
||||||
# 사용자 관리
|
|
||||||
admin.add_view(UserAdmin)
|
|
||||||
admin.add_view(SocialAccountAdmin)
|
|
||||||
|
|
||||||
# 크레딧 관리 (superadmin: 전체, viewer: 읽기 전용)
|
|
||||||
admin.add_view(CreditChargeRequestAdmin)
|
|
||||||
admin.add_view(CreditTransactionAdmin)
|
|
||||||
|
|
||||||
# 백오피스 설정
|
|
||||||
admin.add_view(AdminAdmin)
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
@ -24,11 +24,6 @@ async def lifespan(app: FastAPI):
|
||||||
|
|
||||||
await create_db_tables()
|
await create_db_tables()
|
||||||
logger.info("Database tables created (DEBUG mode)")
|
logger.info("Database tables created (DEBUG mode)")
|
||||||
|
|
||||||
# dashboard 테이블 초기화 및 기존 데이터 마이그레이션 (모든 환경)
|
|
||||||
from app.dashboard.migration import init_dashboard_table
|
|
||||||
await init_dashboard_table()
|
|
||||||
|
|
||||||
await NvMapPwScraper.initiate_scraper()
|
await NvMapPwScraper.initiate_scraper()
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.error("Database initialization timed out")
|
logger.error("Database initialization timed out")
|
||||||
|
|
@ -51,9 +46,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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -309,23 +309,6 @@ def add_exception_handlers(app: FastAPI):
|
||||||
content=content,
|
content=content,
|
||||||
)
|
)
|
||||||
|
|
||||||
# DashboardException 핸들러 추가
|
|
||||||
from app.dashboard.exceptions import DashboardException
|
|
||||||
|
|
||||||
@app.exception_handler(DashboardException)
|
|
||||||
def dashboard_exception_handler(request: Request, exc: DashboardException) -> Response:
|
|
||||||
if exc.status_code < 500:
|
|
||||||
logger.warning(f"Handled DashboardException: {exc.__class__.__name__} - {exc.message}")
|
|
||||||
else:
|
|
||||||
logger.error(f"Handled DashboardException: {exc.__class__.__name__} - {exc.message}")
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=exc.status_code,
|
|
||||||
content={
|
|
||||||
"detail": exc.message,
|
|
||||||
"code": exc.code,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR)
|
@app.exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
def internal_server_error_handler(request, exception):
|
def internal_server_error_handler(request, exception):
|
||||||
# 에러 메시지 로깅 (한글 포함 가능)
|
# 에러 메시지 로깅 (한글 포함 가능)
|
||||||
|
|
|
||||||
|
|
@ -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,5 +0,0 @@
|
||||||
"""
|
|
||||||
Dashboard Module
|
|
||||||
|
|
||||||
YouTube Analytics API를 활용한 대시보드 기능을 제공합니다.
|
|
||||||
"""
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
"""
|
|
||||||
Dashboard API Module
|
|
||||||
"""
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
"""
|
|
||||||
Dashboard Routers
|
|
||||||
"""
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
"""
|
|
||||||
Dashboard V1 Routers
|
|
||||||
"""
|
|
||||||
|
|
||||||
from app.dashboard.api.routers.v1.dashboard import router
|
|
||||||
|
|
||||||
__all__ = ["router"]
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
"""
|
|
||||||
Dashboard API 라우터
|
|
||||||
|
|
||||||
YouTube Analytics 기반 대시보드 통계를 제공합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.dashboard.utils.redis_cache import delete_cache_pattern
|
|
||||||
from app.dashboard.schemas import (
|
|
||||||
CacheDeleteResponse,
|
|
||||||
ConnectedAccountsResponse,
|
|
||||||
DashboardResponse,
|
|
||||||
)
|
|
||||||
from app.dashboard.services import DashboardService
|
|
||||||
from app.database.session import get_session
|
|
||||||
from app.user.dependencies.auth import get_current_user
|
|
||||||
from app.user.models import User
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/dashboard", tags=["Dashboard"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/accounts",
|
|
||||||
response_model=ConnectedAccountsResponse,
|
|
||||||
summary="연결된 소셜 계정 목록 조회",
|
|
||||||
description="""
|
|
||||||
연결된 소셜 계정 목록을 반환합니다.
|
|
||||||
|
|
||||||
여러 계정이 연결된 경우, 반환된 `platformUserId` 값을 `/dashboard/stats?platform_user_id=<값>`에 전달하여 계정을 선택합니다.
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
async def get_connected_accounts(
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> ConnectedAccountsResponse:
|
|
||||||
service = DashboardService()
|
|
||||||
connected = await service.get_connected_accounts(current_user, session)
|
|
||||||
return ConnectedAccountsResponse(accounts=connected)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/stats",
|
|
||||||
response_model=DashboardResponse,
|
|
||||||
summary="대시보드 통계 조회",
|
|
||||||
description="""
|
|
||||||
YouTube Analytics API를 활용한 대시보드 통계를 조회합니다.
|
|
||||||
|
|
||||||
## 주요 기능
|
|
||||||
- 최근 30개 업로드 영상 기준 통계 제공
|
|
||||||
- KPI 지표: 조회수, 시청시간, 평균 시청시간, 신규 구독자, 좋아요, 댓글, 공유, 업로드 영상
|
|
||||||
- 월별 추이: 최근 12개월 vs 이전 12개월 비교
|
|
||||||
- 인기 영상 TOP 4
|
|
||||||
- 시청자 분석: 연령/성별/지역 분포
|
|
||||||
|
|
||||||
## 성능 최적화
|
|
||||||
- 7개 YouTube Analytics API를 병렬로 호출
|
|
||||||
- Redis 캐싱 적용 (TTL: 12시간)
|
|
||||||
|
|
||||||
## 사전 조건
|
|
||||||
- YouTube 계정이 연동되어 있어야 합니다
|
|
||||||
|
|
||||||
## 조회 모드
|
|
||||||
- `day`: 최근 30일 통계 (현재 날짜 -2일 기준)
|
|
||||||
- `month`: 최근 12개월 통계 (현재 날짜 -2일 기준, 기본값)
|
|
||||||
|
|
||||||
## 데이터 특성
|
|
||||||
- **지연 시간**: 48시간 (2일) - 2월 14일 요청 시 2월 12일 자까지 확정
|
|
||||||
- **업데이트 주기**: 하루 1회 (PT 자정, 한국 시간 오후 5~8시)
|
|
||||||
- **실시간 아님**: 전날 데이터가 다음날 확정됩니다
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
async def get_dashboard_stats(
|
|
||||||
mode: Literal["day", "month"] = Query(
|
|
||||||
default="month",
|
|
||||||
description="조회 모드: day(최근 30일), month(최근 12개월)",
|
|
||||||
),
|
|
||||||
platform_user_id: str | None = Query(
|
|
||||||
default=None,
|
|
||||||
description="사용할 YouTube 채널 ID (platform_user_id)",
|
|
||||||
),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> DashboardResponse:
|
|
||||||
service = DashboardService()
|
|
||||||
return await service.get_stats(mode, platform_user_id, current_user, session)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
|
||||||
"/cache",
|
|
||||||
response_model=CacheDeleteResponse,
|
|
||||||
summary="대시보드 캐시 삭제",
|
|
||||||
description="""
|
|
||||||
대시보드 Redis 캐시를 삭제합니다. 인증 없이 호출 가능합니다.
|
|
||||||
|
|
||||||
삭제 후 다음 `/stats` 요청 시 YouTube Analytics API를 새로 호출하여 최신 데이터를 반환합니다.
|
|
||||||
|
|
||||||
## 사용 시나리오
|
|
||||||
- 코드 배포 후 즉시 최신 데이터 반영이 필요할 때
|
|
||||||
- 데이터 이상 발생 시 캐시 강제 갱신
|
|
||||||
|
|
||||||
## 캐시 키 구조
|
|
||||||
`dashboard:{user_uuid}:{platform_user_id}:{mode}` (mode: day 또는 month)
|
|
||||||
|
|
||||||
## 파라미터
|
|
||||||
- `user_uuid`: 삭제할 사용자 UUID (필수)
|
|
||||||
- `mode`: day / month / all (기본값: all)
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
async def delete_dashboard_cache(
|
|
||||||
mode: Literal["day", "month", "all"] = Query(
|
|
||||||
default="all",
|
|
||||||
description="삭제할 캐시 모드: day, month, all(기본값, 모두 삭제)",
|
|
||||||
),
|
|
||||||
user_uuid: str = Query(
|
|
||||||
description="대상 사용자 UUID",
|
|
||||||
),
|
|
||||||
) -> CacheDeleteResponse:
|
|
||||||
if mode == "all":
|
|
||||||
deleted = await delete_cache_pattern(f"dashboard:{user_uuid}:*")
|
|
||||||
message = f"전체 캐시 삭제 완료 ({deleted}개)"
|
|
||||||
else:
|
|
||||||
deleted = await delete_cache_pattern(f"dashboard:{user_uuid}:*:{mode}")
|
|
||||||
message = f"{mode} 캐시 삭제 완료 ({deleted}개)"
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[CACHE DELETE] user_uuid={user_uuid or 'ALL'}, mode={mode}, deleted={deleted}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return CacheDeleteResponse(deleted_count=deleted, message=message)
|
|
||||||
|
|
@ -1,195 +0,0 @@
|
||||||
"""
|
|
||||||
Dashboard Exceptions
|
|
||||||
|
|
||||||
Dashboard API 관련 예외 클래스를 정의합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import status
|
|
||||||
|
|
||||||
|
|
||||||
class DashboardException(Exception):
|
|
||||||
"""Dashboard 기본 예외"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
message: str,
|
|
||||||
status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
code: str = "DASHBOARD_ERROR",
|
|
||||||
):
|
|
||||||
self.message = message
|
|
||||||
self.status_code = status_code
|
|
||||||
self.code = code
|
|
||||||
super().__init__(self.message)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# YouTube Analytics API 관련 예외
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class YouTubeAPIException(DashboardException):
|
|
||||||
"""YouTube Analytics API 관련 예외 기본 클래스"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
message: str = "YouTube Analytics API 호출 중 오류가 발생했습니다.",
|
|
||||||
status_code: int = status.HTTP_502_BAD_GATEWAY,
|
|
||||||
code: str = "YOUTUBE_API_ERROR",
|
|
||||||
):
|
|
||||||
super().__init__(message, status_code, code)
|
|
||||||
|
|
||||||
|
|
||||||
class YouTubeAPIError(YouTubeAPIException):
|
|
||||||
"""YouTube Analytics API 일반 오류"""
|
|
||||||
|
|
||||||
def __init__(self, detail: str = ""):
|
|
||||||
error_message = "YouTube Analytics API 호출에 실패했습니다."
|
|
||||||
if detail:
|
|
||||||
error_message += f" ({detail})"
|
|
||||||
super().__init__(
|
|
||||||
message=error_message,
|
|
||||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
||||||
code="YOUTUBE_API_FAILED",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class YouTubeAuthError(YouTubeAPIException):
|
|
||||||
"""YouTube 인증 실패"""
|
|
||||||
|
|
||||||
def __init__(self, detail: str = ""):
|
|
||||||
error_message = "YouTube 인증에 실패했습니다. 계정 재연동이 필요합니다."
|
|
||||||
if detail:
|
|
||||||
error_message += f" ({detail})"
|
|
||||||
super().__init__(
|
|
||||||
message=error_message,
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
code="YOUTUBE_AUTH_FAILED",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class YouTubeQuotaExceededError(YouTubeAPIException):
|
|
||||||
"""YouTube API 할당량 초과"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__(
|
|
||||||
message="YouTube API 일일 할당량이 초과되었습니다. 내일 다시 시도해주세요.",
|
|
||||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
||||||
code="YOUTUBE_QUOTA_EXCEEDED",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class YouTubeDataNotFoundError(YouTubeAPIException):
|
|
||||||
"""YouTube Analytics 데이터 없음"""
|
|
||||||
|
|
||||||
def __init__(self, detail: str = ""):
|
|
||||||
error_message = "YouTube Analytics 데이터를 찾을 수 없습니다."
|
|
||||||
if detail:
|
|
||||||
error_message += f" ({detail})"
|
|
||||||
super().__init__(
|
|
||||||
message=error_message,
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
code="YOUTUBE_DATA_NOT_FOUND",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 계정 연동 관련 예외
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class YouTubeAccountNotConnectedError(DashboardException):
|
|
||||||
"""YouTube 계정 미연동"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__(
|
|
||||||
message="YouTube 계정이 연동되어 있지 않습니다. 먼저 YouTube 계정을 연동해주세요.",
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
code="YOUTUBE_NOT_CONNECTED",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class YouTubeAccountSelectionRequiredError(DashboardException):
|
|
||||||
"""여러 YouTube 계정이 연동된 경우 계정 선택 필요"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__(
|
|
||||||
message="연결된 YouTube 계정이 여러 개입니다. platform_user_id 파라미터로 사용할 계정을 선택해주세요.",
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
code="YOUTUBE_ACCOUNT_SELECTION_REQUIRED",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class YouTubeAccountNotFoundError(DashboardException):
|
|
||||||
"""지정한 YouTube 계정을 찾을 수 없음"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__(
|
|
||||||
message="지정한 YouTube 계정을 찾을 수 없습니다.",
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
code="YOUTUBE_ACCOUNT_NOT_FOUND",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class YouTubeTokenExpiredError(DashboardException):
|
|
||||||
"""YouTube 토큰 만료"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__(
|
|
||||||
message="YouTube 인증이 만료되었습니다. 계정을 재연동해주세요.",
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
code="YOUTUBE_TOKEN_EXPIRED",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 데이터 관련 예외
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class NoVideosFoundError(DashboardException):
|
|
||||||
"""업로드된 영상 없음"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__(
|
|
||||||
message="업로드된 YouTube 영상이 없습니다. 먼저 영상을 업로드해주세요.",
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
code="NO_VIDEOS_FOUND",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DashboardDataError(DashboardException):
|
|
||||||
"""대시보드 데이터 처리 오류"""
|
|
||||||
|
|
||||||
def __init__(self, detail: str = ""):
|
|
||||||
error_message = "대시보드 데이터 처리 중 오류가 발생했습니다."
|
|
||||||
if detail:
|
|
||||||
error_message += f" ({detail})"
|
|
||||||
super().__init__(
|
|
||||||
message=error_message,
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
code="DASHBOARD_DATA_ERROR",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 캐싱 관련 예외 (경고용, 실제로는 raise하지 않고 로깅만 사용)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class CacheError(DashboardException):
|
|
||||||
"""캐시 작업 오류
|
|
||||||
|
|
||||||
Note:
|
|
||||||
이 예외는 실제로 raise되지 않고,
|
|
||||||
캐시 실패 시 로깅만 하고 원본 데이터를 반환합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, operation: str, detail: str = ""):
|
|
||||||
error_message = f"캐시 {operation} 작업 중 오류가 발생했습니다."
|
|
||||||
if detail:
|
|
||||||
error_message += f" ({detail})"
|
|
||||||
super().__init__(
|
|
||||||
message=error_message,
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
code="CACHE_ERROR",
|
|
||||||
)
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
"""
|
|
||||||
Dashboard Migration
|
|
||||||
|
|
||||||
dashboard 테이블 초기화 및 기존 데이터 마이그레이션을 담당합니다.
|
|
||||||
서버 기동 시 create_db_tables() 이후 호출됩니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from sqlalchemy import func, select, text
|
|
||||||
from sqlalchemy.dialects.mysql import insert
|
|
||||||
|
|
||||||
from app.dashboard.models import Dashboard
|
|
||||||
from app.database.session import AsyncSessionLocal, engine
|
|
||||||
from app.social.models import SocialUpload
|
|
||||||
from app.user.models import SocialAccount
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def _dashboard_table_exists() -> bool:
|
|
||||||
"""dashboard 테이블 존재 여부 확인"""
|
|
||||||
async with engine.connect() as conn:
|
|
||||||
result = await conn.execute(
|
|
||||||
text(
|
|
||||||
"SELECT COUNT(*) FROM information_schema.tables "
|
|
||||||
"WHERE table_schema = DATABASE() AND table_name = 'dashboard'"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return result.scalar() > 0
|
|
||||||
|
|
||||||
|
|
||||||
async def _dashboard_is_empty() -> bool:
|
|
||||||
"""dashboard 테이블 데이터 존재 여부 확인"""
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
select(func.count()).select_from(Dashboard)
|
|
||||||
)
|
|
||||||
return result.scalar() == 0
|
|
||||||
|
|
||||||
|
|
||||||
async def _migrate_existing_data() -> None:
|
|
||||||
"""
|
|
||||||
SocialUpload(status=completed) → Dashboard 마이그레이션.
|
|
||||||
INSERT IGNORE로 중복 안전하게 삽입.
|
|
||||||
"""
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
select(
|
|
||||||
SocialUpload.user_uuid,
|
|
||||||
SocialUpload.platform,
|
|
||||||
SocialUpload.platform_video_id,
|
|
||||||
SocialUpload.platform_url,
|
|
||||||
SocialUpload.title,
|
|
||||||
SocialUpload.uploaded_at,
|
|
||||||
SocialAccount.platform_user_id,
|
|
||||||
)
|
|
||||||
.join(SocialAccount, SocialUpload.social_account_id == SocialAccount.id)
|
|
||||||
.where(
|
|
||||||
SocialUpload.status == "completed",
|
|
||||||
SocialUpload.platform_video_id.isnot(None),
|
|
||||||
SocialUpload.uploaded_at.isnot(None),
|
|
||||||
SocialAccount.platform_user_id.isnot(None),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
rows = result.all()
|
|
||||||
|
|
||||||
if not rows:
|
|
||||||
logger.info("[DASHBOARD_MIGRATE] 마이그레이션 대상 없음")
|
|
||||||
return
|
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
for row in rows:
|
|
||||||
stmt = (
|
|
||||||
insert(Dashboard)
|
|
||||||
.values(
|
|
||||||
user_uuid=row.user_uuid,
|
|
||||||
platform=row.platform,
|
|
||||||
platform_user_id=row.platform_user_id,
|
|
||||||
platform_video_id=row.platform_video_id,
|
|
||||||
platform_url=row.platform_url,
|
|
||||||
title=row.title,
|
|
||||||
uploaded_at=row.uploaded_at,
|
|
||||||
)
|
|
||||||
.prefix_with("IGNORE")
|
|
||||||
)
|
|
||||||
await session.execute(stmt)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
logger.info(f"[DASHBOARD_MIGRATE] 마이그레이션 완료 - {len(rows)}건 삽입")
|
|
||||||
|
|
||||||
|
|
||||||
async def init_dashboard_table() -> None:
|
|
||||||
"""
|
|
||||||
dashboard 테이블 초기화 진입점.
|
|
||||||
|
|
||||||
- 테이블이 없으면 생성 후 마이그레이션
|
|
||||||
- 테이블이 있지만 비어있으면 마이그레이션 (DEBUG 모드에서 create_db_tables()가 빈 테이블 생성한 경우)
|
|
||||||
- 테이블이 있고 데이터도 있으면 스킵
|
|
||||||
"""
|
|
||||||
if not await _dashboard_table_exists():
|
|
||||||
logger.info("[DASHBOARD_MIGRATE] dashboard 테이블 없음 - 생성 및 마이그레이션 시작")
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await conn.run_sync(
|
|
||||||
lambda c: Dashboard.__table__.create(c, checkfirst=True)
|
|
||||||
)
|
|
||||||
await _migrate_existing_data()
|
|
||||||
elif await _dashboard_is_empty():
|
|
||||||
logger.info("[DASHBOARD_MIGRATE] dashboard 테이블 비어있음 - 마이그레이션 시작")
|
|
||||||
await _migrate_existing_data()
|
|
||||||
else:
|
|
||||||
logger.info("[DASHBOARD_MIGRATE] dashboard 테이블 이미 존재 - 스킵")
|
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
"""
|
|
||||||
Dashboard Models
|
|
||||||
|
|
||||||
대시보드 전용 SQLAlchemy 모델을 정의합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from sqlalchemy import BigInteger, DateTime, Index, String, UniqueConstraint, func
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.database.session import Base
|
|
||||||
|
|
||||||
|
|
||||||
class Dashboard(Base):
|
|
||||||
"""
|
|
||||||
채널별 영상 업로드 기록 테이블
|
|
||||||
|
|
||||||
YouTube 업로드 완료 시 채널 ID(platform_user_id)와 함께 기록합니다.
|
|
||||||
SocialUpload.social_account_id는 재연동 시 변경되므로,
|
|
||||||
이 테이블로 채널 기준 안정적인 영상 필터링을 제공합니다.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
id: 고유 식별자 (자동 증가)
|
|
||||||
user_uuid: 사용자 UUID (User.user_uuid 참조)
|
|
||||||
platform: 플랫폼 (youtube/instagram)
|
|
||||||
platform_user_id: 채널 ID (재연동 후에도 불변)
|
|
||||||
platform_video_id: 영상 ID
|
|
||||||
platform_url: 영상 URL
|
|
||||||
title: 영상 제목
|
|
||||||
uploaded_at: SocialUpload 완료 시각
|
|
||||||
created_at: 레코드 생성 시각
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "dashboard"
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint(
|
|
||||||
"platform_video_id",
|
|
||||||
"platform_user_id",
|
|
||||||
name="uq_vcu_video_channel",
|
|
||||||
),
|
|
||||||
Index("idx_vcu_user_platform", "user_uuid", "platform_user_id"),
|
|
||||||
Index("idx_vcu_uploaded_at", "uploaded_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),
|
|
||||||
nullable=False,
|
|
||||||
comment="사용자 UUID (User.user_uuid 참조)",
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==========================================================================
|
|
||||||
# 플랫폼 정보
|
|
||||||
# ==========================================================================
|
|
||||||
platform: Mapped[str] = mapped_column(
|
|
||||||
String(20),
|
|
||||||
nullable=False,
|
|
||||||
comment="플랫폼 (youtube/instagram)",
|
|
||||||
)
|
|
||||||
|
|
||||||
platform_user_id: Mapped[str] = mapped_column(
|
|
||||||
String(100),
|
|
||||||
nullable=False,
|
|
||||||
comment="채널 ID (재연동 후에도 불변)",
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==========================================================================
|
|
||||||
# 플랫폼 결과
|
|
||||||
# ==========================================================================
|
|
||||||
platform_video_id: Mapped[str] = mapped_column(
|
|
||||||
String(100),
|
|
||||||
nullable=False,
|
|
||||||
comment="영상 ID",
|
|
||||||
)
|
|
||||||
|
|
||||||
platform_url: Mapped[Optional[str]] = mapped_column(
|
|
||||||
String(500),
|
|
||||||
nullable=True,
|
|
||||||
comment="영상 URL",
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==========================================================================
|
|
||||||
# 메타데이터
|
|
||||||
# ==========================================================================
|
|
||||||
title: Mapped[str] = mapped_column(
|
|
||||||
String(200),
|
|
||||||
nullable=False,
|
|
||||||
comment="영상 제목",
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==========================================================================
|
|
||||||
# 시간 정보
|
|
||||||
# ==========================================================================
|
|
||||||
uploaded_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime,
|
|
||||||
nullable=False,
|
|
||||||
comment="SocialUpload 완료 시각",
|
|
||||||
)
|
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime,
|
|
||||||
nullable=False,
|
|
||||||
server_default=func.now(),
|
|
||||||
comment="레코드 생성 시각",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return (
|
|
||||||
f"<Dashboard("
|
|
||||||
f"id={self.id}, "
|
|
||||||
f"platform_user_id='{self.platform_user_id}', "
|
|
||||||
f"platform_video_id='{self.platform_video_id}'"
|
|
||||||
f")>"
|
|
||||||
)
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
"""
|
|
||||||
Dashboard Schemas
|
|
||||||
|
|
||||||
Dashboard API의 요청/응답 스키마를 정의합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from app.dashboard.schemas.dashboard_schema import (
|
|
||||||
AudienceData,
|
|
||||||
CacheDeleteResponse,
|
|
||||||
ConnectedAccount,
|
|
||||||
ConnectedAccountsResponse,
|
|
||||||
ContentMetric,
|
|
||||||
DailyData,
|
|
||||||
DashboardResponse,
|
|
||||||
MonthlyData,
|
|
||||||
TopContent,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"ConnectedAccount",
|
|
||||||
"ConnectedAccountsResponse",
|
|
||||||
"ContentMetric",
|
|
||||||
"DailyData",
|
|
||||||
"MonthlyData",
|
|
||||||
"TopContent",
|
|
||||||
"AudienceData",
|
|
||||||
"DashboardResponse",
|
|
||||||
"CacheDeleteResponse",
|
|
||||||
]
|
|
||||||
|
|
@ -1,283 +0,0 @@
|
||||||
"""
|
|
||||||
Dashboard API Schemas
|
|
||||||
|
|
||||||
대시보드 API의 요청/응답 Pydantic 스키마를 정의합니다.
|
|
||||||
YouTube Analytics API 데이터를 프론트엔드에 전달하기 위한 모델입니다.
|
|
||||||
|
|
||||||
사용 예시:
|
|
||||||
from app.dashboard.schemas import DashboardResponse, ContentMetric
|
|
||||||
|
|
||||||
# 라우터에서 response_model로 사용
|
|
||||||
@router.get("/dashboard/stats", response_model=DashboardResponse)
|
|
||||||
async def get_dashboard_stats():
|
|
||||||
...
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any, Literal, Optional
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
|
|
||||||
def to_camel(string: str) -> str:
|
|
||||||
"""snake_case를 camelCase로 변환
|
|
||||||
|
|
||||||
Args:
|
|
||||||
string: snake_case 문자열 (예: "content_metrics")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
camelCase 문자열 (예: "contentMetrics")
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> to_camel("content_metrics")
|
|
||||||
"contentMetrics"
|
|
||||||
>>> to_camel("this_year")
|
|
||||||
"thisYear"
|
|
||||||
"""
|
|
||||||
components = string.split("_")
|
|
||||||
return components[0] + "".join(x.capitalize() for x in components[1:])
|
|
||||||
|
|
||||||
|
|
||||||
class ContentMetric(BaseModel):
|
|
||||||
"""KPI 지표 카드
|
|
||||||
|
|
||||||
대시보드 상단에 표시되는 핵심 성과 지표(KPI) 카드입니다.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
id: 지표 고유 ID (예: "total-views", "total-watch-time", "new-subscribers")
|
|
||||||
label: 한글 라벨 (예: "조회수")
|
|
||||||
value: 원시 숫자값 (단위: unit 참조, 포맷팅은 프론트에서 처리)
|
|
||||||
unit: 값의 단위 — "count" | "hours" | "minutes"
|
|
||||||
- count: 조회수, 구독자, 좋아요, 댓글, 공유, 업로드 영상
|
|
||||||
- hours: 시청시간 (estimatedMinutesWatched / 60)
|
|
||||||
- minutes: 평균 시청시간 (averageViewDuration / 60)
|
|
||||||
trend: 이전 기간 대비 증감량 (unit과 동일한 단위)
|
|
||||||
trend_direction: 증감 방향 ("up": 증가, "down": 감소, "-": 변동 없음)
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> metric = ContentMetric(
|
|
||||||
... id="total-views",
|
|
||||||
... label="조회수",
|
|
||||||
... value=1200000.0,
|
|
||||||
... unit="count",
|
|
||||||
... trend=3800.0,
|
|
||||||
... trend_direction="up"
|
|
||||||
... )
|
|
||||||
"""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
label: str
|
|
||||||
value: float
|
|
||||||
unit: str = "count"
|
|
||||||
trend: float
|
|
||||||
trend_direction: Literal["up", "down", "-"] = Field(alias="trendDirection")
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
alias_generator=to_camel,
|
|
||||||
populate_by_name=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MonthlyData(BaseModel):
|
|
||||||
"""월별 추이 데이터
|
|
||||||
|
|
||||||
전년 대비 월별 조회수 비교 데이터입니다.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
month: 월 표시 (예: "1월", "2월")
|
|
||||||
this_year: 올해 해당 월 조회수
|
|
||||||
last_year: 작년 해당 월 조회수
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> data = MonthlyData(
|
|
||||||
... month="1월",
|
|
||||||
... this_year=150000,
|
|
||||||
... last_year=120000
|
|
||||||
... )
|
|
||||||
"""
|
|
||||||
|
|
||||||
month: str
|
|
||||||
this_year: int = Field(alias="thisYear")
|
|
||||||
last_year: int = Field(alias="lastYear")
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
alias_generator=to_camel,
|
|
||||||
populate_by_name=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DailyData(BaseModel):
|
|
||||||
"""일별 추이 데이터 (mode=day 전용)
|
|
||||||
|
|
||||||
최근 30일과 이전 30일의 일별 조회수 비교 데이터입니다.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
date: 날짜 표시 (예: "1/18", "1/19")
|
|
||||||
this_period: 최근 30일 조회수
|
|
||||||
last_period: 이전 30일 동일 요일 조회수
|
|
||||||
"""
|
|
||||||
|
|
||||||
date: str
|
|
||||||
this_period: int = Field(alias="thisPeriod")
|
|
||||||
last_period: int = Field(alias="lastPeriod")
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
alias_generator=to_camel,
|
|
||||||
populate_by_name=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TopContent(BaseModel):
|
|
||||||
"""인기 영상
|
|
||||||
|
|
||||||
조회수 기준 상위 인기 영상 정보입니다.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
id: YouTube 영상 ID
|
|
||||||
title: 영상 제목
|
|
||||||
thumbnail: 썸네일 이미지 URL
|
|
||||||
platform: 플랫폼 ("youtube" 또는 "instagram")
|
|
||||||
views: 원시 조회수 정수 (포맷팅은 프론트에서 처리, 예: 125400)
|
|
||||||
engagement: 참여율 (예: "8.2%")
|
|
||||||
date: 업로드 날짜 (예: "2026.01.15")
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> content = TopContent(
|
|
||||||
... id="video-id-1",
|
|
||||||
... title="힐링 영상",
|
|
||||||
... thumbnail="https://i.ytimg.com/...",
|
|
||||||
... platform="youtube",
|
|
||||||
... views=125400,
|
|
||||||
... engagement="8.2%",
|
|
||||||
... date="2026.01.15"
|
|
||||||
... )
|
|
||||||
"""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
title: str
|
|
||||||
thumbnail: str
|
|
||||||
platform: Literal["youtube", "instagram"]
|
|
||||||
views: int
|
|
||||||
engagement: str
|
|
||||||
date: str
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
alias_generator=to_camel,
|
|
||||||
populate_by_name=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AudienceData(BaseModel):
|
|
||||||
"""시청자 분석 데이터
|
|
||||||
|
|
||||||
시청자의 연령대, 성별, 지역 분포 데이터입니다.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
age_groups: 연령대별 시청자 비율 리스트
|
|
||||||
[{"label": "18-24", "percentage": 35}, ...]
|
|
||||||
gender: 성별 시청자 비율 (YouTube viewerPercentage 누적값)
|
|
||||||
{"male": 45, "female": 55}
|
|
||||||
top_regions: 상위 국가 리스트 (최대 5개)
|
|
||||||
[{"region": "대한민국", "percentage": 42}, ...]
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> data = AudienceData(
|
|
||||||
... age_groups=[{"label": "18-24", "percentage": 35}],
|
|
||||||
... gender={"male": 45, "female": 55},
|
|
||||||
... top_regions=[{"region": "대한민국", "percentage": 42}]
|
|
||||||
... )
|
|
||||||
"""
|
|
||||||
|
|
||||||
age_groups: list[dict[str, Any]] = Field(alias="ageGroups")
|
|
||||||
gender: dict[str, int]
|
|
||||||
top_regions: list[dict[str, Any]] = Field(alias="topRegions")
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
alias_generator=to_camel,
|
|
||||||
populate_by_name=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DashboardResponse(BaseModel):
|
|
||||||
"""대시보드 전체 응답
|
|
||||||
|
|
||||||
GET /dashboard/stats 엔드포인트의 전체 응답 스키마입니다.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
content_metrics: KPI 지표 카드 리스트 (8개)
|
|
||||||
monthly_data: 월별 추이 데이터 (mode=month 시 채움, 최근 12개월 vs 이전 12개월)
|
|
||||||
daily_data: 일별 추이 데이터 (mode=day 시 채움, 최근 30일 vs 이전 30일)
|
|
||||||
top_content: 조회수 기준 인기 영상 TOP 4
|
|
||||||
audience_data: 시청자 분석 데이터 (연령/성별/지역)
|
|
||||||
has_uploads: 업로드 영상 존재 여부 (False 시 모든 지표가 0, 빈 상태 UI 표시용)
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> response = DashboardResponse(
|
|
||||||
... content_metrics=[...],
|
|
||||||
... monthly_data=[...],
|
|
||||||
... top_content=[...],
|
|
||||||
... audience_data=AudienceData(...),
|
|
||||||
... )
|
|
||||||
>>> json_str = response.model_dump_json() # JSON 직렬화
|
|
||||||
"""
|
|
||||||
|
|
||||||
content_metrics: list[ContentMetric] = Field(alias="contentMetrics")
|
|
||||||
monthly_data: list[MonthlyData] = Field(default=[], alias="monthlyData")
|
|
||||||
daily_data: list[DailyData] = Field(default=[], alias="dailyData")
|
|
||||||
top_content: list[TopContent] = Field(alias="topContent")
|
|
||||||
audience_data: AudienceData = Field(alias="audienceData")
|
|
||||||
has_uploads: bool = Field(default=True, alias="hasUploads")
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
alias_generator=to_camel,
|
|
||||||
populate_by_name=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ConnectedAccount(BaseModel):
|
|
||||||
"""연결된 소셜 계정 정보
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
id: SocialAccount 테이블 PK
|
|
||||||
platform: 플랫폼 (예: "youtube")
|
|
||||||
platform_username: 플랫폼 사용자명 (예: "@channelname")
|
|
||||||
platform_user_id: 플랫폼 채널 고유 ID — 재연동해도 불변.
|
|
||||||
/dashboard/stats?platform_user_id=<값> 으로 계정 선택에 사용
|
|
||||||
channel_title: YouTube 채널 제목 (SocialAccount.platform_data JSON에서 추출)
|
|
||||||
connected_at: 연동 일시
|
|
||||||
is_active: 활성화 상태
|
|
||||||
"""
|
|
||||||
|
|
||||||
id: int
|
|
||||||
platform: str
|
|
||||||
platform_user_id: str
|
|
||||||
platform_username: Optional[str] = None
|
|
||||||
channel_title: Optional[str] = None
|
|
||||||
connected_at: datetime
|
|
||||||
is_active: bool
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
alias_generator=to_camel,
|
|
||||||
populate_by_name=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ConnectedAccountsResponse(BaseModel):
|
|
||||||
"""연결된 소셜 계정 목록 응답
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
accounts: 연결된 계정 목록
|
|
||||||
"""
|
|
||||||
|
|
||||||
accounts: list[ConnectedAccount]
|
|
||||||
|
|
||||||
|
|
||||||
class CacheDeleteResponse(BaseModel):
|
|
||||||
"""캐시 삭제 응답
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
deleted_count: 삭제된 캐시 키 개수
|
|
||||||
message: 처리 결과 메시지
|
|
||||||
"""
|
|
||||||
|
|
||||||
deleted_count: int
|
|
||||||
message: str
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
"""
|
|
||||||
Dashboard Services
|
|
||||||
|
|
||||||
YouTube Analytics API 연동 및 데이터 가공 서비스를 제공합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from app.dashboard.services.dashboard_service import DashboardService
|
|
||||||
from app.dashboard.services.data_processor import DataProcessor
|
|
||||||
from app.dashboard.services.youtube_analytics import YouTubeAnalyticsService
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"DashboardService",
|
|
||||||
"YouTubeAnalyticsService",
|
|
||||||
"DataProcessor",
|
|
||||||
]
|
|
||||||
|
|
@ -1,358 +0,0 @@
|
||||||
"""
|
|
||||||
Dashboard Service
|
|
||||||
|
|
||||||
대시보드 비즈니스 로직을 담당합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from datetime import date, datetime, timedelta
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.dashboard.exceptions import (
|
|
||||||
YouTubeAccountNotConnectedError,
|
|
||||||
YouTubeAccountNotFoundError,
|
|
||||||
YouTubeAccountSelectionRequiredError,
|
|
||||||
YouTubeTokenExpiredError,
|
|
||||||
)
|
|
||||||
from app.dashboard.models import Dashboard
|
|
||||||
from app.dashboard.utils.redis_cache import get_cache, set_cache
|
|
||||||
from app.dashboard.schemas import (
|
|
||||||
AudienceData,
|
|
||||||
ConnectedAccount,
|
|
||||||
ContentMetric,
|
|
||||||
DashboardResponse,
|
|
||||||
TopContent,
|
|
||||||
)
|
|
||||||
from app.dashboard.services.data_processor import DataProcessor
|
|
||||||
from app.dashboard.services.youtube_analytics import YouTubeAnalyticsService
|
|
||||||
from app.social.exceptions import TokenExpiredError
|
|
||||||
from app.social.services import SocialAccountService
|
|
||||||
from app.user.models import SocialAccount, User
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class DashboardService:
|
|
||||||
async def get_connected_accounts(
|
|
||||||
self,
|
|
||||||
current_user: User,
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> list[ConnectedAccount]:
|
|
||||||
result = await session.execute(
|
|
||||||
select(SocialAccount).where(
|
|
||||||
SocialAccount.user_uuid == current_user.user_uuid,
|
|
||||||
SocialAccount.platform == "youtube",
|
|
||||||
SocialAccount.is_active == True, # noqa: E712
|
|
||||||
)
|
|
||||||
)
|
|
||||||
accounts_raw = result.scalars().all()
|
|
||||||
|
|
||||||
connected = []
|
|
||||||
for acc in accounts_raw:
|
|
||||||
data = acc.platform_data if isinstance(acc.platform_data, dict) else {}
|
|
||||||
connected.append(
|
|
||||||
ConnectedAccount(
|
|
||||||
id=acc.id,
|
|
||||||
platform=acc.platform,
|
|
||||||
platform_username=acc.platform_username,
|
|
||||||
platform_user_id=acc.platform_user_id,
|
|
||||||
channel_title=data.get("channel_title"),
|
|
||||||
connected_at=acc.connected_at,
|
|
||||||
is_active=acc.is_active,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[ACCOUNTS] YouTube 계정 목록 조회 - "
|
|
||||||
f"user_uuid={current_user.user_uuid}, count={len(connected)}"
|
|
||||||
)
|
|
||||||
return connected
|
|
||||||
|
|
||||||
def calculate_date_range(
|
|
||||||
self, mode: Literal["day", "month"]
|
|
||||||
) -> tuple[date, date, date, date, date, str]:
|
|
||||||
"""모드별 날짜 범위 계산. (start_dt, end_dt, kpi_end_dt, prev_start_dt, prev_kpi_end_dt, period_desc) 반환"""
|
|
||||||
today = date.today()
|
|
||||||
|
|
||||||
if mode == "day":
|
|
||||||
end_dt = today - timedelta(days=2)
|
|
||||||
kpi_end_dt = end_dt
|
|
||||||
start_dt = end_dt - timedelta(days=29)
|
|
||||||
prev_start_dt = start_dt - timedelta(days=30)
|
|
||||||
prev_kpi_end_dt = kpi_end_dt - timedelta(days=30)
|
|
||||||
period_desc = "최근 30일"
|
|
||||||
else:
|
|
||||||
end_dt = today.replace(day=1)
|
|
||||||
kpi_end_dt = today - timedelta(days=2)
|
|
||||||
start_month = end_dt.month - 11
|
|
||||||
if start_month <= 0:
|
|
||||||
start_month += 12
|
|
||||||
start_year = end_dt.year - 1
|
|
||||||
else:
|
|
||||||
start_year = end_dt.year
|
|
||||||
start_dt = date(start_year, start_month, 1)
|
|
||||||
prev_start_dt = start_dt.replace(year=start_dt.year - 1)
|
|
||||||
try:
|
|
||||||
prev_kpi_end_dt = kpi_end_dt.replace(year=kpi_end_dt.year - 1)
|
|
||||||
except ValueError:
|
|
||||||
prev_kpi_end_dt = kpi_end_dt.replace(year=kpi_end_dt.year - 1, day=28)
|
|
||||||
period_desc = "최근 12개월"
|
|
||||||
|
|
||||||
return start_dt, end_dt, kpi_end_dt, prev_start_dt, prev_kpi_end_dt, period_desc
|
|
||||||
|
|
||||||
async def resolve_social_account(
|
|
||||||
self,
|
|
||||||
current_user: User,
|
|
||||||
session: AsyncSession,
|
|
||||||
platform_user_id: str | None,
|
|
||||||
) -> SocialAccount:
|
|
||||||
result = await session.execute(
|
|
||||||
select(SocialAccount).where(
|
|
||||||
SocialAccount.user_uuid == current_user.user_uuid,
|
|
||||||
SocialAccount.platform == "youtube",
|
|
||||||
SocialAccount.is_active == True, # noqa: E712
|
|
||||||
)
|
|
||||||
)
|
|
||||||
social_accounts_raw = result.scalars().all()
|
|
||||||
|
|
||||||
social_accounts = list(social_accounts_raw)
|
|
||||||
|
|
||||||
if not social_accounts:
|
|
||||||
raise YouTubeAccountNotConnectedError()
|
|
||||||
|
|
||||||
if platform_user_id is not None:
|
|
||||||
matched = [a for a in social_accounts if a.platform_user_id == platform_user_id]
|
|
||||||
if not matched:
|
|
||||||
raise YouTubeAccountNotFoundError()
|
|
||||||
return matched[0]
|
|
||||||
elif len(social_accounts) == 1:
|
|
||||||
return social_accounts[0]
|
|
||||||
else:
|
|
||||||
raise YouTubeAccountSelectionRequiredError()
|
|
||||||
|
|
||||||
async def get_video_counts(
|
|
||||||
self,
|
|
||||||
current_user: User,
|
|
||||||
session: AsyncSession,
|
|
||||||
social_account: SocialAccount,
|
|
||||||
start_dt: date,
|
|
||||||
prev_start_dt: date,
|
|
||||||
prev_kpi_end_dt: date,
|
|
||||||
) -> tuple[int, int]:
|
|
||||||
today = date.today()
|
|
||||||
count_result = await session.execute(
|
|
||||||
select(func.count())
|
|
||||||
.select_from(Dashboard)
|
|
||||||
.where(
|
|
||||||
Dashboard.user_uuid == current_user.user_uuid,
|
|
||||||
Dashboard.platform == "youtube",
|
|
||||||
Dashboard.platform_user_id == social_account.platform_user_id,
|
|
||||||
Dashboard.uploaded_at >= start_dt,
|
|
||||||
Dashboard.uploaded_at < today + timedelta(days=1),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
period_video_count = count_result.scalar() or 0
|
|
||||||
|
|
||||||
prev_count_result = await session.execute(
|
|
||||||
select(func.count())
|
|
||||||
.select_from(Dashboard)
|
|
||||||
.where(
|
|
||||||
Dashboard.user_uuid == current_user.user_uuid,
|
|
||||||
Dashboard.platform == "youtube",
|
|
||||||
Dashboard.platform_user_id == social_account.platform_user_id,
|
|
||||||
Dashboard.uploaded_at >= prev_start_dt,
|
|
||||||
Dashboard.uploaded_at <= prev_kpi_end_dt,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
prev_period_video_count = prev_count_result.scalar() or 0
|
|
||||||
|
|
||||||
return period_video_count, prev_period_video_count
|
|
||||||
|
|
||||||
async def get_video_ids(
|
|
||||||
self,
|
|
||||||
current_user: User,
|
|
||||||
session: AsyncSession,
|
|
||||||
social_account: SocialAccount,
|
|
||||||
) -> tuple[list[str], dict[str, tuple[str, datetime]]]:
|
|
||||||
result = await session.execute(
|
|
||||||
select(
|
|
||||||
Dashboard.platform_video_id,
|
|
||||||
Dashboard.title,
|
|
||||||
Dashboard.uploaded_at,
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
Dashboard.user_uuid == current_user.user_uuid,
|
|
||||||
Dashboard.platform == "youtube",
|
|
||||||
Dashboard.platform_user_id == social_account.platform_user_id,
|
|
||||||
)
|
|
||||||
.order_by(Dashboard.uploaded_at.desc())
|
|
||||||
.limit(30)
|
|
||||||
)
|
|
||||||
rows = result.all()
|
|
||||||
|
|
||||||
video_ids = []
|
|
||||||
video_lookup: dict[str, tuple[str, datetime]] = {}
|
|
||||||
for row in rows:
|
|
||||||
platform_video_id, title, uploaded_at = row
|
|
||||||
video_ids.append(platform_video_id)
|
|
||||||
video_lookup[platform_video_id] = (title, uploaded_at)
|
|
||||||
|
|
||||||
return video_ids, video_lookup
|
|
||||||
|
|
||||||
def build_empty_response(self) -> DashboardResponse:
|
|
||||||
return DashboardResponse(
|
|
||||||
content_metrics=[
|
|
||||||
ContentMetric(id="total-views", label="조회수", value=0.0, unit="count", trend=0.0, trend_direction="-"),
|
|
||||||
ContentMetric(id="total-watch-time", label="시청시간", value=0.0, unit="hours", trend=0.0, trend_direction="-"),
|
|
||||||
ContentMetric(id="avg-view-duration", label="평균 시청시간", value=0.0, unit="minutes", trend=0.0, trend_direction="-"),
|
|
||||||
ContentMetric(id="new-subscribers", label="신규 구독자", value=0.0, unit="count", trend=0.0, trend_direction="-"),
|
|
||||||
ContentMetric(id="likes", label="좋아요", value=0.0, unit="count", trend=0.0, trend_direction="-"),
|
|
||||||
ContentMetric(id="comments", label="댓글", value=0.0, unit="count", trend=0.0, trend_direction="-"),
|
|
||||||
ContentMetric(id="shares", label="공유", value=0.0, unit="count", trend=0.0, trend_direction="-"),
|
|
||||||
ContentMetric(id="uploaded-videos", label="업로드 영상", value=0.0, unit="count", trend=0.0, trend_direction="-"),
|
|
||||||
],
|
|
||||||
monthly_data=[],
|
|
||||||
daily_data=[],
|
|
||||||
top_content=[],
|
|
||||||
audience_data=AudienceData(age_groups=[], gender={"male": 0, "female": 0}, top_regions=[]),
|
|
||||||
has_uploads=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
def inject_video_count(
|
|
||||||
self,
|
|
||||||
response: DashboardResponse,
|
|
||||||
period_video_count: int,
|
|
||||||
prev_period_video_count: int,
|
|
||||||
) -> None:
|
|
||||||
for metric in response.content_metrics:
|
|
||||||
if metric.id == "uploaded-videos":
|
|
||||||
metric.value = float(period_video_count)
|
|
||||||
video_trend = float(period_video_count - prev_period_video_count)
|
|
||||||
metric.trend = video_trend
|
|
||||||
metric.trend_direction = "up" if video_trend > 0 else ("down" if video_trend < 0 else "-")
|
|
||||||
break
|
|
||||||
|
|
||||||
async def get_stats(
|
|
||||||
self,
|
|
||||||
mode: Literal["day", "month"],
|
|
||||||
platform_user_id: str | None,
|
|
||||||
current_user: User,
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> DashboardResponse:
|
|
||||||
logger.info(
|
|
||||||
f"[DASHBOARD] 통계 조회 시작 - "
|
|
||||||
f"user_uuid={current_user.user_uuid}, mode={mode}, platform_user_id={platform_user_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 1. 날짜 계산
|
|
||||||
start_dt, end_dt, kpi_end_dt, prev_start_dt, prev_kpi_end_dt, period_desc = (
|
|
||||||
self.calculate_date_range(mode)
|
|
||||||
)
|
|
||||||
start_date = start_dt.strftime("%Y-%m-%d")
|
|
||||||
end_date = end_dt.strftime("%Y-%m-%d")
|
|
||||||
kpi_end_date = kpi_end_dt.strftime("%Y-%m-%d")
|
|
||||||
logger.debug(f"[1] 날짜 계산 완료 - period={period_desc}, start={start_date}, end={end_date}")
|
|
||||||
|
|
||||||
# 2. YouTube 계정 확인
|
|
||||||
social_account = await self.resolve_social_account(current_user, session, platform_user_id)
|
|
||||||
logger.debug(f"[2] YouTube 계정 확인 완료 - platform_user_id={social_account.platform_user_id}")
|
|
||||||
|
|
||||||
# 3. 영상 수 조회
|
|
||||||
period_video_count, prev_period_video_count = await self.get_video_counts(
|
|
||||||
current_user, session, social_account, start_dt, prev_start_dt, prev_kpi_end_dt
|
|
||||||
)
|
|
||||||
logger.debug(f"[3] 영상 수 - current={period_video_count}, prev={prev_period_video_count}")
|
|
||||||
|
|
||||||
# 4. 캐시 조회
|
|
||||||
cache_key = f"dashboard:{current_user.user_uuid}:{social_account.platform_user_id}:{mode}"
|
|
||||||
cached_raw = await get_cache(cache_key)
|
|
||||||
if cached_raw:
|
|
||||||
try:
|
|
||||||
payload = json.loads(cached_raw)
|
|
||||||
logger.info(f"[CACHE HIT] 캐시 반환 - user_uuid={current_user.user_uuid}")
|
|
||||||
response = DashboardResponse.model_validate(payload["response"])
|
|
||||||
self.inject_video_count(response, period_video_count, prev_period_video_count)
|
|
||||||
return response
|
|
||||||
except (json.JSONDecodeError, KeyError):
|
|
||||||
logger.warning(f"[CACHE PARSE ERROR] 포맷 오류, 무시 - key={cache_key}")
|
|
||||||
|
|
||||||
logger.debug("[4] 캐시 MISS - YouTube API 호출 필요")
|
|
||||||
|
|
||||||
# 5. 업로드 영상 조회
|
|
||||||
video_ids, video_lookup = await self.get_video_ids(current_user, session, social_account)
|
|
||||||
logger.debug(f"[5] 영상 조회 완료 - count={len(video_ids)}")
|
|
||||||
|
|
||||||
if not video_ids:
|
|
||||||
logger.info(f"[DASHBOARD] 업로드 영상 없음, 빈 응답 반환 - user_uuid={current_user.user_uuid}")
|
|
||||||
return self.build_empty_response()
|
|
||||||
|
|
||||||
# 6. 토큰 유효성 확인
|
|
||||||
try:
|
|
||||||
access_token = await SocialAccountService().ensure_valid_token(social_account, session)
|
|
||||||
except TokenExpiredError:
|
|
||||||
logger.warning(f"[TOKEN EXPIRED] 재연동 필요 - user_uuid={current_user.user_uuid}")
|
|
||||||
raise YouTubeTokenExpiredError()
|
|
||||||
logger.debug("[6] 토큰 유효성 확인 완료")
|
|
||||||
|
|
||||||
# 7. YouTube Analytics API 호출
|
|
||||||
youtube_service = YouTubeAnalyticsService()
|
|
||||||
raw_data = await youtube_service.fetch_all_metrics(
|
|
||||||
video_ids=video_ids,
|
|
||||||
start_date=start_date,
|
|
||||||
end_date=end_date,
|
|
||||||
kpi_end_date=kpi_end_date,
|
|
||||||
access_token=access_token,
|
|
||||||
mode=mode,
|
|
||||||
)
|
|
||||||
logger.debug("[7] YouTube Analytics API 호출 완료")
|
|
||||||
|
|
||||||
# 8. TopContent 조립
|
|
||||||
processor = DataProcessor()
|
|
||||||
top_content_rows = raw_data.get("top_videos", {}).get("rows", [])
|
|
||||||
top_content: list[TopContent] = []
|
|
||||||
for row in top_content_rows[:4]:
|
|
||||||
if len(row) < 4:
|
|
||||||
continue
|
|
||||||
video_id, views, likes, comments = row[0], row[1], row[2], row[3]
|
|
||||||
meta = video_lookup.get(video_id)
|
|
||||||
if not meta:
|
|
||||||
continue
|
|
||||||
title, uploaded_at = meta
|
|
||||||
engagement_rate = ((likes + comments) / views * 100) if views > 0 else 0
|
|
||||||
top_content.append(
|
|
||||||
TopContent(
|
|
||||||
id=video_id,
|
|
||||||
title=title,
|
|
||||||
thumbnail=f"https://i.ytimg.com/vi/{video_id}/mqdefault.jpg",
|
|
||||||
platform="youtube",
|
|
||||||
views=int(views),
|
|
||||||
engagement=f"{engagement_rate:.1f}%",
|
|
||||||
date=uploaded_at.strftime("%Y.%m.%d"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
logger.debug(f"[8] TopContent 조립 완료 - count={len(top_content)}")
|
|
||||||
|
|
||||||
# 9. 데이터 가공
|
|
||||||
dashboard_data = processor.process(raw_data, top_content, 0, mode=mode, end_date=end_date)
|
|
||||||
logger.debug("[9] 데이터 가공 완료")
|
|
||||||
|
|
||||||
# 10. 캐시 저장
|
|
||||||
cache_payload = json.dumps({"response": dashboard_data.model_dump(mode="json")})
|
|
||||||
cache_success = await set_cache(cache_key, cache_payload, ttl=43200)
|
|
||||||
if cache_success:
|
|
||||||
logger.debug(f"[CACHE SET] 캐시 저장 성공 - key={cache_key}")
|
|
||||||
else:
|
|
||||||
logger.warning(f"[CACHE SET] 캐시 저장 실패 - key={cache_key}")
|
|
||||||
|
|
||||||
# 11. 업로드 영상 수 주입
|
|
||||||
self.inject_video_count(dashboard_data, period_video_count, prev_period_video_count)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[DASHBOARD] 통계 조회 완료 - "
|
|
||||||
f"user_uuid={current_user.user_uuid}, mode={mode}, period={period_desc}, videos={len(video_ids)}"
|
|
||||||
)
|
|
||||||
return dashboard_data
|
|
||||||
|
|
@ -1,542 +0,0 @@
|
||||||
"""
|
|
||||||
YouTube Analytics 데이터 가공 프로세서
|
|
||||||
|
|
||||||
YouTube Analytics API의 원본 데이터를 프론트엔드용 Pydantic 스키마로 변환합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from collections import defaultdict
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Any, Literal
|
|
||||||
|
|
||||||
from app.dashboard.schemas import (
|
|
||||||
AudienceData,
|
|
||||||
ContentMetric,
|
|
||||||
DailyData,
|
|
||||||
DashboardResponse,
|
|
||||||
MonthlyData,
|
|
||||||
TopContent,
|
|
||||||
)
|
|
||||||
from app.utils.logger import get_logger
|
|
||||||
|
|
||||||
logger = get_logger("dashboard")
|
|
||||||
|
|
||||||
_COUNTRY_CODE_MAP: dict[str, str] = {
|
|
||||||
"KR": "대한민국",
|
|
||||||
"US": "미국",
|
|
||||||
"JP": "일본",
|
|
||||||
"CN": "중국",
|
|
||||||
"GB": "영국",
|
|
||||||
"DE": "독일",
|
|
||||||
"FR": "프랑스",
|
|
||||||
"CA": "캐나다",
|
|
||||||
"AU": "호주",
|
|
||||||
"IN": "인도",
|
|
||||||
"ID": "인도네시아",
|
|
||||||
"TH": "태국",
|
|
||||||
"VN": "베트남",
|
|
||||||
"PH": "필리핀",
|
|
||||||
"MY": "말레이시아",
|
|
||||||
"SG": "싱가포르",
|
|
||||||
"TW": "대만",
|
|
||||||
"HK": "홍콩",
|
|
||||||
"BR": "브라질",
|
|
||||||
"MX": "멕시코",
|
|
||||||
"NL": "네덜란드",
|
|
||||||
"BE": "벨기에",
|
|
||||||
"SE": "스웨덴",
|
|
||||||
"NO": "노르웨이",
|
|
||||||
"FI": "핀란드",
|
|
||||||
"DK": "덴마크",
|
|
||||||
"IE": "아일랜드",
|
|
||||||
"PL": "폴란드",
|
|
||||||
"CZ": "체코",
|
|
||||||
"RO": "루마니아",
|
|
||||||
"HU": "헝가리",
|
|
||||||
"SK": "슬로바키아",
|
|
||||||
"SI": "슬로베니아",
|
|
||||||
"HR": "크로아티아",
|
|
||||||
"GR": "그리스",
|
|
||||||
"PT": "포르투갈",
|
|
||||||
"ES": "스페인",
|
|
||||||
"IT": "이탈리아",
|
|
||||||
}
|
|
||||||
|
|
||||||
class DataProcessor:
|
|
||||||
"""YouTube Analytics 데이터 가공 프로세서
|
|
||||||
|
|
||||||
YouTube Analytics API의 원본 JSON 데이터를 DashboardResponse 스키마로 변환합니다.
|
|
||||||
각 섹션별로 데이터 가공 로직을 분리하여 유지보수성을 향상시켰습니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def process(
|
|
||||||
self,
|
|
||||||
raw_data: dict[str, Any],
|
|
||||||
top_content: list[TopContent],
|
|
||||||
period_video_count: int = 0,
|
|
||||||
mode: Literal["day", "month"] = "month",
|
|
||||||
end_date: str = "",
|
|
||||||
) -> DashboardResponse:
|
|
||||||
"""YouTube Analytics API 원본 데이터를 DashboardResponse로 변환
|
|
||||||
|
|
||||||
Args:
|
|
||||||
raw_data: YouTube Analytics API 응답 데이터 (mode에 따라 키 구성 다름)
|
|
||||||
공통:
|
|
||||||
- kpi: KPI 메트릭 (조회수, 좋아요, 댓글, 시청시간 등)
|
|
||||||
- top_videos: 인기 영상 데이터
|
|
||||||
- demographics: 연령/성별 데이터
|
|
||||||
- region: 지역별 데이터
|
|
||||||
mode="month" 추가:
|
|
||||||
- trend_recent: 최근 12개월 월별 조회수
|
|
||||||
- trend_previous: 이전 12개월 월별 조회수
|
|
||||||
mode="day" 추가:
|
|
||||||
- trend_recent: 최근 30일 일별 조회수
|
|
||||||
- trend_previous: 이전 30일 일별 조회수
|
|
||||||
top_content: TopContent 리스트 (라우터에서 Analytics + DB lookup으로 생성)
|
|
||||||
period_video_count: 조회 기간 내 업로드된 영상 수 (DB에서 집계)
|
|
||||||
mode: 조회 모드 ("month" | "day")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
DashboardResponse: 프론트엔드용 대시보드 응답 스키마
|
|
||||||
- mode="month": monthly_data 채움, daily_data=[]
|
|
||||||
- mode="day": daily_data 채움, monthly_data=[]
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> processor = DataProcessor()
|
|
||||||
>>> response = processor.process(
|
|
||||||
... raw_data={
|
|
||||||
... "kpi": {...},
|
|
||||||
... "monthly_recent": {...},
|
|
||||||
... "monthly_previous": {...},
|
|
||||||
... "top_videos": {...},
|
|
||||||
... "demographics": {...},
|
|
||||||
... "region": {...},
|
|
||||||
... },
|
|
||||||
... top_content=[TopContent(...)],
|
|
||||||
... mode="month",
|
|
||||||
... )
|
|
||||||
"""
|
|
||||||
logger.debug(
|
|
||||||
f"[DataProcessor.process] START - "
|
|
||||||
f"top_content_count={len(top_content)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 각 섹션별 데이터 가공 (안전한 딕셔너리 접근)
|
|
||||||
content_metrics = self._build_content_metrics(
|
|
||||||
raw_data.get("kpi", {}),
|
|
||||||
raw_data.get("kpi_previous", {}),
|
|
||||||
period_video_count,
|
|
||||||
)
|
|
||||||
|
|
||||||
if mode == "month":
|
|
||||||
monthly_data = self._merge_monthly_data(
|
|
||||||
raw_data.get("trend_recent", {}),
|
|
||||||
raw_data.get("trend_previous", {}),
|
|
||||||
end_date=end_date,
|
|
||||||
)
|
|
||||||
daily_data: list[DailyData] = []
|
|
||||||
else: # mode == "day"
|
|
||||||
daily_data = self._build_daily_data(
|
|
||||||
raw_data.get("trend_recent", {}),
|
|
||||||
raw_data.get("trend_previous", {}),
|
|
||||||
end_date=end_date,
|
|
||||||
)
|
|
||||||
monthly_data = []
|
|
||||||
|
|
||||||
audience_data = self._build_audience_data(
|
|
||||||
raw_data.get("demographics") or {},
|
|
||||||
raw_data.get("region") or {},
|
|
||||||
)
|
|
||||||
logger.debug(
|
|
||||||
f"[DataProcessor.process] SUCCESS - "
|
|
||||||
f"mode={mode}, metrics={len(content_metrics)}, "
|
|
||||||
f"top_content={len(top_content)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return DashboardResponse(
|
|
||||||
content_metrics=content_metrics,
|
|
||||||
monthly_data=monthly_data,
|
|
||||||
daily_data=daily_data,
|
|
||||||
top_content=top_content,
|
|
||||||
audience_data=audience_data,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _build_content_metrics(
|
|
||||||
self,
|
|
||||||
kpi_data: dict[str, Any],
|
|
||||||
kpi_previous_data: dict[str, Any],
|
|
||||||
period_video_count: int = 0,
|
|
||||||
) -> list[ContentMetric]:
|
|
||||||
"""KPI 데이터를 ContentMetric 리스트로 변환
|
|
||||||
|
|
||||||
Args:
|
|
||||||
kpi_data: 최근 기간 KPI 응답
|
|
||||||
rows[0] = [views, likes, comments, shares,
|
|
||||||
estimatedMinutesWatched, averageViewDuration,
|
|
||||||
subscribersGained]
|
|
||||||
kpi_previous_data: 이전 기간 KPI 응답 (증감률 계산용)
|
|
||||||
period_video_count: 조회 기간 내 업로드된 영상 수 (DB에서 집계)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list[ContentMetric]: KPI 지표 카드 리스트 (8개)
|
|
||||||
순서: 조회수, 시청시간, 평균 시청시간, 신규 구독자, 좋아요, 댓글, 공유, 업로드 영상
|
|
||||||
"""
|
|
||||||
logger.info(
|
|
||||||
f"[DataProcessor._build_content_metrics] START - "
|
|
||||||
f"kpi_keys={list(kpi_data.keys())}"
|
|
||||||
)
|
|
||||||
|
|
||||||
rows = kpi_data.get("rows", [])
|
|
||||||
if not rows or not rows[0]:
|
|
||||||
logger.warning(
|
|
||||||
f"[DataProcessor._build_content_metrics] NO_DATA - " f"rows={rows}"
|
|
||||||
)
|
|
||||||
return []
|
|
||||||
|
|
||||||
row = rows[0]
|
|
||||||
prev_rows = kpi_previous_data.get("rows", [])
|
|
||||||
prev_row = prev_rows[0] if prev_rows else []
|
|
||||||
|
|
||||||
def _get(r: list, i: int, default: float = 0.0) -> float:
|
|
||||||
return r[i] if len(r) > i else default
|
|
||||||
|
|
||||||
def _trend(recent: float, previous: float) -> tuple[float, str]:
|
|
||||||
pct = recent - previous
|
|
||||||
if pct > 0:
|
|
||||||
direction = "up"
|
|
||||||
elif pct < 0:
|
|
||||||
direction = "down"
|
|
||||||
else:
|
|
||||||
direction = "-"
|
|
||||||
return pct, direction
|
|
||||||
|
|
||||||
# 최근 기간
|
|
||||||
views = _get(row, 0)
|
|
||||||
likes = _get(row, 1)
|
|
||||||
comments = _get(row, 2)
|
|
||||||
shares = _get(row, 3)
|
|
||||||
estimated_minutes_watched = _get(row, 4)
|
|
||||||
average_view_duration = _get(row, 5)
|
|
||||||
subscribers_gained = _get(row, 6)
|
|
||||||
|
|
||||||
# 이전 기간
|
|
||||||
prev_views = _get(prev_row, 0)
|
|
||||||
prev_likes = _get(prev_row, 1)
|
|
||||||
prev_comments = _get(prev_row, 2)
|
|
||||||
prev_shares = _get(prev_row, 3)
|
|
||||||
prev_minutes_watched = _get(prev_row, 4)
|
|
||||||
prev_avg_duration = _get(prev_row, 5)
|
|
||||||
prev_subscribers = _get(prev_row, 6)
|
|
||||||
|
|
||||||
views_trend, views_dir = _trend(views, prev_views)
|
|
||||||
watch_trend, watch_dir = _trend(estimated_minutes_watched, prev_minutes_watched)
|
|
||||||
duration_trend, duration_dir = _trend(average_view_duration, prev_avg_duration)
|
|
||||||
subs_trend, subs_dir = _trend(subscribers_gained, prev_subscribers)
|
|
||||||
likes_trend, likes_dir = _trend(likes, prev_likes)
|
|
||||||
comments_trend, comments_dir = _trend(comments, prev_comments)
|
|
||||||
shares_trend, shares_dir = _trend(shares, prev_shares)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[DataProcessor._build_content_metrics] SUCCESS - "
|
|
||||||
f"views={views}({views_trend:+.1f}), "
|
|
||||||
f"watch_time={estimated_minutes_watched}min({watch_trend:+.1f}), "
|
|
||||||
f"subscribers={subscribers_gained}({subs_trend:+.1f})"
|
|
||||||
)
|
|
||||||
|
|
||||||
return [
|
|
||||||
ContentMetric(
|
|
||||||
id="total-views",
|
|
||||||
label="조회수",
|
|
||||||
value=float(views),
|
|
||||||
unit="count",
|
|
||||||
trend=round(float(views_trend), 1),
|
|
||||||
trend_direction=views_dir,
|
|
||||||
),
|
|
||||||
ContentMetric(
|
|
||||||
id="total-watch-time",
|
|
||||||
label="시청시간",
|
|
||||||
value=round(estimated_minutes_watched / 60, 1),
|
|
||||||
unit="hours",
|
|
||||||
trend=round(watch_trend / 60, 1),
|
|
||||||
trend_direction=watch_dir,
|
|
||||||
),
|
|
||||||
ContentMetric(
|
|
||||||
id="avg-view-duration",
|
|
||||||
label="평균 시청시간",
|
|
||||||
value=round(average_view_duration / 60, 1),
|
|
||||||
unit="minutes",
|
|
||||||
trend=round(duration_trend / 60, 1),
|
|
||||||
trend_direction=duration_dir,
|
|
||||||
),
|
|
||||||
ContentMetric(
|
|
||||||
id="new-subscribers",
|
|
||||||
label="신규 구독자",
|
|
||||||
value=float(subscribers_gained),
|
|
||||||
unit="count",
|
|
||||||
trend=subs_trend,
|
|
||||||
trend_direction=subs_dir,
|
|
||||||
),
|
|
||||||
ContentMetric(
|
|
||||||
id="likes",
|
|
||||||
label="좋아요",
|
|
||||||
value=float(likes),
|
|
||||||
unit="count",
|
|
||||||
trend=likes_trend,
|
|
||||||
trend_direction=likes_dir,
|
|
||||||
),
|
|
||||||
ContentMetric(
|
|
||||||
id="comments",
|
|
||||||
label="댓글",
|
|
||||||
value=float(comments),
|
|
||||||
unit="count",
|
|
||||||
trend=comments_trend,
|
|
||||||
trend_direction=comments_dir,
|
|
||||||
),
|
|
||||||
ContentMetric(
|
|
||||||
id="shares",
|
|
||||||
label="공유",
|
|
||||||
value=float(shares),
|
|
||||||
unit="count",
|
|
||||||
trend=shares_trend,
|
|
||||||
trend_direction=shares_dir,
|
|
||||||
),
|
|
||||||
ContentMetric(
|
|
||||||
id="uploaded-videos",
|
|
||||||
label="업로드 영상",
|
|
||||||
value=float(period_video_count),
|
|
||||||
unit="count",
|
|
||||||
trend=0.0,
|
|
||||||
trend_direction="-",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
def _merge_monthly_data(
|
|
||||||
self,
|
|
||||||
data_recent: dict[str, Any],
|
|
||||||
data_previous: dict[str, Any],
|
|
||||||
end_date: str = "",
|
|
||||||
) -> list[MonthlyData]:
|
|
||||||
"""최근 12개월과 이전 12개월의 월별 데이터를 병합
|
|
||||||
|
|
||||||
end_date 기준 12개월을 명시 생성하여 API가 반환하지 않은 월(당월 등)도 0으로 포함합니다.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
data_recent: 최근 12개월 월별 조회수 데이터
|
|
||||||
rows = [["2026-01", 150000], ["2026-02", 180000], ...]
|
|
||||||
data_previous: 이전 12개월 월별 조회수 데이터
|
|
||||||
rows = [["2025-01", 120000], ["2025-02", 140000], ...]
|
|
||||||
end_date: 기준 종료일 (YYYY-MM-DD). 미전달 시 오늘 사용
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list[MonthlyData]: 월별 비교 데이터 (12개, API 미반환 월은 0)
|
|
||||||
"""
|
|
||||||
logger.debug("[DataProcessor._merge_monthly_data] START")
|
|
||||||
|
|
||||||
rows_recent = data_recent.get("rows", [])
|
|
||||||
rows_previous = data_previous.get("rows", [])
|
|
||||||
|
|
||||||
map_recent = {row[0]: row[1] for row in rows_recent if len(row) >= 2}
|
|
||||||
map_previous = {row[0]: row[1] for row in rows_previous if len(row) >= 2}
|
|
||||||
|
|
||||||
# end_date 기준 12개월 명시 생성 (API 미반환 당월도 0으로 포함)
|
|
||||||
if end_date:
|
|
||||||
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
|
|
||||||
else:
|
|
||||||
end_dt = datetime.today()
|
|
||||||
|
|
||||||
result = []
|
|
||||||
for i in range(11, -1, -1):
|
|
||||||
m = end_dt.month - i
|
|
||||||
y = end_dt.year
|
|
||||||
if m <= 0:
|
|
||||||
m += 12
|
|
||||||
y -= 1
|
|
||||||
month_key = f"{y}-{m:02d}"
|
|
||||||
result.append(
|
|
||||||
MonthlyData(
|
|
||||||
month=f"{m}월",
|
|
||||||
this_year=map_recent.get(month_key, 0),
|
|
||||||
last_year=map_previous.get(f"{y - 1}-{m:02d}", 0),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
f"[DataProcessor._merge_monthly_data] SUCCESS - count={len(result)}"
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _build_daily_data(
|
|
||||||
self,
|
|
||||||
data_recent: dict[str, Any],
|
|
||||||
data_previous: dict[str, Any],
|
|
||||||
end_date: str = "",
|
|
||||||
num_days: int = 30,
|
|
||||||
) -> list[DailyData]:
|
|
||||||
"""최근 30일과 이전 30일의 일별 데이터를 병합
|
|
||||||
|
|
||||||
end_date 기준 num_days개 날짜를 직접 생성하여 YouTube API 응답에
|
|
||||||
해당 날짜 row가 없어도 0으로 채웁니다 (X축 누락 방지).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
data_recent: 최근 30일 일별 조회수 데이터
|
|
||||||
rows = [["2026-01-20", 5000], ["2026-01-21", 6200], ...]
|
|
||||||
data_previous: 이전 30일 일별 조회수 데이터
|
|
||||||
rows = [["2025-12-21", 4500], ["2025-12-22", 5100], ...]
|
|
||||||
end_date: 최근 기간의 마지막 날 (YYYY-MM-DD). 미전달 시 rows 마지막 날 사용
|
|
||||||
num_days: 표시할 일수 (기본 30)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list[DailyData]: 일별 비교 데이터 (num_days개, 데이터 없는 날은 0)
|
|
||||||
"""
|
|
||||||
logger.debug("[DataProcessor._build_daily_data] START")
|
|
||||||
|
|
||||||
rows_recent = data_recent.get("rows", [])
|
|
||||||
rows_previous = data_previous.get("rows", [])
|
|
||||||
|
|
||||||
# 날짜 → 조회수 맵
|
|
||||||
map_recent = {row[0]: row[1] for row in rows_recent if len(row) >= 2}
|
|
||||||
map_previous = {row[0]: row[1] for row in rows_previous if len(row) >= 2}
|
|
||||||
|
|
||||||
# end_date 결정: 전달된 값 우선, 없으면 rows 마지막 날짜 사용
|
|
||||||
if end_date:
|
|
||||||
end_dt = datetime.strptime(end_date, "%Y-%m-%d").date()
|
|
||||||
elif rows_recent:
|
|
||||||
end_dt = datetime.strptime(rows_recent[-1][0], "%Y-%m-%d").date()
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
"[DataProcessor._build_daily_data] NO_DATA - rows_recent 비어있음"
|
|
||||||
)
|
|
||||||
return []
|
|
||||||
|
|
||||||
start_dt = end_dt - timedelta(days=num_days - 1)
|
|
||||||
|
|
||||||
# 날짜 범위를 직접 생성하여 누락된 날짜도 0으로 채움
|
|
||||||
result = []
|
|
||||||
current = start_dt
|
|
||||||
while current <= end_dt:
|
|
||||||
date_str = current.strftime("%Y-%m-%d")
|
|
||||||
date_label = f"{current.month}/{current.day}"
|
|
||||||
|
|
||||||
this_views = map_recent.get(date_str, 0)
|
|
||||||
|
|
||||||
# 이전 기간: 동일 인덱스 날짜 (current - 30일)
|
|
||||||
prev_date_str = (current - timedelta(days=num_days)).strftime("%Y-%m-%d")
|
|
||||||
last_views = map_previous.get(prev_date_str, 0)
|
|
||||||
|
|
||||||
result.append(
|
|
||||||
DailyData(
|
|
||||||
date=date_label,
|
|
||||||
this_period=int(this_views),
|
|
||||||
last_period=int(last_views),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
current += timedelta(days=1)
|
|
||||||
|
|
||||||
logger.debug(f"[DataProcessor._build_daily_data] SUCCESS - count={len(result)}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _build_audience_data(
|
|
||||||
self,
|
|
||||||
demographics_data: dict[str, Any],
|
|
||||||
geography_data: dict[str, Any],
|
|
||||||
) -> AudienceData:
|
|
||||||
"""시청자 분석 데이터 생성
|
|
||||||
|
|
||||||
연령대별, 성별, 지역별 시청자 분포를 분석합니다.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
demographics_data: 연령/성별 API 응답
|
|
||||||
rows = [["age18-24", "male", 45000], ["age18-24", "female", 55000], ...]
|
|
||||||
geography_data: 지역별 API 응답
|
|
||||||
rows = [["KR", 1000000], ["US", 500000], ...]
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
AudienceData: 시청자 분석 데이터
|
|
||||||
- age_groups: 연령대별 비율
|
|
||||||
- gender: 성별 조회수
|
|
||||||
- top_regions: 상위 지역 (5개)
|
|
||||||
"""
|
|
||||||
logger.debug("[DataProcessor._build_audience_data] START")
|
|
||||||
|
|
||||||
# === 연령/성별 데이터 처리 ===
|
|
||||||
demo_rows = demographics_data.get("rows", [])
|
|
||||||
|
|
||||||
age_map: dict[str, float] = {}
|
|
||||||
gender_map_f: dict[str, float] = {"male": 0.0, "female": 0.0}
|
|
||||||
|
|
||||||
for row in demo_rows:
|
|
||||||
if len(row) < 3:
|
|
||||||
continue
|
|
||||||
|
|
||||||
age_group = row[0] # "age18-24"
|
|
||||||
gender = row[1] # "male" or "female"
|
|
||||||
viewer_pct = row[2] # viewerPercentage (이미 % 값, 예: 45.5)
|
|
||||||
|
|
||||||
# 연령대별 집계: 남녀 비율 합산 (age18-24 → 18-24)
|
|
||||||
age_label = age_group.replace("age", "")
|
|
||||||
age_map[age_label] = age_map.get(age_label, 0.0) + viewer_pct
|
|
||||||
|
|
||||||
# 성별 집계
|
|
||||||
if gender in gender_map_f:
|
|
||||||
gender_map_f[gender] += viewer_pct
|
|
||||||
|
|
||||||
# 연령대 5개로 통합: 13-17+18-24 → 13-24, 55-64+65- → 55+
|
|
||||||
merged_age: dict[str, float] = {
|
|
||||||
"13-24": age_map.get("13-17", 0.0) + age_map.get("18-24", 0.0),
|
|
||||||
"25-34": age_map.get("25-34", 0.0),
|
|
||||||
"35-44": age_map.get("35-44", 0.0),
|
|
||||||
"45-54": age_map.get("45-54", 0.0),
|
|
||||||
"55+": age_map.get("55-64", 0.0) + age_map.get("65-", 0.0),
|
|
||||||
}
|
|
||||||
age_groups = [
|
|
||||||
{"label": age, "percentage": int(round(pct))}
|
|
||||||
for age, pct in merged_age.items()
|
|
||||||
]
|
|
||||||
gender_map = {k: int(round(v)) for k, v in gender_map_f.items()}
|
|
||||||
|
|
||||||
# === 지역 데이터 처리 ===
|
|
||||||
geo_rows = geography_data.get("rows", [])
|
|
||||||
total_geo_views = sum(row[1] for row in geo_rows if len(row) >= 2)
|
|
||||||
|
|
||||||
merged_geo: defaultdict[str, int] = defaultdict(int)
|
|
||||||
for row in geo_rows:
|
|
||||||
if len(row) >= 2:
|
|
||||||
merged_geo[self._translate_country_code(row[0])] += row[1]
|
|
||||||
|
|
||||||
top_regions = [
|
|
||||||
{
|
|
||||||
"region": region,
|
|
||||||
"percentage": int((views / total_geo_views * 100) if total_geo_views > 0 else 0),
|
|
||||||
}
|
|
||||||
for region, views in sorted(merged_geo.items(), key=lambda x: x[1], reverse=True)[:5]
|
|
||||||
]
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
f"[DataProcessor._build_audience_data] SUCCESS - "
|
|
||||||
f"age_groups={len(age_groups)}, regions={len(top_regions)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return AudienceData(
|
|
||||||
age_groups=age_groups,
|
|
||||||
gender=gender_map,
|
|
||||||
top_regions=top_regions,
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _translate_country_code(code: str) -> str:
|
|
||||||
"""국가 코드를 한국어로 변환
|
|
||||||
|
|
||||||
ISO 3166-1 alpha-2 국가 코드를 한국어 국가명으로 변환합니다.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
code: ISO 3166-1 alpha-2 국가 코드 (예: "KR", "US")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: 한국어 국가명 (매핑되지 않은 경우 원본 코드 반환)
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> _translate_country_code("KR")
|
|
||||||
"대한민국"
|
|
||||||
>>> _translate_country_code("US")
|
|
||||||
"미국"
|
|
||||||
"""
|
|
||||||
return _COUNTRY_CODE_MAP.get(code, "기타")
|
|
||||||
|
|
@ -1,503 +0,0 @@
|
||||||
"""
|
|
||||||
YouTube Analytics API 서비스
|
|
||||||
|
|
||||||
YouTube Analytics API v2를 호출하여 채널 및 영상 통계를 조회합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Any, Literal
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from app.dashboard.exceptions import (
|
|
||||||
YouTubeAPIError,
|
|
||||||
YouTubeAuthError,
|
|
||||||
YouTubeQuotaExceededError,
|
|
||||||
)
|
|
||||||
from app.utils.logger import get_logger
|
|
||||||
|
|
||||||
logger = get_logger("dashboard")
|
|
||||||
|
|
||||||
|
|
||||||
class YouTubeAnalyticsService:
|
|
||||||
"""YouTube Analytics API 호출 서비스
|
|
||||||
|
|
||||||
YouTube Analytics API v2를 사용하여 채널 통계, 영상 성과,
|
|
||||||
시청자 분석 데이터를 조회합니다.
|
|
||||||
|
|
||||||
API 문서:
|
|
||||||
https://developers.google.com/youtube/analytics/reference
|
|
||||||
"""
|
|
||||||
|
|
||||||
BASE_URL = "https://youtubeanalytics.googleapis.com/v2/reports"
|
|
||||||
|
|
||||||
async def fetch_all_metrics(
|
|
||||||
self,
|
|
||||||
video_ids: list[str],
|
|
||||||
start_date: str,
|
|
||||||
end_date: str,
|
|
||||||
access_token: str,
|
|
||||||
mode: Literal["day", "month"] = "month",
|
|
||||||
kpi_end_date: str = "",
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""YouTube Analytics API 호출을 병렬로 실행
|
|
||||||
|
|
||||||
Args:
|
|
||||||
video_ids: YouTube 영상 ID 리스트 (최대 30개, 빈 리스트 허용)
|
|
||||||
start_date: 조회 시작일 (YYYY-MM-DD)
|
|
||||||
end_date: 조회 종료일 (YYYY-MM-DD)
|
|
||||||
access_token: YouTube OAuth 2.0 액세스 토큰
|
|
||||||
mode: 조회 모드 ("month" | "day")
|
|
||||||
kpi_end_date: KPI 집계 종료일 (미전달 시 end_date와 동일)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict[str, Any]: API 응답 데이터 (7개 키)
|
|
||||||
- kpi: 최근 기간 KPI 메트릭 (조회수, 좋아요, 댓글 등)
|
|
||||||
- kpi_previous: 이전 기간 KPI 메트릭 (trend 계산용)
|
|
||||||
- trend_recent: 최근 기간 추이 (월별 또는 일별 조회수)
|
|
||||||
- trend_previous: 이전 기간 추이 (전년 또는 이전 30일)
|
|
||||||
- top_videos: 조회수 기준 인기 영상 TOP 4
|
|
||||||
- demographics: 연령/성별 시청자 분포
|
|
||||||
- region: 지역별 조회수 TOP 5
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
YouTubeAPIError: API 호출 실패
|
|
||||||
YouTubeQuotaExceededError: 할당량 초과
|
|
||||||
YouTubeAuthError: 인증 실패
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> service = YouTubeAnalyticsService()
|
|
||||||
>>> data = await service.fetch_all_metrics(
|
|
||||||
... video_ids=["dQw4w9WgXcQ", "jNQXAC9IVRw"],
|
|
||||||
... start_date="2026-01-01",
|
|
||||||
... end_date="2026-12-31",
|
|
||||||
... access_token="ya29.a0..."
|
|
||||||
... )
|
|
||||||
"""
|
|
||||||
logger.debug(
|
|
||||||
f"[1/7] YouTube Analytics API 병렬 호출 시작 - "
|
|
||||||
f"video_count={len(video_ids)}, period={start_date}~{end_date}, mode={mode}"
|
|
||||||
)
|
|
||||||
|
|
||||||
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
|
|
||||||
# kpi_end_date: KPI/top_videos/demographics/region 호출에 사용
|
|
||||||
# month 모드에서는 현재 월 전체 데이터를 포함하기 위해 end_date(YYYY-MM-01)보다 늦은 날짜 사용
|
|
||||||
# day 모드 또는 미전달 시 end_date와 동일
|
|
||||||
_kpi_end = kpi_end_date if kpi_end_date else end_date
|
|
||||||
|
|
||||||
if mode == "month":
|
|
||||||
# 월별 차트: 라우터에서 이미 YYYY-MM-01 형식으로 계산된 날짜 그대로 사용
|
|
||||||
# recent: start_date ~ end_date (ex. 2025-03-01 ~ 2026-02-01)
|
|
||||||
# previous: 1년 전 동일 기간 (ex. 2024-03-01 ~ 2025-02-01)
|
|
||||||
recent_start = start_date
|
|
||||||
recent_end = end_date
|
|
||||||
previous_start = f"{int(start_date[:4]) - 1}{start_date[4:]}"
|
|
||||||
previous_end = f"{int(end_date[:4]) - 1}{end_date[4:]}"
|
|
||||||
# KPI 이전 기간: _kpi_end 기준 1년 전 (ex. 2026-02-22 → 2025-02-22)
|
|
||||||
previous_kpi_end = f"{int(_kpi_end[:4]) - 1}{_kpi_end[4:]}"
|
|
||||||
logger.debug(
|
|
||||||
f"[월별 데이터] 최근 12개월: {recent_start}~{recent_end}, "
|
|
||||||
f"이전 12개월: {previous_start}~{previous_end}, "
|
|
||||||
f"KPI 조회 종료일: {_kpi_end}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# 일별 차트: end_date 기준 최근 30일 / 이전 30일
|
|
||||||
day_recent_end = end_date
|
|
||||||
day_recent_start = (end_dt - timedelta(days=29)).strftime("%Y-%m-%d")
|
|
||||||
day_previous_end = (end_dt - timedelta(days=30)).strftime("%Y-%m-%d")
|
|
||||||
day_previous_start = (end_dt - timedelta(days=59)).strftime("%Y-%m-%d")
|
|
||||||
logger.debug(
|
|
||||||
f"[일별 데이터] 최근 30일: {day_recent_start}~{day_recent_end}, "
|
|
||||||
f"이전 30일: {day_previous_start}~{day_previous_end}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 7개 API 호출 태스크 생성 (mode별 선택적)
|
|
||||||
# [0] KPI(최근), [1] KPI(이전), [2] 추이(최근), [3] 추이(이전), [4] 인기영상, [5] 인구통계, [6] 지역
|
|
||||||
# mode=month: [2][3] = 월별 데이터 (YYYY-MM-01 형식 필요)
|
|
||||||
# mode=day: [2][3] = 일별 데이터
|
|
||||||
if mode == "month":
|
|
||||||
tasks = [
|
|
||||||
self._fetch_kpi(video_ids, start_date, _kpi_end, access_token),
|
|
||||||
self._fetch_kpi(video_ids, previous_start, previous_kpi_end, access_token),
|
|
||||||
self._fetch_monthly_data(video_ids, recent_start, recent_end, access_token),
|
|
||||||
self._fetch_monthly_data(video_ids, previous_start, previous_end, access_token),
|
|
||||||
self._fetch_top_videos(video_ids, start_date, _kpi_end, access_token),
|
|
||||||
self._fetch_demographics(start_date, _kpi_end, access_token),
|
|
||||||
self._fetch_region(start_date, _kpi_end, access_token),
|
|
||||||
]
|
|
||||||
else: # mode == "day"
|
|
||||||
tasks = [
|
|
||||||
self._fetch_kpi(video_ids, start_date, end_date, access_token),
|
|
||||||
self._fetch_kpi(video_ids, day_previous_start, day_previous_end, access_token),
|
|
||||||
self._fetch_daily_data(video_ids, day_recent_start, day_recent_end, access_token),
|
|
||||||
self._fetch_daily_data(video_ids, day_previous_start, day_previous_end, access_token),
|
|
||||||
self._fetch_top_videos(video_ids, start_date, end_date, access_token),
|
|
||||||
self._fetch_demographics(start_date, end_date, access_token),
|
|
||||||
self._fetch_region(start_date, end_date, access_token),
|
|
||||||
]
|
|
||||||
|
|
||||||
# 병렬 실행
|
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
||||||
|
|
||||||
# 에러 체크 (YouTubeAuthError, YouTubeQuotaExceededError는 원형 그대로 전파)
|
|
||||||
# demographics(index 5)는 YouTubeAPIError 시 None으로 허용 (YouTube 서버 간헐적 오류 대응)
|
|
||||||
OPTIONAL_INDICES = {5, 6} # demographics, region
|
|
||||||
results = list(results)
|
|
||||||
for i, result in enumerate(results):
|
|
||||||
if isinstance(result, Exception):
|
|
||||||
logger.error(
|
|
||||||
f"[YouTubeAnalyticsService] API 호출 {i+1}/7 실패: {result.__class__.__name__}"
|
|
||||||
)
|
|
||||||
if isinstance(result, (YouTubeAuthError, YouTubeQuotaExceededError)):
|
|
||||||
raise result
|
|
||||||
if i in OPTIONAL_INDICES and isinstance(result, YouTubeAPIError):
|
|
||||||
logger.warning(
|
|
||||||
f"[YouTubeAnalyticsService] 선택적 API 호출 {i+1}/7 실패, None으로 처리: {result}"
|
|
||||||
)
|
|
||||||
results[i] = None
|
|
||||||
continue
|
|
||||||
raise YouTubeAPIError(f"데이터 조회 실패: {result.__class__.__name__}")
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
f"[7/7] YouTube Analytics API 병렬 호출 완료 - mode={mode}, 성공률 100%"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 각 API 호출 결과 디버그 로깅
|
|
||||||
labels = [
|
|
||||||
"kpi",
|
|
||||||
"kpi_previous",
|
|
||||||
"trend_recent",
|
|
||||||
"trend_previous",
|
|
||||||
"top_videos",
|
|
||||||
"demographics",
|
|
||||||
"region",
|
|
||||||
]
|
|
||||||
for label, result in zip(labels, results):
|
|
||||||
rows = result.get("rows") if isinstance(result, dict) else None
|
|
||||||
row_count = len(rows) if rows else 0
|
|
||||||
preview = rows[:2] if rows else []
|
|
||||||
logger.debug(
|
|
||||||
f"[fetch_all_metrics] {label}: row_count={row_count}, preview={preview}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"kpi": results[0],
|
|
||||||
"kpi_previous": results[1],
|
|
||||||
"trend_recent": results[2],
|
|
||||||
"trend_previous": results[3],
|
|
||||||
"top_videos": results[4],
|
|
||||||
"demographics": results[5],
|
|
||||||
"region": results[6],
|
|
||||||
}
|
|
||||||
|
|
||||||
async def _fetch_kpi(
|
|
||||||
self,
|
|
||||||
video_ids: list[str],
|
|
||||||
start_date: str,
|
|
||||||
end_date: str,
|
|
||||||
access_token: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""전체 KPI 메트릭 조회 (contentMetrics용)
|
|
||||||
|
|
||||||
조회수, 좋아요, 댓글, 공유, 시청 시간, 구독자 증감 등
|
|
||||||
핵심 성과 지표를 조회합니다.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
video_ids: YouTube 영상 ID 리스트
|
|
||||||
start_date: 조회 시작일 (YYYY-MM-DD)
|
|
||||||
end_date: 조회 종료일 (YYYY-MM-DD)
|
|
||||||
access_token: OAuth 2.0 액세스 토큰
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict[str, Any]: YouTube Analytics API 응답
|
|
||||||
rows[0] = [views, likes, comments, shares,
|
|
||||||
estimatedMinutesWatched, averageViewDuration,
|
|
||||||
subscribersGained]
|
|
||||||
|
|
||||||
"""
|
|
||||||
logger.debug(
|
|
||||||
f"[YouTubeAnalyticsService._fetch_kpi] START - video_count={len(video_ids)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
params = {
|
|
||||||
"ids": "channel==MINE",
|
|
||||||
"startDate": start_date,
|
|
||||||
"endDate": end_date,
|
|
||||||
"metrics": "views,likes,comments,shares,estimatedMinutesWatched,averageViewDuration,subscribersGained",
|
|
||||||
"filters": f"video=={','.join(video_ids)}",
|
|
||||||
}
|
|
||||||
|
|
||||||
result = await self._call_api(params, access_token)
|
|
||||||
logger.debug("[YouTubeAnalyticsService._fetch_kpi] SUCCESS")
|
|
||||||
return result
|
|
||||||
|
|
||||||
async def _fetch_monthly_data(
|
|
||||||
self,
|
|
||||||
video_ids: list[str],
|
|
||||||
start_date: str,
|
|
||||||
end_date: str,
|
|
||||||
access_token: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""월별 조회수 데이터 조회
|
|
||||||
|
|
||||||
지정된 기간의 월별 조회수를 조회합니다.
|
|
||||||
최근 12개월과 이전 12개월을 각각 조회하여 비교합니다.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
video_ids: YouTube 영상 ID 리스트
|
|
||||||
start_date: 조회 시작일 (YYYY-MM-DD)
|
|
||||||
end_date: 조회 종료일 (YYYY-MM-DD)
|
|
||||||
access_token: OAuth 2.0 액세스 토큰
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict[str, Any]: YouTube Analytics API 응답
|
|
||||||
rows = [["2026-01", 150000], ["2026-02", 180000], ...]
|
|
||||||
"""
|
|
||||||
logger.debug(
|
|
||||||
f"[YouTubeAnalyticsService._fetch_monthly_data] START - "
|
|
||||||
f"period={start_date}~{end_date}"
|
|
||||||
)
|
|
||||||
|
|
||||||
params = {
|
|
||||||
"ids": "channel==MINE",
|
|
||||||
"startDate": start_date,
|
|
||||||
"endDate": end_date,
|
|
||||||
"dimensions": "month",
|
|
||||||
"metrics": "views",
|
|
||||||
"filters": f"video=={','.join(video_ids)}",
|
|
||||||
"sort": "month",
|
|
||||||
}
|
|
||||||
|
|
||||||
result = await self._call_api(params, access_token)
|
|
||||||
logger.debug(
|
|
||||||
f"[YouTubeAnalyticsService._fetch_monthly_data] SUCCESS - "
|
|
||||||
f"period={start_date}~{end_date}"
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
async def _fetch_daily_data(
|
|
||||||
self,
|
|
||||||
video_ids: list[str],
|
|
||||||
start_date: str,
|
|
||||||
end_date: str,
|
|
||||||
access_token: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""일별 조회수 데이터 조회
|
|
||||||
|
|
||||||
지정된 기간의 일별 조회수를 조회합니다.
|
|
||||||
최근 30일과 이전 30일을 각각 조회하여 비교합니다.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
video_ids: YouTube 영상 ID 리스트
|
|
||||||
start_date: 조회 시작일 (YYYY-MM-DD)
|
|
||||||
end_date: 조회 종료일 (YYYY-MM-DD)
|
|
||||||
access_token: OAuth 2.0 액세스 토큰
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict[str, Any]: YouTube Analytics API 응답
|
|
||||||
rows = [["2026-01-18", 5000], ["2026-01-19", 6200], ...]
|
|
||||||
"""
|
|
||||||
logger.debug(
|
|
||||||
f"[YouTubeAnalyticsService._fetch_daily_data] START - "
|
|
||||||
f"period={start_date}~{end_date}"
|
|
||||||
)
|
|
||||||
|
|
||||||
params = {
|
|
||||||
"ids": "channel==MINE",
|
|
||||||
"startDate": start_date,
|
|
||||||
"endDate": end_date,
|
|
||||||
"dimensions": "day",
|
|
||||||
"metrics": "views",
|
|
||||||
"filters": f"video=={','.join(video_ids)}",
|
|
||||||
"sort": "day",
|
|
||||||
}
|
|
||||||
|
|
||||||
result = await self._call_api(params, access_token)
|
|
||||||
logger.debug(
|
|
||||||
f"[YouTubeAnalyticsService._fetch_daily_data] SUCCESS - "
|
|
||||||
f"period={start_date}~{end_date}"
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
async def _fetch_top_videos(
|
|
||||||
self,
|
|
||||||
video_ids: list[str],
|
|
||||||
start_date: str,
|
|
||||||
end_date: str,
|
|
||||||
access_token: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""영상별 조회수 조회 (topContent용)
|
|
||||||
|
|
||||||
조회수 기준 상위 4개 영상의 성과 데이터를 조회합니다.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
video_ids: YouTube 영상 ID 리스트
|
|
||||||
start_date: 조회 시작일 (YYYY-MM-DD)
|
|
||||||
end_date: 조회 종료일 (YYYY-MM-DD)
|
|
||||||
access_token: OAuth 2.0 액세스 토큰
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict[str, Any]: YouTube Analytics API 응답
|
|
||||||
rows = [["video_id", views, likes, comments], ...]
|
|
||||||
조회수 내림차순으로 정렬된 상위 4개 영상
|
|
||||||
"""
|
|
||||||
logger.debug("[YouTubeAnalyticsService._fetch_top_videos] START")
|
|
||||||
|
|
||||||
params = {
|
|
||||||
"ids": "channel==MINE",
|
|
||||||
"startDate": start_date,
|
|
||||||
"endDate": end_date,
|
|
||||||
"dimensions": "video",
|
|
||||||
"metrics": "views,likes,comments",
|
|
||||||
"filters": f"video=={','.join(video_ids)}",
|
|
||||||
"sort": "-views",
|
|
||||||
"maxResults": "4",
|
|
||||||
}
|
|
||||||
|
|
||||||
result = await self._call_api(params, access_token)
|
|
||||||
logger.debug("[YouTubeAnalyticsService._fetch_top_videos] SUCCESS")
|
|
||||||
return result
|
|
||||||
|
|
||||||
async def _fetch_demographics(
|
|
||||||
self,
|
|
||||||
start_date: str,
|
|
||||||
end_date: str,
|
|
||||||
access_token: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""연령/성별 분포 조회 (채널 전체 기준)
|
|
||||||
|
|
||||||
시청자의 연령대별, 성별 시청 비율을 조회합니다.
|
|
||||||
|
|
||||||
Note:
|
|
||||||
YouTube Analytics API 제약: ageGroup/gender 차원은 video 필터와 혼용 불가.
|
|
||||||
채널 전체 시청자 기준 데이터를 반환합니다.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
start_date: 조회 시작일 (YYYY-MM-DD)
|
|
||||||
end_date: 조회 종료일 (YYYY-MM-DD)
|
|
||||||
access_token: OAuth 2.0 액세스 토큰
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict[str, Any]: YouTube Analytics API 응답
|
|
||||||
rows = [["age18-24", "female", 45.5], ["age18-24", "male", 32.1], ...]
|
|
||||||
"""
|
|
||||||
logger.debug("[YouTubeAnalyticsService._fetch_demographics] START")
|
|
||||||
|
|
||||||
# Demographics 보고서는 video 필터 미지원 → 채널 전체 기준 데이터
|
|
||||||
# 지원 filters: country, province, continent, subContinent, liveOrOnDemand, subscribedStatus
|
|
||||||
params = {
|
|
||||||
"ids": "channel==MINE",
|
|
||||||
"startDate": start_date,
|
|
||||||
"endDate": end_date,
|
|
||||||
"dimensions": "ageGroup,gender",
|
|
||||||
"metrics": "viewerPercentage",
|
|
||||||
}
|
|
||||||
|
|
||||||
result = await self._call_api(params, access_token)
|
|
||||||
logger.debug("[YouTubeAnalyticsService._fetch_demographics] SUCCESS")
|
|
||||||
return result
|
|
||||||
|
|
||||||
async def _fetch_region(
|
|
||||||
self,
|
|
||||||
start_date: str,
|
|
||||||
end_date: str,
|
|
||||||
access_token: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""지역별 조회수 조회
|
|
||||||
|
|
||||||
지역별 조회수 분포를 조회합니다 (상위 5개).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
start_date: 조회 시작일 (YYYY-MM-DD)
|
|
||||||
end_date: 조회 종료일 (YYYY-MM-DD)
|
|
||||||
access_token: OAuth 2.0 액세스 토큰
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict[str, Any]: YouTube Analytics API 응답
|
|
||||||
rows = [["KR", 1000000], ["US", 500000], ...]
|
|
||||||
조회수 내림차순으로 정렬된 상위 5개 국가
|
|
||||||
"""
|
|
||||||
logger.debug("[YouTubeAnalyticsService._fetch_region] START")
|
|
||||||
|
|
||||||
params = {
|
|
||||||
"ids": "channel==MINE",
|
|
||||||
"startDate": start_date,
|
|
||||||
"endDate": end_date,
|
|
||||||
"dimensions": "country",
|
|
||||||
"metrics": "views",
|
|
||||||
"sort": "-views",
|
|
||||||
}
|
|
||||||
|
|
||||||
result = await self._call_api(params, access_token)
|
|
||||||
logger.debug("[YouTubeAnalyticsService._fetch_region] SUCCESS")
|
|
||||||
return result
|
|
||||||
|
|
||||||
async def _call_api(
|
|
||||||
self,
|
|
||||||
params: dict[str, str],
|
|
||||||
access_token: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""YouTube Analytics API 호출 공통 로직
|
|
||||||
|
|
||||||
모든 API 호출에 공통적으로 사용되는 HTTP 요청 로직입니다.
|
|
||||||
인증 헤더 추가, 에러 처리, 응답 파싱을 담당합니다.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
params: API 요청 파라미터 (dimensions, metrics, filters 등)
|
|
||||||
access_token: OAuth 2.0 액세스 토큰
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict[str, Any]: YouTube Analytics API JSON 응답
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
YouTubeQuotaExceededError: 할당량 초과 (429)
|
|
||||||
YouTubeAuthError: 인증 실패 (401, 403)
|
|
||||||
YouTubeAPIError: 기타 API 오류
|
|
||||||
|
|
||||||
Note:
|
|
||||||
- 타임아웃: 30초
|
|
||||||
- 할당량 초과 시 자동으로 YouTubeQuotaExceededError 발생
|
|
||||||
- 인증 실패 시 자동으로 YouTubeAuthError 발생
|
|
||||||
"""
|
|
||||||
headers = {"Authorization": f"Bearer {access_token}"}
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
||||||
response = await client.get(
|
|
||||||
self.BASE_URL,
|
|
||||||
params=params,
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 할당량 초과 체크
|
|
||||||
if response.status_code == 429:
|
|
||||||
logger.warning("[YouTubeAnalyticsService._call_api] QUOTA_EXCEEDED")
|
|
||||||
raise YouTubeQuotaExceededError()
|
|
||||||
|
|
||||||
# 인증 실패 체크
|
|
||||||
if response.status_code in (401, 403):
|
|
||||||
logger.warning(
|
|
||||||
f"[YouTubeAnalyticsService._call_api] AUTH_FAILED - status={response.status_code}"
|
|
||||||
)
|
|
||||||
raise YouTubeAuthError(f"YouTube 인증 실패: {response.status_code}")
|
|
||||||
|
|
||||||
# HTTP 에러 체크
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
except (YouTubeAuthError, YouTubeQuotaExceededError):
|
|
||||||
raise # 이미 처리된 예외는 그대로 전파
|
|
||||||
except httpx.HTTPStatusError as e:
|
|
||||||
logger.error(
|
|
||||||
f"[YouTubeAnalyticsService._call_api] HTTP_ERROR - "
|
|
||||||
f"status={e.response.status_code}, body={e.response.text[:500]}"
|
|
||||||
)
|
|
||||||
raise YouTubeAPIError(f"HTTP {e.response.status_code}")
|
|
||||||
except httpx.RequestError as e:
|
|
||||||
logger.error(f"[YouTubeAnalyticsService._call_api] REQUEST_ERROR - {e}")
|
|
||||||
raise YouTubeAPIError(f"네트워크 오류: {e}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[YouTubeAnalyticsService._call_api] UNEXPECTED_ERROR - {e}")
|
|
||||||
raise YouTubeAPIError(f"알 수 없는 오류: {e}")
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
"""
|
|
||||||
Dashboard Background Tasks
|
|
||||||
|
|
||||||
업로드 완료 시 Dashboard 테이블에 레코드를 삽입하는 백그라운드 태스크입니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.dialects.mysql import insert
|
|
||||||
|
|
||||||
from app.dashboard.models import Dashboard
|
|
||||||
from app.database.session import BackgroundSessionLocal
|
|
||||||
from app.social.models import SocialUpload
|
|
||||||
from app.user.models import SocialAccount
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def insert_dashboard(upload_id: int) -> None:
|
|
||||||
"""
|
|
||||||
Dashboard 레코드 삽입
|
|
||||||
|
|
||||||
SocialUpload(id=upload_id) 완료 데이터를 DB에서 조회하여 Dashboard에 삽입합니다.
|
|
||||||
UniqueConstraint(platform_video_id, platform_user_id) 충돌 시 스킵(INSERT IGNORE).
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
async with BackgroundSessionLocal() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
select(
|
|
||||||
SocialUpload.user_uuid,
|
|
||||||
SocialUpload.platform,
|
|
||||||
SocialUpload.platform_video_id,
|
|
||||||
SocialUpload.platform_url,
|
|
||||||
SocialUpload.title,
|
|
||||||
SocialUpload.uploaded_at,
|
|
||||||
SocialAccount.platform_user_id,
|
|
||||||
)
|
|
||||||
.join(SocialAccount, SocialUpload.social_account_id == SocialAccount.id)
|
|
||||||
.where(SocialUpload.id == upload_id)
|
|
||||||
)
|
|
||||||
row = result.one_or_none()
|
|
||||||
|
|
||||||
if not row:
|
|
||||||
logger.warning(f"[dashboard] upload_id={upload_id} 데이터 없음")
|
|
||||||
return
|
|
||||||
|
|
||||||
stmt = (
|
|
||||||
insert(Dashboard)
|
|
||||||
.values(
|
|
||||||
user_uuid=row.user_uuid,
|
|
||||||
platform=row.platform,
|
|
||||||
platform_user_id=row.platform_user_id,
|
|
||||||
platform_video_id=row.platform_video_id,
|
|
||||||
platform_url=row.platform_url,
|
|
||||||
title=row.title,
|
|
||||||
uploaded_at=row.uploaded_at,
|
|
||||||
)
|
|
||||||
.prefix_with("IGNORE")
|
|
||||||
)
|
|
||||||
await session.execute(stmt)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[dashboard] 삽입 완료 - "
|
|
||||||
f"upload_id={upload_id}, platform_video_id={row.platform_video_id}"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"[dashboard] 삽입 실패 - upload_id={upload_id}, error={e}"
|
|
||||||
)
|
|
||||||
|
|
@ -1,173 +0,0 @@
|
||||||
"""
|
|
||||||
Redis 캐싱 유틸리티
|
|
||||||
|
|
||||||
Dashboard API 성능 최적화를 위한 Redis 캐싱 기능을 제공합니다.
|
|
||||||
YouTube Analytics API 호출 결과를 캐싱하여 중복 요청을 방지합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from redis.asyncio import Redis
|
|
||||||
|
|
||||||
from app.utils.logger import get_logger
|
|
||||||
from config import db_settings
|
|
||||||
|
|
||||||
logger = get_logger("redis_cache")
|
|
||||||
|
|
||||||
# Dashboard 전용 Redis 클라이언트 (db=3 사용)
|
|
||||||
_cache_client = Redis(
|
|
||||||
host=db_settings.REDIS_HOST,
|
|
||||||
port=db_settings.REDIS_PORT,
|
|
||||||
db=3,
|
|
||||||
decode_responses=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_cache(key: str) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Redis 캐시에서 값을 조회합니다.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
key: 캐시 키
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
캐시된 값 (문자열) 또는 None (캐시 미스)
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> cached_data = await get_cache("dashboard:user123:2026-01-01:2026-12-31")
|
|
||||||
>>> if cached_data:
|
|
||||||
>>> return json.loads(cached_data)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
logger.debug(f"[GET_CACHE] 캐시 조회 시작 - key: {key}")
|
|
||||||
value = await _cache_client.get(key)
|
|
||||||
|
|
||||||
if value:
|
|
||||||
logger.debug(f"[GET_CACHE] 캐시 HIT - key: {key}")
|
|
||||||
else:
|
|
||||||
logger.debug(f"[GET_CACHE] 캐시 MISS - key: {key}")
|
|
||||||
|
|
||||||
return value
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[GET_CACHE] 캐시 조회 실패 - key: {key}, error: {e}")
|
|
||||||
return None # 캐시 실패 시 None 반환 (원본 데이터 조회하도록 유도)
|
|
||||||
|
|
||||||
|
|
||||||
async def set_cache(key: str, value: str, ttl: int = 43200) -> bool:
|
|
||||||
"""
|
|
||||||
Redis 캐시에 값을 저장합니다.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
key: 캐시 키
|
|
||||||
value: 저장할 값 (문자열)
|
|
||||||
ttl: 캐시 만료 시간 (초). 기본값: 43200초 (12시간)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
성공 여부
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> import json
|
|
||||||
>>> data = {"views": 1000, "likes": 50}
|
|
||||||
>>> await set_cache("dashboard:user123:2026-01-01:2026-12-31", json.dumps(data), ttl=3600)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
logger.debug(f"[SET_CACHE] 캐시 저장 시작 - key: {key}, ttl: {ttl}s")
|
|
||||||
await _cache_client.setex(key, ttl, value)
|
|
||||||
logger.debug(f"[SET_CACHE] 캐시 저장 성공 - key: {key}")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[SET_CACHE] 캐시 저장 실패 - key: {key}, error: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_cache(key: str) -> bool:
|
|
||||||
"""
|
|
||||||
Redis 캐시에서 값을 삭제합니다.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
key: 삭제할 캐시 키
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
성공 여부
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> await delete_cache("dashboard:user123:2026-01-01:2026-12-31")
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
logger.debug(f"[DELETE_CACHE] 캐시 삭제 시작 - key: {key}")
|
|
||||||
deleted_count = await _cache_client.delete(key)
|
|
||||||
logger.debug(
|
|
||||||
f"[DELETE_CACHE] 캐시 삭제 완료 - key: {key}, deleted: {deleted_count}"
|
|
||||||
)
|
|
||||||
return deleted_count > 0
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[DELETE_CACHE] 캐시 삭제 실패 - key: {key}, error: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_cache_pattern(pattern: str) -> int:
|
|
||||||
"""
|
|
||||||
패턴에 매칭되는 모든 캐시 키를 삭제합니다.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
pattern: 삭제할 키 패턴 (예: "dashboard:user123:*")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
삭제된 키 개수
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> # 특정 사용자의 모든 대시보드 캐시 삭제
|
|
||||||
>>> deleted = await delete_cache_pattern("dashboard:user123:*")
|
|
||||||
>>> print(f"{deleted}개의 캐시 삭제됨")
|
|
||||||
|
|
||||||
Note:
|
|
||||||
대량의 키 삭제 시 성능에 영향을 줄 수 있으므로 주의해서 사용하세요.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
logger.debug(f"[DELETE_CACHE_PATTERN] 패턴 캐시 삭제 시작 - pattern: {pattern}")
|
|
||||||
|
|
||||||
# 패턴에 매칭되는 모든 키 조회
|
|
||||||
keys = []
|
|
||||||
async for key in _cache_client.scan_iter(match=pattern):
|
|
||||||
keys.append(key)
|
|
||||||
|
|
||||||
if not keys:
|
|
||||||
logger.debug(f"[DELETE_CACHE_PATTERN] 삭제할 키 없음 - pattern: {pattern}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# 모든 키 삭제
|
|
||||||
deleted_count = await _cache_client.delete(*keys)
|
|
||||||
logger.debug(
|
|
||||||
f"[DELETE_CACHE_PATTERN] 패턴 캐시 삭제 완료 - "
|
|
||||||
f"pattern: {pattern}, deleted: {deleted_count}"
|
|
||||||
)
|
|
||||||
return deleted_count
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"[DELETE_CACHE_PATTERN] 패턴 캐시 삭제 실패 - pattern: {pattern}, error: {e}"
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
async def close_cache_client():
|
|
||||||
"""
|
|
||||||
Redis 클라이언트 연결을 종료합니다.
|
|
||||||
|
|
||||||
애플리케이션 종료 시 호출되어야 합니다.
|
|
||||||
main.py의 shutdown 이벤트 핸들러에서 사용하세요.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> # main.py
|
|
||||||
>>> @app.on_event("shutdown")
|
|
||||||
>>> async def shutdown_event():
|
|
||||||
>>> await close_cache_client()
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
logger.info("[CLOSE_CACHE_CLIENT] Redis 캐시 클라이언트 종료 중...")
|
|
||||||
await _cache_client.close()
|
|
||||||
logger.info("[CLOSE_CACHE_CLIENT] Redis 캐시 클라이언트 종료 완료")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"[CLOSE_CACHE_CLIENT] Redis 캐시 클라이언트 종료 실패 - error: {e}"
|
|
||||||
)
|
|
||||||
|
|
@ -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,8 +1,6 @@
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -75,17 +73,14 @@ async def create_db_tables():
|
||||||
|
|
||||||
# 모델 import (테이블 메타데이터 등록용)
|
# 모델 import (테이블 메타데이터 등록용)
|
||||||
from app.user.models import User, RefreshToken, SocialAccount # noqa: F401
|
from app.user.models import User, RefreshToken, SocialAccount # noqa: F401
|
||||||
from app.home.models import Image, Project, MarketingIntel, ImageTag # noqa: F401
|
from app.home.models import Image, Project, MarketingIntel # noqa: F401
|
||||||
from app.lyric.models import Lyric # noqa: F401
|
from app.lyric.models import Lyric # noqa: F401
|
||||||
from app.song.models import Song, SongTimestamp # noqa: F401
|
from app.song.models import Song, SongTimestamp # noqa: F401
|
||||||
from app.video.models import Video # noqa: F401
|
from app.video.models import Video # noqa: F401
|
||||||
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.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__,
|
||||||
|
|
@ -99,11 +94,6 @@ async def create_db_tables():
|
||||||
SNSUploadTask.__table__,
|
SNSUploadTask.__table__,
|
||||||
SocialUpload.__table__,
|
SocialUpload.__table__,
|
||||||
MarketingIntel.__table__,
|
MarketingIntel.__table__,
|
||||||
Dashboard.__table__,
|
|
||||||
ImageTag.__table__,
|
|
||||||
Admin.__table__,
|
|
||||||
CreditChargeRequest.__table__,
|
|
||||||
CreditTransaction.__table__,
|
|
||||||
]
|
]
|
||||||
|
|
||||||
logger.info("Creating database tables...")
|
logger.info("Creating database tables...")
|
||||||
|
|
@ -135,16 +125,15 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
# )
|
# )
|
||||||
try:
|
try:
|
||||||
yield session
|
yield session
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
except Exception as 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 +161,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(
|
||||||
|
|
@ -181,8 +168,7 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
f"error: {type(e).__name__}: {e}, "
|
f"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"
|
||||||
)
|
)
|
||||||
logger.debug(traceback.format_exc())
|
raise e
|
||||||
raise
|
|
||||||
finally:
|
finally:
|
||||||
total_time = time.perf_counter() - start_time
|
total_time = time.perf_counter() - start_time
|
||||||
# logger.debug(
|
# logger.debug(
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,9 @@ import aiofiles
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import func, select
|
|
||||||
|
|
||||||
from app.database.session import get_session, AsyncSessionLocal
|
from app.database.session import get_session, AsyncSessionLocal
|
||||||
from app.home.models import Image, MarketingIntel, ImageTag
|
from app.home.models import Image, MarketingIntel
|
||||||
from app.user.dependencies.auth import get_current_user
|
from app.user.dependencies.auth import get_current_user
|
||||||
from app.user.models import User
|
from app.user.models import User
|
||||||
from app.home.schemas.home_schema import (
|
from app.home.schemas.home_schema import (
|
||||||
|
|
@ -30,53 +29,42 @@ from app.home.schemas.home_schema import (
|
||||||
)
|
)
|
||||||
from app.home.services.naver_search import naver_search_client
|
from app.home.services.naver_search import naver_search_client
|
||||||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
from app.utils.upload_blob_as_request import AzureBlobUploader
|
||||||
from app.utils.prompts.chatgpt_prompt import ChatgptService, ChatGPTResponseError
|
from app.utils.chatgpt_prompt import ChatgptService, ChatGPTResponseError
|
||||||
from app.utils.common import generate_task_id
|
from app.utils.common import generate_task_id
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from app.utils.nvMapScraper import NvMapScraper, GraphQLException, URLNotFoundException
|
from app.utils.nvMapScraper import NvMapScraper, GraphQLException
|
||||||
from app.utils.nvMapPwScraper import NvMapPwScraper
|
from app.utils.nvMapPwScraper import NvMapPwScraper
|
||||||
from app.utils.prompts.prompts import marketing_prompt
|
from app.utils.prompts.prompts import marketing_prompt
|
||||||
from app.utils.autotag import autotag_images
|
|
||||||
from config import MEDIA_ROOT
|
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 +114,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 ""
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -256,15 +218,6 @@ async def _crawling_logic(
|
||||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
detail=f"네이버 지도 크롤링에 실패했습니다: {e}",
|
detail=f"네이버 지도 크롤링에 실패했습니다: {e}",
|
||||||
)
|
)
|
||||||
except URLNotFoundException as e:
|
|
||||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
|
||||||
logger.error(
|
|
||||||
f"[crawling] Step 1 FAILED - 크롤링 실패: {e} ({step1_elapsed:.1f}ms)"
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"Place ID를 확인할 수 없습니다. URL을 확인하세요. : {e}",
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|
@ -290,7 +243,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)
|
||||||
|
|
||||||
|
|
@ -498,6 +451,255 @@ IMAGES_JSON_EXAMPLE = """[
|
||||||
{"url": "https://naverbooking-phinf.pstatic.net/20240514_259/17156880311809wCnY_JPEG/5.jpg", "name": "외관"}
|
{"url": "https://naverbooking-phinf.pstatic.net/20240514_259/17156880311809wCnY_JPEG/5.jpg", "name": "외관"}
|
||||||
]"""
|
]"""
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/image/upload/server",
|
||||||
|
include_in_schema=False,
|
||||||
|
summary="이미지 업로드 (로컬 서버)",
|
||||||
|
description="""
|
||||||
|
이미지를 로컬 서버(media 폴더)에 업로드하고 새로운 task_id를 생성합니다.
|
||||||
|
|
||||||
|
## 요청 방식
|
||||||
|
multipart/form-data 형식으로 전송합니다.
|
||||||
|
|
||||||
|
## 요청 필드
|
||||||
|
- **images_json**: 외부 이미지 URL 목록 (JSON 문자열, 선택)
|
||||||
|
- **files**: 이미지 바이너리 파일 목록 (선택)
|
||||||
|
|
||||||
|
**주의**: images_json 또는 files 중 최소 하나는 반드시 전달해야 합니다.
|
||||||
|
|
||||||
|
## 지원 이미지 확장자
|
||||||
|
jpg, jpeg, png, webp, heic, heif
|
||||||
|
|
||||||
|
## images_json 예시
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{"url": "https://naverbooking-phinf.pstatic.net/20240514_189/1715688030436xT14o_JPEG/1.jpg"},
|
||||||
|
{"url": "https://naverbooking-phinf.pstatic.net/20240514_48/1715688030574wTtQd_JPEG/2.jpg"},
|
||||||
|
{"url": "https://naverbooking-phinf.pstatic.net/20240514_92/17156880307484bvpH_JPEG/3.jpg"},
|
||||||
|
{"url": "https://naverbooking-phinf.pstatic.net/20240514_7/1715688031000y8Y5q_JPEG/4.jpg"},
|
||||||
|
{"url": "https://naverbooking-phinf.pstatic.net/20240514_259/17156880311809wCnY_JPEG/5.jpg", "name": "외관"}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 바이너리 파일 업로드 테스트 방법
|
||||||
|
|
||||||
|
### 1. Swagger UI에서 테스트
|
||||||
|
1. 이 엔드포인트의 "Try it out" 버튼 클릭
|
||||||
|
2. task_id 입력 (예: test-task-001)
|
||||||
|
3. files 항목에서 "Add item" 클릭하여 로컬 이미지 파일 선택
|
||||||
|
4. (선택) images_json에 URL 목록 JSON 입력
|
||||||
|
5. "Execute" 버튼 클릭
|
||||||
|
|
||||||
|
### 2. cURL로 테스트
|
||||||
|
```bash
|
||||||
|
# 바이너리 파일만 업로드
|
||||||
|
curl -X POST "http://localhost:8000/image/upload/server/test-task-001" \\
|
||||||
|
-F "files=@/path/to/image1.jpg" \\
|
||||||
|
-F "files=@/path/to/image2.png"
|
||||||
|
|
||||||
|
# URL + 바이너리 파일 동시 업로드
|
||||||
|
curl -X POST "http://localhost:8000/image/upload/server/test-task-001" \\
|
||||||
|
-F 'images_json=[{"url":"https://example.com/image.jpg"}]' \\
|
||||||
|
-F "files=@/path/to/local_image.jpg"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Python requests로 테스트
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
url = "http://localhost:8000/image/upload/server/test-task-001"
|
||||||
|
files = [
|
||||||
|
("files", ("image1.jpg", open("image1.jpg", "rb"), "image/jpeg")),
|
||||||
|
("files", ("image2.png", open("image2.png", "rb"), "image/png")),
|
||||||
|
]
|
||||||
|
data = {
|
||||||
|
"images_json": '[{"url": "https://example.com/image.jpg"}]'
|
||||||
|
}
|
||||||
|
response = requests.post(url, files=files, data=data)
|
||||||
|
print(response.json())
|
||||||
|
```
|
||||||
|
|
||||||
|
## 반환 정보
|
||||||
|
- **task_id**: 작업 고유 식별자
|
||||||
|
- **total_count**: 총 업로드된 이미지 개수
|
||||||
|
- **url_count**: URL로 등록된 이미지 개수 (Image 테이블에 외부 URL 그대로 저장)
|
||||||
|
- **file_count**: 파일로 업로드된 이미지 개수 (media 폴더에 저장)
|
||||||
|
- **saved_count**: Image 테이블에 저장된 row 수
|
||||||
|
- **images**: 업로드된 이미지 목록
|
||||||
|
- **source**: "url" (외부 URL) 또는 "file" (로컬 서버 저장)
|
||||||
|
|
||||||
|
## 저장 경로
|
||||||
|
- 바이너리 파일: /media/image/{날짜}/{uuid7}/{파일명}
|
||||||
|
- URL 이미지: 외부 URL 그대로 Image 테이블에 저장
|
||||||
|
|
||||||
|
## 반환 정보
|
||||||
|
- **task_id**: 새로 생성된 작업 고유 식별자
|
||||||
|
- **image_urls**: Image 테이블에 저장된 현재 task_id의 이미지 URL 목록
|
||||||
|
""",
|
||||||
|
response_model=ImageUploadResponse,
|
||||||
|
responses={
|
||||||
|
200: {"description": "이미지 업로드 성공"},
|
||||||
|
400: {"description": "이미지가 제공되지 않음", "model": ErrorResponse},
|
||||||
|
},
|
||||||
|
tags=["Image-Server"],
|
||||||
|
)
|
||||||
|
async def upload_images(
|
||||||
|
images_json: Optional[str] = Form(
|
||||||
|
default=None,
|
||||||
|
description="외부 이미지 URL 목록 (JSON 문자열)",
|
||||||
|
examples=[IMAGES_JSON_EXAMPLE],
|
||||||
|
),
|
||||||
|
files: Optional[list[UploadFile]] = File(
|
||||||
|
default=None, description="이미지 바이너리 파일 목록"
|
||||||
|
),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> ImageUploadResponse:
|
||||||
|
"""이미지 업로드 (URL + 바이너리 파일)"""
|
||||||
|
# task_id 생성
|
||||||
|
task_id = await generate_task_id()
|
||||||
|
|
||||||
|
# 1. 진입 검증: images_json 또는 files 중 하나는 반드시 있어야 함
|
||||||
|
has_images_json = images_json is not None and images_json.strip() != ""
|
||||||
|
has_files = files is not None and len(files) > 0
|
||||||
|
|
||||||
|
if not has_images_json and not has_files:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="images_json 또는 files 중 하나는 반드시 제공해야 합니다.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. images_json 파싱 (있는 경우만)
|
||||||
|
url_images: list[ImageUrlItem] = []
|
||||||
|
if has_images_json:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(images_json)
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
url_images = [ImageUrlItem(**item) for item in parsed if item]
|
||||||
|
except (json.JSONDecodeError, TypeError, ValueError) as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"images_json 파싱 오류: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. 유효한 파일만 필터링 (빈 파일, 유효한 이미지 확장자가 아닌 경우 제외)
|
||||||
|
valid_files: list[UploadFile] = []
|
||||||
|
skipped_files: list[str] = []
|
||||||
|
if has_files and files:
|
||||||
|
for f in files:
|
||||||
|
is_valid_ext = _is_valid_image_extension(f.filename)
|
||||||
|
is_not_empty = (
|
||||||
|
f.size is None or f.size > 0
|
||||||
|
) # size가 None이면 아직 읽지 않은 것
|
||||||
|
is_real_file = (
|
||||||
|
f.filename and f.filename != "filename"
|
||||||
|
) # Swagger 빈 파일 체크
|
||||||
|
if f and is_real_file and is_valid_ext and is_not_empty:
|
||||||
|
valid_files.append(f)
|
||||||
|
else:
|
||||||
|
skipped_files.append(f.filename or "unknown")
|
||||||
|
|
||||||
|
# 유효한 데이터가 하나도 없으면 에러
|
||||||
|
if not url_images and not valid_files:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"유효한 이미지가 없습니다. 지원 확장자: {', '.join(ALLOWED_IMAGE_EXTENSIONS)}. 건너뛴 파일: {skipped_files}",
|
||||||
|
)
|
||||||
|
|
||||||
|
result_images: list[ImageUploadResultItem] = []
|
||||||
|
img_order = 0
|
||||||
|
|
||||||
|
# 1. URL 이미지 저장
|
||||||
|
for url_item in url_images:
|
||||||
|
img_name = url_item.name or _extract_image_name(url_item.url, img_order)
|
||||||
|
|
||||||
|
image = Image(
|
||||||
|
task_id=task_id,
|
||||||
|
img_name=img_name,
|
||||||
|
img_url=url_item.url,
|
||||||
|
img_order=img_order,
|
||||||
|
)
|
||||||
|
session.add(image)
|
||||||
|
await session.flush() # ID 생성을 위해 flush
|
||||||
|
|
||||||
|
result_images.append(
|
||||||
|
ImageUploadResultItem(
|
||||||
|
id=image.id,
|
||||||
|
img_name=img_name,
|
||||||
|
img_url=url_item.url,
|
||||||
|
img_order=img_order,
|
||||||
|
source="url",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
img_order += 1
|
||||||
|
|
||||||
|
# 2. 바이너리 파일을 media에 저장
|
||||||
|
if valid_files:
|
||||||
|
today = date.today().strftime("%Y-%m-%d")
|
||||||
|
# 한 번의 요청에서 업로드된 모든 이미지는 같은 폴더에 저장
|
||||||
|
batch_uuid = await generate_task_id()
|
||||||
|
upload_dir = MEDIA_ROOT / "image" / today / batch_uuid
|
||||||
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for file in valid_files:
|
||||||
|
# 파일명: 원본 파일명 사용 (중복 방지를 위해 순서 추가)
|
||||||
|
original_name = file.filename or "image"
|
||||||
|
ext = _get_file_extension(file.filename) # type: ignore[arg-type]
|
||||||
|
# 파일명에서 확장자 제거 후 순서 추가
|
||||||
|
name_without_ext = (
|
||||||
|
original_name.rsplit(".", 1)[0]
|
||||||
|
if "." in original_name
|
||||||
|
else original_name
|
||||||
|
)
|
||||||
|
filename = f"{name_without_ext}_{img_order:03d}{ext}"
|
||||||
|
|
||||||
|
save_path = upload_dir / filename
|
||||||
|
|
||||||
|
# media에 파일 저장
|
||||||
|
await _save_upload_file(file, save_path)
|
||||||
|
|
||||||
|
# media 기준 URL 생성
|
||||||
|
img_url = f"/media/image/{today}/{batch_uuid}/{filename}"
|
||||||
|
img_name = file.filename or filename
|
||||||
|
|
||||||
|
image = Image(
|
||||||
|
task_id=task_id,
|
||||||
|
img_name=img_name,
|
||||||
|
img_url=img_url, # Media URL을 DB에 저장
|
||||||
|
img_order=img_order,
|
||||||
|
)
|
||||||
|
session.add(image)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
result_images.append(
|
||||||
|
ImageUploadResultItem(
|
||||||
|
id=image.id,
|
||||||
|
img_name=img_name,
|
||||||
|
img_url=img_url,
|
||||||
|
img_order=img_order,
|
||||||
|
source="file",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
img_order += 1
|
||||||
|
|
||||||
|
saved_count = len(result_images)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Image 테이블에서 현재 task_id의 이미지 URL 목록 조회
|
||||||
|
image_urls = [img.img_url for img in result_images]
|
||||||
|
|
||||||
|
return ImageUploadResponse(
|
||||||
|
task_id=task_id,
|
||||||
|
total_count=len(result_images),
|
||||||
|
url_count=len(url_images),
|
||||||
|
file_count=len(valid_files),
|
||||||
|
saved_count=saved_count,
|
||||||
|
images=result_images,
|
||||||
|
image_urls=image_urls,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/image/upload/blob",
|
"/image/upload/blob",
|
||||||
summary="이미지 업로드 (Azure Blob Storage)",
|
summary="이미지 업로드 (Azure Blob Storage)",
|
||||||
|
|
@ -786,10 +988,6 @@ async def upload_images_blob(
|
||||||
saved_count = len(result_images)
|
saved_count = len(result_images)
|
||||||
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}")
|
|
||||||
await tagging_images(image_urls, clear_old_tags=True)
|
|
||||||
logger.info(f"[image_tagging] Done - task_id: {task_id}")
|
|
||||||
|
|
||||||
total_time = time.perf_counter() - request_start
|
total_time = time.perf_counter() - request_start
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[upload_images_blob] SUCCESS - task_id: {task_id}, "
|
f"[upload_images_blob] SUCCESS - task_id: {task_id}, "
|
||||||
|
|
@ -805,42 +1003,3 @@ async def upload_images_blob(
|
||||||
images=result_images,
|
images=result_images,
|
||||||
image_urls=image_urls,
|
image_urls=image_urls,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def tagging_images(
|
|
||||||
image_urls : list[str],
|
|
||||||
clear_old_tags : bool = False
|
|
||||||
) -> None:
|
|
||||||
# 1. 조회
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
stmt = (
|
|
||||||
select(ImageTag)
|
|
||||||
.where(ImageTag.img_url_hash.in_([func.crc32(url) for url in image_urls]))
|
|
||||||
.where(ImageTag.img_url.in_(image_urls))
|
|
||||||
)
|
|
||||||
image_tags_query_results = await session.execute(stmt)
|
|
||||||
image_tags = image_tags_query_results.scalars().all()
|
|
||||||
existing_urls = {tag.img_url for tag in image_tags}
|
|
||||||
new_imt = [
|
|
||||||
ImageTag(img_url=url, img_tag=None)
|
|
||||||
for url in image_urls
|
|
||||||
if url not in existing_urls
|
|
||||||
]
|
|
||||||
if clear_old_tags:
|
|
||||||
for tag in image_tags:
|
|
||||||
tag.img_tag = None
|
|
||||||
session.add_all(new_imt)
|
|
||||||
null_imts = [imt for imt in image_tags if imt.img_tag is None] + new_imt
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
if null_imts:
|
|
||||||
tag_datas = await autotag_images([img.img_url for img in null_imts])
|
|
||||||
print(tag_datas)
|
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
for tag, tag_data in zip(null_imts, tag_datas):
|
|
||||||
if isinstance(tag_data, Exception):
|
|
||||||
continue
|
|
||||||
tag.img_tag = tag_data.model_dump(mode="json")
|
|
||||||
session.add(tag)
|
|
||||||
await session.commit()
|
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,7 @@ Home 모듈 SQLAlchemy 모델 정의
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, List, Optional, Any
|
from typing import TYPE_CHECKING, List, Optional, Any
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Computed, Index, Integer, String, Text, JSON, func
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, JSON, func
|
||||||
from sqlalchemy.dialects.mysql import INTEGER
|
|
||||||
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
|
||||||
|
|
@ -301,12 +300,6 @@ class MarketingIntel(Base):
|
||||||
comment="마케팅 인텔리전스 결과물",
|
comment="마케팅 인텔리전스 결과물",
|
||||||
)
|
)
|
||||||
|
|
||||||
subtitle : Mapped[dict[str, Any]] = mapped_column(
|
|
||||||
JSON,
|
|
||||||
nullable=True,
|
|
||||||
comment="자막 정보 생성 결과물",
|
|
||||||
)
|
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime,
|
DateTime,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
|
|
@ -315,50 +308,13 @@ class MarketingIntel(Base):
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return (
|
task_id_str = (
|
||||||
f"<MarketingIntel(id={self.id}, place_id='{self.place_id}')>"
|
(self.task_id[:10] + "...") if len(self.task_id) > 10 else self.task_id
|
||||||
|
)
|
||||||
|
img_name_str = (
|
||||||
|
(self.img_name[:10] + "...") if len(self.img_name) > 10 else self.img_name
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
class ImageTag(Base):
|
f"<Image(id={self.id}, task_id='{task_id_str}', img_name='{img_name_str}')>"
|
||||||
"""
|
)
|
||||||
이미지 태그 테이블
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "image_tags"
|
|
||||||
__table_args__ = (
|
|
||||||
Index("idx_img_url_hash", "img_url_hash"), # CRC32 index
|
|
||||||
{
|
|
||||||
"mysql_engine": "InnoDB",
|
|
||||||
"mysql_charset": "utf8mb4",
|
|
||||||
"mysql_collate": "utf8mb4_unicode_ci",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(
|
|
||||||
Integer,
|
|
||||||
primary_key=True,
|
|
||||||
nullable=False,
|
|
||||||
autoincrement=True,
|
|
||||||
comment="고유 식별자",
|
|
||||||
)
|
|
||||||
|
|
||||||
img_url: Mapped[str] = mapped_column(
|
|
||||||
String(2048),
|
|
||||||
nullable=False,
|
|
||||||
comment="이미지 URL (blob, CDN 경로)",
|
|
||||||
)
|
|
||||||
|
|
||||||
img_url_hash: Mapped[int] = mapped_column(
|
|
||||||
INTEGER(unsigned=True),
|
|
||||||
Computed("CRC32(img_url)", persisted=True), # generated column
|
|
||||||
comment="URL CRC32 해시 (검색용 index)",
|
|
||||||
)
|
|
||||||
|
|
||||||
img_tag: Mapped[dict[str, Any]] = mapped_column(
|
|
||||||
JSON,
|
|
||||||
nullable=True,
|
|
||||||
default=False,
|
|
||||||
comment="태그 JSON",
|
|
||||||
)
|
|
||||||
|
|
@ -40,10 +40,9 @@ 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
|
||||||
from app.utils.prompts.chatgpt_prompt import ChatgptService
|
from app.utils.chatgpt_prompt import ChatgptService
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from app.utils.pagination import PaginatedResponse, get_paginated
|
from app.utils.pagination import PaginatedResponse, get_paginated
|
||||||
|
|
||||||
|
|
@ -240,22 +239,7 @@ 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(
|
||||||
|
|
@ -269,6 +253,17 @@ async def generate_lyric(
|
||||||
step1_start = time.perf_counter()
|
step1_start = time.perf_counter()
|
||||||
logger.debug(f"[generate_lyric] Step 1: 서비스 초기화 및 프롬프트 생성...")
|
logger.debug(f"[generate_lyric] Step 1: 서비스 초기화 및 프롬프트 생성...")
|
||||||
|
|
||||||
|
# service = ChatgptService(
|
||||||
|
# customer_name=request_body.customer_name,
|
||||||
|
# region=request_body.region,
|
||||||
|
# detail_region_info=request_body.detail_region_info or "",
|
||||||
|
# language=request_body.language,
|
||||||
|
# )
|
||||||
|
|
||||||
|
# prompt = service.build_lyrics_prompt()
|
||||||
|
# 원래는 실제 사용할 프롬프트가 들어가야 하나, 로직이 변경되어 이 시점에서 이곳에서 프롬프트를 생성할 이유가 없어서 삭제됨.
|
||||||
|
# 기존 코드와의 호환을 위해 동일한 로직으로 프롬프트 생성
|
||||||
|
|
||||||
promotional_expressions = {
|
promotional_expressions = {
|
||||||
"Korean" : "인스타 감성, 사진같은 하루, 힐링, 여행, 감성 숙소",
|
"Korean" : "인스타 감성, 사진같은 하루, 힐링, 여행, 감성 숙소",
|
||||||
"English" : "Instagram vibes, picture-perfect day, healing, travel, getaway",
|
"English" : "Instagram vibes, picture-perfect day, healing, travel, getaway",
|
||||||
|
|
@ -356,27 +351,13 @@ async def generate_lyric(
|
||||||
# ========== Step 4: 백그라운드 태스크 스케줄링 ==========
|
# ========== Step 4: 백그라운드 태스크 스케줄링 ==========
|
||||||
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
|
|
||||||
|
|
||||||
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(
|
|
||||||
generate_lyric_background,
|
|
||||||
task_id=task_id,
|
|
||||||
prompt=lyric_prompt,
|
|
||||||
lyric_input_data=lyric_input_data,
|
|
||||||
lyric_id=lyric.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
generate_subtitle_background,
|
generate_lyric_background,
|
||||||
orientation=orientation,
|
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
|
prompt=lyric_prompt,
|
||||||
|
lyric_input_data=lyric_input_data,
|
||||||
|
lyric_id=lyric.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
step4_elapsed = (time.perf_counter() - step4_start) * 1000
|
step4_elapsed = (time.perf_counter() - step4_start) * 1000
|
||||||
|
|
@ -516,86 +497,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="가사 상세 조회",
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ Lyric API Schemas
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, Literal
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
@ -42,8 +42,7 @@ class GenerateLyricRequest(BaseModel):
|
||||||
"region": "군산",
|
"region": "군산",
|
||||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||||
"language": "Korean",
|
"language": "Korean",
|
||||||
"m_id" : 2,
|
"m_id" : 1
|
||||||
"orientation" : "vertical"
|
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -55,8 +54,7 @@ class GenerateLyricRequest(BaseModel):
|
||||||
"region": "군산",
|
"region": "군산",
|
||||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||||
"language": "Korean",
|
"language": "Korean",
|
||||||
"m_id" : 1,
|
"m_id" : 1
|
||||||
"orientation" : "vertical"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -70,13 +68,8 @@ class GenerateLyricRequest(BaseModel):
|
||||||
language: str = Field(
|
language: str = Field(
|
||||||
default="Korean",
|
default="Korean",
|
||||||
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||||
),
|
)
|
||||||
orientation: Literal["horizontal", "vertical"] = Field(
|
|
||||||
default="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 +195,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):
|
||||||
"""가사 목록 아이템 스키마
|
"""가사 목록 아이템 스키마
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,11 @@ Lyric Background Tasks
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
from app.database.session import BackgroundSessionLocal
|
from app.database.session import BackgroundSessionLocal
|
||||||
from app.home.models import Image, Project, MarketingIntel
|
|
||||||
from app.lyric.models import Lyric
|
from app.lyric.models import Lyric
|
||||||
from app.utils.prompts.chatgpt_prompt import ChatgptService, ChatGPTResponseError
|
from app.utils.chatgpt_prompt import ChatgptService, ChatGPTResponseError
|
||||||
from app.utils.subtitles import SubtitleContentsGenerator
|
|
||||||
from app.utils.creatomate import CreatomateService
|
|
||||||
from app.utils.prompts.prompts import Prompt
|
from app.utils.prompts.prompts import Prompt
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
|
|
||||||
|
|
@ -104,6 +100,13 @@ async def generate_lyric_background(
|
||||||
step1_start = time.perf_counter()
|
step1_start = time.perf_counter()
|
||||||
logger.debug(f"[generate_lyric_background] Step 1: ChatGPT 서비스 초기화...")
|
logger.debug(f"[generate_lyric_background] Step 1: ChatGPT 서비스 초기화...")
|
||||||
|
|
||||||
|
# service = ChatgptService(
|
||||||
|
# customer_name="", # 프롬프트가 이미 생성되었으므로 빈 값
|
||||||
|
# region="",
|
||||||
|
# detail_region_info="",
|
||||||
|
# language=language,
|
||||||
|
# )
|
||||||
|
|
||||||
chatgpt = ChatgptService()
|
chatgpt = ChatgptService()
|
||||||
|
|
||||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||||
|
|
@ -155,70 +158,3 @@ async def generate_lyric_background(
|
||||||
elapsed = (time.perf_counter() - task_start) * 1000
|
elapsed = (time.perf_counter() - task_start) * 1000
|
||||||
logger.error(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)", exc_info=True)
|
logger.error(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)", exc_info=True)
|
||||||
await _update_lyric_status(task_id, "failed", f"Error: {str(e)}", lyric_id)
|
await _update_lyric_status(task_id, "failed", f"Error: {str(e)}", lyric_id)
|
||||||
|
|
||||||
async def generate_subtitle_background(
|
|
||||||
orientation: str,
|
|
||||||
task_id: str,
|
|
||||||
max_retries: int = 3,
|
|
||||||
) -> None:
|
|
||||||
logger.info(f"[generate_subtitle_background] START - task_id: {task_id}, orientation: {orientation}")
|
|
||||||
|
|
||||||
for attempt in range(1, max_retries + 1):
|
|
||||||
try:
|
|
||||||
creatomate_service = CreatomateService(orientation=orientation)
|
|
||||||
template = await creatomate_service.get_one_template_data(creatomate_service.template_id)
|
|
||||||
pitchings = creatomate_service.extract_text_format_from_template(template)
|
|
||||||
|
|
||||||
subtitle_generator = SubtitleContentsGenerator()
|
|
||||||
|
|
||||||
async with BackgroundSessionLocal() 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()
|
|
||||||
marketing_result = await session.execute(
|
|
||||||
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
|
|
||||||
)
|
|
||||||
marketing_intelligence = marketing_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
store_address = project.detail_region_info
|
|
||||||
customer_name = project.store_name
|
|
||||||
logger.info(f"[generate_subtitle_background] customer_name: {customer_name}, store_address: {store_address}")
|
|
||||||
|
|
||||||
generated_subtitles = await subtitle_generator.generate_subtitle_contents(
|
|
||||||
marketing_intelligence=marketing_intelligence.intel_result,
|
|
||||||
pitching_label_list=pitchings,
|
|
||||||
customer_name=customer_name,
|
|
||||||
detail_region_info=store_address,
|
|
||||||
)
|
|
||||||
pitching_output_list = generated_subtitles.pitching_results
|
|
||||||
|
|
||||||
subtitle_modifications = {
|
|
||||||
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}")
|
|
||||||
|
|
||||||
async with BackgroundSessionLocal() as session:
|
|
||||||
marketing_result = await session.execute(
|
|
||||||
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
|
|
||||||
)
|
|
||||||
marketing_intelligence = marketing_result.scalar_one_or_none()
|
|
||||||
marketing_intelligence.subtitle = subtitle_modifications
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
logger.info(f"[generate_subtitle_background] DONE - task_id: {task_id} (attempt {attempt}/{max_retries})")
|
|
||||||
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}")
|
|
||||||
|
|
|
||||||
|
|
@ -238,7 +238,7 @@ async def get_account_by_platform(
|
||||||
|
|
||||||
raise SocialAccountNotFoundError(platform=platform.value)
|
raise SocialAccountNotFoundError(platform=platform.value)
|
||||||
|
|
||||||
return social_account_service.to_response(account)
|
return social_account_service._to_response(account)
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,131 @@
|
||||||
"""
|
|
||||||
소셜 SEO API 라우터
|
|
||||||
|
|
||||||
SEO 관련 엔드포인트를 제공합니다.
|
import logging, json
|
||||||
비즈니스 로직은 SeoService에 위임합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
from redis.asyncio import Redis
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from config import social_oauth_settings, db_settings
|
||||||
|
from app.social.constants import YOUTUBE_SEO_HASH
|
||||||
|
from sqlalchemy import select, func
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from app.social.schemas import (
|
||||||
|
YoutubeDescriptionRequest,
|
||||||
|
YoutubeDescriptionResponse,
|
||||||
|
)
|
||||||
|
|
||||||
from app.database.session import get_session
|
from app.database.session import get_session
|
||||||
from app.social.schemas import YoutubeDescriptionRequest, YoutubeDescriptionResponse
|
|
||||||
from app.social.services import seo_service
|
|
||||||
from app.user.dependencies import get_current_user
|
from app.user.dependencies import get_current_user
|
||||||
from app.user.models import User
|
from app.user.models import User
|
||||||
|
from app.home.models import Project, MarketingIntel
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, Depends, Query
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from app.utils.prompts.prompts import yt_upload_prompt
|
||||||
|
from app.utils.chatgpt_prompt import ChatgptService, ChatGPTResponseError
|
||||||
|
|
||||||
|
redis_seo_client = Redis(
|
||||||
|
host=db_settings.REDIS_HOST,
|
||||||
|
port=db_settings.REDIS_PORT,
|
||||||
|
db=0,
|
||||||
|
decode_responses=True,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/seo", tags=["Social SEO"])
|
router = APIRouter(prefix="/seo", tags=["Social SEO"])
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/youtube",
|
"/youtube",
|
||||||
response_model=YoutubeDescriptionResponse,
|
response_model=YoutubeDescriptionResponse,
|
||||||
summary="유튜브 SEO description 생성",
|
summary="유튜브 SEO descrption 생성",
|
||||||
description="유튜브 업로드 시 사용할 description을 SEO 적용하여 생성",
|
description="유튜브 업로드 시 사용할 descrption을 SEO 적용하여 생성",
|
||||||
)
|
)
|
||||||
async def youtube_seo_description(
|
async def youtube_seo_description(
|
||||||
request_body: YoutubeDescriptionRequest,
|
request_body: YoutubeDescriptionRequest,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> YoutubeDescriptionResponse:
|
) -> YoutubeDescriptionResponse:
|
||||||
return await seo_service.get_youtube_seo_description(
|
|
||||||
request_body.task_id, current_user, session
|
# TODO : 나중에 Session Task_id 검증 미들웨어 만들면 추가해주세요.
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[youtube_seo_description] Try Cache - user: {current_user.user_uuid} / task_id : {request_body.task_id}"
|
||||||
)
|
)
|
||||||
|
cached = await get_yt_seo_in_redis(request_body.task_id)
|
||||||
|
if cached: # redis hit
|
||||||
|
return cached
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[youtube_seo_description] Cache miss - user: {current_user.user_uuid} "
|
||||||
|
)
|
||||||
|
updated_seo = await make_youtube_seo_description(request_body.task_id, current_user, session)
|
||||||
|
await set_yt_seo_in_redis(request_body.task_id, updated_seo)
|
||||||
|
|
||||||
|
return updated_seo
|
||||||
|
|
||||||
|
async def make_youtube_seo_description(
|
||||||
|
task_id: str,
|
||||||
|
current_user: User,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> YoutubeDescriptionResponse:
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[make_youtube_seo_description] START - user: {current_user.user_uuid} "
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
project_query = await session.execute(
|
||||||
|
select(Project)
|
||||||
|
.where(
|
||||||
|
Project.task_id == task_id,
|
||||||
|
Project.user_uuid == current_user.user_uuid)
|
||||||
|
.order_by(Project.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
|
||||||
|
project = project_query.scalar_one_or_none()
|
||||||
|
marketing_query = await session.execute(
|
||||||
|
select(MarketingIntel)
|
||||||
|
.where(MarketingIntel.id == project.marketing_intelligence)
|
||||||
|
)
|
||||||
|
marketing_intelligence = marketing_query.scalar_one_or_none()
|
||||||
|
|
||||||
|
hashtags = marketing_intelligence.intel_result["target_keywords"]
|
||||||
|
|
||||||
|
yt_seo_input_data = {
|
||||||
|
"customer_name" : project.store_name,
|
||||||
|
"detail_region_info" : project.detail_region_info,
|
||||||
|
"marketing_intelligence_summary" : json.dumps(marketing_intelligence.intel_result, ensure_ascii=False),
|
||||||
|
"language" : project.language,
|
||||||
|
"target_keywords" : hashtags
|
||||||
|
}
|
||||||
|
chatgpt = ChatgptService(timeout = 180)
|
||||||
|
yt_seo_output = await chatgpt.generate_structured_output(yt_upload_prompt, yt_seo_input_data)
|
||||||
|
result_dict = {
|
||||||
|
"title" : yt_seo_output.title,
|
||||||
|
"description" : yt_seo_output.description,
|
||||||
|
"keywords": hashtags
|
||||||
|
}
|
||||||
|
|
||||||
|
result = YoutubeDescriptionResponse(**result_dict)
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[youtube_seo_description] EXCEPTION - error: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"유튜브 SEO 생성에 실패했습니다. : {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_yt_seo_in_redis(task_id:str) -> YoutubeDescriptionResponse | None:
|
||||||
|
field = f"task_id:{task_id}"
|
||||||
|
yt_seo_info = await redis_seo_client.hget(YOUTUBE_SEO_HASH, field)
|
||||||
|
if yt_seo_info:
|
||||||
|
yt_seo = json.loads(yt_seo_info)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
return YoutubeDescriptionResponse(**yt_seo)
|
||||||
|
|
||||||
|
async def set_yt_seo_in_redis(task_id:str, yt_seo : YoutubeDescriptionResponse) -> None:
|
||||||
|
field = f"task_id:{task_id}"
|
||||||
|
yt_seo_info = json.dumps(yt_seo.model_dump(), ensure_ascii=False)
|
||||||
|
await redis_seo_client.hsetex(YOUTUBE_SEO_HASH, field, yt_seo_info, ex=3600)
|
||||||
|
return
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,34 +2,39 @@
|
||||||
소셜 업로드 API 라우터
|
소셜 업로드 API 라우터
|
||||||
|
|
||||||
소셜 미디어 영상 업로드 관련 엔드포인트를 제공합니다.
|
소셜 미디어 영상 업로드 관련 엔드포인트를 제공합니다.
|
||||||
비즈니스 로직은 SocialUploadService에 위임합니다.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, Query
|
from fastapi import APIRouter, BackgroundTasks, Depends, Query
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import select, func
|
||||||
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.social.constants import SocialPlatform
|
from app.social.constants import SocialPlatform, UploadStatus
|
||||||
|
from app.social.exceptions import SocialAccountNotFoundError, VideoNotFoundError
|
||||||
|
from app.social.models import SocialUpload
|
||||||
from app.social.schemas import (
|
from app.social.schemas import (
|
||||||
MessageResponse,
|
MessageResponse,
|
||||||
|
SocialUploadHistoryItem,
|
||||||
SocialUploadHistoryResponse,
|
SocialUploadHistoryResponse,
|
||||||
SocialUploadRequest,
|
SocialUploadRequest,
|
||||||
SocialUploadResponse,
|
SocialUploadResponse,
|
||||||
SocialUploadStatusResponse,
|
SocialUploadStatusResponse,
|
||||||
)
|
)
|
||||||
from app.social.services import SocialUploadService, social_account_service
|
from app.social.services import social_account_service
|
||||||
|
from app.social.worker.upload_task import process_social_upload
|
||||||
from app.user.dependencies import get_current_user
|
from app.user.dependencies import get_current_user
|
||||||
from app.user.models import User
|
from app.user.models import User
|
||||||
|
from app.video.models import Video
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/upload", tags=["Social Upload"])
|
router = APIRouter(prefix="/upload", tags=["Social Upload"])
|
||||||
|
|
||||||
upload_service = SocialUploadService(account_service=social_account_service)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"",
|
"",
|
||||||
|
|
@ -66,7 +71,126 @@ async def upload_to_social(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> SocialUploadResponse:
|
) -> SocialUploadResponse:
|
||||||
return await upload_service.request_upload(body, current_user, session, background_tasks)
|
"""
|
||||||
|
소셜 플랫폼에 영상 업로드 요청
|
||||||
|
|
||||||
|
백그라운드에서 영상을 다운로드하고 소셜 플랫폼에 업로드합니다.
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
f"[UPLOAD_API] 업로드 요청 - "
|
||||||
|
f"user_uuid: {current_user.user_uuid}, "
|
||||||
|
f"video_id: {body.video_id}, "
|
||||||
|
f"social_account_id: {body.social_account_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1. 영상 조회 및 검증
|
||||||
|
video_result = await session.execute(
|
||||||
|
select(Video).where(Video.id == body.video_id)
|
||||||
|
)
|
||||||
|
video = video_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not video:
|
||||||
|
logger.warning(f"[UPLOAD_API] 영상 없음 - video_id: {body.video_id}")
|
||||||
|
raise VideoNotFoundError(video_id=body.video_id)
|
||||||
|
|
||||||
|
if not video.result_movie_url:
|
||||||
|
logger.warning(f"[UPLOAD_API] 영상 URL 없음 - video_id: {body.video_id}")
|
||||||
|
raise VideoNotFoundError(
|
||||||
|
video_id=body.video_id,
|
||||||
|
detail="영상이 아직 준비되지 않았습니다. 영상 생성이 완료된 후 시도해주세요.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. 소셜 계정 조회 (social_account_id로 직접 조회, 소유권 검증 포함)
|
||||||
|
account = await social_account_service.get_account_by_id(
|
||||||
|
user_uuid=current_user.user_uuid,
|
||||||
|
account_id=body.social_account_id,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not account:
|
||||||
|
logger.warning(
|
||||||
|
f"[UPLOAD_API] 연동 계정 없음 - "
|
||||||
|
f"user_uuid: {current_user.user_uuid}, social_account_id: {body.social_account_id}"
|
||||||
|
)
|
||||||
|
raise SocialAccountNotFoundError()
|
||||||
|
|
||||||
|
# 3. 진행 중인 업로드 확인 (pending 또는 uploading 상태만)
|
||||||
|
in_progress_result = await session.execute(
|
||||||
|
select(SocialUpload).where(
|
||||||
|
SocialUpload.video_id == body.video_id,
|
||||||
|
SocialUpload.social_account_id == account.id,
|
||||||
|
SocialUpload.status.in_([UploadStatus.PENDING.value, UploadStatus.UPLOADING.value]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
in_progress_upload = in_progress_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if in_progress_upload:
|
||||||
|
logger.info(
|
||||||
|
f"[UPLOAD_API] 진행 중인 업로드 존재 - upload_id: {in_progress_upload.id}"
|
||||||
|
)
|
||||||
|
return SocialUploadResponse(
|
||||||
|
success=True,
|
||||||
|
upload_id=in_progress_upload.id,
|
||||||
|
platform=account.platform,
|
||||||
|
status=in_progress_upload.status,
|
||||||
|
message="이미 업로드가 진행 중입니다.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. 업로드 순번 계산 (동일 video + account 조합에서 최대 순번 + 1)
|
||||||
|
max_seq_result = await session.execute(
|
||||||
|
select(func.coalesce(func.max(SocialUpload.upload_seq), 0)).where(
|
||||||
|
SocialUpload.video_id == body.video_id,
|
||||||
|
SocialUpload.social_account_id == account.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
max_seq = max_seq_result.scalar() or 0
|
||||||
|
next_seq = max_seq + 1
|
||||||
|
|
||||||
|
# 5. 새 업로드 레코드 생성 (항상 새로 생성하여 이력 보존)
|
||||||
|
social_upload = SocialUpload(
|
||||||
|
user_uuid=current_user.user_uuid,
|
||||||
|
video_id=body.video_id,
|
||||||
|
social_account_id=account.id,
|
||||||
|
upload_seq=next_seq,
|
||||||
|
platform=account.platform,
|
||||||
|
status=UploadStatus.PENDING.value,
|
||||||
|
upload_progress=0,
|
||||||
|
title=body.title,
|
||||||
|
description=body.description,
|
||||||
|
tags=body.tags,
|
||||||
|
privacy_status=body.privacy_status.value,
|
||||||
|
scheduled_at=body.scheduled_at,
|
||||||
|
platform_options={
|
||||||
|
**(body.platform_options or {}),
|
||||||
|
"scheduled_at": body.scheduled_at.isoformat() if body.scheduled_at else None,
|
||||||
|
},
|
||||||
|
retry_count=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(social_upload)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(social_upload)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[UPLOAD_API] 업로드 레코드 생성 - "
|
||||||
|
f"upload_id: {social_upload.id}, video_id: {body.video_id}, "
|
||||||
|
f"account_id: {account.id}, upload_seq: {next_seq}, platform: {account.platform}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 6. 백그라운드 태스크 등록 (즉시 업로드 or 예약 없을 때만)
|
||||||
|
from app.utils.timezone import now as utcnow
|
||||||
|
is_scheduled = body.scheduled_at and body.scheduled_at > utcnow().replace(tzinfo=None)
|
||||||
|
if not is_scheduled:
|
||||||
|
background_tasks.add_task(process_social_upload, social_upload.id)
|
||||||
|
|
||||||
|
message = "예약 업로드가 등록되었습니다." if is_scheduled else "업로드 요청이 접수되었습니다."
|
||||||
|
return SocialUploadResponse(
|
||||||
|
success=True,
|
||||||
|
upload_id=social_upload.id,
|
||||||
|
platform=account.platform,
|
||||||
|
status=social_upload.status,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|
@ -80,7 +204,43 @@ async def get_upload_status(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> SocialUploadStatusResponse:
|
) -> SocialUploadStatusResponse:
|
||||||
return await upload_service.get_upload_status(upload_id, current_user, session)
|
"""
|
||||||
|
업로드 상태 조회
|
||||||
|
"""
|
||||||
|
logger.info(f"[UPLOAD_API] 상태 조회 - upload_id: {upload_id}")
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(SocialUpload).where(
|
||||||
|
SocialUpload.id == upload_id,
|
||||||
|
SocialUpload.user_uuid == current_user.user_uuid,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
upload = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not upload:
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="업로드 정보를 찾을 수 없습니다.",
|
||||||
|
)
|
||||||
|
|
||||||
|
return SocialUploadStatusResponse(
|
||||||
|
upload_id=upload.id,
|
||||||
|
video_id=upload.video_id,
|
||||||
|
social_account_id=upload.social_account_id,
|
||||||
|
upload_seq=upload.upload_seq,
|
||||||
|
platform=upload.platform,
|
||||||
|
status=UploadStatus(upload.status),
|
||||||
|
upload_progress=upload.upload_progress,
|
||||||
|
title=upload.title,
|
||||||
|
platform_video_id=upload.platform_video_id,
|
||||||
|
platform_url=upload.platform_url,
|
||||||
|
error_message=upload.error_message,
|
||||||
|
retry_count=upload.retry_count,
|
||||||
|
created_at=upload.created_at,
|
||||||
|
uploaded_at=upload.uploaded_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|
@ -107,8 +267,98 @@ async def get_upload_history(
|
||||||
page: int = Query(1, ge=1, description="페이지 번호"),
|
page: int = Query(1, ge=1, description="페이지 번호"),
|
||||||
size: int = Query(20, ge=1, le=100, description="페이지 크기"),
|
size: int = Query(20, ge=1, le=100, description="페이지 크기"),
|
||||||
) -> SocialUploadHistoryResponse:
|
) -> SocialUploadHistoryResponse:
|
||||||
return await upload_service.get_upload_history(
|
"""
|
||||||
current_user, session, tab, platform, year, month, page, size
|
업로드 이력 조회
|
||||||
|
"""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
target_year = year or now.year
|
||||||
|
target_month = month or now.month
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[UPLOAD_API] 이력 조회 - "
|
||||||
|
f"user_uuid: {current_user.user_uuid}, tab: {tab}, "
|
||||||
|
f"year: {target_year}, month: {target_month}, page: {page}, size: {size}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 월 범위 계산
|
||||||
|
from calendar import monthrange
|
||||||
|
last_day = monthrange(target_year, target_month)[1]
|
||||||
|
month_start = datetime(target_year, target_month, 1, 0, 0, 0)
|
||||||
|
month_end = datetime(target_year, target_month, last_day, 23, 59, 59)
|
||||||
|
|
||||||
|
# 기본 쿼리 (cancelled 제외)
|
||||||
|
query = select(SocialUpload).where(
|
||||||
|
SocialUpload.user_uuid == current_user.user_uuid,
|
||||||
|
SocialUpload.created_at >= month_start,
|
||||||
|
SocialUpload.created_at <= month_end,
|
||||||
|
SocialUpload.status != UploadStatus.CANCELLED.value,
|
||||||
|
)
|
||||||
|
count_query = select(func.count(SocialUpload.id)).where(
|
||||||
|
SocialUpload.user_uuid == current_user.user_uuid,
|
||||||
|
SocialUpload.created_at >= month_start,
|
||||||
|
SocialUpload.created_at <= month_end,
|
||||||
|
SocialUpload.status != UploadStatus.CANCELLED.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 탭 필터 적용
|
||||||
|
if tab == "completed":
|
||||||
|
query = query.where(SocialUpload.status == UploadStatus.COMPLETED.value)
|
||||||
|
count_query = count_query.where(SocialUpload.status == UploadStatus.COMPLETED.value)
|
||||||
|
elif tab == "scheduled":
|
||||||
|
query = query.where(
|
||||||
|
SocialUpload.status == UploadStatus.PENDING.value,
|
||||||
|
SocialUpload.scheduled_at.isnot(None),
|
||||||
|
)
|
||||||
|
count_query = count_query.where(
|
||||||
|
SocialUpload.status == UploadStatus.PENDING.value,
|
||||||
|
SocialUpload.scheduled_at.isnot(None),
|
||||||
|
)
|
||||||
|
elif tab == "failed":
|
||||||
|
query = query.where(SocialUpload.status == UploadStatus.FAILED.value)
|
||||||
|
count_query = count_query.where(SocialUpload.status == UploadStatus.FAILED.value)
|
||||||
|
|
||||||
|
# 플랫폼 필터 적용
|
||||||
|
if platform:
|
||||||
|
query = query.where(SocialUpload.platform == platform.value)
|
||||||
|
count_query = count_query.where(SocialUpload.platform == platform.value)
|
||||||
|
|
||||||
|
# 총 개수 조회
|
||||||
|
total_result = await session.execute(count_query)
|
||||||
|
total = total_result.scalar() or 0
|
||||||
|
|
||||||
|
# 페이지네이션 적용
|
||||||
|
query = (
|
||||||
|
query.order_by(SocialUpload.created_at.desc())
|
||||||
|
.offset((page - 1) * size)
|
||||||
|
.limit(size)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await session.execute(query)
|
||||||
|
uploads = result.scalars().all()
|
||||||
|
|
||||||
|
items = [
|
||||||
|
SocialUploadHistoryItem(
|
||||||
|
upload_id=upload.id,
|
||||||
|
video_id=upload.video_id,
|
||||||
|
social_account_id=upload.social_account_id,
|
||||||
|
upload_seq=upload.upload_seq,
|
||||||
|
platform=upload.platform,
|
||||||
|
status=upload.status,
|
||||||
|
title=upload.title,
|
||||||
|
platform_url=upload.platform_url,
|
||||||
|
error_message=upload.error_message,
|
||||||
|
scheduled_at=upload.scheduled_at,
|
||||||
|
created_at=upload.created_at,
|
||||||
|
uploaded_at=upload.uploaded_at,
|
||||||
|
)
|
||||||
|
for upload in uploads
|
||||||
|
]
|
||||||
|
|
||||||
|
return SocialUploadHistoryResponse(
|
||||||
|
items=items,
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
size=size,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -124,7 +374,53 @@ async def retry_upload(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> SocialUploadResponse:
|
) -> SocialUploadResponse:
|
||||||
return await upload_service.retry_upload(upload_id, current_user, session, background_tasks)
|
"""
|
||||||
|
업로드 재시도
|
||||||
|
|
||||||
|
실패한 업로드를 다시 시도합니다.
|
||||||
|
"""
|
||||||
|
logger.info(f"[UPLOAD_API] 재시도 요청 - upload_id: {upload_id}")
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(SocialUpload).where(
|
||||||
|
SocialUpload.id == upload_id,
|
||||||
|
SocialUpload.user_uuid == current_user.user_uuid,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
upload = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not upload:
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="업로드 정보를 찾을 수 없습니다.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if upload.status not in [UploadStatus.FAILED.value, UploadStatus.CANCELLED.value]:
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="실패하거나 취소된 업로드만 재시도할 수 있습니다.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 상태 초기화
|
||||||
|
upload.status = UploadStatus.PENDING.value
|
||||||
|
upload.upload_progress = 0
|
||||||
|
upload.error_message = None
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# 백그라운드 태스크 등록
|
||||||
|
background_tasks.add_task(process_social_upload, upload.id)
|
||||||
|
|
||||||
|
return SocialUploadResponse(
|
||||||
|
success=True,
|
||||||
|
upload_id=upload.id,
|
||||||
|
platform=upload.platform,
|
||||||
|
status=upload.status,
|
||||||
|
message="업로드 재시도가 요청되었습니다.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
|
|
@ -138,4 +434,38 @@ async def cancel_upload(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> MessageResponse:
|
) -> MessageResponse:
|
||||||
return await upload_service.cancel_upload(upload_id, current_user, session)
|
"""
|
||||||
|
업로드 취소
|
||||||
|
|
||||||
|
대기 중인 업로드를 취소합니다.
|
||||||
|
이미 진행 중이거나 완료된 업로드는 취소할 수 없습니다.
|
||||||
|
"""
|
||||||
|
logger.info(f"[UPLOAD_API] 취소 요청 - upload_id: {upload_id}")
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(SocialUpload).where(
|
||||||
|
SocialUpload.id == upload_id,
|
||||||
|
SocialUpload.user_uuid == current_user.user_uuid,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
upload = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not upload:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="업로드 정보를 찾을 수 없습니다.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if upload.status != UploadStatus.PENDING.value:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="대기 중인 업로드만 취소할 수 있습니다.",
|
||||||
|
)
|
||||||
|
|
||||||
|
upload.status = UploadStatus.CANCELLED.value
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return MessageResponse(
|
||||||
|
success=True,
|
||||||
|
message="업로드가 취소되었습니다.",
|
||||||
|
)
|
||||||
|
|
@ -91,7 +91,6 @@ PLATFORM_CONFIG = {
|
||||||
YOUTUBE_SCOPES = [
|
YOUTUBE_SCOPES = [
|
||||||
"https://www.googleapis.com/auth/youtube.upload", # 영상 업로드
|
"https://www.googleapis.com/auth/youtube.upload", # 영상 업로드
|
||||||
"https://www.googleapis.com/auth/youtube.readonly", # 채널 정보 읽기
|
"https://www.googleapis.com/auth/youtube.readonly", # 채널 정보 읽기
|
||||||
"https://www.googleapis.com/auth/yt-analytics.readonly", # 대시보드
|
|
||||||
"https://www.googleapis.com/auth/userinfo.profile", # 사용자 프로필
|
"https://www.googleapis.com/auth/userinfo.profile", # 사용자 프로필
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)}"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
"""
|
"""
|
||||||
소셜 업로드 관련 Pydantic 스키마
|
Social Media Schemas
|
||||||
|
|
||||||
|
소셜 미디어 연동 관련 Pydantic 스키마를 정의합니다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
@ -7,7 +9,123 @@ from typing import Any, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from app.social.constants import PrivacyStatus, UploadStatus
|
from app.social.constants import PrivacyStatus, SocialPlatform, UploadStatus
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# OAuth 관련 스키마
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class SocialConnectResponse(BaseModel):
|
||||||
|
"""소셜 계정 연동 시작 응답"""
|
||||||
|
|
||||||
|
auth_url: str = Field(..., description="OAuth 인증 URL")
|
||||||
|
state: str = Field(..., description="CSRF 방지용 state 토큰")
|
||||||
|
platform: str = Field(..., description="플랫폼명")
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
json_schema_extra={
|
||||||
|
"example": {
|
||||||
|
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth?...",
|
||||||
|
"state": "abc123xyz",
|
||||||
|
"platform": "youtube",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SocialAccountResponse(BaseModel):
|
||||||
|
"""연동된 소셜 계정 정보"""
|
||||||
|
|
||||||
|
id: int = Field(..., description="소셜 계정 ID")
|
||||||
|
platform: str = Field(..., description="플랫폼명")
|
||||||
|
platform_user_id: str = Field(..., description="플랫폼 내 사용자 ID")
|
||||||
|
platform_username: Optional[str] = Field(None, description="플랫폼 내 사용자명")
|
||||||
|
display_name: Optional[str] = Field(None, description="표시 이름")
|
||||||
|
profile_image_url: Optional[str] = Field(None, description="프로필 이미지 URL")
|
||||||
|
is_active: bool = Field(..., description="활성화 상태")
|
||||||
|
connected_at: datetime = Field(..., description="연동 일시")
|
||||||
|
platform_data: Optional[dict[str, Any]] = Field(
|
||||||
|
None, description="플랫폼별 추가 정보 (채널ID, 구독자 수 등)"
|
||||||
|
)
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
from_attributes=True,
|
||||||
|
json_schema_extra={
|
||||||
|
"example": {
|
||||||
|
"id": 1,
|
||||||
|
"platform": "youtube",
|
||||||
|
"platform_user_id": "UC1234567890",
|
||||||
|
"platform_username": "my_channel",
|
||||||
|
"display_name": "My Channel",
|
||||||
|
"profile_image_url": "https://...",
|
||||||
|
"is_active": True,
|
||||||
|
"connected_at": "2024-01-15T12:00:00",
|
||||||
|
"platform_data": {
|
||||||
|
"channel_id": "UC1234567890",
|
||||||
|
"channel_title": "My Channel",
|
||||||
|
"subscriber_count": 1000,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SocialAccountListResponse(BaseModel):
|
||||||
|
"""연동된 소셜 계정 목록 응답"""
|
||||||
|
|
||||||
|
accounts: list[SocialAccountResponse] = Field(..., description="연동 계정 목록")
|
||||||
|
total: int = Field(..., description="총 연동 계정 수")
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
json_schema_extra={
|
||||||
|
"example": {
|
||||||
|
"accounts": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"platform": "youtube",
|
||||||
|
"platform_user_id": "UC1234567890",
|
||||||
|
"platform_username": "my_channel",
|
||||||
|
"display_name": "My Channel",
|
||||||
|
"is_active": True,
|
||||||
|
"connected_at": "2024-01-15T12:00:00",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 내부 사용 스키마 (OAuth 토큰 응답)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthTokenResponse(BaseModel):
|
||||||
|
"""OAuth 토큰 응답 (내부 사용)"""
|
||||||
|
|
||||||
|
access_token: str
|
||||||
|
refresh_token: Optional[str] = None
|
||||||
|
expires_in: int
|
||||||
|
token_type: str = "Bearer"
|
||||||
|
scope: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformUserInfo(BaseModel):
|
||||||
|
"""플랫폼 사용자 정보 (내부 사용)"""
|
||||||
|
|
||||||
|
platform_user_id: str
|
||||||
|
username: Optional[str] = None
|
||||||
|
display_name: Optional[str] = None
|
||||||
|
profile_image_url: Optional[str] = None
|
||||||
|
platform_data: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 업로드 관련 스키마
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class SocialUploadRequest(BaseModel):
|
class SocialUploadRequest(BaseModel):
|
||||||
|
|
@ -41,7 +159,7 @@ class SocialUploadRequest(BaseModel):
|
||||||
"privacy_status": "public",
|
"privacy_status": "public",
|
||||||
"scheduled_at": "2026-02-02T15:00:00",
|
"scheduled_at": "2026-02-02T15:00:00",
|
||||||
"platform_options": {
|
"platform_options": {
|
||||||
"category_id": "22",
|
"category_id": "22", # YouTube 카테고리
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -122,7 +240,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="예약 게시 시간")
|
||||||
|
|
@ -161,3 +278,50 @@ class SocialUploadHistoryResponse(BaseModel):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class YoutubeDescriptionRequest(BaseModel):
|
||||||
|
"""유튜브 SEO Description 제안 (자동완성) Request 모델"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
json_schema_extra={
|
||||||
|
"example": {
|
||||||
|
"task_id" : "019c739f-65fc-7d15-8c88-b31be00e588e"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
task_id: str = Field(..., description="작업 고유 식별자")
|
||||||
|
|
||||||
|
class YoutubeDescriptionResponse(BaseModel):
|
||||||
|
"""유튜브 SEO Description 제안 (자동완성) Response 모델"""
|
||||||
|
title:str = Field(..., description="유튜브 영상 제목 - SEO/AEO 최적화")
|
||||||
|
description : str = Field(..., description="제안된 유튜브 SEO Description")
|
||||||
|
keywords : list[str] = Field(..., description="해시태그 리스트")
|
||||||
|
model_config = ConfigDict(
|
||||||
|
json_schema_extra={
|
||||||
|
"example": {
|
||||||
|
"title" : "여기에 더미 타이틀",
|
||||||
|
"description": "여기에 더미 텍스트",
|
||||||
|
"keywords": ["여기에", "더미", "해시태그"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 공통 응답 스키마
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class MessageResponse(BaseModel):
|
||||||
|
"""단순 메시지 응답"""
|
||||||
|
|
||||||
|
success: bool = Field(..., description="성공 여부")
|
||||||
|
message: str = Field(..., description="응답 메시지")
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
json_schema_extra={
|
||||||
|
"example": {
|
||||||
|
"success": True,
|
||||||
|
"message": "작업이 완료되었습니다.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
from app.social.schemas.oauth_schema import (
|
|
||||||
SocialConnectResponse,
|
|
||||||
SocialAccountResponse,
|
|
||||||
SocialAccountListResponse,
|
|
||||||
OAuthTokenResponse,
|
|
||||||
PlatformUserInfo,
|
|
||||||
MessageResponse,
|
|
||||||
)
|
|
||||||
from app.social.schemas.upload_schema import (
|
|
||||||
SocialUploadRequest,
|
|
||||||
SocialUploadResponse,
|
|
||||||
SocialUploadStatusResponse,
|
|
||||||
SocialUploadHistoryItem,
|
|
||||||
SocialUploadHistoryResponse,
|
|
||||||
)
|
|
||||||
from app.social.schemas.seo_schema import (
|
|
||||||
YoutubeDescriptionRequest,
|
|
||||||
YoutubeDescriptionResponse,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"SocialConnectResponse",
|
|
||||||
"SocialAccountResponse",
|
|
||||||
"SocialAccountListResponse",
|
|
||||||
"OAuthTokenResponse",
|
|
||||||
"PlatformUserInfo",
|
|
||||||
"MessageResponse",
|
|
||||||
"SocialUploadRequest",
|
|
||||||
"SocialUploadResponse",
|
|
||||||
"SocialUploadStatusResponse",
|
|
||||||
"SocialUploadHistoryItem",
|
|
||||||
"SocialUploadHistoryResponse",
|
|
||||||
"YoutubeDescriptionRequest",
|
|
||||||
"YoutubeDescriptionResponse",
|
|
||||||
]
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
"""
|
|
||||||
소셜 OAuth 관련 Pydantic 스키마
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
|
|
||||||
class SocialConnectResponse(BaseModel):
|
|
||||||
"""소셜 계정 연동 시작 응답"""
|
|
||||||
|
|
||||||
auth_url: str = Field(..., description="OAuth 인증 URL")
|
|
||||||
state: str = Field(..., description="CSRF 방지용 state 토큰")
|
|
||||||
platform: str = Field(..., description="플랫폼명")
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth?...",
|
|
||||||
"state": "abc123xyz",
|
|
||||||
"platform": "youtube",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SocialAccountResponse(BaseModel):
|
|
||||||
"""연동된 소셜 계정 정보"""
|
|
||||||
|
|
||||||
id: int = Field(..., description="소셜 계정 ID")
|
|
||||||
platform: str = Field(..., description="플랫폼명")
|
|
||||||
platform_user_id: str = Field(..., description="플랫폼 내 사용자 ID")
|
|
||||||
platform_username: Optional[str] = Field(None, description="플랫폼 내 사용자명")
|
|
||||||
display_name: Optional[str] = Field(None, description="표시 이름")
|
|
||||||
profile_image_url: Optional[str] = Field(None, description="프로필 이미지 URL")
|
|
||||||
is_active: bool = Field(..., description="활성화 상태")
|
|
||||||
connected_at: datetime = Field(..., description="연동 일시")
|
|
||||||
platform_data: Optional[dict[str, Any]] = Field(
|
|
||||||
None, description="플랫폼별 추가 정보 (채널ID, 구독자 수 등)"
|
|
||||||
)
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
from_attributes=True,
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"id": 1,
|
|
||||||
"platform": "youtube",
|
|
||||||
"platform_user_id": "UC1234567890",
|
|
||||||
"platform_username": "my_channel",
|
|
||||||
"display_name": "My Channel",
|
|
||||||
"profile_image_url": "https://...",
|
|
||||||
"is_active": True,
|
|
||||||
"connected_at": "2024-01-15T12:00:00",
|
|
||||||
"platform_data": {
|
|
||||||
"channel_id": "UC1234567890",
|
|
||||||
"channel_title": "My Channel",
|
|
||||||
"subscriber_count": 1000,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SocialAccountListResponse(BaseModel):
|
|
||||||
"""연동된 소셜 계정 목록 응답"""
|
|
||||||
|
|
||||||
accounts: list[SocialAccountResponse] = Field(..., description="연동 계정 목록")
|
|
||||||
total: int = Field(..., description="총 연동 계정 수")
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"accounts": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"platform": "youtube",
|
|
||||||
"platform_user_id": "UC1234567890",
|
|
||||||
"platform_username": "my_channel",
|
|
||||||
"display_name": "My Channel",
|
|
||||||
"is_active": True,
|
|
||||||
"connected_at": "2024-01-15T12:00:00",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total": 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class OAuthTokenResponse(BaseModel):
|
|
||||||
"""OAuth 토큰 응답 (내부 사용)"""
|
|
||||||
|
|
||||||
access_token: str
|
|
||||||
refresh_token: Optional[str] = None
|
|
||||||
expires_in: int
|
|
||||||
token_type: str = "Bearer"
|
|
||||||
scope: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class PlatformUserInfo(BaseModel):
|
|
||||||
"""플랫폼 사용자 정보 (내부 사용)"""
|
|
||||||
|
|
||||||
platform_user_id: str
|
|
||||||
username: Optional[str] = None
|
|
||||||
display_name: Optional[str] = None
|
|
||||||
profile_image_url: Optional[str] = None
|
|
||||||
platform_data: dict[str, Any] = Field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
class MessageResponse(BaseModel):
|
|
||||||
"""단순 메시지 응답"""
|
|
||||||
|
|
||||||
success: bool = Field(..., description="성공 여부")
|
|
||||||
message: str = Field(..., description="응답 메시지")
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"success": True,
|
|
||||||
"message": "작업이 완료되었습니다.",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
"""
|
|
||||||
소셜 SEO 관련 Pydantic 스키마
|
|
||||||
"""
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
|
|
||||||
class YoutubeDescriptionRequest(BaseModel):
|
|
||||||
"""유튜브 SEO Description 제안 요청"""
|
|
||||||
|
|
||||||
task_id: str = Field(..., description="작업 고유 식별자")
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"task_id": "019c739f-65fc-7d15-8c88-b31be00e588e"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class YoutubeDescriptionResponse(BaseModel):
|
|
||||||
"""유튜브 SEO Description 제안 응답"""
|
|
||||||
|
|
||||||
title: str = Field(..., description="유튜브 영상 제목 - SEO/AEO 최적화")
|
|
||||||
description: str = Field(..., description="제안된 유튜브 SEO Description")
|
|
||||||
keywords: list[str] = Field(..., description="해시태그 리스트")
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"title": "여기에 더미 타이틀",
|
|
||||||
"description": "여기에 더미 텍스트",
|
|
||||||
"keywords": ["여기에", "더미", "해시태그"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
@ -188,7 +188,7 @@ class SocialAccountService:
|
||||||
session=session,
|
session=session,
|
||||||
update_connected_at=is_reactivation, # 재활성화 시에만 연결 시간 업데이트
|
update_connected_at=is_reactivation, # 재활성화 시에만 연결 시간 업데이트
|
||||||
)
|
)
|
||||||
return self.to_response(existing_account)
|
return self._to_response(existing_account)
|
||||||
|
|
||||||
# 5. 새 소셜 계정 저장 (기존 계정이 없는 경우에만)
|
# 5. 새 소셜 계정 저장 (기존 계정이 없는 경우에만)
|
||||||
social_account = await self._create_social_account(
|
social_account = await self._create_social_account(
|
||||||
|
|
@ -204,7 +204,7 @@ class SocialAccountService:
|
||||||
f"account_id: {social_account.id}, platform: {platform.value}"
|
f"account_id: {social_account.id}, platform: {platform.value}"
|
||||||
)
|
)
|
||||||
|
|
||||||
return self.to_response(social_account)
|
return self._to_response(social_account)
|
||||||
|
|
||||||
async def get_connected_accounts(
|
async def get_connected_accounts(
|
||||||
self,
|
self,
|
||||||
|
|
@ -241,7 +241,7 @@ class SocialAccountService:
|
||||||
for account in accounts:
|
for account in accounts:
|
||||||
await self._try_refresh_token(account, session)
|
await self._try_refresh_token(account, session)
|
||||||
|
|
||||||
return [self.to_response(account) for account in accounts]
|
return [self._to_response(account) for account in accounts]
|
||||||
|
|
||||||
async def refresh_all_tokens(
|
async def refresh_all_tokens(
|
||||||
self,
|
self,
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -713,7 +713,7 @@ class SocialAccountService:
|
||||||
|
|
||||||
return account
|
return account
|
||||||
|
|
||||||
def to_response(self, account: SocialAccount) -> SocialAccountResponse:
|
def _to_response(self, account: SocialAccount) -> SocialAccountResponse:
|
||||||
"""
|
"""
|
||||||
SocialAccount를 SocialAccountResponse로 변환
|
SocialAccount를 SocialAccountResponse로 변환
|
||||||
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
from app.social.services.account_service import SocialAccountService, social_account_service
|
|
||||||
from app.social.services.upload_service import SocialUploadService
|
|
||||||
from app.social.services.seo_service import SeoService, seo_service
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"SocialAccountService",
|
|
||||||
"social_account_service",
|
|
||||||
"SocialUploadService",
|
|
||||||
"SeoService",
|
|
||||||
"seo_service",
|
|
||||||
]
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
"""
|
|
||||||
소셜 서비스 베이스 클래스
|
|
||||||
"""
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
|
|
||||||
class BaseService:
|
|
||||||
"""서비스 레이어 베이스 클래스"""
|
|
||||||
|
|
||||||
def __init__(self, session: AsyncSession | None = None):
|
|
||||||
self.session = session
|
|
||||||
|
|
@ -1,129 +0,0 @@
|
||||||
"""
|
|
||||||
유튜브 SEO 서비스
|
|
||||||
|
|
||||||
SEO description 생성 및 Redis 캐싱 로직을 처리합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from fastapi import HTTPException
|
|
||||||
from redis.asyncio import Redis
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from config import db_settings
|
|
||||||
from app.home.models import MarketingIntel, Project
|
|
||||||
from app.social.constants import YOUTUBE_SEO_HASH
|
|
||||||
from app.social.schemas import YoutubeDescriptionResponse
|
|
||||||
from app.user.models import User
|
|
||||||
from app.utils.prompts.chatgpt_prompt import ChatgptService
|
|
||||||
from app.utils.prompts.prompts import yt_upload_prompt
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
redis_seo_client = Redis(
|
|
||||||
host=db_settings.REDIS_HOST,
|
|
||||||
port=db_settings.REDIS_PORT,
|
|
||||||
db=0,
|
|
||||||
decode_responses=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SeoService:
|
|
||||||
"""유튜브 SEO 비즈니스 로직 서비스"""
|
|
||||||
|
|
||||||
async def get_youtube_seo_description(
|
|
||||||
self,
|
|
||||||
task_id: str,
|
|
||||||
current_user: User,
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> YoutubeDescriptionResponse:
|
|
||||||
"""
|
|
||||||
유튜브 SEO description 생성
|
|
||||||
|
|
||||||
Redis 캐시 확인 후 miss이면 GPT로 생성하고 캐싱.
|
|
||||||
"""
|
|
||||||
logger.info(
|
|
||||||
f"[SEO_SERVICE] Try Cache - user: {current_user.user_uuid} / task_id: {task_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
cached = await self._get_from_redis(task_id)
|
|
||||||
if cached:
|
|
||||||
return cached
|
|
||||||
|
|
||||||
logger.info(f"[SEO_SERVICE] Cache miss - user: {current_user.user_uuid}")
|
|
||||||
result = await self._generate_seo_description(task_id, current_user, session)
|
|
||||||
await self._set_to_redis(task_id, result)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
async def _generate_seo_description(
|
|
||||||
self,
|
|
||||||
task_id: str,
|
|
||||||
current_user: User,
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> YoutubeDescriptionResponse:
|
|
||||||
"""GPT를 사용하여 SEO description 생성"""
|
|
||||||
logger.info(f"[SEO_SERVICE] Generating SEO - user: {current_user.user_uuid}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
project_result = await session.execute(
|
|
||||||
select(Project)
|
|
||||||
.where(
|
|
||||||
Project.task_id == task_id,
|
|
||||||
Project.user_uuid == current_user.user_uuid,
|
|
||||||
)
|
|
||||||
.order_by(Project.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
project = project_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
marketing_result = await session.execute(
|
|
||||||
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
|
|
||||||
)
|
|
||||||
marketing_intelligence = marketing_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
hashtags = marketing_intelligence.intel_result["target_keywords"]
|
|
||||||
|
|
||||||
yt_seo_input_data = {
|
|
||||||
"customer_name": project.store_name,
|
|
||||||
"detail_region_info": project.detail_region_info,
|
|
||||||
"marketing_intelligence_summary": json.dumps(
|
|
||||||
marketing_intelligence.intel_result, ensure_ascii=False
|
|
||||||
),
|
|
||||||
"language": project.language,
|
|
||||||
"target_keywords": hashtags,
|
|
||||||
}
|
|
||||||
|
|
||||||
chatgpt = ChatgptService(timeout=180)
|
|
||||||
yt_seo_output = await chatgpt.generate_structured_output(yt_upload_prompt, yt_seo_input_data)
|
|
||||||
|
|
||||||
return YoutubeDescriptionResponse(
|
|
||||||
title=yt_seo_output.title,
|
|
||||||
description=yt_seo_output.description,
|
|
||||||
keywords=hashtags,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[SEO_SERVICE] EXCEPTION - error: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f"유튜브 SEO 생성에 실패했습니다. : {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _get_from_redis(self, task_id: str) -> YoutubeDescriptionResponse | None:
|
|
||||||
field = f"task_id:{task_id}"
|
|
||||||
yt_seo_info = await redis_seo_client.hget(YOUTUBE_SEO_HASH, field)
|
|
||||||
if yt_seo_info:
|
|
||||||
return YoutubeDescriptionResponse(**json.loads(yt_seo_info))
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def _set_to_redis(self, task_id: str, yt_seo: YoutubeDescriptionResponse) -> None:
|
|
||||||
field = f"task_id:{task_id}"
|
|
||||||
yt_seo_info = json.dumps(yt_seo.model_dump(), ensure_ascii=False)
|
|
||||||
await redis_seo_client.hset(YOUTUBE_SEO_HASH, field, yt_seo_info)
|
|
||||||
await redis_seo_client.expire(YOUTUBE_SEO_HASH, 3600)
|
|
||||||
|
|
||||||
|
|
||||||
seo_service = SeoService()
|
|
||||||
|
|
@ -1,392 +0,0 @@
|
||||||
"""
|
|
||||||
소셜 업로드 서비스
|
|
||||||
|
|
||||||
업로드 요청, 상태 조회, 이력 조회, 재시도, 취소 관련 비즈니스 로직을 처리합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from calendar import monthrange
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import BackgroundTasks, HTTPException, status
|
|
||||||
from sqlalchemy import func, select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from config import TIMEZONE
|
|
||||||
from app.social.constants import SocialPlatform, UploadStatus
|
|
||||||
from app.social.exceptions import SocialAccountNotFoundError, VideoNotFoundError
|
|
||||||
from app.social.models import SocialUpload
|
|
||||||
from app.social.schemas import (
|
|
||||||
MessageResponse,
|
|
||||||
SocialUploadHistoryItem,
|
|
||||||
SocialUploadHistoryResponse,
|
|
||||||
SocialUploadResponse,
|
|
||||||
SocialUploadStatusResponse,
|
|
||||||
SocialUploadRequest,
|
|
||||||
)
|
|
||||||
from app.social.services.account_service import SocialAccountService
|
|
||||||
from app.social.worker.upload_task import process_social_upload
|
|
||||||
from app.user.models import User
|
|
||||||
from app.video.models import Video
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class SocialUploadService:
|
|
||||||
"""소셜 업로드 비즈니스 로직 서비스"""
|
|
||||||
|
|
||||||
def __init__(self, account_service: SocialAccountService):
|
|
||||||
self._account_service = account_service
|
|
||||||
|
|
||||||
async def request_upload(
|
|
||||||
self,
|
|
||||||
body: SocialUploadRequest,
|
|
||||||
current_user: User,
|
|
||||||
session: AsyncSession,
|
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
) -> SocialUploadResponse:
|
|
||||||
"""
|
|
||||||
소셜 플랫폼 업로드 요청
|
|
||||||
|
|
||||||
영상 검증, 계정 확인, 중복 확인 후 업로드 레코드 생성.
|
|
||||||
즉시 업로드이면 백그라운드 태스크 등록, 예약이면 스케줄러가 처리.
|
|
||||||
"""
|
|
||||||
logger.info(
|
|
||||||
f"[UPLOAD_SERVICE] 업로드 요청 - "
|
|
||||||
f"user_uuid: {current_user.user_uuid}, "
|
|
||||||
f"video_id: {body.video_id}, "
|
|
||||||
f"social_account_id: {body.social_account_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 1. 영상 조회 및 검증
|
|
||||||
video_result = await session.execute(
|
|
||||||
select(Video).where(Video.id == body.video_id)
|
|
||||||
)
|
|
||||||
video = video_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not video:
|
|
||||||
logger.warning(f"[UPLOAD_SERVICE] 영상 없음 - video_id: {body.video_id}")
|
|
||||||
raise VideoNotFoundError(video_id=body.video_id)
|
|
||||||
|
|
||||||
if not video.result_movie_url:
|
|
||||||
logger.warning(f"[UPLOAD_SERVICE] 영상 URL 없음 - video_id: {body.video_id}")
|
|
||||||
raise VideoNotFoundError(
|
|
||||||
video_id=body.video_id,
|
|
||||||
detail="영상이 아직 준비되지 않았습니다. 영상 생성이 완료된 후 시도해주세요.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2. 소셜 계정 조회 및 소유권 검증
|
|
||||||
account = await self._account_service.get_account_by_id(
|
|
||||||
user_uuid=current_user.user_uuid,
|
|
||||||
account_id=body.social_account_id,
|
|
||||||
session=session,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not account:
|
|
||||||
logger.warning(
|
|
||||||
f"[UPLOAD_SERVICE] 연동 계정 없음 - "
|
|
||||||
f"user_uuid: {current_user.user_uuid}, social_account_id: {body.social_account_id}"
|
|
||||||
)
|
|
||||||
raise SocialAccountNotFoundError()
|
|
||||||
|
|
||||||
# 3. 진행 중인 업로드 확인 (pending 또는 uploading 상태만)
|
|
||||||
in_progress_result = await session.execute(
|
|
||||||
select(SocialUpload).where(
|
|
||||||
SocialUpload.video_id == body.video_id,
|
|
||||||
SocialUpload.social_account_id == account.id,
|
|
||||||
SocialUpload.status.in_([UploadStatus.PENDING.value, UploadStatus.UPLOADING.value]),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
in_progress_upload = in_progress_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if in_progress_upload:
|
|
||||||
logger.info(
|
|
||||||
f"[UPLOAD_SERVICE] 진행 중인 업로드 존재 - upload_id: {in_progress_upload.id}"
|
|
||||||
)
|
|
||||||
return SocialUploadResponse(
|
|
||||||
success=True,
|
|
||||||
upload_id=in_progress_upload.id,
|
|
||||||
platform=account.platform,
|
|
||||||
status=in_progress_upload.status,
|
|
||||||
message="이미 업로드가 진행 중입니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 4. 업로드 순번 계산
|
|
||||||
max_seq_result = await session.execute(
|
|
||||||
select(func.coalesce(func.max(SocialUpload.upload_seq), 0)).where(
|
|
||||||
SocialUpload.video_id == body.video_id,
|
|
||||||
SocialUpload.social_account_id == account.id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
next_seq = (max_seq_result.scalar() or 0) + 1
|
|
||||||
|
|
||||||
# 5. 새 업로드 레코드 생성
|
|
||||||
social_upload = SocialUpload(
|
|
||||||
user_uuid=current_user.user_uuid,
|
|
||||||
video_id=body.video_id,
|
|
||||||
social_account_id=account.id,
|
|
||||||
upload_seq=next_seq,
|
|
||||||
platform=account.platform,
|
|
||||||
status=UploadStatus.PENDING.value,
|
|
||||||
upload_progress=0,
|
|
||||||
title=body.title,
|
|
||||||
description=body.description,
|
|
||||||
tags=body.tags,
|
|
||||||
privacy_status=body.privacy_status.value,
|
|
||||||
scheduled_at=body.scheduled_at,
|
|
||||||
platform_options={
|
|
||||||
**(body.platform_options or {}),
|
|
||||||
"scheduled_at": body.scheduled_at.isoformat() if body.scheduled_at else None,
|
|
||||||
},
|
|
||||||
retry_count=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
session.add(social_upload)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(social_upload)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[UPLOAD_SERVICE] 업로드 레코드 생성 - "
|
|
||||||
f"upload_id: {social_upload.id}, video_id: {body.video_id}, "
|
|
||||||
f"account_id: {account.id}, upload_seq: {next_seq}, platform: {account.platform}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 6. 즉시 업로드이면 백그라운드 태스크 등록
|
|
||||||
now_kst_naive = datetime.now(TIMEZONE).replace(tzinfo=None)
|
|
||||||
is_scheduled = body.scheduled_at and body.scheduled_at > now_kst_naive
|
|
||||||
if not is_scheduled:
|
|
||||||
background_tasks.add_task(process_social_upload, social_upload.id)
|
|
||||||
|
|
||||||
message = "예약 업로드가 등록되었습니다." if is_scheduled else "업로드 요청이 접수되었습니다."
|
|
||||||
return SocialUploadResponse(
|
|
||||||
success=True,
|
|
||||||
upload_id=social_upload.id,
|
|
||||||
platform=account.platform,
|
|
||||||
status=social_upload.status,
|
|
||||||
message=message,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_upload_status(
|
|
||||||
self,
|
|
||||||
upload_id: int,
|
|
||||||
current_user: User,
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> SocialUploadStatusResponse:
|
|
||||||
"""업로드 상태 조회"""
|
|
||||||
logger.info(f"[UPLOAD_SERVICE] 상태 조회 - upload_id: {upload_id}")
|
|
||||||
|
|
||||||
result = await session.execute(
|
|
||||||
select(SocialUpload).where(
|
|
||||||
SocialUpload.id == upload_id,
|
|
||||||
SocialUpload.user_uuid == current_user.user_uuid,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
upload = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not upload:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="업로드 정보를 찾을 수 없습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
return SocialUploadStatusResponse(
|
|
||||||
upload_id=upload.id,
|
|
||||||
video_id=upload.video_id,
|
|
||||||
social_account_id=upload.social_account_id,
|
|
||||||
upload_seq=upload.upload_seq,
|
|
||||||
platform=upload.platform,
|
|
||||||
status=UploadStatus(upload.status),
|
|
||||||
upload_progress=upload.upload_progress,
|
|
||||||
title=upload.title,
|
|
||||||
platform_video_id=upload.platform_video_id,
|
|
||||||
platform_url=upload.platform_url,
|
|
||||||
error_message=upload.error_message,
|
|
||||||
retry_count=upload.retry_count,
|
|
||||||
scheduled_at=upload.scheduled_at,
|
|
||||||
created_at=upload.created_at,
|
|
||||||
uploaded_at=upload.uploaded_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_upload_history(
|
|
||||||
self,
|
|
||||||
current_user: User,
|
|
||||||
session: AsyncSession,
|
|
||||||
tab: str = "all",
|
|
||||||
platform: Optional[SocialPlatform] = None,
|
|
||||||
year: Optional[int] = None,
|
|
||||||
month: Optional[int] = None,
|
|
||||||
page: int = 1,
|
|
||||||
size: int = 20,
|
|
||||||
) -> SocialUploadHistoryResponse:
|
|
||||||
"""업로드 이력 조회 (탭/년월/플랫폼 필터, 페이지네이션)"""
|
|
||||||
now_kst = datetime.now(TIMEZONE)
|
|
||||||
target_year = year or now_kst.year
|
|
||||||
target_month = month or now_kst.month
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[UPLOAD_SERVICE] 이력 조회 - "
|
|
||||||
f"user_uuid: {current_user.user_uuid}, tab: {tab}, "
|
|
||||||
f"year: {target_year}, month: {target_month}, page: {page}, size: {size}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 월 범위 계산
|
|
||||||
last_day = monthrange(target_year, target_month)[1]
|
|
||||||
month_start = datetime(target_year, target_month, 1, 0, 0, 0)
|
|
||||||
month_end = datetime(target_year, target_month, last_day, 23, 59, 59)
|
|
||||||
|
|
||||||
# 기본 쿼리 (cancelled 제외)
|
|
||||||
base_conditions = [
|
|
||||||
SocialUpload.user_uuid == current_user.user_uuid,
|
|
||||||
SocialUpload.created_at >= month_start,
|
|
||||||
SocialUpload.created_at <= month_end,
|
|
||||||
SocialUpload.status != UploadStatus.CANCELLED.value,
|
|
||||||
]
|
|
||||||
|
|
||||||
query = select(SocialUpload).where(*base_conditions)
|
|
||||||
count_query = select(func.count(SocialUpload.id)).where(*base_conditions)
|
|
||||||
|
|
||||||
# 탭 필터 적용
|
|
||||||
if tab == "completed":
|
|
||||||
query = query.where(SocialUpload.status == UploadStatus.COMPLETED.value)
|
|
||||||
count_query = count_query.where(SocialUpload.status == UploadStatus.COMPLETED.value)
|
|
||||||
elif tab == "scheduled":
|
|
||||||
query = query.where(
|
|
||||||
SocialUpload.status == UploadStatus.PENDING.value,
|
|
||||||
SocialUpload.scheduled_at.isnot(None),
|
|
||||||
)
|
|
||||||
count_query = count_query.where(
|
|
||||||
SocialUpload.status == UploadStatus.PENDING.value,
|
|
||||||
SocialUpload.scheduled_at.isnot(None),
|
|
||||||
)
|
|
||||||
elif tab == "failed":
|
|
||||||
query = query.where(SocialUpload.status == UploadStatus.FAILED.value)
|
|
||||||
count_query = count_query.where(SocialUpload.status == UploadStatus.FAILED.value)
|
|
||||||
|
|
||||||
# 플랫폼 필터 적용
|
|
||||||
if platform:
|
|
||||||
query = query.where(SocialUpload.platform == platform.value)
|
|
||||||
count_query = count_query.where(SocialUpload.platform == platform.value)
|
|
||||||
|
|
||||||
# 총 개수 조회
|
|
||||||
total_result = await session.execute(count_query)
|
|
||||||
total = total_result.scalar() or 0
|
|
||||||
|
|
||||||
# 페이지네이션 적용
|
|
||||||
query = (
|
|
||||||
query.order_by(SocialUpload.created_at.desc())
|
|
||||||
.offset((page - 1) * size)
|
|
||||||
.limit(size)
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await session.execute(query)
|
|
||||||
uploads = result.scalars().all()
|
|
||||||
|
|
||||||
items = [
|
|
||||||
SocialUploadHistoryItem(
|
|
||||||
upload_id=upload.id,
|
|
||||||
video_id=upload.video_id,
|
|
||||||
social_account_id=upload.social_account_id,
|
|
||||||
upload_seq=upload.upload_seq,
|
|
||||||
platform=upload.platform,
|
|
||||||
status=upload.status,
|
|
||||||
title=upload.title,
|
|
||||||
platform_username=upload.social_account.platform_data.get("display_name") if upload.social_account and upload.social_account.platform_data else None,
|
|
||||||
platform_url=upload.platform_url,
|
|
||||||
error_message=upload.error_message,
|
|
||||||
scheduled_at=upload.scheduled_at,
|
|
||||||
created_at=upload.created_at,
|
|
||||||
uploaded_at=upload.uploaded_at,
|
|
||||||
)
|
|
||||||
for upload in uploads
|
|
||||||
]
|
|
||||||
|
|
||||||
return SocialUploadHistoryResponse(
|
|
||||||
items=items,
|
|
||||||
total=total,
|
|
||||||
page=page,
|
|
||||||
size=size,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def retry_upload(
|
|
||||||
self,
|
|
||||||
upload_id: int,
|
|
||||||
current_user: User,
|
|
||||||
session: AsyncSession,
|
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
) -> SocialUploadResponse:
|
|
||||||
"""실패한 업로드 재시도"""
|
|
||||||
logger.info(f"[UPLOAD_SERVICE] 재시도 요청 - upload_id: {upload_id}")
|
|
||||||
|
|
||||||
result = await session.execute(
|
|
||||||
select(SocialUpload).where(
|
|
||||||
SocialUpload.id == upload_id,
|
|
||||||
SocialUpload.user_uuid == current_user.user_uuid,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
upload = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not upload:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="업로드 정보를 찾을 수 없습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
if upload.status not in [UploadStatus.FAILED.value, UploadStatus.CANCELLED.value]:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="실패하거나 취소된 업로드만 재시도할 수 있습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 상태 초기화
|
|
||||||
upload.status = UploadStatus.PENDING.value
|
|
||||||
upload.upload_progress = 0
|
|
||||||
upload.error_message = None
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
background_tasks.add_task(process_social_upload, upload.id)
|
|
||||||
|
|
||||||
return SocialUploadResponse(
|
|
||||||
success=True,
|
|
||||||
upload_id=upload.id,
|
|
||||||
platform=upload.platform,
|
|
||||||
status=upload.status,
|
|
||||||
message="업로드 재시도가 요청되었습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def cancel_upload(
|
|
||||||
self,
|
|
||||||
upload_id: int,
|
|
||||||
current_user: User,
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> MessageResponse:
|
|
||||||
"""대기 중인 업로드 취소"""
|
|
||||||
logger.info(f"[UPLOAD_SERVICE] 취소 요청 - upload_id: {upload_id}")
|
|
||||||
|
|
||||||
result = await session.execute(
|
|
||||||
select(SocialUpload).where(
|
|
||||||
SocialUpload.id == upload_id,
|
|
||||||
SocialUpload.user_uuid == current_user.user_uuid,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
upload = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not upload:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="업로드 정보를 찾을 수 없습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
if upload.status != UploadStatus.PENDING.value:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="대기 중인 업로드만 취소할 수 있습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
upload.status = UploadStatus.CANCELLED.value
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
return MessageResponse(
|
|
||||||
success=True,
|
|
||||||
message="업로드가 취소되었습니다.",
|
|
||||||
)
|
|
||||||
|
|
@ -18,7 +18,6 @@ from sqlalchemy import select
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
from config import social_upload_settings
|
from config import social_upload_settings
|
||||||
from app.dashboard.tasks import insert_dashboard
|
|
||||||
from app.database.session import BackgroundSessionLocal
|
from app.database.session import BackgroundSessionLocal
|
||||||
from app.social.constants import SocialPlatform, UploadStatus
|
from app.social.constants import SocialPlatform, UploadStatus
|
||||||
from app.social.exceptions import TokenExpiredError, UploadError, UploadQuotaExceededError
|
from app.social.exceptions import TokenExpiredError, UploadError, UploadQuotaExceededError
|
||||||
|
|
@ -319,7 +318,6 @@ async def process_social_upload(upload_id: int) -> None:
|
||||||
f"platform_video_id: {result.platform_video_id}, "
|
f"platform_video_id: {result.platform_video_id}, "
|
||||||
f"url: {result.platform_url}"
|
f"url: {result.platform_url}"
|
||||||
)
|
)
|
||||||
await insert_dashboard(upload_id)
|
|
||||||
else:
|
else:
|
||||||
retry_count = await _increment_retry_count(upload_id)
|
retry_count = await _increment_retry_count(upload_id)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,8 +436,14 @@ async def get_song_status(
|
||||||
)
|
)
|
||||||
|
|
||||||
suno_audio_id = first_clip.get("id")
|
suno_audio_id = first_clip.get("id")
|
||||||
|
word_data = await suno_service.get_lyric_timestamp(
|
||||||
# BGM 모드(lyric_result가 비어 있음)에서는 타임스탬프 생성 스킵
|
suno_task_id, suno_audio_id
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"[get_song_status] word_data from get_lyric_timestamp - "
|
||||||
|
f"suno_task_id: {suno_task_id}, suno_audio_id: {suno_audio_id}, "
|
||||||
|
f"word_data: {word_data}"
|
||||||
|
)
|
||||||
lyric_result = await session.execute(
|
lyric_result = await session.execute(
|
||||||
select(Lyric)
|
select(Lyric)
|
||||||
.where(Lyric.task_id == song.task_id)
|
.where(Lyric.task_id == song.task_id)
|
||||||
|
|
@ -463,60 +451,51 @@ async def get_song_status(
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
lyric = lyric_result.scalar_one_or_none()
|
lyric = lyric_result.scalar_one_or_none()
|
||||||
gt_lyric = lyric.lyric_result if lyric else None
|
gt_lyric = lyric.lyric_result
|
||||||
|
lyric_line_list = gt_lyric.split("\n")
|
||||||
|
sentences = [
|
||||||
|
lyric_line.strip(",. ")
|
||||||
|
for lyric_line in lyric_line_list
|
||||||
|
if lyric_line and lyric_line != "---"
|
||||||
|
]
|
||||||
|
logger.debug(
|
||||||
|
f"[get_song_status] sentences from lyric - "
|
||||||
|
f"sentences: {sentences}"
|
||||||
|
)
|
||||||
|
|
||||||
if gt_lyric:
|
timestamped_lyrics = suno_service.align_lyrics(
|
||||||
word_data = await suno_service.get_lyric_timestamp(
|
word_data, sentences
|
||||||
suno_task_id, suno_audio_id
|
)
|
||||||
)
|
logger.debug(
|
||||||
logger.debug(
|
f"[get_song_status] sentences from lyric - "
|
||||||
f"[get_song_status] word_data from get_lyric_timestamp - "
|
f"sentences: {sentences}"
|
||||||
f"suno_task_id: {suno_task_id}, suno_audio_id: {suno_audio_id}, "
|
)
|
||||||
f"word_data: {word_data}"
|
|
||||||
)
|
|
||||||
lyric_line_list = gt_lyric.split("\n")
|
|
||||||
sentences = [
|
|
||||||
lyric_line.strip(",. ")
|
|
||||||
for lyric_line in lyric_line_list
|
|
||||||
if lyric_line and lyric_line != "---"
|
|
||||||
]
|
|
||||||
logger.debug(
|
|
||||||
f"[get_song_status] sentences from lyric - "
|
|
||||||
f"sentences: {sentences}"
|
|
||||||
)
|
|
||||||
|
|
||||||
timestamped_lyrics = suno_service.align_lyrics(
|
# TODO : DB upload timestamped_lyrics
|
||||||
word_data, sentences
|
for order_idx, timestamped_lyric in enumerate(
|
||||||
)
|
timestamped_lyrics
|
||||||
|
):
|
||||||
for order_idx, timestamped_lyric in enumerate(
|
# start_sec 또는 end_sec가 None인 경우 건너뛰기
|
||||||
timestamped_lyrics
|
if (
|
||||||
|
timestamped_lyric["start_sec"] is None
|
||||||
|
or timestamped_lyric["end_sec"] is None
|
||||||
):
|
):
|
||||||
if (
|
logger.warning(
|
||||||
timestamped_lyric["start_sec"] is None
|
f"[get_song_status] Skipping timestamp - "
|
||||||
or timestamped_lyric["end_sec"] is None
|
f"lyric_line: {timestamped_lyric['text']}, "
|
||||||
):
|
f"start_sec: {timestamped_lyric['start_sec']}, "
|
||||||
logger.warning(
|
f"end_sec: {timestamped_lyric['end_sec']}"
|
||||||
f"[get_song_status] Skipping timestamp - "
|
|
||||||
f"lyric_line: {timestamped_lyric['text']}, "
|
|
||||||
f"start_sec: {timestamped_lyric['start_sec']}, "
|
|
||||||
f"end_sec: {timestamped_lyric['end_sec']}"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
song_timestamp = SongTimestamp(
|
|
||||||
suno_audio_id=suno_audio_id,
|
|
||||||
order_idx=order_idx,
|
|
||||||
lyric_line=timestamped_lyric["text"],
|
|
||||||
start_time=timestamped_lyric["start_sec"],
|
|
||||||
end_time=timestamped_lyric["end_sec"],
|
|
||||||
)
|
)
|
||||||
session.add(song_timestamp)
|
continue
|
||||||
else:
|
|
||||||
logger.info(
|
song_timestamp = SongTimestamp(
|
||||||
f"[get_song_status] BGM 모드 - 타임스탬프 생성 스킵, "
|
suno_audio_id=suno_audio_id,
|
||||||
f"suno_task_id: {suno_task_id}"
|
order_idx=order_idx,
|
||||||
|
lyric_line=timestamped_lyric["text"],
|
||||||
|
start_time=timestamped_lyric["start_sec"],
|
||||||
|
end_time=timestamped_lyric["end_sec"],
|
||||||
)
|
)
|
||||||
|
session.add(song_timestamp)
|
||||||
|
|
||||||
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):
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,14 @@ from sqlalchemy import Connection, text
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from app.lyric.schemas.lyrics_schema import (
|
from app.lyrics.schemas.lyrics_schema import (
|
||||||
AttributeData,
|
AttributeData,
|
||||||
PromptTemplateData,
|
PromptTemplateData,
|
||||||
SongFormData,
|
SongFormData,
|
||||||
SongSampleData,
|
SongSampleData,
|
||||||
StoreData,
|
StoreData,
|
||||||
)
|
)
|
||||||
from app.utils.prompts.chatgpt_prompt import chatgpt_api
|
from app.utils.chatgpt_prompt import chatgpt_api
|
||||||
|
|
||||||
logger = get_logger("song")
|
logger = get_logger("song")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 해시값 생성
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
from pydantic.main import BaseModel
|
|
||||||
|
|
||||||
from app.utils.prompts.chatgpt_prompt import ChatgptService
|
|
||||||
from app.utils.prompts.prompts import image_autotag_prompt
|
|
||||||
from app.utils.prompts.schemas import SpaceType, Subject, Camera, MotionRecommended
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
async def autotag_image(image_url : str) -> list[str]: #tag_list
|
|
||||||
chatgpt = ChatgptService(model_type="gemini")
|
|
||||||
image_input_data = {
|
|
||||||
"img_url" : image_url,
|
|
||||||
"space_type" : list(SpaceType),
|
|
||||||
"subject" : list(Subject),
|
|
||||||
"camera" : list(Camera),
|
|
||||||
"motion_recommended" : list(MotionRecommended)
|
|
||||||
}
|
|
||||||
|
|
||||||
image_result = await chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_url, False)
|
|
||||||
return image_result
|
|
||||||
|
|
||||||
async def autotag_images(image_url_list : list[str]) -> list[dict]: #tag_list
|
|
||||||
chatgpt = ChatgptService(model_type="gemini")
|
|
||||||
image_input_data_list = [{
|
|
||||||
"img_url" : image_url,
|
|
||||||
"space_type" : list(SpaceType),
|
|
||||||
"subject" : list(Subject),
|
|
||||||
"camera" : list(Camera),
|
|
||||||
"motion_recommended" : list(MotionRecommended)
|
|
||||||
}for image_url in image_url_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)
|
|
||||||
MAX_RETRY = 2 # 하드코딩, 어떻게 처리할지는 나중에
|
|
||||||
for _ in range(MAX_RETRY):
|
|
||||||
failed_idx = [i for i, r in enumerate(image_result_list) if isinstance(r, Exception)]
|
|
||||||
print("Failed", failed_idx)
|
|
||||||
if not failed_idx:
|
|
||||||
break
|
|
||||||
retried = await asyncio.gather(
|
|
||||||
*[chatgpt.generate_structured_output(image_autotag_prompt, image_input_data_list[i], image_input_data_list[i]['img_url'], False, silent=True) for i in failed_idx],
|
|
||||||
return_exceptions=True
|
|
||||||
)
|
|
||||||
for i, result in zip(failed_idx, retried):
|
|
||||||
image_result_list[i] = result
|
|
||||||
|
|
||||||
print("Failed", failed_idx)
|
|
||||||
return image_result_list
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
|
from app.utils.logger import get_logger
|
||||||
|
from config import apikey_settings, recovery_settings
|
||||||
|
from app.utils.prompts.prompts import Prompt
|
||||||
|
|
||||||
|
|
||||||
|
# 로거 설정
|
||||||
|
logger = get_logger("chatgpt")
|
||||||
|
|
||||||
|
|
||||||
|
class ChatGPTResponseError(Exception):
|
||||||
|
"""ChatGPT API 응답 에러"""
|
||||||
|
def __init__(self, status: str, error_code: str = None, error_message: str = None):
|
||||||
|
self.status = status
|
||||||
|
self.error_code = error_code
|
||||||
|
self.error_message = error_message
|
||||||
|
super().__init__(f"ChatGPT response failed: status={status}, code={error_code}, message={error_message}")
|
||||||
|
|
||||||
|
|
||||||
|
class ChatgptService:
|
||||||
|
"""ChatGPT API 서비스 클래스
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, timeout: float = None):
|
||||||
|
self.timeout = timeout or recovery_settings.CHATGPT_TIMEOUT
|
||||||
|
self.max_retries = recovery_settings.CHATGPT_MAX_RETRIES
|
||||||
|
self.client = AsyncOpenAI(
|
||||||
|
api_key=apikey_settings.CHATGPT_API_KEY,
|
||||||
|
timeout=self.timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _call_pydantic_output(self, prompt : str, output_format : BaseModel, model : str) -> BaseModel: # 입력 output_format의 경우 Pydantic BaseModel Class를 상속한 Class 자체임에 유의할 것
|
||||||
|
content = [{"type": "input_text", "text": prompt}]
|
||||||
|
last_error = None
|
||||||
|
for attempt in range(self.max_retries + 1):
|
||||||
|
response = await self.client.responses.parse(
|
||||||
|
model=model,
|
||||||
|
input=[{"role": "user", "content": content}],
|
||||||
|
text_format=output_format
|
||||||
|
)
|
||||||
|
# Response 디버그 로깅
|
||||||
|
logger.debug(f"[ChatgptService] attempt: {attempt}")
|
||||||
|
logger.debug(f"[ChatgptService] Response ID: {response.id}")
|
||||||
|
logger.debug(f"[ChatgptService] Response status: {response.status}")
|
||||||
|
logger.debug(f"[ChatgptService] Response model: {response.model}")
|
||||||
|
|
||||||
|
# status 확인: completed, failed, incomplete, cancelled, queued, in_progress
|
||||||
|
if response.status == "completed":
|
||||||
|
logger.debug(f"[ChatgptService] Response output_text: {response.output_text[:200]}..." if len(response.output_text) > 200 else f"[ChatgptService] Response output_text: {response.output_text}")
|
||||||
|
structured_output = response.output_parsed
|
||||||
|
return structured_output #.model_dump() or {}
|
||||||
|
|
||||||
|
# 에러 상태 처리
|
||||||
|
if response.status == "failed":
|
||||||
|
error_code = getattr(response.error, 'code', None) if response.error else None
|
||||||
|
error_message = getattr(response.error, 'message', None) if response.error else None
|
||||||
|
logger.warning(f"[ChatgptService] Response failed (attempt {attempt + 1}/{self.max_retries + 1}): code={error_code}, message={error_message}")
|
||||||
|
last_error = ChatGPTResponseError(response.status, error_code, error_message)
|
||||||
|
|
||||||
|
elif response.status == "incomplete":
|
||||||
|
reason = getattr(response.incomplete_details, 'reason', None) if response.incomplete_details else None
|
||||||
|
logger.warning(f"[ChatgptService] Response incomplete (attempt {attempt + 1}/{self.max_retries + 1}): reason={reason}")
|
||||||
|
last_error = ChatGPTResponseError(response.status, reason, f"Response incomplete: {reason}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
# cancelled, queued, in_progress 등 예상치 못한 상태
|
||||||
|
logger.warning(f"[ChatgptService] Unexpected response status (attempt {attempt + 1}/{self.max_retries + 1}): {response.status}")
|
||||||
|
last_error = ChatGPTResponseError(response.status, None, f"Unexpected status: {response.status}")
|
||||||
|
|
||||||
|
# 마지막 시도가 아니면 재시도
|
||||||
|
if attempt < self.max_retries:
|
||||||
|
logger.info(f"[ChatgptService] Retrying request...")
|
||||||
|
|
||||||
|
# 모든 재시도 실패
|
||||||
|
logger.error(f"[ChatgptService] All retries exhausted. Last error: {last_error}")
|
||||||
|
raise last_error
|
||||||
|
|
||||||
|
async def generate_structured_output(
|
||||||
|
self,
|
||||||
|
prompt : Prompt,
|
||||||
|
input_data : dict,
|
||||||
|
) -> BaseModel:
|
||||||
|
prompt_text = prompt.build_prompt(input_data)
|
||||||
|
|
||||||
|
logger.debug(f"[ChatgptService] Generated Prompt (length: {len(prompt_text)})")
|
||||||
|
logger.info(f"[ChatgptService] Starting GPT request with structured output with model: {prompt.prompt_model}")
|
||||||
|
|
||||||
|
# GPT API 호출
|
||||||
|
#response = await self._call_structured_output_with_response_gpt_api(prompt_text, prompt.prompt_output, prompt.prompt_model)
|
||||||
|
response = await self._call_pydantic_output(prompt_text, prompt.prompt_output_class, prompt.prompt_model)
|
||||||
|
return response
|
||||||
|
|
@ -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 문자열을 생성합니다.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,15 +31,11 @@ response = await creatomate.make_creatomate_call(template_id, modifications)
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
import time
|
import time
|
||||||
from enum import StrEnum
|
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
import traceback
|
|
||||||
import httpx
|
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.common import normalize_location
|
|
||||||
|
|
||||||
from config import apikey_settings, creatomate_settings, recovery_settings
|
from config import apikey_settings, creatomate_settings, recovery_settings
|
||||||
|
|
||||||
# 로거 설정
|
# 로거 설정
|
||||||
|
|
@ -224,28 +220,6 @@ autotext_template_h_1 = {
|
||||||
"stroke_color": "#333333",
|
"stroke_color": "#333333",
|
||||||
"stroke_width": "0.2 vmin"
|
"stroke_width": "0.2 vmin"
|
||||||
}
|
}
|
||||||
DVST0001 = "75161273-0422-4771-adeb-816bd7263fb0"
|
|
||||||
DVST0002 = "c68cf750-bc40-485a-a2c5-3f9fe301e386"
|
|
||||||
DVST0003 = "e1fb5b00-1f02-4f63-99fa-7524b433ba47"
|
|
||||||
DHST0001 = "660be601-080a-43ea-bf0f-adcf4596fa98"
|
|
||||||
DHST0002 = "3f194cc7-464e-4581-9db2-179d42d3e40f"
|
|
||||||
DHST0003 = "f45df555-2956-4a13-9004-ead047070b3d"
|
|
||||||
DVST0001T = "fe11aeab-ff29-4bc8-9f75-c695c7e243e6"
|
|
||||||
HST_LIST = [DHST0001,DHST0002,DHST0003]
|
|
||||||
VST_LIST = [DVST0001,DVST0002,DVST0003, DVST0001T]
|
|
||||||
|
|
||||||
SCENE_TRACK = 1
|
|
||||||
AUDIO_TRACK = 2
|
|
||||||
SUBTITLE_TRACK = 3
|
|
||||||
KEYWORD_TRACK = 4
|
|
||||||
|
|
||||||
def select_template(orientation:OrientationType):
|
|
||||||
if orientation == "horizontal":
|
|
||||||
return DHST0001
|
|
||||||
elif orientation == "vertical":
|
|
||||||
return DVST0001T
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def get_shared_client() -> httpx.AsyncClient:
|
async def get_shared_client() -> httpx.AsyncClient:
|
||||||
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
|
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
|
||||||
|
|
@ -290,10 +264,23 @@ class CreatomateService:
|
||||||
|
|
||||||
BASE_URL = "https://api.creatomate.com"
|
BASE_URL = "https://api.creatomate.com"
|
||||||
|
|
||||||
|
# 템플릿 설정 (config에서 가져옴)
|
||||||
|
TEMPLATE_CONFIG = {
|
||||||
|
"horizontal": {
|
||||||
|
"template_id": creatomate_settings.TEMPLATE_ID_HORIZONTAL,
|
||||||
|
"duration": creatomate_settings.TEMPLATE_DURATION_HORIZONTAL,
|
||||||
|
},
|
||||||
|
"vertical": {
|
||||||
|
"template_id": creatomate_settings.TEMPLATE_ID_VERTICAL,
|
||||||
|
"duration": creatomate_settings.TEMPLATE_DURATION_VERTICAL,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
orientation: OrientationType = "vertical"
|
orientation: OrientationType = "vertical",
|
||||||
|
target_duration: float | None = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -307,7 +294,14 @@ class CreatomateService:
|
||||||
self.orientation = orientation
|
self.orientation = orientation
|
||||||
|
|
||||||
# orientation에 따른 템플릿 설정 가져오기
|
# orientation에 따른 템플릿 설정 가져오기
|
||||||
self.template_id = select_template(orientation)
|
config = self.TEMPLATE_CONFIG.get(
|
||||||
|
orientation, self.TEMPLATE_CONFIG["vertical"]
|
||||||
|
)
|
||||||
|
self.template_id = config["template_id"]
|
||||||
|
self.target_duration = (
|
||||||
|
target_duration if target_duration is not None else config["duration"]
|
||||||
|
)
|
||||||
|
|
||||||
self.headers = {
|
self.headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Authorization": f"Bearer {self.api_key}",
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
|
@ -404,6 +398,14 @@ class CreatomateService:
|
||||||
|
|
||||||
return copy.deepcopy(data)
|
return copy.deepcopy(data)
|
||||||
|
|
||||||
|
# 하위 호환성을 위한 별칭 (deprecated)
|
||||||
|
async def get_one_template_data_async(self, template_id: str) -> dict:
|
||||||
|
"""특정 템플릿 ID로 템플릿 정보를 조회합니다.
|
||||||
|
|
||||||
|
Deprecated: get_one_template_data()를 사용하세요.
|
||||||
|
"""
|
||||||
|
return await self.get_one_template_data(template_id)
|
||||||
|
|
||||||
def parse_template_component_name(self, template_source: list) -> dict:
|
def parse_template_component_name(self, template_source: list) -> dict:
|
||||||
"""템플릿 정보를 파싱하여 리소스 이름을 추출합니다."""
|
"""템플릿 정보를 파싱하여 리소스 이름을 추출합니다."""
|
||||||
|
|
||||||
|
|
@ -430,113 +432,52 @@ class CreatomateService:
|
||||||
result.update(result_element_dict)
|
result.update(result_element_dict)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def parse_template_name_tag(resource_name : str) -> list:
|
|
||||||
tag_list = []
|
|
||||||
tag_list = resource_name.split("_")
|
|
||||||
|
|
||||||
return tag_list
|
|
||||||
|
|
||||||
|
|
||||||
def counting_component(
|
|
||||||
self,
|
|
||||||
template : dict,
|
|
||||||
target_template_type : str
|
|
||||||
) -> list:
|
|
||||||
source_elements = template["source"]["elements"]
|
|
||||||
template_component_data = self.parse_template_component_name(source_elements)
|
|
||||||
count = 0
|
|
||||||
|
|
||||||
for _, (_, template_type) in enumerate(template_component_data.items()):
|
async def template_connect_resource_blackbox(
|
||||||
if template_type == target_template_type:
|
|
||||||
count += 1
|
|
||||||
return count
|
|
||||||
|
|
||||||
def template_matching_taged_image(
|
|
||||||
self,
|
self,
|
||||||
template : dict,
|
template_id: str,
|
||||||
taged_image_list : list, # [{"image_name" : str , "image_tag" : dict}]
|
image_url_list: list[str],
|
||||||
|
lyric: str,
|
||||||
music_url: str,
|
music_url: str,
|
||||||
address : str,
|
address: str = None
|
||||||
duplicate : bool = False
|
) -> dict:
|
||||||
) -> list:
|
"""템플릿 정보와 이미지/가사/음악 리소스를 매핑합니다.
|
||||||
source_elements = template["source"]["elements"]
|
|
||||||
template_component_data = self.parse_template_component_name(source_elements)
|
|
||||||
|
|
||||||
taged_image_list = [img for img in taged_image_list if img.get("image_tag") is not None]
|
Note:
|
||||||
|
- 이미지는 순차적으로 집어넣기
|
||||||
|
- 가사는 개행마다 한 텍스트 삽입
|
||||||
|
- Template에 audio-music 항목이 있어야 함
|
||||||
|
"""
|
||||||
|
template_data = await self.get_one_template_data(template_id)
|
||||||
|
template_component_data = self.parse_template_component_name(
|
||||||
|
template_data["source"]["elements"]
|
||||||
|
)
|
||||||
|
|
||||||
|
lyric = lyric.replace("\r", "")
|
||||||
|
lyric_splited = lyric.split("\n")
|
||||||
modifications = {}
|
modifications = {}
|
||||||
|
|
||||||
for slot_idx, (template_component_name, template_type) in enumerate(template_component_data.items()):
|
for idx, (template_component_name, template_type) in enumerate(
|
||||||
|
template_component_data.items()
|
||||||
|
):
|
||||||
match template_type:
|
match template_type:
|
||||||
case "image":
|
case "image":
|
||||||
image_score_list = self.calculate_image_slot_score_multi(taged_image_list, template_component_name)
|
modifications[template_component_name] = image_url_list[
|
||||||
maximum_idx = image_score_list.index(max(image_score_list))
|
idx % len(image_url_list)
|
||||||
if duplicate:
|
]
|
||||||
selected = taged_image_list[maximum_idx]
|
|
||||||
else:
|
|
||||||
selected = taged_image_list.pop(maximum_idx)
|
|
||||||
image_name = selected["image_url"]
|
|
||||||
modifications[template_component_name] =image_name
|
|
||||||
pass
|
|
||||||
case "text":
|
case "text":
|
||||||
if "address_input" in template_component_name:
|
if "address_input" in template_component_name:
|
||||||
modifications[template_component_name] = address
|
modifications[template_component_name] = address
|
||||||
|
|
||||||
modifications["audio-music"] = music_url
|
modifications["audio-music"] = music_url
|
||||||
|
|
||||||
return modifications
|
return modifications
|
||||||
|
|
||||||
def calculate_image_slot_score_multi(self, taged_image_list : list[dict], slot_name : str):
|
|
||||||
image_tag_list = [taged_image["image_tag"] for taged_image in taged_image_list]
|
|
||||||
slot_tag_dict = self.parse_slot_name_to_tag(slot_name)
|
|
||||||
image_score_list = [0] * len(image_tag_list)
|
|
||||||
|
|
||||||
for slot_tag_cate, slot_tag_item in slot_tag_dict.items():
|
|
||||||
if slot_tag_cate == "narrative_preference":
|
|
||||||
slot_tag_narrative = slot_tag_item
|
|
||||||
continue
|
|
||||||
|
|
||||||
match slot_tag_cate:
|
|
||||||
case "space_type":
|
|
||||||
weight = 2
|
|
||||||
case "subject" :
|
|
||||||
weight = 2
|
|
||||||
case "camera":
|
|
||||||
weight = 1
|
|
||||||
case "motion_recommended" :
|
|
||||||
weight = 0.5
|
|
||||||
case _:
|
|
||||||
raise
|
|
||||||
|
|
||||||
for idx, image_tag in enumerate(image_tag_list):
|
|
||||||
if slot_tag_item.value in image_tag[slot_tag_cate]: #collect!
|
|
||||||
image_score_list[idx] += weight
|
|
||||||
|
|
||||||
for idx, image_tag in enumerate(image_tag_list):
|
|
||||||
image_narrative_score = image_tag["narrative_preference"][slot_tag_narrative]
|
|
||||||
image_score_list[idx] = image_score_list[idx] * image_narrative_score
|
|
||||||
|
|
||||||
return image_score_list
|
|
||||||
|
|
||||||
def parse_slot_name_to_tag(self, slot_name : str) -> dict[str, StrEnum]:
|
|
||||||
tag_list = slot_name.split("-")
|
|
||||||
space_type = SpaceType(tag_list[0])
|
|
||||||
subject = Subject(tag_list[1])
|
|
||||||
camera = Camera(tag_list[2])
|
|
||||||
motion = MotionRecommended(tag_list[3])
|
|
||||||
narrative = NarrativePhase(tag_list[4])
|
|
||||||
tag_dict = {
|
|
||||||
"space_type" : space_type,
|
|
||||||
"subject" : subject,
|
|
||||||
"camera" : camera,
|
|
||||||
"motion_recommended" : motion,
|
|
||||||
"narrative_preference" : narrative,
|
|
||||||
}
|
|
||||||
return tag_dict
|
|
||||||
|
|
||||||
def elements_connect_resource_blackbox(
|
def elements_connect_resource_blackbox(
|
||||||
self,
|
self,
|
||||||
elements: list,
|
elements: list,
|
||||||
image_url_list: list[str],
|
image_url_list: list[str],
|
||||||
|
lyric: str,
|
||||||
music_url: str,
|
music_url: str,
|
||||||
address: str = None
|
address: str = None
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
|
@ -732,6 +673,14 @@ class CreatomateService:
|
||||||
original_response={"last_error": str(last_error)},
|
original_response={"last_error": str(last_error)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 하위 호환성을 위한 별칭 (deprecated)
|
||||||
|
async def make_creatomate_custom_call_async(self, source: dict) -> dict:
|
||||||
|
"""템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다.
|
||||||
|
|
||||||
|
Deprecated: make_creatomate_custom_call()을 사용하세요.
|
||||||
|
"""
|
||||||
|
return await self.make_creatomate_custom_call(source)
|
||||||
|
|
||||||
async def get_render_status(self, render_id: str) -> dict:
|
async def get_render_status(self, render_id: str) -> dict:
|
||||||
"""렌더링 작업의 상태를 조회합니다.
|
"""렌더링 작업의 상태를 조회합니다.
|
||||||
|
|
||||||
|
|
@ -755,60 +704,47 @@ class CreatomateService:
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
# 하위 호환성을 위한 별칭 (deprecated)
|
||||||
|
async def get_render_status_async(self, render_id: str) -> dict:
|
||||||
|
"""렌더링 작업의 상태를 조회합니다.
|
||||||
|
|
||||||
|
Deprecated: get_render_status()를 사용하세요.
|
||||||
|
"""
|
||||||
|
return await self.get_render_status(render_id)
|
||||||
|
|
||||||
def calc_scene_duration(self, template: dict) -> float:
|
def calc_scene_duration(self, template: dict) -> float:
|
||||||
"""템플릿의 전체 장면 duration을 계산합니다."""
|
"""템플릿의 전체 장면 duration을 계산합니다."""
|
||||||
total_template_duration = 0.0
|
total_template_duration = 0.0
|
||||||
track_maximum_duration = {
|
|
||||||
SCENE_TRACK : 0,
|
|
||||||
SUBTITLE_TRACK : 0,
|
|
||||||
KEYWORD_TRACK : 0
|
|
||||||
}
|
|
||||||
for elem in template["source"]["elements"]:
|
for elem in template["source"]["elements"]:
|
||||||
try:
|
try:
|
||||||
if elem["track"] not in track_maximum_duration:
|
if elem["type"] == "audio":
|
||||||
continue
|
continue
|
||||||
if "time" not in elem or elem["time"] == 0: # elem is auto / 만약 마지막 elem이 auto인데 그 앞에 time이 있는 elem 일 시 버그 발생 확률 있음
|
total_template_duration += elem["duration"]
|
||||||
track_maximum_duration[elem["track"]] += elem["duration"]
|
if "animations" not in elem:
|
||||||
|
continue
|
||||||
if "animations" not in elem:
|
for animation in elem["animations"]:
|
||||||
continue
|
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
|
||||||
for animation in elem["animations"]:
|
if animation["transition"]:
|
||||||
if "reversed" in animation:
|
total_template_duration -= animation["duration"]
|
||||||
continue
|
|
||||||
assert animation.get("time",0) == 0 # 0이 아닌 경우 확인 필요
|
|
||||||
if "transition" in animation and animation["transition"]:
|
|
||||||
track_maximum_duration[elem["track"]] -= animation["duration"]
|
|
||||||
else:
|
|
||||||
track_maximum_duration[elem["track"]] = max(track_maximum_duration[elem["track"]], elem["time"] + elem["duration"])
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(traceback.format_exc())
|
|
||||||
logger.error(f"[calc_scene_duration] Error processing element: {elem}, {e}")
|
logger.error(f"[calc_scene_duration] Error processing element: {elem}, {e}")
|
||||||
|
|
||||||
total_template_duration = max(track_maximum_duration.values())
|
|
||||||
|
|
||||||
return total_template_duration
|
return total_template_duration
|
||||||
|
|
||||||
def extend_template_duration(self, template: dict, target_duration: float) -> dict:
|
def extend_template_duration(self, template: dict, target_duration: float) -> dict:
|
||||||
"""템플릿의 duration을 target_duration으로 확장합니다."""
|
"""템플릿의 duration을 target_duration으로 확장합니다."""
|
||||||
# template["duration"] = target_duration + 0.5 # 늘린것보단 짧게
|
template["duration"] = target_duration + 0.5 # 늘린것보단 짧게
|
||||||
# target_duration += 1 # 수동으로 직접 변경 및 테스트 필요 : 파란박스 생기는것
|
target_duration += 1 # 수동으로 직접 변경 및 테스트 필요 : 파란박스 생기는것
|
||||||
total_template_duration = self.calc_scene_duration(template)
|
total_template_duration = self.calc_scene_duration(template)
|
||||||
extend_rate = target_duration / total_template_duration
|
extend_rate = target_duration / total_template_duration
|
||||||
new_template = copy.deepcopy(template)
|
new_template = copy.deepcopy(template)
|
||||||
|
|
||||||
for elem in new_template["source"]["elements"]:
|
for elem in new_template["source"]["elements"]:
|
||||||
try:
|
try:
|
||||||
# if elem["type"] == "audio":
|
if elem["type"] == "audio":
|
||||||
# continue
|
|
||||||
if elem["track"] == AUDIO_TRACK : # audio track은 패스
|
|
||||||
continue
|
continue
|
||||||
|
elem["duration"] = elem["duration"] * extend_rate
|
||||||
if "time" in elem:
|
|
||||||
elem["time"] = elem["time"] * extend_rate
|
|
||||||
if "duration" in elem:
|
|
||||||
elem["duration"] = elem["duration"] * extend_rate
|
|
||||||
|
|
||||||
if "animations" not in elem:
|
if "animations" not in elem:
|
||||||
continue
|
continue
|
||||||
for animation in elem["animations"]:
|
for animation in elem["animations"]:
|
||||||
|
|
@ -849,41 +785,4 @@ class CreatomateService:
|
||||||
return autotext_template_v_1
|
return autotext_template_v_1
|
||||||
case "horizontal":
|
case "horizontal":
|
||||||
return autotext_template_h_1
|
return autotext_template_h_1
|
||||||
|
|
||||||
def extract_text_format_from_template(self, template:dict):
|
|
||||||
keyword_list = []
|
|
||||||
subtitle_list = []
|
|
||||||
for elem in template["source"]["elements"]:
|
|
||||||
try: #최상위 내 텍스트만 검사
|
|
||||||
if elem["type"] == "text":
|
|
||||||
if elem["track"] == SUBTITLE_TRACK:
|
|
||||||
subtitle_list.append(elem["name"])
|
|
||||||
elif elem["track"] == KEYWORD_TRACK:
|
|
||||||
keyword_list.append(elem["name"])
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"[extend_template_duration] Error processing element: {elem}, {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
assert(len(keyword_list)==len(subtitle_list))
|
|
||||||
except Exception as E:
|
|
||||||
logger.error("this template does not have same amount of keyword and subtitle.")
|
|
||||||
pitching_list = keyword_list + subtitle_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
|
|
||||||
def _clean_title(text: str) -> str:
|
|
||||||
text = unescape(text) # HTML 엔티티 디코딩 (& → &)
|
|
||||||
text = re.sub(r"<.*?>", "", text) # HTML 태그 제거
|
|
||||||
return text.strip()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
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}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
await self.goto_url(url, wait_until="networkidle", timeout=self._timeout * 1000)
|
|
||||||
except:
|
|
||||||
if "/place/" in self.page.url:
|
|
||||||
return self.page.url
|
|
||||||
logger.error("[ERROR] Can't Finish networkidle")
|
|
||||||
|
|
||||||
if "/place/" in 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 로 강제 단일결과 재시도 (원본 로직 유지)
|
|
||||||
correct_url = self.page.url.replace("?", "?isCorrectAnswer=true&")
|
|
||||||
try:
|
|
||||||
await self.goto_url(correct_url, wait_until="networkidle", timeout=self._timeout * 1000)
|
|
||||||
except:
|
|
||||||
if "/place/" in self.page.url:
|
|
||||||
return self.page.url
|
|
||||||
logger.error("[ERROR] Can't Finish networkidle (isCorrectAnswer)")
|
|
||||||
|
|
||||||
if "/place/" in self.page.url:
|
|
||||||
return self.page.url
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def get_place_id_url(self, selected):
|
async def get_place_id_url(self, selected):
|
||||||
title = self._clean_title(selected['title'])
|
count = 0
|
||||||
address = self._clean_title(selected.get('roadAddress', selected['address']))
|
get_place_id_url_start = time.perf_counter()
|
||||||
|
while (count <= self._max_retry):
|
||||||
|
title = selected['title'].replace("<b>", "").replace("</b>", "")
|
||||||
|
address = selected.get('roadAddress', selected['address']).replace("<b>", "").replace("</b>", "")
|
||||||
|
encoded_query = parse.quote(f"{address} {title}")
|
||||||
|
url = f"https://map.naver.com/p/search/{encoded_query}"
|
||||||
|
|
||||||
|
wait_first_start = time.perf_counter()
|
||||||
|
|
||||||
# 1차 시도: 원본 주소 + 업체명
|
try:
|
||||||
logger.debug(f"[DEBUG] 1차 시도 - address: {address}")
|
await self.goto_url(url, wait_until="networkidle",timeout = self._timeout*1000)
|
||||||
result = await self._try_search(address, title)
|
except:
|
||||||
if result:
|
if "/place/" in self.page.url:
|
||||||
return result
|
return self.page.url
|
||||||
|
logger.error(f"[ERROR] Can't Finish networkidle")
|
||||||
|
|
||||||
# 2차 시도: 정제 주소 + 업체명
|
|
||||||
refined = self._refine_address(address)
|
|
||||||
if refined != address:
|
|
||||||
logger.info(f"[REFINE] 주소 정제: '{address}' → '{refined}'")
|
|
||||||
result = await self._try_search(refined, title)
|
|
||||||
if result:
|
|
||||||
return result
|
|
||||||
|
|
||||||
# 3차 시도: 업체명만으로 검색
|
wait_first_time = (time.perf_counter() - wait_first_start) * 1000
|
||||||
logger.info(f"[RETRY] 업체명만으로 재시도: '{title}'")
|
|
||||||
result = await self._try_search("", title)
|
|
||||||
if result:
|
|
||||||
return result
|
|
||||||
|
|
||||||
logger.error(f"[ERROR] Not found url for {selected}")
|
logger.debug(f"[DEBUG] Try {count+1} : Wait for perfect matching : {wait_first_time}ms")
|
||||||
return None
|
|
||||||
|
if "/place/" in self.page.url:
|
||||||
|
return self.page.url
|
||||||
|
|
||||||
|
|
||||||
|
logger.debug(f"[DEBUG] Try {count+1} : url place id not found, retry for forced collect answer")
|
||||||
|
wait_forced_correct_start = time.perf_counter()
|
||||||
|
|
||||||
|
url = self.page.url.replace("?","?isCorrectAnswer=true&")
|
||||||
|
try:
|
||||||
|
await self.goto_url(url, wait_until="networkidle",timeout = self._timeout*1000)
|
||||||
|
except:
|
||||||
|
if "/place/" in self.page.url:
|
||||||
|
return self.page.url
|
||||||
|
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:
|
||||||
|
return self.page.url
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
logger.error("[ERROR] Not found url for {selected}")
|
||||||
|
|
||||||
|
return None # 404
|
||||||
|
|
||||||
|
|
||||||
|
# if (count == self._max_retry / 2):
|
||||||
|
# raise Exception("Failed to identify place id. loading timeout")
|
||||||
|
# else:
|
||||||
|
# raise Exception("Failed to identify place id. item is ambiguous")
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,6 @@ class GraphQLException(Exception):
|
||||||
"""GraphQL 요청 실패 시 발생하는 예외"""
|
"""GraphQL 요청 실패 시 발생하는 예외"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
class URLNotFoundException(Exception):
|
|
||||||
"""Place ID 발견 불가능 시 발생하는 예외"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class CrawlingTimeoutException(Exception):
|
class CrawlingTimeoutException(Exception):
|
||||||
"""크롤링 타임아웃 시 발생하는 예외"""
|
"""크롤링 타임아웃 시 발생하는 예외"""
|
||||||
|
|
@ -90,28 +86,34 @@ query getAccommodation($id: String!, $deviceType: String) {
|
||||||
async with session.get(self.url) as response:
|
async with session.get(self.url) as response:
|
||||||
self.url = str(response.url)
|
self.url = str(response.url)
|
||||||
else:
|
else:
|
||||||
raise URLNotFoundException("This URL does not contain a place ID")
|
raise GraphQLException("This URL does not contain a place ID")
|
||||||
|
|
||||||
match = re.search(place_pattern, self.url)
|
match = re.search(place_pattern, self.url)
|
||||||
if not match:
|
if not match:
|
||||||
raise URLNotFoundException("Failed to parse place ID from URL")
|
raise GraphQLException("Failed to parse place ID from URL")
|
||||||
return match[1]
|
return match[1]
|
||||||
|
|
||||||
async def scrap(self):
|
async def scrap(self):
|
||||||
place_id = await self.parse_url()
|
try:
|
||||||
data = await self._call_get_accommodation(place_id)
|
place_id = await self.parse_url()
|
||||||
self.rawdata = data
|
data = await self._call_get_accommodation(place_id)
|
||||||
fac_data = await self._get_facility_string(place_id)
|
self.rawdata = data
|
||||||
# Naver 기준임, 구글 등 다른 데이터 소스의 경우 고유 Identifier 사용할 것.
|
fac_data = await self._get_facility_string(place_id)
|
||||||
self.place_id = self.data_source_identifier + place_id
|
# Naver 기준임, 구글 등 다른 데이터 소스의 경우 고유 Identifier 사용할 것.
|
||||||
self.rawdata["facilities"] = fac_data
|
self.place_id = self.data_source_identifier + place_id
|
||||||
self.image_link_list = [
|
self.rawdata["facilities"] = fac_data
|
||||||
nv_image["origin"]
|
self.image_link_list = [
|
||||||
for nv_image in data["data"]["business"]["images"]["images"]
|
nv_image["origin"]
|
||||||
]
|
for nv_image in data["data"]["business"]["images"]["images"]
|
||||||
self.base_info = data["data"]["business"]["base"]
|
]
|
||||||
self.facility_info = fac_data
|
self.base_info = data["data"]["business"]["base"]
|
||||||
self.scrap_type = "GraphQL"
|
self.facility_info = fac_data
|
||||||
|
self.scrap_type = "GraphQL"
|
||||||
|
|
||||||
|
except GraphQLException:
|
||||||
|
logger.debug("GraphQL failed, fallback to Playwright")
|
||||||
|
self.scrap_type = "Playwright"
|
||||||
|
pass # 나중에 pw 이용한 crawling으로 fallback 추가
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,191 +0,0 @@
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from typing import List, Optional
|
|
||||||
from openai import AsyncOpenAI
|
|
||||||
|
|
||||||
from app.utils.logger import get_logger
|
|
||||||
from config import apikey_settings, recovery_settings
|
|
||||||
from app.utils.prompts.prompts import Prompt
|
|
||||||
|
|
||||||
|
|
||||||
# 로거 설정
|
|
||||||
logger = get_logger("chatgpt")
|
|
||||||
|
|
||||||
|
|
||||||
class ChatGPTResponseError(Exception):
|
|
||||||
"""ChatGPT API 응답 에러"""
|
|
||||||
def __init__(self, status: str, error_code: str = None, error_message: str = None):
|
|
||||||
self.status = status
|
|
||||||
self.error_code = error_code
|
|
||||||
self.error_message = error_message
|
|
||||||
super().__init__(f"ChatGPT response failed: status={status}, code={error_code}, message={error_message}")
|
|
||||||
|
|
||||||
|
|
||||||
class ChatgptService:
|
|
||||||
"""ChatGPT API 서비스 클래스
|
|
||||||
"""
|
|
||||||
|
|
||||||
model_type : str
|
|
||||||
|
|
||||||
def __init__(self, model_type:str = "gpt", timeout: float = None):
|
|
||||||
self.timeout = timeout or recovery_settings.CHATGPT_TIMEOUT
|
|
||||||
self.max_retries = recovery_settings.CHATGPT_MAX_RETRIES
|
|
||||||
self.model_type = model_type
|
|
||||||
match model_type:
|
|
||||||
case "gpt":
|
|
||||||
self.client = AsyncOpenAI(
|
|
||||||
api_key=apikey_settings.CHATGPT_API_KEY,
|
|
||||||
timeout=self.timeout
|
|
||||||
)
|
|
||||||
case "gemini":
|
|
||||||
self.client = AsyncOpenAI(
|
|
||||||
api_key=apikey_settings.GEMINI_API_KEY,
|
|
||||||
base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
|
|
||||||
timeout=self.timeout
|
|
||||||
)
|
|
||||||
case _:
|
|
||||||
raise NotImplementedError(f"Unknown Provider : {model_type}")
|
|
||||||
|
|
||||||
async def _call_pydantic_output(
|
|
||||||
self,
|
|
||||||
prompt : str,
|
|
||||||
output_format : BaseModel, #입력 output_format의 경우 Pydantic BaseModel Class를 상속한 Class 자체임에 유의할 것
|
|
||||||
model : str,
|
|
||||||
img_url : str,
|
|
||||||
image_detail_high : bool) -> BaseModel:
|
|
||||||
content = []
|
|
||||||
if img_url:
|
|
||||||
content.append({
|
|
||||||
"type" : "input_image",
|
|
||||||
"image_url" : img_url,
|
|
||||||
"detail": "high" if image_detail_high else "low"
|
|
||||||
})
|
|
||||||
content.append({
|
|
||||||
"type": "input_text",
|
|
||||||
"text": prompt}
|
|
||||||
)
|
|
||||||
last_error = None
|
|
||||||
for attempt in range(self.max_retries + 1):
|
|
||||||
response = await self.client.responses.parse(
|
|
||||||
model=model,
|
|
||||||
input=[{"role": "user", "content": content}],
|
|
||||||
text_format=output_format
|
|
||||||
)
|
|
||||||
# Response 디버그 로깅
|
|
||||||
logger.debug(f"[ChatgptService({self.model_type})] attempt: {attempt}")
|
|
||||||
logger.debug(f"[ChatgptService({self.model_type})] Response ID: {response.id}")
|
|
||||||
logger.debug(f"[ChatgptService({self.model_type})] Response status: {response.status}")
|
|
||||||
logger.debug(f"[ChatgptService({self.model_type})] Response model: {response.model}")
|
|
||||||
|
|
||||||
# status 확인: completed, failed, incomplete, cancelled, queued, in_progress
|
|
||||||
if response.status == "completed":
|
|
||||||
logger.debug(f"[ChatgptService({self.model_type})] Response output_text: {response.output_text[:200]}..." if len(response.output_text) > 200 else f"[ChatgptService] Response output_text: {response.output_text}")
|
|
||||||
structured_output = response.output_parsed
|
|
||||||
return structured_output #.model_dump() or {}
|
|
||||||
|
|
||||||
# 에러 상태 처리
|
|
||||||
if response.status == "failed":
|
|
||||||
error_code = getattr(response.error, 'code', None) if response.error else None
|
|
||||||
error_message = getattr(response.error, 'message', None) if response.error else None
|
|
||||||
logger.warning(f"[ChatgptService({self.model_type})] Response failed (attempt {attempt + 1}/{self.max_retries + 1}): code={error_code}, message={error_message}")
|
|
||||||
last_error = ChatGPTResponseError(response.status, error_code, error_message)
|
|
||||||
|
|
||||||
elif response.status == "incomplete":
|
|
||||||
reason = getattr(response.incomplete_details, 'reason', None) if response.incomplete_details else None
|
|
||||||
logger.warning(f"[ChatgptService({self.model_type})] Response incomplete (attempt {attempt + 1}/{self.max_retries + 1}): reason={reason}")
|
|
||||||
last_error = ChatGPTResponseError(response.status, reason, f"Response incomplete: {reason}")
|
|
||||||
|
|
||||||
else:
|
|
||||||
# cancelled, queued, in_progress 등 예상치 못한 상태
|
|
||||||
logger.warning(f"[ChatgptService({self.model_type})] Unexpected response status (attempt {attempt + 1}/{self.max_retries + 1}): {response.status}")
|
|
||||||
last_error = ChatGPTResponseError(response.status, None, f"Unexpected status: {response.status}")
|
|
||||||
|
|
||||||
# 마지막 시도가 아니면 재시도
|
|
||||||
if attempt < self.max_retries:
|
|
||||||
logger.info(f"[ChatgptService({self.model_type})] Retrying request...")
|
|
||||||
|
|
||||||
# 모든 재시도 실패
|
|
||||||
logger.error(f"[ChatgptService({self.model_type})] All retries exhausted. Last error: {last_error}")
|
|
||||||
raise last_error
|
|
||||||
|
|
||||||
async def _call_pydantic_output_chat_completion( # alter version
|
|
||||||
self,
|
|
||||||
prompt : str,
|
|
||||||
output_format : BaseModel, #입력 output_format의 경우 Pydantic BaseModel Class를 상속한 Class 자체임에 유의할 것
|
|
||||||
model : str,
|
|
||||||
img_url : str,
|
|
||||||
image_detail_high : bool) -> BaseModel:
|
|
||||||
content = []
|
|
||||||
if img_url:
|
|
||||||
content.append({
|
|
||||||
"type": "image_url",
|
|
||||||
"image_url": {
|
|
||||||
"url": img_url,
|
|
||||||
"detail": "high" if image_detail_high else "low"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
content.append({
|
|
||||||
"type": "text",
|
|
||||||
"text": prompt
|
|
||||||
})
|
|
||||||
last_error = None
|
|
||||||
for attempt in range(self.max_retries + 1):
|
|
||||||
response = await self.client.beta.chat.completions.parse(
|
|
||||||
model=model,
|
|
||||||
messages=[{"role": "user", "content": content}],
|
|
||||||
response_format=output_format
|
|
||||||
)
|
|
||||||
# Response 디버그 로깅
|
|
||||||
logger.debug(f"[ChatgptService({self.model_type})] attempt: {attempt}")
|
|
||||||
logger.debug(f"[ChatgptService({self.model_type})] Response ID: {response.id}")
|
|
||||||
logger.debug(f"[ChatgptService({self.model_type})] Response finish_reason: {response.id}")
|
|
||||||
logger.debug(f"[ChatgptService({self.model_type})] Response model: {response.model}")
|
|
||||||
|
|
||||||
choice = response.choices[0]
|
|
||||||
finish_reason = choice.finish_reason
|
|
||||||
|
|
||||||
if finish_reason == "stop":
|
|
||||||
output_text = choice.message.content or ""
|
|
||||||
logger.debug(f"[ChatgptService({self.model_type})] Response output_text: {output_text[:200]}..." if len(output_text) > 200 else f"[ChatgptService] Response output_text: {output_text}")
|
|
||||||
return choice.message.parsed
|
|
||||||
|
|
||||||
elif finish_reason == "length":
|
|
||||||
logger.warning(f"[ChatgptService({self.model_type})] Response incomplete - token limit reached (attempt {attempt + 1}/{self.max_retries + 1})")
|
|
||||||
last_error = ChatGPTResponseError("incomplete", finish_reason, "Response incomplete: max tokens reached")
|
|
||||||
|
|
||||||
elif finish_reason == "content_filter":
|
|
||||||
logger.warning(f"[ChatgptService({self.model_type})] Response blocked by content filter (attempt {attempt + 1}/{self.max_retries + 1})")
|
|
||||||
last_error = ChatGPTResponseError("failed", finish_reason, "Response blocked by content filter")
|
|
||||||
|
|
||||||
else:
|
|
||||||
logger.warning(f"[ChatgptService({self.model_type})] Unexpected finish_reason (attempt {attempt + 1}/{self.max_retries + 1}): {finish_reason}")
|
|
||||||
last_error = ChatGPTResponseError("failed", finish_reason, f"Unexpected finish_reason: {finish_reason}")
|
|
||||||
|
|
||||||
# 마지막 시도가 아니면 재시도
|
|
||||||
if attempt < self.max_retries:
|
|
||||||
logger.info(f"[ChatgptService({self.model_type})] Retrying request...")
|
|
||||||
|
|
||||||
# 모든 재시도 실패
|
|
||||||
logger.error(f"[ChatgptService({self.model_type})] All retries exhausted. Last error: {last_error}")
|
|
||||||
raise last_error
|
|
||||||
|
|
||||||
async def generate_structured_output(
|
|
||||||
self,
|
|
||||||
prompt : Prompt,
|
|
||||||
input_data : dict,
|
|
||||||
img_url : Optional[str] = None,
|
|
||||||
img_detail_high : bool = False,
|
|
||||||
silent : bool = False
|
|
||||||
) -> BaseModel:
|
|
||||||
prompt_text = prompt.build_prompt(input_data, silent)
|
|
||||||
|
|
||||||
logger.debug(f"[ChatgptService({self.model_type})] Generated Prompt (length: {len(prompt_text)})")
|
|
||||||
if not silent:
|
|
||||||
logger.info(f"[ChatgptService({self.model_type})] Starting GPT request with structured output with model: {prompt.prompt_model}")
|
|
||||||
|
|
||||||
# GPT API 호출
|
|
||||||
#parsed = await self._call_structured_output_with_response_gpt_api(prompt_text, prompt.prompt_output, prompt.prompt_model)
|
|
||||||
# parsed = await self._call_pydantic_output(prompt_text, prompt.prompt_output_class, prompt.prompt_model, img_url, img_detail_high)
|
|
||||||
parsed = await self._call_pydantic_output_chat_completion(prompt_text, prompt.prompt_output_class, prompt.prompt_model, img_url, img_detail_high)
|
|
||||||
return parsed
|
|
||||||
|
|
@ -1,88 +1,65 @@
|
||||||
import gspread
|
import os, json
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from google.oauth2.service_account import Credentials
|
|
||||||
from config import prompt_settings
|
from config import prompt_settings
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from app.utils.prompts.schemas import *
|
from app.utils.prompts.schemas import *
|
||||||
from functools import lru_cache
|
|
||||||
|
|
||||||
logger = get_logger("prompt")
|
logger = get_logger("prompt")
|
||||||
|
|
||||||
_SCOPES = [
|
|
||||||
"https://www.googleapis.com/auth/spreadsheets.readonly"
|
|
||||||
]
|
|
||||||
|
|
||||||
class Prompt():
|
class Prompt():
|
||||||
sheet_name: str
|
prompt_template_path : str #프롬프트 경로
|
||||||
prompt_template: str
|
prompt_template : str # fstring 포맷
|
||||||
prompt_model: str
|
prompt_model : str
|
||||||
|
|
||||||
prompt_input_class = BaseModel
|
prompt_input_class = BaseModel # pydantic class 자체를(instance 아님) 변수로 가짐
|
||||||
prompt_output_class = BaseModel
|
prompt_output_class = BaseModel
|
||||||
|
|
||||||
def __init__(self, sheet_name, prompt_input_class, prompt_output_class):
|
def __init__(self, prompt_template_path, prompt_input_class, prompt_output_class, prompt_model):
|
||||||
self.sheet_name = sheet_name
|
self.prompt_template_path = prompt_template_path
|
||||||
self.prompt_input_class = prompt_input_class
|
self.prompt_input_class = prompt_input_class
|
||||||
self.prompt_output_class = prompt_output_class
|
self.prompt_output_class = prompt_output_class
|
||||||
self.prompt_template, self.prompt_model = self._read_from_sheets()
|
self.prompt_template = self.read_prompt()
|
||||||
|
self.prompt_model = prompt_model
|
||||||
def _read_from_sheets(self) -> tuple[str, str]:
|
|
||||||
creds = Credentials.from_service_account_file(
|
|
||||||
prompt_settings.GOOGLE_SERVICE_ACCOUNT_JSON, scopes=_SCOPES
|
|
||||||
)
|
|
||||||
gc = gspread.authorize(creds)
|
|
||||||
ws = gc.open_by_key(prompt_settings.PROMPT_SPREADSHEET).worksheet(self.sheet_name)
|
|
||||||
model = ws.cell(2, 2).value
|
|
||||||
input_text = ws.cell(3, 2).value
|
|
||||||
return input_text, model
|
|
||||||
|
|
||||||
def _reload_prompt(self):
|
def _reload_prompt(self):
|
||||||
self.prompt_template, self.prompt_model = self._read_from_sheets()
|
self.prompt_template = self.read_prompt()
|
||||||
|
|
||||||
def build_prompt(self, input_data:dict, silent:bool = False) -> str:
|
def read_prompt(self) -> tuple[str, dict]:
|
||||||
|
with open(self.prompt_template_path, "r") as fp:
|
||||||
|
prompt_template = fp.read()
|
||||||
|
|
||||||
|
return prompt_template
|
||||||
|
|
||||||
|
def build_prompt(self, input_data:dict) -> str:
|
||||||
verified_input = self.prompt_input_class(**input_data)
|
verified_input = self.prompt_input_class(**input_data)
|
||||||
build_template = self.prompt_template
|
build_template = self.prompt_template
|
||||||
build_template = build_template.format(**verified_input.model_dump())
|
build_template = build_template.format(**verified_input.model_dump())
|
||||||
if not silent:
|
logger.debug(f"build_template: {build_template}")
|
||||||
logger.debug(f"build_template: {build_template}")
|
logger.debug(f"input_data: {input_data}")
|
||||||
logger.debug(f"input_data: {input_data}")
|
|
||||||
return build_template
|
return build_template
|
||||||
|
|
||||||
marketing_prompt = Prompt(
|
marketing_prompt = Prompt(
|
||||||
sheet_name="marketing",
|
prompt_template_path = os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.MARKETING_PROMPT_FILE_NAME),
|
||||||
prompt_input_class=MarketingPromptInput,
|
prompt_input_class = MarketingPromptInput,
|
||||||
prompt_output_class=MarketingPromptOutput,
|
prompt_output_class = MarketingPromptOutput,
|
||||||
|
prompt_model = prompt_settings.MARKETING_PROMPT_MODEL
|
||||||
)
|
)
|
||||||
|
|
||||||
lyric_prompt = Prompt(
|
lyric_prompt = Prompt(
|
||||||
sheet_name="lyric",
|
prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.LYRIC_PROMPT_FILE_NAME),
|
||||||
prompt_input_class=LyricPromptInput,
|
prompt_input_class = LyricPromptInput,
|
||||||
prompt_output_class=LyricPromptOutput,
|
prompt_output_class = LyricPromptOutput,
|
||||||
|
prompt_model = prompt_settings.LYRIC_PROMPT_MODEL
|
||||||
)
|
)
|
||||||
|
|
||||||
yt_upload_prompt = Prompt(
|
yt_upload_prompt = Prompt(
|
||||||
sheet_name="yt_upload",
|
prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.YOUTUBE_PROMPT_FILE_NAME),
|
||||||
prompt_input_class=YTUploadPromptInput,
|
prompt_input_class = YTUploadPromptInput,
|
||||||
prompt_output_class=YTUploadPromptOutput,
|
prompt_output_class = YTUploadPromptOutput,
|
||||||
|
prompt_model = prompt_settings.YOUTUBE_PROMPT_MODEL
|
||||||
)
|
)
|
||||||
|
|
||||||
image_autotag_prompt = Prompt(
|
|
||||||
sheet_name="image_tag",
|
|
||||||
prompt_input_class=ImageTagPromptInput,
|
|
||||||
prompt_output_class=ImageTagPromptOutput,
|
|
||||||
)
|
|
||||||
|
|
||||||
@lru_cache()
|
|
||||||
def create_dynamic_subtitle_prompt(length: int) -> Prompt:
|
|
||||||
return Prompt(
|
|
||||||
sheet_name="subtitle",
|
|
||||||
prompt_input_class=SubtitlePromptInput,
|
|
||||||
prompt_output_class=SubtitlePromptOutput[length],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def reload_all_prompt():
|
def reload_all_prompt():
|
||||||
marketing_prompt._reload_prompt()
|
marketing_prompt._reload_prompt()
|
||||||
lyric_prompt._reload_prompt()
|
lyric_prompt._reload_prompt()
|
||||||
yt_upload_prompt._reload_prompt()
|
yt_upload_prompt._reload_prompt()
|
||||||
image_autotag_prompt._reload_prompt()
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue