gongxiangp2/docs/safe-migration-plan.md
chenpei ea9076df45 feat: 实现统一的下载错误处理系统并修复产品全案数据库Schema
- 创建统一的下载错误处理模块 (lib/download-errors.ts)
- 实现13种错误类型分类和中文错误消息
- 后端统一使用 createDownloadError 创建标准化错误响应
- 前端统一使用 parseDownloadError 解析错误并显示
- 修复正式系统数据库Schema,添加产品全案相关字段和表
- 执行数据库迁移:添加 task_no, task_status, task_fetched_at 字段
- 创建 material_assets, api_keys, api_call_logs 表
2026-01-05 17:52:42 +08:00

16 KiB
Raw Blame History

用户无感升级 - 安全迁移方案

核心原则: 新旧字段并存,逐步迁移,零数据丢失,用户无感知


🎯 迁移策略

核心原则

  1. 双写双读: 新旧字段同时存在,代码同时读写两个字段
  2. 数据优先: 优先使用新字段,新字段为空时回退到旧字段
  3. 逐步迁移: 先迁移数据,再迁移代码,最后清理旧字段
  4. 完整回滚: 每个步骤都可以安全回滚

📋 第一阶段:用户状态管理(无风险)

1.1 Schema变更

变更内容: 添加新字段,不删除旧字段

model User {
  // ... 现有字段保持不变 ...
  
  // ✅ 新增字段(可选,有默认值)
  status            String      @default("pending")  // pending/active/suspended/archived
  lastLoginAt       DateTime?
  lastLoginIp       String?
  loginCount        Int         @default(0)
  archivedAt        DateTime?
  
  // ✅ 保留旧字段(向后兼容)
  isActive          Boolean     @default(false)  // 继续使用,逐步迁移
}

影响评估:

  • ✅ 零风险: 只添加字段,不修改现有字段
  • ✅ 向后兼容: 所有现有代码继续工作
  • ✅ 默认值: 新字段有默认值,不会影响现有数据

1.2 数据迁移脚本

// scripts/migrate-user-status-safe.ts
// 将 isActive 同步到 status(只迁移一次)
async function migrateUserStatus() {
  // 1. 将 isActive=true 的用户设置为 status='active'
  await prisma.user.updateMany({
    where: { isActive: true, status: 'pending' },
    data: { status: 'active' }
  });
  
  // 2. 保持 isActive=false 的用户 status='pending'(默认值)
  // 无需操作,已经是默认值
}

执行时机: Schema变更后立即执行

1.3 代码更新策略

双写策略: 同时更新新旧字段

// 示例:用户激活API
await prisma.user.update({
  where: { id: userId },
  data: {
    isActive: true,        // ✅ 旧字段(保持兼容)
    status: 'active',      // ✅ 新字段(逐步迁移)
  }
});

双读策略: 优先使用新字段,回退到旧字段

// 示例:登录检查
const user = await prisma.user.findUnique({ where: { id: userId } });

// ✅ 优先使用新字段,如果为空则使用旧字段
const isActive = user.status === 'active' || 
                 (user.status === 'pending' && user.isActive === true);

影响评估:

  • ✅ 零风险: 旧字段继续工作,新字段逐步生效
  • ✅ 平滑过渡: 用户无感知,功能正常

📋 第二阶段:部门外键关联(低风险)

2.1 Schema变更

变更内容: 添加新字段,保留旧字段

model User {
  // ✅ 保留旧字段(向后兼容)
  department        String      @default("")  // 继续使用,逐步迁移
  
  // ✅ 新增字段(可选,可为空)
  departmentId      String?
  departmentRef     Department? @relation("UserDepartment", fields: [departmentId], references: [id])
  
  // ... 其他字段 ...
}

model Department {
  id        String   @id @default(uuid())
  name      String   @unique  // ✅ 保留,用于匹配
  
  // ✅ 新增关联
  users     User[]   @relation("UserDepartment")
  
  // ... 其他字段 ...
}

影响评估:

  • ✅ 低风险: 只添加字段,不删除字段
  • ✅ 向后兼容: 所有现有代码继续工作
  • ⚠️ 注意: 需要确保部门名称唯一(已满足)

2.2 数据迁移脚本

