提供基本前后端骨架

This commit is contained in:
hisatri
2026-01-06 23:49:23 +08:00
parent 84d4ccc226
commit 06f8176e23
89 changed files with 19293 additions and 2 deletions

75
app/schemas/__init__.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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