Files
aiagent/team_projects/b9b14925-f054-43d0-b2f0-6ed6b38dbe4b/Company-context-SQLiteFlutter-React-Nati/09-redemption_service.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

803 lines
30 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.
"""
================================================================================
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)