- 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>
357 lines
14 KiB
Python
357 lines
14 KiB
Python
"""
|
||
家庭阅读激励工具 - 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()
|