using System.Linq.Expressions; using Fake.Application; using Fake.Rbac.Infrastructure; using Microsoft.AspNetCore.Mvc; 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; namespace Oms.Application.Services.System; /// /// 公告 API 服务。 /// [ApiExplorerSettings(GroupName = "OMS-System")] 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(); private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); private FakeRbacDbContext RbacDbContext => LazyServiceProvider.GetRequiredService(); /// /// 查询通知列表。 /// [HttpGet("/system/notice/list")] public async Task> GetListAsync([FromQuery] NoticeListInput input) { var receiverUserId = CurrentUser.GetId(); if (receiverUserId <= 0) { return new OmsPageResult { Items = [], Total = 0 }; } var expectedReceiverType = OmsSessionHelper.IsCustomerLogin(CurrentUser) ? ReceiverTypeCustomer : ReceiverTypeUser; var myReadNoticeIds = (await NoticeReadRepository.GetListAsync(x => x.ReceiverType == expectedReceiverType && x.ReceiverId == receiverUserId)) .Select(x => x.NoticeId) .Distinct() .ToList(); var predicate = BuildListPredicate( input, receiverUserId, expectedReceiverType, input.OnlyUnread == true ? myReadNoticeIds : null); var pageIndex = OmsDocHelper.NormalizePage(input.Page); var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); var notices = await NoticeRepository.GetPagedListAsync( predicate, pageIndex, pageSize, new Dictionary { [nameof(Notice.CreateTime)] = false, [nameof(Notice.Id)] = false }); var total = await NoticeRepository.CountAsync(predicate); var noticeIds = notices.Select(x => x.Id).Distinct().ToArray(); var reads = noticeIds.Length == 0 ? [] : await NoticeReadRepository.GetListAsync(x => noticeIds.Contains(x.NoticeId)); var readCountMap = reads .GroupBy(x => x.NoticeId) .ToDictionary( x => x.Key, x => (int)x.Select(item => $"{item.ReceiverType}:{item.ReceiverId}").Distinct(StringComparer.Ordinal).Count()); var myReadIdSet = myReadNoticeIds.ToHashSet(); var selectionIdMap = await BuildReceiverSelectionIdMapAsync(notices); return new OmsPageResult { Items = notices.Select(x => MapNoticeOutput( x, readCountMap.TryGetValue(x.Id, out var readCount) ? readCount : 0, myReadIdSet.Contains(x.Id), 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) { var noticeType = NormalizeNoticeType(input.NoticeType); ValidateNoticeText(noticeType, input.Title, input.Content); var receiverType = ResolveReceiverType(noticeType, input.ReceiverType); var receiverIds = await NormalizeAndValidateReceiverIdsAsync(noticeType, receiverType, input.ReceiverIds); await NoticeRepository.InsertAsync(new Notice { NoticeType = noticeType, Title = NormalizeTitle(input.Title), Content = input.Content.Trim(), IsPopup = noticeType == NoticeTypeMessage ? (byte)0 : NormalizePopupValue(input.IsPopup), ReceiverType = receiverType, ReceiverIds = OmsDocHelper.SerializeJson(receiverIds) ?? "[]" }); } /// /// 更新通知。 /// [HttpPost("/system/notice/update")] public async Task UpdateAsync(NoticeUpdateInput input) { var entity = await GetNoticeOrThrowAsync(input.Id); var noticeType = NormalizeNoticeType(input.NoticeType); ValidateNoticeText(noticeType, input.Title, input.Content); var receiverType = ResolveReceiverType(noticeType, input.ReceiverType); var receiverIds = await NormalizeAndValidateReceiverIdsAsync(noticeType, receiverType, input.ReceiverIds); entity.NoticeType = noticeType; entity.Title = NormalizeTitle(input.Title); entity.Content = input.Content.Trim(); entity.IsPopup = noticeType == NoticeTypeMessage ? (byte)0 : NormalizePopupValue(input.IsPopup); entity.ReceiverType = receiverType; entity.ReceiverIds = OmsDocHelper.SerializeJson(receiverIds) ?? "[]"; await NoticeRepository.UpdateAsync(entity); // 全体广播不清理已读;定向接收时移除已不在名单内的已读记录 if (receiverIds.Count > 0) { var existsReads = await NoticeReadRepository.GetListAsync(x => x.NoticeId == entity.Id); var receiverIdSet = receiverIds.ToHashSet(); foreach (var read in existsReads.Where(x => !string.Equals(x.ReceiverType, receiverType, StringComparison.OrdinalIgnoreCase) || !receiverIdSet.Contains(x.ReceiverId))) { await NoticeReadRepository.DeleteAsync(read); } } } /// /// 删除通知。 /// [HttpPost("/system/notice/delete")] public async Task DeleteAsync(IdInput input) { var entity = await GetNoticeOrThrowAsync(input.Id); await NoticeRepository.DeleteAsync(entity); } /// /// 确认通知已读。 /// [HttpPost("/system/notice/read-confirm")] public async Task ReadConfirmAsync(IdInput input) { var notice = await GetNoticeOrThrowAsync(input.Id); var receiverIds = OmsDocHelper.DeserializeJson>(notice.ReceiverIds) ?? []; var currentReceiverType = OmsSessionHelper.IsCustomerLogin(CurrentUser) ? ReceiverTypeCustomer : ReceiverTypeUser; var currentReceiverId = CurrentUser.GetId(); if (currentReceiverId <= 0) { throw new BusinessException("当前登录主体无效"); } // 空接收人 = 全体广播;否则校验当前主体在定向名单内 if (receiverIds.Count > 0 && !receiverIds.Contains(currentReceiverId)) { throw new BusinessException("当前主体不在通知接收范围内"); } var existed = await NoticeReadRepository.GetByNoticeReceiverAsync(notice.Id, currentReceiverType, currentReceiverId); if (existed != null) { return; } await NoticeReadRepository.InsertAsync(new NoticeRead { NoticeId = notice.Id, ReceiverType = currentReceiverType, ReceiverId = currentReceiverId, ReadTime = DateTime.Now }); } 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, int receiverUserId, string expectedReceiverType, IReadOnlyCollection? excludeNoticeIds) { var noticeType = input.NoticeType?.Trim(); var receiverType = input.ReceiverType?.Trim(); var keyword = input.Keyword?.Trim(); var receiverIdToken = receiverUserId.ToString(); var excluded = excludeNoticeIds?.Where(x => x > 0).Distinct().ToList() ?? []; return x => (string.IsNullOrWhiteSpace(noticeType) || x.NoticeType == noticeType) && (string.IsNullOrWhiteSpace(receiverType) || x.ReceiverType == receiverType) && (string.IsNullOrWhiteSpace(keyword) || x.Title.Contains(keyword) || x.Content.Contains(keyword)) && ( // 空接收人列表 = 全体广播(员工/客户均可见) x.ReceiverIds == "[]" || (x.ReceiverType == expectedReceiverType && (x.ReceiverIds == $"[{receiverIdToken}]" || x.ReceiverIds.StartsWith($"[{receiverIdToken},") || x.ReceiverIds.Contains($",{receiverIdToken},") || x.ReceiverIds.EndsWith($",{receiverIdToken}]"))) ) && (excluded.Count == 0 || !excluded.Contains(x.Id)); } private async Task GetNoticeOrThrowAsync(int id) { var entity = await NoticeRepository.FirstOrDefaultAsync(x => x.Id == id); if (entity == null) { throw new BusinessException("通知不存在"); } return entity; } private async Task> NormalizeAndValidateReceiverIdsAsync( string noticeType, string receiverType, IEnumerable? receiverIds) { var selectionIds = (receiverIds ?? []) .Where(x => x > 0) .Distinct() .ToList(); // 公告默认全体生效,允许空接收人;站内信必须指定接收人 if (selectionIds.Count == 0) { if (noticeType == NoticeTypeNotice) { return []; } throw new BusinessException("接收人不能为空"); } var normalizedReceiverType = NormalizeReceiverType(receiverType); 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 selectionIds; } /// /// 公告全体广播时接收类型仅作占位;站内信必须明确 user/customer。 /// private static string ResolveReceiverType(string noticeType, string? receiverType) { if (noticeType == NoticeTypeNotice && string.IsNullOrWhiteSpace(receiverType)) { return ReceiverTypeUser; } return NormalizeReceiverType(receiverType ?? string.Empty); } private async Task> QueryValidUserIdsAsync(IReadOnlyCollection userIds) { if (userIds.Count == 0) { return []; } var sql = $"SELECT `userid` AS `UserId` FROM `user` WHERE `IsDeleted` = 0 AND `userid` IN ({string.Join(",", userIds)})"; return await RbacDbContext.Database.SqlQueryRaw(sql) .Select(x => x.UserId) .ToListAsync(); } private static void ValidateNoticeText(string noticeType, string? title, string content) { if (noticeType == NoticeTypeNotice && string.IsNullOrWhiteSpace(title)) { throw new BusinessException("通知标题不能为空"); } if (string.IsNullOrWhiteSpace(content)) { throw new BusinessException("通知内容不能为空"); } } private static string NormalizeTitle(string? title) => string.IsNullOrWhiteSpace(title) ? string.Empty : title.Trim(); private static NoticeOutput MapNoticeOutput( Notice entity, int readCount, bool isRead, List receiverSelectionIds) { var receiverIds = OmsDocHelper.DeserializeJson>(entity.ReceiverIds) ?? []; var isBroadcast = receiverIds.Count == 0; return new NoticeOutput { Id = entity.Id, NoticeType = entity.NoticeType, NoticeTypeName = entity.NoticeType == NoticeTypeNotice ? "公告" : "站内信", Title = entity.Title, Content = entity.Content, IsPopup = entity.IsPopup, ReceiverType = entity.ReceiverType, ReceiverTypeName = isBroadcast ? "全部" : entity.ReceiverType == ReceiverTypeCustomer ? "客户" : "用户", ReceiverIds = receiverIds, ReceiverSelectionIds = receiverSelectionIds, ReadCount = readCount, IsRead = isRead, ReceiverCount = isBroadcast ? 0 : receiverIds.Distinct().Count(), CreateTime = entity.CreateTime, UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime) }; } private static string NormalizeNoticeType(string noticeType) { var normalized = noticeType.Trim().ToLowerInvariant(); return normalized switch { NoticeTypeNotice => NoticeTypeNotice, NoticeTypeMessage => NoticeTypeMessage, _ => throw new BusinessException("通知类型无效") }; } private static string NormalizeReceiverType(string receiverType) { var normalized = receiverType.Trim().ToLowerInvariant(); return normalized switch { ReceiverTypeUser => ReceiverTypeUser, ReceiverTypeCustomer => ReceiverTypeCustomer, _ => throw new BusinessException("接收对象类型无效") }; } private static byte NormalizePopupValue(byte value) { return value switch { 0 => 0, 1 => 1, _ => throw new BusinessException("弹窗标记无效") }; } private sealed class UserIdQueryResult { public int UserId { get; set; } } private sealed class NoticeReceiverOptionRow { public int Value { get; set; } public string? Label { get; set; } public string? Extra { get; set; } } }