Files

963 lines
38 KiB
Python
Raw Normal View History

"""
家庭阅读激励工具 - 数据访问层 (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