Files
opencloud-backend/app/schemas.py
T
Mplan e75833ee4b 重构表结构:合并 profiles→users,展平 API 响应,新增计数器字段
- models: 删除 Profile 模型,字段合并到 User;User 新增云朵/公开/收藏计数器;Cloud 新增 favorite_count
- schemas/serializers: 展平 UserOut/AuthOut/MeOut/AdminUserOut,移除嵌套 profile
- deps: user.profile.role→user.role,移除 selectinload profile
- auth/register: 注册时直接设置 user 字段,不再创建 Profile
- clouds: 创建/删除云朵时同步 user.cloud_count 计数器,状态变化时同步 public_cloud_count
- favorites: 点赞/取消时同步 cloud.favorite_count 计数器
- admin: 审批/隐藏/批量删时同步 public_cloud_count 计数器
- profiles/stats: 用计数器替代实时 COUNT 查询
- alembic: 新增 migration 合并 profiles 到 users,初始化 counters
2026-07-28 23:48:57 +08:00

295 lines
6.5 KiB
Python

import uuid
from datetime import datetime
from typing import Generic, Literal, TypeVar
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator
Role = Literal["user", "admin"]
CloudStatus = Literal["pending", "approved", "rejected"]
Rarity = Literal["common", "uncommon", "rare"]
class MessageOut(BaseModel):
message: str
class RegisterIn(BaseModel):
email: EmailStr
password: str = Field(min_length=8, max_length=128)
username: str = Field(min_length=2, max_length=32)
@field_validator("username")
@classmethod
def strip_username(cls, value: str) -> str:
value = value.strip()
if len(value) < 2:
raise ValueError("昵称至少需要 2 个字符")
return value
class LoginIn(BaseModel):
email: EmailStr
password: str = Field(min_length=1, max_length=128)
class TokenIn(BaseModel):
token: str = Field(min_length=20, max_length=512)
class ForgotPasswordIn(BaseModel):
email: EmailStr
class ResetPasswordIn(TokenIn):
password: str = Field(min_length=8, max_length=128)
class ChangePasswordIn(BaseModel):
password: str = Field(min_length=8, max_length=128)
class UserOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
email: EmailStr
email_verified: bool
username: str
avatar_url: str | None = None
role: Role
is_disabled: bool
cloud_count: int
public_cloud_count: int
collection_count: int
created_at: datetime
class AuthOut(BaseModel):
access_token: str
token_type: Literal["bearer"] = "bearer"
expires_in: int
user: UserOut
class MeOut(BaseModel):
user: UserOut
class ProfileUpdateIn(BaseModel):
username: str = Field(min_length=2, max_length=32)
@field_validator("username")
@classmethod
def strip_username(cls, value: str) -> str:
return value.strip()
class ProfileStatsOut(BaseModel):
user_id: uuid.UUID
username: str
email: str
created_at: datetime
cloud_count: int
public_cloud_count: int
collection_count: int
class CloudTypeSummary(BaseModel):
id: int
name: str
name_en: str
rarity: Rarity
class CloudTypeOut(CloudTypeSummary):
genus: str
description: str | None
icon_url: str | None
created_at: datetime
class CloudOwnerOut(BaseModel):
id: uuid.UUID
username: str
class CloudOut(BaseModel):
id: uuid.UUID
user_id: uuid.UUID
cloud_type_id: int | None
custom_cloud_type: str | None
image_url: str
thumbnail_url: str
latitude: float | None
longitude: float | None
location_name: str | None
description: str | None
captured_at: datetime | None
status: CloudStatus
is_hidden: bool
created_at: datetime
updated_at: datetime
cloud_type: CloudTypeSummary | None
owner: CloudOwnerOut | None
cloud_type_name: str
cloud_type_rarity: Rarity
username: str
favorite_count: int = 0
is_favorited: bool = False
class CloudUpdateIn(BaseModel):
cloud_type_id: int | None = None
custom_cloud_type: str | None = Field(default=None, max_length=80)
latitude: float | None = Field(default=None, ge=-90, le=90)
longitude: float | None = Field(default=None, ge=-180, le=180)
location_name: str | None = Field(default=None, max_length=120)
description: str | None = Field(default=None, max_length=2000)
captured_at: datetime | None = None
is_hidden: bool | None = None
@model_validator(mode="after")
def validate_coordinates(self) -> "CloudUpdateIn":
provided = self.model_fields_set
if ("latitude" in provided) != ("longitude" in provided):
raise ValueError("经纬度必须同时提供")
if "latitude" in provided and ((self.latitude is None) != (self.longitude is None)):
raise ValueError("经纬度必须同时为空或同时有值")
return self
class BadgeOut(BaseModel):
cloud_type_id: int
cloud_name: str
cloud_name_en: str
rarity: Rarity
unlocked_at: datetime
class CloudCreateOut(BaseModel):
cloud: CloudOut
unlocked_badge: BadgeOut | None
T = TypeVar("T")
class PageOut(BaseModel, Generic[T]):
items: list[T]
page: int
page_size: int
total: int
total_pages: int
class BatchIdsIn(BaseModel):
ids: list[uuid.UUID] = Field(min_length=1, max_length=100)
@field_validator("ids")
@classmethod
def unique_ids(cls, value: list[uuid.UUID]) -> list[uuid.UUID]:
return list(dict.fromkeys(value))
class DeleteResultOut(BaseModel):
deleted: int
class CollectionCloudOut(BaseModel):
id: uuid.UUID
image_url: str
thumbnail_url: str
captured_at: datetime | None
created_at: datetime
location_name: str | None
class CollectionOut(BaseModel):
id: uuid.UUID
user_id: uuid.UUID
cloud_type_id: int
first_cloud_id: uuid.UUID | None
unlocked_at: datetime
cloud_type: CloudTypeSummary
first_cloud: CollectionCloudOut | None
class AdminStatsOut(BaseModel):
users: int
images: int
today_uploads: int
pending: int
approved: int
rejected: int
hidden: int
class AdminUserOut(BaseModel):
user: UserOut
class AdminUserUpdateIn(BaseModel):
role: Role | None = None
is_disabled: bool | None = None
@model_validator(mode="after")
def require_change(self) -> "AdminUserUpdateIn":
if not self.model_fields_set:
raise ValueError("至少需要提供一个修改字段")
return self
class AdminCreateUserIn(BaseModel):
email: EmailStr
password: str = Field(min_length=8, max_length=128)
username: str = Field(min_length=2, max_length=32)
role: Role = "user"
@field_validator("username")
@classmethod
def strip_username(cls, value: str) -> str:
value = value.strip()
if len(value) < 2:
raise ValueError("昵称至少需要 2 个字符")
return value
class AdminCloudStatusIn(BatchIdsIn):
status: CloudStatus
class AdminCloudVisibilityIn(BatchIdsIn):
is_hidden: bool
class BatchUpdateOut(BaseModel):
updated: int
class FavoriteIn(BaseModel):
favorited: bool
class FavoriteOut(BaseModel):
cloud_id: uuid.UUID
user_id: uuid.UUID
favorited: bool
favorite_count: int
class LikeStatsPhotoOut(BaseModel):
cloud_id: uuid.UUID
image_url: str
thumbnail_url: str
favorite_count: int
captured_at: datetime | None
created_at: datetime
class LikeStatsOut(BaseModel):
user_id: uuid.UUID
username: str
total_likes: int
photos: list[LikeStatsPhotoOut]