# ============================================================================ # 家庭阅读激励工具 - 兑换校验与事务引擎 # ============================================================================ """ 兑换引擎,负责奖品兑换的完整生命周期管理。 核心原则: 1. 兑换操作原子化:校验 → 扣积分 → 减库存 → 写记录,任一步失败全部回滚 2. 库存扣减使用乐观锁模式(SQLite 单写者场景天然安全) 3. 取消兑换时退还积分,状态流转 pending → cancelled 4. 所有操作记录在 points 审计日志中可追溯 入口方法: RedemptionEngine.redeem(child_id, reward_id, notes) → 发起兑换 RedemptionEngine.fulfill(record_id) → 家长确认发放 RedemptionEngine.cancel_redemption(record_id) → 取消并退还积分 RedemptionEngine.get_available_rewards(child_id) → 获取孩子可兑换的奖品列表 RedemptionEngine.check_eligibility(child_id, reward_id) → 检查是否满足兑换条件 """ from __future__ import annotations from dataclasses import dataclass, field from typing import Optional, List from datetime import datetime from db_config import transaction, get_connection from dal import ( ChildrenDAO, PointsDAO, RewardsDAO, RedemptionRecordsDAO, ) from models import Reward, RedemptionRecord # ============================================================================ # 兑换结果 # ============================================================================ @dataclass class RedemptionResult: """兑换操作结果。""" success: bool redemption_record: Optional[RedemptionRecord] = None error: str = "" new_balance: int = 0 @dataclass class EligibilityResult: """兑换资格检查结果。""" eligible: bool reason: str = "" points_required: int = 0 current_balance: int = 0 available_stock: int = 0 reward_name: str = "" # ============================================================================ # 兑换引擎 # ============================================================================ class RedemptionEngine: """奖品兑换校验与事务引擎。""" # ------------------------------------------------------------------ # 兑换资格检查(无副作用,只读) # ------------------------------------------------------------------ @staticmethod def check_eligibility(child_id: int, reward_id: int) -> EligibilityResult: """ 检查孩子是否满足兑换某奖品的条件。 检查项: 1. 孩子是否存在且活跃 2. 奖品是否存在且活跃 3. 积分余额是否充足 4. 库存是否充足 Returns: EligibilityResult(eligible=True 表示可以兑换) """ child = ChildrenDAO.get_by_id(child_id) if child is None: return EligibilityResult( eligible=False, reason="孩子账户不存在" ) if not child.is_active: return EligibilityResult( eligible=False, reason="孩子账户已归档" ) reward = RewardsDAO.get_by_id(reward_id) if reward is None: return EligibilityResult( eligible=False, reason="奖品不存在" ) if not reward.is_active: return EligibilityResult( eligible=False, reason="奖品已下架" ) balance = ChildrenDAO.get_points(child_id) available_stock = RewardsDAO.get_available_stock(reward_id) result = EligibilityResult( points_required=reward.points_required, current_balance=balance, available_stock=available_stock, reward_name=reward.name, ) if balance < reward.points_required: result.reason = ( f"积分不足: 需要 {reward.points_required} 分," f"当前 {balance} 分" ) return result # stock=-1 表示不限量 if reward.stock != -1 and available_stock <= 0: result.reason = "奖品库存不足" return result result.eligible = True result.reason = "可以兑换" return result @staticmethod def get_available_rewards(child_id: int) -> List[dict]: """ 获取孩子当前可兑换的奖品列表(含兑换资格标记)。 Returns: list[dict]: 每个奖品附带 can_redeem 和 reason 字段 """ rewards = RewardsDAO.list_active() balance = ChildrenDAO.get_points(child_id) result = [] for reward in rewards: available = RewardsDAO.get_available_stock(reward.id) can_redeem = True reason = "" if balance < reward.points_required: can_redeem = False reason = f"还差 {reward.points_required - balance} 分" elif reward.stock != -1 and available <= 0: can_redeem = False reason = "库存不足" reward_dict = reward.to_dict() reward_dict["can_redeem"] = can_redeem reward_dict["reason"] = reason reward_dict["available_stock"] = available result.append(reward_dict) return result # ------------------------------------------------------------------ # 兑换操作(写入,事务保证原子性) # ------------------------------------------------------------------ @staticmethod def redeem( child_id: int, reward_id: int, notes: Optional[str] = None, ) -> RedemptionResult: """ 发起兑换请求。 事务内完成: 1. 校验资格(余额、库存、状态) 2. 写入扣分记录(points, source_type='redemption') 3. 更新 children.total_points 4. 创建兑换记录(redemption_records, status='pending') Args: child_id: 孩子 ID reward_id: 奖品 ID notes: 备注(如孩子希望的颜色、尺寸等) Returns: RedemptionResult """ # 先做无副作用检查 eligibility = RedemptionEngine.check_eligibility(child_id, reward_id) if not eligibility.eligible: return RedemptionResult( success=False, error=eligibility.reason, ) reward = RewardsDAO.get_by_id(reward_id) if reward is None: return RedemptionResult(success=False, error="奖品不存在") points_to_spend = reward.points_required # 在单个事务内完成所有操作 try: with transaction() as conn: # 二次校验余额(防止事务间余额变化) current_balance = ChildrenDAO.get_points(child_id) if current_balance < points_to_spend: return RedemptionResult( success=False, error=f"积分不足: 需要 {points_to_spend} 分,当前 {current_balance} 分", ) # 二次校验库存 if reward.stock != -1: available = RewardsDAO.get_available_stock(reward_id) if available <= 0: return RedemptionResult( success=False, error="奖品库存不足" ) new_balance = current_balance - points_to_spend # 写入扣分记录 PointsDAO.create( child_id=child_id, source_type="redemption", points_change=-points_to_spend, balance_after=new_balance, reason=f"兑换: {reward.name}", ) # 更新孩子积分余额 ChildrenDAO.update(child_id, total_points=new_balance) # 创建兑换记录 redemption = RedemptionRecordsDAO.create( child_id=child_id, reward_id=reward_id, points_spent=points_to_spend, notes=notes, ) return RedemptionResult( success=True, redemption_record=redemption, new_balance=new_balance, ) except Exception as e: return RedemptionResult( success=False, error=f"兑换失败: {str(e)}", ) @staticmethod def fulfill(record_id: int) -> RedemptionResult: """ 家长确认发放奖品(pending → fulfilled)。 Args: record_id: 兑换记录 ID Returns: RedemptionResult """ record = RedemptionRecordsDAO.get_by_id(record_id) if record is None: return RedemptionResult(success=False, error="兑换记录不存在") if record.status != "pending": return RedemptionResult( success=False, error=f"兑换记录状态不是 pending(当前: {record.status})", ) updated = RedemptionRecordsDAO.fulfill(record_id) return RedemptionResult( success=True, redemption_record=updated, ) @staticmethod def cancel_redemption(record_id: int) -> RedemptionResult: """ 取消兑换并退还积分。 事务内完成: 1. 更新兑换记录状态为 cancelled 2. 退还积分(points 写入 + children.total_points 恢复) Args: record_id: 兑换记录 ID Returns: RedemptionResult """ record = RedemptionRecordsDAO.get_by_id(record_id) if record is None: return RedemptionResult(success=False, error="兑换记录不存在") if record.status != "pending": return RedemptionResult( success=False, error=f"只能取消 pending 状态的兑换(当前: {record.status})", ) refund_amount = record.points_spent reward_name = record.reward_name or f"奖品#{record.reward_id}" try: with transaction(): # 退还积分 current_balance = ChildrenDAO.get_points(record.child_id) new_balance = current_balance + refund_amount PointsDAO.create( child_id=record.child_id, source_type="adjustment", points_change=refund_amount, balance_after=new_balance, reason=f"取消兑换退还: {reward_name}", ) ChildrenDAO.update(record.child_id, total_points=new_balance) # 更新兑换记录状态 RedemptionRecordsDAO.cancel(record_id) updated = RedemptionRecordsDAO.get_by_id(record_id) return RedemptionResult( success=True, redemption_record=updated, new_balance=new_balance, ) except Exception as e: return RedemptionResult( success=False, error=f"取消兑换失败: {str(e)}", ) # ------------------------------------------------------------------ # 查询方法 # ------------------------------------------------------------------ @staticmethod def get_pending_redemptions(child_id: Optional[int] = None) -> List[RedemptionRecord]: """ 获取待处理的兑换记录。 Args: child_id: 可选,按孩子过滤 """ if child_id is not None: records = RedemptionRecordsDAO.list_by_child(child_id, limit=100) return [r for r in records if r.status == "pending"] return RedemptionRecordsDAO.list_pending() @staticmethod def get_redemption_history( child_id: int, limit: int = 30, offset: int = 0, ) -> List[RedemptionRecord]: """获取孩子的兑换历史。""" return RedemptionRecordsDAO.list_by_child(child_id, limit=limit, offset=offset)