更新框架

This commit is contained in:
chfei committed 2026-10-27 14:35:03 +08:00
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();
+4 -1
View File
@@ -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>
+8 -21
View File
@@ -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; }
}
}
+26 -12
View File
@@ -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;
@@ -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
}
}
}
}
+1 -1
View File
@@ -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;
}
+10
View File
@@ -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);
}
}
+67
View File
@@ -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);同步基类本身不检查取消。
+25
View File
@@ -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;
}
+78
View File
@@ -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();
}
+19 -6
View File
@@ -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>
+25 -25
View File
@@ -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>();
}
}
+13 -12
View File
@@ -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>
+4 -22
View File
@@ -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();
}
+57
View File
@@ -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();
}
+7
View File
@@ -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>
+54
View File
@@ -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();
}
@@ -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>