提供基本前后端骨架
This commit is contained in:
75
app/schemas/__init__.py
Normal file
75
app/schemas/__init__.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""Pydantic 数据模式"""
|
||||
|
||||
from app.schemas.auth import (
|
||||
LoginRequest,
|
||||
PasswordChangeRequest,
|
||||
RefreshTokenRequest,
|
||||
TokenResponse,
|
||||
)
|
||||
from app.schemas.oauth2 import (
|
||||
OAuth2AuthorizeResponse,
|
||||
OAuth2CallbackRequest,
|
||||
OAuth2LoginResponse,
|
||||
OAuth2TokenData,
|
||||
OAuth2UserInfo,
|
||||
)
|
||||
from app.schemas.user import (
|
||||
UserCreate,
|
||||
UserResponse,
|
||||
UserUpdate,
|
||||
)
|
||||
from app.schemas.balance import (
|
||||
BalanceResponse,
|
||||
TransactionResponse,
|
||||
DeductionRequest,
|
||||
DeductionResponse,
|
||||
AdminAdjustmentRequest,
|
||||
AdminBalanceResponse,
|
||||
)
|
||||
from app.schemas.redeem_code import (
|
||||
RedeemRequest,
|
||||
RedeemResponse,
|
||||
RedeemCodeResponse,
|
||||
BatchCreateRequest,
|
||||
BatchResponse,
|
||||
BulkImportRequest,
|
||||
BulkImportResponse,
|
||||
ExportResponse,
|
||||
UsageLogResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Auth
|
||||
"LoginRequest",
|
||||
"TokenResponse",
|
||||
"RefreshTokenRequest",
|
||||
"PasswordChangeRequest",
|
||||
# OAuth2
|
||||
"OAuth2AuthorizeResponse",
|
||||
"OAuth2CallbackRequest",
|
||||
"OAuth2LoginResponse",
|
||||
"OAuth2TokenData",
|
||||
"OAuth2UserInfo",
|
||||
# User
|
||||
"UserCreate",
|
||||
"UserResponse",
|
||||
"UserUpdate",
|
||||
# Balance
|
||||
"BalanceResponse",
|
||||
"TransactionResponse",
|
||||
"DeductionRequest",
|
||||
"DeductionResponse",
|
||||
"AdminAdjustmentRequest",
|
||||
"AdminBalanceResponse",
|
||||
# Redeem Code
|
||||
"RedeemRequest",
|
||||
"RedeemResponse",
|
||||
"RedeemCodeResponse",
|
||||
"BatchCreateRequest",
|
||||
"BatchResponse",
|
||||
"BulkImportRequest",
|
||||
"BulkImportResponse",
|
||||
"ExportResponse",
|
||||
"UsageLogResponse",
|
||||
]
|
||||
|
||||
112
app/schemas/auth.py
Normal file
112
app/schemas/auth.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
认证相关 Schema
|
||||
|
||||
定义登录、令牌等数据结构。
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.schemas.base import BaseSchema
|
||||
|
||||
|
||||
class LoginRequest(BaseSchema):
|
||||
"""登录请求"""
|
||||
|
||||
username: Annotated[
|
||||
str,
|
||||
Field(
|
||||
min_length=1,
|
||||
description="用户名或邮箱",
|
||||
examples=["john_doe"],
|
||||
),
|
||||
]
|
||||
password: Annotated[
|
||||
str,
|
||||
Field(
|
||||
min_length=1,
|
||||
description="密码",
|
||||
examples=["SecurePass123"],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class TokenResponse(BaseSchema):
|
||||
"""令牌响应"""
|
||||
|
||||
access_token: str = Field(description="访问令牌")
|
||||
refresh_token: str = Field(description="刷新令牌")
|
||||
token_type: str = Field(default="Bearer", description="令牌类型")
|
||||
expires_in: int = Field(description="访问令牌过期时间(秒)")
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseSchema):
|
||||
"""刷新令牌请求"""
|
||||
|
||||
refresh_token: Annotated[
|
||||
str,
|
||||
Field(
|
||||
min_length=1,
|
||||
description="刷新令牌",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class PasswordChangeRequest(BaseSchema):
|
||||
"""修改密码请求"""
|
||||
|
||||
current_password: Annotated[
|
||||
str,
|
||||
Field(
|
||||
min_length=1,
|
||||
description="当前密码",
|
||||
),
|
||||
]
|
||||
new_password: Annotated[
|
||||
str,
|
||||
Field(
|
||||
min_length=8,
|
||||
max_length=128,
|
||||
description="新密码",
|
||||
),
|
||||
]
|
||||
|
||||
def model_post_init(self, __context) -> None:
|
||||
"""验证新旧密码不同"""
|
||||
if self.current_password == self.new_password:
|
||||
raise ValueError("新密码不能与当前密码相同")
|
||||
|
||||
|
||||
class PasswordResetRequest(BaseSchema):
|
||||
"""密码重置请求(忘记密码)"""
|
||||
|
||||
email: Annotated[
|
||||
str,
|
||||
Field(
|
||||
min_length=1,
|
||||
description="注册邮箱",
|
||||
examples=["user@example.com"],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class PasswordResetConfirm(BaseSchema):
|
||||
"""密码重置确认"""
|
||||
|
||||
token: Annotated[
|
||||
str,
|
||||
Field(
|
||||
min_length=1,
|
||||
description="重置令牌",
|
||||
),
|
||||
]
|
||||
new_password: Annotated[
|
||||
str,
|
||||
Field(
|
||||
min_length=8,
|
||||
max_length=128,
|
||||
description="新密码",
|
||||
),
|
||||
]
|
||||
|
||||
285
app/schemas/balance.py
Normal file
285
app/schemas/balance.py
Normal file
@@ -0,0 +1,285 @@
|
||||
"""
|
||||
余额相关 Schema
|
||||
|
||||
定义余额数据的验证和序列化规则。
|
||||
|
||||
设计说明:
|
||||
- 内部存储使用整数单位(units),外部显示使用小数(display)
|
||||
- 1.00 显示余额 = 1000 单位额度
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import Field, field_validator, computed_field
|
||||
|
||||
from app.schemas.base import BaseSchema, PaginatedResponse
|
||||
from app.models.balance import TransactionType, TransactionStatus
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 余额单位转换工具
|
||||
# ============================================================
|
||||
|
||||
UNITS_PER_DISPLAY = 1000 # 1.00 显示余额 = 1000 单位额度
|
||||
|
||||
|
||||
def display_to_units(display_amount: float) -> int:
|
||||
"""将显示金额转换为单位额度"""
|
||||
return int(round(display_amount * UNITS_PER_DISPLAY))
|
||||
|
||||
|
||||
def units_to_display(units: int) -> float:
|
||||
"""将单位额度转换为显示金额"""
|
||||
return units / UNITS_PER_DISPLAY
|
||||
|
||||
|
||||
def format_display(units: int) -> str:
|
||||
"""格式化显示金额(2 位小数)"""
|
||||
return f"{units / UNITS_PER_DISPLAY:.2f}"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 余额账户 Schema
|
||||
# ============================================================
|
||||
|
||||
class BalanceResponse(BaseSchema):
|
||||
"""余额信息响应"""
|
||||
|
||||
user_id: str
|
||||
balance_units: Annotated[
|
||||
int,
|
||||
Field(description="当前余额(单位额度)"),
|
||||
]
|
||||
frozen_units: Annotated[
|
||||
int,
|
||||
Field(description="冻结余额(单位额度)"),
|
||||
]
|
||||
total_recharged_units: Annotated[
|
||||
int,
|
||||
Field(description="累计充值(单位额度)"),
|
||||
]
|
||||
total_consumed_units: Annotated[
|
||||
int,
|
||||
Field(description="累计消费(单位额度)"),
|
||||
]
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def balance(self) -> str:
|
||||
"""显示余额(2 位小数)"""
|
||||
return format_display(self.balance_units)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def available_balance(self) -> str:
|
||||
"""显示可用余额(2 位小数)"""
|
||||
return format_display(self.balance_units - self.frozen_units)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def frozen_balance(self) -> str:
|
||||
"""显示冻结余额(2 位小数)"""
|
||||
return format_display(self.frozen_units)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def total_recharged(self) -> str:
|
||||
"""显示累计充值(2 位小数)"""
|
||||
return format_display(self.total_recharged_units)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def total_consumed(self) -> str:
|
||||
"""显示累计消费(2 位小数)"""
|
||||
return format_display(self.total_consumed_units)
|
||||
|
||||
|
||||
class BalanceSummaryResponse(BaseSchema):
|
||||
"""余额简要信息响应(用于嵌入用户信息)"""
|
||||
|
||||
balance: str = Field(description="当前余额")
|
||||
available_balance: str = Field(description="可用余额")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 交易记录 Schema
|
||||
# ============================================================
|
||||
|
||||
class TransactionResponse(BaseSchema):
|
||||
"""交易记录响应"""
|
||||
|
||||
id: str
|
||||
transaction_type: TransactionType
|
||||
status: TransactionStatus
|
||||
amount_units: Annotated[
|
||||
int,
|
||||
Field(description="交易金额(单位额度,正数收入,负数支出)"),
|
||||
]
|
||||
balance_before_units: Annotated[
|
||||
int,
|
||||
Field(description="交易前余额(单位额度)"),
|
||||
]
|
||||
balance_after_units: Annotated[
|
||||
int,
|
||||
Field(description="交易后余额(单位额度)"),
|
||||
]
|
||||
reference_type: str | None
|
||||
reference_id: str | None
|
||||
description: str | None
|
||||
created_at: datetime
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def amount(self) -> str:
|
||||
"""显示交易金额(带符号,2 位小数)"""
|
||||
return f"{self.amount_units / UNITS_PER_DISPLAY:+.2f}"
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def balance_before(self) -> str:
|
||||
"""显示交易前余额(2 位小数)"""
|
||||
return format_display(self.balance_before_units)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def balance_after(self) -> str:
|
||||
"""显示交易后余额(2 位小数)"""
|
||||
return format_display(self.balance_after_units)
|
||||
|
||||
|
||||
class TransactionListResponse(PaginatedResponse[TransactionResponse]):
|
||||
"""交易记录列表响应"""
|
||||
pass
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 扣款请求 Schema
|
||||
# ============================================================
|
||||
|
||||
class DeductionRequest(BaseSchema):
|
||||
"""扣款请求"""
|
||||
|
||||
amount: Annotated[
|
||||
float,
|
||||
Field(
|
||||
gt=0,
|
||||
description="扣款金额(显示金额,如 1.00)",
|
||||
examples=[1.00, 0.50],
|
||||
),
|
||||
]
|
||||
reference_type: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
default=None,
|
||||
max_length=64,
|
||||
description="关联业务类型",
|
||||
examples=["api_call", "service_fee"],
|
||||
),
|
||||
]
|
||||
reference_id: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
default=None,
|
||||
max_length=64,
|
||||
description="关联业务 ID",
|
||||
),
|
||||
]
|
||||
description: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
default=None,
|
||||
max_length=255,
|
||||
description="交易描述",
|
||||
),
|
||||
]
|
||||
idempotency_key: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
default=None,
|
||||
max_length=64,
|
||||
description="幂等键(防止重复扣款)",
|
||||
),
|
||||
]
|
||||
|
||||
@field_validator("amount")
|
||||
@classmethod
|
||||
def validate_amount(cls, v: float) -> float:
|
||||
"""验证金额精度"""
|
||||
# 最小精度 0.001(即 1 单位额度)
|
||||
if round(v, 3) != v:
|
||||
raise ValueError("金额精度不能超过 3 位小数")
|
||||
return v
|
||||
|
||||
@property
|
||||
def amount_units(self) -> int:
|
||||
"""转换为单位额度"""
|
||||
return display_to_units(self.amount)
|
||||
|
||||
|
||||
class DeductionResponse(BaseSchema):
|
||||
"""扣款响应"""
|
||||
|
||||
transaction_id: str
|
||||
amount: str = Field(description="扣款金额")
|
||||
balance_before: str = Field(description="扣款前余额")
|
||||
balance_after: str = Field(description="扣款后余额")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 管理员操作 Schema
|
||||
# ============================================================
|
||||
|
||||
class AdminAdjustmentRequest(BaseSchema):
|
||||
"""管理员余额调整请求"""
|
||||
|
||||
user_id: Annotated[
|
||||
str,
|
||||
Field(description="目标用户 ID"),
|
||||
]
|
||||
amount: Annotated[
|
||||
float,
|
||||
Field(
|
||||
description="调整金额(正数增加,负数减少)",
|
||||
examples=[10.00, -5.00],
|
||||
),
|
||||
]
|
||||
reason: Annotated[
|
||||
str,
|
||||
Field(
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
description="调整原因",
|
||||
),
|
||||
]
|
||||
|
||||
@field_validator("amount")
|
||||
@classmethod
|
||||
def validate_amount(cls, v: float) -> float:
|
||||
"""验证金额精度"""
|
||||
if round(v, 3) != v:
|
||||
raise ValueError("金额精度不能超过 3 位小数")
|
||||
if v == 0:
|
||||
raise ValueError("调整金额不能为 0")
|
||||
return v
|
||||
|
||||
@property
|
||||
def amount_units(self) -> int:
|
||||
"""转换为单位额度"""
|
||||
return display_to_units(self.amount)
|
||||
|
||||
|
||||
class AdminBalanceResponse(BaseSchema):
|
||||
"""管理员查看的余额信息(包含更多细节)"""
|
||||
|
||||
user_id: str
|
||||
username: str
|
||||
balance: str
|
||||
available_balance: str
|
||||
frozen_balance: str
|
||||
total_recharged: str
|
||||
total_consumed: str
|
||||
version: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
102
app/schemas/base.py
Normal file
102
app/schemas/base.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
基础 Schema 定义
|
||||
|
||||
定义通用的响应模式和基础配置。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
DataT = TypeVar("DataT")
|
||||
|
||||
|
||||
class BaseSchema(BaseModel):
|
||||
"""基础 Schema 配置"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True, # 支持从 ORM 对象创建
|
||||
populate_by_name=True, # 支持字段别名
|
||||
str_strip_whitespace=True, # 自动去除字符串首尾空白
|
||||
)
|
||||
|
||||
|
||||
class TimestampMixin(BaseModel):
|
||||
"""时间戳混入"""
|
||||
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class APIResponse(BaseModel, Generic[DataT]):
|
||||
"""统一 API 响应格式"""
|
||||
|
||||
success: bool = True
|
||||
message: str = "操作成功"
|
||||
data: DataT | None = None
|
||||
|
||||
@classmethod
|
||||
def ok(cls, data: DataT | None = None, message: str = "操作成功") -> "APIResponse[DataT]":
|
||||
"""成功响应"""
|
||||
return cls(success=True, message=message, data=data)
|
||||
|
||||
@classmethod
|
||||
def error(cls, message: str = "操作失败", data: DataT | None = None) -> "APIResponse[DataT]":
|
||||
"""错误响应"""
|
||||
return cls(success=False, message=message, data=data)
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""错误响应"""
|
||||
|
||||
success: bool = False
|
||||
message: str
|
||||
code: str
|
||||
details: dict[str, Any] = {}
|
||||
|
||||
|
||||
class PaginationParams(BaseModel):
|
||||
"""分页参数"""
|
||||
|
||||
page: int = 1
|
||||
page_size: int = 20
|
||||
|
||||
@property
|
||||
def offset(self) -> int:
|
||||
"""计算偏移量"""
|
||||
return (self.page - 1) * self.page_size
|
||||
|
||||
@property
|
||||
def limit(self) -> int:
|
||||
"""获取限制数量"""
|
||||
return self.page_size
|
||||
|
||||
|
||||
class PaginatedResponse(BaseModel, Generic[DataT]):
|
||||
"""分页响应"""
|
||||
|
||||
items: list[DataT]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
items: list[DataT],
|
||||
total: int,
|
||||
page: int,
|
||||
page_size: int,
|
||||
) -> "PaginatedResponse[DataT]":
|
||||
"""创建分页响应"""
|
||||
total_pages = (total + page_size - 1) // page_size if page_size > 0 else 0
|
||||
return cls(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=total_pages,
|
||||
)
|
||||
|
||||
85
app/schemas/oauth2.py
Normal file
85
app/schemas/oauth2.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
OAuth2 相关 Schema
|
||||
|
||||
定义 OAuth2 认证流程的数据结构。
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.schemas.base import BaseSchema
|
||||
|
||||
|
||||
class OAuth2AuthorizeResponse(BaseSchema):
|
||||
"""OAuth2 授权 URL 响应"""
|
||||
|
||||
authorize_url: str = Field(description="OAuth2 授权页面 URL")
|
||||
state: str = Field(description="防 CSRF 状态码")
|
||||
|
||||
|
||||
class OAuth2CallbackRequest(BaseSchema):
|
||||
"""OAuth2 回调请求"""
|
||||
|
||||
code: Annotated[
|
||||
str,
|
||||
Field(
|
||||
min_length=1,
|
||||
description="授权码",
|
||||
),
|
||||
]
|
||||
state: Annotated[
|
||||
str,
|
||||
Field(
|
||||
min_length=1,
|
||||
description="状态码(用于验证请求合法性)",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class OAuth2TokenData(BaseSchema):
|
||||
"""OAuth2 令牌数据(从 OAuth 提供商获取)"""
|
||||
|
||||
access_token: str
|
||||
token_type: str = "Bearer"
|
||||
expires_in: int | None = None
|
||||
refresh_token: str | None = None
|
||||
scope: str | None = None
|
||||
|
||||
|
||||
class OAuth2UserInfo(BaseSchema):
|
||||
"""
|
||||
OAuth2 用户信息(从 Linux.do 获取)
|
||||
|
||||
示例响应:
|
||||
{
|
||||
"id": 1,
|
||||
"username": "neo",
|
||||
"name": "Neo",
|
||||
"active": true,
|
||||
"trust_level": 4,
|
||||
"email": "u1@linux.do",
|
||||
"avatar_url": "https://linux.do/xxxx",
|
||||
"silenced": false
|
||||
}
|
||||
"""
|
||||
|
||||
id: int | str = Field(description="用户 ID")
|
||||
username: str = Field(description="用户名")
|
||||
name: str | None = Field(default=None, description="显示名称")
|
||||
email: str | None = Field(default=None, description="邮箱")
|
||||
avatar_url: str | None = Field(default=None, description="头像 URL")
|
||||
active: bool = Field(default=True, description="是否激活")
|
||||
trust_level: int | None = Field(default=None, description="信任等级")
|
||||
silenced: bool = Field(default=False, description="是否被禁言")
|
||||
|
||||
|
||||
class OAuth2LoginResponse(BaseSchema):
|
||||
"""OAuth2 登录响应"""
|
||||
|
||||
access_token: str = Field(description="JWT 访问令牌")
|
||||
refresh_token: str = Field(description="JWT 刷新令牌")
|
||||
token_type: str = Field(default="Bearer", description="令牌类型")
|
||||
expires_in: int = Field(description="访问令牌过期时间(秒)")
|
||||
is_new_user: bool = Field(description="是否为新注册用户")
|
||||
|
||||
346
app/schemas/redeem_code.py
Normal file
346
app/schemas/redeem_code.py
Normal file
@@ -0,0 +1,346 @@
|
||||
"""
|
||||
兑换码相关 Schema
|
||||
|
||||
定义兑换码数据的验证和序列化规则。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from app.schemas.base import BaseSchema, PaginatedResponse
|
||||
from app.schemas.balance import UNITS_PER_DISPLAY, display_to_units, format_display
|
||||
from app.models.redeem_code import RedeemCodeStatus
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 用户兑换 Schema
|
||||
# ============================================================
|
||||
|
||||
class RedeemRequest(BaseSchema):
|
||||
"""兑换请求"""
|
||||
|
||||
code: Annotated[
|
||||
str,
|
||||
Field(
|
||||
min_length=1,
|
||||
max_length=32,
|
||||
description="兑换码",
|
||||
examples=["ABCD-EFGH-JKLM-NPQR"],
|
||||
),
|
||||
]
|
||||
|
||||
@field_validator("code")
|
||||
@classmethod
|
||||
def normalize_code(cls, v: str) -> str:
|
||||
"""标准化兑换码格式"""
|
||||
# 移除空格,转大写
|
||||
return v.strip().upper().replace(" ", "")
|
||||
|
||||
|
||||
class RedeemResponse(BaseSchema):
|
||||
"""兑换响应"""
|
||||
|
||||
success: bool = True
|
||||
message: str = "兑换成功"
|
||||
face_value: str = Field(description="兑换金额")
|
||||
balance_before: str = Field(description="兑换前余额")
|
||||
balance_after: str = Field(description="兑换后余额")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 兑换码信息 Schema
|
||||
# ============================================================
|
||||
|
||||
class RedeemCodeResponse(BaseSchema):
|
||||
"""兑换码信息响应"""
|
||||
|
||||
id: str
|
||||
code: str
|
||||
face_value_units: int = Field(description="面值(单位额度)")
|
||||
status: RedeemCodeStatus
|
||||
max_uses: int
|
||||
used_count: int
|
||||
expires_at: datetime | None
|
||||
used_at: datetime | None
|
||||
created_at: datetime
|
||||
|
||||
@property
|
||||
def face_value(self) -> str:
|
||||
"""显示面值(2 位小数)"""
|
||||
return format_display(self.face_value_units)
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
"""是否有效"""
|
||||
if self.status != RedeemCodeStatus.ACTIVE:
|
||||
return False
|
||||
if self.used_count >= self.max_uses:
|
||||
return False
|
||||
if self.expires_at and self.expires_at < datetime.now(self.expires_at.tzinfo):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class RedeemCodeDetailResponse(RedeemCodeResponse):
|
||||
"""兑换码详细信息响应(管理员用)"""
|
||||
|
||||
batch_id: str | None
|
||||
batch_name: str | None = None
|
||||
remark: str | None
|
||||
created_by: str | None
|
||||
used_by: str | None
|
||||
|
||||
|
||||
class RedeemCodeListResponse(PaginatedResponse[RedeemCodeDetailResponse]):
|
||||
"""兑换码列表响应"""
|
||||
pass
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 批次 Schema
|
||||
# ============================================================
|
||||
|
||||
class BatchCreateRequest(BaseSchema):
|
||||
"""创建兑换码批次请求"""
|
||||
|
||||
name: Annotated[
|
||||
str,
|
||||
Field(
|
||||
min_length=1,
|
||||
max_length=128,
|
||||
description="批次名称",
|
||||
examples=["2024年1月活动"],
|
||||
),
|
||||
]
|
||||
description: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
default=None,
|
||||
max_length=500,
|
||||
description="批次描述",
|
||||
),
|
||||
]
|
||||
face_value: Annotated[
|
||||
float,
|
||||
Field(
|
||||
gt=0,
|
||||
description="面值(显示金额)",
|
||||
examples=[10.00, 50.00],
|
||||
),
|
||||
]
|
||||
count: Annotated[
|
||||
int,
|
||||
Field(
|
||||
gt=0,
|
||||
le=10000,
|
||||
description="生成数量",
|
||||
examples=[100],
|
||||
),
|
||||
]
|
||||
max_uses: Annotated[
|
||||
int,
|
||||
Field(
|
||||
default=1,
|
||||
gt=0,
|
||||
le=100,
|
||||
description="每个兑换码最大使用次数",
|
||||
),
|
||||
]
|
||||
expires_at: Annotated[
|
||||
datetime | None,
|
||||
Field(
|
||||
default=None,
|
||||
description="过期时间",
|
||||
),
|
||||
]
|
||||
|
||||
@field_validator("face_value")
|
||||
@classmethod
|
||||
def validate_face_value(cls, v: float) -> float:
|
||||
"""验证面值精度"""
|
||||
if round(v, 3) != v:
|
||||
raise ValueError("面值精度不能超过 3 位小数")
|
||||
return v
|
||||
|
||||
@property
|
||||
def face_value_units(self) -> int:
|
||||
"""转换为单位额度"""
|
||||
return display_to_units(self.face_value)
|
||||
|
||||
|
||||
class BatchResponse(BaseSchema):
|
||||
"""批次信息响应"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str | None
|
||||
face_value_units: int
|
||||
total_count: int
|
||||
used_count: int
|
||||
created_by: str | None
|
||||
created_at: datetime
|
||||
|
||||
@property
|
||||
def face_value(self) -> str:
|
||||
"""显示面值(2 位小数)"""
|
||||
return format_display(self.face_value_units)
|
||||
|
||||
@property
|
||||
def unused_count(self) -> int:
|
||||
"""未使用数量"""
|
||||
return self.total_count - self.used_count
|
||||
|
||||
|
||||
class BatchDetailResponse(BatchResponse):
|
||||
"""批次详细信息响应"""
|
||||
|
||||
codes: list[RedeemCodeResponse] = []
|
||||
|
||||
|
||||
class BatchListResponse(PaginatedResponse[BatchResponse]):
|
||||
"""批次列表响应"""
|
||||
pass
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 导入导出 Schema
|
||||
# ============================================================
|
||||
|
||||
class ImportCodeRequest(BaseSchema):
|
||||
"""导入兑换码请求"""
|
||||
|
||||
code: Annotated[
|
||||
str,
|
||||
Field(
|
||||
min_length=1,
|
||||
max_length=32,
|
||||
description="兑换码",
|
||||
),
|
||||
]
|
||||
face_value: Annotated[
|
||||
float,
|
||||
Field(
|
||||
gt=0,
|
||||
description="面值",
|
||||
),
|
||||
]
|
||||
max_uses: Annotated[
|
||||
int,
|
||||
Field(
|
||||
default=1,
|
||||
gt=0,
|
||||
),
|
||||
]
|
||||
expires_at: datetime | None = None
|
||||
remark: str | None = None
|
||||
|
||||
@field_validator("code")
|
||||
@classmethod
|
||||
def normalize_code(cls, v: str) -> str:
|
||||
"""标准化兑换码"""
|
||||
return v.strip().upper()
|
||||
|
||||
@property
|
||||
def face_value_units(self) -> int:
|
||||
"""转换为单位额度"""
|
||||
return display_to_units(self.face_value)
|
||||
|
||||
|
||||
class BulkImportRequest(BaseSchema):
|
||||
"""批量导入兑换码请求"""
|
||||
|
||||
codes: Annotated[
|
||||
list[ImportCodeRequest],
|
||||
Field(
|
||||
min_length=1,
|
||||
max_length=1000,
|
||||
description="兑换码列表",
|
||||
),
|
||||
]
|
||||
batch_name: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
default=None,
|
||||
max_length=128,
|
||||
description="批次名称(可选)",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class BulkImportResponse(BaseSchema):
|
||||
"""批量导入响应"""
|
||||
|
||||
success_count: int
|
||||
failed_count: int
|
||||
failed_codes: list[str] = []
|
||||
batch_id: str | None = None
|
||||
|
||||
|
||||
class ExportCodeItem(BaseSchema):
|
||||
"""导出兑换码条目"""
|
||||
|
||||
code: str
|
||||
face_value: str
|
||||
status: str
|
||||
max_uses: int
|
||||
used_count: int
|
||||
expires_at: str | None
|
||||
created_at: str
|
||||
used_at: str | None
|
||||
used_by: str | None
|
||||
|
||||
|
||||
class ExportResponse(BaseSchema):
|
||||
"""导出响应"""
|
||||
|
||||
total: int
|
||||
codes: list[ExportCodeItem]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 使用日志 Schema
|
||||
# ============================================================
|
||||
|
||||
class UsageLogResponse(BaseSchema):
|
||||
"""兑换码使用日志响应"""
|
||||
|
||||
id: str
|
||||
redeem_code_id: str
|
||||
code_snapshot: str
|
||||
user_id: str
|
||||
username: str | None = None
|
||||
face_value: str
|
||||
ip_address: str | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class UsageLogListResponse(PaginatedResponse[UsageLogResponse]):
|
||||
"""使用日志列表响应"""
|
||||
pass
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 查询参数 Schema
|
||||
# ============================================================
|
||||
|
||||
class RedeemCodeQueryParams(BaseSchema):
|
||||
"""兑换码查询参数"""
|
||||
|
||||
status: RedeemCodeStatus | None = None
|
||||
batch_id: str | None = None
|
||||
code: str | None = None
|
||||
created_after: datetime | None = None
|
||||
created_before: datetime | None = None
|
||||
|
||||
|
||||
class UsageLogQueryParams(BaseSchema):
|
||||
"""使用日志查询参数"""
|
||||
|
||||
redeem_code_id: str | None = None
|
||||
user_id: str | None = None
|
||||
code: str | None = None
|
||||
created_after: datetime | None = None
|
||||
created_before: datetime | None = None
|
||||
|
||||
156
app/schemas/user.py
Normal file
156
app/schemas/user.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
用户相关 Schema
|
||||
|
||||
定义用户数据的验证和序列化规则。
|
||||
"""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import EmailStr, Field, field_validator
|
||||
|
||||
from app.core.config import settings
|
||||
from app.schemas.base import BaseSchema
|
||||
|
||||
|
||||
# 用户名正则:字母开头,只允许字母、数字、下划线
|
||||
USERNAME_PATTERN = re.compile(r"^[a-zA-Z][a-zA-Z0-9_]*$")
|
||||
|
||||
|
||||
class UserBase(BaseSchema):
|
||||
"""用户基础字段"""
|
||||
|
||||
username: Annotated[
|
||||
str,
|
||||
Field(
|
||||
min_length=settings.username_min_length,
|
||||
max_length=settings.username_max_length,
|
||||
description="用户名(字母开头,只允许字母、数字、下划线)",
|
||||
examples=["john_doe"],
|
||||
),
|
||||
]
|
||||
email: Annotated[
|
||||
EmailStr | None,
|
||||
Field(
|
||||
default=None,
|
||||
description="邮箱地址",
|
||||
examples=["user@example.com"],
|
||||
),
|
||||
]
|
||||
nickname: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
default=None,
|
||||
max_length=64,
|
||||
description="昵称",
|
||||
examples=["John"],
|
||||
),
|
||||
]
|
||||
|
||||
@field_validator("username")
|
||||
@classmethod
|
||||
def validate_username(cls, v: str) -> str:
|
||||
"""验证用户名格式"""
|
||||
if not USERNAME_PATTERN.match(v):
|
||||
raise ValueError("用户名必须以字母开头,只能包含字母、数字和下划线")
|
||||
return v.lower() # 统一转小写
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
"""用户注册请求"""
|
||||
|
||||
password: Annotated[
|
||||
str,
|
||||
Field(
|
||||
min_length=settings.password_min_length,
|
||||
max_length=settings.password_max_length,
|
||||
description="密码",
|
||||
examples=["SecurePass123"],
|
||||
),
|
||||
]
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def validate_password(cls, v: str) -> str:
|
||||
"""验证密码强度"""
|
||||
errors: list[str] = []
|
||||
|
||||
if settings.password_require_uppercase and not re.search(r"[A-Z]", v):
|
||||
errors.append("至少包含一个大写字母")
|
||||
|
||||
if settings.password_require_lowercase and not re.search(r"[a-z]", v):
|
||||
errors.append("至少包含一个小写字母")
|
||||
|
||||
if settings.password_require_digit and not re.search(r"\d", v):
|
||||
errors.append("至少包含一个数字")
|
||||
|
||||
if settings.password_require_special and not re.search(r"[!@#$%^&*(),.?\":{}|<>]", v):
|
||||
errors.append("至少包含一个特殊字符")
|
||||
|
||||
if errors:
|
||||
raise ValueError("密码强度不足:" + ";".join(errors))
|
||||
|
||||
return v
|
||||
|
||||
|
||||
class UserUpdate(BaseSchema):
|
||||
"""用户信息更新请求"""
|
||||
|
||||
nickname: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
default=None,
|
||||
max_length=64,
|
||||
description="昵称",
|
||||
),
|
||||
]
|
||||
email: Annotated[
|
||||
EmailStr | None,
|
||||
Field(
|
||||
default=None,
|
||||
description="邮箱地址",
|
||||
),
|
||||
]
|
||||
avatar_url: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
default=None,
|
||||
max_length=512,
|
||||
description="头像 URL",
|
||||
),
|
||||
]
|
||||
bio: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
default=None,
|
||||
max_length=500,
|
||||
description="个人简介",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class UserResponse(BaseSchema):
|
||||
"""用户信息响应"""
|
||||
|
||||
id: str
|
||||
username: str
|
||||
email: str | None
|
||||
nickname: str | None
|
||||
avatar_url: str | None
|
||||
bio: str | None
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
last_login_at: datetime | None
|
||||
|
||||
|
||||
class UserProfileResponse(BaseSchema):
|
||||
"""用户公开资料响应(不包含敏感信息)"""
|
||||
|
||||
id: str
|
||||
username: str
|
||||
nickname: str | None
|
||||
avatar_url: str | None
|
||||
bio: str | None
|
||||
created_at: datetime
|
||||
|
||||
Reference in New Issue
Block a user