614 lines
18 KiB
Python
614 lines
18 KiB
Python
"""
|
||
管理员 - 兑换码管理 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,
|
||
)
|
||
|