postUpdate.mjs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424
  1. /**
  2. * 更新发布工具
  3. *
  4. * Copyright © 2025 imengyu.top imengyu-update-server
  5. */
  6. import { confirm, select, input } from '@inquirer/prompts';
  7. import { writeFile, readFile, access, unlink, stat, constants } from 'node:fs/promises';
  8. import path from 'node:path';
  9. import OSS from 'ali-oss';
  10. import cliProgress from 'cli-progress';
  11. import { selectVersion } from './app.mjs';
  12. import { config } from './postConfig.mjs';
  13. import { readFileRange, compressZip, execAsync, logError } from './utils.mjs';
  14. import { dirname } from 'node:path';
  15. import { fileURLToPath } from 'node:url';
  16. import consola from 'consola';
  17. //基础配置
  18. //========================================
  19. const __filename = fileURLToPath(import.meta.url);
  20. const __dirname = dirname(__filename);
  21. const UPLOAD_APP_TYPE_LOCAL = 1;
  22. const UPLOAD_APP_TYPE_ALI_OSS = 2;
  23. async function getConfig() {
  24. let postConfig = {
  25. lastVersion: '',
  26. lastUpdateInfo: '',
  27. lastSubmitDay: '',
  28. lastTodaySubVersion: 0,
  29. };
  30. try {
  31. postConfig = JSON.parse(await readFile(path.resolve(__dirname, './_config.json')));
  32. } catch {
  33. //
  34. }
  35. if (postConfig.lastSubmitDay != new Date().getDate())
  36. postConfig.lastTodaySubVersion = 0;
  37. return postConfig;
  38. }
  39. async function saveConfig(postConfig) {
  40. postConfig.lastSubmitDay = new Date().getDate();
  41. await writeFile(path.resolve(__dirname, './_config.json'), JSON.stringify(postConfig));
  42. }
  43. async function getUpdateInfo(postConfig) {
  44. let lastUseGit = false;
  45. let updateInfo = await input({ message: '输入更新信息', default: postConfig.lastUpdateInfo });
  46. if (updateInfo === 'git') {
  47. consola.info('开始获取git提交信息');
  48. lastUseGit = true;
  49. updateInfo = await execAsync('git log -1 --pretty=format:"%h %s"');
  50. consola.info('使用git提交信息 "' + updateInfo + '" 作为更新信息');
  51. }
  52. return { updateInfo, lastUseGit };
  53. }
  54. export async function uploadMulitPartLarge(axiosInstance, fileInfo, filePath) {
  55. consola.start('开始分片上传');
  56. const multuploadInfo = (await axiosInstance.post('/update/update-large-token', {
  57. fileSize: fileInfo.size,
  58. fileName: path.basename(filePath)
  59. })).data;
  60. const chunkSize = multuploadInfo.splitPartSize;
  61. const bar1 = new cliProgress.SingleBar({}, cliProgress.Presets.shades_classic);
  62. bar1.start(100, 0);
  63. for (let i = 0; i < multuploadInfo.allChunks; i++) {
  64. const start = i * chunkSize;
  65. const len = Math.min(start + chunkSize, fileInfo.size) - start;
  66. const uploadZipContent = await readFileRange(filePath, start, len);
  67. const subFormData = new FormData();
  68. subFormData.append("file", new Blob([ uploadZipContent ], { type : 'application/zip' }), 'upload.zip');
  69. subFormData.append("key", multuploadInfo.key);
  70. subFormData.append("info", JSON.stringify({}));
  71. (await axiosInstance.post('/update/update-large', subFormData, {
  72. headers: { 'Content-Type': 'multipart/form-data' }
  73. })).data;
  74. bar1.update(Math.floor(i / multuploadInfo.allChunks * 100));
  75. }
  76. bar1.update(100);
  77. bar1.stop();
  78. return multuploadInfo.key;
  79. }
  80. async function aliOSSMultipartUpload(client, fileName, uploadFile, progressCallback) {
  81. const fileInfo = await stat(uploadFile); // 获取文件信息
  82. const partSize = 10 * 1024 * 1024; // 分片大小,这里使用10MB
  83. const partCount = Math.ceil(fileInfo.size / partSize); // 计算总分片数
  84. consola.start(`开始分片上传到阿里OSS,文件大小: ${(fileInfo.size / 1024 / 1024).toFixed(2)}MB,分片数: ${partCount}`);
  85. try {
  86. // 初始化分片上传
  87. const multipartUpload = await client.initMultipartUpload(fileName);
  88. const uploadId = multipartUpload.uploadId;
  89. // 上传分片
  90. const parts = [];
  91. for (let i = 0; i < partCount; i++) {
  92. const start = i * partSize;
  93. const len = Math.min(partSize, fileInfo.size - start);
  94. // 读取文件分片
  95. const fileData = await readFileRange(uploadFile, start, len);
  96. // 上传分片
  97. const partResult = await client.uploadPart(fileName, fileData, uploadId, i + 1);
  98. parts.push({
  99. ETag: partResult.res.headers.etag,
  100. PartNumber: i + 1
  101. });
  102. // 计算并回调进度
  103. const progress = (i + 1) / partCount;
  104. if (progressCallback) {
  105. progressCallback(progress);
  106. }
  107. consola.info(`已上传分片 ${i + 1}/${partCount} (${(progress * 100).toFixed(2)}%)`);
  108. }
  109. // 完成分片上传
  110. await client.completeMultipartUpload(fileName, uploadId, parts);
  111. consola.success('阿里OSS分片上传完成');
  112. // 获取上传后的文件URL
  113. const url = await client.generateObjectUrl(fileName);
  114. return url;
  115. } catch (error) {
  116. logError('阿里OSS分片上传失败:', error);
  117. throw error;
  118. }
  119. }
  120. //App更新与提交
  121. //========================================
  122. export async function postAppUpdate(axiosInstance, param) {
  123. const postConfig = await getConfig();
  124. const { versionId, versionName } = await selectVersion(postConfig.lastVersion);
  125. const { updateInfo, lastUseGit } = await getUpdateInfo(postConfig);
  126. const serverConfig = await axiosInstance.post('/update/update-post', {
  127. config: {
  128. type: 2,
  129. test: true,
  130. versionId,
  131. uploadAppConfig: {},
  132. submitKey: config.submitKey
  133. }
  134. });
  135. postConfig.lastVersion = versionId;
  136. postConfig.lastUpdateInfo = lastUseGit ? 'git': updateInfo;
  137. postConfig.lastTodaySubVersion++;
  138. const updateSource = (await select({
  139. choices: [
  140. {
  141. name: '重新构建',
  142. value: 'rebuild',
  143. },
  144. {
  145. name: '已上传的阿里OSS文件路径',
  146. value: 'uploaded-alioss',
  147. },
  148. ],
  149. message: '选择上传来源',
  150. default: 'rebuild',
  151. }));
  152. const force = await confirm({ message: '强制更新?', default: false });
  153. const updateNext = await confirm({ message: '作为下个版本?', default: false });
  154. const versionCode = await config.buildAppGetVersion(versionName);
  155. await saveConfig(postConfig);
  156. if (updateSource === 'rebuild')
  157. await config.buildAppCallback(param, versionCode, versionName, postConfig.lastTodaySubVersion);
  158. else if (updateSource === 'uploaded-alioss') {
  159. const fileName = await input({ message: '输入已上传的阿里OSS文件路径' });
  160. try {
  161. const result = (await axiosInstance.post('/update/update-post', {
  162. type: 2,
  163. ossConfig: {
  164. ossPath: fileName,
  165. ossPublic: '',
  166. },
  167. uploadAppConfig: {
  168. updateAsNext: updateNext,
  169. },
  170. versionId,
  171. updateInfo,
  172. versionCode: versionCode
  173. })).data;
  174. consola.success('上传成功');
  175. consola.info('新更新ID: ' + result.updateId);
  176. } catch (e) {
  177. logError('上传失败', e);
  178. }
  179. return;
  180. }
  181. else {
  182. logError('错误的选择');
  183. return;
  184. }
  185. const uploadFile = await config.buildAppGetUploadFile(param, versionCode, versionName);
  186. const fileName = `${await config.buildAppGetOSSFileName(param, versionCode, path.basename(uploadFile), uploadFile)}`;
  187. try {
  188. await access(uploadFile, constants.R_OK)
  189. } catch {
  190. logError(`Failed to access ${uploadFile}, did you created it?`);
  191. return;
  192. }
  193. consola.start('开始上传');
  194. //小于8mb则小文件上传,否则使用阿里OSS上传
  195. const fileInfo = await stat(uploadFile);
  196. if (fileInfo.size < 8 * 1024 * 1024) {
  197. const appData = (await readFile(uploadFile));
  198. const formData = new FormData();
  199. formData.append("file", new Blob([ appData ], { type : 'application/zip' }));
  200. formData.append("config", JSON.stringify({
  201. type: 2,
  202. versionId,
  203. versionCode,
  204. updateInfo,
  205. updateCustomConfig: await config.getCustomConfig(param, true, versionName),
  206. uploadAppConfig: { updateAsNext: updateNext },
  207. force,
  208. fileName,
  209. }));
  210. try {
  211. const result = (await axiosInstance.post('/update/update-post', formData, {
  212. headers: { 'Content-Type': 'multipart/form-data' }
  213. })).data;
  214. consola.success('上传成功');
  215. consola.info('新更新ID: ' + result.updateId);
  216. } catch (e) {
  217. logError('上传失败', e);
  218. }
  219. } else {
  220. let ossConfig;
  221. let multuploadedKey;
  222. if (serverConfig.uploadAppType === UPLOAD_APP_TYPE_LOCAL) {
  223. //本地大文件上传
  224. multuploadedKey = await uploadMulitPartLarge(axiosInstance, fileInfo, uploadFile);
  225. } else if (serverConfig.uploadAppType === UPLOAD_APP_TYPE_ALI_OSS) {
  226. //阿里OSS上传
  227. //请求STS进行临时授权
  228. const stsToken = (await axiosInstance.post('/update/update-ali-oss-sts')).data;
  229. const client = new OSS({
  230. region: stsToken.Region,
  231. accessKeyId: stsToken.AccessKeyId,
  232. accessKeySecret: stsToken.AccessKeySecret,
  233. stsToken: stsToken.SecurityToken,
  234. bucket: stsToken.Bucket,
  235. refreshSTSToken: async () => {
  236. const refreshToken = (await axiosInstance.get("/update/update-ali-oss-sts")).data;
  237. return {
  238. accessKeyId: refreshToken.AccessKeyId,
  239. accessKeySecret: refreshToken.AccessKeySecret,
  240. stsToken: refreshToken.SecurityToken,
  241. };
  242. },
  243. });
  244. //小于96mb则直接上传,否则分片上传
  245. consola.start('Start upload to ali oss');
  246. let returnUrl = '';
  247. if (fileInfo.size < 96 * 1024 * 1024) {
  248. const result = await client.put(fileName, uploadFile);
  249. returnUrl = result.url;
  250. } else {
  251. const bar1 = new cliProgress.SingleBar({}, cliProgress.Presets.shades_classic);
  252. bar1.start(100, 0);
  253. returnUrl = await aliOSSMultipartUpload(client, fileName, uploadFile, (p) => {
  254. bar1.update(p * 100);
  255. });
  256. bar1.update(100);
  257. bar1.stop();
  258. }
  259. ossConfig = {
  260. ossPath: fileName,
  261. ossPublic: returnUrl,
  262. };
  263. consola.success('Upload to ali oss done');
  264. } else {
  265. logError('错误的 uploadAppType 配置 ' + serverConfig.uploadAppType);
  266. }
  267. try {
  268. const result = (await axiosInstance.post('/update/update-post', {
  269. config: {
  270. type: 2,
  271. ossConfig,
  272. multuploadedKey,
  273. uploadAppConfig: {
  274. updateAsNext: updateNext,
  275. },
  276. updateCustomConfig: await config.getCustomConfig(param, true, versionName),
  277. versionId,
  278. updateInfo,
  279. versionCode: versionCode,
  280. fileName
  281. },
  282. })).data;
  283. consola.success('上传成功');
  284. consola.info('新更新ID: ' + result.updateId);
  285. } catch (e) {
  286. logError('上传失败', e);
  287. }
  288. }
  289. }
  290. //Web更新与提交
  291. //========================================
  292. export async function postWebUpdate(axiosInstance, param) {
  293. const skipBuild = param.skip
  294. const noDelete = param.ndelete;
  295. const postConfig = await getConfig();
  296. const { versionId, versionName } = await selectVersion(postConfig.lastVersion);
  297. const { updateInfo, lastUseGit } = await getUpdateInfo(postConfig);
  298. postConfig.lastVersion = versionId;
  299. postConfig.lastUpdateInfo = lastUseGit ? 'git': updateInfo;
  300. postConfig.lastTodaySubVersion++;
  301. await axiosInstance.post('/update/update-post', { config: { type: 1, test: true, versionId, uploadWebConfig: config.uploadWebConfig, submitKey: config.submitKey } });
  302. const now = new Date();
  303. const versionCode = await config.buildWebVersionGenerateCommand(now, postConfig.lastTodaySubVersion);
  304. if (config.buildWebOutVersionPath)
  305. await writeFile(path.resolve(__dirname, config.buildWebOutVersionPath), versionCode);
  306. await saveConfig(postConfig);
  307. if (!skipBuild && config.buildWebCommand) {
  308. consola.start('正在执行构建...');
  309. await execAsync(config.buildWebCommand);
  310. consola.success('构建完成');
  311. }
  312. const distDir = path.resolve(__dirname, config.buildWebOutDir);
  313. try {
  314. await access(distDir, constants.R_OK)
  315. } catch {
  316. logError(`Failed to access ${distDir}`);
  317. return;
  318. }
  319. const outputPath = __dirname + '/upload.zip';
  320. const skipFiles = config?.buildWebOptions?.skipFiles ?? [];
  321. if (!skipBuild) {
  322. consola.start('开始压缩zip...');
  323. await compressZip(distDir, outputPath, skipFiles);
  324. }
  325. consola.start('开始上传zip');
  326. let success = false;
  327. //小于8mb则小文件上传,否则分片上传
  328. const fileInfo = await stat(outputPath);
  329. const formData = new FormData();
  330. const submitConfig = {
  331. type: 1,
  332. versionId,
  333. updateInfo,
  334. versionCode,
  335. uploadWebConfig: config.uploadWebConfig,
  336. updateCustomConfig: await config.getCustomConfig(param, true, versionName),
  337. }
  338. if (fileInfo.size < 8 * 1024 * 1024) {
  339. const uploadZipContent = await readFile(outputPath);
  340. formData.append("file", new Blob([ uploadZipContent ], { type : 'application/zip' }), 'upload.zip');
  341. } else {
  342. submitConfig.multuploadedKey = await uploadMulitPartLarge(axiosInstance, fileInfo, outputPath);
  343. }
  344. try {
  345. formData.append("config", JSON.stringify(submitConfig));
  346. const result = (await axiosInstance.post('/update/update-post', formData, {
  347. headers: { 'Content-Type': 'multipart/form-data' }
  348. })).data;
  349. consola.success('上传成功');
  350. consola.info('新更新ID: ' + result.updateId);
  351. success = true;
  352. } catch (e) {
  353. logError('上传失败', e);
  354. }
  355. if (!success || noDelete)
  356. return;
  357. consola.info('删除zip');
  358. await unlink(outputPath);
  359. }