Compare commits
No commits in common. "main" and "subtitle" have entirely different histories.
|
|
@ -52,5 +52,3 @@ Dockerfile
|
|||
.dockerignore
|
||||
|
||||
zzz/
|
||||
credentials/service_account.json
|
||||
o2o-castad-scheduler/
|
||||
|
|
@ -276,5 +276,3 @@ fastapi run main.py
|
|||
│◀───────────────│ │ │
|
||||
│ │ │ │
|
||||
```
|
||||
|
||||
testAc
|
||||
|
|
@ -1,78 +1,48 @@
|
|||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from sqladmin import Admin
|
||||
from 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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
from sqladmin import ModelView
|
||||
from wtforms import PasswordField, SelectField
|
||||
|
||||
from app.backoffice.admin.models import Admin
|
||||
from app.backoffice.mixins import SuperAdminOnly
|
||||
|
||||
|
||||
class AdminAdmin(SuperAdminOnly, ModelView, model=Admin):
|
||||
name = "관리자 계정"
|
||||
name_plural = "관리자 계정 목록"
|
||||
icon = "fa-solid fa-user-shield"
|
||||
category = "백오피스 설정"
|
||||
page_size = 30
|
||||
|
||||
column_list = [
|
||||
"id",
|
||||
"username",
|
||||
"name",
|
||||
"role",
|
||||
"is_active",
|
||||
"last_login_at",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
column_details_list = [
|
||||
"id",
|
||||
"username",
|
||||
"name",
|
||||
"role",
|
||||
"is_active",
|
||||
"last_login_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
form_columns = ["username", "password", "name", "role", "is_active"]
|
||||
|
||||
form_overrides = {
|
||||
"password": PasswordField,
|
||||
"role": SelectField,
|
||||
}
|
||||
|
||||
form_args = {
|
||||
"role": {
|
||||
"label": "권한",
|
||||
"choices": [("superadmin", "전체 관리자"), ("viewer", "일반 관리자")],
|
||||
"default": "viewer",
|
||||
}
|
||||
}
|
||||
|
||||
column_searchable_list = [Admin.username, Admin.name]
|
||||
|
||||
column_default_sort = (Admin.created_at, True)
|
||||
|
||||
column_sortable_list = [
|
||||
Admin.id,
|
||||
Admin.username,
|
||||
Admin.is_active,
|
||||
Admin.last_login_at,
|
||||
Admin.created_at,
|
||||
]
|
||||
|
||||
column_labels = {
|
||||
"id": "ID",
|
||||
"username": "아이디",
|
||||
"name": "이름",
|
||||
"role": "권한",
|
||||
"is_active": "활성화",
|
||||
"last_login_at": "마지막 로그인",
|
||||
"created_at": "생성일시",
|
||||
"updated_at": "수정일시",
|
||||
}
|
||||
|
||||
can_delete = False
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from sqladmin.authentication import AuthenticationBackend
|
||||
from sqlalchemy import select
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
from app.backoffice.admin.models import Admin
|
||||
from app.database.session import AsyncSessionLocal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminAuthBackend(AuthenticationBackend):
|
||||
async def login(self, request: Request) -> bool:
|
||||
form = await request.form()
|
||||
username = form.get("username", "")
|
||||
password = form.get("password", "")
|
||||
|
||||
if not username or not password:
|
||||
return False
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(Admin).where(
|
||||
Admin.username == username,
|
||||
Admin.is_active == True, # noqa: E712
|
||||
)
|
||||
)
|
||||
admin = result.scalar_one_or_none()
|
||||
|
||||
if admin is None or admin.password != password:
|
||||
logger.warning(f"[ADMIN-AUTH] login failed username={username}")
|
||||
return False
|
||||
|
||||
request.session["admin_id"] = admin.id
|
||||
request.session["admin_role"] = admin.role
|
||||
request.session["admin_name"] = admin.name or admin.username
|
||||
logger.info(f"[ADMIN-AUTH] login success admin_id={admin.id} username={username} role={admin.role}")
|
||||
|
||||
# 마지막 로그인 시간 갱신
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(select(Admin).where(Admin.id == admin.id))
|
||||
a = result.scalar_one()
|
||||
a.last_login_at = datetime.now()
|
||||
await session.commit()
|
||||
|
||||
return True
|
||||
|
||||
async def logout(self, request: Request) -> bool:
|
||||
request.session.clear()
|
||||
return True
|
||||
|
||||
async def authenticate(self, request: Request) -> bool:
|
||||
admin_id = request.session.get("admin_id")
|
||||
if not admin_id:
|
||||
return False
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(Admin).where(
|
||||
Admin.id == admin_id,
|
||||
Admin.is_active == True, # noqa: E712
|
||||
)
|
||||
)
|
||||
admin = result.scalar_one_or_none()
|
||||
|
||||
if admin is None:
|
||||
logger.warning(f"[ADMIN-AUTH] authenticate failed admin_id={admin_id}")
|
||||
request.session.clear()
|
||||
return False
|
||||
|
||||
return True
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, DateTime, Index, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database.session import Base
|
||||
|
||||
|
||||
class Admin(Base):
|
||||
__tablename__ = "admin"
|
||||
__table_args__ = (
|
||||
Index("idx_admin_username", "username", unique=True),
|
||||
Index("idx_admin_is_active", "is_active"),
|
||||
{
|
||||
"mysql_engine": "InnoDB",
|
||||
"mysql_charset": "utf8mb4",
|
||||
"mysql_collate": "utf8mb4_unicode_ci",
|
||||
},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BigInteger,
|
||||
primary_key=True,
|
||||
nullable=False,
|
||||
autoincrement=True,
|
||||
comment="고유 식별자",
|
||||
)
|
||||
|
||||
username: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
comment="로그인 ID",
|
||||
)
|
||||
|
||||
password: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
comment="비밀번호",
|
||||
)
|
||||
|
||||
name: Mapped[Optional[str]] = mapped_column(
|
||||
String(50),
|
||||
nullable=True,
|
||||
comment="표시 이름",
|
||||
)
|
||||
|
||||
role: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default="viewer",
|
||||
server_default="viewer",
|
||||
comment="권한 (superadmin: 전체, viewer: 조회만)",
|
||||
)
|
||||
|
||||
is_active: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=True,
|
||||
comment="활성화 상태 (비활성화 시 로그인 차단)",
|
||||
)
|
||||
|
||||
last_login_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime,
|
||||
nullable=True,
|
||||
comment="마지막 로그인 일시",
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
comment="생성 일시",
|
||||
)
|
||||
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
comment="수정 일시",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Admin(id={self.id}, username='{self.username}', is_active={self.is_active})>"
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.backoffice.admin.models import Admin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_admin(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
username: str,
|
||||
password: str,
|
||||
name: Optional[str] = None,
|
||||
) -> Admin:
|
||||
admin = Admin(
|
||||
username=username,
|
||||
password=password,
|
||||
name=name,
|
||||
)
|
||||
session.add(admin)
|
||||
await session.commit()
|
||||
await session.refresh(admin)
|
||||
logger.info(f"[ADMIN] created admin username={username}")
|
||||
return admin
|
||||
|
||||
|
||||
async def change_password(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
admin_id: int,
|
||||
new_password: str,
|
||||
) -> None:
|
||||
result = await session.execute(select(Admin).where(Admin.id == admin_id))
|
||||
admin = result.scalar_one_or_none()
|
||||
if admin is None:
|
||||
raise ValueError(f"Admin id={admin_id} not found")
|
||||
admin.password = new_password
|
||||
await session.commit()
|
||||
logger.info(f"[ADMIN] password changed admin_id={admin_id}")
|
||||
|
|
@ -1,205 +0,0 @@
|
|||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import outerjoin
|
||||
from sqladmin import ModelView, action
|
||||
from app.backoffice.mixins import SuperAdminEditable
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
from app.backoffice.admin.models import Admin
|
||||
from app.credit.models import CreditChargeRequest, CreditTransaction
|
||||
from app.credit.services.credit_service import approve_charge_request, reject_charge_request
|
||||
from app.database.session import AsyncSessionLocal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CreditChargeRequestAdmin(SuperAdminEditable, ModelView, model=CreditChargeRequest):
|
||||
name = "충전 요청"
|
||||
name_plural = "충전 요청 목록"
|
||||
icon = "fa-solid fa-coins"
|
||||
category = "크레딧 관리"
|
||||
page_size = 30
|
||||
can_edit = True
|
||||
can_delete = False
|
||||
|
||||
column_list = [
|
||||
"id",
|
||||
"user_uuid",
|
||||
"requested_amount",
|
||||
"status",
|
||||
"admin.name",
|
||||
"processed_at",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
column_details_list = [
|
||||
"id",
|
||||
"user_uuid",
|
||||
"requested_amount",
|
||||
"message",
|
||||
"status",
|
||||
"admin.name",
|
||||
"admin_note",
|
||||
"processed_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
form_columns = ["admin_note"]
|
||||
|
||||
can_create = False
|
||||
|
||||
column_searchable_list = [
|
||||
CreditChargeRequest.user_uuid,
|
||||
CreditChargeRequest.status,
|
||||
]
|
||||
|
||||
column_default_sort = (CreditChargeRequest.created_at, True)
|
||||
|
||||
column_sortable_list = [
|
||||
CreditChargeRequest.id,
|
||||
CreditChargeRequest.user_uuid,
|
||||
CreditChargeRequest.requested_amount,
|
||||
CreditChargeRequest.status,
|
||||
CreditChargeRequest.processed_at,
|
||||
CreditChargeRequest.created_at,
|
||||
]
|
||||
|
||||
column_labels = {
|
||||
"id": "ID",
|
||||
"user_uuid": "사용자 UUID",
|
||||
"requested_amount": "요청 크레딧",
|
||||
"message": "사용자 메시지",
|
||||
"status": "상태",
|
||||
"admin.name": "처리 관리자",
|
||||
"admin_note": "관리자 메모",
|
||||
"processed_at": "처리일시",
|
||||
"created_at": "요청일시",
|
||||
"updated_at": "수정일시",
|
||||
}
|
||||
|
||||
@action(
|
||||
name="approve_request",
|
||||
label="승인",
|
||||
confirmation_message="선택한 충전 요청을 승인하시겠습니까?",
|
||||
add_in_detail=True,
|
||||
add_in_list=True,
|
||||
)
|
||||
async def approve_action(self, request: Request) -> RedirectResponse:
|
||||
admin_id = request.session.get("admin_id")
|
||||
pks = request.query_params.get("pks", "")
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
for pk in pks.split(","):
|
||||
if not pk.strip():
|
||||
continue
|
||||
try:
|
||||
await approve_charge_request(
|
||||
session=session,
|
||||
request_id=int(pk),
|
||||
admin_id=admin_id,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.warning(f"[CREDIT-ADMIN] approve failed request_id={pk} error={e}")
|
||||
|
||||
return RedirectResponse(request.url_for("admin:list", identity=self.identity), status_code=302)
|
||||
|
||||
@action(
|
||||
name="reject_request",
|
||||
label="반려",
|
||||
confirmation_message="선택한 충전 요청을 반려하시겠습니까?",
|
||||
add_in_detail=True,
|
||||
add_in_list=True,
|
||||
)
|
||||
async def reject_action(self, request: Request) -> RedirectResponse:
|
||||
admin_id = request.session.get("admin_id")
|
||||
pks = request.query_params.get("pks", "")
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
for pk in pks.split(","):
|
||||
if not pk.strip():
|
||||
continue
|
||||
try:
|
||||
await reject_charge_request(
|
||||
session=session,
|
||||
request_id=int(pk),
|
||||
admin_id=admin_id,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.warning(f"[CREDIT-ADMIN] reject failed request_id={pk} error={e}")
|
||||
|
||||
return RedirectResponse(request.url_for("admin:list", identity=self.identity), status_code=302)
|
||||
|
||||
|
||||
class CreditTransactionAdmin(SuperAdminEditable, ModelView, model=CreditTransaction):
|
||||
name = "크레딧 변경"
|
||||
name_plural = "크레딧 변경 목록"
|
||||
icon = "fa-solid fa-clock-rotate-left"
|
||||
category = "크레딧 관리"
|
||||
page_size = 30
|
||||
|
||||
can_create = False
|
||||
can_edit = False
|
||||
can_delete = False
|
||||
|
||||
column_list = [
|
||||
"id",
|
||||
"user_uuid",
|
||||
"amount",
|
||||
"balance_after",
|
||||
"type",
|
||||
"admin.name",
|
||||
"related_request_id",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
column_details_list = [
|
||||
"id",
|
||||
"user_uuid",
|
||||
"amount",
|
||||
"balance_after",
|
||||
"type",
|
||||
"reason",
|
||||
"admin.name",
|
||||
"related_request_id",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
column_searchable_list = [
|
||||
CreditTransaction.user_uuid,
|
||||
CreditTransaction.type,
|
||||
]
|
||||
|
||||
column_default_sort = (CreditTransaction.created_at, True)
|
||||
|
||||
column_sortable_list = [
|
||||
CreditTransaction.id,
|
||||
CreditTransaction.user_uuid,
|
||||
CreditTransaction.amount,
|
||||
CreditTransaction.type,
|
||||
CreditTransaction.created_at,
|
||||
]
|
||||
|
||||
column_labels = {
|
||||
"id": "ID",
|
||||
"user_uuid": "사용자 UUID",
|
||||
"amount": "변경 크레딧",
|
||||
"balance_after": "변경 후 잔액",
|
||||
"type": "변경 유형",
|
||||
"reason": "사유",
|
||||
"admin.name": "처리 관리자",
|
||||
"related_request_id": "충전 요청 ID",
|
||||
"created_at": "변경 일시",
|
||||
}
|
||||
|
||||
def list_query(self, _request: Request):
|
||||
return (
|
||||
select(CreditTransaction)
|
||||
.select_from(outerjoin(CreditTransaction, Admin, CreditTransaction.admin_id == Admin.id))
|
||||
)
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from app.credit.models import ChargeRequestStatus, CreditChargeRequest, CreditTransaction, CreditTransactionType
|
||||
from app.database.session import AsyncSessionLocal
|
||||
from app.user.models import User
|
||||
from config import TIMEZONE
|
||||
|
||||
|
||||
async def get_dashboard_context() -> dict:
|
||||
async with AsyncSessionLocal() as session:
|
||||
today_start = datetime.now(TIMEZONE).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
pending_charge_requests_count = (await session.execute(
|
||||
select(func.count()).select_from(CreditChargeRequest)
|
||||
.where(CreditChargeRequest.status == ChargeRequestStatus.PENDING)
|
||||
)).scalar()
|
||||
|
||||
today_charge = (await session.execute(
|
||||
select(func.count()).select_from(CreditTransaction)
|
||||
.where(
|
||||
CreditTransaction.type == CreditTransactionType.CHARGE,
|
||||
CreditTransaction.created_at >= today_start,
|
||||
)
|
||||
)).scalar()
|
||||
|
||||
today_consume = (await session.execute(
|
||||
select(func.count()).select_from(CreditTransaction)
|
||||
.where(
|
||||
CreditTransaction.type == CreditTransactionType.CONSUME,
|
||||
CreditTransaction.created_at >= today_start,
|
||||
)
|
||||
)).scalar()
|
||||
|
||||
month_start = datetime.now(TIMEZONE).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
month_consume = (await session.execute(
|
||||
select(func.coalesce(func.sum(func.abs(CreditTransaction.amount)), 0))
|
||||
.select_from(CreditTransaction)
|
||||
.where(
|
||||
CreditTransaction.type == CreditTransactionType.CONSUME,
|
||||
CreditTransaction.created_at >= month_start,
|
||||
)
|
||||
)).scalar()
|
||||
|
||||
pending_requests = (await session.execute(
|
||||
select(CreditChargeRequest)
|
||||
.where(CreditChargeRequest.status == ChargeRequestStatus.PENDING)
|
||||
.order_by(CreditChargeRequest.created_at.desc())
|
||||
.limit(10)
|
||||
)).scalars().all()
|
||||
|
||||
recent_transactions = (await session.execute(
|
||||
select(CreditTransaction)
|
||||
.order_by(CreditTransaction.created_at.desc())
|
||||
.limit(10)
|
||||
)).scalars().all()
|
||||
|
||||
recent_users = (await session.execute(
|
||||
select(User)
|
||||
.where(User.is_deleted == False)
|
||||
.order_by(User.created_at.desc())
|
||||
.limit(10)
|
||||
)).scalars().all()
|
||||
|
||||
return {
|
||||
"stats": {
|
||||
"pending_charge_requests": pending_charge_requests_count,
|
||||
"today_charge": today_charge,
|
||||
"today_consume": today_consume,
|
||||
"month_consume": month_consume,
|
||||
},
|
||||
"pending_requests": pending_requests,
|
||||
"recent_transactions": recent_transactions,
|
||||
"recent_users": recent_users,
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
{% extends "sqladmin/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">
|
||||
{% for pk in model_view.pk_columns -%}
|
||||
{{ pk.name }}
|
||||
{%- if not loop.last %};{% endif -%}
|
||||
{% endfor %}: {{ get_object_identifier(model) }}</h3>
|
||||
</div>
|
||||
<div class="card-body border-bottom py-3">
|
||||
<div class="table-responsive">
|
||||
<table class="table card-table table-vcenter text-nowrap table-hover table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-1">Column</th>
|
||||
<th class="w-1">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for name in model_view._details_prop_names %}
|
||||
{% set label = model_view._column_labels.get(name, name) %}
|
||||
<tr>
|
||||
<td>{{ label }}</td>
|
||||
{% set value, formatted_value = model_view.get_detail_value(model, name) %}
|
||||
{% if name in model_view._relation_names %}
|
||||
{% if is_list( value ) %}
|
||||
<td>
|
||||
{% for elem, formatted_elem in zip(value, formatted_value) %}
|
||||
{% if model_view.show_compact_lists %}
|
||||
<a href="{{ model_view._build_url_for('admin:details', request, elem) }}">({{ formatted_elem }})</a>
|
||||
{% else %}
|
||||
<a href="{{ model_view._build_url_for('admin:details', request, elem) }}">{{ formatted_elem }}</a><br/>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
{% else %}
|
||||
<td><a href="{{ model_view._url_for_details_with_prop(request, model, name) }}">{{ formatted_value }}</a>
|
||||
</td>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<td>{{ formatted_value }}</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-footer container">
|
||||
<div class="row">
|
||||
<div class="col-md-1">
|
||||
<a href="{{ url_for('admin:list', identity=model_view.identity) }}" class="btn">
|
||||
Go Back
|
||||
</a>
|
||||
</div>
|
||||
{% if model_view.can_delete and request.session.get('admin_role') == 'superadmin' %}
|
||||
<div class="col-md-1">
|
||||
<a href="#" data-name="{{ model_view.name }}" data-pk="{{ get_object_identifier(model) }}"
|
||||
data-url="{{ model_view._url_for_delete(request, model) }}" data-bs-toggle="modal"
|
||||
data-bs-target="#modal-delete" class="btn btn-danger">
|
||||
Delete
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if model_view.can_edit and request.session.get('admin_role') == 'superadmin' %}
|
||||
<div class="col-md-1">
|
||||
<a href="{{ model_view._build_url_for('admin:edit', request, model) }}" class="btn btn-primary">
|
||||
Edit
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% for custom_action,label in model_view._custom_actions_in_detail.items() %}
|
||||
<div class="col-md-1">
|
||||
{% if custom_action in model_view._custom_actions_confirmation %}
|
||||
<a href="#" class="btn btn-secondary" data-bs-toggle="modal"
|
||||
data-bs-target="#modal-confirmation-{{ custom_action }}">
|
||||
{{ label }}
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ model_view._url_for_action(request, custom_action) }}?pks={{ get_object_identifier(model) }}"
|
||||
class="btn btn-secondary">
|
||||
{{ label }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if model_view.can_delete %}
|
||||
{% include 'sqladmin/modals/delete.html' %}
|
||||
{% endif %}
|
||||
|
||||
{% for custom_action in model_view._custom_actions_in_detail %}
|
||||
{% if custom_action in model_view._custom_actions_confirmation %}
|
||||
{% with confirmation_message = model_view._custom_actions_confirmation[custom_action], custom_action=custom_action,
|
||||
url=model_view._url_for_action(request, custom_action) + '?pks=' + (get_object_identifier(model) | string) %}
|
||||
{% include 'sqladmin/modals/details_action_confirmation.html' %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -1,149 +0,0 @@
|
|||
{% extends "sqladmin/layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<!-- 요약 카드 -->
|
||||
<div class="col-12">
|
||||
<div class="row row-deck row-cards mb-4">
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="subheader">대기 중인 요청</div>
|
||||
<div class="h1 mb-3 text-warning">{{ stats.pending_charge_requests }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="subheader">오늘 승인한 요청</div>
|
||||
<div class="h1 mb-3 text-success">{{ stats.today_charge }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="subheader">오늘 소모 크레딧</div>
|
||||
<div class="h1 mb-3 text-danger">{{ stats.today_consume }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="subheader">이번 달 소모 크레딧</div>
|
||||
<div class="h1 mb-3 text-danger">{{ stats.month_consume }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 대기 중인 요청 -->
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">대기 중인 요청</h3>
|
||||
<div class="card-options">
|
||||
<a href="{{ request.url_for('admin:list', identity='credit-charge-request') }}" class="btn btn-sm btn-primary">전체 보기</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>사용자 UUID</th>
|
||||
<th>요청 크레딧</th>
|
||||
<th>요청일시</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for req in pending_requests %}
|
||||
<tr>
|
||||
<td class="text-truncate" style="max-width:150px;">{{ req.user_uuid }}</td>
|
||||
<td>{{ req.requested_amount }}</td>
|
||||
<td>{{ req.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3" class="text-center text-muted">대기 중인 요청 없음</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 최근 크레딧 변화 -->
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">최근 크레딧 변화</h3>
|
||||
<div class="card-options">
|
||||
<a href="{{ request.url_for('admin:list', identity='credit-transaction') }}" class="btn btn-sm btn-primary">전체 보기</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>사용자 UUID</th>
|
||||
<th>유형</th>
|
||||
<th>변경</th>
|
||||
<th>잔액</th>
|
||||
<th>일시</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tx in recent_transactions %}
|
||||
<tr>
|
||||
<td class="text-truncate" style="max-width:120px;">{{ tx.user_uuid }}</td>
|
||||
<td>{{ tx.type }}</td>
|
||||
<td class="{{ 'text-success' if tx.amount > 0 else 'text-danger' }}">{{ '%+d' % tx.amount }}</td>
|
||||
<td>{{ tx.balance_after }}</td>
|
||||
<td>{{ tx.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="text-center text-muted">이력 없음</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 최근 가입 사용자 -->
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">최근 가입 사용자</h3>
|
||||
<div class="card-options">
|
||||
<a href="{{ request.url_for('admin:list', identity='user') }}" class="btn btn-sm btn-primary">전체 보기</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>닉네임</th>
|
||||
<th>이메일</th>
|
||||
<th>크레딧</th>
|
||||
<th>권한</th>
|
||||
<th>가입일시</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in recent_users %}
|
||||
<tr>
|
||||
<td>{{ user.nickname or '-' }}</td>
|
||||
<td>{{ user.email or '-' }}</td>
|
||||
<td>{{ user.credits }}</td>
|
||||
<td>{{ user.role }}</td>
|
||||
<td>{{ user.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
{% extends "sqladmin/base.html" %}
|
||||
{% from 'sqladmin/_macros.html' import display_menu %}
|
||||
{% block body %}
|
||||
<div class="wrapper">
|
||||
<aside class="navbar navbar-expand-lg navbar-vertical navbar-expand-md navbar-dark">
|
||||
<div class="container-fluid">
|
||||
<h1 class="navbar-brand navbar-brand-autodark">
|
||||
<a href="{{ url_for('admin:index') }}">
|
||||
{% if admin.logo_url %}
|
||||
<img src="{{ admin.logo_url }}" width="64" height="64" alt="Admin" class="navbar-brand-image" />
|
||||
{% else %}
|
||||
<h3>{{ admin.title }}</h3>
|
||||
{% endif %}
|
||||
</a>
|
||||
</h1>
|
||||
<nav class="navbar navbar-expand-sm" id="navbar-menu">
|
||||
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent"
|
||||
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
||||
{{ display_menu(admin._menu, request) }}
|
||||
</div>
|
||||
</nav>
|
||||
{% if admin.authentication_backend %}
|
||||
<div class="mb-2 text-center text-white">
|
||||
<div class="fw-bold">{{ request.session.get('admin_name', '') }}</div>
|
||||
<small>
|
||||
{% if request.session.get('admin_role') == 'superadmin' %}
|
||||
<span class="badge bg-danger">전체 관리자</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark">일반 관리자</span>
|
||||
{% endif %}
|
||||
</small>
|
||||
</div>
|
||||
<a href="{{ request.url_for('admin:logout') }}" class="btn btn-secondary btn-icon">
|
||||
<i class="fa fa-sign-out"></i>
|
||||
<span>Logout</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</aside>
|
||||
<div class="page-wrapper">
|
||||
<div class="container-fluid">
|
||||
<div class="page-header d-print-none">
|
||||
{% block content_header %}
|
||||
<div class="row align-items-center">
|
||||
<div class="col">
|
||||
<h2 class="page-title">{{ title }}</h2>
|
||||
<div class="page-pretitle">{{ subtitle }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-body flex-grow-1">
|
||||
<div class="container-fluid">
|
||||
<div class="row row-deck row-cards">
|
||||
{% block content %} {% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -1,299 +0,0 @@
|
|||
{% extends "sqladmin/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex">
|
||||
<div class="flex-grow-1 me-2">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">{{ model_view.name_plural }}</h3>
|
||||
<div class="ms-auto">
|
||||
{% if model_view.can_export %}
|
||||
{% if model_view.export_types | length > 1 %}
|
||||
<div class="ms-3 d-inline-block dropdown">
|
||||
<a href="#" class="btn btn-secondary dropdown-toggle" id="dropdownMenuButton1" data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
Export
|
||||
</a>
|
||||
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton1">
|
||||
{% for export_type in model_view.export_types %}
|
||||
<li><a class="dropdown-item"
|
||||
href="{{ url_for('admin:export', identity=model_view.identity, export_type=export_type) }}">{{
|
||||
export_type | upper }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% elif model_view.export_types | length == 1 %}
|
||||
<div class="ms-3 d-inline-block">
|
||||
<a href="{{ url_for('admin:export', identity=model_view.identity, export_type=model_view.export_types[0]) }}"
|
||||
class="btn btn-secondary">
|
||||
Export
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if model_view.can_create %}
|
||||
<div class="ms-3 d-inline-block">
|
||||
<a href="{{ url_for('admin:create', identity=model_view.identity) }}" class="btn btn-primary">
|
||||
+ New {{ model_view.name }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body border-bottom py-3">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div class="dropdown col-4">
|
||||
<button {% if not model_view.can_delete and not model_view._custom_actions_in_list %} disabled {% endif %}
|
||||
class="btn btn-light dropdown-toggle" type="button" id="dropdownMenuButton" data-toggle="dropdown"
|
||||
aria-haspopup="true" aria-expanded="false">
|
||||
Actions
|
||||
</button>
|
||||
{% if model_view.can_delete or model_view._custom_actions_in_list %}
|
||||
<div class="dropdown-menu" aria-labelledby="dropdownMenuButton">
|
||||
{% if model_view.can_delete and request.session.get('admin_role') == 'superadmin' %}
|
||||
<a class="dropdown-item" id="action-delete" href="#" data-name="{{ model_view.name }}"
|
||||
data-url="{{ url_for('admin:delete', identity=model_view.identity) }}" data-bs-toggle="modal"
|
||||
data-bs-target="#modal-delete">Delete selected items</a>
|
||||
{% endif %}
|
||||
{% for custom_action, label in model_view._custom_actions_in_list.items() %}
|
||||
{% if custom_action in model_view._custom_actions_confirmation %}
|
||||
<a class="dropdown-item" id="action-customconfirm-{{ custom_action }}" href="#" data-bs-toggle="modal"
|
||||
data-bs-target="#modal-confirmation-{{ custom_action }}">
|
||||
{{ label }}
|
||||
</a>
|
||||
{% else %}
|
||||
<a class="dropdown-item" id="action-custom-{{ custom_action }}" href="#"
|
||||
data-url="{{ model_view._url_for_action(request, custom_action) }}">
|
||||
{{ label }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if model_view.column_searchable_list %}
|
||||
<div class="col-md-4 text-muted">
|
||||
<div class="input-group">
|
||||
<input id="search-input" type="text" class="form-control"
|
||||
placeholder="Search: {{ model_view.search_placeholder() }}"
|
||||
value="{{ request.query_params.get('search', '') }}">
|
||||
<button id="search-button" class="btn" type="button">Search</button>
|
||||
<button id="search-reset" class="btn" type="button" {% if not request.query_params.get('search')
|
||||
%}disabled{% endif %}><i class="fa-solid fa-times"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table card-table table-vcenter text-nowrap">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-1"><input class="form-check-input m-0 align-middle" type="checkbox" aria-label="Select all"
|
||||
id="select-all"></th>
|
||||
<th class="w-1"></th>
|
||||
{% for name in model_view._list_prop_names %}
|
||||
{% set label = model_view._column_labels.get(name, name) %}
|
||||
<th>
|
||||
{% if name in model_view._sort_fields %}
|
||||
{% if request.query_params.get("sortBy") == name and request.query_params.get("sort") == "asc" %}
|
||||
<a href="{{ request.url.include_query_params(sort='desc') }}"><i class="fa-solid fa-arrow-up"></i> {{
|
||||
label }}</a>
|
||||
{% elif request.query_params.get("sortBy") == name and request.query_params.get("sort") == "desc" %}
|
||||
<a href="{{ request.url.include_query_params(sort='asc') }}"><i class="fa-solid fa-arrow-down"></i> {{ label
|
||||
}}</a>
|
||||
{% else %}
|
||||
<a href="{{ request.url.include_query_params(sortBy=name, sort='asc') }}">{{ label }}</a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{{ label }}
|
||||
{% endif %}
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in pagination.rows %}
|
||||
<tr>
|
||||
<td>
|
||||
<input type="hidden" value="{{ get_object_identifier(row) }}">
|
||||
<input class="form-check-input m-0 align-middle select-box" type="checkbox" aria-label="Select item">
|
||||
</td>
|
||||
<td class="text-end">
|
||||
{% if model_view.can_view_details %}
|
||||
<a href="{{ model_view._build_url_for('admin:details', request, row) }}" data-bs-toggle="tooltip"
|
||||
data-bs-placement="top" title="View">
|
||||
<span class="me-1"><i class="fa-solid fa-eye"></i></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if model_view.can_edit and request.session.get('admin_role') == 'superadmin' %}
|
||||
<a href="{{ model_view._build_url_for('admin:edit', request, row) }}" data-bs-toggle="tooltip"
|
||||
data-bs-placement="top" title="Edit">
|
||||
<span class="me-1"><i class="fa-solid fa-pen-to-square"></i></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if model_view.can_delete and request.session.get('admin_role') == 'superadmin' %}
|
||||
<a href="#" data-name="{{ model_view.name }}" data-pk="{{ get_object_identifier(row) }}"
|
||||
data-url="{{ model_view._url_for_delete(request, row) }}" data-bs-toggle="modal"
|
||||
data-bs-target="#modal-delete" title="Delete">
|
||||
<span class="me-1"><i class="fa-solid fa-trash"></i></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% for name in model_view._list_prop_names %}
|
||||
{% set value, formatted_value = model_view.get_list_value(row, name) %}
|
||||
{% if name in model_view._relation_names %}
|
||||
{% if is_list( value ) %}
|
||||
<td>
|
||||
{% for elem, formatted_elem in zip(value, formatted_value) %}
|
||||
{% if model_view.show_compact_lists %}
|
||||
<a href="{{ model_view._build_url_for('admin:details', request, elem) }}">({{ formatted_elem }})</a>
|
||||
{% else %}
|
||||
<a href="{{ model_view._build_url_for('admin:details', request, elem) }}">{{ formatted_elem }}</a><br/>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
{% else %}
|
||||
<td><a href="{{ model_view._url_for_details_with_prop(request, row, name) }}">{{ formatted_value }}</a></td>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<td>{{ formatted_value }}</td>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between align-items-center gap-2">
|
||||
<p class="m-0 text-muted">Showing <span>{{ ((pagination.page - 1) * pagination.page_size) + 1 }}</span> to
|
||||
<span>{{ min(pagination.page * pagination.page_size, pagination.count) }}</span> of <span>{{ pagination.count
|
||||
}}</span> items
|
||||
</p>
|
||||
<ul class="pagination m-0 ms-auto">
|
||||
<li class="page-item {% if not pagination.has_previous %}disabled{% endif %}">
|
||||
{% if pagination.has_previous %}
|
||||
<a class="page-link" href="{{ pagination.previous_page.url }}">
|
||||
{% else %}
|
||||
<a class="page-link" href="#">
|
||||
{% endif %}
|
||||
<i class="fa-solid fa-chevron-left"></i>
|
||||
prev
|
||||
</a>
|
||||
</li>
|
||||
{% for page_control in pagination.page_controls %}
|
||||
<li class="page-item {% if page_control.number == pagination.page %}active{% endif %}"><a class="page-link"
|
||||
href="{{ page_control.url }}">{{ page_control.number }}</a></li>
|
||||
{% endfor %}
|
||||
<li class="page-item {% if not pagination.has_next %}disabled{% endif %}">
|
||||
{% if pagination.has_next %}
|
||||
<a class="page-link" href="{{ pagination.next_page.url }}">
|
||||
{% else %}
|
||||
<a class="page-link" href="#">
|
||||
{% endif %}
|
||||
next
|
||||
<i class="fa-solid fa-chevron-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="dropdown text-muted">
|
||||
Show
|
||||
<a href="#" class="btn btn-sm btn-light dropdown-toggle" data-toggle="dropdown" aria-haspopup="true"
|
||||
aria-expanded="false">
|
||||
{{ request.query_params.get("pageSize") or model_view.page_size }} / Page
|
||||
</a>
|
||||
<div class="dropdown-menu">
|
||||
{% for page_size_option in model_view.page_size_options %}
|
||||
<a class="dropdown-item" href="{{ request.url.include_query_params(pageSize=page_size_option, page=pagination.resize(page_size_option).page) }}">
|
||||
{{ page_size_option }} / Page
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if model_view.get_filters() %}
|
||||
<div class="col-md-3" style="width: 300px; flex-shrink: 0;">
|
||||
<div id="filter-sidebar" class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Filters</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% for filter in model_view.get_filters() %}
|
||||
{% if filter.has_operator %}
|
||||
<div class="mb-3">
|
||||
<div class="fw-bold text-truncate">{{ filter.title }}</div>
|
||||
<div>
|
||||
<!-- Show current filter if active -->
|
||||
{% set current_filter = request.query_params.get(filter.parameter_name, '') %}
|
||||
{% set current_op = request.query_params.get(filter.parameter_name + '_op', '') %}
|
||||
{% if current_filter %}
|
||||
<div class="mb-2 text-muted small">
|
||||
Current: {{ current_op }} {{ current_filter }}
|
||||
<a href="{{ request.url.remove_query_params(filter.parameter_name).remove_query_params(filter.parameter_name + '_op') }}" class="text-decoration-none">[Clear]</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- Single form with dropdown for operations -->
|
||||
<form method="get" class="d-flex flex-column" style="gap: 8px;">
|
||||
<!-- Preserve existing query parameters -->
|
||||
{% for key, value in request.query_params.items() %}
|
||||
{% if key != filter.parameter_name and key != filter.parameter_name + '_op' %}
|
||||
<input type="hidden" name="{{ key }}" value="{{ value }}">
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<!-- Operation dropdown -->
|
||||
<select name="{{ filter.parameter_name }}_op" class="form-select form-select-sm" required>
|
||||
<option value="">Select operation...</option>
|
||||
{% for op_value, op_label in filter.get_operation_options_for_model(model_view.model) %}
|
||||
<option value="{{ op_value }}" {% if current_op == op_value %}selected{% endif %}>{{ op_label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<!-- Value input -->
|
||||
<input type="text"
|
||||
name="{{ filter.parameter_name }}"
|
||||
placeholder="Enter value"
|
||||
class="form-control form-control-sm"
|
||||
value="{{ current_filter }}"
|
||||
required>
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">Apply Filter</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- Fallback for other filter types -->
|
||||
<div class="mb-3">
|
||||
<div class="fw-bold text-truncate">{{ filter.title }}</div>
|
||||
<div>
|
||||
{% for lookup in filter.lookups(request, model_view.model, model_view._run_arbitrary_query) %}
|
||||
<a href="{{ request.url.include_query_params(**{filter.parameter_name: lookup[0]}) }}" class="d-block text-decoration-none text-truncate">
|
||||
{{ lookup[1] }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if model_view.can_delete %}
|
||||
{% include 'sqladmin/modals/delete.html' %}
|
||||
{% endif %}
|
||||
|
||||
{% for custom_action in model_view._custom_actions_in_list %}
|
||||
{% if custom_action in model_view._custom_actions_confirmation %}
|
||||
{% with confirmation_message = model_view._custom_actions_confirmation[custom_action], custom_action=custom_action,
|
||||
url=model_view._url_for_action(request, custom_action) %}
|
||||
{% include 'sqladmin/modals/list_action_confirmation.html' %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
from starlette.requests import Request
|
||||
|
||||
|
||||
class SuperAdminOnly:
|
||||
"""superadmin만 접근 가능 (편집/삭제/액션 모두 허용)"""
|
||||
|
||||
def is_accessible(self, request: Request) -> bool:
|
||||
return request.session.get("admin_role") == "superadmin"
|
||||
|
||||
|
||||
class ViewerReadOnly:
|
||||
"""viewer만 접근 가능한 읽기 전용 뷰"""
|
||||
|
||||
can_create = False
|
||||
can_edit = False
|
||||
can_delete = False
|
||||
|
||||
def is_accessible(self, request: Request) -> bool:
|
||||
return request.session.get("admin_role") == "viewer"
|
||||
|
||||
|
||||
class ViewerAccessible:
|
||||
"""superadmin + viewer 접근 가능, 읽기 전용"""
|
||||
|
||||
can_create = False
|
||||
can_edit = False
|
||||
can_delete = False
|
||||
|
||||
def is_accessible(self, request: Request) -> bool:
|
||||
return request.session.get("admin_role") in ("superadmin", "viewer")
|
||||
|
||||
|
||||
class SuperAdminEditable:
|
||||
"""superadmin + viewer 접근 가능, superadmin만 편집"""
|
||||
|
||||
can_create = False
|
||||
can_edit = False
|
||||
can_delete = False
|
||||
|
||||
def is_accessible(self, request: Request) -> bool:
|
||||
return request.session.get("admin_role") in ("superadmin", "viewer")
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
from app.credit.models import CreditTransactionType
|
||||
from app.credit.services.credit_service import charge_credit, deduct_credit
|
||||
from app.database.session import AsyncSessionLocal
|
||||
from app.user.models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _get_users_by_pks(session, pks: str) -> list[User]:
|
||||
ids = [int(pk) for pk in pks.split(",") if pk.strip()]
|
||||
if not ids:
|
||||
return []
|
||||
result = await session.execute(select(User).where(User.id.in_(ids)))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def handle_block_users(request: Request, identity: str, block: bool) -> RedirectResponse:
|
||||
pks = request.query_params.get("pks", "")
|
||||
action_str = "차단" if block else "차단 해제"
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
users = await _get_users_by_pks(session, pks)
|
||||
for user in users:
|
||||
user.is_active = not block
|
||||
await session.commit()
|
||||
logger.info(f"[USER-ADMIN] {action_str} count={len(users)}")
|
||||
|
||||
return RedirectResponse(request.url_for("admin:list", identity=identity), status_code=302)
|
||||
|
||||
|
||||
async def handle_set_role(request: Request, identity: str, role: str) -> RedirectResponse:
|
||||
pks = request.query_params.get("pks", "")
|
||||
is_admin = role == "admin"
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
users = await _get_users_by_pks(session, pks)
|
||||
for user in users:
|
||||
user.role = role
|
||||
user.is_admin = is_admin
|
||||
await session.commit()
|
||||
logger.info(f"[USER-ADMIN] set_role role={role} count={len(users)}")
|
||||
|
||||
return RedirectResponse(request.url_for("admin:list", identity=identity), status_code=302)
|
||||
|
||||
|
||||
async def handle_grant_credits(
|
||||
request: Request,
|
||||
identity: str,
|
||||
amount: int,
|
||||
admin_id: Optional[int],
|
||||
) -> RedirectResponse:
|
||||
pks = request.query_params.get("pks", "")
|
||||
ids = [int(pk) for pk in pks.split(",") if pk.strip()]
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
for user_id in ids:
|
||||
result = await session.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
continue
|
||||
try:
|
||||
await charge_credit(
|
||||
session=session,
|
||||
user_uuid=user.user_uuid,
|
||||
amount=amount,
|
||||
type=CreditTransactionType.ADMIN_ADJUST,
|
||||
reason="관리자 수동 충전",
|
||||
admin_id=admin_id,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.warning(f"[USER-ADMIN] grant_credits failed user_id={user_id} error={e}")
|
||||
|
||||
return RedirectResponse(request.url_for("admin:list", identity=identity), status_code=302)
|
||||
|
||||
|
||||
async def handle_deduct_credits(
|
||||
request: Request,
|
||||
identity: str,
|
||||
amount: int,
|
||||
admin_id: Optional[int],
|
||||
) -> RedirectResponse:
|
||||
pks = request.query_params.get("pks", "")
|
||||
ids = [int(pk) for pk in pks.split(",") if pk.strip()]
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
for user_id in ids:
|
||||
result = await session.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
continue
|
||||
try:
|
||||
await deduct_credit(
|
||||
session=session,
|
||||
user_uuid=user.user_uuid,
|
||||
amount=amount,
|
||||
type=CreditTransactionType.ADMIN_ADJUST,
|
||||
reason="관리자 수동 차감",
|
||||
admin_id=admin_id,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.warning(f"[USER-ADMIN] deduct_credits failed user_id={user_id} error={e}")
|
||||
|
||||
return RedirectResponse(request.url_for("admin:list", identity=identity), status_code=302)
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
"""
|
||||
Comment API Router
|
||||
|
||||
영상 댓글 관련 엔드포인트를 제공합니다.
|
||||
|
||||
엔드포인트 목록:
|
||||
- POST /comment/video/{video_id}: 댓글/대댓글 작성 (로그인 필수)
|
||||
- GET /comment/video/{video_id}: 댓글 목록 조회 (비로그인 허용)
|
||||
- DELETE /comment/{comment_id}: 본인 댓글 소프트 삭제 (로그인 필수)
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.comment.schemas.comment_schema import (
|
||||
CommentCreateRequest,
|
||||
CommentCreateResponse,
|
||||
CommentItem,
|
||||
DeleteCommentResponse,
|
||||
)
|
||||
from app.comment.services.comment import create_comment, delete_comment, list_comments
|
||||
from app.database.session import get_session
|
||||
from app.dependencies.pagination import PaginationParams, get_pagination_params
|
||||
from app.user.dependencies.auth import get_current_user, get_current_user_optional
|
||||
from app.user.models import User
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.pagination import PaginatedResponse
|
||||
|
||||
logger = get_logger("comment")
|
||||
|
||||
router = APIRouter(prefix="/comment", tags=["Comment"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/video/{video_id}",
|
||||
summary="댓글/대댓글 작성",
|
||||
description="""
|
||||
## 개요
|
||||
영상에 댓글 또는 대댓글을 작성합니다. 로그인 필수.
|
||||
|
||||
## 경로 파라미터
|
||||
- **video_id**: 댓글을 달 영상의 ID
|
||||
|
||||
## 요청 본문
|
||||
- **content**: 댓글 본문 (1~100자)
|
||||
- **parent_id**: 대댓글일 때만 부모 댓글 id (생략 시 최상위 댓글)
|
||||
|
||||
## 참고
|
||||
- 작성자 정보는 응답에 포함되지 않습니다 (익명 정책).
|
||||
- 대댓글에 또 대댓글을 다는 것은 불가합니다 (최대 2-depth).
|
||||
""",
|
||||
response_model=CommentCreateResponse,
|
||||
responses={
|
||||
200: {"description": "댓글 작성 성공"},
|
||||
400: {"description": "잘못된 parent_id (2-depth 초과, 다른 영상의 댓글 등)"},
|
||||
401: {"description": "인증 실패"},
|
||||
404: {"description": "영상을 찾을 수 없음"},
|
||||
},
|
||||
)
|
||||
async def post_comment(
|
||||
video_id: int,
|
||||
body: CommentCreateRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CommentCreateResponse:
|
||||
logger.info(
|
||||
f"[post_comment] START - video_id: {video_id}, user: {current_user.user_uuid}, "
|
||||
f"parent_id: {body.parent_id}"
|
||||
)
|
||||
comment = await create_comment(
|
||||
session=session,
|
||||
video_id=video_id,
|
||||
user_uuid=current_user.user_uuid,
|
||||
nickname=body.nickname,
|
||||
content=body.content,
|
||||
parent_id=body.parent_id,
|
||||
)
|
||||
logger.info(f"[post_comment] SUCCESS - comment_id: {comment.id}")
|
||||
return CommentCreateResponse(
|
||||
id=comment.id,
|
||||
nickname=comment.nickname or "익명",
|
||||
parent_id=comment.parent_id,
|
||||
content=comment.content,
|
||||
created_at=comment.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/video/{video_id}",
|
||||
summary="댓글 목록 조회",
|
||||
description="""
|
||||
## 개요
|
||||
영상의 댓글 목록을 페이지네이션하여 반환합니다. 비로그인도 접근 가능.
|
||||
|
||||
## 경로 파라미터
|
||||
- **video_id**: 댓글을 조회할 영상의 ID
|
||||
|
||||
## 쿼리 파라미터
|
||||
- **page**: 페이지 번호 (기본값: 1)
|
||||
- **page_size**: 페이지당 댓글 수 (기본값: 10, 최대: 100)
|
||||
|
||||
## 참고
|
||||
- 최상위 댓글만 페이지네이션됩니다. 각 댓글의 대댓글은 전부 포함됩니다.
|
||||
- 작성자 정보는 노출되지 않으며, is_mine으로 본인 댓글 여부만 확인 가능합니다.
|
||||
- 삭제된 댓글은 content=null로 노출됩니다 (대댓글이 있는 경우).
|
||||
""",
|
||||
response_model=PaginatedResponse[CommentItem],
|
||||
responses={
|
||||
200: {"description": "댓글 목록 조회 성공"},
|
||||
500: {"description": "조회 실패"},
|
||||
},
|
||||
)
|
||||
async def get_comments(
|
||||
video_id: int,
|
||||
current_user: User | None = Depends(get_current_user_optional),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
pagination: PaginationParams = Depends(get_pagination_params),
|
||||
) -> PaginatedResponse[CommentItem]:
|
||||
logger.info(
|
||||
f"[get_comments] START - video_id: {video_id}, "
|
||||
f"page: {pagination.page}, page_size: {pagination.page_size}"
|
||||
)
|
||||
current_user_uuid = current_user.user_uuid if current_user else None
|
||||
result = await list_comments(
|
||||
session=session,
|
||||
video_id=video_id,
|
||||
page=pagination.page,
|
||||
page_size=pagination.page_size,
|
||||
current_user_uuid=current_user_uuid,
|
||||
)
|
||||
logger.info(f"[get_comments] SUCCESS - total: {result.total}, items: {len(result.items)}")
|
||||
return result
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{comment_id}",
|
||||
summary="댓글 소프트 삭제",
|
||||
description="""
|
||||
## 개요
|
||||
본인이 작성한 댓글을 소프트 삭제합니다. 로그인 필수.
|
||||
|
||||
## 경로 파라미터
|
||||
- **comment_id**: 삭제할 댓글의 ID
|
||||
|
||||
## 참고
|
||||
- 본인 댓글만 삭제 가능합니다.
|
||||
- 소프트 삭제 방식으로 DB에 데이터는 유지됩니다.
|
||||
- 부모 댓글 삭제 시 대댓글은 유지되며, 목록 조회 시 content=null로 표시됩니다.
|
||||
""",
|
||||
response_model=DeleteCommentResponse,
|
||||
responses={
|
||||
200: {"description": "삭제 성공"},
|
||||
401: {"description": "인증 실패"},
|
||||
403: {"description": "삭제 권한 없음"},
|
||||
404: {"description": "댓글을 찾을 수 없음"},
|
||||
},
|
||||
)
|
||||
async def remove_comment(
|
||||
comment_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> DeleteCommentResponse:
|
||||
logger.info(
|
||||
f"[remove_comment] START - comment_id: {comment_id}, user: {current_user.user_uuid}"
|
||||
)
|
||||
await delete_comment(
|
||||
session=session,
|
||||
comment_id=comment_id,
|
||||
current_user_uuid=current_user.user_uuid,
|
||||
)
|
||||
logger.info(f"[remove_comment] SUCCESS - comment_id: {comment_id}")
|
||||
return DeleteCommentResponse(
|
||||
success=True,
|
||||
comment_id=comment_id,
|
||||
message="댓글이 삭제되었습니다.",
|
||||
)
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database.session import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.user.models import User
|
||||
from app.video.models import Video
|
||||
|
||||
|
||||
class Comment(Base):
|
||||
"""
|
||||
영상 댓글 테이블
|
||||
|
||||
2-depth 구조 (최상위 댓글 + 대댓글 1단계).
|
||||
parent_id가 NULL이면 최상위 댓글, 값이 있으면 대댓글.
|
||||
작성자(user_uuid)는 DB에 저장하지만 API 응답에는 미노출 (익명 정책).
|
||||
"""
|
||||
|
||||
__tablename__ = "comment"
|
||||
__table_args__ = (
|
||||
Index("idx_comment_video_id", "video_id"),
|
||||
Index("idx_comment_user_uuid", "user_uuid"),
|
||||
Index("idx_comment_parent_id", "parent_id"),
|
||||
Index("idx_comment_is_deleted", "is_deleted"),
|
||||
{
|
||||
"mysql_engine": "InnoDB",
|
||||
"mysql_charset": "utf8mb4",
|
||||
"mysql_collate": "utf8mb4_unicode_ci",
|
||||
},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
Integer, primary_key=True, autoincrement=True, comment="고유 식별자"
|
||||
)
|
||||
video_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("video.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="연결된 Video의 id",
|
||||
)
|
||||
user_uuid: Mapped[str] = mapped_column(
|
||||
ForeignKey("user.user_uuid", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="작성자 UUID (응답 미노출, 권한 검증용)",
|
||||
)
|
||||
parent_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("comment.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
comment="NULL=최상위 댓글, 값=대댓글의 부모 id",
|
||||
)
|
||||
nickname: Mapped[Optional[str]] = mapped_column(
|
||||
String(50), nullable=True, comment="댓글 작성자 닉네임 (null이면 익명)"
|
||||
)
|
||||
content: Mapped[str] = mapped_column(
|
||||
String(100), nullable=False, comment="댓글 본문 (한글 기준 100자 이내)"
|
||||
)
|
||||
is_deleted: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, comment="소프트 삭제 여부"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
comment="작성 일시",
|
||||
)
|
||||
|
||||
video: Mapped["Video"] = relationship("Video", back_populates="comments")
|
||||
user: Mapped["User"] = relationship("User", back_populates="comments")
|
||||
parent: Mapped[Optional["Comment"]] = relationship(
|
||||
"Comment", remote_side=[id], back_populates="replies"
|
||||
)
|
||||
replies: Mapped[List["Comment"]] = relationship(
|
||||
"Comment",
|
||||
back_populates="parent",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CommentCreateRequest(BaseModel):
|
||||
nickname: Optional[str] = Field(None, min_length=1, max_length=50, description="작성자 닉네임 (미입력 시 익명)")
|
||||
content: str = Field(..., min_length=1, max_length=100, description="댓글 본문 (한글 기준 100자 이내)")
|
||||
parent_id: Optional[int] = Field(None, description="대댓글일 때만 부모 댓글 id")
|
||||
|
||||
|
||||
class ReplyItem(BaseModel):
|
||||
"""대댓글 응답"""
|
||||
|
||||
id: int = Field(..., description="댓글 고유 ID")
|
||||
nickname: str = Field(..., description="작성자 닉네임 (미입력 시 '익명')")
|
||||
content: Optional[str] = Field(None, description="본문 (소프트 삭제된 경우 null)")
|
||||
is_deleted: bool = Field(..., description="삭제 여부")
|
||||
is_mine: bool = Field(..., description="현재 로그인 사용자의 댓글 여부")
|
||||
created_at: datetime = Field(..., description="작성 일시")
|
||||
|
||||
|
||||
class CommentItem(BaseModel):
|
||||
"""최상위 댓글 응답 — replies 포함"""
|
||||
|
||||
id: int = Field(..., description="댓글 고유 ID")
|
||||
nickname: str = Field(..., description="작성자 닉네임 (미입력 시 '익명')")
|
||||
content: Optional[str] = Field(None, description="본문 (소프트 삭제된 경우 null)")
|
||||
is_deleted: bool = Field(..., description="삭제 여부")
|
||||
is_mine: bool = Field(..., description="현재 로그인 사용자의 댓글 여부")
|
||||
created_at: datetime = Field(..., description="작성 일시")
|
||||
replies: List[ReplyItem] = Field(default_factory=list, description="대댓글 목록")
|
||||
|
||||
|
||||
class CommentCreateResponse(BaseModel):
|
||||
id: int = Field(..., description="생성된 댓글 고유 ID")
|
||||
nickname: str = Field(..., description="작성자 닉네임 (미입력 시 '익명')")
|
||||
parent_id: Optional[int] = Field(None, description="부모 댓글 id (대댓글인 경우)")
|
||||
content: str = Field(..., description="댓글 본문")
|
||||
created_at: datetime = Field(..., description="작성 일시")
|
||||
|
||||
|
||||
class DeleteCommentResponse(BaseModel):
|
||||
success: bool = Field(..., description="삭제 성공 여부")
|
||||
comment_id: int = Field(..., description="삭제된 댓글 ID")
|
||||
message: str = Field(..., description="결과 메시지")
|
||||
|
|
@ -1,189 +0,0 @@
|
|||
from collections import defaultdict
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import exists, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.comment.models import Comment
|
||||
from app.comment.schemas.comment_schema import CommentItem, ReplyItem
|
||||
from app.utils.pagination import PaginatedResponse
|
||||
from app.video.models import Video
|
||||
|
||||
|
||||
async def _validate_parent(
|
||||
session: AsyncSession,
|
||||
parent_id: int,
|
||||
video_id: int,
|
||||
) -> None:
|
||||
"""2-depth 제한 + 동일 video 검증."""
|
||||
result = await session.execute(
|
||||
select(Comment).where(
|
||||
Comment.id == parent_id,
|
||||
Comment.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
parent = result.scalar_one_or_none()
|
||||
|
||||
if parent is None:
|
||||
raise HTTPException(status_code=400, detail="부모 댓글을 찾을 수 없습니다.")
|
||||
if parent.video_id != video_id:
|
||||
raise HTTPException(status_code=400, detail="다른 영상의 댓글에는 대댓글을 달 수 없습니다.")
|
||||
if parent.parent_id is not None:
|
||||
raise HTTPException(status_code=400, detail="대댓글에는 대댓글을 달 수 없습니다. (최대 2-depth)")
|
||||
|
||||
|
||||
def _build_comment_items(
|
||||
parents: list,
|
||||
replies_map: dict,
|
||||
current_user_uuid: Optional[str],
|
||||
) -> List[CommentItem]:
|
||||
items = []
|
||||
for c in parents:
|
||||
raw_replies = replies_map.get(c.id, [])
|
||||
replies = [
|
||||
ReplyItem(
|
||||
id=r.id,
|
||||
nickname=r.nickname or "익명",
|
||||
content=None if r.is_deleted else r.content,
|
||||
is_deleted=r.is_deleted,
|
||||
is_mine=(current_user_uuid == r.user_uuid) if current_user_uuid else False,
|
||||
created_at=r.created_at,
|
||||
)
|
||||
for r in raw_replies
|
||||
]
|
||||
items.append(
|
||||
CommentItem(
|
||||
id=c.id,
|
||||
nickname=c.nickname or "익명",
|
||||
content=None if c.is_deleted else c.content,
|
||||
is_deleted=c.is_deleted,
|
||||
is_mine=(current_user_uuid == c.user_uuid) if current_user_uuid else False,
|
||||
created_at=c.created_at,
|
||||
replies=replies,
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
async def create_comment(
|
||||
session: AsyncSession,
|
||||
video_id: int,
|
||||
user_uuid: str,
|
||||
nickname: str,
|
||||
content: str,
|
||||
parent_id: Optional[int],
|
||||
) -> Comment:
|
||||
# Video 존재 확인
|
||||
video_result = await session.execute(
|
||||
select(Video).where(
|
||||
Video.id == video_id,
|
||||
Video.status == "completed",
|
||||
Video.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
if video_result.scalar_one_or_none() is None:
|
||||
raise HTTPException(status_code=404, detail="영상을 찾을 수 없습니다.")
|
||||
|
||||
# parent_id 검증
|
||||
if parent_id is not None:
|
||||
await _validate_parent(session, parent_id, video_id)
|
||||
|
||||
comment = Comment(
|
||||
video_id=video_id,
|
||||
user_uuid=user_uuid,
|
||||
nickname=nickname,
|
||||
parent_id=parent_id,
|
||||
content=content,
|
||||
)
|
||||
session.add(comment)
|
||||
await session.commit()
|
||||
await session.refresh(comment)
|
||||
return comment
|
||||
|
||||
|
||||
async def list_comments(
|
||||
session: AsyncSession,
|
||||
video_id: int,
|
||||
page: int,
|
||||
page_size: int,
|
||||
current_user_uuid: Optional[str],
|
||||
) -> PaginatedResponse[CommentItem]:
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
# 살아있는 자식이 있는지 확인하는 서브쿼리
|
||||
has_live_reply = (
|
||||
exists()
|
||||
.where(
|
||||
Comment.parent_id == Comment.id,
|
||||
Comment.is_deleted == False, # noqa: E712
|
||||
)
|
||||
.correlate(Comment)
|
||||
)
|
||||
|
||||
# 최상위 댓글 필터: 삭제 안 됐거나 살아있는 대댓글이 있는 것
|
||||
parent_where = [
|
||||
Comment.video_id == video_id,
|
||||
Comment.parent_id.is_(None),
|
||||
(Comment.is_deleted == False) | has_live_reply, # noqa: E712
|
||||
]
|
||||
|
||||
from sqlalchemy import func
|
||||
|
||||
count_q = select(func.count(Comment.id)).where(*parent_where)
|
||||
total = (await session.execute(count_q)).scalar() or 0
|
||||
|
||||
parents_q = (
|
||||
select(Comment)
|
||||
.where(*parent_where)
|
||||
.order_by(Comment.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
parents = (await session.execute(parents_q)).scalars().all()
|
||||
|
||||
replies_map: dict = defaultdict(list)
|
||||
if parents:
|
||||
parent_ids = [c.id for c in parents]
|
||||
replies_q = (
|
||||
select(Comment)
|
||||
.where(
|
||||
Comment.parent_id.in_(parent_ids),
|
||||
Comment.is_deleted == False, # noqa: E712
|
||||
)
|
||||
.order_by(Comment.created_at.asc())
|
||||
)
|
||||
replies = (await session.execute(replies_q)).scalars().all()
|
||||
for r in replies:
|
||||
replies_map[r.parent_id].append(r)
|
||||
|
||||
items = _build_comment_items(list(parents), replies_map, current_user_uuid)
|
||||
|
||||
return PaginatedResponse.create(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
async def delete_comment(
|
||||
session: AsyncSession,
|
||||
comment_id: int,
|
||||
current_user_uuid: str,
|
||||
) -> None:
|
||||
result = await session.execute(
|
||||
select(Comment).where(
|
||||
Comment.id == comment_id,
|
||||
Comment.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
comment = result.scalar_one_or_none()
|
||||
|
||||
if comment is None:
|
||||
raise HTTPException(status_code=404, detail="댓글을 찾을 수 없습니다.")
|
||||
if comment.user_uuid != current_user_uuid:
|
||||
raise HTTPException(status_code=403, detail="삭제 권한이 없습니다.")
|
||||
|
||||
comment.is_deleted = True
|
||||
await session.commit()
|
||||
|
|
@ -51,9 +51,6 @@ async def lifespan(app: FastAPI):
|
|||
await close_shared_client()
|
||||
await close_shared_blob_client()
|
||||
|
||||
from app.database.like_cache import close_like_cache
|
||||
await close_like_cache()
|
||||
|
||||
# 데이터베이스 엔진 종료
|
||||
from app.database.session import dispose_engine
|
||||
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -1,162 +0,0 @@
|
|||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.credit.exceptions import ChargeRequestForbiddenError, ChargeRequestNotFoundError
|
||||
from app.credit.models import ChargeRequestStatus, CreditChargeRequest, CreditTransaction
|
||||
from app.credit.schemas.credit_schema import (
|
||||
ChargeRequestCreate,
|
||||
ChargeRequestListResponse,
|
||||
ChargeRequestResponse,
|
||||
CreditTransactionListResponse,
|
||||
CreditTransactionResponse,
|
||||
)
|
||||
from app.database.session import get_session
|
||||
from app.user.dependencies.auth import get_current_user
|
||||
from app.user.models import User
|
||||
|
||||
router = APIRouter(prefix="/credits", tags=["Credits"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/charge-requests",
|
||||
response_model=ChargeRequestResponse,
|
||||
status_code=201,
|
||||
summary="크레딧 충전 요청 제출",
|
||||
)
|
||||
async def create_charge_request(
|
||||
body: ChargeRequestCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ChargeRequestResponse:
|
||||
charge_request = CreditChargeRequest(
|
||||
user_uuid=current_user.user_uuid,
|
||||
requested_amount=body.requested_amount,
|
||||
message=body.message,
|
||||
)
|
||||
session.add(charge_request)
|
||||
await session.commit()
|
||||
await session.refresh(charge_request)
|
||||
return ChargeRequestResponse.model_validate(charge_request)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/charge-requests",
|
||||
response_model=ChargeRequestListResponse,
|
||||
summary="내 충전 요청 목록",
|
||||
)
|
||||
async def list_charge_requests(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ChargeRequestListResponse:
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
total_result = await session.execute(
|
||||
select(func.count()).where(CreditChargeRequest.user_uuid == current_user.user_uuid)
|
||||
)
|
||||
total = total_result.scalar_one()
|
||||
|
||||
items_result = await session.execute(
|
||||
select(CreditChargeRequest)
|
||||
.where(CreditChargeRequest.user_uuid == current_user.user_uuid)
|
||||
.order_by(CreditChargeRequest.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
items = items_result.scalars().all()
|
||||
|
||||
return ChargeRequestListResponse(
|
||||
items=[ChargeRequestResponse.model_validate(i) for i in items],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/charge-requests/{request_id}",
|
||||
response_model=ChargeRequestResponse,
|
||||
summary="내 충전 요청 상세",
|
||||
)
|
||||
async def get_charge_request(
|
||||
request_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ChargeRequestResponse:
|
||||
result = await session.execute(
|
||||
select(CreditChargeRequest).where(
|
||||
CreditChargeRequest.id == request_id,
|
||||
CreditChargeRequest.user_uuid == current_user.user_uuid,
|
||||
)
|
||||
)
|
||||
charge_request = result.scalar_one_or_none()
|
||||
|
||||
if charge_request is None:
|
||||
raise ChargeRequestNotFoundError()
|
||||
|
||||
return ChargeRequestResponse.model_validate(charge_request)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/charge-requests/{request_id}",
|
||||
status_code=204,
|
||||
summary="충전 요청 취소 (pending 상태만)",
|
||||
)
|
||||
async def cancel_charge_request(
|
||||
request_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
result = await session.execute(
|
||||
select(CreditChargeRequest).where(CreditChargeRequest.id == request_id)
|
||||
)
|
||||
charge_request = result.scalar_one_or_none()
|
||||
|
||||
if charge_request is None:
|
||||
raise ChargeRequestNotFoundError()
|
||||
if charge_request.user_uuid != current_user.user_uuid:
|
||||
raise ChargeRequestForbiddenError()
|
||||
|
||||
from app.credit.exceptions import InvalidRequestStateError
|
||||
if charge_request.status != ChargeRequestStatus.PENDING:
|
||||
raise InvalidRequestStateError("대기 중인 요청만 취소할 수 있습니다.")
|
||||
|
||||
charge_request.status = ChargeRequestStatus.CANCELLED
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/transactions",
|
||||
response_model=CreditTransactionListResponse,
|
||||
summary="내 크레딧 거래 이력",
|
||||
)
|
||||
async def list_transactions(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CreditTransactionListResponse:
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
total_result = await session.execute(
|
||||
select(func.count()).where(CreditTransaction.user_uuid == current_user.user_uuid)
|
||||
)
|
||||
total = total_result.scalar_one()
|
||||
|
||||
items_result = await session.execute(
|
||||
select(CreditTransaction)
|
||||
.where(CreditTransaction.user_uuid == current_user.user_uuid)
|
||||
.order_by(CreditTransaction.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
items = items_result.scalars().all()
|
||||
|
||||
return CreditTransactionListResponse(
|
||||
items=[CreditTransactionResponse.model_validate(i) for i in items],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
from starlette import status
|
||||
|
||||
from app.core.exceptions import FastShipError
|
||||
|
||||
|
||||
class InsufficientCreditError(FastShipError):
|
||||
"""크레딧이 부족합니다."""
|
||||
|
||||
status = status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
class InvalidRequestStateError(FastShipError):
|
||||
"""이미 처리된 요청입니다."""
|
||||
|
||||
status = status.HTTP_409_CONFLICT
|
||||
|
||||
|
||||
class ChargeRequestNotFoundError(FastShipError):
|
||||
"""충전 요청을 찾을 수 없습니다."""
|
||||
|
||||
status = status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
class ChargeRequestForbiddenError(FastShipError):
|
||||
"""본인의 충전 요청만 조회할 수 있습니다."""
|
||||
|
||||
status = status.HTTP_403_FORBIDDEN
|
||||
|
|
@ -1,243 +0,0 @@
|
|||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database.session import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.backoffice.admin.models import Admin
|
||||
from app.user.models import User
|
||||
|
||||
|
||||
class ChargeRequestStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class CreditTransactionType(str, Enum):
|
||||
CHARGE = "charge"
|
||||
CONSUME = "consume"
|
||||
REFUND = "refund"
|
||||
ADMIN_ADJUST = "admin_adjust"
|
||||
|
||||
|
||||
class CreditChargeRequest(Base):
|
||||
__tablename__ = "credit_charge_request"
|
||||
__table_args__ = (
|
||||
Index("idx_credit_request_user_uuid", "user_uuid"),
|
||||
Index("idx_credit_request_status", "status"),
|
||||
Index("idx_credit_request_created_at", "created_at"),
|
||||
Index("idx_credit_request_status_created", "status", "created_at"),
|
||||
{
|
||||
"mysql_engine": "InnoDB",
|
||||
"mysql_charset": "utf8mb4",
|
||||
"mysql_collate": "utf8mb4_unicode_ci",
|
||||
},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BigInteger,
|
||||
primary_key=True,
|
||||
nullable=False,
|
||||
autoincrement=True,
|
||||
comment="고유 식별자",
|
||||
)
|
||||
|
||||
user_uuid: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("user.user_uuid", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="사용자 UUID (user.user_uuid 참조)",
|
||||
)
|
||||
|
||||
requested_amount: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
comment="요청 크레딧 수량 (양수)",
|
||||
)
|
||||
|
||||
message: Mapped[Optional[str]] = mapped_column(
|
||||
String(500),
|
||||
nullable=True,
|
||||
comment="사용자 요청 메시지",
|
||||
)
|
||||
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default=ChargeRequestStatus.PENDING,
|
||||
server_default="pending",
|
||||
comment="처리 상태 (pending/approved/rejected/cancelled)",
|
||||
)
|
||||
|
||||
admin_id: Mapped[Optional[int]] = mapped_column(
|
||||
BigInteger,
|
||||
ForeignKey("admin.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
comment="처리한 백오피스 관리자 ID",
|
||||
)
|
||||
|
||||
admin_note: Mapped[Optional[str]] = mapped_column(
|
||||
String(1000),
|
||||
nullable=True,
|
||||
comment="관리자 메모",
|
||||
)
|
||||
|
||||
processed_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime,
|
||||
nullable=True,
|
||||
comment="처리 일시",
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
comment="요청 일시",
|
||||
)
|
||||
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
comment="수정 일시",
|
||||
)
|
||||
|
||||
user: Mapped["User"] = relationship(
|
||||
"User",
|
||||
foreign_keys=[user_uuid],
|
||||
primaryjoin="CreditChargeRequest.user_uuid == User.user_uuid",
|
||||
back_populates="credit_requests",
|
||||
lazy="noload",
|
||||
)
|
||||
|
||||
transactions: Mapped[list["CreditTransaction"]] = relationship(
|
||||
"CreditTransaction",
|
||||
back_populates="charge_request",
|
||||
lazy="noload",
|
||||
)
|
||||
|
||||
admin: Mapped[Optional["Admin"]] = relationship(
|
||||
"Admin",
|
||||
foreign_keys=[admin_id],
|
||||
primaryjoin="CreditChargeRequest.admin_id == Admin.id",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<CreditChargeRequest("
|
||||
f"id={self.id}, user_uuid='{self.user_uuid}', "
|
||||
f"amount={self.requested_amount}, status='{self.status}'"
|
||||
f")>"
|
||||
)
|
||||
|
||||
|
||||
class CreditTransaction(Base):
|
||||
__tablename__ = "credit_transaction"
|
||||
__table_args__ = (
|
||||
Index("idx_credit_tx_user_uuid", "user_uuid"),
|
||||
Index("idx_credit_tx_user_uuid_created", "user_uuid", "created_at"),
|
||||
Index("idx_credit_tx_type", "type"),
|
||||
Index("idx_credit_tx_related_request", "related_request_id"),
|
||||
{
|
||||
"mysql_engine": "InnoDB",
|
||||
"mysql_charset": "utf8mb4",
|
||||
"mysql_collate": "utf8mb4_unicode_ci",
|
||||
},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BigInteger,
|
||||
primary_key=True,
|
||||
nullable=False,
|
||||
autoincrement=True,
|
||||
comment="고유 식별자",
|
||||
)
|
||||
|
||||
user_uuid: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("user.user_uuid", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="사용자 UUID",
|
||||
)
|
||||
|
||||
amount: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
comment="변경 크레딧 수량 (충전 양수, 차감 음수)",
|
||||
)
|
||||
|
||||
balance_after: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
comment="변경 직후 잔액",
|
||||
)
|
||||
|
||||
type: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
comment="변경 유형 (charge/consume/refund/admin_adjust)",
|
||||
)
|
||||
|
||||
reason: Mapped[Optional[str]] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
comment="변경 사유",
|
||||
)
|
||||
|
||||
admin_id: Mapped[Optional[int]] = mapped_column(
|
||||
BigInteger,
|
||||
ForeignKey("admin.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
comment="처리 관리자 ID (관리자 충전/차감 시)",
|
||||
)
|
||||
|
||||
related_request_id: Mapped[Optional[int]] = mapped_column(
|
||||
BigInteger,
|
||||
ForeignKey("credit_charge_request.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
comment="연관 충전 요청 ID",
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
comment="변경 일시",
|
||||
)
|
||||
|
||||
user: Mapped["User"] = relationship(
|
||||
"User",
|
||||
foreign_keys=[user_uuid],
|
||||
primaryjoin="CreditTransaction.user_uuid == User.user_uuid",
|
||||
back_populates="credit_transactions",
|
||||
lazy="noload",
|
||||
)
|
||||
|
||||
charge_request: Mapped[Optional[CreditChargeRequest]] = relationship(
|
||||
"CreditChargeRequest",
|
||||
back_populates="transactions",
|
||||
lazy="noload",
|
||||
)
|
||||
|
||||
admin: Mapped[Optional["Admin"]] = relationship(
|
||||
"Admin",
|
||||
foreign_keys=[admin_id],
|
||||
primaryjoin="CreditTransaction.admin_id == Admin.id",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<CreditTransaction("
|
||||
f"id={self.id}, user_uuid='{self.user_uuid}', "
|
||||
f"amount={self.amount}, type='{self.type}'"
|
||||
f")>"
|
||||
)
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ChargeRequestCreate(BaseModel):
|
||||
requested_amount: int = Field(..., gt=0, le=10000, description="요청 크레딧 수량")
|
||||
message: Optional[str] = Field(None, max_length=500, description="요청 메시지")
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"example": {
|
||||
"requested_amount": 10,
|
||||
"message": "크레딧 충전 요청합니다.",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ChargeRequestResponse(BaseModel):
|
||||
id: int
|
||||
user_uuid: str
|
||||
requested_amount: int
|
||||
message: Optional[str]
|
||||
status: str
|
||||
admin_note: Optional[str]
|
||||
processed_at: Optional[datetime]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ChargeRequestListResponse(BaseModel):
|
||||
items: list[ChargeRequestResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class CreditTransactionResponse(BaseModel):
|
||||
id: int
|
||||
user_uuid: str
|
||||
amount: int
|
||||
balance_after: int
|
||||
type: str
|
||||
reason: Optional[str]
|
||||
related_request_id: Optional[int]
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class CreditTransactionListResponse(BaseModel):
|
||||
items: list[CreditTransactionResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
|
@ -1,195 +0,0 @@
|
|||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from config import TIMEZONE
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.credit.exceptions import (
|
||||
ChargeRequestNotFoundError,
|
||||
InsufficientCreditError,
|
||||
InvalidRequestStateError,
|
||||
)
|
||||
from app.credit.models import (
|
||||
ChargeRequestStatus,
|
||||
CreditChargeRequest,
|
||||
CreditTransaction,
|
||||
CreditTransactionType,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def record_transaction(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
user_uuid: str,
|
||||
amount: int,
|
||||
balance_after: int,
|
||||
type: CreditTransactionType,
|
||||
reason: Optional[str] = None,
|
||||
admin_id: Optional[int] = None,
|
||||
related_request_id: Optional[int] = None,
|
||||
) -> CreditTransaction:
|
||||
tx = CreditTransaction(
|
||||
user_uuid=user_uuid,
|
||||
amount=amount,
|
||||
balance_after=balance_after,
|
||||
type=type,
|
||||
reason=reason,
|
||||
admin_id=admin_id,
|
||||
related_request_id=related_request_id,
|
||||
)
|
||||
session.add(tx)
|
||||
await session.flush()
|
||||
return tx
|
||||
|
||||
|
||||
async def charge_credit(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
user_uuid: str,
|
||||
amount: int,
|
||||
type: CreditTransactionType = CreditTransactionType.CHARGE,
|
||||
reason: Optional[str] = None,
|
||||
admin_id: Optional[int] = None,
|
||||
related_request_id: Optional[int] = None,
|
||||
) -> CreditTransaction:
|
||||
from app.user.models import User
|
||||
|
||||
result = await session.execute(
|
||||
select(User).where(User.user_uuid == user_uuid).with_for_update()
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
from app.user.services.auth import UserNotFoundError
|
||||
raise UserNotFoundError()
|
||||
|
||||
user.credits = user.credits + amount
|
||||
await session.flush()
|
||||
|
||||
tx = await record_transaction(
|
||||
session=session,
|
||||
user_uuid=user_uuid,
|
||||
amount=amount,
|
||||
balance_after=user.credits,
|
||||
type=type,
|
||||
reason=reason,
|
||||
admin_id=admin_id,
|
||||
related_request_id=related_request_id,
|
||||
)
|
||||
logger.info(f"[CREDIT] charge user_uuid={user_uuid} amount=+{amount} balance_after={user.credits}")
|
||||
return tx
|
||||
|
||||
|
||||
async def deduct_credit(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
user_uuid: str,
|
||||
amount: int,
|
||||
type: CreditTransactionType = CreditTransactionType.CONSUME,
|
||||
reason: Optional[str] = None,
|
||||
admin_id: Optional[int] = None,
|
||||
) -> CreditTransaction:
|
||||
from app.user.models import User
|
||||
|
||||
result = await session.execute(
|
||||
select(User).where(User.user_uuid == user_uuid).with_for_update()
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
from app.user.services.auth import UserNotFoundError
|
||||
raise UserNotFoundError()
|
||||
|
||||
if user.credits < amount:
|
||||
logger.warning(f"[CREDIT] insufficient credits user_uuid={user_uuid} credits={user.credits} requested={amount}")
|
||||
raise InsufficientCreditError()
|
||||
|
||||
user.credits = user.credits - amount
|
||||
await session.flush()
|
||||
|
||||
tx = await record_transaction(
|
||||
session=session,
|
||||
user_uuid=user_uuid,
|
||||
amount=-amount,
|
||||
balance_after=user.credits,
|
||||
type=type,
|
||||
reason=reason,
|
||||
admin_id=admin_id,
|
||||
)
|
||||
logger.info(f"[CREDIT] deduct user_uuid={user_uuid} amount=-{amount} balance_after={user.credits}")
|
||||
return tx
|
||||
|
||||
|
||||
async def approve_charge_request(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
request_id: int,
|
||||
admin_id: int,
|
||||
admin_note: Optional[str] = None,
|
||||
) -> CreditChargeRequest:
|
||||
result = await session.execute(
|
||||
select(CreditChargeRequest)
|
||||
.where(CreditChargeRequest.id == request_id)
|
||||
.with_for_update()
|
||||
)
|
||||
charge_request = result.scalar_one_or_none()
|
||||
|
||||
if charge_request is None:
|
||||
raise ChargeRequestNotFoundError()
|
||||
|
||||
if charge_request.status != ChargeRequestStatus.PENDING:
|
||||
logger.warning(f"[CREDIT] approve blocked request_id={request_id} status={charge_request.status}")
|
||||
raise InvalidRequestStateError()
|
||||
|
||||
await charge_credit(
|
||||
session=session,
|
||||
user_uuid=charge_request.user_uuid,
|
||||
amount=charge_request.requested_amount,
|
||||
type=CreditTransactionType.CHARGE,
|
||||
reason="충전 요청 승인",
|
||||
admin_id=admin_id,
|
||||
related_request_id=request_id,
|
||||
)
|
||||
|
||||
charge_request.status = ChargeRequestStatus.APPROVED
|
||||
charge_request.admin_id = admin_id
|
||||
charge_request.admin_note = admin_note
|
||||
charge_request.processed_at = datetime.now(TIMEZONE)
|
||||
await session.flush()
|
||||
|
||||
logger.info(f"[CREDIT] approved request_id={request_id} admin_id={admin_id} amount={charge_request.requested_amount}")
|
||||
return charge_request
|
||||
|
||||
|
||||
async def reject_charge_request(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
request_id: int,
|
||||
admin_id: int,
|
||||
admin_note: Optional[str] = None,
|
||||
) -> CreditChargeRequest:
|
||||
result = await session.execute(
|
||||
select(CreditChargeRequest)
|
||||
.where(CreditChargeRequest.id == request_id)
|
||||
.with_for_update()
|
||||
)
|
||||
charge_request = result.scalar_one_or_none()
|
||||
|
||||
if charge_request is None:
|
||||
raise ChargeRequestNotFoundError()
|
||||
|
||||
if charge_request.status != ChargeRequestStatus.PENDING:
|
||||
logger.warning(f"[CREDIT] reject blocked request_id={request_id} status={charge_request.status}")
|
||||
raise InvalidRequestStateError()
|
||||
|
||||
charge_request.status = ChargeRequestStatus.REJECTED
|
||||
charge_request.admin_id = admin_id
|
||||
charge_request.admin_note = admin_note
|
||||
charge_request.processed_at = datetime.now(TIMEZONE)
|
||||
await session.flush()
|
||||
|
||||
logger.info(f"[CREDIT] rejected request_id={request_id} admin_id={admin_id}")
|
||||
return charge_request
|
||||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 - "
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -1,235 +0,0 @@
|
|||
"""
|
||||
좋아요 Redis 캐시 클라이언트
|
||||
|
||||
Write-Behind 패턴 적용:
|
||||
- 토글 시 Redis를 즉시 업데이트하고 dirty SET에 표시
|
||||
- 스케줄러가 1분마다 dirty 항목을 MySQL에 bulk write
|
||||
|
||||
Key 패턴:
|
||||
- video:like:count:{video_id} INT — 좋아요 카운트
|
||||
- video:like:users:{video_id} SET — 좋아요 누른 user_uuid 목록
|
||||
- video:reaction:dirty SET — DB 동기화 대기 "{video_id}:{user_uuid}"
|
||||
- video:reaction:dirty:processing SET — 플러시 중 임시 (크래시 복구용)
|
||||
|
||||
캐시 미스(Redis 재시작 등) 시 호출부에서 DB 조회 후 backfill_user_set() / set_like_count()로 복구합니다.
|
||||
"""
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from config import db_settings
|
||||
|
||||
_client: aioredis.Redis | None = None
|
||||
|
||||
# 원자적 토글 Lua 스크립트 — 동시 더블클릭 race condition 방지
|
||||
_TOGGLE_LIKE_SCRIPT = """
|
||||
local user_key = KEYS[1]
|
||||
local count_key = KEYS[2]
|
||||
local user_uuid = ARGV[1]
|
||||
|
||||
if redis.call('SISMEMBER', user_key, user_uuid) == 1 then
|
||||
redis.call('SREM', user_key, user_uuid)
|
||||
local c = tonumber(redis.call('DECR', count_key))
|
||||
if c < 0 then
|
||||
redis.call('SET', count_key, 0)
|
||||
c = 0
|
||||
end
|
||||
return {0, c}
|
||||
else
|
||||
redis.call('SADD', user_key, user_uuid)
|
||||
local c = tonumber(redis.call('INCR', count_key))
|
||||
return {1, c}
|
||||
end
|
||||
"""
|
||||
|
||||
_DIRTY_KEY = "video:reaction:dirty"
|
||||
_DIRTY_PROCESSING_KEY = "video:reaction:dirty:processing"
|
||||
|
||||
|
||||
def get_like_cache() -> aioredis.Redis:
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = aioredis.Redis(
|
||||
host=db_settings.REDIS_HOST,
|
||||
port=db_settings.REDIS_PORT,
|
||||
db=2,
|
||||
decode_responses=True,
|
||||
)
|
||||
return _client
|
||||
|
||||
|
||||
async def close_like_cache() -> None:
|
||||
global _client
|
||||
if _client:
|
||||
await _client.aclose()
|
||||
_client = None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Key 헬퍼
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _key(video_id: int) -> str:
|
||||
return f"video:like:count:{video_id}"
|
||||
|
||||
|
||||
def _user_key(video_id: int) -> str:
|
||||
return f"video:like:users:{video_id}"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 카운트 (기존 API 유지)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
async def get_like_count(video_id: int) -> int | None:
|
||||
"""Redis에서 like_count 조회. 캐시 미스 시 None 반환."""
|
||||
val = await get_like_cache().get(_key(video_id))
|
||||
if val is None:
|
||||
return None
|
||||
return max(int(val), 0)
|
||||
|
||||
|
||||
async def get_like_counts(video_ids: list[int]) -> dict[int, int | None]:
|
||||
"""여러 영상의 like_count를 한 번에 조회 (mget).
|
||||
캐시 미스인 video_id는 None으로 반환."""
|
||||
if not video_ids:
|
||||
return {}
|
||||
keys = [_key(vid) for vid in video_ids]
|
||||
values = await get_like_cache().mget(*keys)
|
||||
return {
|
||||
vid: max(int(v), 0) if v is not None else None
|
||||
for vid, v in zip(video_ids, values)
|
||||
}
|
||||
|
||||
|
||||
async def set_like_count(video_id: int, count: int) -> None:
|
||||
"""like_count를 Redis에 저장 (음수 방지)."""
|
||||
await get_like_cache().set(_key(video_id), max(count, 0))
|
||||
|
||||
|
||||
async def mset_like_counts(counts: dict[int, int]) -> None:
|
||||
"""여러 영상의 like_count를 한 번에 저장 (mset)."""
|
||||
if not counts:
|
||||
return
|
||||
await get_like_cache().mset({_key(vid): max(cnt, 0) for vid, cnt in counts.items()})
|
||||
|
||||
|
||||
async def incr_like_count(video_id: int) -> int:
|
||||
"""like_count를 1 증가 후 반환."""
|
||||
return max(int(await get_like_cache().incr(_key(video_id))), 0)
|
||||
|
||||
|
||||
async def decr_like_count(video_id: int) -> int:
|
||||
"""like_count를 1 감소 후 반환 (음수 방지)."""
|
||||
count = int(await get_like_cache().decr(_key(video_id)))
|
||||
if count < 0:
|
||||
await get_like_cache().set(_key(video_id), 0)
|
||||
return 0
|
||||
return count
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 유저 SET (is_liked_by_me source of truth)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
async def toggle_like_atomic(video_id: int, user_uuid: str) -> tuple[bool, int]:
|
||||
"""Lua 스크립트로 원자적 좋아요 토글.
|
||||
|
||||
Returns:
|
||||
(is_liked, new_count) 튜플
|
||||
"""
|
||||
result = await get_like_cache().eval(
|
||||
_TOGGLE_LIKE_SCRIPT,
|
||||
2,
|
||||
_user_key(video_id),
|
||||
_key(video_id),
|
||||
user_uuid,
|
||||
)
|
||||
return bool(result[0]), int(result[1])
|
||||
|
||||
|
||||
async def is_user_liked(video_id: int, user_uuid: str) -> bool | None:
|
||||
"""Redis user-set에서 좋아요 여부 조회.
|
||||
|
||||
Returns:
|
||||
True/False: 조회 성공
|
||||
None: user-set 키가 없음 (cold-start backfill 필요 신호)
|
||||
"""
|
||||
client = get_like_cache()
|
||||
key = _user_key(video_id)
|
||||
if not await client.exists(key):
|
||||
return None
|
||||
return bool(await client.sismember(key, user_uuid))
|
||||
|
||||
|
||||
async def is_user_set_exists(video_id: int) -> bool:
|
||||
"""Redis user-set 키 존재 여부 확인."""
|
||||
return bool(await get_like_cache().exists(_user_key(video_id)))
|
||||
|
||||
|
||||
async def bulk_is_user_liked(
|
||||
video_ids: list[int], user_uuid: str
|
||||
) -> dict[int, bool | None]:
|
||||
"""여러 영상의 is_liked 여부를 한 번에 조회 (pipeline).
|
||||
|
||||
Returns:
|
||||
{video_id: True/False} — user-set 키가 없는 영상은 None
|
||||
"""
|
||||
if not video_ids:
|
||||
return {}
|
||||
client = get_like_cache()
|
||||
async with client.pipeline(transaction=False) as pipe:
|
||||
for vid in video_ids:
|
||||
pipe.exists(_user_key(vid))
|
||||
pipe.sismember(_user_key(vid), user_uuid)
|
||||
responses = await pipe.execute()
|
||||
|
||||
return {
|
||||
vid: (bool(responses[i * 2 + 1]) if responses[i * 2] else None)
|
||||
for i, vid in enumerate(video_ids)
|
||||
}
|
||||
|
||||
|
||||
async def backfill_user_set(video_id: int, user_uuids: list[str]) -> None:
|
||||
"""DB에서 가져온 유저 목록을 Redis SET에 일괄 적재."""
|
||||
if user_uuids:
|
||||
await get_like_cache().sadd(_user_key(video_id), *user_uuids)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Dirty SET (Write-Behind 큐)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
async def mark_dirty(video_id: int, user_uuid: str) -> None:
|
||||
"""DB 동기화 대기 목록에 추가."""
|
||||
await get_like_cache().sadd(_DIRTY_KEY, f"{video_id}:{user_uuid}")
|
||||
|
||||
|
||||
async def drain_dirty() -> list[tuple[int, str]]:
|
||||
"""dirty SET을 processing으로 RENAME 후 전체 반환.
|
||||
|
||||
이전 실행 중 크래시로 남은 processing 항목은 먼저 병합하여 유실 방지.
|
||||
"""
|
||||
client = get_like_cache()
|
||||
|
||||
# 이전 크래시 잔여 항목 병합
|
||||
if await client.exists(_DIRTY_PROCESSING_KEY):
|
||||
await client.sunionstore(_DIRTY_KEY, _DIRTY_KEY, _DIRTY_PROCESSING_KEY)
|
||||
await client.delete(_DIRTY_PROCESSING_KEY)
|
||||
|
||||
if not await client.exists(_DIRTY_KEY):
|
||||
return []
|
||||
|
||||
# RENAME으로 플러시 중 새로 들어오는 토글과 분리
|
||||
await client.rename(_DIRTY_KEY, _DIRTY_PROCESSING_KEY)
|
||||
members = await client.smembers(_DIRTY_PROCESSING_KEY)
|
||||
|
||||
result = []
|
||||
for member in members:
|
||||
vid_str, user_uuid = member.split(":", 1)
|
||||
result.append((int(vid_str), user_uuid))
|
||||
return result
|
||||
|
||||
|
||||
async def commit_dirty_processing() -> None:
|
||||
"""DB 반영 완료 후 processing SET 삭제."""
|
||||
await get_like_cache().delete(_DIRTY_PROCESSING_KEY)
|
||||
|
|
@ -1,13 +1,12 @@
|
|||
import time
|
||||
import 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,23 +128,15 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|||
# )
|
||||
try:
|
||||
yield session
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
await session.rollback()
|
||||
# status_code < 500인 도메인 예외(계정 미연동 등)는 정상적인 비즈니스 흐름이므로 ERROR로 남기지 않음
|
||||
if getattr(e, "status_code", 500) < 500:
|
||||
logger.warning(
|
||||
f"[get_session] ROLLBACK - client error: {type(e).__name__}: {e}, "
|
||||
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
|
||||
)
|
||||
else:
|
||||
logger.error(traceback.format_exc())
|
||||
logger.error(
|
||||
f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, "
|
||||
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
|
||||
)
|
||||
raise
|
||||
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 e
|
||||
finally:
|
||||
total_time = time.perf_counter() - start_time
|
||||
# logger.debug(
|
||||
|
|
@ -179,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(
|
||||
|
|
@ -189,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(
|
||||
|
|
|
|||
|
|
@ -2,19 +2,16 @@ import json
|
|||
import time
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
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 (
|
||||
|
|
@ -27,44 +24,69 @@ from app.home.schemas.home_schema import (
|
|||
ImageUploadResponse,
|
||||
ImageUploadResultItem,
|
||||
ImageUrlItem,
|
||||
ManualMarketingRequest,
|
||||
ProcessedInfo,
|
||||
# MarketingAnalysis,
|
||||
)
|
||||
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.address_parser import extract_region_from_address
|
||||
from app.utils.autotag import autotag_images
|
||||
from app.utils.image_filter import filter_marketing_images, assemble_images
|
||||
from app.video.services.video import get_image_tags_by_task_id
|
||||
from config import MEDIA_ROOT
|
||||
|
||||
# 로거 설정
|
||||
logger = get_logger("home")
|
||||
|
||||
# 전국 시 이름 목록 (roadAddress에서 region 추출용)
|
||||
# fmt: off
|
||||
KOREAN_CITIES = [
|
||||
# 특별시/광역시
|
||||
"서울시", "부산시", "대구시", "인천시", "광주시", "대전시", "울산시", "세종시",
|
||||
# 경기도
|
||||
"수원시", "성남시", "고양시", "용인시", "부천시", "안산시", "안양시", "남양주시",
|
||||
"화성시", "평택시", "의정부시", "시흥시", "파주시", "광명시", "김포시", "군포시",
|
||||
"광주시", "이천시", "양주시", "오산시", "구리시", "안성시", "포천시", "의왕시",
|
||||
"하남시", "여주시", "동두천시", "과천시",
|
||||
# 강원도
|
||||
"춘천시", "원주시", "강릉시", "동해시", "태백시", "속초시", "삼척시",
|
||||
# 충청북도
|
||||
"청주시", "충주시", "제천시",
|
||||
# 충청남도
|
||||
"천안시", "공주시", "보령시", "아산시", "서산시", "논산시", "계룡시", "당진시",
|
||||
# 전라북도
|
||||
"전주시", "군산시", "익산시", "정읍시", "남원시", "김제시",
|
||||
# 전라남도
|
||||
"목포시", "여수시", "순천시", "나주시", "광양시",
|
||||
# 경상북도
|
||||
"포항시", "경주시", "김천시", "안동시", "구미시", "영주시", "영천시", "상주시", "문경시", "경산시",
|
||||
# 경상남도
|
||||
"창원시", "진주시", "통영시", "사천시", "김해시", "밀양시", "거제시", "양산시",
|
||||
# 제주도
|
||||
"제주시", "서귀포시",
|
||||
]
|
||||
# fmt: on
|
||||
|
||||
# router = APIRouter(tags=["Home"])
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/search/accommodation",
|
||||
summary="장소 자동완성 검색 (숙박/음식점 등)",
|
||||
summary="숙박/펜션 자동완성 검색",
|
||||
description="""
|
||||
네이버 지역 검색 API를 이용한 장소 자동완성 검색입니다.
|
||||
네이버 지역 검색 API를 이용한 숙박/펜션 자동완성 검색입니다.
|
||||
|
||||
## 요청 파라미터
|
||||
- **query**: 검색어 (필수)
|
||||
- **category**: 카테고리
|
||||
|
||||
## 반환 정보
|
||||
- **query**: 검색어
|
||||
- **count**: 검색 결과 수 (최대 10개)
|
||||
- **items**: 검색 결과 목록
|
||||
- **title**: 장소명 (HTML 태그 포함 가능)
|
||||
- **title**: 숙소명 (HTML 태그 포함 가능)
|
||||
- **address**: 지번 주소
|
||||
- **roadAddress**: 도로명 주소
|
||||
""",
|
||||
|
|
@ -77,7 +99,7 @@ router = APIRouter()
|
|||
async def search_accommodation(
|
||||
query: str,
|
||||
) -> AccommodationSearchResponse:
|
||||
"""장소 자동완성 검색"""
|
||||
"""숙박/펜션 자동완성 검색"""
|
||||
results = await naver_search_client.search_accommodation(
|
||||
query=query,
|
||||
display=10,
|
||||
|
|
@ -92,46 +114,14 @@ async def search_accommodation(
|
|||
)
|
||||
|
||||
|
||||
def _extract_region_from_address(road_address: str | None, jibun_address: str | None = None) -> str:
|
||||
return extract_region_from_address(road_address, jibun_address)
|
||||
|
||||
|
||||
class _IndustryOutput(BaseModel):
|
||||
industry: Literal[
|
||||
"stay", "restaurant", "cafe", "salon", "clinic", "fitness", "academy", "attraction", "general"
|
||||
]
|
||||
|
||||
|
||||
async def _resolve_industry(category: str, customer_name: str = "") -> str:
|
||||
"""업체를 통합 프롬프트의 9개 industry enum 중 하나로 AI 분류.
|
||||
|
||||
분류 근거는 카테고리를 우선 사용하고, 카테고리가 없으면 업체명으로 분류한다.
|
||||
근거가 둘 다 없으면 빈 문자열 반환. API 장애 시 예외 전파(하위 분석도 동일 API 의존).
|
||||
"""
|
||||
if category:
|
||||
basis_label, basis_value = "네이버 지도 카테고리", category
|
||||
elif customer_name:
|
||||
basis_label, basis_value = "업체명", customer_name
|
||||
else:
|
||||
def _extract_region_from_address(road_address: str | None) -> str:
|
||||
"""roadAddress에서 시 이름 추출"""
|
||||
if not road_address:
|
||||
return ""
|
||||
chatgpt = ChatgptService()
|
||||
prompt = (
|
||||
f"{basis_label}: '{basis_value}'\n"
|
||||
"위 정보를 바탕으로 이 업체를 다음 업종 중 가장 적합한 하나로 분류하세요: "
|
||||
"stay(숙박/펜션/호텔), restaurant(음식점), cafe(카페/디저트), "
|
||||
"salon(미용실/네일/뷰티), clinic(병원/의원/치과), fitness(헬스/필라테스/요가), "
|
||||
"academy(학원/교습소), attraction(관광/체험/액티비티/축제/행사). "
|
||||
"위 8개 중 어느 것에도 명확히 해당하지 않는 경우에만 general(기타/범용 업종)로 분류하세요. "
|
||||
"유사한 업종이 있으면 general 대신 그 업종을 우선 선택하세요."
|
||||
)
|
||||
result = await chatgpt._call_pydantic_output_chat_completion(
|
||||
prompt=prompt,
|
||||
output_format=_IndustryOutput,
|
||||
model="gpt-4o-mini",
|
||||
img_url=None,
|
||||
image_detail_high=False,
|
||||
)
|
||||
return result.industry
|
||||
for city in KOREAN_CITIES:
|
||||
if city in road_address:
|
||||
return city
|
||||
return ""
|
||||
|
||||
|
||||
@router.post(
|
||||
|
|
@ -228,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(
|
||||
|
|
@ -249,12 +230,12 @@ async def _crawling_logic(
|
|||
)
|
||||
|
||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||
image_count = len(scraper.image_link_list) if scraper.image_link_list else 0
|
||||
logger.info(
|
||||
f"[crawling] Step 1 완료 - 업체사진 {len(scraper.owner_images or [])}개, "
|
||||
f"보충사진 {len(scraper.extra_photo_urls or [])}개 ({step1_elapsed:.1f}ms)"
|
||||
f"[crawling] Step 1 완료 - 이미지 {image_count}개 ({step1_elapsed:.1f}ms)"
|
||||
)
|
||||
|
||||
# ========== Step 2: 정보 가공 (industry 선행 계산) ==========
|
||||
# ========== Step 2: 정보 가공 ==========
|
||||
step2_start = time.perf_counter()
|
||||
logger.info("[crawling] Step 2: 정보 가공 시작...")
|
||||
|
||||
|
|
@ -263,141 +244,111 @@ async def _crawling_logic(
|
|||
|
||||
if scraper.base_info:
|
||||
road_address = scraper.base_info.get("roadAddress", "")
|
||||
jibun_address = scraper.base_info.get("address", "")
|
||||
customer_name = scraper.base_info.get("name", "")
|
||||
category = scraper.base_info.get("category", "")
|
||||
region = _extract_region_from_address(road_address, jibun_address)
|
||||
try:
|
||||
# industry는 마케팅 적합성 필터(Step 3)와 ProcessedInfo 양쪽에 필요하므로
|
||||
# 이미지 필터링보다 먼저 계산한다.
|
||||
industry = await _resolve_industry(category, customer_name)
|
||||
except ChatGPTResponseError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"업종 분류 중 ChatGPT 오류가 발생했습니다: {e.error_message}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[crawling] Step 2 FAILED - 업종 분류 오류: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="업종 분류 중 오류가 발생했습니다.",
|
||||
)
|
||||
region = _extract_region_from_address(road_address)
|
||||
|
||||
processed_info = ProcessedInfo(
|
||||
customer_name=customer_name,
|
||||
region=region,
|
||||
detail_region_info=road_address or jibun_address or "",
|
||||
industry=industry,
|
||||
detail_region_info=road_address or "",
|
||||
)
|
||||
|
||||
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
||||
logger.info(
|
||||
f"[crawling] Step 2 완료 - {customer_name}, {region}, "
|
||||
f"category={category!r}, industry={industry!r} ({step2_elapsed:.1f}ms)"
|
||||
f"[crawling] Step 2 완료 - {customer_name}, {region} ({step2_elapsed:.1f}ms)"
|
||||
)
|
||||
|
||||
# ========== Step 3: 이미지 마케팅 적합성 필터링 ==========
|
||||
# 업체 사진이 SUPPLEMENT_THRESHOLD(30장) 이상이면 보충이 불필요하므로
|
||||
# 방문자 사진 필터링(Gemini 호출) 자체를 건너뛴다.
|
||||
# ========== Step 3: ChatGPT 마케팅 분석 ==========
|
||||
step3_start = time.perf_counter()
|
||||
owner_images = scraper.owner_images or []
|
||||
extra_photo_urls = scraper.extra_photo_urls or []
|
||||
|
||||
if len(owner_images) >= NvMapScraper.SUPPLEMENT_THRESHOLD:
|
||||
# 업체 제공 사진은 필터링 면제 대상이므로 MAX_IMAGES 상한 없이 수집분을 전부 사용한다
|
||||
# (MAX_IMAGES 상한은 방문자 사진이 섞이는 보충 경로에만 적용.
|
||||
# 수집 자체는 NvMapScraper.BIZ_MAX_PAGES가 상한이며 도달 시 scraper가 warning을 남긴다).
|
||||
scraper.image_link_list = list(owner_images)
|
||||
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
||||
logger.info(
|
||||
f"[crawling] Step 3 SKIP - 업체 사진 {len(owner_images)}장 "
|
||||
f"≥ {NvMapScraper.SUPPLEMENT_THRESHOLD}장 → 방문자 사진 필터링 생략, 업체 사진만 사용"
|
||||
)
|
||||
else:
|
||||
try:
|
||||
extra_pass_flags = await filter_marketing_images(
|
||||
[img["original"] for img in extra_photo_urls], industry
|
||||
)
|
||||
except Exception as e:
|
||||
# logger.error(f"[crawling] Step 3 FAILED - 이미지 필터링 중 오류: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="이미지 마케팅 적합성 필터링 중 오류가 발생했습니다.",
|
||||
)
|
||||
scraper.image_link_list = assemble_images(
|
||||
owner_images, extra_photo_urls, extra_pass_flags, NvMapScraper.MAX_IMAGES
|
||||
)
|
||||
passed_count = sum(extra_pass_flags)
|
||||
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
||||
logger.info(
|
||||
f"[crawling] Step 3 완료 - 방문자 사진 {len(extra_photo_urls)}장 중 "
|
||||
f"{passed_count}장 통과 → 최종 이미지 {len(scraper.image_link_list)}장 "
|
||||
f"({step3_elapsed:.1f}ms)"
|
||||
)
|
||||
if not scraper.image_link_list:
|
||||
logger.warning("[crawling] Step 3 - 필터링 후 사용 가능 이미지가 0장입니다.")
|
||||
|
||||
# ========== Step 4: ChatGPT 마케팅 분석 ==========
|
||||
step4_start = time.perf_counter()
|
||||
logger.info("[crawling] Step 4: ChatGPT 마케팅 분석 시작...")
|
||||
logger.info("[crawling] Step 3: ChatGPT 마케팅 분석 시작...")
|
||||
|
||||
try:
|
||||
# Step 4-1: ChatGPT 서비스 초기화 및 입력 데이터 구성
|
||||
# Step 3-1: ChatGPT 서비스 초기화
|
||||
step3_1_start = time.perf_counter()
|
||||
chatgpt_service = ChatgptService()
|
||||
# 메뉴 전달은 잠정 보류 — 사이드 메뉴(볶음밥·냉면 등)가 분석에 과도한 영향을 주는
|
||||
# 문제로 프롬프트에서 {menu_info}를 제거함. 크롤링(scraper.menu_info)은 유지 중이므로
|
||||
# 주력/사이드 구분 방안이 정리되면 아래 주석을 되살려 재전달할 것.
|
||||
# menus = scraper.menu_info or []
|
||||
# menus = sorted(menus, key=lambda m: not m.get("recommend"))[:15]
|
||||
# menu_info = ", ".join(m["name"] for m in menus if m.get("name"))
|
||||
top_keywords = (scraper.voted_keyword_stats or [])[:5]
|
||||
step3_1_elapsed = (time.perf_counter() - step3_1_start) * 1000
|
||||
logger.debug(
|
||||
f"[crawling] Step 3-1: 서비스 초기화 완료 ({step3_1_elapsed:.1f}ms)"
|
||||
)
|
||||
|
||||
# Step 3-2: 프롬프트 생성
|
||||
# step3_2_start = time.perf_counter()
|
||||
input_marketing_data = {
|
||||
"customer_name": customer_name,
|
||||
"region": region,
|
||||
"detail_region_info": road_address or "",
|
||||
"industry": industry, # 통합 프롬프트 내부 분기용 업종 enum
|
||||
"category": category, # 네이버 지도 원본 카테고리 텍스트
|
||||
"facility_info": scraper.facility_info or "",
|
||||
"voted_keywords": ", ".join(kw["displayName"] for kw in top_keywords),
|
||||
# "menu_info": menu_info, # 실제 판매 품목 (셀링포인트 구체화용)
|
||||
}
|
||||
# prompt = chatgpt_service.build_market_analysis_prompt()
|
||||
# prompt1 = marketing_prompt.build_prompt(input_marketing_data)
|
||||
# step3_2_elapsed = (time.perf_counter() - step3_2_start) * 1000
|
||||
|
||||
# Step 4-2: GPT API 호출 → 구조화된 마케팅 분석 결과 반환
|
||||
marketing_analysis = await chatgpt_service.generate_structured_output(
|
||||
# Step 3-3: GPT API 호출
|
||||
step3_3_start = time.perf_counter()
|
||||
structured_report = await chatgpt_service.generate_structured_output(
|
||||
marketing_prompt, input_marketing_data
|
||||
)
|
||||
|
||||
# Step 4-3: 분석 결과 DB 저장 (industry는 Project로 흐르므로 여기엔 미저장)
|
||||
marketing_intel = MarketingIntel(
|
||||
place_id=scraper.place_id,
|
||||
intel_result=marketing_analysis.model_dump(),
|
||||
marketing_intelligence = MarketingIntel(
|
||||
place_id = scraper.place_id,
|
||||
intel_result = structured_report.model_dump()
|
||||
)
|
||||
session.add(marketing_intel)
|
||||
session.add(marketing_intelligence)
|
||||
await session.commit()
|
||||
await session.refresh(marketing_intel)
|
||||
m_id = marketing_intel.id
|
||||
logger.debug(f"[MarketingPrompt] INSERT place_id={marketing_intel.place_id} id={marketing_intel.id}")
|
||||
|
||||
step4_elapsed = (time.perf_counter() - step4_start) * 1000
|
||||
await session.refresh(marketing_intelligence)
|
||||
m_id = marketing_intelligence.id
|
||||
logger.debug(f"[MarketingPrompt] INSERT placeid {marketing_intelligence.place_id}")
|
||||
step3_3_elapsed = (time.perf_counter() - step3_3_start) * 1000
|
||||
logger.info(
|
||||
f"[crawling] Step 4 완료 - 마케팅 분석 성공 ({step4_elapsed:.1f}ms)"
|
||||
f"[crawling] Step 3-3: GPT API 호출 완료 - ({step3_3_elapsed:.1f}ms)"
|
||||
)
|
||||
logger.debug(
|
||||
f"[crawling] Step 3-3: GPT API 호출 완료 - ({step3_3_elapsed:.1f}ms)"
|
||||
)
|
||||
|
||||
# Step 3-4: 응답 파싱 (크롤링에서 가져온 facility_info 전달)
|
||||
step3_4_start = time.perf_counter()
|
||||
logger.debug(
|
||||
f"[crawling] Step 3-4: 응답 파싱 시작 - facility_info: {scraper.facility_info}"
|
||||
)
|
||||
|
||||
# 요약 Deprecated / 20250115 / Selling points를 첫 prompt에서 추출 중
|
||||
# parsed = await chatgpt_service.parse_marketing_analysis(
|
||||
# structured_report, facility_info=scraper.facility_info
|
||||
# )
|
||||
|
||||
# marketing_analysis = MarketingAnalysis(**parsed)
|
||||
|
||||
logger.debug(
|
||||
f"structured_report = {structured_report.model_dump()}"
|
||||
)
|
||||
|
||||
marketing_analysis = structured_report
|
||||
|
||||
step3_4_elapsed = (time.perf_counter() - step3_4_start) * 1000
|
||||
logger.debug(
|
||||
f"[crawling] Step 3-4: 응답 파싱 완료 ({step3_4_elapsed:.1f}ms)"
|
||||
)
|
||||
|
||||
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
||||
logger.info(
|
||||
f"[crawling] Step 3 완료 - 마케팅 분석 성공 ({step3_elapsed:.1f}ms)"
|
||||
)
|
||||
|
||||
except ChatGPTResponseError as e:
|
||||
step4_elapsed = (time.perf_counter() - step4_start) * 1000
|
||||
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
||||
logger.error(
|
||||
f"[crawling] Step 4 FAILED - ChatGPT Error: status={e.status}, "
|
||||
f"code={e.error_code}, message={e.error_message} ({step4_elapsed:.1f}ms)"
|
||||
f"[crawling] Step 3 FAILED - ChatGPT Error: status={e.status}, "
|
||||
f"code={e.error_code}, message={e.error_message} ({step3_elapsed:.1f}ms)"
|
||||
)
|
||||
marketing_analysis = None
|
||||
gpt_status = "failed"
|
||||
|
||||
except Exception as e:
|
||||
step4_elapsed = (time.perf_counter() - step4_start) * 1000
|
||||
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
||||
logger.error(
|
||||
f"[crawling] Step 4 FAILED - GPT 마케팅 분석 중 오류: {e} ({step4_elapsed:.1f}ms)"
|
||||
f"[crawling] Step 3 FAILED - GPT 마케팅 분석 중 오류: {e} ({step3_elapsed:.1f}ms)"
|
||||
)
|
||||
logger.exception("[crawling] Step 4 상세 오류:")
|
||||
logger.exception("[crawling] Step 3 상세 오류:")
|
||||
# GPT 실패 시에도 크롤링 결과는 반환
|
||||
marketing_analysis = None
|
||||
gpt_status = "failed"
|
||||
else:
|
||||
|
|
@ -414,9 +365,9 @@ async def _crawling_logic(
|
|||
if scraper.base_info:
|
||||
logger.info(f"[crawling] - Step 2 (정보가공): {step2_elapsed:.1f}ms")
|
||||
if "step3_elapsed" in locals():
|
||||
logger.info(f"[crawling] - Step 3 (이미지 필터링): {step3_elapsed:.1f}ms")
|
||||
if "step4_elapsed" in locals():
|
||||
logger.info(f"[crawling] - Step 4 (GPT 분석): {step4_elapsed:.1f}ms")
|
||||
logger.info(f"[crawling] - Step 3 (GPT 분석): {step3_elapsed:.1f}ms")
|
||||
if "step3_3_elapsed" in locals():
|
||||
logger.info(f"[crawling] - GPT API 호출: {step3_3_elapsed:.1f}ms")
|
||||
|
||||
return {
|
||||
"status": gpt_status if 'gpt_status' in locals() else "completed",
|
||||
|
|
@ -424,97 +375,10 @@ async def _crawling_logic(
|
|||
"image_count": len(scraper.image_link_list) if scraper.image_link_list else 0,
|
||||
"processed_info": processed_info,
|
||||
"marketing_analysis": marketing_analysis,
|
||||
"m_id": m_id,
|
||||
"industry": industry if 'industry' in locals() else "",
|
||||
"m_id" : m_id
|
||||
}
|
||||
|
||||
|
||||
|
||||
@router.post(
|
||||
"/marketing",
|
||||
summary="업체명+주소 직접 입력 마케팅 분석",
|
||||
description="""
|
||||
네이버 크롤링 없이 업체명과 주소를 직접 입력받아 마케팅 분석을 수행합니다.
|
||||
|
||||
## 요청 필드
|
||||
- **customer_name**: 업체명 / 브랜드명 (필수)
|
||||
- **address**: 도로명 또는 지번 주소 (필수)
|
||||
- **category**: 업종/카테고리 자유 입력 (선택, 예: 펜션, 카페). 비우면 업체명 기반 AI 분류
|
||||
|
||||
## 반환 정보
|
||||
- **processed_info**: 가공된 장소 정보 (customer_name, region, detail_region_info)
|
||||
- **marketing_analysis**: ChatGPT 마케팅 분석 결과
|
||||
- **m_id**: 마케팅 분석 결과 ID (이후 영상생성 파이프라인에 사용)
|
||||
""",
|
||||
response_model=CrawlingResponse,
|
||||
tags=["Marketing"],
|
||||
)
|
||||
async def manual_marketing(
|
||||
request_body: ManualMarketingRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
# Step 1: 주소에서 지역명 추출 및 processed_info 구성
|
||||
region = _extract_region_from_address(request_body.address)
|
||||
processed_info = ProcessedInfo(
|
||||
customer_name=request_body.store_name,
|
||||
region=region,
|
||||
detail_region_info=request_body.address,
|
||||
)
|
||||
try:
|
||||
# Step 2: GPT API 호출 → 마케팅 분석 결과 생성
|
||||
# place_id 없이 업체명+주소만으로 분석 (크롤링 없이 직접 입력된 경우)
|
||||
chatgpt_service = ChatgptService()
|
||||
# 크롤링 경로와 동일하게 category를 우선 사용하고, 없으면 업체명 기반 AI 분류
|
||||
industry = await _resolve_industry(
|
||||
request_body.category, request_body.store_name
|
||||
)
|
||||
processed_info.industry = industry
|
||||
input_marketing_data = {
|
||||
"customer_name": request_body.store_name,
|
||||
"region": region,
|
||||
"detail_region_info": request_body.address,
|
||||
"industry": industry, # 통합 프롬프트 내부 분기용 업종 enum
|
||||
"category": request_body.category, # 사용자 입력 원본 카테고리 텍스트 (세부 묘사 참고용)
|
||||
}
|
||||
marketing_analysis = await chatgpt_service.generate_structured_output(
|
||||
marketing_prompt, input_marketing_data
|
||||
)
|
||||
|
||||
# Step 3: 분석 결과 DB 저장 (place_id=None — 네이버 장소와 연결되지 않음)
|
||||
marketing_intel = MarketingIntel(
|
||||
place_id=None,
|
||||
intel_result=marketing_analysis.model_dump(),
|
||||
)
|
||||
session.add(marketing_intel)
|
||||
await session.commit()
|
||||
await session.refresh(marketing_intel)
|
||||
m_id = marketing_intel.id
|
||||
logger.debug(f"[MarketingPrompt] INSERT id={marketing_intel.id}")
|
||||
except ChatGPTResponseError as e:
|
||||
logger.error(
|
||||
f"[marketing] ChatGPT Error: status={e.status}, "
|
||||
f"code={e.error_code}, message={e.error_message}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"마케팅 분석 중 ChatGPT 오류가 발생했습니다: {e.error_message}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[marketing] 마케팅 분석 중 오류: {e}")
|
||||
logger.exception("[marketing] 상세 오류:")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="마케팅 분석 중 오류가 발생했습니다.",
|
||||
)
|
||||
return CrawlingResponse(
|
||||
status="completed",
|
||||
processed_info=processed_info,
|
||||
marketing_analysis=marketing_analysis,
|
||||
m_id=m_id,
|
||||
industry=industry,
|
||||
)
|
||||
|
||||
|
||||
async def _autocomplete_logic(autocomplete_item:dict):
|
||||
step1_start = time.perf_counter()
|
||||
try:
|
||||
|
|
@ -587,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)",
|
||||
|
|
@ -675,10 +788,6 @@ async def upload_images_blob(
|
|||
default=None,
|
||||
description="이미지 바이너리 파일 목록",
|
||||
),
|
||||
industry: str = Form(
|
||||
default="",
|
||||
description="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general). 크롤링 응답의 industry 값을 그대로 전달",
|
||||
),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> ImageUploadResponse:
|
||||
"""이미지 업로드 (URL + Azure Blob Storage)
|
||||
|
|
@ -879,22 +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, industry=industry, clear_old_tags=True)
|
||||
logger.info(f"[image_tagging] Done - task_id: {task_id}")
|
||||
|
||||
# 태깅 직후 영상 생성에 사용 가능한 이미지가 하나도 없으면 조기에 실패시킨다.
|
||||
# (여기서 걸러지지 않으면 훨씬 나중인 영상 생성 단계에서야 슬롯 미배정으로 발견됨)
|
||||
# marketing_acceptable 필터링은 크롤링 단계에서 이미 완료되었으므로 여기서는 재필터링하지 않는다.
|
||||
taged_image_list = await get_image_tags_by_task_id(task_id)
|
||||
logger.info(f"태깅된 이미지: {len(taged_image_list)}개 - task_id: {task_id}")
|
||||
if not taged_image_list:
|
||||
logger.error(f"[image_tagging] 영상 생성에 적합한 이미지가 없음 - task_id: {task_id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="영상 생성에 적합한 이미지가 없습니다. 다른 이미지로 다시 업로드해주세요.",
|
||||
)
|
||||
|
||||
total_time = time.perf_counter() - request_start
|
||||
logger.info(
|
||||
f"[upload_images_blob] SUCCESS - task_id: {task_id}, "
|
||||
|
|
@ -910,48 +1003,3 @@ async def upload_images_blob(
|
|||
images=result_images,
|
||||
image_urls=image_urls,
|
||||
)
|
||||
|
||||
|
||||
async def tagging_images(
|
||||
image_urls : list[str],
|
||||
industry: 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], industry=industry)
|
||||
# print(tag_datas)
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
for tag, tag_data in zip(null_imts, tag_datas):
|
||||
if isinstance(tag_data, Exception):
|
||||
# 태깅 실패 이미지는 img_tag가 NULL로 남아 영상 생성 풀에서 제외됨
|
||||
logger.warning(
|
||||
f"[tagging_images] 이미지 태깅 최종 실패 - url: {tag.img_url}, "
|
||||
f"error: {type(tag_data).__name__}: {tag_data}"
|
||||
)
|
||||
continue
|
||||
tag.img_tag = tag_data.model_dump(mode="json")
|
||||
session.add(tag)
|
||||
await session.commit()
|
||||
|
|
|
|||
|
|
@ -9,8 +9,7 @@ Home 모듈 SQLAlchemy 모델 정의
|
|||
from datetime import datetime
|
||||
from 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
|
||||
|
|
@ -114,13 +113,6 @@ class Project(Base):
|
|||
comment="마케팅 인텔리전스 결과 정보 저장",
|
||||
)
|
||||
|
||||
industry: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
default="",
|
||||
comment="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general)",
|
||||
)
|
||||
|
||||
language: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
|
|
@ -296,10 +288,10 @@ class MarketingIntel(Base):
|
|||
comment="고유 식별자",
|
||||
)
|
||||
|
||||
place_id: Mapped[Optional[str]] = mapped_column(
|
||||
place_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
nullable=True,
|
||||
comment="매장 소스별 고유 식별자 (네이버 크롤링 시 'nv{id}' 형식; 직접 입력 시 NULL)",
|
||||
nullable=False,
|
||||
comment="매장 소스별 고유 식별자",
|
||||
)
|
||||
|
||||
intel_result : Mapped[dict[str, Any]] = mapped_column(
|
||||
|
|
@ -314,12 +306,6 @@ class MarketingIntel(Base):
|
|||
comment="자막 정보 생성 결과물",
|
||||
)
|
||||
|
||||
image_match : Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON,
|
||||
nullable=True,
|
||||
comment="이미지 슬롯 배정 결과물 — {슬롯명: image_url, 'audio-music': music_url, ...}",
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
|
|
@ -328,52 +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(
|
||||
# none_as_null=True: ORM에서 None 대입 시 JSON 리터럴 null이 아닌 SQL NULL로 저장.
|
||||
# 미지정 시 JSON null이 저장되어 `img_tag.is_not(None)` 필터를 통과하는 버그가 있었음.
|
||||
JSON(none_as_null=True),
|
||||
nullable=True,
|
||||
default=None,
|
||||
comment="태그 JSON",
|
||||
)
|
||||
return (
|
||||
f"<Image(id={self.id}, task_id='{task_id_str}', img_name='{img_name_str}')>"
|
||||
)
|
||||
|
|
@ -78,7 +78,6 @@ class ProcessedInfo(BaseModel):
|
|||
customer_name: str = Field(..., description="고객명/가게명 (base_info.name)")
|
||||
region: str = Field(..., description="지역명 (roadAddress에서 추출한 시 이름)")
|
||||
detail_region_info: str = Field(..., description="상세 지역 정보 (roadAddress)")
|
||||
industry: str = Field(default="", description="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general)")
|
||||
|
||||
|
||||
# class MarketingAnalysisDetail(BaseModel):
|
||||
|
|
@ -98,12 +97,6 @@ class ProcessedInfo(BaseModel):
|
|||
# selling_points: list[str] = Field(default_factory=list, description="추천 부대시설 목록")
|
||||
|
||||
|
||||
class ImageListItem(BaseModel):
|
||||
"""크롤링 이미지 아이템 (미리보기 URL + 원본 URL)"""
|
||||
preview: str = Field(..., description="미리보기용 CDN URL")
|
||||
original: str = Field(..., description="업로드용 원본 URL")
|
||||
|
||||
|
||||
class CrawlingResponse(BaseModel):
|
||||
"""크롤링 응답 스키마"""
|
||||
|
||||
|
|
@ -111,7 +104,7 @@ class CrawlingResponse(BaseModel):
|
|||
json_schema_extra={
|
||||
"example": {
|
||||
"status": "completed",
|
||||
"image_list": [{"preview": "https://example.com/image1.jpg", "original": "https://example.com/image1.jpg"}],
|
||||
"image_list": ["https://example.com/image1.jpg", "https://example.com/image2.jpg"],
|
||||
"image_count": 2,
|
||||
"processed_info": {
|
||||
"customer_name": "스테이 머뭄",
|
||||
|
|
@ -240,8 +233,8 @@ class CrawlingResponse(BaseModel):
|
|||
default="completed",
|
||||
description="처리 상태 (completed: 성공, failed: ChatGPT 분석 실패)"
|
||||
)
|
||||
image_list: Optional[list[ImageListItem]] = Field(None, description="이미지 목록 (preview/original URL)")
|
||||
image_count: Optional[int] = Field(None, description="이미지 개수")
|
||||
image_list: Optional[list[str]] = Field(None, description="이미지 URL 목록")
|
||||
image_count: int = Field(..., description="이미지 개수")
|
||||
processed_info: Optional[ProcessedInfo] = Field(
|
||||
None, description="가공된 장소 정보 (customer_name, region, detail_region_info)"
|
||||
)
|
||||
|
|
@ -249,25 +242,6 @@ class CrawlingResponse(BaseModel):
|
|||
None, description="마케팅 분석 결과 . 실패 시 null"
|
||||
)
|
||||
m_id : int = Field(..., description="마케팅 분석 결과 ID")
|
||||
industry: str = Field(default="", description="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general)")
|
||||
|
||||
|
||||
class ManualMarketingRequest(BaseModel):
|
||||
"""업체명+주소 직접 입력 마케팅 분석 요청"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"store_name": "스테이 머뭄",
|
||||
"address": "전북특별자치도 군산시 절골길 18",
|
||||
"category": "펜션",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
store_name: str = Field(..., description="업체명 / 브랜드명")
|
||||
address: str = Field(..., description="도로명 또는 지번 주소")
|
||||
category: str = Field(default="", description="업체 업종/카테고리 자유 입력 (예: 펜션, 카페). 크롤링 경로와 동일하게 AI가 8개 industry enum으로 자동 분류하는 데 사용. 비우면 업체명 기반 AI 분류")
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ class NaverSearchClient:
|
|||
display: int = 5,
|
||||
) -> List[dict]:
|
||||
"""
|
||||
장소 검색 (숙박, 음식점 등)
|
||||
숙박/펜션 검색
|
||||
|
||||
Args:
|
||||
query: 검색어
|
||||
|
|
@ -41,7 +41,8 @@ class NaverSearchClient:
|
|||
Returns:
|
||||
검색 결과 리스트 (address, roadAddress, title)
|
||||
"""
|
||||
search_query = query
|
||||
# 숙박/펜션 카테고리 검색을 위해 쿼리에 키워드 추가
|
||||
search_query = f"{query} 숙박"
|
||||
|
||||
headers = {
|
||||
"X-Naver-Client-Id": self.client_id,
|
||||
|
|
|
|||
|
|
@ -40,11 +40,9 @@ from app.lyric.schemas.lyric import (
|
|||
LyricDetailResponse,
|
||||
LyricListItem,
|
||||
LyricStatusResponse,
|
||||
SubtitleStatusResponse,
|
||||
)
|
||||
from app.lyric.worker.lyric_task import generate_lyric_background
|
||||
from app.video.worker.creative_assets_task import generate_subtitle_background
|
||||
from app.utils.prompts.chatgpt_prompt import ChatgptService
|
||||
from app.lyric.worker.lyric_task import generate_lyric_background, generate_subtitle_background
|
||||
from app.utils.chatgpt_prompt import ChatgptService
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.pagination import PaginatedResponse, get_paginated
|
||||
|
||||
|
|
@ -161,7 +159,6 @@ async def get_lyric_by_task_id(
|
|||
project_id=lyric.project_id,
|
||||
status=lyric.status,
|
||||
lyric_result=lyric.lyric_result,
|
||||
genre=lyric.genre,
|
||||
created_at=lyric.created_at,
|
||||
)
|
||||
|
||||
|
|
@ -243,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(
|
||||
|
|
@ -271,21 +253,35 @@ 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",
|
||||
"Chinese" : "网红打卡, 治愈系, 旅行, 度假, 拍照圣地",
|
||||
"Japanese" : "インスタ映え, 写真のような一日, 癒し, 旅行, 絶景",
|
||||
"Thai" : "ที่พักสวย, ฮีลใจ, เที่ยว, ถ่ายรูป, วิวสวย",
|
||||
"Vietnamese" : "check-in đẹp, healing, du lịch, nghỉ dưỡng, view đẹp"
|
||||
}# HARD CODED, 어디에 정리하지? 아직 정리되지 않음
|
||||
|
||||
timing_rules = {
|
||||
"60s" : """
|
||||
8–12 lines
|
||||
Full verse flow, immersive mood
|
||||
"""
|
||||
}
|
||||
marketing_intel_result = await session.execute(select(MarketingIntel).where(MarketingIntel.id == request_body.m_id))
|
||||
marketing_intel = marketing_intel_result.scalar_one_or_none()
|
||||
|
||||
# 자동 선택(genre 미지정) 재생성 시, 직전에 사용된 장르를 조회해 이번엔 다른 장르가 나오도록 제외 처리
|
||||
exclude_genre = ""
|
||||
if not request_body.genre:
|
||||
previous_lyric_result = await session.execute(
|
||||
select(Lyric)
|
||||
.where(Lyric.task_id == task_id, Lyric.genre.is_not(None))
|
||||
.order_by(Lyric.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
previous_lyric = previous_lyric_result.scalar_one_or_none()
|
||||
if previous_lyric:
|
||||
exclude_genre = previous_lyric.genre
|
||||
|
||||
lyric_input_data = {
|
||||
"customer_name" : request_body.customer_name,
|
||||
|
|
@ -293,14 +289,10 @@ async def generate_lyric(
|
|||
"detail_region_info" : request_body.detail_region_info or "",
|
||||
"marketing_intelligence_summary" : json.dumps(marketing_intel.intel_result, ensure_ascii = False),
|
||||
"language" : request_body.language,
|
||||
"genre": request_body.genre or "", # 비어있으면 GPT가 가사 무드에 맞춰 장르를 직접 추천
|
||||
"exclude_genre": exclude_genre, # 자동 선택 재생성 시 직전 장르 제외
|
||||
"industry": request_body.industry, # 크롤 응답에서 받아 전달된 업종 enum
|
||||
"promotional_expression_example" : promotional_expressions[request_body.language],
|
||||
"timing_rules" : timing_rules["60s"], # 아직은 선택지 하나
|
||||
}
|
||||
|
||||
# 업종 분기는 프롬프트 내부 {industry}로 처리하므로 단일 프롬프트 사용
|
||||
selected_lyric_prompt = lyric_prompt
|
||||
|
||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||
#logger.debug(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)")
|
||||
|
||||
|
|
@ -326,8 +318,7 @@ async def generate_lyric(
|
|||
detail_region_info=request_body.detail_region_info,
|
||||
language=request_body.language,
|
||||
user_uuid=current_user.user_uuid,
|
||||
marketing_intelligence=request_body.m_id,
|
||||
industry=request_body.industry,
|
||||
marketing_intelligence = request_body.m_id
|
||||
)
|
||||
session.add(project)
|
||||
await session.commit()
|
||||
|
|
@ -341,7 +332,7 @@ async def generate_lyric(
|
|||
step3_start = time.perf_counter()
|
||||
logger.debug(f"[generate_lyric] Step 3: Lyric 저장 (processing)...")
|
||||
|
||||
estimated_prompt = selected_lyric_prompt.build_prompt(lyric_input_data)
|
||||
estimated_prompt = lyric_prompt.build_prompt(lyric_input_data)
|
||||
lyric = Lyric(
|
||||
project_id=project.id,
|
||||
task_id=task_id,
|
||||
|
|
@ -349,7 +340,6 @@ async def generate_lyric(
|
|||
lyric_prompt=estimated_prompt,
|
||||
lyric_result=None,
|
||||
language=request_body.language,
|
||||
genre=request_body.genre or None, # 자동 선택인 경우 GPT 응답 완료 후 채워짐
|
||||
)
|
||||
session.add(lyric)
|
||||
await session.commit()
|
||||
|
|
@ -362,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=selected_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
|
||||
|
|
@ -521,96 +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}'에 해당하는 마케팅 인텔리전스를 찾을 수 없습니다.",
|
||||
)
|
||||
|
||||
# 자막과 이미지 배정 모두 완료돼야 영상 생성 가능
|
||||
subtitle_done = bool(intel.subtitle)
|
||||
image_match_done = bool(intel.image_match)
|
||||
|
||||
if subtitle_done and image_match_done:
|
||||
logger.info(f"[get_subtitle_status] completed (subtitle+image_match) - task_id: {task_id}")
|
||||
return SubtitleStatusResponse(
|
||||
task_id=task_id,
|
||||
status="completed",
|
||||
message="자막 생성 및 이미지 배정이 완료되었습니다.",
|
||||
)
|
||||
|
||||
pending_parts = []
|
||||
if not subtitle_done:
|
||||
pending_parts.append("자막")
|
||||
if not image_match_done:
|
||||
pending_parts.append("이미지 배정")
|
||||
pending_msg = ", ".join(pending_parts)
|
||||
logger.info(f"[get_subtitle_status] pending ({pending_msg}) - task_id: {task_id}")
|
||||
return SubtitleStatusResponse(
|
||||
task_id=task_id,
|
||||
status="pending",
|
||||
message=f"{pending_msg} 처리가 진행 중입니다. 잠시 후 다시 확인해주세요.",
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{task_id}",
|
||||
summary="가사 상세 조회",
|
||||
|
|
|
|||
|
|
@ -93,13 +93,6 @@ class Lyric(Base):
|
|||
comment="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||
)
|
||||
|
||||
genre: Mapped[str | None] = mapped_column(
|
||||
String(20),
|
||||
nullable=True,
|
||||
comment="음악 장르 (kpop, pop, ballad, hip-hop, rnb, edm, jazz, rock). "
|
||||
"수동 선택 시 요청값, 자동 선택 시 GPT 추천값",
|
||||
)
|
||||
|
||||
is_deleted: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ class GenerateLyricRequest(BaseModel):
|
|||
"region": "군산",
|
||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||
"language": "Korean",
|
||||
"m_id" : 2,
|
||||
"m_id" : 1,
|
||||
"orientation" : "vertical"
|
||||
}
|
||||
"""
|
||||
|
|
@ -70,19 +70,12 @@ class GenerateLyricRequest(BaseModel):
|
|||
language: str = Field(
|
||||
default="Korean",
|
||||
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||
)
|
||||
),
|
||||
orientation: Literal["horizontal", "vertical"] = Field(
|
||||
default="vertical",
|
||||
description="영상 방향 (horizontal: 가로형, vertical: 세로형)",
|
||||
)
|
||||
),
|
||||
m_id : Optional[int] = Field(None, description="마케팅 인텔리전스 ID 값")
|
||||
industry: str = Field(default="", description="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general)")
|
||||
instrumental: bool = Field(default=False, description="BGM 전용 모드 (가사 생성 안 함)")
|
||||
genre: Optional[str] = Field(
|
||||
None,
|
||||
description="사용자가 수동으로 고른 음악 장르 (kpop|pop|ballad|hip-hop|rnb|edm|jazz|rock). "
|
||||
"'자동 선택'인 경우 None으로 전달하면 GPT가 가사 무드에 맞춰 추천",
|
||||
)
|
||||
|
||||
|
||||
class GenerateLyricResponse(BaseModel):
|
||||
|
|
@ -205,62 +198,9 @@ class LyricDetailResponse(BaseModel):
|
|||
project_id: int = Field(..., description="프로젝트 ID")
|
||||
status: str = Field(..., description="처리 상태 (processing, completed, failed)")
|
||||
lyric_result: Optional[str] = Field(None, description="생성된 가사 또는 에러 메시지 (실패 시)")
|
||||
genre: Optional[str] = Field(
|
||||
None,
|
||||
description="확정된 음악 장르. 수동 선택 시 요청값, 자동 선택 시 GPT 추천값 (완료 전에는 None)",
|
||||
)
|
||||
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):
|
||||
"""가사 목록 아이템 스키마
|
||||
|
||||
|
|
|
|||
|
|
@ -4,24 +4,30 @@ Lyric Background Tasks
|
|||
가사 생성 관련 백그라운드 태스크를 정의합니다.
|
||||
"""
|
||||
|
||||
import traceback
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
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
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
# 로거 설정
|
||||
logger = get_logger("lyric")
|
||||
|
||||
|
||||
async def _update_lyric_status(
|
||||
task_id: str,
|
||||
status: str,
|
||||
result: str | None = None,
|
||||
lyric_id: int | None = None,
|
||||
genre: str | None = None,
|
||||
) -> bool:
|
||||
"""Lyric 테이블의 상태를 업데이트합니다.
|
||||
|
||||
|
|
@ -30,7 +36,6 @@ async def _update_lyric_status(
|
|||
status: 변경할 상태 ("processing", "completed", "failed")
|
||||
result: 가사 결과 또는 에러 메시지
|
||||
lyric_id: 특정 Lyric 레코드 ID (재생성 시 정확한 레코드 식별용)
|
||||
genre: 확정된 음악 장르 (자동 선택이었던 경우 GPT 추천값으로 갱신)
|
||||
|
||||
Returns:
|
||||
bool: 업데이트 성공 여부
|
||||
|
|
@ -56,8 +61,6 @@ async def _update_lyric_status(
|
|||
lyric.status = status
|
||||
if result is not None:
|
||||
lyric.lyric_result = result
|
||||
if genre is not None:
|
||||
lyric.genre = genre
|
||||
await session.commit()
|
||||
logger.info(f"[Lyric] Status updated - task_id: {task_id}, lyric_id: {lyric_id}, status: {status}")
|
||||
return True
|
||||
|
|
@ -101,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
|
||||
|
|
@ -114,18 +124,14 @@ async def generate_lyric_background(
|
|||
#result = await service.generate(prompt=prompt)
|
||||
result_response = await chatgpt.generate_structured_output(prompt, lyric_input_data)
|
||||
result = result_response.lyric
|
||||
confirmed_genre = result_response.recommended_genre
|
||||
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
||||
logger.info(
|
||||
f"[generate_lyric_background] Step 2 완료 - 응답 {len(result)}자, "
|
||||
f"genre: {confirmed_genre} ({step2_elapsed:.1f}ms)"
|
||||
)
|
||||
logger.info(f"[generate_lyric_background] Step 2 완료 - 응답 {len(result)}자 ({step2_elapsed:.1f}ms)")
|
||||
|
||||
# ========== Step 3: DB 상태 업데이트 ==========
|
||||
step3_start = time.perf_counter()
|
||||
logger.debug(f"[generate_lyric_background] Step 3: DB 상태 업데이트...")
|
||||
|
||||
await _update_lyric_status(task_id, "completed", result, lyric_id, genre=confirmed_genre)
|
||||
await _update_lyric_status(task_id, "completed", result, lyric_id)
|
||||
|
||||
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
||||
logger.debug(f"[generate_lyric_background] Step 3 완료 ({step3_elapsed:.1f}ms)")
|
||||
|
|
@ -156,3 +162,55 @@ async def generate_lyric_background(
|
|||
elapsed = (time.perf_counter() - task_start) * 1000
|
||||
logger.error(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)", exc_info=True)
|
||||
await _update_lyric_status(task_id, "failed", f"Error: {str(e)}", lyric_id)
|
||||
|
||||
async def generate_subtitle_background(
|
||||
orientation: str,
|
||||
task_id: str
|
||||
) -> None:
|
||||
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)
|
||||
|
||||
subtitle_generator = SubtitleContentsGenerator()
|
||||
|
||||
async with BackgroundSessionLocal() as session:
|
||||
project_result = await session.execute(
|
||||
select(Project)
|
||||
.where(Project.task_id == task_id)
|
||||
.order_by(Project.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
project = project_result.scalar_one_or_none()
|
||||
marketing_result = await session.execute(
|
||||
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
|
||||
)
|
||||
marketing_intelligence = marketing_result.scalar_one_or_none()
|
||||
|
||||
store_address = project.detail_region_info
|
||||
customer_name = project.store_name
|
||||
logger.info(f"[generate_subtitle_background] customer_name: {customer_name}, {store_address}")
|
||||
|
||||
generated_subtitles = await subtitle_generator.generate_subtitle_contents(
|
||||
marketing_intelligence = marketing_intelligence.intel_result,
|
||||
pitching_label_list = pitchings,
|
||||
customer_name = customer_name,
|
||||
detail_region_info = store_address,
|
||||
)
|
||||
pitching_output_list = generated_subtitles.pitching_results
|
||||
|
||||
subtitle_modifications = {pitching_output.pitching_tag : pitching_output.pitching_data for pitching_output in pitching_output_list}
|
||||
logger.info(f"[generate_subtitle_background] subtitle_modifications: {subtitle_modifications}")
|
||||
|
||||
async with BackgroundSessionLocal() as session:
|
||||
marketing_result = await session.execute(
|
||||
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
|
||||
)
|
||||
marketing_intelligence = marketing_result.scalar_one_or_none()
|
||||
marketing_intelligence.subtitle = subtitle_modifications
|
||||
await session.commit()
|
||||
logger.info(f"[generate_subtitle_background] task_id: {task_id} DONE")
|
||||
|
||||
|
||||
|
||||
return
|
||||
|
|
|
|||
|
|
@ -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)}"
|
||||
|
|
|
|||
|
|
@ -56,8 +56,6 @@ class SocialUploadResponse(BaseModel):
|
|||
platform: str = Field(..., description="플랫폼명")
|
||||
status: str = Field(..., description="업로드 상태")
|
||||
message: str = Field(..., description="응답 메시지")
|
||||
scheduled_at: Optional[datetime] = Field(None, description="예약 시간 (예약 업로드 충돌 시 반환)")
|
||||
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
|
|
@ -67,7 +65,6 @@ class SocialUploadResponse(BaseModel):
|
|||
"platform": "youtube",
|
||||
"status": "pending",
|
||||
"message": "업로드 요청이 접수되었습니다.",
|
||||
"scheduled_at": "2026-02-02T15:00:00",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -125,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="예약 게시 시간")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
|
@ -94,10 +94,8 @@ class SeoService:
|
|||
),
|
||||
"language": project.language,
|
||||
"target_keywords": hashtags,
|
||||
"industry": project.industry or "", # 크롤 시 분류해 Project에 저장한 업종 enum
|
||||
}
|
||||
|
||||
# 업종 분기는 프롬프트 내부 {industry}로 처리하므로 단일 프롬프트 사용
|
||||
chatgpt = ChatgptService(timeout=180)
|
||||
yt_seo_output = await chatgpt.generate_structured_output(yt_upload_prompt, yt_seo_input_data)
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from datetime import datetime
|
|||
from typing import Optional
|
||||
|
||||
from fastapi import BackgroundTasks, HTTPException, status
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import TIMEZONE
|
||||
|
|
@ -90,19 +90,12 @@ class SocialUploadService:
|
|||
)
|
||||
raise SocialAccountNotFoundError()
|
||||
|
||||
# 3. 중복 업로드 확인
|
||||
now_kst_naive = datetime.now(TIMEZONE).replace(tzinfo=None)
|
||||
|
||||
# 3-1. 진행 중인 업로드 확인 (즉시 pending 또는 uploading)
|
||||
# 3. 진행 중인 업로드 확인 (pending 또는 uploading 상태만)
|
||||
in_progress_result = await session.execute(
|
||||
select(SocialUpload).where(
|
||||
SocialUpload.video_id == body.video_id,
|
||||
SocialUpload.social_account_id == account.id,
|
||||
SocialUpload.status.in_([UploadStatus.PENDING.value, UploadStatus.UPLOADING.value]),
|
||||
or_(
|
||||
SocialUpload.scheduled_at.is_(None),
|
||||
SocialUpload.scheduled_at <= now_kst_naive,
|
||||
),
|
||||
)
|
||||
)
|
||||
in_progress_upload = in_progress_result.scalar_one_or_none()
|
||||
|
|
@ -119,32 +112,6 @@ class SocialUploadService:
|
|||
message="이미 업로드가 진행 중입니다.",
|
||||
)
|
||||
|
||||
# 3-2. 미래 예약 업로드 확인
|
||||
scheduled_result = await session.execute(
|
||||
select(SocialUpload).where(
|
||||
SocialUpload.video_id == body.video_id,
|
||||
SocialUpload.social_account_id == account.id,
|
||||
SocialUpload.status == UploadStatus.PENDING.value,
|
||||
SocialUpload.scheduled_at.isnot(None),
|
||||
SocialUpload.scheduled_at > now_kst_naive,
|
||||
)
|
||||
)
|
||||
scheduled_upload = scheduled_result.scalar_one_or_none()
|
||||
|
||||
if scheduled_upload:
|
||||
logger.info(
|
||||
f"[UPLOAD_SERVICE] 예약된 업로드 존재 - "
|
||||
f"upload_id: {scheduled_upload.id}, scheduled_at: {scheduled_upload.scheduled_at}"
|
||||
)
|
||||
return SocialUploadResponse(
|
||||
success=False,
|
||||
upload_id=scheduled_upload.id,
|
||||
platform=account.platform,
|
||||
status=scheduled_upload.status,
|
||||
message="이미 예약된 업로드가 있습니다.",
|
||||
scheduled_at=scheduled_upload.scheduled_at,
|
||||
)
|
||||
|
||||
# 4. 업로드 순번 계산
|
||||
max_seq_result = await session.execute(
|
||||
select(func.coalesce(func.max(SocialUpload.upload_seq), 0)).where(
|
||||
|
|
@ -186,6 +153,7 @@ class SocialUploadService:
|
|||
)
|
||||
|
||||
# 6. 즉시 업로드이면 백그라운드 태스크 등록
|
||||
now_kst_naive = datetime.now(TIMEZONE).replace(tzinfo=None)
|
||||
is_scheduled = body.scheduled_at and body.scheduled_at > now_kst_naive
|
||||
if not is_scheduled:
|
||||
background_tasks.add_task(process_social_upload, social_upload.id)
|
||||
|
|
@ -323,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,
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ class YouTubeUploader(BaseSocialUploader):
|
|||
body = {
|
||||
"snippet": {
|
||||
"title": metadata.title,
|
||||
"description": self._sanitize_description(metadata.description),
|
||||
"description": metadata.description or "",
|
||||
"tags": metadata.tags or [],
|
||||
"categoryId": self._get_category_id(metadata),
|
||||
},
|
||||
|
|
@ -380,12 +380,6 @@ class YouTubeUploader(BaseSocialUploader):
|
|||
)
|
||||
return False
|
||||
|
||||
def _sanitize_description(self, description: str | None) -> str:
|
||||
"""YouTube API가 거부하는 문자를 제거합니다. (<, > 포함 시 invalidVideoDescription 오류 발생)"""
|
||||
if not description:
|
||||
return ""
|
||||
return description.replace("<", "").replace(">", "")
|
||||
|
||||
def _convert_privacy_status(self, privacy_status: PrivacyStatus) -> str:
|
||||
"""
|
||||
PrivacyStatus를 YouTube API 형식으로 변환
|
||||
|
|
|
|||
|
|
@ -35,33 +35,6 @@ logger = get_logger("song")
|
|||
|
||||
router = APIRouter(prefix="/song", tags=["Song"])
|
||||
|
||||
TARGET_SONG_DURATION_SECONDS = 40.0
|
||||
|
||||
|
||||
def _select_clip_by_duration(
|
||||
clips_data: list[dict], target_seconds: float = TARGET_SONG_DURATION_SECONDS
|
||||
) -> dict:
|
||||
"""생성된 클립 중 목표 길이(target_seconds)에 가장 가까운 클립을 선택합니다.
|
||||
|
||||
길이 차이가 동일하면 먼저 등장한 클립(더 낮은 인덱스)을 선택합니다.
|
||||
duration 정보가 없는 클립은 비교 대상에서 제외되며, 모든 클립에 duration이 없으면
|
||||
첫 번째 클립을 반환합니다.
|
||||
"""
|
||||
best_clip = None
|
||||
best_diff = None
|
||||
|
||||
for clip in clips_data:
|
||||
duration = clip.get("duration")
|
||||
if duration is None:
|
||||
continue
|
||||
|
||||
diff = abs(duration - target_seconds)
|
||||
if best_diff is None or diff < best_diff:
|
||||
best_diff = diff
|
||||
best_clip = clip
|
||||
|
||||
return best_clip if best_clip is not None else clips_data[0]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/generate/{task_id}",
|
||||
|
|
@ -99,7 +72,7 @@ curl -X POST "http://localhost:8000/song/generate/019123ab-cdef-7890-abcd-ef1234
|
|||
```
|
||||
|
||||
## 참고
|
||||
- 생성되는 노래는 약 40초 내외 길이입니다.
|
||||
- 생성되는 노래는 약 1분 이내 길이입니다.
|
||||
- song_id를 사용하여 /status/{song_id} 엔드포인트에서 생성 상태를 확인할 수 있습니다.
|
||||
- Song 테이블에 데이터가 저장되며, project_id와 lyric_id가 자동으로 연결됩니다.
|
||||
""",
|
||||
|
|
@ -130,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}"
|
||||
|
|
@ -212,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"
|
||||
|
|
@ -277,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()
|
||||
|
|
@ -440,14 +395,12 @@ async def get_song_status(
|
|||
clips_data = response_data.get("sunoData") or []
|
||||
|
||||
if clips_data:
|
||||
# 생성된 클립(보통 2개) 중 목표 길이(1분)에 가장 가까운 클립 선택 (동일하면 첫 번째)
|
||||
first_clip = _select_clip_by_duration(clips_data)
|
||||
# 첫 번째 클립(clips[0])의 audioUrl과 duration 사용
|
||||
first_clip = clips_data[0]
|
||||
audio_url = first_clip.get("audioUrl")
|
||||
clip_duration = first_clip.get("duration")
|
||||
logger.debug(
|
||||
f"[get_song_status] Selected clip by duration - id: {first_clip.get('id')}, "
|
||||
f"audio_url: {audio_url}, duration: {clip_duration}, "
|
||||
f"candidates: {[(c.get('id'), c.get('duration')) for c in clips_data]}"
|
||||
f"[get_song_status] Using first clip - id: {first_clip.get('id')}, audio_url: {audio_url}, duration: {clip_duration}"
|
||||
)
|
||||
|
||||
if audio_url:
|
||||
|
|
@ -483,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)
|
||||
|
|
@ -492,74 +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
|
||||
)
|
||||
timestamped_lyrics = suno_service.align_lyrics(
|
||||
word_data, sentences
|
||||
)
|
||||
logger.debug(
|
||||
f"[get_song_status] sentences from lyric - "
|
||||
f"sentences: {sentences}"
|
||||
)
|
||||
|
||||
# None이면 Suno 타임스탬프가 아직 미준비 상태.
|
||||
# processing을 반환해 클라이언트가 폴링을 계속하도록 한다.
|
||||
if word_data is None:
|
||||
logger.info(
|
||||
f"[get_song_status] 타임스탬프 미준비 - 폴링 유지, "
|
||||
f"suno_task_id: {suno_task_id}, suno_audio_id: {suno_audio_id}"
|
||||
)
|
||||
return PollingSongResponse(
|
||||
success=True,
|
||||
status="processing",
|
||||
message="타임스탬프 생성 중입니다.",
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"[get_song_status] word_data from get_lyric_timestamp - "
|
||||
f"suno_task_id: {suno_task_id}, suno_audio_id: {suno_audio_id}, "
|
||||
f"word_data: {word_data}"
|
||||
)
|
||||
lyric_line_list = gt_lyric.split("\n")
|
||||
sentences = [
|
||||
lyric_line.strip(",. ")
|
||||
for lyric_line in lyric_line_list
|
||||
if lyric_line and lyric_line != "---"
|
||||
]
|
||||
logger.debug(
|
||||
f"[get_song_status] sentences from lyric - "
|
||||
f"sentences: {sentences}"
|
||||
)
|
||||
|
||||
timestamped_lyrics = suno_service.align_lyrics(
|
||||
word_data, sentences
|
||||
)
|
||||
|
||||
for order_idx, timestamped_lyric in enumerate(
|
||||
timestamped_lyrics
|
||||
# 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"
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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에서 라우터가 등록됨)
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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("
|
||||
|
|
|
|||
|
|
@ -160,22 +160,6 @@ class LoginResponse(BaseModel):
|
|||
}
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# 크레딧 스키마
|
||||
# =============================================================================
|
||||
class CreditResponse(BaseModel):
|
||||
"""잔여 크레딧 응답"""
|
||||
|
||||
credits: int = Field(..., description="영상 생성 크레딧")
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"example": {
|
||||
"credits": 3
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 내부 사용 스키마 (카카오 API 응답 파싱)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
import logging
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.credit.exceptions import InsufficientCreditError
|
||||
from app.credit.models import CreditTransactionType
|
||||
from app.credit.services.credit_service import deduct_credit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def consume_credit(user_uuid: str, session: AsyncSession, *, reason: str = "video generation") -> bool:
|
||||
"""크레딧 1 차감. 기존 호출처와 시그니처 호환 유지."""
|
||||
try:
|
||||
await deduct_credit(
|
||||
session=session,
|
||||
user_uuid=user_uuid,
|
||||
amount=1,
|
||||
type=CreditTransactionType.CONSUME,
|
||||
reason=reason,
|
||||
)
|
||||
return True
|
||||
except InsufficientCreditError:
|
||||
return False
|
||||
|
|
@ -116,28 +116,6 @@ def decode_token(token: str) -> Optional[dict]:
|
|||
return None
|
||||
|
||||
|
||||
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 해시값 생성
|
||||
|
|
|
|||
|
|
@ -1,112 +0,0 @@
|
|||
# 특별시/광역시/세종시: 하위 행정구역이 구(gu)이므로 별도 처리
|
||||
METRO_SIDOS: dict[str, str] = {
|
||||
"서울특별시": "서울시",
|
||||
"부산광역시": "부산시",
|
||||
"대구광역시": "대구시",
|
||||
"인천광역시": "인천시",
|
||||
"광주광역시": "광주시",
|
||||
"대전광역시": "대전시",
|
||||
"울산광역시": "울산시",
|
||||
"세종특별시": "세종시",
|
||||
}
|
||||
|
||||
SIDO_CITIES: dict[str, list[str]] = {
|
||||
"서울특별시": ["서울시"],
|
||||
"부산광역시": ["부산시"],
|
||||
"대구광역시": ["대구시"],
|
||||
"인천광역시": ["인천시"],
|
||||
"광주광역시": ["광주시"],
|
||||
"대전광역시": ["대전시"],
|
||||
"울산광역시": ["울산시"],
|
||||
"세종특별시": ["세종시"],
|
||||
"경기도": [
|
||||
"수원시", "성남시", "고양시", "용인시", "부천시", "안산시", "안양시", "남양주시",
|
||||
"화성시", "평택시", "의정부시", "시흥시", "파주시", "김포시", "광주시", "광명시",
|
||||
"군포시", "하남시", "오산시", "이천시", "안성시", "구리시", "양주시", "포천시",
|
||||
"여주시", "동두천시", "과천시", "가평군", "양평군", "연천군",
|
||||
],
|
||||
"강원도": [
|
||||
"춘천시", "원주시", "강릉시", "동해시", "태백시", "속초시", "삼척시",
|
||||
"홍천군", "횡성군", "영월군", "평창군", "정선군", "철원군", "화천군",
|
||||
"양구군", "인제군", "고성군", "양양군",
|
||||
],
|
||||
"충청북도": ["청주시", "충주시", "제천시", "보은군", "옥천군", "영동군", "증평군", "진천군", "괴산군", "음성군", "단양군"],
|
||||
"충청남도": ["천안시", "공주시", "보령시", "아산시", "서산시", "논산시", "계룡시", "당진시", "금산군", "부여군", "서천군", "청양군", "홍성군", "예산군", "태안군"],
|
||||
"전라북도": ["전주시", "군산시", "익산시", "정읍시", "남원시", "김제시", "완주군", "진안군", "무주군", "장수군", "임실군", "순창군", "고창군", "부안군"],
|
||||
"전라남도": ["목포시", "여수시", "순천시", "나주시", "광양시", "담양군", "곡성군", "구례군", "고흥군", "보성군", "화순군", "장흥군", "강진군", "해남군", "영암군", "무안군", "함평군", "영광군", "장성군", "완도군", "진도군", "신안군"],
|
||||
"경상북도": ["포항시", "경주시", "김천시", "안동시", "구미시", "영주시", "영천시", "상주시", "문경시", "경산시", "의성군", "청송군", "영양군", "영덕군", "청도군", "고령군", "성주군", "칠곡군", "예천군", "봉화군", "울진군", "울릉군"],
|
||||
"경상남도": ["창원시", "진주시", "통영시", "사천시", "김해시", "밀양시", "거제시", "양산시", "의령군", "함안군", "창녕군", "고성군", "남해군", "하동군", "산청군", "함양군", "거창군", "합천군"],
|
||||
"제주도": ["제주시", "서귀포시"],
|
||||
}
|
||||
|
||||
# 도 약칭 → 정식 명칭
|
||||
SIDO_NAME_ALIASES: dict[str, str] = {
|
||||
"서울": "서울특별시", "부산": "부산광역시", "대구": "대구광역시",
|
||||
"인천": "인천광역시", "광주": "광주광역시", "대전": "대전광역시",
|
||||
"울산": "울산광역시", "세종": "세종특별시",
|
||||
"경기": "경기도", "강원": "강원도",
|
||||
"충북": "충청북도", "충남": "충청남도",
|
||||
"전북": "전라북도", "전남": "전라남도",
|
||||
"경북": "경상북도", "경남": "경상남도",
|
||||
"제주": "제주도",
|
||||
}
|
||||
|
||||
# 도 정식 명칭 → 약칭 + 이형 목록 (필터 검색용)
|
||||
SIDO_SEARCH_ALIASES: dict[str, list[str]] = {
|
||||
"경기도": ["경기도", "경기"],
|
||||
"강원도": ["강원도", "강원", "강원특별자치도"],
|
||||
"충청북도": ["충청북도", "충북", "충북특별자치도"],
|
||||
"충청남도": ["충청남도", "충남"],
|
||||
"전라북도": ["전라북도", "전북", "전북특별자치도"],
|
||||
"전라남도": ["전라남도", "전남"],
|
||||
"경상북도": ["경상북도", "경북"],
|
||||
"경상남도": ["경상남도", "경남"],
|
||||
"제주도": ["제주도", "제주", "제주특별자치도"],
|
||||
}
|
||||
|
||||
|
||||
def extract_sigungu(address: str) -> str:
|
||||
"""주소 문자열에서 시/군 이름을 추출합니다."""
|
||||
tokens = address.split()
|
||||
if not tokens:
|
||||
return ""
|
||||
|
||||
# 첫 토큰으로 도 판별 (정식명 or 약칭)
|
||||
sido = SIDO_NAME_ALIASES.get(tokens[0], tokens[0])
|
||||
|
||||
# 특별시/광역시/세종시: 구(district) 레벨이 하위이므로 시 이름을 바로 반환
|
||||
metro_name = METRO_SIDOS.get(sido)
|
||||
if metro_name:
|
||||
return metro_name
|
||||
|
||||
cities = SIDO_CITIES.get(sido)
|
||||
if cities and len(tokens) >= 2:
|
||||
second = tokens[1]
|
||||
if second in cities:
|
||||
return second
|
||||
# DB에 없는 신설 행정구역 대비 — 시/군 접미사 폴백
|
||||
if second.endswith(("시", "군")):
|
||||
return second
|
||||
|
||||
# 도 판별 실패 시 전체 도에서 토큰 완전 일치 검색
|
||||
token_set = set(tokens)
|
||||
for city_list in SIDO_CITIES.values():
|
||||
for city in city_list:
|
||||
if city in token_set:
|
||||
return city
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def extract_region_from_address(
|
||||
road_address: str | None,
|
||||
jibun_address: str | None = None,
|
||||
) -> str:
|
||||
"""도로명 주소 우선으로 시/군을 추출합니다. 실패 시 지번 주소로 재시도합니다."""
|
||||
if road_address:
|
||||
result = extract_sigungu(road_address)
|
||||
if result:
|
||||
return result
|
||||
if jibun_address:
|
||||
return extract_sigungu(jibun_address)
|
||||
return ""
|
||||
|
|
@ -1,50 +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, industry: str = "") -> list[str]: #tag_list
|
||||
chatgpt = ChatgptService(model_type="gpt")
|
||||
image_input_data = {
|
||||
"img_url" : image_url,
|
||||
"industry" : industry,
|
||||
"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], industry: str = "") -> list[dict]: #tag_list
|
||||
chatgpt = ChatgptService(model_type="gpt")
|
||||
image_input_data_list = [{
|
||||
"img_url" : image_url,
|
||||
"industry" : industry,
|
||||
"space_type" : list(SpaceType),
|
||||
"subject" : list(Subject),
|
||||
"camera" : list(Camera),
|
||||
"motion_recommended" : list(MotionRecommended)
|
||||
}for image_url in image_url_list]
|
||||
|
||||
image_result_tasks = [chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_input_data['img_url'], False, silent = True) for image_input_data in image_input_data_list]
|
||||
image_result_list: list[BaseModel | BaseException] = await asyncio.gather(*image_result_tasks, return_exceptions=True)
|
||||
MAX_RETRY = 2
|
||||
for _ in range(MAX_RETRY):
|
||||
failed_idx = [i for i, r in enumerate(image_result_list) if isinstance(r, Exception)]
|
||||
# print("Failed", failed_idx)
|
||||
if not failed_idx:
|
||||
break
|
||||
retried = await asyncio.gather(
|
||||
*[chatgpt.generate_structured_output(image_autotag_prompt, image_input_data_list[i], image_input_data_list[i]['img_url'], False, silent=True) for i in failed_idx],
|
||||
return_exceptions=True
|
||||
)
|
||||
for i, result in zip(failed_idx, retried):
|
||||
image_result_list[i] = result
|
||||
|
||||
# print("Failed", failed_idx)
|
||||
return image_result_list
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
"""
|
||||
BGM 모드용 더미 가사 템플릿
|
||||
|
||||
instrumental=True 호출 시 Suno가 가사 길이/구조를 참고해 60초짜리 BGM을 생성하도록
|
||||
placeholder 가사를 제공합니다. 실제 보컬은 생성되지 않습니다.
|
||||
|
||||
장르 순서: K-Pop, Pop, R&B, Hip-Hop, Ballad, EDM, Rock, Jazz
|
||||
섹션 태그 없이 한국어 10줄로 구성.
|
||||
"""
|
||||
|
||||
_BGM_DUMMY_LYRICS: dict[str, str] = {
|
||||
"K-Pop": (
|
||||
"반짝이는 눈빛으로 날 바라봐\n"
|
||||
"심장이 터질 것 같은 이 느낌\n"
|
||||
"너만 보면 세상이 달라 보여\n"
|
||||
"오늘도 설레임이 멈추질 않아\n"
|
||||
"같이 걸어가는 이 길 위에서\n"
|
||||
"우리 둘만의 노래가 흘러\n"
|
||||
"빛나는 순간들을 모아모아\n"
|
||||
"영원히 기억할 우리의 이야기\n"
|
||||
"지금 이 순간 네 곁에 있을게\n"
|
||||
"함께라면 뭐든 할 수 있어\n"
|
||||
),
|
||||
"Pop": (
|
||||
"햇살 가득한 아침이 시작되고\n"
|
||||
"따스한 바람이 살며시 불어와\n"
|
||||
"거리마다 웃음꽃이 피어나고\n"
|
||||
"오늘도 설레는 하루가 열려\n"
|
||||
"가볍게 발걸음을 내딛으며\n"
|
||||
"환한 빛 속으로 걸어가는 길\n"
|
||||
"두근두근 설레는 이 순간을\n"
|
||||
"온 마음 가득 담아 느껴봐\n"
|
||||
"오늘 하루도 빛나는 하루야\n"
|
||||
"환한 미소로 하루를 마무리해\n"
|
||||
),
|
||||
"R&B": (
|
||||
"부드럽게 흐르는 이 그루브에\n"
|
||||
"몸이 저절로 리듬을 타기 시작해\n"
|
||||
"네 목소리가 귓가에 맴돌고\n"
|
||||
"이 감각이 온몸을 감싸줘\n"
|
||||
"달콤한 밤이 깊어질수록\n"
|
||||
"너와 나 사이 거리가 좁혀져\n"
|
||||
"촛불처럼 은은하게 타오르는\n"
|
||||
"이 감정을 숨길 수가 없어\n"
|
||||
"부드럽게 내 손을 잡아줘\n"
|
||||
"오늘 밤 우리만의 노래를 불러\n"
|
||||
),
|
||||
"Hip-Hop": (
|
||||
"내 방식대로 살아가는 이 길\n"
|
||||
"누가 뭐래도 내 페이스로 가\n"
|
||||
"매일 아침 눈을 뜨면 새로운 무대\n"
|
||||
"두려움 없이 앞으로 나아가\n"
|
||||
"땀과 노력으로 쌓아온 오늘\n"
|
||||
"포기란 없어 끝까지 밀어붙여\n"
|
||||
"내 이름을 기억해 두라고\n"
|
||||
"이 무대 위에 내 발자국 남겨\n"
|
||||
"하나둘 쌓여가는 내 이야기\n"
|
||||
"진짜배기는 지금부터 시작이야\n"
|
||||
),
|
||||
"Ballad": (
|
||||
"저녁 노을이 물드는 창가에서\n"
|
||||
"조용히 흘러가는 시간 속에\n"
|
||||
"잔잔한 바람이 마음을 적시고\n"
|
||||
"기억 속 풍경이 스쳐 지나가\n"
|
||||
"부드럽게 감기는 이 느낌처럼\n"
|
||||
"천천히 숨을 고르며 머물러\n"
|
||||
"마음 깊은 곳에 스며드는 온기\n"
|
||||
"조용히 눈을 감고 느껴봐\n"
|
||||
"이 순간 여기 머무는 것만으로도 충분해\n"
|
||||
"고요한 밤이 나를 감싸 안아줘\n"
|
||||
),
|
||||
"EDM": (
|
||||
"밤거리에 불빛이 타오르고\n"
|
||||
"심장이 두근두근 뛰기 시작해\n"
|
||||
"온몸에 퍼지는 뜨거운 열기\n"
|
||||
"멈출 수 없는 이 흐름 속으로\n"
|
||||
"있는 힘껏 달려가는 이 순간\n"
|
||||
"모든 걸 내려놓고 느껴봐\n"
|
||||
"짜릿하게 타오르는 지금 이 밤\n"
|
||||
"온 세상이 하나로 움직여\n"
|
||||
"끝까지 불태워 이 에너지를\n"
|
||||
"새벽빛이 밝아올 때까지 달려\n"
|
||||
),
|
||||
"Rock": (
|
||||
"굉음을 내며 울려 퍼지는 기타\n"
|
||||
"온몸을 뒤흔드는 강렬한 비트\n"
|
||||
"규칙 따위는 집어치우고\n"
|
||||
"있는 그대로 외쳐봐\n"
|
||||
"불꽃처럼 타오르는 이 열정\n"
|
||||
"아무도 막을 수 없어 지금\n"
|
||||
"거침없이 달려가는 이 무대\n"
|
||||
"목청껏 소리 질러 자유를\n"
|
||||
"부서질 듯 뜨겁게 흔들어\n"
|
||||
"이 밤이 끝날 때까지 록이야\n"
|
||||
),
|
||||
"Jazz": (
|
||||
"커피 향이 피어오르는 오후\n"
|
||||
"느긋하게 흐르는 재즈 선율에\n"
|
||||
"발끝이 리듬을 타기 시작해\n"
|
||||
"달콤한 여유가 가득 차오르고\n"
|
||||
"창밖엔 황금빛 도시가 반짝여\n"
|
||||
"잔을 들어 이 순간을 건배해\n"
|
||||
"스윙하는 박자에 몸을 맡기고\n"
|
||||
"부드럽게 흘러가는 이 밤을\n"
|
||||
"기억 속에 새겨두고 싶어\n"
|
||||
"재즈처럼 자유롭게 살고 싶어\n"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
_GENRE_ALIAS: dict[str, str] = {
|
||||
"kpop": "K-Pop",
|
||||
"k-pop": "K-Pop",
|
||||
"k_pop": "K-Pop",
|
||||
"pop": "Pop",
|
||||
"rnb": "R&B",
|
||||
"r&b": "R&B",
|
||||
"r_b": "R&B",
|
||||
"hiphop": "Hip-Hop",
|
||||
"hip-hop": "Hip-Hop",
|
||||
"hip_hop": "Hip-Hop",
|
||||
"ballad": "Ballad",
|
||||
"edm": "EDM",
|
||||
"rock": "Rock",
|
||||
"jazz": "Jazz",
|
||||
}
|
||||
|
||||
|
||||
def normalize_genre(genre: str) -> str:
|
||||
return _GENRE_ALIAS.get(genre.lower(), genre)
|
||||
|
||||
|
||||
def get_bgm_lyrics(genre: str) -> str:
|
||||
"""장르에 맞는 BGM 더미 가사를 반환합니다.
|
||||
|
||||
Args:
|
||||
genre: 장르명 (K-Pop, Pop, R&B, Hip-Hop, Ballad, EDM, Rock, Jazz)
|
||||
대소문자 및 구분자(-, _) 무관하게 처리됩니다.
|
||||
|
||||
Returns:
|
||||
선택된 장르의 가사 텍스트
|
||||
"""
|
||||
return _BGM_DUMMY_LYRICS[normalize_genre(genre)]
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import json
|
||||
import re
|
||||
from pydantic import BaseModel
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from config import apikey_settings, recovery_settings
|
||||
from app.utils.prompts.prompts import Prompt
|
||||
|
||||
|
||||
# 로거 설정
|
||||
logger = get_logger("chatgpt")
|
||||
|
||||
|
||||
class ChatGPTResponseError(Exception):
|
||||
"""ChatGPT API 응답 에러"""
|
||||
def __init__(self, status: str, error_code: str = None, error_message: str = None):
|
||||
self.status = status
|
||||
self.error_code = error_code
|
||||
self.error_message = error_message
|
||||
super().__init__(f"ChatGPT response failed: status={status}, code={error_code}, message={error_message}")
|
||||
|
||||
|
||||
class ChatgptService:
|
||||
"""ChatGPT API 서비스 클래스
|
||||
"""
|
||||
|
||||
def __init__(self, timeout: float = None):
|
||||
self.timeout = timeout or recovery_settings.CHATGPT_TIMEOUT
|
||||
self.max_retries = recovery_settings.CHATGPT_MAX_RETRIES
|
||||
self.client = AsyncOpenAI(
|
||||
api_key=apikey_settings.CHATGPT_API_KEY,
|
||||
timeout=self.timeout
|
||||
)
|
||||
|
||||
async def _call_pydantic_output(self, prompt : str, output_format : BaseModel, model : str) -> BaseModel: # 입력 output_format의 경우 Pydantic BaseModel Class를 상속한 Class 자체임에 유의할 것
|
||||
content = [{"type": "input_text", "text": prompt}]
|
||||
last_error = None
|
||||
for attempt in range(self.max_retries + 1):
|
||||
response = await self.client.responses.parse(
|
||||
model=model,
|
||||
input=[{"role": "user", "content": content}],
|
||||
text_format=output_format
|
||||
)
|
||||
# Response 디버그 로깅
|
||||
logger.debug(f"[ChatgptService] attempt: {attempt}")
|
||||
logger.debug(f"[ChatgptService] Response ID: {response.id}")
|
||||
logger.debug(f"[ChatgptService] Response status: {response.status}")
|
||||
logger.debug(f"[ChatgptService] Response model: {response.model}")
|
||||
|
||||
# status 확인: completed, failed, incomplete, cancelled, queued, in_progress
|
||||
if response.status == "completed":
|
||||
logger.debug(f"[ChatgptService] Response output_text: {response.output_text[:200]}..." if len(response.output_text) > 200 else f"[ChatgptService] Response output_text: {response.output_text}")
|
||||
structured_output = response.output_parsed
|
||||
return structured_output #.model_dump() or {}
|
||||
|
||||
# 에러 상태 처리
|
||||
if response.status == "failed":
|
||||
error_code = getattr(response.error, 'code', None) if response.error else None
|
||||
error_message = getattr(response.error, 'message', None) if response.error else None
|
||||
logger.warning(f"[ChatgptService] Response failed (attempt {attempt + 1}/{self.max_retries + 1}): code={error_code}, message={error_message}")
|
||||
last_error = ChatGPTResponseError(response.status, error_code, error_message)
|
||||
|
||||
elif response.status == "incomplete":
|
||||
reason = getattr(response.incomplete_details, 'reason', None) if response.incomplete_details else None
|
||||
logger.warning(f"[ChatgptService] Response incomplete (attempt {attempt + 1}/{self.max_retries + 1}): reason={reason}")
|
||||
last_error = ChatGPTResponseError(response.status, reason, f"Response incomplete: {reason}")
|
||||
|
||||
else:
|
||||
# cancelled, queued, in_progress 등 예상치 못한 상태
|
||||
logger.warning(f"[ChatgptService] Unexpected response status (attempt {attempt + 1}/{self.max_retries + 1}): {response.status}")
|
||||
last_error = ChatGPTResponseError(response.status, None, f"Unexpected status: {response.status}")
|
||||
|
||||
# 마지막 시도가 아니면 재시도
|
||||
if attempt < self.max_retries:
|
||||
logger.info(f"[ChatgptService] Retrying request...")
|
||||
|
||||
# 모든 재시도 실패
|
||||
logger.error(f"[ChatgptService] All retries exhausted. Last error: {last_error}")
|
||||
raise last_error
|
||||
|
||||
async def generate_structured_output(
|
||||
self,
|
||||
prompt : Prompt,
|
||||
input_data : dict,
|
||||
) -> BaseModel:
|
||||
prompt_text = prompt.build_prompt(input_data)
|
||||
|
||||
logger.debug(f"[ChatgptService] Generated Prompt (length: {len(prompt_text)})")
|
||||
logger.info(f"[ChatgptService] Starting GPT request with structured output with model: {prompt.prompt_model}")
|
||||
|
||||
# GPT API 호출
|
||||
#response = await self._call_structured_output_with_response_gpt_api(prompt_text, prompt.prompt_output, prompt.prompt_model)
|
||||
response = await self._call_pydantic_output(prompt_text, prompt.prompt_output_class, prompt.prompt_model)
|
||||
return response
|
||||
|
|
@ -19,16 +19,12 @@ Note:
|
|||
|
||||
import os
|
||||
import 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 문자열을 생성합니다.
|
||||
|
||||
|
|
|
|||
|
|
@ -30,17 +30,12 @@ response = await creatomate.make_creatomate_call(template_id, modifications)
|
|||
"""
|
||||
|
||||
import copy
|
||||
import random
|
||||
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
|
||||
|
||||
# 로거 설정
|
||||
|
|
@ -225,120 +220,27 @@ autotext_template_h_1 = {
|
|||
"stroke_color": "#333333",
|
||||
"stroke_width": "0.2 vmin"
|
||||
}
|
||||
DVST0001 = "fe11aeab-ff29-4bc8-9f75-c695c7e243e6"
|
||||
DVRT0001 = "3c4b74c4-82d7-4742-9b05-4b999fef4cbd"
|
||||
DVCF0001 = "ebb532ec-e49a-4a7e-b0e2-a8132f1bf1f7"
|
||||
DVAT0001 = "14d4fa22-49b8-49ca-a233-ac0556436f60"
|
||||
DVSL0001 = "20c526a7-b184-46f3-9138-9dfaff2fa342"
|
||||
DVCL0001 = "b57dca80-167d-430a-8905-30f4674ff72b"
|
||||
DVFT0001 = "03b6d521-4864-4e69-8e6c-1deab9f0ce6e"
|
||||
DVAC0001 = "4db8f5a3-4fd1-42cf-86b3-d997f0713d99"
|
||||
|
||||
DVST0001 = "75161273-0422-4771-adeb-816bd7263fb0"
|
||||
DVST0002 = "c68cf750-bc40-485a-a2c5-3f9fe301e386"
|
||||
DVST0003 = "e1fb5b00-1f02-4f63-99fa-7524b433ba47"
|
||||
DHST0001 = "660be601-080a-43ea-bf0f-adcf4596fa98"
|
||||
|
||||
HST_LIST = [DHST0001]
|
||||
VST_LIST = [DVST0001,DVRT0001,DVCF0001,DVAT0001,DVSL0001,DVCL0001,DVFT0001,DVAC0001]
|
||||
|
||||
# industry → 세로형 템플릿 매핑 (신규 세로형 템플릿 추가 시 여기에만 등록)
|
||||
VERTICAL_INDUSTRY_TEMPLATE_MAP: dict[str, str] = {
|
||||
"stay": DVST0001,
|
||||
"restaurant": DVRT0001,
|
||||
"cafe": DVCF0001,
|
||||
"attraction": DVAT0001,
|
||||
"salon": DVSL0001,
|
||||
"clinic": DVCL0001,
|
||||
"fitness": DVFT0001,
|
||||
"academy": DVAC0001,
|
||||
}
|
||||
|
||||
# 매핑에 없는 업종(general)의 폴백 템플릿
|
||||
DEFAULT_VERTICAL_TEMPLATE = DVST0001
|
||||
|
||||
# 템플릿 슬롯명의 모션 동의어 → MotionRecommended 표준값 정규화 맵.
|
||||
# 동의어를 enum에 추가하면 LLM 태그 후보가 갈라져 매칭 정확도가 떨어지므로,
|
||||
# enum은 표준 어휘만 유지하고 슬롯명 파싱 경계에서 흡수한다.
|
||||
MOTION_TOKEN_NORMALIZATION: dict[str, MotionRecommended] = {
|
||||
"zoom_in": MotionRecommended.slow_zoom_in,
|
||||
"zoom_out": MotionRecommended.slow_zoom_out,
|
||||
"pan_left": MotionRecommended.slow_pan,
|
||||
"pan_right": MotionRecommended.slow_pan,
|
||||
"tilt_up": MotionRecommended.slow_pan,
|
||||
"tilt_down": MotionRecommended.slow_pan,
|
||||
"push_diag": MotionRecommended.dolly,
|
||||
"montage": MotionRecommended.static,
|
||||
}
|
||||
DHST0002 = "3f194cc7-464e-4581-9db2-179d42d3e40f"
|
||||
DHST0003 = "f45df555-2956-4a13-9004-ead047070b3d"
|
||||
HST_LIST = [DHST0001,DHST0002,DHST0003]
|
||||
VST_LIST = [DVST0001,DVST0002,DVST0003]
|
||||
|
||||
SCENE_TRACK = 1
|
||||
AUDIO_TRACK = 2
|
||||
SUBTITLE_TRACK = 3
|
||||
KEYWORD_TRACK = 4
|
||||
|
||||
# 썸네일 컴포지션의 이미지 슬롯명 접미사 (8개 업종 템플릿 전수 확인, 예:
|
||||
# "exterior_front-architecture_detail-wide_angle-slow_zoom_in-intro-core-9999").
|
||||
# 일반 씬 이미지는 "-0001"~"-0030"류 4자리 순번을 쓰는 반면 썸네일만 "-9999"라
|
||||
# 슬롯명 접미사만으로 안전하게 식별 가능(가로 템플릿처럼 썸네일 컴포지션이
|
||||
# 없는 템플릿도 있음).
|
||||
THUMBNAIL_SLOT_MARKER = "-9999"
|
||||
|
||||
|
||||
def is_fixed_slot_name(name: str) -> bool:
|
||||
"""이름이 '-fixed'로 끝나는 요소인지 판별합니다.
|
||||
|
||||
템플릿 명명 규칙상 마지막 토큰이 'fixed'인 요소(예: 'brand-cta-bi_logo-
|
||||
factual-fixed', 'brand-cta-contact_phone-factual-fixed')는 회사 연락처
|
||||
문구·로고 등 매 영상마다 절대 바뀌면 안 되는 고정 콘텐츠다. 이런 요소는
|
||||
이미지 배정/자막 생성 대상에서 제외하고 템플릿에 이미 설정된 값을 그대로
|
||||
보존해야 한다.
|
||||
"""
|
||||
if not name:
|
||||
return False
|
||||
return name.rsplit("-", 1)[-1] == "fixed"
|
||||
|
||||
# 언어별 대체 폰트 (Google Fonts — Creatomate 기본 지원).
|
||||
# 템플릿 기본 폰트(GyeonggiTitleOTF/Pretendard)는 한글·라틴 전용이라 여기 있는
|
||||
# 언어는 렌더 직전 전체 텍스트 폰트를 교체한다. 매핑에 없는 언어(Korean,
|
||||
# English 등)는 템플릿 원본 폰트를 유지한다. 중국어는 간체(SC) 기준.
|
||||
LANGUAGE_FONT_MAP = {
|
||||
"Japanese": "Noto Sans JP",
|
||||
"Chinese": "Noto Sans SC",
|
||||
"Thai": "Noto Sans Thai",
|
||||
"Vietnamese": "Noto Sans",
|
||||
}
|
||||
|
||||
def select_template(
|
||||
orientation: OrientationType,
|
||||
industry: str | None = None,
|
||||
project_id: int | None = None,
|
||||
) -> str:
|
||||
"""orientation과 industry에 따라 Creatomate 템플릿 ID를 선택합니다.
|
||||
|
||||
Args:
|
||||
orientation: 영상 방향 ("horizontal" 또는 "vertical")
|
||||
industry: 업종 분류 (stay|restaurant|cafe|attraction 등).
|
||||
세로형에서 매핑에 있으면 전용 템플릿을 반환합니다.
|
||||
project_id: 미매핑 업종(general 등)의 세로형 분배용 키.
|
||||
project_id % len(VST_LIST)로 결정적으로 선택하므로, 같은
|
||||
프로젝트는 몇 번 호출해도 동일 템플릿을 받아 사전/렌더 단계가
|
||||
일치한다. 없으면 DEFAULT_VERTICAL_TEMPLATE로 폴백한다.
|
||||
|
||||
Returns:
|
||||
선택된 템플릿 ID
|
||||
"""
|
||||
def select_template(orientation:OrientationType):
|
||||
if orientation == "horizontal":
|
||||
template_id = DHST0001
|
||||
return DHST0001
|
||||
elif orientation == "vertical":
|
||||
mapped = VERTICAL_INDUSTRY_TEMPLATE_MAP.get(industry)
|
||||
if mapped is not None:
|
||||
template_id = mapped
|
||||
elif project_id is not None:
|
||||
# 미매핑 업종: project_id 나머지로 VST_LIST 결정적 분배 (공유 상태 없음)
|
||||
template_id = VST_LIST[project_id % len(VST_LIST)]
|
||||
else:
|
||||
template_id = DEFAULT_VERTICAL_TEMPLATE
|
||||
return DVST0001
|
||||
else:
|
||||
raise
|
||||
logger.info(f"[select_template] orientation={orientation}, industry={industry}, project_id={project_id}, template_id={template_id}")
|
||||
return template_id
|
||||
|
||||
async def get_shared_client() -> httpx.AsyncClient:
|
||||
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
|
||||
|
|
@ -386,25 +288,21 @@ class CreatomateService:
|
|||
def __init__(
|
||||
self,
|
||||
api_key: str | None = None,
|
||||
orientation: OrientationType = "vertical",
|
||||
industry: str | None = None,
|
||||
project_id: int | None = None,
|
||||
orientation: OrientationType = "vertical"
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
api_key: Creatomate API 키 (Bearer token으로 사용)
|
||||
None일 경우 config에서 자동으로 가져옴
|
||||
orientation: 영상 방향 ("horizontal" 또는 "vertical", 기본값: "vertical")
|
||||
industry: 업종 분류 (stay|restaurant|cafe|attraction 등). 세로형 템플릿 선택에 사용되며,
|
||||
매핑에 있으면 전용 템플릿을 사용
|
||||
project_id: 미매핑 업종(general 등)의 세로형 분배용 키. 매핑에 없을 때
|
||||
project_id % len(VST_LIST)로 결정적 선택
|
||||
target_duration: 목표 영상 길이 (초)
|
||||
None일 경우 orientation에 해당하는 기본값 사용
|
||||
"""
|
||||
self.api_key = api_key or apikey_settings.CREATOMATE_API_KEY
|
||||
self.orientation = orientation
|
||||
|
||||
# orientation·industry에 따른 템플릿 설정 가져오기
|
||||
self.template_id = select_template(orientation, industry=industry, project_id=project_id)
|
||||
# orientation에 따른 템플릿 설정 가져오기
|
||||
self.template_id = select_template(orientation)
|
||||
self.headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
|
|
@ -501,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:
|
||||
"""템플릿 정보를 파싱하여 리소스 이름을 추출합니다."""
|
||||
|
||||
|
|
@ -528,387 +434,41 @@ 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:
|
||||
"""target_template_type 요소 개수를 셉니다.
|
||||
|
||||
target_template_type이 "image"인 경우, 고정 자산('-fixed' 명명) 및
|
||||
5토큰 명명 규칙을 따르지 않는 요소(고정 워터마크 등)는 콘텐츠 슬롯이
|
||||
아니므로 카운트에서 제외합니다 (template_matching_taged_image의
|
||||
image_slots 필터링과 동일 기준).
|
||||
"""
|
||||
source_elements = template["source"]["elements"]
|
||||
template_component_data = self.parse_template_component_name(source_elements)
|
||||
count = 0
|
||||
|
||||
for name, template_type in template_component_data.items():
|
||||
if template_type != target_template_type:
|
||||
continue
|
||||
if target_template_type == "image" and (is_fixed_slot_name(name) or self.parse_slot_name_to_tag(name) is None):
|
||||
continue
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def _slot_scores_with_fitness(
|
||||
self,
|
||||
pool_subset: list[dict],
|
||||
slot: str,
|
||||
thumbnail_fitness_map: dict | None,
|
||||
) -> list[float]:
|
||||
"""슬롯 점수에 썸네일 픽셀 적합도를 결합합니다.
|
||||
|
||||
썸네일 슬롯(-9999)에 한해 태그 점수에 적합도를 반영한다:
|
||||
- reject(저해상도/극단 가로형) → 0점 (하드 배제)
|
||||
- 정상 → 태그 점수 × 적합도 배율(0.25~1.0)
|
||||
- 적합도 데이터 없음(헤더 확보/파싱 실패) → 태그 점수 그대로(중립).
|
||||
다운로드 실패를 배제로 오판해 풀 전체가 날아가는 것을 막기 위함.
|
||||
일반 씬 슬롯은 태그 점수를 그대로 반환한다.
|
||||
"""
|
||||
scores = self.calculate_image_slot_score_multi(pool_subset, slot)
|
||||
if not thumbnail_fitness_map or not slot.endswith(THUMBNAIL_SLOT_MARKER):
|
||||
return scores
|
||||
|
||||
adjusted = []
|
||||
for item, score in zip(pool_subset, scores):
|
||||
fitness = thumbnail_fitness_map.get(item.get("image_url"))
|
||||
if fitness is None:
|
||||
adjusted.append(score)
|
||||
elif fitness["reject"]:
|
||||
adjusted.append(0.0)
|
||||
else:
|
||||
adjusted.append(score * fitness["score"])
|
||||
return adjusted
|
||||
|
||||
def rank_thumbnail_candidates(
|
||||
self,
|
||||
template: dict,
|
||||
taged_image_list: list,
|
||||
thumbnail_fitness_map: dict | None = None,
|
||||
top_n: int = 3,
|
||||
) -> dict[str, list[dict]]:
|
||||
"""썸네일 슬롯별로 상위 후보(태그×픽셀 점수 내림차순)를 반환합니다.
|
||||
|
||||
비전 LLM 최종 선택(Phase 2)에 넘길 후보 압축용. 읽기 전용 — pool을
|
||||
변형하지 않으며 배정도 하지 않는다. 점수 0 이하(픽셀 reject 포함)는
|
||||
제외한다.
|
||||
|
||||
Returns:
|
||||
{슬롯명: [pool item, ...]} — 슬롯당 최대 top_n개, 점수 내림차순.
|
||||
후보가 없는 슬롯은 키 자체를 포함하지 않는다.
|
||||
"""
|
||||
component = self.parse_template_component_name(template["source"]["elements"])
|
||||
thumbnail_slots = [
|
||||
name for name, t in component.items()
|
||||
if t == "image" and name.endswith(THUMBNAIL_SLOT_MARKER)
|
||||
and not is_fixed_slot_name(name) and self.parse_slot_name_to_tag(name) is not None
|
||||
]
|
||||
result: dict[str, list[dict]] = {}
|
||||
for slot in thumbnail_slots:
|
||||
scores = self._slot_scores_with_fitness(taged_image_list, slot, thumbnail_fitness_map)
|
||||
ranked = sorted(
|
||||
range(len(scores)), key=lambda i: scores[i], reverse=True
|
||||
)
|
||||
candidates = [taged_image_list[i] for i in ranked if scores[i] > 0][:top_n]
|
||||
if candidates:
|
||||
result[slot] = candidates
|
||||
return result
|
||||
|
||||
def template_matching_taged_image(
|
||||
self,
|
||||
template: dict,
|
||||
taged_image_list: list, # [{"image_url": str, "image_tag": dict}] — 이미 마케팅 적합성 필터를 통과한 이미지만 전달할 것
|
||||
template_id: str,
|
||||
image_url_list: list[str],
|
||||
music_url: str,
|
||||
address: str,
|
||||
duplicate: bool = False,
|
||||
thumbnail_fitness_map: dict | None = None, # {image_url: {"score", "reject"}} — 썸네일 슬롯 픽셀 적합도
|
||||
thumbnail_choice: dict | None = None, # {슬롯명: image_url} — 비전 LLM 최종 선택(있으면 결정론 최고점 대신 사용)
|
||||
) -> tuple[dict, dict]:
|
||||
"""템플릿 슬롯에 이미지를 배정합니다.
|
||||
|
||||
Returns:
|
||||
(modifications, assigned) 튜플
|
||||
- modifications: {슬롯명: image_url, "audio-music": music_url, ...} — modify_element에 전달
|
||||
- assigned: {슬롯명: image_tag} — 자막 생성 컨텍스트에 전달 (modify_element에 넣지 않음)
|
||||
|
||||
배정 전략 (duplicate=False):
|
||||
- 순수 greedy pop 대신 "난이도 우선 greedy" 적용
|
||||
- 사전에 전체 슬롯 점수를 계산해 최고 달성 점수가 낮은(=까다로운) 슬롯을 먼저 배정
|
||||
- 이미지 배정 직전 점수를 재계산 (pop 이후 인덱스 변동 대응)
|
||||
|
||||
배정 전략 (duplicate=True, 이미지 < 슬롯):
|
||||
- 1단계(최소 커버리지 보장): 이미지별로 가장 잘 맞는 슬롯을 찾아 pool의 모든 이미지를
|
||||
서로 다른 슬롯에 먼저 배정한다 (이미지 수 <= 슬롯 수이므로 전량 배정 가능).
|
||||
슬롯을 먼저 소진시키는 것이 아니라 "이미지"를 기준으로 난이도 우선 배정하여,
|
||||
특정 이미지 몇 장이 여러 슬롯을 독점하는 것을 방지한다.
|
||||
- 2단계: 1단계에서 못 채운 나머지 슬롯은 기존처럼 pool 전체에서 최고점 이미지를
|
||||
배정한다 (여기서부터는 중복 재사용 불가피).
|
||||
address: str = None
|
||||
) -> dict:
|
||||
"""템플릿 정보와 이미지/가사/음악 리소스를 매핑합니다.
|
||||
|
||||
Note:
|
||||
marketing_acceptable 필터링은 크롤링 단계에서 이미 완료되므로 여기서는 재필터링하지 않는다.
|
||||
taged_image_list를 그대로 pool로 사용한다.
|
||||
- 이미지는 순차적으로 집어넣기
|
||||
- 가사는 개행마다 한 텍스트 삽입
|
||||
- Template에 audio-music 항목이 있어야 함
|
||||
"""
|
||||
source_elements = template["source"]["elements"]
|
||||
template_component_data = self.parse_template_component_name(source_elements)
|
||||
template_data = await self.get_one_template_data(template_id)
|
||||
template_component_data = self.parse_template_component_name(
|
||||
template_data["source"]["elements"]
|
||||
)
|
||||
modifications = {}
|
||||
|
||||
pool = taged_image_list
|
||||
modifications: dict = {}
|
||||
assigned: dict = {} # {슬롯명: image_tag} — 자막 컨텍스트용
|
||||
|
||||
# 이미지 슬롯과 텍스트 슬롯 분리
|
||||
# 고정 자산(이름이 '-fixed'로 끝나는 로고 등) 및 5토큰 명명 규칙을 따르지
|
||||
# 않는 image 요소는 콘텐츠 슬롯이 아니므로 배정 대상에서 제외 — 그렇지
|
||||
# 않으면 파싱 실패로 0점 처리되어 "가장 까다로운 슬롯"으로 취급되고
|
||||
# 무작위 이미지로 덮어써진다.
|
||||
image_slots = [
|
||||
name for name, t in template_component_data.items()
|
||||
if t == "image" and not is_fixed_slot_name(name) and self.parse_slot_name_to_tag(name) is not None
|
||||
]
|
||||
text_slots = [(name, t) for name, t in template_component_data.items() if t == "text"]
|
||||
|
||||
# ── 썸네일 슬롯(-9999) 우선 배정 ─────────────────────────────────
|
||||
# 썸네일은 영상에서 가장 노출이 큰 표면이므로 씬 슬롯과 다르게 취급한다:
|
||||
# 1) 씬 슬롯이 pool에서 좋은 컷을 pop해 가기 전에 먼저 배정 (같은 태그
|
||||
# 계열 씬 슬롯이 여러 개면 썸네일 차례에 적합 컷이 소진되는 문제 방지)
|
||||
# 2) "상위 3장 가중 랜덤" 없이 결정론적 최고점 선택 — 랜덤 다양화가
|
||||
# 저점수 컷(예: space_type 불일치 ×0.1 페널티 컷)을 확률적으로
|
||||
# 썸네일에 앉히는 사고 방지
|
||||
# duplicate(이미지 부족) 시에는 pop하지 않아 씬 커버리지를 깎지 않는다.
|
||||
# thumbnail_choice(비전 LLM 최종 선택)가 해당 슬롯에 있으면 그 이미지를,
|
||||
# 없으면(선택 실패/미제공) 결정론적 최고점 컷을 사용한다 — 폴백 안전.
|
||||
thumbnail_choice = thumbnail_choice or {}
|
||||
thumbnail_slots = [s for s in image_slots if s.endswith(THUMBNAIL_SLOT_MARKER)]
|
||||
image_slots = [s for s in image_slots if not s.endswith(THUMBNAIL_SLOT_MARKER)]
|
||||
for slot in thumbnail_slots:
|
||||
if not pool:
|
||||
logger.warning(f"[template_matching_taged_image] 이미지 풀 없음 — 썸네일 슬롯 배정 불가: {slot}")
|
||||
break
|
||||
scores = self._slot_scores_with_fitness(pool, slot, thumbnail_fitness_map)
|
||||
chosen_url = thumbnail_choice.get(slot)
|
||||
chosen_idx = next(
|
||||
(i for i, item in enumerate(pool) if item["image_url"] == chosen_url), None
|
||||
) if chosen_url else None
|
||||
if chosen_idx is not None:
|
||||
sel_idx, sel_source = chosen_idx, "vision"
|
||||
else:
|
||||
sel_idx, sel_source = max(range(len(scores)), key=lambda i: scores[i]), "결정론"
|
||||
selected = pool[sel_idx] if duplicate else pool.pop(sel_idx)
|
||||
modifications[slot] = selected["image_url"]
|
||||
assigned[slot] = selected["image_tag"]
|
||||
logger.info(
|
||||
f"[template_matching_taged_image] 썸네일 배정({sel_source}) — slot: {slot}, "
|
||||
f"score: {scores[sel_idx]:.3f}, url: {selected['image_url']}"
|
||||
)
|
||||
|
||||
if not pool:
|
||||
logger.warning("[template_matching_taged_image] 태그된 이미지가 없음 — 이미지 슬롯 배정 불가")
|
||||
elif duplicate:
|
||||
# 이미지 부족(슬롯 수 > 이미지 수)
|
||||
# Step 1: 최소 커버리지 보장 — 이미지마다 가장 잘 맞는 슬롯을 찾아 서로 다른 슬롯에 배정
|
||||
remaining_slots = list(image_slots)
|
||||
|
||||
def _best_slot_for_image(image: dict, slots: list) -> tuple[str | None, float]:
|
||||
best_slot, best_score = None, -1.0
|
||||
for slot in slots:
|
||||
score = self._slot_scores_with_fitness([image], slot, thumbnail_fitness_map)[0]
|
||||
if score > best_score:
|
||||
best_slot, best_score = slot, score
|
||||
return best_slot, best_score
|
||||
|
||||
# 사전 점수 계산 (최고 달성 점수가 낮을수록 배정처가 마땅찮은=까다로운 이미지)
|
||||
prelim_best_score = [
|
||||
_best_slot_for_image(image, remaining_slots)[1] for image in pool
|
||||
]
|
||||
ordered_indices = sorted(
|
||||
range(len(pool)),
|
||||
key=lambda i: prelim_best_score[i]
|
||||
)
|
||||
|
||||
for idx in ordered_indices:
|
||||
if not remaining_slots:
|
||||
break
|
||||
image = pool[idx]
|
||||
# 슬롯이 이전 배정으로 줄어들었으므로 현재 remaining_slots 기준 재계산
|
||||
best_slot, _ = _best_slot_for_image(image, remaining_slots)
|
||||
if best_slot is None:
|
||||
continue
|
||||
modifications[best_slot] = image["image_url"]
|
||||
assigned[best_slot] = image["image_tag"]
|
||||
remaining_slots.remove(best_slot)
|
||||
|
||||
# Step 2: 커버리지 배정 후 남은 슬롯은 pool 전체에서 최고점 이미지 배정 (재사용 불가피)
|
||||
for slot in remaining_slots:
|
||||
scores = self._slot_scores_with_fitness(pool, slot, thumbnail_fitness_map)
|
||||
if not scores:
|
||||
continue
|
||||
best_idx = scores.index(max(scores))
|
||||
selected = pool[best_idx]
|
||||
modifications[slot] = selected["image_url"]
|
||||
assigned[slot] = selected["image_tag"]
|
||||
else:
|
||||
# 이미지 충분(슬롯 수 <= 이미지 수): 난이도 우선 greedy 배정
|
||||
# Step 1: 정렬용 사전 점수 계산 (최고 달성 점수가 낮을수록 배정이 어려운 슬롯)
|
||||
prelim = {slot: self._slot_scores_with_fitness(pool, slot, thumbnail_fitness_map) for slot in image_slots}
|
||||
ordered_slots = sorted(
|
||||
image_slots,
|
||||
key=lambda s: max(prelim[s]) if prelim[s] else 0.0
|
||||
# 오름차순 → 달성 최대값이 낮은(어려운) 슬롯이 앞으로
|
||||
)
|
||||
|
||||
# Step 2: 난이도 순서로 배정, 배정마다 pool에서 pop + 다음 슬롯은 점수 재계산
|
||||
for slot in ordered_slots:
|
||||
if not pool:
|
||||
logger.warning(f"[template_matching_taged_image] 이미지 풀 소진 — 남은 슬롯 배정 불가: {slot}")
|
||||
break
|
||||
# pop 후 인덱스 변동이 있으므로 현재 pool로 재계산 (사전 점수 재사용 금지)
|
||||
scores = self._slot_scores_with_fitness(pool, slot, thumbnail_fitness_map)
|
||||
if not scores:
|
||||
continue
|
||||
|
||||
# 상위 3장 중 점수 비례 확률로 선택 (영상 다양화)
|
||||
sorted_indices = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)
|
||||
top_indices = sorted_indices[:min(3, len(sorted_indices))]
|
||||
weights = [scores[i] for i in top_indices]
|
||||
if sum(weights) > 0:
|
||||
chosen_pool_idx = random.choices(top_indices, weights=weights, k=1)[0]
|
||||
else:
|
||||
chosen_pool_idx = random.choice(top_indices)
|
||||
|
||||
selected = pool.pop(chosen_pool_idx)
|
||||
modifications[slot] = selected["image_url"]
|
||||
assigned[slot] = selected["image_tag"]
|
||||
|
||||
# 텍스트 슬롯 처리
|
||||
for name, _ in text_slots:
|
||||
if "address_input" in name:
|
||||
modifications[name] = address
|
||||
for idx, (template_component_name, template_type) in enumerate(
|
||||
template_component_data.items()
|
||||
):
|
||||
match template_type:
|
||||
case "image":
|
||||
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, assigned
|
||||
|
||||
# 점수 계산 상수 (모듈 레벨로 빼면 더 좋지만 기존 코드 스타일 따라 클래스 내부에 위치)
|
||||
_NARR_FLOOR = 0.25 # narrative 최솟값 modulator: base 점수가 완전히 0이 되는 것을 방지
|
||||
_NARR_BONUS = 1.5 # narrative 독립 보너스: base=0이어도 narrative 신호가 점수에 남음
|
||||
_NARR_MIN = 0.0 # narrative_preference 값 clamp 하한 (무경계 스키마 방어)
|
||||
_NARR_MAX = 100.0 # narrative_preference 값 clamp 상한. NarrativePreference 스키마(0.0~100.0)와 스케일 일치
|
||||
_SPACE_TYPE_MISMATCH_PENALTY = 0.1 # 슬롯이 요구하는 space_type과 다른 이미지에 적용하는 페널티 배수 (하드 필터에 가깝게)
|
||||
|
||||
def calculate_image_slot_score_multi(self, taged_image_list : list[dict], slot_name : str):
|
||||
"""이미지 슬롯에 대한 각 이미지의 매칭 점수를 계산합니다.
|
||||
|
||||
점수 = (base * narr_mod + NARR_BONUS * narr) * space_type_penalty
|
||||
- base: 태그 카테고리 매칭 합산 (최대 6.5 = space_type 2.0 + subject 3.0 + camera 1.0 + motion 0.5)
|
||||
- narr_mod: [NARR_FLOOR, 1.0] 구간으로 완화된 narrative 계수
|
||||
- NARR_BONUS * narr: narrative 독립 보너스 (base=0이어도 신호 유지)
|
||||
- space_type_penalty: 슬롯이 요구하는 space_type과 이미지가 불일치하면
|
||||
SPACE_TYPE_MISMATCH_PENALTY(0.1)를 곱해 사실상 탈락시킴. narrative_preference
|
||||
점수가 아무리 높아도(예: bathroom 이미지가 intro narr=100) 슬롯이 지정한
|
||||
space_type(예: exterior_front)과 다르면 우선 배정되지 않도록 하기 위함 —
|
||||
같은 space_type 이미지가 pool에 하나도 없을 때만 상대적으로 선택됨(fallback 유지).
|
||||
이 구조로 "base=0, narrative가 높음"과 "완전히 틀린 이미지(0,0)"를 구별할 수 있음.
|
||||
"""
|
||||
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)
|
||||
if slot_tag_dict is None:
|
||||
# parse 실패 시 전부 0점 반환 (슬롯 skip)
|
||||
return [0.0] * len(image_tag_list)
|
||||
|
||||
base_score_list = [0.0] * len(image_tag_list)
|
||||
# slot_tag_narrative = NarrativePhase.accent # 기본값 (narrative 토큰 없는 슬롯 대비)
|
||||
slot_space_type = slot_tag_dict.get("space_type")
|
||||
space_type_match_list = [
|
||||
slot_space_type is not None and slot_space_type.value in image_tag.get("space_type", [])
|
||||
for image_tag in image_tag_list
|
||||
]
|
||||
|
||||
# 썸네일 슬롯 한정: subject가 슬롯 요구와 일치하면 space_type 불일치
|
||||
# 페널티를 면제한다. 배경: 일부 업종(예: 레스토랑 dining_hall-food_dish)은
|
||||
# 태깅 관행상 슬롯이 요구하는 subject(음식 클로즈업)가 실제로는 다른
|
||||
# space_type(table_setting/detail_plating 등)으로 붙는다 — space_type과
|
||||
# subject가 한 사진에 공존하기 어려운 조합. 이런 슬롯은 space_type 하드
|
||||
# 필터가 정작 원하는 subject 이미지를 전부 걸러내는 역효과를 낸다.
|
||||
# 템플릿 슬롯명을 바꾸지 않고 여기서 흡수(썸네일 한정이라 일반 씬 슬롯의
|
||||
# space_type 하드 필터는 그대로 유지).
|
||||
slot_subject = slot_tag_dict.get("subject")
|
||||
if slot_name.endswith(THUMBNAIL_SLOT_MARKER) and slot_subject is not None:
|
||||
for idx, image_tag in enumerate(image_tag_list):
|
||||
if slot_subject.value in image_tag.get("subject", []):
|
||||
space_type_match_list[idx] = True
|
||||
|
||||
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.0
|
||||
case "subject":
|
||||
weight = 3.0
|
||||
case "camera":
|
||||
weight = 1.0
|
||||
case "motion_recommended":
|
||||
weight = 0.5
|
||||
case _:
|
||||
raise ValueError(f"[calculate_image_slot_score_multi] unknown slot tag category: {slot_tag_cate}")
|
||||
|
||||
for idx, image_tag in enumerate(image_tag_list):
|
||||
if slot_tag_item.value in image_tag[slot_tag_cate]: # collect!
|
||||
base_score_list[idx] += weight
|
||||
|
||||
# narrative 점수 적용: 순수 곱셈 대신 "완화 modulator + 독립 보너스"
|
||||
# - narr_mod: base를 흐릴 수 있지만 NARR_FLOOR 이하로는 안 떨어짐
|
||||
# - NARR_BONUS * narr: base=0이어도 narrative가 높으면 양수 점수 확보
|
||||
image_score_list = []
|
||||
for idx, image_tag in enumerate(image_tag_list):
|
||||
raw_narr = image_tag.get("narrative_preference", {}).get(slot_tag_narrative, 0.0)
|
||||
clamped_narr = min(self._NARR_MAX, max(self._NARR_MIN, float(raw_narr)))
|
||||
narr = clamped_narr / self._NARR_MAX # 0.0~1.0로 정규화
|
||||
narr_mod = self._NARR_FLOOR + (1.0 - self._NARR_FLOOR) * narr
|
||||
score = base_score_list[idx] * narr_mod + self._NARR_BONUS * narr
|
||||
if not space_type_match_list[idx]:
|
||||
score *= self._SPACE_TYPE_MISMATCH_PENALTY
|
||||
image_score_list.append(score)
|
||||
|
||||
return image_score_list
|
||||
|
||||
def parse_slot_name_to_tag(self, slot_name: str) -> dict[str, StrEnum] | None:
|
||||
"""슬롯 이름을 파싱하여 태그 딕셔너리를 반환합니다.
|
||||
|
||||
슬롯 이름 형식: {space_type}-{subject}-{camera}-{motion}-{narrative}
|
||||
파싱 실패 시 None을 반환합니다 (호출자가 해당 슬롯을 skip+log 처리).
|
||||
"""
|
||||
try:
|
||||
tag_list = slot_name.split("-")
|
||||
if len(tag_list) < 5:
|
||||
logger.warning(f"[parse_slot_name_to_tag] 슬롯명 토큰 부족 ({len(tag_list)}개): '{slot_name}' — 슬롯 skip")
|
||||
return None
|
||||
space_type = SpaceType(tag_list[0])
|
||||
subject = Subject(tag_list[1])
|
||||
camera = Camera(tag_list[2])
|
||||
# 모션 동의어(zoom_in, pan_left 등)는 표준 enum 값으로 정규화 후 변환
|
||||
motion = MOTION_TOKEN_NORMALIZATION.get(tag_list[3]) or 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
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.warning(f"[parse_slot_name_to_tag] 슬롯명 파싱 실패: '{slot_name}' — {e} — 슬롯 skip")
|
||||
return None
|
||||
return modifications
|
||||
|
||||
def elements_connect_resource_blackbox(
|
||||
self,
|
||||
|
|
@ -939,26 +499,20 @@ class CreatomateService:
|
|||
return modifications
|
||||
|
||||
def modify_element(self, elements: list, modification: dict) -> list:
|
||||
"""elements의 source를 modification에 따라 수정합니다.
|
||||
|
||||
modification에 없는 image/text 요소(예: '-fixed' 명명의 고정 로고·
|
||||
연락처 문구처럼 배정/자막 생성 대상에서 제외된 고정 콘텐츠)는 템플릿에
|
||||
이미 설정된 source/text를 그대로 보존한다 (KeyError·빈 문자열로
|
||||
렌더가 깨지지 않도록).
|
||||
"""
|
||||
"""elements의 source를 modification에 따라 수정합니다."""
|
||||
|
||||
def recursive_modify(element: dict) -> None:
|
||||
if "name" in element:
|
||||
match element["type"]:
|
||||
case "image":
|
||||
element["source"] = modification.get(element["name"], element.get("source"))
|
||||
element["source"] = modification[element["name"]]
|
||||
case "audio":
|
||||
element["source"] = modification.get(element["name"], "")
|
||||
case "video":
|
||||
element["source"] = modification[element["name"]]
|
||||
case "text":
|
||||
#element["source"] = modification[element["name"]]
|
||||
element["text"] = modification.get(element["name"], element.get("text", ""))
|
||||
element["text"] = modification.get(element["name"], "")
|
||||
case "composition":
|
||||
for minor in element["elements"]:
|
||||
recursive_modify(minor)
|
||||
|
|
@ -1062,8 +616,6 @@ class CreatomateService:
|
|||
"""
|
||||
url = f"{self.BASE_URL}/v2/renders"
|
||||
|
||||
source["frame_rate"] = 30
|
||||
|
||||
last_error: Exception | None = None
|
||||
|
||||
for attempt in range(recovery_settings.CREATOMATE_MAX_RETRIES + 1):
|
||||
|
|
@ -1117,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:
|
||||
"""렌더링 작업의 상태를 조회합니다.
|
||||
|
||||
|
|
@ -1140,19 +700,13 @@ class CreatomateService:
|
|||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
@staticmethod
|
||||
def _entrance_transition_offset(elem: dict) -> float:
|
||||
"""요소의 entrance transition(transition=true, time==0) duration 합을 반환합니다.
|
||||
# 하위 호환성을 위한 별칭 (deprecated)
|
||||
async def get_render_status_async(self, render_id: str) -> dict:
|
||||
"""렌더링 작업의 상태를 조회합니다.
|
||||
|
||||
순차 배치 시 이 값만큼 이전 요소와 겹치도록 시작점이 앞당겨집니다.
|
||||
Deprecated: get_render_status()를 사용하세요.
|
||||
"""
|
||||
offset = 0.0
|
||||
for animation in elem.get("animations", []):
|
||||
if "reversed" in animation:
|
||||
continue
|
||||
if "transition" in animation and animation["transition"] and animation.get("time", 0) == 0:
|
||||
offset += animation["duration"]
|
||||
return offset
|
||||
return await self.get_render_status(render_id)
|
||||
|
||||
def calc_scene_duration(self, template: dict) -> float:
|
||||
"""템플릿의 전체 장면 duration을 계산합니다."""
|
||||
|
|
@ -1166,86 +720,25 @@ 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 일 시 버그 발생 확률 있음
|
||||
track_maximum_duration[elem["track"]] += elem["duration"] - self._entrance_transition_offset(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"]:
|
||||
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())
|
||||
|
||||
return total_template_duration
|
||||
|
||||
def calc_track_time_ranges(self, template: dict, track: int) -> list[tuple[str, float, float]]:
|
||||
"""최상위 요소 중 지정 track의 (name, start, end) 시간 범위를 계산합니다.
|
||||
|
||||
Creatomate 규칙에 따라 `time`이 없거나 0인 요소는 같은 트랙의 이전 요소 뒤에
|
||||
순차 배치되며, entrance transition duration만큼 이전 요소와 겹치도록
|
||||
시작점을 앞당깁니다(calc_scene_duration의 차감 규칙과 동치).
|
||||
`time`이 명시된 요소는 해당 시각에서 시작하고, 커서는 그 요소의 끝으로 이동합니다.
|
||||
"""
|
||||
ranges: list[tuple[str, float, float]] = []
|
||||
cursor = 0.0
|
||||
for elem in template["source"]["elements"]:
|
||||
try:
|
||||
if elem.get("track") != track:
|
||||
continue
|
||||
duration = elem["duration"]
|
||||
|
||||
if "time" not in elem or elem["time"] == 0: # 순차 배치(auto)
|
||||
start = max(0.0, cursor - self._entrance_transition_offset(elem))
|
||||
else:
|
||||
start = elem["time"]
|
||||
|
||||
end = start + duration
|
||||
cursor = end
|
||||
ranges.append((elem.get("name", ""), start, end))
|
||||
except Exception as e:
|
||||
logger.debug(traceback.format_exc())
|
||||
logger.error(f"[calc_track_time_ranges] Error processing element: {elem}, {e}")
|
||||
|
||||
return ranges
|
||||
|
||||
@staticmethod
|
||||
def _scale_element(elem: dict, extend_rate: float) -> None:
|
||||
"""element 하나의 time/duration/animations를 extend_rate만큼 스케일링합니다.
|
||||
|
||||
composition의 자식 element까지 재귀적으로 처리합니다. 재귀하지 않으면
|
||||
부모 컴포지션(씬 슬롯)만 늘어나고 내부 leaf 이미지/애니메이션은 원래
|
||||
길이에서 끝나버려, 슬롯이 끝나기 전 컴포지션의 fill_color(회색)가
|
||||
노출되는 버그가 생긴다 — modify_element/parse_template_component_name과
|
||||
동일하게 composition을 재귀 순회해야 함.
|
||||
|
||||
오디오 판별은 track 번호가 아닌 type == "audio"로 한다. composition
|
||||
내부의 track 번호는 그 컴포지션 안에서만 유효한 로컬 레이어 인덱스라
|
||||
전역 AUDIO_TRACK/KEYWORD_TRACK 상수와 우연히 값이 겹칠 수 있다
|
||||
(실제 템플릿에도 다중 레이어 씬의 2번째/3번째 이미지가 track=2, track=4인
|
||||
경우가 존재함).
|
||||
"""
|
||||
if elem.get("type") == "audio":
|
||||
return
|
||||
|
||||
# "time"은 숫자 오프셋 대신 "end"(컴포지션 끝에 고정) 같은 특수 키워드일 수 있음 —
|
||||
# 이런 값은 렌더 시점에 (이미 스케일링된) duration 기준으로 재계산되므로 그대로 둔다.
|
||||
if "time" in elem and isinstance(elem["time"], (int, float)):
|
||||
elem["time"] = elem["time"] * extend_rate
|
||||
if "duration" in elem and isinstance(elem["duration"], (int, float)):
|
||||
elem["duration"] = elem["duration"] * extend_rate
|
||||
|
||||
for animation in elem.get("animations", []):
|
||||
anim_time = animation.get("time", 0)
|
||||
if isinstance(anim_time, (int, float)) and anim_time != 0:
|
||||
animation["time"] = anim_time * extend_rate
|
||||
if isinstance(animation.get("duration"), (int, float)):
|
||||
animation["duration"] = animation["duration"] * extend_rate
|
||||
|
||||
if elem.get("type") == "composition":
|
||||
for child in elem.get("elements", []):
|
||||
CreatomateService._scale_element(child, extend_rate)
|
||||
|
||||
def extend_template_duration(self, template: dict, target_duration: float) -> dict:
|
||||
"""템플릿의 duration을 target_duration으로 확장합니다."""
|
||||
# template["duration"] = target_duration + 0.5 # 늘린것보단 짧게
|
||||
|
|
@ -1256,10 +749,21 @@ class CreatomateService:
|
|||
|
||||
for elem in new_template["source"]["elements"]:
|
||||
try:
|
||||
if elem["track"] == AUDIO_TRACK : # audio track(최상위 음악 트랙)은 패스
|
||||
# if elem["type"] == "audio":
|
||||
# continue
|
||||
if elem["track"] == AUDIO_TRACK : # audio track은 패스
|
||||
continue
|
||||
|
||||
self._scale_element(elem, extend_rate)
|
||||
if "time" in elem:
|
||||
elem["time"] = elem["time"] * extend_rate
|
||||
if "duration" in elem:
|
||||
elem["duration"] = elem["duration"] * extend_rate
|
||||
|
||||
if "animations" not in elem:
|
||||
continue
|
||||
for animation in elem["animations"]:
|
||||
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
|
||||
animation["duration"] = animation["duration"] * extend_rate
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[extend_template_duration] Error processing element: {elem}, {e}"
|
||||
|
|
@ -1296,38 +800,12 @@ class CreatomateService:
|
|||
case "horizontal":
|
||||
return autotext_template_h_1
|
||||
|
||||
def apply_language_font(self, template: dict, language: str) -> dict:
|
||||
"""출력 언어에 맞춰 템플릿 내 모든 텍스트 요소의 폰트를 교체합니다.
|
||||
|
||||
템플릿 기본 폰트(GyeonggiTitleOTF/Pretendard)는 한글·라틴 전용이라
|
||||
CJK/태국어 글리프가 없다. LANGUAGE_FONT_MAP에 있는 언어는 전체 텍스트
|
||||
폰트를 해당 언어 지원 폰트(Google Fonts)로 교체하고, 매핑에 없는
|
||||
언어(Korean, English 등)는 템플릿 원본 폰트를 유지한다.
|
||||
"""
|
||||
font_family = LANGUAGE_FONT_MAP.get(language)
|
||||
if not font_family:
|
||||
return template
|
||||
|
||||
def recursive_apply(element: dict) -> None:
|
||||
if element.get("type") == "text":
|
||||
element["font_family"] = font_family
|
||||
for minor in element.get("elements") or []:
|
||||
recursive_apply(minor)
|
||||
|
||||
for elem in template["source"]["elements"]:
|
||||
recursive_apply(elem)
|
||||
|
||||
logger.info(
|
||||
f"[apply_language_font] 텍스트 폰트 교체 완료 - language: {language}, font: {font_family}"
|
||||
)
|
||||
return template
|
||||
|
||||
def extract_text_format_from_template(self, template:dict):
|
||||
keyword_list = []
|
||||
subtitle_list = []
|
||||
for elem in template["source"]["elements"]:
|
||||
try: #최상위 내 텍스트만 검사
|
||||
if elem["type"] == "text" and not is_fixed_slot_name(elem["name"]):
|
||||
if elem["type"] == "text":
|
||||
if elem["track"] == SUBTITLE_TRACK:
|
||||
subtitle_list.append(elem["name"])
|
||||
elif elem["track"] == KEYWORD_TRACK:
|
||||
|
|
@ -1341,41 +819,5 @@ class CreatomateService:
|
|||
assert(len(keyword_list)==len(subtitle_list))
|
||||
except Exception as E:
|
||||
logger.error("this template does not have same amount of keyword and subtitle.")
|
||||
|
||||
# 썸네일 텍스트(thumb-*)는 전용 트랙이 아닌 thumbnail-composition 내부에
|
||||
# 중첩되어 있어 위 최상위 트랙 스캔에 잡히지 않는다. 다국어 자막 생성에
|
||||
# 함께 포함되도록 재귀 파싱으로 수집한다.
|
||||
component_data = self.parse_template_component_name(
|
||||
template["source"]["elements"]
|
||||
)
|
||||
thumbnail_list = [
|
||||
name
|
||||
for name, elem_type in component_data.items()
|
||||
if elem_type == "text" and name.startswith("thumb-")
|
||||
]
|
||||
|
||||
pitching_list = keyword_list + subtitle_list + thumbnail_list
|
||||
pitching_list = keyword_list + subtitle_list
|
||||
return pitching_list
|
||||
|
||||
|
||||
def make_thumbnail_modification(self, brand_name : str, region : str, category_definition : str, target_keywords : list[str], detail_region_info : 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]]
|
||||
|
||||
# 지역 표기: 상세주소의 공백 기준 앞 두 마디 사용
|
||||
# (예: "전북 군산시 절골길 18" → "전북 군산시").
|
||||
# 상세주소가 없으면 기존 region 정규화 표기로 폴백.
|
||||
if detail_region_info and detail_region_info.strip():
|
||||
region_display = " ".join(detail_region_info.split()[:2])
|
||||
else:
|
||||
region_display = normalize_location(region)
|
||||
|
||||
mod_dict = {
|
||||
"thumb-hashtag-primary" : ' '.join(hashtaged_target_keywords),
|
||||
"thumb-headline-brand_name-factual" : brand_name,
|
||||
"thumb-subheadline-local_info-factual" : region_display,
|
||||
"thumb-badge-category" : category_definition,
|
||||
}
|
||||
return mod_dict
|
||||
|
|
|
|||
|
|
@ -1,125 +0,0 @@
|
|||
"""크롤링 단계 이미지 마케팅 적합성 필터.
|
||||
|
||||
마케팅 적합성(marketing_acceptable) 판정은 이 모듈이 전담한다. 크롤링 시점에
|
||||
방문자/AI View 사진(extra_photo_urls)만을 대상으로 판정하며, 업로드 이후 단계의
|
||||
태깅(app/utils/autotag.py의 autotag_images(), sheet 기반 image_autotag_prompt)은
|
||||
공간/피사체/카메라/모션/내러티브 태그만 다루고 marketing_acceptable은 포함하지 않는다
|
||||
(이미 이 단계에서 걸러진 이미지만 태깅 대상이 되기 때문). 프롬프트가 짧아
|
||||
시트(Prompt) 대신 코드에 하드코딩한다.
|
||||
|
||||
업체 등록 사진(owner_images)은 이 필터의 대상이 아니며, 호출측(라우터)에서
|
||||
owner_images 개수가 NvMapScraper.SUPPLEMENT_THRESHOLD(30) 이상이면 이 모듈을
|
||||
아예 호출하지 않는 것이 원칙이다.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.prompts.chatgpt_prompt import ChatgptService
|
||||
from app.utils.prompts.schemas import MarketingFilterOutput
|
||||
|
||||
logger = get_logger("image_filter")
|
||||
|
||||
# 크롤링 단계 필터링에 사용할 Gemini 모델. 시트(prompts.py)와 달리 코드에 고정한다.
|
||||
MARKETING_FILTER_MODEL = "gpt-5-mini"
|
||||
|
||||
MAX_RETRY = 2
|
||||
|
||||
MARKETING_FILTER_PROMPT_TEMPLATE = """\
|
||||
당신은 광고 영상 제작을 위한 이미지 심사자입니다. 아래 업종의 광고 영상에 이 이미지를 \
|
||||
사용해도 되는지 판정하세요.
|
||||
|
||||
업종(industry): {industry}
|
||||
|
||||
## MARKETING SUITABILITY CHECK (industry-aware)
|
||||
- marketing_acceptable: true | false
|
||||
- reject_reason: short reason if false (e.g., 저화질, 인물 신원 노출, 업종 부적합, 민감/의료 장면, 미성년자 식별 노출). Empty if acceptable.
|
||||
- Person identity protection: 식별 가능한 얼굴이 주피사체이고 동의 여부가 불확실하면 보수적으로 처리.
|
||||
|
||||
주어진 스키마에 맞춰 marketing_acceptable과 reject_reason만 반환하세요.\
|
||||
"""
|
||||
|
||||
|
||||
def _build_prompt(industry: str) -> str:
|
||||
return MARKETING_FILTER_PROMPT_TEMPLATE.format(industry=industry or "미상")
|
||||
|
||||
|
||||
async def filter_marketing_images(image_url_list: list[str], industry: str = "") -> list[bool]:
|
||||
"""이미지 URL마다 마케팅 적합성(marketing_acceptable)만 판정해 통과 여부 리스트를 반환한다.
|
||||
|
||||
이미지 1장당 API 호출 1회를 asyncio.gather로 병렬 실행한다(배치 호출 아님).
|
||||
실패한 이미지는 최대 MAX_RETRY회 재시도하며, 그래도 실패하면 보수적으로
|
||||
False(제외)로 처리한다.
|
||||
|
||||
Args:
|
||||
image_url_list: 판정할 이미지 URL 목록 (extra_photo_urls의 original만 전달할 것)
|
||||
industry: 업종 분류값 (_resolve_industry 결과). 필터 프롬프트 앞에서 먼저 계산되어야 함.
|
||||
|
||||
Returns:
|
||||
image_url_list와 같은 순서의 통과 여부(bool) 리스트.
|
||||
"""
|
||||
if not image_url_list:
|
||||
return []
|
||||
|
||||
chatgpt = ChatgptService(model_type="gpt")
|
||||
prompt_text = _build_prompt(industry)
|
||||
|
||||
async def _call(url: str):
|
||||
return await chatgpt._call_pydantic_output_chat_completion(
|
||||
prompt=prompt_text,
|
||||
output_format=MarketingFilterOutput,
|
||||
model=MARKETING_FILTER_MODEL,
|
||||
img_url=url,
|
||||
image_detail_high=True,
|
||||
)
|
||||
|
||||
results: list[MarketingFilterOutput | BaseException] = await asyncio.gather(
|
||||
*[_call(url) for url in image_url_list], return_exceptions=True
|
||||
)
|
||||
|
||||
for _ in range(MAX_RETRY):
|
||||
failed_idx = [i for i, r in enumerate(results) if isinstance(r, Exception)]
|
||||
if not failed_idx:
|
||||
break
|
||||
# logger.warning(f"[image_filter] 재시도 대상 인덱스: {failed_idx}")
|
||||
retried = await asyncio.gather(
|
||||
*[_call(image_url_list[i]) for i in failed_idx], return_exceptions=True
|
||||
)
|
||||
for i, result in zip(failed_idx, retried):
|
||||
results[i] = result
|
||||
|
||||
final_failed = [i for i, r in enumerate(results) if isinstance(r, Exception)]
|
||||
# if final_failed:
|
||||
# logger.warning(
|
||||
# f"[image_filter] {len(final_failed)}건 최종 실패 → 보수적으로 제외 처리: {final_failed}"
|
||||
# )
|
||||
|
||||
return [
|
||||
(not isinstance(r, Exception)) and r.marketing_acceptable
|
||||
for r in results
|
||||
]
|
||||
|
||||
|
||||
def assemble_images(
|
||||
owner_images: list[dict],
|
||||
extra_images: list[dict],
|
||||
extra_pass_flags: list[bool],
|
||||
max_images: int,
|
||||
) -> list[dict]:
|
||||
"""owner_images 전량 + 필터 통과한 extra_images를 합쳐 최대 max_images장을 만든다.
|
||||
|
||||
owner_images는 무조건 포함(필터링 대상 아님). extra_images는 extra_pass_flags와
|
||||
같은 순서로 매칭되며, 통과분만 순서대로 이어붙인다. original URL 기준으로 중복을
|
||||
제거한다(nvMapScraper._extract_origins의 dedup 방식과 동일).
|
||||
"""
|
||||
passed_extra = [img for img, ok in zip(extra_images, extra_pass_flags) if ok]
|
||||
|
||||
seen: set[str] = set()
|
||||
combined: list[dict] = []
|
||||
for img in owner_images + passed_extra:
|
||||
original = img.get("original")
|
||||
if original and original not in seen:
|
||||
seen.add(original)
|
||||
combined.append(img)
|
||||
|
||||
return combined[:max_images]
|
||||
|
|
@ -1,8 +1,4 @@
|
|||
import asyncio
|
||||
import random
|
||||
import re
|
||||
from html import unescape
|
||||
from difflib import SequenceMatcher
|
||||
from playwright.async_api import async_playwright
|
||||
from urllib import parse
|
||||
import time
|
||||
|
|
@ -20,76 +16,23 @@ class NvMapPwScraper():
|
|||
_win_width = 1280
|
||||
_win_height = 720
|
||||
_max_retry = 3
|
||||
_timeout = 30 # place id timeout threshold seconds
|
||||
_retry_delay_ms = 1500 # 검색 실패 후 재시도 전 대기시간 (네이버 요청 밀도 완화)
|
||||
|
||||
# UA·뷰포트·sec-ch-ua를 한 세트로 묶은 브라우저 프로필 후보군 (안티봇 핑거프린트 회피용)
|
||||
_UA_PROFILES = [
|
||||
{
|
||||
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
|
||||
"sec_ch_ua": '"Not?A_Brand";v="99", "Chromium";v="130", "Google Chrome";v="130"',
|
||||
"viewport": {"width": 1280, "height": 720},
|
||||
},
|
||||
{
|
||||
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
|
||||
"sec_ch_ua": '"Not?A_Brand";v="99", "Chromium";v="129", "Google Chrome";v="129"',
|
||||
"viewport": {"width": 1366, "height": 768},
|
||||
},
|
||||
{
|
||||
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
|
||||
"sec_ch_ua": '"Not?A_Brand";v="99", "Chromium";v="130", "Google Chrome";v="130"',
|
||||
"viewport": {"width": 1440, "height": 900},
|
||||
},
|
||||
{
|
||||
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||
"sec_ch_ua": '"Not?A_Brand";v="99", "Chromium";v="131", "Google Chrome";v="131"',
|
||||
"viewport": {"width": 1536, "height": 864},
|
||||
},
|
||||
]
|
||||
_current_profile = None
|
||||
|
||||
# 헤드리스 자동화 탐지 신호(navigator.webdriver, 빈 plugins/languages, window.chrome 부재,
|
||||
# permissions.query 이상 동작)를 정상 브라우저처럼 위장하는 스텔스 패치.
|
||||
# create_page()와 fetch_graphql() 양쪽에서 생성하는 모든 page에 반드시 적용해야 한다 —
|
||||
# 한쪽이라도 빠지면 그 경로만 헤드리스로 노출되어 안티봇 캡차 트리거 확률이 올라간다.
|
||||
_STEALTH_INIT_SCRIPT = '''
|
||||
Object.defineProperty(Navigator.prototype, "webdriver", {
|
||||
set: undefined,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: () => false,
|
||||
});
|
||||
Object.defineProperty(navigator, "plugins", { get: () => [1, 2, 3, 4, 5] });
|
||||
Object.defineProperty(navigator, "languages", { get: () => ["ko-KR", "ko"] });
|
||||
window.chrome = window.chrome || { runtime: {} };
|
||||
const originalQuery = window.navigator.permissions && window.navigator.permissions.query;
|
||||
if (originalQuery) {
|
||||
window.navigator.permissions.query = (parameters) => (
|
||||
parameters.name === "notifications"
|
||||
? Promise.resolve({ state: Notification.permission })
|
||||
: originalQuery(parameters)
|
||||
);
|
||||
}
|
||||
'''
|
||||
_timeout = 60 # place id timeout threshold seconds
|
||||
|
||||
# instance var
|
||||
page = None
|
||||
|
||||
@classmethod
|
||||
def _pick_profile(cls):
|
||||
"""현재 프로필과 다른 프로필을 무작위로 선택한다 (후보가 1개뿐이면 그대로 반환)."""
|
||||
candidates = [p for p in cls._UA_PROFILES if p is not cls._current_profile]
|
||||
return random.choice(candidates or cls._UA_PROFILES)
|
||||
|
||||
@classmethod
|
||||
def default_context_builder(cls):
|
||||
cls._current_profile = cls._pick_profile()
|
||||
profile = cls._current_profile
|
||||
|
||||
context_builder_dict = {}
|
||||
context_builder_dict['viewport'] = dict(profile['viewport'])
|
||||
context_builder_dict['screen'] = dict(profile['viewport'])
|
||||
context_builder_dict['user_agent'] = profile['user_agent']
|
||||
context_builder_dict['viewport'] = {
|
||||
'width' : cls._win_width,
|
||||
'height' : cls._win_height
|
||||
}
|
||||
context_builder_dict['screen'] = {
|
||||
'width' : cls._win_width,
|
||||
'height' : cls._win_height
|
||||
}
|
||||
context_builder_dict['user_agent'] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"
|
||||
context_builder_dict['locale'] = 'ko-KR'
|
||||
context_builder_dict['timezone_id']='Asia/Seoul'
|
||||
|
||||
|
|
@ -105,129 +48,6 @@ if (originalQuery) {
|
|||
cls._context = await cls._browser.new_context(**cls.default_context_builder())
|
||||
cls.is_ready = True
|
||||
|
||||
@classmethod
|
||||
async def _recreate_context(cls):
|
||||
"""네이버 안티봇 차단이 의심될 때 브라우저 컨텍스트를 새로 생성해 세션/핑거프린트를 초기화한다."""
|
||||
old_context = cls._context
|
||||
cls._context = await cls._browser.new_context(**cls.default_context_builder())
|
||||
if old_context:
|
||||
await old_context.close()
|
||||
|
||||
@classmethod
|
||||
async def _new_stealth_page(cls):
|
||||
"""스텔스 패치(webdriver 위장 등)와 sec-ch-ua 헤더가 적용된 새 page를 생성한다."""
|
||||
page = await cls._context.new_page()
|
||||
await page.add_init_script(cls._STEALTH_INIT_SCRIPT)
|
||||
if cls._current_profile:
|
||||
await page.set_extra_http_headers({
|
||||
'sec-ch-ua': cls._current_profile['sec_ch_ua']
|
||||
})
|
||||
return page
|
||||
|
||||
GRAPHQL_URL = "https://pcmap-api.place.naver.com/graphql"
|
||||
|
||||
@classmethod
|
||||
async def fetch_graphql(
|
||||
cls,
|
||||
place_id: str,
|
||||
payloads: list[dict],
|
||||
) -> list[dict | None] | None:
|
||||
"""실제 브라우저로 네이버 WTM 안티봇 캡차를 통과해 GraphQL 쿼리를 실행한다.
|
||||
|
||||
네이버 pcmap GraphQL은 두 헤더를 검사한다:
|
||||
- x-wtm-graphql : base64({"arg": place_id, "type": ..., "source": "place"})
|
||||
- x-wtm-ncaptcha-token : 캡차 JS가 생성 (요청마다 발급, 직접 생성 불가)
|
||||
둘 다 없으면 405(캡차)로 막힌다. 따라서 place 페이지를 실제로 로드해
|
||||
페이지가 자연 발생시키는 GraphQL 요청에서 두 헤더를 캡처한 뒤,
|
||||
같은 토큰으로 우리 쿼리들을 in-page fetch로 실행한다.
|
||||
|
||||
Args:
|
||||
place_id: 네이버 place ID
|
||||
payloads: GraphQL POST 본문 목록
|
||||
Returns:
|
||||
payload별 파싱 JSON 목록(실패 항목은 None). 토큰 캡처 실패 시 None.
|
||||
"""
|
||||
if not cls.is_ready:
|
||||
logger.warning("[NvMapPwScraper] fetch_graphql: scraper가 초기화되지 않았습니다")
|
||||
return None
|
||||
|
||||
page = await cls._new_stealth_page()
|
||||
captured: dict = {}
|
||||
|
||||
def on_request(req):
|
||||
if "/graphql" in req.url and req.method == "POST" and "tok" not in captured:
|
||||
tok = req.headers.get("x-wtm-ncaptcha-token")
|
||||
if tok:
|
||||
captured["tok"] = tok
|
||||
captured["wtm"] = req.headers.get("x-wtm-graphql")
|
||||
|
||||
page.on("request", on_request)
|
||||
|
||||
try:
|
||||
for t in ("place", "restaurant", "accommodation"):
|
||||
try:
|
||||
await page.goto(
|
||||
f"https://pcmap.place.naver.com/{t}/{place_id}/home",
|
||||
wait_until="domcontentloaded",
|
||||
timeout=30000,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"[NvMapPwScraper] goto {t} 실패: {e}")
|
||||
continue
|
||||
# 페이지가 GraphQL 요청을 보내며 토큰이 헤더에 실릴 때까지 대기 (최대 ~9초)
|
||||
for _ in range(30):
|
||||
if captured.get("tok"):
|
||||
break
|
||||
await page.wait_for_timeout(300)
|
||||
if captured.get("tok"):
|
||||
logger.info(f"[NvMapPwScraper] WTM 토큰 캡처 성공 (type={t})")
|
||||
break
|
||||
|
||||
if not captured.get("tok"):
|
||||
logger.warning("[NvMapPwScraper] WTM 토큰 캡처 실패")
|
||||
return None
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"x-wtm-graphql": captured["wtm"],
|
||||
"x-wtm-ncaptcha-token": captured["tok"],
|
||||
}
|
||||
|
||||
results: list[dict | None] = []
|
||||
for idx, payload in enumerate(payloads, start=1):
|
||||
r = await page.evaluate(
|
||||
"""async (a) => {
|
||||
const [url, body, hdr] = a;
|
||||
try {
|
||||
const r = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: hdr,
|
||||
body: JSON.stringify(body),
|
||||
credentials: 'include',
|
||||
});
|
||||
if (r.status !== 200) return {__err: r.status};
|
||||
return await r.json();
|
||||
} catch (e) { return {__err: String(e)}; }
|
||||
}""",
|
||||
[cls.GRAPHQL_URL, payload, headers],
|
||||
)
|
||||
if isinstance(r, dict) and "__err" in r:
|
||||
op = payload.get("operationName", "?")
|
||||
logger.warning(
|
||||
f"[NvMapPwScraper] graphql fetch 실패 "
|
||||
f"({idx}/{len(payloads)} {op}): {r['__err']}"
|
||||
)
|
||||
results.append(None)
|
||||
else:
|
||||
results.append(r)
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[NvMapPwScraper] fetch_graphql 오류: {e}")
|
||||
return None
|
||||
finally:
|
||||
await page.close()
|
||||
|
||||
def __init__(self):
|
||||
if not self.is_ready:
|
||||
raise Exception("nvMapScraper is not initiated")
|
||||
|
|
@ -240,255 +60,92 @@ if (originalQuery) {
|
|||
await self.page.close()
|
||||
|
||||
async def create_page(self):
|
||||
self.page = await self._new_stealth_page()
|
||||
self.page = await self._context.new_page()
|
||||
await self.page.add_init_script(
|
||||
'''const defaultGetter = Object.getOwnPropertyDescriptor(
|
||||
Navigator.prototype,
|
||||
"webdriver"
|
||||
).get;
|
||||
defaultGetter.apply(navigator);
|
||||
defaultGetter.toString();
|
||||
Object.defineProperty(Navigator.prototype, "webdriver", {
|
||||
set: undefined,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: new Proxy(defaultGetter, {
|
||||
apply: (target, thisArg, args) => {
|
||||
Reflect.apply(target, thisArg, args);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
});
|
||||
const patchedGetter = Object.getOwnPropertyDescriptor(
|
||||
Navigator.prototype,
|
||||
"webdriver"
|
||||
).get;
|
||||
patchedGetter.apply(navigator);
|
||||
patchedGetter.toString();''')
|
||||
|
||||
await self.page.set_extra_http_headers({
|
||||
'sec-ch-ua': '\"Not?A_Brand\";v=\"99\", \"Chromium\";v=\"130\"'
|
||||
})
|
||||
await self.page.goto("http://google.com")
|
||||
|
||||
async def goto_url(self, url, wait_until="domcontentloaded", timeout=20000):
|
||||
page = self.page
|
||||
await page.goto(url, wait_until=wait_until, timeout=timeout)
|
||||
|
||||
@staticmethod
|
||||
def _clean_title(text: str) -> str:
|
||||
text = unescape(text) # HTML 엔티티 디코딩 (& → &)
|
||||
text = re.sub(r"<.*?>", "", text) # HTML 태그 제거
|
||||
return text.strip()
|
||||
async def get_place_id_url(self, selected):
|
||||
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}"
|
||||
|
||||
@staticmethod
|
||||
def _similarity(a: str, b: str) -> float:
|
||||
return SequenceMatcher(None, a, b).ratio()
|
||||
wait_first_start = time.perf_counter()
|
||||
|
||||
@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
|
||||
|
||||
@staticmethod
|
||||
def _parse_allsearch_candidates(body: dict) -> list[dict]:
|
||||
"""allSearch 응답 JSON에서 후보 목록을 추출한다."""
|
||||
place_list = (((body.get("result") or {}).get("place") or {}).get("list")) or []
|
||||
return [
|
||||
{
|
||||
"title": p.get("name") or "",
|
||||
"place_url": f"https://map.naver.com/p/entry/place/{p['id']}",
|
||||
"roadAddress": p.get("roadAddress") or "",
|
||||
"address": p.get("address") or "",
|
||||
}
|
||||
for p in place_list
|
||||
if p.get("id")
|
||||
]
|
||||
|
||||
def _select_best_candidate(self, candidates: list[dict], title: str, address: str) -> dict | None:
|
||||
"""이름 유사도(70%)와 주소 유사도(30%)를 함께 고려해 최적 후보를 선택한다.
|
||||
|
||||
주소 없이 업체명만으로 검색한 경우(3차 폴백)는 이름 유사도만 사용한다.
|
||||
"""
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
for c in candidates:
|
||||
name_score = self._similarity(title, self._clean_title(c["title"]))
|
||||
cand_addr = c.get("roadAddress") or c.get("address") or ""
|
||||
addr_score = self._similarity(address, cand_addr) if address and cand_addr else 0.0
|
||||
c["_name_score"] = name_score
|
||||
c["_addr_score"] = addr_score
|
||||
c["_total_score"] = name_score * 0.7 + addr_score * 0.3 if address else name_score
|
||||
|
||||
return max(candidates, key=lambda c: c["_total_score"])
|
||||
|
||||
async def _capture_allsearch(self, url: str, timeout_s: float = 5.0) -> list[dict]:
|
||||
"""검색 페이지가 자연 발생시키는 allSearch API 응답을 캡처해 후보 목록으로 변환한다.
|
||||
|
||||
pcmap iframe 렌더링을 기다릴 필요가 없어 경쟁 조건이 없고, 업체명 단독
|
||||
검색처럼 iframe 자체가 생성되지 않는 케이스에서도 동작한다.
|
||||
"""
|
||||
captured: list[dict] = []
|
||||
|
||||
def on_response(resp):
|
||||
# GET 요청의 실제 응답만 캡처 (OPTIONS preflight 등 오탐 방지)
|
||||
if "allSearch" in resp.url and resp.request.method == "GET":
|
||||
async def _consume():
|
||||
try:
|
||||
captured.append(await resp.json())
|
||||
except Exception:
|
||||
pass
|
||||
asyncio.create_task(_consume())
|
||||
|
||||
self.page.on("response", on_response)
|
||||
try:
|
||||
try:
|
||||
await self.goto_url(url, wait_until="domcontentloaded", timeout=self._timeout * 1000)
|
||||
except Exception:
|
||||
logger.error("[ERROR] Can't Finish domcontentloaded")
|
||||
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")
|
||||
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
if "/place/" in self.page.url or captured:
|
||||
break
|
||||
await self.page.wait_for_timeout(200)
|
||||
finally:
|
||||
self.page.remove_listener("response", on_response)
|
||||
|
||||
if not captured:
|
||||
return []
|
||||
return self._parse_allsearch_candidates(captured[0])
|
||||
wait_first_time = (time.perf_counter() - wait_first_start) * 1000
|
||||
|
||||
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}"
|
||||
logger.debug(f"[DEBUG] Try {count+1} : Wait for perfect matching : {wait_first_time}ms")
|
||||
|
||||
# 1순위: allSearch API 응답 캡처 (iframe 렌더링 대기 불필요, 업체명 단독 검색도 지원)
|
||||
candidates = await self._capture_allsearch(url)
|
||||
if "/place/" in self.page.url:
|
||||
return self.page.url
|
||||
|
||||
if candidates:
|
||||
best = self._select_best_candidate(candidates, title, address)
|
||||
logger.info(
|
||||
f"[AUTO-SELECT] '{title}' → '{best['title']}' "
|
||||
f"(name={best['_name_score']:.2f}, addr={best['_addr_score']:.2f}) {best['place_url']}"
|
||||
)
|
||||
return best['place_url']
|
||||
|
||||
# 2순위(안전망): allSearch 캡처 실패 시 기존 iframe HTML 파싱 방식으로 폴백
|
||||
# ── 잠시 비활성화 ──
|
||||
# logger.warning("[FALLBACK] allSearch 캡처 실패 → iframe 파싱 방식 시도")
|
||||
# iframe_candidates = []
|
||||
# for _ in range(3): # 500ms × 3 = 최대 1.5초
|
||||
# if "/place/" in self.page.url:
|
||||
# return self.page.url
|
||||
# iframe_candidates = await self._extract_candidates_from_list_page()
|
||||
# if iframe_candidates:
|
||||
# break
|
||||
# await self.page.wait_for_timeout(500)
|
||||
#
|
||||
# if iframe_candidates:
|
||||
# best = self._select_best_candidate(iframe_candidates, title, address)
|
||||
# logger.info(
|
||||
# f"[AUTO-SELECT-IFRAME] '{title}' → '{best['title']}' "
|
||||
# f"(name={best['_name_score']:.2f}, addr={best['_addr_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
|
||||
logger.debug(f"[DEBUG] Try {count+1} : url place id not found, retry for forced collect answer")
|
||||
wait_forced_correct_start = time.perf_counter()
|
||||
|
||||
async def get_place_id_url(self, selected):
|
||||
title = self._clean_title(selected['title'])
|
||||
address = self._clean_title(selected.get('roadAddress', selected['address']))
|
||||
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")
|
||||
|
||||
# 1차 시도: 원본 주소 + 업체명
|
||||
logger.debug(f"[DEBUG] 1차 시도 - address: {address}")
|
||||
result = await self._try_search(address, title)
|
||||
if result:
|
||||
return result
|
||||
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")
|
||||
|
||||
# 2차 시도: 정제 주소 + 업체명
|
||||
refined = self._refine_address(address)
|
||||
if refined != address:
|
||||
await self.page.wait_for_timeout(self._retry_delay_ms)
|
||||
logger.info(f"[REFINE] 주소 정제: '{address}' → '{refined}'")
|
||||
result = await self._try_search(refined, title)
|
||||
if result:
|
||||
return result
|
||||
if "/place/" in self.page.url:
|
||||
return self.page.url
|
||||
count += 1
|
||||
|
||||
# 3차 시도: 업체명만으로 검색
|
||||
await self.page.wait_for_timeout(self._retry_delay_ms)
|
||||
logger.info(f"[RETRY] 업체명만으로 재시도: '{title}'")
|
||||
result = await self._try_search("", title)
|
||||
if result:
|
||||
return result
|
||||
logger.error("[ERROR] Not found url for {selected}")
|
||||
|
||||
# 1~3차 모두 실패 → 네이버 안티봇 차단 의심, 컨텍스트 재생성 후 원본 조건으로 1회 재시도
|
||||
logger.warning(f"[BLOCK-SUSPECTED] 3회 시도 모두 실패 → 컨텍스트 재생성 후 재시도: '{title}'")
|
||||
await self.page.close()
|
||||
await self._recreate_context()
|
||||
await self.create_page()
|
||||
result = await self._try_search(address, title)
|
||||
if result:
|
||||
return result
|
||||
return None # 404
|
||||
|
||||
logger.error(f"[ERROR] Not found url for {selected}")
|
||||
return None
|
||||
|
||||
# if (count == self._max_retry / 2):
|
||||
# raise Exception("Failed to identify place id. loading timeout")
|
||||
# else:
|
||||
# raise Exception("Failed to identify place id. item is ambiguous")
|
||||
|
|
|
|||
|
|
@ -16,10 +16,6 @@ class GraphQLException(Exception):
|
|||
"""GraphQL 요청 실패 시 발생하는 예외"""
|
||||
pass
|
||||
|
||||
class URLNotFoundException(Exception):
|
||||
"""Place ID 발견 불가능 시 발생하는 예외"""
|
||||
pass
|
||||
|
||||
|
||||
class CrawlingTimeoutException(Exception):
|
||||
"""크롤링 타임아웃 시 발생하는 예외"""
|
||||
|
|
@ -35,13 +31,6 @@ class NvMapScraper:
|
|||
GRAPHQL_URL: str = "https://pcmap-api.place.naver.com/graphql"
|
||||
REQUEST_TIMEOUT = 120 # 초
|
||||
data_source_identifier = "nv"
|
||||
SUPPLEMENT_THRESHOLD = 30 # 업체 사진이 이 수 미만일 때 방문자 사진으로 보충
|
||||
# 방문자 사진 보충 시의 합산 상한 (필터링·정책상 제한).
|
||||
# 업체 제공 사진은 필터링 면제 대상이므로 이 상한과 무관하게 수집분을 전부 사용한다
|
||||
# (수집 자체는 BIZ_MAX_PAGES가 상한 — 초과 시 warning 로그 발생).
|
||||
MAX_IMAGES = 50
|
||||
BIZ_PAGE_SIZE = 20 # getPhotoViewerItems 'biz' 커서의 페이지당 사진 수
|
||||
BIZ_MAX_PAGES = 5 # 업체 사진 수집 상한 (5×20=100장, 도달 시 warning)
|
||||
OVERVIEW_QUERY: str = """
|
||||
query getAccommodation($id: String!, $deviceType: String) {
|
||||
business: placeDetail(input: {id: $id, isNx: true, deviceType: $deviceType}) {
|
||||
|
|
@ -57,37 +46,8 @@ query getAccommodation($id: String!, $deviceType: String) {
|
|||
conveniences
|
||||
visitorReviewsTotal
|
||||
}
|
||||
menus {
|
||||
name
|
||||
price
|
||||
description
|
||||
recommend
|
||||
}
|
||||
images { images { origin url } }
|
||||
}
|
||||
}"""
|
||||
|
||||
PHOTO_VIEWER_QUERY: str = """
|
||||
query getPhotoViewerItems($input: PhotoViewerInput) {
|
||||
photoViewer(input: $input) {
|
||||
photos {
|
||||
originalUrl
|
||||
}
|
||||
}
|
||||
}"""
|
||||
|
||||
REVIEW_STATS_QUERY: str = """
|
||||
query getVisitorReviewStats($id: String!) {
|
||||
visitorReviewStats(input: {businessId: $id}) {
|
||||
analysis {
|
||||
votedKeyword {
|
||||
details {
|
||||
code
|
||||
displayName
|
||||
count
|
||||
}
|
||||
}
|
||||
}
|
||||
cpImages(source: [ugcImage]) { images { origin url } }
|
||||
}
|
||||
}"""
|
||||
|
||||
|
|
@ -105,13 +65,9 @@ query getVisitorReviewStats($id: String!) {
|
|||
)
|
||||
self.scrap_type: str | None = None
|
||||
self.rawdata: dict | None = None
|
||||
self.image_link_list: list[dict] | None = None # [{"preview": str, "original": str}]
|
||||
self.owner_images: list[dict] | None = None # 업체 등록 사진 (마케팅 필터 제외 대상)
|
||||
self.extra_photo_urls: list[dict] | None = None # 방문자/AI View 보충 사진 (마케팅 필터 대상)
|
||||
self.image_link_list: list[str] | None = None
|
||||
self.base_info: dict | None = None
|
||||
self.facility_info: str | None = None
|
||||
self.voted_keyword_stats: list[dict] | None = None # 키워드 투표 집계 (displayName, count)
|
||||
self.menu_info: list[dict] | None = None # 메뉴 목록 (name, price, description, recommend)
|
||||
|
||||
def _get_request_headers(self) -> dict:
|
||||
headers = self.DEFAULT_HEADERS.copy()
|
||||
|
|
@ -119,98 +75,6 @@ query getVisitorReviewStats($id: String!) {
|
|||
headers["Cookie"] = self.cookies
|
||||
return headers
|
||||
|
||||
_GIF_PATTERN = re.compile(r"\.gif(?:/|$)", re.IGNORECASE)
|
||||
|
||||
@classmethod
|
||||
def _is_gif_url(cls, url: str) -> bool:
|
||||
"""URL의 확장자가 gif인지 확인한다.
|
||||
|
||||
네이버 CDN은 리사이즈 파라미터를 확장자 뒤에 경로 세그먼트로 붙인다
|
||||
(예: ".../3982000924.gif/500x500") — endswith(".gif")로는 잡히지 않으므로
|
||||
경로 세그먼트 단위로 ".gif" 뒤에 "/" 또는 문자열 끝이 오는지 검사한다.
|
||||
"""
|
||||
return bool(cls._GIF_PATTERN.search(url))
|
||||
|
||||
@staticmethod
|
||||
def _extract_origins(image_node: dict) -> list[dict]:
|
||||
"""GraphQL 이미지 노드({ images: [{origin, url}, ...] })에서 {preview, original} 목록을 추출한다.
|
||||
origin = 원본(업로드용), url = CDN 리사이즈(미리보기용)
|
||||
"""
|
||||
items = image_node.get("images") or []
|
||||
return [
|
||||
{"preview": item.get("url") or item["origin"], "original": item["origin"]}
|
||||
for item in items
|
||||
if item.get("origin") and not NvMapScraper._is_gif_url(item["origin"])
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _extract_photo_viewer_urls(photo_viewer_raw: dict | None) -> list[dict]:
|
||||
"""getPhotoViewerItems 응답에서 {preview, original} 목록을 추출한다.
|
||||
photoViewer는 썸네일 필드가 없으므로 preview = original 동일하게 사용.
|
||||
"""
|
||||
photos = ((photo_viewer_raw or {}).get("data") or {}).get("photoViewer") or {}
|
||||
items = photos.get("photos") or []
|
||||
return [
|
||||
{"preview": p["originalUrl"], "original": p["originalUrl"]}
|
||||
for p in items
|
||||
if p.get("originalUrl") and not NvMapScraper._is_gif_url(p["originalUrl"])
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def _build_biz_payload(cls, place_id: str, page: int) -> dict:
|
||||
"""'biz' 커서(업체 등록 사진)의 page번째(0-base) 페이지 요청 payload를 만든다."""
|
||||
start_index = page * cls.BIZ_PAGE_SIZE
|
||||
cursor: dict = {"id": "biz"}
|
||||
if start_index > 0:
|
||||
cursor.update({
|
||||
"startIndex": start_index,
|
||||
"hasNext": True,
|
||||
"lastCursor": str(start_index),
|
||||
})
|
||||
return {
|
||||
"operationName": "getPhotoViewerItems",
|
||||
"variables": {
|
||||
"input": {
|
||||
"businessId": place_id,
|
||||
"cursors": [cursor],
|
||||
"dateRange": "",
|
||||
"excludeAuthorIds": [],
|
||||
"excludeClipIds": [],
|
||||
"excludeSection": [],
|
||||
}
|
||||
},
|
||||
"query": cls.PHOTO_VIEWER_QUERY,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _raw_photo_count(photo_viewer_raw: dict | None) -> int:
|
||||
"""getPhotoViewerItems 응답의 사진 수(gif 필터링 전 원본 기준)를 반환한다.
|
||||
|
||||
페이지네이션 계속 여부 판단용 — gif가 걸러진 뒤의 수로 판단하면
|
||||
마지막 페이지가 아닌데도 조기 종료할 수 있어 원본 개수를 사용한다.
|
||||
"""
|
||||
photos = ((photo_viewer_raw or {}).get("data") or {}).get("photoViewer") or {}
|
||||
return len(photos.get("photos") or [])
|
||||
|
||||
@staticmethod
|
||||
def _dedup_by_original(images: list[dict]) -> list[dict]:
|
||||
"""{"preview","original"} dict 리스트를 original URL 기준으로 순서를 유지한 채 중복 제거한다."""
|
||||
seen: set[str] = set()
|
||||
result = []
|
||||
for img in images:
|
||||
original = img.get("original")
|
||||
if original and original not in seen:
|
||||
seen.add(original)
|
||||
result.append(img)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _interleave(a: list, b: list) -> list:
|
||||
"""두 리스트를 1:1 교차 병합한다. 남은 항목은 뒤에 붙인다."""
|
||||
result = [x for pair in zip(a, b) for x in pair]
|
||||
longer = a[len(b):] if len(a) > len(b) else b[len(a):]
|
||||
return result + longer
|
||||
|
||||
async def parse_url(self) -> str:
|
||||
"""URL에서 place ID를 추출합니다. 단축 URL인 경우 실제 URL로 변환합니다."""
|
||||
place_pattern = r"/place/(\d+)"
|
||||
|
|
@ -222,226 +86,37 @@ query getVisitorReviewStats($id: 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:
|
||||
# place.naver.com/{type}/{id} 형식 (예: m.place.naver.com/restaurant/11667161)
|
||||
type_match = re.search(r"place\.naver\.com/([a-zA-Z]+)/(\d+)", self.url)
|
||||
if type_match:
|
||||
return type_match.group(2)
|
||||
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()
|
||||
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"
|
||||
|
||||
# ── 빠른 경로: 직접 aiohttp 호출 (현재 비활성화) ──
|
||||
# 데이터센터 IP에서는 네이버 WTM 안티봇이 대부분 405(캡차)로 차단해
|
||||
# 시간만 버리므로 비활성화하고 곧바로 브라우저 경로를 탄다.
|
||||
# 네이버가 차단을 완화하거나 다른 IP에서 운영해 직접 호출 성공률이
|
||||
# 높아지면 아래 try 블록을 되살려 빠른 경로를 우선 시도하면 된다.
|
||||
# try:
|
||||
# data, fac_data, stats_data = await asyncio.gather(
|
||||
# self._call_get_accommodation(place_id),
|
||||
# self._get_facility_string(place_id),
|
||||
# self._call_get_review_stats(place_id),
|
||||
# )
|
||||
# self.scrap_type = "GraphQL"
|
||||
# except (GraphQLException, CrawlingTimeoutException) as e:
|
||||
# logger.info(f"[NvMapScraper] 직접 호출 실패({e}) → 브라우저 폴백 시도")
|
||||
# data, stats_data = await self._scrap_via_browser(place_id)
|
||||
# fac_data = None # 편의시설(HTML)은 폴백 경로에서 생략 (best-effort)
|
||||
# self.scrap_type = "GraphQL-Browser"
|
||||
|
||||
# ── 실제 브라우저로 WTM 캡차 우회 ──
|
||||
data, stats_data, extra_photo_urls, biz_photo_urls = await self._scrap_via_browser(place_id)
|
||||
# 편의시설은 HTML 페이지 파싱이라 GraphQL(WTM) 차단과 별개로 직접 시도 (best-effort, 실패 시 None)
|
||||
fac_data = await self._get_facility_string(place_id)
|
||||
self.scrap_type = "GraphQL-Browser"
|
||||
|
||||
self.rawdata = data
|
||||
# Naver 기준임, 구글 등 다른 데이터 소스의 경우 고유 Identifier 사용할 것.
|
||||
self.place_id = self.data_source_identifier + place_id
|
||||
self.rawdata["facilities"] = fac_data
|
||||
business = data["data"]["business"]
|
||||
|
||||
# 업체 등록 이미지 (마케팅 적합성 필터 제외 대상 — 자체 dedup).
|
||||
# placeDetail.images가 대표 사진 일부만 반환하는 경우가 있어 biz 커서 결과를 병합한다.
|
||||
self.owner_images = self._dedup_by_original(
|
||||
self._extract_origins(business.get("images") or {}) + biz_photo_urls
|
||||
)
|
||||
|
||||
# 방문자/AI View 보충 사진 (마케팅 적합성 필터 대상). owner에 이미 있는 원본은 제외.
|
||||
owner_originals = {img["original"] for img in self.owner_images}
|
||||
self.extra_photo_urls = self._dedup_by_original(
|
||||
[img for img in extra_photo_urls if img["original"] not in owner_originals]
|
||||
)
|
||||
|
||||
# 업체 사진이 임계값 미만이면 내부/외부/리뷰 사진으로 보충
|
||||
# (필터링 없는 기본 조립. 마케팅 적합성 필터를 적용하려면 호출측에서
|
||||
# owner_images / extra_photo_urls를 직접 사용해 image_filter.assemble_images로 재조립할 것.)
|
||||
# MAX_IMAGES 상한은 방문자 사진이 섞이는 보충 경로에만 적용하며(필터링·정책상 제한),
|
||||
# 업체 제공 사진만으로 구성되는 경우는 수집분(최대 BIZ_MAX_PAGES 페이지)을 전부 사용한다.
|
||||
if len(self.owner_images) < self.SUPPLEMENT_THRESHOLD:
|
||||
combined = self._dedup_by_original(self.owner_images + self.extra_photo_urls)
|
||||
logger.info(
|
||||
f"[NvMapScraper] 업체 사진 {len(self.owner_images)}장 < {self.SUPPLEMENT_THRESHOLD}장 "
|
||||
f"→ 보충 사진 {len(self.extra_photo_urls)}장 추가 (합산 {len(combined)}장, 상한 {self.MAX_IMAGES}장)"
|
||||
)
|
||||
self.image_link_list = combined[: self.MAX_IMAGES]
|
||||
else:
|
||||
self.image_link_list = list(self.owner_images)
|
||||
self.base_info = data["data"]["business"]["base"]
|
||||
self.facility_info = fac_data
|
||||
self.voted_keyword_stats = stats_data
|
||||
self.menu_info = business.get("menus") or None
|
||||
except GraphQLException:
|
||||
logger.debug("GraphQL failed, fallback to Playwright")
|
||||
self.scrap_type = "Playwright"
|
||||
pass # 나중에 pw 이용한 crawling으로 fallback 추가
|
||||
|
||||
return
|
||||
|
||||
async def _scrap_via_browser(self, place_id: str) -> tuple[dict, list[dict] | None, list[dict], list[dict]]:
|
||||
"""직접 호출이 WTM 캡차에 막힌 경우, 실제 브라우저로 GraphQL을 호출한다.
|
||||
|
||||
Returns:
|
||||
(overview_data, review_stats_details, extra_photo_urls, biz_photo_urls)
|
||||
|
||||
Raises:
|
||||
GraphQLException: 브라우저 폴백마저 실패한 경우
|
||||
"""
|
||||
from app.utils.nvMapPwScraper import NvMapPwScraper
|
||||
|
||||
overview_payload = {
|
||||
"operationName": "getAccommodation",
|
||||
"variables": {"id": place_id, "deviceType": "pc"},
|
||||
"query": self.OVERVIEW_QUERY,
|
||||
}
|
||||
stats_payload = {
|
||||
"operationName": "getVisitorReviewStats",
|
||||
"variables": {"id": place_id},
|
||||
"query": self.REVIEW_STATS_QUERY,
|
||||
}
|
||||
interior_payload = {
|
||||
"operationName": "getPhotoViewerItems",
|
||||
"variables": {
|
||||
"input": {
|
||||
"businessId": place_id,
|
||||
"cursors": [{"id": "aiView"}],
|
||||
"filter": "AI View",
|
||||
"subFilter": "INTERIOR",
|
||||
"dateRange": "",
|
||||
"excludeAuthorIds": [],
|
||||
"excludeClipIds": [],
|
||||
"excludeSection": [],
|
||||
}
|
||||
},
|
||||
"query": self.PHOTO_VIEWER_QUERY,
|
||||
}
|
||||
exterior_payload = {
|
||||
"operationName": "getPhotoViewerItems",
|
||||
"variables": {
|
||||
"input": {
|
||||
"businessId": place_id,
|
||||
"cursors": [{"id": "aiView"}],
|
||||
"filter": "AI View",
|
||||
"subFilter": "EXTERIOR",
|
||||
"dateRange": "",
|
||||
"excludeAuthorIds": [],
|
||||
"excludeClipIds": [],
|
||||
"excludeSection": [],
|
||||
}
|
||||
},
|
||||
"query": self.PHOTO_VIEWER_QUERY,
|
||||
}
|
||||
review_payload = {
|
||||
"operationName": "getPhotoViewerItems",
|
||||
"variables": {
|
||||
"input": {
|
||||
"businessId": place_id,
|
||||
"cursors": [{"id": "placeReview"}],
|
||||
"dateRange": "",
|
||||
"excludeAuthorIds": [],
|
||||
"excludeClipIds": [],
|
||||
"excludeSection": [],
|
||||
}
|
||||
},
|
||||
"query": self.PHOTO_VIEWER_QUERY,
|
||||
}
|
||||
# 업체 등록 사진. placeDetail.images는 대표 사진 일부(1~수 장)만 반환하는
|
||||
# 경우가 있어, 사진 탭이 실제 사용하는 'biz' 커서로 별도 조회해 보강한다.
|
||||
# biz 커서는 페이지당 BIZ_PAGE_SIZE(20)장이며 lastCursor가 단순 인덱스 문자열
|
||||
# ("20", "40")이라 이전 응답 없이도 모든 페이지를 미리 만들 수 있고, 범위를
|
||||
# 벗어난 페이지는 빈 목록을 반환하므로 블라인드 요청해도 안전하다.
|
||||
# 따라서 상한(BIZ_MAX_PAGES)까지의 전 페이지를 첫 배치에 한꺼번에 실어 보낸다
|
||||
# — 추가 왕복(페이지 재탐색 + WTM 토큰 재캡처)이 없고, 개별 페이지 실패가
|
||||
# 이후 페이지 수집을 막지 못한다.
|
||||
biz_payloads = [
|
||||
self._build_biz_payload(place_id, page) for page in range(self.BIZ_MAX_PAGES)
|
||||
]
|
||||
|
||||
payloads = [overview_payload, stats_payload, interior_payload, exterior_payload, review_payload, *biz_payloads]
|
||||
MAX_RETRY = 3
|
||||
results = None
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(1, MAX_RETRY + 1):
|
||||
try:
|
||||
results = await NvMapPwScraper.fetch_graphql(place_id, payloads)
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
logger.warning(f"[NvMapScraper] 브라우저 폴백 시도 {attempt}/{MAX_RETRY} 오류: {e}")
|
||||
if attempt < MAX_RETRY:
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
if results and results[0] is not None and "data" in results[0]:
|
||||
break
|
||||
logger.warning(f"[NvMapScraper] 브라우저 폴백 시도 {attempt}/{MAX_RETRY} 실패(405 등), 재시도")
|
||||
results = None
|
||||
if attempt < MAX_RETRY:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
if not results or results[0] is None or "data" not in results[0]:
|
||||
if last_error:
|
||||
raise GraphQLException(f"브라우저 폴백 실패: {last_error}")
|
||||
raise GraphQLException("브라우저 폴백 크롤링 실패 (WTM 토큰 또는 응답 없음)")
|
||||
|
||||
data = results[0]
|
||||
stats_data = None
|
||||
stats_raw = results[1] if len(results) > 1 else None
|
||||
if stats_raw:
|
||||
_vrs = (stats_raw.get("data") or {}).get("visitorReviewStats") or {}
|
||||
_analysis = _vrs.get("analysis") or {}
|
||||
_voted = _analysis.get("votedKeyword") or {}
|
||||
stats_data = _voted.get("details") or None
|
||||
|
||||
interior_urls = self._extract_photo_viewer_urls(results[2] if len(results) > 2 else None)
|
||||
exterior_urls = self._extract_photo_viewer_urls(results[3] if len(results) > 3 else None)
|
||||
review_urls = self._extract_photo_viewer_urls(results[4] if len(results) > 4 else None)
|
||||
|
||||
biz_pages = results[5:]
|
||||
biz_urls = [url for page_result in biz_pages for url in self._extract_photo_viewer_urls(page_result)]
|
||||
# 개별 페이지 실패(None)는 해당 20장만 누락되고 나머지 페이지는 영향 없다 (best-effort).
|
||||
failed_biz_pages = sum(1 for r in biz_pages if r is None)
|
||||
if failed_biz_pages:
|
||||
logger.warning(
|
||||
f"[NvMapScraper] 업체 사진 {failed_biz_pages}개 페이지 응답 실패 "
|
||||
f"— 페이지당 최대 {self.BIZ_PAGE_SIZE}장 누락 가능 (수집분으로 진행)"
|
||||
)
|
||||
# 마지막 페이지까지 가득 차 있으면 상한 밖에 사진이 더 있을 수 있다.
|
||||
if biz_pages and self._raw_photo_count(biz_pages[-1]) >= self.BIZ_PAGE_SIZE:
|
||||
logger.warning(
|
||||
f"[NvMapScraper] 업체 사진이 수집 상한(BIZ_MAX_PAGES={self.BIZ_MAX_PAGES}, "
|
||||
f"{self.BIZ_MAX_PAGES * self.BIZ_PAGE_SIZE}장)까지 가득 참 — 초과분은 수집되지 않음"
|
||||
)
|
||||
|
||||
extra_photo_urls = self._interleave(interior_urls, exterior_urls) + review_urls
|
||||
logger.info(
|
||||
f"[NvMapScraper] 보충 이미지 - 내부:{len(interior_urls)} 외부:{len(exterior_urls)} "
|
||||
f"리뷰:{len(review_urls)} / 업체(biz):{len(biz_urls)}"
|
||||
)
|
||||
|
||||
logger.info(f"[NvMapScraper] 브라우저 폴백 SUCCESS - place_id: {place_id}")
|
||||
return data, stats_data, extra_photo_urls, biz_urls
|
||||
|
||||
async def _call_get_accommodation(self, place_id: str) -> dict:
|
||||
"""GraphQL API를 호출하여 숙소 정보를 가져옵니다.
|
||||
|
||||
|
|
@ -470,14 +145,13 @@ query getVisitorReviewStats($id: String!) {
|
|||
async with session.post(
|
||||
self.GRAPHQL_URL,
|
||||
data=json_payload,
|
||||
headers=self._get_request_headers(),
|
||||
headers=self._get_request_headers()
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
logger.info(f"[NvMapScraper] SUCCESS - place_id: {place_id}")
|
||||
return await response.json()
|
||||
|
||||
# 405/429 등 실패: 네이버 WTM 안티봇 캡차 차단 (데이터센터 IP/지문 기반).
|
||||
# 직접 호출로는 통과 불가 → scrap()에서 브라우저 폴백으로 전환.
|
||||
# 실패 상태 코드
|
||||
logger.error(f"[NvMapScraper] Failed with status {response.status} - place_id: {place_id}")
|
||||
raise GraphQLException(
|
||||
f"Request failed with status {response.status}"
|
||||
|
|
@ -486,43 +160,13 @@ query getVisitorReviewStats($id: String!) {
|
|||
except (TimeoutError, asyncio.TimeoutError):
|
||||
logger.error(f"[NvMapScraper] Timeout - place_id: {place_id}")
|
||||
raise CrawlingTimeoutException(f"Request timed out after {self.REQUEST_TIMEOUT}s")
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f"[NvMapScraper] Client error: {e}")
|
||||
raise GraphQLException(f"Client error: {e}")
|
||||
|
||||
async def _call_get_review_stats(self, place_id: str) -> list[dict] | None:
|
||||
"""방문자 키워드 투표 집계를 가져옵니다.
|
||||
|
||||
Returns:
|
||||
[{"code": ..., "displayName": ..., "count": ...}, ...] 또는 None
|
||||
"""
|
||||
payload = {
|
||||
"operationName": "getVisitorReviewStats",
|
||||
"variables": {"id": place_id},
|
||||
"query": self.REVIEW_STATS_QUERY,
|
||||
}
|
||||
timeout = aiohttp.ClientTimeout(total=self.REQUEST_TIMEOUT)
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.post(
|
||||
self.GRAPHQL_URL,
|
||||
json=payload,
|
||||
headers=self._get_request_headers(),
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
logger.warning(f"[NvMapScraper] review stats failed: {response.status}")
|
||||
return None
|
||||
result = await response.json()
|
||||
_vrs = (result.get("data") or {}).get("visitorReviewStats") or {}
|
||||
_analysis = _vrs.get("analysis") or {}
|
||||
_voted = _analysis.get("votedKeyword") or {}
|
||||
return _voted.get("details") or None
|
||||
except Exception as e:
|
||||
logger.warning(f"[NvMapScraper] Failed to get review stats: {e}")
|
||||
return None
|
||||
|
||||
async def _get_facility_string(self, place_id: str) -> str | None:
|
||||
"""장소 페이지에서 편의시설 정보를 크롤링합니다. 숙소, 음식점 순으로 시도합니다.
|
||||
"""숙소 페이지에서 편의시설 정보를 크롤링합니다.
|
||||
|
||||
Args:
|
||||
place_id: 네이버 지도 장소 ID
|
||||
|
|
@ -530,17 +174,16 @@ query getVisitorReviewStats($id: String!) {
|
|||
Returns:
|
||||
편의시설 정보 문자열 또는 None
|
||||
"""
|
||||
place_types = ["place", "accommodation", "restaurant"]
|
||||
url = f"https://pcmap.place.naver.com/accommodation/{place_id}/home"
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
for place_type in place_types:
|
||||
url = f"https://pcmap.place.naver.com/{place_type}/{place_id}/home"
|
||||
async with session.get(url, headers=self._get_request_headers()) as response:
|
||||
soup = bs4.BeautifulSoup(await response.read(), "html.parser")
|
||||
c_elem = soup.find("span", "place_blind", string="편의")
|
||||
if c_elem:
|
||||
return c_elem.parent.parent.find("div").string
|
||||
return None
|
||||
async with session.get(url, headers=self._get_request_headers()) as response:
|
||||
soup = bs4.BeautifulSoup(await response.read(), "html.parser")
|
||||
c_elem = soup.find("span", "place_blind", string="편의")
|
||||
if c_elem:
|
||||
facilities = c_elem.parent.parent.find("div").string
|
||||
return facilities
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"[NvMapScraper] Failed to get facility info: {e}")
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -1,257 +0,0 @@
|
|||
import json
|
||||
import re
|
||||
from pydantic import BaseModel, ValidationError
|
||||
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):
|
||||
try:
|
||||
response = await self.client.beta.chat.completions.parse(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": content}],
|
||||
response_format=output_format
|
||||
)
|
||||
except (ValidationError, json.JSONDecodeError) as e:
|
||||
# 모델이 스키마에 맞지 않는 JSON을 반환한 경우 (예: trailing characters).
|
||||
# 확률적 출력 문제일 수 있으므로 재시도 대상에 포함한다.
|
||||
logger.warning(
|
||||
f"[ChatgptService({self.model_type})] Structured output parse failed "
|
||||
f"(attempt {attempt + 1}/{self.max_retries + 1}): {e}"
|
||||
)
|
||||
last_error = ChatGPTResponseError("parse_error", type(e).__name__, str(e))
|
||||
if attempt < self.max_retries:
|
||||
logger.info(f"[ChatgptService({self.model_type})] Retrying request...")
|
||||
continue
|
||||
# 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_multi_image(
|
||||
self,
|
||||
prompt_text: str,
|
||||
output_format: BaseModel,
|
||||
model: str,
|
||||
img_urls: List[str],
|
||||
image_detail_high: bool = True,
|
||||
) -> BaseModel:
|
||||
"""여러 이미지를 한 번에 보고 구조화 출력을 생성합니다 (썸네일 비전 선택용).
|
||||
|
||||
sheet 기반 Prompt를 거치지 않고 코드에서 조립한 프롬프트 텍스트와
|
||||
이미지 URL 리스트를 직접 받는다. 이미지들은 프롬프트에 나열된 순서와
|
||||
동일하게 첨부되므로, 프롬프트에서 "N번째 이미지"로 지칭할 수 있다.
|
||||
"""
|
||||
content = []
|
||||
for url in img_urls:
|
||||
content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": url,
|
||||
"detail": "high" if image_detail_high else "low",
|
||||
},
|
||||
})
|
||||
content.append({"type": "text", "text": prompt_text})
|
||||
|
||||
last_error = None
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
response = await self.client.beta.chat.completions.parse(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": content}],
|
||||
response_format=output_format,
|
||||
)
|
||||
except (ValidationError, json.JSONDecodeError) as e:
|
||||
logger.warning(
|
||||
f"[ChatgptService({self.model_type})] multi-image parse failed "
|
||||
f"(attempt {attempt + 1}/{self.max_retries + 1}): {e}"
|
||||
)
|
||||
last_error = ChatGPTResponseError("parse_error", type(e).__name__, str(e))
|
||||
if attempt < self.max_retries:
|
||||
continue
|
||||
raise last_error
|
||||
|
||||
choice = response.choices[0]
|
||||
if choice.finish_reason == "stop":
|
||||
return choice.message.parsed
|
||||
logger.warning(
|
||||
f"[ChatgptService({self.model_type})] multi-image unexpected finish_reason "
|
||||
f"(attempt {attempt + 1}/{self.max_retries + 1}): {choice.finish_reason}"
|
||||
)
|
||||
last_error = ChatGPTResponseError("failed", choice.finish_reason, "multi-image call failed")
|
||||
|
||||
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 = True
|
||||
) -> BaseModel:
|
||||
prompt_text = prompt.build_prompt(input_data, silent)
|
||||
|
||||
logger.debug(f"[ChatgptService({self.model_type})] Generated Prompt (length: {len(prompt_text)})")
|
||||
if not silent:
|
||||
logger.info(f"[ChatgptService({self.model_type})] Starting GPT request with structured output with model: {prompt.prompt_model}")
|
||||
|
||||
# GPT API 호출
|
||||
#parsed = await self._call_structured_output_with_response_gpt_api(prompt_text, prompt.prompt_output, prompt.prompt_model)
|
||||
# parsed = await self._call_pydantic_output(prompt_text, prompt.prompt_output_class, prompt.prompt_model, img_url, img_detail_high)
|
||||
parsed = await self._call_pydantic_output_chat_completion(prompt_text, prompt.prompt_output_class, prompt.prompt_model, img_url, img_detail_high)
|
||||
return parsed
|
||||
|
|
@ -1,123 +1,75 @@
|
|||
import re
|
||||
|
||||
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 *
|
||||
from functools import lru_cache
|
||||
|
||||
# {known_field} 형태만 치환하기 위한 패턴. JSON 예시 등 리터럴 중괄호는 보존된다.
|
||||
_PLACEHOLDER_RE = re.compile(r"\{(\w+)\}")
|
||||
|
||||
logger = get_logger("prompt")
|
||||
|
||||
_SCOPES = [
|
||||
"https://www.googleapis.com/auth/spreadsheets.readonly"
|
||||
]
|
||||
|
||||
_sheet_cache: dict[str, tuple[str, str]] = {}
|
||||
|
||||
|
||||
def _get_spreadsheet():
|
||||
creds = Credentials.from_service_account_file(
|
||||
prompt_settings.GOOGLE_SERVICE_ACCOUNT_JSON, scopes=_SCOPES
|
||||
)
|
||||
return gspread.authorize(creds).open_by_key(prompt_settings.PROMPT_SPREADSHEET)
|
||||
|
||||
|
||||
def _preload_all_sheets(sheet_names: list[str]):
|
||||
ranges = [f"{name}!B2:B3" for name in sheet_names]
|
||||
response = _get_spreadsheet().values_batch_get(ranges)
|
||||
for value_range in response["valueRanges"]:
|
||||
range_name = value_range.get("range", "")
|
||||
name = range_name.split("!")[0].strip("'")
|
||||
if name not in sheet_names:
|
||||
continue
|
||||
values = value_range.get("values", [])
|
||||
if len(values) < 2 or not values[0] or not values[1]:
|
||||
logger.warning(f"Sheet '{name}' has missing data, skipping preload")
|
||||
continue
|
||||
_sheet_cache[name] = (values[1][0], values[0][0]) # (B3=template, B2=model)
|
||||
|
||||
|
||||
def _read_sheet_data(sheet_name: str) -> tuple[str, str]:
|
||||
if sheet_name not in _sheet_cache:
|
||||
ws = _get_spreadsheet().worksheet(sheet_name)
|
||||
values = ws.batch_get(["B2:B3"])[0]
|
||||
_sheet_cache[sheet_name] = (values[1][0], values[0][0])
|
||||
return _sheet_cache[sheet_name]
|
||||
|
||||
|
||||
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 = _read_sheet_data(sheet_name)
|
||||
self.prompt_template = self.read_prompt()
|
||||
self.prompt_model = prompt_model
|
||||
|
||||
def build_prompt(self, input_data:dict, silent:bool = False) -> str:
|
||||
def _reload_prompt(self):
|
||||
self.prompt_template = self.read_prompt()
|
||||
|
||||
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)
|
||||
values = verified_input.model_dump()
|
||||
# 알려진 {필드}만 치환하고, 프롬프트 내 JSON 예시 등 리터럴 중괄호는 그대로 둔다.
|
||||
# (str.format은 모든 {..}를 필드로 해석해 리터럴 중괄호에서 깨지므로 사용하지 않음)
|
||||
build_template = _PLACEHOLDER_RE.sub(
|
||||
lambda m: str(values[m.group(1)]) if m.group(1) in values else m.group(0),
|
||||
self.prompt_template,
|
||||
)
|
||||
if not silent:
|
||||
logger.debug(f"build_template: {build_template}")
|
||||
logger.debug(f"input_data: {input_data}")
|
||||
build_template = self.prompt_template
|
||||
build_template = build_template.format(**verified_input.model_dump())
|
||||
logger.debug(f"build_template: {build_template}")
|
||||
logger.debug(f"input_data: {input_data}")
|
||||
return build_template
|
||||
|
||||
# 업종 분기는 각 프롬프트 내부에서 {industry} 변수로 처리하므로 시트는 타입별 1개로 통합.
|
||||
_preload_all_sheets([
|
||||
"marketing",
|
||||
"lyric",
|
||||
"subtitle",
|
||||
"yt_upload",
|
||||
"image_tag",
|
||||
])
|
||||
|
||||
|
||||
# 업종 분기는 프롬프트 내부 {industry} 변수로 처리하므로 타입별 단일 프롬프트만 둔다.
|
||||
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, industry: str = "") -> Prompt:
|
||||
# industry 인자는 캐시 구분/하위 호환용. 시트는 단일 'subtitle'로 통합됨.
|
||||
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()
|
||||
|
|
@ -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
|
||||
|
|
@ -1,219 +0,0 @@
|
|||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
from enum import StrEnum, auto
|
||||
|
||||
class SpaceType(StrEnum):
|
||||
"""이미지가 촬영된 공간·장소 유형. LLM이 이미지를 보고 해당하는 공간 태그를 선택하는 데 사용."""
|
||||
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()
|
||||
# 레스토랑(DVRT0001) 템플릿 어휘
|
||||
dining_hall = auto()
|
||||
table_setting = auto()
|
||||
private_room = auto()
|
||||
detail_cooking = auto()
|
||||
detail_plating = auto()
|
||||
detail_menu = auto()
|
||||
detail_dish_main = auto()
|
||||
detail_dish_side = auto()
|
||||
brand_summary = auto()
|
||||
# 카페(DVCF0001) 템플릿 어휘
|
||||
hall = auto()
|
||||
counter_bar = auto()
|
||||
seating_window = auto()
|
||||
seating_lounge = auto()
|
||||
signage = auto()
|
||||
detail_dessert = auto()
|
||||
detail_signature_drink = auto()
|
||||
# 관광지(DVAT0001) 템플릿 어휘
|
||||
entrance_gate = auto()
|
||||
exhibition_hall = auto()
|
||||
landmark_main = auto()
|
||||
landmark_detail = auto()
|
||||
night_view = auto()
|
||||
panorama_view = auto()
|
||||
photo_spot = auto()
|
||||
seasonal_scene = auto()
|
||||
walking_path = auto()
|
||||
# 미용실(DVSL0001) 템플릿 어휘
|
||||
mirror_detail = auto()
|
||||
styling_zone = auto()
|
||||
waiting_lounge = auto()
|
||||
# 병원(DVCL0001) 템플릿 어휘
|
||||
consultation_room = auto()
|
||||
detail_amenity = auto()
|
||||
equipment_zone = auto()
|
||||
recovery_room = auto()
|
||||
treatment_room = auto()
|
||||
waiting_area = auto()
|
||||
# 피트니스(DVFT0001) 템플릿 어휘
|
||||
apparatus_zone = auto()
|
||||
brand_sign = auto()
|
||||
detail_equipment = auto()
|
||||
locker_room = auto()
|
||||
powder_room = auto()
|
||||
pt_zone = auto()
|
||||
reformer_zone = auto()
|
||||
# 학원(DVAC0001) 템플릿 어휘
|
||||
classroom = auto()
|
||||
counseling_room = auto()
|
||||
detail_facility = auto()
|
||||
detail_interior_prop = auto()
|
||||
detail_materials = auto()
|
||||
library_corner = auto()
|
||||
study_room = 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()
|
||||
# 레스토랑(DVRT0001) 템플릿 어휘
|
||||
cooking_action = auto()
|
||||
menu_board = auto()
|
||||
triptych = auto()
|
||||
# 카페(DVCF0001) 템플릿 어휘
|
||||
beverage = auto()
|
||||
brewing_action = auto()
|
||||
dessert = auto()
|
||||
# 관광지(DVAT0001) 템플릿 어휘
|
||||
scenery = auto()
|
||||
structure = auto()
|
||||
# 병원(DVCL0001) 템플릿 어휘
|
||||
interior_clean = auto()
|
||||
medical_equipment = auto()
|
||||
# 피트니스(DVFT0001) 템플릿 어휘
|
||||
interior_scale = auto()
|
||||
pilates_apparatus = auto()
|
||||
training_action = auto()
|
||||
# 학원(DVAC0001) 템플릿 어휘
|
||||
facility_equipment = auto()
|
||||
learning_material = auto()
|
||||
study_scene = 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()
|
||||
cta = auto()
|
||||
|
||||
NARRATIVE_PHASE_LABEL: dict[NarrativePhase, str] = {
|
||||
NarrativePhase.intro: "첫인상(intro)",
|
||||
NarrativePhase.welcome: "진입(welcome)",
|
||||
NarrativePhase.core: "핵심(core)",
|
||||
NarrativePhase.highlight: "차별화(highlight)",
|
||||
NarrativePhase.support: "보조(support)",
|
||||
NarrativePhase.accent: "감성(accent)",
|
||||
NarrativePhase.cta: "행동유도(cta)",
|
||||
}
|
||||
|
||||
class NarrativePreference(BaseModel):
|
||||
"""이미지가 각 내러티브 단계에 얼마나 적합한지를 나타내는 점수 모델 (0.0 ~ 100.0).
|
||||
LLM이 이미지를 분석한 뒤 각 단계별 적합도를 수치로 반환하며,
|
||||
영상 편집기가 이 점수를 기반으로 컷 배치 순서를 자동 결정하는 데 사용."""
|
||||
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="감성/마무리 — 어떤 느낌인가 | 공간의 분위기와 톤을 전달하는 감성 디테일 컷. 직접적 정보 전달보다 **느낌과 무드**를 제공. 영상 사이사이에 삽입되어 완성도를 높이는 역할.")
|
||||
cta: float = Field(..., description="행동 유도 — 지금 예약/방문하고 싶게 만드는 컷 | 가격·혜택·한정 오퍼 등 직접적 행동을 촉구하거나, 마지막 장면에서 강한 인상을 남기는 이미지.")
|
||||
|
||||
# Input 정의
|
||||
class ImageTagPromptInput(BaseModel):
|
||||
"""이미지 태깅 프롬프트에 전달되는 입력 스키마.
|
||||
분석할 이미지 URL과 업종 정보, LLM이 선택 가능한 태그 후보 리스트를 포함."""
|
||||
img_url : str = Field(..., description="이미지 URL")
|
||||
industry : str = Field(default="", description = "업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general). 통합 프롬프트가 이 값으로 내부 분기")
|
||||
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="가능한 카메라 모션 리스트")
|
||||
|
||||
class MarketingFilterOutput(BaseModel):
|
||||
"""크롤링 단계에서 이미지의 마케팅(광고 영상) 사용 가능 여부만 판정하는 프롬프트의 출력 스키마.
|
||||
ImageTagPromptOutput과 달리 태깅(공간/피사체/카메라/모션/내러티브)은 포함하지 않는다."""
|
||||
marketing_acceptable: bool = Field(..., description="광고 영상 사용 가능 여부")
|
||||
reject_reason: str = Field(default="", description="거부 사유 (marketing_acceptable=false 시). 예: 저화질, 인물 신원 노출, 업종 부적합, 민감/의료 장면, 미성년자 식별 노출")
|
||||
|
||||
|
||||
# Output 정의
|
||||
class ImageTagPromptOutput(BaseModel):
|
||||
"""이미지 태깅 프롬프트의 LLM 출력 스키마.
|
||||
공간·피사체·카메라·모션 태그와 내러티브 적합도 점수를 담아 반환.
|
||||
마케팅 사용 가능 여부는 크롤링 단계(MarketingFilterOutput)에서 이미 판정되므로 포함하지 않는다."""
|
||||
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="이미지의 내러티브 상 점수")
|
||||
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
from pydantic import BaseModel, Field
|
||||
from typing import List, Literal, Optional
|
||||
|
||||
GENRE_VALUES = Literal["kpop", "pop", "ballad", "hip-hop", "rnb", "edm", "jazz", "rock"]
|
||||
from typing import List, Optional
|
||||
|
||||
# Input 정의
|
||||
class LyricPromptInput(BaseModel):
|
||||
|
|
@ -10,16 +8,10 @@ class LyricPromptInput(BaseModel):
|
|||
detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세")
|
||||
marketing_intelligence_summary : Optional[str] = Field(None, description = "마케팅 분석 정보 보고서")
|
||||
language : str= Field(..., description = "가사 언어")
|
||||
genre : str = Field(default="", description = "지정된 음악 장르 (kpop|pop|ballad|hip-hop|rnb|edm|jazz|rock). 비어있으면 GPT가 무드에 맞는 장르를 자유롭게 추천")
|
||||
exclude_genre : str = Field(default="", description = "재생성 시 직전에 사용된 장르. genre가 비어있는 경우(자동 선택) 이 장르는 추천에서 제외")
|
||||
industry : str = Field(default="", description = "업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general). 통합 프롬프트가 이 값으로 내부 분기")
|
||||
promotional_expression_example : str = Field(..., description = "판촉 가사 표현 예시")
|
||||
timing_rules : str = Field(..., description = "시간 제어문")
|
||||
|
||||
# Output 정의
|
||||
class LyricPromptOutput(BaseModel):
|
||||
lyric: str = Field(..., description="생성된 가사")
|
||||
suno_prompt: str = Field(..., description="Suno AI용 프롬프트")
|
||||
recommended_genre: GENRE_VALUES = Field(
|
||||
...,
|
||||
description="가사에 사용된(또는 가장 잘 맞는) 음악 장르. genre 입력이 주어졌다면 그 값을 그대로 반환하고, "
|
||||
"비어있었다면 exclude_genre를 제외한 나머지 중 가사 무드에 가장 잘 맞는 장르를 직접 선택해 반환",
|
||||
)
|
||||
|
|
@ -6,18 +6,14 @@ class MarketingPromptInput(BaseModel):
|
|||
customer_name : str = Field(..., description = "마케팅 대상 사업체 이름")
|
||||
region : str = Field(..., description = "마케팅 대상 지역")
|
||||
detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세")
|
||||
industry : str = Field(default="", description = "업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general). 통합 프롬프트가 이 값으로 내부 분기")
|
||||
category : str = Field(default="", description = "네이버 지도 원본 카테고리 텍스트 (예: 펜션, 한식). industry 모듈 선택에는 관여하지 않고 세부 묘사의 참고 정보로만 사용")
|
||||
facility_info : str = Field(default="", description = "편의시설 정보 (네이버 지도 크롤링, 없으면 빈 문자열)")
|
||||
voted_keywords : str = Field(default="", description = "방문자 키워드 투표 상위 5개 (쉼표 구분, 없으면 빈 문자열)")
|
||||
# 메뉴 전달은 잠정 보류 (사이드 메뉴가 분석에 과도한 영향을 주는 문제). 재도입 시 주석 해제.
|
||||
# menu_info : str = Field(default="", 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="마케팅 포지션 핵심 가치")
|
||||
|
|
@ -26,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="셀링포인트 카테고리(한글)")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from pydantic import BaseModel, create_model, Field
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
from functools import lru_cache
|
||||
|
||||
# Input 정의
|
||||
|
|
@ -9,9 +9,8 @@ class SubtitlePromptInput(BaseModel):
|
|||
pitching_tag_list_string : str = Field(..., description="필요한 피칭 레이블 리스트 stringify")
|
||||
customer_name : str = Field(..., description = "마케팅 대상 사업체 이름")
|
||||
detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세")
|
||||
language : str = Field(default="Korean", description="자막 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)")
|
||||
industry : str = Field(default="", description = "업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general). 통합 프롬프트가 이 값으로 내부 분기")
|
||||
|
||||
#subtillecars :
|
||||
# Output 정의
|
||||
class PitchingOutput(BaseModel):
|
||||
pitching_tag: str = Field(..., description="피칭 레이블")
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ class YTUploadPromptInput(BaseModel):
|
|||
marketing_intelligence_summary : Optional[str] = Field(None, description = "마케팅 분석 정보 보고서")
|
||||
language : str= Field(..., description = "영상 언어")
|
||||
target_keywords: List[str] = Field(..., description="태그 키워드 리스트")
|
||||
industry : str = Field(default="", description = "업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general). 통합 프롬프트가 이 값으로 내부 분기")
|
||||
|
||||
# Output 정의
|
||||
class YTUploadPromptOutput(BaseModel):
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
당신은 숙박 브랜드 숏폼 영상의 자막 콘텐츠를 추출하는 전문가입니다.
|
||||
|
||||
입력으로 주어지는 **1) 5가지 기준의 레이어 이름 리스트**와 **2) 마케팅 인텔리전스 분석 결과(JSON)**를 바탕으로, 각 레이어 이름의 의미에 정확히 1:1 매칭되는 텍스트 콘텐츠만을 추출하세요.
|
||||
|
||||
분석 결과에 없는 정보는 절대 지어내거나 추론하지 마세요. 오직 제공된 JSON 데이터 내에서만 텍스트를 구성해야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 레이어 네이밍 규칙 해석 및 매핑 가이드
|
||||
|
||||
입력되는 모든 레이어 이름은 예외 없이 `<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 내의 다른 소구점이나 데이터를 추출**하여 내용이 중복되지 않도록 해야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 콘텐츠 추출 시 주의사항
|
||||
|
||||
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자 내로 구성하세요.
|
||||
|
||||
---
|
||||
|
||||
## 3. 출력 결과 포맷 및 예시
|
||||
|
||||
입력된 레이어 이름 순서에 맞춰, 매핑된 텍스트 콘텐츠만 작성하세요. (반드시 intro, core, highlight, outro 등 모든 씬 단계가 명확하게 매핑되어야 합니다.)
|
||||
|
||||
### 입력 레이어 리스트 예시 및 출력 예시
|
||||
|
||||
| Layer Name | Text Content |
|
||||
|---|---|
|
||||
| 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 | 강원 홍천군 화촌면 |
|
||||
|
||||
|
||||
# 입력
|
||||
**입력 1: 레이어 이름 리스트**
|
||||
{pitching_tag_list_string}
|
||||
|
||||
**입력 2: 마케팅 인텔리전스 JSON**
|
||||
{marketing_intelligence}
|
||||
|
||||
**입력 3: 비즈니스 정보 **
|
||||
Business Name: {customer_name}
|
||||
Region Details: {detail_region_info}
|
||||
|
||||
|
||||
|
||||
|
|
@ -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 brand’s 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 2–3 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 1–2 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: 45–65 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 (3–4 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 3–5 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 (1–2)
|
||||
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.
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import copy
|
||||
import re
|
||||
import time
|
||||
import json
|
||||
from typing import Literal, Any
|
||||
|
|
@ -7,101 +6,25 @@ 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")
|
||||
|
||||
# 비한국어 출력에서 번역 누락(한글 잔존)을 탐지하기 위한 패턴
|
||||
_HANGUL_RE = re.compile(r"[가-힣]")
|
||||
|
||||
# 한글 잔존 시 GPT 재호출 최대 횟수 (최초 호출 포함)
|
||||
_MAX_LANGUAGE_ATTEMPTS = 3
|
||||
|
||||
|
||||
class SubtitleContentsGenerator():
|
||||
def __init__(self):
|
||||
self.chatgpt_service = ChatgptService(timeout=60.0)
|
||||
self.chatgpt_service = ChatgptService()
|
||||
|
||||
@staticmethod
|
||||
def _find_hangul_leftovers(output_data: SubtitlePromptOutput) -> list[str]:
|
||||
"""비한국어 출력에서 한글이 남아 있는 pitching_tag 목록을 반환합니다.
|
||||
|
||||
프롬프트가 '한국어로 생성 후 {language}로 번역'하는 2단계 구조라서,
|
||||
항목 수가 많으면 일부가 번역되지 않은 채(한국어/혼합 문장) 돌아오는
|
||||
사례가 있어 코드 레벨에서 검증한다.
|
||||
"""
|
||||
return [
|
||||
result.pitching_tag
|
||||
for result in output_data.pitching_results
|
||||
if _HANGUL_RE.search(result.pitching_data)
|
||||
]
|
||||
|
||||
async def generate_subtitle_contents(self, marketing_intelligence : dict[str, Any], pitching_label_list : list[Any], customer_name : str, detail_region_info : str, language : str = "Korean", industry: 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}, "
|
||||
f"language: {language}, "
|
||||
f"industry: {industry}"
|
||||
)
|
||||
|
||||
dynamic_subtitle_prompt = create_dynamic_subtitle_prompt(len(pitching_label_list), industry)
|
||||
async def generate_subtitle_contents(self, marketing_intelligence : dict[str, Any], pitching_label_list : list[Any], customer_name : str, detail_region_info : str) -> SubtitlePromptOutput:
|
||||
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)
|
||||
input_data = {
|
||||
"marketing_intelligence" : marketing_intel_string,
|
||||
"marketing_intelligence" : marketing_intel_string ,
|
||||
"pitching_tag_list_string" : pitching_label_string,
|
||||
"customer_name" : customer_name,
|
||||
"detail_region_info" : detail_region_info,
|
||||
"language" : language,
|
||||
"industry": industry, # 가사/영상 파이프라인에서 전달된 업종 enum
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f"[SubtitleContentsGenerator] GPT 호출 시작 - model: {dynamic_subtitle_prompt.prompt_model}"
|
||||
)
|
||||
|
||||
# 비한국어 언어는 번역 누락(한글 잔존) 검증 후 필요 시 재호출.
|
||||
# 전부 실패하면 잔존 건수가 가장 적은 시도를 채택한다 (영상 생성 자체는 진행).
|
||||
output_data = None
|
||||
best_output = None
|
||||
best_leftover_count: int | None = None
|
||||
for lang_attempt in range(1, _MAX_LANGUAGE_ATTEMPTS + 1):
|
||||
candidate = await self.chatgpt_service.generate_structured_output(dynamic_subtitle_prompt, input_data)
|
||||
|
||||
if language == "Korean":
|
||||
output_data = candidate
|
||||
break
|
||||
|
||||
leftovers = self._find_hangul_leftovers(candidate)
|
||||
if not leftovers:
|
||||
output_data = candidate
|
||||
break
|
||||
|
||||
logger.warning(
|
||||
f"[SubtitleContentsGenerator] 번역 누락(한글 잔존) {len(leftovers)}건 "
|
||||
f"(attempt {lang_attempt}/{_MAX_LANGUAGE_ATTEMPTS}) - language: {language}, "
|
||||
f"tags: {leftovers}"
|
||||
)
|
||||
if best_leftover_count is None or len(leftovers) < best_leftover_count:
|
||||
best_output = candidate
|
||||
best_leftover_count = len(leftovers)
|
||||
|
||||
if output_data is None:
|
||||
logger.error(
|
||||
f"[SubtitleContentsGenerator] 모든 시도에서 한글 잔존 - "
|
||||
f"최소 잔존 {best_leftover_count}건 결과 채택 - language: {language}"
|
||||
)
|
||||
output_data = best_output
|
||||
|
||||
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]}"
|
||||
)
|
||||
output_data = await self.chatgpt_service.generate_structured_output(dynamic_subtitle_prompt, input_data)
|
||||
return output_data
|
||||
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue