Compare commits

..

No commits in common. "main" and "get_video" have entirely different histories.

152 changed files with 3852 additions and 15284 deletions

6
.gitignore vendored
View File

@ -49,8 +49,4 @@ logs/
*.yml *.yml
Dockerfile Dockerfile
.dockerignore .dockerignore
zzz/
credentials/service_account.json
o2o-castad-scheduler/

View File

@ -276,5 +276,3 @@ fastapi run main.py
│◀───────────────│ │ │ │◀───────────────│ │ │
│ │ │ │ │ │ │ │
``` ```
testAc

View File

@ -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

View File

@ -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(

View File

@ -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

View File

@ -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

View File

@ -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})>"

View File

@ -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}")

View File

@ -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))
)

View File

@ -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,
}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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")

View File

@ -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)

View File

@ -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="댓글이 삭제되었습니다.",
)

View File

@ -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",
)

View File

@ -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="결과 메시지")

View File

@ -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()

View File

@ -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

View File

@ -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):
# 에러 메시지 로깅 (한글 포함 가능) # 에러 메시지 로깅 (한글 포함 가능)

View File

View File

@ -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,
)

View File

@ -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

View File

@ -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")>"
)

View File

@ -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

View File

@ -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

View File

@ -1,5 +0,0 @@
"""
Dashboard Module
YouTube Analytics API를 활용한 대시보드 기능을 제공합니다.
"""

View File

@ -1,3 +0,0 @@
"""
Dashboard API Module
"""

View File

@ -1,3 +0,0 @@
"""
Dashboard Routers
"""

View File

@ -1,7 +0,0 @@
"""
Dashboard V1 Routers
"""
from app.dashboard.api.routers.v1.dashboard import router
__all__ = ["router"]

View File

@ -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)

View File

@ -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",
)

View File

@ -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 테이블 이미 존재 - 스킵")

View File

@ -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")>"
)

View File

@ -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",
]

View File

@ -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

View File

@ -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",
]

View File

@ -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

View File

@ -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, "기타")

View File

@ -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}")

View File

@ -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}"
)

View File

@ -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}"
)

View File

@ -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)

View File

@ -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 # 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__,
@ -98,12 +93,6 @@ async def create_db_tables():
Video.__table__, Video.__table__,
SNSUploadTask.__table__, SNSUploadTask.__table__,
SocialUpload.__table__, SocialUpload.__table__,
MarketingIntel.__table__,
Dashboard.__table__,
ImageTag.__table__,
Admin.__table__,
CreditChargeRequest.__table__,
CreditTransaction.__table__,
] ]
logger.info("Creating database tables...") logger.info("Creating database tables...")
@ -135,23 +124,13 @@ async def get_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()
# status_code < 500인 도메인 예외(계정 미연동 등)는 정상적인 비즈니스 흐름이므로 ERROR로 남기지 않음 logger.error(
if getattr(e, "status_code", 500) < 500: f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, "
logger.warning( f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
f"[get_session] ROLLBACK - client error: {type(e).__name__}: {e}, " )
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms" raise e
)
else:
logger.error(traceback.format_exc())
logger.error(
f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, "
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
)
raise
finally: finally:
total_time = time.perf_counter() - start_time total_time = time.perf_counter() - start_time
# logger.debug( # logger.debug(
@ -179,8 +158,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(
@ -188,8 +165,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(

File diff suppressed because it is too large Load Diff

View File

@ -7,10 +7,9 @@ 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
from sqlalchemy import Boolean, DateTime, ForeignKey, Computed, Index, Integer, String, Text, JSON, func from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, 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
@ -108,19 +107,6 @@ class Project(Base):
comment="상세 지역 정보", comment="상세 지역 정보",
) )
marketing_intelligence: Mapped[Optional[str]] = mapped_column(
Integer,
nullable=True,
comment="마케팅 인텔리전스 결과 정보 저장",
)
industry: Mapped[str] = mapped_column(
String(50),
nullable=False,
default="",
comment="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general)",
)
language: Mapped[str] = mapped_column( language: Mapped[str] = mapped_column(
String(50), String(50),
nullable=False, nullable=False,
@ -263,117 +249,3 @@ class Image(Base):
return ( return (
f"<Image(id={self.id}, task_id='{task_id_str}', img_name='{img_name_str}')>" f"<Image(id={self.id}, task_id='{task_id_str}', img_name='{img_name_str}')>"
) )
class MarketingIntel(Base):
"""
마케팅 인텔리전스 결과물 테이블
마케팅 분석 결과물 저장합니다.
Attributes:
id: 고유 식별자 (자동 증가)
place_id : 데이터 소스별 식별자
intel_result : 마케팅 분석 결과물 json
created_at: 생성 일시 (자동 설정)
"""
__tablename__ = "marketing"
__table_args__ = (
Index("idx_place_id", "place_id"),
{
"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="고유 식별자",
)
place_id: Mapped[Optional[str]] = mapped_column(
String(36),
nullable=True,
comment="매장 소스별 고유 식별자 (네이버 크롤링 시 'nv{id}' 형식; 직접 입력 시 NULL)",
)
intel_result : Mapped[dict[str, Any]] = mapped_column(
JSON,
nullable=False,
comment="마케팅 인텔리전스 결과물",
)
subtitle : Mapped[dict[str, Any]] = mapped_column(
JSON,
nullable=True,
comment="자막 정보 생성 결과물",
)
image_match : Mapped[dict[str, Any]] = mapped_column(
JSON,
nullable=True,
comment="이미지 슬롯 배정 결과물 — {슬롯명: image_url, 'audio-music': music_url, ...}",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
comment="생성 일시",
)
def __repr__(self) -> str:
return (
f"<MarketingIntel(id={self.id}, place_id='{self.place_id}')>"
)
class ImageTag(Base):
"""
이미지 태그 테이블
"""
__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(
# none_as_null=True: ORM에서 None 대입 시 JSON 리터럴 null이 아닌 SQL NULL로 저장.
# 미지정 시 JSON null이 저장되어 `img_tag.is_not(None)` 필터를 통과하는 버그가 있었음.
JSON(none_as_null=True),
nullable=True,
default=None,
comment="태그 JSON",
)

View File

@ -3,6 +3,112 @@ from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from app.utils.prompts.schemas import MarketingPromptOutput from app.utils.prompts.schemas import MarketingPromptOutput
class AttributeInfo(BaseModel):
"""음악 속성 정보"""
genre: str = Field(..., description="음악 장르")
vocal: str = Field(..., description="보컬 스타일")
tempo: str = Field(..., description="템포")
mood: str = Field(..., description="분위기")
class GenerateRequestImg(BaseModel):
"""이미지 URL 스키마"""
url: str = Field(..., description="이미지 URL")
name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)")
class GenerateRequestInfo(BaseModel):
"""생성 요청 정보 스키마 (이미지 제외)"""
customer_name: str = Field(..., description="고객명/가게명")
region: str = Field(..., description="지역명")
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
attribute: AttributeInfo = Field(..., description="음악 속성 정보")
language: str = Field(
default="Korean",
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
)
class GenerateRequest(GenerateRequestInfo):
"""기본 생성 요청 스키마 (이미지 없음, JSON body)
이미지 없이 프로젝트 정보만 전달합니다.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"customer_name": "스테이 머뭄",
"region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을",
"attribute": {
"genre": "K-Pop",
"vocal": "Raspy",
"tempo": "110 BPM",
"mood": "happy",
},
"language": "Korean",
}
}
)
class GenerateUrlsRequest(GenerateRequestInfo):
"""URL 기반 생성 요청 스키마 (JSON body)
GenerateRequestInfo를 상속받아 이미지 목록을 추가합니다.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"customer_name": "스테이 머뭄",
"region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을",
"attribute": {
"genre": "K-Pop",
"vocal": "Raspy",
"tempo": "110 BPM",
"mood": "happy",
},
"language": "Korean",
"images": [
{"url": "https://example.com/images/image_001.jpg"},
{"url": "https://example.com/images/image_002.jpg", "name": "외관"},
],
}
}
)
images: list[GenerateRequestImg] = Field(
..., description="이미지 URL 목록", min_length=1
)
class GenerateUploadResponse(BaseModel):
"""파일 업로드 기반 생성 응답 스키마"""
task_id: str = Field(..., description="작업 고유 식별자 (UUID7)")
status: Literal["processing", "completed", "failed"] = Field(
..., description="작업 상태"
)
message: str = Field(..., description="응답 메시지")
uploaded_count: int = Field(..., description="업로드된 이미지 개수")
class GenerateResponse(BaseModel):
"""생성 응답 스키마"""
task_id: str = Field(..., description="작업 고유 식별자 (UUID7)")
status: Literal["processing", "completed", "failed"] = Field(
..., description="작업 상태"
)
message: str = Field(..., description="응답 메시지")
class CrawlingRequest(BaseModel): class CrawlingRequest(BaseModel):
"""크롤링 요청 스키마""" """크롤링 요청 스키마"""
@ -78,7 +184,6 @@ class ProcessedInfo(BaseModel):
customer_name: str = Field(..., description="고객명/가게명 (base_info.name)") customer_name: str = Field(..., description="고객명/가게명 (base_info.name)")
region: str = Field(..., description="지역명 (roadAddress에서 추출한 시 이름)") region: str = Field(..., description="지역명 (roadAddress에서 추출한 시 이름)")
detail_region_info: str = Field(..., description="상세 지역 정보 (roadAddress)") detail_region_info: str = Field(..., description="상세 지역 정보 (roadAddress)")
industry: str = Field(default="", description="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general)")
# class MarketingAnalysisDetail(BaseModel): # class MarketingAnalysisDetail(BaseModel):
@ -98,12 +203,6 @@ class ProcessedInfo(BaseModel):
# selling_points: list[str] = Field(default_factory=list, description="추천 부대시설 목록") # selling_points: list[str] = Field(default_factory=list, description="추천 부대시설 목록")
class ImageListItem(BaseModel):
"""크롤링 이미지 아이템 (미리보기 URL + 원본 URL)"""
preview: str = Field(..., description="미리보기용 CDN URL")
original: str = Field(..., description="업로드용 원본 URL")
class CrawlingResponse(BaseModel): class CrawlingResponse(BaseModel):
"""크롤링 응답 스키마""" """크롤링 응답 스키마"""
@ -111,7 +210,7 @@ class CrawlingResponse(BaseModel):
json_schema_extra={ json_schema_extra={
"example": { "example": {
"status": "completed", "status": "completed",
"image_list": [{"preview": "https://example.com/image1.jpg", "original": "https://example.com/image1.jpg"}], "image_list": ["https://example.com/image1.jpg", "https://example.com/image2.jpg"],
"image_count": 2, "image_count": 2,
"processed_info": { "processed_info": {
"customer_name": "스테이 머뭄", "customer_name": "스테이 머뭄",
@ -230,8 +329,7 @@ class CrawlingResponse(BaseModel):
"힐링스테이", "힐링스테이",
"스테이머뭄" "스테이머뭄"
] ]
}, }
"m_id" : 1
} }
} }
) )
@ -240,34 +338,14 @@ class CrawlingResponse(BaseModel):
default="completed", default="completed",
description="처리 상태 (completed: 성공, failed: ChatGPT 분석 실패)" description="처리 상태 (completed: 성공, failed: ChatGPT 분석 실패)"
) )
image_list: Optional[list[ImageListItem]] = Field(None, description="이미지 목록 (preview/original URL)") image_list: Optional[list[str]] = Field(None, description="이미지 URL 목록")
image_count: Optional[int] = Field(None, description="이미지 개수") image_count: int = Field(..., description="이미지 개수")
processed_info: Optional[ProcessedInfo] = Field( processed_info: Optional[ProcessedInfo] = Field(
None, description="가공된 장소 정보 (customer_name, region, detail_region_info)" None, description="가공된 장소 정보 (customer_name, region, detail_region_info)"
) )
marketing_analysis: Optional[MarketingPromptOutput] = Field( marketing_analysis: Optional[MarketingPromptOutput] = Field(
None, description="마케팅 분석 결과 . 실패 시 null" None, description="마케팅 분석 결과 . 실패 시 null"
) )
m_id : int = Field(..., description="마케팅 분석 결과 ID")
industry: str = Field(default="", description="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general)")
class ManualMarketingRequest(BaseModel):
"""업체명+주소 직접 입력 마케팅 분석 요청"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"store_name": "스테이 머뭄",
"address": "전북특별자치도 군산시 절골길 18",
"category": "펜션",
}
}
)
store_name: str = Field(..., description="업체명 / 브랜드명")
address: str = Field(..., description="도로명 또는 지번 주소")
category: str = Field(default="", description="업체 업종/카테고리 자유 입력 (예: 펜션, 카페). 크롤링 경로와 동일하게 AI가 8개 industry enum으로 자동 분류하는 데 사용. 비우면 업체명 기반 AI 분류")
class ErrorResponse(BaseModel): class ErrorResponse(BaseModel):
@ -291,6 +369,29 @@ class ImageUrlItem(BaseModel):
name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)") name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)")
class ImageUploadRequest(BaseModel):
"""이미지 업로드 요청 스키마 (JSON body 부분)
URL 이미지 목록을 전달합니다.
바이너리 파일은 multipart/form-data로 별도 전달됩니다.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"images": [
{"url": "https://example.com/images/image_001.jpg"},
{"url": "https://example.com/images/image_002.jpg", "name": "외관"},
]
}
}
)
images: Optional[list[ImageUrlItem]] = Field(
None, description="외부 이미지 URL 목록"
)
class ImageUploadResultItem(BaseModel): class ImageUploadResultItem(BaseModel):
"""업로드된 이미지 결과 아이템""" """업로드된 이미지 결과 아이템"""