// scripts/migrate-department-to-id-safe.ts
async function migrateDepartmentToId() {
  // 1. 确保所有用户部门都在 Department 表中(已有脚本)
  await syncDepartmentsFromUsers();
  
  // 2. 为每个用户设置 departmentId(基于 department 名称匹配)
  const users = await prisma.user.findMany({
    where: {
      department: { not: '' },
      departmentId: null,  // 只迁移未设置的
    },
    select: { id: true, department: true }
  });
  
  for (const user of users) {
    const dept = await prisma.department.findUnique({
      where: { name: user.department }
    });
    
    if (dept) {
      await prisma.user.update({
        where: { id: user.id },
        data: { departmentId: dept.id }
      });
    }
  }
  
  console.log(`✅ 已迁移 ${users.length} 个用户的部门关联`);
}

执行时机: Schema变更后立即执行

安全措施:

  • ✅ 分批处理: 每次处理一批用户,避免长时间锁定
  • ✅ 幂等性: 可以重复执行,不会重复迁移
  • ✅ 验证: 迁移后验证所有用户都有有效的 departmentId

2.3 代码更新策略

双写策略: 同时更新新旧字段

// 示例:更新用户部门API
async function updateUserDepartment(userId: string, departmentName: string) {
  // 1. 查找部门ID
  const dept = await prisma.department.findUnique({
    where: { name: departmentName }
  });
  
  if (!dept) {
    throw new Error('部门不存在');
  }
  
  // 2. 同时更新新旧字段
  await prisma.user.update({
    where: { id: userId },
    data: {
      department: departmentName,  // ✅ 旧字段(保持兼容)
      departmentId: dept.id,       // ✅ 新字段(逐步迁移)
    }
  });
}

双读策略: 优先使用新字段,回退到旧字段

// 示例:获取用户部门
async function getUserDepartment(userId: string): Promise<string | null> {
  const user = await prisma.user.findUnique({
    where: { id: userId },
    include: { departmentRef: true }  // 关联查询
  });
  
  // ✅ 优先使用新字段,如果为空则使用旧字段
  if (user.departmentRef) {
    return user.departmentRef.name;
  }
  
  // 回退到旧字段
  return user.department || null;
}

权限检查更新:

// lib/department-permission.ts
export async function getUserDepartment(user: any): Promise<string | null> {
  // ✅ 优先使用新字段
  if (user.departmentId && user.departmentRef) {
    return user.departmentRef.name;
  }
  
  // 回退到旧字段
  return user.department || null;
}

影响评估:

  • ✅ 低风险: 旧字段继续工作,新字段逐步生效
  • ✅ 平滑过渡: 用户无感知,功能正常
  • ⚠️ 注意: 需要更新所有使用 department 字段的代码(但可以逐步更新)

📋 第三阶段:部门层级结构(中风险)

3.1 Schema变更

变更内容: 添加新字段,保留旧字段

model Department {
  id        String   @id @default(uuid())
  name      String   @unique  // ✅ 保留,向后兼容
  
  // ✅ 新增字段(可选,可为空)
  code        String?   @unique  // 部门编码(可选)
  parentId    String?   // 父部门ID
  parent      Department? @relation("DepartmentHierarchy", fields: [parentId], references: [id])
  children    Department[] @relation("DepartmentHierarchy")
  
  level       Int      @default(1)  // 层级:1/2/3
  managerId   String?
  manager     User?    @relation("DepartmentManager", fields: [managerId], references: [id])
  
  status      String   @default("active")  // active/disabled/archived
  description String?
  sortOrder   Int      @default(0)
  
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  
  // ✅ 保留关联
  users     User[]   @relation("UserDepartment")
  permissions DepartmentMaterialPermission[]
}

影响评估:

  • ✅ 低风险: 只添加字段,不删除字段
  • ✅ 向后兼容: 所有现有代码继续工作
  • ✅ 默认值: 新字段有默认值,现有部门自动为一级部门

3.2 数据迁移脚本

// scripts/migrate-department-hierarchy-safe.ts
async function migrateDepartmentHierarchy() {
  // 1. 为所有现有部门设置默认值
  await prisma.department.updateMany({
    where: {
      level: null,  // 或者没有这个字段
    },
    data: {
      level: 1,           // 默认一级部门
      status: 'active',   // 默认启用
      code: null,          // 编码可选,后续可以生成
    }
  });
  
  // 2. 如果需要设置层级关系,可以基于部门名称规则
  // 例如:如果部门名称包含 "-",可能是二级部门
  // 这部分需要根据实际业务规则定制
  
  console.log('✅ 部门层级结构迁移完成');
}

