diff --git a/AntJob.Agent/AntJob.Agent.csproj b/AntJob.Agent/AntJob.Agent.csproj index ac5d7d7..7b0f4ca 100644 --- a/AntJob.Agent/AntJob.Agent.csproj +++ b/AntJob.Agent/AntJob.Agent.csproj @@ -7,7 +7,7 @@ 调度中心下发C#或Sql给蚂蚁代理执行 新生命开发团队 版权所有(C) 新生命开发团队 2022 - 3.3 + 3.4 $([System.DateTime]::Now.ToString(`yyyy.MMdd`)) $(VersionPrefix).$(VersionSuffix) $(Version) diff --git a/AntJob.Data/AntJob.Data.csproj b/AntJob.Data/AntJob.Data.csproj index b74c561..eab036f 100644 --- a/AntJob.Data/AntJob.Data.csproj +++ b/AntJob.Data/AntJob.Data.csproj @@ -4,8 +4,8 @@ 蚂蚁数据 蚂蚁调度系统数据库结构 新生命开发团队 - 版权所有(C) 新生命开发团队 2020 - 2.0 + 版权所有(C) 新生命开发团队 2024 + 3.4 $([System.DateTime]::Now.ToString(`yyyy.MMdd`)) $(VersionPrefix).$(VersionSuffix) $(Version) diff --git a/AntJob.Extensions/AntJob.Extensions.csproj b/AntJob.Extensions/AntJob.Extensions.csproj index a0e7392..4bcf78e 100644 --- a/AntJob.Extensions/AntJob.Extensions.csproj +++ b/AntJob.Extensions/AntJob.Extensions.csproj @@ -5,8 +5,8 @@ 蚂蚁数据调度SDK 分布式任务调度系统,纯NET打造的重量级大数据实时计算平台,万亿级调度经验积累。 新生命开发团队 - ©2002-2023 NewLife - 3.3 + ©2002-2024 NewLife + 3.4 $([System.DateTime]::Now.ToString(`yyyy.MMdd`)) $(VersionPrefix).$(VersionSuffix) $(Version) diff --git a/AntJob.Server/AntJob.Server.csproj b/AntJob.Server/AntJob.Server.csproj index 475576b..6da9ada 100644 --- a/AntJob.Server/AntJob.Server.csproj +++ b/AntJob.Server/AntJob.Server.csproj @@ -7,7 +7,7 @@ 分布式任务调度系统,纯NET打造的重量级大数据实时计算平台,万亿级调度经验积累 新生命开发团队 版权所有(C) 新生命开发团队 2023 - 3.3 + 3.4 $([System.DateTime]::Now.ToString(`yyyy.MMdd`)) $(VersionPrefix).$(VersionSuffix) $(Version) diff --git a/AntJob.Server/AntService.cs b/AntJob.Server/AntService.cs index 5bba367..5db006a 100644 --- a/AntJob.Server/AntService.cs +++ b/AntJob.Server/AntService.cs @@ -58,7 +58,8 @@ class AntService : IApi, IActionFilter { _App = app; - var online = GetOnline(app, _Net); + var ip = _Net.Remote.Host; + var online = _appService.GetOnline(app, ip); online.UpdateTime = TimerX.Now; online.SaveAsync(); } @@ -81,7 +82,9 @@ class AntService : IApi, IActionFilter else XTrace.WriteException(ex); - WriteHistory(null, filterContext.ActionName, false, ex.GetMessage()); + _Net = Session as INetSession; + var ip = _Net.Remote.Host; + _appService.WriteHistory(_App, filterContext.ActionName, false, ex.GetMessage(), ip); } } } @@ -136,39 +139,8 @@ class AntService : IApi, IActionFilter var job = model.Job?.Trim(); if (job.IsNullOrEmpty()) return new TaskModel[0]; - return _jobService.Acquire(_App, model); - } - - private void CheckErrorTask(App app, Job jb, Int32 count, List list) - { - // 每分钟检查一下错误任务和中断任务 - var nextKey = $"_NextAcquireOld_{jb.ID}"; - var now = TimerX.Now; - var ext = Session as IExtend; - var next = (DateTime)(ext[nextKey] ?? DateTime.MinValue); - if (next < now) - { - //var ps = ControllerContext.Current.Parameters; - //var server = ps["server"] + ""; - //var pid = ps["pid"].ToInt(); - var online = GetOnline(app, _Net); - var ip = _Net.Remote.Host; - - next = now.AddSeconds(60); - list.AddRange(jb.AcquireOld(online.Server, ip, online.ProcessId, count, _cacheProvider.Cache)); - - if (list.Count > 0) - { - // 既然有数据,待会还来 - next = now; - - var n1 = list.Count(e => e.Status == JobStatus.错误 || e.Status == JobStatus.取消); - var n2 = list.Count(e => e.Status == JobStatus.就绪 || e.Status == JobStatus.抽取中 || e.Status == JobStatus.处理中); - _log.Info("作业[{0}/{1}]准备处理[{2}]个错误和[{3}]超时任务 [{4}]", app, jb.Name, n1, n2, list.Join(",", e => e.ID + "")); - } - else - ext[nextKey] = next; - } + var ip = _Net.Remote.Host; + return _jobService.Acquire(_App, model, ip); } /// 生产消息 @@ -191,7 +163,8 @@ class AntService : IApi, IActionFilter { if (task == null || task.ID == 0) throw new InvalidOperationException("无效操作 TaskID=" + task?.ID); - return _jobService.Report(_App, task); + var ip = _Net.Remote.Host; + return _jobService.Report(_App, task, ip); } #endregion } \ No newline at end of file diff --git a/AntJob.Server/Services/AppService.cs b/AntJob.Server/Services/AppService.cs index c806860..f04e4b5 100644 --- a/AntJob.Server/Services/AppService.cs +++ b/AntJob.Server/Services/AppService.cs @@ -9,6 +9,11 @@ using NewLife.Log; using NewLife.Net; using NewLife.Security; using AntJob.Models; +using NewLife.Data; +using NewLife.Remoting; +using NewLife.Web; +using System.Reflection; +using System.Xml.Linq; namespace AntJob.Server.Services; @@ -64,14 +69,11 @@ public class AppService app.Save(); // 应用上线 - var online = CreateOnline(app, _Net, model.Machine, model.ProcessId); + var online = CreateOnline(app, ip, model.Machine, model.ProcessId); online.Version = model.Version; online.CompileTime = model.Compile; online.Save(); - //// 记录当前用户 - //Session["App"] = app; - WriteHistory(app, autoReg ? "注册" : "登录", true, $"[{model.User}/{model.Pass}]在[{model.Machine}@{model.ProcessId}]登录[{app}]成功"); var rs = new LoginResponse { Name = app.Name, DisplayName = app.DisplayName }; @@ -133,49 +135,34 @@ public class AppService return olts.Select(e => e.ToModel()).ToArray(); } - AppOnline CreateOnline(App app, INetSession ns, String machine, Int32 pid) + AppOnline CreateOnline(App app, String ip, String machine, Int32 pid) { - var ip = ns.Remote.Host; - - var online = GetOnline(app, ns); + var online = GetOnline(app, ip); online.Client = $"{(ip.IsNullOrEmpty() ? machine : ip)}@{pid}"; online.Name = machine; online.ProcessId = pid; online.UpdateIP = ip; //online.Version = version; - online.Server = Local + ""; + online.Server = Environment.MachineName; //online.Save(); - // 真正的用户 - Session["AppOnline"] = online; - - // 下线 - ns.OnDisposed += (s, e) => - { - online.Delete(); - WriteHistory(online.App, "下线", true, $"[{online.Name}]登录于{online.CreateTime},最后活跃于{online.UpdateTime}"); - }; - return online; } - public AppOnline GetOnline(App app, INetSession ns) + public AppOnline GetOnline(App app, String ip) { - if (Session["AppOnline"] is AppOnline online) return online; - - var ip = ns.Remote.Host; - var ins = ns.Remote.EndPoint + ""; - online = AppOnline.FindByInstance(ins) ?? new AppOnline { CreateIP = ip }; + var ins = $"{app.Name}@{ip}"; + var online = AppOnline.FindByInstance(ins) ?? new AppOnline { CreateIP = ip }; online.AppID = app.ID; online.Instance = ins; return online; } - public void UpdateOnline(App app, JobTask ji, INetSession ns) + public void UpdateOnline(App app, JobTask ji, String ip) { - var online = GetOnline(app, ns); + var online = GetOnline(app, ip); online.Total += ji.Total; online.Success += ji.Success; online.Error += ji.Error; @@ -187,6 +174,77 @@ public class AppService #endregion #region 写历史 - public void WriteHistory(App app, String action, Boolean success, String remark) => AppHistory.Create(app, action, success, remark, Local + "", _Net.Remote?.Host); + public void WriteHistory(App app, String action, Boolean success, String remark, String ip = null) => + AppHistory.Create(app, action, success, remark, Environment.MachineName, ip); + #endregion + + #region 辅助 + public TokenModel IssueToken(String name, AntJobSetting set) + { + // 颁发令牌 + var ss = set.TokenSecret.Split(':'); + var jwt = new JwtBuilder + { + Issuer = Assembly.GetEntryAssembly().GetName().Name, + Subject = name, + Id = Rand.NextString(8), + Expire = DateTime.Now.AddSeconds(set.TokenExpire), + + Algorithm = ss[0], + Secret = ss[1], + }; + + return new TokenModel + { + AccessToken = jwt.Encode(null), + TokenType = jwt.Type ?? "JWT", + ExpireIn = set.TokenExpire, + RefreshToken = jwt.Encode(null), + }; + } + + public (App, Exception) DecodeToken(String token, String tokenSecret) + { + if (token.IsNullOrEmpty()) throw new ArgumentNullException(nameof(token)); + //if (token.IsNullOrEmpty()) throw new ApiException(401, $"节点未登录[ip={UserHost}]"); + + // 解码令牌 + var ss = tokenSecret.Split(':'); + var jwt = new JwtBuilder + { + Algorithm = ss[0], + Secret = ss[1], + }; + + var rs = jwt.TryDecode(token, out var message); + var app = App.FindByName(jwt.Subject); + + Exception ex = null; + if (!rs || app == null) + { + if (app != null) + ex = new ApiException(403, $"[{app.Name}/{app.DisplayName}]非法访问 {message}"); + else + ex = new ApiException(403, $"[{jwt.Subject}]非法访问 {message}"); + } + + return (app, ex); + } + + public TokenModel ValidAndIssueToken(String deviceCode, String token, AntJobSetting set) + { + if (token.IsNullOrEmpty()) return null; + //var set = Setting.Current; + + // 令牌有效期检查,10分钟内过期者,重新颁发令牌 + var ss = set.TokenSecret.Split(':'); + var jwt = new JwtBuilder + { + Algorithm = ss[0], + Secret = ss[1], + }; + var rs = jwt.TryDecode(token, out var message); + return !rs || jwt == null ? null : DateTime.Now.AddMinutes(10) > jwt.Expire ? IssueToken(deviceCode, set) : null; + } #endregion } diff --git a/AntJob.Server/Services/JobService.cs b/AntJob.Server/Services/JobService.cs index 756cd24..7c87641 100644 --- a/AntJob.Server/Services/JobService.cs +++ b/AntJob.Server/Services/JobService.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using AntJob.Data; +using AntJob.Data; using AntJob.Data.Entity; using AntJob.Models; using NewLife; @@ -87,7 +84,7 @@ public class JobService /// 申请作业任务 /// 模型 /// - public ITask[] Acquire(App app, AcquireModel model) + public ITask[] Acquire(App app, AcquireModel model, String ip) { var job = model.Job?.Trim(); if (job.IsNullOrEmpty()) return new TaskModel[0]; @@ -111,12 +108,12 @@ public class JobService if (jb == null) throw new XException($"应用[{app.ID}/{app.Name}]下未找到作业[{job}]"); if (jb.Step == 0 || jb.Start.Year <= 2000) throw new XException("作业[{0}/{1}]未设置开始时间或步进", jb.ID, jb.Name); - var online = _appService.GetOnline(app, _Net); + var online = _appService.GetOnline(app, ip); var list = new List(); // 每分钟检查一下错误任务和中断任务 - CheckErrorTask(app, jb, model.Count, list); + CheckErrorTask(app, jb, model.Count, list, ip); // 错误项不够时,增加切片 if (list.Count < model.Count) @@ -125,7 +122,6 @@ public class JobService var server = online.Name; var pid = online.ProcessId; //var topic = ps["topic"] + ""; - var ip = _Net.Remote.Host; switch (jb.Mode) { @@ -157,20 +153,15 @@ public class JobService return list.Select(e => e.ToModel()).ToArray(); } - private void CheckErrorTask(App app, Job jb, Int32 count, List list) + private void CheckErrorTask(App app, Job jb, Int32 count, List list, String ip) { // 每分钟检查一下错误任务和中断任务 - var nextKey = $"_NextAcquireOld_{jb.ID}"; + var nextKey = $"antjob:NextAcquireOld_{jb.ID}"; var now = TimerX.Now; - var ext = Session as IExtend; - var next = (DateTime)(ext[nextKey] ?? DateTime.MinValue); + var next = _cacheProvider.Cache.Get(nextKey); if (next < now) { - //var ps = ControllerContext.Current.Parameters; - //var server = ps["server"] + ""; - //var pid = ps["pid"].ToInt(); - var online = _appService.GetOnline(app, _Net); - var ip = _Net.Remote.Host; + var online = _appService.GetOnline(app, ip); next = now.AddSeconds(60); list.AddRange(jb.AcquireOld(online.Server, ip, online.ProcessId, count, _cacheProvider.Cache)); @@ -185,7 +176,7 @@ public class JobService _log.Info("作业[{0}/{1}]准备处理[{2}]个错误和[{3}]超时任务 [{4}]", app, jb.Name, n1, n2, list.Join(",", e => e.ID + "")); } else - ext[nextKey] = next; + _cacheProvider.Cache.Set(nextKey, next); } } @@ -276,7 +267,7 @@ public class JobService /// 报告状态(进度、成功、错误) /// /// - public Boolean Report(App app, TaskResult task) + public Boolean Report(App app, TaskResult task, String ip) { if (task == null || task.ID == 0) throw new InvalidOperationException("无效操作 TaskID=" + task?.ID); @@ -308,7 +299,7 @@ public class JobService SetJobFinish(job, jt); // 记录状态 - _appService.UpdateOnline(app, jt, _Net); + _appService.UpdateOnline(app, jt, ip); } if (task.Status == JobStatus.错误) { diff --git a/AntJob.Server/Setting.cs b/AntJob.Server/Setting.cs index 7fbed39..c0fb289 100644 --- a/AntJob.Server/Setting.cs +++ b/AntJob.Server/Setting.cs @@ -16,6 +16,18 @@ public class AntJobSetting : Config [Description("端口")] public Int32 Port { get; set; } = 9999; + /// 令牌密钥。用于生成JWT令牌的算法和密钥,如HS256:ABCD1234 + [Description("令牌密钥。用于生成JWT令牌的算法和密钥,如HS256:ABCD1234")] + public String TokenSecret { get; set; } + + /// 令牌有效期。默认2*3600秒 + [Description("令牌有效期。默认2*3600秒")] + public Int32 TokenExpire { get; set; } = 2 * 3600; + + /// 会话超时。默认600秒 + [Description("会话超时。默认600秒")] + public Int32 SessionTimeout { get; set; } = 600; + /// 自动注册。任意应用登录时自动注册,省去人工配置应用账号的麻烦,默认true [Description("自动注册。任意应用登录时自动注册,省去人工配置应用账号的麻烦,默认true")] public Boolean AutoRegistry { get; set; } = true; diff --git a/AntJob.Web/AntJob.Web.csproj b/AntJob.Web/AntJob.Web.csproj index 15c964e..601ca66 100644 --- a/AntJob.Web/AntJob.Web.csproj +++ b/AntJob.Web/AntJob.Web.csproj @@ -6,7 +6,7 @@ 分布式任务调度系统,纯NET打造的重量级大数据实时计算平台,万亿级调度经验积累 新生命开发团队 版权所有(C) 新生命开发团队 2023 - 3.3 + 3.4 $([System.DateTime]::Now.ToString(`yyyy.MMdd`)) $(VersionPrefix).$(VersionSuffix) $(Version) @@ -52,7 +52,6 @@ - \ No newline at end of file diff --git a/AntJob.Web/Common/ApiFilterAttribute.cs b/AntJob.Web/Common/ApiFilterAttribute.cs new file mode 100644 index 0000000..c7f8381 --- /dev/null +++ b/AntJob.Web/Common/ApiFilterAttribute.cs @@ -0,0 +1,81 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Filters; +using NewLife; +using NewLife.Log; + +namespace AntJob.Web.Common; + +/// 统一Api过滤处理 +/// +/// 1,解析访问令牌 +/// 2,包装响应结果为标准Json格式 +/// 3,拦截异常,包装为标准Json格式 +/// +public sealed class ApiFilterAttribute : ActionFilterAttribute +{ + /// 从请求头中获取令牌 + /// + /// + public static String GetToken(HttpContext httpContext) + { + var request = httpContext.Request; + var token = request.Query["Token"] + ""; + if (token.IsNullOrEmpty()) token = (request.Headers["Authorization"] + "").TrimStart("Bearer "); + if (token.IsNullOrEmpty()) token = request.Headers["X-Token"] + ""; + if (token.IsNullOrEmpty()) token = request.Cookies["Token"] + ""; + + return token; + } + + /// 执行前,验证模型 + /// + public override void OnActionExecuting(ActionExecutingContext context) + { + //if (!context.ModelState.IsValid) + // throw new ApplicationException(context.ModelState.Values.First(p => p.Errors.Count > 0).Errors[0].ErrorMessage); + + // 访问令牌 + var token = GetToken(context.HttpContext); + context.HttpContext.Items["Token"] = token; + if (!context.ActionArguments.ContainsKey("token")) context.ActionArguments.Add("token", token); + + base.OnActionExecuting(context); + } + + /// 执行后,包装结果和异常 + /// + public override void OnActionExecuted(ActionExecutedContext context) + { + if (context.HttpContext.WebSockets.IsWebSocketRequest) return; + + if (context.Result != null) + if (context.Result is ObjectResult obj) + context.Result = new JsonResult(new { code = obj.StatusCode ?? 0, data = obj.Value }); + else if (context.Result is EmptyResult) + { + DefaultTracer.Instance?.NewSpan("apiFilter-EmptyResult"); + context.Result = new JsonResult(new { code = 0, data = new { } }); + } + else if (context.Exception != null && !context.ExceptionHandled) + { + var ex = context.Exception.GetTrue(); + if (ex is NewLife.Remoting.ApiException aex) + context.Result = new JsonResult(new { code = aex.Code, data = aex.Message }); + else + { + context.Result = new JsonResult(new { code = 500, data = ex.Message }); + + // 埋点拦截业务异常 + var action = context.HttpContext.Request.Path + ""; + if (context.ActionDescriptor is ControllerActionDescriptor act) action = $"/{act.ControllerName}/{act.ActionName}"; + + DefaultTracer.Instance?.NewError(action, ex); + } + + context.ExceptionHandled = true; + } + + base.OnActionExecuted(context); + } +} \ No newline at end of file diff --git a/AntJob.Web/Controllers/AntJobController.cs b/AntJob.Web/Controllers/AntJobController.cs new file mode 100644 index 0000000..4b62292 --- /dev/null +++ b/AntJob.Web/Controllers/AntJobController.cs @@ -0,0 +1,167 @@ +using System.Reflection; +using AntJob.Data; +using AntJob.Data.Entity; +using AntJob.Models; +using AntJob.Server; +using AntJob.Server.Services; +using AntJob.Web.Common; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Filters; +using NewLife; +using NewLife.Caching; +using NewLife.Cube; +using NewLife.Log; +using NewLife.Remoting; +using NewLife.Serialization; +using IActionFilter = Microsoft.AspNetCore.Mvc.Filters.IActionFilter; + +namespace AntJob.Web.Controllers; + +public class AntJobController : ControllerBase, IActionFilter +{ + /// 令牌 + public String Token { get; private set; } + + /// 用户主机 + public String UserHost => HttpContext.GetUserHost(); + + private App _App; + private IDictionary _args; + private AntJobSetting _setting; + private readonly AppService _appService; + private readonly JobService _jobService; + private readonly ICacheProvider _cacheProvider; + + #region 构造 + public AntJobController(AppService appService, JobService jobService, AntJobSetting setting) + { + _appService = appService; + _jobService = jobService; + _setting = setting; + } + + void IActionFilter.OnActionExecuting(ActionExecutingContext context) + { + _args = context.ActionArguments; + + var token = Token = ApiFilterAttribute.GetToken(context.HttpContext); + + try + { + if (context.ActionDescriptor is ControllerActionDescriptor act && !act.MethodInfo.IsDefined(typeof(AllowAnonymousAttribute))) + { + var rs = !token.IsNullOrEmpty() && OnAuthorize(token); + if (!rs) throw new ApiException(403, "认证失败"); + } + } + catch (Exception ex) + { + var traceId = DefaultSpan.Current?.TraceId; + context.Result = ex is ApiException aex + ? new JsonResult(new { code = aex.Code, data = aex.Message, traceId }) + : new JsonResult(new { code = 500, data = ex.Message, traceId }); + + WriteError(ex, context); + } + } + + void IActionFilter.OnActionExecuted(ActionExecutedContext context) + { + if (context.Exception != null) WriteError(context.Exception, context); + } + + protected Boolean OnAuthorize(String token) + { + var (app, ex) = _appService.DecodeToken(token, _setting.TokenSecret); + _App = app; + if (ex != null) throw ex; + + return app != null; + } + + private void WriteError(Exception ex, ActionContext context) + { + // 拦截全局异常,写日志 + var action = context.HttpContext.Request.Path + ""; + if (context.ActionDescriptor is ControllerActionDescriptor act) action = $"{act.ControllerName}/{act.ActionName}"; + + _appService.WriteHistory(_App, action, false, ex?.GetTrue() + Environment.NewLine + _args?.ToJson(true), UserHost); + } + #endregion + + #region 登录 + /// 应用登录 + /// 模型 + /// + [AllowAnonymous] + [HttpPost(nameof(Login))] + public LoginResponse Login(LoginModel model) + { + if (model.User.IsNullOrEmpty()) throw new ArgumentNullException(nameof(model.User)); + + var (app, rs) = _appService.Login(model, UserHost); + + return rs; + } + + /// 获取当前应用的所有在线实例 + /// + [HttpGet(nameof(GetPeers))] + public PeerModel[] GetPeers() => _appService.GetPeers(_App); + #endregion + + #region 业务 + /// 获取指定名称的作业 + /// + [HttpGet(nameof(GetJobs))] + public IJob[] GetJobs() => _jobService.GetJobs(_App); + + /// 批量添加作业 + /// + /// + [HttpPost(nameof(AddJobs))] + public String[] AddJobs(JobModel[] jobs) + { + if (jobs == null || jobs.Length == 0) return new String[0]; + + return _jobService.AddJobs(_App, jobs); + } + + /// 申请作业任务 + /// 模型 + /// + [HttpPost(nameof(Acquire))] + public ITask[] Acquire(AcquireModel model) + { + var job = model.Job?.Trim(); + if (job.IsNullOrEmpty()) return new TaskModel[0]; + + return _jobService.Acquire(_App, model, UserHost); + } + + /// 生产消息 + /// 模型 + /// + [HttpPost(nameof(Produce))] + public Int32 Produce(ProduceModel model) + { + var messages = model?.Messages?.Where(e => !e.IsNullOrEmpty()).Distinct().ToArray(); + if (messages == null || messages.Length == 0) return 0; + + return _jobService.Produce(_App, model); + } + + /// 报告状态(进度、成功、错误) + /// + /// + [HttpPost(nameof(Report))] + public Boolean Report(TaskResult task) + { + if (task == null || task.ID == 0) throw new InvalidOperationException("无效操作 TaskID=" + task?.ID); + + return _jobService.Report(_App, task, UserHost); + } + #endregion +} diff --git a/AntJob/AntJob.csproj b/AntJob/AntJob.csproj index e703e48..465ead5 100644 --- a/AntJob/AntJob.csproj +++ b/AntJob/AntJob.csproj @@ -5,8 +5,8 @@ 蚂蚁调度SDK 分布式任务调度系统,纯NET打造的重量级大数据实时计算平台,万亿级调度经验积累。 新生命开发团队 - ©2002-2023 NewLife - 3.3 + ©2002-2024 NewLife + 3.4 $([System.DateTime]::Now.ToString(`yyyy.MMdd`)) $(VersionPrefix).$(VersionSuffix) $(Version)