""" 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