SysUpdateService.cs 17 KB

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