Files
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

357 lines
14 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.
"""
家庭阅读激励工具 - Schema版本管理与迁移
========================================
基于schema_version表实现渐进式迁移。
每次Schema变更定义为一个迁移步骤(版本号+升级SQL+回滚SQL)。
支持正向迁移和回滚,迁移记录持久化到数据库。
使用方式:
# 正向迁移到最新版本
python 05-migrations.py migrate
# 回滚到指定版本
python 05-migrations.py rollback 1
# 查看当前版本与待执行迁移
python 05-migrations.py status
"""
import sqlite3
import os
import sys
import logging
from typing import Optional
from datetime import datetime
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
# 数据库路径(与db_config一致)
DB_DIR = os.environ.get("READING_APP_DB_DIR", os.path.dirname(os.path.abspath(__file__)))
DB_NAME = os.environ.get("READING_APP_DB_NAME", "reading_app.db")
DB_PATH = os.path.join(DB_DIR, DB_NAME)
# ============================================================
# 迁移定义列表
# 每个迁移:(version, description, upgrade_sql, rollback_sql)
# 版本号递增,不可重复,不可删除已执行的迁移
# ============================================================
MIGRATIONS = [
{
"version": 1,
"description": "初始Schema:全部7张表 + schema_version",
"upgrade_sql": """
PRAGMA journal_mode=WAL;
PRAGMA foreign_keys=ON;
CREATE TABLE IF NOT EXISTS children (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
avatar_path TEXT,
age INTEGER CHECK(age>=0 AND age<=18),
total_points INTEGER NOT NULL DEFAULT 0 CHECK(total_points>=0),
is_active INTEGER NOT NULL DEFAULT 1 CHECK(is_active IN(0,1)),
created_at TEXT NOT NULL DEFAULT(datetime('now','localtime')),
updated_at TEXT NOT NULL DEFAULT(datetime('now','localtime'))
);
CREATE INDEX IF NOT EXISTS idx_children_active ON children(is_active);
CREATE INDEX IF NOT EXISTS idx_children_name ON children(name);
CREATE TABLE IF NOT EXISTS books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
author TEXT,
cover_image_path TEXT,
thumbnail_path TEXT,
page_count INTEGER CHECK(page_count>0),
is_active INTEGER NOT NULL DEFAULT 1 CHECK(is_active IN(0,1)),
created_at TEXT NOT NULL DEFAULT(datetime('now','localtime')),
updated_at TEXT NOT NULL DEFAULT(datetime('now','localtime'))
);
CREATE INDEX IF NOT EXISTS idx_books_title ON books(title);
CREATE INDEX IF NOT EXISTS idx_books_active ON books(is_active);
CREATE TABLE IF NOT EXISTS 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 CHECK(duration_minutes>0),
pages_read INTEGER CHECK(pages_read>=0),
notes TEXT,
created_at TEXT NOT NULL DEFAULT(datetime('now','localtime')),
FOREIGN KEY(child_id) REFERENCES children(id) ON DELETE CASCADE,
FOREIGN KEY(book_id) REFERENCES books(id) ON DELETE RESTRICT,
UNIQUE(child_id, book_id, read_date)
);
CREATE INDEX IF NOT EXISTS idx_reading_child ON reading_records(child_id);
CREATE INDEX IF NOT EXISTS idx_reading_book ON reading_records(book_id);
CREATE INDEX IF NOT EXISTS idx_reading_date ON reading_records(read_date);
CREATE INDEX IF NOT EXISTS idx_reading_child_date ON reading_records(child_id, read_date);
CREATE TABLE IF NOT EXISTS points (
id INTEGER PRIMARY KEY AUTOINCREMENT,
child_id INTEGER NOT NULL,
points_change INTEGER NOT NULL,
reason TEXT NOT NULL,
reference_type TEXT,
reference_id INTEGER,
balance_after INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT(datetime('now','localtime')),
FOREIGN KEY(child_id) REFERENCES children(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_points_child ON points(child_id);
CREATE INDEX IF NOT EXISTS idx_points_created ON points(created_at);
CREATE INDEX IF NOT EXISTS idx_points_child_created ON points(child_id, created_at);
CREATE INDEX IF NOT EXISTS idx_points_reference ON points(reference_type, reference_id);
CREATE TABLE IF NOT EXISTS rewards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
image_path TEXT,
points_required INTEGER NOT NULL CHECK(points_required>0),
stock INTEGER NOT NULL DEFAULT -1,
is_active INTEGER NOT NULL DEFAULT 1 CHECK(is_active IN(0,1)),
created_at TEXT NOT NULL DEFAULT(datetime('now','localtime')),
updated_at TEXT NOT NULL DEFAULT(datetime('now','localtime'))
);
CREATE INDEX IF NOT EXISTS idx_rewards_active ON rewards(is_active);
CREATE INDEX IF NOT EXISTS idx_rewards_points ON rewards(points_required);
CREATE TABLE IF NOT EXISTS redemption_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
child_id INTEGER NOT NULL,
reward_id INTEGER NOT NULL,
points_spent INTEGER NOT NULL CHECK(points_spent>0),
status TEXT NOT NULL DEFAULT 'fulfilled' CHECK(status IN('pending','fulfilled','cancelled')),
redeemed_at TEXT NOT NULL DEFAULT(datetime('now','localtime')),
created_at TEXT NOT NULL DEFAULT(datetime('now','localtime')),
FOREIGN KEY(child_id) REFERENCES children(id) ON DELETE CASCADE,
FOREIGN KEY(reward_id) REFERENCES rewards(id) ON DELETE RESTRICT
);
CREATE INDEX IF NOT EXISTS idx_redemption_child ON redemption_records(child_id);
CREATE INDEX IF NOT EXISTS idx_redemption_reward ON redemption_records(reward_id);
CREATE INDEX IF NOT EXISTS idx_redemption_date ON redemption_records(created_at);
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT(datetime('now','localtime'))
);
""",
"rollback_sql": """
DROP TABLE IF EXISTS redemption_records;
DROP TABLE IF EXISTS rewards;
DROP TABLE IF EXISTS points;
DROP TABLE IF EXISTS reading_records;
DROP TABLE IF EXISTS books;
DROP TABLE IF EXISTS children;
DROP TABLE IF EXISTS app_settings;
""",
},
# 未来迁移在此追加,例如:
# {
# "version": 2,
# "description": "添加书籍分类字段",
# "upgrade_sql": "ALTER TABLE books ADD COLUMN category TEXT;",
# "rollback_sql": "-- SQLite不支持DROP COLUMN,回滚需重建表",
# },
]
# ============================================================
# 迁移引擎
# ============================================================
class MigrationEngine:
"""Schema迁移引擎"""
def __init__(self, db_path: str = DB_PATH):
self.db_path = db_path
def _get_conn(self) -> sqlite3.Connection:
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys=ON")
return conn
def _ensure_version_table(self, conn: sqlite3.Connection) -> None:
"""确保schema_version表存在"""
conn.execute("""
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
description TEXT
)
""")
conn.commit()
def get_current_version(self) -> int:
"""获取当前Schema版本号(未初始化返回0)"""
conn = self._get_conn()
try:
self._ensure_version_table(conn)
row = conn.execute(
"SELECT MAX(version) as v FROM schema_version"
).fetchone()
return row["v"] if row and row["v"] is not None else 0
finally:
conn.close()
def get_pending_migrations(self) -> list[dict]:
"""获取待执行的迁移列表"""
current = self.get_current_version()
return [m for m in MIGRATIONS if m["version"] > current]
def migrate(self, target_version: int = None) -> int:
"""
正向迁移到目标版本(默认最新)。
返回执行的迁移数量。
"""
current = self.get_current_version()
pending = self.get_pending_migrations()
if target_version:
pending = [m for m in pending if m["version"] <= target_version]
if not pending:
logger.info("数据库已在最新版本(%d),无需迁移", current)
return 0
conn = self._get_conn()
try:
self._ensure_version_table(conn)
executed = 0
for migration in pending:
ver = migration["version"]
desc = migration["description"]
logger.info("执行迁移 v%d: %s", ver, desc)
try:
conn.executescript(migration["upgrade_sql"])
conn.execute(
"INSERT INTO schema_version (version, description) VALUES (?, ?)",
(ver, desc),
)
conn.commit()
executed += 1
logger.info(" ✓ 完成 v%d", ver)
except Exception as e:
conn.rollback()
logger.error(" ✗ 迁移失败 v%d: %s", ver, e)
raise
return executed
finally:
conn.close()
def rollback(self, target_version: int) -> int:
"""
回滚到目标版本(执行目标版本之后的所有迁移的rollback_sql)。
返回回滚的迁移数量。
"""
current = self.get_current_version()
if target_version >= current:
logger.info("目标版本(%d) >= 当前版本(%d),无需回滚", target_version, current)
return 0
# 需要回滚的迁移(倒序)
to_rollback = [m for m in MIGRATIONS if m["version"] > target_version and m["version"] <= current]
to_rollback.sort(key=lambda m: m["version"], reverse=True)
if not to_rollback:
return 0
conn = self._get_conn()
try:
executed = 0
for migration in to_rollback:
ver = migration["version"]
desc = migration["description"]
logger.info("回滚 v%d: %s", ver, desc)
try:
conn.executescript(migration["rollback_sql"])
conn.execute("DELETE FROM schema_version WHERE version=?", (ver,))
conn.commit()
executed += 1
logger.info(" ✓ 回滚完成 v%d", ver)
except Exception as e:
conn.rollback()
logger.error(" ✗ 回滚失败 v%d: %s", ver, e)
raise
return executed
finally:
conn.close()
def status(self) -> dict:
"""返回迁移状态摘要"""
current = self.get_current_version()
pending = self.get_pending_migrations()
all_versions = [m["version"] for m in MIGRATIONS]
latest = max(all_versions) if all_versions else 0
status_info = {
"current_version": current,
"latest_version": latest,
"pending_count": len(pending),
"pending_versions": [m["version"] for m in pending],
"need_migration": len(pending) > 0,
}
logger.info("=" * 50)
logger.info("Schema迁移状态")
logger.info("=" * 50)
logger.info(" 当前版本: %d", current)
logger.info(" 最新版本: %d", latest)
logger.info(" 待执行: %d个迁移", len(pending))
for m in pending:
logger.info(" - v%d: %s", m["version"], m["description"])
if not pending:
logger.info(" ✓ 数据库已是最新版本")
return status_info
# ============================================================
# CLI入口
# ============================================================
def main():
engine = MigrationEngine()
if len(sys.argv) < 2:
print("用法: python 05-migrations.py <command> [args]")
print(" migrate — 迁移到最新版本")
print(" migrate <ver> — 迁移到指定版本")
print(" rollback <ver> — 回滚到指定版本")
print(" status — 查看迁移状态")
sys.exit(1)
command = sys.argv[1]
if command == "migrate":
target = int(sys.argv[2]) if len(sys.argv) > 2 else None
count = engine.migrate(target)
logger.info("共执行 %d 个迁移", count)
elif command == "rollback":
if len(sys.argv) < 3:
print("错误: rollback需要指定目标版本号")
sys.exit(1)
target = int(sys.argv[2])
engine.rollback(target)
elif command == "status":
engine.status()
else:
print(f"未知命令: {command}")
sys.exit(1)
if __name__ == "__main__":
main()