#!/usr/bin/env node /** * 腾讯云 COS 部署脚本 * 将构建产物上传到 COS 存储桶,配合 CDN 实现静态资源加速 * * 使用方法: * node scripts/deploy-to-cos.js * * 配置文件: * .env.cos (从 .env.cos.example 复制并填写) */ const COS = require('cos-nodejs-sdk-v5'); const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); // ============================================================================ // 配置加载 // ============================================================================ // 加载 .env.cos 配置 function loadConfig() { const envPath = path.join(__dirname, '..', '.env.cos'); if (!fs.existsSync(envPath)) { console.error('\x1b[31m[错误]\x1b[0m 配置文件不存在: .env.cos'); console.log('\n请先创建配置文件:'); console.log(' 1. 复制 .env.cos.example 为 .env.cos'); console.log(' 2. 填写腾讯云 SecretId、SecretKey 等信息\n'); process.exit(1); } const envContent = fs.readFileSync(envPath, 'utf-8'); const config = {}; envContent.split('\n').forEach(line => { const trimmed = line.trim(); if (trimmed && !trimmed.startsWith('#')) { const [key, ...valueParts] = trimmed.split('='); if (key) { config[key.trim()] = valueParts.join('=').trim(); } } }); // 验证必需配置 const required = ['COS_SECRET_ID', 'COS_SECRET_KEY', 'COS_BUCKET', 'COS_REGION']; const missing = required.filter(key => !config[key]); if (missing.length > 0) { console.error('\x1b[31m[错误]\x1b[0m 配置不完整,缺少:', missing.join(', ')); process.exit(1); } return config; } // ============================================================================ // COS 客户端 // ============================================================================ let cos = null; let config = null; function initCOS() { config = loadConfig(); cos = new COS({ SecretId: config.COS_SECRET_ID, SecretKey: config.COS_SECRET_KEY, }); console.log('\x1b[32m[✓]\x1b[0m COS 客户端初始化成功'); console.log(` 存储桶: ${config.COS_BUCKET}`); console.log(` 地域: ${config.COS_REGION}`); } // ============================================================================ // 文件操作 // ============================================================================ /** * 递归获取目录下所有文件 */ function getAllFiles(dirPath, arrayOfFiles = []) { const files = fs.readdirSync(dirPath); files.forEach(file => { const fullPath = path.join(dirPath, file); if (fs.statSync(fullPath).isDirectory()) { getAllFiles(fullPath, arrayOfFiles); } else { arrayOfFiles.push(fullPath); } }); return arrayOfFiles; } /** * 获取文件的 Content-Type */ function getContentType(filePath) { const ext = path.extname(filePath).toLowerCase(); const mimeTypes = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'application/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.webp': 'image/webp', '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.eot': 'application/vnd.ms-fontobject', '.map': 'application/json', '.txt': 'text/plain; charset=utf-8', '.xml': 'application/xml', '.webmanifest': 'application/manifest+json', }; return mimeTypes[ext] || 'application/octet-stream'; } /** * 获取缓存控制头 */ function getCacheControl(filePath) { const ext = path.extname(filePath).toLowerCase(); const fileName = path.basename(filePath); // HTML 文件不缓存(或短缓存) if (ext === '.html') { return 'no-cache, no-store, must-revalidate'; } // 带 hash 的静态资源长期缓存(JS/CSS 构建产物) // 文件名格式: main.abc123.js, styles.def456.css if (/\.[a-f0-9]{8,}\.(js|css)$/.test(fileName)) { return 'public, max-age=31536000, immutable'; // 1 年 } // 图片、字体缓存 30 天 if (['.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico', '.webp', '.woff', '.woff2', '.ttf', '.eot'].includes(ext)) { return 'public, max-age=2592000'; // 30 天 } // 其他文件缓存 1 天 return 'public, max-age=86400'; } // ============================================================================ // 上传功能 // ============================================================================ /** * 上传单个文件到 COS */ function uploadFile(localPath, remotePath) { return new Promise((resolve, reject) => { const contentType = getContentType(localPath); const cacheControl = getCacheControl(localPath); cos.putObject({ Bucket: config.COS_BUCKET, Region: config.COS_REGION, Key: remotePath, Body: fs.createReadStream(localPath), ContentType: contentType, CacheControl: cacheControl, }, (err, data) => { if (err) { reject(err); } else { resolve(data); } }); }); } /** * 清空存储桶(可选) */ async function clearBucket() { console.log('\n\x1b[36m[清理]\x1b[0m 清空存储桶旧文件...'); return new Promise((resolve, reject) => { cos.getBucket({ Bucket: config.COS_BUCKET, Region: config.COS_REGION, MaxKeys: 1000, }, async (err, data) => { if (err) { reject(err); return; } if (!data.Contents || data.Contents.length === 0) { console.log(' 存储桶为空,无需清理'); resolve(); return; } const objects = data.Contents.map(item => ({ Key: item.Key })); console.log(` 发现 ${objects.length} 个文件`); cos.deleteMultipleObject({ Bucket: config.COS_BUCKET, Region: config.COS_REGION, Objects: objects, }, (err, data) => { if (err) { reject(err); } else { console.log(` 已删除 ${objects.length} 个文件`); resolve(); } }); }); }); } /** * 上传整个 build 目录 */ async function uploadBuildDir() { const buildDir = path.join(__dirname, '..', 'build'); if (!fs.existsSync(buildDir)) { console.error('\x1b[31m[错误]\x1b[0m build 目录不存在,请先运行 npm run build'); process.exit(1); } const files = getAllFiles(buildDir); console.log(`\n\x1b[36m[上传]\x1b[0m 共 ${files.length} 个文件待上传...\n`); let uploaded = 0; let failed = 0; const errors = []; // 并发上传(限制并发数) const concurrency = 10; const chunks = []; for (let i = 0; i < files.length; i += concurrency) { chunks.push(files.slice(i, i + concurrency)); } for (const chunk of chunks) { await Promise.all(chunk.map(async (filePath) => { const relativePath = path.relative(buildDir, filePath).replace(/\\/g, '/'); try { await uploadFile(filePath, relativePath); uploaded++; // 进度显示 const progress = Math.round((uploaded + failed) / files.length * 100); process.stdout.write(`\r 进度: ${progress}% (${uploaded}/${files.length})`); } catch (err) { failed++; errors.push({ file: relativePath, error: err.message }); } })); } console.log('\n'); if (failed > 0) { console.log('\x1b[33m[警告]\x1b[0m 部分文件上传失败:'); errors.forEach(({ file, error }) => { console.log(` - ${file}: ${error}`); }); } console.log(`\x1b[32m[✓]\x1b[0m 上传完成: 成功 ${uploaded},失败 ${failed}`); return { uploaded, failed }; } // ============================================================================ // CDN 刷新(可选) // ============================================================================ /** * 刷新 CDN 缓存 * 注意:需要额外配置 CDN API 权限 */ async function refreshCDN() { if (!config.CDN_DOMAIN) { console.log('\n\x1b[33m[提示]\x1b[0m 未配置 CDN 域名,跳过 CDN 刷新'); console.log(' 如需自动刷新 CDN,请在 .env.cos 中配置 CDN_DOMAIN'); return; } console.log('\n\x1b[36m[CDN]\x1b[0m 刷新 CDN 缓存...'); console.log(` 域名: ${config.CDN_DOMAIN}`); console.log('\n\x1b[33m[提示]\x1b[0m 由于文件名包含 hash,通常不需要手动刷新 CDN'); console.log(' 如需刷新,请到腾讯云 CDN 控制台操作:'); console.log(' https://console.cloud.tencent.com/cdn/refresh'); } // ============================================================================ // 主流程 // ============================================================================ async function main() { console.log('\n╔════════════════════════════════════════════════════════════════╗'); console.log('║ 腾讯云 COS 部署工具 - ValueFrontier ║'); console.log('╚════════════════════════════════════════════════════════════════╝\n'); const startTime = Date.now(); try { // 1. 初始化 COS 客户端 initCOS(); // 2. 可选:清空存储桶 const clearFirst = process.argv.includes('--clear'); if (clearFirst) { await clearBucket(); } // 3. 上传文件 const { uploaded, failed } = await uploadBuildDir(); // 4. CDN 刷新提示 await refreshCDN(); // 5. 完成 const duration = ((Date.now() - startTime) / 1000).toFixed(1); console.log('\n╔════════════════════════════════════════════════════════════════╗'); console.log('║ 🎉 部署成功! ║'); console.log('╚════════════════════════════════════════════════════════════════╝'); console.log(`\n 耗时: ${duration} 秒`); console.log(` 上传: ${uploaded} 个文件`); if (config.CDN_DOMAIN) { console.log(`\n 访问地址: https://${config.CDN_DOMAIN}`); } console.log('\n'); } catch (err) { console.error('\n\x1b[31m[错误]\x1b[0m 部署失败:', err.message); process.exit(1); } } // 运行 main();