提供基本前后端骨架

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

2
app/api/v1/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
"""API v1 模块"""

View File

@@ -0,0 +1,2 @@
"""API v1 端点"""

View File

@@ -0,0 +1,6 @@
"""管理员 API 端点"""
from app.api.v1.endpoints.admin import redeem_codes
__all__ = ["redeem_codes"]

View File

@@ -0,0 +1,613 @@
"""
管理员 - 兑换码管理 API
包括批量生成、导入导出、查询使用日志等接口。
"""
from datetime import datetime
from fastapi import APIRouter, HTTPException, Query, status
from fastapi.responses import JSONResponse
from app.api.deps import SuperUser, DbSession
from app.core.exceptions import ResourceNotFoundError, ValidationError
from app.schemas.base import APIResponse, PaginatedResponse
from app.schemas.balance import (
AdminAdjustmentRequest,
AdminBalanceResponse,
format_display,
)
from app.schemas.redeem_code import (
BatchCreateRequest,
BatchResponse,
BatchDetailResponse,
BatchListResponse,
RedeemCodeDetailResponse,
RedeemCodeListResponse,
BulkImportRequest,
BulkImportResponse,
ExportResponse,
ExportCodeItem,
UsageLogResponse,
UsageLogListResponse,
)
from app.services.balance import BalanceService, InsufficientBalanceError
from app.services.redeem_code import RedeemCodeService
from app.models.redeem_code import RedeemCodeStatus
router = APIRouter()
# ============================================================
# 批次管理
# ============================================================
@router.post(
"/batches",
response_model=APIResponse[BatchResponse],
summary="创建兑换码批次",
description="批量生成指定数量的兑换码",
)
async def create_batch(
request: BatchCreateRequest,
current_user: SuperUser,
session: DbSession,
) -> APIResponse[BatchResponse]:
"""
创建兑换码批次
批量生成指定数量的兑换码。
- **name**: 批次名称
- **face_value**: 面值(如 10.00
- **count**: 生成数量(最大 10000
- **max_uses**: 每个兑换码最大使用次数
- **expires_at**: 过期时间(可选)
"""
redeem_service = RedeemCodeService(session)
try:
batch = await redeem_service.create_batch(
name=request.name,
face_value_units=request.face_value_units,
count=request.count,
created_by=current_user.id,
description=request.description,
max_uses=request.max_uses,
expires_at=request.expires_at,
)
return APIResponse.ok(
data=BatchResponse(
id=batch.id,
name=batch.name,
description=batch.description,
face_value_units=batch.face_value,
total_count=batch.total_count,
used_count=batch.used_count,
created_by=batch.created_by,
created_at=batch.created_at,
),
message=f"成功创建批次,生成 {request.count} 个兑换码",
)
except ValidationError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=e.message,
)
@router.get(
"/batches",
response_model=APIResponse[BatchListResponse],
summary="获取批次列表",
description="获取所有兑换码批次",
)
async def get_batches(
current_user: SuperUser,
session: DbSession,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
) -> APIResponse[BatchListResponse]:
"""获取批次列表"""
redeem_service = RedeemCodeService(session)
offset = (page - 1) * page_size
batches, total = await redeem_service.get_batches(
offset=offset,
limit=page_size,
)
items = [
BatchResponse(
id=b.id,
name=b.name,
description=b.description,
face_value_units=b.face_value,
total_count=b.total_count,
used_count=b.used_count,
created_by=b.created_by,
created_at=b.created_at,
)
for b in batches
]
return APIResponse.ok(
data=BatchListResponse.create(
items=items,
total=total,
page=page,
page_size=page_size,
),
)
@router.get(
"/batches/{batch_id}",
response_model=APIResponse[BatchDetailResponse],
summary="获取批次详情",
description="获取指定批次的详细信息",
)
async def get_batch_detail(
batch_id: str,
current_user: SuperUser,
session: DbSession,
) -> APIResponse[BatchDetailResponse]:
"""获取批次详情"""
redeem_service = RedeemCodeService(session)
try:
batch = await redeem_service.get_batch_detail(batch_id)
return APIResponse.ok(
data=BatchDetailResponse(
id=batch.id,
name=batch.name,
description=batch.description,
face_value_units=batch.face_value,
total_count=batch.total_count,
used_count=batch.used_count,
created_by=batch.created_by,
created_at=batch.created_at,
),
)
except ResourceNotFoundError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=e.message,
)
# ============================================================
# 兑换码管理
# ============================================================
@router.get(
"/codes",
response_model=APIResponse[RedeemCodeListResponse],
summary="获取兑换码列表",
description="获取所有兑换码,支持多种过滤条件",
)
async def get_codes(
current_user: SuperUser,
session: DbSession,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
status_filter: RedeemCodeStatus | None = Query(None, alias="status"),
batch_id: str | None = Query(None),
code: str | None = Query(None, description="兑换码模糊搜索"),
created_after: datetime | None = Query(None),
created_before: datetime | None = Query(None),
) -> APIResponse[RedeemCodeListResponse]:
"""
获取兑换码列表
支持过滤:
- **status**: 状态active/used/disabled/expired
- **batch_id**: 批次 ID
- **code**: 兑换码模糊搜索
- **created_after/created_before**: 创建时间范围
"""
redeem_service = RedeemCodeService(session)
offset = (page - 1) * page_size
codes, total = await redeem_service.get_codes(
offset=offset,
limit=page_size,
status=status_filter,
batch_id=batch_id,
code_like=code,
created_after=created_after,
created_before=created_before,
)
items = [
RedeemCodeDetailResponse(
id=c.id,
code=c.code,
face_value_units=c.face_value,
status=c.status,
max_uses=c.max_uses,
used_count=c.used_count,
expires_at=c.expires_at,
used_at=c.used_at,
created_at=c.created_at,
batch_id=c.batch_id,
batch_name=c.batch.name if c.batch else None,
remark=c.remark,
created_by=c.created_by,
used_by=c.used_by,
)
for c in codes
]
return APIResponse.ok(
data=RedeemCodeListResponse.create(
items=items,
total=total,
page=page,
page_size=page_size,
),
)
@router.get(
"/codes/{code_id}",
response_model=APIResponse[RedeemCodeDetailResponse],
summary="获取兑换码详情",
)
async def get_code_detail(
code_id: str,
current_user: SuperUser,
session: DbSession,
) -> APIResponse[RedeemCodeDetailResponse]:
"""获取兑换码详情"""
redeem_service = RedeemCodeService(session)
try:
code = await redeem_service.get_code_detail(code_id)
return APIResponse.ok(
data=RedeemCodeDetailResponse(
id=code.id,
code=code.code,
face_value_units=code.face_value,
status=code.status,
max_uses=code.max_uses,
used_count=code.used_count,
expires_at=code.expires_at,
used_at=code.used_at,
created_at=code.created_at,
batch_id=code.batch_id,
batch_name=code.batch.name if code.batch else None,
remark=code.remark,
created_by=code.created_by,
used_by=code.used_by,
),
)
except ResourceNotFoundError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=e.message,
)
@router.post(
"/codes/{code_id}/disable",
response_model=APIResponse[RedeemCodeDetailResponse],
summary="禁用兑换码",
)
async def disable_code(
code_id: str,
current_user: SuperUser,
session: DbSession,
) -> APIResponse[RedeemCodeDetailResponse]:
"""禁用指定兑换码"""
redeem_service = RedeemCodeService(session)
try:
code = await redeem_service.disable_code(code_id)
return APIResponse.ok(
data=RedeemCodeDetailResponse(
id=code.id,
code=code.code,
face_value_units=code.face_value,
status=code.status,
max_uses=code.max_uses,
used_count=code.used_count,
expires_at=code.expires_at,
used_at=code.used_at,
created_at=code.created_at,
batch_id=code.batch_id,
remark=code.remark,
created_by=code.created_by,
used_by=code.used_by,
),
message="兑换码已禁用",
)
except ResourceNotFoundError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=e.message,
)
@router.post(
"/codes/{code_id}/enable",
response_model=APIResponse[RedeemCodeDetailResponse],
summary="启用兑换码",
)
async def enable_code(
code_id: str,
current_user: SuperUser,
session: DbSession,
) -> APIResponse[RedeemCodeDetailResponse]:
"""重新启用指定兑换码"""
redeem_service = RedeemCodeService(session)
try:
code = await redeem_service.enable_code(code_id)
return APIResponse.ok(
data=RedeemCodeDetailResponse(
id=code.id,
code=code.code,
face_value_units=code.face_value,
status=code.status,
max_uses=code.max_uses,
used_count=code.used_count,
expires_at=code.expires_at,
used_at=code.used_at,
created_at=code.created_at,
batch_id=code.batch_id,
remark=code.remark,
created_by=code.created_by,
used_by=code.used_by,
),
message="兑换码已启用",
)
except ResourceNotFoundError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=e.message,
)
# ============================================================
# 导入导出
# ============================================================
@router.post(
"/codes/import",
response_model=APIResponse[BulkImportResponse],
summary="批量导入兑换码",
description="批量导入自定义兑换码",
)
async def import_codes(
request: BulkImportRequest,
current_user: SuperUser,
session: DbSession,
) -> APIResponse[BulkImportResponse]:
"""
批量导入兑换码
可以导入自定义格式的兑换码。
- **codes**: 兑换码列表
- **batch_name**: 批次名称(可选)
"""
redeem_service = RedeemCodeService(session)
# 转换请求数据
codes_data = [
{
"code": c.code,
"face_value_units": c.face_value_units,
"max_uses": c.max_uses,
"expires_at": c.expires_at,
"remark": c.remark,
}
for c in request.codes
]
result = await redeem_service.import_codes(
codes_data,
created_by=current_user.id,
batch_name=request.batch_name,
)
return APIResponse.ok(
data=BulkImportResponse(
success_count=result["success_count"],
failed_count=result["failed_count"],
failed_codes=result["failed_codes"],
batch_id=result["batch_id"],
),
message=f"导入完成:成功 {result['success_count']},失败 {result['failed_count']}",
)
@router.get(
"/codes/export",
response_model=APIResponse[ExportResponse],
summary="导出兑换码",
description="导出兑换码数据",
)
async def export_codes(
current_user: SuperUser,
session: DbSession,
batch_id: str | None = Query(None),
status_filter: RedeemCodeStatus | None = Query(None, alias="status"),
limit: int = Query(10000, ge=1, le=50000),
) -> APIResponse[ExportResponse]:
"""
导出兑换码
支持按批次或状态过滤。
"""
redeem_service = RedeemCodeService(session)
codes = await redeem_service.export_codes(
batch_id=batch_id,
status=status_filter,
limit=limit,
)
items = [
ExportCodeItem(
code=c["code"],
face_value=c["face_value"],
status=c["status"],
max_uses=c["max_uses"],
used_count=c["used_count"],
expires_at=c["expires_at"],
created_at=c["created_at"],
used_at=c["used_at"],
used_by=c["used_by"],
)
for c in codes
]
return APIResponse.ok(
data=ExportResponse(
total=len(items),
codes=items,
),
)
# ============================================================
# 使用日志
# ============================================================
@router.get(
"/usage-logs",
response_model=APIResponse[UsageLogListResponse],
summary="获取使用日志",
description="查询兑换码使用记录",
)
async def get_usage_logs(
current_user: SuperUser,
session: DbSession,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
redeem_code_id: str | None = Query(None),
user_id: str | None = Query(None),
code: str | None = Query(None, description="兑换码模糊搜索"),
created_after: datetime | None = Query(None),
created_before: datetime | None = Query(None),
) -> APIResponse[UsageLogListResponse]:
"""
获取使用日志
支持过滤:
- **redeem_code_id**: 兑换码 ID
- **user_id**: 用户 ID
- **code**: 兑换码模糊搜索
- **created_after/created_before**: 使用时间范围
"""
redeem_service = RedeemCodeService(session)
offset = (page - 1) * page_size
logs, total = await redeem_service.get_usage_logs(
offset=offset,
limit=page_size,
redeem_code_id=redeem_code_id,
user_id=user_id,
code_like=code,
created_after=created_after,
created_before=created_before,
)
items = [
UsageLogResponse(
id=log.id,
redeem_code_id=log.redeem_code_id,
code_snapshot=log.code_snapshot,
user_id=log.user_id,
username=log.user.username if log.user else None,
face_value=format_display(log.face_value),
ip_address=log.ip_address,
created_at=log.created_at,
)
for log in logs
]
return APIResponse.ok(
data=UsageLogListResponse.create(
items=items,
total=total,
page=page,
page_size=page_size,
),
)
# ============================================================
# 余额管理
# ============================================================
@router.post(
"/balance/adjust",
response_model=APIResponse[AdminBalanceResponse],
summary="调整用户余额",
description="管理员手动调整用户余额",
)
async def adjust_balance(
request: AdminAdjustmentRequest,
current_user: SuperUser,
session: DbSession,
) -> APIResponse[AdminBalanceResponse]:
"""
调整用户余额
- **user_id**: 目标用户 ID
- **amount**: 调整金额(正数增加,负数减少)
- **reason**: 调整原因
"""
balance_service = BalanceService(session)
try:
transaction = await balance_service.admin_adjust(
request.user_id,
request.amount_units,
operator_id=current_user.id,
reason=request.reason,
)
# 获取更新后的余额
balance = await balance_service.get_balance(request.user_id)
return APIResponse.ok(
data=AdminBalanceResponse(
user_id=balance.user_id,
username=balance.user.username if balance.user else "",
balance=format_display(balance.balance),
available_balance=format_display(balance.available_balance),
frozen_balance=format_display(balance.frozen_balance),
total_recharged=format_display(balance.total_recharged),
total_consumed=format_display(balance.total_consumed),
version=balance.version,
created_at=balance.created_at,
updated_at=balance.updated_at,
),
message=f"余额调整成功,变动 {request.amount:+.2f}",
)
except InsufficientBalanceError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=e.message,
)
except ValidationError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=e.message,
)

