833c604fd7
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 32s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 38s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 58s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 1m4s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m0s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m12s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m51s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m6s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 2m10s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 2m39s
299 lines
11 KiB
Python
Executable File
299 lines
11 KiB
Python
Executable File
"""视频分享 API 路由."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Optional
|
|
|
|
from app.api.routes._helpers import format_utc_datetime
|
|
from app.auth import AuthenticatedUser, get_current_user
|
|
from app.core.storage import OSSStorageService, get_storage_service
|
|
from app.dependencies import get_db_session, get_generated_video_repository
|
|
from app.schemas.video_share import (
|
|
CreateShareRequest,
|
|
ShareAccessResponse,
|
|
ShareListResponse,
|
|
ShareMetaResponse,
|
|
ShareResponse,
|
|
UpdateShareRequest,
|
|
VerifySharePasswordRequest,
|
|
)
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
|
from sqlalchemy.orm import Session
|
|
|
|
from packages.adapters.sqlalchemy_impl.video_share_repository import (
|
|
SQLAlchemyVideoShareRepository,
|
|
)
|
|
from packages.application.video_share.commands import (
|
|
CreateShareCommand,
|
|
UpdateShareCommand,
|
|
)
|
|
from packages.application.video_share.use_cases import (
|
|
AccessShareUseCase,
|
|
CreateShareUseCase,
|
|
GetShareByTokenUseCase,
|
|
InvalidPasswordError,
|
|
ListSharesByUserUseCase,
|
|
ListSharesByVideoUseCase,
|
|
NotFoundError,
|
|
PasswordRequiredError,
|
|
RecordShareDownloadUseCase,
|
|
RevokeShareUseCase,
|
|
ShareExpiredError,
|
|
UpdateShareUseCase,
|
|
VideoNotFoundError,
|
|
)
|
|
from packages.ports.generated_video_repository import GeneratedVideoRepository
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _get_share_repository(
|
|
session: Session = Depends(get_db_session),
|
|
) -> SQLAlchemyVideoShareRepository:
|
|
return SQLAlchemyVideoShareRepository(session)
|
|
|
|
|
|
def _to_share_response(share) -> ShareResponse:
|
|
return ShareResponse(
|
|
id=share.id,
|
|
video_id=share.video_id,
|
|
share_token=share.share_token,
|
|
has_password=share.has_password,
|
|
expires_at=share.expires_at,
|
|
view_count=share.view_count,
|
|
download_count=share.download_count,
|
|
is_active=share.is_active,
|
|
created_at=format_utc_datetime(share.created_at),
|
|
updated_at=format_utc_datetime(share.updated_at),
|
|
)
|
|
|
|
|
|
# ── 用户侧:创建/管理分享 ──────────────────────────────────
|
|
|
|
|
|
@router.post("/videos/{video_id}/share", response_model=ShareResponse)
|
|
def create_share(
|
|
video_id: str,
|
|
request: CreateShareRequest,
|
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
|
|
video_repo: GeneratedVideoRepository = Depends(get_generated_video_repository),
|
|
) -> ShareResponse:
|
|
"""为视频创建分享链接."""
|
|
use_case = CreateShareUseCase(share_repo, video_repo)
|
|
try:
|
|
share = use_case.execute(
|
|
CreateShareCommand(
|
|
video_id=video_id,
|
|
user_id=authenticated_user.user.id,
|
|
password=request.password,
|
|
expires_at=request.expires_at,
|
|
)
|
|
)
|
|
except VideoNotFoundError as e:
|
|
raise HTTPException(status_code=404, detail=str(e)) from e
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
|
|
|
logger.info(
|
|
"Share created: video_id=%s share_id=%s token=%s user=%s",
|
|
video_id,
|
|
share.id,
|
|
share.share_token,
|
|
authenticated_user.user.id,
|
|
)
|
|
return _to_share_response(share)
|
|
|
|
|
|
@router.get("/videos/{video_id}/shares", response_model=ShareListResponse)
|
|
def list_video_shares(
|
|
video_id: str,
|
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
|
|
) -> ShareListResponse:
|
|
"""获取某个视频的所有分享记录."""
|
|
use_case = ListSharesByVideoUseCase(share_repo)
|
|
items = use_case.execute(video_id, authenticated_user.user.id)
|
|
return ShareListResponse(
|
|
items=[_to_share_response(s) for s in items],
|
|
total=len(items),
|
|
skip=0,
|
|
limit=len(items),
|
|
)
|
|
|
|
|
|
@router.get("/shares", response_model=ShareListResponse)
|
|
def list_user_shares(
|
|
skip: int = Query(0, ge=0),
|
|
limit: int = Query(20, ge=1, le=100),
|
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
|
|
) -> ShareListResponse:
|
|
"""获取用户创建的所有分享记录."""
|
|
use_case = ListSharesByUserUseCase(share_repo)
|
|
items, total = use_case.execute(authenticated_user.user.id, skip=skip, limit=limit)
|
|
return ShareListResponse(
|
|
items=[_to_share_response(s) for s in items],
|
|
total=total,
|
|
skip=skip,
|
|
limit=limit,
|
|
)
|
|
|
|
|
|
@router.patch("/shares/{share_id}", response_model=ShareResponse)
|
|
def update_share(
|
|
share_id: str,
|
|
request: UpdateShareRequest,
|
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
|
|
) -> ShareResponse:
|
|
"""更新分享配置(密码、有效期等)."""
|
|
use_case = UpdateShareUseCase(share_repo)
|
|
try:
|
|
share = use_case.execute(
|
|
UpdateShareCommand(
|
|
share_id=share_id,
|
|
user_id=authenticated_user.user.id,
|
|
password=request.password,
|
|
expires_at=request.expires_at,
|
|
)
|
|
)
|
|
except NotFoundError as e:
|
|
raise HTTPException(status_code=404, detail=str(e)) from e
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
|
return _to_share_response(share)
|
|
|
|
|
|
@router.delete(
|
|
"/shares/{share_id}",
|
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
response_model=None,
|
|
response_class=Response,
|
|
)
|
|
def revoke_share(
|
|
share_id: str,
|
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
|
|
) -> Response:
|
|
"""撤销/删除分享链接."""
|
|
use_case = RevokeShareUseCase(share_repo)
|
|
try:
|
|
use_case.execute(share_id, authenticated_user.user.id)
|
|
except NotFoundError as e:
|
|
raise HTTPException(status_code=404, detail=str(e)) from e
|
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
|
# ── 公开侧:访问分享内容(无需登录) ──────────────────────
|
|
|
|
|
|
@router.get("/share/{token}/meta", response_model=ShareMetaResponse)
|
|
def get_share_meta(
|
|
token: str,
|
|
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
|
|
video_repo: GeneratedVideoRepository = Depends(get_generated_video_repository),
|
|
storage: OSSStorageService = Depends(get_storage_service),
|
|
) -> ShareMetaResponse:
|
|
"""获取分享元信息(不需要密码,用于分享页加载前判断)。"""
|
|
use_case = GetShareByTokenUseCase(share_repo)
|
|
try:
|
|
share = use_case.execute(token)
|
|
except (NotFoundError, ShareExpiredError) as e:
|
|
raise HTTPException(status_code=404, detail="分享链接不存在或已失效") from e
|
|
|
|
video = video_repo.get(share.video_id)
|
|
video_name = video.name if video else ""
|
|
video_duration = video.duration if video else 0.0
|
|
thumbnail_url = None
|
|
if video and video.thumbnail_url:
|
|
try:
|
|
thumbnail_url = storage.get_download_url(video.thumbnail_url)
|
|
except Exception:
|
|
thumbnail_url = video.thumbnail_url
|
|
|
|
return ShareMetaResponse(
|
|
share_token=share.share_token,
|
|
has_password=share.has_password,
|
|
is_expired=share.is_expired,
|
|
is_active=share.is_active,
|
|
video_name=video_name,
|
|
video_duration=video_duration,
|
|
thumbnail_url=thumbnail_url,
|
|
created_at=format_utc_datetime(share.created_at),
|
|
)
|
|
|
|
|
|
@router.post("/share/{token}/access", response_model=ShareAccessResponse)
|
|
def access_share(
|
|
token: str,
|
|
request: Optional[VerifySharePasswordRequest] = None,
|
|
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
|
|
video_repo: GeneratedVideoRepository = Depends(get_generated_video_repository),
|
|
storage: OSSStorageService = Depends(get_storage_service),
|
|
) -> ShareAccessResponse:
|
|
"""访问分享内容(验证密码后返回视频信息+播放/下载地址)。"""
|
|
use_case = AccessShareUseCase(share_repo, video_repo)
|
|
password = request.password if request else None
|
|
try:
|
|
result = use_case.execute(token, password=password)
|
|
except NotFoundError as e:
|
|
raise HTTPException(status_code=404, detail="分享链接不存在或已失效") from e
|
|
except ShareExpiredError as e:
|
|
raise HTTPException(status_code=410, detail="分享链接已过期或已撤销") from e
|
|
except PasswordRequiredError as e:
|
|
raise HTTPException(status_code=403, detail="需要访问密码") from e
|
|
except InvalidPasswordError as e:
|
|
raise HTTPException(status_code=403, detail="密码错误") from e
|
|
except VideoNotFoundError as e:
|
|
raise HTTPException(status_code=404, detail="视频不存在") from e
|
|
|
|
# 生成下载URL
|
|
download_url = None
|
|
if result.video.file_url:
|
|
try:
|
|
download_url = storage.get_download_url(result.video.file_url)
|
|
except Exception:
|
|
download_url = result.video.file_url
|
|
|
|
# 缩略图URL
|
|
thumbnail_url = None
|
|
if result.video.thumbnail_url:
|
|
try:
|
|
thumbnail_url = storage.get_download_url(result.video.thumbnail_url)
|
|
except Exception:
|
|
thumbnail_url = result.video.thumbnail_url
|
|
|
|
return ShareAccessResponse(
|
|
share=_to_share_response(result.share),
|
|
video_name=result.video.name,
|
|
video_duration=result.video.duration,
|
|
video_size=result.video.file_size,
|
|
thumbnail_url=thumbnail_url,
|
|
download_url=download_url,
|
|
password_verified=result.password_verified,
|
|
)
|
|
|
|
|
|
@router.post("/share/{token}/download")
|
|
def record_share_download(
|
|
token: str,
|
|
request: Optional[VerifySharePasswordRequest] = None,
|
|
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
|
|
) -> dict:
|
|
"""记录分享下载(下载计数+1)。"""
|
|
use_case = RecordShareDownloadUseCase(share_repo)
|
|
password = request.password if request else None
|
|
try:
|
|
use_case.execute(token, password=password)
|
|
except NotFoundError as e:
|
|
raise HTTPException(status_code=404, detail="分享链接不存在或已失效") from e
|
|
except ShareExpiredError as e:
|
|
raise HTTPException(status_code=410, detail="分享链接已过期或已撤销") from e
|
|
except InvalidPasswordError as e:
|
|
raise HTTPException(status_code=403, detail="密码错误") from e
|
|
return {"success": True}
|