admin/src/Oms.Application/Services/System/NoticeService.cs
zzh 25b4211b46 feat: 销售产品门户目录、线路排序与公告/到货调整
新增门户目录接口与 route_sort 字段迁移;完善公告创建与包裹到货相关逻辑。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 18:39:55 +08:00

566 lines
21 KiB
C#

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;
/// <summary>
/// 公告 API 服务。
/// </summary>
[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<INoticeRepository>();
private INoticeReadRepository NoticeReadRepository => LazyServiceProvider.GetRequiredService<INoticeReadRepository>();
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
private FakeRbacDbContext RbacDbContext => LazyServiceProvider.GetRequiredService<FakeRbacDbContext>();
/// <summary>
/// 查询通知列表。
/// </summary>
[HttpGet("/system/notice/list")]
public async Task<OmsPageResult<NoticeOutput>> GetListAsync([FromQuery] NoticeListInput input)
{
var receiverUserId = CurrentUser.GetId<int>();
if (receiverUserId <= 0)
{
return new OmsPageResult<NoticeOutput> { 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<string, bool> { [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<NoticeOutput>
{
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
};
}
/// <summary>
/// 接收人候选项(按接收对象类型加载)。
/// </summary>
[HttpGet("/system/notice/receiver-options")]
public async Task<OmsPageResult<NoticeReceiverOptionOutput>> 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);
}
/// <summary>
/// 新增通知。
/// </summary>
[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) ?? "[]"
});
}
/// <summary>
/// 更新通知。
/// </summary>
[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);
}
}
}
/// <summary>
/// 删除通知。
/// </summary>
[HttpPost("/system/notice/delete")]
public async Task DeleteAsync(IdInput input)
{
var entity = await GetNoticeOrThrowAsync(input.Id);
await NoticeRepository.DeleteAsync(entity);
}
/// <summary>
/// 确认通知已读。
/// </summary>
[HttpPost("/system/notice/read-confirm")]
public async Task ReadConfirmAsync(IdInput input)
{
var notice = await GetNoticeOrThrowAsync(input.Id);
var receiverIds = OmsDocHelper.DeserializeJson<List<int>>(notice.ReceiverIds) ?? [];
var currentReceiverType = OmsSessionHelper.IsCustomerLogin(CurrentUser)
? ReceiverTypeCustomer
: ReceiverTypeUser;
var currentReceiverId = CurrentUser.GetId<int>();
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<OmsPageResult<NoticeReceiverOptionOutput>> QueryUserReceiverOptionsAsync(
string? keyword,
int pageIndex,
int pageSize)
{
var where = $"WHERE u.`IsDeleted` = 0 AND {InternalUserFilter}";
var args = new List<object>();
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<long>(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<NoticeReceiverOptionRow>(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<NoticeReceiverOptionOutput> { Items = items, Total = total };
}
private async Task<OmsPageResult<NoticeReceiverOptionOutput>> QueryCustomerReceiverOptionsAsync(
string? keyword,
int pageIndex,
int pageSize)
{
Expression<Func<Customer, bool>> 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<string, bool> { [nameof(Customer.CustomerName)] = true, [nameof(Customer.Id)] = true });
return new OmsPageResult<NoticeReceiverOptionOutput>
{
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<Dictionary<int, List<int>>> BuildReceiverSelectionIdMapAsync(IReadOnlyCollection<Notice> notices)
{
var result = new Dictionary<int, List<int>>();
if (notices.Count == 0)
{
return result;
}
var customerNotices = notices
.Where(x => string.Equals(x.ReceiverType, ReceiverTypeCustomer, StringComparison.OrdinalIgnoreCase))
.ToList();
Dictionary<int, List<int>> userIdToCustomerIds = [];
if (customerNotices.Count > 0)
{
var allUserIds = customerNotices
.SelectMany(x => OmsDocHelper.DeserializeJson<List<int>>(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<List<int>>(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<Func<Notice, bool>> BuildListPredicate(
NoticeListInput input,
int receiverUserId,
string expectedReceiverType,
IReadOnlyCollection<int>? 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<Notice> GetNoticeOrThrowAsync(int id)
{
var entity = await NoticeRepository.FirstOrDefaultAsync(x => x.Id == id);
if (entity == null)
{
throw new BusinessException("通知不存在");
}
return entity;
}
private async Task<List<int>> NormalizeAndValidateReceiverIdsAsync(
string noticeType,
string receiverType,
IEnumerable<int>? 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;
}
/// <summary>
/// 公告全体广播时接收类型仅作占位;站内信必须明确 user/customer。
/// </summary>
private static string ResolveReceiverType(string noticeType, string? receiverType)
{
if (noticeType == NoticeTypeNotice && string.IsNullOrWhiteSpace(receiverType))
{
return ReceiverTypeUser;
}
return NormalizeReceiverType(receiverType ?? string.Empty);
}
private async Task<List<int>> QueryValidUserIdsAsync(IReadOnlyCollection<int> 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<UserIdQueryResult>(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<int> receiverSelectionIds)
{
var receiverIds = OmsDocHelper.DeserializeJson<List<int>>(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; }
}
}