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