View File

@@ -0,0 +1,191 @@
"""
认证相关 API
包括注册、登录、退出、刷新令牌、修改密码等接口。
"""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import ActiveUser, DbSession, get_auth_service
from app.core.exceptions import (
InvalidCredentialsError,
PasswordValidationError,
ResourceConflictError,
TokenError,
UserDisabledError,
)
from app.schemas.auth import (
LoginRequest,
PasswordChangeRequest,
RefreshTokenRequest,
TokenResponse,
)
from app.schemas.base import APIResponse
from app.schemas.user import UserCreate, UserResponse
from app.services.auth import AuthService
from app.services.user import UserService
router = APIRouter()
@router.post(
"/register",
response_model=APIResponse[UserResponse],
status_code=status.HTTP_201_CREATED,
summary="用户注册",
description="创建新用户账户",
)
async def register(
user_data: UserCreate,
session: DbSession,
) -> APIResponse[UserResponse]:
"""
用户注册接口
- **username**: 用户名字母开头3-32位
- **email**: 邮箱(可选)
- **password**: 密码8位以上需包含大小写字母和数字
- **nickname**: 昵称(可选)
"""
user_service = UserService(session)
try:
user = await user_service.create_user(user_data)
return APIResponse.ok(
data=UserResponse.model_validate(user),
message="注册成功",
)
except ResourceConflictError as e:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=e.message,
)
@router.post(
"/login",
response_model=APIResponse[TokenResponse],
summary="用户登录",
description="使用用户名/邮箱和密码登录",
)
async def login(
login_data: LoginRequest,
auth_service: Annotated[AuthService, Depends(get_auth_service)],
) -> APIResponse[TokenResponse]:
"""
用户登录接口
- **username**: 用户名或邮箱
- **password**: 密码
返回访问令牌和刷新令牌。
"""
try:
_, tokens = await auth_service.login(
username=login_data.username,
password=login_data.password,
)
return APIResponse.ok(data=tokens, message="登录成功")
except InvalidCredentialsError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=e.message,
headers={"WWW-Authenticate": "Bearer"},
)
except UserDisabledError as e:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=e.message,
)
@router.post(
"/logout",
response_model=APIResponse[None],
summary="用户退出",
description="退出登录(客户端应删除本地令牌)",
)
async def logout(
current_user: ActiveUser,
) -> APIResponse[None]:
"""
用户退出接口
由于使用的是无状态 JWT服务端不存储令牌
因此退出登录主要由客户端删除本地存储的令牌实现。
如果需要实现令牌黑名单,可以在后续版本中添加。
"""
# 可以在这里添加令牌黑名单逻辑
# 或者记录退出日志
return APIResponse.ok(message="退出成功")
@router.post(
"/refresh",
response_model=APIResponse[TokenResponse],
summary="刷新令牌",
description="使用刷新令牌获取新的访问令牌",
)
async def refresh_token(
token_data: RefreshTokenRequest,
auth_service: Annotated[AuthService, Depends(get_auth_service)],
) -> APIResponse[TokenResponse]:
"""
刷新令牌接口
使用刷新令牌获取新的访问令牌和刷新令牌。
"""
try:
tokens = await auth_service.refresh_tokens(token_data.refresh_token)
return APIResponse.ok(data=tokens, message="刷新成功")
except TokenError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=e.message,
headers={"WWW-Authenticate": "Bearer"},
)
except UserDisabledError as e:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=e.message,
)
@router.post(
"/change-password",
response_model=APIResponse[None],
summary="修改密码",
description="修改当前用户密码",
)
async def change_password(
password_data: PasswordChangeRequest,
current_user: ActiveUser,
auth_service: Annotated[AuthService, Depends(get_auth_service)],
) -> APIResponse[None]:
"""
修改密码接口
- **current_password**: 当前密码
- **new_password**: 新密码
"""
try:
await auth_service.change_password(
user_id=current_user.id,
password_data=password_data,
)
return APIResponse.ok(message="密码修改成功")
except InvalidCredentialsError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=e.message,
)
except PasswordValidationError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=e.message,
)