View File

@ -32,7 +32,7 @@ class NaverSearchClient:
display: int = 5, display: int = 5,
) -> List[dict]: ) -> List[dict]:
""" """
장소 검색 (숙박, 음식점 ) 숙박/펜션 검색
Args: Args:
query: 검색어 query: 검색어
@ -41,7 +41,8 @@ class NaverSearchClient:
Returns: Returns:
검색 결과 리스트 (address, roadAddress, title) 검색 결과 리스트 (address, roadAddress, title)
""" """
search_query = query # 숙박/펜션 카테고리 검색을 위해 쿼리에 키워드 추가
search_query = f"{query} 숙박"
headers = { headers = {
"X-Naver-Client-Id": self.client_id, "X-Naver-Client-Id": self.client_id,

View File

@ -30,7 +30,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session from app.database.session import get_session
from app.home.models import Project, MarketingIntel from app.home.models import Project
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.lyric.models import Lyric from app.lyric.models import Lyric
@ -40,17 +40,14 @@ from app.lyric.schemas.lyric import (
LyricDetailResponse, LyricDetailResponse,
LyricListItem, LyricListItem,
LyricStatusResponse, LyricStatusResponse,
SubtitleStatusResponse,
) )
from app.lyric.worker.lyric_task import generate_lyric_background from app.lyric.worker.lyric_task import generate_lyric_background
from app.video.worker.creative_assets_task import generate_subtitle_background from app.utils.chatgpt_prompt import ChatgptService
from app.utils.prompts.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
from app.utils.prompts.prompts import lyric_prompt from app.utils.prompts.prompts import lyric_prompt
import traceback as tb import traceback as tb
import json
# 로거 설정 # 로거 설정
logger = get_logger("lyric") logger = get_logger("lyric")
@ -161,7 +158,6 @@ async def get_lyric_by_task_id(
project_id=lyric.project_id, project_id=lyric.project_id,
status=lyric.status, status=lyric.status,
lyric_result=lyric.lyric_result, lyric_result=lyric.lyric_result,
genre=lyric.genre,
created_at=lyric.created_at, created_at=lyric.created_at,
) )
@ -242,22 +238,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(
@ -271,35 +252,42 @@ 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: 서비스 초기화 및 프롬프트 생성...")
marketing_intel_result = await session.execute(select(MarketingIntel).where(MarketingIntel.id == request_body.m_id)) # service = ChatgptService(
marketing_intel = marketing_intel_result.scalar_one_or_none() # customer_name=request_body.customer_name,
# region=request_body.region,
# detail_region_info=request_body.detail_region_info or "",
# language=request_body.language,
# )
# 자동 선택(genre 미지정) 재생성 시, 직전에 사용된 장르를 조회해 이번엔 다른 장르가 나오도록 제외 처리 # prompt = service.build_lyrics_prompt()
exclude_genre = "" # 원래는 실제 사용할 프롬프트가 들어가야 하나, 로직이 변경되어 이 시점에서 이곳에서 프롬프트를 생성할 이유가 없어서 삭제됨.
if not request_body.genre: # 기존 코드와의 호환을 위해 동일한 로직으로 프롬프트 생성
previous_lyric_result = await session.execute(
select(Lyric)
.where(Lyric.task_id == task_id, Lyric.genre.is_not(None))
.order_by(Lyric.created_at.desc())
.limit(1)
)
previous_lyric = previous_lyric_result.scalar_one_or_none()
if previous_lyric:
exclude_genre = previous_lyric.genre
promotional_expressions = {
"Korean" : "인스타 감성, 사진같은 하루, 힐링, 여행, 감성 숙소",
"English" : "Instagram vibes, picture-perfect day, healing, travel, getaway",
"Chinese" : "网红打卡, 治愈系, 旅行, 度假, 拍照圣地",
"Japanese" : "インスタ映え, 写真のような一日, 癒し, 旅行, 絶景",
"Thai" : "ที่พักสวย, ฮีลใจ, เที่ยว, ถ่ายรูป, วิวสวย",
"Vietnamese" : "check-in đẹp, healing, du lịch, nghỉ dưỡng, view đẹp"
}# HARD CODED, 어디에 정리하지? 아직 정리되지 않음
timing_rules = {
"60s" : """
812 lines
Full verse flow, immersive mood
"""
}
lyric_input_data = { lyric_input_data = {
"customer_name" : request_body.customer_name, "customer_name" : request_body.customer_name,
"region" : request_body.region, "region" : request_body.region,
"detail_region_info" : request_body.detail_region_info or "", "detail_region_info" : request_body.detail_region_info or "",
"marketing_intelligence_summary" : json.dumps(marketing_intel.intel_result, ensure_ascii = False), "marketing_intelligence_summary" : None, # task_idx 변경 후 marketing intelligence summary DB에 저장하고 사용할 필요가 있음
"language" : request_body.language, "language" : request_body.language,
"genre": request_body.genre or "", # 비어있으면 GPT가 가사 무드에 맞춰 장르를 직접 추천 "promotional_expression_example" : promotional_expressions[request_body.language],
"exclude_genre": exclude_genre, # 자동 선택 재생성 시 직전 장르 제외 "timing_rules" : timing_rules["60s"], # 아직은 선택지 하나
"industry": request_body.industry, # 크롤 응답에서 받아 전달된 업종 enum
} }
estimated_prompt = lyric_prompt.build_prompt(lyric_input_data)
# 업종 분기는 프롬프트 내부 {industry}로 처리하므로 단일 프롬프트 사용
selected_lyric_prompt = lyric_prompt
step1_elapsed = (time.perf_counter() - step1_start) * 1000 step1_elapsed = (time.perf_counter() - step1_start) * 1000
#logger.debug(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)") #logger.debug(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)")
@ -326,8 +314,6 @@ async def generate_lyric(
detail_region_info=request_body.detail_region_info, detail_region_info=request_body.detail_region_info,
language=request_body.language, language=request_body.language,
user_uuid=current_user.user_uuid, user_uuid=current_user.user_uuid,
marketing_intelligence=request_body.m_id,
industry=request_body.industry,
) )
session.add(project) session.add(project)
await session.commit() await session.commit()
@ -341,7 +327,7 @@ async def generate_lyric(
step3_start = time.perf_counter() step3_start = time.perf_counter()
logger.debug(f"[generate_lyric] Step 3: Lyric 저장 (processing)...") logger.debug(f"[generate_lyric] Step 3: Lyric 저장 (processing)...")
estimated_prompt = selected_lyric_prompt.build_prompt(lyric_input_data) estimated_prompt = lyric_prompt.build_prompt(lyric_input_data)
lyric = Lyric( lyric = Lyric(
project_id=project.id, project_id=project.id,
task_id=task_id, task_id=task_id,
@ -349,7 +335,6 @@ async def generate_lyric(
lyric_prompt=estimated_prompt, lyric_prompt=estimated_prompt,
lyric_result=None, lyric_result=None,
language=request_body.language, language=request_body.language,
genre=request_body.genre or None, # 자동 선택인 경우 GPT 응답 완료 후 채워짐
) )
session.add(lyric) session.add(lyric)
await session.commit() await session.commit()
@ -361,27 +346,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=selected_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
@ -521,96 +492,6 @@ async def list_lyrics(
) )
@router.get(
"/subtitle/status/{task_id}",
summary="자막 생성 상태 조회",
description="""
자막(subtitle) 생성 완료 여부를 조회합니다.
## 인증
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
## 경로 파라미터
- **task_id**: 프로젝트 task_id (필수)
## 상태 값
- **pending**: 자막 생성 진행 잠시 재요청
- **completed**: 자막 생성 완료 `/video/generate/{task_id}` 호출 가능
## 사용 예시 (cURL)
```bash
curl -X GET "http://localhost:8000/lyric/subtitle/status/019123ab-cdef-7890-abcd-ef1234567890" \\
-H "Authorization: Bearer {access_token}"
```
## 참고
- 자막은 `/lyric/generate` 호출 백그라운드에서 자동 생성됩니다.
- 클라이언트는 `completed` 상태 확인 `/video/generate` 호출해야 합니다.
""",
response_model=SubtitleStatusResponse,
responses={
200: {"description": "상태 조회 성공"},
401: {"description": "인증 실패 (토큰 없음/만료)"},
404: {"description": "해당 task_id에 해당하는 프로젝트를 찾을 수 없음"},
},
)
async def get_subtitle_status(
task_id: str,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> SubtitleStatusResponse:
"""task_id로 자막 생성 상태를 조회합니다."""
logger.info(f"[get_subtitle_status] START - task_id: {task_id}")
project_result = await session.execute(
select(Project)
.where(Project.task_id == task_id)
.order_by(Project.created_at.desc())
.limit(1)
)
project = project_result.scalar_one_or_none()
if not project:
raise HTTPException(
status_code=404,
detail=f"task_id '{task_id}'에 해당하는 프로젝트를 찾을 수 없습니다.",
)
marketing_result = await session.execute(
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
)
intel = marketing_result.scalar_one_or_none()
if not intel:
raise HTTPException(
status_code=404,
detail=f"task_id '{task_id}'에 해당하는 마케팅 인텔리전스를 찾을 수 없습니다.",
)
# 자막과 이미지 배정 모두 완료돼야 영상 생성 가능
subtitle_done = bool(intel.subtitle)
image_match_done = bool(intel.image_match)
if subtitle_done and image_match_done:
logger.info(f"[get_subtitle_status] completed (subtitle+image_match) - task_id: {task_id}")
return SubtitleStatusResponse(
task_id=task_id,
status="completed",
message="자막 생성 및 이미지 배정이 완료되었습니다.",
)
pending_parts = []
if not subtitle_done:
pending_parts.append("자막")
if not image_match_done:
pending_parts.append("이미지 배정")
pending_msg = ", ".join(pending_parts)
logger.info(f"[get_subtitle_status] pending ({pending_msg}) - task_id: {task_id}")
return SubtitleStatusResponse(
task_id=task_id,
status="pending",
message=f"{pending_msg} 처리가 진행 중입니다. 잠시 후 다시 확인해주세요.",
)
@router.get( @router.get(
"/{task_id}", "/{task_id}",
summary="가사 상세 조회", summary="가사 상세 조회",

View File

@ -93,13 +93,6 @@ class Lyric(Base):
comment="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)", comment="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
) )
genre: Mapped[str | None] = mapped_column(
String(20),
nullable=True,
comment="음악 장르 (kpop, pop, ballad, hip-hop, rnb, edm, jazz, rock). "
"수동 선택 시 요청값, 자동 선택 시 GPT 추천값",
)
is_deleted: Mapped[bool] = mapped_column( is_deleted: Mapped[bool] = mapped_column(
Boolean, Boolean,
nullable=False, nullable=False,

View File

@ -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
@ -41,9 +41,7 @@ class GenerateLyricRequest(BaseModel):
"customer_name": "스테이 머뭄", "customer_name": "스테이 머뭄",
"region": "군산", "region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을", "detail_region_info": "군산 신흥동 말랭이 마을",
"language": "Korean", "language": "Korean"
"m_id" : 2,
"orientation" : "vertical"
} }
""" """
@ -55,8 +53,6 @@ class GenerateLyricRequest(BaseModel):
"region": "군산", "region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을", "detail_region_info": "군산 신흥동 말랭이 마을",
"language": "Korean", "language": "Korean",
"m_id" : 1,
"orientation" : "vertical"
} }
} }
) )
@ -71,18 +67,6 @@ class GenerateLyricRequest(BaseModel):
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 값")
industry: str = Field(default="", description="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general)")
instrumental: bool = Field(default=False, description="BGM 전용 모드 (가사 생성 안 함)")
genre: Optional[str] = Field(
None,
description="사용자가 수동으로 고른 음악 장르 (kpop|pop|ballad|hip-hop|rnb|edm|jazz|rock). "
"'자동 선택'인 경우 None으로 전달하면 GPT가 가사 무드에 맞춰 추천",
)
class GenerateLyricResponse(BaseModel): class GenerateLyricResponse(BaseModel):
@ -205,62 +189,9 @@ class LyricDetailResponse(BaseModel):
project_id: int = Field(..., description="프로젝트 ID") project_id: int = Field(..., description="프로젝트 ID")
status: str = Field(..., description="처리 상태 (processing, completed, failed)") status: str = Field(..., description="처리 상태 (processing, completed, failed)")
lyric_result: Optional[str] = Field(None, description="생성된 가사 또는 에러 메시지 (실패 시)") lyric_result: Optional[str] = Field(None, description="생성된 가사 또는 에러 메시지 (실패 시)")
genre: Optional[str] = Field(
None,
description="확정된 음악 장르. 수동 선택 시 요청값, 자동 선택 시 GPT 추천값 (완료 전에는 None)",
)
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):
"""가사 목록 아이템 스키마 """가사 목록 아이템 스키마

