137 lines
5.7 KiB
Python
137 lines
5.7 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""콘텐츠(피드)·생성·좋아요·업장명 자동완성 라우터."""
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from .. import credit_service, jobs
|
|
from ..config import GEMINI_API_KEY, SCENARIO_ENGINE, settings
|
|
from ..content_sync import sync_output
|
|
from ..database import get_session
|
|
from ..deps import Pagination, get_current_user, get_current_user_optional, get_pagination
|
|
from ..models import Comment, Content, Like, User
|
|
from ..schemas import CreateReq, FeedItem, PaginatedResponse
|
|
|
|
router = APIRouter(prefix="/api", tags=["Content"])
|
|
|
|
|
|
@router.get("/feed", response_model=PaginatedResponse[FeedItem])
|
|
async def feed(
|
|
sort: str = Query("latest", pattern="^(latest|likes)$"),
|
|
owner: str | None = None, # "me" → 내 채널
|
|
pg: Pagination = Depends(get_pagination),
|
|
user: User | None = Depends(get_current_user_optional),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
like_ct = (
|
|
select(func.count(Like.id)).where(Like.content_id == Content.id).correlate(Content).scalar_subquery()
|
|
)
|
|
cmt_ct = (
|
|
select(func.count(Comment.id))
|
|
.where(Comment.content_id == Content.id, Comment.is_deleted == False) # noqa: E712
|
|
.correlate(Content).scalar_subquery()
|
|
)
|
|
where = [Content.is_deleted == False] # noqa: E712
|
|
if owner == "me":
|
|
if user is None:
|
|
return PaginatedResponse.create([], 0, pg.page, pg.page_size)
|
|
where.append(Content.owner_uuid == user.user_uuid)
|
|
|
|
total = (await session.execute(select(func.count(Content.id)).where(*where))).scalar() or 0
|
|
order = like_ct.desc() if sort == "likes" else Content.created_at.desc()
|
|
rows = (
|
|
await session.execute(
|
|
select(Content, like_ct.label("likes"), cmt_ct.label("comments"))
|
|
.where(*where).order_by(order).offset(pg.offset).limit(pg.page_size)
|
|
)
|
|
).all()
|
|
|
|
ids = [r[0].id for r in rows]
|
|
liked = set()
|
|
if user and ids:
|
|
liked = set(
|
|
(await session.execute(
|
|
select(Like.content_id).where(Like.user_uuid == user.user_uuid, Like.content_id.in_(ids))
|
|
)).scalars().all()
|
|
)
|
|
|
|
items = [
|
|
FeedItem(
|
|
id=c.id, sc=c.scenario, title=c.title, video=c.video, views=c.views,
|
|
likes=likes, comments=comments, liked=(c.id in liked),
|
|
mine=(user is not None and c.owner_uuid == user.user_uuid), createdAt=c.created_at,
|
|
)
|
|
for (c, likes, comments) in rows
|
|
]
|
|
return PaginatedResponse.create(items, total, pg.page, pg.page_size)
|
|
|
|
|
|
@router.post("/create")
|
|
async def create(body: CreateReq, user: User = Depends(get_current_user)):
|
|
if body.scenario not in SCENARIO_ENGINE:
|
|
raise HTTPException(400, f"알 수 없는 시나리오: {body.scenario}")
|
|
if not body.input.strip():
|
|
raise HTTPException(400, "링크 또는 업장명이 필요합니다.")
|
|
if not GEMINI_API_KEY:
|
|
raise HTTPException(400, "GEMINI_API_KEY 가 설정되지 않았습니다.")
|
|
if user.credits < settings.CREDITS_PER_VIDEO:
|
|
raise HTTPException(402, "크레딧이 부족합니다. 충전 후 이용해주세요.")
|
|
job = jobs.create_job(
|
|
body.scenario, body.input.strip(),
|
|
max(4, min(20, body.scenes)), max(20, min(90, body.seconds)),
|
|
owner_uuid=user.user_uuid,
|
|
)
|
|
return {"id": job["id"], "status": job["status"]}
|
|
|
|
|
|
@router.get("/jobs/{jid}")
|
|
async def job_status(jid: str, session: AsyncSession = Depends(get_session)):
|
|
job = jobs.get_job(jid)
|
|
if not job:
|
|
raise HTTPException(404, "잡을 찾을 수 없습니다.")
|
|
# 완료 최초 관측 시 1회: 새 output → content 삽입 + 크레딧 차감
|
|
if job["status"] == "done" and not job["finalized"]:
|
|
job["finalized"] = True
|
|
try:
|
|
await sync_output(session, owner_uuid=job["owner_uuid"])
|
|
if job["owner_uuid"]:
|
|
await credit_service.deduct(session, job["owner_uuid"], settings.CREDITS_PER_VIDEO, reason="영상 생성")
|
|
await session.commit()
|
|
except Exception:
|
|
await session.rollback()
|
|
return {
|
|
"id": job["id"], "status": job["status"], "step": job["step"],
|
|
"stepName": jobs.STEP_NAMES[job["step"] - 1] if job["step"] else "대기 중",
|
|
"output": job["output"], "error": job["error"], "log": job["log"][-15:],
|
|
}
|
|
|
|
|
|
@router.post("/content/{cid}/like")
|
|
async def toggle_like(cid: str, user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session)):
|
|
c = await session.get(Content, cid)
|
|
if c is None or c.is_deleted:
|
|
raise HTTPException(404, "콘텐츠를 찾을 수 없습니다.")
|
|
existing = (
|
|
await session.execute(select(Like).where(Like.content_id == cid, Like.user_uuid == user.user_uuid))
|
|
).scalar_one_or_none()
|
|
if existing:
|
|
await session.delete(existing)
|
|
liked = False
|
|
else:
|
|
session.add(Like(content_id=cid, user_uuid=user.user_uuid))
|
|
liked = True
|
|
await session.commit()
|
|
count = (await session.execute(select(func.count(Like.id)).where(Like.content_id == cid))).scalar() or 0
|
|
return {"liked": liked, "likes": count}
|
|
|
|
|
|
@router.get("/search/place", tags=["Search"])
|
|
async def search_place(query: str = Query(..., min_length=2)):
|
|
"""업장명 → 네이버 지도 후보 목록(동명 업장 주소로 구분). 키 불필요(Playwright).
|
|
|
|
각 후보는 place_url 을 가지므로, 프론트에서 하나를 고르면 그 URL 로 정확히 크롤링한다.
|
|
"""
|
|
from .. import place_service
|
|
items = await place_service.search_places(query, limit=8)
|
|
return {"query": query, "count": len(items), "items": items}
|