Files
SatoNano/app/api/v1/endpoints/balance.py
2026-01-06 23:49:23 +08:00

271 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
余额相关 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,
)