执行时机: Schema变更后立即执行

3.3 代码更新策略

查询兼容: 支持新旧两种查询方式

// 示例:获取部门列表
async function getDepartments(includeHierarchy = false) {
  if (includeHierarchy) {
    // ✅ 新方式:支持层级查询
    return await prisma.department.findMany({
      where: { status: 'active' },
      include: {
        parent: true,
        children: true,
      },
      orderBy: [
        { level: 'asc' },
        { sortOrder: 'asc' },
        { name: 'asc' },
      ],
    });
  } else {
    // ✅ 旧方式:扁平列表(向后兼容)
    return await prisma.department.findMany({
      orderBy: { name: 'asc' },
    });
  }
}

影响评估:

  • ✅ 低风险: 旧代码继续工作,新功能逐步启用
  • ✅ 平滑过渡: 用户无感知,功能正常

📋 第四阶段:角色枚举扩展(低风险)

4.1 Schema变更

变更内容: 扩展枚举,不删除现有值

enum Role {
  USER           // ✅ 保留
  ADMIN          // ✅ 保留(映射到 SYSTEM_ADMIN)
  DEPT_ADMIN     // ✅ 新增
  SYSTEM_ADMIN   // ✅ 新增
}

影响评估:

  • ⚠️ 注意: 需要将现有 ADMIN 角色映射到 SYSTEM_ADMIN
  • ✅ 向后兼容: 现有 USER 和 ADMIN 角色继续工作

4.2 数据迁移脚本

// scripts/migrate-role-enum-safe.ts
async function migrateRoleEnum() {
  // 1. 将现有 ADMIN 角色迁移为 SYSTEM_ADMIN
  // 注意:Prisma 不支持直接修改枚举值,需要:
  // - 先添加新角色
  // - 更新数据
  // - 可选:删除旧角色(但建议保留以兼容)
  
  // 由于 Prisma 限制,可能需要使用原生 SQL
  await prisma.$executeRaw`
    UPDATE users 
    SET role = 'SYSTEM_ADMIN' 
    WHERE role = 'ADMIN'
  `;
  
  console.log('✅ 角色枚举迁移完成');
}

执行时机: Schema变更后立即执行

4.3 代码更新策略

角色检查兼容: 支持新旧角色

// 示例:权限检查
function isAdmin(role: string): boolean {
  // ✅ 兼容新旧角色
  return role === 'ADMIN' || role === 'SYSTEM_ADMIN';
}

function isDepartmentAdmin(role: string): boolean {
  return role === 'DEPT_ADMIN';
}

影响评估:

  • ⚠️ 注意: 需要更新所有角色检查代码
  • ✅ 向后兼容: 现有 ADMIN 角色继续工作

🔒 安全措施

1. 数据库备份

每个阶段前必须备份:

# 备份命令(根据实际环境调整)
pg_dump -h 192.168.58.20 -U username -d lobechat > backup_$(date +%Y%m%d_%H%M%S).sql

2. 迁移验证

每个阶段后验证:

// scripts/verify-migration.ts
async function verifyMigration() {
  // 1. 验证所有用户都有有效的 status
  const usersWithoutStatus = await prisma.user.count({
    where: { status: null }
  });
  console.log(`用户状态: ${usersWithoutStatus === 0 ? '✅ 通过' : '❌ 失败'}`);
  
  // 2. 验证所有用户的 departmentId 都有效(如果 department 不为空)
  const usersWithInvalidDept = await prisma.user.count({
    where: {
      department: { not: '' },
      departmentId: null,
    }
  });
  console.log(`部门关联: ${usersWithInvalidDept === 0 ? '✅ 通过' : '❌ 失败'}`);
  
  // 3. 验证新旧字段一致性
  const inconsistentUsers = await prisma.user.findMany({
    where: {
      department: { not: '' },
      departmentId: { not: null },
    },
    include: { departmentRef: true }
  });
  
  const inconsistent = inconsistentUsers.filter(
    u => u.department !== u.departmentRef?.name
  );
  console.log(`字段一致性: ${inconsistent.length === 0 ? '✅ 通过' : '❌ 失败'}`);
}

3. 回滚方案

每个阶段都可以回滚:

