- 创建统一的下载错误处理模块 (lib/download-errors.ts) - 实现13种错误类型分类和中文错误消息 - 后端统一使用 createDownloadError 创建标准化错误响应 - 前端统一使用 parseDownloadError 解析错误并显示 - 修复正式系统数据库Schema,添加产品全案相关字段和表 - 执行数据库迁移:添加 task_no, task_status, task_fetched_at 字段 - 创建 material_assets, api_keys, api_call_logs 表
16 KiB
16 KiB
用户无感升级 - 安全迁移方案
核心原则: 新旧字段并存,逐步迁移,零数据丢失,用户无感知
🎯 迁移策略
核心原则
- 双写双读: 新旧字段同时存在,代码同时读写两个字段
- 数据优先: 优先使用新字段,新字段为空时回退到旧字段
- 逐步迁移: 先迁移数据,再迁移代码,最后清理旧字段
- 完整回滚: 每个步骤都可以安全回滚
📋 第一阶段:用户状态管理(无风险)
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已更新
- 功能测试通过
🚨 风险控制
高风险操作
-
删除旧字段 ❌
- 风险: 可能导致现有代码崩溃
- 措施: 至少保留3个月,确认所有代码已迁移后再删除
-
修改现有字段类型 ❌
- 风险: 可能导致数据丢失
- 措施: 不修改,只添加新字段
-
强制迁移 ❌
- 风险: 可能导致服务中断
- 措施: 使用双写双读,逐步迁移
低风险操作
-
添加新字段 ✅
- 风险: 低,有默认值
- 措施: 确保有默认值,可选字段可为空
-
添加新关联 ✅
- 风险: 低,不影响现有数据
- 措施: 确保外键可为空
-
扩展枚举 ✅
- 风险: 低,不删除现有值
- 措施: 保留现有值,只添加新值
📝 总结
核心策略
- 新旧字段并存: 所有变更都添加新字段,保留旧字段
- 双写双读: 代码同时读写新旧字段,确保兼容性
- 逐步迁移: 先迁移数据,再迁移代码,最后清理
- 完整验证: 每个阶段都进行数据验证和功能测试
用户无感保证
- ✅ 数据不丢失: 所有数据都保留,新旧字段同步
- ✅ 功能不中断: 旧代码继续工作,新功能逐步启用
- ✅ 性能不下降: 添加索引,优化查询
- ✅ 可回滚: 每个阶段都可以安全回滚
文档版本: 1.0
创建日期: 2025-12-27
最后更新: 2025-12-27