479 lines
19 KiB
Python
479 lines
19 KiB
Python
"""
|
||
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 |