diff --git a/src/Oms.Application/Dtos/Customers/CustomerPaginatedOutput.cs b/src/Oms.Application/Dtos/Customers/CustomerPaginatedOutput.cs index 6ddc01a..cdcceb3 100644 --- a/src/Oms.Application/Dtos/Customers/CustomerPaginatedOutput.cs +++ b/src/Oms.Application/Dtos/Customers/CustomerPaginatedOutput.cs @@ -7,6 +7,11 @@ namespace Oms.Application.Dtos.Customers; /// public class CustomerPaginatedOutput { + /// + /// 主键。 + /// + public int Id { get; set; } + /// /// 父客户ID,空表示主客户。 /// diff --git a/src/Oms.Application/Dtos/System/NoticeOutput.cs b/src/Oms.Application/Dtos/System/NoticeOutput.cs index 39a4350..058f368 100644 --- a/src/Oms.Application/Dtos/System/NoticeOutput.cs +++ b/src/Oms.Application/Dtos/System/NoticeOutput.cs @@ -46,10 +46,15 @@ public class NoticeOutput public string ReceiverTypeName { get; set; } = string.Empty; /// - /// 接收主体 ID 列表。 + /// 接收主体 ID 列表(持久化为 user.userid)。 /// public List ReceiverIds { get; set; } = []; + /// + /// 表单回显用选中 ID:user 与 一致;customer 为 t_customer.Id。 + /// + public List ReceiverSelectionIds { get; set; } = []; + /// /// 已读人数。 /// diff --git a/src/Oms.Application/Dtos/System/NoticeReceiverOptionOutput.cs b/src/Oms.Application/Dtos/System/NoticeReceiverOptionOutput.cs new file mode 100644 index 0000000..103556f --- /dev/null +++ b/src/Oms.Application/Dtos/System/NoticeReceiverOptionOutput.cs @@ -0,0 +1,22 @@ +namespace Oms.Application.Dtos.System; + +/// +/// 公告接收人候选项。 +/// +public class NoticeReceiverOptionOutput +{ + /// + /// 表单选中值:user 为 user.userid,customer 为 t_customer.Id。 + /// + public int Value { get; set; } + + /// + /// 展示名称。 + /// + public string Label { get; set; } = string.Empty; + + /// + /// 辅助说明(账号、客户编码等)。 + /// + public string? Extra { get; set; } +} diff --git a/src/Oms.Application/Dtos/System/NoticeReceiverOptionsInput.cs b/src/Oms.Application/Dtos/System/NoticeReceiverOptionsInput.cs new file mode 100644 index 0000000..be0f1ea --- /dev/null +++ b/src/Oms.Application/Dtos/System/NoticeReceiverOptionsInput.cs @@ -0,0 +1,27 @@ +namespace Oms.Application.Dtos.System; + +/// +/// 公告接收人候选项查询入参。 +/// +public class NoticeReceiverOptionsInput +{ + /// + /// 接收对象类型:user/customer。 + /// + public string ReceiverType { get; set; } = string.Empty; + + /// + /// 关键字。 + /// + public string? Keyword { get; set; } + + /// + /// 页码。 + /// + public int Page { get; set; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; set; } = 50; +} diff --git a/src/Oms.Application/Services/System/NoticeService.cs b/src/Oms.Application/Services/System/NoticeService.cs index faa8933..9393413 100644 --- a/src/Oms.Application/Services/System/NoticeService.cs +++ b/src/Oms.Application/Services/System/NoticeService.cs @@ -6,6 +6,7 @@ using Microsoft.EntityFrameworkCore; using Oms.Application.Dtos.Shared; using Oms.Application.Dtos.System; using Oms.Application.Shared; +using Oms.Domain.CustomersAggregate.Entities; using Oms.Domain.CustomersAggregate.Repositories; using Oms.Domain.SystemAggregate.Entities; using Oms.Domain.SystemAggregate.Repositories; @@ -21,6 +22,21 @@ public class NoticeService : ApplicationService private const string NoticeTypeNotice = "notice"; private const string NoticeTypeMessage = "message"; private const string ReceiverTypeUser = "user"; + private const string ReceiverTypeCustomer = "customer"; + + private const string InternalUserFilter = """ + ( + NOT EXISTS (SELECT 1 FROM `user_role` ur0 WHERE ur0.`UserId` = u.`Id`) + OR EXISTS ( + SELECT 1 + FROM `user_role` ur + INNER JOIN `role` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0 + WHERE ur.`UserId` = u.`Id` + AND r.`Code` <> 'Customer' + AND r.`Code` NOT LIKE 'CustomerSub\_%' + ) + ) + """; private INoticeRepository NoticeRepository => LazyServiceProvider.GetRequiredService(); private INoticeReadRepository NoticeReadRepository => LazyServiceProvider.GetRequiredService(); @@ -30,8 +46,6 @@ public class NoticeService : ApplicationService /// /// 查询通知列表。 /// - /// 入参:通知分页查询条件。 - /// 通知分页结果,包含通知主体、接收范围与已读统计。 [HttpGet("/system/notice/list")] public async Task> GetListAsync([FromQuery] NoticeListInput input) { @@ -55,20 +69,40 @@ public class NoticeService : ApplicationService x => x.Key, x => (int)x.Select(item => $"{item.ReceiverType}:{item.ReceiverId}").Distinct(StringComparer.Ordinal).Count()); + var selectionIdMap = await BuildReceiverSelectionIdMapAsync(notices); + return new OmsPageResult { Items = notices.Select(x => MapNoticeOutput( x, - readCountMap.TryGetValue(x.Id, out var readCount) ? readCount : 0)).ToList(), + readCountMap.TryGetValue(x.Id, out var readCount) ? readCount : 0, + selectionIdMap.TryGetValue(x.Id, out var selectionIds) ? selectionIds : [])).ToList(), Total = total }; } + /// + /// 接收人候选项(按接收对象类型加载)。 + /// + [HttpGet("/system/notice/receiver-options")] + public async Task> GetReceiverOptionsAsync([FromQuery] NoticeReceiverOptionsInput input) + { + var receiverType = NormalizeReceiverType(input.ReceiverType); + var pageIndex = OmsDocHelper.NormalizePage(input.Page); + var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); + var keyword = input.Keyword?.Trim(); + + if (receiverType == ReceiverTypeCustomer) + { + return await QueryCustomerReceiverOptionsAsync(keyword, pageIndex, pageSize); + } + + return await QueryUserReceiverOptionsAsync(keyword, pageIndex, pageSize); + } + /// /// 新增通知。 /// - /// 入参:通知新增参数。 - /// 无返回体,仅完成新增通知。 [HttpPost("/system/notice/create")] public async Task CreateAsync(NoticeCreateInput input) { @@ -88,8 +122,6 @@ public class NoticeService : ApplicationService /// /// 更新通知。 /// - /// 入参:通知更新参数。 - /// 无返回体,仅完成更新通知。 [HttpPost("/system/notice/update")] public async Task UpdateAsync(NoticeUpdateInput input) { @@ -106,7 +138,7 @@ public class NoticeService : ApplicationService var existsReads = await NoticeReadRepository.GetListAsync(x => x.NoticeId == entity.Id); var receiverIdSet = receiverIds.ToHashSet(); - foreach (var read in existsReads.Where(x => x.ReceiverType != entity.ReceiverType || !receiverIdSet.Contains(x.ReceiverId))) + foreach (var read in existsReads.Where(x => x.ReceiverType != ReceiverTypeUser || !receiverIdSet.Contains(x.ReceiverId))) { await NoticeReadRepository.DeleteAsync(read); } @@ -115,8 +147,6 @@ public class NoticeService : ApplicationService /// /// 删除通知。 /// - /// 入参:目标主键。 - /// 无返回体,仅完成删除通知。 [HttpPost("/system/notice/delete")] public async Task DeleteAsync(IdInput input) { @@ -127,8 +157,6 @@ public class NoticeService : ApplicationService /// /// 确认通知已读。 /// - /// 入参:目标通知主键。 - /// 无返回体,仅完成当前主体的已读确认。 [HttpPost("/system/notice/read-confirm")] public async Task ReadConfirmAsync(IdInput input) { @@ -141,8 +169,7 @@ public class NoticeService : ApplicationService throw new BusinessException("当前登录主体无效"); } - if (!string.Equals(notice.ReceiverType, currentReceiverType, StringComparison.OrdinalIgnoreCase) || - !receiverIds.Contains(currentReceiverId)) + if (!receiverIds.Contains(currentReceiverId)) { throw new BusinessException("当前主体不在通知接收范围内"); } @@ -162,6 +189,131 @@ public class NoticeService : ApplicationService }); } + private async Task> QueryUserReceiverOptionsAsync( + string? keyword, + int pageIndex, + int pageSize) + { + var where = $"WHERE u.`IsDeleted` = 0 AND {InternalUserFilter}"; + var args = new List(); + if (!string.IsNullOrWhiteSpace(keyword)) + { + var pattern = $"%{keyword}%"; + where += $" AND (u.`Name` LIKE {{{args.Count}}} OR u.`Account` LIKE {{{args.Count + 1}}})"; + args.Add(pattern); + args.Add(pattern); + } + + var countSql = $"SELECT COUNT(*) AS `Value` FROM `user` u {where}"; + var total = await RbacDbContext.Database.SqlQueryRaw(countSql, args.ToArray()).FirstAsync(); + var offset = Math.Max(pageIndex - 1, 0) * pageSize; + var listSql = $""" + SELECT + u.`userid` AS `Value`, + u.`Name` AS `Label`, + u.`Account` AS `Extra` + FROM `user` u + {where} + ORDER BY u.`Name`, u.`userid` + LIMIT {pageSize} OFFSET {offset} + """; + var items = await RbacDbContext.Database.SqlQueryRaw(listSql, args.ToArray()) + .Select(x => new NoticeReceiverOptionOutput + { + Value = x.Value, + Label = string.IsNullOrWhiteSpace(x.Label) ? $"用户{x.Value}" : x.Label.Trim(), + Extra = string.IsNullOrWhiteSpace(x.Extra) ? null : x.Extra.Trim() + }) + .ToListAsync(); + + return new OmsPageResult { Items = items, Total = total }; + } + + private async Task> QueryCustomerReceiverOptionsAsync( + string? keyword, + int pageIndex, + int pageSize) + { + Expression> predicate = x => x.ParentCustomerId == null; + if (!string.IsNullOrWhiteSpace(keyword)) + { + var pattern = keyword.Trim(); + predicate = x => + x.ParentCustomerId == null && + (x.CustomerName.Contains(pattern) || + x.CustomerCode.Contains(pattern) || + x.Phone.Contains(pattern)); + } + + var total = await CustomerRepository.CountAsync(predicate); + var customers = await CustomerRepository.GetPagedListAsync( + predicate, + pageIndex, + pageSize, + new Dictionary { [nameof(Customer.CustomerName)] = true, [nameof(Customer.Id)] = true }); + + return new OmsPageResult + { + Total = total, + Items = customers.Select(x => new NoticeReceiverOptionOutput + { + Value = x.Id, + Label = string.IsNullOrWhiteSpace(x.CustomerName) ? $"客户{x.Id}" : x.CustomerName.Trim(), + Extra = string.IsNullOrWhiteSpace(x.CustomerCode) ? x.Phone : x.CustomerCode.Trim() + }).ToList() + }; + } + + private async Task>> BuildReceiverSelectionIdMapAsync(IReadOnlyCollection notices) + { + var result = new Dictionary>(); + if (notices.Count == 0) + { + return result; + } + + var customerNotices = notices + .Where(x => string.Equals(x.ReceiverType, ReceiverTypeCustomer, StringComparison.OrdinalIgnoreCase)) + .ToList(); + Dictionary> userIdToCustomerIds = []; + if (customerNotices.Count > 0) + { + var allUserIds = customerNotices + .SelectMany(x => OmsDocHelper.DeserializeJson>(x.ReceiverIds) ?? []) + .Where(x => x > 0) + .Distinct() + .ToList(); + if (allUserIds.Count > 0) + { + var customers = await CustomerRepository.GetListAsync(x => + x.ParentCustomerId == null && allUserIds.Contains(x.UserId)); + userIdToCustomerIds = customers + .GroupBy(x => x.UserId) + .ToDictionary(x => x.Key, x => x.Select(c => c.Id).Distinct().ToList()); + } + } + + foreach (var notice in notices) + { + var storedUserIds = OmsDocHelper.DeserializeJson>(notice.ReceiverIds) ?? []; + if (string.Equals(notice.ReceiverType, ReceiverTypeCustomer, StringComparison.OrdinalIgnoreCase)) + { + var selectionIds = storedUserIds + .SelectMany(userId => userIdToCustomerIds.TryGetValue(userId, out var customerIds) + ? customerIds + : []) + .Distinct() + .ToList(); + result[notice.Id] = selectionIds.Count > 0 ? selectionIds : storedUserIds; + continue; + } + + result[notice.Id] = storedUserIds.Distinct().ToList(); + } + + return result; + } + private static Expression> BuildListPredicate(NoticeListInput input) { var noticeType = input.NoticeType?.Trim(); @@ -189,23 +341,47 @@ public class NoticeService : ApplicationService private async Task> NormalizeAndValidateReceiverIdsAsync(string receiverType, IEnumerable? receiverIds) { var normalizedReceiverType = NormalizeReceiverType(receiverType); - var normalizedIds = (receiverIds ?? []) + var selectionIds = (receiverIds ?? []) .Where(x => x > 0) .Distinct() .ToList(); - if (normalizedIds.Count == 0) + if (selectionIds.Count == 0) { throw new BusinessException("接收人不能为空"); } - var validUserIds = await QueryValidUserIdsAsync(normalizedIds); - if (validUserIds.Count != normalizedIds.Count) + if (normalizedReceiverType == ReceiverTypeCustomer) + { + var customers = await CustomerRepository.GetListAsync(x => + selectionIds.Contains(x.Id) && x.ParentCustomerId == null); + if (customers.Count != selectionIds.Count) + { + throw new BusinessException("接收客户包含无效数据"); + } + + var userIds = customers.Select(x => x.UserId).Where(x => x > 0).Distinct().ToList(); + if (userIds.Count == 0) + { + throw new BusinessException("接收客户未关联有效用户"); + } + + var validUserIds = await QueryValidUserIdsAsync(userIds); + if (validUserIds.Count != userIds.Count) + { + throw new BusinessException("接收客户关联用户无效"); + } + + return userIds; + } + + var validIds = await QueryValidUserIdsAsync(selectionIds); + if (validIds.Count != selectionIds.Count) { throw new BusinessException("接收用户包含无效数据"); } - return normalizedIds; + return selectionIds; } private async Task> QueryValidUserIdsAsync(IReadOnlyCollection userIds) @@ -234,7 +410,7 @@ public class NoticeService : ApplicationService } } - private static NoticeOutput MapNoticeOutput(Notice entity, int readCount) + private static NoticeOutput MapNoticeOutput(Notice entity, int readCount, List receiverSelectionIds) { var receiverIds = OmsDocHelper.DeserializeJson>(entity.ReceiverIds) ?? []; return new NoticeOutput @@ -246,8 +422,9 @@ public class NoticeService : ApplicationService Content = entity.Content, IsPopup = entity.IsPopup, ReceiverType = entity.ReceiverType, - ReceiverTypeName = "用户", + ReceiverTypeName = entity.ReceiverType == ReceiverTypeCustomer ? "客户" : "用户", ReceiverIds = receiverIds, + ReceiverSelectionIds = receiverSelectionIds, ReadCount = readCount, ReceiverCount = receiverIds.Distinct().Count(), CreateTime = entity.CreateTime, @@ -272,6 +449,7 @@ public class NoticeService : ApplicationService return normalized switch { ReceiverTypeUser => ReceiverTypeUser, + ReceiverTypeCustomer => ReceiverTypeCustomer, _ => throw new BusinessException("接收对象类型无效") }; } @@ -290,4 +468,13 @@ public class NoticeService : ApplicationService { public int UserId { get; set; } } + + private sealed class NoticeReceiverOptionRow + { + public int Value { get; set; } + + public string? Label { get; set; } + + public string? Extra { get; set; } + } } diff --git a/src/SimpleAdmin.Api/Auth/AuthRequestDtos.cs b/src/SimpleAdmin.Api/Auth/AuthRequestDtos.cs index fdc6ebd..8927898 100644 --- a/src/SimpleAdmin.Api/Auth/AuthRequestDtos.cs +++ b/src/SimpleAdmin.Api/Auth/AuthRequestDtos.cs @@ -31,6 +31,12 @@ public class OmsWxLoginInput public string LoginUuid { get; set; } = string.Empty; } +public class OmsCustomerOmsLoginInput +{ + [Required] + public int CustomerId { get; set; } +} + public class OmsChangePasswordInput { [Required] diff --git a/src/SimpleAdmin.Api/Auth/AuthService.cs b/src/SimpleAdmin.Api/Auth/AuthService.cs index 80f7b03..b64a73a 100644 --- a/src/SimpleAdmin.Api/Auth/AuthService.cs +++ b/src/SimpleAdmin.Api/Auth/AuthService.cs @@ -62,6 +62,53 @@ public partial class AuthService( }; } + /// + /// 管理员代登客户门户(免密)。 + /// + /// 入参:目标客户主键。 + /// 取消令牌。 + /// 客户门户访问令牌。 + [HttpPost("/rbac/auth/customer-oms-login")] + public async Task CustomerOmsLoginAsync( + [FromBody] OmsCustomerOmsLoginInput input, + CancellationToken cancellationToken = default) + { + EnsureAdminPrincipal(); + + var customer = await customerRepository.FirstOrDefaultAsync(x => x.Id == input.CustomerId, cancellationToken); + if (customer == null) + { + throw new BusinessException("客户不存在"); + } + + if (customer.ParentCustomerId.HasValue) + { + ValidateSubCustomerStatus(customer); + var mainCustomer = await customerRepository.FirstOrDefaultAsync( + x => x.Id == customer.ParentCustomerId.Value, + cancellationToken); + if (mainCustomer == null) + { + throw new BusinessException("主客户不存在"); + } + + ValidateMainCustomerStatus(mainCustomer); + } + else + { + ValidateMainCustomerStatus(customer); + } + + var userGuid = await GetRbacUserGuidByMapIdAsync(customer.UserId, cancellationToken); + if (!userGuid.HasValue) + { + throw new BusinessException("客户未关联用户"); + } + + var claims = await BuildClaimsAsync(userGuid.Value, OmsLoginType.Customer, cancellationToken); + return BuildLoginResult(userGuid.Value, claims); + } + /// /// 微信扫码登录轮询状态(key 是否已有绑定账号)。 ///