# 2026-02-15 数据库迁移链完整修复 ## 问题来源 远程仓库提交 `ecef7a7` 和 `7ad3bf9` 中的迁移文件存在以下 Bug: 1. **UniqueViolationError**:`CREATE OR REPLACE FUNCTION` 在 asyncpg 驱动下触发唯一约束冲突 2. **UndefinedTableError**:迁移脚本引用已在后续迁移中删除的表 3. **DuplicateColumnError**:非幂等的 `ADD COLUMN` 导致重复创建列 ## 根本原因 ### 1. asyncpg 驱动的特殊行为 ```python # ❌ 在 asyncpg 下会报错 CREATE OR REPLACE FUNCTION update_updated_at_column() ... # 错误信息 UniqueViolationError: duplicate key value violates unique constraint "pg_proc_proname_args_nsp_index" ``` **原因**:asyncpg 在执行 `CREATE OR REPLACE FUNCTION` 时会先尝试创建新函数,再删除旧函数,导致唯一键冲突。 ### 2. 迁移顺序问题 迁移文件 `20260205_1156_72966791b2f1` 创建时,以下表还存在: - `screenplay_characters` - `screenplay_locations` - `screenplay_props` - `storyboard_resources` 但在后续迁移中这些表被删除了: - `drop_screenplay_tables.py` - 删除 screenplay_* 表 - `20260210_2059_bb1f354cdf48` - 删除 storyboard_resources 表 当执行 `start_docker.sh -c -b` 清库重建时,迁移按时间顺序执行,`20260205_1156` 尝试操作不存在的表导致失败。 ### 3. 非幂等的列创建 ```python # ❌ 非幂等 op.add_column('screenplays', sa.Column('parsing_status', ...)) # 重复执行时报错 DuplicateColumnError: column "parsing_status" of relation "screenplays" already exists ``` ## 修复方案 ### 文件 1:20260205_1156_72966791b2f1_fix_time_fields_functions_and_triggers.py #### 修复 1:函数创建改为 DROP + CREATE ```python # ✅ 修复后 op.execute("DROP FUNCTION IF EXISTS update_updated_at_column() CASCADE;") op.execute(""" CREATE FUNCTION update_updated_at_column() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; """) ``` **效果**: - 先删除旧函数及其依赖的触发器(CASCADE) - 再创建新函数 - 避免唯一键冲突 #### 修复 2:移除已删除表的引用 ```python # ✅ 修复后 tables_need_updated_at = [ 'ai_usage_logs', # ... # 'storyboard_resources', # 已在 bb1f354cdf48 中删除 'user_sessions' ] tables_with_updated_at = [ # ... # 'screenplay_characters', # 已删除 # 'screenplay_locations', # 已删除 # 'screenplay_props', # 已删除 # 'storyboard_resources', # 已在 bb1f354cdf48 中删除 ] ``` **效果**: - 避免 `UndefinedTableError` - 迁移可以在空库上干净执行 ### 文件 2:20260205_1440_add_screenplay_parsing_fields.py #### 修复:改用原生 SQL 确保幂等性 ```python # ✅ 修复后 op.execute(""" ALTER TABLE screenplays ADD COLUMN IF NOT EXISTS parsing_status SMALLINT NOT NULL DEFAULT 0; """) op.execute(""" CREATE INDEX IF NOT EXISTS idx_screenplays_parsing_status ON screenplays (parsing_status) WHERE parsing_status IN (1, 2); """) ``` **效果**: - 多次执行不会报错 - 保证部署安全性 ### 文件 3:20260205_1600_add_missing_column_comments.py #### 修复:注释掉已删除表的操作 ```python # ✅ 修复后 # ==================== storyboard_resources ==================== # 注意:storyboard_resources 表已在 bb1f354cdf48 中删除 # op.execute("COMMENT ON COLUMN storyboard_resources.storyboard_resource_id IS ...") # op.execute("COMMENT ON COLUMN storyboard_resources.storyboard_id IS ...") # ...(其他7个字段的注释也注释掉) ``` **效果**: - 避免 `UndefinedColumnError` - 历史原因保留注释代码供参考 ## 测试验证 ### 1. 清库重建测试 ```bash cd /Users/panta/py_work/jointoai_work/Jointoai/server ./start_docker.sh -c -b ``` **结果**: ``` ✅ 容器重建成功 ✅ 数据库清空成功 ✅ 迁移执行成功(alembic upgrade head) ✅ 应用启动成功 ``` ### 2. 健康检查 ```bash curl http://localhost:6170/health ``` **响应**: ```json { "status": "healthy", "environment": "development" } ``` ### 3. 迁移版本检查 ```bash docker exec jointo-server-app alembic current ``` **输出**: ``` cb015d9eec93 (head) ``` ### 4. 增量迁移测试 在已有数据库上执行 `alembic upgrade head`: - ✅ 不会因为函数已存在而报错 - ✅ 不会因为列已存在而报错 - ✅ 不会因为表不存在而报错 ## 修改统计 | 文件 | 修改类型 | 行数 | 说明 | |------|---------|-----|------| | 20260205_1156_72966791b2f1 | 函数创建 | +4 DROP | 4个函数先删除再创建 | | 20260205_1156_72966791b2f1 | 移除引用 | -4 表 | 移除已删除表的引用 | | 20260205_1440 | 改用SQL | ~30行 | ADD COLUMN IF NOT EXISTS | | 20260205_1600 | 注释代码 | ~14行 | 注释掉storyboard_resources | | **合计** | | **66 insertions(+), 68 deletions(-)** | | ## 影响范围 ### 无影响 - ✅ 数据库结构:无变化 - ✅ 已有数据:不受影响 - ✅ 应用逻辑:无变更 ### 有改善 - ✅ 迁移稳定性:从不可执行到完全可靠 - ✅ 部署体验:`start_docker.sh -c -b` 从失败到成功 - ✅ 幂等性:多次执行安全 - ✅ 团队协作:拉取代码后不会破坏本地环境 ## 提交信息 **Commit**:`20dfc1b` **标题**:`fix: 修复数据库迁移链问题(3个迁移文件)` **推送**:✅ 已推送至 `origin/main` ## 最佳实践建议 ### 1. 编写迁移时的注意事项 ```python # ✅ 推荐:显式 DROP + CREATE(asyncpg 友好) op.execute("DROP FUNCTION IF EXISTS my_func() CASCADE;") op.execute("CREATE FUNCTION my_func() ...") # ❌ 避免:CREATE OR REPLACE(asyncpg 有坑) op.execute("CREATE OR REPLACE FUNCTION my_func() ...") ``` ### 2. 列/索引操作的幂等性 ```python # ✅ 推荐:使用 IF NOT EXISTS op.execute("ALTER TABLE t ADD COLUMN IF NOT EXISTS col TEXT;") op.execute("CREATE INDEX IF NOT EXISTS idx ON t(col);") # ❌ 避免:非幂等操作 op.add_column('t', sa.Column('col', sa.TEXT())) op.create_index('idx', 't', ['col']) ``` ### 3. 表删除后的清理 当删除表时,记得检查: - 其他迁移是否引用了该表? - 是否有触发器或函数依赖该表? - 注释掉或删除相关的历史迁移代码 ### 4. 迁移测试流程 ```bash # 1. 清库测试(最严格) ./start_docker.sh -c -b # 2. 增量测试 docker exec jointo-server-app alembic upgrade head # 3. 回滚测试 docker exec jointo-server-app alembic downgrade -1 # 4. 应用启动测试 docker logs jointo-server-app | grep "✅" ``` ## 相关文档 - [ADR: 禁用物理外键](/Users/panta/py_work/jointoai_work/Jointoai/docs/architecture/adrs/002-no-physical-foreign-keys.md) - [Alembic 使用指南](/Users/panta/py_work/jointoai_work/Jointoai/server/README.md#数据库迁移) - [PostgreSQL 函数管理最佳实践](https://www.postgresql.org/docs/current/sql-createfunction.html) ## 总结 这次修复彻底解决了远程仓库迁移文件的3个核心问题: 1. ✅ 解决 asyncpg 下的函数创建冲突 2. ✅ 移除已删除表的引用 3. ✅ 确保所有迁移操作的幂等性 **修复结果**: - 团队成员拉取代码后不会遇到迁移失败 - `start_docker.sh -c -b` 可以稳定执行 - 数据库迁移链完整且可靠 **推荐操作**: ```bash # 1. 拉取最新代码 git pull origin main # 2. 清库重建(可选) cd server && ./start_docker.sh -c -b # 3. 或者增量迁移 docker exec jointo-server-app alembic upgrade head ``` --- **作者**:Claude (AI Assistant) **日期**:2026-02-15 **状态**:✅ 已验证并推送至远程