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