- 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>
369 lines
12 KiB
Python
369 lines
12 KiB
Python
# ============================================================================
|
||
# 家庭阅读激励工具 - 积分计算引擎
|
||
# ============================================================================
|
||
"""
|
||
积分计算引擎,负责所有积分获取/消耗的计算与执行。
|
||
|
||
核心原则:
|
||
1. 积分操作必须在事务内完成(points 写入 + children.total_points 更新)
|
||
2. 积分记录只增不改(审计日志)
|
||
3. 所有积分计算基于 PointsConfig 配置,无硬编码分值
|
||
4. 每日积分有硬上限,防止刷分
|
||
|
||
入口方法:
|
||
PointsEngine.add_reading_points(child_id, record_id) → 打卡后自动积分
|
||
PointsEngine.add_bonus_points(child_id, amount, reason) → 家长手动奖励
|
||
PointsEngine.adjust_points(child_id, amount, reason) → 家长手动调整
|
||
PointsEngine.get_today_reading_points(child_id) → 查询今日已获积分
|
||
PointsEngine.calculate_expected_points(duration, pages, streak) → 预计算积分
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Optional, NamedTuple
|
||
from datetime import date
|
||
|
||
from db_config import get_connection, transaction
|
||
from dal import ChildrenDAO, PointsDAO, ReadingRecordsDAO
|
||
from points_config import PointsConfig
|
||
|
||
|
||
# ============================================================================
|
||
# 积分计算结果
|
||
# ============================================================================
|
||
|
||
class PointsBreakdown(NamedTuple):
|
||
"""积分明细分解(用于前端展示积分来源)。"""
|
||
base: int = 0
|
||
streak_bonus: int = 0
|
||
duration_bonus: int = 0
|
||
pages_bonus: int = 0
|
||
total: int = 0
|
||
capped: bool = False # 是否触发每日上限
|
||
streak_days: int = 0 # 当前连续天数
|
||
|
||
|
||
# ============================================================================
|
||
# 积分引擎
|
||
# ============================================================================
|
||
|
||
class PointsEngine:
|
||
"""积分计算与执行引擎。"""
|
||
|
||
# ------------------------------------------------------------------
|
||
# 预计算(无副作用,不写数据库)
|
||
# ------------------------------------------------------------------
|
||
|
||
@staticmethod
|
||
def calculate_expected_points(
|
||
duration_minutes: int = 0,
|
||
pages_read: int = 0,
|
||
streak_days: int = 0,
|
||
config: Optional[PointsConfig] = None,
|
||
) -> PointsBreakdown:
|
||
"""
|
||
预计算某次打卡预期获得的积分(纯计算,不写数据库)。
|
||
|
||
用于前端实时预览「打卡后将获得 X 分」。
|
||
|
||
Args:
|
||
duration_minutes: 阅读时长(分钟)
|
||
pages_read: 阅读页数
|
||
streak_days: 当前连续打卡天数(打卡前)
|
||
config: 积分配置,不传则从数据库加载
|
||
|
||
Returns:
|
||
PointsBreakdown 积分明细
|
||
"""
|
||
if config is None:
|
||
config = PointsConfig.load()
|
||
|
||
base = config.base_points
|
||
streak_bonus = 0
|
||
duration_bonus = 0
|
||
pages_bonus = 0
|
||
|
||
# 连续打卡加成(基于打卡后的连续天数,即 streak_days + 1)
|
||
new_streak = streak_days + 1
|
||
if config.streak_enabled:
|
||
if new_streak >= 30:
|
||
streak_bonus = config.streak_threshold_30
|
||
elif new_streak >= 14:
|
||
streak_bonus = config.streak_threshold_14
|
||
elif new_streak >= 7:
|
||
streak_bonus = config.streak_threshold_7
|
||
|
||
# 阅读时长加成
|
||
if config.duration_enabled and duration_minutes > 0:
|
||
units = duration_minutes // config.duration_unit_minutes
|
||
duration_bonus = min(
|
||
units * config.duration_points_per_unit,
|
||
config.duration_max_bonus,
|
||
)
|
||
|
||
# 页数加成
|
||
if config.pages_enabled and pages_read > 0:
|
||
pages_bonus = min(
|
||
pages_read // config.pages_per_point,
|
||
config.pages_max_bonus,
|
||
)
|
||
|
||
raw_total = base + streak_bonus + duration_bonus + pages_bonus
|
||
|
||
# 每日硬上限
|
||
capped = raw_total > config.max_daily_points
|
||
total = min(raw_total, config.max_daily_points)
|
||
|
||
return PointsBreakdown(
|
||
base=base,
|
||
streak_bonus=streak_bonus,
|
||
duration_bonus=duration_bonus,
|
||
pages_bonus=pages_bonus,
|
||
total=total,
|
||
capped=capped,
|
||
streak_days=new_streak,
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# 核心积分操作
|
||
# ------------------------------------------------------------------
|
||
|
||
@staticmethod
|
||
def add_reading_points(
|
||
child_id: int,
|
||
record_id: int,
|
||
config: Optional[PointsConfig] = None,
|
||
) -> PointsBreakdown:
|
||
"""
|
||
阅读打卡后自动发放积分。
|
||
|
||
流程:
|
||
1. 读取打卡记录(时长、页数)
|
||
2. 计算当前连续天数
|
||
3. 获取今日已获积分(阅读类)
|
||
4. 计算本次应得积分(扣除今日已得部分)
|
||
5. 在事务内写入 points 记录 + 更新 children.total_points
|
||
|
||
Args:
|
||
child_id: 孩子 ID
|
||
record_id: 阅读打卡记录 ID
|
||
config: 积分配置(可选,不传自动加载)
|
||
|
||
Returns:
|
||
PointsBreakdown 实际发放的积分明细
|
||
|
||
Raises:
|
||
ValueError: 孩子/记录不存在
|
||
"""
|
||
if config is None:
|
||
config = PointsConfig.load()
|
||
|
||
# 1. 读取打卡记录
|
||
record = ReadingRecordsDAO.get_by_id(record_id)
|
||
if record is None:
|
||
raise ValueError(f"阅读记录不存在: id={record_id}")
|
||
if record.child_id != child_id:
|
||
raise ValueError(
|
||
f"记录归属不匹配: record.child_id={record.child_id}, expected={child_id}"
|
||
)
|
||
|
||
# 2. 计算连续天数(打卡前的连续天数)
|
||
streak_before = ReadingRecordsDAO.get_streak(child_id)
|
||
|
||
# 3. 获取今日已获阅读积分
|
||
today_points_already = PointsEngine.get_today_reading_points(child_id)
|
||
|
||
# 4. 计算本次应得积分
|
||
breakdown = PointsEngine.calculate_expected_points(
|
||
duration_minutes=record.duration_minutes,
|
||
pages_read=record.pages_read,
|
||
streak_days=streak_before,
|
||
config=config,
|
||
)
|
||
|
||
# 扣除今日已获取的阅读积分(同一孩子一天多次打卡,积分上限共享)
|
||
remaining_allowance = config.max_daily_points - today_points_already
|
||
if remaining_allowance <= 0:
|
||
return PointsBreakdown(
|
||
base=0, streak_bonus=0, duration_bonus=0,
|
||
pages_bonus=0, total=0, capped=True,
|
||
streak_days=breakdown.streak_days,
|
||
)
|
||
|
||
actual_total = min(breakdown.total, remaining_allowance)
|
||
|
||
if actual_total <= 0:
|
||
return PointsBreakdown(
|
||
base=0, streak_bonus=0, duration_bonus=0,
|
||
pages_bonus=0, total=0, capped=True,
|
||
streak_days=breakdown.streak_days,
|
||
)
|
||
|
||
# 5. 在事务内写入积分记录并更新余额
|
||
with transaction():
|
||
current_balance = ChildrenDAO.get_points(child_id)
|
||
new_balance = current_balance + actual_total
|
||
|
||
PointsDAO.create(
|
||
child_id=child_id,
|
||
source_type="reading",
|
||
source_id=record_id,
|
||
points_change=actual_total,
|
||
balance_after=new_balance,
|
||
reason=(
|
||
f"阅读打卡: {record.read_date}"
|
||
f" (基础+{config.base_points}"
|
||
f"{f', 连续{breakdown.streak_days}天+{breakdown.streak_bonus}' if breakdown.streak_bonus > 0 else ''}"
|
||
f"{f', 时长{record.duration_minutes}分钟+{breakdown.duration_bonus}' if breakdown.duration_bonus > 0 else ''}"
|
||
f"{f', 页数{record.pages_read}页+{breakdown.pages_bonus}' if breakdown.pages_bonus > 0 else ''}"
|
||
f")"
|
||
),
|
||
)
|
||
|
||
ChildrenDAO.update(child_id, total_points=new_balance)
|
||
|
||
# 返回实际发放的明细(如果被上限截断,按优先级裁剪)
|
||
if actual_total < breakdown.total:
|
||
shortage = breakdown.total - actual_total
|
||
# 优先保留 base + streak,从 pages 后 duration 开始裁剪
|
||
actual_pages = max(0, breakdown.pages_bonus - shortage)
|
||
shortage -= (breakdown.pages_bonus - actual_pages)
|
||
actual_duration = max(0, breakdown.duration_bonus - shortage)
|
||
|
||
return PointsBreakdown(
|
||
base=breakdown.base,
|
||
streak_bonus=breakdown.streak_bonus,
|
||
duration_bonus=actual_duration,
|
||
pages_bonus=actual_pages,
|
||
total=actual_total,
|
||
capped=True,
|
||
streak_days=breakdown.streak_days,
|
||
)
|
||
|
||
return breakdown
|
||
|
||
@staticmethod
|
||
def add_bonus_points(
|
||
child_id: int,
|
||
amount: int,
|
||
reason: str = "家长奖励",
|
||
) -> int:
|
||
"""
|
||
家长手动奖励积分(如完成家务、表现好等)。
|
||
|
||
Args:
|
||
child_id: 孩子 ID
|
||
amount: 奖励积分(必须 > 0)
|
||
reason: 奖励原因
|
||
|
||
Returns:
|
||
变动后余额
|
||
|
||
Raises:
|
||
ValueError: amount <= 0
|
||
"""
|
||
if amount <= 0:
|
||
raise ValueError("奖励积分必须大于0")
|
||
|
||
child = ChildrenDAO.get_by_id(child_id)
|
||
if child is None:
|
||
raise ValueError(f"孩子不存在: id={child_id}")
|
||
|
||
with transaction():
|
||
current_balance = ChildrenDAO.get_points(child_id)
|
||
new_balance = current_balance + amount
|
||
|
||
PointsDAO.create(
|
||
child_id=child_id,
|
||
source_type="bonus",
|
||
points_change=amount,
|
||
balance_after=new_balance,
|
||
reason=reason,
|
||
)
|
||
|
||
ChildrenDAO.update(child_id, total_points=new_balance)
|
||
|
||
return new_balance
|
||
|
||
@staticmethod
|
||
def adjust_points(
|
||
child_id: int,
|
||
amount: int,
|
||
reason: str = "手动调整",
|
||
) -> int:
|
||
"""
|
||
家长手动调整积分(可正可负,用于修正错误)。
|
||
|
||
扣减时余额不足则置为 0(不允许负积分)。
|
||
|
||
Args:
|
||
child_id: 孩子 ID
|
||
amount: 调整值(正=增加,负=扣减)
|
||
reason: 调整原因
|
||
|
||
Returns:
|
||
变动后余额
|
||
"""
|
||
child = ChildrenDAO.get_by_id(child_id)
|
||
if child is None:
|
||
raise ValueError(f"孩子不存在: id={child_id}")
|
||
|
||
with transaction():
|
||
current_balance = ChildrenDAO.get_points(child_id)
|
||
|
||
if amount < 0 and current_balance + amount < 0:
|
||
amount = -current_balance
|
||
|
||
if amount == 0:
|
||
return current_balance
|
||
|
||
new_balance = current_balance + amount
|
||
|
||
PointsDAO.create(
|
||
child_id=child_id,
|
||
source_type="adjustment",
|
||
points_change=amount,
|
||
balance_after=new_balance,
|
||
reason=reason,
|
||
)
|
||
|
||
ChildrenDAO.update(child_id, total_points=new_balance)
|
||
|
||
return new_balance
|
||
|
||
# ------------------------------------------------------------------
|
||
# 查询方法
|
||
# ------------------------------------------------------------------
|
||
|
||
@staticmethod
|
||
def get_today_reading_points(child_id: int) -> int:
|
||
"""
|
||
获取孩子今日已获取的阅读类积分总和。
|
||
|
||
用于判断是否达到每日上限。
|
||
"""
|
||
today_str = date.today().isoformat()
|
||
row = get_connection().execute(
|
||
"""SELECT COALESCE(SUM(points_change), 0) AS total
|
||
FROM points
|
||
WHERE child_id = ?
|
||
AND source_type = 'reading'
|
||
AND date(created_at) = ?""",
|
||
(child_id, today_str),
|
||
).fetchone()
|
||
return row["total"] if row else 0
|
||
|
||
@staticmethod
|
||
def get_balance(child_id: int) -> int:
|
||
"""获取孩子当前积分余额。"""
|
||
return ChildrenDAO.get_points(child_id)
|
||
|
||
@staticmethod
|
||
def get_points_history(
|
||
child_id: int,
|
||
limit: int = 50,
|
||
offset: int = 0,
|
||
):
|
||
"""获取积分历史记录。"""
|
||
return PointsDAO.list_by_child(child_id, limit=limit, offset=offset)
|