Files
xiaoxia 528f56254d
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 / Check push changed paths (pull_request) 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 / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 8s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 20s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 21s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 23s
CI/CD Pipeline / Build Staging API Image (push) Successful in 17s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 23s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 33s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m0s
CI/CD Pipeline / Integration Tests (push) Successful in 2m17s
CI/CD Pipeline / Validate - Style (push) Successful in 2m28s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m8s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m6s
AI Code Review / AI Code Review (pull_request) Successful in 3m27s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m38s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m30s
CI/CD Pipeline / Validate - Security (push) Successful in 4m54s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m17s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m11s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m10s
CI/CD Pipeline / Unit Tests (push) Successful in 8m18s
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 / CI Gate (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 / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 11m48s
feat(#1718): PATCH /auth/me 资料更新接口 + profile_completed 字段 (#1728)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-06 12:06:58 +08:00

304 lines
9.6 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.
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
# 资料是否已完善(微信新用户首次设置昵称后置 True;邮箱注册默认 True)
profile_completed: bool = True
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 = ""
client_upload_id: 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 = "",
client_upload_id: 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(),
client_upload_id=client_upload_id.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 = ""
asset_id: str = ""
celery_task_id: 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 = "",
asset_id: str = "",
celery_task_id: 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(),
asset_id=asset_id.strip(),
celery_task_id=celery_task_id.strip(),
)