/** * 更新发布工具 * * Copyright © 2025 imengyu.top imengyu-update-server */ import { confirm, select, input } from '@inquirer/prompts'; import { writeFile, readFile, access, unlink, stat, constants } from 'node:fs/promises'; import path from 'node:path'; import OSS from 'ali-oss'; import cliProgress from 'cli-progress'; import { selectVersion } from './index.mjs'; import { config } from './postConfig.mjs'; import { readFileRange, compressZip, execAsync } from './utils.mjs'; import { dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; //基础配置 //======================================== const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const UPLOAD_APP_TYPE_LOCAL = 1; const UPLOAD_APP_TYPE_ALI_OSS = 2; async function getConfig() { let postConfig = { lastVersion: '', lastUpdateInfo: '', lastSubmitDay: '', lastTodaySubVersion: 0, }; try { postConfig = JSON.parse(await readFile(path.resolve(__dirname, './_config.json'))); } catch { // } if (postConfig.lastSubmitDay != new Date().getDate()) postConfig.lastTodaySubVersion = 0; return postConfig; } async function saveConfig(postConfig) { postConfig.lastSubmitDay = new Date().getDate(); await writeFile(path.resolve(__dirname, './_config.json'), JSON.stringify(postConfig)); } async function getUpdateInfo(postConfig) { let updateInfo = await input({ message: '输入更新信息', default: postConfig.lastUpdateInfo }); if (updateInfo === 'git') { console.log('开始获取git提交信息'); updateInfo = await execAsync('git log -1 --pretty=format:"%h %s"'); console.log('使用git提交信息 "' + updateInfo + '" 作为更新信息'); } return updateInfo; } async function uploadMulitPartLarge(axiosInstance, fileInfo, filePath) { console.log('开始分片上传'); const multuploadInfo = (await axiosInstance.post('/update-large-token', { fileSize: fileInfo.size, fileName: path.basename(filePath) })).data; const chunkSize = multuploadInfo.splitPartSize; const bar1 = new cliProgress.SingleBar({}, cliProgress.Presets.shades_classic); bar1.start(100, 0); for (let i = 0; i < multuploadInfo.allChunks; i++) { const start = i * chunkSize; const len = Math.min(start + chunkSize, fileInfo.size) - start; const uploadZipContent = await readFileRange(filePath, start, len); const subFormData = new FormData(); subFormData.append("file", new Blob([ uploadZipContent ], { type : 'application/zip' }), 'upload.zip'); subFormData.append("key", multuploadInfo.key); subFormData.append("info", JSON.stringify({})); (await axiosInstance.post('/update-large', subFormData, { headers: { 'Content-Type': 'multipart/form-data' } })).data; bar1.update(Math.floor(i / multuploadInfo.allChunks * 100)); } bar1.update(100); bar1.stop(); return multuploadInfo.key; } async function aliOSSMultipartUpload(client, fileName, uploadFile, progressCallback) { const fileInfo = await stat(uploadFile); // 获取文件信息 const partSize = 10 * 1024 * 1024; // 分片大小,这里使用10MB const partCount = Math.ceil(fileInfo.size / partSize); // 计算总分片数 console.log(`开始分片上传到阿里OSS,文件大小: ${(fileInfo.size / 1024 / 1024).toFixed(2)}MB,分片数: ${partCount}`); try { // 初始化分片上传 const multipartUpload = await client.initMultipartUpload(fileName); const uploadId = multipartUpload.uploadId; // 上传分片 const parts = []; for (let i = 0; i < partCount; i++) { const start = i * partSize; const len = Math.min(partSize, fileInfo.size - start); // 读取文件分片 const fileData = await readFileRange(uploadFile, start, len); // 上传分片 const partResult = await client.uploadPart(fileName, fileData, uploadId, i + 1); parts.push({ ETag: partResult.res.headers.etag, PartNumber: i + 1 }); // 计算并回调进度 const progress = (i + 1) / partCount; if (progressCallback) { progressCallback(progress); } console.log(`已上传分片 ${i + 1}/${partCount} (${(progress * 100).toFixed(2)}%)`); } // 完成分片上传 await client.completeMultipartUpload(fileName, uploadId, parts); console.log('阿里OSS分片上传完成'); // 获取上传后的文件URL const url = await client.generateObjectUrl(fileName); return url; } catch (error) { console.error('阿里OSS分片上传失败:', error); throw error; } } //App更新与提交 //======================================== export async function postAppUpdate(axiosInstance, param) { const postConfig = await getConfig(); const { versionId, versionName } = await selectVersion(false, postConfig.lastVersion); const updateInfo = await getUpdateInfo(postConfig); const serverConfig = await axiosInstance.post('/update-post', { config: { type: 2, test: true, versionId, uploadAppConfig: {}, submitKey: config.submitKey } }); postConfig.lastVersion = versionId; postConfig.lastUpdateInfo = updateInfo; postConfig.lastTodaySubVersion++; const updateSource = (await select({ choices: [ { name: '重新构建', value: 'rebuild', }, { name: '已上传的阿里OSS文件路径', value: 'uploaded-alioss', }, ], message: '选择上传来源', default: 'rebuild', })); const force = await confirm({ message: '强制更新?', default: false }); const updateNext = await confirm({ message: '作为下个版本?', default: false }); const versionCode = await config.buildAppGetVersion(versionName); await saveConfig(postConfig); if (updateSource === 'rebuild') await config.buildAppCallback(param, versionCode, versionName, postConfig.lastTodaySubVersion); else if (updateSource === 'uploaded-alioss') { const fileName = await input({ message: '输入已上传的阿里OSS文件路径' }); try { const result = (await axiosInstance.post('/update-post', { type: 2, ossConfig: { ossPath: fileName, ossPublic: '', }, uploadAppConfig: { updateAsNext: updateNext, }, versionId, updateInfo, versionCode: versionCode })).data; console.log('上传成功'); console.log('新更新ID: ' + result.updateId); } catch (e) { console.error('上传失败', e); } return; } else { console.error('错误的选择'); return; } const uploadFile = await config.buildAppGetUploadFile(param, versionCode, versionName); const fileName = `${await config.buildAppGetOSSFileName(param, versionCode, path.basename(uploadFile), uploadFile)}`; try { await access(uploadFile, constants.R_OK) } catch { console.error(`Failed to access ${uploadFile}, did you created it?`); return; } console.log('开始上传'); //小于8mb则小文件上传,否则使用阿里OSS上传 const fileInfo = await stat(uploadFile); if (fileInfo.size < 8 * 1024 * 1024) { const appData = (await readFile(uploadFile)); const formData = new FormData(); formData.append("file", new Blob([ appData ], { type : 'application/zip' })); formData.append("config", JSON.stringify({ type: 2, versionId, versionCode, updateInfo, updateCustomConfig: await config.getCustomConfig(param, true, versionName), uploadAppConfig: { updateAsNext: updateNext }, force, fileName, })); try { const result = (await axiosInstance.post('/update-post', formData, { headers: { 'Content-Type': 'multipart/form-data' } })).data; console.log('上传成功'); console.log('新更新ID: ' + result.updateId); } catch (e) { console.error('上传失败', e); } } else { let ossConfig; let multuploadedKey; if (serverConfig.uploadAppType === UPLOAD_APP_TYPE_LOCAL) { //本地大文件上传 multuploadedKey = await uploadMulitPartLarge(axiosInstance, fileInfo, uploadFile); } else if (serverConfig.uploadAppType === UPLOAD_APP_TYPE_ALI_OSS) { //阿里OSS上传 //请求STS进行临时授权 const stsToken = (await axiosInstance.post('/update-ali-oss-sts')).data; const client = new OSS({ region: stsToken.Region, accessKeyId: stsToken.AccessKeyId, accessKeySecret: stsToken.AccessKeySecret, stsToken: stsToken.SecurityToken, bucket: stsToken.Bucket, refreshSTSToken: async () => { const refreshToken = (await axiosInstance.get("/update-ali-oss-sts")).data; return { accessKeyId: refreshToken.AccessKeyId, accessKeySecret: refreshToken.AccessKeySecret, stsToken: refreshToken.SecurityToken, }; }, }); //小于96mb则直接上传,否则分片上传 console.log('Start upload to ali oss'); let returnUrl = ''; if (fileInfo.size < 96 * 1024 * 1024) { const result = await client.put(fileName, uploadFile); returnUrl = result.url; } else { const bar1 = new cliProgress.SingleBar({}, cliProgress.Presets.shades_classic); bar1.start(100, 0); returnUrl = await aliOSSMultipartUpload(client, fileName, uploadFile, (p) => { bar1.update(p * 100); }); bar1.update(100); bar1.stop(); } ossConfig = { ossPath: fileName, ossPublic: returnUrl, }; console.log('Upload to ali oss done'); } else { console.error('错误的 uploadAppType 配置 ' + serverConfig.uploadAppType); } try { const result = (await axiosInstance.post('/update-post', { config: { type: 2, ossConfig, multuploadedKey, uploadAppConfig: { updateAsNext: updateNext, }, updateCustomConfig: await config.getCustomConfig(param, true, versionName), versionId, updateInfo, versionCode: versionCode, fileName }, })).data; console.log('上传成功'); console.log('新更新ID: ' + result.updateId); } catch (e) { console.error('上传失败', e); } } } //Web更新与提交 //======================================== export async function postWebUpdate(axiosInstance, param) { const skipBuild = param.skip const noDelete = param.ndelete; const postConfig = await getConfig(); const { versionId, versionName } = await selectVersion(false, postConfig.lastVersion); const updateInfo = await getUpdateInfo(postConfig); postConfig.lastVersion = versionId; postConfig.lastUpdateInfo = updateInfo; postConfig.lastTodaySubVersion++; await axiosInstance.post('/update-post', { config: { type: 1, test: true, versionId, uploadWebConfig: config.uploadWebConfig, submitKey: config.submitKey } }); const now = new Date(); const versionCode = await config.buildWebVersionGenerateCommand(now, postConfig.lastTodaySubVersion); if (config.buildWebOutVersionPath) await writeFile(path.resolve(__dirname, config.buildWebOutVersionPath), versionCode); await saveConfig(postConfig); if (!skipBuild && config.buildWebCommand) { console.log('正在执行构建...'); await execAsync(config.buildWebCommand); console.log('构建完成'); } const distDir = path.resolve(__dirname, config.buildWebOutDir); try { await access(distDir, constants.R_OK) } catch { console.error(`Failed to access ${distDir}`); return; } const outputPath = __dirname + '/upload.zip'; const skipFiles = config?.buildWebOptions?.skipFiles ?? []; if (!skipBuild) { console.log('开始压缩zip...'); await compressZip(distDir, outputPath, skipFiles); } console.log('开始上传zip'); let success = false; //小于8mb则小文件上传,否则分片上传 const fileInfo = await stat(outputPath); const formData = new FormData(); const submitConfig = { type: 1, versionId, updateInfo, versionCode, uploadWebConfig: config.uploadWebConfig, updateCustomConfig: await config.getCustomConfig(param, true, versionName), } if (fileInfo.size < 8 * 1024 * 1024) { const uploadZipContent = await readFile(outputPath); formData.append("file", new Blob([ uploadZipContent ], { type : 'application/zip' }), 'upload.zip'); } else { submitConfig.multuploadedKey = await uploadMulitPartLarge(axiosInstance, fileInfo, outputPath); } try { formData.append("config", JSON.stringify(submitConfig)); const result = (await axiosInstance.post('/update-post', formData, { headers: { 'Content-Type': 'multipart/form-data' } })).data; console.log('上传成功'); console.log('新更新ID: ' + result.updateId); success = true; } catch (e) { console.error('上传失败', e); } if (!success || noDelete) return; console.log('删除zip'); await unlink(outputPath); }