View File

@ -4,24 +4,26 @@ Lyric Background Tasks
가사 생성 관련 백그라운드 태스크를 정의합니다. 가사 생성 관련 백그라운드 태스크를 정의합니다.
""" """
import traceback
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from app.database.session import BackgroundSessionLocal from app.database.session import BackgroundSessionLocal
from app.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.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
# 로거 설정 # 로거 설정
logger = get_logger("lyric") logger = get_logger("lyric")
async def _update_lyric_status( async def _update_lyric_status(
task_id: str, task_id: str,
status: str, status: str,
result: str | None = None, result: str | None = None,
lyric_id: int | None = None, lyric_id: int | None = None,
genre: str | None = None,
) -> bool: ) -> bool:
"""Lyric 테이블의 상태를 업데이트합니다. """Lyric 테이블의 상태를 업데이트합니다.
@ -30,7 +32,6 @@ async def _update_lyric_status(
status: 변경할 상태 ("processing", "completed", "failed") status: 변경할 상태 ("processing", "completed", "failed")
result: 가사 결과 또는 에러 메시지 result: 가사 결과 또는 에러 메시지
lyric_id: 특정 Lyric 레코드 ID (재생성 정확한 레코드 식별용) lyric_id: 특정 Lyric 레코드 ID (재생성 정확한 레코드 식별용)
genre: 확정된 음악 장르 (자동 선택이었던 경우 GPT 추천값으로 갱신)
Returns: Returns:
bool: 업데이트 성공 여부 bool: 업데이트 성공 여부
@ -56,8 +57,6 @@ async def _update_lyric_status(
lyric.status = status lyric.status = status
if result is not None: if result is not None:
lyric.lyric_result = result lyric.lyric_result = result
if genre is not None:
lyric.genre = genre
await session.commit() await session.commit()
logger.info(f"[Lyric] Status updated - task_id: {task_id}, lyric_id: {lyric_id}, status: {status}") logger.info(f"[Lyric] Status updated - task_id: {task_id}, lyric_id: {lyric_id}, status: {status}")
return True return True
@ -101,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
@ -114,18 +120,14 @@ async def generate_lyric_background(
#result = await service.generate(prompt=prompt) #result = await service.generate(prompt=prompt)
result_response = await chatgpt.generate_structured_output(prompt, lyric_input_data) result_response = await chatgpt.generate_structured_output(prompt, lyric_input_data)
result = result_response.lyric result = result_response.lyric
confirmed_genre = result_response.recommended_genre
step2_elapsed = (time.perf_counter() - step2_start) * 1000 step2_elapsed = (time.perf_counter() - step2_start) * 1000
logger.info( logger.info(f"[generate_lyric_background] Step 2 완료 - 응답 {len(result)}자 ({step2_elapsed:.1f}ms)")
f"[generate_lyric_background] Step 2 완료 - 응답 {len(result)}자, "
f"genre: {confirmed_genre} ({step2_elapsed:.1f}ms)"
)
# ========== Step 3: DB 상태 업데이트 ========== # ========== Step 3: DB 상태 업데이트 ==========
step3_start = time.perf_counter() step3_start = time.perf_counter()
logger.debug(f"[generate_lyric_background] Step 3: DB 상태 업데이트...") logger.debug(f"[generate_lyric_background] Step 3: DB 상태 업데이트...")
await _update_lyric_status(task_id, "completed", result, lyric_id, genre=confirmed_genre) await _update_lyric_status(task_id, "completed", result, lyric_id)
step3_elapsed = (time.perf_counter() - step3_start) * 1000 step3_elapsed = (time.perf_counter() - step3_start) * 1000
logger.debug(f"[generate_lyric_background] Step 3 완료 ({step3_elapsed:.1f}ms)") logger.debug(f"[generate_lyric_background] Step 3 완료 ({step3_elapsed:.1f}ms)")

View File

@ -4,7 +4,7 @@ SNS API Schemas
Instagram 업로드 관련 Pydantic 스키마를 정의합니다. Instagram 업로드 관련 Pydantic 스키마를 정의합니다.
""" """
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Any, Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@ -98,6 +98,20 @@ class Media(BaseModel):
children: Optional[list["Media"]] = None children: Optional[list["Media"]] = None
class MediaList(BaseModel):
"""미디어 목록 응답"""
data: list[Media] = Field(default_factory=list)
paging: Optional[dict[str, Any]] = None
@property
def next_cursor(self) -> Optional[str]:
"""다음 페이지 커서"""
if self.paging and "cursors" in self.paging:
return self.paging["cursors"].get("after")
return None
class MediaContainer(BaseModel): class MediaContainer(BaseModel):
"""미디어 컨테이너 상태""" """미디어 컨테이너 상태"""

View File

@ -4,5 +4,5 @@ Social API Routers v1
from app.social.api.routers.v1.oauth import router as oauth_router from app.social.api.routers.v1.oauth import router as oauth_router
from app.social.api.routers.v1.upload import router as upload_router from app.social.api.routers.v1.upload import router as upload_router
from app.social.api.routers.v1.seo import router as seo_router
__all__ = ["oauth_router", "upload_router", "seo_router"] __all__ = ["oauth_router", "upload_router"]

View File

@ -1,39 +0,0 @@
"""
내부 전용 소셜 업로드 API
스케줄러 서버에서만 호출하는 내부 엔드포인트입니다.
X-Internal-Secret 헤더로 인증합니다.
"""
import logging
from fastapi import APIRouter, BackgroundTasks, Header, HTTPException, status
from app.social.worker.upload_task import process_social_upload
from config import internal_settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/internal/social", tags=["Internal"])
@router.post(
"/upload/{upload_id}",
summary="[내부] 예약 업로드 실행",
description="스케줄러 서버에서 호출하는 내부 전용 엔드포인트입니다.",
)
async def trigger_scheduled_upload(
upload_id: int,
background_tasks: BackgroundTasks,
x_internal_secret: str = Header(...),
) -> dict:
if x_internal_secret != internal_settings.INTERNAL_SECRET_KEY:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid internal secret",
)
logger.info(f"[INTERNAL] 예약 업로드 실행 - upload_id: {upload_id}")
background_tasks.add_task(process_social_upload, upload_id)
return {"success": True, "upload_id": upload_id, "message": "업로드 작업이 시작되었습니다."}

View File

@ -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(

View File

@ -1,37 +0,0 @@
"""
소셜 SEO API 라우터
SEO 관련 엔드포인트를 제공합니다.
비즈니스 로직은 SeoService에 위임합니다.
"""
import logging
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
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.models import User
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/seo", tags=["Social SEO"])
@router.post(
"/youtube",
response_model=YoutubeDescriptionResponse,
summary="유튜브 SEO description 생성",
description="유튜브 업로드 시 사용할 description을 SEO 적용하여 생성",
)
async def youtube_seo_description(
request_body: YoutubeDescriptionRequest,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> YoutubeDescriptionResponse:
return await seo_service.get_youtube_seo_description(
request_body.task_id, current_user, session
)

View File

@ -2,34 +2,37 @@
소셜 업로드 API 라우터 소셜 업로드 API 라우터
소셜 미디어 영상 업로드 관련 엔드포인트를 제공합니다. 소셜 미디어 영상 업로드 관련 엔드포인트를 제공합니다.
비즈니스 로직은 SocialUploadService에 위임합니다.
""" """
import logging import logging
from typing import Optional from typing import Optional
from fastapi import APIRouter, BackgroundTasks, Depends, Query from fastapi import APIRouter, BackgroundTasks, Depends, Query
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 +69,121 @@ 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,
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. 백그라운드 태스크 등록
background_tasks.add_task(process_social_upload, social_upload.id)
return SocialUploadResponse(
success=True,
upload_id=social_upload.id,
platform=account.platform,
status=social_upload.status,
message="업로드 요청이 접수되었습니다.",
)
@router.get( @router.get(
@ -80,35 +197,120 @@ 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(
"/history", "/history",
response_model=SocialUploadHistoryResponse, response_model=SocialUploadHistoryResponse,
summary="업로드 이력 조회", summary="업로드 이력 조회",
description=""" description="사용자의 소셜 미디어 업로드 이력을 조회합니다.",
사용자의 소셜 미디어 업로드 이력을 조회합니다.
## tab 파라미터
- `all`: 전체 (기본값)
- `completed`: 완료된 업로드
- `scheduled`: 예약 업로드 (pending + scheduled_at 있음)
- `failed`: 실패한 업로드
""",
) )
async def get_upload_history( async def get_upload_history(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
tab: str = Query("all", description="탭 필터 (all/completed/scheduled/failed)"),
platform: Optional[SocialPlatform] = Query(None, description="플랫폼 필터"), platform: Optional[SocialPlatform] = Query(None, description="플랫폼 필터"),
year: Optional[int] = Query(None, description="조회 연도 (없으면 현재 연도)"), status: Optional[UploadStatus] = Query(None, description="상태 필터"),
month: Optional[int] = Query(None, ge=1, le=12, description="조회 월 (없으면 현재 월)"),
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 업로드 이력 조회
"""
logger.info(
f"[UPLOAD_API] 이력 조회 - "
f"user_uuid: {current_user.user_uuid}, page: {page}, size: {size}"
)
# 기본 쿼리
query = select(SocialUpload).where(
SocialUpload.user_uuid == current_user.user_uuid
)
count_query = select(func.count(SocialUpload.id)).where(
SocialUpload.user_uuid == current_user.user_uuid
)
# 필터 적용
if platform:
query = query.where(SocialUpload.platform == platform.value)
count_query = count_query.where(SocialUpload.platform == platform.value)
if status:
query = query.where(SocialUpload.status == status.value)
count_query = count_query.where(SocialUpload.status == status.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,
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 +326,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 +386,42 @@ 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:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="업로드 정보를 찾을 수 없습니다.",
)
if upload.status != UploadStatus.PENDING.value:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="대기 중인 업로드만 취소할 수 있습니다.",
)
upload.status = UploadStatus.CANCELLED.value
await session.commit()
return MessageResponse(
success=True,
message="업로드가 취소되었습니다.",
)

View File

@ -91,12 +91,9 @@ 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", # 사용자 프로필
] ]
YOUTUBE_SEO_HASH = "SEO_Describtion_YT"
# ============================================================================= # =============================================================================
# Instagram/Facebook OAuth Scopes (추후 구현) # Instagram/Facebook OAuth Scopes (추후 구현)
# ============================================================================= # =============================================================================

View File

@ -190,15 +190,6 @@ class SocialUpload(Base):
comment="플랫폼별 추가 옵션 (JSON)", comment="플랫폼별 추가 옵션 (JSON)",
) )
# ==========================================================================
# 예약 게시 시간
# ==========================================================================
scheduled_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
nullable=True,
comment="예약 게시 시간 (스케줄러가 이 시간 이후에 업로드 실행)",
)
# ========================================================================== # ==========================================================================
# 에러 정보 # 에러 정보
# ========================================================================== # ==========================================================================

View File

@ -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)}"

View File

@ -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 카테고리
}, },
} }
} }
@ -56,8 +174,6 @@ class SocialUploadResponse(BaseModel):
platform: str = Field(..., description="플랫폼명") platform: str = Field(..., description="플랫폼명")
status: str = Field(..., description="업로드 상태") status: str = Field(..., description="업로드 상태")
message: str = Field(..., description="응답 메시지") message: str = Field(..., description="응답 메시지")
scheduled_at: Optional[datetime] = Field(None, description="예약 시간 (예약 업로드 충돌 시 반환)")
model_config = ConfigDict( model_config = ConfigDict(
json_schema_extra={ json_schema_extra={
@ -67,7 +183,6 @@ class SocialUploadResponse(BaseModel):
"platform": "youtube", "platform": "youtube",
"status": "pending", "status": "pending",
"message": "업로드 요청이 접수되었습니다.", "message": "업로드 요청이 접수되었습니다.",
"scheduled_at": "2026-02-02T15:00:00",
} }
} }
) )
@ -88,7 +203,6 @@ class SocialUploadStatusResponse(BaseModel):
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="에러 메시지")
retry_count: int = Field(default=0, description="재시도 횟수") retry_count: int = Field(default=0, description="재시도 횟수")
scheduled_at: Optional[datetime] = Field(None, description="예약 게시 시간 (있으면 예약 업로드)")
created_at: datetime = Field(..., description="생성 일시") created_at: datetime = Field(..., description="생성 일시")
uploaded_at: Optional[datetime] = Field(None, description="업로드 완료 일시") uploaded_at: Optional[datetime] = Field(None, description="업로드 완료 일시")
@ -125,10 +239,7 @@ class SocialUploadHistoryItem(BaseModel):
platform: str = Field(..., description="플랫폼명") platform: str = Field(..., description="플랫폼명")
status: str = Field(..., description="업로드 상태") status: str = Field(..., description="업로드 상태")
title: str = Field(..., description="영상 제목") title: str = Field(..., description="영상 제목")
platform_username: Optional[str] = Field(None, description="플랫폼 채널명")
platform_url: Optional[str] = Field(None, description="플랫폼 영상 URL") platform_url: Optional[str] = Field(None, description="플랫폼 영상 URL")
error_message: Optional[str] = Field(None, description="에러 메시지")
scheduled_at: Optional[datetime] = Field(None, description="예약 게시 시간")
created_at: datetime = Field(..., description="생성 일시") created_at: datetime = Field(..., description="생성 일시")
uploaded_at: Optional[datetime] = Field(None, description="업로드 완료 일시") uploaded_at: Optional[datetime] = Field(None, description="업로드 완료 일시")
@ -164,3 +275,24 @@ class SocialUploadHistoryResponse(BaseModel):
} }
} }
) )
# =============================================================================
# 공통 응답 스키마
# =============================================================================
class MessageResponse(BaseModel):
"""단순 메시지 응답"""
success: bool = Field(..., description="성공 여부")
message: str = Field(..., description="응답 메시지")
model_config = ConfigDict(
json_schema_extra={
"example": {
"success": True,
"message": "작업이 완료되었습니다.",
}
}
)

View File

@ -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",
]

View File

@ -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": "작업이 완료되었습니다.",
}
}
)

View File

@ -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": ["여기에", "더미", "해시태그"]
}
}
)

View File

@ -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로 변환

View File

@ -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",
]

View File

@ -1,12 +0,0 @@
"""
소셜 서비스 베이스 클래스
"""
from sqlalchemy.ext.asyncio import AsyncSession
class BaseService:
"""서비스 레이어 베이스 클래스"""
def __init__(self, session: AsyncSession | None = None):
self.session = session

View File

@ -1,131 +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,
"industry": project.industry or "", # 크롤 시 분류해 Project에 저장한 업종 enum
}
# 업종 분기는 프롬프트 내부 {industry}로 처리하므로 단일 프롬프트 사용
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()

View File

@ -1,424 +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, or_, 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. 중복 업로드 확인
now_kst_naive = datetime.now(TIMEZONE).replace(tzinfo=None)
# 3-1. 진행 중인 업로드 확인 (즉시 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]),
or_(
SocialUpload.scheduled_at.is_(None),
SocialUpload.scheduled_at <= now_kst_naive,
),
)
)
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="이미 업로드가 진행 중입니다.",
)
# 3-2. 미래 예약 업로드 확인
scheduled_result = await session.execute(
select(SocialUpload).where(
SocialUpload.video_id == body.video_id,
SocialUpload.social_account_id == account.id,
SocialUpload.status == UploadStatus.PENDING.value,
SocialUpload.scheduled_at.isnot(None),
SocialUpload.scheduled_at > now_kst_naive,
)
)
scheduled_upload = scheduled_result.scalar_one_or_none()
if scheduled_upload:
logger.info(
f"[UPLOAD_SERVICE] 예약된 업로드 존재 - "
f"upload_id: {scheduled_upload.id}, scheduled_at: {scheduled_upload.scheduled_at}"
)
return SocialUploadResponse(
success=False,
upload_id=scheduled_upload.id,
platform=account.platform,
status=scheduled_upload.status,
message="이미 예약된 업로드가 있습니다.",
scheduled_at=scheduled_upload.scheduled_at,
)
# 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. 즉시 업로드이면 백그라운드 태스크 등록
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="업로드가 취소되었습니다.",
)

View File

@ -144,7 +144,7 @@ class YouTubeUploader(BaseSocialUploader):
body = { body = {
"snippet": { "snippet": {
"title": metadata.title, "title": metadata.title,
"description": self._sanitize_description(metadata.description), "description": metadata.description or "",
"tags": metadata.tags or [], "tags": metadata.tags or [],
"categoryId": self._get_category_id(metadata), "categoryId": self._get_category_id(metadata),
}, },
@ -380,12 +380,6 @@ class YouTubeUploader(BaseSocialUploader):
) )
return False return False
def _sanitize_description(self, description: str | None) -> str:
"""YouTube API가 거부하는 문자를 제거합니다. (<, > 포함 시 invalidVideoDescription 오류 발생)"""
if not description:
return ""
return description.replace("<", "").replace(">", "")
def _convert_privacy_status(self, privacy_status: PrivacyStatus) -> str: def _convert_privacy_status(self, privacy_status: PrivacyStatus) -> str:
""" """
PrivacyStatus를 YouTube API 형식으로 변환 PrivacyStatus를 YouTube API 형식으로 변환

View File

@ -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)

View File

@ -35,33 +35,6 @@ logger = get_logger("song")
router = APIRouter(prefix="/song", tags=["Song"]) router = APIRouter(prefix="/song", tags=["Song"])
TARGET_SONG_DURATION_SECONDS = 40.0
def _select_clip_by_duration(
clips_data: list[dict], target_seconds: float = TARGET_SONG_DURATION_SECONDS
) -> dict:
"""생성된 클립 중 목표 길이(target_seconds)에 가장 가까운 클립을 선택합니다.
길이 차이가 동일하면 먼저 등장한 클립( 낮은 인덱스) 선택합니다.
duration 정보가 없는 클립은 비교 대상에서 제외되며, 모든 클립에 duration이 없으면
번째 클립을 반환합니다.
"""
best_clip = None
best_diff = None
for clip in clips_data:
duration = clip.get("duration")
if duration is None:
continue
diff = abs(duration - target_seconds)
if best_diff is None or diff < best_diff:
best_diff = diff
best_clip = clip
return best_clip if best_clip is not None else clips_data[0]
@router.post( @router.post(
"/generate/{task_id}", "/generate/{task_id}",
@ -99,7 +72,7 @@ curl -X POST "http://localhost:8000/song/generate/019123ab-cdef-7890-abcd-ef1234
``` ```
## 참고 ## 참고
- 생성되는 노래는 40 내외 길이입니다. - 생성되는 노래는 1 이내 길이입니다.
- song_id를 사용하여 /status/{song_id} 엔드포인트에서 생성 상태를 확인할 있습니다. - song_id를 사용하여 /status/{song_id} 엔드포인트에서 생성 상태를 확인할 있습니다.
- Song 테이블에 데이터가 저장되며, project_id와 lyric_id가 자동으로 연결됩니다. - Song 테이블에 데이터가 저장되며, project_id와 lyric_id가 자동으로 연결됩니다.
""", """,
@ -130,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}"
@ -212,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"
@ -277,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()
@ -440,14 +395,12 @@ async def get_song_status(
clips_data = response_data.get("sunoData") or [] clips_data = response_data.get("sunoData") or []
if clips_data: if clips_data:
# 생성된 클립(보통 2개) 중 목표 길이(1분)에 가장 가까운 클립 선택 (동일하면 첫 번째) # 첫 번째 클립(clips[0])의 audioUrl과 duration 사용
first_clip = _select_clip_by_duration(clips_data) first_clip = clips_data[0]
audio_url = first_clip.get("audioUrl") audio_url = first_clip.get("audioUrl")
clip_duration = first_clip.get("duration") clip_duration = first_clip.get("duration")
logger.debug( logger.debug(
f"[get_song_status] Selected clip by duration - id: {first_clip.get('id')}, " f"[get_song_status] Using first clip - id: {first_clip.get('id')}, audio_url: {audio_url}, duration: {clip_duration}"
f"audio_url: {audio_url}, duration: {clip_duration}, "
f"candidates: {[(c.get('id'), c.get('duration')) for c in clips_data]}"
) )
if audio_url: if audio_url:
@ -483,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)
@ -492,74 +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(
f"[get_song_status] sentences from lyric - "
f"sentences: {sentences}"
)
# None이면 Suno 타임스탬프가 아직 미준비 상태. # TODO : DB upload timestamped_lyrics
# processing을 반환해 클라이언트가 폴링을 계속하도록 한다. for order_idx, timestamped_lyric in enumerate(
if word_data is None: timestamped_lyrics
logger.info( ):
f"[get_song_status] 타임스탬프 미준비 - 폴링 유지, " # start_sec 또는 end_sec가 None인 경우 건너뛰기
f"suno_task_id: {suno_task_id}, suno_audio_id: {suno_audio_id}" if (
) timestamped_lyric["start_sec"] is None
return PollingSongResponse( or timestamped_lyric["end_sec"] is None
success=True,
status="processing",
message="타임스탬프 생성 중입니다.",
)
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_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(
word_data, sentences
)
for order_idx, timestamped_lyric in enumerate(
timestamped_lyrics
): ):
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"

View File

@ -1,6 +1,9 @@
from typing import Optional from dataclasses import dataclass, field
from datetime import datetime
from typing import Dict, List, Optional
from pydantic import BaseModel, Field, model_validator from fastapi import Request
from pydantic import BaseModel, Field
# ============================================================================= # =============================================================================
@ -33,7 +36,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 +45,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):
@ -113,6 +107,21 @@ class GenerateSongResponse(BaseModel):
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)") error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
class PollingSongRequest(BaseModel):
"""노래 생성 상태 조회 요청 스키마 (Legacy)
Note:
현재 사용되지 않음. GET /song/status/{song_id} 엔드포인트 사용.
Example Request:
{
"task_id": "abc123..."
}
"""
task_id: str = Field(..., description="Suno 작업 ID")
class SongClipData(BaseModel): class SongClipData(BaseModel):
"""생성된 노래 클립 정보""" """생성된 노래 클립 정보"""
@ -225,3 +234,94 @@ class PollingSongResponse(BaseModel):
song_result_url: Optional[str] = Field( song_result_url: Optional[str] = Field(
None, description="노래 결과 URL (Song 테이블 status가 completed일 때 반환)" None, description="노래 결과 URL (Song 테이블 status가 completed일 때 반환)"
) )
# =============================================================================
# Dataclass Schemas (Legacy)
# =============================================================================
@dataclass
class StoreData:
id: int
created_at: datetime
store_name: str
store_category: str | None = None
store_region: str | None = None
store_address: str | None = None
store_phone_number: str | None = None
store_info: str | None = None
@dataclass
class AttributeData:
id: int
attr_category: str
attr_value: str
created_at: datetime
@dataclass
class SongSampleData:
id: int
ai: str
ai_model: str
sample_song: str
season: str | None = None
num_of_people: int | None = None
people_category: str | None = None
genre: str | None = None
@dataclass
class PromptTemplateData:
id: int
prompt: str
description: str | None = None
@dataclass
class SongFormData:
store_name: str
store_id: str
prompts: str
attributes: Dict[str, str] = field(default_factory=dict)
attributes_str: str = ""
lyrics_ids: List[int] = field(default_factory=list)
llm_model: str = "gpt-5-mini"
@classmethod
async def from_form(cls, request: Request):
"""Request의 form 데이터로부터 dataclass 인스턴스 생성"""
form_data = await request.form()
# 고정 필드명들
fixed_keys = {"store_info_name", "store_id", "llm_model", "prompts"}
# lyrics-{id} 형태의 모든 키를 찾아서 ID 추출
lyrics_ids = []
attributes = {}
for key, value in form_data.items():
if key.startswith("lyrics-"):
lyrics_id = key.split("-")[1]
lyrics_ids.append(int(lyrics_id))
elif key not in fixed_keys:
attributes[key] = value
# attributes를 문자열로 변환
attributes_str = (
"\r\n\r\n".join([f"{key} : {value}" for key, value in attributes.items()])
if attributes
else ""
)
return cls(
store_name=form_data.get("store_info_name", ""),
store_id=form_data.get("store_id", ""),
attributes=attributes,
attributes_str=attributes_str,
lyrics_ids=lyrics_ids,
llm_model=form_data.get("llm_model", "gpt-5-mini"),
prompts=form_data.get("prompts", ""),
)

View File

@ -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")

View File

@ -23,12 +23,11 @@ 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, AccessTokenResponse,
KakaoCodeRequest, KakaoCodeRequest,
KakaoLoginResponse, KakaoLoginResponse,
LoginResponse, LoginResponse,
RefreshTokenRequest, RefreshTokenRequest,
TokenResponse,
UserResponse, UserResponse,
) )
from app.user.services import auth_service, kakao_client from app.user.services import auth_service, kakao_client
@ -241,30 +240,24 @@ async def kakao_verify(
@router.post( @router.post(
"/refresh", "/refresh",
response_model=TokenResponse, response_model=AccessTokenResponse,
summary="토큰 갱신 (Refresh Token Rotation)", summary="토큰 갱신",
description="리프레시 토큰으로 새 액세스 토큰과 새 리프레시 토큰함께 발급합니다. 사용된 기존 리프레시 토큰은 즉시 폐기됩니다.", description="리프레시 토큰으로 새 액세스 토큰을 발급합니다.",
) )
async def refresh_token( async def refresh_token(
body: RefreshTokenRequest, body: RefreshTokenRequest,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> TokenResponse: ) -> AccessTokenResponse:
""" """
토큰 갱신 (Refresh Token Rotation) 액세스 토큰 갱신
유효한 리프레시 토큰을 제출하면 액세스 토큰 리프레시 토큰발급합니다. 유효한 리프레시 토큰을 제출하면 액세스 토큰발급합니다.
사용된 기존 리프레시 토큰은 즉시 폐기(revoke)니다. 리프레시 토큰은 변경되지 않습니다.
""" """
logger.info(f"[ROUTER] POST /auth/refresh - token: ...{body.refresh_token[-20:]}") return await auth_service.refresh_tokens(
result = await auth_service.refresh_tokens(
refresh_token=body.refresh_token, refresh_token=body.refresh_token,
session=session, session=session,
) )
logger.info(
f"[ROUTER] POST /auth/refresh 완료 - new_access: ...{result.access_token[-20:]}, "
f"new_refresh: ...{result.refresh_token[-20:]}"
)
return result
@router.post( @router.post(
@ -288,16 +281,11 @@ async def logout(
현재 사용 중인 리프레시 토큰을 폐기합니다. 현재 사용 중인 리프레시 토큰을 폐기합니다.
해당 토큰으로는 이상 액세스 토큰을 갱신할 없습니다. 해당 토큰으로는 이상 액세스 토큰을 갱신할 없습니다.
""" """
logger.info(
f"[ROUTER] POST /auth/logout - user_id: {current_user.id}, "
f"user_uuid: {current_user.user_uuid}, token: ...{body.refresh_token[-20:]}"
)
await auth_service.logout( await auth_service.logout(
user_id=current_user.id, user_id=current_user.id,
refresh_token=body.refresh_token, refresh_token=body.refresh_token,
session=session, session=session,
) )
logger.info(f"[ROUTER] POST /auth/logout 완료 - user_id: {current_user.id}")
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
@ -321,15 +309,10 @@ async def logout_all(
사용자의 모든 리프레시 토큰을 폐기합니다. 사용자의 모든 리프레시 토큰을 폐기합니다.
모든 기기에서 재로그인이 필요합니다. 모든 기기에서 재로그인이 필요합니다.
""" """
logger.info(
f"[ROUTER] POST /auth/logout/all - user_id: {current_user.id}, "
f"user_uuid: {current_user.user_uuid}"
)
await auth_service.logout_all( await auth_service.logout_all(
user_id=current_user.id, user_id=current_user.id,
session=session, session=session,
) )
logger.info(f"[ROUTER] POST /auth/logout/all 완료 - user_id: {current_user.id}")
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
@ -354,22 +337,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에서 라우터가 등록됨)
# ============================================================================= # =============================================================================

View File

@ -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",

View File

@ -4,7 +4,6 @@
FastAPI 라우터에서 사용할 인증 관련 의존성을 정의합니다. FastAPI 라우터에서 사용할 인증 관련 의존성을 정의합니다.
""" """
import logging
from typing import Optional from typing import Optional
from fastapi import Depends from fastapi import Depends
@ -18,13 +17,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__)
security = HTTPBearer(auto_error=False) security = HTTPBearer(auto_error=False)
@ -51,31 +47,18 @@ async def get_current_user(
UserInactiveError: 비활성화된 계정인 경우 UserInactiveError: 비활성화된 계정인 경우
""" """
if credentials is None: if credentials is None:
logger.info("[AUTH-DEP] 토큰 없음 - MissingTokenError")
raise MissingTokenError() raise MissingTokenError()
token = credentials.credentials payload = decode_token(credentials.credentials)
logger.debug(f"[AUTH-DEP] Access Token 검증 시작 - token: ...{token[-20:]}")
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:]}")
raise InvalidTokenError() raise InvalidTokenError()
# 토큰 타입 확인 # 토큰 타입 확인
if payload.get("type") != "access": if payload.get("type") != "access":
logger.warning(
f"[AUTH-DEP] 토큰 타입 불일치 - expected: access, "
f"got: {payload.get('type')}, sub: {payload.get('sub')}"
)
raise InvalidTokenError("액세스 토큰이 아닙니다.") raise InvalidTokenError("액세스 토큰이 아닙니다.")
user_uuid = payload.get("sub") user_uuid = payload.get("sub")
if user_uuid is None: if user_uuid is None:
logger.warning(f"[AUTH-DEP] 토큰에 sub 클레임 없음 - token: ...{token[-20:]}")
raise InvalidTokenError() raise InvalidTokenError()
# 사용자 조회 # 사용자 조회
@ -88,18 +71,11 @@ async def get_current_user(
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if user is None: if user is None:
logger.warning(f"[AUTH-DEP] 사용자 미존재 - user_uuid: {user_uuid}")
raise UserNotFoundError() raise UserNotFoundError()
if not user.is_active: if not user.is_active:
logger.warning(
f"[AUTH-DEP] 비활성 사용자 접근 - user_uuid: {user_uuid}, user_id: {user.id}"
)
raise UserInactiveError() raise UserInactiveError()
logger.debug(
f"[AUTH-DEP] Access Token 검증 성공 - user_uuid: {user_uuid}, user_id: {user.id}"
)
return user return user
@ -120,24 +96,17 @@ async def get_current_user_optional(
User | None: 로그인한 사용자 또는 None User | None: 로그인한 사용자 또는 None
""" """
if credentials is None: if credentials is None:
logger.debug("[AUTH-DEP] 선택적 인증 - 토큰 없음")
return None return None
token = credentials.credentials payload = decode_token(credentials.credentials)
payload = decode_token(token)
if payload is None: if payload is None:
logger.debug(f"[AUTH-DEP] 선택적 인증 - 디코딩 실패, token: ...{token[-20:]}")
return None return None
if payload.get("type") != "access": if payload.get("type") != "access":
logger.debug(
f"[AUTH-DEP] 선택적 인증 - 타입 불일치 (type={payload.get('type')})"
)
return None return None
user_uuid = payload.get("sub") user_uuid = payload.get("sub")
if user_uuid is None: if user_uuid is None:
logger.debug("[AUTH-DEP] 선택적 인증 - sub 없음")
return None return None
result = await session.execute( result = await session.execute(
@ -149,14 +118,8 @@ async def get_current_user_optional(
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if user is None or not user.is_active: if user is None or not user.is_active:
logger.debug(
f"[AUTH-DEP] 선택적 인증 - 사용자 미존재 또는 비활성, user_uuid: {user_uuid}"
)
return None return None
logger.debug(
f"[AUTH-DEP] 선택적 인증 성공 - user_uuid: {user_uuid}, user_id: {user.id}"
)
return user return user

View File

@ -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("

View File

@ -1,4 +1,5 @@
from app.user.schemas.user_schema import ( from app.user.schemas.user_schema import (
AccessTokenResponse,
KakaoCodeRequest, KakaoCodeRequest,
KakaoLoginResponse, KakaoLoginResponse,
KakaoTokenResponse, KakaoTokenResponse,
@ -11,6 +12,7 @@ from app.user.schemas.user_schema import (
) )
__all__ = [ __all__ = [
"AccessTokenResponse",
"KakaoCodeRequest", "KakaoCodeRequest",
"KakaoLoginResponse", "KakaoLoginResponse",
"KakaoTokenResponse", "KakaoTokenResponse",

View File

@ -64,6 +64,24 @@ class TokenResponse(BaseModel):
} }
class AccessTokenResponse(BaseModel):
"""액세스 토큰 갱신 응답"""
access_token: str = Field(..., description="액세스 토큰")
token_type: str = Field(default="Bearer", description="토큰 타입")
expires_in: int = Field(..., description="액세스 토큰 만료 시간 (초)")
model_config = {
"json_schema_extra": {
"example": {
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzA1MzE1MjAwfQ.new_token",
"token_type": "Bearer",
"expires_in": 3600
}
}
}
class RefreshTokenRequest(BaseModel): class RefreshTokenRequest(BaseModel):
"""토큰 갱신 요청""" """토큰 갱신 요청"""
@ -160,22 +178,6 @@ class LoginResponse(BaseModel):
} }
} }
# =============================================================================
# 크레딧 스키마
# =============================================================================
class CreditResponse(BaseModel):
"""잔여 크레딧 응답"""
credits: int = Field(..., description="영상 생성 크레딧")
model_config = {
"json_schema_extra": {
"example": {
"credits": 3
}
}
}
# ============================================================================= # =============================================================================
# 내부 사용 스키마 (카카오 API 응답 파싱) # 내부 사용 스키마 (카카오 API 응답 파싱)

View File

@ -81,9 +81,9 @@ class AdminRequiredError(AuthException):
from app.user.models import RefreshToken, User from app.user.models import RefreshToken, User
from app.utils.common import generate_uuid from app.utils.common import generate_uuid
from app.user.schemas.user_schema import ( from app.user.schemas.user_schema import (
AccessTokenResponse,
KakaoUserInfo, KakaoUserInfo,
LoginResponse, LoginResponse,
TokenResponse,
) )
from app.user.services.jwt import ( from app.user.services.jwt import (
create_access_token, create_access_token,
@ -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
@ -189,132 +188,59 @@ class AuthService:
self, self,
refresh_token: str, refresh_token: str,
session: AsyncSession, session: AsyncSession,
) -> TokenResponse: ) -> AccessTokenResponse:
""" """
리프레시 토큰으로 액세스 토큰 + 리프레시 토큰 갱신 (Refresh Token Rotation) 리프레시 토큰으로 액세스 토큰 갱신
기존 리프레시 토큰을 폐기하고, 액세스 토큰과 리프레시 토큰을 함께 발급합니다.
사용자가 서비스를 지속 사용하는 세션이 자동 유지됩니다.
Args: Args:
refresh_token: 리프레시 토큰 refresh_token: 리프레시 토큰
session: DB 세션 session: DB 세션
Returns: Returns:
TokenResponse: 액세스 토큰 + 리프레시 토큰 AccessTokenResponse: 액세스 토큰
Raises: Raises:
InvalidTokenError: 토큰이 유효하지 않은 경우 InvalidTokenError: 토큰이 유효하지 않은 경우
TokenExpiredError: 토큰이 만료된 경우 TokenExpiredError: 토큰이 만료된 경우
TokenRevokedError: 토큰이 폐기된 경우 TokenRevokedError: 토큰이 폐기된 경우
""" """
logger.info(f"[AUTH] 토큰 갱신 시작 (Rotation) - token: ...{refresh_token[-20:]}")
# 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:]}")
raise InvalidTokenError() raise InvalidTokenError()
if payload.get("type") != "refresh": if payload.get("type") != "refresh":
logger.warning(
f"[AUTH] 토큰 갱신 실패 [1/8 타입] - type={payload.get('type')}, "
f"sub: {payload.get('sub')}"
)
raise InvalidTokenError("리프레시 토큰이 아닙니다.") raise InvalidTokenError("리프레시 토큰이 아닙니다.")
logger.debug(
f"[AUTH] 토큰 갱신 [1/8] 디코딩 성공 - sub: {payload.get('sub')}, "
f"exp: {payload.get('exp')}"
)
# 2. DB에서 리프레시 토큰 조회 # 2. DB에서 리프레시 토큰 조회
token_hash = get_token_hash(refresh_token) token_hash = get_token_hash(refresh_token)
db_token = await self._get_refresh_token_by_hash(token_hash, session) db_token = await self._get_refresh_token_by_hash(token_hash, session)
if db_token is None: if db_token is None:
logger.warning(
f"[AUTH] 토큰 갱신 실패 [2/8 DB조회] - DB에 없음, "
f"token_hash: {token_hash[:16]}..."
)
raise InvalidTokenError() raise InvalidTokenError()
logger.debug(
f"[AUTH] 토큰 갱신 [2/8] DB 조회 성공 - token_hash: {token_hash[:16]}..., "
f"user_uuid: {db_token.user_uuid}, is_revoked: {db_token.is_revoked}, "
f"expires_at: {db_token.expires_at}"
)
# 3. 토큰 상태 확인 # 3. 토큰 상태 확인
if db_token.is_revoked: if db_token.is_revoked:
logger.warning(
f"[AUTH] 토큰 갱신 실패 [3/8 폐기됨] - 이미 폐기된 토큰 (replay attack 의심), "
f"token_hash: {token_hash[:16]}..., user_uuid: {db_token.user_uuid}, "
f"revoked_at: {db_token.revoked_at}"
)
raise TokenRevokedError() raise TokenRevokedError()
# 4. 만료 확인
if db_token.expires_at < now().replace(tzinfo=None): if db_token.expires_at < now().replace(tzinfo=None):
logger.info(
f"[AUTH] 토큰 갱신 실패 [4/8 만료] - expires_at: {db_token.expires_at}, "
f"user_uuid: {db_token.user_uuid}"
)
raise TokenExpiredError() raise TokenExpiredError()
# 5. 사용자 확인 # 4. 사용자 확인
user_uuid = payload.get("sub") user_uuid = payload.get("sub")
user = await self._get_user_by_uuid(user_uuid, session) user = await self._get_user_by_uuid(user_uuid, session)
if user is None: if user is None:
logger.warning(
f"[AUTH] 토큰 갱신 실패 [5/8 사용자] - 사용자 미존재, user_uuid: {user_uuid}"
)
raise UserNotFoundError() raise UserNotFoundError()
if not user.is_active: if not user.is_active:
logger.warning(
f"[AUTH] 토큰 갱신 실패 [5/8 비활성] - user_uuid: {user_uuid}, "
f"user_id: {user.id}"
)
raise UserInactiveError() raise UserInactiveError()
# 6. 기존 리프레시 토큰 폐기 (ORM 직접 수정 — _revoke_refresh_token_by_hash는 내부 commit이 있어 사용하지 않음) # 5. 새 액세스 토큰 발급
db_token.is_revoked = True
db_token.revoked_at = now().replace(tzinfo=None)
logger.debug(f"[AUTH] 토큰 갱신 [6/8] 기존 토큰 폐기 - token_hash: {token_hash[:16]}...")
# 7. 새 토큰 발급
new_access_token = create_access_token(user.user_uuid) new_access_token = create_access_token(user.user_uuid)
new_refresh_token = create_refresh_token(user.user_uuid)
logger.debug(
f"[AUTH] 토큰 갱신 [7/8] 새 토큰 발급 - new_access: ...{new_access_token[-20:]}, "
f"new_refresh: ...{new_refresh_token[-20:]}"
)
# 8. 새 리프레시 토큰 DB 저장 (_save_refresh_token은 flush만 수행) return AccessTokenResponse(
await self._save_refresh_token(
user_id=user.id,
user_uuid=user.user_uuid,
token=new_refresh_token,
session=session,
)
# 폐기 + 저장을 하나의 트랜잭션으로 커밋
await session.commit()
logger.info(
f"[AUTH] 토큰 갱신 완료 [8/8] - user_uuid: {user.user_uuid}, "
f"user_id: {user.id}, old_hash: {token_hash[:16]}..., "
f"new_refresh: ...{new_refresh_token[-20:]}"
)
return TokenResponse(
access_token=new_access_token, access_token=new_access_token,
refresh_token=new_refresh_token,
token_type="Bearer", token_type="Bearer",
expires_in=get_access_token_expire_seconds(), expires_in=get_access_token_expire_seconds(),
) )
@ -334,12 +260,7 @@ class AuthService:
session: DB 세션 session: DB 세션
""" """
token_hash = get_token_hash(refresh_token) token_hash = get_token_hash(refresh_token)
logger.info(
f"[AUTH] 로그아웃 - user_id: {user_id}, token_hash: {token_hash[:16]}..., "
f"token: ...{refresh_token[-20:]}"
)
await self._revoke_refresh_token_by_hash(token_hash, session) await self._revoke_refresh_token_by_hash(token_hash, session)
logger.info(f"[AUTH] 로그아웃 완료 - user_id: {user_id}")
async def logout_all( async def logout_all(
self, self,
@ -353,9 +274,7 @@ class AuthService:
user_id: 사용자 ID user_id: 사용자 ID
session: DB 세션 session: DB 세션
""" """
logger.info(f"[AUTH] 전체 로그아웃 - user_id: {user_id}")
await self._revoke_all_user_tokens(user_id, session) await self._revoke_all_user_tokens(user_id, session)
logger.info(f"[AUTH] 전체 로그아웃 완료 - user_id: {user_id}")
async def _get_or_create_user( async def _get_or_create_user(
self, self,
@ -485,11 +404,6 @@ class AuthService:
) )
session.add(refresh_token) session.add(refresh_token)
await session.flush() await session.flush()
logger.debug(
f"[AUTH] Refresh Token DB 저장 - user_uuid: {user_uuid}, "
f"token_hash: {token_hash[:16]}..., expires_at: {expires_at}"
)
return refresh_token return refresh_token
async def _get_refresh_token_by_hash( async def _get_refresh_token_by_hash(

View File

@ -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

View File

@ -5,18 +5,14 @@ Access Token과 Refresh Token의 생성, 검증, 해시 기능을 제공합니
""" """
import hashlib import hashlib
import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional from typing import Optional
from jose import JWTError, jwt from jose import JWTError, jwt
from jose.exceptions import ExpiredSignatureError, JWTClaimsError
from app.utils.timezone import now from app.utils.timezone import now
from config import jwt_settings from config import jwt_settings
logger = logging.getLogger(__name__)
def create_access_token(user_uuid: str) -> str: def create_access_token(user_uuid: str) -> str:
""" """
@ -36,16 +32,11 @@ def create_access_token(user_uuid: str) -> str:
"exp": expire, "exp": expire,
"type": "access", "type": "access",
} }
token = jwt.encode( return jwt.encode(
to_encode, to_encode,
jwt_settings.JWT_SECRET, jwt_settings.JWT_SECRET,
algorithm=jwt_settings.JWT_ALGORITHM, algorithm=jwt_settings.JWT_ALGORITHM,
) )
logger.debug(
f"[JWT] Access Token 발급 - user_uuid: {user_uuid}, "
f"expires: {expire}, token: ...{token[-20:]}"
)
return token
def create_refresh_token(user_uuid: str) -> str: def create_refresh_token(user_uuid: str) -> str:
@ -66,16 +57,11 @@ def create_refresh_token(user_uuid: str) -> str:
"exp": expire, "exp": expire,
"type": "refresh", "type": "refresh",
} }
token = jwt.encode( return jwt.encode(
to_encode, to_encode,
jwt_settings.JWT_SECRET, jwt_settings.JWT_SECRET,
algorithm=jwt_settings.JWT_ALGORITHM, algorithm=jwt_settings.JWT_ALGORITHM,
) )
logger.debug(
f"[JWT] Refresh Token 발급 - user_uuid: {user_uuid}, "
f"expires: {expire}, token: ...{token[-20:]}"
)
return token
def decode_token(token: str) -> Optional[dict]: def decode_token(token: str) -> Optional[dict]:
@ -94,48 +80,9 @@ def decode_token(token: str) -> Optional[dict]:
jwt_settings.JWT_SECRET, jwt_settings.JWT_SECRET,
algorithms=[jwt_settings.JWT_ALGORITHM], algorithms=[jwt_settings.JWT_ALGORITHM],
) )
logger.debug(
f"[JWT] 토큰 디코딩 성공 - type: {payload.get('type')}, "
f"sub: {payload.get('sub')}, exp: {payload.get('exp')}, "
f"token: ...{token[-20:]}"
)
return payload return payload
except ExpiredSignatureError:
logger.info(f"[JWT] 토큰 만료 - token: ...{token[-20:]}")
return None
except JWTClaimsError as e:
logger.warning(
f"[JWT] 클레임 검증 실패 - error: {e}, token: ...{token[-20:]}"
)
return None
except JWTError as e:
logger.warning(
f"[JWT] 토큰 디코딩 실패 - error: {type(e).__name__}: {e}, "
f"token: ...{token[-20:]}"
)
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: except JWTError:
return False return None
def get_token_hash(token: str) -> str: def get_token_hash(token: str) -> str:

Some files were not shown because too many files have changed in this diff Show More