""" 一键执行 chapter_sections 迁移:先执行 SQL 建表/加列,再回填数据并删列。 依赖:.env 中 DATABASE_URL,以及 python-dotenv。 用法(在 api 目录下): python -m scripts.run_chapter_sections_migration """ import json import os import sys import uuid from pathlib import Path # 配置由 app.core.config 加载;DB 使用 psycopg 同步驱动 sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) os.chdir(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from sqlalchemy import create_engine, text from sqlalchemy.engine import Engine from app.core.config import settings from app.core.db import ensure_psycopg_url from app.core.logging import get_logger, setup_logging setup_logging() logger = get_logger(__name__) def get_engine() -> Engine: url = (settings.migration_database_url or "").strip() or settings.database_url return create_engine(ensure_psycopg_url(url), pool_pre_ping=True) def run_sql_migration(engine: Engine): sql_path = Path(__file__).parent.parent / "migrations" / "add_chapter_sections.sql" sql = sql_path.read_text(encoding="utf-8") # 按 DO $$ ... $$; 与普通 ; 拆分,避免把 PL/pgSQL 块拆碎 stmts = [] rest = sql while rest: rest = rest.lstrip() if rest.startswith("--"): rest = rest[rest.find("\n") + 1:] if "\n" in rest else "" continue if rest.upper().startswith("DO "): # 找到 $$; 或 $$ ; i = rest.find("$$") if i == -1: break j = rest.find("$$", i + 2) if j == -1: break stmts.append(rest[: j + 2].strip() + ";") rest = rest[j + 2:].lstrip().lstrip(";").lstrip() continue idx = rest.find(";") if idx == -1: break part = rest[: idx].strip() rest = rest[idx + 1:] if part and not part.startswith("--"): stmts.append(part + ";") with engine.begin() as conn: for i, s in enumerate(stmts): try: conn.execute(text(s)) logger.info(" SQL %s OK", i + 1) except Exception as e: if "already exists" in str(e).lower(): logger.info(" SQL %s (已存在)", i + 1) continue raise logger.info("1/2 SQL 迁移完成") def run_data_migration(engine: Engine): from app.features.memoir.memoir_images.parser import split_narrative_to_sections with engine.connect() as conn: r = conn.execute(text(""" SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'chapters' AND column_name = 'content' """)) if r.fetchone() is None: logger.info("chapters.content 已不存在,跳过数据迁移") return rows = conn.execute(text(""" SELECT id, content, images FROM chapters WHERE content IS NOT NULL AND trim(content) != '' """)).fetchall() for row in rows: ch_id, content, images_raw = row[0], row[1], row[2] if isinstance(images_raw, str): try: images = json.loads(images_raw) except Exception: images = [] else: images = images_raw if isinstance(images_raw, list) else [] sections = split_narrative_to_sections(content or "") if not sections: section_id = str(uuid.uuid4()).replace("-", "")[:32] conn.execute(text(""" INSERT INTO chapter_sections (id, chapter_id, order_index, content, image, updated_at) VALUES (:id, :ch_id, 0, :content, NULL, NOW()) """), {"id": section_id, "ch_id": ch_id, "content": (content or "").strip()}) conn.commit() logger.info("章节 %s: 1 条 section(无图)", ch_id) continue first_cover = None img_index = 0 for order_idx, seg in enumerate(sections): section_id = str(uuid.uuid4()).replace("-", "")[:32] seg_content = seg.get("content") or "" ph = seg.get("placeholder_info") image_json = None if ph is not None and img_index < len(images): image_json = json.dumps(images[img_index]) if isinstance(images[img_index], dict) else None if first_cover is None and image_json: first_cover = image_json img_index += 1 conn.execute(text(""" INSERT INTO chapter_sections (id, chapter_id, order_index, content, image, updated_at) VALUES (:id, :ch_id, :ord, :content, CAST(:img AS jsonb), NOW()) """), { "id": section_id, "ch_id": ch_id, "ord": order_idx, "content": seg_content, "img": image_json, }) if first_cover: conn.execute( text("UPDATE chapters SET cover_image = CAST(:img AS jsonb) WHERE id = :id"), {"img": first_cover, "id": ch_id}, ) conn.commit() logger.info("章节 %s: %d 条 sections", ch_id, len(sections)) conn.execute(text("ALTER TABLE chapters DROP COLUMN IF EXISTS content")) conn.execute(text("ALTER TABLE chapters DROP COLUMN IF EXISTS images")) conn.commit() logger.info("已删除 chapters.content 与 chapters.images") logger.info("2/2 数据迁移完成") if __name__ == "__main__": logger.info("开始 chapter_sections 迁移…") engine = get_engine() run_sql_migration(engine) run_data_migration(engine) logger.info("迁移全部完成")