247 lines
15 KiB
C#
247 lines
15 KiB
C#
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);
|
|
}
|