diff --git a/Core/MAX.Framework/Conventions/ApiRoutePrefixConvention.cs b/Core/MAX.Framework/Conventions/ApiRoutePrefixConvention.cs
new file mode 100644
index 0000000..5e6ce98
--- /dev/null
+++ b/Core/MAX.Framework/Conventions/ApiRoutePrefixConvention.cs
@@ -0,0 +1,55 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ApplicationModels;
+using Microsoft.AspNetCore.Mvc.Infrastructure;
+
+namespace MAX.Framework.Conventions;
+
+/// 为 API 控制器的属性路由统一添加 api 前缀,已含前缀的路由保持不变。
+public sealed class ApiRoutePrefixConvention : IApplicationModelConvention
+{
+ public void Apply(ApplicationModel application)
+ {
+ foreach (var controller in application.Controllers)
+ {
+ if (!controller.Attributes.OfType().Any()
+ && !controller.ControllerType.Assembly.IsDefined(typeof(ApiControllerAttribute), true))
+ {
+ continue;
+ }
+
+ var hasControllerRoute = controller.Selectors.Any(selector => selector.AttributeRouteModel != null);
+ foreach (var selector in controller.Selectors)
+ {
+ if (selector.AttributeRouteModel is { } route)
+ {
+ AddPrefix(route);
+ }
+ }
+
+ foreach (var action in controller.Actions)
+ foreach (var selector in action.Selectors)
+ {
+ // 绝对动作路由会覆盖控制器路由,因此需要单独添加前缀。
+ if (selector.AttributeRouteModel is { } route
+ && (!hasControllerRoute || route.IsAbsoluteTemplate))
+ {
+ AddPrefix(route);
+ }
+ }
+ }
+ }
+
+ private static void AddPrefix(AttributeRouteModel route)
+ {
+ var template = route.Template ?? "";
+ var absolute = route.IsAbsoluteTemplate;
+ template = template.StartsWith("~/", StringComparison.Ordinal) ? template[2..] : template.TrimStart('/');
+ if (!template.Equals("api", StringComparison.OrdinalIgnoreCase)
+ && !template.StartsWith("api/", StringComparison.OrdinalIgnoreCase))
+ {
+ template = string.IsNullOrEmpty(template) ? "api" : $"api/{template}";
+ }
+
+ route.Template = absolute ? $"~/{template}" : template;
+ }
+}
diff --git a/Core/MAX.Framework/Conventions/DefaultHttpGetAttribute.cs b/Core/MAX.Framework/Conventions/DefaultHttpGetAttribute.cs
new file mode 100644
index 0000000..8f0fc11
--- /dev/null
+++ b/Core/MAX.Framework/Conventions/DefaultHttpGetAttribute.cs
@@ -0,0 +1,28 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc.ActionConstraints;
+using Microsoft.AspNetCore.Mvc.ApplicationModels;
+using Microsoft.AspNetCore.Routing;
+
+namespace MAX.Framework.Conventions;
+
+///
+/// 控制器内未声明 HTTP 方法的操作默认只接受 GET,供各模块复用。
+/// 显式 HTTP 方法声明优先;同时补充路由约束和端点元数据,保证接口文档一致。
+///
+[AttributeUsage(AttributeTargets.Class, Inherited = true)]
+public sealed class DefaultHttpGetAttribute : Attribute, IControllerModelConvention
+{
+ public void Apply(ControllerModel controller)
+ {
+ foreach (var action in controller.Actions)
+ foreach (var selector in action.Selectors)
+ {
+ if (selector.ActionConstraints.OfType().Any()
+ || selector.EndpointMetadata.OfType().Any())
+ continue;
+
+ selector.ActionConstraints.Add(new HttpMethodActionConstraint([HttpMethods.Get]));
+ selector.EndpointMetadata.Add(new HttpMethodMetadata([HttpMethods.Get]));
+ }
+ }
+}
diff --git a/Core/MAX.Framework/Conventions/DefaultHttpPostAttribute.cs b/Core/MAX.Framework/Conventions/DefaultHttpPostAttribute.cs
new file mode 100644
index 0000000..47001e5
--- /dev/null
+++ b/Core/MAX.Framework/Conventions/DefaultHttpPostAttribute.cs
@@ -0,0 +1,28 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc.ActionConstraints;
+using Microsoft.AspNetCore.Mvc.ApplicationModels;
+using Microsoft.AspNetCore.Routing;
+
+namespace MAX.Framework.Conventions;
+
+///
+/// 控制器内未声明 HTTP 方法的操作默认只接受 POST。
+/// 同时补充动作约束和端点元数据,使路由匹配与 OpenAPI 文档保持一致;显式方法声明优先。
+///
+[AttributeUsage(AttributeTargets.Class, Inherited = true)]
+public sealed class DefaultHttpPostAttribute : Attribute, IControllerModelConvention
+{
+ public void Apply(ControllerModel controller)
+ {
+ foreach (var action in controller.Actions)
+ foreach (var selector in action.Selectors)
+ {
+ if (selector.ActionConstraints.OfType().Any()
+ || selector.EndpointMetadata.OfType().Any())
+ continue;
+
+ selector.ActionConstraints.Add(new HttpMethodActionConstraint([HttpMethods.Post]));
+ selector.EndpointMetadata.Add(new HttpMethodMetadata([HttpMethods.Post]));
+ }
+ }
+}
diff --git a/Core/MAX.Framework/Validation/RequestValidator.cs b/Core/MAX.Framework/Validation/RequestValidator.cs
new file mode 100644
index 0000000..e697129
--- /dev/null
+++ b/Core/MAX.Framework/Validation/RequestValidator.cs
@@ -0,0 +1,27 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace MAX.Framework.Validation;
+
+/// 通用请求校验入口,供各模块复用,不依赖 HTTP、数据库或具体业务。
+public static class RequestValidator
+{
+ ///
+ /// 检查对象非空,并验证全部属性上的数据注解及 IValidatableObject 规则。
+ /// 不递归验证子对象;对象为空抛出 ArgumentNullException,规则不满足抛出 ValidationException。
+ ///
+ public static void Validate(object? request)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ Validator.ValidateObject(request, new ValidationContext(request), validateAllProperties: true);
+ }
+
+ /// 不抛出规则验证异常,适用于登录等需要自行处理校验结果的入口;对象为空仍视为调用错误。
+ public static bool TryValidate(object? request, out IReadOnlyList errors)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ var results = new List();
+ bool valid = Validator.TryValidateObject(request, new ValidationContext(request), results, validateAllProperties: true);
+ errors = results.AsReadOnly();
+ return valid;
+ }
+}
diff --git a/Modules/Store.Users.Contracts/Dtos/UserFundsRequest.cs b/Modules/Store.Users.Contracts/Dtos/UserFundsRequest.cs
new file mode 100644
index 0000000..aec5b74
--- /dev/null
+++ b/Modules/Store.Users.Contracts/Dtos/UserFundsRequest.cs
@@ -0,0 +1,15 @@
+using System.ComponentModel.DataAnnotations;
+namespace Store.Users.Contracts.Dtos;
+
+/// 资金请求;DTO 只声明非空规则,金额、长度和操作规则由服务判断。
+public sealed class UserFundsRequest
+{
+ [Required(ErrorMessage = "目标账户不能为空")] public string UserName { get; set; } = "";
+ [Required(ErrorMessage = "请求号不能为空")] public string RequestId { get; set; } = "";
+ public decimal Amount { get; set; }
+ /// 积分支持充值、转移;余额支持充值、消费。
+ [Required(ErrorMessage = "操作类型不能为空")] public string Operation { get; set; } = "";
+ public string? Remark { get; set; }
+}
+
+public sealed record UserFundsResponse(string UserName, decimal Value, string RequestId);
diff --git a/Modules/Store.Users.Contracts/Dtos/UserIdentityRequests.cs b/Modules/Store.Users.Contracts/Dtos/UserIdentityRequests.cs
new file mode 100644
index 0000000..acf291b
--- /dev/null
+++ b/Modules/Store.Users.Contracts/Dtos/UserIdentityRequests.cs
@@ -0,0 +1,22 @@
+using System.ComponentModel.DataAnnotations;
+namespace Store.Users.Contracts.Dtos;
+
+/// 按用户名查询账户;仅声明用户名非空。
+public sealed class UserNameRequest
+{
+ [Required(ErrorMessage = "用户名不能为空")]
+ public string UserName { get; set; } = "";
+}
+
+/// 登录输入;长度、账号是否存在及密码匹配由认证服务处理。
+public sealed class UserLoginRequest
+{
+ [Required(ErrorMessage = "用户名不能为空")] public string UserName { get; set; } = "";
+ [Required(ErrorMessage = "密码不能为空")] public string Password { get; set; } = "";
+}
+
+/// 按代理编码查询充值二维码。
+public sealed class UserTopUpQrcodeQuery
+{
+ [Required(ErrorMessage = "代理编码不能为空")] public string AgentCode { get; set; } = "";
+}
diff --git a/Modules/Store.Users.Contracts/Dtos/UserInfo.cs b/Modules/Store.Users.Contracts/Dtos/UserInfo.cs
index 1c9990f..b6fe891 100644
--- a/Modules/Store.Users.Contracts/Dtos/UserInfo.cs
+++ b/Modules/Store.Users.Contracts/Dtos/UserInfo.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text;
@@ -24,7 +24,8 @@ namespace Store.Users.Contracts.Dtos
///
/// 登录密码
///
- public string Password { get; set; }
+ [System.Text.Json.Serialization.JsonIgnore]
+ public string Password { get; set; }
///
/// card_id
diff --git a/Modules/Store.Users.Contracts/Dtos/UserManagementRequests.cs b/Modules/Store.Users.Contracts/Dtos/UserManagementRequests.cs
new file mode 100644
index 0000000..7efcf83
--- /dev/null
+++ b/Modules/Store.Users.Contracts/Dtos/UserManagementRequests.cs
@@ -0,0 +1,67 @@
+using System.ComponentModel.DataAnnotations;
+namespace Store.Users.Contracts.Dtos;
+
+/// 账户资料;null 保留原值,空字符串清空文本。长度与范围由服务检查。
+public class UserEditRequest
+{
+ public string? NickName { get; set; }
+ public string? Mobile { get; set; }
+ public string? CompanyName { get; set; }
+ public string? Notes { get; set; }
+ public string? CardId { get; set; }
+ public byte? GroupId { get; set; }
+ /// 仅管理员可设置;子账号不能成为管理员。
+ public short? SaLevel { get; set; }
+ public string? Authority { get; set; }
+ public string? ForbiddenPage { get; set; }
+ public string? OemId { get; set; }
+ public int? ShopPointsPerMonth { get; set; }
+ public decimal? PostagePerOrder { get; set; }
+ public decimal? RefundPostagePerOrder { get; set; }
+ public decimal? GiftOemAmountPerOrder { get; set; }
+ public decimal? SyncLogisticsPerOrder { get; set; }
+}
+
+/// 修改账户资料;账户 ID 与资料字段统一通过 JSON 请求体提交。
+public sealed class UserUpdateRequest : UserEditRequest
+{
+ public int Id { get; set; }
+}
+
+/// 创建主账号或当前主账号的直属子账号。
+public sealed class UserCreateRequest : UserEditRequest
+{
+ [Required(ErrorMessage = "用户名不能为空")] public string UserName { get; set; } = "";
+ [Required(ErrorMessage = "密码不能为空")] public string Password { get; set; } = "";
+}
+
+/// 修改本人密码,必须校验旧密码。
+public sealed class ChangePasswordRequest
+{
+ [Required(ErrorMessage = "原密码不能为空")] public string OldPassword { get; set; } = "";
+ [Required(ErrorMessage = "新密码不能为空")] public string NewPassword { get; set; } = "";
+}
+
+/// 分页参数;页码、分页大小和过滤值由服务检查。
+public class UserSearchRequest
+{
+ public int Page { get; set; } = 1;
+ public int PageSize { get; set; } = 50;
+ public string? Keyword { get; set; }
+ public byte? GroupId { get; set; }
+}
+
+public sealed record UserPage(long Total, IReadOnlyList Items);
+
+/// 流水查询支持时间范围、操作类型、店铺及订单过滤。
+public sealed class UserRecordSearchRequest : UserSearchRequest
+{
+ /// 目标账户;省略时查询本人。
+ public string? UserName { get; set; }
+ public DateTime? StartTime { get; set; }
+ public DateTime? EndTime { get; set; }
+ public string? Operation { get; set; }
+ public string? ShopId { get; set; }
+ public int? PaymentType { get; set; }
+ public string? OrderId { get; set; }
+}
diff --git a/Modules/Store.Users.Contracts/Dtos/UserTopUpRequests.cs b/Modules/Store.Users.Contracts/Dtos/UserTopUpRequests.cs
new file mode 100644
index 0000000..5158332
--- /dev/null
+++ b/Modules/Store.Users.Contracts/Dtos/UserTopUpRequests.cs
@@ -0,0 +1,21 @@
+using System.ComponentModel.DataAnnotations;
+namespace Store.Users.Contracts.Dtos;
+
+/// 用户提交的充值备注。
+public sealed class UserTopUpMessageRequest
+{
+ [Required(ErrorMessage = "充值备注不能为空")] public string Message { get; set; } = "";
+}
+
+public sealed class UserTopUpResultRequest
+{
+ public long Id { get; set; }
+ [Required(ErrorMessage = "处理结果不能为空")] public string Result { get; set; } = "";
+}
+
+/// 充值二维码;长度与 Base64 格式在服务中检查。
+public sealed class UserTopUpQrcodeRequest
+{
+ [Required(ErrorMessage = "代理编码不能为空")] public string AgentCode { get; set; } = "";
+ [Required(ErrorMessage = "二维码不能为空")] public string QrcodeBase64 { get; set; } = "";
+}
diff --git a/Modules/Store.Users.Contracts/IUserAuthenticationService.cs b/Modules/Store.Users.Contracts/IUserAuthenticationService.cs
new file mode 100644
index 0000000..441bdaf
--- /dev/null
+++ b/Modules/Store.Users.Contracts/IUserAuthenticationService.cs
@@ -0,0 +1,8 @@
+using Store.Users.Contracts.Dtos;
+namespace Store.Users.Contracts;
+
+/// 用户认证入口,统一供宿主签发登录令牌之前调用。
+public interface IUserAuthenticationService
+{
+ Task Authenticate(string userName, string password, CancellationToken cancellationToken = default);
+}
diff --git a/Modules/Store.Users/Commands/ShopRenewCommand.cs b/Modules/Store.Users/Commands/ShopRenewCommand.cs
index 016031b..a0988f6 100644
--- a/Modules/Store.Users/Commands/ShopRenewCommand.cs
+++ b/Modules/Store.Users/Commands/ShopRenewCommand.cs
@@ -1,4 +1,6 @@
-using System.ComponentModel.DataAnnotations;
+using Store.Users.Services;
+using MAX.Framework.Validation;
+using Store.Users.Dtos;
using YourNamespace;
using MAX.Framework.Mediation;
using MAX.Core.Mediation;
@@ -25,14 +27,11 @@ public sealed record ShopRenewCommand(string UserName, string ShopId, int RenewD
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个字符");
+ RequestValidator.Validate(new UserNameRequest { UserName = UserName?.Trim()! });
+ UserInputChecks.Length(UserName.Trim(), 20, "用户名");
+ var request = new ShopRenewRequest { ShopId = ShopId, RenewDuration = RenewDuration, Remark = Remark };
+ RequestValidator.Validate(request);
+ UserInputChecks.Renew(request);
}
ConsumptionQuote IAccountConsumptionRequest.CalculateConsumption(UserEntity user)
diff --git a/Modules/Store.Users/Controllers/UserController.cs b/Modules/Store.Users/Controllers/UserController.cs
index db0ea47..2f77b6b 100644
--- a/Modules/Store.Users/Controllers/UserController.cs
+++ b/Modules/Store.Users/Controllers/UserController.cs
@@ -1,45 +1,143 @@
-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.Dtos;
+using Store.Users.Entities;
+using Store.Users.Services;
+using MAX.Framework.Dtos;
+using MAX.Framework.Conventions;
namespace Store.Users.Controllers;
+/// 用户模块接口;所有管理操作均以当前 JWT 对应的数据库账户进行授权。
[ApiController]
-[ApiVersionNeutral]
-[Route("user/[action]")]
-public class UserController : ControllerBase
+[DefaultHttpPost]
+[Authorize]
+[Route("v{version:apiVersion}/user/[action]")]
+public class UserController(UserManagementService users, UserFundsService funds, IMediator mediator, ICurrentUser currentUser) : ControllerBase
{
- private readonly IUserQueryService _userQuery;
- private readonly IMediator _mediator;
- private readonly ICurrentUser _currentUser;
-
- public UserController(IUserQueryService userQuery, IMediator mediator, ICurrentUser currentUser)
+ /// 获取本人或授权范围内的用户信息,响应不包含密码。
+ public Task GetUser(string? userName, CancellationToken cancellationToken)
{
- _userQuery = userQuery;
- _mediator = mediator;
- _currentUser = currentUser;
+ return users.GetUser(userName, cancellationToken);
}
- [HttpGet]
- public async Task GetUser(string userName)
+ /// 管理员分页查询所有用户。
+ public Task> GetAllUser(UserSearchRequest request, CancellationToken cancellationToken)
{
- return await _userQuery.GetUser(userName);
+ return users.Search(request, false, cancellationToken);
+ }
+
+ /// 分页查询当前主账号的直属子账号。
+ public Task> GetSubUser(UserSearchRequest request, CancellationToken cancellationToken)
+ {
+ return users.Search(request, true, cancellationToken);
+ }
+
+ /// 管理员创建主账号。
+ public Task AddUser(UserCreateRequest request, CancellationToken cancellationToken)
+ {
+ return users.Create(request, false, cancellationToken);
+ }
+
+ /// 主账号创建直属子账号。
+ public Task AddSubUser(UserCreateRequest request, CancellationToken cancellationToken)
+ {
+ return users.Create(request, true, cancellationToken);
+ }
+
+ /// 管理员修改账户资料,用户名不可变更。
+ public Task UpdateUser(UserUpdateRequest request, CancellationToken cancellationToken)
+ {
+ return users.Update(request.Id, request, false, cancellationToken);
+ }
+
+ /// 主账号修改直属子账号资料。
+ public Task UpdateSubUser(UserUpdateRequest request, CancellationToken cancellationToken)
+ {
+ return users.Update(request.Id, request, true, cancellationToken);
+ }
+
+ /// 删除无资金和业务关联的账户。
+ public async Task DelUser(int id, CancellationToken cancellationToken)
+ {
+ await users.Delete(id, cancellationToken);
+ return ApiResult.Ok();
+ }
+
+ /// 校验旧密码并修改本人密码,成功后需重新登录。
+ public async Task ChangePassword(ChangePasswordRequest request, CancellationToken cancellationToken)
+ {
+ await users.ChangePassword(request, cancellationToken);
+ return ApiResult.Ok();
+ }
+
+ /// 查询授权账户的积分流水。
+ public Task> GetUserPoints(UserRecordSearchRequest request, CancellationToken cancellationToken)
+ {
+ return users.Points(request.UserName, request, cancellationToken);
+ }
+
+ /// 查询授权账户的余额流水。
+ public Task> GetUserBalance(UserRecordSearchRequest request, CancellationToken cancellationToken)
+ {
+ return users.Balance(request.UserName, request, cancellationToken);
+ }
+
+ /// 管理员充值积分,或当前用户向其他账户转移自己的积分。
+ public Task UpdateUserPoints(UserFundsRequest request, CancellationToken cancellationToken)
+ {
+ return funds.Change(request, true, cancellationToken);
+ }
+
+ /// 管理员充值或扣减余额,自动记录审计流水。
+ public Task UpdateUserBalance(UserFundsRequest request, CancellationToken cancellationToken)
+ {
+ return funds.Change(request, false, cancellationToken);
+ }
+
+ /// 提交本人的充值备注。
+ public Task AddUserTopUpMsg(UserTopUpMessageRequest request, CancellationToken cancellationToken)
+ {
+ return users.AddTopUpMessage(request, cancellationToken);
+ }
+
+ /// 管理员填写充值备注的处理结果。
+ public async Task UpdateUserTopUpMsg(UserTopUpResultRequest request, CancellationToken cancellationToken)
+ {
+ await users.UpdateTopUpMessage(request, cancellationToken);
+ return ApiResult.Ok();
+ }
+
+ /// 分页查询充值备注。
+ public Task> GetUserTopUpMsg(UserSearchRequest request, CancellationToken cancellationToken)
+ {
+ return users.TopUpMessages(request, cancellationToken);
+ }
+
+ /// 管理员维护代理充值二维码。
+ public async Task UpdateTopUpQrcode(UserTopUpQrcodeRequest request, CancellationToken cancellationToken)
+ {
+ await users.UpdateTopUpQrcode(request, cancellationToken);
+ return ApiResult.Ok();
+ }
+
+ /// 获取代理充值二维码。
+ public Task GetTopUpQrcode(string agentCode, CancellationToken cancellationToken)
+ {
+ return users.GetTopUpQrcode(agentCode, cancellationToken);
}
/// 使用当前登录用户的积分为商铺续费。
- [HttpPost]
- public Task ShopRenew([FromBody] ShopRenewRequest request, CancellationToken cancellationToken)
+ public Task ShopRenew(ShopRenewRequest request, CancellationToken cancellationToken)
{
- if (!_currentUser.IsAuthenticated || string.IsNullOrWhiteSpace(_currentUser.UserName))
+ if (!currentUser.IsAuthenticated || string.IsNullOrWhiteSpace(currentUser.UserName))
+ {
throw new UnauthorizedAccessException();
-
- return _mediator.Send(new ShopRenewCommand(
- _currentUser.UserName, request.ShopId, request.RenewDuration, request.Remark), cancellationToken);
+ }
+ return mediator.Send(new ShopRenewCommand(currentUser.UserName, request.ShopId, request.RenewDuration, request.Remark), cancellationToken);
}
}
diff --git a/Modules/Store.Users/Dtos/ShopRenewRequest.cs b/Modules/Store.Users/Dtos/ShopRenewRequest.cs
index 7c4fa77..2cfd990 100644
--- a/Modules/Store.Users/Dtos/ShopRenewRequest.cs
+++ b/Modules/Store.Users/Dtos/ShopRenewRequest.cs
@@ -1,27 +1,11 @@
-using System;
-using System.Collections.Generic;
-using System.Text;
+using System.ComponentModel.DataAnnotations;
+namespace Store.Users.Dtos;
-namespace Store.Users.Dtos
+/// 续费请求;仅声明店铺ID非空,时长和长度由服务或命令处理。
+public class ShopRenewRequest
{
- ///
- /// 用户续费商铺
- ///
- public class ShopRenewRequest
- {
- ///
- /// 店铺ID
- ///
- public string ShopId { get; set; }
-
- ///
- /// 续费时长数值,例如 3;范围 1-120 个月
- ///
- public int RenewDuration { get; set; }
-
- ///
- /// 备注
- ///
- public string Remark { get; set; }
- }
+ [Required(ErrorMessage = "店铺ID不能为空")] public string ShopId { get; set; } = "";
+ /// 续费时长,单位月,合法范围1-120。
+ public int RenewDuration { get; set; }
+ public string? Remark { get; set; }
}
diff --git a/Modules/Store.Users/Entities/UserTopUpEntity.cs b/Modules/Store.Users/Entities/UserTopUpEntity.cs
new file mode 100644
index 0000000..928b967
--- /dev/null
+++ b/Modules/Store.Users/Entities/UserTopUpEntity.cs
@@ -0,0 +1,21 @@
+using FreeSql.DataAnnotations;
+namespace Store.Users.Entities;
+
+/// 用户提交的积分充值备注。处理结果仅由管理员填写,不自动变更资金。
+[Table(Name = "wxxd.user_points_top_up_msg")]
+public sealed class UserTopUpMessageEntity
+{
+ [Column(Name = "id", IsPrimary = true, IsIdentity = true)] public long Id { get; set; }
+ [Column(Name = "user_name")] public string UserName { get; set; } = "";
+ [Column(Name = "nick_name")] public string NickName { get; set; } = "";
+ [Column(Name = "msg")] public string Message { get; set; } = "";
+ [Column(Name = "op_rst")] public string? Result { get; set; }
+}
+
+/// 按代理编码保存的充值二维码;代理编码沿用旧表的唯一键。
+[Table(Name = "wxxd.user_top_up_qrcode")]
+public sealed class UserTopUpQrcodeEntity
+{
+ [Column(Name = "agent_code", IsPrimary = true)] public string AgentCode { get; set; } = "";
+ [Column(Name = "qrcode_base64")] public string QrcodeBase64 { get; set; } = "";
+}
diff --git a/Modules/Store.Users/Mappers/UserMapper.cs b/Modules/Store.Users/Mappers/UserMapper.cs
index b6c5071..01f6f04 100644
--- a/Modules/Store.Users/Mappers/UserMapper.cs
+++ b/Modules/Store.Users/Mappers/UserMapper.cs
@@ -1,15 +1,14 @@
-using Riok.Mapperly.Abstractions;
+using Riok.Mapperly.Abstractions;
using Store.Users.Contracts.Dtos;
using Store.Users.Entities;
-using System;
-using System.Collections.Generic;
-using System.Text;
-namespace Store.Users.Mappers
+namespace Store.Users.Mappers;
+
+/// 账户输出映射;任何查询路径均不携带密码值。
+[Mapper]
+internal partial class UserMapper
{
- [Mapper]
- internal partial class UserMapper
- {
- public partial UserInfo ToInfo(UserEntity entity);
- }
+ [MapperIgnoreSource(nameof(UserEntity.Password))]
+ [MapperIgnoreTarget(nameof(UserInfo.Password))]
+ public partial UserInfo ToInfo(UserEntity entity);
}
diff --git a/Modules/Store.Users/README.md b/Modules/Store.Users/README.md
new file mode 100644
index 0000000..16e4d9b
--- /dev/null
+++ b/Modules/Store.Users/README.md
@@ -0,0 +1,87 @@
+# 用户模块迁移与运行
+
+本模块参考 `D:/Works/KJD_WebService/KJD_WebService/BLL/WXXD/WXUserManage.cs` 重写,保留旧表映射,采用 ASP.NET Core 控制器、DTO、服务、FreeSql 仓储和现有 JWT 身份体系。所有新代码均含中文用途或关键规则注释。
+
+## 文件与职责
+
+- 文件:`Controllers/UserController.cs`:`/user/[action]` 接口,统一要求 Bearer Token。
+- 文件:`Services/UserManagementService.cs`:主子账号管理、权限检查、分页、密码修改和流水查询。
+- 文件:`Services/UserManagementService.TopUp.cs`:充值备注与代理二维码。
+- 文件:`Services/UserFundsService.cs`:积分充值、积分转移、余额充值/消费;事务、行锁、请求去重与流水写入。
+- 文件:`Services/UserQueryService.cs`、`Repositories/UserRepository.cs`:认证和查询、主子账号有效配置继承。
+- 文件:`Services/UserPassword.cs`:带盐哈希密码与旧明文密码兼容。
+- 文件:`../Store.Users.Contracts/Dtos/UserManagementRequests.cs`、`UserFundsRequest.cs`、`UserTopUpRequests.cs`:输入验证与响应契约。
+- 文件:`Database/prepare-user-module.sql`:上线前密码列、唯一用户名、事务引擎及请求号列检查。
+- 文件:`../../Store.API/Controllers/AuthController.cs`:登录签发 JWT 前调用真实密码验证,不再接受任意密码。
+- 文件:`../../Tests/UserManagementSmoke/Program.cs`:临时 SQLite 集成验证,避免访问业务数据库。
+
+## 接口
+
+HTTP 方法约定:控制器通过 [DefaultHttpPost] 启用默认 POST。所有操作只接受 POST,无需逐个标记 [HttpPost]。该约定同时生成路由约束和端点元数据,接口文档也显示 POST。
+
+| 方法 | 路径 | 访问与行为 |
+| --- | --- | --- |
+| POST | /user/GetUser?userName=... | 本人、直属主账号或管理员;省略用户名读取本人 |
+| POST | /user/GetAllUser | 管理员分页查询,支持 Keyword、GroupId |
+| POST | /user/GetSubUser | 当前主账号直属子账号分页查询 |
+| POST | /user/AddUser | 管理员新增主账号 |
+| POST | /user/AddSubUser | 当前主账号新增直属子账号 |
+| POST | /user/UpdateUser | 管理员修改资料 |
+| POST | /user/UpdateSubUser | 主账号修改直属子账号 |
+| POST | /user/DelUser?id=... | 管理员或直属主账号删除空账户 |
+| POST | /user/ChangePassword | 本人校验旧密码后修改,会话立即失效 |
+| POST | /user/GetUserPoints | 授权账户积分流水,支持起止时间、操作类型、店铺过滤 |
+| POST | /user/GetUserBalance | 授权账户余额流水,支持起止时间、支付类型、订单过滤 |
+| POST | /user/UpdateUserPoints | 管理员充值;本人向其他账户转移自己的积分 |
+| POST | /user/UpdateUserBalance | 管理员充值或消费调整 |
+| POST | /user/AddUserTopUpMsg | 本人提交充值备注 |
+| POST | /user/UpdateUserTopUpMsg | 管理员填写处理结果,不自动充值 |
+| POST | /user/GetUserTopUpMsg | 本人备注或管理员查看全部 |
+| POST | /user/UpdateTopUpQrcode | 管理员维护代理二维码 |
+| POST | /user/GetTopUpQrcode?agentCode=... | 登录用户读取代理二维码 |
+| POST | /user/ShopRenew | 保留原有 MediatR 事务续费用例 |
+
+分页查询与流水查询通过 JSON 请求体提交;流水查询的 userName 与筛选字段放在同一对象中。UpdateUser、UpdateSubUser 使用 JSON 请求体,例如 `{"id":123,"nickName":"新昵称"}`。仅有单个业务参数的 GetUser、DelUser、GetTopUpQrcode 仍使用查询参数。
+
+分页 Page 从 1 开始,PageSize 默认 50,最大 200。流水时间参数为 StartTime、EndTime(包含边界)。资料更新 null 表示保留原值,空字符串清空文本字段。账户名不可修改,以免破坏历史关联。SaLevel 仅管理员可设置为 0/1,子账号不能成为管理员。普通主账号不能授予自己没有的权限,也不能修改计费配置。子账号积分用户权限、OEM 和每单费用继承主账号,保留 2.2/2.5 元费用下限。
+
+资金请求示例:
+
+```json
+{"userName":"target","requestId":"客户端生成的唯一请求号","amount":100,"operation":"充值","remark":"人工充值已核实"}
+```
+
+积分 Operation 支持“充值”“转移”;余额支持“充值”“消费”。Amount 始终为正数,最多两位小数。转移仅从当前登录账户转出,不支持负金额拉取对方积分。相同目标账户和资金类型下,重复 RequestId 返回已处理错误,不再次记账;调用失败重试必须保留请求号。不同请求号视作不同业务,调用方需确保业务唯一性。积分转入累计充值口径沿用旧系统。余额流水类型沿用旧系统充值 1、人工消费 0;商铺续费继续走专用接口。
+
+## 运行与部署
+
+1. 检查现有 MySQL `DefaultConnection` 配置和已有表。确认 `wxxd.user` 中存在可登录的 `sa_level=1` 管理员。
+2. 审阅并执行 `Database/prepare-user-module.sql`。应用不自动建表或改表;资金表必须是 InnoDB,用户名必须有唯一索引。
+3. 构建:`dotnet build Store.API/Store.Api.csproj`。
+4. 验证:`dotnet run --project Tests/UserManagementSmoke/UserManagementSmoke.csproj`,以及原有 `dotnet run --project Tests/ShopRenewSmoke/ShopRenewSmoke.csproj`。
+5. 启动:`dotnet run --project Store.API/Store.Api.csproj`。先调用宿主 `/api/v1.0/Auth/Login`,再携带 Bearer Token 调用用户接口。
+
+认证兼容 WXUserManage 的旧明文密码和 Identity 带盐哈希,登录时仅验证,不修改密码;新建和修改密码直接保存哈希。旧项目按 SQL 明文比较密码的登录接口无法识别哈希密码,共用数据库时需同步切换相关调用方。新接口采用结构化 JSON,不兼容旧 inputData 拼接字符串及 time/code 签名。UserInfo 保留 Password 属性用于源码兼容,但不映射值、不向 JSON 序列化。
+
+## 迁移边界与验证限制
+
+旧文件中的 E57 第三方令牌、微信销售助手 Cookie、淘宝买手管理属于外部系统或独立业务,未加入用户账户模块。本次未修改旧项目或调用第三方服务。
+
+集成验证使用临时 SQLite 库,覆盖认证、登录不修改密码、主子账号、越权、计费、资金写入、重复请求、超扣、强制流水失败的事务回滚、删除边界、备注和二维码更新。MySQL 的真实并发、行锁、历史表结构和旧客户端迁移仍需在测试库联调。可使用“代码审查”继续检查实现质量。
+
+## 输入校验约定
+
+User 模块的请求 DTO 只保留 `[Required]`,校验必填、null、空字符串和纯空白字符串。服务使用 `MAX.Framework.Validation.RequestValidator.Validate(request)` 统一执行这类非空校验,防止内部调用绕过 HTTP 模型验证。
+
+```csharp
+using MAX.Framework.Validation;
+
+RequestValidator.Validate(request);
+// 非空之外的规则由服务继续手动判断。
+```
+
+长度、数值范围、金额精度、操作类型、字段组合、日期顺序和二维码 Base64 格式在服务层手动检查。可复用检查集中于 `Services/UserInputChecks.cs`,资金金额与操作类型直接由 UserFundsService 判断;续费的非空输入由 ShopRenewRequest 声明,其余规则通过命令验证管道调用服务层检查后执行事务。
+
+权限、账户归属、数据库记录是否存在、余额是否足够、重复请求等业务规则继续在服务或业务管道中判断。不合法的参数抛出 ValidationException,权限和业务错误沿用现有业务异常;现有过滤器将它们转换为 HTTP 200 和对应业务码。
+
+公共 RequestValidator 位于 `Core/MAX.Framework/Validation/RequestValidator.cs`。它仍支持标准 DataAnnotations 及 IValidatableObject,其他模块可以直接复用;User 模块“DTO 仅非空”的约定不改变公共校验器能力。该工具不递归验证嵌套对象。
diff --git a/Modules/Store.Users/Repositories/UserRepository.cs b/Modules/Store.Users/Repositories/UserRepository.cs
index cbaf751..3c3dd28 100644
--- a/Modules/Store.Users/Repositories/UserRepository.cs
+++ b/Modules/Store.Users/Repositories/UserRepository.cs
@@ -1,50 +1,31 @@
-using FreeSql;
+using FreeSql;
using MAX.Core.DependencyInjection;
-using Store.Users.Contracts.Dtos;
using Store.Users.Entities;
-using Store.Users.Mappers;
-using System;
-using System.Collections.Generic;
-using System.Text;
+namespace Store.Users.Repositories;
-namespace Store.Users.Repositories
+public interface IUserRepository : IBaseRepository
{
- public interface IUserRepository : IBaseRepository
- {
- Task GetUser(string userName);
- }
- internal class UserRepository : BaseRepository, IUserRepository, ITransientDependency
- {
- private readonly IFreeSql _freeSql;
+ Task GetUser(string userName);
+}
- public UserRepository(IFreeSql freeSql) : base(freeSql)
- {
- _freeSql = freeSql;
- }
-
- public async Task GetUser(string userName)
- {
- var sql = Where(a => a.UserName == userName).ToSql();
- var user = await Where(a => a.UserName == userName).FirstAsync();
- if (user != null)
- {
- if (user.IsMainUser == false)
- {
- var mainUser = await Where(a => a.UserName == user.ParentUserName).FirstAsync();
- if (mainUser != null)
- {
- user.OemId = mainUser.OemId;
- user.PostagePerOrder = mainUser.PostagePerOrder;
- user.RefundPostagePerOrder = mainUser.RefundPostagePerOrder;
- user.GiftOemAmountPerOrder = mainUser.GiftOemAmountPerOrder;
- user.SyncLogisticsPerOrder = mainUser.SyncLogisticsPerOrder;
- }
- }
- }
- return user;
- }
-
-
-
- }
+/// 集中处理旧系统主子账号的有效计费配置和积分权限继承。
+internal class UserRepository(IFreeSql freeSql) : BaseRepository(freeSql), IUserRepository, ITransientDependency
+{
+ public async Task GetUser(string userName)
+ {
+ var user = await Where(u => u.UserName == userName).FirstAsync();
+ if (user == null || user.IsMainUser) return user;
+ var parent = await Where(u => u.UserName == user.ParentUserName).FirstAsync();
+ if (parent == null) throw new MAX.Framework.Commons.AppException("主账号不存在,请联系管理员");
+ user.OemId = parent.OemId;
+ user.PostagePerOrder = Math.Max(2.2m, parent.PostagePerOrder ?? 0);
+ user.RefundPostagePerOrder = Math.Max(2.5m, parent.RefundPostagePerOrder ?? 0);
+ user.GiftOemAmountPerOrder = Math.Max(0, parent.GiftOemAmountPerOrder ?? 0);
+ user.SyncLogisticsPerOrder = parent.SyncLogisticsPerOrder;
+ var authority = (user.Authority ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToHashSet();
+ var parentAuthority = (parent.Authority ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ if (parentAuthority.Contains("积分用户")) authority.Add("积分用户"); else authority.Remove("积分用户");
+ user.Authority = string.Join(",", authority);
+ return user;
+ }
}
diff --git a/Modules/Store.Users/Services/UserFundsService.cs b/Modules/Store.Users/Services/UserFundsService.cs
new file mode 100644
index 0000000..8ea40ad
--- /dev/null
+++ b/Modules/Store.Users/Services/UserFundsService.cs
@@ -0,0 +1,106 @@
+using System.ComponentModel.DataAnnotations;
+using MAX.Framework.Validation;
+using FreeSql;
+using MAX.Core.DependencyInjection;
+using MAX.Framework.Commons;
+using MAX.Framework.Security;
+using Store.Users.Contracts.Dtos;
+using Store.Users.Entities;
+namespace Store.Users.Services;
+
+/// 资金写入集中在事务中完成。账户行锁防止超扣,流水与余额同提交、同回滚。
+public sealed class UserFundsService(IFreeSql orm, ICurrentUser currentUser) : ITransientDependency
+{
+ public async Task Change(UserFundsRequest request, bool points, CancellationToken ct)
+ {
+ RequestValidator.Validate(request);
+ // DTO 只检查非空,其余长度、金额和操作组合在服务中手动判断。
+ UserInputChecks.Length(request.UserName, 20, "目标账户");
+ UserInputChecks.Length(request.RequestId, 64, "请求号");
+ UserInputChecks.Length(request.Remark, 255, "备注");
+ if (request.Amount is < 0.01m or > 1000000m)
+ throw new ValidationException("金额必须为0.01-1000000");
+ if (decimal.Round(request.Amount, 2) != request.Amount)
+ throw new ValidationException("金额最多保留两位小数");
+ if (points ? request.Operation is not ("充值" or "转移") : request.Operation is not ("充值" or "消费"))
+ throw new ValidationException("不支持的资金操作类型");
+ ct.ThrowIfCancellationRequested();
+ if (!currentUser.IsAuthenticated || string.IsNullOrWhiteSpace(currentUser.UserName)) throw new UnauthorizedAccessException();
+ using var work = orm.CreateUnitOfWork();
+ work.GetOrBeginTransaction();
+ var db = work.Orm;
+ var actor = await db.Select().Where(u => u.UserName == currentUser.UserName).FirstAsync(ct)
+ ?? throw new UnauthorizedAccessException();
+ bool transfer = points && request.Operation == "转移";
+ if (!transfer && actor.SaLevel != 1) throw new AppException("充值和余额调整仅允许管理员操作", 403);
+ var name = request.UserName.Trim();
+ var target = await db.Select().Where(u => u.UserName == name).FirstAsync(ct)
+ ?? throw new AppException("目标账户不存在");
+ if (transfer && actor.Id == target.Id) throw new AppException("不能向自己转移积分");
+ // 双账户转移按相同主键顺序加锁,避免双方同时转账时产生反向锁顺序。
+ var ids = transfer ? new[] { actor.Id, target.Id }.Order().ToArray() : new[] { target.Id };
+ foreach (var id in ids)
+ {
+ var locked = await db.Select().Where(u => u.Id == id).ForUpdate().FirstAsync(ct)
+ ?? throw new AppException("账户已被删除");
+ if (id == target.Id) target = locked;
+ if (id == actor.Id) actor = locked;
+ }
+ var key = "user:" + request.RequestId.Trim();
+ // 持有目标行锁后检查请求号,相同账户的并发重试只能执行一次。
+ bool duplicate = points
+ ? await db.Select().Where(r => r.UserName == target.UserName && r.OutTradeNo == key).AnyAsync(ct)
+ : await db.Select().Where(r => r.UserName == target.UserName && r.RefundFlowNo == key).AnyAsync(ct);
+ if (duplicate) throw new AppException("该请求已处理,请查询流水确认结果,勿重复提交");
+ decimal value;
+ if (points)
+ {
+ value = checked(target.Points + request.Amount);
+ if (transfer)
+ {
+ if (actor.Points < request.Amount) throw new AppException("积分不足");
+ if (await db.Update().Set(u => u.Points, actor.Points - request.Amount)
+ .Where(u => u.Id == actor.Id && u.Points == actor.Points).ExecuteAffrowsAsync(ct) != 1)
+ throw new AppException("转出账户状态已改变");
+ await AddPointsRecord(db, actor, actor, request.Amount, actor.Points - request.Amount,
+ "转出", target.UserName, key, request.Remark, ct);
+ }
+ var update = db.Update().Set(u => u.Points, value)
+ .Where(u => u.Id == target.Id && u.Points == target.Points);
+ // 延续原系统口径:积分转入也计入接收方累计充值。
+ update = update.Set(u => u.RechargePointsTotal, checked((target.RechargePointsTotal ?? 0) + request.Amount));
+ if (await update.ExecuteAffrowsAsync(ct) != 1) throw new AppException("账户状态已改变");
+ await AddPointsRecord(db, target, actor, request.Amount, value, transfer ? "转入" : "充值",
+ transfer ? actor.UserName : null, key, request.Remark, ct);
+ }
+ else
+ {
+ bool recharge = request.Operation == "充值";
+ value = checked((target.Balance ?? 0) + (recharge ? request.Amount : -request.Amount));
+ if (value < 0) throw new AppException("余额不足");
+ var update = db.Update().Set(u => u.Balance, value).Where(u => u.Id == target.Id && u.Balance == target.Balance);
+ if (recharge) update = update.Set(u => u.RechargeAmountTotal, checked((target.RechargeAmountTotal ?? 0) + request.Amount));
+ if (await update.ExecuteAffrowsAsync(ct) != 1) throw new AppException("账户状态已改变");
+ if (await db.Insert(new UserPaymentRecordEntity
+ {
+ UserName = target.UserName, Type = recharge ? 1 : 0, Amount = request.Amount, Balance = value,
+ CreateBy = actor.UserName, CreateTime = DateTime.Now, Remark = request.Remark ?? "",
+ PlatformCode = "", OrderId = "", UpdateBy = "", CounterpartyAccountName = "", RefundFlowNo = key
+ }).ExecuteAffrowsAsync(ct) != 1) throw new AppException("写入余额流水失败");
+ }
+ ct.ThrowIfCancellationRequested();
+ work.Commit();
+ return new(target.UserName, value, request.RequestId);
+ }
+
+ private static async Task AddPointsRecord(IFreeSql db, UserEntity user, UserEntity actor, decimal amount,
+ decimal value, string operation, string? counterparty, string key, string? remark, CancellationToken ct)
+ {
+ if (await db.Insert(new UserPointsRecordEntity
+ {
+ UserName = user.UserName, UserNick = user.NickName, DeltaPoints = amount, NewPoints = value,
+ OptionType = operation, OperatorType = actor.SaLevel == 1 ? "管理员" : "用户", OperatorName = actor.UserName,
+ CounterpartyAccountName = counterparty, OutTradeNo = key, Remark = remark, AddTime = DateTime.Now
+ }).ExecuteAffrowsAsync(ct) != 1) throw new AppException("写入积分流水失败");
+ }
+}
diff --git a/Modules/Store.Users/Services/UserInputChecks.cs b/Modules/Store.Users/Services/UserInputChecks.cs
new file mode 100644
index 0000000..98ac26e
--- /dev/null
+++ b/Modules/Store.Users/Services/UserInputChecks.cs
@@ -0,0 +1,89 @@
+using System.ComponentModel.DataAnnotations;
+using Store.Users.Contracts.Dtos;
+using Store.Users.Dtos;
+
+namespace Store.Users.Services;
+
+/// User 服务共用的手动参数检查。DTO 只保留非空规则,其余检查在服务层执行。
+internal static class UserInputChecks
+{
+ public static void Length(string? value, int max, string name, int min = 0)
+ {
+ if (value != null && (value.Length < min || value.Length > max))
+ throw new ValidationException(min > 0 ? $"{name}长度必须为{min}-{max}个字符" : $"{name}长度不能超过{max}个字符");
+ }
+
+ public static void PositiveId(long id)
+ {
+ if (id <= 0) throw new ValidationException("ID必须大于0");
+ }
+
+ private static void Group(byte? group)
+ {
+ if (group.HasValue && group.Value is not (3 or 4 or 5 or 8 or 9 or 10 or 11))
+ throw new ValidationException("用户分组必须为3、4、5、8、9、10或11");
+ }
+
+ public static void Edit(UserEditRequest request)
+ {
+ Length(request.NickName, 50, "昵称");
+ Length(request.Mobile, 30, "手机号");
+ Length(request.CompanyName, 100, "公司名称");
+ Length(request.Notes, 255, "备注");
+ Length(request.CardId, 50, "卡号");
+ Length(request.Authority, 1000, "权限");
+ Length(request.ForbiddenPage, 1000, "禁止页面");
+ Length(request.OemId, 50, "OEM编号");
+ Group(request.GroupId);
+ if (request.SaLevel.HasValue && request.SaLevel.Value is not (0 or 1))
+ throw new ValidationException("管理员级别只能为0或1");
+ if (request.ShopPointsPerMonth < 30) throw new ValidationException("每月续费积分不能低于30");
+ if (request.PostagePerOrder is < 2.2m or > 999999m) throw new ValidationException("每单费用必须为2.2-999999元");
+ if (request.RefundPostagePerOrder is < 2.5m or > 999999m) throw new ValidationException("退货费用必须为2.5-999999元");
+ if (request.GiftOemAmountPerOrder is < 0m or > 999999m) throw new ValidationException("礼物附加费用必须为0-999999元");
+ if (request.SyncLogisticsPerOrder is < 0m or > 999999m) throw new ValidationException("物流同步费用必须为0-999999元");
+ }
+
+ public static void Search(UserSearchRequest request)
+ {
+ if (request.Page is < 1 or > 1000000) throw new ValidationException("页码必须为1-1000000");
+ if (request.PageSize is < 1 or > 200) throw new ValidationException("每页条数必须为1-200");
+ Length(request.Keyword, 100, "查询关键字");
+ Group(request.GroupId);
+ }
+
+ public static void Records(UserRecordSearchRequest request)
+ {
+ Search(request);
+ Length(request.Operation, 20, "操作类型");
+ Length(request.ShopId, 50, "店铺ID");
+ Length(request.OrderId, 100, "订单ID");
+ if (request.StartTime.HasValue && request.EndTime.HasValue && request.StartTime > request.EndTime)
+ throw new ValidationException("开始时间不能晚于结束时间");
+ }
+
+ public static void Renew(ShopRenewRequest request)
+ {
+ Length(request.ShopId.Trim(), 50, "店铺ID");
+ if (request.RenewDuration is < 1 or > 120) throw new ValidationException("续费时长必须为1-120个月");
+ Length(request.Remark?.Trim(), 255, "备注");
+ }
+
+ public static void Qrcode(UserTopUpQrcodeRequest request)
+ {
+ Length(request.AgentCode, 50, "代理编码");
+ Length(request.QrcodeBase64, 2 * 1024 * 1024, "二维码");
+ var content = request.QrcodeBase64;
+ if (content.StartsWith("data:image/", StringComparison.OrdinalIgnoreCase))
+ {
+ int comma = content.IndexOf(',');
+ if (comma < 0 || !content[..comma].EndsWith(";base64", StringComparison.OrdinalIgnoreCase))
+ throw new ValidationException("二维码格式错误");
+ content = content[(comma + 1)..];
+ }
+ bool valid;
+ try { valid = Convert.FromBase64String(content).Length > 0; }
+ catch (FormatException) { valid = false; }
+ if (!valid) throw new ValidationException("二维码必须为有效的Base64数据");
+ }
+}
diff --git a/Modules/Store.Users/Services/UserManagementService.TopUp.cs b/Modules/Store.Users/Services/UserManagementService.TopUp.cs
new file mode 100644
index 0000000..36c21aa
--- /dev/null
+++ b/Modules/Store.Users/Services/UserManagementService.TopUp.cs
@@ -0,0 +1,71 @@
+using MAX.Framework.Validation;
+using MAX.Framework.Commons;
+using Store.Users.Contracts.Dtos;
+using Store.Users.Entities;
+namespace Store.Users.Services;
+
+public sealed partial class UserManagementService
+{
+ /// 以本人身份提交充值备注,不能伪造提交账户或昵称。
+ public async Task AddTopUpMessage(UserTopUpMessageRequest request, CancellationToken ct)
+ {
+ RequestValidator.Validate(request);
+ UserInputChecks.Length(request.Message, 1000, "充值备注");
+ var actor = await Actor(orm, ct);
+ return await orm.Insert(new UserTopUpMessageEntity
+ {
+ UserName = actor.UserName, NickName = actor.NickName, Message = request.Message.Trim()
+ }).ExecuteIdentityAsync(ct);
+ }
+
+ /// 管理员记录人工处理结果。备注处理与实际充值是不同用例。
+ public async Task UpdateTopUpMessage(UserTopUpResultRequest request, CancellationToken ct)
+ {
+ RequestValidator.Validate(request);
+ UserInputChecks.PositiveId(request.Id);
+ UserInputChecks.Length(request.Result, 1000, "处理结果");
+ var actor = await Actor(orm, ct);
+ if (!IsAdmin(actor)) throw new AppException("仅管理员可以处理充值备注", 403);
+ if (!await orm.Select().Where(m => m.Id == request.Id).AnyAsync(ct))
+ throw new AppException("充值备注不存在");
+ await orm.Update().Set(m => m.Result, request.Result.Trim())
+ .Where(m => m.Id == request.Id).ExecuteAffrowsAsync(ct);
+ }
+
+ /// 普通用户仅查看自己提交的备注,管理员查看全部。
+ public async Task> TopUpMessages(UserSearchRequest request, CancellationToken ct)
+ {
+ RequestValidator.Validate(request);
+ UserInputChecks.Search(request);
+ var actor = await Actor(orm, ct);
+ var select = orm.Select();
+ if (!IsAdmin(actor)) select = select.Where(m => m.UserName == actor.UserName);
+ var total = await select.CountAsync(ct);
+ var rows = await select.OrderByDescending(m => m.Id).Page(request.Page, request.PageSize).ToListAsync(ct);
+ return new(total, rows);
+ }
+
+ /// 管理员维护代理充值二维码,使用旧表唯一代理编码原子更新或插入。
+ public async Task UpdateTopUpQrcode(UserTopUpQrcodeRequest request, CancellationToken ct)
+ {
+ RequestValidator.Validate(request);
+ UserInputChecks.Qrcode(request);
+ var actor = await Actor(orm, ct);
+ if (!IsAdmin(actor)) throw new AppException("仅管理员可以维护充值二维码", 403);
+ await orm.InsertOrUpdate().SetSource(new UserTopUpQrcodeEntity
+ {
+ AgentCode = request.AgentCode.Trim(), QrcodeBase64 = request.QrcodeBase64
+ }).ExecuteAffrowsAsync(ct);
+ }
+
+ /// 已认证用户按代理编码读取收款二维码。
+ public async Task GetTopUpQrcode(string agentCode, CancellationToken ct)
+ {
+ RequestValidator.Validate(new UserTopUpQrcodeQuery { AgentCode = agentCode?.Trim()! });
+ UserInputChecks.Length(agentCode.Trim(), 50, "代理编码");
+ await Actor(orm, ct);
+ var code = agentCode.Trim();
+ var item = await orm.Select().Where(q => q.AgentCode == code).FirstAsync(ct);
+ return item?.QrcodeBase64;
+ }
+}
diff --git a/Modules/Store.Users/Services/UserManagementService.cs b/Modules/Store.Users/Services/UserManagementService.cs
new file mode 100644
index 0000000..1ac2d7e
--- /dev/null
+++ b/Modules/Store.Users/Services/UserManagementService.cs
@@ -0,0 +1,246 @@
+using MAX.Framework.Validation;
+using FreeSql;
+using MAX.Core.DependencyInjection;
+using MAX.Framework.Commons;
+using MAX.Framework.Security;
+using Store.Users.Contracts;
+using Store.Users.Contracts.Dtos;
+using Store.Users.Entities;
+using Store.Users.Mappers;
+
+namespace Store.Users.Services;
+
+/// 账户管理用例:所有操作从认证上下文确定操作者,禁止相信客户端传入的父账号或管理员标记。
+public sealed partial class UserManagementService(IFreeSql orm, ICurrentUser currentUser,
+ IUserQueryService query, IOnlineUserStore onlineUsers) : ITransientDependency
+{
+ private async Task Actor(IFreeSql db, CancellationToken ct)
+ {
+ ct.ThrowIfCancellationRequested();
+ if (!currentUser.IsAuthenticated || string.IsNullOrWhiteSpace(currentUser.UserName))
+ throw new UnauthorizedAccessException();
+ return await db.Select().Where(u => u.UserName == currentUser.UserName).FirstAsync(ct)
+ ?? throw new UnauthorizedAccessException("登录账户不存在");
+ }
+
+ private static bool IsAdmin(UserEntity actor) => actor.SaLevel == 1;
+ private static bool Owns(UserEntity actor, UserEntity target) =>
+ actor.IsMainUser && string.Equals(target.ParentUserName, actor.UserName, StringComparison.OrdinalIgnoreCase);
+
+ /// 仅本人、直属主账号或超级管理员可以读取账户详情。
+ public async Task GetUser(string? userName, CancellationToken ct)
+ {
+ var actor = await Actor(orm, ct);
+ var name = string.IsNullOrWhiteSpace(userName) ? actor.UserName : userName.Trim();
+ RequestValidator.Validate(new UserNameRequest { UserName = name });
+ UserInputChecks.Length(name, 20, "用户名");
+ var target = await orm.Select().Where(u => u.UserName == name).FirstAsync(ct)
+ ?? throw new AppException("用户不存在");
+ if (!IsAdmin(actor) && actor.Id != target.Id && !Owns(actor, target))
+ throw new AppException("无权访问该账户", 403);
+ return await query.GetUser(target.UserName);
+ }
+
+ /// 管理员查询全部账户;普通主账号只能查询自己的直属子账号。
+ public async Task> Search(UserSearchRequest request, bool subUsersOnly, CancellationToken ct)
+ {
+ RequestValidator.Validate(request);
+ UserInputChecks.Search(request);
+ var actor = await Actor(orm, ct);
+ if (!subUsersOnly && !IsAdmin(actor)) throw new AppException("仅管理员可以查询全部账户", 403);
+ if (subUsersOnly && !actor.IsMainUser) throw new AppException("子账号不能管理其他账户", 403);
+ var select = orm.Select();
+ if (subUsersOnly) select = select.Where(u => u.ParentUserName == actor.UserName);
+ if (request.GroupId.HasValue) select = select.Where(u => u.GroupId == request.GroupId.Value);
+ if (!string.IsNullOrWhiteSpace(request.Keyword))
+ {
+ var keyword = request.Keyword.Trim();
+ select = select.Where(u => u.UserName.Contains(keyword) || u.NickName.Contains(keyword)
+ || u.Mobile.Contains(keyword) || u.Notes.Contains(keyword));
+ }
+ var total = await select.CountAsync(ct);
+ var items = await select.OrderByDescending(u => u.Id).Page(request.Page, request.PageSize).ToListAsync(ct);
+ return new UserPage(total, items.Select(u => new UserMapper().ToInfo(u)).ToList());
+ }
+
+ /// 创建账户并初始化余额。主账号仅由管理员创建,子账号的归属由服务端指定。
+ public async Task Create(UserCreateRequest request, bool subUser, CancellationToken ct)
+ {
+ RequestValidator.Validate(request);
+ UserInputChecks.Length(request.UserName, 20, "用户名");
+ UserInputChecks.Length(request.Password, 128, "密码", 6);
+ UserInputChecks.Edit(request);
+
+ using var work = orm.CreateUnitOfWork();
+ work.GetOrBeginTransaction();
+ var db = work.Orm;
+ var actor = await Actor(db, ct);
+ // 锁住主账号,使其创建子账号和删除操作串行化。
+ actor = await db.Select().Where(u => u.Id == actor.Id).ForUpdate().FirstAsync(ct)
+ ?? throw new AppException("操作者账户不存在");
+ if (subUser ? !actor.IsMainUser : !IsAdmin(actor)) throw new AppException("无权创建该账户", 403);
+ var name = request.UserName.Trim();
+ if (await db.Select().Where(u => u.UserName == name).AnyAsync(ct)) throw new AppException("用户名已存在");
+ var user = new UserEntity
+ {
+ UserName = name, ParentUserName = subUser ? actor.UserName : "", GroupId = subUser ? (byte)4 : (byte)3,
+ NickName = name, Mobile = "", CompanyName = "", Notes = "", CardId = "", Authority = "",
+ ForbiddenPage = "", OemId = "", SaLevel = 0, ShopPointsPerMonth = 30,
+ PostagePerOrder = 2.2m, RefundPostagePerOrder = 2.5m, GiftOemAmountPerOrder = 0,
+ SyncLogisticsPerOrder = 0, Points = 0, Balance = 0, RechargePointsTotal = 0,
+ RechargeAmountTotal = 0, AddTime = DateTime.Now, MdyTime = DateTime.Now
+ };
+ Apply(user, request, actor, subUser);
+ await CheckOem(db, user, ct);
+ user.Password = UserPassword.Hash(user, request.Password);
+ user.Id = checked((int)await db.Insert(user).ExecuteIdentityAsync(ct));
+ ct.ThrowIfCancellationRequested();
+ work.Commit();
+ return await query.GetUser(user.UserName);
+ }
+
+ /// 修改资料。登录名保持稳定,避免破坏主子账号、商铺和历史流水中的关联。
+ public async Task Update(int id, UserEditRequest request, bool subUser, CancellationToken ct)
+ {
+ UserInputChecks.PositiveId(id);
+ RequestValidator.Validate(request);
+ UserInputChecks.Edit(request);
+ using var work = orm.CreateUnitOfWork();
+ work.GetOrBeginTransaction();
+ var db = work.Orm;
+ var actor = await Actor(db, ct);
+ var target = await db.Select().Where(u => u.Id == id).ForUpdate().FirstAsync(ct)
+ ?? throw new AppException("用户不存在");
+ if (subUser ? !Owns(actor, target) : !IsAdmin(actor)) throw new AppException("无权修改该账户", 403);
+ Apply(target, request, actor, !target.IsMainUser);
+ await CheckOem(db, target, ct);
+ target.MdyTime = DateTime.Now;
+ // 仅写入资料列,不把读取到的余额和积分覆盖回数据库。
+ await db.Update().SetSource(target).UpdateColumns(u => new
+ {
+ u.NickName, u.Mobile, u.CompanyName, u.Notes, u.CardId, u.GroupId, u.SaLevel, u.Authority, u.ForbiddenPage,
+ u.OemId, u.ShopPointsPerMonth, u.PostagePerOrder, u.RefundPostagePerOrder,
+ u.GiftOemAmountPerOrder, u.SyncLogisticsPerOrder, u.MdyTime
+ }).ExecuteAffrowsAsync(ct);
+ ct.ThrowIfCancellationRequested();
+ work.Commit();
+ onlineUsers.SignOut(target.UserName);
+ return await query.GetUser(target.UserName);
+ }
+
+ /// 只有无余额、无积分、无子账号且无商铺关联的账户才能删除,保留历史流水。
+ public async Task Delete(int id, CancellationToken ct)
+ {
+ UserInputChecks.PositiveId(id);
+ using var work = orm.CreateUnitOfWork();
+ work.GetOrBeginTransaction();
+ var db = work.Orm;
+ var actor = await Actor(db, ct);
+ var target = await db.Select().Where(u => u.Id == id).ForUpdate().FirstAsync(ct)
+ ?? throw new AppException("用户不存在");
+ if (actor.Id == target.Id) throw new AppException("不能删除当前登录账户");
+ if (!IsAdmin(actor) && !Owns(actor, target)) throw new AppException("无权删除该账户", 403);
+ if ((target.Balance ?? 0) != 0 || target.Points != 0) throw new AppException("账户仍有余额或积分,不能删除");
+ if (await db.Select().Where(u => u.ParentUserName == target.UserName).AnyAsync(ct))
+ throw new AppException("请先处理该账户的子账号");
+ if (await db.Select().Where(s => s.UserName == target.UserName).AnyAsync(ct))
+ throw new AppException("账户仍有关联店铺,不能删除");
+ if (await db.Delete().Where(u => u.Id == id && u.Points == 0 && (u.Balance == null || u.Balance == 0))
+ .ExecuteAffrowsAsync(ct) != 1) throw new AppException("账户状态已改变,请刷新后重试");
+ ct.ThrowIfCancellationRequested();
+ work.Commit();
+ onlineUsers.SignOut(target.UserName);
+ }
+
+ /// 比较并更新密码,防止并发密码变更丢失;成功后立即失效已有登录会话。
+ public async Task ChangePassword(ChangePasswordRequest request, CancellationToken ct)
+ {
+ RequestValidator.Validate(request);
+ UserInputChecks.Length(request.OldPassword, 128, "原密码");
+ UserInputChecks.Length(request.NewPassword, 128, "新密码", 6);
+ var actor = await Actor(orm, ct);
+ if (!UserPassword.Verify(actor, request.OldPassword)) throw new AppException("原密码错误");
+ var hash = UserPassword.Hash(actor, request.NewPassword);
+ if (await orm.Update().Set(u => u.Password, hash).Set(u => u.MdyTime, DateTime.Now)
+ .Where(u => u.Id == actor.Id && u.Password == actor.Password).ExecuteAffrowsAsync(ct) != 1)
+ throw new AppException("密码已发生变化,请重新登录");
+ onlineUsers.SignOut(actor.UserName);
+ }
+
+ /// 按账户归属查询积分流水;分页与账户详情使用相同的访问范围。
+ public async Task> Points(string? userName, UserRecordSearchRequest request, CancellationToken ct)
+ {
+ RequestValidator.Validate(request);
+ UserInputChecks.Records(request);
+ var user = await GetUser(userName, ct);
+ var select = orm.Select().Where(r => r.UserName == user.UserName);
+ if (request.StartTime.HasValue) select = select.Where(r => r.AddTime >= request.StartTime.Value);
+ if (request.EndTime.HasValue) select = select.Where(r => r.AddTime <= request.EndTime.Value);
+ if (!string.IsNullOrWhiteSpace(request.Operation)) select = select.Where(r => r.OptionType == request.Operation);
+ if (!string.IsNullOrWhiteSpace(request.ShopId)) select = select.Where(r => r.ShopId == request.ShopId);
+ var total = await select.CountAsync(ct);
+ var rows = await select.OrderByDescending(r => r.Id).Page(request.Page, request.PageSize).ToListAsync(ct);
+ return new(total, rows);
+ }
+
+ /// 余额流水排除旧系统已标记删除的记录。
+ public async Task> Balance(string? userName, UserRecordSearchRequest request, CancellationToken ct)
+ {
+ RequestValidator.Validate(request);
+ UserInputChecks.Records(request);
+ var user = await GetUser(userName, ct);
+ var select = orm.Select().Where(r => r.UserName == user.UserName && r.IsDeleted == 0);
+ if (request.StartTime.HasValue) select = select.Where(r => r.CreateTime >= request.StartTime.Value);
+ if (request.EndTime.HasValue) select = select.Where(r => r.CreateTime <= request.EndTime.Value);
+ if (request.PaymentType.HasValue) select = select.Where(r => r.Type == request.PaymentType.Value);
+ if (!string.IsNullOrWhiteSpace(request.OrderId)) select = select.Where(r => r.OrderId == request.OrderId);
+ var total = await select.CountAsync(ct);
+ var rows = await select.OrderByDescending(r => r.Id).Page(request.Page, request.PageSize).ToListAsync(ct);
+ return new(total, rows);
+ }
+
+ private static async Task CheckOem(IFreeSql db, UserEntity user, CancellationToken ct)
+ {
+ if (!string.IsNullOrWhiteSpace(user.OemId) && !await db.Select().Where(o => o.OemId == user.OemId).AnyAsync(ct))
+ throw new AppException("OEM编号不存在");
+ }
+
+ private static void Apply(UserEntity user, UserEditRequest request, UserEntity actor, bool subUser)
+ {
+ // 合法分组值已由服务参数检查保证,这里检查主/子账号身份是否匹配。
+ if (request.GroupId.HasValue && (subUser ? request.GroupId == 3 : request.GroupId != 3))
+ throw new AppException("主账号分组必须为3,子账号分组必须为4、5、8、9、10或11");
+ // 普通主账号只能授予自己已有的权限,不能通过创建子账号提升权限。
+ if (!IsAdmin(actor) && request.Authority != null)
+ {
+ var allowed = Split(actor.Authority);
+ if (Split(request.Authority).Any(a => !allowed.Contains(a))) throw new AppException("不能授予超出自身范围的权限", 403);
+ }
+ if (!IsAdmin(actor) && (request.OemId != null || request.ShopPointsPerMonth != null
+ || request.PostagePerOrder != null || request.RefundPostagePerOrder != null
+ || request.GiftOemAmountPerOrder != null || request.SyncLogisticsPerOrder != null))
+ throw new AppException("仅管理员可以修改计费配置", 403);
+ if (request.SaLevel.HasValue)
+ {
+ if (!IsAdmin(actor) || (subUser && request.SaLevel != 0)) throw new AppException("无权设置管理员级别", 403);
+ user.SaLevel = request.SaLevel;
+ }
+ user.NickName = request.NickName?.Trim() ?? user.NickName;
+ user.Mobile = request.Mobile?.Trim() ?? user.Mobile;
+ user.CompanyName = request.CompanyName?.Trim() ?? user.CompanyName;
+ user.Notes = request.Notes ?? user.Notes;
+ user.CardId = request.CardId ?? user.CardId;
+ user.GroupId = request.GroupId ?? user.GroupId;
+ user.Authority = request.Authority == null ? user.Authority : string.Join(",", Split(request.Authority));
+ user.ForbiddenPage = request.ForbiddenPage ?? user.ForbiddenPage;
+ user.OemId = request.OemId?.Trim() ?? user.OemId;
+ user.ShopPointsPerMonth = request.ShopPointsPerMonth ?? user.ShopPointsPerMonth;
+ user.PostagePerOrder = request.PostagePerOrder ?? user.PostagePerOrder;
+ user.RefundPostagePerOrder = request.RefundPostagePerOrder ?? user.RefundPostagePerOrder;
+ user.GiftOemAmountPerOrder = request.GiftOemAmountPerOrder ?? user.GiftOemAmountPerOrder;
+ user.SyncLogisticsPerOrder = request.SyncLogisticsPerOrder ?? user.SyncLogisticsPerOrder;
+ }
+
+ private static HashSet Split(string? value) => (value ?? "").Split(',',
+ StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToHashSet(StringComparer.Ordinal);
+}
diff --git a/Modules/Store.Users/Services/UserPassword.cs b/Modules/Store.Users/Services/UserPassword.cs
new file mode 100644
index 0000000..5bb48fe
--- /dev/null
+++ b/Modules/Store.Users/Services/UserPassword.cs
@@ -0,0 +1,22 @@
+using Microsoft.AspNetCore.Identity;
+using Store.Users.Entities;
+namespace Store.Users.Services;
+
+/// 新密码使用带盐哈希;兼容旧 wxxd.user 的明文密码,登录验证不修改密码。
+public static class UserPassword
+{
+ private static readonly PasswordHasher Hasher = new();
+ public static string Hash(UserEntity user, string password) => Hasher.HashPassword(user, password);
+ public static bool Verify(UserEntity user, string password)
+ {
+ if (string.IsNullOrEmpty(user.Password) || string.IsNullOrEmpty(password)) return false;
+ // Identity 的密码以固定的版本字节开头,格式损坏时拒绝验证。
+ if (user.Password.StartsWith("AQAAAA", StringComparison.Ordinal) || user.Password.StartsWith("AA", StringComparison.Ordinal))
+ {
+ try { return Hasher.VerifyHashedPassword(user, user.Password, password) != PasswordVerificationResult.Failed; }
+ catch (FormatException) { return false; }
+ }
+ return System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(
+ System.Text.Encoding.UTF8.GetBytes(user.Password), System.Text.Encoding.UTF8.GetBytes(password));
+ }
+}
diff --git a/Modules/Store.Users/Services/UserQueryService.cs b/Modules/Store.Users/Services/UserQueryService.cs
index 6283be8..6a4701b 100644
--- a/Modules/Store.Users/Services/UserQueryService.cs
+++ b/Modules/Store.Users/Services/UserQueryService.cs
@@ -1,43 +1,35 @@
-using MAX.Core.DependencyInjection;
+using MAX.Framework.Validation;
+using MAX.Core.DependencyInjection;
+using MAX.Framework.Commons;
using Store.Users.Contracts;
using Store.Users.Contracts.Dtos;
using Store.Users.Entities;
using Store.Users.Mappers;
using Store.Users.Repositories;
-using System;
-using System.Collections.Generic;
-using System.Text;
+namespace Store.Users.Services;
-namespace Store.Users.Services
+internal sealed class UserQueryService(IUserRepository repository) : IUserQueryService, ITransientDependency
{
- internal class UserQueryService : IUserQueryService, ITransientDependency
- {
- private readonly IUserRepository _userRepository;
+ public async Task GetUser(string userName)
+ {
+ RequestValidator.Validate(new UserNameRequest { UserName = userName?.Trim()! });
+ UserInputChecks.Length(userName.Trim(), 20, "用户名");
+ var user = await repository.GetUser(userName.Trim()) ?? throw new AppException("用户不存在");
+ return new UserMapper().ToInfo(user);
+ }
+}
- public UserQueryService(IUserRepository userRepository)
- {
- _userRepository = userRepository;
- }
- public async Task GetUser(string userName)
- {
- var sql = _userRepository.Where(a => a.UserName == userName).ToSql();
- var user = await _userRepository.Where(a => a.UserName == userName).FirstAsync();
- if (user != null)
- {
- if (user.IsMainUser == false)
- {
- var mainUser = await _userRepository.Where(a => a.UserName == user.ParentUserName).FirstAsync();
- if (mainUser != null)
- {
- user.OemId = mainUser.OemId;
- user.PostagePerOrder = mainUser.PostagePerOrder;
- user.RefundPostagePerOrder = mainUser.RefundPostagePerOrder;
- user.GiftOemAmountPerOrder = mainUser.GiftOemAmountPerOrder;
- user.SyncLogisticsPerOrder = mainUser.SyncLogisticsPerOrder;
- }
- }
- }
- return new UserMapper().ToInfo(user);
- }
- }
+internal sealed class UserAuthenticationService(IFreeSql orm, IUserQueryService query)
+ : IUserAuthenticationService, ITransientDependency
+{
+ public async Task Authenticate(string userName, string password, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (!RequestValidator.TryValidate(new UserLoginRequest { UserName = userName?.Trim()!, Password = password }, out _))
+ return null;
+ if (userName.Trim().Length > 20 || password.Length > 128) return null;
+ var user = await orm.Select().Where(u => u.UserName == userName.Trim()).FirstAsync(cancellationToken);
+ if (user == null || !UserPassword.Verify(user, password)) return null;
+ return await query.GetUser(user.UserName);
+ }
}
diff --git a/Modules/Store.Users/Store.Users.csproj b/Modules/Store.Users/Store.Users.csproj
index 329daf9..3fa9a40 100644
--- a/Modules/Store.Users/Store.Users.csproj
+++ b/Modules/Store.Users/Store.Users.csproj
@@ -11,4 +11,8 @@
+
+
+
+
diff --git a/Store.API/Controllers/AuthController.cs b/Store.API/Controllers/AuthController.cs
index 371090e..309d2e4 100644
--- a/Store.API/Controllers/AuthController.cs
+++ b/Store.API/Controllers/AuthController.cs
@@ -1,26 +1,27 @@
-using Asp.Versioning;
+using Asp.Versioning;
using MAX.Framework.Dtos;
using MAX.Framework.Security;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
+using Store.Users.Contracts;
namespace Store.Api.Controllers
{
[ApiController]
[ApiVersion("1.0")]
- [Route("api/v{version:apiVersion}/[controller]/[action]")]
+ [Route("/v{version:apiVersion}/[controller]/[action]")]
public class AuthController : ControllerBase
{
- //private readonly IUserService _userService;
+ private readonly IUserAuthenticationService _users;
private readonly ITokenService _tokenService;
private readonly ICurrentUser _currentUser;
private readonly IOnlineUserStore _onlineUserStore;
private readonly ILogger _logger;
- public AuthController(ITokenService tokenService, ICurrentUser currentUser, IOnlineUserStore onlineUserStore, ILogger logger)
+ public AuthController(ITokenService tokenService, ICurrentUser currentUser, IOnlineUserStore onlineUserStore, ILogger logger, IUserAuthenticationService users)
{
- //_userService = userService;
+ _users = users;
_tokenService = tokenService;
_currentUser = currentUser;
_onlineUserStore = onlineUserStore;
@@ -34,22 +35,19 @@ namespace Store.Api.Controllers
[AllowAnonymous]
public async Task Login([FromBody] LoginRequest request)
{
- _logger.LogError("login errr");
_logger.LogInformation("login");
if (request is null || string.IsNullOrWhiteSpace(request.UserName) || string.IsNullOrWhiteSpace(request.Password))
{
return ApiResult.Fail("用户名或密码不能为空", 400);
}
- //UserDto? user = await _userService.LoginAsync(request.UserName, request.Password);
- //if (user is null)
- //{
- // return ApiResult.Error("用户名或密码错误", 401);
- //}
+ // 认证通过后才签发令牌,权限信息以用户模块中的数据库记录为准。
+ var user = await _users.Authenticate(request.UserName, request.Password, HttpContext.RequestAborted);
+ if (user is null) return ApiResult.Fail("用户名或密码错误", 401);
var claims = new List
{
- new(JwtClaimTypes.UserName, request.UserName),
+ new(JwtClaimTypes.UserName, user.UserName),
};
TokenResult token = _tokenService.CreateToken(claims);
@@ -108,4 +106,3 @@ namespace Store.Api.Controllers
public string Password { get; set; } = string.Empty;
}
}
-
diff --git a/Store.API/Program.cs b/Store.API/Program.cs
index d3ff631..dab87eb 100644
--- a/Store.API/Program.cs
+++ b/Store.API/Program.cs
@@ -1,4 +1,5 @@
using Asp.Versioning;
+using MAX.Framework.Conventions;
using MAX.Framework.DependencyInjection;
using Scalar.AspNetCore;
@@ -21,16 +22,21 @@ public partial class Program
// // 兼容迁移的同步控制器:允许同步读写请求/响应 IO(旧 asmx 行为)
// options.AllowSynchronousIO = true;
//});
- builder.Services.AddControllers();
+ builder.Services.AddControllers(options =>
+ {
+ options.Conventions.Add(new ApiRoutePrefixConvention());
+ });
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
+ options.AssumeDefaultVersionWhenUnspecified = true;// 未指定时使用默认版本
options.ReportApiVersions = true;
options.ApiVersionReader = new UrlSegmentApiVersionReader();
}).AddMvc().AddApiExplorer(options =>
{
- options.GroupNameFormat = "'v'V";
+ // 保留次版本号,避免 1.0 与 1.1 都映射为 v1,造成 OpenAPI 服务重复注册。
+ options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
}).AddOpenApi();
@@ -49,8 +55,8 @@ public partial class Program
app.MapScalarApiReference(options =>
{
options.DefaultHttpClient = new(ScalarTarget.CSharp, ScalarClient.HttpClient);
- options.Title = "商铺管理API V1";
- options.AddDocuments(new[] { "v1", "v2" });
+ options.Title = "商铺管理API";
+ options.AddDocuments(app.DescribeApiVersions().Select(version => version.GroupName).ToArray());
});
}
diff --git a/Store.API/Properties/launchSettings.json b/Store.API/Properties/launchSettings.json
index 7927d11..87e48d8 100644
--- a/Store.API/Properties/launchSettings.json
+++ b/Store.API/Properties/launchSettings.json
@@ -5,7 +5,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
- "launchUrl": "scalar/v1",
+ "launchUrl": "scalar/",
"applicationUrl": "http://localhost:14312",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
@@ -15,7 +15,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
- "launchUrl": "scalar/v1",
+ "launchUrl": "scalar/",
"applicationUrl": "https://localhost:7241;http://localhost:14312",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
diff --git a/Store.API/appsettings.json b/Store.API/appsettings.json
index d96f366..0838f1f 100644
--- a/Store.API/appsettings.json
+++ b/Store.API/appsettings.json
@@ -7,31 +7,6 @@
},
"ConnectionStrings": {
"DefaultConnection": "Server=192.168.0.200;Port=3306;Database=mysql;Uid=root;Pwd=123456;",
- "KJDConnection": "Server=192.168.0.200;Port=3306;Database=mysql;Uid=root;Pwd=123456;",
- "ConnKJD": "Server=192.168.0.200;Port=3306;Database=mysql;Uid=root;Pwd=123456;"
- //"KJDConnection": "Max Pool Size=512;Database='kjd';Data Source='nj-cynosdbmysql-grp-hl51pkfr.sql.tencentcdb.com';Port=27660;User Id='kjd';Password='Temukjd0625';Charset=utf8mb4;",
- //"KJDBAKConnection": "Max Pool Size=512;Database='kjd';Data Source='nj-cynosdbmysql-grp-9s7x16mt.sql.tencentcdb.com';Port=24049;User Id='kjdbak';Password='Temukjd0126';Charset=utf8mb4;",
- //"ConnKJD": "Max Pool Size=512;Database='kjd';Data Source='nj-cynosdbmysql-grp-hl51pkfr.sql.tencentcdb.com';Port=27660;User Id='kjd';Password='Temukjd0625';Charset=utf8mb4;",
- //"ConnKJDBAK": "Max Pool Size=512;Database='kjd';Data Source='nj-cynosdbmysql-grp-9s7x16mt.sql.tencentcdb.com';Port=24049;User Id='kjdbak';Password='Temukjd0126';Charset=utf8mb4;"
- },
- "KJDConfig": {
- "UpdateVersion": "525",
- "UpdateFileDir": "C:\\inetpub\\wwwroot\\kjd_update",
- "UpdateNewUrl": "http://weidian.e57.cn/kjd_update/",
- "UpdateExe": "http://1.13.195.131:9000/Download/Update.exe",
- "UpdateExeChk": "CE4A009B7AA330314F03EEA0C3AB23F9",
- "LowVersion": "523",
- "LimitVersion": "823",
- "GetReturnPackage": "true",
- "GetWXRecommendHotProd": "true",
- "GetWXRecommendHotProdOncePerDay": "false",
- "GetWXRecommendHotProdMinViewUv": "200",
- "GetWXRecommendHotProdInterval": "3",
- "IsKeepBase64UploadImgFile": "0",
- "UpdateWxFindOrderFileDir": "C:\\inetpub\\wwwroot\\find_order_update",
- "UpdateWxFindOrderUrl": "http://weidian.e57.cn/find_order_update/",
- "WxFindOrderLimitVersion": "172",
- "WxFindOrderLowVersion": "99"
},
"Jwt": {
"Issuer": "StoreHub",
diff --git a/Store.Api.slnx b/Store.Api.slnx
index 01c9f28..ced9886 100644
--- a/Store.Api.slnx
+++ b/Store.Api.slnx
@@ -4,6 +4,9 @@
+
+
+
diff --git a/Store.Framework/Store.Framework.csproj b/Store.Framework/Store.Framework.csproj
index 161cbb5..38a5bf8 100644
--- a/Store.Framework/Store.Framework.csproj
+++ b/Store.Framework/Store.Framework.csproj
@@ -13,8 +13,4 @@
-
-
-
-
diff --git a/Tests/Store.Users.Tests/ApiRoutePrefixTests.cs b/Tests/Store.Users.Tests/ApiRoutePrefixTests.cs
new file mode 100644
index 0000000..c5358cc
--- /dev/null
+++ b/Tests/Store.Users.Tests/ApiRoutePrefixTests.cs
@@ -0,0 +1,90 @@
+using MAX.Framework.Conventions;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ApiExplorer;
+using Microsoft.AspNetCore.Mvc.Controllers;
+using Microsoft.AspNetCore.Mvc.Infrastructure;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Store.Users.Tests;
+
+public sealed class ApiRoutePrefixTests
+{
+ [Theory]
+ [InlineData(typeof(PrefixRouteTestController), "Read", "api/prefix-tests/Read")]
+ [InlineData(typeof(PrefixRouteTestController), "Absolute", "api/absolute-test")]
+ [InlineData(typeof(PrefixRouteTestController), "ExistingAbsolute", "api/existing-absolute-test")]
+ [InlineData(typeof(ExistingPrefixTestController), "Read", "api/v1/prefix-tests/Read")]
+ [InlineData(typeof(ActionPrefixTestController), "Read", "api/action-prefix-test")]
+ [InlineData(typeof(NonApiPrefixTestController), "Read", "non-api-prefix-test")]
+ public void RoutesAndApiExplorer_UsePrefixOnlyForApiControllers(Type controllerType, string actionName, string expected)
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddControllers(options => options.Conventions.Add(new ApiRoutePrefixConvention()))
+ .AddApplicationPart(typeof(PrefixRouteTestController).Assembly);
+ using var provider = services.BuildServiceProvider();
+ var action = Assert.Single(provider.GetRequiredService()
+ .ActionDescriptors.Items.OfType(), action =>
+ action.ControllerTypeInfo.AsType() == controllerType && action.ActionName == actionName);
+ Assert.Equal(expected, action.AttributeRouteInfo!.Template);
+ var description = Assert.Single(provider.GetRequiredService()
+ .ApiDescriptionGroups.Items.SelectMany(group => group.Items), description => description.ActionDescriptor.Id == action.Id);
+ Assert.Equal(expected, description.RelativePath);
+ }
+}
+
+[ApiController]
+[Route("prefix-tests/[action]")]
+public sealed class PrefixRouteTestController : ControllerBase
+{
+ [HttpPost]
+ public string Read()
+ {
+ return "ok";
+ }
+
+ [HttpPost("~/absolute-test")]
+ public string Absolute()
+ {
+ return "ok";
+ }
+
+ [HttpPost("/api/existing-absolute-test")]
+ public string ExistingAbsolute()
+ {
+ return "ok";
+ }
+}
+
+[ApiController]
+[Route("api/v1/prefix-tests/[action]")]
+public sealed class ExistingPrefixTestController : ControllerBase
+{
+ [HttpPost]
+ public string Read()
+ {
+ return "ok";
+ }
+}
+
+[ApiController]
+public sealed class ActionPrefixTestController : ControllerBase
+{
+ [HttpPost("action-prefix-test")]
+ public string Read()
+ {
+ return "ok";
+ }
+}
+
+#pragma warning disable AV0014 // 故意保留非 API 控制器,验证约定的作用范围。
+[ApiExplorerSettings(IgnoreApi = false)]
+public sealed class NonApiPrefixTestController : ControllerBase
+{
+ [HttpPost("non-api-prefix-test")]
+ public string Read()
+ {
+ return "ok";
+ }
+}
+#pragma warning restore AV0014
diff --git a/Tests/Store.Users.Tests/DefaultHttpGetTests.cs b/Tests/Store.Users.Tests/DefaultHttpGetTests.cs
new file mode 100644
index 0000000..e6f36ee
--- /dev/null
+++ b/Tests/Store.Users.Tests/DefaultHttpGetTests.cs
@@ -0,0 +1,82 @@
+using System.Net;
+using MAX.Framework.Conventions;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ActionConstraints;
+using Microsoft.AspNetCore.Mvc.ApiExplorer;
+using Microsoft.AspNetCore.Mvc.Controllers;
+using Microsoft.AspNetCore.Mvc.Infrastructure;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Store.Users.Tests;
+
+/// 验证公共默认 GET 约定的真实路由行为及接口文档。
+public sealed class DefaultHttpGetTests : IAsyncLifetime
+{
+ private WebApplication _app = null!;
+ private HttpClient _client = null!;
+
+ public async Task InitializeAsync()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ builder.Logging.ClearProviders();
+ builder.Services.AddControllers().AddApplicationPart(typeof(DefaultGetTestController).Assembly);
+ _app = builder.Build();
+ _app.MapControllers();
+ await _app.StartAsync();
+ _client = _app.GetTestClient();
+ }
+
+ [Theory]
+ [InlineData("Read", "GET", "POST")]
+ [InlineData("Create", "POST", "GET")]
+ [InlineData("Replace", "PUT", "GET")]
+ public async Task UnmarkedAction_DefaultsToGet_AndExplicitMethodsTakePrecedence(string action, string allowed, string rejected)
+ {
+ using var validRequest = new HttpRequestMessage(new HttpMethod(allowed), $"/default-get-tests/{action}");
+ using var validResponse = await _client.SendAsync(validRequest);
+ Assert.Equal(HttpStatusCode.OK, validResponse.StatusCode);
+ using var invalidRequest = new HttpRequestMessage(new HttpMethod(rejected), $"/default-get-tests/{action}");
+ using var invalidResponse = await _client.SendAsync(invalidRequest);
+ Assert.Equal(HttpStatusCode.MethodNotAllowed, invalidResponse.StatusCode);
+ Assert.Contains(allowed, invalidResponse.Content.Headers.Allow);
+ }
+
+ [Fact]
+ public void ActionConstraints_EndpointMetadata_AndApiExplorer_Agree()
+ {
+ var action = Assert.Single(_app.Services.GetRequiredService()
+ .ActionDescriptors.Items.OfType(), a =>
+ a.ControllerTypeInfo.AsType() == typeof(DefaultGetTestController) && a.ActionName == "Read");
+ Assert.Equal("GET", Assert.Single(Assert.Single(action.ActionConstraints!.OfType()).HttpMethods));
+ Assert.Equal("GET", Assert.Single(Assert.Single(action.EndpointMetadata.OfType()).HttpMethods));
+ var description = Assert.Single(_app.Services.GetRequiredService()
+ .ApiDescriptionGroups.Items.SelectMany(g => g.Items), d => d.ActionDescriptor.Id == action.Id);
+ Assert.Equal("GET", description.HttpMethod);
+ }
+
+ public async Task DisposeAsync()
+ {
+ _client?.Dispose();
+ if (_app != null) await _app.DisposeAsync();
+ }
+}
+
+[ApiController]
+[DefaultHttpGet]
+[Route("default-get-tests/[action]")]
+public sealed class DefaultGetTestController : ControllerBase
+{
+ public string Read() => "read";
+
+ [HttpPost]
+ public string Create() => "created";
+
+ [HttpPut]
+ public string Replace() => "replaced";
+}
diff --git a/Tests/Store.Users.Tests/README.md b/Tests/Store.Users.Tests/README.md
new file mode 100644
index 0000000..e717445
--- /dev/null
+++ b/Tests/Store.Users.Tests/README.md
@@ -0,0 +1,38 @@
+# User 模块测试
+
+## 执行
+
+在仓库根目录执行:
+
+```powershell
+dotnet test Tests/Store.Users.Tests/Store.Users.Tests.csproj
+```
+
+按类别运行:
+
+```powershell
+dotnet test Tests/Store.Users.Tests/Store.Users.Tests.csproj --filter FullyQualifiedName~UserFundsTests
+dotnet test Tests/Store.Users.Tests/Store.Users.Tests.csproj --filter FullyQualifiedName~UserHttpTests
+```
+
+## 文件与覆盖范围
+
+| 文件 | 用途与关键用例 |
+| --- | --- |
+| `UserTestContext.cs` | 每个用例单独建立临时 SQLite 数据库,构造真实 FreeSql 仓储和服务,模拟当前用户及会话注销记录,运行后清理 |
+| `UserAuthenticationTests.cs` | 空输入、不存在账号、错误密码、SQL 注入形式输入、明文密码保持不变、哈希随机盐、损坏哈希、修改密码、取消请求 |
+| `UserManagementTests.cs` | 认证要求、本人/主子账号/管理员访问范围、无密码输出、账户创建默认值、重名拒绝、分组规则、权限提升拦截、资料更新、计费继承、分页、删除限制 |
+| `UserFundsTests.cs` | 积分和余额充值、累计金额、审计字段、重复请求、双账户积分转移、向自己转移、超扣、非管理员调整、非法金额/类型、流水故障回滚及原请求重试 |
+| `UserRecordTests.cs` | 账户隔离、操作类型/店铺/订单/时间组合过滤、时间包含边界、分页排序、排除已删除记录、无效日期范围 |
+| `UserTopUpTests.cs` | 充值备注身份与可见范围、处理权限、处理备注不自动充值、二维码更新或插入、Data URI、损坏数据、管理员限制 |
+| `UserHttpTests.cs` | TestServer 真实 MVC 请求,12 个默认 POST 操作拒绝 GET/PUT/DELETE,7 个 GET 操作拒绝 POST;未登录 401、JSON 模型验证、有效 POST 创建、认证身份读取、ApiExplorer 与端点方法一致 |
+
+使用 xUnit,Theory 每组参数分别统计,失败时输出具体测试名称。测试项目已添加到解决方案的 Tests 分组。
+
+## 隔离与边界
+
+测试不会读取 `Store.API/appsettings.json`、访问业务 MySQL、调用第三方服务或修改业务数据。每个测试拥有独立数据库,时间过滤采用固定时间,身份通过测试上下文或仅在 TestServer 注册的测试认证方案提供。HTTP 测试接入真实 UnifyResultFilter、GlobalExceptionFilter 及与宿主一致的模型验证响应,验证数据只包装一次,无数据操作、业务异常和参数错误均返回 HTTP 200 的 ApiResult。路由拒绝与测试认证挑战仍保留原始 405/401,此测试宿主未装配全局状态码中间件;测试认证不验证 JWT 签名。
+
+SQLite 能验证数据持久化和异常回滚,但不能替代 MySQL InnoDB 并发行锁、隔离级别和迁移脚本验证。原有 `UserManagementSmoke` 和 `ShopRenewSmoke` 冒烟入口仍可单独运行。
+
+User DTO 仅声明 Required。RequestValidatorTests 验证非空规则、错误集合和这一模块约定,同时确认公共工具仍支持其他模块的完整 DataAnnotations。UserServiceValidationTests 验证服务层的长度、范围和 ID 判断;原有金额、精度、格式、日期等用例继续通过服务入口验证。HTTP 测试分别验证 DTO 非空拦截与服务手动参数检查,都按系统约定返回 HTTP 200 和 ValidationError。
diff --git a/Tests/Store.Users.Tests/RequestValidatorTests.cs b/Tests/Store.Users.Tests/RequestValidatorTests.cs
new file mode 100644
index 0000000..a2ca1da
--- /dev/null
+++ b/Tests/Store.Users.Tests/RequestValidatorTests.cs
@@ -0,0 +1,103 @@
+using System.ComponentModel.DataAnnotations;
+using MAX.Framework.Validation;
+using Store.Users.Contracts.Dtos;
+using Store.Users.Dtos;
+
+namespace Store.Users.Tests;
+
+/// 验证公共工具,以及 User DTO 仅保留 Required 的模块约定。
+public sealed class RequestValidatorTests
+{
+ [Fact]
+ public void NullRequest_IsRejectedByBothEntrypoints()
+ {
+ Assert.Equal("request", Assert.Throws(() => RequestValidator.Validate(null)).ParamName);
+ Assert.Throws(() => RequestValidator.TryValidate(null, out _));
+ }
+
+ [Fact]
+ public void ValidDto_PassesWithoutChangingInput()
+ {
+ var request = new UserCreateRequest { UserName = " main ", Password = "password", Notes = "" };
+ RequestValidator.Validate(request);
+ Assert.True(RequestValidator.TryValidate(request, out var errors));
+ Assert.Empty(errors);
+ Assert.Equal(" main ", request.UserName);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void RequiredFields_RejectNullEmptyAndWhitespace(string? value)
+ {
+ var request = new UserFundsRequest { UserName = value!, RequestId = "id", Amount = 1m, Operation = "充值" };
+ var error = Assert.Throws(() => RequestValidator.Validate(request));
+ Assert.Equal("目标账户不能为空", error.Message);
+ Assert.Contains(nameof(UserFundsRequest.UserName), error.ValidationResult!.MemberNames);
+ }
+
+ [Fact]
+ public void TryValidate_ReturnsAllMissingRequiredFields()
+ {
+ Assert.False(RequestValidator.TryValidate(new UserFundsRequest(), out var errors));
+ Assert.Equal(3, errors.Count);
+ Assert.Contains(errors, e => e.MemberNames.Contains(nameof(UserFundsRequest.UserName)));
+ Assert.Contains(errors, e => e.MemberNames.Contains(nameof(UserFundsRequest.RequestId)));
+ Assert.Contains(errors, e => e.MemberNames.Contains(nameof(UserFundsRequest.Operation)));
+ }
+
+ [Theory]
+ [InlineData(typeof(UserFundsRequest))]
+ [InlineData(typeof(UserEditRequest))]
+ [InlineData(typeof(UserCreateRequest))]
+ [InlineData(typeof(ChangePasswordRequest))]
+ [InlineData(typeof(UserSearchRequest))]
+ [InlineData(typeof(UserRecordSearchRequest))]
+ [InlineData(typeof(UserTopUpMessageRequest))]
+ [InlineData(typeof(UserTopUpResultRequest))]
+ [InlineData(typeof(UserTopUpQrcodeRequest))]
+ [InlineData(typeof(UserNameRequest))]
+ [InlineData(typeof(UserLoginRequest))]
+ [InlineData(typeof(UserTopUpQrcodeQuery))]
+ [InlineData(typeof(ShopRenewRequest))]
+ public void UserDtos_DeclareOnlyRequiredValidation(Type type)
+ {
+ Assert.False(typeof(IValidatableObject).IsAssignableFrom(type));
+ foreach (var property in type.GetProperties())
+ Assert.All(property.GetCustomAttributes(typeof(ValidationAttribute), true), attribute => Assert.IsType(attribute));
+ }
+
+ [Fact]
+ public void UserDto_DoesNotCheckNonEmptyParameterValues()
+ {
+ // 这些值非空但不合法:DTO 应通过,服务负责拒绝。
+ RequestValidator.Validate(new UserFundsRequest { UserName = new string('u', 21), RequestId = "id", Amount = -1, Operation = "非法操作" });
+ RequestValidator.Validate(new UserTopUpQrcodeRequest { AgentCode = "agent", QrcodeBase64 = "invalid!" });
+ RequestValidator.Validate(new UserRecordSearchRequest { Page = 0, StartTime = new(2025, 2, 1), EndTime = new(2025, 1, 1) });
+ RequestValidator.Validate(new ShopRenewRequest { ShopId = "shop", RenewDuration = 0 });
+ RequestValidator.Validate(new UserCreateRequest { UserName = "user", Password = "123", ShopPointsPerMonth = 1 });
+ }
+
+ [Fact]
+ public void FrameworkValidator_StillSupportsOtherModulesAnnotationsAndCrossFieldRules()
+ {
+ // User 的约定不限制公共工具支持标准 DataAnnotations 的能力。
+ Assert.Throws(() => RequestValidator.Validate(new OtherModuleRequest { Name = "toolong" }));
+ Assert.False(RequestValidator.TryValidate(new OtherModuleRequest { Name = "ok", Count = 0 }, out _));
+ var request = new OtherModuleRequest { Name = "ok", Count = 2, Start = 2, End = 1 };
+ Assert.Equal("顺序错误", Assert.Throws(() => RequestValidator.Validate(request)).Message);
+ }
+
+ private sealed class OtherModuleRequest : IValidatableObject
+ {
+ [Required, StringLength(4)] public string Name { get; set; } = "";
+ [Range(1, 10)] public int Count { get; set; } = 1;
+ public int Start { get; set; }
+ public int End { get; set; }
+ public IEnumerable Validate(ValidationContext context)
+ {
+ if (Start > End) yield return new ValidationResult("顺序错误");
+ }
+ }
+}
diff --git a/Tests/Store.Users.Tests/Store.Users.Tests.csproj b/Tests/Store.Users.Tests/Store.Users.Tests.csproj
new file mode 100644
index 0000000..a8a011f
--- /dev/null
+++ b/Tests/Store.Users.Tests/Store.Users.Tests.csproj
@@ -0,0 +1,19 @@
+
+
+ true
+ false
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
diff --git a/Tests/Store.Users.Tests/UserAuthenticationTests.cs b/Tests/Store.Users.Tests/UserAuthenticationTests.cs
new file mode 100644
index 0000000..775ea2e
--- /dev/null
+++ b/Tests/Store.Users.Tests/UserAuthenticationTests.cs
@@ -0,0 +1,98 @@
+using Store.Users.Entities;
+using Store.Users.Services;
+using MAX.Framework.Commons;
+
+namespace Store.Users.Tests;
+
+public sealed class UserAuthenticationTests
+{
+ [Theory]
+ [InlineData("", "password")]
+ [InlineData(" ", "password")]
+ [InlineData("main", "")]
+ [InlineData("missing", "legacy-password")]
+ [InlineData("main", "wrong")]
+ [InlineData("main' OR 1=1 --", "legacy-password")]
+ public async Task InvalidCredentials_ReturnNullWithoutChangingPassword(string name, string password)
+ {
+ using var context = new UserTestContext();
+ Assert.Null(await context.Authentication.Authenticate(name, password));
+ Assert.Equal("legacy-password", (await context.Read("main")).Password);
+ }
+
+ [Fact]
+ public async Task LegacyPassword_RemainsUnchangedAfterRepeatedLogins()
+ {
+ using var context = new UserTestContext();
+ var info = await context.Authentication.Authenticate(" main ", "legacy-password");
+ Assert.Equal("main", info!.UserName);
+ var user = await context.Read("main");
+ Assert.Equal("legacy-password", user.Password);
+ Assert.True(UserPassword.Verify(user, "legacy-password"));
+ Assert.NotNull(await context.Authentication.Authenticate("main", "legacy-password"));
+ Assert.Equal("legacy-password", (await context.Read("main")).Password);
+ }
+
+ [Fact]
+ public async Task HashedPassword_RemainsUnchangedAfterLogin()
+ {
+ using var context = new UserTestContext();
+ context.CurrentUser.UserName = "main";
+ await context.Users.ChangePassword(new() { OldPassword = "legacy-password", NewPassword = "new-password" }, default);
+ var hash = (await context.Read("main")).Password;
+
+ Assert.NotNull(await context.Authentication.Authenticate("main", "new-password"));
+ Assert.Equal(hash, (await context.Read("main")).Password);
+ }
+
+ [Fact]
+ public void PasswordHashes_AreSalted_AndRejectWrongPassword()
+ {
+ var user = new UserEntity();
+ user.Password = UserPassword.Hash(user, "test-password");
+ Assert.NotEqual(user.Password, UserPassword.Hash(user, "test-password"));
+ Assert.True(UserPassword.Verify(user, "test-password"));
+ Assert.False(UserPassword.Verify(user, "wrong-password"));
+ }
+
+ [Theory]
+ [InlineData("AQAAAAinvalid!")]
+ [InlineData("AAinvalid!")]
+ [InlineData("")]
+ public void MalformedPasswordHash_IsRejected(string hash) =>
+ Assert.False(UserPassword.Verify(new UserEntity { Password = hash }, "test-password"));
+
+ [Fact]
+ public async Task WrongOldPassword_DoesNotChangePasswordOrSignOut()
+ {
+ using var context = new UserTestContext();
+ context.CurrentUser.UserName = "main";
+ var error = await Assert.ThrowsAsync(() => context.Users.ChangePassword(
+ new() { OldPassword = "wrong", NewPassword = "new-password" }, default));
+ Assert.Equal("原密码错误", error.Message);
+ Assert.Equal("legacy-password", (await context.Read("main")).Password);
+ Assert.Empty(context.Sessions.SignedOut);
+ }
+
+ [Fact]
+ public async Task ChangePassword_InvalidatesOldPasswordAndSession()
+ {
+ using var context = new UserTestContext();
+ context.CurrentUser.UserName = "main";
+ await context.Users.ChangePassword(new() { OldPassword = "legacy-password", NewPassword = "new-password" }, default);
+ Assert.Contains("main", context.Sessions.SignedOut);
+ Assert.Null(await context.Authentication.Authenticate("main", "legacy-password"));
+ Assert.NotNull(await context.Authentication.Authenticate("main", "new-password"));
+ }
+
+ [Fact]
+ public async Task CancelledAuthentication_ThrowsBeforeChangingPassword()
+ {
+ using var context = new UserTestContext();
+ using var cancellation = new CancellationTokenSource();
+ cancellation.Cancel();
+ await Assert.ThrowsAnyAsync(() =>
+ context.Authentication.Authenticate("main", "legacy-password", cancellation.Token));
+ Assert.Equal("legacy-password", (await context.Read("main")).Password);
+ }
+}
diff --git a/Tests/Store.Users.Tests/UserFundsTests.cs b/Tests/Store.Users.Tests/UserFundsTests.cs
new file mode 100644
index 0000000..4a2d945
--- /dev/null
+++ b/Tests/Store.Users.Tests/UserFundsTests.cs
@@ -0,0 +1,194 @@
+using System.ComponentModel.DataAnnotations;
+using MAX.Framework.Commons;
+using Store.Users.Contracts.Dtos;
+using Store.Users.Entities;
+
+namespace Store.Users.Tests;
+
+public sealed class UserFundsTests
+{
+ private static UserFundsRequest Request(string operation = "充值", decimal amount = 100m, string target = "main", string id = "request-1") =>
+ new() { UserName = target, RequestId = id, Amount = amount, Operation = operation, Remark = "测试操作" };
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task Recharge_UpdatesFundsAndTotals_AndWritesAuditRecord(bool points)
+ {
+ using var context = new UserTestContext();
+ var response = await context.Funds.Change(Request(), points, default);
+ Assert.Equal(100m, response.Value);
+ var user = await context.Read("main");
+ if (points)
+ {
+ Assert.Equal(100m, user.Points);
+ Assert.Equal(100m, user.RechargePointsTotal);
+ var record = Assert.Single(await context.Db.Select().ToListAsync());
+ Assert.Equal("充值", record.OptionType);
+ Assert.Equal("admin", record.OperatorName);
+ Assert.Equal("管理员", record.OperatorType);
+ Assert.Equal(100m, record.NewPoints);
+ Assert.Equal("user:request-1", record.OutTradeNo);
+ }
+ else
+ {
+ Assert.Equal(100m, user.Balance);
+ Assert.Equal(100m, user.RechargeAmountTotal);
+ var record = Assert.Single(await context.Db.Select().ToListAsync());
+ Assert.Equal(1, record.Type);
+ Assert.Equal("admin", record.CreateBy);
+ Assert.Equal(100m, record.Balance);
+ Assert.Equal("user:request-1", record.RefundFlowNo);
+ }
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task RepeatedRequest_IsRejectedWithoutDuplicateFundsOrRecords(bool points)
+ {
+ using var context = new UserTestContext();
+ await context.Funds.Change(Request(), points, default);
+ var error = await Assert.ThrowsAsync(() => context.Funds.Change(Request(), points, default));
+ Assert.Contains("已处理", error.Message);
+ var user = await context.Read("main");
+ Assert.Equal(100m, points ? user.Points : user.Balance);
+ Assert.Equal(1, points ? await context.Db.Select().CountAsync()
+ : await context.Db.Select().CountAsync());
+ }
+
+ [Fact]
+ public async Task Transfer_MovesOwnPointsAndCreatesMatchingAuditPair()
+ {
+ using var context = new UserTestContext();
+ await context.Funds.Change(Request(), true, default);
+ context.CurrentUser.UserName = "main";
+ await context.Funds.Change(Request("转移", 30m, "other", "transfer"), true, default);
+ var sender = await context.Read("main");
+ var receiver = await context.Read("other");
+ Assert.Equal(70m, sender.Points);
+ Assert.Equal(30m, receiver.Points);
+ Assert.Equal(100m, sender.RechargePointsTotal);
+ Assert.Equal(30m, receiver.RechargePointsTotal);
+ var records = await context.Db.Select().Where(r => r.OutTradeNo == "user:transfer").ToListAsync();
+ Assert.Equal(2, records.Count);
+ var debit = Assert.Single(records, r => r.OptionType == "转出");
+ var credit = Assert.Single(records, r => r.OptionType == "转入");
+ Assert.Equal(30m, debit.DeltaPoints);
+ Assert.Equal(debit.DeltaPoints, credit.DeltaPoints);
+ Assert.Equal("other", debit.CounterpartyAccountName);
+ Assert.Equal("main", credit.CounterpartyAccountName);
+ Assert.All(records, r => Assert.Equal("main", r.OperatorName));
+ }
+
+ [Fact]
+ public async Task TransferToSelf_IsRejectedWithoutRecords()
+ {
+ using var context = new UserTestContext();
+ context.CurrentUser.UserName = "main";
+ var error = await Assert.ThrowsAsync(() => context.Funds.Change(Request("转移"), true, default));
+ Assert.Contains("自己", error.Message);
+ Assert.Equal(0, await context.Db.Select().CountAsync());
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task InsufficientFunds_DoesNotChangeAccountsOrCreateRecords(bool points)
+ {
+ using var context = new UserTestContext();
+ if (points) context.CurrentUser.UserName = "other";
+ var error = await Assert.ThrowsAsync(() => context.Funds.Change(Request(points ? "转移" : "消费"), points, default));
+ Assert.Contains("不足", error.Message);
+ Assert.Equal(0m, (await context.Read("main")).Points);
+ Assert.Equal(0m, (await context.Read("other")).Points);
+ Assert.Equal(0, await context.Db.Select().CountAsync());
+ Assert.Equal(0, await context.Db.Select().CountAsync());
+ }
+
+ [Fact]
+ public async Task BalanceConsumption_DoesNotReduceHistoricalRechargeTotal()
+ {
+ using var context = new UserTestContext();
+ await context.Funds.Change(Request(), false, default);
+ await context.Funds.Change(Request("消费", 25.5m, id: "consume"), false, default);
+ var user = await context.Read("main");
+ Assert.Equal(74.5m, user.Balance);
+ Assert.Equal(100m, user.RechargeAmountTotal);
+ var record = await context.Db.Select().Where(r => r.Type == 0).FirstAsync();
+ Assert.Equal(25.5m, record.Amount);
+ Assert.Equal(74.5m, record.Balance);
+ }
+
+ [Theory]
+ [InlineData(true, "充值")]
+ [InlineData(false, "充值")]
+ [InlineData(false, "消费")]
+ public async Task NonAdmin_CannotAdjustFunds(bool points, string operation)
+ {
+ using var context = new UserTestContext();
+ context.CurrentUser.UserName = "main";
+ var error = await Assert.ThrowsAsync(() => context.Funds.Change(Request(operation), points, default));
+ Assert.Equal(403, error.Code);
+ }
+
+ [Theory]
+ [InlineData("0")]
+ [InlineData("-1")]
+ [InlineData("0.001")]
+ [InlineData("1000001")]
+ public async Task InvalidAmount_IsRejectedBeforeWriting(string amount)
+ {
+ using var context = new UserTestContext();
+ await Assert.ThrowsAsync(() => context.Funds.Change(Request(amount:
+ decimal.Parse(amount, System.Globalization.CultureInfo.InvariantCulture)), true, default));
+ Assert.Equal(0, await context.Db.Select().CountAsync());
+ }
+
+ [Theory]
+ [InlineData(true, "消费")]
+ [InlineData(false, "转移")]
+ public async Task UnsupportedOperation_IsRejected(bool points, string operation)
+ {
+ using var context = new UserTestContext();
+ var error = await Assert.ThrowsAsync(() => context.Funds.Change(Request(operation), points, default));
+ Assert.Equal("不支持的资金操作类型", error.Message);
+ Assert.Equal(0, await context.Db.Select().CountAsync());
+ Assert.Equal(0, await context.Db.Select().CountAsync());
+ }
+
+ [Fact]
+ public async Task UnknownOperation_IsRejectedByServiceValidation()
+ {
+ using var context = new UserTestContext();
+ var error = await Assert.ThrowsAsync(() => context.Funds.Change(Request("续费"), true, default));
+ Assert.Equal("不支持的资金操作类型", error.Message);
+ Assert.Equal(0, await context.Db.Select().CountAsync());
+ }
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task AuditWriteFailure_RollsBackAllChanges_AndAllowsRetryWithSameRequest(bool points)
+ {
+ using var context = new UserTestContext();
+ // 积分在第二条转账流水故障,余额在充值流水故障,均应回滚先前账户更新。
+ if (points)
+ {
+ await context.Funds.Change(Request(), true, default);
+ context.CurrentUser.UserName = "main";
+ }
+ var trigger = points
+ ? "CREATE TRIGGER fail_audit BEFORE INSERT ON points WHEN NEW.option_type = '转入' BEGIN SELECT RAISE(ABORT, 'forced audit failure'); END"
+ : "CREATE TRIGGER fail_audit BEFORE INSERT ON payments BEGIN SELECT RAISE(ABORT, 'forced audit failure'); END";
+ await context.Db.Ado.ExecuteNonQueryAsync(trigger);
+ var request = Request(points ? "转移" : "充值", 20m, "other", "retry");
+ var error = await Assert.ThrowsAnyAsync(() => context.Funds.Change(request, points, default));
+ Assert.Contains("forced audit failure", error.Message);
+ Assert.Equal(0m, points ? (await context.Read("other")).Points : (await context.Read("other")).Balance);
+ Assert.Equal(0, await context.Db.Select().Where(r => r.OutTradeNo == "user:retry").CountAsync());
+ Assert.Equal(0, await context.Db.Select().CountAsync());
+ if (points) Assert.Equal(100m, (await context.Read("main")).Points);
+ await context.Db.Ado.ExecuteNonQueryAsync("DROP TRIGGER fail_audit");
+ Assert.Equal(20m, (await context.Funds.Change(request, points, default)).Value);
+ }
+}
diff --git a/Tests/Store.Users.Tests/UserHttpTests.cs b/Tests/Store.Users.Tests/UserHttpTests.cs
new file mode 100644
index 0000000..7810d3b
--- /dev/null
+++ b/Tests/Store.Users.Tests/UserHttpTests.cs
@@ -0,0 +1,279 @@
+using Asp.Versioning;
+using System.Net;
+using System.Net.Http.Json;
+using System.Security.Claims;
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using MAX.Framework.Commons;
+using MAX.Framework.Dtos;
+using Microsoft.AspNetCore.Mvc;
+using MAX.Framework.DependencyInjection;
+using MAX.Framework.Security;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.ActionConstraints;
+using Microsoft.AspNetCore.Mvc.ApiExplorer;
+using Microsoft.AspNetCore.Mvc.Controllers;
+using Microsoft.AspNetCore.Mvc.Infrastructure;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Store.Users.Contracts.Dtos;
+using Store.Users.Controllers;
+using Store.Users.Services;
+
+namespace Store.Users.Tests;
+
+/// 真实 MVC 请求管线测试:仅替换认证方案和数据库,不启动业务宿主或访问外部系统。
+public sealed class UserHttpTests : IAsyncLifetime
+{
+ private readonly UserTestContext _context = new();
+ private WebApplication _app = null!;
+ private HttpClient _client = null!;
+
+ public async Task InitializeAsync()
+ {
+ var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = "Testing" });
+ builder.WebHost.UseTestServer();
+ builder.Logging.ClearProviders();
+ // 与宿主一致:正常数据自动包装,显式 ApiResult 不重复包装,业务异常及模型验证返回 HTTP 200。
+ builder.Services.AddControllers(options =>
+ {
+ options.Filters.Add();
+ options.Filters.Add();
+ }).AddApplicationPart(typeof(UserController).Assembly).ConfigureApiBehaviorOptions(options =>
+ {
+ options.InvalidModelStateResponseFactory = context => new ObjectResult(ApiResult.Fail(
+ string.Join("; ", context.ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage)),
+ ResultCode.ValidationError)) { StatusCode = 200 };
+ });
+ builder.Services.AddApiVersioning(options =>
+ {
+ options.DefaultApiVersion = new ApiVersion(1, 0);
+ options.ApiVersionReader = new UrlSegmentApiVersionReader();
+ }).AddMvc();
+ builder.Services.AddAuthentication("Test").AddScheme("Test", _ => { });
+ builder.Services.AddAuthorization();
+ builder.Services.AddHttpContextAccessor();
+ builder.Services.AddScoped();
+ builder.Services.AddSingleton(_context.Db);
+ builder.Services.AddSingleton(_context.Query);
+ builder.Services.AddSingleton(_context.Sessions);
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+ builder.Services.AddMediatRAttributedBehaviors(typeof(UserController).Assembly);
+ _app = builder.Build();
+ _app.UseAuthentication();
+ _app.UseAuthorization();
+ _app.MapControllers();
+ await _app.StartAsync();
+ _client = _app.GetTestClient();
+ _client.DefaultRequestHeaders.Add("X-Test-User", "admin");
+ }
+
+ [Theory]
+ [InlineData("AddUser")]
+ [InlineData("AddSubUser")]
+ [InlineData("UpdateUser")]
+ [InlineData("UpdateSubUser")]
+ [InlineData("DelUser")]
+ [InlineData("ChangePassword")]
+ [InlineData("UpdateUserPoints")]
+ [InlineData("UpdateUserBalance")]
+ [InlineData("AddUserTopUpMsg")]
+ [InlineData("UpdateUserTopUpMsg")]
+ [InlineData("UpdateTopUpQrcode")]
+ [InlineData("ShopRenew")]
+ public async Task UnmarkedActions_RejectGetPutAndDelete(string action)
+ {
+ foreach (var method in new[] { HttpMethod.Get, HttpMethod.Put, HttpMethod.Delete })
+ {
+ using var request = new HttpRequestMessage(method, $"/v1/user/{action}");
+ using var response = await _client.SendAsync(request);
+ Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode);
+ Assert.Contains("POST", response.Content.Headers.Allow);
+ }
+ }
+
+ [Theory]
+ [InlineData("GetUser")]
+ [InlineData("GetAllUser")]
+ [InlineData("GetSubUser")]
+ [InlineData("GetUserPoints")]
+ [InlineData("GetUserBalance")]
+ [InlineData("GetUserTopUpMsg")]
+ [InlineData("GetTopUpQrcode")]
+ public async Task QueryActions_RejectGet(string action)
+ {
+ using var response = await _client.GetAsync($"/v1/user/{action}");
+ Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode);
+ Assert.Contains("POST", response.Content.Headers.Allow);
+ }
+
+ [Fact]
+ public async Task UnauthenticatedRequests_AreChallengedBeforeReadingOrWriting()
+ {
+ _client.DefaultRequestHeaders.Remove("X-Test-User");
+ using var read = await _client.PostAsJsonAsync("/v1/user/GetUser", new { });
+ using var write = await _client.PostAsJsonAsync("/v1/user/AddUser", new { userName = "created", password = "new-password" });
+ Assert.Equal(HttpStatusCode.Unauthorized, read.StatusCode);
+ Assert.Equal(HttpStatusCode.Unauthorized, write.StatusCode);
+ Assert.Null(await _context.Read("created"));
+ }
+
+ [Fact]
+ public async Task DefaultPost_CreatesAccountFromJsonBody_WithoutExposingPassword()
+ {
+ using var response = await _client.PostAsJsonAsync("/v1/user/AddUser", new { userName = "created", password = "new-password" });
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var content = await response.Content.ReadAsStringAsync();
+ Assert.DoesNotContain("password", content, StringComparison.OrdinalIgnoreCase);
+ var result = await response.Content.ReadFromJsonAsync>();
+ Assert.True(result!.Success);
+ var info = result.Data;
+ Assert.Equal("created", info!.UserName);
+ Assert.NotNull(await _context.Read("created"));
+ }
+
+ [Fact]
+ public async Task Post_ReadsCurrentUserFromRequestIdentity()
+ {
+ _client.DefaultRequestHeaders.Remove("X-Test-User");
+ _client.DefaultRequestHeaders.Add("X-Test-User", "main");
+ using var response = await _client.PostAsJsonAsync("/v1/user/GetUser", new { });
+ var result = await response.Content.ReadFromJsonAsync>();
+ Assert.True(result!.Success);
+ var info = result.Data;
+ Assert.Equal("main", info!.UserName);
+ }
+
+ [Fact]
+ public async Task InvalidPasswordLength_IsRejectedByServiceBeforeCreatingAccount()
+ {
+ // 遵循宿主验证约定:HTTP 200 + 业务错误码,不返回默认的 HTTP 400。
+ using var response = await _client.PostAsJsonAsync("/v1/user/AddUser", new { userName = "created", password = "123" });
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var result = await response.Content.ReadFromJsonAsync();
+ Assert.Equal(ResultCode.ValidationError, result!.Code);
+ Assert.False(result.Success);
+ Assert.Null(await _context.Read("created"));
+ }
+
+ [Theory]
+ [InlineData(null, "password")]
+ [InlineData("", "password")]
+ [InlineData("created", " ")]
+ public async Task MissingRequiredInput_IsRejectedByDtoBeforeCreatingAccount(string? userName, string password)
+ {
+ using var response = await _client.PostAsJsonAsync("/v1/user/AddUser", new { userName, password });
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var result = await response.Content.ReadFromJsonAsync();
+ Assert.Equal(ResultCode.ValidationError, result!.Code);
+ Assert.False(result.Success);
+ Assert.Null(await _context.Read("created"));
+ }
+
+ [Theory]
+ [InlineData("DelUser")]
+ [InlineData("ChangePassword")]
+ [InlineData("UpdateUserTopUpMsg")]
+ [InlineData("UpdateTopUpQrcode")]
+ public async Task CommandsWithoutData_ReturnHttp200AndSingleApiResult(string action)
+ {
+ string url = $"/v1/user/{action}";
+ object body = new { };
+ switch (action)
+ {
+ case "DelUser":
+ url += $"?id={_context.Other.Id}";
+ break;
+ case "ChangePassword":
+ body = new { oldPassword = "legacy-password", newPassword = "new-password" };
+ break;
+ case "UpdateUserTopUpMsg":
+ var id = await _context.Users.AddTopUpMessage(new() { Message = "测试充值备注" }, default);
+ body = new { id, result = "已处理" };
+ break;
+ case "UpdateTopUpQrcode":
+ body = new { agentCode = "agent", qrcodeBase64 = "aGVsbG8=" };
+ break;
+ }
+ using var response = await _client.PostAsJsonAsync(url, body);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
+ var result = document.RootElement;
+ Assert.Equal(ResultCode.Success, result.GetProperty("code").GetInt32());
+ Assert.True(result.GetProperty("success").GetBoolean());
+ Assert.Equal("操作成功", result.GetProperty("msg").GetString());
+ // data 必须为 null,不能变成包含另一层 code/data 的 ApiResult。
+ Assert.Equal(JsonValueKind.Null, result.GetProperty("data").ValueKind);
+ }
+
+ [Fact]
+ public async Task BusinessFailure_ReturnsHttp200WithFailureApiResult()
+ {
+ using var response = await _client.PostAsJsonAsync("/v1/user/ChangePassword",
+ new { oldPassword = "wrong-password", newPassword = "new-password" });
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var result = await response.Content.ReadFromJsonAsync();
+ Assert.Equal(ResultCode.BusinessError, result!.Code);
+ Assert.False(result.Success);
+ Assert.Equal("原密码错误", result.Msg);
+ Assert.Null(result.Data);
+ Assert.Empty(_context.Sessions.SignedOut);
+ }
+
+ [Fact]
+ public async Task InvalidQrcode_IsRejectedByServiceWithValidationApiResult()
+ {
+ using var response = await _client.PostAsJsonAsync("/v1/user/UpdateTopUpQrcode",
+ new { agentCode = "agent", qrcodeBase64 = "invalid!" });
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var result = await response.Content.ReadFromJsonAsync();
+ Assert.Equal(ResultCode.ValidationError, result!.Code);
+ Assert.False(result.Success);
+ Assert.Contains("Base64", result.Msg);
+ Assert.Equal(0, await _context.Db.Select().CountAsync());
+ }
+
+ [Fact]
+ public void ApiExplorerAndEndpointMetadata_UsePostForEveryAction()
+ {
+ var descriptors = _app.Services.GetRequiredService().ActionDescriptors.Items
+ .OfType().Where(a => a.ControllerTypeInfo.AsType() == typeof(UserController)).ToArray();
+ var descriptions = _app.Services.GetRequiredService().ApiDescriptionGroups.Items
+ .SelectMany(g => g.Items).Where(d => d.ActionDescriptor is ControllerActionDescriptor a
+ && a.ControllerTypeInfo.AsType() == typeof(UserController)).ToArray();
+ Assert.Equal(19, descriptors.Length);
+ Assert.Equal(descriptors.Length, descriptions.Length);
+ foreach (var action in descriptors)
+ {
+ var expected = "POST";
+ Assert.Equal(expected, Assert.Single(Assert.Single(action.ActionConstraints!.OfType()).HttpMethods));
+ Assert.Equal(expected, Assert.Single(Assert.Single(action.EndpointMetadata.OfType()).HttpMethods));
+ Assert.Equal(expected, Assert.Single(descriptions, d => d.ActionDescriptor.Id == action.Id).HttpMethod);
+ }
+ }
+
+ public async Task DisposeAsync()
+ {
+ _client?.Dispose();
+ if (_app != null) await _app.DisposeAsync();
+ _context.Dispose();
+ }
+}
+
+/// 仅在测试宿主注册:请求头模拟已验证身份,生产认证配置不会引用此类。
+internal sealed class TestAuthenticationHandler(IOptionsMonitor options,
+ ILoggerFactory logger, UrlEncoder encoder) : AuthenticationHandler(options, logger, encoder)
+{
+ protected override Task HandleAuthenticateAsync()
+ {
+ if (!Request.Headers.TryGetValue("X-Test-User", out var name)) return Task.FromResult(AuthenticateResult.NoResult());
+ var principal = new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.UserName, name.ToString())], Scheme.Name));
+ return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)));
+ }
+}
diff --git a/Tests/Store.Users.Tests/UserManagementTests.cs b/Tests/Store.Users.Tests/UserManagementTests.cs
new file mode 100644
index 0000000..e77ebaf
--- /dev/null
+++ b/Tests/Store.Users.Tests/UserManagementTests.cs
@@ -0,0 +1,246 @@
+using System.ComponentModel.DataAnnotations;
+using System.Text.Json;
+using MAX.Framework.Commons;
+using Store.Users.Contracts.Dtos;
+using Store.Users.Entities;
+using YourNamespace;
+
+namespace Store.Users.Tests;
+
+public sealed class UserManagementTests
+{
+ [Theory]
+ [InlineData(false, "main")]
+ [InlineData(true, null)]
+ [InlineData(true, "missing")]
+ public async Task MissingIdentity_IsRejected(bool authenticated, string? name)
+ {
+ using var context = new UserTestContext();
+ context.CurrentUser.IsAuthenticated = authenticated;
+ context.CurrentUser.UserName = name;
+ await Assert.ThrowsAsync(() => context.Users.GetUser(null, default));
+ }
+
+ [Theory]
+ [InlineData("main", "main")]
+ [InlineData("main", "child")]
+ [InlineData("admin", "other")]
+ [InlineData("child", "child")]
+ public async Task AuthorizedAccount_CanBeRead(string actor, string target)
+ {
+ using var context = new UserTestContext();
+ context.CurrentUser.UserName = actor;
+ var info = await context.Users.GetUser(target, default);
+ Assert.Equal(target, info.UserName);
+ Assert.Null(info.Password);
+ Assert.DoesNotContain("password", JsonSerializer.Serialize(info), StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Theory]
+ [InlineData("main", "other")]
+ [InlineData("child", "main")]
+ [InlineData("other", "child")]
+ public async Task UnrelatedAccount_IsForbidden(string actor, string target)
+ {
+ using var context = new UserTestContext();
+ context.CurrentUser.UserName = actor;
+ var error = await Assert.ThrowsAsync(() => context.Users.GetUser(target, default));
+ Assert.Equal(403, error.Code);
+ }
+
+ [Fact]
+ public async Task CreateMainAccount_InitializesDefaultsAndHashesPassword()
+ {
+ using var context = new UserTestContext();
+ var info = await context.Users.Create(new() { UserName = "created", Password = "new-password" }, false, default);
+ var stored = await context.Read("created");
+ Assert.True(info.IsMainUser);
+ Assert.Equal((byte)3, info.GroupId);
+ Assert.Equal(30, info.ShopPointsPerMonth);
+ Assert.Equal(0, info.Points);
+ Assert.Equal(0m, info.Balance);
+ Assert.NotEqual("new-password", stored.Password);
+ Assert.NotNull(stored.AddTime);
+ Assert.Null(info.Password);
+ }
+
+ [Fact]
+ public async Task DuplicateUsername_IsRejectedWithoutCreatingAnAccount()
+ {
+ using var context = new UserTestContext();
+ var count = await context.Db.Select().CountAsync();
+ var error = await Assert.ThrowsAsync(() => context.Users.Create(
+ new() { UserName = " main ", Password = "new-password" }, false, default));
+ Assert.Equal("用户名已存在", error.Message);
+ Assert.Equal(count, await context.Db.Select().CountAsync());
+ }
+
+ [Fact]
+ public async Task NonAdmin_CannotCreateMainAccount()
+ {
+ using var context = new UserTestContext();
+ context.CurrentUser.UserName = "main";
+ var error = await Assert.ThrowsAsync(() => context.Users.Create(
+ new() { UserName = "created", Password = "new-password" }, false, default));
+ Assert.Equal(403, error.Code);
+ }
+
+ [Theory]
+ [InlineData(4)]
+ [InlineData(5)]
+ [InlineData(8)]
+ [InlineData(9)]
+ [InlineData(10)]
+ [InlineData(11)]
+ public async Task MainAccount_CreatesChildWithServerAssignedParent(byte groupId)
+ {
+ using var context = new UserTestContext();
+ context.CurrentUser.UserName = "main";
+ var info = await context.Users.Create(new()
+ {
+ UserName = "created", Password = "new-password", GroupId = groupId, Authority = "订单"
+ }, true, default);
+ Assert.Equal("main", info.ParentUserName);
+ Assert.Equal(groupId, info.GroupId);
+ Assert.Equal((short)0, info.SaLevel);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(255)]
+ public async Task ChildGroup_MustBeOneOfSupportedGroups(byte groupId)
+ {
+ using var context = new UserTestContext();
+ context.CurrentUser.UserName = "main";
+ await Assert.ThrowsAsync(() => context.Users.Create(new()
+ {
+ UserName = "created", Password = "new-password", GroupId = groupId
+ }, true, default));
+ Assert.False(await context.Db.Select().Where(u => u.UserName == "created").AnyAsync());
+ }
+
+ [Fact]
+ public async Task MainGroup_CannotBeUsedForChildAccount()
+ {
+ using var context = new UserTestContext();
+ context.CurrentUser.UserName = "main";
+ var error = await Assert.ThrowsAsync(() => context.Users.Create(new()
+ {
+ UserName = "created", Password = "new-password", GroupId = 3
+ }, true, default));
+ Assert.Contains("子账号分组", error.Message);
+ }
+ [Fact]
+ public async Task ChildAccount_CannotCreateAnotherChild()
+ {
+ using var context = new UserTestContext();
+ context.CurrentUser.UserName = "child";
+ var error = await Assert.ThrowsAsync(() => context.Users.Create(
+ new() { UserName = "created", Password = "new-password" }, true, default));
+ Assert.Equal(403, error.Code);
+ }
+
+ [Fact]
+ public async Task MainAccount_CannotGrantAdditionalAuthorityOrAdministratorLevel()
+ {
+ using var context = new UserTestContext();
+ context.CurrentUser.UserName = "main";
+ foreach (var request in new UserEditRequest[]
+ {
+ new() { Authority = "不存在的权限" }, new() { SaLevel = 1 }, new() { ShopPointsPerMonth = 30 }
+ })
+ {
+ var error = await Assert.ThrowsAsync(() => context.Users.Update(context.Child.Id, request, true, default));
+ Assert.Equal(403, error.Code);
+ }
+ Assert.Equal((short)0, (await context.Read("child")).SaLevel);
+ }
+
+ [Fact]
+ public async Task Update_PreservesNullFields_ClearsEmptyFields_AndKeepsFunds()
+ {
+ using var context = new UserTestContext();
+ await context.Db.Update().Set(u => u.Notes, "old note").Set(u => u.Mobile, "13800000000")
+ .Set(u => u.Points, 42m).Set(u => u.Balance, 11m).Where(u => u.Id == context.Main.Id).ExecuteAffrowsAsync();
+ var info = await context.Users.Update(context.Main.Id, new() { Notes = "", NickName = " New Name " }, false, default);
+ Assert.Equal("", info.Notes);
+ Assert.Equal("13800000000", info.Mobile);
+ Assert.Equal("New Name", info.NickName);
+ Assert.Equal(42m, info.Points);
+ Assert.Equal(11m, info.Balance);
+ Assert.Contains("main", context.Sessions.SignedOut);
+ }
+
+ [Fact]
+ public async Task Child_InheritsOemFeesAndPointsAuthorityFromParent()
+ {
+ using var context = new UserTestContext();
+ await context.Db.Update().Set(u => u.OemId, "brand").Set(u => u.PostagePerOrder, 1m)
+ .Set(u => u.RefundPostagePerOrder, 1m).Set(u => u.GiftOemAmountPerOrder, -1m)
+ .Set(u => u.SyncLogisticsPerOrder, 0.5m).Where(u => u.Id == context.Main.Id).ExecuteAffrowsAsync();
+ var info = await context.Query.GetUser("child");
+ Assert.Equal("brand", info.OemId);
+ Assert.Equal(2.2m, info.PostagePerOrder);
+ Assert.Equal(2.5m, info.RefundPostagePerOrder);
+ Assert.Equal(0m, info.GiftOemAmountPerOrder);
+ Assert.Equal(0.5m, info.SyncLogisticsPerOrder);
+ await context.Db.Update().Set(u => u.Authority, "订单").Where(u => u.Id == context.Main.Id).ExecuteAffrowsAsync();
+ Assert.DoesNotContain("积分用户", (await context.Query.GetUser("child")).Authority);
+ // 有效查询结果不能把继承值写回子账号原始配置。
+ Assert.Equal("", (await context.Read("child")).OemId);
+ }
+
+ [Fact]
+ public async Task Search_FiltersAndPaginatesWithoutLeakingOtherChildren()
+ {
+ using var context = new UserTestContext();
+ context.Seed("child-two", parent: "main");
+ context.Seed("foreign-child", parent: "other");
+ context.CurrentUser.UserName = "main";
+ var page = await context.Users.Search(new() { Keyword = "child", GroupId = 4, PageSize = 1 }, true, default);
+ Assert.Equal(2, page.Total);
+ Assert.Equal("child-two", Assert.Single(page.Items).UserName);
+ var second = await context.Users.Search(new() { Page = 2, PageSize = 1 }, true, default);
+ Assert.Equal("child", Assert.Single(second.Items).UserName);
+ Assert.Equal(403, (await Assert.ThrowsAsync(() => context.Users.Search(new(), false, default))).Code);
+ }
+
+ [Theory]
+ [InlineData(0, 50)]
+ [InlineData(1, 0)]
+ [InlineData(1, 201)]
+ public async Task InvalidPagination_IsRejected(int page, int pageSize)
+ {
+ using var context = new UserTestContext();
+ await Assert.ThrowsAsync(() => context.Users.Search(new() { Page = page, PageSize = pageSize }, false, default));
+ }
+
+ [Fact]
+ public async Task EmptyAccount_CanBeDeleted_AndSessionIsInvalidated()
+ {
+ using var context = new UserTestContext();
+ await context.Users.Delete(context.Other.Id, default);
+ Assert.Null(await context.Read("other"));
+ Assert.Contains("other", context.Sessions.SignedOut);
+ }
+
+ [Theory]
+ [InlineData("self")]
+ [InlineData("points")]
+ [InlineData("balance")]
+ [InlineData("children")]
+ [InlineData("shops")]
+ public async Task AccountWithBlockingCondition_CannotBeDeleted(string condition)
+ {
+ using var context = new UserTestContext();
+ var target = context.Other;
+ if (condition == "self") target = context.Admin;
+ if (condition == "children") target = context.Main;
+ if (condition == "points") await context.Db.Update().Set(u => u.Points, 1m).Where(u => u.Id == target.Id).ExecuteAffrowsAsync();
+ if (condition == "balance") await context.Db.Update().Set(u => u.Balance, 1m).Where(u => u.Id == target.Id).ExecuteAffrowsAsync();
+ if (condition == "shops") await context.Db.Insert(new ShopEntity { ShopId = "shop", UserName = target.UserName }).ExecuteAffrowsAsync();
+ await Assert.ThrowsAsync(() => context.Users.Delete(target.Id, default));
+ Assert.NotNull(await context.Read(target.UserName));
+ Assert.Empty(context.Sessions.SignedOut);
+ }
+}
diff --git a/Tests/Store.Users.Tests/UserRecordTests.cs b/Tests/Store.Users.Tests/UserRecordTests.cs
new file mode 100644
index 0000000..d7a1179
--- /dev/null
+++ b/Tests/Store.Users.Tests/UserRecordTests.cs
@@ -0,0 +1,112 @@
+using System.ComponentModel.DataAnnotations;
+using MAX.Framework.Commons;
+using Store.Users.Entities;
+
+namespace Store.Users.Tests;
+
+public sealed class UserRecordTests
+{
+ private static readonly DateTime Time = new(2025, 1, 15, 12, 0, 0);
+
+ [Fact]
+ public async Task PointsQuery_AppliesAccountOperationShopAndInclusiveTimeRange()
+ {
+ using var context = new UserTestContext();
+ await context.Db.Insert(new[]
+ {
+ Points("main", "充值", "shop-1", Time),
+ Points("main", "充值", "shop-2", Time),
+ Points("main", "消费", "shop-1", Time),
+ Points("main", "充值", "shop-1", Time.AddSeconds(-1)),
+ Points("other", "充值", "shop-1", Time)
+ }).ExecuteAffrowsAsync();
+ var page = await context.Users.Points("main", new()
+ {
+ Operation = "充值", ShopId = "shop-1", StartTime = Time, EndTime = Time
+ }, default);
+ var record = Assert.Single(page.Items);
+ Assert.Equal(1, page.Total);
+ Assert.Equal("main", record.UserName);
+ Assert.Equal(Time, record.AddTime);
+ }
+
+ [Fact]
+ public async Task PointsQuery_PaginatesWithStableDescendingIdOrder()
+ {
+ using var context = new UserTestContext();
+ await context.Db.Insert(new[]
+ {
+ Points("main", "充值", "shop-1", Time),
+ Points("main", "充值", "shop-2", Time),
+ Points("main", "充值", "shop-3", Time)
+ }).ExecuteAffrowsAsync();
+ var page = await context.Users.Points("main", new() { Page = 2, PageSize = 1 }, default);
+ Assert.Equal(3, page.Total);
+ Assert.Equal("shop-2", Assert.Single(page.Items).ShopId);
+ }
+
+ [Fact]
+ public async Task BalanceQuery_FiltersTypeOrderAndTime_AndExcludesDeletedRecords()
+ {
+ using var context = new UserTestContext();
+ await context.Db.Insert(new[]
+ {
+ Payment("main", 1, "order", Time),
+ Payment("main", 0, "order", Time),
+ Payment("main", 1, "different", Time),
+ Payment("main", 1, "order", Time.AddSeconds(1)),
+ Payment("other", 1, "order", Time),
+ Payment("main", 1, "order", Time, deleted: true)
+ }).ExecuteAffrowsAsync();
+ var page = await context.Users.Balance("main", new()
+ {
+ PaymentType = 1, OrderId = "order", StartTime = Time, EndTime = Time
+ }, default);
+ var record = Assert.Single(page.Items);
+ Assert.Equal(1, page.Total);
+ Assert.Equal("main", record.UserName);
+ Assert.Equal(0, record.IsDeleted);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task OrdinaryUser_CannotQuerySomeoneElsesFundsHistory(bool points)
+ {
+ using var context = new UserTestContext();
+ context.CurrentUser.UserName = "main";
+ var error = await Assert.ThrowsAsync(async () =>
+ {
+ if (points) await context.Users.Points("other", new(), default);
+ else await context.Users.Balance("other", new(), default);
+ });
+ Assert.Equal(403, error.Code);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task ReversedDateRange_IsRejected(bool points)
+ {
+ using var context = new UserTestContext();
+ await Assert.ThrowsAsync(async () =>
+ {
+ var request = new Store.Users.Contracts.Dtos.UserRecordSearchRequest { StartTime = Time, EndTime = Time.AddSeconds(-1) };
+ if (points) await context.Users.Points("main", request, default);
+ else await context.Users.Balance("main", request, default);
+ });
+ }
+
+ private static UserPointsRecordEntity Points(string user, string operation, string shop, DateTime time) => new()
+ {
+ UserName = user, UserNick = user, OptionType = operation, OperatorType = "管理员", OperatorName = "admin",
+ DeltaPoints = 10m, NewPoints = 10m, ShopId = shop, AddTime = time
+ };
+
+ private static UserPaymentRecordEntity Payment(string user, int type, string order, DateTime time, bool deleted = false) => new()
+ {
+ UserName = user, Type = type, Amount = 10m, Balance = 10m, OrderId = order, CreateTime = time,
+ CreateBy = "admin", UpdateBy = "", PlatformCode = "", Remark = "", CounterpartyAccountName = "",
+ RefundFlowNo = "", IsDeleted = deleted ? 1 : 0
+ };
+}
diff --git a/Tests/Store.Users.Tests/UserServiceValidationTests.cs b/Tests/Store.Users.Tests/UserServiceValidationTests.cs
new file mode 100644
index 0000000..554a927
--- /dev/null
+++ b/Tests/Store.Users.Tests/UserServiceValidationTests.cs
@@ -0,0 +1,76 @@
+using System.ComponentModel.DataAnnotations;
+using Store.Users.Contracts.Dtos;
+
+namespace Store.Users.Tests;
+
+/// 确认从 DTO 移出的长度和范围规则仍在服务写入前生效。
+public sealed class UserServiceValidationTests
+{
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public async Task NonPositiveId_IsRejectedByService(int id)
+ {
+ using var context = new UserTestContext();
+ await Assert.ThrowsAsync(() => context.Users.Delete(id, default));
+ await Assert.ThrowsAsync(() => context.Users.Update(id, new(), false, default));
+ }
+
+ [Theory]
+ [InlineData("nickname")]
+ [InlineData("mobile")]
+ [InlineData("company")]
+ [InlineData("notes")]
+ [InlineData("card")]
+ [InlineData("authority")]
+ [InlineData("forbidden")]
+ [InlineData("oem")]
+ [InlineData("group")]
+ [InlineData("level")]
+ [InlineData("monthly")]
+ [InlineData("postage")]
+ [InlineData("refund")]
+ [InlineData("gift")]
+ [InlineData("logistics")]
+ public async Task InvalidEdit_IsRejectedBeforePersistence(string field)
+ {
+ using var context = new UserTestContext();
+ var request = new UserEditRequest();
+ switch (field)
+ {
+ case "nickname": request.NickName = new string('x', 51); break;
+ case "mobile": request.Mobile = new string('x', 31); break;
+ case "company": request.CompanyName = new string('x', 101); break;
+ case "notes": request.Notes = new string('x', 256); break;
+ case "card": request.CardId = new string('x', 51); break;
+ case "authority": request.Authority = new string('x', 1001); break;
+ case "forbidden": request.ForbiddenPage = new string('x', 1001); break;
+ case "oem": request.OemId = new string('x', 51); break;
+ case "group": request.GroupId = 0; break;
+ case "level": request.SaLevel = 2; break;
+ case "monthly": request.ShopPointsPerMonth = 29; break;
+ case "postage": request.PostagePerOrder = 2.1m; break;
+ case "refund": request.RefundPostagePerOrder = 2.4m; break;
+ case "gift": request.GiftOemAmountPerOrder = -1m; break;
+ case "logistics": request.SyncLogisticsPerOrder = -1m; break;
+ }
+ await Assert.ThrowsAsync(() => context.Users.Update(context.Main.Id, request, false, default));
+ Assert.Empty(context.Sessions.SignedOut);
+ Assert.Equal("main", (await context.Read("main")).NickName);
+ }
+
+ [Theory]
+ [InlineData("username")]
+ [InlineData("password-short")]
+ [InlineData("password-long")]
+ public async Task InvalidCreateLength_IsRejectedByService(string field)
+ {
+ using var context = new UserTestContext();
+ var request = new UserCreateRequest { UserName = "created", Password = "password" };
+ if (field == "username") request.UserName = new string('u', 21);
+ if (field == "password-short") request.Password = "123";
+ if (field == "password-long") request.Password = new string('p', 129);
+ await Assert.ThrowsAsync(() => context.Users.Create(request, false, default));
+ Assert.Null(await context.Read(request.UserName));
+ }
+}
diff --git a/Tests/Store.Users.Tests/UserTestContext.cs b/Tests/Store.Users.Tests/UserTestContext.cs
new file mode 100644
index 0000000..611ad6b
--- /dev/null
+++ b/Tests/Store.Users.Tests/UserTestContext.cs
@@ -0,0 +1,103 @@
+using FreeSql;
+using MAX.Framework.Security;
+using Store.Users.Contracts;
+using Store.Users.Entities;
+using Store.Users.Services;
+using System.Security.Claims;
+using YourNamespace;
+
+namespace Store.Users.Tests;
+
+/// 每个测试独享数据库和身份上下文,不读取宿主连接配置,可并行运行。
+internal sealed class UserTestContext : IDisposable
+{
+ private readonly string _path = Path.Combine(Path.GetTempPath(), $"store-user-test-{Guid.NewGuid():N}.db");
+ public IFreeSql Db { get; }
+ public TestCurrentUser CurrentUser { get; } = new();
+ public TestOnlineUsers Sessions { get; } = new();
+ public IUserQueryService Query { get; }
+ public IUserAuthenticationService Authentication { get; }
+ public UserManagementService Users { get; }
+ public UserFundsService Funds { get; }
+ public UserEntity Admin { get; }
+ public UserEntity Main { get; }
+ public UserEntity Other { get; }
+ public UserEntity Child { get; }
+
+ public UserTestContext()
+ {
+ Db = new FreeSqlBuilder()
+ .UseMappingPriority(FreeSql.Internal.MappingPriorityType.Attribute,
+ FreeSql.Internal.MappingPriorityType.Aop, FreeSql.Internal.MappingPriorityType.FluentApi)
+ .UseConnectionString(DataType.Sqlite, $"Data Source={_path};Pooling=False")
+ .Build();
+ // 覆盖业务库 schema,只在临时库建立实体测试表。
+ Db.CodeFirst.ConfigEntity(e => e.Name("users"));
+ Db.CodeFirst.ConfigEntity(e => e.Name("points"));
+ Db.CodeFirst.ConfigEntity(e => e.Name("payments"));
+ Db.CodeFirst.ConfigEntity(e => e.Name("shops"));
+ Db.CodeFirst.ConfigEntity(e => e.Name("oems"));
+ Db.CodeFirst.ConfigEntity(e => e.Name("messages"));
+ Db.CodeFirst.ConfigEntity(e => e.Name("qrcodes"));
+ Db.CodeFirst.SyncStructure(typeof(UserEntity), typeof(UserPointsRecordEntity), typeof(UserPaymentRecordEntity),
+ typeof(ShopEntity), typeof(OEMEntity), typeof(UserTopUpMessageEntity), typeof(UserTopUpQrcodeEntity));
+ // 模拟部署脚本提供的唯一索引。
+ Db.Ado.ExecuteNonQuery("CREATE UNIQUE INDEX ux_user_name ON users(user_name)");
+ Admin = Seed("admin", 1);
+ Main = Seed("main");
+ Other = Seed("other");
+ Child = Seed("child", parent: Main.UserName);
+ CurrentUser.UserName = Admin.UserName;
+
+ // 生产实现为 internal;通过反射构造真实仓储和查询服务,无需放宽业务代码可见性。
+ var assembly = typeof(UserManagementService).Assembly;
+ var repository = Activator.CreateInstance(assembly.GetType("Store.Users.Repositories.UserRepository", true)!, Db)!;
+ Query = (IUserQueryService)Activator.CreateInstance(assembly.GetType("Store.Users.Services.UserQueryService", true)!, repository)!;
+ Authentication = (IUserAuthenticationService)Activator.CreateInstance(
+ assembly.GetType("Store.Users.Services.UserAuthenticationService", true)!, Db, Query)!;
+ Users = new(Db, CurrentUser, Query, Sessions);
+ Funds = new(Db, CurrentUser);
+ }
+
+ public UserEntity Seed(string name, short level = 0, string parent = "")
+ {
+ var user = new UserEntity
+ {
+ UserName = name, NickName = name, Password = "legacy-password", ParentUserName = parent,
+ GroupId = string.IsNullOrEmpty(parent) ? (byte)3 : (byte)4, SaLevel = level,
+ Mobile = "", CompanyName = "", Notes = "", CardId = "", Authority = "积分用户,订单",
+ ForbiddenPage = "", OemId = "", Balance = 0, Points = 0, ShopPointsPerMonth = 30,
+ PostagePerOrder = 1m, RefundPostagePerOrder = 1m
+ };
+ user.Id = checked((int)Db.Insert(user).ExecuteIdentity());
+ return user;
+ }
+
+ public Task Read(string name) => Db.Select().Where(u => u.UserName == name).FirstAsync();
+
+ public void Dispose()
+ {
+ Db.Dispose();
+ if (File.Exists(_path)) File.Delete(_path);
+ }
+}
+
+internal sealed class TestCurrentUser : ICurrentUser
+{
+ public bool IsAuthenticated { get; set; } = true;
+ public string? UserName { get; set; }
+ public string? NickName => UserName;
+ public IEnumerable Claims => [];
+ public string? GetClaimValue(string claimType) => null;
+ public T? GetClaimValue(string claimType) => default;
+}
+
+internal sealed class TestOnlineUsers : IOnlineUserStore
+{
+ public HashSet SignedOut { get; } = [];
+ public void SignOut(string userName) => SignedOut.Add(userName);
+ public void SignIn(OnlineUserInfo info) { }
+ public bool IsTokenActive(string userName, string jti) => false;
+ public IReadOnlyList GetOnlineUsers() => [];
+ public int Count => 0;
+}
diff --git a/Tests/Store.Users.Tests/UserTopUpTests.cs b/Tests/Store.Users.Tests/UserTopUpTests.cs
new file mode 100644
index 0000000..7489174
--- /dev/null
+++ b/Tests/Store.Users.Tests/UserTopUpTests.cs
@@ -0,0 +1,82 @@
+using System.ComponentModel.DataAnnotations;
+using MAX.Framework.Commons;
+using Store.Users.Entities;
+
+namespace Store.Users.Tests;
+
+public sealed class UserTopUpTests
+{
+ [Fact]
+ public async Task Message_UsesCurrentIdentity_AndIsVisibleOnlyToOwnerOrAdmin()
+ {
+ using var context = new UserTestContext();
+ context.CurrentUser.UserName = "main";
+ var id = await context.Users.AddTopUpMessage(new() { Message = " 充值待核实 " }, default);
+ var message = Assert.Single((await context.Users.TopUpMessages(new(), default)).Items);
+ Assert.Equal(id, message.Id);
+ Assert.Equal("main", message.UserName);
+ Assert.Equal("main", message.NickName);
+ Assert.Equal("充值待核实", message.Message);
+ context.CurrentUser.UserName = "other";
+ Assert.Empty((await context.Users.TopUpMessages(new(), default)).Items);
+ context.CurrentUser.UserName = "admin";
+ Assert.Single((await context.Users.TopUpMessages(new(), default)).Items);
+ }
+
+ [Fact]
+ public async Task ProcessingMessage_RequiresAdmin_AndDoesNotRechargeFunds()
+ {
+ using var context = new UserTestContext();
+ context.CurrentUser.UserName = "main";
+ var id = await context.Users.AddTopUpMessage(new() { Message = "充值待核实" }, default);
+ var error = await Assert.ThrowsAsync(() => context.Users.UpdateTopUpMessage(new() { Id = id, Result = "已核实" }, default));
+ Assert.Equal(403, error.Code);
+ context.CurrentUser.UserName = "admin";
+ await context.Users.UpdateTopUpMessage(new() { Id = id, Result = "已核实" }, default);
+ Assert.Equal("已核实", (await context.Users.TopUpMessages(new(), default)).Items.Single().Result);
+ Assert.Equal(0m, (await context.Read("main")).Points);
+ Assert.Equal(0, await context.Db.Select().CountAsync());
+ }
+
+ [Fact]
+ public async Task MissingMessage_CannotBeProcessed()
+ {
+ using var context = new UserTestContext();
+ var error = await Assert.ThrowsAsync(() => context.Users.UpdateTopUpMessage(new() { Id = 9999, Result = "已核实" }, default));
+ Assert.Equal("充值备注不存在", error.Message);
+ }
+
+ [Fact]
+ public async Task Qrcode_UpsertsByAgentCode_AndSupportsDataUri()
+ {
+ using var context = new UserTestContext();
+ await context.Users.UpdateTopUpQrcode(new() { AgentCode = "agent", QrcodeBase64 = "aGVsbG8=" }, default);
+ const string data = "data:image/png;base64,d29ybGQ=";
+ await context.Users.UpdateTopUpQrcode(new() { AgentCode = " agent ", QrcodeBase64 = data }, default);
+ Assert.Equal(data, await context.Users.GetTopUpQrcode("agent", default));
+ Assert.Equal(1, await context.Db.Select().CountAsync());
+ Assert.Null(await context.Users.GetTopUpQrcode("missing", default));
+ }
+
+ [Theory]
+ [InlineData("invalid!")]
+ [InlineData("data:image/png,abc")]
+ [InlineData("data:image/png;base64,")]
+ public async Task InvalidQrcode_DoesNotOverwriteStoredValue(string invalid)
+ {
+ using var context = new UserTestContext();
+ await context.Users.UpdateTopUpQrcode(new() { AgentCode = "agent", QrcodeBase64 = "aGVsbG8=" }, default);
+ await Assert.ThrowsAsync(() => context.Users.UpdateTopUpQrcode(new() { AgentCode = "agent", QrcodeBase64 = invalid }, default));
+ Assert.Equal("aGVsbG8=", await context.Users.GetTopUpQrcode("agent", default));
+ }
+
+ [Fact]
+ public async Task NonAdmin_CannotModifyQrcode()
+ {
+ using var context = new UserTestContext();
+ context.CurrentUser.UserName = "main";
+ var error = await Assert.ThrowsAsync(() => context.Users.UpdateTopUpQrcode(new() { AgentCode = "agent", QrcodeBase64 = "aGVsbG8=" }, default));
+ Assert.Equal(403, error.Code);
+ Assert.Equal(0, await context.Db.Select().CountAsync());
+ }
+}
diff --git a/Tests/Store.Users.Tests/VersionedOpenApiTests.cs b/Tests/Store.Users.Tests/VersionedOpenApiTests.cs
new file mode 100644
index 0000000..6a183f7
--- /dev/null
+++ b/Tests/Store.Users.Tests/VersionedOpenApiTests.cs
@@ -0,0 +1,66 @@
+using System.Net;
+using System.Text.Json;
+using Asp.Versioning;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Store.Users.Tests;
+
+public sealed class VersionedOpenApiTests
+{
+ [Fact]
+ public async Task MajorAndMinorVersions_GenerateSeparateDocuments()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ builder.Logging.ClearProviders();
+ builder.Services.AddControllers().AddApplicationPart(typeof(OpenApiMinorVersionTestController).Assembly);
+ builder.Services.AddApiVersioning(options =>
+ {
+ options.DefaultApiVersion = new ApiVersion(1, 0);
+ options.AssumeDefaultVersionWhenUnspecified = true;
+ options.ApiVersionReader = new UrlSegmentApiVersionReader();
+ }).AddMvc().AddApiExplorer(options =>
+ {
+ options.GroupNameFormat = "'v'VVV";
+ options.SubstituteApiVersionInUrl = true;
+ }).AddOpenApi();
+
+ await using var app = builder.Build();
+ app.MapControllers();
+ app.MapOpenApi().WithDocumentPerVersion();
+ await app.StartAsync();
+ using var client = app.GetTestClient();
+
+ var groups = app.DescribeApiVersions().Select(version => version.GroupName).ToArray();
+ Assert.Contains("v1", groups);
+ Assert.Contains("v1.1", groups);
+ Assert.Equal(groups.Length, groups.Distinct(StringComparer.OrdinalIgnoreCase).Count());
+
+ foreach (var group in new[] { "v1", "v1.1" })
+ {
+ using var response = await client.GetAsync($"/openapi/{group}.json");
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
+ var paths = document.RootElement.GetProperty("paths");
+ var ownPath = group == "v1" ? "/default-get-tests/Read" : "/openapi-minor-tests/Read";
+ Assert.True(paths.TryGetProperty(ownPath, out _));
+ }
+ }
+}
+
+[ApiController]
+[ApiVersion("1.1")]
+[Route("openapi-minor-tests/[action]")]
+public sealed class OpenApiMinorVersionTestController : ControllerBase
+{
+ [HttpPost]
+ public string Read()
+ {
+ return "ok";
+ }
+}
diff --git a/Tests/UserManagementSmoke/Program.cs b/Tests/UserManagementSmoke/Program.cs
new file mode 100644
index 0000000..8e0001e
--- /dev/null
+++ b/Tests/UserManagementSmoke/Program.cs
@@ -0,0 +1,166 @@
+using System.ComponentModel.DataAnnotations;
+using System.Reflection;
+using System.Security.Claims;
+using System.Text.Json;
+using FreeSql;
+using MAX.Framework.Commons;
+using MAX.Framework.Security;
+using Store.Users.Contracts;
+using Store.Users.Contracts.Dtos;
+using Store.Users.Entities;
+using Store.Users.Services;
+using YourNamespace;
+
+UserRoutingChecks.Run();
+
+// 独立临时 SQLite 数据库,绝不连接开发或生产数据库。
+var path = Path.Combine(Path.GetTempPath(), $"store-users-{Guid.NewGuid():N}.db");
+try
+{
+ using var db = new FreeSqlBuilder().UseMappingPriority(FreeSql.Internal.MappingPriorityType.Attribute, FreeSql.Internal.MappingPriorityType.Aop, FreeSql.Internal.MappingPriorityType.FluentApi).UseConnectionString(FreeSql.DataType.Sqlite, $"Data Source={path}").Build();
+ db.CodeFirst.ConfigEntity(e => e.Name("users"));
+ db.CodeFirst.ConfigEntity(e => e.Name("points"));
+ db.CodeFirst.ConfigEntity(e => e.Name("payments"));
+ db.CodeFirst.ConfigEntity(e => e.Name("shops"));
+ db.CodeFirst.ConfigEntity(e => e.Name("oems"));
+ db.CodeFirst.ConfigEntity(e => e.Name("messages"));
+ db.CodeFirst.ConfigEntity(e => e.Name("qrcodes"));
+ db.CodeFirst.SyncStructure(typeof(UserEntity), typeof(UserPointsRecordEntity), typeof(UserPaymentRecordEntity), typeof(ShopEntity), typeof(OEMEntity), typeof(UserTopUpMessageEntity), typeof(UserTopUpQrcodeEntity));
+ var admin = Seed("admin", 1);
+ var main = Seed("main", 0);
+ var other = Seed("other", 0);
+ var actor = new Actor { UserName = admin.UserName };
+ var sessions = new Sessions();
+ var repositoryType = typeof(UserManagementService).Assembly.GetType("Store.Users.Repositories.UserRepository")!;
+ var repository = Activator.CreateInstance(repositoryType, db)!;
+ var queryType = typeof(UserManagementService).Assembly.GetType("Store.Users.Services.UserQueryService")!;
+ var query = (IUserQueryService)Activator.CreateInstance(queryType, repository)!;
+ var authType = typeof(UserManagementService).Assembly.GetType("Store.Users.Services.UserAuthenticationService")!;
+ var auth = (IUserAuthenticationService)Activator.CreateInstance(authType, db, query)!;
+ var users = new UserManagementService(db, actor, query, sessions);
+ var funds = new UserFundsService(db, actor);
+ Assert(await auth.Authenticate("main", "wrong") == null, "错误密码被接受");
+ Assert(await auth.Authenticate(" main ", "legacy-password") != null, "旧密码登录失败");
+ var stored = await db.Select().Where(u => u.Id == main.Id).FirstAsync();
+ Assert(stored.Password != "legacy-password" && UserPassword.Verify(stored, "legacy-password"), "旧密码未升级");
+ Assert(await auth.Authenticate("main", "legacy-password") != null, "哈希密码登录失败");
+ var created = await users.Create(new() { UserName = "newmain", Password = "new-password" }, false, default);
+ Assert(created.GroupId == 3 && created.ShopPointsPerMonth == 30 && created.Points == 0, "主账号默认值错误");
+ Assert(!JsonSerializer.Serialize(created).Contains("Password", StringComparison.OrdinalIgnoreCase), "响应包含密码字段");
+ actor.UserName = main.UserName;
+ var sub = await users.Create(new() { UserName = "child", Password = "child-password", Authority = "积分用户" }, true, default);
+ Assert(sub.ParentUserName == main.UserName && sub.OemId == main.OemId, "子账号归属或OEM继承错误");
+ Assert(sub.PostagePerOrder == 2.2m && sub.RefundPostagePerOrder == 2.5m, "继承费用下限错误");
+ await Reject(() => users.Create(new() { UserName = "bad", Password = "password", Authority = "超级权限" }, true, default));
+ await Reject(() => users.Create(new() { UserName = "bad-admin", Password = "password", SaLevel = 1 }, true, default));
+ await Reject(() => users.Update(sub.Id, new() { ShopPointsPerMonth = 30 }, true, default));
+ await Reject(() => users.GetUser(other.UserName, default));
+ await Reject(() => users.Search(new(), false, default));
+ await Reject(() => users.Update(other.Id, new() { Notes = "tampered" }, true, default));
+ await users.Update(sub.Id, new() { Notes = "" }, true, default);
+ Assert((await users.Search(new(), true, default)).Total == 1, "子账号查询范围错误");
+ await users.ChangePassword(new() { OldPassword = "legacy-password", NewPassword = "changed-password" }, default);
+ Assert(sessions.Names.Contains("main"), "修改密码未注销会话");
+ Assert(await auth.Authenticate("main", "legacy-password") == null, "旧密码仍能登录");
+ Assert(await auth.Authenticate("main", "changed-password") != null, "新密码登录失败");
+ await Reject(() => funds.Change(new() { UserName = "other", Amount = 1, Operation = "充值", RequestId = "forbidden" }, true, default));
+ actor.UserName = "admin";
+ var recharge = new UserFundsRequest { UserName = "main", Amount = 100, Operation = "充值", RequestId = "points-1" };
+ await funds.Change(recharge, true, default);
+ await Reject(() => funds.Change(recharge, true, default));
+ Assert((await db.Select().Where(u => u.Id == main.Id).FirstAsync()).Points == 100, "重复请求造成重复充值");
+ actor.UserName = "main";
+ await funds.Change(new() { UserName = "other", Amount = 30, Operation = "转移", RequestId = "transfer-1" }, true, default);
+ Assert((await db.Select().Where(u => u.Id == main.Id).FirstAsync()).Points == 70, "转出错误");
+ Assert((await db.Select().Where(u => u.Id == other.Id).FirstAsync()).Points == 30, "转入错误");
+ await Reject(() => funds.Change(new() { UserName = "other", Amount = 1000, Operation = "转移", RequestId = "overdraft" }, true, default));
+ Assert(await db.Select().Where(r => r.OutTradeNo == "user:overdraft").CountAsync() == 0, "失败转账产生流水");
+ actor.UserName = "admin";
+ await funds.Change(new() { UserName = "main", Amount = 25.5m, Operation = "充值", RequestId = "balance-1" }, false, default);
+ await funds.Change(new() { UserName = "main", Amount = 5m, Operation = "消费", RequestId = "balance-2" }, false, default);
+ Assert((await db.Select().Where(u => u.Id == main.Id).FirstAsync()).Balance == 20.5m, "余额变更错误");
+ await Reject(() => users.Delete(main.Id, default));
+ await Reject(() => users.Delete(admin.Id, default));
+ await users.Delete(created.Id, default);
+ Assert(!await db.Select().Where(u => u.Id == created.Id).AnyAsync(), "空账户删除失败");
+ await Reject(() => users.Search(new() { PageSize = 201 }, false, default));
+ await Reject(() => funds.Change(new() { UserName = "main", Amount = 0.001m, Operation = "充值", RequestId = "precision" }, false, default));
+ Assert((await users.Points("main", new() { Operation = "充值" }, default)).Total == 1, "积分流水过滤错误");
+ Assert((await users.Balance("main", new() { PaymentType = 1 }, default)).Total == 1, "余额流水过滤错误");
+ await Reject(() => users.Points("main", new() { StartTime = DateTime.Today, EndTime = DateTime.Today.AddDays(-1) }, default));
+ using (var cancelled = new CancellationTokenSource())
+ {
+ cancelled.Cancel();
+ bool cancellationObserved = false;
+ try { await auth.Authenticate("main", "changed-password", cancelled.Token); }
+ catch (OperationCanceledException) { cancellationObserved = true; }
+ Assert(cancellationObserved, "认证未响应取消请求");
+ }
+ // 强制第二条转账流水写入失败,验证已更新的双方积分和第一条流水一起回滚。
+ actor.UserName = "main";
+ await db.Ado.ExecuteNonQueryAsync("CREATE TRIGGER reject_credit BEFORE INSERT ON points WHEN NEW.option_type = '转入' BEGIN SELECT RAISE(ABORT, 'test rollback'); END");
+ bool failed = false;
+ try { await funds.Change(new() { UserName = "other", Amount = 10, Operation = "转移", RequestId = "rollback" }, true, default); }
+ catch (Exception ex) when (ex.Message.Contains("test rollback")) { failed = true; }
+ Assert(failed, "测试故障未触发");
+ Assert((await db.Select().Where(u => u.Id == main.Id).FirstAsync()).Points == 70, "转账失败未回滚转出账户");
+ Assert((await db.Select().Where(u => u.Id == other.Id).FirstAsync()).Points == 30, "转账失败未回滚转入账户");
+ Assert(await db.Select().Where(r => r.OutTradeNo == "user:rollback").CountAsync() == 0, "转账失败未回滚流水");
+ await db.Ado.ExecuteNonQueryAsync("DROP TRIGGER reject_credit");
+ var messageId = await users.AddTopUpMessage(new() { Message = "银行充值待核实" }, default);
+ await Reject(() => users.UpdateTopUpMessage(new() { Id = messageId, Result = "已处理" }, default));
+ actor.UserName = "other";
+ Assert((await users.TopUpMessages(new(), default)).Total == 0, "充值备注越权泄漏");
+ actor.UserName = "admin";
+ await users.UpdateTopUpMessage(new() { Id = messageId, Result = "已处理" }, default);
+ Assert((await users.TopUpMessages(new(), default)).Items.Single().Result == "已处理", "备注处理结果未保存");
+ await users.UpdateTopUpQrcode(new() { AgentCode = "agent", QrcodeBase64 = "aGVsbG8=" }, default);
+ await users.UpdateTopUpQrcode(new() { AgentCode = "agent", QrcodeBase64 = "d29ybGQ=" }, default);
+ Assert(await users.GetTopUpQrcode("agent", default) == "d29ybGQ=", "二维码更新失败");
+ Assert(await db.Select().CountAsync() == 1, "二维码产生重复编码");
+ await Reject(() => users.UpdateTopUpQrcode(new() { AgentCode = "agent", QrcodeBase64 = "invalid!" }, default));
+ actor.UserName = "child";
+ await Reject(() => users.Create(new() { UserName = "grandchild", Password = "password" }, true, default));
+ actor.UserName = "admin";
+ var shopOwner = Seed("shop-owner", 0);
+ await db.Insert(new ShopEntity { UserName = shopOwner.UserName, ShopId = "test-shop" }).ExecuteAffrowsAsync();
+ await Reject(() => users.Delete(shopOwner.Id, default));
+ Console.WriteLine("PASS: 用户认证、密码升级、权限隔离、主子账号、计费继承、资金事务、重复请求、超扣回滚、删除限制、参数边界。");
+
+ UserEntity Seed(string name, short level)
+ {
+ var u = new UserEntity { UserName = name, NickName = name, Password = "legacy-password", ParentUserName = "", GroupId = 3,
+ Mobile = "", CompanyName = "", Notes = "", CardId = "", Authority = "积分用户", ForbiddenPage = "", OemId = "",
+ SaLevel = level, Balance = 0, Points = 0, ShopPointsPerMonth = 30, PostagePerOrder = 1, RefundPostagePerOrder = 1 };
+ u.Id = (int)db.Insert(u).ExecuteIdentity();
+ return u;
+ }
+}
+finally { if (File.Exists(path)) File.Delete(path); }
+
+static void Assert(bool condition, string message) { if (!condition) throw new Exception(message); }
+static async Task Reject(Func action)
+{
+ try { await action(); }
+ catch (AppException) { return; }
+ catch (ValidationException) { return; }
+ throw new Exception("操作应被拒绝");
+}
+sealed class Actor : ICurrentUser
+{
+ public bool IsAuthenticated => true;
+ public string? UserName { get; set; }
+ public string? NickName => UserName;
+ public IEnumerable Claims => [];
+ public string? GetClaimValue(string type) => null;
+ public T? GetClaimValue(string type) => default;
+}
+sealed class Sessions : IOnlineUserStore
+{
+ public HashSet Names { get; } = [];
+ public void SignOut(string name) => Names.Add(name);
+ public void SignIn(OnlineUserInfo info) { }
+ public bool IsTokenActive(string name, string jti) => false;
+ public IReadOnlyList GetOnlineUsers() => [];
+ public int Count => 0;
+}
diff --git a/Tests/UserManagementSmoke/UserManagementSmoke.csproj b/Tests/UserManagementSmoke/UserManagementSmoke.csproj
new file mode 100644
index 0000000..daa4bd9
--- /dev/null
+++ b/Tests/UserManagementSmoke/UserManagementSmoke.csproj
@@ -0,0 +1,7 @@
+
+ Exe
+
+
+
+
+
diff --git a/Tests/UserManagementSmoke/UserRoutingChecks.cs b/Tests/UserManagementSmoke/UserRoutingChecks.cs
new file mode 100644
index 0000000..da7b815
--- /dev/null
+++ b/Tests/UserManagementSmoke/UserRoutingChecks.cs
@@ -0,0 +1,37 @@
+using Microsoft.AspNetCore.Mvc.ActionConstraints;
+using Microsoft.AspNetCore.Mvc.ApiExplorer;
+using Microsoft.AspNetCore.Mvc.Controllers;
+using Microsoft.AspNetCore.Mvc.Infrastructure;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+using Store.Users.Controllers;
+
+internal static class UserRoutingChecks
+{
+ public static void Run()
+ {
+ // 经真实 MVC 模型构建流程验证约定,不只是检查特性或直接调用 Apply。
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddControllers().AddApplicationPart(typeof(UserController).Assembly);
+ using var provider = services.BuildServiceProvider();
+ var actions = provider.GetRequiredService().ActionDescriptors.Items
+ .OfType().Where(a => a.ControllerTypeInfo.AsType() == typeof(UserController)).ToArray();
+ if (actions.Length != 19) throw new Exception("用户控制器操作发现数量错误");
+ foreach (var action in actions)
+ {
+ var method = "POST";
+ var constraint = action.ActionConstraints!.OfType().Single();
+ var metadata = action.EndpointMetadata.OfType().Single();
+ if (!constraint.HttpMethods.SequenceEqual([method]) || !metadata.HttpMethods.SequenceEqual([method]))
+ throw new Exception($"{action.ActionName} 的方法约束或端点元数据错误");
+ }
+ var descriptions = provider.GetRequiredService().ApiDescriptionGroups.Items
+ .SelectMany(g => g.Items).Where(d => d.ActionDescriptor is ControllerActionDescriptor a
+ && a.ControllerTypeInfo.AsType() == typeof(UserController)).ToArray();
+ if (descriptions.Length != actions.Length || descriptions.Any(d => d.HttpMethod !=
+ "POST"))
+ throw new Exception("用户接口文档的 HTTP 方法错误");
+ Console.WriteLine("PASS: User 统一 POST、路由约束、端点元数据和接口文档验证通过。");
+ }
+}