提供基本前后端骨架

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

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,
)