Strapi对接阿里oss:迁移历史数据

201 阅读4分钟

公司项目对接了oss后,需要将历史数据迁移到oss上,本文将详细介绍如何安全地将Strapi中的媒体文件迁移到OSS,并更新所有数据库引用。


为什么需要迁移到OSS?

  1. 存储空间解放 - 释放服务器宝贵的磁盘空间
  2. 访问速度提升 - 利用CDN全球加速
  3. 成本优化 - 按需付费比固定存储更经济
  4. 可靠性增强 - OSS提供99.9999999999%的数据持久性
  5. 扩展性提升 - 轻松应对流量激增

迁移前准备工作

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:验证迁移结果

  1. 检查OSS控制台文件列表
  2. 随机抽查数据库记录:
SELECT id, url, provider FROM files LIMIT 5;
  1. 访问生成的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加速域名

迁移后优化建议

  1. 设置OSS生命周期规则

    # 自动删除30天前的临时文件
    ali oss lifecycle set oss-lifecycle.json
    
    {
      "rules": [{
        "id": "delete-temp",
        "prefix": "temp/",
        "status": "Enabled",
        "days": 30
      }]
    }
    
  2. 开启CDN加速

    # 配置CDN缓存策略
    aliyun cdn ModifyCdnDomain --DomainName your.cdn.com \
      --CacheType "suffix" \
      --CacheContent ".jpg;.png;.gif" \
      --TTL 2592000
    
  3. 自动化备份策略

    # 每天1点同步到备份Bucket
    0 1 * * * aliyun oss cp oss://prod-bucket oss://backup-bucket --meta only
    
  4. 迁移验证脚本

    // 验证文件数量和大小
    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"