- Add company module (3-tier org, CEO planning, parallel departments) - Add company orchestrator, knowledge extractor, presets, scheduler - Add company API endpoints, models, and frontend views - Add 今天吃啥 PWA app (69 dishes, real images, offline support) - Add team_projects output directory structure - Add unified manage.ps1 for service lifecycle - Add Windows startup guide v1.0 - Add TTS troubleshooting doc - Update frontend (AgentChat UX overhaul, new views) - Update backend (voice engine fix, multi-tenant, RBAC) - Remove deprecated startup scripts and old docs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
368 lines
12 KiB
Python
368 lines
12 KiB
Python
# ============================================================================
|
||
# 家庭阅读激励工具 - 兑换校验与事务引擎
|
||
# ============================================================================
|
||
"""
|
||
兑换引擎,负责奖品兑换的完整生命周期管理。
|
||
|
||
核心原则:
|
||
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)
|