View File

@@ -0,0 +1,270 @@
"""
余额相关 API
包括余额查询、交易记录、兑换等接口。
"""
from fastapi import APIRouter, HTTPException, Query, Request, status
from app.api.deps import ActiveUser, DbSession
from app.core.exceptions import AppException, ValidationError
from app.schemas.base import APIResponse, PaginatedResponse
from app.schemas.balance import (
BalanceResponse,
TransactionResponse,
DeductionRequest,
DeductionResponse,
UNITS_PER_DISPLAY,
format_display,
)
from app.schemas.redeem_code import RedeemRequest, RedeemResponse
from app.services.balance import (
BalanceService,
InsufficientBalanceError,
DuplicateTransactionError,
)
from app.services.redeem_code import (
RedeemCodeService,
RedeemCodeNotFoundError,
RedeemCodeInvalidError,
RedeemCodeExpiredError,
RedeemCodeUsedError,
RedeemCodeDisabledError,
)
from app.models.balance import TransactionType
router = APIRouter()
# ============================================================
# 余额查询
# ============================================================
@router.get(
"",
response_model=APIResponse[BalanceResponse],
summary="获取当前用户余额",
description="获取当前登录用户的余额信息",
)
async def get_my_balance(
current_user: ActiveUser,
session: DbSession,
) -> APIResponse[BalanceResponse]:
"""
获取当前用户余额
返回:
- **balance**: 当前总余额
- **available_balance**: 可用余额(总余额 - 冻结)
- **frozen_balance**: 冻结余额
- **total_recharged**: 累计充值
- **total_consumed**: 累计消费
"""
balance_service = BalanceService(session)
balance = await balance_service.get_balance(current_user.id)
return APIResponse.ok(
data=BalanceResponse(
user_id=balance.user_id,
balance_units=balance.balance,
frozen_units=balance.frozen_balance,
total_recharged_units=balance.total_recharged,
total_consumed_units=balance.total_consumed,
),
)
# ============================================================
# 交易记录
# ============================================================
@router.get(
"/transactions",
response_model=APIResponse[PaginatedResponse[TransactionResponse]],
summary="获取交易记录",
description="获取当前用户的余额交易记录",
)
async def get_my_transactions(
current_user: ActiveUser,
session: DbSession,
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
transaction_type: TransactionType | None = Query(
None, description="交易类型过滤"
),
) -> APIResponse[PaginatedResponse[TransactionResponse]]:
"""
获取交易记录
支持按交易类型过滤:
- **recharge**: 充值
- **deduction**: 扣款
- **refund**: 退款
- **adjustment**: 管理员调整
"""
balance_service = BalanceService(session)
offset = (page - 1) * page_size
transactions, total = await balance_service.get_transactions(
current_user.id,
offset=offset,
limit=page_size,
transaction_type=transaction_type,
)
items = [
TransactionResponse(
id=t.id,
transaction_type=t.transaction_type,
status=t.status,
amount_units=t.amount,
balance_before_units=t.balance_before,
balance_after_units=t.balance_after,
reference_type=t.reference_type,
reference_id=t.reference_id,
description=t.description,
created_at=t.created_at,
)
for t in transactions
]
return APIResponse.ok(
data=PaginatedResponse.create(
items=items,
total=total,
page=page,
page_size=page_size,
),
)
# ============================================================
# 兑换码兑换
# ============================================================
@router.post(
"/redeem",
response_model=APIResponse[RedeemResponse],
summary="兑换余额",
description="使用兑换码充值余额",
)
async def redeem_code(
request: Request,
redeem_request: RedeemRequest,
current_user: ActiveUser,
session: DbSession,
) -> APIResponse[RedeemResponse]:
"""
使用兑换码充值余额
- **code**: 兑换码(格式如 XXXX-XXXX-XXXX-XXXX
返回兑换结果,包括充值金额和余额变化。
"""
redeem_service = RedeemCodeService(session)
# 获取客户端信息
ip_address = request.client.host if request.client else None
user_agent = request.headers.get("user-agent")
try:
result = await redeem_service.redeem(
current_user.id,
redeem_request.code,
ip_address=ip_address,
user_agent=user_agent,
)
return APIResponse.ok(
data=RedeemResponse(
success=True,
message="兑换成功",
face_value=result["face_value"],
balance_before=result["balance_before"],
balance_after=result["balance_after"],
),
message="兑换成功",
)
except RedeemCodeNotFoundError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=e.message,
)
except (
RedeemCodeInvalidError,
RedeemCodeExpiredError,
RedeemCodeUsedError,
RedeemCodeDisabledError,
) as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=e.message,
)
# ============================================================
# 扣款(内部 API / 服务间调用)
# ============================================================
@router.post(
"/deduct",
response_model=APIResponse[DeductionResponse],
summary="扣款",
description="从当前用户余额中扣款(通常由其他服务调用)",
include_in_schema=True, # 可设为 False 隐藏此 API
)
async def deduct_balance(
deduction_request: DeductionRequest,
current_user: ActiveUser,
session: DbSession,
) -> APIResponse[DeductionResponse]:
"""
扣款
从当前用户余额中扣除指定金额。
- **amount**: 扣款金额(如 1.00
- **reference_type**: 关联业务类型(如 api_call
- **reference_id**: 关联业务 ID
- **description**: 交易描述
- **idempotency_key**: 幂等键(防止重复扣款)
"""
balance_service = BalanceService(session)
try:
transaction = await balance_service.deduct(
current_user.id,
deduction_request.amount_units,
reference_type=deduction_request.reference_type,
reference_id=deduction_request.reference_id,
description=deduction_request.description,
idempotency_key=deduction_request.idempotency_key,
)
return APIResponse.ok(
data=DeductionResponse(
transaction_id=transaction.id,
amount=format_display(abs(transaction.amount)),
balance_before=format_display(transaction.balance_before),
balance_after=format_display(transaction.balance_after),
),
message="扣款成功",
)
except InsufficientBalanceError as e:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail=e.message,
)
except DuplicateTransactionError as e:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=e.message,
)
except ValidationError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=e.message,
)

