Files
xiaoxia-saas/packages/domain/entities.py
T
xiaoxia 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
refactor(#779): 消除重复枚举定义,统一到classification模块 (#801)
2026-07-24 08:03:08 +08:00

293 lines
9.2 KiB
Python
Executable File
Raw 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.
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(),
)