#!/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 https = require('https'); const crypto = require('crypto'); // ============================================================================ // 配置加载 // ============================================================================ // 加载 .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(); } }); }); }); } /** * 计算文件的 MD5 */ function getFileMd5(filePath) { const content = fs.readFileSync(filePath); return crypto.createHash('md5').update(content).digest('hex'); } /** * 获取 COS 上所有文件的 ETag(MD5) */ async function getRemoteFiles() { return new Promise((resolve, reject) => { const remoteFiles = {}; let marker = ''; const fetchBatch = () => { cos.getBucket({ Bucket: config.COS_BUCKET, Region: config.COS_REGION, MaxKeys: 1000, Marker: marker, }, (err, data) => { if (err) { reject(err); return; } data.Contents.forEach(item => { // COS 的 ETag 是 MD5,去掉引号 remoteFiles[item.Key] = item.ETag.replace(/"/g, ''); }); if (data.IsTruncated === 'true') { marker = data.NextMarker; fetchBatch(); } else { resolve(remoteFiles); } }); }; fetchBatch(); }); } /** * 上传整个 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 localFiles = getAllFiles(buildDir); console.log(`\n\x1b[36m[扫描]\x1b[0m 本地共 ${localFiles.length} 个文件`); // 获取远程文件列表 console.log('\x1b[36m[同步]\x1b[0m 获取 COS 文件列表...'); let remoteFiles = {}; try { remoteFiles = await getRemoteFiles(); console.log(` COS 上共 ${Object.keys(remoteFiles).length} 个文件`); } catch (err) { console.log(`\x1b[33m[警告]\x1b[0m 获取远程文件列表失败,将全量上传: ${err.message}`); } // 比对找出需要上传的文件 const filesToUpload = []; for (const filePath of localFiles) { const relativePath = path.relative(buildDir, filePath).replace(/\\/g, '/'); const localMd5 = getFileMd5(filePath); const remoteMd5 = remoteFiles[relativePath]; if (localMd5 !== remoteMd5) { filesToUpload.push({ filePath, relativePath }); } } console.log(`\x1b[36m[上传]\x1b[0m 需要上传 ${filesToUpload.length} 个文件(跳过 ${localFiles.length - filesToUpload.length} 个未变更)\n`); if (filesToUpload.length === 0) { console.log('\x1b[32m[✓]\x1b[0m 所有文件已是最新,无需上传'); return { uploaded: 0, failed: 0, skipped: localFiles.length }; } let uploaded = 0; let failed = 0; const errors = []; // 并发上传(限制并发数) const concurrency = 10; const chunks = []; for (let i = 0; i < filesToUpload.length; i += concurrency) { chunks.push(filesToUpload.slice(i, i + concurrency)); } for (const chunk of chunks) { await Promise.all(chunk.map(async ({ filePath, relativePath }) => { try { await uploadFile(filePath, relativePath); uploaded++; // 进度显示 const progress = Math.round((uploaded + failed) / filesToUpload.length * 100); process.stdout.write(`\r 进度: ${progress}% (${uploaded}/${filesToUpload.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},跳过 ${localFiles.length - filesToUpload.length},失败 ${failed}`); return { uploaded, failed, skipped: localFiles.length - filesToUpload.length }; } // ============================================================================ // CDN 刷新 (使用原生 HTTPS + TC3 签名,无需额外 SDK) // ============================================================================ /** * 生成腾讯云 TC3-HMAC-SHA256 签名 */ function generateTC3Signature(secretId, secretKey, service, action, payload) { const timestamp = Math.floor(Date.now() / 1000); const date = new Date(timestamp * 1000).toISOString().slice(0, 10); const host = `${service}.tencentcloudapi.com`; // 1. 拼接规范请求串 const httpRequestMethod = 'POST'; const canonicalUri = '/'; const canonicalQueryString = ''; const hashedPayload = crypto.createHash('sha256').update(payload).digest('hex'); // 只签名 content-type 和 host(按字母顺序) const canonicalHeaders = `content-type:application/json; charset=utf-8\nhost:${host}\n`; const signedHeaders = 'content-type;host'; const canonicalRequest = `${httpRequestMethod}\n${canonicalUri}\n${canonicalQueryString}\n${canonicalHeaders}\n${signedHeaders}\n${hashedPayload}`; // 2. 拼接待签名字符串 const algorithm = 'TC3-HMAC-SHA256'; const credentialScope = `${date}/${service}/tc3_request`; const hashedCanonicalRequest = crypto.createHash('sha256').update(canonicalRequest).digest('hex'); const stringToSign = `${algorithm}\n${timestamp}\n${credentialScope}\n${hashedCanonicalRequest}`; // 3. 计算签名 const secretDate = crypto.createHmac('sha256', `TC3${secretKey}`).update(date).digest(); const secretService = crypto.createHmac('sha256', secretDate).update(service).digest(); const secretSigning = crypto.createHmac('sha256', secretService).update('tc3_request').digest(); const signature = crypto.createHmac('sha256', secretSigning).update(stringToSign).digest('hex'); // 4. 拼接 Authorization const authorization = `${algorithm} Credential=${secretId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; return { authorization, timestamp }; } /** * 调用腾讯云 CDN API */ function callCdnApi(secretId, secretKey, action, params) { return new Promise((resolve, reject) => { const payload = JSON.stringify(params); const { authorization, timestamp } = generateTC3Signature(secretId, secretKey, 'cdn', action, payload); const options = { hostname: 'cdn.tencentcloudapi.com', port: 443, path: '/', method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8', 'Host': 'cdn.tencentcloudapi.com', 'X-TC-Action': action, 'X-TC-Version': '2018-06-06', 'X-TC-Timestamp': timestamp.toString(), 'X-TC-Region': '', 'Authorization': authorization, }, }; const req = https.request(options, (res) => { let data = ''; res.on('data', chunk => data += chunk); res.on('end', () => { try { const result = JSON.parse(data); if (result.Response && result.Response.Error) { reject(new Error(`${result.Response.Error.Code}: ${result.Response.Error.Message}`)); } else { resolve(result.Response); } } catch (e) { reject(new Error(`解析响应失败: ${data}`)); } }); }); req.on('error', reject); req.write(payload); req.end(); }); } /** * 刷新 CDN 缓存 * 自动刷新 index.html 等关键文件,确保用户获取最新版本 */ async function refreshCDN() { if (!config.CDN_DOMAIN) { console.log('\n\x1b[33m[提示]\x1b[0m 未配置 CDN 域名,跳过 CDN 刷新'); console.log(' 如需自动刷新 CDN,请在 .env.cos 中配置 CDN_DOMAIN'); return { success: false, reason: 'no_domain' }; } console.log('\n\x1b[36m[CDN]\x1b[0m 刷新 CDN 缓存...'); console.log(` 域名: ${config.CDN_DOMAIN}`); try { // 需要刷新的关键文件(不带 hash 的文件) const domain = config.CDN_DOMAIN.replace(/^https?:\/\//, ''); const urlsToRefresh = [ `https://${domain}/index.html`, `https://${domain}/`, `https://${domain}/asset-manifest.json`, `https://${domain}/manifest.json`, ]; console.log(' 刷新文件:'); urlsToRefresh.forEach(url => console.log(` - ${url}`)); // 调用 PurgeUrlsCache 接口 const result = await callCdnApi( config.COS_SECRET_ID, config.COS_SECRET_KEY, 'PurgeUrlsCache', { Urls: urlsToRefresh } ); console.log(`\n\x1b[32m[✓]\x1b[0m CDN 刷新任务已提交`); console.log(` 任务ID: ${result.TaskId}`); console.log(' 刷新生效时间约 5 分钟'); return { success: true, taskId: result.TaskId }; } catch (err) { console.error(`\n\x1b[33m[警告]\x1b[0m CDN 刷新失败: ${err.message}`); if (err.message.includes('AuthFailure')) { console.log(' 请检查 API 密钥是否正确'); } else if (err.message.includes('UnauthorizedOperation')) { console.log(' 请确保 API 密钥有 CDN 刷新权限'); console.log(' 权限策略: QcloudCDNFullAccess 或自定义 cdn:PurgeUrlsCache'); } console.log('\n 可手动刷新: https://console.cloud.tencent.com/cdn/refresh'); return { success: false, reason: 'api_error', error: err.message }; } } // ============================================================================ // 主流程 // ============================================================================ 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 刷新 const cdnResult = 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} 个文件`); console.log(` CDN刷新: ${cdnResult.success ? '✓ 已提交' : '✗ 跳过'}`); if (config.CDN_DOMAIN) { console.log(`\n 访问地址: https://${config.CDN_DOMAIN}`); if (cdnResult.success) { console.log(' 提示: CDN 刷新约需 5 分钟生效'); } } console.log('\n'); } catch (err) { console.error('\n\x1b[31m[错误]\x1b[0m 部署失败:', err.message); process.exit(1); } } // 运行 main();