Files
CI Bot 7680247a25
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 / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m45s
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 / Validate - Migration (alembic) (push) Successful in 2m6s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m57s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m5s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m38s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m25s
CI/CD Pipeline / Integration Tests (push) Successful in 2m23s
CI/CD Pipeline / Unit Tests (push) Successful in 5m20s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 8m13s
CI/CD Pipeline / Build Staging API Image (push) Successful in 17m2s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m10s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 39s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 54s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m7s
style: auto-format with black + isort + prettier
2026-07-24 00:20:47 +00:00

223 lines
7.1 KiB
Python
Executable File
Raw Permalink 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.
"""视频分享 Use cases."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import List, Optional
from packages.application.video_share.commands import (
CreateShareCommand,
UpdateShareCommand,
)
from packages.domain.exceptions import NotFoundError
from packages.domain.generated_video import GeneratedVideo
from packages.domain.video_share import VideoShare
from packages.ports.generated_video_repository import GeneratedVideoRepository
from packages.ports.video_share_repository import VideoShareRepositoryPort
class VideoNotFoundError(Exception):
"""视频不存在."""
pass
class ShareExpiredError(Exception):
"""分享已过期或已撤销."""
pass
class PasswordRequiredError(Exception):
"""需要访问密码."""
pass
class InvalidPasswordError(Exception):
"""密码错误."""
pass
@dataclass
class ShareAccessResult:
"""分享访问结果(验证通过后返回视频信息+分享记录)."""
share: VideoShare
video: GeneratedVideo
password_verified: bool = True
class CreateShareUseCase:
"""创建视频分享链接."""
def __init__(
self,
share_repository: VideoShareRepositoryPort,
video_repository: GeneratedVideoRepository,
) -> None:
self.share_repo = share_repository
self.video_repo = video_repository
def execute(self, command: CreateShareCommand) -> VideoShare:
# 校验视频存在且属于该用户
video = self.video_repo.get(command.video_id)
if video is None:
raise VideoNotFoundError(f"Video {command.video_id} not found")
# 用 user_id 校验(视频的user_id需要匹配)
if hasattr(video, "user_id") and video.user_id and video.user_id != command.user_id:
raise VideoNotFoundError("Video not found")
share = VideoShare.create(
video_id=command.video_id,
user_id=command.user_id,
password=command.password,
expires_at=command.expires_at,
)
return self.share_repo.create(share)
class GetShareByTokenUseCase:
"""通过token获取分享信息(不带视频内容,仅元信息)。
用于分享页加载前判断:是否需要密码、是否过期等。
"""
def __init__(self, share_repository: VideoShareRepositoryPort) -> None:
self.share_repo = share_repository
def execute(self, token: str) -> VideoShare:
share = self.share_repo.get_by_token(token)
if share is None:
raise NotFoundError(f"Share not found: {token}")
if not share.is_accessible:
raise ShareExpiredError("Share is not accessible")
return share
class AccessShareUseCase:
"""访问分享内容(验证密码+返回视频信息+计数浏览量)。"""
def __init__(
self,
share_repository: VideoShareRepositoryPort,
video_repository: GeneratedVideoRepository,
) -> None:
self.share_repo = share_repository
self.video_repo = video_repository
def execute(self, token: str, password: Optional[str] = None) -> ShareAccessResult:
share = self.share_repo.get_by_token(token)
if share is None:
raise NotFoundError(f"Share not found: {token}")
if not share.is_accessible:
raise ShareExpiredError("Share is not accessible")
# 密码校验
password_verified = True
if share.has_password:
if not password:
raise PasswordRequiredError("Password required")
if not share.verify_password(password):
raise InvalidPasswordError("Invalid password")
password_verified = True
# 获取视频信息
video = self.video_repo.get(share.video_id)
if video is None:
raise VideoNotFoundError("Video not found")
# 浏览量+1
self.share_repo.increment_view(share.id)
share.view_count += 1
return ShareAccessResult(share=share, video=video, password_verified=password_verified)
class ListSharesByVideoUseCase:
"""列出某个视频的所有分享记录."""
def __init__(self, share_repository: VideoShareRepositoryPort) -> None:
self.share_repo = share_repository
def execute(self, video_id: str, user_id: str) -> List[VideoShare]:
return self.share_repo.list_by_video(video_id, user_id)
class ListSharesByUserUseCase:
"""列出用户创建的所有分享记录."""
def __init__(self, share_repository: VideoShareRepositoryPort) -> None:
self.share_repo = share_repository
def execute(self, user_id: str, skip: int = 0, limit: int = 20) -> tuple[List[VideoShare], int]:
items = self.share_repo.list_by_user(user_id, skip=skip, limit=limit)
total = self.share_repo.count_by_user(user_id)
return items, total
class UpdateShareUseCase:
"""更新分享配置(密码、有效期等)."""
def __init__(self, share_repository: VideoShareRepositoryPort) -> None:
self.share_repo = share_repository
def execute(self, command: UpdateShareCommand) -> VideoShare:
share = self.share_repo.get_by_id(command.share_id, command.user_id)
if share is None:
raise NotFoundError(f"Share {command.share_id} not found")
# password=None表示不修改;空字符串表示清除密码
if command.password is not None:
from packages.domain.video_share import _hash_password
if command.password == "":
share.password_hash = None
else:
share.password_hash = _hash_password(command.password)
# expires_at=None表示不修改
if command.expires_at is not None:
if command.expires_at < datetime.now(timezone.utc):
raise ValueError("expires_at cannot be in the past")
share.expires_at = command.expires_at
return self.share_repo.update(share)
class RevokeShareUseCase:
"""撤销/删除分享."""
def __init__(self, share_repository: VideoShareRepositoryPort) -> None:
self.share_repo = share_repository
def execute(self, share_id: str, user_id: str) -> bool:
share = self.share_repo.get_by_id(share_id, user_id)
if share is None:
raise NotFoundError(f"Share {share_id} not found")
return self.share_repo.delete(share_id, user_id)
class RecordShareDownloadUseCase:
"""记录分享下载(下载量+1."""
def __init__(self, share_repository: VideoShareRepositoryPort) -> None:
self.share_repo = share_repository
def execute(self, token: str, password: Optional[str] = None) -> None:
share = self.share_repo.get_by_token(token)
if share is None:
raise NotFoundError(f"Share not found: {token}")
if not share.is_accessible:
raise ShareExpiredError("Share is not accessible")
# 密码校验
if share.has_password:
if not password or not share.verify_password(password):
raise InvalidPasswordError("Invalid password")
self.share_repo.increment_download(share.id)