SysUpdateService.cs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. // Admin.NET 项目的版权、商标、专利和其他相关权利均受相应法律法规的保护。使用本项目应遵守相关法律法规和许可证的要求。
  2. //
  3. // 本项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。许可证位于源代码树根目录中的 LICENSE-MIT 和 LICENSE-APACHE 文件。
  4. //
  5. // 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
  6. using System.IO.Compression;
  7. namespace Admin.NET.Core.Service;
  8. /// <summary>
  9. /// 系统更新管理服务 🧩
  10. /// </summary>
  11. [ApiDescriptionSettings(Order = 390)]
  12. public class SysUpdateService : IDynamicApiController, ITransient
  13. {
  14. private readonly SysCacheService _sysCacheService;
  15. private readonly CDConfigOptions _cdConfigOptions;
  16. public SysUpdateService(IOptions<CDConfigOptions> giteeOptions, SysCacheService sysCacheService)
  17. {
  18. _cdConfigOptions = giteeOptions.Value;
  19. _sysCacheService = sysCacheService;
  20. }
  21. /// <summary>
  22. /// 备份列表
  23. /// </summary>
  24. /// <returns></returns>
  25. [DisplayName("备份列表")]
  26. [ApiDescriptionSettings(Name = "List"), HttpPost]
  27. public Task<List<BackupOutput>> List()
  28. {
  29. const string backendDir = "Admin.NET";
  30. var rootPath = Path.GetFullPath(Path.Combine(_cdConfigOptions.BackendOutput, ".."));
  31. return Task.FromResult(Directory.GetFiles(rootPath, backendDir + "*.zip", SearchOption.TopDirectoryOnly)
  32. .Select(filePath =>
  33. {
  34. var file = new FileInfo(filePath);
  35. return new BackupOutput
  36. {
  37. CreateTime = file.CreationTime,
  38. FilePath = filePath,
  39. FileName = file.Name
  40. };
  41. })
  42. .OrderByDescending(u => u.CreateTime)
  43. .ToList());
  44. }
  45. /// <summary>
  46. /// 还原
  47. /// </summary>
  48. /// <returns></returns>
  49. [DisplayName("还原")]
  50. [ApiDescriptionSettings(Name = "Restore"), HttpPost]
  51. public async Task Restore(RestoreInput input)
  52. {
  53. // 检查参数
  54. CheckConfig();
  55. try
  56. {
  57. var file = (await List()).FirstOrDefault(u => u.FileName.EqualIgnoreCase(input.FileName));
  58. if (file == null)
  59. {
  60. PrintfLog("文件不存在...");
  61. return;
  62. }
  63. PrintfLog("正在还原...");
  64. using ZipArchive archive = new(File.OpenRead(file.FilePath), ZipArchiveMode.Read, leaveOpen: false);
  65. archive.ExtractToDirectory(_cdConfigOptions.BackendOutput, true);
  66. PrintfLog("还原成功...");
  67. }
  68. catch (Exception ex)
  69. {
  70. PrintfLog("发生异常:" + ex.Message);
  71. throw;
  72. }
  73. }
  74. /// <summary>
  75. /// 从远端更新系统
  76. /// </summary>
  77. /// <returns></returns>
  78. [DisplayName("系统更新")]
  79. [ApiDescriptionSettings(Name = "Update"), HttpPost]
  80. public async Task Update()
  81. {
  82. var originColor = Console.ForegroundColor;
  83. Console.ForegroundColor = ConsoleColor.Yellow;
  84. Console.WriteLine($"【{DateTime.Now}】从远端仓库部署项目");
  85. try
  86. {
  87. PrintfLog("----------------------------从远端仓库部署项目-开始----------------------------");
  88. // 检查参数
  89. CheckConfig();
  90. // 检查操作间隔
  91. if (_cdConfigOptions.UpdateInterval > 0)
  92. {
  93. if (_sysCacheService.Get<bool>(CacheConst.KeySysUpdateInterval)) throw Oops.Oh("请勿频繁操作");
  94. _sysCacheService.Set(CacheConst.KeySysUpdateInterval, true, TimeSpan.FromMinutes(_cdConfigOptions.UpdateInterval));
  95. }
  96. PrintfLog($"客户端host:{App.HttpContext.Request.Host}");
  97. PrintfLog($"客户端IP:{App.HttpContext.GetRemoteIpAddressToIPv4(true)}");
  98. PrintfLog($"仓库地址:https://gitee.com/{_cdConfigOptions.Owner}/{_cdConfigOptions.Repo}.git");
  99. PrintfLog($"仓库分支:{_cdConfigOptions.Branch}");
  100. // 获取解压后的根目录
  101. var rootPath = Path.GetFullPath(Path.Combine(_cdConfigOptions.BackendOutput, ".."));
  102. var tempDir = Path.Combine(rootPath, $"{_cdConfigOptions.Repo}-{_cdConfigOptions.Branch}");
  103. PrintfLog("清理旧文件...");
  104. FileHelper.TryDelete(tempDir);
  105. PrintfLog("拉取远端代码...");
  106. var stream = await GiteeHelper.DownloadRepoZip(_cdConfigOptions.Owner, _cdConfigOptions.Repo,
  107. _cdConfigOptions.AccessToken, _cdConfigOptions.Branch);
  108. PrintfLog("文件包解压...");
  109. using ZipArchive archive = new(stream, ZipArchiveMode.Read, leaveOpen: false);
  110. archive.ExtractToDirectory(rootPath);
  111. // 项目目录
  112. var backendDir = "Admin.NET"; // 后端根目录
  113. var entryProjectName = "Admin.NET.Web.Entry"; // 启动项目目录
  114. var tempOutput = Path.Combine(rootPath, $"{_cdConfigOptions.Repo}_temp");
  115. PrintfLog("编译项目...");
  116. PrintfLog($"发布版本:{_cdConfigOptions.Publish.Configuration}");
  117. PrintfLog($"目标框架:{_cdConfigOptions.Publish.TargetFramework}");
  118. PrintfLog($"运行环境:{_cdConfigOptions.Publish.RuntimeIdentifier}");
  119. var option = _cdConfigOptions.Publish;
  120. var adminNetDir = Path.Combine(tempDir, backendDir);
  121. var args = $"publish \"{entryProjectName}\" -c {option.Configuration} -f {option.TargetFramework} -r {option.RuntimeIdentifier} --output \"{tempOutput}\"";
  122. await RunCommandAsync("dotnet", args, adminNetDir);
  123. PrintfLog("复制 wwwroot 目录...");
  124. var wwwrootDir = Path.Combine(adminNetDir, entryProjectName, "wwwroot");
  125. FileHelper.CopyDirectory(wwwrootDir, Path.Combine(tempOutput, "wwwroot"), true);
  126. // 删除排除文件
  127. foreach (var filePath in (_cdConfigOptions.ExcludeFiles ?? new()).SelectMany(file => Directory.GetFiles(tempOutput, file, SearchOption.TopDirectoryOnly)))
  128. {
  129. PrintfLog($"排除文件:{filePath}");
  130. FileHelper.TryDelete(filePath);
  131. }
  132. PrintfLog("备份原项目文件...");
  133. string backupPath = Path.Combine(rootPath, $"{_cdConfigOptions.Repo}_{DateTime.Now:yyyy_MM_dd}.zip") ;
  134. if (File.Exists(backupPath)) File.Delete(backupPath);
  135. ZipFile.CreateFromDirectory(_cdConfigOptions.BackendOutput, backupPath);
  136. // 将临时文件移动到正式目录
  137. FileHelper.CopyDirectory(tempOutput, _cdConfigOptions.BackendOutput, true);
  138. PrintfLog("清理文件...");
  139. FileHelper.TryDelete(tempOutput);
  140. FileHelper.TryDelete(tempDir);
  141. if (_cdConfigOptions.BackupCount > 0)
  142. {
  143. var fileList = await List();
  144. if (fileList.Count > _cdConfigOptions.BackupCount)
  145. PrintfLog("清除多余的备份文件...");
  146. while (fileList.Count > _cdConfigOptions.BackupCount)
  147. {
  148. var last = fileList.Last();
  149. FileHelper.TryDelete(last.FilePath);
  150. fileList.Remove(last);
  151. }
  152. }
  153. PrintfLog("重启项目后生效...");
  154. }
  155. catch (Exception ex)
  156. {
  157. PrintfLog("发生异常:" + ex.Message);
  158. throw;
  159. }
  160. finally
  161. {
  162. PrintfLog("----------------------------从远端仓库部署项目-结束----------------------------");
  163. Console.ForegroundColor = originColor;
  164. }
  165. }
  166. /// <summary>
  167. /// 仓库WebHook接口
  168. /// </summary>
  169. /// <returns></returns>
  170. [AllowAnonymous]
  171. [DisplayName("仓库WebHook接口")]
  172. [ApiDescriptionSettings(Name = "WebHook"), HttpPost]
  173. public async Task WebHook(WebHookInput input)
  174. {
  175. if (!_cdConfigOptions.Enabled) throw Oops.Oh("未启用持续部署功能");
  176. if (CryptogramUtil.Decrypt(input.Key) != GetWebHookKeyPlainText()) throw Oops.Oh("非法密钥");
  177. var updateInterval = _cdConfigOptions.UpdateInterval;
  178. try
  179. {
  180. _cdConfigOptions.UpdateInterval = 0;
  181. await Update();
  182. }
  183. finally
  184. {
  185. _cdConfigOptions.UpdateInterval = updateInterval;
  186. }
  187. }
  188. /// <summary>
  189. /// 获取WebHook接口密钥
  190. /// </summary>
  191. /// <returns></returns>
  192. [DisplayName("获取WebHook接口密钥")]
  193. [ApiDescriptionSettings(Name = "WebHookKey"), HttpGet]
  194. public string GetWebHookKey()
  195. {
  196. return CryptogramUtil.Encrypt(GetWebHookKeyPlainText());
  197. }
  198. /// <summary>
  199. /// 获取日志列表
  200. /// </summary>
  201. /// <returns></returns>
  202. [DisplayName("获取日志列表")]
  203. [ApiDescriptionSettings(Name = "Logs"), HttpGet]
  204. public List<string> LogList()
  205. {
  206. return _sysCacheService.Get<List<string>>(CacheConst.KeySysUpdateLog) ?? new();
  207. }
  208. /// <summary>
  209. /// 清空日志
  210. /// </summary>
  211. /// <returns></returns>
  212. [DisplayName("清空日志")]
  213. [ApiDescriptionSettings(Name = "Clear"), HttpGet]
  214. public void ClearLog()
  215. {
  216. _sysCacheService.Remove(CacheConst.KeySysUpdateLog);
  217. }
  218. /// <summary>
  219. /// 获取密钥明文
  220. /// </summary>
  221. /// <returns></returns>
  222. private string GetWebHookKeyPlainText()
  223. {
  224. return $"https://gitee.com/{_cdConfigOptions.Owner}/{_cdConfigOptions.Repo}.git-{_cdConfigOptions.Branch}-{_cdConfigOptions.AccessToken}";
  225. }
  226. /// <summary>
  227. /// 检查参数
  228. /// </summary>
  229. /// <returns></returns>
  230. private void CheckConfig()
  231. {
  232. PrintfLog("检查CD配置参数...");
  233. if (_cdConfigOptions == null) throw Oops.Oh("CDConfig配置不能为空");
  234. if (string.IsNullOrWhiteSpace(_cdConfigOptions.Owner)) throw Oops.Oh("仓库用户名不能为空");
  235. if (string.IsNullOrWhiteSpace(_cdConfigOptions.Repo)) throw Oops.Oh("仓库名不能为空");
  236. // if (string.IsNullOrWhiteSpace(_cdConfigOptions.Branch)) throw Oops.Oh("分支名不能为空");
  237. if (string.IsNullOrWhiteSpace(_cdConfigOptions.AccessToken)) throw Oops.Oh("授权信息不能为空");
  238. if (string.IsNullOrWhiteSpace(_cdConfigOptions.BackendOutput)) throw Oops.Oh("部署目录不能为空");
  239. if (_cdConfigOptions.Publish == null) throw Oops.Oh("编译配置不能为空");
  240. if (string.IsNullOrWhiteSpace(_cdConfigOptions.Publish.Configuration)) throw Oops.Oh("运行环境编译配置不能为空");
  241. if (string.IsNullOrWhiteSpace(_cdConfigOptions.Publish.TargetFramework)) throw Oops.Oh(".NET版本编译配置不能为空");
  242. if (string.IsNullOrWhiteSpace(_cdConfigOptions.Publish.RuntimeIdentifier)) throw Oops.Oh("运行平台配置不能为空");
  243. }
  244. /// <summary>
  245. /// 打印日志
  246. /// </summary>
  247. /// <param name="message"></param>
  248. private void PrintfLog(string message)
  249. {
  250. var logList = _sysCacheService.Get<List<string>>(CacheConst.KeySysUpdateLog) ?? new();
  251. var content = $"【{DateTime.Now}】 {message}";
  252. Console.WriteLine(content);
  253. logList.Add(content);
  254. _sysCacheService.Set(CacheConst.KeySysUpdateLog, logList);
  255. }
  256. /// <summary>
  257. /// 执行命令
  258. /// </summary>
  259. /// <param name="command">命令</param>
  260. /// <param name="arguments">参数</param>
  261. /// <param name="workingDirectory">工作目录</param>
  262. private async Task RunCommandAsync(string command, string arguments, string workingDirectory)
  263. {
  264. var processStartInfo = new ProcessStartInfo
  265. {
  266. FileName = command,
  267. Arguments = arguments,
  268. WorkingDirectory = workingDirectory,
  269. RedirectStandardOutput = true,
  270. RedirectStandardError = true,
  271. StandardOutputEncoding = Encoding.UTF8,
  272. StandardErrorEncoding = Encoding.UTF8,
  273. UseShellExecute = false,
  274. CreateNoWindow = true
  275. };
  276. using var process = new Process();
  277. process.StartInfo = processStartInfo;
  278. process.Start();
  279. while (!process.StandardOutput.EndOfStream)
  280. {
  281. string line = await process.StandardOutput.ReadLineAsync();
  282. if (string.IsNullOrEmpty(line)) continue;
  283. PrintfLog(line.Trim());
  284. }
  285. await process.WaitForExitAsync();
  286. }
  287. }