All checks were successful
Build and Deploy / deploy (push) Successful in 1m11s
Co-authored-by: Cursor <cursoragent@cursor.com>
953 lines
35 KiB
C#
953 lines
35 KiB
C#
using System.Linq.Expressions;
|
||
using Fake.Application;
|
||
using Fake.Rbac.Application.Dtos.Role;
|
||
using Fake.Rbac.Application.Dtos.User;
|
||
using Fake.Rbac.Application.Services;
|
||
using Fake.Rbac.Infrastructure;
|
||
using Fake.Security.Claims;
|
||
using Fake.EntityFrameworkCore;
|
||
using Microsoft.AspNetCore.Authorization;
|
||
using Microsoft.AspNetCore.Mvc;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Oms.Application.Dtos.Customers;
|
||
using Oms.Application.Dtos.Shared;
|
||
using Oms.Application.Rbac;
|
||
using Oms.Application.Shared;
|
||
using Oms.Domain.CustomersAggregate.Entities;
|
||
using Oms.Domain.CustomersAggregate.Enums;
|
||
using Oms.Domain.CustomersAggregate.Repositories;
|
||
using Oms.Domain.AgentsAggregate.Repositories;
|
||
using Oms.Domain.MarketingAggregate.Repositories;
|
||
using Oms.Domain.Shared.Enums;
|
||
|
||
namespace Oms.Application.Services.Customers;
|
||
|
||
/// <summary>
|
||
/// 客户 API 服务。
|
||
/// </summary>
|
||
[ApiExplorerSettings(GroupName = "OMS-Customer")]
|
||
public class CustomerService : ApplicationService
|
||
{
|
||
private const string CustomerRoleCode = "Customer";
|
||
|
||
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
|
||
|
||
private IMemberLevelRepository MemberLevelRepository =>
|
||
LazyServiceProvider.GetRequiredService<IMemberLevelRepository>();
|
||
|
||
private ICustomerReferralCodeRepository ReferralCodeRepository =>
|
||
LazyServiceProvider.GetRequiredService<ICustomerReferralCodeRepository>();
|
||
|
||
private IAgentRepository AgentRepository =>
|
||
LazyServiceProvider.GetRequiredService<IAgentRepository>();
|
||
|
||
private MarketingCouponManager CouponManager =>
|
||
LazyServiceProvider.GetRequiredService<MarketingCouponManager>();
|
||
|
||
private UserService UserService => LazyServiceProvider.GetRequiredService<UserService>();
|
||
|
||
private RoleService RoleService => LazyServiceProvider.GetRequiredService<RoleService>();
|
||
|
||
private FakeRbacDbContext RbacDbContext => LazyServiceProvider.GetRequiredService<FakeRbacDbContext>();
|
||
|
||
private OmsRbacUserDisplayNameResolver UserDisplayNameResolver =>
|
||
LazyServiceProvider.GetRequiredService<OmsRbacUserDisplayNameResolver>();
|
||
|
||
private CustomerServiceAdvisorResolver ServiceAdvisorResolver =>
|
||
LazyServiceProvider.GetRequiredService<CustomerServiceAdvisorResolver>();
|
||
|
||
/// <summary>
|
||
/// 查询客户列表。
|
||
/// </summary>
|
||
/// <param name="input">入参:客户分页查询条件。</param>
|
||
/// <returns>客户分页结果,包含列表数据和总记录数。</returns>
|
||
[HttpGet("/customer/list")]
|
||
public async Task<OmsPageResult<CustomerPaginatedOutput>> GetPaginatedListAsync([FromQuery] CustomerPaginatedInput input)
|
||
{
|
||
var predicate = BuildPaginatedPredicate(input);
|
||
var pageIndex = OmsDocHelper.NormalizePage(input.Page);
|
||
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
|
||
|
||
var entities = await CustomerRepository.GetPagedListAsync(
|
||
predicate,
|
||
pageIndex,
|
||
pageSize,
|
||
BuildCustomerSortDictionary(input));
|
||
|
||
var totalCount = await CustomerRepository.CountAsync(predicate);
|
||
var auditUserNameMap = await UserDisplayNameResolver.ResolveManyAsync(
|
||
entities.SelectMany(x => new[] { x.CreateUserId, x.UpdateUserId }));
|
||
var memberLevelNameMap = await ResolveMemberLevelNameMapAsync(
|
||
entities.Where(x => x.MemberLevelId.HasValue).Select(x => x.MemberLevelId!.Value));
|
||
var agentMap = await ResolveAgentDisplayMapAsync(
|
||
entities.Where(x => x.AgentId.HasValue).Select(x => x.AgentId!.Value));
|
||
var items = entities.Select(entity =>
|
||
{
|
||
var output = ObjectMapper.Map<Customer, CustomerPaginatedOutput>(entity);
|
||
output.CreateBy = OmsRbacUserDisplayNameResolver.FormatUserId(entity.CreateUserId, auditUserNameMap);
|
||
output.UpdateBy = OmsRbacUserDisplayNameResolver.FormatUserId(entity.UpdateUserId, auditUserNameMap);
|
||
output.UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime);
|
||
if (entity.MemberLevelId.HasValue
|
||
&& memberLevelNameMap.TryGetValue(entity.MemberLevelId.Value, out var levelName))
|
||
{
|
||
output.MemberLevelName = levelName;
|
||
}
|
||
|
||
FillAgentDisplay(output, entity.AgentId, agentMap);
|
||
return output;
|
||
}).ToList();
|
||
|
||
return new OmsPageResult<CustomerPaginatedOutput>
|
||
{
|
||
Items = items,
|
||
Total = totalCount
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 查询客户详情。
|
||
/// </summary>
|
||
/// <param name="id">入参:目标主键。</param>
|
||
/// <returns>客户详情。</returns>
|
||
[HttpGet("/customer/get")]
|
||
public async Task<CustomerOutput> GetByIdAsync([FromQuery] int id)
|
||
{
|
||
var entity = await GetCustomerOrThrowAsync(id);
|
||
var output = ObjectMapper.Map<Customer, CustomerOutput>(entity);
|
||
var auditUserNameMap = await UserDisplayNameResolver.ResolveManyAsync(
|
||
[entity.CreateUserId, entity.UpdateUserId]);
|
||
output.CreateBy = OmsRbacUserDisplayNameResolver.FormatUserId(entity.CreateUserId, auditUserNameMap);
|
||
output.UpdateBy = OmsRbacUserDisplayNameResolver.FormatUserId(entity.UpdateUserId, auditUserNameMap);
|
||
output.UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime);
|
||
if (entity.MemberLevelId.HasValue)
|
||
{
|
||
var level = await MemberLevelRepository.FirstOrDefaultAsync(x => x.Id == entity.MemberLevelId.Value);
|
||
output.MemberLevelName = level?.LevelName;
|
||
}
|
||
|
||
if (entity.AgentId.HasValue)
|
||
{
|
||
var agentMap = await ResolveAgentDisplayMapAsync([entity.AgentId.Value]);
|
||
FillAgentDisplay(output, entity.AgentId, agentMap);
|
||
}
|
||
|
||
return output;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 查询当前登录客户所属顾问信息。
|
||
/// </summary>
|
||
/// <returns>专属顾问姓名、微信号、微信二维码。</returns>
|
||
[HttpGet("/customer/service-advisor")]
|
||
public async Task<CustomerServiceAdvisorOutput> GetServiceAdvisorAsync(CancellationToken cancellationToken = default)
|
||
{
|
||
var userId = CurrentUser.GetId<int>();
|
||
if (userId <= 0)
|
||
{
|
||
return new CustomerServiceAdvisorOutput();
|
||
}
|
||
|
||
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.UserId == userId, cancellationToken);
|
||
if (customer == null || string.IsNullOrWhiteSpace(customer.CustomerService))
|
||
{
|
||
return new CustomerServiceAdvisorOutput();
|
||
}
|
||
|
||
return await ServiceAdvisorResolver.ResolveAsync(customer.CustomerService, cancellationToken);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 当前登录客户联系方式完整度(下单/揽收前校验)。
|
||
/// </summary>
|
||
[HttpGet("/customer/profile-completeness")]
|
||
public async Task<CustomerProfileCompletenessOutput> GetProfileCompletenessAsync(
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
var customer = await GetCurrentCustomerOrThrowAsync(cancellationToken);
|
||
var phoneFilled = CustomerContactHelper.HasPhone(customer);
|
||
var emailFilled = CustomerContactHelper.HasEmail(customer);
|
||
return new CustomerProfileCompletenessOutput
|
||
{
|
||
PhoneFilled = phoneFilled,
|
||
EmailFilled = emailFilled,
|
||
NeedContact = !phoneFilled || !emailFilled,
|
||
CustomerCode = customer.CustomerCode,
|
||
Phone = customer.Phone,
|
||
Email = customer.Email
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 补全手机号与邮箱(下单前)。
|
||
/// </summary>
|
||
[HttpPost("/customer/complete-contact")]
|
||
public async Task CompleteContactAsync(
|
||
[FromBody] CompleteCustomerContactInput input,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
var customer = await GetCurrentCustomerOrThrowAsync(cancellationToken);
|
||
var phone = WeChatRegistrationFieldNormalizer.TruncateRequired(
|
||
NormalizeRequiredText(input.Phone, "手机号"),
|
||
WeChatRegistrationFieldNormalizer.PhoneMaxLength);
|
||
var email = WeChatRegistrationFieldNormalizer.TruncateRequired(
|
||
NormalizeRequiredText(input.Email, "邮箱"),
|
||
WeChatRegistrationFieldNormalizer.EmailMaxLength);
|
||
|
||
await FreeSoftDeletedCustomerIdentityIfNeededAsync(
|
||
customer.CustomerCode,
|
||
phone,
|
||
email,
|
||
excludeId: customer.Id);
|
||
|
||
var duplicatedPhone = await CustomerRepository.GetByPhoneAsync(phone, cancellationToken);
|
||
if (duplicatedPhone != null && duplicatedPhone.Id != customer.Id)
|
||
{
|
||
throw new BusinessException("手机号已存在");
|
||
}
|
||
|
||
var duplicatedEmail = await CustomerRepository.GetByEmailAsync(email, cancellationToken);
|
||
if (duplicatedEmail != null && duplicatedEmail.Id != customer.Id)
|
||
{
|
||
throw new BusinessException("邮箱已存在");
|
||
}
|
||
|
||
customer.Phone = phone;
|
||
customer.Email = email;
|
||
await CustomerRepository.UpdateAsync(customer);
|
||
|
||
// 同步 RBAC 登录邮箱,覆盖注册时的 @noemail.local 占位
|
||
try
|
||
{
|
||
var syncEmailSql =
|
||
$"UPDATE `{FakeTables.User}` SET `Email` = {{0}} WHERE `userid` = {{1}} AND `IsDeleted` = 0";
|
||
await RbacDbContext.Database.ExecuteSqlRawAsync(syncEmailSql, email, customer.UserId);
|
||
}
|
||
catch
|
||
{
|
||
// 客户资料已更新;RBAC 同步失败不阻断补全
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 新增客户(公开注册页可匿名调用;管理端也可带 token 代开户)。
|
||
/// </summary>
|
||
/// <param name="input">入参:客户创建参数。</param>
|
||
/// <returns>无返回体,仅完成新增客户。</returns>
|
||
[AllowAnonymous]
|
||
[HttpPost("/customer/create")]
|
||
public async Task CreateAsync(CreateCustomerInput input)
|
||
{
|
||
var normalized = await NormalizeCreateCustomerInputAsync(input, "客户编号", "客户名称");
|
||
|
||
await FreeSoftDeletedCustomerIdentityIfNeededAsync(
|
||
normalized.CustomerCode,
|
||
normalized.Phone,
|
||
normalized.Email);
|
||
await EnsureCustomerIdentityUniqueAsync(normalized.CustomerCode, normalized.Phone, normalized.Email);
|
||
|
||
var customerRoleId = await GetCustomerRoleIdOrThrowAsync();
|
||
var user = await CreateRbacUserAsync(input, normalized);
|
||
|
||
try
|
||
{
|
||
await UserService.AssignRolesAsync(user.Id, [customerRoleId]);
|
||
var userId = await GetRbacUserMapIdOrThrowAsync(user.Id);
|
||
var entity = BuildCustomerEntity(input, normalized, null, userId);
|
||
entity.MemberLevelId = await ResolveMemberLevelIdForCreateAsync(input.MemberLevelId);
|
||
entity.PriceTierKey = MarketingCustomerHelper.ResolveNewCustomerPriceTierKey(input.PriceTierKey);
|
||
await AgentBinder.BindAgentOnCreateAsync(entity, input.AgentCode, AgentRepository);
|
||
// 有代理编号时不走 C2C 邀请,避免双发佣金
|
||
if (!entity.AgentId.HasValue)
|
||
{
|
||
await MarketingReferralBinder.BindReferrerOnCreateAsync(
|
||
entity,
|
||
input.InviteCode,
|
||
ReferralCodeRepository,
|
||
CustomerRepository,
|
||
MemberLevelRepository,
|
||
LazyServiceProvider.GetRequiredService<IReferralCommissionRuleRepository>());
|
||
}
|
||
await StaffAdvisorBinder.BindAdvisorOnCreateAsync(
|
||
entity,
|
||
input.AdvisorCode,
|
||
RbacDbContext);
|
||
await CustomerRepository.InsertAsync(entity);
|
||
try
|
||
{
|
||
await CouponManager.GrantNewcomerCouponsAsync(entity.Id);
|
||
if (entity.ReferrerCustomerId.HasValue)
|
||
{
|
||
await CouponManager.GrantInviteeCouponAsync(entity.Id);
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// 新人券发放失败不回滚注册
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
await TryRollbackRbacUserAsync(user.Id);
|
||
throw;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 创建子客户。
|
||
/// </summary>
|
||
/// <param name="input">入参:子客户基础资料、登录密码和授权权限编码列表。</param>
|
||
/// <returns>无返回体,仅完成子客户创建和RBAC用户、角色绑定。</returns>
|
||
[HttpPost("/customer/sub/create")]
|
||
public async Task CreateSubCustomerAsync([FromBody] CreateSubCustomerInput input)
|
||
{
|
||
var mainCustomer = await GetCurrentMainCustomerOrThrowAsync();
|
||
ValidateCustomerStatus(mainCustomer);
|
||
|
||
var normalized = await NormalizeCreateCustomerInputAsync(input, "子客户编号", "子客户名称");
|
||
var permissions = await NormalizeAndValidateSubCustomerPermissionsAsync(input.Permissions);
|
||
|
||
await FreeSoftDeletedCustomerIdentityIfNeededAsync(
|
||
normalized.CustomerCode,
|
||
normalized.Phone,
|
||
normalized.Email);
|
||
await EnsureCustomerIdentityUniqueAsync(normalized.CustomerCode, normalized.Phone, normalized.Email);
|
||
|
||
var user = await CreateRbacUserAsync(input, normalized);
|
||
|
||
Guid? roleId = null;
|
||
try
|
||
{
|
||
var userId = await GetRbacUserMapIdOrThrowAsync(user.Id);
|
||
roleId = await CreateSubCustomerRoleAsync(userId, normalized.CustomerName, permissions);
|
||
await UserService.AssignRolesAsync(user.Id, [roleId.Value]);
|
||
var entity = BuildCustomerEntity(input, normalized, mainCustomer.Id, userId);
|
||
// 子客户跟随主客户价区与等级展示,计价走主客户
|
||
entity.MemberLevelId = mainCustomer.MemberLevelId
|
||
?? await ResolveMemberLevelIdForCreateAsync(input.MemberLevelId);
|
||
entity.PriceTierKey = MarketingCustomerHelper.NormalizePriceTierKeyOrDefault(
|
||
mainCustomer.PriceTierKey ?? input.PriceTierKey);
|
||
await CustomerRepository.InsertAsync(entity);
|
||
}
|
||
catch
|
||
{
|
||
if (roleId.HasValue)
|
||
{
|
||
await TryRollbackRbacRoleAsync(roleId.Value);
|
||
}
|
||
|
||
await TryRollbackRbacUserAsync(user.Id);
|
||
throw;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 更新客户。
|
||
/// </summary>
|
||
/// <param name="input">入参:客户更新参数。</param>
|
||
/// <returns>无返回体,仅完成更新客户。</returns>
|
||
[HttpPost("/customer/update")]
|
||
public async Task UpdateAsync(UpdateCustomerInput input)
|
||
{
|
||
var entity = await GetCustomerOrThrowAsync(input.Id);
|
||
// 未传字段保留原值,避免局部更新(如只改名称)把编号/余额/状态冲成空或默认值
|
||
var customerCode = string.IsNullOrWhiteSpace(input.CustomerCode)
|
||
? entity.CustomerCode
|
||
: NormalizeRequiredText(input.CustomerCode, "客户编号");
|
||
var customerName = string.IsNullOrWhiteSpace(input.CustomerName)
|
||
? entity.CustomerName
|
||
: NormalizeRequiredText(input.CustomerName, "客户名称");
|
||
var phone = string.IsNullOrWhiteSpace(input.Phone)
|
||
? entity.Phone
|
||
: NormalizeRequiredText(input.Phone, "手机号");
|
||
var email = input.Email != null ? NormalizeOptionalText(input.Email) : entity.Email;
|
||
|
||
await EnsureCustomerIdentityUniqueForUpdateAsync(entity.Id, customerCode, phone, email);
|
||
|
||
entity.CustomerCode = customerCode;
|
||
entity.CustomerName = customerName;
|
||
entity.Phone = phone;
|
||
entity.Email = email;
|
||
if (input.WechatAvatar != null)
|
||
{
|
||
entity.WechatAvatar = NormalizeOptionalText(input.WechatAvatar);
|
||
}
|
||
|
||
if (input.WechatNickname != null)
|
||
{
|
||
entity.WechatNickname = NormalizeOptionalText(input.WechatNickname);
|
||
}
|
||
|
||
if (input.Balance.HasValue)
|
||
{
|
||
entity.Balance = input.Balance.Value;
|
||
}
|
||
|
||
if (input.TotalQuota.HasValue)
|
||
{
|
||
entity.TotalQuota = input.TotalQuota.Value;
|
||
}
|
||
|
||
if (input.UsedQuota.HasValue)
|
||
{
|
||
entity.UsedQuota = input.UsedQuota.Value;
|
||
}
|
||
|
||
if (input.Status.HasValue && input.Status.Value != CustomerStatus.None)
|
||
{
|
||
entity.Status = input.Status.Value;
|
||
}
|
||
|
||
if (input.Remark != null)
|
||
{
|
||
entity.Remark = NormalizeOptionalText(input.Remark);
|
||
}
|
||
|
||
if (input.Salesman != null)
|
||
{
|
||
entity.Salesman = NormalizeOptionalText(input.Salesman);
|
||
}
|
||
|
||
if (input.CustomerService != null)
|
||
{
|
||
entity.CustomerService = NormalizeOptionalText(input.CustomerService);
|
||
}
|
||
|
||
if (input.AgentCode != null)
|
||
{
|
||
await AgentBinder.ApplyAgentBindingAsync(entity, input.AgentCode, AgentRepository);
|
||
}
|
||
|
||
if (input.CustomerGroup.HasValue)
|
||
{
|
||
entity.CustomerGroup = input.CustomerGroup;
|
||
}
|
||
|
||
if (input.MemberLevelId.HasValue)
|
||
{
|
||
entity.MemberLevelId = await ResolveMemberLevelIdForSaveAsync(input.MemberLevelId);
|
||
}
|
||
|
||
if (input.PriceTierKey != null)
|
||
{
|
||
entity.PriceTierKey = MarketingCustomerHelper.NormalizePriceTierKeyOrDefault(input.PriceTierKey);
|
||
}
|
||
|
||
await CustomerRepository.UpdateAsync(entity);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 删除客户。
|
||
/// </summary>
|
||
/// <param name="input">入参:目标主键。</param>
|
||
/// <returns>无返回体,仅完成删除客户。</returns>
|
||
[HttpPost("/customer/delete")]
|
||
public async Task DeleteAsync(IdInput input)
|
||
{
|
||
var entity = await GetCustomerOrThrowAsync(input.Id);
|
||
|
||
if (!entity.ParentCustomerId.HasValue
|
||
&& await CustomerRepository.AnyAsync(x => x.ParentCustomerId == entity.Id))
|
||
{
|
||
throw new BusinessException("存在子客户,请先删除子客户");
|
||
}
|
||
|
||
await CustomerRepository.DeleteAsync(entity);
|
||
await DeleteLinkedRbacUserAsync(entity);
|
||
}
|
||
|
||
private async Task DeleteLinkedRbacUserAsync(Customer entity)
|
||
{
|
||
var userGuid = await GetRbacUserGuidByMapIdAsync(entity.UserId);
|
||
if (userGuid.HasValue && userGuid.Value != Guid.Empty)
|
||
{
|
||
await UserService.DeleteAsync(userGuid.Value);
|
||
}
|
||
|
||
if (entity.ParentCustomerId.HasValue)
|
||
{
|
||
await TryDeleteSubCustomerRoleAsync(entity.UserId);
|
||
}
|
||
}
|
||
|
||
private async Task<Guid?> GetRbacUserGuidByMapIdAsync(int mapId)
|
||
{
|
||
var sql = $"select `Id` as `Value` from `{FakeTables.User}` where `userid` = {{0}} and `IsDeleted` = 0";
|
||
return await RbacDbContext.Database.SqlQueryRaw<Guid?>(sql, mapId)
|
||
.FirstOrDefaultAsync();
|
||
}
|
||
|
||
private async Task TryDeleteSubCustomerRoleAsync(int userId)
|
||
{
|
||
try
|
||
{
|
||
var roles = await RoleService.GetAllRolesAsync();
|
||
var role = roles.FirstOrDefault(x =>
|
||
string.Equals(x.Code, $"CustomerSub_{userId}", StringComparison.OrdinalIgnoreCase));
|
||
if (role != null)
|
||
{
|
||
await RoleService.DeleteAsync(role.Id);
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// 子客户角色清理失败不阻断客户删除
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 重置客户密码。
|
||
/// </summary>
|
||
/// <param name="input">入参:客户密码重置参数。</param>
|
||
/// <returns>无返回体,仅完成重置客户密码。</returns>
|
||
[HttpPost("/customer/reset-password")]
|
||
public async Task ResetPasswordAsync(ResetCustomerPasswordInput input)
|
||
{
|
||
var entity = await GetCustomerOrThrowAsync(input.Id);
|
||
entity.Password = OmsPasswordHelper.ToMd5(input.NewPassword);
|
||
await CustomerRepository.UpdateAsync(entity);
|
||
}
|
||
|
||
private Expression<Func<Customer, bool>> BuildPaginatedPredicate(CustomerPaginatedInput input)
|
||
{
|
||
var keyword = input.Keyword?.Trim();
|
||
var salesman = input.Salesman?.Trim();
|
||
var customerService = input.CustomerService?.Trim();
|
||
var hasStatusFilter = input.Status.HasValue;
|
||
var statusValue = input.Status.GetValueOrDefault();
|
||
var hasCustomerGroupFilter = input.CustomerGroup.HasValue;
|
||
var customerGroupValue = input.CustomerGroup.GetValueOrDefault();
|
||
var hasAgentFilter = input.AgentId is > 0;
|
||
var agentId = input.AgentId.GetValueOrDefault();
|
||
|
||
return customer =>
|
||
(string.IsNullOrWhiteSpace(keyword) ||
|
||
customer.CustomerCode.Contains(keyword) ||
|
||
customer.CustomerName.Contains(keyword) ||
|
||
(customer.Phone ?? string.Empty).Contains(keyword) ||
|
||
(customer.Email ?? string.Empty).Contains(keyword)) &&
|
||
(!hasStatusFilter || customer.Status == statusValue) &&
|
||
(!hasCustomerGroupFilter || customer.CustomerGroup == customerGroupValue) &&
|
||
(string.IsNullOrWhiteSpace(salesman) || customer.Salesman == salesman) &&
|
||
(string.IsNullOrWhiteSpace(customerService) || customer.CustomerService == customerService) &&
|
||
(!hasAgentFilter || customer.AgentId == agentId);
|
||
}
|
||
|
||
private async Task<Dictionary<int, (string Code, string Name)>> ResolveAgentDisplayMapAsync(
|
||
IEnumerable<int> agentIds)
|
||
{
|
||
var ids = agentIds.Distinct().ToList();
|
||
if (ids.Count == 0)
|
||
{
|
||
return new Dictionary<int, (string Code, string Name)>();
|
||
}
|
||
|
||
var agents = await AgentRepository.GetListAsync(x => ids.Contains(x.Id));
|
||
return agents.ToDictionary(x => x.Id, x => (x.AgentCode, x.AgentName));
|
||
}
|
||
|
||
private static void FillAgentDisplay(
|
||
CustomerPaginatedOutput output,
|
||
int? agentId,
|
||
IReadOnlyDictionary<int, (string Code, string Name)> agentMap)
|
||
{
|
||
output.AgentId = agentId;
|
||
if (agentId.HasValue && agentMap.TryGetValue(agentId.Value, out var agent))
|
||
{
|
||
output.AgentCode = agent.Code;
|
||
output.AgentName = agent.Name;
|
||
}
|
||
}
|
||
|
||
private static void FillAgentDisplay(
|
||
CustomerOutput output,
|
||
int? agentId,
|
||
IReadOnlyDictionary<int, (string Code, string Name)> agentMap)
|
||
{
|
||
output.AgentId = agentId;
|
||
if (agentId.HasValue && agentMap.TryGetValue(agentId.Value, out var agent))
|
||
{
|
||
output.AgentCode = agent.Code;
|
||
output.AgentName = agent.Name;
|
||
}
|
||
}
|
||
|
||
private static Dictionary<string, bool> BuildCustomerSortDictionary(CustomerPaginatedInput input)
|
||
{
|
||
var field = input.SortField?.Trim();
|
||
var asc = string.Equals(input.SortOrder, "asc", StringComparison.OrdinalIgnoreCase);
|
||
// Dictionary value: true=asc, false=desc(与仓库约定一致)
|
||
if (string.Equals(field, "createTime", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return new Dictionary<string, bool> { [nameof(Customer.CreateTime)] = asc, [nameof(Customer.Id)] = false };
|
||
}
|
||
|
||
if (string.Equals(field, "customerCode", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return new Dictionary<string, bool> { [nameof(Customer.CustomerCode)] = asc, [nameof(Customer.Id)] = false };
|
||
}
|
||
|
||
if (string.Equals(field, "customerName", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return new Dictionary<string, bool> { [nameof(Customer.CustomerName)] = asc, [nameof(Customer.Id)] = false };
|
||
}
|
||
|
||
if (string.Equals(field, "balance", StringComparison.OrdinalIgnoreCase)
|
||
|| string.Equals(field, "accountBalance", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return new Dictionary<string, bool> { [nameof(Customer.Balance)] = asc, [nameof(Customer.Id)] = false };
|
||
}
|
||
|
||
if (string.Equals(field, "updateTime", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return new Dictionary<string, bool> { [nameof(Customer.UpdateTime)] = asc, [nameof(Customer.Id)] = false };
|
||
}
|
||
|
||
return new Dictionary<string, bool> { [nameof(Customer.UpdateTime)] = false, [nameof(Customer.Id)] = false };
|
||
}
|
||
|
||
private async Task<Customer> GetCustomerOrThrowAsync(int id)
|
||
{
|
||
var entity = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == id);
|
||
if (entity == null)
|
||
{
|
||
throw new BusinessException("客户不存在");
|
||
}
|
||
|
||
return entity;
|
||
}
|
||
|
||
private async Task<int> GetRbacUserMapIdOrThrowAsync(Guid id)
|
||
{
|
||
var sql = $"select `userid` as `Value` from `{FakeTables.User}` where `Id` = {{0}}";
|
||
var userId = await RbacDbContext.Database.SqlQueryRaw<int?>(sql, id)
|
||
.FirstOrDefaultAsync();
|
||
if (!userId.HasValue || userId.Value <= 0)
|
||
{
|
||
throw new BusinessException("用户编号创建失败");
|
||
}
|
||
|
||
return userId.Value;
|
||
}
|
||
|
||
private async Task<Guid> GetCustomerRoleIdOrThrowAsync()
|
||
{
|
||
var roles = await RoleService.GetAllRolesAsync();
|
||
var role = roles.FirstOrDefault(x => string.Equals(x.Code, CustomerRoleCode, StringComparison.OrdinalIgnoreCase));
|
||
if (role == null)
|
||
{
|
||
throw new BusinessException("客户角色未初始化");
|
||
}
|
||
|
||
return role.Id;
|
||
}
|
||
|
||
private async Task<Customer> GetCurrentCustomerOrThrowAsync(CancellationToken cancellationToken = default)
|
||
{
|
||
var userId = CurrentUser.GetId<int>();
|
||
if (userId <= 0)
|
||
{
|
||
throw new BusinessException("客户未登录");
|
||
}
|
||
|
||
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.UserId == userId, cancellationToken);
|
||
if (customer == null)
|
||
{
|
||
throw new BusinessException("客户不存在");
|
||
}
|
||
|
||
return customer;
|
||
}
|
||
|
||
private async Task<Customer> GetCurrentMainCustomerOrThrowAsync()
|
||
{
|
||
var userId = CurrentUser.GetId<int>();
|
||
if (userId <= 0)
|
||
{
|
||
throw new BusinessException("客户未登录");
|
||
}
|
||
|
||
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.UserId == userId && x.ParentCustomerId == null);
|
||
if (customer == null)
|
||
{
|
||
throw new BusinessException("仅主客户可以管理子客户");
|
||
}
|
||
|
||
return customer;
|
||
}
|
||
|
||
private async Task EnsureCustomerIdentityUniqueAsync(string customerCode, string phone, string email)
|
||
{
|
||
if (await CustomerRepository.GetByCustomerCodeAsync(customerCode) != null)
|
||
{
|
||
throw new BusinessException("客户编号已存在");
|
||
}
|
||
|
||
if (await CustomerRepository.GetByPhoneAsync(phone) != null)
|
||
{
|
||
throw new BusinessException("手机号已存在");
|
||
}
|
||
|
||
if (await CustomerRepository.GetByEmailAsync(email) != null)
|
||
{
|
||
throw new BusinessException("邮箱已存在");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 客户创建会新建 RBAC 用户,不适合直接恢复软删行;释放软删占用的唯一键即可。
|
||
/// </summary>
|
||
private async Task FreeSoftDeletedCustomerIdentityIfNeededAsync(
|
||
string customerCode,
|
||
string phone,
|
||
string? email,
|
||
int? excludeId = null)
|
||
{
|
||
var byCode = await CustomerRepository.GetByCustomerCodeIncludingDeletedAsync(customerCode);
|
||
if (byCode != null && byCode.IsDeleted && byCode.Id != excludeId)
|
||
{
|
||
byCode.CustomerCode = $"{customerCode}__deleted_{byCode.Id}";
|
||
await CustomerRepository.UpdateAsync(byCode);
|
||
}
|
||
|
||
var byPhone = await CustomerRepository.GetByPhoneIncludingDeletedAsync(phone);
|
||
if (byPhone != null && byPhone.IsDeleted && byPhone.Id != excludeId)
|
||
{
|
||
byPhone.Phone = $"{phone}__deleted_{byPhone.Id}";
|
||
await CustomerRepository.UpdateAsync(byPhone);
|
||
}
|
||
|
||
if (string.IsNullOrWhiteSpace(email))
|
||
{
|
||
return;
|
||
}
|
||
|
||
var byEmail = await CustomerRepository.GetByEmailIncludingDeletedAsync(email);
|
||
if (byEmail != null && byEmail.IsDeleted && byEmail.Id != excludeId)
|
||
{
|
||
byEmail.Email = $"{email}__deleted_{byEmail.Id}";
|
||
await CustomerRepository.UpdateAsync(byEmail);
|
||
}
|
||
}
|
||
|
||
private async Task EnsureCustomerIdentityUniqueForUpdateAsync(int id, string customerCode, string phone, string? email)
|
||
{
|
||
await FreeSoftDeletedCustomerIdentityIfNeededAsync(customerCode, phone, email, excludeId: id);
|
||
|
||
var duplicatedCode = await CustomerRepository.GetByCustomerCodeAsync(customerCode);
|
||
if (duplicatedCode != null && duplicatedCode.Id != id)
|
||
{
|
||
throw new BusinessException("客户编号已存在");
|
||
}
|
||
|
||
var duplicatedPhone = await CustomerRepository.GetByPhoneAsync(phone);
|
||
if (duplicatedPhone != null && duplicatedPhone.Id != id)
|
||
{
|
||
throw new BusinessException("手机号已存在");
|
||
}
|
||
|
||
if (email == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var duplicatedEmail = await CustomerRepository.GetByEmailAsync(email);
|
||
if (duplicatedEmail != null && duplicatedEmail.Id != id)
|
||
{
|
||
throw new BusinessException("邮箱已存在");
|
||
}
|
||
}
|
||
|
||
private async Task<UserDto> CreateRbacUserAsync(CreateCustomerInput input, NormalizedCustomerInput normalized)
|
||
{
|
||
return await UserService.CreateAsync(new UserCreateDto
|
||
{
|
||
Name = normalized.CustomerName,
|
||
Account = normalized.Phone,
|
||
Password = input.Password,
|
||
Email = normalized.Email,
|
||
Avatar = NormalizeOptionalText(input.WechatAvatar),
|
||
RoleIds = []
|
||
});
|
||
}
|
||
|
||
private static Customer BuildCustomerEntity(
|
||
CreateCustomerInput input,
|
||
NormalizedCustomerInput normalized,
|
||
int? parentCustomerId,
|
||
int userId)
|
||
{
|
||
return new Customer
|
||
{
|
||
ParentCustomerId = parentCustomerId,
|
||
UserId = userId,
|
||
CustomerCode = normalized.CustomerCode,
|
||
CustomerName = normalized.CustomerName,
|
||
WechatAvatar = NormalizeOptionalText(input.WechatAvatar),
|
||
WechatNickname = NormalizeOptionalText(input.WechatNickname),
|
||
Phone = normalized.Phone,
|
||
Email = normalized.Email,
|
||
Password = OmsPasswordHelper.ToMd5(input.Password),
|
||
Balance = input.Balance,
|
||
TotalQuota = input.TotalQuota,
|
||
UsedQuota = input.UsedQuota,
|
||
Status = input.Status,
|
||
Remark = NormalizeOptionalText(input.Remark),
|
||
Salesman = NormalizeOptionalText(input.Salesman),
|
||
CustomerService = NormalizeOptionalText(input.CustomerService),
|
||
CustomerGroup = input.CustomerGroup,
|
||
MemberLevelId = input.MemberLevelId,
|
||
PriceTierKey = input.PriceTierKey
|
||
};
|
||
}
|
||
|
||
private async Task<Dictionary<int, string>> ResolveMemberLevelNameMapAsync(IEnumerable<int> memberLevelIds)
|
||
{
|
||
var ids = memberLevelIds.Distinct().ToList();
|
||
if (ids.Count == 0)
|
||
{
|
||
return new Dictionary<int, string>();
|
||
}
|
||
|
||
var levels = await MemberLevelRepository.GetListAsync(x => ids.Contains(x.Id));
|
||
return levels.ToDictionary(x => x.Id, x => x.LevelName);
|
||
}
|
||
|
||
/// <summary>创建时:未指定则默认「普通会员」等级。</summary>
|
||
private async Task<int?> ResolveMemberLevelIdForCreateAsync(int? memberLevelId)
|
||
{
|
||
if (memberLevelId.HasValue)
|
||
{
|
||
return await ResolveMemberLevelIdForSaveAsync(memberLevelId);
|
||
}
|
||
|
||
return await MarketingCustomerHelper.ResolveDefaultMemberLevelIdAsync(MemberLevelRepository);
|
||
}
|
||
|
||
private async Task<int?> ResolveMemberLevelIdForSaveAsync(int? memberLevelId)
|
||
{
|
||
if (!memberLevelId.HasValue)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
var level = await MemberLevelRepository.FirstOrDefaultAsync(x => x.Id == memberLevelId.Value);
|
||
if (level == null)
|
||
{
|
||
throw new BusinessException("会员等级不存在");
|
||
}
|
||
|
||
if (level.Status != EnableStatus.Enabled)
|
||
{
|
||
throw new BusinessException("会员等级已禁用,无法分配");
|
||
}
|
||
|
||
return level.Id;
|
||
}
|
||
|
||
private async Task<NormalizedCustomerInput> NormalizeCreateCustomerInputAsync(
|
||
CreateCustomerInput input,
|
||
string codeFieldName,
|
||
string nameFieldName)
|
||
{
|
||
var customerCode = string.IsNullOrWhiteSpace(input.CustomerCode)
|
||
? await CustomerCodeGenerator.GenerateNextAsync(CustomerRepository)
|
||
: NormalizeRequiredText(input.CustomerCode, codeFieldName);
|
||
|
||
return new NormalizedCustomerInput(
|
||
customerCode,
|
||
NormalizeRequiredText(input.CustomerName, nameFieldName),
|
||
NormalizeRequiredText(input.Phone, "手机号"),
|
||
NormalizeRequiredText(input.Email, "邮箱"));
|
||
}
|
||
|
||
private async Task<List<string>> NormalizeAndValidateSubCustomerPermissionsAsync(IEnumerable<string>? permissions)
|
||
{
|
||
var requestedPermissions = (permissions ?? [])
|
||
.Select(NormalizeOptionalText)
|
||
.Where(x => x != null)
|
||
.Select(x => x!)
|
||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||
.ToList();
|
||
|
||
var currentUserId = CurrentUser.Id ?? throw new BusinessException("客户未登录");
|
||
var currentPermissions = await UserService.GetUserPermissionsAsync(currentUserId);
|
||
var currentPermissionSet = currentPermissions.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||
if (requestedPermissions.Any(x => !currentPermissionSet.Contains(x)))
|
||
{
|
||
throw new BusinessException("子客户权限不能超出当前客户权限");
|
||
}
|
||
|
||
return requestedPermissions;
|
||
}
|
||
|
||
private async Task<Guid> CreateSubCustomerRoleAsync(int userId, string customerName, List<string> permissions)
|
||
{
|
||
var role = await RoleService.CreateAsync(new RoleCreateDto
|
||
{
|
||
Code = $"CustomerSub_{userId}",
|
||
Name = $"子客户-{customerName}",
|
||
Permissions = permissions
|
||
});
|
||
|
||
return role.Id;
|
||
}
|
||
|
||
private async Task TryRollbackRbacUserAsync(Guid userId)
|
||
{
|
||
try
|
||
{
|
||
await UserService.DeleteAsync(userId);
|
||
}
|
||
catch
|
||
{
|
||
}
|
||
}
|
||
|
||
private async Task TryRollbackRbacRoleAsync(Guid roleId)
|
||
{
|
||
try
|
||
{
|
||
await RoleService.DeleteAsync(roleId);
|
||
}
|
||
catch
|
||
{
|
||
}
|
||
}
|
||
|
||
private static void ValidateCustomerStatus(Customer customer)
|
||
{
|
||
if (customer.Status == CustomerStatus.Disabled)
|
||
{
|
||
throw new BusinessException("客户已禁用");
|
||
}
|
||
|
||
if (customer.Status == CustomerStatus.Frozen)
|
||
{
|
||
throw new BusinessException("客户已冻结");
|
||
}
|
||
}
|
||
|
||
private static string NormalizeRequiredText(string? value, string fieldName)
|
||
{
|
||
var normalized = value?.Trim();
|
||
if (string.IsNullOrWhiteSpace(normalized))
|
||
{
|
||
throw new BusinessException($"{fieldName}不能为空");
|
||
}
|
||
|
||
return normalized;
|
||
}
|
||
|
||
private static string? NormalizeOptionalText(string? value)
|
||
{
|
||
var normalized = value?.Trim();
|
||
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
|
||
}
|
||
|
||
private sealed record NormalizedCustomerInput(
|
||
string CustomerCode,
|
||
string CustomerName,
|
||
string Phone,
|
||
string Email);
|
||
}
|