- 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>
909 lines
32 KiB
Python
909 lines
32 KiB
Python
"""
|
||
================================================================================
|
||
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)
|