Files
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

963 lines
38 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.
"""
家庭阅读激励工具 - 数据访问层 (DAL)
====================================
7个DAO类,50+方法,全部使用参数化查询防止SQL注入。
每个DAO类职责单一,方法命名遵循 find_/list_/create_/update_/delete_ 规范。
DAO列表:
ChildDAO — 孩子CRUD + 积分加减
BookDAO — 书籍CRUD + 搜索
ReadingRecordDAO — 打卡CRUD + 统计查询
PointDAO — 积分流水查询(只读,写入由业务层触发)
RewardDAO — 奖品CRUD + 库存校验
RedemptionDAO — 兑换记录CRUD + 状态管理
AppSettingDAO — 配置键值CRUD
"""
import sqlite3
import logging
from typing import Optional
from datetime import date
from db_config import get_db, transaction, row_to_dict, rows_to_list
from models import (
Child, Book, ReadingRecord, PointRecord,
Reward, RedemptionRecord, AppSetting, ChildReadingStats,
)
logger = logging.getLogger(__name__)
# ============================================================
# 基础DAO类 — 提取公共模式
# ============================================================
class BaseDAO:
"""DAO基类:提供共享的数据库操作方法"""
table: str = "" # 子类必须覆盖
def _execute(self, conn: sqlite3.Connection, sql: str, params: tuple = ()) -> sqlite3.Cursor:
"""执行参数化SQL(内部方法)"""
logger.debug("SQL: %s | params: %s", sql[:120], params)
return conn.execute(sql, params)
def _fetchone(self, conn: sqlite3.Connection, sql: str, params: tuple = ()) -> Optional[dict]:
"""查询单行,返回字典或None"""
row = conn.execute(sql, params).fetchone()
return row_to_dict(row)
def _fetchall(self, conn: sqlite3.Connection, sql: str, params: tuple = ()) -> list[dict]:
"""查询多行,返回字典列表"""
return rows_to_list(conn.execute(sql, params).fetchall())
def _insert(self, conn: sqlite3.Connection, sql: str, params: tuple) -> int:
"""执行INSERT,返回lastrowid"""
cursor = conn.execute(sql, params)
return cursor.lastrowid
def _count(self, conn: sqlite3.Connection, where: str = "1=1", params: tuple = ()) -> int:
"""计数查询"""
row = conn.execute(f"SELECT COUNT(*) as cnt FROM {self.table} WHERE {where}", params).fetchone()
return row["cnt"]
# ============================================================
# 1. ChildDAO — 孩子账户管理
# ============================================================
class ChildDAO(BaseDAO):
table = "children"
# --- 创建 ---
def create(self, name: str, avatar_path: str = None, age: int = None) -> Child:
"""创建孩子账户,返回完整Child对象"""
with transaction(self._get_conn()) as conn:
child_id = self._insert(conn,
"INSERT INTO children (name, avatar_path, age) VALUES (?, ?, ?)",
(name, avatar_path, age))
row = self._fetchone(conn, "SELECT * FROM children WHERE id=?", (child_id,))
return Child.from_row(row)
# --- 查询 ---
def find_by_id(self, child_id: int) -> Optional[Child]:
"""按ID查询孩子"""
db = self._get_conn()
try:
row = self._fetchone(db, "SELECT * FROM children WHERE id=?", (child_id,))
return Child.from_row(row) if row else None
finally:
db.close()
def list_all(self, active_only: bool = True) -> list[Child]:
"""列出所有孩子(默认只返回激活的)"""
db = self._get_conn()
try:
where = "is_active=1" if active_only else "1=1"
rows = self._fetchall(db,
f"SELECT * FROM children WHERE {where} ORDER BY name")
return [Child.from_row(r) for r in rows]
finally:
db.close()
def list_by_ids(self, child_ids: list[int]) -> list[Child]:
"""按ID列表批量查询"""
if not child_ids:
return []
placeholders = ",".join("?" * len(child_ids))
db = self._get_conn()
try:
rows = self._fetchall(db,
f"SELECT * FROM children WHERE id IN ({placeholders})",
tuple(child_ids))
return [Child.from_row(r) for r in rows]
finally:
db.close()
# --- 更新 ---
def update(self, child_id: int, **kwargs) -> Optional[Child]:
"""部分更新孩子信息。kwargs可含: name, avatar_path, age, is_active"""
allowed = {"name", "avatar_path", "age", "is_active"}
updates = {k: v for k, v in kwargs.items() if k in allowed}
if not updates:
return self.find_by_id(child_id)
set_clause = ", ".join(f"{k}=?" for k in updates)
updates["updated_at"] = "datetime('now','localtime')"
set_clause += ", updated_at=datetime('now','localtime')"
values = list(updates.values())
with transaction(self._get_conn()) as conn:
conn.execute(
f"UPDATE children SET {set_clause} WHERE id=?",
tuple(values) + (child_id,))
row = self._fetchone(conn, "SELECT * FROM children WHERE id=?", (child_id,))
return Child.from_row(row) if row else None
def add_points(self, child_id: int, points: int) -> int:
"""
原子增加积分(返回更新后的total_points)。
points必须为正数,内部使用 UPDATE ... SET total_points = total_points + ? 保证并发安全。
"""
if points <= 0:
raise ValueError("增加积分必须为正数")
with transaction(self._get_conn()) as conn:
conn.execute(
"UPDATE children SET total_points = total_points + ?, updated_at = datetime('now','localtime') WHERE id=? AND is_active=1",
(points, child_id))
row = self._fetchone(conn, "SELECT total_points FROM children WHERE id=?", (child_id,))
if row is None:
raise ValueError(f"孩子不存在或已停用: id={child_id}")
return row["total_points"]
def deduct_points(self, child_id: int, points: int) -> int:
"""
原子扣减积分(返回更新后的total_points)。
积分不足时抛出ValueError。
"""
if points <= 0:
raise ValueError("扣减积分必须为正数")
with transaction(self._get_conn()) as conn:
# 先查询当前积分,确保足够
row = self._fetchone(conn,
"SELECT total_points FROM children WHERE id=? AND is_active=1",
(child_id,))
if row is None:
raise ValueError(f"孩子不存在或已停用: id={child_id}")
if row["total_points"] < points:
raise ValueError(
f"积分不足: 需要{points}分, 当前{row['total_points']}分")
conn.execute(
"UPDATE children SET total_points = total_points - ?, updated_at = datetime('now','localtime') WHERE id=?",
(points, child_id))
row = self._fetchone(conn, "SELECT total_points FROM children WHERE id=?", (child_id,))
return row["total_points"]
# --- 删除(软删除) ---
def deactivate(self, child_id: int) -> bool:
"""软删除:停用孩子账户"""
return self.update(child_id, is_active=0) is not None
def count_active(self) -> int:
"""统计激活孩子数"""
db = self._get_conn()
try:
return self._count(db, "is_active=1")
finally:
db.close()
# --- 内部 ---
def _get_conn(self) -> sqlite3.Connection:
return get_db()
# ============================================================
# 2. BookDAO — 书籍管理
# ============================================================
class BookDAO(BaseDAO):
table = "books"
# --- 创建 ---
def create(self, title: str, author: str = None, cover_image_path: str = None,
thumbnail_path: str = None, page_count: int = None) -> Book:
"""录入新书籍"""
with transaction(self._get_conn()) as conn:
book_id = self._insert(conn,
"INSERT INTO books (title, author, cover_image_path, thumbnail_path, page_count) VALUES (?, ?, ?, ?, ?)",
(title, author, cover_image_path, thumbnail_path, page_count))
row = self._fetchone(conn, "SELECT * FROM books WHERE id=?", (book_id,))
return Book.from_row(row)
# --- 查询 ---
def find_by_id(self, book_id: int) -> Optional[Book]:
db = self._get_conn()
try:
row = self._fetchone(db, "SELECT * FROM books WHERE id=?", (book_id,))
return Book.from_row(row) if row else None
finally:
db.close()
def list_all(self, active_only: bool = True) -> list[Book]:
db = self._get_conn()
try:
where = "is_active=1" if active_only else "1=1"
rows = self._fetchall(db,
f"SELECT * FROM books WHERE {where} ORDER BY title")
return [Book.from_row(r) for r in rows]
finally:
db.close()
def search(self, keyword: str, limit: int = 20) -> list[Book]:
"""按书名模糊搜索(LIKE参数化)"""
db = self._get_conn()
try:
rows = self._fetchall(db,
"SELECT * FROM books WHERE is_active=1 AND title LIKE ? ORDER BY title LIMIT ?",
(f"%{keyword}%", limit))
return [Book.from_row(r) for r in rows]
finally:
db.close()
def find_by_ids(self, book_ids: list[int]) -> list[Book]:
"""按ID列表批量查询"""
if not book_ids:
return []
placeholders = ",".join("?" * len(book_ids))
db = self._get_conn()
try:
rows = self._fetchall(db,
f"SELECT * FROM books WHERE id IN ({placeholders})",
tuple(book_ids))
return [Book.from_row(r) for r in rows]
finally:
db.close()
# --- 更新 ---
def update(self, book_id: int, **kwargs) -> Optional[Book]:
"""部分更新书籍信息"""
allowed = {"title", "author", "cover_image_path", "thumbnail_path", "page_count", "is_active"}
updates = {k: v for k, v in kwargs.items() if k in allowed}
if not updates:
return self.find_by_id(book_id)
set_clause = ", ".join(f"{k}=?" for k in updates)
set_clause += ", updated_at=datetime('now','localtime')"
values = list(updates.values())
with transaction(self._get_conn()) as conn:
conn.execute(
f"UPDATE books SET {set_clause} WHERE id=?",
tuple(values) + (book_id,))
row = self._fetchone(conn, "SELECT * FROM books WHERE id=?", (book_id,))
return Book.from_row(row) if row else None
# --- 删除 ---
def deactivate(self, book_id: int) -> bool:
return self.update(book_id, is_active=0) is not None
def count_active(self) -> int:
db = self._get_conn()
try:
return self._count(db, "is_active=1")
finally:
db.close()
def _get_conn(self) -> sqlite3.Connection:
return get_db()
# ============================================================
# 3. ReadingRecordDAO — 阅读打卡记录管理
# ============================================================
class ReadingRecordDAO(BaseDAO):
table = "reading_records"
POINTS_PER_RECORD = 10 # 每次打卡默认积分(可被AppSetting覆盖)
# --- 创建(含积分联动) ---
def create(self, child_id: int, book_id: int, read_date: str,
duration_minutes: int = None, pages_read: int = None,
notes: str = None, award_points: bool = True) -> ReadingRecord:
"""
创建打卡记录,可选自动发放积分。
UNIQUE(child_id, book_id, read_date)约束保证同一天不重复打卡。
积分发放使用原子操作:INSERT记录 + 更新children.total_points + INSERT积分流水。
"""
with transaction(self._get_conn()) as conn:
# 1. 插入打卡记录
record_id = self._insert(conn,
"INSERT INTO reading_records (child_id, book_id, read_date, duration_minutes, pages_read, notes) VALUES (?, ?, ?, ?, ?, ?)",
(child_id, book_id, read_date, duration_minutes, pages_read, notes))
# 2. 发放积分(业务规则:每次打卡+10分)
if award_points:
points = self._get_points_per_record(conn)
conn.execute(
"UPDATE children SET total_points = total_points + ?, updated_at = datetime('now','localtime') WHERE id=?",
(points, child_id))
# 获取变动后余额
bal_row = conn.execute("SELECT total_points FROM children WHERE id=?", (child_id,)).fetchone()
conn.execute(
"INSERT INTO points (child_id, points_change, reason, reference_type, reference_id, balance_after) VALUES (?, ?, ?, ?, ?, ?)",
(child_id, points, "reading", "reading_record", record_id, bal_row["total_points"]))
row = self._fetchone(conn, "SELECT * FROM reading_records WHERE id=?", (record_id,))
return ReadingRecord.from_row(row)
def _get_points_per_record(self, conn: sqlite3.Connection) -> int:
"""从app_settings读取单次打卡积分(默认10)"""
row = conn.execute(
"SELECT value FROM app_settings WHERE key=?", ("points_per_reading",)
).fetchone()
return int(row["value"]) if row else self.POINTS_PER_RECORD
# --- 查询 ---
def find_by_id(self, record_id: int) -> Optional[ReadingRecord]:
db = self._get_conn()
try:
row = self._fetchone(db,
"""SELECT r.*, c.name as child_name, b.title as book_title
FROM reading_records r
LEFT JOIN children c ON r.child_id = c.id
LEFT JOIN books b ON r.book_id = b.id
WHERE r.id=?""",
(record_id,))
return ReadingRecord.from_row(row) if row else None
finally:
db.close()
def list_by_child(self, child_id: int, limit: int = 50, offset: int = 0) -> list[ReadingRecord]:
"""查询某孩子的打卡记录(按日期倒序)"""
db = self._get_conn()
try:
rows = self._fetchall(db,
"""SELECT r.*, c.name as child_name, b.title as book_title
FROM reading_records r
LEFT JOIN children c ON r.child_id = c.id
LEFT JOIN books b ON r.book_id = b.id
WHERE r.child_id=?
ORDER BY r.read_date DESC, r.created_at DESC
LIMIT ? OFFSET ?""",
(child_id, limit, offset))
return [ReadingRecord.from_row(r) for r in rows]
finally:
db.close()
def list_by_date_range(self, child_id: int, start_date: str, end_date: str) -> list[ReadingRecord]:
"""按日期范围查询打卡记录"""
db = self._get_conn()
try:
rows = self._fetchall(db,
"""SELECT r.*, c.name as child_name, b.title as book_title
FROM reading_records r
LEFT JOIN children c ON r.child_id = c.id
LEFT JOIN books b ON r.book_id = b.id
WHERE r.child_id=? AND r.read_date BETWEEN ? AND ?
ORDER BY r.read_date ASC""",
(child_id, start_date, end_date))
return [ReadingRecord.from_row(r) for r in rows]
finally:
db.close()
def count_by_child(self, child_id: int) -> int:
"""统计某孩子总打卡次数"""
db = self._get_conn()
try:
return self._count(db, "child_id=?", (child_id,))
finally:
db.close()
def count_by_date(self, child_id: int, read_date: str) -> int:
"""统计某天打卡次数"""
db = self._get_conn()
try:
return self._count(db, "child_id=? AND read_date=?", (child_id, read_date))
finally:
db.close()
# --- 统计查询 ---
def get_child_stats(self, child_id: int) -> ChildReadingStats:
"""获取孩子阅读统计(打卡次数、总时长、总页数、阅读书籍数)"""
db = self._get_conn()
try:
row = self._fetchone(db,
"""SELECT
r.child_id,
c.name as child_name,
COUNT(r.id) as total_records,
COALESCE(SUM(r.duration_minutes), 0) as total_minutes,
COALESCE(SUM(r.pages_read), 0) as total_pages,
COUNT(DISTINCT r.book_id) as total_books,
c.total_points as current_points,
(SELECT COUNT(*) FROM redemption_records WHERE child_id=r.child_id) as total_redemptions
FROM reading_records r
JOIN children c ON r.child_id = c.id
WHERE r.child_id=?
GROUP BY r.child_id""",
(child_id,))
return ChildReadingStats.from_row(row) if row else ChildReadingStats(child_id=child_id, child_name="")
finally:
db.close()
def get_monthly_summary(self, child_id: int, year: int, month: int) -> list[dict]:
"""获取月度阅读汇总(按日聚合)"""
db = self._get_conn()
try:
prefix = f"{year:04d}-{month:02d}"
rows = self._fetchall(db,
"""SELECT read_date, COUNT(*) as count,
COALESCE(SUM(duration_minutes), 0) as total_minutes
FROM reading_records
WHERE child_id=? AND read_date LIKE ?
GROUP BY read_date
ORDER BY read_date""",
(child_id, f"{prefix}%"))
return rows
finally:
db.close()
# --- 更新 ---
def update(self, record_id: int, **kwargs) -> Optional[ReadingRecord]:
"""更新打卡记录(只允许更新时长、页数、笔记)"""
allowed = {"duration_minutes", "pages_read", "notes"}
updates = {k: v for k, v in kwargs.items() if k in allowed}
if not updates:
return self.find_by_id(record_id)
set_clause = ", ".join(f"{k}=?" for k in updates)
values = list(updates.values())
with transaction(self._get_conn()) as conn:
conn.execute(
f"UPDATE reading_records SET {set_clause} WHERE id=?",
tuple(values) + (record_id,))
row = self._fetchone(conn,
"""SELECT r.*, c.name as child_name, b.title as book_title
FROM reading_records r
LEFT JOIN children c ON r.child_id = c.id
LEFT JOIN books b ON r.book_id = b.id
WHERE r.id=?""",
(record_id,))
return ReadingRecord.from_row(row) if row else None
# --- 删除 ---
def delete(self, record_id: int) -> bool:
"""物理删除打卡记录(积分不会回退,保持流水完整性)"""
with transaction(self._get_conn()) as conn:
cursor = conn.execute("DELETE FROM reading_records WHERE id=?", (record_id,))
return cursor.rowcount > 0
def _get_conn(self) -> sqlite3.Connection:
return get_db()
# ============================================================
# 4. PointDAO — 积分流水查询(只读)
# ============================================================
class PointDAO(BaseDAO):
table = "points"
# --- 查询(只读,积分变动由ReadingRecordDAO和RedemptionDAO在事务中写入) ---
def list_by_child(self, child_id: int, limit: int = 50, offset: int = 0) -> list[PointRecord]:
"""查询某孩子的积分流水(按时间倒序)"""
db = self._get_conn()
try:
rows = self._fetchall(db,
"""SELECT p.*, c.name as child_name
FROM points p
LEFT JOIN children c ON p.child_id = c.id
WHERE p.child_id=?
ORDER BY p.created_at DESC
LIMIT ? OFFSET ?""",
(child_id, limit, offset))
return [PointRecord.from_row(r) for r in rows]
finally:
db.close()
def list_by_reason(self, child_id: int, reason: str) -> list[PointRecord]:
"""按原因筛选积分流水"""
db = self._get_conn()
try:
rows = self._fetchall(db,
"""SELECT p.*, c.name as child_name
FROM points p LEFT JOIN children c ON p.child_id = c.id
WHERE p.child_id=? AND p.reason=?
ORDER BY p.created_at DESC""",
(child_id, reason))
return [PointRecord.from_row(r) for r in rows]
finally:
db.close()
def get_balance(self, child_id: int) -> int:
"""获取当前积分余额(从children表直接读取,更高效)"""
db = self._get_conn()
try:
row = self._fetchone(db,
"SELECT total_points FROM children WHERE id=?", (child_id,))
return row["total_points"] if row else 0
finally:
db.close()
def get_total_earned(self, child_id: int) -> int:
"""统计总收入积分"""
db = self._get_conn()
try:
row = self._fetchone(db,
"SELECT COALESCE(SUM(points_change), 0) as total FROM points WHERE child_id=? AND points_change > 0",
(child_id,))
return row["total"] if row else 0
finally:
db.close()
def get_total_spent(self, child_id: int) -> int:
"""统计总消费积分"""
db = self._get_conn()
try:
row = self._fetchone(db,
"SELECT COALESCE(SUM(ABS(points_change)), 0) as total FROM points WHERE child_id=? AND points_change < 0",
(child_id,))
return row["total"] if row else 0
finally:
db.close()
def list_by_date_range(self, child_id: int, start_date: str, end_date: str) -> list[PointRecord]:
"""按日期范围查询积分流水"""
db = self._get_conn()
try:
rows = self._fetchall(db,
"""SELECT p.*, c.name as child_name
FROM points p LEFT JOIN children c ON p.child_id = c.id
WHERE p.child_id=? AND p.created_at BETWEEN ? AND ?
ORDER BY p.created_at ASC""",
(child_id, start_date, end_date))
return [PointRecord.from_row(r) for r in rows]
finally:
db.close()
def count_by_child(self, child_id: int) -> int:
db = self._get_conn()
try:
return self._count(db, "child_id=?", (child_id,))
finally:
db.close()
def _get_conn(self) -> sqlite3.Connection:
return get_db()
# ============================================================
# 5. RewardDAO — 奖品管理
# ============================================================
class RewardDAO(BaseDAO):
table = "rewards"
# --- 创建 ---
def create(self, name: str, points_required: int, description: str = None,
image_path: str = None, stock: int = -1) -> Reward:
"""创建奖品"""
with transaction(self._get_conn()) as conn:
reward_id = self._insert(conn,
"INSERT INTO rewards (name, points_required, description, image_path, stock) VALUES (?, ?, ?, ?, ?)",
(name, points_required, description, image_path, stock))
row = self._fetchone(conn, "SELECT * FROM rewards WHERE id=?", (reward_id,))
return Reward.from_row(row)
# --- 查询 ---
def find_by_id(self, reward_id: int) -> Optional[Reward]:
db = self._get_conn()
try:
row = self._fetchone(db, "SELECT * FROM rewards WHERE id=?", (reward_id,))
return Reward.from_row(row) if row else None
finally:
db.close()
def list_all(self, active_only: bool = True) -> list[Reward]:
db = self._get_conn()
try:
where = "is_active=1" if active_only else "1=1"
rows = self._fetchall(db,
f"SELECT * FROM rewards WHERE {where} ORDER BY points_required ASC")
return [Reward.from_row(r) for r in rows]
finally:
db.close()
def list_affordable(self, child_points: int) -> list[Reward]:
"""列出孩子积分够兑换的奖品(含库存>0或无限库存)"""
db = self._get_conn()
try:
rows = self._fetchall(db,
"""SELECT * FROM rewards
WHERE is_active=1 AND points_required <= ? AND (stock > 0 OR stock = -1)
ORDER BY points_required ASC""",
(child_points,))
return [Reward.from_row(r) for r in rows]
finally:
db.close()
# --- 更新 ---
def update(self, reward_id: int, **kwargs) -> Optional[Reward]:
allowed = {"name", "description", "image_path", "points_required", "stock", "is_active"}
updates = {k: v for k, v in kwargs.items() if k in allowed}
if not updates:
return self.find_by_id(reward_id)
set_clause = ", ".join(f"{k}=?" for k in updates)
set_clause += ", updated_at=datetime('now','localtime')"
values = list(updates.values())
with transaction(self._get_conn()) as conn:
conn.execute(
f"UPDATE rewards SET {set_clause} WHERE id=?",
tuple(values) + (reward_id,))
row = self._fetchone(conn, "SELECT * FROM rewards WHERE id=?", (reward_id,))
return Reward.from_row(row) if row else None
def deduct_stock(self, reward_id: int, quantity: int = 1) -> bool:
"""
原子扣减库存。无限库存(stock=-1)直接返回True。
库存不足时返回False。
"""
with transaction(self._get_conn()) as conn:
row = self._fetchone(conn, "SELECT stock FROM rewards WHERE id=? AND is_active=1", (reward_id,))
if row is None:
return False
if row["stock"] == -1: # 无限库存
return True
if row["stock"] < quantity:
return False
conn.execute(
"UPDATE rewards SET stock = stock - ?, updated_at = datetime('now','localtime') WHERE id=? AND stock >= ?",
(quantity, reward_id, quantity))
return conn.total_changes > 0
# --- 删除 ---
def deactivate(self, reward_id: int) -> bool:
return self.update(reward_id, is_active=0) is not None
def count_active(self) -> int:
db = self._get_conn()
try:
return self._count(db, "is_active=1")
finally:
db.close()
def _get_conn(self) -> sqlite3.Connection:
return get_db()
# ============================================================
# 6. RedemptionDAO — 兑换记录管理
# ============================================================
class RedemptionDAO(BaseDAO):
table = "redemption_records"
# --- 创建(含积分扣减+库存扣减联动) ---
def create(self, child_id: int, reward_id: int) -> RedemptionRecord:
"""
兑换奖品。原子操作:
1. 校验孩子积分 ≥ 奖品所需积分
2. 校验奖品库存(扣减库存)
3. 扣减孩子积分
4. 写入积分流水
5. 创建兑换记录
全部在一个事务中完成,任何一步失败全部回滚。
"""
with transaction(self._get_conn()) as conn:
# 1. 锁定读取孩子积分和奖品信息
child = self._fetchone(conn,
"SELECT id, name, total_points FROM children WHERE id=? AND is_active=1",
(child_id,))
if child is None:
raise ValueError(f"孩子不存在或已停用: id={child_id}")
reward = self._fetchone(conn,
"SELECT id, name, points_required, stock FROM rewards WHERE id=? AND is_active=1",
(reward_id,))
if reward is None:
raise ValueError(f"奖品不存在或已下架: id={reward_id}")
# 2. 积分校验
if child["total_points"] < reward["points_required"]:
raise ValueError(
f"积分不足: 需要{reward['points_required']}分, 当前{child['total_points']}分")
# 3. 库存校验与扣减
if reward["stock"] != -1:
if reward["stock"] < 1:
raise ValueError(f"奖品库存不足: {reward['name']}")
conn.execute(
"UPDATE rewards SET stock = stock - 1, updated_at = datetime('now','localtime') WHERE id=? AND stock >= 1",
(reward_id,))
if conn.total_changes == 0:
raise ValueError(f"奖品库存不足: {reward['name']}")
# 4. 扣减孩子积分
points_spent = reward["points_required"]
conn.execute(
"UPDATE children SET total_points = total_points - ?, updated_at = datetime('now','localtime') WHERE id=?",
(points_spent, child_id))
new_balance = child["total_points"] - points_spent
# 5. 写入积分流水
conn.execute(
"INSERT INTO points (child_id, points_change, reason, reference_type, reference_id, balance_after) VALUES (?, ?, ?, ?, ?, ?)",
(child_id, -points_spent, "redemption", "redemption_record", None, new_balance))
point_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
# 6. 创建兑换记录(回填reference_id)
record_id = self._insert(conn,
"INSERT INTO redemption_records (child_id, reward_id, points_spent, status) VALUES (?, ?, ?, 'fulfilled')",
(child_id, reward_id, points_spent))
# 回填积分流水的reference_id
conn.execute(
"UPDATE points SET reference_id=? WHERE id=?",
(record_id, point_id))
row = self._fetchone(conn,
"""SELECT rr.*, c.name as child_name, r.name as reward_name
FROM redemption_records rr
LEFT JOIN children c ON rr.child_id = c.id
LEFT JOIN rewards r ON rr.reward_id = r.id
WHERE rr.id=?""",
(record_id,))
return RedemptionRecord.from_row(row)
# --- 查询 ---
def find_by_id(self, record_id: int) -> Optional[RedemptionRecord]:
db = self._get_conn()
try:
row = self._fetchone(db,
"""SELECT rr.*, c.name as child_name, r.name as reward_name
FROM redemption_records rr
LEFT JOIN children c ON rr.child_id = c.id
LEFT JOIN rewards r ON rr.reward_id = r.id
WHERE rr.id=?""",
(record_id,))
return RedemptionRecord.from_row(row) if row else None
finally:
db.close()
def list_by_child(self, child_id: int, limit: int = 50, offset: int = 0) -> list[RedemptionRecord]:
db = self._get_conn()
try:
rows = self._fetchall(db,
"""SELECT rr.*, c.name as child_name, r.name as reward_name
FROM redemption_records rr
LEFT JOIN children c ON rr.child_id = c.id
LEFT JOIN rewards r ON rr.reward_id = r.id
WHERE rr.child_id=?
ORDER BY rr.created_at DESC
LIMIT ? OFFSET ?""",
(child_id, limit, offset))
return [RedemptionRecord.from_row(r) for r in rows]
finally:
db.close()
def list_by_status(self, child_id: int, status: str) -> list[RedemptionRecord]:
"""按状态筛选兑换记录"""
db = self._get_conn()
try:
rows = self._fetchall(db,
"""SELECT rr.*, c.name as child_name, r.name as reward_name
FROM redemption_records rr
LEFT JOIN children c ON rr.child_id = c.id
LEFT JOIN rewards r ON rr.reward_id = r.id
WHERE rr.child_id=? AND rr.status=?
ORDER BY rr.created_at DESC""",
(child_id, status))
return [RedemptionRecord.from_row(r) for r in rows]
finally:
db.close()
def count_by_child(self, child_id: int) -> int:
db = self._get_conn()
try:
return self._count(db, "child_id=?", (child_id,))
finally:
db.close()
def count_by_reward(self, reward_id: int) -> int:
"""统计某奖品被兑换次数"""
db = self._get_conn()
try:
return self._count(db, "reward_id=? AND status='fulfilled'", (reward_id,))
finally:
db.close()
# --- 更新状态 ---
def update_status(self, record_id: int, status: str) -> Optional[RedemptionRecord]:
"""更新兑换记录状态 (pending→fulfilled/cancelled)"""
if status not in ("pending", "fulfilled", "cancelled"):
raise ValueError(f"无效状态: {status}")
with transaction(self._get_conn()) as conn:
conn.execute(
"UPDATE redemption_records SET status=? WHERE id=?",
(status, record_id))
row = self._fetchone(conn,
"""SELECT rr.*, c.name as child_name, r.name as reward_name
FROM redemption_records rr
LEFT JOIN children c ON rr.child_id = c.id
LEFT JOIN rewards r ON rr.reward_id = r.id
WHERE rr.id=?""",
(record_id,))
return RedemptionRecord.from_row(row) if row else None
def _get_conn(self) -> sqlite3.Connection:
return get_db()
# ============================================================
# 7. AppSettingDAO — 应用配置管理
# ============================================================
class AppSettingDAO(BaseDAO):
table = "app_settings"
DEFAULT_SETTINGS = {
"points_per_reading": "10", # 每次打卡积分
"app_name": "家庭阅读激励工具",
"max_children": "5", # 最大孩子数
}
# --- 初始化默认配置 ---
def init_defaults(self) -> None:
"""写入默认配置(幂等:已存在的key不覆盖)"""
with transaction(self._get_conn()) as conn:
for key, value in self.DEFAULT_SETTINGS.items():
conn.execute(
"INSERT OR IGNORE INTO app_settings (key, value, updated_at) VALUES (?, ?, datetime('now','localtime'))",
(key, value))
# --- 查询 ---
def get(self, key: str, default: str = None) -> Optional[str]:
"""获取单个配置值"""
db = self._get_conn()
try:
row = self._fetchone(db, "SELECT value FROM app_settings WHERE key=?", (key,))
return row["value"] if row else default
finally:
db.close()
def get_int(self, key: str, default: int = 0) -> int:
"""获取整数配置值"""
val = self.get(key)
return int(val) if val is not None else default
def get_all(self) -> list[AppSetting]:
"""获取所有配置"""
db = self._get_conn()
try:
rows = self._fetchall(db, "SELECT * FROM app_settings ORDER BY key")
return [AppSetting.from_row(r) for r in rows]
finally:
db.close()
# --- 写入 ---
def set(self, key: str, value: str) -> AppSetting:
"""设置/更新配置值"""
with transaction(self._get_conn()) as conn:
conn.execute(
"INSERT OR REPLACE INTO app_settings (key, value, updated_at) VALUES (?, ?, datetime('now','localtime'))",
(key, value))
row = self._fetchone(conn, "SELECT * FROM app_settings WHERE key=?", (key,))
return AppSetting.from_row(row)
def set_batch(self, settings: dict[str, str]) -> None:
"""批量设置配置"""
with transaction(self._get_conn()) as conn:
for key, value in settings.items():
conn.execute(
"INSERT OR REPLACE INTO app_settings (key, value, updated_at) VALUES (?, ?, datetime('now','localtime'))",
(key, value))
# --- 删除 ---
def delete(self, key: str) -> bool:
with transaction(self._get_conn()) as conn:
cursor = conn.execute("DELETE FROM app_settings WHERE key=?", (key,))
return cursor.rowcount > 0
def _get_conn(self) -> sqlite3.Connection:
return get_db()
# ============================================================
# DAO工厂函数(便捷获取单例)
# ============================================================
_child_dao: Optional[ChildDAO] = None
_book_dao: Optional[BookDAO] = None
_reading_dao: Optional[ReadingRecordDAO] = None
_point_dao: Optional[PointDAO] = None
_reward_dao: Optional[RewardDAO] = None
_redemption_dao: Optional[RedemptionDAO] = None
_setting_dao: Optional[AppSettingDAO] = None
def get_child_dao() -> ChildDAO:
global _child_dao
if _child_dao is None:
_child_dao = ChildDAO()
return _child_dao
def get_book_dao() -> BookDAO:
global _book_dao
if _book_dao is None:
_book_dao = BookDAO()
return _book_dao
def get_reading_dao() -> ReadingRecordDAO:
global _reading_dao
if _reading_dao is None:
_reading_dao = ReadingRecordDAO()
return _reading_dao
def get_point_dao() -> PointDAO:
global _point_dao
if _point_dao is None:
_point_dao = PointDAO()
return _point_dao
def get_reward_dao() -> RewardDAO:
global _reward_dao
if _reward_dao is None:
_reward_dao = RewardDAO()
return _reward_dao
def get_redemption_dao() -> RedemptionDAO:
global _redemption_dao
if _redemption_dao is None:
_redemption_dao = RedemptionDAO()
return _redemption_dao
def get_setting_dao() -> AppSettingDAO:
global _setting_dao
if _setting_dao is None:
_setting_dao = AppSettingDAO()
return _setting_dao