Files
vf_react/scripts/deploy-to-cos.js
2026-01-09 11:10:05 +08:00

432 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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');
// CDN SDK (可选,如果安装了的话)
let CdnClient = null;
try {
const tencentcloud = require('tencentcloud-sdk-nodejs-cdn');
CdnClient = tencentcloud.cdn.v20180606.Client;
} catch (e) {
// CDN SDK 未安装,稍后会提示用户
}
// ============================================================================
// 配置加载
// ============================================================================
// 加载 .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 缓存
* 自动刷新 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' };
}
// 检查是否安装了 CDN SDK
if (!CdnClient) {
console.log('\n\x1b[33m[提示]\x1b[0m CDN SDK 未安装,跳过自动刷新');
console.log(' 安装命令: npm install tencentcloud-sdk-nodejs-cdn --save-dev');
console.log(' 或手动刷新: https://console.cloud.tencent.com/cdn/refresh');
return { success: false, reason: 'no_sdk' };
}
console.log('\n\x1b[36m[CDN]\x1b[0m 刷新 CDN 缓存...');
console.log(` 域名: ${config.CDN_DOMAIN}`);
try {
// 初始化 CDN 客户端
const cdnClient = new CdnClient({
credential: {
secretId: config.COS_SECRET_ID,
secretKey: config.COS_SECRET_KEY,
},
region: '', // CDN 为全局服务region 留空
profile: {
signMethod: 'TC3-HMAC-SHA256',
httpProfile: {
reqMethod: 'POST',
reqTimeout: 30,
},
},
});
// 需要刷新的关键文件(不带 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 cdnClient.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.code === 'AuthFailure.SecretIdNotFound' || err.code === 'AuthFailure.SignatureFailure') {
console.log(' 请检查 API 密钥是否正确');
} else if (err.code === '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();