- 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>
803 lines
30 KiB
Python
803 lines
30 KiB
Python
"""
|
||
================================================================================
|
||
09-redemption_service.py — 兑换校验与事务处理
|
||
================================================================================
|
||
|
||
功能:
|
||
1. 兑换资格校验: 积分余额、奖品库存、每日兑换上限、冷却时间
|
||
2. 原子兑换事务: 扣积分 → 减库存 → 流水记录 → 兑换记录,全成功或全回滚
|
||
3. 兑换管理: 家长端兑现(fulfill)、取消(cancel)、退还积分
|
||
4. 兑换历史: 按孩子/时间范围/状态查询
|
||
|
||
设计原则:
|
||
- 单一事务保证数据一致性
|
||
- 悲观锁(行锁)防止超兑
|
||
- 详细的校验错误信息,前端可直接展示
|
||
|
||
依赖:
|
||
- 03-models.py: RedemptionRecord, Reward
|
||
- 04-dal.py: RedemptionDAO, RewardDAO, PointDAO
|
||
- 08-points_engine.py: PointsEngine (积分流水写入)
|
||
- 02-db_config.py: get_connection
|
||
|
||
用法:
|
||
from redemption_service import RedemptionService
|
||
svc = RedemptionService(conn)
|
||
result = svc.redeem(child_id=1, reward_id=3)
|
||
# => RedeemResult(success=True, record_id=5, ...)
|
||
================================================================================
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from dataclasses import dataclass, field
|
||
from datetime import date, datetime, timedelta
|
||
from enum import Enum
|
||
from typing import Any, Optional
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
# ============================================================================
|
||
# 数据模型
|
||
# ============================================================================
|
||
|
||
class RedeemErrorCode(Enum):
|
||
"""兑换失败错误码"""
|
||
INSUFFICIENT_POINTS = "insufficient_points" # 积分不足
|
||
OUT_OF_STOCK = "out_of_stock" # 库存不足
|
||
DAILY_LIMIT_REACHED = "daily_limit_reached" # 达到每日兑换上限
|
||
COOLDOWN_ACTIVE = "cooldown_active" # 冷却期内
|
||
REWARD_INACTIVE = "reward_inactive" # 奖品已下架
|
||
CHILD_INACTIVE = "child_inactive" # 孩子账户已停用
|
||
UNKNOWN = "unknown_error"
|
||
|
||
|
||
@dataclass
|
||
class RedeemResult:
|
||
"""兑换结果"""
|
||
success: bool
|
||
record_id: Optional[int] = None
|
||
error_code: Optional[RedeemErrorCode] = None
|
||
error_message: str = ""
|
||
points_spent: int = 0
|
||
balance_after: int = 0
|
||
reward_name: str = ""
|
||
child_name: str = ""
|
||
|
||
def to_dict(self) -> dict:
|
||
return {
|
||
"success": self.success,
|
||
"record_id": self.record_id,
|
||
"error_code": self.error_code.value if self.error_code else None,
|
||
"error_message": self.error_message,
|
||
"points_spent": self.points_spent,
|
||
"balance_after": self.balance_after,
|
||
"reward_name": self.reward_name,
|
||
"child_name": self.child_name,
|
||
}
|
||
|
||
|
||
@dataclass
|
||
class RedemptionSummary:
|
||
"""兑换汇总(家长视角)"""
|
||
child_id: int
|
||
child_name: str
|
||
total_redeemed: int # 累计兑换次数
|
||
total_points_spent: int # 累计消耗积分
|
||
pending_count: int # 待兑现数
|
||
fulfilled_count: int # 已兑现数
|
||
cancelled_count: int # 已取消数
|
||
today_count: int # 今日兑换次数
|
||
|
||
|
||
# ============================================================================
|
||
# 默认配置
|
||
# ============================================================================
|
||
|
||
DEFAULT_REDEMPTION_RULES: dict[str, Any] = {
|
||
"redeem.daily_limit": 3, # 每日兑换上限(每孩子)
|
||
"redeem.cooldown_minutes": 0, # 兑换冷却时间(分钟,0=无冷却)
|
||
"redeem.allow_negative_balance": False, # 是否允许积分透支兑换
|
||
"redeem.auto_fulfill": False, # 是否自动兑现(家长端可手动设为 True)
|
||
}
|
||
|
||
|
||
# ============================================================================
|
||
# 兑换服务
|
||
# ============================================================================
|
||
|
||
class RedemptionService:
|
||
"""
|
||
兑换服务 —— 处理奖品兑换全流程。
|
||
|
||
核心流程 (redeem):
|
||
1. 校验孩子状态 (is_active)
|
||
2. 校验奖品状态 (is_active + stock)
|
||
3. 校验每日兑换上限
|
||
4. 校验冷却时间
|
||
5. 校验积分余额
|
||
6. 开启事务: 扣积分 → 减库存 → 写积分流水 → 创建兑换记录
|
||
7. 提交事务 / 异常回滚
|
||
|
||
注意: 此服务不直接操作 points_engine,而是内联积分扣减逻辑,
|
||
确保扣积分和写兑换记录在同一个事务中。
|
||
"""
|
||
|
||
def __init__(self, conn, rules: Optional[dict] = None):
|
||
"""
|
||
Args:
|
||
conn: SQLite 连接(sqlite3.Connection)
|
||
rules: 可选的自定义规则
|
||
"""
|
||
self._conn = conn
|
||
self._rules = {**DEFAULT_REDEMPTION_RULES, **(rules or {})}
|
||
self._load_rules_from_db()
|
||
|
||
# ------------------------------------------------------------------
|
||
# 规则管理
|
||
# ------------------------------------------------------------------
|
||
|
||
def _load_rules_from_db(self) -> None:
|
||
try:
|
||
cur = self._conn.execute(
|
||
"SELECT key, value FROM app_settings WHERE key LIKE 'redeem.%'"
|
||
)
|
||
for key, value in cur.fetchall():
|
||
if value.lower() in ("true", "false"):
|
||
self._rules[key] = value.lower() == "true"
|
||
elif value.replace(".", "").isdigit():
|
||
self._rules[key] = float(value) if "." in value else int(value)
|
||
else:
|
||
self._rules[key] = value
|
||
except Exception:
|
||
pass
|
||
|
||
def get_rule(self, key: str, default: Any = None) -> Any:
|
||
return self._rules.get(key, default)
|
||
|
||
# ------------------------------------------------------------------
|
||
# 校验方法(可在 redeem 前单独调用预览)
|
||
# ------------------------------------------------------------------
|
||
|
||
def check_eligibility(
|
||
self, child_id: int, reward_id: int
|
||
) -> RedeemResult:
|
||
"""
|
||
校验兑换资格(不执行兑换)。
|
||
|
||
Returns:
|
||
RedeemResult(success=True) 表示可以兑换,
|
||
RedeemResult(success=False) 含具体错误码和信息。
|
||
"""
|
||
# 1. 校验孩子
|
||
cur = self._conn.execute(
|
||
"SELECT id, name, total_points, is_active FROM children WHERE id = ?",
|
||
(child_id,),
|
||
)
|
||
child = cur.fetchone()
|
||
if child is None:
|
||
return RedeemResult(
|
||
success=False,
|
||
error_code=RedeemErrorCode.CHILD_INACTIVE,
|
||
error_message="孩子账户不存在",
|
||
)
|
||
if not child["is_active"]:
|
||
return RedeemResult(
|
||
success=False,
|
||
error_code=RedeemErrorCode.CHILD_INACTIVE,
|
||
error_message="孩子账户已停用",
|
||
)
|
||
|
||
# 2. 校验奖品
|
||
cur = self._conn.execute(
|
||
"SELECT id, name, points_required, stock, is_active FROM rewards WHERE id = ?",
|
||
(reward_id,),
|
||
)
|
||
reward = cur.fetchone()
|
||
if reward is None or not reward["is_active"]:
|
||
return RedeemResult(
|
||
success=False,
|
||
error_code=RedeemErrorCode.REWARD_INACTIVE,
|
||
error_message="奖品不存在或已下架",
|
||
)
|
||
|
||
# 3. 校验库存 (stock=-1 = 无限)
|
||
if reward["stock"] == 0:
|
||
return RedeemResult(
|
||
success=False,
|
||
error_code=RedeemErrorCode.OUT_OF_STOCK,
|
||
error_message=f"奖品「{reward['name']}」已兑完",
|
||
)
|
||
|
||
# 4. 校验积分
|
||
if child["total_points"] < reward["points_required"]:
|
||
return RedeemResult(
|
||
success=False,
|
||
error_code=RedeemErrorCode.INSUFFICIENT_POINTS,
|
||
error_message=(
|
||
f"积分不足: 需要 {reward['points_required']},"
|
||
f"当前 {child['total_points']}"
|
||
),
|
||
)
|
||
|
||
# 5. 校验每日兑换上限
|
||
today = date.today().isoformat()
|
||
daily_limit = self.get_rule("redeem.daily_limit", 3)
|
||
if daily_limit > 0:
|
||
cur = self._conn.execute(
|
||
"SELECT COUNT(*) FROM redemption_records "
|
||
"WHERE child_id = ? AND date(created_at) = ? AND status != 'cancelled'",
|
||
(child_id, today),
|
||
)
|
||
today_count = cur.fetchone()[0]
|
||
if today_count >= daily_limit:
|
||
return RedeemResult(
|
||
success=False,
|
||
error_code=RedeemErrorCode.DAILY_LIMIT_REACHED,
|
||
error_message=f"今日已兑换 {today_count} 次,达到上限 {daily_limit} 次",
|
||
)
|
||
|
||
# 6. 校验冷却时间
|
||
cooldown = self.get_rule("redeem.cooldown_minutes", 0)
|
||
if cooldown > 0:
|
||
cutoff = (datetime.now() - timedelta(minutes=cooldown)).isoformat()
|
||
cur = self._conn.execute(
|
||
"SELECT COUNT(*) FROM redemption_records "
|
||
"WHERE child_id = ? AND created_at > ? AND status != 'cancelled'",
|
||
(child_id, cutoff),
|
||
)
|
||
if cur.fetchone()[0] > 0:
|
||
return RedeemResult(
|
||
success=False,
|
||
error_code=RedeemErrorCode.COOLDOWN_ACTIVE,
|
||
error_message=f"冷却中,请 {cooldown} 分钟后再兑换",
|
||
)
|
||
|
||
return RedeemResult(
|
||
success=True,
|
||
points_spent=reward["points_required"],
|
||
reward_name=reward["name"],
|
||
child_name=child["name"],
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# 核心兑换(原子事务)
|
||
# ------------------------------------------------------------------
|
||
|
||
def redeem(self, child_id: int, reward_id: int) -> RedeemResult:
|
||
"""
|
||
执行兑换 —— 单事务完成校验+扣减+记录。
|
||
|
||
Returns:
|
||
RedeemResult(success=True, record_id=...) 或含错误信息。
|
||
"""
|
||
# 先做校验(事务外,避免持锁太久)
|
||
eligibility = self.check_eligibility(child_id, reward_id)
|
||
if not eligibility.success:
|
||
return eligibility
|
||
|
||
# --- 事务开始 ---
|
||
# SQLite 默认 autocommit 模式,BEGIN 开启显式事务
|
||
try:
|
||
self._conn.execute("BEGIN IMMEDIATE")
|
||
|
||
# 1. 重新在事务内读取最新数据(防止幻读)
|
||
cur = self._conn.execute(
|
||
"SELECT total_points FROM children WHERE id = ?",
|
||
(child_id,),
|
||
)
|
||
child_balance = cur.fetchone()["total_points"]
|
||
|
||
cur = self._conn.execute(
|
||
"SELECT name, points_required, stock FROM rewards WHERE id = ?",
|
||
(reward_id,),
|
||
)
|
||
reward = cur.fetchone()
|
||
points_needed = reward["points_required"]
|
||
|
||
# 二次校验(事务内,因为 COMMIT 前可能有并发写)
|
||
if child_balance < points_needed:
|
||
self._conn.execute("ROLLBACK")
|
||
return RedeemResult(
|
||
success=False,
|
||
error_code=RedeemErrorCode.INSUFFICIENT_POINTS,
|
||
error_message=f"积分不足: 需 {points_needed},当前 {child_balance}",
|
||
)
|
||
|
||
if reward["stock"] == 0:
|
||
self._conn.execute("ROLLBACK")
|
||
return RedeemResult(
|
||
success=False,
|
||
error_code=RedeemErrorCode.OUT_OF_STOCK,
|
||
error_message=f"奖品「{reward['name']}」已被兑完",
|
||
)
|
||
|
||
# 2. 扣减积分(原子 SET)
|
||
new_balance = child_balance - points_needed
|
||
now = datetime.now().isoformat()
|
||
self._conn.execute(
|
||
"UPDATE children SET total_points = ?, updated_at = ? WHERE id = ?",
|
||
(new_balance, now, child_id),
|
||
)
|
||
|
||
# 3. 扣减库存(stock=-1 无限库存跳过)
|
||
if reward["stock"] > 0:
|
||
self._conn.execute(
|
||
"UPDATE rewards SET stock = stock - 1 WHERE id = ? AND stock > 0",
|
||
(reward_id,),
|
||
)
|
||
|
||
# 4. 写入积分流水(负值 = 消耗)
|
||
cur = self._conn.execute(
|
||
"INSERT INTO points (child_id, points_change, balance_after, reason, "
|
||
"reference_type, reference_id, created_at) VALUES (?, ?, ?, ?, 'redeem', ?, ?)",
|
||
(child_id, -points_needed, new_balance,
|
||
f"兑换「{reward['name']}」", reward_id, now),
|
||
)
|
||
point_record_id = cur.lastrowid
|
||
|
||
# 更新 reference_id 回指(points.reference_id → redemption_records.id,双向关联)
|
||
# 先创建兑换记录,再回写
|
||
|
||
# 5. 创建兑换记录
|
||
cur = self._conn.execute(
|
||
"INSERT INTO redemption_records (child_id, reward_id, points_spent, "
|
||
"status, created_at, updated_at) VALUES (?, ?, ?, 'pending', ?, ?)",
|
||
(child_id, reward_id, points_needed, now, now),
|
||
)
|
||
record_id = cur.lastrowid
|
||
|
||
# 6. 回写积分流水的 reference_id(指向兑换记录)
|
||
self._conn.execute(
|
||
"UPDATE points SET reference_id = ? WHERE id = ?",
|
||
(record_id, point_record_id),
|
||
)
|
||
|
||
self._conn.execute("COMMIT")
|
||
|
||
# 查询孩子姓名用于返回
|
||
cur = self._conn.execute("SELECT name FROM children WHERE id = ?", (child_id,))
|
||
child_name = cur.fetchone()["name"]
|
||
|
||
logger.info(
|
||
"兑换成功: child=%s(%d) reward=%s(%d) points=%d balance=%d record=%d",
|
||
child_name, child_id, reward["name"], reward_id,
|
||
points_needed, new_balance, record_id,
|
||
)
|
||
|
||
return RedeemResult(
|
||
success=True,
|
||
record_id=record_id,
|
||
points_spent=points_needed,
|
||
balance_after=new_balance,
|
||
reward_name=reward["name"],
|
||
child_name=child_name,
|
||
)
|
||
|
||
except Exception as e:
|
||
self._conn.execute("ROLLBACK")
|
||
logger.error("兑换事务回滚: %s", e)
|
||
return RedeemResult(
|
||
success=False,
|
||
error_code=RedeemErrorCode.UNKNOWN,
|
||
error_message=f"兑换失败: {e}",
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# 兑换管理(家长端)
|
||
# ------------------------------------------------------------------
|
||
|
||
def fulfill(self, record_id: int) -> bool:
|
||
"""家长兑现奖品(pending → fulfilled)"""
|
||
return self._update_status(record_id, "fulfilled")
|
||
|
||
def cancel(self, record_id: int, refund_points: bool = True) -> RedeemResult:
|
||
"""
|
||
取消兑换(pending → cancelled),可选退还积分。
|
||
|
||
退还积分在独立事务中完成。
|
||
"""
|
||
# 读取兑换记录
|
||
cur = self._conn.execute(
|
||
"SELECT r.id, r.child_id, r.reward_id, r.points_spent, r.status, "
|
||
"rw.name as reward_name, rw.stock, c.name as child_name "
|
||
"FROM redemption_records r "
|
||
"JOIN rewards rw ON r.reward_id = rw.id "
|
||
"JOIN children c ON r.child_id = c.id "
|
||
"WHERE r.id = ?",
|
||
(record_id,),
|
||
)
|
||
row = cur.fetchone()
|
||
if row is None:
|
||
return RedeemResult(
|
||
success=False,
|
||
error_code=RedeemErrorCode.UNKNOWN,
|
||
error_message="兑换记录不存在",
|
||
)
|
||
if row["status"] != "pending":
|
||
return RedeemResult(
|
||
success=False,
|
||
error_code=RedeemErrorCode.UNKNOWN,
|
||
error_message=f"仅可取消 pending 状态记录,当前: {row['status']}",
|
||
)
|
||
|
||
try:
|
||
self._conn.execute("BEGIN IMMEDIATE")
|
||
now = datetime.now().isoformat()
|
||
|
||
# 更新状态
|
||
self._conn.execute(
|
||
"UPDATE redemption_records SET status = 'cancelled', updated_at = ? "
|
||
"WHERE id = ?",
|
||
(now, record_id),
|
||
)
|
||
|
||
# 退还库存(stock=-1 无限库存跳过)
|
||
if row["stock"] >= 0:
|
||
self._conn.execute(
|
||
"UPDATE rewards SET stock = stock + 1 WHERE id = ?",
|
||
(row["reward_id"],),
|
||
)
|
||
|
||
# 退还积分
|
||
if refund_points:
|
||
cur = self._conn.execute(
|
||
"SELECT total_points FROM children WHERE id = ?",
|
||
(row["child_id"],),
|
||
)
|
||
current_balance = cur.fetchone()["total_points"]
|
||
new_balance = current_balance + row["points_spent"]
|
||
|
||
self._conn.execute(
|
||
"UPDATE children SET total_points = ?, updated_at = ? WHERE id = ?",
|
||
(new_balance, now, row["child_id"]),
|
||
)
|
||
|
||
# 积分退还流水
|
||
self._conn.execute(
|
||
"INSERT INTO points (child_id, points_change, balance_after, reason, "
|
||
"reference_type, reference_id, created_at) VALUES (?, ?, ?, ?, 'redeem_refund', ?, ?)",
|
||
(row["child_id"], row["points_spent"], new_balance,
|
||
f"取消兑换「{row['reward_name']}」退还积分", record_id, now),
|
||
)
|
||
|
||
self._conn.execute("COMMIT")
|
||
|
||
logger.info(
|
||
"兑换取消: record=%d child=%s points=%d refund=%s",
|
||
record_id, row["child_name"], row["points_spent"], refund_points,
|
||
)
|
||
|
||
return RedeemResult(
|
||
success=True,
|
||
record_id=record_id,
|
||
points_spent=row["points_spent"],
|
||
reward_name=row["reward_name"],
|
||
child_name=row["child_name"],
|
||
)
|
||
|
||
except Exception as e:
|
||
self._conn.execute("ROLLBACK")
|
||
logger.error("取消兑换事务回滚: %s", e)
|
||
return RedeemResult(
|
||
success=False,
|
||
error_code=RedeemErrorCode.UNKNOWN,
|
||
error_message=f"取消失败: {e}",
|
||
)
|
||
|
||
def _update_status(self, record_id: int, new_status: str) -> bool:
|
||
"""更新兑换记录状态(仅 pending → fulfilled/cancelled)"""
|
||
now = datetime.now().isoformat()
|
||
cur = self._conn.execute(
|
||
"UPDATE redemption_records SET status = ?, updated_at = ? "
|
||
"WHERE id = ? AND status = 'pending'",
|
||
(new_status, now, record_id),
|
||
)
|
||
self._conn.commit()
|
||
return cur.rowcount > 0
|
||
|
||
# ------------------------------------------------------------------
|
||
# 查询方法
|
||
# ------------------------------------------------------------------
|
||
|
||
def get_redemption(self, record_id: int) -> Optional[dict]:
|
||
"""查询单条兑换记录"""
|
||
cur = self._conn.execute(
|
||
"SELECT r.*, rw.name as reward_name, c.name as child_name "
|
||
"FROM redemption_records r "
|
||
"JOIN rewards rw ON r.reward_id = rw.id "
|
||
"JOIN children c ON r.child_id = c.id "
|
||
"WHERE r.id = ?",
|
||
(record_id,),
|
||
)
|
||
row = cur.fetchone()
|
||
return dict(row) if row else None
|
||
|
||
def list_by_child(
|
||
self,
|
||
child_id: int,
|
||
status: Optional[str] = None,
|
||
limit: int = 50,
|
||
offset: int = 0,
|
||
) -> list[dict]:
|
||
"""查询孩子的兑换记录"""
|
||
query = (
|
||
"SELECT r.*, rw.name as reward_name, rw.image_path "
|
||
"FROM redemption_records r "
|
||
"JOIN rewards rw ON r.reward_id = rw.id "
|
||
"WHERE r.child_id = ?"
|
||
)
|
||
params: list = [child_id]
|
||
if status:
|
||
query += " AND r.status = ?"
|
||
params.append(status)
|
||
query += " ORDER BY r.created_at DESC LIMIT ? OFFSET ?"
|
||
params.extend([limit, offset])
|
||
|
||
cur = self._conn.execute(query, params)
|
||
return [dict(row) for row in cur.fetchall()]
|
||
|
||
def list_pending(self, child_id: Optional[int] = None) -> list[dict]:
|
||
"""查询待兑现的兑换记录(家长端待办)"""
|
||
query = (
|
||
"SELECT r.*, rw.name as reward_name, c.name as child_name "
|
||
"FROM redemption_records r "
|
||
"JOIN rewards rw ON r.reward_id = rw.id "
|
||
"JOIN children c ON r.child_id = c.id "
|
||
"WHERE r.status = 'pending'"
|
||
)
|
||
params: list = []
|
||
if child_id is not None:
|
||
query += " AND r.child_id = ?"
|
||
params.append(child_id)
|
||
query += " ORDER BY r.created_at ASC"
|
||
|
||
cur = self._conn.execute(query, params)
|
||
return [dict(row) for row in cur.fetchall()]
|
||
|
||
def get_summary(self, child_id: int) -> RedemptionSummary:
|
||
"""获取孩子兑换汇总"""
|
||
cur = self._conn.execute(
|
||
"SELECT name FROM children WHERE id = ?", (child_id,)
|
||
)
|
||
child_name = cur.fetchone()["name"]
|
||
|
||
cur = self._conn.execute(
|
||
"SELECT COUNT(*) as total, "
|
||
"COALESCE(SUM(points_spent), 0) as total_spent, "
|
||
"SUM(CASE WHEN status='pending' THEN 1 ELSE 0 END) as pending, "
|
||
"SUM(CASE WHEN status='fulfilled' THEN 1 ELSE 0 END) as fulfilled, "
|
||
"SUM(CASE WHEN status='cancelled' THEN 1 ELSE 0 END) as cancelled "
|
||
"FROM redemption_records WHERE child_id = ?",
|
||
(child_id,),
|
||
)
|
||
row = cur.fetchone()
|
||
|
||
today = date.today().isoformat()
|
||
cur = self._conn.execute(
|
||
"SELECT COUNT(*) FROM redemption_records "
|
||
"WHERE child_id = ? AND date(created_at) = ? AND status != 'cancelled'",
|
||
(child_id, today),
|
||
)
|
||
today_count = cur.fetchone()[0]
|
||
|
||
return RedemptionSummary(
|
||
child_id=child_id,
|
||
child_name=child_name,
|
||
total_redeemed=row["total"],
|
||
total_points_spent=row["total_spent"],
|
||
pending_count=row["pending"],
|
||
fulfilled_count=row["fulfilled"],
|
||
cancelled_count=row["cancelled"],
|
||
today_count=today_count,
|
||
)
|
||
|
||
def get_affordable_rewards(self, child_id: int) -> list[dict]:
|
||
"""查询孩子积分足够兑换的奖品列表(含积分差距)"""
|
||
cur = self._conn.execute(
|
||
"SELECT rw.*, c.total_points as child_points, "
|
||
"(c.total_points - rw.points_required) as points_after "
|
||
"FROM rewards rw, children c "
|
||
"WHERE c.id = ? AND rw.is_active = 1 "
|
||
"AND (rw.stock > 0 OR rw.stock = -1) "
|
||
"AND c.total_points >= rw.points_required "
|
||
"ORDER BY rw.points_required ASC",
|
||
(child_id,),
|
||
)
|
||
return [dict(row) for row in cur.fetchall()]
|
||
|
||
|
||
# ============================================================================
|
||
# 便捷工厂
|
||
# ============================================================================
|
||
|
||
def create_redemption_service(db_path: str = "reading_app.db") -> RedemptionService:
|
||
import sqlite3
|
||
conn = sqlite3.connect(db_path)
|
||
conn.row_factory = sqlite3.Row
|
||
conn.execute("PRAGMA journal_mode=WAL")
|
||
return RedemptionService(conn)
|
||
|
||
|
||
# ============================================================================
|
||
# 自测
|
||
# ============================================================================
|
||
|
||
def _self_test():
|
||
import sqlite3
|
||
import os
|
||
import tempfile
|
||
|
||
db_path = os.path.join(tempfile.gettempdir(), "test_redemption.db")
|
||
conn = sqlite3.connect(db_path)
|
||
conn.row_factory = sqlite3.Row
|
||
|
||
conn.executescript("""
|
||
CREATE TABLE children (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
name TEXT NOT NULL,
|
||
age INTEGER,
|
||
total_points INTEGER DEFAULT 0,
|
||
is_active INTEGER DEFAULT 1,
|
||
created_at TEXT DEFAULT (datetime('now')),
|
||
updated_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
CREATE TABLE rewards (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
name TEXT NOT NULL,
|
||
description TEXT,
|
||
points_required INTEGER NOT NULL,
|
||
stock INTEGER DEFAULT -1,
|
||
image_path TEXT,
|
||
is_active INTEGER DEFAULT 1,
|
||
created_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
CREATE TABLE redemption_records (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
child_id INTEGER NOT NULL,
|
||
reward_id INTEGER NOT NULL,
|
||
points_spent INTEGER NOT NULL,
|
||
status TEXT DEFAULT 'pending' CHECK(status IN ('pending','fulfilled','cancelled')),
|
||
created_at TEXT DEFAULT (datetime('now')),
|
||
updated_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
CREATE TABLE points (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
child_id INTEGER NOT NULL,
|
||
points_change INTEGER NOT NULL,
|
||
balance_after INTEGER NOT NULL,
|
||
reason TEXT,
|
||
reference_type TEXT DEFAULT 'redeem',
|
||
reference_id INTEGER,
|
||
created_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
CREATE TABLE app_settings (
|
||
key TEXT PRIMARY KEY,
|
||
value TEXT,
|
||
updated_at TEXT
|
||
);
|
||
|
||
INSERT INTO children (id, name, total_points) VALUES (1, '小明', 100);
|
||
INSERT INTO children (id, name, total_points) VALUES (2, '小红', 5);
|
||
INSERT INTO rewards (id, name, points_required, stock) VALUES (1, '贴纸', 10, 50);
|
||
INSERT INTO rewards (id, name, points_required, stock) VALUES (2, '绘本', 50, 3);
|
||
INSERT INTO rewards (id, name, points_required, stock) VALUES (3, '玩具车', 200, 1);
|
||
INSERT INTO rewards (id, name, points_required, stock, is_active) VALUES (4, '下架奖品', 10, 10, 0);
|
||
INSERT INTO rewards (id, name, points_required, stock) VALUES (5, '无限库存贴纸', 10, -1);
|
||
""")
|
||
conn.commit()
|
||
|
||
svc = RedemptionService(conn)
|
||
passed = 0
|
||
failed = 0
|
||
tests = []
|
||
|
||
def check(name, cond, detail=""):
|
||
nonlocal passed, failed
|
||
if cond:
|
||
passed += 1
|
||
tests.append(f" ✅ {name}")
|
||
else:
|
||
failed += 1
|
||
tests.append(f" ❌ {name}: {detail}")
|
||
|
||
print("=" * 60)
|
||
print(" 09-redemption_service.py 自测")
|
||
print("=" * 60)
|
||
|
||
# Test 1: 资格校验 - 积分不足
|
||
result = svc.check_eligibility(child_id=2, reward_id=1)
|
||
check("积分不足检测", not result.success and result.error_code == RedeemErrorCode.INSUFFICIENT_POINTS)
|
||
|
||
# Test 2: 资格校验 - 库存不足(兑完)
|
||
# 先兑掉唯一库存
|
||
conn.execute("UPDATE rewards SET stock = 0 WHERE id = 3")
|
||
result = svc.check_eligibility(child_id=1, reward_id=3)
|
||
check("库存不足检测", not result.success and result.error_code == RedeemErrorCode.OUT_OF_STOCK)
|
||
conn.execute("UPDATE rewards SET stock = 1 WHERE id = 3")
|
||
|
||
# Test 3: 资格校验 - 奖品下架
|
||
result = svc.check_eligibility(child_id=1, reward_id=4)
|
||
check("奖品下架检测", not result.success and result.error_code == RedeemErrorCode.REWARD_INACTIVE)
|
||
|
||
# Test 4: 资格校验 - 通过
|
||
result = svc.check_eligibility(child_id=1, reward_id=1)
|
||
check("资格校验通过", result.success)
|
||
|
||
# Test 5: 执行兑换
|
||
result = svc.redeem(child_id=1, reward_id=1)
|
||
check("兑换成功", result.success and result.record_id is not None,
|
||
f"success={result.success}, id={result.record_id}")
|
||
check("积分扣减", result.points_spent == 10, f"spent={result.points_spent}")
|
||
check("余额正确", result.balance_after == 90, f"balance={result.balance_after}")
|
||
|
||
# Test 6: 兑换后余额验证
|
||
cur = conn.execute("SELECT total_points FROM children WHERE id = 1")
|
||
check("DB余额=90", cur.fetchone()["total_points"] == 90)
|
||
|
||
# Test 7: 兑换后库存验证
|
||
cur = conn.execute("SELECT stock FROM rewards WHERE id = 1")
|
||
check("库存减1", cur.fetchone()["stock"] == 49)
|
||
|
||
# Test 8: 积分流水记录
|
||
cur = conn.execute("SELECT COUNT(*) as cnt FROM points WHERE child_id = 1 AND points_change < 0")
|
||
check("积分流水已写入", cur.fetchone()["cnt"] >= 1)
|
||
|
||
# Test 9: 无限库存兑换
|
||
result = svc.redeem(child_id=1, reward_id=5)
|
||
check("无限库存兑换成功", result.success)
|
||
cur = conn.execute("SELECT stock FROM rewards WHERE id = 5")
|
||
check("无限库存不变", cur.fetchone()["stock"] == -1)
|
||
|
||
# Test 10: 兑换待兑现列表
|
||
pending = svc.list_pending()
|
||
check("待兑现列表", len(pending) >= 1, f"count={len(pending)}")
|
||
|
||
# Test 11: 兑现
|
||
record_id = pending[0]["id"]
|
||
ok = svc.fulfill(record_id)
|
||
check("兑现操作", ok)
|
||
cur = conn.execute("SELECT status FROM redemption_records WHERE id = ?", (record_id,))
|
||
check("状态变为 fulfilled", cur.fetchone()["status"] == "fulfilled")
|
||
|
||
# Test 12: 取消兑换(退还积分)
|
||
# 先再兑换一个
|
||
result = svc.redeem(child_id=1, reward_id=1) # 余额 80 → 70
|
||
cancel_result = svc.cancel(result.record_id, refund_points=True)
|
||
check("取消兑换成功", cancel_result.success)
|
||
cur = conn.execute("SELECT total_points FROM children WHERE id = 1")
|
||
check("积分已退还", cur.fetchone()["total_points"] == 80, f"expected 80")
|
||
|
||
# Test 13: 汇总
|
||
summary = svc.get_summary(1)
|
||
check("汇总查询", summary.total_redeemed >= 1)
|
||
|
||
# Test 14: 可兑换奖品列表
|
||
affordable = svc.get_affordable_rewards(1)
|
||
check("可兑换列表", len(affordable) >= 1, f"count={len(affordable)}")
|
||
|
||
# Test 15: 每日上限(模拟)
|
||
svc._rules["redeem.daily_limit"] = 0 # 设为0禁止
|
||
result = svc.check_eligibility(child_id=1, reward_id=1)
|
||
check("每日上限检测", not result.success and result.error_code == RedeemErrorCode.DAILY_LIMIT_REACHED,
|
||
f"code={result.error_code}")
|
||
svc._rules["redeem.daily_limit"] = 3 # 恢复
|
||
|
||
# 清理
|
||
conn.close()
|
||
os.unlink(db_path)
|
||
|
||
print(f"\n结果: {passed} passed, {failed} failed")
|
||
for t in tests:
|
||
print(t)
|
||
|
||
return failed == 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
||
success = _self_test()
|
||
exit(0 if success else 1)
|