diff --git a/Modules/Store.Users.Contracts/Dtos/UserFundsRequest.cs b/Modules/Store.Users.Contracts/Dtos/UserFundsRequest.cs index aec5b74..4a27de7 100644 --- a/Modules/Store.Users.Contracts/Dtos/UserFundsRequest.cs +++ b/Modules/Store.Users.Contracts/Dtos/UserFundsRequest.cs @@ -1,15 +1,23 @@ using System.ComponentModel.DataAnnotations; namespace Store.Users.Contracts.Dtos; -/// 资金请求;DTO 只声明非空规则,金额、长度和操作规则由服务判断。 -public sealed class UserFundsRequest +/// 资金请求;DTO 检查输入,服务区分积分/余额操作并校验权限。 +public sealed class UserFundsRequest : IValidatableObject { - [Required(ErrorMessage = "目标账户不能为空")] public string UserName { get; set; } = ""; - [Required(ErrorMessage = "请求号不能为空")] public string RequestId { get; set; } = ""; + [Required(ErrorMessage = "目标账户不能为空"), StringLength(20)] public string UserName { get; set; } = ""; + [Required(ErrorMessage = "请求号不能为空"), StringLength(64)] public string RequestId { get; set; } = ""; + [Range(typeof(decimal), "0.01", "1000000")] public decimal Amount { get; set; } /// 积分支持充值、转移;余额支持充值、消费。 - [Required(ErrorMessage = "操作类型不能为空")] public string Operation { get; set; } = ""; + [Required(ErrorMessage = "操作类型不能为空"), StringLength(20)] public string Operation { get; set; } = ""; + [StringLength(255)] public string? Remark { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (decimal.Round(Amount, 2) != Amount) + yield return new ValidationResult("金额最多保留两位小数", [nameof(Amount)]); + } } 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 index acf291b..2ef3bcb 100644 --- a/Modules/Store.Users.Contracts/Dtos/UserIdentityRequests.cs +++ b/Modules/Store.Users.Contracts/Dtos/UserIdentityRequests.cs @@ -1,22 +1,22 @@ using System.ComponentModel.DataAnnotations; namespace Store.Users.Contracts.Dtos; -/// 按用户名查询账户;仅声明用户名非空。 +/// 按用户名查询账户;声明用户名非空及长度。 public sealed class UserNameRequest { - [Required(ErrorMessage = "用户名不能为空")] + [Required(ErrorMessage = "用户名不能为空"), StringLength(20)] public string UserName { get; set; } = ""; } -/// 登录输入;长度、账号是否存在及密码匹配由认证服务处理。 +/// 登录输入;输入在 DTO 中检查,账号存在及密码匹配由认证服务处理。 public sealed class UserLoginRequest { - [Required(ErrorMessage = "用户名不能为空")] public string UserName { get; set; } = ""; - [Required(ErrorMessage = "密码不能为空")] public string Password { get; set; } = ""; + [Required(ErrorMessage = "用户名不能为空"), StringLength(20)] public string UserName { get; set; } = ""; + [Required(ErrorMessage = "密码不能为空"), StringLength(128)] public string Password { get; set; } = ""; } /// 按代理编码查询充值二维码。 public sealed class UserTopUpQrcodeQuery { - [Required(ErrorMessage = "代理编码不能为空")] public string AgentCode { get; set; } = ""; + [Required(ErrorMessage = "代理编码不能为空"), StringLength(50)] public string AgentCode { get; set; } = ""; } diff --git a/Modules/Store.Users.Contracts/Dtos/UserInfo.cs b/Modules/Store.Users.Contracts/Dtos/UserInfo.cs index b6fe891..f11fd63 100644 --- a/Modules/Store.Users.Contracts/Dtos/UserInfo.cs +++ b/Modules/Store.Users.Contracts/Dtos/UserInfo.cs @@ -1,3 +1,4 @@ +using Store.Users.Contracts.Enums; using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; @@ -33,9 +34,9 @@ namespace Store.Users.Contracts.Dtos public string CardId { get; set; } /// - /// 用户分组 3主号 4客服 5加盟 8登录 9采购 10加盟 11上货 + /// 用户分组,参见 。 /// - public byte GroupId { get; set; } + public UserGroup GroupId { get; set; } /// /// 父账户用户名 @@ -73,9 +74,9 @@ namespace Store.Users.Contracts.Dtos public string Notes { get; set; } /// - /// 超管等级 0 普通用户 1 超级管理员 + /// 超管等级,参见 。 /// - public short? SaLevel { get; set; } + public UserAdminLevel? SaLevel { get; set; } /// /// 账户权限 @@ -138,7 +139,7 @@ namespace Store.Users.Contracts.Dtos /// public bool IsMainUser { - get => string.IsNullOrWhiteSpace(ParentUserName); + get => GroupId == UserGroup.MainAccount && string.IsNullOrWhiteSpace(ParentUserName); } /// /// 是否OEM租户 diff --git a/Modules/Store.Users.Contracts/Dtos/UserManagementRequests.cs b/Modules/Store.Users.Contracts/Dtos/UserManagementRequests.cs index 9b85997..3668db4 100644 --- a/Modules/Store.Users.Contracts/Dtos/UserManagementRequests.cs +++ b/Modules/Store.Users.Contracts/Dtos/UserManagementRequests.cs @@ -1,30 +1,48 @@ +using Store.Users.Contracts.Enums; using System.ComponentModel.DataAnnotations; namespace Store.Users.Contracts.Dtos; -/// 账户资料;null 保留原值,空字符串清空文本。长度与范围由服务检查。 +/// 账户资料;null 保留原值,空字符串清空文本。输入约束由 DTO 声明,权限与账户关系由服务检查。 public class UserEditRequest { + [StringLength(50)] public string? NickName { get; set; } + [StringLength(30)] public string? Mobile { get; set; } + [StringLength(100)] public string? CompanyName { get; set; } + [StringLength(255)] public string? Notes { get; set; } + [StringLength(50)] public string? CardId { get; set; } - public byte? GroupId { get; set; } + [EnumDataType(typeof(UserGroup), ErrorMessage = "请选择有效的用户分组")] + public UserGroup? GroupId { get; set; } /// 仅管理员可设置;普通账号不能通过资料修改提升管理员级别。 - public short? SaLevel { get; set; } + [EnumDataType(typeof(UserAdminLevel), ErrorMessage = "管理员级别只能为普通用户或超级管理员")] + public UserAdminLevel? SaLevel { get; set; } + [StringLength(1000)] public string? Authority { get; set; } + [StringLength(1000)] public string? ForbiddenPage { get; set; } + [StringLength(50)] public string? OemId { get; set; } + [Range(30, int.MaxValue, ErrorMessage = "每月续费积分不能低于30")] public int? ShopPointsPerMonth { get; set; } + [Range(typeof(decimal), "2.2", "999999")] public decimal? PostagePerOrder { get; set; } + [Range(typeof(decimal), "2.5", "999999")] public decimal? RefundPostagePerOrder { get; set; } + [Range(typeof(decimal), "0", "999999")] public decimal? GiftOemAmountPerOrder { get; set; } + [Range(typeof(decimal), "0", "999999")] public decimal? SyncLogisticsPerOrder { get; set; } + } /// 修改账户资料;账户 ID 与资料字段统一通过 JSON 请求体提交。 public sealed class UserUpdateRequest : UserEditRequest { + [Range(1, int.MaxValue, ErrorMessage = "ID必须大于0")] public int Id { get; set; } } @@ -32,17 +50,20 @@ public sealed class UserUpdateRequest : UserEditRequest public sealed class UserCreateRequest : UserEditRequest { /// 创建子账号时的父账号;管理员可指定任意主账号,普通主账号只能指定自己。 + [StringLength(20)] public string? ParentUserName { get; set; } - [Required(ErrorMessage = "用户名不能为空")] public string UserName { get; set; } = ""; - [Required(ErrorMessage = "密码不能为空")] public string Password { get; set; } = ""; + [Required(ErrorMessage = "用户名不能为空"), StringLength(20)] public string UserName { get; set; } = ""; + [Required(ErrorMessage = "密码不能为空"), StringLength(128, MinimumLength = 6)] public string Password { get; set; } = ""; } -/// 本人改密需原密码;管理员及主账号可为管理范围内的其他账户重置密码。 +/// 非管理员本人改密需原密码;管理员可重置任意账户,主账号可重置直属子账号密码。 public sealed class ChangePasswordRequest { + [StringLength(20)] public string? UserName { get; set; } + [StringLength(128)] public string? OldPassword { get; set; } - [Required(ErrorMessage = "新密码不能为空")] public string NewPassword { get; set; } = ""; + [Required(ErrorMessage = "新密码不能为空"), StringLength(128, MinimumLength = 6)] public string NewPassword { get; set; } = ""; } /// 本人修改密码时验证原密码非空。 @@ -51,28 +72,51 @@ public sealed class UserPasswordProofRequest [Required(ErrorMessage = "原密码不能为空")] public string OldPassword { get; set; } = ""; } -/// 分页参数;页码、分页大小和过滤值由服务检查。 +/// 分页参数;在查询数据库前完成范围校验。 public class UserSearchRequest { + [Range(1, 1000000)] public int Page { get; set; } = 1; + [Range(1, 200)] public int PageSize { get; set; } = 50; + [StringLength(100)] public string? Keyword { get; set; } - public byte? GroupId { get; set; } + [EnumDataType(typeof(UserGroup), ErrorMessage = "请选择有效的用户分组")] + public UserGroup? GroupId { get; set; } /// 查询子账号时可按父账号筛选;管理员可指定任意账号,普通主账号只能指定自己。 + [StringLength(20)] public string? ParentUserName { get; set; } + } public sealed record UserPage(long Total, IReadOnlyList Items); /// 流水查询支持时间范围、操作类型、店铺及订单过滤。 -public sealed class UserRecordSearchRequest : UserSearchRequest +public sealed class UserRecordSearchRequest : UserSearchRequest, IValidatableObject { /// 目标账户;省略时查询本人。 + [StringLength(20)] public string? UserName { get; set; } public DateTime? StartTime { get; set; } public DateTime? EndTime { get; set; } + [StringLength(20)] public string? Operation { get; set; } + [StringLength(50)] public string? ShopId { get; set; } public int? PaymentType { get; set; } + [StringLength(100)] public string? OrderId { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (StartTime > EndTime) + yield return new ValidationResult("开始时间不能晚于结束时间", [nameof(StartTime), nameof(EndTime)]); + } +} + +/// 单个账户 ID 参数的校验,供服务内部调用复用。 +public sealed class UserIdRequest +{ + [Range(1, int.MaxValue, ErrorMessage = "ID必须大于0")] + public int Id { get; set; } } diff --git a/Modules/Store.Users.Contracts/Dtos/UserTopUpRequests.cs b/Modules/Store.Users.Contracts/Dtos/UserTopUpRequests.cs index 6341b04..8fb8b30 100644 --- a/Modules/Store.Users.Contracts/Dtos/UserTopUpRequests.cs +++ b/Modules/Store.Users.Contracts/Dtos/UserTopUpRequests.cs @@ -5,19 +5,40 @@ namespace Store.Users.Contracts.Dtos; public sealed class UserTopUpMessageRequest { /// 目标账户;省略时为本人,指定时检查管理权限。 + [StringLength(20)] public string? UserName { get; set; } - [Required(ErrorMessage = "充值备注不能为空")] public string Message { get; set; } = ""; + [Required(ErrorMessage = "充值备注不能为空"), StringLength(1000)] public string Message { get; set; } = ""; } public sealed class UserTopUpResultRequest { + [Range(1, long.MaxValue, ErrorMessage = "ID必须大于0")] public long Id { get; set; } - [Required(ErrorMessage = "处理结果不能为空")] public string Result { get; set; } = ""; + [Required(ErrorMessage = "处理结果不能为空"), StringLength(1000)] public string Result { get; set; } = ""; } -/// 充值二维码;长度与 Base64 格式在服务中检查。 -public sealed class UserTopUpQrcodeRequest +/// 充值二维码;非空、长度与 Base64 格式由 DTO 检查。 +public sealed class UserTopUpQrcodeRequest : IValidatableObject { - [Required(ErrorMessage = "代理编码不能为空")] public string AgentCode { get; set; } = ""; - [Required(ErrorMessage = "二维码不能为空")] public string QrcodeBase64 { get; set; } = ""; + [Required(ErrorMessage = "代理编码不能为空"), StringLength(50)] public string AgentCode { get; set; } = ""; + [Required(ErrorMessage = "二维码不能为空"), StringLength(2097152)] public string QrcodeBase64 { get; set; } = ""; + + public IEnumerable Validate(ValidationContext validationContext) + { + var content = QrcodeBase64; + if (content.StartsWith("data:image/", StringComparison.OrdinalIgnoreCase)) + { + int comma = content.IndexOf(','); + if (comma < 0 || !content[..comma].EndsWith(";base64", StringComparison.OrdinalIgnoreCase)) + { + yield return new ValidationResult("二维码格式错误", [nameof(QrcodeBase64)]); + yield break; + } + content = content[(comma + 1)..]; + } + bool valid; + try { valid = Convert.FromBase64String(content).Length > 0; } + catch (FormatException) { valid = false; } + if (!valid) yield return new ValidationResult("二维码必须为有效的Base64数据", [nameof(QrcodeBase64)]); + } } diff --git a/Modules/Store.Users.Contracts/Enums/UserAdminLevel.cs b/Modules/Store.Users.Contracts/Enums/UserAdminLevel.cs new file mode 100644 index 0000000..e67c838 --- /dev/null +++ b/Modules/Store.Users.Contracts/Enums/UserAdminLevel.cs @@ -0,0 +1,12 @@ +using System.ComponentModel; + +namespace Store.Users.Contracts.Enums; + +/// 超管等级,数值与旧系统 sa_level 保持一致。 +public enum UserAdminLevel : short +{ + /// 普通用户。 + [Description("普通用户")] Normal = 0, + /// 超级管理员。 + [Description("超级管理员")] SuperAdmin = 1 +} diff --git a/Modules/Store.Users.Contracts/Enums/UserGroup.cs b/Modules/Store.Users.Contracts/Enums/UserGroup.cs new file mode 100644 index 0000000..77e3aa9 --- /dev/null +++ b/Modules/Store.Users.Contracts/Enums/UserGroup.cs @@ -0,0 +1,22 @@ +using System.ComponentModel; + +namespace Store.Users.Contracts.Enums; + +/// 用户分组,数值与旧系统 group_id 保持一致。 +public enum UserGroup : byte +{ + /// 主号。 + [Description("主号")] MainAccount = 3, + /// 客服。 + [Description("客服")] CustomerService = 4, + /// 加盟(旧系统分组 5)。 + [Description("加盟(5)")] Franchise5 = 5, + /// 登录。 + [Description("登录")] Login = 8, + /// 采购。 + [Description("采购")] Purchasing = 9, + /// 加盟(旧系统分组 10);保留与分组 5 的区别。 + [Description("加盟(10)")] Franchise10 = 10, + /// 上货。 + [Description("上货")] Listing = 11 +} diff --git a/Modules/Store.Users/Behaviors/AccountConsumptionBehavior.cs b/Modules/Store.Users/Behaviors/AccountConsumptionBehavior.cs index 43e5fac..762a35b 100644 --- a/Modules/Store.Users/Behaviors/AccountConsumptionBehavior.cs +++ b/Modules/Store.Users/Behaviors/AccountConsumptionBehavior.cs @@ -1,3 +1,5 @@ +using MAX.Framework.Security; +using Store.Users.Services; using MAX.Framework.Commons; using MAX.Framework.Mediation; using MediatR; @@ -5,9 +7,9 @@ using Store.Users.Entities; namespace Store.Users.Behaviors; -/// 锁定积分账户并检查消费额度,不依赖商铺信息。 +/// 从登录上下文确认操作者,校验目标账户归属后锁定积分账户并检查消费额度。 public sealed class AccountConsumptionBehavior( - UnitOfWorkExecutionContext executionContext) + UnitOfWorkExecutionContext executionContext, ICurrentUser currentUser) : IPipelineBehavior where TRequest : class, IRequest, IAccountConsumptionRequest { @@ -15,22 +17,26 @@ public sealed class AccountConsumptionBehavior( RequestHandlerDelegate next) { cancellationToken.ThrowIfCancellationRequested(); - if (string.IsNullOrWhiteSpace(request.UserName)) - throw new AppException("用户名不能为空"); - var userName = request.UserName.Trim(); - var user = await executionContext.Orm.Select().Where(a => a.UserName == userName) - .ForUpdate().FirstAsync(cancellationToken) - ?? throw new AppException("不存在的用户"); + if (!currentUser.IsAuthenticated || string.IsNullOrWhiteSpace(currentUser.UserName)) + throw new UnauthorizedAccessException(); + var orm = executionContext.Orm; + var actor = await orm.Select().Where(u => u.UserName == currentUser.UserName) + .FirstAsync(cancellationToken) ?? throw new UnauthorizedAccessException("登录账户不存在"); + var userName = string.IsNullOrWhiteSpace(request.UserName) ? actor.UserName : request.UserName.Trim(); + var user = await orm.Select().Where(u => u.UserName == userName) + .ForUpdate().FirstAsync(cancellationToken) ?? throw new AppException("用户不存在"); + if (!UserAccessPolicy.CanUseSelfService(actor, user)) + throw new AppException("无权为该账户续费", 403); var quote = request.CalculateConsumption(user); if (quote.Amount <= 0 || quote.UnitPrice <= 0) throw new AppException("消费金额必须大于0"); if (user.Points < quote.Amount) - throw new AppException("余额不足"); + throw new AppException("积分不足"); lock (request) { if (request.ConsumptionContext != null) throw new InvalidOperationException("同一个消费命令不能同时执行"); - request.ConsumptionContext = new AccountConsumptionContext(user, quote); + request.ConsumptionContext = new AccountConsumptionContext(user, actor, quote); } try { diff --git a/Modules/Store.Users/Behaviors/ConsumptionContracts.cs b/Modules/Store.Users/Behaviors/ConsumptionContracts.cs index 2e91feb..016e090 100644 --- a/Modules/Store.Users/Behaviors/ConsumptionContracts.cs +++ b/Modules/Store.Users/Behaviors/ConsumptionContracts.cs @@ -6,7 +6,7 @@ namespace Store.Users.Behaviors; /// 需要账户积分消费检查的请求。 public interface IAccountConsumptionRequest { - string UserName { get; } + string? UserName { get; } AccountConsumptionContext? ConsumptionContext { get; set; } ConsumptionQuote CalculateConsumption(UserEntity user); } @@ -20,4 +20,4 @@ public interface IShopOwnershipRequest } public sealed record ConsumptionQuote(decimal Amount, decimal UnitPrice); -public sealed record AccountConsumptionContext(UserEntity User, ConsumptionQuote Quote); +public sealed record AccountConsumptionContext(UserEntity User, UserEntity Actor, ConsumptionQuote Quote); diff --git a/Modules/Store.Users/Behaviors/ShopOwnershipBehavior.cs b/Modules/Store.Users/Behaviors/ShopOwnershipBehavior.cs index 586acb1..430b800 100644 --- a/Modules/Store.Users/Behaviors/ShopOwnershipBehavior.cs +++ b/Modules/Store.Users/Behaviors/ShopOwnershipBehavior.cs @@ -14,8 +14,6 @@ public sealed class ShopOwnershipBehavior( RequestHandlerDelegate next) { cancellationToken.ThrowIfCancellationRequested(); - if (string.IsNullOrWhiteSpace(request.UserName) || string.IsNullOrWhiteSpace(request.ShopId)) - throw new AppException("用户名和商铺ID不能为空"); var userName = request.UserName.Trim(); var shopId = request.ShopId.Trim(); var orm = executionContext.IsActive ? executionContext.Orm : freeSql; @@ -23,8 +21,6 @@ public sealed class ShopOwnershipBehavior( if (executionContext.IsActive) query = query.ForUpdate(); var shop = await query.FirstAsync(cancellationToken) ?? throw new AppException("不存在的商铺或该商铺不属于当前用户"); - if (!string.Equals(shop.UserName, userName, StringComparison.OrdinalIgnoreCase)) - throw new AppException("该商铺不属于当前用户"); lock (request) { if (request.OwnedShop != null) diff --git a/Modules/Store.Users/Commands/ShopRenewCommand.cs b/Modules/Store.Users/Commands/ShopRenewCommand.cs index 311ab0d..b783890 100644 --- a/Modules/Store.Users/Commands/ShopRenewCommand.cs +++ b/Modules/Store.Users/Commands/ShopRenewCommand.cs @@ -18,27 +18,28 @@ namespace Store.Users.Commands; [MediatRBehavior(typeof(TransactionBehavior<,>))] [MediatRBehavior(typeof(AccountConsumptionBehavior<,>))] [MediatRBehavior(typeof(ShopOwnershipBehavior<,>))] -public sealed record ShopRenewCommand(string UserName, string ShopId, int RenewDuration, string? Remark = null, string? OperatorName = null) +public sealed record ShopRenewCommand(ShopRenewRequest Input) : IRequest, IValidatableRequest, IAccountConsumptionRequest, IShopOwnershipRequest { - // 由消费管道建立,仅在当前事务执行期间有效。 - AccountConsumptionContext? IAccountConsumptionRequest.ConsumptionContext { get; set; } + // 请求只包含业务输入;实际操作者与目标账户由消费管道从数据库确定。 + public string? UserName => Input.UserName; + public string ShopId => Input.ShopId; + public int RenewDuration => Input.RenewDuration; + public string? Remark => Input.Remark; + private AccountConsumptionContext? consumptionContext; + AccountConsumptionContext? IAccountConsumptionRequest.ConsumptionContext + { + get => consumptionContext; + set => consumptionContext = value; + } + // 消费管道先于店铺管道执行,店铺校验使用已授权的账户名称。 + string IShopOwnershipRequest.UserName => consumptionContext!.User.UserName; ShopEntity? IShopOwnershipRequest.OwnedShop { get; set; } - void IValidatableRequest.Validate() - { - RequestValidator.Validate(new UserNameRequest { UserName = UserName?.Trim()! }); - UserInputChecks.Length(UserName.Trim(), 20, "用户名"); - UserInputChecks.Length(OperatorName, 20, "操作者"); - var request = new ShopRenewRequest { ShopId = ShopId, RenewDuration = RenewDuration, Remark = Remark }; - RequestValidator.Validate(request); - UserInputChecks.Renew(request); - } + void IValidatableRequest.Validate() => RequestValidator.Validate(Input); ConsumptionQuote IAccountConsumptionRequest.CalculateConsumption(UserEntity user) { - if (RenewDuration is < 1 or > 120) - throw new AppException("续费时长必须为1-120个月"); int price = Math.Max(30, user.ShopPointsPerMonth); return new ConsumptionQuote((decimal)price * RenewDuration, price); } @@ -49,9 +50,7 @@ internal sealed class ShopRenewCommandHandler(UnitOfWorkExecutionContext executi { public async Task Handle(ShopRenewCommand request, CancellationToken cancellationToken) { - ArgumentNullException.ThrowIfNull(request); cancellationToken.ThrowIfCancellationRequested(); - var userName = request.UserName.Trim(); var shopId = request.ShopId.Trim(); var remark = string.IsNullOrWhiteSpace(request.Remark) ? null : request.Remark.Trim(); @@ -59,12 +58,8 @@ internal sealed class ShopRenewCommandHandler(UnitOfWorkExecutionContext executi ?? throw new InvalidOperationException("续费命令必须通过账户消费管道执行"); var orm = executionContext.Orm; var user = context.User; - // 代操作权限在事务中再次确认,审计流水记录实际操作者而非被操作账户。 - var operatorName = request.OperatorName ?? userName; - var actor = await orm.Select().Where(u => u.UserName == operatorName).FirstAsync(cancellationToken) - ?? throw new AppException("操作者账户不存在"); - if (!UserAccessPolicy.CanManage(actor, user) && actor.Id != user.Id) - throw new AppException("无权为该账户续费", 403); + var userName = user.UserName; + var actor = context.Actor; var shop = ((IShopOwnershipRequest)request).OwnedShop ?? throw new InvalidOperationException("续费命令必须通过商铺归属管道执行"); decimal pointsPerMonth = context.Quote.UnitPrice; diff --git a/Modules/Store.Users/Controllers/UserController.cs b/Modules/Store.Users/Controllers/UserController.cs index a747f42..009daef 100644 --- a/Modules/Store.Users/Controllers/UserController.cs +++ b/Modules/Store.Users/Controllers/UserController.cs @@ -1,4 +1,3 @@ -using MAX.Framework.Security; using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -18,7 +17,7 @@ namespace Store.Users.Controllers; [DefaultHttpPost] [Authorize] [Route("v{version:apiVersion}/user/[action]")] -public class UserController(UserManagementService users, UserFundsService funds, IMediator mediator, ICurrentUser currentUser) : ControllerBase +public class UserController(UserManagementService users, UserFundsService funds, IMediator mediator) : ControllerBase { /// 获取本人或授权范围内的用户信息,响应不包含密码。 [EndpointSummary("获取用户信息")] @@ -62,14 +61,14 @@ public class UserController(UserManagementService users, UserFundsService funds, [EndpointSummary("修改用户资料")] public Task UpdateUser(UserUpdateRequest request, CancellationToken cancellationToken) { - return users.Update(request.Id, request, false, cancellationToken); + return users.Update(request, false, cancellationToken); } /// 管理员修改任意子账号,普通主账号仅修改直属子账号。 [EndpointSummary("修改直属子账号资料")] public Task UpdateSubUser(UserUpdateRequest request, CancellationToken cancellationToken) { - return users.Update(request.Id, request, true, cancellationToken); + return users.Update(request, true, cancellationToken); } /// 删除无资金和业务关联的账户。 @@ -80,7 +79,7 @@ public class UserController(UserManagementService users, UserFundsService funds, return ApiResult.Ok(); } - /// 本人改密校验原密码;管理员或主账号可为管理范围内的其他账户重置密码。 + /// 非管理员本人改密校验原密码;管理员可重置任意账户,主账号可重置直属子账号密码。 [EndpointSummary("修改或重置用户密码")] public async Task ChangePassword(ChangePasswordRequest request, CancellationToken cancellationToken) { @@ -102,7 +101,7 @@ public class UserController(UserManagementService users, UserFundsService funds, return users.Balance(request.UserName, request, cancellationToken); } - /// 管理员充值积分,或当前用户向其他账户转移自己的积分。 + /// 管理员充值积分,或主账号向直属子账号转移自己的积分。 [EndpointSummary("充值或转移用户积分")] public Task UpdateUserPoints(UserFundsRequest request, CancellationToken cancellationToken) { @@ -155,14 +154,9 @@ public class UserController(UserManagementService users, UserFundsService funds, /// 管理员或主账号为授权账户的商铺续费,扣除店铺所属账户积分并记录实际操作者。 [EndpointSummary("使用积分为商铺续费")] - public async Task ShopRenew(ShopRenewRequest request, CancellationToken cancellationToken) + public Task ShopRenew(ShopRenewRequest request, CancellationToken cancellationToken) { - if (!currentUser.IsAuthenticated || string.IsNullOrWhiteSpace(currentUser.UserName)) - { - throw new UnauthorizedAccessException(); - } - var targetName = await users.GetManagedUserName(request.UserName, cancellationToken); - return await mediator.Send(new ShopRenewCommand(targetName, request.ShopId, request.RenewDuration, request.Remark, - currentUser.UserName), cancellationToken); + // 操作者身份、目标账户归属和消费额度统一在命令管道中检查。 + return mediator.Send(new ShopRenewCommand(request), cancellationToken); } } diff --git a/Modules/Store.Users/Dtos/ShopRenewRequest.cs b/Modules/Store.Users/Dtos/ShopRenewRequest.cs index 28be91d..27c516e 100644 --- a/Modules/Store.Users/Dtos/ShopRenewRequest.cs +++ b/Modules/Store.Users/Dtos/ShopRenewRequest.cs @@ -1,13 +1,16 @@ using System.ComponentModel.DataAnnotations; namespace Store.Users.Dtos; -/// 续费请求;仅声明店铺ID非空,时长和长度由服务或命令处理。 +/// 续费请求;输入规则由 DTO 声明,命令管道在事务前校验。 public class ShopRenewRequest { /// 续费账户;管理员可指定任意账户,普通主账号仅限本人及直属子账号。 + [StringLength(20)] public string? UserName { get; set; } - [Required(ErrorMessage = "店铺ID不能为空")] public string ShopId { get; set; } = ""; + [Required(ErrorMessage = "店铺ID不能为空"), StringLength(50)] public string ShopId { get; set; } = ""; /// 续费时长,单位月,合法范围1-120。 + [Range(1, 120, ErrorMessage = "续费时长必须为1-120个月")] public int RenewDuration { get; set; } + [StringLength(255)] public string? Remark { get; set; } } diff --git a/Modules/Store.Users/Entities/UserEntity.cs b/Modules/Store.Users/Entities/UserEntity.cs index 0eb517b..32cf327 100644 --- a/Modules/Store.Users/Entities/UserEntity.cs +++ b/Modules/Store.Users/Entities/UserEntity.cs @@ -1,4 +1,5 @@ -using FreeSql.DataAnnotations; +using Store.Users.Contracts.Enums; +using FreeSql.DataAnnotations; using System; namespace Store.Users.Entities @@ -40,10 +41,10 @@ namespace Store.Users.Entities public string CardId { get; set; } /// - /// 用户分组 3主号 4客服 5加盟 8登录 9采购 10加盟 11上货 + /// 用户分组,参见 。 /// - [Column(Name = "group_id")] - public byte GroupId { get; set; } + [Column(Name = "group_id", MapType = typeof(byte))] + public UserGroup GroupId { get; set; } /// /// 父账户用户名 @@ -88,10 +89,10 @@ namespace Store.Users.Entities public string Notes { get; set; } /// - /// 超管等级 0 普通用户 1 超级管理员 + /// 超管等级,参见 。 /// - [Column(Name = "sa_level")] - public short? SaLevel { get; set; } + [Column(Name = "sa_level", MapType = typeof(short))] + public UserAdminLevel? SaLevel { get; set; } /// /// 账户权限 @@ -165,7 +166,8 @@ namespace Store.Users.Entities /// public bool IsMainUser { - get => string.IsNullOrWhiteSpace(ParentUserName); + // 只有主号分组且无父账号才是主账号,不能把缺失父账号的子账号当作主账号。 + get => GroupId == UserGroup.MainAccount && string.IsNullOrWhiteSpace(ParentUserName); } /// /// 是否OEM租户 diff --git a/Modules/Store.Users/README.md b/Modules/Store.Users/README.md index fc6c2aa..cd68e9b 100644 --- a/Modules/Store.Users/README.md +++ b/Modules/Store.Users/README.md @@ -1,99 +1,57 @@ -# 用户模块迁移与运行 +# 用户模块 -本模块参考 `D:/Works/KJD_WebService/KJD_WebService/BLL/WXXD/WXUserManage.cs` 重写,保留旧表映射,采用 ASP.NET Core 控制器、DTO、服务、FreeSql 仓储和现有 JWT 身份体系。所有新代码均含中文用途或关键规则注释。 +参考旧版 `D:/Works/KJD_WebService/KJD_WebService/BLL/WXXD/WXUserManage.cs`,沿用用户、积分、余额、充值备注等旧表。接口采用 JSON DTO 和现有 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 集成验证,避免访问业务数据库。 +- 文件:`../Store.Users.Contracts/Dtos/*.cs`、`Dtos/ShopRenewRequest.cs`:非空、长度、数值范围、日期顺序、金额精度和二维码格式。`null` 更新字段保留原值,空字符串可清空可选文本。 +- 文件:`Services/UserAccessPolicy.cs`:集中定义管理员、主账号、子账号的操作范围。 +- 文件:`Services/UserManagementService.cs`:账户增删改查、密码和流水查询。 +- 文件:`Services/UserManagementService.TopUp.cs`:充值备注和公共代理二维码。 +- 文件:`Services/UserFundsService.cs`:资金事务、按主键顺序锁定账户、权限校验、请求去重和流水写入。 +- 文件:`Commands/ShopRenewCommand.cs`、`Behaviors/*.cs`:续费输入验证 → 事务 → 当前身份、账户归属及积分检查 → 店铺归属 → 扣费及延长有效期。 +- 文件:`Repositories/UserRepository.cs`:子账号的有效 OEM、费用和积分用户权限继承。 +- 文件:`Services/UserPassword.cs`:兼容旧明文密码;新建和修改密码保存带盐哈希。 -## 接口 +## 权限规则 -HTTP 方法约定:控制器通过 [DefaultHttpPost] 启用默认 POST。所有操作只接受 POST,无需逐个标记 [HttpPost]。该约定同时生成路由约束和端点元数据,接口文档也显示 POST。 +身份从登录上下文和数据库读取,管理员为 `SaLevel == UserAdminLevel.SuperAdmin`;普通主账号必须 `GroupId == UserGroup.MainAccount` 且无父账号。父账号缺失的子账号不会自动获得主账号权限。 -| 方法 | 路径 | 访问与行为 | -| --- | --- | --- | -| POST | /user/GetUser?userName=... | 本人、直属主账号或管理员;省略用户名读取本人 | -| POST | /user/GetAllUser | 管理员查询全部;普通主账号查询本人及直属子账号 | -| POST | /user/GetSubUser | 管理员查询所有子账号,可用 ParentUserName 按父账号筛选;普通主账号仅能查询自己的子账号 | -| 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 元费用下限。 +## 校验与业务边界 -资金请求示例: +DTO 用 `[Required]` 声明非空,使用数据注解和 `IValidatableObject` 声明输入规则。Service 入口统一调用 `RequestValidator.Validate`,续费通过 `ValidationBehavior` 调用 DTO 校验,保证内部调用也执行输入检查。Service 和后续管道只保留数据库状态、主子关系、权限、资金与店铺归属判断,不再重复校验必填字段。 -```json -{"userName":"target","requestId":"客户端生成的唯一请求号","amount":100,"operation":"充值","remark":"人工充值已核实"} +账户登录名保持稳定,避免破坏店铺和历史流水关联。新建账户的主/子身份由接口决定,`GroupId` 必须与该身份一致。管理员创建子账号可指定 `ParentUserName`,普通主账号只能指定本人。查询列表先限定可见范围,再应用筛选与分页;不能用筛选条件扩大权限。 + +续费命令只接收 `ShopRenewRequest`,不再接收 `OperatorName`。管道从 `ICurrentUser` 读取实际操作者,目标账户省略时默认为本人。店铺必须属于目标账户,扣除目标账户积分,流水记录真实操作者。续费保留旧规则:每月最少 30 积分,在当前有效期和当前时间中较晚者的基础上延长。 + +资金请求的 `Amount` 为正数且最多两位小数;积分支持“充值”“转移”,余额支持“充值”“消费”。积分转入仍计入累计充值。资金和流水在同一事务提交;同一目标和资金类型下,重复 `RequestId` 拒绝再次记账。重试必须复用请求号。充值备注的处理不自动增加余额或积分。 + +## 接口与运行 + +所有用户接口要求 Bearer Token,采用 POST,路由为 `v{version}/user/[action]`。资料和资金操作使用 JSON 请求体;`GetUser`、`DelUser`、`GetTopUpQrcode` 使用查询参数。分页从 1 开始,默认每页 50 条,最大 200 条。 + +```powershell +dotnet build Modules/Store.Users/Store.Users.csproj +dotnet build Store.API/Store.Api.csproj +dotnet run --project Store.API/Store.Api.csproj ``` -积分 Operation 支持“充值”“转移”;余额支持“充值”“消费”。Amount 始终为正数,最多两位小数。转移仅从当前登录账户转出,不支持负金额拉取对方积分。相同目标账户和资金类型下,重复 RequestId 返回已处理错误,不再次记账;调用失败重试必须保留请求号。不同请求号视作不同业务,调用方需确保业务唯一性。积分转入累计充值口径沿用旧系统。余额流水类型沿用旧系统充值 1、人工消费 0;商铺续费继续走专用接口。 +连接现有 MySQL 前确认账户表用户名唯一、密码列可容纳 Identity 哈希、资金相关表支持事务。应用不自动创建或修改旧表。旧系统直接比较明文密码的登录接口不能识别新哈希,需随调用方迁移。 -## 运行与部署 - -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 仅非空”的约定不改变公共校验器能力。该工具不递归验证嵌套对象。 - -## 账户管理权限 - -统一使用 Services/UserAccessPolicy:管理员可管理任意账户,普通主账号可管理本人及直属子账号;普通子账号保留本人查询和本人改密能力,不能管理其他账户。UpdateUser 支持此统一范围,UpdateSubUser 同时要求目标确实是子账号。管理员创建子账号时可传 ParentUserName 指定任意主账号;普通主账号只能指定自己。 - -ChangePassword 可传 UserName 指定目标,省略表示本人。本人改密仍须正确 OldPassword;管理员或主账号重置管理范围内的其他账户时仅需 NewPassword。成功后注销目标账户的会话。 - -已确认的财务权限保持:直接充值积分/余额、余额扣款、计费配置和公共代理二维码维护仅管理员可用;普通主账号不能提升管理员级别。积分转移仅从当前登录账户转出,普通主账号的转入目标限直属子账号。充值备注的提交/处理不代表资金到账。 - -ShopRenew 可传 UserName 指定授权账户,以店铺所属账户积分扣款,流水记录实际 OperatorName。账户删除仍禁止删除当前登录账户,且要求无积分、无余额、无子账号和无店铺关联。 - -管理员角色账户即使存在父账号关系,也不能由普通主账号修改或重置密码,避免通过子账号关系获得管理员身份。 +本次不修改或运行 Test 项目。编译和独立参数/权限边界验证不能替代真实 MySQL 的并发与表结构联调。旧文件中的 E57 令牌、销售助手 Cookie、淘宝买手不属于本模块范围。可以继续使用“代码审查”检查实现质量。 diff --git a/Modules/Store.Users/Services/UserAccessPolicy.cs b/Modules/Store.Users/Services/UserAccessPolicy.cs index bf8041b..7655dd9 100644 --- a/Modules/Store.Users/Services/UserAccessPolicy.cs +++ b/Modules/Store.Users/Services/UserAccessPolicy.cs @@ -1,3 +1,4 @@ +using Store.Users.Contracts.Enums; using Store.Users.Entities; namespace Store.Users.Services; @@ -5,14 +6,19 @@ namespace Store.Users.Services; /// 统一账户管理范围:管理员管理全部,主账号管理本人及直属子账号。 internal static class UserAccessPolicy { - public static bool IsAdmin(UserEntity actor) => actor.SaLevel == 1; + public static bool IsAdmin(UserEntity actor) => actor.SaLevel == UserAdminLevel.SuperAdmin; public static bool Owns(UserEntity actor, UserEntity target) => actor.IsMainUser && string.Equals(target.ParentUserName, actor.UserName, StringComparison.OrdinalIgnoreCase); public static bool CanRead(UserEntity actor, UserEntity target) => - IsAdmin(actor) || actor.Id == target.Id || Owns(actor, target); + CanUseSelfService(actor, target); + /// 子账号可查询本人、修改本人密码、提交充值备注和为本人店铺续费。 + public static bool CanUseSelfService(UserEntity actor, UserEntity target) => + actor.Id == target.Id || CanManage(actor, target); + + /// 管理员不受归属限制;普通主账号不能接管具有管理员身份的子账号。 public static bool CanManage(UserEntity actor, UserEntity target) => IsAdmin(actor) || (!IsAdmin(target) && actor.IsMainUser && (actor.Id == target.Id || Owns(actor, target))); } diff --git a/Modules/Store.Users/Services/UserFundsService.cs b/Modules/Store.Users/Services/UserFundsService.cs index 4365b74..96807ad 100644 --- a/Modules/Store.Users/Services/UserFundsService.cs +++ b/Modules/Store.Users/Services/UserFundsService.cs @@ -14,14 +14,6 @@ public sealed class UserFundsService(IFreeSql orm, ICurrentUser currentUser) : I 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(); @@ -32,15 +24,12 @@ public sealed class UserFundsService(IFreeSql orm, ICurrentUser currentUser) : I 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("不能向自己转移积分"); - if (transfer && !UserAccessPolicy.CanManage(actor, target)) - throw new AppException("只能向管理范围内的账户转移积分", 403); - // 双账户转移按相同主键顺序加锁,避免双方同时转账时产生反向锁顺序。 - var ids = transfer ? new[] { actor.Id, target.Id }.Order().ToArray() : new[] { target.Id }; + // 操作者和目标按主键顺序加锁,再按最新账户状态授权,避免权限变更与资金写入交错。 + var ids = new[] { actor.Id, target.Id }.Distinct().Order().ToArray(); foreach (var id in ids) { var locked = await db.Select().Where(u => u.Id == id).ForUpdate().FirstAsync(ct) @@ -48,6 +37,10 @@ public sealed class UserFundsService(IFreeSql orm, ICurrentUser currentUser) : I if (id == target.Id) target = locked; if (id == actor.Id) actor = locked; } + if (transfer && !UserAccessPolicy.CanManage(actor, target)) + throw new AppException("只能向管理范围内的账户转移积分", 403); + if (!transfer && !UserAccessPolicy.IsAdmin(actor)) + throw new AppException("充值和余额调整仅允许管理员操作", 403); var key = "user:" + request.RequestId.Trim(); // 持有目标行锁后检查请求号,相同账户的并发重试只能执行一次。 bool duplicate = points @@ -101,7 +94,7 @@ public sealed class UserFundsService(IFreeSql orm, ICurrentUser currentUser) : I 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, + OptionType = operation, OperatorType = UserAccessPolicy.IsAdmin(actor) ? "管理员" : "用户", 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 deleted file mode 100644 index 657835a..0000000 --- a/Modules/Store.Users/Services/UserInputChecks.cs +++ /dev/null @@ -1,90 +0,0 @@ -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, "查询关键字"); - Length(request.ParentUserName, 20, "父账号"); - 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 index 109973b..5984e13 100644 --- a/Modules/Store.Users/Services/UserManagementService.TopUp.cs +++ b/Modules/Store.Users/Services/UserManagementService.TopUp.cs @@ -1,3 +1,4 @@ +using Store.Users.Contracts.Enums; using MAX.Framework.Validation; using MAX.Framework.Commons; using Store.Users.Contracts.Dtos; @@ -10,13 +11,11 @@ public sealed partial class UserManagementService public async Task AddTopUpMessage(UserTopUpMessageRequest request, CancellationToken ct) { RequestValidator.Validate(request); - UserInputChecks.Length(request.Message, 1000, "充值备注"); - UserInputChecks.Length(request.UserName, 20, "用户名"); var actor = await Actor(orm, ct); var name = string.IsNullOrWhiteSpace(request.UserName) ? actor.UserName : request.UserName.Trim(); var target = await orm.Select().Where(u => u.UserName == name).FirstAsync(ct) ?? throw new AppException("用户不存在"); - if (!UserAccessPolicy.CanManage(actor, target) && actor.Id != target.Id) + if (!UserAccessPolicy.CanUseSelfService(actor, target)) throw new AppException("无权为该账户提交充值备注", 403); return await orm.Insert(new UserTopUpMessageEntity { @@ -28,8 +27,6 @@ public sealed partial class UserManagementService 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); var message = await orm.Select().Where(m => m.Id == request.Id).FirstAsync(ct) ?? throw new AppException("充值备注不存在"); @@ -47,14 +44,13 @@ public sealed partial class UserManagementService 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)) { if (actor.IsMainUser) select = select.Where(m => m.UserName == actor.UserName || orm.Select() - .Where(u => u.UserName == m.UserName && u.ParentUserName == actor.UserName).Any()); + .Where(u => u.UserName == m.UserName && u.ParentUserName == actor.UserName && (u.SaLevel == null || u.SaLevel != UserAdminLevel.SuperAdmin)).Any()); else select = select.Where(m => m.UserName == actor.UserName); } var total = await select.CountAsync(ct); @@ -66,7 +62,6 @@ public sealed partial class UserManagementService 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 @@ -79,7 +74,6 @@ public sealed partial class UserManagementService 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); diff --git a/Modules/Store.Users/Services/UserManagementService.cs b/Modules/Store.Users/Services/UserManagementService.cs index 4ea29bf..1b4eecf 100644 --- a/Modules/Store.Users/Services/UserManagementService.cs +++ b/Modules/Store.Users/Services/UserManagementService.cs @@ -1,3 +1,4 @@ +using Store.Users.Contracts.Enums; using MAX.Framework.Validation; using FreeSql; using MAX.Core.DependencyInjection; @@ -30,7 +31,6 @@ public sealed partial class UserManagementService(IFreeSql orm, ICurrentUser cur 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 (!UserAccessPolicy.CanRead(actor, target)) @@ -38,27 +38,12 @@ public sealed partial class UserManagementService(IFreeSql orm, ICurrentUser cur return await query.GetUser(target.UserName); } - /// 解析代操作目标,服务端校验管理员或主子账号归属,省略用户名时操作本人。 - public async Task GetManagedUserName(string? userName, CancellationToken ct) - { - var actor = await Actor(orm, ct); - var name = string.IsNullOrWhiteSpace(userName) ? actor.UserName : userName.Trim(); - UserInputChecks.Length(name, 20, "用户名"); - var target = await orm.Select().Where(u => u.UserName == name).FirstAsync(ct) - ?? throw new AppException("用户不存在"); - if (!UserAccessPolicy.CanManage(actor, target) && actor.Id != target.Id) - throw new AppException("无权操作该账户", 403); - return 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) && !actor.IsMainUser) throw new AppException("无权查询账户列表", 403); - if (subUsersOnly && !IsAdmin(actor) && !actor.IsMainUser) throw new AppException("子账号不能管理其他账户", 403); + if (!IsAdmin(actor) && !actor.IsMainUser) throw new AppException("子账号不能管理账户列表", 403); var select = orm.Select(); if (subUsersOnly) { @@ -75,11 +60,16 @@ public sealed partial class UserManagementService(IFreeSql orm, ICurrentUser cur if (!string.IsNullOrWhiteSpace(parentUserName) && !string.Equals(parentUserName, actor.UserName, StringComparison.OrdinalIgnoreCase)) throw new AppException("只能查询自己的子账号", 403); - select = select.Where(u => u.ParentUserName == actor.UserName); + select = select.Where(u => u.ParentUserName == actor.UserName && (u.SaLevel == null || u.SaLevel != UserAdminLevel.SuperAdmin)); } } else if (!IsAdmin(actor)) - select = select.Where(u => u.Id == actor.Id || u.ParentUserName == actor.UserName); + select = select.Where(u => u.Id == actor.Id || (u.ParentUserName == actor.UserName && (u.SaLevel == null || u.SaLevel != UserAdminLevel.SuperAdmin))); + if (!subUsersOnly && !string.IsNullOrWhiteSpace(request.ParentUserName)) + { + var parentName = request.ParentUserName.Trim(); + select = select.Where(u => u.ParentUserName == parentName); + } if (request.GroupId.HasValue) select = select.Where(u => u.GroupId == request.GroupId.Value); if (!string.IsNullOrWhiteSpace(request.Keyword)) { @@ -96,10 +86,6 @@ public sealed partial class UserManagementService(IFreeSql orm, ICurrentUser cur 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); - UserInputChecks.Length(request.ParentUserName, 20, "父账号"); using var work = orm.CreateUnitOfWork(); work.GetOrBeginTransaction(); @@ -124,9 +110,9 @@ public sealed partial class UserManagementService(IFreeSql orm, ICurrentUser cur if (await db.Select().Where(u => u.UserName == name).AnyAsync(ct)) throw new AppException("用户名已存在"); var user = new UserEntity { - UserName = name, ParentUserName = parentName, GroupId = subUser ? (byte)4 : (byte)3, + UserName = name, ParentUserName = parentName, GroupId = subUser ? UserGroup.CustomerService : UserGroup.MainAccount, NickName = name, Mobile = "", CompanyName = "", Notes = "", CardId = "", Authority = "", - ForbiddenPage = "", OemId = "", SaLevel = 0, ShopPointsPerMonth = 30, + ForbiddenPage = "", OemId = "", SaLevel = UserAdminLevel.Normal, 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 @@ -141,11 +127,10 @@ public sealed partial class UserManagementService(IFreeSql orm, ICurrentUser cur } /// 修改资料。登录名保持稳定,避免破坏主子账号、商铺和历史流水中的关联。 - public async Task Update(int id, UserEditRequest request, bool subUser, CancellationToken ct) + public async Task Update(UserUpdateRequest request, bool subUser, CancellationToken ct) { - UserInputChecks.PositiveId(id); RequestValidator.Validate(request); - UserInputChecks.Edit(request); + var id = request.Id; using var work = orm.CreateUnitOfWork(); work.GetOrBeginTransaction(); var db = work.Orm; @@ -173,14 +158,13 @@ public sealed partial class UserManagementService(IFreeSql orm, ICurrentUser cur /// 只有无余额、无积分、无子账号且无商铺关联的账户才能删除,保留历史流水。 public async Task Delete(int id, CancellationToken ct) { - UserInputChecks.PositiveId(id); + RequestValidator.Validate(new UserIdRequest { Id = 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 (!UserAccessPolicy.CanManage(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)) @@ -194,18 +178,15 @@ public sealed partial class UserManagementService(IFreeSql orm, ICurrentUser cur 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); var name = string.IsNullOrWhiteSpace(request.UserName) ? actor.UserName : request.UserName.Trim(); - UserInputChecks.Length(name, 20, "用户名"); var target = await orm.Select().Where(u => u.UserName == name).FirstAsync(ct) ?? throw new AppException("用户不存在"); - if (target.Id == actor.Id) + if (target.Id == actor.Id && !IsAdmin(actor)) { RequestValidator.Validate(new UserPasswordProofRequest { OldPassword = request.OldPassword! }); if (!UserPassword.Verify(target, request.OldPassword!)) throw new AppException("原密码错误"); @@ -223,7 +204,6 @@ public sealed partial class UserManagementService(IFreeSql orm, ICurrentUser cur 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); @@ -239,7 +219,6 @@ public sealed partial class UserManagementService(IFreeSql orm, ICurrentUser cur 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); @@ -259,9 +238,9 @@ public sealed partial class UserManagementService(IFreeSql orm, ICurrentUser cur 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"); + // 合法分组值已由 DTO 保证,这里检查主/子账号身份是否匹配。 + if (request.GroupId.HasValue && (subUser ? request.GroupId == UserGroup.MainAccount : request.GroupId != UserGroup.MainAccount)) + throw new AppException("主账号必须使用主号分组,子账号不能使用主号分组"); // 普通主账号只能授予自己已有的权限,不能通过创建子账号提升权限。 if (!IsAdmin(actor) && request.Authority != null) { diff --git a/Modules/Store.Users/Services/UserQueryService.cs b/Modules/Store.Users/Services/UserQueryService.cs index 6a4701b..da1f219 100644 --- a/Modules/Store.Users/Services/UserQueryService.cs +++ b/Modules/Store.Users/Services/UserQueryService.cs @@ -13,7 +13,6 @@ internal sealed class UserQueryService(IUserRepository repository) : IUserQueryS 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); } @@ -27,7 +26,6 @@ internal sealed class UserAuthenticationService(IFreeSql orm, IUserQueryService 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);