修改User模块,同步原始代码
This commit is contained in:
1 parent
4a5fada435
commit
78b0c2bb2e
47 files changed
+2939
-193
No files matched your search
@@ -1,4 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Store.Users.Services;
|
||||
using MAX.Framework.Validation;
|
||||
using Store.Users.Dtos;
|
||||
using YourNamespace;
|
||||
using MAX.Framework.Mediation;
|
||||
using MAX.Core.Mediation;
|
||||
@@ -25,14 +27,11 @@ public sealed record ShopRenewCommand(string UserName, string ShopId, int RenewD
|
||||
|
||||
void IValidatableRequest.Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(UserName) || UserName.Trim().Length > 20)
|
||||
throw new ValidationException("用户名不能为空且长度不能超过20个字符");
|
||||
if (string.IsNullOrWhiteSpace(ShopId) || ShopId.Trim().Length > 50)
|
||||
throw new ValidationException("店铺ID不能为空且长度不能超过50个字符");
|
||||
if (RenewDuration is < 1 or > 120)
|
||||
throw new ValidationException("续费时长必须为1-120个月");
|
||||
if (Remark?.Trim().Length > 255)
|
||||
throw new ValidationException("备注长度不能超过255个字符");
|
||||
RequestValidator.Validate(new UserNameRequest { UserName = UserName?.Trim()! });
|
||||
UserInputChecks.Length(UserName.Trim(), 20, "用户名");
|
||||
var request = new ShopRenewRequest { ShopId = ShopId, RenewDuration = RenewDuration, Remark = Remark };
|
||||
RequestValidator.Validate(request);
|
||||
UserInputChecks.Renew(request);
|
||||
}
|
||||
|
||||
ConsumptionQuote IAccountConsumptionRequest.CalculateConsumption(UserEntity user)
|
||||
|
||||
@@ -1,45 +1,143 @@
|
||||
using Asp.Versioning;
|
||||
using MAX.Framework.Security;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Store.Users.Commands;
|
||||
using Store.Users.Contracts;
|
||||
using Store.Users.Contracts.Dtos;
|
||||
using Store.Users.Dtos;
|
||||
using Store.Users.Entities;
|
||||
using Store.Users.Services;
|
||||
using MAX.Framework.Dtos;
|
||||
using MAX.Framework.Conventions;
|
||||
|
||||
namespace Store.Users.Controllers;
|
||||
|
||||
/// <summary>用户模块接口;所有管理操作均以当前 JWT 对应的数据库账户进行授权。</summary>
|
||||
[ApiController]
|
||||
[ApiVersionNeutral]
|
||||
[Route("user/[action]")]
|
||||
public class UserController : ControllerBase
|
||||
[DefaultHttpPost]
|
||||
[Authorize]
|
||||
[Route("v{version:apiVersion}/user/[action]")]
|
||||
public class UserController(UserManagementService users, UserFundsService funds, IMediator mediator, ICurrentUser currentUser) : ControllerBase
|
||||
{
|
||||
private readonly IUserQueryService _userQuery;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
|
||||
public UserController(IUserQueryService userQuery, IMediator mediator, ICurrentUser currentUser)
|
||||
/// <summary>获取本人或授权范围内的用户信息,响应不包含密码。</summary>
|
||||
public Task<UserInfo> GetUser(string? userName, CancellationToken cancellationToken)
|
||||
{
|
||||
_userQuery = userQuery;
|
||||
_mediator = mediator;
|
||||
_currentUser = currentUser;
|
||||
return users.GetUser(userName, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<UserInfo> GetUser(string userName)
|
||||
/// <summary>管理员分页查询所有用户。</summary>
|
||||
public Task<UserPage<UserInfo>> GetAllUser(UserSearchRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _userQuery.GetUser(userName);
|
||||
return users.Search(request, false, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>分页查询当前主账号的直属子账号。</summary>
|
||||
public Task<UserPage<UserInfo>> GetSubUser(UserSearchRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return users.Search(request, true, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>管理员创建主账号。</summary>
|
||||
public Task<UserInfo> AddUser(UserCreateRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return users.Create(request, false, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>主账号创建直属子账号。</summary>
|
||||
public Task<UserInfo> AddSubUser(UserCreateRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return users.Create(request, true, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>管理员修改账户资料,用户名不可变更。</summary>
|
||||
public Task<UserInfo> UpdateUser(UserUpdateRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return users.Update(request.Id, request, false, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>主账号修改直属子账号资料。</summary>
|
||||
public Task<UserInfo> UpdateSubUser(UserUpdateRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return users.Update(request.Id, request, true, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>删除无资金和业务关联的账户。</summary>
|
||||
public async Task<ApiResult> DelUser(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
await users.Delete(id, cancellationToken);
|
||||
return ApiResult.Ok();
|
||||
}
|
||||
|
||||
/// <summary>校验旧密码并修改本人密码,成功后需重新登录。</summary>
|
||||
public async Task<ApiResult> ChangePassword(ChangePasswordRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
await users.ChangePassword(request, cancellationToken);
|
||||
return ApiResult.Ok();
|
||||
}
|
||||
|
||||
/// <summary>查询授权账户的积分流水。</summary>
|
||||
public Task<UserPage<UserPointsRecordEntity>> GetUserPoints(UserRecordSearchRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return users.Points(request.UserName, request, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>查询授权账户的余额流水。</summary>
|
||||
public Task<UserPage<UserPaymentRecordEntity>> GetUserBalance(UserRecordSearchRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return users.Balance(request.UserName, request, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>管理员充值积分,或当前用户向其他账户转移自己的积分。</summary>
|
||||
public Task<UserFundsResponse> UpdateUserPoints(UserFundsRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return funds.Change(request, true, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>管理员充值或扣减余额,自动记录审计流水。</summary>
|
||||
public Task<UserFundsResponse> UpdateUserBalance(UserFundsRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return funds.Change(request, false, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>提交本人的充值备注。</summary>
|
||||
public Task<long> AddUserTopUpMsg(UserTopUpMessageRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return users.AddTopUpMessage(request, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>管理员填写充值备注的处理结果。</summary>
|
||||
public async Task<ApiResult> UpdateUserTopUpMsg(UserTopUpResultRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
await users.UpdateTopUpMessage(request, cancellationToken);
|
||||
return ApiResult.Ok();
|
||||
}
|
||||
|
||||
/// <summary>分页查询充值备注。</summary>
|
||||
public Task<UserPage<UserTopUpMessageEntity>> GetUserTopUpMsg(UserSearchRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return users.TopUpMessages(request, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>管理员维护代理充值二维码。</summary>
|
||||
public async Task<ApiResult> UpdateTopUpQrcode(UserTopUpQrcodeRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
await users.UpdateTopUpQrcode(request, cancellationToken);
|
||||
return ApiResult.Ok();
|
||||
}
|
||||
|
||||
/// <summary>获取代理充值二维码。</summary>
|
||||
public Task<string?> GetTopUpQrcode(string agentCode, CancellationToken cancellationToken)
|
||||
{
|
||||
return users.GetTopUpQrcode(agentCode, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>使用当前登录用户的积分为商铺续费。</summary>
|
||||
[HttpPost]
|
||||
public Task<ShopRenewResponse> ShopRenew([FromBody] ShopRenewRequest request, CancellationToken cancellationToken)
|
||||
public Task<ShopRenewResponse> ShopRenew(ShopRenewRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_currentUser.IsAuthenticated || string.IsNullOrWhiteSpace(_currentUser.UserName))
|
||||
if (!currentUser.IsAuthenticated || string.IsNullOrWhiteSpace(currentUser.UserName))
|
||||
{
|
||||
throw new UnauthorizedAccessException();
|
||||
|
||||
return _mediator.Send(new ShopRenewCommand(
|
||||
_currentUser.UserName, request.ShopId, request.RenewDuration, request.Remark), cancellationToken);
|
||||
}
|
||||
return mediator.Send(new ShopRenewCommand(currentUser.UserName, request.ShopId, request.RenewDuration, request.Remark), cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,11 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
namespace Store.Users.Dtos;
|
||||
|
||||
namespace Store.Users.Dtos
|
||||
/// <summary>续费请求;仅声明店铺ID非空,时长和长度由服务或命令处理。</summary>
|
||||
public class ShopRenewRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户续费商铺
|
||||
/// </summary>
|
||||
public class ShopRenewRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 店铺ID
|
||||
/// </summary>
|
||||
public string ShopId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 续费时长数值,例如 3;范围 1-120 个月
|
||||
/// </summary>
|
||||
public int RenewDuration { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注
|
||||
/// </summary>
|
||||
public string Remark { get; set; }
|
||||
}
|
||||
[Required(ErrorMessage = "店铺ID不能为空")] public string ShopId { get; set; } = "";
|
||||
/// <summary>续费时长,单位月,合法范围1-120。</summary>
|
||||
public int RenewDuration { get; set; }
|
||||
public string? Remark { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using FreeSql.DataAnnotations;
|
||||
namespace Store.Users.Entities;
|
||||
|
||||
/// <summary>用户提交的积分充值备注。处理结果仅由管理员填写,不自动变更资金。</summary>
|
||||
[Table(Name = "wxxd.user_points_top_up_msg")]
|
||||
public sealed class UserTopUpMessageEntity
|
||||
{
|
||||
[Column(Name = "id", IsPrimary = true, IsIdentity = true)] public long Id { get; set; }
|
||||
[Column(Name = "user_name")] public string UserName { get; set; } = "";
|
||||
[Column(Name = "nick_name")] public string NickName { get; set; } = "";
|
||||
[Column(Name = "msg")] public string Message { get; set; } = "";
|
||||
[Column(Name = "op_rst")] public string? Result { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>按代理编码保存的充值二维码;代理编码沿用旧表的唯一键。</summary>
|
||||
[Table(Name = "wxxd.user_top_up_qrcode")]
|
||||
public sealed class UserTopUpQrcodeEntity
|
||||
{
|
||||
[Column(Name = "agent_code", IsPrimary = true)] public string AgentCode { get; set; } = "";
|
||||
[Column(Name = "qrcode_base64")] public string QrcodeBase64 { get; set; } = "";
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
using Riok.Mapperly.Abstractions;
|
||||
using Riok.Mapperly.Abstractions;
|
||||
using Store.Users.Contracts.Dtos;
|
||||
using Store.Users.Entities;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace Store.Users.Mappers
|
||||
namespace Store.Users.Mappers;
|
||||
|
||||
/// <summary>账户输出映射;任何查询路径均不携带密码值。</summary>
|
||||
[Mapper]
|
||||
internal partial class UserMapper
|
||||
{
|
||||
[Mapper]
|
||||
internal partial class UserMapper
|
||||
{
|
||||
public partial UserInfo ToInfo(UserEntity entity);
|
||||
}
|
||||
[MapperIgnoreSource(nameof(UserEntity.Password))]
|
||||
[MapperIgnoreTarget(nameof(UserInfo.Password))]
|
||||
public partial UserInfo ToInfo(UserEntity entity);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
# 用户模块迁移与运行
|
||||
|
||||
本模块参考 `D:/Works/KJD_WebService/KJD_WebService/BLL/WXXD/WXUserManage.cs` 重写,保留旧表映射,采用 ASP.NET Core 控制器、DTO、服务、FreeSql 仓储和现有 JWT 身份体系。所有新代码均含中文用途或关键规则注释。
|
||||
|
||||
## 文件与职责
|
||||
|
||||
- 文件:`Controllers/UserController.cs`:`/user/[action]` 接口,统一要求 Bearer Token。
|
||||
- 文件:`Services/UserManagementService.cs`:主子账号管理、权限检查、分页、密码修改和流水查询。
|
||||
- 文件:`Services/UserManagementService.TopUp.cs`:充值备注与代理二维码。
|
||||
- 文件:`Services/UserFundsService.cs`:积分充值、积分转移、余额充值/消费;事务、行锁、请求去重与流水写入。
|
||||
- 文件:`Services/UserQueryService.cs`、`Repositories/UserRepository.cs`:认证和查询、主子账号有效配置继承。
|
||||
- 文件:`Services/UserPassword.cs`:带盐哈希密码与旧明文密码兼容。
|
||||
- 文件:`../Store.Users.Contracts/Dtos/UserManagementRequests.cs`、`UserFundsRequest.cs`、`UserTopUpRequests.cs`:输入验证与响应契约。
|
||||
- 文件:`Database/prepare-user-module.sql`:上线前密码列、唯一用户名、事务引擎及请求号列检查。
|
||||
- 文件:`../../Store.API/Controllers/AuthController.cs`:登录签发 JWT 前调用真实密码验证,不再接受任意密码。
|
||||
- 文件:`../../Tests/UserManagementSmoke/Program.cs`:临时 SQLite 集成验证,避免访问业务数据库。
|
||||
|
||||
## 接口
|
||||
|
||||
HTTP 方法约定:控制器通过 [DefaultHttpPost] 启用默认 POST。所有操作只接受 POST,无需逐个标记 [HttpPost]。该约定同时生成路由约束和端点元数据,接口文档也显示 POST。
|
||||
|
||||
| 方法 | 路径 | 访问与行为 |
|
||||
| --- | --- | --- |
|
||||
| POST | /user/GetUser?userName=... | 本人、直属主账号或管理员;省略用户名读取本人 |
|
||||
| POST | /user/GetAllUser | 管理员分页查询,支持 Keyword、GroupId |
|
||||
| POST | /user/GetSubUser | 当前主账号直属子账号分页查询 |
|
||||
| POST | /user/AddUser | 管理员新增主账号 |
|
||||
| POST | /user/AddSubUser | 当前主账号新增直属子账号 |
|
||||
| POST | /user/UpdateUser | 管理员修改资料 |
|
||||
| POST | /user/UpdateSubUser | 主账号修改直属子账号 |
|
||||
| POST | /user/DelUser?id=... | 管理员或直属主账号删除空账户 |
|
||||
| POST | /user/ChangePassword | 本人校验旧密码后修改,会话立即失效 |
|
||||
| POST | /user/GetUserPoints | 授权账户积分流水,支持起止时间、操作类型、店铺过滤 |
|
||||
| POST | /user/GetUserBalance | 授权账户余额流水,支持起止时间、支付类型、订单过滤 |
|
||||
| POST | /user/UpdateUserPoints | 管理员充值;本人向其他账户转移自己的积分 |
|
||||
| POST | /user/UpdateUserBalance | 管理员充值或消费调整 |
|
||||
| POST | /user/AddUserTopUpMsg | 本人提交充值备注 |
|
||||
| POST | /user/UpdateUserTopUpMsg | 管理员填写处理结果,不自动充值 |
|
||||
| POST | /user/GetUserTopUpMsg | 本人备注或管理员查看全部 |
|
||||
| POST | /user/UpdateTopUpQrcode | 管理员维护代理二维码 |
|
||||
| POST | /user/GetTopUpQrcode?agentCode=... | 登录用户读取代理二维码 |
|
||||
| POST | /user/ShopRenew | 保留原有 MediatR 事务续费用例 |
|
||||
|
||||
分页查询与流水查询通过 JSON 请求体提交;流水查询的 userName 与筛选字段放在同一对象中。UpdateUser、UpdateSubUser 使用 JSON 请求体,例如 `{"id":123,"nickName":"新昵称"}`。仅有单个业务参数的 GetUser、DelUser、GetTopUpQrcode 仍使用查询参数。
|
||||
|
||||
分页 Page 从 1 开始,PageSize 默认 50,最大 200。流水时间参数为 StartTime、EndTime(包含边界)。资料更新 null 表示保留原值,空字符串清空文本字段。账户名不可修改,以免破坏历史关联。SaLevel 仅管理员可设置为 0/1,子账号不能成为管理员。普通主账号不能授予自己没有的权限,也不能修改计费配置。子账号积分用户权限、OEM 和每单费用继承主账号,保留 2.2/2.5 元费用下限。
|
||||
|
||||
资金请求示例:
|
||||
|
||||
```json
|
||||
{"userName":"target","requestId":"客户端生成的唯一请求号","amount":100,"operation":"充值","remark":"人工充值已核实"}
|
||||
```
|
||||
|
||||
积分 Operation 支持“充值”“转移”;余额支持“充值”“消费”。Amount 始终为正数,最多两位小数。转移仅从当前登录账户转出,不支持负金额拉取对方积分。相同目标账户和资金类型下,重复 RequestId 返回已处理错误,不再次记账;调用失败重试必须保留请求号。不同请求号视作不同业务,调用方需确保业务唯一性。积分转入累计充值口径沿用旧系统。余额流水类型沿用旧系统充值 1、人工消费 0;商铺续费继续走专用接口。
|
||||
|
||||
## 运行与部署
|
||||
|
||||
1. 检查现有 MySQL `DefaultConnection` 配置和已有表。确认 `wxxd.user` 中存在可登录的 `sa_level=1` 管理员。
|
||||
2. 审阅并执行 `Database/prepare-user-module.sql`。应用不自动建表或改表;资金表必须是 InnoDB,用户名必须有唯一索引。
|
||||
3. 构建:`dotnet build Store.API/Store.Api.csproj`。
|
||||
4. 验证:`dotnet run --project Tests/UserManagementSmoke/UserManagementSmoke.csproj`,以及原有 `dotnet run --project Tests/ShopRenewSmoke/ShopRenewSmoke.csproj`。
|
||||
5. 启动:`dotnet run --project Store.API/Store.Api.csproj`。先调用宿主 `/api/v1.0/Auth/Login`,再携带 Bearer Token 调用用户接口。
|
||||
|
||||
认证兼容 WXUserManage 的旧明文密码和 Identity 带盐哈希,登录时仅验证,不修改密码;新建和修改密码直接保存哈希。旧项目按 SQL 明文比较密码的登录接口无法识别哈希密码,共用数据库时需同步切换相关调用方。新接口采用结构化 JSON,不兼容旧 inputData 拼接字符串及 time/code 签名。UserInfo 保留 Password 属性用于源码兼容,但不映射值、不向 JSON 序列化。
|
||||
|
||||
## 迁移边界与验证限制
|
||||
|
||||
旧文件中的 E57 第三方令牌、微信销售助手 Cookie、淘宝买手管理属于外部系统或独立业务,未加入用户账户模块。本次未修改旧项目或调用第三方服务。
|
||||
|
||||
集成验证使用临时 SQLite 库,覆盖认证、登录不修改密码、主子账号、越权、计费、资金写入、重复请求、超扣、强制流水失败的事务回滚、删除边界、备注和二维码更新。MySQL 的真实并发、行锁、历史表结构和旧客户端迁移仍需在测试库联调。可使用“代码审查”继续检查实现质量。
|
||||
|
||||
## 输入校验约定
|
||||
|
||||
User 模块的请求 DTO 只保留 `[Required]`,校验必填、null、空字符串和纯空白字符串。服务使用 `MAX.Framework.Validation.RequestValidator.Validate(request)` 统一执行这类非空校验,防止内部调用绕过 HTTP 模型验证。
|
||||
|
||||
```csharp
|
||||
using MAX.Framework.Validation;
|
||||
|
||||
RequestValidator.Validate(request);
|
||||
// 非空之外的规则由服务继续手动判断。
|
||||
```
|
||||
|
||||
长度、数值范围、金额精度、操作类型、字段组合、日期顺序和二维码 Base64 格式在服务层手动检查。可复用检查集中于 `Services/UserInputChecks.cs`,资金金额与操作类型直接由 UserFundsService 判断;续费的非空输入由 ShopRenewRequest 声明,其余规则通过命令验证管道调用服务层检查后执行事务。
|
||||
|
||||
权限、账户归属、数据库记录是否存在、余额是否足够、重复请求等业务规则继续在服务或业务管道中判断。不合法的参数抛出 ValidationException,权限和业务错误沿用现有业务异常;现有过滤器将它们转换为 HTTP 200 和对应业务码。
|
||||
|
||||
公共 RequestValidator 位于 `Core/MAX.Framework/Validation/RequestValidator.cs`。它仍支持标准 DataAnnotations 及 IValidatableObject,其他模块可以直接复用;User 模块“DTO 仅非空”的约定不改变公共校验器能力。该工具不递归验证嵌套对象。
|
||||
@@ -1,50 +1,31 @@
|
||||
using FreeSql;
|
||||
using FreeSql;
|
||||
using MAX.Core.DependencyInjection;
|
||||
using Store.Users.Contracts.Dtos;
|
||||
using Store.Users.Entities;
|
||||
using Store.Users.Mappers;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
namespace Store.Users.Repositories;
|
||||
|
||||
namespace Store.Users.Repositories
|
||||
public interface IUserRepository : IBaseRepository<UserEntity>
|
||||
{
|
||||
public interface IUserRepository : IBaseRepository<UserEntity>
|
||||
{
|
||||
Task<UserEntity> GetUser(string userName);
|
||||
}
|
||||
internal class UserRepository : BaseRepository<UserEntity>, IUserRepository, ITransientDependency
|
||||
{
|
||||
private readonly IFreeSql _freeSql;
|
||||
Task<UserEntity> GetUser(string userName);
|
||||
}
|
||||
|
||||
public UserRepository(IFreeSql freeSql) : base(freeSql)
|
||||
{
|
||||
_freeSql = freeSql;
|
||||
}
|
||||
|
||||
public async Task<UserEntity> GetUser(string userName)
|
||||
{
|
||||
var sql = Where(a => a.UserName == userName).ToSql();
|
||||
var user = await Where(a => a.UserName == userName).FirstAsync();
|
||||
if (user != null)
|
||||
{
|
||||
if (user.IsMainUser == false)
|
||||
{
|
||||
var mainUser = await Where(a => a.UserName == user.ParentUserName).FirstAsync();
|
||||
if (mainUser != null)
|
||||
{
|
||||
user.OemId = mainUser.OemId;
|
||||
user.PostagePerOrder = mainUser.PostagePerOrder;
|
||||
user.RefundPostagePerOrder = mainUser.RefundPostagePerOrder;
|
||||
user.GiftOemAmountPerOrder = mainUser.GiftOemAmountPerOrder;
|
||||
user.SyncLogisticsPerOrder = mainUser.SyncLogisticsPerOrder;
|
||||
}
|
||||
}
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// <summary>集中处理旧系统主子账号的有效计费配置和积分权限继承。</summary>
|
||||
internal class UserRepository(IFreeSql freeSql) : BaseRepository<UserEntity>(freeSql), IUserRepository, ITransientDependency
|
||||
{
|
||||
public async Task<UserEntity> GetUser(string userName)
|
||||
{
|
||||
var user = await Where(u => u.UserName == userName).FirstAsync();
|
||||
if (user == null || user.IsMainUser) return user;
|
||||
var parent = await Where(u => u.UserName == user.ParentUserName).FirstAsync();
|
||||
if (parent == null) throw new MAX.Framework.Commons.AppException("主账号不存在,请联系管理员");
|
||||
user.OemId = parent.OemId;
|
||||
user.PostagePerOrder = Math.Max(2.2m, parent.PostagePerOrder ?? 0);
|
||||
user.RefundPostagePerOrder = Math.Max(2.5m, parent.RefundPostagePerOrder ?? 0);
|
||||
user.GiftOemAmountPerOrder = Math.Max(0, parent.GiftOemAmountPerOrder ?? 0);
|
||||
user.SyncLogisticsPerOrder = parent.SyncLogisticsPerOrder;
|
||||
var authority = (user.Authority ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToHashSet();
|
||||
var parentAuthority = (parent.Authority ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parentAuthority.Contains("积分用户")) authority.Add("积分用户"); else authority.Remove("积分用户");
|
||||
user.Authority = string.Join(",", authority);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using MAX.Framework.Validation;
|
||||
using FreeSql;
|
||||
using MAX.Core.DependencyInjection;
|
||||
using MAX.Framework.Commons;
|
||||
using MAX.Framework.Security;
|
||||
using Store.Users.Contracts.Dtos;
|
||||
using Store.Users.Entities;
|
||||
namespace Store.Users.Services;
|
||||
|
||||
/// <summary>资金写入集中在事务中完成。账户行锁防止超扣,流水与余额同提交、同回滚。</summary>
|
||||
public sealed class UserFundsService(IFreeSql orm, ICurrentUser currentUser) : ITransientDependency
|
||||
{
|
||||
public async Task<UserFundsResponse> Change(UserFundsRequest request, bool points, CancellationToken ct)
|
||||
{
|
||||
RequestValidator.Validate(request);
|
||||
// DTO 只检查非空,其余长度、金额和操作组合在服务中手动判断。
|
||||
UserInputChecks.Length(request.UserName, 20, "目标账户");
|
||||
UserInputChecks.Length(request.RequestId, 64, "请求号");
|
||||
UserInputChecks.Length(request.Remark, 255, "备注");
|
||||
if (request.Amount is < 0.01m or > 1000000m)
|
||||
throw new ValidationException("金额必须为0.01-1000000");
|
||||
if (decimal.Round(request.Amount, 2) != request.Amount)
|
||||
throw new ValidationException("金额最多保留两位小数");
|
||||
if (points ? request.Operation is not ("充值" or "转移") : request.Operation is not ("充值" or "消费"))
|
||||
throw new ValidationException("不支持的资金操作类型");
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (!currentUser.IsAuthenticated || string.IsNullOrWhiteSpace(currentUser.UserName)) throw new UnauthorizedAccessException();
|
||||
using var work = orm.CreateUnitOfWork();
|
||||
work.GetOrBeginTransaction();
|
||||
var db = work.Orm;
|
||||
var actor = await db.Select<UserEntity>().Where(u => u.UserName == currentUser.UserName).FirstAsync(ct)
|
||||
?? throw new UnauthorizedAccessException();
|
||||
bool transfer = points && request.Operation == "转移";
|
||||
if (!transfer && actor.SaLevel != 1) throw new AppException("充值和余额调整仅允许管理员操作", 403);
|
||||
var name = request.UserName.Trim();
|
||||
var target = await db.Select<UserEntity>().Where(u => u.UserName == name).FirstAsync(ct)
|
||||
?? throw new AppException("目标账户不存在");
|
||||
if (transfer && actor.Id == target.Id) throw new AppException("不能向自己转移积分");
|
||||
// 双账户转移按相同主键顺序加锁,避免双方同时转账时产生反向锁顺序。
|
||||
var ids = transfer ? new[] { actor.Id, target.Id }.Order().ToArray() : new[] { target.Id };
|
||||
foreach (var id in ids)
|
||||
{
|
||||
var locked = await db.Select<UserEntity>().Where(u => u.Id == id).ForUpdate().FirstAsync(ct)
|
||||
?? throw new AppException("账户已被删除");
|
||||
if (id == target.Id) target = locked;
|
||||
if (id == actor.Id) actor = locked;
|
||||
}
|
||||
var key = "user:" + request.RequestId.Trim();
|
||||
// 持有目标行锁后检查请求号,相同账户的并发重试只能执行一次。
|
||||
bool duplicate = points
|
||||
? await db.Select<UserPointsRecordEntity>().Where(r => r.UserName == target.UserName && r.OutTradeNo == key).AnyAsync(ct)
|
||||
: await db.Select<UserPaymentRecordEntity>().Where(r => r.UserName == target.UserName && r.RefundFlowNo == key).AnyAsync(ct);
|
||||
if (duplicate) throw new AppException("该请求已处理,请查询流水确认结果,勿重复提交");
|
||||
decimal value;
|
||||
if (points)
|
||||
{
|
||||
value = checked(target.Points + request.Amount);
|
||||
if (transfer)
|
||||
{
|
||||
if (actor.Points < request.Amount) throw new AppException("积分不足");
|
||||
if (await db.Update<UserEntity>().Set(u => u.Points, actor.Points - request.Amount)
|
||||
.Where(u => u.Id == actor.Id && u.Points == actor.Points).ExecuteAffrowsAsync(ct) != 1)
|
||||
throw new AppException("转出账户状态已改变");
|
||||
await AddPointsRecord(db, actor, actor, request.Amount, actor.Points - request.Amount,
|
||||
"转出", target.UserName, key, request.Remark, ct);
|
||||
}
|
||||
var update = db.Update<UserEntity>().Set(u => u.Points, value)
|
||||
.Where(u => u.Id == target.Id && u.Points == target.Points);
|
||||
// 延续原系统口径:积分转入也计入接收方累计充值。
|
||||
update = update.Set(u => u.RechargePointsTotal, checked((target.RechargePointsTotal ?? 0) + request.Amount));
|
||||
if (await update.ExecuteAffrowsAsync(ct) != 1) throw new AppException("账户状态已改变");
|
||||
await AddPointsRecord(db, target, actor, request.Amount, value, transfer ? "转入" : "充值",
|
||||
transfer ? actor.UserName : null, key, request.Remark, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
bool recharge = request.Operation == "充值";
|
||||
value = checked((target.Balance ?? 0) + (recharge ? request.Amount : -request.Amount));
|
||||
if (value < 0) throw new AppException("余额不足");
|
||||
var update = db.Update<UserEntity>().Set(u => u.Balance, value).Where(u => u.Id == target.Id && u.Balance == target.Balance);
|
||||
if (recharge) update = update.Set(u => u.RechargeAmountTotal, checked((target.RechargeAmountTotal ?? 0) + request.Amount));
|
||||
if (await update.ExecuteAffrowsAsync(ct) != 1) throw new AppException("账户状态已改变");
|
||||
if (await db.Insert(new UserPaymentRecordEntity
|
||||
{
|
||||
UserName = target.UserName, Type = recharge ? 1 : 0, Amount = request.Amount, Balance = value,
|
||||
CreateBy = actor.UserName, CreateTime = DateTime.Now, Remark = request.Remark ?? "",
|
||||
PlatformCode = "", OrderId = "", UpdateBy = "", CounterpartyAccountName = "", RefundFlowNo = key
|
||||
}).ExecuteAffrowsAsync(ct) != 1) throw new AppException("写入余额流水失败");
|
||||
}
|
||||
ct.ThrowIfCancellationRequested();
|
||||
work.Commit();
|
||||
return new(target.UserName, value, request.RequestId);
|
||||
}
|
||||
|
||||
private static async Task AddPointsRecord(IFreeSql db, UserEntity user, UserEntity actor, decimal amount,
|
||||
decimal value, string operation, string? counterparty, string key, string? remark, CancellationToken ct)
|
||||
{
|
||||
if (await db.Insert(new UserPointsRecordEntity
|
||||
{
|
||||
UserName = user.UserName, UserNick = user.NickName, DeltaPoints = amount, NewPoints = value,
|
||||
OptionType = operation, OperatorType = actor.SaLevel == 1 ? "管理员" : "用户", OperatorName = actor.UserName,
|
||||
CounterpartyAccountName = counterparty, OutTradeNo = key, Remark = remark, AddTime = DateTime.Now
|
||||
}).ExecuteAffrowsAsync(ct) != 1) throw new AppException("写入积分流水失败");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Store.Users.Contracts.Dtos;
|
||||
using Store.Users.Dtos;
|
||||
|
||||
namespace Store.Users.Services;
|
||||
|
||||
/// <summary>User 服务共用的手动参数检查。DTO 只保留非空规则,其余检查在服务层执行。</summary>
|
||||
internal static class UserInputChecks
|
||||
{
|
||||
public static void Length(string? value, int max, string name, int min = 0)
|
||||
{
|
||||
if (value != null && (value.Length < min || value.Length > max))
|
||||
throw new ValidationException(min > 0 ? $"{name}长度必须为{min}-{max}个字符" : $"{name}长度不能超过{max}个字符");
|
||||
}
|
||||
|
||||
public static void PositiveId(long id)
|
||||
{
|
||||
if (id <= 0) throw new ValidationException("ID必须大于0");
|
||||
}
|
||||
|
||||
private static void Group(byte? group)
|
||||
{
|
||||
if (group.HasValue && group.Value is not (3 or 4 or 5 or 8 or 9 or 10 or 11))
|
||||
throw new ValidationException("用户分组必须为3、4、5、8、9、10或11");
|
||||
}
|
||||
|
||||
public static void Edit(UserEditRequest request)
|
||||
{
|
||||
Length(request.NickName, 50, "昵称");
|
||||
Length(request.Mobile, 30, "手机号");
|
||||
Length(request.CompanyName, 100, "公司名称");
|
||||
Length(request.Notes, 255, "备注");
|
||||
Length(request.CardId, 50, "卡号");
|
||||
Length(request.Authority, 1000, "权限");
|
||||
Length(request.ForbiddenPage, 1000, "禁止页面");
|
||||
Length(request.OemId, 50, "OEM编号");
|
||||
Group(request.GroupId);
|
||||
if (request.SaLevel.HasValue && request.SaLevel.Value is not (0 or 1))
|
||||
throw new ValidationException("管理员级别只能为0或1");
|
||||
if (request.ShopPointsPerMonth < 30) throw new ValidationException("每月续费积分不能低于30");
|
||||
if (request.PostagePerOrder is < 2.2m or > 999999m) throw new ValidationException("每单费用必须为2.2-999999元");
|
||||
if (request.RefundPostagePerOrder is < 2.5m or > 999999m) throw new ValidationException("退货费用必须为2.5-999999元");
|
||||
if (request.GiftOemAmountPerOrder is < 0m or > 999999m) throw new ValidationException("礼物附加费用必须为0-999999元");
|
||||
if (request.SyncLogisticsPerOrder is < 0m or > 999999m) throw new ValidationException("物流同步费用必须为0-999999元");
|
||||
}
|
||||
|
||||
public static void Search(UserSearchRequest request)
|
||||
{
|
||||
if (request.Page is < 1 or > 1000000) throw new ValidationException("页码必须为1-1000000");
|
||||
if (request.PageSize is < 1 or > 200) throw new ValidationException("每页条数必须为1-200");
|
||||
Length(request.Keyword, 100, "查询关键字");
|
||||
Group(request.GroupId);
|
||||
}
|
||||
|
||||
public static void Records(UserRecordSearchRequest request)
|
||||
{
|
||||
Search(request);
|
||||
Length(request.Operation, 20, "操作类型");
|
||||
Length(request.ShopId, 50, "店铺ID");
|
||||
Length(request.OrderId, 100, "订单ID");
|
||||
if (request.StartTime.HasValue && request.EndTime.HasValue && request.StartTime > request.EndTime)
|
||||
throw new ValidationException("开始时间不能晚于结束时间");
|
||||
}
|
||||
|
||||
public static void Renew(ShopRenewRequest request)
|
||||
{
|
||||
Length(request.ShopId.Trim(), 50, "店铺ID");
|
||||
if (request.RenewDuration is < 1 or > 120) throw new ValidationException("续费时长必须为1-120个月");
|
||||
Length(request.Remark?.Trim(), 255, "备注");
|
||||
}
|
||||
|
||||
public static void Qrcode(UserTopUpQrcodeRequest request)
|
||||
{
|
||||
Length(request.AgentCode, 50, "代理编码");
|
||||
Length(request.QrcodeBase64, 2 * 1024 * 1024, "二维码");
|
||||
var content = request.QrcodeBase64;
|
||||
if (content.StartsWith("data:image/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
int comma = content.IndexOf(',');
|
||||
if (comma < 0 || !content[..comma].EndsWith(";base64", StringComparison.OrdinalIgnoreCase))
|
||||
throw new ValidationException("二维码格式错误");
|
||||
content = content[(comma + 1)..];
|
||||
}
|
||||
bool valid;
|
||||
try { valid = Convert.FromBase64String(content).Length > 0; }
|
||||
catch (FormatException) { valid = false; }
|
||||
if (!valid) throw new ValidationException("二维码必须为有效的Base64数据");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using MAX.Framework.Validation;
|
||||
using MAX.Framework.Commons;
|
||||
using Store.Users.Contracts.Dtos;
|
||||
using Store.Users.Entities;
|
||||
namespace Store.Users.Services;
|
||||
|
||||
public sealed partial class UserManagementService
|
||||
{
|
||||
/// <summary>以本人身份提交充值备注,不能伪造提交账户或昵称。</summary>
|
||||
public async Task<long> AddTopUpMessage(UserTopUpMessageRequest request, CancellationToken ct)
|
||||
{
|
||||
RequestValidator.Validate(request);
|
||||
UserInputChecks.Length(request.Message, 1000, "充值备注");
|
||||
var actor = await Actor(orm, ct);
|
||||
return await orm.Insert(new UserTopUpMessageEntity
|
||||
{
|
||||
UserName = actor.UserName, NickName = actor.NickName, Message = request.Message.Trim()
|
||||
}).ExecuteIdentityAsync(ct);
|
||||
}
|
||||
|
||||
/// <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("充值备注不存在");
|
||||
await orm.Update<UserTopUpMessageEntity>().Set(m => m.Result, request.Result.Trim())
|
||||
.Where(m => m.Id == request.Id).ExecuteAffrowsAsync(ct);
|
||||
}
|
||||
|
||||
/// <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);
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>管理员维护代理充值二维码,使用旧表唯一代理编码原子更新或插入。</summary>
|
||||
public async Task UpdateTopUpQrcode(UserTopUpQrcodeRequest request, CancellationToken ct)
|
||||
{
|
||||
RequestValidator.Validate(request);
|
||||
UserInputChecks.Qrcode(request);
|
||||
var actor = await Actor(orm, ct);
|
||||
if (!IsAdmin(actor)) throw new AppException("仅管理员可以维护充值二维码", 403);
|
||||
await orm.InsertOrUpdate<UserTopUpQrcodeEntity>().SetSource(new UserTopUpQrcodeEntity
|
||||
{
|
||||
AgentCode = request.AgentCode.Trim(), QrcodeBase64 = request.QrcodeBase64
|
||||
}).ExecuteAffrowsAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>已认证用户按代理编码读取收款二维码。</summary>
|
||||
public async Task<string?> GetTopUpQrcode(string agentCode, CancellationToken ct)
|
||||
{
|
||||
RequestValidator.Validate(new UserTopUpQrcodeQuery { AgentCode = agentCode?.Trim()! });
|
||||
UserInputChecks.Length(agentCode.Trim(), 50, "代理编码");
|
||||
await Actor(orm, ct);
|
||||
var code = agentCode.Trim();
|
||||
var item = await orm.Select<UserTopUpQrcodeEntity>().Where(q => q.AgentCode == code).FirstAsync(ct);
|
||||
return item?.QrcodeBase64;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
using MAX.Framework.Validation;
|
||||
using FreeSql;
|
||||
using MAX.Core.DependencyInjection;
|
||||
using MAX.Framework.Commons;
|
||||
using MAX.Framework.Security;
|
||||
using Store.Users.Contracts;
|
||||
using Store.Users.Contracts.Dtos;
|
||||
using Store.Users.Entities;
|
||||
using Store.Users.Mappers;
|
||||
|
||||
namespace Store.Users.Services;
|
||||
|
||||
/// <summary>账户管理用例:所有操作从认证上下文确定操作者,禁止相信客户端传入的父账号或管理员标记。</summary>
|
||||
public sealed partial class UserManagementService(IFreeSql orm, ICurrentUser currentUser,
|
||||
IUserQueryService query, IOnlineUserStore onlineUsers) : ITransientDependency
|
||||
{
|
||||
private async Task<UserEntity> Actor(IFreeSql db, CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (!currentUser.IsAuthenticated || string.IsNullOrWhiteSpace(currentUser.UserName))
|
||||
throw new UnauthorizedAccessException();
|
||||
return await db.Select<UserEntity>().Where(u => u.UserName == currentUser.UserName).FirstAsync(ct)
|
||||
?? throw new UnauthorizedAccessException("登录账户不存在");
|
||||
}
|
||||
|
||||
private static bool IsAdmin(UserEntity actor) => actor.SaLevel == 1;
|
||||
private static bool Owns(UserEntity actor, UserEntity target) =>
|
||||
actor.IsMainUser && string.Equals(target.ParentUserName, actor.UserName, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>仅本人、直属主账号或超级管理员可以读取账户详情。</summary>
|
||||
public async Task<UserInfo> GetUser(string? userName, CancellationToken ct)
|
||||
{
|
||||
var actor = await Actor(orm, ct);
|
||||
var name = string.IsNullOrWhiteSpace(userName) ? actor.UserName : userName.Trim();
|
||||
RequestValidator.Validate(new UserNameRequest { UserName = name });
|
||||
UserInputChecks.Length(name, 20, "用户名");
|
||||
var target = await orm.Select<UserEntity>().Where(u => u.UserName == name).FirstAsync(ct)
|
||||
?? throw new AppException("用户不存在");
|
||||
if (!IsAdmin(actor) && actor.Id != target.Id && !Owns(actor, target))
|
||||
throw new AppException("无权访问该账户", 403);
|
||||
return await query.GetUser(target.UserName);
|
||||
}
|
||||
|
||||
/// <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);
|
||||
var select = orm.Select<UserEntity>();
|
||||
if (subUsersOnly) select = select.Where(u => u.ParentUserName == actor.UserName);
|
||||
if (request.GroupId.HasValue) select = select.Where(u => u.GroupId == request.GroupId.Value);
|
||||
if (!string.IsNullOrWhiteSpace(request.Keyword))
|
||||
{
|
||||
var keyword = request.Keyword.Trim();
|
||||
select = select.Where(u => u.UserName.Contains(keyword) || u.NickName.Contains(keyword)
|
||||
|| u.Mobile.Contains(keyword) || u.Notes.Contains(keyword));
|
||||
}
|
||||
var total = await select.CountAsync(ct);
|
||||
var items = await select.OrderByDescending(u => u.Id).Page(request.Page, request.PageSize).ToListAsync(ct);
|
||||
return new UserPage<UserInfo>(total, items.Select(u => new UserMapper().ToInfo(u)).ToList());
|
||||
}
|
||||
|
||||
/// <summary>创建账户并初始化余额。主账号仅由管理员创建,子账号的归属由服务端指定。</summary>
|
||||
public async Task<UserInfo> Create(UserCreateRequest request, bool subUser, CancellationToken ct)
|
||||
{
|
||||
RequestValidator.Validate(request);
|
||||
UserInputChecks.Length(request.UserName, 20, "用户名");
|
||||
UserInputChecks.Length(request.Password, 128, "密码", 6);
|
||||
UserInputChecks.Edit(request);
|
||||
|
||||
using var work = orm.CreateUnitOfWork();
|
||||
work.GetOrBeginTransaction();
|
||||
var db = work.Orm;
|
||||
var actor = await Actor(db, ct);
|
||||
// 锁住主账号,使其创建子账号和删除操作串行化。
|
||||
actor = await db.Select<UserEntity>().Where(u => u.Id == actor.Id).ForUpdate().FirstAsync(ct)
|
||||
?? throw new AppException("操作者账户不存在");
|
||||
if (subUser ? !actor.IsMainUser : !IsAdmin(actor)) throw new AppException("无权创建该账户", 403);
|
||||
var name = request.UserName.Trim();
|
||||
if (await db.Select<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,
|
||||
NickName = name, Mobile = "", CompanyName = "", Notes = "", CardId = "", Authority = "",
|
||||
ForbiddenPage = "", OemId = "", SaLevel = 0, ShopPointsPerMonth = 30,
|
||||
PostagePerOrder = 2.2m, RefundPostagePerOrder = 2.5m, GiftOemAmountPerOrder = 0,
|
||||
SyncLogisticsPerOrder = 0, Points = 0, Balance = 0, RechargePointsTotal = 0,
|
||||
RechargeAmountTotal = 0, AddTime = DateTime.Now, MdyTime = DateTime.Now
|
||||
};
|
||||
Apply(user, request, actor, subUser);
|
||||
await CheckOem(db, user, ct);
|
||||
user.Password = UserPassword.Hash(user, request.Password);
|
||||
user.Id = checked((int)await db.Insert(user).ExecuteIdentityAsync(ct));
|
||||
ct.ThrowIfCancellationRequested();
|
||||
work.Commit();
|
||||
return await query.GetUser(user.UserName);
|
||||
}
|
||||
|
||||
/// <summary>修改资料。登录名保持稳定,避免破坏主子账号、商铺和历史流水中的关联。</summary>
|
||||
public async Task<UserInfo> Update(int id, UserEditRequest request, bool subUser, CancellationToken ct)
|
||||
{
|
||||
UserInputChecks.PositiveId(id);
|
||||
RequestValidator.Validate(request);
|
||||
UserInputChecks.Edit(request);
|
||||
using var work = orm.CreateUnitOfWork();
|
||||
work.GetOrBeginTransaction();
|
||||
var db = work.Orm;
|
||||
var actor = await Actor(db, ct);
|
||||
var target = await db.Select<UserEntity>().Where(u => u.Id == id).ForUpdate().FirstAsync(ct)
|
||||
?? throw new AppException("用户不存在");
|
||||
if (subUser ? !Owns(actor, target) : !IsAdmin(actor)) throw new AppException("无权修改该账户", 403);
|
||||
Apply(target, request, actor, !target.IsMainUser);
|
||||
await CheckOem(db, target, ct);
|
||||
target.MdyTime = DateTime.Now;
|
||||
// 仅写入资料列,不把读取到的余额和积分覆盖回数据库。
|
||||
await db.Update<UserEntity>().SetSource(target).UpdateColumns(u => new
|
||||
{
|
||||
u.NickName, u.Mobile, u.CompanyName, u.Notes, u.CardId, u.GroupId, u.SaLevel, u.Authority, u.ForbiddenPage,
|
||||
u.OemId, u.ShopPointsPerMonth, u.PostagePerOrder, u.RefundPostagePerOrder,
|
||||
u.GiftOemAmountPerOrder, u.SyncLogisticsPerOrder, u.MdyTime
|
||||
}).ExecuteAffrowsAsync(ct);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
work.Commit();
|
||||
onlineUsers.SignOut(target.UserName);
|
||||
return await query.GetUser(target.UserName);
|
||||
}
|
||||
|
||||
/// <summary>只有无余额、无积分、无子账号且无商铺关联的账户才能删除,保留历史流水。</summary>
|
||||
public async Task Delete(int id, CancellationToken ct)
|
||||
{
|
||||
UserInputChecks.PositiveId(id);
|
||||
using var work = orm.CreateUnitOfWork();
|
||||
work.GetOrBeginTransaction();
|
||||
var db = work.Orm;
|
||||
var actor = await Actor(db, ct);
|
||||
var target = await db.Select<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 ((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("请先处理该账户的子账号");
|
||||
if (await db.Select<YourNamespace.ShopEntity>().Where(s => s.UserName == target.UserName).AnyAsync(ct))
|
||||
throw new AppException("账户仍有关联店铺,不能删除");
|
||||
if (await db.Delete<UserEntity>().Where(u => u.Id == id && u.Points == 0 && (u.Balance == null || u.Balance == 0))
|
||||
.ExecuteAffrowsAsync(ct) != 1) throw new AppException("账户状态已改变,请刷新后重试");
|
||||
ct.ThrowIfCancellationRequested();
|
||||
work.Commit();
|
||||
onlineUsers.SignOut(target.UserName);
|
||||
}
|
||||
|
||||
/// <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);
|
||||
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)
|
||||
throw new AppException("密码已发生变化,请重新登录");
|
||||
onlineUsers.SignOut(actor.UserName);
|
||||
}
|
||||
|
||||
/// <summary>按账户归属查询积分流水;分页与账户详情使用相同的访问范围。</summary>
|
||||
public async Task<UserPage<UserPointsRecordEntity>> Points(string? userName, UserRecordSearchRequest request, CancellationToken ct)
|
||||
{
|
||||
RequestValidator.Validate(request);
|
||||
UserInputChecks.Records(request);
|
||||
var user = await GetUser(userName, ct);
|
||||
var select = orm.Select<UserPointsRecordEntity>().Where(r => r.UserName == user.UserName);
|
||||
if (request.StartTime.HasValue) select = select.Where(r => r.AddTime >= request.StartTime.Value);
|
||||
if (request.EndTime.HasValue) select = select.Where(r => r.AddTime <= request.EndTime.Value);
|
||||
if (!string.IsNullOrWhiteSpace(request.Operation)) select = select.Where(r => r.OptionType == request.Operation);
|
||||
if (!string.IsNullOrWhiteSpace(request.ShopId)) select = select.Where(r => r.ShopId == request.ShopId);
|
||||
var total = await select.CountAsync(ct);
|
||||
var rows = await select.OrderByDescending(r => r.Id).Page(request.Page, request.PageSize).ToListAsync(ct);
|
||||
return new(total, rows);
|
||||
}
|
||||
|
||||
/// <summary>余额流水排除旧系统已标记删除的记录。</summary>
|
||||
public async Task<UserPage<UserPaymentRecordEntity>> Balance(string? userName, UserRecordSearchRequest request, CancellationToken ct)
|
||||
{
|
||||
RequestValidator.Validate(request);
|
||||
UserInputChecks.Records(request);
|
||||
var user = await GetUser(userName, ct);
|
||||
var select = orm.Select<UserPaymentRecordEntity>().Where(r => r.UserName == user.UserName && r.IsDeleted == 0);
|
||||
if (request.StartTime.HasValue) select = select.Where(r => r.CreateTime >= request.StartTime.Value);
|
||||
if (request.EndTime.HasValue) select = select.Where(r => r.CreateTime <= request.EndTime.Value);
|
||||
if (request.PaymentType.HasValue) select = select.Where(r => r.Type == request.PaymentType.Value);
|
||||
if (!string.IsNullOrWhiteSpace(request.OrderId)) select = select.Where(r => r.OrderId == request.OrderId);
|
||||
var total = await select.CountAsync(ct);
|
||||
var rows = await select.OrderByDescending(r => r.Id).Page(request.Page, request.PageSize).ToListAsync(ct);
|
||||
return new(total, rows);
|
||||
}
|
||||
|
||||
private static async Task CheckOem(IFreeSql db, UserEntity user, CancellationToken ct)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(user.OemId) && !await db.Select<OEMEntity>().Where(o => o.OemId == user.OemId).AnyAsync(ct))
|
||||
throw new AppException("OEM编号不存在");
|
||||
}
|
||||
|
||||
private static void Apply(UserEntity user, UserEditRequest request, UserEntity actor, bool subUser)
|
||||
{
|
||||
// 合法分组值已由服务参数检查保证,这里检查主/子账号身份是否匹配。
|
||||
if (request.GroupId.HasValue && (subUser ? request.GroupId == 3 : request.GroupId != 3))
|
||||
throw new AppException("主账号分组必须为3,子账号分组必须为4、5、8、9、10或11");
|
||||
// 普通主账号只能授予自己已有的权限,不能通过创建子账号提升权限。
|
||||
if (!IsAdmin(actor) && request.Authority != null)
|
||||
{
|
||||
var allowed = Split(actor.Authority);
|
||||
if (Split(request.Authority).Any(a => !allowed.Contains(a))) throw new AppException("不能授予超出自身范围的权限", 403);
|
||||
}
|
||||
if (!IsAdmin(actor) && (request.OemId != null || request.ShopPointsPerMonth != null
|
||||
|| request.PostagePerOrder != null || request.RefundPostagePerOrder != null
|
||||
|| request.GiftOemAmountPerOrder != null || request.SyncLogisticsPerOrder != null))
|
||||
throw new AppException("仅管理员可以修改计费配置", 403);
|
||||
if (request.SaLevel.HasValue)
|
||||
{
|
||||
if (!IsAdmin(actor) || (subUser && request.SaLevel != 0)) throw new AppException("无权设置管理员级别", 403);
|
||||
user.SaLevel = request.SaLevel;
|
||||
}
|
||||
user.NickName = request.NickName?.Trim() ?? user.NickName;
|
||||
user.Mobile = request.Mobile?.Trim() ?? user.Mobile;
|
||||
user.CompanyName = request.CompanyName?.Trim() ?? user.CompanyName;
|
||||
user.Notes = request.Notes ?? user.Notes;
|
||||
user.CardId = request.CardId ?? user.CardId;
|
||||
user.GroupId = request.GroupId ?? user.GroupId;
|
||||
user.Authority = request.Authority == null ? user.Authority : string.Join(",", Split(request.Authority));
|
||||
user.ForbiddenPage = request.ForbiddenPage ?? user.ForbiddenPage;
|
||||
user.OemId = request.OemId?.Trim() ?? user.OemId;
|
||||
user.ShopPointsPerMonth = request.ShopPointsPerMonth ?? user.ShopPointsPerMonth;
|
||||
user.PostagePerOrder = request.PostagePerOrder ?? user.PostagePerOrder;
|
||||
user.RefundPostagePerOrder = request.RefundPostagePerOrder ?? user.RefundPostagePerOrder;
|
||||
user.GiftOemAmountPerOrder = request.GiftOemAmountPerOrder ?? user.GiftOemAmountPerOrder;
|
||||
user.SyncLogisticsPerOrder = request.SyncLogisticsPerOrder ?? user.SyncLogisticsPerOrder;
|
||||
}
|
||||
|
||||
private static HashSet<string> Split(string? value) => (value ?? "").Split(',',
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToHashSet(StringComparer.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Store.Users.Entities;
|
||||
namespace Store.Users.Services;
|
||||
|
||||
/// <summary>新密码使用带盐哈希;兼容旧 wxxd.user 的明文密码,登录验证不修改密码。</summary>
|
||||
public static class UserPassword
|
||||
{
|
||||
private static readonly PasswordHasher<UserEntity> Hasher = new();
|
||||
public static string Hash(UserEntity user, string password) => Hasher.HashPassword(user, password);
|
||||
public static bool Verify(UserEntity user, string password)
|
||||
{
|
||||
if (string.IsNullOrEmpty(user.Password) || string.IsNullOrEmpty(password)) return false;
|
||||
// Identity 的密码以固定的版本字节开头,格式损坏时拒绝验证。
|
||||
if (user.Password.StartsWith("AQAAAA", StringComparison.Ordinal) || user.Password.StartsWith("AA", StringComparison.Ordinal))
|
||||
{
|
||||
try { return Hasher.VerifyHashedPassword(user, user.Password, password) != PasswordVerificationResult.Failed; }
|
||||
catch (FormatException) { return false; }
|
||||
}
|
||||
return System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(
|
||||
System.Text.Encoding.UTF8.GetBytes(user.Password), System.Text.Encoding.UTF8.GetBytes(password));
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,35 @@
|
||||
using MAX.Core.DependencyInjection;
|
||||
using MAX.Framework.Validation;
|
||||
using MAX.Core.DependencyInjection;
|
||||
using MAX.Framework.Commons;
|
||||
using Store.Users.Contracts;
|
||||
using Store.Users.Contracts.Dtos;
|
||||
using Store.Users.Entities;
|
||||
using Store.Users.Mappers;
|
||||
using Store.Users.Repositories;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
namespace Store.Users.Services;
|
||||
|
||||
namespace Store.Users.Services
|
||||
internal sealed class UserQueryService(IUserRepository repository) : IUserQueryService, ITransientDependency
|
||||
{
|
||||
internal class UserQueryService : IUserQueryService, ITransientDependency
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
public async Task<UserInfo> GetUser(string userName)
|
||||
{
|
||||
RequestValidator.Validate(new UserNameRequest { UserName = userName?.Trim()! });
|
||||
UserInputChecks.Length(userName.Trim(), 20, "用户名");
|
||||
var user = await repository.GetUser(userName.Trim()) ?? throw new AppException("用户不存在");
|
||||
return new UserMapper().ToInfo(user);
|
||||
}
|
||||
}
|
||||
|
||||
public UserQueryService(IUserRepository userRepository)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
}
|
||||
public async Task<UserInfo> GetUser(string userName)
|
||||
{
|
||||
var sql = _userRepository.Where(a => a.UserName == userName).ToSql();
|
||||
var user = await _userRepository.Where(a => a.UserName == userName).FirstAsync();
|
||||
if (user != null)
|
||||
{
|
||||
if (user.IsMainUser == false)
|
||||
{
|
||||
var mainUser = await _userRepository.Where(a => a.UserName == user.ParentUserName).FirstAsync();
|
||||
if (mainUser != null)
|
||||
{
|
||||
user.OemId = mainUser.OemId;
|
||||
user.PostagePerOrder = mainUser.PostagePerOrder;
|
||||
user.RefundPostagePerOrder = mainUser.RefundPostagePerOrder;
|
||||
user.GiftOemAmountPerOrder = mainUser.GiftOemAmountPerOrder;
|
||||
user.SyncLogisticsPerOrder = mainUser.SyncLogisticsPerOrder;
|
||||
}
|
||||
}
|
||||
}
|
||||
return new UserMapper().ToInfo(user);
|
||||
}
|
||||
}
|
||||
internal sealed class UserAuthenticationService(IFreeSql orm, IUserQueryService query)
|
||||
: IUserAuthenticationService, ITransientDependency
|
||||
{
|
||||
public async Task<UserInfo?> Authenticate(string userName, string password, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (!RequestValidator.TryValidate(new UserLoginRequest { UserName = userName?.Trim()!, Password = password }, out _))
|
||||
return null;
|
||||
if (userName.Trim().Length > 20 || password.Length > 128) return null;
|
||||
var user = await orm.Select<UserEntity>().Where(u => u.UserName == userName.Trim()).FirstAsync(cancellationToken);
|
||||
if (user == null || !UserPassword.Verify(user, password)) return null;
|
||||
return await query.GetUser(user.UserName);
|
||||
}
|
||||
}
|
||||
@@ -11,4 +11,8 @@
|
||||
<ProjectReference Include="..\Store.Users.Contracts\Store.Users.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Conventions\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in new issue
Block a user