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);