提交管理员操作一切
This commit is contained in:
1 parent
a87ebeaffb
commit
d6471f97d3
23 files changed
+600
-73
No files matched your search
@@ -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<IHostEnvironment>();
|
||||
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<bool>("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 =>
|
||||
{
|
||||
|
||||
@@ -10,7 +10,7 @@ public class UserEditRequest
|
||||
public string? Notes { get; set; }
|
||||
public string? CardId { get; set; }
|
||||
public byte? GroupId { get; set; }
|
||||
/// <summary>仅管理员可设置;子账号不能成为管理员。</summary>
|
||||
/// <summary>仅管理员可设置;普通账号不能通过资料修改提升管理员级别。</summary>
|
||||
public short? SaLevel { get; set; }
|
||||
public string? Authority { get; set; }
|
||||
public string? ForbiddenPage { get; set; }
|
||||
@@ -31,17 +31,26 @@ public sealed class UserUpdateRequest : UserEditRequest
|
||||
/// <summary>创建主账号或当前主账号的直属子账号。</summary>
|
||||
public sealed class UserCreateRequest : UserEditRequest
|
||||
{
|
||||
/// <summary>创建子账号时的父账号;管理员可指定任意主账号,普通主账号只能指定自己。</summary>
|
||||
public string? ParentUserName { get; set; }
|
||||
[Required(ErrorMessage = "用户名不能为空")] public string UserName { get; set; } = "";
|
||||
[Required(ErrorMessage = "密码不能为空")] public string Password { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>修改本人密码,必须校验旧密码。</summary>
|
||||
/// <summary>本人改密需原密码;管理员及主账号可为管理范围内的其他账户重置密码。</summary>
|
||||
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; } = "";
|
||||
}
|
||||
|
||||
/// <summary>本人修改密码时验证原密码非空。</summary>
|
||||
public sealed class UserPasswordProofRequest
|
||||
{
|
||||
[Required(ErrorMessage = "原密码不能为空")] public string OldPassword { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>分页参数;页码、分页大小和过滤值由服务检查。</summary>
|
||||
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; }
|
||||
/// <summary>查询子账号时可按父账号筛选;管理员可指定任意账号,普通主账号只能指定自己。</summary>
|
||||
public string? ParentUserName { get; set; }
|
||||
}
|
||||
|
||||
public sealed record UserPage<T>(long Total, IReadOnlyList<T> Items);
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace Store.Users.Contracts.Dtos;
|
||||
/// <summary>用户提交的充值备注。</summary>
|
||||
public sealed class UserTopUpMessageRequest
|
||||
{
|
||||
/// <summary>目标账户;省略时为本人,指定时检查管理权限。</summary>
|
||||
public string? UserName { get; set; }
|
||||
[Required(ErrorMessage = "充值备注不能为空")] public string Message { get; set; } = "";
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ShopRenewResponse>, 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<UserEntity>().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,
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>获取本人或授权范围内的用户信息,响应不包含密码。</summary>
|
||||
[EndpointSummary("获取用户信息")]
|
||||
public Task<UserInfo> GetUser(string? userName, CancellationToken cancellationToken)
|
||||
{
|
||||
return users.GetUser(userName, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>管理员分页查询所有用户。</summary>
|
||||
/// <summary>管理员查询全部用户;普通主账号仅查询本人及直属子账号。</summary>
|
||||
[EndpointSummary("分页查询所有用户")]
|
||||
public Task<UserPage<UserInfo>> GetAllUser(UserSearchRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return users.Search(request, false, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>分页查询当前主账号的直属子账号。</summary>
|
||||
/// <summary>管理员可查询所有账号的子账号,普通主账号只能查询自己的直属子账号。</summary>
|
||||
/// <param name="request">管理员可通过 ParentUserName 筛选指定父账号;普通主账号仅可指定自己。</param>
|
||||
/// <param name="cancellationToken">请求取消标记。</param>
|
||||
[EndpointSummary("分页查询子账号")]
|
||||
[EndpointDescription("管理员不传 ParentUserName 时查询全部子账号,传入时按指定父账号筛选;普通主账号只能查询自己的子账号。")]
|
||||
public Task<UserPage<UserInfo>> GetSubUser(UserSearchRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return users.Search(request, true, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>管理员创建主账号。</summary>
|
||||
[EndpointSummary("创建主账号")]
|
||||
public Task<UserInfo> AddUser(UserCreateRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return users.Create(request, false, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>主账号创建直属子账号。</summary>
|
||||
/// <summary>管理员可为指定主账号创建子账号,普通主账号仅可为自己创建子账号。</summary>
|
||||
[EndpointSummary("创建直属子账号")]
|
||||
public Task<UserInfo> AddSubUser(UserCreateRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return users.Create(request, true, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>管理员修改账户资料,用户名不可变更。</summary>
|
||||
/// <summary>管理员修改任意账户;普通主账号仅修改本人及直属子账号,用户名不可变更。</summary>
|
||||
[EndpointSummary("修改用户资料")]
|
||||
public Task<UserInfo> UpdateUser(UserUpdateRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return users.Update(request.Id, request, false, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>主账号修改直属子账号资料。</summary>
|
||||
/// <summary>管理员修改任意子账号,普通主账号仅修改直属子账号。</summary>
|
||||
[EndpointSummary("修改直属子账号资料")]
|
||||
public Task<UserInfo> UpdateSubUser(UserUpdateRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return users.Update(request.Id, request, true, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>删除无资金和业务关联的账户。</summary>
|
||||
[EndpointSummary("删除用户")]
|
||||
public async Task<ApiResult> DelUser(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
await users.Delete(id, cancellationToken);
|
||||
return ApiResult.Ok();
|
||||
}
|
||||
|
||||
/// <summary>校验旧密码并修改本人密码,成功后需重新登录。</summary>
|
||||
/// <summary>本人改密校验原密码;管理员或主账号可为管理范围内的其他账户重置密码。</summary>
|
||||
[EndpointSummary("修改或重置用户密码")]
|
||||
public async Task<ApiResult> ChangePassword(ChangePasswordRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
await users.ChangePassword(request, cancellationToken);
|
||||
@@ -76,49 +89,57 @@ public class UserController(UserManagementService users, UserFundsService funds,
|
||||
}
|
||||
|
||||
/// <summary>查询授权账户的积分流水。</summary>
|
||||
[EndpointSummary("查询用户积分流水")]
|
||||
public Task<UserPage<UserPointsRecordEntity>> GetUserPoints(UserRecordSearchRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return users.Points(request.UserName, request, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>查询授权账户的余额流水。</summary>
|
||||
[EndpointSummary("查询用户余额流水")]
|
||||
public Task<UserPage<UserPaymentRecordEntity>> GetUserBalance(UserRecordSearchRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return users.Balance(request.UserName, request, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>管理员充值积分,或当前用户向其他账户转移自己的积分。</summary>
|
||||
[EndpointSummary("充值或转移用户积分")]
|
||||
public Task<UserFundsResponse> UpdateUserPoints(UserFundsRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return funds.Change(request, true, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>管理员充值或扣减余额,自动记录审计流水。</summary>
|
||||
[EndpointSummary("充值或扣减用户余额")]
|
||||
public Task<UserFundsResponse> UpdateUserBalance(UserFundsRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return funds.Change(request, false, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>提交本人的充值备注。</summary>
|
||||
/// <summary>提交本人或管理范围内账户的充值备注。</summary>
|
||||
[EndpointSummary("提交充值备注")]
|
||||
public Task<long> AddUserTopUpMsg(UserTopUpMessageRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return users.AddTopUpMessage(request, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>管理员填写充值备注的处理结果。</summary>
|
||||
/// <summary>管理员可处理所有充值备注,普通主账号仅处理本人及直属子账号备注,不自动充值。</summary>
|
||||
[EndpointSummary("处理充值备注")]
|
||||
public async Task<ApiResult> UpdateUserTopUpMsg(UserTopUpResultRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
await users.UpdateTopUpMessage(request, cancellationToken);
|
||||
return ApiResult.Ok();
|
||||
}
|
||||
|
||||
/// <summary>分页查询充值备注。</summary>
|
||||
/// <summary>按管理员、主账号或子账号的授权范围分页查询充值备注。</summary>
|
||||
[EndpointSummary("分页查询充值备注")]
|
||||
public Task<UserPage<UserTopUpMessageEntity>> GetUserTopUpMsg(UserSearchRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return users.TopUpMessages(request, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>管理员维护代理充值二维码。</summary>
|
||||
[EndpointSummary("维护代理充值二维码")]
|
||||
public async Task<ApiResult> UpdateTopUpQrcode(UserTopUpQrcodeRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
await users.UpdateTopUpQrcode(request, cancellationToken);
|
||||
@@ -126,18 +147,22 @@ public class UserController(UserManagementService users, UserFundsService funds,
|
||||
}
|
||||
|
||||
/// <summary>获取代理充值二维码。</summary>
|
||||
[EndpointSummary("获取代理充值二维码")]
|
||||
public Task<string?> GetTopUpQrcode(string agentCode, CancellationToken cancellationToken)
|
||||
{
|
||||
return users.GetTopUpQrcode(agentCode, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>使用当前登录用户的积分为商铺续费。</summary>
|
||||
public Task<ShopRenewResponse> ShopRenew(ShopRenewRequest request, CancellationToken cancellationToken)
|
||||
/// <summary>管理员或主账号为授权账户的商铺续费,扣除店铺所属账户积分并记录实际操作者。</summary>
|
||||
[EndpointSummary("使用积分为商铺续费")]
|
||||
public async Task<ShopRenewResponse> 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);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ namespace Store.Users.Dtos;
|
||||
/// <summary>续费请求;仅声明店铺ID非空,时长和长度由服务或命令处理。</summary>
|
||||
public class ShopRenewRequest
|
||||
{
|
||||
/// <summary>续费账户;管理员可指定任意账户,普通主账号仅限本人及直属子账号。</summary>
|
||||
public string? UserName { get; set; }
|
||||
[Required(ErrorMessage = "店铺ID不能为空")] public string ShopId { get; set; } = "";
|
||||
/// <summary>续费时长,单位月,合法范围1-120。</summary>
|
||||
public int RenewDuration { get; set; }
|
||||
|
||||
@@ -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。账户删除仍禁止删除当前登录账户,且要求无积分、无余额、无子账号和无店铺关联。
|
||||
|
||||
管理员角色账户即使存在父账号关系,也不能由普通主账号修改或重置密码,避免通过子账号关系获得管理员身份。
|
||||
@@ -0,0 +1,18 @@
|
||||
using Store.Users.Entities;
|
||||
|
||||
namespace Store.Users.Services;
|
||||
|
||||
/// <summary>统一账户管理范围:管理员管理全部,主账号管理本人及直属子账号。</summary>
|
||||
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)));
|
||||
}
|
||||
@@ -37,6 +37,8 @@ public sealed class UserFundsService(IFreeSql orm, ICurrentUser currentUser) : I
|
||||
var target = await db.Select<UserEntity>().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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,40 +6,57 @@ namespace Store.Users.Services;
|
||||
|
||||
public sealed partial class UserManagementService
|
||||
{
|
||||
/// <summary>以本人身份提交充值备注,不能伪造提交账户或昵称。</summary>
|
||||
/// <summary>为本人或授权账户提交充值备注,昵称从目标账户读取。</summary>
|
||||
public async Task<long> 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<UserEntity>().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);
|
||||
}
|
||||
|
||||
/// <summary>管理员记录人工处理结果。备注处理与实际充值是不同用例。</summary>
|
||||
/// <summary>管理范围内记录人工处理结果。备注处理不会自动增加积分或余额。</summary>
|
||||
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<UserTopUpMessageEntity>().Where(m => m.Id == request.Id).AnyAsync(ct))
|
||||
throw new AppException("充值备注不存在");
|
||||
var message = await orm.Select<UserTopUpMessageEntity>().Where(m => m.Id == request.Id).FirstAsync(ct)
|
||||
?? throw new AppException("充值备注不存在");
|
||||
if (!IsAdmin(actor))
|
||||
{
|
||||
var target = await orm.Select<UserEntity>().Where(u => u.UserName == message.UserName).FirstAsync(ct);
|
||||
if (target == null || !UserAccessPolicy.CanManage(actor, target))
|
||||
throw new AppException("无权处理该账户的充值备注", 403);
|
||||
}
|
||||
await orm.Update<UserTopUpMessageEntity>().Set(m => m.Result, request.Result.Trim())
|
||||
.Where(m => m.Id == request.Id).ExecuteAffrowsAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>普通用户仅查看自己提交的备注,管理员查看全部。</summary>
|
||||
/// <summary>管理员查看全部,主账号查看本人和直属子账号,子账号仅查看本人备注。</summary>
|
||||
public async Task<UserPage<UserTopUpMessageEntity>> TopUpMessages(UserSearchRequest request, CancellationToken ct)
|
||||
{
|
||||
RequestValidator.Validate(request);
|
||||
UserInputChecks.Search(request);
|
||||
var actor = await Actor(orm, ct);
|
||||
var select = orm.Select<UserTopUpMessageEntity>();
|
||||
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<UserEntity>()
|
||||
.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);
|
||||
|
||||
@@ -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);
|
||||
/// <summary>仅本人、直属主账号或超级管理员可以读取账户详情。</summary>
|
||||
public async Task<UserInfo> 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<UserEntity>().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);
|
||||
}
|
||||
|
||||
/// <summary>管理员查询全部账户;普通主账号只能查询自己的直属子账号。</summary>
|
||||
/// <summary>解析代操作目标,服务端校验管理员或主子账号归属,省略用户名时操作本人。</summary>
|
||||
public async Task<string> 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<UserEntity>().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;
|
||||
}
|
||||
|
||||
/// <summary>管理员查询全部账户;普通主账号的列表范围为本人和直属子账号。</summary>
|
||||
public async Task<UserPage<UserInfo>> 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<UserEntity>();
|
||||
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<UserEntity>().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<UserEntity>().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<UserEntity>().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<UserEntity>().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<UserEntity>().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<UserEntity>().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);
|
||||
}
|
||||
|
||||
/// <summary>比较并更新密码,防止并发密码变更丢失;成功后立即失效已有登录会话。</summary>
|
||||
/// <summary>本人改密校验原密码,管理者可重置授权账户密码;比较并更新后失效目标账户会话。</summary>
|
||||
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<UserEntity>().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<UserEntity>().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);
|
||||
}
|
||||
|
||||
/// <summary>按账户归属查询积分流水;分页与账户详情使用相同的访问范围。</summary>
|
||||
@@ -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;
|
||||
|
||||
@@ -32,6 +32,7 @@ namespace Store.Api.Controllers
|
||||
/// 登录:校验用户名/密码,签发 JWT(单点登录:同用户的新登录会使旧令牌失效)
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[EndpointSummary("用户登录")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ApiResultBase> Login([FromBody] LoginRequest request)
|
||||
{
|
||||
@@ -58,6 +59,7 @@ namespace Store.Api.Controllers
|
||||
/// 退出登录:销毁当前用户的服务端在线会话,令牌立即失效
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[EndpointSummary("退出登录")]
|
||||
[Authorize]
|
||||
public ApiResult<object?> Logout()
|
||||
{
|
||||
@@ -73,6 +75,7 @@ namespace Store.Api.Controllers
|
||||
/// 获取当前登录用户信息(需携带 Bearer Token)
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[EndpointSummary("获取当前登录用户信息")]
|
||||
[Authorize]
|
||||
public ApiResult<object?> Me()
|
||||
{
|
||||
@@ -87,6 +90,7 @@ namespace Store.Api.Controllers
|
||||
/// 在线人员列表(需携带 Bearer Token)
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[EndpointSummary("获取在线人员列表")]
|
||||
[Authorize]
|
||||
public ApiResult<object?> OnlineUsers()
|
||||
{
|
||||
|
||||
@@ -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<VersionedOpenApiOptions>(options =>
|
||||
{
|
||||
options.Document.AddDocumentTransformer((document, _, _) =>
|
||||
{
|
||||
// 接口路径以 / 开头,服务器地址不保留尾部斜杠,兼容导入工具直接拼接 URL。
|
||||
foreach (var server in document.Servers ?? [])
|
||||
{
|
||||
server.Url = server.Url?.TrimEnd('/');
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"Jwt": {
|
||||
"DevelopmentToken": {
|
||||
"Enabled": true,
|
||||
"Token": "store-local-dev-7f2c9e4a6b814d38a5910e62c4f837bd",
|
||||
"UserName": "18990991234"
|
||||
}
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
|
||||
@@ -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<string, string?>
|
||||
{
|
||||
["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"));
|
||||
}
|
||||
}
|
||||
@@ -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 测试覆盖资料修改与密码重置接口。
|
||||
@@ -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;
|
||||
|
||||
/// <summary>管理员、主账号及子账号的管理权限矩阵;资金特权单独验证。</summary>
|
||||
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<AppException>(() => 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<AppException>(() => context.Users.Create(new()
|
||||
{
|
||||
UserName = "new-child", Password = "password", ParentUserName = "other"
|
||||
}, true, default));
|
||||
Assert.Equal(403, error.Code);
|
||||
Assert.Equal(403, (await Assert.ThrowsAsync<AppException>(() => 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<AppException>(() => 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<AppException>(() => 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<AppException>(() => context.Users.ChangePassword(new()
|
||||
{
|
||||
UserName = privilegedChild.UserName, NewPassword = "takeover-password"
|
||||
}, default))).Code);
|
||||
Assert.Equal(403, (await Assert.ThrowsAsync<AppException>(() => 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<AppException>(() => 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<AppException>(() => context.Users.Update(target.Id,
|
||||
new() { ShopPointsPerMonth = 45 }, false, default))).Code);
|
||||
}
|
||||
Assert.Equal(403, (await Assert.ThrowsAsync<AppException>(() => 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<ShopExpireEntity>(e => e.Name("shop_expirations"));
|
||||
context.Db.CodeFirst.SyncStructure<ShopExpireEntity>();
|
||||
await context.Db.Update<UserEntity>().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<UnitOfWorkExecutionContext>();
|
||||
services.AddMediatRAttributedBehaviors(typeof(ShopRenewCommand).Assembly);
|
||||
services.AddTransient(typeof(IRequestHandler<ShopRenewCommand, ShopRenewResponse>),
|
||||
typeof(ShopRenewCommand).Assembly.GetType("Store.Users.Commands.ShopRenewCommandHandler", true)!);
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var mediator = provider.GetRequiredService<IMediator>();
|
||||
var command = new ShopRenewCommand("child", "shop", 1, OperatorName: actor);
|
||||
if (!allowed)
|
||||
{
|
||||
Assert.Equal(403, (await Assert.ThrowsAsync<AppException>(() => mediator.Send(command))).Code);
|
||||
Assert.Equal(100m, (await context.Read("child")).Points);
|
||||
Assert.Equal(0, await context.Db.Select<UserPointsRecordEntity>().CountAsync());
|
||||
return;
|
||||
}
|
||||
await mediator.Send(command);
|
||||
Assert.Equal(70m, (await context.Read("child")).Points);
|
||||
var record = Assert.Single(await context.Db.Select<UserPointsRecordEntity>().ToListAsync());
|
||||
Assert.Equal(actor, record.OperatorName);
|
||||
Assert.Equal("child", record.UserName);
|
||||
Assert.Equal(actor == "admin" ? "管理员" : "用户", record.OperatorType);
|
||||
}
|
||||
}
|
||||
@@ -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<AppException>(() => context.Funds.Change(Request(points ? "转移" : "消费"), points, default));
|
||||
if (points) context.CurrentUser.UserName = "main";
|
||||
var error = await Assert.ThrowsAsync<AppException>(() => 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<Exception>(() => 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<UserPointsRecordEntity>().Where(r => r.OutTradeNo == "user:retry").CountAsync());
|
||||
Assert.Equal(0, await context.Db.Select<UserPaymentRecordEntity>().CountAsync());
|
||||
if (points) Assert.Equal(100m, (await context.Read("main")).Points);
|
||||
|
||||
@@ -239,6 +239,54 @@ public sealed class UserHttpTests : IAsyncLifetime
|
||||
Assert.Equal(0, await _context.Db.Select<Store.Users.Entities.UserTopUpQrcodeEntity>().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<ApiResult<UserPage<UserInfo>>>();
|
||||
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<ApiResult<UserPage<UserInfo>>>();
|
||||
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<ApiResult<UserPage<UserInfo>>>();
|
||||
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<ApiResult>();
|
||||
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<ApiResult<UserInfo>>())!.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<ApiResult<UserInfo>>())!.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<ApiResult>())!.Code);
|
||||
using var reset = await _client.PostAsJsonAsync("/v1/user/ChangePassword", new { userName = "child", newPassword = "reset-password" });
|
||||
Assert.True((await reset.Content.ReadFromJsonAsync<ApiResult>())!.Success);
|
||||
Assert.NotNull(await _context.Authentication.Authenticate("child", "reset-password"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApiExplorerAndEndpointMetadata_UsePostForEveryAction()
|
||||
{
|
||||
|
||||
@@ -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<AppException>(() => 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<ValidationException>(() => 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<AppException>(() => 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<AppException>(() => context.Users.Search(new(), true, default));
|
||||
Assert.Equal(403, error.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyAccount_CanBeDeleted_AndSessionIsInvalidated()
|
||||
{
|
||||
|
||||
@@ -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<AppException>(() => 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);
|
||||
|
||||
@@ -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<UserEntity>().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<UserEntity>().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<UserEntity>().Where(u => u.Id == main.Id).FirstAsync()).Points == 70, "转出错误");
|
||||
Assert((await db.Select<UserEntity>().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<UserEntity>().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<UserPointsRecordEntity>().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<UserEntity>().Where(u => u.Id == main.Id).FirstAsync()).Points == 70, "转账失败未回滚转出账户");
|
||||
Assert((await db.Select<UserEntity>().Where(u => u.Id == other.Id).FirstAsync()).Points == 30, "转账失败未回滚转入账户");
|
||||
Assert((await db.Select<UserEntity>().Where(u => u.Id == sub.Id).FirstAsync()).Points == 30, "转账失败未回滚转入账户");
|
||||
Assert(await db.Select<UserPointsRecordEntity>().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)
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user