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