Files
xiaoxia 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
feat(video-share): 视频分享后端 - 密码保护/有效期/浏览下载计数 (#737)
2026-07-23 09:38:21 +08:00

109 lines
3.2 KiB
Python
Executable File

"""视频分享领域实体."""
from __future__ import annotations
import secrets
from dataclasses import dataclass, field
from datetime import datetime, timezone
from hashlib import sha256
from typing import Optional
from uuid import uuid4
def _hash_password(password: str) -> str:
"""简单密码哈希(SHA-256 + salt)。
分享链接的密码保护安全级别要求不高,
使用简单的加盐哈希即可,避免引入bcrypt等重依赖。
"""
if not password:
return ""
salt = "xiaoxia_share_salt"
return sha256(f"{salt}:{password}".encode()).hexdigest()
def generate_share_token(length: int = 12) -> str:
"""生成URL友好的分享token."""
# 使用urlsafe的base64,但去掉可能引起歧义的字符
alphabet = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789"
return "".join(secrets.choice(alphabet) for _ in range(length))
@dataclass(slots=True)
class VideoShare:
"""视频分享记录."""
id: str
video_id: str
user_id: str
share_token: str
password_hash: Optional[str] = None
expires_at: Optional[datetime] = None
view_count: int = 0
download_count: int = 0
is_active: bool = True
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@classmethod
def create(
cls,
video_id: str,
user_id: str,
*,
password: Optional[str] = None,
expires_at: Optional[datetime] = None,
) -> "VideoShare":
if not video_id.strip():
raise ValueError("video_id cannot be empty")
if not user_id.strip():
raise ValueError("user_id cannot be empty")
if expires_at and expires_at < datetime.now(timezone.utc):
raise ValueError("expires_at cannot be in the past")
return cls(
id=uuid4().hex,
video_id=video_id.strip(),
user_id=user_id.strip(),
share_token=generate_share_token(),
password_hash=_hash_password(password) if password else None,
expires_at=expires_at,
)
@property
def has_password(self) -> bool:
"""是否设置了访问密码."""
return bool(self.password_hash)
@property
def is_expired(self) -> bool:
"""是否已过期."""
if not self.expires_at:
return False
return datetime.now(timezone.utc) > self.expires_at
@property
def is_accessible(self) -> bool:
"""是否可以访问(活跃且未过期)."""
return self.is_active and not self.is_expired
def verify_password(self, password: str) -> bool:
"""验证访问密码."""
if not self.password_hash:
return True # 没有密码直接通过
if not password:
return False
return _hash_password(password) == self.password_hash
def increment_view_count(self) -> None:
"""浏览次数+1."""
self.view_count += 1
def increment_download_count(self) -> None:
"""下载次数+1."""
self.download_count += 1
def revoke(self) -> None:
"""撤销分享."""
self.is_active = False