Compare commits

..

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

108 changed files with 2568 additions and 6802 deletions

4
.gitignore vendored
View File

@ -52,7 +52,3 @@ Dockerfile
.dockerignore
zzz/
credentials/service_account.json
# Scheduler (separate repo)
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 sqladmin.authentication import login_required
from starlette.exceptions import HTTPException
from starlette.requests import Request
from starlette.responses import Response
from sqlalchemy.ext.asyncio import AsyncEngine
from app.backoffice.admin.admin_view import AdminAdmin
from app.backoffice.admin.auth import AdminAuthBackend
from app.backoffice.credit_view import CreditChargeRequestAdmin, CreditTransactionAdmin
from app.backoffice.dashboard import get_dashboard_context
from app.user.api.user_admin import SocialAccountAdmin, UserAdmin
from app.database.session import engine
from app.home.api.home_admin import ImageAdmin, ProjectAdmin
from app.lyric.api.lyrics_admin import LyricAdmin
from app.song.api.song_admin import SongAdmin
from app.sns.api.sns_admin import SNSUploadTaskAdmin
from app.user.api.user_admin import RefreshTokenAdmin, SocialAccountAdmin, UserAdmin
from app.video.api.video_admin import VideoAdmin
from config import prj_settings
TEMPLATES_DIR = Path(__file__).parent / "backoffice" / "frontend" / "templates"
class DashboardAdmin(Admin):
@login_required
async def index(self, request: Request) -> Response:
ctx = await get_dashboard_context()
admin_role = request.session.get("admin_role", "viewer")
return await self.templates.TemplateResponse(
request,
"sqladmin/index.html",
{"title": "대시보드", "subtitle": "", "admin_role": admin_role, **ctx},
)
@login_required
async def edit(self, request: Request) -> Response:
if request.session.get("admin_role") == "viewer":
raise HTTPException(status_code=403)
return await super().edit(request)
@login_required
async def create(self, request: Request) -> Response:
if request.session.get("admin_role") == "viewer":
raise HTTPException(status_code=403)
return await super().create(request)
@login_required
async def delete(self, request: Request) -> Response:
if request.session.get("admin_role") == "viewer":
raise HTTPException(status_code=403)
return await super().delete(request)
# https://github.com/aminalaee/sqladmin
def init_admin(
app: FastAPI,
db_engine: AsyncEngine,
db_engine: engine,
base_url: str = prj_settings.ADMIN_BASE_URL,
) -> Admin:
auth_backend = AdminAuthBackend(secret_key=prj_settings.ADMIN_SESSION_SECRET)
admin = DashboardAdmin(
admin = Admin(
app,
db_engine,
base_url=base_url,
authentication_backend=auth_backend,
title="ADO2 관리자",
templates_dir=str(TEMPLATES_DIR),
)
# 프로젝트 관리
admin.add_view(ProjectAdmin)
admin.add_view(ImageAdmin)
# 가사 관리
admin.add_view(LyricAdmin)
# 노래 관리
admin.add_view(SongAdmin)
# 영상 관리
admin.add_view(VideoAdmin)
# 사용자 관리
admin.add_view(UserAdmin)
admin.add_view(RefreshTokenAdmin)
admin.add_view(SocialAccountAdmin)
# 크레딧 관리 (superadmin: 전체, viewer: 읽기 전용)
admin.add_view(CreditChargeRequestAdmin)
admin.add_view(CreditTransactionAdmin)
# 백오피스 설정
admin.add_view(AdminAdmin)
# SNS 관리
admin.add_view(SNSUploadTaskAdmin)
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.utils.logger import get_logger
from app.utils.pagination import PaginatedResponse
from app.comment.models import Comment
from app.database.like_cache import get_like_counts, mset_like_counts
from app.video.models import Video, VideoReaction
from app.video.models import Video
from app.video.schemas.video_schema import VideoListItem
logger = get_logger(__name__)
@ -101,22 +99,9 @@ async def get_videos(
total_result = await session.execute(count_query)
total = total_result.scalar() or 0
# 쿼리 2: Video + Project + comment_count 조회 (like_count는 Redis에서)
comment_count_subq = (
select(func.count(Comment.id))
.where(
Comment.video_id == Video.id,
Comment.is_deleted == False, # noqa: E712
)
.correlate(Video)
.scalar_subquery()
)
# 쿼리 2: Video + Project 데이터 조회 (task_id별 최신 영상만)
data_query = (
select(
Video,
Project,
comment_count_subq.label("comment_count"),
)
select(Video, Project)
.join(Project, Video.project_id == Project.id)
.where(Video.id.in_(select(latest_video_ids.c.latest_id)))
.order_by(Video.created_at.desc())
@ -126,29 +111,6 @@ async def get_videos(
result = await session.execute(data_query)
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으로 변환
items = [
VideoListItem(
@ -158,10 +120,8 @@ async def get_videos(
task_id=video.task_id,
result_movie_url=video.result_movie_url,
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(

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

@ -51,9 +51,6 @@ async def lifespan(app: FastAPI):
await close_shared_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

View File

@ -314,10 +314,7 @@ def add_exception_handlers(app: FastAPI):
@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}")
logger.debug(f"Handled DashboardException: {exc.__class__.__name__} - {exc.message}")
return JSONResponse(
status_code=exc.status_code,
content={

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

@ -4,22 +4,43 @@ Dashboard API 라우터
YouTube Analytics 기반 대시보드 통계를 제공합니다.
"""
import json
import logging
from datetime import date, datetime, timedelta
from typing import Literal
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
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.exceptions import (
YouTubeAccountNotConnectedError,
YouTubeAccountNotFoundError,
YouTubeAccountSelectionRequiredError,
YouTubeTokenExpiredError,
)
from app.dashboard.schemas import (
AudienceData,
CacheDeleteResponse,
ConnectedAccount,
ConnectedAccountsResponse,
ContentMetric,
DashboardResponse,
TopContent,
)
from app.dashboard.services import DataProcessor, YouTubeAnalyticsService
from app.dashboard.redis_cache import (
delete_cache,
delete_cache_pattern,
get_cache,
set_cache,
)
from app.dashboard.services import DashboardService
from app.database.session import get_session
from app.dashboard.models import Dashboard
from app.social.exceptions import TokenExpiredError
from app.social.services import SocialAccountService
from app.user.dependencies.auth import get_current_user
from app.user.models import User
from app.user.models import SocialAccount, User
logger = logging.getLogger(__name__)
@ -40,8 +61,41 @@ 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)
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()
# platform_user_id 기준
seen_platform_ids: set[str] = set()
connected = []
for acc in sorted(
accounts_raw, key=lambda a: a.connected_at or datetime.min, reverse=True
):
if acc.platform_user_id in seen_platform_ids:
continue
seen_platform_ids.add(acc.platform_user_id)
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 ConnectedAccountsResponse(accounts=connected)
@ -88,8 +142,328 @@ async def get_dashboard_stats(
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)
"""
대시보드 통계 조회
Args:
mode: 조회 모드 (day: 최근 30, month: 최근 12개월)
platform_user_id: 사용할 YouTube 채널 ID (여러 계정 연결 필수, 재연동해도 불변)
current_user: 현재 인증된 사용자
session: 데이터베이스 세션
Returns:
DashboardResponse: 대시보드 통계 데이터
Raises:
YouTubeAccountNotConnectedError: YouTube 계정이 연동되어 있지 않음
YouTubeAccountSelectionRequiredError: 여러 계정이 연결되어 있으나 계정 미선택
YouTubeAccountNotFoundError: 지정한 계정을 찾을 없음
YouTubeTokenExpiredError: YouTube 토큰 만료 (재연동 필요)
YouTubeAPIError: YouTube Analytics API 호출 실패
"""
logger.info(
f"[DASHBOARD] 통계 조회 시작 - "
f"user_uuid={current_user.user_uuid}, mode={mode}, platform_user_id={platform_user_id}"
)
# 1. 모드별 날짜 자동 계산
today = date.today()
if mode == "day":
# 48시간 지연 적용: 오늘 기준 -2일을 end로 사용
# ex) 오늘 2/20 → end=2/18, start=1/20
end_dt = today - timedelta(days=2)
kpi_end_dt = end_dt
start_dt = end_dt - timedelta(days=29)
# 이전 30일 (YouTube API day_previous와 동일 기준)
prev_start_dt = start_dt - timedelta(days=30)
prev_kpi_end_dt = kpi_end_dt - timedelta(days=30)
period_desc = "최근 30일"
else: # mode == "month"
# 월별 차트: dimensions=month API는 YYYY-MM-01 형식 필요
# ex) 오늘 2/24 → end=2026-02-01, start=2025-03-01 → 2025-03 ~ 2026-02 (12개월)
end_dt = today.replace(day=1)
# KPI 등 집계형 API: 48시간 지연 적용하여 현재 월 전체 데이터 포함
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)
# 이전 12개월 (YouTube API previous와 동일 기준 — 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: # 윤년 2/29 → 이전 연도 2/28
prev_kpi_end_dt = kpi_end_dt.replace(year=kpi_end_dt.year - 1, day=28)
period_desc = "최근 12개월"
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 계정 연동 확인
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()
# platform_user_id 기준으로 중복 제거 (가장 최근 연동 계정 우선)
seen_platform_ids_stats: set[str] = set()
social_accounts = []
for acc in sorted(
social_accounts_raw, key=lambda a: a.connected_at or datetime.min, reverse=True
):
if acc.platform_user_id not in seen_platform_ids_stats:
seen_platform_ids_stats.add(acc.platform_user_id)
social_accounts.append(acc)
if not social_accounts:
logger.warning(
f"[NO YOUTUBE ACCOUNT] YouTube 계정 미연동 - "
f"user_uuid={current_user.user_uuid}"
)
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:
logger.warning(
f"[ACCOUNT NOT FOUND] 지정 계정 없음 - "
f"user_uuid={current_user.user_uuid}, platform_user_id={platform_user_id}"
)
raise YouTubeAccountNotFoundError()
social_account = matched[0]
elif len(social_accounts) == 1:
social_account = social_accounts[0]
else:
logger.warning(
f"[MULTI ACCOUNT] 계정 선택 필요 - "
f"user_uuid={current_user.user_uuid}, count={len(social_accounts)}"
)
raise YouTubeAccountSelectionRequiredError()
logger.debug(
f"[2] YouTube 계정 확인 완료 - platform_user_id={social_account.platform_user_id}"
)
# 3. 기간 내 업로드 영상 수 조회
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
# 이전 기간 업로드 영상 수 조회 (trend 계산용)
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
logger.debug(
f"[3] 기간 내 업로드 영상 수 - current={period_video_count}, prev={prev_period_video_count}"
)
# 4. Redis 캐시 조회
# platform_user_id 기준 캐시 키: 재연동해도 채널 ID는 불변 → 캐시 유지됨
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"])
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
return response
except (json.JSONDecodeError, KeyError):
logger.warning(f"[CACHE PARSE ERROR] 포맷 오류, 무시 - key={cache_key}")
logger.debug("[4] 캐시 MISS - YouTube API 호출 필요")
# 5. 최근 30개 업로드 영상 조회 (Analytics API 전달용)
# YouTube Analytics API 제약사항:
# - 영상 개수: 20~30개 권장 (최대 50개, 그 이상은 응답 지연 발생)
# - URL 길이: 2000자 제한 (video ID 11자 × 30개 = 330자로 안전)
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()
logger.debug(f"[5] 영상 조회 완료 - count={len(rows)}")
# 6. video_ids + 메타데이터 조회용 dict 구성
video_ids = []
video_lookup: dict[str, tuple[str, datetime]] = {} # {video_id: (title, uploaded_at)}
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)
logger.debug(
f"[6] 영상 메타데이터 구성 완료 - count={len(video_ids)}, sample={video_ids[:3]}"
)
# 6.1 업로드 영상 없음 → YouTube API 호출 없이 빈 응답 반환
if not video_ids:
logger.info(
f"[DASHBOARD] 업로드 영상 없음, 빈 응답 반환 - "
f"user_uuid={current_user.user_uuid}"
)
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,
)
# 7. 토큰 유효성 확인 및 자동 갱신 (만료 10분 전 갱신)
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("[7] 토큰 유효성 확인 완료")
# 8. YouTube Analytics API 호출 (7개 병렬)
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("[8] YouTube Analytics API 호출 완료")
# 9. TopContent 조립 (Analytics top_videos + DB lookup)
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"[9] TopContent 조립 완료 - count={len(top_content)}")
# 10. 데이터 가공 (period_video_count=0 — API 무관 DB 집계값, 캐시에 포함하지 않음)
dashboard_data = processor.process(
raw_data, top_content, 0, mode=mode, end_date=end_date
)
logger.debug("[10] 데이터 가공 완료")
# 11. Redis 캐싱 (TTL: 12시간)
# YouTube Analytics는 하루 1회 갱신 (PT 자정, 한국 시간 오후 5~8시)
# 48시간 지연된 데이터이므로 12시간 캐싱으로 API 호출 최소화
# period_video_count는 캐시에 포함하지 않음 (DB 직접 집계, API 미사용)
cache_payload = json.dumps(
{"response": json.loads(dashboard_data.model_dump_json())}
)
cache_success = await set_cache(
cache_key,
cache_payload,
ttl=43200, # 12시간
)
if cache_success:
logger.debug(f"[CACHE SET] 캐시 저장 성공 - key={cache_key}")
else:
logger.warning(f"[CACHE SET] 캐시 저장 실패 - key={cache_key}")
# 12. 업로드 영상 수 및 trend 주입 (캐시 저장 후 — 항상 DB에서 직접 집계)
for metric in dashboard_data.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
logger.info(
f"[DASHBOARD] 통계 조회 완료 - "
f"user_uuid={current_user.user_uuid}, "
f"mode={mode}, period={period_desc}, videos={len(video_ids)}"
)
return dashboard_data
@router.delete(
@ -109,7 +483,7 @@ async def get_dashboard_stats(
`dashboard:{user_uuid}:{platform_user_id}:{mode}` (mode: day 또는 month)
## 파라미터
- `user_uuid`: 삭제할 사용자 UUID (필수)
- `user_uuid`: 특정 사용자 캐시만 삭제. 미입력 전체 삭제
- `mode`: day / month / all (기본값: all)
""",
)
@ -118,16 +492,33 @@ async def delete_dashboard_cache(
default="all",
description="삭제할 캐시 모드: day, month, all(기본값, 모두 삭제)",
),
user_uuid: str = Query(
description="대상 사용자 UUID",
user_uuid: str | None = Query(
default=None,
description="대상 사용자 UUID. 미입력 시 전체 사용자 캐시 삭제",
),
) -> CacheDeleteResponse:
if mode == "all":
deleted = await delete_cache_pattern(f"dashboard:{user_uuid}:*")
message = f"전체 캐시 삭제 완료 ({deleted}개)"
"""
대시보드 캐시 삭제
Args:
mode: 삭제할 캐시 모드 (day / month / all)
user_uuid: 대상 사용자 UUID (없으면 전체 삭제)
Returns:
CacheDeleteResponse: 삭제된 캐시 개수 메시지
"""
if user_uuid:
if mode == "all":
deleted = await delete_cache_pattern(f"dashboard:{user_uuid}:*")
message = f"전체 캐시 삭제 완료 ({deleted}개)"
else:
cache_key = f"dashboard:{user_uuid}:{mode}"
success = await delete_cache(cache_key)
deleted = 1 if success else 0
message = f"{mode} 캐시 삭제 {'완료' if success else '실패 (키 없음)'}"
else:
deleted = await delete_cache_pattern(f"dashboard:{user_uuid}:*:{mode}")
message = f"{mode} 캐시 삭제 완료 ({deleted}개)"
deleted = await delete_cache_pattern("dashboard:*")
message = f"전체 사용자 캐시 삭제 완료 ({deleted}개)"
logger.info(
f"[CACHE DELETE] user_uuid={user_uuid or 'ALL'}, mode={mode}, deleted={deleted}"

View File

@ -113,7 +113,7 @@ class YouTubeAccountSelectionRequiredError(DashboardException):
def __init__(self):
super().__init__(
message="연결된 YouTube 계정이 여러 개입니다. platform_user_id 파라미터로 사용할 계정을 선택해주세요.",
message="연결된 YouTube 계정이 여러 개입니다. social_account_id 파라미터로 사용할 계정을 선택해주세요.",
status_code=status.HTTP_400_BAD_REQUEST,
code="YOUTUBE_ACCOUNT_SELECTION_REQUIRED",
)

View File

@ -197,6 +197,35 @@ class AudienceData(BaseModel):
)
# class PlatformMetric(BaseModel):
# """플랫폼별 메트릭 (미사용 — platform_data 기능 미구현)"""
#
# id: str
# label: str
# value: str
# unit: Optional[str] = None
# trend: float
# trend_direction: Literal["up", "down", "-"] = Field(alias="trendDirection")
#
# model_config = ConfigDict(
# alias_generator=to_camel,
# populate_by_name=True,
# )
#
#
# class PlatformData(BaseModel):
# """플랫폼별 데이터 (미사용 — platform_data 기능 미구현)"""
#
# platform: Literal["youtube", "instagram"]
# display_name: str = Field(alias="displayName")
# metrics: list[PlatformMetric]
#
# model_config = ConfigDict(
# alias_generator=to_camel,
# populate_by_name=True,
# )
class DashboardResponse(BaseModel):
"""대시보드 전체 응답
@ -226,6 +255,7 @@ class DashboardResponse(BaseModel):
top_content: list[TopContent] = Field(alias="topContent")
audience_data: AudienceData = Field(alias="audienceData")
has_uploads: bool = Field(default=True, alias="hasUploads")
# platform_data: list[PlatformData] = Field(default=[], alias="platformData") # 미사용
model_config = ConfigDict(
alias_generator=to_camel,

View File

@ -4,12 +4,10 @@ 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

@ -143,8 +143,8 @@ class DataProcessor:
monthly_data = []
audience_data = self._build_audience_data(
raw_data.get("demographics") or {},
raw_data.get("region") or {},
raw_data.get("demographics", {}),
raw_data.get("region", {}),
)
logger.debug(
f"[DataProcessor.process] SUCCESS - "

View File

@ -141,9 +141,6 @@ class YouTubeAnalyticsService:
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(
@ -151,12 +148,6 @@ class YouTubeAnalyticsService:
)
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(

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,13 +1,12 @@
import time
import traceback
from typing import AsyncGenerator
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.utils.logger import get_logger
from config import db_settings
import traceback
logger = get_logger("database")
@ -75,17 +74,15 @@ async def create_db_tables():
# 모델 import (테이블 메타데이터 등록용)
from app.user.models import User, RefreshToken, SocialAccount # noqa: F401
from app.home.models import Image, Project, MarketingIntel, ImageTag # noqa: F401
from app.home.models import Image, Project, MarketingIntel # noqa: F401
from app.lyric.models import Lyric # noqa: F401
from app.song.models import Song, SongTimestamp # noqa: F401
from app.video.models import Video # noqa: F401
from app.sns.models import SNSUploadTask # noqa: F401
from app.social.models import SocialUpload # noqa: F401
from app.dashboard.models import Dashboard # noqa: F401
from app.backoffice.admin.models import Admin # noqa: F401
from app.credit.models import CreditChargeRequest, CreditTransaction # noqa: F401
# 생성할 테이블 목록 (FK 순서: 참조 대상 먼저)
# 생성할 테이블 목록
tables_to_create = [
User.__table__,
RefreshToken.__table__,
@ -100,10 +97,6 @@ async def create_db_tables():
SocialUpload.__table__,
MarketingIntel.__table__,
Dashboard.__table__,
ImageTag.__table__,
Admin.__table__,
CreditChargeRequest.__table__,
CreditTransaction.__table__,
]
logger.info("Creating database tables...")
@ -135,16 +128,15 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
# )
try:
yield session
except HTTPException:
raise
except Exception as e:
import traceback
await session.rollback()
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
raise e
finally:
total_time = time.perf_counter() - start_time
# logger.debug(
@ -172,8 +164,6 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
# )
try:
yield session
except HTTPException:
raise
except Exception as e:
await session.rollback()
logger.error(
@ -182,7 +172,7 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
)
logger.debug(traceback.format_exc())
raise
raise e
finally:
total_time = time.perf_counter() - start_time
# logger.debug(

View File

@ -9,10 +9,9 @@ import aiofiles
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import func, select
from app.database.session import get_session, AsyncSessionLocal
from app.home.models import Image, MarketingIntel, ImageTag
from app.home.models import Image, MarketingIntel
from app.user.dependencies.auth import get_current_user
from app.user.models import User
from app.home.schemas.home_schema import (
@ -30,53 +29,42 @@ from app.home.schemas.home_schema import (
)
from app.home.services.naver_search import naver_search_client
from app.utils.upload_blob_as_request import AzureBlobUploader
from app.utils.prompts.chatgpt_prompt import ChatgptService, ChatGPTResponseError
from app.utils.chatgpt_prompt import ChatgptService, ChatGPTResponseError
from app.utils.common import generate_task_id
from app.utils.logger import get_logger
from app.utils.nvMapScraper import NvMapScraper, GraphQLException, URLNotFoundException
from app.utils.nvMapScraper import NvMapScraper, GraphQLException
from app.utils.nvMapPwScraper import NvMapPwScraper
from app.utils.prompts.prompts import marketing_prompt
from app.utils.autotag import autotag_images
from config import MEDIA_ROOT
# 로거 설정
logger = get_logger("home")
# 전국 시/군 이름 목록 (roadAddress에서 region 추출용)
# 전국 시 이름 목록 (roadAddress에서 region 추출용)
# fmt: off
KOREAN_CITIES = [
# 특별시/광역시
"서울시", "부산시", "대구시", "인천시", "광주시", "대전시", "울산시", "세종시",
# 경기도
"수원시", "성남시", "고양시", "용인시", "부천시", "안산시", "안양시", "남양주시",
"화성시", "평택시", "의정부시", "시흥시", "파주시", "김포시", "광주시", "광명",
"군포시", "하남시", "오산시", "이천시", "안성시", "구리시", "양주시", "포천",
"여주시", "동두천시", "과천시", "가평군", "양평군", "연천군",
# 강원특별자치
"화성시", "평택시", "의정부시", "시흥시", "파주시", "광명시", "김포시", "군포",
"광주시", "이천시", "양주시", "오산시", "구리시", "안성시", "포천시", "의왕",
"하남시", "여주시", "동두천시", "과천시",
# 강원
"춘천시", "원주시", "강릉시", "동해시", "태백시", "속초시", "삼척시",
"홍천군", "횡성군", "영월군", "평창군", "정선군", "철원군", "화천군",
"양구군", "인제군", "고성군", "양양군",
# 충청북도
"청주시", "충주시", "제천시",
"보은군", "옥천군", "영동군", "증평군", "진천군", "괴산군", "음성군", "단양군",
# 충청남도
"천안시", "공주시", "보령시", "아산시", "서산시", "논산시", "계룡시", "당진시",
"금산군", "부여군", "서천군", "청양군", "홍성군", "예산군", "태안군",
# 전북특별자치도
# 전라북도
"전주시", "군산시", "익산시", "정읍시", "남원시", "김제시",
"완주군", "진안군", "무주군", "장수군", "임실군", "순창군", "고창군", "부안군",
# 전라남도
"목포시", "여수시", "순천시", "나주시", "광양시",
"담양군", "곡성군", "구례군", "고흥군", "보성군", "화순군", "장흥군", "강진군",
"해남군", "영암군", "무안군", "함평군", "영광군", "장성군", "완도군", "진도군", "신안군",
# 경상북도
"포항시", "경주시", "김천시", "안동시", "구미시", "영주시", "영천시", "상주시", "문경시", "경산시",
"의성군", "청송군", "영양군", "영덕군", "청도군", "고령군", "성주군", "칠곡군",
"예천군", "봉화군", "울진군", "울릉군",
# 경상남도
"창원시", "진주시", "통영시", "사천시", "김해시", "밀양시", "거제시", "양산시",
"의령군", "함안군", "창녕군", "고성군", "남해군", "하동군", "산청군", "함양군", "거창군", "합천군",
# 제주특별자치도
# 제주도
"제주시", "서귀포시",
]
# fmt: on
@ -126,39 +114,13 @@ async def search_accommodation(
)
METRO_CITY_MAP = {
"서울": "서울시", "부산": "부산시", "대구": "대구시",
"인천": "인천시", "광주": "광주시", "대전": "대전시",
"울산": "울산시", "세종": "세종시",
}
def _extract_region_from_address(road_address: str | None) -> str:
"""roadAddress에서 시/군 이름 추출
매칭 우선순위:
1. KOREAN_CITIES 직접 매칭 (/ 접미사 포함)
2. KOREAN_CITIES 접미사 생략 매칭
3. 주소 번째 토큰이 /군으로 끝나는 경우 (: "전북 군산시 ...")
4. 주소 번째 토큰이 /동인 경우 번째 토큰으로 광역시 매핑 (: "서울 강남구 ...")
"""
"""roadAddress에서 시 이름 추출"""
if not road_address:
return ""
for city in KOREAN_CITIES:
if city in road_address:
return city
if city[:-1] in road_address:
return city
tokens = road_address.split()
if len(tokens) >= 2:
second = tokens[1]
if second.endswith("") or second.endswith(""):
return second
if second.endswith("") or second.endswith(""):
return METRO_CITY_MAP.get(tokens[0], "")
return ""
@ -256,15 +218,6 @@ async def _crawling_logic(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"네이버 지도 크롤링에 실패했습니다: {e}",
)
except URLNotFoundException as e:
step1_elapsed = (time.perf_counter() - step1_start) * 1000
logger.error(
f"[crawling] Step 1 FAILED - 크롤링 실패: {e} ({step1_elapsed:.1f}ms)"
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Place ID를 확인할 수 없습니다. URL을 확인하세요. : {e}",
)
except Exception as e:
step1_elapsed = (time.perf_counter() - step1_start) * 1000
logger.error(
@ -290,7 +243,7 @@ async def _crawling_logic(
marketing_analysis = None
if scraper.base_info:
road_address = scraper.base_info.get("roadAddress", "") or scraper.base_info.get("address", "")
road_address = scraper.base_info.get("roadAddress", "")
customer_name = scraper.base_info.get("name", "")
region = _extract_region_from_address(road_address)
@ -498,6 +451,255 @@ IMAGES_JSON_EXAMPLE = """[
{"url": "https://naverbooking-phinf.pstatic.net/20240514_259/17156880311809wCnY_JPEG/5.jpg", "name": "외관"}
]"""
@router.post(
"/image/upload/server",
include_in_schema=False,
summary="이미지 업로드 (로컬 서버)",
description="""
이미지를 로컬 서버(media 폴더) 업로드하고 새로운 task_id를 생성합니다.
## 요청 방식
multipart/form-data 형식으로 전송합니다.
## 요청 필드
- **images_json**: 외부 이미지 URL 목록 (JSON 문자열, 선택)
- **files**: 이미지 바이너리 파일 목록 (선택)
**주의**: images_json 또는 files 최소 하나는 반드시 전달해야 합니다.
## 지원 이미지 확장자
jpg, jpeg, png, webp, heic, heif
## images_json 예시
```json
[
{"url": "https://naverbooking-phinf.pstatic.net/20240514_189/1715688030436xT14o_JPEG/1.jpg"},
{"url": "https://naverbooking-phinf.pstatic.net/20240514_48/1715688030574wTtQd_JPEG/2.jpg"},
{"url": "https://naverbooking-phinf.pstatic.net/20240514_92/17156880307484bvpH_JPEG/3.jpg"},
{"url": "https://naverbooking-phinf.pstatic.net/20240514_7/1715688031000y8Y5q_JPEG/4.jpg"},
{"url": "https://naverbooking-phinf.pstatic.net/20240514_259/17156880311809wCnY_JPEG/5.jpg", "name": "외관"}
]
```
## 바이너리 파일 업로드 테스트 방법
### 1. Swagger UI에서 테스트
1. 엔드포인트의 "Try it out" 버튼 클릭
2. task_id 입력 (: test-task-001)
3. files 항목에서 "Add item" 클릭하여 로컬 이미지 파일 선택
4. (선택) images_json에 URL 목록 JSON 입력
5. "Execute" 버튼 클릭
### 2. cURL로 테스트
```bash
# 바이너리 파일만 업로드
curl -X POST "http://localhost:8000/image/upload/server/test-task-001" \\
-F "files=@/path/to/image1.jpg" \\
-F "files=@/path/to/image2.png"
# URL + 바이너리 파일 동시 업로드
curl -X POST "http://localhost:8000/image/upload/server/test-task-001" \\
-F 'images_json=[{"url":"https://example.com/image.jpg"}]' \\
-F "files=@/path/to/local_image.jpg"
```
### 3. Python requests로 테스트
```python
import requests
url = "http://localhost:8000/image/upload/server/test-task-001"
files = [
("files", ("image1.jpg", open("image1.jpg", "rb"), "image/jpeg")),
("files", ("image2.png", open("image2.png", "rb"), "image/png")),
]
data = {
"images_json": '[{"url": "https://example.com/image.jpg"}]'
}
response = requests.post(url, files=files, data=data)
print(response.json())
```
## 반환 정보
- **task_id**: 작업 고유 식별자
- **total_count**: 업로드된 이미지 개수
- **url_count**: URL로 등록된 이미지 개수 (Image 테이블에 외부 URL 그대로 저장)
- **file_count**: 파일로 업로드된 이미지 개수 (media 폴더에 저장)
- **saved_count**: Image 테이블에 저장된 row
- **images**: 업로드된 이미지 목록
- **source**: "url" (외부 URL) 또는 "file" (로컬 서버 저장)
## 저장 경로
- 바이너리 파일: /media/image/{날짜}/{uuid7}/{파일명}
- URL 이미지: 외부 URL 그대로 Image 테이블에 저장
## 반환 정보
- **task_id**: 새로 생성된 작업 고유 식별자
- **image_urls**: Image 테이블에 저장된 현재 task_id의 이미지 URL 목록
""",
response_model=ImageUploadResponse,
responses={
200: {"description": "이미지 업로드 성공"},
400: {"description": "이미지가 제공되지 않음", "model": ErrorResponse},
},
tags=["Image-Server"],
)
async def upload_images(
images_json: Optional[str] = Form(
default=None,
description="외부 이미지 URL 목록 (JSON 문자열)",
examples=[IMAGES_JSON_EXAMPLE],
),
files: Optional[list[UploadFile]] = File(
default=None, description="이미지 바이너리 파일 목록"
),
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> ImageUploadResponse:
"""이미지 업로드 (URL + 바이너리 파일)"""
# task_id 생성
task_id = await generate_task_id()
# 1. 진입 검증: images_json 또는 files 중 하나는 반드시 있어야 함
has_images_json = images_json is not None and images_json.strip() != ""
has_files = files is not None and len(files) > 0
if not has_images_json and not has_files:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="images_json 또는 files 중 하나는 반드시 제공해야 합니다.",
)
# 2. images_json 파싱 (있는 경우만)
url_images: list[ImageUrlItem] = []
if has_images_json:
try:
parsed = json.loads(images_json)
if isinstance(parsed, list):
url_images = [ImageUrlItem(**item) for item in parsed if item]
except (json.JSONDecodeError, TypeError, ValueError) as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"images_json 파싱 오류: {str(e)}",
)
# 3. 유효한 파일만 필터링 (빈 파일, 유효한 이미지 확장자가 아닌 경우 제외)
valid_files: list[UploadFile] = []
skipped_files: list[str] = []
if has_files and files:
for f in files:
is_valid_ext = _is_valid_image_extension(f.filename)
is_not_empty = (
f.size is None or f.size > 0
) # size가 None이면 아직 읽지 않은 것
is_real_file = (
f.filename and f.filename != "filename"
) # Swagger 빈 파일 체크
if f and is_real_file and is_valid_ext and is_not_empty:
valid_files.append(f)
else:
skipped_files.append(f.filename or "unknown")
# 유효한 데이터가 하나도 없으면 에러
if not url_images and not valid_files:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"유효한 이미지가 없습니다. 지원 확장자: {', '.join(ALLOWED_IMAGE_EXTENSIONS)}. 건너뛴 파일: {skipped_files}",
)
result_images: list[ImageUploadResultItem] = []
img_order = 0
# 1. URL 이미지 저장
for url_item in url_images:
img_name = url_item.name or _extract_image_name(url_item.url, img_order)
image = Image(
task_id=task_id,
img_name=img_name,
img_url=url_item.url,
img_order=img_order,
)
session.add(image)
await session.flush() # ID 생성을 위해 flush
result_images.append(
ImageUploadResultItem(
id=image.id,
img_name=img_name,
img_url=url_item.url,
img_order=img_order,
source="url",
)
)
img_order += 1
# 2. 바이너리 파일을 media에 저장
if valid_files:
today = date.today().strftime("%Y-%m-%d")
# 한 번의 요청에서 업로드된 모든 이미지는 같은 폴더에 저장
batch_uuid = await generate_task_id()
upload_dir = MEDIA_ROOT / "image" / today / batch_uuid
upload_dir.mkdir(parents=True, exist_ok=True)
for file in valid_files:
# 파일명: 원본 파일명 사용 (중복 방지를 위해 순서 추가)
original_name = file.filename or "image"
ext = _get_file_extension(file.filename) # type: ignore[arg-type]
# 파일명에서 확장자 제거 후 순서 추가
name_without_ext = (
original_name.rsplit(".", 1)[0]
if "." in original_name
else original_name
)
filename = f"{name_without_ext}_{img_order:03d}{ext}"
save_path = upload_dir / filename
# media에 파일 저장
await _save_upload_file(file, save_path)
# media 기준 URL 생성
img_url = f"/media/image/{today}/{batch_uuid}/{filename}"
img_name = file.filename or filename
image = Image(
task_id=task_id,
img_name=img_name,
img_url=img_url, # Media URL을 DB에 저장
img_order=img_order,
)
session.add(image)
await session.flush()
result_images.append(
ImageUploadResultItem(
id=image.id,
img_name=img_name,
img_url=img_url,
img_order=img_order,
source="file",
)
)
img_order += 1
saved_count = len(result_images)
await session.commit()
# Image 테이블에서 현재 task_id의 이미지 URL 목록 조회
image_urls = [img.img_url for img in result_images]
return ImageUploadResponse(
task_id=task_id,
total_count=len(result_images),
url_count=len(url_images),
file_count=len(valid_files),
saved_count=saved_count,
images=result_images,
image_urls=image_urls,
)
@router.post(
"/image/upload/blob",
summary="이미지 업로드 (Azure Blob Storage)",
@ -786,10 +988,6 @@ async def upload_images_blob(
saved_count = len(result_images)
image_urls = [img.img_url for img in result_images]
logger.info(f"[image_tagging] START - task_id: {task_id}")
await tagging_images(image_urls, clear_old_tags=True)
logger.info(f"[image_tagging] Done - task_id: {task_id}")
total_time = time.perf_counter() - request_start
logger.info(
f"[upload_images_blob] SUCCESS - task_id: {task_id}, "
@ -805,42 +1003,3 @@ async def upload_images_blob(
images=result_images,
image_urls=image_urls,
)
async def tagging_images(
image_urls : list[str],
clear_old_tags : bool = False
) -> None:
# 1. 조회
async with AsyncSessionLocal() as session:
stmt = (
select(ImageTag)
.where(ImageTag.img_url_hash.in_([func.crc32(url) for url in image_urls]))
.where(ImageTag.img_url.in_(image_urls))
)
image_tags_query_results = await session.execute(stmt)
image_tags = image_tags_query_results.scalars().all()
existing_urls = {tag.img_url for tag in image_tags}
new_imt = [
ImageTag(img_url=url, img_tag=None)
for url in image_urls
if url not in existing_urls
]
if clear_old_tags:
for tag in image_tags:
tag.img_tag = None
session.add_all(new_imt)
null_imts = [imt for imt in image_tags if imt.img_tag is None] + new_imt
await session.commit()
if null_imts:
tag_datas = await autotag_images([img.img_url for img in null_imts])
print(tag_datas)
async with AsyncSessionLocal() as session:
for tag, tag_data in zip(null_imts, tag_datas):
if isinstance(tag_data, Exception):
continue
tag.img_tag = tag_data.model_dump(mode="json")
session.add(tag)
await session.commit()

View File

@ -9,8 +9,7 @@ Home 모듈 SQLAlchemy 모델 정의
from datetime import datetime
from typing import TYPE_CHECKING, List, Optional, Any
from sqlalchemy import Boolean, DateTime, ForeignKey, Computed, Index, Integer, String, Text, JSON, func
from sqlalchemy.dialects.mysql import INTEGER
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, JSON, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base
@ -315,50 +314,13 @@ class MarketingIntel(Base):
)
def __repr__(self) -> str:
return (
f"<MarketingIntel(id={self.id}, place_id='{self.place_id}')>"
task_id_str = (
(self.task_id[:10] + "...") if len(self.task_id) > 10 else self.task_id
)
img_name_str = (
(self.img_name[:10] + "...") if len(self.img_name) > 10 else self.img_name
)
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(
JSON,
nullable=True,
default=False,
comment="태그 JSON",
)
return (
f"<Image(id={self.id}, task_id='{task_id_str}', img_name='{img_name_str}')>"
)

View File

@ -40,10 +40,9 @@ from app.lyric.schemas.lyric import (
LyricDetailResponse,
LyricListItem,
LyricStatusResponse,
SubtitleStatusResponse,
)
from app.lyric.worker.lyric_task import generate_lyric_background, generate_subtitle_background
from app.utils.prompts.chatgpt_prompt import ChatgptService
from app.utils.chatgpt_prompt import ChatgptService
from app.utils.logger import get_logger
from app.utils.pagination import PaginatedResponse, get_paginated
@ -241,21 +240,6 @@ async def generate_lyric(
request_start = time.perf_counter()
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(
@ -269,6 +253,17 @@ async def generate_lyric(
step1_start = time.perf_counter()
logger.debug(f"[generate_lyric] Step 1: 서비스 초기화 및 프롬프트 생성...")
# service = ChatgptService(
# customer_name=request_body.customer_name,
# region=request_body.region,
# detail_region_info=request_body.detail_region_info or "",
# language=request_body.language,
# )
# prompt = service.build_lyrics_prompt()
# 원래는 실제 사용할 프롬프트가 들어가야 하나, 로직이 변경되어 이 시점에서 이곳에서 프롬프트를 생성할 이유가 없어서 삭제됨.
# 기존 코드와의 호환을 위해 동일한 로직으로 프롬프트 생성
promotional_expressions = {
"Korean" : "인스타 감성, 사진같은 하루, 힐링, 여행, 감성 숙소",
"English" : "Instagram vibes, picture-perfect day, healing, travel, getaway",
@ -357,26 +352,18 @@ async def generate_lyric(
step4_start = time.perf_counter()
logger.debug(f"[generate_lyric] Step 4: 백그라운드 태스크 스케줄링...")
orientation = request_body.orientation
if request_body.instrumental:
# BGM 모드: ChatGPT 가사 생성 없이 Lyric을 즉시 completed로 마무리
lyric.status = "completed"
lyric.lyric_result = ""
await session.commit()
logger.info(f"[generate_lyric] BGM 모드 - 가사 생성 스킵, lyric_id: {lyric.id}")
else:
background_tasks.add_task(
generate_lyric_background,
task_id=task_id,
prompt=lyric_prompt,
lyric_input_data=lyric_input_data,
lyric_id=lyric.id,
)
background_tasks.add_task(
generate_lyric_background,
task_id=task_id,
prompt=lyric_prompt,
lyric_input_data=lyric_input_data,
lyric_id=lyric.id,
)
background_tasks.add_task(
generate_subtitle_background,
orientation=orientation,
task_id=task_id,
orientation = orientation,
task_id=task_id
)
step4_elapsed = (time.perf_counter() - step4_start) * 1000
@ -516,86 +503,6 @@ async def list_lyrics(
)
@router.get(
"/subtitle/status/{task_id}",
summary="자막 생성 상태 조회",
description="""
자막(subtitle) 생성 완료 여부를 조회합니다.
## 인증
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
## 경로 파라미터
- **task_id**: 프로젝트 task_id (필수)
## 상태 값
- **pending**: 자막 생성 진행 잠시 재요청
- **completed**: 자막 생성 완료 `/video/generate/{task_id}` 호출 가능
## 사용 예시 (cURL)
```bash
curl -X GET "http://localhost:8000/lyric/subtitle/status/019123ab-cdef-7890-abcd-ef1234567890" \\
-H "Authorization: Bearer {access_token}"
```
## 참고
- 자막은 `/lyric/generate` 호출 백그라운드에서 자동 생성됩니다.
- 클라이언트는 `completed` 상태 확인 `/video/generate` 호출해야 합니다.
""",
response_model=SubtitleStatusResponse,
responses={
200: {"description": "상태 조회 성공"},
401: {"description": "인증 실패 (토큰 없음/만료)"},
404: {"description": "해당 task_id에 해당하는 프로젝트를 찾을 수 없음"},
},
)
async def get_subtitle_status(
task_id: str,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> SubtitleStatusResponse:
"""task_id로 자막 생성 상태를 조회합니다."""
logger.info(f"[get_subtitle_status] START - task_id: {task_id}")
project_result = await session.execute(
select(Project)
.where(Project.task_id == task_id)
.order_by(Project.created_at.desc())
.limit(1)
)
project = project_result.scalar_one_or_none()
if not project:
raise HTTPException(
status_code=404,
detail=f"task_id '{task_id}'에 해당하는 프로젝트를 찾을 수 없습니다.",
)
marketing_result = await session.execute(
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
)
intel = marketing_result.scalar_one_or_none()
if not intel:
raise HTTPException(
status_code=404,
detail=f"task_id '{task_id}'에 해당하는 마케팅 인텔리전스를 찾을 수 없습니다.",
)
if intel.subtitle:
logger.info(f"[get_subtitle_status] completed - task_id: {task_id}")
return SubtitleStatusResponse(
task_id=task_id,
status="completed",
message="자막 생성이 완료되었습니다.",
)
logger.info(f"[get_subtitle_status] pending - task_id: {task_id}")
return SubtitleStatusResponse(
task_id=task_id,
status="pending",
message="자막 생성이 진행 중입니다. 잠시 후 다시 확인해주세요.",
)
@router.get(
"/{task_id}",
summary="가사 상세 조회",

View File

@ -42,7 +42,7 @@ class GenerateLyricRequest(BaseModel):
"region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을",
"language": "Korean",
"m_id" : 2,
"m_id" : 1,
"orientation" : "vertical"
}
"""
@ -76,7 +76,6 @@ class GenerateLyricRequest(BaseModel):
description="영상 방향 (horizontal: 가로형, vertical: 세로형)",
),
m_id : Optional[int] = Field(None, description="마케팅 인텔리전스 ID 값")
instrumental: bool = Field(default=False, description="BGM 전용 모드 (가사 생성 안 함)")
class GenerateLyricResponse(BaseModel):
@ -202,55 +201,6 @@ class LyricDetailResponse(BaseModel):
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):
"""가사 목록 아이템 스키마

View File

@ -13,7 +13,7 @@ from sqlalchemy.exc import SQLAlchemyError
from app.database.session import BackgroundSessionLocal
from app.home.models import Image, Project, MarketingIntel
from app.lyric.models import Lyric
from app.utils.prompts.chatgpt_prompt import ChatgptService, ChatGPTResponseError
from app.utils.chatgpt_prompt import ChatgptService, ChatGPTResponseError
from app.utils.subtitles import SubtitleContentsGenerator
from app.utils.creatomate import CreatomateService
from app.utils.prompts.prompts import Prompt
@ -104,6 +104,13 @@ async def generate_lyric_background(
step1_start = time.perf_counter()
logger.debug(f"[generate_lyric_background] Step 1: ChatGPT 서비스 초기화...")
# service = ChatgptService(
# customer_name="", # 프롬프트가 이미 생성되었으므로 빈 값
# region="",
# detail_region_info="",
# language=language,
# )
chatgpt = ChatgptService()
step1_elapsed = (time.perf_counter() - step1_start) * 1000
@ -158,67 +165,52 @@ async def generate_lyric_background(
async def generate_subtitle_background(
orientation: str,
task_id: str,
max_retries: int = 3,
task_id: str
) -> None:
logger.info(f"[generate_subtitle_background] START - task_id: {task_id}, orientation: {orientation}")
logger.info(f"[generate_subtitle_background] task_id: {task_id}, {orientation}")
creatomate_service = CreatomateService(orientation=orientation)
template = await creatomate_service.get_one_template_data_async(creatomate_service.template_id)
pitchings = creatomate_service.extract_text_format_from_template(template)
for attempt in range(1, max_retries + 1):
try:
creatomate_service = CreatomateService(orientation=orientation)
template = await creatomate_service.get_one_template_data(creatomate_service.template_id)
pitchings = creatomate_service.extract_text_format_from_template(template)
subtitle_generator = SubtitleContentsGenerator()
subtitle_generator = SubtitleContentsGenerator()
async with BackgroundSessionLocal() as session:
project_result = await session.execute(
select(Project)
.where(Project.task_id == task_id)
.order_by(Project.created_at.desc())
.limit(1)
)
project = project_result.scalar_one_or_none()
marketing_result = await session.execute(
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
)
marketing_intelligence = marketing_result.scalar_one_or_none()
async with BackgroundSessionLocal() as session:
project_result = await session.execute(
select(Project)
.where(Project.task_id == task_id)
.order_by(Project.created_at.desc())
.limit(1)
)
project = project_result.scalar_one_or_none()
marketing_result = await session.execute(
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
)
marketing_intelligence = marketing_result.scalar_one_or_none()
store_address = project.detail_region_info
customer_name = project.store_name
logger.info(f"[generate_subtitle_background] customer_name: {customer_name}, {store_address}")
store_address = project.detail_region_info
customer_name = project.store_name
logger.info(f"[generate_subtitle_background] customer_name: {customer_name}, store_address: {store_address}")
generated_subtitles = await subtitle_generator.generate_subtitle_contents(
marketing_intelligence = marketing_intelligence.intel_result,
pitching_label_list = pitchings,
customer_name = customer_name,
detail_region_info = store_address,
)
pitching_output_list = generated_subtitles.pitching_results
generated_subtitles = await subtitle_generator.generate_subtitle_contents(
marketing_intelligence=marketing_intelligence.intel_result,
pitching_label_list=pitchings,
customer_name=customer_name,
detail_region_info=store_address,
)
pitching_output_list = generated_subtitles.pitching_results
subtitle_modifications = {pitching_output.pitching_tag : pitching_output.pitching_data for pitching_output in pitching_output_list}
logger.info(f"[generate_subtitle_background] subtitle_modifications: {subtitle_modifications}")
subtitle_modifications = {
pitching_output.pitching_tag: pitching_output.pitching_data
for pitching_output in pitching_output_list
}
logger.info(f"[generate_subtitle_background] subtitle_modifications: {subtitle_modifications}")
async with BackgroundSessionLocal() as session:
marketing_result = await session.execute(
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
)
marketing_intelligence = marketing_result.scalar_one_or_none()
marketing_intelligence.subtitle = subtitle_modifications
await session.commit()
logger.info(f"[generate_subtitle_background] task_id: {task_id} DONE")
async with BackgroundSessionLocal() as session:
marketing_result = await session.execute(
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
)
marketing_intelligence = marketing_result.scalar_one_or_none()
marketing_intelligence.subtitle = subtitle_modifications
await session.commit()
logger.info(f"[generate_subtitle_background] DONE - task_id: {task_id} (attempt {attempt}/{max_retries})")
return
except Exception as e:
logger.error(
f"[generate_subtitle_background] FAILED (attempt {attempt}/{max_retries}) - task_id: {task_id}, error: {e}",
exc_info=True,
)
if attempt < max_retries:
logger.info(f"[generate_subtitle_background] 재시도 중... ({attempt + 1}/{max_retries}) - task_id: {task_id}")
logger.error(f"[generate_subtitle_background] 모든 재시도 실패 - task_id: {task_id}")
return

View File

@ -59,7 +59,7 @@ class YouTubeOAuthClient(BaseOAuthClient):
"response_type": "code",
"scope": " ".join(YOUTUBE_SCOPES),
"access_type": "offline", # refresh_token 받기 위해 필요
"prompt": "consent", # 항상 동의 화면 표시하여 refresh_token 발급 보장
"prompt": "select_account", # 계정 선택만 표시 (동의 화면은 최초 1회만)
"state": state,
}
url = f"{self.AUTHORIZATION_URL}?{urlencode(params)}"

View File

@ -122,7 +122,6 @@ class SocialUploadHistoryItem(BaseModel):
platform: str = Field(..., description="플랫폼명")
status: str = Field(..., description="업로드 상태")
title: str = Field(..., description="영상 제목")
platform_username: Optional[str] = Field(None, description="플랫폼 채널명")
platform_url: Optional[str] = Field(None, description="플랫폼 영상 URL")
error_message: Optional[str] = Field(None, description="에러 메시지")
scheduled_at: Optional[datetime] = Field(None, description="예약 게시 시간")

View File

@ -306,7 +306,7 @@ class SocialAccountService:
else:
# DB datetime은 naive, now()는 aware이므로 naive로 통일하여 비교
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:
should_refresh = True

View File

@ -17,7 +17,7 @@ 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.chatgpt_prompt import ChatgptService
from app.utils.prompts.prompts import yt_upload_prompt
logger = logging.getLogger(__name__)

View File

@ -291,7 +291,6 @@ class SocialUploadService:
platform=upload.platform,
status=upload.status,
title=upload.title,
platform_username=upload.social_account.platform_data.get("display_name") if upload.social_account and upload.social_account.platform_data else None,
platform_url=upload.platform_url,
error_message=upload.error_message,
scheduled_at=upload.scheduled_at,

View File

@ -103,22 +103,6 @@ async def generate_song(
from app.database.session import AsyncSessionLocal
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(
f"[generate_song] START - task_id: {task_id}, "
f"genre: {request_body.genre}, language: {request_body.language}"
@ -185,10 +169,9 @@ async def generate_song(
)
# Song 테이블에 초기 데이터 저장
if request_body.instrumental:
song_prompt = f"[Instrumental]\n[Genre]\n{request_body.genre}"
else:
song_prompt = f"[Lyrics]\n{request_body.lyrics}\n\n[Genre]\n{request_body.genre}"
song_prompt = (
f"[Lyrics]\n{request_body.lyrics}\n\n[Genre]\n{request_body.genre}"
)
logger.debug(
f"[generate_song] Lyrics comparison - task_id: {task_id}\n"
f"{'=' * 60}\n"
@ -250,7 +233,6 @@ async def generate_song(
suno_task_id = await suno_service.generate(
prompt=request_body.lyrics,
genre=request_body.genre,
instrumental=request_body.instrumental,
)
stage2_time = time.perf_counter()
@ -454,8 +436,14 @@ async def get_song_status(
)
suno_audio_id = first_clip.get("id")
# BGM 모드(lyric_result가 비어 있음)에서는 타임스탬프 생성 스킵
word_data = await suno_service.get_lyric_timestamp(
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(
select(Lyric)
.where(Lyric.task_id == song.task_id)
@ -463,60 +451,51 @@ async def get_song_status(
.limit(1)
)
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:
word_data = await suno_service.get_lyric_timestamp(
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_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
)
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
# TODO : DB upload timestamped_lyrics
for order_idx, timestamped_lyric in enumerate(
timestamped_lyrics
):
# start_sec 또는 end_sec가 None인 경우 건너뛰기
if (
timestamped_lyric["start_sec"] is None
or timestamped_lyric["end_sec"] is None
):
if (
timestamped_lyric["start_sec"] is None
or timestamped_lyric["end_sec"] is None
):
logger.warning(
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"],
logger.warning(
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']}"
)
session.add(song_timestamp)
else:
logger.info(
f"[get_song_status] BGM 모드 - 타임스탬프 생성 스킵, "
f"suno_task_id: {suno_task_id}"
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)
await session.commit()
parsed_response.status = "processing"

View File

@ -1,6 +1,6 @@
from typing import Optional
from pydantic import BaseModel, Field, model_validator
from pydantic import BaseModel, Field
# =============================================================================
@ -33,7 +33,7 @@ class GenerateSongRequest(BaseModel):
}
}
lyrics: Optional[str] = Field(None, description="노래에 사용할 가사 (instrumental=True이면 생략 가능)")
lyrics: str = Field(..., description="노래에 사용할 가사")
genre: str = Field(
...,
description="음악 장르 (K-Pop, Pop, R&B, Hip-Hop, Ballad, EDM, Rock, Jazz 등)",
@ -42,15 +42,6 @@ class GenerateSongRequest(BaseModel):
default="Korean",
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):

View File

@ -7,14 +7,14 @@ from sqlalchemy import Connection, text
from sqlalchemy.exc import SQLAlchemyError
from app.utils.logger import get_logger
from app.lyric.schemas.lyrics_schema import (
from app.lyrics.schemas.lyrics_schema import (
AttributeData,
PromptTemplateData,
SongFormData,
SongSampleData,
StoreData,
)
from app.utils.prompts.chatgpt_prompt import chatgpt_api
from app.utils.chatgpt_prompt import chatgpt_api
logger = get_logger("song")

View File

@ -23,7 +23,6 @@ logger = logging.getLogger(__name__)
from app.user.dependencies import get_current_user
from app.user.models import RefreshToken, User
from app.user.schemas.user_schema import (
CreditResponse,
KakaoCodeRequest,
KakaoLoginResponse,
LoginResponse,
@ -354,22 +353,6 @@ async def get_me(
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에서 라우터가 등록됨)
# =============================================================================

View File

@ -1,35 +1,20 @@
import logging
from sqladmin import ModelView
from sqladmin import ModelView, action
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__)
from app.user.models import RefreshToken, SocialAccount, User
class UserAdmin(SuperAdminEditable, ModelView, model=User):
class UserAdmin(ModelView, model=User):
name = "사용자"
name_plural = "사용자 목록"
icon = "fa-solid fa-user"
category = "사용자 관리"
page_size = 30
can_edit = True
can_delete = True
page_size = 20
column_list = [
"id",
"user_uuid",
"kakao_id",
"email",
"nickname",
"credits",
"role",
"is_active",
"is_deleted",
@ -38,7 +23,7 @@ class UserAdmin(SuperAdminEditable, ModelView, model=User):
column_details_list = [
"id",
"user_uuid",
"kakao_id",
"email",
"nickname",
"profile_image_url",
@ -47,7 +32,6 @@ class UserAdmin(SuperAdminEditable, ModelView, model=User):
"name",
"birth_date",
"gender",
"credits",
"is_active",
"is_admin",
"role",
@ -58,22 +42,16 @@ class UserAdmin(SuperAdminEditable, ModelView, model=User):
"updated_at",
]
form_columns = [
"nickname",
"email",
"phone",
"name",
"birth_date",
"gender",
"credits",
"is_active",
"is_admin",
"role",
"is_deleted",
form_excluded_columns = [
"created_at",
"updated_at",
"projects",
"refresh_tokens",
"social_accounts",
]
column_searchable_list = [
User.user_uuid,
User.kakao_id,
User.email,
User.nickname,
User.phone,
@ -84,10 +62,9 @@ class UserAdmin(SuperAdminEditable, ModelView, model=User):
column_sortable_list = [
User.id,
User.user_uuid,
User.kakao_id,
User.email,
User.nickname,
User.credits,
User.role,
User.is_active,
User.is_deleted,
@ -96,16 +73,15 @@ class UserAdmin(SuperAdminEditable, ModelView, model=User):
column_labels = {
"id": "ID",
"user_uuid": "UUID",
"kakao_id": "카카오 ID",
"email": "이메일",
"nickname": "닉네임",
"profile_image_url": "프로필 이미지",
"thumbnail_image_url": "썸네일 이미지",
"phone": "전화번호",
"name": "이름",
"name": "실명",
"birth_date": "생년월일",
"gender": "성별",
"credits": "크레딧",
"is_active": "활성화",
"is_admin": "관리자",
"role": "권한",
@ -116,71 +92,71 @@ class UserAdmin(SuperAdminEditable, ModelView, model=User):
"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(
name="02_unblock_user",
label="차단 해제",
confirmation_message="선택한 사용자의 차단을 해제하시겠습니까?",
add_in_list=True,
)
async def seq_e_unblock_user_action(self, request: Request) -> RedirectResponse:
return await handle_block_users(request, self.identity, block=False)
class RefreshTokenAdmin(ModelView, model=RefreshToken):
name = "리프레시 토큰"
name_plural = "리프레시 토큰 목록"
icon = "fa-solid fa-key"
category = "사용자 관리"
page_size = 20
@action(
name="03_grant_credits_1",
label="크레딧 +1",
confirmation_message="선택한 사용자에게 크레딧 1개를 충전하시겠습니까?",
add_in_list=True,
)
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)
column_list = [
"id",
"user_id",
"is_revoked",
"expires_at",
"created_at",
]
@action(
name="04_grant_credits_5",
label="크레딧 +5",
confirmation_message="선택한 사용자에게 크레딧 5개를 충전하시겠습니까?",
add_in_list=True,
)
async def seq_c_grant_credits_5_action(self, request: Request) -> RedirectResponse:
admin_id = request.session.get("admin_id")
return await handle_grant_credits(request, self.identity, amount=5, admin_id=admin_id)
column_details_list = [
"id",
"user_id",
"token_hash",
"expires_at",
"is_revoked",
"created_at",
"revoked_at",
"user_agent",
"ip_address",
]
@action(
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)
form_excluded_columns = ["created_at", "user"]
@action(
name="06_deduct_credits_1",
label="크레딧 -1",
confirmation_message="선택한 사용자의 크레딧 1개를 차감하시겠습니까?",
add_in_list=True,
)
async def seq_a_deduct_credits_1_action(self, request: Request) -> RedirectResponse:
admin_id = request.session.get("admin_id")
return await handle_deduct_credits(request, self.identity, amount=1, admin_id=admin_id)
column_searchable_list = [
RefreshToken.user_id,
RefreshToken.token_hash,
RefreshToken.ip_address,
]
column_default_sort = (RefreshToken.created_at, True)
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_plural = "소셜 계정 목록"
icon = "fa-solid fa-share-nodes"
category = "사용자 관리"
page_size = 30
page_size = 20
column_list = [
"id",
@ -198,6 +174,8 @@ class SocialAccountAdmin(ViewerAccessible, ModelView, model=SocialAccount):
"platform",
"platform_user_id",
"platform_username",
"platform_data",
"scope",
"token_expires_at",
"is_active",
"is_deleted",

View File

@ -18,11 +18,10 @@ from app.user.services.auth import (
AdminRequiredError,
InvalidTokenError,
MissingTokenError,
TokenExpiredError,
UserInactiveError,
UserNotFoundError,
)
from app.user.services.jwt import decode_token, is_token_expired
from app.user.services.jwt import decode_token
logger = logging.getLogger(__name__)
@ -59,9 +58,6 @@ async def get_current_user(
payload = decode_token(token)
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()

View File

@ -16,10 +16,7 @@ from app.database.session import Base
if TYPE_CHECKING:
from app.comment.models import Comment
from app.credit.models import CreditChargeRequest, CreditTransaction
from app.home.models import Project
from app.video.models import VideoReaction
class User(Base):
@ -219,14 +216,6 @@ class User(Base):
comment="마지막 로그인 일시",
)
credits: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=3,
server_default="3",
comment="잔여 영상 생성 크레딧",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
@ -279,38 +268,6 @@ class User(Base):
lazy="selectin",
)
credit_requests: Mapped[List["CreditChargeRequest"]] = relationship(
"CreditChargeRequest",
foreign_keys="CreditChargeRequest.user_uuid",
primaryjoin="User.user_uuid == CreditChargeRequest.user_uuid",
back_populates="user",
cascade="all, delete-orphan",
lazy="noload",
)
credit_transactions: Mapped[List["CreditTransaction"]] = relationship(
"CreditTransaction",
foreign_keys="CreditTransaction.user_uuid",
primaryjoin="User.user_uuid == CreditTransaction.user_uuid",
back_populates="user",
cascade="all, delete-orphan",
lazy="noload",
)
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:
return (
f"<User("

View File

@ -160,22 +160,6 @@ class LoginResponse(BaseModel):
}
}
# =============================================================================
# 크레딧 스키마
# =============================================================================
class CreditResponse(BaseModel):
"""잔여 크레딧 응답"""
credits: int = Field(..., description="영상 생성 크레딧")
model_config = {
"json_schema_extra": {
"example": {
"credits": 3
}
}
}
# =============================================================================
# 내부 사용 스키마 (카카오 API 응답 파싱)

View File

@ -92,7 +92,6 @@ from app.user.services.jwt import (
get_access_token_expire_seconds,
get_refresh_token_expires_at,
get_token_hash,
is_token_expired,
)
from app.user.services.kakao import kakao_client
@ -213,9 +212,6 @@ class AuthService:
# 1. 토큰 디코딩 및 검증
payload = decode_token(refresh_token)
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()

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

@ -116,28 +116,6 @@ def decode_token(token: str) -> Optional[dict]:
return None
def is_token_expired(token: str) -> bool:
"""
토큰이 만료됐는지 확인 (서명/형식은 유효하지만 exp 초과인 경우)
Returns:
True: 서명은 유효하나 만료된 토큰, False: 형식/서명 자체가 잘못된 토큰
"""
try:
payload = jwt.decode(
token,
jwt_settings.JWT_SECRET,
algorithms=[jwt_settings.JWT_ALGORITHM],
options={"verify_exp": False},
)
exp = payload.get("exp")
if exp is None:
return False
return datetime.fromtimestamp(exp) < datetime.now()
except JWTError:
return False
def get_token_hash(token: str) -> str:
"""
토큰의 SHA-256 해시값 생성

View File

@ -1,48 +0,0 @@
from pydantic.main import BaseModel
from app.utils.prompts.chatgpt_prompt import ChatgptService
from app.utils.prompts.prompts import image_autotag_prompt
from app.utils.prompts.schemas import SpaceType, Subject, Camera, MotionRecommended
import asyncio
async def autotag_image(image_url : str) -> list[str]: #tag_list
chatgpt = ChatgptService(model_type="gemini")
image_input_data = {
"img_url" : image_url,
"space_type" : list(SpaceType),
"subject" : list(Subject),
"camera" : list(Camera),
"motion_recommended" : list(MotionRecommended)
}
image_result = await chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_url, False)
return image_result
async def autotag_images(image_url_list : list[str]) -> list[dict]: #tag_list
chatgpt = ChatgptService(model_type="gemini")
image_input_data_list = [{
"img_url" : image_url,
"space_type" : list(SpaceType),
"subject" : list(Subject),
"camera" : list(Camera),
"motion_recommended" : list(MotionRecommended)
}for image_url in image_url_list]
image_result_tasks = [chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_input_data['img_url'], False, silent = True) for image_input_data in image_input_data_list]
image_result_list: list[BaseModel | BaseException] = await asyncio.gather(*image_result_tasks, return_exceptions=True)
MAX_RETRY = 2 # 하드코딩, 어떻게 처리할지는 나중에
for _ in range(MAX_RETRY):
failed_idx = [i for i, r in enumerate(image_result_list) if isinstance(r, Exception)]
print("Failed", failed_idx)
if not failed_idx:
break
retried = await asyncio.gather(
*[chatgpt.generate_structured_output(image_autotag_prompt, image_input_data_list[i], image_input_data_list[i]['img_url'], False, silent=True) for i in failed_idx],
return_exceptions=True
)
for i, result in zip(failed_idx, retried):
image_result_list[i] = result
print("Failed", failed_idx)
return image_result_list

View File

@ -1,63 +0,0 @@
"""
BGM 모드용 더미 가사 템플릿
instrumental=True 호출 Suno가 가사 길이/구조를 참고해 60초짜리 BGM을 생성하도록
placeholder 가사를 제공합니다. 실제 보컬은 생성되지 않습니다.
3가지 버전 모두 섹션 태그 없이 한국어 9줄로 통일.
분위기(밝음/감성/에너지) 가사 텍스트로 차별화합니다.
"""
import random
_BGM_DUMMY_LYRICS_LIST = [
# 버전 1 — 밝고 경쾌한 분위기
(
"햇살 가득한 아침이 시작되고\n"
"따스한 바람이 살며시 불어와\n"
"거리마다 웃음꽃이 피어나고\n"
"오늘도 설레는 하루가 열려\n"
"가볍게 발걸음을 내딛으며\n"
"환한 빛 속으로 걸어가는 길\n"
"두근두근 설레는 이 순간을\n"
"온 마음 가득 담아 느껴봐\n"
"오늘 하루도 빛나는 하루야\n"
"환한 미소로 하루를 마무리해\n"
),
# 버전 2 — 잔잔하고 감성적인 분위기
(
"저녁 노을이 물드는 창가에서\n"
"조용히 흘러가는 시간 속에\n"
"잔잔한 바람이 마음을 적시고\n"
"기억 속 풍경이 스쳐 지나가\n"
"부드럽게 감기는 이 느낌처럼\n"
"천천히 숨을 고르며 머물러\n"
"마음 깊은 곳에 스며드는 온기\n"
"조용히 눈을 감고 느껴봐\n"
"이 순간 여기 머무는 것만으로도 충분해\n"
"고요한 밤이 나를 감싸 안아줘\n"
),
# 버전 3 — 강렬하고 에너지 넘치는 분위기
(
"밤거리에 불빛이 타오르고\n"
"심장이 두근두근 뛰기 시작해\n"
"온몸에 퍼지는 뜨거운 열기\n"
"멈출 수 없는 이 흐름 속으로\n"
"있는 힘껏 달려가는 이 순간\n"
"모든 걸 내려놓고 느껴봐\n"
"짜릿하게 타오르는 지금 이 밤\n"
"온 세상이 하나로 움직여\n"
"끝까지 불태워 이 에너지를\n"
"새벽빛이 밝아올 때까지 달려\n"
),
]
def get_random_bgm_lyrics() -> tuple[str, int]:
"""BGM 더미 가사 3종 중 하나를 랜덤으로 반환합니다.
Returns:
(lyrics, version): 선택된 가사 텍스트와 버전 번호 (1~3)
"""
index = random.randrange(len(_BGM_DUMMY_LYRICS_LIST))
return _BGM_DUMMY_LYRICS_LIST[index], index + 1

View File

@ -0,0 +1,95 @@
import json
import re
from pydantic import BaseModel
from openai import AsyncOpenAI
from app.utils.logger import get_logger
from config import apikey_settings, recovery_settings
from app.utils.prompts.prompts import Prompt
# 로거 설정
logger = get_logger("chatgpt")
class ChatGPTResponseError(Exception):
"""ChatGPT API 응답 에러"""
def __init__(self, status: str, error_code: str = None, error_message: str = None):
self.status = status
self.error_code = error_code
self.error_message = error_message
super().__init__(f"ChatGPT response failed: status={status}, code={error_code}, message={error_message}")
class ChatgptService:
"""ChatGPT API 서비스 클래스
"""
def __init__(self, timeout: float = None):
self.timeout = timeout or recovery_settings.CHATGPT_TIMEOUT
self.max_retries = recovery_settings.CHATGPT_MAX_RETRIES
self.client = AsyncOpenAI(
api_key=apikey_settings.CHATGPT_API_KEY,
timeout=self.timeout
)
async def _call_pydantic_output(self, prompt : str, output_format : BaseModel, model : str) -> BaseModel: # 입력 output_format의 경우 Pydantic BaseModel Class를 상속한 Class 자체임에 유의할 것
content = [{"type": "input_text", "text": prompt}]
last_error = None
for attempt in range(self.max_retries + 1):
response = await self.client.responses.parse(
model=model,
input=[{"role": "user", "content": content}],
text_format=output_format
)
# Response 디버그 로깅
logger.debug(f"[ChatgptService] attempt: {attempt}")
logger.debug(f"[ChatgptService] Response ID: {response.id}")
logger.debug(f"[ChatgptService] Response status: {response.status}")
logger.debug(f"[ChatgptService] Response model: {response.model}")
# status 확인: completed, failed, incomplete, cancelled, queued, in_progress
if response.status == "completed":
logger.debug(f"[ChatgptService] Response output_text: {response.output_text[:200]}..." if len(response.output_text) > 200 else f"[ChatgptService] Response output_text: {response.output_text}")
structured_output = response.output_parsed
return structured_output #.model_dump() or {}
# 에러 상태 처리
if response.status == "failed":
error_code = getattr(response.error, 'code', None) if response.error else None
error_message = getattr(response.error, 'message', None) if response.error else None
logger.warning(f"[ChatgptService] Response failed (attempt {attempt + 1}/{self.max_retries + 1}): code={error_code}, message={error_message}")
last_error = ChatGPTResponseError(response.status, error_code, error_message)
elif response.status == "incomplete":
reason = getattr(response.incomplete_details, 'reason', None) if response.incomplete_details else None
logger.warning(f"[ChatgptService] Response incomplete (attempt {attempt + 1}/{self.max_retries + 1}): reason={reason}")
last_error = ChatGPTResponseError(response.status, reason, f"Response incomplete: {reason}")
else:
# cancelled, queued, in_progress 등 예상치 못한 상태
logger.warning(f"[ChatgptService] Unexpected response status (attempt {attempt + 1}/{self.max_retries + 1}): {response.status}")
last_error = ChatGPTResponseError(response.status, None, f"Unexpected status: {response.status}")
# 마지막 시도가 아니면 재시도
if attempt < self.max_retries:
logger.info(f"[ChatgptService] Retrying request...")
# 모든 재시도 실패
logger.error(f"[ChatgptService] All retries exhausted. Last error: {last_error}")
raise last_error
async def generate_structured_output(
self,
prompt : Prompt,
input_data : dict,
) -> BaseModel:
prompt_text = prompt.build_prompt(input_data)
logger.debug(f"[ChatgptService] Generated Prompt (length: {len(prompt_text)})")
logger.info(f"[ChatgptService] Starting GPT request with structured output with model: {prompt.prompt_model}")
# GPT API 호출
#response = await self._call_structured_output_with_response_gpt_api(prompt_text, prompt.prompt_output, prompt.prompt_model)
response = await self._call_pydantic_output(prompt_text, prompt.prompt_output_class, prompt.prompt_model)
return response

View File

@ -19,16 +19,12 @@ Note:
import os
import time
import re
from typing import Any, Optional, Type
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
def normalize_location(name: str) -> str:
return re.sub(r'(특별시|광역시|특별자치시|특별자치도|시|군|구|도)$', '', name)
def _generate_uuid7_string() -> str:
"""UUID7 문자열을 생성합니다.

View File

@ -31,15 +31,11 @@ response = await creatomate.make_creatomate_call(template_id, modifications)
import copy
import time
from enum import StrEnum
from typing import Literal
import traceback
import httpx
from app.utils.logger import get_logger
from app.utils.prompts.schemas.image import SpaceType,Subject,Camera,MotionRecommended,NarrativePhase
from app.utils.common import normalize_location
from config import apikey_settings, creatomate_settings, recovery_settings
# 로거 설정
@ -230,9 +226,8 @@ DVST0003 = "e1fb5b00-1f02-4f63-99fa-7524b433ba47"
DHST0001 = "660be601-080a-43ea-bf0f-adcf4596fa98"
DHST0002 = "3f194cc7-464e-4581-9db2-179d42d3e40f"
DHST0003 = "f45df555-2956-4a13-9004-ead047070b3d"
DVST0001T = "fe11aeab-ff29-4bc8-9f75-c695c7e243e6"
HST_LIST = [DHST0001,DHST0002,DHST0003]
VST_LIST = [DVST0001,DVST0002,DVST0003, DVST0001T]
VST_LIST = [DVST0001,DVST0002,DVST0003]
SCENE_TRACK = 1
AUDIO_TRACK = 2
@ -243,7 +238,7 @@ def select_template(orientation:OrientationType):
if orientation == "horizontal":
return DHST0001
elif orientation == "vertical":
return DVST0001T
return DVST0001
else:
raise
@ -404,6 +399,14 @@ class CreatomateService:
return copy.deepcopy(data)
# 하위 호환성을 위한 별칭 (deprecated)
async def get_one_template_data_async(self, template_id: str) -> dict:
"""특정 템플릿 ID로 템플릿 정보를 조회합니다.
Deprecated: get_one_template_data() 사용하세요.
"""
return await self.get_one_template_data(template_id)
def parse_template_component_name(self, template_source: list) -> dict:
"""템플릿 정보를 파싱하여 리소스 이름을 추출합니다."""
@ -431,108 +434,42 @@ class CreatomateService:
return result
async def parse_template_name_tag(resource_name : str) -> list:
tag_list = []
tag_list = resource_name.split("_")
return tag_list
def counting_component(
async def template_connect_resource_blackbox(
self,
template : dict,
target_template_type : str
) -> list:
source_elements = template["source"]["elements"]
template_component_data = self.parse_template_component_name(source_elements)
count = 0
for _, (_, template_type) in enumerate(template_component_data.items()):
if template_type == target_template_type:
count += 1
return count
def template_matching_taged_image(
self,
template : dict,
taged_image_list : list, # [{"image_name" : str , "image_tag" : dict}]
template_id: str,
image_url_list: list[str],
music_url: str,
address : str,
duplicate : bool = False
) -> list:
source_elements = template["source"]["elements"]
template_component_data = self.parse_template_component_name(source_elements)
address: str = None
) -> dict:
"""템플릿 정보와 이미지/가사/음악 리소스를 매핑합니다.
taged_image_list = [img for img in taged_image_list if img.get("image_tag") is not None]
Note:
- 이미지는 순차적으로 집어넣기
- 가사는 개행마다 텍스트 삽입
- Template에 audio-music 항목이 있어야
"""
template_data = await self.get_one_template_data(template_id)
template_component_data = self.parse_template_component_name(
template_data["source"]["elements"]
)
modifications = {}
for slot_idx, (template_component_name, template_type) in enumerate(template_component_data.items()):
for idx, (template_component_name, template_type) in enumerate(
template_component_data.items()
):
match template_type:
case "image":
image_score_list = self.calculate_image_slot_score_multi(taged_image_list, template_component_name)
maximum_idx = image_score_list.index(max(image_score_list))
if duplicate:
selected = taged_image_list[maximum_idx]
else:
selected = taged_image_list.pop(maximum_idx)
image_name = selected["image_url"]
modifications[template_component_name] =image_name
pass
modifications[template_component_name] = image_url_list[
idx % len(image_url_list)
]
case "text":
if "address_input" in template_component_name:
modifications[template_component_name] = address
modifications["audio-music"] = music_url
return modifications
def calculate_image_slot_score_multi(self, taged_image_list : list[dict], slot_name : str):
image_tag_list = [taged_image["image_tag"] for taged_image in taged_image_list]
slot_tag_dict = self.parse_slot_name_to_tag(slot_name)
image_score_list = [0] * len(image_tag_list)
for slot_tag_cate, slot_tag_item in slot_tag_dict.items():
if slot_tag_cate == "narrative_preference":
slot_tag_narrative = slot_tag_item
continue
match slot_tag_cate:
case "space_type":
weight = 2
case "subject" :
weight = 2
case "camera":
weight = 1
case "motion_recommended" :
weight = 0.5
case _:
raise
for idx, image_tag in enumerate(image_tag_list):
if slot_tag_item.value in image_tag[slot_tag_cate]: #collect!
image_score_list[idx] += weight
for idx, image_tag in enumerate(image_tag_list):
image_narrative_score = image_tag["narrative_preference"][slot_tag_narrative]
image_score_list[idx] = image_score_list[idx] * image_narrative_score
return image_score_list
def parse_slot_name_to_tag(self, slot_name : str) -> dict[str, StrEnum]:
tag_list = slot_name.split("-")
space_type = SpaceType(tag_list[0])
subject = Subject(tag_list[1])
camera = Camera(tag_list[2])
motion = MotionRecommended(tag_list[3])
narrative = NarrativePhase(tag_list[4])
tag_dict = {
"space_type" : space_type,
"subject" : subject,
"camera" : camera,
"motion_recommended" : motion,
"narrative_preference" : narrative,
}
return tag_dict
def elements_connect_resource_blackbox(
self,
elements: list,
@ -732,6 +669,14 @@ class CreatomateService:
original_response={"last_error": str(last_error)},
)
# 하위 호환성을 위한 별칭 (deprecated)
async def make_creatomate_custom_call_async(self, source: dict) -> dict:
"""템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다.
Deprecated: make_creatomate_custom_call() 사용하세요.
"""
return await self.make_creatomate_custom_call(source)
async def get_render_status(self, render_id: str) -> dict:
"""렌더링 작업의 상태를 조회합니다.
@ -755,6 +700,14 @@ class CreatomateService:
response.raise_for_status()
return response.json()
# 하위 호환성을 위한 별칭 (deprecated)
async def get_render_status_async(self, render_id: str) -> dict:
"""렌더링 작업의 상태를 조회합니다.
Deprecated: get_render_status() 사용하세요.
"""
return await self.get_render_status(render_id)
def calc_scene_duration(self, template: dict) -> float:
"""템플릿의 전체 장면 duration을 계산합니다."""
total_template_duration = 0.0
@ -767,22 +720,19 @@ class CreatomateService:
try:
if elem["track"] not in track_maximum_duration:
continue
if "time" not in elem or elem["time"] == 0: # elem is auto / 만약 마지막 elem이 auto인데 그 앞에 time이 있는 elem 일 시 버그 발생 확률 있음
if elem["time"] == 0: # elem is auto / 만약 마지막 elem이 auto인데 그 앞에 time이 있는 elem 일 시 버그 발생 확률 있음
track_maximum_duration[elem["track"]] += elem["duration"]
if "animations" not in elem:
continue
for animation in elem["animations"]:
if "reversed" in animation:
continue
assert animation.get("time",0) == 0 # 0이 아닌 경우 확인 필요
if "transition" in animation and animation["transition"]:
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
if animation["transition"]:
track_maximum_duration[elem["track"]] -= animation["duration"]
else:
track_maximum_duration[elem["track"]] = max(track_maximum_duration[elem["track"]], elem["time"] + elem["duration"])
except Exception as e:
logger.debug(traceback.format_exc())
logger.error(f"[calc_scene_duration] Error processing element: {elem}, {e}")
total_template_duration = max(track_maximum_duration.values())
@ -871,19 +821,3 @@ class CreatomateService:
logger.error("this template does not have same amount of keyword and subtitle.")
pitching_list = keyword_list + subtitle_list
return pitching_list
def make_thumbnail_modification(self, brand_name : str, region : str, brand_concept : str, category_definition : str, target_keywords : list[str]):
len_keywords = len(target_keywords) if len(target_keywords) < 3 else 3
hashtaged_target_keywords = [f"#{tk}" for tk in target_keywords[len_keywords]]
mod_dict = {
"thumb-hashtag-primary" : ' '.join(hashtaged_target_keywords),
"thumb-brand-wordmark" : brand_name,
"thumb-subheadline-selling_point" : f"{brand_name} · {normalize_location(region)}",
"thumb-headline-hook_claim-aspirational" : brand_concept,
"thumb-badge-category" : category_definition,
}
return mod_dict

View File

@ -1,7 +1,4 @@
import asyncio
import re
from html import unescape
from difflib import SequenceMatcher
from playwright.async_api import async_playwright
from urllib import parse
import time
@ -98,156 +95,57 @@ patchedGetter.toString();''')
page = self.page
await page.goto(url, wait_until=wait_until, timeout=timeout)
@staticmethod
def _clean_title(text: str) -> str:
text = unescape(text) # HTML 엔티티 디코딩 (&amp; → &)
text = re.sub(r"<.*?>", "", text) # HTML 태그 제거
return text.strip()
@staticmethod
def _similarity(a: str, b: str) -> float:
return SequenceMatcher(None, a, b).ratio()
@staticmethod
def _refine_address(address: str) -> str:
"""한국 주소 패턴에서 첫 번째 유효한 주소만 추출한다."""
patterns = [
# 도로명 (정식): 경기도 가평군 운악로 278
re.compile(
r'[가-힣]+(?:특별시|광역시|특별자치시|도|특별자치도|시)\s+'
r'[가-힣\s]+?(?:로|길|대로)\s+\d+(?:-\d+)?'
),
# 지번 (정식): 경기도 가평군 조종면 운악리 278
re.compile(
r'[가-힣]+(?:특별시|광역시|특별자치시|도|특별자치도|시)\s+'
r'[가-힣\s]+?(?:읍|면|동|리|가)\s+\d+(?:-\d+)?'
),
# 도로명 (축약): 경기 가평 운악로 278
re.compile(
r'[가-힣]{1,4}\s+[가-힣]{1,6}\s+'
r'[가-힣\s]+?(?:로|길|대로)\s+\d+(?:-\d+)?'
),
# 지번 (축약): 경기 가평 조종면 운악리 278
re.compile(
r'[가-힣]{1,4}\s+[가-힣]{1,6}\s+'
r'[가-힣\s]+?(?:읍|면|동|리|가)\s+\d+(?:-\d+)?'
),
]
for pattern in patterns:
m = pattern.search(address)
if m:
return m.group().strip()
return address
async def _extract_candidates_from_list_page(self) -> list[dict]:
"""pcmap.place.naver.com iframe HTML에서 place ID와 업체명을 추출한다."""
pcmap_frame = None
for frame in self.page.frames:
if "pcmap.place.naver.com" in frame.url:
pcmap_frame = frame
logger.debug(f"[DEBUG] pcmap frame 발견: {frame.url[:80]}")
break
if not pcmap_frame:
logger.debug("[DEBUG] pcmap frame 없음")
return []
try:
html = await pcmap_frame.content()
except Exception as e:
logger.debug(f"[DEBUG] pcmap frame content 추출 실패: {e}")
return []
# {"id":"11659052","name":"프레지던트 호텔",...} 형태의 JSON 쌍 추출
pair_pattern = re.compile(
r'"id"\s*:\s*"(\d{5,})"[^}]{0,200}?"name"\s*:\s*"([^"]{1,60})"'
r'|"name"\s*:\s*"([^"]{1,60})"[^}]{0,200}?"id"\s*:\s*"(\d{5,})"'
)
seen = {} # place_id → title (순서 보존)
for m in pair_pattern.finditer(html):
if m.group(1): # id 먼저
pid, title = m.group(1), m.group(2)
else: # name 먼저
pid, title = m.group(4), m.group(3)
if pid not in seen:
seen[pid] = title
candidates = [
{"title": title, "place_url": f"https://map.naver.com/p/entry/place/{pid}"}
for pid, title in list(seen.items())[:10]
]
for i, c in enumerate(candidates):
logger.debug(f"[DEBUG] 후보 {i+1}: {c['title']} / {c['place_url']}")
logger.debug(f"[DEBUG] 목록 후보 {len(candidates)}개 추출")
return candidates
async def _try_search(self, address: str, title: str) -> str | None:
"""주어진 주소+업체명으로 검색해서 place URL을 반환한다. 실패 시 None."""
encoded_query = parse.quote(f"{address} {title}".strip())
url = f"https://map.naver.com/p/search/{encoded_query}"
try:
await self.goto_url(url, wait_until="networkidle", timeout=self._timeout * 1000)
except:
if "/place/" in self.page.url:
return self.page.url
logger.error("[ERROR] Can't Finish networkidle")
if "/place/" in self.page.url:
return self.page.url
candidates = await self._extract_candidates_from_list_page()
if candidates:
best = max(
candidates,
key=lambda c: self._similarity(title, self._clean_title(c['title']))
)
best_score = self._similarity(title, self._clean_title(best['title']))
logger.info(
f"[AUTO-SELECT] '{title}''{best['title']}' (score={best_score:.2f}) {best['place_url']}"
)
return best['place_url']
# isCorrectAnswer=true 로 강제 단일결과 재시도 (원본 로직 유지)
correct_url = self.page.url.replace("?", "?isCorrectAnswer=true&")
try:
await self.goto_url(correct_url, wait_until="networkidle", timeout=self._timeout * 1000)
except:
if "/place/" in self.page.url:
return self.page.url
logger.error("[ERROR] Can't Finish networkidle (isCorrectAnswer)")
if "/place/" in self.page.url:
return self.page.url
return None
async def get_place_id_url(self, selected):
title = self._clean_title(selected['title'])
address = self._clean_title(selected.get('roadAddress', selected['address']))
count = 0
get_place_id_url_start = time.perf_counter()
while (count <= self._max_retry):
title = selected['title'].replace("<b>", "").replace("</b>", "")
address = selected.get('roadAddress', selected['address']).replace("<b>", "").replace("</b>", "")
encoded_query = parse.quote(f"{address} {title}")
url = f"https://map.naver.com/p/search/{encoded_query}"
# 1차 시도: 원본 주소 + 업체명
logger.debug(f"[DEBUG] 1차 시도 - address: {address}")
result = await self._try_search(address, title)
if result:
return result
wait_first_start = time.perf_counter()
# 2차 시도: 정제 주소 + 업체명
refined = self._refine_address(address)
if refined != address:
logger.info(f"[REFINE] 주소 정제: '{address}''{refined}'")
result = await self._try_search(refined, title)
if result:
return result
try:
await self.goto_url(url, wait_until="networkidle",timeout = self._timeout*1000)
except:
if "/place/" in self.page.url:
return self.page.url
logger.error(f"[ERROR] Can't Finish networkidle")
# 3차 시도: 업체명만으로 검색
logger.info(f"[RETRY] 업체명만으로 재시도: '{title}'")
result = await self._try_search("", title)
if result:
return result
logger.error(f"[ERROR] Not found url for {selected}")
return None
wait_first_time = (time.perf_counter() - wait_first_start) * 1000
logger.debug(f"[DEBUG] Try {count+1} : Wait for perfect matching : {wait_first_time}ms")
if "/place/" in self.page.url:
return self.page.url
logger.debug(f"[DEBUG] Try {count+1} : url place id not found, retry for forced collect answer")
wait_forced_correct_start = time.perf_counter()
url = self.page.url.replace("?","?isCorrectAnswer=true&")
try:
await self.goto_url(url, wait_until="networkidle",timeout = self._timeout*1000)
except:
if "/place/" in self.page.url:
return self.page.url
logger.error(f"[ERROR] Can't Finish networkidle")
wait_forced_correct_time = (time.perf_counter() - wait_forced_correct_start) * 1000
logger.debug(f"[DEBUG] Try {count+1} : Wait for forced isCorrectAnswer flag : {wait_forced_correct_time}ms")
if "/place/" in self.page.url:
return self.page.url
count += 1
logger.error("[ERROR] Not found url for {selected}")
return None # 404
# if (count == self._max_retry / 2):
# raise Exception("Failed to identify place id. loading timeout")
# else:
# raise Exception("Failed to identify place id. item is ambiguous")

View File

@ -16,10 +16,6 @@ class GraphQLException(Exception):
"""GraphQL 요청 실패 시 발생하는 예외"""
pass
class URLNotFoundException(Exception):
"""Place ID 발견 불가능 시 발생하는 예외"""
pass
class CrawlingTimeoutException(Exception):
"""크롤링 타임아웃 시 발생하는 예외"""
@ -90,28 +86,34 @@ query getAccommodation($id: String!, $deviceType: String) {
async with session.get(self.url) as response:
self.url = str(response.url)
else:
raise URLNotFoundException("This URL does not contain a place ID")
raise GraphQLException("This URL does not contain a place ID")
match = re.search(place_pattern, self.url)
if not match:
raise URLNotFoundException("Failed to parse place ID from URL")
raise GraphQLException("Failed to parse place ID from URL")
return match[1]
async def scrap(self):
place_id = await self.parse_url()
data = await self._call_get_accommodation(place_id)
self.rawdata = data
fac_data = await self._get_facility_string(place_id)
# Naver 기준임, 구글 등 다른 데이터 소스의 경우 고유 Identifier 사용할 것.
self.place_id = self.data_source_identifier + place_id
self.rawdata["facilities"] = fac_data
self.image_link_list = [
nv_image["origin"]
for nv_image in data["data"]["business"]["images"]["images"]
]
self.base_info = data["data"]["business"]["base"]
self.facility_info = fac_data
self.scrap_type = "GraphQL"
try:
place_id = await self.parse_url()
data = await self._call_get_accommodation(place_id)
self.rawdata = data
fac_data = await self._get_facility_string(place_id)
# Naver 기준임, 구글 등 다른 데이터 소스의 경우 고유 Identifier 사용할 것.
self.place_id = self.data_source_identifier + place_id
self.rawdata["facilities"] = fac_data
self.image_link_list = [
nv_image["origin"]
for nv_image in data["data"]["business"]["images"]["images"]
]
self.base_info = data["data"]["business"]["base"]
self.facility_info = fac_data
self.scrap_type = "GraphQL"
except GraphQLException:
logger.debug("GraphQL failed, fallback to Playwright")
self.scrap_type = "Playwright"
pass # 나중에 pw 이용한 crawling으로 fallback 추가
return

View File

@ -1,191 +0,0 @@
import json
import re
from pydantic import BaseModel
from typing import List, Optional
from openai import AsyncOpenAI
from app.utils.logger import get_logger
from config import apikey_settings, recovery_settings
from app.utils.prompts.prompts import Prompt
# 로거 설정
logger = get_logger("chatgpt")
class ChatGPTResponseError(Exception):
"""ChatGPT API 응답 에러"""
def __init__(self, status: str, error_code: str = None, error_message: str = None):
self.status = status
self.error_code = error_code
self.error_message = error_message
super().__init__(f"ChatGPT response failed: status={status}, code={error_code}, message={error_message}")
class ChatgptService:
"""ChatGPT API 서비스 클래스
"""
model_type : str
def __init__(self, model_type:str = "gpt", timeout: float = None):
self.timeout = timeout or recovery_settings.CHATGPT_TIMEOUT
self.max_retries = recovery_settings.CHATGPT_MAX_RETRIES
self.model_type = model_type
match model_type:
case "gpt":
self.client = AsyncOpenAI(
api_key=apikey_settings.CHATGPT_API_KEY,
timeout=self.timeout
)
case "gemini":
self.client = AsyncOpenAI(
api_key=apikey_settings.GEMINI_API_KEY,
base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
timeout=self.timeout
)
case _:
raise NotImplementedError(f"Unknown Provider : {model_type}")
async def _call_pydantic_output(
self,
prompt : str,
output_format : BaseModel, #입력 output_format의 경우 Pydantic BaseModel Class를 상속한 Class 자체임에 유의할 것
model : str,
img_url : str,
image_detail_high : bool) -> BaseModel:
content = []
if img_url:
content.append({
"type" : "input_image",
"image_url" : img_url,
"detail": "high" if image_detail_high else "low"
})
content.append({
"type": "input_text",
"text": prompt}
)
last_error = None
for attempt in range(self.max_retries + 1):
response = await self.client.responses.parse(
model=model,
input=[{"role": "user", "content": content}],
text_format=output_format
)
# Response 디버그 로깅
logger.debug(f"[ChatgptService({self.model_type})] attempt: {attempt}")
logger.debug(f"[ChatgptService({self.model_type})] Response ID: {response.id}")
logger.debug(f"[ChatgptService({self.model_type})] Response status: {response.status}")
logger.debug(f"[ChatgptService({self.model_type})] Response model: {response.model}")
# status 확인: completed, failed, incomplete, cancelled, queued, in_progress
if response.status == "completed":
logger.debug(f"[ChatgptService({self.model_type})] Response output_text: {response.output_text[:200]}..." if len(response.output_text) > 200 else f"[ChatgptService] Response output_text: {response.output_text}")
structured_output = response.output_parsed
return structured_output #.model_dump() or {}
# 에러 상태 처리
if response.status == "failed":
error_code = getattr(response.error, 'code', None) if response.error else None
error_message = getattr(response.error, 'message', None) if response.error else None
logger.warning(f"[ChatgptService({self.model_type})] Response failed (attempt {attempt + 1}/{self.max_retries + 1}): code={error_code}, message={error_message}")
last_error = ChatGPTResponseError(response.status, error_code, error_message)
elif response.status == "incomplete":
reason = getattr(response.incomplete_details, 'reason', None) if response.incomplete_details else None
logger.warning(f"[ChatgptService({self.model_type})] Response incomplete (attempt {attempt + 1}/{self.max_retries + 1}): reason={reason}")
last_error = ChatGPTResponseError(response.status, reason, f"Response incomplete: {reason}")
else:
# cancelled, queued, in_progress 등 예상치 못한 상태
logger.warning(f"[ChatgptService({self.model_type})] Unexpected response status (attempt {attempt + 1}/{self.max_retries + 1}): {response.status}")
last_error = ChatGPTResponseError(response.status, None, f"Unexpected status: {response.status}")
# 마지막 시도가 아니면 재시도
if attempt < self.max_retries:
logger.info(f"[ChatgptService({self.model_type})] Retrying request...")
# 모든 재시도 실패
logger.error(f"[ChatgptService({self.model_type})] All retries exhausted. Last error: {last_error}")
raise last_error
async def _call_pydantic_output_chat_completion( # alter version
self,
prompt : str,
output_format : BaseModel, #입력 output_format의 경우 Pydantic BaseModel Class를 상속한 Class 자체임에 유의할 것
model : str,
img_url : str,
image_detail_high : bool) -> BaseModel:
content = []
if img_url:
content.append({
"type": "image_url",
"image_url": {
"url": img_url,
"detail": "high" if image_detail_high else "low"
}
})
content.append({
"type": "text",
"text": prompt
})
last_error = None
for attempt in range(self.max_retries + 1):
response = await self.client.beta.chat.completions.parse(
model=model,
messages=[{"role": "user", "content": content}],
response_format=output_format
)
# Response 디버그 로깅
logger.debug(f"[ChatgptService({self.model_type})] attempt: {attempt}")
logger.debug(f"[ChatgptService({self.model_type})] Response ID: {response.id}")
logger.debug(f"[ChatgptService({self.model_type})] Response finish_reason: {response.id}")
logger.debug(f"[ChatgptService({self.model_type})] Response model: {response.model}")
choice = response.choices[0]
finish_reason = choice.finish_reason
if finish_reason == "stop":
output_text = choice.message.content or ""
logger.debug(f"[ChatgptService({self.model_type})] Response output_text: {output_text[:200]}..." if len(output_text) > 200 else f"[ChatgptService] Response output_text: {output_text}")
return choice.message.parsed
elif finish_reason == "length":
logger.warning(f"[ChatgptService({self.model_type})] Response incomplete - token limit reached (attempt {attempt + 1}/{self.max_retries + 1})")
last_error = ChatGPTResponseError("incomplete", finish_reason, "Response incomplete: max tokens reached")
elif finish_reason == "content_filter":
logger.warning(f"[ChatgptService({self.model_type})] Response blocked by content filter (attempt {attempt + 1}/{self.max_retries + 1})")
last_error = ChatGPTResponseError("failed", finish_reason, "Response blocked by content filter")
else:
logger.warning(f"[ChatgptService({self.model_type})] Unexpected finish_reason (attempt {attempt + 1}/{self.max_retries + 1}): {finish_reason}")
last_error = ChatGPTResponseError("failed", finish_reason, f"Unexpected finish_reason: {finish_reason}")
# 마지막 시도가 아니면 재시도
if attempt < self.max_retries:
logger.info(f"[ChatgptService({self.model_type})] Retrying request...")
# 모든 재시도 실패
logger.error(f"[ChatgptService({self.model_type})] All retries exhausted. Last error: {last_error}")
raise last_error
async def generate_structured_output(
self,
prompt : Prompt,
input_data : dict,
img_url : Optional[str] = None,
img_detail_high : bool = False,
silent : bool = False
) -> BaseModel:
prompt_text = prompt.build_prompt(input_data, silent)
logger.debug(f"[ChatgptService({self.model_type})] Generated Prompt (length: {len(prompt_text)})")
if not silent:
logger.info(f"[ChatgptService({self.model_type})] Starting GPT request with structured output with model: {prompt.prompt_model}")
# GPT API 호출
#parsed = await self._call_structured_output_with_response_gpt_api(prompt_text, prompt.prompt_output, prompt.prompt_model)
# parsed = await self._call_pydantic_output(prompt_text, prompt.prompt_output_class, prompt.prompt_model, img_url, img_detail_high)
parsed = await self._call_pydantic_output_chat_completion(prompt_text, prompt.prompt_output_class, prompt.prompt_model, img_url, img_detail_high)
return parsed

View File

@ -1,6 +1,5 @@
import gspread
import os, json
from pydantic import BaseModel
from google.oauth2.service_account import Credentials
from config import prompt_settings
from app.utils.logger import get_logger
from app.utils.prompts.schemas import *
@ -8,81 +7,69 @@ from functools import lru_cache
logger = get_logger("prompt")
_SCOPES = [
"https://www.googleapis.com/auth/spreadsheets.readonly"
]
class Prompt():
sheet_name: str
prompt_template: str
prompt_model: str
prompt_template_path : str #프롬프트 경로
prompt_template : str # fstring 포맷
prompt_model : str
prompt_input_class = BaseModel
prompt_input_class = BaseModel # pydantic class 자체를(instance 아님) 변수로 가짐
prompt_output_class = BaseModel
def __init__(self, sheet_name, prompt_input_class, prompt_output_class):
self.sheet_name = sheet_name
def __init__(self, prompt_template_path, prompt_input_class, prompt_output_class, prompt_model):
self.prompt_template_path = prompt_template_path
self.prompt_input_class = prompt_input_class
self.prompt_output_class = prompt_output_class
self.prompt_template, self.prompt_model = self._read_from_sheets()
def _read_from_sheets(self) -> tuple[str, str]:
creds = Credentials.from_service_account_file(
prompt_settings.GOOGLE_SERVICE_ACCOUNT_JSON, scopes=_SCOPES
)
gc = gspread.authorize(creds)
ws = gc.open_by_key(prompt_settings.PROMPT_SPREADSHEET).worksheet(self.sheet_name)
model = ws.cell(2, 2).value
input_text = ws.cell(3, 2).value
return input_text, model
self.prompt_template = self.read_prompt()
self.prompt_model = prompt_model
def _reload_prompt(self):
self.prompt_template, self.prompt_model = self._read_from_sheets()
self.prompt_template = self.read_prompt()
def build_prompt(self, input_data:dict, silent:bool = False) -> str:
def read_prompt(self) -> tuple[str, dict]:
with open(self.prompt_template_path, "r") as fp:
prompt_template = fp.read()
return prompt_template
def build_prompt(self, input_data:dict) -> str:
verified_input = self.prompt_input_class(**input_data)
build_template = self.prompt_template
build_template = build_template.format(**verified_input.model_dump())
if not silent:
logger.debug(f"build_template: {build_template}")
logger.debug(f"input_data: {input_data}")
logger.debug(f"build_template: {build_template}")
logger.debug(f"input_data: {input_data}")
return build_template
marketing_prompt = Prompt(
sheet_name="marketing",
prompt_input_class=MarketingPromptInput,
prompt_output_class=MarketingPromptOutput,
prompt_template_path = os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.MARKETING_PROMPT_FILE_NAME),
prompt_input_class = MarketingPromptInput,
prompt_output_class = MarketingPromptOutput,
prompt_model = prompt_settings.MARKETING_PROMPT_MODEL
)
lyric_prompt = Prompt(
sheet_name="lyric",
prompt_input_class=LyricPromptInput,
prompt_output_class=LyricPromptOutput,
prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.LYRIC_PROMPT_FILE_NAME),
prompt_input_class = LyricPromptInput,
prompt_output_class = LyricPromptOutput,
prompt_model = prompt_settings.LYRIC_PROMPT_MODEL
)
yt_upload_prompt = Prompt(
sheet_name="yt_upload",
prompt_input_class=YTUploadPromptInput,
prompt_output_class=YTUploadPromptOutput,
)
image_autotag_prompt = Prompt(
sheet_name="image_tag",
prompt_input_class=ImageTagPromptInput,
prompt_output_class=ImageTagPromptOutput,
prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.YOUTUBE_PROMPT_FILE_NAME),
prompt_input_class = YTUploadPromptInput,
prompt_output_class = YTUploadPromptOutput,
prompt_model = prompt_settings.YOUTUBE_PROMPT_MODEL
)
@lru_cache()
def create_dynamic_subtitle_prompt(length: int) -> Prompt:
return Prompt(
sheet_name="subtitle",
prompt_input_class=SubtitlePromptInput,
prompt_output_class=SubtitlePromptOutput[length],
)
def create_dynamic_subtitle_prompt(length : int) -> Prompt:
prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.SUBTITLE_PROMPT_FILE_NAME)
prompt_input_class = SubtitlePromptInput
prompt_output_class = SubtitlePromptOutput[length]
prompt_model = prompt_settings.SUBTITLE_PROMPT_MODEL
return Prompt(prompt_template_path, prompt_input_class, prompt_output_class, prompt_model)
def reload_all_prompt():
marketing_prompt._reload_prompt()
lyric_prompt._reload_prompt()
yt_upload_prompt._reload_prompt()
image_autotag_prompt._reload_prompt()

View File

@ -1,5 +1,4 @@
from .lyric import LyricPromptInput, LyricPromptOutput
from .marketing import MarketingPromptInput, MarketingPromptOutput
from .youtube import YTUploadPromptInput, YTUploadPromptOutput
from .image import *
from .subtitle import SubtitlePromptInput, SubtitlePromptOutput

View File

@ -1,110 +0,0 @@
from pydantic import BaseModel, Field
from typing import List, Optional
from enum import StrEnum, auto
class SpaceType(StrEnum):
exterior_front = auto()
exterior_night = auto()
exterior_aerial = auto()
exterior_sign = auto()
garden = auto()
entrance = auto()
lobby = auto()
reception = auto()
hallway = auto()
bedroom = auto()
livingroom = auto()
kitchen = auto()
dining = auto()
room = auto()
bathroom = auto()
amenity = auto()
view_window = auto()
view_ocean = auto()
view_city = auto()
view_mountain = auto()
balcony = auto()
cafe = auto()
lounge = auto()
rooftop = auto()
pool = auto()
breakfast_hall = auto()
spa = auto()
fitness = auto()
bbq = auto()
terrace = auto()
glamping = auto()
neighborhood = auto()
landmark = auto()
detail_welcome = auto()
detail_beverage = auto()
detail_lighting = auto()
detail_decor = auto()
detail_tableware = auto()
class Subject(StrEnum):
empty_space = auto()
exterior_building = auto()
architecture_detail = auto()
decoration = auto()
furniture = auto()
food_dish = auto()
nature = auto()
signage = auto()
amenity_item = auto()
person = auto()
class Camera(StrEnum):
wide_angle = auto()
tight_crop = auto()
panoramic = auto()
symmetrical = auto()
leading_line = auto()
golden_hour = auto()
night_shot = auto()
high_contrast = auto()
low_light = auto()
drone_shot = auto()
has_face = auto()
class MotionRecommended(StrEnum):
static = auto()
slow_pan = auto()
slow_zoom_in = auto()
slow_zoom_out = auto()
walkthrough = auto()
dolly = auto()
class NarrativePhase(StrEnum):
intro = auto()
welcome = auto()
core = auto()
highlight = auto()
support = auto()
accent = auto()
class NarrativePreference(BaseModel):
intro: float = Field(..., description="첫인상 — 여기가 어디인가 | 장소의 정체성과 위치를 전달하는 이미지. 영상 첫 1~2초에 어떤 곳인지 즉시 인지시키는 역할. 건물 외관, 간판, 정원 등 **장소 자체를 보여주는** 컷")
welcome: float = Field(..., description="진입/환영 — 어떻게 들어가나 | 도착 후 내부로 들어가는 경험을 전달하는 이미지. 공간의 첫 분위기와 동선을 보여줘 들어가고 싶다는 기대감을 만드는 역할. **문을 열고 들어갔을 때 보이는** 컷.")
core: float = Field(..., description="핵심 가치 — 무엇을 경험하나 | **고객이 이 장소를 찾는 본질적 이유.** 이 이미지가 없으면 영상 자체가 성립하지 않음. 질문: 이 비즈니스에서 돈을 지불하는 대상이 뭔가? → 그 답이 core.")
highlight: float = Field(..., description="차별화 — 뭐가 특별한가 | **같은 카테고리의 경쟁사 대비 이곳을 선택하게 만드는 이유.** core가 왜 왔는가라면, highlight는 왜 **여기**인가에 대한 답.")
support: float = Field(..., description="보조/부대 — 그 외에 뭐가 있나 | 핵심은 아니지만 전체 경험을 풍성하게 하는 부가 요소. 없어도 영상은 성립하지만, 있으면 설득력이 올라감. **이것도 있어요** 라고 말하는 컷.")
accent: float = Field(..., description="감성/마무리 — 어떤 느낌인가 | 공간의 분위기와 톤을 전달하는 감성 디테일 컷. 직접적 정보 전달보다 **느낌과 무드**를 제공. 영상 사이사이에 삽입되어 완성도를 높이는 역할.")
# Input 정의
class ImageTagPromptInput(BaseModel):
img_url : str = Field(..., description="이미지 URL")
space_type: list[str] = Field(list(SpaceType), description="공간적 정보를 가지는 태그 리스트")
subject: list[str] = Field(list(Subject), description="피사체 정보를 가지는 태그 리스트")
camera: list[str] = Field(list(Camera), description="카메라 정보를 가지는 태그 리스트")
motion_recommended: list[str] = Field(list(MotionRecommended), description="가능한 카메라 모션 리스트")
# Output 정의
class ImageTagPromptOutput(BaseModel):
#ad_avaliable : bool = Field(..., description="광고 영상 사용 가능 이미지 여부")
space_type: list[SpaceType] = Field(..., description="공간적 정보를 가지는 태그 리스트")
subject: list[Subject] = Field(..., description="피사체 정보를 가지는 태그 리스트")
camera: list[Camera] = Field(..., description="카메라 정보를 가지는 태그 리스트")
motion_recommended: list[MotionRecommended] = Field(..., description="가능한 카메라 모션 리스트")
narrative_preference: NarrativePreference = Field(..., description="이미지의 내러티브 상 점수")

View File

@ -7,11 +7,13 @@ class MarketingPromptInput(BaseModel):
region : str = Field(..., description = "마케팅 대상 지역")
detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세")
# Output 정의
class BrandIdentity(BaseModel):
location_feature_analysis: str = Field(..., description="입지 특성 분석 (80자 이상 150자 이하)", min_length = 80, max_length = 150) # min/max constraint는 현재 openai json schema 등에서 작동하지 않는다는 보고가 있음.
concept_scalability: str = Field(..., description="컨셉 확장성 (80자 이상 150자 이하)", min_length = 80, max_length = 150)
class MarketPositioning(BaseModel):
category_definition: str = Field(..., description="마케팅 카테고리")
core_value: str = Field(..., description="마케팅 포지션 핵심 가치")
@ -20,12 +22,14 @@ class AgeRange(BaseModel):
min_age : int = Field(..., ge=0, le=100)
max_age : int = Field(..., ge=0, le=100)
class TargetPersona(BaseModel):
persona: str = Field(..., description="타겟 페르소나 이름/설명")
age: AgeRange = Field(..., description="타겟 페르소나 나이대")
favor_target: List[str] = Field(..., description="페르소나의 선호 요소")
decision_trigger: str = Field(..., description="구매 결정 트리거")
class SellingPoint(BaseModel):
english_category: str = Field(..., description="셀링포인트 카테고리(영문)")
korean_category: str = Field(..., description="셀링포인트 카테고리(한글)")

View File

@ -0,0 +1,64 @@
[Role & Objective]
Act as a content marketing expert with strong domain knowledge in the Korean pension / stay-accommodation industry.
Your goal is to produce a Marketing Intelligence Report that will be shown to accommodation owners BEFORE any content is generated.
The report must clearly explain what makes the property sellable, marketable, and scalable through content.
[INPUT]
- Business Name: {customer_name}
- Region: {region}
- Region Details: {detail_region_info}
[Core Analysis Requirements]
Analyze the property based on:
Location, concept, and nearby environment
Target customer behavior and reservation decision factors
Include:
- Target customer segments & personas
- Unique Selling Propositions (USPs)
- Competitive landscape (direct & indirect competitors)
- Market positioning
[Key Selling Point Structuring UI Optimized]
From the analysis above, extract the main Key Selling Points using the structure below.
Rules:
Focus only on factors that directly influence booking decisions
Each selling point must be concise and visually scannable
Language must be reusable for ads, short-form videos, and listing headlines
Avoid full sentences in descriptions; use short selling phrases
Do not provide in report
Output format:
[Category]
(Tag keyword 5~8 words, noun-based, UI oval-style)
One-line selling phrase (not a full sentence)
Limit:
5 to 8 Key Selling Points only
Do not provide in report
[Content & Automation Readiness Check]
Ensure that:
Each tag keyword can directly map to a content theme
Each selling phrase can be used as:
- Video hook
- Image headline
- Ad copy snippet
[Tag Generation Rules]
- Tags must include **only core keywords that can be directly used for viral video song lyrics**
- Each tag should be selected with **search discovery + emotional resonance + reservation conversion** in mind
- The number of tags must be **exactly 5**
- Tags must be **nouns or short keyword phrases**; full sentences are strictly prohibited
- The following categories must be **balanced and all represented**:
1) **Location / Local context** (region name, neighborhood, travel context)
2) **Accommodation positioning** (emotional stay, private stay, boutique stay, etc.)
3) **Emotion / Experience** (healing, rest, one-day escape, memory, etc.)
4) **SNS / Viral signals** (Instagram vibes, picture-perfect day, aesthetic travel, etc.)
5) **Travel & booking intent** (travel, getaway, stay, relaxation, etc.)
- If a brand name exists, **at least one tag must include the brand name or a brand-specific expression**
- Avoid overly generic keywords (e.g., “hotel”, “travel” alone); **prioritize distinctive, differentiating phrases**
- The final output must strictly follow the JSON format below, with no additional text
"tags": ["Tag1", "Tag2", "Tag3", "Tag4", "Tag5"]

View File

@ -0,0 +1,77 @@
[ROLE]
You are a content marketing expert, brand strategist, and creative songwriter
specializing in Korean pension / accommodation businesses.
You create lyrics strictly based on Brand & Marketing Intelligence analysis
and optimized for viral short-form video content.
Marketing Intelligence Report is background reference.
[INPUT]
Business Name: {customer_name}
Region: {region}
Region Details: {detail_region_info}
Brand & Marketing Intelligence Report: {marketing_intelligence_summary}
Output Language: {language}
[INTERNAL ANALYSIS DO NOT OUTPUT]
Internally analyze the following to guide all creative decisions:
- Core brand identity and positioning
- Emotional hooks derived from selling points
- Target audience lifestyle, desires, and travel motivation
- Regional atmosphere and symbolic imagery
- How the stay converts into “shareable moments”
- Which selling points must surface implicitly in lyrics
[LYRICS & MUSIC CREATION TASK]
Based on the Brand & Marketing Intelligence Report for [{customer_name} ({region})], generate:
- Original promotional lyrics
- Music attributes for AI music generation (Suno-compatible prompt)
The output must be designed for VIRAL DIGITAL CONTENT
(short-form video, reels, ads).
[LYRICS REQUIREMENTS]
Mandatory Inclusions:
- Business name
- Region name
- Promotion subject
- Promotional expressions including:
{promotional_expression_example}
Content Rules:
- Lyrics must be emotionally driven, not descriptive listings
- Selling points must be IMPLIED, not explained
- Must sound natural when sung
- Must feel like a lifestyle moment, not an advertisement
Tone & Style:
- Warm, emotional, and aspirational
- Trendy, viral-friendly phrasing
- Calm but memorable hooks
- Suitable for travel / stay-related content
[SONG & MUSIC ATTRIBUTES FOR SUNO PROMPT]
After the lyrics, generate a concise music prompt including:
Song mood (emotional keywords)
BPM range
Recommended genres (max 2)
Key musical motifs or instruments
Overall vibe (1 short sentence)
[CRITICAL LANGUAGE REQUIREMENT ABSOLUTE RULE]
ALL OUTPUT MUST BE 100% WRITTEN IN {language}.
no mixed languages
All names, places, and expressions must be in {language}
Any violation invalidates the entire output
[OUTPUT RULES STRICT]
{timing_rules}
No explanations
No headings
No bullet points
No analysis
No extra text
[FAILURE FORMAT]
If generation is impossible:
ERROR: Brief reason in English

View File

@ -0,0 +1,42 @@
# Role
Act as a Senior Brand Strategist and Marketing Data Analyst. Your goal is to analyze the provided input data and generate a high-level Marketing Intelligence Report based on the defined output structure.
# Input Data
* **Customer Name:** {customer_name}
* **Region:** {region}
* **Detail Region Info:** {detail_region_info}
# Output Rules
1. **Language:** All descriptive content must be written in **Korean (한국어)**.
2. **Terminology:** Use professional marketing terminology suitable for the hospitality and stay industry.
3. **Strict Selection for `selling_points.english_category` and `selling_points.korean_category`:** You must select the value for both category field in `selling_points` strictly from the following English - Korean set allowed list to ensure UI compatibility:
* `LOCATION` (입지 환경), `CONCEPT` (브랜드 컨셉), `PRIVACY` (프라이버시), `NIGHT MOOD` (야간 감성), `HEALING` (힐링 요소), `PHOTO SPOT` (포토 스팟), `SHORT GETAWAY` (숏브레이크), `HOSPITALITY` (서비스), `SWIMMING POOL` (수영장), `JACUZZI` (자쿠지), `BBQ PARTY` (바베큐), `FIRE PIT` (불멍), `GARDEN` (정원), `BREAKFAST` (조식), `KIDS FRIENDLY` (키즈 케어), `PET FRIENDLY` (애견 동반), `OCEAN VIEW` (오션뷰), `PRIVATE POOL` (개별 수영장), `OCEAN VIEW`, `PRIVATE POOL`.
---
# Instruction per Output Field (Mapping Logic)
### 1. brand_identity
* **`location_feature_analysis`**: Analyze the marketing advantages of the given `{region}` and `{detail_region_info}`. Explain why this specific location is attractive to travelers. summarize in 1-2 sentences. (e.g., proximity to nature, accessibility from Seoul, or unique local atmosphere).
* **`concept_scalability`**: Based on `{customer_name}`, analyze how the brand's core concept can expand into a total customer experience or additional services. summarize in 1-2 sentences.
### 2. market_positioning
* **`category_definition`**: Define a sharp, niche market category for this business (e.g., "Private Forest Cabin" or "Luxury Kids Pool Villa").
* **`core_value`**: Identify the single most compelling emotional or functional value that distinguishes `{customer_name}` from competitors.
### 3. target_persona
Generate a list of personas based on the following:
* **`persona`**: Provide a descriptive name and profile for the target group.
* **`age`**: Set `min_age` and `max_age` (Integer 0-100) that accurately reflects the segment.
* **`favor_target`**: List specific elements or vibes this persona prefers (e.g., "Minimalist interior", "Pet-friendly facilities").
* **`decision_trigger`**: Identify the specific "Hook" or facility that leads this persona to finalize a booking.
### 4. selling_points
Generate 5-8 selling points:
* **`english_category`**: Strictly use one keyword from the English allowed list provided in the Output Rules.
* **`korean category`**: Strictly use one keyword from the Korean allowed list provided in the Output Rules . It must be matched with english category.
* **`description`**: A short, punchy marketing phrase in Korean (15~30 characters).
* **`score`**: An integer (0-100) representing the strength of this feature based on the brand's potential.
### 5. target_keywords
* **`target_keywords`**: Provide a list of 10 highly relevant marketing keywords or hashtags for search engine optimization and social media targeting. Do not insert # in front of hashtag.

View File

@ -1,224 +1,75 @@
# System Prompt: 숙박 숏폼 자막 생성 (OpenAI Optimized)
당신은 숙박 브랜드 숏폼 영상의 자막 콘텐츠를 추출하는 전문가입니다.
You are a subtitle copywriter for hospitality short-form videos. You generate subtitle text AND layer names from marketing JSON data.
입력으로 주어지는 **1) 5가지 기준의 레이어 이름 리스트**와 **2) 마케팅 인텔리전스 분석 결과(JSON)**를 바탕으로, 각 레이어 이름의 의미에 정확히 1:1 매칭되는 텍스트 콘텐츠만을 추출하세요.
분석 결과에 없는 정보는 절대 지어내거나 추론하지 마세요. 오직 제공된 JSON 데이터 내에서만 텍스트를 구성해야 합니다.
---
### RULES
## 1. 레이어 네이밍 규칙 해석 및 매핑 가이드
1. NEVER copy JSON verbatim. ALWAYS rewrite into video-optimized copy.
2. NEVER invent facts not in the data. You MAY freely transform expressions.
3. Each scene = 1 subtitle + 1 keyword (a "Pair"). Same pair_id for both.
입력되는 모든 레이어 이름은 예외 없이 `<track_role>-<narrative_phase>-<content_type>-<tone>-<pair_id>` 의 5단계 구조로 되어 있습니다.
마지막의 3자리 숫자 ID(`-001`, `-002` 등)는 모든 레이어에 필수적으로 부여됩니다.
### [1] track_role (텍스트 형태)
- `subtitle`: 씬 상황을 설명하는 간결한 문장형 텍스트 (1줄 이내)
- `keyword`: 씬을 상징하고 시선을 끄는 단답형/명사형 텍스트 (1~2단어)
### [2] narrative_phase (영상 흐름)
- `intro`: 영상 도입부. 가장 시선을 끄는 정보를 배치.
- `core`: 핵심 매력이나 주요 편의 시설 어필.
- `highlight`: 세부적인 매력 포인트나 공간의 특별한 분위기 묘사.
- `outro`: 영상 마무리. 브랜드 명칭 복기 및 타겟/위치 정보 제공.
### [3] content_type (데이터 매핑 대상)
- `hook_claim` 👉 `selling_points`에서 점수가 가장 높은 1순위 소구점이나 `market_positioning.core_value`를 활용하여 가장 강력한 핵심 세일즈 포인트를 어필. (가장 강력한 셀링포인트를 의미함)
- `selling_point` 👉 `selling_points`의 `description`, `korean_category` 등을 narrative 흐름에 맞춰 순차적으로 추출.
- `brand_name` 👉 JSON의 `store_name`을 추출.
- `location_info` 👉 JSON의 `detail_region_info`를 요약.
- `target_tag` 👉 `target_persona`나 `target_keywords`에서 타겟 고객군 또는 해시태그 추출.
### [4] tone (텍스트 어조)
- `sensory`: 직관적이고 감각적인 단어 사용
- `factual`: 과장 없이 사실 정보를 담백하게 전달
- `empathic`: 고객의 상황에 공감하는 따뜻한 어조
- `aspirational`: 열망을 자극하고 기대감을 주는 느낌
### [5] pair_id (씬 묶음 식별 번호)
- 텍스트 레이어는 `subtitle`과 `keyword`가 하나의 페어(Pair)를 이뤄 하나의 씬(Scene)에서 함께 등장합니다.
- 따라서 **동일한 씬에 속하는 `subtitle`과 `keyword` 레이어는 동일한 3자리 순번 ID(예: `-001`)**를 공유합니다.
- 영상 전반적인 씬 전개 순서에 따라 **다음 씬으로 넘어갈 때마다 ID가 순차적으로 증가**합니다. (예: 씬1은 `-001`, 씬2는 `-002`, 씬3은 `-003`...)
- **중요**: ID가 달라진다는 것은 '새로운 씬' 혹은 '다른 텍스트 쌍'을 의미하므로, **ID가 바뀌면 반드시 JSON 내의 다른 소구점이나 데이터를 추출**하여 내용이 중복되지 않도록 해야 합니다.
---
### LAYER NAME FORMAT (5-criteria)
## 2. 콘텐츠 추출 시 주의사항
```
(track_role)-(narrative_phase)-(content_type)-(tone)-(pair_id)
```
- Criteria separator: hyphen `-`
- Multi-word value: underscore `_`
- pair_id: 3-digit zero-padded (`001`~`999`)
Example: `subtitle-intro-hook_claim-aspirational-001`
1. 각 입력 레이어 이름 1개당 **오직 1개의 텍스트 콘텐츠**만 매핑하여 출력합니다. (레이어명 이름 자체를 수정하거나 새로 만들지 마세요.)
2. `content_type`이 `selling_point`로 동일하더라도, `narrative_phase`(core, highlight)나 `tone`이 달라지면 JSON 내의 2순위, 3순위 세일즈 포인트를 순차적으로 활용하여 내용 겹침을 방지하세요.
3. 같은 씬에 속하는(같은 ID 번호를 가진) keyword는 핵심 단어로, subtitle은 적절한 마케팅 문구가 되어야 하며, 자연스럽게 이어지는 문맥을 형성하도록 구성하세요.
4. keyword가 subtitle에 완전히 포함되는 단어가 되지 않도록 유의하세요.
5. 정보 태그가 같더라도 ID가 다르다면 중복되지 않는 새로운 텍스트를 도출해야 합니다.
6. 콘텐츠 추출 시 마케팅 인텔리전스의 내용을 그대로 사용하기보다는 paraphrase을 수행하세요.
7. keyword는 공백 포함 전각 8자 / 반각 16자내, subtitle은 전각 15자 / 반각 30자 내로 구성하세요.
---
### TAG VALUES
## 3. 출력 결과 포맷 및 예시
**track_role**: `subtitle` | `keyword`
입력된 레이어 이름 순서에 맞춰, 매핑된 텍스트 콘텐츠만 작성하세요. (반드시 intro, core, highlight, outro 등 모든 씬 단계가 명확하게 매핑되어야 합니다.)
**narrative_phase** (= emotion goal):
- `intro` → Curiosity (stop the scroll)
- `welcome` → Warmth
- `core` → Trust
- `highlight` → Desire (peak moment)
- `support` → Discovery
- `accent` → Belonging
- `cta` → Action
### 입력 레이어 리스트 예시 및 출력 예시
**content_type** → source mapping:
- `hook_claim` ← selling_points[0] or core_value
- `space_feature` ← selling_points[].description
- `emotion_cue` ← same source, sensory rewrite
- `brand_name` ← store_name (verbatim OK)
- `brand_address` ← detail_region_info (verbatim OK)
- `lifestyle_fit` ← target_persona[].favor_target
- `local_info` ← location_feature_analysis
- `target_tag` ← target_keywords[] as hashtags
- `availability` ← fixed: "지금 예약 가능"
- `cta_action` ← fixed: "예약하러 가기"
**tone**: `sensory` | `factual` | `empathic` | `aspirational` | `social_proof` | `urgent`
---
### SCENE STRUCTURE
**Anchors (FIXED — never remove):**
| Position | Phase | subtitle | keyword |
|---|---|---|---|
| First | intro | hook_claim | brand_name |
| Last-3 | support | brand_address | brand_name |
| Last-2 | accent | target_tag | lifestyle_fit |
| Last | cta | availability | cta_action |
**Middle (FLEXIBLE — fill by selling_points score desc):**
| Phase | subtitle | keyword |
|---|---|---|
| welcome | emotion_cue | space_feature |
| core | space_feature | emotion_cue |
| highlight | space_feature | emotion_cue |
| support(mid) | local_info | lifestyle_fit |
Default: 7 scenes. Fewer scenes → remove flexible slots only.
---
### TEXT SPECS
**subtitle**: 8~18 chars. Sentence fragment, conversational.
**keyword**: 2~6 chars. MUST follow Korean word-formation rules below.
---
### KEYWORD RULES (한국어 조어법 기반)
Keywords MUST follow one of these **permitted Korean patterns**. Any keyword that does not match a pattern below is INVALID.
#### Pattern 1: 관형형 + 명사 (Attributive + Noun) — 가장 자연스러운 패턴
한국어는 수식어가 앞, 피수식어가 뒤. 형용사의 관형형(~ㄴ/~한/~는/~운)을 명사 앞에 붙인다.
| Structure | GOOD | BAD (역순/비문) |
|---|---|---|
| 형용사 관형형 + 명사 | 고요한 숲, 깊은 쉼, 온전한 쉼 | ~~숲고요~~, ~~쉼깊은~~ |
| 형용사 관형형 + 명사 | 따뜻한 독채, 느린 하루 | ~~독채따뜻~~, ~~하루느린~~ |
| 동사 관형형 + 명사 | 쉬어가는 숲, 머무는 시간 | ~~숲쉬어가는~~ |
#### Pattern 2: 기존 대중화 합성어 ONLY (Established Trending Compound)
이미 SNS·미디어에서 대중화된 합성어만 허용. 임의 신조어 생성 금지.
| GOOD (대중화 확인됨) | Origin | BAD (임의 생성) |
|---|---|---|
| 숲멍 | 숲+멍때리기 (불멍, 물멍 시리즈) | ~~숲고요~~, ~~숲힐~~ |
| 댕캉스 | 댕댕이+바캉스 (여행업계 통용) | ~~댕쉼~~, ~~댕여행~~ |
| 꿀잠 / 꿀쉼 | 꿀+잠/쉼 (일상어 정착) | ~~꿀독채~~, ~~꿀숲~~ |
| 집콕 / 숲콕 | 집+콕 → 숲+콕 (변형 허용) | ~~계곡콕~~ |
| 주말러 | 주말+~러 (~러 접미사 정착) | ~~평일러~~ |
> **판별 기준**: "이 단어를 네이버/인스타에서 검색하면 결과가 나오는가?" YES → 허용, NO → 금지
#### Pattern 3: 명사 + 명사 (Natural Compound Noun)
한국어 복합명사 규칙을 따르는 결합만 허용. 앞 명사가 뒷 명사를 수식하는 관계여야 한다.
| Structure | GOOD | BAD (부자연스러운 결합) |
|---|---|---|
| 장소 + 유형 | 숲속독채, 계곡펜션 | ~~햇살독채~~ (햇살은 장소가 아님) |
| 대상 + 활동 | 반려견산책, 가족피크닉 | ~~견주피크닉~~ (견주가 피크닉하는 건 어색) |
| 시간 + 활동 | 주말탈출, 새벽산책 | ~~자연독채~~ (자연은 시간/방식이 아님) |
#### Pattern 4: 해시태그형 (#키워드)
accent(target_tag) 씬에서만 사용. 기존 검색 키워드를 # 붙여서 사용.
| GOOD | BAD |
| Layer Name | Text Content |
|---|---|
| #프라이빗독채, #홍천여행 | #숲고요, #감성쩌는 (검색량 없음) |
#### Pattern 5: 감각/상태 명사 (단독 사용 가능한 것만)
그 자체로 의미가 완결되는 감각·상태 명사만 단독 사용 허용.
| GOOD (단독 의미 완결) | BAD (단독으로 의미 불완전) |
|---|---|
| 고요, 여유, 쉼, 온기 | ~~감성~~, ~~자연~~, ~~힐링~~ (너무 모호) |
| 숲멍, 꿀쉼 | ~~좋은쉼~~, ~~편안함~~ (형용사 포함 시 Pattern 1 사용) |
---
### KEYWORD VALIDATION CHECKLIST (생성 후 자가 검증)
Every keyword MUST pass ALL of these:
- [ ] 한국어 어순이 자연스러운가? (수식어→피수식어 순서)
- [ ] 소리 내어 읽었을 때 어색하지 않은가?
- [ ] 네이버/인스타에서 검색하면 실제 결과가 나올 법한 표현인가?
- [ ] 허용된 5개 Pattern 중 하나에 해당하는가?
- [ ] 이전 씬 keyword와 동일한 Pattern을 연속 사용하지 않았는가?
- [ ] 금지 표현 사전에 해당하지 않는가?
---
### EXPRESSION DICTIONARY
**SCAN BEFORE WRITING.** If JSON contains these → MUST replace:
| Forbidden | → Use Instead |
|---|---|
| 눈치 없는/없이 | 눈치 안 보는 · 프라이빗한 · 온전한 · 마음 편히 |
| 감성 쩌는/쩌이 | 감성 가득한 · 감성이 머무는 |
| 가성비 | 합리적인 · 가치 있는 |
| 힐링되는 | 회복되는 · 쉬어가는 · 숨 쉬는 |
| 인스타감성 | 감성 스팟 · 기록하고 싶은 |
| 혜자 | 풍성한 · 넉넉한 |
**ALWAYS FORBIDDEN**: 저렴한, 싼, 그냥, 보통, 무난한, 평범한, 쩌는, 쩔어, 개(접두사), 존맛, 핵, 인스타, 유튜브, 틱톡
**SYNONYM ROTATION**: Same Korean word max 2 scenes. Rotate:
- 프라이빗 계열: 온전한 · 오롯한 · 나만의 · 독채 · 단독
- 자연 계열: 숲속 · 초록 · 산림 · 계곡
- 쉼 계열: 쉼 · 여유 · 느린 하루 · 머무름 · 숨고르기
- 반려견: 댕댕이(max 1회, intro/accent만) · 반려견 · 우리 강아지
---
### TRANSFORM RULES BY CONTENT_TYPE
**hook_claim** (intro only):
- Format: question OR exclamation OR provocation. Pick ONE.
- FORBIDDEN: brand name, generic greetings
- `"반려견과 눈치 없는 힐링"` → BAD: 그대로 복사 → GOOD: "댕댕이가 먼저 뛰어간 숲"
**space_feature** (core/highlight):
- ONE selling point per scene
- NEVER use korean_category directly
- Viewer must imagine themselves there
- `"홍천 자연 속 조용한 쉼"` → BAD: "입지 환경이 좋은 곳" → GOOD: "계곡 소리만 들리는 독채"
**emotion_cue** (welcome/core/highlight):
- Senses: smell, sound, touch, temperature, light
- Poetic fragments, not full sentences
- `"감성 쩌이 완성되는 공간"` → GOOD: "햇살이 내려앉는 테라스"
**lifestyle_fit** (accent/support):
- Address target directly in their language
- `persona: "서울·경기 주말러"` → GOOD: "이번 주말, 댕댕이랑 어디 가지?"
**local_info** (support):
- Accessibility or charm, NOT administrative address
- GOOD: "서울에서 1시간 반, 홍천 숲속" / BAD: "강원 홍천군 화촌면"
---
### PACING
```
intro(8~12) → welcome(12~18) → core(alternate 8~12 ↔ 12~18) → highlight(8~14) → support(12~18) → accent(variable) → cta(12~16)
```
**RULE: No 3+ consecutive scenes in same char-count range.**
---
Keyword pattern analysis:
- "스테이펫" → brand_name verbatim (허용)
- "고요한 숲" → Pattern 1: 관형형+명사 (형용사 관형형 "고요한" + 명사 "숲")
- "깊은 쉼" → Pattern 1: 관형형+명사 (형용사 관형형 "깊은" + 명사 "쉼")
- "숲멍" → Pattern 2: 기존 대중화 합성어 (불멍·물멍·숲멍 시리즈)
- "댕캉스" → Pattern 2: 기존 대중화 합성어 (댕댕이+바캉스, 여행업계 통용)
- "예약하기" → Pattern 5: 의미 완결 동사 명사형
| subtitle-intro-hook_claim-aspirational-001 | 반려견과 눈치 없이 온전하게 쉬는 완벽한 휴식 |
| keyword-intro-brand_name-sensory-001 | 스테이펫 홍천 |
| subtitle-core-selling_point-empathic-002 | 우리만의 독립된 공간감이 주는 진정한 쉼 |
| keyword-core-selling_point-factual-002 | 프라이빗 독채 |
| subtitle-highlight-selling_point-sensory-003 | 탁 트인 야외 무드존과 포토 스팟의 감성 컷 |
| keyword-highlight-selling_point-factual-003 | 넓은 정원 |
| subtitle-outro-target_tag-empathic-004 | #강원도애견동반 #주말숏브레이크 |
| keyword-outro-location_info-factual-004 | 강원 홍천군 화촌면 |
# 입력
@ -231,3 +82,6 @@ Keyword pattern analysis:
**입력 3: 비즈니스 정보 **
Business Name: {customer_name}
Region Details: {detail_region_info}

View File

@ -0,0 +1,143 @@
[ROLE]
You are a YouTube SEO/AEO content strategist specialized in local stay, pension, and accommodation brands in Korea.
You create search-optimized, emotionally appealing, and action-driving titles and descriptions based on Brand & Marketing Intelligence.
Your goal is to:
Increase search visibility
Improve click-through rate
Reflect the brands positioning
Trigger emotional interest
Encourage booking or inquiry actions through subtle CTA
[INPUT]
Business Name: {customer_name}
Region Details: {detail_region_info}
Brand & Marketing Intelligence Report: {marketing_intelligence_summary}
Target Keywords: {target_keywords}
Output Language: {language}
[INTERNAL ANALYSIS DO NOT OUTPUT]
Analyze the following from the marketing intelligence:
Core brand concept
Main emotional promise
Primary target persona
Top 23 USP signals
Stay context (date, healing, local trip, etc.)
Search intent behind the target keywords
Main booking trigger
Emotional moment that would make the viewer want to stay
Use these to guide:
Title tone
Opening CTA line
Emotional hook in the first sentences
[TITLE GENERATION RULES]
The title must:
Include the business name or region when natural
Always wrap the business name in quotation marks
Example: “스테이 머뭄”
Include 12 high-intent keywords
Reflect emotional positioning
Suggest a desirable stay moment
Sound like a natural YouTube title, not an advertisement
Length rules:
Hard limit: 100 characters
Target range: 4565 characters
Place primary keyword in the first half
Avoid:
ALL CAPS
Excessive symbols
Price or promotion language
Hard-sell expressions
[DESCRIPTION GENERATION RULES]
Character rules:
Maximum length: 1,000 characters
Critical information must appear within the first 150 characters
Language style rules (mandatory):
Use polite Korean honorific style
Replace “있나요?” with “있으신가요?”
Do not start sentences with “이곳은”
Replace “선택이 됩니다” with “추천 드립니다”
Always wrap the business name in quotation marks
Example: “스테이 머뭄”
Avoid vague location words like “근대거리” alone
Use specific phrasing such as:
“군산 근대역사문화거리 일대”
Structure:
Opening CTA (first line)
Must be a question or gentle suggestion
Must use honorific tone
Example:
“조용히 쉴 수 있는 군산숙소를 찾고 있으신가요?”
Core Stay Introduction (within first 150 characters total)
Mention business name with quotation marks
Mention region
Include main keyword
Briefly describe the stay experience
Brand Experience
Core value and emotional promise
Based on marketing intelligence positioning
Key Highlights (34 short lines)
Derived from USP signals
Natural sentences
Focus on booking-trigger moments
Local Context
Mention nearby experiences
Use specific local references
Example:
“군산 근대역사문화거리 일대 산책이나 로컬 카페 투어”
Soft Closing Line
One gentle, non-salesy closing sentence
Must end with a recommendation tone
Example:
“군산에서 조용한 시간을 보내고 싶다면 ‘스테이 머뭄’을 추천 드립니다.”
[SEO & AEO RULES]
Naturally integrate 35 keywords from {target_keywords}
Avoid keyword stuffing
Use conversational, search-like phrasing
Optimize for:
YouTube search
Google video results
AI answer summaries
Keywords should appear in:
Title (12)
First 150 characters of description
Highlight or context sections
[LANGUAGE RULE]
All output must be written entirely in {language}.
No mixed languages.
[OUTPUT FORMAT STRICT]
title:
description:
No explanations.
No headings.
No extra text.

View File

@ -6,24 +6,15 @@ from typing import Literal, Any
import httpx
from app.utils.logger import get_logger
from app.utils.prompts.chatgpt_prompt import ChatgptService
from app.utils.chatgpt_prompt import ChatgptService
from app.utils.prompts.schemas import *
from app.utils.prompts.prompts import *
logger = get_logger("subtitle")
class SubtitleContentsGenerator():
def __init__(self):
self.chatgpt_service = ChatgptService(timeout=60.0)
self.chatgpt_service = ChatgptService()
async def generate_subtitle_contents(self, marketing_intelligence : dict[str, Any], pitching_label_list : list[Any], customer_name : str, detail_region_info : str) -> SubtitlePromptOutput:
start = time.perf_counter()
logger.info(
f"[SubtitleContentsGenerator] START - customer: {customer_name}, "
f"pitching_count: {len(pitching_label_list)}, "
f"labels: {pitching_label_list}"
)
dynamic_subtitle_prompt = create_dynamic_subtitle_prompt(len(pitching_label_list))
pitching_label_string = "\n".join(pitching_label_list)
marketing_intel_string = json.dumps(marketing_intelligence, ensure_ascii=False)
@ -33,17 +24,7 @@ class SubtitleContentsGenerator():
"customer_name" : customer_name,
"detail_region_info" : detail_region_info,
}
logger.info(
f"[SubtitleContentsGenerator] GPT 호출 시작 - model: {dynamic_subtitle_prompt.prompt_model}"
)
output_data = await self.chatgpt_service.generate_structured_output(dynamic_subtitle_prompt, input_data)
elapsed = (time.perf_counter() - start) * 1000
logger.info(
f"[SubtitleContentsGenerator] DONE - 소요시간: {elapsed:.0f}ms, "
f"결과: {[r.pitching_tag for r in output_data.pitching_results]}"
)
return output_data

View File

@ -55,12 +55,11 @@ generate() 호출 시 callback_url 파라미터를 전달하면 생성 완료
- 동일 task_id에 대해 여러 콜백 수신 가능 (멱등성 처리 필요)
"""
from typing import Any
from typing import Any, List, Optional
import httpx
from app.song.schemas.song_schema import PollingSongResponse, SongClipData
from app.utils.bgm_lyrics import get_random_bgm_lyrics
from app.utils.logger import get_logger
from config import apikey_settings, recovery_settings
@ -103,10 +102,9 @@ class SunoService:
async def generate(
self,
prompt: str | None = None,
prompt: str,
genre: str | None = None,
callback_url: str | None = None,
instrumental: bool = False,
) -> str:
"""
음악 생성 요청
@ -117,7 +115,6 @@ class SunoService:
genre: 음악 장르 (: "K-Pop", "Pop", "R&B", "Hip-Hop", "Ballad", "EDM")
None일 경우 style 파라미터를 전송하지 않음
callback_url: 생성 완료 알림 받을 URL (None일 경우 config에서 기본값 사용)
instrumental: True이면 BGM 전용 더미 가사로 60 길이를 유도하고 보컬 없이 생성
Returns:
task_id: 작업 추적용 ID
@ -127,26 +124,23 @@ class SunoService:
- 다운로드 URL: 2-3 생성
- 생성되는 노래는 1 이내의 길이
"""
actual_callback_url = callback_url or apikey_settings.SUNO_CALLBACK_URL
# 정확히 1분 길이의 노래 생성을 위한 프롬프트 조건 추가
formatted_prompt = f"[Song Duration: Exactly 1 minute - Must be precisely 60 seconds]\n{prompt}"
if instrumental:
bgm_lyrics, bgm_version = get_random_bgm_lyrics()
formatted_prompt = f"[Song Duration: Around 1 minute - Must be around 60 seconds]\n{bgm_lyrics}"
logger.info(f"[Suno] BGM 더미 가사 버전 {bgm_version} 선택됨")
else:
formatted_prompt = (
f"[Song Duration: Exactly 1 minute - Must be precisely 60 seconds]\n{prompt}"
)
# callback_url이 없으면 config에서 기본값 사용 (Suno API 필수 파라미터)
actual_callback_url = callback_url or apikey_settings.SUNO_CALLBACK_URL
payload: dict[str, Any] = {
"model": "V5",
"customMode": True,
"instrumental": instrumental,
"instrumental": False,
"prompt": formatted_prompt,
"callBackUrl": actual_callback_url,
}
# genre가 있을 때만 style 추가
if genre:
payload["style"] = f"{genre}, around 60 seconds" if instrumental else genre
payload["style"] = genre
last_error: Exception | None = None

View File

@ -1,98 +0,0 @@
"""
내부 전용 좋아요 반응 플러시 API
스케줄러가 1분마다 호출하여 Redis dirty SET의 좋아요 토글을 MySQL에 bulk write합니다.
X-Internal-Secret 헤더로 인증합니다.
"""
import logging
from fastapi import APIRouter, Depends, Header, HTTPException, status
from sqlalchemy import delete, insert, tuple_
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.like_cache import (
commit_dirty_processing,
drain_dirty,
is_user_liked,
)
from app.database.session import get_session
from app.video.models import VideoReaction
from config import internal_settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/internal/video", tags=["Internal"])
@router.post(
"/reactions/flush",
summary="[내부] 좋아요 반응 DB 플러시",
description="스케줄러 서버에서 1분마다 호출하는 내부 전용 엔드포인트입니다. "
"Redis dirty SET의 항목을 MySQL video_reaction 테이블에 bulk write합니다.",
)
async def flush_reactions(
session: AsyncSession = Depends(get_session),
x_internal_secret: str = Header(...),
) -> dict:
"""Redis dirty SET → MySQL bulk write.
1. drain_dirty(): dirty SET을 processing으로 RENAME 항목 조회
2. 항목의 현재 Redis 상태(is_liked) 확인
3. is_liked=True INSERT IGNORE, is_liked=False DELETE
4. commit_dirty_processing(): processing SET 삭제
"""
if x_internal_secret != internal_settings.INTERNAL_SECRET_KEY:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid internal secret",
)
pairs = await drain_dirty()
if not pairs:
logger.info("[REACTION_FLUSH] dirty 항목 없음, 종료")
return {"flushed": 0, "adds": 0, "dels": 0}
logger.info(f"[REACTION_FLUSH] START - dirty 항목 {len(pairs)}")
adds: list[dict] = []
dels: list[tuple[int, str]] = []
# Redis 현재 상태 기준으로 add / delete 분류
for video_id, user_uuid in pairs:
liked = await is_user_liked(video_id, user_uuid)
if liked:
adds.append({"video_id": video_id, "user_uuid": user_uuid})
else:
dels.append((video_id, user_uuid))
try:
# Bulk INSERT IGNORE — UniqueConstraint 보장으로 멱등 처리
if adds:
await session.execute(
insert(VideoReaction).prefix_with("IGNORE").values(adds)
)
# Bulk DELETE
if dels:
await session.execute(
delete(VideoReaction).where(
tuple_(
VideoReaction.video_id,
VideoReaction.user_uuid,
).in_(dels)
)
)
await session.commit()
await commit_dirty_processing()
logger.info(
f"[REACTION_FLUSH] SUCCESS - adds: {len(adds)}, dels: {len(dels)}"
)
return {"flushed": len(pairs), "adds": len(adds), "dels": len(dels)}
except Exception as e:
await session.rollback()
logger.error(f"[REACTION_FLUSH] EXCEPTION - error: {e}")
raise HTTPException(status_code=500, detail=f"플러시 실패: {str(e)}")

View File

@ -14,51 +14,31 @@ Video API Router
"""
import json
from collections import defaultdict
import asyncio
from typing import Literal
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
from sqlalchemy import func, or_, select
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from 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.dependencies.auth import get_current_user
from app.user.models import User
from app.utils.pagination import PaginatedResponse
from app.home.models import Image, Project, MarketingIntel, ImageTag
from app.home.api.routers.v1.home import _extract_region_from_address
from app.home.models import Image, Project, MarketingIntel
from app.lyric.models import Lyric
from app.song.models import Song, SongTimestamp
from app.utils.creatomate import CreatomateService
from app.utils.subtitles import SubtitleContentsGenerator
from app.comment.models import Comment
from app.database.like_cache import (
backfill_user_set,
bulk_is_user_liked,
get_like_count,
get_like_counts,
is_user_liked,
is_user_set_exists,
mark_dirty,
mset_like_counts,
set_like_count,
toggle_like_atomic,
)
from app.utils.logger import get_logger
from app.video.models import Video, VideoReaction
from app.video.models import Video
from app.video.schemas.video_schema import (
DownloadVideoResponse,
GenerateVideoResponse,
LikeToggleResponse,
PollingVideoResponse,
VideoDetailResponse,
VideoRenderData,
VideoThumbnailItem,
)
from app.video.worker.video_task import download_and_upload_video_to_blob
from app.video.services.video import get_image_tags_by_task_id
from config import creatomate_settings
@ -167,9 +147,37 @@ async def generate_video(
image_urls: list[str] = []
try:
subtitle_done = False
count = 0
async with AsyncSessionLocal() as session:
project_result = await session.execute(
select(Project)
.where(Project.task_id == task_id)
.order_by(Project.created_at.desc())
.limit(1)
)
project = project_result.scalar_one_or_none()
while not subtitle_done:
async with AsyncSessionLocal() as session:
logger.info(f"[generate_video] Checking subtitle- task_id: {task_id}, count : {count}")
marketing_result = await session.execute(
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
)
marketing_intelligence = marketing_result.scalar_one_or_none()
subtitle_done = bool(marketing_intelligence.subtitle)
if subtitle_done:
logger.info(f"[generate_video] Check subtitle done task_id: {task_id}")
break
await asyncio.sleep(5)
if count > 12 :
raise Exception("subtitle 결과 생성 실패")
count += 1
# 세션을 명시적으로 열고 DB 작업 후 바로 닫음
async with AsyncSessionLocal() as session:
# ===== 순차 쿼리 실행: Project, MarketingIntel, Lyric, Song, Image =====
# ===== 순차 쿼리 실행: Project, Lyric, Song, Image =====
# Note: AsyncSession은 동일 세션에서 병렬 쿼리를 지원하지 않음
# Project 조회
@ -179,44 +187,6 @@ async def generate_video(
.order_by(Project.created_at.desc())
.limit(1)
)
project = project_result.scalar_one_or_none()
if not project:
logger.warning(f"[generate_video] Project NOT FOUND - task_id: {task_id}")
raise HTTPException(
status_code=404,
detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.",
)
project_id = project.id
store_address = project.detail_region_info
brand_name = project.store_name
region = project.region
# MarketingIntel 조회
marketing_result = await session.execute(
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
)
marketing_intelligence: MarketingIntel = marketing_result.scalar_one_or_none()
# subtitle 미완료 시 즉시 반환 — Lyric/Song/Image 쿼리 전에 체크하여 불필요한 조회 방지
# 클라이언트가 /lyric/subtitle/status/{task_id} 폴링 후 재시도
if not marketing_intelligence.subtitle:
logger.info(f"[generate_video] subtitle pending - task_id: {task_id}")
return GenerateVideoResponse(
success=False,
status="subtitle_pending",
task_id=task_id,
creatomate_render_id=None,
message="자막 생성이 아직 완료되지 않았습니다. /lyric/subtitle/status/{task_id}로 완료 확인 후 재요청하세요.",
error_message=None,
)
category_definition = marketing_intelligence.intel_result["market_positioning"]["category_definition"]
target_keywords = marketing_intelligence.intel_result["target_keywords"]
brand_concept = ""
for sp in marketing_intelligence.intel_result["selling_points"]:
if "concept" in sp["english_category"].lower():
brand_concept = sp["description"]
# Lyric 조회
lyric_result = await session.execute(
@ -247,6 +217,25 @@ async def generate_video(
f"elapsed: {(query_time - request_start) * 1000:.1f}ms"
)
# ===== 결과 처리: Project =====
project = project_result.scalar_one_or_none()
if not project:
logger.warning(
f"[generate_video] Project NOT FOUND - task_id: {task_id}"
)
raise HTTPException(
status_code=404,
detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.",
)
project_id = project.id
store_address = project.detail_region_info
# customer_name = project.store_name
marketing_result = await session.execute(
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
)
marketing_intelligence = marketing_result.scalar_one_or_none()
# ===== 결과 처리: Lyric =====
lyric = lyric_result.scalar_one_or_none()
if not lyric:
@ -294,19 +283,10 @@ async def generate_video(
)
image_urls = [img.img_url for img in images]
# SongTimestamp 조회 (외부 API 호출 전 필요한 데이터이므로 1단계에서 수집)
song_timestamp_result = await session.execute(
select(SongTimestamp).where(
SongTimestamp.suno_audio_id == song.suno_audio_id
)
)
song_timestamp_list = song_timestamp_result.scalars().all()
logger.info(
f"[generate_video] Data loaded - task_id: {task_id}, "
f"project_id: {project_id}, lyric_id: {lyric_id}, "
f"song_id: {song_id}, images: {len(image_urls)}, "
f"timestamps: {len(song_timestamp_list)}"
f"song_id: {song_id}, images: {len(image_urls)}"
)
# ===== Video 테이블에 초기 데이터 저장 및 커밋 =====
@ -357,47 +337,23 @@ async def generate_video(
)
# 6-1. 템플릿 조회 (비동기)
template = await creatomate_service.get_one_template_data(
template = await creatomate_service.get_one_template_data_async(
creatomate_service.template_id
)
logger.debug(f"[generate_video] Template fetched - task_id: {task_id}")
# 6-2. elements에서 리소스 매핑 생성
# modifications = creatomate_service.elements_connect_resource_blackbox(
# elements=template["source"]["elements"],
# image_url_list=image_urls,
# music_url=music_url,
# address=store_address
taged_image_list = await get_image_tags_by_task_id(task_id)
min_image_num = creatomate_service.counting_component(
template = template,
target_template_type = "image"
)
duplicate = bool(len(taged_image_list) < min_image_num)
logger.info(f"[generate_video] Duplicate : {duplicate} | length of taged_image {len(taged_image_list)}, min_len {min_image_num},- task_id: {task_id}")
modifications = creatomate_service.template_matching_taged_image(
template = template,
taged_image_list = taged_image_list,
music_url = music_url,
address = store_address,
duplicate = duplicate,
modifications = creatomate_service.elements_connect_resource_blackbox(
elements=template["source"]["elements"],
image_url_list=image_urls,
music_url=music_url,
address=store_address
)
logger.debug(f"[generate_video] Modifications created - task_id: {task_id}")
subtitle_modifications = marketing_intelligence.subtitle
modifications.update(subtitle_modifications)
# revert thumbnail scene
# thumbnail_modifications = creatomate_service.make_thumbnail_modification(
# brand_name =brand_name,
# region = region,
# brand_concept = brand_concept,
# category_definition= category_definition,
# target_keywords=target_keywords)
# modifications.update(thumbnail_modifications)
# 6-3. elements 수정
new_elements = creatomate_service.modify_element(
template["source"]["elements"],
@ -416,6 +372,13 @@ async def generate_video(
logger.debug(f"[generate_video] Duration extended - task_id: {task_id}")
song_timestamp_result = await session.execute(
select(SongTimestamp).where(
SongTimestamp.suno_audio_id == song.suno_audio_id
)
)
song_timestamp_list = song_timestamp_result.scalars().all()
logger.debug(f"[generate_video] song_timestamp_list count: {len(song_timestamp_list)}")
for i, ts in enumerate(song_timestamp_list):
@ -450,7 +413,7 @@ async def generate_video(
# f"[generate_video] final_template: {json.dumps(final_template, indent=2, ensure_ascii=False)}"
# )
# 6-5. 커스텀 렌더링 요청 (비동기)
render_response = await creatomate_service.make_creatomate_custom_call(
render_response = await creatomate_service.make_creatomate_custom_call_async(
final_template["source"],
)
@ -602,7 +565,7 @@ async def get_video_status(
)
try:
creatomate_service = CreatomateService()
result = await creatomate_service.get_render_status(creatomate_render_id)
result = await creatomate_service.get_render_status_async(creatomate_render_id)
logger.debug(
f"[get_video_status] Creatomate API response - creatomate_render_id: {creatomate_render_id}, status: {result.get('status')}"
)
@ -677,7 +640,7 @@ async def get_video_status(
import traceback
logger.error(
f"[get_video_status] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}\n{traceback.format_exc()}"
f"[get_video_status] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}"
)
return PollingVideoResponse(
success=False,
@ -685,7 +648,7 @@ async def get_video_status(
message="상태 조회에 실패했습니다.",
render_data=None,
raw_response=None,
error_message=f"{type(e).__name__}: {e}",
error_message=f"{type(e).__name__}: {e}\n{traceback.format_exc()}",
)
@ -795,7 +758,7 @@ async def download_video(
status="completed",
message="영상 다운로드가 완료되었습니다.",
store_name=project.store_name if project else None,
region=project.region or _extract_region_from_address(project.detail_region_info) if project else None,
region=project.region if project else None,
task_id=task_id,
result_movie_url=video.result_movie_url,
created_at=video.created_at,
@ -809,353 +772,3 @@ async def download_video(
message="영상 다운로드 조회에 실패했습니다.",
error_message=str(e),
)
@router.get(
"/all",
summary="ADO2 콘텐츠 - 전체 사용자 영상 갤러리",
description="""
## 개요
모든 사용자가 생성 완료한 영상을 페이지네이션하여 반환합니다.
## 쿼리 파라미터
- **page**: 페이지 번호 (1부터 시작, 기본값: 1)
- **page_size**: 페이지당 데이터 (기본값: 10, 최대: 100)
- **sort_by**: 정렬 기준 (created_at: 최신순, like_count: 좋아요순, comment_count: 댓글순, 기본값: created_at)
- **order**: 정렬 방향 (desc: 내림차순, asc: 오름차순, 기본값: desc)
- **store_name**: 업체명 검색 (부분 일치, 값이 있을 때만 전송)
- **region**: 지역명 검색 (부분 일치, 값이 있을 때만 전송)
""",
response_model=PaginatedResponse[VideoThumbnailItem],
responses={
200: {"description": "갤러리 조회 성공"},
500: {"description": "조회 실패"},
},
)
async def get_all_videos(
current_user: User | None = Depends(get_current_user_optional),
session: AsyncSession = Depends(get_session),
pagination: PaginationParams = Depends(get_pagination_params),
sort_by: str = Query(default="created_at", description="정렬 기준 (created_at, like_count, comment_count)"),
order: str = Query(default="desc", description="정렬 방향 (desc, asc)"),
store_name: str | None = Query(default=None, description="업체명 검색 (부분 일치)"),
region: str | None = Query(default=None, description="지역명 검색 (부분 일치)"),
) -> PaginatedResponse[VideoThumbnailItem]:
"""전체 사용자의 완료된 영상 갤러리를 반환합니다."""
logger.info(
f"[get_all_videos] START - page: {pagination.page}, page_size: {pagination.page_size}, "
f"sort_by: {sort_by}, order: {order}, store_name: {store_name}, region: {region}"
)
try:
offset = (pagination.page - 1) * pagination.page_size
where_clauses = [
Video.status == "completed",
Video.is_deleted == False, # noqa: E712
Project.is_deleted == False, # noqa: E712
Video.result_movie_url.is_not(None),
]
if store_name:
where_clauses.append(Project.store_name.ilike(f"%{store_name}%"))
if region:
where_clauses.append(
or_(
Project.region.ilike(f"%{region}%"),
Project.detail_region_info.ilike(f"%{region}%"),
)
)
count_q = (
select(func.count(Video.id))
.join(Project, Video.project_id == Project.id)
.where(*where_clauses)
)
total = (await session.execute(count_q)).scalar() or 0
comment_count_subq = (
select(func.count(Comment.id))
.where(
Comment.video_id == Video.id,
Comment.is_deleted == False, # noqa: E712
)
.correlate(Video)
.scalar_subquery()
)
# like_count 정렬은 Redis 대신 서브쿼리로 처리 (ORDER BY에만 사용)
like_count_subq_for_sort = (
select(func.count(VideoReaction.id))
.where(VideoReaction.video_id == Video.id)
.correlate(Video)
.scalar_subquery()
)
sort_col_map = {
"like_count": like_count_subq_for_sort,
"comment_count": comment_count_subq,
"created_at": Video.created_at,
}
sort_col = sort_col_map.get(sort_by, Video.created_at)
order_clause = sort_col.asc() if order == "asc" else sort_col.desc()
list_q = (
select(
Video,
Project,
comment_count_subq.label("comment_count"),
)
.join(Project, Video.project_id == Project.id)
.where(*where_clauses)
.order_by(order_clause)
.offset(offset)
.limit(pagination.page_size)
)
rows = (await session.execute(list_q)).all()
video_ids = [v.id for v, p, _ in rows]
# Redis mget으로 like_count 일괄 조회
like_count_map = await get_like_counts(video_ids)
# 카운트 캐시 미스 보정
missing_ids = [vid for vid, cnt in like_count_map.items() if cnt is None]
if missing_ids:
db_counts = (await session.execute(
select(VideoReaction.video_id, func.count(VideoReaction.id))
.where(VideoReaction.video_id.in_(missing_ids))
.group_by(VideoReaction.video_id)
)).all()
db_found_ids = set()
batch = {}
for vid, cnt in db_counts:
batch[vid] = cnt
like_count_map[vid] = cnt
db_found_ids.add(vid)
await mset_like_counts(batch)
for vid in missing_ids:
if vid not in db_found_ids:
like_count_map[vid] = 0
# is_liked_by_me: Redis user-set 기준, cold-start 시 DB backfill
liked_map: dict[int, bool] = {}
if current_user:
raw_liked = await bulk_is_user_liked(video_ids, current_user.user_uuid)
# user-set이 없는(None) 영상 중 count > 0인 것만 backfill 필요
needs_backfill = [
vid for vid, liked in raw_liked.items()
if liked is None and like_count_map.get(vid, 0) > 0
]
if needs_backfill:
reaction_rows = (await session.execute(
select(VideoReaction.video_id, VideoReaction.user_uuid)
.where(VideoReaction.video_id.in_(needs_backfill))
)).all()
user_map: dict[int, list[str]] = defaultdict(list)
for vid, uuid in reaction_rows:
user_map[vid].append(uuid)
for vid in needs_backfill:
await backfill_user_set(vid, user_map.get(vid, []))
# backfill 후 재조회
updated = await bulk_is_user_liked(needs_backfill, current_user.user_uuid)
raw_liked.update(updated)
liked_map = {vid: bool(liked) for vid, liked in raw_liked.items()}
items = [
VideoThumbnailItem(
video_id=v.id,
store_name=p.store_name,
result_movie_url=v.result_movie_url,
created_at=v.created_at,
like_count=like_count_map.get(v.id) or 0,
is_liked_by_me=liked_map.get(v.id, False),
comment_count=comment_count or 0,
)
for v, p, comment_count in rows
]
response = PaginatedResponse.create(
items=items,
total=total,
page=pagination.page,
page_size=pagination.page_size,
)
logger.info(f"[get_all_videos] SUCCESS - total: {total}, items: {len(items)}")
return response
except Exception as e:
logger.error(f"[get_all_videos] EXCEPTION - error: {e}")
raise HTTPException(status_code=500, detail=f"갤러리 조회에 실패했습니다: {str(e)}")
@router.post(
"/{video_id}/like",
summary="영상 좋아요 토글",
description="""
## 개요
영상에 좋아요를 토글합니다. 로그인 필수.
- 처음 호출: 좋아요 추가 (is_liked=true)
- 다시 호출: 좋아요 취소 (is_liked=false)
""",
response_model=LikeToggleResponse,
responses={
200: {"description": "토글 성공"},
401: {"description": "인증 실패"},
404: {"description": "영상을 찾을 수 없음"},
},
)
async def toggle_like(
video_id: int,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> LikeToggleResponse:
"""영상 좋아요를 토글합니다.
Write-Behind 패턴:
1. Redis user-set / count를 즉시 원자적으로 업데이트 (Lua script)
2. dirty SET에 표시 스케줄러가 1분마다 MySQL에 반영
DB write가 없으므로 고트래픽에서도 응답 지연 없음.
"""
logger.info(f"[toggle_like] START - video_id: {video_id}, user: {current_user.user_uuid}")
try:
# 영상 존재 확인 (DB read는 유지 — 404 처리 필수)
video_result = await session.execute(
select(Video).where(
Video.id == video_id,
Video.status == "completed",
Video.is_deleted == False, # noqa: E712
)
)
if video_result.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="영상을 찾을 수 없습니다.")
# Cold-start 보정: Redis에 데이터가 없으면 DB에서 backfill
count = await get_like_count(video_id)
if count is None:
# 카운트와 user-set 모두 없음 → DB에서 전체 복구
user_uuids = (await session.execute(
select(VideoReaction.user_uuid)
.where(VideoReaction.video_id == video_id)
)).scalars().all()
await backfill_user_set(video_id, list(user_uuids))
await set_like_count(video_id, len(user_uuids))
elif count > 0:
if not await is_user_set_exists(video_id):
# 카운트는 있지만 user-set이 증발한 경우 (부분 캐시 미스)
user_uuids = (await session.execute(
select(VideoReaction.user_uuid)
.where(VideoReaction.video_id == video_id)
)).scalars().all()
await backfill_user_set(video_id, list(user_uuids))
# Lua 스크립트로 원자적 토글 (race condition 방지)
is_liked, like_count = await toggle_like_atomic(video_id, current_user.user_uuid)
# dirty SET에 표시 → 스케줄러가 DB에 반영
await mark_dirty(video_id, current_user.user_uuid)
logger.info(
f"[toggle_like] SUCCESS - video_id: {video_id}, "
f"is_liked: {is_liked}, count: {like_count}"
)
return LikeToggleResponse(video_id=video_id, is_liked=is_liked, like_count=like_count)
except HTTPException:
raise
except Exception as e:
logger.error(f"[toggle_like] EXCEPTION - video_id: {video_id}, error: {e}")
raise HTTPException(status_code=500, detail=f"좋아요 처리에 실패했습니다: {str(e)}")
@router.get(
"/{video_id}",
summary="단일 영상 상세 조회",
description="""
## 개요
video_id에 해당하는 완료된 영상의 상세 정보를 반환합니다.
## 경로 파라미터
- **video_id**: 조회할 영상의 ID (Video.id)
""",
response_model=VideoDetailResponse,
responses={
200: {"description": "상세 조회 성공"},
404: {"description": "영상을 찾을 수 없음"},
500: {"description": "조회 실패"},
},
)
async def get_video_detail(
video_id: int,
current_user: User | None = Depends(get_current_user_optional),
session: AsyncSession = Depends(get_session),
) -> VideoDetailResponse:
"""video_id에 해당하는 완료된 영상 상세 정보를 반환합니다."""
logger.info(f"[get_video_detail] START - video_id: {video_id}")
try:
result = await session.execute(
select(Video, Project)
.join(Project, Video.project_id == Project.id)
.where(
Video.id == video_id,
Video.status == "completed",
Video.is_deleted == False, # noqa: E712
Project.is_deleted == False, # noqa: E712
)
)
row = result.one_or_none()
if row is None:
logger.warning(f"[get_video_detail] NOT FOUND - video_id: {video_id}")
raise HTTPException(status_code=404, detail="영상을 찾을 수 없습니다.")
video, project = row
# like_count: Redis 조회, 캐시 미스 시 DB backfill
like_count = await get_like_count(video_id)
if like_count is None:
user_uuids = (await session.execute(
select(VideoReaction.user_uuid)
.where(VideoReaction.video_id == video_id)
)).scalars().all()
like_count = len(user_uuids)
await backfill_user_set(video_id, list(user_uuids))
await set_like_count(video_id, like_count)
# is_liked_by_me: Redis user-set 기준, cold-start 시 DB backfill
is_liked_by_me = False
if current_user:
liked = await is_user_liked(video_id, current_user.user_uuid)
if liked is None:
# user-set 없음 → count key로 cold-start 여부 판별
if like_count > 0:
user_uuids = (await session.execute(
select(VideoReaction.user_uuid)
.where(VideoReaction.video_id == video_id)
)).scalars().all()
await backfill_user_set(video_id, list(user_uuids))
liked = current_user.user_uuid in set(user_uuids)
else:
liked = False
is_liked_by_me = liked
logger.info(f"[get_video_detail] SUCCESS - video_id: {video_id}")
return VideoDetailResponse(
video_id=video.id,
result_movie_url=video.result_movie_url,
store_name=project.store_name,
region=project.region or _extract_region_from_address(project.detail_region_info),
created_at=video.created_at,
like_count=like_count,
is_liked_by_me=is_liked_by_me,
)
except HTTPException:
raise
except Exception as e:
logger.error(f"[get_video_detail] EXCEPTION - video_id: {video_id}, error: {e}")
raise HTTPException(status_code=500, detail=f"영상 조회에 실패했습니다: {str(e)}")

View File

@ -1,17 +1,15 @@
from datetime import datetime
from typing import TYPE_CHECKING, List, Optional
from typing import TYPE_CHECKING, Optional
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, UniqueConstraint, func
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base
if TYPE_CHECKING:
from app.comment.models import Comment
from app.home.models import Project
from app.lyric.models import Lyric
from app.song.models import Song
from app.user.models import User
class Video(Base):
@ -35,8 +33,6 @@ class Video(Base):
project: 연결된 Project
lyric: 연결된 Lyric
song: 연결된 Song
comments: 영상 댓글 목록
likes: 영상 좋아요 목록
"""
__tablename__ = "video"
@ -136,20 +132,6 @@ class Video(Base):
back_populates="videos",
)
comments: Mapped[List["Comment"]] = relationship(
"Comment",
back_populates="video",
cascade="all, delete-orphan",
lazy="noload",
)
reactions: Mapped[List["VideoReaction"]] = relationship(
"VideoReaction",
back_populates="video",
cascade="all, delete-orphan",
lazy="noload",
)
def __repr__(self) -> str:
def truncate(value: str | None, max_len: int = 10) -> str:
if value is None:
@ -163,50 +145,3 @@ class Video(Base):
f"status='{self.status}'"
f")>"
)
class VideoReaction(Base):
"""
영상 반응 테이블
사용자가 영상에 반응(현재는 좋아요) 남기면 생성, 다시 누르면 삭제(토글).
(user_uuid, video_id) 유니크 제약으로 1 1 보장.
향후 reaction_type 컬럼 추가로 다양한 반응 종류 확장 가능.
"""
__tablename__ = "video_reaction"
__table_args__ = (
UniqueConstraint("user_uuid", "video_id", name="uq_video_reaction_user_video"),
Index("idx_video_reaction_video_id", "video_id"),
Index("idx_video_reaction_user_uuid", "user_uuid"),
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
},
)
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True, comment="고유 식별자"
)
video_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("video.id", ondelete="CASCADE"),
nullable=False,
comment="연결된 Video의 id",
)
user_uuid: Mapped[str] = mapped_column(
String(36),
ForeignKey("user.user_uuid", ondelete="CASCADE"),
nullable=False,
comment="반응한 사용자 UUID",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
comment="반응 일시",
)
video: Mapped["Video"] = relationship("Video", back_populates="reactions")
user: Mapped["User"] = relationship("User", back_populates="video_reactions")

View File

@ -36,7 +36,6 @@ class GenerateVideoResponse(BaseModel):
)
success: bool = Field(..., description="요청 성공 여부")
status: Optional[str] = Field(None, description="처리 상태 (subtitle_pending: 자막 미완료, completed: 정상 접수)")
task_id: Optional[str] = Field(None, description="내부 작업 ID (Project task_id)")
creatomate_render_id: Optional[str] = Field(None, description="Creatomate 렌더 ID")
message: str = Field(..., description="응답 메시지")
@ -158,51 +157,5 @@ class VideoListItem(BaseModel):
task_id: str = Field(..., description="작업 고유 식별자")
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
created_at: Optional[datetime] = Field(None, description="생성 일시")
like_count: int = Field(0, description="좋아요 수")
comment_count: int = Field(0, description="댓글 수 (대댓글 포함)")
class VideoThumbnailItem(BaseModel):
"""ADO2 콘텐츠 갤러리용 최소 영상 정보 (썸네일 표시 + 상세 페이지 이동용)
Usage:
GET /video/all 응답의 개별 영상 정보
"""
video_id: int = Field(..., description="영상 고유 ID (상세 페이지 라우팅 키)")
store_name: str = Field(..., description="업체명")
result_movie_url: str = Field(..., description="영상 URL — 프론트에서 <video> 태그 첫 프레임을 썸네일로 사용")
created_at: datetime = Field(..., description="생성 일시")
like_count: int = Field(..., description="좋아요 수")
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)")
comment_count: int = Field(..., description="댓글 수 (대댓글 포함)")
class VideoDetailResponse(BaseModel):
"""단일 영상 상세 응답
Usage:
GET /video/{video_id}
"""
video_id: int = Field(..., description="영상 고유 ID")
result_movie_url: str = Field(..., description="영상 URL")
store_name: Optional[str] = Field(None, description="업체명")
region: Optional[str] = Field(None, description="지역명")
created_at: datetime = Field(..., description="생성 일시")
like_count: int = Field(..., description="좋아요 수")
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)")
class LikeToggleResponse(BaseModel):
"""좋아요 토글 응답
Usage:
POST /video/{video_id}/like
"""
video_id: int = Field(..., description="영상 고유 ID")
is_liked: bool = Field(..., description="토글 후 상태 (true=좋아요 누름, false=취소됨)")
like_count: int = Field(..., description="토글 후 전체 좋아요 수")

File diff suppressed because it is too large Load Diff

View File

@ -13,7 +13,6 @@ from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from app.database.session import BackgroundSessionLocal
from app.user.services.credit import consume_credit
from app.video.models import Video
from app.utils.upload_blob_as_request import AzureBlobUploader
from app.utils.logger import get_logger
@ -155,12 +154,6 @@ async def download_and_upload_video_to_blob(
# Video 테이블 업데이트 (creatomate_render_id로 특정 Video 식별)
await _update_video_status(task_id, "completed", blob_url, creatomate_render_id)
# 영상 생성 완료 시 크레딧 1 차감 (credits > 0 조건으로 음수 방지)
async with BackgroundSessionLocal() as session:
await consume_credit(user_uuid, session)
await session.commit()
logger.info(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}, creatomate_render_id: {creatomate_render_id}")
except httpx.HTTPError as e:

View File

@ -31,8 +31,6 @@ class ProjectSettings(BaseSettings):
VERSION: str = Field(default="0.1.0")
DESCRIPTION: str = Field(default="FastAPI 기반 CastAD 프로젝트")
ADMIN_BASE_URL: str = Field(default="/admin")
ADMIN_SESSION_SECRET: str = Field(default="dev-secret-change-me-in-production")
ADMIN_SESSION_MAX_AGE: int = Field(default=60 * 60 * 8)
DEBUG: bool = Field(default=True)
TIMEZONE: str = Field(
default="Asia/Seoul",
@ -44,7 +42,6 @@ class ProjectSettings(BaseSettings):
class APIKeySettings(BaseSettings):
CHATGPT_API_KEY: str = Field(default="your-chatgpt-api-key") # 기본값 추가
GEMINI_API_KEY: str = Field(default="your-gemeni-api-key") # 기본값 추가
SUNO_API_KEY: str = Field(default="your-suno-api-key") # Suno API 키
SUNO_CALLBACK_URL: str = Field(
default="https://example.com/api/suno/callback"
@ -183,8 +180,19 @@ class CreatomateSettings(BaseSettings):
model_config = _base_config
class PromptSettings(BaseSettings):
GOOGLE_SERVICE_ACCOUNT_JSON: str = Field(...)
PROMPT_SPREADSHEET: str = Field(...)
PROMPT_FOLDER_ROOT : str = Field(default="./app/utils/prompts/templates")
MARKETING_PROMPT_FILE_NAME : str = Field(default="marketing_prompt.txt")
MARKETING_PROMPT_MODEL : str = Field(default="gpt-5.2")
LYRIC_PROMPT_FILE_NAME : str = Field(default="lyric_prompt.txt")
LYRIC_PROMPT_MODEL : str = Field(default="gpt-5-mini")
YOUTUBE_PROMPT_FILE_NAME : str = Field(default="yt_upload_prompt.txt")
YOUTUBE_PROMPT_MODEL : str = Field(default="gpt-5-mini")
SUBTITLE_PROMPT_FILE_NAME : str = Field(...)
SUBTITLE_PROMPT_MODEL : str = Field(...)
model_config = _base_config
@ -198,14 +206,6 @@ class RecoverySettings(BaseSettings):
# ============================================================
# ChatGPT API 설정
# ============================================================
LLM_TIMEOUT: float = Field(
default=600.0,
description="LLM Default API 타임아웃 (초)",
)
LLM_MAX_RETRIES: int = Field(
default=1,
description="LLM API 응답 실패 시 최대 재시도 횟수",
)
CHATGPT_TIMEOUT: float = Field(
default=600.0,
description="ChatGPT API 타임아웃 (초). OpenAI Python SDK 기본값: 600초 (10분)",
@ -214,6 +214,7 @@ class RecoverySettings(BaseSettings):
default=1,
description="ChatGPT API 응답 실패 시 최대 재시도 횟수",
)
# ============================================================
# Suno API 설정
# ============================================================

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