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、路由约束、端点元数据和接口文档验证通过。"); + } +}