559 lines
19 KiB
JavaScript
559 lines
19 KiB
JavaScript
#!/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();
|