# S3素材统一迁移方案 ## 背景 当前系统中素材分散在两个S3存储位置: - **老位置**:`s3.1080p.hk/jsxiangche` - 老素材 - **新位置**:`s3.yydd.ai/gongxiangpan` - 新素材 **目标**:将所有素材统一迁移到新位置 `s3.yydd.ai/gongxiangpan`,方便后期维护。 --- ## 项目存储结构 ### 文件存储方式 1. **源文件(压缩包)** → 本地 `./uploads` 目录(Docker挂载)- **不需要迁移**(1,411个,699.5 GB) 2. **音频/视频源文件** → MinIO S3(zipFileName字段)- **需要迁移**(97个在老S3) 3. **预览图/视频转码文件** → MinIO S3 - **需要迁移** ### 数据库中的S3相关字段 | 字段 | 说明 | 存储内容 | |------|------|----------| | previewUrl | 预览图URL | 图片(webp/jpg) | | zipFileName | 源文件名/音频视频URL | 本地文件名 或 S3音频/视频URL | | zipFilePath | 源文件路径 | 本地路径(uploads/xxx 或 /app/uploads/xxx) | | displayImages | 详情图数组 | 多张图片URL | | videoSourceUrl | 视频源文件 | S3视频URL | | videoPreview540Url | 540P预览视频 | S3视频URL | | videoPreviewClipUrl | 视频片段 | S3视频URL | | videoCoverUrl | 视频封面 | S3图片URL | **重要说明**: - `zipFilePath` 存储本地路径,有两种格式:`uploads/xxx.zip` 或 `/app/uploads/xxx.zip` - 音频/视频类素材的源文件存储在 `zipFileName` 字段(S3 URL),`zipFilePath` 为空 --- ## 迁移数据量统计 ### 需要迁移的S3文件(从 s3.1080p.hk 到 s3.yydd.ai) | 字段 | 总数 | 老S3 (需迁移) | 新S3 (已在目标) | 空/本地 | |------|------|---------------|-----------------|---------| | previewUrl | 1,525 | **1,114** | 313 | 97 | | zipFileName (音频) | 1,525 | **97** | 13 | 1,415 | | videoSourceUrl | 1,525 | **4** | 13 | 1,401 | | videoPreview540Url | 1,525 | **122** | 1 | 1,402 | | videoPreviewClipUrl | 1,525 | **122** | 1 | 1,402 | | videoCoverUrl | 1,525 | **78** | 45 | 1,402 | | displayImages | 2,665张 | **2,358** | 307 | - | ### 需要迁移的文件总数 - previewUrl: 1,114个 - zipFileName (音频): 97个 - videoSourceUrl: 4个 - videoPreview540Url: 122个 - videoPreviewClipUrl: 122个 - videoCoverUrl: 78个 - displayImages: 2,358个 - **总计:约 3,895 个文件** --- ## 文件大小估算 ### 1. 音频/视频源文件(zipFileName字段,已知精确大小) 从数据库 `zipFileSize` 字段获取: - 音乐:93个,**523.11 MB**(平均 5.62 MB/个) - 主图视频源文件:4个,**183.06 MB**(平均 45.76 MB/个) - **音频/视频源文件总计:706.17 MB** ### 2. 预览图(估算) - 数量:1,114个 - 平均大小:约 50KB - **预览图总计:约 55.7 MB** ### 3. 详情图(估算) - 数量:2,358个 - 平均大小:约 100KB - **详情图总计:约 235.8 MB** ### 4. 视频转码文件(估算) 视频时长统计: - 种草视频:56个,总时长 16.53分钟 - 主图视频:39个,总时长 17.18分钟 - 安装视频:27个,总时长 77.33分钟 - **总时长:约 111分钟** 估算大小(540P视频约 2MB/分钟,片段约 0.5MB/分钟): - videoPreview540Url:122个,约 **222 MB** - videoPreviewClipUrl:122个,约 **55 MB** - videoCoverUrl:78个,约 **4 MB** - **视频转码文件总计:约 281 MB** ### 总数据量 | 类型 | 文件数 | 大小 | |------|--------|------| | 音频/视频源文件 | 101 | 706 MB | | 预览图 | 1,114 | 56 MB | | 详情图 | 2,358 | 236 MB | | 视频转码文件 | 322 | 281 MB | | **总计** | **3,895** | **约 1.28 GB** --- ## 迁移时间估算 ### 网络速度参考 根据网络诊断测试结果: - **s3.1080p.hk 公网下载速度**:20-33 KB/s(frp内网穿透,很慢) - **s3.yydd.ai 公网下载速度**:类似(也是frp) - **内网速度**:服务器在内网,速度快得多 ### 迁移方式对比 #### 方案1:通过服务器内网中转(推荐) **流程**:s3.1080p.hk → 192.168.58.20服务器 → s3.yydd.ai **优点**: - 服务器在内网,下载/上传速度快 - 可以并发处理多个文件 - 可以断点续传 **预计时间**: - 假设内网速度:10 MB/s - 数据量:1.28 GB - 下载时间:1.28 GB ÷ 10 MB/s = **约 2分钟** - 上传时间:1.28 GB ÷ 10 MB/s = **约 2分钟** - **总计:约 4分钟**(理想情况) **实际时间**: - 考虑并发处理、网络波动、错误重试 - **预计:10-20分钟** #### 方案2:直接S3间复制 **流程**:s3.1080p.hk → s3.yydd.ai(直接复制) **缺点**: - 两个S3可能不支持服务端复制 - 可能走公网,速度慢 - **不推荐** --- ## 迁移方案(推荐方案1) ### 迁移步骤 #### 1. 准备阶段 - [x] 统计需要迁移的文件数量和大小 - [x] 了解项目存储结构 - [ ] 备份数据库(防止迁移失败) - [ ] 创建迁移脚本 - [ ] 在影子系统测试迁移脚本 #### 2. 迁移脚本功能 需要迁移的字段: - `previewUrl` - 预览图 - `zipFileName` - 音频文件(仅当包含S3 URL时) - `displayImages` - 详情图数组 - `videoSourceUrl` - 视频源文件 - `videoPreview540Url` - 540P预览视频 - `videoPreviewClipUrl` - 视频片段 - `videoCoverUrl` - 视频封面 脚本逻辑: - 从数据库查询所有包含 `s3.1080p.hk` 的素材 - 遍历每个素材的每个S3字段: - 下载文件到服务器临时目录 - 上传到 `s3.yydd.ai` - 更新数据库中的URL - 记录迁移日志(成功/失败) - 支持断点续传(失败后可继续) #### 3. 迁移执行 - 在影子系统先测试(少量素材) - 验证迁移后的素材可正常访问 - 在正式系统执行完整迁移 - 迁移过程中系统可正常使用(只读) #### 4. 验证阶段 - 检查数据库中所有URL已更新为 `s3.yydd.ai` - 随机抽查素材可正常访问 - 检查详情图、视频预览可正常显示 - 确认数据量一致 #### 5. 清理阶段 - 确认迁移成功后,可选择删除 `s3.1080p.hk` 上的旧文件 - 更新 `.env` 配置,统一使用 `s3.yydd.ai` - 重新部署应用 --- ## 迁移脚本设计 ### 脚本功能 ```typescript // scripts/migrate-s3-materials.ts interface MigrationResult { success: number; failed: number; skipped: number; errors: Array<{ materialId: string; field: string; error: string }>; } // 需要迁移的S3字段 const S3_FIELDS = [ 'previewUrl', 'zipFileName', // 仅当包含S3 URL时 'videoSourceUrl', 'videoPreview540Url', 'videoPreviewClipUrl', 'videoCoverUrl', ]; async function migrateMaterials() { // 1. 查询所有需要迁移的素材(任意字段包含老S3 URL) const materials = await prisma.$queryRaw` SELECT * FROM materials WHERE "deletedAt" IS NULL AND ( "previewUrl" LIKE '%s3.1080p.hk%' OR "zipFileName" LIKE '%s3.1080p.hk%' OR "videoSourceUrl" LIKE '%s3.1080p.hk%' OR "videoPreview540Url" LIKE '%s3.1080p.hk%' OR "videoPreviewClipUrl" LIKE '%s3.1080p.hk%' OR "videoCoverUrl" LIKE '%s3.1080p.hk%' OR EXISTS ( SELECT 1 FROM unnest("displayImages") AS url WHERE url LIKE '%s3.1080p.hk%' ) ) `; // 2. 遍历迁移 for (const material of materials) { try { const updates: Record = {}; // 2.1 迁移各个S3字段 for (const field of S3_FIELDS) { const url = material[field]; if (url && url.includes('s3.1080p.hk')) { const newUrl = await migrateFile(url); updates[field] = newUrl; } } // 2.2 迁移详情图数组 let newDisplayImages = material.displayImages; if (material.displayImages && material.displayImages.length > 0) { newDisplayImages = []; for (const imageUrl of material.displayImages) { if (imageUrl.includes('s3.1080p.hk')) { const newImageUrl = await migrateFile(imageUrl); newDisplayImages.push(newImageUrl); } else { newDisplayImages.push(imageUrl); } } updates.displayImages = newDisplayImages; } // 2.3 更新数据库 if (Object.keys(updates).length > 0) { await prisma.material.update({ where: { id: material.id }, data: updates }); } console.log(`✅ Migrated: ${material.id}`); } catch (error) { console.error(`❌ Failed: ${material.id}`, error); } } } async function migrateFile(oldUrl: string): Promise { // 1. 从旧S3下载文件 const response = await fetch(oldUrl); const buffer = await response.arrayBuffer(); // 2. 提取文件名和类型 const fileName = extractFileName(oldUrl); const contentType = getContentType(fileName); // 3. 上传到新S3 const newUrl = await uploadToNewS3(Buffer.from(buffer), fileName, contentType); return newUrl; } ``` ### 并发控制 ```typescript // 使用 p-limit 控制并发数 import pLimit from 'p-limit'; const limit = pLimit(5); // 同时处理5个文件(避免内存溢出) const promises = materials.map(material => limit(() => migrateMaterial(material)) ); await Promise.all(promises); ``` --- ## 风险评估 ### 高风险 - ❌ **数据丢失**:迁移过程中文件丢失 - 缓解措施:先备份数据库,迁移后验证 - ❌ **URL更新失败**:数据库更新失败导致URL不一致 - 缓解措施:使用事务,失败自动回滚 ### 中风险 - ⚠️ **迁移时间过长**:影响用户使用 - 缓解措施:选择低峰期迁移,支持断点续传 - ⚠️ **网络不稳定**:下载/上传失败 - 缓解措施:自动重试机制,记录失败日志 ### 低风险 - ℹ️ **旧文件残留**:迁移后旧文件未删除 - 缓解措施:迁移成功后手动清理 --- ## 回滚方案 如果迁移失败,需要回滚: ### 回滚步骤 1. 停止迁移脚本 2. 恢复数据库备份 3. 检查已迁移的素材 4. 删除新S3上的文件(如果需要) 5. 重新执行迁移(修复问题后) ### 回滚SQL ```sql -- 恢复备份(如果有) -- 或者手动更新URL UPDATE materials SET "previewUrl" = REPLACE("previewUrl", 's3.yydd.ai/gongxiangpan', 's3.1080p.hk/jsxiangche'), "displayImages" = ARRAY( SELECT REPLACE(url::text, 's3.yydd.ai/gongxiangpan', 's3.1080p.hk/jsxiangche') FROM unnest("displayImages") AS url ) WHERE "previewUrl" LIKE '%s3.yydd.ai%'; ``` --- ## 迁移后配置更新 ### 1. 更新 .env 配置 ```bash # 旧配置(s3.1080p.hk) S3_ENDPOINT=https://s3.1080p.hk S3_BUCKET=jsxiangche S3_PUBLIC_DOMAIN=https://s3.1080p.hk/jsxiangche # 新配置(s3.yydd.ai) S3_ENDPOINT=https://s3.yydd.ai S3_BUCKET=gongxiangpan S3_PUBLIC_DOMAIN=https://s3.yydd.ai/gongxiangpan S3_FILE_PATH=由壹点AI/ ``` ### 2. 需要确认的新S3凭证 迁移前需要确认 `s3.yydd.ai` 的访问凭证: - S3_ACCESS_KEY_ID - S3_SECRET_ACCESS_KEY ### 3. 重新部署应用 ```bash # 部署到影子系统测试 .\deploy-local-build.ps1 -Target shadow # 验证成功后部署到正式系统 .\deploy-local-build.ps1 ``` --- ## 执行计划 ### 时间安排 - **准备阶段**:30分钟(创建脚本、配置凭证) - **影子系统测试**:10分钟(迁移少量素材测试) - **正式系统迁移**:10-20分钟(迁移全部素材) - **验证阶段**:10分钟(检查数据完整性) - **总计**:约 1小时 ### 执行时间建议 - 选择低峰期(晚上或周末) - 提前通知用户(如果需要) - 准备回滚方案 --- ## 总结 ### 迁移数据量 - **文件数量**:3,895个文件 - **数据大小**:约 1.28 GB - **预计时间**:10-20分钟(实际迁移) ### 文件类型分布 | 类型 | 文件数 | 大小 | |------|--------|------| | 音频/视频源文件 | 101 | 706 MB | | 预览图 | 1,114 | 56 MB | | 详情图 | 2,358 | 236 MB | | 视频转码文件 | 322 | 281 MB | | **总计** | **3,895** | **约 1.28 GB** | ### 推荐方案 - 使用方案1(通过服务器内网中转) - 并发处理(5个文件同时) - 支持断点续传 - 完整的日志记录 ### 下一步 1. 确认 `s3.yydd.ai` 的访问凭证 2. 创建迁移脚本 3. 在影子系统测试 4. 执行正式迁移 5. 验证数据完整性 6. 更新配置并重新部署