View File

@@ -0,0 +1,157 @@
"""
OAuth2 认证 API
提供 OAuth2 第三方登录功能。
"""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import DbSession
from app.core.config import settings
from app.core.exceptions import AuthenticationError
from app.schemas.base import APIResponse
from app.schemas.oauth2 import OAuth2AuthorizeResponse, OAuth2LoginResponse
from app.services.oauth2 import OAuth2Service, OAuth2StateError
router = APIRouter()
def get_oauth2_service(session: DbSession) -> OAuth2Service:
"""获取 OAuth2 服务实例"""
return OAuth2Service(session)
def _get_redirect_uri(request: Request) -> str:
"""
获取 OAuth2 回调 URL
根据请求动态构建完整的回调 URL
"""
# 优先使用 X-Forwarded 头(反向代理场景)
scheme = request.headers.get("X-Forwarded-Proto", request.url.scheme)
host = request.headers.get("X-Forwarded-Host", request.url.netloc)
return f"{scheme}://{host}{settings.oauth2_callback_path}"
@router.get(
"/authorize",
response_model=APIResponse[OAuth2AuthorizeResponse],
summary="获取 OAuth2 授权 URL",
description="获取第三方登录授权页面 URL",
)
async def get_authorize_url(
request: Request,
session: DbSession,
) -> APIResponse[OAuth2AuthorizeResponse]:
"""
获取 OAuth2 授权 URL
返回授权页面 URL 和状态码,客户端应重定向用户到该 URL。
流程:
1. 前端调用此接口获取授权 URL
2. 前端将用户重定向到授权 URL
3. 用户在第三方平台完成授权
4. 第三方平台重定向回回调 URL携带 code 和 state
5. 前端调用 /callback 接口完成登录
"""
# 检查 OAuth2 是否已配置
if not settings.oauth2_client_id or not settings.oauth2_client_secret:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="OAuth2 登录未配置",
)
oauth2_service = OAuth2Service(session)
redirect_uri = _get_redirect_uri(request)
authorize_url, state = oauth2_service.generate_authorize_url(redirect_uri)
return APIResponse.ok(
data=OAuth2AuthorizeResponse(
authorize_url=authorize_url,
state=state,
),
message="请重定向到授权 URL",
)
@router.get(
"/callback",
response_model=APIResponse[OAuth2LoginResponse],
summary="OAuth2 回调",
description="处理 OAuth2 授权回调,完成登录",
)
async def oauth2_callback(
request: Request,
code: Annotated[str, Query(description="授权码")],
state: Annotated[str, Query(description="状态码")],
session: DbSession,
) -> APIResponse[OAuth2LoginResponse]:
"""
OAuth2 回调接口
处理第三方平台的授权回调:
1. 验证 state 防止 CSRF 攻击
2. 用 code 换取访问令牌
3. 获取用户信息
4. 创建或关联本地用户
5. 返回 JWT 令牌
"""
oauth2_service = OAuth2Service(session)
redirect_uri = _get_redirect_uri(request)
try:
user, tokens, is_new_user = await oauth2_service.authenticate(
code=code,
state=state,
redirect_uri=redirect_uri,
)
return APIResponse.ok(
data=OAuth2LoginResponse(
access_token=tokens.access_token,
refresh_token=tokens.refresh_token,
token_type=tokens.token_type,
expires_in=tokens.expires_in,
is_new_user=is_new_user,
),
message="登录成功" if not is_new_user else "注册并登录成功",
)
except OAuth2StateError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=e.message,
)
except AuthenticationError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=e.message,
)
@router.post(
"/callback",
response_model=APIResponse[OAuth2LoginResponse],
summary="OAuth2 回调 (POST)",
description="处理 OAuth2 授权回调POST 方式)",
)
async def oauth2_callback_post(
request: Request,
code: Annotated[str, Query(description="授权码")],
state: Annotated[str, Query(description="状态码")],
session: DbSession,
) -> APIResponse[OAuth2LoginResponse]:
"""
OAuth2 回调接口POST 方式)
某些场景下前端可能使用 POST 方式调用回调。
逻辑与 GET 方式相同。
"""
return await oauth2_callback(request, code, state, session)

