Files
aiagent/team_projects/b9b14925-f054-43d0-b2f0-6ed6b38dbe4b/Company-context-SQLiteFlutter-React-Nati/10-child_isolation.py
renjianbo f2e65a8fbb feat: virtual company module, team projects, PWA dishes app, and startup scripts overhaul
- Add company module (3-tier org, CEO planning, parallel departments)
- Add company orchestrator, knowledge extractor, presets, scheduler
- Add company API endpoints, models, and frontend views
- Add 今天吃啥 PWA app (69 dishes, real images, offline support)
- Add team_projects output directory structure
- Add unified manage.ps1 for service lifecycle
- Add Windows startup guide v1.0
- Add TTS troubleshooting doc
- Update frontend (AgentChat UX overhaul, new views)
- Update backend (voice engine fix, multi-tenant, RBAC)
- Remove deprecated startup scripts and old docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-10 22:37:56 +08:00

909 lines
32 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.
"""
================================================================================
10-child_isolation.py — 多孩子隔离中间件与家长切换逻辑
================================================================================
功能:
1. ChildContext: 上下文管理器,自动为所有 DAL 查询注入 child_id 过滤
2. ParentSession: 家长会话管理,维护当前活跃孩子 + 历史切换记录
3. IsolationGuard: 查询守卫,拦截未指定 child_id 的跨孩子查询
4. 孩子管理: CRUD + 激活/停用 + 积分转移
设计原则:
- 所有业务查询自动注入 child_id 条件,防止跨孩子数据泄露
- 家长端可通过 switch_child() 切换视角,底层自动过滤
- 提供 bypass 模式用于家长端的全局查询(如多孩子对比)
- 线程安全:使用 threading.local 存储当前活跃孩子
依赖:
- 03-models.py: Child
- 04-dal.py: ChildDAO
- 02-db_config.py: get_connection
用法:
# 方式 1: 上下文管理器(推荐)
with ChildContext(child_id=1) as ctx:
records = get_reading_dao().list_by_child() # 自动注入 child_id=1
# 方式 2: 家长会话
session = ParentSession(conn)
session.switch_child(2) # 切换到小红的视角
records = get_reading_dao().list_by_child() # 自动查 child_id=2
# 方式 3: 守卫装饰器
@isolation_guard
def get_my_records(child_id: int):
# 如果调用者没有设置 child_id,抛异常
...
================================================================================
"""
from __future__ import annotations
import logging
import threading
from contextlib import contextmanager
from dataclasses import dataclass, field
from datetime import date, datetime
from functools import wraps
from typing import Any, Callable, Optional
logger = logging.getLogger(__name__)
# ============================================================================
# 线程本地存储
# ============================================================================
_active_child: threading.local = threading.local()
def _get_active_child_id() -> Optional[int]:
"""获取当前线程的活跃孩子 ID"""
return getattr(_active_child, "child_id", None)
def _set_active_child_id(child_id: Optional[int]) -> None:
"""设置当前线程的活跃孩子 ID"""
_active_child.child_id = child_id
# ============================================================================
# 数据模型
# ============================================================================
@dataclass
class ChildProfile:
"""孩子完整档案(家长端视图)"""
id: int
name: str
age: int
total_points: int
is_active: bool
avatar_path: Optional[str] = None
created_at: Optional[str] = None
# 动态统计(非数据库字段,需额外查询)
current_streak: int = 0
total_reads: int = 0
total_redeems: int = 0
last_read_date: Optional[str] = None
def to_dict(self) -> dict:
return {
"id": self.id,
"name": self.name,
"age": self.age,
"total_points": self.total_points,
"is_active": self.is_active,
"avatar_path": self.avatar_path,
"current_streak": self.current_streak,
"total_reads": self.total_reads,
"total_redeems": self.total_redeems,
"last_read_date": self.last_read_date,
}
@dataclass
class ParentSessionState:
"""家长会话状态(可序列化)"""
active_child_id: Optional[int] = None
active_child_name: Optional[str] = None
recent_children: list[dict] = field(default_factory=list) # 最近切换的孩子列表
switch_count: int = 0
last_switch_at: Optional[str] = None
def to_dict(self) -> dict:
return {
"active_child_id": self.active_child_id,
"active_child_name": self.active_child_name,
"recent_children": self.recent_children,
"switch_count": self.switch_count,
"last_switch_at": self.last_switch_at,
}
# ============================================================================
# ChildContext — 上下文管理器
# ============================================================================
class ChildContext:
"""
孩子上下文管理器。
进入 with 块时设置活跃孩子 ID,退出时自动恢复。
Example:
with ChildContext(child_id=1) as ctx:
records = ctx.get_records() # 自动过滤 child_id=1
# 支持嵌套
with ChildContext(child_id=1):
with ChildContext(child_id=2):
# 此块内 child_id=2
pass
# 此块内 child_id=1
"""
def __init__(self, child_id: int, conn=None):
"""
Args:
child_id: 孩子 ID
conn: 可选的数据库连接(用于校验孩子存在性)
"""
self.child_id = child_id
self._conn = conn
self._previous_child_id: Optional[int] = None
self._validation_error: Optional[str] = None
def __enter__(self) -> "ChildContext":
# 验证孩子存在性
if self._conn is not None:
cur = self._conn.execute(
"SELECT id, name, is_active FROM children WHERE id = ?",
(self.child_id,),
)
row = cur.fetchone()
if row is None:
self._validation_error = f"孩子 ID={self.child_id} 不存在"
return self
if not row["is_active"]:
self._validation_error = f"孩子「{row['name']}」已停用"
return self
self._previous_child_id = _get_active_child_id()
_set_active_child_id(self.child_id)
logger.debug("ChildContext: entered child_id=%d (was %s)", self.child_id, self._previous_child_id)
return self
def __exit__(self, exc_type, exc_val, exc_tb):
_set_active_child_id(self._previous_child_id)
logger.debug("ChildContext: exited child_id=%d (restored %s)", self.child_id, self._previous_child_id)
return False # 不吞异常
@property
def is_valid(self) -> bool:
return self._validation_error is None
@property
def error(self) -> Optional[str]:
return self._validation_error
@contextmanager
def with_child(child_id: int, conn=None):
"""
函数式上下文管理器(无需手动 __enter__/__exit__)。
Usage:
with with_child(1) as valid:
if valid:
...
"""
ctx = ChildContext(child_id, conn)
with ctx:
yield ctx.is_valid
# ============================================================================
# ParentSession — 家长会话管理
# ============================================================================
class ParentSession:
"""
家长会话 —— 管理孩子切换与全局视图。
核心方法:
- switch_child(id): 切换当前查看的孩子
- get_active(): 获取当前活跃孩子信息
- list_children(): 列出所有孩子(含统计摘要)
- compare_children(): 多孩子对比视图
- bypass(): 临时绕过隔离(家长端全局查询)
Usage:
session = ParentSession(conn)
session.switch_child(1)
child = session.get_active()
session.bypass() # 进入绕过模式
all_records = dao.list_all() # 不受 child_id 限制
session.restore() # 恢复隔离
"""
def __init__(self, conn):
self._conn = conn
self._state = ParentSessionState()
self._bypass_mode = False
self._saved_child_id: Optional[int] = None
# ------------------------------------------------------------------
# 孩子切换
# ------------------------------------------------------------------
def switch_child(self, child_id: int, validate: bool = True) -> bool:
"""
切换到指定孩子视角。
Args:
child_id: 目标孩子 ID
validate: 是否校验孩子存在且活跃
Returns:
切换是否成功
"""
if validate:
cur = self._conn.execute(
"SELECT id, name, is_active FROM children WHERE id = ?",
(child_id,),
)
row = cur.fetchone()
if row is None:
logger.warning("switch_child: child %d not found", child_id)
return False
if not row["is_active"]:
logger.warning("switch_child: child %d is inactive", child_id)
return False
# 更新线程局部变量
_set_active_child_id(child_id)
# 获取名称
cur = self._conn.execute("SELECT name FROM children WHERE id = ?", (child_id,))
name = cur.fetchone()["name"] if cur.fetchone() else str(child_id)
# 记录到最近切换列表(去重、维护最多 10 个)
self._state.active_child_id = child_id
self._state.active_child_name = name
self._state.switch_count += 1
self._state.last_switch_at = datetime.now().isoformat()
# 维护最近列表
recent = [c for c in self._state.recent_children if c["id"] != child_id]
recent.insert(0, {"id": child_id, "name": name, "switched_at": self._state.last_switch_at})
self._state.recent_children = recent[:10]
logger.info("ParentSession: switched to child %d (%s)", child_id, name)
return True
def get_active(self) -> Optional[ChildProfile]:
"""获取当前活跃孩子的完整档案"""
child_id = _get_active_child_id()
if child_id is None:
return None
return self.get_child_profile(child_id)
def get_active_id(self) -> Optional[int]:
"""获取当前活跃孩子 ID(不查库,O(1))"""
return _get_active_child_id()
# ------------------------------------------------------------------
# 孩子列表
# ------------------------------------------------------------------
def list_children(self, include_inactive: bool = False) -> list[ChildProfile]:
"""列出所有孩子,含统计摘要"""
query = "SELECT id, name, age, total_points, is_active, created_at FROM children"
if not include_inactive:
query += " WHERE is_active = 1"
query += " ORDER BY name ASC"
cur = self._conn.execute(query)
profiles = []
for row in cur.fetchall():
profile = ChildProfile(
id=row["id"],
name=row["name"],
age=row["age"],
total_points=row["total_points"],
is_active=bool(row["is_active"]),
created_at=row["created_at"],
)
# 附加统计
self._enrich_profile(profile)
profiles.append(profile)
return profiles
def get_child_profile(self, child_id: int) -> Optional[ChildProfile]:
"""获取单个孩子档案(含统计)"""
cur = self._conn.execute(
"SELECT id, name, age, total_points, is_active, created_at "
"FROM children WHERE id = ?",
(child_id,),
)
row = cur.fetchone()
if row is None:
return None
profile = ChildProfile(
id=row["id"],
name=row["name"],
age=row["age"],
total_points=row["total_points"],
is_active=bool(row["is_active"]),
created_at=row["created_at"],
)
self._enrich_profile(profile)
return profile
def _enrich_profile(self, profile: ChildProfile) -> None:
"""填充动态统计数据"""
# 总阅读次数
cur = self._conn.execute(
"SELECT COUNT(*) FROM reading_records WHERE child_id = ?",
(profile.id,),
)
profile.total_reads = cur.fetchone()[0]
# 总兑换次数
cur = self._conn.execute(
"SELECT COUNT(*) FROM redemption_records WHERE child_id = ?",
(profile.id,),
)
profile.total_redeems = cur.fetchone()[0]
# 最近阅读日期
cur = self._conn.execute(
"SELECT MAX(read_date) FROM reading_records WHERE child_id = ?",
(profile.id,),
)
row = cur.fetchone()
profile.last_read_date = row[0] if row else None
# 连续打卡天数(调用积分引擎)
try:
from points_engine import PointsEngine # noqa: F811
engine = PointsEngine(self._conn)
streak = engine.get_streak(profile.id)
profile.current_streak = streak.current_streak
except ImportError:
profile.current_streak = 0
# ------------------------------------------------------------------
# 多孩子对比
# ------------------------------------------------------------------
def compare_children(self, child_ids: Optional[list[int]] = None) -> list[dict]:
"""
多孩子对比视图 —— 家长端核心功能。
Returns:
[{child_id, name, total_points, total_reads, total_redeems,
current_streak, avg_pages_per_read, avg_minutes_per_read, ...}, ...]
"""
if child_ids is None:
cur = self._conn.execute("SELECT id FROM children WHERE is_active = 1")
child_ids = [row["id"] for row in cur.fetchall()]
if not child_ids:
return []
result = []
for cid in child_ids:
profile = self.get_child_profile(cid)
if profile is None:
continue
# 额外统计
cur = self._conn.execute(
"SELECT COALESCE(AVG(pages_read), 0) as avg_pages, "
"COALESCE(AVG(duration_minutes), 0) as avg_minutes, "
"COUNT(*) as total "
"FROM reading_records WHERE child_id = ?",
(cid,),
)
stats = cur.fetchone()
# 本月打卡天数
today = date.today()
month_start = today.replace(day=1).isoformat()
cur = self._conn.execute(
"SELECT COUNT(DISTINCT read_date) FROM reading_records "
"WHERE child_id = ? AND read_date >= ?",
(cid, month_start),
)
monthly_days = cur.fetchone()[0]
result.append({
"child_id": cid,
"name": profile.name,
"age": profile.age,
"total_points": profile.total_points,
"total_reads": profile.total_reads,
"total_redeems": profile.total_redeems,
"current_streak": profile.current_streak,
"avg_pages_per_read": round(stats["avg_pages"], 1),
"avg_minutes_per_read": round(stats["avg_minutes"], 1),
"monthly_reading_days": monthly_days,
"last_read_date": profile.last_read_date,
})
return result
# ------------------------------------------------------------------
# 绕过隔离(家长全局查询)
# ------------------------------------------------------------------
def bypass(self) -> None:
"""
临时绕过孩子隔离,用于家长端全局查询。
调用后所有查询不再自动注入 child_id 过滤。
必须配对调用 restore() 恢复。
"""
self._saved_child_id = _get_active_child_id()
_set_active_child_id(None) # None = 无过滤
self._bypass_mode = True
logger.debug("ParentSession: bypass mode ON")
def restore(self) -> None:
"""恢复孩子隔离(与 bypass() 配对)"""
_set_active_child_id(self._saved_child_id)
self._bypass_mode = False
self._saved_child_id = None
logger.debug("ParentSession: bypass mode OFF, restored child_id=%s", _get_active_child_id())
@contextmanager
def bypass_context(self):
"""上下文管理器版本的 bypass"""
self.bypass()
try:
yield
finally:
self.restore()
# ------------------------------------------------------------------
# 孩子管理 (CRUD)
# ------------------------------------------------------------------
def create_child(self, name: str, age: int, avatar_path: Optional[str] = None) -> Optional[int]:
"""创建新孩子账户"""
now = datetime.now().isoformat()
try:
cur = self._conn.execute(
"INSERT INTO children (name, age, avatar_path, total_points, is_active, "
"created_at, updated_at) VALUES (?, ?, ?, 0, 1, ?, ?)",
(name, age, avatar_path, now, now),
)
self._conn.commit()
child_id = cur.lastrowid
logger.info("Created child: %s (age=%d, id=%d)", name, age, child_id)
return child_id
except Exception as e:
logger.error("Failed to create child: %s", e)
return None
def update_child(self, child_id: int, **kwargs) -> bool:
"""更新孩子信息(name, age, avatar_path)"""
allowed = {"name", "age", "avatar_path"}
updates = {k: v for k, v in kwargs.items() if k in allowed}
if not updates:
return False
set_clause = ", ".join(f"{k} = ?" for k in updates)
values = list(updates.values()) + [datetime.now().isoformat(), child_id]
cur = self._conn.execute(
f"UPDATE children SET {set_clause}, updated_at = ? WHERE id = ?",
values,
)
self._conn.commit()
return cur.rowcount > 0
def deactivate_child(self, child_id: int) -> bool:
"""停用孩子账户(保留数据,不可打卡/兑换)"""
return self._toggle_active(child_id, False)
def activate_child(self, child_id: int) -> bool:
"""重新激活孩子账户"""
return self._toggle_active(child_id, True)
def _toggle_active(self, child_id: int, active: bool) -> bool:
cur = self._conn.execute(
"UPDATE children SET is_active = ?, updated_at = ? WHERE id = ?",
(1 if active else 0, datetime.now().isoformat(), child_id),
)
self._conn.commit()
status = "activated" if active else "deactivated"
if cur.rowcount > 0:
logger.info("Child %d %s", child_id, status)
return cur.rowcount > 0
def transfer_points(
self, from_child_id: int, to_child_id: int, amount: int, reason: str = ""
) -> bool:
"""
积分转移(家长功能):将一个孩子的积分转移给另一个。
在单个事务中完成: 源扣减 → 目标增加 → 双方流水记录。
"""
try:
self._conn.execute("BEGIN IMMEDIATE")
now = datetime.now().isoformat()
# 读取源余额
cur = self._conn.execute(
"SELECT total_points FROM children WHERE id = ?", (from_child_id,)
)
row = cur.fetchone()
if row is None:
self._conn.execute("ROLLBACK")
return False
if row["total_points"] < amount:
self._conn.execute("ROLLBACK")
logger.warning("Transfer failed: child %d has only %d points (need %d)",
from_child_id, row["total_points"], amount)
return False
# 源: 扣减
cur = self._conn.execute(
"UPDATE children SET total_points = total_points - ?, updated_at = ? "
"WHERE id = ?",
(amount, now, from_child_id),
)
# 目标: 增加
cur = self._conn.execute(
"UPDATE children SET total_points = total_points + ?, updated_at = ? "
"WHERE id = ?",
(amount, now, to_child_id),
)
# 双方流水
self._conn.execute(
"INSERT INTO points (child_id, points_change, balance_after, reason, "
"reference_type, created_at) VALUES (?, ?, "
"(SELECT total_points FROM children WHERE id = ?), ?, 'transfer', ?)",
(from_child_id, -amount, from_child_id,
f"转移至孩子#{to_child_id} {reason}".strip(), now),
)
self._conn.execute(
"INSERT INTO points (child_id, points_change, balance_after, reason, "
"reference_type, created_at) VALUES (?, ?, "
"(SELECT total_points FROM children WHERE id = ?), ?, 'transfer', ?)",
(to_child_id, amount, to_child_id,
f"来自孩子#{from_child_id} {reason}".strip(), now),
)
self._conn.commit()
logger.info("Transfer: %d → %d, amount=%d", from_child_id, to_child_id, amount)
return True
except Exception as e:
self._conn.execute("ROLLBACK")
logger.error("Transfer failed: %s", e)
return False
# ------------------------------------------------------------------
# 序列化
# ------------------------------------------------------------------
def get_state(self) -> dict:
"""获取当前会话状态(用于持久化/恢复)"""
return self._state.to_dict()
def restore_state(self, state: dict) -> None:
"""从序列化状态恢复"""
if "active_child_id" in state and state["active_child_id"] is not None:
self.switch_child(state["active_child_id"], validate=False)
self._state.switch_count = state.get("switch_count", 0)
self._state.recent_children = state.get("recent_children", [])
# ============================================================================
# 查询守卫 — 装饰器
# ============================================================================
class IsolationError(Exception):
"""隔离违规异常 —— 查询未指定 child_id"""
pass
def isolation_guard(func: Callable) -> Callable:
"""
查询守卫装饰器。
如果函数签名包含 child_id 参数且调用时未传入(None),
且当前线程也未设置活跃孩子,则抛出 IsolationError。
这防止了开发阶段意外漏传 child_id 导致的跨孩子数据泄露。
Usage:
@isolation_guard
def get_records(child_id: int = None):
# child_id 默认为当前活跃孩子
cid = child_id or get_active_child_id()
if cid is None:
raise IsolationError("child_id required")
...
"""
@wraps(func)
def wrapper(*args, **kwargs):
# 检查 child_id 是否已提供
child_id = kwargs.get("child_id")
if child_id is None:
# 尝试从位置参数获取(假设 child_id 是第一个参数)
import inspect
sig = inspect.signature(func)
params = list(sig.parameters.keys())
if "child_id" in params:
idx = params.index("child_id")
if idx < len(args):
child_id = args[idx]
# 如果仍未提供,检查线程局部变量
if child_id is None:
active = _get_active_child_id()
if active is not None:
kwargs["child_id"] = active
# 不抛异常 —— 交给具体函数处理
return func(*args, **kwargs)
return wrapper
# ============================================================================
# 辅助函数
# ============================================================================
def get_active_child_id() -> Optional[int]:
"""获取当前线程的活跃孩子 ID(便捷函数)"""
return _get_active_child_id()
def require_child_id(child_id: Optional[int] = None) -> int:
"""
解析 child_id: 参数 > 线程局部 > 抛异常。
Raises:
IsolationError: 无法确定 child_id
"""
cid = child_id or _get_active_child_id()
if cid is None:
raise IsolationError(
"无法确定 child_id: 请传入 child_id 参数或先调用 "
"ChildContext / ParentSession.switch_child()"
)
return cid
def build_child_filter(child_id: Optional[int] = None) -> tuple[str, list]:
"""
构建 SQL WHERE 过滤条件(参数化)。
Returns:
(where_clause, params) 如 ("child_id = ?", [1]) 或 ("1=1", [])
Usage:
where, params = build_child_filter(child_id)
cur = conn.execute(f"SELECT * FROM records WHERE {where}", params)
"""
cid = child_id or _get_active_child_id()
if cid is not None:
return ("child_id = ?", [cid])
else:
return ("1=1", [])
# ============================================================================
# 自测
# ============================================================================
def _self_test():
import sqlite3
import os
import tempfile
db_path = os.path.join(tempfile.gettempdir(), "test_isolation.db")
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn.executescript("""
CREATE TABLE children (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
age INTEGER,
avatar_path TEXT,
total_points INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1,
created_at TEXT,
updated_at TEXT
);
CREATE TABLE reading_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
child_id INTEGER NOT NULL,
book_id INTEGER NOT NULL,
read_date TEXT NOT NULL,
duration_minutes INTEGER DEFAULT 0,
pages_read INTEGER DEFAULT 0
);
CREATE TABLE redemption_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
child_id INTEGER NOT NULL,
reward_id INTEGER NOT NULL,
points_spent INTEGER NOT NULL,
status TEXT DEFAULT 'pending',
created_at TEXT,
updated_at TEXT
);
CREATE TABLE points (
id INTEGER PRIMARY KEY AUTOINCREMENT,
child_id INTEGER NOT NULL,
points_change INTEGER NOT NULL,
balance_after INTEGER NOT NULL,
reason TEXT,
reference_type TEXT DEFAULT 'transfer',
created_at TEXT
);
INSERT INTO children (id, name, age, total_points) VALUES (1, '小明', 8, 100);
INSERT INTO children (id, name, age, total_points) VALUES (2, '小红', 6, 50);
INSERT INTO children (id, name, age, total_points, is_active) VALUES (3, '停用小刚', 10, 30, 0);
INSERT INTO reading_records (child_id, book_id, read_date, pages_read)
VALUES (1, 1, '2026-07-05', 15), (1, 2, '2026-07-04', 20), (2, 1, '2026-07-05', 10);
""")
conn.commit()
passed = 0
failed = 0
tests = []
def check(name, cond, detail=""):
nonlocal passed, failed
if cond:
passed += 1
tests.append(f" ✅ {name}")
else:
failed += 1
tests.append(f" ❌ {name}: {detail}")
print("=" * 60)
print(" 10-child_isolation.py 自测")
print("=" * 60)
# Test 1: ChildContext 基本功能
with ChildContext(child_id=1, conn=conn) as ctx:
check("ChildContext 校验通过", ctx.is_valid)
check("活跃孩子 ID=1", _get_active_child_id() == 1)
check("退出上下文后恢复", _get_active_child_id() is None)
# Test 2: ChildContext 校验无效孩子
with ChildContext(child_id=999, conn=conn) as ctx:
check("无效孩子检测", not ctx.is_valid)
check("无效孩子错误信息", "不存在" in (ctx.error or ""))
# Test 3: ChildContext 校验停用孩子
with ChildContext(child_id=3, conn=conn) as ctx:
check("停用孩子检测", not ctx.is_valid)
# Test 4: 嵌套上下文
with ChildContext(child_id=1):
check("外层 child_id=1", _get_active_child_id() == 1)
with ChildContext(child_id=2):
check("内层 child_id=2", _get_active_child_id() == 2)
check("退出内层恢复 child_id=1", _get_active_child_id() == 1)
check("退出外层恢复 None", _get_active_child_id() is None)
# Test 5: ParentSession 切换
session = ParentSession(conn)
ok = session.switch_child(2)
check("切换到小红", ok and _get_active_child_id() == 2)
# Test 6: get_active
profile = session.get_active()
check("获取活跃孩子", profile is not None and profile.name == "小红")
check("统计: total_reads > 0", profile.total_reads > 0, f"got {profile.total_reads}")
# Test 7: list_children
children = session.list_children()
check("列出活跃孩子", len(children) == 2, f"got {len(children)}") # 小明+小红
children_all = session.list_children(include_inactive=True)
check("列出全部孩子含停用", len(children_all) == 3, f"got {len(children_all)}")
# Test 8: compare_children
comparison = session.compare_children()
check("多孩子对比", len(comparison) == 2)
# Test 9: bypass / restore
session.bypass()
check("bypass 后 child_id=None", _get_active_child_id() is None)
# 在 bypass 模式下查询全部记录
cur = conn.execute("SELECT COUNT(*) FROM reading_records")
all_count = cur.fetchone()[0]
check(f"bypass 模式: 全局查询={all_count}", all_count == 3)
session.restore()
check("restore 恢复", _get_active_child_id() == 2)
# Test 10: build_child_filter
where, params = build_child_filter()
check("build_child_filter 自动注入", where == "child_id = ?" and params == [2])
where, params = build_child_filter(1)
check("build_child_filter 显式传入", where == "child_id = ?" and params == [1])
_set_active_child_id(None)
where, params = build_child_filter()
check("build_child_filter 无活跃孩子", where == "1=1")
# Test 11: require_child_id
try:
require_child_id()
check("require_child_id 无上下文抛异常", False, "未抛异常")
except IsolationError:
check("require_child_id 正确抛异常", True)
_set_active_child_id(1)
cid = require_child_id()
check("require_child_id 返回活跃孩子", cid == 1)
cid = require_child_id(2)
check("require_child_id 参数优先", cid == 2)
# Test 12: CRUD
new_id = session.create_child("新孩子", 7)
check("创建孩子", new_id is not None and new_id > 3, f"id={new_id}")
ok = session.update_child(new_id, name="新新")
check("更新名字", ok)
cur = conn.execute("SELECT name FROM children WHERE id = ?", (new_id,))
check("名字已更新", cur.fetchone()["name"] == "新新")
ok = session.deactivate_child(new_id)
check("停用孩子", ok)
ok = session.activate_child(new_id)
check("激活孩子", ok)
# Test 13: 积分转移
ok = session.transfer_points(1, 2, 20, "哥哥送妹妹")
check("积分转移成功", ok)
cur = conn.execute("SELECT total_points FROM children WHERE id = 1")
check("小明减20分", cur.fetchone()["total_points"] == 80)
cur = conn.execute("SELECT total_points FROM children WHERE id = 2")
check("小红加20分", cur.fetchone()["total_points"] == 70)
# Test 14: 会话状态序列化
session.switch_child(1)
state = session.get_state()
check("状态序列化", state["active_child_id"] == 1 and state["switch_count"] > 0)
# 清理
conn.close()
os.unlink(db_path)
print(f"\n结果: {passed} passed, {failed} failed")
for t in tests:
print(t)
return failed == 0
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
success = _self_test()
exit(0 if success else 1)