Files
aiagent/team_projects/b9b14925-f054-43d0-b2f0-6ed6b38dbe4b/Company-context-SQLiteFlutter-React-Nati/10-redemption_engine.py
renjianbo f2e65a8fbb feat: virtual company module, team projects, PWA dishes app, and startup scripts overhaul
- 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>
2026-07-10 22:37:56 +08:00

368 lines
12 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.
# ============================================================================
# 家庭阅读激励工具 - 兑换校验与事务引擎
# ============================================================================
"""
兑换引擎,负责奖品兑换的完整生命周期管理。
核心原则:
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)