"""迁移 015: PPT/海报功能扩展 — 扩展现有表 + 创建新表。 覆盖: - 扩展 insurance_ppt_companies(+3 字段) - 扩展 insurance_ppt_products(+16 字段) - 扩展 insurance_ppt_templates(+6 字段) - 创建 insurance_ppt_history - 创建 poster_case_uploads - 创建 poster_templates - 创建 poster_copy_templates - 创建 poster_records - 创建 system_settings + 种子数据 """ import logging logger = logging.getLogger(__name__) def _add_column(table, column_def, db): """安全添加列(已存在则跳过)。""" from sqlalchemy import text try: db.session.execute(text(f"ALTER TABLE {table} ADD COLUMN {column_def}")) except Exception as e: # 仅忽略"列已存在"错误,其他错误记录日志 err_msg = str(e).lower() if "already exists" not in err_msg and "duplicate column" not in err_msg: logger.warning(f"添加列 {table}.{column_def} 失败: {e}") def _table_exists(table_name, db): """检查表是否存在。""" from sqlalchemy import text try: dialect = db.engine.dialect.name if dialect == 'postgresql': result = db.session.execute(text( "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = :name)" ), {"name": table_name}) return result.fetchone()[0] else: # SQLite result = db.session.execute(text( "SELECT name FROM sqlite_master WHERE type='table' AND name=:name" ), {"name": table_name}) return result.fetchone() is not None except Exception: return False def _get_create_table_sql_postgres(): """返回 PostgreSQL 的 CREATE TABLE 语句。""" return { "insurance_ppt_history": """ CREATE TABLE IF NOT EXISTS insurance_ppt_history ( id BIGSERIAL PRIMARY KEY, user_id VARCHAR(50) NOT NULL, session_id VARCHAR(50), action_type VARCHAR(20) NOT NULL, company_id VARCHAR(50), product_id VARCHAR(50), template_id VARCHAR(50), content_snapshot TEXT, file_url VARCHAR(500), ip VARCHAR(50), user_agent VARCHAR(500), created_at TIMESTAMP DEFAULT NOW() ) """, "poster_case_uploads": """ CREATE TABLE IF NOT EXISTS poster_case_uploads ( id BIGSERIAL PRIMARY KEY, user_id VARCHAR(50) NOT NULL, product_id VARCHAR(50) NOT NULL, source_file_url VARCHAR(500) NOT NULL, parse_status VARCHAR(20) DEFAULT 'pending', parsed_data TEXT, confirmed_data TEXT, confirmed_by VARCHAR(50), confirmed_at TIMESTAMP, created_at TIMESTAMP DEFAULT NOW() ) """, "poster_templates": """ CREATE TABLE IF NOT EXISTS poster_templates ( id BIGSERIAL PRIMARY KEY, name VARCHAR(100) NOT NULL, scenario_tag VARCHAR(50), style_description TEXT NOT NULL, color_scheme TEXT, reference_image VARCHAR(500), preview_image VARCHAR(500), status SMALLINT DEFAULT 1, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ) """, "poster_copy_templates": """ CREATE TABLE IF NOT EXISTS poster_copy_templates ( id BIGSERIAL PRIMARY KEY, name VARCHAR(100) NOT NULL, scenario_tag VARCHAR(50), content TEXT NOT NULL, variables TEXT, status SMALLINT DEFAULT 1, created_at TIMESTAMP DEFAULT NOW() ) """, "poster_records": """ CREATE TABLE IF NOT EXISTS poster_records ( id BIGSERIAL PRIMARY KEY, user_id VARCHAR(50) NOT NULL, product_id VARCHAR(50), case_upload_id BIGINT, template_id BIGINT, copy_mode VARCHAR(20), copy_content TEXT, ai_raw_content TEXT, export_url VARCHAR(500), export_format VARCHAR(10), export_size VARCHAR(20), reference_image_used VARCHAR(500), prompt_used TEXT, created_at TIMESTAMP DEFAULT NOW() ) """, "system_settings": """ CREATE TABLE IF NOT EXISTS system_settings ( id BIGSERIAL PRIMARY KEY, key VARCHAR(100) UNIQUE NOT NULL, value TEXT NOT NULL, description VARCHAR(500), updated_by VARCHAR(50), updated_at TIMESTAMP DEFAULT NOW() ) """, } def _get_create_table_sql_sqlite(): """返回 SQLite 的 CREATE TABLE 语句。""" return { "insurance_ppt_history": """ CREATE TABLE IF NOT EXISTS insurance_ppt_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id VARCHAR(50) NOT NULL, session_id VARCHAR(50), action_type VARCHAR(20) NOT NULL, company_id VARCHAR(50), product_id VARCHAR(50), template_id VARCHAR(50), content_snapshot TEXT, file_url VARCHAR(500), ip VARCHAR(50), user_agent VARCHAR(500), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """, "poster_case_uploads": """ CREATE TABLE IF NOT EXISTS poster_case_uploads ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id VARCHAR(50) NOT NULL, product_id VARCHAR(50) NOT NULL, source_file_url VARCHAR(500) NOT NULL, parse_status VARCHAR(20) DEFAULT 'pending', parsed_data TEXT, confirmed_data TEXT, confirmed_by VARCHAR(50), confirmed_at TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """, "poster_templates": """ CREATE TABLE IF NOT EXISTS poster_templates ( id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(100) NOT NULL, scenario_tag VARCHAR(50), style_description TEXT NOT NULL, color_scheme TEXT, reference_image VARCHAR(500), preview_image VARCHAR(500), status SMALLINT DEFAULT 1, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """, "poster_copy_templates": """ CREATE TABLE IF NOT EXISTS poster_copy_templates ( id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(100) NOT NULL, scenario_tag VARCHAR(50), content TEXT NOT NULL, variables TEXT, status SMALLINT DEFAULT 1, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """, "poster_records": """ CREATE TABLE IF NOT EXISTS poster_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id VARCHAR(50) NOT NULL, product_id VARCHAR(50), case_upload_id BIGINT, template_id BIGINT, copy_mode VARCHAR(20), copy_content TEXT, ai_raw_content TEXT, export_url VARCHAR(500), export_format VARCHAR(10), export_size VARCHAR(20), reference_image_used VARCHAR(500), prompt_used TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """, "system_settings": """ CREATE TABLE IF NOT EXISTS system_settings ( id INTEGER PRIMARY KEY AUTOINCREMENT, key VARCHAR(100) UNIQUE NOT NULL, value TEXT NOT NULL, description VARCHAR(500), updated_by VARCHAR(50), updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """, } def migrate(): """迁移入口。""" from insurance.db.compat import db from sqlalchemy import text dialect = db.engine.dialect.name is_postgres = dialect == 'postgresql' # ========== 1. 扩展 insurance_ppt_companies ========== logger.info("[migrate_015] 扩展 insurance_ppt_companies ...") for col in [ "logo_url VARCHAR(500)", "status SMALLINT DEFAULT 1", "sort_order INTEGER DEFAULT 0", ]: _add_column("insurance_ppt_companies", col, db) db.session.execute(text( "UPDATE insurance_ppt_companies SET status = 1 WHERE status IS NULL" )) db.session.commit() logger.info("[migrate_015] insurance_ppt_companies 扩展完成") # ========== 2. 扩展 insurance_ppt_products ========== logger.info("[migrate_015] 扩展 insurance_ppt_products ...") for col in [ "product_code VARCHAR(50)", "product_type VARCHAR(20)", "coverage_period VARCHAR(50)", "payment_period VARCHAR(50)", "insured_age_range VARCHAR(50)", "waiting_period VARCHAR(50)", "highlights TEXT", "extra_fields TEXT", "status SMALLINT DEFAULT 1", "sort_order INTEGER DEFAULT 0", "manual_file_url VARCHAR(500)", "manual_parse_status VARCHAR(20) DEFAULT 'none'", "manual_parsed_rules TEXT", "manual_reviewed_by VARCHAR(50)", "manual_reviewed_at TIMESTAMP", ]: _add_column("insurance_ppt_products", col, db) # updated_at 列可能已存在 if is_postgres: _add_column("insurance_ppt_products", "updated_at TIMESTAMP DEFAULT NOW()", db) else: _add_column("insurance_ppt_products", "updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP", db) db.session.execute(text( "UPDATE insurance_ppt_products SET status = 1 WHERE status IS NULL" )) db.session.commit() logger.info("[migrate_015] insurance_ppt_products 扩展完成") # ========== 3. 扩展 insurance_ppt_templates ========== logger.info("[migrate_015] 扩展 insurance_ppt_templates ...") for col in [ "name VARCHAR(100)", "scenario_tag VARCHAR(50)", "preview_image VARCHAR(500)", "applicable_company_ids TEXT", "applicable_product_ids TEXT", "status SMALLINT DEFAULT 1", ]: _add_column("insurance_ppt_templates", col, db) db.session.commit() logger.info("[migrate_015] insurance_ppt_templates 扩展完成") # ========== 4-9. 创建新表(根据数据库方言选择 SQL) ========== sql_map = _get_create_table_sql_postgres() if is_postgres else _get_create_table_sql_sqlite() for table_name in ["insurance_ppt_history", "poster_case_uploads", "poster_templates", "poster_copy_templates", "poster_records", "system_settings"]: if _table_exists(table_name, db): logger.info(f"[migrate_015] 表 {table_name} 已存在,跳过创建") continue logger.info(f"[migrate_015] 创建 {table_name} ...") db.session.execute(text(sql_map[table_name])) db.session.commit() logger.info(f"[migrate_015] {table_name} 创建完成") # ========== 索引 ========== indexes = [ "CREATE INDEX IF NOT EXISTS idx_ppt_history_user ON insurance_ppt_history(user_id, created_at DESC)", "CREATE INDEX IF NOT EXISTS idx_poster_records_user ON poster_records(user_id, created_at DESC)", ] for idx_sql in indexes: try: db.session.execute(text(idx_sql)) except Exception as e: logger.warning(f"创建索引失败: {e}") db.session.commit() # ========== 种子数据 ========== logger.info("[migrate_015] 写入种子数据 ...") for key, value, desc in [ ("ppt_history_retention_days", "365", "PPT 历史记录保留天数(0=永久保留)"), ("poster_history_retention_days", "365", "海报历史记录保留天数(0=永久保留)"), ]: if is_postgres: db.session.execute(text( "INSERT INTO system_settings (key, value, description) " "SELECT :key, :value, :desc " "WHERE NOT EXISTS (SELECT 1 FROM system_settings WHERE key = :key)" ), {"key": key, "value": value, "desc": desc}) else: # SQLite 不支持 SELECT ... INSERT 语法,用 try/except try: db.session.execute(text( "INSERT INTO system_settings (key, value, description) VALUES (:key, :value, :desc)" ), {"key": key, "value": value, "desc": desc}) except Exception: pass # 已存在则忽略 db.session.commit() logger.info("[migrate_015] 种子数据写入完成") logger.info("[migrate_015] 全部迁移完成")