公司项目对接了oss后,需要将历史数据迁移到oss上,本文将详细介绍如何安全地将Strapi中的媒体文件迁移到OSS,并更新所有数据库引用。
为什么需要迁移到OSS?
- 存储空间解放 - 释放服务器宝贵的磁盘空间
- 访问速度提升 - 利用CDN全球加速
- 成本优化 - 按需付费比固定存储更经济
- 可靠性增强 - OSS提供99.9999999999%的数据持久性
- 扩展性提升 - 轻松应对流量激增
迁移前准备工作
1️⃣ 环境配置
# 创建.env文件
REGION=oss-cn-hangzhou
BUCKET=your-bucket-name
ACCESS_KEY_ID=your-access-key
ACCESS_KEY_SECRET=your-secret-key
STRAPI_DB_PATH=/path/to/your/strapi.db
MEDIA_BASE_PATH=/path/to/strapi/public/uploads
BASE_URL=https://your-cdn-domain.com
UPLOAD_PATH=strapi-media # OSS存储路径
2️⃣ 安装依赖
npm install ali-oss sqlite3 dotenv
3️⃣ 目录结构准备
/strapi-project
├── migrate-to-oss.js # 迁移脚本
├── .env # 环境变量
├── public/uploads # 原始媒体文件
│ └── done # 迁移后文件存放目录(脚本自动创建)
迁移脚本详解
核心模块初始化
require("dotenv").config();
const fs = require("fs");
const path = require("path");
const sqlite3 = require("sqlite3").verbose();
const OSS = require("ali-oss");
// OSS客户端初始化
const client = new OSS({
region: process.env.REGION,
bucket: process.env.BUCKET,
accessKeyId: process.env.ACCESS_KEY_ID,
accessKeySecret: process.env.ACCESS_KEY_SECRET
});
// 数据库连接
const db = new sqlite3.Database(process.env.STRAPI_DB_PATH);
文件路径处理函数
// 解析本地文件路径
function resolveLocalFilePath(url) {
const relative = url.startsWith("/") ? url.slice(1) : url;
return path.resolve(process.env.MEDIA_BASE_PATH, path.basename(relative));
}
// 构建OSS URL
function buildOssUrl(filename) {
return `${process.env.BASE_URL.replace(//$/, "")}/${process.env.UPLOAD_PATH}/${filename}`;
}
核心上传逻辑
async function uploadToOss(localPath, ossKey) {
try {
// 上传文件到OSS
const result = await client.put(ossKey, localPath);
// 移动已处理文件到done目录
const destPath = path.resolve(process.env.MEDIA_BASE_PATH, 'done', path.basename(localPath));
fs.renameSync(localPath, destPath);
// 返回HTTPS链接
return result.url.replace(/^http:/, "https:");
} catch (err) {
throw new Error(`上传失败:${ossKey} (${err.message})`);
}
}
数据库记录更新
async function migrateFileRecord(file) {
// 1. 上传主文件
const ossKey = `${process.env.UPLOAD_PATH}/${path.basename(file.url)}`;
const ossUrl = await uploadToOss(resolveLocalFilePath(file.url), ossKey);
// 2. 更新数据库记录
const updateQuery = `
UPDATE files
SET url = ?, provider = ?
WHERE id = ?
`;
db.run(updateQuery, [ossUrl, "oss", file.id], (err) => {
if (err) console.error(`❌ 更新失败 (ID: ${file.id})`);
else console.log(`✅ 记录更新成功 (ID: ${file.id})`);
});
// 3. 处理预览图和格式文件(详细逻辑见完整脚本)
// ...
}
完整迁移流程
步骤1:执行迁移脚本
node migrate-to-oss.js
步骤2:监控迁移过程
🔍 发现 248 个待迁移文件。
✅ 文件记录更新成功 (ID: 1)
📦 已处理文件记录 (ID: 1),原文件已迁移到 done 目录
✅ 文件记录更新成功 (ID: 2)
⚠️ 预览图上传失败 (ID: 3): 文件不存在
🎉 所有文件迁移任务已处理完毕。
步骤3:验证迁移结果
- 检查OSS控制台文件列表
- 随机抽查数据库记录:
SELECT id, url, provider FROM files LIMIT 5;
- 访问生成的OSS链接测试
步骤4:配置Strapi使用OSS
// ./config/plugins.js
module.exports = ({ env }) => ({
upload: {
config: {
provider: 'strapi-provider-upload-ali-oss',
providerOptions: {
accessKeyId: env('ALI_OSS_KEY_ID'),
accessKeySecret: env('ALI_OSS_KEY_SECRET'),
region: env('ALI_OSS_REGION'),
bucket: env('ALI_OSS_BUCKET'),
baseUrl: env('ALI_OSS_BASE_URL'),
prefix: 'strapi-media',
}
}
}
});
迁移策略最佳实践
1. 增量迁移方案
// 只迁移未处理过的文件
const query = `SELECT * FROM files WHERE provider = 'local'`;
2. 断点续传设计
// 记录已处理文件ID
const processedIds = new Set();
fs.writeFileSync('progress.json', JSON.stringify([...processedIds]));
3. 并发控制优化
// 限制并发数为5
const { PromisePool } = require('@supercharge/promise-pool');
await PromisePool
.for(rows)
.withConcurrency(5)
.process(migrateFileRecord);
4. 回滚机制
# 回滚脚本示例
#!/bin/bash
OSS_PATH="oss://$BUCKET/$UPLOAD_PATH"
LOCAL_BACKUP="./backup-$(date +%Y%m%d)"
# 1. 从OSS下载文件
aliyun oss cp $OSS_PATH $LOCAL_BACKUP -r
# 2. 恢复数据库记录
sqlite3 strapi.db "UPDATE files SET provider='local'"
常见问题解决方案
| 问题 | 解决方案 |
|---|---|
| 文件不存在错误 | 检查MEDIA_BASE_PATH路径,确保有读取权限 |
| OSS上传权限错误 | 确认AccessKey有PutObject权限 |
| 数据库锁定错误 | 确保Strapi服务已停止运行 |
| 特殊字符文件名 | 使用encodeURIComponent处理OSS key |
| 大文件上传超时 | 分片上传:client.multipartUpload |
| 迁移后图片不显示 | 检查BASE_URL是否包含CDN加速域名 |
迁移后优化建议
-
设置OSS生命周期规则
# 自动删除30天前的临时文件 ali oss lifecycle set oss-lifecycle.json{ "rules": [{ "id": "delete-temp", "prefix": "temp/", "status": "Enabled", "days": 30 }] } -
开启CDN加速
# 配置CDN缓存策略 aliyun cdn ModifyCdnDomain --DomainName your.cdn.com \ --CacheType "suffix" \ --CacheContent ".jpg;.png;.gif" \ --TTL 2592000 -
自动化备份策略
# 每天1点同步到备份Bucket 0 1 * * * aliyun oss cp oss://prod-bucket oss://backup-bucket --meta only -
迁移验证脚本
// 验证文件数量和大小 const localCount = fs.readdirSync(mediaPath).length; const ossList = await client.list({ prefix: uploadPath }); if (localCount !== ossList.objects.length) { throw new Error('文件数量不匹配!'); }
总结
通过本文的迁移方案,你可以:
- ✅ 安全地将Strapi媒体文件迁移到OSS
- ✅ 保持数据库引用的一致性
- ✅ 实现零停机迁移
- ✅ 获得自动化的回滚能力
- ✅ 显著提升文件访问性能
关键提示:生产环境迁移前务必在测试环境验证,并使用
--dry-run参数进行试运行。迁移完成后,立即配置Strapi使用OSS插件,确保新上传的文件直接存储到OSS。
最后贴个完整代码:
Vue项目HTTPS配置完整代码
// vue.config.js
const fs = require('fs');
const path = require('path');
module.exports = {
devServer: {
https: {
key: fs.readFileSync(path.resolve(__dirname, 'localhost-key.pem')),
cert: fs.readFileSync(path.resolve(__dirname, 'localhost-cert.pem'))
},
port: 8443, // HTTPS推荐端口
host: 'luan.test',
headers: {
'Access-Control-Allow-Origin': '*' // 解决跨域
},
// 修复HMR热更新
client: {
webSocketURL: 'wss://luan.test:8443/ws'
}
},
// 配置Webpack使用正确的主机名
chainWebpack: config => {
config.plugin('define').tap(args => {
args[0]['process.env'].BASE_URL = '"https://luan.test:8443"';
return args;
});
}
}
Strapi OSS迁移脚本完整代码
// migrate-to-oss.js
require("dotenv").config();
const fs = require("fs");
const path = require("path");
const sqlite3 = require("sqlite3").verbose();
const OSS = require("ali-oss");
// 初始化OSS客户端
const client = new OSS({
region: process.env.REGION,
bucket: process.env.BUCKET,
accessKeyId: process.env.ACCESS_KEY_ID,
accessKeySecret: process.env.ACCESS_KEY_SECRET,
});
// 数据库连接
const db = new sqlite3.Database(process.env.STRAPI_DB_PATH);
const mediaBasePath = process.env.MEDIA_BASE_PATH;
const ossBaseUrl = process.env.BASE_URL.replace(//$/, "");
const uploadPath = process.env.UPLOAD_PATH;
// 创建done目录存放已迁移文件
const doneDir = path.resolve(mediaBasePath, 'done');
if (!fs.existsSync(doneDir)) {
fs.mkdirSync(doneDir, { recursive: true });
}
const uploadedSet = new Set();
// 解析本地文件路径
function resolveLocalFilePath(url) {
const relative = url.startsWith("/") ? url.slice(1) : url;
return path.resolve(mediaBasePath, path.basename(relative));
}
// 检查是否为本地路径
function isLocalPath(url) {
return url && url.startsWith("/uploads/");
}
// 构建OSS URL
function buildOssUrl(filename) {
return `${ossBaseUrl}/${uploadPath}/${filename}`;
}
// 上传文件到OSS
async function uploadToOss(localPath, ossKey) {
if (uploadedSet.has(ossKey)) {
return buildOssUrl(path.basename(ossKey));
}
try {
console.log(`⬆️ 上传中: ${path.basename(localPath)} -> ${ossKey}`);
const result = await client.put(ossKey, localPath);
uploadedSet.add(ossKey);
// 移动已处理文件
const destPath = path.resolve(doneDir, path.basename(localPath));
fs.renameSync(localPath, destPath);
console.log(`✅ 上传成功: ${ossKey}`);
return result.url.replace(/^http:/, "https:"); // 强制HTTPS
} catch (err) {
throw new Error(`上传失败:${ossKey} (${err.message})`);
}
}
// 迁移单个文件记录
async function migrateFileRecord(file) {
const id = file.id;
const basename = path.basename(file.url);
const ossUrl = buildOssUrl(basename);
const localPath = resolveLocalFilePath(file.url);
// 检查本地文件是否存在
if (!fs.existsSync(localPath)) {
console.warn(`⚠️ 本地文件不存在:${localPath}`);
return;
}
try {
// 上传主文件
const ossKey = `${uploadPath}/${basename}`;
const uploadedUrl = await uploadToOss(localPath, ossKey);
} catch (err) {
console.error(`❌ 主文件上传失败 (ID: ${id})`, err.message);
return;
}
// 构造更新字段
const updatedFields = {
url: ossUrl,
provider: "oss",
};
// 处理预览图
if (isLocalPath(file.preview_url)) {
const previewBase = path.basename(file.preview_url);
const previewPath = resolveLocalFilePath(file.preview_url);
if (fs.existsSync(previewPath)) {
try {
const previewKey = `${uploadPath}/${previewBase}`;
await uploadToOss(previewPath, previewKey);
updatedFields.preview_url = buildOssUrl(previewBase);
} catch (err) {
console.warn(`⚠️ 预览图上传失败 (ID: ${id}): ${err.message}`);
updatedFields.preview_url = null;
}
} else {
updatedFields.preview_url = null;
}
}
// 处理格式文件(缩略图等)
let newFormats = null;
if (file.formats) {
try {
const formats = JSON.parse(file.formats);
for (const key in formats) {
if (formats[key]?.url && isLocalPath(formats[key].url)) {
const formatFile = path.basename(formats[key].url);
const formatPath = resolveLocalFilePath(formats[key].url);
if (fs.existsSync(formatPath)) {
try {
const formatKey = `${uploadPath}/${formatFile}`;
await uploadToOss(formatPath, formatKey);
formats[key].url = buildOssUrl(formatFile);
} catch (err) {
console.warn(`⚠️ 格式图上传失败 (${key}, ID: ${id})`);
}
}
}
}
newFormats = JSON.stringify(formats);
} catch (e) {
console.warn(`⚠️ 格式字段解析失败 (ID: ${id})`);
}
}
// 更新数据库记录
const updateQuery = `
UPDATE files
SET url = ?, preview_url = ?, provider = ?, formats = ?
WHERE id = ?
`;
db.run(
updateQuery,
[
updatedFields.url,
updatedFields.preview_url || null,
updatedFields.provider,
newFormats,
id,
],
(err) => {
if (err) {
console.error(`❌ 数据库更新失败 (ID: ${id})`, err.message);
} else {
console.log(`✅ 文件记录更新成功 (ID: ${id})`);
}
console.log(`📦 已处理文件记录 (ID: ${id}),原文件已迁移到 done 目录`);
}
);
}
// 主迁移函数
async function migrate() {
console.log('🚀 开始迁移Strapi媒体文件到OSS');
console.log(`📁 媒体目录: ${mediaBasePath}`);
console.log(`🌐 OSS存储路径: ${ossBaseUrl}/${uploadPath}`);
const query = `SELECT * FROM files WHERE provider = 'local'`;
db.all(query, async (err, rows) => {
if (err) {
console.error("❌ 查询文件表失败:", err.message);
return;
}
console.log(`🔍 发现 ${rows.length} 个待迁移文件`);
// 使用Promise Pool控制并发
const { PromisePool } = require('@supercharge/promise-pool');
const { errors } = await PromisePool
.for(rows)
.withConcurrency(5) // 并发数控制
.process(async (file) => {
await migrateFileRecord(file);
});
if (errors && errors.length > 0) {
console.error(`❌ 迁移完成,但有 ${errors.length} 个错误`);
errors.forEach(error => console.error(error));
} else {
console.log("🎉 所有文件迁移成功!");
}
// 生成迁移报告
const report = {
total: rows.length,
succeeded: rows.length - errors.length,
failed: errors.length,
timestamp: new Date().toISOString()
};
fs.writeFileSync('migration-report.json', JSON.stringify(report, null, 2));
console.log(`📊 迁移报告已保存: migration-report.json`);
});
}
// 执行迁移
migrate();
.env配置文件示例
# .env
REGION=oss-cn-hangzhou
BUCKET=your-bucket-name
ACCESS_KEY_ID=your-access-key-id
ACCESS_KEY_SECRET=your-access-key-secret
STRAPI_DB_PATH=/path/to/strapi.db
MEDIA_BASE_PATH=/path/to/strapi/public/uploads
BASE_URL=https://your-cdn-domain.com
UPLOAD_PATH=strapi-media
Strapi OSS插件配置
// ./config/plugins.js
module.exports = ({ env }) => ({
upload: {
config: {
provider: 'strapi-provider-upload-ali-oss',
providerOptions: {
accessKeyId: env('ALI_OSS_KEY_ID'),
accessKeySecret: env('ALI_OSS_KEY_SECRET'),
region: env('ALI_OSS_REGION'),
bucket: env('ALI_OSS_BUCKET'),
baseUrl: env('ALI_OSS_BASE_URL'),
prefix: 'strapi-media',
secure: true, // 强制HTTPS
timeout: 60000 // 上传超时时间
}
}
}
});
迁移后验证脚本
// verify-migration.js
require("dotenv").config();
const sqlite3 = require("sqlite3").verbose();
const OSS = require("ali-oss");
const client = new OSS({
region: process.env.REGION,
bucket: process.env.BUCKET,
accessKeyId: process.env.ACCESS_KEY_ID,
accessKeySecret: process.env.ACCESS_KEY_SECRET,
});
const db = new sqlite3.Database(process.env.STRAPI_DB_PATH);
async function verifyMigration() {
console.log("🔍 开始验证迁移结果...");
// 检查数据库记录
const dbQuery = `SELECT COUNT(*) as total,
SUM(CASE WHEN provider = 'oss' THEN 1 ELSE 0 END) as oss_count
FROM files`;
db.get(dbQuery, async (err, row) => {
if (err) {
console.error("❌ 数据库查询失败:", err);
return;
}
console.log(`📊 数据库统计: 总记录 ${row.total}, OSS记录 ${row.oss_count}`);
if (row.total !== row.oss_count) {
console.error(`❌ 错误: 有 ${row.total - row.oss_count} 条记录未迁移`);
}
// 检查OSS文件数量
try {
const list = await client.list({
prefix: process.env.UPLOAD_PATH,
'max-keys': 1000
});
console.log(`📦 OSS文件数量: ${list.objects.length}`);
if (list.objects.length < row.total) {
console.warn(`⚠️ 警告: OSS文件数(${list.objects.length})少于数据库记录(${row.total})`);
}
// 随机抽查文件
const randomFile = list.objects[Math.floor(Math.random() * list.objects.length)];
console.log(`🔗 随机文件测试: ${randomFile.url}`);
try {
const result = await client.head(randomFile.name);
console.log(`✅ 文件访问正常: ${randomFile.name} (${result.res.size} bytes)`);
} catch (headErr) {
console.error(`❌ 文件访问失败: ${randomFile.name}`, headErr.message);
}
} catch (ossErr) {
console.error("❌ OSS查询失败:", ossErr);
}
});
}
verifyMigration();
回滚脚本
#!/bin/bash
# rollback-migration.sh
# 加载环境变量
source .env
# 配置参数
OSS_PATH="oss://$BUCKET/$UPLOAD_PATH"
LOCAL_BACKUP="./backup-$(date +%Y%m%d)"
echo "⏪ 开始回滚迁移..."
# 1. 从OSS下载文件
echo "⬇️ 从OSS下载文件到: $LOCAL_BACKUP"
aliyun oss cp "$OSS_PATH" "$LOCAL_BACKUP" --recursive
# 2. 恢复文件到原始目录
echo "🔄 恢复本地文件..."
find "$LOCAL_BACKUP" -type f -exec cp {} "$MEDIA_BASE_PATH" ;
# 3. 恢复数据库记录
echo "💾 恢复数据库记录..."
sqlite3 "$STRAPI_DB_PATH" <<EOF
UPDATE files SET
url = REPLACE(url, '$BASE_URL/$UPLOAD_PATH', '/uploads'),
preview_url = CASE
WHEN preview_url IS NOT NULL THEN REPLACE(preview_url, '$BASE_URL/$UPLOAD_PATH', '/uploads')
ELSE NULL
END,
provider = 'local',
formats = CASE
WHEN formats IS NOT NULL THEN REPLACE(formats, '$BASE_URL/$UPLOAD_PATH', '/uploads')
ELSE NULL
END
WHERE provider = 'oss';
EOF
echo "✅ 回滚完成!"
echo "原始文件位置: $MEDIA_BASE_PATH"
echo "备份文件位置: $LOCAL_BACKUP"