// Admin.NET 项目的版权、商标、专利和其他相关权利均受相应法律法规的保护。使用本项目应遵守相关法律法规和许可证的要求。
//
// 本项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。许可证位于源代码树根目录中的 LICENSE-MIT 和 LICENSE-APACHE 文件。
//
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
using System.IO.Compression;
using System.Net;
using System.Security.Cryptography;
namespace Admin.NET.Core.Service;
///
/// 系统更新管理服务 🧩
///
[ApiDescriptionSettings(Order = 390)]
public class SysUpdateService : IDynamicApiController, ITransient
{
private readonly SysCacheService _sysCacheService;
private readonly CDConfigOptions _cdConfigOptions;
public SysUpdateService(IOptions giteeOptions, SysCacheService sysCacheService)
{
_cdConfigOptions = giteeOptions.Value;
_sysCacheService = sysCacheService;
}
///
/// 备份列表
///
///
[DisplayName("备份列表")]
[ApiDescriptionSettings(Name = "List"), HttpPost]
public Task> List()
{
const string backendDir = "Admin.NET";
var rootPath = Path.GetFullPath(Path.Combine(_cdConfigOptions.BackendOutput, ".."));
return Task.FromResult(Directory.GetFiles(rootPath, backendDir + "*.zip", SearchOption.TopDirectoryOnly)
.Select(filePath =>
{
var file = new FileInfo(filePath);
return new BackupOutput
{
CreateTime = file.CreationTime,
FilePath = filePath,
FileName = file.Name
};
})
.OrderByDescending(u => u.CreateTime)
.ToList());
}
///
/// 还原
///
///
[DisplayName("还原")]
[ApiDescriptionSettings(Name = "Restore"), HttpPost]
public async Task Restore(RestoreInput input)
{
// 检查参数
CheckConfig();
try
{
var file = (await List()).FirstOrDefault(u => u.FileName.EqualIgnoreCase(input.FileName));
if (file == null)
{
PrintfLog("文件不存在...");
return;
}
PrintfLog("正在还原...");
using ZipArchive archive = new(File.OpenRead(file.FilePath), ZipArchiveMode.Read, leaveOpen: false);
archive.ExtractToDirectory(_cdConfigOptions.BackendOutput, true);
PrintfLog("还原成功...");
}
catch (Exception ex)
{
PrintfLog("发生异常:" + ex.Message);
throw;
}
}
///
/// 从远端更新系统
///
///
[DisplayName("系统更新")]
[ApiDescriptionSettings(Name = "Update"), HttpPost]
public async Task Update()
{
var originColor = Console.ForegroundColor;
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"【{DateTime.Now}】从远端仓库部署项目");
try
{
PrintfLog("----------------------------从远端仓库部署项目-开始----------------------------");
// 检查参数
CheckConfig();
// 检查操作间隔
if (_cdConfigOptions.UpdateInterval > 0)
{
if (_sysCacheService.Get(CacheConst.KeySysUpdateInterval)) throw Oops.Oh("请勿频繁操作");
_sysCacheService.Set(CacheConst.KeySysUpdateInterval, true, TimeSpan.FromMinutes(_cdConfigOptions.UpdateInterval));
}
PrintfLog($"客户端host:{App.HttpContext.Request.Host}");
PrintfLog($"客户端IP:{App.HttpContext.GetRemoteIpAddressToIPv4(true)}");
PrintfLog($"仓库地址:https://gitee.com/{_cdConfigOptions.Owner}/{_cdConfigOptions.Repo}.git");
PrintfLog($"仓库分支:{_cdConfigOptions.Branch}");
// 获取解压后的根目录
var rootPath = Path.GetFullPath(Path.Combine(_cdConfigOptions.BackendOutput, ".."));
var tempDir = Path.Combine(rootPath, $"{_cdConfigOptions.Repo}-{_cdConfigOptions.Branch}");
PrintfLog("清理旧文件...");
FileHelper.TryDelete(tempDir);
PrintfLog("拉取远端代码...");
var stream = await GiteeHelper.DownloadRepoZip(_cdConfigOptions.Owner, _cdConfigOptions.Repo,
_cdConfigOptions.AccessToken, _cdConfigOptions.Branch);
PrintfLog("文件包解压...");
using ZipArchive archive = new(stream, ZipArchiveMode.Read, leaveOpen: false);
archive.ExtractToDirectory(rootPath);
// 项目目录
var backendDir = "Admin.NET"; // 后端根目录
var entryProjectName = "Admin.NET.Web.Entry"; // 启动项目目录
var tempOutput = Path.Combine(rootPath, $"{_cdConfigOptions.Repo}_temp");
PrintfLog("编译项目...");
PrintfLog($"发布版本:{_cdConfigOptions.Publish.Configuration}");
PrintfLog($"目标框架:{_cdConfigOptions.Publish.TargetFramework}");
PrintfLog($"运行环境:{_cdConfigOptions.Publish.RuntimeIdentifier}");
var option = _cdConfigOptions.Publish;
var adminNetDir = Path.Combine(tempDir, backendDir);
var args = $"publish \"{entryProjectName}\" -c {option.Configuration} -f {option.TargetFramework} -r {option.RuntimeIdentifier} --output \"{tempOutput}\"";
await RunCommandAsync("dotnet", args, adminNetDir);
PrintfLog("复制 wwwroot 目录...");
var wwwrootDir = Path.Combine(adminNetDir, entryProjectName, "wwwroot");
FileHelper.CopyDirectory(wwwrootDir, Path.Combine(tempOutput, "wwwroot"), true);
// 删除排除文件
foreach (var filePath in (_cdConfigOptions.ExcludeFiles ?? new()).SelectMany(file => Directory.GetFiles(tempOutput, file, SearchOption.TopDirectoryOnly)))
{
PrintfLog($"排除文件:{filePath}");
FileHelper.TryDelete(filePath);
}
PrintfLog("备份原项目文件...");
string backupPath = Path.Combine(rootPath, $"{_cdConfigOptions.Repo}_{DateTime.Now:yyyy_MM_dd}.zip");
if (File.Exists(backupPath)) File.Delete(backupPath);
ZipFile.CreateFromDirectory(_cdConfigOptions.BackendOutput, backupPath);
// 将临时文件移动到正式目录
FileHelper.CopyDirectory(tempOutput, _cdConfigOptions.BackendOutput, true);
PrintfLog("清理文件...");
FileHelper.TryDelete(tempOutput);
FileHelper.TryDelete(tempDir);
if (_cdConfigOptions.BackupCount > 0)
{
var fileList = await List();
if (fileList.Count > _cdConfigOptions.BackupCount)
PrintfLog("清除多余的备份文件...");
while (fileList.Count > _cdConfigOptions.BackupCount)
{
var last = fileList.Last();
FileHelper.TryDelete(last.FilePath);
fileList.Remove(last);
}
}
PrintfLog("重启项目后生效...");
}
catch (Exception ex)
{
PrintfLog("发生异常:" + ex.Message);
throw;
}
finally
{
PrintfLog("----------------------------从远端仓库部署项目-结束----------------------------");
Console.ForegroundColor = originColor;
}
}
///
/// 仓库WebHook接口
///
///
[AllowAnonymous]
[DisplayName("仓库WebHook接口")]
[ApiDescriptionSettings(Name = "WebHook"), HttpPost]
public async Task WebHook(Dictionary input)
{
if (!_cdConfigOptions.Enabled) throw Oops.Oh("未启用持续部署功能");
PrintfLog("----------------------------收到WebHook请求-开始----------------------------");
try
{
// 获取请求头信息
var even = App.HttpContext.Request.Headers.FirstOrDefault(u => u.Key == "X-Gitee-Event").Value
.FirstOrDefault();
var ua = App.HttpContext.Request.Headers.FirstOrDefault(u => u.Key == "User-Agent").Value.FirstOrDefault();
var timestamp = input.GetValueOrDefault("timestamp")?.ToString();
var token = input.GetValueOrDefault("sign")?.ToString();
PrintfLog("User-Agent:" + ua);
PrintfLog("Gitee-Event:" + even);
PrintfLog("Gitee-Token:" + token);
PrintfLog("Gitee-Timestamp:" + timestamp);
PrintfLog("开始验签...");
var secret = GetWebHookKey();
var stringToSign = $"{timestamp}\n{secret}";
using var mac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var signData = mac.ComputeHash(Encoding.UTF8.GetBytes(stringToSign));
var encodedSignData = Convert.ToBase64String(signData);
var calculatedSignature = WebUtility.UrlEncode(encodedSignData);
if (calculatedSignature != token) throw Oops.Oh("非法签名");
PrintfLog("验签成功...");
var hookName = input.GetValueOrDefault("hook_name") as string;
PrintfLog("Hook-Name:" + hookName);
switch (hookName)
{
// 提交修改
case "push_hooks":
{
var commitList = input.GetValueOrDefault("commits")?.Adapt>>() ?? new();
foreach (var commit in commitList)
{
var author = commit.GetValueOrDefault("author")?.Adapt>();
PrintfLog("Commit-Message:" + commit.GetValueOrDefault("message"));
PrintfLog("Commit-Time:" + commit.GetValueOrDefault("timestamp"));
PrintfLog("Commit-Author:" + author?.GetValueOrDefault("username"));
PrintfLog("Modified-List:" + author?.GetValueOrDefault("modified")?.Adapt>().Join());
PrintfLog("----------------------------------------------------------");
}
break;
}
// 合并 Pull Request
case "merge_request_hooks":
{
var pull = input.GetValueOrDefault("pull_request")?.Adapt>();
var user = pull?.GetValueOrDefault("user")?.Adapt>();
PrintfLog("Pull-Request-Title:" + pull?.GetValueOrDefault("message"));
PrintfLog("Pull-Request-Time:" + pull?.GetValueOrDefault("created_at"));
PrintfLog("Pull-Request-Author:" + user?.GetValueOrDefault("username"));
PrintfLog("Pull-Request-Body:" + pull?.GetValueOrDefault("body"));
break;
}
// 新的issue
case "issue_hooks":
{
var issue = input.GetValueOrDefault("issue")?.Adapt>();
var user = issue?.GetValueOrDefault("user")?.Adapt>();
var labelList = issue?.GetValueOrDefault("labels")?.Adapt>>();
PrintfLog("Issue-UserName:" + user?.GetValueOrDefault("username"));
PrintfLog("Issue-Labels:" + labelList?.Select(u => u.GetValueOrDefault("name")).Join());
PrintfLog("Issue-Title:" + issue?.GetValueOrDefault("title"));
PrintfLog("Issue-Time:" + issue?.GetValueOrDefault("created_at"));
PrintfLog("Issue-Body:" + issue?.GetValueOrDefault("body"));
return;
}
// 评论
case "note_hooks":
{
var comment = input.GetValueOrDefault("comment")?.Adapt>();
var user = input.GetValueOrDefault("user")?.Adapt>();
PrintfLog("comment-UserName:" + user?.GetValueOrDefault("username"));
PrintfLog("comment-Time:" + comment?.GetValueOrDefault("created_at"));
PrintfLog("comment-Content:" + comment?.GetValueOrDefault("body"));
return;
}
default:
return;
}
var updateInterval = _cdConfigOptions.UpdateInterval;
try
{
_cdConfigOptions.UpdateInterval = 0;
await Update();
}
finally
{
_cdConfigOptions.UpdateInterval = updateInterval;
}
}
finally
{
PrintfLog("----------------------------收到WebHook请求-结束----------------------------");
}
}
///
/// 获取WebHook接口密钥
///
///
[DisplayName("获取WebHook接口密钥")]
[ApiDescriptionSettings(Name = "WebHookKey"), HttpGet]
public string GetWebHookKey()
{
return CryptogramUtil.Encrypt(_cdConfigOptions.AccessToken);
}
///
/// 获取日志列表
///
///
[DisplayName("获取日志列表")]
[ApiDescriptionSettings(Name = "Logs"), HttpGet]
public List LogList()
{
return _sysCacheService.Get>(CacheConst.KeySysUpdateLog) ?? new();
}
///
/// 清空日志
///
///
[DisplayName("清空日志")]
[ApiDescriptionSettings(Name = "Clear"), HttpGet]
public void ClearLog()
{
_sysCacheService.Remove(CacheConst.KeySysUpdateLog);
}
///
/// 检查参数
///
///
private void CheckConfig()
{
PrintfLog("检查CD配置参数...");
if (_cdConfigOptions == null) throw Oops.Oh("CDConfig配置不能为空");
if (string.IsNullOrWhiteSpace(_cdConfigOptions.Owner)) throw Oops.Oh("仓库用户名不能为空");
if (string.IsNullOrWhiteSpace(_cdConfigOptions.Repo)) throw Oops.Oh("仓库名不能为空");
// if (string.IsNullOrWhiteSpace(_cdConfigOptions.Branch)) throw Oops.Oh("分支名不能为空");
if (string.IsNullOrWhiteSpace(_cdConfigOptions.AccessToken)) throw Oops.Oh("授权信息不能为空");
if (string.IsNullOrWhiteSpace(_cdConfigOptions.BackendOutput)) throw Oops.Oh("部署目录不能为空");
if (_cdConfigOptions.Publish == null) throw Oops.Oh("编译配置不能为空");
if (string.IsNullOrWhiteSpace(_cdConfigOptions.Publish.Configuration)) throw Oops.Oh("运行环境编译配置不能为空");
if (string.IsNullOrWhiteSpace(_cdConfigOptions.Publish.TargetFramework)) throw Oops.Oh(".NET版本编译配置不能为空");
if (string.IsNullOrWhiteSpace(_cdConfigOptions.Publish.RuntimeIdentifier)) throw Oops.Oh("运行平台配置不能为空");
}
///
/// 打印日志
///
///
private void PrintfLog(string message)
{
var logList = _sysCacheService.Get>(CacheConst.KeySysUpdateLog) ?? new();
var content = $"【{DateTime.Now}】 {message}";
Console.WriteLine(content);
logList.Add(content);
_sysCacheService.Set(CacheConst.KeySysUpdateLog, logList);
}
///
/// 执行命令
///
/// 命令
/// 参数
/// 工作目录
private async Task RunCommandAsync(string command, string arguments, string workingDirectory)
{
var processStartInfo = new ProcessStartInfo
{
FileName = command,
Arguments = arguments,
WorkingDirectory = workingDirectory,
RedirectStandardOutput = true,
RedirectStandardError = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = new Process();
process.StartInfo = processStartInfo;
process.Start();
while (!process.StandardOutput.EndOfStream)
{
string line = await process.StandardOutput.ReadLineAsync();
if (string.IsNullOrEmpty(line)) continue;
PrintfLog(line.Trim());
}
await process.WaitForExitAsync();
}
}