feat(customer): 列表返回 Id 并支持管理员代登客户门户

补充客户分页 Id 字段与 customer-oms-login 接口,修复管理端删除/免密登录;公告通知增加接收人候选项与客户接收类型。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-06 18:35:46 +08:00
parent 90f6c25b04
commit 74151b0738
7 changed files with 321 additions and 22 deletions

View File

@ -7,6 +7,11 @@ namespace Oms.Application.Dtos.Customers;
/// </summary>
public class CustomerPaginatedOutput
{
/// <summary>
/// 主键。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 父客户ID,空表示主客户。
/// </summary>

View File

@ -46,10 +46,15 @@ public class NoticeOutput
public string ReceiverTypeName { get; set; } = string.Empty;
/// <summary>
/// 接收主体 ID 列表。
/// 接收主体 ID 列表(持久化为 <c>user.userid</c>)。
/// </summary>
public List<int> ReceiverIds { get; set; } = [];
/// <summary>
/// 表单回显用选中 ID:user 与 <see cref="ReceiverIds"/> 一致;customer 为 <c>t_customer.Id</c>。
/// </summary>
public List<int> ReceiverSelectionIds { get; set; } = [];
/// <summary>
/// 已读人数。
/// </summary>

View File

@ -0,0 +1,22 @@
namespace Oms.Application.Dtos.System;
/// <summary>
/// 公告接收人候选项。
/// </summary>
public class NoticeReceiverOptionOutput
{
/// <summary>
/// 表单选中值:user 为 <c>user.userid</c>,customer 为 <c>t_customer.Id</c>。
/// </summary>
public int Value { get; set; }
/// <summary>
/// 展示名称。
/// </summary>
public string Label { get; set; } = string.Empty;
/// <summary>
/// 辅助说明(账号、客户编码等)。
/// </summary>
public string? Extra { get; set; }
}

View File

@ -0,0 +1,27 @@
namespace Oms.Application.Dtos.System;
/// <summary>
/// 公告接收人候选项查询入参。
/// </summary>
public class NoticeReceiverOptionsInput
{
/// <summary>
/// 接收对象类型:user/customer。
/// </summary>
public string ReceiverType { get; set; } = string.Empty;
/// <summary>
/// 关键字。
/// </summary>
public string? Keyword { get; set; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; set; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; set; } = 50;
}

View File

@ -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<INoticeRepository>();
private INoticeReadRepository NoticeReadRepository => LazyServiceProvider.GetRequiredService<INoticeReadRepository>();
@ -30,8 +46,6 @@ public class NoticeService : ApplicationService
/// <summary>
/// 查询通知列表。
/// </summary>
/// <param name="input">入参:通知分页查询条件。</param>
/// <returns>通知分页结果,包含通知主体、接收范围与已读统计。</returns>
[HttpGet("/system/notice/list")]
public async Task<OmsPageResult<NoticeOutput>> 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<NoticeOutput>
{
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
};
}
/// <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>
/// <param name="input">入参:通知新增参数。</param>
/// <returns>无返回体,仅完成新增通知。</returns>
[HttpPost("/system/notice/create")]
public async Task CreateAsync(NoticeCreateInput input)
{
@ -88,8 +122,6 @@ public class NoticeService : ApplicationService
/// <summary>
/// 更新通知。
/// </summary>
/// <param name="input">入参:通知更新参数。</param>
/// <returns>无返回体,仅完成更新通知。</returns>
[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
/// <summary>
/// 删除通知。
/// </summary>
/// <param name="input">入参:目标主键。</param>
/// <returns>无返回体,仅完成删除通知。</returns>
[HttpPost("/system/notice/delete")]
public async Task DeleteAsync(IdInput input)
{
@ -127,8 +157,6 @@ public class NoticeService : ApplicationService
/// <summary>
/// 确认通知已读。
/// </summary>
/// <param name="input">入参:目标通知主键。</param>
/// <returns>无返回体,仅完成当前主体的已读确认。</returns>
[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<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)
{
var noticeType = input.NoticeType?.Trim();
@ -189,23 +341,47 @@ public class NoticeService : ApplicationService
private async Task<List<int>> NormalizeAndValidateReceiverIdsAsync(string receiverType, IEnumerable<int>? 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<List<int>> QueryValidUserIdsAsync(IReadOnlyCollection<int> 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<int> receiverSelectionIds)
{
var receiverIds = OmsDocHelper.DeserializeJson<List<int>>(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; }
}
}

View File

@ -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]

View File

@ -62,6 +62,53 @@ public partial class AuthService(
};
}
/// <summary>
/// 管理员代登客户门户(免密)。
/// </summary>
/// <param name="input">入参:目标客户主键。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>客户门户访问令牌。</returns>
[HttpPost("/rbac/auth/customer-oms-login")]
public async Task<LoginResultDto> 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);
}
/// <summary>
/// 微信扫码登录轮询状态(key 是否已有绑定账号)。
/// </summary>