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
+
+