View File

@@ -0,0 +1,103 @@
"""
用户相关 API
包括获取用户信息、更新用户资料等接口。
"""
from fastapi import APIRouter, HTTPException, status
from app.api.deps import ActiveUser, DbSession
from app.core.exceptions import ResourceConflictError, UserNotFoundError
from app.schemas.base import APIResponse
from app.schemas.user import UserResponse, UserUpdate
from app.services.user import UserService
router = APIRouter()
@router.get(
"/me",
response_model=APIResponse[UserResponse],
summary="获取当前用户信息",
description="获取当前登录用户的详细信息",
)
async def get_current_user_info(
current_user: ActiveUser,
) -> APIResponse[UserResponse]:
"""
获取当前用户信息
返回当前登录用户的完整信息。
"""
return APIResponse.ok(
data=UserResponse.model_validate(current_user),
)
@router.patch(
"/me",
response_model=APIResponse[UserResponse],
summary="更新当前用户信息",
description="更新当前登录用户的资料",
)
async def update_current_user(
update_data: UserUpdate,
current_user: ActiveUser,
session: DbSession,
) -> APIResponse[UserResponse]:
"""
更新当前用户信息
支持更新:
- **nickname**: 昵称
- **email**: 邮箱
- **avatar_url**: 头像 URL
- **bio**: 个人简介
"""
user_service = UserService(session)
try:
user = await user_service.update_user(
user_id=current_user.id,
update_data=update_data,
)
return APIResponse.ok(
data=UserResponse.model_validate(user),
message="更新成功",
)
except ResourceConflictError as e:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=e.message,
)
@router.get(
"/{user_id}",
response_model=APIResponse[UserResponse],
summary="获取指定用户信息",
description="获取指定用户的公开信息",
)
async def get_user_by_id(
user_id: str,
current_user: ActiveUser,
session: DbSession,
) -> APIResponse[UserResponse]:
"""
获取指定用户信息
- **user_id**: 用户 ID
"""
user_service = UserService(session)
try:
user = await user_service.get_user_by_id(user_id)
return APIResponse.ok(
data=UserResponse.model_validate(user),
)
except UserNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="用户不存在",
)

45
app/api/v1/router.py Normal file
View File

@@ -0,0 +1,45 @@
"""
API v1 路由聚合
汇总所有 v1 版本的 API 路由。
"""
from fastapi import APIRouter
from app.api.v1.endpoints import auth, oauth2, users, balance
from app.api.v1.endpoints.admin import redeem_codes as admin_redeem_codes
api_router = APIRouter()
# 注册子路由
api_router.include_router(
auth.router,
prefix="/auth",
tags=["认证"],
)
api_router.include_router(
oauth2.router,
prefix="/auth/oauth2",
tags=["OAuth2 登录"],
)
api_router.include_router(
users.router,
prefix="/users",
tags=["用户"],
)
api_router.include_router(
balance.router,
prefix="/balance",
tags=["余额"],
)
# 管理员路由
api_router.include_router(
admin_redeem_codes.router,
prefix="/admin/redeem-codes",
tags=["管理员 - 兑换码"],
)