From d6471f97d3dc659e0843a29e8396b534cff3a7fd Mon Sep 17 00:00:00 2001 From: chfei Date: Fri, 23 Oct 2026 18:16:05 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4=E7=AE=A1=E7=90=86=E5=91=98?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E4=B8=80=E5=88=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../JwtServiceCollectionExtensions.cs | 24 +++ .../Dtos/UserManagementRequests.cs | 17 +- .../Dtos/UserTopUpRequests.cs | 2 + .../Store.Users/Commands/ShopRenewCommand.cs | 13 +- .../Store.Users/Controllers/UserController.cs | 49 +++-- Modules/Store.Users/Dtos/ShopRenewRequest.cs | 2 + Modules/Store.Users/README.md | 30 ++- .../Store.Users/Services/UserAccessPolicy.cs | 18 ++ .../Store.Users/Services/UserFundsService.cs | 2 + .../Store.Users/Services/UserInputChecks.cs | 1 + .../Services/UserManagementService.TopUp.cs | 33 ++- .../Services/UserManagementService.cs | 90 +++++++-- Store.API/Controllers/AuthController.cs | 4 + Store.API/Program.cs | 13 ++ Store.API/appsettings.Development.json | 7 + .../DevelopmentTokenTests.cs | 44 ++++ Tests/Store.Users.Tests/README.md | 2 + Tests/Store.Users.Tests/UserAccessTests.cs | 189 ++++++++++++++++++ Tests/Store.Users.Tests/UserFundsTests.cs | 14 +- Tests/Store.Users.Tests/UserHttpTests.cs | 48 +++++ .../Store.Users.Tests/UserManagementTests.cs | 47 ++++- Tests/Store.Users.Tests/UserTopUpTests.cs | 5 +- Tests/UserManagementSmoke/Program.cs | 19 +- 23 files changed, 600 insertions(+), 73 deletions(-) create mode 100644 Modules/Store.Users/Services/UserAccessPolicy.cs create mode 100644 Tests/Store.Users.Tests/DevelopmentTokenTests.cs create mode 100644 Tests/Store.Users.Tests/UserAccessTests.cs diff --git a/Core/MAX.Framework/DependencyInjection/JwtServiceCollectionExtensions.cs b/Core/MAX.Framework/DependencyInjection/JwtServiceCollectionExtensions.cs index 8126967..ae4636a 100644 --- a/Core/MAX.Framework/DependencyInjection/JwtServiceCollectionExtensions.cs +++ b/Core/MAX.Framework/DependencyInjection/JwtServiceCollectionExtensions.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi; using System.Security.Claims; @@ -63,6 +64,29 @@ public static class JwtServiceCollectionExtensions { OnChallenge = JwtBearerResponseHandler.OnChallenge, OnForbidden = JwtBearerResponseHandler.OnForbidden, + OnMessageReceived = context => + { + // 固定测试凭据只在 Development 环境生效,不依赖 JWT 过期时间或在线会话。 + var environment = context.HttpContext.RequestServices.GetRequiredService(); + var development = configuration.GetSection("Jwt:DevelopmentToken"); + var token = development["Token"]; + var userName = development["UserName"]; + var authorization = context.Request.Headers.Authorization.ToString(); + if (environment.IsDevelopment() + && development.GetValue("Enabled") + && !string.IsNullOrWhiteSpace(token) + && !string.IsNullOrWhiteSpace(userName) + && authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) + && string.Equals(authorization[7..].Trim(), token, StringComparison.Ordinal)) + { + context.Principal = new ClaimsPrincipal(new ClaimsIdentity( + [new Claim(JwtClaimTypes.UserName, userName), + new Claim(JwtClaimTypes.NickName, "开发测试用户")], + JwtBearerDefaults.AuthenticationScheme)); + context.Success(); + } + return Task.CompletedTask; + }, // 单点登录校验:会话必须仍有效(未退出、未被新登录顶替) OnTokenValidated = context => { diff --git a/Modules/Store.Users.Contracts/Dtos/UserManagementRequests.cs b/Modules/Store.Users.Contracts/Dtos/UserManagementRequests.cs index 7efcf83..9b85997 100644 --- a/Modules/Store.Users.Contracts/Dtos/UserManagementRequests.cs +++ b/Modules/Store.Users.Contracts/Dtos/UserManagementRequests.cs @@ -10,7 +10,7 @@ public class UserEditRequest public string? Notes { get; set; } public string? CardId { get; set; } public byte? GroupId { get; set; } - /// 仅管理员可设置;子账号不能成为管理员。 + /// 仅管理员可设置;普通账号不能通过资料修改提升管理员级别。 public short? SaLevel { get; set; } public string? Authority { get; set; } public string? ForbiddenPage { get; set; } @@ -31,17 +31,26 @@ public sealed class UserUpdateRequest : UserEditRequest /// 创建主账号或当前主账号的直属子账号。 public sealed class UserCreateRequest : UserEditRequest { + /// 创建子账号时的父账号;管理员可指定任意主账号,普通主账号只能指定自己。 + public string? ParentUserName { get; set; } [Required(ErrorMessage = "用户名不能为空")] public string UserName { get; set; } = ""; [Required(ErrorMessage = "密码不能为空")] public string Password { get; set; } = ""; } -/// 修改本人密码,必须校验旧密码。 +/// 本人改密需原密码;管理员及主账号可为管理范围内的其他账户重置密码。 public sealed class ChangePasswordRequest { - [Required(ErrorMessage = "原密码不能为空")] public string OldPassword { get; set; } = ""; + public string? UserName { get; set; } + public string? OldPassword { get; set; } [Required(ErrorMessage = "新密码不能为空")] public string NewPassword { get; set; } = ""; } +/// 本人修改密码时验证原密码非空。 +public sealed class UserPasswordProofRequest +{ + [Required(ErrorMessage = "原密码不能为空")] public string OldPassword { get; set; } = ""; +} + /// 分页参数;页码、分页大小和过滤值由服务检查。 public class UserSearchRequest { @@ -49,6 +58,8 @@ public class UserSearchRequest public int PageSize { get; set; } = 50; public string? Keyword { get; set; } public byte? GroupId { get; set; } + /// 查询子账号时可按父账号筛选;管理员可指定任意账号,普通主账号只能指定自己。 + public string? ParentUserName { get; set; } } public sealed record UserPage(long Total, IReadOnlyList Items); diff --git a/Modules/Store.Users.Contracts/Dtos/UserTopUpRequests.cs b/Modules/Store.Users.Contracts/Dtos/UserTopUpRequests.cs index 5158332..6341b04 100644 --- a/Modules/Store.Users.Contracts/Dtos/UserTopUpRequests.cs +++ b/Modules/Store.Users.Contracts/Dtos/UserTopUpRequests.cs @@ -4,6 +4,8 @@ namespace Store.Users.Contracts.Dtos; /// 用户提交的充值备注。 public sealed class UserTopUpMessageRequest { + /// 目标账户;省略时为本人,指定时检查管理权限。 + public string? UserName { get; set; } [Required(ErrorMessage = "充值备注不能为空")] public string Message { get; set; } = ""; } diff --git a/Modules/Store.Users/Commands/ShopRenewCommand.cs b/Modules/Store.Users/Commands/ShopRenewCommand.cs index a0988f6..311ab0d 100644 --- a/Modules/Store.Users/Commands/ShopRenewCommand.cs +++ b/Modules/Store.Users/Commands/ShopRenewCommand.cs @@ -18,7 +18,7 @@ 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) +public sealed record ShopRenewCommand(string UserName, string ShopId, int RenewDuration, string? Remark = null, string? OperatorName = null) : IRequest, IValidatableRequest, IAccountConsumptionRequest, IShopOwnershipRequest { // 由消费管道建立,仅在当前事务执行期间有效。 @@ -29,6 +29,7 @@ public sealed record ShopRenewCommand(string UserName, string ShopId, int RenewD { 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); @@ -58,6 +59,12 @@ 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 shop = ((IShopOwnershipRequest)request).OwnedShop ?? throw new InvalidOperationException("续费命令必须通过商铺归属管道执行"); decimal pointsPerMonth = context.Quote.UnitPrice; @@ -87,8 +94,8 @@ internal sealed class ShopRenewCommandHandler(UnitOfWorkExecutionContext executi var record = new UserPointsRecordEntity { OptionType = "续费", - OperatorType = "用户", - OperatorName = userName, + OperatorType = UserAccessPolicy.IsAdmin(actor) ? "管理员" : "用户", + OperatorName = actor.UserName, UserName = userName, UserNick = user.NickName, DeltaPoints = pointsPerMonth, diff --git a/Modules/Store.Users/Controllers/UserController.cs b/Modules/Store.Users/Controllers/UserController.cs index 2f77b6b..a747f42 100644 --- a/Modules/Store.Users/Controllers/UserController.cs +++ b/Modules/Store.Users/Controllers/UserController.cs @@ -1,6 +1,7 @@ using MAX.Framework.Security; using MediatR; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Store.Users.Commands; using Store.Users.Contracts.Dtos; @@ -20,55 +21,67 @@ namespace Store.Users.Controllers; public class UserController(UserManagementService users, UserFundsService funds, IMediator mediator, ICurrentUser currentUser) : ControllerBase { /// 获取本人或授权范围内的用户信息,响应不包含密码。 + [EndpointSummary("获取用户信息")] public Task GetUser(string? userName, CancellationToken cancellationToken) { return users.GetUser(userName, cancellationToken); } - /// 管理员分页查询所有用户。 + /// 管理员查询全部用户;普通主账号仅查询本人及直属子账号。 + [EndpointSummary("分页查询所有用户")] public Task> GetAllUser(UserSearchRequest request, CancellationToken cancellationToken) { return users.Search(request, false, cancellationToken); } - /// 分页查询当前主账号的直属子账号。 + /// 管理员可查询所有账号的子账号,普通主账号只能查询自己的直属子账号。 + /// 管理员可通过 ParentUserName 筛选指定父账号;普通主账号仅可指定自己。 + /// 请求取消标记。 + [EndpointSummary("分页查询子账号")] + [EndpointDescription("管理员不传 ParentUserName 时查询全部子账号,传入时按指定父账号筛选;普通主账号只能查询自己的子账号。")] public Task> GetSubUser(UserSearchRequest request, CancellationToken cancellationToken) { return users.Search(request, true, cancellationToken); } /// 管理员创建主账号。 + [EndpointSummary("创建主账号")] public Task AddUser(UserCreateRequest request, CancellationToken cancellationToken) { return users.Create(request, false, cancellationToken); } - /// 主账号创建直属子账号。 + /// 管理员可为指定主账号创建子账号,普通主账号仅可为自己创建子账号。 + [EndpointSummary("创建直属子账号")] public Task AddSubUser(UserCreateRequest request, CancellationToken cancellationToken) { return users.Create(request, true, cancellationToken); } - /// 管理员修改账户资料,用户名不可变更。 + /// 管理员修改任意账户;普通主账号仅修改本人及直属子账号,用户名不可变更。 + [EndpointSummary("修改用户资料")] public Task UpdateUser(UserUpdateRequest request, CancellationToken cancellationToken) { return users.Update(request.Id, request, false, cancellationToken); } - /// 主账号修改直属子账号资料。 + /// 管理员修改任意子账号,普通主账号仅修改直属子账号。 + [EndpointSummary("修改直属子账号资料")] public Task UpdateSubUser(UserUpdateRequest request, CancellationToken cancellationToken) { return users.Update(request.Id, request, true, cancellationToken); } /// 删除无资金和业务关联的账户。 + [EndpointSummary("删除用户")] public async Task DelUser(int id, CancellationToken cancellationToken) { await users.Delete(id, cancellationToken); return ApiResult.Ok(); } - /// 校验旧密码并修改本人密码,成功后需重新登录。 + /// 本人改密校验原密码;管理员或主账号可为管理范围内的其他账户重置密码。 + [EndpointSummary("修改或重置用户密码")] public async Task ChangePassword(ChangePasswordRequest request, CancellationToken cancellationToken) { await users.ChangePassword(request, cancellationToken); @@ -76,49 +89,57 @@ public class UserController(UserManagementService users, UserFundsService funds, } /// 查询授权账户的积分流水。 + [EndpointSummary("查询用户积分流水")] public Task> GetUserPoints(UserRecordSearchRequest request, CancellationToken cancellationToken) { return users.Points(request.UserName, request, cancellationToken); } /// 查询授权账户的余额流水。 + [EndpointSummary("查询用户余额流水")] public Task> GetUserBalance(UserRecordSearchRequest request, CancellationToken cancellationToken) { return users.Balance(request.UserName, request, cancellationToken); } /// 管理员充值积分,或当前用户向其他账户转移自己的积分。 + [EndpointSummary("充值或转移用户积分")] public Task UpdateUserPoints(UserFundsRequest request, CancellationToken cancellationToken) { return funds.Change(request, true, cancellationToken); } /// 管理员充值或扣减余额,自动记录审计流水。 + [EndpointSummary("充值或扣减用户余额")] public Task UpdateUserBalance(UserFundsRequest request, CancellationToken cancellationToken) { return funds.Change(request, false, cancellationToken); } - /// 提交本人的充值备注。 + /// 提交本人或管理范围内账户的充值备注。 + [EndpointSummary("提交充值备注")] public Task AddUserTopUpMsg(UserTopUpMessageRequest request, CancellationToken cancellationToken) { return users.AddTopUpMessage(request, cancellationToken); } - /// 管理员填写充值备注的处理结果。 + /// 管理员可处理所有充值备注,普通主账号仅处理本人及直属子账号备注,不自动充值。 + [EndpointSummary("处理充值备注")] public async Task UpdateUserTopUpMsg(UserTopUpResultRequest request, CancellationToken cancellationToken) { await users.UpdateTopUpMessage(request, cancellationToken); return ApiResult.Ok(); } - /// 分页查询充值备注。 + /// 按管理员、主账号或子账号的授权范围分页查询充值备注。 + [EndpointSummary("分页查询充值备注")] public Task> GetUserTopUpMsg(UserSearchRequest request, CancellationToken cancellationToken) { return users.TopUpMessages(request, cancellationToken); } /// 管理员维护代理充值二维码。 + [EndpointSummary("维护代理充值二维码")] public async Task UpdateTopUpQrcode(UserTopUpQrcodeRequest request, CancellationToken cancellationToken) { await users.UpdateTopUpQrcode(request, cancellationToken); @@ -126,18 +147,22 @@ public class UserController(UserManagementService users, UserFundsService funds, } /// 获取代理充值二维码。 + [EndpointSummary("获取代理充值二维码")] public Task GetTopUpQrcode(string agentCode, CancellationToken cancellationToken) { return users.GetTopUpQrcode(agentCode, cancellationToken); } - /// 使用当前登录用户的积分为商铺续费。 - public Task ShopRenew(ShopRenewRequest request, CancellationToken cancellationToken) + /// 管理员或主账号为授权账户的商铺续费,扣除店铺所属账户积分并记录实际操作者。 + [EndpointSummary("使用积分为商铺续费")] + public async Task ShopRenew(ShopRenewRequest request, CancellationToken cancellationToken) { if (!currentUser.IsAuthenticated || string.IsNullOrWhiteSpace(currentUser.UserName)) { throw new UnauthorizedAccessException(); } - return mediator.Send(new ShopRenewCommand(currentUser.UserName, request.ShopId, request.RenewDuration, request.Remark), cancellationToken); + var targetName = await users.GetManagedUserName(request.UserName, cancellationToken); + return await mediator.Send(new ShopRenewCommand(targetName, request.ShopId, request.RenewDuration, request.Remark, + currentUser.UserName), cancellationToken); } } diff --git a/Modules/Store.Users/Dtos/ShopRenewRequest.cs b/Modules/Store.Users/Dtos/ShopRenewRequest.cs index 2cfd990..28be91d 100644 --- a/Modules/Store.Users/Dtos/ShopRenewRequest.cs +++ b/Modules/Store.Users/Dtos/ShopRenewRequest.cs @@ -4,6 +4,8 @@ namespace Store.Users.Dtos; /// 续费请求;仅声明店铺ID非空,时长和长度由服务或命令处理。 public class ShopRenewRequest { + /// 续费账户;管理员可指定任意账户,普通主账号仅限本人及直属子账号。 + public string? UserName { get; set; } [Required(ErrorMessage = "店铺ID不能为空")] public string ShopId { get; set; } = ""; /// 续费时长,单位月,合法范围1-120。 public int RenewDuration { get; set; } diff --git a/Modules/Store.Users/README.md b/Modules/Store.Users/README.md index 16e4d9b..fc6c2aa 100644 --- a/Modules/Store.Users/README.md +++ b/Modules/Store.Users/README.md @@ -22,21 +22,21 @@ HTTP 方法约定:控制器通过 [DefaultHttpPost] 启用默认 POST。所有 | 方法 | 路径 | 访问与行为 | | --- | --- | --- | | POST | /user/GetUser?userName=... | 本人、直属主账号或管理员;省略用户名读取本人 | -| POST | /user/GetAllUser | 管理员分页查询,支持 Keyword、GroupId | -| POST | /user/GetSubUser | 当前主账号直属子账号分页查询 | +| POST | /user/GetAllUser | 管理员查询全部;普通主账号查询本人及直属子账号 | +| POST | /user/GetSubUser | 管理员查询所有子账号,可用 ParentUserName 按父账号筛选;普通主账号仅能查询自己的子账号 | | POST | /user/AddUser | 管理员新增主账号 | | POST | /user/AddSubUser | 当前主账号新增直属子账号 | -| POST | /user/UpdateUser | 管理员修改资料 | -| POST | /user/UpdateSubUser | 主账号修改直属子账号 | +| POST | /user/UpdateUser | 管理员修改任意账户;普通主账号修改本人及直属子账号 | +| POST | /user/UpdateSubUser | 管理员修改任意子账号;普通主账号修改直属子账号 | | POST | /user/DelUser?id=... | 管理员或直属主账号删除空账户 | -| POST | /user/ChangePassword | 本人校验旧密码后修改,会话立即失效 | +| POST | /user/ChangePassword | 本人改密校验原密码;管理员/主账号可重置授权账户密码 | | POST | /user/GetUserPoints | 授权账户积分流水,支持起止时间、操作类型、店铺过滤 | | POST | /user/GetUserBalance | 授权账户余额流水,支持起止时间、支付类型、订单过滤 | -| POST | /user/UpdateUserPoints | 管理员充值;本人向其他账户转移自己的积分 | +| POST | /user/UpdateUserPoints | 管理员充值;本人向管理范围内账户转移自己的积分 | | POST | /user/UpdateUserBalance | 管理员充值或消费调整 | -| POST | /user/AddUserTopUpMsg | 本人提交充值备注 | -| POST | /user/UpdateUserTopUpMsg | 管理员填写处理结果,不自动充值 | -| POST | /user/GetUserTopUpMsg | 本人备注或管理员查看全部 | +| POST | /user/AddUserTopUpMsg | 本人或授权账户提交充值备注 | +| POST | /user/UpdateUserTopUpMsg | 管理员或主账号处理授权账户的备注,不自动充值 | +| POST | /user/GetUserTopUpMsg | 管理员查看全部;主账号查看本人及直属子账号备注 | | POST | /user/UpdateTopUpQrcode | 管理员维护代理二维码 | | POST | /user/GetTopUpQrcode?agentCode=... | 登录用户读取代理二维码 | | POST | /user/ShopRenew | 保留原有 MediatR 事务续费用例 | @@ -85,3 +85,15 @@ RequestValidator.Validate(request); 权限、账户归属、数据库记录是否存在、余额是否足够、重复请求等业务规则继续在服务或业务管道中判断。不合法的参数抛出 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。账户删除仍禁止删除当前登录账户,且要求无积分、无余额、无子账号和无店铺关联。 + +管理员角色账户即使存在父账号关系,也不能由普通主账号修改或重置密码,避免通过子账号关系获得管理员身份。 diff --git a/Modules/Store.Users/Services/UserAccessPolicy.cs b/Modules/Store.Users/Services/UserAccessPolicy.cs new file mode 100644 index 0000000..bf8041b --- /dev/null +++ b/Modules/Store.Users/Services/UserAccessPolicy.cs @@ -0,0 +1,18 @@ +using Store.Users.Entities; + +namespace Store.Users.Services; + +/// 统一账户管理范围:管理员管理全部,主账号管理本人及直属子账号。 +internal static class UserAccessPolicy +{ + public static bool IsAdmin(UserEntity actor) => actor.SaLevel == 1; + + 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); + + 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 8ea40ad..4365b74 100644 --- a/Modules/Store.Users/Services/UserFundsService.cs +++ b/Modules/Store.Users/Services/UserFundsService.cs @@ -37,6 +37,8 @@ public sealed class UserFundsService(IFreeSql orm, ICurrentUser currentUser) : I 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 }; foreach (var id in ids) diff --git a/Modules/Store.Users/Services/UserInputChecks.cs b/Modules/Store.Users/Services/UserInputChecks.cs index 98ac26e..657835a 100644 --- a/Modules/Store.Users/Services/UserInputChecks.cs +++ b/Modules/Store.Users/Services/UserInputChecks.cs @@ -49,6 +49,7 @@ internal static class UserInputChecks 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); } diff --git a/Modules/Store.Users/Services/UserManagementService.TopUp.cs b/Modules/Store.Users/Services/UserManagementService.TopUp.cs index 36c21aa..109973b 100644 --- a/Modules/Store.Users/Services/UserManagementService.TopUp.cs +++ b/Modules/Store.Users/Services/UserManagementService.TopUp.cs @@ -6,40 +6,57 @@ namespace Store.Users.Services; public sealed partial class UserManagementService { - /// 以本人身份提交充值备注,不能伪造提交账户或昵称。 + /// 为本人或授权账户提交充值备注,昵称从目标账户读取。 public async Task AddTopUpMessage(UserTopUpMessageRequest request, CancellationToken ct) { RequestValidator.Validate(request); UserInputChecks.Length(request.Message, 1000, "充值备注"); + 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) + throw new AppException("无权为该账户提交充值备注", 403); return await orm.Insert(new UserTopUpMessageEntity { - UserName = actor.UserName, NickName = actor.NickName, Message = request.Message.Trim() + UserName = target.UserName, NickName = target.NickName, Message = request.Message.Trim() }).ExecuteIdentityAsync(ct); } - /// 管理员记录人工处理结果。备注处理与实际充值是不同用例。 + /// 管理范围内记录人工处理结果。备注处理不会自动增加积分或余额。 public async Task UpdateTopUpMessage(UserTopUpResultRequest request, CancellationToken ct) { RequestValidator.Validate(request); UserInputChecks.PositiveId(request.Id); UserInputChecks.Length(request.Result, 1000, "处理结果"); var actor = await Actor(orm, ct); - if (!IsAdmin(actor)) throw new AppException("仅管理员可以处理充值备注", 403); - if (!await orm.Select().Where(m => m.Id == request.Id).AnyAsync(ct)) - throw new AppException("充值备注不存在"); + var message = await orm.Select().Where(m => m.Id == request.Id).FirstAsync(ct) + ?? throw new AppException("充值备注不存在"); + if (!IsAdmin(actor)) + { + var target = await orm.Select().Where(u => u.UserName == message.UserName).FirstAsync(ct); + if (target == null || !UserAccessPolicy.CanManage(actor, target)) + throw new AppException("无权处理该账户的充值备注", 403); + } await orm.Update().Set(m => m.Result, request.Result.Trim()) .Where(m => m.Id == request.Id).ExecuteAffrowsAsync(ct); } - /// 普通用户仅查看自己提交的备注,管理员查看全部。 + /// 管理员查看全部,主账号查看本人和直属子账号,子账号仅查看本人备注。 public async Task> TopUpMessages(UserSearchRequest request, CancellationToken ct) { RequestValidator.Validate(request); UserInputChecks.Search(request); var actor = await Actor(orm, ct); var select = orm.Select(); - if (!IsAdmin(actor)) select = select.Where(m => m.UserName == actor.UserName); + 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()); + else select = select.Where(m => m.UserName == actor.UserName); + } var total = await select.CountAsync(ct); var rows = await select.OrderByDescending(m => m.Id).Page(request.Page, request.PageSize).ToListAsync(ct); return new(total, rows); diff --git a/Modules/Store.Users/Services/UserManagementService.cs b/Modules/Store.Users/Services/UserManagementService.cs index 1ac2d7e..4ea29bf 100644 --- a/Modules/Store.Users/Services/UserManagementService.cs +++ b/Modules/Store.Users/Services/UserManagementService.cs @@ -23,10 +23,7 @@ public sealed partial class UserManagementService(IFreeSql orm, ICurrentUser cur ?? throw new UnauthorizedAccessException("登录账户不存在"); } - private static bool IsAdmin(UserEntity actor) => actor.SaLevel == 1; - private static bool Owns(UserEntity actor, UserEntity target) => - actor.IsMainUser && string.Equals(target.ParentUserName, actor.UserName, StringComparison.OrdinalIgnoreCase); - + private static bool IsAdmin(UserEntity actor) => UserAccessPolicy.IsAdmin(actor); /// 仅本人、直属主账号或超级管理员可以读取账户详情。 public async Task GetUser(string? userName, CancellationToken ct) { @@ -36,21 +33,53 @@ public sealed partial class UserManagementService(IFreeSql orm, ICurrentUser cur UserInputChecks.Length(name, 20, "用户名"); var target = await orm.Select().Where(u => u.UserName == name).FirstAsync(ct) ?? throw new AppException("用户不存在"); - if (!IsAdmin(actor) && actor.Id != target.Id && !Owns(actor, target)) + if (!UserAccessPolicy.CanRead(actor, target)) throw new AppException("无权访问该账户", 403); 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)) throw new AppException("仅管理员可以查询全部账户", 403); - if (subUsersOnly && !actor.IsMainUser) throw new AppException("子账号不能管理其他账户", 403); + if (!subUsersOnly && !IsAdmin(actor) && !actor.IsMainUser) throw new AppException("无权查询账户列表", 403); + if (subUsersOnly && !IsAdmin(actor) && !actor.IsMainUser) throw new AppException("子账号不能管理其他账户", 403); var select = orm.Select(); - if (subUsersOnly) select = select.Where(u => u.ParentUserName == actor.UserName); + if (subUsersOnly) + { + var parentUserName = request.ParentUserName?.Trim(); + if (IsAdmin(actor)) + { + // 管理员省略父账号时查询所有子账号,但不混入主账号。 + select = select.Where(u => u.ParentUserName != null && u.ParentUserName.Trim() != ""); + if (!string.IsNullOrWhiteSpace(parentUserName)) + select = select.Where(u => u.ParentUserName == parentUserName); + } + else + { + if (!string.IsNullOrWhiteSpace(parentUserName) + && !string.Equals(parentUserName, actor.UserName, StringComparison.OrdinalIgnoreCase)) + throw new AppException("只能查询自己的子账号", 403); + select = select.Where(u => u.ParentUserName == actor.UserName); + } + } + else if (!IsAdmin(actor)) + select = select.Where(u => u.Id == actor.Id || u.ParentUserName == actor.UserName); if (request.GroupId.HasValue) select = select.Where(u => u.GroupId == request.GroupId.Value); if (!string.IsNullOrWhiteSpace(request.Keyword)) { @@ -70,6 +99,7 @@ public sealed partial class UserManagementService(IFreeSql orm, ICurrentUser cur 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(); @@ -78,12 +108,23 @@ public sealed partial class UserManagementService(IFreeSql orm, ICurrentUser cur // 锁住主账号,使其创建子账号和删除操作串行化。 actor = await db.Select().Where(u => u.Id == actor.Id).ForUpdate().FirstAsync(ct) ?? throw new AppException("操作者账户不存在"); - if (subUser ? !actor.IsMainUser : !IsAdmin(actor)) throw new AppException("无权创建该账户", 403); + if (subUser ? !IsAdmin(actor) && !actor.IsMainUser : !IsAdmin(actor)) throw new AppException("无权创建该账户", 403); + var parentName = ""; + if (subUser) + { + parentName = string.IsNullOrWhiteSpace(request.ParentUserName) ? actor.UserName : request.ParentUserName.Trim(); + if (!IsAdmin(actor) && !string.Equals(parentName, actor.UserName, StringComparison.OrdinalIgnoreCase)) + throw new AppException("只能在自己的账户下创建子账号", 403); + var parent = await db.Select().Where(u => u.UserName == parentName).ForUpdate().FirstAsync(ct) + ?? throw new AppException("父账号不存在"); + if (!parent.IsMainUser) throw new AppException("父账号必须是主账号"); + parentName = parent.UserName; + } var name = request.UserName.Trim(); if (await db.Select().Where(u => u.UserName == name).AnyAsync(ct)) throw new AppException("用户名已存在"); var user = new UserEntity { - UserName = name, ParentUserName = subUser ? actor.UserName : "", GroupId = subUser ? (byte)4 : (byte)3, + UserName = name, ParentUserName = parentName, GroupId = subUser ? (byte)4 : (byte)3, NickName = name, Mobile = "", CompanyName = "", Notes = "", CardId = "", Authority = "", ForbiddenPage = "", OemId = "", SaLevel = 0, ShopPointsPerMonth = 30, PostagePerOrder = 2.2m, RefundPostagePerOrder = 2.5m, GiftOemAmountPerOrder = 0, @@ -111,7 +152,8 @@ public sealed partial class UserManagementService(IFreeSql orm, ICurrentUser cur var actor = await Actor(db, ct); var target = await db.Select().Where(u => u.Id == id).ForUpdate().FirstAsync(ct) ?? throw new AppException("用户不存在"); - if (subUser ? !Owns(actor, target) : !IsAdmin(actor)) throw new AppException("无权修改该账户", 403); + if (!UserAccessPolicy.CanManage(actor, target)) throw new AppException("无权修改该账户", 403); + if (subUser && target.IsMainUser) throw new AppException("目标账户不是子账号"); Apply(target, request, actor, !target.IsMainUser); await CheckOem(db, target, ct); target.MdyTime = DateTime.Now; @@ -139,7 +181,7 @@ public sealed partial class UserManagementService(IFreeSql orm, ICurrentUser cur var target = await db.Select().Where(u => u.Id == id).ForUpdate().FirstAsync(ct) ?? throw new AppException("用户不存在"); if (actor.Id == target.Id) throw new AppException("不能删除当前登录账户"); - if (!IsAdmin(actor) && !Owns(actor, target)) throw new AppException("无权删除该账户", 403); + if (!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)) throw new AppException("请先处理该账户的子账号"); @@ -152,19 +194,29 @@ 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); - if (!UserPassword.Verify(actor, request.OldPassword)) throw new AppException("原密码错误"); - var hash = UserPassword.Hash(actor, request.NewPassword); + 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) + { + RequestValidator.Validate(new UserPasswordProofRequest { OldPassword = request.OldPassword! }); + if (!UserPassword.Verify(target, request.OldPassword!)) throw new AppException("原密码错误"); + } + else if (!UserAccessPolicy.CanManage(actor, target)) + throw new AppException("无权修改该账户密码", 403); + var hash = UserPassword.Hash(target, request.NewPassword); if (await orm.Update().Set(u => u.Password, hash).Set(u => u.MdyTime, DateTime.Now) - .Where(u => u.Id == actor.Id && u.Password == actor.Password).ExecuteAffrowsAsync(ct) != 1) + .Where(u => u.Id == target.Id && u.Password == target.Password).ExecuteAffrowsAsync(ct) != 1) throw new AppException("密码已发生变化,请重新登录"); - onlineUsers.SignOut(actor.UserName); + onlineUsers.SignOut(target.UserName); } /// 按账户归属查询积分流水;分页与账户详情使用相同的访问范围。 @@ -222,7 +274,7 @@ public sealed partial class UserManagementService(IFreeSql orm, ICurrentUser cur throw new AppException("仅管理员可以修改计费配置", 403); if (request.SaLevel.HasValue) { - if (!IsAdmin(actor) || (subUser && request.SaLevel != 0)) throw new AppException("无权设置管理员级别", 403); + if (!IsAdmin(actor)) throw new AppException("无权设置管理员级别", 403); user.SaLevel = request.SaLevel; } user.NickName = request.NickName?.Trim() ?? user.NickName; diff --git a/Store.API/Controllers/AuthController.cs b/Store.API/Controllers/AuthController.cs index 309d2e4..5541079 100644 --- a/Store.API/Controllers/AuthController.cs +++ b/Store.API/Controllers/AuthController.cs @@ -32,6 +32,7 @@ namespace Store.Api.Controllers /// 登录:校验用户名/密码,签发 JWT(单点登录:同用户的新登录会使旧令牌失效) /// [HttpPost] + [EndpointSummary("用户登录")] [AllowAnonymous] public async Task Login([FromBody] LoginRequest request) { @@ -58,6 +59,7 @@ namespace Store.Api.Controllers /// 退出登录:销毁当前用户的服务端在线会话,令牌立即失效 /// [HttpPost] + [EndpointSummary("退出登录")] [Authorize] public ApiResult Logout() { @@ -73,6 +75,7 @@ namespace Store.Api.Controllers /// 获取当前登录用户信息(需携带 Bearer Token) /// [HttpGet] + [EndpointSummary("获取当前登录用户信息")] [Authorize] public ApiResult Me() { @@ -87,6 +90,7 @@ namespace Store.Api.Controllers /// 在线人员列表(需携带 Bearer Token) /// [HttpGet] + [EndpointSummary("获取在线人员列表")] [Authorize] public ApiResult OnlineUsers() { diff --git a/Store.API/Program.cs b/Store.API/Program.cs index dab87eb..3fc5f46 100644 --- a/Store.API/Program.cs +++ b/Store.API/Program.cs @@ -1,4 +1,5 @@ using Asp.Versioning; +using Asp.Versioning.OpenApi; using MAX.Framework.Conventions; using MAX.Framework.DependencyInjection; using Scalar.AspNetCore; @@ -39,6 +40,18 @@ public partial class Program options.GroupNameFormat = "'v'VVV"; options.SubstituteApiVersionInUrl = true; }).AddOpenApi(); + builder.Services.Configure(options => + { + options.Document.AddDocumentTransformer((document, _, _) => + { + // 接口路径以 / 开头,服务器地址不保留尾部斜杠,兼容导入工具直接拼接 URL。 + foreach (var server in document.Servers ?? []) + { + server.Url = server.Url?.TrimEnd('/'); + } + return Task.CompletedTask; + }); + }); WebApplication app = builder.Build(); diff --git a/Store.API/appsettings.Development.json b/Store.API/appsettings.Development.json index 0c208ae..3d85b74 100644 --- a/Store.API/appsettings.Development.json +++ b/Store.API/appsettings.Development.json @@ -1,4 +1,11 @@ { + "Jwt": { + "DevelopmentToken": { + "Enabled": true, + "Token": "store-local-dev-7f2c9e4a6b814d38a5910e62c4f837bd", + "UserName": "18990991234" + } + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/Tests/Store.Users.Tests/DevelopmentTokenTests.cs b/Tests/Store.Users.Tests/DevelopmentTokenTests.cs new file mode 100644 index 0000000..5fdc5b2 --- /dev/null +++ b/Tests/Store.Users.Tests/DevelopmentTokenTests.cs @@ -0,0 +1,44 @@ +using MAX.Framework.DependencyInjection; +using MAX.Framework.Security; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Store.Users.Tests; + +public sealed class DevelopmentTokenTests +{ + [Theory] + [InlineData("Development", true, "test-fixed-token", "admin")] + [InlineData("Development", true, "incorrect-token", "")] + [InlineData("Development", false, "test-fixed-token", "")] + [InlineData("Production", true, "test-fixed-token", "")] + public async Task FixedToken_OnlyAuthenticatesWhenEnabledInDevelopment( + string environment, bool enabled, string token, string expectedUser) + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = environment }); + builder.WebHost.UseTestServer(); + builder.Logging.ClearProviders(); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["Jwt:DevelopmentToken:Enabled"] = enabled.ToString(), + ["Jwt:DevelopmentToken:Token"] = "test-fixed-token", + ["Jwt:DevelopmentToken:UserName"] = "admin" + }); + builder.Services.AddStoreHubJwt(builder.Configuration); + await using var app = builder.Build(); + app.UseAuthentication(); + app.MapGet("/identity", (ICurrentUser user) => + user.IsAuthenticated ? user.UserName : ""); + await app.StartAsync(); + using var client = app.GetTestClient(); + client.DefaultRequestHeaders.Authorization = new("Bearer", token); + + Assert.Equal(expectedUser, await client.GetStringAsync("/identity")); + // 同一凭据不依赖首次请求创建的会话。 + Assert.Equal(expectedUser, await client.GetStringAsync("/identity")); + } +} diff --git a/Tests/Store.Users.Tests/README.md b/Tests/Store.Users.Tests/README.md index e717445..31bce7e 100644 --- a/Tests/Store.Users.Tests/README.md +++ b/Tests/Store.Users.Tests/README.md @@ -36,3 +36,5 @@ dotnet test Tests/Store.Users.Tests/Store.Users.Tests.csproj --filter FullyQuali SQLite 能验证数据持久化和异常回滚,但不能替代 MySQL InnoDB 并发行锁、隔离级别和迁移脚本验证。原有 `UserManagementSmoke` 和 `ShopRenewSmoke` 冒烟入口仍可单独运行。 User DTO 仅声明 Required。RequestValidatorTests 验证非空规则、错误集合和这一模块约定,同时确认公共工具仍支持其他模块的完整 DataAnnotations。UserServiceValidationTests 验证服务层的长度、范围和 ID 判断;原有金额、精度、格式、日期等用例继续通过服务入口验证。HTTP 测试分别验证 DTO 非空拦截与服务手动参数检查,都按系统约定返回 HTTP 200 和 ValidationError。 + +UserAccessTests 覆盖统一管理范围:管理员跨主账号修改/创建子账号/重置密码,主账号修改本人和直属子账号、重置子账号密码、处理子账号充值备注,非授权账户拒绝操作;普通主账号不能接管管理员账户,也不能充值、扣款或改计费。店铺续费通过真实 MediatR 事务管道测试代操作权限、目标账户扣款与实际操作者审计。HTTP 测试覆盖资料修改与密码重置接口。 diff --git a/Tests/Store.Users.Tests/UserAccessTests.cs b/Tests/Store.Users.Tests/UserAccessTests.cs new file mode 100644 index 0000000..7bb4316 --- /dev/null +++ b/Tests/Store.Users.Tests/UserAccessTests.cs @@ -0,0 +1,189 @@ +using MAX.Framework.Commons; +using MAX.Framework.DependencyInjection; +using MAX.Framework.Mediation; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using Store.Users.Commands; +using Store.Users.Contracts.Dtos; +using Store.Users.Entities; +using YourNamespace; + +namespace Store.Users.Tests; + +/// 管理员、主账号及子账号的管理权限矩阵;资金特权单独验证。 +public sealed class UserAccessTests +{ + [Theory] + [InlineData("admin", "other", false)] + [InlineData("admin", "child", true)] + [InlineData("admin", "child", false)] + [InlineData("main", "main", false)] + [InlineData("main", "child", false)] + [InlineData("main", "child", true)] + public async Task AuthorizedActor_CanUpdateAccount(string actor, string targetName, bool subRoute) + { + using var context = new UserTestContext(); + context.CurrentUser.UserName = actor; + var target = await context.Read(targetName); + await context.Users.Update(target.Id, new() { Notes = "updated" }, subRoute, default); + Assert.Equal("updated", (await context.Read(targetName)).Notes); + Assert.Contains(targetName, context.Sessions.SignedOut); + } + + [Theory] + [InlineData("main", "other")] + [InlineData("child", "main")] + [InlineData("child", "child")] + public async Task UnauthorizedActor_CannotUpdateAccount(string actor, string targetName) + { + using var context = new UserTestContext(); + context.CurrentUser.UserName = actor; + var target = await context.Read(targetName); + var error = await Assert.ThrowsAsync(() => context.Users.Update(target.Id, new() { Notes = "bad" }, false, default)); + Assert.Equal(403, error.Code); + Assert.Equal("", (await context.Read(targetName)).Notes); + } + + [Fact] + public async Task Admin_CanCreateChildForAnotherMainAccount() + { + using var context = new UserTestContext(); + var created = await context.Users.Create(new() + { + UserName = "new-child", Password = "password", ParentUserName = "other" + }, true, default); + Assert.Equal("other", created.ParentUserName); + } + + [Fact] + public async Task Main_CannotCreateChildForAnotherAccount_OrElevateOwnRole() + { + using var context = new UserTestContext(); + context.CurrentUser.UserName = "main"; + var error = await Assert.ThrowsAsync(() => context.Users.Create(new() + { + UserName = "new-child", Password = "password", ParentUserName = "other" + }, true, default)); + Assert.Equal(403, error.Code); + Assert.Equal(403, (await Assert.ThrowsAsync(() => context.Users.Update(context.Main.Id, + new() { SaLevel = 1 }, false, default))).Code); + } + + [Theory] + [InlineData("admin", "other")] + [InlineData("admin", "child")] + [InlineData("main", "child")] + public async Task AuthorizedActor_CanResetManagedAccountPassword(string actor, string target) + { + using var context = new UserTestContext(); + context.CurrentUser.UserName = actor; + await context.Users.ChangePassword(new() { UserName = target, NewPassword = "reset-password" }, default); + Assert.NotNull(await context.Authentication.Authenticate(target, "reset-password")); + Assert.Null(await context.Authentication.Authenticate(target, "legacy-password")); + Assert.Contains(target, context.Sessions.SignedOut); + Assert.DoesNotContain(actor, context.Sessions.SignedOut); + } + + [Theory] + [InlineData("main", "other")] + [InlineData("child", "main")] + public async Task UnauthorizedActor_CannotResetPassword(string actor, string target) + { + using var context = new UserTestContext(); + context.CurrentUser.UserName = actor; + var error = await Assert.ThrowsAsync(() => context.Users.ChangePassword(new() + { + UserName = target, NewPassword = "reset-password" + }, default)); + Assert.Equal(403, error.Code); + Assert.True(Store.Users.Services.UserPassword.Verify(await context.Read(target), "legacy-password")); + } + + [Fact] + public async Task Main_CanCreateReadAndProcessOwnChildsTopUpMessage() + { + using var context = new UserTestContext(); + context.CurrentUser.UserName = "main"; + var id = await context.Users.AddTopUpMessage(new() { UserName = "child", Message = "pending" }, default); + await context.Users.UpdateTopUpMessage(new() { Id = id, Result = "done" }, default); + var message = Assert.Single((await context.Users.TopUpMessages(new(), default)).Items); + Assert.Equal("child", message.UserName); + Assert.Equal("done", message.Result); + Assert.Equal(0m, (await context.Read("child")).Points); + context.CurrentUser.UserName = "other"; + Assert.Empty((await context.Users.TopUpMessages(new(), default)).Items); + Assert.Equal(403, (await Assert.ThrowsAsync(() => context.Users.UpdateTopUpMessage(new() { Id = id, Result = "bad" }, default))).Code); + } + + [Fact] + public async Task Main_CannotTakeOverAnAdministratorAccountThroughChildRelationship() + { + using var context = new UserTestContext(); + var privilegedChild = context.Seed("privileged-child", 1, "main"); + context.CurrentUser.UserName = "main"; + Assert.Equal(403, (await Assert.ThrowsAsync(() => context.Users.ChangePassword(new() + { + UserName = privilegedChild.UserName, NewPassword = "takeover-password" + }, default))).Code); + Assert.Equal(403, (await Assert.ThrowsAsync(() => context.Users.Update(privilegedChild.Id, + new() { Notes = "takeover" }, true, default))).Code); + Assert.True(Store.Users.Services.UserPassword.Verify(await context.Read(privilegedChild.UserName), "legacy-password")); + } + + [Fact] + public async Task Main_CannotRechargeOwnOrChildAccounts_OrModifyBillingConfiguration() + { + using var context = new UserTestContext(); + context.CurrentUser.UserName = "main"; + foreach (var name in new[] { "main", "child" }) + { + foreach (bool points in new[] { true, false }) + Assert.Equal(403, (await Assert.ThrowsAsync(() => context.Funds.Change(new() + { + UserName = name, Amount = 10m, Operation = "充值", RequestId = Guid.NewGuid().ToString("N") + }, points, default))).Code); + var target = await context.Read(name); + Assert.Equal(403, (await Assert.ThrowsAsync(() => context.Users.Update(target.Id, + new() { ShopPointsPerMonth = 45 }, false, default))).Code); + } + Assert.Equal(403, (await Assert.ThrowsAsync(() => context.Funds.Change(new() + { + UserName = "other", Amount = 1m, Operation = "转移", RequestId = "outside" + }, true, default))).Code); + } + + [Theory] + [InlineData("admin", true)] + [InlineData("main", true)] + [InlineData("other", false)] + public async Task ShopRenew_UsesManagedAccountAndAuditsActualActor(string actor, bool allowed) + { + using var context = new UserTestContext(); + context.Db.CodeFirst.ConfigEntity(e => e.Name("shop_expirations")); + context.Db.CodeFirst.SyncStructure(); + await context.Db.Update().Set(u => u.Points, 100m).Where(u => u.Id == context.Child.Id).ExecuteAffrowsAsync(); + await context.Db.Insert(new ShopEntity { ShopId = "shop", ShopAccount = "shop-account", UserName = "child" }).ExecuteAffrowsAsync(); + var services = new ServiceCollection(); + services.AddSingleton(context.Db); + services.AddSingleton(); + services.AddMediatRAttributedBehaviors(typeof(ShopRenewCommand).Assembly); + services.AddTransient(typeof(IRequestHandler), + typeof(ShopRenewCommand).Assembly.GetType("Store.Users.Commands.ShopRenewCommandHandler", true)!); + using var provider = services.BuildServiceProvider(); + var mediator = provider.GetRequiredService(); + var command = new ShopRenewCommand("child", "shop", 1, OperatorName: actor); + if (!allowed) + { + Assert.Equal(403, (await Assert.ThrowsAsync(() => mediator.Send(command))).Code); + Assert.Equal(100m, (await context.Read("child")).Points); + Assert.Equal(0, await context.Db.Select().CountAsync()); + return; + } + await mediator.Send(command); + Assert.Equal(70m, (await context.Read("child")).Points); + var record = Assert.Single(await context.Db.Select().ToListAsync()); + Assert.Equal(actor, record.OperatorName); + Assert.Equal("child", record.UserName); + Assert.Equal(actor == "admin" ? "管理员" : "用户", record.OperatorType); + } +} diff --git a/Tests/Store.Users.Tests/UserFundsTests.cs b/Tests/Store.Users.Tests/UserFundsTests.cs index 4a2d945..1670d7e 100644 --- a/Tests/Store.Users.Tests/UserFundsTests.cs +++ b/Tests/Store.Users.Tests/UserFundsTests.cs @@ -63,9 +63,9 @@ public sealed class UserFundsTests using var context = new UserTestContext(); await context.Funds.Change(Request(), true, default); context.CurrentUser.UserName = "main"; - await context.Funds.Change(Request("转移", 30m, "other", "transfer"), true, default); + await context.Funds.Change(Request("转移", 30m, "child", "transfer"), true, default); var sender = await context.Read("main"); - var receiver = await context.Read("other"); + var receiver = await context.Read("child"); Assert.Equal(70m, sender.Points); Assert.Equal(30m, receiver.Points); Assert.Equal(100m, sender.RechargePointsTotal); @@ -76,7 +76,7 @@ public sealed class UserFundsTests var credit = Assert.Single(records, r => r.OptionType == "转入"); Assert.Equal(30m, debit.DeltaPoints); Assert.Equal(debit.DeltaPoints, credit.DeltaPoints); - Assert.Equal("other", debit.CounterpartyAccountName); + Assert.Equal("child", debit.CounterpartyAccountName); Assert.Equal("main", credit.CounterpartyAccountName); Assert.All(records, r => Assert.Equal("main", r.OperatorName)); } @@ -97,8 +97,8 @@ public sealed class UserFundsTests public async Task InsufficientFunds_DoesNotChangeAccountsOrCreateRecords(bool points) { using var context = new UserTestContext(); - if (points) context.CurrentUser.UserName = "other"; - var error = await Assert.ThrowsAsync(() => context.Funds.Change(Request(points ? "转移" : "消费"), points, default)); + if (points) context.CurrentUser.UserName = "main"; + var error = await Assert.ThrowsAsync(() => context.Funds.Change(Request(points ? "转移" : "消费", target: points ? "child" : "main"), points, default)); Assert.Contains("不足", error.Message); Assert.Equal(0m, (await context.Read("main")).Points); Assert.Equal(0m, (await context.Read("other")).Points); @@ -181,10 +181,10 @@ public sealed class UserFundsTests ? "CREATE TRIGGER fail_audit BEFORE INSERT ON points WHEN NEW.option_type = '转入' BEGIN SELECT RAISE(ABORT, 'forced audit failure'); END" : "CREATE TRIGGER fail_audit BEFORE INSERT ON payments BEGIN SELECT RAISE(ABORT, 'forced audit failure'); END"; await context.Db.Ado.ExecuteNonQueryAsync(trigger); - var request = Request(points ? "转移" : "充值", 20m, "other", "retry"); + var request = Request(points ? "转移" : "充值", 20m, "child", "retry"); var error = await Assert.ThrowsAnyAsync(() => context.Funds.Change(request, points, default)); Assert.Contains("forced audit failure", error.Message); - Assert.Equal(0m, points ? (await context.Read("other")).Points : (await context.Read("other")).Balance); + Assert.Equal(0m, points ? (await context.Read("child")).Points : (await context.Read("child")).Balance); Assert.Equal(0, await context.Db.Select().Where(r => r.OutTradeNo == "user:retry").CountAsync()); Assert.Equal(0, await context.Db.Select().CountAsync()); if (points) Assert.Equal(100m, (await context.Read("main")).Points); diff --git a/Tests/Store.Users.Tests/UserHttpTests.cs b/Tests/Store.Users.Tests/UserHttpTests.cs index 7810d3b..2c51915 100644 --- a/Tests/Store.Users.Tests/UserHttpTests.cs +++ b/Tests/Store.Users.Tests/UserHttpTests.cs @@ -239,6 +239,54 @@ public sealed class UserHttpTests : IAsyncLifetime Assert.Equal(0, await _context.Db.Select().CountAsync()); } + [Fact] + public async Task GetSubUser_UsesAdminOrMainAccountScope() + { + _context.Seed("foreign-child", parent: "other"); + using var allResponse = await _client.PostAsJsonAsync("/v1/user/GetSubUser", new { }); + var all = await allResponse.Content.ReadFromJsonAsync>>(); + Assert.Equal(HttpStatusCode.OK, allResponse.StatusCode); + Assert.True(all!.Success); + Assert.Equal(2, all.Data!.Total); + + using var filteredResponse = await _client.PostAsJsonAsync("/v1/user/GetSubUser", new { parentUserName = "other" }); + var filtered = await filteredResponse.Content.ReadFromJsonAsync>>(); + Assert.True(filtered!.Success); + Assert.Equal("foreign-child", Assert.Single(filtered.Data!.Items).UserName); + + _client.DefaultRequestHeaders.Remove("X-Test-User"); + _client.DefaultRequestHeaders.Add("X-Test-User", "main"); + using var ownResponse = await _client.PostAsJsonAsync("/v1/user/GetSubUser", new { }); + var own = await ownResponse.Content.ReadFromJsonAsync>>(); + Assert.True(own!.Success); + Assert.Equal("child", Assert.Single(own.Data!.Items).UserName); + + using var forbiddenResponse = await _client.PostAsJsonAsync("/v1/user/GetSubUser", new { parentUserName = "other" }); + Assert.Equal(HttpStatusCode.OK, forbiddenResponse.StatusCode); + var forbidden = await forbiddenResponse.Content.ReadFromJsonAsync(); + Assert.Equal(ResultCode.Forbidden, forbidden!.Code); + } + + [Fact] + public async Task UpdateEndpoints_ApplySharedAccountManagementScope() + { + using var adminUpdate = await _client.PostAsJsonAsync("/v1/user/UpdateSubUser", new { id = _context.Child.Id, notes = "admin-update" }); + Assert.True((await adminUpdate.Content.ReadFromJsonAsync>())!.Success); + _client.DefaultRequestHeaders.Remove("X-Test-User"); + _client.DefaultRequestHeaders.Add("X-Test-User", "main"); + foreach (var id in new[] { _context.Main.Id, _context.Child.Id }) + { + using var update = await _client.PostAsJsonAsync("/v1/user/UpdateUser", new { id, notes = "main-update" }); + Assert.Equal(HttpStatusCode.OK, update.StatusCode); + Assert.True((await update.Content.ReadFromJsonAsync>())!.Success); + } + using var forbidden = await _client.PostAsJsonAsync("/v1/user/UpdateUser", new { id = _context.Other.Id, notes = "bad" }); + Assert.Equal(ResultCode.Forbidden, (await forbidden.Content.ReadFromJsonAsync())!.Code); + using var reset = await _client.PostAsJsonAsync("/v1/user/ChangePassword", new { userName = "child", newPassword = "reset-password" }); + Assert.True((await reset.Content.ReadFromJsonAsync())!.Success); + Assert.NotNull(await _context.Authentication.Authenticate("child", "reset-password")); + } + [Fact] public void ApiExplorerAndEndpointMetadata_UsePostForEveryAction() { diff --git a/Tests/Store.Users.Tests/UserManagementTests.cs b/Tests/Store.Users.Tests/UserManagementTests.cs index e77ebaf..9f67d9c 100644 --- a/Tests/Store.Users.Tests/UserManagementTests.cs +++ b/Tests/Store.Users.Tests/UserManagementTests.cs @@ -202,7 +202,7 @@ public sealed class UserManagementTests Assert.Equal("child-two", Assert.Single(page.Items).UserName); var second = await context.Users.Search(new() { Page = 2, PageSize = 1 }, true, default); Assert.Equal("child", Assert.Single(second.Items).UserName); - Assert.Equal(403, (await Assert.ThrowsAsync(() => context.Users.Search(new(), false, default))).Code); + Assert.Equal(3, (await context.Users.Search(new(), false, default)).Total); } [Theory] @@ -215,6 +215,51 @@ public sealed class UserManagementTests await Assert.ThrowsAsync(() => context.Users.Search(new() { Page = page, PageSize = pageSize }, false, default)); } + [Fact] + public async Task Admin_CanQueryAllChildrenWithoutIncludingMainAccounts() + { + using var context = new UserTestContext(); + context.Seed("foreign-child", parent: "other"); + context.Seed("admin-child", parent: "admin"); + context.Seed("blank-parent", parent: " "); + var page = await context.Users.Search(new(), true, default); + Assert.Equal(3, page.Total); + Assert.Equal(new[] { "admin-child", "child", "foreign-child" }, page.Items.Select(u => u.UserName).Order().ToArray()); + Assert.All(page.Items, u => Assert.False(u.IsMainUser)); + } + + [Theory] + [InlineData("main", "child")] + [InlineData(" other ", "foreign-child")] + public async Task Admin_CanFilterChildrenByAnyParent(string parent, string expectedChild) + { + using var context = new UserTestContext(); + context.Seed("foreign-child", parent: "other"); + var page = await context.Users.Search(new() { ParentUserName = parent }, true, default); + Assert.Equal(1, page.Total); + Assert.Equal(expectedChild, Assert.Single(page.Items).UserName); + } + + [Fact] + public async Task MainAccount_CannotSelectAnotherParentsChildren() + { + using var context = new UserTestContext(); + context.CurrentUser.UserName = "main"; + var error = await Assert.ThrowsAsync(() => context.Users.Search(new() { ParentUserName = "other" }, true, default)); + Assert.Equal(403, error.Code); + var page = await context.Users.Search(new() { ParentUserName = "main" }, true, default); + Assert.Equal("child", Assert.Single(page.Items).UserName); + } + + [Fact] + public async Task ChildAccount_CannotQueryChildLists() + { + using var context = new UserTestContext(); + context.CurrentUser.UserName = "child"; + var error = await Assert.ThrowsAsync(() => context.Users.Search(new(), true, default)); + Assert.Equal(403, error.Code); + } + [Fact] public async Task EmptyAccount_CanBeDeleted_AndSessionIsInvalidated() { diff --git a/Tests/Store.Users.Tests/UserTopUpTests.cs b/Tests/Store.Users.Tests/UserTopUpTests.cs index 7489174..7d5879a 100644 --- a/Tests/Store.Users.Tests/UserTopUpTests.cs +++ b/Tests/Store.Users.Tests/UserTopUpTests.cs @@ -24,14 +24,15 @@ public sealed class UserTopUpTests } [Fact] - public async Task ProcessingMessage_RequiresAdmin_AndDoesNotRechargeFunds() + public async Task ProcessingMessage_UsesAccountScope_AndDoesNotRechargeFunds() { using var context = new UserTestContext(); context.CurrentUser.UserName = "main"; var id = await context.Users.AddTopUpMessage(new() { Message = "充值待核实" }, default); + context.CurrentUser.UserName = "other"; var error = await Assert.ThrowsAsync(() => context.Users.UpdateTopUpMessage(new() { Id = id, Result = "已核实" }, default)); Assert.Equal(403, error.Code); - context.CurrentUser.UserName = "admin"; + context.CurrentUser.UserName = "main"; await context.Users.UpdateTopUpMessage(new() { Id = id, Result = "已核实" }, default); Assert.Equal("已核实", (await context.Users.TopUpMessages(new(), default)).Items.Single().Result); Assert.Equal(0m, (await context.Read("main")).Points); diff --git a/Tests/UserManagementSmoke/Program.cs b/Tests/UserManagementSmoke/Program.cs index 8e0001e..3405039 100644 --- a/Tests/UserManagementSmoke/Program.cs +++ b/Tests/UserManagementSmoke/Program.cs @@ -42,8 +42,8 @@ try Assert(await auth.Authenticate("main", "wrong") == null, "错误密码被接受"); Assert(await auth.Authenticate(" main ", "legacy-password") != null, "旧密码登录失败"); var stored = await db.Select().Where(u => u.Id == main.Id).FirstAsync(); - Assert(stored.Password != "legacy-password" && UserPassword.Verify(stored, "legacy-password"), "旧密码未升级"); - Assert(await auth.Authenticate("main", "legacy-password") != null, "哈希密码登录失败"); + Assert(stored.Password == "legacy-password" && UserPassword.Verify(stored, "legacy-password"), "登录不应修改已有密码"); + Assert(await auth.Authenticate("main", "legacy-password") != null, "重复登录失败"); var created = await users.Create(new() { UserName = "newmain", Password = "new-password" }, false, default); Assert(created.GroupId == 3 && created.ShopPointsPerMonth == 30 && created.Points == 0, "主账号默认值错误"); Assert(!JsonSerializer.Serialize(created).Contains("Password", StringComparison.OrdinalIgnoreCase), "响应包含密码字段"); @@ -55,7 +55,7 @@ try await Reject(() => users.Create(new() { UserName = "bad-admin", Password = "password", SaLevel = 1 }, true, default)); await Reject(() => users.Update(sub.Id, new() { ShopPointsPerMonth = 30 }, true, default)); await Reject(() => users.GetUser(other.UserName, default)); - await Reject(() => users.Search(new(), false, default)); + Assert((await users.Search(new(), false, default)).Total == 2, "主账号列表范围错误"); await Reject(() => users.Update(other.Id, new() { Notes = "tampered" }, true, default)); await users.Update(sub.Id, new() { Notes = "" }, true, default); Assert((await users.Search(new(), true, default)).Total == 1, "子账号查询范围错误"); @@ -70,10 +70,10 @@ try await Reject(() => funds.Change(recharge, true, default)); Assert((await db.Select().Where(u => u.Id == main.Id).FirstAsync()).Points == 100, "重复请求造成重复充值"); actor.UserName = "main"; - await funds.Change(new() { UserName = "other", Amount = 30, Operation = "转移", RequestId = "transfer-1" }, true, default); + await funds.Change(new() { UserName = "child", Amount = 30, Operation = "转移", RequestId = "transfer-1" }, true, default); Assert((await db.Select().Where(u => u.Id == main.Id).FirstAsync()).Points == 70, "转出错误"); - Assert((await db.Select().Where(u => u.Id == other.Id).FirstAsync()).Points == 30, "转入错误"); - await Reject(() => funds.Change(new() { UserName = "other", Amount = 1000, Operation = "转移", RequestId = "overdraft" }, true, default)); + Assert((await db.Select().Where(u => u.Id == sub.Id).FirstAsync()).Points == 30, "转入错误"); + await Reject(() => funds.Change(new() { UserName = "child", Amount = 1000, Operation = "转移", RequestId = "overdraft" }, true, default)); Assert(await db.Select().Where(r => r.OutTradeNo == "user:overdraft").CountAsync() == 0, "失败转账产生流水"); actor.UserName = "admin"; await funds.Change(new() { UserName = "main", Amount = 25.5m, Operation = "充值", RequestId = "balance-1" }, false, default); @@ -100,14 +100,15 @@ try actor.UserName = "main"; await db.Ado.ExecuteNonQueryAsync("CREATE TRIGGER reject_credit BEFORE INSERT ON points WHEN NEW.option_type = '转入' BEGIN SELECT RAISE(ABORT, 'test rollback'); END"); bool failed = false; - try { await funds.Change(new() { UserName = "other", Amount = 10, Operation = "转移", RequestId = "rollback" }, true, default); } + try { await funds.Change(new() { UserName = "child", Amount = 10, Operation = "转移", RequestId = "rollback" }, true, default); } catch (Exception ex) when (ex.Message.Contains("test rollback")) { failed = true; } Assert(failed, "测试故障未触发"); Assert((await db.Select().Where(u => u.Id == main.Id).FirstAsync()).Points == 70, "转账失败未回滚转出账户"); - Assert((await db.Select().Where(u => u.Id == other.Id).FirstAsync()).Points == 30, "转账失败未回滚转入账户"); + Assert((await db.Select().Where(u => u.Id == sub.Id).FirstAsync()).Points == 30, "转账失败未回滚转入账户"); Assert(await db.Select().Where(r => r.OutTradeNo == "user:rollback").CountAsync() == 0, "转账失败未回滚流水"); await db.Ado.ExecuteNonQueryAsync("DROP TRIGGER reject_credit"); var messageId = await users.AddTopUpMessage(new() { Message = "银行充值待核实" }, default); + actor.UserName = "other"; await Reject(() => users.UpdateTopUpMessage(new() { Id = messageId, Result = "已处理" }, default)); actor.UserName = "other"; Assert((await users.TopUpMessages(new(), default)).Total == 0, "充值备注越权泄漏"); @@ -125,7 +126,7 @@ try var shopOwner = Seed("shop-owner", 0); await db.Insert(new ShopEntity { UserName = shopOwner.UserName, ShopId = "test-shop" }).ExecuteAffrowsAsync(); await Reject(() => users.Delete(shopOwner.Id, default)); - Console.WriteLine("PASS: 用户认证、密码升级、权限隔离、主子账号、计费继承、资金事务、重复请求、超扣回滚、删除限制、参数边界。"); + Console.WriteLine("PASS: 用户认证、登录不修改密码、权限隔离、主子账号、计费继承、资金事务、重复请求、超扣回滚、删除限制、参数边界。"); UserEntity Seed(string name, short level) {