增加Mediat Pip特性,并用实例来做一个demo
This commit is contained in:
1 parent
292cb8638c
commit
c9ac7412ab
19 files changed
+682
-108
No files matched your search
@@ -0,0 +1,7 @@
|
||||
namespace MAX.Core.Mediation;
|
||||
|
||||
/// <summary>提供请求自身的参数校验;不合法时抛出 ValidationException。</summary>
|
||||
public interface IValidatableRequest
|
||||
{
|
||||
void Validate();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Adapted from ITIXO/MediatR.Extensions.AttributedBehaviors (MIT).
|
||||
// Copyright (c) 2021 ITIXO s.r.o. See ThirdPartyNotices/MediatR.Extensions.AttributedBehaviors.LICENSE.txt.
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace MAX.Core.Mediation;
|
||||
|
||||
/// <summary>声明命令管道。每个特性独占一行,按源码行号从上到下执行。</summary>
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = true, Inherited = false)]
|
||||
public sealed class MediatRBehaviorAttribute(Type behaviorType, [CallerLineNumber] int lineNumber = 0) : Attribute
|
||||
{
|
||||
public Type BehaviorType { get; } = behaviorType ?? throw new ArgumentNullException(nameof(behaviorType));
|
||||
public int LineNumber { get; } = lineNumber;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using MAX.Framework.Mediation;
|
||||
// Adapted from ITIXO/MediatR.Extensions.AttributedBehaviors (MIT).
|
||||
// Copyright (c) 2021 ITIXO s.r.o. See ThirdPartyNotices/MediatR.Extensions.AttributedBehaviors.LICENSE.txt.
|
||||
using System.Reflection;
|
||||
using MAX.Core.Mediation;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace MAX.Framework.DependencyInjection;
|
||||
|
||||
public static class MediatRBehaviorServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>扫描命令特性,按声明行号注册闭合管道,适配 MediatR 9。</summary>
|
||||
public static IServiceCollection AddMediatRAttributedBehaviors(this IServiceCollection services,
|
||||
params Assembly[] assemblies)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(assemblies);
|
||||
// 先完成全部校验,避免无效配置留下部分注册。
|
||||
var registrations = new List<ServiceDescriptor>();
|
||||
foreach (var request in assemblies.Distinct().SelectMany(a => a.DefinedTypes))
|
||||
{
|
||||
var attributes = request.GetCustomAttributes<MediatRBehaviorAttribute>(false)
|
||||
.OrderBy(a => a.LineNumber).ToArray();
|
||||
if (attributes.Length == 0) continue;
|
||||
if (request.IsAbstract || request.ContainsGenericParameters)
|
||||
throw new InvalidOperationException($"{request.FullName} 必须是具体的闭合请求类型。");
|
||||
var responses = request.GetInterfaces()
|
||||
.Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IRequest<>))
|
||||
.Select(i => i.GetGenericArguments()[0]).Distinct().ToArray();
|
||||
if (responses.Length != 1)
|
||||
throw new InvalidOperationException($"{request.FullName} 必须实现唯一的 IRequest<TResponse>(无返回值使用 IRequest)。");
|
||||
if (attributes.GroupBy(a => a.LineNumber).Any(g => g.Count() > 1))
|
||||
throw new InvalidOperationException($"{request.FullName} 的管道特性必须独占一行,不能使用相同的行号。");
|
||||
if (attributes.GroupBy(a => a.BehaviorType).Any(g => g.Count() > 1))
|
||||
throw new InvalidOperationException($"{request.FullName} 重复声明了同一个管道。");
|
||||
|
||||
var serviceType = typeof(IPipelineBehavior<,>).MakeGenericType(request.AsType(), responses[0]);
|
||||
foreach (var attribute in attributes)
|
||||
{
|
||||
var behavior = attribute.BehaviorType;
|
||||
if (behavior.IsGenericTypeDefinition)
|
||||
{
|
||||
if (behavior.GetGenericArguments().Length != 2)
|
||||
throw new InvalidOperationException($"开放泛型管道 {behavior} 必须具有 TRequest、TResponse 两个泛型参数。");
|
||||
try
|
||||
{
|
||||
behavior = behavior.MakeGenericType(request.AsType(), responses[0]);
|
||||
}
|
||||
catch (ArgumentException exception)
|
||||
{
|
||||
throw new InvalidOperationException($"管道 {behavior} 的泛型约束与请求 {request.FullName} 不匹配。", exception);
|
||||
}
|
||||
}
|
||||
if (!behavior.IsClass || behavior.IsAbstract || behavior.ContainsGenericParameters
|
||||
|| !serviceType.IsAssignableFrom(behavior))
|
||||
throw new InvalidOperationException($"管道 {behavior.FullName} 必须是实现 {serviceType} 的具体闭合类型。");
|
||||
registrations.Add(ServiceDescriptor.Transient(serviceType, behavior));
|
||||
}
|
||||
}
|
||||
services.TryAddSingleton<UnitOfWorkExecutionContext>();
|
||||
services.TryAddTransient<ServiceFactory>(provider => provider.GetRequiredService);
|
||||
services.TryAddTransient<IMediator, Mediator>();
|
||||
services.TryAddTransient<ISender>(provider => provider.GetRequiredService<IMediator>());
|
||||
services.TryAddTransient<IPublisher>(provider => provider.GetRequiredService<IMediator>());
|
||||
foreach (var registration in registrations)
|
||||
services.TryAddEnumerable(registration);
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,7 @@ namespace MAX.Framework.DependencyInjection
|
||||
RunDebugStep("Plugin", "注册全部插件", () => RunPlugins(host, assemblies, instances));
|
||||
|
||||
RunDebugStep("Startup", "初始化并注册启动服务", () => RunStartups(host.Services, assemblies, instances));
|
||||
RunDebugStep("MediatR", "按命令特性注册管道", () => host.Services.AddMediatRAttributedBehaviors(assemblies.ToArray()));
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using FreeSql;
|
||||
using MediatR;
|
||||
|
||||
namespace MAX.Framework.Mediation;
|
||||
|
||||
/// <summary>为通过特性选择的命令管理事务;所有读写使用执行上下文中的 ORM。</summary>
|
||||
public sealed class TransactionBehavior<TRequest, TResponse>(
|
||||
IFreeSql freeSql, UnitOfWorkExecutionContext context) : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : IRequest<TResponse>
|
||||
{
|
||||
public async Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken,
|
||||
RequestHandlerDelegate<TResponse> next)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (context.IsActive)
|
||||
throw new InvalidOperationException("不支持嵌套事务命令,请在同一个命令处理器内完成事务操作");
|
||||
using var work = freeSql.CreateUnitOfWork();
|
||||
work.GetOrBeginTransaction();
|
||||
using var execution = context.Enter(work.Orm);
|
||||
var response = await next();
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
work.Commit();
|
||||
return response;
|
||||
// 未提交时,工作单元释放自动回滚;执行上下文同时清理。
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Threading;
|
||||
|
||||
namespace MAX.Framework.Mediation;
|
||||
|
||||
/// <summary>沿当前异步调用链传递事务 ORM,隔离并行命令的执行状态。</summary>
|
||||
public sealed class UnitOfWorkExecutionContext
|
||||
{
|
||||
private readonly AsyncLocal<IFreeSql?> _current = new();
|
||||
|
||||
public IFreeSql Orm => _current.Value
|
||||
?? throw new InvalidOperationException("当前操作必须在事务管道内执行");
|
||||
|
||||
public bool IsActive => _current.Value != null;
|
||||
|
||||
internal IDisposable Enter(IFreeSql orm)
|
||||
{
|
||||
if (IsActive)
|
||||
throw new InvalidOperationException("不支持嵌套事务命令,请在同一个命令处理器内完成事务操作");
|
||||
_current.Value = orm;
|
||||
return new ExecutionScope(this);
|
||||
}
|
||||
|
||||
private sealed class ExecutionScope(UnitOfWorkExecutionContext context) : IDisposable
|
||||
{
|
||||
public void Dispose() => context._current.Value = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using MAX.Core.Mediation;
|
||||
using MediatR;
|
||||
|
||||
namespace MAX.Framework.Mediation;
|
||||
|
||||
/// <summary>执行请求自身的参数校验,验证通过后才进入后续管道。</summary>
|
||||
public sealed class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : IRequest<TResponse>, IValidatableRequest
|
||||
{
|
||||
public Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken,
|
||||
RequestHandlerDelegate<TResponse> next)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
request.Validate();
|
||||
return next();
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,8 @@ namespace Store.Users.Entities
|
||||
/// 过期时间
|
||||
/// </summary>
|
||||
// 数据库列名末尾包含 U+200C,不能改成普通 expire_time。
|
||||
[Column(Name = "expire_time")]
|
||||
[Column(Name = "expire_time")]
|
||||
|
||||
public int ExpireTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using MAX.Framework.Commons;
|
||||
using MAX.Framework.Mediation;
|
||||
using MediatR;
|
||||
using Store.Users.Entities;
|
||||
|
||||
namespace Store.Users.Behaviors;
|
||||
|
||||
/// <summary>锁定积分账户并检查消费额度,不依赖商铺信息。</summary>
|
||||
public sealed class AccountConsumptionBehavior<TRequest, TResponse>(
|
||||
UnitOfWorkExecutionContext executionContext)
|
||||
: IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : class, IRequest<TResponse>, IAccountConsumptionRequest
|
||||
{
|
||||
public async Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken,
|
||||
RequestHandlerDelegate<TResponse> next)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (string.IsNullOrWhiteSpace(request.UserName))
|
||||
throw new AppException("用户名不能为空");
|
||||
var userName = request.UserName.Trim();
|
||||
var user = await executionContext.Orm.Select<UserEntity>().Where(a => a.UserName == userName)
|
||||
.ForUpdate().FirstAsync(cancellationToken)
|
||||
?? throw new AppException("不存在的用户");
|
||||
var quote = request.CalculateConsumption(user);
|
||||
if (quote.Amount <= 0 || quote.UnitPrice <= 0)
|
||||
throw new AppException("消费金额必须大于0");
|
||||
if (user.Points < quote.Amount)
|
||||
throw new AppException("余额不足");
|
||||
lock (request)
|
||||
{
|
||||
if (request.ConsumptionContext != null)
|
||||
throw new InvalidOperationException("同一个消费命令不能同时执行");
|
||||
request.ConsumptionContext = new AccountConsumptionContext(user, quote);
|
||||
}
|
||||
try
|
||||
{
|
||||
return await next();
|
||||
}
|
||||
finally
|
||||
{
|
||||
request.ConsumptionContext = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Store.Users.Entities;
|
||||
using YourNamespace;
|
||||
|
||||
namespace Store.Users.Behaviors;
|
||||
|
||||
/// <summary>需要账户积分消费检查的请求。</summary>
|
||||
public interface IAccountConsumptionRequest
|
||||
{
|
||||
string UserName { get; }
|
||||
AccountConsumptionContext? ConsumptionContext { get; set; }
|
||||
ConsumptionQuote CalculateConsumption(UserEntity user);
|
||||
}
|
||||
|
||||
/// <summary>需要检查商铺归属的请求,与是否消费无关。</summary>
|
||||
public interface IShopOwnershipRequest
|
||||
{
|
||||
string UserName { get; }
|
||||
string ShopId { get; }
|
||||
ShopEntity? OwnedShop { get; set; }
|
||||
}
|
||||
|
||||
public sealed record ConsumptionQuote(decimal Amount, decimal UnitPrice);
|
||||
public sealed record AccountConsumptionContext(UserEntity User, ConsumptionQuote Quote);
|
||||
@@ -0,0 +1,43 @@
|
||||
using MAX.Framework.Commons;
|
||||
using MAX.Framework.Mediation;
|
||||
using MediatR;
|
||||
using YourNamespace;
|
||||
|
||||
namespace Store.Users.Behaviors;
|
||||
|
||||
/// <summary>独立检查商铺归属;事务中执行时锁定商铺,读取场景可不启用事务。</summary>
|
||||
public sealed class ShopOwnershipBehavior<TRequest, TResponse>(
|
||||
IFreeSql freeSql, UnitOfWorkExecutionContext executionContext) : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : class, IRequest<TResponse>, IShopOwnershipRequest
|
||||
{
|
||||
public async Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken,
|
||||
RequestHandlerDelegate<TResponse> next)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (string.IsNullOrWhiteSpace(request.UserName) || string.IsNullOrWhiteSpace(request.ShopId))
|
||||
throw new AppException("用户名和商铺ID不能为空");
|
||||
var userName = request.UserName.Trim();
|
||||
var shopId = request.ShopId.Trim();
|
||||
var orm = executionContext.IsActive ? executionContext.Orm : freeSql;
|
||||
var query = orm.Select<ShopEntity>().Where(a => a.ShopId == shopId && a.UserName == userName);
|
||||
if (executionContext.IsActive) query = query.ForUpdate();
|
||||
var shop = await query.FirstAsync(cancellationToken)
|
||||
?? throw new AppException("不存在的商铺或该商铺不属于当前用户");
|
||||
if (!string.Equals(shop.UserName, userName, StringComparison.OrdinalIgnoreCase))
|
||||
throw new AppException("该商铺不属于当前用户");
|
||||
lock (request)
|
||||
{
|
||||
if (request.OwnedShop != null)
|
||||
throw new InvalidOperationException("同一个商铺操作命令不能同时执行");
|
||||
request.OwnedShop = shop;
|
||||
}
|
||||
try
|
||||
{
|
||||
return await next();
|
||||
}
|
||||
finally
|
||||
{
|
||||
request.OwnedShop = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,81 +1,146 @@
|
||||
//using MediatR;
|
||||
//using Store.Framework.Models;
|
||||
//using Store.Shops.Contracts;
|
||||
//using Store.Users.Contracts.Dtos;
|
||||
//using Store.Users.Entities;
|
||||
//using Store.Users.Repositories;
|
||||
//using System;
|
||||
//using System.Collections.Generic;
|
||||
//using System.Text;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using YourNamespace;
|
||||
using MAX.Framework.Mediation;
|
||||
using MAX.Core.Mediation;
|
||||
using Store.Users.Behaviors;
|
||||
using MAX.Core.Helpers;
|
||||
using MAX.Framework.Commons;
|
||||
using MediatR;
|
||||
using Store.Users.Contracts.Dtos;
|
||||
using Store.Users.Entities;
|
||||
|
||||
//namespace Store.Users.Commands
|
||||
//{
|
||||
// /// <summary>
|
||||
// /// 商铺续费
|
||||
// /// </summary>
|
||||
// /// <param name="UserName"></param>
|
||||
// /// <param name="ShopId"></param>
|
||||
// /// <param name="RenewDuration"></param>
|
||||
// internal record class ShopRenewCommand(string UserName, string ShopId, int RenewDuration) : IRequest<ShopRenewResponse>;
|
||||
// internal class ShopRenewCommandHandler : IRequestHandler<ShopRenewCommand, ShopRenewResponse>
|
||||
// {
|
||||
// private readonly IUserRepository _userRepository;
|
||||
// private readonly IShopQueryService _shopQueryService;
|
||||
namespace Store.Users.Commands;
|
||||
|
||||
// public ShopRenewCommandHandler(IUserRepository userRepository, IShopQueryService shopQueryService)
|
||||
// {
|
||||
// _userRepository = userRepository;
|
||||
// _shopQueryService = shopQueryService;
|
||||
// }
|
||||
// async Task<ShopRenewResponse> IRequestHandler<ShopRenewCommand, ShopRenewResponse>.Handle(ShopRenewCommand request, CancellationToken cancellationToken)
|
||||
// {
|
||||
// ShopRenewResponse response = new ShopRenewResponse();
|
||||
// var user = await _userRepository.GetUser(request.UserName);
|
||||
// int shopPointsPerMonth = Math.Max(30, user.ShopPointsPerMonth);
|
||||
// int deltaPoints = shopPointsPerMonth * request.RenewDuration;
|
||||
// int newPoints = (int)user.Points - deltaPoints;
|
||||
// DateTime expireTime = _shopQueryService.GetExpireTime(request.ShopId);
|
||||
/// <summary>商铺续费,续费时长单位为月。</summary>
|
||||
[MediatRBehavior(typeof(ValidationBehavior<,>))]
|
||||
[MediatRBehavior(typeof(TransactionBehavior<,>))]
|
||||
[MediatRBehavior(typeof(AccountConsumptionBehavior<,>))]
|
||||
[MediatRBehavior(typeof(ShopOwnershipBehavior<,>))]
|
||||
public sealed record ShopRenewCommand(string UserName, string ShopId, int RenewDuration, string? Remark = null)
|
||||
: IRequest<ShopRenewResponse>, IValidatableRequest, IAccountConsumptionRequest, IShopOwnershipRequest
|
||||
{
|
||||
// 由消费管道建立,仅在当前事务执行期间有效。
|
||||
AccountConsumptionContext? IAccountConsumptionRequest.ConsumptionContext { get; set; }
|
||||
ShopEntity? IShopOwnershipRequest.OwnedShop { get; set; }
|
||||
|
||||
void IValidatableRequest.Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(UserName) || UserName.Trim().Length > 20)
|
||||
throw new ValidationException("用户名不能为空且长度不能超过20个字符");
|
||||
if (string.IsNullOrWhiteSpace(ShopId) || ShopId.Trim().Length > 50)
|
||||
throw new ValidationException("店铺ID不能为空且长度不能超过50个字符");
|
||||
if (RenewDuration is < 1 or > 120)
|
||||
throw new ValidationException("续费时长必须为1-120个月");
|
||||
if (Remark?.Trim().Length > 255)
|
||||
throw new ValidationException("备注长度不能超过255个字符");
|
||||
}
|
||||
|
||||
ConsumptionQuote IAccountConsumptionRequest.CalculateConsumption(UserEntity user)
|
||||
{
|
||||
if (RenewDuration is < 1 or > 120)
|
||||
throw new AppException("续费时长必须为1-120个月");
|
||||
int price = Math.Max(30, user.ShopPointsPerMonth);
|
||||
return new ConsumptionQuote((decimal)price * RenewDuration, price);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ShopRenewCommandHandler(UnitOfWorkExecutionContext executionContext)
|
||||
: IRequestHandler<ShopRenewCommand, ShopRenewResponse>
|
||||
{
|
||||
public async Task<ShopRenewResponse> Handle(ShopRenewCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var userName = request.UserName.Trim();
|
||||
var shopId = request.ShopId.Trim();
|
||||
var remark = string.IsNullOrWhiteSpace(request.Remark) ? null : request.Remark.Trim();
|
||||
|
||||
var context = ((IAccountConsumptionRequest)request).ConsumptionContext
|
||||
?? throw new InvalidOperationException("续费命令必须通过账户消费管道执行");
|
||||
var orm = executionContext.Orm;
|
||||
var user = context.User;
|
||||
var shop = ((IShopOwnershipRequest)request).OwnedShop
|
||||
?? throw new InvalidOperationException("续费命令必须通过商铺归属管道执行");
|
||||
decimal pointsPerMonth = context.Quote.UnitPrice;
|
||||
decimal cost = context.Quote.Amount;
|
||||
decimal newPoints = user.Points - cost;
|
||||
var now = DateTime.Now;
|
||||
var currentExpire = await orm.Select<ShopExpireEntity>().Where(a => a.ShopId == shopId)
|
||||
.FirstAsync(cancellationToken);
|
||||
var expireBase = now;
|
||||
if (currentExpire is { ExpireTime: > 0 })
|
||||
{
|
||||
var currentTime = DateHelper.ConvertFromUnixTimestampInSeconds(currentExpire.ExpireTime);
|
||||
if (currentTime > expireBase)
|
||||
expireBase = currentTime;
|
||||
}
|
||||
int newExpireTime = CalculateNewExpireTime(expireBase, request.RenewDuration);
|
||||
|
||||
|
||||
// if (currentExpire != null && currentExpire.ExpireTime > 0)
|
||||
// {
|
||||
// DateTime currentExpireTime = TimeUtils.StampToDateTime(currentExpire.ExpireTime);
|
||||
// if (currentExpireTime > expireBaseTime)
|
||||
// {
|
||||
// expireBaseTime = currentExpireTime;
|
||||
// }
|
||||
// }
|
||||
int affected = await orm.Update<UserEntity>().Set(a => a.Points, newPoints)
|
||||
.Where(a => a.Id == user.Id && a.Points == user.Points)
|
||||
.ExecuteAffrowsAsync(cancellationToken);
|
||||
if (affected != 1)
|
||||
throw new AppException("账户已发生改变,请刷新账户后重新操作");
|
||||
|
||||
// int newExpireTime = CalculateNewExpireTime(expireBaseTime, request.RenewDuration);
|
||||
for (int i = 0; i < request.RenewDuration; i++)
|
||||
{
|
||||
var record = new UserPointsRecordEntity
|
||||
{
|
||||
OptionType = "续费",
|
||||
OperatorType = "用户",
|
||||
OperatorName = userName,
|
||||
UserName = userName,
|
||||
UserNick = user.NickName,
|
||||
DeltaPoints = pointsPerMonth,
|
||||
NewPoints = user.Points - (decimal)pointsPerMonth * (i + 1),
|
||||
ShopId = shop.ShopId,
|
||||
ShopAccount = shop.ShopAccount,
|
||||
Remark = remark,
|
||||
AddTime = now.AddSeconds(i)
|
||||
};
|
||||
if (await orm.Insert(record).ExecuteAffrowsAsync(cancellationToken) != 1)
|
||||
throw new AppException("新增操作记录失败,请稍候重试");
|
||||
}
|
||||
|
||||
if (currentExpire == null)
|
||||
{
|
||||
affected = await orm.Insert(new ShopExpireEntity
|
||||
{
|
||||
ShopId = shopId,
|
||||
ExpireTime = newExpireTime,
|
||||
AddTime = now,
|
||||
MdyTime = now
|
||||
}).ExecuteAffrowsAsync(cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
affected = await orm.Update<ShopExpireEntity>()
|
||||
.Set(a => a.ExpireTime, newExpireTime).Set(a => a.MdyTime, now)
|
||||
.Where(a => a.Id == currentExpire.Id && a.ExpireTime == currentExpire.ExpireTime)
|
||||
.ExecuteAffrowsAsync(cancellationToken);
|
||||
}
|
||||
if (affected != 1)
|
||||
throw new AppException("更新商铺过期时间失败,请刷新店铺后重新操作");
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return new ShopRenewResponse { ShopId = shopId, UserPoints = newPoints, ExpireTime = newExpireTime };
|
||||
}
|
||||
|
||||
// response.ShopId = request.ShopId;
|
||||
// response.UserPoints = newPoints;
|
||||
// response.ExpireTime = newExpireTime;
|
||||
// return response;
|
||||
// }
|
||||
// private int CalculateNewExpireTime(DateTime expireBaseTime, int renewDuration)
|
||||
// {
|
||||
// DateTime newExpireDateTime;
|
||||
// try
|
||||
// {
|
||||
// newExpireDateTime = expireBaseTime.AddMonths(renewDuration);
|
||||
// }
|
||||
// catch (ArgumentOutOfRangeException)
|
||||
// {
|
||||
// throw new InvalidOperationException("续费时长超出允许范围");
|
||||
// }
|
||||
|
||||
// if (newExpireDateTime > TimeUtils.StampToDateTime(int.MaxValue))
|
||||
// {
|
||||
// throw new InvalidOperationException("续费时间超出系统支持范围");
|
||||
// }
|
||||
|
||||
// return TimeUtils.DateTimeToTimestampSeconds(newExpireDateTime);
|
||||
// }
|
||||
|
||||
// }
|
||||
|
||||
|
||||
//}
|
||||
private static int CalculateNewExpireTime(DateTime expireBase, int duration)
|
||||
{
|
||||
DateTime newExpireDate;
|
||||
try
|
||||
{
|
||||
newExpireDate = expireBase.AddMonths(duration);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
throw new AppException("续费时长超出允许范围");
|
||||
}
|
||||
long timestamp = DateHelper.GetUnixTimestampInSeconds(newExpireDate);
|
||||
if (timestamp > int.MaxValue || timestamp < 0)
|
||||
throw new AppException("续费时间超出系统支持范围");
|
||||
return (int)timestamp;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,45 @@
|
||||
using Asp.Versioning;
|
||||
using Asp.Versioning;
|
||||
using MAX.Framework.Security;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Store.Users.Commands;
|
||||
using Store.Users.Contracts;
|
||||
using Store.Users.Contracts.Dtos;
|
||||
using Store.Users.Services;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using Store.Users.Dtos;
|
||||
|
||||
namespace Store.Users.Controllers
|
||||
namespace Store.Users.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[ApiVersionNeutral]
|
||||
[Route("user/[action]")]
|
||||
public class UserController : ControllerBase
|
||||
{
|
||||
[ApiController]
|
||||
[ApiVersionNeutral]
|
||||
[Route("user/[action]")]
|
||||
public class UserController : ControllerBase
|
||||
{
|
||||
private readonly IUserQueryService _userQuery;
|
||||
private readonly IUserQueryService _userQuery;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
|
||||
public UserController(IUserQueryService userQuery)
|
||||
{
|
||||
_userQuery = userQuery;
|
||||
}
|
||||
[HttpGet]
|
||||
public async Task<UserInfo> GetUser(string userName)
|
||||
{
|
||||
return await _userQuery.GetUser(userName);
|
||||
}
|
||||
}
|
||||
public UserController(IUserQueryService userQuery, IMediator mediator, ICurrentUser currentUser)
|
||||
{
|
||||
_userQuery = userQuery;
|
||||
_mediator = mediator;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<UserInfo> GetUser(string userName)
|
||||
{
|
||||
return await _userQuery.GetUser(userName);
|
||||
}
|
||||
|
||||
/// <summary>使用当前登录用户的积分为商铺续费。</summary>
|
||||
[HttpPost]
|
||||
public Task<ShopRenewResponse> ShopRenew([FromBody] ShopRenewRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_currentUser.IsAuthenticated || string.IsNullOrWhiteSpace(_currentUser.UserName))
|
||||
throw new UnauthorizedAccessException();
|
||||
|
||||
return _mediator.Send(new ShopRenewCommand(
|
||||
_currentUser.UserName, request.ShopId, request.RenewDuration, request.Remark), cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
@@ -8,6 +8,6 @@ namespace Store.Users.Contracts.Dtos
|
||||
{
|
||||
public string ShopId { get; set; }
|
||||
public int ExpireTime { get; set; }
|
||||
public int UserPoints { get; set; }
|
||||
public decimal UserPoints { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
using MAX.Core.DependencyInjection;
|
||||
using MediatR;
|
||||
using Store.Users.Commands;
|
||||
using Store.Users.Contracts.Dtos;
|
||||
using MAX.Core.DependencyInjection;
|
||||
using MAX.Core.Interfaces;
|
||||
|
||||
namespace Store.Users
|
||||
@@ -13,6 +16,7 @@ namespace Store.Users
|
||||
|
||||
public void Register(IServiceRegistrar registrar)
|
||||
{
|
||||
registrar.RegisterTransient<IRequestHandler<ShopRenewCommand, ShopRenewResponse>, ShopRenewCommandHandler>();
|
||||
}
|
||||
|
||||
public void Start(IServiceProvider serviceProvider)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Store.Framework\Store.Framework.csproj" />
|
||||
<ProjectReference Include="..\Store.Shops.Contracts\Store.Shops.Contracts.csproj" />
|
||||
<ProjectReference Include="..\Store.Shops\Store.Shops.csproj" />
|
||||
<ProjectReference Include="..\Store.Users.Contracts\Store.Users.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ namespace Store.Api.Controllers
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[AllowAnonymous]
|
||||
public async Task<ApiResult> Login([FromBody] LoginRequest request)
|
||||
public async Task<ApiResultBase> Login([FromBody] LoginRequest request)
|
||||
{
|
||||
_logger.LogError("login errr");
|
||||
_logger.LogInformation("login");
|
||||
@@ -47,14 +47,13 @@ namespace Store.Api.Controllers
|
||||
// return ApiResult.Error("用户名或密码错误", 401);
|
||||
//}
|
||||
|
||||
//var claims = new List<Claim>
|
||||
//{
|
||||
// new(JwtClaimTypes.UserName, user.UserName),
|
||||
// new(JwtClaimTypes.NickName, user.NickName),
|
||||
//};
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtClaimTypes.UserName, request.UserName),
|
||||
};
|
||||
|
||||
//TokenResult token = _tokenService.CreateToken(claims);
|
||||
return ApiResult.Ok();
|
||||
TokenResult token = _tokenService.CreateToken(claims);
|
||||
return ApiResult.Ok(token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
using Store.Users.Behaviors;
|
||||
using Store.Users.Entities;
|
||||
using YourNamespace;
|
||||
using System.Reflection;
|
||||
using MAX.Framework.Mediation;
|
||||
using MAX.Core.Mediation;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using MAX.Core.DependencyInjection;
|
||||
using MAX.Framework.DependencyInjection;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Store.Users.Commands;
|
||||
using Store.Users.Contracts.Dtos;
|
||||
|
||||
// 使用实际 Startup 注册路径,替换数据库处理器以单独验证 MediatR 管道。
|
||||
var services = new ServiceCollection();
|
||||
var registrarType = typeof(ServiceCollectionExtensions).Assembly
|
||||
.GetType("MAX.Framework.DependencyInjection.ServiceRegistrarProxy", throwOnError: true)!;
|
||||
var registrar = (IServiceRegistrar)Activator.CreateInstance(registrarType, services)!;
|
||||
new Store.Users.Startup().Register(registrar);
|
||||
services.AddMediatRAttributedBehaviors(typeof(ShopRenewCommand).Assembly);
|
||||
services.AddMediatRAttributedBehaviors(typeof(ShopRenewCommand).Assembly); // 重复扫描不重复注册
|
||||
var pipelineType = typeof(IPipelineBehavior<ShopRenewCommand, ShopRenewResponse>);
|
||||
var pipelines = services.Where(s => s.ServiceType == pipelineType).ToArray();
|
||||
if (pipelines.Length != 4
|
||||
|| pipelines[0].ImplementationType?.Name != "ValidationBehavior`2"
|
||||
|| pipelines[1].ImplementationType?.Name != "TransactionBehavior`2"
|
||||
|| pipelines[2].ImplementationType?.Name != "AccountConsumptionBehavior`2"
|
||||
|| pipelines[3].ImplementationType?.Name != "ShopOwnershipBehavior`2")
|
||||
throw new Exception("管道注册顺序错误,应为参数验证、事务、消费检查");
|
||||
// 本测试只验证参数管道;事务与消费管道需在测试库中进行集成测试。
|
||||
services.Remove(pipelines[1]);
|
||||
services.Remove(pipelines[2]);
|
||||
services.Remove(pipelines[3]);
|
||||
services.AddSingleton<ProbeHandler>();
|
||||
services.AddTransient<IRequestHandler<ShopRenewCommand, ShopRenewResponse>>(sp => sp.GetRequiredService<ProbeHandler>());
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var mediator = provider.GetRequiredService<IMediator>();
|
||||
var probe = provider.GetRequiredService<ProbeHandler>();
|
||||
var valid = new ShopRenewCommand("user", "shop", 1);
|
||||
ShopRenewCommand[] invalid =
|
||||
[
|
||||
valid with { UserName = " " }, valid with { UserName = new string('u', 21) },
|
||||
valid with { ShopId = " " }, valid with { ShopId = new string('s', 51) },
|
||||
valid with { RenewDuration = 0 }, valid with { RenewDuration = 121 },
|
||||
valid with { Remark = new string('r', 256) }
|
||||
];
|
||||
foreach (var command in invalid)
|
||||
{
|
||||
try
|
||||
{
|
||||
await mediator.Send(command);
|
||||
throw new Exception("无效参数未被管道拦截");
|
||||
}
|
||||
catch (ValidationException) { }
|
||||
}
|
||||
if (probe.Calls != 0) throw new Exception("无效命令进入了处理器");
|
||||
await mediator.Send(valid);
|
||||
await mediator.Send(valid with
|
||||
{
|
||||
UserName = " " + new string('u', 20) + " ", ShopId = new string('s', 50),
|
||||
RenewDuration = 120, Remark = " " + new string('r', 255) + " "
|
||||
});
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
try
|
||||
{
|
||||
await mediator.Send(valid, cancellation.Token);
|
||||
throw new Exception("已取消命令未被拦截");
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
if (probe.Calls != 2) throw new Exception("有效命令或取消命令的调用次数错误");
|
||||
var minimumQuote = ((IAccountConsumptionRequest)(valid with { RenewDuration = 3 })).CalculateConsumption(new UserEntity { ShopPointsPerMonth = 10 });
|
||||
var customQuote = ((IAccountConsumptionRequest)(valid with { RenewDuration = 2 })).CalculateConsumption(new UserEntity { ShopPointsPerMonth = 45 });
|
||||
if (minimumQuote != new ConsumptionQuote(90m, 30m) || customQuote != new ConsumptionQuote(90m, 45m))
|
||||
throw new Exception("续费最低价格或账户自定义价格计算错误");
|
||||
var reuseServices = new ServiceCollection();
|
||||
reuseServices.AddMediatRAttributedBehaviors(typeof(AccountOnlyRequest).Assembly);
|
||||
var accountBehaviors = reuseServices.Where(s => s.ServiceType == typeof(IPipelineBehavior<AccountOnlyRequest, string>)).ToArray();
|
||||
var shopBehaviors = reuseServices.Where(s => s.ServiceType == typeof(IPipelineBehavior<ShopOnlyRequest, string>)).ToArray();
|
||||
if (accountBehaviors.Length != 2
|
||||
|| accountBehaviors[0].ImplementationType != typeof(TransactionBehavior<AccountOnlyRequest, string>)
|
||||
|| accountBehaviors[1].ImplementationType != typeof(AccountConsumptionBehavior<AccountOnlyRequest, string>)
|
||||
|| shopBehaviors.Length != 1
|
||||
|| shopBehaviors[0].ImplementationType != typeof(ShopOwnershipBehavior<ShopOnlyRequest, string>))
|
||||
throw new Exception("非商铺消费和非消费商铺操作的管道注册未独立");
|
||||
Console.WriteLine("PASS: 独立消费与商铺操作管道注册、续费计费策略验证通过。");
|
||||
|
||||
var orderedServices = new ServiceCollection();
|
||||
orderedServices.AddSingleton<ExecutionLog>();
|
||||
orderedServices.AddTransient<IRequestHandler<OrderRequest, string>, OrderHandler>();
|
||||
orderedServices.AddMediatRAttributedBehaviors(typeof(OrderRequest).Assembly);
|
||||
orderedServices.AddMediatRAttributedBehaviors(typeof(OrderRequest).Assembly);
|
||||
using var orderedProvider = orderedServices.BuildServiceProvider();
|
||||
await orderedProvider.GetRequiredService<IMediator>().Send(new OrderRequest());
|
||||
var events = orderedProvider.GetRequiredService<ExecutionLog>().Events;
|
||||
if (!events.SequenceEqual(new[] { "Outer before", "Inner before", "Handler", "Inner after", "Outer after" }))
|
||||
throw new Exception("特性管道执行顺序不正确:" + string.Join(", ", events));
|
||||
if (pipelines[1].ImplementationType != typeof(TransactionBehavior<ShopRenewCommand, ShopRenewResponse>))
|
||||
throw new Exception("开放泛型事务管道未正确闭合");
|
||||
var executionContext = new UnitOfWorkExecutionContext();
|
||||
var enter = typeof(UnitOfWorkExecutionContext).GetMethod("Enter", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||
IFreeSql CreateOrm() => DispatchProxy.Create<IFreeSql, UnusedOrmProxy>();
|
||||
IDisposable Enter(IFreeSql orm) => (IDisposable)enter.Invoke(executionContext, [orm])!;
|
||||
try
|
||||
{
|
||||
_ = executionContext.Orm;
|
||||
throw new Exception("无事务时不应允许访问 ORM");
|
||||
}
|
||||
catch (InvalidOperationException) { }
|
||||
async Task VerifyIsolation(IFreeSql orm)
|
||||
{
|
||||
using (Enter(orm))
|
||||
{
|
||||
await Task.Yield();
|
||||
if (!ReferenceEquals(executionContext.Orm, orm)) throw new Exception("并行事务上下文串用");
|
||||
try
|
||||
{
|
||||
using var nested = Enter(CreateOrm());
|
||||
throw new Exception("嵌套事务应被拒绝");
|
||||
}
|
||||
catch (TargetInvocationException exception) when (exception.InnerException is InvalidOperationException) { }
|
||||
if (!ReferenceEquals(executionContext.Orm, orm)) throw new Exception("嵌套失败破坏了外层上下文");
|
||||
}
|
||||
if (executionContext.IsActive) throw new Exception("执行结束后上下文未清理");
|
||||
}
|
||||
await Task.WhenAll(VerifyIsolation(CreateOrm()), VerifyIsolation(CreateOrm()));
|
||||
try
|
||||
{
|
||||
using var scope = Enter(CreateOrm());
|
||||
throw new ApplicationException("模拟处理器失败");
|
||||
}
|
||||
catch (ApplicationException) { }
|
||||
if (executionContext.IsActive) throw new Exception("异常后上下文未清理");
|
||||
Console.WriteLine("PASS: 泛型事务管道闭合、并行上下文隔离、嵌套拒绝和异常清理验证通过。");
|
||||
|
||||
Console.WriteLine("PASS: CallerLineNumber 顺序、真实嵌套调用与重复扫描验证通过。");
|
||||
|
||||
Console.WriteLine("PASS: 实际 DI 注册、7 项非法输入、有效边界参数和取消请求验证通过。");
|
||||
|
||||
sealed class ProbeHandler : IRequestHandler<ShopRenewCommand, ShopRenewResponse>
|
||||
{
|
||||
public int Calls { get; private set; }
|
||||
public Task<ShopRenewResponse> Handle(ShopRenewCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
Calls++;
|
||||
return Task.FromResult(new ShopRenewResponse { ShopId = request.ShopId });
|
||||
}
|
||||
}
|
||||
|
||||
[MediatRBehavior(typeof(OuterOrderBehavior))]
|
||||
[MediatRBehavior(typeof(InnerOrderBehavior))]
|
||||
sealed record OrderRequest : IRequest<string>;
|
||||
|
||||
sealed class ExecutionLog
|
||||
{
|
||||
public List<string> Events { get; } = [];
|
||||
}
|
||||
|
||||
sealed class OrderHandler(ExecutionLog log) : IRequestHandler<OrderRequest, string>
|
||||
{
|
||||
public Task<string> Handle(OrderRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
log.Events.Add("Handler");
|
||||
return Task.FromResult("ok");
|
||||
}
|
||||
}
|
||||
|
||||
sealed class OuterOrderBehavior(ExecutionLog log) : IPipelineBehavior<OrderRequest, string>
|
||||
{
|
||||
public async Task<string> Handle(OrderRequest request, CancellationToken cancellationToken,
|
||||
RequestHandlerDelegate<string> next)
|
||||
{
|
||||
log.Events.Add("Outer before");
|
||||
var result = await next();
|
||||
log.Events.Add("Outer after");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
sealed class InnerOrderBehavior(ExecutionLog log) : IPipelineBehavior<OrderRequest, string>
|
||||
{
|
||||
public async Task<string> Handle(OrderRequest request, CancellationToken cancellationToken,
|
||||
RequestHandlerDelegate<string> next)
|
||||
{
|
||||
log.Events.Add("Inner before");
|
||||
var result = await next();
|
||||
log.Events.Add("Inner after");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
public class UnusedOrmProxy : DispatchProxy
|
||||
{
|
||||
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
|
||||
=> throw new NotSupportedException("上下文测试不应访问数据库");
|
||||
}
|
||||
// 非商铺消费:没有 ShopId,也不启用商铺归属管道。
|
||||
[MediatRBehavior(typeof(TransactionBehavior<,>))]
|
||||
[MediatRBehavior(typeof(AccountConsumptionBehavior<,>))]
|
||||
sealed record AccountOnlyRequest(string UserName, decimal Amount) : IRequest<string>, IAccountConsumptionRequest
|
||||
{
|
||||
public AccountConsumptionContext? ConsumptionContext { get; set; }
|
||||
public ConsumptionQuote CalculateConsumption(UserEntity user) => new(Amount, Amount);
|
||||
}
|
||||
|
||||
// 商铺操作:不涉及余额与计费,也可用于不需要事务的读取。
|
||||
[MediatRBehavior(typeof(ShopOwnershipBehavior<,>))]
|
||||
sealed record ShopOnlyRequest(string UserName, string ShopId) : IRequest<string>, IShopOwnershipRequest
|
||||
{
|
||||
public ShopEntity? OwnedShop { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup><OutputType>Exe</OutputType></PropertyGroup>
|
||||
<ItemGroup><ProjectReference Include="../../Modules/Store.Users/Store.Users.csproj" /></ItemGroup>
|
||||
</Project>
|
||||
Reference in new issue
Block a user