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

369 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. 积分操作必须在事务内完成(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)