Files
vf_react/scripts/deploy-to-cos.js

559 lines
19 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 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 上所有文件的 ETagMD5
*/
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();