postUpdate.mjs 13 KB

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