// scripts/rollback-stage1.ts
async function rollbackStage1() {
  // 回滚用户状态:将 status 同步回 isActive
  await prisma.user.updateMany({
    where: { status: 'active' },
    data: { isActive: true }
  });
  
  await prisma.user.updateMany({
    where: { status: { in: ['pending', 'suspended', 'archived'] } },
    data: { isActive: false }
  });
  
  console.log('✅ 阶段一回滚完成');
}

📅 实施时间表

阶段一:用户状态管理(1周)

Day 1-2: Schema变更 + 数据迁移

  • 更新 prisma/schema.prisma
  • 运行 npx prisma db push
  • 运行迁移脚本
  • 验证数据

Day 3-5: 代码更新(双写双读)

  • 更新登录API(记录审计字段)
  • 更新用户管理API(双写 status 和 isActive)
  • 更新前端(可选,逐步迁移)

Day 6-7: 测试验证

  • 功能测试
  • 性能测试
  • 数据一致性验证

阶段二:部门外键关联(1-2周)

Week 1: Schema变更 + 数据迁移

  • 更新 prisma/schema.prisma
  • 运行迁移脚本
  • 验证数据

Week 2: 代码更新(双写双读)

  • 更新所有使用 department 字段的代码
  • 更新权限检查逻辑
  • 更新前端(可选)

阶段三:部门层级结构(1-2周)

Week 1: Schema变更 + 数据迁移

  • 更新 prisma/schema.prisma
  • 运行迁移脚本
  • 验证数据

Week 2: 代码更新

  • 更新部门管理API
  • 更新前端UI

阶段四:角色枚举扩展(1周)

Day 1-2: Schema变更 + 数据迁移

  • 更新 prisma/schema.prisma
  • 运行迁移脚本

Day 3-5: 代码更新

  • 更新权限检查逻辑
  • 更新前端UI

✅ 检查清单

阶段一检查清单

  • 数据库备份完成
  • Schema变更已应用
  • 数据迁移脚本已执行
  • 数据验证通过
  • 登录API已更新(记录审计字段)
  • 用户管理API已更新(双写)
  • 功能测试通过
  • 性能测试通过

阶段二检查清单

  • 数据库备份完成
  • Schema变更已应用
  • 部门数据已同步
  • 用户部门关联已迁移
  • 数据验证通过
  • 所有使用 department 的代码已更新(双写双读)
  • 权限检查逻辑已更新
  • 功能测试通过

阶段三检查清单

  • 数据库备份完成
  • Schema变更已应用
  • 部门层级数据已迁移
  • 数据验证通过
  • 部门管理API已更新
  • 前端UI已更新
  • 功能测试通过

阶段四检查清单

  • 数据库备份完成
  • Schema变更已应用
  • 角色数据已迁移
  • 数据验证通过
  • 权限检查逻辑已更新
  • 前端UI已更新
  • 功能测试通过

🚨 风险控制

高风险操作

  1. 删除旧字段 ❌

    • 风险: 可能导致现有代码崩溃
    • 措施: 至少保留3个月,确认所有代码已迁移后再删除
  2. 修改现有字段类型 ❌

    • 风险: 可能导致数据丢失
    • 措施: 不修改,只添加新字段
  3. 强制迁移 ❌

    • 风险: 可能导致服务中断
    • 措施: 使用双写双读,逐步迁移

低风险操作

  1. 添加新字段 ✅

    • 风险: 低,有默认值
    • 措施: 确保有默认值,可选字段可为空
  2. 添加新关联 ✅

    • 风险: 低,不影响现有数据
    • 措施: 确保外键可为空
  3. 扩展枚举 ✅

    • 风险: 低,不删除现有值
    • 措施: 保留现有值,只添加新值

📝 总结

核心策略

  1. 新旧字段并存: 所有变更都添加新字段,保留旧字段
  2. 双写双读: 代码同时读写新旧字段,确保兼容性
  3. 逐步迁移: 先迁移数据,再迁移代码,最后清理
  4. 完整验证: 每个阶段都进行数据验证和功能测试

用户无感保证

  • ✅ 数据不丢失: 所有数据都保留,新旧字段同步
  • ✅ 功能不中断: 旧代码继续工作,新功能逐步启用
  • ✅ 性能不下降: 添加索引,优化查询
  • ✅ 可回滚: 每个阶段都可以安全回滚

文档版本: 1.0
创建日期: 2025-12-27
最后更新: 2025-12-27