uploader-bot/app/api/fastapi_content_routes.py

479 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
FastAPI маршруты для управления контентом
Критически важные эндпоинты для web2-client совместимости
"""
import asyncio
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Any
from uuid import UUID, uuid4
from fastapi import APIRouter, HTTPException, Request, Depends, UploadFile, File
from fastapi.responses import JSONResponse, StreamingResponse
from sqlalchemy import select, update, delete, and_, or_, func
from sqlalchemy.orm import selectinload
from pydantic import BaseModel, Field
from app.core.config import get_settings
from app.core.database import db_manager, get_cache_manager
from app.core.logging import get_logger
from app.core.models.content_models import StoredContent as Content, UserContent as ContentMetadata
from app.core.models.user import User
from app.api.fastapi_middleware import get_current_user, require_auth
# Initialize router
router = APIRouter(prefix="", tags=["content"])
logger = get_logger(__name__)
settings = get_settings()
# Pydantic models
class ContentViewRequest(BaseModel):
"""Модель для просмотра контента (совместимость с web2-client)"""
pass
class NewContentRequest(BaseModel):
"""Модель для создания нового контента"""
title: str = Field(..., min_length=1, max_length=200)
content: str = Field(..., min_length=1)
image: str = Field(..., min_length=1)
description: str = Field(..., max_length=1000)
hashtags: List[str] = Field(default=[])
price: str = Field(..., min_length=1)
resaleLicensePrice: str = Field(default="0")
allowResale: bool = Field(default=False)
authors: List[str] = Field(default=[])
royaltyParams: List[Dict[str, Any]] = Field(default=[])
downloadable: bool = Field(default=True)
class PurchaseContentRequest(BaseModel):
"""Модель для покупки контента"""
content_address: str = Field(..., min_length=1)
license_type: str = Field(..., pattern="^(listen|resale)$")
class ContentResponse(BaseModel):
"""Модель ответа с информацией о контенте"""
address: str
amount: str
payload: str
@router.get("/content.view/{content_id}")
async def view_content(
content_id: str,
request: Request,
current_user: User = Depends(get_current_user)
):
"""
Просмотр контента - критически важный эндпоинт для web2-client
Эквивалент GET /content.view/{id} из web2-client/src/shared/services/content/index.ts
"""
try:
# Проверка авторизации
auth_token = request.headers.get('authorization')
if not auth_token and not current_user:
# Для совместимости с web2-client, проверяем localStorage token из headers
auth_token = request.headers.get('authorization')
if not auth_token:
raise HTTPException(status_code=401, detail="Authentication required")
# Валидация content_id
try:
content_uuid = UUID(content_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid content ID format")
# Кэширование
cache_manager = await get_cache_manager()
cache_key = f"content_view:{content_id}"
cached_content = await cache_manager.get(cache_key)
if cached_content:
await logger.ainfo(
"Content view (cached)",
content_id=content_id,
user_id=str(current_user.id) if current_user else "anonymous"
)
return cached_content
async with db_manager.get_session() as session:
# Загрузка контента с метаданными
stmt = (
select(Content)
.options(
selectinload(Content.metadata),
selectinload(Content.access_controls)
)
.where(Content.id == content_uuid)
)
result = await session.execute(stmt)
content = result.scalar_one_or_none()
if not content:
raise HTTPException(status_code=404, detail="Content not found")
# Проверка доступа
has_access = await _check_content_access(content, current_user, session)
if not has_access:
raise HTTPException(status_code=403, detail="Access denied")
# Формирование ответа (совместимость с web2-client)
content_data = {
"id": str(content.id),
"title": content.title,
"description": content.description,
"content_type": content.content_type,
"file_size": content.file_size,
"status": content.status,
"visibility": content.visibility,
"tags": content.tags or [],
"created_at": content.created_at.isoformat(),
"updated_at": content.updated_at.isoformat(),
"user_id": str(content.user_id),
"file_url": f"/api/v1/content/{content_id}/download",
"preview_url": f"/api/v1/content/{content_id}/preview",
"metadata": {
"duration": getattr(content, 'duration', None),
"bitrate": getattr(content, 'bitrate', None),
"format": content.content_type
}
}
# Кэшируем на 10 минут
await cache_manager.set(cache_key, content_data, ttl=600)
# Обновляем статистику просмотров
await _update_view_stats(content_id, current_user)
await logger.ainfo(
"Content viewed successfully",
content_id=content_id,
user_id=str(current_user.id) if current_user else "anonymous"
)
return content_data
except HTTPException:
raise
except Exception as e:
await logger.aerror(
"Content view failed",
content_id=content_id,
error=str(e)
)
raise HTTPException(status_code=500, detail="Failed to load content")
@router.post("/blockchain.sendNewContentMessage", response_model=ContentResponse)
async def send_new_content_message(
request: Request,
content_data: NewContentRequest,
current_user: User = Depends(require_auth)
):
"""
Создание нового контента - критически важный эндпоинт для web2-client
Эквивалент useCreateNewContent из web2-client
"""
try:
await logger.ainfo("Content creation started", step="begin", user_id=str(current_user.id))
# Проверка квот пользователя
await logger.ainfo("Getting cache manager", step="cache_init")
cache_manager = await get_cache_manager()
await logger.ainfo("Cache manager obtained", step="cache_ready")
quota_key = f"user:{current_user.id}:content_quota"
daily_content = await cache_manager.get(quota_key, default=0)
await logger.ainfo("Quota checked", step="quota_check", daily_content=daily_content)
if daily_content >= settings.MAX_CONTENT_PER_DAY:
raise HTTPException(status_code=429, detail="Daily content creation limit exceeded")
# Валидация данных контента
if not content_data.title or not content_data.content:
raise HTTPException(status_code=400, detail="Title and content are required")
# Валидация цены
try:
price_nanotons = int(content_data.price)
if price_nanotons < 0:
raise ValueError("Price cannot be negative")
except ValueError:
raise HTTPException(status_code=400, detail="Invalid price format")
async with db_manager.get_session() as session:
# Создание записи контента
new_content = Content(
id=uuid4(),
user_id=current_user.id,
title=content_data.title,
description=content_data.description,
content_type="application/json", # Для метаданных
status="pending",
visibility="public" if not content_data.price or price_nanotons == 0 else "premium",
tags=content_data.hashtags,
file_size=len(content_data.content.encode('utf-8'))
)
session.add(new_content)
await session.commit()
await session.refresh(new_content)
# Создание метаданных
content_metadata = ContentMetadata(
content_id=new_content.id,
metadata_type="blockchain_content",
data={
"content": content_data.content,
"image": content_data.image,
"price": content_data.price,
"resaleLicensePrice": content_data.resaleLicensePrice,
"allowResale": content_data.allowResale,
"authors": content_data.authors,
"royaltyParams": content_data.royaltyParams,
"downloadable": content_data.downloadable
}
)
session.add(content_metadata)
await session.commit()
# Обновляем квоту
await cache_manager.increment(quota_key, ttl=86400)
# Генерируем blockchain payload для TON
blockchain_payload = await _generate_blockchain_payload(
content_id=str(new_content.id),
price=content_data.price,
metadata=content_data.__dict__
)
await logger.ainfo(
"New content message created",
content_id=str(new_content.id),
user_id=str(current_user.id),
title=content_data.title,
price=content_data.price
)
# Ответ в формате, ожидаемом web2-client
return ContentResponse(
address=settings.TON_CONTRACT_ADDRESS or "EQC_CONTRACT_ADDRESS",
amount=str(settings.TON_DEPLOY_FEE or "50000000"), # 0.05 TON в наноTON
payload=blockchain_payload
)
except HTTPException:
raise
except Exception as e:
await logger.aerror(
"New content creation failed",
user_id=str(current_user.id),
error=str(e)
)
raise HTTPException(status_code=500, detail="Failed to create content")
@router.post("/blockchain.sendPurchaseContentMessage", response_model=ContentResponse)
async def send_purchase_content_message(
request: Request,
purchase_data: PurchaseContentRequest,
current_user: User = Depends(require_auth)
):
"""
Покупка контента - критически важный эндпоинт для web2-client
Эквивалент usePurchaseContent из web2-client
"""
try:
content_address = purchase_data.content_address
license_type = purchase_data.license_type
# Валидация адреса контента
if not content_address:
raise HTTPException(status_code=400, detail="Content address is required")
# Поиск контента по адресу (или ID)
async with db_manager.get_session() as session:
# Пытаемся найти по UUID
content = None
try:
content_uuid = UUID(content_address)
stmt = select(Content).where(Content.id == content_uuid)
result = await session.execute(stmt)
content = result.scalar_one_or_none()
except ValueError:
# Если не UUID, ищем по другим полям
stmt = select(Content).where(Content.blockchain_address == content_address)
result = await session.execute(stmt)
content = result.scalar_one_or_none()
if not content:
raise HTTPException(status_code=404, detail="Content not found")
# Проверка, что пользователь не владелец контента
if content.user_id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot purchase own content")
# Получаем метаданные для определения цены
metadata_stmt = select(ContentMetadata).where(
ContentMetadata.content_id == content.id,
ContentMetadata.metadata_type == "blockchain_content"
)
metadata_result = await session.execute(metadata_stmt)
metadata = metadata_result.scalar_one_or_none()
if not metadata:
raise HTTPException(status_code=404, detail="Content metadata not found")
# Определяем цену в зависимости от типа лицензии
content_data = metadata.data
if license_type == "listen":
price = content_data.get("price", "0")
elif license_type == "resale":
price = content_data.get("resaleLicensePrice", "0")
if not content_data.get("allowResale", False):
raise HTTPException(status_code=400, detail="Resale not allowed for this content")
else:
raise HTTPException(status_code=400, detail="Invalid license type")
# Валидация цены
try:
price_nanotons = int(price)
if price_nanotons < 0:
raise ValueError("Invalid price")
except ValueError:
raise HTTPException(status_code=400, detail="Invalid content price")
# Генерируем blockchain payload для покупки
purchase_payload = await _generate_purchase_payload(
content_id=str(content.id),
content_address=content_address,
license_type=license_type,
price=price,
buyer_id=str(current_user.id)
)
await logger.ainfo(
"Purchase content message created",
content_id=str(content.id),
content_address=content_address,
license_type=license_type,
price=price,
buyer_id=str(current_user.id)
)
# Ответ в формате, ожидаемом web2-client
return ContentResponse(
address=content_address,
amount=price,
payload=purchase_payload
)
except HTTPException:
raise
except Exception as e:
await logger.aerror(
"Purchase content failed",
content_address=purchase_data.content_address,
user_id=str(current_user.id),
error=str(e)
)
raise HTTPException(status_code=500, detail="Failed to create purchase message")
# Helper functions
async def _check_content_access(content: Content, user: Optional[User], session) -> bool:
"""Проверка доступа к контенту"""
# Публичный контент доступен всем
if content.visibility == "public":
return True
# Владелец всегда имеет доступ
if user and content.user_id == user.id:
return True
# Премиум контент требует покупки
if content.visibility == "premium":
if not user:
return False
# Проверяем, покупал ли пользователь этот контент
# Здесь должна быть проверка в таблице покупок
return False
# Приватный контент доступен только владельцу
return False
async def _update_view_stats(content_id: str, user: Optional[User]) -> None:
"""Обновление статистики просмотров"""
try:
cache_manager = await get_cache_manager()
# Обновляем счетчики просмотров
today = datetime.utcnow().date().isoformat()
stats_key = f"content_views:{content_id}:{today}"
await cache_manager.increment(stats_key, ttl=86400)
if user:
user_views_key = f"user_content_views:{user.id}:{today}"
await cache_manager.increment(user_views_key, ttl=86400)
except Exception as e:
await logger.awarning(
"Failed to update view stats",
content_id=content_id,
error=str(e)
)
async def _generate_blockchain_payload(content_id: str, price: str, metadata: Dict[str, Any]) -> str:
"""Генерация payload для blockchain транзакции создания контента"""
import base64
import json
payload_data = {
"action": "create_content",
"content_id": content_id,
"price": price,
"timestamp": datetime.utcnow().isoformat(),
"metadata": {
"title": metadata.get("title"),
"description": metadata.get("description"),
"hashtags": metadata.get("hashtags", []),
"authors": metadata.get("authors", []),
"downloadable": metadata.get("downloadable", True)
}
}
# Кодируем в base64 для TON
payload_json = json.dumps(payload_data, separators=(',', ':'))
payload_base64 = base64.b64encode(payload_json.encode()).decode()
return payload_base64
async def _generate_purchase_payload(
content_id: str,
content_address: str,
license_type: str,
price: str,
buyer_id: str
) -> str:
"""Генерация payload для blockchain транзакции покупки контента"""
import base64
import json
payload_data = {
"action": "purchase_content",
"content_id": content_id,
"content_address": content_address,
"license_type": license_type,
"price": price,
"buyer_id": buyer_id,
"timestamp": datetime.utcnow().isoformat()
}
# Кодируем в base64 для TON
payload_json = json.dumps(payload_data, separators=(',', ':'))
payload_base64 = base64.b64encode(payload_json.encode()).decode()
return payload_base64