5679daca41
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m55s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m50s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m54s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m58s
CI/CD Pipeline / Unit Tests (push) Successful in 6m48s
CI/CD Pipeline / Integration Tests (push) Successful in 2m31s
CI/CD Pipeline / Frontend Lint (push) Successful in 49s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m36s
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 Staging API Image (push) Successful in 13m52s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m28s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m50s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
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 / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
293 lines
9.2 KiB
Python
Executable File
293 lines
9.2 KiB
Python
Executable File
from __future__ import annotations
|
||
|
||
import sys
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime, timezone
|
||
|
||
if sys.version_info >= (3, 11):
|
||
from enum import StrEnum
|
||
else:
|
||
from enum import Enum
|
||
|
||
class StrEnum(str, Enum):
|
||
pass
|
||
|
||
|
||
from typing import Any
|
||
from uuid import uuid4
|
||
|
||
# 枚举统一从 classification 模块导入,消除重复定义
|
||
from packages.domain.classification import (
|
||
AssetLibraryKind,
|
||
ClassificationStatus,
|
||
IngestJobStatus,
|
||
)
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class User:
|
||
id: str
|
||
email: str
|
||
display_name: str
|
||
username: str = ""
|
||
# 认证相关字段
|
||
password_hash: str = ""
|
||
email_verified: bool = False
|
||
email_verification_token: str | None = None
|
||
password_reset_token: str | None = None
|
||
password_reset_expires_at: datetime | None = None
|
||
last_login_at: datetime | None = None
|
||
last_login_ip: str | None = None
|
||
# 订阅相关字段 (移到 User 级别)
|
||
subscription_plan: str = "free" # free, pro, enterprise
|
||
subscription_status: str = "active" # active, cancelled, expired
|
||
subscription_expires_at: datetime | None = None
|
||
# 配额限制 (移到 User 级别)
|
||
max_projects: int = 3 # free: 3, pro: unlimited, enterprise: unlimited
|
||
max_storage_gb: int = 10 # free: 10, pro: 100, enterprise: 1000
|
||
used_storage_gb: float = 0.0
|
||
# 管理员标识
|
||
is_admin: bool = False
|
||
|
||
# 微信绑定
|
||
wechat_openid: str | None = None
|
||
wechat_unionid: str | None = None
|
||
|
||
# 手机号绑定
|
||
phone: str | None = None
|
||
phone_verified: bool = False
|
||
binding_completed_at: datetime | None = None
|
||
|
||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class Project:
|
||
id: str
|
||
owner_user_id: str
|
||
name: str
|
||
description: str = ""
|
||
shared_users: list[str] = field(default_factory=list) # 被共享的用户 ID 列表
|
||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||
|
||
@classmethod
|
||
def create(cls, owner_user_id: str, name: str, description: str = "") -> "Project":
|
||
clean_name = name.strip()
|
||
if not clean_name:
|
||
raise ValueError("项目名称不能为空")
|
||
return cls(
|
||
id=uuid4().hex,
|
||
owner_user_id=owner_user_id,
|
||
name=clean_name,
|
||
description=description.strip(),
|
||
shared_users=[],
|
||
)
|
||
|
||
def is_owner(self, user_id: str) -> bool:
|
||
"""检查用户是否为项目所有者"""
|
||
return self.owner_user_id == user_id
|
||
|
||
def is_shared_with(self, user_id: str) -> bool:
|
||
"""检查项目是否共享给指定用户"""
|
||
return user_id in self.shared_users
|
||
|
||
def can_access(self, user_id: str) -> bool:
|
||
"""检查用户是否有权限访问项目"""
|
||
return self.is_owner(user_id) or self.is_shared_with(user_id)
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class AssetLibrary:
|
||
id: str
|
||
project_id: str
|
||
name: str
|
||
kind: AssetLibraryKind
|
||
asset_count: int = 0
|
||
total_size: int = 0
|
||
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,
|
||
project_id: str,
|
||
name: str,
|
||
kind: AssetLibraryKind,
|
||
) -> "AssetLibrary":
|
||
clean_name = name.strip()
|
||
if not clean_name:
|
||
raise ValueError("素材库名称不能为空")
|
||
return cls(
|
||
id=uuid4().hex,
|
||
project_id=project_id,
|
||
name=clean_name,
|
||
kind=kind,
|
||
asset_count=0,
|
||
total_size=0,
|
||
)
|
||
|
||
|
||
class AssetStatus(StrEnum):
|
||
UPLOADING = "uploading"
|
||
READY = "ready"
|
||
PROCESSING = "processing"
|
||
ERROR = "error"
|
||
DELETED = "deleted"
|
||
|
||
@classmethod
|
||
def _missing_(cls, value: object) -> "AssetStatus":
|
||
"""兼容历史数据,避免枚举转换失败导致500。
|
||
|
||
- uploaded → READY(早期版本用 uploaded 表示上传完成)
|
||
- 其他未知值 → READY(兜底,不阻塞业务)
|
||
"""
|
||
if isinstance(value, str):
|
||
normalized = value.strip().lower()
|
||
if normalized in ("uploaded", "success", "ok", "done", "complete"):
|
||
return cls.READY
|
||
if normalized in ("upload", "uploading_start", "upload_start"):
|
||
return cls.UPLOADING
|
||
if normalized in ("failed", "fail", "err"):
|
||
return cls.ERROR
|
||
if normalized in ("process", "processing", "running", "run"):
|
||
return cls.PROCESSING
|
||
return cls.READY
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class Asset:
|
||
id: str
|
||
project_id: str
|
||
library_id: str
|
||
name: str
|
||
storage_key: str
|
||
mime_type: str
|
||
file_size: int = 0
|
||
thumbnail_url: str | None = None
|
||
duration: float | None = None
|
||
width: int | None = None
|
||
height: int | None = None
|
||
fps: float | None = None
|
||
codec: str | None = None
|
||
status: AssetStatus = AssetStatus.UPLOADING
|
||
classification_status: ClassificationStatus = ClassificationStatus.PENDING
|
||
quality_score: float | None = None
|
||
uploaded_by_user_id: str = ""
|
||
file_hash: str = ""
|
||
metadata: dict[str, Any] = field(default_factory=dict)
|
||
tag_ids: list[str] = field(default_factory=list)
|
||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||
|
||
@property
|
||
def file_type(self) -> str:
|
||
"""文件类型(从 mime_type 推导,如 video/audio/image)."""
|
||
if "/" in self.mime_type:
|
||
return self.mime_type.split("/")[0]
|
||
return self.mime_type
|
||
|
||
@classmethod
|
||
def create(
|
||
cls,
|
||
project_id: str,
|
||
library_id: str,
|
||
name: str,
|
||
storage_key: str,
|
||
mime_type: str,
|
||
metadata: dict[str, Any] | None = None,
|
||
*,
|
||
file_size: int = 0,
|
||
thumbnail_url: str | None = None,
|
||
duration: float | None = None,
|
||
width: int | None = None,
|
||
height: int | None = None,
|
||
fps: float | None = None,
|
||
codec: str | None = None,
|
||
status: AssetStatus = AssetStatus.UPLOADING,
|
||
classification_status: ClassificationStatus = ClassificationStatus.PENDING,
|
||
quality_score: float | None = None,
|
||
uploaded_by_user_id: str = "",
|
||
file_hash: str = "",
|
||
) -> "Asset":
|
||
clean_name = name.strip()
|
||
if not clean_name:
|
||
raise ValueError("素材名称不能为空")
|
||
if not storage_key.strip():
|
||
raise ValueError("storage_key 不能为空")
|
||
if not mime_type.strip():
|
||
raise ValueError("mime_type 不能为空")
|
||
return cls(
|
||
id=uuid4().hex,
|
||
project_id=project_id,
|
||
library_id=library_id,
|
||
name=clean_name,
|
||
storage_key=storage_key.strip(),
|
||
mime_type=mime_type.strip(),
|
||
file_size=file_size,
|
||
thumbnail_url=str(thumbnail_url) if thumbnail_url else None,
|
||
duration=duration,
|
||
width=width,
|
||
height=height,
|
||
fps=fps,
|
||
codec=codec,
|
||
status=status,
|
||
classification_status=classification_status,
|
||
quality_score=quality_score,
|
||
uploaded_by_user_id=uploaded_by_user_id.strip(),
|
||
file_hash=file_hash.strip(),
|
||
metadata=metadata or {},
|
||
tag_ids=[],
|
||
)
|
||
|
||
def add_tag(self, tag_id: str) -> None:
|
||
"""添加标签 ID。空 ID 会被忽略,自动去重。"""
|
||
clean_id = tag_id.strip()
|
||
if not clean_id:
|
||
raise ValueError("标签 ID 不能为空")
|
||
if clean_id not in self.tag_ids:
|
||
self.tag_ids.append(clean_id)
|
||
self.updated_at = datetime.now(timezone.utc)
|
||
|
||
def remove_tag(self, tag_id: str) -> None:
|
||
"""删除标签 ID。如果标签不存在,不报错(幂等性)。"""
|
||
clean_id = tag_id.strip()
|
||
if clean_id in self.tag_ids:
|
||
self.tag_ids.remove(clean_id)
|
||
self.updated_at = datetime.now(timezone.utc)
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class IngestJob:
|
||
id: str
|
||
project_id: str
|
||
library_id: str
|
||
storage_key: str
|
||
status: IngestJobStatus = IngestJobStatus.PENDING
|
||
error_message: str = ""
|
||
result_asset_id: str = ""
|
||
file_hash: str = ""
|
||
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,
|
||
project_id: str,
|
||
library_id: str,
|
||
storage_key: str,
|
||
file_hash: str = "",
|
||
) -> "IngestJob":
|
||
if not project_id.strip():
|
||
raise ValueError("project_id 不能为空")
|
||
if not library_id.strip():
|
||
raise ValueError("library_id 不能为空")
|
||
if not storage_key.strip():
|
||
raise ValueError("storage_key 不能为空")
|
||
return cls(
|
||
id=uuid4().hex,
|
||
project_id=project_id.strip(),
|
||
library_id=library_id.strip(),
|
||
storage_key=storage_key.strip(),
|
||
file_hash=file_hash.strip(),
|
||
)
|