更新框架
This commit is contained in:
1 parent
ef50fa0620
commit
84b28f9672
72 files changed
+2222
-282
No files matched your search
@@ -1,85 +0,0 @@
|
||||
using Autofac;
|
||||
using MAX.Core.DependencyInjection;
|
||||
using MAX.Core.Helpers;
|
||||
using MAX.Core.Infrastructure;
|
||||
using MAX.Core.Reflections;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace MAX.Core.Autofac;
|
||||
|
||||
public static class AutofaceMiddlewareExtensions
|
||||
{
|
||||
public static Bootstrap UseAutofac(this Bootstrap engine)
|
||||
{
|
||||
engine.UseMiddleware<AutofaceMiddleware>();
|
||||
return engine;
|
||||
}
|
||||
}
|
||||
|
||||
internal class AutofaceMiddleware : IBootstrap
|
||||
{
|
||||
private ContainerBuilder _builder;
|
||||
public void Build()
|
||||
{
|
||||
IocManager.Container = _builder.Build();
|
||||
}
|
||||
|
||||
public void Use()
|
||||
{
|
||||
_builder = new ContainerBuilder();
|
||||
IocManager.ContainerBuilder = _builder;
|
||||
RegisterDependencies(_builder);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 注册IOC
|
||||
/// </summary>
|
||||
/// <param name="builder"></param>
|
||||
/// <param name="cnetConfig"></param>
|
||||
private void RegisterDependencies(ContainerBuilder builder)
|
||||
{
|
||||
#region 注册所有IOC接口
|
||||
|
||||
IEnumerable<IDependencyRegistrar> ListDependency = ReflectionHelper.GetInstancesByInterface<IDependencyRegistrar>(AppTypeFinder.Assemblies.ToArray());
|
||||
foreach (IDependencyRegistrar item in ListDependency)
|
||||
{
|
||||
item.Register(builder);
|
||||
}
|
||||
|
||||
|
||||
//ISingletonDependency
|
||||
Type singtype = typeof(ISingletonDependency);
|
||||
builder.RegisterAssemblyTypes(AppTypeFinder.Assemblies.ToArray())
|
||||
.Where(a =>
|
||||
singtype.IsAssignableFrom(a)
|
||||
&& a.IsClass
|
||||
&& !a.IsAbstract
|
||||
&& !typeof(INoDependency).IsAssignableFrom(a)
|
||||
)
|
||||
.AsImplementedInterfaces()
|
||||
.AsSelf()
|
||||
.PropertiesAutowired(new CustomPropertySelector())
|
||||
.SingleInstance();
|
||||
|
||||
|
||||
//ITransientDependency
|
||||
Type transienttype = typeof(ITransientDependency);
|
||||
builder.RegisterAssemblyTypes(AppTypeFinder.Assemblies.ToArray())
|
||||
.Where(a =>
|
||||
transienttype.IsAssignableFrom(a)
|
||||
&& a.IsClass
|
||||
&& !a.IsAbstract
|
||||
&& !typeof(INoDependency).IsAssignableFrom(a)
|
||||
)
|
||||
.AsImplementedInterfaces()
|
||||
.AsSelf()
|
||||
.PropertiesAutowired(new CustomPropertySelector())
|
||||
.InstancePerLifetimeScope();
|
||||
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using Autofac;
|
||||
|
||||
namespace MAX.Core.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// 将通知发布器的服务解析委托给当前 Autofac 作用域,无须引入额外适配包。
|
||||
/// </summary>
|
||||
internal sealed class AutofacNotificationServiceProvider(ILifetimeScope scope) : IServiceProvider
|
||||
{
|
||||
public object? GetService(Type serviceType)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(serviceType);
|
||||
return scope.ResolveOptional(serviceType);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace MAX.Core.Infrastructure;
|
||||
|
||||
public class Bootstrap//Bootstrap
|
||||
{
|
||||
public Bootstrap()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
private readonly List<IBootstrap> _listUse = [];
|
||||
public static Bootstrap Create()
|
||||
{
|
||||
return new Bootstrap();
|
||||
}
|
||||
|
||||
|
||||
public void UseMiddleware<T>() where T : IBootstrap, new()
|
||||
{
|
||||
T t = new();
|
||||
t.Use();
|
||||
_listUse.Add(t);
|
||||
}
|
||||
public void Builder()
|
||||
{
|
||||
foreach (IBootstrap item in _listUse)
|
||||
{
|
||||
item.Build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace MAX.Core.Infrastructure;
|
||||
|
||||
public interface IBootstrap
|
||||
{
|
||||
void Use();
|
||||
void Build();
|
||||
}
|
||||
@@ -1,18 +1,15 @@
|
||||
using MAX.Core.Autofac;
|
||||
using MediatR;
|
||||
using Microsoft.Win32;
|
||||
using MAX.Core.Autofac;
|
||||
using MAX.Notification;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace MAX.Core.Infrastructure;
|
||||
public class MediatorManager : Mediator
|
||||
public class MediatorManager : NotificationPublisher
|
||||
{
|
||||
private readonly DynamicNotificationHandlerRegistry _registry;
|
||||
|
||||
@@ -24,11 +21,11 @@ public class MediatorManager : Mediator
|
||||
}
|
||||
}
|
||||
|
||||
public MediatorManager(ServiceFactory serviceFactory) : base(serviceFactory)
|
||||
public MediatorManager(IServiceProvider serviceProvider) : base(serviceProvider)
|
||||
{
|
||||
_registry = new DynamicNotificationHandlerRegistry();
|
||||
}
|
||||
protected override async Task PublishCore(IEnumerable<Func<INotification, CancellationToken, Task>> allHandlers, INotification notification, CancellationToken cancellationToken)
|
||||
protected override async Task PublishCore(IEnumerable<NotificationHandlerExecutor> allHandlers, INotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
await base.PublishCore(allHandlers, notification, cancellationToken);
|
||||
|
||||
@@ -47,7 +44,7 @@ class DynamicNotificationHandlerRegistry
|
||||
public DynamicNotificationHandlerRegistry()
|
||||
{
|
||||
}
|
||||
// Key: notification type, Value: 已注册的 handler 调用委托列表
|
||||
// 按通知类型保存动态注册的处理器调用委托。
|
||||
private readonly ConcurrentDictionary<Type, List<Func<INotification, CancellationToken, Task>>>
|
||||
_handlers = new();
|
||||
|
||||
@@ -82,7 +79,7 @@ class DynamicNotificationHandlerRegistry
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Publish 时由 PluginAwareMediator 调用</summary>
|
||||
/// <summary>发布通知时调用动态处理器。</summary>
|
||||
public async Task Publish(INotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
var notificationType = notification.GetType();
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Autofac" Version="8.3.0" />
|
||||
<PackageReference Include="MediatR" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MAX.Notification\MAX.Notification.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,4 +1,4 @@
|
||||
/***
|
||||
/***
|
||||
* _ooOoo_
|
||||
* o8888888o
|
||||
* 88" . "88
|
||||
@@ -20,7 +20,7 @@
|
||||
*
|
||||
* .............................................
|
||||
* 佛祖保佑 永无BUG
|
||||
* 快加工资 不改需求
|
||||
* 快加工资 不改需求
|
||||
*/
|
||||
|
||||
|
||||
@@ -28,12 +28,8 @@ using Autofac;
|
||||
using MAX.Core.DependencyInjection;
|
||||
using MAX.Core.Infrastructure;
|
||||
using MAX.Core.Reflections;
|
||||
using MediatR;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using MAX.Notification;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace MAX.Core;
|
||||
|
||||
@@ -50,10 +46,13 @@ public class Startup : IStartup
|
||||
{
|
||||
builder.RegisterInstance(LazyServiceProvider.Instance).As<ILazyServiceProvider>().AsSelf().SingleInstance();
|
||||
|
||||
#region 注册MediatR
|
||||
#region 注册通知服务
|
||||
Assembly[] assemblies = AppTypeFinder.Assemblies.ToArray();
|
||||
|
||||
builder.RegisterType<MediatorManager>().As<IMediator>().AsSelf().SingleInstance();
|
||||
builder.Register(ctx => new MediatorManager(
|
||||
new AutofacNotificationServiceProvider(ctx.Resolve<ILifetimeScope>())))
|
||||
.As<IPublisher>().AsSelf().InstancePerLifetimeScope();
|
||||
|
||||
|
||||
builder.RegisterAssemblyTypes(assemblies)
|
||||
.AsClosedTypesOf(typeof(INotificationHandler<>))
|
||||
@@ -61,18 +60,6 @@ public class Startup : IStartup
|
||||
.AsSelf()
|
||||
.InstancePerLifetimeScope();
|
||||
|
||||
builder.RegisterAssemblyTypes(assemblies)
|
||||
.AsClosedTypesOf(typeof(IRequestHandler<>))
|
||||
.AsImplementedInterfaces()
|
||||
.AsSelf()
|
||||
.InstancePerLifetimeScope();
|
||||
|
||||
builder.Register<ServiceFactory>(ctx =>
|
||||
{
|
||||
var c = ctx.Resolve<IComponentContext>();
|
||||
return t => c.Resolve(t);
|
||||
}).SingleInstance();
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using MAX.Core.DependencyInjection;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace MAX.Framework.Interfaces
|
||||
{
|
||||
public interface IFolderConfig : ISingletonDependency
|
||||
{
|
||||
string ConfigFolder { get; }
|
||||
string DataFolder { get; }
|
||||
string LoggingFolder { get; }
|
||||
string CacheFolder { get; }
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,42 @@
|
||||
using MAX.Core.Infrastructure;
|
||||
using MAX.Core.DependencyInjection;
|
||||
using MAX.Core.Infrastructure;
|
||||
using MAX.Core.Logger;
|
||||
using MAX.Framework.Interfaces;
|
||||
using NLog;
|
||||
using NLog.Config;
|
||||
using NLog.Targets;
|
||||
using System;
|
||||
using LLogLevel = MAX.Core.Logger.LogLevel;
|
||||
using NLogLevel = NLog.LogLevel;
|
||||
|
||||
namespace MAX.Framework.Logger;
|
||||
|
||||
public class NLogger : SingleInstance<NLogger>, ILoggerFacade
|
||||
public class NLogger : ILoggerFacade, ISingletonDependency
|
||||
{
|
||||
private readonly ILogger _log;
|
||||
|
||||
private NLogger()
|
||||
public static ILoggerFacade Instance => LazyServiceProvider.Instance.LazyGetService<ILoggerFacade>();
|
||||
public NLogger(IFolderConfig folderConfig)
|
||||
{
|
||||
//Target target = new FileTarget("log1")
|
||||
//{
|
||||
// FileName = $"{FolderConfig.LoggingFolder}/{DateTime.Now:yyyy-MM-dd}.log",
|
||||
//};
|
||||
//LoggingConfiguration config = new();
|
||||
//config.AddRule(NLogLevel.Debug, NLogLevel.Fatal, target);
|
||||
|
||||
//LogManager.Configuration = config;
|
||||
ArgumentNullException.ThrowIfNull(folderConfig);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(folderConfig.LoggingFolder);
|
||||
lock (ConfigurationLock)
|
||||
{
|
||||
// 使用进程上下文,让 XML 配置及其自动重载共用宿主的目录约定。
|
||||
GlobalDiagnosticsContext.Set("LoggingFolder", folderConfig.LoggingFolder);
|
||||
if (LogManager.Configuration == null)
|
||||
{
|
||||
var target = new FileTarget("application")
|
||||
{
|
||||
FileName = System.IO.Path.Combine(folderConfig.LoggingFolder, "${shortdate}.log"),
|
||||
};
|
||||
var config = new LoggingConfiguration();
|
||||
config.AddRule(NLogLevel.Debug, NLogLevel.Fatal, target);
|
||||
LogManager.Configuration = config;
|
||||
}
|
||||
}
|
||||
_log = LogManager.GetLogger("GlobalLogger");
|
||||
}
|
||||
private static readonly object ConfigurationLock = new();
|
||||
|
||||
public bool IsDebugEnabled { get; private set; } = true;
|
||||
|
||||
|
||||
+17
-5
@@ -150,12 +150,18 @@ public class AutofacContainerExtension : IContainerExtension<IContainer>, IConta
|
||||
|
||||
public IContainerRegistry RegisterSingleton(Type type, Func<object> factoryMethod)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
ArgumentNullException.ThrowIfNull(type);
|
||||
ArgumentNullException.ThrowIfNull(factoryMethod);
|
||||
Builder.Register(_ => factoryMethod()).As(type).SingleInstance();
|
||||
return this;
|
||||
}
|
||||
|
||||
public IContainerRegistry RegisterSingleton(Type type, Func<IContainerProvider, object> factoryMethod)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
ArgumentNullException.ThrowIfNull(type);
|
||||
ArgumentNullException.ThrowIfNull(factoryMethod);
|
||||
Builder.Register(_ => factoryMethod(this)).As(type).SingleInstance();
|
||||
return this;
|
||||
}
|
||||
|
||||
public object Resolve(Type type)
|
||||
@@ -192,12 +198,18 @@ public class AutofacContainerExtension : IContainerExtension<IContainer>, IConta
|
||||
|
||||
public IContainerRegistry Register(Type type, Func<object> factoryMethod)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
ArgumentNullException.ThrowIfNull(type);
|
||||
ArgumentNullException.ThrowIfNull(factoryMethod);
|
||||
Builder.Register(_ => factoryMethod()).As(type);
|
||||
return this;
|
||||
}
|
||||
|
||||
public IContainerRegistry Register(Type type, Func<IContainerProvider, object> factoryMethod)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
ArgumentNullException.ThrowIfNull(type);
|
||||
ArgumentNullException.ThrowIfNull(factoryMethod);
|
||||
Builder.Register(_ => factoryMethod(this)).As(type);
|
||||
return this;
|
||||
}
|
||||
public IContainerRegistry RegisterMany(Type type, params Type[] serviceTypes)
|
||||
{
|
||||
@@ -320,4 +332,4 @@ public class AutofacContainerExtension : IContainerExtension<IContainer>, IConta
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ public class Startup : IStartup
|
||||
|
||||
public void Register(ContainerBuilder builder)
|
||||
{
|
||||
builder.RegisterInstance(NLogger.Instance).As<ILoggerFacade>().SingleInstance();
|
||||
//builder.RegisterInstance(NLogger.Instance).As<ILoggerFacade>().SingleInstance();
|
||||
|
||||
#region 注册所有IOC接口
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// 摘自 MediatR 14.2.0,许可声明与修改记录见 LICENSE.md 和 UPSTREAM.md。
|
||||
namespace MAX.Notification;
|
||||
|
||||
/// <summary>
|
||||
/// 标记可发布的通知。
|
||||
/// </summary>
|
||||
public interface INotification { }
|
||||
@@ -0,0 +1,41 @@
|
||||
// 摘自 MediatR 14.2.0,许可声明与修改记录见 LICENSE.md 和 UPSTREAM.md。
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace MAX.Notification;
|
||||
|
||||
/// <summary>
|
||||
/// 定义通知处理器。
|
||||
/// </summary>
|
||||
/// <typeparam name="TNotification">要处理的通知类型。</typeparam>
|
||||
public interface INotificationHandler<in TNotification>
|
||||
where TNotification : INotification
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理通知。
|
||||
/// </summary>
|
||||
/// <param name="notification">要处理的通知。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
Task Handle(TNotification notification, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 同步通知处理器的基类。
|
||||
/// </summary>
|
||||
/// <typeparam name="TNotification">通知类型。</typeparam>
|
||||
public abstract class NotificationHandler<TNotification> : INotificationHandler<TNotification>
|
||||
where TNotification : INotification
|
||||
{
|
||||
Task INotificationHandler<TNotification>.Handle(TNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
Handle(notification);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在派生类中重写此方法,实现同步通知处理逻辑。
|
||||
/// </summary>
|
||||
/// <param name="notification">要处理的通知。</param>
|
||||
protected abstract void Handle(TNotification notification);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// 摘自 MediatR 14.2.0,许可声明与修改记录见 LICENSE.md 和 UPSTREAM.md。
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading;
|
||||
|
||||
namespace MAX.Notification;
|
||||
|
||||
public interface INotificationPublisher
|
||||
{
|
||||
Task Publish(IEnumerable<NotificationHandlerExecutor> handlerExecutors, INotification notification,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// 摘自 MediatR 14.2.0,许可声明与修改记录见 LICENSE.md 和 UPSTREAM.md。
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace MAX.Notification;
|
||||
|
||||
/// <summary>
|
||||
/// 发布通知或事件,交由多个处理器处理。
|
||||
/// </summary>
|
||||
public interface IPublisher
|
||||
{
|
||||
/// <summary>
|
||||
/// 异步向多个处理器分发通知。
|
||||
/// </summary>
|
||||
/// <param name="notification">通知对象。</param>
|
||||
/// <param name="cancellationToken">可选的取消令牌。</param>
|
||||
/// <returns>表示本次通知发布操作的任务。</returns>
|
||||
Task Publish(object notification, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 异步向多个处理器分发通知。
|
||||
/// </summary>
|
||||
/// <param name="notification">通知对象。</param>
|
||||
/// <param name="cancellationToken">可选的取消令牌。</param>
|
||||
/// <returns>表示本次通知发布操作的任务。</returns>
|
||||
Task Publish<TNotification>(TNotification notification, CancellationToken cancellationToken = default)
|
||||
where TNotification : INotification;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
By accessing code under the [Lucky Penny Software GitHub Organization](https://github.com/LuckyPennySoftware) (Lucky Penny Software) here, you are agreeing to the following licensing terms.
|
||||
If you do not agree to these terms, do not access Lucky Penny Software code.
|
||||
|
||||
Your license to Lucky Penny Software source code and/or binaries is governed by the Reciprocal Public License 1.5 (RPL1.5) license as described here:
|
||||
|
||||
https://opensource.org/license/rpl-1-5/
|
||||
|
||||
If you do not wish to release the source of software you build using Lucky Penny Software source code and/or binaries under the terms above, you may use Lucky Penny Software source code and/or binaries under the License Agreement described here:
|
||||
|
||||
https://luckypennysoftware.com/license
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Remove="PolySharp" />
|
||||
<None Update="LICENSE.md" Pack="true" PackagePath="" />
|
||||
<None Update="UPSTREAM.md" Pack="true" PackagePath="" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
// 摘自 MediatR 14.2.0,许可声明与修改记录见 LICENSE.md 和 UPSTREAM.md。
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace MAX.Notification;
|
||||
|
||||
public record NotificationHandlerExecutor(object HandlerInstance, Func<INotification, CancellationToken, Task> HandlerCallback);
|
||||
@@ -0,0 +1,67 @@
|
||||
// 摘自 MediatR 14.2.0 的 Mediator.cs,仅保留通知功能。
|
||||
// 许可声明与修改记录见 LICENSE.md 和 UPSTREAM.md。
|
||||
using System.Collections.Concurrent;
|
||||
using MAX.Notification.NotificationPublishers;
|
||||
using MAX.Notification.Wrappers;
|
||||
|
||||
namespace MAX.Notification;
|
||||
|
||||
public class NotificationPublisher : IPublisher
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly INotificationPublisher _publisher;
|
||||
private static readonly ConcurrentDictionary<Type, NotificationHandlerWrapper> _notificationHandlers = new();
|
||||
|
||||
public NotificationPublisher(IServiceProvider serviceProvider)
|
||||
: this(serviceProvider, new ForeachAwaitPublisher()) { }
|
||||
|
||||
public NotificationPublisher(IServiceProvider serviceProvider, INotificationPublisher publisher)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(serviceProvider);
|
||||
ArgumentNullException.ThrowIfNull(publisher);
|
||||
_serviceProvider = serviceProvider;
|
||||
_publisher = publisher;
|
||||
}
|
||||
public Task Publish<TNotification>(TNotification notification, CancellationToken cancellationToken = default)
|
||||
where TNotification : INotification
|
||||
{
|
||||
if (notification == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(notification));
|
||||
}
|
||||
|
||||
return PublishNotification(notification, cancellationToken);
|
||||
}
|
||||
|
||||
public Task Publish(object notification, CancellationToken cancellationToken = default) =>
|
||||
notification switch
|
||||
{
|
||||
null => throw new ArgumentNullException(nameof(notification)),
|
||||
INotification instance => PublishNotification(instance, cancellationToken),
|
||||
_ => throw new ArgumentException($"{nameof(notification)} does not implement ${nameof(INotification)}")
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 在派生类中重写此方法可控制任务的等待方式。默认调用 <see cref="INotificationPublisher"/>。
|
||||
/// </summary>
|
||||
/// <param name="handlerExecutors">封装各通知处理器实例及其调用委托的执行器集合。</param>
|
||||
/// <param name="notification">要发布的通知。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>表示本次处理器分发操作的任务。</returns>
|
||||
protected virtual Task PublishCore(IEnumerable<NotificationHandlerExecutor> handlerExecutors, INotification notification, CancellationToken cancellationToken)
|
||||
=> _publisher.Publish(handlerExecutors, notification, cancellationToken);
|
||||
|
||||
private Task PublishNotification(INotification notification, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var handler = _notificationHandlers.GetOrAdd(notification.GetType(), static notificationType =>
|
||||
{
|
||||
var wrapperType = typeof(NotificationHandlerWrapperImpl<>).MakeGenericType(notificationType);
|
||||
var wrapper = Activator.CreateInstance(wrapperType) ?? throw new InvalidOperationException($"Could not create wrapper for type {notificationType}");
|
||||
return (NotificationHandlerWrapper)wrapper;
|
||||
});
|
||||
|
||||
return handler.Handle(notification, _serviceProvider, PublishCore, cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// 摘自 MediatR 14.2.0,许可声明与修改记录见 LICENSE.md 和 UPSTREAM.md。
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace MAX.Notification.NotificationPublishers;
|
||||
|
||||
/// <summary>
|
||||
/// 在循环中依次调用并等待各通知处理器:
|
||||
/// <code>
|
||||
/// foreach (var handler in handlers) {
|
||||
/// await handler(notification, cancellationToken);
|
||||
/// }
|
||||
/// </code>
|
||||
/// </summary>
|
||||
public class ForeachAwaitPublisher : INotificationPublisher
|
||||
{
|
||||
public async Task Publish(IEnumerable<NotificationHandlerExecutor> handlerExecutors, INotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var handler in handlerExecutors)
|
||||
{
|
||||
await handler.HandlerCallback(notification, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// 摘自 MediatR 14.2.0,许可声明与修改记录见 LICENSE.md 和 UPSTREAM.md。
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace MAX.Notification.NotificationPublishers;
|
||||
|
||||
/// <summary>
|
||||
/// 使用 Task.WhenAll 等待通知处理器任务集合:
|
||||
/// <code>
|
||||
/// var tasks = handlers
|
||||
/// .Select(handler => handler.Handle(notification, cancellationToken))
|
||||
/// .ToList();
|
||||
///
|
||||
/// return Task.WhenAll(tasks);
|
||||
/// </code>
|
||||
/// </summary>
|
||||
public class TaskWhenAllPublisher : INotificationPublisher
|
||||
{
|
||||
public Task Publish(IEnumerable<NotificationHandlerExecutor> handlerExecutors, INotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
var tasks = handlerExecutors
|
||||
.Select(handler => handler.HandlerCallback(notification, cancellationToken))
|
||||
.ToArray();
|
||||
|
||||
return Task.WhenAll(tasks);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
# MAX.Notification
|
||||
|
||||
从本地 MediatR 14.2.0 源码抽取的通知组件,仅依赖 .NET 8,不引用任何第三方 NuGet 包。原许可声明完整保存在 LICENSE.md;源码来源和修改记录见 UPSTREAM.md。不包含请求响应、管道、流、许可证检测或容器扫描功能。移除第三方依赖不改变原源码的许可条件。
|
||||
|
||||
## 定义与使用
|
||||
|
||||
```csharp
|
||||
using MAX.Notification;
|
||||
|
||||
public sealed record ProductChanged(int ProductId) : INotification;
|
||||
|
||||
public sealed class RefreshCacheHandler : INotificationHandler<ProductChanged>
|
||||
{
|
||||
public Task Handle(ProductChanged notification, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
// 更新缓存,或返回真正的异步业务操作。
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
发布器通过标准 `IServiceProvider.GetService(typeof(IEnumerable<INotificationHandler<T>>))` 获取处理器。调用方可自行实现此接口;组件不拥有、不释放容器及处理器。无容器实现可参考测试项目中的 TestServices。
|
||||
|
||||
宿主已经使用 Microsoft DI 时,可以在宿主中注册(以下扩展方法来自宿主自己的 DI 包,本项目无须引用):
|
||||
|
||||
```csharp
|
||||
services.AddTransient<INotificationHandler<ProductChanged>, RefreshCacheHandler>();
|
||||
services.AddScoped<IPublisher>(sp => new NotificationPublisher(sp));
|
||||
// 在有效作用域内获取 IPublisher 后:
|
||||
await publisher.Publish(new ProductChanged(42), cancellationToken);
|
||||
```
|
||||
|
||||
宿主使用 Autofac 时:
|
||||
|
||||
```csharp
|
||||
builder.RegisterType<RefreshCacheHandler>()
|
||||
.As<INotificationHandler<ProductChanged>>();
|
||||
builder.Register(ctx => new NotificationPublisher(
|
||||
new AutofacServiceProvider(ctx.Resolve<ILifetimeScope>())))
|
||||
.As<IPublisher>().InstancePerLifetimeScope();
|
||||
```
|
||||
|
||||
上述 Autofac 示例需要宿主已有 Autofac.Extensions.DependencyInjection,或者宿主自己实现 IServiceProvider 适配器。不要把捕获短期作用域的发布器注册为单例。
|
||||
|
||||
## 行为与边界
|
||||
|
||||
- 默认 ForeachAwaitPublisher:按容器返回顺序逐个等待;Handler 抛异常或返回取消任务后停止,已完成的操作不会回滚。可实现 INotificationPublisher 自定义分发策略。
|
||||
- 并发策略:构造时传入 new MAX.Notification.NotificationPublishers.TaskWhenAllPublisher()。使用原版 Task.WhenAll 实现;Handler 同步抛异常会中断后续枚举,已启动任务不会由此次调用统一等待。返回失败 Task 则会继续启动其他 Handler 并等待全部完成。没有 Task.Run,不保证运行在后台线程,不应并发共享非线程安全资源。
|
||||
- 按通知实际运行时类型解析;不自动查找基类通知处理器,是否支持逆变解析取决于容器。
|
||||
- 一次发布内相同处理器实际类型只执行第一个实例;不同类型各执行一次。这也会合并同类型但不同配置的实例。
|
||||
- 无处理器时成功返回。每次发布重新解析,只缓存类型适配器,不缓存处理器或页面实例。
|
||||
- 取消行为与原版一致:只传递原始 CancellationToken,发布器不主动检查取消。Handler 应自行检查或把令牌传入异步操作;没有 Handler 时,已取消的令牌不会使发布失败。
|
||||
- 无消息去重、持久化、重试、事务、跨进程通信或 UI 线程调度。重复 Publish 仍会重复执行。
|
||||
- 页面关闭不会自动影响 DI 中的处理器;业务 Handler 应与页面生命周期解耦。
|
||||
- 使用运行时泛型反射构建适配器;当前未提供 NativeAOT / trimming 支持保证。
|
||||
|
||||
## 验证
|
||||
|
||||
```powershell
|
||||
dotnet build Source/Core/MAX.Notification/MAX.Notification.csproj -c Release
|
||||
dotnet run --project Tests/MAX.Notification.SmokeTests/MAX.Notification.SmokeTests.csproj -c Release
|
||||
```
|
||||
|
||||
测试项目同样不依赖第三方包,覆盖类型分发、重复处理器、重新解析、空处理器、输入校验、异常、顺序/并发及取消。
|
||||
|
||||
支持继承 NotificationHandler<TNotification> 实现同步处理器,覆写 protected Handle(notification);同步基类本身不检查取消。
|
||||
@@ -0,0 +1,25 @@
|
||||
# 源码来源与修改记录
|
||||
|
||||
来源:用户提供的 `D:\Sources\MediatR-14.2.0`。
|
||||
上游项目:https://github.com/LuckyPennySoftware/MediatR
|
||||
|
||||
LICENSE.md 为原目录同名文件的原样副本,声明 RPL 1.5 或商业许可。移除依赖不改变原许可要求。
|
||||
|
||||
## 直接抽取
|
||||
|
||||
- src/MediatR.Contracts/INotification.cs
|
||||
- src/MediatR/INotificationHandler.cs(含同步 NotificationHandler 基类)
|
||||
- src/MediatR/IPublisher.cs
|
||||
- src/MediatR/INotificationPublisher.cs
|
||||
- src/MediatR/NotificationHandlerExecutor.cs
|
||||
- src/MediatR/NotificationPublishers/ForeachAwaitPublisher.cs
|
||||
- src/MediatR/NotificationPublishers/TaskWhenAllPublisher.cs
|
||||
- src/MediatR/Wrappers/NotificationHandlerWrapper.cs
|
||||
|
||||
以上代码注释已翻译为中文,许可声明保留原文;命名空间由 MediatR 改为 MAX.Notification,并添加来源说明。Wrapper 移除 Microsoft.Extensions.DependencyInjection,GetServices<T>() 改为标准 IServiceProvider.GetService(typeof(IEnumerable<T>)),空结果使用空集合,错误返回类型抛出转换异常。保留原处理器类型去重逻辑。
|
||||
|
||||
NotificationPublisher.cs 从 src/MediatR/Mediator.cs 抽取两个 Publish 重载、PublishCore、PublishNotification、通知缓存及相关字段。入口类改名,保留默认和自定义发布策略构造方式,增加构造参数 null 校验。去除请求响应、流以及第三方许可证检测调用,保留许可声明。
|
||||
|
||||
项目配置、使用说明和测试为新增内容。库不依赖第三方 NuGet 包,也排除全局 PolySharp 引用。
|
||||
|
||||
保留原版行为:只传递 CancellationToken,不主动检查取消;TaskWhenAllPublisher 枚举期间遇到同步异常时停止启动后续处理器。
|
||||
@@ -0,0 +1,32 @@
|
||||
// 摘自 MediatR 14.2.0,许可声明与修改记录见 LICENSE.md 和 UPSTREAM.md。
|
||||
namespace MAX.Notification.Wrappers;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
|
||||
public abstract class NotificationHandlerWrapper
|
||||
{
|
||||
public abstract Task Handle(INotification notification, IServiceProvider serviceFactory,
|
||||
Func<IEnumerable<NotificationHandlerExecutor>, INotification, CancellationToken, Task> publish,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public class NotificationHandlerWrapperImpl<TNotification> : NotificationHandlerWrapper
|
||||
where TNotification : INotification
|
||||
{
|
||||
public override Task Handle(INotification notification, IServiceProvider serviceFactory,
|
||||
Func<IEnumerable<NotificationHandlerExecutor>, INotification, CancellationToken, Task> publish,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var handlers = ((IEnumerable<INotificationHandler<TNotification>>?)serviceFactory.GetService(typeof(IEnumerable<INotificationHandler<TNotification>>)) ?? Array.Empty<INotificationHandler<TNotification>>())
|
||||
.GroupBy(static x => x.GetType())
|
||||
.Select(static g => g.First())
|
||||
.Select(static x => new NotificationHandlerExecutor(x, (theNotification, theToken) => x.Handle((TNotification)theNotification, theToken)));
|
||||
|
||||
return publish(handlers, notification, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Store.Management.Api;
|
||||
|
||||
public sealed class ApiClientOptions
|
||||
{
|
||||
public required Uri BaseUrl { get; init; }
|
||||
public string Version { get; init; } = "v1";
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
if (BaseUrl is null || !BaseUrl.IsAbsoluteUri ||
|
||||
(BaseUrl.Scheme != "https" && BaseUrl.Scheme != "http") ||
|
||||
!string.IsNullOrEmpty(BaseUrl.UserInfo) || !string.IsNullOrEmpty(BaseUrl.Query) || !string.IsNullOrEmpty(BaseUrl.Fragment))
|
||||
throw new ArgumentException("BaseUrl 必须是无凭据、查询参数和片段的 HTTP(S) 绝对地址。");
|
||||
if (!System.Text.RegularExpressions.Regex.IsMatch(Version, @"^v\d+(\.\d+)?$"))
|
||||
throw new ArgumentException("Version 格式应为 v1 或 v1.0。");
|
||||
if (Timeout <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(Timeout));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using RestSharp;
|
||||
using Store.Management.Api.Transport;
|
||||
|
||||
namespace Store.Management.Api.Authentication;
|
||||
|
||||
public sealed class LoginRequest
|
||||
{
|
||||
[Required] public string UserName { get; set; } = "";
|
||||
[Required] public string Password { get; set; } = "";
|
||||
[StringLength(100)] public string? DeviceId { get; set; }
|
||||
[StringLength(100)] public string? DeviceName { get; set; }
|
||||
}
|
||||
|
||||
public sealed class LoginResult
|
||||
{
|
||||
public string TokenType { get; init; } = "Bearer";
|
||||
public string Token { get; init; } = "";
|
||||
public uint ExpiresInMinutes { get; init; }
|
||||
}
|
||||
|
||||
public sealed record CurrentUser(string UserName, string? NickName);
|
||||
|
||||
public interface IAuthApi
|
||||
{
|
||||
Task<LoginResult> LoginAsync(LoginRequest request, CancellationToken cancellationToken = default);
|
||||
Task LogoutAsync(CancellationToken cancellationToken = default);
|
||||
Task<CurrentUser> MeAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
internal sealed class AuthApi(ApiTransport transport, TokenSession session) : IAuthApi
|
||||
{
|
||||
// 登录和退出串行执行,避免迟到的登录响应覆盖退出操作。
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
public async Task<LoginResult> LoginAsync(LoginRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var result = await transport.GetRequiredAsync<LoginResult>("Auth/Login", Method.Post, request,
|
||||
authenticated: false, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(result.Token) || result.Token.Contains('\r') || result.Token.Contains('\n') ||
|
||||
!string.Equals(result.TokenType, "Bearer", StringComparison.OrdinalIgnoreCase))
|
||||
throw new ApiException(ApiErrorKind.InvalidResponse, "登录响应缺少有效的 Bearer Token。");
|
||||
session.Set(result.Token);
|
||||
return result;
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
public async Task LogoutAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
var token = session.Token;
|
||||
try
|
||||
{
|
||||
if (token is not null)
|
||||
await transport.SendAsync<object>("Auth/Logout", Method.Post, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally { session.ClearIfCurrent(token); _gate.Release(); }
|
||||
}
|
||||
|
||||
public Task<CurrentUser> MeAsync(CancellationToken cancellationToken = default)
|
||||
=> transport.GetRequiredAsync<CurrentUser>("Auth/Me", Method.Get, cancellationToken: cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Store.Management.Api.Authentication;
|
||||
|
||||
/// <summary>会话仅保存在内存中,不将密码或 Token 写入配置与日志。</summary>
|
||||
public sealed class TokenSession
|
||||
{
|
||||
private string? _token;
|
||||
public bool IsAuthenticated => Volatile.Read(ref _token) is not null;
|
||||
internal string? Token => Volatile.Read(ref _token);
|
||||
internal void Set(string token) => Interlocked.Exchange(ref _token, token);
|
||||
public void Clear() => Interlocked.Exchange(ref _token, null);
|
||||
// 旧请求的 401 不应清除重新登录后建立的新会话。
|
||||
internal void ClearIfCurrent(string? token)
|
||||
{
|
||||
if (token is not null) Interlocked.CompareExchange(ref _token, null, token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Store.Management.Api.Models;
|
||||
|
||||
public sealed class ApiResult<T>
|
||||
{
|
||||
public required int Code { get; init; }
|
||||
public string? Msg { get; init; }
|
||||
public string? TraceId { get; init; }
|
||||
public T? Data { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PageResult<T>(long Total, IReadOnlyList<T> Items);
|
||||
|
||||
public class PageRequest
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Range(1, 1000000)]
|
||||
public int Page { get; set; } = 1;
|
||||
[System.ComponentModel.DataAnnotations.Range(1, 200)]
|
||||
public int PageSize { get; set; } = 50;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
# Store Management API 客户端
|
||||
|
||||
独立的 net10.0 类库,使用 RestSharp 112.1.0 连接 StoreWeb。WPF 项目已引用该类库。每个服务地址及用户会话创建一个 StoreApiClient,建议在应用容器注册为单例,退出应用时释放。
|
||||
|
||||
## 调用
|
||||
|
||||
```csharp
|
||||
using Store.Management.Api;
|
||||
using Store.Management.Api.Authentication;
|
||||
using Store.Management.Api.Users;
|
||||
using Store.Management.Api.Transport;
|
||||
|
||||
using var api = new StoreApiClient(new ApiClientOptions
|
||||
{
|
||||
BaseUrl = new Uri("https://your-server/"), // 替换为实际服务地址
|
||||
Version = "v1",
|
||||
Timeout = TimeSpan.FromSeconds(30)
|
||||
});
|
||||
try
|
||||
{
|
||||
await api.Auth.LoginAsync(new LoginRequest
|
||||
{
|
||||
UserName = userName,
|
||||
Password = password,
|
||||
DeviceId = deviceId, // 可选;稳定设备标识由宿主应用管理
|
||||
DeviceName = Environment.MachineName
|
||||
});
|
||||
var me = await api.Auth.MeAsync();
|
||||
var page = await api.Users.SearchAsync(new UserSearchRequest
|
||||
{
|
||||
Page = 1, PageSize = 50, Keyword = ""
|
||||
});
|
||||
var created = await api.Users.CreateAsync(new UserCreateRequest
|
||||
{
|
||||
UserName = "new-user", Password = initialPassword, NickName = "新用户"
|
||||
});
|
||||
await api.Users.UpdateAsync(new UserUpdateRequest { Id = created.Id, NickName = "修改后的昵称" });
|
||||
await api.Auth.LogoutAsync();
|
||||
}
|
||||
catch (ApiException ex)
|
||||
{
|
||||
// 显示 ex.Message;根据 Kind 区分失败类型,TraceId 可用于联系服务端排查。
|
||||
// Unauthorized 时导航登录页;HTTP 403 不清除会话。
|
||||
}
|
||||
```
|
||||
|
||||
示例中的账号、密码、设备标识来自调用方输入。异步方法均支持 CancellationToken;主动取消抛出 OperationCanceledException,本地输入校验抛出 ValidationException。
|
||||
|
||||
## 文件与职责
|
||||
|
||||
- StoreApiClient.cs:组合会话、传输层与业务模块,集中管理生命周期。
|
||||
- ApiClientOptions.cs:服务地址、版本和超时配置。
|
||||
- Transport/ApiTransport.cs:唯一 HTTP 出口,统一 Bearer、JSON、查询参数、校验及错误映射。
|
||||
- Transport/ApiException.cs:区分网络、超时、HTTP、业务、响应格式与认证错误,保留状态码、业务码与 TraceId。
|
||||
- Authentication/:登录、退出、当前用户;Token 仅存内存。
|
||||
- Models/:服务端响应信封与分页模型。
|
||||
- Users/:用户分页、查询、添加与修改,DTO 只包含当前基础功能所需字段。
|
||||
|
||||
## 协议与扩展
|
||||
|
||||
对齐 StoreWeb 的 AuthController、TokenResult、UnifyResultFilter 和用户管理控制器。成功码为 200;HTTP 200 内的业务失败同样抛出 ApiException。登录读取 data.token,认证采用 Bearer opaque Token,不解析 JWT,不根据首次登录的有效期自行判定续期。HTTP 或业务 401 清除当前请求所用的会话;旧请求的 401 不清除新 Token。
|
||||
|
||||
后续商品模块按 Users 的方式新增独立 DTO、接口和实现,由 StoreApiClient 暴露模块入口。模块通过 Transport.GetRequiredAsync<T> 发送有数据请求,通过 SendAsync<object> 发送无数据操作。路由采用 user/GetAllUser 这样的相对路径,传输层自动添加版本。简单参数使用 query,复杂参数使用 body,HTTP 方法与服务端契约一致。避免按常规 REST 习惯猜测当前服务端的动作路由。
|
||||
|
||||
请求不自动重试,避免添加、修改操作重复执行。禁止自动重定向;服务地址应直接指向最终 API 地址。退出时即使服务端请求失败也清理本地 Token,并将错误交给宿主;此时服务端会话可能仍有效。Session.Clear 可用于本地退出,不撤销服务器会话。
|
||||
|
||||
当前不包含登录界面、Token 磁盘持久化、自动刷新或商品接口。实际地址由宿主提供,尚未进行真实服务联调。
|
||||
|
||||
## 验证
|
||||
|
||||
在仓库根目录运行:
|
||||
|
||||
```powershell
|
||||
dotnet build Store.Management.slnx
|
||||
dotnet run --project Tests/Store.Management.Api.SmokeTests/Store.Management.Api.SmokeTests.csproj
|
||||
```
|
||||
|
||||
模拟 HTTP 测试覆盖登录路由、Bearer、分页序列化与解析、输入边界、业务错误、无效 JSON、403、HTTP 与业务 401、退出及主动取消,不会调用真实服务。
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="RestSharp" Version="112.1.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,29 @@
|
||||
using Store.Management.Api.Authentication;
|
||||
using Store.Management.Api.Transport;
|
||||
using Store.Management.Api.Users;
|
||||
|
||||
namespace Store.Management.Api;
|
||||
|
||||
/// <summary>一个服务地址与一个用户会话对应一个实例;在应用容器中注册为单例。</summary>
|
||||
public sealed class StoreApiClient : IDisposable
|
||||
{
|
||||
public TokenSession Session { get; } = new();
|
||||
public ApiTransport Transport { get; }
|
||||
public IAuthApi Auth { get; }
|
||||
public IUserApi Users { get; }
|
||||
|
||||
/// <param name="httpClient">可选的外部 HTTP 客户端,由调用方管理其生命周期。</param>
|
||||
public StoreApiClient(ApiClientOptions options, HttpClient? httpClient = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
Transport = new ApiTransport(options, Session, httpClient);
|
||||
Auth = new AuthApi(Transport, Session);
|
||||
Users = new UserApi(Transport);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Session.Clear();
|
||||
Transport.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Net;
|
||||
|
||||
namespace Store.Management.Api.Transport;
|
||||
|
||||
public enum ApiErrorKind { Network, Timeout, Http, Business, InvalidResponse, Unauthorized }
|
||||
|
||||
public sealed class ApiException : Exception
|
||||
{
|
||||
public ApiErrorKind Kind { get; }
|
||||
public HttpStatusCode? StatusCode { get; }
|
||||
public int? Code { get; }
|
||||
public string? TraceId { get; }
|
||||
|
||||
public ApiException(ApiErrorKind kind, string message, HttpStatusCode? statusCode = null,
|
||||
int? code = null, string? traceId = null, Exception? innerException = null)
|
||||
: base(message, innerException)
|
||||
=> (Kind, StatusCode, Code, TraceId) = (kind, statusCode, code, traceId);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using RestSharp;
|
||||
using Store.Management.Api.Authentication;
|
||||
using Store.Management.Api.Models;
|
||||
|
||||
namespace Store.Management.Api.Transport;
|
||||
|
||||
/// <summary>唯一 HTTP 出口;模块仅指定相对路由、请求数据与响应类型。</summary>
|
||||
public sealed class ApiTransport : IDisposable
|
||||
{
|
||||
private readonly RestClient _client;
|
||||
private readonly TokenSession _session;
|
||||
private readonly string _version;
|
||||
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
||||
|
||||
internal ApiTransport(ApiClientOptions options, TokenSession session, HttpClient? httpClient)
|
||||
{
|
||||
options.Validate();
|
||||
_session = session;
|
||||
_version = options.Version;
|
||||
var settings = new RestClientOptions(new Uri(options.BaseUrl.AbsoluteUri.TrimEnd('/') + "/"))
|
||||
{
|
||||
Timeout = options.Timeout,
|
||||
FollowRedirects = false
|
||||
};
|
||||
_client = httpClient is null ? new RestClient(settings) : new RestClient(httpClient, settings, disposeHttpClient: false);
|
||||
}
|
||||
|
||||
public async Task<T?> SendAsync<T>(string route, Method method, object? body = null,
|
||||
IReadOnlyDictionary<string, string?>? query = null, bool authenticated = true,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(route) || route.StartsWith('/') || route.Contains("..") ||
|
||||
route.Contains(':') || route.Contains('?') || route.Contains('#') || route.Contains('\\'))
|
||||
throw new ArgumentException("接口必须使用相对路由,例如 user/GetAllUser。", nameof(route));
|
||||
if (body is not null) Validator.ValidateObject(body, new ValidationContext(body), true);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var token = authenticated ? _session.Token : null;
|
||||
if (authenticated && token is null) throw new ApiException(ApiErrorKind.Unauthorized, "请先登录。", code: 401);
|
||||
var request = new RestRequest($"{_version}/{route}", method);
|
||||
if (token is not null) request.AddHeader("Authorization", $"Bearer {token}");
|
||||
if (body is not null) request.AddJsonBody(body);
|
||||
if (query is not null)
|
||||
foreach (var pair in query)
|
||||
if (pair.Value is not null) request.AddQueryParameter(pair.Key, pair.Value);
|
||||
var response = await _client.ExecuteAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (response.ResponseStatus == ResponseStatus.TimedOut)
|
||||
throw new ApiException(ApiErrorKind.Timeout, "请求超时,请稍后重试。", innerException: response.ErrorException);
|
||||
if ((int)response.StatusCode == 0)
|
||||
throw new ApiException(ApiErrorKind.Network, "无法连接服务器,请检查网络与服务地址。", innerException: response.ErrorException);
|
||||
|
||||
ApiResult<T>? result = null;
|
||||
JsonException? parseError = null;
|
||||
try { result = JsonSerializer.Deserialize<ApiResult<T>>(response.Content ?? "", Json); }
|
||||
catch (JsonException ex) { parseError = ex; }
|
||||
if (response.StatusCode == HttpStatusCode.Unauthorized || result?.Code == 401)
|
||||
{
|
||||
_session.ClearIfCurrent(token);
|
||||
throw new ApiException(ApiErrorKind.Unauthorized, result?.Msg ?? "登录已失效,请重新登录。",
|
||||
response.StatusCode, 401, result?.TraceId);
|
||||
}
|
||||
if (!response.IsSuccessful)
|
||||
throw new ApiException(ApiErrorKind.Http, result?.Msg ?? $"服务器请求失败({(int)response.StatusCode})。",
|
||||
response.StatusCode, result?.Code, result?.TraceId);
|
||||
if (result is null)
|
||||
throw new ApiException(ApiErrorKind.InvalidResponse, "服务器响应格式无效。", response.StatusCode, innerException: parseError);
|
||||
if (result.Code != 200)
|
||||
throw new ApiException(ApiErrorKind.Business, result.Msg ?? "操作失败。", response.StatusCode, result.Code, result.TraceId);
|
||||
return result.Data;
|
||||
}
|
||||
|
||||
public async Task<T> GetRequiredAsync<T>(string route, Method method, object? body = null,
|
||||
IReadOnlyDictionary<string, string?>? query = null, bool authenticated = true, CancellationToken cancellationToken = default)
|
||||
where T : class
|
||||
=> await SendAsync<T>(route, method, body, query, authenticated, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new ApiException(ApiErrorKind.InvalidResponse, "服务器响应缺少 data。");
|
||||
|
||||
public void Dispose() => _client.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using RestSharp;
|
||||
using Store.Management.Api.Models;
|
||||
using Store.Management.Api.Transport;
|
||||
|
||||
namespace Store.Management.Api.Users;
|
||||
|
||||
public interface IUserApi
|
||||
{
|
||||
Task<PageResult<UserInfo>> SearchAsync(UserSearchRequest request, CancellationToken cancellationToken = default);
|
||||
Task<UserInfo> GetAsync(string? userName = null, CancellationToken cancellationToken = default);
|
||||
Task<UserInfo> CreateAsync(UserCreateRequest request, CancellationToken cancellationToken = default);
|
||||
Task<UserInfo> UpdateAsync(UserUpdateRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
internal sealed class UserApi(ApiTransport transport) : IUserApi
|
||||
{
|
||||
public Task<PageResult<UserInfo>> SearchAsync(UserSearchRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
return transport.GetRequiredAsync<PageResult<UserInfo>>("user/GetAllUser", Method.Post, request, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public Task<UserInfo> GetAsync(string? userName = null, CancellationToken cancellationToken = default)
|
||||
=> transport.GetRequiredAsync<UserInfo>("user/GetUser", Method.Post,
|
||||
query: new Dictionary<string, string?> { ["userName"] = userName }, cancellationToken: cancellationToken);
|
||||
|
||||
public Task<UserInfo> CreateAsync(UserCreateRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
return transport.GetRequiredAsync<UserInfo>("user/AddUser", Method.Post, request, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public Task<UserInfo> UpdateAsync(UserUpdateRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
return transport.GetRequiredAsync<UserInfo>("user/UpdateUser", Method.Post, request, cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Store.Management.Api.Models;
|
||||
|
||||
namespace Store.Management.Api.Users;
|
||||
|
||||
public sealed class UserSearchRequest : PageRequest
|
||||
{
|
||||
[StringLength(100)] public string? Keyword { get; set; }
|
||||
public int? GroupId { get; set; }
|
||||
[StringLength(20)] public string? ParentUserName { get; set; }
|
||||
}
|
||||
|
||||
public class UserEditRequest
|
||||
{
|
||||
[StringLength(50)] public string? NickName { get; set; }
|
||||
[StringLength(30)] public string? Mobile { get; set; }
|
||||
[StringLength(100)] public string? CompanyName { get; set; }
|
||||
[StringLength(255)] public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UserCreateRequest : UserEditRequest
|
||||
{
|
||||
[Required, StringLength(20)] public string UserName { get; set; } = "";
|
||||
[Required, StringLength(128, MinimumLength = 6)] public string Password { get; set; } = "";
|
||||
[StringLength(20)] public string? ParentUserName { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UserUpdateRequest : UserEditRequest
|
||||
{
|
||||
[Range(1, int.MaxValue)] public int Id { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>管理端当前使用的用户资料字段;不接收密码。</summary>
|
||||
public sealed class UserInfo
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public string UserName { get; init; } = "";
|
||||
public string? NickName { get; init; }
|
||||
public string? Mobile { get; init; }
|
||||
public string? CompanyName { get; init; }
|
||||
public string? Notes { get; init; }
|
||||
public string? ParentUserName { get; init; }
|
||||
public int GroupId { get; init; }
|
||||
public decimal Points { get; init; }
|
||||
public decimal? Balance { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using Store.Management.Framework.Configs;
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using System.Security;
|
||||
using System.Security.Principal;
|
||||
|
||||
namespace Store.Management.Framework;
|
||||
|
||||
/// <summary>当前进程的应用信息;目录与持久化设置分别由 FolderConfig 和设置服务管理。</summary>
|
||||
public static class Config
|
||||
{
|
||||
public static Guid SessionID { get; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>进程启动时间(本地时间),不受首次访问 Config 的时机影响。</summary>
|
||||
public static DateTime AppStartTime { get; } = GetAppStartTime();
|
||||
|
||||
public static string AppName => FolderConfig.AppName;
|
||||
public static string AppFullName => AppName;
|
||||
public static Version WindowsVersion => Environment.OSVersion.Version;
|
||||
|
||||
/// <summary>优先使用入口程序集文件版本;单文件发布等场景回退到程序集版本。</summary>
|
||||
public static Version Version { get; } = GetVersion();
|
||||
|
||||
/// <summary>入口程序集的编译配置,不使用是否附加调试器作为判断依据。</summary>
|
||||
public static bool IsRelease { get; } = !string.Equals(
|
||||
EntryAssembly.GetCustomAttribute<AssemblyConfigurationAttribute>()?.Configuration,
|
||||
"Debug", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public static bool IsAdministrator
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) return false;
|
||||
try
|
||||
{
|
||||
using WindowsIdentity identity = WindowsIdentity.GetCurrent();
|
||||
return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator);
|
||||
}
|
||||
catch (SecurityException) { return false; }
|
||||
catch (UnauthorizedAccessException) { return false; }
|
||||
}
|
||||
}
|
||||
|
||||
private static Assembly EntryAssembly => Assembly.GetEntryAssembly() ?? typeof(Config).Assembly;
|
||||
|
||||
public static string GetFullVersionString() =>
|
||||
$"{Version} - {(IsRelease ? "Release" : "Debug")}{(IsAdministrator ? " - 管理员" : string.Empty)}";
|
||||
|
||||
private static Version GetVersion()
|
||||
{
|
||||
string? fileVersion = EntryAssembly.GetCustomAttribute<AssemblyFileVersionAttribute>()?.Version;
|
||||
return System.Version.TryParse(fileVersion, out Version? version)
|
||||
? version : EntryAssembly.GetName().Version ?? new Version(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
private static DateTime GetAppStartTime()
|
||||
{
|
||||
using Process process = Process.GetCurrentProcess();
|
||||
return process.StartTime;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using MAX.Framework.Interfaces;
|
||||
using System.IO;
|
||||
|
||||
namespace Store.Management.Framework.Configs;
|
||||
|
||||
/// <summary>应用目录约定。路径读取不创建目录,保存配置时自动创建所需目录。</summary>
|
||||
public sealed class FolderConfig : IFolderConfig
|
||||
{
|
||||
/// <summary>供启动配置使用;容器也可通过 IFolderConfig 解析目录配置。</summary>
|
||||
public static FolderConfig Instance { get; } = new();
|
||||
|
||||
public FolderConfig() { }
|
||||
|
||||
string IFolderConfig.ConfigFolder => ConfigFolder;
|
||||
string IFolderConfig.DataFolder => DataFolder;
|
||||
string IFolderConfig.LoggingFolder => LoggingFolder;
|
||||
string IFolderConfig.CacheFolder => CacheFolder;
|
||||
|
||||
public const string AppName = "Store.Management";
|
||||
|
||||
/// <summary>安装目录,用于读取随程序分发的资源。</summary>
|
||||
public static string ExeFolder => AppContext.BaseDirectory;
|
||||
|
||||
/// <summary>当前用户的数据根目录:%LOCALAPPDATA%\Store.Management。</summary>
|
||||
public static string AppFolder => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), AppName);
|
||||
|
||||
public static string ConfigFolder => Path.Combine(AppFolder, "Config");
|
||||
public static string DataFolder => Path.Combine(AppFolder, "Data");
|
||||
public static string LoggingFolder => Path.Combine(AppFolder, "Logs");
|
||||
public static string DatabaseFolder => Path.Combine(AppFolder, "Db");
|
||||
public static string CacheFolder => Path.Combine(AppFolder, "Cache");
|
||||
public static string AppSettingsFilePath => GetSettingsFilePath("AppSettings.json");
|
||||
|
||||
/// <summary>获取配置文件路径,仅接受文件名,防止写出配置目录。</summary>
|
||||
public static string GetSettingsFilePath(string fileName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
|
||||
if (fileName is "." or ".." || fileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0
|
||||
|| fileName.EndsWith('.') || fileName.EndsWith(' '))
|
||||
throw new ArgumentException("配置文件名不能包含路径、非法字符或以点、空格结尾。", nameof(fileName));
|
||||
return Path.Combine(ConfigFolder, fileName);
|
||||
}
|
||||
|
||||
/// <summary>按需在宿主启动时创建应用目录;创建失败向调用者报告。</summary>
|
||||
public static void Initialize()
|
||||
{
|
||||
foreach (string path in new[] { ConfigFolder, DataFolder, LoggingFolder, DatabaseFolder, CacheFolder })
|
||||
Directory.CreateDirectory(path);
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,12 @@ public partial class DialogWindow : WindowBase, IDialogWindow
|
||||
|
||||
protected override void OnContentChanged(object oldContent, object newContent)
|
||||
{
|
||||
// InitializeComponent 设置初始内容时,窗口样式尚未加载。
|
||||
if (!isConstructorSuccessful)
|
||||
{
|
||||
base.OnContentChanged(oldContent, newContent);
|
||||
return;
|
||||
}
|
||||
this.Style = dialogWindowStyle;
|
||||
if (newContent is UserControl control)
|
||||
{
|
||||
@@ -124,9 +130,9 @@ public partial class DialogWindow : WindowBase, IDialogWindow
|
||||
|
||||
protected override void OnStyleChanged(Style oldStyle, Style newStyle)
|
||||
{
|
||||
if (newStyle.TargetType == typeof(Window))
|
||||
if (newStyle?.TargetType == typeof(Window))
|
||||
{
|
||||
if (isConstructorSuccessful)
|
||||
if (isConstructorSuccessful && !ReferenceEquals(dialogWindowStyle, newStyle))
|
||||
{
|
||||
dialogWindowStyle.Merge(newStyle);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Data;
|
||||
|
||||
namespace Store.Management.Framework.Converters;
|
||||
|
||||
/// <summary>空字符串或 null 显示占位内容,其他值折叠;仅用于单向绑定。</summary>
|
||||
public sealed class StringNullOrEmpty2VisibilityConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
=> value is null || value is string { Length: 0 } ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
=> Binding.DoNothing;
|
||||
}
|
||||
@@ -1,51 +1,150 @@
|
||||
using Prism.Ioc;
|
||||
using Prism.Mvvm;
|
||||
using Prism.Services.Dialogs;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using Store.Management.Framework.Extensions;
|
||||
using MAX.Framework.Logger;
|
||||
using NLog;
|
||||
using Prism.Autofac;
|
||||
using Prism.Ioc;
|
||||
using Prism.Services.Dialogs;
|
||||
using Store.Management.Framework.Configs;
|
||||
using Store.Management.Framework.Extensions;
|
||||
using Store.Management.Framework.Services;
|
||||
using System.Threading;
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
|
||||
namespace Store.Management.Framework;
|
||||
|
||||
public abstract class FrameworkApplication : PrismApplication
|
||||
{
|
||||
private Mutex? _mutex;
|
||||
private bool _ownsMutex;
|
||||
private int _fatalHandling;
|
||||
private int _shutdownRequested;
|
||||
public static bool ShuttingDown { get; private set; }
|
||||
public static bool IsAppInitialized { get; private set; }
|
||||
public static event Action<Exception>? FatalErrorRaised;
|
||||
|
||||
protected FrameworkApplication()
|
||||
{
|
||||
protected FrameworkApplication()
|
||||
{
|
||||
DispatcherUnhandledException += OnDispatcherUnhandledException;
|
||||
AppDomain.CurrentDomain.UnhandledException += OnDomainUnhandledException;
|
||||
TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
|
||||
}
|
||||
|
||||
}
|
||||
/// <summary>返回 null 可允许多开;默认限制同一登录会话中的应用实例。</summary>
|
||||
public virtual string? GetMutexName() => @"Local\Store.Management";
|
||||
|
||||
protected override void OnStartup(StartupEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
//string? name = GetMutexName();
|
||||
//if (!string.IsNullOrWhiteSpace(name))
|
||||
//{
|
||||
// _mutex = new Mutex(true, name, out _ownsMutex);
|
||||
// if (!_ownsMutex)
|
||||
// {
|
||||
// MessageBox.Show("程序已在运行。", Config.AppName, MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
// Shutdown();
|
||||
// return;
|
||||
// }
|
||||
//}
|
||||
FolderConfig.Initialize();
|
||||
base.OnStartup(e);
|
||||
ApplicationLogService.Initialize();
|
||||
NLogger.Instance.Info($"软件启动:{Config.AppFullName},版本:{Config.GetFullVersionString()},会话:{Config.SessionID},启动时间:{Config.AppStartTime:O},Windows:{Config.WindowsVersion}");
|
||||
DISource.Resolver = Resolve;
|
||||
}
|
||||
catch (Exception ex) { Fatal(ex); }
|
||||
}
|
||||
|
||||
protected override void OnStartup(StartupEventArgs e)
|
||||
{
|
||||
DISource.Resolver = Resolve;
|
||||
base.OnStartup(e);
|
||||
}
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
base.OnInitialized();
|
||||
IsAppInitialized = true;
|
||||
NLogger.Instance.Info("软件初始化成功,进入主页面");
|
||||
}
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
(Container as IContainerExtension).RegisterDialog<DialogWindow>();
|
||||
base.OnInitialized();
|
||||
}
|
||||
protected override void RegisterRequiredTypes(IContainerRegistry registry)
|
||||
{
|
||||
base.RegisterRequiredTypes(registry);
|
||||
registry.RegisterDialogWindow<Controls.DialogWindow>();
|
||||
}
|
||||
|
||||
private object Resolve(Type type, string name)
|
||||
{
|
||||
if (type == null || Container is null) return null!;
|
||||
return name != null ? Container.Resolve(type, name) : Container.Resolve(type);
|
||||
}
|
||||
|
||||
private object Resolve(Type type, string name)
|
||||
{
|
||||
if (type == null || Container is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
private void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e)
|
||||
{
|
||||
e.Handled = true;
|
||||
Fatal(e.Exception);
|
||||
}
|
||||
|
||||
if (name != null)
|
||||
{
|
||||
return Container.Resolve(type, name);
|
||||
}
|
||||
private void OnDomainUnhandledException(object sender, UnhandledExceptionEventArgs e)
|
||||
{
|
||||
Exception exception = e.ExceptionObject as Exception ?? new Exception(e.ExceptionObject?.ToString());
|
||||
// CLR 终止期间同步记录,不等待 UI 线程。
|
||||
if (e.IsTerminating) ApplicationLogService.WriteFatal(exception, "AppDomain.UnhandledException");
|
||||
else Fatal(exception);
|
||||
}
|
||||
|
||||
return Container.Resolve(type);
|
||||
}
|
||||
private void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e)
|
||||
{
|
||||
ApplicationLogService.WriteFatal(e.Exception, "TaskScheduler.UnobservedTaskException");
|
||||
e.SetObserved();
|
||||
}
|
||||
|
||||
/// <summary>先同步保存报告,再通知订阅者并关闭应用。</summary>
|
||||
public static void Fatal(Exception exception)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(exception);
|
||||
string? path = ApplicationLogService.WriteFatal(exception, "Fatal");
|
||||
if (Current is not FrameworkApplication app || Interlocked.Exchange(ref app._fatalHandling, 1) != 0) return;
|
||||
foreach (Action<Exception> handler in FatalErrorRaised?.GetInvocationList() ?? [])
|
||||
{
|
||||
try { handler(exception); }
|
||||
catch (Exception ex) { ApplicationLogService.WriteFatal(ex, "FatalErrorRaised"); }
|
||||
}
|
||||
void ShowErrorAndShutdown()
|
||||
{
|
||||
try
|
||||
{
|
||||
MessageBox.Show($"软件发生致命错误,即将关闭。\n{exception.Message}\n\n日志:{path ?? "写入失败,请查看调试输出"}",
|
||||
Config.AppName, MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
finally { TotalShutdown(-1); }
|
||||
}
|
||||
if (app.Dispatcher.CheckAccess()) ShowErrorAndShutdown();
|
||||
else if (!app.Dispatcher.HasShutdownStarted) app.Dispatcher.BeginInvoke((Action)ShowErrorAndShutdown);
|
||||
}
|
||||
|
||||
/// <summary>通过 WPF 正常退出,执行退出清理。</summary>
|
||||
public static void TotalShutdown(int exitCode = 0)
|
||||
{
|
||||
if (Current is not FrameworkApplication app || Interlocked.Exchange(ref app._shutdownRequested, 1) != 0) return;
|
||||
ShuttingDown = true;
|
||||
if (app.Dispatcher.CheckAccess()) app.Shutdown(exitCode);
|
||||
else if (!app.Dispatcher.HasShutdownStarted) app.Dispatcher.BeginInvoke((Action)(() => app.Shutdown(exitCode)));
|
||||
}
|
||||
|
||||
protected override void OnExit(ExitEventArgs e)
|
||||
{
|
||||
ShuttingDown = true;
|
||||
try { base.OnExit(e); }
|
||||
finally
|
||||
{
|
||||
DispatcherUnhandledException -= OnDispatcherUnhandledException;
|
||||
AppDomain.CurrentDomain.UnhandledException -= OnDomainUnhandledException;
|
||||
TaskScheduler.UnobservedTaskException -= OnUnobservedTaskException;
|
||||
DISource.Resolver = null!;
|
||||
try { if (_ownsMutex) _mutex?.ReleaseMutex(); }
|
||||
finally
|
||||
{
|
||||
_mutex?.Dispose();
|
||||
try { NLogger.Instance.Info($"软件关闭,退出码:{e.ApplicationExitCode}"); }
|
||||
finally { LogManager.Shutdown(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<ResourceDictionary
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:sys="clr-namespace:System;assembly=mscorlib">
|
||||
<sys:Double x:Key="DefaultControlHeight">28</sys:Double>
|
||||
<sys:Double x:Key="SmallControlHeight">10</sys:Double>
|
||||
<Thickness x:Key="DefaultControlPadding">10,5</Thickness>
|
||||
<Thickness x:Key="DefaultInputPadding">8,0</Thickness>
|
||||
<CornerRadius x:Key="DefaultCornerRadius">2</CornerRadius>
|
||||
<sys:Double x:Key="{x:Static SystemParameters.VerticalScrollBarButtonHeightKey}">30</sys:Double>
|
||||
<sys:Double x:Key="LargeFontSize">24</sys:Double>
|
||||
<sys:Double x:Key="HeadFontSize">20</sys:Double>
|
||||
<sys:Double x:Key="SubHeadFontSize">16</sys:Double>
|
||||
<sys:Double x:Key="TextFontSize">12</sys:Double>
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,60 @@
|
||||
using MAX.Framework.Logger;
|
||||
using NLog;
|
||||
using NLog.Config;
|
||||
using NLog.Targets;
|
||||
using Store.Management.Framework.Configs;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace Store.Management.Framework.Services;
|
||||
|
||||
/// <summary>默认运行日志与不依赖容器的同步崩溃报告。</summary>
|
||||
public static class ApplicationLogService
|
||||
{
|
||||
public static void Initialize()
|
||||
{
|
||||
// 在加载文件配置前注入目录,配置自动重载时仍可读取。
|
||||
GlobalDiagnosticsContext.Set("LoggingFolder", FolderConfig.LoggingFolder);
|
||||
// 尊重宿主提供的 NLog 配置。
|
||||
if (LogManager.Configuration != null) return;
|
||||
var target = new FileTarget("application")
|
||||
{
|
||||
FileName = Path.Combine(FolderConfig.LoggingFolder, "${shortdate}.log"),
|
||||
Layout = "${longdate}|${level:uppercase=true}|${logger}|${message} ${exception:format=tostring}",
|
||||
Encoding = Encoding.UTF8,
|
||||
MaxArchiveDays = 30
|
||||
};
|
||||
var configuration = new LoggingConfiguration();
|
||||
configuration.AddRule(LogLevel.Debug, LogLevel.Fatal, target);
|
||||
LogManager.Configuration = configuration;
|
||||
}
|
||||
|
||||
/// <returns>报告路径;所有目录均写入失败时返回 null。</returns>
|
||||
public static string? WriteFatal(Exception exception, string source)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(exception);
|
||||
string id = $"{DateTime.UtcNow:yyyyMMdd-HHmmssfff}-{Guid.NewGuid():N}";
|
||||
string report = $"Id: {id}\nTime: {DateTimeOffset.Now:O}\nSource: {source}\nApp: {Config.AppFullName}\nVersion: {Config.Version}\nSession: {Config.SessionID}\nStarted: {Config.AppStartTime:O}\nWindows: {Config.WindowsVersion}\n{exception}";
|
||||
Debug.WriteLine(report);
|
||||
string? savedPath = null;
|
||||
foreach (string directory in new[] { FolderConfig.LoggingFolder, Path.Combine(Path.GetTempPath(), FolderConfig.AppName, "Logs") })
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
savedPath = Path.Combine(directory, $"fatal-{id}.log");
|
||||
File.WriteAllText(savedPath, report, Encoding.UTF8);
|
||||
break;
|
||||
}
|
||||
catch (Exception ex) { savedPath = null; Debug.WriteLine(ex); }
|
||||
}
|
||||
try
|
||||
{
|
||||
LogManager.GetLogger("GlobalLogger").Fatal(exception, "{0}: {1}; Report: {2}", source, id, savedPath);
|
||||
LogManager.Flush(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
catch (Exception ex) { Debug.WriteLine(ex); }
|
||||
return savedPath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using System.IO;
|
||||
using Newtonsoft.Json;
|
||||
using Prism.Mvvm;
|
||||
using Store.Management.Framework.Configs;
|
||||
|
||||
namespace Store.Management.Framework.Services;
|
||||
|
||||
public interface ISettingsServiceBase<T> where T : class
|
||||
{
|
||||
T Model { get; }
|
||||
string FilePath { get; }
|
||||
T GetDefaultModel();
|
||||
void LoadSettings();
|
||||
Task LoadSettingsAsync();
|
||||
void SaveSettings();
|
||||
Task SaveSettingsAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JSON 配置服务。缺失文件保留当前配置,加载失败保留旧模型并抛出异常。
|
||||
/// 保存通过同目录临时文件替换目标;模型变更应在所属 UI 线程调用。
|
||||
/// </summary>
|
||||
public abstract class JsonSettingsServiceBase<T> : BindableBase, ISettingsServiceBase<T> where T : class
|
||||
{
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private T? _model;
|
||||
/// <summary>默认按模型类型名保存到用户配置目录,派生类可覆盖文件名或完整路径。</summary>
|
||||
public virtual string FilePath => FolderConfig.GetSettingsFilePath(typeof(T).Name + ".json");
|
||||
public abstract T GetDefaultModel();
|
||||
public T Model
|
||||
{
|
||||
get => _model ??= GetDefaultModel() ?? throw new InvalidOperationException("默认配置不能为空。");
|
||||
protected set
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
SetProperty(ref _model, value);
|
||||
}
|
||||
}
|
||||
|
||||
public void LoadSettings()
|
||||
{
|
||||
T? loaded = LoadModelAsync().GetAwaiter().GetResult();
|
||||
if (loaded is not null) Model = loaded;
|
||||
}
|
||||
public void SaveSettings() => SaveSettingsAsync().GetAwaiter().GetResult();
|
||||
|
||||
public async Task LoadSettingsAsync()
|
||||
{
|
||||
T? loaded = await LoadModelAsync();
|
||||
if (loaded is not null) Model = loaded;
|
||||
}
|
||||
|
||||
private async Task<T?> LoadModelAsync()
|
||||
{
|
||||
T? loaded = null;
|
||||
await _gate.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
string path = GetFullPath();
|
||||
try
|
||||
{
|
||||
string json = await File.ReadAllTextAsync(path).ConfigureAwait(false);
|
||||
loaded = JsonConvert.DeserializeObject<T>(json)
|
||||
?? throw new JsonSerializationException($"配置文件不能为 null:{path}");
|
||||
}
|
||||
catch (FileNotFoundException) { }
|
||||
catch (DirectoryNotFoundException) { }
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
return loaded;
|
||||
}
|
||||
|
||||
public async Task SaveSettingsAsync()
|
||||
{
|
||||
// 在首次等待前读取模型,避免在后台线程初始化默认模型。
|
||||
string json = JsonConvert.SerializeObject(Model, Formatting.Indented);
|
||||
await _gate.WaitAsync().ConfigureAwait(false);
|
||||
string? temporaryPath = null;
|
||||
try
|
||||
{
|
||||
string path = GetFullPath();
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
temporaryPath = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||
await File.WriteAllTextAsync(temporaryPath, json).ConfigureAwait(false);
|
||||
File.Move(temporaryPath, path, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (temporaryPath is not null && File.Exists(temporaryPath)) File.Delete(temporaryPath); }
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
}
|
||||
|
||||
private string GetFullPath()
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(FilePath);
|
||||
return Path.GetFullPath(FilePath);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<UseWPF>true</UseWPF>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="HandyControl" Version="3.6.0.9" />
|
||||
<Content Remove="C:\Users\chaih\.nuget\packages\handycontrol\3.6.2.1\contentFiles\any\net8.0-windows7.0\LangProvider.tt" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="HandyControl" Version="3.6.2.1" />
|
||||
<ProjectReference Include="..\Core\MAX.Core\MAX.Core.csproj" />
|
||||
<ProjectReference Include="..\Core\MAX.Framework\MAX.Framework.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace Store.Management.Framework.Utilities;
|
||||
|
||||
/// <summary>按订阅顺序等待异步处理器;异常向调用者传播并停止后续处理。</summary>
|
||||
public sealed class AsyncEvent<TEventArgs> where TEventArgs : EventArgs
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly List<Func<object?, TEventArgs, Task>> _handlers = [];
|
||||
|
||||
public void Subscribe(Func<object?, TEventArgs, Task> callback)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(callback);
|
||||
lock (_gate) { _handlers.Add(callback); }
|
||||
}
|
||||
|
||||
public void Unsubscribe(Func<object?, TEventArgs, Task> callback)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(callback);
|
||||
lock (_gate)
|
||||
{
|
||||
int index = _handlers.LastIndexOf(callback);
|
||||
if (index >= 0) _handlers.RemoveAt(index);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(object? sender, TEventArgs eventArgs)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(eventArgs);
|
||||
Func<object?, TEventArgs, Task>[] snapshot;
|
||||
lock (_gate) { snapshot = _handlers.ToArray(); }
|
||||
foreach (var callback in snapshot) await callback(sender, eventArgs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Store.Management.Framework.Utilities;
|
||||
|
||||
/// <summary>在释放时执行一次清理操作,可安全地重复或并发释放。</summary>
|
||||
public sealed class DisposeAction : IDisposable
|
||||
{
|
||||
private Action? _action;
|
||||
|
||||
public DisposeAction(Action action)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(action);
|
||||
_action = action;
|
||||
}
|
||||
|
||||
public void Dispose() => Interlocked.Exchange(ref _action, null)?.Invoke();
|
||||
}
|
||||
@@ -1,9 +1,22 @@
|
||||
<mf:FrameworkApplication
|
||||
<mf:FrameworkApplication
|
||||
x:Class="Store.Management.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:Store.Management"
|
||||
xmlns:mf="Store.Management.Framework"
|
||||
StartupUri="MainWindow.xaml">
|
||||
<mf:FrameworkApplication.Resources />
|
||||
</mf:FrameworkApplication>
|
||||
xmlns:mf="clr-namespace:Store.Management.Framework;assembly=Store.Management.Framework">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<SolidColorBrush x:Key="PrimaryBrush" Color="#3267C8" />
|
||||
<SolidColorBrush x:Key="TextIconBrush" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="BackgroundBrush" Color="#FFFFFF" />
|
||||
<Style x:Key="DialogWindowStyle" TargetType="Window">
|
||||
<Setter Property="ResizeMode" Value="NoResize" />
|
||||
</Style>
|
||||
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="pack://application:,,,/Store.Management.Framework;component/Resources/HandyControlBasic.xaml" />
|
||||
<ResourceDictionary Source="pack://application:,,,/HandyControl;component/Themes/SkinDefault.xaml" />
|
||||
<ResourceDictionary Source="pack://application:,,,/HandyControl;component/Themes/Theme.xaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</mf:FrameworkApplication>
|
||||
@@ -1,29 +1,29 @@
|
||||
using Prism.Ioc;
|
||||
using Prism.Ioc;
|
||||
using MAX.Notification;
|
||||
using Store.Management.Notifications;
|
||||
using Store.Management.Framework;
|
||||
using System.Configuration;
|
||||
using System.Data;
|
||||
using Store.Management.Services;
|
||||
using Store.Management.ViewModels;
|
||||
using Store.Management.Views;
|
||||
using System.Windows;
|
||||
|
||||
namespace Store.Management
|
||||
namespace Store.Management;
|
||||
public partial class App : FrameworkApplication
|
||||
{
|
||||
/// <summary>
|
||||
/// Interaction logic for App.xaml
|
||||
/// </summary>
|
||||
public partial class App : FrameworkApplication
|
||||
{
|
||||
protected override void OnStartup(StartupEventArgs e)
|
||||
{
|
||||
base.OnStartup(e);
|
||||
}
|
||||
protected override Window CreateShell()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
protected override void RegisterTypes(IContainerRegistry containerRegistry)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
protected override Window CreateShell() => Container.Resolve<MainWindow>();
|
||||
protected override void RegisterTypes(IContainerRegistry registry)
|
||||
{
|
||||
registry.RegisterSingleton<SingletonProbe>();
|
||||
registry.Register<TransientProbe>();
|
||||
registry.RegisterSingleton<NotificationExampleState>();
|
||||
registry.Register<IPublisher>(provider => new NotificationPublisher(
|
||||
new PrismNotificationServiceProvider(provider)));
|
||||
// 显式提供处理器集合,展示一条通知分发给多个业务模块。
|
||||
registry.Register<IEnumerable<INotificationHandler<ProductSavedNotification>>>(provider =>
|
||||
new INotificationHandler<ProductSavedNotification>[]
|
||||
{
|
||||
new ProductCacheHandler(provider.Resolve<NotificationExampleState>()),
|
||||
new ProductAuditHandler(provider.Resolve<NotificationExampleState>())
|
||||
});
|
||||
registry.RegisterDialog<FrameworkTestDialog, FrameworkTestDialogViewModel>();
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
<Window x:Class="Store.Management.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:local="clr-namespace:Store.Management"
|
||||
mc:Ignorable="d"
|
||||
Title="MainWindow" Height="450" Width="800">
|
||||
<Grid>
|
||||
|
||||
</Grid>
|
||||
</Window>
|
||||
<Window
|
||||
x:Class="Store.Management.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:views="clr-namespace:Store.Management.Views"
|
||||
Title="Store.Management · 框架能力测试"
|
||||
Width="960"
|
||||
Height="720"
|
||||
MinWidth="720"
|
||||
MinHeight="560"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
<views:FrameworkTestPage />
|
||||
</Window>
|
||||
@@ -1,24 +1,6 @@
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Data;
|
||||
using System.Windows.Documents;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Navigation;
|
||||
using System.Windows.Shapes;
|
||||
|
||||
namespace Store.Management
|
||||
namespace Store.Management;
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
/// <summary>
|
||||
/// Interaction logic for MainWindow.xaml
|
||||
/// </summary>
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
}
|
||||
public MainWindow() => InitializeComponent();
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<nlog
|
||||
xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.nlog-project.org/schemas/NLog.xsd NLog.xsd"
|
||||
autoReload="true"
|
||||
throwExceptions="false"
|
||||
internalLogLevel="Off">
|
||||
|
||||
<!-- optional, add some variables
|
||||
https://github.com/nlog/NLog/wiki/Configuration-file#variables
|
||||
-->
|
||||
|
||||
<variable name="logdir" value="${gdc:item=LoggingFolder}" />
|
||||
|
||||
<targets async="true">
|
||||
<!--Type输出类型:
|
||||
Console 输出到控制台
|
||||
Debugger 输出到VS输出窗口
|
||||
File 输出到文件
|
||||
Mail 输出为邮件发送
|
||||
Network 输出到网络地址
|
||||
Database 输出到数据库-->
|
||||
|
||||
<target xsi:type="File" name="Fatal"
|
||||
fileName="${logdir}/${date:format=yyyyMM}/Fatal/${shortdate}.log"
|
||||
layout="${longdate}|${level}|${logger}|${callsite}|${message}|${exception:format=tostring}"
|
||||
/>
|
||||
<target xsi:type="File" name="Error"
|
||||
fileName="${logdir}/${date:format=yyyyMM}/Error/${shortdate}.log"
|
||||
layout="${longdate}|${level}|${logger}|${callsite}|${message}|${exception:format=tostring}"
|
||||
/>
|
||||
<target xsi:type="File" name="Info" fileName="${logdir}/${date:format=yyyyMM}/Info/${shortdate}.log"/>
|
||||
<target xsi:type="File" name="Debug" fileName="${logdir}/${date:format=yyyyMM}/Debug/${shortdate}.log"/>
|
||||
</targets>
|
||||
|
||||
<rules>
|
||||
<!--NLog支持如下几种记录等级:
|
||||
1.Trace - 最常见的记录信息,一般用于普通输出
|
||||
2.Debug - 同样是记录信息,不过出现的频率要比Trace少一些,一般用来调试程序
|
||||
3.Info - 信息类型的消息
|
||||
4.Warn - 警告信息,一般用于比较重要的场合
|
||||
5.Error - 错误信息
|
||||
6.Fatal - 致命异常信息。一般来讲,发生致命异常之后程序将无法继续执行。-->
|
||||
<logger name="*" level="Fatal" writeTo="Fatal" />
|
||||
<logger name="*" level="Error" writeTo="Error" />
|
||||
<logger name="*" level="Warn" writeTo="Error" />
|
||||
<logger name="*" level="Info" writeTo="Info" />
|
||||
<logger name="*" level="Debug" writeTo="Debug" />
|
||||
<!--<logger name="*" level="Trace" writeTo="Trace" />-->
|
||||
<!--将所有记录的信息输出至控制台-->
|
||||
<!--<logger name="*" writeTo="Debugger" />-->
|
||||
<!--<logger name="*" minlevel="Debug" writeTo="Debugger" />
|
||||
此处增加了一个minlevel="Warn",规则就变成了将所有记录等级大于等于Warn的等级信息输出至控制台-->
|
||||
<!--<logger name="*" minlevel="Debug" writeTo="csv" />-->
|
||||
</rules>
|
||||
</nlog>
|
||||
@@ -0,0 +1,54 @@
|
||||
using MAX.Notification;
|
||||
using Prism.Ioc;
|
||||
|
||||
namespace Store.Management.Notifications;
|
||||
|
||||
/// <summary>保存示例处理结果,由发布方等待完成后读取。</summary>
|
||||
public sealed class NotificationExampleState
|
||||
{
|
||||
private readonly object gate = new();
|
||||
private readonly List<string> entries = [];
|
||||
|
||||
public void Record(string message)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
entries.Add(message);
|
||||
if (entries.Count > 10) entries.RemoveAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
public string GetHistory()
|
||||
{
|
||||
lock (gate) return string.Join(Environment.NewLine, entries);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>模拟模块一:商品保存后刷新缓存。</summary>
|
||||
public sealed class ProductCacheHandler(NotificationExampleState state)
|
||||
: INotificationHandler<ProductSavedNotification>
|
||||
{
|
||||
public Task Handle(ProductSavedNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
state.Record($"缓存模块:已刷新商品「{notification.ProductName}」。");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>模拟模块二:异步记录操作日志。</summary>
|
||||
public sealed class ProductAuditHandler(NotificationExampleState state)
|
||||
: INotificationHandler<ProductSavedNotification>
|
||||
{
|
||||
public async Task Handle(ProductSavedNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Delay(200, cancellationToken).ConfigureAwait(false);
|
||||
state.Record($"审计模块:已记录商品「{notification.ProductName}」的保存操作。");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>将标准服务解析转交给当前 Prism 容器。</summary>
|
||||
internal sealed class PrismNotificationServiceProvider(IContainerProvider container) : IServiceProvider
|
||||
{
|
||||
public object? GetService(Type serviceType) => container.Resolve(serviceType);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using MAX.Notification;
|
||||
|
||||
namespace Store.Management.Notifications;
|
||||
|
||||
/// <summary>商品保存后发布的业务通知;不持有页面或控件实例。</summary>
|
||||
public sealed record ProductSavedNotification(string ProductName) : INotification;
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace Store.Management.Services;
|
||||
/// <summary>通过实例标识观察容器生命周期。</summary>
|
||||
public sealed class SingletonProbe { public Guid InstanceId { get; } = Guid.NewGuid(); }
|
||||
public sealed class TransientProbe { public Guid InstanceId { get; } = Guid.NewGuid(); }
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWPF>true</UseWPF>
|
||||
@@ -10,6 +9,13 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Store.Management.Framework\Store.Management.Framework.csproj" />
|
||||
<ProjectReference Include="..\Store.Management.Api\Store.Management.Api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="NLog.config">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,23 @@
|
||||
using MAX.Framework.Controls;
|
||||
using Prism.Services.Dialogs;
|
||||
namespace Store.Management.ViewModels;
|
||||
public sealed class FrameworkTestDialogViewModel : ConfirmDialogViewModel
|
||||
{
|
||||
private string message = "";
|
||||
public FrameworkTestDialogViewModel() => Title = "框架弹窗测试";
|
||||
public string Message { get => message; set => SetProperty(ref message, value); }
|
||||
public override void OnDialogOpened(IDialogParameters parameters)
|
||||
{
|
||||
Message = parameters.GetValue<string>("message") ?? "";
|
||||
ErrorMsg = "";
|
||||
}
|
||||
protected override DialogResult Confirm()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Message))
|
||||
{
|
||||
ErrorMsg = "返回内容不能为空。";
|
||||
return null!; // 基类约定:null 表示保持弹窗打开。
|
||||
}
|
||||
return new DialogResult(ButtonResult.OK, new DialogParameters { { "message", Message.Trim() } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using Prism.Commands;
|
||||
using MAX.Notification;
|
||||
using Store.Management.Notifications;
|
||||
using Prism.Ioc;
|
||||
using Prism.Mvvm;
|
||||
using Prism.Services.Dialogs;
|
||||
using Store.Management.Services;
|
||||
using Store.Management.Views;
|
||||
namespace Store.Management.ViewModels;
|
||||
public sealed class FrameworkTestPageViewModel : BindableBase
|
||||
{
|
||||
private readonly IContainerProvider container;
|
||||
private readonly SingletonProbe singleton;
|
||||
private readonly TransientProbe transient;
|
||||
private readonly IDialogService dialogs;
|
||||
private readonly IPublisher publisher;
|
||||
private readonly NotificationExampleState notificationState;
|
||||
private string productName = "示例商品";
|
||||
private string notificationResult = "输入商品名称,发布后查看两个模块的处理结果。";
|
||||
private string iocResult = "点击按钮查看实例 ID 与测试结果。";
|
||||
private string dialogInput = "来自主页面的测试参数";
|
||||
private string dialogResult = "等待弹窗返回。";
|
||||
public FrameworkTestPageViewModel(IContainerProvider container, SingletonProbe singleton, TransientProbe transient, IDialogService dialogs,
|
||||
IPublisher publisher, NotificationExampleState notificationState)
|
||||
{
|
||||
this.container = container;
|
||||
this.singleton = singleton;
|
||||
this.transient = transient;
|
||||
this.dialogs = dialogs;
|
||||
this.publisher = publisher;
|
||||
this.notificationState = notificationState;
|
||||
PublishNotificationCommand = new AsyncDelegateCommand(PublishNotificationAsync);
|
||||
TestIocCommand = new DelegateCommand(TestIoc);
|
||||
ShowModalCommand = new DelegateCommand(() => OpenDialog(true));
|
||||
ShowModelessCommand = new DelegateCommand(() => OpenDialog(false));
|
||||
}
|
||||
public DelegateCommand TestIocCommand { get; }
|
||||
public AsyncDelegateCommand PublishNotificationCommand { get; }
|
||||
public string ProductName { get => productName; set => SetProperty(ref productName, value); }
|
||||
public string NotificationResult { get => notificationResult; private set => SetProperty(ref notificationResult, value); }
|
||||
|
||||
private async Task PublishNotificationAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ProductName))
|
||||
{
|
||||
NotificationResult = "请输入商品名称。";
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
NotificationResult = "正在等待所有通知处理器完成……";
|
||||
await publisher.Publish(new ProductSavedNotification(ProductName.Trim()));
|
||||
// 回到页面的 UI 上下文后更新绑定;业务处理器不访问 ViewModel。
|
||||
NotificationResult = $"通知处理完成(显示最近 10 条记录):\n{notificationState.GetHistory()}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
NotificationResult = $"通知处理失败:{ex.GetBaseException().Message}";
|
||||
}
|
||||
}
|
||||
public DelegateCommand ShowModalCommand { get; }
|
||||
public DelegateCommand ShowModelessCommand { get; }
|
||||
public string IocResult { get => iocResult; private set => SetProperty(ref iocResult, value); }
|
||||
public string DialogInput { get => dialogInput; set => SetProperty(ref dialogInput, value); }
|
||||
public string DialogResult { get => dialogResult; private set => SetProperty(ref dialogResult, value); }
|
||||
private void TestIoc()
|
||||
{
|
||||
try
|
||||
{
|
||||
var resolved = container.Resolve<SingletonProbe>();
|
||||
var first = container.Resolve<TransientProbe>();
|
||||
var second = container.Resolve<TransientProbe>();
|
||||
bool passed = ReferenceEquals(singleton, resolved) && !ReferenceEquals(transient, first) && !ReferenceEquals(first, second);
|
||||
IocResult = $"{(passed ? "通过" : "失败")}\n构造注入单例:{singleton.InstanceId}\n再次解析单例:{resolved.InstanceId}\n构造注入瞬态:{transient.InstanceId}\n解析瞬态 A:{first.InstanceId}\n解析瞬态 B:{second.InstanceId}";
|
||||
}
|
||||
catch (Exception ex) { IocResult = $"IoC 测试失败:{ex.Message}"; }
|
||||
}
|
||||
private void OpenDialog(bool modal)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(DialogInput))
|
||||
{
|
||||
DialogResult = "请输入测试参数后再打开弹窗。";
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
var parameters = new DialogParameters { { "message", DialogInput.Trim() } };
|
||||
DialogResult = "弹窗已打开,等待操作。";
|
||||
void OnClosed(IDialogResult result) => DialogResult = result.Result == ButtonResult.OK
|
||||
? $"确认:{result.Parameters.GetValue<string>("message")}" : $"弹窗已关闭,结果:{result.Result}";
|
||||
if (modal) dialogs.ShowDialog(nameof(FrameworkTestDialog), parameters, OnClosed);
|
||||
else dialogs.Show(nameof(FrameworkTestDialog), parameters, OnClosed);
|
||||
}
|
||||
catch (Exception ex) { DialogResult = $"打开弹窗失败:{ex.GetBaseException().Message}"; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<f:UserControlBase x:Class="Store.Management.Views.FrameworkTestDialog" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:f="clr-namespace:Store.Management.Framework.Controls;assembly=Store.Management.Framework" Width="520" Height="300">
|
||||
<Grid Margin="24">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<TextBlock Text="编辑返回内容" FontSize="20" FontWeight="SemiBold" />
|
||||
<TextBox Height="36" Grid.Row="1" Text="{Binding Message, UpdateSourceTrigger=PropertyChanged}" MaxLength="200" Margin="0,16,0,12" />
|
||||
<TextBlock Grid.Row="2" Text="{Binding ErrorMsg}" Foreground="Firebrick" TextWrapping="Wrap" />
|
||||
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||
<Button Height="36" Content="取消(Esc)" Command="{Binding CancelCommand}" Padding="12,6" Margin="0,0,12,0" />
|
||||
<Button Height="36" Content="确认(Enter)" Command="{Binding ConfirmCommand}" Padding="12,6" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</f:UserControlBase>
|
||||
@@ -0,0 +1,6 @@
|
||||
using Store.Management.Framework.Controls;
|
||||
namespace Store.Management.Views;
|
||||
public partial class FrameworkTestDialog : UserControlBase
|
||||
{
|
||||
public FrameworkTestDialog() => InitializeComponent();
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<f:UserControlBase
|
||||
x:Class="Store.Management.Views.FrameworkTestPage"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:di="clr-namespace:Store.Management.Framework.Extensions;assembly=Store.Management.Framework"
|
||||
xmlns:f="clr-namespace:Store.Management.Framework.Controls;assembly=Store.Management.Framework"
|
||||
xmlns:s="clr-namespace:Store.Management.Services">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Margin="28">
|
||||
<TextBlock
|
||||
FontSize="26"
|
||||
FontWeight="SemiBold"
|
||||
Text="框架能力测试" />
|
||||
<TextBlock Margin="0,8,0,20" Text="验证 Prism / Autofac 注入、DISource 扩展及框架弹窗。" />
|
||||
<GroupBox Padding="16" Header="IoC · 生命周期与注入">
|
||||
<StackPanel>
|
||||
<TextBlock Text="构造函数注入与重复解析:单例应一致,瞬态应不同。" TextWrapping="Wrap" />
|
||||
<Button
|
||||
HorizontalAlignment="Left"
|
||||
Command="{Binding TestIocCommand}"
|
||||
Content="运行 IoC 测试" />
|
||||
<TextBlock Text="{Binding IocResult}" TextWrapping="Wrap" />
|
||||
<TextBlock Margin="0,16,0,4" Text="XAML DISource 注入单例 ID(应与上方一致):" />
|
||||
<TextBlock DataContext="{di:DISource Type={x:Type s:SingletonProbe}}" Text="{Binding InstanceId}" />
|
||||
</StackPanel>
|
||||
</GroupBox>
|
||||
<GroupBox
|
||||
Margin="0,20,0,0"
|
||||
Padding="16"
|
||||
Header="弹出框 · 参数与返回结果">
|
||||
<StackPanel>
|
||||
<TextBlock Text="输入传入弹窗的内容,编辑后确认并观察返回结果。Enter 确认,Esc 取消。" TextWrapping="Wrap" />
|
||||
<TextBox
|
||||
Height="36"
|
||||
Margin="0,12"
|
||||
MaxLength="200"
|
||||
Text="{Binding DialogInput, UpdateSourceTrigger=PropertyChanged}" />
|
||||
<WrapPanel>
|
||||
<Button
|
||||
Command="{Binding ShowModalCommand}"
|
||||
Content="打开模态弹窗"
|
||||
Style="{StaticResource ButtonDefault}" />
|
||||
<Button Command="{Binding ShowModelessCommand}" Content="打开非模态弹窗" />
|
||||
</WrapPanel>
|
||||
<TextBlock
|
||||
Margin="0,8,0,0"
|
||||
Text="{Binding DialogResult}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</GroupBox>
|
||||
<GroupBox Margin="0,20,0,0" Padding="16" Header="通知 · 一个发布方与两个处理器">
|
||||
<StackPanel>
|
||||
<TextBlock Text="模拟保存商品,依次通知缓存模块和审计模块;等待全部完成后显示结果,无须页面订阅事件。" TextWrapping="Wrap" />
|
||||
<TextBox Margin="0,12" MaxLength="100" Text="{Binding ProductName, UpdateSourceTrigger=PropertyChanged}" />
|
||||
<Button HorizontalAlignment="Left" Command="{Binding PublishNotificationCommand}" Content="发布商品保存通知" />
|
||||
<TextBlock Margin="0,8,0,0" Text="{Binding NotificationResult}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</GroupBox>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</f:UserControlBase>
|
||||
@@ -0,0 +1,6 @@
|
||||
using Store.Management.Framework.Controls;
|
||||
namespace Store.Management.Views;
|
||||
public partial class FrameworkTestPage : UserControlBase
|
||||
{
|
||||
public FrameworkTestPage() => InitializeComponent();
|
||||
}
|
||||
@@ -2,10 +2,17 @@
|
||||
<Folder Name="/Core/">
|
||||
<Project Path="Source/Core/MAX.Core/MAX.Core.csproj" />
|
||||
<Project Path="Source/Core/MAX.Framework/MAX.Framework.csproj" />
|
||||
<Project Path="Source/Core/MAX.Notification/MAX.Notification.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Tests/">
|
||||
<Project Path="Tests/MAX.Core.SmokeTests/MAX.Core.SmokeTests.csproj" />
|
||||
<Project Path="Tests/MAX.Notification.SmokeTests/MAX.Notification.SmokeTests.csproj" />
|
||||
<Project Path="Tests/Store.Management.Api.SmokeTests/Store.Management.Api.SmokeTests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/_SolutionItems/">
|
||||
<File Path="Directory.Build.props" />
|
||||
</Folder>
|
||||
<Project Path="Source/Store.Management.Api/Store.Management.Api.csproj" />
|
||||
<Project Path="Source/Store.Management.Framework/Store.Management.Framework.csproj" />
|
||||
<Project Path="Source/Store.Management/Store.Management.csproj" />
|
||||
</Solution>
|
||||
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../Source/Core/MAX.Core/MAX.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,54 @@
|
||||
using Autofac;
|
||||
using MAX.Core.Infrastructure;
|
||||
using MAX.Notification;
|
||||
|
||||
var builder = new ContainerBuilder();
|
||||
new MAX.Core.Startup().Register(builder);
|
||||
builder.RegisterType<ScopeState>().InstancePerLifetimeScope();
|
||||
using var container = builder.Build();
|
||||
using (var scope = container.BeginLifetimeScope())
|
||||
{
|
||||
var publisher = scope.Resolve<IPublisher>();
|
||||
var manager = scope.Resolve<MediatorManager>();
|
||||
Check(ReferenceEquals(publisher, manager), "发布接口和管理器应为同一实例");
|
||||
var state = scope.Resolve<ScopeState>();
|
||||
await publisher.Publish(new Changed());
|
||||
Check(state.Calls == 1, "程序集扫描和作用域内处理器解析");
|
||||
var dynamicHandler = new DynamicHandler<Changed>();
|
||||
manager.RegisterNotification(dynamicHandler);
|
||||
await publisher.Publish(new Changed());
|
||||
Check(state.Calls == 2 && dynamicHandler.Calls == 1, "容器和动态处理器均收到通知");
|
||||
using var otherScope = container.BeginLifetimeScope();
|
||||
var otherPublisher = otherScope.Resolve<IPublisher>();
|
||||
Check(!ReferenceEquals(publisher, otherPublisher), "不同作用域拥有不同发布器");
|
||||
await otherPublisher.Publish(new Changed());
|
||||
Check(otherScope.Resolve<ScopeState>().Calls == 1 && state.Calls == 2,
|
||||
"处理器使用所属作用域的依赖");
|
||||
Check(dynamicHandler.Calls == 1, "动态注册不泄漏到其他作用域");
|
||||
}
|
||||
Console.WriteLine("MAX.Core notification integration tests passed.");
|
||||
|
||||
static void Check(bool condition, string message)
|
||||
{
|
||||
if (!condition) throw new InvalidOperationException(message);
|
||||
}
|
||||
public sealed record Changed : INotification;
|
||||
public sealed class ScopeState { public int Calls { get; set; } }
|
||||
public sealed class ScopedHandler(ScopeState state) : INotificationHandler<Changed>
|
||||
{
|
||||
public Task Handle(Changed notification, CancellationToken cancellationToken)
|
||||
{
|
||||
state.Calls++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
// 保持开放泛型,避免被程序集扫描为封闭处理器;由测试显式注册实例。
|
||||
public class DynamicHandler<T> : INotificationHandler<T> where T : INotification
|
||||
{
|
||||
public int Calls { get; private set; }
|
||||
public Task Handle(T notification, CancellationToken cancellationToken)
|
||||
{
|
||||
Calls++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Remove="PolySharp" />
|
||||
<ProjectReference Include="../../Source/Core/MAX.Notification/MAX.Notification.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,86 @@
|
||||
using MAX.Notification.NotificationPublishers;
|
||||
using MAX.Notification;
|
||||
|
||||
var calls = new List<int>();
|
||||
var services = new TestServices();
|
||||
services.Handlers = [new First((_, _) => { calls.Add(1); return Task.CompletedTask; }),
|
||||
new First((_, _) => { calls.Add(99); return Task.CompletedTask; }),
|
||||
new Second((_, _) => { calls.Add(2); return Task.CompletedTask; })];
|
||||
var publisher = new NotificationPublisher(services);
|
||||
INotification message = new Changed();
|
||||
await publisher.Publish(message);
|
||||
Check(calls.SequenceEqual([1, 2]), "运行时类型分发、顺序和类型去重");
|
||||
calls.Clear();
|
||||
await publisher.Publish((object)message);
|
||||
Check(calls.SequenceEqual([1, 2]) && services.Resolutions == 2, "对象分发和每次重新解析");
|
||||
services.Handlers = [];
|
||||
await publisher.Publish(message);
|
||||
services.Handlers = null;
|
||||
await publisher.Publish(message);
|
||||
await Throws<ArgumentNullException>(() => publisher.Publish((object)null!));
|
||||
await Throws<ArgumentException>(() => publisher.Publish(new object()));
|
||||
await Throws<ArgumentNullException>(() => Task.FromResult(new NotificationPublisher(services, null!)));
|
||||
using var cancelled = new CancellationTokenSource();
|
||||
cancelled.Cancel();
|
||||
// 原版只传递取消令牌;没有处理器时即使已取消也正常完成。
|
||||
await publisher.Publish(message, cancelled.Token);
|
||||
|
||||
services.Handlers = [new First((_, _) => throw new InvalidOperationException("expected")),
|
||||
new Second((_, _) => { calls.Add(3); return Task.CompletedTask; })];
|
||||
calls.Clear();
|
||||
await Throws<InvalidOperationException>(() => publisher.Publish(message));
|
||||
Check(calls.Count == 0, "顺序模式失败后停止");
|
||||
await Throws<InvalidOperationException>(() => new NotificationPublisher(services,
|
||||
new TaskWhenAllPublisher()).Publish(message));
|
||||
Check(calls.Count == 0, "原版并发模式同步异常阻止后续枚举");
|
||||
|
||||
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var started = 0;
|
||||
Task Handle(Changed _, CancellationToken token)
|
||||
{
|
||||
Check(token == cancelled.Token || !token.IsCancellationRequested, "取消令牌传递");
|
||||
if (++started == 2) gate.SetResult();
|
||||
return gate.Task;
|
||||
}
|
||||
services.Handlers = [new First(Handle), new Second(Handle)];
|
||||
await new NotificationPublisher(services, new TaskWhenAllPublisher())
|
||||
.Publish(message).WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Check(started == 2, "并发启动和等待全部完成");
|
||||
|
||||
using var during = new CancellationTokenSource();
|
||||
services.Handlers = [new First((_, token) => { Check(token == during.Token, "原始令牌"); during.Cancel(); return Task.CompletedTask; }),
|
||||
new Second((_, token) => { token.ThrowIfCancellationRequested(); return Task.CompletedTask; })];
|
||||
await Throws<OperationCanceledException>(() => publisher.Publish(message, during.Token));
|
||||
Console.WriteLine("All notification smoke tests passed.");
|
||||
|
||||
static void Check(bool condition, string name)
|
||||
{
|
||||
if (!condition) throw new Exception($"检查失败:{name}");
|
||||
}
|
||||
static async Task Throws<T>(Func<Task> action) where T : Exception
|
||||
{
|
||||
try { await action(); }
|
||||
catch (T) { return; }
|
||||
throw new Exception($"预期异常:{typeof(T).Name}");
|
||||
}
|
||||
sealed record Changed : INotification;
|
||||
sealed class First(Func<Changed, CancellationToken, Task> action) : INotificationHandler<Changed>
|
||||
{
|
||||
public Task Handle(Changed notification, CancellationToken cancellationToken) => action(notification, cancellationToken);
|
||||
}
|
||||
sealed class Second(Func<Changed, CancellationToken, Task> action) : INotificationHandler<Changed>
|
||||
{
|
||||
public Task Handle(Changed notification, CancellationToken cancellationToken) => action(notification, cancellationToken);
|
||||
}
|
||||
sealed class TestServices : IServiceProvider
|
||||
{
|
||||
public INotificationHandler<Changed>[]? Handlers { get; set; }
|
||||
public int Resolutions { get; private set; }
|
||||
public object? GetService(Type serviceType)
|
||||
{
|
||||
if (serviceType != typeof(IEnumerable<INotificationHandler<Changed>>))
|
||||
throw new Exception($"错误的解析类型:{serviceType}");
|
||||
Resolutions++;
|
||||
return Handlers;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Store.Management.Api;
|
||||
using Store.Management.Api.Authentication;
|
||||
using Store.Management.Api.Transport;
|
||||
using Store.Management.Api.Users;
|
||||
|
||||
static void Check(bool condition, string message)
|
||||
{
|
||||
if (!condition) throw new Exception(message);
|
||||
}
|
||||
static async Task Error(Func<Task> action, ApiErrorKind kind)
|
||||
{
|
||||
try { await action(); }
|
||||
catch (ApiException ex) { Check(ex.Kind == kind, $"预期 {kind},实际 {ex.Kind}"); return; }
|
||||
throw new Exception($"未抛出 {kind}");
|
||||
}
|
||||
var handler = new StubHandler();
|
||||
using var http = new HttpClient(handler);
|
||||
using var api = new StoreApiClient(new ApiClientOptions { BaseUrl = new Uri("https://test.invalid/root/") }, http);
|
||||
await Error(async () => await api.Auth.MeAsync(), ApiErrorKind.Unauthorized);
|
||||
Check(handler.Count == 0, "未登录不应发送请求");
|
||||
handler.Json = """{"code":200,"data":{"token":"session-one","tokenType":"Bearer","expiresInMinutes":100}}""";
|
||||
await api.Auth.LoginAsync(new LoginRequest { UserName = "admin", Password = "secret" });
|
||||
Check(handler.Path == "/root/v1/Auth/Login" && handler.Authorization is null, "登录路由或匿名认证错误");
|
||||
handler.Json = """{"code":200,"data":{"total":1,"items":[{"id":3,"userName":"alice"}]}}""";
|
||||
var page = await api.Users.SearchAsync(new UserSearchRequest { Page = 2, PageSize = 10 });
|
||||
Check(page.Total == 1 && page.Items[0].UserName == "alice", "分页解析失败");
|
||||
Check(handler.Authorization == "Bearer session-one" && handler.Body!.Contains("\"page\":2"), "Token 或分页请求体错误");
|
||||
var count = handler.Count;
|
||||
try { await api.Users.SearchAsync(new UserSearchRequest { Page = 0 }); throw new Exception("未校验分页"); }
|
||||
catch (ValidationException) { Check(handler.Count == count, "无效参数不应发送"); }
|
||||
handler.Json = """{"code":2001,"msg":"业务失败","traceId":"trace-1","data":null}""";
|
||||
await Error(async () => await api.Auth.MeAsync(), ApiErrorKind.Business);
|
||||
Check(api.Session.IsAuthenticated, "业务失败不应退出会话");
|
||||
handler.Json = "not-json";
|
||||
await Error(async () => await api.Auth.MeAsync(), ApiErrorKind.InvalidResponse);
|
||||
handler.Status = HttpStatusCode.Forbidden;
|
||||
handler.Json = """{"code":403,"msg":"无权限","data":null}""";
|
||||
await Error(async () => await api.Auth.MeAsync(), ApiErrorKind.Http);
|
||||
Check(api.Session.IsAuthenticated, "403 不应清除会话");
|
||||
handler.Status = HttpStatusCode.OK;
|
||||
handler.Json = """{"code":401,"msg":"已失效","data":null}""";
|
||||
await Error(async () => await api.Auth.MeAsync(), ApiErrorKind.Unauthorized);
|
||||
Check(!api.Session.IsAuthenticated, "业务401应清理会话");
|
||||
handler.Json = """{"code":200,"data":{"token":"session-two"}}""";
|
||||
await api.Auth.LoginAsync(new LoginRequest { UserName = "admin", Password = "secret" });
|
||||
handler.Status = HttpStatusCode.Unauthorized;
|
||||
await Error(async () => await api.Auth.MeAsync(), ApiErrorKind.Unauthorized);
|
||||
Check(!api.Session.IsAuthenticated, "HTTP401应清理会话");
|
||||
handler.Status = HttpStatusCode.OK;
|
||||
handler.Json = """{"code":200,"data":{"token":"session-three"}}""";
|
||||
await api.Auth.LoginAsync(new LoginRequest { UserName = "admin", Password = "secret" });
|
||||
handler.Json = """{"code":200,"data":null}""";
|
||||
await api.Auth.LogoutAsync();
|
||||
Check(!api.Session.IsAuthenticated && handler.Path.EndsWith("Auth/Logout"), "退出失败");
|
||||
using var cancelled = new CancellationTokenSource();
|
||||
cancelled.Cancel();
|
||||
try { await api.Auth.LoginAsync(new LoginRequest(), cancelled.Token); throw new Exception("取消未生效"); }
|
||||
catch (OperationCanceledException) { }
|
||||
Console.WriteLine("PASS: 登录、路由、Bearer、分页、校验、业务错误、格式错误、403、HTTP/业务401、退出、取消。");
|
||||
|
||||
sealed class StubHandler : HttpMessageHandler
|
||||
{
|
||||
public string Json { get; set; } = "";
|
||||
public HttpStatusCode Status { get; set; } = HttpStatusCode.OK;
|
||||
public int Count { get; private set; }
|
||||
public string Path { get; private set; } = "";
|
||||
public string? Authorization { get; private set; }
|
||||
public string? Body { get; private set; }
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
Count++;
|
||||
Path = request.RequestUri!.AbsolutePath;
|
||||
Authorization = request.Headers.Authorization?.ToString();
|
||||
Body = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
|
||||
return new HttpResponseMessage(Status) { Content = new StringContent(Json, Encoding.UTF8, "application/json") };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net10.0</TargetFramework><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings></PropertyGroup>
|
||||
<ItemGroup><ProjectReference Include="..\..\Source\Store.Management.Api\Store.Management.Api.csproj" /></ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
using Store.Management;
|
||||
using Store.Management.Views;
|
||||
using Store.Management.ViewModels;
|
||||
using Store.Management.Framework.Controls;
|
||||
class Program
|
||||
{
|
||||
[STAThread]
|
||||
static int Main()
|
||||
{
|
||||
var app = new App();
|
||||
app.InitializeComponent();
|
||||
int exitCode = 0;
|
||||
app.Dispatcher.BeginInvoke(DispatcherPriority.ApplicationIdle, new Action(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var page = (FrameworkTestPage)((MainWindow)app.MainWindow).Content;
|
||||
var vm = (FrameworkTestPageViewModel)page.DataContext;
|
||||
vm.TestIocCommand.Execute();
|
||||
Check(vm.IocResult.StartsWith("通过"), vm.IocResult);
|
||||
vm.ProductName = " ";
|
||||
await vm.PublishNotificationCommand.Execute();
|
||||
Check(vm.NotificationResult == "请输入商品名称。", "通知输入校验");
|
||||
vm.ProductName = "通知测试商品";
|
||||
await vm.PublishNotificationCommand.Execute();
|
||||
Check(vm.NotificationResult.StartsWith("通知处理完成")
|
||||
&& vm.NotificationResult.Contains("缓存模块")
|
||||
&& vm.NotificationResult.Contains("审计模块")
|
||||
&& vm.NotificationResult.Contains(vm.ProductName), vm.NotificationResult);
|
||||
vm.ShowModelessCommand.Execute();
|
||||
var dialog = app.Windows.OfType<DialogWindow>().Single();
|
||||
var dialogVm = (FrameworkTestDialogViewModel)dialog.DataContext;
|
||||
Check(dialogVm.Message == vm.DialogInput, "Dialog input");
|
||||
dialogVm.Message = " ";
|
||||
dialogVm.ConfirmCommand.Execute(null);
|
||||
Check(dialog.IsVisible && dialogVm.ErrorMsg.Length > 0, "Empty validation");
|
||||
dialogVm.Message = "返回测试";
|
||||
dialogVm.ConfirmCommand.Execute(null);
|
||||
Check(vm.DialogResult == "确认:返回测试", vm.DialogResult);
|
||||
vm.ShowModelessCommand.Execute();
|
||||
app.Windows.OfType<DialogWindow>().Single().Close();
|
||||
Check(vm.DialogResult.Contains("None"), vm.DialogResult);
|
||||
_ = app.Dispatcher.BeginInvoke(DispatcherPriority.ApplicationIdle, new Action(() =>
|
||||
((FrameworkTestDialogViewModel)app.Windows.OfType<DialogWindow>().Single().DataContext).CancelCommand.Execute(null)));
|
||||
vm.ShowModalCommand.Execute();
|
||||
Check(vm.DialogResult.Contains("Cancel"), vm.DialogResult);
|
||||
vm.DialogInput = " ";
|
||||
vm.ShowModalCommand.Execute();
|
||||
Check(!app.Windows.OfType<DialogWindow>().Any(), "Empty input blocks dialog");
|
||||
Console.WriteLine("PASS: startup, view model wiring, IoC, modeless/modal dialogs, parameters, validation, confirm/cancel/window-close.");
|
||||
}
|
||||
catch (Exception ex) { Console.Error.WriteLine(ex); exitCode = 1; }
|
||||
finally { app.Shutdown(); }
|
||||
}));
|
||||
app.Run();
|
||||
return exitCode;
|
||||
}
|
||||
static void Check(bool success, string message) { if (!success) throw new Exception(message); }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<UseWPF>true</UseWPF>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../Source/Store.Management/Store.Management.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,113 @@
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using Newtonsoft.Json;
|
||||
using Store.Management.Framework.Controls;
|
||||
using Store.Management.Framework.Converters;
|
||||
using Store.Management.Framework.Services;
|
||||
using Store.Management.Framework.Utilities;
|
||||
using Store.Management.Framework.Configs;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
[STAThread]
|
||||
private static void Main()
|
||||
{
|
||||
int count = 0;
|
||||
var cleanup = new DisposeAction(() => Interlocked.Increment(ref count));
|
||||
Parallel.For(0, 100, _ => cleanup.Dispose());
|
||||
Check(count == 1, "Concurrent cleanup");
|
||||
VerifyEvents().GetAwaiter().GetResult();
|
||||
string? crashPath = ApplicationLogService.WriteFatal(new InvalidOperationException("smoke-crash-marker"), "SmokeTest");
|
||||
Check(crashPath != null && File.ReadAllText(crashPath).Contains("smoke-crash-marker"), "Crash report without logger initialization");
|
||||
File.Delete(crashPath!);
|
||||
|
||||
Check(Path.IsPathFullyQualified(FolderConfig.ConfigFolder), "Absolute config directory");
|
||||
Check(FolderConfig.AppSettingsFilePath == Path.Combine(FolderConfig.ConfigFolder, "AppSettings.json"), "Settings location");
|
||||
Check(new DefaultPathSettings().FilePath == Path.Combine(FolderConfig.ConfigFolder, "SettingsModel.json"), "Default model location");
|
||||
foreach (string invalid in new[] { "../escape.json", "D:\\escape.json", "..", "config.json." })
|
||||
{
|
||||
try { FolderConfig.GetSettingsFilePath(invalid); throw new Exception("Expected invalid filename error"); }
|
||||
catch (ArgumentException) { }
|
||||
}
|
||||
|
||||
string directory = Path.Combine(Path.GetTempPath(), "StoreFramework-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(directory);
|
||||
try
|
||||
{
|
||||
string file = Path.Combine(directory, "settings.JSON");
|
||||
var settings = new TestSettings(file);
|
||||
settings.LoadSettings();
|
||||
Check(settings.Model.Value == 7, "Missing settings defaults");
|
||||
settings.Model.Value = 42;
|
||||
settings.SaveSettings();
|
||||
var reloaded = new TestSettings(file);
|
||||
reloaded.LoadSettingsAsync().GetAwaiter().GetResult();
|
||||
Check(reloaded.Model.Value == 42, "Settings roundtrip");
|
||||
Task.WhenAll(Enumerable.Range(0, 10).Select(_ => settings.SaveSettingsAsync())).GetAwaiter().GetResult();
|
||||
Check(Directory.GetFiles(directory, "*.tmp").Length == 0, "Temporary file cleanup");
|
||||
File.WriteAllText(file, "{broken");
|
||||
try { reloaded.LoadSettings(); throw new Exception("Expected JSON error"); }
|
||||
catch (JsonException) { }
|
||||
Check(reloaded.Model.Value == 42, "Corrupt settings preserve model");
|
||||
File.WriteAllText(file, "null");
|
||||
try { reloaded.LoadSettings(); throw new Exception("Expected null error"); }
|
||||
catch (JsonException) { }
|
||||
string[] args = [file];
|
||||
AppArgsService.Initialize(args);
|
||||
args[0] = "changed";
|
||||
Check(AppArgsService.GetArgs()[0] == file, "Arguments snapshot");
|
||||
Check(AppArgsService.GetFilePaths("json").Single() == file, "Extension case");
|
||||
Check(AppArgsService.GetFilePaths(".JSON").Single() == file, "Dotted extension");
|
||||
try { AppArgsService.GetFilePaths("."); throw new Exception("Expected invalid extension error"); }
|
||||
catch (ArgumentException) { }
|
||||
}
|
||||
finally { Directory.Delete(directory, recursive: true); }
|
||||
|
||||
var grid = new AutoColumnsUniformGrid { CellMinWidth = 200, MinColumns = 2, MaxColumns = 4 };
|
||||
grid.Measure(new Size(1000, 500));
|
||||
Check(grid.Columns == 4, "Maximum columns");
|
||||
grid.Measure(new Size(100, 500));
|
||||
Check(grid.Columns == 2, "Minimum columns");
|
||||
grid.MaxColumns = 1;
|
||||
grid.Measure(new Size(1000, 500));
|
||||
Check(grid.Columns == 2, "Conflicting bounds");
|
||||
try { grid.CellMinWidth = double.NaN; throw new Exception("Expected invalid width error"); }
|
||||
catch (ArgumentException) { }
|
||||
var converter = new StringNullOrEmpty2VisibilityConverter();
|
||||
Check(Equals(converter.Convert(null!, typeof(Visibility), null!, CultureInfo.InvariantCulture), Visibility.Visible), "Null placeholder");
|
||||
Check(Equals(converter.Convert("value", typeof(Visibility), null!, CultureInfo.InvariantCulture), Visibility.Collapsed), "Nonempty placeholder");
|
||||
Console.WriteLine("PASS: cleanup, async events, settings, arguments, WPF layout and converter boundaries.");
|
||||
}
|
||||
|
||||
private static async Task VerifyEvents()
|
||||
{
|
||||
var events = new AsyncEvent<EventArgs>();
|
||||
var calls = new List<int>();
|
||||
Func<object?, EventArgs, Task> second = (_, _) => { calls.Add(2); return Task.CompletedTask; };
|
||||
events.Subscribe(async (_, _) => { await Task.Yield(); calls.Add(1); events.Unsubscribe(second); });
|
||||
events.Subscribe(second);
|
||||
await events.InvokeAsync(null, EventArgs.Empty);
|
||||
Check(calls.SequenceEqual([1, 2]), "Event snapshot and order");
|
||||
calls.Clear();
|
||||
await events.InvokeAsync(null, EventArgs.Empty);
|
||||
Check(calls.SequenceEqual([1]), "Event unsubscribe");
|
||||
}
|
||||
|
||||
private static void Check(bool result, string message)
|
||||
{
|
||||
if (!result) throw new Exception(message);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SettingsModel { public int Value { get; set; } = 7; }
|
||||
internal sealed class TestSettings(string path) : JsonSettingsServiceBase<SettingsModel>
|
||||
{
|
||||
public override string FilePath => path;
|
||||
public override SettingsModel GetDefaultModel() => new();
|
||||
}
|
||||
|
||||
internal sealed class DefaultPathSettings : JsonSettingsServiceBase<SettingsModel>
|
||||
{
|
||||
public override SettingsModel GetDefaultModel() => new();
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<UseWPF>true</UseWPF>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../Source/Store.Management.Framework/Store.Management.Framework.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in new issue
Block a user