271 lines
7.9 KiB
Python
271 lines
7.9 KiB
Python
"""
|
||
余额相关 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,
|
||
)
|
||
|