From c9ac7412abf0da9916f419e9ce2af304ba2ab2c2 Mon Sep 17 00:00:00 2001 From: chfei Date: Thu, 22 Oct 2026 17:32:58 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0Mediat=20Pip=E7=89=B9?= =?UTF-8?q?=E6=80=A7=EF=BC=8C=E5=B9=B6=E7=94=A8=E5=AE=9E=E4=BE=8B=E6=9D=A5?= =?UTF-8?q?=E5=81=9A=E4=B8=80=E4=B8=AAdemo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MAX.Core/Mediation/IValidatableRequest.cs | 7 + .../Mediation/MediatRBehaviorAttribute.cs | 13 ++ ...iatRBehaviorServiceCollectionExtensions.cs | 71 ++++++ .../ServiceCollectionExtensions.cs | 1 + .../Mediation/TransactionBehavior.cs | 26 +++ .../Mediation/UnitOfWorkExecutionContext.cs | 27 +++ .../Mediation/ValidationBehavior.cs | 18 ++ .../Store.Shops/Entities/ShopExpireEntity.cs | 3 +- .../Behaviors/AccountConsumptionBehavior.cs | 44 ++++ .../Behaviors/ConsumptionContracts.cs | 23 ++ .../Behaviors/ShopOwnershipBehavior.cs | 43 ++++ .../Store.Users/Commands/ShopRenewCommand.cs | 211 ++++++++++++------ .../Store.Users/Controllers/UserController.cs | 60 +++-- Modules/Store.Users/Dtos/ShopRenewResponse.cs | 4 +- Modules/Store.Users/Startup.cs | 6 +- Modules/Store.Users/Store.Users.csproj | 3 +- Store.API/Controllers/AuthController.cs | 15 +- Tests/ShopRenewSmoke/Program.cs | 211 ++++++++++++++++++ Tests/ShopRenewSmoke/ShopRenewSmoke.csproj | 4 + 19 files changed, 682 insertions(+), 108 deletions(-) create mode 100644 Core/MAX.Core/Mediation/IValidatableRequest.cs create mode 100644 Core/MAX.Core/Mediation/MediatRBehaviorAttribute.cs create mode 100644 Core/MAX.Framework/DependencyInjection/MediatRBehaviorServiceCollectionExtensions.cs create mode 100644 Core/MAX.Framework/Mediation/TransactionBehavior.cs create mode 100644 Core/MAX.Framework/Mediation/UnitOfWorkExecutionContext.cs create mode 100644 Core/MAX.Framework/Mediation/ValidationBehavior.cs create mode 100644 Modules/Store.Users/Behaviors/AccountConsumptionBehavior.cs create mode 100644 Modules/Store.Users/Behaviors/ConsumptionContracts.cs create mode 100644 Modules/Store.Users/Behaviors/ShopOwnershipBehavior.cs create mode 100644 Tests/ShopRenewSmoke/Program.cs create mode 100644 Tests/ShopRenewSmoke/ShopRenewSmoke.csproj diff --git a/Core/MAX.Core/Mediation/IValidatableRequest.cs b/Core/MAX.Core/Mediation/IValidatableRequest.cs new file mode 100644 index 0000000..770541d --- /dev/null +++ b/Core/MAX.Core/Mediation/IValidatableRequest.cs @@ -0,0 +1,7 @@ +namespace MAX.Core.Mediation; + +/// 提供请求自身的参数校验;不合法时抛出 ValidationException。 +public interface IValidatableRequest +{ + void Validate(); +} diff --git a/Core/MAX.Core/Mediation/MediatRBehaviorAttribute.cs b/Core/MAX.Core/Mediation/MediatRBehaviorAttribute.cs new file mode 100644 index 0000000..5fe67cd --- /dev/null +++ b/Core/MAX.Core/Mediation/MediatRBehaviorAttribute.cs @@ -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; + +/// 声明命令管道。每个特性独占一行,按源码行号从上到下执行。 +[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; +} diff --git a/Core/MAX.Framework/DependencyInjection/MediatRBehaviorServiceCollectionExtensions.cs b/Core/MAX.Framework/DependencyInjection/MediatRBehaviorServiceCollectionExtensions.cs new file mode 100644 index 0000000..d9f94e3 --- /dev/null +++ b/Core/MAX.Framework/DependencyInjection/MediatRBehaviorServiceCollectionExtensions.cs @@ -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 +{ + /// 扫描命令特性,按声明行号注册闭合管道,适配 MediatR 9。 + public static IServiceCollection AddMediatRAttributedBehaviors(this IServiceCollection services, + params Assembly[] assemblies) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(assemblies); + // 先完成全部校验,避免无效配置留下部分注册。 + var registrations = new List(); + foreach (var request in assemblies.Distinct().SelectMany(a => a.DefinedTypes)) + { + var attributes = request.GetCustomAttributes(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(无返回值使用 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(); + services.TryAddTransient(provider => provider.GetRequiredService); + services.TryAddTransient(); + services.TryAddTransient(provider => provider.GetRequiredService()); + services.TryAddTransient(provider => provider.GetRequiredService()); + foreach (var registration in registrations) + services.TryAddEnumerable(registration); + return services; + } +} diff --git a/Core/MAX.Framework/DependencyInjection/ServiceCollectionExtensions.cs b/Core/MAX.Framework/DependencyInjection/ServiceCollectionExtensions.cs index 9c4788e..4bb0c09 100644 --- a/Core/MAX.Framework/DependencyInjection/ServiceCollectionExtensions.cs +++ b/Core/MAX.Framework/DependencyInjection/ServiceCollectionExtensions.cs @@ -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())); diff --git a/Core/MAX.Framework/Mediation/TransactionBehavior.cs b/Core/MAX.Framework/Mediation/TransactionBehavior.cs new file mode 100644 index 0000000..4c27dfa --- /dev/null +++ b/Core/MAX.Framework/Mediation/TransactionBehavior.cs @@ -0,0 +1,26 @@ +using FreeSql; +using MediatR; + +namespace MAX.Framework.Mediation; + +/// 为通过特性选择的命令管理事务;所有读写使用执行上下文中的 ORM。 +public sealed class TransactionBehavior( + IFreeSql freeSql, UnitOfWorkExecutionContext context) : IPipelineBehavior + where TRequest : IRequest +{ + public async Task Handle(TRequest request, CancellationToken cancellationToken, + RequestHandlerDelegate 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; + // 未提交时,工作单元释放自动回滚;执行上下文同时清理。 + } +} diff --git a/Core/MAX.Framework/Mediation/UnitOfWorkExecutionContext.cs b/Core/MAX.Framework/Mediation/UnitOfWorkExecutionContext.cs new file mode 100644 index 0000000..47f733f --- /dev/null +++ b/Core/MAX.Framework/Mediation/UnitOfWorkExecutionContext.cs @@ -0,0 +1,27 @@ +using System.Threading; + +namespace MAX.Framework.Mediation; + +/// 沿当前异步调用链传递事务 ORM,隔离并行命令的执行状态。 +public sealed class UnitOfWorkExecutionContext +{ + private readonly AsyncLocal _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; + } +} diff --git a/Core/MAX.Framework/Mediation/ValidationBehavior.cs b/Core/MAX.Framework/Mediation/ValidationBehavior.cs new file mode 100644 index 0000000..98ab799 --- /dev/null +++ b/Core/MAX.Framework/Mediation/ValidationBehavior.cs @@ -0,0 +1,18 @@ +using MAX.Core.Mediation; +using MediatR; + +namespace MAX.Framework.Mediation; + +/// 执行请求自身的参数校验,验证通过后才进入后续管道。 +public sealed class ValidationBehavior : IPipelineBehavior + where TRequest : IRequest, IValidatableRequest +{ + public Task Handle(TRequest request, CancellationToken cancellationToken, + RequestHandlerDelegate next) + { + ArgumentNullException.ThrowIfNull(request); + cancellationToken.ThrowIfCancellationRequested(); + request.Validate(); + return next(); + } +} diff --git a/Modules/Store.Shops/Entities/ShopExpireEntity.cs b/Modules/Store.Shops/Entities/ShopExpireEntity.cs index 919d4d1..8950d9e 100644 --- a/Modules/Store.Shops/Entities/ShopExpireEntity.cs +++ b/Modules/Store.Shops/Entities/ShopExpireEntity.cs @@ -25,7 +25,8 @@ namespace Store.Users.Entities /// 过期时间 /// // 数据库列名末尾包含 U+200C,不能改成普通 expire_time。 - [Column(Name = "expire_time")] + [Column(Name = "expire_time‌")] + public int ExpireTime { get; set; } /// diff --git a/Modules/Store.Users/Behaviors/AccountConsumptionBehavior.cs b/Modules/Store.Users/Behaviors/AccountConsumptionBehavior.cs new file mode 100644 index 0000000..43e5fac --- /dev/null +++ b/Modules/Store.Users/Behaviors/AccountConsumptionBehavior.cs @@ -0,0 +1,44 @@ +using MAX.Framework.Commons; +using MAX.Framework.Mediation; +using MediatR; +using Store.Users.Entities; + +namespace Store.Users.Behaviors; + +/// 锁定积分账户并检查消费额度,不依赖商铺信息。 +public sealed class AccountConsumptionBehavior( + UnitOfWorkExecutionContext executionContext) + : IPipelineBehavior + where TRequest : class, IRequest, IAccountConsumptionRequest +{ + public async Task Handle(TRequest request, CancellationToken cancellationToken, + RequestHandlerDelegate next) + { + cancellationToken.ThrowIfCancellationRequested(); + if (string.IsNullOrWhiteSpace(request.UserName)) + throw new AppException("用户名不能为空"); + var userName = request.UserName.Trim(); + var user = await executionContext.Orm.Select().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; + } + } +} diff --git a/Modules/Store.Users/Behaviors/ConsumptionContracts.cs b/Modules/Store.Users/Behaviors/ConsumptionContracts.cs new file mode 100644 index 0000000..2e91feb --- /dev/null +++ b/Modules/Store.Users/Behaviors/ConsumptionContracts.cs @@ -0,0 +1,23 @@ +using Store.Users.Entities; +using YourNamespace; + +namespace Store.Users.Behaviors; + +/// 需要账户积分消费检查的请求。 +public interface IAccountConsumptionRequest +{ + string UserName { get; } + AccountConsumptionContext? ConsumptionContext { get; set; } + ConsumptionQuote CalculateConsumption(UserEntity user); +} + +/// 需要检查商铺归属的请求,与是否消费无关。 +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); diff --git a/Modules/Store.Users/Behaviors/ShopOwnershipBehavior.cs b/Modules/Store.Users/Behaviors/ShopOwnershipBehavior.cs new file mode 100644 index 0000000..586acb1 --- /dev/null +++ b/Modules/Store.Users/Behaviors/ShopOwnershipBehavior.cs @@ -0,0 +1,43 @@ +using MAX.Framework.Commons; +using MAX.Framework.Mediation; +using MediatR; +using YourNamespace; + +namespace Store.Users.Behaviors; + +/// 独立检查商铺归属;事务中执行时锁定商铺,读取场景可不启用事务。 +public sealed class ShopOwnershipBehavior( + IFreeSql freeSql, UnitOfWorkExecutionContext executionContext) : IPipelineBehavior + where TRequest : class, IRequest, IShopOwnershipRequest +{ + public async Task Handle(TRequest request, CancellationToken cancellationToken, + RequestHandlerDelegate 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().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; + } + } +} diff --git a/Modules/Store.Users/Commands/ShopRenewCommand.cs b/Modules/Store.Users/Commands/ShopRenewCommand.cs index 510050e..016031b 100644 --- a/Modules/Store.Users/Commands/ShopRenewCommand.cs +++ b/Modules/Store.Users/Commands/ShopRenewCommand.cs @@ -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 -//{ -// /// -// /// 商铺续费 -// /// -// /// -// /// -// /// -// internal record class ShopRenewCommand(string UserName, string ShopId, int RenewDuration) : IRequest; -// internal class ShopRenewCommandHandler : IRequestHandler -// { -// private readonly IUserRepository _userRepository; -// private readonly IShopQueryService _shopQueryService; +namespace Store.Users.Commands; -// public ShopRenewCommandHandler(IUserRepository userRepository, IShopQueryService shopQueryService) -// { -// _userRepository = userRepository; -// _shopQueryService = shopQueryService; -// } -// async Task IRequestHandler.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); +/// 商铺续费,续费时长单位为月。 +[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, 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 +{ + public async Task 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().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().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() + .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; + } +} diff --git a/Modules/Store.Users/Controllers/UserController.cs b/Modules/Store.Users/Controllers/UserController.cs index add205c..db0ea47 100644 --- a/Modules/Store.Users/Controllers/UserController.cs +++ b/Modules/Store.Users/Controllers/UserController.cs @@ -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 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 GetUser(string userName) + { + return await _userQuery.GetUser(userName); + } + + /// 使用当前登录用户的积分为商铺续费。 + [HttpPost] + public Task 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); + } } diff --git a/Modules/Store.Users/Dtos/ShopRenewResponse.cs b/Modules/Store.Users/Dtos/ShopRenewResponse.cs index fc63cef..62a30fd 100644 --- a/Modules/Store.Users/Dtos/ShopRenewResponse.cs +++ b/Modules/Store.Users/Dtos/ShopRenewResponse.cs @@ -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; } } } diff --git a/Modules/Store.Users/Startup.cs b/Modules/Store.Users/Startup.cs index 7cc1687..ea0d328 100644 --- a/Modules/Store.Users/Startup.cs +++ b/Modules/Store.Users/Startup.cs @@ -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, ShopRenewCommandHandler>(); } public void Start(IServiceProvider serviceProvider) diff --git a/Modules/Store.Users/Store.Users.csproj b/Modules/Store.Users/Store.Users.csproj index 6402cfa..329daf9 100644 --- a/Modules/Store.Users/Store.Users.csproj +++ b/Modules/Store.Users/Store.Users.csproj @@ -1,4 +1,4 @@ - + @@ -7,6 +7,7 @@ + diff --git a/Store.API/Controllers/AuthController.cs b/Store.API/Controllers/AuthController.cs index 973d759..371090e 100644 --- a/Store.API/Controllers/AuthController.cs +++ b/Store.API/Controllers/AuthController.cs @@ -32,7 +32,7 @@ namespace Store.Api.Controllers /// [HttpPost] [AllowAnonymous] - public async Task Login([FromBody] LoginRequest request) + public async Task 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 - //{ - // new(JwtClaimTypes.UserName, user.UserName), - // new(JwtClaimTypes.NickName, user.NickName), - //}; + var claims = new List + { + new(JwtClaimTypes.UserName, request.UserName), + }; - //TokenResult token = _tokenService.CreateToken(claims); - return ApiResult.Ok(); + TokenResult token = _tokenService.CreateToken(claims); + return ApiResult.Ok(token); } /// diff --git a/Tests/ShopRenewSmoke/Program.cs b/Tests/ShopRenewSmoke/Program.cs new file mode 100644 index 0000000..c9acb0e --- /dev/null +++ b/Tests/ShopRenewSmoke/Program.cs @@ -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); +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(); +services.AddTransient>(sp => sp.GetRequiredService()); +using var provider = services.BuildServiceProvider(); +var mediator = provider.GetRequiredService(); +var probe = provider.GetRequiredService(); +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)).ToArray(); +var shopBehaviors = reuseServices.Where(s => s.ServiceType == typeof(IPipelineBehavior)).ToArray(); +if (accountBehaviors.Length != 2 + || accountBehaviors[0].ImplementationType != typeof(TransactionBehavior) + || accountBehaviors[1].ImplementationType != typeof(AccountConsumptionBehavior) + || shopBehaviors.Length != 1 + || shopBehaviors[0].ImplementationType != typeof(ShopOwnershipBehavior)) + throw new Exception("非商铺消费和非消费商铺操作的管道注册未独立"); +Console.WriteLine("PASS: 独立消费与商铺操作管道注册、续费计费策略验证通过。"); + +var orderedServices = new ServiceCollection(); +orderedServices.AddSingleton(); +orderedServices.AddTransient, OrderHandler>(); +orderedServices.AddMediatRAttributedBehaviors(typeof(OrderRequest).Assembly); +orderedServices.AddMediatRAttributedBehaviors(typeof(OrderRequest).Assembly); +using var orderedProvider = orderedServices.BuildServiceProvider(); +await orderedProvider.GetRequiredService().Send(new OrderRequest()); +var events = orderedProvider.GetRequiredService().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)) + throw new Exception("开放泛型事务管道未正确闭合"); +var executionContext = new UnitOfWorkExecutionContext(); +var enter = typeof(UnitOfWorkExecutionContext).GetMethod("Enter", BindingFlags.Instance | BindingFlags.NonPublic)!; +IFreeSql CreateOrm() => DispatchProxy.Create(); +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 +{ + public int Calls { get; private set; } + public Task Handle(ShopRenewCommand request, CancellationToken cancellationToken) + { + Calls++; + return Task.FromResult(new ShopRenewResponse { ShopId = request.ShopId }); + } +} + +[MediatRBehavior(typeof(OuterOrderBehavior))] +[MediatRBehavior(typeof(InnerOrderBehavior))] +sealed record OrderRequest : IRequest; + +sealed class ExecutionLog +{ + public List Events { get; } = []; +} + +sealed class OrderHandler(ExecutionLog log) : IRequestHandler +{ + public Task Handle(OrderRequest request, CancellationToken cancellationToken) + { + log.Events.Add("Handler"); + return Task.FromResult("ok"); + } +} + +sealed class OuterOrderBehavior(ExecutionLog log) : IPipelineBehavior +{ + public async Task Handle(OrderRequest request, CancellationToken cancellationToken, + RequestHandlerDelegate next) + { + log.Events.Add("Outer before"); + var result = await next(); + log.Events.Add("Outer after"); + return result; + } +} + +sealed class InnerOrderBehavior(ExecutionLog log) : IPipelineBehavior +{ + public async Task Handle(OrderRequest request, CancellationToken cancellationToken, + RequestHandlerDelegate 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, 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, IShopOwnershipRequest +{ + public ShopEntity? OwnedShop { get; set; } +} \ No newline at end of file diff --git a/Tests/ShopRenewSmoke/ShopRenewSmoke.csproj b/Tests/ShopRenewSmoke/ShopRenewSmoke.csproj new file mode 100644 index 0000000..0ad4553 --- /dev/null +++ b/Tests/ShopRenewSmoke/ShopRenewSmoke.csproj @@ -0,0 +1,4 @@ + + Exe + +